资讯详情

资讯详情

STM32软PLC实现:FX3U V8.8源码架构与避坑

简介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 点按位打包16Y 输出128 点按位打包16M 辅助继电器3072 点按位打包384S 状态1024 点按位打包128T 定时器256 点位 当前值 设定值1536C 计数器256 点位 当前值 设定值1536D 数据寄存器8000 点uint16 数组16000掉电保持区512 点备份寄存器 Flash1024这张表里最容易被忽略的是掉电保持区。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 / TIMplc_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.cModbus 功能码处理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勾选方便后面裁剪不用的指令FPUF4 系列单精度需要浮点指令时才开开了体积会涨编译前先去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 收发器的使能脚有没有在正确时刻翻转。下载成功后进入运行状态用强制功能点一下X0Y0对应的输出指示灯应该立刻亮。这一步过了说明指令解释器、软元件映射、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.cIO 映射集中成表换板子只改这里 */ 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脉冲输出直接偏了。从那以后我每次加通信功能都强制走一遍示波器量扫描周期的流程先把抖动量化出来再谈优化。这套源码的通信和扫描是分开的改的时候别把这个边界打破。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →