☰
基于STM32的FX3U兼容PLC运行时源码架构与二次开发解析
2026/11/4 13:17:47 网站建设 项目流程

简介:这份源码升级包面向研究三菱FX3U系列PLC编程与仿制实现的开发者、嵌入式学习者,围绕V8.8版本提供可参考的固件与工程源码,并涉及STM32与FX3U协同控制的实践场景。压缩包共713个文件、约78.89MB,包含116个C源文件、113个头文件及大量编译中间文件,另有PDF文档、原理图预览、HEX固件、GX Works相关工程与若干说明文本,便于从代码、文档和可烧录文件多个层面理解工程结构。已有1559人学习下载,说明其在PLC源码研读与仿三菱实现方向具有一定参考热度。读者可借此梳理FX3U指令处理、通信交互与STM32底层控制的实现思路,结合示例工程和文档进行二次学习,但资料定位为学习参考,商用前仍需自行验证合规性与完整性。

1. 从一台老设备的控制柜说起:这份 FX3U V8.8 源码能做什么

车间里那台老设备控制柜门一开,里面一块巴掌大的板子跑得好好的,问下来才知道是拿 STM32 做的 FX3U 兼容 PLC——这类东西在工控圈一直有人做,但能做到结构完整、还有版本迭代记录的源码不多见。这份 V8.8 升级版本质就是一套跑在 STM32 上的 PLC 运行时:解析梯形图指令表、维护 X/Y/M/S/T/C/D 软元件区、按扫描周期刷新 I/O、带编程口下载和 Modbus 从站。适合三类人:想把 PLC 从黑匣子变成自己能改固件的电气工程师,做非标设备想砍掉成品 PLC 成本的控制开发者,以及手里已经打好了板子、只缺一套能跑起来的固件的硬件同学。下面按“它怎么分层、指令怎么执行、通信怎么通、哪里容易翻车、怎么改”走一遍。

2. 架构拆解:STM32 上的 PLC 运行时是怎么分层跑起来的

2.1 为什么是裸机 STM32 而不是 Linux 板卡

PLC 的语义核心是“一个扫描周期内 I/O 映像不变”,用户程序看到的 X 值在整个扫描里必须是同一个快照。这个语义在 Linux 上很难保证——调度抖动会把 10ms 定时器拖成 13ms、18ms,梯形图里做的时间逻辑立刻失真。所以这套源码走的是 Cortex-M 裸机路线,常见选型是 STM32F407VGT6(168MHz、192KB RAM、1MB Flash)跑中大型程序,或者 STM32F103VET6(72MHz、64KB RAM)跑小点位机型。F4 的优势不只是主频,而是有 CCIRAM、有 FPU、有更多定时器可以同时做高速计数和脉冲输出。

中断优先级的设计比选型更影响手感。我一般的分法是:SysTick 1ms 时基放最高,因为所有定时器和通信超时都挂在它上面;高速计数和脉冲输出的定时器中断次之,这类任务丢一个脉冲就是丢一个脉冲,不能等;串口收发放第三,用 DMA + 环形缓冲,中断里只搬数据不做解析;扫描主循环永远在最低优先级,谁都能打断它。看门狗(IWDG)独立看门,超时时间取正常扫描周期的 5~8 倍,既能抓住死循环,又不会被一个偶发的长扫描误复位。

裸机的代价是要自己管内存。这套源码里没有 malloc,所有软元件区、指令表缓冲区、通信帧缓冲都是编译期静态分配的,链接脚本里把 .bss 算清楚,RAM 够不够一眼就能看出来。好处是跑上几个月也不会有碎片。

2.2 目录结构与模块边界

一份能二次开发的固件,目录划分比代码本身更重要。这套 V8.8 的结构大体是这样:

FX3U_V88/ ├── bsp/ 裸机外设层, 换 MCU 只动这里 │ ├── bsp_clock.c 时钟树、SysTick 1ms │ ├── bsp_gpio.c X/Y 引脚映射、光耦消抖 │ ├── bsp_tim.c 高速计数、脉冲输出、时基 │ ├── bsp_uart.c 编程口 + Modbus 两路串口 DMA │ ├── bsp_eeprom.c 参数区双备份读写 │ └── bsp_flash.c 用户程序存储、扇区擦写 ├── plc/ PLC 语义层, 与硬件无关 │ ├── plc_softdev.c X/Y/M/S/T/C/D 软元件区读写 │ ├── plc_instr.c 指令表解释器 │ ├── plc_timer.c 定时器/计数器扫描 │ ├── plc_pulse.c PLSY/PLSR 脉冲输出 │ └── plc_scan.c 扫描调度与看门狗 ├── proto/ 协议层 │ ├── prog_port.c 编程口下载/监控协议 │ └── modbus_slave.c Modbus RTU 从站 ├── app/ │ ├── main.c 初始化 + 主循环 │ └── ladder_buf.c 用户指令表缓冲区 └── tools/ 上位机侧编译脚本与测试工装

