ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32F103上FreeRTOS与Modbus主机协议栈整合实战

STM32F103上FreeRTOS与Modbus主机协议栈整合实战

简介:这份资源面向嵌入式开发初学者与工业控制方向工程师,提供STM32F103平台下基于FreeRTOS实时操作系统的Modbus主机完整工程代码,用于解决多任务环境下串口通信调度与协议解析的实战问题。压缩包共303个文件,约849KB,以61个c源文件与95个h头文件为核心,涵盖Modbus协议栈、串口驱动、FreeRTOS任务与配置文件,另含uvprojx工程、hex固件、map映射及若干编译中间文件,可直接导入Keil环境编译调试。目前已有1619人学习下载。代码围绕Modbus RTU帧的编码解码、CRC校验、串口初始化及任务间信号量同步展开,任务划分清晰,注释规范,便于读者理解主机请求发送与响应解析的完整链路,并在此基础上扩展寄存器读写、超时与校验错误处理等应用逻辑,适合作为工业通信项目的参考模板。

1. 从一块跑着 Modbus 的 STM32 说起:这套主机代码到底解决什么问题

如果你手头有一块 STM32F103,需要它主动去轮询几个从站设备——比如采集温湿度传感器、读电表寄存器、控制变频器启停——那你大概率绕不开 Modbus 协议。裸机写轮询不是不行,但一旦从站数量上去、还要同时处理按键、串口打印、状态灯,主循环里塞满HAL_Delay就会开始翻车:响应慢、丢帧、时序乱。这套代码的思路很直接,把 Modbus 主机协议栈和 FreeRTOS 揉在一起,用独立任务跑轮询,用队列做任务间通信,让 STM32F103 这种资源有限的芯片也能稳定当主机用。它适合正在做工业采集、设备联网、多从站轮询的嵌入式开发者,尤其是那些已经会点 HAL 库、但还没把 RTOS 和协议栈整合明白的人。下面我按「能干什么 → 怎么跑 → 怎么改」的顺序,把这份代码拆开讲。

2. 架构拆解:FreeRTOS 任务怎么和 Modbus 主机协议栈配合

2.1 整体分层与任务划分

这套代码不是把 Modbus 塞进一个任务里从头跑到尾,而是做了分层。底层是 STM32F103 的 USART 外设加 DMA 收发,中间是 Modbus RTU 帧的组包与解析,上层是 FreeRTOS 的任务调度。常见做法是拆成三个任务:一个负责定时触发轮询请求,一个负责串口数据的接收与帧解析,还有一个处理业务逻辑,比如把读回来的寄存器值做换算、上报或者驱动本地动作。

为什么这么拆?因为 Modbus 主机本质是「问—答」模型,发完请求要等从站回复,这个等待时间如果放在业务任务里,整个任务就被阻塞了。用独立任务跑协议栈,业务任务只管从队列里取结果,互不干扰。STM32F103 的 RAM 通常只有 20KB 到 64KB,任务栈要省着用,所以每个任务的栈深度得根据实际调用链估算,不能随手给个 1024。

/* 任务优先级安排:接收任务最高,轮询次之,业务最低 */ #define TASK_POLL_PRIO (tskIDLE_PRIORITY + 3) #define TASK_RECV_PRIO (tskIDLE_PRIORITY + 4) #define TASK_APP_PRIO (tskIDLE_PRIORITY + 2) /* 队列:接收任务解析完一帧后,把结果发给业务任务 */ QueueHandle_t xQueueModbusResult; void ModbusHost_Init(void) { xQueueModbusResult = xQueueCreate(8, sizeof(ModbusResult_t)); xTaskCreate(Task_Poll, "MB_Poll", 256, NULL, TASK_POLL_PRIO, NULL); xTaskCreate(Task_Recv, "MB_Recv", 256, NULL, TASK_RECV_PRIO, NULL); xTaskCreate(Task_App, "MB_App", 512, NULL, TASK_APP_PRIO, NULL); }

