ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32 Modbus RTU从站:DMA空闲中断与RS485方向控制

STM32 Modbus RTU从站:DMA空闲中断与RS485方向控制

简介:面向嵌入式开发者的STM32F103ZET6 RS485通信实战工程,围绕DMA数据传输与Modbus协议,给出主机读取多个从机寄存器、向指定寄存器写入数据的完整实现思路,适合已掌握C语言和STM32基础、希望进阶工业总线通信的读者参考。压缩包共171个文件,约3.32MB,以38个.h头文件和37个.c源文件为核心,配套Keil工程文件、启动文件、链接脚本、hex输出及map映射等编译产物,便于直接打开、编译与对照分析。已有8318人学习下载。工程涵盖RS485方向控制、串口与DMA初始化、0x03/0x06功能码报文解析构建、DMA自动收发、超时重试与通信异常恢复等模块,可作为工业自动化、物联网设备主从通信项目的代码骨架和排错参考。

1. 从一块偶发超时的从站板说起

去年调一块采集板,主站轮询 20 个从站,示波器上每一帧都完整,主站却隔三差五报超时——最后发现从站在最后一个字节的停止位还没发完就把 RS485 方向脚拉低了。这类毛病在 STM32 + DMA + Modbus 的组合里特别常见:协议本身不复杂,难的是把「什么时候算收完一帧」「什么时候能切方向」这两件事做扎实。这份工程就围绕这两点搭了一套 Modbus RTU 从站:串口用 DMA 搬数据,空闲中断切帧,方向脚由 GPIO 控制,保持寄存器与输入寄存器分开映射,支持 03/04/06/10 四个功能码,CRC16 查表校验。适合做传感器采集、仪表对接、PLC 联调又不想背整套协议栈的人,代码量小,每一层都能看穿。

2. 硬件与工程骨架:方向脚、DMA 通道和目录分工

拿到一份 485 从站代码,别急着看协议解析,先看两处:方向脚怎么控,DMA 通道怎么分。这两处定了,后面所有问题的形态基本就定了。我见过太多工程在协议层写得漂漂亮亮,结果卡在「发完最后一字节忘了切回接收」这种一行代码上,调两天。

2.1 收发器方向控制:一个 GPIO 决定整条总线的死活

RS485 是半双工,同一对差分线,同一时刻只能有一个说话的人。常见的收发器把 DE(驱动器使能)和 RE(接收器使能)做成互补,实际接线时把这两个脚并在一起接一个 GPIO:拉高进发送态,拉低回接收态。网上不少例程把 RE 直接接地、只控 DE,这样本机在发送时接收器仍然开着,会把自己发出去的数据原样收回来,如果你恰好开了回环或者用中断收,就会自己跟自己打架。

先把这个最底层的开关封装掉,后面所有代码都只调它:

/* rs485.h —— 方向脚封装,DE/RE 并联接在 PA8 */ #define RS485_DE_PORT GPIOA #define RS485_DE_PIN GPIO_Pin_8 static inline void rs485_tx_enable(void) { GPIO_SetBits(RS485_DE_PORT, RS485_DE_PIN); /* 驱动器上电,占住总线 */ } static inline void rs485_rx_enable(void) { GPIO_ResetBits(RS485_DE_PORT, RS485_DE_PIN); /* 释放总线,回到高阻接收 */ } /* 上电第一件事:把方向脚定死在接收态,再干别的 */ void rs485_gpio_init(void) { GPIO_InitTypeDef gpio; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_ResetBits(RS485_DE_PORT, RS485_DE_PIN); /* 先写电平,再配模式 */ gpio.GPIO_Pin = RS485_DE_PIN; gpio.GPIO_Mode = GPIO_Mode_Out_PP; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(RS485_DE_PORT, &gpio); }

这里有一个顺序上的讲究:GPIO_ResetBits要写在GPIO_Init之前。GPIO 复位后默认是浮空输入,如果你先配成推挽输出再写电平,中间那几十个时钟周期里引脚电平是不确定的,收发器可能瞬间把总线拉低。总线上挂十几个从站时,这一个毛刺足够让别的从站收到一串 0x00 起始的垃圾帧。血泪经验是:板子上再给 DE 加一个 10k 下拉电阻,软件顺序错了也还有硬件兜底。

