news 2026/9/11 20:17:24

用C++23重写RTOS:协程与模块化设计实践指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用C++23重写RTOS:协程与模块化设计实践指南

最近半年我一直在折腾一个叫 ZerOS 的小项目。说白了,就是想把传统 RTOS 里那套任务、信号量、队列、中断管理的经典逻辑,用 C++23 重新表达一遍。刚开始只是觉得 C 语言写内核实在太啰嗦了,后来越写越发现,语言层面的变化对整个系统的设计思路影响很大。ZerOS 不打算成为又一个生产级 RTOS,它更像一个原型实验:当协程、模块、concept 这些新武器撞上经典 RTOS 概念,最终能摩擦出什么设计?下面就把我整个设计取舍、关键代码、移植到 GD32F103 的实操记录,以及调试中踩过的那些坑摊开讲一讲。内容对已经写过一段时间单片机、正在接触 RTOS 或者准备嵌入式岗位面试的人,应该会有点用。

1. 为什么要在 C++23 里重写一个 RTOS

1.1 传统 RTOS 的痛点

很多人写单片机项目,第一个接触的 RTOS 可能都是 FreeRTOS 或者 RT-Thread,C 语言写的,体积小,资料多。我自己也用了很长时间,不能说不够好,但用着用着就会碰到一些语言表达能力带来的限制。

第一个痛点是任务切换的“割裂感”。在 C 语言 RTOS 里,一个任务因为等待信号量而挂起时,你需要在当前函数里调用类似 osSemaphoreAcquire 的函数,然后函数就“停住”了。停在哪个状态,全靠内核保存的寄存器现场来表达,你自己写的代码反而看不出“我在等什么”。调试的时候,经常要打开寄存器窗口,心里默念“程序计数器在这个位置,栈指针在那个位置”。

第二个痛点是资源管理的原始性。C 语言里的互斥量、队列往往需要手动释放,忘记释放就死锁。还有内存管理,很多 RTOS 内核里就是一个简单的栈池,申请和释放全靠程序员自觉,一旦越界就随机崩溃。我遇到过两次因为任务栈溢出导致的内存踩踏,查了两天才找到根因,那种经历真的不想再来一次。

第三个痛点是配置的“魔法宏”。各种 configXXX 的宏散布在头文件里,改一个数值就得重新编译全工程。虽然 C 语言也能做编译期配置,但是表达力有限,很难做到像 C++ 模板那样按需实例化。更多时候,你只是在“填表”,而不是在“描述系统”。

还有一点,C 语言的模块化能力太弱。一个内核头文件会把数据类型、句柄、API 全部堆在一起,用户代码和内核实现耦合很深。如果你想换一个调度策略,基本要重写底层。这样的代价导致很多项目选择“能跑就行”,一旦遇到新的需求就只能在旧代码上打补丁。

1.2 C++23 带来了哪些新工具

C++23 不是 C++20 的小版本,它对嵌入式开发者来说,其实带来了几个非常关键的能力。我说几个自己最看重的。

第一个是模块(Modules)。import 一个模块比 #include 一堆头文件干净太多,编译速度也快很多。RTOS 的组件很多,任务、信号量、队列、事件组,如果用传统头文件,相互依赖非常容易炸。模块化之后,接口和实现分离得更好,甚至很多内部结构可以不暴露给使用者。尤其是 GD32F103 这种小工程,模块化能显著减少无意义的重新编译。

第二个是协程(Coroutines)。C++20 已经有协程了,C++23 做了一些修正和标准库支持。RTOS 的调度核心和协程简直是天生一对:一个任务就是一段可以暂停和恢复的代码。传统 RTOS 的任务切换需要汇编保存寄存器和栈指针,而 C++ 协程把这份“暂停现场”变成了语言层面的机制。虽然底层真正完成上下文切换还是需要靠汇编,但上层的阻塞、唤醒逻辑可以写得非常自然。

第三个是概念(Concepts)。调度器需要用户提供一些回调或者特性时,以前要靠函数指针、宏、虚函数,现在可以用 concept 静态约束。比如我定义一个“可等待对象”的概念,要求这个类型必须提供 await_ready、await_suspend、await_resume 三个方法。这样编译器就能在模板实例化时直接给出清晰的错误,而不是等程序跑挂了再去查。

