Windows 下实现 4ms 虚拟 PLC 周期调度:从 sleep_for 到高精度定时器
Windows 用户态线程无法保证实时唤醒,直接使用 std::this_thread::sleep_for 承担 4ms PLC 周期调度会出现周期丢失与抖动。本文记录 ZeroOne Virtual Motion PLC 从 sleep_for 到 Windows Waitable Timer + 500us 高精度等待的调度方案演进,包含 steady_clock 单调时钟、Scheduler 与平台 Timer 解耦的架构设计,以及 4ms 周期实测数据(平均 3999.75us、最大抖动 204us)。
Windows 下实现 4ms 虚拟 PLC 周期调度:从 sleep_for 到高精度定时器
一、项目背景
在开发 ZeroOne Virtual Motion PLC 的过程中,我们首先需要解决一个非常基础、但又非常关键的问题:
Windows 普通应用程序能不能稳定运行 4ms PLC 周期?
对于传统 PLC,4ms、2ms 甚至更短周期通常由实时操作系统、专用控制器或者硬件定时机制保证。而 Virtual PLC 不同,我们的开发环境首先是:
Windows
↓
C++20
↓
Qt / MinGW
↓
Virtual PLC
↓
4ms PLC Scheduler
Virtual PLC 本质上是运行在 Windows 用户态环境中的普通应用程序。因此,第一个问题并不是 PLC 算法,而是:
Windows 普通线程到底能不能可靠地"每 4ms 执行一次"?
二、技术方案:最初的 Scheduler 实现
第一版 Scheduler 使用的是:
std::this_thread::sleep_for();
基本思路非常简单:
任务周期
↓
计算下一次执行时间
↓
sleep_for()
↓
执行 PLC Task
↓
计算下一周期
例如:
std::this_thread::sleep_for(
std::chrono::microseconds(3950)
);
看起来没有问题。但是实际测试以后发现,情况并没有这么简单。
2.1 4ms 为什么没有想象中那么简单?
理论上:
1000ms / 4ms = 250
因此运行 1 秒,理论周期次数约为 250 次。但实际测试过程中出现过:
4ms task cycles = 10
后来修改 Scheduler 后又出现:
4ms task cycles = 13
这并不意味着 4ms PLC 算法本身存在问题。真正的问题在于:Windows 用户态线程的睡眠和唤醒时间并不是实时保证的。
std::this_thread::sleep_for() 的含义更接近"至少等待这么长时间之后,线程才具备重新运行的条件",而不是"精确经过这么长时间后立即执行"。
2.2 Windows 下的两个时间概念
这里需要区分"时间经过了"和"线程什么时候真正获得 CPU"两件事。
例如:
T0 = 1000.000 ms
等待 4ms,理论目标是:
T1 = 1004.000 ms
但实际情况可能变成:
1000.000 ms
↓
sleep
↓
1004.000 ms
↓
线程仍然没有立即获得 CPU
↓
1004.2 ms
↓
线程真正开始执行
于是实际周期可能变成 4.2ms,甚至更长。所以:
sleep_for(4ms) ≠ 4.000ms 后执行
2.3 为什么普通 sleep 不适合直接承担 PLC 调度
PLC Scheduler 最关注的是周期、抖动、超时、执行时间和周期稳定性。例如目标 4ms,实际可能是:
3.95ms
4.02ms
4.01ms
4.20ms
3.88ms
4.05ms
虽然平均值可能接近 4ms,但是对于 Motion Control 来说,周期抖动本身就是一个需要关注的工程指标。尤其当 PLC 后面继续加入 PLC ST、Motion、Axis、IO、EtherCAT、Modbus TCP、ROS2、LinuxCNC 以后,Scheduler 就不能简单地依赖普通 sleep_for()。
三、系统架构:Scheduler 与平台 Timer 分离
ZeroOne Virtual Motion PLC 最终采用了一个比较明确的架构:
Scheduler
│
▼
WindowsHighResTimer
│
┌────────┴────────┐
│ │
Waitable Timer 精确等待
│ │
└────────┬────────┘
▼
PLC Task
也就是说,Scheduler 不直接负责 Windows 定时器细节。Scheduler 只负责任务、周期、执行、统计、超时;平台层负责 Windows Timer。这样未来迁移 Linux 时,可以变成:
Windows:
Scheduler
↓
WindowsHighResTimer
Linux:
Scheduler
↓
LinuxHighResTimer
核心调度逻辑不需要重写。最终形成的目录结构是:
Core
├── Scheduler
├── Runtime
├── VariableManager
├── IOManager
└── Motion
Platform
├── Windows
│ └── WindowsHighResTimer
└── Linux
└── LinuxHighResTimer
这也是 Virtual Motion PLC 后续跨平台的重要基础。
3.1 Windows Waitable Timer
Windows 提供了 Waitable Timer 机制,核心 API 包括:
CreateWaitableTimerW():创建定时器SetWaitableTimer():设置等待时间WaitForSingleObject():等待定时器触发
基本流程:
CreateWaitableTimer
↓
SetWaitableTimer
↓
WaitForSingleObject
↓
Timer Signal
↓
继续执行
相比简单使用 sleep_for(),这种方案可以更明确地控制 Windows 等待机制。
四、实施过程:为什么没有完全依赖 Waitable Timer
这是整个设计中比较重要的一点。我们没有认为"Windows Waitable Timer = 实时系统",这是错误的。Windows 仍然不是硬实时操作系统。因此我们的设计采用"粗粒度等待 + 精确等待"的两级策略:
粗粒度等待
↓
Windows Waitable Timer
↓
剩余约 500us
↓
精确等待
↓
执行任务
也就是:长时间使用 Timer,短时间进行精确等待。这样可以避免整个 4ms 周期一直忙等。
4.1 500us 精确等待窗口
例如目标时间 T = 4.000ms,假设当前距离目标还有 2ms,这时候没有必要忙等,可以使用 Waitable Timer 等待大部分时间:
2ms
↓
Waitable Timer
↓
剩余 500us
↓
进入最后 500us 后高精度时间检查
代码核心思想:
while (Clock::now() < target)
{
std::this_thread::yield();
}
这里使用 std::chrono::steady_clock,而不是系统墙上时钟。
4.2 为什么使用 steady_clock?
PLC 周期测量需要的是单调递增的时间,例如 1000、1001、1002、1003。不能因为用户修改 Windows 系统时间(10:00 → 09:00)而导致 Scheduler 的时间轴倒退。所以:
using Clock = std::chrono::steady_clock;
非常适合周期调度、执行时间测量、超时判断和抖动统计。
4.3 第一次高精度 Timer 实测
完成 WindowsHighResTimer 后,我们没有直接把它接入 Scheduler,而是先做独立平台测试。测试条件:
系统:Windows
周期:4ms
测试时间:1000ms
理论周期:250
实际结果:
Target period : 4000 us
Test duration : 1000 ms
Cycles : 249
Min period : 3848 us
Max period : 4204 us
Average : 3999.75 us
Max jitter : 204 us
结果判定:
[PASS] 4ms cycle execution
[PASS] Period measurement
[PASS] Timer shutdown
4.4 如何理解这个测试结果?
最值得关注的不是 249 这个次数,而是:
Average = 3999.75 us
目标 4000us,实际 3999.75us,两者非常接近。同时 Min = 3848us、Max = 4204us,最大偏差约 204us。这说明:在当前测试环境下,Windows 用户态高精度定时机制已经能够为 Virtual PLC 的 4ms 调度提供一个相对可靠的基础。
但这不能解释为"Windows 已经具备硬实时能力"——这是两个完全不同的概念。
五、应用价值:Virtual PLC 的正确定位
5.1 Virtual PLC 和真正实时 PLC 的区别
这一点在工程产品设计中非常重要。我们的目标是 Virtual PLC,而不是 Hard Real-Time PLC。
Windows Virtual PLC 更适合:
- PLC 程序开发
- ST 逻辑调试
- Motion 算法验证
- IO 逻辑验证
- Modbus TCP 测试
- 上位机联调
- ROS2 联调
- 设备仿真
- 软件测试
而对于硬实时 Motion Control、EtherCAT DC、极低周期控制、严格确定性控制,最终仍然应该使用 Linux PREEMPT_RT、专用实时控制器、MCU 或实时工业计算平台。
5.2 因此 Virtual PLC 的正确定位
ZeroOne Virtual Motion PLC 的定位不是用 Windows 取代真正的实时运动控制器,而是:让同一套 PLC / Motion Runtime 可以先在 Windows 环境完成开发、测试和验证,再迁移到 Linux 或实际控制硬件。
整体架构:
PLC / Motion Runtime
│
┌─────────┴─────────┐
│ │
Windows 平台 Linux 平台
│ │
WindowsHighResTimer LinuxHighResTimer
│ │
Virtual PLC Real Controller
这才是我们希望实现的平台抽象。
5.3 Scheduler 不应该和 Windows API 耦合
不推荐的做法:
// Scheduler.cpp
#include <windows.h>
CreateWaitableTimer();
SetWaitableTimer();
WaitForSingleObject();
因为这样以后迁移 Linux 会非常麻烦。更合理的是通过抽象时间接口分层:
Scheduler
│
│ 抽象时间接口
▼
HighResTimer
│
├── WindowsHighResTimer
│
└── LinuxHighResTimer
5.4 4ms 并不是越快越好
还有一个容易产生误区的问题:PLC 周期是不是越短越好?并不是。
- 4ms 意味着 250 Hz
- 2ms 意味着 500 Hz
周期越短,对 CPU、Scheduler、Timer、任务执行时间、系统抖动和实时性的要求越高。因此 PLC Scheduler 应该支持 10ms / 5ms / 4ms / 2ms / 1ms,但具体周期需要结合任务数量、任务执行时间、Motion 算法、IO 数量、通信任务、CPU 性能和操作系统实时性综合确定。
六、最终形成的工程原则
经过这次 Windows 4ms 调度问题,我们确定了几个原则。
原则一:不要直接把 sleep_for 当作实时定时器。 sleep_for 适合普通后台任务,不应该直接承担 Motion PLC 的核心周期调度。
原则二:Windows 可以做 Virtual PLC,但不能假装成硬实时系统。 应该明确:Windows Virtual PLC ≠ Hard Real-Time PLC。
原则三:Scheduler 与平台 Timer 分离。 应该是 Scheduler → Platform Timer,而不是 Scheduler → Windows API。
原则四:先测 Timer,再测 Scheduler。 开发过程中应该按 WindowsHighResTimerTest → SchedulerTest → RuntimeTest 的顺序逐级确认,而不是所有东西一起调试。
七、下一阶段
目前 ZeroOne Virtual Motion PLC 已经完成:
VariableManager
↓
IOManager
↓
Scheduler
↓
WindowsHighResTimer
其中 Windows 高精度定时器已经完成独立测试。下一步就是:
Scheduler
↓
WindowsHighResTimer
↓
4ms PLC Task
进行真正的集成测试。最终测试目标不是简单看 cycles = 250,而是进一步统计周期平均值、最小周期、最大周期、平均抖动、最大抖动、任务执行时间、最大执行时间、Overrun 与 CPU 占用率。这样才能真正评价一个 Virtual PLC Scheduler 的工程质量。
八、总结
Windows 并不是一个硬实时操作系统,但这并不意味着 Windows 不能用于 Virtual PLC,关键在于明确产品定位和技术边界。
对于 Virtual Motion PLC:
Windows
+ 高精度 Timer
+ 单调时钟
+ 绝对时间调度
+ Scheduler 统计
+ 平台抽象
可以构建一个比较可靠的虚拟 PLC 运行环境。而对于最终的实时运动控制产品,则可以进一步迁移到:
Linux
+ PREEMPT_RT
+ 实时调度
+ 专用硬件
这样就形成:
Windows 开发
↓
Virtual PLC 验证
↓
Linux Runtime
↓
实际 Motion Controller
同一套 PLC / Motion Runtime,多平台运行,是 ZeroOne Virtual Motion PLC 当前架构设计的核心目标之一。
附:WindowsHighResTimer 实测输出
========================================
ZeroOne Windows HighResTimer Test
========================================
[PASS] Timer initialize
[PASS] Timer initialized state
Target period : 4000 us
Test duration : 1000 ms
Cycles : 249
Min period : 3848 us
Max period : 4204 us
Average : 3999.75 us
Max jitter : 204 us
[PASS] 4ms cycle execution
[PASS] Period measurement
[PASS] Timer shutdown
----------------------------------------
WindowsHighResTimer: TEST PASSED
========================================
九、SEO关键词
虚拟PLC、Windows高精度定时器、4ms周期调度、Waitable Timer、PLC Scheduler、Virtual PLC、运动控制、Motion Control、实时系统、steady_clock、C++20、周期抖动、Windows实时性、PREEMPT_RT
推荐阅读
更多工业 AI、智能制造与技术实践文章
Conda环境下测试Intel NPU:Python版本如何选择?
在 Intel Core Ultra + OpenVINO + YOLO 工业视觉项目中,Conda 环境测试 Intel NPU 时 Python 版本如何选择?本文结合实际开发经验,推荐 Python 3.10 作为 NPU 测试环境,并给出从环境搭建、设备识别到 NPU 模型编译、性能测试的分层验证流程。
Linux系统篇(二十二)——文件(六):目标文件与 ELF 深度解析:从编译到加载的全景揭秘
从目标文件出发,逐层解剖ELF头部、节头表与程序头表,讲透静态链接重定位、动态链接GOT/PLT以及程序从编译到加载的完整过程。
Linux系统篇(五)——工具篇(一):搞懂 Linux 包管理,玩转 Vim 编辑器
从软件安装底层逻辑讲透 yum/apt 包管理器工作原理,再深入 Vim 多模式操作与高效快捷键,为 Linux 开发打下坚实基础。
IMX6ULL 裸机开发学习(一):汇编点亮 LED
基于野火 IMX6ULL mini 开发板,用 GNU ARM 汇编完成 LED 闪烁实验:CCM 时钟使能、IOMUX 复用配置、GPIO 方向与数据寄存器操作,配套 Makefile 与 imxdownload 烧写流程。
Linux系统篇(七)——工具篇(二):一篇搞懂 C 语言程序从代码到可执行文件的完整旅程
拆解 C 程序预处理、编译、汇编、链接四步变身流程,讲透条件编译与静态、动态链接差异,附分步实操与报错排查。
小龙虾 Skill 技能编写入门:从 Markdown 到工业智能体工具调用
本文从最简单的 SKILL.md 讲起,系统介绍工业智能体“小龙虾”的 Skill 技能编写方法:功能说明、触发条件、执行流程、工具调用、权限分级,并结合 MES、MQTT、RAG、AGV 等工业场景,说明如何让 AI 从“聊天机器人”进化为能查询实时数据、执行任务的工业智能体。
