【Zephyr|ESP32-S3】基础学习:多线程与同步

哈喽,我是余火,一个普通的牛马打工人,目前正在学如何使用Zephyr RTOS。

上篇用 GPIO 中断来控制 WS2812 换颜色。为了系统稳定性,ISR 里只做了最轻量的原子操作(atomic_set 置标志),真正的颜色切换放到主线程,此时主线程用 while(1) 不停检查标志有没有被置位,有就切颜色,没有就 sleep 一会儿。

这种"ISR 置标志、主线程轮询消费"的模式能跑,但有个问题:不管你按不按键,主线程都得周期醒来检查,没按键就是在空转浪费 CPU

这次引入多线程和信号量来优化——ISR 发信号,线程收到才醒来,没信号就安心睡觉,不再空转。

改了哪些东西

在上一篇 GPIO 中断工程上直接改,这次无需改动配置文件,Zephyr 作为 RTOS,默认就启动了线程调度相关的内核功能,不需要额外开 CONFIG。

文件 改了什么
src/main.c 新增两个线程(button_thread / effect_thread)+ 信号量 + 互斥锁,删除 atomic 轮询,main 简化为仅初始化
prj.conf 无改动,线程/信号量/互斥锁由内核默认提供

为什么要用多线程

在上篇的方案中,主线程的代码长这样:

/* 上篇方案:轮询模式 */
while (1) {
    if (atomic_test_and_clear(&flag)) {
        /* 有按键,切颜色 */
    }
    k_msleep(10);  /* 没按键也要醒来检查 */
}

这段代码看起来简单,但存在一个根本性的效率问题:线程每 10ms 就要被调度器唤醒一次,即使按键一个没按,CPU 也要执行一次 atomic_test_and_clear 的判断。虽然单次开销很小,但在电池供电的嵌入式场景中,这种"空转"会显著增加功耗。

💡 事件驱动 vs 轮询的 CPU 占用对比

模式 无事件时 CPU 行为 响应延迟 典型 CPU 占用
轮询(polling) 周期性醒来检查,空转 ≤ sleep 间隔(如 10ms) 持续消耗(即使无事件)
事件驱动(interrupt + semaphore) 线程挂起,零 CPU 消耗 ISR 触发后立即唤醒 仅事件发生时消耗

结论很明确:在"事件稀疏、响应要求高"的场景下,事件驱动完胜轮询。而嵌入式系统中大多数外设交互(按键、传感器中断、通信接收)都符合这个特征。

更进一步,用多线程还能做到关注点分离:按键处理和灯效渲染是两个独立的逻辑,放在同一个 while(1) 里会互相耦合,用线程拆开后各自独立循环,代码清晰度大幅提升。

Zephyr 同步原语对比

Zephyr 内核提供了多种线程间同步和通信的原语。这次我们用信号量和互斥锁,先来看看 Zephyr 全家福:

原语类型 用途 ISR-safe(ISR 中可用) 阻塞行为 典型场景
k_sem(信号量) 计数型通知/资源计数 ✅ give 可在 ISR 调用 take 可阻塞等待 ISR → 线程通知、生产者-消费者
k_mutex(互斥锁) 独占访问共享资源 ❌ 不能在 ISR 中使用 lock 可阻塞等待 保护全局变量/外设寄存器
k_event(事件对象) 多条件位掩码通知 ✅ post 可在 ISR 调用 wait 可阻塞等待 多事件组合等待(如按键+超时)
k_work_queue(工作队列) 延迟执行耗时任务 ✅ submit 可在 ISR 调用 工作线程处理 ISR 中无法完成的耗时操作
k_msgq(消息队列) 结构化数据传递 ✅ put 可在 ISR 调用 get 可阻塞等待 传感器数据 → 处理线程

这次项目只需要前两个:信号量负责 ISR → 线程的通知,互斥锁保护两个线程共享的 led_mode 变量

定义同步原语

信号量和互斥锁的定义使用编译期宏,放在全局作用域即可:

/* ========== 同步原语定义 ========== */

/*
 * 信号量:ISR 释放 → 按键线程等待
 *
 * K_SEM_DEFINE(name, initial_count, count_limit)
 *   initial_count = 0:初始时无信号可用,线程一上来就会阻塞
 *   count_limit   = 1:最多累积 1 个信号(二值信号量)
 *
 * ISR 调用 k_sem_give() 将计数从 0→1(唤醒线程);
 * 线程调用 k_sem_take() 将计数从 1→0(消费事件)。
 */
K_SEM_DEFINE(button_sem, 0, 1);