另一个参数是方向切换的时机,这个不在 GPIO 层解决,放到 3.3 和 4.3 讲——简单说就是「发送前置高要早于第一个字节写进 DR,发送后拉低要晚于 TC 置位」,早了晚了都是坑。

2.2 工程目录:从站代码该分成哪几层

从站代码最容易写成一坨:串口初始化、CRC、功能码、业务逻辑全塞在main.c里,改一个寄存器地址要翻 800 行。我一般按「驱动 / 协议 / 应用」三层拆,协议层不认识任何具体业务,应用层只跟寄存器数组打交道:

modbus_slave/ ├── app/ │ ├── main.c # 主循环:初始化 + 轮询队列 + 刷输入寄存器 │ ├── app_regmap.c # 业务寄存器定义、写寄存器钩子 │ └── app_regmap.h ├── bsp/ │ ├── bsp_rs485.c # 方向脚、收发器时序 │ ├── bsp_rs485.h │ ├── bsp_uart_dma.c # USART + DMA + IDLE 初始化 │ └── bsp_uart_dma.h ├── modbus/ │ ├── modbus_slave.c # 帧解析、功能码分发、异常响应 │ ├── modbus_slave.h │ ├── modbus_crc.c # CRC16 查表 │ └── modbus_reg.c # 保持/输入寄存器区与越界检查 └── third_party/ └── ringbuf.c # 无锁环形队列,中断塞主循环取

modbus/这层是可以整包复制到下一个项目里的,只要它对外只暴露三个接口:初始化时传进从站地址和波特率、注册寄存器区首地址与长度、主循环里调一次轮询函数。app/那层负责真正跟硬件打交道,比如读到温度传感器的值往input_reg[]里写,或者收到写保持寄存器的请求后立刻改 PWM 占空比。这条分界线划清楚了,后面加功能码、加寄存器都只是往表里添一行的事。

2.3 资源映射:DMA 通道和中断优先级怎么排

STM32F1 系列的 DMA1 通道和外设是硬绑定的,不能随便挑,选错了现象是「DMA 初始化不报错,但一个字节都收不到」,特别玄学。常用的几组映射我列在下面,画板子之前先对一遍:

外设请求数据方向DMA 控制器 / 通道典型用法
USART1_RX外设 → 内存DMA1 通道 5485 接收,循环模式
USART1_TX内存 → 外设DMA1 通道 4485 发送,普通模式,可选
USART2_RX外设 → 内存DMA1 通道 6第二路 485 或调试口
USART3_RX外设 → 内存DMA1 通道 3第三路
TIM2_UP事件DMA1 通道 2定时采样搬运,可选

中断优先级按「谁丢了数据谁优先」来排:USART 的 IDLE 中断排最高(抢占优先级 1),因为它是帧边界信号,被拖晚了 DMA 可能已经把下一帧的开头覆盖上来;定时器采样中断排次一级(2);主循环里那些按键、LED 之类的东西压根不开中断,扔轮询里。抢占优先级相同的情况下,中断服务函数里别做浮点运算和memcpy大块数据,那些活留给主循环。

还有一处容易漏:开了 DMA 接收之后,USART 的 RXNE 中断就别再开了。两个都开,DMA 在搬数据的同时 RXNE 也在置位,中断频率高得离谱,CPU 全耗在进出中断上;更糟的是你在 RXNE 里读一次 DR,等于从 DMA 手里抢走一个字节,接收缓冲区从此对不上号。这在调试时表现为「短帧正常,长帧丢中间几个字节」,非常难查。

3. 串口空闲中断 + DMA 收包:一帧 Modbus RTU 是怎么被接住的

Modbus RTU 用帧间隔分帧,没有帧头帧尾,两帧之间至少静默 3.5 个字符时间。传统做法是开一个定时器,每收一个字节重置计数,超时就认为一帧结束——能用,但字节一多中断就密集。用 DMA 收 + IDLE 中断判帧是更省 CPU 的路子:DMA 自己把字节搬到内存,CPU 只在总线静下来那一刻被打断一次。

3.1 串口与 DMA 初始化:一次配对,后面都靠它

初始化的顺序是有讲究的,我习惯按「时钟 → 引脚 → 方向脚 → 串口参数 → DMA → 中断」这个顺序走,每一步都能单独验证。下面这段是 USART1 收、DMA1 通道 5 搬、循环模式的完整初始化:

