ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32F407上FX3U兼容PLC运行时源码解析与二次开发

STM32F407上FX3U兼容PLC运行时源码解析与二次开发

简介:面向三菱FX3U PLC底层开发与二次开发的学习者,这份V50.0版源码基于STM32F407平台重构程序架构,指令集涵盖脉冲型、浮点运算、位操作与移位、数据传送等类别,注释较完整,便于对照理解寄存器映射与指令实现逻辑。资源一并给出以太网与4G模块的接入思路,以及一路485口在编程口协议与Modbus RTU间通过D8120切换的方案,可结合在线监视用于调试。压缩包共7个文件,含4个txt说明与代码片段、1份doc文档、1个html页面和1张jpg示意图,整体约423KB,结构轻量便于按模块顺序阅读。更新记录显示2019年11月至2020年3月累计补充ZCPP、DMOVP、SFTR、WSFR等120余条指令,2021年3月修复D8000~D255在线监视异常及监视卡死问题。已有129人学习,适合具备STM32与PLC基础、希望深入底层实现的中高级开发者参考。

1. 从一台带 4G 的 FX3U 兼容控制器说起

上周有人抱着一块 F407 的核心板来问我:这套带以太网和 4G 模块的 FX3U 兼容 PLC 底层源码,能不能直接改成产品?我说先别改,先把它跑起来看三个数——扫描周期、指令覆盖度、掉电保持。V50.0 这份源码给的是完整运行时:软元件映像表、指令解释器、扫描调度、以太网与 4G 两条通信链路都在,注释密度也够,一条 LD 指令从取指到刷新输出的全过程能顺着看下来。它适合手上有 STM32F407 板子、想弄明白 PLC 内部到底怎么运转、又不满足于只在编程软件里画梯形图的人。

2. 分层架构拆解:STM32F407 上的 PLC 运行时怎么划分职责

很多人第一次翻这类源码,看到的是几百个 .c 文件堆在一起,翻两页就放弃了。其实它的组织逻辑非常清晰,本质上是把一个“周期性执行指令流的虚拟机”拆成了四层:最底下是 BSP,负责光耦输入、锁存输出、外扩 SRAM、串口这些物理动作;往上一层是软元件映像(设备表),X/Y/M/S/T/C/D 全部落在一块连续内存里;再往上是解释器和扫描调度;最上面才是 Modbus TCP、Modbus RTU、4G 这些通信服务。四个层的依赖是单向的,通信层只能读写映像表,不能直接碰 GPIO,这一点决定了后面改代码时哪些地方你能动、哪些地方一动就全乱。

选 STM32F407 做这个平台,很实际的理由是它自带以太网 MAC,外挂一片 RMII PHY 就能上 LwIP,省掉一颗独立的以太网控制器;192KB 内置 SRAM 配合 FSMC 外扩一片 512K×16 的 SRAM,软元件映像、指令区、通信缓冲全放得下;168MHz 的主频跑几千步基本逻辑,扫描周期能压在 1ms 上下。这类切换型 PLC 的实时性要求是“周期稳定”而不是“单条指令极快”,所以架构上更看重的是每一次扫描的时间抖动要小,而不是某个函数跑得多快。

2.1 四层结构:BSP、映像、解释器、通信

从上到下捋一遍调用关系会更直观。扫描调度器在主循环里被 1ms 节拍唤醒,先调 BSP 采样一次输入,把 X 区整体刷新;然后跑解释器,解释器从指令区第 0 步开始,逐条取指、译码、执行,执行过程中只读写映像表;跑到 END 指令停下来,调度器再一次性把 Y 区写进输出锁存器。通信层在扫描间隙被轮询,它读写的是同一块映像表,所以业务程序看到的数据和上位机看到的数据天然一致,不需要额外的同步机制。

这个结构带来的好处是“指令执行”和“硬件时序”彻底解耦。你在解释器里改一条 ADD 的溢出行为,不会影响输入采样的时序;你在 BSP 里把输出锁存器从 74HC595 换成并口直驱,解释器一行都不用动。代价是映像表变成了全局唯一的共享状态,任何一次越界写入都会踩坏别的软元件,所以后面第 5 章里那些“跑着跑着数据自己变了”的问题,八成都能追到越界上。

2.2 软元件映像表:位区和字区怎么排

