简介:面向工业自动化与嵌入式开发者的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 和 X9;M、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 \ -mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard \ -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,-Map=build/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-abi=soft和硬件 FPU 混用时,中断里保存现场的方式不对,表现为「主循环好好的,一进串口中断就死」;.ld文件里的 RAM 长度照抄了别的板子,栈顶指针指到不存在的地址,上电第一件事就是进 HardFault 死循环。
时钟配置是第二个坑。这类固件通常要跑高速计数和脉冲输出,主频不能太低。F103 一般配到 72MHz,F407 配到 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, sysclk=168000000,说明时钟、串口、时钟树三者一致。收到乱码说明波特率算错了,多半是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 编程口协议栈:帧结构、校验与超时重传
编程口的帧结构是这套固件里唯一必须严格对外兼容的部分,错一个字节上位机就不认。帧格式是定长的五个部分,字符型传输,不是二进制:
| 部分 | 长度 | 说明 |
|---|---|---|
| STX | 1 字节 | 固定 0x02,帧开始 |
| CMD | 1 字节 | 命令码,批量读、批量写、读型号等 |
| DATA | N 字节 | 命令参数和载荷,ASCII 十六进制字符 |
| ETX | 1 字节 | 固定 0x03,帧结束 |
| SUM | 2 字节 | 前面所有字节累加和取低 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 现象:高速计数器接编码器,低速正常,一快就丢脉冲
根因在中断里干了太多事。高速计数如果靠外部中断逐个数,编码器一转几千个脉冲,中断频率上去了,主循环被挤得没时间跑扫描,丢脉冲是必然的。表现是低速时计数准,速度一过某个值就开始少计,而且少得很稳定 —— 因为每次都在同一个耗时点丢。
正确的做法是用定时器的硬件编码器模式或者输入捕获加 DMA,CPU 完全不参与计数,只在扫描周期开始时读一次计数值。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 对应 4x00001,X0 对应 0x00001,Y0 对应 0x10001,M0 对应 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 配置,都会先跑这个最小用例再往下加功能 —— 底层外设的坑,往往要到功能堆上去之后才暴露,那时候回头查成本就高了。希望帮到你。
本文还有配套的精品资源,点击获取