STM32实现三菱PLC协议源码:MDK混合编译与联调指南
2026/9/16 5:28:56 网站建设 项目流程

简介:面向嵌入式与工业控制开发者的STM32与三菱FX3U PLC通信源码包,基于MDK5环境编译可零错误生成固件,解决工业现场中单片机与PLC之间数据互通、远程监控、故障诊断与数据采集等实际问题。压缩包共403个文件,以C源文件和H头文件为主,另含hex固件、map映射及部分编译中间文件,并按版本目录分组,便于检索与对比,整体大小13.33MB。源码覆盖串口初始化、数据帧发送接收、PLC响应解析、定时器与Flash外设驱动等关键内容,同时保留uvproj/uvopt工程配置,基本可解压后直接打开编译;作者已亲测可用,适合直接学习、调试或作为现场项目原型基础。已有1104人学习下载,适合熟悉C/C++与串行通信、希望快速实现STM32与FX3U联调的工程技术人员。

1. STM32三菱PLC源码为何能跳过采购周期直接用于联调

当现场要临时验证 PLC 通信逻辑,或者要把一台设备的控制系统改成低成本 PLC 方案时,最尴尬的不是写不出梯形图,而是手里没有三菱 PLC 实体,却又要和 GX Works 这类官方工具对得上号。标题里这个 STM32 三菱 PLC 源码,解决的就是这个缺口:把三菱 PLC 的通信协议、软元件映射和基础扫描逻辑移植到 STM32 上,用 MDK 编译,工程里 C 和 C++ 混用,代码完全开放。它和模拟器的区别在于,跑在真实 MCU 上,可以通过 RS232/RS485 接上位机、触摸屏或变频器,适合做协议转换网关、低成本 PLC 替代和产线测试工具。你不需要先买到 PLC 才能推进项目,烧录一块最小板就能直接和上位机对话,再从通信层面反推协议细节。

2. 先读懂三菱 PLC 协议与外设资源分配,再动 MDK 工程

2.1 三菱 PLC 三种通信协议:编程口、MC 协议和 Modbus 的区别

三菱 PLC 对外通信最常见的是 FX 系列编程口协议、MC 协议(A/Q/L 系列)以及内置的 Modbus-RTU。你拿到的 STM32 源码,多数实现的是 FX 编程口协议,因为 GX Developer / GX Works2 通过这个口读取软元件、写入程序,使用频率最高。编程口物理层是 RS422,逻辑层是单主多从,字节间隔通常要求在几毫秒内完成回包。MC 协议则在 Q 系列和以太网模块上更常见,报文带有命令头、子命令、长度和校验,适合通过网关转发到串口。

Modbus-RTU 是三菱 FX3U 之后才普遍支持的协议,源码若是直接做从站,也能复用同一套寄存器映射表。三者在帧结构上差异很大,但底层都是串口传输。建议拿到源码后先确认实现的是哪一种。如果是从 Gitee 或 GitHub 上找的通用软 PLC,一般会同时保留编程口和 Modbus-RTU 两条通路;如果是自主开发的网关项目,可能只实现了 MC 协议的子集。

三菱 PLC 的软元件编号需要当成地址映射来看,而不是直接当成内存地址。D 寄存器是字元件,M 是中继,X 是输入点,Y 是输出点。STM32 源码里必然存在一个映射表,负责把协议里的 D100 转换成内部的 RAM 数组下标,把 X0 转换成 GPIO 输入引脚。映射表的完整程度直接决定了你能读写多少软元件,以及越界访问会不会把系统搞挂。

2.2 STM32 外设分配:USART + DMA 收帧,定时器定扫描周期

STM32 作为从站时,串口必须能接收不定长帧。如果串口中断每收到一个字节就进一次业务处理,上位机在连续读大量寄存器时,MCU 很容易漏掉后半个包。更常见的做法是让 USART 工作在空闲中断加 DMA 接收模式,收到完整一帧后触发一次空闲中断,CPU 在中断里只拷贝数据并置位协议解析标志,主循环再去逐帧解析。这样中断开销被压缩到微秒级,即使系统正在执行梯形图逻辑,也不会丢帧。