FX3U 的软元件分两类:位软元件(X、Y、M、S、T 线圈、C 线圈)和字软元件(T 当前值、C 当前值、D、V、Z、R)。常见做法是开两块连续内存,一块按 bit 寻址,一块按 halfword 寻址,然后用一张描述表记录每种软元件的起始偏移和点数。这样dev_read_bit(ctx, DEV_M, 100)换算出来就是一次移位加掩码,编译完基本是几条指令,不会成为扫描周期的瓶颈。

软元件类型点数(典型配置)存储位置掉电保持
X位256,八进制编号 X0~X377bit_image 前段否,每周期重采样
Y位256,八进制编号 Y0~Y377bit_image 第二段否,输出锁存
M位7680,M0~M7679bit_image 第三段可设区间
S位4096,S0~S4095bit_image 第四段否
T位 + 字512 点线圈 + 512 字当前值bit/word 双区设定值保持
C位 + 字256 点线圈 + 256 字当前值bit/word 双区当前值保持
D字8000,D0~D7999word_image 主体可设区间
V / Z字各 8 点word_image 末尾否

描述表长这样,写法朴素但够用:

/* plc_device.h —— 软元件描述表,所有编号换算都查这张表 */ typedef enum { DEV_X = 0, DEV_Y, DEV_M, DEV_S, DEV_T, DEV_C, DEV_D, DEV_V, DEV_Z, DEV_INVALID } dev_type_t; typedef struct { uint16_t bit_count; /* 位软元件点数,0 表示该类不占位区 */ uint16_t word_count; /* 字软元件点数,0 表示不占字区 */ uint32_t bit_base; /* 在 bit_image[] 中的起始 bit 偏移 */ uint32_t word_base; /* 在 word_image[] 中的起始字偏移 */ } dev_desc_t; static const dev_desc_t g_dev_desc[DEV_INVALID] = { [DEV_X] = { 256, 0, 0, 0 }, [DEV_Y] = { 256, 0, 256, 0 }, [DEV_M] = { 7680, 0, 512, 0 }, [DEV_S] = { 4096, 0, 8192, 0 }, [DEV_T] = { 512, 512, 12288, 0 }, [DEV_C] = { 256, 256, 12800, 512 }, [DEV_D] = { 0, 8000, 0, 768 }, [DEV_V] = { 0, 8, 0, 8768 }, [DEV_Z] = { 0, 8, 0, 8776 }, }; /* 位读写:编号合法性必须查,越界一次就能把相邻软元件写花 */ static inline uint8_t dev_read_bit(plc_ctx_t *ctx, dev_type_t t, uint16_t addr) { const dev_desc_t *d = &g_dev_desc[t]; if (addr >= d->bit_count) return 0; /* 越界当 0 读,且记一次故障码 */ uint32_t bit = d->bit_base + addr; return (ctx->bit_image[bit >> 3] >> (bit & 7)) & 0x01; }

bit_base和word_base是手工排的,改点数配置的时候这两个值要跟着改,忘了改就是经典的内存重叠。我一般会在初始化里加一段自检:遍历所有软元件类型,检查bit_base + bit_count和word_base + word_count有没有超过映像区总长,超了就直接停在错误灯上,比运行到一半莫名其妙死机强得多。

2.3 扫描周期:从输入采样到输出刷新的一次闭环

扫描函数是整个运行时的骨架,它的执行顺序就是 PLC 的工作制。写法上没什么玄学,关键是每一步做什么、不做什么要划清楚:

/* scan_scheduler.c —— 一次完整扫描,顺序不能调换 */ void plc_scan_once(plc_ctx_t *ctx) { uint32_t t0 = dwt_get_cycles(); bsp_input_sample(ctx); /* 1. 输入采样:整周期内 X 区保持不变 */ cell_poll_uart(ctx); /* 2. 通信轮询:只做非阻塞收发,绝不在这里等应答 */ plc_vm_run(ctx); /* 3. 程序执行:从 step 0 跑到 END */ bsp_output_flush(ctx); /* 4. 输出刷新:一次性写锁存器,避免中间态抖动 */ plc_wdt_feed(ctx); /* 5. 喂狗 + 周期统计 */ ctx->scan_cycles = dwt_get_cycles() - t0; if (ctx->scan_cycles > ctx->scan_cycles_max) ctx->scan_cycles_max = ctx->scan_cycles; /* 最长周期,抖动全靠这个数定位 */ }