/* bsp_uart_dma.c */ #define MODBUS_RX_BUF_SIZE 256 /* 要大于协议允许的最长帧,03 功能码最长约 256 */ uint8_t modbus_rx_buf[MODBUS_RX_BUF_SIZE]; void modbus_uart_init(uint32_t baud) { GPIO_InitTypeDef gpio; USART_InitTypeDef uart; DMA_InitTypeDef dma; NVIC_InitTypeDef nvic; /* 1. 时钟:USART1 在 APB2,DMA1 在 AHB */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); /* 2. 引脚:PA9 复用推挽输出,PA10 浮空输入 */ gpio.GPIO_Pin = GPIO_Pin_9; gpio.GPIO_Mode = GPIO_Mode_AF_PP; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &gpio); gpio.GPIO_Pin = GPIO_Pin_10; gpio.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &gpio); /* 3. 方向脚先落地成接收态 */ rs485_gpio_init(); /* 4. 串口:8 数据位、无校验、1 停止位,收发都开 */ uart.USART_BaudRate = baud; uart.USART_WordLength = USART_WordLength_8b; uart.USART_StopBits = USART_StopBits_1; uart.USART_Parity = USART_Parity_No; uart.USART_HardwareFlowControl = USART_HardwareFlowControl_None; uart.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, &uart); /* 5. DMA1 通道 5 = USART1_RX,循环模式,一次装满整个缓冲区 */ DMA_DeInit(DMA1_Channel5); dma.DMA_PeripheralBaseAddr = (uint32_t)&USART1->DR; /* 外设端固定是数据寄存器 */ dma.DMA_MemoryBaseAddr = (uint32_t)modbus_rx_buf; /* 内存端是接收缓冲区首地址 */ dma.DMA_DIR = DMA_DIR_PeripheralSRC; /* 外设是源,内存是目的 */ dma.DMA_BufferSize = MODBUS_RX_BUF_SIZE; dma.DMA_PeripheralInc = DMA_PeripheralInc_Disable; /* 外设地址不自增 */ dma.DMA_MemoryInc = DMA_MemoryInc_Enable; /* 内存地址自增 */ dma.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; dma.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; dma.DMA_Mode = DMA_Mode_Circular; /* 循环模式,省掉重装 */ dma.DMA_Priority = DMA_Priority_High; dma.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel5, &dma); DMA_Cmd(DMA1_Channel5, ENABLE); /* 6. 打开串口的 DMA 接收请求,再开空闲中断 */ USART_DMACmd(USART1, USART_DMAReq_Rx, ENABLE); USART_ITConfig(USART1, USART_IT_IDLE, ENABLE); USART_Cmd(USART1, ENABLE); /* 7. NVIC */ nvic.NVIC_IRQChannel = USART1_IRQn; nvic.NVIC_IRQChannelPreemptionPriority = 1; nvic.NVIC_IRQChannelSubPriority = 0; nvic.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&nvic); }

逐段说下关键参数。DMA_PeripheralBaseAddr取的是&USART1->DR的地址,不是&USART1->SR,取错了 DMA 会去搬状态寄存器的值,每搬一次就把错误标志读一遍,看起来「有数据」但全是 0 或 0x20 这类鬼东西。DMA_DIR_PeripheralSRC的意思是数据从外设流向内存,写成MemorySRC就是反的,接收会一个字节都没有。DMA_Mode_Circular是这套方案的核心——环形模式下 DMA 搬满 256 字节后自动回卷,不需要在中断里重新初始化,代价是你得自己算「这一帧从哪开始、有多长」,3.2 会讲。

MODBUS_RX_BUF_SIZE取 256 是个折中:03/04 功能码一次最多读 125 个寄存器,响应帧 255 字节左右,留 256 刚好够。如果你只读几个寄存器,可以缩到 64,省 RAM 也能提高缓存命中;但别缩到比请求帧还小,否则主站一次读 100 个寄存器,请求帧本身就把缓冲区写穿了。

3.2 空闲中断切帧:怎么知道这一帧收完了

IDLE 中断的触发条件是「接收线路上出现一个字符时间的静默」。STM32 的清 IDLE 标志有个固定动作:先读 SR 再读 DR,少读一次都清不掉,中断会一直重入。这个动作顺序写反了是新手最常翻的车之一。

