简介一份面向STM32嵌入式开发者的MODBUS RTU通信实例工程围绕功能码覆盖全面的特点给出从硬件配置、协议栈搭建、帧解析到应用层联调的完整参考。压缩包共787个文件、11.14MB以C语言源码.c/.h、Keil工程.uvprojx、汇编启动文件.s及HTML文档和PNG截图为主同时包含HEX固件、MAP映射、PDF文档等文件便于直接编译烧录和对照学习。实例从串口与RS485接线入手详细讲解帧结构、CRC校验、中断接收和所有主流功能码读线圈、读寄存器、写单/多寄存器等的实现过程配套文档与图片辅助理解目录结构清晰工程文件完整注释简明方便按模块查阅。已有5991人学习下载。实例无论对于刚接触MODBUS协议的初学者还是希望在产品中快速集成RTU通信的工程师都能提供可运行的代码与排错思路适合作为二次开发的起点。1. 单片机STM32 MODBUS RTU通讯实例功能码覆盖最全的落地参考做嵌入式串口通讯的人应该都有过这种经历MODBUS RTU协议看着简单无非是地址、功能码、数据、CRC校验可真到联调的时候粘包、断包、字节序反了、寄存器地址对不上任何一个坑都能让人debug到深夜。这套基于STM32的MODBUS RTU通讯实例最大的价值在于把常用功能码几乎全部实现了——0x01读线圈、0x02读离散输入、0x03读保持寄存器、0x04读输入寄存器、0x05写单个线圈、0x06写单个寄存器、0x0F写多个线圈、0x10写多个寄存器外加异常码处理。对正在做单片机通讯开发或者手头有设备需要对接组态屏、PLC、上位机的人这套代码可以直接作为协议栈嵌入自己的工程。2. 项目结构和通讯模型从串口字节到MODBUS帧的完整链路2.1 整体目录结构与文件职责拿到工程后先别急着编译花十分钟把目录结构看明白后面改起来会顺手很多。这套工程按模块划分得比较清晰大致是这样的布局Project/ ├── Core/ │ ├── Inc/ │ │ ├── modbus.h // MODBUS协议栈头文件功能码宏定义、帧结构体 │ │ ├── modbus_rtu.h // RTU传输层接口CRC、串口收发函数声明 │ │ ├── stm32f1xx_it.h // 中断服务函数声明 │ │ └── main.h // 主函数相关配置 │ └── Src/ │ ├── modbus.c // 协议解析核心功能码分发处理 │ ├── modbus_rtu.c // 串口接收状态机、CRC校验、帧组装 │ ├── stm32f1xx_it.c // 串口中断、定时器中断入口 │ └── main.c // 寄存器区定义、主循环调度 ├── Drivers/ │ ├── CMSIS/ │ └── STM32F1xx_HAL_Driver/ // HAL库源码不必改动 └── MDK-ARM/ // Keil工程文件modbus.c和modbus_rtu.c是这套代码的心脏。前者负责协议语义也就是收到一帧请求后主站要读什么、写什么寄存器映射到哪些内存地址后者负责传输语义解决串口上字节流怎么切成帧、CRC怎么校验、超时怎么判断这些底层问题。新工程移植的时候main.c里寄存器区的定义几乎要全部重写但modbus_rtu.c的接收逻辑基本可以原样搬走。2.2 串口配置和接收模型设计MODBUS RTU 物理层就是标准串口常见的配置是 9600 波特率、8 数据位、1 停止位、无校验当然代码里也支持 4800、19200 这些常用速率。HAL 库的初始化代码比较样板化关键在于接收模型的选择。这套工程采用的是串口中断逐字节接收 定时器超时判帧的方案而不是空闲中断IDLE方案。原因是很多 STM32 型号的 UART 空闲中断在用 DMA 接收时才有意义而逐字节中断的方式对帧间隔的控制更直观。RTU 协议规定一帧内的字节间隔不得超过 1.5 个字符时间帧与帧之间的间隔不得小于 3.5 个字符时间定时器就是用来卡这个边界的。// 串口接收中断每次收到一个字节存入环形缓冲区并重置超时定时器 void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { g_rx_buffer[g_rx_index] g_rx_byte; // 字节进缓冲区 if (g_rx_index RX_BUF_SIZE) { g_rx_index RX_BUF_SIZE - 1; // 防溢出保护超长帧截断 } __HAL_TIM_SET_COUNTER(htim2, 0); // 计数器清零 HAL_TIM_Base_Start_IT(htim2); // 启动超时定时器 HAL_UART_Receive_IT(huart1, g_rx_byte, 1); // 继续接收下一字节 } }这里有几个关键参数需要说明RX_BUF_SIZE一般设为 256 字节MODBUS RTU 最大帧长是 256 字节但在有广播地址和扩展协议的场景下建议留 300 字节的余量g_rx_byte是单字节接收缓冲每收完一字节必须立刻重新调用HAL_UART_Receive_IT挂下一次接收否则串口会卡住不再进中断。如果你在调试时发现只能收到第一字节九成是漏了这行重挂接收的代码。2.3 定时器超时时间怎么算定时器的超时时间不是随便填的它直接影响通讯的稳定性。按 RTU 规范1.5 个字符时间和 3.5 个字符时间在 9600 波特率下分别约为 1.6ms 和 3.6ms。工程里一般把超时判帧时间设成 3.5 个字符时间也就是一帧收完后约 4ms 没有新字节进来就认为这一帧结束了。void TIM2_IRQHandler(void) { if (__HAL_TIM_GET_FLAG(htim2, TIM_FLAG_UPDATE) ! RESET) { __HAL_TIM_CLEAR_FLAG(htim2, TIM_FLAG_UPDATE); HAL_TIM_Base_Stop_IT(htim2); g_frame_ready 1; // 置位帧就绪标志交给主循环处理 } }以 9600 波特率为例一个字符帧含起始位、8 数据位、停止位约 1.04ms3.5 个字符时间就是 3.64ms。定时器预分频设成 1MHz72MHz 主频分频 72计数周期设 4000就能得到 4ms 的超时窗口。这个值向上取整一点没关系但不建议超过 10ms否则主站连续发帧时从站会把两帧当成一帧处理解析必然报错。主循环的调度方式很简单轮询g_frame_ready标志位置位就调用modbus_poll()处理帧处理完后清标志。很多初学者会把这个逻辑直接写进中断里这是不合适的——MODBUS 功能码的处理涉及寄存器读写、批量操作和响应发送放进中断会阻塞其他中断响应串口很容易丢字节。宁可主循环晚几个毫秒处理也不能在中断里做重活。3. 核心实现拆解一帧MODBUS请求从进入到返回的完整流程3.1 CRC校验与帧合法性检查MODBUS RTU 的帧格式是地址码 1 字节 功能码 1 字节 数据区 N 字节 CRC 校验 2 字节低字节在前。CRC 是数据校验的核心工程里用的是标准 CRC-16/MODBUS 算法多项式 0xA001初始值 0xFFFF。uint16_t modbus_crc16(uint8_t *buffer, uint16_t length) { uint16_t crc 0xFFFF; for (uint16_t i 0; i length; i) { crc ^ buffer[i]; for (uint8_t bit 0; bit 8; bit) { if (crc 0x0001) { crc 1; crc ^ 0xA001; } else { crc 1; } } } return crc; }这段代码每次处理一个字节对 CRC 值做 8 次移位和异或。逻辑上不复杂但有两个细节很容易翻车。第一是 CRC 的低字节在前发送时先发低 8 位再发高 8 位很多人在上位机里看到的 CRC 和计算出来的看起来是反的不是算法错了而是发送顺序错了。第二是 0xA001 是多项式 0x8005 的反序形式因为这里用的是右移算法如果改成查表法表值本身也是按反序生成的两种实现最终结果必须一致。帧校验的入口函数modbus_rtu_parse()处理三件事检查地址是否匹配从站地址或广播地址 0检查 CRC 是否正确检查帧长度是否在合法范围内。uint8_t modbus_rtu_parse(modbus_frame_t *frame) { // 1. 地址过滤广播地址0x00或者本机地址0x01才继续处理 if (frame-addr ! MODBUS_SLAVE_ADDR frame-addr ! 0x00) { return MODBUS_ERR_ADDR; } // 2. CRC校验计算出的CRC必须与帧尾2字节一致 uint16_t crc_calc modbus_crc16(frame-raw, frame-len - 2); uint16_t crc_recv (frame-raw[frame-len - 1] 8) | frame-raw[frame-len - 2]; if (crc_calc ! crc_recv) { return MODBUS_ERR_CRC; } // 3. 功能码合法性初步检查 if (frame-func 0x01 || frame-func 0x10) { return MODBUS_ERR_FUNC; } return MODBUS_OK; }这里想提醒一点CRC 计算的长度是len - 2因为帧的最后两个字节就是 CRC 本身不能再参与计算。收到的 CRC 拼回 16 位时要注意帧尾先收到的是低字节所以组合方式是(高字节 8) | 低字节这个顺序搞反了所有帧都会报 CRC 错误。你可以在调试时打印crc_calc和crc_recv的十六进制值对比能比较快地定位是算法问题还是字节序问题。3.2 主循环MODBUS状态机modbus_poll()是状态机的核心入口负责把原始帧交给解析器再按功能码分发到具体的处理函数。这里的逻辑组织方式是这套代码最值得借鉴的地方。void modbus_poll(void) { if (!g_frame_ready) return; modbus_frame_t frame; // 从接收缓冲区拷贝原始帧数据 memcpy(frame.raw, g_rx_buffer, g_rx_index); frame.len g_rx_index; frame.addr frame.raw[0]; frame.func frame.raw[1]; // 帧合法性校验 uint8_t err modbus_rtu_parse(frame); if (err ! MODBUS_OK) { g_frame_ready 0; g_rx_index 0; return; // 注意地址不匹配或CRC错误时从站不应回复任何内容 } // 功能码分发 switch (frame.func) { case 0x01: handle_read_coils(frame); break; case 0x02: handle_read_discrete_inputs(frame); break; case 0x03: handle_read_holding_registers(frame); break; case 0x04: handle_read_input_registers(frame); break; case 0x05: handle_write_single_coil(frame); break; case 0x06: handle_write_single_register(frame); break; case 0x0F: handle_write_multiple_coils(frame); break; case 0x10: handle_write_multiple_registers(frame); break; default: send_exception(frame, 0x01); break; // 非法功能码 } // 复位帧就绪标志 g_frame_ready 0; g_rx_index 0; }有个细节值得单独拎出来说当地址不匹配或者 CRC 错误的时候MODBUS 协议要求从站保持沉默不能回复任何字节。很多初写 MODBUS 从站的开发者看到错误帧就急着回异常响应结果主站那边本来就什么都没发对这边再回个错误帧反而把总线搞得更乱。正确做法就是静默丢弃这也符合 RS485 半双工总线的礼仪——别抢话。3.3 异常码回复机制当请求帧格式正确、CRC 也通过但操作本身有问题时需要回异常响应。异常响应帧的结构是从站地址 功能码最高位置 1 异常码 CRC。比如功能码 0x03 的异常响应功能码字段是 0x83。void send_exception(modbus_frame_t *frame, uint8_t exception_code) { uint8_t resp[5]; resp[0] frame-addr; resp[1] frame-func | 0x80; // 最高位置1标识异常响应 resp[2] exception_code; uint16_t crc modbus_crc16(resp, 3); resp[3] crc 0xFF; // CRC低字节 resp[4] crc 8; // CRC高字节 modbus_rtu_send(resp, 5); }异常码的语义在MODBUS协议里有明确规定0x01 非法功能码、0x02 非法数据地址、0x03 非法数据值、0x04 从站设备故障。这套工程里对这几个异常码的触发场景做了区分——功能码不认识回 0x01寄存器地址超出范围回 0x02写入了超限的值回 0x03。你二次开发时新增功能码异常码的语义一定要按协议来不要自定义否则对接组态屏时屏端只会按标准异常码解析。4. 功能码逐个拆解读写操作背后是同一套套路4.1 读操作0x01、0x02、0x03、0x04读操作的处理思路是一致的主站发来起始地址和数量从站按地址去寄存器区取数打包成响应帧回传。区别只在于操作的数据宽度不同——0x01 和 0x02 按位操作0x03 和 0x04 按 16 位寄存器操作。以 0x03 读保持寄存器为例请求帧格式是地址 0x03 起始地址高字节 起始地址低字节 寄存器数量高字节 寄存器数量低字节 CRC。响应帧格式是地址 0x03 字节数 数据1高字节 数据1低字节 ... CRC。void handle_read_holding_registers(modbus_frame_t *frame) { uint16_t start_addr (frame-raw[2] 8) | frame-raw[3]; uint16_t reg_count (frame-raw[4] 8) | frame-raw[5]; // 参数校验数量不能为0且不能超过一次最多读125个寄存器 if (reg_count 0 || reg_count 125 || start_addr reg_count HOLDING_REG_SIZE) { send_exception(frame, 0x02); // 非法数据地址 return; } // 组织响应帧 uint8_t resp[3 reg_count * 2]; resp[0] frame-addr; resp[1] 0x03; resp[2] reg_count * 2; // 数据字节数 寄存器数量 × 2 for (uint16_t i 0; i reg_count; i) { uint16_t value g_holding_regs[start_addr i]; resp[3 i * 2] value 8; // 寄存器值高字节 resp[4 i * 2] value 0xFF; // 寄存器值低字节 } uint16_t crc modbus_crc16(resp, 3 reg_count * 2); resp[3 reg_count * 2] crc 0xFF; resp[4 reg_count * 2] crc 8; modbus_rtu_send(resp, 5 reg_count * 2); }MODBUS 协议规定03 功能码一次最多读 125 个寄存器0x7D01 和 02 功能码一次最多读 2000 个位04 功能码也是 125 个寄存器。这个上限是协议强制约束的不能突破。如果主站请求的数量超过这个值正确的做法是回异常码 0x03非法数据值而不是把超长的响应帧硬发出去——超长帧会把串口缓冲区和从站的状态机都搞乱。读线圈和读离散输入的区别在于数据来源不同线圈对应输出状态离散输入对应输入状态。在内存里可以分别定义两组位数组// main.c中定义寄存器区 uint16_t g_holding_regs[128]; // 0x03 保持寄存器 uint16_t g_input_regs[128]; // 0x04 输入寄存器 uint8_t g_coils[128]; // 0x01 线圈状态 uint8_t g_discrete_inputs[128]; // 0x02 离散输入读线圈的响应里需要把位打包成字节每个字节 8 个位最后一个字节不满 8 位时高位补 0。这个位打包逻辑写起来要仔细一点很多人在这里翻车。// 0x01 读线圈响应数据部分组装 for (uint16_t i 0; i bit_count; i) { if (g_coils[start_addr i]) { resp[3 i / 8] | (1 (i % 8)); // 按位或注意位序 } }这里位序的约定是第一个线圈对应字节的最低位LSB不是最高位。有些上位机软件这里默认是按高位开始的如果联调时发现读回来的线圈状态每一位都错位了先检查这里而不是查硬件。4.2 单点写操作0x05 写单个线圈、0x06 写单个寄存器0x06 写单个寄存器的请求帧是地址 0x06 寄存器地址 写入值 CRC。处理逻辑相对简单只需校验地址合法性然后写入寄存器数组。void handle_write_single_register(modbus_frame_t *frame) { uint16_t reg_addr (frame-raw[2] 8) | frame-raw[3]; uint16_t value (frame-raw[4] 8) | frame-raw[5]; if (reg_addr HOLDING_REG_SIZE) { send_exception(frame, 0x02); return; } g_holding_regs[reg_addr] value; // 写单个寄存器的响应帧 请求帧原样返回 modbus_rtu_send(frame-raw, frame-len); }注意这里有一个很实用的协议特性0x05 和 0x06 的响应帧就是请求帧的完整回显。也就是说从站收到什么就原样回什么中间不需要额外构造数据。这个设计简化了处理逻辑——你只需要做参数校验然后直接调用modbus_rtu_send(frame-raw, frame-len)把收到的原始帧发回去即可。0x05 写线圈也是同理但协议规定写入线圈的数据值必须是 0xFF00表示 ON或 0x0000表示 OFF其他值属于非法数据值应当回异常码 0x03。// 0x05 写单个线圈的关键判断 if (value 0xFF00) { g_coils[coil_addr] 1; } else if (value 0x0000) { g_coils[coil_addr] 0; } else { send_exception(frame, 0x03); // 非法数据值 return; }有些上位机或调试工具写线圈时会发 0x0001 而不是 0xFF00这在协议上是错的但从站可以宽容处理一下把非 0 值都当作 ON。工程代码里默认按严格模式不过你二次开发时可以根据实际对接的设备决定是否放宽。4.3 批量写操作0x0F 写多个线圈、0x10 写多个寄存器0x10 写多个寄存器是所有功能码里帧结构最复杂的因为数据区是变长的。请求帧格式为地址 0x10 起始地址 寄存器数量 字节数 数据区 CRC。这里字节数字段是数据区的总字节数等于寄存器数量 × 2。void handle_write_multiple_registers(modbus_frame_t *frame) { uint16_t start_addr (frame-raw[2] 8) | frame-raw[3]; uint16_t reg_count (frame-raw[4] 8) | frame-raw[5]; uint8_t byte_count frame-raw[6]; // 校验1字节数必须等于寄存器数量的2倍 if (byte_count ! reg_count * 2) { send_exception(frame, 0x03); return; } // 校验2地址范围不能越界 if (start_addr reg_count HOLDING_REG_SIZE) { send_exception(frame, 0x02); return; } // 解析数据并写入寄存器区 for (uint16_t i 0; i reg_count; i) { uint16_t value (frame-raw[7 i * 2] 8) | frame-raw[8 i * 2]; g_holding_regs[start_addr i] value; } // 批量写响应的特殊约定只回地址功能码起始地址数量不带数据区 uint8_t resp[8]; resp[0] frame-addr; resp[1] 0x10; resp[2] frame-raw[2]; resp[3] frame-raw[3]; resp[4] frame-raw[4]; resp[5] frame-raw[5]; uint16_t crc modbus_crc16(resp, 6); resp[6] crc 0xFF; resp[7] crc 8; modbus_rtu_send(resp, 8); }0x10 的响应帧只有 8 个字节内容是地址 0x10 起始地址 数量 CRC数据区不回显。很多刚摸 MODBUS 的人以为批量写和单点写一样要回显全部数据结果响应帧超出预期长度主站解析直接异常。这属于协议理解问题不是代码问题对照上面注释里的响应帧结构自己数一遍字节就明白了。0x0F 写多个线圈的实现思路与 0x10 类似差别在于数据区按位打包而且协议规定一次最多写 1968 个位。处理时要先算byte_count是否等于(bit_count 7) / 8然后按位解析。这里有个容易踩的坑主站发的位数据字节里多余的高位如果不确定有些从站会报非法数据值稳妥的做法是忽略多余高位而不报错。5. 避坑指南调试MODBUS RTU时最容易翻车的5个细节5.1 粘包与断包定时器超时时间设置不当现象是主站连续发帧时从站偶尔会把两帧数据当一帧解析或者一帧数据被拆成两半。原因在定时器超时时间设得太长或太短。时间太长帧间隔小于超时时间两帧就粘在一起时间太短主站发送过程中字节间隔稍微拉长一帧之内就误判帧结束。解决方法是按波特率精确计算——9600 波特率下3.5 个字符时间是 3.6ms你设 4ms 没问题但如果波特率降到 2400同样 4ms 就只能覆盖约 1 个字符时间一帧数据必然会断。每次改波特率必须重新算超时时间。5.2 CRC字节序反了上位机一直报校验失败现象是上位机收到的响应帧能解析出数据但 CRC 校验永远不过。这种情况几乎都是发送字节序的问题。MODBUS RTU 的 CRC 在帧内是低字节在前而大多数调试工具展示时按十六进制从左到右显示看起来像是反了。解决方法是确认发送时resp[n-2]放 CRC 低字节、resp[n-1]放高字节。如果你把计算结果的crc直接按大端序发出收端必然报错。一个快速验证方法是抓一帧标准请求用在线 CRC 计算工具算出结果和代码里发出的字节对比一下顺序。5.3 寄存器地址偏移上位机地址总差1现象是上位机写寄存器地址 0 时实际改到的是从站的寄存器 1。原因是 MODBUS 协议里的地址是数据地址而从站内部实际访问的内存数组下标通常从 0 开始很多人会把两者混为一谈。解决方法是先明确你定义的内存映射关系。如果你的寄存器区数组下标 0 对应 MODBUS 地址 0那请求帧里的起始地址就是数组下标但很多组态屏的地址是从 1 开始编号的屏端发地址 1 时从站收到的起始地址字段是 0——中间差了一个偏移量。这个偏移要在协议解析层处理不要在上位机里强行减一。5.4 0x10功能码的字节数校验漏了现象是主站写多个寄存器时偶尔写入的数据是正确的但偶尔会写进一堆 0 或乱码。原因是主站发出的byte_count字段和实际数据区长度不一致而从站没有校验这一项直接把偏移后的数据区内容当寄存器值解析了。解决方法是严格检查byte_count reg_count * 2不一致立刻回异常码 0x03不要让脏数据进入寄存器区。5.5 RS485方向切换时机不对现象是挂在 RS485 总线上调试时从站发送的响应偶尔会丢第一个字节或者最后一个字节。原因在方向控制引脚由发送模式切换回接收模式的时机太早或太晚。发送完最后一个字节后串口移位寄存器可能还有数据没完全送出去立刻切回接收会把最后的停止位截断。解决方法是发完最后一个字节后延时 23 个字符时间再拉低 TX_EN 引脚保证移位寄存器里的数据完整送出。6. 二次开发实战给这套MODBUS从站增加自定义功能码拿到这套代码去做二次开发最常见的一个需求就是扩展自定义功能码。比如你的设备需要支持一个读取固件版本号的功能MODBUS 标准里没有对应分配可以用 0x41用户自定义区功能码 6572 属于用户定义区间。下面是一段可以参考的扩展方式// 在modbus_poll()的switch分支中追加 case 0x41: handle_read_firmware_version(frame); break; // 具体的处理函数 void handle_read_firmware_version(modbus_frame_t *frame) { // 版本号格式V1.2.3拆成3个寄存器返回 uint8_t resp[9]; resp[0] frame-addr; resp[1] 0x41; resp[2] 6; // 6字节数据3个寄存器 × 2 resp[3] 0x00; resp[4] 0x01; // 主版本号 1 resp[5] 0x00; resp[6] 0x02; // 次版本号 2 resp[7] 0x00; resp[8] 0x03; // 修订号 3 uint16_t crc modbus_crc16(resp, 9); // 追加CRC后调用modbus_rtu_send发送 }扩展到自定义功能码时有三件事要记得做。第一是在modbus_rtu_parse()的合法性检查里放开自定义区的功能码否则你的 0x41 还没走到 switch 就已经被当非法功能码挡掉了。第二是异常响应里frame-func | 0x80的规则同样适用自定义功能码的异常响应也是把最高位置 1。第三是上位机或组态屏那边要同步支持自定义功能码的报文定义否则从站这边功能再全主站不认得也是白搭。移植到其他 STM32 型号时需要改的地方集中在modbus_rtu.c里串口和定时器的句柄——把huart1换成目标芯片的串口句柄把htim2换成实际使用的定时器超时计数值按新的时钟主频重新算一遍其他协议逻辑几乎不需要动。寄存器区的定义和大小在main.c里调整注意HOLDING_REG_SIZE必须在所有读取处理函数里保持一致大小改一处另一处也要同步。这套代码我反复翻过多次最打动我的是作者没有把 MODBUS 写成能用就行的玩具——异常码处理、广播地址支持、字节序细节、超时判帧这些真正在总线上跑过才知道的细节都覆盖到了。以前我自己从零写过一版以为自己全都懂结果和标准的 MODBUS 主站设备联调时光是批次写响应帧格式和位打包顺序就来回折腾了一整天。从那以后我每写一次串口通讯协议都强制走一遍按协议标准逐字段核对帧结构的流程。希望这篇解析能帮你在 MODBUS 开发上少走点弯路。本文还有配套的精品资源点击获取