
简介STM32 MODBUS主机程序是一套基于标准外设库的完整工程面向需要实现工业串行通信的嵌入式开发者。工程让STM32作为MODBUS网络主站主动发起读保持寄存器、写单个寄存器等请求并处理从站返回的数据与异常适用于PLC互联、传感器数据采集、设备远程控制等场景。压缩包共160个文件以41个.h头文件和39个.c源码文件为核心另含编译生成的.o、.axf、.hex镜像、.uvprojx工程配置及.map映射文件等覆盖从编辑到烧录的完整链路整包仅3.41MB体量精简。已有1740人学习下载。程序代码涵盖MODBUS RTU帧封装与解析、CRC16校验、UART中断收发、超时与错误重试机制并对寄存器地址映射做了清晰注释工程按外设模块划分文件目录结构直观既适合MODBUS协议初学者对照源码理解主机时序也可作为基础框架直接移植到实际项目中节省协议栈开发时间。 做嵌入式这些年跟 Modbus 打交道的次数多得我自己都数不清。以前做项目时都是写从机程序也就是被动等主机来问直到接手一台老设备改造要把 STM32 直接接进现有上位机系统才发现主机程序写起来完全是另一回事。这篇文章就是基于我实际调通的 STM32 Modbus 主机程序把我踩过的坑、梳理过的思路、调试的方法都整理出来。如果你是第一次用 STM32 做 Modbus 主站或者写好了从机代码但不确定主机该怎么组织这篇文章刚好适合你。1. 主机方案的整体设计思路1.1 为什么你的 STM32 得做主机而不是从机很多初学者接触 Modbus 都是从“写一个从站让上位机来读”开始这很正常因为从站逻辑简单——收到请求、解析功能码、回复数据处理流程固定。但实际工控场景里真正承担“大脑”角色的往往是主站它决定什么时候去采集数据、什么时候下发控制指令、设备断了要不要重试。我这次的项目背景是一套老旧的温控与电机联动设备原来靠人工巡检记录数据领导要求接入数据采集系统做实时监控。由于现场没有现成的上位机做主站最省事的做法是直接在控制板上用 STM32 自己当 Modbus 主机按照设定周期去轮询各个从站设备温控表、变频器、电能表把拿到的数据汇总后再通过串口或者以太网上传给后台。所以 STM32 做主机的核心价值就是让单片机拥有主动访问外部设备的能力在没有 PC 的场合也能独立完成工业通信。1.2 协议选型RTU 还是 TCPModbus 协议本身分 RTU、ASCII、TCP 几种形态。做主机程序第一步不是写代码而是选对传输载体。如果从站设备都是 RS485 接口、分布在几十米甚至上百米范围内那基本就是 Modbus RTU 跑在 UARTRS485 上如果设备已经支持以太网接口或者要跨交换机传输那就选 Modbus TCP。经验上说工业现场 90% 的传感器、仪表、变频器还都是 RS485 接口所以这次我用的是 Modbus RTU。RTU 模式帧格式紧凑8 位数据、CRC16 校验效率高但对时序要求严格——一帧数据的字符间隔不能超过 1.5 个字符时间否则从站会认为帧结束了。STM32 的串口外设配合中断处理完全能胜任不需要特殊硬件。选型时还有一个考量从站设备的数量和分布。如果只有一两个设备直接点对点接线就行如果设备多了就要组 RS485 总线主机用轮询方式逐个访问总线上同一时刻只能有一个设备在发送数据。1.3 状态机思维别把主机程序写成一团乱麻我见过不少人写主机程序思路是“先发请求、等回复、处理结果”然后靠 delay 死等。这么做在简单场景下能跑一旦出现从站没应答、响应超时、数据出错代码很快就会变成一团乱麻。主机程序的正解是状态机驱动。我把整个通信过程划分为几个明确状态空闲IDLE、等待发送完成、等待接收响应、处理响应数据、超时重试或错误处理。主循环只要不断检查当前状态并按条件跳转逻辑就非常清晰。这样做不仅方便后期加功能排查问题的时候也能通过打印当前状态号快速定位。经过这段经历我现在做任何通信协议栈第一步都是先画状态转移关系代码反而是后面的事。2. 核心代码模块拆解与实现细节2.1 串口初始化参数必须跟从站严格保持一致Modbus RTU 通常运行在 9600 或 19200 波特率上8 个数据位无校验或者偶校验1 个停止位。这些参数要是不一致从站根本不会理你。用 STM32 标准库或者 HAL 库配置串口都非常简单重点在使能串口中断和 DMA。我习惯用“发送用阻塞/中断接收用中断一字节一字节收”的方式因为接收才是最容易出问题的地方。void Modbus_UART_Init(void) { // 以USART1为例波特率96008位数据无校验1停止位 UART_HandleTypeDef huart1; huart1.Instance USART1; huart1.Init.BaudRate 9600; huart1.Init.WordLength UART_WORDLENGTH_8B; huart1.Init.StopBits UART_STOPBITS_1; huart1.Init.Parity UART_PARITY_NONE; huart1.Init.Mode UART_MODE_TX_RX; huart1.Init.HwFlowCtl UART_HWCONTROL_NONE; HAL_UART_Init(huart1); // 接收中断每收到一个字节进入回调 HAL_UART_Receive_IT(huart1, rx_buffer[rx_index], 1); }2.2 收字节回调里的“拼帧”逻辑Modbus RTU 是以帧为单位的主机收到一串字节后要先判断这帧数据是否完整再判断地址是不是发给自己的、功能码是否正确、CRC 是否合法。所以接收中断函数里要做的不是处理业务而是把字节攒起来外加一个帧结束的判断。帧结束判断有两种常用方法一种是开一个定时器每收到一字节就重置定时器定时时间超过 3.5 个字符周期就认为帧结束还有一种更简单粗暴——按功能码和长度字段算总字节数收够了就处理。我的习惯是第一字节收到后启动超时定时定时时间一到就去解析缓冲区的数据。void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { rx_buffer[rx_index] rx_byte; // 重置帧超时定时器3.5字符时间9600≈4ms __HAL_TIM_SET_COUNTER(htim2, 0); // 继续接收下一字节 HAL_UART_Receive_IT(htim2, rx_byte, 1); } }这里有个实操细节9600 波特率下1 个字符约 1.04ms3.5 个字符时间约 3.6ms。我定时器直接用 5ms 作为帧超时阈值留了余量实测不管从站是 9600 还是 19200都能稳定判断帧边界。2.3 CRC16 校验两种写法都得信手拈来Modbus RTU 的 CRC16 是每个做 Modbus 开发的人都绕不过去的。它多项式是 0xA001初始值为 0xFFFF计算时低位在前。常见写法有两种按位循环计算和查表法。按位算的代码好理解适合学习查表法速度快适合频繁通信的场景。做主机程序我推荐用查表法因为主机往往要轮询多个从站一个周期内可能要做几十次 CRC 计算查表法几乎不占 CPU 时间。uint16_t Modbus_CRC16(uint8_t *data, uint16_t len) { uint16_t crc 0xFFFF; for (uint16_t i 0; i len; i) { crc ^ data[i]; for (uint8_t j 0; j 8; j) { if (crc 0x0001) crc (crc 1) ^ 0xA001; else crc 1; } } return crc; }注意 CRC 在组帧时是低字节在前发送的也就是先发 crc 0xFF再发 (crc 8) 0xFF。我见过有人计算完成后直接按高字节在前发结果从站一直不响应浪费了一整天排查。接收端校验的时候是把收到的整帧包括 CRC 字节重新计算一次 CRC结果是 0就代表校验通过。2.4 功能码 03/06/16 的组帧与解析主机最常用的几个功能码03 读保持寄存器、06 写单个寄存器、16 写多个寄存器。对于采集温度、转速、电压这类数据读保持寄存器 03 是主力对变频器启停、设定目标值这类控制操作写单个或多个寄存器是必须的。以读保持寄存器为例主机发送的请求帧格式是从站地址1字节 功能码 0x031字节 起始寄存器地址高字节 起始寄存器地址低字节 寄存器数量高字节 寄存器数量低字节 CRC 低字节 CRC 高字节。从站正常的响应帧则是地址 功能码 字节数 数据若干字节 CRC。解析响应时要先确认地址和功能码再检查字节数是否等于寄存器数量乘 2最后 CRC 校验通过才使用数据。uint8_t modbus_build_read_hold_regs(uint8_t addr, uint16_t start_reg, uint16_t reg_count) { tx_buffer[0] addr; tx_buffer[1] 0x03; tx_buffer[2] start_reg 8; tx_buffer[3] start_reg 0xFF; tx_buffer[4] reg_count 8; tx_buffer[5] reg_count 0xFF; uint16_t crc Modbus_CRC16(tx_buffer, 6); tx_buffer[6] crc 0xFF; tx_buffer[7] crc 8; return 8; }解析的时候有一个注意点从站返回的数据是寄存器值的高字节在前。如果从站存的是一个 32 位浮点数比如温度 -25.6°C通常会占两个寄存器那就要小心大小端拼接不同的仪表厂家处理方式还不一样。我踩过的坑是某温控表把浮点数按“低字在前、高字在后”存放跟说明书上写的不一致最后用 Modbus Slave 工具读了一遍原始数据才确认规律。2.5 超时重试机制主机和从站之间需要信任但不能盲等主机发出请求后从站可能因为忙、通信干扰、地址不对等原因不回复。如果主机死等整个轮询周期就卡住了。所以必须给每个请求设置超时。超时时间要大于从站正常响应时间——一般从站响应在几十毫秒内但考虑到总线负载和从站内部处理我通常设 200ms 超时超出就重发。重试次数也有讲究。重试太多次会拖慢轮询周期一次都不重试偶发干扰就会导致数据缺失。我常用的策略是每帧重试 2 次连续 3 次失败就标记该从站离线不再不断重试等到下一个轮询周期再尝试连接。这样既保证可靠性又不让单个故障设备拖累整条总线。void modbus_poll_process(void) { switch (modbus_state) { case STATE_IDLE: if (poll_tick poll_interval_ms) { modbus_build_read_hold_regs(current_slave_addr, start_reg, reg_count); modbus_state STATE_TX_DONE; HAL_UART_Transmit(huart1, tx_buffer, tx_len, 100); } break; case STATE_WAIT_RESP: if (frame_timeout_flag) { if (retry_cnt 2) { retry_cnt; HAL_UART_Transmit(huart1, tx_buffer, tx_len, 100); } else { slave_online[current_slave_addr] 0; next_slave(); retry_cnt 0; } frame_timeout_flag 0; } break; } }3. 实际调试环境搭建与工具配合3.1 用 Modbus Slave 模拟从站验证主机逻辑写主机程序最头疼的一件事是——从站设备不是随时都有的或者现场只有一台设备调试时却需要验证多个从站轮询。我的办法是在电脑上装一个 Modbus 从站模拟器把串口通过 USB-TTL 或者 USB-485 接到 STM32 开发板上用模拟器扮演温度表、变频器这样就能在没有真实设备的情况下完整验证主机逻辑。模拟器里可以手动设置寄存器数值、修改从站地址、模拟异常应答比拿真设备调试方便太多。比如我想验证主机在读寄存器数量超限时能不能正确处理异常码 02非法数据地址直接在模拟器里设一个超出范围的寄存器地址看主机是否正常解析异常帧。这类异常场景在真实设备上很难模拟用模拟器则一分钟搞定。3.2 抓包分析一个串口监视器的关键作用调试 Modbus 主机还有一个神器是串口抓包工具。把电脑串口接到 STM32 和从站模拟器之间的总线上就能监视总线上所有报文。这样你就能亲眼看到主机到底发了什么帧、从站回了什么帧、CRC 对不对、有没有重复帧。我调试时最常用的方式是电脑开两个串口窗口一个接模拟器监视从站视角一个直接抓总线报文。比如主机发了一帧“01 03 00 00 00 02 C4 0B”从站回“01 03 04 XX XX XX XX CRC”如果总线上只有请求没有响应说明从站没收到或者收到了但认为帧错误重点查地址和 CRC如果有响应但主机没处理问题就在主机解析侧。3.3 调试中容易被忽略的三个细节第一RS485 的收发切换方向。STM32 的 USART 本身是全双工的但 RS485 是半双工需要通过 DE/RE 引脚控制收发方向。实际调试中我见过不少人发送数据后忘了把方向切回接收导致从站回复的数据主机完全收不到。正确的做法是在发送完成中断里立刻把方向引脚拉低。第二串口中断优先级的设置。如果系统里还有其他中断比如定时器、外部中断要把串口接收中断优先级设得足够高否则在忙别的任务时很容易丢字节。有一次我在主循环里加了一个比较耗时的 LCD 刷新结果帧数据老是缺字节把接收中断优先级调高之后问题消失。第三上电时序。主机和从站设备上电时间不一致前几次请求可能无应答。所以程序启动后不要立刻开始大量轮询可以延迟几百毫秒做初始化给从站留出启动时间。4. 工程化细节与性能优化经验4.1 定时器扫描周期与 CPU 占用率Modbus 主机的轮询周期不能拍脑袋定。轮询周期太短总线上会时刻充满报文既占用带宽也会让从站忙于响应而影响其控制性能周期太长监控数据的实时性不足。对于温度、压力这类慢变化量500ms~1s 的周期足够对于变频器状态、电机转速这类需要实时反馈的量可以缩短到 200ms。STM32 主频通常几十 MHz 到上百 MHz跑 9600 波特率时一帧 8 字节的报文传输就要约 8ms串口中断处理只占 CPU 的极小比例整体占用率可忽略不计。但如果从站数量多比如 32 个设备按每个设备 200ms 超时算轮询一圈最坏情况要 6 秒以上这时就要合理设置超时时间和重试次数或者把不需要实时更新的设备放慢周期。4.2 多从站轮询调度用数组管理地址别用 switch-case多个从站设备轮询时很多人会写 switch-case 来区分不同设备然后每个分支都写一套收发逻辑代码会很长且难以维护。我的做法是用一个设备配置表把每个从站的地址、起始寄存器、寄存器数量、轮询周期都放在数组里主循环统一遍历。typedef struct { uint8_t addr; uint16_t start_reg; uint16_t reg_count; uint16_t poll_interval; uint16_t tick_counter; } Modbus_SlaveCfg_t; const Modbus_SlaveCfg_t slave_table[] { {0x01, 0x0000, 10, 500}, // 温控表 {0x02, 0x0100, 5, 200}, // 变频器 {0x03, 0x0200, 8, 1000}, // 电能表 };有了这张表新增一个从站设备只需要在数组里加一行完全不用改协议栈代码。我在实际项目中用这个方案管理了 8 个从站逻辑非常清爽。4.3 发送缓冲区的竞争保护如果 Modbus 请求发送和响应的解析发生在不同上下文比如主循环组包、串口中断接收要注意缓冲区共享问题。我习惯把 tx_buffer 只给主循环用接收数据只写入 rx_buffer中断里只负责拼帧和置标志位主循环检测到帧完成后才去解析 rx_buffer这样就避免了中断与主循环的竞争。还有一种情况是使用了操作系统比如 FreeRTOS之后串口中断里不能直接调用阻塞式 HAL_UART_Transmit应当通过消息队列或者信号量把事件通知给任务否则低优先级任务会被中断卡死。5. 常见问题速查与避坑清单5.1 现象排查对照表现象可能原因排查与解决从站完全无响应地址错误、串口参数不一致、485方向未切换先用 USB-485 工具直连从站确认参数偶尔能通偶尔超时干扰、接触不良、发送后没有等从站准备检查接地和屏蔽层发送前留 10ms 以上空闲能收到响应但数据明显不对大小端解析错误、寄存器地址偏移用从站模拟器逐字节核对报文CRC 一直校验失败组帧时高低字节顺序反了打印原始字节比对 CRC 计算顺序程序死循环或卡死等待响应时间过长未设置超时给每个请求加超时定时器超时走重试5.2 一些测试与容错的经验正式对接现场设备之前我强烈建议先做一轮“睡眠测试”和“异常注入测试”。睡眠测试是让从站模拟器在收到请求后故意延迟几秒再回复看主机的超时重试是否正常异常注入测试是让模拟器返回异常码01 非法功能、02 非法数据地址、03 非法数据值看主机是否把这些异常帧识别出来并记录日志而不是当作普通数据处理。我做测试时还会故意拔掉总线验证从站报离线之后恢复的过程。一开始我的程序只要重试失败就把从站标记离线但总线恢复后却忘了把从站重新标记在线导致设备一直不再被轮询。后来改成每个轮询周期至少对离线设备尝试一次问题就解决了。这类边界场景在产品发布前必须模拟一遍不然到了现场很容易被真正的意外状况打蒙。5.3 数据校验与容错多一重保险Modbus 本身有 CRC16这是帧传输层的校验但是数据在业务层面的正确性还需要额外处理。比如读取温度寄存器返回数值 0xFFFF 或者 0x7FFF往往代表传感器断线或设备处于异常状态我在解析时会做一个范围判断超过合理范围的数据直接丢弃保持上次有效值并记录错误计数。如果有多台从站设备最好在数据区给每台设备都留一个“数据有效”标志位。后台系统读取时能明确知道当前数据是实时采集的还是掉线前缓存的数据避免误判设备运行状态。这种细节不会体现在协议里但做工程的人都知道通信可靠不等于数据可信业务层的校验是最后一道保险。6. 一些移植和后续扩展的想法做主机程序时最好把协议代码跟硬件平台解耦。我这次用的 STM32但核心代码——组帧、解析、CRC、状态机——全部只用了标准 C 语法没有依赖任何 STM32 专用库。这样后续如果换平台比如从 STM32F1 换到 STM32H7或者移植到其他 MCU只需要重写串口初始化、发送接收几个底层函数协议栈部分几乎零改动。如果是需要上以太网的场景可以考虑 Modbus TCP。TCP 模式的组包比 RTU 简单没有 CRC只加了报文长度前缀。状态机思维完全一致只是底层换成了 TCP Socket。我后来在另一个项目里就把这套状态机平滑迁移到了 LWIPModbus TCP 上只花了不到一周时间。最后想说的是Modbus 主机的工程量不在于代码量有多大而在于对协议细节的把控和异常情况的处理是否到位。只要把帧格式、CRC、超时重试、状态机这些基础模块搭稳了后面无论面对什么牌子的从站设备心里都会比较有底。本文还有配套的精品资源点击获取