/* 每帧解析完丢进环形队列,主循环慢慢处理 */ typedef struct { uint8_t *data; uint16_t len; } modbus_frame_t; extern ringbuf_t modbus_rx_queue; void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_IDLE) != RESET) { /* 清 IDLE:读 SR 再读 DR,顺序不能反 */ volatile uint16_t tmp = USART1->SR; tmp = USART1->DR; (void)tmp; /* 循环模式下 CNDTR 是剩余计数,总量减剩余就是本帧字节数 */ uint16_t remain = DMA_GetCurrDataCounter(DMA1_Channel5); uint16_t len = MODBUS_RX_BUF_SIZE - remain; if (len >= MODBUS_MIN_FRAME && len <= MODBUS_RX_BUF_SIZE) { modbus_frame_t frame = { modbus_rx_buf, len }; ringbuf_push(&modbus_rx_queue, &frame); /* 只搬运,不解析 */ } /* 关键:disable → 重设计数 → enable,让内存指针回到缓冲区首地址 */ DMA_Cmd(DMA1_Channel5, DISABLE); DMA_SetCurrDataCounter(DMA1_Channel5, MODBUS_RX_BUF_SIZE); DMA_Cmd(DMA1_Channel5, ENABLE); } }

DMA_GetCurrDataCounter返回的是「还能搬多少个字节」,所以帧长 = 缓冲区大小 − 剩余计数。MODBUS_MIN_FRAME一般取 4(地址 + 功能码 + CRC 两字节),低于这个长度说明是干扰毛刺,直接扔掉,别送进协议层。

后半段那三行是最容易省略、也最容易出事的地方。循环模式下 DMA 搬完 256 字节才自动回卷,可一帧往往只有 8~20 字节,内存指针停在缓冲区中间。如果只重设CNDTR而不先DMA_Cmd(DISABLE),内存地址寄存器不会重新加载,下一帧会从偏移处接着写——现象是「第一帧正常,从第二帧开始数据整体错位,CRC 全错」,而且错得很有规律,特别像协议解析写错了,实际跟协议一点关系没有。

至于为什么中断里只做入队不做解析:解析一帧要算 CRC、查表、翻寄存器、组响应,几十微秒起步;主站轮询 20 个从站时帧间隔可能只有几毫秒,中断里干重活很容易错过下一帧的 IDLE。用环形队列隔开,中断的活儿控制在几微秒,剩下的交给主循环,这是这套架构能扛住高频轮询的原因。

3.3 帧间隔 T3.5 与主循环消费

IDLE 中断判的是「一个字符时间的静默」,而 Modbus RTU 规定帧间隔至少 3.5 个字符时间。这两个值不相等,所以 IDLE 只能算「疑似帧尾」,真正的合法性还得在协议层补一刀:给每帧打一个时间戳,两帧之间间隔不足 3.5 字符时间的,视为一帧被拆开或者总线抖动,该合并合并、该丢弃丢弃。

用 SysTick 做一个毫秒计数器就够,不必上高精度定时器:

/* 波特率 9600 时,一个字符(11 bit)约 1.15ms,3.5 字符约 4ms */ #define MODBUS_T35_MS(baud) ((3500U * 11U) / ((baud) / 100U) / 100U + 1U) void modbus_poll(void) { modbus_frame_t frame; static uint32_t last_tick = 0; while (ringbuf_pop(&modbus_rx_queue, &frame)) { uint32_t now = sys_tick_get(); if (last_tick != 0 && (now - last_tick) < MODBUS_T35_MS(g_modbus_baud)) { last_tick = now; continue; /* 间隔太短,判为残帧丢弃 */ } last_tick = now; modbus_process_frame(frame.data, frame.len); } }

MODBUS_T35_MS这个宏算的是 3.5 个字符时间对应的毫秒数,加 1 是为了向上取整——波特率 115200 时 3.5 字符只有 0.33ms,整数毫秒算不出来,这时候就别纠结 T3.5 了,直接用 IDLE 中断的判定即可(高速率下 IDLE 的粒度已经比 T3.5 粗)。反过来,9600 波特率下 T3.5 约 4ms,如果你的主循环里有耗时超过 4ms 的任务(比如 Flash 擦写、LCD 刷屏),必须把它挪到通信之外的时间片,否则modbus_poll被拖延,队列积压,看起来就像「从站反应慢」。

主循环里的结构大致是这样:先modbus_poll(),再刷输入寄存器(读传感器、算工程量),最后处理写寄存器的副作用。刷新输入寄存器建议按固定周期做(比如 100ms 一次),别每轮循环都读 I2C 传感器,那会把主循环周期拖到几毫秒以上,反过来影响通信。