这段初始化里,队列长度给了 8,意味着最多缓存 8 条从站回复结果,业务任务处理慢一点也不会立刻丢数据。任务栈 256 字(注意 FreeRTOS 里单位是字,不是字节)对协议解析够用,业务任务因为可能做浮点换算,给了 512。优先级上接收最高,保证串口数据不丢;轮询次之;业务最低,不影响实时性。

2.2 串口收发与 DMA 的配合

STM32F103 的 USART 如果只用中断收,波特率一高就容易丢字节。这套代码用的是「DMA 接收 + 空闲中断」的组合,这也是目前 STM32 上跑 Modbus 比较稳的做法。DMA 负责把串口数据搬到缓冲区,空闲中断负责判断一帧结束,然后通知接收任务去解析。

/* 串口 DMA 接收配置,缓冲区大小按最大帧长给 */ #define MODBUS_RX_BUF_SIZE 256 uint8_t g_modbus_rx_buf[MODBUS_RX_BUF_SIZE]; volatile uint16_t g_modbus_rx_len = 0; void Modbus_Uart_Init(void) { /* ... 串口参数配置省略,8N1,波特率 9600 或 115200 ... */ HAL_UART_Receive_DMA(&huart2, g_modbus_rx_buf, MODBUS_RX_BUF_SIZE); __HAL_UART_ENABLE_IT(&huart2, UART_IT_IDLE); /* 开启空闲中断 */ } /* 空闲中断回调里计算本帧长度,并通知任务 */ void USART2_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart2, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart2); HAL_UART_DMAStop(&huart2); g_modbus_rx_len = MODBUS_RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(huart2.hdmarx); /* 通知接收任务,实际项目里用任务通知或信号量 */ vTaskNotifyGiveFromISR(xTaskRecvHandle, NULL); HAL_UART_Receive_DMA(&huart2, g_modbus_rx_buf, MODBUS_RX_BUF_SIZE); } HAL_UART_IRQHandler(&huart2); }

这里的关键点是:DMA 接收缓冲区给 256 字节,Modbus RTU 一帧最长 256 字节左右,够用。空闲中断触发后,用MODBUS_RX_BUF_SIZE减去 DMA 剩余计数,得到实际收到的字节数,这就是本帧长度。然后重新启动 DMA,准备收下一帧。注意HAL_UART_DMAStop和重启之间的顺序,如果先重启再算长度,计数器可能已经被重置,长度就算错了,这是实际调试时容易踩的坑。

2.3 Modbus RTU 帧的组包与解析

Modbus RTU 帧结构是「地址 + 功能码 + 数据 + CRC16」,主机发请求时要自己算 CRC,收到回复后要校验 CRC。这套代码里 CRC 用的是查表法,比逐位计算快,适合 STM32F103 这种没有硬件 CRC 外设的型号。

/* CRC16 查表,多项式 0xA001,Modbus 标准 */ static const uint16_t crc_table[256] = { /* 表内容省略,标准表 */ }; 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; } /* 组一个读保持寄存器的请求帧 */ uint16_t Modbus_BuildReadHolding(uint8_t *buf, uint8_t slave, uint16_t start, uint16_t num) { uint16_t idx = 0; buf[idx++] = slave; buf[idx++] = 0x03; /* 功能码 03:读保持寄存器 */ buf[idx++] = start >> 8; buf[idx++] = start & 0xFF; buf[idx++] = num >> 8; buf[idx++] = num & 0xFF; uint16_t crc = Modbus_CRC16(buf, idx); buf[idx++] = crc & 0xFF; /* CRC 低字节在前 */ buf[idx++] = crc >> 8; return idx; /* 返回帧长度 */ }

组包函数里,CRC 低字节在前、高字节在后,这是 Modbus RTU 的规定,写反了从站直接不回复。功能码 0x03 是读保持寄存器,如果要读输入寄存器用 0x04,读线圈用 0x01。起始地址和数量都是大端,高字节先发。这个函数返回帧长度,调用者拿这个长度去发串口。