定时器用于生成 PLC 的扫描周期。典型扫描周期是 1ms 到 10ms,源码里会把扫描周期做成一个宏,配合 SysTick 或基础定时器。扫描流程分为三个动作:读输入映像区、执行用户逻辑、写输出映像区。串口通信中的数据读写请求会被穿插在扫描周期的固定位置,否则会出现一个扫描周期内读到的寄存器值前后不一致。

GPIO 分配要尽量按照 PLC 命名习惯来做。X 输入接按键、传感器和编码器,Y 输出接继电器和指示灯,M 是内部继电器。这样写梯形图逻辑时,不需要反复去看原理图。源码如果有硬件抽象层,通常只需要改引脚定义和串口号,协议栈部分完全不用动。

2.3 源码里哪些模块能直接复用,哪些必须重写

协议解析、帧校验、D 寄存器读写和应答帧构建这些模块,一般都能直接复用。需要重写的是底层串口驱动、定时器和 GPIO 初始化。如果源码使用了 C++,串口和 GPIO 往往封装成类,换一个 STM32 型号只需要改类实现,不用碰协议类。但要注意中断回调函数属于 C 函数,C++ 类的成员函数不能直接注册到中断向量表,需要加一层 C 接口中转。

通信参数也要提前确认。三菱 FX 系列编程口常见配置是 9600bps、7 位数据、偶校验、1 位停止位。如果源码默认配置成 8N1,GX Works 连接时大概率报“通信超时”。不要直接照抄源码里的串口初始化结构体,先看协议文档或监听原装 PLC 的报文。换主控型号时,还要注意时钟频率不同,串口波特率误差要控制在 2% 以内,否则长帧传输会偶发错帧。

MDK 工程的编码也值得处理。很多源码是从老工程师手里传下来的,中文注释和字符串用的是 GBK 编码,在 Windows 环境用 MDK 打开没问题,一旦用 VS Code 等编辑器修改并保存为 UTF-8,编译后中文字符串会变成乱码,有的甚至会引发隐形错误。MDK 本身的编码设置要和源文件保持一致,或者在工程里统一转成 UTF-8。若使用 VS Code 配置 C/C++ 环境做编辑和检索,转码后重新编译是更稳妥的选择。

3. MDK 编译 C/C++ 混合工程的 4 个关键设置

3.1 新建 MDK 工程并导入源码文件的正确顺序

在 MDK 里新建工程时,选错芯片型号是初学者最常见的问题。不同 STM32 系列的外设寄存器地址不同,协议栈底层的寄存器操作如果按 F1 写死,换到 F4 会编译通过但运行不正常。正确步骤是先确认目标芯片的具体型号,比如 STM32F103RCT6,在 MDK 的选择器里找到对应型号,生成启动文件和分散加载文件。

源码导入时,建议保持原目录结构,不要把所有 .c 文件拖到一个文件夹。协议栈和驱动分开,后续升级和维护才方便。在 Project 面板里右键添加组,分别命名为 App、Protocol、HAL_Driver 等,再把文件按组添加。添加完之后把 Include Path 配置到协议栈头文件目录、HAL 驱动头文件目录和 MDK 自动生成的 RTE 目录。缺少头文件路径的典型报错是 fatal error: xxx.h: No such file or directory。

3.2 C 和 C++ 混合编译的 extern "C" 处理

源码若同时有 .c 和 .cpp,协议栈的核心通常用 C 实现,硬件驱动和业务逻辑用 C++ 封装。C 文件调用 C++ 函数,或者 C++ 调用 C 函数,都必须遵守 C 链接规则。有效的做法是在公共头文件里加上:

#ifdef __cplusplus extern "C" { #endif uint8_t plc_protocol_process(uint8_t *data, uint16_t len); void plc_memory_init(void); #ifdef __cplusplus } #endif

这样做是为了告诉 C++ 编译器,括号内的函数使用 C 语言链接规范,避免函数名被 C++ 名字修饰后,链接阶段找不到符号。没有加 extern "C" 时,编译.cpp 文件却调用 .c 函数,链接器会报 undefined symbol。有些源码里会在协议实现文件顶部直接加 extern "C" 包裹,这也是常见写法,但公共头文件统一处理更规范。