4. Modbus RTU 协议栈:寄存器映射、功能码分发与 CRC

协议层要做的事其实就三件:校验帧、按功能码分发、组响应。写得好不好,区别在于「加一个新功能码要改几处」。我的标准是改一处——往分发表里加一行,再写一个处理函数,主流程一行不动。

4.1 功能码分发表:新增功能码不该动主流程

先定义状态码和处理函数签名。处理函数只负责 PDU 部分的解析和填充,地址、CRC、异常响应的封装全部由外层统一收口。这样每个处理函数都短得能一眼看完,也不会有人忘记加 CRC。

/* modbus_slave.h */ typedef enum { MODBUS_OK = 0, MODBUS_ERR_CRC = 1, /* 帧校验失败,静默丢弃,不回包 */ MODBUS_ERR_ADDR = 2, /* 不是本机地址,静默丢弃 */ MODBUS_ERR_FC = 3, /* 不支持的功能码,回异常码 01 */ MODBUS_ERR_PARAM = 4, /* 地址或数量越界,回异常码 02/03 */ } modbus_status_t; /* pdu 指向功能码之后的数据,resp 用于填响应 PDU */ typedef modbus_status_t (*modbus_handler_t)(const uint8_t *pdu, uint16_t pdu_len, uint8_t *resp, uint16_t *resp_len); typedef struct { uint8_t fc; modbus_handler_t handler; } modbus_entry_t; static const modbus_entry_t modbus_table[] = { { 0x03, handle_read_holding }, /* 读保持寄存器 */ { 0x04, handle_read_input }, /* 读输入寄存器 */ { 0x06, handle_write_single }, /* 写单个保持寄存器 */ { 0x10, handle_write_multiple }, /* 写多个保持寄存器 */ }; #define MODBUS_TABLE_SIZE (sizeof(modbus_table) / sizeof(modbus_table[0]))

fc用裸的十六进制而不是枚举,是为了跟协议文档对得上——调试时抓包看到0x10,直接能在表里搜到,不用在枚举名和数值之间来回换算。handler的四个参数里,pdu和pdu_len是输入,resp和resp_len是输出,这种「输入输出分离」的签名比让处理函数直接操作全局缓冲区好测得多,单元测试里传两个数组就能跑。

分发逻辑放在modbus_process_frame里,顺序是:查地址 → 查 CRC → 查功能码 → 调处理函数 → 组响应。注意 CRC 校验要在功能码分发之前,CRC 错的帧要静默丢弃而不是回异常响应——总线上有个干扰毛刺,你回一个异常帧,主站会认为从站有问题,反而把问题放大。

void modbus_process_frame(uint8_t *buf, uint16_t len) { if (len < 4) return; /* 地址+功能码+CRC */ if (buf[0] != g_slave_addr && buf[0] != 0xFF) return; /* 0xFF 是广播 */ uint16_t crc_calc = modbus_crc16(buf, len - 2); uint16_t crc_recv = (uint16_t)buf[len - 1] << 8 | buf[len - 2]; if (crc_calc != crc_recv) return; /* CRC 错,静默丢弃 */ uint8_t fc = buf[1]; uint8_t resp[MODBUS_MAX_PDU]; uint16_t resp_len = 0; modbus_status_t st = MODBUS_ERR_FC; for (uint8_t i = 0; i < MODBUS_TABLE_SIZE; i++) { if (modbus_table[i].fc == fc) { st = modbus_table[i].handler(&buf[2], len - 4, resp, &resp_len); break; } } if (st == MODBUS_OK) modbus_send_response(g_slave_addr, fc, resp, resp_len); else if (buf[0] != 0xFF) /* 广播不回异常帧 */ modbus_send_exception(g_slave_addr, fc, (uint8_t)(st == MODBUS_ERR_FC ? 0x01 : 0x02)); }

这里有两个细节。一是 CRC 的比较方式:接收到的 CRC 低字节在前、高字节在后,跟寄存器数据的大端序正好相反,(uint16_t)buf[len-1] << 8 | buf[len-2]这个写法别改成buf[len-2] << 8 | buf[len-1],改了之后的现象是「所有帧 CRC 都错」,你会怀疑人生。二是广播地址 0xFF:广播帧不回应答也不回异常,但 06/10 这类写操作仍然要执行——不少实现图省事,buf[0] != g_slave_addr直接 return,广播就失效了。

