【Zephyr|ESP32-S3】基础学习:多线程与同步
【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;
}
编译烧录后,你就能看到效果:
- 上电后 WS2812 显示第一种颜色(红色)的常亮模式
- 按一下 BOOT 键,LOG 打印
Mode -> breathe,LED 进入呼吸效果——亮度从全暗平滑渐亮再渐暗 - 再按一下,LOG 打印
Mode -> steady,回到常亮 - 反复切换,呼吸每次都从全暗重新开始

常见问题
Q1:线程栈溢出怎么办?
512 字节对本工程够用(按键线程和灯效线程的局部变量都很少)。如果后续加了复杂逻辑(如 JSON 解析、字符串拼接),栈可能不够。在 prj.conf 加 CONFIG_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 刷新等场景,本质都是"一个线程等事件,多个线程共享数据"。
希望我的笔记能对你有一点点点的帮助!欢迎关注一起学习👇
更多推荐


所有评论(0)