/*
 * 互斥锁:保护 led_mode 共享变量
 *
 * button_thread 写 led_mode,effect_thread 读 led_mode,
 * 互斥锁保证同一时刻只有一个线程访问该变量,避免读写竞争。
 */
K_MUTEX_DEFINE(mode_mutex);

ISR 改造:信号量替代 atomic

上篇 ISR 里用了 atomic_set,这次换成信号量的 k_sem_give

/* ========== GPIO 中断回调 ========== */

/*
 * button_pressed_cb — GPIO 中断回调函数
 *
 * 运行在 ISR 上下文,优先级高于所有线程。
 * ISR 里不能做任何可能引起阻塞或调度的操作(如 LOG、k_msleep、k_mutex_lock)。
 * k_sem_give() 是 ISR-safe API:不拿锁、不阻塞,仅将信号量计数 +1 并唤醒等待线程。
 */
static void button_pressed_cb(const struct device *dev,
                               struct gpio_callback *cb,
                               uint32_t pins)
{
    ARG_UNUSED(dev);
    ARG_UNUSED(cb);
    ARG_UNUSED(pins);

    /* 本篇:信号量通知,替代上篇的 atomic_set */
    k_sem_give(&button_sem);
}

关键区别k_sem_give() 是 Zephyr 官方标记为 ISR-safe 的 API(函数名带 _from_isr 后缀或不带后缀但文档标注 ISR-safe)。ISR 中能用的 API 非常有限,k_sem_give 恰好是其中之一,这也是选择信号量而非其他原语的核心原因。

按键线程:等待信号量并切换模式

按键线程的职责很清晰——等待信号量,收到后切换灯效模式:

/* ========== 按键线程 ========== */

/*
 * button_thread — 按键处理线程
 *
 * 使用 k_sem_take(K_FOREVER) 阻塞等待信号量:
 *   无按键事件时线程处于挂起状态,不消耗 CPU;
 *   ISR 调用 k_sem_give() 后线程被调度器唤醒继续执行。
 */
void button_thread(void *p1, void *p2, void *p3)
{
    ARG_UNUSED(p1);
    ARG_UNUSED(p2);
    ARG_UNUSED(p3);

    while (1) {
        /* 阻塞等待:信号量计数为 0 时线程睡眠,
         * ISR 调用 k_sem_give() 后自动唤醒 */
        k_sem_take(&button_sem, K_FOREVER);

        /* 加锁保护 led_mode 的读写,防止与 effect_thread 并发访问 */
        k_mutex_lock(&mode_mutex, K_FOREVER);
        led_mode = (led_mode + 1) % 2;  /* 常亮 ↔ 呼吸循环切换 */
        int current_mode = led_mode;     /* 拷贝到局部变量 */
        k_mutex_unlock(&mode_mutex);    /* 立即释放锁 */

        LOG_INF("Mode -> %s", mode_names[current_mode]);

        /* 切换到呼吸模式时重置亮度参数,从全暗开始渐亮 */
        if (current_mode == MODE_BREATHE) {
            brightness = 0;
            direction = 1;
        }
    }
}

💡 mutex 最佳实践:加锁 → 拷贝共享变量到局部变量 → 立即解锁,然后在锁外使用局部变量完成后续操作。锁内绝对不做耗时操作(如 WS2812 驱动、LOG 打印、延时等),否则其他线程会被长时间阻塞,甚至导致优先级反转。

K_THREAD_DEFINE 静态创建线程

Zephyr 提供了 K_THREAD_DEFINE 宏,在编译期静态创建线程,无需在 main 里手动调用 k_thread_create()。它的 9 个参数含义如下:

参数位置 参数名 本例值 说明
1 name button_tid 线程标识符,后续可用 k_thread_name_set() 等函数引用
2 stack_size 512 栈大小(字节),局部变量 + 函数调用链都在栈上分配
3 entry button_thread 线程入口函数,签名为 void func(void *p1, void *p2, void *p3)
4 p1 NULL 传给入口函数的第一个参数
5 p2 NULL 传给入口函数的第二个参数
6 p3 NULL 传给入口函数的第三个参数
7 prio 7 优先级,数值越小优先级越高;Zephyr 协作式调度优先级范围 0~15
8 options 0 线程选项位掩码(如 K_ESSENTIAL 表示不可终止),0 = 无特殊选项
9 delay 0 启动延迟(毫秒),0 = 系统启动后立即参与调度

两个线程的定义如下:

/* ========== 线程定义 ========== */

