ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

STM32运行FX3U V8.8兼容PLC固件源码架构与调试

STM32运行FX3U V8.8兼容PLC固件源码架构与调试

简介:面向工控与嵌入式学习者的一份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~D7999,16 位 */ #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; /* 记录本周期的扫描时间供监视 */ } }

四件事的先后顺序不能随便调:

  1. scan_input_sample()一次性把 32 字节 X 区从 GPIO 读进来。为什么不在指令执行时按需读?因为那样同一条 LD X0 在一段程序里被执行两次,可能读到不同的值,梯形图的逻辑就不闭合了。采样阶段把这一个周期的输入"冻结",是 PLC 一致性的基础。
  2. vm_execute()是耗时大户,里面没有阻塞操作,也不做通信。用户程序多长,这块就多长。
  3. scan_output_refresh()把 Y 映像整体推到输出锁存器(一般是 74HC595 或者直接 GPIO),一次写完整块,避免边算边写造成的中间态。
  4. 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 设定 K10(100ms 型,理论 1 秒),实测 1.2 秒左右,负载轻的时候偏差小,负载重的时候偏差大。

原因:定时器在扫描周期里用delta / step累加,整除把余数丢掉了。如果扫描周期是 1.3ms,100ms 型的 step=100,那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 的最小程序确认基本通路,再跑一段定时器加计数器的程序确认时基,最后用上位机下装一个几百步的完整程序确认通信和译码不丢帧。这三步加起来不到十分钟,但能挡掉绝大多数因为改了一行宏定义引发的连锁问题。这类固件的耦合度比普通单片机项目高,软元件区一动,指令、通信、掉电保存全都跟着动,靠肉眼审查是看不出来的。希望这套拆解能帮你少走点弯路。

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

RELATED READING

延伸阅读

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