分层的意义在于:plc/里一行寄存器操作都不该出现,全部通过bsp/暴露的接口拿数据。我见过太多项目把GPIOA->IDR直接写进指令解释器里,后来想把输入点从 32 点扩到 64 点,才发现要改二十几个文件。判断一份源码值不值得接手,就看这条线划得干不干净。

2.3 扫描周期的三段式与主循环顺序

PLC 的扫描是“输入采样 → 程序执行 → 输出刷新”,这个顺序不能图省事调换。主循环的骨架大概长这样:

/* app/main.c —— 主循环只做五件事, 顺序写死 */ int main(void) { bsp_clock_init(); /* 168MHz, SysTick 配 1ms */ bsp_gpio_init(); /* 输出引脚默认全部断开, 防止上电误动作 */ plc_softdev_init(); /* 清软元件区, 再从掉电保持区恢复 D/T/C */ plc_program_load(); /* 从 Flash 载入用户指令表, 校验 CRC */ bsp_uart_init(115200); /* 编程口 115200, Modbus 口 9600 */ while (1) { iwdg_feed(); /* 喂狗: 正常扫描 2ms, 狗 20ms 超时 */ plc_input_refresh(); /* 1. 输入采样: 一次性把 X 区锁存 */ if (plc_state == PLC_RUN) { plc_exec_program(); /* 2. 解释执行用户指令表 */ } plc_output_refresh(); /* 3. 输出刷新: Y 映像整体写回引脚 */ plc_comm_poll(); /* 4. 通信轮询: 状态机, 不阻塞 */ plc_scan_monitor(); /* 5. 记录本周期耗时, 超阈值报警 */ } }

几个参数值得说明。plc_state只有 RUN/STOP 两态,STOP 时跳过用户程序但保留通信和输出刷新,这样上位机在线监控还能读到当前 X 状态,而现场输出全部按停止逻辑走。plc_input_refresh()里做的是整体拷贝而不是逐点读,因为光耦输入在扫描中途变化会让同一段梯形图前后两次读 X0 拿到不同的值,那种 bug 在现场能把人逼疯。plc_output_refresh()一定要放在执行之后,否则用户程序里写的 Y 要到下个周期才生效,时序全乱。

2.4 哪些活必须扔进中断,哪些必须留在主循环

这里有个容易走偏的点:不是所有实时任务都该进中断。SysTick 里只做一件事——维护一个毫秒计数g_ms_tick,别的什么都不干。定时器和计数器的累加放在扫描里做(下一章细说),因为几百个定时器逐个进中断,中断开销比扫描累加还大。真正必须进中断的是三类:高速计数器的硬件计数(TIM 的编码器模式,CPU 完全不参与)、脉冲输出的脉冲个数中断(丢一个脉冲就是位置偏差)、串口 DMA 的收发完成。

串口这块要特别说一句。编程口和 Modbus 口如果在主循环里死等一帧收完,一个 9600 波特率的 Modbus 请求光字符间隔就能拖出十几毫秒,扫描周期直接不可控。这套源码用的是 DMA + 环形缓冲 + 状态机:中断只负责把字节塞进队列,解析在plc_comm_poll()里按状态推进,收不满一帧就下一轮继续。这个设计决定了通信忙的时候扫描周期不会跟着抖。

3. 指令解释器与软元件区:一条 LD 指令从取指到改写的全过程

3.1 软元件区的内存布局与八进制编址的坑

软元件区是整个运行时的地基。位软元件(X/Y/M/S)用位图,字软元件(D/V/Z)用 uint16 数组,这是最省 RAM 也最快的做法:

/* plc/plc_softdev.h —— 软元件区的静态布局 */ typedef struct { uint8_t x[16]; /* X0~X77, 八进制编址, 64 点位图 = 64/8 字节 */ uint8_t y[16]; /* Y 输出映像, 与 x 对称 */ uint8_t m[1024]; /* M0~M8191, 8192 个辅助继电器 */ uint8_t s[128]; /* S0~S1023 状态继电器 */ uint16_t d[8000]; /* D0~D7999, D8000 起是特殊寄存器, 单独放 */ uint16_t t_cur[T_MAX]; /* 定时器当前值 */ uint16_t t_pre[T_MAX]; /* 定时器设定值 */ uint16_t c_cur[C_MAX]; /* 计数器当前值 */ uint16_t c_pre[C_MAX]; } SoftDev_t; extern SoftDev_t g_sd;

位操作用宏,不要为了“优雅”写成函数调用,解释器里这是热点路径:

#define BIT_GET(p, n) (((p)[(n) >> 3] >> ((n) & 7)) & 1u) #define BIT_SET(p, n) ((p)[(n) >> 3] |= (uint8_t)(1u << ((n) & 7))) #define BIT_CLR(p, n) ((p)[(n) >> 3] &= (uint8_t)~(1u << ((n) & 7)))

真正的坑在编址。FX 系列的 X/Y 是八进制编号,X10 是第 8 个输入点,不是第 10 个。指令表里存的操作数如果是十进制 10,直接拿去当位序号会错位。所以中间必须有一层转换:

/* 八进制编号(按十进制存储) -> 位序号: X17 -> 15, X20 -> 16 */ static inline uint16_t io_index(uint16_t octal_no) { return (uint16_t)(((octal_no / 10) << 3) + (octal_no % 10)); }

这个函数用除法而不是移位,因为操作数是按十进制存的,17表示八进制 17。看着别扭,但这样上位机下发指令表时不用做任何转换,可读性也好。踩过一次坑之后,我习惯在plc_softdev_read_bit()里加一个越界断言,X 只允许 0~77、Y 只允许 0~77,越界直接返回 0 并置诊断标志,比悄悄写到 M 区去好排查得多。

3.2 指令表的结构与解释循环

梯形图在下载前会被上位机编译成定长指令表,每条指令四个 16 位字段:操作码加最多三个操作数。定长是为了让pc++就能跳下一条,变长指令表在跳转指令那里会多出一堆边界计算。

/* plc/plc_instr.h —— 一条指令的定长表示 */ typedef struct { uint16_t op; /* 操作码: 高 4 位分组, 低 12 位是具体指令 */ uint16_t s1; /* 操作数1: 高 4 位软元件类型, 低 12 位序号 */ uint16_t s2; /* 操作数2 */ uint16_t s3; /* 操作数3, 三操作数指令用 */ } PlcInstr_t;

解释循环本身就是一个大 switch,核心是那个叫acc的累加器——它代表梯形图里母线的通断状态,LD 类指令给它赋值,AND 类指令跟它做与运算,OUT 类指令把它的值写出去:

/* plc/plc_instr.c —— 主解释循环: 取指 / 译码 / 执行 */ void plc_exec_program(void) { uint16_t pc = 0; uint8_t acc = 0; /* 母线状态累加器 */ uint8_t mps[8], sp = 0; /* MPS/MRD/MPP 用的栈, 深度 8 足够 */ while (pc < g_prog_len) { const PlcInstr_t *ins = &g_prog[pc]; switch (ins->op & 0x0FFF) { case OP_LD: acc = softdev_read_bit(ins->s1); break; case OP_LDI: acc = !softdev_read_bit(ins->s1); break; case OP_AND: acc = acc && softdev_read_bit(ins->s1); break; case OP_OR: acc = acc || softdev_read_bit(ins->s1); break; case OP_ANB: if (sp) acc = (uint8_t)(acc && mps[--sp]); break; case OP_MPS: if (sp < 8) mps[sp++] = acc; break; case OP_OUT: softdev_write_bit(ins->s1, acc); break; case OP_SET: if (acc) softdev_write_bit(ins->s1, 1); break; case OP_RST: if (acc) softdev_write_bit(ins->s1, 0); break; case OP_MOV: softdev_write_word(ins->s1, softdev_read_word(ins->s2)); break; case OP_DMOV: softdev_write_dword(ins->s1, softdev_read_dword(ins->s2)); break; case OP_CJ: if (acc) { pc = ins->s1; continue; } break; case OP_END: return; default: g_diag_bad_instr++; break; } pc++; } }

