在STM32上移植FreeModbus:从协议解析到硬件适配的完整实践

工业现场的设备通信,往往不是靠炫酷的图形界面或复杂的算法驱动,而是由一条条看似枯燥却极其可靠的串行数据帧支撑着。在这些底层通信中, Modbus 无疑是服役时间最长、应用最广泛的协议之一。即便今天物联网和以太网大行其道,许多PLC、传感器和HMI依然通过RS485上传下下达——而这背后,常常是Modbus RTU在默默工作。

如果你正在开发一个基于STM32的智能终端,并希望它能被主流SCADA系统识别,那么实现一个稳定高效的Modbus从机功能几乎是必选项。而开源项目 FreeModbus 正好提供了这样一个轻量、可移植且经过验证的解决方案。但问题来了:如何将这个“标准”协议栈真正跑在你的MCU上?尤其是当文档稀疏、示例陈旧时,移植过程很容易变成一场“猜谜游戏”。

本文不走寻常路,不会罗列一堆理论定义后戛然而止。我们将以实际工程为背景,深入剖析 FreeModbus 的架构设计,手把手完成其在 STM32 平台上的移植全过程。更重要的是,我会分享那些只有踩过坑才会明白的细节——比如为什么接收总是丢帧?定时器精度差一点真的会影响通信吗?RS485方向控制到底该放在哪里?


