串口转 WiFi 服务器 --- 从零到完整实现
主控:STM32F407 + FreeRTOS | WiFi:ESP8266 | 传感器:Modbus RTU(F030)
01. 项目目标
以项目方式练习 FreeRTOS 基础,涉及如下内容:
- FreeRTOS 基础使用:创建任务、队列、信号量、互斥量
- AT 模块的 socket 封装
- WebServer 的实现
02. 使用 AT 命令操作 ESP8266 模块
参考视频: https://www.bilibili.com/video/BV1oF3XzpEuQ/
(选集:2-1 网络通信概述 → 2-7 UDP客户端编程,重点关注 3-1_esp8266的AT命令使用示例 )

下载资料: 上面链接里有资料下载方法,视频里用到什么就下载什么。
2.1 目标
通过串口助手发送 AT 指令给 ESP8266,完成 WiFi 连接和 TCP 通信,验证硬件和通信链路。
2.2 硬件接线

STM32F407 ESP8266
PA2 (USART2_TX) ────────→ RXD
PA3 (USART2_RX) ←──────── TXD
GND ──────→ GND
3.3V ──────→ VCC
2.3 使用工具
| 工具 | 作用 |
|---|---|
| AnxinKe 串口调试助手 | 发送 AT 指令给 ESP8266,查看回复 |
| SSCOM | PC 端 TCP Server,测试网络通信 |
2.4 AT 指令测试流程
打开串口助手(COM7,115200,8N1),依次发送:
AT ← 测试联通,应回复 OK
ATE0 ← 关闭回显,回复 OK
AT+CWMODE=1 ← Station 模式
AT+CWJAP="1","987654321" ← 连接 WiFi,等待 10 秒
AT+CIFSR ← 查看本机 IP
AT+CIPMUX=1 ← 开启多连接
AT+CIPSTART=0,"TCP","192.168.2.175",8080 ← 连接 PC 端 TCP Server
AT+CIPSEND=0,5 ← 发送 5 字节数据
> hello ← 出现 > 后输入数据
2.5 成果展示
ESP8266 test start
mode set
WiFi connected
OK
AT+CIPSTART=0,"TCP","192.168.2.175",8080
CONNECT
OK
AT+CIPSEND=0,5
> hello
SEND OK
+IPD,0,8:你也好 ← PC 发来的数据
到此完成: 硬件连接正常,ESP8266 可以联网和收发数据。
03. 编程实现 ESP8266 的 AT 命令操作
3.1 目标
自己实现一套代码,能连接 WiFi 热点,能跟 PC 上的 SSCOM 收发数据。
提示:要考虑进行数据收发时,使用什么端口,是 TCP 还是 UDP,是 server 还是 client。
3.2 核心函数:发 AT 指令等回复
c
int at_exec_cmd(const char *cmd, int timeout_ms)
{
xSemaphoreTake(at_lock, portMAX_DELAY);
// 通过 UART 发送 AT 命令
ptDev->ptUARTDev->Send(ptDev->ptUARTDev,
(uint8_t *)cmd, strlen(cmd), 100);
// 等回复(Parser 解析到 OK/ERROR 后给信号量)
if (xSemaphoreTake(at_resp_sem, timeout_ms) == pdPASS)
ret = 0;
else
ret = -1;
xSemaphoreGive(at_lock);
return ret;
}
3.3 连接 WiFi(断线重连)
c
while (1) {
at_exec_cmd("AT+CWMODE=1\r\n", 1000);
if (at_exec_cmd("AT+CWJAP=\"1\",\"987654321\"\r\n", 10000) == 0)
break; // 连接成功
osDelay(3000); // 等 3 秒重试
}
3.4 建立 TCP 连接
c
at_exec_cmd("AT+CIPSTART=0,\"TCP\",\"192.168.2.175\",8080\r\n", 10000);
3.5 成果展示
[22:18:21] ESP8266 test start
[22:18:21] mode set
[22:18:21] WiFi connected
[22:18:22] TCP connected
[22:18:41] +IPD,8:你也好 ← WiFi 收到 PC 数据
到此完成: 代码可以控制 ESP8266 连接 WiFi 和 TCP。
04. 熟悉 socket 接口
参考视频: https://www.bilibili.com/video/BV1oF3XzpEuQ/
(2-1 网络通信概述 → 2-7 UDP客户端编程,共 7 个视频)