几个细节要交代清楚。acc是局部变量而不是全局,因为一次扫描里它只在解释器内部有意义,放全局反而容易被通信中断改脏。ins->op & 0x0FFF掩掉分组位,这样同一条实现可以被不同分组复用。OP_CJ里直接pc = ins->s1; continue;跳过结尾的pc++,是无条件跳转和条件跳转共用一条指令的写法。default分支不能空着,统计到一个诊断计数器上,现场如果发现程序行为不对,先看这个计数是不是在涨,指令表损坏或者上位机版本不匹配会立刻暴露。

性能上,这个 switch 在 168MHz 的 F4 上跑一千条指令大概 40~60 微秒,一万条也就半毫秒出头,扫描周期完全可以接受。要进一步优化,常见做法是把最热的 LD/AND/OUT 三条放 switch 最前面,或者把softdev_read_bit里的类型判断提到外层做一次缓存。

3.3 定时器与计数器:为什么不在中断里累加

定时器的实现方式最能看出一套 PLC 固件懂不懂现场。教科书做法是每个定时器挂一个硬件定时器或者进中断累加,实际根本扛不住——一个 256 点的定时器数组,全速运行时中断开销能把扫描周期吃掉一半。这套源码走的是扫描式累加:

/* plc/plc_timer.c —— 每个扫描周期调用一次, 用 ms 差值驱动所有定时器 */ void plc_timer_scan(void) { uint16_t elapsed = (uint16_t)(g_ms_tick - g_last_ms); if (elapsed == 0) return; /* 扫描太快, 本周期没有整毫秒流过 */ g_last_ms = g_ms_tick; for (uint16_t i = 0; i < T_MAX; i++) { if (!BIT_GET(g_sd.m, M_T_ACTIVE_BASE + i)) continue; /* 未激活跳过 */ /* T0~T199: 100ms T200~T245: 10ms T246~T255: 1ms 保持型 */ uint16_t base = (i < 200) ? 100 : (i < 246 ? 10 : 1); g_t_acc[i] += elapsed; while (g_t_acc[i] >= base) { g_t_acc[i] -= base; if (++g_sd.t_cur[i] >= g_sd.t_pre[i]) { g_sd.t_cur[i] = g_sd.t_pre[i]; BIT_SET(g_sd.m, M_T_DONE_BASE + i); /* 输出触点置位 */ break; } } } }

g_t_acc是每个定时器独立的余数累加器,这个设计解决的是扫描周期不是 1ms 整数倍的问题。假设扫描周期 2.3ms,100ms 定时器不可能每扫一次加 100,必须先把零头攒起来。不攒会怎样?一个设定 100 的 100ms 定时器,实际延时可能在 100~130ms 之间浮动,做包装机封口延时就是几毫米的偏差,客户一眼看出来。

计数器同理,只是在计数脉冲上升沿时c_cur++,不需要时基。保持型的定时器和计数器(T246 以上、C 区的一部分)要在掉电前把当前值存进参数区,上电恢复,这部分和 4.3 的参数保存是同一套机制。

3.4 高速计数与脉冲输出:交给硬件,别交给软件

普通计数器的计数频率受扫描周期限制,扫描 2ms 意味着一秒最多识别 500 个脉冲,接个 1000 线的编码器直接废。所以高速计数必须走定时器的硬件计数通道:

/* bsp/bsp_tim.c —— TIM2 配编码器模式, 对应 C251 高速计数器 */ void bsp_hsc_init(void) { /* PA0/PA1 接编码器 A/B 相, 硬件自动四倍频, CPU 零参与 */ TIM_EncoderInterfaceConfig(TIM2, TIM_EncoderMode_TI12, TIM_ICPolarity_Rising, TIM_ICPolarity_Rising); TIM_SetCounter(TIM2, 0); TIM_Cmd(TIM2, ENABLE); } /* 读的时候直接取寄存器, 但要注意 32 位计数器跨扫描的回绕 */ uint32_t plc_hsc_read(void) { return (uint32_t)TIM_GetCounter(TIM2); }

脉冲输出(PLSY 类指令)反过来用:频率写进 PWM 的 ARR/PSC 决定,脉冲个数用一个更新中断去减,减到零关输出。参数上要注意 ARR 计算的最小频率和最大频率边界——频率太低(比如 1Hz)时 PSC 会溢出,频率太高(超过 200kHz)时 F4 的定时器分辨率不够,占空比会失真。常见做法是把频率钳在 1Hz~200kHz,超范围直接报错不执行,而不是悄悄跑个错频率出来。加减速脉冲(PLSR)则要额外维护一张加速曲线表,在更新中断里改 ARR,这个实现成本明显更高,源码里是否支持要看具体版本,改之前先确认定时器中断余量够不够。