第一步和第四步的“一次性”很重要。输入采样如果在程序执行过程中被中断刷新,就会出现“前半段程序看到的 X0 是 0、后半段看到的是 1”的怪现象,梯形图逻辑在这种条件下跑出来的结果没人能预测。输出同理,如果每条 OUT 指令执行完就立刻写引脚,一组互锁输出在切换瞬间会出现短暂的非法组合,接继电器会听到“哒哒”两声。把这两步收拢到扫描边界上做,是这套架构最值得照抄的地方。

第三步的plc_vm_run里需要有步数上限保护。正常情况下程序跑到 END 就退出,但如果指令区被写坏、END 指令丢了,解释器会一路往后跑,跑到映像区外面去。常见做法是给一个max_steps上限(比如实际程序步数加 1000 的余量),超了就置故障码并停止输出,这比看门狗复位好排查得多,因为故障码会留在映像表里,复位后还能读出来。

2.4 目录结构与编译开关

源码的目录基本对应上面四层,认目录比认文件名快:

plc_v50/ ├── app/ │ ├── scan_scheduler.c # 扫描调度,1ms 节拍驱动 │ ├── plc_vm.c # 解释器主循环,取指译码执行 │ ├── device_image.c # 软元件描述表与读写接口 │ └── plc_fault.c # 故障码记录与恢复策略 ├── plc/ │ ├── instr_basic.c # LD/LDI/AND/OR/OUT/SET/RST/MPS 系列 │ ├── instr_move.c # MOV/DMOV/BMOV/FMOV 数据传送 │ ├── instr_math.c # ADD/SUB/MUL/DIV/INC/DEC 四则与自增 │ └── instr_shift.c # SFTR/SFTL/WSFR/WSFL 移位 ├── bsp/ │ ├── bsp_gpio.c # 输入光耦采样、输出锁存刷新 │ ├── bsp_fsmc.c # 外扩 SRAM 初始化与时序配置 │ └── bsp_uart.c # 编程口与 4G 模组两路串口 ├── comm/ │ ├── modbus_tcp.c # 502 端口,走 LwIP │ ├── modbus_rtu.c # 编程口从站 │ └── cellular_at.c # 4G 模组 AT 状态机 ├── lwip/ # 协议栈与网卡驱动 └── config/ └── plc_config.h # 点数、步数上限、掉电保持区间全在这里

plc_config.h是改配置的唯一入口,点数、程序步数上限、掉电保持的 D 区范围、扫描周期看门狗阈值都定义在这儿。二次开发时先改这个文件,别去各个 .c 里改宏,否则换一个板子就得重来一遍。这里还有一个容易被忽略的开关:调试模式下可以把解释器的单步执行打印打开,每执行一条指令就往串口打一行“步号 + 助记符 + 操作数 + 逻辑栈顶”,第一次跑新程序时这个功能救命,但它会直接把扫描周期拉长几十倍,量产固件里必须关掉。

3. 指令系统与编译前端:用正则表达式把指令表啃下来

指令系统是这个项目里体量最大、也最容易劝退的部分。FX3U 兼容指令集有上百条,从最基本的 LD/OUT 到 DMOV、ZCP、SFTL 这类带多操作数的指令,光靠读源码一条条记是记不住的。我的做法是先把它还原成文本:把指令表导出来,用正则表达式做统计和分类,先搞清楚“这份源码到底实现了哪些指令、哪些只有译码没有执行体”,再进代码里逐条对。这样比漫无目的地翻文件效率高一个量级。

3.1 一条指令在源码里的四件套

不管具体实现多复杂,每条指令在源码里都是固定的四件套:助记符字符串、操作数个数、占用步数、执行函数指针。步长这一项特别容易被忽略,但它决定了指令区和编程软件能不能对上——LD 占 1 步、MOV 占 2 步、DMOV 这种双字指令占 5 步,如果你的步长和上位机软件算出来的不一致,下载进去的程序会整体错位,跑起来完全是另一套逻辑。

/* plc_instr.h —— 指令表项,助记符字符串被编译前端和反汇编共用 */ typedef struct { const char *mnemonic; /* 助记符,必须和编程软件里的写法完全一致,大小写敏感 */ uint8_t operand_cnt; /* 0 = 无操作数(ANB/ORB/END),1 = 单操作数,2 = 双操作数 */ uint8_t step_len; /* 占用步数,这个值错了整个指令区就错位 */ int32_t (*exec)(plc_ctx_t *ctx, const operand_t *op); } instr_entry_t; const instr_entry_t g_instr_tbl[] = { { "LD", 1, 1, exec_ld }, { "LDI", 1, 1, exec_ldi }, { "AND", 1, 1, exec_and }, { "OR", 1, 1, exec_or }, { "OUT", 1, 1, exec_out }, { "SET", 1, 1, exec_set }, { "MPS", 0, 1, exec_mps }, { "MOV", 2, 2, exec_mov }, { "DMOV", 2, 5, exec_dmov }, { "END", 0, 1, exec_end }, };

