
简介这是一份面向嵌入式开发者的STM32 Modbus通信移植资料包基于硬件抽象层库实现了Modbus RTU协议的完整工程源码与配套说明既适合需要快速在STM32平台上集成Modbus通信的工程师也适合从零入门协议栈移植的学习者。压缩包共包含551个文件整体大小约18.22兆字节主体为C语言源文件、头文件、汇编及链接脚本等代码文件同时附带了常用集成开发环境的工程配置、编译生成的hex/axf固件以及PDF说明文档目录划分清晰方便按模块查阅和对照实际工程学习。资源深入展示了Modbus链路建立的完整过程包括定时器波特率生成、串口参数配置、中断方式帧接收、CRC16循环冗余校验、异常处理与功能码应答等关键环节并结合实际开发板工程梳理了从底层驱动到协议帧处理的实现路径。目前已有262人学习使用对正着手进行STM32的Modbus移植或相关工业通信开发的读者具有直接参考价值。1. 一条串口线上挂着几十台设备ModbusHAL 库版本解决的是什么Modbus 的协议格式简单到可以用一张 A4 纸写清但它在 STM32 上跑不稳的情况十有八九不是协议问题而是帧边界切错、CRC 算反、485 方向没切换干净。你在搜索里输入“modbus stm32 hal”找到的多数是以 .rar 打包的 HAL 库版本工程源码解压后通常是 CubeMX 工程加一套主从机 demo真正要让这套代码从“能通信”变成“能上线”需要把帧接收模型、超时策略和寄存器映射重新梳理一遍。这篇就按我平时在 STM32 上写 Modbus RTU 的路线来讲覆盖主机轮询、从机应答、485 半双工切换和联调验证新手能照着搭熟手也能对照检查自己的状态机边界。2. Modbus RTU 帧结构、CRC16 与 HAL 库接收窗口2.1 RTU 帧的三个硬指标帧间隔、字节间隔、CRC 校验Modbus RTU 的报文本身没有起始符和结束符它靠时间间隔来切帧一帧开始前要有至少 3.5 个字符时间的静默帧内两个字节之间的间隔不能超过 1.5 个字符时间。也就是说决定一帧“借宿”的不是缓冲区里有没有数据而是总线空闲了多久。波特率 9600 时1 个字符按 11 bit 算1 起始位 8 数据位 1 校验位 1 停止位3.5 个字符大约 4 ms这也是接收端判断帧结束的最小窗口。常用功能码里03 读保持寄存器、06 写单寄存器、160x10写多寄存器覆盖了大多数设备控制场景。帧格式很固定地址 1 字节、功能码 1 字节、数据 N 字节、CRC16 低字节在前共 2 字节。下面这张表是我在实际项目中默认参考的配置具体波特率和校验位以对端设备说明书为准。参数常见设置说明波特率9600 / 19200 / 115200现场总线常用 9600调试时可提高数据位8RTU 模式固定校验位偶校验EVEN或 无NONE部分 PLC 默认 8E1也有用 8N1停止位1带校验时建议 1无校验可配 2帧间隔3.5 字符时间接收端断帧依据需要强调一个很多人忽略的点Modbus RTU 里 CRC 校验是“必须做而不是建议做”。串口在工业现场受干扰时字节错位后功能码可能恰好合法没有 CRC你会把一个坏帧当成有效数据写进寄存器。3.5 字符时间在代码里建议用微秒级参数化不要硬编码“延时 5 ms”这种魔法值。计算方式为字符时间 11 / 波特率秒3.5 字符就是 3.5 × 11 / 波特率。9600 bps 时约 4010 µs19200 时约 2005 µs这样后续换波特率断帧逻辑不用改。2.2 用 CubeMX 把 USART 和定时器配置成可收发状态第一步是在 STM32CubeMX 里选定串口外设配置异步模式参数按上一节表格选一组。生成代码时记得勾选中断HAL 库默认把接收中断回调挂在HAL_UART_RxCpltCallback上。如果板子上是 RS485 接口一般还要留一个 GPIO 做方向控制DE/RE这个引脚不归串口外设管方法见第 5 章。工程生成的main.c中相关初始化类似这样huart2.Instance USART2; huart2.Init.BaudRate 9600; huart2.Init.WordLength UART_WORDLENGTH_8B; huart2.Init.StopBits UART_STOPBITS_1; huart2.Init.Parity UART_PARITY_EVEN; huart2.Init.Mode UART_MODE_TX_RX; huart2.Init.HwFlowCtl UART_HWCONTROL_NONE; HAL_UART_Init(huart2);参数里Parity改成UART_PARITY_NONE就是 8N1改动只会影响 CRC 之外的数据位排布协议帧结构不变。生成工程后我一般会先把HAL_UART_Receive_IT打开从单字节接收开始调而不是一上来就上 DMA这样出问题时更容易定位是字节没进中断还是帧切错了。2.3 CRC16-Modbus 查表实现CRC16-Modbus 的算法特征是初值 0xFFFF多项式 0xA001反向结果低字节先发。很多从机对 CRC 没有容错收发两端表不一致就会稳定失败。这里给出查表法实现运行前调用一次初始化生成 256 项表之后每帧计算只需要查表逐字节处理。static uint16_t s_crc_table[256]; void MODBUS_CRC_Init(void) { for (uint16_t i 0; i 256; i) { uint16_t crc i; for (uint8_t bit 0; bit 8; bit) { crc (crc 1U) ? (crc 1U) ^ 0xA001U : (crc 1U); } s_crc_table[i] crc; } } uint16_t MODBUS_CRC16(const uint8_t *buf, uint16_t len) { uint16_t crc 0xFFFFU; while (len--) { crc (crc 8U) ^ s_crc_table[(crc ^ *buf) 0xFFU]; } return crc; }查表法的逻辑是每次取出当前字节与 CRC 低 8 位异或作为查表下标用表值去异或原来的高 8 位。发送时注意字节序crc 0xFF先发crc 8后发。调试时最容易犯的错是发高位在前表现为主机和从机各自计算的 CRC 都对但对方永远回异常。2.4 单字节中断接收 3.5 字符断帧的 HAL 写法接收端拆帧的通用做法是串口每收到一个字节进中断把字节存进缓冲区并刷新“最后字节时间戳”主循环或定时器里检查当前时间与时间戳之差超过 3.5 字符时间就认为一帧收完。先给一个基于HAL_GetTick的版本逻辑直观typedef struct { uint8_t frame[256]; uint16_t len; uint32_t last_tick; uint8_t ready; } ModbusRx_t; ModbusRx_t g_rx; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART2) { g_rx.frame[g_rx.len] g_rx.byte; g_rx.last_tick HAL_GetTick(); HAL_UART_Receive_IT(huart, g_rx.byte, 1); } }注意回调里会再次调用HAL_UART_Receive_IT这样才形成持续的单字节接收链。主循环里每 5 ms 检查一次void MODBUS_RxFrameCheck(void) { uint32_t idle HAL_GetTick() - g_rx.last_tick; if ((g_rx.len 0) (idle g_rx.frame_gap_ms)) { g_rx.ready 1; } }frame_gap_ms按 3.5 字符时间向上取整9600 波特率时给 5 ms。HAL_GetTick()的粒度是 1 ms9600 波特率下字节间隔约 1.15 ms判断帧结束误差在 1 ms 以内多数场景够用但 115200 波特率时 3.5 字符时间只有约 334 µs1 ms 时间戳根本切不准。这时建议换定时器输入捕获或者直接用 HAL 的 DMA 空闲中断方案也就是HAL_UARTEx_ReceiveToIdle_DMAHAL_UARTEx_RxEventCallback空闲中断由硬件判定精度比 Tick 可靠。3. 主站请求调度从超时重试到多从机轮询3.1 把请求封装成结构体与状态机主站的本质是定时向多个从机发起请求等待响应超时后重试然后切到下一台。这里我习惯用一个请求结构体加一个四状态状态机而不是每台设备单独写收发代码。typedef enum { MB_IDLE, MB_TX_WAIT, MB_RX_WAIT, MB_RX_COMPLETE } MbMasterState_t; typedef struct { uint8_t slave_addr; uint8_t func; uint16_t reg_start; uint16_t reg_count; uint16_t timeout_ms; uint8_t retry_left; } MbRequest_t;slave_addr是从机站号func是功能码reg_start和reg_count决定要读写的寄存器范围。retry_left记录剩余重试次数默认我给 2 次即最多请求 3 遍。状态机的迁移规则是IDLE 时若请求队列非空就拼帧并发送发送完成后进入 RX_WAIT在 RX_WAIT 里校验响应成功则转到 RX_COMPLETE 由业务层取数据超时则扣减重试次数后重新发送或回到 IDLE。3.2 请求帧拼装与发送回调以读保持寄存器为例拼帧函数如下uint8_t tx_buf[16]; uint8_t MODBUS_BuildReadFrame(MbRequest_t *req, uint8_t *out) { uint8_t len 0; out[len] req-slave_addr; out[len] req-func; /* 0x03 */ out[len] (req-reg_start 8) 0xFF; out[len] req-reg_start 0xFF; out[len] (req-reg_count 8) 0xFF; out[len] req-reg_count 0xFF; uint16_t crc MODBUS_CRC16(out, len); out[len] crc 0xFF; out[len] crc 8; return len; }这段的逻辑很简单先按 RTU 大端方式填入地址、功能码、寄存器起始号和数量然后整段算 CRC低字节在前追加到末尾。发送用中断方式比较稳妥HAL_UART_Transmit_IT不阻塞主循环HAL_UART_Transmit_IT(huart2, tx_buf, len);发送完成后 HAL 回调HAL_UART_TxCpltCallback在这里置一个发送完成标志同时如果是 RS485 半双工此时才允许把方向引脚拉回接收。需要留意的是HAL_UART_Transmit_IT内部对传入缓冲区有要求如果不是静态数组必须在回调置标志之前保证缓冲区不被改写。这也是我坚持用模块级静态tx_buf的原因。3.3 在轮询函数里处理超时、重试与下一次请求主站逻辑集中在一个 5 ms 周期调用的函数里避免把协议处理散落到各处中断void MODBUS_MasterPoll(void) { switch (g_master.state) { case MB_IDLE: if (g_req_queue_cnt 0) { g_req_now g_req_queue[0]; uint8_t len MODBUS_BuildReadFrame(g_req_now, tx_buf); HAL_UART_Transmit_IT(huart2, tx_buf, len); g_req_start_tick HAL_GetTick(); g_master.state MB_RX_WAIT; } break; case MB_RX_WAIT: if (g_rx.ready) { g_rx.ready 0; if (MODBUS_CheckResponse(g_rx.frame, g_rx.len, g_req_now)) { g_master.state MB_IDLE; MODBUS_QueuePop(); } else { MODBUS_RetryOrNext(); } } else if ((HAL_GetTick() - g_req_start_tick) g_req_now.timeout_ms) { MODBUS_RetryOrNext(); } break; default: break; } }这里的MODBUS_CheckResponse做三件事校验 CRC、核对从机地址、核对功能码是否与请求一致。如果响应功能码最高位是 1说明从机返回的是异常码此时应按正常响应处理而不是反复重试。超时判断用HAL_GetTick()差值天然规避了 Tick 回绕问题。多从机轮询只需要把请求队列换成循环数组每次状态回 IDLE 后取下一项即可不需要为每个从机建独立状态机。3.4 主站常见坑CRC 字节序与响应长度匹配主站最容易踩的坑有三个一个是 CRC 高低字节顺序反了收发两端都验不过一个是按固定长度接收响应没有根据功能码和寄存器数量计算期望长度导致把半截帧当完整帧还有一个是收到广播请求的从机不应答却去等待它的响应直到超时。前两个属于代码问题第三个是协议理解问题——地址 0 是广播地址所有从机执行动作但不回帧主站对广播请求必须直接跳过等待阶段。4. 从机侧用寄存器表把协议栈接到业务变量4.1 从机帧接收与地址过滤从机的接收模型和主机一致都是“单字节中断收 定时断帧”。区别在于收到完整帧后要做的不是校验请求而是按本机地址决定是否处理。地址匹配有三种可能广播地址 0 必须处理但不回复等于本机地址时正常处理并回复两者都不是则直接丢弃整帧。void MODBUS_SlaveProcessFrame(uint8_t *frame, uint16_t len) { if (MODBUS_CRC16(frame, len) ! 0) { return; /* CRC 校验失败直接丢弃 */ } uint8_t addr frame[0]; uint8_t func frame[1]; if (addr ! MB_SLAVE_ADDR addr ! 0x00) { return; } MODBUS_SlaveDispatch(addr, func, frame[2], len - 4); }接收完的帧最后两字节就是 CRC所以对整帧重算 CRC 后结果应为 0。这个判断和“取出 CRC 再比较”等效但少一次字节拷贝。len - 4是去掉地址、功能码和 2 字节 CRC 后的数据长度。4.2 03/06/16 功能码的请求分发与异常码从机收到合法帧后开始分发。这里用一个内部函数表处理不同的功能码每个处理函数返回 0 表示成功返回非 0 表示异常码。异常码固定是01 非法功能、02 非法数据地址、03 非法数据值。static uint16_t s_holding_regs[64]; uint8_t MODBUS_ReadHoldingRegs(uint16_t start, uint16_t count, uint8_t *resp_data) { if ((start count) 64) { return 0x02; /* 地址越界 */ } for (uint16_t i 0; i count; i) { resp_data[i * 2] (s_holding_regs[start i] 8) 0xFF; resp_data[i * 2 1] s_holding_regs[start i] 0xFF; } return 0; }响应帧组装顺势复用发送用的tx_buf地址、功能码、字节数、数据再补 CRC。06 写单寄存器的响应规则是原样回显请求帧16 写多寄存器则只回地址、功能码、起始地址和寄存器数量。很多初学实现会把 16 功能码响应也包含全部写入的数据从机这样发主机也能接受但不标准按 Modbus 规范写多寄存器的响应只有 8 字节。表驱动分发的好处是新增功能码时只需加一个分支和一个处理函数不需要动接收逻辑switch (func) { case 0x03: ret MODBUS_ReadHoldingRegs(start, count, resp[2]); break; case 0x06: ret MODBUS_WriteSingleReg(start, value); break; case 0x10: ret MODBUS_WriteMultiRegs(start, count, data); break; default: ret 0x01; break; }业务代码与协议栈的边界就是s_holding_regs这张表。控制类设备把启停、转速、报警标志映射到表中采集类设备在定时任务里刷新表中的测量值主机发 03 读到的永远是最近一次刷新结果。4.3 寄存器表与业务变量的三种映射方式实际项目中寄存器表不能只是一个裸数组因为寄存器里存的往往不是原始整数而是经过线性变换的工程量。常见做法有三种按复杂度递增映射方式实现思路适用场景直接数组业务代码直接读写s_holding_regs[index]测试、变量简单的小板卡回调函数寄存器读写触发read_cb/write_cb需要单位换算、上下限限制内存镜像 同步任务业务变量镜像到寄存器区定时同步参数频繁变化、需要掉电保存我倾向至少用回调函数写寄存器时做范围检查和联动动作而不是让协议层直接改业务结构体。比如写入转速寄存器 0x1000write_cb里同时把 PWM 比较值更新这样协议解析和电机控制解耦出问题时只需要盯回调函数。5. RS485 方向切换、中断协同与字节超时的边界5.1 DE/RE 方向引脚在 HAL 发送完成回调里切换RS485 是半双工收发共用一个差分对所以 DE/RE 引脚必须在发送前置为发送方向发完后拉回接收方向。方向切换时机稍有偏差就会出现两个问题发完后立刻切接收最后一个停止位还没发完总线上产生残波接收端把残波当成帧头切早了则自己发的数据回环进接收缓冲区干扰后续帧判断。推荐的做法是在HAL_UART_TxCpltCallback中等待 TC 标志后再切方向extern UART_HandleTypeDef huart2; void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART2) { while (__HAL_UART_GET_FLAG(huart, UART_FLAG_TC) RESET) { } HAL_GPIO_WritePin(RS485_DE_GPIO_Port, RS485_DE_Pin, GPIO_PIN_RESET); } }HAL_UART_TxCpltCallback在最后一个数据字节写入数据寄存器后触发但此时移位寄存器可能还在输出停止位因此要等TCTransmission Complete置位才表示停止位也送完了。这个while等待在最坏情况下只有一个 bit 时间9600 波特率约 104 µs放在中断回调里问题不大如果追求更稳可以把 TC 中断打开在 TC 中断里拉低方向。5.2 为什么发送完成后不能立刻拉低方向TC 标志与停止位很多人第一次调 485 发现“能收能发但总是多收一两个字节”原因就在这里。发送 API 返回时数据只进了发送缓冲区只有TC标志置位才算真正送完。若在这一刻之前切到接收方向本端会立刻收到自己发出的残波数据。处理手段有两种一是上面说的等待 TC二是在方向引脚上做硬件延时。软件方案更可靠不依赖具体芯片的电气特性。另外方向切换必须放在HAL_UART_TxCpltCallback而不是HAL_UART_Transmit_IT调用之后因为后者只是注册了发送任务回调才是真实发送结束点。中断里不要做重活只保留 GPIO 操作和标志位更新。5.3 中断优先级与帧接收窗口的配合从机端的中断协同有一个典型矛盾串口接收中断希望在字节到达时立刻把数据存下来而断帧定时器又需要稳定推进。如果把串口中断优先级低于某个频繁触发的外设中断字节间隔会被拉长到超过 1.5 字符时间接收端就会把一个正常帧切成好几段最后 CRC 全错。中断源抢占优先级建议说明USART 接收中断最高0字节存储不能被打断太久定时器断帧中断次高1提供毫秒/微秒级时间基准SysTick默认可低于串口只用于 Tick 计数如果使用 DMA 空闲接收DMA 搬运本身不占 CPU串口中断优先级可以适当降低。要注意的是 HAL 库的HAL_UART_Receive_IT在回调里再次调用时中断持续开启如果业务在中断里做了耗时的数组遍历或 CRC 计算会影响其他外设的实时性。我一般只在回调里存字节、刷新时间戳、置标志帧解析全部放到主循环或协议任务中执行。6. 联调技巧让 Modbus Poll 和报文时间戳一起上阵6.1 先用 Modbus Poll 验证从机再用真实主机跑轮询新板子调协议我不会一上来就两台设备对联而是先把从机代码烧进板子用 PC 上的 Modbus Poll 作为主站去读寄存器。Modbus Poll 是调试工具不是协议栈它按照你填写的从机地址、功能码和寄存器范围周期性发报文能直观看到哪一帧超时、返回什么异常码。验证步骤串口接 USB 转 485设置与从机一致的波特率和校验位地址填从机地址功能码选 03 保持寄存器地址和数量按寄存器表填。若 Poll 能持续读到值说明从机的接收、CRC、响应链路已经通了若超时先用示波器或逻辑分析仪看总线上有没有完整波形再回头查帧间隔和方向切换。6.2 用 DWT 统计一帧往返时间最后给一个排查性能边界时很实用的技巧用 Cortex-M 内核的 DWT 周期计数器做响应时间统计精度比HAL_GetTick高得多。void DWT_Init(void) { CoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; DWT-CYCCNT 0; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk; } uint32_t DWT_GetCycles(void) { return DWT-CYCCNT; }在发送请求前记录t_start收到完整帧后记录t_end往返时间毫秒数就是(t_end - t_start) / (SystemCoreClock / 1000)。这里用无符号差值同样不用处理回绕。用这套统计去对比理论值有个经验公式一帧往返时间大致等于请求字节数 响应字节数 2 × 3.5 字符× 11 / 波特率。9600 波特率下读 1 个保持寄存器请求 8 字节响应 9 字节理论值约 (8 9 7) × 11 / 9600 ≈ 27.5 ms如果实测多出 30% 以上优先检查主站发送完成后是否多等了不必要的延时以及 485 方向切换是不是在 TC 之后执行。本文还有配套的精品资源点击获取