解析侧的逻辑是反过来的:先校验地址是不是本机请求的从站,再校验 CRC,然后按功能码取数据。如果 CRC 不对,直接丢弃,不要回复任何东西,这是 Modbus 主机的容错原则。

3. 跑起来:从工程配置到第一个从站响应

3.1 工程结构与依赖

拿到这份代码,目录结构大致是这样:Core/下是main.c和中断处理,Modbus/下是协议栈的组包解析,Tasks/下是三个 FreeRTOS 任务,Drivers/是 HAL 库和 FreeRTOS 内核。如果你用的是 STM32CubeMX 生成的工程,FreeRTOS 可以通过 CubeMX 直接勾选,但要注意 CubeMX 默认用的是 CMSIS-RTOS v2 封装,而这份代码可能直接调的是 FreeRTOS 原生 API,两者混用会编译报错。常见做法是统一用原生 API,把 CubeMX 里的 FreeRTOS 接口选成「CMSIS-RTOS v1」或者干脆手动移植。

依赖上,除了 STM32 HAL 库和 FreeRTOS 源码,没有其他第三方库。Modbus 协议栈是自包含的,不依赖 libmodbus 之类的外部库,这对资源受限的 F103 来说是好事,省 Flash 省 RAM。

3.2 时钟与串口参数配置

STM32F103 跑 FreeRTOS,系统时钟一般配到 72MHz。FreeRTOS 的 tick 频率建议 1000Hz,也就是 1ms 一个 tick,这样任务延时和超时判断的精度够用。串口波特率根据从站设备定,工业现场常见 9600 和 19200,如果从站支持,115200 能显著缩短轮询周期。

/* FreeRTOSConfig.h 里几个关键配置 */ #define configTICK_RATE_HZ (1000) #define configTOTAL_HEAP_SIZE (10 * 1024) /* F103 上给 10KB 堆 */ #define configMINIMAL_STACK_SIZE (128) #define configUSE_PREEMPTION 1 #define configUSE_MUTEXES 1

堆大小给 10KB 是保守值,F103C8T6 只有 20KB RAM,任务栈、队列、协议缓冲区都要从这 20KB 里出。如果编译后发现堆不够,先看是不是任务栈给大了,再考虑优化缓冲区。configUSE_MUTEXES打开是因为串口发送可能需要互斥,避免多个任务同时往串口写数据导致帧交错。

3.3 轮询任务的实现与超时处理

轮询任务的核心是一个状态机:发请求 → 等回复 → 超时重试 → 下一个从站。等待回复不能用HAL_Delay,要用 FreeRTOS 的xTaskNotifyWait或者信号量,带超时参数。

void Task_Poll(void *pvParameters) { uint8_t req_buf[16]; uint16_t req_len; const uint8_t slave_list[] = {0x01, 0x02, 0x03}; uint8_t idx = 0; for (;;) { uint8_t slave = slave_list[idx]; req_len = Modbus_BuildReadHolding(req_buf, slave, 0x0000, 2); Modbus_Send(req_buf, req_len); /* 等待接收任务通知,超时 200ms */ uint32_t notified = ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(200)); if (notified == 0) { /* 超时,记录错误,重试或跳过 */ Modbus_LogTimeout(slave); } idx = (idx + 1) % (sizeof(slave_list) / sizeof(slave_list[0])); vTaskDelay(pdMS_TO_TICKS(50)); /* 帧间间隔,给从站喘息时间 */ } }

这里超时给了 200ms,对于 9600 波特率、读 2 个寄存器的请求,正常回复在几十毫秒内,200ms 足够。如果从站响应慢,可以适当加大。帧间间隔 50ms 是经验值,Modbus 规范要求帧间至少 3.5 个字符时间,但实际从站处理需要时间,给 50ms 更稳。ulTaskNotifyTake的第一个参数pdTRUE表示取走通知后清零,配合接收任务里的vTaskNotifyGiveFromISR使用。

3.4 接收任务与队列投递

接收任务在收到空闲中断的通知后,从 DMA 缓冲区取数据,校验 CRC,解析出寄存器值,打包成结构体投递到队列。