第四个是 constexpr 和编译期计算。C++23 允许在 constexpr 函数里做更多事情,配合 consteval,很多初始化工作可以在编译期完成。比如把静态任务表的合法性校验放在编译期做,运行时零开销。我以前在 C 项目里要写一堆启动时的 assert 和 if 判断,现在直接用 static_assert 解决。

当然还有 std::span、std::atomic 的改进、std::jthread 和 stop_token 这些标准库设施。不过要注意,很多标准库设施在裸机环境下没有完整实现,或者带来巨大 ROM/RAM 开销,ZerOS 的内核部分并没有全盘依赖标准库,而是选择性地借鉴这些语法表达。比如 std::jthread 的停止令牌机制我拿到任务取消里做过实验,但最终因为 C++ ABI 体积问题改成了轻量自研版本。语言特性归语言特性,工程实现还是要看最终二进制。

1.3 为什么不直接用 Zephyr 或 FreeRTOS

既然已经有那么多成熟 RTOS,为什么还要自己写一个?这个问题我在项目初期也问过自己。答案其实很简单:我想验证的不只是“能不能做出来”,而是“换一种语言表达之后,设计思维本身会不会改变”。

FreeRTOS 的代码非常成熟,但它的 API 风格带着浓厚的 C 语言痕迹。比如任务句柄就是一个指针,信号量是一个 void*,你很难从类型系统里得到提示。Zephyr 虽然现代化很多,但它的体量太大,devicetree、驱动模型、构建系统一套下来,对一个 32K RAM 的 MCU 来说过于臃肿。我想要的是一种介于两者之间的东西:语言表达接近 Zephyr 的现代感,资源占用接近 FreeRTOS 的轻量级。

当然,自己写内核的另一个好处是完全可控。我想加一个协程接口就加一个,想改调度策略就改调度策略,不用跟上游社区争论。维护成本是我自己的,但对于学习来说,没有比亲手实现一遍更好的方式了。尤其是调度器、信号量这些基础组件,你真动手写过一遍,很多面试题都不需要背了。而且自己写可以让代码风格高度统一,改 bug 时不需要在第三方代码里翻来翻去。

2. ZerOS 的整体架构与设计思路

2.1 基于模块化的骨架

ZerOS 的源码结构我一开始就按模块来组织,避免传统 C 工程里“一个头文件包含半个项目”的毛病。模块划分可以这样看:

zos.core 内核基元:任务控制块、调度器、时钟、延时 zos.sync 同步原语:信号量、互斥量、事件标志 zos.io 数据交换:队列、环形缓冲、流式日志 zos.arch CPU 架构:Cortex-M3、RISC-V 的切换层 zos.platform 开发板适配:GD32F103、STM32F103 等

在 C++23 模块化代码里,每个模块的接口可以非常清晰地暴露出来。比如内核基础模块导出 spawn_task、yield、current_task 之类的东西;同步模块导出 Semaphore、Mutex、EventGroup。使用方只需要 import zos.core;,不需要关心内部链表、临界区这些实现细节。

一个模块的导出接口可以写成类似这样:

export module zos.sync; export template <std::size_t MaxCount> class Semaphore; export template <typename T, std::size_t Depth> class Queue; export class EventFlag; export class Mutex;

这么设计最大的好处是接口和实现分离。我可以在内核实现里随便改调度算法,只要对外接口不变,用户的业务代码完全不用动。对于 GD32F103 这种资源有限的 MCU,模块化还可以减少编译时间,改一个模块只重编一个模块,这在老式 IDE 上体验极佳。

我实际用 GCC 13 的 modules 支持编译过,C++23 模块在 OpenOCD + CMake 流程里也跑得通。要说坑的话,目前 GDB 对模块内断点的支持还不是特别好,经常需要打断点打到 source 文件的实际行号上,模块声明的行号会对不上。开发阶段我还是习惯用传统头文件做调试的快速验证,发布模块接口再切回来。

2.2 任务态与协程态:调度器怎么表达

传统 RTOS 的任务本质上是“一个永不返回的函数”。调度器通过切换栈来切换任务。ZerOS 想做的事,是把“任务”和“协程”这两个概念融合起来。

任务可以分为两种形态。第一种是传统形态,就是一个无限循环的函数,内核仍然用栈切换来切换它,适合从 C 风格代码迁移过来的老逻辑。第二种是协程形态,任务本身是一个 C++ 协程,可以因为等待某个条件而 co_await 自己“挂起”。挂起之后,协程的栈帧自动保存,不需要内核去操作栈指针,因为协程的挂起状态由编译器生成的状态机管理。

