资讯动态

STM32F103+FreeRTOS+Modbus主机代码:工业网关多从站轮询实战

发布时间:2026/10/20 19:15:39 来源:尧图企业网站定制
简介这份资源面向嵌入式开发初学者与工业控制方向的工程师提供STM32F103平台下基于FreeRTOS的Modbus主机完整实现代码帮助解决裸机开发中多任务调度与串行通信难以兼顾的问题。压缩包共303个文件约849KB以61个c源文件与95个h头文件为核心涵盖Modbus协议栈、串口驱动、FreeRTOS任务与配置文件另含uvprojx工程、hex固件、map映射及若干说明文档便于直接编译与二次开发。资源围绕Modbus RTU帧编解码、CRC校验、串口初始化、任务优先级划分与信号量同步等关键环节展开读者可据此理解主机请求发送、响应解析及超时与校验错误处理思路。目前已有1619人学习下载适合作为工业通信入门与FreeRTOS多任务实践的参考工程。1. 为什么我劝你先跑通这套 STM32F103 Modbus 主机再谈工业网关去年帮一个做配电柜监测的团队看代码他们用 STM32F103 做 Modbus 主机轮询 8 个从站电表裸机跑着跑着就丢包加了个看门狗又频繁复位。后来换成 FreeRTOS 把轮询、解析、上报拆成独立任务问题一下就收敛了。这套「STM32F103 Modbus FreeRTOS 主机代码」解决的正是这个场景让一块几十块钱的 F103 稳定地当 Modbus RTU 主机去轮询电表、温控器、变频器这类从站设备同时还能腾出 CPU 干别的事。它适合谁适合正在做工业数据采集、边缘网关、多从站轮询又不想上 Linux 方案的嵌入式开发者。裸机轮询不是不能做但一旦从站数量上去、还要兼顾串口屏或 4G 上报任务调度就是绕不开的坎。2. 架构拆解主机任务、串口收发与 Modbus 状态机怎么分工2.1 整体任务划分与数据流这套代码的核心思路是「一个主机任务 一个串口收发链路 一个协议状态机」。FreeRTOS 这边通常起三个任务主机轮询任务负责按顺序向各从站发请求串口接收任务负责把 USART 中断收到的字节塞进队列解析任务负责从队列取数据、跑 Modbus 状态机、把结果写进共享数据结构。三者之间用队列和信号量解耦而不是在一个大循环里 if-else 堆到底。数据流是这样的主机任务构造请求帧 → 通过串口发送 → 从站回复 → USART 中断逐字节接收 → 接收任务把字节推入消息队列 → 解析任务从队列取字节喂给状态机 → 状态机判断帧完整性和 CRC → 校验通过后更新寄存器缓存 → 主机任务读取缓存或触发上报。这个链路里最关键的是「接收超时」和「帧间隔」的处理Modbus RTU 规定帧间至少 3.5 个字符时间F103 上一般用定时器或者空闲中断来判定一帧结束。常见做法是用 USART 的 IDLE 空闲中断配合 DMA 接收这样一帧数据收完直接触发中断不用一个字节一个字节进中断CPU 负担小很多。如果不用 DMA那就得靠定时器做 3.5 字符超时判断代码会啰嗦一些。2.2 串口收发链路的实现要点先看串口初始化和中断接收这部分这是整个主机能不能稳定收到回复的基础。/* usart.c - Modbus RTU 串口初始化以 USART2 为例 */ void Modbus_UART_Init(uint32_t baudrate) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2, ENABLE); /* TX: PA2 复用推挽输出 */ GPIO_InitStructure.GPIO_Pin GPIO_Pin_2; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); /* RX: PA3 浮空输入 */ GPIO_InitStructure.GPIO_Pin GPIO_Pin_3; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, GPIO_InitStructure); USART_InitStructure.USART_BaudRate baudrate; /* 常用 9600 / 19200 */ USART_InitStructure.USART_WordLength USART_WordLength_8b; USART_InitStructure.USART_StopBits USART_StopBits_1; USART_InitStructure.USART_Parity USART_Parity_No; /* Modbus RTU 通常无校验 */ USART_InitStructure.USART_HardwareFlowControl USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART2, USART_InitStructure); /* 开启接收非空中断每收到一个字节进一次中断 */ USART_ITConfig(USART2, USART_IT_RXNE, ENABLE); NVIC_InitStructure.NVIC_IRQChannel USART2_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 6; NVIC_InitStructure.NVIC_IRQChannelSubPriority 0; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); USART_Cmd(USART2, ENABLE); }这段代码里几个参数值得说清楚。波特率按从站手册来电表类设备 9600 最常见部分变频器支持 19200 甚至 115200但线一长、干扰一大高波特率反而容易出错我一般先降到 9600 调通再往上试。校验位这里设的是无校验因为 Modbus RTU 靠 CRC16 做帧校验串口本身的奇偶校验位通常不用。中断优先级设成 6比 FreeRTOS 的 SysTick 和 PendSV 低避免打断调度器但要比普通任务高保证字节不丢。接收中断里不要做解析只做搬运/* stm32f1xx_it.c - USART2 中断服务函数 */ void USART2_IRQHandler(void) { BaseType_t xHigherPriorityTaskWoken pdFALSE; uint8_t rx_byte; if (USART_GetITStatus(USART2, USART_IT_RXNE) ! RESET) { rx_byte USART_ReceiveData(USART2); /* 读走数据清标志 */ /* 把字节塞进队列由解析任务去处理 */ xQueueSendFromISR(xModbusRxQueue, rx_byte, xHigherPriorityTaskWoken); USART_ClearITPendingBit(USART2, USART_IT_RXNE); } portYIELD_FROM_ISR(xHigherPriorityTaskWoken); }这里用xQueueSendFromISR而不是在中断里直接解析是为了把耗时操作挪到任务上下文。队列长度一般给 256 够用因为一帧 Modbus 报文最长也就 256 字节左右。portYIELD_FROM_ISR保证如果解析任务优先级比当前被中断的任务高退出中断后立刻切换过去降低响应延迟。2.3 Modbus 状态机与 CRC 校验解析任务里跑的是一个状态机按「等待地址 → 等待功能码 → 等待数据长度 → 收数据 → 校验 CRC」推进。CRC16 用查表法比逐位算快得多F103 主频 72MHz逐位算一帧也就几十微秒但轮询频繁时查表更省。/* modbus_crc.c - CRC16 查表法Modbus 多项式 0xA001 */ static const uint16_t crc_table[256] { /* 省略表内容标准 Modbus 表 */ }; uint16_t Modbus_CRC16(const uint8_t *buf, uint16_t len) { uint16_t crc 0xFFFF; while (len--) { crc (crc 8) ^ crc_table[(crc ^ *buf) 0xFF]; } return crc; } /* 校验一帧末尾两字节是 CRC低字节在前 */ uint8_t Modbus_CheckFrame(const uint8_t *frame, uint16_t len) { uint16_t crc_calc, crc_recv; if (len 4) return 0; /* 最短帧地址功能CRC */ crc_calc Modbus_CRC16(frame, len - 2); crc_recv frame[len - 2] | (frame[len - 1] 8); return (crc_calc crc_recv) ? 1 : 0; }CRC 校验这里有个血泪经验Modbus 的 CRC 是低字节在前、高字节在后跟很多协议反着来。我第一次调的时候按大端拼怎么都对不上查了半天才发现是字节序问题。crc_recv那行frame[len-2] | (frame[len-1] 8)就是按低字节在前拼的别写反。状态机本身不复杂关键是超时处理。主机发完请求后要等回复等不到就得超时重试不能让任务卡死。常见做法是在解析任务里用xQueueReceive带超时参数比如 200ms 收不到完整帧就判定超时记录一次错误继续轮询下一个从站。3. 跑起来从工程配置到第一个从站响应的完整步骤3.1 工程结构与依赖确认拿到代码先别急着编译花两分钟看目录结构能省后面一堆事。典型结构是这样目录内容Core/main.c、中断服务函数、时钟配置Drivers/STM32F1 标准外设库或 HAL 库FreeRTOS/FreeRTOS 内核源码与配置文件Modbus/主机协议实现、CRC、帧处理App/业务任务、从站轮询表、数据缓存依赖上这套代码一般基于 STM32F1 标准外设库SPL或者 HAL 库。SPL 体积小、寄存器操作直接老项目用得多HAL 库移植方便但代码量大。FreeRTOS 版本常见是 V10.x配置文件FreeRTOSConfig.h里要确认configTOTAL_HEAP_SIZE够用起三个任务加队列一般给 10KB 以上比较稳。3.2 关键配置项怎么改跑通之前有几个地方必须按自己的硬件改。串口引脚和波特率在Modbus_UART_Init里改从站地址列表和轮询顺序在业务任务里改。轮询表通常是个数组/* app_modbus_master.c - 从站轮询表 */ typedef struct { uint8_t slave_addr; /* 从站地址 1~247 */ uint8_t func_code; /* 功能码03 读保持寄存器最常见 */ uint16_t start_reg; /* 起始寄存器地址 */ uint16_t reg_count; /* 读取寄存器数量 */ uint16_t poll_interval; /* 该从站轮询间隔单位 ms */ } ModbusPollItem_t; static const ModbusPollItem_t poll_table[] { { 0x01, 0x03, 0x0000, 0x000A, 1000 }, /* 1 号电表读 10 个寄存器 */ { 0x02, 0x03, 0x0000, 0x000A, 1000 }, /* 2 号电表 */ { 0x03, 0x04, 0x0000, 0x0004, 2000 }, /* 3 号温控器读输入寄存器 */ };slave_addr按从站实际拨码或配置填别填 00 是广播地址从站不会回复。func_code里 03 读保持寄存器、04 读输入寄存器、01 读线圈、02 读离散输入看设备手册。poll_interval是每个从站的轮询周期别设太短从站响应需要时间设成 500ms 以下容易自己把自己堵死。3.3 编译下载与首次联调编译用 Keil MDK 或 STM32CubeIDE 都行注意 FreeRTOS 的port.c要选对编译器版本Keil 用 ARM_CM3 端口。下载后先别接从站用串口助手抓主机发出的请求帧确认帧格式对地址、功能码、起始地址高字节、低字节、数量高字节、低字节、CRC 低、CRC 高。比如读 1 号从站 0x0000 开始的 10 个寄存器请求帧是01 03 00 00 00 0A C5 CD。确认发出去的帧没问题再接从站。如果从站不回复先查三样A/B 线有没有接反、波特率和校验位跟从站是否一致、从站地址是否匹配。这三样占了我遇到的不响应问题的八成以上。接上从站后用调试器看解析任务里的接收队列能收到字节说明硬件链路通了收不到就回到串口层面查。提示首次联调建议只挂一个从站轮询表里只留一条跑通再加。多个从站一起上出问题不好定位是哪个环节。4. 避坑与排查主机轮询最常见的五类翻车4.1 从站不回复或回复超时现象是主机发请求后队列里一直没数据或者偶尔收到几个字节就断了。原因通常有三个一是 RS485 收发方向控制没做对发送时没把 DE 引脚拉高或者发送完没及时拉低导致总线冲突二是波特率或校验位跟从站不一致收到的全是乱码三是 A/B 线接反。解决方法是先用示波器或逻辑分析仪看总线波形确认发送时差分信号正常再逐个核对串口参数。DE 引脚控制我一般放在发送函数里发完最后一个字节后等 TC 标志置位再拉低别发完就拉最后一个字节可能还没出去。4.2 CRC 校验一直失败现象是能收到完整帧但 CRC 校验过不了。最常见的原因是字节序搞反了Modbus CRC 低字节在前很多人按习惯写成高字节在前。另一个原因是接收缓冲区里混入了上一帧的残留数据帧边界没切干净。解决方法是打印出收到的原始字节和计算出的 CRC 对比一眼就能看出是字节序问题还是数据问题。帧边界建议用空闲中断或者 3.5 字符定时器来切别靠固定长度判断。4.3 任务优先级设错导致丢包现象是单个从站跑得好好的加到三四个就开始丢包或者串口屏刷新时轮询就卡。原因是接收任务优先级太低被其他任务抢占队列满了新字节进不来。FreeRTOS 里队列满了xQueueSendFromISR会直接返回失败字节就丢了。解决办法是把接收和解析任务的优先级设得比业务任务高队列长度给足或者在队列满时做丢弃计数方便定位。我一般把解析任务设成比主机轮询任务高一级保证收到就能处理。4.4 堆栈溢出导致随机复位现象是跑一段时间后随机复位或者某个任务突然不执行了。原因是 FreeRTOS 任务堆栈给太小解析任务里如果用了局部大数组或者调用了 printf 这类吃栈的函数很容易溢出。解决办法是把configCHECK_FOR_STACK_OVERFLOW打开设成 2溢出时进钩子函数打个标记。任务堆栈一般给 256 字1024 字节起步解析任务给 512 字更稳。别在任务里用大局部数组改成静态或全局。4.5 轮询间隔太短把总线堵死现象是从站偶尔回复、偶尔超时整体采集率上不去。原因是轮询间隔设得太短上一个请求还没等到回复或超时下一个请求就发出去了总线上请求和回复交错。解决办法是保证「请求发送 → 等待回复或超时 → 处理 → 下一个请求」是串行的别并发发。轮询间隔至少留出从站响应时间加超时时间9600 波特率下一帧十几个字节大概十几毫秒加上从站处理时间间隔设 200ms 以上比较安全。5. 二次开发把轮询表做成可配置、把数据对接上云5.1 从硬编码轮询表到参数化配置原始代码里轮询表多半是写死的数组改一个从站就得重新编译下载现场调试很痛苦。二次开发第一个该动的地方就是把它参数化。常见做法是把轮询表存到 F103 的 Flash 末尾几页或者外挂一片 24C02 这类 EEPROM上电时读出来。再进一步可以留一个串口配置通道用自定义的 ASCII 命令增删从站比如ADD 01 03 0000 000A 1000表示加一个从站。这样现场不用带电脑和下载器拿串口助手就能配。/* app_config.c - 从 Flash 读取轮询配置 */ #define CONFIG_FLASH_ADDR 0x0801FC00 /* F103 中容量型号最后一页 */ #define CONFIG_MAGIC 0x4D425553 /* MBUS */ typedef struct { uint32_t magic; uint16_t item_count; ModbusPollItem_t items[16]; /* 最多 16 个从站 */ uint16_t crc; } ModbusConfig_t; uint8_t Config_Load(ModbusConfig_t *cfg) { memcpy(cfg, (void *)CONFIG_FLASH_ADDR, sizeof(ModbusConfig_t)); if (cfg-magic ! CONFIG_MAGIC) return 0; /* 未配置过 */ if (!Config_CheckCRC(cfg)) return 0; /* 配置损坏 */ return 1; }这里加 magic 和 CRC 是为了防止 Flash 里是空白或者写坏的数据被当成有效配置。加载失败就回退到默认轮询表保证设备还能跑。写 Flash 要注意 F103 是按页擦除的改一个从站也得整页擦再写所以配置别存太频繁一般只在配置命令触发时写一次。5.2 数据缓存与上报任务的对接主机轮询拿到的寄存器值不能只放在解析任务的局部变量里得有个共享缓存让上报任务能读。常见做法是定义一个全局的从站数据数组解析成功后按从站地址和寄存器偏移写进去上报任务定时读这个数组打包成 JSON 或自定义协议发出去。/* app_datastore.c - 从站数据缓存 */ #define MAX_SLAVE 16 #define MAX_REG 32 static uint16_t slave_regs[MAX_SLAVE][MAX_REG]; static uint32_t slave_update_tick[MAX_SLAVE]; /* 最后更新时间 */ void DataStore_Update(uint8_t slave_idx, uint16_t reg_offset, const uint16_t *regs, uint16_t count) { if (slave_idx MAX_SLAVE || reg_offset count MAX_REG) return; taskENTER_CRITICAL(); /* 防止上报任务读到半更新数据 */ memcpy(slave_regs[slave_idx][reg_offset], regs, count * sizeof(uint16_t)); slave_update_tick[slave_idx] xTaskGetTickCount(); taskEXIT_CRITICAL(); }taskENTER_CRITICAL这段临界区不能省上报任务和解析任务可能同时访问这个数组不加保护会读到写了一半的数据。临界区别待太久memcpy 几十个字节很快没问题。上报任务里读的时候也建议进临界区拷一份出来再处理别在临界区里做打包和发送。5.3 一个验证改造是否成功的小技巧改完配置化和上报之后怎么确认没改坏我的习惯是保留一个「回环测试」模式把主机自己的请求帧直接喂给解析状态机看能不能正确解析出寄存器值。这个模式不依赖真实从站编译时用宏开关切换改协议或改状态机后先跑回环过了再接真实设备。这样能把「协议实现问题」和「硬件链路问题」分开省得两头猜。从那以后我每次动 Modbus 状态机或者 CRC 相关代码都强制先跑一遍回环测试再接从站翻车次数少了一大半。希望这套代码和这些踩坑记录能帮到你拿到源码先跑通单从站再按自己的从站表改轮询配置最后接上报一步一步来最稳。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