void Task_Recv(void *pvParameters) { ModbusResult_t result; for (;;) { ulTaskNotifyTake(pdTRUE, portMAX_DELAY); if (g_modbus_rx_len < 5) continue; /* 最短帧:地址+功能+CRC2 */ uint16_t crc_calc = Modbus_CRC16(g_modbus_rx_buf, g_modbus_rx_len - 2); uint16_t crc_recv = g_modbus_rx_buf[g_modbus_rx_len - 2] | (g_modbus_rx_buf[g_modbus_rx_len - 1] << 8); if (crc_calc != crc_recv) continue; /* CRC 错,丢弃 */ result.slave = g_modbus_rx_buf[0]; result.func = g_modbus_rx_buf[1]; result.reg[0] = (g_modbus_rx_buf[3] << 8) | g_modbus_rx_buf[4]; result.reg[1] = (g_modbus_rx_buf[5] << 8) | g_modbus_rx_buf[6]; xQueueSend(xQueueModbusResult, &result, 0); } }

接收任务里先判断帧长,小于 5 字节的直接丢。CRC 校验用收到的最后两字节和计算值比对,注意收到的 CRC 是低字节在前。解析出的寄存器值按大端拼回 16 位。投递队列时超时给 0,表示不等待,队列满就丢——这在主机场景下可以接受,因为下一轮还会再读。

4. 避坑与排查:调试 Modbus 主机时最容易翻车的几个点

4.1 从站不回复,示波器看串口有波形但数据不对

现象是主机发了请求,从站毫无反应。原因通常是波特率不匹配、校验位不对,或者 A/B 线接反。Modbus RTU 默认 8 数据位、无校验、1 停止位,但有些设备是 8 数据位、偶校验、1 停止位。解决方法是先用串口助手手动发一帧,确认从站能回,再检查代码里的串口配置。A/B 线接反在 RS485 上很常见,交换一下即可。

4.2 CRC 校验一直失败

现象是接收任务里 CRC 比对永远不通过。原因可能是 CRC 表用错了,或者字节序搞反了。Modbus CRC 是低字节在前,发送时先发低字节。另外注意 CRC 计算范围是「地址到数据结束」,不包括 CRC 本身。解决方法是拿一帧已知正确的数据手动算一遍,和代码结果比对。

4.3 轮询任务卡死,其他任务也不跑了

现象是系统跑一段时间后完全无响应。原因通常是轮询任务里用了阻塞式等待且没有超时,或者串口发送用了死循环等 TC 标志。解决方法是所有等待都带超时,串口发送用中断或 DMA,不要在任务里死等。另外检查任务栈是否溢出,F103 上栈溢出会直接 HardFault。

4.4 多个从站轮询时数据串台

现象是读回来的数据对应错了从站。原因可能是接收任务没有校验回复帧里的从站地址,或者队列里的结果没有带从站标识。解决方法是解析时先比对g_modbus_rx_buf[0]和当前请求的从站地址,不一致就丢弃。队列结构体里必须带slave字段。

4.5 编译通过但运行就 HardFault

现象是下载后一运行就进 HardFault。原因可能是 FreeRTOS 堆不够、任务栈太小、或者中断优先级配置冲突。STM32F103 的 FreeRTOS 要求configMAX_SYSCALL_INTERRUPT_PRIORITY配置正确,串口中断优先级不能高于这个值,否则在中断里调 FreeRTOS API 会出问题。解决方法是检查FreeRTOSConfig.h里的优先级宏,串口中断优先级设成 5 或更低。

5. 二次开发:把这套主机代码改成你自己的采集器

5.1 增加从站和功能码的扩展方式

现在代码里从站列表是写死的数组,实际项目里从站数量可能几十个,寄存器地址也不一样。改造思路是把从站配置抽成结构体数组,每个元素包含从站地址、功能码、起始地址、寄存器数量、轮询周期。

