首页 / 资讯中心 / 文章详情

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

STM32F407上FX3U兼容PLC运行时源码解析与二次开发 ★ FEATURED ARTICLE
简介面向三菱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位7680M0~M7679bit_image 第三段可设区间S位4096S0~S4095bit_image 第四段否T位 字512 点线圈 512 字当前值bit/word 双区设定值保持C位 字256 点线圈 256 字当前值bit/word 双区当前值保持D字8000D0~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(100Z0)执行时要先读 Z0 的当前值再做加法加完还得回头检查有没有超出 D 区范围。这一步不做边界检查就是第 5 章里那条“跑着跑着数据自己变了”的直接来源。3.2 用正则表达式清点指令覆盖度把指令表导成文本以后一行大概是这个样子DMOV D100 D200 ; 双字传送占 5 步。用一条正则就能把操作码、操作数、注释三段拆开import re from collections import Counter # 操作数允许 0~2 个中间用逗号分隔结尾分号后是注释 LINE re.compile( r^\s*(?Pop[A-Z]{2,5})\s r(?Poperands[A-Z]{1,2}\d(?:Z\d)?(?:\s*,\s*[A-Z]{1,2}\d(?:Z\d)?)*)? r\s*(?:;\s*(?Pnote.*))?$ ) def scan_il(path, implemented): stat, missing Counter(), [] for no, raw in enumerate(open(path, encodingutf-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*(?Pnote.*))?把分号后的内容整段收走。注意八进制编号的软元件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 头加 PDUMBAP 里的协议标识固定为 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_taddr最大 65535、qty最大 125相加会溢出回绕。上位机只要发一个addr 65530, qty 100addr 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, ATCPIN?, CPIN: READY, 1000)) c-state CELL_NET_REG; break; case CELL_NET_REG: if (at_send_expect(c, ATCGREG?, ,1, 2000)) /* 只有 ,1 才算注册上 */ c-state CELL_PDP_ACTIVE; break; case CELL_PDP_ACTIVE: if (at_send_expect(c, ATQIACT1, 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 次心跳无应答”的兜底判断超了就硬复位模组。三层做完断线恢复时间能压到一分钟以内比人工去现场重启强。注意模组复位不要只发ATCFUN1,1有些模组的软件复位不彻底射频部分还在工作重新初始化会失败。留一路 GPIO 直接控制模组的复位脚和电源脚才是最后的手段。6. 二次开发加一条自定义指令并验证它的完整路径改这套源码最典型的需求就是“加一条指令”。可能是客户要一个专用的批量搬运指令也可能是想把某个频繁使用的组合逻辑固化成一条指令来省步数。路径是固定的四步走完一遍你就摸清了整套代码的骨架。第一步在plc_instr.h的指令表末尾追加表项助记符取一个新的、和现有指令不冲突的名字操作数个数和步长一次定死步长定完就不要改否则已经下载到设备里的旧程序会集体错位。/* 自定义指令把 src 起始的 n 个字复制到 dstn 由第三个操作数给出 */ { 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了但编程软件导出的指令表里如果还是把它当未知指令下载进去就是本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站