4.2 寄存器映射:保持寄存器和输入寄存器分开管

Modbus 的四类寄存器里,实际项目最常用的是保持寄存器(03 读、06/10 写)和输入寄存器(04 读)。前者是可读可写的配置参数、控制字,后者是只读的测量值。分成两个数组,比塞进一个数组再用地址段区分清晰得多,也顺手把「输入寄存器被写」这种非法操作挡在类型层面。

/* modbus_reg.c */ #define HOLDING_REG_NUM 64 #define INPUT_REG_NUM 32 static uint16_t holding_reg[HOLDING_REG_NUM]; /* 03 / 06 / 10 */ static uint16_t input_reg[INPUT_REG_NUM]; /* 04 只读 */ /* 弱函数:应用层重写它,寄存器一被写就立刻响应,不用等主循环下一轮 */ __weak void on_holding_reg_written(uint16_t addr, uint16_t value) { (void)addr; (void)value; } uint16_t *modbus_input_reg_base(void) { return input_reg; } uint16_t *modbus_holding_reg_base(void) { return holding_reg; } /* 主循环里周期调用:把传感器值刷进输入寄存器 */ void app_refresh_input_reg(void) { input_reg[0] = (uint16_t)(sensor_read_temp() * 10); /* 温度 ×10,单位 0.1℃ */ input_reg[1] = (uint16_t)sensor_read_humidity(); input_reg[2] = g_run_state; /* 运行状态字 */ input_reg[3] = (uint16_t)(sys_tick_get() & 0xFFFF); /* 心跳,主站可判从站是否活着 */ }

__weak那个钩子函数是这套结构里最值钱的一行。写保持寄存器的典型场景是主站下发控制命令,比如「启动」「改目标温度」「切手动模式」,如果等主循环下一轮扫描到寄存器变化再动作,中间可能隔着几十毫秒;用钩子函数,handle_write_single写完立刻调一次,响应就是即时的。默认实现是空函数,不重写也不会报链接错误。

至于越界检查,放在每个处理函数里做,不要指望在分发表外层统一拦——因为 03 和 06 的边界条件不一样(03 一次最多 125 个,06 只能写一个),统一拦反而写不干净。

static modbus_status_t handle_read_holding(const uint8_t *pdu, uint16_t pdu_len, uint8_t *resp, uint16_t *resp_len) { if (pdu_len < 4) return MODBUS_ERR_PARAM; uint16_t start = ((uint16_t)pdu[0] << 8) | pdu[1]; /* 起始地址,大端 */ uint16_t count = ((uint16_t)pdu[2] << 8) | pdu[3]; /* 寄存器数量,大端 */ if (count == 0 || count > 125) return MODBUS_ERR_PARAM; if ((uint32_t)start + count > HOLDING_REG_NUM) return MODBUS_ERR_PARAM; resp[0] = (uint8_t)(count * 2); /* 后续字节数 */ for (uint16_t i = 0; i < count; i++) { resp[1 + i * 2] = (uint8_t)(holding_reg[start + i] >> 8); /* 高字节先发 */ resp[2 + i * 2] = (uint8_t)(holding_reg[start + i] & 0xFF); } *resp_len = 1 + count * 2; return MODBUS_OK; }

(uint32_t)start + count这里强制转 32 位不是多此一举:start最大 65535,count最大 125,两个 uint16 相加会溢出回绕,0xFFFF + 2变成 1,越界检查直接失效,然后循环里holding_reg[0xFFFF]读飞。这类漏洞在实验室里永远测不出来,到了现场主站发一帧异常请求就 HardFault 重启。

4.3 CRC16 与响应组帧:字节序错一次,调一天

CRC16 用查表法,但 256 项的常量表贴进代码里难看又容易贴错。我一般用运行时生成:256×8 次循环,在 72MHz 上不到 1 毫秒,main开头调一次就行,省了手抄表的风险。

