STM32 Modbus RTU通信实战:从硬件连接到CRC校验全流程解析

最近在做一个工业数据采集的项目,客户现场的设备清一色用的都是Modbus RTU协议。说实话,第一次看到RS485总线上挂着一排设备,心里还是有点发怵的——线接错了怎么办?数据帧发出去没回应怎么办?CRC校验总是不通过又怎么办?这些问题在实际动手之前,光看协议文档是找不到答案的。折腾了几个星期,踩了不少坑,也总结出了一套从硬件连接到软件实现的完整流程。今天就把这些实战经验梳理出来,希望能帮到那些正准备在STM32上实现Modbus RTU通信的朋友们,特别是对RS485硬件和协议细节还不太熟悉的工程师。我们不讲太多空洞的理论,直接看代码、看接线、看调试过程中会遇到哪些具体问题。

1. 硬件连接与RS485电路设计

在开始写一行代码之前,硬件连接的正确性是通信成功的一半。很多通信失败的问题,根源都出在硬件上。

1.1 RS485收发器选型与电路

STM32的UART外设输出的是TTL电平,而RS485总线要求的是差分信号。因此,我们需要一个RS485收发器芯片作为“翻译官”。市面上常见的型号有SP3485、MAX485、SN65HVD72等。它们的功能大同小异,但有些细节需要注意。

以经典的SP3485为例,其典型应用电路如下。我们需要关注几个关键引脚:

  • RO (Receiver Output): 接收器输出,连接STM32的RX引脚。
  • DI (Driver Input): 驱动器输入,连接STM32的TX引脚。
  • RE (Receiver Enable): 接收使能,低电平有效。
  • DE (Driver Enable): 发送使能,高电平有效。

很多设计为了节省一个GPIO,会将REDE引脚短接,用一个GPIO(我们称之为DIR)统一控制。当DIR=1时,芯片处于发送模式;当DIR=0时,芯片处于接收模式。这里有一个非常重要的细节: 必须确保在发送数据之前,DIR引脚已经稳定切换到高电平;在发送完最后一个字节后,需要等待一个短暂的时间(例如,确保最后一个字节的停止位已完整发出),再将DIR切换回低电平,进入接收模式。切换太快,会导致最后一个字节发送不完整;切换太慢,则会错过从机的应答。

// 假设使用PA8控制收发方向
#define RS485_DIR_PIN GPIO_PIN_8
#define RS485_DIR_PORT GPIOA

#define RS485_SET_TX() HAL_GPIO_WritePin(RS485_DIR_PORT, RS485_DIR_PIN, GPIO_PIN_SET) // 进入发送模式
#define RS485_SET_RX() HAL_GPIO_WritePin(RS485_DIR_PORT, RS485_DIR_PIN, GPIO_PIN_RESET) // 进入接收模式

// 在发送函数中
void RS485_SendBytes(uint8_t *pData, uint16_t Size) {
    RS485_SET_TX(); // 先切换到发送模式
    HAL_Delay(1); // 等待芯片稳定,时间根据芯片手册调整,通常几微秒即可
    HAL_UART_Transmit(&huart2, pData, Size, 1000); // 发送数据
    // 等待发送完成,确保最后一个字节已离开移位寄存器
    while(__HAL_UART_GET_FLAG(&huart2, UART_FLAG_TC) == RESET);
    HAL_Delay(1); // 可选,确保停止位已发出
    RS485_SET_RX(); // 立即切换回接收模式
}

注意:HAL_Delay(1)在实际产品中应替换为更精确的微秒级延时或基于定时器的判断。这里仅为示意。关键是要查阅你所使用的RS485芯片数据手册,了解其DE/RE切换的稳定时间要求。

1.2 终端电阻与总线拓扑

RS485总线是差分传输,在高速或长距离通信时,为了抑制信号反射,需要在总线两端的设备上并联一个终端电阻,其阻值通常等于传输线的特征阻抗(一般为120Ω)。

场景 终端电阻配置建议 说明
短距离、低速率 (<50米, <19.2kbps) 可以不接 信号反射影响较小,简化硬件。
中长距离或高速率 必须在总线物理两端的设备上接120Ω电阻 匹配阻抗,吸收反射波,保证信号完整性。
多节点挂接 仅在线路两端的设备接电阻,中间设备不接。 如果所有设备都接电阻,会导致总线负载过重,驱动能力不足。

