
1. 项目概述为什么国产PLC的运行时系统是块硬骨头干了十几年工业自动化从最早用进口PLC搭第一条产线到后来参与国产化替代项目我最大的感受就是硬件好抄软件难仿而PLC的“灵魂”——运行时系统Runtime System更是难中之难。用户看到的可能只是一个梯形图编程界面或者一个下载按钮但背后支撑这一切稳定运行的是一个7x24小时不间断、毫秒级响应、还要能应对各种突发状况的复杂软件内核。这次要聊的“国产PLC运行时系统软件设计”核心就是解决三个工业现场最头疼的问题高精度调度、增量更新和热备冗余。简单来说这就像给一个工厂的“大脑”做手术。高精度调度是确保这个大脑的每一次“思考”执行逻辑运算都准时准点不早不晚增量更新是让这个大脑能在不停机的情况下只更新有问题的“知识”程序而不是全盘重启热备冗余则是给大脑准备一个随时能接班的“副脑”主脑一宕机副脑立刻顶上生产过程零中断。这三个功能是高端PLC尤其是流程行业、电力、轨道交通这些对连续性和可靠性要求极高的领域必须跨过去的门槛。市面上成熟的方案基本被西门子、罗克韦尔等巨头垄断其技术细节是黑箱。国产化这条路就得我们自己从原理到实现一步步啃下来。2. 核心需求与设计思路拆解2.1 高精度调度确定性实时性的基石PLC不是通用计算机它的核心使命是在确定的时间周期内完成确定的任务。所谓高精度调度就是要保证用户程序无论是梯形图、指令表还是结构化文本的扫描周期Scan Cycle抖动极小通常在微秒级。比如设定为10ms的周期那么每一次循环的执行时间必须稳定在10ms±50μs以内不能这次9ms下次11ms。为什么这么难因为PLC的CPU除了跑用户逻辑还要处理通信如Profinet、EtherCAT、硬件中断如高速计数器、系统自检等一堆事情。通用操作系统的“公平调度”策略在这里是灾难一个偶然的网络数据包处理就可能让用户程序执行延迟。我们的设计思路是“抢占式分区调度”时间片划分将一个扫描周期划分为多个固定长度的时间片Time Slice例如一个10ms周期划分为2ms通信任务、7ms用户程序执行、0.5ms系统后台任务、0.5ms空闲/容错。硬件定时器驱动调度器的时钟源不是操作系统时钟而是独立的硬件定时器如CPU的Timer或FPGA产生的高精度脉冲这提供了纳秒级的计时精度。最高优先级给用户程序在用户程序执行的时间片内赋予其最高的软件优先级并暂时屏蔽大部分低优先级中断。只有少数更高优先级的硬件中断如急停信号能打断它。周期补偿机制实时监测每个周期的实际执行时间。如果本次用户程序执行快了只用了6.5ms则在周期末尾主动“空转”等待直到10ms时间点再开始下一周期如果执行慢了用了7.5ms则记录本次周期超时并在下一个周期中通过动态微调非关键任务的时间片尝试“追回”周期时间避免误差累积。注意禁用中断要非常小心。我们只屏蔽诸如普通通信中断等对于安全相关的硬中断看门狗、电源故障必须始终保持使能。这需要在系统初始化时精细配置中断控制器如NVIC。2.2 增量更新生产线的“不停机升级”传统PLC更新程序需要停机、下载整个程序可能几MB、重启这对于一个每分钟产值数万的产线是无法接受的。增量更新Delta Update的目标是只将程序中变化的部分可能只有几KB下载到PLC并在运行时无缝切换业务零中断。核心挑战在于“一致性”和“原子性”一致性新老程序的数据变量值如何继承比如一个正在累加的计数器从100切换到新程序后值应该是100还是从0开始原子性切换过程必须瞬间完成不能出现部分旧逻辑、部分新逻辑混合执行的状态。我们的实现方案差异比较与生成在编程软件上位机端通过对比新旧程序的编译中间代码或符号表生成一个描述差异的“增量包”。这个包包含新增/删除/修改的逻辑块FB, FC、数据块DB的偏移量变化、以及变量初始值的映射关系。双存储区与影子切换PLC的Flash中划分两个完整的程序存储区Active区当前运行和Standby区备用。收到增量包后系统在后台将增量包与Standby区的旧程序基础版本合并生成一个完整的新程序映像写入Standby区。此时Active区仍在正常运行。状态同步与切换在下一个扫描周期开始前的特定窗口期通常是在系统任务阶段调度器执行切换操作暂停当前周期。将Active区中所有非保持性变量的当前值按新程序的变量地址映射关系快速拷贝到Standby区的对应位置。保持性变量Retentive的值本身就存储在非易失存储器中按新程序的声明重新链接即可。将程序指针Program Counter和堆栈等执行上下文重定向到Standby区。将Standby区标记为新的Active区。恢复周期执行。 这个过程通常在几百微秒内完成对于大多数控制任务来说是无感的。实操心得增量更新最怕的是“变结构”比如修改了一个FB的接口输入输出变量。我们的策略是对于接口变更的FB强制要求其所有实例在更新时进行“冷切换”即该FB的所有实例在切换后的第一个扫描周期按初始值执行。这需要在上位机做兼容性检查并提示工程师风险。2.3 热备冗余从“有备无患”到“无缝接管”热备冗余Hot Standby Redundancy的目标是消除单点故障。主CPU和备用CPU执行完全相同的程序当主CPU故障硬件失效、程序跑飞、通信中断时备用CPU能在极短时间通常100ms内接管控制输出不抖动。设计的关键在于“状态同步”和“故障裁决”硬件架构双CPU通过高速背板总线如PCIe或专用的冗余同步模块进行连接周期性地交换同步数据。此外需要冗余的电源、甚至冗余的网络接口。状态同步这是核心中的核心。每个扫描周期结束后主CPU不是立刻开始下一周期而是进入一个“同步窗口”。在这个窗口内它将本周期内所有影响下一周期逻辑判断的变量包括输出映像、内部状态变量、定时器/计数器当前值等打包通过高速通道发送给备用CPU。备用CPU接收后用这些数据覆盖自己的内部状态确保其内存镜像与主CPU完全一致。然后双方再共同进入下一个周期。故障检测与切换心跳检测主备CPU之间持续发送“心跳”信号。看门狗互检除了各自的硬件看门狗还通过同步链路互相检查对方的程序执行是否卡死。输出比较通过冗余IO模块或比较电路实时比较主备CPU的输出是否一致。如果不一致且超过阈值可能意味着某一方发生了静默故障。切换逻辑当备用CPU连续多个周期如3-5个收不到主CPU的同步数据或心跳信号或者检测到主CPU输出异常时会启动切换流程。切换包括接管总线控制权、激活自己的输出使能、向网络发送主备变更通知。无扰切换由于备用CPU的状态与主CPU高度同步接管时其输出的信号与故障前的主CPU输出理论上是一致的从而避免了执行器如阀门、电机的突然动作。踩过的坑同步数据量需要精心设计。早期我们试图同步全部数据区导致同步窗口时间过长影响了周期稳定性。后来优化为只同步“脏数据”本周期被修改过的变量和关键系统状态数据量减少了90%以上。同步协议也要带CRC校验和序列号防止数据错误或乱序导致备用机状态错乱。3. 核心模块的详细设计与实现3.1 调度器模块的实现细节调度器是整个运行时系统的节拍器。我们采用基于时间触发的Time-Triggered静态优先级调度。数据结构设计typedef struct { uint32_t period_ticks; // 任务周期以硬件定时器滴答数为单位 uint32_t phase_ticks; // 相位偏移用于错开多个任务的启动时间 uint32_t wcet_ticks; // 最坏情况执行时间预计算或测量 void (*task_entry)(void); // 任务函数指针 uint8_t priority; // 静态优先级 uint32_t last_release_time; // 上一次释放开始执行的时间戳 bool is_ready; // 就绪标志 } sched_task_t; // 调度表一个预先计算好的时间表定义了什么时间点执行哪个任务 typedef struct { uint32_t time_mark; sched_task_t* task_to_run; } schedule_table_entry_t;调度流程在一个硬件定时器中断服务程序ISR中定时器中断触发进入高优先级的调度器ISR。读取高精度硬件计时器获取当前绝对时间戳current_time。遍历schedule_table找出所有time_mark current_time且未执行的任务将其is_ready置位。根据优先级从就绪任务中选择最高优先级的任务调用其task_entry()。在任务函数执行前后分别记录last_release_time和实际执行时长用于监控和周期补偿。中断返回。关键参数计算示例 假设我们需要一个1ms的调度器时钟粒度CPU主频为200MHz。硬件定时器预分频与重载值计算Timer_Reload_Value CPU_Freq / (Desired_Granularity * Prescaler)。若预分频设为200则重载值 200,000,000 / (1000 * 200) 1000。即定时器每计数1000次产生一次中断1ms。用户程序任务周期设定若扫描周期为10ms则其period_ticks 10因为1个tick1ms。最坏情况执行时间WCET估算需要通过静态分析或大量实测确定用户程序在最复杂逻辑路径下的执行时间。例如实测为6.5ms则设置wcet_ticks 7向上取整留有余量。确保wcet_ticks period_ticks。3.2 增量更新服务模块的实现该模块运行在一个较低优先级的后台任务中负责与上位机通信、校验更新包、管理存储区。更新流程握手与元数据获取上位机通过特定服务例如自定义的TCP端口或ADS协议连接PLC的更新服务。服务端上报当前Active区的程序CRC校验和版本。上位机据此判断基础版本并发送增量包的元数据大小、目标版本、依赖关系。校验与准备PLC校验元数据确认Standby区空间足够且当前系统负载允许进行后台更新。然后进入“准备更新”状态通知上位机可以发送数据。数据传输与校验上位机分块发送增量包数据。PLC每接收一块立即计算CRC并与包内自带的校验值比对。同时将数据写入Standby区的临时缓存。合并与构建全部数据接收并校验无误后启动一个专门的“合并任务”。该任务根据增量包中的指令将Standby区的基础程序映像与增量修改部分合并生成完整的新程序映像。这个过程可能涉及地址重定位、符号解析等。就绪与切换等待合并完成后计算新映像的CRC并与上位机发送的最终校验和比对。一致后将Standby区标记为“更新就绪”并等待一个由用户程序或工程师触发的“切换指令”如一个特定的系统位被置位。执行切换收到切换指令后在下一个调度周期的同步窗口调用switch_to_standby()函数完成前述的状态同步与指针切换。数据结构和协议要点增量包格式需要包含头部魔术字、版本、旧版本CRC、新版本CRC、操作码序列如ADD_SECTION,MODIFY_DATA,RELOCATE_SYMBOL、以及对应的数据负载。变量地址映射表这是实现数据无缝迁移的关键。它是一个在新旧程序编译时生成的对照表记录了每个变量在新旧程序数据区中的偏移地址。切换时根据此表进行内存拷贝。3.3 热备冗余同步模块的实现同步模块是热备冗余的“数据高速公路”要求极高的实时性和可靠性。同步通道选择首选专用硬件链路如通过FPGA实现的并行IO或高速串行RapidIO。优点是延迟极低微秒级、确定性高、不占用CPU和系统总线资源。次选高速总线如PCIe或千兆以太网的私有协议。需要在驱动层实现高优先级、带时间戳的裸数据包传输。同步数据包设计 每个同步周期发送一个数据包包含| 包头(序列号、时间戳、CRC) | 系统状态字 | 脏数据块1地址 | 脏数据块1数据 | ... | 脏数据块N地址 | 脏数据块N数据 | 包尾(CRC) |脏数据块只同步本周期内被修改过的变量所在的连续内存区域。这需要在变量写操作时通过内存保护单元MPU或软件标记位来跟踪。序列号和时间戳用于检测丢包、乱序和计算网络延迟。主备状态机 实现一个清晰的状态机是避免“脑裂”两台都认为自己是主机的关键。typedef enum { STATE_INIT, // 初始化 STATE_STANDBY, // 备用状态监听同步数据 STATE_ACTIVE, // 主控状态发送同步数据 STATE_TAKEOVER, // 接管中 STATE_FAILURE // 故障 } redundancy_state_t;上电后通过硬件拨码或仲裁协议比较MAC地址、CPU ID等决定初始主备。备机在STATE_STANDBY下持续校验接收到的同步包。超时则触发STATE_TAKEOVER。主机在STATE_ACTIVE下持续发送同步包并监控备机心跳。发现自身故障如看门狗复位或收到备机发来的“强制切换请求”时进入STATE_FAILURE或STATE_STANDBY。4. 系统集成与调试中的挑战4.1 三大功能的协同与冲突这三个高级功能不是孤立的集成时会相互影响调度与冗余的冲突同步操作发生在周期末尾的“同步窗口”这个窗口本身是调度出来的一个高优先级任务。如果同步数据量大或网络波动窗口时间可能被拉长挤占下一个周期的用户程序时间甚至导致周期超时。解决方案为同步任务设置一个最长时间预算超时则丢弃本次同步记录错误确保周期准时开始。同时优化同步数据量。增量更新与冗余的协同当对冗余系统进行增量更新时必须确保主备机同时、同版本更新。我们的策略是更新包先发给主机主机在Standby区构建新程序并通过同步通道将更新包和构建指令转发给备机。备机在后台同步构建。待双方都就绪后由主机在某个同步周期发起一个“协同切换命令”双方在下一个完全同步的周期后同时执行切换动作。调度器在更新期间的行为在后台合并程序映像时这是一个计算密集型任务必须确保其优先级低于所有实时任务并且其执行时间被严格监控防止它“饿死”用户程序。通常将其放在最低优先级的后台任务中并采用分片执行的方式每次只合并一小部分。4.2 性能测试与稳定性验证这类系统级的软件测试必须极端严苛。调度精度测试使用高精度示波器或逻辑分析仪监控PLC的某个特定输出点在程序里每周期翻转一次。测量连续数百万个周期的脉冲宽度统计其标准差和最大抖动。目标是将抖动控制在周期长度的1%以内如10ms周期抖动100μs。增量更新压力测试频繁更新模拟在短时间内如1分钟连续进行数十次小增量更新检查系统内存、任务调度是否出现泄漏或紊乱。异常更新在下载更新包过程中随机断开网络模拟断电然后恢复。系统应能回滚到上一个稳定版本并报告更新失败。数据一致性验证在更新前后对比关键过程数据如流量累计值、PID调节器内部状态是否连续、正确。冗余切换测试暴力拔线在生产线全速运行时直接拔掉主CPU的电源或同步线缆。用高速摄像机记录输出继电器的状态观察是否有任何毛刺或跳动。切换时间应小于一个输出模块的“断线检测时间”。故障注入编写测试程序故意让主CPU的任务死循环、访问非法内存触发硬件错误、或疯狂占用总线。观察备用CPU能否正确检测并接管。网络风暴下的同步在冗余同步网络上灌入大量干扰流量测试同步协议的抗干扰能力和数据完整性。4.3 常见问题排查实录在实际部署和调试中会遇到许多文档上不会写的问题。问题一调度周期偶尔出现“尖峰”超时。现象大部分周期稳定在10ms但每隔几十分钟会出现一个长达15ms甚至20ms的周期。排查首先检查是否是用户程序逻辑导致。在超时周期内添加调试代码记录程序执行路径未发现异常复杂分支。怀疑是中断被长时间关闭。在调度器开关中断的位置打点并用逻辑分析仪抓取对应GPIO引脚的电平变化。发现超时周期内一个低优先级的中断服务程序ISR执行时间异常长。深入该ISR发现是处理一段非关键的诊断日志其中调用了sprintf函数在某个特定情况下如浮点数格式化此函数在嵌入式环境下的执行时间会急剧增加。解决禁止在实时性要求高的ISR中使用耗时不定的标准库函数如printf,sprintf。将日志改为简单的字符串拷贝或二进制记录放到低优先级后台任务中去处理。问题二增量更新后某个模拟量输入值跳变。现象更新一个与模拟量输入模块无关的逻辑块后发现一个温度采集值从80.5°C瞬间变成了0.0°C但很快下一个周期又恢复正常。排查检查增量包确认没有修改该模拟量输入通道对应的数据块DB地址或结构。检查切换时的变量映射表发现该模拟量值对应的变量地址映射正确。在切换函数switch_to_standby()中添加对关键模拟量DB的切换前后数据快照对比。发现快照显示数据拷贝正确80.5。进一步追踪发现该模拟量值在用户程序中被一个背景数据块Instance DB中的某个结构体成员引用。而增量更新修改了该背景数据块所属函数块FB的局部变量声明顺序导致结构体成员的内存偏移发生了变化。切换时只同步了绝对地址的变量但通过错误偏移访问的结构体成员在新程序第一次执行时读到了错误的内存位置。解决优化编译器和更新服务。对于包含复杂结构体的DB在增量更新时如果其父FB的接口或局部变量有变动编译器应发出警告并建议对该DB进行“冷初始化”或生成更精细的、基于符号名而非偏移量的数据迁移脚本。问题三冗余系统在切换瞬间某个高速脉冲输出出现丢失。现象主CPU故障切换至备用CPU后控制伺服电机的高速脉冲PTO输出停顿了约2ms导致电机轻微抖动。排查分析脉冲输出模块的工作原理。该模块通常有一个内部的缓冲区FIFO和位置计数器。主CPU持续向FIFO写入位置指令。检查冗余同步数据发现我们同步了CPU内部软件计算出的“目标位置”但没有同步脉冲输出模块硬件内部的当前实际位置计数器和FIFO状态。切换后备用CPU从同步得到的“目标位置”开始计算新的脉冲序列但硬件模块的实际位置已经超前因为主CPU故障前已经发送了一些脉冲。这导致备用CPU计算的起始相位与硬件实际相位不匹配需要时间重新同步期间输出紊乱。解决将关键硬件模块如高速计数器、PTO的实时状态寄存器也纳入冗余同步的数据范围。备用CPU在接管后首先读取这些硬件状态并以此为基础调整自己的控制算法输出实现真正的“无扰”接管。这要求硬件模块支持状态读取和精确的初始值设置。5. 工具链与开发环境的选择设计这样的运行时系统离不开强大的工具链支持。编译器与链接器需要定制或深度配置。它不仅要生成机器码还要生成用于增量更新的差异分析文件描述代码和数据变化、变量地址映射表、以及用于冗余同步的变量脏数据跟踪信息。我们选择了LLVM/Clang作为基础进行二次开发利用其强大的中间表示IR和模块化设计在编译流程中插入我们自定义的分析和代码生成Pass。实时操作系统RTOS或裸机裸机完全自主可控调度器、内存管理、中断管理全部自己写。优点是极致性能和确定性缺点是一切从零开始开发调试难度大。适合对成本和功耗极其敏感的专用控制器。商用RTOS如VxWorks、QNX、INTEGRITY。它们提供了经过认证的高可靠实时内核、文件系统、网络协议栈。在此基础上开发应用层如PLC运行时效率更高但授权费用昂贵且内核层面是黑盒遇到深层次问题调试困难。开源RTOS如FreeRTOS、Zephyr、µC/OS。我们在多个项目中使用过FreeRTOS其优点是免费、源码可见、生态丰富。但它的调度器优先级抢占并非为严格的时间触发设计我们需要在其基础上关闭其任务调度用我们自己的硬件定时器驱动的时间片调度器来接管FreeRTOS仅作为任务容器和通信、内存管理的基础设施。仿真与测试框架在宿主机如Linux PC上构建一个硬件在环HIL仿真环境至关重要。我们用QEMU模拟目标CPU如ARM Cortex-R将我们开发的运行时系统代码编译到该仿真环境中。然后编写Python脚本模拟各种IO信号、网络报文、故障注入并自动验证调度时序、更新流程和冗余切换逻辑。这能在硬件板卡就绪前发现大部分逻辑错误。6. 写给开发同行的几点心得最后分享几点在泥潭里摸爬滚打换来的经验这些在教科书和标准文档里很少提到关于高精度调度别迷信“纳秒级”这种宣传。在复杂的多任务环境下能达到微秒级的确定性已经非常优秀。影响抖动的最大因素往往不是调度算法本身而是内存访问延迟尤其是SDRAM的刷新和换行、缓存一致性操作、以及不可屏蔽中断NMI的处理。务必使用带紧耦合内存TCM或核心耦合存储器CCM的CPU将最关键的调度器代码和中断向量表放在这里。仔细配置缓存策略对实时任务的数据区设置为“非缓存”或“写透”模式。关于增量更新把“兼容性”作为第一原则来设计。强制约定增量更新不允许修改全局数据块DB的大小和初始值布局不允许修改函数块FB的接口输入、输出、输入输出参数。如果必须修改则将其视为不兼容更新需要整块替换并在更新协议中明确标识触发更复杂的、可能涉及短暂停机的迁移流程。一开始就定好规矩比后期处理各种奇怪的运行时错误要省心得多。关于热备冗余冗余不是为了证明系统不会坏而是为了在坏的时候用户无感。因此测试时要抱着“搞破坏”的心态。不仅要模拟主CPU死机还要模拟“半死不活”的状态——比如主CPU程序跑飞但看门狗没复位、同步链路单比特翻转、主备时钟轻微不同步。这些“灰色故障”才是最难检测和处理的。我们的做法是在同步协议中加入“合理性检查”例如备用机连续收到的主机状态字如果完全不变可能主机卡死在循环里或者变化规律严重违背工艺逻辑也应触发报警和预切换判断。开发国产PLC的运行时系统是一条充满挑战但意义非凡的路。它没有那么多炫酷的新技术更多的是对可靠性、确定性和工程细节的极致打磨。每一个微秒的优化每一行处理异常情况的代码都是为了最终能让设备在工厂里稳定运行数年如一日。当你看到自己编写的系统控制着一条庞大的生产线平稳运转那种成就感是单纯做应用开发难以比拟的。这条路很长需要耐心更需要敬畏之心。