
简介一套基于STM32微控制器与RS485总线的Modbus-RTU主从机通信完整工程面向嵌入式开发者和工业通信学习者重点解决多从机轮询采集、寄存器读取及主从角色切换等问题。系统默认运行在主机模式自动寻址地址为01的从机获取数据通过按键1~3可分别查询01、02、03号从机数据按下按键4则切换为从机模式本机地址变为0x02并响应其他主机请求。代码中还包含LED状态指示、串口收发控制、定时器超时与帧间隔判断采用非DMA方式实现流程直观便于理解Modbus-RTU帧格式与RS485收发切换逻辑。压缩包内共163个文件大小3.02MB以C语言源码为主包含37个.h头文件和36个.c源文件附带Keil工程配置、编译生成的hex/axf、备份文件、脚本与说明文档可直接导入工程编译烧录。该资源已有515人学习浏览借助完整的主机、从机双角色代码和按键交互演示可快速掌握工业现场常用通信方案的工程实现适合在此基础上二次开发或移植到实际项目。1. 为什么说 Modbus-RTU 在 STM32 上的难点不是协议而是串口、定时器和 RS485 的配合把 Modbus-RTU 移植到 STM32 上其实不复杂一个串口UART收发字节一个定时器判断帧结束一颗 RS485 收发器切换方向。很多人卡住不是不懂协议而是把这三者的配合时序搞乱了最后出来的现象非常玄学——从机偶尔回、主机发出去没反应、CRC 明明对了解析还是错。这份资源把主机和从机代码都拆好了串口和定时器部分也是能直接用的适合两种人一是手里有带 RS485 的传感器或仪表盒子想用 STM32 做数据采集二是要做多设备组网需要自己维护一套主从轮询逻辑。文章后面我会把协议底子、收发实现、主从代码怎么落地说清楚再把我踩过的几个坑逐个拆开讲。2. Modbus-RTU 协议底子帧格式、功能码、寄存器模型与 CRC16 这关先过2.1 帧格式与三个关键时间Modbus-RTU 的帧结构非常固定一帧最多 256 字节。没有起始符没有结束符靠的是时间间隔来切帧。帧结构见下表字段长度说明从机地址1 字节0x01 ~ 0xF70x00 为广播地址功能码1 字节03 读保持寄存器、06 写单个寄存器、160x10写多个寄存器等数据域N 字节寄存器地址、寄存器数量、字节数、数据值最大 253 字节CRC162 字节低字节在前高字节在后关键的时间参数有三个t1.5、t3.5 和字符时间。Modbus 标准规定一帧内字节与字节之间的间隔要小于 t1.5一帧与下一帧之间的间隔要大于 t3.5。t3.5 等于 3.5 个字符时间每个字符按 11 位算1 起始位 8 数据位 1 校验位 1 停止位的情况所以 t3.5 11 × 3.5 / 波特率。波特率 9600 时约 3.57ms115200 时约 0.334ms。这也是为什么必须在串口之外配一个定时器UART 接收中断只能告诉你“收到一个字节”判断“这一帧收完没有”得靠定时器开一个时间窗口。窗口超时还没收到下一个字节就认为帧结束了可以进协议解析。这个窗口如果设短了一帧会被拆成几段设长了主机轮询周期会被拖慢。2.2 功能码与寄存器模型嵌入式 Modbus 设备最常用的功能码就三个03 读保持寄存器、06 写单个寄存器、160x10写多个寄存器。功能码 0x80 加原功能码表示异常响应比如 0x83 表示读保持寄存器请求失败。功能码名称用途响应帧特征0x03读保持寄存器读传感器数据、设备状态返回寄存器数量、数据字节数、寄存器值0x06写单个寄存器设置参数、开关控制镜像返回请求帧0x10写多个寄存器批量下发参数返回起始地址和寄存器数量0x81/0x83/0x90异常响应非法功能码、非法地址、非法数据数据域为异常码寄存器模型分四类线圈0x 开头、离散输入1x 开头、输入寄存器3x 开头、保持寄存器4x 开头。实际项目里保持寄存器用得最多一个寄存器 16 位多字节数据大端传输高位在前。有一点容易绕晕协议里寄存器地址是从 0 开始的偏移量。比如读保持寄存器请求01 03 00 00 00 01意思是读从机地址 0x01 的保持寄存器区从偏移 0x0000 开始读 1 个寄存器。对外描述成“40001 地址”是上位机或触摸屏的习惯代码里操作的就是偏移地址。这一层不提前理清后面看到抓包数据会半天对不上。2.3 CRC16 实现与参数核对CRC16 是 Modbus-RTU 唯一的校验手段算法参数固定多项式 0xA001反向写法实际是 0x8005 的按位反序、初值 0xFFFF。工程上不推荐逐位计算直接用查表法速度差一个数量级。static uint16_t crc_table[256]; /* 初始化 CRC 查表按 0xA001 反向多项式生成 */ void crc16_table_init(void) { uint16_t i, j, crc; for (i 0; i 256; i) { crc i; for (j 0; j 8; j) { if (crc 0x0001) { crc (crc 1) ^ 0xA001; } else { crc crc 1; } } crc_table[i] crc; } } /* 计算一帧数据的 CRC16 */ uint16_t modbus_crc16(uint8_t *buf, uint16_t len) { uint16_t crc 0xFFFF; while (len--) { crc (crc 8) ^ crc_table[(crc ^ *buf) 0xFF]; } return crc; }逻辑说明modbus_crc16对传入的整帧数据从从机地址开始到数据域结束不含 CRC 本身计算 CRC。返回值是 16 位校验值。发送时必须先发低字节再发高字节这是 Modbus-RTU 协议明确规定的字节序。参数说明多项式 0xA001 是 0x8005 反向过来的初值固定 0xFFFF这两个参数一个都不能改。表驱动法的本质就是一个字节一个字节地拿当前 CRC 的低字节去查表查出来的值跟原 CRC 的高字节异或效率远高于按位循环。这里给一个核对抓手请求01 03 00 00 00 01的 CRC 是0x840A发送顺序为0x0A 0x84。我每次移植完 CRC 代码第一件事就是用这帧数据在串口助手上核对对了再往下走省得后面所有调错都叠加在一起。3. 串口、定时器与 RS485 方向切换收一帧干净数据的必要条件3.1 串口参数与波特率误差先算清楚串口参数用 8 数据位、无校验、1 停止位8N1最常见。有些设备要求偶校验但绝大多数传感器和仪表出厂默认就是 8N1先把无校验跑通再根据具体设备文档去调校验位也不迟。UART_HandleTypeDef huart2; static void MX_USART2_UART_Init(void) { huart2.Instance USART2; huart2.Init.BaudRate 9600; huart2.Init.WordLength UART_WORDLENGTH_8B; huart2.Init.StopBits UART_STOPBITS_1; huart2.Init.Parity UART_PARITY_NONE; huart2.Init.Mode UART_MODE_TX_RX; huart2.Init.HwFlowCtl UART_HWCONTROL_NONE; HAL_UART_Init(huart2); }逻辑说明这里以 USART2 为例配置成 9600 波特率、8N1、收发模式。实际工程里波特率从 9600 到 115200 都常见传感器类设备多数默认 9600。参数说明重点不是代码本身而是波特率误差。STM32F103 的 USART1 挂在 APB272MHz 或 36MHzUSART2、USART3 挂在 APB1最大 36MHz。计算波特率分频值是BRR PCLK / (16 × BaudRate)如果除不尽实际波特率就跟目标有偏差。误差超过 2% 后帧会偶发乱码尤其线长、干扰大的场合更明显而且这种故障不是每次都出现排查起来很费劲。我一般会在移植时直接把 APB1、APB2 的时钟配置打开看一眼时钟树确保 USART 的输入时钟是整一点的数值再去算波特率误差。像 72MHz 下跑 115200 是能整除的36MHz 下跑 9600 也整除这两个组合基本不用操心。3.2 定时器做 3.5 字符超时别直接依赖 SysTick判断帧结束常见做法是“收到任意字节后清定时器定时器溢出就代表帧结束”。这里定时器要用硬件定时器不建议直接用 SysTick 做 1ms 滴答。9600 波特率下 t3.5 约 3.57msSysTick 勉强够用但切到 115200 时 t3.5 约 0.334ms1ms 的滴答粒度根本分不出帧间隔会把连续两帧当成一帧。/* TIM2 配置1MHz 计数频率ARR 按 t3.5 换算 */ static void MX_TIM2_Init(uint32_t baudrate) { float t35_us 11.0f * 3.5f / baudrate * 1000000.0f; TIM_HandleTypeDef htim2 {0}; htim2.Instance TIM2; htim2.Init.Prescaler 36 - 1; /* 36MHz 分频到 1MHz计数周期 1us */ htim2.Init.CounterMode TIM_COUNTERMODE_UP; htim2.Init.Period (uint32_t)t35_us - 1; htim2.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; HAL_TIM_Base_Init(htim2); HAL_TIM_Base_Start_IT(htim2); }逻辑说明TIM2 挂在 APB1 上输入时钟 36MHz预分频 35得到 1MHz 计数频率1 个计数单位就是 1us。把Period设为 t3.5 对应的微秒数减 1计数器从 0 数到 ARR 溢出就产生一次更新中断。t35_us的计算是把“字符时间 11 位 / 波特率”乘以 3.5 再换算成微秒。参数说明变更波特率时Period必须同步重算。代码里传baudrate参数就是为了避免换波特率时忘记更新定时器。如果希望留一点余量可以把Period乘一个 1.2 系数但不能超过 t3.5 太多不然主机切换下一帧时从机还在等。接收逻辑里每收到一个字节就把计数器清零重载同时把数据放进接收缓冲区。UART 接收中断和定时器更新中断在两个回调里配合volatile uint8_t rx_buf[256]; volatile uint16_t rx_len 0; volatile uint8_t rx_byte; volatile uint8_t frame_ready 0; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART2) { rx_buf[rx_len] rx_byte; __HAL_TIM_SET_COUNTER(htim2, 0); /* 清计数器重新开始计时 */ } } void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM2 rx_len 0) { HAL_TIM_Base_Stop_IT(htim2); /* 帧结束停掉定时器 */ frame_ready 1; /* 主循环里查这个标志位 */ } }逻辑说明收到一个字节进HAL_UART_RxCpltCallback把字节存进缓冲区然后清空 TIM2 计数器重新开始计时。只要字节间隔小于 ARR 溢出时间定时器就不会溢出帧就一直在累积。一旦字节间隔超过 t3.5TIM2 更新中断触发置frame_ready。主循环看到这个标志后把整帧数据交给解析函数然后重新开启 UART 接收。参数说明rx_buf长度 256刚好是 RTU 帧上限。rx_len是当前帧累计长度解析完成后必须清零否则下一帧数据会跟在后面。用 1MHz 计数时3600 的 ARR 对应 3.6ms跟 9600 波特率下的 t3.5 很接近实际 3.57ms 也够用。3.3 RS485 DE/RE 方向切换发送完成才算完成RS485 是半双工同一时刻只能有一个方向在发。一般把收发器的 DE 和 RE 引脚接在一起用一个 GPIO 控制方向拉高进入发送态拉低进入接收态。方向切换这件事最容易翻车。#define RS485_DE_HIGH() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_12, GPIO_PIN_SET) #define RS485_DE_LOW() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_12, GPIO_PIN_RESET) void rs485_send_frame(uint8_t *buf, uint16_t len) { RS485_DE_HIGH(); /* 切到发送方向 */ HAL_UART_Transmit(huart2, buf, len, 100); /* 阻塞发送 */ /* 关键等发送移位寄存器彻底移完 */ while (__HAL_UART_GET_FLAG(huart2, UART_FLAG_TC) RESET); RS485_DE_LOW(); /* 再切回接收方向 */ }逻辑说明HAL_UART_Transmit返回时数据只是从软件缓冲区搬到了 UART 发送数据寄存器最后一个字节可能还在移位寄存器里没发完。如果此时立刻把 DE 拉低收发器瞬间切到接收方向总线上的最后几个位会被截断从机收到的是残缺帧CRC 必挂。正确的姿势是等UART_FLAG_TC发送完成标志置位再去拉低 DE。参数说明HAL_UART_Transmit第三个参数是超时时间设为 100ms 即可正常一次发几个字节用不了 1ms。while轮询 TC 标志是最稳的写法没有任何延时误差。要注意的是 DMA 场景很多人以为 DMA 传输完成中断里拉低 DE 就行但 DMA 完成同样不等于移位寄存器发送完成仍然要等 TC 标志。4. 主机与从机代码落地从状态机到轮询调度的完整实现4.1 从机地址匹配、状态机与功能码分派从机的核心逻辑可以抽象成三步收帧 → CRC 校验和地址匹配 → 功能码分派并回帧。地址不匹配直接忽略不回任何数据。typedef enum { MB_IDLE, MB_RX, MB_HANDLE, MB_TX } mb_state_t; mb_state_t mb_state MB_IDLE; void modbus_slave_poll(void) { uint16_t crc_calc; if (frame_ready 0) { return; } /* 1. 帧完整性检查 */ if (rx_len 4) { /* 最短帧地址功能码CRC */ mb_state MB_IDLE; frame_ready 0; rx_len 0; return; } /* 2. CRC 校验对整个帧除 CRC 外的部分计算 */ crc_calc modbus_crc16((uint8_t *)rx_buf, rx_len - 2); if ((uint8_t)(crc_calc 0xFF) ! rx_buf[rx_len - 2] || (uint8_t)(crc_calc 8) ! rx_buf[rx_len - 1]) { frame_ready 0; rx_len 0; return; /* CRC 错静默丢弃 */ } /* 3. 地址匹配 */ if (rx_buf[0] ! slave_addr) { /* 不是发给我的 */ frame_ready 0; rx_len 0; return; } mb_state MB_HANDLE; handle_modbus_frame(); /* 功能码处理组织回帧 */ mb_state MB_TX; rs485_send_frame((uint8_t *)tx_buf, tx_len); frame_ready 0; rx_len 0; }逻辑说明modbus_slave_poll放在主循环里轮询调。frame_ready被置位说明定时器判定一帧收完了。第一步先查最小长度地址 1 字节、功能码 1 字节、CRC 至少 2 字节加起来 4 字节少于 4 字节的帧直接丢掉。第二步用modbus_crc16对整个帧不含 CRC 的部分重新计算跟帧尾两字节比对不一致就静默丢弃不回异常帧。第三步比对从机地址不匹配也静默丢弃。参数说明slave_addr是编译期宏比如0x01。如果你想做地址拨码把它改成uint8_t变量从 GPIO 读取拨码开关状态即可。CRC 校验失败不回帧是 Modbus 协议的标准行为因为无法确认帧是发给谁的乱回帧反而会造成总线冲突。4.2 功能码处理03 读、06 写、16 批量写功能码处理是从机代码的核心。下面这段覆盖了最常用的读保持寄存器和写保持寄存器。#define REG_NUM 8 static uint16_t regs[REG_NUM]; /* 保持寄存器映射表 */ void handle_modbus_frame(void) { uint8_t func rx_buf[1]; uint16_t start_addr; uint16_t reg_cnt; uint16_t i; /* 功能码异常只支持 03/06/16 */ if (func ! 0x03 func ! 0x06 func ! 0x10) { tx_buf[0] slave_addr; tx_buf[1] func | 0x80; tx_buf[2] 0x01; /* 01: 非法功能码 */ tx_len 3; tx_len 2; /* 补 CRC代码略同上 */ return; } start_addr (rx_buf[2] 8) | rx_buf[3]; /* 读保持寄存器功能码 0x03 */ if (func 0x03) { reg_cnt (rx_buf[4] 8) | rx_buf[5]; if (start_addr reg_cnt REG_NUM) { /* 地址越界 */ tx_buf[0] slave_addr; tx_buf[1] 0x83; tx_buf[2] 0x02; /* 02: 非法数据地址 */ tx_len 5; return; } tx_buf[0] slave_addr; tx_buf[1] 0x03; tx_buf[2] reg_cnt * 2; /* 字节数 */ for (i 0; i reg_cnt; i) { tx_buf[3 i * 2] (regs[start_addr i] 8) 0xFF; tx_buf[4 i * 2] regs[start_addr i] 0xFF; } tx_len 3 reg_cnt * 2; tx_len 2; /* 追加 CRC 的位置 */ } /* 写单个寄存器功能码 0x06镜像返回请求帧 */ if (func 0x06) { if (start_addr REG_NUM) { tx_buf[0] slave_addr; tx_buf[1] 0x86; tx_buf[2] 0x02; tx_len 5; return; } regs[start_addr] (rx_buf[4] 8) | rx_buf[5]; memcpy((uint8_t *)tx_buf, (uint8_t *)rx_buf, rx_len); tx_len rx_len; tx_len 2; } /* 最后统一在 tx_buf 尾部补 CRC */ uint16_t crc modbus_crc16((uint8_t *)tx_buf, tx_len - 2); tx_buf[tx_len - 2] crc 0xFF; tx_buf[tx_len - 1] crc 8; }逻辑说明handle_modbus_frame先做功能码合法性检查只认 03、06、16其余全部回0x80 | func的异常帧。读请求把起始地址和寄存器数量组合出来先做越界检查再组织响应。写单个寄存器的响应帧按协议要求镜像请求帧原样返回。最后统一补 CRC发送前再走rs485_send_frame的方向切换。参数说明REG_NUM是保持寄存器数量资源里默认 8 个够放温度、湿度、状态这类数据。start_addr reg_cnt REG_NUM的判断是必须的很多安全隐患都出在这里寄存器地址越界会把内存写坏。回复的字节数reg_cnt * 2是 16 位寄存器的字节数这里别写成reg_cnt否则上位机解析会少一半数据。4.3 主机组帧、等待超时与多从机轮询主机的逻辑跟从机正好相反发请求帧等从机回帧超时重试。由于 RS485 是半双工主机发出请求后必须立刻切回接收方向然后等待从机应答。等待窗口要大于从机的处理时间一般不小于 20ms。uint8_t build_request(uint8_t addr, uint8_t func, uint16_t start_addr, uint16_t reg_cnt, uint8_t *buf) { uint16_t crc; buf[0] addr; buf[1] func; buf[2] (start_addr 8) 0xFF; buf[3] start_addr 0xFF; buf[4] (reg_cnt 8) 0xFF; buf[5] reg_cnt 0xFF; crc modbus_crc16(buf, 6); buf[6] crc 0xFF; buf[7] crc 8; return 8; } uint8_t wait_for_response(uint16_t timeout_ms) { uint32_t start HAL_GetTick(); while (HAL_GetTick() - start timeout_ms) { if (frame_ready) { uint16_t crc modbus_crc16((uint8_t *)rx_buf, rx_len - 2); if ((uint8_t)(crc 0xFF) rx_buf[rx_len - 2] (uint8_t)(crc 8) rx_buf[rx_len - 1]) { return 1; } frame_ready 0; rx_len 0; } } return 0; /* 超时无有效应答 */ }逻辑说明build_request把地址、功能码、起始地址、寄存器数量填进缓冲区再补上 CRC。wait_for_response用HAL_GetTick做超时上限在窗口内不断查frame_ready收到帧就校验 CRC。参数说明timeout_ms要根据从机响应速度调。普通传感器从机通常在 10ms 内回帧填 50ms 足够如果总线上挂了 PLC 这类响应慢的设备要放宽到 200ms。重试逻辑我一般做 3 次第一次超时隔 20ms 重发三次都失败标记该从机离线继续轮询下一台。别做无限重试一台上电慢的设备会把整条总线的轮询周期拖死。多从机轮询可以用一个结构体数组把每台设备的地址、寄存器区间、数据存放位置都管起来typedef struct { uint8_t addr; uint16_t start_reg; uint16_t reg_cnt; uint16_t *data_buf; } modbus_node_t; modbus_node_t nodes[] { { 0x01, 0x0000, 2, sensor1_data }, { 0x02, 0x0000, 4, sensor2_data }, };逻辑说明主循环里遍历这个数组对每个节点执行“组帧 → 发送 → 等待应答 → 解析数据”的流程。应答解析时除了 CRC 校验还要确认应答的地址等于请求地址、功能码等于请求功能码防止总线上有其它设备窜扰导致错收。参数说明data_buf指向一个uint16_t数组接收到的寄存器值直接按顺序存进去。这样应用层代码只需要看sensor1_data[0]就能拿到第一个寄存器的实时值无需关心协议细节。5. 避坑与排查RS485 总线上几个翻车现场5.1 方向切换不够干净从机收不到完整帧现象主机发请求后从机一次都不回。用串口助手抓主机 TX数据看着没问题但接上从机就是没反应。原因DE 引脚拉低太早。HAL_UART_Transmit返回后最后一个字节可能还在移位寄存器里此时切到接收方向最后几个位被硬生生截断。从机收到的帧长度不对CRC 必然校验失败按协议要求静默丢弃所以主机看到的就是“发出去没人回”。解决发送完成后必须等待UART_FLAG_TC置位再拉低 DE。这个是所有 RS485 半双工通信里最容易被忽视的坑。另外注意DMA 传输完成中断里拉低 DE 也是一样的隐患DMA 完成 ≠ 移位寄存器发送完毕必须在 TC 中断里处理。5.2 波特率误差导致偶发乱码现象短距离通信正常线拉到 20 米以上就开始偶发乱码天气热的时候故障率明显上升。原因波特率误差超过了 RS485 接收端的容忍范围。STM32F103 的串口时钟来自 APB1/APB2如果分频数除不尽实际波特率跟目标有偏差线长了以后波形边沿退化误差叠加就直接错位。温度升高会让芯片内部 RC 精度进一步下降。解决先用逻辑分析仪抓实际波形量一下每位宽度是否跟理论值一致。然后在初始化时把时钟树配置打开让串口时钟尽量整除目标波特率。9600 和 115200 在 72MHz / 36MHz 的主频下都有不错的组合避开 4800、19200 这类容易除出小数的档位。距离长的话把波特率降到 9600450 米内基本够用。5.3 终端电阻、手拉手接线与地电位现象总线上挂 5 台设备单独跟每台通信都正常组网后偶发一帧错误主机重试又恢复。原因要么没接终端电阻信号在总线末端反射要么接线方式不是手拉手菊花链而是星形接法反射叠加在数据帧上。另一个隐蔽原因是各设备供电不共地RS485 的 A/B 线之间出现共模电压差。解决总线物理链路首尾两端各接一个 120Ω 终端电阻中间设备不接。接线顺序必须手拉手主机 → 设备1 → 设备2 → ... → 最后一台设备不允许从中间分叉出去再接长线。共地问题最稳的办法是让所有设备的地电位拉通或者用带隔离的 RS485 收发器比如 ISO3082。保护电路方面A/B 线上加 TVS 管到地防止插拔瞬间的静电打坏收发器。5.4 CH340 下程序失败或下载后串口不工作现象板载 RS485 收发器CH340 下载程序时提示连接失败有时候下载成功了串口调试助手打开就卡住。原因CH340 的 TXD 直接连到了 RS485 收发器的 DI 引脚。如果 DE 处于发送方向CH340 的下载数据会被 485 芯片放大到总线上但平时 DE 处于接收方向时CH340 的 TXD 信号被 485 芯片屏蔽下载协议握手失败。更常见的是 RS485 收发器的 RO 引脚输出一直把 RX 线拉在某个电平导致串口工具一打开就误判收到数据。解决下载程序时把 RS485 的 DE 引脚强制拉低或者干脆加一个跳线帽把 485 芯片的 RO 和 CH340 的 RX 断开。工程化做法是设计一键下载电路时把 BOOT0 拉高和 DE 拉低联动。调试时优先用 TTL 串口直接连 STM32 的 PA9/PA10绕开 485 芯片先把协议逻辑调通再切回 RS485 总线路。5.5 三极管/MOS 自动收发电路在高波特率下波形畸变现象有人为了省一个 GPIO用三极管或 MOS 管搭“免方向控制”电路9600 波特率下一切正常换成 230400 后从机完全乱码。原因这类自动收发电路靠 TXD 的电平反向驱动 DE方向切换的边沿取决于三极管/MOS 管的开关速度和上拉电阻的充放电时间。波特率一高位宽从 104us 缩到 4.3us方向切换沿还没稳定数据位已经开始输出了收发器在发送态和接收态之间反复横跳。解决低速场景≤19200可以用自动收发电路但 115200 及以上不建议。改用 GPIO 直接控制 DE配合 TC 标志切换时序完全可控。如果是自己画板直接在原理图阶段就留一个 MCU GPIO 到 DE/RE 的网络比后期飞线改板子省事得多。6. 进阶DMA 加空闲中断收帧把 Modbus 从机的 CPU 占用降下来6.1 为什么要换成 DMA 收帧前面的实现是每个字节触发一次 UART 接收中断外加一次定时器更新中断或超时查询。9600 波特率下一帧 8 个字节也就是 8 次中断CPU 负担不大。但一旦波特率拉到 115200一秒钟上千字节每字节一次中断的开销就很可观了而且中断里还要做缓冲区写入和定时器清零。更麻烦的是从机同时还要处理其它任务中断一多主循环的实时性就会受影响。换成 DMA 加 UART 空闲中断后数据由 DMA 自动搬进缓冲区CPU 只在“一帧收完”这个时刻被唤醒一次。6.2 HAL 库的 DMA 加空闲中断实现STM32 的 UART 自带空闲帧检测总线空闲超过一个字节时间就产生 IDLE 中断。把 DMA 设成循环模式接收数据持续进缓冲区一旦 IDLE 中断触发就说明一帧结束了此时从 DMA 缓冲区里取数据即可。uint8_t dma_rx_buf[256]; static void MX_USART2_UART_Init_DMA(void) { /* 基础 UART 配置同前BaudRate 按实际调 */ huart2.Init.BaudRate 9600; HAL_UART_Init(huart2); /* 绑定 DMA 通道循环模式 */ hdma_uart2_rx.Instance DMA1_Channel6; hdma_uart2_rx.Init.Direction DMA_PERIPH_TO_MEMORY; hdma_uart2_rx.Init.PeriphInc DMA_PINC_DISABLE; hdma_uart2_rx.Init.MemInc DMA_MINC_ENABLE; hdma_uart2_rx.Init.PeriphDataAlignment DMA_PDATAALIGN_BYTE; hdma_uart2_rx.Init.MemDataAlignment DMA_MDATAALIGN_BYTE; hdma_uart2_rx.Init.Mode DMA_CIRCULAR; HAL_DMA_Init(hdma_uart2_rx); __HAL_LINKDMA(huart2, hdmarx, hdma_uart2_rx); /* 开启 DMA 接收 空闲中断 */ HAL_UARTEx_ReceiveToIdle_DMA(huart2, dma_rx_buf, sizeof(dma_rx_buf)); __HAL_UART_ENABLE_IT(huart2, UART_IT_IDLE); } void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart-Instance USART2) { uint16_t len Size; /* 本次事件已经收到的字节数 */ uint16_t crc; if (len 4) { crc modbus_crc16((uint8_t *)dma_rx_buf, len - 2); if ((uint8_t)(crc 0xFF) dma_rx_buf[len - 2] (uint8_t)(crc 8) dma_rx_buf[len - 1]) { /* 完整有效帧交给协议解析 */ handle_modbus_frame_from_buf((uint8_t *)dma_rx_buf, len); } } /* 重新开启接收准备下一帧 */ HAL_UARTEx_ReceiveToIdle_DMA(huart2, dma_rx_buf, sizeof(dma_rx_buf)); } }逻辑说明DMA 处于循环模式收到的数据不断写入dma_rx_buf写满后自动从头开始覆盖。HAL_UARTEx_ReceiveToIdle_DMA会把 DMA 和空闲中断绑定在一起一帧数据结束后触发HAL_UARTEx_RxEventCallback参数Size就是本次收到的字节数。因为 DMA 是循环模式如果一帧超过缓冲区长度数据会覆盖所以要保证dma_rx_buf不小于 256 字节。参数说明Size拿到的就是已接收字节数不需要再读 DMA 计数器这是 HAL 库封装好的行为。有些旧版本固件库需要手动读DMA_GetCurrDataCounter来计算长度移植时留意一下版本差异。另外空闲中断触发后要立刻重新调用一次HAL_UARTEx_ReceiveToIdle_DMA否则只能收到第一帧。6.3 验证方法与后续扩展换成 DMA 方案后验证工作分三步走。第一步用逻辑分析仪抓总线波形看每帧之间的间隔是否大于 t3.5小于 t1.5 的字节间隔是否正常。第二步用 Modbus Poll 或串口助手连续读从机保持寄存器 100 次统计错误率正常应为 0。第三步人为把总线拔掉再插上看主机能否在超时后自动重连从机能否在恢复后正常应答。这套 DMA 收帧逻辑同样可以复用到 Modbus 主机侧只需要把空闲中断后的处理函数从“解析请求”改成“解析应答”。资源里的从机代码和主机代码结构是分开的寄存器映射表、CRC 校验、超时重试都是独立模块改起来不需要动协议骨架。我那之后每次上新的 RS485 项目都强制自己按“接收超时 → CRC 校验 → 方向切换 → 压测”这个顺序完整走一遍再急也不敢跳步。希望帮到你。本文还有配套的精品资源点击获取