MDK 从 V5.36 开始默认编译器是 AC6,源码如果是老工程,建议先在 Target 页面把编译器切换为 AC5,或者直接使用 Keil MDK 自带的默认版本。AC5 与 AC6 的语法差异主要体现在位段操作、内联汇编和 struct 对齐上。老代码里常见的 __irq 关键字在 AC6 里要换成 __IRQ,__forceinline 也要改成attribute((always_inline))。如果坚持用 AC6,在 C/C++ 页面添加 --c99 --gnu11 可减少兼容问题。

3.3 分散加载文件与堆栈尺寸调整

MDK 自动生成的 .sct 文件按芯片的 Flash 大小和 RAM 地址分配区域。源码自带的 .sct 若是从其他型号拷过来的,内存区域名和首地址往往对不上。实际使用中建议以 MDK 自动生成的分散加载文件为基础,再手动增加协议栈需要的外部 RAM 区域。比如把 D 寄存器映射区放到 SRAM 的固定段:

LR_IROM1 0x08000000 0x00100000 { ER_IROM1 0x08000000 0x00100000 { *.o (RESET, +First) *(InRoot$$Sections) .ANY (+RO) } RW_IRAM1 0x20000000 0x00020000 { .ANY (+RW +ZI) } RW_IRAM2 0x68000000 0x00100000 { plc_memory.o (+RW +ZI) } }

协议栈的 D 寄存器数组和缓冲区放在 RW_IRAM2 外部 RAM 区,可以节省内部 SRAM。但要注意外部 RAM 的访问速度比内部 RAM 慢,如果扫描周期要求很短,建议把高频访问的软元件放内部 RAM,低频数据文件放外部 RAM。C++ 工程还需要检查栈空间,MDK 默认 Stack_Size 通常是 0x400,C++ 构造函数和局部对象如果分配较多,可调整到 0x1000 以上。

3.4 三处必调的参数:波特率、站号、软元件上限

源码里一般会有一个 config.h 或 plc_config.h,集中放通信参数。下表列出建议调整的项目:

参数常见取值影响范围
串口波特率9600 / 19200 / 115200编程口与上位机通信速度,改错会导致握手失败
从站站号0 到 31多机挂总线时决定地址,重复会导致总线冲突
最大 D 寄存器数1000 / 8000决定 RAM 占用和读写命令的合法范围
最大 M 继电器数512 / 2048影响梯形图中间变量的容量
扫描周期1ms 到 10ms影响实时性和通信响应速度

如果源码支持通过拨码开关设置站号,可以把 config.h 里的宏设计成运行时读取,否则每次改站号都要重新烧写固件。批量生产时这个差异会决定效率。波特率一般建议保持协议默认值,因为触摸屏和上位机大多按固定波特率配置,源码里提供的默认值不要轻易改。若现场因线缆较长产生误码,可尝试降低波特率并同步修改上位机设置。

MDK 工程编码默认 GBK,若协议栈内部有中文打印字符串,保持全局统一编码。把编辑器编码改为 UTF-8 后,字符串常量会以 UTF-8 字节序列写入 Flash,串口终端若按 GBK 解析就会打印乱码。所以编码调整不是单点操作,要与上位机和日志分析工具保持一致。

4. 把三菱 PLC 协议栈跑稳的代码级细节

4.1 串口接收状态机:帧头、帧长、校验分步处理

三菱 FX 编程口帧以 STX(0x02) 开头,以 ETX(0x03) 结束,中间包含命令码、软元件地址和数据长度,末尾是累加和。实现协议栈时,一个健壮的接收状态机比在中断里做复杂判断更重要。状态机每一步只处理一个字节,能避免半包数据被误解析。

