简介:面向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. 串口:波特率 9600,8 数据位,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 小时连续跑帧测试,观察有没有累积性错误,比如内存缓慢增长、缓冲区边界错位导致的偶发崩溃。这类问题在短时间测试里几乎不会暴露,但一旦上线就会变成现场事故。我吃过一次亏:某次设备在现场稳定运行三天后突然不再响应任何请求,复位后恢复,但几小时后又复现,最后定位到错误处理分支里漏了一处缓冲区指针复位,累积到一定量时才必现。从那以后,每次改完协议栈,我都强制走一遍连续跑帧测试,至少十万帧无错误才算通过。希望这些过程记录能帮你减少排查时间,尤其是功能和地址映射部分,直接照搬这套思路能省不少事。
本文还有配套的精品资源,点击获取