参考源码: Gitee 仓库
20250614_FreeRTOS-WIFI-Module-Socket-Encapsulation / 02_源码 / 5-2-4_学员代码赏析
4.1 目标
理解标准 Socket API 的设计,思考如何把 AT 指令封装成类似 Socket 的接口。
4.2 标准 Socket API
c
int socket(int domain, int type, int protocol); // 创建
int connect(int sock, const struct sockaddr *addr); // 连接
int send(int sock, const void *buf, int len, int flags); // 发送
int recv(int sock, void *buf, int len, int flags); // 接收
int close(int sock); // 关闭
4.3 封装的意义
封装前------到处拼 AT 指令:
c
sprintf(cmd, "AT+CIPSEND=0,%d\r\n", len);
HAL_UART_Transmit(&huart2, cmd, strlen(cmd), 100);
HAL_UART_Receive(&huart2, buf, len, 1000);
// ... 数据解析 ...
封装后------调用标准接口:
c
at_send(sock, data, len);
at_recv(sock, buf, 256);
4.4 本项目的 Socket 封装设计
c
typedef struct AT_Socket {
int used; // 0=空闲, 1=已使用
int type; // SOCK_STREAM / SOCK_DGRAM
SemaphoreHandle_t at_packet_sem; // 数据到达信号量
QueueHandle_t recv_queue; // 接收数据队列
} AT_Socket;
// 封装的 API
int at_socket(int domain, int type, int protocol);
int at_connect(int sock, const struct sockaddr *addr);
int at_send(int sock, const uint8_t *data, int len);
int at_recv(int sock, uint8_t *buf, int len);
int at_closesocket(int sock);
到此完成: 理解了 Socket 接口的设计思路。
05. 基于 FreeRTOS 实现 ESP8266 的 socket 接口
参考视频: https://www.bilibili.com/video/BV1oF3XzpEuQ/
(有思路后就不看视频直接写程序)