4. 通信与掉电保存:编程口下载、Modbus 从站和参数不丢的实现

4.1 编程口协议:下载、校验和在线监控

编程口是这套固件跟外部交换数据的通道,跑在 RS422 上(差分、抗干扰、能拉几十米),默认 115200 8N1。帧格式通常是中规中矩的:起始符 0x02、站号、命令码、长度、数据区、校验和、结束符 0x03。解析千万不能用“等整帧收完再处理”的写法,一帧最长可能几百字节,9600 波特率下就是几百毫秒的阻塞。正确做法是逐字节状态机:

/* proto/prog_port.c —— 逐字节推进的接收状态机 */ typedef enum { ST_HEAD, ST_ADDR, ST_CMD, ST_LEN, ST_DATA, ST_SUM } RxState_t; void prog_port_rx_byte(uint8_t byte) { static RxState_t st = ST_HEAD; static uint8_t sum, len, idx; switch (st) { case ST_HEAD: if (byte == 0x02) { sum = 0x02; st = ST_ADDR; } break; case ST_ADDR: /* 站号匹配或广播 0xFF 才继续, 否则丢弃重来 */ if (byte == g_station_no || byte == 0xFF) { sum += byte; st = ST_CMD; } else st = ST_HEAD; break; case ST_CMD: g_frame.cmd = byte; sum += byte; st = ST_LEN; break; case ST_LEN: len = byte; idx = 0; sum += byte; st = (len > 0 && len <= sizeof(g_frame.data)) ? ST_DATA : ST_HEAD; break; case ST_DATA: g_frame.data[idx++] = byte; sum += byte; if (idx >= len) st = ST_SUM; break; case ST_SUM: if (byte == sum) g_frame_ready = 1; /* 校验通过, 交给主循环处理 */ st = ST_HEAD; break; } }

命令码一般是这几类:批量读软元件、批量写软元件、在线监控(周期读指定区)、强制置位复位、RUN/STOP 切换。下载流程是上位机把梯形图编译成指令表,按每帧 128 字节分包下发,每包带偏移地址和 CRC,全部收完后固件做一次整体校验,通过才写进用户程序区并置 RUN 允许位。这个“先校验再落盘”的顺序不能省,否则下到一半断电,上电后程序区是半截指令,跑起来行为完全不可预测。

状态机的sum是累加和不是 CRC,这是为了跟老上位机兼容。校验强度确实弱一些,所以指令表落盘前的那次整体 CRC 必须做,两道关总比一道强。

4.2 Modbus RTU 从站:把 D 区当保持寄存器

现场设备十有八九要 Modbus,这套固件带一个 RTU 从站,通常跑在第二路串口上,9600 8N1。功能码支持 01/03/05/06/0F/10 这几条基本够用,寄存器映射最常见的是把 D0~D3999 直接映射成保持寄存器 0~3999,M 区映射成线圈。

/* proto/modbus_slave.c —— 03 功能码: 读保持寄存器 */ void mb_handle_03(const uint8_t *rx, uint8_t *tx, uint16_t *tx_len) { uint16_t start = (uint16_t)((rx[2] << 8) | rx[3]); uint16_t count = (uint16_t)((rx[4] << 8) | rx[5]); /* 数量 0 或超过 125 个寄存器, 按协议回异常码 03 */ if (count == 0 || count > 125) { mb_exception(tx, 0x03, 0x03, tx_len); return; } tx[0] = g_station_no; tx[1] = 0x03; tx[2] = (uint8_t)(count * 2); for (uint16_t i = 0; i < count; i++) { uint16_t v = mb_read_reg(start + i); tx[3 + i * 2] = (uint8_t)(v >> 8); tx[4 + i * 2] = (uint8_t)(v & 0xFF); } uint16_t crc = mb_crc16(tx, 3 + count * 2); tx[3 + count * 2] = (uint8_t)(crc & 0xFF); /* CRC 低字节在前 */ tx[4 + count * 2] = (uint8_t)(crc >> 8); *tx_len = (uint16_t)(5 + count * 2); }