operand_t里放的是解析后的软元件类型和编号,不是原始字符串:

typedef struct { dev_type_t dev; /* DEV_X / DEV_M / DEV_D ... */ uint16_t addr; /* 编号,八进制软元件在解析时已转成十进制 */ uint8_t idx; /* 索引修饰,0 表示无,1~14 表示 Z0~Z13 或 V0~V7 */ } operand_t;

idx这一项是给变址寻址用的。梯形图里写D100Z0,实际访问的是D(100+Z0),执行时要先读 Z0 的当前值再做加法,加完还得回头检查有没有超出 D 区范围。这一步不做边界检查,就是第 5 章里那条“跑着跑着数据自己变了”的直接来源。

3.2 用正则表达式清点指令覆盖度

把指令表导成文本以后,一行大概是这个样子:DMOV D100 D200 ; 双字传送,占 5 步。用一条正则就能把操作码、操作数、注释三段拆开:

import re from collections import Counter # 操作数允许 0~2 个,中间用逗号分隔,结尾分号后是注释 LINE = re.compile( r'^\s*(?P<op>[A-Z]{2,5})\s+' r'(?P<operands>[A-Z]{1,2}\d+(?:Z\d)?(?:\s*,\s*[A-Z]{1,2}\d+(?:Z\d)?)*)?' r'\s*(?:;\s*(?P<note>.*))?$' ) def scan_il(path, implemented): stat, missing = Counter(), [] for no, raw in enumerate(open(path, encoding='utf-8'), 1): line = raw.strip() if not line or line.startswith('//'): continue m = LINE.match(line) if not m: print(f'{path}:{no} 解析失败: {line}') # 解析失败必须打印,静默跳过会漏指令 continue op = m.group('op') stat[op] += 1 if op not in implemented: missing.append((no, op)) return stat, missing # g_instr_tbl 里的助记符导出成集合后传进来 stat, missing = scan_il('demo.il', implemented={'LD','LDI','AND','OR','OUT','MOV','DMOV','END'}) print(stat.most_common(10)) print('未实现的指令:', missing[:20])

这条正则里几个片段值得说清楚。[A-Z]{2,5}匹配 2 到 5 个大写字母的助记符,FX3U 兼容指令里最长的也就这个量级;[A-Z]{1,2}\d+匹配软元件编号,X、Y、M、D、T、C都是单字母,Z这类变址寄存器也是单字母,所以{1,2}其实留了余量;(?:Z\d)?处理变址修饰,D100Z0这种写法如果不加这一段,整行会匹配失败。注释部分整体可选,所以用(?:;\s*(?P<note>.*))?把分号后的内容整段收走。

注意:八进制编号的软元件(X 和 Y)在这里是按文本直接抓的,X10抓到的字面量是 "10",但它的实际编号是 8。统计指令条数时无所谓,一旦要拿这个编号去做地址换算,必须在后面单独做一次八进制转换,否则X10会被当成第 10 号输入点,而它其实是第 8 号。

跑完这一步,你会得到两张表:一张是各助记符的出现频次,频次高的说明是业务主力,值得优先读懂;另一张是“程序里用了但源码里没有执行体”的清单,这就是你要补的活。我一般会把这两张表存成文本,改代码的时候放旁边对着看,比在 IDE 里全局搜索靠谱。

3.3 译码器:查表加步长推进

解释器主循环的骨架非常简单,就是一个 PC 加上一个步长推进:

/* plc_vm.c —— 取指、译码、执行、推进,四步循环到 END */ void plc_vm_run(plc_ctx_t *ctx) { ctx->pc = 0; ctx->logic_sp = 0; ctx->mps_sp = 0; while (ctx->pc < ctx->max_steps) { uint16_t raw = ctx->instr_area[ctx->pc]; uint16_t op = raw & 0x03FF; /* 低 10 位是操作码索引 */ uint16_t arg = raw >> 10; /* 高 6 位是操作数在参数区的下标 */ if (op >= ARRAY_SIZE(g_instr_tbl)) { /* 操作码非法,直接停 */ plc_fault(ctx, FAULT_ILLEGAL_OPCODE); return; } const instr_entry_t *ins = &g_instr_tbl[op]; if (ins->exec(ctx, &ctx->operand_pool[arg]) != 0) { plc_fault(ctx, FAULT_EXEC_ERROR); /* 执行体返回非 0 一律视为故障 */ return; } ctx->pc += ins->step_len; /* 关键:按指令自身步长推进 PC */ if (op == OP_END) break; } }