总线拓扑应尽量采用菊花链结构,避免星型连接。分支线应尽可能短。

推荐(菊花链):
主机 ---[RS485 A/B]--- 从机1 ---[RS485 A/B]--- 从机2 ---[RS485 A/B]--- ... ---[终端电阻]

不推荐(星型):
                从机1
                /
主机 ---[RS485]--- 从机2
                \
                从机3

2. STM32 UART与Modbus基础配置

硬件准备就绪后,我们来配置STM32的软件部分。核心是利用UART(或USART)外设。

2.1 UART初始化关键参数

使用STM32CubeMX或直接编写代码初始化UART时,以下参数必须与Modbus RTU规范及你的从机设备保持一致:

  • 波特率 (Baud Rate): 常见的有9600, 19200, 38400, 115200等。总线上所有设备必须相同
  • 数据位 (Data Bits): 固定为 8 位
  • 停止位 (Stop Bits): 固定为 1 位
  • 校验位 (Parity): Modbus RTU 通常使用无校验 (None)。虽然协议支持奇偶校验,但大部分工业设备默认使用无校验,依靠CRC进行数据校验。务必确认你的从机设备设置。
// 使用HAL库的初始化示例(以UART2为例)
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_TX_RX;
    huart2.Init.HwFlowCtl = UART_HWCONTROL_NONE;
    huart2.Init.OverSampling = UART_OVERSAMPLING_16;
    if (HAL_UART_Init(&huart2) != HAL_OK) {
        Error_Handler();
    }
    // 使能接收中断(用于接收数据)
    HAL_UART_Receive_IT(&huart2, &rx_buffer, 1);
}

2.2 理解RTU帧的“3.5字符静默”

Modbus RTU帧没有像ASCII帧那样的起始符(:)和结束符(\r\n)。它依靠帧间的静默时间来界定一帧数据的开始和结束。

  • 帧起始: 总线空闲时间 ≥ 3.5个字符传输时间
  • 帧结束: 最后一个字符结束后,总线空闲时间 ≥ 3.5个字符传输时间

这意味着我们的接收程序不能简单地收完固定长度的数据就认为一帧结束,而需要用一个定时器来监测字符间隔。

计算3.5字符时间: 假设波特率为9600 bps。 传输1位的时间 T_bit = 1 / 9600 ≈ 104.2 μs。 1个字符(8数据位+1停止位=10位)的时间 T_char = 10 * T_bit ≈ 1.042 ms。 3.5字符时间 T_3.5 = 3.5 * T_char ≈ 3.65 ms

因此,我们需要一个超时时间设定为约3.65ms的定时器。在UART收到第一个字节时启动定时器,之后每收到一个字节都重置(重启)定时器。当定时器超时(即超过3.65ms没收到新字节),就认为一帧数据接收完成。

3. 数据帧的接收与解析机制

这是Modbus RTU实现的核心逻辑。我们需要结合UART中断和定时器中断来构建一个稳健的接收状态机。

3.1 接收状态机设计

一个简单有效的接收状态机可以包含以下几个状态:

  1. IDLE (空闲): 等待帧开始。
  2. RECEIVING (接收中): 正在接收数据字节。
  3. COMPLETE (完成): 一帧数据接收完毕,等待处理。

状态迁移由UART接收中断定时器超时中断共同触发。

// 定义接收状态和缓冲区
typedef enum {
    MB_RX_STATE_IDLE,
    MB_RX_STATE_RECEIVING,
    MB_RX_STATE_COMPLETE
} ModbusRxState_t;

ModbusRxState_t mbRxState = MB_RX_STATE_IDLE;
uint8_t mbRxBuffer[256];
uint16_t mbRxIndex = 0;
uint16_t mbRxLength = 0;

