资讯详情

资讯详情

STM32实现FX3U兼容PLC:源码架构与工程避坑

简介面向工业自动化与嵌入式开发者的FX3U V8.8源码升级包围绕三菱FX3U系列PLC的编程逻辑、指令集应用及STM32协同控制展开可用于学习“仿三菱”实现思路、教学验证与原型开发资料仅供学习参考。压缩包共713个文件、约78.89MB以C/H源码为主体辅以编译中间文件、工程配置、HEX烧录文件及PDF/DOC说明文档便于按工程结构梳理代码与查阅配套资料。已有1558人学习下载适合具备一定嵌入式基础、希望从源码层面理解PLC运行机制与通信处理的中高级读者。包内还保留升级版与基础版相关工程痕迹可对照分析版本差异结合示例与文档复现实验环境、排查编译配置问题并提取可复用的控制逻辑与调试思路。1. FX3U V8.8 源码升级版一块 STM32 顶掉一台小型 PLC 主机去年帮朋友救一台包装机主机是 FX3U输出板烧了两路备件要等六周机器停一天损失几千。当时就想要是手上有套能跑在 STM32 上的 FX3U 兼容固件周末就能拿块 F407 的核心板顶上去。这份 FX3U V8.8 源码升级版干的就是这件事在 STM32 上实现 FX 系列指令集兼容的小型 PLC 内核包含梯形图指令的解释执行、软元件内存管理、编程口通信协议、IO 刷新、高速计数与脉冲输出往下直接对接 STM32 的 GPIO、定时器和串口。它适合三类人设备维修和替换场景里想自研主控的工控开发做教学实验台和 PLC 原理课程的老师以及想搞明白「梯形图到底是怎样一条条被 CPU 执行」的嵌入式工程师。想跑多轴插补、要过功能安全认证的场合这套东西不合适。2. 架构拆解从梯形图到输出端子一个扫描周期里发生了什么拿到包之后别急着打开 main.c先看它分了几层。这类 STM32 上的 PLC 固件合格的做法一定是把「和硬件打交道」和「和指令打交道」隔开否则换一款 MCU 就要重写一遍。FX3U V8.8 这套工程的骨架基本是 BSP 层、软元件层、指令层、协议层四块叠起来再加一个决定节奏的快照循环。2.1 目录分层BSP、软元件、指令、协议四块怎么切这类工程的目录形状通常长这样你打开包之后可以对着核一遍缺哪块说明这份源码被裁剪过fx3u_v8.8/ ├── Core/ │ ├── Src/ │ │ ├── main.c # 时钟、外设初始化进主循环 │ │ ├── plc_scan.c # 扫描周期调度 │ │ ├── plc_exec.c # 指令解释执行 │ │ ├── plc_mem.c # 软元件读写接口 │ │ └── stm32f4xx_it.c # 中断入口只做置标志 │ └── Inc/ ├── BSP/ │ ├── bsp_io.c # 输入滤波、输出锁存 │ ├── bsp_uart.c # 编程口 调试口 │ ├── bsp_eeprom.c # 程序区与掉电保持区 │ ├── bsp_hsc.c # 高速计数器 │ └── bsp_pulse.c # 脉冲输出 ├── PLC/ │ ├── plc_inst.c # 指令表定义与译码 │ ├── plc_proto.c # 编程口帧解析与组包 │ └── plc_prog.c # 用户程序存储、校验 └── MDK-ARM/ (或 Makefile)分层的关键不在目录名在于依赖方向PLC/里不允许出现任何HAL_开头的调用BSP/里不允许出现任何指令码。我见过不少人图省事在指令执行函数里直接HAL_GPIO_WritePin结果客户要换成另一家芯片整个指令层跟着重写血泪经验。正确的切法是 BSP 只暴露bsp_read_x(n)、bsp_write_y(n, v)这种语义接口指令层只认软元件编号。plc_mem.c是唯一被上下两层同时引用的模块它负责把「X17 表示第 17 号输入点」这个业务概念翻译成「第 2 个字节的第 1 位」这种物理概念。这一层做干净了后面加 Modbus、加上位机、加触摸屏都是加法不是改法。2.2 扫描周期输入采样、程序执行、输出刷新三段式PLC 和普通单片机程序最大的区别是它有一个固定节奏所有逻辑都在这一个节奏里跑完。三段式是所有兼容 FX 指令集的固件都绕不开的模型/* plc_scan.c —— 三个阶段的顺序不能调换 */ void plc_scan_cycle(void) { uint32_t t0 dwt_get_us(); plc_io_sample(); /* 1. 输入采样GPIO/HSC 现场值刷进 X 区 */ plc_exec_program(); /* 2. 指令执行从程序区逐条解释 */ plc_io_refresh(); /* 3. 输出刷新Y 区一次性写进输出锁存 */ plc_wdt_feed(); /* 只在循环末尾喂狗卡死必复位 */ g_scan_us dwt_get_us() - t0; /* 供上位机读扫描时间 */ }顺序换了会出什么事值得说清楚。如果先刷新输出再执行程序用户看到的永远是上一周期的结果梯形图里的自锁回路会慢一拍调试时表现为「按下按钮松手后输出才吸合」很难查。如果输入采样放到执行中间同一周期里前后两条指令读到的 X 值可能不一样用户写的互锁逻辑就失效了。所以plc_io_sample必须在最前面一次性把硬件状态全搬进内存之后整个周期只读内存不碰 GPIO。plc_wdt_feed放在末尾也是个讲究。放在开头的话如果程序执行阶段进了死循环看门狗根本等不到喂狗就复位了 —— 这是对的但如果某个指令执行特别慢比如 32 位乘除法在 F1 上要几十微秒喂狗位置太靠前会导致误复位。末尾喂一次等于给整个周期定了个上限。扫描时间g_scan_us一定要往上报。FX 系列的习惯是把它映射到某个特殊数据寄存器常见是 D8010 这一类上位机监控时能直接看到。现场调试时这个值就是黑匣子正常应该稳定在几百微秒到几毫秒一旦看到它跳到 20ms 以上还上下抖八成是有条指令在死循环或者某个中断里干了重活。2.3 软元件编号与内存模型FX 系列的软元件编号是个八进制混十进制的怪东西X 和 Y 按八进制走X0 到 X7 之后是 X10没有 X8 和 X9M、S、D、T、C 按十进制。这个规则必须在内存模型里体现否则用户从上位机写的 X10 和你固件里算出来的下标对不上现象就是「写 X10 结果 X8 动了」。常见的做法是编号直接当位下标用不做八进制到十进制的转换但读取时按八进制递增。也就是说 X 区是一个连续的位图第 n 位就是 X 的第 n 个点而用户界面上显示的编号由上位机或固件里的转换函数负责。这样指令层拿到的永远是线性下标省掉每次访问都做进制换算的开销。内存分配上位元件用位图字元件用数组这是唯一合理的选法。X、Y、M、S 各开一块uint8_t数组按位寻址D、T 当前值、C 当前值开uint16_t数组按字寻址。掉电保持段不是单独一块内存而是在 M 区和 D 区里划一段地址区间保存时按区间整体搬到 EEPROM上电时再搬回来。区间边界通常做成宏改配置只改一处。提示软元件总数不要照抄 FX3U 的规格表。STM32F103 只有 20KB RAM把 D0 到 D7999 全开出来就是 16KB程序区再占几 KB栈就没地方了。按实际项目需要裁剪D 区开到 2000 点已经能覆盖绝大多数小型设备。3. 工具链与第一次上电MDK 工程编译、下载、编程口自环验证工程能编译不代表能跑能跑不代表跑对。这一章按我自己的顺序走一遍先把工具链对齐再让板子活过来最后用上位机下一段最简梯形图验证从协议到输出的整条链路。3.1 工具链选择与工程配置要点两条路都能走。一条是 Keil MDK 5 加对应的器件支持包工程里.uvprojx双击就能开适合习惯图形界面的人另一条是arm-none-eabi-gcc加 Makefile适合要接 CI 或者用 Linux 开发的场景。这套源码两种工程结构一般都会带MDK-ARM/和根目录的Makefile各取所需。用 GCC 这条路的编译命令大概是这样参数含义我逐条标一下# 编译单个源文件到目标文件-mcpu 必须和板子上的芯片一致 arm-none-eabi-gcc -c Core/Src/plc_exec.c \ -mcpucortex-m4 -mthumb -mfpufpv4-sp-d16 -mfloat-abihard \ -O2 -g3 \ -ICore/Inc -IBSP -IPLC -IDrivers/CMSIS/Include \ -o build/plc_exec.o # 链接注意 .ld 里的 RAM 长度要和芯片实际一致写大了链接能过但跑起来必崩 arm-none-eabi-gcc build/*.o -T stm32f407.ld \ -Wl,-Mapbuild/fx3u.map,--cref -o build/fx3u.elf # 转 bin 并烧录openocd 的配置文件按你手上的调试器选 arm-none-eabi-objcopy -O binary build/fx3u.elf build/fx3u.bin openocd -f interface/stlink.cfg -f target/stm32f4x.cfg \ -c program build/fx3u.elf verify reset exit三个参数最容易翻车。-mcpu写成cortex-m3而芯片是 M4编出来的代码里带浮点指令会直接 HardFault-mfloat-abisoft和硬件 FPU 混用时中断里保存现场的方式不对表现为「主循环好好的一进串口中断就死」.ld文件里的 RAM 长度照抄了别的板子栈顶指针指到不存在的地址上电第一件事就是进 HardFault 死循环。时钟配置是第二个坑。这类固件通常要跑高速计数和脉冲输出主频不能太低。F103 一般配到 72MHzF407 配到 168MHz。用 CubeMX 生成的话记得把SystemCoreClock更新那行留着很多手写工程把它删了结果HAL_Delay延时准不准全看运气。串口波特率的计算依赖这个值主频改了对不上编程口就一直是乱码。3.2 第一次编译下载从点灯到串口打印别一上来就下梯形图。先确认板子活着、时钟对了、串口通了这三件事任何一件没成后面调试全是盲人摸象。第一步改main.c把 PLC 主循环暂时注释掉只留一个闪灯int main(void) { HAL_Init(); SystemClock_Config(); /* 72M 或 168M看芯片 */ MX_GPIO_Init(); MX_USART1_UART_Init(); /* 调试口115200 8N1 */ MX_USART2_UART_Init(); /* 编程口9600 8E1 */ printf(fx3u v8.8 boot, sysclk%lu\r\n, SystemCoreClock); while (1) { HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); HAL_Delay(500); } }printf重定向到串口这件事必须做它决定了后面所有调试的效率。重定向就是实现_write或者fputc往huart1里塞。串口助手能收到boot, sysclk168000000说明时钟、串口、时钟树三者一致。收到乱码说明波特率算错了多半是SystemCoreClock没更新。什么都收不到先量一下 TX 引脚有没有波形没有就是 GPIO 复用没配。第二步把 PLC 主循环放回去但程序区留空。这时候plc_exec_program每周期扫过 0 条指令g_scan_us应该稳定在一个很小的值。在调试口每秒打一次扫描时间观察十秒抖动不该超过 20%。这一条能过说明定时器、看门狗、主循环调度都没问题。第三步才是接编程口。这一步最容易被跳过也最容易在后面吃亏。3.3 编程口自环与上位机联调编程口是这套固件和外界唯一的正经通道它不通你连一行梯形图都下不进去。先做自环把板子的编程口 TX 和 RX 短接注意是电平侧短接不是经过收发器之后跑一个自发自收的测试/* 自环测试发一帧读版本号的命令看收回来的字节对不对 */ uint8_t tx[] {0x02, 0, 0, 0, 0x03, 0, 6}; uint8_t rx[16]; uint16_t n; HAL_UART_Transmit(huart2, tx, sizeof(tx), 100); n uart_recv_timeout(rx, sizeof(rx), 200); printf(loopback n%d\r\n, n); for (int i 0; i n; i) printf(%02X , rx[i]);收发字节数一致、内容一致说明 UART 配置、中断、超时机制都对。这里常翻车的是校验位FX 编程口习惯用偶校验 8E1你要是配成 8N1收到的每个字节最高位都可能被误判表现为「偶尔能通重发几次又好了」这种玄学现象最难查。自环过了再接真实的上位机。上位机兼容 FX 协议的编程软件连上之后第一件事是读 PLC 型号和版本固件这边要在协议层正确应答否则上位机会直接判定「通信失败」并不再往下走。应答的内容不求多真但格式必须对帧头、命令码、数据区、帧尾、校验和五个部分少一个都不行。能连上之后下第一段程序别搞复杂的就一条LD X0 OUT Y0下载、运行、把 X0 短接到 COM 或者 24V看 Y0 对应的指示灯亮不亮。这一条通了说明从「上位机发帧」到「协议解析」到「程序存储」到「指令执行」再到「输出刷新」整条链路全通了。后面再复杂的梯形图都只是在这条链路上加指令。注意下程序前先确认 EEPROM 里的程序区是干净的。有些板子第一次上电时 EEPROM 是 0xFF程序长度字段读出来是个巨大值解释器会一路扫到越界。加个长度上限校验比事后查半天强。4. 核心模块精读软元件内存、指令执行引擎与编程口协议栈到这一层能跑起来已经不是问题了问题是跑得对不对、改得动不动。下面四块是这套源码里最值得逐行读的地方也是二次开发时改动最频繁的地方。4.1 软元件内存位元件用位图字元件用数组内存布局决定了整个系统的性能上限。位元件用位图是唯一选择一个字节存 8 个点X 区 128 个点只占 16 字节。用uint8_t数组而不是uint32_t是因为 STM32 是 32 位机但位图访问都是单字节操作用 8 位数组反而省内存也好算下标。/* plc_mem.h —— 全部静态分配运行期一次 malloc 都没有 */ #define X_WORDS 16 /* 128 个输入点 */ #define Y_WORDS 16 #define M_WORDS 256 /* 2048 个辅助继电器 */ #define D_COUNT 2000 /* 2000 个数据寄存器按 RAM 裁剪 */ typedef struct { uint8_t X[X_WORDS]; /* 位元件X0..X177 */ uint8_t Y[Y_WORDS]; uint8_t M[M_WORDS]; /* 其中 M500..M1023 为掉电保持段 */ uint16_t D[D_COUNT]; /* 其中 D200..D511 为掉电保持段 */ uint16_t T_cur[256]; /* 定时器当前值 */ uint16_t C_cur[256]; /* 计数器当前值 */ } plc_mem_t; extern plc_mem_t g_plc; /* 位访问用宏省掉函数调用形参 n 是线性位下标不是八进制编号 */ #define GET_BIT(arr, n) ((arr)[(n) 3] (1u ((n) 7))) #define SET_BIT(arr, n) ((arr)[(n) 3] | (1u ((n) 7))) #define CLR_BIT(arr, n) ((arr)[(n) 3] ~(1u ((n) 7)))n 3是除以 8 得到字节下标n 7是取余得到字节内的位号这是位图的标准玩法比查表快也比函数调用省。宏的副作用要注意SET_BIT(g_plc.Y, i)这种写法会把i自增两次因为宏展开后n出现两遍。用宏就老老实实传变量别塞表达式。掉电保持段为什么不单独开内存因为用户视角里 M500 和 M0 是一类东西分开存会让「读 M 区某一段」这种批量操作变得很别扭。做法是在保存和恢复时按地址区间切片memcpy到 EEPROM 的连续区域恢复时再memcpy回来。切片边界做成两个宏HOLD_M_START和HOLD_M_END改范围只改这两个数。定时器和计数器单独开数组是因为它们的当前值需要每周期更新而且位数是 16 位。T 和 C 的触点状态是否到达设定值则存在 M 区或者单独一个位图里靠比较当前值和设定值算出来。这种「当前值 设定值 比较」的模型比给每个定时器开一个结构体省一半内存。4.2 指令执行引擎acc 栈的梯形图求值梯形图本质是一串布尔表达式指令表是它的线性化形式。执行引擎要做的就是按顺序求值中间结果存一个累加器acc遇到逻辑块串并联时用栈暂存。这是最经典也最可靠的做法比递归下降省栈空间比先建语法树省内存。/* plc_exec.c —— 栈式求值acc 存当前结果stk 存逻辑块中间态 */ static uint8_t acc; static uint8_t stk[8]; static uint8_t sp; void plc_exec_program(void) { uint16_t pc 0; acc 0; sp 0; while (pc g_prog.len) { const plc_inst_t *ins g_prog.code[pc]; switch (ins-op) { case OP_LD: acc GET_BIT(g_plc.X, ins-no) ! 0; break; case OP_LDI: acc (GET_BIT(g_plc.X, ins-no) 0); break; case OP_AND: acc acc (GET_BIT(g_plc.X, ins-no) ! 0); break; case OP_OR: acc acc || (GET_BIT(g_plc.X, ins-no) ! 0); break; case OP_ANB: acc (stk[--sp] acc); break; /* 串联两个块 */ case OP_ORB: acc (stk[--sp] || acc); break; /* 并联两个块 */ case OP_OUT: if (acc) SET_BIT(g_plc.Y, ins-no); else CLR_BIT(g_plc.Y, ins-no); break; case OP_MOV: g_plc.D[ins-no] g_plc.D[ins-no2]; break; case OP_CJ: if (acc) { pc find_label(ins-no); continue; } break; case OP_END: return; default: break; } pc; if (g_guard SCAN_GUARD_MAX) break; /* 防跑飞 */ } }acc就是梯形图里那条从左母线往右走的「能流」LD装第一个触点AND/OR串并联后续触点OUT把能流落到线圈上。ANB和ORB是块运算比如(X0 OR X1) AND (X2 OR X3)编译成指令表是LD X0 / OR X1 / LD X2 / OR X3 / ANB。执行到第二个LD时第一个块的结果必须先存起来否则acc被覆盖就丢了 —— 所以LD类指令在sp 0或者已经有过一次LD时要先把acc压栈。这段压栈逻辑通常不在switch里而在指令译码阶段就确定好了运行期只管弹。stk[8]八层深度够不够绝大多数梯形图的嵌套不超过四层八层是留了余量。真遇到超深嵌套与其加栈不如在译码阶段报错让用户重构梯形图 —— 现场维护的人看图也受不了太深。CJ跳转要小心它改的是pc而不是acc所以执行完必须continue跳过末尾的pc否则会跳过跳转目标的下一条指令。这个小细节漏掉现象是「跳转指令后面的第一条指令永远不执行」非常隐蔽。g_guard是死循环保护。用户可能写出CJ跳回自己的程序PC 会一直在里面转。设个上限超过就中止本周期并置一个错误标志上位机能读到现场也能看到故障灯亮。这比看门狗直接复位好 —— 复位会丢掉所有软元件状态故障反而更难定位。4.3 编程口协议栈帧结构、校验与超时重传编程口的帧结构是这套固件里唯一必须严格对外兼容的部分错一个字节上位机就不认。帧格式是定长的五个部分字符型传输不是二进制部分长度说明STX1 字节固定 0x02帧开始CMD1 字节命令码批量读、批量写、读型号等DATAN 字节命令参数和载荷ASCII 十六进制字符ETX1 字节固定 0x03帧结束SUM2 字节前面所有字节累加和取低 8 位转成两个 ASCII 字符校验算法是字节累加取低 8 位再把结果拆成两个十六进制字符。注意累加范围到 ETX 为止不含 SUM 本身这个边界写错就永远校验不过。/* plc_proto.c —— 解析一帧返回错误码而不是布尔值方便定位 */ int plc_frame_parse(const uint8_t *buf, uint16_t len, plc_frame_t *out) { uint16_t sum 0; uint8_t hi, lo; if (len 6) return ERR_SHORT; if (buf[0] ! 0x02) return ERR_STX; if (buf[len - 3] ! 0x03) return ERR_ETX; for (uint16_t i 0; i len - 2; i) sum buf[i]; /* 含 STX 到 ETX */ sum 0xFF; hi (sum 4) 0x0F; lo sum 0x0F; /* 这里简化了实际要转成 ASCII 字符比较 */ if (ascii_hex(buf[len - 2]) ! hi || ascii_hex(buf[len - 1]) ! lo) return ERR_SUM; out-cmd buf[1]; out-data buf[2]; out-dlen len - 5; /* 去掉 STX/CMD/ETX/SUM×2 */ return OK; }返回错误码而不是bool是为了在调试口打出具体原因。ERR_SUM说明线路有干扰或者双方算法不一致ERR_STX说明帧同步丢了这时候要丢弃缓冲区重新找帧头而不是把整帧当垃圾扔掉继续等 —— 后者会导致后面所有帧都错位。超时重传是协议层必须自己做的。上位机发一帧固件要在规定时间内应答超时上位机会重发。固件的策略是「收到完整帧才处理处理完立刻应答」不要做缓冲攒批。有人为了减轻 CPU 负担把应答攒到下一个扫描周期再发结果扫描时间一长就超时上位机判定通信失败。通信应答必须放在中断或者主循环的高优先级分支里宁可打断一次指令执行。还有一个容易忽略的点半双工收发器的方向切换。用 485 或者 422 芯片时发送完最后一个字节到切换成接收中间要留至少一个字节时间的延时。切早了最后一个字节被截断切晚了对方发来的应答撞上你还在发送状态。常见的做法是在 UART 的 TC 中断里切方向而不是在 TXE 中断里 —— TC 才代表移位寄存器真的发完了最后一个位。4.4 掉电保存双区轮换 CRC 的程序区掉电丢程序是这类设备最致命的问题用户在现场改了一下午的梯形图一断电全没了第二天找上门。根因不在 EEPROM 本身在于写入策略。EEPROM 写入是个慢过程页擦除要几毫秒字节写要几毫秒。如果在写入过程中掉电正在写的那一页就是半新半旧的垃圾数据上电读出来长度字段可能是 0xFFFF解释器直接跑飞。解决办法是双区轮换加序号/* bsp_eeprom.c —— A/B 两区交替写永远有一个区是完整的 */ #define SLOT_A 0 #define SLOT_B 1 typedef struct { uint32_t seq; /* 单调递增越大越新 */ uint16_t crc; /* 覆盖 len data */ uint16_t len; } ee_hdr_t; int ee_save_program(const void *prog, uint16_t len) { uint32_t s1 ee_read_seq(SLOT_A); uint32_t s2 ee_read_seq(SLOT_B); int slot (s1 s2) ? SLOT_A : SLOT_B; /* 覆盖旧的那个 */ ee_erase_slot(slot); if (ee_write_slot(slot, prog, len) ! OK) return -1; ee_write_seq(slot, (s1 s2 ? s1 : s2) 1); /* 序号最后写 */ return slot; }核心在「序号最后写」。数据写完、CRC 校验通过之后才更新seq。如果写到一半掉电seq还是旧的上电时两个区比较序号选大的那个正好是上一次完整写入的区。CRC 是第二道保险防止 EEPROM 长期使用后某些位自然翻转导致序号对但数据错。上电加载时不能只看序号还要校验 CRC 和长度上限。长度超过MAX_PROG_LEN直接判该区无效去读另一个区。两个区都无效就进「无程序」状态所有输出清零等上位机重新下载。这个状态一定要有对应的指示灯或者故障码否则现场看到的是「设备不动也不知道为什么」。写入频率要控制。有些实现每改一次梯形图就整片重写EEPROM 寿命十万次一个月改几十次的调试期两年就写坏了。常见的做法是加个「脏标记」程序改动只在 RAM 里生效用户显式点了「写入 PLC」或者断电前才落盘。更稳妥的是延时落盘改动后 3 秒没有新改动才写避免连续调整时反复擦写。注意ECC 或者带磨损均衡的外部存储芯片初始化时要等内部就绪。上电立刻读前几毫秒读出来全是 0会被误判成「程序区为空」然后自动进无程序状态用户以为程序丢了。加个 10ms 延时再读省无数扯皮。5. 避坑排查上电不跑、通信超时、掉电丢程序的五条血泪记录下面五条都是我或者身边的人真踩过的按「现象 → 原因 → 解决」写现场遇到时可以当检查表用。5.1 现象编译零错误下载后板子一动不动灯也不闪原因几乎都在启动阶段就挂了。最常见的三个一是链接脚本里的 RAM 长度和实际芯片不符栈顶指到不存在的地址上电第一条指令就 HardFault二是中断向量表偏移没设SCB-VTOR还是默认值中断一来就跳到错误地址三是看门狗在初始化之前就被使能了有些芯片选项字节里默认开初始化还没跑完就被复位表现为「灯闪一下就灭反复重启」。解决顺序是先用调试器单步看能不能停在main的第一行。停不住就是启动文件或者链接脚本的问题逐个核-mcpu、.ld里的FLASH和RAM长度、Reset_Handler是否指向正确的SystemInit。能停住但跑两步就飞看HardFault_Handler里的LR寄存器能反推出是从哪个函数跳过来的。看门狗的问题更隐蔽先把IWDG初始化那段注释掉再试能稳定跑就说明是喂狗时机不对。5.2 现象上位机连不上一直提示通信超时偶尔能连上又断先分清是物理层还是协议层。用示波器或者逻辑分析仪量编程口的 TX 线上位机发命令时应该有波形。没波形说明线接错了或者收发器方向控制不对 —— 半双工芯片的 DE 引脚如果一直拉低芯片永远在接收状态根本发不出去。有波形但通信不上看波特率和校验位。FX 编程口的习惯配置是 9600、8 位数据、偶校验、1 位停止。配成无校验是最常见的错误因为大多数调试串口都是 8N1习惯了。校验位不对的话收到的字节最高位随机翻转帧头 0x02 可能变成 0x82解析直接判ERR_STX。「偶尔能连上又断」这种玄学现象八成是收发方向切换的延时不够。发送完最后一个字节立刻切接收最后一个停止位还没发完就被截断对方收到的帧校验不过。在 TC 中断里切并且加一个字节时间的延时能解决绝大部分。5.3 现象断电再上电梯形图没了或者变成乱码输出乱动先确认是「没存进去」还是「存进去读错了」。断电前用上位机读一次程序能读出来说明 RAM 里是对的再断电重启读一次读不出来或者 CRC 错问题在存储。存不进去的常见原因是写入时序。EEPROM 页写之后需要内部写周期这段时间芯片不响应任何命令代码里如果不等待就发下一条后面的数据全丢。查手册看Twr典型值写完一页延时够再继续。另一个原因是写保护引脚没拉对有些人硬件上直接把 WP 接地了看起来能读能写实际上写进去的数据被芯片自己丢掉了。读出来是乱码多半是加载时没校验。长度字段读出来是 0xFFFF 就直接 memcpy程序区后面全是 0xFF解释器把 0xFF 当指令码执行输出当然乱动。加载前必须三段校验长度在合理区间、CRC 对得上、指令码在合法范围内。三道都过才认为是有效程序任何一道不过就切到备份区。5.4 现象高速计数器接编码器低速正常一快就丢脉冲根因在中断里干了太多事。高速计数如果靠外部中断逐个数编码器一转几千个脉冲中断频率上去了主循环被挤得没时间跑扫描丢脉冲是必然的。表现是低速时计数准速度一过某个值就开始少计而且少得很稳定 —— 因为每次都在同一个耗时点丢。正确的做法是用定时器的硬件编码器模式或者输入捕获加 DMACPU 完全不参与计数只在扫描周期开始时读一次计数值。F4 系列的定时器基本都支持编码器接口两路正交信号直接接CH1和CH2配成编码器模式后计数值自动增减读寄存器就行。如果硬件上确实只能走外部中断那至少要做两件事中断函数里只做计数加一别做滤波、别做比较、别调用任何HAL_函数输入加 RC 滤波硬件滤掉毛刺。软件滤波放在扫描周期里做别放中断里。5.5 现象输出继电器抖动或者扫描时间忽长忽短输出抖动先看刷新时机。如果代码里在指令执行过程中就写 GPIO用户程序还没跑完输出已经变了自锁和互锁逻辑全乱。必须等整个程序跑完一次性把 Y 区刷到输出锁存器。这就是前面说的三段式顺序不能动。扫描时间忽长忽短找那条耗时不稳定的指令。最常见的元凶是浮点运算和 32 位除法 —— M3 内核没有硬件除法一次 32 位除要上百个周期程序里要是有几十条扫描时间能翻几倍。另一个是字符串或者数组的循环操作比如批量MOV几百个字每周期都做一遍。排查手段是在扫描周期里打点。取一个空闲的 GPIO在plc_io_sample前拉高、plc_io_refresh后拉低示波器上看这个方波的宽度就是扫描时间配合指令计数能定位到具体哪一段。这个方法比打串口日志可靠因为串口本身就有毫秒级开销测不准。6. 二次开发给这套固件补一路 Modbus RTU 从站并把脉冲输出交给 DMA这套源码最常见的两个改造需求一是接触摸屏或者上位机组态软件二是带步进或者伺服做定位。前者要 Modbus 从站后者要脉冲输出不占 CPU。两块都不难但都有个前提别动指令执行层。Modbus 从站的核心是地址映射。把 D 区映射成保持寄存器4x 区X 和 Y 映射成线圈0x 区M 区映射成离散输入或者第二组线圈。映射函数写成一个 switch加一层越界判断其余交给现成的协议栈/* modbus_slave.c —— 只做地址翻译不碰业务逻辑 */ uint16_t mb_read_holding(uint16_t addr) { if (addr 0x1000 || addr 0x1000 D_COUNT) return 0; return g_plc.D[addr - 0x1000]; /* 4x00001 对应 D0 */ } void mb_write_holding(uint16_t addr, uint16_t val) { if (addr 0x1000 || addr 0x1000 D_COUNT) return; /* 掉电保持段禁止从通信口随意写避免和用户程序抢 */ if (addr - 0x1000 HOLD_D_START addr - 0x1000 HOLD_D_END) return; g_plc.D[addr - 0x1000] val; }映射表要写进文档给组态的人看否则现场对地址能耗一整天。建议 D0 对应 4x00001X0 对应 0x00001Y0 对应 0x10001M0 对应 0x20001 这类规整偏移别搞跳跃映射。脉冲输出改 DMA 的思路是让定时器自己跑DMA 负责数个数。定时器配成 PWM 模式频率由ARR决定占空比固定 50%DMA 挂在更新事件上每来一个周期搬运一次递减的计数值减到 0 就关定时器。CPU 全程不参与扫描周期完全不受影响/* bsp_pulse.c —— 频率决定 ARR个数交给 DMA 递减 */ void pulse_start(uint8_t ch, uint32_t hz, uint32_t count) { TIM_TypeDef *tim (ch 0) ? TIM2 : TIM5; tim-ARR (SystemCoreClock / hz) - 1; /* 分频按实际总线时钟算 */ tim-CCR1 tim-ARR / 2; /* 50% 占空比 */ dma_load_count(tim, count); /* 缓冲区装脉冲个数 */ tim-CR1 | TIM_CR1_CEN; }SystemCoreClock这里要注意如果定时器挂在 APB1 上而 APB1 有分频实际时钟是PCLK1 × 2直接用SystemCoreClock算出来的频率会差一倍。查一下 RCC 配置里 APB1 的分频系数别想当然。验证方法很直接拿示波器量输出脚看频率和个数。设定的 10kHz、1000 个脉冲示波器上应该是一个宽度 100ms 的方波串数一下个数对不对。个数不对通常是 DMA 缓冲区长度写错了或者更新事件的触发源选成了比较匹配而不是溢出。改完之后我的习惯是拿一段最简梯形图跑一遍回归LD X0 / PLSY K1000 K1000 Y0按一下按钮示波器上必须出现精确的 1000 个脉冲同时扫描时间不能有明显波动。从那以后我每次动定时器或者 DMA 配置都会先跑这个最小用例再往下加功能 —— 底层外设的坑往往要到功能堆上去之后才暴露那时候回头查成本就高了。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

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

立即咨询 →