ctx->pc += ins->step_len这一行是整套设计的关键。指令区是按“步”编址的,不是按“条”编址的,DMOV 占 5 步意味着它后面那条指令的起始地址是当前地址加 5。如果这里错误地统一加 1,整个程序从第一条双字指令开始就全乱了,而且现象会很迷惑:前几条逻辑指令看着正常,一旦执行到 MOV 就跳飞。调试这类问题时,把 PC 和当前助记符一起打到串口上,一眼就能看出步长对不对。

3.4 基本逻辑指令与 MPS/MRD/MPP 分支栈

基本逻辑指令的执行体短得不像话,但不能小看它们,因为梯形图里的串并联全靠这几条加上一个逻辑栈实现:

/* 逻辑栈:栈顶是"当前运算结果",AND/OR 都是跟栈顶运算,不出栈 */ int32_t exec_ld(plc_ctx_t *ctx, const operand_t *op) { if (op->dev >= DEV_INVALID) return ERR_OPERAND; ctx->logic_stack[ctx->logic_sp++] = dev_read_bit(ctx, op->dev, op->addr); return 0; } int32_t exec_and(plc_ctx_t *ctx, const operand_t *op) { uint8_t v = dev_read_bit(ctx, op->dev, op->addr); ctx->logic_stack[ctx->logic_sp - 1] &= v; /* 与栈顶求与,栈深不变 */ return 0; } int32_t exec_or(plc_ctx_t *ctx, const operand_t *op) { uint8_t v = dev_read_bit(ctx, op->dev, op->addr); ctx->logic_stack[ctx->logic_sp - 1] |= v; return 0; }

分支靠的是三条栈操作指令。梯形图里一个节点后面引出两条支路,编译成指令表时会在分支点插入 MPS 把当前结果存起来,第一条支路走完用 MRD 把存的值读回来接着跑第二条,最后用 MPP 弹出并恢复。这三条对应的是一个独立的 MPS 栈,FX3U 的规格是 11 层,实现时按 16 层留余量:

int32_t exec_mps(plc_ctx_t *ctx, const operand_t *op) { if (ctx->mps_sp + 1 >= MPS_STACK_DEPTH) /* 16 层,超过就是程序嵌套太深 */ return plc_fault(ctx, FAULT_MPS_OVERFLOW); ctx->mps_stack[ctx->mps_sp++] = ctx->logic_stack[ctx->logic_sp - 1]; return 0; } int32_t exec_mpp(plc_ctx_t *ctx, const operand_t *op) { if (ctx->mps_sp == 0) return plc_fault(ctx, FAULT_MPS_UNDERFLOW); ctx->logic_stack[ctx->logic_sp - 1] = ctx->mps_stack[--ctx->mps_sp]; return 0; }

栈深溢出和栈空弹出都必须报故障而不是静默返回。这两条在正常程序里永远不会触发,一旦触发就说明指令表本身有问题——要么是下载过程中数据被截断,要么是上位机生成指令表时对分支嵌套算错了。让它在第一时间停下并置故障码,比让它带着错乱的逻辑栈继续跑要安全得多,毕竟后面挂的是真实的接触器和阀。

4. 通信落地:以太网与 4G 模块两条链路的接入细节

通信这块是两个完全不同的战场。以太网侧相对“正统”,STM32F407 自带 MAC,接一片 RMII PHY,跑 LwIP,上面挂 Modbus TCP 从站,502 端口,上位机或 SCADA 直接连;4G 侧则是典型的串口 AT 指令活,模组自己处理射频和协议栈,单片机这边只管喂指令、解析回显和 URC、维持链路。两条链路最终都落到同一张软元件映像表上,所以业务逻辑完全不用关心数据是从网线来的还是从空中来的。

4.1 以太网侧:MAC 加 LwIP 上的 Modbus TCP