// UART接收中断回调函数
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
    if(huart->Instance == USART2) {
        uint8_t rxByte = rx_buffer; // 假设rx_buffer是全局变量,存放收到的单个字节

        switch(mbRxState) {
            case MB_RX_STATE_IDLE:
                // 收到第一个字节,意味着检测到帧开始(总线已静默超过3.5字符)
                mbRxIndex = 0;
                mbRxBuffer[mbRxIndex++] = rxByte;
                mbRxState = MB_RX_STATE_RECEIVING;
                // 启动或重置超时定时器,设定为3.5字符时间
                __HAL_TIM_SET_AUTORELOAD(&htim3, 365); // 假设定时器1ms计数,365对应3.65ms
                HAL_TIM_Base_Start_IT(&htim3);
                break;

            case MB_RX_STATE_RECEIVING:
                // 接收后续字节
                if(mbRxIndex < sizeof(mbRxBuffer)) {
                    mbRxBuffer[mbRxIndex++] = rxByte;
                }
                // 重置超时定时器
                __HAL_TIM_SET_COUNTER(&htim3, 0);
                break;

            case MB_RX_STATE_COMPLETE:
                // 理论上不应进入此状态,因为完成帧后应复位。这里做保护性处理。
                break;
        }
        // 重新启动接收中断,等待下一个字节
        HAL_UART_Receive_IT(&huart2, &rx_buffer, 1);
    }
}

// 定时器超时中断回调函数(用于判断帧结束)
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) {
    if(htim->Instance == TIM3) {
        if(mbRxState == MB_RX_STATE_RECEIVING) {
            // 超时发生,意味着3.5字符时间内没收到新数据,一帧接收完成
            mbRxLength = mbRxIndex;
            mbRxState = MB_RX_STATE_COMPLETE;
            // 停止定时器
            HAL_TIM_Base_Stop_IT(&htim3);
            // 可以设置一个标志位,通知主循环处理接收到的帧
            mbFrameReadyFlag = 1;
        }
        // 如果是IDLE状态下的超时,忽略即可
    }
}

3.2 帧长度验证与地址过滤

mbFrameReadyFlag置位后,主循环应处理接收到的帧。第一步是进行基本验证:

  • 最小长度: 一个合法的RTU帧(从地址到CRC)至少4个字节。
  • 地址匹配: 检查帧的第一个字节(设备地址)是否与本机地址匹配。如果不匹配,直接丢弃此帧。
  • CRC校验: 这是下一步的重点。
void Modbus_ProcessFrame(void) {
    if(mbFrameReadyFlag) {
        mbFrameReadyFlag = 0;

        if(mbRxLength < 4) { // 地址(1) + 功能码(1) + CRC(2) = 4
            // 帧太短,无效
            mbRxState = MB_RX_STATE_IDLE;
            return;
        }

        // 1. 地址过滤 (假设本机地址为0x01)
        if(mbRxBuffer[0] != 0x01) {
            // 不是发给本机的,忽略。注意:地址0是广播地址,通常也需要处理。
            mbRxState = MB_RX_STATE_IDLE;
            return;
        }

        // 2. CRC校验
        uint16_t crcReceived = (mbRxBuffer[mbRxLength - 1] << 8) | mbRxBuffer[mbRxLength - 2]; // CRC低字节在前
        uint16_t crcCalculated = Modbus_CRC16(mbRxBuffer, mbRxLength - 2); // 计算除CRC外的所有字节

        if(crcCalculated == crcReceived) {
            // CRC校验通过,解析功能码和数据
            uint8_t funcCode = mbRxBuffer[1];
            Modbus_ExecuteFunction(funcCode, &mbRxBuffer[2], mbRxLength - 4); // 减去地址、功能码、CRC
        } else {
            // CRC错误,可记录错误或发送异常响应
            // Modbus_SendExceptionResponse(mbRxBuffer[0], funcCode, EXCEPTION_CRC_ERROR);
        }

        // 处理完毕,状态复位
        mbRxState = MB_RX_STATE_IDLE;
    }
}

4. CRC-16校验的算法实现与优化

CRC校验是保证Modbus RTU通信可靠性的关键。虽然HAL库或标准库可能不直接提供Modbus CRC16函数,但其算法是标准的,我们可以自己实现。

4.1 标准查表法实现

最常用且效率高的方法是查表法。预先计算好一个256字节的CRC表,校验时直接查表计算。

// Modbus CRC16 预计算表 (多项式 0xA001)
static const uint16_t crc16_table[256] = {
    0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241,
    0xC601, 0x06C0, 0x0780, 0xC741, 0x0500, 0xC5C1, 0xC481, 0x0440,
    // ... 此处省略中间248个值,实际代码需补全
    0xCC01, 0x0CC0, 0x0D80, 0xCD41, 0x0F00, 0xCFC1, 0xCE81, 0x0E40,
    0x0A00, 0xCAC1, 0xCB81, 0x0B40, 0xC901, 0x09C0, 0x0880, 0xC841
};