/* modbus_crc.c */ static uint16_t s_crc_table[256]; void modbus_crc16_init(void) { for (uint16_t i = 0; i < 256; i++) { uint16_t crc = i; for (uint8_t bit = 0; bit < 8; bit++) { /* Modbus 用的是反向多项式 0xA001,不是 CRC-16/CCITT 的 0x1021 */ crc = (crc & 0x0001) ? ((crc >> 1) ^ 0xA001) : (crc >> 1); } s_crc_table[i] = crc; } } uint16_t modbus_crc16(const uint8_t *buf, uint16_t len) { uint16_t crc = 0xFFFF; /* 初值固定 0xFFFF,不是 0x0000 */ while (len--) crc = (crc >> 8) ^ s_crc_table[(crc ^ *buf++) & 0xFF]; return crc; /* 不取反,直接返回 */ }

三个参数错了任何一个,CRC 就全错:多项式必须是0xA001(0x8005的反转形式),初值必须是0xFFFF,最后不要取反。这三条跟常见的 CRC-16/CCITT、CRC-16/XMODEM 都不一样,网上抄来的 CRC 代码十有八九是 CCITT 版本,表现是「自己发的自己收能对上,跟主站就是通不了」。

组帧函数把地址、PDU、CRC 拼起来一次性发出去,注意发送完等的是 TC 标志:

void modbus_send_response(uint8_t addr, uint8_t fc, const uint8_t *pdu, uint16_t pdu_len) { uint8_t frame[MODBUS_MAX_FRAME]; uint16_t n = 0; frame[n++] = addr; frame[n++] = fc; if (pdu && pdu_len) { memcpy(&frame[n], pdu, pdu_len); n += pdu_len; } uint16_t crc = modbus_crc16(frame, n); frame[n++] = (uint8_t)(crc & 0xFF); /* CRC 低字节在前,和寄存器序相反 */ frame[n++] = (uint8_t)(crc >> 8); rs485_tx_enable(); for (uint16_t i = 0; i < n; i++) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); USART_SendData(USART1, frame[i]); } /* 必须等 TC:TXE 只说明数据进了移位寄存器,最后一个字节还没移出去 */ while (USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET); rs485_rx_enable(); }

USART_FLAG_TXE和USART_FLAG_TC的区别是这套代码里最容易翻车的一处。TXE 置位表示「发送数据寄存器空了,可以写下一个字节」,此时最后一个字节可能还躺在移位寄存器里往外移;这时候拉低方向脚,收发器立刻进高阻,最后一个字节的停止位直接被截断。主站那边的表现是「数据都对,就是 CRC 校验不过」,因为 CRC 低字节恰好是倒数第二个字节,往往是好的,最后那个字节的高位被吃了。

如果响应帧比较长,可以把发送也交给 DMA(通道 4),CPU 只等传输完成中断,在中断里拉低方向脚。这一点在 6 章会提,属于二次开发的可选项。

5. 排查:从站不回包、CRC 报错、DMA 卡死的五个现场

这套代码在现场能遇到的坑,八成集中在五个地方。我把现象、原因、解决按我实际处理的顺序写下来,照着对一遍基本能定位。

5.1 收发时序上的三个现场

现象一:主站报超时,示波器上看从站确实发了,但最后一个字节的电平宽度比正常短一截。原因是方向脚拉低用的是 TXE 标志,最后一个字节还没移完就切了高阻。解决就是 4.3 里写的,改成等 TC;再稳一点的做法是等 TC 之后加一两个微秒的空操作延时,让收发器彻底释放。这个坑我在两个项目里各踩过一次,第二次是因为换了个响应更快的收发器,原本能跑的时序又不满足了。

现象二:从站的应答和主站的下一帧在总线上撞车,两边都报错。原因是主站还在发、从站就开始回。Modbus 规定从站必须收完一整帧、校验通过才能应答,而 IDLE 中断到modbus_process_frame之间还有队列和时间戳判断,理论上不会抢跑。真正出问题的是 T3.5 判断写得太松:帧间隔阈值按 9600 波特率算死了 4ms,现场改成 19200 之后阈值还是 4ms,主站两帧之间的空隙被误判成一帧,从站把两帧拼起来解析,解析失败后回了异常帧,正好撞上主站的新请求。解决是把阈值跟着波特率算,或者直接按上一章的宏动态算。

现象三:一上电整条总线被拉死,所有从站都通信不上,断电重启又好了。原因是方向脚在 GPIO 初始化为输出之前处于浮空态,收发器的 DE 被外部干扰拉高,把总线占住了;或者是软件初始化顺序里先开了 USART 再配方向脚,串口一使能就往外发了个 0x00。解决是rs485_gpio_init里先写电平再配模式,硬件上再加 10k 下拉。这个问题的隐蔽之处在于它不是每次都出现,取决于上电时电容充电的快慢,实验室十次里可能只出一次。

