(RK3588S + STM32F103C8T6 工业光源控制器·第三篇)
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 发出去。这样做的好处是:
- STM32 固件不感知网络层,只处理标准 RTU 帧,代码简洁
- 工厂里已有的串口 RTU 设备,通过串口转以太网模块接入时,完全不需要修改协议
- 上位机(运行在 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_addr 到 start_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 的固件实现,核心要点:
- RTU over TCP vs 纯 Modbus TCP:前者完全兼容串口 RTU 设备,实现成本低,本项目选择前者
- 3.5 字符超时帧判断:中断存数据 + 主循环检超时,帧处理不阻塞中断,保护优先
- 完整寄存器地址表:18个保持寄存器 + 10个线圈,覆盖所有控制和状态读取
- 占空比双模式隔离:触发模式下写占空比只改变量不改 CCR,当前曝光不受影响
- 过流手动恢复机制:需要人工确认排除故障后才能恢复输出,防止反复损坏
下一篇将介绍双模式 PWM 光源控制的固件实现:TIM1 初始化、触发状态机(PENDING → ACTIVE → IDLE)的完整设计,以及四通道独立运行的实现细节。
如果本文对你有帮助,欢迎点赞收藏,系列持续更新中 🚀
标签:
#STM32#嵌入式硬件#Modbus#单片机#工业控制
更多推荐



所有评论(0)