简介:面向嵌入式开发者和工控工程师的STM32 MODBUS RTU通信完整实例,覆盖MODBUS协议全部常用功能码,解决设备间串行通信、寄存器读写、CRC校验等工程落地问题,尤其适合PLC、传感器和仪表类设备的数据交互场景。资源包共787个文件,压缩后11.14MB,核心包括工程源码(.c/.h)、Keil与IAR工程文件(.uvprojx/.ewp)、编译生成固件(.hex/.axf)、综合文档(.pdf/.html)以及原理图与流程图(.png/.jpg),源码注释和目录结构便于按模块查阅。已有5991人学习下载,适合刚接触STM32串口通信或需要在工业设备中快速集成MODBUS RTU的开发者。内容包括USART/RS485硬件配置、协议栈帧解析与构建、所有功能码逻辑、CRC16校验、中断收发处理,以及调试工具与烧录脚本;参照完整工程可直接移植到自己的STM32项目中,并能根据实际需求扩展读寄存器、写寄存器等业务逻辑,有效缩短Modbus通信功能的开发与排错周期。
1. 一条串口线上的完整从站:STM32的MODBUS RTU功能码实现为什么值得完整做一遍
做单片机课程设计或毕业设计时,通信协议这块绕不开MODBUS,尤其是要和PLC、组态屏、上位机组态软件对接的场景,MODBUS RTU几乎是默认选项。很多同学习惯从网上抄一段能收发数据的代码,CRC校验过了就觉得完事,结果联调现场翻车不断:主站发广播地址不回应、寄存器地址差一个偏移、功能码只做了03没做06,最后被现场逼着补代码。这份方案针对的是要把STM32从站协议栈做完整的场景,覆盖帧格式解析、寄存器映射、状态机设计以及03/04/06/16四种常用功能码的完整实现,适合直接用来交课设、毕设,也适合工程师拿去做设备接入PLC的快速改造。
2. 协议基础与功能码规划:把帧格式和寄存器映射表定死在写代码之前
2.1 MODBUS RTU帧格式:一个字节一个字节拆开看
MODBUS RTU的帧结构很紧凑,一帧由四部分组成:从站地址1字节、功能码1字节、数据区N字节、CRC校验2字节。数据区里的具体内容由功能码决定,比如读保持寄存器时,数据区是“起始地址2字节+寄存器数量2字节”,读请求总共8字节;写多个寄存器时,数据区变长,包含“起始地址2字节+数量2字节+字节数1字节+N个寄存器数据”。整个帧最大长度一般不超过256字节,所以从站侧开一个256字节的接收缓冲就够用。
这里有一个关键点经常被忽略:RTU协议没有帧起始和帧结束标志,靠的是时间间隔分帧。标准规定帧内字符间隔不能超过1.5个字符时间,两帧之间至少要有3.5个字符时间的静默期。以9600波特率、8N1格式来算,一个字符约1.13ms,3.5个字符时间大约是4ms。这意味着从站收到最后一个字节后,只要超过这个间隔没收到新字节,就要认为当前帧结束,立即进入CRC校验和响应流程。
帧间隔的概念决定了接收方式不能是“攒够固定长度再判断”,因为不同功能码的数据区长度不一样。我一般这样处理:每个字节进串口接收中断,先根据功能码推断期望的总长度,收够以后不立即处理,而是启动一个5ms左右的定时器,定时器溢出才真正开始校验。这个“等帧间隙”的动作,是后面状态机里最关键的一环,很多成品代码把它漏掉,导致响应快但兼容性差。
2.2 功能码选型:四种功能码覆盖九成从站场景
做从站设备,功能码不是越多越好,而是要和设备的物理量类型对上。工业现场最常见的四类操作是:读采集值、读设备参数、写单个设定值、一次写多个参数,分别对应四个标准功能码。
- 0x03 读保持寄存器:读可读可写的寄存器,比如设备参数、控制字。
- 0x04 读输入寄存器:读只读的寄存器,比如温度、湿度、电压等采集量。
- 0x06 写单个保持寄存器:写一个16位寄存器,比如修改一个设定值。
- 0x16 写多个保持寄存器:一次写入连续多个寄存器,适合参数下发。
0x01读线圈、0x05写单个线圈这类功能码针对开关量输出,如果设备只有模拟量采集和参数设定,用不到;0x10(十进制16)写多个寄存器其实是0x16的重复写法,标准名称就是0x10,但很多中文资料习惯称“16功能码”,本文按简称说明。真正要把功能码做全,上面四个已经能应付绝大多数传感器、变送器和智能设备场景。
2.3 寄存器地址规划:把协议地址和物理数组下标分开
寄存器映射表一定要在写代码之前定死,不然后面加功能时改起来很痛苦。我给某公司的温湿度采集器做过一个从站,当时的规划是:保持寄存器放设备地址、波特率、修正系数,输入寄存器放温度和湿度。用一张表固定下来,代码结构就非常清楚。
| 寄存器类型 | 协议地址范围 | 物理数组映射 | 读写属性 | 典型内容 |
|---|---|---|---|---|
| 保持寄存器 | 0x0000 - 0x0007 | hold_reg[0] - hold_reg[7] | 读写 | 设备地址、波特率、控制字 |
| 保持寄存器 | 0x0010 - 0x001F | hold_reg[16] - hold_reg[31] | 读写 | 设定温度、报警阈值 |
| 输入寄存器 | 0x0000 - 0x000F | input_reg[0] - input_reg[15] | 只读 | 温度、湿度、电压采样值 |
之所以中间空出一段地址,是给以后扩展留空间。实际使用中还有一个经常踩坑的“地址偏移”问题:组态软件界面里看到的保持寄存器地址通常写法是40001,对应协议里的地址偏移量0x0000,上位机地址等于协议地址加1。从站解析时如果直接用协议地址去索引数组,就会差一个数。通常的做法是在寄存器映射层做一次转换,而不是在功能码处理函数里到处减一。
2.4 一个完整请求响应示例:先看懂再来写状态机
用读保持寄存器举例,假设从站地址是0x01,要读起始地址0x0000开始的10个寄存器,请求帧长这样:
| 方向 | 字节内容 | 说明 |
|---|---|---|
| 主站→从站 | 01 03 00 00 00 0A CRC | 地址1,功能码03,起始地址0x0000,数量10 |
| 从站→主站 | 01 03 14 数据20字节 CRC | 地址1,功能码03,字节数0x14,后面跟20个字节 |
响应里的0x14是十六进制的20,等于寄存器数量10乘以每个寄存器2字节。CRC是两个字节,低字节在前。这个示例一定要亲手算一遍,把每个字节的含义标出来,后面写状态机时才能明白为什么收完数据区后要等帧间隙再做校验。
3. 总体架构设计:串口状态机与三层模块的职责划分
3.1 串口接收的两条路线:逐字节中断与DMA+空闲中断
STM32串口接收通常有两种做法。第一种是每个字节进一次接收中断,把字节交给协议层,逻辑直观,CPU占用也不算高——9600波特率下大约每秒接收9600字节,每字节中断一次,72MHz的STM32F103完全扛得住。第二种是DMA接收配合串口空闲中断,空闲中断在总线静默时置位,等于自动完成“一帧结束”的判定,CPU开销更低,但配置复杂,要同时处理DMA、串口和中断优先级,还要考虑DMA缓冲区的回绕。
我一般建议优先用逐字节中断做从站,原因有三:一是从站单帧长度有限,中断频率完全可接受;二是状态机天然适合逐字节驱动,每个字节推进一次状态,逻辑容易对齐,出问题好排查;三是后续要扩展广播地址处理、异常帧过滤时,逐字节判断比DMA整帧收完再统一解析更灵活。DMA方案更适合数据量大、频率高的主站设备。
3.2 协议处理层:四状态状态机与5ms超时判定
状态机是整个从站的核心调度,我把它拆成四个状态:空闲态、地址态、功能码态、数据态。复位时停在空闲态,收到任意字节进入地址态;地址收完进入功能码态;功能码决定数据区的期望长度,状态机根据这个长度决定数据态要收几个字节;数据收完后,启动5ms定时器等待帧间隔,定时器溢出前没有新字节,就判定帧结束,进入CRC校验和功能码分发流程。
这里最容易混的逻辑是把“收到一个字节”和“处理一帧”揉在一起。很多人在串口中断里直接调用功能码处理函数,导致一帧被拆成多次处理,或者处理函数耗时太长把下一个字节的接收中断堵死。正确的分层是:中断服务函数只负责把字节塞进状态机、推进状态、重置超时定时器;帧处理放在主循环里扫描,校验通过后执行功能码逻辑。状态机里只做长度累计和状态迁移,不碰寄存器读写,这样即使某个功能码处理耗时稍长,串口也不会丢字节。
3.3 寄存器映射层:协议地址与物理数组解耦
寄存器映射层解决的问题是“协议地址0x0010对应内部数组哪个元素”。哪怕内部寄存器就是按协议地址顺序排的,也建议单独留一个映射接口,别在功能码处理函数里直接用起始地址去索引数组。原因很现实:后期设备加一路采集通道,寄存器排布可能调整,有映射层就只改一个地方。
接口可以拆成三个函数:读保持寄存器、写保持寄存器、读输入寄存器。协议层拿到起始地址和数量后,先调用映射接口里的合法性校验函数,确认地址范围合法才执行读写,再把结果拼成响应帧。校验失败时返回特定错误码,协议层据此生成MODBUS异常响应0x02(非法数据地址)。这一步相当于把“协议解析”和“业务数据”彻底分开,调试时能快速定位是协议问题还是寄存器参数问题。
4. 关键实现落地:CRC16计算、接收状态机与四个功能码的C代码
4.1 CRC16-MODBUS的实现与发送字节序
CRC16-MODBUS使用的是多项式0xA001(对0x8005反转后的结果),计算时初值为0xFFFF。逐位算法的实现代码短、容易验证,适合从站这种低算力场景:
uint16_t modbus_crc16(const uint8_t *data, uint16_t len) { uint16_t crc = 0xFFFF; /* MODBUS规定初值为0xFFFF */ for (uint16_t i = 0; i < len; i++) { crc ^= data[i]; for (uint8_t bit = 0; bit < 8; bit++) { if (crc & 0x0001) { crc = (crc >> 1) ^ 0xA001; /* 多项式反码 */ } else { crc >>= 1; } } } return crc; }这个函数传入的数据是排除CRC两字节以外的完整帧内容。计算完的结果在写入响应帧时要注意字节序:MODBUS RTU规定CRC低字节在前、高字节在后。也就是返回的uint16_t数值,要先取低8位放在帧尾倒数第二个位置,再取高8位放在最后一个位置。反了的话,主站直接用调试助手看不到异常,但PLC或组态软件会一直报CRC校验错误。
逐位算法每字节需要8次循环,若追求效率可以用查表法:预先生成256项的表,每字节只做一次查表和异或。对于从站每秒几十帧的处理量,逐位算法一般够用,没必要为了性能牺牲可读性。
4.2 接收状态机的完整代码
状态机接收逻辑是协议栈的核心,这里给出一个精简可用的实现:
typedef enum { MB_RX_IDLE = 0, /* 空闲态 */ MB_RX_ADDR, /* 已收到从站地址 */ MB_RX_FUNC, /* 已收到功能码 */ MB_RX_DATA, /* 正在收数据区 */ MB_RX_FINISHED /* 一帧收完,等待主循环处理 */ } mb_rx_state_t; static uint8_t rx_buf[256]; static uint8_t rx_len; static uint8_t rx_expect_len; /* 预期总长度 */ static mb_rx_state_t rx_state = MB_RX_IDLE; void mb_uart_rx_isr(uint8_t byte) { if (rx_state == MB_RX_FINISHED) return; /* 上一帧未处理,丢弃后续字节 */ rx_buf[rx_len++] = byte; switch (rx_state) { case MB_RX_IDLE: if (byte == 0x00) { /* 地址0是广播地址,从站要接收但不应答 */ rx_state = MB_RX_ADDR; } else if (byte == slave_addr) { rx_state = MB_RX_ADDR; } else { rx_len = 0; /* 地址不匹配,整帧丢弃 */ } break; case MB_RX_ADDR: rx_buf[1] = byte; rx_expect_len = mb_get_frame_len(byte); /* 由功能码推算总长度 */ if (rx_expect_len == 0) { rx_state = MB_RX_FINISHED; /* 非法功能码,走异常流程 */ } else { rx_state = MB_RX_FUNC; } break; case MB_RX_FUNC: case MB_RX_DATA: if (rx_len >= rx_expect_len) { rx_state = MB_RX_FINISHED; /* 长度够了,等主循环处理 */ } break; default: break; } mb_timer_start(5); /* 每收到一个字节就重置5ms帧间隙定时器 */ }状态机在中断里只做两件事:收字节、迁移状态。这里有两个设计点值得说明。第一,广播地址0的处理:从站必须接收地址0的帧,但按照规范不能应答,所以这里把0地址也放行进入状态机,在后面的主循环里单独判断不应答。第二,rx_expect_len的推算依据功能码:03和04固定收4字节,06固定收4字节,16功能码则要从数据区里解析出字节数后再确认总长度,所以在16功能码分支里要特殊处理,建议单独写一个函数做二次长度修正。
4.3 功能码03和04的实现:读寄存器要防越界
读保持寄存器和读输入寄存器的逻辑几乎一样,只有寄存器表不同。03功能码的请求格式是:地址、功能码、起始地址高字节、起始地址低字节、数量高字节、数量低字节,共8字节。实现时最需要注意的是越界检查,不能只检查“数量不为0”,还要检查“起始地址加数量”是否超出寄存器表上限,否则主站恶意发一个超大数量,从站响应长度会溢出缓冲区。
void mb_handle_read_hold(uint8_t *req, uint8_t req_len) { uint16_t start_addr = (req[2] << 8) | req[3]; uint16_t reg_cnt = (req[4] << 8) | req[5]; uint8_t resp[20 + 5]; if (reg_cnt == 0 || start_addr + reg_cnt > HOLD_REG_MAX) { mb_build_exception(req[1], MB_EX_ADDR); /* 返回异常码02 */ return; } resp[0] = req[0]; /* 从站地址 */ resp[1] = 0x03; /* 功能码 */ resp[2] = reg_cnt * 2; /* 数据字节数 */ for (uint16_t i = 0; i < reg_cnt; i++) { resp[3 + i * 2] = hold_reg[start_addr + i] >> 8; /* 每个寄存器先高字节 */ resp[3 + i * 2 + 1] = hold_reg[start_addr + i] & 0xFF; /* 再低字节 */ } uint8_t resp_len = 3 + reg_cnt * 2; uint16_t crc = modbus_crc16(resp, resp_len); resp[resp_len] = crc & 0xFF; /* 低字节在前 */ resp[resp_len + 1] = crc >> 8; mb_uart_send(resp, resp_len + 2); }04功能码的处理完全一样,只是把hold_reg换成input_reg。这里有两个细节要提。第一,MODBUS寄存器传输是高字节在前,所以响应里要先放高位再放低位,而CRC又是低字节在前,两者别搞混。第二,越界判断用的start_addr + reg_cnt要放在赋值之前,而且最好用uint32_t临时变量做累加,避免uint16溢出,尤其当数量是0xFFFF时,直接相加会回绕成一个小值,绕过检查——这个坑我在现场联调时真实遇到过,主站配置错误导致批量读的时候从站数组越界。
4.4 功能码06和16的实现:写多个寄存器要先解析字节数
写单个寄存器06的请求是8字节固定长,处理相对简单,直接取数据区第2个字节到第3个字节作为写入值,写进寄存器表后把请求帧原样返回即可。写多个寄存器16的请求是变长的,格式为:地址、功能码、起始地址高字节、起始地址低字节、数量高字节、数量低字节、字节数、N个寄存器数据。解析时必须用字节数字段来约束循环次数,而不是直接用数量字段,因为协议里数量是寄存器个数,字节数是数量乘以2。
void mb_handle_write_multi(uint8_t *req, uint8_t req_len) { uint16_t start_addr = (req[2] << 8) | req[3]; uint16_t reg_cnt = (req[4] << 8) | req[5]; uint8_t byte_cnt = req[6]; /* 后续数据的字节数 */ /* 长度校验:字节数必须等于寄存器数量*2,且不超过请求长度 */ if (byte_cnt != reg_cnt * 2 || byte_cnt > 120) { mb_build_exception(0x10, MB_EX_VALUE); return; } if (start_addr + reg_cnt > HOLD_REG_MAX) { mb_build_exception(0x10, MB_EX_ADDR); return; } for (uint16_t i = 0; i < reg_cnt; i++) { hold_reg[start_addr + i] = (req[7 + i * 2] << 8) | req[8 + i * 2]; } /* 响应只需返回地址、功能码、起始地址、数量,共8字节 */ uint8_t resp[8]; resp[0] = req[0]; resp[1] = 0x10; resp[2] = req[2]; resp[3] = req[3]; resp[4] = req[4]; resp[5] = req[5]; uint16_t crc = modbus_crc16(resp, 6); resp[6] = crc & 0xFF; resp[7] = crc >> 8; mb_uart_send(resp, 8); }这里最容易翻车的是字节序处理:分组配置工具下发的数据有的默认高字节在前,但有些国产组态软件会发低字节在前。我一般建议在寄存器映射层做一个字节序开关,用宏或配置项控制,而不是在功能码里写死。实际项目里遇到过同一套代码接三款不同上位机,其中一款的字节序和另外两款相反,最后就是靠这个开关解决的。
4.5 异常响应帧的组织:让主站明确知道错在哪
MODBUS异常响应和正常响应的区别是功能码最高位置1。例如正常读保持寄存器功能码是0x03,异常响应功能码就是0x83,后面跟一个异常码字节。常见异常码有三个:
| 异常码 | 含义 | 典型触发场景 |
|---|---|---|
| 0x01 | 非法功能码 | 主站发了0x07等未实现功能码 |
| 0x02 | 非法数据地址 | 起始地址加数量超出寄存器表范围 |
| 0x03 | 非法数据值 | 写入值超出设定范围,比如温度上限写成了5000 |
异常响应帧格式:从站地址、异常功能码、异常码、CRC,总共5字节。注意异常功能码是原功能码加0x80。我在调试阶段会在异常码处理函数里加一个串口调试输出,把主站发来的原始帧和生成的异常帧都打印出来,极大缩短问题定位时间。
5. 避坑指南:STM32+MODBUS RTU最容易翻车的五个细节
5.1 现象一:复位后第一帧总是解析失败,后续帧正常
原因通常是单片机刚上电时串口接收到一个残留的0x00或0xFF字符,状态机误入中间态后没复位。这种现象在带有RS485收发器且收发切换由软件控制的设备上尤其明显,因为上电瞬间收发引脚电平不定。
解决方法是硬件初始化完成后、打开接收中断之前,先清空串口接收寄存器和状态机标志位,并且让状态机初始就是空闲态。另一个技巧是给状态机加一个“帧超时强制复位”逻辑:如果收完地址字节后超过5ms没收到后续字节,就把状态机踢回空闲态,避免半截帧卡住后续的合法帧。
5.2 现象二:电脑串口助手收发正常,PLC主站死活连不上
串口助手只负责收发,不检查帧间隔和时序;PLC主站对3.5字符时间间隔卡得很严。常见原因有两个:一是从站响应太快,主站刚发完请求,从站立即返回,根本没到3.5字符时间;二是从站发送时开启发送Enable到真正发字节之间的延迟过长,导致RS485总线冲突。
解决方法是响应前做一个固定延时,一般10ms左右比较安全,既能满足3.5字符间隔,又不会让主站认为超时。另一个经验是把RS485收发切换的GPIO置位和串口发送函数放在同一个临界区里,避免发送过程中被打断。
5.3 现象三:读出来的数据全部差一个寄存器,像是整体偏移
这个是我自己的血泪经验。组态软件里显示的是40001,对应协议地址0x0000;我一开始直接用协议地址索引数组,结果从站返回的永远是40201对应的值,也就是数组下标1。整个排查过程像玄学,后来一帧一帧对比才发现是地址转换缺失。
解决方法是统一在寄存器映射层做一次“界面地址减1转协议地址”的转换,并且在上位机侧用Modbus Poll验证时,先写一个全为固定值的寄存器表,比如第一个寄存器值是0xA001,第二个是0xA002,这样偏移一眼就能看出来。
5.4 现象四:115200波特率下偶发丢字节,CRC频繁失败
波特率从9600提到115200后,每个字节的接收时间从1ms压缩到87us,如果代码里有打印、延时或较长中断处理,很容易丢字节。典型现象是CRC偶尔报错,重试后正常。
解决方法是把串口中断优先级提到最高,中断函数里只做状态机推进和缓冲写入,任何调试打印和协议处理都挪到主循环。还可以开启串口FIFO并调整接收FIFO阈值,比如设置成4字节或8字节触发中断,缓解高波特率下的中断频繁问题。
5.5 现象五:写多个寄存器时从站死机,看门狗超时复位
现象是主站发送一长帧写操作时,从站跑到一半复位,现场表现像“一写就死”。原因大概率是功能码处理函数内部循环太多,或者把寄存器写入和Flash保存放一起执行,耗时超过看门狗喂狗周期。
解决方法是把耗时操作拆分:寄存器表先写入RAM,主循环里设置一个“需要保存”标志,再在空闲阶段统一写Flash或EEPROM。这样单次功能码处理的耗时能控制在几百us以内,看门狗也来得及喂。另一个相关经验是16功能码一次写的寄存器数量要设上限,超过120个直接返回异常码0x03,防止响应帧后再出错。
6. 验收和答辩:用主站模拟工具把功能码逐个过一遍
6.1 用Modbus Poll搭建验收环境,先跑通正常路径再跑异常路径
验收不能只测“能读能写”,要按功能码逐个过一遍正常和异常分支。我的验收清单固定以下顺序:设备地址设为1,波特率9600;用Modbus Poll的03功能码读地址0x0000到0x000F,核对每个寄存器返回值和预期一致;再用04功能码读输入寄存器区域,确认只读属性生效;接着用06功能码写单个寄存器,写完后重新读回,确认值已更新;最后用16功能码一次写5个连续寄存器,再读回校验。
异常路径测试同样重要:尝试读起始地址0x00A0(超出范围),从站必须返回异常码0x02;发送一个未实现的功能码0x07,必须返回0x01;写一个超出设定范围的数值,必须返回0x03。只有正常和异常路径都通过,这套从站才算交接合格。
6.2 答辩现场容易被追问的三个问题和回答思路
第一个追问大概率是“为什么用MODBUS RTU而不用MODBUS TCP”。回答思路是STM32在资源受限场景下,RTU只需一个串口和一个收发切换引脚,协议开销小,一条两线制RS485总线最多挂32个节点,配合中继还能更多,适合传感器和控制器之间的远距离低速率通信。
第二个追问围绕状态机:“为什么不用操作系统,用裸机状态机”。可以回答从站单帧最大256字节,处理逻辑是串行的,裸机状态机已经能完整覆盖,引入RTOS反而增加任务切换和互斥开销,可靠性未必更高。如果项目里有多个设备需要并发管理,可以考虑RTOS,但纯MODBUS从站不需要。
第三个追问是“广播地址怎么处理”。正确答案是:地址0是广播帧,设备必须接收但不返回响应。实现上就是状态机放行地址0,主循环里判断地址等于0时跳过发送函数。
我从那以后每次写完MODBUS从站,都会强制把异常码路径也完整测一遍,这套流程帮我挡掉过好几个现场问题,希望帮到你。
本文还有配套的精品资源,点击获取