Modbus RTU over TCP 通信——寄存器设计与固件实现全解析


系列回顾:

  • 第一篇:RK3588S + STM32F103C8T6 工业光源控制器——硬件架构全解析
  • 第二篇:FM25L16B F-RAM 驱动——工业级掉电保存的正确打开方式
  • 本篇(第三篇):Modbus RTU over TCP 通信——寄存器设计与固件实现全解析
  • 第四篇:双模式PWM光源控制——常亮与外部触发模式(待更新)
  • 第五篇:生产测试通过!联调全流程实录与量产经验总结(待更新)

一、为什么选 Modbus RTU over TCP,而不是纯 Modbus TCP

这是本项目通信方案里最常被问到的问题。先说清楚两者的区别:

对比项 Modbus RTU over TCP 纯 Modbus TCP(MBAP)
帧结构 原生 RTU 帧(含 CRC)直接套 TCP 替换了帧头,用 MBAP 事务标识符,去掉 CRC
端口 502(同标准) 502(同标准)
CRC 保留 移除(TCP 本身有校验)
兼容性 与串口 RTU 设备直接兼容,网关透传无需转换 需要重新实现协议层
上位机支持 Modbus Poll、大多数 PLC、SCADA 均支持 同上
实现复杂度 低(RTU 固件不用改,加 TCP 层即可) 需要重写帧解析

本项目的选择逻辑

STM32 端跑的是纯 Modbus RTU 固件(USART1 串口),YT8531C PHY芯片做 TCP 透传——本质上是把串口 RTU 帧原封不动地通过 TCP 发出去。这样做的好处是:

  1. STM32 固件不感知网络层,只处理标准 RTU 帧,代码简洁
  2. 工厂里已有的串口 RTU 设备,通过串口转以太网模块接入时,完全不需要修改协议
  3. 上位机(运行在 RK3588S Linux 上的 Python/Qt 程序)用 Socket 连接 192.168.0.205:502,读写寄存器的代码和 Modbus Poll 工具完全一致

💡 简单理解:Modbus RTU over TCP = 把串口线换成网线,帧格式完全不变。


二、RTU 帧结构

标准 Modbus RTU 帧结构:

┌──────────┬──────────┬──────────────────────┬───────────┐
│ 地址域   │ 功能码   │ 数据域               │ CRC       │
│ 1 Byte   │ 1 Byte   │ N Bytes              │ 2 Bytes   │
└──────────┴──────────┴──────────────────────┴───────────┘

以读 CH1 占空比(FC03,寄存器 0x0002,读 1 个)为例:

请求帧:01 03 00 02 00 01 25 CA
         │  │  └──┬──┘ └──┬──┘ └───┬──┘
         │  │   起始地址  寄存器数  CRC
         │  FC03
         从机地址 0x01

响应帧:01 03 02 00 80 B9 FC
         │  │  │  └──┬──┘ └───┬──┘
         │  │  │  数据(0x0080=128) CRC
         │  │  字节数(2)
         │  FC03
         从机地址 0x01

帧边界识别:Modbus RTU 没有帧长度字段,靠 3.5 字符静默时间 判断帧结束。9600bps 下,1 个字符(10位)= 1.04ms,3.5 字符 ≈ 3.65ms,本项目取 4ms 作为超时判断阈值。


三、USART1 初始化(重映射到 PB6/PB7)

