简介:面向嵌入式开发者,提供一套基于 STM32F103ZET6 的完整工程,融合 RS485 多点通信接口、DMA 直接存储器访问与 Modbus RTU 协议,解决主机批量读取/写入多个从机寄存器以及长距离、高速率数据传输中的效率问题。资源共包含 171 个文件,压缩包大小 3.32MB,以 C 源码、H 头文件、Keil 工程配置、编译中间文件及最终固件为主,目录结构清晰,适合直接导入开发环境对照学习。目前已有 8310 人学习下载,适用于工业自动化、物联网设备等嵌入式开发场景。内容覆盖 GPIO 与串口参数配置、RS485 收发方向切换、DMA 通道分配与中断处理、Modbus 报文解析与应答构建等关键环节,并实现了读寄存器(0x03)与写单个寄存器(0x06)功能码。工程中还附带调试脚本、备份工程、批处理工具与多种编译产物,可省去重复搭建环境的时间,便于二次开发、功能裁剪和现场通信排错。
1. STM32+RS485+DMA+Modbus:为什么这是工控串口通信的首选
接到一个现场需求,要把一台主机和十几个分布在车间里的仪表连起来,距离几十米,线缆旁边还跑着变频器。最开始我用普通串口中断逐字节接收,主从问答一频繁就收半个帧,后来换成 STM32+RS485+DMA+Modbus 协议这套组合,问题一下子清爽了。RS485 解决距离和抗干扰,Modbus RTU 解决帧格式和主从问答,DMA 负责把串口数据搬运到内存,CPU 只在完整帧到达时动一次。这套方案特别适合做采集网关、传感器汇总、PLC 通信扩展这类 STM32 嵌入式项目,既有现成协议可循,又能把 CPU 占用压得非常低。下面从架构到核心代码,再到调试踩坑,完整拆一遍。
2. 架构与技术选型:从RS485电平到Modbus RTU帧的完整链路
2.1 RS485解决的是物理层问题,不是协议问题
很多新手把 RS485 当成一种“协议”,其实它只是物理层电气标准。RS485 用 A、B 两根差分线传输,抗共模干扰能力强,标准负载下一条总线能挂 32 个节点,波特率 9600 时通信距离可以到 1200 米左右。对比普通 TTL 串口,TTL 电平参考的是地,线一长地电位漂移就翻车;RS485 参考的是两根线之间的电压差,所以干扰再大也不容易误码。
它和 UART 的关系是:MCU 内部依然用串口外设收发字节,外面加一个 RS485 收发器芯片,把 TTL 电平转成差分电平。收发器通常有一个方向控制脚 DE/RE,高电平发、低电平收。Modbus 是半双工主从协议,同一时刻总线上只允许一个节点发送,所以方向脚切换的时机非常关键,后面代码部分会重点讲。
2.2 Modbus RTU主从模型:一问一答的报文结构
Modbus RTU 的报文结构是固定套路:从机地址(1 字节) + 功能码(1 字节) + 数据(可变长) + CRC16(2 字节)。主机发送请求帧,从机匹配地址后执行操作,再回一个响应帧。常见功能码就那几个:03 读保持寄存器、06 写单个寄存器、16 写多个寄存器,做采集类项目基本够用。
帧与帧之间要求有大于 3.5 个字符时间的间隔,主机不能连续发两帧不带停顿。这个特性正好可以用串口空闲中断来检测:总线上一帧数据发完后,线路会进入空闲状态,此时触发的空闲中断就可以认为一帧接收完成。用 DMA + 空闲中断的方式接收,天然满足 Modbus RTU 的帧边界判定,不用自己去算超时时间,适合做主从轮询模型下的从机程序。
2.3 工程结构划分:把通信、协议、应用三层分开
我一般会把代码拆成三层:底层驱动(bsp)、协议层(modbus)、应用层(app)。通信底层的 UART 和 DMA 初始化不放协议层,寄存器读写也不放通信层,这样后面换芯片、加功能码,改动范围都能控制住。
app/ main.c # 主循环,处理接收事件,调用协议处理 modbus_slave.c # Modbus从机状态机,地址匹配、CRC校验 modbus_slave.h modbus_reg.c # 寄存器表,应用变量映射到Modbus地址 modbus_reg.h bsp/ bsp_uart_dma.c # UART初始化、DMA接收启动、空闲中断处理 bsp_uart_dma.h bsp_rs485.c # RS485方向控制,DE/RE引脚操作 bsp_rs485.h| 模块 | 职责 | 关键接口 |
|---|---|---|
| bsp_uart_dma.c | 串口+DMA启动、空闲中断读取帧长度 | MODBUS_Init() |
| bsp_rs485.c | 发送前拉高方向脚,发送后拉低 | RS485_DE_SET() / RS485_DE_CLEAR() |
| modbus_slave.c | 地址匹配、CRC校验、功能码分发 | MODBUS_Poll() |
| modbus_reg.c | 寄存器表读写映射 | REG_Read() / REG_Write() |
初始化流程是:先初始化 UART 和 DMA,再启动 DMA 接收,使能空闲中断,最后往主循环里丢一个 MODBUS_Poll()。这样分层的好处是,DMA 怎么收数据、Modbus 怎么解析,两边可以各自独立测试。如果换一个不带的 UART 空闲中断的芯片,只需要把 bsp_uart_dma.c 里的接收逻辑替换成定时器超时判断,上面的协议层完全不用动。
3. 核心模块拆解:DMA空闲中断接收、发送方向控制与CRC16校验
3.1 DMA循环接收 + UART空闲中断:怎样拿到一帧Modbus数据
Modbus RTU 是变长帧,帧长从几个字节到两百多字节不等,所以不能用固定长度的 DMA 接收完成中断来判断一帧结束。常见做法是把 DMA 配置成循环模式,数据不断往缓冲区里写,同时使能串口空闲中断。总线空闲时,最后一批数据已经进入缓冲区,在中断里读取 DMA 剩余计数,就能算出本次收到的字节数。
#define RX_DMA_BUF_SIZE 512 /* 必须比最长modbus帧大 */ #define MODBUS_RX_MAX_LEN 256 uint8_t dma_rx_buf[RX_DMA_BUF_SIZE]; /* DMA循环写入的缓冲 */ uint8_t modbus_rx_buf[MODBUS_RX_MAX_LEN]; /* 拷贝出来的完整帧 */ volatile uint16_t modbus_rx_len; volatile uint16_t modbus_evt; void MODBUS_UART_IRQHandler(void) { HAL_UART_IRQHandler(&huart); if (__HAL_UART_GET_FLAG(&huart, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLE_FLAG(&huart); /* NDTR是DMA剩余字节数,用缓冲总长减去剩余就是已接收长度 */ uint16_t remain = __HAL_DMA_GET_COUNTER(&hdma_uart_rx); uint16_t len = RX_DMA_BUF_SIZE - remain; if (len > 0 && len <= MODBUS_RX_MAX_LEN) { memcpy(modbus_rx_buf, dma_rx_buf, len); modbus_rx_len = len; modbus_evt |= MODBUS_EVT_RX; /* 事件标志通知主循环 */ } } }这段逻辑里最关键的是__HAL_DMA_GET_COUNTER,它读回来的是 DMA 通道还没搬运完的字节数。循环模式下 DMA 始终在跑,所以每次空闲中断读到的剩余值,配合缓冲大小就能逆推出“新写入的长度”。这里有个参数选择问题:RX_DMA_BUF_SIZE一定要大于最大 Modbus 帧长度,否则数据填满缓冲区后 DMA 会回头覆盖旧数据,而且不会触发空闲中断。Modbus RTU 最长 256 字节,所以缓冲定 512 比较稳。
另一个隐蔽问题是 HAL 的HAL_UART_IRQHandler会处理各种串口错误状态,IDLE 标志如果不手动清,同一帧可能触发两次空闲中断。所以代码顺序是先调 HAL 的通用中断处理,再判断和清除 IDLE 标志。DMA 用循环模式还有个好处:帧处理过程中不需要去停止或重启 DMA,拷贝完成后直接清事件标志,DMA 自己会继续接收下一帧,不会丢总线数据。
3.2 发送路径与485方向控制:半双工不是写完代码就能跑的
RS485 是半双工,收发共用一个差分总线。发送前必须把收发器的 DE/RE 方向脚拉高,把驱动器使能打开;发送完成后必须再拉低,回到接收状态。这一步如果做错,现象非常典型:对方收到乱码,或者干脆收不到你后面的应答帧。
void MODBUS_Send(uint8_t *data, uint16_t len) { RS485_DE_SET(); /* 发送前置方向脚为发送态 */ HAL_UART_Transmit_DMA(&huart, data, len); } void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USARTx) { RS485_DE_CLEAR(); /* DMA发送完成后回到接收态 */ } }这里最常翻车的是:调用HAL_UART_Transmit_DMA之后立刻把方向脚拉低。DMA 只是把数据交给串口外设,但最后一个字节还在移位寄存器里没有完全输出,此时拉低等于把发送中断了。正确做法是把方向脚复位放在 DMA 发送完成回调里,让硬件告诉你“数据真的发完了”。
如果需要更保险,还可以在回调整一个短延时,等最后一个停止位彻底发完,再拉低方向脚。波特率 9600 时一个字节大约 1ms,延时 1~2ms 就足够,波特率越高需要的延时越短。发送路径上我还习惯在MODBUS_Send里加一个判断,总线忙时直接丢弃新请求,防止半双工收发打架。
3.3 Modbus RTU帧校验与功能码分发
拿到帧之后,不是直接进 switch 处理功能码,先做两层过滤:地址不匹配直接丢弃,CRC 不对直接丢弃。CRC16 算法必须用 Modbus 专用的多项式 0xA001,和普通的 CRC16 不一样,网上抄代码时容易拿错表。
static uint16_t crc16_modbus(uint8_t *buf, uint16_t len) { uint16_t crc = 0xFFFF; for (uint16_t i = 0; i < len; i++) { crc ^= buf[i]; for (uint8_t j = 0; j < 8; j++) { if (crc & 0x0001) { crc = (crc >> 1) ^ 0xA001; } else { crc >>= 1; } } } return crc; }计算 CRC 时只算“从机地址 + 功能码 + 数据”这段,帧尾的 2 字节 CRC 本身不参与计算。发送响应时再把算出来的 CRC 低字节放前面、高字节放后面,因为 Modbus RTU 规定 CRC 低字节在前。这是另一个高频踩坑点,经常有人算对了 CRC 却因为高低字节顺序反了,导致主机一直报 CRC error。
uint8_t MODBUS_Poll(void) { if (!(modbus_evt & MODBUS_EVT_RX)) { return 0; } modbus_evt &= ~MODBUS_EVT_RX; if (modbus_rx_len < 4) { return MODBUS_ERR_SHORT_FRAME; } if (modbus_rx_buf[0] != MODBUS_SLAVE_ADDR) { return MODBUS_ERR_ADDR; /* 地址不匹配,不回任何东西 */ } uint16_t crc_recv = modbus_rx_buf[modbus_rx_len - 1] << 8 | modbus_rx_buf[modbus_rx_len - 2]; uint16_t crc_cal = crc16_modbus(modbus_rx_buf, modbus_rx_len - 2); if (crc_recv != crc_cal) { return MODBUS_ERR_CRC; } /* 地址和CRC都通过,才按功能码分发 */ uint8_t fn = modbus_rx_buf[1]; if (fn == 0x03) { HandleReadHoldingRegisters(modbus_rx_buf, modbus_rx_len); } else if (fn == 0x06) { HandleWriteSingleRegister(modbus_rx_buf, modbus_rx_len); } return 0; }主循环每圈调用一次MODBUS_Poll,没有事件就立刻返回,绝不在这里阻塞等待。处理 03 功能码时,先从帧里解析起始地址和寄存器数量,再去modbus_reg.c的寄存器表里取值,拼成响应帧后调用MODBUS_Send。寄存器表设计成统一数组后,应用层想暴漏什么变量,直接映射到对应地址就行,不用为每个数据写独立处理函数。
4. 跑起来:CubeMX配置、硬件接线与主从联调
4.1 CubeMX关键配置项:串口、DMA、空闲中断
用 HAL 库开发时,我习惯先在 CubeMX 里生成工程骨架,再手工补空闲中断逻辑。CubeMX 配置串口环节有几个关键点,配置错了后面代码再对也收不到数据。
| 配置项 | 推荐值 | 说明 |
|---|---|---|
| 时钟源 | 外部晶振 | 内部 RC 振荡器偏差大,波特率误差会导致丢帧 |
| UART 模式 | 异步模式 | 8 位数据、无校验、1 停止位,Modbus RTU 标准格式 |
| 波特率 | 9600 / 115200 | 现场干扰大选 9600,传输数据量选 115200 |
| UART RX DMA | Circular | 循环模式,避免一帧接收完成就要重启 |
| UART TX DMA | Normal | 发送是一次性的,Normal 模式足够 |
| DMA 数据宽度 | Byte | 串口数据按字节搬运 |
| NVIC | 打开串口全局中断 | 空闲中断依赖串口中断入口 |
初始化完成后,在 main 函数里启动 DMA 接收和空闲中断,代码是固定的两步:
HAL_UART_Receive_DMA(&huart, dma_rx_buf, RX_DMA_BUF_SIZE); __HAL_UART_ENABLE_IT(&huart, UART_IT_IDLE);注意HAL_UART_Receive_DMA只调用这一次,不要放在主循环里反复调用。HAL 库内部有接收状态机,第一次调用之后RxState会变成 BUSY,再调用返回 HAL_BUSY,看起来像是“死机”了,实际是状态冲突。DMA 循环模式下,接收通道一直在跑,不需要重新启动。
4.2 硬件接线:A/B、共地、终端电阻
RS485 接线看起来简单,实际调试时很多杂音问题出在接线上。A 接 A、B 接 B,这是肯定的,但共地这条经常被忽略。两个设备距离稍远,如果地电位不一致,共模电压可能超过收发器容忍范围,轻则误码,重则烧芯片。所以 RS485 通信也要求设备之间共地,至少要在某个节点把地连起来,不能只靠两根差分线。
总线两端要各接一个 120 欧姆终端电阻,作用是匹配阻抗、减少反射。短距离、低波特率试验时可以省,但一挂多节点就建议加上。还有一个容易漏掉的细节:如果手头只有主机的 USB 转 RS485 模块,模块上一般有一个 R/A 和 T/B 端子,接法同样是 A-A、B-B,测试前先确认模块这边有没有自带的偏置电阻,避免空总线时电平悬空。
4.3 联调步骤:从PC发一帧,看板子怎么回
调通线路最快的方法是先用 PC 串口工具当 Modbus 主机,向板子发一帧标准读寄存器请求,看板子能不能正确回帧。假设从机地址是 0x01,读寄存器起始地址 0x0000,数量 2 个,请求帧是:
01 03 00 00 00 02 C4 0B01 是从机地址,03 是读保持寄存器功能码,00 00 是起始地址,00 02 是寄存器个数,C4 0B 是 CRC16 校验。用串口工具十六进制发送,如果板子正常返回类似01 03 04 00 0F 01 2C CRC 低 CRC 高的帧,说明 DMA 接收、CRC 校验、485 方向切换这一整条链路已经通了。
调试顺序我习惯这样做:
- 先用排除法验证硬件:PC 串口直接接板子的 UART 引脚(绕过 485 收发器),发字符看能否收到回显,确认 MCU 串口本身没问题。
- 再把 485 收发器接回去,用 PC 的 USB 转 485 模块发同样的帧,对比收发内容。
- 确认 CRC 没问题之后,再去加多个从机、改寄存器表。
如果卡在第二步,优先查 DE/RE 方向脚波形,用示波器量发送期间方向脚是不是全程高电平,有没有提前掉下来。
5. 避坑与常见问题:DMA丢数、485方向切换与帧错位排查
5.1 现象:DMA接收到的长度总是比实际少一个字节
原因:使用空闲中断配合 DMA 接收时,最后一个字节可能还没有被完整搬进缓冲区,空闲中断就触发了。或者说 DMA 还在搬运过程中,CPU 读取NDTR计数器时拿到了一个中间状态。
解决:不要在空闲中断里立刻处理帧,先清中断标志,把数据和长度拷贝到独立缓冲,丢给主循环慢慢解析。如果仍然少字节,可以在收到空闲中断后加一个微秒级延时,等 DMA 完成搬运再读取计数器。这里最可靠的还是事件标志机制,中断里只负责“通知”,主循环处理时数据肯定稳定了。
5.2 现象:同一帧触发了两次空闲中断,解析出两段半截数据
原因:空闲中断标志没有在进入中断处理时清掉,或者HAL_UART_IRQHandler和手动处理 IDLE 的代码顺序不对,导致同一段空闲状态被判断了两次。
解决:固定顺序是先调用HAL_UART_IRQHandler,再读UART_FLAG_IDLE标志,清标志然后处理数据。同时在拷贝前加长度判断,len 为 0 时直接跳过,能滤掉多数重复中断。如果还担心重入,可以加一个processing标志,主循环没消费完事件之前,中断里只更新长度不再覆盖缓冲。
5.3 现象:接收端能收到帧头,最后一个字节丢了,CRC不对
原因:这是 RS485 方向脚切换时序的经典问题,DMA 发送回调里拉低 DE 太快,最后一个字节还在移位寄存器里没发出去。
解决:把RS485_DE_CLEAR()放在HAL_UART_TxCpltCallback里执行,如果芯片的 DMA 完成中断比串口移位寄存器发送完成早,就要再加一个短延时。我在实际项目里习惯发完最后一个停止位后再延时 1 个字符时间,9600 波特率下大概 1ms,这一毫秒能省掉后面一整天的抓狂。
5.4 现象:总线上挂多台从机时,多台设备同时应答,总线乱套
原因:从机地址匹配写得不对,或者地址匹配后没有丢弃 CRC 错误的帧,让所有设备把请求当成自己的。
解决:地址匹配必须在 CRC 校验通过之后做。做法是:先计算 CRC,CRC 通过后判断帧头地址是否等于本机地址,不相等就直接丢弃;最后才是功能码分发。这里有一个习惯值得养成:从机地址做成宏定义,烧录前统一核对,别在代码里改一半留一半。
5.5 现象:总线空闲时一直收到 0xFF 垃圾数据,帧头永远对不上
原因:RS485 总线空闲时没有主动驱动,A/B 两线之间电压差接近门限值,收发器输出脚乱跳,串口就收到了随机数据。常见于短路、没接偏置电阻、或者某端收发器没有正常工作。
解决:在总线两端加上下拉偏置电阻,A 线接上拉、B 线接下拉,让空闲状态稳定在逻辑 1。这也是为什么很多工业 485 模块上能看到 R1/R2 这种偏置电阻。如果在测试板上遇到,先确认收发器方向脚有没有被固化成接收态。
6. 二次开发:功能码注册表与寄存器表驱动的改造思路
6.1 用注册表替代散落的功能码switch
拿到这套代码做二次开发,最值得改的地方是把功能码分发从 if/else 链改成注册表。每次加新功能码都要往MODBUS_Poll里插一个 else if,代码越来越长也容易误改。注册表的方式是定义一个结构体数组,功能码和处理函数一一对应:
typedef int (*modbus_handler_t)(uint8_t *pdu, uint16_t len); typedef struct { uint8_t code; modbus_handler_t handler; } modbus_fn_t; static const modbus_fn_t modbus_fns[] = { { 0x03, HandleReadHoldingRegisters }, { 0x06, HandleWriteSingleRegister }, { 0x10, HandleWriteMultipleRegisters }, };分发函数只需要遍历这个数组,匹配到功能码就调用对应处理函数。新增一条指令,三步走:先在寄存器表里定义地址和数据变量,然后写一个 handler 函数,最后在数组里加一行。想支持标准功能码,把 0x2B 之类的处理函数写好挂进去就行,主流程一行不用动。
6.2 把寄存器表当成应用和协议之间的接口
二次开发的核心不是协议,而是寄存器表的映射关系。保持寄存器放读写的模拟量,输入寄存器放只读状态,线圈放开关量,应用代码只操作数组下标,Modbus 层只读这张表。这样做的好处是,无论你后面是加传感器还是加控制阀,都变成“填表”的活。从那以后我每次接手 Modbus 项目,都会先画一张寄存器地址表,把每个地址对应的物理量、读数权限和缩放系数写清楚,然后再动代码,这步省下来的时间远比自己埋头看逻辑多。希望帮到你。
本文还有配套的精品资源,点击获取