资讯动态

STM32 MODBUS RTU从站协议栈完整实现:四种功能码与状态机设计

发布时间:2026/10/19 8:02:37 来源:尧图企业网站定制
简介面向嵌入式开发者和工控工程师的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.13ms3.5个字符时间大约是4ms。这意味着从站收到最后一个字节后只要超过这个间隔没收到新字节就要认为当前帧结束立即进入CRC校验和响应流程。帧间隔的概念决定了接收方式不能是“攒够固定长度再判断”因为不同功能码的数据区长度不一样。我一般这样处理每个字节进串口接收中断先根据功能码推断期望的总长度收够以后不立即处理而是启动一个5ms左右的定时器定时器溢出才真正开始校验。这个“等帧间隙”的动作是后面状态机里最关键的一环很多成品代码把它漏掉导致响应快但兼容性差。2.2 功能码选型四种功能码覆盖九成从站场景做从站设备功能码不是越多越好而是要和设备的物理量类型对上。工业现场最常见的四类操作是读采集值、读设备参数、写单个设定值、一次写多个参数分别对应四个标准功能码。0x03 读保持寄存器读可读可写的寄存器比如设备参数、控制字。0x04 读输入寄存器读只读的寄存器比如温度、湿度、电压等采集量。0x06 写单个保持寄存器写一个16位寄存器比如修改一个设定值。0x16 写多个保持寄存器一次写入连续多个寄存器适合参数下发。0x01读线圈、0x05写单个线圈这类功能码针对开关量输出如果设备只有模拟量采集和参数设定用不到0x10十进制16写多个寄存器其实是0x16的重复写法标准名称就是0x10但很多中文资料习惯称“16功能码”本文按简称说明。真正要把功能码做全上面四个已经能应付绝大多数传感器、变送器和智能设备场景。2.3 寄存器地址规划把协议地址和物理数组下标分开寄存器映射表一定要在写代码之前定死不然后面加功能时改起来很痛苦。我给某公司的温湿度采集器做过一个从站当时的规划是保持寄存器放设备地址、波特率、修正系数输入寄存器放温度和湿度。用一张表固定下来代码结构就非常清楚。寄存器类型协议地址范围物理数组映射读写属性典型内容保持寄存器0x0000 - 0x0007hold_reg[0] - hold_reg[7]读写设备地址、波特率、控制字保持寄存器0x0010 - 0x001Fhold_reg[16] - hold_reg[31]读写设定温度、报警阈值输入寄存器0x0000 - 0x000Finput_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. 避坑指南STM32MODBUS 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从站都会强制把异常码路径也完整测一遍这套流程帮我挡掉过好几个现场问题希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