void MODBUS_Init(uint32_t baudrate)
{
    GPIO_InitTypeDef  GPIO_InitStruct;
    USART_InitTypeDef USART_InitStruct;
    NVIC_InitTypeDef  NVIC_InitStruct;

    /* 使能 GPIOB、AFIO、USART1 时钟 */
    RCC_APB2PeriphClockCmd(MODBUS_GPIO_CLK | RCC_APB2Periph_AFIO, ENABLE);
    RCC_APB2PeriphClockCmd(MODBUS_USART_CLK, ENABLE);

    /* USART1 重映射:PA9/PA10 → PB6/PB7 */
    /* 原因:PA9/PA10 已被 TIM1 CH2/CH3 PWM 占用 */
    GPIO_PinRemapConfig(GPIO_Remap_USART1, ENABLE);

    /* PB6 — TX:复用推挽输出 */
    GPIO_InitStruct.GPIO_Pin   = MODBUS_TX_PIN;   /* GPIO_Pin_6 */
    GPIO_InitStruct.GPIO_Mode  = GPIO_Mode_AF_PP;
    GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(MODBUS_GPIO_PORT, &GPIO_InitStruct);

    /* PB7 — RX:浮空输入 */
    GPIO_InitStruct.GPIO_Pin  = MODBUS_RX_PIN;    /* GPIO_Pin_7 */
    GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IN_FLOATING;
    GPIO_Init(MODBUS_GPIO_PORT, &GPIO_InitStruct);

    /* USART1:8N1,9600bps */
    USART_InitStruct.USART_BaudRate            = baudrate;
    USART_InitStruct.USART_WordLength          = USART_WordLength_8b;
    USART_InitStruct.USART_StopBits            = USART_StopBits_1;
    USART_InitStruct.USART_Parity             = USART_Parity_No;
    USART_InitStruct.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
    USART_InitStruct.USART_Mode               = USART_Mode_Rx | USART_Mode_Tx;
    USART_Init(MODBUS_USART, &USART_InitStruct);

    /* 使能接收中断 */
    USART_ITConfig(MODBUS_USART, USART_IT_RXNE, ENABLE);
    USART_Cmd(MODBUS_USART, ENABLE);

    /* NVIC:抢占优先级 2,子优先级 0 */
    NVIC_InitStruct.NVIC_IRQChannel                   = MODBUS_IRQn;
    NVIC_InitStruct.NVIC_IRQChannelPreemptionPriority = 2;
    NVIC_InitStruct.NVIC_IRQChannelSubPriority        = 0;
    NVIC_InitStruct.NVIC_IRQChannelCmd                = ENABLE;
    NVIC_Init(&NVIC_InitStruct);
}

中断优先级设计:本项目使用 NVIC_PriorityGroup_2(2位抢占 + 2位子优先级),各中断优先级如下:

中断源 抢占优先级 子优先级 说明
EXTI15_10(过流 + 触发) 1 0 最高,过流必须第一响应
SysTick 1 1 系统滴答,时基
USART1(Modbus) 2 0 通信中断,低于保护中断

四、接收框架:中断 + 3.5 字符超时

/* 接收缓冲区 */
static uint8_t  s_rx_buf[MODBUS_MAX_FRAME];  /* 最大 256 字节 */
static uint16_t s_rx_len    = 0;
static uint8_t  s_rx_ready  = 0;
static uint32_t s_last_rx_tick = 0;

#define MODBUS_T35_MS   4U   /* 3.5字符超时,9600bps 取 4ms */

/* USART1 接收中断:每收到一个字节就存入缓冲区,记录时间戳 */
void MODBUS_UART_IRQHandler(void)
{
    extern uint32_t g_systick_ms;
    if (USART_GetITStatus(MODBUS_USART, USART_IT_RXNE) != RESET) {
        uint8_t data = (uint8_t)USART_ReceiveData(MODBUS_USART);
        if (s_rx_len < MODBUS_MAX_FRAME) {
            s_rx_buf[s_rx_len++] = data;
        }
        s_last_rx_tick = g_systick_ms;  /* 每收到字节就刷新时间戳 */
        s_rx_ready = 0;
    }
}

/* 主循环轮询:检测超时,判断帧结束 */
void MODBUS_Poll(void)
{
    extern uint32_t g_systick_ms;

    if (s_rx_len > 0 && !s_rx_ready) {
        /* 距离最后一个字节超过 4ms,认为帧结束 */
        if ((g_systick_ms - s_last_rx_tick) >= MODBUS_T35_MS) {
            s_rx_ready = 1;
            MODBUS_ProcessFrame();   /* 处理完整帧 */
            s_rx_len   = 0;          /* 清空缓冲区,准备下一帧 */
            s_rx_ready = 0;
        }
    }
}