网卡驱动和协议栈是现成的,需要动的是应用层的帧处理和软元件映射。Modbus TCP 的报文结构比较固定:7 字节 MBAP 头加 PDU,MBAP 里的协议标识固定为 0,长度字段要按实际字节数填,单元标识在纯 TCP 场景下随便填但必须原样回。最常见的翻车点是长度字段算错——多算一个字节或者少算一个字节,上位机会一直等超时,而不给你任何错误提示。

/* modbus_tcp.c —— 只做帧解析与结果回填,业务映射交给 mb_map */ void mb_tcp_handle(plc_ctx_t *ctx, uint8_t *buf, uint16_t len) { if (len < 8) return; /* MBAP 都不够,直接丢 */ uint16_t proto = (buf[2] << 8) | buf[3]; if (proto != 0) return; /* 协议标识非 0 不是 Modbus */ uint16_t pdu_len = ((buf[4] << 8) | buf[5]) - 1; /* 长度字段含单元标识 */ uint8_t *pdu = &buf[7]; uint8_t fc = pdu[0]; int32_t rc; switch (fc) { case 0x03: rc = mb_read_holding(pdu, pdu_len); break; /* 读保持寄存器 -> D 区 */ case 0x10: rc = mb_write_holding(pdu, pdu_len); break; /* 写多个寄存器 */ case 0x01: rc = mb_read_coils(pdu, pdu_len); break; /* 读线圈 -> M 区 */ case 0x05: rc = mb_write_coil(pdu, pdu_len); break; default: rc = MB_EX_ILLEGAL_FUNC; break; } if (rc != 0) { /* 异常响应:功能码最高位置 1 */ pdu[0] = fc | 0x80; pdu[1] = (uint8_t)rc; pdu_len = 2; } mb_tcp_reply(ctx, buf, pdu_len); /* 复用原 MBAP 头回包 */ }

pdu_len那个减 1 是必须的,长度字段统计的是“单元标识 + PDU”的总字节数,算 PDU 长度得减掉单元标识那一个字节。这段代码里所有多字节字段都是大端,直接按字节拼就行,不要用memcpy到uint16_t再赋值,在小端机器上会得到反过来的值。

4.2 软元件到 Modbus 地址的映射规则

映射规则得自己定,但要定得让上位机好配。常见做法是把 4x 保持寄存器的前 8000 个地址直接对 D0~D7999,再往后划出 T 和 C 的当前值区,线圈区则对 M 区。这样上位机组态时不需要查任何额外表格,写 40001 就是 D0,写 40101 就是 D100,把软件里的地址习惯直接平移过来。

/* modbus_map.c —— 地址翻译层,越界必须返回异常码而不是硬写 */ int32_t mb_read_holding(const uint8_t *pdu, uint16_t pdu_len) { uint16_t addr = (pdu[1] << 8) | pdu[2]; uint16_t qty = (pdu[3] << 8) | pdu[4]; if (qty == 0 || qty > 125) return MB_EX_ILLEGAL_VALUE; /* 协议上限 125 个寄存器 */ if (addr + qty <= 8000) { /* 4x 前段 -> D 区 */ for (uint16_t i = 0; i < qty; i++) pdu[3 + i * 2] = 0; /* 占位:实际由 dev_read_word 填,见下行 */ return 0; } if (addr >= 0x2000 && addr + qty <= 0x2000 + 512) { /* T 当前值区 */ return 0; } return MB_EX_ILLEGAL_ADDR; /* 不认识的地址,回异常 */ }

注意这里的addr + qty和 8000 比较,两个都是uint16_t,addr最大 65535、qty最大 125,相加会溢出回绕。上位机只要发一个addr = 65530, qty = 100,addr + qty就变成 94,直接被判成合法地址,然后循环里往pdu后面写 100 个寄存器,把接收缓冲整个踩穿。修法很简单:先把addr和qty提升成uint32_t再相加,或者先判断addr > 8000直接拒掉。这种事上位机不会故意干,但组态配错一个偏移量就会发生,而且现场表现是“接上网线跑几分钟就死机”,特别难查。

4.3 4G 侧:AT 状态机与断线自愈

4G 模组的接入没有捷径,就是一个状态机加一堆超时。上电、查 SIM、查网络注册、激活 PDP、开 socket、上线心跳,每一步都可能卡住,卡住了不能原地死等,必须超时回退到复位流程。把每一步写成状态,主循环里每次只推进一步,扫描周期完全不受影响:

