STM32实战:从零构建Modbus RTU从站(RS485通信+完整源码解析)
第一次接触工业通信协议时,我被Modbus协议简洁高效的魅力所吸引。记得三年前在自动化产线调试现场,看着PLC通过两根双绞线就能控制数十台设备,这种看似简单却异常可靠的通信方式让我着迷。今天,我们就用STM32F103这颗经典芯片,亲手搭建一个能接入工业控制系统的Modbus从站设备。
1. 硬件准备与环境搭建
1.1 硬件选型与连接
手头准备一块STM32开发板(我用的是正点原子MiniSTM32),外加一个RS485转换模块。市面上常见的MAX485芯片模块价格不到10元,却能在工业环境中稳定工作。接线时特别注意:
- A/B线:RS485差分信号线,必须双绞且远离电源线
- RE/DE:收发控制引脚,接STM32任意GPIO(我用的PC9)
- 终端电阻:长距离通信时在总线两端接120Ω电阻
// GPIO初始化示例(CubeMX生成) void MX_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOC_CLK_ENABLE(); // RS485收发控制引脚 GPIO_InitStruct.Pin = GPIO_PIN_9; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOC, &GPIO_InitStruct); HAL_GPIO_WritePin(GPIOC, GPIO_PIN_9, GPIO_PIN_RESET); // 默认接收模式 }1.2 开发环境配置
推荐使用STM32CubeIDE,它自动生成的代码框架能省去大量底层配置时间。关键配置步骤:
- 在Pinout视图启用USART2(或其他可用串口)
- 参数设置:波特率9600,8数据位,无校验,1停止位
- 开启串口全局中断
- 生成代码前勾选"Generate peripheral initialization as a pair of .c/.h files"
注意:如果使用Keil MDK,记得在Options for Target的C/C++选项卡添加
USE_HAL_DRIVER宏定义
2. Modbus协议核心实现
2.1 寄存器映射设计
Modbus协议通过4种寄存器与设备交互,我们需在STM32内存中建立映射关系:
| 寄存器类型 | 功能码 | 地址范围 | 映射目标 |
|---|---|---|---|
| 线圈寄存器 | 0x01 | 0x0000-0xFFFF | GPIO输出状态 |
| 离散输入 | 0x02 | 0x0000-0xFFFF | GPIO输入状态 |
| 保持寄存器 | 0x03 | 0x0000-0xFFFF | 内部变量数组 |
| 输入寄存器 | 0x04 | 0x0000-0xFFFF | ADC采样值 |
// 寄存器映射示例 typedef struct { uint8_t coil[COIL_REG_SIZE]; // 可读写位寄存器 uint8_t input[INPUT_REG_SIZE]; // 只读位寄存器 uint16_t holding[HOLDING_REG_SIZE]; // 可读写字寄存器 uint16_t input_reg[INPUT_REG_SIZE]; // 只读字寄存器 } ModbusRegMap; ModbusRegMap mb_regs = {0};2.2 CRC16校验实现
Modbus RTU必须的CRC校验算法,这个优化版本比查表法更节省内存:
uint16_t ModbusCRC16(uint8_t *pdata, uint16_t len) { uint16_t crc = 0xFFFF; while (len--) { crc ^= *pdata++; for (uint8_t i = 0; i < 8; i++) { if (crc & 0x0001) { crc >>= 1; crc ^= 0xA001; } else { crc >>= 1; } } } return (crc << 8) | (crc >> 8); // 高低字节交换 }3. 功能码处理实战
3.1 读取保持寄存器(0x03)
这是最常用的功能码,上位机通过它获取设备数据。响应报文格式:
[设备地址][功能码][字节数][数据1][数据2]...[CRC16]void Handle_ReadHoldingRegisters(uint8_t *request, uint8_t *response) { uint16_t startAddr = (request[2] << 8) | request[3]; uint16_t regCount = (request[4] << 8) | request[5]; // 异常检查 if (regCount > 125) { BuildExceptionResponse(request, response, ILLEGAL_DATA_VALUE); return; } response[0] = request[0]; // 设备地址 response[1] = request[1]; // 功能码 response[2] = regCount * 2; // 字节数 for (uint16_t i = 0; i < regCount; i++) { uint16_t regValue = mb_regs.holding[startAddr + i]; response[3 + i*2] = regValue >> 8; response[4 + i*2] = regValue & 0xFF; } uint16_t crc = ModbusCRC16(response, 3 + regCount*2); response[3 + regCount*2] = crc & 0xFF; response[4 + regCount*2] = crc >> 8; }3.2 预设单个寄存器(0x06)
PLC常用此功能码修改设备参数。典型请求报文:
[01][06][00][01][00][03][CRC16]表示将地址0x0001的保持寄存器值设为0x0003
void Handle_PresetSingleRegister(uint8_t *request, uint8_t *response) { uint16_t regAddr = (request[2] << 8) | request[3]; uint16_t regValue = (request[4] << 8) | request[5]; if (regAddr >= HOLDING_REG_SIZE) { BuildExceptionResponse(request, response, ILLEGAL_DATA_ADDRESS); return; } mb_regs.holding[regAddr] = regValue; // 回显相同数据作为响应 memcpy(response, request, 6); uint16_t crc = ModbusCRC16(response, 6); response[6] = crc & 0xFF; response[7] = crc >> 8; }4. 通信优化与故障排查
4.1 超时管理机制
工业现场必须考虑通信异常情况,建议实现以下保护措施:
- 帧间隔超时:3.5个字符时间(9600bps时约4ms)
- 响应超时:从收到完整请求到开始响应不超过1秒
- 静默超时:持续2秒无通信自动复位接收状态
// 使用HAL库的定时器实现 void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim == &htim3) { // 10ms定时器 static uint16_t silenceTimeout = 0; if (uartRxState == RX_BUSY) { if (++silenceTimeout > 200) { // 2秒超时 uartRxState = RX_IDLE; silenceTimeout = 0; } } } }4.2 典型故障处理方案
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 通信时好时坏 | 终端电阻未接 | 在总线两端接120Ω电阻 |
| 全部从站无响应 | A/B线接反 | 调换A、B线位置 |
| CRC校验失败 | 波特率偏差 | 检查双方波特率设置 |
| 偶发数据错误 | 电磁干扰 | 使用屏蔽双绞线,远离变频器 |
5. 工程源码架构解析
完整项目包含这些关键文件:
├── Core │ ├── Src │ │ ├── main.c // 主循环和初始化 │ │ ├── modbus_rtu.c // Modbus协议处理 │ │ └── rs485.c // 硬件驱动层 │ └── Inc │ ├── modbus_rtu.h │ └── rs485.h ├── Drivers └── STM32F1xx_HAL_Drivermodbus_rtu.c的核心处理流程:
void Modbus_ProcessRequest(uint8_t *request, uint16_t len) { // 校验CRC uint16_t crc = ModbusCRC16(request, len - 2); if (crc != ((request[len-1] << 8) | request[len-2])) { return; // 丢弃CRC错误帧 } // 检查设备地址 if (request[0] != DEVICE_ADDRESS) { return; // 非本设备报文 } switch (request[1]) { // 功能码分发 case 0x01: Handle_ReadCoils(request, response); break; case 0x03: Handle_ReadHoldingRegisters(request, response); break; case 0x06: Handle_PresetSingleRegister(request, response); break; // 其他功能码处理... default: BuildExceptionResponse(request, response, ILLEGAL_FUNCTION); } RS485_Send(response, GetResponseLength(response)); }6. 上位机测试技巧
使用Modbus Poll软件测试时,这几个参数必须匹配:
- 传输模式:RTU
- 串口参数:波特率、数据位、停止位
- 从站地址:与代码中
DEVICE_ADDRESS一致 - 扫描间隔:建议初始设置为1000ms
测试用例设计示例:
- 读取40001-40005保持寄存器(功能码0x03)
- 写入40001寄存器值为0x55AA(功能码0x06)
- 读取40001验证写入结果
- 故意发送错误CRC测试容错性
# 简单的Python测试脚本示例 import serial import struct ser = serial.Serial('COM3', 9600, timeout=1) def build_rtu_frame(addr, func, data): frame = bytes([addr, func]) + data crc = struct.pack('<H', ModbusCRC16(frame)) return frame + crc # 读取保持寄存器40001-40003 request = build_rtu_frame(0x01, 0x03, bytes([0x00, 0x00, 0x00, 0x03])) ser.write(request) response = ser.read(9) # 预期返回7字节+2字节CRC调试阶段建议在关键位置添加日志输出:
printf("[Modbus] RX: "); for(int i=0; i<len; i++) printf("%02X ", buf[i]); printf("\r\n");通过示波器观察RS485信号质量时,健康波形应具备:
- 差分电压幅值大于1.5V
- 上升/下降沿陡峭无振铃
- 逻辑电平转换期间无毛刺