typedef struct { uint8_t slave; uint8_t func; uint16_t start; uint16_t num; uint16_t period_ms; /* 该从站的轮询周期 */ uint32_t last_tick; /* 上次轮询时间 */ } ModbusSlaveCfg_t; static ModbusSlaveCfg_t g_slave_cfg[] = { {0x01, 0x03, 0x0000, 2, 1000, 0}, {0x02, 0x04, 0x0000, 4, 2000, 0}, {0x03, 0x01, 0x0000, 8, 500, 0}, };

轮询任务改成遍历这个数组,用xTaskGetTickCount()判断是否到了该从站的轮询时间。这样不同从站可以有不同的轮询频率,慢变的温度 2 秒读一次,快变的开关量 500ms 读一次,总线负载更合理。

5.2 数据上报与本地存储的接入点

读回来的寄存器值最终要落地。常见做法是在业务任务里做两件事:一是通过另一个串口或者无线模块上报,二是存到本地 Flash 或者 SD 卡。上报协议可以用 JSON,也可以用自定义二进制帧。存储要注意 Flash 擦写寿命,不要每条数据都写,攒够一批再写。

void Task_App(void *pvParameters) { ModbusResult_t res; for (;;) { if (xQueueReceive(xQueueModbusResult, &res, portMAX_DELAY) == pdTRUE) { /* 换算:比如温度寄存器值除以 10 */ float temp = res.reg[0] / 10.0f; /* 上报 */ Report_Upload(res.slave, temp); /* 存储,带缓存批量写 */ Storage_Push(res.slave, temp); } } }

业务任务从队列取数据,做换算后分两路走。上报和存储都应该是非阻塞的,上报失败不能影响下一轮采集。存储用环形缓冲区,写满一批再擦写 Flash。

5.3 用事件组替代轮询做任务同步

如果系统里还有其他事件要处理,比如按键触发立即读某个从站,可以用 FreeRTOS 的事件组。轮询任务平时按周期跑,收到事件组的位就立即插入一次请求。这样比在轮询任务里加一堆标志位干净。

EventGroupHandle_t xModbusEvents; #define BIT_IMMEDIATE_POLL (1 << 0) /* 按键中断里 */ BaseType_t xHigherPriorityTaskWoken = pdFALSE; xEventGroupSetBitsFromISR(xModbusEvents, BIT_IMMEDIATE_POLL, &xHigherPriorityTaskWoken); portYIELD_FROM_ISR(xHigherPriorityTaskWoken); /* 轮询任务里 */ EventBits_t bits = xEventGroupWaitBits(xModbusEvents, BIT_IMMEDIATE_POLL, pdTRUE, pdFALSE, pdMS_TO_TICKS(100)); if (bits & BIT_IMMEDIATE_POLL) { /* 立即执行一次轮询 */ }

事件组的好处是可以同时等多个事件,而且带超时。xEventGroupWaitBits的第三个参数pdTRUE表示取走后清零,第四个pdFALSE表示不需要所有位都满足。这样轮询任务既能按周期跑,又能被外部事件唤醒。

5.4 调试与验证的实用技巧

改完代码怎么验证?我一般会先用 Modbus 从站模拟器在 PC 上跑一个虚拟从站,通过 USB 转 485 接到板子上,确认基本收发正常。然后抓串口波形,看请求帧和回复帧的时序。如果从站多,用 Modbus Poll 这类工具逐个确认。最后再上真实设备。

还有一个技巧是在协议栈里加一个统计结构体,记录每个从站的请求次数、超时次数、CRC 错误次数,通过串口打印出来。这样现场调试时一眼就能看出是哪个从站有问题。

typedef struct { uint32_t req_cnt; uint32_t timeout_cnt; uint32_t crc_err_cnt; } ModbusStat_t; ModbusStat_t g_stat[16]; /* 按从站地址索引 */

从那以后我每次改完 Modbus 主机的轮询逻辑,都强制先用模拟从站跑满 1000 次请求,确认超时和错误统计符合预期,再上真实设备。这个习惯帮我省了很多现场排查的时间。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进