5.2 DMA 与缓冲区上的两个现场

现象四:第一帧正常,从第二帧开始数据整体错位,CRC 全错。原因是循环 DMA 里只重设了CNDTR,没有先DMA_Cmd(DISABLE),内存地址寄存器没回卷,第二帧从上一帧结束的位置接着写。解决就是 3.2 里那三行:disable → set counter → enable。判断这个问题有个小技巧:把接收缓冲区整个打印出来,如果错位量正好等于上一帧的长度,基本可以确定是这个问题,跟协议解析一点关系没有。

现象五:长帧被拆成两段,或者干脆丢一半。两种情况。一是MODBUS_RX_BUF_SIZE小于主站请求的最长帧,DMA 写满 256 字节自动回卷,把开头的字节覆盖了,表现出来就是「读 100 个寄存器时好时坏」;二是中断里做了耗时操作(比如在 IDLE 里直接算 CRC),下一帧的 IDLE 来了还没处理完,队列满了丢帧。前者的解决是把缓冲区按最大可能帧长(255 左右)留足,后者的解决是把解析挪到主循环,中断里只入队——这也是第 3 章那套结构的意义。

6. 二次开发:把它改成你自己的从站设备

这套代码真正能省时间的地方,是改造成本很低。举个具体场景:你手上有个设备,要暴露 8 个测量值、4 个控制参数给 PLC,PLC 那边固定用 03/06 功能码轮询。改动量是这样的:

改造点改动位置大致工作量
改从站地址与波特率main.c里的两个宏2 行
定义测量值到输入寄存器app_regmap.c的app_refresh_input_reg一个函数
控制参数落到硬件重写on_holding_reg_written一个 switch
加 0x01 线圈功能码modbus_table[]加一行 + 一个 handler约 40 行
发送改 DMAmodbus_send_response换实现约 30 行

最值得动手的是on_holding_reg_written那个钩子。假设保持寄存器 0 是「目标温度 ×10」,重写之后主站一写就立刻更新 PWM 比较值,不用等主循环扫描:

void on_holding_reg_written(uint16_t addr, uint16_t value) { switch (addr) { case 0: /* 目标温度,单位 0.1℃,限幅到 0~1200 也就是 0~120℃ */ if (value > 1200) value = 1200; pwm_set_duty(TEMP_PWM_CH, temp_to_duty(value)); break; case 1: /* 运行模式:0 停止,1 自动,2 手动 */ g_run_mode = (value <= 2) ? value : 0; break; default: break; } }

注意这里的限幅是在钩子里做的,不是在handle_write_single里。原因是从站不只有一种写入口——主站可能用 06 写单个,也可能用 10 连续写一批,限幅逻辑写在协议层就得写两遍,写在钩子里只写一遍,两条路径自动都有。

验证环节我一般分三步走。第一步用电脑上的 Modbus 调试工具单发一帧 03 请求,看响应字节数和 CRC 对不对,这一步能过滤掉 80% 的组帧问题。第二步用脚本连续轮询 10000 次,统计超时率和 CRC 错误率,跑之前把串口助手关掉——两个程序抢一个串口,报的错全是假的。第三步接 PLC 或主站真机跑满 24 小时,观察有没有累积性的错误(比如计数器慢慢偏移,那多半是缓冲区错位没解决干净)。

发送改 DMA 是我在高速率场景下必做的一步。modbus_send_response里那个逐字节while(TXE)的循环,在 115200 波特率下发 200 字节要 17ms,这段时间 CPU 全被占着,如果同时还有别的高优先级任务就会卡。改成 DMA 发送后,CPU 只在传输完成中断里拉低方向脚,代码结构基本不变,把循环换成DMA_Init+DMA_Cmd,把rs485_rx_enable()挪到DMA1_Channel4_IRQHandler里即可。

最后说个我自己的习惯。这套从站代码我每接一个新项目,都会先只跑「回环测试」——方向脚强制拉高,让从站自己发自己收,把 CRC 和组帧逻辑验证一遍,确认没问题再接真总线。有一次跳过了这步,直接上现场,查了整整一个下午才发现是主站发的是大端、我按小端解析,跟 485 硬件一点关系没有。从那以后我每次移植这套代码,都强制走一遍自发自收的回环,再上真实总线。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进