作为一名在工业自动化领域摸爬滚打多年的工程师,我深知Modbus协议在设备通信中的重要性。最近在为一个STM32F103项目开发Modbus主站功能时,发现网上大多数开源实现都存在过度设计的问题——动辄十几个文件、层层封装的架构,对于只需要基础通信功能的项目来说实在太过臃肿。经过一周的调试和优化,我最终实现了一个高度精简的Modbus主站方案,所有核心功能都浓缩在一个不到500行的modbus.c文件中。
这个实现方案具有几个显著特点:
在STM32F103平台上,我选择了USART2作为物理接口,具体引脚配置如下:
关键提示:RS485芯片的A/B线之间务必并联120Ω终端电阻,长距离通信时还要在总线两端各加一个。这个细节很多初学者都会忽略,导致通信不稳定。
GPIO初始化代码需要特别注意工作模式的选择:
c复制GPIO_InitTypeDef GPIO_InitStructure;
// TX引脚配置
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_2;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; // 必须使用复用推挽输出
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
// 485方向控制引脚
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_1;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; // 普通推挽输出
GPIO_Init(GPIOA, &GPIO_InitStructure);
Modbus RTU对时序要求严格,USART配置必须精确:
c复制USART_InitTypeDef USART_InitStructure;
USART_InitStructure.USART_BaudRate = 9600; // 常用波特率
USART_InitStructure.USART_WordLength = USART_WordLength_8b;
USART_InitStructure.USART_StopBits = USART_StopBits_1;
USART_InitStructure.USART_Parity = USART_Parity_No;
USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx;
USART_Init(USART2, &USART_InitStructure);
USART_Cmd(USART2, ENABLE);
USART_ITConfig(USART2, USART_IT_RXNE, ENABLE); // 使能接收中断
Modbus协议帧的构造需要注意字节序问题,特别是多字节数据的高低位顺序:
c复制uint8_t Modbus_Send(uint8_t slaveAddr, uint8_t funcCode, uint16_t regAddr, uint16_t dataLen)
{
static uint8_t txBuffer[8]; // 03功能码请求帧固定8字节
// 填充协议帧
txBuffer[0] = slaveAddr; // 从机地址
txBuffer[1] = funcCode; // 功能码
txBuffer[2] = regAddr >> 8; // 寄存器地址高字节
txBuffer[3] = regAddr & 0xFF; // 寄存器地址低字节
txBuffer[4] = dataLen >> 8; // 数据长度高字节
txBuffer[5] = dataLen & 0xFF; // 数据长度低字节
// CRC校验(小端模式)
uint16_t crc = CRC16_Modbus(txBuffer, 6);
txBuffer[6] = crc & 0xFF; // CRC低字节在前
txBuffer[7] = crc >> 8; // CRC高字节在后
// 发送前切换485为发送模式
GPIO_SetBits(GPIOA, GPIO_Pin_1); // DE=1
// 逐字节发送,带超时检测
for(uint8_t i = 0; i < 8; i++) {
USART_SendData(USART2, txBuffer[i]);
uint32_t timeout = 100000;
while(--timeout && USART_GetFlagStatus(USART2, USART_FLAG_TXE) == RESET);
if(timeout == 0) return 0; // 发送超时
}
// 立即切换回接收模式
GPIO_ResetBits(GPIOA, GPIO_Pin_1); // DE=0
return 1;
}
经验之谈:在RS485通信中,发送完成后必须立即切换回接收模式。实测发现即使延迟几十微秒,也可能错过从机的快速响应。我曾因此调试了一整天,最后用逻辑分析仪才捕捉到这个微妙的时间差。
帧接收采用"3.5字符超时"的Modbus标准判断机制:
c复制#define MODBUS_TIMEOUT_MS 50 // 超时时间(根据波特率调整)
volatile uint8_t modbusRxBuf[256];
volatile uint16_t rxIndex = 0;
volatile uint32_t lastRxTime = 0;
void USART2_IRQHandler(void)
{
if(USART_GetITStatus(USART2, USART_IT_RXNE) != RESET) {
uint8_t ch = USART_ReceiveData(USART2);
if(rxIndex < sizeof(modbusRxBuf)) {
modbusRxBuf[rxIndex++] = ch;
lastRxTime = HAL_GetTick(); // 记录最后接收时间
}
USART_ClearITPendingBit(USART2, USART_IT_RXNE);
}
}
uint8_t Modbus_CheckFrameComplete(void)
{
// 检测3.5字符静默时间
if(rxIndex > 0 && (HAL_GetTick() - lastRxTime) > MODBUS_TIMEOUT_MS) {
return 1;
}
return 0;
}
稳定的多从机管理需要合理的时序控制:
c复制typedef struct {
uint8_t addr;
uint16_t regAddr;
uint16_t regCount;
int16_t values[16];
uint8_t errorCount;
} SlaveDevice;
void Modbus_PollSlaves(SlaveDevice *slaves, uint8_t count)
{
for(uint8_t i = 0; i < count; i++) {
// 发送读寄存器请求
if(Modbus_Send(slaves[i].addr, 0x03, slaves[i].regAddr, slaves[i].regCount)) {
// 等待响应(超时100ms)
uint32_t start = HAL_GetTick();
while((HAL_GetTick() - start) < 100) {
if(Modbus_CheckFrameComplete()) {
if(Modbus_VerifyCRC() && modbusRxBuf[0] == slaves[i].addr) {
// 解析数据...
break;
}
}
}
}
HAL_Delay(10); // 帧间间隔
}
}
工业现场环境复杂,必须考虑通信异常情况:
c复制#define MAX_RETRY 3
void Modbus_SafePoll(SlaveDevice *slave)
{
uint8_t retry = 0;
while(retry < MAX_RETRY) {
if(Modbus_Send(slave->addr, 0x03, slave->regAddr, slave->regCount)) {
uint32_t start = HAL_GetTick();
while((HAL_GetTick() - start) < 100) {
if(Modbus_CheckFrameComplete()) {
if(Modbus_VerifyCRC()) {
if(modbusRxBuf[0] == slave->addr) {
slave->errorCount = 0;
// 成功处理数据...
return;
}
}
}
}
}
retry++;
slave->errorCount++;
HAL_Delay(20 * retry); // 指数退避
}
if(slave->errorCount > 5) {
// 触发从机故障处理
}
}
这个极简Modbus实现只需修改三个核心部分:
例如在GD32平台上移植时,只需要:
c复制// 替换STM32的HAL库函数为GD32的标准外设库
#define GPIO_SetBits(port, pin) gpio_bit_set(port, pin)
#define USART_SendData(usart, data) usart_data_transmit(usart, data)
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 接收全为0xFF | 485芯片未供电/损坏 | 检查VCC电压,更换芯片 |
| 偶发通信失败 | 终端电阻缺失 | 在总线两端加120Ω电阻 |
| CRC校验失败 | 字节序错误 | 确认CRC低字节在前 |
| 从机无响应 | 地址配置错误 | 用调试工具监听总线 |
在实际项目中,我发现最隐蔽的问题是电源干扰。有一次调试时通信时好时坏,最后发现是开关电源的噪声耦合到了485总线上。改用线性电源后问题立即消失。这也提醒我们,工业现场的环境远比实验室复杂,健壮的代码必须配合可靠的硬件设计。