☰
STM32 MODBUS RTU从站完整实现:8大功能码解析与避坑指南
2026/10/19 9:37:39 网站建设 项目流程

简介:一份面向STM32嵌入式开发者的MODBUS RTU通信实例工程,围绕功能码覆盖全面的特点,给出从硬件配置、协议栈搭建、帧解析到应用层联调的完整参考。压缩包共787个文件、11.14MB,以C语言源码(.c/.h)、Keil工程(.uvprojx)、汇编启动文件(.s)及HTML文档和PNG截图为主,同时包含HEX固件、MAP映射、PDF文档等文件,便于直接编译烧录和对照学习。实例从串口与RS485接线入手,详细讲解帧结构、CRC校验、中断接收和所有主流功能码(读线圈、读寄存器、写单/多寄存器等)的实现过程,配套文档与图片辅助理解,目录结构清晰,工程文件完整,注释简明,方便按模块查阅。已有5991人学习下载。实例无论对于刚接触MODBUS协议的初学者,还是希望在产品中快速集成RTU通信的工程师,都能提供可运行的代码与排错思路,适合作为二次开发的起点。

1. 单片机STM32 MODBUS RTU通讯实例:功能码覆盖最全的落地参考

做嵌入式串口通讯的人应该都有过这种经历:MODBUS RTU协议看着简单,无非是地址、功能码、数据、CRC校验,可真到联调的时候,粘包、断包、字节序反了、寄存器地址对不上,任何一个坑都能让人debug到深夜。这套基于STM32的MODBUS RTU通讯实例,最大的价值在于把常用功能码几乎全部实现了——0x01读线圈、0x02读离散输入、0x03读保持寄存器、0x04读输入寄存器、0x05写单个线圈、0x06写单个寄存器、0x0F写多个线圈、0x10写多个寄存器,外加异常码处理。对正在做单片机通讯开发,或者手头有设备需要对接组态屏、PLC、上位机的人,这套代码可以直接作为协议栈嵌入自己的工程。

2. 项目结构和通讯模型:从串口字节到MODBUS帧的完整链路

2.1 整体目录结构与文件职责

拿到工程后先别急着编译,花十分钟把目录结构看明白,后面改起来会顺手很多。这套工程按模块划分得比较清晰,大致是这样的布局:

Project/ ├── Core/ │ ├── Inc/ │ │ ├── modbus.h // MODBUS协议栈头文件,功能码宏定义、帧结构体 │ │ ├── modbus_rtu.h // RTU传输层接口,CRC、串口收发函数声明 │ │ ├── stm32f1xx_it.h // 中断服务函数声明 │ │ └── main.h // 主函数相关配置 │ └── Src/ │ ├── modbus.c // 协议解析核心,功能码分发处理 │ ├── modbus_rtu.c // 串口接收状态机、CRC校验、帧组装 │ ├── stm32f1xx_it.c // 串口中断、定时器中断入口 │ └── main.c // 寄存器区定义、主循环调度 ├── Drivers/ │ ├── CMSIS/ │ └── STM32F1xx_HAL_Driver/ // HAL库源码,不必改动 └── MDK-ARM/ // Keil工程文件

modbus.c和modbus_rtu.c是这套代码的心脏。前者负责"协议语义",也就是收到一帧请求后,主站要读什么、写什么,寄存器映射到哪些内存地址;后者负责"传输语义",解决串口上字节流怎么切成帧、CRC怎么校验、超时怎么判断这些底层问题。新工程移植的时候,main.c里寄存器区的定义几乎要全部重写,但modbus_rtu.c的接收逻辑基本可以原样搬走。

2.2 串口配置和接收模型设计

MODBUS RTU 物理层就是标准串口,常见的配置是 9600 波特率、8 数据位、1 停止位、无校验,当然代码里也支持 4800、19200 这些常用速率。HAL 库的初始化代码比较样板化,关键在于接收模型的选择。

这套工程采用的是"串口中断逐字节接收 + 定时器超时判帧"的方案,而不是空闲中断(IDLE)方案。原因是很多 STM32 型号的 UART 空闲中断在用 DMA 接收时才有意义,而逐字节中断的方式对帧间隔的控制更直观。RTU 协议规定一帧内的字节间隔不得超过 1.5 个字符时间,帧与帧之间的间隔不得小于 3.5 个字符时间,定时器就是用来卡这个边界的。

