
简介面向STM32物联网开发的工程资源聚焦借助HAL库操作ESP8266模块实现UART串口通信、AT指令配置、Wi-Fi连接与透传模式数据转发适合需要快速上手无线通信的嵌入式开发者。压缩包共77个文件其中C源文件21个、头文件48个另含IOC工程配置、Keil工程文件、Hex固件及辅助清理脚本可对照源码学习从串口初始化到网络透传的完整路径资源大小仅549KB便于下载与本地编译。已有2520人学习/下载。内容涵盖STM32 HAL库外设配置、ESP8266 Station模式接入、常用AT指令交互如连接Wi-Fi、建立TCP连接、发送数据以及透传模式下收发数据的处理工程目录按Inc/Src/Drivers划分结构清晰对理解嵌入式Wi-Fi透传方案、构建物联网原型节点具有直接参考价值。资源按功能模块划分代码便于移植至其他STM32系列适合正在调试无线透传的开发者参考。1. 为什么说操作 ESP8266 的难点根本不在 WiFi如果你从 STC51 或裸机寄存器开发转过来第一次用 HAL 库驱动 ESP8266 时会有一个明显错觉库文件太大、初始化代码太多、根本不知道从哪里开始。其实 HAL 库操作 ESP8266 的核心从来不是 WiFi 协议本身而是把 USART 的事件模型用对——什么时候启动接收、什么时候处理一帧完整数据、什么时候清空溢出标志。网上大量“连不上、收不到 OK、透传丢字节”的例子九成都是串口层面的问题。这个标题真正要解决的是两件事一是用 CubeMX 生成的 HAL 代码把 ESP8266 的 AT 指令通道跑通二是在透传模式下不丢数据地做双向转发。无论你是做 stm32 鱼缸的环境监测上报还是用 esp8266 无线控制 WS2812 灯带做渐变、海浪、滚动这类 10 种灯光效果底层都是同一套 USART 与 ESP8266 的交互逻辑。本文不依赖某个“源码包”按工程思路从零把链路搭起来。2. CubeMX 配置 STM32 与 ESP8266 的通信链路先定 USART 再谈其他2.1 硬件连接三点确认避免把时间浪费在排查上STM32 与 ESP8266 之间只有三根线要接TX、RX、GND。但很多模块在开发板上不是引出的标准串口排针连接前先确认三件事。第一是供电。ESP8266 在 WiFi 发射瞬间电流可以到 300mA 以上工作平均电流 70~80mA。用 STM32 板上那颗 3.3V LDO 同时给模组和其他外设供电大概率会出现“ATCWJAP 发出去之后模块自动重启”的现象。我一般单独给 ESP8266 供电或者至少保证稳压芯片有 500mA 余量。第二是电平。绝大多数 ESP8266 模块是 3.3V 电平STM32F103 的 TX 输出 3.3V 可以直接接模块 RX但模块 TX 到 STM32 RX 这条线如果 STM32 的 RX 引脚不是 5V 容忍引脚建议串一个 1kΩ 电阻做保护。第三是 CH_PDEN引脚必须拉高。有人把 CH_PD 悬空模块完全不工作AT 指令永远没响应。下表是我常用的一种引脚映射USART2 留给 ESP8266USART1 保留给调试串口。STM32 引脚外设功能连接目标说明PA2USART2_TXESP8266 RXD推挽复用输出PA3USART2_RXESP8266 TXD上拉输入或浮空输入PB0GPIO 输出ESP8266 RST复位控制默认高PB1GPIO 输出ESP8266 CH_PD(EN)使能控制默认高如果用的是 STM32F103C8T6 这类小封装USART2 和 USART1 都在 PA 口上注意别把调试串口的 PA9/PA10 和 ESP8266 的 PA2/PA3 搞混。接线完成后先用 USB 转 TTL 单独验证 ESP8266 模块能正常回 AT再接到 STM32 上。跳过这一步会浪费后面大量排错时间。2.2 CubeMX 里针对 AT 固件的 6 个关键参数打开 STM32CubeMX选好芯片型号后在 Connectivity 里找到 USART2开启 Asynchronous 模式。ESP8266 的 AT 指令通道参数是固定的115200 波特率、8 位数据、无校验、1 位停止位。少数老模块出厂固件默认波特率是 9600如果你发 AT 收不到 OK先用 USB 转 TTL 确认模块当前波特率再回 CubeMX 改。这里说 6 个必须检查的参数任何一个不对后面都跑不起来Baud Rate 设为 115200与模块 AT 固件默认值一致。改 ATUART_CUR 可以改当前运行波特率但断电恢复默认值不建议依赖它。Word Length 选 8 BitsParity 选 NoneStop Bits 选 1。这是 AT 固件固定格式不要为了“省一个校验位”改成 7 位。Hardware Flow Control 必须选 Disable。很多 ESP8266 模块的 RTS/CTS 引脚被复用到 GPIO15/GPIO13开启硬件流控反而把 GPIO 功能顶掉。在 NVIC Settings 里勾选 USART2 global interrupt。HAL 库的接收中断依赖这个开关不勾选 HAL_UART_Receive_IT 不会触发回调。如果后面打算走 DMA 接收需要把 USART2_RX 的 DMA Request 添加到 DMA1 Channel6Mode 选 Normal 或 CircularData Width 都选 Byte。时钟树确认 APB1 时钟为 36MHzF103USART2 挂载在 APB1 上波特率计算依赖这个时钟源。CubeMX 默认生成没问题但如果你手动改过时钟树波特率会随机漂移。这些参数的生成结果直接体现在 HAL_UART_Init 的 huart2.Init 结构体里。确认无误后生成工程接下来看初始化代码的坑。2.3 HAL_UART_Init 只配置寄存器不启动接收CubeMX 生成的 MX_USART2_UART_Init 函数内容如下static void MX_USART2_UART_Init(void) { huart2.Instance USART2; huart2.Init.BaudRate 115200; huart2.Init.WordLength UART_WORDLENGTH_8B; huart2.Init.StopBits UART_STOPBITS_1; huart2.Init.Parity UART_PARITY_NONE; huart2.Init.Mode UART_MODE_TX_RX; huart2.Init.HwFlowCtl UART_HWCONTROL_NONE; huart2.Init.OverSampling UART_OVERSAMPLING_16; if (HAL_UART_Init(huart2) ! HAL_OK) { Error_Handler(); } }这段代码做了什么它把 USART2 的波特率、帧格式、收发模式写入寄存器并调用 HAL_UART_MspInit 完成 GPIO 复用和中断优先级的配置。很多刚从寄存器开发转过来的人以为调用完 HAL_UART_Init 之后串口收到数据就会自动进中断。实际上 HAL 库在 Init 阶段不会使能任何接收中断也不会启动 DMA 传输。要真正开始收数据必须主动调用一次 HAL_UART_Receive_IT 或 HAL_UART_Receive_DMA。这个“手动启动接收”的设计贯穿整个 HAL 库每次接收完成后接收中断自动关闭你需要重新调用一次。在 main 函数里加这段uint8_t rx_byte; int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART2_UART_Init(); HAL_UART_Receive_IT(huart2, rx_byte, 1); // 启动单字节中断接收 while (1) { } }参数说明HAL_UART_Receive_IT 的第三个参数是接收长度这里是 1代表每收到一个字节触发一次中断。这个模式适合 AT 指令阶段数据量小、逐字节解析可控。后面做透传时再改成 DMA。把 HAL_UART_Receive_IT 的调用放在 while(1) 之前是为了确保主循环跑起来之前USART2 的接收中断已经处于使能状态。3. AT 指令应答解析HAL_UART_Receive_IT 与行状态机的配合3.1 为什么不能只判断“收到 OK 两个字符”ESP8266 的 AT 固件默认开启回显。你发一条AT模块在串口上返回的是AT\r\nOK\r\n——先是指令回显再是换行最后才是 OK。如果在中断回调里用 strstr 找 “OK”回显行里的 AT 不会造成误判但指令里本身可能包含 OK 字样比如ATCIPSTART...的返回里出现CONNECT OK这就容易把 “CONNECT OK” 和普通 OK 搞混。正确做法是按行解析。AT 固件的所有响应都以\r\n作为一行的结束符行内容是OK、ERROR、IPD,...、WIFI CONNECTED这类关键字。状态机只需要做一件事攒字节遇到\n判定一行结束然后交给解析函数。每次解析时去掉行尾的\r再匹配行首关键字。这样无论回显还是主动上报都能被同一套逻辑处理。3.2 单字节中断接收最小可用的行状态机在 AT 指令阶段我最常用的是单字节中断接收也就是 HAL_UART_Receive_IT。下面这段代码可以直接抄进工程它把 USART2 收到的内容按行切分#define AT_LINE_MAX 256 static uint8_t rx_byte; static uint8_t at_line[AT_LINE_MAX]; static volatile uint16_t at_line_len; static volatile uint8_t at_line_ready; void AT_UART_StartReceive(void) { at_line_len 0; at_line_ready 0; HAL_UART_Receive_IT(huart2, rx_byte, 1); } void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART2) { if (at_line_len (AT_LINE_MAX - 1)) { at_line[at_line_len] rx_byte; } if (rx_byte \n) { at_line[at_line_len] \0; // 补结束符 at_line_ready 1; // 标记一行数据已就绪 } HAL_UART_Receive_IT(huart2, rx_byte, 1); // 重新启动下一次接收 } }逻辑说明每次收到一个字节把它放入 at_line 缓冲区同时检查这个字节是不是\n。如果是说明一行数据结束置位 at_line_ready 交给上层解析。缓冲区上限设为 256 字节覆盖 AT 指令应答绰绰有余。回调函数末尾必须重新调用 HAL_UART_Receive_IT否则接收中断只触发一次之后的串口数据全部收不到。这里有个容易被忽略的点AT 固件的换行符是\r\n两个字节为什么只在\n处断行因为\r会被当作普通字节先存进缓冲区\n到达时间一行才真正结束。解析时只需要忽略行尾的\r即可。如果某个第三方固件只用\n不用\r这套逻辑同样成立。3.3 行解析与命令超时把 AT 指令封装成同步接口有了行状态机还要解决两个问题怎么从多行响应里找到目标关键行以及等不到 OK 时怎么超时返回。这两个问题用同一个模块处理。常见的一套做法是定义结果枚举然后轮询处理typedef enum { AT_RESULT_NONE 0, AT_RESULT_OK, AT_RESULT_ERROR, AT_RESULT_TIMEOUT } at_result_t; static volatile at_result_t at_result AT_RESULT_NONE; static uint8_t at_line_match(const char *line, const char *keyword) { while (*keyword *line *keyword) { line; keyword; } return (*keyword \0); } void AT_ParseLine(const char *line, uint16_t len) { if (at_line_match(line, OK)) { at_result AT_RESULT_OK; } else if (at_line_match(line, ERROR)) { at_result AT_RESULT_ERROR; } else if (at_line_match(line, IPD)) { // 透传模式下由 DMA 处理指令阶段一般不会出现 } } void AT_Task(void) { if (at_line_ready) { at_line_ready 0; AT_ParseLine((const char *)at_line, at_line_len); at_line_len 0; } } at_result_t AT_Command(const char *cmd, uint32_t timeout_ms) { at_result AT_RESULT_NONE; uint32_t start HAL_GetTick(); HAL_UART_Transmit(huart2, (uint8_t *)cmd, strlen(cmd), 100); while ((HAL_GetTick() - start) timeout_ms) { AT_Task(); if (at_result ! AT_RESULT_NONE) { return at_result; } } return AT_RESULT_TIMEOUT; }参数说明AT_Command 的第一个参数是完整指令比如ATCWMODE1\r\n注意结尾必须带\r\n。HAL_UART_Transmit 的最后一个参数是发送超时100ms 足够AT 指令本身很短。timeout_ms 建议根据指令类型设置AT、ATCWMODE 这类本地配置指令 500ms 足够ATCWJAP 连接路由器需要 3~5 秒。这套实现把 AT 指令的“发送—等待—判断”变成了同步调用裸机主循环里直接调 AT_Command 就能拿到结果。注意 AT_Task 不能阻塞否则主循环里其他任务会被卡住。4. WiFi 透传掉字节用 HAL 库 DMA IDLE 中断做串口桥接4.1 为什么单字节中断模式在透传场景会丢数据AT 指令阶段数据量极小单字节中断完全够用。但进入ATCIPMODE1透传模式后ESP8266 和远端服务器之间的数据是双向全双工流STM32 的 USART2接 ESP8266和 USART1接主控或传感器同时持续收发。单字节中断每一字节都触发一次中断回调里还要重新使能 HAL_UART_Receive_IT在高波特率下会频繁打断主循环。另一个问题是 HAL_UART_Receive_IT 在每次接收完成后会自动关闭接收中断。从“最后一个字节触发回调”到“重新调用 HAL_UART_Receive_IT”之间存在一个时间窗口如果这个窗口内模块又发来数据数据就丢了。调试时你能在串口助手里看到数据但 STM32 拿到的数据不完整CRC 校验通不过、数据帧错位、灯带颜色乱跳这些现象都指向同一个原因—串口层丢字节。4.2 DMA 循环接收 空闲中断的标准做法透传场景的标准方案是 DMA 循环接收配合 USART 空闲中断。DMA 负责把串口数据自动搬运到内存缓冲区不占用 CPU当一帧数据发送完毕串口总线上出现空闲状态触发 IDLE 中断此时读取 DMA 当前计数就能知道这一帧的实际长度。这套组合在 HAL 库里有现成支持新版 HAL 库提供了 HAL_UARTEx_RxEventCallback 回调函数专门处理 IDLE 事件。CubeMX 里按下面这张表配置 DMA配置项值说明DMA RequestUSART2_RX只为接收申请 DMA 通道DirectionPeripheral To Memory外设到内存ModeCircular循环模式自动回绕Data WidthByte单字节宽度PriorityMedium中断优先级取中值发送方向不需要 DMA。ESP8266 的 TX 是主动发数据STM32 主循环里用 HAL_UART_Transmit 同步发即可发送量大时改成 HAL_UART_Transmit_IT避免主循环死等。4.3 桥接代码一进一出两个串口的 DMA 处理下面这段代码把 USART2 收到的数据直接转发给 USART1同时把 USART1 收到的数据转给 ESP8266形成最小的双向桥接#define UART_DMA_BUF_SIZE 512 static uint8_t esp_dma_buf[UART_DMA_BUF_SIZE]; static uint8_t dbg_dma_buf[UART_DMA_BUF_SIZE]; void UART_Bridge_Start(void) { HAL_UART_Receive_DMA(huart2, esp_dma_buf, UART_DMA_BUF_SIZE); HAL_UART_Receive_DMA(huart1, dbg_dma_buf, UART_DMA_BUF_SIZE); } void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart-Instance USART2) { HAL_UART_Transmit(huart1, esp_dma_buf, Size, 10); HAL_UART_Receive_DMA(huart2, esp_dma_buf, UART_DMA_BUF_SIZE); } else if (huart-Instance USART1) { HAL_UART_Transmit(huart2, dbg_dma_buf, Size, 10); HAL_UART_Receive_DMA(huart1, dbg_dma_buf, UART_DMA_BUF_SIZE); } }逻辑说明DMA 在循环模式下会自动回绕不会自己停下来。每当串口总线空闲一段时间HAL 库会触发 HAL_UARTEx_RxEventCallbackSize 参数就是当前 DMA 缓冲区里已收到的有效字节数。拿到这一帧数据后直接用 HAL_UART_Transmit 发给另一个串口然后重新调用 HAL_UART_Receive_DMA 准备下一帧。参数说明DMA 缓冲区大小 512 字节如果做纯数据透传远端服务器发来的单个 TCP 包可能超过 512这时可以在 CubeMX 里把缓冲区调到 1500 字节左右代价是多占 1.5KB RAM。F103 的 USART1 和 USART2 各占一个 512 字节缓冲区总共 1KBRAM 压力可接受。缓冲区必须定义为全局变量局部变量会因栈空间不足导致 DMA 写入越界。4.4 旧版 HAL 库没有 RxEventCallback 的处理方式部分旧版本 HAL 库只有 HAL_UART_RxCpltCallback没有 HAL_UARTEx_RxEventCallback。此时需要手动在 USART 中断服务函数里检查 IDLE 标志。CubeMX 生成的 stm32f1xx_it.c 中USART2_IRQHandler 会调用 HAL_UART_IRQHandler旧库在 IDLE 事件上不会做任何处理。可以在 HAL_UART_IRQHandler 之后补一段void USART2_IRQHandler(void) { HAL_UART_IRQHandler(huart2); if (__HAL_UART_GET_FLAG(huart2, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart2); uint16_t current_pos UART_DMA_BUF_SIZE - __HAL_DMA_GET_COUNTER(huart2.hdmarx); HAL_UART_Transmit(huart1, esp_dma_buf, current_pos, 10); HAL_UART_Receive_DMA(huart2, esp_dma_buf, UART_DMA_BUF_SIZE); } }逻辑说明DMA 计数器是从缓冲区尾部倒数计数的用缓冲大小减去当前 DMA 计数器值就得到这一帧的数据长度。这种方式绕过了 RxEventCallback在旧 HAL 库上也能实现同样的效果。唯一的坑是 IDLE 标志必须手动清除否则下一次空闲不会触发中断。如果你用的是新版 HAL 库直接用 RxEventCallback 更省事。5. 不响应、死机、花屏HAL 库下 5 个高频故障排查点5.1 AT 无响应先量电平再看时序“发 AT 没有任何返回”是最常见的问题。按照下面这张表逐项排查通常能在十分钟内定位。现象可能原因检查方法完全无响应CH_PD 悬空或拉低万用表量 EN 引脚电压应接近 3.3V模块发热、反复重启供电电流不足示波器看 VCC 波形发射时是否跌落返回乱码波特率不匹配改 CubeMX 波特率为 9600 再试只有回显没有 OKRST 时序问题确认上电后 RST 拉高低电平脉宽足够STM32 死机中断优先级配置错误检查 USART2 抢占优先级 SysTick除了报表中的内容还有一个很容易踩的坑测试指令必须是AT\r\n而不是AT。某些固件对不带换行的指令不做响应。我之前在调试一个模块时串口助手里发 AT 明明有回显接上 STM32 就是收不到 OK最后发现是 HAL_UART_Transmit 发送的字符串没有带上\r\n模块把指令当成无效输入丢弃了。5.2 ORE 溢出与 HAL_UART_ErrorCallbackHAL 库接收数据时如果上一个字节还没被读走下一个字节就到达硬件会置位 OREOverrun Error标志。这个标志一旦置位后续接收的数据会被丢弃体现在行为上就是“串口偶尔丢字节程序运行一段时间后完全收不到数据”。HAL 库检测到 ORE 会调用 HAL_UART_ErrorCallback如果你没实现这个回调错误标志会一直保持。处理方法是在回调里清标志并重新启动接收void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART2) { __HAL_UART_CLEAR_OREFLAG(huart); HAL_UART_Receive_DMA(huart2, esp_dma_buf, UART_DMA_BUF_SIZE); } else if (huart-Instance USART1) { __HAL_UART_CLEAR_OREFLAG(huart); HAL_UART_Receive_DMA(huart1, dbg_dma_buf, UART_DMA_BUF_SIZE); } }参数说明__HAL_UART_CLEAR_OREFLAG 是 HAL 库提供的宏直接操作 USART_ICR 寄存器清除溢出标志。清完标志后必须重新调用 HAL_UART_Receive_DMA因为溢出发生后 DMA 接收链路已经中断。如果不清标志后续字节会一直被丢弃。5.3 串口助手正常但单片机解析失败粘包与回显差异USB 转 TTL 连 ESP8266 一切正常接上 STM32 就解析失败这种情况优先怀疑“粘包”。多个 AT 响应在短时间内连续到达比如ATCWJAP返回WIFI CONNECTED\r\nWIFI GOT IP\r\n两行数据可能挤在同一个缓冲区批次里。如果解析逻辑只处理一行就清空缓冲区第二行就会被吞掉。修正思路是把行解析改成循环void AT_Task(void) { while (at_line_ready) { at_line_ready 0; AT_ParseLine((const char *)at_line, at_line_len); at_line_len 0; if (at_line_ready) { continue; } } }这里的逻辑是一次处理完当前缓冲区里所有完整行而不是只处理一行。注意 while 循环里的 at_line_ready 可能被中断回调再次置位所以用 volatile 修饰并在每次循环体开头重新读取。5.4 中断优先级配置与 HAL_Delay 卡死HAL 库的 HAL_Delay 依赖 SysTick 中断。如果 USART2 的抢占优先级比 SysTick 低串口中断服务函数运行时不会打断 SysTickHAL_Delay 正常工作。反过来如果把串口中断优先级配得比 SysTick 高且在串口中断回调里调用了 HAL_DelaySysTick 无法抢占串口中断HAL_Delay 里的滴答计数永远不增长程序直接卡死在中断里。CubeMX 的 NVIC 配置里SysTick 默认抢占优先级为 15最低如果 USART2 抢占优先级配成 5就会出现这个问题。建议把 USART2 的抢占优先级设为 7 或更低数值数字越大优先级越低并保证在任何中断回调里不调用 HAL_Delay需要延时用状态机的超时判断替代。6. 把 AT 指令层封装成与 HAL 解耦的小组件方便迁到 RTOS6.1 用接口结构体把 HAL 串口操作隔离在组件外裸机代码堆到一定规模后AT 指令和串口底层混在一起会很难维护。常见的做法是定义一个接口结构体把串口发送、系统 tick、延时抽象成三个函数指针组件内部只依赖这个结构体typedef struct { void (*send)(const uint8_t *data, uint16_t len); uint32_t (*tick)(void); void (*delay)(uint32_t ms); } esp_io_t; static const esp_io_t *esp_io; void ESP_Init(const esp_io_t *io) { esp_io io; } uint8_t ESP_SendCommand(const char *cmd, uint32_t timeout_ms) { esp_io-send((const uint8_t *)cmd, strlen(cmd)); uint32_t start esp_io-tick(); while ((esp_io-tick() - start) timeout_ms) { // 这里调用行解析状态机 if (at_result ! AT_RESULT_NONE) { return (at_result AT_RESULT_OK); } } return 0; }HAL 库这边只需要实现三个函数在 main 初始化时传入结构体即可static void esp_send(const uint8_t *data, uint16_t len) { HAL_UART_Transmit(huart2, data, len, 50); } static uint32_t esp_tick(void) { return HAL_GetTick(); } static void esp_delay(uint32_t ms) { HAL_Delay(ms); } const esp_io_t esp_hal_io { .send esp_send, .tick esp_tick, .delay esp_delay };参数说明这种封装的价值在于 AT 指令层不再依赖具体的 HAL 句柄和串口号。以后从 F103 换到 F407或者把 ESP8266 换到其他串口只改 esp_send 的实现AT 指令层的状态机、超时逻辑、解析代码一行不改。接收方向的字节流仍然通过中断回调注入回调里只做字节积累和行就绪标记不做协议解析。6.2 接收缓冲改用环形队列兼容 RTOS 信号量裸机状态下直接用一个 at_line 数组 中断里写、主循环里读的方式最简单。但如果你打算跑 FreeRTOS中断回调不能长时间执行最干净的做法是字节进环形队列任务里阻塞等待信号量。HAL 中断回调只做两件事把字节压入队列释放一个二值信号量。队列实现在 RTOS 环境下可以直接用 osMessageQueuePut裸机环境下用经典的环形缓冲#define RING_BUF_SIZE 1024 static uint8_t ring_buf[RING_BUF_SIZE]; static volatile uint16_t ring_head; static volatile uint16_t ring_tail; void RING_Push(uint8_t byte) { uint16_t next (ring_head 1) % RING_BUF_SIZE; if (next ! ring_tail) { ring_buf[ring_head] byte; ring_head next; } } uint8_t RING_Pop(uint8_t *byte) { if (ring_head ring_tail) { return 0; } *byte ring_buf[ring_tail]; ring_tail (ring_tail 1) % RING_BUF_SIZE; return 1; }HAL_UART_RxCpltCallback 里改成直接调 RING_Push行解析逻辑移到任务循环这样中断处理时间缩短到几微秒。队列长度 1024 字节时即使主循环被其他任务阻塞几个毫秒串口数据也不会丢失。这个环形队列的容量参数在 AT 指令阶段可以缩小到 256透传阶段至少要给到 1KB 以上。后续换成 MQTT 连云时transport 层只需要在接收侧重新搭一条同样的串口 DMA 通道把数据从环形队列搬进 MQTT 客户端的缓冲区即可。本文还有配套的精品资源点击获取