调度器本身则统一管理两类任务:把传统任务包装成“不可暂停的协程”,或者更准确地说,把传统任务看作永远处于就绪态的协程。这样调度器只需要维护一个协程队列。实际实现时,我保留了 Cortex-M 的 PendSV 机制来处理真正的栈切换,但上层调度逻辑已经变成 C++ 的协程队列操作了。

有人可能会问:协程不是已经把任务暂停了吗,为什么还需要 PendSV?这里的关键区别是:协程的 co_await 是协作式挂起,只有当前代码主动挂起才会发生。但 RTOS 需要抢占式调度,也就是定时器中断一到,不管任务正在干什么,都要强占 CPU 给更高优先级的任务。这个“强占”必须由中断触发上下文切换,也就是 PendSV 的活儿。

所以在 ZerOS 里,协程负责“协作”,PendSV 负责“抢占”。两者配合起来,既能享受高级语言的表达力,又不丢失实时性。我个人的体会是,这个设计比纯汇编切换要容易调试得多,因为挂起点变成了代码层面的 co_await 表达式,而不是魔幻的栈指针。

2.3 内存与对象生命周期

嵌入式系统最怕动态内存碎片。ZerOS 没有使用 new 堆分配器作为唯一的内存来源,而是提供了一套静态对象池的方案。

任务栈、信号量、消息队列这些内核对象都可以声明成静态对象。用 C++ 表达就是模板参数指定容量和数量:

using TaskStack_1 = zos::Stack<1024>; static zos::TaskController main_task("main", zos::priority::High, TaskStack_1);

对于动态创建的场景,我也提供了 ObjectPool 模板,它内部是编译期定长的槽位数组,创建时从池里拿一个槽,销毁时返回池。

template <typename T, std::size_t N> class ObjectPool { public: template <typename... Args> T* acquire(Args&&... args) { std::lock_guard lock(m_mutex); for (std::size_t i = 0; i < N; ++i) { if (!m_used[i]) { m_used[i] = true; return new (&m_storage[i]) T(std::forward<Args>(args)...); } } return nullptr; } void release(T* obj) { if (obj == nullptr) return; obj->~T(); std::size_t index = get_index(obj); if (index != N) m_used[index] = false; } private: std::aligned_storage_t<sizeof(T), alignof(T)> m_storage[N]; bool m_used[N]; zos::Mutex m_mutex; };

C++23 这里的好处是生命周期更规范:通过 placement new 构造,通过析构函数释放。相比 C 语言的 memset 清零,至少不会在对象析构时漏掉重要的清理逻辑。比如某个消息类型内部持有另一个互斥量,析构时必须正确释放,C 语言里你很容易因为只是 m->data = 0 而跳过这个释放。

对于栈的分配,ZerOS 在编译期校验所有静态任务栈的总大小,超出 MCU RAM 时报错。这个校验我放在了 static_assert 里,配合 constexpr 任务表,可以在编译期直接拦截常见的栈超配问题。

constexpr auto total_stack_size() { std::size_t sum = 0; for (auto& info : task_table) { if constexpr (info.stack_size != 0) sum += info.stack_size; } return sum; } static_assert(total_stack_size() <= 32 * 1024, "task stacks exceed available RAM");

这样配置一个任务栈,如果分配多了,直接编译失败,不会到运行期才突然崩溃。我在实际项目里就靠这条 static_assert 拦住过一次 RAM 超限,那会儿如果没拦住,下载到板子上大概率是上电后不定时 HardFault。

3. 核心机制的 C++23 实现细节

3.1 用协程表达任务与阻塞

在 ZerOS 里,一个典型任务的写法大概是:

zos::Task<void> my_task() { while (true) { auto sensor = co_await sensor_read(); co_await zos::sleep_for(100ms); zos::log("sensor: {}", sensor); } }

这段代码在传统 RTOS 里很难写。sensor_read() 可能是一个异步操作,在 ZerOS 里它返回一个可等待对象,当数据准备好时恢复协程。zos::sleep_for(100ms) 就是经典的任务延时,但这里直接 co_await,不需要回调,也不需要手动保存中间变量。

