
简介面向STM32-F0/F1/F2开发者的Modbus通信测试项目内置主从站协议实现并集成串口、定时器、DMA、LED、LCD等外设驱动适合学习工业串行通信与嵌入式外设协作的开发者。压缩包共70个文件、约727KB包含C/H源码、Keil工程文件uvproj/uvopt、编译中间文件o/crf/d以及可直接烧录的hex/axf产物头文件声明接口C文件实现协议与外设逻辑中间文件可辅助分析依赖关系map/hex/axf分别用于查看内存布局、下载固件和调试程序便于在开发板上快速复现Modbus通信流程。已有163人浏览学习。工程按用户、硬件等目录组织涉及Modbus协议栈、串口收发、DMA传输、定时器超时控制、LCD状态显示等功能可帮助理清RTU帧格式、寄存器读写、波特率校验、异常码处理等关键点同时LED/LCD反馈降低了调试门槛适合边读源码边验证。对想深入STM32串口通信与工业总线应用的开发者这是一份既能巩固基础、又能直接参考实现的完整样例无论初学协议还是调试实际设备都有较大帮助。1. 把 Modbus 测试工程烧进 STM32先解决“上位机为什么收不到应答”如果你在单片机/嵌入式 STM32-F0/F1/F2 专区翻到这份“电子-modbusTest.zip”大概率不是来学 Modbus 协议定义的而是手头有一块板子、一个串口助手或 Modbus Poll正等着把从站应答调通。这份资源是一个可以直接烧进 F1 或 F0 系列芯片的 Modbus RTU 从站测试工程里面带好了串口初始化、协议解析、CRC 校验和寄存器映射的完整代码省掉从零搭框架的功夫。适合刚接触 Modbus 的开发者先跑通流程也适合熟手拿来对比自己的状态机设计和超时处理。它解决的核心问题很具体让一块 STM32 作为 Modbus 从站被上位机正确读写而不是卡在“发了报文没反应”的玄学闷局里。2. 先对齐协议和工程结构从 RTU 帧格式到 zip 里的文件布局2.1 Modbus RTU 的帧格式与从站应答规则为什么状态机比中断标志更可靠Modbus RTU 是串口上最常见的工业总线协议一帧报文的结构是从站地址1 字节、功能码1 字节、数据段不定长、CRC16 校验2 字节。主站发请求从站必须应答要么返回正常应答、要么返回异常应答。帧与帧之间要求有至少 3.5 个字符时间的静默间隔这个间隔是协议层判断一帧是否结束的关键。很多人第一次写从站接收时习惯用“串口接收中断里攒字节等接收空闲中断再去解析”。这个思路在 STM32 标准外设库里没问题但有个前提空闲中断和字节间隔要匹配。如果用 DMA 接收的空闲中断帧间隔刚好是 3.5T但 USART 的空闲检测是基于 1 位空闲时间而不是 3.5 个字符所以直接用空闲中断会把一帧拆成多帧。更稳的做法是用一个接收状态机配合定时器测量字符间隔或者用定时器做帧超时。这份工程里走的就是状态机路线每个字节进中断状态机根据当前状态决定是继续收还是判定帧结束。状态机的价值在于它把“什么时候该收、什么时候该处理”变成了显式的逻辑而不是依赖中断标志的时序。比如主站发的是 8 个字节的请求但串口分包到达第一次中断进来 5 个字节第二次进来 3 个字节。如果只靠空闲中断第一次就会触发解析结果帧不完整CRC 必然失败。状态机配合超时定时器只有超过 3.5T 没来新字节才认为帧结束这样分包到达也能合并成完整帧。2.2 解压后的工程目录哪个文件管配置、哪个文件管协议、哪个文件是测试入口这份 zip 解压后是一个标准 Keil 工程没有用 HAL 库而是标准外设库这样在 F0/F1/F2 上都可以直接编译依赖很少。解压后的目录大致如下电子-modbusTest/ ├── USER/ │ ├── main.c // 入口初始化外设启动主循环 │ ├── modbus_slave.c // Modbus 协议解析、功能码处理、异常应答 │ ├── modbus_slave.h // 寄存器映射宏、状态码定义 │ ├── stm32f1xx_it.c // 中断服务函数串口接收、定时器超时 │ └── system_stm32f1xx.c // 时钟初始化 ├── HARDWARE/ │ ├── uart.c // 串口初始化接收中断、发送 │ ├── tim.c // 定时器配置用于帧间隔/超时 │ └── gpio.c // RS485 方向控制引脚配置 ├── LIB/ // 标准外设库不用改 └── Project/ └── modbusTest.uvprojx // Keil 工程文件打开 main.c 能看到主循环里做了三件事查询协议状态机的解析结果、处理需要定时发送的报文、喂软看门狗。uart.c 里是串口底层modbus_slave.c 是整个工程的核心。它对外暴露了一个接口ModbusSlave_Poll()主循环周期调用把接收缓冲区和寄存器数组传进去解析并构造应答。对于初次接触的人我建议先别改协议代码先把 udev 里对应的串口号改成你的实际串口然后打开 uart.c 看USARTx_IRQHandler。这个中断函数里只有三个动作读数据到缓冲区、启动帧超时定时器、把接收指针往后移。解析逻辑完全不在中断里而是在主循环的 Poll 函数中。这样做的好处是把中断处理时间压到最短避免高波特率下中断嵌套导致丢字节。3. 移植到你的板子串口参数、定时器帧超时与寄存器映射3.1 串口参数与 F0/F1 外设差异从 USART 到 GPIO 复用Modbus RTU 最常用的是 9600 波特率、8 位数据位、无校验、1 位停止位即 8N1。这份工程默认也是 8N1波特率在 uart.c 顶部用宏定义#define MODBUS_BAUDRATE 9600初始化过程里先开外设时钟再配置 GPIO 复用然后是 USART 本身。标准外设库的写法是void Modbus_UART_Init(void) { // 使能 USART1 和 GPIOA 时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // TX 推挽复用输出RX 上拉输入 GPIO_InitStructure.GPIO_Pin GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; GPIO_Init(GPIOA, GPIO_InitStructure); USART_InitStructure.USART_BaudRate MODBUS_BAUDRATE; USART_InitStructure.USART_WordLength USART_WordLength_8b; USART_InitStructure.USART_StopBits USART_StopBits_1; USART_InitStructure.USART_Parity USART_Parity_No; USART_InitStructure.USART_Mode USART_Mode_RX | USART_Mode_TX; USART_Init(USART1, USART_InitStructure); USART_Cmd(USART1, ENABLE); }这里有个 F0 和 F1 的差异容易踩F1 的串口 TX/RX 复用是 GPIO_Mode_AF_PPF0 的 GPIO 初始化方式完全不同需要先开启 GPIO 时钟再调用GPIO_PinAFConfig指定复用功能。如果你从 F1 工程直接抄到 F030 上这一行不改串口完全是死的效果——初始化不报错但引脚没有连接到 USART 外设上。参数说明波特率决定了 3.5T 时间。9600 波特率下1 个字符11 位含起始位和停止位约 1.146ms3.5T 约 4.01ms。如果换成 19200这个时间减半定时器分频也要相应调整。工程里默认把定时器设计成可配置的但别忽略波特率更换后要重新计算超时值。3.2 寄存器映射表的设计把线圈、离散量、保持寄存器对应到数组Modbus 从站说到底是把一组内存暴露给主站读写。这份工程里定义了几个数组来映射四种对象线圈0x 开头、离散输入1x、输入寄存器3x、保持寄存器4x。看 modbus_slave.h#define MODBUS_NB_COILS 64 // 线圈数量 #define MODBUS_NB_DISCRETE_IN 64 // 离散输入数量 #define MODBUS_NB_INPUT_REGS 64 // 输入寄存器数量 #define MODBUS_NB_HOLDING_REGS 128 // 保持寄存器数量 extern uint8_t coil_status[MODBUS_NB_COILS]; extern uint8_t discrete_input[MODBUS_NB_DISCRETE_IN]; extern uint16_t input_regs[MODBUS_NB_INPUT_REGS]; extern uint16_t holding_regs[MODBUS_NB_HOLDING_REGS];长度按项目需求改即可。如果你的应用只需要 16 个保持寄存器把MODBUS_NB_HOLDING_REGS改成 16整个工程的缓冲区、越界检查会跟着变化。注意保持寄存器是 16 位无符号整数上位机写入的值如果是负数需要用补码表示工程里没有对写入值做类型转换这一点在对接浮点数时要自己处理。寄存器映射的逻辑在modbus_ReadHoldingRegisters里主站发一个起始地址加数量从站要校验地址越界。工程里统一用addr start addr num start count这样的判断方式防止主站请求超出数组边界。这个判断是 Modbus 从站最容易写错的地方——很多人只判断首地址不判断首地址加数量结果主站连续读 32 个寄存器时后一半数据是缓冲区后面的垃圾值。3.3 地址分配与功能码处理从站地址、广播帧和异常应答的边界从站地址在 modbus_slave.h 里用MODBUS_SLAVE_ADDR定义默认是 1。接收帧的地址字段如果不等于本机地址直接丢弃如果等于 0则是广播帧从站需要执行但没有应答。这个广播逻辑在工作中很容易被忽略但 Modbus 规范就是这么定义的。处理功能码的方式是分支函数uint8_t ModbusSlave_ProcessFrame(uint8_t *frame, uint8_t len) { uint8_t addr frame[0]; uint8_t func frame[1]; if (addr ! MODBUS_SLAVE_ADDR addr ! 0x00) { return 0; // 不是发给本机也不广播直接忽略 } switch (func) { case 0x01: return Modbus_ReadCoilStatus(frame, len); case 0x02: return Modbus_ReadDiscreteInputs(frame, len); case 0x03: return Modbus_ReadHoldingRegisters(frame, len); case 0x04: return Modbus_ReadInputRegisters(frame, len); case 0x05: return Modbus_WriteSingleCoil(frame, len); case 0x06: return Modbus_WriteSingleRegister(frame, len); case 0x0F: return Modbus_WriteMultipleCoils(frame, len); case 0x10: return Modbus_WriteMultipleRegisters(frame, len); default: return Modbus_Exception(addr, func, 0x01); } }每种功能码的调用都会先检查长度。比如 0x01 功能码请求至少 5 个字节地址、功能码、起始地址高、起始地址低、数量高、数量低实际上 6 字节。如果长度不够直接构造异常应答异常码 0x03非法数据值而不是硬解析避免收到不完整帧时访问越界。这里的每个返回函数最终都会把应答帧填到发送缓冲区并设置发送标志。4. 关键代码拆解接收状态机、CRC16 校验与超时判断4.1 接收状态机3.5T 字符间隔怎么用定时器实现Modbus RTU 的帧结束条件是静默超过 3.5T。工程里用一个基本定时器TIM6 或 TIM7来做这个间隔测量。每次收到一个字节就把定时器计数值清零并重新启动如果定时器溢出说明超过 3.5T 没有新字节就把当前缓冲区长度交给解析函数。串口接收中断的代码大致是这样volatile uint8_t recv_buf[MODBUS_RX_BUF_MAX]; volatile uint16_t recv_len 0; void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) ! RESET) { uint8_t data USART_ReceiveData(USART1); if (recv_len MODBUS_RX_BUF_MAX) { recv_buf[recv_len] data; recv_len; } // 复位帧超时定时器 TIM_SetCounter(MODBUS_TIM, 0); TIM_Cmd(MODBUS_TIM, ENABLE); } }定时器溢出中断里做解析void TIM6_IRQHandler(void) { if (TIM_GetITStatus(MODBUS_TIM, TIM_IT_Update) ! RESET) { TIM_ClearITPendingBit(MODBUS_TIM, TIM_IT_Update); TIM_Cmd(MODBUS_TIM, DISABLE); if (recv_len 0) { ModbusSlave_ProcessFrame(recv_buf, recv_len); recv_len 0; } } }这里的关键是换算定时器溢出时间。9600 波特率下 3.5T 大约是 4ms使用 72MHz 时钟定时器预分频设为 7200 后计数频率为 10kHz溢出时间就是(Period 1) * 0.1ms。工程里把 Period 设为 39溢出时间为 4ms。如果改波特率到 19200这个值要跟着减半不然主站已经发下一帧了从站还在等上一帧超时导致连续丢帧。这种实现的另一个好处是无论是一帧完整到达还是半帧后停顿都能被定时器兜住。如果主站发的请求帧被拆成 A 和 B 两段超过 3.5T 后解析器会先把 A 段当作独立帧尝试处理这时长度和 CRC 大概率不对会被判为非法帧丢弃B 段到达后同样无主。所以实际使用中建议上位机的帧间隔尽量短或者干脆用单次请求、等待应答的模式。4.2 CRC16-Modbus 查表法实现与校验陷阱Modbus RTU 的 CRC 校验是 CRC16-IBM 多项式 0x8005初始值 0xFFFF结果低字节在前。工程里用查表法速度最快。表是 256 项的常量数组代码里已经生成好直接用uint16_t Modbus_CRC16(uint8_t *buf, uint16_t len) { uint16_t crc 0xFFFF; while (len--) { crc ^ *buf; for (uint8_t i 0; i 8; i) { if (crc 0x0001) { crc (crc 1) ^ 0xA001; } else { crc 1; } } } return crc; }0xA001是反转后的多项式等价于常规的 0x8005 右移算法。发送时要先发 CRC 的低字节再发高字节。接收校验时要么把收到的整帧含 CRC重新算一遍结果为 0 则正确要么把数据段算出来 CRC再与收到的两个字节逐字节比较。工程里用的是后者因为这样你能直接定位是数据段错了还是 CRC 接收错了。常见的坑是只校验数据位把收到的 CRC 高位、低位倒过来比。主站发来的 CRC 字节序是低位在前比如计算出的 CRC 是 0x1234主站发的第一个 CRC 字节是 0x34第二个是 0x12。如果你用if (crc ((crc_hi 8) | crc_lo))去比永远失败。正确做法是uint16_t crc_calc Modbus_CRC16(frame, len - 2); uint8_t crc_lo frame[len - 2]; uint8_t crc_hi frame[len - 1]; if ((uint16_t)((crc_hi 8) | crc_lo) crc_calc) { // 校验通过 }如果想看到位级别的差别可以在 CRC 计算里插入打印把收到的两个字节和计算值分别以十六进制打印出来一眼就能看出字节序问题。这算是 Modbus 调试中最快定位 CRC 问题的办法。4.3 异常应答与错误码01/02/03/04 什么时候返回Modbus 协议定义了几个标准异常码从站收到格式正确但语义不对的请求时要返回一个异常帧结构是从站地址、功能码 | 0x80、异常码、CRC。这份工程里的异常处理函数是uint8_t Modbus_Exception(uint8_t addr, uint8_t func, uint8_t exception_code) { uint8_t respond[5]; respond[0] addr; respond[1] func | 0x80; respond[2] exception_code; uint16_t crc Modbus_CRC16(respond, 3); respond[3] crc 0xFF; respond[4] (crc 8) 0xFF; // 发送 respond }异常码 01非法功能是最常用的用来响应不支持的 Modbus 功能码。02非法数据地址用于请求的寄存器地址越界。03非法数据值用于请求中写入的值超过量程。04从站设备故障是最后兜底的内部错误时返回。需要注意的是异常应答本身也带 CRC而且从站必须正确发送。有些新手在解析异常时只关注功能码忘记校验整个异常帧的 CRC结果上位机反复报错。这个在调试阶段最容易伪装成“从站没应答”其实是应答帧的 CRC 算错了被主站丢弃。工程里每个功能码函数在返回异常时都走同一个Modbus_Exception通道不会出现不同函数对同一个错误码却拼出不同帧结构的情况。这个设计很朴素但确实能减少低级错误。5. 避坑排查从串口乱码到连接不上五个最常见的 Modbus 测试血泪问题5.1 现象上位机收不到任何应答串口助手发出请求后一片空白原因大概率是串口引脚配置不对尤其是从 F1 移植到 F0 时GPIO 复用模式没有改。F1 的 USART1 TX 是 PA9配置成 AF_PP 就能用F0 需要先调GPIO_PinAFConfig(GPIOA, GPIO_PinSource9, GPIO_AF_1)否则 PA9 就是普通引脚数据根本送不出去。解决方法是打开 GPIO 复用功能头文件确认该芯片的 AF 映射表。另一个常见原因是 RS485 芯片的收发方向控制引脚没有初始化如果板子上有 RS485必须保证发送前把 DE 拉高接收时拉低否则总线一直被占用。5.2 现象偶发应答错误上位机显示 CRC 校验失败重置一次又好了原因是中断优先级配置不当串口接收中断和定时器中断互相抢占导致定时器超时中断把接收缓冲区清空了或者接收中断被打断丢失字节。尤其是 F1 的 NVIC 优先级分组是抢占优先级和子优先级共用 4 位如果串口中断被其他外设中断抢占刚好丢的是帧中间的数据CRC 自然错。解决方法是把串口中断优先级设成最高抢占优先级为 0定时器中断设成比它低一级比如抢占优先级为 1确保接收中断只要触发就能立刻读走数据。同时检查中断服务函数里有没有做耗时操作比如在串口中断里调printf这种写法会直接废掉高波特率接收。5.3 现象用 USB 转串口调试正常换成 RS485 就完全不通原因是 RS485 的半双工切换时机不对。发送完最后一个字节后不能立刻把 DE 拉低因为串口移位寄存器可能还没发完。正确做法是在发送完成中断中延时一段时间常见为 1 个字节时间的 2 倍或者等 USART_FLAG_TC 置位后再拉低 DE。这份工程里给了方向控制引脚的示例但没说延时实际使用建议在发送函数末尾加一个while (!USART_GetFlagStatus(USART1, USART_FLAG_TC));之后再拉低 DE。如果省略这一步最后一个字节会被截断上位机看到的帧长度少一个字节或 CRC 错。5.4 现象Modbus Poll 能读但写入保持寄存器后值会变且只在一段地址上生效原因是寄存器数组的大小没跟上地址范围或者请求的数量超过了实际数组边界。比如你定义了MODBUS_NB_HOLDING_REGS 128但主站一次写 130 个寄存器工程里的判断应该返回异常码 02。如果还是能写说明判断逻辑写成了只检查起始地址没有检查start num。解决办法是把所有地址越界判断统一为区间判断并在写操作函数里先计算请求数量超出数组长度直接返回 0x02。调试时可以在写函数入口加一个断点看调用参数里的num是不是超出预期。5.5 现象上电后第一次请求正常第二次就超时之后又恢复原因是缓冲区没有在解析完成后清空或者上一次应答还没发完就接收新请求。Modbus 从站必须串行处理请求来了解析应答发完再进入空闲接收。如果上电后第一次请求恰好和工程初始化错开之后每次处理完没有把recv_len归零第二次请求的长度会变成之前累计的长度加新长度CRC 必然失败。解决方法是检查定时器中断里的recv_len 0;这一行是否在解析函数调用前后都执行。解析函数内部也要在构造应答后把发送完成标志清掉避免USART_SendData在缓冲区未空时重复调用。6. 进阶验证用 Modbus Poll 做一致性测试和寄存器回环技巧当从站能通上电、能被 Modbus Poll 读到一个保持寄存器时别急着去联调业务逻辑先花十几分钟做一轮协议一致性测试。我一般会先把保持寄存器映射里几个固定地址初始化为已知值比如holding_regs[0] 0xAAAA;然后在上位机里读 0 号寄存器看返回的十六进制是不是 AAAA。这一步能排除字节序问题。接着写一个回环自检在holding_regs[1]里写 0x1234然后立刻读holding_regs[1]比较写入值和读取值。如果值变了说明校验或者地址解析可能有歧义比如地址偏移差了 1。很多 Modbus 主站在地址上是从 0 开始编号而有的组态软件是从 1 开始这会导致读出的数据一直错位。解决办法是在寄存器映射上做统一约定物理地址数组下标 0 对应协议地址 40001偏移量在代码里固定住不跟上位机的显示编号摆动。每完成一项验证就用波特率为 9600、数据位 8、停止位 1 的固定参数跑一轮连续读测试建议连续读 100 次每次间隔 50ms 以上。一旦出现 CRC 错立刻停下来查看中断代码里是否丢失了字节。这类连续性测试最容易暴露中断优先级和帧时序问题。如果连续读 100 次全部通过基本可以认定底层收发可靠。我自己的习惯是每次拿到一个 Modbus 资源或工程第一件事不是去看代码结构而是先看它的帧超时定时器配置值。把波特率换算成微秒后再对照 3.5T 的时间如果偏差超过 20%立刻改掉。这条经验让我少走了很多弯路。希望这份工程的拆解能帮你少花一个下午在“明明什么都是对的就是不通”的排查上希望帮到你。本文还有配套的精品资源点击获取