两个参数容易错。CRC 在 Modbus RTU 里是低字节先发,跟数据区的高字节先发相反,写反了对方一帧都收不对。帧间隔判定用 3.5 个字符时间,9600 波特率下约 4ms,很多实现图省事用 1ms 定时器判断,结果高速率下把一帧拆成两帧、低速率下把两帧粘成一帧,这是现场最难查的一类问题。稳妥做法是按当前波特率算出一个 tick 数,用一个独立的定时器在收到字节时重置,超时即判帧结束。

4.3 掉电保存:双备份加序号,写坏的永远是旧的那份

参数区存的是掉电要保持的 D 区、保持型计数器和定时器当前值、以及通信配置。这块最容易翻车:写在内部 Flash 里,擦除要按扇区,扇区擦到一半掉电,整块参数就没了。这套源码用的是双备份 + 序号 + CRC 的结构:

/* bsp/bsp_eeprom.c —— 参数块结构: 两份轮换写, seq 大的有效 */ typedef struct { uint32_t magic; /* 0x504C4331, 用来判断是不是首次上电 */ uint16_t seq; /* 写入序号, 0~65535 循环, 读取时取较大的那份 */ uint16_t crc; /* 从 len 开始到数据末尾的 CRC16 */ uint16_t len; /* 数据区长度, 换版本时校验布局是否匹配 */ uint8_t data[512];/* D 区保持部分 + 保持型 T/C + 通信参数 */ } ParamBlock_t;

写入时序是:先擦备份 B、写备份 B、回读校验、成功后再把序号加一。读取时两份都读出来,magic 和 CRC 都过的才认,都过就比 seq。这样任何时刻掉电,最多丢掉最后一次写入,绝不会两份一起坏。

什么时候写也有讲究。常见错误是在掉电检测中断里直接写 Flash——中断里写 512 字节,几十毫秒,等写完电容早放光了,反倒把正在写的那份弄坏。正确做法是掉电中断里只置一个save_pending标志并立刻切断输出,真正的写动作放在正常运行时(RUN 切 STOP 时写一次)或者上电自检时补写。硬件上配个几百微法的电容撑住 MCU 几十毫秒,就有足够余量把这套流程走完。

5. 调试避坑:烧录、I/O 抖动、扫描超时和通信丢包的排查记录

5.1 现象:程序烧进去跑一秒就复位,Debug 也进不去

原因:IWDG 喂狗语句放在了扫描主循环末尾,但初始化阶段有个while等待外部 EEPROM 上电就绪,等不到就一直转,狗先咬。另一种情况是中断优先级配错,串口中断里做了解析又调了printf,卡在中断里出不来,主循环根本没机会喂狗。

解决:把喂狗提前到初始化完成之后、主循环开始之前,并且所有while等待都加超时计数。中断服务函数里只置标志、搬数据,绝不做解析和打印。调试阶段先把 IWDG 关掉,确认功能正常再打开,用调试器看g_ms_tick是否还在增长,一秒内不涨就是卡死了。

5.2 现象:输入点接了个按钮,按一下计数器加了七八个

原因:机械按钮抖动在光耦输入端被完整保留,扫描周期 2ms,抖动持续 5~10ms,正好抓到好几个上升沿。有人在硬件上加了 RC 但时间常数算错,1k + 0.1μF 才 0.1ms,等于没加。

解决:软件消抖放在plc_input_refresh()里,连续三次采样一致才更新 X 映像:

/* bsp/bsp_gpio.c —— 三次一致才认, 2ms 扫描对应 6ms 消抖窗口 */ static uint8_t x_raw[8], x_cnt[64]; void plc_input_refresh(void) { for (uint8_t i = 0; i < 64; i++) { uint8_t now = (uint8_t)((GPIO_ReadInputData(GPIOE) >> (i & 15)) & 1u); if (now != ((x_raw[i >> 3] >> (i & 7)) & 1u)) { if (++x_cnt[i] >= 3) { /* 连续 3 次不同才翻转 */ if (now) BIT_SET(g_sd.x, i); else BIT_CLR(g_sd.x, i); x_cnt[i] = 0; } } else { x_cnt[i] = 0; } } }

硬件侧同步把 RC 改成 10k + 0.1μF(约 1ms)配施密特触发器输入,软硬两道一起上才踏实。

5.3 现象:设备跑了半天,扫描周期从 2ms 慢慢涨到 8ms,最后看门狗复位

