【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; } */
#define STRIP_NODE DT_ALIAS(led_strip)
/* 级联像素数量:对应 overlay 中 chain-length = <1> */
#define STRIP_NUM_PIXELS DT_PROP(STRIP_NODE, chain_length)
/* LED strip 设备指针,用于 led_strip_update_rgb() 调用 */
static const struct device *const strip = 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 中断。
*/
static const struct device *const uart_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} */
#define RGB(r, g, b) {r, g, b}
/* 像素缓冲区:保存待推送到 WS2812 的一帧数据 */
static struct led_rgb pixels[STRIP_NUM_PIXELS];
/* 命令表:命令字符串与对应颜色的映射 */
static const struct {
const char *name;
struct led_rgb color;
} 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 和线程之间通过缓冲区+信号量通信:
/* ========== 命令接收缓冲区 + 信号量 ========== */
#define CMD_BUF_SIZE 32 /* 最长命令 "yellow"=6 字节,32 绰绰有余 */
static uint8_t cmd_buf[CMD_BUF_SIZE]; /* ISR 写入、线程读取的命令缓冲区 */
static volatile uint8_t cmd_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
* 等中断状态位。如果不调用,中断状态位不被清除,会导致中断反复触发、系统卡死。
*/
static void uart_rx_cb(const struct device *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_t c;
/* 从硬件 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);
}
} else if (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 收到完整命令行后释放信号量,线程被调度器唤醒继续执行。
*/
void cmd_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", (const char *)cmd_buf);
/* 遍历命令表做字符串匹配 */
bool matched = false;
for (size_t i = 0; i < ARRAY_SIZE(cmd_table); i++) {
if (strcmp((const char *)cmd_buf, cmd_table[i].name) == 0) {
push_color(cmd_table[i].color);
matched = true;
break;
}
}
if (!matched) {
LOG_WRN("Unknown command: %s", (const char *)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 时序协议发送出去。
*/
static void push_color(struct led_rgb color)
{
for (size_t i = 0; i < STRIP_NUM_PIXELS; i++) {
memcpy(&pixels[i], &color, sizeof(struct led_rgb));
}
led_strip_update_rgb(strip, pixels, STRIP_NUM_PIXELS);
}
int main(void)
{
/* 检查 WS2812 LED strip 设备是否就绪 */
if (!device_is_ready(strip)) {
LOG_ERR("LED strip not ready");
return 0;
}
/* 检查 UART0 设备是否就绪 */
if (!device_is_ready(uart_dev)) {
LOG_ERR("UART not ready");
return 0;
}
/*
* 注册 UART 中断回调
*
* uart_irq_callback_set(dev, cb) 将 cb 注册到 UART 驱动,
* 之后每次 UART 中断触发时,驱动的 uart_esp32_isr() 会调用此回调。
*
* 注意:此函数只需 2 个参数(dev, cb),不需要传 user_data。
* 回调函数本身的签名是 (dev, user_data),但 user_data 在此版本中未使用。
*/
int ret = uart_irq_callback_set(uart_dev, uart_rx_cb);
if (ret != 0) {
LOG_ERR("UART callback set failed (%d)", ret);
return 0;
}
/*
* 启用 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");
return 0;
}
💡
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 终端等场景。
希望我的笔记能对你有一点点点的帮助!欢迎关注一起学习👇
更多推荐



所有评论(0)