参考源码: https://gitee.com/weidongshan/20250614_FreeRTOS-WIFI-Module-Socket-Encapsulation.git
5.1 整体架构
应用层 (StartDefaultTask)
│
├─ at_send ──→ AT 命令层 ──→ UART 设备层 ──→ ESP8266
│
└─ at_recv ←── Socket 队列 ←── Parser 任务 ←── UART 设备层
5.2 五层软件结构
┌──────────────────────────────────────────────────────┐
│ 应用层 (StartDefaultTask) freertos.c │
├──────────────────────────────────────────────────────┤
│ Socket API 层 │
│ at_socket / at_connect / at_send / at_recv │
├──────────────────────────────────────────────────────┤
│ AT 命令层 │
│ at_exec_cmd / at_send_datas │
├──────────────────────────────────────────────────────┤
│ Parser 任务层 │
│ ESP8266 回复解析、+IPD 状态机 │
├──────────────────────────────────────────────────────┤
│ UART 设备层 │
│ UART_Device 结构体、DMA+IDLE │
├──────────────────────────────────────────────────────┤
│ 硬件层 (USART2 PA2/PA3 → ESP8266) │
└──────────────────────────────────────────────────────┘
5.3 各层数据结构
UART_Device(硬件抽象层):
c
struct UART_Device {
char *name;
int (*Init)(struct UART_Device *, int, int, char, int);
int (*Send)(struct UART_Device *, uint8_t *, int, uint32_t);
int (*Recv)(struct UART_Device *, uint8_t *, uint32_t);
void *priv_data; // 指向 UART_Data(含 DMA 缓冲区、队列)
};
AT_Device(AT 设备层):
c
typedef struct AT_Device {
char *name;
struct UART_Device *ptUARTDev; // 指向底层 UART
SemaphoreHandle_t at_lock; // 互斥锁
SemaphoreHandle_t at_resp_sem; // AT 回复信号量
uint8_t resp_line[256];
uint32_t resp_status; // 0=OK, 1=ERROR
AT_Socket sockets[5]; // 5 个 Socket 槽
} AT_Device;
AT_Socket(Socket 层):
c
typedef struct AT_Socket {
int used;
int type;
SemaphoreHandle_t at_packet_sem; // 数据到达信号量
QueueHandle_t recv_queue; // 接收数据队列
} AT_Socket;
5.4 at_send 实现
c
int at_send(int sock, const uint8_t *data, int len)
{
char cmd[32];
// 通知 ESP8266 要发数据
sprintf(cmd, "AT+CIPSEND=0,%d\r\n", len);
at_exec_cmd(cmd, 3000);
// 发实际数据
at_send_datas(0, data, len);
return 0;
}
5.5 at_recv 实现
c
int at_recv(int sock, uint8_t *buf, int len)
{
AT_Socket *s = &g_at_sockets[sock];
int ret = 0;
// 先看队列有没有数据(非阻塞)
while (xQueueReceive(s->recv_queue, buf + ret, 0) == pdPASS)
if (++ret >= len) break;
if (ret > 0) return ret;
// 队列空,等信号量(100ms 超时)
if (xSemaphoreTake(s->at_packet_sem, pdMS_TO_TICKS(100)) == pdPASS)
while (xQueueReceive(s->recv_queue, buf + ret, 0) == pdPASS)
if (++ret >= len) break;
return ret;
}
5.6 Parser 任务------自动解析 ESP8266 回复
c
void parser_task(void *pvParameters)
{
uint8_t ch;
int line_len = 0;
while (1) {
// 从 DMA 队列收一个字节
ptDev->ptUARTDev->Recv(ptDev->ptUARTDev, &ch, portMAX_DELAY);
ptDev->resp_line[line_len++] = ch;
if (ch == '\n') {
if (strstr(ptDev->resp_line, "OK")) {
xSemaphoreGive(ptDev->at_resp_sem);
} else if (strstr(ptDev->resp_line, "+IPD")) {
parse_ipd_data(ptDev, ptDev->resp_line);
xSemaphoreGive(ptDev->at_packet_sem);
}
line_len = 0;
}
}
}
5.7 应用层完整代码
c
void StartDefaultTask(void *argument)
{
// 第 1 步:初始化 ESP8266
esp8266_init("dma_uart2");
// 第 2 步:连 WiFi(失败自动重试)
while (esp8266_connect_ap("1", "987654321") != 0)
osDelay(3000);
// 第 3 步:连 TCP
int sock = at_socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_port = 8080;
addr.sin_addr = (192<<24)|(168<<16)|(2<<8)|175;
at_connect(sock, &addr);
// 第 4 步:循环收发
uint8_t buf[256];
for (;;) {
// 接收 WiFi 数据 → 打印
int ret = at_recv(sock, buf, 256);
if (ret > 0) printf("PC: %s\r\n", buf);
// 串口输入 → WiFi 发送
uint8_t ch;
while (UART3_ReadByte(&ch)) {
tx_line[tx_len++] = ch;
if (ch == '\n') {
at_send(sock, tx_line, tx_len);
tx_len = 0;
}
}
osDelay(50);
}
}
5.8 成果展示

[20:34:49.642] tx->hello ← STM32 发送到 PC
[20:34:52.698] rx<-hello ← PC 发送到 STM32
[20:34:55.000] PC: hello ← printf 打印接收到的数据
TCPserver: connected
local 192.168.2.175:8080
remote 192.168.2.222:4435
到此完成: Socket 封装实现完毕,WiFi 双向通信可以用标准 API 完成。
06. FreeRTOS 理论补充
学习内容(韦东山 FreeRTOS 快速入门):
https://yiint.xetslk.com/s/2b7MPa
| 编号 | 内容 | 说明 |
|---|---|---|
| 06-1 | 任务状态理论讲解 | 就绪/运行/阻塞/挂起 |
| 06-2 | 任务状态实验 | 实际操作 |
| 06-3 | vTaskDelay 和 vTaskDelayUntil | 延时函数 |
| 06-4 | 空闲任务及其钩子函数 | Idle 任务 |
| 06-5 | 任务调度算法 | 优先级/时间片 |
| 07 | 同步互斥与通信概述 | 概念 |
| 08-1 | 队列的理论讲解 | Queue |
| 08-2 | 队列的常规使用 | Queue API |
| 08-3 | 队列集 | Queue Set |
| 09-1 | 信号量的理论讲解 | Semaphore |
| 09-2 | 信号量的常规使用 | Semaphore API |
| 10-1 | 互斥量的理论讲解 | Mutex |
| 10-2 | 互斥量的常规使用 | Mutex API |
| 10-3 | 互斥量的缺陷和递归锁 | 优先级反转与递归锁 |
6.1 互斥锁(Mutex)
保护共享资源,同一时间只有一个任务能访问。
c
at_lock = xSemaphoreCreateMutex();
xSemaphoreTake(at_lock, portMAX_DELAY); // 取锁
// ... 操作共享资源(发 AT 指令)...
xSemaphoreGive(at_lock); // 放锁
6.2 信号量(Semaphore)
c
// 二值信号量(事件通知)
at_packet_sem = xSemaphoreCreateBinary();
xSemaphoreGive(at_packet_sem); // Parser 给
xSemaphoreTake(at_packet_sem, 100ms); // at_recv 等
// 计数信号量(可积累多次)
at_resp_sem = xSemaphoreCreateCounting(10, 0);
6.3 队列(Queue)
ISR → 任务之间传数据。
c
xRxQueue = xQueueCreate(1024, 1); // 1024 个元素,每个 1 字节
// ISR 放:xQueueSendFromISR(xRxQueue, &byte, NULL)
// 任务取:xQueueReceive(xRxQueue, &byte, timeout)
6.4 中断优先级规则(重点)
configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY = 5
优先级 0~4 → 高于阈值 → 不能调 FreeRTOS API → 调了就死
优先级 5~15 → 低于或等于 → 可以调 FromISR 函数
本项目踩的坑:
USART2 IRQ 优先级 = 4 → 调 xQueueSendFromISR → 系统卡死
改为 5 → 正常运行
07. Modbus 温湿度传感器接入
7.1 本节要做什么
把你的 STM32F407 通过 RS485 总线连接一个温湿度传感器(STM32F030),用 Modbus 协议读取温度值,通过串口打印出来。

