【Zephyr|ESP32-S3】基础学习:用UART串口中断+命令解析控制WS2812变色
【Zephyr|ESP32-S3】基础学习:用UART串口中断+命令解析控制WS2812变色
哈喽,我是余火,一个普通的牛马打工人,目前正在学如何使用Zephyr RTOS。
上篇用定时器做了消抖和灯效节奏控制,上上篇用 GPIO 按键中断实现了按键控灯。不过到现在为止,所有的输入都来自板子自身——按键、定时器、PWM,都是板子自己玩。这次换个玩法:让电脑通过串口给板子发命令,板子收到后控制 WS2812 变色。这是嵌入式最常用的调试和控制方式,也是从硬件层进入通信层的第一步。
💡本篇学习目标
• UART 串口通信原理(波特率、数据位、停止位、校验位)
•uart_irq_callback_set:注册串口中断回调
•uart_fifo_read/uart_irq_rx_enable:中断驱动的串口收发
• ISR + 信号量 + 线程:串口命令解析的标准范式
改了哪些东西
在上一篇 PWM 呼吸灯工程基础上改造,核心是把 GPIO 按键输入替换为 UART 串口输入:
| 文件 | 改了什么 |
|---|---|
prj.conf | 新增CONFIG_SERIAL=y+CONFIG_UART_INTERRUPT_DRIVEN=y,删除 PWM 相关配置 |
| overlay | 新增/delete-property/ zephyr,shell-uart |
src/main.c | 完全重写:UART 中断接收 + 命令解析线程 + 命令表匹配 |
CMakeLists.txt | 无改动 |
UART 串口通信基础
UART(Universal Asynchronous Receiver/Transmitter,通用异步收发器)是嵌入式最基础的通信接口。几乎所有开发板都标配 USB-Serial 桥接芯片,插上电脑就能用串口通信。
UART 是什么
UART 是一种异步通信协议——发送方和接收方不需要额外的时钟线来同步,双方靠事先约定好的参数来正确解读数据。这就要求双方必须配置完全一致的通信参数。
四个关键参数
| 参数 | 含义 | 常见值 | 说明 |
|---|---|---|---|
| 波特率(Baud Rate) | 每秒传输的符号数 | 9600 / 115200 | 最常用的嵌入式串口速率,115200 是开发板默认值 |
| 数据位(Data Bits) | 每帧有效数据的位数 | 8 | 标准配置 |
| 停止位(Stop Bits) | 帧末尾的标记位数 | 1 | 标准配置 |
| 校验位(Parity) | 错误检测方式 | None | 大多数场景不用校验 |
ESP32-S3 的 UART0 对应板载 GPIO43(TX)和 GPIO44(RX),通过板载 USB-Serial 桥接芯片直接和电脑的 USB 通信。115200-8N1(波特率115200、8数据位、无校验、1停止位)是嵌入式开发中约定俗成的默认配置。
串口接收的两种模式
| 模式 | 原理 | CPU 占用 | 适用场景 |
|---|---|---|---|
| 轮询(Polling) | CPU 不停检查有没有数据到达 | 高,持续占用 | 调试打印、简单场景 |
| 中断驱动(Interrupt Driven) | 数据到达时硬件自动触发中断 | 低,收到数据才唤醒 | 命令接收、协议解析 |
本篇用中断驱动模式——效率更高,也是实际项目中串口通信的标准做法。
💡中断驱动串口的标准范式:UART 硬件收到字节 → 触发中断 → 回调函数逐字节读到缓冲区 → 收到行结束符后用信号量通知解析线程 → 线程匹配命令执行。这套范式同样适用于 AT 指令解析(WiFi/BLE 模块)、Modbus 通信、自定义 Shell 终端等场景。
设备树与 Kconfig 配置
overlay 删除 Shell 占用
Zephyr 默认把 UART0 分配给 Shell 终端,Shell 会独占 RX 中断回调,自定义回调永远收不到数据。需要在 overlay 中删除zephyr,shell-uart属性:
/ { aliases { led-strip = &led_strip; }; chosen { /delete-property/ zephyr,shell-uart; }; };💡为什么必须删除
zephyr,shell-uart:这个属性告诉 Zephyr「把 UART0 作为 Shell 的输入输出通道」。删除后 UART0 归我们的代码管,Shell 改用其他通道(或完全禁用)。不删除的话,你注册的uart_rx_cb永远不会被调用——因为 Shell 的回调先占了位置。
prj.conf 启用串口驱动
CONFIG_SERIAL=y /* 启用串口驱动 */ CONFIG_UART_INTERRUPT_DRIVEN=y /* 启用中断驱动模式 */CONFIG_SERIAL=y拉起 Zephyr 的串口子系统框架CONFIG_UART_INTERRUPT_DRIVEN=y启用中断驱动 API(uart_fifo_read、uart_irq_rx_enable等),不开这个会导致编译报undefined reference to uart_fifo_read错误
头文件与设备节点
#include<string.h>#include<zephyr/kernel.h>#include<zephyr/device.h>#include<zephyr/drivers/led_strip.h>#include<zephyr/drivers/uart.h>#include<zephyr/sys/util.h>#include<zephyr/logging/log.h>LOG_MODULE_REGISTER(main,CONFIG_LOG_DEFAULT_LEVEL);相比上一篇,新增了uart.h(UART 驱动 API)和string.h(strcmp命令匹配用)。
设备树节点获取方式和前几篇一样,新增 UART0 设备指针:
/* ========== 设备树节点 ========== *//* WS2812 LED strip 设备树节点:对应 overlay 中 aliases { led-strip = &led_strip; } */#defineSTRIP_NODEDT_ALIAS(led_strip)/* 级联像素数量:对应 overlay 中 chain-length = <1> */#defineSTRIP_NUM_PIXELSDT_PROP(STRIP_NODE,chain_length)/* LED strip 设备指针,用于 led_strip_update_rgb() 调用 */staticconststructdevice*conststrip=DEVICE_DT_GET(STRIP_NODE);/* * UART0 设备指针 * * 通过 DT_NODELABEL(uart0) 获取,对应 ESP32-S3 的硬件 UART0 * (GPIO43=TX, GPIO44=RX,板载 USB-Serial 桥接)。 * overlay 中删除了 zephyr,shell-uart,防止 Zephyr shell 占用 RX 中断。 */staticconststructdevice*constuart_dev=DEVICE_DT_GET(DT_NODELABEL(uart0));💡Zephyr 的 UART 设备树抽象:
DEVICE_DT_GET(DT_NODELABEL(uart0))通过设备树节点标签获取 UART 设备指针,代码里不需要硬编码 GPIO 引脚号。这是 Zephyr HAL 的设计理念——硬件配置在设备树,代码只操作逻辑设备。如果你换了一块板子(比如 nRF52840),只要 overlay 里uart0的引脚配置正确,应用代码完全不用改。
命令表与缓冲区设计
串口命令解析的核心是一个命令查找表——命令名字符串和对应 RGB 颜色的映射:
/* RGB 颜色初始化辅助宏:将 (r, g, b) 展开为 struct led_rgb 的初始化列表 {r, g, b} */#defineRGB(r,g,b){r,g,b}/* 像素缓冲区:保存待推送到 WS2812 的一帧数据 */staticstructled_rgbpixels[STRIP_NUM_PIXELS];/* 命令表:命令字符串与对应颜色的映射 */staticconststruct{constchar*name;structled_rgbcolor;}cmd_table[]={{"red",RGB(0x20,0x00,0x00)},{"green",RGB(0x00,0x20,0x00)},{"blue",RGB(0x00,0x00,0x20)},{"yellow",RGB(0x20,0x20,0x00)},{"off",RGB(0x00,0x00,0x00)},};| 命令 | R | G | B | 说明 |
|---|---|---|---|---|
red | 0x20 | 0x00 | 0x00 | 红色 |
green | 0x00 | 0x20 | 0x00 | 绿色 |
blue | 0x00 | 0x00 | 0x20 | 蓝色 |
yellow | 0x20 | 0x20 | 0x00 | 黄色(红+绿) |
off | 0x00 | 0x00 | 0x00 | 关闭 |
💡为什么 RGB 值用 0x20 而不是 0xFF:WS2812 的亮度取决于 RGB 值大小,0xFF 是最亮。0x20(十进制32)只是用于室内调试的合适亮度,不会太刺眼。你可以根据需要调大这个值,最大到 0xFF。
ISR 和线程之间通过缓冲区+信号量通信:
/* ========== 命令接收缓冲区 + 信号量 ========== */#defineCMD_BUF_SIZE32/* 最长命令 "yellow"=6 字节,32 绰绰有余 */staticuint8_tcmd_buf[CMD_BUF_SIZE];/* ISR 写入、线程读取的命令缓冲区 */staticvolatileuint8_tcmd_len;/* 当前已接收的字节数(volatile 保证 ISR/线程间可见性) *//* * 命令完成信号量 * * K_SEM_DEFINE(name, initial_count, count_limit) * initial_count = 0:初始时无线索可用,线程一上来就会阻塞 * count_limit = 1:最多累积 1 个信号(二值信号量) * * ISR 收到 \r/\n 后调用 k_sem_give() 将计数 0→1(唤醒线程); * 线程调用 k_sem_take() 将计数 1→0(消费事件)。 */K_SEM_DEFINE(cmd_sem,0,1);💡缓冲区溢出防护:
cmd_len < CMD_BUF_SIZE - 1的边界检查是必须的——如果没有这个检查,超长的串口输入(比如终端误发的二进制数据)会写越界,导致内存破坏和系统崩溃。超出缓冲区容量的字符被静默丢弃,这是嵌入式串口接收的常见防御策略。
UART 中断接收回调
这是整个串口命令解析的核心。UART 硬件收到字节后触发中断,回调函数在中断上下文中逐字节读取:
/* * uart_rx_cb — UART 中断接收回调 * * 【调用时机】 * UART0 硬件 RX FIFO 收到字节后触发中断 → ESP32 UART 驱动的 * uart_esp32_isr() 被调用 → 该 ISR 读取中断状态后调用用户 * 通过 uart_irq_callback_set() 注册的本函数。 * * 【ISR 上下文约束】 * 本函数运行在中断上下文,优先级高于所有线程。只能调用 * ISR-safe 的 API(如 k_sem_give、k_timer_start), * 不能调用可能阻塞的 API(如 k_msleep、k_mutex_lock、LOG_INF)。 * * 【uart_irq_update() 为什么必须调用】 * ESP32 UART 驱动(uart_esp32_irq_update)会清除 RXFIFO_FULL、RXFIFO_TOUT * 等中断状态位。如果不调用,中断状态位不被清除,会导致中断反复触发、系统卡死。 */staticvoiduart_rx_cb(conststructdevice*dev,void*user_data){ARG_UNUSED(user_data);/* * uart_irq_update() 清除中断状态位(必须调用) * uart_irq_rx_ready() 检查 RX FIFO 中是否有数据可读 * * 用 while 循环一次性读完 FIFO 中所有字节, * 避免一次中断只处理一个字节,导致频繁中断。 */while(uart_irq_update(dev)&&uart_irq_rx_ready(dev)){uint8_tc;/* 从硬件 RX FIFO 读取 1 个字节 */if(uart_fifo_read(dev,&c,1)!=1){break;}if(c=='\r'||c=='\n'){/* * 收到回车/换行:命令结束 * * 只有 cmd_len > 0 时才提交(防止 \r\n 双字节回车 * 触发两次:第一个 \r 清零 cmd_len 并提交, * 第二个 \n 时 cmd_len == 0,跳过)。 */if(cmd_len>0){cmd_buf[cmd_len]='\0';cmd_len=0;k_sem_give(&cmd_sem);}}elseif(cmd_len<CMD_BUF_SIZE-1){/* 普通字符:累积到缓冲区,保留 1 字节给末尾 '\0' */cmd_buf[cmd_len++]=c;}/* 超出缓冲区容量的字符被静默丢弃,防止缓冲区溢出 */}}整个回调的逻辑很清晰:
串口收到字节 → 硬件中断 → uart_rx_cb() ├─ 是 \r 或 \n? │ ├─ cmd_len > 0 → 添加 '\0',k_sem_give() 通知线程 │ └─ cmd_len == 0 → 跳过(处理 \r\n 双字节情况) └─ 是普通字符? ├─ cmd_len < 31 → 存入 cmd_buf[cmd_len++] └─ cmd_len >= 31 → 静默丢弃(防溢出)三个关键点:
uart_irq_update()必须调用——ESP32 驱动靠它清除中断状态位,不调会导致中断风暴- while 循环读 FIFO——一次中断读完所有已到达的字节,减少中断次数
\r\n双字节处理——串口助手通常发送\r\n,需要防止同一命令被提交两次
命令解析线程
线程通过信号量阻塞等待,被 ISR 唤醒后遍历命令表匹配并控灯:
/* * cmd_thread — 阻塞等待完整命令,匹配命令表并控灯 * * 线程通过 k_sem_take(K_FOREVER) 挂起,不消耗 CPU。 * ISR 收到完整命令行后释放信号量,线程被调度器唤醒继续执行。 */voidcmd_thread(void*p1,void*p2,void*p3){ARG_UNUSED(p1);ARG_UNUSED(p2);ARG_UNUSED(p3);while(1){/* 阻塞等待:信号量计数为 0 时线程睡眠,ISR give 后自动唤醒 */k_sem_take(&cmd_sem,K_FOREVER);LOG_INF("CMD: %s",(constchar*)cmd_buf);/* 遍历命令表做字符串匹配 */bool matched=false;for(size_ti=0;i<ARRAY_SIZE(cmd_table);i++){if(strcmp((constchar*)cmd_buf,cmd_table[i].name)==0){push_color(cmd_table[i].color);matched=true;break;}}if(!matched){LOG_WRN("Unknown command: %s",(constchar*)cmd_buf);}}}/* * K_THREAD_DEFINE — 静态定义线程(编译期创建,无需手动 k_thread_create) * * 参数说明: * cmd_tid — 线程标识符 * 512 — 栈大小(字节) * cmd_thread — 线程入口函数 * NULL, NULL, NULL — 传给入口函数的三个 void* 参数(未使用) * 7 — 优先级(数值越小优先级越高;7 为较低优先级) * 0 — 线程选项(无特殊选项) * 0 — 启动延迟 ms(0 = 系统启动后立即调度运行) */K_THREAD_DEFINE(cmd_tid,512,cmd_thread,NULL,NULL,NULL,7,0,0);解析线程的模型和上一篇 GPIO 按键中断一模一样——ISR 做最少的事(收字节、给信号量),线程做重活(匹配命令、控灯)。这是 RTOS 中 ISR 设计的基本原则:ISR 要快,复杂的逻辑放到线程里做。
灯效与初始化
push_color将指定颜色推送到 WS2812 所有像素,main负责设备检查、回调注册和中断启用:
/* * push_color — 将指定颜色推送到 WS2812 所有像素 * * 将 color 复制到每个像素位置,然后调用 led_strip_update_rgb() * 将像素数据通过 I2S+DMA 编码为 WS2812 时序协议发送出去。 */staticvoidpush_color(structled_rgbcolor){for(size_ti=0;i<STRIP_NUM_PIXELS;i++){memcpy(&pixels[i],&color,sizeof(structled_rgb));}led_strip_update_rgb(strip,pixels,STRIP_NUM_PIXELS);}intmain(void){/* 检查 WS2812 LED strip 设备是否就绪 */if(!device_is_ready(strip)){LOG_ERR("LED strip not ready");return0;}/* 检查 UART0 设备是否就绪 */if(!device_is_ready(uart_dev)){LOG_ERR("UART not ready");return0;}/* * 注册 UART 中断回调 * * uart_irq_callback_set(dev, cb) 将 cb 注册到 UART 驱动, * 之后每次 UART 中断触发时,驱动的 uart_esp32_isr() 会调用此回调。 * * 注意:此函数只需 2 个参数(dev, cb),不需要传 user_data。 * 回调函数本身的签名是 (dev, user_data),但 user_data 在此版本中未使用。 */intret=uart_irq_callback_set(uart_dev,uart_rx_cb);if(ret!=0){LOG_ERR("UART callback set failed (%d)",ret);return0;}/* * 启用 UART RX 中断 * * 这会启用 UART 硬件的 RXFIFO_FULL 和 RXFIFO_TOUT 中断: * - RXFIFO_FULL:FIFO 中字节数达到阈值时触发 * - RXFIFO_TOUT:FIFO 中有数据但一段时间没有新字节到达时触发 * 两种中断确保及时读取接收到的数据。 */uart_irq_rx_enable(uart_dev);LOG_INF("Send commands: red/green/blue/yellow/off");return0;}💡
main返回后线程还在跑吗?是的。cmd_thread由K_THREAD_DEFINE在编译期静态创建,由 Zephyr 内核调度器管理,和main的生命周期无关。main返回后,内核继续调度所有已创建的线程,cmd_thread照常在信号量上阻塞等待命令。这是 Zephyr(以及大多数 RTOS)的常见模式——main只负责初始化,实际业务逻辑在独立线程中运行。
编译烧录与效果
编译烧录后,打开串口助手(波特率 115200),勾选「发送新行」(自动追加\r\n),发送以下命令即可控制 WS2812:
| 发送命令 | 效果 | LOG 输出 |
|---|---|---|
red | 红色亮起 | CMD: red |
green | 绿色亮起 | CMD: green |
blue | 蓝色亮起 | CMD: blue |
yellow | 黄色亮起 | CMD: yellow |
off | 灯灭 | CMD: off |
hello | 无反应 | Unknown command: hello |
常见问题
Q1:串口助手发送命令后灯没反应?
检查串口助手是否勾选了「发送新行」选项。命令结束标志是\r或\n,没有这个标志 ISR 不会提交命令,cmd_thread永远等不到信号量。
Q2:LOG 没有输出?
删掉zephyr,shell-uart后,LOG 仍通过 UART0 输出,和命令接收共用同一通道。确认prj.conf中CONFIG_LOG=y已开启即可。LOG 输出和命令接收在方向上不冲突(LOG 只发不收,命令只收不发),所以可以共用 UART0。
Q3:编译报undefined reference to 'uart_fifo_read'?
prj.conf缺少CONFIG_UART_INTERRUPT_DRIVEN=y。这个 Kconfig 选项启用中断驱动 UART API,不开启的话uart_fifo_read、uart_irq_rx_enable等函数不会被编译进来。加上后重新编译即可。
Q4:灯亮了但是颜色不对?
检查 overlay 中的color-mapping顺序是否为<LED_COLOR_ID_GREEN LED_COLOR_ID_RED LED_COLOR_ID_BLUE>。WS2812 的 GRB 色序和代码中 RGB 宏的排列不一致,必须在设备树中用color-mapping转换。
总结
本篇用 UART 中断接收 + 信号量通知 + 命令解析线程实现了串口命令控灯。同样这套"中断收字节→缓冲组帧→信号量通知线程→匹配执行"的范式也能直接套用到 AT 指令解析(WiFi/BLE 模块)、Modbus 通信、自定义 Shell 终端等场景。
希望我的笔记能对你有一点点点的帮助!欢迎关注一起学习👇