设计要点

接收采用「中断存数据 + 主循环判超时」的经典架构,而不是在中断里直接处理帧。这样做的好处是帧处理逻辑(switch-case)在主循环中执行,不阻塞中断,过流保护的 EXTI 中断随时可以抢占。

g_systick_ms 是 SysTick 每 1ms 递增的全局计数器,是整个定时系统的时基,触发延时、关断计时、心跳 LED 都依赖它。


五、CRC16 校验实现

static uint16_t CRC16(const uint8_t *buf, uint16_t len)
{
    uint16_t crc = 0xFFFF;
    uint16_t i, j;
    for (i = 0; i < len; i++) {
        crc ^= buf[i];
        for (j = 0; j < 8; j++) {
            if (crc & 0x0001) crc = (crc >> 1) ^ 0xA001;
            else               crc >>= 1;
        }
    }
    return crc;
}

Modbus RTU 使用 CRC-16/ARC 算法,多项式 0x8005,初始值 0xFFFF,输入输出均反转。注意 CRC 在帧中是低字节在前

crc = CRC16(resp, idx);
resp[idx++] = (uint8_t)(crc & 0xFF);  /* 低字节先发 */
resp[idx++] = (uint8_t)(crc >> 8);    /* 高字节后发 */

校验时同样低字节在前:

crc_recv = (uint16_t)s_rx_buf[s_rx_len - 1] << 8 | s_rx_buf[s_rx_len - 2];

⚠️ 常见踩坑:CRC 字节序搞反是调试 Modbus 最常见的问题之一。如果上位机显示 CRC 错误,先检查字节序,再查多项式。


六、完整寄存器地址表

6.1 保持寄存器(FC03 读 / FC06 写)

地址 宏定义 读写 数据范围 说明
0x0000 MB_REG_DIN_STATUS 只读 bit0~bit3 IN1~IN4 输入状态,光耦低有效(0=有信号)
0x0001 MB_REG_DOUT_STATUS 读写 bit0~bit3 OUT1~OUT4 输出状态,写即生效
0x0002 MB_REG_PWM_CH1_DUTY 读写 0~255 CH1 占空比(255=100%)
0x0003 MB_REG_PWM_CH2_DUTY 读写 0~255 CH2 占空比
0x0004 MB_REG_PWM_CH3_DUTY 读写 0~255 CH3 占空比
0x0005 MB_REG_PWM_CH4_DUTY 读写 0~255 CH4 占空比
0x0006 MB_REG_VOLTAGE_MV 只读 电源电压(本版本恒返回 0,预留)
0x0007 MB_REG_PWM_MODE 读写 0 / 1 0=常亮模式,1=外部触发模式
0x0008 MB_REG_SLAVE_ADDR 读写 1~247 从机地址,写后自动保存到 F-RAM
0x0009 MB_REG_FAULT_STATUS 读写 bit0 bit0=过流故障;写任意值清除并恢复 OE#
0x000A MB_REG_TG1_OFF_MS 读写 0~65535 TG1 关断时间(ms),0=触发后永不关断
0x000B MB_REG_TG2_OFF_MS 读写 0~65535 TG2 关断时间(ms)
0x000C MB_REG_TG3_OFF_MS 读写 0~65535 TG3 关断时间(ms)
0x000D MB_REG_TG4_OFF_MS 读写 0~65535 TG4 关断时间(ms)
0x000E MB_REG_TG1_ON_DELAY_MS 读写 0~65535 TG1 触发后开启延时(ms),0=立即开启
0x000F MB_REG_TG2_ON_DELAY_MS 读写 0~65535 TG2 开启延时(ms)
0x0010 MB_REG_TG3_ON_DELAY_MS 读写 0~65535 TG3 开启延时(ms)
0x0011 MB_REG_TG4_ON_DELAY_MS 读写 0~65535 TG4 开启延时(ms)

