简介:面向工业控制与嵌入式开发者的三菱FX3U PLC底层源码资料,基于STM32F407平台构建全新程序架构,适合研究PLC指令系统、通信扩展与在线监控实现的中高级读者。资源包共7个文件,包含4个txt说明、1个jpg图示、1个html页面和1个doc文档,整体约423KB,便于按说明、图示与文档结合查阅。源码在V50.0版本中补充了ZCPP、DMOVP、FMOVP、DADD、DIVP等120余条指令,并支持以太网与4G模块扩展;一路485口可在编程口协议和Modbus RTU间通过D8120切换,还修复了D8000~D255在线监视及卡死问题。已有129人学习,适合对照注释理解底层调度、指令实现和通信配置,快速建立FX3U软硬件协同的排查思路。
1. 板子烧完第一次上电,RUN 灯不亮
把 FX3U 源码往 STM32F407 上搬的人,八成都在同一个地方卡过:工程编译零警告、烧录工具提示成功、复位之后 RUN 灯不亮,串口也只吐一串乱码。我头一回接手这套 V50.0 源码时就栽在这儿,查了两天才发现是晶振宏和实际贴片对不上,跟代码逻辑一行关系都没有。这类问题最坑的地方在于它伪装成"代码有 bug",让人一头扎进指令表和软元件映射里翻,越翻越远。适合正把 FX3U 架构往 STM32F407 移植、准备挂以太网和 4G 模块的开发者看。当时我拿正则表达式把整个工程里所有HSE_VALUE相关的定义扫了一遍,才把范围从八千行缩到三行。
2. 编译一上来就红一片:寄存器宏重定义、位域宽度与批量改名的正则写法
拿到一套"全新程序架构、注释详细"的 FX3U 源码,第一反应通常是直接拖进 Keil 或 CubeIDE 编译,然后收获几十条错误。别急着一条条看,先分类。绝大多数红字集中在三个位置:符号重定义、位域宽度、以及 C 标准差异。这三类错误的修法完全不同,混在一起看只会越改越乱。
2.1 先把报错分成三类,别一上来就改代码
把编译输出重定向到文件,用一次简单的过滤就能看清主次:
# 把编译日志里的错误行抽出来,按错误码聚合并计数 grep -oE 'error: #[0-9]+' build.log | sort | uniq -c | sort -rn | head -20这条命令做两件事:grep -oE把每行里的错误码单独抠出来(-o只输出匹配部分,-E用扩展正则),uniq -c统计每种错误码出现次数,最后按数量倒序排。跑完你会发现前三种错误码通常占了八成以上,剩下的零散错误往往是前三种的连锁反应,主因修完自己就消失了。
我一般的处理顺序是:先修符号冲突,再修语言标准,最后才动位域。理由是符号冲突会让编译器在解析阶段就放弃,后面的报错全是噪音;而位域问题只在真正访问软元件映射表时才暴露,属于运行期隐患,可以放到编译通过之后单独处理。
提示:不同工具链的错误码编号不通用,
#20在 ARMCC 里是标识符未定义,在 GCC 里可能根本不存在。用错误码聚合只能用来定位主次,具体含义还得看错误原文。
2.2 寄存器与宏重定义:符号冲突的真实来源
FX3U 源码原本跑在别的平台上,它自己定义了一套 GPIO、定时器、串口的寄存器结构体和操作宏。而 STM32F407 的 CMSIS 头文件里也有一套同名的GPIO_TypeDef、TIM_TypeDef、__IO。两套东西一撞,编译器就会报multiple definition或者类型冲突。
常见的做法不是去改 CMSIS,也不是把源码里的定义全删掉,而是在源码的硬件抽象层外面加一层薄封装,把头文件包含顺序和符号名隔开:
/* hal_bridge.h —— 隔离层:只在实现文件里包含 CMSIS,头文件只暴露中性类型 */ #ifndef HAL_BRIDGE_H #define HAL_BRIDGE_H #include <stdint.h> /* 对外只暴露不冲突的类型名,避免 GPIO_TypeDef 这类符号泄漏到业务代码 */ typedef struct plc_gpio_map plc_gpio_map_t; /* 端口初始化:port 取值 0~4 对应 PA~PE,mask 为位掩码 */ void plc_gpio_init(uint8_t port, uint16_t mask, uint8_t mode); /* 批量读输入点:把 16 个 X 点打包成一个字节对,返回读到的位数 */ uint16_t plc_gpio_read_input(uint8_t group); #endif关键点在注释里已经写了:CMSIS 的头文件只在.c实现里包含,.h里一个都不出现。这样业务代码编译时看不到GPIO_TypeDef,自然不会冲突。plc_gpio_map_t用不完整类型声明,是为了让上层能持有指针但不能解引用,强迫所有硬件访问都走封装函数。
参数上,port用 0~4 而不是直接传GPIOA,是为了让上层代码和具体芯片解耦;mask用 16 位而不是 32 位,是因为 FX3U 的 X/Y 点按八进制编址,一组最多 8 个点,两组打包刚好一个字节对,和软元件表的索引方式对得上。
如果源码里已经到处直接写了GPIOA->ODR = ...,那就要做一次批量替换。这一步很容易出错,建议先用正则做 dry-run,看清楚会改哪些地方再落盘:
# dry-run:把所有直接操作 ODR 的语句列出来,但不修改文件 grep -rnE '\bGPIO[A-E]->(ODR|IDR|MODER)\b' --include='*.c' --include='*.h' src/ \ | sed -E 's/^([^:]+):([0-9]+):.*(GPIO[A-E]->[A-Z]+).*/\1:\2 \3/'-rnE表示递归、显示行号、用扩展正则;--include限定只扫 C 源文件,避免把编译产物也扫进来;管道后面的sed用捕获组把"文件:行号 命中的寄存器"重新拼成一行,输出比原始 grep 结果干净得多,方便你按文件分组估算改动量。这个 dry-run 不做任何修改,跑完看一眼数量,几十处就手工改,上千处再考虑写脚本批量替换——上千处的场景下,直接上sed -i前务必先git commit。
2.3 位域宽度和 pack:软元件映射表为什么对不上
编译过了,程序也跑起来了,但 D 寄存器的值读出来是错的、M 点的状态串位,这类问题的根因经常是位域。FX3U 的软元件表大量使用位域来描述状态字,比如把 16 个 M 点塞进一个uint16_t。不同编译器对位域的内存布局、对齐规则、以及跨存储单元的填充方式处理不一样,源码在旧平台上是对的,换到 ARMCC 或 GCC 上就可能整个错位。
判断方法很直接:写一段自检代码,把结构体的大小和每个字段的偏移打出来,跟设计预期对一遍。
/* 编译期自检:位域结构体的大小和偏移必须和软元件表的设计一致 */ #include <stddef.h> #include <stdio.h> typedef struct { uint16_t m0 : 1; /* M0 占 1 位 */ uint16_t m1 : 1; uint16_t m2 : 1; uint16_t rsv : 13; /* 预留 13 位,凑满 16 位 */ } m_status_t; /* 若断言失败,说明当前工具链的位域布局与预期不符,需改用显式位操作 */ _Static_assert(sizeof(m_status_t) == 2, "m_status_t 必须是 2 字节"); void dump_layout(void) { printf("size=%zu m0_off=%zu\n", sizeof(m_status_t), offsetof(m_status_t, m0)); }_Static_assert是编译期检查,条件不成立直接编译失败,比运行到一半才发现串位强得多。offsetof需要<stddef.h>,打印出来的是字段相对结构体起始地址的字节偏移。参数上要注意:rsv这类预留位一定要显式写出来并给足宽度,否则编译器可能自行对齐到 4 字节,sizeof就变成 4 了。
如果断言确实失败,别去调编译器的 pack 选项硬凑——#pragma pack(1)会影响整个文件里所有结构体,副作用太大。正确做法是把位域改成显式的移位和掩码操作,代价是代码啰嗦一点,但跨平台行为完全确定。这也是我处理老代码移植时的一条硬规矩:凡是跨越硬件和业务边界的结构体,一律不用位域。
2.4 用正则批量改名和扫宏,而不是逐个点开改
源码里如果有大量命名风格不统一的前缀(比如DREG、d_reg、D_Reg混着用),手工改到崩溃也没改完。这时候正则的批量预览能力就值钱了。下面这条命令把三种写法统一映射成DR_前缀,先只打印不改:
# 预览:把多种前缀写法统一成 DR_<n>,只输出转换结果,不写回文件 grep -rnoE '\b[Dd]_?[Rr][Ee][Gg][0-9]+\b' --include='*.c' --include='*.h' src/ \ | sed -E 's/([^:]+):([0-9]+):.*[0-9]+$/\1:\2/' \ | sort -u | wc -lgrep -o只输出匹配到的符号本身,\b是单词边界,防止把MY_DREG1X这种无关符号也匹配进来;[Dd]_?[Rr][Ee][Gg]覆盖了大小写和下划线有无的组合。后面接的sed只保留"文件:行号",再sort -u去重、wc -l计数,得到一个"涉及多少个不同位置"的数字。这个数字是决策依据:小于 50 就手工,大于 50 再上批量替换脚本,并且替换脚本必须先在副本目录上跑一遍并diff验证。
同样的思路可以用来扫宏定义冲突。下面这条能找出工程里所有重复定义的宏名,是排查multiple definition的入口:
# 找出在多个头文件里重复定义的宏名(同一宏名出现超过一次即列出) grep -rhoE '^[[:space:]]*#[[:space:]]*define[[:space:]]+[A-Za-z_][A-Za-z0-9_]*' \ --include='*.h' src/ \ | sed -E 's/.*define[[:space:]]+//' | sort | uniq -duniq -d只输出重复项,正好是要找的目标。注意#[[:space:]]*define这种写法是为了兼容宏定义前面有缩进的情况,老代码里这种格式很常见。
3. 烧录成功却不跑:向量表、时钟树和 BOOT 引脚的四个坑
编译过了,烧录器也返回成功,但程序就是不跑。这一层的排查难点在于"没有报错"——你手上只有一个不亮的灯和一段乱码,没有任何提示。我的习惯是从复位那一刻开始,沿着"芯片取指 → 时钟起来 → 进 main"这条链倒着查。
3.1 烧录工具说成功,不等于程序跑起来了
烧录工具校验的是 Flash 里的字节和你给的文件一致,它不负责验证这段代码能不能跑。所以"烧录成功"这个信息量几乎为零。真正要确认的是三件事:芯片是否真的从你烧的地址取指、时钟是否起振、main是否被执行到。
最省事的确认方式是在main第一行点亮一个 LED 或者翻转一个引脚,然后示波器或者万用表看一眼。如果这一步都过不去,问题百分之百在启动阶段,跟业务代码无关。别在这个时候去翻指令表,那是浪费时间。
如果连烧录都不确定,可以用调试器直接读内存,确认 Flash 里的内容和你编译出的 bin 一致:
# 用命令行调试器读取 Flash 起始处 16 字节,和本地 bin 的前 16 字节对比 xxd -l 16 build/app.bin # 输出示例:00000000: 0030 0x0800 中的初始栈顶值 + Reset_Handler 地址xxd -l 16只打印前 16 字节,前 4 字节是初始栈顶指针(MSP 初值),紧接着 4 字节是复位向量(Reset_Handler地址)。这两个值必须落在 SRAM 和 Flash 的合法范围内。如果前 4 字节是0x00000000或者0xFFFFFFFF,说明链接脚本或者启动文件根本没用上,链接出来的镜像就是废的。
3.2 向量表偏移与分区:APP 里必须重设 VTOR
只要工程里带了 Bootloader,或者 Flash 被划成了几块(以太网协议栈、4G 驱动、PLC 逻辑分开放),就必须处理向量表偏移。Cortex-M4 复位后默认从0x08000000取向量表,如果你的应用实际烧在0x08008000,那它取到的还是 Bootloader 的向量,中断一进来就跑飞。
解决分两步,缺一不可。第一步改链接脚本,把代码段起始地址挪过去:
/* app.ld —— 应用分区从 0x08008000 开始,前 32KB 留给 Bootloader */ MEMORY { FLASH (rx) : ORIGIN = 0x08008000, LENGTH = 480K RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K } /* 显式导出向量表符号,供 C 代码引用 */ _vectors_start = ORIGIN(FLASH);第二步在main的最开头、任何中断使能之前重设向量表寄存器:
/* 必须在使能任何外设中断之前执行,否则第一次中断就会跑错地址 */ extern uint32_t _vectors_start; SCB->VTOR = (uint32_t)&_vectors_start; __DSB(); /* 数据同步屏障:确保 VTOR 写入完成 */ __ISB(); /* 指令同步屏障:清空流水线,让后续取指生效 */__DSB和__ISB这两条不是可选项。Cortex-M4 有写缓冲和流水线,写完VTOR立刻使能中断,有可能中断进得很早而新向量表还没生效。加屏障的开销可以忽略,省掉它换来的是偶发性的、极难复现的跑飞。
LENGTH的算法也要注意:0x08008000到0x08080000是 480KB,如果你后面还要划一块参数存储区,得从 480K 里扣掉,否则运行期写 Flash 参数会覆盖代码——这个坑我在第 5 章还会提到。
3.3 HSE_VALUE 与实际晶振不一致,串口和以太网一起废
这是第 1 章那个坑的真身。工程里有个HSE_VALUE宏,定义了外部晶振的频率,系统时钟树、串口波特率、以太网 RMII 的 50MHz 参考时钟全都从它推导。板上贴的是 8MHz,宏写的 25MHz,编译器一声不吭——因为宏的值语法上完全合法。结果是:时钟树算出来的 PLL 参数是错的,实际主频远低于预期,串口波特率跟着错,打印出来就是乱码;以太网那边 PHY 拿不到准确的参考时钟,链路根本起不来。
查法很简单,但要查全。用一条命令把所有出现的地方列出来,逐个核对:
# 列出工程里所有定义或引用晶振频率宏的位置,逐个人工核对 grep -rnE '\b(HSE_VALUE|HSI_VALUE|LSE_VALUE)\b' --include='*.h' --include='*.c' .\b边界符防止匹配到MY_HSE_VALUE_OLD这类变体。跑完你会看到它在system_xxx.c、stm32f4xx_conf.h、以及可能的board.h里各定义了一次——以最后一次预处理生效的为准,所以要确认包含顺序。我一般直接把这三处的值改成一致并在旁边加注释写清板载晶振型号,避免下一个人再踩。
注意:改了
HSE_VALUE之后,串口的分频系数是运行期算的还是编译期算的,要分清楚。如果是编译期通过宏算的,改完必须全量重编;只增量编译某一两个文件,可能出现一部分模块用新值、一部分用旧值的情况,现象比原来更诡异。
验证是否修好,最直接的办法是量一个确定频率的输出——比如配置一个 1kHz 的方波引脚,示波器一测就知道主频对不对。比看串口有没有乱码可靠得多,因为串口自适应的容错范围有时能掩盖小的频率偏差。
3.4 BOOT 引脚与看门狗:程序还没到 main 就被按住
还有两个纯硬件层面的坑,跟代码完全无关但症状一模一样。第一个是 BOOT 引脚电平不对,芯片进了系统存储器启动模式,压根不从 Flash 取指,你烧什么进去都没用。这个用万用表量一下复位瞬间的电平就能排除。
第二个更隐蔽:外部看门狗芯片或者内部看门狗在复位后默认就是使能的,main里第一行代码还没执行到喂狗,芯片已经被复位了。表现出来就是"程序好像在跑但一直重启",串口偶尔能吐出半行字符。处理办法是在启动文件里、进main之前就把看门狗关掉或者先喂一次:
/* 在 SystemInit 末尾、跳转 main 之前执行:先喂一次狗,给初始化留出时间 */ /* IWDG 一旦启动无法关闭,只能通过重装载争取时间 */ IWDG->KR = 0xAAAA; /* 重装载计数器 */ IWDG->KR = 0x5555; /* 解锁 PR/RLR 寄存器 */ IWDG->RLR = 0xFFF; /* 把重装载值拉到最大,约 26 秒 @32kHz LSI */0xAAAA是重装载命令,0x5555是写使能命令,这两个魔数在参考手册里有,写错一个就完全没效果。RLR拉满只是争取时间,正经做法还是尽快在初始化完成后把喂狗逻辑挂到主循环里,第 5 章会讲喂狗位置放错的后果。
4. 以太网和 4G 连不上:链路层到 AT 指令的超时链路
FX3U 源码这一版主打的就是挂以太网和 4G 模块做远程通信。通信类故障的特点是"看起来连上了",connect返回成功、socket 句柄有效,但数据死活收不到。排查必须严格分层,跳层查会浪费大量时间。
4.1 以太网这边先确认链路,再谈协议
分层顺序是:PHY 链路 → ARP → IP → TCP → 应用层。绝大多数"连不上"卡在第一层。STM32F407 内置 MAC,外面接 PHY,走 RMII 接口,需要 50MHz 参考时钟。三个最常见的失败点:
第一,PHY 地址配错。PHY 的 SMI 地址由硬件引脚决定,代码里配置的地址必须和板上实际一致,否则 MDIO 读回来的全是0xFFFF。第二,RMII 参考时钟来源搞错,有的板子从 MCU 的 MCO 引脚输出,有的从外部晶振来,配置错了 MAC 就没有时钟。第三,MAC 地址全零或者全是广播地址,交换机侧直接丢弃。
先确认 MAC 能不能读到 PHY 的 ID 寄存器,这是最底层的连通性验证:
/* 读 PHY 的标识寄存器,正常应返回 0x0007 和 0xC0F1 这类厂商 ID */ uint16_t id1 = 0, id2 = 0; eth_read_phy_reg(PHY_ADDR, 0x02, &id1); /* PHYID1 */ eth_read_phy_reg(PHY_ADDR, 0x03, &id2); /* PHYID2 */ /* 读回 0xFFFF 说明 MDIO 无响应:查地址、查 MDC 时钟、查 PHY 供电 */ if (id1 == 0xFFFF && id2 == 0xFFFF) { log_error("PHY no response, check PHY_ADDR=%d and MDC clock", PHY_ADDR); }0x02和0x03是 IEEE 802.3 规定的 PHY 标识寄存器地址,任何符合标准的 PHY 都必须实现。返回0xFFFF几乎可以断定 MDIO 通信没建立,跟协议栈配置无关,别去调 lwIP 的参数。PHY_ADDR一般取 0 或 1,具体看板子上 PHY 的地址引脚接法。
链路通了之后,先ping通再谈 TCP。ping 不通但链路显示 up,八成是 IP 配置或者 ARP 有问题——比如子网掩码配成了255.255.255.255,或者网关和本机不在同一网段。
4.2 4G 模块的坑在"假连接",不在拨号
4G 模块走 AT 指令,接通过程相对标准化:查卡、查注册、激活 PDP、建 socket。真正折磨人的是接通过之后的"假连接"——模块返回CONNECT OK,你这边 socket 也认为建立了,但对端收不到任何数据。
根因通常有三个。一是透传模式没真正切进去,指令回显说切了但实际还在命令模式,你发的数据被当成 AT 指令解析掉了。二是心跳包缺失,运营商侧的 NAT 表项老化(通常几分钟到十几分钟),连接被静默回收,本地却毫不知情。三是 DNS 解析失败但你连的是域名,AT+CIPSTART返回 OK 之后一直卡在解析阶段。
判断"假连接"最有效的办法是定时发心跳并检查回执,而不是只看连接状态。心跳间隔一般取 60 秒以内,比 NAT 老化时间短一截:
/* 心跳发送与超时判定:连续 3 次无回执就主动重连,不要等 TCP 超时 */ static uint8_t hb_fail_cnt = 0; void heartbeat_task(void) { if (send_heartbeat() == 0) { /* 返回 0 表示发送成功 */ hb_fail_cnt = 0; return; } if (++hb_fail_cnt >= 3) { /* 阈值 3,兼顾误判和恢复速度 */ log_warn("heartbeat lost x3, force reconnect"); modem_close_socket(); /* 先关 socket,避免句柄泄漏 */ modem_connect_async(); /* 异步重连,不阻塞扫描周期 */ hb_fail_cnt = 0; } }阈值取 3 而不是 1,是因为单次发送失败可能只是模块忙;取 3 以上恢复太慢,PLC 侧的数据采集周期通常按秒算。重连必须异步,这一点在第 5 章会展开——同步等 AT 回执会把整个扫描周期拖垮。
提示:切透传模式用的
+++转义序列前后必须有静默期(通常几百毫秒到 1 秒),静默期不够模块不认。这个静默期在中断频繁的系统里很容易被打断,建议在切换时临时关掉高优先级中断,切完立刻恢复。
4.3 用正则把 AT 回显和日志变成可判断的状态
调试 4G 模块最痛苦的是 AT 指令的回显格式不稳定:不同固件版本的空格、引号、逗号位置都有差异,用strstr硬找子串很容易漏。正则在这里比字符串函数合适得多,尤其是把几百行串口日志离线分析的时候。
比如解析AT+CIPSTATUS的返回,格式大致是+CIPSTATUS: <n>,<type>,"<ip>",<port>,<state>,用一条正则就能把四个字段一次抓全:
import re # 匹配 +CIPSTATUS 行,容忍任意空白,抓取连接号、协议、远端 IP、端口、状态 PATTERN = re.compile( r'\+CIPSTATUS:\s*(?P<id>\d+)\s*,\s*"(?P<proto>TCP|UDP)"\s*,\s*' r'"(?P<ip>\d{1,3}(?:\.\d{1,3}){3})"\s*,\s*(?P<port>\d+)\s*,\s*(?P<state>\d+)' ) def parse_status(log_text): # finditer 而不是 findall:需要拿到每条连接的完整字段 for m in PATTERN.finditer(log_text): yield m.groupdict() # 用法:把串口捕获的日志丢进来,直接得到结构化结果 # for st in parse_status(open('uart.log').read()): # print(st['proto'], st['ip'], st['port'], st['state'])几个关键点:\s*而不是固定空格,是为了兼容不同固件的排版差异;"必须显式匹配,因为协议类型和 IP 都带引号,漏掉引号会把后续字段全错位;(?:\.\d{1,3}){3}用非捕获组限定 IP 的点分格式,比.*严谨得多——用.*的后果是遇到格式异常的行会跨行贪婪匹配,把后面几条记录一起吞掉。finditer配合groupdict直接拿到命名字段,比按索引取group(1)、group(2)可读性好,字段增删时也不容易改错位置。
在设备端做实时判断时,同样的正则思路可以用 C 里的轻量实现,但更实际的做法是:设备端只做"整行比对",把回显原样透传到日志,离线用上面的脚本分析。设备端跑复杂正则会吃 RAM 和栈,不值当。
5. 避坑记录:五个让我加班到凌晨的现场
这一章的五条都是我或者身边人真栽过的,现象看起来各不相同,共同点是"代码逻辑本身没错",错在时序、位置和边界。每条按现象、原因、解决三段说。
5.1 看门狗在中断里被饿死
现象:系统跑几分钟到几十分钟不等就重启,重启时间点没有规律,负载重的时候更容易出现。
原因:喂狗语句写在了主循环里,而主循环前面挂了一个高优先级的通信中断。通信量一大,中断几乎占满 CPU,主循环长时间得不到调度,喂狗自然断了。看门狗复位后一切正常,日志里什么都没有,所以特别难查。
解决:喂狗不能依赖任何一个"可能被饿死"的执行流。两种可靠做法:一是放到最低优先级的定时器中断里,确保它一定能被调度到;二是让各个关键任务各自上报心跳标志,主循环检查所有标志都置位才喂狗——这样任何一个任务卡死,狗都会咬。我倾向第二种,因为它能定位到具体是哪个任务卡住。诊断阶段可以临时把看门狗关掉,先确认是否真的是狗咬的,再开回来。
5.2 掉电保持区写一次丢一次
现象:D 寄存器的掉电保持功能时灵时不灵,有时候改完参数断电再上电,值回到默认。
原因:参数保存区的 Flash 地址和应用程序区重叠了。前面改链接脚本把应用挪到0x08008000、长度设成 480K,算下来末尾正好压到参数区起始地址。写参数时擦掉的其实是一段代码,程序还能跑是因为那段代码暂时没被执行到,等到某个分支跳过去就崩了。
解决:在链接脚本里显式切出一块参数区,并在代码里用编译期断言锁死边界,让越界在编译阶段就暴露:
/* 参数区单独划出来,绝对地址固定,不参与代码段的长度计算 */ MEMORY { FLASH (rx) : ORIGIN = 0x08008000, LENGTH = 448K PARAM (r) : ORIGIN = 0x08078000, LENGTH = 32K RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K }LENGTH从 480K 改成 448K,腾出的 32K 正好是参数区,两个区域的起止地址完全不相交。改完要重新算一遍:0x08008000 + 448K = 0x08078000,和参数区起始地址严丝合缝。
另外,Flash 擦除的最小单位是扇区,不是字节。写参数前必须整扇区擦除,如果参数区跨了两个扇区而只擦了第一个,第二个扇区里的旧数据会和新数据混在一起。
5.3 高速脉冲频率差一倍
现象:PLC 输出的高速脉冲,实测频率总是理论值的两倍或者一半,换个目标频率又是准的,没有规律。
原因:定时器的计数时钟源分频和预装载值算错了。脉冲输出的频率公式是"时钟 / (分频 × 重装载值)",其中重装载值通常要减一(计数从 0 开始)。老代码里沿用了别的平台"从 1 开始计数"的假设,减一减错了位置。还有一种情况是时钟源选错,本该用 APB1 定时器时钟,结果用了内部 RC。
解决:先把定时器的实际输入时钟打印出来,别靠推算。这个值在时钟树初始化之后就能读到,和手册上算出来的对比一下,不一致就说明分频配置有问题。然后统一用一个宏封装频率计算,避免公式散落在几十个地方各写各的。测试方法也要固定:用示波器量实际输出,和理论值对比误差,超过 1% 就说明分频链路有问题,不要用"看起来差不多"糊弄过去。
5.4 通信阻塞把扫描周期拖到几十毫秒
现象:一开远程通信,PLC 的扫描周期从 1 毫秒飙到几十毫秒,急停响应明显变慢,操作员能感觉到延迟。
原因:AT 指令的等待写成了同步轮询,while (!ready) ;这种。4G 模块建立连接动辄几秒,这几秒里整个 PLC 逻辑停摆。以太网那边如果用的是阻塞式 socket 发送,遇到网络拥塞同样会卡住。
解决:通信全部改成状态机加超时,绝不在主循环里等任何一个外部事件。把"连接""发送""收数据""重连"拆成独立的非阻塞状态,每次主循环只推进一小步:
/* 非阻塞通信状态机:每次调用只做一件事,立刻返回,不占扫描周期 */ typedef enum { ST_IDLE, ST_CONNECTING, ST_SENDING, ST_WAIT_ACK, ST_RETRY } comm_state_t; void comm_poll(void) { switch (state) { case ST_CONNECTING: if (at_is_ok()) { state = ST_SENDING; } else if (timeout_expired(5000)) { state = ST_RETRY; } /* 5 秒硬超时 */ break; case ST_WAIT_ACK: if (at_has_data()) { parse_reply(); state = ST_IDLE; } else if (timeout_expired(3000)) { state = ST_RETRY; } break; /* 其余状态同理,每个分支都必须有出口,不允许停留等待 */ } }comm_poll在主循环里每次调用,所有分支都只是判断加赋值,最坏情况执行时间在微秒级。超时值 5000 和 3000 是按 4G 网络的典型 RTT 定的,本地以太网可以压到 500 毫秒以内。ST_RETRY里要有退避,不能立刻重连——网络刚断就疯狂重连,模块会直接卡死。
5.5 换块板子就跑不起来:晶振和 PHY 地址
现象:同一套固件,在一块板子上跑得好好的,换一块一模一样的板子就不行,串口乱码或者以太网不亮。
原因:物料批次差异。有的批次贴 8MHz 晶振,有的贴 25MHz;PHY 的地址引脚在不同版本 PCB 上接了上拉或下拉。代码里的宏和常量是照着一块板子写死的,换个批次就失效。
解决:把硬件差异全部收敛到一个板级配置文件里,一个板子一份,编译时选择。不要靠运行期自动检测——PHY 地址能扫,晶振频率扫不出来,靠猜只会让问题更隐蔽。具体做法是:board_cfg.h里只放HSE_VALUE、PHY_ADDR、FLASH_PARAM_ADDR这几个关键常量,每个板子版本一个独立分支或者独立的编译宏,烧录前确认选对了版本。改完在板上贴个标签写上版本号,比什么都管用。
6. 怎么在十分钟内把范围缩到一行代码
前面几章讲的都是具体故障,实际现场遇到的多半是"不知道是哪一类"。我现在的习惯是固定按三层往下切,每层只花两三分钟,十分钟内基本能把范围缩到几十行以内。
第一层看灯和引脚。电源灯、RUN 灯、通信链路灯、ACT 闪烁灯。RUN 不亮说明卡在启动阶段,直接跳到第 3 章那四节;RUN 亮但通信灯不闪,说明问题在以太网或 4G 的物理层,跳到第 4 章前两节;都正常但行为不对,才进入下一层。这一层不用工具,肉眼加万用表,两分钟。
第二层看日志和故障寄存器。串口日志要提前用 DMA 加环形缓冲存起来,别用阻塞式打印——出问题的时候恰恰是最不该阻塞的时候。程序挂了先读 Cortex-M4 的故障状态寄存器,这是硬件给的"黑匣子":
/* 硬件异常发生后调用:打印 CFSR 和 HFSR,定位是取指、总线还是用法错误 */ void dump_fault(void) { uint32_t cfsr = SCB->CFSR; uint32_t hfsr = SCB->HFSR; /* MMAR/BFAR 只有在对应位使能时才有效,读之前先判断 */ printf("CFSR=%08lx HFSR=%08lx BFAR=%08lx\n", cfsr, hfsr, SCB->BFAR); /* CFSR 的 bit[7:0] 是 MMFSR,bit[15:8] 是 BFSR,bit[31:16] 是 UFSR */ }CFSR里UFSR段(高 16 位)非零通常是除零、非对齐访问、非法指令这类软件问题,直接看是哪个位对应哪条指令;BFSR段(中 8 位)非零是总线错误,多半是野指针访问了不存在的地址,配合BFAR能拿到具体地址。HFSR的FORCED位说明有下级异常被升级成了 HardFault,真正的原因还在CFSR里,别只看HFSR就下结论。
第三层用正则压日志。几千行串口输出肉眼看不动,用正则按错误码聚合一次,重复性故障一下就显形了:
# 从日志里抽出所有错误码并统计出现次数,按频率排序 grep -oE 'ERR[_ ]?(CODE)?[= :]*[-0-9A-Fx]{2,8}' uart.log \ | sed -E 's/.*[= :]//' | sort | uniq -c | sort -rn | head-oE只抓错误码片段,sed去掉前缀只留码值,uniq -c计数,最后按次数倒序。跑完你会看到某几个码反复出现几百次,剩下的都是一次性的噪音。真正的根因几乎总在头部那几个高频码里,一次性的那些往往是故障扩散后的次生现象。
| 现象 | 第一怀疑点 | 快速验证方式 | 对应章节 |
|---|---|---|---|
| 上电无任何反应 | 供电 / BOOT 引脚 | 万用表量复位瞬间电平 | 3.4 |
| 串口全乱码 | 晶振宏与实际不符 | 量一个已知频率的输出引脚 | 3.3 |
| 烧录成功不运行 | 向量表偏移 / 链接地址 | 读 Flash 前 8 字节看栈顶和复位向量 | 3.1、3.2 |
| 以太网灯不亮 | PHY 地址 / RMII 时钟 | 读 PHY ID 寄存器 | 4.1 |
| 4G 显示已连接但无数据 | 透传模式 / NAT 老化 | 发心跳看回执 | 4.2 |
| 定时重启 | 喂狗位置 / 中断饥饿 | 临时关狗观察是否还重启 | 5.1 |
| 参数断电丢失 | 参数区与代码区重叠 | 核对链接脚本的起止地址 | 5.2 |
| 扫描周期突然变长 | 通信阻塞 | 打印主循环单次执行时间 | 5.4 |
这套流程跑熟之后,大部分故障在第二层就能定位。真正需要翻源码细看的比例不高,但那部分往往就是最有价值的——比如位域布局、定时器分频这类,改一次就能永久消掉一类问题。
从那以后我每次拿到一套新源码,第一件事不是看指令表有多丰富,而是先把晶振、向量表、喂狗位置这三处强制走一遍,改完再加注释写清为什么。这套 FX3U 的 V50.0 源码注释确实细,但注释解释的是"这段代码做什么",不解释"这块板子上为什么要这么配",后者只能靠自己一条条验过来。仓库里那份带板级配置说明和排查记录的源码放在文末资料区,按需取用。希望帮到你。
本文还有配套的精品资源,点击获取