☰
STM32手写MODBUS RTU主机程序:从硬件到状态机轮询全解析
2026/10/4 5:12:10 网站建设 项目流程

简介:STM32 MODBUS主机程序是一份基于ARM Cortex-M内核的STM32微控制器实现MODBUS主站通信的工程源码包,面向嵌入式开发者及工业自动化项目学习者,可解决在STM32上主动发起读写寄存器、线圈状态等通信请求的需求。压缩包共160个文件,整体约3.41MB,主要包含C语言源文件与头文件(41个h、39个c)、编译调试产物(o、crf、axf、hex)、Keil工程配置文件(uvprojx、uvoptx、dep等)以及少量说明文档,既可直接查看代码逻辑,也可配合工程还原开发环境。该资源已有1740人学习下载,核心代码涉及UART配置、CRC校验、Modbus RTU帧处理及中断接收,并包含寄存器映射与轮询流程等实践内容。通过研读源码可掌握MODBUS主机通信的关键实现思路,包括错误重试机制、功能码调用方式及硬件接口适配方法,适合作为入门MODBUS主机开发的完整范例。 写MODBUS主机,最难受的一件事就是:网上90%的资料都是教你写从机。你想让STM32做主机去读一堆温湿度传感器、电表、变频器的数据,翻遍论坛都是freemodbus从机移植教程,好不容易找到个主机例程,还是阻塞式轮询,一个从站没响应,整个系统卡死。这篇文章我把从零手写MODBUS RTU主机程序的完整思路拆开讲,从硬件RS485通信到状态机轮询再到调试工具搭配,把我踩过的坑和最终能稳定跑一年的工程实现一起整理出来,给真正需要在STM32上做一主多从数据采集的朋友做个参考。

1. 为什么你的STM32要做MODBUS主机

1.1 从站思维与主机思维的本质差异

坊间流传的freemodbus、libmodbus,绝大多数移植教程做的是从机。从机程序的逻辑是被动的:别人发送请求,我解析请求,然后组织响应发回去。这种模式写起来相对简单,因为状态机是由外部请求驱动的,没有请求的时候从机只需要挂着等中断就行。

主机程序完全不同。主机是主动方,要自己决定什么时刻向哪个从站发起什么功能码请求——是读保持寄存器还是读输入寄存器,是单点读还是批量读,从站没回应怎么办,响应帧校验失败怎么处理。核心区别一句话概括:从机是等别人找它,主机是它去催别人,还得催得有节奏、催得有条理,还要能承受别人不理你的尴尬。

我的项目场景是采集18个从站设备,每个从站30个寄存器,涵盖温湿度、压力、电流和几个开关量。如果按从站思路写,这些数据根本自己不会主动上报,必须靠主机逐个去问。这决定了程序的整体架构从一开始就要以"轮询调度器"为核心,而不是以"协议解析"为核心。

1.2 哪些场景下主机程序是刚需

我在做项目过程中总结下来,真正逼着你必须自己写主机的场景无非这么几类:

  • 现有设备全部是从站接口,比如市面上的智能电表、温控器、变频器,它们固化了MODBUS从站协议,MCU只能做主机去采集。
  • 需要把数据汇聚后转发到上位机/云平台,这时MCU在链路中承担"协议转换网关"的角色,向下是MODBUS主机,向上是TCP/MQTT。
  • 需要批量操作成百上千个IO点,且对轮询周期、数据刷新率有明确要求,只有自己掌控主机调度逻辑才能保证。

如果你的项目只是两块自己的板子之间通信,那MODBUS主机确实杀鸡用牛刀,直接自定义协议更轻快。但只要有外购第三方从站设备,MODBUS主机基本就是唯一现实选择。

2. 硬件底子:RS485是主机程序的地基

2.1 从选型开始避开通信隐患

STM32的USART本身就是个现成的MODBUS RTU物理层载体,但绝大多数工业场景走的是RS485总线而不是TTL电平。选型上我用的是SP3485或MAX3485这类3.3V供电的RS485收发器,直接兼容STM32的IO电平,不用额外电平转换。如果你手头只有MAX485这种5V供电的芯片,那STM32的TX端必须串电阻分压再进芯片DI脚,不然长期跑容易烧引脚。

