简介:面向需要掌握工业串行通信的嵌入式开发者,这是一套基于STM32的MODBUS RTU通信实例,覆盖0x03、0x04、0x06、0x10等全部常用功能码,适合从协议理解到工程落地的进阶学习。压缩包共787个文件、约11.14MB,包含HAL库相关c/h源码、Keil工程文件(uvprojx/uvproj)、hex固件、PDF说明、辅助脚本与大量文档页,目录中有bsp_sdio_sd、LCD_RA8875等外设模块,便于按模块查阅。已有5991人学习下载。读者可依据源码了解帧解析、CRC校验、RS485收发控制与中断处理流程,借助html及txt资料梳理协议细节,并通过演示工程快速移植到自己的STM32项目中。整套资源偏向完整工程参考,既照顾初学者的逐步配置需求,也能为需要多功能码实现和排错思路的开发者提供直接参照。
1. 这个 STM32 MODBUS 工程能直接拿去用:功能码一次配齐
做单片机上位机通讯时,最烦的不是写串口收发的底层,而是跟组态软件或触摸屏对协议。对方冷不丁发来个 0x10 写多寄存器,或者 0x0F 写多线圈,你手里只有个读寄存器的 Demo,当场就傻眼。这个工程把 MODBUS RTU 从站常用的 8 个功能码全部实现了,01、02、03、04 读操作和 05、06、0F、10 写操作一应俱全,连异常码回复都处理好了。拿到手把串口和定时器引脚一改,烧进 STM32 就能当个标准 MODBUS 从站用。适合两类人:正在做上位机-单片机联调的开发者,以及想搞明白 MODBUS 协议栈到底怎么在 MCU 上落地的初学者。下文从代码分层讲到实际踩坑,最后给二次开发的思路。
2. 项目骨架与技术选型:从串口到协议栈的分层设计
2.1 拿到手先看这几层
整个工程没有用操作系统,就是裸机状态机。说实话,MODBUS RTU 这种半双工问答协议在单片机上压根不需要 RTOS,一个串口中断加一个定时器中断就能把一帧报文完整拆出来。工程的目录结构很清楚,分成了 bsp、modbus、app 三个层次:
app/ main.c // 主循环,轮询处理接收完成标志 modbus/ modbus.h / modbus.c // 协议栈主逻辑,功能码处理 crc16.h / crc16.c // CRC16 校验计算 regtable.h / regtable.c // 寄存器地址映射表 bsp/ uart.c / uart.h // 串口 + RS485 方向控制 timer.c / timer.h // 帧超时定时器这个分层的用意很直接:bsp 层只负责把字节收进来、把字节发出去,完全不理解报文内容;modbus 层收到一帧完整数据后做 CRC 校验和功能码分发;regtable 层把协议里的寄存器地址和单片机内存里的变量对应起来。以后要换芯片平台,只改 bsp 层就行,modbus 层和 regtable 层原封不动搬走。
main 主循环其实极其简单,就是检查一个全局标志位,一帧报文收完了就丢给 modbus_process() 去处理。没有中断嵌套的复杂逻辑,也不会出现在中断里做耗时计算导致丢包的场景。这种设计在 9600 波特率下跑非常稳,串口中断只做了“收字节、喂看门狗定时器”两件事。
2.2 寄存器区没有数据库,只有一块内存
第一次读这个工程的人,最容易懵的地方是寄存器区。MODBUS 协议里说的“保持寄存器地址 0x0000”,在单片机里到底是哪个变量?很多新手以为协议地址就是内存地址,那是大错。这个工程的做法是维护一个全局数组:保持寄存器是 reg_holding[100],线圈是 coil[16],输入寄存器是 reg_input[50],离散输入是 discrete_input[16]。
协议报文的寄存器地址来了之后,先校验地址在不在数组长度范围内,再直接偏移访问数组。比如 03 功能码读保持寄存器,起始地址是 0x0000,数量 10,实际读到的就是 reg_holding[0] 到 reg_holding[9]。你在上位机写入地址 0x0005,操作的其实是数组第 6 个元素。这个映射关系一旦理清,整个工程的代码读起来就通透了。
2.3 为什么选定时器而不是串口空闲中断来判断帧结束
MODBUS RTU 规定一帧报文的结束是“静默 3.5 个字符时间”。有些芯片有串口空闲中断,能自动检测总线空闲,但很多国产单片机和 STM32 的老型号对这种场景支持并不好。工程里用的是通用做法:一个基本定时器,串口每收到一个字节就重置计数,定时器溢出中断里将“帧接收完成”置位。这样不管什么波特率,只要修改定时器的重装载值就行,不依赖特定外设特性。
主循环的逻辑也在性能上做了取舍。协议解析全部放在主循环里做,而不是在中断里做。串口中断只是把数据塞进缓冲区,这样即使协议处理耗时几毫秒,也不会堵塞中断导致后续字节丢失。实际测试时,连续写 50 个寄存器,主循环跑完整个 0x10 功能码处理只需要不到 1ms,远小于 9600 波特率下字节间的传输间隔。
3. 核心模块拆解:一帧 MODBUS 请求从串口到响应的完整链路
3.1 串口接收:用状态机把一帧报文完整收下来
串口接收这块用的是“字节收满 + 超时判定”的状态机。先看 bsp 层的串口中断接收代码:
volatile uint8_t rx_buf[256]; // 接收缓冲区 volatile uint16_t rx_len = 0; // 当前已接收字节数 volatile uint8_t frame_done = 0; // 帧完成标志 void UART_IRQHandler(void) { uint8_t byte; byte = USART_ReceiveData(); // 读寄存器清中断标志 if (frame_done == 0) { rx_buf[rx_len++] = byte; // 上一帧没处理完则丢弃新字节 TIM_ResetCounter(); // 重置帧超时定时器 } }定时器中断里的逻辑是另一个关键点,它负责判定一帧数据是不是结束了:
void TIM_IRQHandler(void) { // 距离最后一个字节超过 3.5 字符时间 if (rx_len > 0) { frame_done = 1; // 通知主循环可以处理了 } }这段代码里的 rx_len 就是实际收到的报文长度。为什么要把 frame_done 置位放在定时器中断里?因为 MODBUS RTU 的帧没有固定长度,请求帧最短 8 字节,最长 256 字节,靠字节数判断不靠谱。常见做法是:字节之间的间隔不超过 1.5 个字符时间就认为是同一帧,超过 3.5 个字符时间就是帧结束。定时器溢出中断刚好就是“超时”信号。要注意的是,每收到一个字节必须重置定时器,不然一帧内字节稍微间隔长一点就会被误判成两帧。
3.2 CRC16:校验不过的帧一律不响应
MODBUS RTU 的 CRC16 是校验的核心,公式是多项式 0xA001 的循环冗余校验,计算方式不复杂。工程里用的是逐位计算法,代码很紧凑,适合放在中断外的主循环里跑:
uint16_t modbus_crc16(uint8_t *frame, uint16_t len) { uint16_t crc = 0xFFFF; for (uint16_t i = 0; i < len; i++) { crc ^= frame[i]; for (uint8_t bit = 0; bit < 8; bit++) { if (crc & 0x0001) { crc = (crc >> 1) ^ 0xA001; } else { crc >>= 1; } } } return crc; }这段代码的入参 frame 是完整的报文(从站地址到数据段),len 是去掉 CRC 部分的长度。返回值就是 CRC16 校验值。发送响应帧时,CRC 的低字节在前、高字节在后,工程里构造响应的时候是这样拼的:
uint16_t crc = modbus_crc16(tx_buf, tx_len - 2); tx_buf[tx_len - 2] = crc & 0xFF; // 低字节 tx_buf[tx_len - 1] = (crc >> 8) & 0xFF; // 高字节协议里 CRC 的字节序是个老坑。接收方向:收到的帧最后两个字节就是 CRC,对比时要先把接收到的高低位组合,再与本地计算值比对。工程里处理方式是先收完帧,再对除最后两字节之外的数据做运算,结果与报文最后两字节做相等比较,不一致则直接丢弃不响应。这样能保证总线上一个 bit 的错误都不会被误处理。
3.3 功能码分发:switch 结构映射 8 个功能码
modbus_process() 是协议栈的核心入口,主循环每检测到 frame_done 就会调用它。函数先取报文第二字节(功能码),通过 switch 分发到不同处理函数:
uint8_t modbus_process(uint8_t *rx, uint8_t *tx) { uint8_t func = rx[1]; // 报文格式:从站地址 + 功能码 + 数据 uint16_t crc = modbus_crc16(rx, rx_len - 2); if ((rx[rx_len-2] | (rx[rx_len-1] << 8)) != crc) { return 0; // CRC 不对直接丢弃 } switch (func) { case 0x01: return read_coils(rx, tx); // 读线圈 case 0x02: return read_discrete_inputs(rx, tx); // 读离散输入 case 0x03: return read_holding_regs(rx, tx); // 读保持寄存器 case 0x04: return read_input_regs(rx, tx); // 读输入寄存器 case 0x05: return write_single_coil(rx, tx); // 写单线圈 case 0x06: return write_single_reg(rx, tx); // 写单寄存器 case 0x0F: return write_multi_coils(rx, tx); // 写多线圈 case 0x10: return write_multi_regs(rx, tx); // 写多寄存器 default: tx[0] = rx[0]; // 回异常码 01:非法功能码 tx[1] = func | 0x80; tx[2] = 0x01; return 3; } }这段代码比较有意思的是异常码的构造方式。MODBUS 协议规定,异常响应帧的功能码是原功能码最高位置 1(即加上 0x80),异常码放第三个字节。01 表示功能码不支持,02 表示地址越界,03 表示数值非法。工程里每个处理函数在地址越界或数量越界时都会返回异常码 02,这一步判断做得很细。
再往下看读保持寄存器这个具体功能码的实现,逻辑是标准的,但地址校验值得注意:
uint8_t read_holding_regs(uint8_t *rx, uint8_t *tx) { uint16_t addr = (rx[2] << 8) | rx[3]; // 起始地址 uint16_t count = (rx[4] << 8) | rx[5]; // 寄存器数量 if (count == 0 || count > 125) { return make_exception(tx, 0x03); // 数量非法 } if (addr + count > REG_HOLDING_SIZE) { return make_exception(tx, 0x02); // 地址越界 } tx[0] = rx[0]; tx[1] = 0x03; tx[2] = count * 2; // 后面跟 count*2 个字节数据 for (uint16_t i = 0; i < count; i++) { tx[3 + i*2] = reg_holding[addr + i] >> 8; // 高字节在前 tx[4 + i*2] = reg_holding[addr + i] & 0xFF; } return 3 + count * 2; // 返回响应帧长度 }这里 count * 2 是因为每个寄存器占两字节,MODBUS 寄存器数据一律是大端传输(高字节在前)。很多新手在这里翻车:单片机内存里是小端存储,直接把寄存器变量内存丢给串口发送,上位机读到的数据高低字节全反了。
3.4 构造响应帧:把寄存器值打包回总线上
写单寄存器(0x06)的实现是理解“寄存器回显”的好例子。协议规定写单寄存器的响应帧是原请求帧原样返回,成功与否以 CRC 校验为准。工程里直接做的是:先按地址写入 reg_holding,再把收到的请求帧拷贝到发送缓冲区返回。这有个细节,写寄存器时操作的对象是内存数组,但要限定地址范围:
uint8_t write_single_reg(uint8_t *rx, uint8_t *tx) { uint16_t addr = (rx[2] << 8) | rx[3]; uint16_t value = (rx[4] << 8) | rx[5]; if (addr >= REG_HOLDING_SIZE) { return make_exception(tx, 0x02); // 地址越界 } reg_holding[addr] = value; for (uint8_t i = 0; i < 6; i++) { tx[i] = rx[i]; // 回显请求帧 } return 6; // 固定 6 字节长度 }写多寄存器(0x10)稍微复杂一点,因为报文结构变了。请求帧是:从站地址 + 功能码 + 起始地址(2B) + 数量(2B) + 字节数(1B) + 数据。工程里在写入前会先校验“字节计数字段”是不是等于“数量 * 2”,不一致就回异常码。为了防止半路写了一半断电导致数据不一致,常见的做法是先存到临时数组,全部校验通过后再一次性 memcpy 到 reg_holding。
这个工程的响应帧长度都是函数返回值提供的,modbus_process 最后会把返回长度交给 bsp 层去触发发送。发送时记得先拉高 RS485 方向引脚,发完全部字节再拉低,这个细节在避坑章节会详细说。
4. 从工程导入到跑通:烧录、接线与串口调试的完整步骤
4.1 硬件准备与 RS485 接线
跑这个工程最低配置是:一块 STM32 最小系统板、一个 MAX3485 或 SP3485 电平转换芯片、一个 USB 转 485 的调试器。接线方面,STM32 的 TX 接 485 芯片的 DI,RX 接 RO,一个 GPIO 引脚接 DE/RE 引脚控制收发方向。A/B 线接 USB 转 485 的 A/B,末端并联一个 120 欧终端电阻,如果距离短不接也能通。
这里有一个容易迷惑的点:RS485 是半双工的,同一时刻只能收或发。工程里 uart.c 里有个 rs485_dir(uint8_t tx) 函数,发送前置高 DE,发送完成中断里或发送后延时拉低。用串口助手调试时,注意别把 A/B 接反,很多新手接反后从站毫无反应,还以为是程序问题。
4.2 用串口助手模拟主站发报文
在还没有上位机组态软件的情况下,直接用串口助手就能验证从站。核心是手动拼报文,比如读从站地址 1 的保持寄存器 0x0000 开始 2 个寄存器,请求帧是:
01 03 00 00 00 02后面还要跟两个字节的 CRC,串口助手里选“按十六进制发送”,可以让工具自动追加 CRC16。发送后从站应该回复:
01 03 04 00 00 00 00其中 04 表示后面跟 4 个字节数据,也就是两个寄存器的值。写单寄存器,把地址 0x0000 的保持寄存器写成 100,报文是:
01 06 00 00 00 64回复是请求帧原样返回。确认了这三个报文,基础通讯链路就算通了。要注意串口参数必须和固件保持一致,工程默认是 9600-8-N-1,改动的话在 uart_init 里修改波特率寄存器配置。
4.3 用 Modbus Poll 做批量验证
串口助手只能一条条手敲,验证 8 个功能码太累。工程的 README 里建议用支持 MODBUS 协议的调试工具来做批量测试。打开工具后新建连接,选串口,波特率 9600、8-N-1,从站地址 1,协议选 RTU。读保持寄存器可以一次性拉 100 个地址,写多寄存器功能码选 0x10,测试 0x0F 写多线圈时会看到位操作的效果。
跑通过程中最有价值的一个验证是同时开两三个监控窗口,分别读保持寄存器、读输入寄存器、读线圈。当用 0x06 写一个保持寄存器后,读保持寄存器窗口里对应地址的值应该立刻变化。这个过程能直观看到寄存器区和内存数组的映射关系。我一般在这步就把寄存器地址规划表准备好,不然调试到一半地址会乱。
4.4 多从机组网与地址配置
MODBUS 总线上可以挂多个从站,区分依据是帧头第一字节的从站地址。工程里的从站地址是通过宏定义的,在 modbus.h 里有一个 MODBUS_SLAVE_ADDR 配置项。改成不同地址编译烧录,就能把多个 STM32 挂同一条总线。主站发 01 开头的帧只有地址为 1 的从站响应,发 02 开头的帧只有地址为 2 的从站响应。这一点测试时可以用两个开发板,一个设地址 1,一个设地址 2,串口助手分别发不同地址的报文,观察各自响应。
注意 RS485 总线是共享介质,同一时刻只允许一个设备发送。多个从站同时响应会造成总线冲突,这也是为什么从站地址必须唯一的原因。如果两个板子地址都设成 1,主站发一帧报文,两个板子同时回,总线直接乱掉。
5. MODBUS 通讯常见坑与排查:实测踩过的 5 个典型问题
5.1 从站死活不回包
现象:串口助手发完报文,从站没有任何反应,串口调试工具的接收区一直空白。
原因排查顺序很重要,先查硬件再看软件。我用万用表量过 A/B 线电压,静态时应该在 2V 以上,0V 附近说明总线没偏置或没接好。其次查 RS485 方向引脚是不是被拉死在某一个电平上。常见做法是确认 DE/RE 引脚初始化为低电平(接收状态),发送时才拉高。另外,串口 TX/RX 有没有交叉接反也是高频问题。软件层最容易忽略的是帧超时定时器没有初始化,定时器中断没跑起来,frame_done 永远是 0,主循环永远进不去处理逻辑。
解决:按上述顺序排查。工程里 uart_init 里应当有独立的串口中断使能,timer_init 里要有定时器溢出中断使能。调试时先用逻辑分析仪看 TX 引脚有没有数据波形,有波形但总线没反应,那就是 485 芯片方向控制或 A/B 接反。
5.2 回包 CRC 校验错误
现象:从站回了数据,但上位机报 CRC 错误,或者串口助手里能看到回包,但数据不对。
原因:最常见的是 CRC 字节序搞反,MODBUS RTU 规定校验值低字节在前、高字节在后。工程里发送函数如果先发高字节,上位机算出来肯定不对。另一个隐蔽原因是发送过程中 RS485 方向引脚拉得太早,最后一个字节还没完全从移位寄存器发出去就切回接收模式,导致末尾的 CRC 少发了一位或被干扰。
解决:检查发送函数中方向引脚切换时机,必须等发送完成中断标志置位后再拉低。代码上建议在发送函数末尾加一个 while (USART_GetFlagStatus(USART_FLAG_TC) == RESET) 等待,然后再拉低 DE 引脚。
5.3 功能码 10 写多寄存器偶尔写不进去
现象:上位机用 0x10 功能码写一批寄存器,有时全部成功,有时部分成功,有时全部失败。
原因:工程里 reg_holding 数组大小有限,如果请求的起始地址 + 数量超出了数组边界,就会发生越界写,把数组后面的程序数据或者变量覆盖掉。这种问题很诡异,因为表现是随机的——有时写的是相邻数组元素,影响不大,有时覆盖到了关键变量,程序直接跑飞。更深一层的原因是,0x10 的请求帧里如果字节计数字段和实际数据长度不匹配,直接按记忆偏移解析就会补到错误的地方。
解决:工程里 read/write 函数中都加了 addr + count > REG_HOLDING_SIZE 的判断,越界就回异常码 02。建议保持这个判断逻辑,不要为了省代码删掉。同时我一般会在 regtable.c 里加一个边界断言函数,所有写操作进函数前统一校验。
5.4 通讯一段时间后从站死机
现象:运行十几分钟或几小时后,从站无响应,看门狗也没有复位。
原因:这是典型的缓冲区溢出问题。MODBUS 报文最长 256 字节,如果 rx_buf 数组只有 128 字节,干扰信号导致假帧过长,接收函数继续往里写,就会把栈或全局变量冲掉。另一个原因是串口中断优先级不当,与定时器中断嵌套导致数据竞争。
解决:工程里 rx_buf 定义的是 256 字节,够用。但如果自己改小过,建议改回来,或者在接收函数里加 rx_len >= sizeof(rx_buf) 的判断,超过长度直接丢弃整帧。串口中断优先级建议设成比定时器低,这样定时器不会打断正在拼接的帧数据。调试时遇到死机也别急于复位,先看串口助手发到第几条报文之后死的,定位是不是特定功能码触发的。
5.5 上位机读线圈读到全 0
现象:01 功能码读线圈,返回的位和实际 GPIO 输出对不上,永远是 0。
原因:线圈变量是一个 uint16_t,每一位代表一个线圈。项目里把线圈的状态和实际 GPIO 输出分离了——coil 数组只是协议层的映射,GPIO 的刷新在主循环里单独做。新手如果直接把 coildata 数组赋值给 GPIO 输出寄存器,会发现所有线圈不管是 ON 还是 OFF,输出全是低电平。另一个原因是协议读写的是位操作,工程里用了 ((coil[addr >> 4] >> (addr & 0x0F)) & 1) 这种位运算来获取一个线圈的值。
解决:确认修改线圈数组之后,有没有调用一个 coil_refresh 之类的应用层函数把位状态同步到 GPIO。没有这个函数就加一个,在写单线圈和写多线圈的处理函数尾部调用。位操作的时候注意地址偏移计算,addr 是在数组元素级别还是字节级别要区分清楚。
6. 二次开发思路:扩展寄存器区并加一个新功能码
6.1 扩展寄存器区的具体改法
实际项目里 100 个保持寄存器往往不够用,比如要存温度、电压、电流等 30 个参数,加上配置参数和校准参数,很快就爆了。扩展的做法不复杂:先在 regtable.h 里改数组大小和边界宏,注意所有功能码处理函数里用的都是 REG_HOLDING_SIZE 这个宏,只改一处。其次如果原工程把 reg_holding 定义成局部变量,要改成全局数组,否则外部函数访问不到。
扩展之后要做一次“寄存器地址规划”,我习惯用表格把地址和含义记录在代码注释顶部。比如地址 0x0000-0x0013 存运行参数,0x0100-0x0110 存配置参数,这样上位机开发同事拿到表就能直接写组态脚本。规划表放 regtable.h 里,兼顾了可移植性和可维护性。关键点在于寄存器数组大小和地址规划是一体的,改数组大小时一定同步检查所有功能码处理函数的地址校验边界。
6.2 新增功能码的接入位置
MODBUS 标准功能码还有几个没实现,比如 0x07 读异常状态、0x08 诊断。如果在 Modbus 标准功能码范围内加,直接在 modbus_process 的 switch 里加 case 就行。如果遇到的是厂商自定义功能码(0x41-0x4F 区间),做法是类似的:先实现处理函数,函数签名保持 uint8_t func_handler(uint8_t *rx, uint8_t *tx),返回值是响应帧长度。然后注册到 switch 里:
case 0x41: return handle_vendor_cmd(rx, tx);接新功能码时,必须遵守前面讲的规则:入参 rx 指向接收缓冲区,tx 指向发送缓冲区;所有多字节数据一律大端传输;处理完返回完整的响应帧长度,发送函数会自动追加 CRC。这样加功能码的过程就是“仿照已有函数改一个取数逻辑”的事,不涉及串口层和定时器层。
6.3 联调验证的流程
改完代码先编译烧录,用串口助手拼最简报文验证新功能码能回包,再通过 MODBUS 调试工具做边界测试。我每次改完都是这样验证的:先测正常范围内的请求,确认返回值正确;再测起始地址等于边界减 1 的请求,确认数组不会越界;最后故意发一个地址超限的请求,确认异常码 02 能正常返回。这三步走完,新功能码基本就没问题了。
从那以后,我每次加新功能码或者扩展寄存器区,都会把上面这套流程强制走一遍:改数组边界、更新地址规划表、烧录试错、边界报文验证。这套工程的代码风格很直白,没有花哨的抽象,适合作为基础框架持续迭代。希望帮到你。
本文还有配套的精品资源,点击获取