从零开始读懂 RS485 Modbus 协议代码:一个嵌入式工程师的实战笔记
你有没有遇到过这样的场景?
手头有个传感器要通过 RS485 接到 PLC,调试时数据乱跳;或者自己写的 Modbus 从站程序,主站一问就“超时”……最后只能靠换模块、调线序、改参数“试出来”,却始终不知道问题出在哪。
别急,这背后其实不是玄学,而是我们对RS485 + Modbus RTU这个经典组合的理解还不够透彻。今天我就带你从源码层面,一步步拆解这个工业通信基石的技术实现——不讲空话,只说你能用得上的东西。
为什么是 RS485 和 Modbus?
在工业现场,设备分布广、电磁干扰强、布线成本敏感。这时候,像以太网这种高速但娇贵的方案就不一定合适了。而RS485 + Modbus RTU的组合,就像一位“皮实耐用的老电工”,扛得住噪声、拉得动长线、接得了多台设备。
- RS485是物理层标准,解决的是“怎么传”的问题:用差分信号抗干扰,支持最多32个节点挂在同一对线上。
- Modbus RTU是应用层协议,定义了“传什么”和“怎么解释”:它把读寄存器、写线圈这些操作封装成一个个二进制帧,在串口上跑。
两者结合,构成了最常见、也最容易上手的工业通信链路。
第一步:让单片机“说话”——RS485 方向控制
RS485 是半双工总线,意味着同一时刻只能发或收。不像我们平时用的串口(比如 USB 转 TTL),它是双向自动切换的。RS485 需要你手动告诉芯片:“我现在要发数据了,请打开输出驱动。”
这就引出了关键角色:MAX485 或 SN75176 这类收发器芯片。它的 DE(Driver Enable)和 RE(Receiver Enable)引脚决定了工作模式:
| DE | RE | 模式 |
|---|---|---|
| 1 | X | 发送模式 |
| 0 | 1 | 接收模式 |
注意:RE 通常低有效,所以很多设计会把 DE 和 !RE 并联,用一个 GPIO 控制即可。
实战代码:STM32 上的方向切换
#define RS485_DIR_GPIO_PORT GPIOB #define RS485_DIR_PIN GPIO_PIN_12 // 宏定义方便操作 #define RS485_TX_EN() HAL_GPIO_WritePin(RS485_DIR_GPIO_PORT, RS485_DIR_PIN, GPIO_PIN_SET) #define RS485_RX_EN() HAL_GPIO_WritePin(RS485_DIR_GPIO_PORT, RS485_DIR_PIN, GPIO_PIN_RESET) void RS485_SendData(uint8_t *data, uint16_t len) { RS485_TX_EN(); // 切换为发送模式 HAL_UART_Transmit(&huart2, data, len, 100); HAL_Delay(1); // 关键!等待最后一个 bit 发完 RS485_RX_EN(); // 立即切回接收,避免占用总线 }坑点与秘籍
为什么需要
HAL_Delay(1)?
因为HAL_UART_Transmit是阻塞发送,但它返回时 UART 可能还在移位寄存器里发最后一个 bit。如果你马上切回接收,就会丢掉帧尾,导致 CRC 校验失败。具体延时时间应根据波特率计算:
$$
\text{字符时间} = \frac{11}{\text{波特率}} \times 3.5
$$
比如 9600bps 下,一个字符约 1.14ms,3.5 字符 ≈ 4ms。但在实际代码中,只要保证比单个字符时间稍长就行,1ms 够用了。能否不用延时?
可以!更优雅的做法是使用UART 发送完成中断(TXE或TC标志位),在中断里自动切回接收模式。这样既精准又不影响主循环效率。
第二步:构建通信语言——Modbus RTU 帧结构解析
有了物理通道,接下来就是约定“语言”。Modbus RTU 的帧长得像这样:
[设备地址][功能码][数据...][CRC低][CRC高]没有起始符和结束符,那怎么知道一帧什么时候开始、什么时候结束?答案是:靠静默时间。
帧边界识别:3.5 字符时间法则
Modbus 规定,任意两帧之间必须有至少3.5 个字符时间的空闲间隔。你可以把它想象成“一句话说完后的停顿”。
所以在接收端,我们需要做的是:
- 收到第一个字节时启动定时器;
- 后续每收到一个字节重置定时器;
- 如果连续超过 3.5 字符时间没新数据到来 → 认为一帧已完整接收。
如何实现?
#define CHAR_TIME_3_5_MS 4 // 9600bps 下约为 4ms uint8_t rx_buffer[256]; uint16_t rx_index = 0; uint32_t last_byte_time; // 在 UART 接收中断中 void UART_RX_IRQHandler(uint8_t byte) { uint32_t now = GetTick_ms(); // 判断是否为新帧开始 if (now - last_byte_time > CHAR_TIME_3_5_MS && rx_index > 0) { // 上一帧未处理,这里可以加溢出保护 rx_index = 0; } rx_buffer[rx_index++] = byte; // 防止缓冲区溢出 if (rx_index >= sizeof(rx_buffer)) { rx_index = 0; // 重置并丢弃 } last_byte_time = now; }然后在主循环或定时任务中检查是否有完整帧待处理。
第三步:验证数据完整性——CRC16 校验
网络传输难免出错,Modbus 用 CRC16-CCITT 来检测错误。发送方把前面所有字节算一遍 CRC,附加在帧末尾;接收方重新计算,如果不一致就直接丢弃。
CRC16 函数实现(可移植版本)
uint16_t Modbus_CRC16(uint8_t *buf, uint16_t len) { uint16_t crc = 0xFFFF; for (int i = 0; i < len; i++) { crc ^= buf[i]; for (int j = 0; j < 8; j++) { if (crc & 0x0001) { crc = (crc >> 1) ^ 0xA001; } else { crc >>= 1; } } } return crc; }使用示例
uint8_t frame[] = {0x01, 0x03, 0x00, 0x00, 0x00, 0x02}; // 请求读两个寄存器 uint16_t crc = Modbus_CRC16(frame, 6); frame[6] = crc & 0xFF; // 先低后高 frame[7] = (crc >> 8) & 0xFF;⚠️ 特别注意:CRC 是低位在前、高位在后,别搞反了!
第四步:理解“命令”——功能码处理机制
Modbus 中的功能码就像是 API 接口。最常见的几个:
| 功能码 | 名称 | 用途 |
|---|---|---|
| 0x01 | Read Coils | 读开关量输出(DO) |
| 0x02 | Read Input Discretes | 读开关量输入(DI) |
| 0x03 | Read Holding Registers | 读保持寄存器(模拟量等) |
| 0x04 | Read Input Registers | 读输入寄存器 |
| 0x05 | Write Single Coil | 写单个线圈 |
| 0x06 | Write Single Register | 写单个保持寄存器 |
| 0x10 | Write Multiple Registers | 批量写寄存器 |
我们重点看0x03 读保持寄存器的处理逻辑。
示例:实现一个简单的从站响应函数
// 假设我们有 256 个 16 位保持寄存器 uint16_t holding_registers[256] = {0}; void Modbus_Handle_ReadHoldingRegs(uint8_t *req, uint8_t *resp, uint8_t *resp_len) { uint8_t addr = req[0]; // 设备地址 uint16_t start_addr = (req[2] << 8) | req[3]; // 起始地址 uint16_t reg_count = (req[4] << 8) | req[5]; // 寄存器数量 // 参数合法性检查 if (reg_count == 0 || reg_count > 125) { // 最多返回 250 字节数据 Modbus_BuildException(resp, resp_len, addr, 0x03 | 0x80, 0x03); return; } if (start_addr + reg_count > 256) { Modbus_BuildException(resp, resp_len, addr, 0x03 | 0x80, 0x02); // 地址越界 return; } // 构造正常响应 resp[0] = addr; resp[1] = 0x03; resp[2] = reg_count * 2; // 数据字节数 *resp_len = 3; for (int i = 0; i < reg_count; i++) { uint16_t value = holding_registers[start_addr + i]; resp[*resp_len] = (value >> 8) & 0xFF; // 高位在前(大端) resp[*resp_len + 1] = value & 0xFF; *resp_len += 2; } // 添加 CRC uint16_t crc = Modbus_CRC16(resp, *resp_len); resp[*resp_len] = crc & 0xFF; resp[*resp_len + 1] = (crc >> 8) & 0xFF; *resp_len += 2; } // 构建异常响应 void Modbus_BuildException(uint8_t *resp, uint8_t *len, uint8_t addr, uint8_t func, uint8_t code) { resp[0] = addr; resp[1] = func; resp[2] = code; *len = 3; uint16_t crc = Modbus_CRC16(resp, 3); resp[3] = crc & 0xFF; resp[4] = (crc >> 8) & 0xFF; *len = 5; }异常码含义速查表
| 异常码 | 含义 |
|---|---|
| 0x01 | 非法功能码 |
| 0x02 | 非法数据地址(超出范围) |
| 0x03 | 非法数据值(如数量为0) |
| 0x04 | 从站设备故障 |
当你看到主站报错 “Exception 02”,就知道可能是地址写错了。
实际系统如何组织?——典型架构与流程
在一个完整的 Modbus 从站程序中,通常包含以下几个模块:
[UART中断] ↓ [接收缓冲管理] ↓ [帧完整性判断(3.5T)] ↓ [CRC校验 + 地址匹配] ↓ [功能码分发调度] ↓ [寄存器读写 / 外设控制] ↓ [构建响应帧] ↓ [RS485 发送出去]初始化要点
void Modbus_Slave_Init(void) { MX_USART2_UART_Init(); // 初始化 UART RS485_RX_EN(); // 默认处于接收模式 __HAL_UART_ENABLE_IT(&huart2, UART_IT_RXNE); // 开启接收中断 }主循环中的帧处理
while (1) { if (frame_complete_flag) { if (rx_index >= 4 && Modbus_ValidateFrame(rx_buffer, rx_index)) { uint8_t addr = rx_buffer[0]; if (addr == DEVICE_ADDRESS || addr == 0) { // 支持广播地址0 uint8_t func = rx_buffer[1]; switch (func) { case 0x03: Modbus_Handle_ReadHoldingRegs(rx_buffer, tx_buffer, &tx_len); RS485_SendData(tx_buffer, tx_len); break; case 0x06: Modbus_Handle_WriteSingleRegister(rx_buffer, tx_buffer, &tx_len); RS485_SendData(tx_buffer, tx_len); break; default: Modbus_BuildException(tx_buffer, &tx_len, addr, func | 0x80, 0x01); RS485_SendData(tx_buffer, tx_len); break; } } } frame_complete_flag = 0; rx_index = 0; } // 其他任务... }新手常见问题与避坑指南
❌ 问题1:主站总是显示“超时”
排查方向:
- 是否正确设置了设备地址?
- 是否在接收完成后及时切换回接收模式?
- 是否遗漏了CRC 校验或字节顺序错误?
- 波特率、数据位、停止位、校验方式是否完全一致?
❌ 问题2:偶尔收到错误数据
可能原因:
- 总线上终端电阻缺失(应在总线两端各加 120Ω 电阻)
- 电源共地不良,引起信号参考电平漂移
- 多个设备同时发送(方向控制失控)
✅ 最佳实践建议
- 抽象 UART 层:将
SendByte,ReceiveISR等接口独立出来,便于移植到不同平台(如 STM8、ESP32、GD32)。 - 使用状态机接收:对于 RAM 小的 8 位机,可用状态机逐字节解析,避免大缓冲区。
- 限制写权限:某些关键寄存器禁止外部修改,防止误操作。
- 加入日志输出:开发阶段可通过额外串口打印收发帧内容,极大提升调试效率。
结语:简单,才是最大的竞争力
虽然 OPC UA、MQTT、TSN 等新技术正在兴起,但在未来很长一段时间内,RS485 + Modbus RTU仍将是工业现场不可替代的存在。
因为它够简单、够稳定、够便宜。只要你懂一点 C 语言,加上这篇笔记里的核心逻辑,就能写出可靠的通信程序。
下次当你面对一堆杂乱的通信线时,不妨停下来想想:
- 我的设备地址设对了吗?
- CRC 加了吗?
- 方向切换有没有延迟?
往往答案就在这些细节之中。
如果你正在做智能电表、温控器、PLC 扩展模块,欢迎留言交流你的实现思路。我们一起把这套“老技术”玩得更明白。