简介:本资源面向嵌入式开发初学者与工业控制方向工程师,提供基于STM32F103的Modbus主机完整工程代码,结合FreeRTOS实现多任务调度下的串行通信主站功能。包内共303个文件,以c源码与h头文件为核心,辅以编译生成的o目标文件、d依赖文件及uvprojx、uvoptx等Keil工程配置,另含hex烧录文件、map映射表与ioc配置,压缩包约849KB,结构完整可直接导入开发环境。代码覆盖串口初始化、Modbus RTU帧编解码与CRC校验、FreeRTOS任务创建及信号量同步、中断接收处理与超时错误处理等关键环节,并配有清晰注释,便于理解主机请求发送与从机响应解析的协作流程。目前已有1619人学习下载,适合作为工业通信项目参考或课程设计、毕业设计的实践模板,帮助读者快速掌握STM32与Modbus、FreeRTOS的整合开发思路。
1. 从一次轮询卡死说起:这套 STM32F103 Modbus 主机代码到底解决什么问题
如果你用 STM32F103 裸机跑过 Modbus 主机,大概率遇到过这种场景:主循环里轮询三五个从站,某个从站掉线或响应慢,整个while(1)就被HAL_UART_Receive的超时阻塞拖住,按键没反应、LED 不闪、看门狗差点复位。这不是代码写错了,是裸机轮询模型本身撑不住多从站并发。这套STM32F103 + Modbus + FreeRTOS 主机代码要解决的正是这件事——把 Modbus RTU 主机的请求发送、响应接收、超时重试拆成独立任务,用队列和信号量做任务间同步,让通信和业务逻辑互不阻塞。它适合已经会点 FreeRTOS、手上有 F103 板子、需要接 PLC、变频器、温控仪表、电表这类标准 Modbus 从站的嵌入式开发者。整套代码基于标准外设库或 HAL 库均可移植,核心是任务划分和状态机,不是某个库的专属写法。
2. 主机任务怎么拆:从裸机轮询到 FreeRTOS 多任务的映射
2.1 为什么必须拆任务,而不是在中断里硬扛
裸机 Modbus 主机的典型写法是:发一帧 → 死等接收 → 校验 → 处理 → 发下一帧。问题在于 Modbus RTU 的帧间隔是 3.5 个字符时间,9600 波特率下一帧 8 字节大概 8ms,加上从站响应时间,一轮下来几十毫秒。如果从站超时(常见设 300ms~1s),主循环就被吃掉。FreeRTOS 的价值不是"用了 RTOS 就高级",而是把等待这件事从 CPU 占用变成任务挂起。
我一般把主机拆成三个任务加一个中断:
| 任务/中断 | 优先级 | 职责 | 同步方式 |
|---|---|---|---|
| Modbus 发送任务 | 中 | 从请求队列取请求,组帧发送 | 队列接收阻塞 |
| Modbus 接收任务 | 中 | 等待信号量,解析响应帧 | 信号量阻塞 |
| 业务处理任务 | 低 | 处理解析后的数据、刷新显示 | 队列接收阻塞 |
| 串口中断 | 最高 | 收字节、判帧结束、给信号量 | 中断上下文 |
这样拆的好处是:接收任务在没数据时挂起,不占 CPU;发送任务在没请求时挂起;业务任务慢一点也不会丢帧,因为接收中断和接收任务之间用信号量+缓冲区兜底。
2.2 任务间通信:队列传请求,信号量通知收帧
请求队列里放的是结构体,不是裸字节。我习惯这样定义:
/* modbus_host.h */ typedef struct { uint8_t slave_addr; /* 从站地址 1~247 */ uint8_t func_code; /* 功能码 03/04/06/10 */ uint16_t reg_addr; /* 寄存器起始地址 */ uint16_t reg_num; /* 寄存器数量,读时用 */ uint16_t *data; /* 写时指向数据,读时为 NULL */ uint8_t retry; /* 剩余重试次数 */ } ModbusReq_t; extern QueueHandle_t xModbusReqQueue; /* 请求队列 */ extern SemaphoreHandle_t xRxFrameSem; /* 收帧完成信号量 */发送任务的核心逻辑:
void vModbusSendTask(void *pvParameters) { ModbusReq_t req; uint8_t tx_buf[256]; uint16_t len; for (;;) { /* 阻塞等待请求,没请求时任务挂起,不占 CPU */ if (xQueueReceive(xModbusReqQueue, &req, portMAX_DELAY) == pdTRUE) { len = Modbus_BuildFrame(&req, tx_buf); /* 组帧+CRC */ /* 发送前清接收缓冲,避免上一帧残留 */ Modbus_RxReset(); HAL_UART_Transmit(&huart2, tx_buf, len, 100); /* 启动接收,等接收任务给信号量 */ Modbus_StartRxTimeout(req.slave_addr, req.func_code); } } }逻辑说明:xQueueReceive的portMAX_DELAY让发送任务在没有请求时彻底挂起,这是和裸机轮询最大的区别。Modbus_RxReset必须在发送前调用,否则上一帧的残留字节会污染本次解析——这是血泪经验,我见过太多人栽在这。Modbus_StartRxTimeout不是简单开中断,而是记录当前请求的从站地址和功能码,供接收任务校验响应对不对得上。
参数说明:tx_buf大小按最大帧算,读保持寄存器 03 功能码请求帧固定 8 字节,响应帧最大 256 字节(125 个寄存器)。HAL_UART_Transmit的超时给 100ms 足够,波特率再低也不会超。
2.3 接收任务:信号量 + 状态机解析
接收任务不直接读串口,而是等信号量。串口中断收到帧结束标志(用空闲中断或定时器判 3.5 字符)后释放信号量:
void vModbusRecvTask(void *pvParameters) { uint8_t rx_frame[256]; uint16_t rx_len; ModbusResp_t resp; for (;;) { /* 阻塞等收帧完成信号量,超时由接收超时定时器给 */ if (xSemaphoreTake(xRxFrameSem, pdMS_TO_TICKS(500)) == pdTRUE) { rx_len = Modbus_GetRxFrame(rx_frame, sizeof(rx_frame)); if (Modbus_ParseFrame(rx_frame, rx_len, &resp) == MODBUS_OK) { /* 校验从站地址、功能码是否匹配当前请求 */ if (Modbus_MatchPending(&resp)) { xQueueSend(xDataQueue, &resp, 0); Modbus_ClearPending(); } } } else { /* 超时:当前请求失败,触发重试或上报错误 */ Modbus_OnTimeout(); } } }逻辑说明:xSemaphoreTake的超时值要略大于从站最大响应时间。如果超时返回pdFALSE,说明从站没响应,走Modbus_OnTimeout重试逻辑。Modbus_MatchPending是关键——Modbus 主机同一时刻只应有一个未完成请求,收到不匹配的帧直接丢弃,防止串扰。
参数说明:pdMS_TO_TICKS(500)里的 500ms 要按实际从站调整,变频器这类慢设备可能要给到 1s。xQueueSend的阻塞时间给 0,因为数据队列满说明业务任务处理不过来,丢一帧比阻塞接收任务更合理。
3. 帧组装与 CRC 校验:主机侧最容易写错的三个地方
3.1 请求帧组装的字节序和地址偏移
Modbus RTU 请求帧格式固定:从站地址(1) + 功能码(1) + 数据(N) + CRC(2),CRC 低字节在前。读保持寄存器 03 功能码的请求是:地址 + 03 + 起始地址高 + 起始地址低 + 数量高 + 数量低 + CRC低 + CRC高,共 8 字节。很多人第一次写会把寄存器地址的高低字节写反,或者把 CRC 高低字节写反,结果从站直接不回。
uint16_t Modbus_BuildFrame(ModbusReq_t *req, uint8_t *buf) { uint16_t idx = 0; uint16_t crc; buf[idx++] = req->slave_addr; buf[idx++] = req->func_code; if (req->func_code == 0x03 || req->func_code == 0x04) { buf[idx++] = req->reg_addr >> 8; /* 地址高字节在前 */ buf[idx++] = req->reg_addr & 0xFF; buf[idx++] = req->reg_num >> 8; /* 数量高字节在前 */ buf[idx++] = req->reg_num & 0xFF; } else if (req->func_code == 0x06) { buf[idx++] = req->reg_addr >> 8; buf[idx++] = req->reg_addr & 0xFF; buf[idx++] = req->data[0] >> 8; /* 写入值高字节在前 */ buf[idx++] = req->data[0] & 0xFF; } crc = Modbus_CRC16(buf, idx); buf[idx++] = crc & 0xFF; /* CRC 低字节在前 */ buf[idx++] = crc >> 8; return idx; }逻辑说明:Modbus 规定所有多字节字段都是大端(高字节在前),唯独 CRC 是低字节在前,这个不对称是新手翻车重灾区。Modbus_CRC16用标准多项式 0xA001,初始值 0xFFFF,逐字节异或后右移。
参数说明:reg_addr和reg_num都是 16 位,>>8取高字节。写单个寄存器 06 功能码时data必须非空,读时data不用。
3.2 CRC16 查表法 vs 逐位计算
逐位计算 CRC 每字节要循环 8 次,8 字节帧大概 64 次循环,F103 72MHz 下耗时微秒级,其实够用。但如果主机要轮询几十个从站、帧率很高,查表法能省一半时间。我一般给两个版本,资源紧张用逐位,追求效率用查表:
/* 逐位计算,代码小,够用 */ uint16_t Modbus_CRC16(uint8_t *buf, uint16_t len) { uint16_t crc = 0xFFFF; uint8_t i; while (len--) { crc ^= *buf++; for (i = 0; i < 8; i++) { if (crc & 0x0001) { crc >>= 1; crc ^= 0xA001; } else { crc >>= 1; } } } return crc; }逻辑说明:crc ^= *buf++把当前字节异或到 CRC 低字节,然后逐位判断最低位,是 1 就右移后异或多项式 0xA001。这个多项式是 Modbus 规定的,不能改。
参数说明:len是不含 CRC 的字节数。校验响应帧时,把收到的整帧(含 CRC)传进去,结果应为 0,这是判断 CRC 是否正确的简便方法。
3.3 响应帧解析:功能码最高位是错误标志
从站正常响应时功能码和请求一致;出错时功能码最高位置 1(比如 03 变成 0x83),后面跟一个异常码。解析时必须先判断这一位:
ModbusStatus_t Modbus_ParseFrame(uint8_t *frame, uint16_t len, ModbusResp_t *resp) { uint16_t crc_calc; if (len < 5) return MODBUS_ERR_LEN; /* 最短异常帧 5 字节 */ crc_calc = Modbus_CRC16(frame, len); if (crc_calc != 0) return MODBUS_ERR_CRC; /* 整帧 CRC 应为 0 */ resp->slave_addr = frame[0]; resp->func_code = frame[1]; if (frame[1] & 0x80) { /* 最高位为 1 是异常 */ resp->err_code = frame[2]; return MODBUS_ERR_EXCEPTION; } /* 正常响应:03/04 功能码第 2 字节是数据字节数 */ if (frame[1] == 0x03 || frame[1] == 0x04) { resp->byte_count = frame[2]; resp->data = &frame[3]; } return MODBUS_OK; }逻辑说明:Modbus_CRC16(frame, len)对整帧计算,正确时结果为 0,这是最省事的校验方式。frame[1] & 0x80判断异常,异常码常见有 01(非法功能)、02(非法地址)、03(非法数据值)。
参数说明:len是实际收到的字节数,由接收中断或空闲中断给出。resp->data指向帧内缓冲区,业务任务用完前不能释放该缓冲区。
4. 避坑与排查:主机跑不通时先看这几条
4.1 从站不回,先量 A/B 线电压和终端电阻
现象:发送任务正常发出帧,接收任务一直超时,从站毫无反应。原因:RS485 物理层问题占主机调试失败的一半以上。A/B 线接反、没有 120Ω 终端电阻、共地没接,都会导致从站收不到或收到乱码。解决:用示波器或万用表量 A/B 差分电压,空闲时应为 200mV 以上;总线两端各加 120Ω 终端电阻;主机和从站共地。别一上来就怀疑代码。
4.2 收到帧但 CRC 校验失败
现象:接收任务能收到数据,但Modbus_ParseFrame返回MODBUS_ERR_CRC。原因:一是波特率不匹配,主机 9600 从站 19200,收到的全是错位字节;二是接收缓冲没清干净,上一帧残留混进来;三是帧结束判断不准,多收或少收字节。解决:先确认双方波特率、数据位、停止位、校验位完全一致;发送前调Modbus_RxReset;用空闲中断判帧结束比定时器更稳。
4.3 任务优先级设反导致响应延迟
现象:通信能通,但业务任务刷新慢,或者接收任务抢不到 CPU。原因:把业务处理任务优先级设得比接收任务高,业务任务一忙,接收任务排不上,信号量释放了也没人取,缓冲区被覆盖。解决:接收任务优先级 ≥ 发送任务 > 业务任务。接收任务只做解析和入队,重活交给业务任务。
4.4 信号量超时值设得太短
现象:偶尔丢帧,重试后又能通。原因:xSemaphoreTake的超时值设得比从站实际响应时间短。变频器、电表这类设备响应可能到 200ms 以上,超时设 100ms 就会误判失败。解决:超时值 = 从站最大响应时间 × 1.5,常见设 500ms~1s。同时重试次数给 2~3 次,别一次失败就报错。
4.5 中断里调用了带阻塞的 API
现象:程序跑着跑着死机或 HardFault。原因:在串口中断里调用了xQueueSend或xSemaphoreGive的阻塞版本,或者调用了HAL_UART_Transmit这种带超时的函数。解决:中断里只能用FromISR结尾的 API,如xSemaphoreGiveFromISR,并且要传pxHigherPriorityTaskWoken参数。发送操作一律放任务里做。
5. 进阶:用状态机把重试和轮询调度收进一个任务
前面拆了发送和接收两个任务,实际项目里我更倾向把"当前请求"做成一个显式状态机,让重试、超时、轮询调度都有据可查。状态就四个:IDLE(空闲)、SENDING(发送中)、WAITING(等响应)、RETRY(重试)。用一个ModbusHost_t结构体持有当前状态、当前请求、重试计数:
typedef enum { ST_IDLE, ST_SENDING, ST_WAITING, ST_RETRY } ModbusState_t; typedef struct { ModbusState_t state; ModbusReq_t cur_req; uint8_t retry_left; uint32_t tick_sent; } ModbusHost_t;调度任务每 10ms 跑一次状态机:
void vModbusPollTask(void *pvParameters) { ModbusHost_t *host = &g_modbus_host; for (;;) { switch (host->state) { case ST_IDLE: /* 从请求队列取新请求,取不到就继续空闲 */ if (xQueueReceive(xModbusReqQueue, &host->cur_req, 0) == pdTRUE) { host->retry_left = host->cur_req.retry; host->state = ST_SENDING; } break; case ST_SENDING: Modbus_SendOnce(&host->cur_req); host->tick_sent = xTaskGetTickCount(); host->state = ST_WAITING; break; case ST_WAITING: if (Modbus_TryParse(&host->cur_req) == MODBUS_OK) { host->state = ST_IDLE; /* 成功,等下一个请求 */ } else if ((xTaskGetTickCount() - host->tick_sent) > pdMS_TO_TICKS(500)) { host->state = ST_RETRY; /* 超时,进重试 */ } break; case ST_RETRY: if (host->retry_left > 0) { host->retry_left--; host->state = ST_SENDING; } else { Modbus_ReportError(&host->cur_req); /* 重试用尽,上报 */ host->state = ST_IDLE; } break; } vTaskDelay(pdMS_TO_TICKS(10)); } }逻辑说明:状态机把"发—等—重试"的时序显式化,每个状态只做一件事,调试时打印host->state就能定位卡在哪一步。Modbus_TryParse是非阻塞的,检查接收缓冲区有没有完整帧,有就解析,没有就返回失败,不阻塞状态机。vTaskDelay(10ms)让调度任务以固定节拍运行,10ms 对 Modbus 来说足够细。
参数说明:retry_left来自请求结构体的retry字段,一般给 2~3。超时 500ms 按从站调整。tick_sent用xTaskGetTickCount记录,注意 tick 溢出问题,用差值比较能自动处理溢出。
这套状态机的好处是轮询多个从站时,只要往xModbusReqQueue里塞请求就行,调度任务自动按顺序处理,失败自动重试,业务层完全不用关心时序。我一般还会加一个统计结构体,记录每个从站的成功次数、失败次数、平均响应时间,现场调试时特别有用——哪个从站老超时,一眼就能看出来。
从那以后我每次接新从站,都强制先用状态机跑一遍单从站读写,确认物理层和参数没问题,再挂多从站轮询。这个习惯帮我省了无数次在总线上瞎猜的时间。希望帮到你。
本文还有配套的精品资源,点击获取