
简介STM32仿三菱PLC源码项目是一份基于STM32F10x系列微控制器模拟三菱PLC功能的嵌入式实操资料适合希望在工业控制场景中复用PLC逻辑或学习MCU实现方式的开发者。项目核心覆盖PLC的I/O处理、定时器、计数器等基本逻辑并通过中断服务例程响应外部事件代码多基于HAL/LL库编写便于理解与二次开发。压缩包共1.76MB、352个文件以h头文件120个、c源码72个为主另含o目标文件、lst列表文件、icf链接配置文件及hex烧录文件等项目文件结构完整可直接在IAR等IDE中打开编译。已有1068人学习下载。通过阅读源码开发者可以掌握GPIO配置、定时器设定、中断处理流程以及基于串口/RS485等方式模拟三菱PLC通信协议的设计思路对提升STM32底层开发和工业自动化技能均有直接帮助也可为自研低成本PLC方案提供参考。1. 仿三菱PLC源码的真相在STM32上要复现的是解释器下载站里凡是“STM32仿三菱PLC源码.rar”这种名字后缀里通常还会带着blewmsm、reasonn一类的字符那只是网盘系统的上传编号和作者没任何关系先忽略它。真正值钱的部分不是那一堆Keil工程文件而是把三菱FX系列指令表字节码逐条解释执行的软PLC内核。三菱FX的运算核心是专用ASIC在STM32上做不到芯片级复刻能做的是让FX指令集在行为上兼容GX Works2写出来的梯形图能被你的板子跑起来上位机发出来的三菱FX读写帧能被正确应答。这套工程的核心由四块组成设备表、指令译码器、内存映像、通讯协议栈下面按设计顺序把每一块的参数设定和踩坑点展开。适合毕设、从单片机转PLC的工程师以及想搞懂国产“三菱兼容板”本质的维护人员。2. 软PLC的整体架构源码包先拆成四件套再谈移植拿到这类源码第一步不是急着打开 Keil 编译而是先按模块把压缩包拆开。见过几十个类似工程后我发现它们再怎么改名换姓核心目录基本是四件套设备表头文件定义 X、Y、M、D 这些软元件的地址范围解释器源码负责逐条执行指令表字节码内核调度处理定时器、输入输出刷新和扫描周期协议栈常见的是三菱FX串口协议或 Modbus。如果压缩包里的文件比这还乱就先亲手把这四类文件分出来再决定要不要留外设驱动层。2.1 设备表与软元件内存映像先定义寄存器再谈逻辑软PLC的一切运算都建立在“软元件地址”上。三菱FX3U有输入继电器X、输出继电器Y、内部继电器M、状态继电器S、数据寄存器D、定时器T和计数器C它们位宽不同、寻址方式不同映射到STM32内存里的方案直接决定解释器好不好写。我常用的映射如下表软元件典型范围位/字内存映射建议说明XX0~X177位bit_dev[0x0000~0x007F]输入继电器只读YY0~Y177位bit_dev[0x0080~0x00FF]输出继电器刷新到GPIOMM0~M7679位bit_dev[0x0100~0x1F7F]内部继电器断电不保持SS0~S4095位bit_dev[0x2000~0x2FFF]状态继电器步进指令用DD0~D7999字d_dev[0x0000~0x1FFF]数据寄存器32位用连续两个T/C视型号混合单独结构体数组含当前值和触点状态把位软元件和字软元件分开成两个数组是因为它们在解释器里访问频率高位按字节寻址、字按int32_t寻址都能用偏移量直接算指针。不要用C语言位域去表达X和Y位域的字节序和赋值原子性在不同编译器下表现不一致职场上吃过亏的人不少。/* 设备表头文件要点 */ #define BIT_X_BASE 0x0000U #define BIT_Y_BASE 0x0080U #define BIT_M_BASE 0x0100U #define BIT_S_BASE 0x2000U #define D_REG_MAX 0x2000U /* D0 ~ D7999 */ static uint8_t bit_dev[0x3000]; /* 位软元件统一数组 */ static int32_t d_dev[0x2000]; /* 字软元件统一数组 */ static inline uint8_t* bit_ptr(uint32_t addr) { return bit_dev[addr 3]; }这里bit_ptr()把一个位地址换算成字节指针位偏移用addr 7取。按位访问时先读字节、改位、再写回整个软PLC只有一个地方允许这么操作其它地方一律经过这个函数避免多任务环境下位被破坏。后面所有指令、所有协议帧都只操作这套数组上位机读D寄存器本质上就是读d_dev[偏移]。2.2 扫描周期与IO刷新把三菱PLC时序搬到STM32上三菱PLC的执行模型是周而复始的“输入刷新—程序执行—输出刷新”中间还夹着通讯服务和系统监视。STM32上模仿这套模型最直接的做法是开一个1ms的定时器中断作为时基主循环里跑非阻塞的指令表执行器。static uint32_t tick_ms; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM6) { tick_ms; sys_tick_handler(); /* 定时器、计数器在这里累加 */ } } void PLC_Loop(void) { uint32_t scan_start tick_ms; input_refresh(); /* 读GPIO填入BIT_X_BASE区 */ exec_instruction_table(); /* 逐条执行字节码 */ output_refresh(); /* 把BIT_Y_BASE区推到GPIO */ comm_service(scan_start); /* 串口协议解析放在程序执行后 */ }输入刷新和输出刷新之间夹着整个指令表扫描。扫描周期取决于指令条数和每条指令的平均执行时间STM32F103在72MHz下跑一个包含2000条基本指令的扫描大约在1ms量级F407能快三到四倍。10ms的PLC扫描周期对大多数继电器控制足够伺服轴控制场景才需要压到1ms以内这时要换成按任务分段执行。输出刷新还有一个设计要点不能在解释器每执行完一条OUT Y0就立即写GPIO必须等整个扫描结束统一刷新。否则线圈在一个扫描周期内多次赋值时GPIO上会出现中间毛刺继电器线圈不怕晶体管输出和伺服使能信号会出问题。2.3 通讯服务怎么放协议栈只配当配角把三菱FX协议处理和Modbus从站逻辑直接塞进指令执行器会拖垮扫描周期。通用的做法是先定义一个通讯缓冲区和状态机在主循环的空闲时间或定时器中断的尾部取帧、解析、响应。对于裸机工程我习惯用一个comm_service()函数放在输出刷新之后因为它不参与控制逻辑晚几个毫秒响应完全没影响。void comm_service(uint32_t scan_start) { uint32_t now tick_ms; uint32_t used now - scan_start; if (used 5) { /* 扫描周期吃不饱才处理通讯 */ fx_serial_poll(); /* 三菱FX串口状态机 */ modbus_poll(); /* Modbus RTU或TCP状态机 */ } }这个5ms的门槛是经验值指令表执行用时越短留给通讯的时间越多。如果扫描用时已经超过设定的PLC周期就不该再分时间给协议栈否则控制周期抖动会变大。把通讯放主循环尾部还有一个好处调试时用上位机读写软元件和PLC本体的控制逻辑不会相互阻塞。3. 把梯形图翻译成STM32字节码指令码表与解释器实现三菱GX Works2把梯形图编译成设备内部的指令表格式而不是直接生成二进制机器码。仿制PLC的最终目标就是吃掉这份指令表。指令表由助记符加操作数组成比如LD X0、OUT Y0、MOV K100 D0我们要做的是再把它编码成自己的紧凑字节码让STM32解释器方便解码。3.1 指令集子集的取舍先定一个能跑通的最小集合三菱FX3U完整指令集有几百条全部实现不现实维护成本太高。工程上先挑一个“最小可跑集合”覆盖逻辑控制和简单运算基本指令选LD、LDI、AND、ANI、OR、ANI、OUT、SET、RST应用指令选MOV、CMP、ADD、SUB、INC、DEC、PLS、PLF。这套子集能解决车间里八成以上的继电器逻辑和数据处理场景。指令操作数类型字节码长度说明LD位地址0x014字节读触点状态入栈LDI位地址0x024字节读反相触点入栈OUT位地址0x034字节栈顶结果写位软元件SET位地址0x044字节置位保持为1MOV字源/字目标0x1010字节源操作数写入目标D区ADD字源/字源/字目标0x1114字节两个源相加写入目标操作码用一个字节位地址用24位字地址用16位立即数用32位。这样MOV K100 D0编码成0x10 0x00000064 0x0000整个字节码定长与变长混合解释器通过操作码表知道每条指令的长度取指的时候就能安全跳过。3.2 解释器主循环先写出能逐条取指的C骨架指令表在STM32里以字节数组存放可以是const数组烧在Flash也可以放在外部Flash的某个扇区。解释器的工作很简单拿操作码查长度表跳转到对应的执行函数。typedef struct { uint8_t op; /* 操作码 */ uint8_t len; /* 本指令总长度 */ void (*exec)(const uint8_t *pc); } OPCODE_TBL; static const OPCODE_TBL op_tbl[] { { 0x01, 4, exec_ld }, { 0x02, 4, exec_ldi }, { 0x03, 4, exec_out }, { 0x04, 4, exec_set }, { 0x10, 10, exec_mov }, { 0x00, 0, NULL } /* 结束标记 */ }; void exec_instruction_table(void) { const uint8_t *pc program_start; while (pc program_end) { uint8_t op *pc; /* 查表找到len和exec */ pc op_tbl[op].len; op_tbl[op].exec(pc - op_tbl[op].len); } }这里每个执行函数都接收指向本指令起始地址的指针由它自己解析操作数。操作码查找用的是线性表指令条数不多时没问题追求性能可以改成256项的直接跳转表以操作码为下标访问函数指针。要注意每个执行函数末尾都必须把PC推进到正确位置否则程序会跑飞调试时第一步先打印每条指令的起始地址。3.3 LD、OUT、SET/RST的执行语义逻辑结果栈与扫描一致性三菱PLC执行基本指令时本质上维护着一个逻辑结果栈。LD把触点状态压栈AND把栈顶和触点状态做与运算ANB则把栈中两个相邻结果做与运算。自己写解释器时这个栈是核心数据结构。#define STACK_MAX 16 static uint8_t lstack[STACK_MAX]; static uint8_t sp; static void exec_ld(const uint8_t *pc) { uint32_t addr load_24bit(pc 1); lstack[sp] read_bit(addr); /* 触点状态入栈 */ } static void exec_out(const uint8_t *pc) { uint32_t addr load_24bit(pc 1); if (sp 0) sp--; write_bit(addr, lstack[sp]); /* 栈顶结果写线圈 */ } static void exec_set(const uint8_t *pc) { uint32_t addr load_24bit(pc 1); write_bit(addr, 1); /* SET立即置位不受断电影响 */ }栈深16对绝大多数梯形图够用三菱FX系列逻辑栈本来也只有8层。执行OUT时要把栈顶弹出因为一个输出指令结束后逻辑结果栈应该恢复到进入该梯级前的高度。一个很容易踩的坑是SET和RST在同一个扫描周期里连续出现时最终状态以最后一条为准解释器不需要做特殊处理按顺序执行自然满足。倒是定时器T的OUT需要特殊对待它写的是定时器当前值而触点状态是定时器比较结果两者不能混在一个位数组里所以T的当前值放在d_dev触点状态单独放位数组。4. 三菱FX串行帧与Modbus双栈让仿真PLC对得上GX Works2和上位机仿三菱PLC做得再像对外没有协议口就是一块跑着奇怪程序的单片机。实际项目中至少需要两种通讯口一个走三菱FX编程口协议让GX Works2能连机监视一个走Modbus RTU或TCP让HMI、SCADA和物联网网关能读数据。这个双栈设计是我做协议层时最常用的方案。4.1 三菱FX编程口协议从串口BUFFER到命令分派三菱FX系列编程口协议属于“命令-响应”型帧格式大致是帧头、命令、软元件地址、数据长度、校验和、帧尾。具体地址码规则因软元件类型而异D寄存器和M继电器的地址码计算方法不同工程上最可靠的做法是查地址码映射表而不是套公式硬算。typedef struct { uint8_t buf[128]; uint8_t len; uint8_t state; } FX_FRAME; void fx_serial_poll(void) { uint8_t ch; while (uart_receive_byte(ch) OK) { fx_feed_byte(fx_frame, ch); if (fx_frame.state FX_FRAME_COMPLETE) { fx_dispatch(fx_frame); /* 解析命令并生成响应 */ fx_frame.len 0; fx_frame.state FX_FRAME_IDLE; } } }fx_dispatch内部通过一个命令分派表完成读写。三菱编程口常见命令包括读取软元件、写入软元件、运行控制和停止控制读取D寄存器对应响应帧里要按地址顺序回传数据。这里最麻烦的是握手流程GX Works2连机时会先读设备型号、检查程序区校验和必须模拟出一台真实FX3U的行为GX Works2才会继续往里灌程序。我一般先实现设备型号应答和D区读写连上在线监视后再逐步补程序区读写。4.2 Modbus RTU/TCP从站把设备表暴露成一个规整寄存器区上位机侧通常不希望直接和三菱协议打交道所以第二个从站接口给Modbus。Modbus对PLC的抽象是线圈和保持寄存器正好能和我们的设备表对上D区映射到保持寄存器M区映射到线圈X区可以映射到只读离散输入。Modbus对象功能码映射区域说明保持寄存器0x03读/0x06写单个/0x10写多个D0~D7999对应d_dev[0]起线圈0x01读/0x05写单个/0x0F写多个M0~M7679对应bit_dev[0x100]起离散输入0x02读X0~X177对应bit_dev[0x0]起Modbus RTU的CRC16校验必须自己实现常见的查表法占用256字节ROM速度最快。TCP模式则省掉CRC但要多维护一个连接状态机。uint16_t modbus_crc16(const uint8_t *data, uint16_t len) { uint16_t crc 0xFFFF; for (uint16_t i 0; i len; i) { crc ^ data[i]; for (uint8_t bit 0; bit 8; bit) { if (crc 1) crc (crc 1) ^ 0xA001; else crc 1; } } return crc; }CRC计算里0xA001是Modbus标准的多项式高低字节交换后输出。新手常犯的错是把CRC放到帧尾前忘了交换字节序导致上位机一直报帧错误。调试时用串口助手先发一条已知正确CRC的报文核对能省下大量排查时间。4.3 双协议共存的坑波特率、半双工方向控制与超时两个协议栈共用同一个串口时最先踩的坑是RS485的方向切换。RS485是半双工发送数据前必须拉高发送使能发送完再拉低切换太晚会把响应帧的末尾几个字节吃掉上位机看到的就是CRC错误。控制使能脚的位置应该在最后一个字节写入数据寄存器之后、等待发送完成中断时。第二个坑是GX Works2的连机超时。FX协议的命令帧之间有严格的时序要求单片机扫描周期超过50ms时GX Works2会认为通信异常。解决方法是把通讯解析放到定时器中断尾部而不是主循环保证应答延迟稳定。第三个坑是协议不分家有些仿PLC源码偷懒Modbus和FX协议共用一个缓冲区调试时总出现响应错乱。正确的做法是两个协议各有独立环形缓冲区收到字节后先判断帧头是FX风格还是Modbus风格再送入对应解析器。5. 验证仿三菱PLC的对错回环测试与扫描周期实测软件仿真做得再好最终要看能不能被GX Works2当一台真FX3U来用。常见的验证路径是回环测试加逻辑分析仪实测。5.1 先用GX Works2生成指令表再转成字节码在GX Works2里新建一个FX3U工程写一段最简单的逻辑X0触点驱动Y0线圈外加一条MOV K1234 D0。编译后查看指令表会看到类似LD X0、OUT Y0、MOV K1234 D0这样的助记符序列。用一个小脚本把助记符转成自己解释器的字节码烧进STM32的Flash。这一步不建议手工转一个几十行的Python脚本就能完成还能顺便检查每条指令的字节数。5.2 串口回读软元件三行代码验证MOV和D寄存器程序跑起来后用一个串口调试助手或Python脚本向Modbus从站发送“读保持寄存器D0”的报文看返回的数值是不是1234。Modbus的寄存器地址和PLC的D编号有一个映射关系通常D0对应地址0x0000直接在映射表里查。import serial, struct ser serial.Serial(COM3, 115200, timeout0.5) req bytes([0x01, 0x03, 0x00, 0x00, 0x00, 0x01]) crc 0xFFFF for b in req: crc ^ b for _ in range(8): crc (crc 1) ^ 0xA001 if (crc 1) else crc 1 req struct.pack(H, crc) ser.write(req) resp ser.read(7) print(resp.hex()) # 分析第3字节起的寄存器值只读D0这一个寄存器就能确认三件事Modbus协议是否正常、设备表地址映射是否正确、MOV指令的执行结果是否写入了目标寄存器。如果D0返回1234说明从指令译码到内存写入的整条链路是通的。5.3 实测扫描周期用SysTick计数和示波器对拍逻辑对了只算一半还要看时序是否满足PLC场景。用一个GPIO在output_refresh()开始时拉高、结束时拉低示波器探头夹上读出高电平宽度就是指令表的净执行时间。再用SysTick记录两次exec_instruction_table()调用的间隔对比是否有抖动。对于继电器控制程序扫描周期抖动在1ms内都能接受如果抖动超过5ms优先检查通讯状态机里是否有阻塞式等待。把扫描周期打印到串口配合示波器波形就能评估解释器的性能余量。以后拿到任何仿三菱PLC源码包先找设备表头文件和指令码表对着GX Works2监视画面逐条核验这两张表读透后剩下的问题基本都能归约成内存读写。本文还有配套的精品资源点击获取