/* 按键线程:优先级 7(较低),按键响应不需要实时性 */
K_THREAD_DEFINE(button_tid, 512, button_thread,
                 NULL, NULL, NULL, 7, 0, 0);

/* 灯效线程:优先级 5(较高),保证灯效时序不被按键处理抢占 */
K_THREAD_DEFINE(effect_tid, 512, effect_thread,
                 NULL, NULL, NULL, 5, 0, 0);

为什么灯效线程优先级更高? 灯效渲染对时序有一定要求(呼吸模式每 20ms 刷新一次),如果被按键处理线程抢占,可能导致呼吸效果出现卡顿。数字越小优先级越高,所以 effect_thread 的 5 优先于 button_thread 的 7

💡 Zephyr 默认使用优先级抢占式调度:不同优先级的线程会抢占(高优先级就绪时立即抢占低优先级),而同优先级线程之间采用协作式调度(不会互相抢占,需主动调用 k_sleep() / k_sem_take() 等阻塞 API 才让出 CPU)。这也是为什么我们需要合理分配优先级。

灯效线程:常亮与呼吸渲染

灯效线程是这次的重头戏,按键不再是"切颜色",而是"切灯效模式"——常亮和呼吸两种模式循环切换:

/* ========== 灯效线程 ========== */

/*
 * effect_thread — 灯效渲染线程
 *
 * 以固定频率循环,根据当前灯效模式执行不同的渲染逻辑:
 *   - 常亮模式(MODE_STEADY):每 50ms 刷新一次,保持像素满亮度点亮
 *   - 呼吸模式(MODE_BREATHE):每 20ms 调整亮度并刷新,产生平滑渐变效果
 */
void effect_thread(void *p1, void *p2, void *p3)
{
    ARG_UNUSED(p1);
    ARG_UNUSED(p2);
    ARG_UNUSED(p3);

    while (1) {
        /* 加锁读取当前模式,拷贝到局部变量后立即释放锁
         * 锁内不做耗时操作,持有时间越短越好 */
        k_mutex_lock(&mode_mutex, K_FOREVER);
        int current_mode = led_mode;
        k_mutex_unlock(&mode_mutex);

        if (current_mode == MODE_STEADY) {
            /* 常亮模式:推送当前颜色(满亮度) */
            push_color(color_idx);
            k_msleep(50);
        } else {
            /*
             * 呼吸模式:亮度在 0~255 之间来回渐变
             * 每次步进 4 个亮度单位,到达边界时反转方向。
             * 使用 int 中间变量避免 uint8_t 下溢(0 - 4 = 252)
             */
            push_color_bright(color_idx, brightness);

            int tmp = (int)brightness + 4 * direction;
            if (tmp >= 255) {
                brightness = 255;
                direction = -1;
            } else if (tmp <= 0) {
                brightness = 0;
                direction = 1;
            } else {
                brightness = (uint8_t)tmp;
            }

            k_msleep(20);  /* 20ms 间隔,50Hz 刷新,渐变更平滑 */
        }
    }
}

亮度缩放函数 push_color_bright 是呼吸效果的核心——对每个 RGB 通道值按亮度比例缩放:

/* ========== 亮度缩放推送 ========== */

/*
 * push_color_bright — 按指定亮度缩放后推送到 WS2812
 *
 * bri = 0   → 完全熄灭(所有通道 × 0/255)
 * bri = 255 → 满亮度(所有通道 × 255/255 = 原值)
 *
 * 关键:使用 uint16_t 中间结果避免 8 位溢出
 *   错误写法:(uint8_t)(colors[idx].r * bri / 255) ← r * bri 可能超过 255
 *   正确写法:(uint8_t)((uint16_t)colors[idx].r * bri / 255)
 */
static void push_color_bright(size_t idx, uint8_t bri)
{
    memset(pixels, 0, sizeof(pixels));
    for (size_t i = 0; i < STRIP_NUM_PIXELS; i++) {
        pixels[i].r = (uint8_t)((uint16_t)colors[idx].r * bri / 255);
        pixels[i].g = (uint8_t)((uint16_t)colors[idx].g * bri / 255);
        pixels[i].b = (uint8_t)((uint16_t)colors[idx].b * bri / 255);
    }
    led_strip_update_rgb(strip, pixels, STRIP_NUM_PIXELS);
}

💡 为什么用 uint16_t 中间变量? 假设颜色通道值为 0x20(32),亮度为 255,乘积 = 32 × 255 = 8160,远超 uint8_t 的 255 上限。如果直接用 8 位运算,结果会被截断,呼吸效果的亮度映射就完全错了。先乘后除的方式保证了最大精度。