原因:指令表里有一条CJ跳转到自身形成死循环,或者用户在梯形图里用了间接寻址的变址寄存器 V/Z,越界后跳到了非法地址。还有一种隐蔽情况:Modbus 主站疯狂轮询,每次请求都在主循环里同步等应答,通信越忙扫描越长。

解决:解释循环里加指令计数上限,一个扫描周期执行超过g_prog_len * 2条指令就强制退出并置诊断标志,这能兜住绝大多数跳转异常。通信改成完全非阻塞状态机,收不满就走。同时把扫描时间统计做出来,超过设定阈值(比如 10ms)就点亮一个诊断位,让上位机能读到,别等看门狗复位了才去猜。

5.4 现象:Modbus 通信时好时坏,同一帧有时通有时不通

原因:帧间隔判断用 1ms 定时器,9600 波特率下 3.5 字符是 4ms,判断粒度太粗;RS485 收发切换没有延时,最后一字节还在移位寄存器里就切了方向,对方收到的是残帧;多台设备共地不良,差分电平飘。

解决:帧间隔按波特率算成微秒级,用一个专门的定时器做;发送完成后等 TC(传输完成)标志再切方向,别用 TXE;总线上首末两端各加 120Ω 终端电阻,屏蔽层单点接地。查这种问题示波器比日志有用得多,差分探头挂在 A/B 上看波形,一眼就能看出是残帧还是干扰。

5.5 现象:断电重启后参数回到出厂值,之前设的全没了

原因:在掉电中断里写 Flash,写到一半电压塌了,正在写的那份备份被写坏;另一份备份因为序号比较逻辑写反,被判定为旧数据丢弃,两份都失效。

解决:掉电中断只置标志、关输出,不在中断里动 Flash。写入时机挪到 RUN→STOP 切换和上电自检补写。序号比较改成“两份都校验通过才比 seq,只有一份通过就用那一份,都不过才回出厂值”,这个降级逻辑必须有,否则一次意外掉电就得返厂。

6. 二次开发:改指令集、加扩展模块和自己造一套验证工装

改指令集是最常见的二次开发需求,比如加一条自定义的SCALE指令做模拟量标定。路径很清晰:plc_instr.h里加一个操作码,plc_instr.c的 switch 里加一个 case,然后在tools/下的编译脚本里给它分配编码并定义操作数个数。验证方式我强烈建议不要直接上整机梯形图,而是写一段只包含这一条指令的最小程序,用在线监控读 D 区结果:

# tools/check_instr.py —— 最小验证: 下发一条指令, 回读 D100 看结果 import serial def build_frame(cmd, payload, station=0x01): body = bytes([station, cmd, len(payload)]) + payload return b'\x02' + body + bytes([sum(body) & 0xFF]) + b'\x03' ser = serial.Serial('COM5', 115200, timeout=0.5) # 写入一段 3 条指令的程序: MOV K100 D100 / SCALE D100 K2 D101 / END ser.write(build_frame(0x4D, b'\x00\x00' + bytes([0x5A, 0x00, 0x64, 0x00]))) print(ser.read(16).hex()) # 期望返回校验通过的应答 ser.write(build_frame(0x4B, b'\x00\x64\x00\x02')) # 读 D100~D101 print(ser.read(16).hex()) # D101 期望是 200

加扩展模块是第二个高频需求。X/Y 映像区是按 64 点预留的,扩到 128 点时把x[16]改成x[32]、同步改io_index()的越界上限、在bsp_gpio.c里补上第二组端口的映射即可。这里有个容易漏的地方:上位机的软元件输入范围也要跟着改,否则用户写 X100 编译不过。真正让我吃过亏的是扩展模块的输入刷新要放在同一批次里,曾经把扩展点的刷新写在主循环外侧,结果同一次扫描里主板上 X0 和扩展板上 X100 差了半个周期。

验证工装这件事,我现在的习惯是每接一套新板子,先写一个最简单的自测程序:X0~X7 直通到 Y0~Y7,中间串一个 100ms 定时器翻转,然后用一根杜邦线逐个短接输入点,拿万用表量输出。这一步花二十分钟,能挡掉后面至少两天。从那以后我每次改完bsp_gpio.c或者动了映像区布局,都强制把这段自测梯形图重新走一遍,不管改得多小。希望这套流程也能帮到你,先把自测跑通,再去碰指令集。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询