协程的底层实现,我提取了 C++ 协程的完成接口。每个任务协程对应一个 Promise 类型,Promise 里保存了任务控制块的信息,比如优先级、状态、剩余睡眠时间。调度器通过 Promise 拿到任务句柄,在协程挂起时把任务从就绪队列摘下,在条件满足时再塞回去。这个设计的核心是把协程帧作为任务现场的一部分,而不是另起炉灶。

Promise 的大致轮廓如下:

class TaskPromise { public: zos::Task<void> get_return_object() noexcept; void unhandled_exception() noexcept; void return_void() noexcept; std::suspend_always initial_suspend() noexcept { return {}; } std::suspend_always final_suspend() noexcept { return {}; } // 关键:覆盖默认 new,防止协程帧跑到堆上 static void* operator new(std::size_t size) { return TaskPool::allocate(size); } static void operator delete(void* ptr, std::size_t size) noexcept { TaskPool::free(ptr, size); } // 任务控制信息 zos::TaskControlBlock tcb; };

这里有两个细节很关键。第一个是 initial_suspend 返回 suspend_always,意思是协程创建后不立即执行,而是先进入就绪队列,等待调度器选择。如果不这样做,协程函数体会在 get_return_object 之前就跑掉一段,任务控制块还没接好,会出大问题。第二个是 operator new 的覆盖,裸机环境下一旦协程帧跑到堆上,动态内存碎片会很快毁掉整个系统。我实战中第一次跑就直接卡死,排查半天才意识到协程帧默认在堆上分配。

3.2 信号量与队列的现代写法

RTOS 的同步原语,在 ZerOS 里我尝试用 C++23 的模板和概念重写。信号量在传统 RTOS 中是一个 struct 加若干 API,在 ZerOS 里它是一个类模板:

template <std::size_t MaxCount> class Semaphore { public: explicit Semaphore(std::size_t init = 0); bool try_acquire() noexcept; void acquire(); std::size_t release() noexcept; auto acquire_awaiter() noexcept; private: zos::Atomic<std::size_t> m_count; zos::WaitList m_waiters; };

acquire() 可以被普通任务调用,等同于阻塞等待。也可以用 co_await sem.acquire_awaiter(),协程挂起而不阻塞 CPU。这里 WaitList 内部是一个侵入式链表,链表节点的内存来自任务控制块本身,不会额外分配。这个设计我参考了 Linux 内核里 waitqueue 的思路,每个等待者把自身的节点挂到链表上,而不是内核去新建节点,彻底避免动态分配。

release 的内部实现可以简化如下:

std::size_t Semaphore<MaxCount>::release() noexcept { ScopedLock irq(m_lock); if (m_count == MaxCount) return 0; ++m_count; auto* waiter = m_waiters.pop_front(); if (waiter != nullptr) { waiter->task->set_ready(); // 重新放回就绪队列 --m_count; // 直接把计数给等待者 } return 1; }

消息队列的写法类似。我用 concept 约束消息类型必须是可移动构造和可析构的,因为队列内部要预分配槽位。

template <typename T> concept Queueable = std::is_nothrow_move_constructible_v<T> && std::is_nothrow_destructible_v<T>; template <Queueable T, std::size_t Depth> class Queue { public: bool send(const T& item); bool send(T&& item); bool receive(T& out); auto send_awaiter(const T& item); auto receive_awaiter(); private: zos::RingBuffer<T, Depth> m_buffer; zos::WaitList m_send_waiters; zos::WaitList m_recv_waiters; };

用模板和 concept 可以在编译期拒绝不可驻留的类型,避免运行期尴尬。我在开发中甚至编译期静态断言了队列深度必须是 2 的幂,这样环形缓冲取模可以用位运算,省了一个除法指令。对于 Cortex-M0/M0+ 这种没有硬件除法指令的内核,这个细节性能差异挺明显。

template <std::size_t Depth> concept PowerOfTwo = (Depth & (Depth - 1)) == 0; template <Queueable T, std::size_t Depth> requires PowerOfTwo<Depth> class Queue { static constexpr std::size_t depth_mask = Depth - 1; // index 自增时直接 & depth_mask };

如果 Depth 不是 2 的幂,编译器直接拒绝。这种编译期约束,比 C 语言运行时的“嗯,应该没事”要可靠太多。以前用宏配置队列长度,偶尔敲错一个数字,程序跑起来才突然丢数据,现在这类低级错误在编译阶段就被拦住了。

3.3 中断服务:把异步变成可等待

传统 RTOS 里,中断和任务之间的通信要么用队列,要么用信号量。但在中断里调用这些 API 时需要专门的安全接口,比如从 ISR 调用 xQueueSendFromISR。ZerOS 希望把这些接口统一起来,让中断也能产生一个“可等待事件”。

具体做法是:ISR 负责置位一个 atomic 标志或者把数据写入无锁环形缓冲,然后调 zos::Interrupt::notify() 唤醒目标协程。因为 Cortex-M 里中断上下文和线程上下文都共享内存,只要临界区保护得当,这里无须调用任何阻塞函数。

例如,一个 GPIO 外部中断的 IRQ 处理函数:

void EXTI0_IRQHandler() { if (EXTI->INTF & EXTI_IMR_IM0) { EXTI->INTF = EXTI_IMR_IM0; button_pressed_event.set_isr(); } }

业务任务可以这样写:

zos::Task<void> button_task() { while (true) { co_await button_pressed_event; zos::log("button pressed"); } }

这里的 button_pressed_event 是 zos::EventFlag 的一种。co_await 一个事件时,任务会注册到事件等待队列;ISR 里置位时唤醒。这套表达比我之前用 C 写的 RTOS 要清晰得多,不再需要反复检查 FromISR API 的命名。

中断里的临界区保护还是老规矩:能关中断就关中断,但不能关太久。ZerOS 里的 Interrupt::notify() 内部只做原子位操作和一次 PendSV 请求,时间开销是确定且极短的。事件标志和二进制信号量的主要区别是语义:事件标志可以同时等待多个位组合,信号量是计数资源。如果你的逻辑是“多个事件都发生了才执行”,事件标志明显更合适。

3.4 编译期配置与静态多态

传统 RTOS 的配置文件全是宏,用起来灵活但不好查错。ZerOS 把很多配置变成了编译期常量,甚至用 consteval 函数做复杂校验。

namespace zos::config { constexpr std::size_t MaxTasks = 16; constexpr std::size_t MainStackSize = 2048; constexpr bool EnableStats = true; constexpr auto TickHz = 1'000; } static_assert(zos::config::MaxTasks <= 32, "too many tasks"); static_assert(zos::config::MaxTasks >= 2, "at least idle and main task required");

如果任务表大小超过 MCU 的 RAM,static_assert 可以给出清晰的错误提示。调度器本身我用了静态多态:调度策略作为模板参数传入任务控制器,这样不同策略之间没有虚函数开销,全部在编译期选定。

template <typename SchedulerPolicy> class Kernel { public: static constexpr bool supports_preemptive = SchedulerPolicy::preemptive; // 调度相关实现可基于 policy 做 if constexpr 分支 }; using MyKernel = Kernel<PriorityRoundRobinPolicy>;

这种写法比用 #define configUSE_PREEMPTION 更类型安全,也比虚函数更零开销。调试的时候,编译器甚至能把整个调度器内联到主循环里,性能反而比通用的 C 实现更优。C++ 模板最容易被诟病的是代码膨胀,但在嵌入式中,我们本来就是为每一块芯片定制镜像,按需实例化的膨胀反而不是问题,它换来的是类型安全和零开销抽象。

我还用 consteval 做了事件注册表的检查。比如每个任务在注册回调时,编译器会验证这个回调是否真的满足 concept 的要求,而不是等到运行期回调被调用时才炸。这在多人协作的工程里尤其有价值,新人写代码时编译器就能给出具体报错位置,不用老工程师逐个代码 review。

4. 在 GD32F103 上的移植实践

4.1 移植层需要做什么

GD32F103 是 Cortex-M3 内核,硬件上有 SysTick、NVIC、PendSV 和 SVC。移植 RTOS 的经典步骤是把这几块硬件抽象出来。ZerOS 的架构相关模块定义了这样的接口:

namespace zos::arch { void init(); void start_context(); void switch_context(); void tick_handler(); void svc_handler(); }

因为 GD32F103 和 STM32F103 非常接近,很多寄存器配置可以直接复用。移植时最需要关注的是中断优先级分组。Cortex-M3 要求 PendSV 和 SysTick 必须是最低优先级,否则低优先级任务会被高优先级中断抢占,导致调度时序混乱。这一点我刚开始没注意,跑起来之后任务莫名其妙卡死,查了很久才发现是 PendSV 的优先级没设对。

为了把优先级分组这个问题一次性说清,我放一段典型初始化代码:

void arch::init() { // 4 位抢占优先级,0 位子优先级 NVIC_SetPriorityGrouping(3); // SysTick 和 PendSV 都设为最低优先级,防止任务调度被高优先级中断干扰 NVIC_SetPriority(SysTick_IRQn, 15); NVIC_SetPriority(PendSV_IRQn, 15); SysTick_Config(SystemCoreClock / zos::config::TickHz); }

GD32F103 和 STM32F103 的最大区别是主频和部分外设地址。GD32 的标准库和固件库命名不同,但寄存器偏移基本兼容。我们用寄存器操作直接写,不依赖 HAL,这样移植成本反而最低。start_context 第一次启动任务时,实际上是把第一个任务控制块的初始寄存器弹出到 CPU,然后跳转到任务入口。PendSV 在后续切换时负责保存旧任务、恢复新任务的现场。

4.2 任务栈与协程帧的内存估算

在 20KB RAM 的 MCU 上,内存规划比代码更紧张。我整理了一个常用的内存规划表:

项目数量大小合计
主任务栈12048 B2048 B
空闲任务栈1512 B512 B
应用任务栈4512 B2048 B
协程帧池16256 B4096 B
系统堆(预留)14096 B4096 B
内核对象-约 2 KB约 2 KB

合计大约 14KB 左右,在 GD32F103VBT6 的 20KB RAM 里还能留下约 6KB 给中断栈和全局变量。注意这里协程帧池不是给每个任务单独分配 256B,而是所有协程共享,挂起时临时从池里取。这样即使有很多协程,只要同时挂起的协程数量不超过池子大小就不会爆。

我实测下来,一个空转协程任务,协程帧加任务控制块大约需要 200 字节左右,比传统 RTOS 的裸任务栈还要省一些。原因是用协程挂起时不需要为每个可能阻塞点预留完整栈深,编译器能精确计算出最小栈帧。传统 RTOS 不知道阻塞函数内部会调用多深,只能按最坏情况预留栈;协程的每个挂起点都是静态已知的,编译器生成的协程状态机只保存真正需要的变量,内存效率自然更高。

4.3 实测数据与踩坑记录

我在 GD32F103 上跑了一个 demo:两个协程任务,一个 LED 闪烁,一个用队列收发数据;另外开一个普通传统任务做运算。SysTick 设置为 1kHz。

实测数据:

  • 协程任务切换时间(通过 GPIO 翻转测量):约 2.1us
  • 信号量 acquire/release 的完整调用:约 1.4us
  • 队列 send/receive(深度 8)平均:约 2.6us
  • 空闲时 CPU 占用率:约 0.5%

这些数字和 FreeRTOS 在同样硬件上的表现基本持平,但代码的阅读难度明显低了很多。以前写一个状态机要画半天图,现在直接用协程表达顺序逻辑,代码量少了将近一半。有人怀疑 C++ 的抽象会带来额外开销,实测下来,只要用好编译期多态和 static 绑定,开销完全可以接受。

踩坑记录:

第一个坑是 C++ 异常。GCC 的 ARM 裸机默认开了异常,如果协程内部抛出异常,会因为找不到异常处理函数而死循环。ZerOS 明确关闭了异常,所有错误用返回值或者 assert 处理。用 CMake 配置就是 -fno-exceptions -fno-rtti,省了太多 ROM 空间。

第二个坑是 std::atomic 在 Cortex-M3 上的实现。ARMCC 6 和 GCC 的原子操作实现不一样,GCC 12 以下对 8 字节原子对象支持不完善,会调用 __atomic_load_8 的库函数,链接时容易报错。解决方式是内核里的原子量只做 32 位和 16 位操作,避免 64 位原子操作。别听网上说 std::atomic 一定是 lock-free,在 32 位 MCU 上随便用 64 位原子类型,很容易踩到坑。

第三个坑是协程调试信息。GDB 对协程的支持不是很好,挂起状态时看不到协程帧的局部变量。我的经验是在关键挂起点打日志,通过 zos::log 输出协程 ID 和状态,比硬调试要快得多。如果你用 Segger RTT,可以把日志通道接成 RTT,实时性也好,非常适合跟踪任务状态变化。

5. 常见问题与面试向考点速查

5.1 面试官最爱问的 RTOS 细节

ZerOS 做了之后,我对 RTOS 很多概念的理解比以前深了一个层次。这里整理一些面试经常问到的点,用 ZerOS 的设计来回答。

第一个问题:任务切换时,内核保存了什么?很多同学会说保存寄存器。更准确地说,是保存“当前任务的下一条指令地址、栈指针、以及可能被破坏的寄存器”。ZerOS 里的协程任务,编译器把挂起状态保存到协程帧,普通任务则保存到任务栈。调度器切换时,本质上是在不同“可执行现场”之间跳转。

第二个问题:信号量的最大值和初值为什么不同?信号量的计数不能为负。初值表示初始可用的资源数,最大值限制资源上限。比如串口发送缓冲区有 16 个槽,初值应该设为 16,最大值 16。如果初值设为 0,那么所有发送任务一开始都会阻塞,这通常是 bug。ZerOS 的 Semaphore 模板把 MaxCount 固定在类型里,从类型系统上避免初值超过最大值的错误。

第三个问题:优先级反转怎么解决?经典方案是优先级继承。ZerOS 目前实现了一个简化的互斥量,支持优先级继承。当低优先级任务持有互斥量,而高优先级任务在等待时,内核会把低优先级任务临时提升到高优先级任务的优先级,释放后再恢复。虽然实现增加了一点代码量,但真实系统中非常有用。如果不解决优先级反转,一个普通的共享总线独占片刻,高优先级实时任务就可能错过时序窗口。

第四个问题:协程和 RTOS 的任务有什么区别?这是面试中比较新的问题。RTOS 任务是系统调度实体,由内核决定何时切换;协程是语言层面的可暂停函数,是由代码主动挂起。ZerOS 把两者结合:协程作为任务的一种表达方式,但最终调度权仍然在内核手里。协程可以理解为“任务内部的状态机生成器”,而 RTOS 任务是协作式和抢占式调度混合的平台。

第五个问题:怎么测量任务切换时间?最经典的方法是 GPIO 翻转,在 switch_context 入口拉高,出口拉低,用示波器看脉宽。ZerOS 的 arch::switch_context 我也这么做过。还有一种方法是在任务里读 SysTick 的当前计数值,但要注意编译器可能重排指令,需要加编译器屏障。纯软件计时一定要关中断,否则测量结果会被高优先级中断污染。

5.2 自己在调试中踩过的坑

调试 RTOS 最痛苦的就是死锁。ZerOS 里最经典的死锁场景是这样的:任务 A 等待任务 B 的信号量,而任务 B 因为等待任务 A 的队列而阻塞。两个任务互相等,CPU 空转。

遇到这种问题,ZerOS 提供一个静态调试接口:zos::debug::dump_wait_graph(),把当前所有任务的状态和等待对象打印出来。

Task "led_task" state: waiting_semaphore -> sem_uart_tx Task "uart_task" state: waiting_queue -> queue_main Task "main_task" state: running

一眼就能看出谁在等谁,形成环就是死锁。这个功能在开发阶段帮了大忙,比传统 RTOS 的堆栈回溯直观很多。我曾经遇到一个任务等待另一个任务释放资源,但释放资源的代码在一个很深的 if 分支里没有执行,dump 出来后才看清整个依赖链。

另外一个坑是栈溢出检测。协程任务虽然栈帧小,但无限递归仍然会越界。ZerOS 用了 Cortex-M3 的 MPU 可选保护,但 MPU 只有 8 个区域,任务一多就不够分。退而求其次,我给任务栈两端填充了哨兵字节,调度时检查哨兵是否被破坏。这个方法虽然不能 100% 防住,但至少能在崩溃前打印出是哪个任务的栈被踩了。

bool check_stack_magic(const zos::TaskControlBlock& tcb) { auto* bottom = reinterpret_cast<std::uint32_t*>(tcb.stack_bottom); return bottom[0] == STACK_MAGIC && bottom[1] == STACK_MAGIC; }

栈溢出往往不会立刻触发 HardFault,而是先踩坏相邻内存,过了很久才随机崩。加哨兵以后,每次任务切换都检查一次,虽然增加了几百纳秒开销,但换来的是提前定位问题的能力,这笔买卖非常划算。

最后分享一个小技巧:调试时间相关 bug 时,一定要在 SysTick 中断里打一个计数器戳,否则很难分清是任务延时到了还是被抢占打断。ZerOS 里的 zos::time::now() 就是基于 tick 计数器的,配合逻辑分析仪量 GPIO 的时候,这个时间戳特别有用。

6. 复盘与后续

6.1 代码规模与可维护性

ZerOS 核心代码量不算大,整个内核加同步原语大概 3000 行 C++,其中一半是协程和调度器,剩下的是架构移植层。这个规模对个人项目来说很舒服,也足够当实验田。和 Zephyr 这种大型 RTOS 相比,功能少得多,但正因为小而清晰,可以更方便地验证 C++23 在嵌入式实时场景下的边界。

可维护性我觉得是模块化带来的最大收获。以前用 C 写一个小内核,加一个功能要理解所有头文件的包含关系。现在 imports 关系一目了然,改同步原语不会影响调度器。更重要的是,编译器在编译期帮我抓住了很多低级错误,比如类型不匹配、concept 不满足、生命周期问题,这在 C 工程里都是靠人眼盯。虽然 C++ 调试体验偶尔让人头疼,但类型系统省下来的时间,总体上远远超过调试 C++ 花掉的时间。

6.2 下一步想实现的东西

后续我主要在考虑两件事。一个是任务依赖图,想在模块层提供编译期描述的依赖关系,任务启动时自动按依赖顺序创建,避免手动初始化顺序错误。另一个是协程栈的进一步优化,目前是每个任务固定栈,如果未来芯片内存更宽裕,也许可以实验一下按需增长的栈段。按需增长栈在 C++ 协程里其实有天然优势,因为每个挂起点的存活变量是编译期已知的,理论上可以做到更紧凑的内存布局。

还有一个待办事项是 RISC-V 移植。Cortex-M 上已经有非常成熟的 PendSV 机制,RISC-V 的机器定时器中断和软件中断处理略有不同,协程帧和汇编切换层的接口需要重新适配。等手上有了 RISC-V 开发板,我打算把 ZerOS 平移过去。相比 ARM,RISC-V 的原子操作在 Linux 内核里暴露得更多,C++20 的 atomic_ref 或许能找到用武之地。

ZerOS 这个项目的价值不在于又造了一个轮子,而在于验证了另一种表达方式。如果读者手头有 MCU 开发板,我也建议不要急着调用现成 RTOS 的 API,先照着这个思路写一个最小内核试试。当那些经典概念你用 C++23 重新“说”出来的时候,很多以前靠记忆背结论的细节,会突然变得真正理解了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/11 20:17:19

直流母线电容选型:纹波电流校核与三种拓扑计算实例

选直流母线电容&#xff0c;这是我被问得最多的话题。前阵子一个做储能变换器的朋友&#xff0c;拿着算好的铭牌过来问我&#xff1a;“公式都套了&#xff0c;容量也对&#xff0c;为什么满载跑二十分钟电容烫得不敢碰&#xff1f;”我看了一眼设计&#xff0c;容量确实按教科…

作者头像 李华
网站建设 2026/9/11 20:17:19

STM32三大底层认知断层:时钟树、寄存器原子性与调试接口耦合

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/11 20:15:06

HFSS、CST、ADS选型实战:高频仿真工具的底层逻辑与技巧

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/11 20:09:56

温湿度传感器入门到实战:原理、接线与代码

一、为什么需要温湿度传感器温度与湿度是环境中最重要的两个基础参数&#xff0c;直接关系到设备寿命、产品质量与人体舒适度&#xff1a;农业与种植&#xff1a;大棚、育苗、菌菇房需要精准控温控湿&#xff1b;机房与设备间&#xff1a;服务器运行环境温湿度异常会加速硬件老…

作者头像 李华
网站建设 2026/9/11 20:08:43

为什么说Lombok是一把双刃剑?用对了效率翻倍,用错了灾难

Java 开发者几乎都用过 Lombok。一个 Data 注解&#xff0c;省去了几十行 getter、setter、equals、hashCode、toString&#xff1b;一个 Slf4j&#xff0c;连 Logger 声明都免了。它让代码变得干净利落&#xff0c;开发效率肉眼可见地提升。但我在多个项目中见过 Lombok 引发的…

作者头像 李华
网站建设 2026/9/11 20:08:26

VS Code highlight-words配置与实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华