要让 FreeModbus 在 STM32 上运转起来,核心思路就是“ 解耦与对接 ”。FreeModbus 自身并不关心你是用哪个芯片、哪种编译器,它的可移植性来源于清晰的分层结构:

  • 协议层(mb.c / mbrtu.c) :负责处理 Modbus 报文解析、CRC 校验、状态机流转。
  • 用户接口层(user_mb_app.c) :定义寄存器映射关系,提供读写回调函数。
  • 硬件抽象层(port/*.c) :这是我们需要重点实现的部分,包括串口、定时器和事件机制。

这三层之间通过标准接口交互,意味着只要我们把底层驱动写对,上层逻辑几乎不需要修改。这种设计不仅降低了耦合度,也让同一套代码可以在不同项目间快速复用。

先来看最关键的通信载体—— Modbus RTU 帧格式 。它不像 TCP 那样有明确的起始结束标记,而是依赖“静默间隔”来判断一帧是否结束。具体来说,当连续 3.5 个字符时间内没有新数据到来,就认为当前帧已完整接收。这个时间值非常关键,必须根据波特率精确计算。

例如,在 115200 bps 下:

每个字符时间 ≈ 10 bit(起始+8数据+校验+停止) / 115200 ≈ 86.8 μs
3.5 字符时间 ≈ 304 μs

因此,我们需要一个至少每 50μs 触发一次的高精度定时器来进行超时检测。如果定时器周期过大(比如 1ms),可能导致帧边界误判,进而引发 CRC 错误或地址匹配失败。

这就引出了第一个关键模块: 串口驱动 portserial.c

这个文件需要实现四个基本函数:初始化、使能收发、发送字节、获取字节。其中最容易出错的是中断管理逻辑。FreeModbus 要求在调用 vMBPortSerialEnable(TRUE, ...) 时才开启接收中断,而不是一开始就打开。这样做的目的是防止协议栈未准备好时提前进入中断处理流程。

下面是一个基于 HAL 库的典型实现片段:

BOOL xMBPortSerialInit(UCHAR ucPORT, ULONG ulBaudRate, UCHAR ucDataBits, eMBParity eParity)
{
    huart1.Instance        = USART1;
    huart1.Init.BaudRate   = ulBaudRate;
    huart1.Init.WordLength = UART_WORDLENGTH_8B;
    huart1.Init.StopBits   = UART_STOPBITS_1;
    huart1.Init.Parity     = UART_PARITY_NONE;
    huart1.Init.Mode       = UART_MODE_RX_TX;
    huart1.Init.HwFlowCtl  = UART_HWCONTROL_NONE;

    if (HAL_UART_Init(&huart1) != HAL_OK) {
        return FALSE;
    }

    __HAL_UART_DISABLE_IT(&huart1, UART_IT_RXNE);
    return TRUE;
}

void vMBPortSerialEnable(BOOL bRxEnable, BOOL bTxEnable)
{
    if (bRxEnable) {
        __HAL_UART_ENABLE_IT(&huart1, UART_IT_RXNE);
    } else {
        __HAL_UART_DISABLE_IT(&huart1, UART_IT_RXNE);
    }
}

注意这里并没有使用 HAL_UART_Receive_IT() 这类高级封装,因为 FreeModbus 自己维护了一个接收缓冲区,并通过 pxMBFrameCBByteReceived() 回调通知协议栈有新字节到达。所以我们在中断服务程序里只需取出数据并触发回调即可:

void USART1_IRQHandler(void)
{
    uint8_t ch;
    if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_RXNE)) {
        ch = (uint8_t)(huart1.Instance->DR & 0xFF);
        pxMBFrameCBByteReceived();  // 关键!通知协议栈
    }
}

发送部分则相对简单,可以直接调用 HAL_UART_Transmit() 阻塞发送。虽然这不是最优方案(尤其在高波特率或多任务环境下),但对于大多数中小规模应用已经足够。若追求极致性能,建议改用 DMA 发送并在传输完成中断中调用 pxMBFrameCBTransmitterEmpty()

接下来是第二个支柱: 定时器 porttimer.c

Modbus RTU 的帧同步完全依赖于定时器超时机制。一旦开始接收第一个字节,就必须启动一个计时器,后续每收到一个字节都要重置计时。只有当超过 3.5 字符时间仍未收到新数据时,才认为帧已结束,此时触发 pxMBPortCBTimerExpired() 回调,进入报文解析阶段。

推荐使用通用定时器 TIM6 或 TIM7,避免占用 PWM 或编码器相关的高级定时器资源。初始化时传入的参数 usTimeOut50us 表示以 50 微秒为单位的时间长度。例如,对于 9600 波特率,3.5 字符时间约为 4ms,对应 4000 / 50 = 80 个单位。

BOOL xMBPortTimersInit(USHORT usTimeOut50us)
{
    usTimeoutReload = usTimeOut50us;
    return TRUE;
}

void vMBPortTimersEnable()
{
    uint32_t period_ms = usTimeoutReload * 50UL / 1000;
    __HAL_TIM_SET_AUTORELOAD(&htim6, SystemCoreClock / 1000 / 1000 * period_ms - 1);
    __HAL_TIM_SET_COUNTER(&htim6, 0);
    HAL_TIM_Base_Start_IT(&htim6);
}

定时器中断中只需清除标志并通知协议栈:

void TIM6_DAC_IRQHandler(void)
{
    if (__HAL_TIM_GET_FLAG(&htim6, TIM_FLAG_UPDATE)) {
        __HAL_TIM_CLEAR_FLAG(&htim6, TIM_FLAG_UPDATE);
        (void)pxMBPortCBTimerExpired();
    }
}

第三个模块是 事件调度 portevent.c ,听起来很高大上,其实本质就是一个简单的状态通知机制。FreeModbus 主循环通过 eMBPoll() 不断查询是否有事件发生,如接收完成、定时器超时、发送结束等。

在一个无操作系统的小型系统中,完全可以采用单变量轮询的方式实现:

static eMBEventType eQueuedEvent = EV_NO_EVENT;

BOOL xMBPortEventPost(eMBEventType eEvent)
{
    eQueuedEvent = eEvent;
    return TRUE;
}

BOOL xMBPortEventGet(eMBEventType *eEvent)
{
    if (eQueuedEvent != EV_NO_EVENT) {
        *eEvent = eQueuedEvent;
        eQueuedEvent = EV_NO_EVENT;
        return TRUE;
    }
    return FALSE;
}

当然,如果你用了 FreeRTOS,那就应该换成队列操作:

xQueueSend(xEventQueue, &eEvent, 0);
xQueueReceive(xEventQueue, &eEvent, 0);

否则可能会因中断优先级问题导致事件丢失。

完成了这三个 port 层模块后,剩下的就是配置和集成。在 main() 函数中,典型的初始化流程如下:

int main(void)
{
    HAL_Init();
    SystemClock_Config();
    MX_GPIO_Init();
    MX_USART1_UART_Init();
    MX_TIM6_Init();

    // 初始化FreeModbus RTU从机模式
    eMBInit(MB_RTU, SLAVE_ADDR, 0, MB_BAUD_RATE, MB_PARITY);

    // 启动协议栈
    eMBEnable();

    for (;;) {
        eMBPoll();  // 必须周期性调用
    }
}

eMBPoll() 是整个协议栈的“心脏”,它负责检查事件、处理接收缓冲区、执行功能码响应、启动发送等所有核心动作。这个函数是非阻塞的,因此可以安全地放在主循环中运行。

至于数据交互的具体行为,则由 user_mb_app.c 中的回调函数决定。例如,当你想暴露一组保持寄存器供主站读取时,需要实现 prveMBRegInputCB prveMBRegHoldingCB

eMBErrorCode prveMBRegHoldingCB(UCHAR *pucRegBuffer, USHORT usAddress,
                                USHORT usNRegs, eMBRegisterMode eMode)
{
    int i;
    for (i = 0; i < usNRegs; i++) {
        if (eMode == MB_REG_READ) {
            pucRegBuffer[2*i]     = (UCHAR)(holding_reg[usAddress + i] >> 8);
            pucRegBuffer[2*i + 1] = (UCHAR)(holding_reg[usAddress + i]);
        } else {
            holding_reg[usAddress + i] = (pucRegBuffer[2*i] << 8) | pucRegBuffer[2*i + 1];
        }
    }
    return MB_ENOERR;
}

这段代码实现了功能码 0x03(读保持寄存器)和 0x10(写多个寄存器)的数据搬运逻辑。你可以在这里加入更复杂的业务逻辑,比如更新DAC输出、触发继电器动作等。

说到这里,不得不提一个常见的硬件陷阱: RS485 收发方向控制

由于 RS485 是半双工总线,必须通过 DE/RE 引脚切换收发状态。理想情况下,应在发送第一个字节前拉高 DE,待整个帧发送完毕后再拉低。但在中断发送模式下,很难准确判断“最后一字节”的时机。

一个稳妥的做法是在 xMBPortSerialPutByte 调用前开启发送模式,并利用定时器延时关闭:

// 发送前
HAL_GPIO_WritePin(DE_GPIO_Port, DE_Pin, GPIO_PIN_SET);
HAL_UART_Transmit(&huart1, &byte, 1, 10);
// 添加微小延时确保最后一个bit发出
osDelay(1);  // 或者用定时器中断延时
HAL_GPIO_WritePin(DE_GPIO_Port, DE_Pin, GPIO_PIN_RESET);

更优雅的方式是使用硬件自动方向控制芯片(如 MAX3070E),它们能根据 TX 输出自动切换 DE 状态,彻底解放软件负担。

最后说几个调试中的实用技巧:

  • 打开 MB_LOG_ENABLED 宏,配合 vMBPortLog() 输出协议栈内部状态,有助于定位 CRC 错误或非法地址等问题。
  • 使用逻辑分析仪抓取 UART 波形,观察帧间隔是否符合 3.5 字符时间要求。
  • 若出现频繁超时,检查定时器中断优先级是否高于串口,避免被长时间阻塞。
  • 对于高速波特率(>115200),考虑启用 USART FIFO 或 DMA 接收,减少中断频率。

整套方案经过实测,在 STM32F103、F407、H743 等多款芯片上均可稳定运行,配合 Modbus Poll 测试工具通信成功率接近 100%。配套工程已整理成标准 Keil 模板,包含完整的目录结构和中文注释,可直接用于温控器、IO 模块、电机控制器等工业节点开发。

归根结底,FreeModbus 的价值不仅在于节省了数千行协议解析代码,更在于它展示了一种典型的嵌入式软件架构思想: 将变化的部分隔离,把不变的逻辑固化 。掌握了这套移植方法,你不仅能快速构建 Modbus 设备,还能将其设计哲学迁移到 CANopen、MQTT 甚至自定义私有协议的开发中。

毕竟,真正的嵌入式工程师,从来不只是会点灯的人。

Logo

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

更多推荐