6.2 线圈(FC05 写单线圈)

地址 宏定义 说明
0x0000 MB_COIL_OUT1 数字输出 OUT1(0xFF00=ON,0x0000=OFF)
0x0001 MB_COIL_OUT2 数字输出 OUT2
0x0002 MB_COIL_OUT3 数字输出 OUT3
0x0003 MB_COIL_OUT4 数字输出 OUT4
0x0004 MB_COIL_PWM_OE PWM 总使能(OE#),写1使能,写0禁止
0x0005 MB_COIL_FAULT_CLR 写1清除过流故障并恢复输出
0x0006 MB_COIL_PWM_CH1 CH1 通道开关(掉电保存)
0x0007 MB_COIL_PWM_CH2 CH2 通道开关(掉电保存)
0x0008 MB_COIL_PWM_CH3 CH3 通道开关(掉电保存)
0x0009 MB_COIL_PWM_CH4 CH4 通道开关(掉电保存)

七、三个功能码的实现详解

7.1 FC03:读保持寄存器

static void HandleReadHoldingRegs(uint16_t start_addr, uint16_t count)
{
    uint8_t  resp[MODBUS_MAX_FRAME];
    uint16_t i, crc, idx = 0;

    /* 构建响应头 */
    resp[idx++] = g_modbus_addr;
    resp[idx++] = MB_FC_READ_HOLDING_REGS;
    resp[idx++] = (uint8_t)(count * 2);  /* 数据字节数 */

    for (i = 0; i < count; i++) {
        uint16_t reg = start_addr + i;
        uint16_t val = 0;
        switch (reg) {
            case MB_REG_DIN_STATUS:   val = DIN_ReadAll();   break;
            case MB_REG_PWM_CH1_DUTY: val = PWM_GetDuty(1);  break;
            case MB_REG_PWM_MODE:     val = g_pwm_mode;      break;
            case MB_REG_FAULT_STATUS: val = g_fault_status;  break;
            case MB_REG_TG1_OFF_MS:   val = g_tg_off_ms[0];  break;
            /* ... 其他寄存器 ... */
            default:
                SendException(MB_FC_READ_HOLDING_REGS, MB_EX_ILLEGAL_ADDRESS);
                return;
        }
        /* 大端序:高字节在前 */
        resp[idx++] = (uint8_t)(val >> 8);
        resp[idx++] = (uint8_t)(val & 0xFF);
    }

    crc = CRC16(resp, idx);
    resp[idx++] = (uint8_t)(crc & 0xFF);
    resp[idx++] = (uint8_t)(crc >> 8);
    MODBUS_Send(resp, idx);
}

FC03 支持连续读多个寄存器,start_addrstart_addr + count - 1,每个寄存器 2 字节大端序。上位机用 Modbus Poll 或 Python pymodbus 可以一次读取整段寄存器区,效率高。


7.2 FC06:写单寄存器(核心业务逻辑)

FC06 是本项目使用最频繁的功能码,几乎所有参数修改都走这里。最值得关注的是占空比写入的双模式处理

case MB_REG_PWM_CH1_DUTY:
    if (g_pwm_mode == PWM_MODE_MODBUS) {
        /* 常亮模式:立即更新 TIM1 CCR,输出实时变化 */
        PWM_SetDuty(1, (uint8_t)(value & 0xFF));
    } else {
        /* 触发模式:只更新参数变量,不碰 CCR */
        /* 当前正在输出的 PWM 波形完全不受影响 */
        g_pwm_duty[0] = (uint8_t)(value & 0xFF);
    }
    FRAM_SaveParams();  /* 两种模式都保存 */
    break;

为什么触发模式下不能直接调用 PWM_SetDuty()

触发模式下,某一通道可能正处于 TG_STATE_ACTIVE(点亮状态),此时 CCR 里是当前输出占空比。如果上位机在点亮期间修改占空比,立即写 CCR 会导致亮度突变,对正在曝光的相机造成影响。

将修改隔离到 g_pwm_duty[] 变量,下次触发时状态机会用新的 g_pwm_duty[] 值写 CCR,当前曝光不受任何影响。


切换工作模式时的处理:

case MB_REG_PWM_MODE:
    g_pwm_mode = (uint8_t)(value & 0x01);
    /* 切换模式时强制关闭所有通道,防止状态混乱 */
    PWM_SetChEnable(1, 0);
    PWM_SetChEnable(2, 0);
    PWM_SetChEnable(3, 0);
    PWM_SetChEnable(4, 0);
    FRAM_SaveParams();
    break;

模式切换时强制关闭所有 PWM 通道,原因是两种模式的通道状态含义完全不同:

  • 常亮模式:通道 enable 直接控制是否输出
  • 触发模式:通道状态由状态机管理(IDLE/PENDING/ACTIVE)

如果不清零,切换后可能出现通道保持旧模式状态的异常输出。


过流故障清除:

case MB_REG_FAULT_STATUS:
    g_fault_status = 0;    /* 清除故障标志 */
    PWM_Enable();          /* 恢复 OE# 使能,重新开始输出 */
    break;

过流发生时,ISR 会拉低 OE# 并置 g_fault_status bit0。上位机确认排除故障后,写 MB_REG_FAULT_STATUS 任意值,固件清标志、恢复 OE#,设备回到正常工作状态。这是一个需要人工确认的恢复机制,而不是自动恢复,防止过流原因未排除时自动重启导致反复损坏。


7.3 FC05:写单线圈

static void HandleWriteSingleCoil(uint16_t addr, uint16_t value)
{
    /* FC05 规定:0xFF00=ON,0x0000=OFF,其他值返回异常 */
    if (value != 0xFF00 && value != 0x0000) {
        SendException(MB_FC_WRITE_SINGLE_COIL, MB_EX_ILLEGAL_VALUE);
        return;
    }
    uint8_t state = (value == 0xFF00) ? 1 : 0;

    switch (addr) {
        case MB_COIL_OUT1: DOUT_Set(1, state); break;
        /* OUT2~OUT4 类似 */

        case MB_COIL_PWM_CH1:
            PWM_SetChEnable(1, state);
            FRAM_SaveParams();   /* 通道开关状态掉电保存 */
            break;

        case MB_COIL_PWM_OE:
            if (state) PWM_Enable(); else PWM_Disable();
            break;

        case MB_COIL_FAULT_CLR:
            if (state) { g_fault_status = 0; PWM_Enable(); }
            break;
    }
    /* 回显:响应帧与请求帧完全相同(除了从机地址) */
}

线圈和保持寄存器的职责划分:

  • 线圈:控制开/关状态(数字输出、通道使能、OE#),适合布尔操作
  • 保持寄存器:控制数值参数(占空比、延时、地址),适合数值读写

这种划分让上位机的控制逻辑更清晰:开关操作用 FC05,参数设置用 FC06,状态读取用 FC03。


八、帧处理主函数

static void MODBUS_ProcessFrame(void)
{
    uint16_t crc_recv, crc_calc, start_addr, count, value;

    /* ① 最短帧校验 */
    if (s_rx_len < 4) return;

    /* ② CRC 校验 */
    crc_recv = (uint16_t)s_rx_buf[s_rx_len - 1] << 8
             | s_rx_buf[s_rx_len - 2];
    crc_calc = CRC16(s_rx_buf, s_rx_len - 2);
    if (crc_recv != crc_calc) return;  /* CRC 错误,静默丢弃,不回复 */

    /* ③ 地址过滤 */
    if (s_rx_buf[0] != g_modbus_addr) return;  /* 非本机地址,静默丢弃 */

    start_addr = ((uint16_t)s_rx_buf[2] << 8) | s_rx_buf[3];

    /* ④ 功能码分发 */
    switch (s_rx_buf[1]) {
        case MB_FC_READ_HOLDING_REGS:   /* 0x03 */
            count = ((uint16_t)s_rx_buf[4] << 8) | s_rx_buf[5];
            HandleReadHoldingRegs(start_addr, count);
            break;
        case MB_FC_WRITE_SINGLE_COIL:   /* 0x05 */
            value = ((uint16_t)s_rx_buf[4] << 8) | s_rx_buf[5];
            HandleWriteSingleCoil(start_addr, value);
            break;
        case MB_FC_WRITE_SINGLE_REG:    /* 0x06 */
            value = ((uint16_t)s_rx_buf[4] << 8) | s_rx_buf[5];
            HandleWriteSingleReg(start_addr, value);
            break;
        case MB_FC_WRITE_MULTI_REGS:    /* 0x10 */
            count = ((uint16_t)s_rx_buf[4] << 8) | s_rx_buf[5];
            HandleWriteMultiRegs(start_addr, count, &s_rx_buf[7]);
            break;
        default:
            SendException(s_rx_buf[1], MB_EX_ILLEGAL_FUNCTION);
            break;
    }
}

三道过滤:长度检查 → CRC 校验 → 地址匹配。CRC 错误和地址不匹配时静默丢弃,不发任何响应,符合 Modbus 协议规范(CRC 错误说明帧损坏,不应响应)。


九、异常响应机制

static void SendException(uint8_t fc, uint8_t ex_code)
{
    uint8_t resp[5];
    uint16_t crc;
    resp[0] = g_modbus_addr;
    resp[1] = fc | 0x80;   /* 功能码最高位置1,表示异常 */
    resp[2] = ex_code;
    crc = CRC16(resp, 3);
    resp[3] = (uint8_t)(crc & 0xFF);
    resp[4] = (uint8_t)(crc >> 8);
    MODBUS_Send(resp, 5);
}

本项目使用的异常码:

异常码 宏定义 含义 触发场景
0x01 MB_EX_ILLEGAL_FUNCTION 非法功能码 收到未实现的功能码
0x02 MB_EX_ILLEGAL_ADDRESS 非法地址 访问未定义的寄存器地址
0x03 MB_EX_ILLEGAL_VALUE 非法数据值 FC05 值不是 0xFF00/0x0000;从机地址超范围

十、主循环集成

/* main.c */
while (1)
{
    MODBUS_Poll();          /* Modbus 帧超时检测与处理 */
    GPIO_IO_Poll();         /* 触发状态机推进(TG1~TG4 延时计时) */

    /* 心跳 LED:500ms 翻转 */
    if (g_systick_ms - last_hb_tick >= 500) {
        last_hb_tick = g_systick_ms;
        HB_LED_TOGGLE();
    }
}

主循环三件事:Modbus 轮询、触发状态机推进、心跳 LED。三者都是非阻塞的轮询,不使用任何 delay(),保证 Modbus 响应的实时性。


总结

本篇完整介绍了 Modbus RTU over TCP 的固件实现,核心要点:

  1. RTU over TCP vs 纯 Modbus TCP:前者完全兼容串口 RTU 设备,实现成本低,本项目选择前者
  2. 3.5 字符超时帧判断:中断存数据 + 主循环检超时,帧处理不阻塞中断,保护优先
  3. 完整寄存器地址表:18个保持寄存器 + 10个线圈,覆盖所有控制和状态读取
  4. 占空比双模式隔离:触发模式下写占空比只改变量不改 CCR,当前曝光不受影响
  5. 过流手动恢复机制:需要人工确认排除故障后才能恢复输出,防止反复损坏

下一篇将介绍双模式 PWM 光源控制的固件实现:TIM1 初始化、触发状态机(PENDING → ACTIVE → IDLE)的完整设计,以及四通道独立运行的实现细节。


如果本文对你有帮助,欢迎点赞收藏,系列持续更新中 🚀

标签#STM32 #嵌入式硬件 #Modbus #单片机 #工业控制

Logo

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

更多推荐