从零开始打造Modbus从机:STM32实战驱动开发全解析
你有没有遇到过这样的场景?手头有一堆传感器,想把它们接入PLC或上位机系统,但通信协议成了拦路虎。工业现场最常见的答案是什么?Modbus。
作为工业自动化领域最“长寿”的通信协议之一,Modbus至今仍在无数产线、楼宇和能源系统中默默运行。而如果你正在做嵌入式开发——尤其是用STM32这类主流MCU——掌握如何实现一个可靠的Modbus从机(Slave),几乎是必备技能。
本文不讲空泛理论,也不堆砌术语。我们将以STM32F103C8T6 + RS-485为硬件平台,从零开始一步步写出一个真正可用的Modbus RTU从机程序。整个过程涵盖协议理解、帧处理逻辑、CRC校验、中断接收与超时判断等核心环节,并附带完整可运行代码框架。
准备好了吗?让我们一起动手,把你的MCU变成工业总线上的“标准设备”。
为什么是 Modbus?它到底解决了什么问题?
在没有统一协议的年代,每家设备都用私有通信方式,集成起来就像拼图找错片。Modbus的出现改变了这一切。它简单、开放、跨平台,只靠几个字节就能完成数据读写。
更重要的是,它的主从架构非常清晰:
- 主站(Master)发起请求,比如问:“0x01号设备,把你第0个寄存器的值告诉我。”
- 从站(Slave)被动响应:“我是0x01,这是我的值。”
这种模式天然适合监控系统:一台工控机轮询多个终端节点,结构稳定且易于调试。
我们今天要做的,就是让STM32扮演那个被查询的“从站”,支持常见的功能码如0x03(读保持寄存器)、0x06和0x10(写单个/多个寄存器),并正确返回数据。
核心挑战:如何识别一帧完整的 Modbus 报文?
Modbus RTU走的是串行总线(通常是RS-485),不像TCP有明确的数据包边界。那怎么知道一条消息什么时候结束?
答案是:字符间超时机制。
根据Modbus规范,当两个字符之间的间隔超过3.5个字符时间时,就认为当前帧已结束。例如,在9600bps下,每个字符约1ms(10位),那么3.5个字符 ≈ 3.5ms。只要在这之后没新数据来,就可以开始解析。
这意味着我们必须:
1. 用UART中断接收每一个字节;
2. 每收到一个字节,重置定时器;
3. 定时器超时后触发帧完整性检查。
这正是实现Modbus Slave的关键所在。
硬件连接与资源配置
本案例使用以下配置:
| 组件 | 型号/说明 |
|---|---|
| MCU | STM32F103C8T6(最小系统板) |
| 串口 | USART1 |
| 收发器 | MAX485(半双工) |
| 控制引脚 | DE/RE 接 PC0,用于控制发送使能 |
接线示意:
STM32 USART1_TX → RO (MAX485) STM32 PC0 → DE/RE (MAX485) STM32 USART1_RX ← DI (MAX485) A/B端子接RS-485总线,两端加120Ω终端电阻(>50米建议添加)⚠️ 注意:DE和RE必须连在一起控制方向。发送时拉高,接收时拉低。
软件设计:从协议到代码的落地路径
我们的目标是从无到有构建一个轻量级、可移植的Modbus从机模块。不需要RTOS,不依赖庞大库,纯裸机+HAL库即可运行。
关键模块拆解
- CRC16校验计算
- UART中断接收缓冲管理
- 帧结束检测(基于定时器)
- 地址匹配与功能码解析
- 响应报文构造与发送
下面我们逐个击破。
✅ 步骤一:实现 CRC16 校验函数
所有Modbus RTU帧末尾都有两个字节的CRC校验值,用于验证数据完整性。我们需要能自己算出正确的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; // 多项式:x^16 + x^15 + x^2 + 1 } else { crc >>= 1; } } } return crc; }🔍 小贴士:这个CRC算法是标准Modbus定义的,不能改!如果算出来的和Wireshark或Modbus工具里的不一样,一定是哪里漏了字节。
✅ 步骤二:设置 UART 中断 + 定时器超时检测
我们使用HAL_UART_Receive_IT()开启中断接收,每次收到一个字节进入回调函数。
同时启用一个定时器(比如TIM3),每次收到数据就清零计数器。一旦超过3.5字符时间未更新,则判定帧结束。
初始化部分(伪代码)
// 启动UART非阻塞接收 HAL_UART_Receive_IT(&huart1, &received_byte, 1); // 配置TIM3:假设72MHz主频,分频7200 → 10kHz,计数35 → 3.5ms __HAL_RCC_TIM3_CLK_ENABLE(); htim3.Instance = TIM3; htim3.Init.Prescaler = 7200 - 1; htim3.Init.CounterMode = TIM_COUNTERMODE_UP; htim3.Init.Period = 35 - 1; // 对应3.5ms @ 9600bps HAL_TIM_Base_Start(&htim3);中断回调函数
uint8_t received_byte; extern uint8_t rx_buffer[256]; extern uint8_t rx_index; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { // 重置超时计数器 __HAL_TIM_SET_COUNTER(&htim3, 0); // 缓存数据 if (rx_index < 255) { rx_buffer[rx_index++] = received_byte; } // 重启定时器 __HAL_TIM_ENABLE(&htim3); // 重新开启下一次中断接收 HAL_UART_Receive_IT(&huart1, &received_byte, 1); } }定时器中断:帧结束处理
void TIM3_IRQHandler(void) { if (__HAL_TIM_GET_FLAG(&htim3, TIM_FLAG_UPDATE)) { __HAL_TIM_CLEAR_FLAG(&htim3, TIM_FLAG_UPDATE); __HAL_TIM_DISABLE(&htim3); // 停止定时器 if (rx_index >= 5) { // 最小Modbus帧长为5字节 uint16_t recv_crc = rx_buffer[rx_index - 2] | (rx_buffer[rx_index - 1] << 8); uint16_t calc_crc = modbus_crc16(rx_buffer, rx_index - 2); if (recv_crc == calc_crc) { process_modbus_frame(rx_buffer, rx_index); } // 否则丢弃错误帧 } rx_index = 0; // 清空缓存 } }💡 提示:你可以通过波特率动态调整定时器周期。例如115200bps时,3.5字符时间约为0.3ms,需相应缩短定时。
✅ 步骤三:解析请求并生成响应
这是最核心的部分——process_modbus_frame()函数。
我们支持三个常用功能码:
| 功能码 | 含义 |
|---|---|
0x03 | 读保持寄存器 |
0x06 | 写单个保持寄存器 |
0x10 | 写多个保持寄存器 |
先定义寄存器映射区域:
#define SLAVE_ADDRESS 0x01 #define REG_START_ADDR 0x00 #define REG_COUNT 10 uint16_t holding_registers[REG_COUNT] = {0}; // 可映射为温度、湿度、状态标志等主处理函数如下:
void process_modbus_frame(uint8_t *frame, uint8_t len) { uint8_t addr = frame[0]; uint8_t func = frame[1]; // 地址不匹配且非广播地址(0x00),忽略 if (addr != SLAVE_ADDRESS && addr != 0x00) return; switch (func) { case 0x03: // 读保持寄存器 handle_read_holding_registers(frame, len); break; case 0x06: // 写单个保持寄存器 handle_write_single_register(frame, len); break; case 0x10: // 写多个保持寄存器 handle_write_multiple_registers(frame, len); break; default: send_exception_response(addr, func | 0x80, 0x01); // 不支持的功能码 break; } }辅助函数:异常响应封装
void send_exception_response(uint8_t addr, uint8_t func, uint8_t exception_code) { uint8_t resp[5] = {addr, func, exception_code}; uint16_t crc = modbus_crc16(resp, 3); resp[3] = crc & 0xFF; resp[4] = crc >> 8; set_rs485_mode(SEND_MODE); // 切换至发送模式 HAL_UART_Transmit(&huart1, resp, 5, 100); set_rs485_mode(RECEIVE_MODE); // 切回接收 }实现读寄存器(0x03)
void handle_read_holding_registers(uint8_t *frame, uint8_t len) { uint16_t start_addr = (frame[2] << 8) | frame[3]; uint16_t reg_count = (frame[4] << 8) | frame[5]; // 范围检查 if (start_addr + reg_count > REG_COUNT) { send_exception_response(SLAVE_ADDRESS, 0x83, 0x02); // 非法数据地址 return; } // 构造响应 uint8_t response[256]; int idx = 0; response[idx++] = SLAVE_ADDRESS; response[idx++] = 0x03; response[idx++] = reg_count * 2; for (int i = 0; i < reg_count; i++) { uint16_t val = holding_registers[start_addr + i]; response[idx++] = (val >> 8) & 0xFF; response[idx++] = val & 0xFF; } uint16_t crc = modbus_crc16(response, idx); response[idx++] = crc & 0xFF; response[idx++] = crc >> 8; set_rs485_mode(SEND_MODE); HAL_UART_Transmit(&huart1, response, idx, 100); set_rs485_mode(RECEIVE_MODE); }写单个寄存器(0x06)
注意:成功时回显原请求 + CRC
void handle_write_single_register(uint8_t *frame, uint8_t len) { uint16_t reg_addr = (frame[2] << 8) | frame[3]; uint16_t value = (frame[4] << 8) | frame[5]; if (reg_addr >= REG_COUNT) { send_exception_response(SLAVE_ADDRESS, 0x86, 0x02); return; } holding_registers[reg_addr] = value; // 回传原请求帧(共8字节) uint16_t crc = modbus_crc16(frame, 6); frame[6] = crc & 0xFF; frame[7] = crc >> 8; set_rs485_mode(SEND_MODE); HAL_UART_Transmit(&huart1, frame, 8, 100); set_rs485_mode(RECEIVE_MODE); }写多个寄存器(0x10)
void handle_write_multiple_registers(uint8_t *frame, uint8_t len) { uint16_t start_addr = (frame[2] << 8) | frame[3]; uint16_t count = (frame[4] << 8) | frame[5]; uint8_t byte_count = frame[6]; if (byte_count != count * 2 || start_addr + count > REG_COUNT) { send_exception_response(SLAVE_ADDRESS, 0x90, 0x02); return; } int data_idx = 7; for (int i = 0; i < count; i++) { holding_registers[start_addr + i] = (frame[data_idx] << 8) | frame[data_idx + 1]; data_idx += 2; } // 响应:返回起始地址、数量 uint8_t resp[8] = { SLAVE_ADDRESS, 0x10, frame[2], frame[3], frame[4], frame[5] }; uint16_t crc = modbus_crc16(resp, 6); resp[6] = crc & 0xFF; resp[7] = crc >> 8; set_rs485_mode(SEND_MODE); HAL_UART_Transmit(&huart1, resp, 8, 100); set_rs485_mode(RECEIVE_MODE); }RS-485 方向控制函数
#define RS485_DE_PIN GPIO_PIN_0 #define RS485_PORT GPIOC void set_rs485_mode(uint8_t mode) { if (mode == SEND_MODE) { HAL_GPIO_WritePin(RS485_PORT, RS485_DE_PIN, GPIO_PIN_SET); } else { HAL_GPIO_WritePin(RS485_PORT, RS485_DE_PIN, GPIO_PIN_RESET); } }寄存器映射设计建议
别小看这一步,合理的地址规划能让后期维护轻松十倍。
推荐格式:
| 起始地址 | 名称 | 类型 | 描述 |
|---|---|---|---|
| 0x00 | 温度值 | uint16 | ×10 表示实际摄氏度 |
| 0x01 | 湿度值 | uint16 | ×10 表示百分比 |
| 0x02 | 设备状态 | uint16 | bit0: 运行, bit1: 故障 |
| 0x03 | 控制命令 | uint16 | 上位机下发指令 |
| … | … | … | … |
🛠 示例:若当前温度为25.6°C,则往0x00写入
256;读取时除以10还原。
调试技巧与常见坑点
❌ 坑点1:CRC校验失败
- 检查是否包含地址和功能码参与CRC计算?
- 是否误将接收到的CRC本身再次纳入计算?
✅ 正确做法:CRC只计算前n-2字节!
❌ 坑点2:收不到完整帧
- 波特率设置不一致(主从双方必须相同)
- 定时器超时时间不准(不同波特率需调整)
✅ 解决方案:使用串口助手(如Modbus Poll)模拟主站测试。
❌ 坑点3:MAX485方向切换延迟
- 发送完成后立即切回接收,可能导致最后一个字节丢失
- 加一点微小延时(如
HAL_Delay(1))更稳妥
应用扩展思路
你现在有了一个基础Modbus从机,接下来可以做什么?
- ✅ 接入真实传感器(DHT22、SHT30等),实时更新寄存器
- ✅ 添加心跳机制:定期自增某寄存器,供主站判断在线状态
- ✅ 支持广播地址(0x00):实现批量参数初始化
- ✅ 移植到FreeRTOS:分离接收任务与业务逻辑
- ✅ 升级为Modbus TCP:通过ENC28J60或W5500接入网络
- ✅ 结合MQTT网关:桥接工业协议与云平台
总结:你已经掌握了工业互联的“普通话”
看到这里,你应该已经明白:
Modbus不是魔法,而是一种约定。只要你遵守规则,任何MCU都能成为工业生态的一员。
本文带你走完了从协议理解到代码落地的全过程,重点不在“用了哪个库”,而在理解本质:帧边界检测、CRC校验、主从交互流程、异常处理机制。
这些能力不仅能让你独立开发Modbus从机,更能迁移到其他通信协议的学习中。
下次当你面对一个新的通信需求时,不妨想想:
“我能听懂它的‘语言’吗?”
“我能按时回应吗?”
“我说的话对方会误解吗?”
这些问题,正是嵌入式通信的核心。
如果你实现了这个Demo,欢迎在评论区分享你的设备地址和第一个寄存器的值 😊