
简介这份资源面向STM32嵌入式开发初学者与需要落地RS485通信的工程师围绕HAL库、串口、定时器与Modbus协议展开提供主机与从机双向通信的完整测试代码。包内共167个文件以24个c源文件、51个h头文件为核心配合o、d、crf等编译中间文件及uvprojx、uvoptx工程文件、hex与axf可执行文件压缩包约8.32MB可直接用Keil打开编译下载。内容按三部分组织主机读取从机数据、主机向从机单个寄存器写入数据以及本设备作为从机使用从机地址0x02覆盖Modbus常用功能码的收发流程。代码基于STM32F1系列HAL库涉及串口收发、定时器超时与帧间隔处理、DMA等底层配置便于读者理解RS485方向控制与协议解析细节。已有44人学习适合作为课程设计、工业现场调试或协议入门的参考工程。1. 从一块工控板说起STM32 HAL 库 RS485 Modbus 到底解决什么问题手上有一块 STM32 板子两个 RS485 接口一个接上位机一个接现场传感器要求跑 Modbus 协议主机轮询、从机响应都要能测。这个场景在工控、电力、暖通、光伏逆变器、伺服驱动里反复出现核心诉求就一句话用 STM32 HAL 库把 RS485 半双工收发时序管住再在串口字节流上跑通 Modbus RTU 的帧解析。标题里的五个词——STM32、HAL 库、RS485、串口、定时器、Modbus——不是并列关系而是层层依赖HAL 库配置串口和定时器串口挂 RS485 收发器定时器负责 3.5 字符帧间隔判定Modbus 协议跑在最上层。新手最容易翻车的地方不是协议本身而是 RS485 方向引脚切换和帧间隔判定这两个玄学环节。这篇笔记按硬件怎么接 → CubeMX 怎么配 → 收发怎么切 → 帧怎么判 → 主从怎么测的顺序讲透适合刚接触工控通信的嵌入式工程师也适合从标准库转 HAL 库、被 HAL_UART_Transmit 阻塞坑过的老手。2. RS485 硬件链路与 CubeMX 最小配置先把物理层立住RS485 是半双工差分总线物理层不立住后面协议调多久都是白费。这一章先把收发器电路、终端电阻、方向控制引脚讲清楚再用 STM32CubeMX 把 USART 和定时器配出来。2.1 RS485 收发器电路与 DE/RE 方向引脚常见收发器是 MAX485、SP3485、ADM2483 这类。以 MAX485 为例RO 接 MCU 的 RXDI 接 MCU 的 TXRE 和 DE 短接后由一个 GPIO 控制拉高发送拉低接收。这里有个血泪经验——很多人把 RE 和 DE 分开接两个 GPIO结果发送时忘了拉低 RE接收时忘了拉高总线直接哑掉。短接成一个方向脚是最省心的做法。总线两端各接一个 120Ω 终端电阻中间节点不接。A 接 A、B 接 B别接反接反了收发都收不到但示波器上看波形还正常属于典型翻车点。如果现场电磁环境差A/B 上各串一个 10Ω 电阻再并 TVS共模干扰能压下去不少。器件作用典型值终端电阻消除信号反射120Ω仅两端偏置电阻空闲时维持差分电平A 上拉 680ΩB 下拉 680Ω限流电阻抑制浪涌A/B 各串 10ΩTVS防浪涌6.8V 双向偏置电阻不是必须但总线空闲时如果差分电压落在阈值附近从机会收到随机起始位表现为莫名多出一堆乱码。加偏置后空闲电平稳定这个现象基本消失。2.2 CubeMX 里 USART 与定时器的参数怎么填打开 CubeMX选好芯片型号先配时钟树再配 USART。以 USART2 为例模式选 Asynchronous波特率 9600 或 115200字长 8 位无校验停止位 1——这就是 Modbus RTU 的标准串口参数。如果现场设备要求偶校验就在 Parity 里选 Even但要注意 Modbus RTU 规范里校验位算在字符长度内配错会导致帧判定偏移。NVIC 里把 USART2 全局中断打开优先级设成比定时器中断低一点避免定时器被打断导致帧间隔判定不准。再配一个基本定时器 TIM6预分频和重装载值按下面公式算定时器溢出时间 (PSC1) * (ARR1) / 定时器时钟频率假设定时器时钟 72MHz要 50us 溢出一次PSC 取 71ARR 取 49则 (711)*(491)/72e6 50us。这个 50us 是后面帧间隔判定的时间基准波特率越高基准要越小。9600 波特率下 1 个字符约 1.04ms3.5 字符约 3.64ms用 50us 基准计 73 次即可。方向控制引脚随便选一个 GPIO比如 PA1初始电平拉低接收态推挽输出速度设 Low 就够RS485 方向切换不需要高速。2.3 生成代码后先验证串口自发自收CubeMX 生成代码后别急着写 Modbus先做一件事把方向脚拉高用 HAL_UART_Transmit 发一串字节再把方向脚拉低看能不能收到。这一步能排除 90% 的硬件问题。/* 方向脚拉高进入发送态 */ HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_SET); uint8_t test[] {0x01, 0x02, 0x03, 0x04}; HAL_UART_Transmit(huart2, test, sizeof(test), 100); /* 等发送真正完成再切回接收否则最后几个字节会被截断 */ while(__HAL_UART_GET_FLAG(huart2, UART_FLAG_TC) RESET); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_RESET);这里的关键是UART_FLAG_TCTransmit Complete不是UART_FLAG_TXE。TXE 只表示发送数据寄存器空最后一个字节还在移位寄存器里往外挪这时候切方向脚最后一个字节直接丢。这个坑我踩过不止一次现象是接收端永远少一个字节查半天以为是协议问题。参数说明HAL_UART_Transmit的超时给 100ms 足够波特率 9600 发 4 个字节也就 4ms 左右。如果超时返回 HAL_TIMEOUT先查波特率对不对、TX/RX 有没有接反。3. HAL 库串口收发与 RS485 方向切换中断和 DMA 怎么选物理层通了接下来解决收发效率问题。HAL 库的阻塞式收发在 Modbus 场景下基本不能用因为主机要轮询多个从机阻塞会拖垮整个轮询周期。这一章讲中断接收、DMA 接收和方向切换的配合。3.1 中断接收逐字节进缓冲区最直接的方式是开接收中断每来一个字节进一次回调。CubeMX 里已经开了 USART2 全局中断代码里调用一次HAL_UART_Receive_IT启动接收然后在回调里把字节存进环形缓冲区。#define RX_BUF_SIZE 256 uint8_t rx_byte; /* 单字节接收暂存 */ uint8_t rx_buf[RX_BUF_SIZE]; /* 环形缓冲区 */ volatile uint16_t rx_head 0; /* 写入位置 */ volatile uint16_t rx_tail 0; /* 读取位置 */ /* 启动接收每次只收一个字节 */ void RS485_StartReceive(void) { HAL_UART_Receive_IT(huart2, rx_byte, 1); } /* 接收完成回调HAL 库自动调用 */ void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART2) { uint16_t next (rx_head 1) % RX_BUF_SIZE; if (next ! rx_tail) { /* 缓冲区未满才写入 */ rx_buf[rx_head] rx_byte; rx_head next; } /* 重新启动接收否则只能收一个字节 */ HAL_UART_Receive_IT(huart2, rx_byte, 1); } }逻辑说明环形缓冲区用 head 和 tail 两个指针head 是中断里写的tail 是主循环里读的两者不冲突。缓冲区大小 256 对 Modbus 足够因为 Modbus RTU 单帧最大 256 字节。参数上HAL_UART_Receive_IT每次只收 1 字节中断频率高但逻辑简单如果波特率上到 115200 以上中断开销会明显这时候考虑 DMA。3.2 DMA 接收 空闲中断高波特率下的省心方案DMA 接收配合串口空闲中断IDLE是工控里最常见的组合。DMA 把收到的字节自动搬进缓冲区CPU 不用管总线空闲时触发 IDLE 中断在中断里算出这一帧收了多少字节。#define DMA_RX_SIZE 256 uint8_t dma_rx_buf[DMA_RX_SIZE]; volatile uint16_t frame_len 0; /* 本帧实际长度 */ volatile uint8_t frame_ready 0; /* 帧接收完成标志 */ /* 启动 DMA 接收和空闲中断 */ void RS485_StartDMAReceive(void) { __HAL_UART_ENABLE_IT(huart2, UART_IT_IDLE); /* 使能空闲中断 */ HAL_UART_Receive_DMA(huart2, dma_rx_buf, DMA_RX_SIZE); } /* 空闲中断处理放在 stm32fxxx_it.c 的 USART2_IRQHandler 里 */ void USART2_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart2, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart2); /* 停止 DMA 才能读出已接收长度 */ HAL_UART_DMAStop(huart2); frame_len DMA_RX_SIZE - __HAL_DMA_GET_COUNTER(huart2.hdmarx); frame_ready 1; /* 重新启动准备下一帧 */ HAL_UART_Receive_DMA(huart2, dma_rx_buf, DMA_RX_SIZE); } HAL_UART_IRQHandler(huart2); }逻辑说明__HAL_DMA_GET_COUNTER返回 DMA 剩余未搬运的字节数用总长度减去它就是已接收长度。注意必须先HAL_UART_DMAStop再读计数器否则计数器还在变。参数上DMA 缓冲区 256 字节Modbus 单帧不会超过这个值。IDLE 中断的触发条件是总线空闲一个字符时间这正好和 Modbus 的帧间隔判定天然契合所以 DMA IDLE 是 Modbus RTU 接收的最优解。3.3 方向切换的时机发送完成中断里切方向切换的时机前面提过必须等 TC 标志。用中断发送时在HAL_UART_TxCpltCallback里切方向脚最稳妥。/* 发送完成回调在这里切回接收态 */ void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART2) { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_RESET); /* 切回接收 */ RS485_StartDMAReceive(); /* 重新开启接收 */ } } /* 发送一帧数据 */ void RS485_SendFrame(uint8_t *data, uint16_t len) { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_SET); /* 切到发送 */ HAL_UART_Transmit_IT(huart2, data, len); /* 中断发送 */ }逻辑说明发送前拉高方向脚调用中断发送发送完成后在回调里拉低方向脚并重启接收。这样整个收发切换是自动的主循环只管组帧和解析。参数上HAL_UART_Transmit_IT不需要超时参数因为它是非阻塞的。注意发送期间不要调用接收启动函数否则会冲突。4. 定时器做 3.5 字符帧间隔判定Modbus RTU 的帧边界怎么算Modbus RTU 靠时间间隔划分帧边界帧内字符间隔不超过 1.5 个字符时间帧间间隔至少 3.5 个字符时间。这个判定用定时器做最准用 HAL_Delay 做必翻车。4.1 3.5 字符时间的计算方法一个字符在串口上占多少时间取决于波特率和帧格式。标准 Modbus RTU 是 1 起始位 8 数据位 1 校验位 1 停止位 11 位。所以1 字符时间 11 / 波特率 3.5 字符时间 3.5 * 11 / 波特率9600 波特率下1 字符约 1.146ms3.5 字符约 4.01ms。115200 波特率下3.5 字符约 334us。注意有些资料按 10 位算无校验结果会偏小帧判定就会把两帧粘在一起。我一般按 11 位算留点余量。波特率1 字符时间3.5 字符时间定时器计数值(50us基准)96001.146ms4.01ms8019200573us2.00ms4038400286us1.00ms2011520095us334us74.2 定时器中断里做帧超时判定思路是每收到一个字节就重置定时器计数如果定时器溢出说明超过 3.5 字符时间没有新字节就认为一帧结束。volatile uint16_t frame_gap_cnt 0; /* 帧间隔计数 */ volatile uint8_t frame_end 0; /* 帧结束标志 */ /* 每收到一个字节调用此函数重置帧间隔计数 */ void RS485_FeedFrameTimer(void) { frame_gap_cnt 0; } /* 定时器溢出回调50us 一次 */ void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM6) { if (frame_gap_cnt 0xFFFF) { frame_gap_cnt; } /* 9600 波特率下 80 次 * 50us 4ms达到 3.5 字符时间 */ if (frame_gap_cnt FRAME_GAP_THRESHOLD) { frame_end 1; frame_gap_cnt 0; } } }逻辑说明FRAME_GAP_THRESHOLD按波特率查表取值9600 取 80115200 取 7。每收到一个字节在接收回调里调用RS485_FeedFrameTimer清零计数。主循环检测到frame_end置位就把缓冲区里的数据交给 Modbus 解析。参数上定时器基准 50us 是权衡太小中断频繁太大判定精度不够。115200 下 7 次 * 50us 350us和 334us 接近够用。4.3 帧判定和 DMA 空闲中断的取舍DMA IDLE 方案里IDLE 中断本身就代表总线空闲一个字符时间但 Modbus 要求 3.5 字符所以 IDLE 触发后还要再等 2.5 个字符时间才能确认帧结束。实际工程里两种做法一是纯定时器方案每字节重置计数溢出即帧结束逻辑清晰但每字节进中断。二是 DMA IDLE 先收再用定时器延时确认。我一般用第一种因为 Modbus 波特率通常不高9600/19200中断开销可以接受逻辑简单不容易出错。如果波特率上到 115200 且从机多用第二种。提示帧间隔判定是 Modbus RTU 最容易被忽略的环节。很多能通但偶尔丢帧的问题根源就是帧间隔算错两帧粘在一起被当成一帧解析CRC 校验失败。5. Modbus RTU 主从机实现与联调从组帧到 CRC 校验协议层是最后一块拼图。Modbus RTU 帧格式简单地址 功能码 数据 CRC16。主机发请求从机回响应。这一章把组帧、解析、CRC 和主从联调讲透。5.1 Modbus RTU 帧结构与 CRC16 查表实现请求帧读保持寄存器功能码 0x03从机地址(1) 功能码(1) 起始地址(2) 寄存器数量(2) CRC(2)。响应帧从机地址(1) 功能码(1) 字节数(1) 数据(N) CRC(2)。CRC16 用查表法最快表是固定的 256 项。/* CRC16 查表多项式 0xA001Modbus 标准 */ static const uint16_t crc_table[256] { 0x0000, 0xC0C1, 0xC181, 0x0140, /* ... 完整表省略实际工程需补全 256 项 ... */ }; 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; }逻辑说明CRC 初值 0xFFFF逐字节异或查表。发送时 CRC 低字节在前、高字节在后这是 Modbus 规定。参数上len是不含 CRC 的字节数。校验时把整帧含 CRC算一遍结果为 0 说明正确。5.2 主机轮询状态机主机不能死等响应要用状态机。状态空闲 → 发送请求 → 等待响应 → 超时/解析 → 下一个从机。typedef enum { MASTER_IDLE, MASTER_SEND, MASTER_WAIT, MASTER_TIMEOUT } master_state_t; master_state_t master_state MASTER_IDLE; uint8_t current_slave 1; /* 当前轮询的从机地址 */ uint32_t wait_start_tick 0; /* 等待响应起始时刻 */ void Modbus_Master_Poll(void) { switch (master_state) { case MASTER_IDLE: /* 组请求帧读从机 current_slave 的 0x0000 起 2 个寄存器 */ BuildReadRequest(current_slave, 0x0000, 2); RS485_SendFrame(tx_frame, tx_len); wait_start_tick HAL_GetTick(); master_state MASTER_WAIT; break; case MASTER_WAIT: if (frame_end) { /* 收到完整帧 */ frame_end 0; if (ParseResponse(dma_rx_buf, frame_len) 0) { /* 解析成功处理数据 */ } current_slave (current_slave % 247) 1; /* 下一个从机 */ master_state MASTER_IDLE; } else if (HAL_GetTick() - wait_start_tick 200) { /* 200ms 无响应判超时 */ current_slave (current_slave % 247) 1; master_state MASTER_IDLE; } break; default: master_state MASTER_IDLE; break; } }逻辑说明状态机在主循环里轮询不阻塞。HAL_GetTick提供毫秒级超时200ms 对 9600 波特率足够一帧最长约 256 字节 * 1.146ms ≈ 293ms实际常用帧远小于此200ms 够。参数上从机地址范围 1~2470 是广播248~255 保留。轮询间隔由状态机自然控制不用额外延时。5.3 从机响应与主从联调步骤从机收到请求后判断地址是否匹配匹配则解析功能码组响应帧发回。void Modbus_Slave_Process(uint8_t *frame, uint16_t len) { if (len 4) return; /* 最短帧 4 字节 */ if (frame[0] ! SLAVE_ADDR frame[0] ! 0) return; /* 地址不匹配 */ if (Modbus_CRC16(frame, len) ! 0) return; /* CRC 错误 */ uint8_t func frame[1]; if (func 0x03) { uint16_t start (frame[2] 8) | frame[3]; uint16_t count (frame[4] 8) | frame[5]; /* 组响应地址 功能码 字节数 数据 CRC */ BuildReadResponse(SLAVE_ADDR, start, count); RS485_SendFrame(tx_frame, tx_len); } /* 其他功能码类似处理 */ }逻辑说明从机先校验地址和 CRC再按功能码分支。响应帧的字节数是寄存器数量乘 2。参数上SLAVE_ADDR是宏定义的从机地址广播地址 0 不响应。联调步骤先用串口调试助手模拟从机手动发响应帧验证主机轮询逻辑再用两块 STM32 板一块跑主机一块跑从机用示波器看 A/B 差分波形确认方向切换时机最后接真实传感器用 Modbus 调试工具如 Modbus Poll交叉验证。每一步都单独验证别一次全接上出问题定位困难。6. 避坑与排查RS485 Modbus 联调中最容易翻车的 5 个点这一章全是踩坑记录每条按现象、原因、解决写。这些坑我在不同项目里反复遇到新手基本躲不过。现象一主机发出去没响应示波器看 A/B 有波形但幅值很小。原因方向脚没拉高收发器一直处于接收态DI 上的数据没驱动到总线。 解决检查方向脚 GPIO 初始化电平和发送前是否拉高。用万用表量 DE 脚电压发送时应该是高电平。现象二接收端永远少最后一个字节。原因用 TXE 标志判断发送完成就切方向脚最后一个字节还在移位寄存器里。 解决改用 TC 标志或者用发送完成中断回调里切方向。前面 2.3 节代码里已经强调过。现象三偶尔收到乱码CRC 校验随机失败。原因总线空闲时差分电平不稳定从机收到随机起始位或者终端电阻没接、接多了。 解决加偏置电阻稳定空闲电平检查终端电阻只在总线两端各一个中间节点不能接。现象四两帧数据粘在一起被当成一帧解析。原因帧间隔判定阈值算错按 10 位算字符时间而不是 11 位或者定时器基准太大导致判定精度不够。 解决按 11 位重算 3.5 字符时间定时器基准降到 50us 以下。用示波器量两帧之间的实际间隔和理论值对比。现象五多从机轮询时某个从机响应慢导致整个轮询卡住。原因主机用阻塞式发送或死等响应一个从机不响应就拖垮整个周期。 解决用状态机 超时机制每个从机等待响应设独立超时200ms 左右超时直接跳到下一个从机。前面 5.2 节的状态机就是干这个的。注意RS485 总线上所有节点的波特率、校验位、停止位必须完全一致。现场调试时遇到过从机波特率是 9600 但校验位是偶校验主机按无校验发结果从机收到的帧全部 CRC 错误。这种问题用示波器看不出来只能逐项核对串口参数。7. 进阶技巧用 DMA 定时器双缓冲把轮询周期压到最短前面讲的方案在 9600 波特率、几个从机的场景下够用。但如果从机数量上到几十个或者波特率要求 115200轮询周期就成了瓶颈。这一章讲一个我实际项目里用过的优化DMA 双缓冲接收 定时器精确帧判定把单帧处理时间压到最小。核心思路是接收用 DMA 双缓冲HAL 库的HAL_UARTEx_ReceiveToIdle_DMA支持一个缓冲区在收的时候另一个缓冲区在处理CPU 不用等。帧判定还是用定时器但阈值按波特率动态算不写死。#define BUF_SIZE 256 uint8_t rx_dma_buf[2][BUF_SIZE]; /* 双缓冲 */ volatile uint8_t active_buf 0; /* 当前 DMA 写入的缓冲区 */ volatile uint16_t recv_len[2] {0, 0}; /* 启动 DMA 双缓冲接收空闲事件触发回调 */ void RS485_StartDoubleBuffer(void) { HAL_UARTEx_ReceiveToIdle_DMA(huart2, rx_dma_buf[0], BUF_SIZE); /* 注意HAL 库双缓冲需要手动管理这里用两个独立启动模拟 */ } /* 空闲事件回调HAL 库在收到 IDLE 时调用 */ void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart-Instance USART2) { recv_len[active_buf] Size; /* 切换到另一个缓冲区继续收 */ active_buf ^ 1; HAL_UARTEx_ReceiveToIdle_DMA(huart2, rx_dma_buf[active_buf], BUF_SIZE); /* 通知主循环处理 recv_len[active_buf ^ 1] 对应的数据 */ frame_ready 1; } }逻辑说明HAL_UARTEx_ReceiveToIdle_DMA在收到 IDLE 或缓冲区满时触发HAL_UARTEx_RxEventCallbackSize参数就是本帧长度。切换缓冲区后立即重启接收处理上一帧和接收下一帧并行。参数上双缓冲各 256 字节对 Modbus 足够。注意 HAL 库的双缓冲不是真正的硬件双缓冲是靠切换缓冲区指针模拟的切换期间有极短的空窗115200 下约几十微秒实际测试不影响 Modbus 帧接收。动态帧间隔阈值这样算/* 根据波特率动态计算 3.5 字符时间的定时器计数值 */ uint16_t CalcFrameGapThreshold(uint32_t baudrate, uint32_t timer_tick_us) { /* 11 位/字符3.5 字符单位微秒 */ uint32_t gap_us (uint32_t)(3.5f * 11.0f * 1000000.0f / baudrate); return (uint16_t)(gap_us / timer_tick_us); }逻辑说明gap_us是 3.5 字符时间的微秒值除以定时器基准微秒数得到计数值。参数上timer_tick_us是定时器溢出周期比如 50us。这个函数在初始化时调用一次把结果存全局变量定时器中断里用。验证方法用逻辑分析仪抓 A/B 差分信号量两帧之间的实际间隔和gap_us对比误差在 10% 以内算合格。再用 Modbus Poll 连续轮询 1 小时统计丢帧率正常应该为 0。如果丢帧先查帧间隔阈值再查方向切换时机。我自己的习惯是每做一个新的 RS485 项目先在面包板上把收发器和 MCU 连起来用串口助手手动发几帧确认物理层和方向切换没问题再写协议代码。这个习惯帮我省了至少一半的调试时间。RS485 这东西硬件上一个小疏忽软件调三天都找不出来。希望帮到你。本文还有配套的精品资源点击获取