main 函数简化

有了两个独立线程,main 只做初始化,设备检查和 ISR 注册跟第 5 篇完全一样,只是去掉了 while(1) 轮询循环

/* ========== 主函数 ========== */

/*
 * main — 只负责硬件初始化和 ISR 注册
 *
 * 初始化完成后返回,button_thread 和 effect_thread 由 K_THREAD_DEFINE
 * 静态创建,独立于 main 运行,不会因 main 返回而退出。
 */
int main(void)
{
    /* 检查 WS2812 设备是否就绪 */
    if (!device_is_ready(strip)) {
        LOG_ERR("LED strip device not ready");
        return 0;
    }
    LOG_INF("WS2812 strip: %d pixel(s)", STRIP_NUM_PIXELS);

    /* 检查按键 GPIO 控制器是否就绪 */
    if (!device_is_ready(button.port)) {
        LOG_ERR("Button GPIO device not ready");
        return 0;
    }

    /* 配置 BOOT 键引脚为输入(设备树已声明 GPIO_PULL_UP) */
    int ret = gpio_pin_configure_dt(&button, GPIO_INPUT);
    if (ret != 0) {
        LOG_ERR("Failed to configure button GPIO (%d)", ret);
        return 0;
    }

    /* 配置中断:下降沿触发(GPIO_ACTIVE_LOW + EDGE_TO_ACTIVE = 按下瞬间) */
    ret = gpio_pin_interrupt_configure_dt(&button, GPIO_INT_EDGE_TO_ACTIVE);
    if (ret != 0) {
        LOG_ERR("Failed to configure button interrupt (%d)", ret);
        return 0;
    }

    /* 注册 GPIO 中断回调 */
    gpio_init_callback(&button_cb_data, button_pressed_cb, BIT(button.pin));
    gpio_add_callback(button.port, &button_cb_data);

    /* 初始点亮第一种颜色 */
    push_color(color_idx);
    LOG_INF("Press BOOT button to toggle mode (steady/breathe)");

    /* main 返回后,两个 K_THREAD_DEFINE 线程继续独立运行 */
    return 0;
}

编译烧录后,你就能看到效果:

  1. 上电后 WS2812 显示第一种颜色(红色)的常亮模式
  2. 按一下 BOOT 键,LOG 打印 Mode -> breathe,LED 进入呼吸效果——亮度从全暗平滑渐亮再渐暗
  3. 再按一下,LOG 打印 Mode -> steady,回到常亮
  4. 反复切换,呼吸每次都从全暗重新开始
    LOG打印截图

常见问题

Q1:线程栈溢出怎么办?

512 字节对本工程够用(按键线程和灯效线程的局部变量都很少)。如果后续加了复杂逻辑(如 JSON 解析、字符串拼接),栈可能不够。在 prj.confCONFIG_STACK_USAGE=y,启动时 LOG 会打印每个线程的栈使用峰值和剩余空间,一目了然。

Q2:按一次键切了多个模式**?**

这是按键抖动的典型表现——机械按键在按下/释放瞬间会产生数十毫秒的抖动,GPIO 会在极短时间内触发多次边沿中断,每次中断都 k_sem_give 一次,按键线程就会连续切换多次模式。解决方案:加延时消抖,或用定时器去抖(下篇会讲定时器,届时一并解决这个问题)。

Q3:为什么 main 返回了线程还在跑?

Zephyr 的线程模型里,main() 只是系统启动后创建的第一个线程。K_THREAD_DEFINE 创建的线程独立于 main,main 返回后该线程退出,但其他线程照样运行。不要以为 main return 0 意味着程序结束——在 RTOS 里,只有所有线程都退出才会"结束",而 while(1) 线程永远不会退出。

Q4:互斥锁和信号量到底选哪个?

简单判断:保护共享资源用 mutex,通知/事件用 semaphore。如果你只需要"一个线程告诉另一个线程某件事发生了",用信号量。如果两个线程都要读写同一个变量,用互斥锁。更复杂的通知场景(多个事件条件组合)可以用 k_event

总结

本篇用信号量 + 互斥锁把"ISR 置标志、主线程轮询"升级为多线程事件驱动。同样这套三件套——K_THREAD_DEFINE + k_sem + k_mutex——也能直接套用到传感器定时采集、UART 通信收发、OLED 刷新等场景,本质都是"一个线程等事件,多个线程共享数据"。


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

Logo

免费领 150 小时云算力,进群参与显卡、AI PC 幸运抽奖

更多推荐