模块化方案可选带自动收发功能的RS485模块,比如常见的"自动收发模块"会在发送数据时自动把RE和DE拉高,省一路GPIO控制。但我实测下来,自动收发模块在9600波特率以下偶发最后一位被截断的情况,原因是发送完成后方向切换太快,总线释放时间的尾部几个微秒波形不完整。所以我个人不推荐自动收发模块用在关键工业场景,宁可多占用一个GPIO做方向控制,换来的是每一个字节都发得干净利落。

2.2 方向控制GPIO的时序账

RS485是半双工总线,收和发共用一对差分线,所以必须有一个方向切换动作。程序里我配置了一个DIR引脚(PA8),发送前置高,发送完成后延时一会再拉低,这个时序账必须算清楚。

USART发送完成的判定,建议用发送完成中断(TC)或查询TC标志位,而不是TXE数据寄存器空标志。TXE只代表数据已经从数据寄存器挪到了移位寄存器,此时移位寄存器可能还在往外送最后一个字节,立刻拉低DIR会导致最后一个字节被硬生生截断。TC标志才代表整个帧已经在物理线上发完整了。

void rs485_send_buf(uint8_t *buf, uint16_t len) { DIR_SET_HIGH(); // 拉高方向,进入发送模式 HAL_UART_Transmit(&huart, buf, len, HAL_MAX_DELAY); while (__HAL_UART_GET_FLAG(&huart, UART_FLAG_TC) == RESET); // 等TC DIR_SET_LOW(); // 确保最后一个字节完整发出后再切回接收 HAL_Delay(1); // 保守起见,留1个字符时间的余量 }

这个1ms的延时并不浪费。在9600波特率下,1个字节约1.04ms,留1ms可以保证总线上所有数据的最后一位稳定发送完毕,也给从站设备一个重要提示:主机已经切回接收态,你们可以回复了。

2.3 偏置电阻、终端电阻和地线

RS485通信的不稳定,一半以上不是程序问题而是物理层问题。A、B线上必须加偏置电阻将空闲态钳位在确定电平,否则总线空闲时电平处于不定态,接收端可能收到乱码中断。常规做法是A线通过10K电阻上拉到VCC,B线通过10K电阻下拉到GND。

终端电阻120Ω只在总线两端各接一个。如果只有两台设备且距离很短(几米内),不接终端电阻通常也能正常工作。但距离超过50米或挂载节点数量较多时,漏接终端电阻会使信号反射加重,出现"近距离正常、拉远距离就丢帧"的诡异问题。此外RS485需要A/B线之外,最好再拉一根公共地线,防止收发器共模电压超限。

3. 从零手写主机:帧构建与CRC16

3.1 帧结构不能只靠协议文档理解

MODBUS RTU帧格式本身不复杂:从站地址1字节、功能码1字节、数据若干字节、CRC16低8位在前高8位在后。但到手写主机的时候容易漏掉几点:

  • 请求帧的功能码与响应帧功能码并非总是相同。异常情况下从站会返回错误功能码,就是把请求功能码的最高位置1,比如请求03读保持寄存器,出错时返回0x83。
  • 广播地址0x00是个特殊存在,从站不回任何响应,主机发完就完事。
  • 功能码03读保持寄存器的数据字段,请求是起始地址2字节加寄存器数量2字节,响应是字节数1字节加寄存器值2字节乘以N。起始地址从0开始数,比如Modbus Poll里显示40001,协议地址实际是0x0000。这个偏移量是新手的头号坑。
// 组合03功能码读请求帧 uint8_t build_read_hold_req(uint8_t slave_addr, uint16_t start_reg, uint16_t reg_cnt, uint8_t *frame) { uint8_t idx = 0; frame[idx++] = slave_addr; frame[idx++] = 0x03; // 功能码读保持寄存器 frame[idx++] = (start_reg >> 8) & 0xFF; frame[idx++] = start_reg & 0xFF; // 起始地址 frame[idx++] = (reg_cnt >> 8) & 0xFF; frame[idx++] = reg_cnt & 0xFF; // 寄存器数量 uint16_t crc = crc16(frame, idx); // 计算CRC frame[idx++] = crc & 0xFF; // CRC低字节 frame[idx++] = (crc >> 8) & 0xFF; // CRC高字节 return idx; }

3.2 CRC16的查表法与bit逐位法怎么选

实现方式很多,大部分人会直接用查表法,查表法快,但需要先把256个CRC表项生成好,要么编译期计算,要么固定一个const数组。我实际项目中用的是bit逐位法,原因是在STM32F103这档主频72MHz的MCU上,每帧就6到8个字节,逐位算CRC消耗的时间完全可以忽略不计,而浪费64字节的const数组去做查表反而占用有限的Flash,在这类低数据量场景里显得没必要。

uint16_t crc16(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; }

注意多项式0xA001是MODBUS的标准CRC16多项式0x8005的反转形式,别用成0x8408(那是另一个变种),两者的结果完全不同,调试时候对不上会很痛苦。

3.3 发送缓冲区与接收缓冲区的生命周期

主机是多从站轮询的,每次请求前要把帧填充到发送缓冲区,请求发出后立刻切换到接收等待状态。我的处理是定义两个全局缓冲数组,发送缓冲区tx_buf[16],接收缓冲区rx_buf[64]。接收缓冲区用串口空闲中断配合DMA接收,或者用串口接收中断加字节间超时判断。

我采用的方式是空闲中断加DMA,STM32HAL库有个骚操作:串口空闲中断会在总线上出现一个字节时间的空闲状态时触发,正好对应MODBUS RTU的3.5字符时间间隔。配合DMA的接收计数,可以在不占用CPU逐字节处理的情况下完整获得一帧响应,而且DMA接收是边进内存边接收,中断触发时数据已经在缓冲区里了。

4. 主机程序的核心逻辑:轮询调度与从站数据组织

4.1 请求表驱动模式,不要每个从站写一段重复代码

如果18个从站每个站自己写一套轮询逻辑,代码会膨胀到没法维护,而且各个从站的频率不同、寄存器数量不同、重要程度不同,写死的逻辑根本调不动。

我用一张请求表来驱动整个轮询过程。表里每一行定义一次请求的完整属性:从站地址、功能码、起始寄存器、寄存器数量、请求周期、超时时间、重试次数、解析目标缓冲区的指针。

typedef struct { uint8_t slave_addr; // 从站地址 uint8_t func_code; // 功能码 uint16_t start_reg; // 起始寄存器地址 uint16_t reg_cnt; // 寄存器数量 uint16_t period_ms; // 轮询周期 uint16_t timeout_ms; // 帧超时时间 uint8_t retry_cnt; // 重试次数 uint16_t *dst_buf; // 数据解析目标 uint32_t last_tick; // 上次请求时间 uint8_t fail_cnt; // 连续失败计数 } modbus_req_t; static modbus_req_t req_table[] = { {0x01, 0x03, 0x0000, 10, 1000, 100, 3, hmi_data_1, 0, 0}, {0x02, 0x03, 0x0000, 30, 2000, 150, 2, sensor_data_2, 0, 0}, {0x03, 0x04, 0x0000, 20, 5000, 200, 3, elec_meter_3, 0, 0}, };

轮询调度时,系统滴答定时器每1ms计数一次,调度逻辑遍历请求表,找出所有到期(当前tick减去last_tick大于等于period_ms)的请求,然后按顺序执行一个。这样每个从站的查询周期可以完全不同,紧急的从站可以100ms轮一次,次要的可以5秒轮一次。

4.2 状态机轮询而非阻塞延时

初期我用的阻塞方式:发请求,然后死等应答,等不到就延时重发。从站数量少的时候没问题,但一旦某个从站故障,整个系统的轮询周期崩溃,后面的站全部被拖住。

最终改成了精简的MODBUS主机状态机,状态跳转如下:

  1. IDLE(空闲/调度):遍历请求表,找到到期的请求,构建发送帧,切换到WAIT_SEND。
  2. WAIT_SEND(等待发送完成):数据通过DMA或中断发送完成后,切换到WAIT_RESP,记录当前时间戳。
  3. WAIT_RESP(等待响应):开启接收超时定时器。收到完整一帧后用CRC和从站地址校验,校验通过进入PARSE,超时未收到进入RETRY流程。
  4. PARSE(解析):按功能码解析响应帧,写入请求表里绑定的目标缓冲区,然后回到IDLE。
  5. RETRY(失败重试):重试次数未耗尽则重新发送请求,耗尽则标记该从站失败,回到IDLE去轮询下一个站。

这个状态机最核心的优势是,无论当前从站是否正常应答,整个循环都掌握在可预期的节奏里,单个从站掉线不会拖垮全局。用100ms超时加2次重试计算,一个故障从站最多占用300ms时间片,然后就会跳到下一个站继续轮询。

4.3 响应超时为什么不能只靠延时

MODBUS RTU没有在帧内声明超时时间,不同的从站设备响应速度差异巨大。有的PLC从站10ms就回了,有的网关设备可能要到200ms才把数据整理出来。超时设短了,慢速设备一直超时;超时设长了,掉线从站占用的时间片变大。

我的做法是超时时间可配置,默认120ms,遇到特定慢速设备在请求表里单独调大。工程上还要注意:超时计时应该放在状态机里用硬件定时器(比如TIM4的1ms中断或者系统滴答计时)维护,而不是用HAL_Delay。因为HAL_Delay会阻塞状态机的其他逻辑,而且还会影响串口中断的实时性,让接收缓冲区覆盖出问题。

5. 调试与验证:没有调试工具你会在暗坑里挣扎

5.1 用Modbus Slave模拟从站,用Modbus Poll反向验证

写主机程序时,你手上可能没有真正的从站设备,或者从站设备在很远的生产现场。我的调试流程是先用PC端的Modbus Slave模拟一个虚拟从站,把地址、寄存器数量按协议文档填好,然后让STM32主机去读写它。

Modbus Slave的配置注意几个点:功能码03要勾选允许读,寄存器地址范围要和主机发送的请求帧对应上,从站地址必须一致。虚拟从站还有一个好用功能是可以设置异常应答、改变响应延时,这样我可以在PC上模拟从站掉线、CRC错误、异常码等场景,把主机的容错逻辑全部测一遍,而不是等到现场才暴露问题。

5.2 逻辑分析仪是抓物理层问题的神器

USB转TTL的调试助手能看到收发数据,但看不到波形。理论上已经用TC标志保证了帧完整,实际调的时候出现过"软件里看数据正确、从站就是不认"的问题,最后用逻辑分析仪抓A、B线上的波形才发现,某个模块的RS485收发器方向切换有5us的毛刺,导致总线上多了一段噪声。没有逻辑分析仪只能看到0和1,看不到电平抖动。

推荐用24MHz采样率的逻辑分析仪,配合Sigrok PulseView软件,直接把A线、B线、DIR引脚三路信号同时抓下来,波形对照一看就明白。

5.3 调试寄存器映射时最容易出现的地址偏移错误

Modbus协议里40001对应PLC的0号保持寄存器,但在很多传感器、仪表里,厂商文档写的地址是"40001"这种PLC风格的5位数字,对应的协议实际地址是40001减40001得0,这个规则还好理解;坑的是有的设备文档直接写"寄存器地址40001",实际在协议里却要求发送0x0001而不是0x0000。这种偏移偏差只能在调试阶段用Modbus Poll手动发一帧,对比设备回应来确认。我建议在搭建调试台的时候就拿目标设备实测一轮,别完全信文档,文档与实际不一致的情况在国产设备里概率并不低。

6. 工程化经验:把Demo变成能稳定跑一年的程序

6.1 中断服务函数里尽量不要干重活

串口接收中断或DMA传输完成中断里,建议只做数据搬运和置标志位,解析协议、校验CRC、更新请求表这些逻辑全部放到主循环里处理。比如在空闲中断里把接收计数和缓冲区指针保存下来,设置一个rx_frame_done标志,主循环检测到标志后再去解析。这样做的原因有二:一是中断里做耗时操作会阻塞其他中断的响应,比如TIM定时器中断可能被卡死,导致超时计数不准确;二是如果后续要上RTOS,中断里做重活把临界区拉长,任务调度抖动会非常明显。

6.2 硬件看门狗要和通信状态监测配合

程序跑飞被看门狗复位之后,如果看门狗复位时间远大于从站的重连周期,从站可能还在持续给主机发送响应帧,而这些响应帧会进入复位后主机的接收缓冲区变成垃圾数据。我的处理是在看门狗喂狗函数里加一个通信状态判断:如果距离上次成功通信超过一定时间(比如10秒),就自动把接收缓冲区清了,防止死机恢复后收到历史残留帧。

这里有个细节:如果程序长时间没有任何通信发生,硬件看门狗还是必须喂的,否则系统自己复位,所以通信状态监测不能替代看门狗喂狗,两者要同时存在。

6.3 一主多从总线上掉线节点的影响面控制

故障从站对总线的异常影响,主要是它可能会发送"残缺响应帧"——发了开头几个字节后因为自身故障停住,导致主机认为收到了半截帧。我的接收逻辑里专门加了一个3.5字节时间间隔判定:收到第一个字节后如果在3.5个字符时间内没有后续字节,就清空接收缓冲区当无效帧处理。这也和MODBUS RTU官方标准一致——帧与帧之间的间隔必须大于3.5个字符时间,刚好作为分辨断帧的工具。

如果某个从站在重试多次后仍然无响应,我的策略是不仅标记失败,还把它从活跃轮询表里摘除,只在慢速巡检列表里每隔30秒尝试一次,直到恢复。这样做的好处是,故障从站不会一直占满每个轮询周期,其他健康从站的数据刷新率不受到影响。

6.4 上电初始化和波特率自适应

上电后不要立刻往总线上发数据。原因很实际:很多从站设备断电重启后需要几秒初始化时间,ST-Link烧录器也会占用串口引脚一小段时间,这期间发出的请求帧没人响应,白白消耗重试次数还可能在故障记录里留下误报。我的初始化流程是上电延时5秒,先主动清一次接收缓冲区,再开始第一轮轮询。

波特率基本用9600最稳妥,MODBUS RTU标准规定的默认波特率也是9600。如果你要追求速度用115200,记得把总线上的终端电阻和线材质量一起检查一遍,高速率下对总线拓扑的要求会高很多。我实际测试过,同一套硬件9600下稳定跑,19200就开始间歇性超时,换屏蔽双绞线加终端电阻后才恢复正常。

6.5 寄存器数据的浮点数与大小端处理

从站读回来的16位寄存器数组,如果原始数据是浮点数,通常占用2个寄存器(32位),大小端排列在不同设备里各玩各的。

我的通用做法是:读回来的寄存器数组统一按大端方式拼出32位无符号整数,再memcpy成float。这样处理Intel格式的浮点数(低字在前、高字在后)时,需要把寄存器顺序反过来再拼。很多国产设备手册里会写"IEEE 754标准格式",但实际字节序跟WAGO、西门子的习惯不一样,这块最保险的是用Modbus Poll手动发一帧读取,然后通过协议栈的下发字节和解析出的浮点数对照,确认字节序,而不是想当然地用一套固定逻辑。

我整理了一个经过验证的转换函数,比较实用:

// reg_data 为从响应中提取的寄存器数组 // 将reg_data[0]作为低16位、reg_data[1]作为高16位拼成float float modbus_regs_to_float_low_first(uint16_t *reg_data) { uint32_t tmp = ((uint32_t)reg_data[1] << 16) | reg_data[0]; float result; memcpy(&result, &tmp, sizeof(float)); return result; }

同样的,写主机的时候也要反推出来:下发一个浮点数到两个寄存器时,哪个寄存器是高位哪个是低位,最好在代码注释里同时记录设备型号,免得三个月后回头维护时又要翻设备手册。

写到这里,这套STM32 MODBUS主机程序的整体框架基本完整了。从硬件RS485布线的几个关键参数,到状态机轮询的调度思路,再到调试和容错处理手法,这些内容组合起来就是一套能直接支撑现场项目的主机方案。我自己的体会是,MODBUS主机真正的大坑往往不是协议本身,而是物理层的稳定性、超时与重试的节奏、以及设备文档与实际行为之间的缝隙。如果你刚开始做,建议先拿最简单的单个从站把读写流程打通,再逐步扩展请求表和容错逻辑,这样排查问题时边界清楚很多。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询