/* cellular_at.c —— 非阻塞状态机,每次调用只走一步 */ static void cell_step(cell_ctx_t *c) { if (c->step_timeout) { cell_hard_reset(c); return; } /* 任何一步超时统一复位 */ switch (c->state) { case CELL_AT_READY: if (at_send_expect(c, "AT", "OK", 500)) c->state = CELL_SIM_READY; break; case CELL_SIM_READY: if (at_send_expect(c, "AT+CPIN?", "+CPIN: READY", 1000)) c->state = CELL_NET_REG; break; case CELL_NET_REG: if (at_send_expect(c, "AT+CGREG?", ",1", 2000)) /* 只有 ,1 才算注册上 */ c->state = CELL_PDP_ACTIVE; break; case CELL_PDP_ACTIVE: if (at_send_expect(c, "AT+QIACT=1", "OK", 30000)) /* 激活 PDP,给足 30 秒 */ c->state = CELL_SOCKET_OPEN; break; case CELL_ONLINE: if (++c->hb_tick >= 30000) { /* 30 秒一次心跳 */ c->hb_tick = 0; mqtt_publish_heartbeat(c); } break; default: break; } }

状态机只能处理“我主动问”的流程,模组自己冒出来的 URC 得单独接。断线是最典型的:模组检测到链路断开后会主动吐一条+QIURC: "closed",这时候状态机还老老实实停在CELL_ONLINE,心跳照发但全丢进黑洞,上位机那边看到的是“设备在线但数据不动”。正确做法是在串口接收回调里扫这行 URC,命中就把状态打回CELL_SOCKET_OPEN重新建链。

提示:URC 的解析不要用strstr全缓冲搜索,因为接收缓冲是环形复用的,搜索可能跨到半条旧报文上。用一个行缓冲,收到\n才做一次完整匹配,匹配失败就整行丢弃,这样误判率会低很多。

4.4 通信与扫描的隔离:双缓冲

最后一个结构性问题:通信报文和扫描周期怎么共存。以太网收包是中断驱动的,如果中断里直接往软元件映像表写,恰好撞上解释器正在读同一片区域,就会读到“一半新一半旧”的数据。常见做法是通信侧维护一块影子缓冲,中断里只往影子缓冲写,扫描边界上做一次整体交换;读方向则相反,扫描边界把映像表快照到影子缓冲,通信侧只读快照。

代价是多占一份内存,收益是不用关中断、不用加锁,也不会出现“某个 D 寄存器在同一个扫描周期里前后读到两个值”的诡异现象。这套东西在单核单片机上是标配,尤其当你的程序里有 32 位运算指令要读连续的 D 区时,一个字节被中途改掉就能算出完全离谱的结果,现场表现是“偶尔算错一次”,查起来能耗掉一整天。

5. 避坑与排查:上手这套源码最容易翻车的 5 个点

源码注释再详细,跑起来该撞的坑一个都不会少。下面这五个是我在类似项目里反复踩过的,每一个都附带现象、原因和改法,照着对一遍能省不少时间。

5.1 三个把板子跑挂的运行期问题

现象一:输出继电器周期性抖动,扫描周期从 1ms 变成十几毫秒忽长忽短。

原因基本可以锁定在通信任务阻塞了扫描。很多人图省事,在扫描函数里直接调at_send_expect这种“发完等应答”的同步函数,等一个 OK 回显等 500ms,这 500ms 里整台 PLC 的程序是不执行的,输出保持上一次状态。负载一变,等的时间就变,抖动就是这么来的。

改法是把通信彻底改成非阻塞:发送只往环形缓冲塞数据,接收在串口中断里做,状态机在主循环里每次只推进一步,不等任何应答。等应答的超时判断用 tick 计数,不用阻塞延时。改完之后扫描周期的最长值应该能稳定在一个很小的范围内,用scan_cycles_max那个字段就能验证。

现象二:断电重启后 D 区的参数全变成 0,重新下装一次就好。

这是掉电保持没做对。常见错误是只往 Flash 写了一份,而且写的时候没考虑掉电时机——如果正好在擦除过程中断电,整页数据就全废了。另一个错误是只存了 D 区,忘了存 M 区里那些被当作保持位用的点。

改法是用双页轮换加 CRC。两页轮流写,每页头部带一个序号和一个 CRC32,上电时读两页、校验通过的取序号大的那页。写的时候先擦旧页、写完校验、再更新页头,任何一步断电最多丢掉最新一次保存,上一份数据还在。保存频率也要控制,别每个扫描周期都写,一般做成“参数变化后延时 2 秒落盘”或者由特定指令触发。

