简介:FX3U V8.8源码升级包面向工控开发、嵌入式学习与三菱PLC仿真研究人群,围绕FX3U逻辑控制与STM32底层交互提供可参考的源码与工程资料,适合已有一定C语言和嵌入式基础、希望理解仿三菱PLC实现思路的读者。压缩包共713个文件、约78.89MB,涵盖C/H源码、Keil工程与编译中间文件、HEX固件、PDF文档、GXW工程及原理图预览等,既保留代码结构,也便于对照文档和示例梳理指令集、通信与硬件接口。已有1558人学习下载,说明其在PLC源码研读与原型验证场景中具有一定参考价值。读者可据此了解FX3U程序组织、STM32协同控制、版本升级差异及工程配置方法,用于课程设计、实验验证和二次开发前的技术预研,但需注意仅作学习参考并遵守版权与使用条款。
1. 一块 STM32 板子跑起 FX3U 程序:这套 V8.8 源码解决的是什么问题
去年帮一个做非标包装机的朋友看现场,客户提的要求很直接:程序必须是 FX3U 兼容的指令集,原来的老师傅会用编程口软件改梯形图,设备上不许换工控机。但原厂模块的价格和交期谁做谁知道。翻来覆去折腾了一周,最后落到这套FX3U V8.8源码升级版上:STM32 做主控,固件里实现软元件、指令解释、编程口协议和 Modbus,把一块几百块的开发板做成了一台能联机、能下梯形图、能跑高速脉冲的仿 FX3U 控制器。这套源码适合三类人:想做软 PLC 的嵌入式开发者、需要低成本替换 FX3U 主机的设备厂、以及想搞懂 PLC 扫描周期到底怎么落地成 C 代码的学生。第 3 章的指令解释器和第 5 章的五个坑,是最值得先看的。
2. 架构选型:STM32 怎么扛住 PLC 的软元件与扫描周期
2.1 硬件最小系统与内存预算
拿到源码第一步,我习惯先算内存账,因为软 PLC 最先被卡死的地方永远是 RAM,而不是 CPU。FX3U 兼容这一层要撑起的软元件规模大致是:X 输入 128 点、Y 输出 128 点、M 辅助继电器 3072 点、S 状态继电器 1024 点、T 定时器 256 点、C 计数器 256 点、D 数据寄存器 8000 点。前六项按位算,D 寄存器按 16 位字算,光 D 就吃掉 16000 字节。用 F103C8 那种 20KB RAM 的芯片,跑是能跑,但一加通信缓冲和梯形图下载缓存就满了,调试阶段经常莫名其妙跑飞。这套源码的默认工程配置是 F103ZET6 或 F407ZGT6 这一类 64KB/192KB RAM 的型号,属于典型的「先保证能装下,再谈实时性」。
| 软元件 | 数量 | 存储形式 | 占用字节(估算) |
|---|---|---|---|
| X 输入 | 128 点 | 按位打包 | 16 |
| Y 输出 | 128 点 | 按位打包 | 16 |
| M 辅助继电器 | 3072 点 | 按位打包 | 384 |
| S 状态 | 1024 点 | 按位打包 | 128 |
| T 定时器 | 256 点 | 位 + 当前值 + 设定值 | 1536 |
| C 计数器 | 256 点 | 位 + 当前值 + 设定值 | 1536 |
| D 数据寄存器 | 8000 点 | uint16 数组 | 16000 |
| 掉电保持区 | 512 点 | 备份寄存器 + Flash | 1024 |
这张表里最容易被忽略的是掉电保持区。FX3U 里 D200~D511 默认是保持型,源码里对应一块独立的备份区,上电时从 Flash 的保留页读回,掉电中断里写回。如果直接把整个 D 区都做成保持型,每次掉电要擦一个 1KB 页甚至 2KB 页,写放大严重,Flash 寿命掉得很快,这是后面第 5 章要讲的坑之一。
CPU 主频方面,F103 跑 72MHz 时,一条 32 位加法指令加上取指大概几百纳秒量级,一个 1000 步的梯形图程序扫一遍大概在 1~2ms 量级;换成 F407 跑 168MHz 就非常宽裕了。但如果程序里堆了大量 32 位浮点指令,F103 没有 FPU,全是软件模拟,扫描周期会直接翻几倍,这是移植时最容易被低估的点。
提示:先量扫描周期,再决定要不要换芯片。把 SysTick 计数差打印出来,比拍脑袋估算靠谱得多。
2.2 固件分层:驱动、内核、通信三条腿
这套源码的结构并不复杂,但分层思路值得抄。最底下是驱动层,把 STM32 的 GPIO、USART、TIM、IWDG 封装成几个固定接口,上层代码不碰寄存器;中间是 PLC 内核,负责软元件内存、指令解释执行、扫描调度、定时器计数器刷新;最上面是通信层,一边对接编程口协议(上位机下载梯形图、读写软元件、强制 IO),一边对外提供 Modbus RTU 从站,方便接触摸屏和上位机组态。三层之间只通过软元件映像区交换数据,不互相调用内部函数,好处是换一款 MCU 只要重写驱动层,内核一行不用动。
扫描周期的组织方式是这套固件的核心。和通用 MCU 常见的「中断里干所有事」不同,这里的主循环是一个确定性的状态机:采样输入 → 执行用户程序 → 刷新输出 → 处理通信 → 刷新定时器/计数器 → 喂狗,一轮走完进入下一轮。通信收包放在串口中断里只做入环形缓冲,真正的协议解析放到主循环里做,避免中断里做长耗时操作导致扫描周期抖动。这个取舍很关键,中断里解析一帧 200 字节的下载报文,耗时可到几百微秒,正好卡在一次扫描的中间,表现出来就是输出点偶尔抖一下。
我一般会在这个循环外面再加一层计时:用 DWT 周期计数器记录每轮扫描耗时,存到一个诊断寄存器里,上位机联机时可以读出来看最小值、最大值和平均值。没有这组数据,后面调实时性全是玄学。
2.3 目录结构与文件职责
源码包的目录大致按功能切开,熟悉之后找东西很快。下面这套结构是我按工程实际整理的对照表,括号里是各目录的作用,文件名做了泛化处理,具体以包内为准。
| 目录 / 文件 | 职责 |
|---|---|
Core/ | 启动文件、时钟配置、主函数入口 |
Drivers/ | MCU 厂商外设库或 HAL 层,GPIO / USART / TIM |
plc_kernel/ | 软元件定义、指令解释器、扫描调度 |
plc_kernel/plc_mem.c | 软元件内存分配与位/字寻址 |
plc_kernel/plc_exec.c | 指令表解释执行主循环 |
plc_kernel/plc_timer.c | 定时器、计数器、脉冲输出 |
comm/ | 编程口协议、Modbus RTU 从站 |
comm/fx_proto.c | 编程口帧收、校验、应答 |
comm/modbus_slave.c | Modbus 功能码处理 |
bsp/ | 板级支持包:IO 映射、输入滤波、掉电检测 |
user_app/ | 应用层:指示灯、故障码、参数保存 |
tools/ | 上位机联机脚本、串口调试辅助 |
看目录能看出一个设计取向:plc_kernel和comm之间没有直接 include 关系,两边都只 includeplc_mem.h里的软元件访问接口。这意味着你以后想把编程口协议换成以太网、或者把 Modbus 换成 TCP,内核完全不用动。二次开发时这个边界感很值钱。
3. 核心链路拆解:一条梯形图指令从下载到输出的全过程
3.1 软元件内存模型与位地址映射
PLC 编程里M100、D200、X0这种写法,在 C 里必须变成确定的地址计算。这套源码的做法是:位软元件用uint8_t数组打包存放,字软元件单独一个uint16_t数组,然后用一组内联函数做位读写,把「第几个字节的第几位」这层换算彻底封起来。这样做的直接好处是,指令解释器里不需要关心底层怎么排布,只调plc_bit_get(AREA_M, 100)就行。
/* plc_mem.h:软元件区定义,位元件按字节打包,字元件独立数组 */ #define M_COUNT 3072 #define D_COUNT 8000 #define X_COUNT 128 #define Y_COUNT 128 typedef enum { AREA_X = 0, AREA_Y, AREA_M, AREA_S, AREA_T, AREA_C, AREA_D } PlcArea; typedef struct { uint8_t x[(X_COUNT + 7) / 8]; /* 输入映像,1 点 1 bit */ uint8_t y[(Y_COUNT + 7) / 8]; /* 输出映像 */ uint8_t m[(M_COUNT + 7) / 8]; /* 辅助继电器 */ uint8_t s[(S_COUNT + 7) / 8]; /* 状态继电器 */ uint16_t d[D_COUNT]; /* 数据寄存器,16 位 */ uint16_t t_cur[T_COUNT]; /* 定时器当前值 */ uint16_t t_set[T_COUNT]; /* 定时器设定值 */ uint8_t t_coil[(T_COUNT + 7) / 8]; /* 定时器线圈状态 */ /* 计数器区结构相同,此处省略 */ } PlcMem; /* 位读取:area 决定去哪个数组,idx 是软元件编号 */ static inline uint8_t plc_bit_get(PlcMem *m, PlcArea area, uint16_t idx) { uint8_t *base; switch (area) { case AREA_X: base = m->x; break; case AREA_Y: base = m->y; break; case AREA_M: base = m->m; break; case AREA_S: base = m->s; break; default: return 0; } return (base[idx >> 3] >> (idx & 0x07)) & 0x01; } static inline void plc_bit_set(PlcMem *m, PlcArea area, uint16_t idx, uint8_t v) { uint8_t *base = (area == AREA_M) ? m->m : m->y; /* 简化示意 */ if (v) base[idx >> 3] |= (uint8_t)(1u << (idx & 0x07)); else base[idx >> 3] &= (uint8_t)~(1u << (idx & 0x07)); }idx >> 3是除以 8 得到字节偏移,idx & 0x07是取模 8 得到位偏移,这是位打包的标准写法。位数不是 8 的整数倍时,(COUNT + 7) / 8保证数组末尾还剩一个字节,不会越界。实际工程里,plc_bit_set的 switch 要写全七个区,上面为了篇幅只留了示意分支,抄的时候记得补完整,否则 M 区写入会被错误地打到 Y 区去,这种 bug 表现是「改一个辅助继电器,输出点莫名其妙动了一下」,查起来非常费时间。
字软元件的读写就简单得多,D区直接数组下标访问,但要做边界检查。上位机下载程序时如果给了个越界的D9999,没有检查就会直接踩到相邻数组,典型现象是下载完程序后定时器全部乱掉。加一句if (idx >= D_COUNT) return 0;能省掉一晚上的排查。
3.2 扫描周期调度与指令解释器
指令解释器的思路是「把梯形图编译成指令表,再逐条解释执行」。上位机下载下来的是已经编译好的指令表,每条指令固定长度,通常包含操作码、操作数 1、操作数 2 三部分,存在 Flash 的用户程序区里。扫描时用程序指针从头走到尾,遇到跳转指令就改指针,遇到输出指令就写软元件。这套模型的好处是程序大小不影响固件大小,用户程序改多少次都不用重新烧固件。
/* plc_exec.c:一次完整扫描,顺序固定,不能随意调换 */ void plc_scan_once(PlcMem *m) { uint32_t t_start = dwt_get_cycles(); plc_read_inputs(m); /* 1. 采样物理输入到 X 映像区 */ plc_exec_program(m); /* 2. 从程序头解释执行到程序尾 */ plc_write_outputs(m); /* 3. Y 映像区刷到物理输出 */ plc_timer_update(m); /* 4. 刷新 100ms / 10ms 定时器 */ plc_counter_update(m); /* 5. 刷新高速计数与普通计数器 */ comm_poll(m); /* 6. 处理编程口与 Modbus 报文 */ /* 诊断:记录本轮扫描耗时,单位是 CPU 周期 */ uint32_t dt = dwt_get_cycles() - t_start; if (dt > g_scan_max) g_scan_max = dt; g_scan_sum += dt; g_scan_cnt++; } /* 指令解释主循环,PC 是程序指针,指向 Flash 里的指令表 */ void plc_exec_program(PlcMem *m) { const uint8_t *pc = g_user_prog_base; const uint8_t *end = g_user_prog_base + g_user_prog_len; while (pc < end) { uint8_t op = pc[0]; switch (op) { case OP_LD: /* 取常开触点 */ g_acc = plc_bit_get(m, pc[1], (uint16_t)(pc[2] | (pc[3] << 8))); pc += 4; break; case OP_OUT: /* 输出线圈 */ plc_bit_set(m, pc[1], (uint16_t)(pc[2] | (pc[3] << 8)), g_acc); pc += 4; break; case OP_MOV: /* 传送指令,D 区之间搬字 */ plc_word_set(m, pc[1], (uint16_t)(pc[2] | (pc[3] << 8)), plc_word_get(m, pc[4], (uint16_t)(pc[5] | (pc[6] << 8)))); pc += 7; break; case OP_END: return; /* END 指令,直接退出本轮扫描 */ default: pc += 4; /* 未知指令跳过,避免死循环卡死 */ break; } } }plc_scan_once里六个步骤的顺序是硬约束。输入采样必须在程序执行之前,否则第一个扫描周期读到的是上一轮的值;输出刷新必须在程序执行之后,否则后面的程序逻辑改不动本轮输出。这两条如果写反,程序逻辑完全正确但设备行为就是不对,属于典型的「代码看起来没问题」类 bug。
指令解释部分里,g_acc是累加器,代表当前逻辑运算的结果,LD装载、AND与、OR或都围绕它做。pc += 4的步长说明这条指令占 4 字节:1 字节操作码、1 字节区标识、2 字节软元件编号。MOV占 7 字节是因为带了两个操作数。实际工程里指令表格式一般会在文件头写一个魔数和版本号,下载时先校验,不对就直接拒绝,防止把别的型号的程序灌进来导致解释器跑飞。
OP_END的处理值得多说一句。用户程序区长度是下载时写进去的,如果下载中断导致长度比实际指令多,程序指针会走到未初始化的 Flash 区域。Flash 擦除后的值是0xFF,对应到一个不存在的操作码,如果不做default分支的跳过处理,就会原地打转或者跳到乱七八糟的地址。这个防护必须在解释器第一版就加上。
3.3 编程口协议与 Modbus RTU 从站
两个通信口各自独立。编程口走的是 FX 系列那套帧格式:STX起始、命令码、数据区、ETX结束,最后跟一个单字节累加和校验。串口参数常见配置是 9600 或 19200、7 数据位、偶校验、1 停止位,物理层是 RS422 四线全双工。这个 7 数据位是很多人第一次接触时最容易翻车的地方——STM32 的 USART 支持 9 位字长(8 数据 + 1 校验),配置成 7 数据位 + 偶校验时寄存器写法比较绕,配错了就是能发不能收,或者收到的全是乱码。
/* fx_proto.c:编程口帧接收与应答,收包在中断里只入缓冲 */ static uint8_t rx_buf[256]; static uint16_t rx_len; static uint8_t frame_ready; /* 串口中断服务:逐字节收,检测到 ETX + 校验字节后置标志 */ void USART1_IRQHandler(void) { if (USART1->SR & USART_SR_RXNE) { uint8_t ch = (uint8_t)(USART1->DR & 0xFF); if (rx_len < sizeof(rx_buf)) rx_buf[rx_len++] = ch; /* STX 开头,ETX 后还有 1 字节校验,凑够就标记 */ if (rx_len >= 2 && rx_buf[0] == 0x02 && rx_buf[rx_len - 2] == 0x03) frame_ready = 1; } } /* 主循环里处理完整帧 */ void comm_poll(PlcMem *m) { if (!frame_ready) return; frame_ready = 0; uint16_t data_len = rx_len - 4; /* 去掉 STX/ETX/SUM 和 1 字节命令 */ uint8_t sum = 0; for (uint16_t i = 1; i < rx_len - 1; i++) sum += rx_buf[i]; /* 从命令码累加到数据末字节 */ if (sum != rx_buf[rx_len - 1]) { /* 校验失败,直接丢弃 */ rx_len = 0; return; } switch (rx_buf[1]) { /* rx_buf[1] 是命令码 */ case 0x30: /* 读软元件,批量 */ fx_reply_read(m, &rx_buf[2], data_len); break; case 0x31: /* 写软元件 */ fx_handle_write(m, &rx_buf[2], data_len); break; case 0x35: /* 强制 IO 置位/复位 */ fx_force_io(m, &rx_buf[2], data_len); break; default: fx_reply_nak(); break; } rx_len = 0; }校验和的累加范围是关键细节:从命令码开始,一直加到数据区最后一个字节,不包括STX,也不包括ETX和校验字节本身。范围错一位,所有帧都会被判失败,表现是上位机提示「通信错误」但串口助手能看到完整报文——这种情况几乎 100% 是校验范围写错了。命令码的具体取值各版本固件可能略有差异,以包内fx_proto.c的注释为准,不要照搬别处的表。
Modbus 从站这边就常规多了,用0x03读保持寄存器、0x06写单个、0x10写多个,把 D 区整体映射成保持寄存器区,起始地址一般在固件里用一个宏定义。触摸屏要读某个 D 寄存器,地址就是「起始地址 + 寄存器编号」。这里有个约定要提前和现场说清楚:Modbus 地址是 0 基还是 1 基,很多触摸屏组态软件默认 1 基,差一位就全部读错。我一般的做法是在固件里把映射表头写成注释贴出来,交付时直接给现场。
3.4 高速 IO:输入滤波、脉冲输出与高速计数
PLC 现场最躲不开的就是输入抖动和脉冲输出。机械触点闭合瞬间会有几十微秒到几毫秒的抖,如果每个扫描周期直接读 GPIO,表现出来就是计数器多计了好几个数。这套源码的做法是软件滤波:连续 N 次采样到相同的电平才认,N 一般取 4~8,按 1ms 采样一次算,滤波时间就是 4~8ms。对于需要快速响应的输入(比如急停),可以单独走一条不滤波的通路。
/* bsp/io_filter.c:输入软件滤波,8 次一致才更新映像区 */ #define FILTER_CNT 8 static uint8_t stable[IO_IN_NUM]; /* 稳定后的电平 */ static uint8_t cnt[IO_IN_NUM]; /* 连续一致计数 */ void io_sample_inputs(PlcMem *m) { for (uint16_t i = 0; i < IO_IN_NUM; i++) { uint8_t raw = (uint8_t)HAL_GPIO_ReadPin(IN_PORT(i), IN_PIN(i)); if (raw == stable[i]) { if (cnt[i]) cnt[i]--; /* 一致则递减计数,避免长期饱和 */ } else { if (++cnt[i] >= FILTER_CNT) { stable[i] = raw; /* 连续 8 次不同,才认这个新电平 */ cnt[i] = 0; plc_bit_set(m, AREA_X, i, raw); } } } }脉冲输出这条链路更麻烦一点。PLSY这类指令要输出固定频率、固定数量的脉冲,实现上用定时器 PWM 出波形,同时开一个计数器中断统计已发脉冲数,到了设定值就关掉 PWM。这里最容易翻车的是脉冲数不准:如果是在 PWM 中断里判断计数,中断延迟会导致多发或少发几个脉冲。稳妥做法是把脉冲计数接到定时器的外部计数输入上,让硬件自己数,到数后进中断停 PWM,误差可以压到 1 个脉冲以内。
定时器和计数器刷新这块有个设计选择:普通 100ms 定时器用软件计数,在主循环里按扫描次数累加,扫描周期不稳定时定时就不准。更好的做法是挂到 SysTick 或者一个 1ms 硬件定时器中断里,用硬件时基驱动,扫描周期再怎么抖都不影响定时精度。这套源码是后者,plc_timer_update里只做「时基到了就减当前值」的判断,不依赖扫描次数。
4. 从源码到转起来的设备:编译、烧录、联机三步走
4.1 Keil 工程配置与编译选项
源码包大概率带的是 Keil MDK 工程,.uvprojx直接在包里找。打开之前先确认两件事:装没装对应型号的器件支持包,以及工程用的编译器版本是 AC5 还是 AC6。这两个版本在语法上差别不小,AC6 对隐式函数声明、char符号性、内联汇编的检查都严格很多,用 AC5 编过的代码丢进 AC6 常常是一屏警告加几个报错,其中「隐式声明」如果只是警告不管它,链接出来的调用的地址可能对不上,跑起来就是随机死机。
几个必须核对的编译选项:
| 选项 | 建议值 | 说明 |
|---|---|---|
| Optimization | -O1或-O2 | 不要用-O0调试实时性;-O3会让断点错位 |
| C99/C11 | 勾选 C99 | 源码里用了stdint.h和行内注释 |
| MicroLIB | 勾选 | 省 RAM,且不需要完整的 C 库 |
| One ELF Section per Function | 勾选 | 方便后面裁剪不用的指令 |
| FPU(F4 系列) | 单精度 | 需要浮点指令时才开,开了体积会涨 |
编译前先去plc_mem.h确认软元件规模常量和目标板 RAM 对得上。这一步看着多余,但现实里很常见:源码是给 192KB RAM 的板子配的,你拿 64KB 的板子直接编,链接阶段报.bss溢出,然后开始狂删常量,删到最后功能缺一半。宁可先换板子。
4.2 烧录与首次上电自检
烧录用 ST-Link 或者串口 ISP 都行,调试阶段建议 SWD,因为要打断点看软元件。烧完第一次上电,别急着接设备,先看三件事:电源指示灯、心跳指示灯、串口有没有上电自检报文。
心跳灯一般是一秒闪一次,由主循环里的计数器翻转。灯不闪说明程序卡在某个地方了,常见的是卡在时钟初始化等待 HSE 就绪——板子上晶振没焊或者焊错频率,代码里HSE_VALUE写的 8MHz 而实际焊的 12MHz,就会一直等。这种情况不用怀疑代码,先拿示波器看晶振起没起振。
上电自检报文里一般会带固件版本号、软元件规模、用户程序区状态。版本号要和你手里的包对得上——标题里的 V8.8 是固件内部版本,联机后读版本寄存器能读到同一个值,对不上说明烧的是旧固件,或者包里有两个工程。用户程序区如果显示「空」,那是正常的,首次上电还没有下载过程序。
4.3 联机下载梯形图并验证
联机这一步,先拿一个最小的梯形图验证链路:一个X0常开触点驱动Y0线圈,程序长度不超过 10 步。上位机连上后依次做:读取 PLC 型号 → 读软元件测试 → 写程序 → 运行 → 强制X0→ 看Y0是否动作。
通信参数按 3.3 节说的配:9600、7、E、1,站号默认 0。连不上时按这个顺序排查:先确认物理线序(RS422 的 TX+/TX−/RX+/RX− 四根线,接反了就是能发不能收),再用串口助手直接抓上位机发出来的报文,看STX后面第一个字节是不是合法命令码,最后量一下 MAX485 或者 422 收发器的使能脚有没有在正确时刻翻转。
下载成功后进入运行状态,用强制功能点一下X0,Y0对应的输出指示灯应该立刻亮。这一步过了,说明指令解释器、软元件映射、IO 刷新整条链路都通了。如果Y0的映像区变了但物理输出没动,问题一定在输出刷新那一层,去查plc_write_outputs里 GPIO 端口和位号的映射表,十有八九是映射表的顺序和实际 PCB 布线对不上。
注意:第一次联机不要直接下现场的实际程序。用最小程序验证链路,链路不通的时候排查范围小得多。
5. 避坑排查:这份源码最容易翻车的五个地方
5.1 编译通过但一上电就跑飞,卡在 HardFault
现象是烧录后指示灯完全不闪,用调试器暂停发现停在HardFault_Handler。原因通常是栈溢出:软元件结构体定义成全局变量还好,但如果某个函数里在栈上开了几百字节的临时缓冲(比如下载报文缓存、Modbus 应答缓冲),而启动文件里的栈大小还是默认的0x400,一进这个函数就踩到别的段了。另一个常见来源是指令解释器里的递归——指令表里如果有跳转子程序,实现时用递归调用,嵌套几层就把栈吃穿。
解决方式是把启动文件里的Stack_Size从0x400提到0x1000或更大,同时检查plc_exec_program有没有递归。真需要子程序调用,用显式的调用栈数组模拟,深度限制在 8 层以内,超了直接报错停程序,比静默溢出强。
5.2 上位机一直提示通信超时
现象是串口助手能收到完整报文,但上位机始终报超时。原因按概率排:第一是串口参数配错,尤其是 7 数据位/偶校验这一组,很多人习惯性配成 8/N/1;第二是校验和范围写错,多算了STX或者少算了最后一字节;第三是应答延迟太长,上位机等 100ms 就判超时,而固件里应答是在主循环里处理的,一轮扫描 20ms,碰上正在下载程序时更长。
解决:先用示波器或者逻辑分析仪量一下固件确实发出了应答帧;确认发出的话问题在上位机侧参数;没发出的话在comm_poll入口加个 IO 翻转,看是没进这个函数还是进了没匹配到命令码。应答时间这个可以优化,把编程口的应答提到plc_write_outputs之后、定时器刷新之前处理,能少等半轮扫描。
5.3 定时器走了但时间明显不对
现象是设定 5 秒的定时器,实际 3 秒多就动作,或者扫描周期一变定时偏差就跟着变。原因多半是定时器用扫描次数累加实现的,T_cur++放在主循环里,扫描快了定时就短。这套源码本身是用硬件时基的,但如果二次开发时图省事改成了软件计数,就会踩这个坑。
解决:定时器刷新必须挂到固定时基上,1ms 或 10ms 硬件中断里统一减,主循环只读当前值判断。如果非要用软件方式,至少要用 DWT 周期计数器算真实时间,别用循环次数。
5.4 掉电保持数据丢失或者变乱码
现象是断电重启后 D200 之后的值全部变 0,或者变成一堆不相干的数。原因通常是 Flash 写入没有按页对齐,写之前没擦除,或者擦除时把相邻的参数区一起擦了。Flash 的擦除单位是页,1KB 或 2KB,一个页里往往同时放了掉电保持数据和校准参数,擦的时候一锅端。
解决:把 Flash 保留页按用途分区,掉电保持单独占一个页,写之前先读到 RAM 缓冲,擦整页,再把整个缓冲连同新数据一起写回。写的时候注意别在中断里做,Flash 擦写期间取指会被阻塞,扫描周期直接拉长到几十毫秒,正在跑的设备会明显顿一下。掉电检测中断里只置标志,真正的保存在主循环里做。
5.5 脉冲输出数量差几个,或者频率上不去
现象是让发 10000 个脉冲,实际发出来 9997 个或者 10003 个。原因是脉冲计数在中断里做,中断响应有延迟,最后几个脉冲容易被漏掉或者多算一次。频率上不去则是定时器分频和自动重装值算错,或者中断服务函数太长。
解决:脉冲计数交给定时器的外部计数通道,硬件数,到数触发中断停 PWM,误差能压到 1 个以内。频率计算按f = 定时器时钟 / (预分频 + 1) / (重装值 + 1)反推,别用经验值。中断服务里只做一件事:关 PWM 输出,其他都放主循环。
6. 二次开发:换板子、加指令、控节拍的三条改造路径
换板子是最常见的需求。这套源码的驱动层是收敛的,移植工作量集中在bsp/目录:把io_sample_inputs、plc_write_outputs里的端口和引脚映射改掉,把串口初始化的波特率字长校验改掉,把定时器时基从 SysTick 换成你板子上的通用定时器。映射关系建议做成一张表,而不是散在代码各处的宏,改的时候只改表。我一般的做法是写一个const IoMap io_map[],每项是「软元件编号 + 端口 + 引脚 + 有效电平」,初始化时遍历一遍,以后换板子只维护这一张表。
/* bsp/io_map.c:IO 映射集中成表,换板子只改这里 */ typedef struct { uint16_t comp_idx; /* X 或 Y 的编号 */ GPIO_TypeDef *port; uint16_t pin; uint8_t active_low; /* 光耦隔离常见低有效 */ } IoMap; const IoMap g_x_map[] = { { 0, GPIOA, GPIO_PIN_0, 1 }, { 1, GPIOA, GPIO_PIN_1, 1 }, /* ... 按实际 PCB 顺序补全 */ }; void plc_write_outputs(PlcMem *m) { for (uint32_t i = 0; i < sizeof(g_y_map) / sizeof(g_y_map[0]); i++) { uint8_t v = plc_bit_get(m, AREA_Y, g_y_map[i].comp_idx); if (g_y_map[i].active_low) v = !v; HAL_GPIO_WritePin(g_y_map[i].port, g_y_map[i].pin, v ? GPIO_PIN_SET : GPIO_PIN_RESET); } }加自定义指令也不难。指令解释器的switch里加一个case,在plc_exec.c里写好执行逻辑,同时和上位机那边的指令码对上就行。有几个约束要守住:新指令的长度必须固定,因为程序指针是按固定步长走的;新指令不能修改程序区本身;指令码不要和已有的冲突,包里一般有个指令码表,加之前先扫一遍。做完之后,用一条最小程序验证:只放这一条新指令,看执行结果、看程序指针有没有正常前进、看扫描周期有没有异常变大。
实时性验证是我每次改完都必须做的一步。方法很土但有效:在plc_scan_once首尾各翻转一个空闲 IO,用示波器量高电平宽度,那就是扫描周期。再叠一个 1000 步左右的满负载程序,看最大周期和最小周期差多少。差值超过 30% 就说明某处有不确定延迟,去查通信处理和 Flash 操作是不是跑到主循环里了。
有个血泪教训:早期我图省事,把 Modbus 应答也放进主循环,觉得一帧就几十字节无所谓。结果现场接了个每秒轮询 20 次的触摸屏,扫描周期从 1.2ms 抖到 6ms,脉冲输出直接偏了。从那以后我每次加通信功能,都强制走一遍示波器量扫描周期的流程,先把抖动量化出来再谈优化。这套源码的通信和扫描是分开的,改的时候别把这个边界打破。希望帮到你。
本文还有配套的精品资源,点击获取