// 串口接收中断:每次收到一个字节,存入环形缓冲区并重置超时定时器 void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { g_rx_buffer[g_rx_index++] = g_rx_byte; // 字节进缓冲区 if (g_rx_index >= RX_BUF_SIZE) { g_rx_index = RX_BUF_SIZE - 1; // 防溢出保护,超长帧截断 } __HAL_TIM_SET_COUNTER(&htim2, 0); // 计数器清零 HAL_TIM_Base_Start_IT(&htim2); // 启动超时定时器 HAL_UART_Receive_IT(&huart1, &g_rx_byte, 1); // 继续接收下一字节 } }

这里有几个关键参数需要说明:RX_BUF_SIZE一般设为 256 字节,MODBUS RTU 最大帧长是 256 字节,但在有广播地址和扩展协议的场景下,建议留 300 字节的余量;g_rx_byte是单字节接收缓冲,每收完一字节必须立刻重新调用HAL_UART_Receive_IT挂下一次接收,否则串口会"卡住"不再进中断。如果你在调试时发现只能收到第一字节,九成是漏了这行重挂接收的代码。

2.3 定时器超时时间怎么算

定时器的超时时间不是随便填的,它直接影响通讯的稳定性。按 RTU 规范,1.5 个字符时间和 3.5 个字符时间在 9600 波特率下分别约为 1.6ms 和 3.6ms。工程里一般把超时判帧时间设成 3.5 个字符时间,也就是一帧收完后约 4ms 没有新字节进来,就认为这一帧结束了。