#define FRAME_STX 0x02 #define FRAME_ETX 0x03 typedef enum { RX_WAIT_STX, RX_WAIT_CMD, RX_WAIT_LEN, RX_WAIT_DATA, RX_WAIT_SUM, RX_WAIT_ETX } rx_state_t; static rx_state_t rx_state = RX_WAIT_STX; static uint8_t rx_data[512]; static uint16_t rx_index; static uint16_t rx_length; static uint8_t rx_sum; void plc_uart_rx_byte(uint8_t byte) { switch (rx_state) { case RX_WAIT_STX: if (byte == FRAME_STX) { rx_data[0] = byte; rx_index = 1; rx_sum = 0; rx_state = RX_WAIT_CMD; } else { // 没有STX,直接丢弃,避免垃圾数据干扰 } break; case RX_WAIT_CMD: rx_data[rx_index++] = byte; rx_sum += byte; rx_state = RX_WAIT_LEN; break; case RX_WAIT_LEN: rx_data[rx_index++] = byte; rx_sum += byte; rx_length = byte; if (rx_length == 0 || rx_length > sizeof(rx_data) - 8) { rx_state = RX_WAIT_STX; } else { rx_state = RX_WAIT_DATA; } break; case RX_WAIT_DATA: rx_data[rx_index++] = byte; rx_sum += byte; if (rx_index >= rx_length + 4) { rx_state = RX_WAIT_SUM; } break; case RX_WAIT_SUM: if (rx_sum == byte) { rx_data[rx_index++] = byte; rx_state = RX_WAIT_ETX; } else { rx_state = RX_WAIT_STX; } break; case RX_WAIT_ETX: if (byte == FRAME_ETX) { // 完整帧,交给协议解析 plc_protocol_parse(rx_data, rx_index); } rx_state = RX_WAIT_STX; break; } }

这个状态机把每个字节的处理量控制得非常小,适合在串口中断里直接调用。要注意 rx_length 字段不一定代表剩余字节数,不同协议命令的长度定义不同,需要对照协议文档确认。帧超时处理一般放在定时器中断里,超过 20ms 未收到下一个字节就强制复位状态机,防止总线断开后状态机卡死在 WAIT_DATA。

4.2 和校验与 CRC 的工程实现

FX 编程口使用累加和校验,MC 协议在二进制模式下使用 CRC-16。源码里往往分两套函数,一套算 sum8,一套算 CRC16。CRC 计算不能只靠查表法,对存储空间受限的 MCU,可以用直接计算法,代码更短但耗时稍长。更常见的处理是在协议栈初始化时生成查找表,把 CRC 计算降到每次查表加异或。

static uint16_t crc16_table[256]; void crc16_table_init(void) { for (uint16_t i = 0; i < 256; i++) { uint16_t crc = i << 8; for (uint8_t j = 0; j < 8; j++) { crc = (crc & 0x8000) ? (crc << 1) ^ 0x1021 : crc << 1; } crc16_table[i] = crc & 0xFFFF; } } uint16_t crc16_update(uint16_t crc, uint8_t byte) { return (crc << 8) ^ crc16_table[((crc >> 8) ^ byte) & 0xFF]; }

计算 CRC 的初始值和多项式需要与三菱协议对齐,常见多项式是 0x1021,初始值为 0xFFFF。报文里的每个字节都要参与计算,包括帧头但不包括结束符。计算完的 CRC 要按低字节在前、高字节在后的顺序填入报文,很容易写错,确认源码时用已知报文对照一遍最好。

写入操作时,上位机会发来一帧数据,PLC 从站先校验数据帧的 CRC,通过后才会执行写入并回放应答。如果写寄存器时收到错误响应,先排查 CRC 顺序,再看地址字节的高低位是否反了。三菱 PLC 的地址大多是小端存放,D0 的地址可能被写成两个字节,顺序相反会造成写入到不存在的软元件区。

4.3 寄存器读写映射与定时扫描周期联调

协议栈解析完读写命令后,需要把命令中的软元件地址转换成内部索引。常见映射函数是 range check 加偏移:

