
简介面向工控与嵌入式学习者的一份FX3U PLC源码升级包围绕“仿三菱”与STM32协同开发展开适合希望理解三菱FX3U指令集、编程逻辑、通信处理及底层硬件交互的开发者参考。包内共713个文件以C源码和头文件为主体配合PDF说明文档、原理图预览、Keil工程配置、HEX固件及MAP映射等资料覆盖从源码到编译产物的完整工程链路压缩包约78.89MB目录按工程与版本组织便于检索和对照阅读。已有1559人学习下载读者可从中梳理FX3U程序的模块划分、I/O与通信实现思路观察STM32在系统中的角色并借助示例工程、编译产物和说明文档完成源码阅读、功能验证与二次学习。资料定位为学习参考未附带电路图对教学演示、测试验证和原型开发具有参考意义更适合具备一定嵌入式与PLC基础的人做专题研究使用时需注意遵守版权与使用条款。1. 为什么一块 STM32 能跑起 FX3U V8.8 兼容固件前阵子有人拿着一块 STM32 最小系统板问我这东西真能当 FX3U 用吗。我把这份 FX3U V8.8 源码升级版编译烧进去接上 24V 电源和几个拨码开关上位机连上就认了出来——梯形图能下装X 点能读状态Y 点能驱动继电器板。这套资源就是一份跑在 STM32 上的 FX3U 兼容 PLC 固件源码把指令解释器、软元件管理、编程口协议、RS485 通信这几块完整实现了一遍版本号 V8.8 对应的是指令集覆盖度和通信兼容性的一轮升级。它适合两类人。一类是手里有小型自动化项目、想用自研板替代成品 PLC 把成本压下来的硬件工程师另一类是想搞清楚小型 PLC 扫描周期到底怎么转、软元件区在内存里怎么摆的嵌入式开发。别指望它是个点亮 LED 的入门例程X/Y/M/S/D/T/C 这套软元件体系、语句表解释、定时器计数器时基全都得真刀真枪跑起来才算数。2. 固件架构拆解软元件区、扫描周期与指令解释器拿到一份 PLC 固件源码第一眼别急着看 main 函数先看它把内存怎么切。小型 PLC 的性能上限几乎全压在软元件区布局上位元件和字元件怎么共存、掉电保持区怎么划、指令里怎么用最短的路径寻址到某个 D 寄存器的第几位。这套 FX3U V8.8 的架构走的是经典三层——硬件抽象层管 GPIO 和串口运行时层管软元件和扫描调度指令层管语句表译码执行。三层之间靠一张全局的软元件描述表通信不互相直接调函数这一点在二次开发时很关键。2.1 源码目录与工程分层工程按功能切目录不是按文件类型切这是嵌入式项目里比较省心的一种组织方式。常见做法是把与芯片相关的代码全塞进 bsp 目录芯片无关的运行时逻辑放 core用户程序存储和译码放 vm通信协议单独一个 comm。这样换 MCU 型号时只动 bsp其余目录基本不用碰。FX3U_V8.8/ ├── bsp/ # 芯片相关时钟、GPIO、串口、Flash 读写 │ ├── stm32f10x_it.c # 中断向量SysTick 与串口中断入口 │ ├── board_io.c # 输入输出点映射与光耦极性处理 │ └── flash_eeprom.c # 用户程序与掉电保持区的 Flash 模拟 ├── core/ # 运行时核心 │ ├── softdev.c # 软元件区定义、寻址、上下电初始化 │ ├── scan.c # 扫描周期调度采样-执行-刷新 │ └── timer.c # 定时器/计数器时基与刷新 ├── vm/ # 指令虚拟机 │ ├── il_decode.c # 语句表译码主循环 │ ├── il_basic.c # 触点、线圈、置位复位 │ └── il_func.c # 加减乘除、比较、移位、传送 ├── comm/ # 通信 │ ├── prog_port.c # 编程口协议解析 │ └── modbus_slave.c # Modbus RTU 从站 └── user/ # 用户程序区下装目标这个分层有个隐含约定core 和 vm 里不允许出现任何寄存器名比如不许直接写 GPIOA-ODR只能通过 board_io.c 暴露的io_read_x()/io_write_y()。我第一次改这块的时候图省事在定时器中断里直接操作了输出寄存器结果程序执行阶段刷新 Y 区时又把值覆盖回去了输出抖动了好几版才定位到。分层不是为了好看是为了让谁最终决定这个点的电平这件事只有一个答案。2.2 软元件映射X/Y/M/S/D/T/C 在 RAM 里怎么摆软元件是 PLC 的灵魂。X 是输入映像Y 是输出映像M 是辅助继电器S 是状态继电器D 是 16 位数据寄存器T 是定时器含当前值和触点C 是计数器。这些东西在源码里通常不是一个个变量而是几块连续的数组靠宏做位寻址。/* softdev.h —— 软元件区定义地址用宏算不占额外空间 */ #define X_BYTES 32 /* X0~X255按位寻址 */ #define Y_BYTES 32 /* Y0~Y255 */ #define M_BYTES 128 /* M0~M1023 */ #define D_COUNT 8000 /* D0~D799916 位 */ #define T_COUNT 256 #define C_COUNT 256 typedef struct { uint16_t cur; /* 当前值 */ uint16_t preset; /* 设定值 */ uint8_t coil; /* 触点状态0 断开 1 接通 */ uint8_t flag; /* bit0正在计时 bit1已到设定值 */ } TmrBlock; extern uint8_t gX[X_BYTES]; /* 输入映像只由采样阶段写 */ extern uint8_t gY[Y_BYTES]; /* 输出映像只由刷新阶段读 */ extern uint8_t gM[M_BYTES]; extern uint16_t gD[D_COUNT]; extern TmrBlock gT[T_COUNT]; extern TmrBlock gC[C_COUNT]; /* 位元件的读写统一走这两个 inline编译后基本就是几条指令 */ static inline uint8_t sd_bit_get(uint8_t *base, uint16_t addr) { return (base[addr 3] (addr 7)) 0x01; } static inline void sd_bit_set(uint8_t *base, uint16_t addr, uint8_t v) { if (v) base[addr 3] | (uint8_t)(1u (addr 7)); else base[addr 3] (uint8_t)~(1u (addr 7)); }sd_bit_get/sd_bit_set这两个函数是整个固件里调用频率最高的代码没有之一。用位移和掩码而不是位域bit-field原因是位域在不同编译器下的内存排布顺序不保证一致用户程序是按固定地址下装的排布一变就全乱套这种玄学问题能避开就避开。几个地方值得单独说X 区和 Y 区必须分离。X 只在扫描周期的输入采样阶段被写一次Y 只在输出刷新阶段被真正推到 GPIO。中间程序执行阶段随便怎么改 Y 映像都不会立刻反映到端子上这是 PLC 确定性的来源。我见过有人为了实时响应在中断里直接改 Y 区结果扫描周期和中断打架输出出现半个周期的毛刺。掉电保持区单独划。D200~D511、M500~M1023 这类地址在 FX3U 里属于保持型源码里一般用一份独立的镜像数组 Flash 写入队列实现上电时从 Flash 恢复掉电检测中断里落盘。T 和 C 用结构体而不是裸数组。定时器除了当前值还要存设定值、触点状态、运行标志拆成三个数组反而更难维护。提示软元件区是整个固件里最吃 RAM 的部分。D8000 个 16 位寄存器就是 16KB再加上 M 区、T/C 结构体STM32F103C8 那种 20KB RAM 的型号会直接撑爆。选型时先按 8 这个公式估一遍D 数量 × 2 字节 M 数量 ÷ 8 栈空间 通信缓冲区。2.3 扫描周期的三段式执行PLC 和普通单片机程序最大的差别就是确定性。普通固件是事件驱动、谁中断谁说了算PLC 是死循环一个周期固定做三件事读输入、跑用户程序、写输出然后再处理通信和自检。这套 FX3U V8.8 的调度主循环大致长这样。/* scan.c —— 扫描周期主循环看门狗在循环头喂 */ void scan_main_loop(void) { uint32_t t_start; while (1) { t_start get_us(); /* 周期起始时间戳用于算扫描时间 */ scan_input_sample(); /* 1. 输入采样GPIO - gX */ vm_execute(gUserProg, gProgLen); /* 2. 程序执行解释语句表 */ scan_output_refresh(); /* 3. 输出刷新gY - GPIO */ timer_tick_service(); /* 定时器/计数器按已过时间推进 */ comm_poll(); /* 编程口 Modbus 非阻塞轮询 */ wdt_feed(); /* 喂狗位置必须在循环体末尾 */ gScanTimeUs get_us() - t_start; /* 记录本周期的扫描时间供监视 */ } }四件事的先后顺序不能随便调scan_input_sample()一次性把 32 字节 X 区从 GPIO 读进来。为什么不在指令执行时按需读因为那样同一条 LD X0 在一段程序里被执行两次可能读到不同的值梯形图的逻辑就不闭合了。采样阶段把这一个周期的输入冻结是 PLC 一致性的基础。vm_execute()是耗时大户里面没有阻塞操作也不做通信。用户程序多长这块就多长。scan_output_refresh()把 Y 映像整体推到输出锁存器一般是 74HC595 或者直接 GPIO一次写完整块避免边算边写造成的中间态。comm_poll()放在输出刷新之后是血泪经验。早期版本把它放在采样之前结果通信收包一慢输入采样就被推后扫描周期抖得厉害。放到末尾意味着通信最坏情况只影响周期长度不影响采样的时间点。gScanTimeUs这个变量在联调阶段特别有用。上位机一般都有扫描周期监视但那个值往往被通信平滑过不如直接拿调试器看这个原始值。正常空程序跑下来是几十微秒量级一旦跳到几毫秒八成是指令解释器里某个循环写炸了或者通信在扫描体里做了阻塞等待。3. 指令解释器核心从梯形图到寄存器改写上位机下装下来的不是梯形图图形是编译好的语句表Instruction List。指令解释器的工作就是把这一串字节码逐条执行每条指令干两件事读一个或几个软元件写一个软元件。听起来简单但真正决定这个项目能不能用的是指令的编码密度和译码循环的效率——用户程序动辄几百上千步每步多花 100 纳秒整体周期就上去了。3.1 指令结构与译码主循环指令在 Flash 里的排布方式各家做法不一样。这套源码采用的是定长操作码 变长操作数每条指令头部一个字节是操作码后面跟 0~4 字节操作数操作数的长度和含义由操作码查表决定。好处是译码循环不需要为每条指令写一个 switch 分支坏处是指令边界不能靠偏移直接跳必须顺序扫。/* il_decode.c —— 操作码表字段顺序操作数个数、类型、处理函数 */ typedef struct { uint8_t operand_cnt; /* 操作数个数0/1/2 */ uint8_t operand_type; /* 位元件/字元件/立即数 组合 */ void (*handler)(const uint8_t *ops); } IlEntry; /* 只截取常用指令完整表在源码里有一百多条 */ const IlEntry gIlTable[256] { [0x01] { 1, OP_BIT, il_ld }, /* LD X/M/S/T/C */ [0x02] { 1, OP_BIT, il_ldi }, /* LDI 取反 */ [0x03] { 1, OP_BIT, il_out }, /* OUT 线圈 */ [0x04] { 1, OP_BIT, il_set }, /* SET */ [0x05] { 1, OP_BIT, il_rst }, /* RST */ [0x10] { 2, OP_WORD, il_mov }, /* MOV 字传送 */ [0x11] { 2, OP_WORD, il_cmp }, /* CMP 比较 */ [0x12] { 2, OP_WORD, il_add }, /* ADD */ [0x20] { 2, OP_BITW, il_out_t }, /* OUT T 定时器 */ }; /* 译码主循环一条指令取一次操作数指针处理完就往后推 */ void vm_execute(const uint8_t *prog, uint16_t len) { const uint8_t *pc prog; const uint8_t *end prog len; while (pc end gRunFlag) { const IlEntry *e gIlTable[*pc]; /* 取操作码pc 自增 */ if (e-handler NULL) { /* 未定义指令停机报警 */ gErrCode ERR_ILLEGAL_INST; gRunFlag 0; break; } e-handler(pc); /* 处理函数自己按 cnt 前推 pc */ pc il_operand_bytes(e); /* 跳过本指令的操作数 */ } }il_operand_bytes()根据operand_cnt和operand_type算出字节数位元件操作数占 2 字节区号 地址字元件占 2 字节立即数占 2 或 4 字节。这个函数被调用的次数和指令条数一样多一般直接写成查表而不是 if-else 嵌套。有个细节容易被忽略gRunFlag的判断放在了循环条件里。用户按了 STOP 之后得保证当前周期内尽快退出来而不是跑完整个程序再停。但也不能在 handler 里中途 return否则栈上残留的累加器状态会污染下一个周期。3.2 触点、线圈与定时器指令的实现触点指令的核心是把结果写到哪。PLC 的语句表是栈式求值的一种变体但为了省 RAM大多数小型 PLC 固件不用完整栈只用几个固定的中间寄存器当前逻辑结果累加器 A、上一条的结果用于 ANB/ORB 块操作。这套源码走的是同样的路子。/* il_basic.c */ static uint8_t gAcc; /* 当前逻辑结果 */ static uint8_t gAccOld; /* 上一逻辑块结果供 ANB/ORB 使用 */ /* LD把指定软元件的状态装进累加器 */ void il_ld(const uint8_t *ops) { uint8_t area ops[0], addr ops[1]; switch (area) { case AR_X: gAcc sd_bit_get(gX, addr); break; case AR_M: gAcc sd_bit_get(gM, addr); break; case AR_T: gAcc gT[addr].coil; break; case AR_C: gAcc gC[addr].coil; break; default: gAcc 0; break; } } /* AND与当前累加器做逻辑与串接一个常开点 */ void il_and(const uint8_t *ops) { uint8_t area ops[0], addr ops[1]; uint8_t v 0; if (area AR_X) v sd_bit_get(gX, addr); else if (area AR_M) v sd_bit_get(gM, addr); else if (area AR_T) v gT[addr].coil; gAcc (uint8_t)(gAcc v); } /* OUT把累加器写到线圈同时记录驱动条件给定时器用 */ void il_out(const uint8_t *ops) { uint8_t area ops[0], addr ops[1]; if (area AR_Y) sd_bit_set(gY, addr, gAcc); else if (area AR_M) sd_bit_set(gM, addr, gAcc); else if (area AR_S) sd_bit_set(gS, addr, gAcc); } /* OUT T定时器驱动条件由 0 变 1 时开始计时由 1 变 0 时复位 */ void il_out_t(const uint8_t *ops) { uint8_t idx ops[0]; uint16_t preset ops[1] | (ops[2] 8); TmrBlock *t gT[idx]; t-preset preset; if (gAcc 0) { /* 驱动条件断开清零复位 */ t-cur 0; t-flag 0; t-coil 0; } else if (!(t-flag 0x01)) { /* 上升沿置运行标志 */ t-flag | 0x01; t-cur 0; } /* 计时推进放在 timer_tick_service()不在这里做 */ }这里最关键的是计时动作和驱动动作分离。il_out_t只负责判断驱动条件的上升沿、设置运行标志、装载设定值真正的累加放在扫描循环末尾的timer_tick_service()里统一做。为什么这么设计因为定时器如果在指令执行阶段各自加自己的那么离程序开头近的定时器和离结尾近的定时器在同一个周期内推进的时刻不同两个定时器串联时就会出现一个周期的偏差——这种偏差在高速逻辑里会造成实实在在的误动作。gAcc和gAccOld用两个静态变量而不是局部变量是因为 ANB/ORB 这类块指令需要跨指令保存状态。副作用是这套解释器不可重入所以它只能跑在主循环里不能在中断里调。我一般会在头文件里加一句注释把这条写死省得后来接手的人想当然地在中断里加个il_ld。3.3 高速计数与中断里的时间基准定时器和计数器的精度完全取决于时基怎么来。这套固件用 SysTick 做 1ms 基准在中断里只做一件事给一个全局毫秒计数加一并把各定时器按类型标志推到待刷新队列。真正的值累加放到扫描周期里做用的是实测量而不是数中断次数。/* timer.c —— 1ms 系统节拍只记时间不做业务 */ volatile uint32_t gTickMs 0; void SysTick_Handler(void) { gTickMs; } /* 扫描周期末尾调用用时间差推进定时器避免中断里做重活 */ void timer_tick_service(void) { static uint32_t last_ms 0; uint32_t now gTickMs; uint32_t delta now - last_ms; /* 无符号减法溢出也正确 */ if (delta 0) return; last_ms now; for (uint16_t i 0; i T_COUNT; i) { TmrBlock *t gT[i]; if (!(t-flag 0x01)) continue; /* 未在计时 */ /* 按定时器类型换算100ms 型每 100 个毫秒推进 1 */ uint16_t step (t-type TMR_100MS) ? 100 : (t-type TMR_10MS) ? 10 : 1; t-cur (uint16_t)(delta / step); if (t-cur t-preset) { t-cur t-preset; t-coil 1; t-flag (uint8_t)~0x01; /* 停止计时 */ } } }delta now - last_ms用无符号减法是处理计数器回绕的标准写法49 天溢出一次也不会出错。要是写成if (now last_ms) delta now - last_ms;溢出那一下就会漏掉一个周期表现出来就是设备运行一个多月后定时器偶尔不准一次——这种问题现场根本查不出来。高速计数器是另一套逻辑。X0~X7 里通常有 2~4 路支持 10kHz 以上的输入走的是定时器外部时钟模式或者输入捕获不经过 1ms 软时基。常见做法是配置 TIM 的编码器模式或外部计数模式硬件自动计数扫描周期里只读 CNT 寄存器。这种硬件计数和软件计数的区别在用户程序里表现为 C251 这类高速计数器和普通 C0 的差异二次开发时如果给普通计数器加了太高的输入频率丢脉冲是必然的。4. 通信层编程口协议与 Modbus 从站PLC 固件里最容易让人放弃的部分就是通信。协议文档往往只有几页但帧格式、校验、超时重传、与扫描周期的配合每一样都能卡住人。这套 FX3U V8.8 把通信做成了两个互不干扰的通道编程口负责上位机下装程序和在线监视Modbus RTU 走 485 接触摸屏或变频器。4.1 编程口帧格式与握手流程编程口的物理层是 RS422 全双工板子上一般是两个光耦加一个 4 芯端子参数固定 9600bps、8 位数据、偶校验、1 位停止。帧结构是 STX(0x02) 命令字 数据区 ETX(0x03) 校验和校验和是前面所有字节的累加和取低 8 位。/* prog_port.c —— 编程口收帧状态机整体在扫描循环里非阻塞推进 */ typedef enum { PS_IDLE, PS_HEAD, PS_BODY, PS_TAIL } ProtoState; static ProtoState gPs; static uint8_t gRxBuf[256]; static uint16_t gRxLen; void prog_port_poll(void) { while (uart_rx_available()) { uint8_t b uart_rx_byte(); /* 从环形缓冲取一个字节 */ switch (gPs) { case PS_IDLE: if (b 0x02) { gPs PS_HEAD; gRxLen 0; } break; case PS_HEAD: if (b 0x03) { /* 空帧回 IDLE */ gPs PS_IDLE; } else { gRxBuf[gRxLen] b; if (gRxLen 4) gPs PS_BODY; /* 至少收到命令长度 */ } break; case PS_BODY: { uint16_t expect 4 gRxBuf[2]; /* 命令字长度数据 */ gRxBuf[gRxLen] b; if (gRxLen expect) gPs PS_TAIL; break; } case PS_TAIL: gPs PS_IDLE; if (checksum_ok(gRxBuf, gRxLen)) { prog_cmd_dispatch(gRxBuf); /* 分发到读/写/运行/停止 */ } else { uart_send_nak(); /* 校验错回否定应答 */ } break; } } }状态机的写法比收够 256 字节再解析要稳得多。串口数据是流式的谁也没法保证一帧恰好在你读到的时候完整到达。用状态机加环形缓冲区主循环每次都只处理当前能处理的剩下的留在缓冲里等下一个周期扫描周期就不会被通信拖长。几个容易翻车的点半双工切换延时。RS422 编程口本身是全双工问题不大但 485 是半双工发送完最后一个字节后必须等移位寄存器真的空掉查 TC 标志不是 TXE才能把方向引脚拉回接收。这块晚一个字节时间就会把从站回的第一字节吃掉。我一般会多等一个字节时间再切。校验和算法。累加和取低 8 位简单但不是所有帧都这么算某些命令字的校验包含 STX 不包含 ETX。下装程序时的长帧如果校验错上位机会一直重发现象是下载卡在 3%很多人第一反应是串口线坏了。响应延迟。上位机一般有 500ms 超时固件只要保证最坏情况下响应不超过这个值就行。别在收到命令的当场就回把应答排进发送队列等scan_output_refresh()之后再发让通信的时序落在周期边界上。4.2 RS485 收发切换与 Modbus 寄存器映射Modbus RTU 从站这部分相对标准功能码 03读保持寄存器、06写单个、16写多个基本够用。麻烦的是软元件和保持寄存器之间的映射关系——D 寄存器直接对 40001 起那 M 位元件和 X 输入怎么给触摸屏读/* modbus_slave.c —— 保持寄存器区映射用只读镜像避免越权写 */ typedef enum { MAP_D_REG 0, /* 40001~ : 直接映射 D0 起 */ MAP_M_WORD 1, /* 41001~ : M0 起按 16 位打包只读 */ MAP_X_WORD 2, /* 42001~ : X0 起按 16 位打包只读 */ MAP_Y_WORD 3 /* 43001~ : Y0 起按 16 位打包只读 */ } MapRegion; uint16_t modbus_read_reg(uint16_t addr) { if (addr 40001 addr 40001 D_COUNT) return gD[addr - 40001]; if (addr 41001 addr 41001 M_BYTES / 2) return *(uint16_t *)gM[(addr - 41001) * 2]; if (addr 42001 addr 42001 X_BYTES / 2) return *(uint16_t *)gX[(addr - 42001) * 2]; if (addr 43001 addr 43001 Y_BYTES / 2) return *(uint16_t *)gY[(addr - 43001) * 2]; return 0; }把 M/X/Y 打进字寄存器区只读暴露是现场最实用的做法。触摸屏上一个位指示灯绑定到 41001.3 往往比绑到某个线圈地址更省事。这里有个坑*(uint16_t *)gM[...]这种强制转换默认按小端访问如果触摸屏那边按大端解析读出来高低字节是反的。联调时先在串口助手里抓一帧原始数据对一遍字节序比在触摸屏上瞎调快得多。写操作要单独走一条路径且必须做范围检查。D 区可写M/X/Y 严格只读一旦允许通过 485 写 Y 区就等于绕过了整个用户程序的逻辑输出会变成远程直接控制这在安全上是不该出现的口子。提示Modbus 从站和编程口共用一套串口中断服务程序时必须用一个当前活动通道标志互斥否则两个通道的帧会拼在一起。最常见现象是下装程序到一半突然收到一个 Modbus 异常应答上位机报设备无响应。5. 常见问题排查上电不跑、通信超时、定时器不准固件源码能编译通过、烧进板子、灯也亮了离能用还有一段距离。下面这几条是我在调试这类方案时反复碰到的按现象、原因、解决三段走遇到类似的可以直接对号。5.1 上电后 RUN 灯亮但输出全无动作现象程序下装成功上位机监视里 M 点状态正常翻转但 Y 端子万用表量不出电压。原因八成是输出刷新阶段的极性处理反了。FX3U 的 Y 输出是漏型还是源型取决于硬件设计固件里scan_output_refresh()把 Y 映像直接写进 74HC595 或者 GPIO中间少了一次取反或者输出使能引脚没打开。另一种可能是用户程序里用了OUT Y000但下装时地址映射到的是内部继电器。解决先不跑用户程序在scan_output_refresh()里手动把 gY[0] 置 0xFF看端子是否动作这一步能立刻区分是硬件问题还是程序问题。如果手动置位有效再检查下装时的元件地址映射表。我习惯在 board_io.c 里加一个#define OUT_ACTIVE_LOW 1之类的开关而不是分散在各个地方取反。5.2 扫描周期从几十微秒涨到几毫秒现象空程序时gScanTimeUs很小一旦下装几十步的程序扫描时间暴涨上位机监视刷新明显卡顿。原因常见于指令解释器里的循环写错了边界。比如vm_execute()的pc end判断如果il_operand_bytes()算错了一位pc 每次只推进一步操作数被当成操作码执行整个程序就会跑飞甚至死循环。另一种可能是掉电保持区的 Flash 写入被放在了扫描体里每次改一个 D 寄存器就擦一次 Flash那个耗时是毫秒级的。解决用调试器在vm_execute()里下断点单步走一遍前二十条指令看 pc 的推进是否符合预期。Flash 写入一定要做脏页缓存 周期外批量落盘绝不能在指令执行路径上直接擦写。这条我是踩过的一个 D 寄存器在程序里被频繁赋值结果每个周期擦一次 Flash板子跑两天就挂了。5.3 上位机下装程序卡在固定百分比现象编程口连接正常读 PLC 型号、读运行状态都没问题一下装程序就卡在 3% 或者 47% 不动超时后重试。原因长帧的接收缓冲区溢出。下装程序时一帧可能有 200 多字节如果环形缓冲区只有 128 字节收到一半就丢了校验自然不过上位机重发还是丢。另一个原因是状态机里expect算错了长度字段是数据区字节数还是整帧字节数没对齐。解决把gRxBuf扩到 512 字节并在状态机里加一个溢出保护——gRxLen到达上限就强制复位到 PS_IDLE宁可丢一帧回 NAK也不要越界写。另外在串口中断里统计接收字节数和上位机发送的字节数对一下立刻能确认是丢包还是解析错。5.4 定时器设定 1 秒实际跑了 1.2 秒现象T0 设定 K10100ms 型理论 1 秒实测 1.2 秒左右负载轻的时候偏差小负载重的时候偏差大。原因定时器在扫描周期里用delta / step累加整除把余数丢掉了。如果扫描周期是 1.3ms100ms 型的 step100那delta/step在 77 个周期里都是 0只在凑够 100 的时候加一次长期下来就会有系统性偏差而且扫描周期越不规整偏差越大。解决把余数留下来。给每个定时器结构体加一个uint16_t rem;字段累加时t-rem delta; t-cur t-rem / step; t-rem % step;。这样长期精度就只取决于 1ms 时基本身的准确度和扫描周期怎么抖无关。改完之后实测 1 秒设定跑出来 1.000 秒上下这才算合格。5.5 通信正常但读上来的 D 寄存器值对不上现象触摸屏读 D100显示的值和上位机监视里的值不一致有时候差 256 倍。原因字节序或者寄存器偏移错位。Modbus 协议规定寄存器地址从 0 起算而设备手册上写的 40001 是 1 起算的显示地址两者差 1。另外 32 位数据比如 D100/D101 组成一个长整数在寄存器里的高低字顺序不同厂家的默认约定不一样。解决先在串口助手里抓一帧 03 功能码的原始应答手工解析出字节和固件里modbus_read_reg()的返回值逐个比对。确认是偏移错还是字节序错之后在映射函数里统一处理别在上位机侧改——现场换一台屏就又要改一次。6. 二次开发加自定义指令和扩展 IO 的落地思路源码能跑通之后多数人的下一个需求是我要加个自己的功能。这里最省事的入口是指令表不是扫描主循环。加一条自定义指令改三个地方就够了在il_decode.c的操作码表里占一个未使用的码位在il_func.c里写处理函数在上位机侧的指令库里加对应的助记符。整个过程不用碰扫描调度也不用碰软元件定义。比如要加一条把 D 区一段数据求和的指令处理函数长这样。/* 自定义指令示例SUM D100 D110 D200 把 D100~D109 求和结果存 D200 */ void il_sum(const uint8_t *ops) { uint16_t start ops[0] | (ops[1] 8); uint16_t cnt ops[2] | (ops[3] 8); uint16_t dst ops[4] | (ops[5] 8); uint32_t sum 0; if (start cnt D_COUNT || dst D_COUNT) { /* 边界必须查 */ gErrCode ERR_OPERAND_RANGE; gRunFlag 0; return; } for (uint16_t i 0; i cnt; i) sum gD[start i]; gD[dst] (uint16_t)(sum 0xFFFF); /* 结果按 16 位截断符合 PLC 惯例 */ gD[dst 1] (uint16_t)(sum 16); /* 高字放下一个寄存器 */ }边界检查那一段不能省。用户程序里的地址是上位机下装下来的谁也不能保证它不越界一旦越界写穿到相邻数组症状会诡异到怀疑人生。扩展 IO 是另一条常见的改造路线。加一块 16 点输入板要走的是扩展 gX 数组的长度而不是在中断里直接读。具体做法是把X_BYTES从 32 改成 64然后在scan_input_sample()里增加一段对扩展板一般是 74HC165 移位读或者 I2C 的 IO 扩展芯片的轮询读回来的字节填到 gX[32] 开始的位置。用户程序里 X100 以后的点就自动可用了指令解释器一行都不用改。有几个改造前值得先确认的点改造方向主要改动文件风险点加自定义指令il_decode.c / il_func.c / 上位机指令库操作数长度算错会导致整个程序跑飞扩展输入输出点softdev.h / board_io.c / scan.cX/Y 区扩容后 RAM 占用增加注意栈溢出换 MCU 型号整个 bsp 目录SysTick 频率和串口中断优先级要重配增加掉电保持范围flash_eeprom.cFlash 擦写次数有限必须做脏页缓存最后说个习惯。每次改完软元件区的大小或者指令编码我都会强制走一遍三步验证先跑一段只有 LD/OUT 的最小程序确认基本通路再跑一段定时器加计数器的程序确认时基最后用上位机下装一个几百步的完整程序确认通信和译码不丢帧。这三步加起来不到十分钟但能挡掉绝大多数因为改了一行宏定义引发的连锁问题。这类固件的耦合度比普通单片机项目高软元件区一动指令、通信、掉电保存全都跟着动靠肉眼审查是看不出来的。希望这套拆解能帮你少走点弯路。本文还有配套的精品资源点击获取