// 计算Modbus CRC16
uint16_t Modbus_CRC16(const uint8_t *pData, uint16_t length) {
    uint8_t crcHi = 0xFF; // 高字节初始值
    uint8_t crcLo = 0xFF; // 低字节初始值
    uint16_t index;

    while (length--) {
        index = crcLo ^ *pData++; // 计算查表索引
        crcLo = crcHi ^ (crc16_table[index] & 0xFF);
        crcHi = crc16_table[index] >> 8;
    }
    return (crcHi << 8) | crcLo;
}

// 在发送前,将CRC附加到数据帧末尾
void Modbus_AppendCRC(uint8_t *pFrame, uint16_t lengthWithoutCRC) {
    uint16_t crc = Modbus_CRC16(pFrame, lengthWithoutCRC);
    pFrame[lengthWithoutCRC] = crc & 0xFF;   // 低字节在前
    pFrame[lengthWithoutCRC + 1] = crc >> 8; // 高字节在后
}

提示:完整的256字节CRC表可以在网上轻松找到,或使用在线的CRC计算工具生成。务必确认使用的是Modbus标准的多项式(0x8005,但位序反转后常表示为0xA001)。

4.2 常见CRC错误排查

如果你的CRC校验总是不通过,可以按以下步骤排查:

  • 字节顺序: 确认发送和接收时,CRC的两个字节是低字节在前,高字节在后。这是Modbus RTU的规定。
  • 计算范围: 确认计算CRC的字节范围是否正确。计算范围是从设备地址开始,到数据区结束,不包括CRC本身。
  • 初始值: Modbus CRC16的初始值是0xFFFF。
  • 多项式: 确认使用的多项式是0xA001(反转后的0x8005)。
  • 在线工具验证: 将你的数据帧(不包括CRC)和计算出的CRC,用可靠的在线Modbus CRC计算工具进行比对。

5. 功能码处理与数据模型构建

通过了CRC校验,我们终于拿到了干净、可信的数据帧。接下来就是根据功能码执行相应的操作。Modbus定义了访问四种基本数据模型的功能码:

  • 线圈 (Coils): 可读可写的布尔量(1位),功能码 0x01(读),0x05(写单个),0x0F(写多个)。
  • 离散输入 (Discrete Inputs): 只读的布尔量,功能码 0x02(读)。
  • 保持寄存器 (Holding Registers): 可读可写的16位整数,功能码 0x03(读),0x06(写单个),0x10(写多个)。
  • 输入寄存器 (Input Registers): 只读的16位整数,功能码 0x04(读)。

5.1 设计内部数据映射

在STM32中,我们需要在内存中开辟数组来模拟这些数据模型。

// 定义数据模型大小(根据实际需求调整)
#define COILS_SIZE         100
#define DISCRETE_INPUTS_SIZE 50
#define HOLDING_REGS_SIZE  100
#define INPUT_REGS_SIZE    50

// 数据存储区
uint8_t coils[COILS_SIZE]; // 每个元素存8个线圈,需要位操作
uint16_t holdingRegs[HOLDING_REGS_SIZE];
uint8_t discreteInputs[DISCRETE_INPUTS_SIZE];
uint16_t inputRegs[INPUT_REGS_SIZE];

// 示例:初始化一些测试数据
void Modbus_DataInit(void) {
    holdingRegs[0] = 0x1234;
    holdingRegs[1] = 0x5678;
    inputRegs[0] = 0xABCD;
    // 设置第0个线圈为ON
    coils[0] |= 0x01;
}

5.2 实现读保持寄存器功能(0x03)

这是最常用的功能码之一。主机请求帧中会指定起始寄存器地址和要读取的寄存器数量。

// 处理读保持寄存器请求
ModbusError_t Modbus_Handle_ReadHoldingRegs(uint8_t *pRequest, uint8_t *pResponse) {
    // pRequest指向请求帧的数据区(地址之后)
    uint16_t startAddr = (pRequest[0] << 8) | pRequest[1]; // 高字节在前
    uint16_t regCount = (pRequest[2] << 8) | pRequest[3];

    // 1. 参数检查
    if (regCount == 0 || regCount > 0x007D) { // Modbus协议限制最多125个寄存器
        return MB_EX_ILLEGAL_DATA_VALUE;
    }
    if ((startAddr + regCount) > HOLDING_REGS_SIZE) { // 越界检查
        return MB_EX_ILLEGAL_DATA_ADDRESS;
    }

    // 2. 构建响应
    pResponse[0] = 0x03; // 功能码
    pResponse[1] = regCount * 2; // 字节数 = 寄存器数 * 2
    for (int i = 0; i < regCount; i++) {
        pResponse[2 + i * 2] = holdingRegs[startAddr + i] >> 8; // 高字节
        pResponse[3 + i * 2] = holdingRegs[startAddr + i] & 0xFF; // 低字节
    }
    // 响应长度 = 功能码(1) + 字节数(1) + 数据(regCount*2)
    *respLength = 2 + regCount * 2;

    return MB_EX_NONE; // 无异常
}

