简介面向STM32开发者的Modbus通信实现资料包内含作者多次实测通过的Modbus从机程序、FreeModbus协议栈1.6完整源码以及CRC计算助手适合需要快速在STM32上集成Modbus RTU通信的嵌入式工程师也适合初学者对照学习协议栈移植方法。资源共107个文件以C源文件和头文件为主配合Keil工程配置、STM32标准外设驱动、汇编启动代码、可烧录的hex文件、PDF说明文档、辅助脚本及工程清理工具目录结构清晰便于按模块查阅和二次开发。整个压缩包仅4.69MB轻量紧凑。目前已有3457人学习下载。代码经过作者反复验证稳定性有保障FreeModbus 1.6源码可供深入分析协议栈实现细节CRC计算工具可在调试时快速校验数据帧配合配套博文的讲解能帮助读者少走弯路快速完成Modbus功能的开发与调试无论是用于产品原型验证还是学习Modbus协议这套资料都能提供扎实参考。1. 从Modbus RTU到STM32从站这份源码包解决的是“读得上、写得动、不重置”做现场设备联调那几年我至少见过三次这种情况明明硬件没问题上位机却时不时读不到数据最后排查出来不是协议栈没写好就是串口收发逻辑有硬伤。Modbus RTU在STM32上属于“看起来简单、写起来绕”的东西——帧怎么拼、断帧怎么判、CRC16高低字节怎么发、485方向脚什么时候切每一步都能卡住人。而“stm32 for modbus.zip”这份源码包正好把从站端这套完整实现收在一起串口拼帧、超时定时器、CRC16校验、03/06/16功能码处理、寄存器映射和移植层代码都给你归置好了。它解决的就是一个具体问题让一台STM32设备在Modbus总线上老老实实当一个从站数据读得到、写得进、跑得稳。适合刚接触Modbus的嵌入式新手也适合想快速给现有工程加协议栈的老手拿来改。2. 拆开源码包移植层、协议层、寄存器模型和串口中断怎么分工2.1 两层的文件划分协议层不碰芯片寄存器移植层才操作USART我拆这份源码包的第一感受是它没有把所有代码揉进一个main.c里而是按“协议层”和“移植层”分成了两堆。协议层负责处理Modbus那一套规则——帧结构对不对、CRC对不对、功能码该执行什么、回答帧怎么组移植层负责跟STM32打交道——从串口寄存器里拿字节、往发送寄存器里写数、开定时器、翻转485的方向引脚。这种分层的好处是你换一块开发板只需要改移植层那几个函数协议层基本不动。源码包里典型的文件规划大概是下面这个样子stm32_for_modbus/ ├── mb_crc.c # CRC16 计算多项式 0xA001 ├── mb_rtu.c # Modbus RTU 帧解析与功能码分发 ├── mb_reg.c # 寄存器区映射0x/1x/3x/4x 对应的RAM数组 ├── mb_port.c # 平台移植层串口收发、定时器、485 方向控制 ├── mb_config.h # 宏配置从站地址、波特率、功能码开关 └── example/ ├── main.c # 初始化示例串口、定时器、主循环调用 └── stm32f1xx_hal_msp.c再说每个文件里大概放什么。mb_crc.c就一个函数算Modbus RTU的CRC16多项式是0xA001初始值是0xFFFF。mb_rtu.c是整个包的核心它里面有一个状态机从空闲态进入接收态收到完整一帧后做地址匹配、CRC校验、功能码分发最后把响应帧塞到发送缓冲区。mb_reg.c不是单纯定义几个数组它还负责“寄存器地址到数组下标”的换算比如外部主站读保持寄存器地址0x0001实际对应的是holding_regs[0]这个偏移关系在接收帧的数据段里就要换算好。移植层mb_port.c是大多数人第一次用这个包时唯一要改的地方。它对外暴露几个接口mb_port_init()做串口和定时器初始化mb_port_send_byte()逐字节发送mb_port_start_rx_timer()用来启动断帧超时定时器还有两个回调函数——收到一个字节触发一次、帧超时触发一次。我实际用下来觉得这个接口设计其实可以更省事直接把串口接收中断里“取字节、缓存、复位定时器”的模板代码也放进去新手照着抄就行。2.2 寄存器区映射0x/1x/3x/4x各自对应的RAM数组Modbus协议里寄存器分成四个区这个源码包也按这个套路组织存储区域0x区是线圈可读可写位操作1x区是离散输入只读3x区是输入寄存器只读16位4x区是保持寄存器可读可写也是最常用的一个区。源码包里用了一个结构体把这四个区分开typedef struct { uint16_t coils[64]; /* 0x 区线圈按位使用 */ uint16_t discrete_in[64]; /* 1x 区离散输入只读 */ uint16_t input_regs[64]; /* 3x 区输入寄存器只读 */ uint16_t holding_regs[128]; /* 4x 区保持寄存器读写 */ } mb_reg_bank_t; static mb_reg_bank_t reg_bank;这里有一个很多新手会踩的概念Modbus协议里寄存器地址是从1开始编号的而C数组下标是从0开始的。所以源码包里所有功能码在处理地址时都要做一次“减一”换算比如主站发来地址0x0000表示要读保持寄存器的第一个元素内部实际访问的是holding_regs[0]如果直接用地址0x0000去访问数组读到的就是越界前的脏数据了。还有一个值得注意的设计这个结构体把四个区都声明成uint16_t数组但线圈区其实用不到16位只是为了统一访问方式。我看到不少商用代码会把coils单独做成bitmap省RAM但代码复杂度上来。而这份源码包选择用uint16_t数组好处是读写逻辑统一坏处是浪费了一点RAM——对STM32F103这种有20K RAM的芯片来说四个区全开也就占不到1K完全无所谓。寄存器数量上限是编译期常量你嫌128个保持寄存器多可以在mb_config.h里改小。2.3 串口接收和帧边界判定为什么用“接收中断 超时定时器”而不是空闲中断Modbus RTU是异步串口协议一帧数据的结束标志不是某个特殊字节而是“静默超过3.5个字符时间”。比如9600波特率下一帧结束后总线空闲约4毫秒从站就该认为帧收完了。源码包用的是最传统也最通用的方案串口逐字节接收中断 一个基本定时器做超时判定。每收到一个字节就把定时器清零重新开始计时一旦定时器溢出就说明后面没有新字节了当前缓冲区内就是一整帧。为什么不直接用STM32一些新系列芯片自带的空闲中断IDLE因为不是所有型号都有而且空闲中断在不同HAL库版本里回调写法不一样移植的时候容易出幺蛾子。定时器方案只要有任意一个基本定时器就能跑逻辑也直白。给串口的接收引脚接上逻辑分析仪你会看到每一帧之间的低电平间隔明显大于帧内字节间隔这个时间差正是超时定时器要捕捉的东西。/* 串口接收中断只做缓存和复位定时器不做CRC校验 */ void USART1_IRQHandler(void) { uint8_t rx_byte; if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_RXNE)) { rx_byte (uint8_t)(huart1.Instance-DR 0xFF); if (mb_rx_index MB_RX_BUF_SIZE) { mb_rx_buf[mb_rx_index] rx_byte; __HAL_TIM_SET_COUNTER(htim3, 0); /* 每收一字节超时计数清零 */ __HAL_TIM_SET_AUTORELOAD(htim3, MB_FRAME_TIMEOUT_TICKS); __HAL_TIM_ENABLE(htim3); } } }这段中断处理逻辑很关键它在中断里只做了三件事——读DR寄存器取出字节、存进缓冲区、复位超时定时器。这里故意不做CRC校验也不做功能码解析因为这些操作耗时不确定放在中断里会让系统响应变得不可预测。MB_RX_BUF_SIZE默认给256如果协议栈只处理03/06/16这几个功能码最大帧长按16个功能码算也就几十字节但缓冲区留大一点后面扩展功能码时不用回头改。超时定时器的重装载值MB_FRAME_TIMEOUT_TICKS怎么算假定定时器时钟是72MHz分频器设成7200那么计数频率是10KHz一个tick是100微秒。9600波特率下3.5个字符时间约4毫秒换算成tick就是40。所以这个宏一般设成40~50既能区分帧内字节间隔又不会把两帧数据并成一帧。19200波特率时帧间隔缩短到约2毫秒宏要相应调小到20~25否则主站连续发两帧时从站可能把两帧合并解析直接导致地址或CRC错乱。3. 一步步跑起来从空工程到收到第一个03功能码回包3.1 拷贝代码和第一个配置文件管脚、波特率、从站地址拿到这个包第一步不是打开main.c看热闹而是先把移植层三个文件拖进自己的工程mb_rtu.c、mb_port.c、mb_crc.c再把mb_config.h放到include路径里。这个头文件是全局配置中心我在工程里通常会把它当成“这个设备在Modbus总线上的人设”来看待——它决定了你是谁、说话多快、愿意回答哪些请求。/* mb_config.h 关键配置项 */ #define MB_SLAVE_ADDR 0x11 /* 从站地址范围 1~247 */ #define MB_USART_BAUDRATE 9600 /* 串口波特率 */ #define MB_FRAME_TIMEOUT_TICKS 40 /* 帧超时单位定时器 tick */ #define MB_SUPPORT_FC03 1 /* 读保持寄存器 */ #define MB_SUPPORT_FC06 1 /* 写单个保持寄存器 */ #define MB_SUPPORT_FC16 1 /* 写多个保持寄存器 */ #define MB_RX_BUF_SIZE 256 /* 接收缓冲区大小 */从站地址这一个参数就值得单独说。Modbus协议里地址0是广播地址所有从站都要接收但不回包实际设备地址从1到247。有些工程为了省事把MAC地址最后一位当从站地址结果设备编号超过247就完蛋。我这里习惯做法是单独用一个拨码开关或EEPROM存储地址工程里预留一个mb_set_slave_addr(uint8_t addr)接口而不是把地址写死在头文件里。管脚配置要看你自己板子的485芯片接在哪。大部分485模块的RE/DE方向脚共用一根GPIO源码包移植层里md_port_set_dir(uint8_t tx_en)这个函数就是干这个的。我的建议是把方向脚初始化放在串口初始化之前避免上电瞬间485芯片处于发送态把总线拉死。很多第一次用485的人就在这里翻车——上电后整个总线通信全部瘫痪还以为是主站问题。3.2 中断里只做三件事收字节、复位定时器、置标记串口这边我按“精简中断”原则来接收中断只负责把数据搬进缓冲区、复位超时定时器、把接收状态置为“正在接收”。发送时不要全程开发送中断而是把响应帧准备好后用轮询或单字节发送中断把数据推出去推完立即关发送中断并拉低方向脚。这个包里的移植层模板就是这么写的。/* 定时器超时中断表示一帧接收完毕 */ void TIM3_IRQHandler(void) { if (__HAL_TIM_GET_FLAG(htim3, TIM_FLAG_UPDATE)) { __HAL_TIM_CLEAR_FLAG(htim3, TIM_FLAG_UPDATE); __HAL_TIM_DISABLE(htim3); mb_frame_ready 1; /* 主循环轮询这个标记 */ } }这段代码要配合上一章的串口接收中断一起看串口每来一个字节定时器就被重置所以只要字节还在持续到达定时器永远不会溢出一旦帧结束总线静默超过阈值定时器溢出mb_frame_ready被置1。这时主循环就知道“缓冲区里有一帧完整数据可以处理了”。这里有个细节被很多人忽略定时器溢出中断里不应该直接调用mb_poll()去解析协议因为你可能在一个低优先级任务或某个临界区里直接解析会拉长中断时间统一置标记让主循环去处理是最不容易出错的结构。3.3 主循环状态机拼帧完成、校验CRC、功能码分发、组回包主循环里我建议做成一个非阻塞的轮询状态机避免用while(1)死等某个事件。下面这段是一个可以直接用的模板while (1) { if (mb_frame_ready) { mb_frame_ready 0; mb_poll_frame(); } /* 其他应用任务 */ app_task_tick(); }mb_poll_frame()内部流程是这样的第一步检查帧长度至少4字节地址功能码CRC低字节CRC高字节才继续第二步做地址匹配地址不等于本机地址也不是广播地址就丢弃第三步用mb_crc16()算出全帧CRC和帧尾的2字节CRC比对不一致直接丢弃第四步才进入功能码分发。这个顺序不能乱——先验CRC再解析功能码能挡掉绝大多数总线噪声造成的脏帧也能防止CRC错误的数据进到寄存器操作逻辑里。有人会问既然都进主循环了收到错帧为什么不回一个异常码我在现场见过不少从站实现这么干结果一帧噪声导致从站回了一个异常响应主站那边逻辑不健壮就误报“设备故障”。Modbus规范里收到CRC错误帧的正确做法是静默丢弃不回任何东西。这个源码包也是这么处理的所以不要去改成回异常帧。3.4 三个最常用的功能码03读保持寄存器、06写单个、16写多个03功能码是从站最常被调用的入口它的请求帧是地址 0x03 起始地址(2字节) 寄存器数量(2字节) CRC。源码包里的实现会先校验请求的地址范围是否越界再校验起始地址加数量是否超过本机寄存器表长度两者任一越界都回异常码0x02非法数据地址。/* 03 功能码响应帧地址功能码字节数寄存器数据CRC */ static void mb_respond_read_holding(uint8_t slave_addr, uint16_t start_addr, uint16_t quantity) { uint8_t resp[128]; uint16_t idx 0; resp[idx] slave_addr; resp[idx] 0x03; resp[idx] (uint8_t)(quantity * 2); for (uint16_t i 0; i quantity; i) { uint16_t val reg_bank.holding_regs[start_addr i]; resp[idx] (uint8_t)(val 8); /* 高字节在前 */ resp[idx] (uint8_t)(val 0xFF); } uint16_t crc mb_crc16(resp, idx); resp[idx] (uint8_t)(crc 0xFF); /* CRC 低字节在前 */ resp[idx] (uint8_t)(crc 8); mb_port_send_frame(resp, idx); }这里最容易写错的地方我已经在注释里标出来了寄存器数据本身是大端传输高字节在前而CRC16的两个字节是低字节在前。这两个“前”的方向完全相反我见过不止一个人把CRC也按大端发出去结果主站那边CRC校验永远失败。另外resp数组长度要按“寄存器数量上限×2 5”来开这个包默认每个响应最大支持125个寄存器协议标准上限所以128字节的缓冲区够用。06功能码是写单个保持寄存器请求帧里带有要写的寄存器地址和值从站执行写操作后把请求帧原样返回作为成功确认。这里有个细节写寄存器操作之前源码包会把新值先写进reg_bank.holding_regs然后再回响应帧。顺序不能反过来因为主站这边通常先收到响应再读确认值如果先回包再写寄存器主站立刻发03读请求时可能读到旧值。16功能码是写多个寄存器响应帧只需返回地址、功能码、起始地址和数量不用回数据这也是新手最容易多写东西的地方。4. 避坑排查485方向切换、CRC16字节序与多站抢答的五个典型问题4.1 第一个坑发送字节全乱码或第一帧发出去就石沉大海现象从站收到请求后回包但主站显示收到乱码偶尔第一个字节正常、后面全错或者示波器上看发送波形畸变严重。 原因485方向脚切换时机不对。很多板子的方向脚是由MCU的GPIO控制的从接收态切到发送态后485收发器内部还需要一小段稳定时间立刻发数据就会导致总线上的电平还没建立好第一个字节就被破坏。另外发完最后一字节后立刻把方向脚拉回接收态发送移位寄存器里可能还有尾字节没送完也会截断帧尾。 解决源码包的mb_port_send_frame()开头先拉高方向脚插入至少0.1毫秒延时9600波特率下再开始逐字节发送发完最后一个字节后再延时0.1毫秒确认数据全部从移位寄存器输出完毕再拉低方向脚。我实际测试发现有些廉价485模块需要0.2毫秒才足够稳定保险起见延时按波特率反比来设。4.2 第二个坑CRC16校验一直失败但数据内容肉眼看着是对的现象用串口助手抓帧地址、功能码、寄存器数据都对但CRC那两个字节跟计算器算出来的不一样。 原因Modbus RTU规定CRC以低字节在前、高字节在后的顺序发送很多人在生成响应帧时把CRC当成普通16位整数直接大端发送。帧内容看起来“差不多”但主站按协议解析时校验不过。 解决发送响应帧时先放crc的低8位再放crc的高8位。对应代码里就是resp[idx] (uint8_t)(crc 0xFF); resp[idx] (uint8_t)(crc 8);我后来养成一个习惯写完CRC发送逻辑后用第三方Modbus调试工具发一条原始帧来验证——直接对比工具显示“计算CRC0x4B39”再数一下我们自己帧里最后两个字节是0x39 0x4B还是0x4B 0x39一眼就定位。4.3 第三个坑读保持寄存器返回的数据跟实际值对不上现象上位机读地址0x0100的寄存器返回的数值像是另外某个地址的数据或者读出来的值是上一次写入的旧值。 原因寄存器地址和数组下标之间少做了一次减一换算。Modbus协议地址从1开始编号而C数组从0开始如果直接把请求帧里的地址字段当数组下标用读到的第一个元素就是holding_regs[1]漏掉了真正的第一个寄存器更麻烦的是边界检查也一起错位。 解决在处理地址时统一用uint16_t index reg_addr - 1;然后再做越界判断判断条件变成index quantity MAX_HOLDING_REGS。源码包在03、06、16三个功能码里都遵守了这个换算如果你自己扩展功能码一定也要在同一个地方做不要三处各写一遍。4.4 第四个坑主站轮询频率一提高从站偶尔不响应或重启现象轮询间隔从100ms改成30ms后从站开始丢响应时间再短从站直接看门狗复位。 原因协议解析被放在了中断里或者某个功能码的写操作直接操作了EEPROM/Flash耗时几百毫秒导致主循环阻塞看门狗没喂上。更隐蔽的情况是在串口接收中断里调用了CRC16计算计算量不大但如果同时开了多个中断且优先级配置不当就会有概率丢字节。 解决严格只做“中断收字节”协议解析和寄存器读写全部挪到主循环。凡是写Flash、写外部EEPROM这类耗时操作做成请求标记返回“正在处理”状态等操作完成后再在下一个轮询周期回包。如果项目必须快速响应我给个建议把串口中断优先级设为最高定时器超时中断次之CRC计算和功能码执行全放主循环。4.5 第五个坑多个从站挂在总线上地址各不相同还是互相干扰现象地址没冲突但主站读某个从站时偶尔收到另一个从站的响应总线上出现两个设备同时发送。 原因两个常见原因。一是某个从站的超时定时器参数设得太大主站连续发多帧时它没能及时切开帧误把两帧当一帧解析然后往后延迟回包正好跟另一个从站的响应撞在一起。二是从站收到广播帧后执行了写操作但没回包不过有些实现误把广播帧也回了导致总线冲突。 解决把每个从站的帧超时时间设成一致且准确——9600波特率下就是40个tick左右不要图省事直接给200。广播帧处理单独走一个分支强制不回包。另外我给每个从站响应前加了一个可配置的“触发延迟”范围0~1000毫秒主站做轮询时给每台设备分配不同的延迟窗口能彻底避免多站抢答。5. 按需求改造裁剪功能码、广播帧处理和超时参数怎么定5.1 功能码按宏裁剪减少Flash占用也缩小协议暴露面很多项目只用到03读和06写16都很少用。源码包在mb_config.h里提供了功能码开关宏关掉不需要的功能码后编译器直接把对应的处理函数排除出编译既省Flash也让协议面更干净。我在实际项目里遇到过一个问题设备挂在总线上主站扫描时发送了不支持的04功能码从站回了异常码0x01非法功能结果某款组态软件把这个异常当成“严重设备故障”直接弹报警。裁剪后从站直接忽略不支持的请求反而更安静。#define MB_SUPPORT_FC03 1 #define MB_SUPPORT_FC06 1 #define MB_SUPPORT_FC16 0 /* 用不到就关掉 */ #define MB_SUPPORT_FC05 0 /* 线圈写入也用不到 */ #define MB_SUPPORT_FC04 0 /* 读输入寄存器可选 */裁剪功能码时要注意一个连带问题如果关掉了16功能码但寄存器初始化代码里有批量写逻辑要考虑改走06单写。我建议在功能码分发入口加一个统一的宏开关不要在每个case里散落#ifdef这样排查问题时只需要看一处。另外异常码回复逻辑不要裁剪——即使某个功能码被关闭收到未知功能码时回0x01这个行为应该保留除非你确定上位机不会因此误报。5.2 广播帧地址0的接入收下数据但不回包广播帧是Modbus里比较特殊的存在地址0表示“所有从站都接收”但它只应用于写命令从站执行写入后不允许回任何字节否则总线会被多个设备同时响应直接打爆。源码包的地址匹配逻辑里单独处理了这一条static uint8_t mb_addr_match(uint8_t rx_addr) { if (rx_addr 0x00) { return 1; /* 广播帧接收但 mb_rtu.c 里会跳过回包 */ } return (rx_addr mb_slave_addr); }我在mb_rtu.c里的分发表看到广播帧处理函数mb_handle_broadcast()只允许执行06和16这类写操作03读操作如果收到广播帧会在地址匹配之后直接判定非法。实际项目中广播帧最常用在“热重启所有设备寄存器到默认值”这种场景比如一台主站批量重置12台从站的参数用广播写16功能码一次搞定比逐台轮询快一个数量级。要注意的是广播帧的CRC校验和其他帧一样严格不能用广播帧测试CRC失效的容错逻辑。5.3 超时、响应时延与轮询间隔参数表这套源码包里跟时间相关的参数有三个帧超时时间、从站响应时延、主站轮询间隔。前两个在从站代码里配第三个在主机侧配。我见过不少项目两边各自都能跑合在一起就断断续续十有八九是这三个参数互相不匹配。下面这张表是我调试时的默认起点具体还要结合示波器实测微调波特率帧超时tick10KHz建议从站响应时延主站最小轮询间隔240018010ms50ms4800805ms30ms9600402ms20ms19200201ms15ms这个表的逻辑是帧超时必须略大于3.5个字符时间从站响应时延要小于主站设置的超时阈值主站轮询间隔要大于“从站最大处理时间总线往返时间”三者按这个顺序去调。如果你用的是115200波特率帧超时tick数会降到个位数这时分频器需要调小让计数分辨率到几十微秒级别否则区分不出帧间隔。另外室内短距离跟线缆超过100米时的信号反射不一样长线时寄存器读错值的概率明显上升可以适当把主站轮询间隔拉大20%。6. 验证协议栈Python模拟主站、逻辑分析仪测帧间隔与断帧测试协议栈移植完我最推荐的验证方式不是直接接真实PLC而是先用Python脚本模拟主站把基础收发打通再用逻辑分析仪看物理层波形。下面这段Python脚本可以发一条完整的03功能码请求帧并解析从站回包import serial, time, struct def modbus_crc16(data: bytes) - int: crc 0xFFFF for byte in data: crc ^ byte for _ in range(8): if crc 0x0001: crc (crc 1) ^ 0xA001 else: crc 1 return crc def read_holding(slave: int, addr: int, qty: int) - bytes: req struct.pack(BBHH, slave, 0x03, addr, qty) crc modbus_crc16(req) req struct.pack(H, crc) return req ser serial.Serial(COM3, 9600, timeout0.3) frame read_holding(0x11, 0x0000, 0x0002) ser.write(frame) resp ser.read(64) # 期望回包长度 qty*25 print( .join(f{b:02X} for b in resp))这段脚本里有两个细节跟从站端代码是对应的请求帧的地址和数量用大端打包BBHHCRC用H小端打包这跟协议定义的“数据大端、CRC低字节在前”完全一致。如果从站回包里的CRC也能被脚本自动校验通过就说明双方的字节序全部对上了。我一般还会把脚本扩展成连续读500次统计成功率这一步能暴露偶发丢帧。逻辑分析仪重点抓三处波形第一处是请求帧和响应帧之间留了多少间隔正常应该大于3.5个字符时间第二处是485方向脚电平切换的瞬间确认切换没有发生在最后一个数据位中间第三处是帧内字节间隔如果超过帧超时阈值说明主站发送端在字节之间故意插了延时从站会把一帧拆成两帧。断帧测试则是人为把一帧数据切成两半发送中间隔50ms验证从站不会把这两段拼成完整帧解析——正确的行为是把前半段静默丢弃。这套验证流程走完后我还会再做一次上电时序测试从站上电后立刻连续发50帧请求确认第一帧请求能正常响应验证复位后串口和定时器初始化顺序没有问题。从那以后我每次换平台或者给别人做代码评审都强制要求把这四项——冒烟收发、连续读写、断帧隔离、上电抢答——走完一遍再出门。这四条里任何一条没过我都不同意的代码合并。希望帮到你。本文还有配套的精品资源点击获取