void TIM2_IRQHandler(void) { if (__HAL_TIM_GET_FLAG(&htim2, TIM_FLAG_UPDATE) != RESET) { __HAL_TIM_CLEAR_FLAG(&htim2, TIM_FLAG_UPDATE); HAL_TIM_Base_Stop_IT(&htim2); g_frame_ready = 1; // 置位帧就绪标志,交给主循环处理 } }

以 9600 波特率为例,一个字符帧(含起始位、8 数据位、停止位)约 1.04ms,3.5 个字符时间就是 3.64ms。定时器预分频设成 1MHz(72MHz 主频分频 72),计数周期设 4000,就能得到 4ms 的超时窗口。这个值向上取整一点没关系,但不建议超过 10ms,否则主站连续发帧时,从站会把两帧当成一帧处理,解析必然报错。

主循环的调度方式很简单:轮询g_frame_ready标志位,置位就调用modbus_poll()处理帧,处理完后清标志。很多初学者会把这个逻辑直接写进中断里,这是不合适的——MODBUS 功能码的处理涉及寄存器读写、批量操作和响应发送,放进中断会阻塞其他中断响应,串口很容易丢字节。宁可主循环晚几个毫秒处理,也不能在中断里做重活。

3. 核心实现拆解:一帧MODBUS请求从进入到返回的完整流程

3.1 CRC校验与帧合法性检查

MODBUS RTU 的帧格式是:地址码 1 字节 + 功能码 1 字节 + 数据区 N 字节 + CRC 校验 2 字节(低字节在前)。CRC 是数据校验的核心,工程里用的是标准 CRC-16/MODBUS 算法,多项式 0xA001,初始值 0xFFFF。

uint16_t modbus_crc16(uint8_t *buffer, uint16_t length) { uint16_t crc = 0xFFFF; for (uint16_t i = 0; i < length; i++) { crc ^= buffer[i]; for (uint8_t bit = 0; bit < 8; bit++) { if (crc & 0x0001) { crc >>= 1; crc ^= 0xA001; } else { crc >>= 1; } } } return crc; }

这段代码每次处理一个字节,对 CRC 值做 8 次移位和异或。逻辑上不复杂,但有两个细节很容易翻车。第一是 CRC 的低字节在前,发送时先发低 8 位再发高 8 位,很多人在上位机里看到的 CRC 和计算出来的看起来是反的,不是算法错了,而是发送顺序错了。第二是 0xA001 是多项式 0x8005 的反序形式,因为这里用的是右移算法,如果改成查表法,表值本身也是按反序生成的,两种实现最终结果必须一致。

帧校验的入口函数modbus_rtu_parse()处理三件事:检查地址是否匹配(从站地址或广播地址 0),检查 CRC 是否正确,检查帧长度是否在合法范围内。

uint8_t modbus_rtu_parse(modbus_frame_t *frame) { // 1. 地址过滤:广播地址0x00或者本机地址0x01才继续处理 if (frame->addr != MODBUS_SLAVE_ADDR && frame->addr != 0x00) { return MODBUS_ERR_ADDR; } // 2. CRC校验:计算出的CRC必须与帧尾2字节一致 uint16_t crc_calc = modbus_crc16(frame->raw, frame->len - 2); uint16_t crc_recv = (frame->raw[frame->len - 1] << 8) | frame->raw[frame->len - 2]; if (crc_calc != crc_recv) { return MODBUS_ERR_CRC; } // 3. 功能码合法性初步检查 if (frame->func < 0x01 || frame->func > 0x10) { return MODBUS_ERR_FUNC; } return MODBUS_OK; }

这里想提醒一点:CRC 计算的长度是len - 2,因为帧的最后两个字节就是 CRC 本身,不能再参与计算。收到的 CRC 拼回 16 位时,要注意帧尾先收到的是低字节,所以组合方式是(高字节 << 8) | 低字节,这个顺序搞反了,所有帧都会报 CRC 错误。你可以在调试时打印crc_calc和crc_recv的十六进制值对比,能比较快地定位是算法问题还是字节序问题。

3.2 主循环MODBUS状态机

modbus_poll()是状态机的核心入口,负责把原始帧交给解析器,再按功能码分发到具体的处理函数。这里的逻辑组织方式是这套代码最值得借鉴的地方。

void modbus_poll(void) { if (!g_frame_ready) return; modbus_frame_t frame; // 从接收缓冲区拷贝原始帧数据 memcpy(frame.raw, g_rx_buffer, g_rx_index); frame.len = g_rx_index; frame.addr = frame.raw[0]; frame.func = frame.raw[1]; // 帧合法性校验 uint8_t err = modbus_rtu_parse(&frame); if (err != MODBUS_OK) { g_frame_ready = 0; g_rx_index = 0; return; // 注意:地址不匹配或CRC错误时,从站不应回复任何内容 } // 功能码分发 switch (frame.func) { case 0x01: handle_read_coils(&frame); break; case 0x02: handle_read_discrete_inputs(&frame); break; case 0x03: handle_read_holding_registers(&frame); break; case 0x04: handle_read_input_registers(&frame); break; case 0x05: handle_write_single_coil(&frame); break; case 0x06: handle_write_single_register(&frame); break; case 0x0F: handle_write_multiple_coils(&frame); break; case 0x10: handle_write_multiple_registers(&frame); break; default: send_exception(&frame, 0x01); break; // 非法功能码 } // 复位帧就绪标志 g_frame_ready = 0; g_rx_index = 0; }

有个细节值得单独拎出来说:当地址不匹配或者 CRC 错误的时候,MODBUS 协议要求从站保持沉默,不能回复任何字节。很多初写 MODBUS 从站的开发者看到错误帧就急着回异常响应,结果主站那边本来就什么都没发对,这边再回个错误帧,反而把总线搞得更乱。正确做法就是静默丢弃,这也符合 RS485 半双工总线的礼仪——别抢话。

3.3 异常码回复机制

当请求帧格式正确、CRC 也通过,但操作本身有问题时,需要回异常响应。异常响应帧的结构是:从站地址 + 功能码(最高位置 1)+ 异常码 + CRC。比如功能码 0x03 的异常响应,功能码字段是 0x83。

void send_exception(modbus_frame_t *frame, uint8_t exception_code) { uint8_t resp[5]; resp[0] = frame->addr; resp[1] = frame->func | 0x80; // 最高位置1,标识异常响应 resp[2] = exception_code; uint16_t crc = modbus_crc16(resp, 3); resp[3] = crc & 0xFF; // CRC低字节 resp[4] = crc >> 8; // CRC高字节 modbus_rtu_send(resp, 5); }

异常码的语义在MODBUS协议里有明确规定:0x01 非法功能码、0x02 非法数据地址、0x03 非法数据值、0x04 从站设备故障。这套工程里对这几个异常码的触发场景做了区分——功能码不认识回 0x01,寄存器地址超出范围回 0x02,写入了超限的值回 0x03。你二次开发时新增功能码,异常码的语义一定要按协议来,不要自定义,否则对接组态屏时,屏端只会按标准异常码解析。

4. 功能码逐个拆解:读写操作背后是同一套套路

4.1 读操作:0x01、0x02、0x03、0x04

读操作的处理思路是一致的:主站发来起始地址和数量,从站按地址去寄存器区取数,打包成响应帧回传。区别只在于操作的数据宽度不同——0x01 和 0x02 按位操作,0x03 和 0x04 按 16 位寄存器操作。

以 0x03 读保持寄存器为例,请求帧格式是:地址 + 0x03 + 起始地址高字节 + 起始地址低字节 + 寄存器数量高字节 + 寄存器数量低字节 + CRC。响应帧格式是:地址 + 0x03 + 字节数 + 数据1高字节 + 数据1低字节 + ... + CRC。

void handle_read_holding_registers(modbus_frame_t *frame) { uint16_t start_addr = (frame->raw[2] << 8) | frame->raw[3]; uint16_t reg_count = (frame->raw[4] << 8) | frame->raw[5]; // 参数校验:数量不能为0,且不能超过一次最多读125个寄存器 if (reg_count == 0 || reg_count > 125 || start_addr + reg_count > HOLDING_REG_SIZE) { send_exception(frame, 0x02); // 非法数据地址 return; } // 组织响应帧 uint8_t resp[3 + reg_count * 2]; resp[0] = frame->addr; resp[1] = 0x03; resp[2] = reg_count * 2; // 数据字节数 = 寄存器数量 × 2 for (uint16_t i = 0; i < reg_count; i++) { uint16_t value = g_holding_regs[start_addr + i]; resp[3 + i * 2] = value >> 8; // 寄存器值高字节 resp[4 + i * 2] = value & 0xFF; // 寄存器值低字节 } uint16_t crc = modbus_crc16(resp, 3 + reg_count * 2); resp[3 + reg_count * 2] = crc & 0xFF; resp[4 + reg_count * 2] = crc >> 8; modbus_rtu_send(resp, 5 + reg_count * 2); }

MODBUS 协议规定,03 功能码一次最多读 125 个寄存器(0x7D),01 和 02 功能码一次最多读 2000 个位,04 功能码也是 125 个寄存器。这个上限是协议强制约束的,不能突破。如果主站请求的数量超过这个值,正确的做法是回异常码 0x03(非法数据值),而不是把超长的响应帧硬发出去——超长帧会把串口缓冲区和从站的状态机都搞乱。

读线圈和读离散输入的区别在于数据来源不同:线圈对应输出状态,离散输入对应输入状态。在内存里可以分别定义两组位数组:

// main.c中定义寄存器区 uint16_t g_holding_regs[128]; // 0x03 保持寄存器 uint16_t g_input_regs[128]; // 0x04 输入寄存器 uint8_t g_coils[128]; // 0x01 线圈状态 uint8_t g_discrete_inputs[128]; // 0x02 离散输入

读线圈的响应里需要把位打包成字节,每个字节 8 个位,最后一个字节不满 8 位时高位补 0。这个"位打包"逻辑写起来要仔细一点,很多人在这里翻车。

// 0x01 读线圈响应数据部分组装 for (uint16_t i = 0; i < bit_count; i++) { if (g_coils[start_addr + i]) { resp[3 + i / 8] |= (1 << (i % 8)); // 按位或,注意位序 } }

这里位序的约定是:第一个线圈对应字节的最低位(LSB),不是最高位。有些上位机软件这里默认是按高位开始的,如果联调时发现读回来的线圈状态每一位都"错位"了,先检查这里而不是查硬件。

4.2 单点写操作:0x05 写单个线圈、0x06 写单个寄存器

0x06 写单个寄存器的请求帧是:地址 + 0x06 + 寄存器地址 + 写入值 + CRC。处理逻辑相对简单,只需校验地址合法性,然后写入寄存器数组。

void handle_write_single_register(modbus_frame_t *frame) { uint16_t reg_addr = (frame->raw[2] << 8) | frame->raw[3]; uint16_t value = (frame->raw[4] << 8) | frame->raw[5]; if (reg_addr >= HOLDING_REG_SIZE) { send_exception(frame, 0x02); return; } g_holding_regs[reg_addr] = value; // 写单个寄存器的响应帧 = 请求帧原样返回 modbus_rtu_send(frame->raw, frame->len); }

注意这里有一个很实用的协议特性:0x05 和 0x06 的响应帧就是请求帧的完整回显。也就是说,从站收到什么就原样回什么,中间不需要额外构造数据。这个设计简化了处理逻辑——你只需要做参数校验,然后直接调用modbus_rtu_send(frame->raw, frame->len)把收到的原始帧发回去即可。0x05 写线圈也是同理,但协议规定写入线圈的数据值必须是 0xFF00(表示 ON)或 0x0000(表示 OFF),其他值属于非法数据值,应当回异常码 0x03。

// 0x05 写单个线圈的关键判断 if (value == 0xFF00) { g_coils[coil_addr] = 1; } else if (value == 0x0000) { g_coils[coil_addr] = 0; } else { send_exception(frame, 0x03); // 非法数据值 return; }

有些上位机或调试工具写线圈时会发 0x0001 而不是 0xFF00,这在协议上是错的,但从站可以"宽容"处理一下,把非 0 值都当作 ON。工程代码里默认按严格模式,不过你二次开发时可以根据实际对接的设备决定是否放宽。

4.3 批量写操作:0x0F 写多个线圈、0x10 写多个寄存器

0x10 写多个寄存器是所有功能码里帧结构最复杂的,因为数据区是变长的。请求帧格式为:地址 + 0x10 + 起始地址 + 寄存器数量 + 字节数 + 数据区 + CRC。这里"字节数"字段是数据区的总字节数,等于寄存器数量 × 2。

void handle_write_multiple_registers(modbus_frame_t *frame) { uint16_t start_addr = (frame->raw[2] << 8) | frame->raw[3]; uint16_t reg_count = (frame->raw[4] << 8) | frame->raw[5]; uint8_t byte_count = frame->raw[6]; // 校验1:字节数必须等于寄存器数量的2倍 if (byte_count != reg_count * 2) { send_exception(frame, 0x03); return; } // 校验2:地址范围不能越界 if (start_addr + reg_count > HOLDING_REG_SIZE) { send_exception(frame, 0x02); return; } // 解析数据并写入寄存器区 for (uint16_t i = 0; i < reg_count; i++) { uint16_t value = (frame->raw[7 + i * 2] << 8) | frame->raw[8 + i * 2]; g_holding_regs[start_addr + i] = value; } // 批量写响应的特殊约定:只回地址+功能码+起始地址+数量,不带数据区 uint8_t resp[8]; resp[0] = frame->addr; resp[1] = 0x10; resp[2] = frame->raw[2]; resp[3] = frame->raw[3]; resp[4] = frame->raw[4]; resp[5] = frame->raw[5]; uint16_t crc = modbus_crc16(resp, 6); resp[6] = crc & 0xFF; resp[7] = crc >> 8; modbus_rtu_send(resp, 8); }

0x10 的响应帧只有 8 个字节,内容是"地址 + 0x10 + 起始地址 + 数量 + CRC",数据区不回显。很多刚摸 MODBUS 的人以为批量写和单点写一样要回显全部数据,结果响应帧超出预期长度,主站解析直接异常。这属于协议理解问题,不是代码问题,对照上面注释里的响应帧结构自己数一遍字节就明白了。

0x0F 写多个线圈的实现思路与 0x10 类似,差别在于数据区按位打包,而且协议规定一次最多写 1968 个位。处理时要先算byte_count是否等于(bit_count + 7) / 8,然后按位解析。这里有个容易踩的坑:主站发的位数据字节里,多余的高位如果不确定,有些从站会报非法数据值,稳妥的做法是忽略多余高位而不报错。

5. 避坑指南:调试MODBUS RTU时最容易翻车的5个细节

5.1 粘包与断包:定时器超时时间设置不当

现象是主站连续发帧时,从站偶尔会把两帧数据当一帧解析,或者一帧数据被拆成两半。原因在定时器超时时间设得太长或太短。时间太长,帧间隔小于超时时间,两帧就"粘"在一起;时间太短,主站发送过程中字节间隔稍微拉长,一帧之内就误判帧结束。解决方法是按波特率精确计算——9600 波特率下,3.5 个字符时间是 3.6ms,你设 4ms 没问题,但如果波特率降到 2400,同样 4ms 就只能覆盖约 1 个字符时间,一帧数据必然会断。每次改波特率,必须重新算超时时间。

5.2 CRC字节序反了:上位机一直报校验失败

现象是上位机收到的响应帧能解析出数据,但 CRC 校验永远不过。这种情况几乎都是发送字节序的问题。MODBUS RTU 的 CRC 在帧内是低字节在前,而大多数调试工具展示时按十六进制从左到右显示,看起来像是"反了"。解决方法是确认发送时resp[n-2]放 CRC 低字节、resp[n-1]放高字节。如果你把计算结果的crc直接按大端序发出,收端必然报错。一个快速验证方法是抓一帧标准请求,用在线 CRC 计算工具算出结果,和代码里发出的字节对比一下顺序。

5.3 寄存器地址偏移:上位机地址总差1

现象是上位机写寄存器地址 0 时,实际改到的是从站的寄存器 1。原因是 MODBUS 协议里的地址是"数据地址",而从站内部实际访问的内存数组下标通常从 0 开始,很多人会把两者混为一谈。解决方法是先明确你定义的内存映射关系。如果你的寄存器区数组下标 0 对应 MODBUS 地址 0,那请求帧里的起始地址就是数组下标;但很多组态屏的地址是从 1 开始编号的,屏端发地址 1 时,从站收到的起始地址字段是 0——中间差了一个偏移量。这个偏移要在协议解析层处理,不要在上位机里强行减一。

5.4 0x10功能码的字节数校验漏了

现象是主站写多个寄存器时,偶尔写入的数据是正确的,但偶尔会写进一堆 0 或乱码。原因是主站发出的byte_count字段和实际数据区长度不一致,而从站没有校验这一项,直接把偏移后的数据区内容当寄存器值解析了。解决方法是严格检查byte_count == reg_count * 2,不一致立刻回异常码 0x03,不要让脏数据进入寄存器区。

5.5 RS485方向切换时机不对

现象是挂在 RS485 总线上调试时,从站发送的响应偶尔会丢第一个字节或者最后一个字节。原因在方向控制引脚由发送模式切换回接收模式的时机太早或太晚。发送完最后一个字节后,串口移位寄存器可能还有数据没完全送出去,立刻切回接收会把最后的停止位截断。解决方法是发完最后一个字节后,延时 2~3 个字符时间再拉低 TX_EN 引脚,保证移位寄存器里的数据完整送出。

6. 二次开发实战:给这套MODBUS从站增加自定义功能码

拿到这套代码去做二次开发,最常见的一个需求就是扩展自定义功能码。比如你的设备需要支持一个"读取固件版本号"的功能,MODBUS 标准里没有对应分配,可以用 0x41(用户自定义区,功能码 65~72 属于用户定义区间)。下面是一段可以参考的扩展方式:

// 在modbus_poll()的switch分支中追加 case 0x41: handle_read_firmware_version(&frame); break; // 具体的处理函数 void handle_read_firmware_version(modbus_frame_t *frame) { // 版本号格式:V1.2.3,拆成3个寄存器返回 uint8_t resp[9]; resp[0] = frame->addr; resp[1] = 0x41; resp[2] = 6; // 6字节数据:3个寄存器 × 2 resp[3] = 0x00; resp[4] = 0x01; // 主版本号 1 resp[5] = 0x00; resp[6] = 0x02; // 次版本号 2 resp[7] = 0x00; resp[8] = 0x03; // 修订号 3 uint16_t crc = modbus_crc16(resp, 9); // 追加CRC后调用modbus_rtu_send发送 }

扩展到自定义功能码时,有三件事要记得做。第一是在modbus_rtu_parse()的合法性检查里放开自定义区的功能码,否则你的 0x41 还没走到 switch 就已经被当非法功能码挡掉了。第二是异常响应里frame->func | 0x80的规则同样适用,自定义功能码的异常响应也是把最高位置 1。第三是上位机或组态屏那边要同步支持自定义功能码的报文定义,否则从站这边功能再全,主站不认得也是白搭。

移植到其他 STM32 型号时,需要改的地方集中在modbus_rtu.c里串口和定时器的句柄——把huart1换成目标芯片的串口句柄,把htim2换成实际使用的定时器,超时计数值按新的时钟主频重新算一遍,其他协议逻辑几乎不需要动。寄存器区的定义和大小在main.c里调整,注意HOLDING_REG_SIZE必须在所有读取处理函数里保持一致,大小改一处,另一处也要同步。

这套代码我反复翻过多次,最打动我的是作者没有把 MODBUS 写成"能用就行"的玩具——异常码处理、广播地址支持、字节序细节、超时判帧,这些真正在总线上跑过才知道的细节都覆盖到了。以前我自己从零写过一版,以为自己全都懂,结果和标准的 MODBUS 主站设备联调时,光是批次写响应帧格式和位打包顺序就来回折腾了一整天。从那以后我每写一次串口通讯协议,都强制走一遍"按协议标准逐字段核对帧结构"的流程。希望这篇解析能帮你在 MODBUS 开发上少走点弯路。

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

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

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

立即咨询