uint16_t plc_memory_read_word(uint16_t addr) { if (addr >= D_REG_BASE && addr < D_REG_BASE + D_REG_NUM) { return d_register[addr - D_REG_BASE]; } // 返回异常值,调用方可以广播错误码 return 0xFFFF; }

地址映射不是简单的加减,三菱 PLC 的软元件编码在不同协议里甚至不是连续区间。写这个函数时,先把 D、M、X、Y 四类软元件的划分区间写到表格里,再实现转换逻辑。转换逻辑里必须做边界检查,越界访问要么返回错误码,要么直接忽略,否则缓冲区溢出会把系统堆栈写烂。

PLC 的扫描周期和通信模块的并发访问,用双缓冲解决。一个缓冲区给串口 DMA 写数据用,另一个给主循环解析用。DMA 收到完整帧后,交换两个缓冲区的指针,再通知协议解析线程。这样协议解析即使耗时较长,也不会延迟下一帧接收。状态机和扫描周期的关系,可以用一个简化的主循环体现:

while (1) { uint32_t tick = get_tick_ms(); if (tick - last_scan_tick >= PLC_SCAN_PERIOD_MS) { last_scan_tick = tick; plc_input_refresh(); plc_logic_execute(); plc_output_refresh(); } plc_uart_poll(); }

扫描周期不能缩到太短,否则串口解析会频繁抢占 CPU。实测 1ms 扫描周期配合 9600bps 串口时,CPU 占用率约 30% 左右;如果系统中还跑着显示刷新或运动控制,建议把扫描周期放宽到 5ms。

4.4 C++ 封装硬件接口时避开中断上下文

很多现代化源码用 C++ 写业务层,把底层串口封装成 UartDriver 类。但在中断里调用类成员函数要小心。中断服务函数是普通 C 函数,当中断里需要执行类成员方法时,正确的做法是先在中断里关闭中断并保存状态,设置一个 flag 后立即退出,等主循环处理。

// 中断服务函数 extern "C" void USART1_IRQHandler(void) { if (LL_USART_IsActiveFlag_RXNE(USART1)) { uint8_t byte = LL_USART_ReceiveData8(USART1); rx_ring_buffer.push(byte); } }

如果协议栈是纯静态函数,中断可以直接调用;如果协议栈对象在堆上分配,中断里访问成员变量时,必须保证该对象的内存不会在中断期间被回收。避免在中断里使用 new、delete、malloc、free 等动态内存操作,否则系统容易在低内存时进入 HardFault。C++ 的虚函数在中断里也能调用,但会增加几微秒的开销,如果协议栈对响应时间有要求,优先把高频路径设计成普通函数或内联函数。

5. 现场联调前,先用这三个验证动作确认源码可靠

5.1 用串口助手做最小回环测试

烧写固件后,把 USB 转 TTL 模块接到 STM32 的串口,打开串口助手,手动发送一帧已知的读取命令。以 FX 编程口为例,发送02 01 00 00 00 01 03 04 00 00 00 00 05 03,观察返回帧是否携带相同的命令码和数据区。这里要注意串口助手的发送模式,有的工具默认按 ASCII 发送,必须切换到 HEX 发送。如果返回帧是乱码,先检查波特率和校验位是否和源码配置一致。

5.2 用逻辑分析仪抓取字节间隔和 RS485 方向切换

RS485 通信最容易坏在现场方向切换瞬间。STM32 发送完毕后,如果立即把 DE 引脚拉低,最后一个字节的尾巴会被截断。判断方法是抓取 TX 引脚和 DE 引脚的波形,看 DE 是否在发送结束标志后继续保持了一个字符时间。源码里发送函数通常会用 delay 或等待发送完成中断,再关 DE。检查时把时间基准放到微秒档,逐个字节数位宽,确认波特率误差低于 2%。如果使用 DMA 发送,还要确认 DMA 传输完成中断触发时,移位寄存器里的数据是否已经完全发出。

5.3 长时间连接测试并验证断线重连

用 GX Works 或 Modbus Poll 持续读写寄存器 30 分钟以上,期间人为插拔串口线、切换站号、改变波特率,看协议栈能否自动恢复。源码里如果没有超时复位机制,断线后状态机会卡在等待数据的状态,上位机只能复位重启。在协议栈初始化时,把接收状态机、DMA 缓冲区、定时器计数器全部重置,同时保留软元件数据。实际项目中,这个设计能省去大量现场上门维护的时间。

若最终目的是用这个软 PLC 做 PID 控制,那么三菱 PLC 的 PID 指令会依赖 D 寄存器里的比例增益、积分时间和微分时间参数。确认源码的 D 寄存器映射完整,且上位机写入 PID 参数后不会被扫描周期无条件清零,再用小功率加热或角度控制测试自整定效果。先用最小系统验证通信,再逐步增加功能模块,这个源码才能真正替代实体 PLC 完成产线任务。

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

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

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

立即咨询