现象三:接了编码器做高速计数,低速正常,转速一上去就丢脉冲。

原因是计数用了普通定时器中断轮询,脉冲频率一高,中断还没响应完下一个脉冲就过去了。F407 上有专门的编码器接口模式,把定时器配成编码器模式,硬件自动计数,CPU 只负责定期读计数值,几百 kHz 也不丢。

如果脉冲频率更高,得走定时器的外部时钟输入加 DMA,或者干脆用外部计数芯片。判断标准很简单:算一下最高脉冲频率,乘以中断响应时间,如果接近 1 个脉冲周期,就必须换方案。

5.2 两处只在联调时才暴露的坑

现象四:梯形图分支一多,程序就跑到别的地方去了,输出全乱。

先查 MPS 栈。分支嵌套超过栈深时,如果代码里没做溢出判断,栈指针会一直往上加,加到数组外面去,踩坏相邻的变量。之后 MRD 和 MPP 读出来的值就是垃圾,整个逻辑栈跟着错。

改法是溢出直接报故障并停机,别想“优雅降级”。同时把 MPS 栈和逻辑栈都放在映像区之外的独立段,两边加哨兵值,出了越界能第一时间定位。指令表本身也要核对,上位机生成指令表时对分支嵌套的计算方式要与解释器一致,这一步最好拿一段最简单的两分支程序手工对一遍。

现象五:4G 链路白天正常,晚上固定时间断一次,断了不自动恢复。

固定时间断,一般是运营商侧回收空闲连接,或者模组侧检测到链路异常。如果你的代码没有解析 URC,状态机就一直停在CELL_ONLINE,自己以为还连着。

改法有三层:一是把心跳周期压到 30 秒以内,别让链路长时间空闲;二是必须解析+QIURC: "closed"这类 URC,命中就把状态打回建链;三是加一个“连续 N 次心跳无应答”的兜底判断,超了就硬复位模组。三层做完,断线恢复时间能压到一分钟以内,比人工去现场重启强。

注意:模组复位不要只发AT+CFUN=1,1,有些模组的软件复位不彻底,射频部分还在工作,重新初始化会失败。留一路 GPIO 直接控制模组的复位脚和电源脚,才是最后的手段。

6. 二次开发:加一条自定义指令并验证它的完整路径

改这套源码最典型的需求就是“加一条指令”。可能是客户要一个专用的批量搬运指令,也可能是想把某个频繁使用的组合逻辑固化成一条指令来省步数。路径是固定的四步,走完一遍你就摸清了整套代码的骨架。

第一步,在plc_instr.h的指令表末尾追加表项,助记符取一个新的、和现有指令不冲突的名字,操作数个数和步长一次定死,步长定完就不要改,否则已经下载到设备里的旧程序会集体错位。

/* 自定义指令:把 src 起始的 n 个字复制到 dst,n 由第三个操作数给出 */ { "BMOVX", 3, 7, exec_bmovx }, /* 三操作数,占 7 步,性质同 BMOV */

第二步,写执行体。执行的入口必须做三件事:校验操作数类型、校验变址后的地址范围、保证不跨越映像区的边界。

int32_t exec_bmovx(plc_ctx_t *ctx, const operand_t *op) { uint16_t dst = dev_index(ctx, &op[0]); /* 已叠加 Z/V 变址 */ uint16_t src = dev_index(ctx, &op[1]); uint16_t n = dev_read_word(ctx, op[2].dev, op[2].addr); if (n == 0 || n > 512) return ERR_OPERAND; /* 数量上限自己定,别无限 */ if (dst + n > MAX_D_REG || src + n > MAX_D_REG) return ERR_RANGE; for (uint16_t i = 0; i < n; i++) /* 用 memmove 更稳,源和目的可能重叠 */ dev_write_word(ctx, DEV_D, dst + i, dev_read_word(ctx, DEV_D, src + i)); return 0; }

dev_index负责把变址寄存器叠上去并做范围检查,dev_read_word和dev_write_word是映像表的读写接口。源地址和目的地址重叠时,逐字节的正向复制会把数据覆盖掉,所以这里应该用memmove或者先判断方向,这类细节在测试时用一组dst = 100, src = 102的数据就能暴露出来。

第三步,更新编译前端的助记符表。这一步最容易漏:解释器认识BMOVX了,但编程软件导出的指令表里如果还是把它当未知指令,下载进去就是

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进