串口转 WiFi 服务器 — 完整教学文档从0到1

串口转 WiFi 服务器 --- 从零到完整实现

主控:STM32F407 + FreeRTOS | WiFi:ESP8266 | 传感器:Modbus RTU(F030)


01. 项目目标

以项目方式练习 FreeRTOS 基础,涉及如下内容:

  1. FreeRTOS 基础使用:创建任务、队列、信号量、互斥量
  2. AT 模块的 socket 封装
  3. 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 也一样,它规定了:

  1. 谁先说话(主站 先发,从站不能主动说话)

  2. 数据长什么样(帧格式:地址 + 功能码 + 数据 + 校验)

  3. 说错了怎么办(异常码

    你的 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