恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
STM32实现MODBUS RTU从机:功能码全覆盖与高频故障排查
首页
资讯中心
/
STM32实现MODBUS RTU从机:功能码全覆盖与高频故障排查
STM32实现MODBUS RTU从机:功能码全覆盖与高频故障排查
发布时间:2026/10/19 3:03:11
简介面向STM32嵌入式开发者与工业通信工程师的MODBUS RTU通讯实例功能码覆盖全面适合PLC、传感器、仪表间串行通信及上位机联调场景。资源共787个文件压缩包11.14MB以C/H源码、Keil工程文件、HTML说明文档、PNG图片为主并包含汇编启动文件、链接脚本、调试配置等工程结构清晰可直接打开学习和二次开发。内容从UART/RS485硬件配置、MODBUS协议栈搭建到读寄存器0x03、0x04、写单个寄存器0x06、写多个寄存器0x10等功能码的帧解析与响应均有完整实现同时给出CRC校验、中断服务以及错误处理机制适合作为完整参考工程。目前已有5991人学习下载便于开发者快速梳理RTU帧结构、功能码含义与STM32串口编程要点并迁移到更复杂的嵌入式系统。1. STM32 跑 MODBUS RTU功能码越全后期越省心做单片机通讯的人大概率都跟 MODBUS 打过交道网上 Demo 一抓一把但十个里面八个只做了 03 和 06等你真上了设备发现要读开关量、要写线圈、要批量下发参数代码又要推翻重写。这份 STM32 MODBUS RTU 实例的最大价值在于功能码做得全01、02、03、04、05、06、15、16 都带了完整实现适合正在做 PLC 通讯、传感器采集、触摸屏对接的开发者直接移植省掉自己凑协议栈的时间。我用它做了一台模拟项目X的从机对接串口屏从移植到调通只花了一个晚上。2. 选型与工程准备MODBUS RTU 为什么还不过时以及裸机工程怎么搭2.1 先选对物理层RS485 与 RS232 的取舍MODBUS RTU 跑在串口上物理层常见 RS232 和 RS485 两种。RS232 是全双工适合短距离调试RS485 是半双工但抗干扰能力强布线距离能到几百米工厂现场基本都用它。我做的是 RS485 方案因为现场有变频器干扰RS232 在这种环境下根本扛不住。这里有一个关键点采用 RS485 时收发切换需要一个方向控制引脚通常用单片机的 GPIO 接在 MAX3485 这类收发器的 DE/RE 脚上硬件设计阶段就要留好不然后期飞线容易出事故。2.2 串口与定时器的初始化开启接收的完整链路我用的是某款主流 STM32F103 芯片常见做法是用 USART2 配合 DMA 接收外加一个定时器做帧超时判断。第一个坑是 DMA 接收模式要配置成循环模式不然缓冲区满一次就停止接收了第二个坑是波特率必须和上位机一致MODBUS RTU 对波特率敏感一个 bit 偏差都可能导致 CRC 校验不过。初始化逻辑分三部分GPIO 配置、串口配置、DMA 配置。代码块里给出关键部分void Modbus_UART_Init(void) { GPIO_InitTypeDef GPIO_InitStruct; USART_InitTypeDef USART_InitStruct; DMA_InitTypeDef DMA_InitStruct; /* 1. 使能时钟GPIOA 用于复用DMA1 用于通道USART2 挂在 APB1 上 */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); /* 2. TX (PA2) 复用推挽输出RX (PA3) 浮空输入 */ GPIO_InitStruct.GPIO_Pin GPIO_Pin_2; GPIO_InitStruct.GPIO_Mode GPIO_Mode_AF_PP; GPIO_InitStruct.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStruct); GPIO_InitStruct.GPIO_Pin GPIO_Pin_3; GPIO_InitStruct.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, GPIO_InitStruct); /* 3. 串口波特率 96008 数据位1 停止位无校验 —— RTU 标准格式 */ USART_InitStruct.USART_BaudRate 9600; USART_InitStruct.USART_WordLength USART_WordLength_8b; USART_InitStruct.USART_StopBits USART_StopBits_1; USART_InitStruct.USART_Parity USART_Parity_No; USART_InitStruct.USART_HardwareFlowControl USART_HardwareFlowControl_None; USART_InitStruct.USART_Mode USART_Mode_RX | USART_Mode_TX; USART_Init(USART2, USART_InitStruct); /* 4. DMA 接收循环模式缓冲区 256 字节数据到了不打扰 CPU */ DMA_DeInit(DMA1_Channel5); DMA_InitStruct.DMA_PeripheralBaseAddr (uint32_t)USART2-DR; DMA_InitStruct.DMA_MemoryBaseAddr (uint32_t)Modbus_RxBuf; DMA_InitStruct.DMA_DIR DMA_DIR_PeripheralSRC; DMA_InitStruct.DMA_BufferSize 256; DMA_InitStruct.DMA_PeripheralInc DMA_PeripheralInc_Disable; DMA_InitStruct.DMA_MemoryInc DMA_MemoryInc_Enable; DMA_InitStruct.DMA_PeripheralDataSize DMA_PeripheralDataSize_Byte; DMA_InitStruct.DMA_MemoryDataSize DMA_MemoryDataSize_Byte; DMA_InitStruct.DMA_Mode DMA_Mode_Circular; DMA_InitStruct.DMA_Priority DMA_Priority_High; DMA_InitStruct.DMA_M2M DMA_M2M_Disable; DMA_Init(DMA1_Channel5, DMA_InitStruct); DMA_Cmd(DMA1_Channel5, ENABLE); USART_DMACmd(USART2, USART_DMAReq_RX, ENABLE); USART_Cmd(USART2, ENABLE); }这里有一个容易忽略的细节DMA 循环模式下数据是持续往缓冲区里写的所以解析帧时不能直接从头读缓冲区而要借助 DMA 当前地址寄存器的值来判断新数据到了哪里。我一般会在中断里读取 DMA_GetCurrDataCounter 的差值或者配合空闲中断做一帧数据的完整切分。用空闲中断的好处是串口在一段时间没有新数据时自动触发一次中断这种方式天然符合 MODBUS RTU 的帧结束判定逻辑。配置完串口之后还需要在中断处理函数里把空闲中断标志位清掉否则帧之间的间隔会导致误触发这一点在后面避坑章节里我会再单独讲。2.3 定时器做 3.5T 帧间隔为什么不能用延时函数处理MODBUS RTU 的帧结束是靠静默时间判断的标准规定两个帧之间的间隔至少是 3.5 个字符时间超过这个间隔就认为上一帧结束了。9600 波特率下一个字符大约是 1ms所以 3.5 个字符就是大约 4ms。如果用延时函数在主循环里等待单片机在等待期间什么都干不了而且在中断里做延时会让系统卡死。常见做法是开启一个定时器每次收到一个字节就把定时器清零重新开始计时定时器溢出中断时表示一帧收到了这时再去解析缓冲区。void TIM2_Config(void) { TIM_TimeBaseInitTypeDef TIM_TimeBaseStruct; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); /* 72MHz 主频预分频 7200计数频率 10kHz计数 40 次 4ms */ TIM_TimeBaseStruct.TIM_Prescaler 7200 - 1; TIM_TimeBaseStruct.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseStruct.TIM_Period 40 - 1; TIM_TimeBaseStruct.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseInit(TIM2, TIM_TimeBaseStruct); TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE); TIM_Cmd(TIM2, ENABLE); }参数为什么这么取STM32F103 的系统时钟通常是 72MHz预分频 7200 后定时器计数频率变成 10kHz计数 40 次就是 4ms正好是 9600 波特率下 3.5T 的时间。波特率改成 115200 时3.5T 大约只有 0.4ms此时定时器周期就要调整为 2ms 左右的容差范围。实际调参时我不建议把时间掐得太死适当放宽到 2 倍字符时间即可因为上位机的发送间隔不一定严格遵守 MODBUS 标准很多组态软件发送两帧之间的间隔比 3.5T 长得多如果帧超时时间太短可能会把连续发来的两帧数据误判成一帧导致 CRC 校验永远过不去。同样地如果超时时间太长主机发送一帧后等待从机响应的时间会变长影响整体通讯效率这个后面讲主机轮询周期时还会再提到。3. 报文收发核心空闲中断切帧与 CRC 校验的完整实现3.1 接收缓冲区的管理空闲中断 循环队列的配合方式串口接收数据的处理是 MODBUS 从机设计的关键。硬件层面的方案有两种一种是用空闲中断判断帧结束另一种是定时器超时判断。由于串口空闲中断在标准外设库中默认是关闭的需要手动使能我一般会在串口初始化之后单独加一步/* 使能 USART 空闲中断用于判断一帧数据接收完成 */ USART_ITConfig(USART2, USART_IT_IDLE, ENABLE);触发空闲中断后需要读取一下 USART2-SR 和 USART2-DR 寄存器把标志位清掉否则下一次空闲事件不会再触发。清完标志后通过 DMA 当前地址计算本次接收到的数据长度。这里有一个容易踩的坑数据长度不是 DMA_GetCurrDataCounter 的返回值本身而是缓冲区总长度减去当前计数器的值因为计数器是从缓冲区末尾往回数的。代码实现如下void USART2_IRQHandler(void) { if (USART_GetITStatus(USART2, USART_IT_IDLE) ! RESET) { /* 先清空闲中断标志再读 DR 是标准操作顺序 */ USART_ReceiveData(USART2); USART_ClearITPendingBit(USART2, USART_IT_IDLE); /* 计算本次接收长度256 - 当前 DMA 计数 */ uint16_t len 256 - DMA_GetCurrDataCounter(DMA1_Channel5); if (len 4) // MODBUS 最短帧 8 位地址 功能码 起始地址 数量 CRC { Modbus_FrameLen len; Modbus_FrameFlag 1; } /* 清零 DMA 计数器恢复下一次接收 */ DMA_SetCurrDataCounter(DMA1_Channel5, 256); DMA_Cmd(DMA1_Channel5, ENABLE); } }这段代码的逻辑分为三个动作读取数据清标志、计算出当前帧的数据长度、把 DMA 计数器恢复到初始值以便接收下一帧。需要特别注意的是DMA_SetCurrDataCounter 必须在 DMA 使能状态下调用才有效有人先在前面 DMA_Cmd(ENABLE)顺序反过来之后发现计数器一直写不进去接收长度就全乱了。这是我在实际调试中反复踩过的坑。3.2 CRC16-MODBUS 校验查表法比逐位计算快在哪里MODBUS RTU 的 CRC16 算法是固定多项式 0xA001初始值为 0xFFFF。实现方式有两种逐位计算和查表。逐位计算代码短、好理解但每个字节要循环 8 次报文多了 CPU 开销大查表法用 256 个预计算值做一次查表和两次异或速度提升明显。我用的是查表法工程里把 crc_table 数组单独放在一个源文件里不占栈空间。计算函数如下uint16_t Modbus_CRC16(uint8_t *pData, uint16_t len) { uint16_t crc 0xFFFF; uint16_t i; while (len--) { crc ^ *pData; for (i 0; i 8; i) { if (crc 0x0001) crc (crc 1) ^ 0xA001; else crc 1; } } return crc; }这是逐位方式工程包里还带着一张查表版的 crc_fast 函数。它们的计算结果是一样的区别只在速度。返回的 crc 值是低字节在前、高字节在后报文里的存放顺序也遵循这个习惯发送时先把低字节发出去再发高字节。很多新手在这里搞反收到 01 03 00 00 00 01 CRC_H CRC_L 的报文时把 CRC 高低字节顺序弄错结果校验永远过不了。我自己的习惯是收到一帧后先拿原始报文算一遍 CRC然后把计算结果的低字节和报文倒数第二个字节对比高字节和倒数第一个字节对比不一致就直接丢弃不回复这样能挡住大部分串口干扰导致的脏数据。3.3 发送流程与 RS485 方向切换先切方向再发数据RS485 半双工模式下发送前要把方向引脚拉高发送完成后拉低这中间有一个延时问题。如果发送完成中断触发后马上拉低方向引脚最后一个字节可能还没完整发出去对方收到的是残缺帧。常见做法是在发送完成中断里做一个小延时或者用发送空闲中断判断。我用的方案是开一个 1ms 定时器延时一个 tick 再拉低方向稳妥起见直接写死为 1 个字节的传输时间加一点余量。发送伪代码如下void Modbus_SendFrame(uint8_t *pFrame, uint16_t len) { RS485_DIR_TX(); /* 拉高方向引脚进入发送模式 */ USART_DMACmd(USART2, USART_DMAReq_TX, ENABLE); /* 使能 DMA 发送 */ DMA_SetCurrDataCounter(DMA1_Channel4, len); DMA_Cmd(DMA1_Channel4, ENABLE); USART_ClearFlag(USART2, USART_FLAG_TC); }发送完成中断里再调用 RS485_DIR_RX() 拉低方向回到接收模式。这里有个细节有些 STM32 的 USART 发送完成标志 TC 在使能 DMA 发送前需要清零否则第一次发送会直接进完成中断导致方向引脚被提前拉低。我调试时遇到过一次现象第一帧回复上位机正常第二帧开始上位机就报超时排查半天发现就是 TC 标志没清干净第一次进入发送完成中断后还没来得及清标志第二次发送就来了进入了死循环状态。从那以后我每次发送前都会强制清一次 TC这个习惯让我省了很多调试时间。4. 功能码实现位操作与寄存器操作的完整对照4.1 01/02/05/15线圈与离散输入的处理功能码 01 读线圈和 02 读离散输入的区别在于访问的数据区不同底层操作都是按位读取。功能码 05 写单个线圈和 15 写多个线圈则是按位写。这一组功能码的报文格式有共同点请求帧里带起始地址和数量响应帧里带字节数和位数据。先看一个 01 功能码的响应组织逻辑uint8_t Modbus_HandleReadCoil(uint8_t *pReq, uint8_t *pResp) { uint16_t start_addr (pReq[2] 8) | pReq[3]; uint16_t quantity (pReq[4] 8) | pReq[5]; uint16_t byte_count (quantity 7) / 8; /* 位转字节向上取整 */ pResp[0] pReq[0]; /* 从机地址原样返回 */ pResp[1] 0x01; /* 功能码 */ pResp[2] byte_count; for (uint16_t i 0; i quantity; i) { uint8_t bit_val Coil_Table[start_addr i]; if (bit_val) pResp[3 i / 8] | (1 (i % 8)); } return 3 byte_count; }这里 Coil_Table 是一个全局数组维护线圈的开关状态。05 功能码的报文里需要判断开关量是 0xFF00 还是 0x0000如果收到的是其他值应该回异常码 03非法数据值。15 功能码则要在报文中跳过数量字段和字节数再按字节每一位去更新线圈表。我裁过一次跟头05 功能码的数据值是 0xFF00 表示开、0x0000 表示关但我当时用了 0x01 去赋值导致上位机写线圈后读出来全是 0后来对照协议才发现 05 功能码的数据值是一个 16 位的固定数值不是单字节的 0/1。4.2 03/04/06/16保持寄存器与输入寄存器的读写功能码 03 读保持寄存器和 04 读输入寄存器是最常用的两个。保持寄存器是可读可写的输入寄存器是只读的底层数据结构都是 uint16_t 数组。03 功能的处理步骤是从请求报文解析起始地址和寄存器数量从寄存器表取出相应数据填充响应。06 写单个寄存器的报文里数据是 2 字节直接写入寄存器表对应位置。16 写多个寄存器稍复杂因为请求报文多了字节数需要按字节数循环赋值。uint16_t Reg_Holding[100]; uint16_t Reg_Input[100]; uint8_t Modbus_HandleReadHolding(uint8_t *pReq, uint8_t *pResp) { uint16_t start_addr (pReq[2] 8) | pReq[3]; uint16_t quantity (pReq[4] 8) | pReq[5]; /* MODBUS 协议规定一次最多读 125 个寄存器超出则返回异常码 03 */ if (quantity 125) { pResp[0] pReq[0]; pResp[1] pReq[1] | 0x80; pResp[2] 0x03; return 3; } pResp[0] pReq[0]; pResp[1] 0x03; pResp[2] quantity * 2; for (uint16_t i 0; i quantity; i) { pResp[3 i * 2] Reg_Holding[start_addr i] 8; pResp[3 i * 2 1] Reg_Holding[start_addr i] 0xFF; } return 3 quantity * 2; }一个易错点寄存器地址是 16 位的起始地址和数量必须拆成两个字节放进报文。上位机发过来的报文中地址是高位在前低字节在后组合的时候要左移 8 位或上低字节。还有一个细节MODBUS 协议地址是从 0 开始编号的但触摸屏的地址映射通常是从 40001 开始两者之间差 1做地址映射时不要搞混不然上位机显示的数据永远对不上。4.3 功能码与异常码对照遇到不支持的操作怎么回状态码含义典型场景0x01非法功能码收到未知功能码0x02非法数据地址访问超出寄存器表范围0x03非法数据值05 功能码数据值不是 0xFF00/0x0000或数量超限0x04从站设备故障设备内部判断数据异常异常响应帧的格式是从机地址 (功能码 | 0x80) 异常码 CRC。注意这里功能码要按位或 0x80不能直接改成其他值这是协议明确规定的上位机才能从响应中识别出异常类型。我在写代码时把异常码设计成函数返回值统一在 Modbus_Parse 的分发函数里组装异常响应保证所有异常路径都能正常回帧。5. 踩坑与排查五个真实高频故障的定位方法5.1 帧超时误判导致粘包现象两个寄存器周期性地读不到数据用串口助手抓包发现上位机连续发了两帧从机当成一帧处理。原因帧超时定时器设置成了 4ms而上位机两帧之间的间隔也是 4ms 左右定时器还没溢出第二帧就到了DMA 缓冲区里的数据被覆盖解析必然出错。解决把帧超时提到 10ms大于一个字符时间的两倍同时把定时器中断里解析逻辑改成先复位 DMA 再处理数据。修改参数需要实测用示波器抓上位机实际发送间隔再去反推定时器周期不要照着协议手册的 3.5T 照搬。5.2 干扰导致 CRC 频繁不过现象现场一旦变频器启动从机收到的帧几乎都是坏的CRC 校验全不过。原因RS485 没有做屏蔽和接地A/B 线也没有加终端匹配电阻变频器高频噪声通过地线串进总线。解决两端各加一个 120Ω 终端电阻A/B 线对地加 TVS 管或者用带隔离的 RS485 模块。软件上多做一层防护连续收到 3 帧 CRC 错误的报文时自动把接收状态机初始化防止一块脏数据卡死逻辑。5.3 功能码 05 写了不回读现象上位机写单个线圈成功但立即回读状态还是 0。原因寄存器表里用了局部变量05 功能码的写入只在函数内部生效函数结束后数据就丢了。解决把 Coil_Table 和 Reg_Holding 这两个存储区改成全局数组并且在 head 文件里加 extern 声明。这是个低级错误但被我身边的同事踩过不止一次贴出来提醒一下。5.4 DMA 计数器写不进去现象第一次通讯正常第二次开始数据长度越算越大最后缓冲区溢出。原因DMA_SetCurrDataCounter 调用时 DMA 还处于关闭状态计数器的值没有被成功装载。解决先调 DMA_Cmd 使能再调用 DMA_SetCurrDataCounter并且在这个操作之前先做一次 DMA_DeInit确保 DMA 的配置寄存器处于干净状态。这条在 3.1 节的代码里我已经按正确顺序写了对照看一下就能发现问题。5.5 串口助手能通但上位机不通现象用串口助手手动发报文从机响应完全正常一接组态软件就通讯超时。原因组态软件发送帧之间带了一个 100ms 的固定间隔而从机的帧超时时间只有 4ms导致从机把两帧合并解析。解决查上位机的通讯参数设置把“帧间隔时间”调大到 50ms 以上。这个现象还提醒了一个点调试时尽量用与现场相同的上位机软件不要只依赖串口助手因为串口助手手动发帧不会模拟出真实主站的发送节奏。6. 进阶技巧用回环测试验证功能码覆盖率的完整流程把整套协议栈移植到新工程之后怎么确定每个功能码都真的能用我的习惯是在主循环外面加一段回环自测逻辑单片机自己给自己发包把请求直接扔给解析函数再把响应帧的 CRC 重新算一遍全部通过才允许进入正常通讯模式。这样做验证范围更全面。常见做法是做一张指令表把每个功能码的代表帧预置在程序里循环发送一遍统计响应帧数量。如果某个功能码没有响应直接定位到对应分支省下接线连上位机的排查时间。应用技巧方面我一般会在寄存器地址映射里留一段固定字段存放设备版本号和固件时间戳这样上位机读设备信息时不用看代码就能知道现场固件版本。这个方法被我在模拟项目X里用得很顺手。使用上位机做交互验证时Modbus Poll 能勾选多个寄存器地址连续读取实测下来每次读 60 个保持寄存器刷新周期 200ms从机在 9600 波特率下全速响应不会丢帧。需要留意的是Modbus Poll 默认读地址从 0 开始而触摸屏地址从 40001 开始两者映射关系要在上位机工具里配好否则看到的数据错位会让人怀疑是代码问题追查半天发现是地址偏移搞错了。关于波特率与帧超时的联动调参我给一个经验值参考9600 波特率下字符时间约 1ms帧超时设为 10ms 是安全区间115200 波特率下字符时间约 0.087ms帧超时设为 2ms 就够用。定时器中断里处理一拍数据的时间最好控制在 500us 以内超过这个数值在高速轮询时会影响主站的定时精度。验证方法是在线调试时数一下中断函数进出时间或者直接在中断入口翻转一个 GPIO用示波器量脉宽看到每次解析耗时。回环测试通过之后一定要用真实主站做一次 8 小时连续跑帧测试观察有没有累积性错误比如内存缓慢增长、缓冲区边界错位导致的偶发崩溃。这类问题在短时间测试里几乎不会暴露但一旦上线就会变成现场事故。我吃过一次亏某次设备在现场稳定运行三天后突然不再响应任何请求复位后恢复但几小时后又复现最后定位到错误处理分支里漏了一处缓冲区指针复位累积到一定量时才必现。从那以后每次改完协议栈我都强制走一遍连续跑帧测试至少十万帧无错误才算通过。希望这些过程记录能帮你减少排查时间尤其是功能和地址映射部分直接照搬这套思路能省不少事。本文还有配套的精品资源点击获取