【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_readuart_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.hstrcmp 命令匹配用)。

设备树节点获取方式和前几篇一样,新增 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 → 静默丢弃(防溢出)

三个关键点

  1. uart_irq_update() 必须调用——ESP32 驱动靠它清除中断状态位,不调会导致中断风暴
  2. while 循环读 FIFO——一次中断读完所有已到达的字节,减少中断次数
  3. \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_threadK_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.confCONFIG_LOG=y 已开启即可。LOG 输出和命令接收在方向上不冲突(LOG 只发不收,命令只收不发),所以可以共用 UART0。

Q3:编译报 undefined reference to 'uart_fifo_read'

prj.conf 缺少 CONFIG_UART_INTERRUPT_DRIVEN=y。这个 Kconfig 选项启用中断驱动 UART API,不开启的话 uart_fifo_readuart_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 终端等场景。

希望我的笔记能对你有一点点点的帮助!欢迎关注一起学习👇

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