ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

STM32+FreeRTOS实现Modbus RTU从机:RS485通信实战指南

STM32+FreeRTOS实现Modbus RTU从机:RS485通信实战指南 简介面向STM32F407嵌入式开发者的Modbus从站移植参考工程基于HAL库实现RS485通信并整合FreeRTOS实时操作系统适合需要快速搭建工业通信节点或学习协议栈集成的中高级开发者。资源包共1719个文件压缩后17.19MB以C语言源码和头文件为主包含启动汇编文件、工程配置、链接脚本、Hex固件及文本说明可覆盖从编译到烧录的完整开发链路。工程中已集成FreeModbus协议栈提供CubeMX初始化配置、UART/TIM回调处理、RS485方向控制及FreeRTOS任务挂载等关键实现方便对照理解Modbus从站状态机与实时系统协同工作的原理。目前已有1428人学习下载对希望基于STM32F407快速开展Modbus设备开发或验证通信方案的读者具有直接参考价值。 前阵子给一台环境监测设备做下位机主控是STM32F407跑着FreeRTOS要跟PLC通过Modbus RTU通信物理层走RS485。按理说Modbus从机移植是老话题了网上例程一抓一大把可真到了既有实时操作系统、又用RS485、还得按工业现场那种不太友好的时序跑的时候光那些教学Demo根本不够看。我前后折腾了几天把一套能稳定跑起来的工程结构梳理出来了顺手把踩过的坑也记下来给后面要做同样事情的兄弟省点时间。这篇文章适合谁手头有STM32F407探索者或类似板子想用HAL库把Modbus从机跑起来或者已经在裸机上写好了Modbus正准备把工程迁到FreeRTOS上做任务拆分的人。内容从CubeMX配置串口讲起到协议解析、任务划分、RS485方向切换最后是联调时最容易翻车的几个问题全程用代码说话。1. 为什么下位机通信要这么折腾裸机方案在真实场景里的局限先说个我亲身经历的现象。最开始我偷懒没用RTOS直接在超级循环里轮询收串口、解析Modbus帧。单独测试一点问题没有但把温湿度传感器采样、OLED刷新、几个继电器控制全塞进主循环之后Modbus Poll那边开始时不时报超时和CRC错误。后来用调试器打印才发现主循环里一旦执行I2C读传感器或者清屏这种耗时操作串口收帧的间隙就被拉长Modbus RTU的帧间隔判断直接乱套。Modbus RTU协议有个硬性要求帧内字节间隔不能超过1.5个字符时间帧结束要有3.5个字符时间的静默。波特率9600下1个字符大约1ms也就是说主循环里只要卡个几毫秒一帧数据就可能被错误拆成两段或者跟下一帧黏在一起。裸机超级循环做不到串口来数据立刻处理这种实时性因为循环里其他任务的耗时是不可控的。这正是引入FreeRTOS的核心原因把通信这件事从主循环里摘出来串口中断负责第一时间收数据协议解析和应答专门跑一个独立任务和其他业务任务隔离。但加RTOS也不是一劳永逸它只是把任务分工理清了Modbus协议本身、RS485物理层方向切换、HAL库的回调机制、中断和任务的协作方式任何一个环节配合不到位照样翻车。这套方案每一层的职责我整理成了表格方便后面展开讲层级选型承担的工作物理层RS485MAX3485或类似芯片差分信号传输抗干扰支持总线挂多从机数据链路层UART 8N1字节流的收发应用层Modbus RTU协议地址寻址、功能码解析、寄存器读写软件框架HAL库 FreeRTOS外设驱动、中断处理、任务调度、数据传递这套组合在工业现场非常常见F407性能足够跑点中等复杂度的业务逻辑HAL库配合CubeMX图形化配置大幅减少初始化代码FreeRTOS解决了多任务调度问题RS485则扛住了长距离和强干扰的物理层需求。下面从串口和RS485方向控制开始说起这一步错了后面全白搭。2. HAL库侧UART与RS485方向控制没有正确配置后面全是坑2.1 CubeMX里串口参数怎么给最稳妥CubeMX里配置串口本身很简单但有几个细节直接影响Modbus运行。我用的USART2波特率96008位数据位无校验1位停止位关闭硬件流控。波特率这边多说一句Modbus RTU在工业现场最常用的是9600虽然F407跑到115200甚至更高都没问题但很多老旧PLC和仪表只支持9600现场联调时按9600来最省心。打开串口全局中断这一步很容易漏漏了中断里收不到数据。RS485方向控制引脚我用的是普通GPIO接到MAX3485的DE和RE引脚。实际电路里DE和RE通常直接短接在一起高电平进入发送模式低电平回到接收模式。具体极性要对照你的板子原理图确认有的板子做了反相代码写反了就会变成发不出也收不到。2.2 接收方式为什么我推荐用空闲中断判断帧结束HAL库处理串口接收有几种方式HAL_UART_Receive_IT一次只能接收指定数量的字节不太适合Modbus这种不定长帧HAL_UART_Receive_DMA配合空闲中断是很好的组合但最简单直接的是直接用HAL_UARTEx_ReceiveToIdle_IT这个接口是HAL库较新版本提供的接收线上空闲时自动触发事件回调天然适合Modbus RTU的帧结束判断。如果你的HAL库版本比较老没有这个接口也可以手动在UART中断里判断IDLE标志位。我这边用新版HAL库初始化代码如下uint8_t rx_buf[256]; volatile uint16_t rx_len 0; void MX_USART2_UART_Init(void) { huart2.Instance USART2; huart2.Init.BaudRate 9600; huart2.Init.WordLength UART_WORDLENGTH_8B; huart2.Init.StopBits UART_STOPBITS_1; huart2.Init.Parity UART_PARITY_NONE; huart2.Init.Mode UART_MODE_TX_RX; huart2.Init.HwFlowCtl UART_HWCONTROL_NONE; huart2.Init.OverSampling UART_OVERSAMPLING_16; HAL_UART_Init(huart2); HAL_UARTEx_ReceiveToIdle_IT(huart2, rx_buf, 256); }第三个参数256是最大接收长度超过这个长度也会触发事件回调Modbus RTU最大帧长一般不会超过256字节所以给够就行。每次传输完成后需要重新调用这个函数否则后续帧收不进来。事件回调里做两件事记录本次收到的字节数然后通过队列通知Modbus任务来处理。注意Size参数是实际收到的字节数不是缓冲长度别拿错void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart-Instance USART2) { rx_len Size; BaseType_t xHigherPriorityTaskWoken pdFALSE; xQueueSendFromISR(modbus_rx_queue, (void*)rx_len, xHigherPriorityTaskWoken); portYIELD_FROM_ISR(xHigherPriorityTaskWoken); HAL_UARTEx_ReceiveToIdle_IT(huart2, rx_buf, 256); } }这里有个容易踩的坑必须在一个字节到达后、总线空闲达到一个字符时间时IDLE标志才会置位。很多主站尤其PLC发送Modbus帧时帧内字节是连续发出的中间没有明显停顿所以这个1字符空闲的时机刚好落在整帧结束后能准确判断一帧完成。但如果主站发帧不连续、中途有停顿就会把一帧拆成多段触发多次回调这个问题我在后面第5章专门讲解决方案。2.3 RS485方向切换的时机差之毫厘谬以千里RS485是半双工总线同一时刻只能有一个节点发送。从机平时处于接收模式需要应答时才切到发送模式发完立刻切回接收。这句话听起来简单但方向切换的时机差一点点就出问题。先说错误示范。有些人往UART数据寄存器写入最后一个字节后火急火燎把DE拉低结果主站收到的帧少一个字节CRC永远校验不过。原因在于UART发送有发送数据寄存器和移位寄存器两级缓冲往DR写入数据只表示数据进了第一级缓冲真正从TX引脚发出去还要等当前位移完。如果这时候切回接收模式最后一个字节就被硬生生掐断了。正确姿势是确保所有字节真正从引脚上发出去之后再切换方向。HAL库的阻塞发送HAL_UART_Transmit内部会等待TC发送完成标志置位所以用它最省事void modbus_rs485_send(uint8_t *buf, uint16_t len) { HAL_GPIO_WritePin(RS485_DE_PORT, RS485_DE_PIN, GPIO_PIN_SET); // 进入发送模式 HAL_UART_Transmit(huart2, buf, len, 100); HAL_GPIO_WritePin(RS485_DE_PORT, RS485_DE_PIN, GPIO_PIN_RESET); // 切回接收模式 }注意HAL_UART_Transmit的第四个参数是超时时间这里给100ms。Modbus应答帧一般不超过几十个字节9600波特率下全部发完也就几十毫秒100ms足够。实际开发中这100ms是全阻塞的意味着调用它的任务会一直卡在这里直到发完。放在RTOS环境里只要通信任务的优先级合理这点阻塞完全可接受后面第4章展开讲。3. Modbus RTU从机协议层状态机解帧、CRC校验与寄存器表3.1 从机处理一帧请求的完整流程串口层收完一帧数据后协议层要按固定顺序处理先做地址匹配再校验CRC然后解析功能码最后操作寄存器并组织应答。顺序不能乱尤其是地址不匹配时直接丢弃、不回复这一点是RS485总线挂多从机的基石。总线上所有从机都收到主站的帧但只有地址匹配的那个从机应答其他从机保持静默。地址匹配通过后接下来校验CRC。CRC不对直接丢弃也不回复。很多初学者在CRC错误时会尝试回一个异常帧这在工业现场反而是添乱——CRC错误大概率是总线干扰或者帧被截断回复异常帧只会让主站误以为是有效响应。功能码处理时不支持的返回异常码0x01非法功能码寄存器地址越界返回0x02非法数据地址寄存器数量或数值不合法返回0x03非法数据值。异常应答的格式是从机地址 (功能码 | 0x80) 异常码 CRC。3.2 CRC16的查表实现和字节序坑Modbus RTU使用的CRC16叫CRC16-MODBUS多项式0x8005初始值0xFFFF。实际计算时可以逐位计算也可以查表。F407主频168MHz逐位算一帧几十个字节也就几个微秒性能完全够用没必要上查表法static uint16_t modbus_crc16(uint8_t *data, uint16_t len) { uint16_t crc 0xFFFF; while (len--) { crc ^ *data; for (uint8_t i 0; i 8; i) { if (crc 0x0001) { crc (crc 1) ^ 0xA001; } else { crc 1; } } } return crc; }0xA001是0x8005按位反转后的结果这是Modbus协议规定的查表方向。发送时CRC要低字节在前、高字节在后很多人在这里翻车明明校验值算对了发送顺序反了主站那边照样报CRC错误。比如CRC算出来是0xC40B发送顺序是0x0B 0xC4。3.3 寄存器表的组织方式Modbus从机的核心是寄存器区。我的做法是直接把寄存器地址映射到一维数组的下标从机地址和数组下标一一对应代码写起来非常机械#define REG_HOLDING_NUM 8 uint16_t holding_regs[REG_HOLDING_NUM];比如定义0x0000地址为版本号0x0001为温度值0x0002为湿度值0x0003为继电器状态那么holding_regs[0]就是版本号holding_regs[1]就是温度。业务任务往数组里写值Modbus任务从数组里读值两者通过数组天然解耦。有两点要注意一是寄存器数量要固定好三是处理好大小端。Modbus RTU是多字节数据大端传输高字节在前。STM32是小端MCU一个uint16_t变量的低字节存在低地址所以组织应答帧时不能直接把内存数据塞进缓冲区要手动拆字节buf[0] (value 8) 0xFF; // 高字节在前 buf[1] value 0xFF;反过来写多个寄存器时收到的字节也要先合并成uint16_t再存入数组。我早期在这里栽过跟头Modbus Poll读出来数值跟我预期完全对不上查了半天才发现是字节顺序反了。3.4 常用功能码03读保持寄存器、06写单个、10写多个常用功能码按重要程度排最核心的是03读保持寄存器、06写单个保持寄存器、10写多个保持寄存器。04读输入寄存器在采集类设备里也经常用处理逻辑和03几乎一样只是数据源不同。03应答帧格式从机地址 0x03 字节数 寄存器数据高字节在前 CRC。比如读地址0x0000、数量2应答是01 03 04 00 01 01 2C CRClo CRChi。这里字节数是寄存器数量乘以2不是寄存器数量写代码时容易漏。06应答最简单原样返回请求帧。10应答则返回从机地址 功能码 起始地址 寄存器数量 CRC。我实现时把这些功能码统一在一个分发函数里static void modbus_process_frame(modbus_frame_t *frame) { uint8_t addr frame-data[0]; uint8_t func frame-data[1]; // 地址不匹配直接丢弃包括广播地址0x00 if (addr ! MODBUS_SLAVE_ADDR addr ! 0x00) { return; } uint16_t crc modbus_crc16(frame-data, frame-len - 2); uint16_t recv_crc frame-data[frame-len - 1] 8 | frame-data[frame-len - 2]; if (crc ! recv_crc) { return; } switch (func) { case 0x03: modbus_read_holding_regs(frame); break; case 0x04: modbus_read_input_regs(frame); break; case 0x06: modbus_write_single_reg(frame); break; case 0x10: modbus_write_multi_regs(frame); break; default: modbus_send_exception(func, 0x01); break; } }注意第一行判断里地址0x00是广播地址广播帧从机要处理但不回复应答。这个细节很多例程没有处理如果主站用了广播写操作从机回复反而会扰乱总线。4. FreeRTOS下的任务分工中断只收数据协议交给任务跑4.1 任务模型中断收帧、队列传信、任务解析把协议处理放进FreeRTOS任务之后整个通信链路变成四段串口接收中断把收到的字节填入缓冲区空闲中断触发后把整帧数据拷进队列然后通知Modbus任务Modbus任务阻塞等待队列消息收到一帧就执行地址匹配、CRC校验、功能码分发、寄存器读写需要应答时任务内切RS485方向、调用阻塞发送发出响应帧、发完切回接收模式其他业务任务采集、显示、控制正常跑自己的逻辑不参与通信过程。这个模型的精髓是中断只做最轻量的事把数据拷到队列就立刻返回不占用中断太多时间。而协议解析这种稍微费点时间的操作放在任务上下文里执行即使处理过程中来了新中断系统也只是挂起当前任务不会造成长时间关中断导致外设响应延迟。4.2 队列里传什么结构体比裸指针更稳队列传的参数有两种选择传指针或传结构体。传指针最快但要保证指针指向的缓冲不能在下一次接收前被覆盖否则任务还没处理完数据就没了。传结构体最稳每次接收把数据整个拷贝进去任务从队列里取到的是独立副本不存在覆盖问题。Modbus RTU帧一般不长我直接定义了一个结构体typedef struct { uint8_t data[256]; uint16_t len; } modbus_frame_t; QueueHandle_t modbus_rx_queue; modbus_rx_queue xQueueCreate(4, sizeof(modbus_frame_t));队列深度设为4正常情况下足够的主站轮询走的是一问一答模式不会同时堆积多个请求。但如果突发异常队列满了之后xQueueSendFromISR会返回errQUEUE_FULL这时从机直接不应答即可主站那边表现为超时不会造成系统崩溃。中断回调里完整写法void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart-Instance USART2) { BaseType_t xHigherPriorityTaskWoken pdFALSE; modbus_frame_t rx_frame; rx_frame.len Size; memcpy(rx_frame.data, rx_buf, Size); xQueueSendFromISR(modbus_rx_queue, rx_frame, xHigherPriorityTaskWoken); HAL_UARTEx_ReceiveToIdle_IT(huart2, rx_buf, 256); portYIELD_FROM_ISR(xHigherPriorityTaskWoken); } }注意HAL_UARTEx_ReceiveToIdle_IT的重置必须放在回调里而且要在xQueueSendFromISR之后再次调用否则下一帧数据来的时候接收功能没重新启动数据就丢了。4.3 任务侧阻塞等队列有帧才干活Modbus任务的实现相当简单void ModbusTask(void *argument) { modbus_frame_t rx_frame; for (;;) { if (xQueueReceive(modbus_rx_queue, rx_frame, portMAX_DELAY) pdPASS) { modbus_process_frame(rx_frame); } } }xQueueReceive用portMAX_DELAY阻塞等待没有消息时任务进入挂起态几乎不占CPU时间。有消息时被唤醒执行解析和应答。这里有个任务优先级和调度延迟的问题Modbus主站通常要求从站在几十毫秒内给出响应。FreeRTOS在通信任务阻塞等待时如果有更高优先级的任务在运行通信任务会等到高优先级任务主动阻塞或超时才能被调度。所以通信任务优先级要设得比普通业务任务高我一般设3数值越小优先级越低取0~4范围里较高的一档。业务任务里如果有长耗时操作比如等待外部设备超时建议使用任务延时或者信号量挂起避免长时间霸占CPU。4.4 串口中断优先级FreeRTOS的雷区这条极其容易踩。在FreeRTOS里凡是中断服务函数中调用了以FromISR结尾的API比如xQueueSendFromISR这个中断的优先级必须满足一个约束优先级数值不能小于configMAX_SYSCALL_INTERRUPT_PRIORITY。Cortex-M的优先级数值越小优先级越高所以这句话翻译成人话就是串口中断的优先级要比FreeRTOS允许调API的那个阈值低数值大。默认情况下CubeMX生成的FreeRTOS配置里configMAX_SYSCALL_INTERRUPT_PRIORITY通常是5。那么串口中断优先级应该设为5~15之间的数值7或者8都行。如果你手贱把它设成0或1在中断里调xQueueSendFromISR时轻则触发断言重则直接HardFault。我那次就是调试了一天最后发现是NVIC优先级设置的问题。修改方法在CubeMX里打开NVIC配置把我用的USART2全局中断优先级从默认改成7或者直接改代码里HAL_NVIC_SetPriority那一行。4.5 发送模式下的缓冲区和回环问题RS485半双工时从机发送应答帧的瞬间自己的RX引脚上也会读到总线数据——如果总线上挂了其他设备或者线路质量差有反射接收端可能收到自己的回环信号。更常见的情况是发送过程中RXNE标志没有清除导致接收缓冲区里混入自己发出去的字节。稳妥的处理方式发送前清空接收缓冲区并临时关掉接收中断发完再重新开启。阻塞发送期间因为发送和接收共用同一个串口外设理论上接收仍可触发中断我实际测试发现如果不做处理偶发情况下接收缓冲区会混入1~2个字节导致下一帧解析错乱。处理方式是在modbus_rs485_send函数开头加个清理动作void modbus_rs485_send(uint8_t *buf, uint16_t len) { HAL_GPIO_WritePin(RS485_DE_PORT, RS485_DE_PIN, GPIO_PIN_SET); __HAL_UART_CLEAR_OREFLAG(huart2); HAL_UART_Transmit(huart2, buf, len, 100); HAL_GPIO_WritePin(RS485_DE_PORT, RS485_DE_PIN, GPIO_PIN_RESET); }__HAL_UART_CLEAR_OREFLAG清的是溢出标志防止发送前缓冲区残留过去的数据。这种小细节调试时踩到才知道疼。5. 联调实测Modbus Poll收发对不上时我踩过的五个坑代码写完进真机调试Modbus Poll一接上头就大了。下面这几个坑是我自己实际遇到的按排查思路写出来比直接给结论更能帮到你。5.1 CRC狂报错其实帧尾被掐了现象是Modbus Poll能收到响应但每次都报CRC错误。我先把USB转RS485接到PC上用串口助手抓从机发出的原始字节发现帧尾总是少一个字节。检查代码里发送长度没有错接着用逻辑分析仪同时抓UART TX引脚和DE控制引脚的波形真相大白DE引脚在最后一个字节还没从移位寄存器里移完就拉低了硬件直接切回接收模式最后一截数据被掐断。修复方法是把DE拉低的动作放到HAL_UART_Transmit返回之后这一点我前面已经强调过。这种问题很难光靠看代码发现最好有逻辑分析仪或者示波器看一眼波形几秒钟就能确认。5.2 一帧连续请求被拆成两帧现象是用Modbus Poll高速轮询时从机偶发不响应。我起初怀疑任务是处理不过来后来在中断回调里打印每次收到的字节数发现主站发的一帧请求被拆成了两段变成两次队列消息。原因是主站发帧时某个字节之间停顿超过了1字符时间提前触发了空闲中断。这种情况在规约规范的PLC上很少见但用PC软件做压力测试时就可能出现。解决的思路是做个帧合并Modbus任务里记录上一帧的接收时间戳收到新帧时如果距离上一帧结束小于3.5字符时间就把两段数据拼接后再解析。实际项目中如果主站是正规工业设备你也可以先忽略这个问题等真出问题再做处理。5.3 上电第一帧请求必超时冷启动后Modbus Poll第一次读取总是超时后面就正常了。排查下来有两个原因叠加一是刚上电时Modbus任务还没进入队列阻塞状态主站就在这几十毫秒内发来请求消息可能丢了二是复位之后接收缓冲区里有随机残留被当成了一帧非法数据地址不匹配直接被丢弃而主站此时还在等应答。处理办法很简单上电初始化时先清一次接收缓冲再调用HAL_UARTEx_ReceiveToIdle_IT同时在上电流程里加200ms左右的延时等系统完全稳定后再开始对外通信。这个延时不影响实际使用因为Modbus主站本来就有重试机制。5.4 寄存器值对不上果然是大小端读出来版本号是0x0100实际应该是0x0001这肯定是高低字节反了。Modbus RTU大端传输数据帧里先发高字节再发低字节而STM32是小端机内存里低字节在低地址直接搬内存必然出错。必须手动拆字节拼字节这个方法前面已经给过代码。如果寄存器表里数据量很大建议写两个通用函数modbus_get_u16和modbus_set_u16统一处理拆包和组包不要每次手动写位移操作容易漏。5.5 随机性HardFault优先级问题惹的祸系统跑一段时间后突然死机调试器挂上去发现HardFault出现在xQueueSendFromISR调用处。查了一下NVIC配置串口中断优先级设成了2低于FreeRTOS的configMAX_SYSCALL_INTERRUPT_PRIORITY数值5在中断里调FreeRTOS API被判定为非法操作。修复把串口中断优先级改成7问题消失。顺带检查了整个工程里所有在中断里调FreeRTOS API的外设确保它们优先级都低于5。这个坑隐蔽性很强因为它在低负载下可能永远不触发一旦总线上数据密集一点就随机崩溃。5.6 我自己惯用的联调顺序最后分享一套我实测好用的验证顺序。千万不要一开始就上Modbus Poll盲调分层次排查会快很多先用USB转TTL直接把STM32的UART接到PC串口助手手动发一帧Modbus请求比如01 03 00 00 00 02 C4 0B看从机回什么。这一步能验证UART收发和协议解析是否正确排除RS485方向切换的干扰。然后在TX和DE引脚上加逻辑分析仪确认方向切换的时序没有问题。最后换成USB转RS485接PC用Modbus Poll做长时间轮询压力测试观察是否有偶发超时和错误帧。出现问题按先看原始字节再看CRC最后看波形的顺序排查基本都能快速定位。我做这套移植最大的体会是Modbus RTU从站本身代码量不大难点全在各层配合上——RS485方向切换早一点晚一点都不行中断和任务的交接不能建立在错误的优先级上上电时序需要专门处理。如果你也是从裸机迁到FreeRTOS建议先把裸机版的串口收发调通确认收发字节和CRC都正常再加RTOS分步验证不要一口气全上。这样出了问题定位范围会小得多。本文还有配套的精品资源点击获取
返回列表