
简介基于STM32 HAL库的Modbus协议主机与从机通信测试工程适合嵌入式开发者及工业自动化方向学习者参考解决RS485总线场景下Modbus RTU主从通信的代码实现与验证问题。工程共166个文件压缩包约8.04MB包含51个h头文件、24个c源码文件、25个o及24个d等编译中间产物、hex可执行文件、uvprojx工程配置文件等便于核对编译链接过程与烧录测试。已有1824人学习下载。资源内含三部分独立测试主机读取从机数据、主机向从机单寄存器写入数据、本设备作为从机地址0x02使用覆盖常见主从交互流程。配合定时器与串口中断实现并附有MAP、LST、AXF等调试文件可帮助快速理解HAL库下Modbus协议栈的移植方法也可当作模板直接改造复用。1. STM32 HAL 库 Modbus RTU 跑在 RS485 上先想清楚主从时序工业现场里Modbus RTU 是 PLC、变频器、电表、传感器默认都会讲的一种协议语言RS485 是它的物理载体半双工、一主多从。用 STM32 的 HAL 库做这套东西时真正难的不是协议本身而是三件事RS485 收发方向怎么切、一帧数据什么时候算收完、主机等不到应答怎么办。这三件事全部依赖定时器和串口中断的配合任何一个环节时序不对就会表现出串口中断接收只收一次偶发收到错误帧CRC 校验永远不过这类现象。这篇文章从物理层到协议层再到主机从机联调给出一套可以在 STM32F103C8T6 上直接跑通的最小验证方案。先说一个反直觉的结论Modbus 联调时九成问题不在协议解析代码而在 3.5T 定时和 485 方向切换的时序上。2. RS485 自动收发电路与串口参数物理层决定 Modbus 能不能通2.1 RS485 差分信号与半双工特性RS485 用 A、B 两根线之间的差分电压传输数据A 相对 B 高于 200mV 表示逻辑 1空闲态低于 -200mV 表示逻辑 0。接收端判断的是差值而不是对地绝对电压所以共模范围能做到 -7V 到 12V抗干扰能力比 TTL 串口强一个量级这也是它能承担几十米到上千米线缆的原因。要区分 RS485 与 Modbus 的关系RS485 只解决电平怎么传Modbus 解决字节怎么组织、谁先说话前者是物理层后者是应用层协议两者经常一起出现但不是一个东西。RS485 是半双工总线同一时刻要么发要么收多节点挂在同一对 A/B 线上靠地址区分应答方。组网时要关注 unit load单元负载概念标准 MAX485 是一个 unit load常规总线最多挂 32 个节点选 1/4 负载的芯片可以扩到 128 个。线缆两端各接一个 120Ω 终端电阻中间节点不接否则信号反射会在高速率下表现为偶发错帧。判断接线是否规范断电后量 A/B 之间阻值正常约 60Ω说明两个终端电阻都在位。2.2 自动收发电路与 GPIO 控制 DE/RE 的取舍485 收发芯片以 MAX485/SP3485 为例的 DE 和 RE 引脚通常直接连在一起接高电平进发送模式接低电平进接收模式。方向控制有两种做法。第一种用 GPIO 控制 DE时序完全可控任意波特率都可以用也是调试阶段最推荐的方式#define RS485_DE_SET() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_8, GPIO_PIN_SET) #define RS485_DE_CLR() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_8, GPIO_PIN_RESET) void modbus_uart_send(uint8_t *buf, uint16_t len) { RS485_DE_SET(); /* 先切到发送方向 */ HAL_Delay(1); /* DE 建立时间至少 1 个字符时长 */ HAL_UART_Transmit(huart1, buf, len, 100); /* 阻塞发送 */ while (__HAL_UART_GET_FLAG(huart1, UART_FLAG_TC) RESET); RS485_DE_CLR(); /* 等移出完成后拉低回接收态 */ }这里最容易被忽视的是 HAL_UART_Transmit 返回后数据可能还在 TDR 里没完全移出移位寄存器如果立刻拉低 DE最后一个字节会被截断从机侧看到的就是帧尾 CRC 少一字节。所以必须等 TCTransmission Complete标志再切方向。HAL_Delay(1) 只在 DE 切换速率跟不上时才需要波特率越低这个建立时间越要留足。第二种是自动收发电路TXD 空闲时为高电平三极管导通把 DE 拉低处于接收态TXD 起始位变低时三极管截止DE 被上拉为高进入发送态。好处是省一个 GPIO代价是方向切换受三极管和 RC 网络开关时间影响波特率超过 19200 或帧长较长时帧头帧尾可能被切掉。两种方案对比见表 2-1。方案元件数量时序可控性适用场景GPIO 控制 DE0完全可控精确到字节任意波特率调试期首选三极管自动收发2~3 个受开关时间影响≤19200bps、短帧带方向管理芯片0芯片内部处理量产场景如果不想自己搭三极管电路可以直接选 MAX13487E 这类带自动方向管理的收发芯片切换逻辑在芯片内部完成主控完全不用碰 DE。2.3 串口参数与 HAL 初始化要点Modbus RTU 常用参数组合是 9600 8N1、9600 8E1、19200 8N1即 8 数据位、无校验或偶校验、1 个停止位。CubeMX 里配置 USART1 异步模式时对应到 HAL 的结构体参数是这样的huart1.Init.BaudRate 9600; /* 典型起始波特率跑通后再提速 */ huart1.Init.WordLength UART_WORDLENGTH_8B; /* 8 个数据位 */ huart1.Init.StopBits UART_STOPBITS_1; /* 1 个停止位 */ huart1.Init.Parity UART_PARITY_NONE; /* 无校验也可选 UART_PARITY_EVEN */注意 HAL 库里 Parity 与 WordLength 存在联动选 UART_PARITY_NONE 时数据位是 8一旦选 UART_PARITY_EVEN硬件会把有效数据位降为 7 位再加 1 位校验位CubeMX 界面上 WordLength 会跟着变。主机与从机的串口参数必须完全一致校验位不一致时双方能收到字节但 CRC 永远算不对。另外在 CubeMX 的 SYS 页面要把 Debug 选成 Serial Wire否则 ST-LINK 下载一次后会出现 error: no stm32 target found! 的经典报错。上位机这一端USB 转 485 调试器主控芯片常见的是 CH340 和 FTDI 两种。CH340 要先装驱动设备管理器里能看到虚拟 COM 口如果出现黄色感叹号先换 USB 线和接口排除供电问题再重装驱动版本。提示调试阶段建议主机和从机先统一用 9600 8N1 跑通再改偶校验或更高波特率避免同时排查两个变量。2.4 接线与共地A 对 A、B 对 B 是铁律。市面上转换器标注有 A/B、485/485-、D/D- 多种写法不好判断时上电量空闲态电压A 相对 B 为正且差值在 1.55V 左右通常就是标准空闲态。另一个高频翻车点是共地节点距离较远时必须在设备之间拉一根信号地SGND否则共模电压超限轻则通信时好时坏重则烧掉收发芯片。总线拓扑尽量走手拉手主线避免星型和 T 型分支分支过长会在线缆上产生驻波。3. Modbus RTU 帧格式与 CRC16 实现先于串口把协议栈写好3.1 帧结构与功能码Modbus RTU 帧按地址(1B) 功能码(1B) 数据(NB) CRC16(2B)排列CRC 低字节在前、高字节在后。地址 0 是广播地址从机收到后执行但不应答1247 是有效从机地址。表 3-1 列出最常用的功能码做变频器或伺服通讯时主要用到 0x03、0x06、0x10 这几个。功能码名称读/写典型用途0x01读线圈读开关量采集0x02读离散输入读状态量采集0x03读保持寄存器读读参数、读测量值0x04读输入寄存器读只读传感器数据0x05写单个线圈写单点控制0x06写单个寄存器写写单个参数0x10写多个寄存器写批量下装参数0x03 和 0x04 的区别在于保持寄存器可读可写输入寄存器只读。伺服驱动器的速度、电流这类运行参数一般在保持寄存器区用 0x03 读、0x06/0x10 写温度采样、编码器计数可能落在输入寄存器区用 0x04 读。从机收到不支持的功能码时回 0x80 或上原功能码数据区放异常码0x01 非法功能、0x02 非法数据地址、0x03 非法数据值。如果项目后续要和上位机走以太网Modbus TCP 只是把这里的 RTU 头换成 MBAP 头并去掉 CRC 域功能码和数据区完全一致寄存器地址定义可以整体复用。3.2 CRC16 逐位法实现与验证Modbus RTU 的 CRC16 使用多项式 0x8005 的反射形式 0xA001初值 0xFFFF计算过程中不取反。逐位实现最直观代码量小、好移植uint16_t modbus_crc16(const uint8_t *buf, uint16_t len) { uint16_t crc 0xFFFF; while (len--) { crc ^ *buf; for (int i 0; i 8; i) { if (crc 0x0001) crc (crc 1) ^ 0xA001; /* LSB 为 1 时与多项式异或 */ else crc 1; } } return crc; }逻辑是每字节先异或进 CRC 的低字节再按 LSB-first 逐位移位。发送时先发 crc 0xFF再发 crc 8。接收端校验更省事的是把整帧含 CRC 两字节重新算一遍结果应为 0。用 01 03 00 00 00 04 这 6 个字节验证算法算出的 CRC 应为 0x0944线上字节顺序是 44 09。逐位法在 72MHz 主频下算一个 8 字节帧只要几十微秒对 Modbus 的轮询频率完全够用。只有从机数量多、单轮要读几百个寄存器时才值得换成查表法预生成 256 项的高低位表每字节做两次查表和两次异或耗时降到逐位法的八分之一左右。判断是否需要优化看主循环周期够不够用而不是拍脑袋上表。3.3 帧解析框架与串口解耦把解析写成纯函数输入内存缓冲、输出解析结果不直接操作寄存器这样同一套逻辑在主机和从机两端都能用也可以在 PC 上做单元测试。最小框架如下typedef struct { uint8_t addr; /* 从机地址 */ uint8_t func; /* 功能码 */ uint8_t data[220]; /* 数据区 */ uint16_t data_len; } modbus_pdu_t; int modbus_parse_frame(const uint8_t *buf, uint16_t len, modbus_pdu_t *pdu) { if (len 4) return -1; /* 最短帧地址功能码CRC 两字节 */ if (modbus_crc16(buf, len) ! 0) return -2; /* CRC 不过直接拒收 */ pdu-addr buf[0]; pdu-func buf[1]; pdu-data_len len - 4; for (int i 0; i pdu-data_len; i) pdu-data[i] buf[2 i]; return 0; }返回的 -1 和 -2 是联调时的排查抓手频繁返回 -1说明帧被切断或长度统计错误优先查 3.5T 定时返回 -2 说明字节收齐但内容坏了优先查串口参数一致性和是否混入了本机发送的回环数据。CRC 出错的隐蔽来源之一是定时器判断帧结束的间隔小于 3.5T把一个请求在字节中间切断后半段被当成新帧下一轮拼帧时长度永远对不上。这个问题在下一章展开。4. 用定时器卡 3.5T 字符间隔与应答超时串口中断与 HAL 定时器配合4.1 为什么 Modbus RTU 必须依赖定时器RS485 总线上没有帧头帧尾标记Modbus RTU 靠时间间隔切帧一帧内相邻字节间隔不能超过 1.5T一帧结束后总线静默 3.5T 以上才能开始下一帧。工程实现里一般只用一个 3.5T 定时器同时充当字节间隔和帧结束判据简单可靠严格的 1.5T 转发间隔只在网关透传两个 Modbus 网段时才需要单独实现。3.5T 时长按字符位数计算。9600 8N1 下一个字符是 1 起始位 8 数据位 1 停止位共 10 位1 字符时间约 1.042ms3.5T 约 3.646ms。表 4-1 给出常用波特率下的数值。波特率 (bps)1 字符时间 (ms)1.5T (ms)3.5T (ms)96001.0421.5633.646192000.5210.7811.823576000.1740.2600.6071152000.0870.1300.3049600 波特率时 3.5T 超过 3.6ms定时器用 1ms 节拍就够115200 时只有 0.3ms节拍最好做到 0.1ms 量级否则误差占比太大。另一个隐藏风险是主频偏差晶振偏差超过 2% 时中长帧会在停止位附近出错典型表现是收一帧正常、下一帧 CRC 就错这类问题用示波器量位宽比改代码更容易定位。4.2 HAL 定时器配置与重装值计算以 STM32F103 的 72MHz 定时器时钟为例把 TIM3 配成 0.1ms 节拍9600 波特率下 ARR 取 40对应 4.0ms略大于 3.5T 理论值留一点裕量避免噪声提前切帧void MX_TIM3_Init(void) { htim3.Instance TIM3; htim3.Init.Prescaler 7199; /* 72MHz/(71991) 10kHz即 0.1ms 一节拍 */ htim3.Init.CounterMode TIM_COUNTERMODE_UP; htim3.Init.Period 40; /* 40 × 0.1ms 4ms适配 9600 的 3.5T */ htim3.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; HAL_TIM_Base_Init(htim3); }Prescaler 和 Period 的单位都是计数个数实际周期 (PSC1) × (ARR1) / 定时器时钟。改波特率时只需改 Period19200 取 202.0ms115200 取 40.4ms。定时器中断里只置标志位不要在中断里跑 CRC 或组帧把协议处理放到主循环避免长时间关中断影响串口接收。应答超时可以和 3.5T 复用同一个定时器用不同模式区分收帧时每收到一字节清零重新计时主机发完请求后不清零让它继续走到设定的超时值比如 100ms就置应答超时标志。两种用法靠一个状态变量区分避免开两个定时器增加配置负担。4.3 串口中断里每收一字节就喂一次定时器接收方向用 HAL 的单字节接收中断每字节进入回调拷进缓冲区并重置定时器计数这就是俗称的喂狗式收帧uint8_t rx_byte; uint8_t rx_buf[256]; volatile uint16_t rx_len 0; volatile uint8_t frame_ready 0; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { if (rx_len sizeof(rx_buf)) rx_buf[rx_len] rx_byte; /* 先收进缓冲区 */ __HAL_TIM_SET_COUNTER(htim3, 0); /* 清零计数重新开始 3.5T 计时 */ HAL_TIM_Base_Start_IT(htim3); /* 续启定时器中断 */ HAL_UART_Receive_IT(huart, rx_byte, 1); /* 重新挂单字节接收否则只收一次 */ } } void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM3) { HAL_TIM_Base_Stop_IT(htim3); /* 停定时器等下一帧首字节再启动 */ if (rx_len 0) { frame_ready 1; /* 一帧到齐主循环去解析 */ } } }最典型的坑就是回调末尾的 HAL_UART_Receive_IT 被漏掉。HAL 的中断接收是一次性的不重新挂接收中断只触发一次现象就是串口中断接收只收一次第一帧之后再无数据。另一个细节主循环处理完一帧后要把 rx_len 清零否则下一帧字节会拼在旧数据后面CRC 必然不过。多串口项目里回调会被所有 UART 实例共享判断实例时用 huart-Instance USART1 加 huart huart1 双重确认更稳。注意HAL_TIM_PeriodElapsedCallback 里不要调用 HAL_UART_Transmit发送逻辑留在主循环状态机里避免中断上下文与主循环同时操作发送缓冲区造成竞态。有人会想用串口空闲中断 IDLE 代替定时器。STM32 的 IDLE 标志在总线空闲大约一个字符帧时长后置位比 3.5T 短得多。从机应答慢或字节间隔接近 1.5T 的场合IDLE 会把一帧切成两半。IDLE 适合数据流长、帧尾特征明显的私有协议做 Modbus 还是按标准走定时器方案更稳。5. 主机和从机最小实现把应答与轮询都写成状态机5.1 从机端地址过滤、CRC 校验、功能码分发从机以第 4 章的 frame_ready 标志为处理起点固定流程是地址过滤、CRC 校验、功能码分发三步。地址不符或 CRC 不过都静默丢弃不回任何数据这是 Modbus 从机的标准行为#define MB_SLAVE_ADDR 0x01 #define MB_REG_COUNT 16 uint16_t holding_reg[MB_REG_COUNT]; /* 保持寄存器堆 */ void modbus_slave_poll(void) { modbus_pdu_t pdu; if (!frame_ready) return; frame_ready 0; if (rx_buf[0] ! MB_SLAVE_ADDR rx_buf[0] ! 0x00) return; /* 地址不符静默丢弃 */ if (modbus_crc16(rx_buf, rx_len) ! 0) return; /* CRC 不过静默丢弃 */ if (modbus_parse_frame(rx_buf, rx_len, pdu) ! 0) return; switch (pdu.func) { case 0x03: modbus_slave_read_holding(pdu); break; case 0x06: modbus_slave_write_single (pdu); break; default: modbus_slave_exception (pdu, 0x01); break; } rx_len 0; /* 清帧准备收下一帧 */ }0x03 读保持寄存器的处理要按大端序填充寄存器值高字节先发地址越界时回异常 0x83 0x02static void modbus_slave_read_holding(modbus_pdu_t *pdu) { uint16_t start (pdu-data[0] 8) | pdu-data[1]; uint16_t count (pdu-data[2] 8) | pdu-data[3]; uint8_t resp[64]; if (count 0 || count 64 || start count MB_REG_COUNT) { modbus_slave_exception(pdu, 0x02); /* 非法数据地址 */ return; } resp[0] MB_SLAVE_ADDR; resp[1] 0x03; resp[2] count * 2; /* 数据字节数 寄存器数 × 2 */ for (int i 0; i count; i) { resp[3 i * 2] holding_reg[start i] 8; /* 高字节在前 */ resp[4 i * 2] holding_reg[start i] 0xFF; } uint16_t crc modbus_crc16(resp, 3 count * 2); resp[3 count * 2] crc 0xFF; resp[4 count * 2] crc 8; modbus_uart_send(resp, 5 count * 2); }modbus_slave_exception 的组帧与上面相同只是功能码变成 0x80 | 原功能码数据区放一个异常码帧长固定 5 字节。从机在应答后要马上把 rx_len 清零并重新挂接收中断因为 485 是半双工自己发送期间总线上还残留回波不清零的话回波会被当成下一帧。5.2 主机端轮询、等待应答与超时重试主机端的难点不是组帧而是管好发请求、等应答、超时重发的时序。用枚举状态机最直观主循环每几十毫秒调一次不阻塞其他任务typedef enum { MB_IDLE, MB_WAIT_RESP, MB_HANDLED, MB_RETRY } mb_master_state_t; volatile uint8_t master_timeout; /* 由毫秒计数或定时器回调置位 */ void modbus_master_poll(void) { static mb_master_state_t state MB_IDLE; static uint8_t retry_cnt 0; switch (state) { case MB_IDLE: { uint8_t req[8] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x04 }; uint16_t crc modbus_crc16(req, 6); req[6] crc 0xFF; req[7] crc 8; modbus_uart_send(req, 8); /* 读 1 号从机 0x0000 起始 4 个寄存器 */ state MB_WAIT_RESP; retry_cnt 0; break; } case MB_WAIT_RESP: if (frame_ready) { /* 收到一帧可能是正常应答或异常帧 */ frame_ready 0; if (rx_buf[0] 0x01 modbus_crc16(rx_buf, rx_len) 0) state MB_HANDLED; else state MB_RETRY; } else if (master_timeout) { /* 超过应答超时时间 */ master_timeout 0; state MB_RETRY; } break; case MB_HANDLED: /* 解析 rx_buf 更新本地镜像进入下一轮 */ state MB_IDLE; break; case MB_RETRY: if (retry_cnt 3) { HAL_Delay(50); /* 退避后再重发避免总线碰撞 */ state MB_IDLE; } else { retry_cnt 0; /* 连续失败置故障标志并换下一从机 */ state MB_IDLE; } break; } }主机端有两个时间概念必须分开3.5T 用于判断一帧收完靠字节喂狗应答超时用于判断从机没回从请求发送完成那一刻开始计时。应答超时建议设 50200ms参考总线上最慢从机的响应能力。变频器、伺服驱动器响应通常在 10ms 量级仪表、网关可能到 100ms。超时设太短会出现主机重发而从机应答还在路上的情况两帧叠在总线上双方 CRC 都过不了。提示超时标志由 1ms 系统节拍或定时器回调置位不要在关中断的发送代码里去轮询它避免丢节拍。5.3 多从机轮询与广播写一主多从时把 5.2 的固定请求数组改成查表生成设备表每行保存从机地址、寄存器起始、寄存器数量、轮询周期主循环按上次访问时刻判断是否轮到该设备依次完成读操作。顺序轮询的间隔要大于从机最长响应时间与 3.5T 之和否则总线空闲不足从机端 3.5T 判定会串帧。广播写地址 0x00适合时间同步和联动启停广播帧所有从机都要执行但不应答所以主机发完广播帧后直接进入下一轮不走等待应答分支。6. 联调验证三板斧串口助手抓帧、Modbus 调试软件对跑、逻辑分析仪看时序6.1 抓总线字节确认两端收发完全一致用一个 USB 转 RS485 调试器并联到 A/B 总线上串口调试助手开 HEX 显示。第一步电脑作为主机发 01 03 00 00 00 04 44 09看 STM32 从机是否回 01 03 08 开头、共 13 字节的应答帧第二步STM32 作为主机电脑装 Modbus Slave 模拟从机反向确认 STM32 发出的帧与助手里收到的字节完全一致。电脑能通但 STM32 不通时优先检查 DE 方向切换和回环数据污染而不是怀疑协议逻辑。6.2 用逻辑分析仪量 3.5T 和 DE 波形把逻辑分析仪通道夹在 485 芯片的 RO 引脚和 DE 引脚或直接跨接 A/B 线看差分波形量两个关键点。第一帧与帧之间的静默间隔是否接近 3.5T9600 时应约 3.6ms间隔明显偏小说明切帧偏早帧尾可能被切断。第二发送期间 DE 高电平是否完整覆盖整帧且无毛刺DE 在帧尾提前变低基本可以断定 modbus_uart_send 里漏了等 TC 标志。A/B 线波形振铃明显时检查总线两端是否都接了 120Ω 终端电阻以及分支线长度是否过长。6.3 常见故障速查现象优先检查收不到任何数据A/B 是否反接、共地是否接、CH340 驱动是否装好串口中断接收只收一次回调末尾是否重新调用 HAL_UART_Receive_IT帧长正确但 CRC 总错串口参数是否一致、3.5T 定时是否把帧切断应答偶发丢失DE 切换时序、总线上是否有两个节点同时发送高速率下波形振铃终端电阻、双绞线质量、分支长度联调时按表 6-1 从上往下查绝大多数问题在第三行之前就能定位剩下的振铃和丢帧问题回到终端电阻和 DE 波形的排查上用逻辑分析仪量一轮发送周期基本都能看到根因。本文还有配套的精品资源点击获取