资讯动态

2-LED梯形图转C语言:扫描周期与定时器的实现详解

发布时间:2026/9/17 13:27:01 来源:尧图企业网站定制
简介面向STM8S103F3微控制器的梯形图转C语言实战工程以2-LED控制与EV1527无线遥控解码为具体案例完整演示从PLC梯形图逻辑到C语言状态机的转换思路。资源包共99个文件以29个h头文件与28个c源文件为主体并包含IAR工程配置文件、编译输出及Hex烧录文件整体仅827KB便于快速查看工程结构和构建流程。目前已有293人学习资源聚焦EV1527信号捕获、中断服务程序设计、GPIO输出控制以及错误检测机制同时给出main.c、led.c、stm8s_conf.h等模块职责划分可对照学习定时器配置、状态机跳转和底层寄存器操作。通过学习能够掌握外设初始化、无线解码算法在真实项目中的落地方式也能加深对嵌入式开发流程与代码组织方法的理解适合正在入门单片机或准备进行PLC逻辑移植的工程师参考。1. 2-LED梯形图转C语言源码先读懂扫描再动手映射一个只有两个 LED 的 PLC 控制程序,可能是整个工控领域最简单的梯形图样例但把它完整转换成 C 语言源码这件事却能把梯形图和嵌入式开发之间最核心的差异暴露出来梯形图天生就是为循环扫描设计的而 C 语言只提供顺序执行。常见做法是把 PLC 的扫描周期用while循环模拟出来把每个网络翻译成一个布尔表达式把触点翻译成if条件把线圈翻译成赋值语句。这篇博文面向两类人一类是 PLC 工程师想搞懂梯形图的 C 语言实现原理另一类是嵌入式开发者在接手把 PLC 程序移植到单片机这类任务需要一份能直接参考的映射规则和源码骨架。先把梯形图执行模型讲透再给出 2-LED 交替闪烁的完整转换代码最后落在定时器语义和可断言的验证方法上。2. 梯形图执行模型与 C 语言映射的基础规则2.1 为什么梯形图不能逐行翻译成 C 函数直接对照梯形图的图形逐行写 C 代码是最容易踩坑的做法。原因在于梯形图程序在 PLC 里不是按出现的顺序执行一遍就结束而是按照固定的扫描周期不断循环CPU 先读取所有输入信号到输入映像区然后从第一条网络执行到最后一条网络期间所有输出先写到输出映像区扫描结束后统一刷新到物理输出端口。这个读输入—执行—写输出的周期在 C 语言里对应一个无限循环而不是一个普通函数调用。如果只把梯形图网络翻译成 C 代码片段却没有外层循环和输入输出映像区程序的时序行为会完全改变。拿启动按钮按下LED 点亮并自锁这个最小例子来说梯形图里的常开触点 X0 和自锁触点 X0 在同一个网络中出现两次PLC 在一个扫描周期内读到的都是同一个输入映像值所以自锁逻辑稳定。翻译到 C 语言时如果不建立输入映像区触点在执行过程中重新读取物理引脚一次扫描内前后状态不一致自锁条件会随机失效。所以转换的首要步骤是先建立一个和 PLC 内存模型对应的结构体。2.2 触点、线圈与网络的三层映射表我一般会把映射规则固定成三张表转换前先在纸上或者注释里写清楚再动代码。梯形图元素C 语言表示说明常开触点 Xif (X)或(X 1)触点闭合条件是变量为 1常闭触点 Xif (!X)或(X 0)触点闭合条件是变量为 0线圈 YY 条件表达式线圈得电条件是整个网络串联逻辑为 1串联与逻辑网络中触点按串联顺序用与连接并联或逻辑||多个分支用或连接左母线到右母线1恒真只要有线圈就一定有一条从左边来的通路这张表的核心思想是梯形图网络本质上是一个布尔表达式从左母线这个恒真源出发经过触点的条件筛选最后到达线圈赋值。串联分支对应短路与并联分支对应短路或翻译时用括号把并联分支包起来避免优先级出错。/* 梯形图网络1 常开触点A -- 常闭触点B -- 线圈Y */ if (input.A 1 input.B 0) { output.Y 1; } else { output.Y 0; }这段代码里的input和output不是物理端口而是输入映像区和输出映像区的结构体变量。input结构体在扫描开始时统一更新output结构体在扫描结束后统一刷新。如果把input.B直接写成本地读取的位带操作那么在一个扫描周期内多次读取同一个输入引脚时引脚电平的突变会造成同一网络的重复扫描结果不一致这是转换时最容易引入的隐蔽缺陷。2.3 一个单 LED 网络的完整最小循环用最少的代码验证上面这个模型我会建立一个全局的扫描循环把网络执行放在独立的函数里。#include stdbool.h #include stdint.h typedef struct { bool start_btn; /* 输入映像启动按钮 */ bool stop_btn; /* 输入映像停止按钮 */ } InputImage; typedef struct { bool led; /* 输出映像LED */ } OutputImage; static InputImage in_img; static OutputImage out_img; /* 每个扫描周期执行一次的梯形图网络 */ static void ladder_scan(void) { /* 网络1常开 start_btn 串联 常闭 stop_btn 控制 led */ if (in_img.start_btn !in_img.stop_btn) { out_img.led true; } else { out_img.led false; } } int main(void) { while (1) { /* 1. 读物理输入到输入映像区 */ in_img.start_btn read_pin(GPIO_PIN_START); in_img.stop_btn read_pin(GPIO_PIN_STOP); /* 2. 执行梯形图逻辑 */ ladder_scan(); /* 3. 输出映像区刷新到物理输出 */ write_pin(GPIO_PIN_LED, out_img.led); } }ladder_scan函数对应 PLC 的执行阶段它只读in_img、只写out_img不直接操作 GPIO。这样做的目的是把扫描周期内程序的确定性锁死同一个扫描周期内无论ladder_scan里有多少个网络引用了start_btn得到的都是同一个值。实际工程中如果还要处理输入滤波、通信报文和故障诊断建议按照先刷新输入、再跑用户逻辑、最后刷新输出、剩余时间通信的骨架来组织主循环这和 PLC 的循环扫描模型是一致的。3. 2-LED 交替闪烁梯形图到 C 语言源码的完整转换3.1 先确定目标梯形图的逻辑结构与 I/O 表我以最常见的双 LED 交替闪烁为例控制要求是上电后 LED1 亮 1 秒然后 LED1 灭、LED2 亮 1 秒循环交替。用 PLC 的定时器 TON 实现时梯形图需要 4 个网络网络1 用常闭触点 T2.Q 控制定时器 T1 计时网络2 用 T1.Q 直接驱动 LED1网络3 用 T1.Q 控制定时器 T2 计时网络4 用常闭触点 T1.Q 驱动 LED2。整个逻辑的核心是利用两个定时器的完成位互为对方的复位条件形成自激振荡。I/O 表不需要很复杂两个输出足以演示完整的映射过程。如果手头的 PLC 型号是 S7-1200 或三菱 FX 系列网络结构基本一致只是定时器编号和地址格式不同。转换前先把这个 I/O 表和定时器分配固定下来再进入代码映射能避免在翻译过程中反复改寄存器地址。名称类型作用在梯形图中的表示T1TON 定时器PT1000ms产生 LED1 的 1 秒延时定时器线圈使能条件是 T2.Q 为 0T2TON 定时器PT1000ms产生 LED2 的 1 秒延时定时器线圈使能条件是 T1.Q 为 1LED1输出线圈亮灭状态驱动条件是 T1.Q 为 1LED2输出线圈亮灭状态驱动条件是 T1.Q 为 0这里最关键的一点是 T1 和 T2 的使能条件必须互斥T1 在 T2 没有完成时计时T2 在 T1 完成时开始计时。这样两个定时器不可能同时处于计时状态LED1 和 LED2 也永远不会同时点亮。3.2 定时器控制块的数据结构设计在 C 语言里TON 定时器需要保存使能输入、当前计时值、预设值和输出状态。我一般用结构体来定义定时器控制块并把系统毫秒时间戳作为时间基准传入更新函数。typedef struct { bool enable; /* 使能输入对应梯形图中定时器线圈左侧的网络逻辑 */ bool q; /* 完成位输出对应 TON 的 Q 触点 */ uint32_t pt_ms; /* 预设时间单位毫秒对应 PT 引脚 */ uint32_t start_ms; /* 使能变为有效时的系统时间戳 */ uint32_t et_ms; /* 当前计时值单位毫秒对应 ET 引脚 */ } TimerTon;这个结构体的生命周期和 PLC 内部的定时器功能块完全对应。enable每次扫描都可能变化start_ms记录了enable从 0 变 1 的那个时刻的毫秒时间戳q在计时达到pt_ms后置位。注意et_ms不是实时计算出来的而是每次调用更新函数时才计算一次这正好模拟了 PLC 定时器只在扫描周期内刷新一次的行为。3.3 TON 定时器更新函数的实现原理TON 的更新逻辑可以拆成三句话使能为 0 时清零使能为 1 且时间差达到 PT 时置位 Q使能保持为 1 但时间差未到 PT 时持续累计。void timer_ton_update(TimerTon *tmr, uint32_t now_ms) { if (!tmr-enable) { tmr-et_ms 0; tmr-q false; tmr-start_ms now_ms; /* 使能无效时重置起始时间戳 */ return; } if (now_ms - tmr-start_ms tmr-pt_ms) { tmr-et_ms tmr-pt_ms; /* 计时到顶不再累加 */ tmr-q true; } else { tmr-et_ms now_ms - tmr-start_ms; tmr-q false; } }这段代码有个细节在使能为 0 的分支里我把start_ms重置为当前的now_ms。这样做的原因是当enable下一次变为 1 时start_ms不会仍然是上一次的历史值避免了首次计时的跳变。now_ms - tmr-start_ms使用无符号整型减法即使测试环境的时间戳发生了回绕只要回绕前后两个值都处于同一 32 位时间坐标系内差值依然正确。3.4 四个梯形图网络到 C 函数的逐行映射把前面设计的 4 个网络翻译成 C 函数顺序和梯形图的网络顺序完全一致先 T1再 LED1再 T2再 LED2。扫描函数的每次调用代表 PLC 的一个扫描周期。static TimerTon t1; static TimerTon t2; static void ladder_scan(void) { uint32_t now_ms get_tick_ms(); /* 网络1常闭触点 T2.Q -- T1定时器线圈TON, PT1000ms */ t1.enable !t2.q; timer_ton_update(t1, now_ms); /* 网络2T1.Q -- LED1输出线圈 */ out_img.led1 t1.q; /* 网络3常开触点 T1.Q -- T2定时器线圈TON, PT1000ms */ t2.enable t1.q; timer_ton_update(t2, now_ms); /* 网络4常闭触点 T1.Q -- LED2输出线圈 */ out_img.led2 !t1.q; }初看这段代码会觉得网络2 和网络4 只是把定时器完成位直接赋值给了输出但真正驱动状态转换的是网络1 和网络3 的交叉反馈T1 的使能被 T2.Q 常闭触点控制T2 的使能被 T1.Q 常开触点控制。两个定时器更新函数的调用顺序是严格确定的如果先更新 T2 再更新 T1整个振荡会进入完全不同的状态序列。转换时务必保留梯形图中网络执行的先后顺序这一点在后面的边界条件章节还会专门展开。3.5 主循环与时间基准的接入主循环只需要在扫描前刷新输入扫描后刷新输出每个循环之间做一次固定的毫秒延时或等待系统节拍。int main(void) { system_init(); /* 时钟、GPIO、SysTick 初始化 */ while (1) { ladder_scan(); write_pin(GPIO_PIN_LED1, out_img.led1); write_pin(GPIO_PIN_LED2, out_img.led2); /* 等待下一个系统节拍保持扫描周期均匀避免时间基准漂移 */ while (get_tick_ms() - last_scan_ms 10) { } last_scan_ms get_tick_ms(); } }要特别提醒的是get_tick_ms()必须返回系统上电后的累计毫秒数不能是某个时刻的相对值。实际工程中我习惯用一个 32 位无符号整型配合 SysTick 中断累加中断每 1ms 加 1主循环里只读取不修改这样start_ms和now_ms永远在同一个时间坐标系内定时器计时不会因为主循环的耗时抖动而累积误差。4. 定时器 TON 与扫描周期的关系为什么不能直接 sleep4.1 PLC 定时器和 C 语言延时函数的本质区别很多从 PLC 转到嵌入式开发的工程师第一次转换时会本能地用delay(1000)来实现 1 秒延时。这个做法在小规模演示程序里能跑通但和梯形图的 TON 语义并不等价。TON 表示的是使能输入持续有效的时间达到 PT而delay表示的是无条件阻塞当前线程指定时间两者在行为上有三个关键区别TON 受使能条件控制条件断开立即清零TON 在一个扫描周期内最多刷新一次不会阻塞其他网络TON 的计时基准是系统的全局时间戳而不是指令执行消耗的时钟周期。这三个区别直接决定了交替闪烁程序的行为。在 2-LED 这个例子中T1 和 T2 的使能条件互相交叉如果使用delay那么网络3 的 T2 计时必须等 LED1 的 1 秒亮完才开始整个顺序逻辑虽然也能工作但当梯形图扩展到 5 个以上网络、加入急停和复位条件时阻塞式延时会让网络之间的时序关系彻底失控。4.2 每个扫描周期调用一次更新函数的物理含义TON 更新函数必须在每个扫描周期被调用一次而不是在使能变化时才调用。为了说明这一点我用一个简单的表格列出两个相邻扫描周期内 T1 的状态演变。扫描周期now_msT2.QT1.enableT1.et_msT1.QLED1第 1 个周期00100灭第 500 个周期500015000灭第 1001 个周期10000110001亮第 1002 个周期10010110001亮第 2002 个周期10011000灭观察第 1001 个周期到第 2002 个周期T1.Q 从 1 变成 0 并不是因为 T1 自身计时清零而是因为 T2.Q 变为 1使得网络1 的常闭触点断开T1 的enable变为 0。这正是梯形图定时器线圈失电后 ET 清零的物理含义。如果实现时只在enable为 1 时才调用更新函数这个清零过程就永远不会发生LED2 也永远不会被点亮。4.3 时间戳计算中的饱和与回绕处理TON 的et_ms在达到 PT 后必须饱和不能再继续增加否则当now_ms - start_ms超过 PT 很多时某些依赖et_ms做百分比或分段判断的网络会产生错误。回绕处理则依赖于无符号减法这里有个实用的写法原则始终用当前时间减去起始时间绝对不要反过来用起始时间减去当前时间因为无符号减法在有符号环境下会被当作负数处理导致计时瞬间变成一个巨大的偏置值。/* 正确无符号差值32 位回绕后依然正确 */ uint32_t elapsed now_ms - tmr-start_ms; /* 错误当 now_ms 回绕后这个差值的符号意义会失序 */ uint32_t bad_elapsed tmr-start_ms - now_ms;即使在嵌入式裸机环境中系统连续运行 50 天32 位毫秒时间戳大约 49.7 天回绕一次使用无符号差值仍然能正确计算出时间差因为 C 语言的整数回绕规则保证了减法的数学结果是模 2 的 32 次方等价类。如果项目需要对时或跨设备同步建议升级为 64 位时间戳但 2-LED 这个级别的移植完全不需要增加这个复杂度。4.4 多个定时器同周期更新的顺序依赖当同一个扫描周期内有多个定时器需要更新时更新顺序会影响计算结果。在 2-LED 的例子中T1 的使能依赖 T2.QT2 的使能依赖 T1.Q这是一个环形依赖。PLC 解决这个环形依赖的方法是严格按照网络顺序扫描先跑到网络1 时T2.Q 还是上一周期的旧值等跑到网络3 更新 T2 时T1.Q 已经是本周期更新后的新值。这种先到先得的串行更新方式和 C 语言函数调用顺序完全一致。T1 使能 ← 上一周期 T2.Q T2 使能 ← 本周期 T1.Q刚更新因此在 C 语言里绝对不能把t1和t2的更新放在同一个函数里并行执行也不应该在更新 T2 前先保存 T1.Q 的副本再统一赋值。老老实实按照网络顺序调用才能还原 PLC 的扫描语义。这个顺序依赖往往被初版代码忽略等到两个定时器同时到达 PT、输出出现竞争状态时才会暴露。5. 常见坑位与边界条件双线圈、边沿检测和首次扫描5.1 双线圈问题的判定与修正策略梯形图中同一个输出线圈出现在多个网络时PLC 会按网络顺序让后一个网络覆盖前一个值。C 语言翻译时如果只简单写成多个赋值语句行为虽然一致但可读性和排错成本会快速恶化。/* 梯形图里 LED1 线圈出现在网络2 和网络5 */ if (t1.q) { out_img.led1 true; /* 网络2 */ } /* ……其他网络…… */ if (emergency_stop) { out_img.led1 false; /* 网络5最终生效 */ }我的建议是在转换前先用一个扫描周期模拟表把每个线圈在所有网络中的赋值轨迹列出来确认最终生效的是哪个网络。大多数情况下双线圈是设计缺陷正确的做法是设计一个中间变量汇总所有置位和复位条件再统一输出。如果确实需要模拟 PLC 的双线圈覆盖行为必须在代码注释中显著标注覆盖关系否则三个月后自己回头看也会被绕进去。5.2 上升沿与下降沿在 C 语言中的等价实现PLC 的上升沿触点P 触点只在信号从 0 变 1 的那个扫描周期内导通一个周期下降沿N 触点相反。C 语言实现边沿检测需要一个静态变量记住上一次扫描周期的信号状态。static bool detect_rising_edge(bool signal) { static bool last_state; bool edge signal !last_state; last_state signal; return edge; }注意这个静态变量last_state不能在梯形图网络间共享每个边沿触点都必须有自己独立的上次状态存储。如果两个边沿触点接的是同一个输入信号就必须分别调用两次这个函数各自维护独立的静态变量。常见的错误是只写了一个通用函数却在多处复用同一个last_state结果第二个边沿触点永远检测不到上升沿。5.3 首次扫描标志的必要性PLC 有特殊存储器位标识首次扫描周期用于初始化某些变量。C 语言转换时可以在主循环外显式执行一次初始化。static bool first_scan true; if (first_scan) { t1.enable false; t2.enable false; t1.q false; t2.q false; out_img.led1 false; out_img.led2 false; first_scan false; }这段初始化代码必须放在第一个扫描周期的ladder_scan调用之前。在 2-LED 这个例子中如果没有首扫初始化t1和t2里的start_ms就会是 0而上电后系统可能已经跑了几百毫秒才进入主循环第一次计时就会出现几百毫秒的偏移。把初始化放在main函数里、主循环之前是最稳妥的做法。5.4 扫描周期对定时精度的影响梯形图定时器的计时精度上限是扫描周期不是定时器自身的分辨率。假设扫描周期是 10ms那么 PT1000ms 的 TON 实际导通时间会在 1000ms 到 1010ms 之间波动。这个抖动对 LED 闪烁这种应用毫无影响但如果需要精确的 PWM 或编码器测量就必须改用定时器中断或硬件定时器而不是依赖扫描循环刷新定时器状态。/* 模拟扫描周期导致的量化误差预设 1000ms实际在 1000~1010ms 之间 */ #define SCAN_CYCLE_MS 10在设计阶段就明确定时精度 扫描周期 1 个周期抖动这个关系可以避免后期把很多不相关的现象误判为定时器实现错误。比如两个 LED 的交替频率出现少量漂移通常不是代码的问题而是采用了软件定时器的固有特性。6. 用一个可断言的测试用例验证 2-LED 交替输出6.1 模拟毫秒时间戳的测试骨架为了在主机环境验证转换后的代码我会把所有硬件访问抽象成接口让测试代码可以直接推进时间。get_tick_ms在测试环境返回一个可手动递增的变量这样就能精确控制每个扫描周期发生的时刻。static uint32_t mock_now_ms; uint32_t get_tick_ms(void) { return mock_now_ms; } static void advance_cycles(int cycles, int period_ms) { for (int i 0; i cycles; i) { mock_now_ms period_ms; ladder_scan(); } }这个测试骨架的精妙之处在于它把时间和执行解耦了每次推进时间后调用一次ladder_scan等价于 PLC 在一个固定扫描周期内运行一次。测试用例里不需要真实的 GPIO只要在每次扫描后检查out_img.led1和out_img.led2即可。6.2 关键边界时刻的断言清单针对 2-LED 交替闪烁我把测试点选在三个关键时刻上电初始状态、第一个 1 秒完成时刻、以及 T2 完成导致 T1 复位的时刻。assert(!out_img.led1 !out_img.led2); /* 初始两个 LED 都灭 */ mock_now_ms 0; ladder_scan(); /* 第 0 个周期T2.Q0T1 开始计时 */ advance_cycles(99, 10); /* 推到 990ms */ assert(out_img.led1 false); assert(out_img.led2 true); /* T1 未完成LED2 由常闭 T1.Q 驱动 */ advance_cycles(1, 10); /* 到达 1000ms */ assert(out_img.led1 true); assert(out_img.led2 false); /* LED1 亮LED2 灭 */ advance_cycles(100, 10); /* 到达 2000ms */ assert(out_img.led1 false); assert(out_img.led2 true); /* T2 完成LED1 灭LED2 亮 */注意第一个断言在 0ms 时LED1 和 LED2 都是灭的因为第 0 个周期 T1 刚被 enableQ 还未置位。这组断言覆盖了交替闪烁的完整周期990ms 时 LED2 亮是因为 T1.Q 为 0 驱动了网络4 的常闭触点1000ms 时 T1.Q 置位LED1 亮而 LED2 灭2000ms 时 T2.Q 置位T1 被复位LED1 灭、LED2 重新亮。6.3 把测试固化进构建流程建议把上述断言放进一个独立的test_ladder.c文件用make test或 CMake 的 CTest 在提交代码前自动运行。这样梯形图逻辑的回归测试能和嵌入式固件构建解耦后续加入第三个 LED 或修改定时时长时只需改动ladder_scan和测试用例中的期望值不需要连上开发板就能验证行为。我一般还会在测试里额外跑 200 个周期模拟 10 秒以上运行确认 LED1 和 LED2 的状态不会出现同时为真或同时为假的情况这一步能把大部分定时器互锁逻辑的错误提前拦截在主机环境。最后保留一个调试技巧在ladder_scan的末尾用printf打印每个周期结束后的 T1.Q、T2.Q、LED1、LED2 四个状态输出到文件后和手推的状态表逐行比对。这个办法虽然原始但在处理环形依赖的定时器网络时比用调试器单步跟踪更直观。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价