// 在主处理函数中调用
void Modbus_ExecuteFunction(uint8_t funcCode, uint8_t *pData, uint16_t dataLen) {
    uint8_t responseBuffer[256];
    uint16_t respLen = 0;
    ModbusError_t error = MB_EX_NONE;

    switch(funcCode) {
        case 0x03:
            error = Modbus_Handle_ReadHoldingRegs(pData, responseBuffer, &respLen);
            break;
        case 0x06:
            // 处理写单个寄存器
            break;
        // ... 其他功能码
        default:
            error = MB_EX_ILLEGAL_FUNCTION;
            break;
    }

    if (error == MB_EX_NONE) {
        // 发送正常响应
        uint8_t txFrame[256];
        txFrame[0] = SLAVE_ADDRESS; // 从机地址
        memcpy(&txFrame[1], responseBuffer, respLen); // 复制功能码和数据
        Modbus_AppendCRC(txFrame, respLen + 1); // 计算并附加CRC (+1是地址)
        RS485_SendBytes(txFrame, respLen + 1 + 2); // 发送整个帧(地址+数据+CRC)
    } else {
        // 发送异常响应
        uint8_t txFrame[5];
        txFrame[0] = SLAVE_ADDRESS;
        txFrame[1] = funcCode | 0x80; // 异常响应功能码 = 原功能码 + 0x80
        txFrame[2] = error; // 异常码
        Modbus_AppendCRC(txFrame, 3);
        RS485_SendBytes(txFrame, 5);
    }
}

5.3 数据同步与实时性考虑

在工业控制中,Modbus数据往往映射到实际的物理IO或内部变量。你需要决定数据更新的策略:

  • 被动更新: 仅在主机查询时,才去读取最新的物理IO状态并更新到数据模型中。响应会有延迟。
  • 主动更新: 在后台任务或中断中持续更新数据模型,主机查询时直接返回最新值。这需要处理好数据访问的互斥(如果是在RTOS中)。

对于写操作(如0x06写单个寄存器),在更新内存中的数据模型后,通常需要立即触发一个动作,比如更新DAC输出、改变PWM占空比等。

// 示例:写单个保持寄存器(0x06)的处理
ModbusError_t Modbus_Handle_WriteSingleReg(uint8_t *pRequest, uint8_t *pResponse) {
    uint16_t regAddr = (pRequest[0] << 8) | pRequest[1];
    uint16_t regValue = (pRequest[2] << 8) | pRequest[3];

    if (regAddr >= HOLDING_REGS_SIZE) {
        return MB_EX_ILLEGAL_DATA_ADDRESS;
    }

    // 1. 更新内存中的数据模型
    holdingRegs[regAddr] = regValue;

    // 2. 触发相应的物理操作(例如,如果该寄存器映射到某个模拟量输出)
    if (regAddr == 0) { // 假设地址0映射到DAC通道1
        HAL_DAC_SetValue(&hdac, DAC_CHANNEL_1, DAC_ALIGN_12B_R, regValue);
    }

    // 3. 构建响应(回显写入的值)
    memcpy(pResponse, pRequest, 4); // 响应帧直接返回收到的地址和值
    *respLength = 4;

    return MB_EX_NONE;
}

最后,关于通信超时处理。主机发送请求后,如果在规定时间内(例如1秒)没有收到从机的任何响应,应视为通信超时。这需要在主机端的代码中实现重试或错误上报机制。而从机端,只要严格遵循上述的接收、校验、处理、响应流程,就能构建出一个稳定可靠的Modbus RTU从站。调试时,一个好用的USB转RS485适配器和一个像Modbus Poll/Modbus Slave这样的调试软件是你的最佳搭档,可以直观地验证每一帧数据的收发是否正确。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