最终效果: 上电后每隔 1 秒打印一次温度。
Modbus Master Start!
Temperature: 26.9 C
Temperature: 27.0 C
7.2 硬件接线

STM32F407 TTL485-V2.0 模块 F030 温湿度传感器
PA2 (USART2_TX) ────────→ RXD A ───── A
PA3 (USART2_RX) ←──────── TXD B ───── B
GND ──────→ GND
3.3V ──────→ VCC
为什么用 RS485 而不是直接串口?
| 方案 | 传输距离 | 抗干扰 | 多设备 |
|---|---|---|---|
| 直接 TTL 串口 | 几米 | 弱 | 一对一 |
| RS485 | 几百米 | 强(差分信号) | 一对多 |
RS485 使用两根线(A 和 B)传输差分信号。所谓差分信号,就是数据不是用"一根线对地电压"表示的,而是用"两根线之间的电压差"表示的。干扰同时作用在两根线上,差值不变------这就是它抗干扰的原因。
TTL485-V2.0 模块: 这个红色小模块的作用是把 STM32 的 TTL 串口信号(PA2/PA3)转换成 RS485 差分信号(A/B)。它只需要 4 根线(VCC、GND、TX、RX),是自动切换方向的------发数据时自动切到发送,收数据时自动切到接收,不需要用 GPIO 控制方向。
7.3 Modbus 是什么(第一次接触的人请仔细读)
Modbus 是一种通信协议,1979 年诞生,现在是工业领域最广泛使用的标准。
协议的本质是什么?
协议就是双方约定好的沟通规则。你跟人打电话,规则是:先拨号→对方接→你说"喂"→对方回"喂"→然后开始说话。Modbus 也一样,它规定了:
-
谁先说话(主站 先发,从站不能主动说话)
-
数据长什么样(帧格式:地址 + 功能码 + 数据 + 校验)
-
说错了怎么办(异常码)
你的 F407 是主站(Master),主动问
传感器是 从站(Slave),被动回答主站问:地址 3 的传感器,把你的温度给我
传感器答:温度是 26.9°C
三种物理层对比:
| 物理层 | 特点 | 你在这个项目中用的 |
|---|---|---|
| RS232 | 一对一点对点,距离短 | --- |
| RS485 | 差分信号,抗干扰,一主多从 | ✅ 你用这个 |
| TCP/IP | 以太网,跨车间通信 | --- |
7.4 四类寄存器(Modbus 协议核心概念)
Modbus 定义了 4 种寄存器来存储数据。初学者看到这 4 个名字很容易晕,但用下面这个表就能分清:
| 寄存器类型 | 简称 | 操作单位 | 读写属性 | 类比 |
|---|---|---|---|---|
| 线圈状态 (Coil Status) | 0x | 1 bit | 可读可写 | 控制 LED 开/关 |
| 离散输入状态 (Discrete Input Status) | 1x | 1 bit | 只读 | 读取按键按下/松开 |
| 保持寄存器 (Holding Register) | 4x | 16 bit | 可读可写 | 设置 PID 参数 |
| 输入寄存器 (Input Register) | 3x | 16 bit | 只读 | 读取温度传感器值 |
记忆口诀:
偶数类寄存器(0x、4x)→ 可读可写
奇数类寄存器(1x、3x)→ 只读
0x、1x → 位操作(1 bit,只有 0 或 1)
3x、4x → 16 位寄存器操作(0~65535 的数值)
你的传感器用到了其中两种:
| 点表写 | 实际类别 | 干什么 |
|---|---|---|
| AI(模拟量输入)→ 输入寄存器(3x) | 只读 16 位 | 读温度、湿度 |
| DO(数字量输出)→ 线圈状态(0x) | 可读可写 1 位 | 控制蜂鸣器、LED |
7.5 温湿度传感器点表
"点表"就是传感器厂家提供的寄存器地址对照表,告诉你哪个地址对应什么数据。
你的传感器点表:
| 设备地址 | 寄存器地址 | 寄存器类别 | 用途 | 描述 |
|---|---|---|---|---|
| 03H | 0000H | DO | 控制蜂鸣器 1 | 1=响,0=不响 |
| 03H | 0001H | DO | 控制蜂鸣器 2 | 1=响,0=不响 |
| 03H | 0002H | DO | 控制 LED1 | 1=亮,0=灭 |
| 03H | 0003H | DO | 控制 LED2 | 1=亮,0=灭 |
| 03H | 0004H | DO | 控制 LED3 | 1=亮,0=灭 |
| 03H | 0000H | AI | 读取温度 | 单位 0.1°C,16 位有符号整数 |
| 03H | 0001H | AI | 读取湿度 | 单位 0.1% RH,16 位有符号整数 |
解读这张表:
- 设备地址 03H: 这个传感器在 RS485 总线上的地址是 3。如果总线上挂了 5 个传感器,各自地址不同,主站通过地址区分要跟谁说话。
- 寄存器地址 0000H: 温度数据存在地址 0 的输入寄存器里。
- AI: 模拟量输入,对应功能码 04(读输入寄存器)。
- 单位 0.1°C: 如果读出来的数值是 269,实际温度 = 269 × 0.1 = 26.9°C。
- 16 位有符号整数: "有符号"意味着最高位是符号位,取值范围 -32768~32767。温度可能是负的(比如 -5°C),所以用有符号。读数值 -50 就是 -5.0°C。湿度不会为负,但在协议层也用有符号存储。
7.6 Modbus 报文初体验(用 Modbus Poll 验证)
在写代码之前,先用 Modbus Poll 工具手动试一下,确认传感器能正常工作。
Modbus Poll 配置:
| 参数 | 值 |
|---|---|
| Serial Port | 传感器实际连接的 COM 口 |
| Baud Rate | 115200 |
| Data Bits | 8 |
| Parity | None |
| Stop Bits | 1 |
| Slave ID | 3 |
| Function | 04 Read Input Register(3x) |
| Address | 0 |
| Quantity | 1 |
成果展示: 温度值显示在 Modbus Poll 窗口中,双击还可以改值写入。
你可以看到 Modbus Poll 自动发送了这样的报文(打开 Display → Communication 查看):
TX: 03 04 00 00 00 01 30 28 ← 你发给传感器的请求
RX: 03 04 02 01 0D 00 A5 ← 传感器的回复
这就是 Modbus 通信。现在你要做的是:不依赖 Modbus Poll,让你的 F407 发出跟上面一模一样的字节,收到回复后解析出温度。
7.7 自己写第一个 Modbus 函数:读温度
先理解你要发出去的帧:
请求帧(F407 → 传感器,8 字节):
03 04 00 00 00 01 CRC
│ │ │ │
│ │ │ └─ 数量:读 1 个寄存器
│ │ └─ 起始地址:从 0 开始(温度)
│ └─ 功能码:04(读输入寄存器)
└─ 从机地址:3
响应帧(传感器 → F407,7 字节):
03 04 02 01 0D CRC
│ │ │ │
│ │ │ └─ 数据:0x010D = 269
│ │ └─ 字节数:2 字节(每个寄存器占 2 字节)
│ └─ 功能码:04
└─ 从机地址:3
换算法: 0x010D = 1×256 + 13 = 269。269 × 0.1 = 26.9°C。
7.8 写 CRC16 函数(数据校验)
发送的数据末尾要附上 CRC 校验码,接收方用同样的算法算一遍,不一致说明数据传错了。
CRC 计算不需要理解数学原理,直接使用查表法------提前算好 256 个值存成数组:
c
static const uint16_t crc_table[256] = {
0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241,
// ... 共 256 个值(从 libmodbus 源码复制)
};
uint16_t crc16_modbus(uint8_t *data, uint16_t len)
{
uint16_t crc = 0xFFFF; // 初始值
for (uint16_t i = 0; i < len; i++)
crc = (crc >> 8) ^ crc_table[(crc ^ data[i]) & 0xFF];
return crc;
}
⚠️ CRC 在帧中的摆放顺序(容易搞反):
c
uint16_t crc = crc16_modbus(tx_buf, 6);
tx_buf[6] = crc & 0xFF; // CRC 低字节在前 ← 不是高字节!
tx_buf[7] = (crc >> 8) & 0xFF; // CRC 高字节在后
Modbus 协议规定:地址、数量、数据都是高位在前 ,唯独 CRC 是低位在前。
7.9 printf 重定向
在你的代码中要确认 printf 能输出到 PC 串口。在 main.c 中加:
c
int fputc(int ch, FILE *f) {
HAL_UART_Transmit(&huart3, (uint8_t *)&ch, 1, 100);
return ch;
}
同时要在 Keil 中勾选:Options for Target → C/C++ → Use MicroLIB。否则 printf 不能正常工作。
7.10 发送和接收函数
c
int16_t read_temperature(void)
{
uint8_t tx_buf[8] = {0x03, 0x04, 0x00, 0x00, 0x00, 0x01, 0, 0};
uint8_t rx_buf[7];
// 1. 算 CRC
uint16_t crc = crc16_modbus(tx_buf, 6);
tx_buf[6] = crc & 0xFF;
tx_buf[7] = (crc >> 8) & 0xFF;
// 2. 通过 USART2 发送
HAL_UART_Transmit(&huart2, tx_buf, 8, 100);
// 3. 等传感器回复(超时 500ms)
if (HAL_UART_Receive(&huart2, rx_buf, 7, 500) == HAL_OK) {
// 4. 检查回复的地址和功能码对不对
if (rx_buf[0] == 0x03 && rx_buf[1] == 0x04) {
// 5. 解析温度值,高位在前
return (rx_buf[3] << 8) | rx_buf[4];
}
}
return -999; // 失败
}
重点理解 if (HAL_UART_Receive(&huart2, rx_buf, 7, 500) == HAL_OK):
&huart2:指定使用 USART2 这个外设rx_buf:收到数据存放在这里7:期望收到 7 个字节500:最多等 500ms,超时返回 HAL_TIMEOUT- 返回值 HAL_OK 表示确收到了 7 个字节
7.11 封装成公共收发函数
你会发现不同功能码的"发→收→校验"流程一模一样。把它抽出来:
c
int modbus_send_recv(uint8_t *tx_buf, uint8_t tx_len,
uint8_t *rx_buf, uint8_t rx_len)
{
// 算 CRC
uint16_t crc = crc16_modbus(tx_buf, tx_len - 2);
tx_buf[tx_len - 2] = crc & 0xFF;
tx_buf[tx_len - 1] = (crc >> 8) & 0xFF;
// 发送
HAL_UART_Transmit(&huart2, tx_buf, tx_len, 100);
// 接收
if (HAL_UART_Receive(&huart2, rx_buf, rx_len, 500) == HAL_OK)
return 0;
return -1;
}
然后 read_temperature 简化为:
c
int16_t read_temperature(void)
{
uint8_t tx_buf[8] = {0x03, 0x04, 0x00, 0x00, 0x00, 0x01, 0, 0};
uint8_t rx_buf[7];
if (modbus_send_recv(tx_buf, 8, rx_buf, 7) == 0) {
if (rx_buf[0] == 0x03 && rx_buf[1] == 0x04)
return (rx_buf[3] << 8) | rx_buf[4];
}
return -999;
}
这就是封装------公共逻辑(CRC 计算、收发)抽到公用函数里,每个业务函数只关心自己的帧格式和解析逻辑。
7.12 用参数化函数代替写死函数
上面的 read_temperature 把地址写死成 3、功能码写死成 04。改成参数化:
c
int modbus_ai_read(int dev_addr, int reg_addr, int *val)
{
uint8_t tx_buf[8] = {dev_addr, 0x04,
(reg_addr >> 8) & 0xFF, reg_addr & 0xFF,
0x00, 0x01, 0, 0};
uint8_t rx_buf[7];
if (modbus_send_recv(tx_buf, 8, rx_buf, 7) == 0) {
if (rx_buf[0] == dev_addr && rx_buf[1] == 0x04) {
*val = (rx_buf[3] << 8) | rx_buf[4];
return 0;
}
}
return -1;
}
解释参数:
dev_addr:从机的设备地址(你的传感器是 3)reg_addr:寄存器地址(温度是 0,湿度是 1)val:指针参数,函数把读取的值写到这个变量里- 返回值:0=成功,-1=失败(这是 C 语言的典型模式)
调用方式:
c
int temp;
if (modbus_ai_read(3, 0, &temp) == 0) // 读传感器地址 3 的寄存器 0
printf("温度: %d.%d C\r\n", temp / 10, temp % 10);
else
printf("读取失败\r\n");
7.13 控制蜂鸣器/LED
除了读温度湿度,还可以控制传感器上的输出(蜂鸣器、LED)。
功能码 05(写单个线圈):
写请求: 03 05 00 00 FF 00 CRC ← 地址 0 的线圈,开
写请求: 03 05 00 00 00 00 CRC ← 地址 0 的线圈,关
注意: ON = FF 00,不是 01 00。这是 Modbus 协议的硬性规定。
c
int modbus_do_write(int dev_addr, int reg_addr, int val)
{
uint8_t tx_buf[8] = {dev_addr, 0x05,
(reg_addr >> 8) & 0xFF, reg_addr & 0xFF,
val ? 0xFF : 0x00, 0x00, 0, 0};
uint8_t rx_buf[8];
if (modbus_send_recv(tx_buf, 8, rx_buf, 8) == 0) {
if (rx_buf[0] == dev_addr && rx_buf[1] == 0x05)
return 0;
}
return -1;
}
调用:
c
modbus_do_write(3, 0, 1); // 开蜂鸣器1
HAL_Delay(500);
modbus_do_write(3, 0, 0); // 关蜂鸣器1
7.14 移植 libmodbus(标准库方式)
当你写了 3 个自定义函数后,你会发现:如果需要支持所有功能码,你需要写几十个这样的函数。这就是为什么用 libmodbus------一个标准的开源 Modbus 库。
移植的核心:替换 send 和 recv 函数。 只需要修改 4 个函数:
c
static ssize_t _modbus_rtu_send(modbus_t *ctx, const uint8_t *req, int req_length)
{
if (HAL_UART_Transmit(&huart2, (uint8_t *)req, req_length, 1000) == HAL_OK)
return req_length;
return -1;
}
static ssize_t _modbus_rtu_recv(modbus_t *ctx, uint8_t *rsp, int rsp_length)
{
if (HAL_UART_Receive(&huart2, rsp, rsp_length, 1000) == HAL_OK)
return rsp_length;
return -1;
}
移植后,在 main 中调用:
c
modbus_t *ctx = modbus_new_st_rtu("usart2", 115200, 'N', 8, 1);
modbus_set_slave(ctx, 3);
modbus_connect(ctx);
uint16_t temp;
if (modbus_read_input_registers(ctx, 0, 1, &temp) == 1)
printf("Temperature: %d.%d C\r\n", temp / 10, temp % 10);
modbus_close(ctx);
modbus_free(ctx);
关于 libmodbus 移植的完整步骤(构造函数、backend 函数表、编译适配)详见《Modbus 核心知识手册》第 17~22 章。
7.15 主循环完整代码
c
void StartDefaultTask(void *argument)
{
// ... 原有 ESP8266 初始化代码 ...
// Modbus 初始化
modbus_t *ctx = modbus_new_st_rtu("usart2", 115200, 'N', 8, 1);
modbus_set_slave(ctx, 3);
modbus_connect(ctx);
for (;;) {
// 读温度
uint16_t temp;
if (modbus_read_input_registers(ctx, 0, 1, &temp) == 1)
printf("Temperature: %d.%d C\r\n", temp / 10, temp % 10);
else
printf("Read failed\r\n");
// 读湿度
uint16_t hum;
if (modbus_read_input_registers(ctx, 1, 1, &hum) == 1)
printf("Humidity: %d.%d%%\r\n", hum / 10, hum % 10);
osDelay(1000);
}
}
7.16 成果展示
烧录后,按复位键,串口输出:
Modbus Master Start!
Temperature: 25.6 C
Humidity: 47.7%
Temperature: 25.6 C
Humidity: 47.8%
到此完成: 你的串口服务器可以同时做 WiFi 通信和 Modbus 传感器采集。
7.17 常见问题排查
| 现象 | 原因 | 解决 |
|---|---|---|
| 一直显示 Read failed | TTL485 没接好或传感器没供电 | 检查接线和传感器 USB 供电 |
| 乱码 | sscom 中文编码问题 | 改用英文提示 |
| 温度值异常大 | CRC 算错或字节序搞反 | 确认 CRC 低字节在前 |
| printf 不输出 | Keil 没勾 Use MicroLIB | 勾选 MicroLIB |
08. 调试笔记------常见 Bug 与解决
Bug 1:任务栈溢出
现象:程序自动重启,"Task started!" 打印两次
根因:FreeRTOS 默认栈 512 字节,printf 使用超过 512 导致栈溢出
修复:在 freertos.c 中将栈改为 1024×4 = 4096 字节
教训:printf 会消耗大量栈空间。栈溢出时系统不会报错,而是静默重启。
Bug 2:TX DMA 未配置
现象:ESP8266 无响应,"WiFi connect failed, retry..." 无限循环
根因:CubeMX 只配置了 RX DMA,TX DMA 没配,HAL_UART_Transmit_DMA 返回错误
修复:改为查询方式发送 HAL_UART_Transmit()
教训:永远检查 HAL 函数的返回值,否则失败了你不知道。
Bug 3:中断优先级冲突
现象:系统在 xSemaphoreTake 处卡死,超时无效
根因:USART2 IRQ 优先级设为 4,而 configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY = 5。
优先级 4 > 5,属于"FreeRTOS 管不到的中断"。在这个中断里调 xQueueSendFromISR
会破坏 FreeRTOS 内部数据结构,导致死机。
修复:USART2 NVIC 优先级从 4 改为 5
教训:所有需要调用 FreeRTOS API(如 xQueueSendFromISR)的中断,优先级必须
≤ configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY。
Bug 4:+IPD 格式不匹配
现象:at_recv 始终收不到数据
根因:Parser 状态机只处理了多连接模式的 +IPD 格式(+IPD,link_id,len:data),
但 ESP8266 运行在单连接模式(+IPD,len:data,没有 link_id 字段)。
修复:在状态机中增加对无 link_id 的 +IPD 格式处理
Bug 5:at_recv 永久阻塞
现象:没有网络数据时,主循环卡死在 at_recv,无法执行其他代码
根因:xSemaphoreTake(s->at_packet_sem, portMAX_DELAY) --- 永久等待
修复:改为 xSemaphoreTake(s->at_packet_sem, pdMS_TO_TICKS(100)),100ms 超时
教训:永久阻塞的 API 一定要确认"一定有东西来唤醒你"才用。
Bug 6:HAL 版本不兼容
现象:USART3 串口输入完全收不到
根因:旧版 HAL(V1.16)中 HAL_UART_StateTypeDef 是单个字段,TX 和 RX 共用。
printf 发送时设 State = BUSY_TX,HAL_UART_Receive_IT 检查 State 发现不是
READY,返回 HAL_BUSY。
修复:放弃 HAL,USART3 直接操作寄存器读 DR、检测 RXNE 标志。
教训:标准库版本差异有时候比想象的大。V1.16(旧版)和 V1.28(新版)的代码不兼容。
附录:完整运行效果
WiFi 连接
[22:18:21] ESP8266 test start
[22:18:21] mode set
[22:18:21] WiFi connected
[22:18:22] TCP connected
双向通信

[22:18:41] rx<-你也好 ← PC → STM32(WiFi)
[22:18:43] tx->hello ← STM32 → PC(WiFi)
[22:18:45] PC: 你好 ← at_recv 打印
传感器数据
Modbus Master Start!
Temperature: 25.6 C
Temperature: 25.7 C
Temperature: 25.6 C