从零开始玩转Modbus:STM32做从站,一文搞定工业通信
你有没有遇到过这样的场景?
手头有个STM32开发板,想把它接入PLC或者上位机系统,读点传感器数据、控制几个继电器。结果一查资料——满屏的“主从架构”、“功能码0x03”、“CRC校验失败”,瞬间劝退。
别急,今天我们就用最接地气的方式,带你从零搭建一个能跑起来的Modbus Slave节点,不讲虚的,只说实战中真正要用到的东西。哪怕你是第一次听说Modbus,也能照着这篇文章一步步调通。
为什么是Modbus?它真的适合初学者吗?
在工业现场,设备之间怎么“说话”?答案五花八门:CAN、Profibus、EtherCAT……但要说最容易上手、文档最多、工具最全的,还得是Modbus。
尤其是它的RTU模式 + RS-485物理层组合,简直是嵌入式新手的入门神技:
- 协议简单:没有复杂握手,主发从回,像对讲机一样;
- 不依赖操作系统:裸机STM32就能实现;
- 调试方便:电脑端有 Modbus Poll、QModMaster 这类神器,发个命令立马看响应;
- 硬件便宜:一片MAX485芯片几毛钱,搞定半双工通信。
更重要的是,Modbus Slave(从站)逻辑清晰、流程固定,非常适合用来理解“协议栈是怎么工作的”。
我们今天的任务就是:让一块STM32F103C8T6(蓝丸板),通过RS-485,响应上位机读取保持寄存器的请求——比如返回当前温度值或IO状态。
核心三件事:收数据、解协议、回响应
要让STM32当好一个Modbus从站,本质上只需要做好三步:
- 收到主机发来的数据帧
- 判断是不是发给我的?有没有出错?要我干什么?
- 组装应答包,原路发回去
听起来很简单,但难点在于:怎么知道一帧数据什么时候结束?
串口是逐字节接收的,而Modbus没有明确的“帧头帧尾”。它的秘诀是:利用帧间静默时间来判断帧边界。
规范要求:两个Modbus帧之间必须间隔至少3.5个字符时间。例如9600bps下,一个字符约1.04ms,3.5个就是约3.64ms。只要在这段时间内没收到新字节,就认为前一帧已经收完。
这个机制决定了我们在STM32上必须借助中断 + 定时器超时来精准捕获帧结束。
UART配置:不只是初始化那么简单
很多人以为UART初始化完了就万事大吉,其实关键在如何高效可靠地接收不定长数据。
基础参数设置(以9600bps为例)
| 参数 | 值 |
|---|---|
| 波特率 | 9600 |
| 数据位 | 8位 |
| 停止位 | 1位 |
| 校验位 | 无 |
| 模式 | 异步接收(RX only) |
使用HAL库初始化如下:
UART_HandleTypeDef huart2; void MX_USART2_UART_Init(void) { huart2.Instance = USART2; huart2.Init.BaudRate = 9600; huart2.Init.WordLength = UART_WORDLENGTH_8B; huart2.Init.StopBits = UART_STOPBITS_1; huart2.Init.Parity = UART_PARITY_NONE; huart2.Init.Mode = UART_MODE_RX; huart2.Init.HwFlowCtl = UART_HWCONTROL_NONE; HAL_UART_Init(&huart2); // 启动中断接收(单字节触发) HAL_UART_Receive_IT(&huart2, &rx_byte, 1); }⚠️ 注意:这里不要用轮询
HAL_UART_Receive(),否则会阻塞整个程序!
关键技巧:用定时器识别帧结束
每次收到一个字节,我们就重启一个4ms左右的定时器。如果下一个字节迟迟不来,定时器超时,说明这帧数据收完了。
实现步骤:
定义缓冲区和计数器:
c uint8_t rx_buffer[256]; uint8_t rx_count = 0; uint8_t frame_timeout_flag = 0;在UART接收中断中重置定时器:
```c
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
if (huart == &huart2) {
rx_buffer[rx_count++] = rx_byte;// 重启超时检测(4ms) __HAL_TIM_SET_COUNTER(&htim3, 0); HAL_TIM_Base_Start_IT(&htim3); // 继续等待下一字节 HAL_UART_Receive_IT(huart, &rx_byte, 1);}
}
```定时器超时回调中标记帧完成:
c void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim == &htim3) { frame_timeout_flag = 1; // 帧接收完成 HAL_TIM_Base_Stop_IT(htim); // 停止定时 } }
这样,主循环就可以放心处理完整帧了:
while (1) { if (frame_timeout_flag) { handle_modbus_frame(rx_buffer, rx_count); rx_count = 0; frame_timeout_flag = 0; } }CRC-16校验:别跳过,它是稳定通信的生命线
很多初学者为了省事直接跳过CRC验证,结果通信时不时出错还找不到原因。记住一句话:不验CRC的Modbus就像不系安全带开车。
Modbus RTU使用的CRC-16/MODBUS标准如下:
- 多项式:
0x8005 - 初始值:
0xFFFF - 输入/输出不反转
- 小端格式(低字节在前)
一行都不能错的CRC函数
uint16_t Modbus_CRC16(uint8_t *buf, int 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; // 0xA001是0x8005的反向 } else { crc >>= 1; } } } return crc; // 返回值需按低字节+高字节顺序附加到帧尾 }使用时注意:计算CRC时不包含原始CRC字段本身!
比如你收到8字节数据,前6字节是地址+功能码+数据,后2字节是CRC。你应该拿前6字节重新算一遍CRC,再和接收到的后2字节比较。
寄存器映射:给你的数据起个“地址名”
Modbus规定了几种数据区:
| 类型 | 功能码示例 | 地址范围(常用) | 含义 |
|---|---|---|---|
| 线圈(Coil) | 0x01 | 0x0000~ | 可读写,1位(ON/OFF) |
| 离散输入 | 0x02 | 0x1000~ | 只读,1位 |
| 输入寄存器 | 0x04 | 0x3000~ | 只读,16位 |
| 保持寄存器 | 0x03/0x06/0x10 | 0x4000~ | 可读写,16位 |
我们通常定义一个数组作为“保持寄存器”:
#define REG_HOLDING_COUNT 100 uint16_t holding_reg[REG_HOLDING_COUNT];然后约定:
-holding_reg[0]→ 对应地址40001
-holding_reg[1]→ 40002
- ……
这样上位机读40001,你就返回holding_reg[0]的值。
支持哪些功能码?先搞定最常见的三个
作为从站,不需要支持全部功能码。刚开始我们只实现这三个就够了:
| 功能码 | 名称 | 用途 |
|---|---|---|
| 0x03 | Read Holding Registers | 读多个保持寄存器(最常用) |
| 0x06 | Write Single Register | 写单个寄存器(比如设目标温度) |
| 0x10 | Write Multiple Registers | 写多个寄存器(批量配置参数) |
示例:处理功能码0x03(读保持寄存器)
假设主机发来:
[0x01][0x03][0x00][0x00][0x00][0x02][CRC_L][CRC_H]意思是:设备地址0x01,读40001开始的2个寄存器。
我们的响应应该是:
[0x01][0x03][0x04][H1][L1][H2][L2][CRC_L][CRC_H]其中0x04表示后面跟着4字节数据。
代码框架如下:
void handle_modbus_frame(uint8_t *buf, uint8_t len) { if (len < 8) return; // 最小帧长 uint8_t addr = buf[0]; uint8_t func = buf[1]; // 1. 检查设备地址是否匹配 if (addr != SLAVE_ADDRESS) return; // 2. 验证CRC(去掉最后两字节) uint16_t received_crc = (buf[len-1] << 8) | buf[len-2]; uint16_t calc_crc = Modbus_CRC16(buf, len - 2); if (received_crc != calc_crc) return; // 3. 解析功能码 switch(func) { case 0x03: modbus_func_03(buf, len); break; case 0x06: modbus_func_06(buf, len); break; case 0x10: modbus_func_10(buf, len); break; default: send_exception_response(addr, func, 0x01); // 非法功能 break; } }每个功能码函数负责构造响应并发送出去。
MAX485方向控制:别忘了切换收发模式!
RS-485是半双工总线,同一时刻只能收或发。我们需要用一个GPIO控制MAX485的RE/DE引脚。
一般接法:
| STM32引脚 | 接MAX485引脚 | 说明 |
|---|---|---|
| TX | DI | 发送数据 |
| RX | RO | 接收数据 |
| PB10 | RE/DE | 高电平=发送,低电平=接收 |
发送前打开发送使能:
void usart_set_transmit_mode() { HAL_GPIO_WritePin(RE_DE_GPIO_Port, RE_DE_Pin, GPIO_PIN_SET); HAL_Delay(1); // 稳定时间 } void usart_set_receive_mode() { HAL_Delay(1); HAL_GPIO_WritePin(RE_DE_GPIO_Port, RE_DE_Pin, GPIO_PIN_RESET); }发送完记得切回接收模式!
常见坑点与调试秘籍
❌ 问题1:主机发了命令但从站没反应?
排查思路:
- 是否正确设置了从站地址?
- 是否开启了UART中断?
- 是否忘记启动初始的HAL_UART_Receive_IT?
- MAX485方向控制线接反了吗?
👉建议:先用串口助手直接给STM32发数据,观察是否能进中断。
❌ 问题2:CRC总是校验失败?
真相往往是字节顺序搞错了!
Modbus帧中CRC是低字节在前,比如计算得到0x1234,应该先发0x34,再发0x12。
错误写法:
tx_buf[n++] = (crc >> 8); // 先发高字节 —— 错! tx_buf[n++] = crc & 0xFF;正确写法:
tx_buf[n++] = crc & 0xFF; // 先发低字节 tx_buf[n++] = (crc >> 8);✅ 调试利器:打印原始Hex数据
在主循环加一段日志输出:
if (frame_timeout_flag) { printf("Recv: "); for(int i=0; i<rx_count; i++) { printf("%02X ", rx_buffer[i]); } printf("\r\n"); handle_modbus_frame(rx_buffer, rx_count); // ... }然后用串口助手看接收到的数据,对比Modbus Poll发出的内容,一眼看出问题在哪。
扩展玩法:让你的从站更聪明
一旦基础通信跑通,接下来可以轻松扩展:
- 动态改地址:把
holding_reg[0]当作地址寄存器,写入即修改本机地址,掉电保存到Flash; - 异常上报:某些事件发生时主动上报状态(虽然Modbus本身不支持“主动上报”,但可以用“伪轮询”模拟);
- 多接口共存:同时支持Modbus RTU和Modbus TCP(需要以太网模块);
- 结合FreeRTOS:将Modbus任务独立运行,不影响其他逻辑。
写在最后:这不是终点,而是起点
看到这里,你应该已经掌握了:
- 如何用STM32实现一个可运行的Modbus Slave;
- 怎么处理UART中断与帧边界识别;
- CRC校验的重要性及实现方法;
- 寄存器映射与功能码解析的核心逻辑;
- 硬件连接的关键细节(MAX485控制);
这套方案已经在温控仪、智能电表、远程IO模块等项目中广泛应用。它足够轻量,可以直接移植到任何STM32型号;也足够健壮,能在工业现场长期稳定运行。
如果你正在做一个需要联网的嵌入式设备,不妨先从Modbus开始练手。当你第一次看到Modbus Poll里成功读出自己STM32上传的数据时,那种成就感,绝对值得你熬夜调试。
如果你在实现过程中遇到了具体问题,欢迎留言交流。我们可以一起看看是CRC错了,还是定时器没对上——毕竟,每一个成功的通信背后,都曾有过无数次“收不到回应”的夜晚。