资讯动态

用C++23重写RTOS内核:协程、类型安全与编译期配置的探索

发布时间:2026/9/11 22:33:49 来源:尧图企业网站定制
1. 先问一句RTOS 的内核凭什么还是三十年前的写法前阵子帮朋友调一块 GD32F103 的裸机项目他在上面跑的 FreeRTOS任务里一个状态机洋洋洒洒写了四个 switch-case 嵌套。我看了半天说这状态机放在单片机里确实够用但你想过没有任务本身就是一个可以暂停、可以恢复的执行流为什么我们非要用结构体保存状态 函数指针分派 全局变量共享数据这种 C 语言时代的组合拳去模拟它这不是抬杠。RTOS 的核心概念——任务、信号量、消息队列、定时器——本质上都是执行流管理和资源同步这两个问题的具体化。C 语言能表达这些概念但表达得很勉强TCB 是一大坨结构体队列是 memcpy 来 memcpy 去信号量忘记释放就是经典的死锁现场。而 C23 带来的协程、concepts、std::expected、consteval 这些东西恰好对准了这些痛点。ZerOS 这个名字你可以读作从零写一个 RTOS也可以读作把 RTOS 归零重新想一遍。它不是我已经量产的内核而是一个设计探索如果抛开必须用 C 写嵌入式内核的惯性用 C23 的语法重新表达 RTOS 的核心原语长什么样它能解决哪些老问题又会惹出哪些新麻烦这篇文章就是这份探索的记录。适合两类人看一类是日常工作被 FreeRTOS、Zephyr、RT-Thread 折磨的嵌入式开发者想看看下一代工具链能带来什么另一类是还在学 RTOS 的新人想理解信号量、队列、任务调度这些概念在更高级的语言抽象下会变成什么形态——顺便说一句这对手撕 RTOS 面试题也有点帮助。2. C23 给嵌入式内核的四个支点在谈 ZerOS 的任务模型之前得先把 C23 里到底有哪些东西能真正落进一个运行在 Cortex-M3 上的内核讲清楚。不是所有新特性都适合嵌入式这四个是我认为最关键的支点。2.1 std::expected内核 API 的错误处理终于像样了传统 RTOS 的 API 错误处理有两种流派。FreeRTOS 派系返回 BaseType_t 错误码调用方得记得检查忘了就等着诡异 Bug另一种是部分 C RTOS 直接抛异常但嵌入式环境普遍-fno-exceptions这条路走不通。std::expectedT, E是 C23 标准化的要么有值、要么有错误的容器。它不像错误码那样容易被忽略也不像异常那样需要运行时开销。以信号量获取为例// 传统 FreeRTOS 风格 BaseType_t ok xSemaphoreTake(sem, pdMS_TO_TICKS(100)); if (ok ! pdPASS) { // 处理超时 } // ZerOS 风格 auto result sem-try_acquire_for(100ms); if (!result) { auto err result.error(); // Timeout 或 AlreadyLocked // 处理超时 }std::expected最大的价值不是语法糖而是把可能失败这件事写进了类型系统。编译器强制你处理错误路径想无视都不行。内核内部代码也因此受益调度器里的每个潜在失败点都可以精确传递错误来源而不是用一个统一的-1打天下。2.2 consteval 与 constexpr把内核配置压进编译期RTOS 的配置有一堆矛盾静态分配保证确定性但配置信息充斥着魔数动态分配灵活但 Worst-Case 执行时间不可控。C20 的 constexpr 已经很强C23 继续扩展了编译期运算能力加上consteval强制在编译期求值之后一个很自然的想法浮现了任务控制块、信号量、队列这些内核对象能不能在编译期就构建好consteval auto make_tcb(auto fn, Priority prio, StackSize stack) { TaskControlBlock tcb{}; tcb.entry fn; tcb.priority prio; tcb.stack_depth stack; tcb.state TaskState::Ready; return tcb; } // 编译期就确定了三个任务的全部属性 constinit auto tasks std::array{ make_tcb(blink_task, Priority::Normal, StackSize::Of512), make_tcb(uart_task, Priority::High, StackSize::Of1024), make_tcb(health_task, Priority::Low, StackSize::Of256), };constinit保证变量在静态初始化阶段完成初始化不依赖运行时构造函数这正好符合单片机startup.s之后、进入main之前那个时间段的要求。传统 RTOS 需要写一堆#define TASK_STACK_SIZE和宏展开在这里变成了类型安全的编译期表达式。2.3 concepts接口从文档约定变成编译器检查C 语言里任务函数长什么样靠注释约定参数是一个 void 指针返回值是 void。你要是传错类型编译器最多给你一个 warning剩下的交给运行时去爆。C20 concepts 把这种约定变成编译期契约。ZerOS 对任务体这个概念可以这样约束template typename T concept TaskBody requires(T t, TaskContext ctx) { { t.run(ctx) } - std::same_asvoid; // 普通函数式任务 { t.co_run(ctx) } - Awaitable; // 协程式任务 };概念约束下你在create_task()里传进去一个不满足 TaskBody 的类型的瞬间编译器就会把错误拍在你脸上。这比在运行时才从任务列表里发现函数指针悬空要舒服一个数量级。2.4 协程执行流管理的原生语法这是四个支点里最重要、也最需要谨慎对待的一个。C20 引入了协程C23 的std::generator等机制让协程在库层面更可用。协程的本质是可挂起/可恢复的函数这不就是 RTOS 里的任务吗传统 RTOS 的任务是半路被杀/被切换——上下文切换保存的是 CPU 寄存器任务被认为是一个无限循环的函数。C 协程则不同它在语言层面定义了挂起点suspend point挂起时保存的是协程帧。这意味着任务代码可以写成顺序的、线性的流程而不是一个巨大的什么都不返回的循环。看一个典型任务每 500ms 翻转一次 LED同时每 5s 通过串口打印一次状态。FreeRTOS 的写法是void vTask(void* pvParams) { while (1) { vTaskDelay(pdMS_TO_TICKS(500)); HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); static int cnt 0; if (cnt % 10 0) { printf(tick %d\n, cnt); } } }ZerOS 用协程可以写成Task blink_led(Led led, Uart uart) { int cnt 0; while (true) { co_await kernel::sleep_for(500ms); led.toggle(); if (cnt % 10 0) { co_await uart.write_line(tick {}, cnt); } } }语法上只是多了一个co_await但背后的执行模型完全变了co_await kernel::sleep_for(500ms)这个挂起点把本轮迭代结束、该让出 CPU这个意图直接写在了代码里而不是依赖调度器在任意指令边界强切任务。栈开销从传统任务的一整个栈变成每次只分配一个协程帧。当然协程不是没有代价的协程帧要放在哪、分配是否确定、不同架构下如何实现这些在 ZerOS 里需要一整套设计来承接后面专门说。3. ZerOS 的任务模型从 TCB 结构体到协程任务3.1 任务控制块编译期定骨架运行期只动状态传统 RTOS 的 TCB 是一个巨大的 C 结构体里面堆了几十个字段栈指针、优先级、状态、事件等待列表、各种回调函数指针、嵌套调度计数器……运行时才填充。ZerOS 把 TCB 拆成两个层面。编译期层面只保留这个任务是谁、栈多大、优先级多高这些不变信息用 constexpr 构造好放进只读区。运行期层面保留当前处于什么状态、在等待什么、协程帧在哪这些可变信息。struct TaskControlBlock { // 编译期不变的部分 Priority priority; StackSize stack_size; // 运行期可变的部分 TaskState state; void* coroutine_frame; // 协程恢复时需要的上下文 std::source_location created_from; // 调试用 };这样设计有一个实际好处编译期字段可以被调度器做常量折叠。比如优先级反转避免协议Priority Inheritance需要临时提升任务优先级传统 RTOS 要修改 TCB 里的 priority 字段。ZerOS 里如果你允许这种动态调整就要把 priority 挪到运行期字段如果内核配置里声明不允许优先级动态变化整个字段就留在只读区调度器直接按常量处理连读内存都省了。配置决定形态这也是 C 模板元编程带来的设计自由度。3.2 调度器就绪队列不是找最高优先级而是消费可执行协程传统调度器的核心是一张就绪表每次任务切换都扫描一遍找最高优先级。ZerOS 的调度器可以换一个思路因为协程挂起点静默地处理了这个任务现在不想跑比如在等信号量、等延时就绪队列里放的都是真的可以跑的协程句柄。class Scheduler { public: void run() { while (!ready_queue_.empty()) { auto handle ready_queue_.top(); handle.resume(); // 恢复一个协程 if (handle.done()) { finalize_task(handle); } } } private: priority_queuecoroutine_handle ready_queue_; };这个模型和我手写裸机调度器的体验完全不同任务切换不再是在任意点被强行打断保存现场而是协程运行到自己声明的挂起点。对于大多数 I/O 密集任务等串口、等按键、等 DMA 完成这种协作式协作模型的上下文切换开销是极低的——不需要改栈指针不需要手工切换 MSP/PSP只需要恢复协程帧。不过必须说明ZerOS 在必须抢占的场景下仍然需要定时器中断去做抢占式调度。只是中断处理函数里不需要再保存全部寄存器现场了因为协程帧已经在语言层面兜住了。理想情况下中断只负责唤醒对应协程把它放入就绪队列然后返回上下文切换被推迟到调度器主权运行的那一个明确时刻。这样可以消除内核中大量关中断/开中断的临界区代码。3.3 任务体的写法普通函数和协程任务可以并存有些任务非常简单比如 LED 闪烁不需要挂起点就是一个死循环。这类任务 Zeros 允许你用普通函数定义。有些任务天然有等待语义比如等待串口数据、等待信号量这就是协程的舒适区。// 方式一普通函数任务适合自包含死循环 void heartbeat_task() { while (true) { kernel::this_task::sleep_for(1s); bsp::led_toggle(BspLed::Heartbeat); } }这里kernel::this_task::sleep_for在普通任务里会被翻译成传统的阻塞延时调用系统调用后触发任务切换但在协程任务里会被翻译成co_await。一套 API 名字两种实现路径由任务的类型在编译期决定。这样设计是为了兼容一个很现实的需求你把一个旧的 FreeRTOS 任务函数迁移到 ZerOS 时可以先把它当成普通函数任务跑起来再逐步重构成协程。迁移动力不足没关系系统不会逼你。3.4 定时器与延时co_await 背后藏着什么传统 RTOS 软定时器是实现为一棵定时器树每个 tick 检查有没有到期的。ZerOS 里延时是一个顶层协程操作struct SleepAwaiter { TickType deadline; Scheduler scheduler; bool await_ready() const { return false; } void await_suspend(std::coroutine_handle h) { scheduler.add_timer(deadline, h); // 把当前协程挂到定时器堆 } void await_resume() {} }; inline auto sleep_for(Duration d) { return SleepAwaiter{TickClock::now() d, kernel::scheduler}; }关键在await_suspend协程挂起时不是把自己丢进就绪队列那是立即执行的而是放进一个最小堆定时器。调度器每次 tick 后检查堆顶把到期的协程移动到就绪队列。这个模型比传统 RTOS 一个个链表节点找过期定时器要清晰得多也更容易验证正确性。这里有一个新手容易踩的坑不要在 ISR 里co_await。协程挂起/恢复只能发生在线程上下文包括内核调度器的控制流里。中断里要做的事情是标记事件、唤醒对应的协程然后触发 PendSV 让调度器在安全的时候恢复它。这个约束实际上比传统 RTOS 里中断里不能调用哪些 API的清单更简单、更统一。4. 同步与通信原语信号量、队列的类型安全重构4.1 信号量从 take/give 到带 RAII 的自动释放信号量忘释放是我在 RTOS 面试里最喜欢问的问题之一拿到信号量之后提前 return忘了释放会发生什么正确答案应该是其他等待该信号量的任务永久阻塞但实际项目里它往往表现为系统跑一段时间后莫名其妙卡死。ZerOS 把信号量封装成带作用域的锁解决这个问题class CountingSemaphore { public: explicit CountingSemaphore(size_t initial_count); [[nodiscard]] ExpectedAcquireGuard, SemError try_acquire_for(Duration timeout); void release(); // RAII析构时自动 release class AcquireGuard { public: ~AcquireGuard() { sem_.release(); } // 禁止拷贝 }; };用法是auto guard can_bus_sem.try_acquire_for(100ms); if (!guard) { // 超时处理 return; } // 此后不用手动 release离开作用域自动释放[[nodiscard]]保证返回值不被丢弃AcquireGuard保证释放绝不遗漏。这比 C 语言的接口在工程安全性上完全是两个物种。4.2 消息队列std::span 解决变长消息的拷贝焦虑传统 RTOS 队列在创建时需要指定单个消息大小因为内部用数组memcpy 实现。这导致一个经典问题你想传一个网络包包头 4 字节、载荷 256 字节、最大 1500 字节你得按最大长度建队列内存浪费四倍。ZerOS 队列不直接存消息而是在创建时指定一个内存池或接受一个外部 buffer队列元素就是一个std::spanconst std::bytetemplate typename T class MessageQueue { public: Expectedvoid, QueueError send(const std::spanconst T data, Duration timeout); Expectedstd::spanT, QueueError receive(Duration timeout); };发送端把消息的 span 投进队列接收端从队列拿到 span直接操作原始内存完全避免 memcpy。当然这不是没有代价消息的所有者必须保证数据源在接收端完成读取之前有效。ZerOS 的文档会明确建议发送方发送后不要再写这块数据这属于一种显式的所有权转移约定。它把这个问题从队列内部偷偷拷贝了一个坏数据变成你违反了我文档里的约定后者在排查问题时体感好得多。4.3 内存池嵌入式 new 的体面替代协程帧要存储消息缓冲要存储。直接在嵌入式内核里调用标准库operator new不是一个好主意——C 的 new 默认走堆嵌入式堆默认是malloc/free实现的碎片、不确定性、不可重入全是雷。ZerOS 的策略是所有协程帧、队列缓冲、内核对象默认都在编译期分配constexpr 数组需要动态分配的场景必须显式指定一个内存池内核只从这个池子分配池子满时分配失败会返回 Expected 错误而不是抛异常或中断。class FixedBlockPool { public: FixedBlockPool(std::spanstd::byte storage, size_t block_size); Expectedvoid*, PoolError allocate(); void deallocate(void* ptr); constexpr size_t capacity() const; };这样设计之后内核的分配行为是完全确定的要么从静态区拿要么从指定的池子拿没有隐式 malloc。你在代码里看到new (pool) Task(...)看到co_await时会注意到协程帧的分配。协程本身的分配也可以在 C20 之后通过自定义operator new在每个协程函数里定制ZerOS 直接用这个能力把协程帧也放进固定内存池。不过这里有个非常反直觉的经验协程帧的大小不由函数名直接决定它取决于函数内挂了哪些协程调用、每个 co_await 的 Awaiter 对象有多大。所以 ZerOS 提供编译期工具static_assert来检查协程帧大小上限防止一个看起来人畜无害的函数偷偷吃掉几 KB。5. 在 GD32F103 这类 MCU 上的现实约束与取舍写到这里肯定有人要问这些设计在真机上跑得动吗尤其是 GD32F103 / STM32F103 这种 72MHz Cortex-M3只有 20KB RAM 的芯片。我的回答是部分跑得动部分需要谨慎设计。这一节专门说约束和取舍。5.1 工具链现状C23 在 MCU 上到底能用多少先泼一盆冷水完整 C23 支持对嵌入式工具链来说目前还是个进行时。我实测以及参考社区反馈的情况是这样协程C20 特性GCC 10 之后已经稳定arm-none-eabi GCC 目前可用std::expectedGCC 12 之后在-stdc23或实验模式下可用但头文件实现依赖一些异常相关的类型信息需要在无异常模式下仔细验证conceptsGCC 10 之后可用编译期元编程的编译速度会变慢对老 MCU 开发机的「编译几十秒」体验是一个考验std::generator/ ranges 的完整支持在 MCU 工具链上还不建议用生成器和 Range 适配器的代码膨胀经常超标。所以在真正的 GD32F103 工程里ZerOS 的项目会开一个-stdc23开关但内部有自己的 feature detection 层。如果某个特性在目标工具链上不可用ZerOS 退回到 C20 的近似写法比如用std::optionalstd::reference_wrapperT模拟 expected 的大部分语义。这种渐进式兼容策略对新语言特性在嵌入式中的落地很重要——你不会希望整个内核编译不过仅仅因为工具链还没实现某个标准库组件。5.2 栈、堆与协程帧一个必须坦白的代价协程带来的最大变化不是语法而是内存模型。传统 RTOS 任务栈是一块连续内存大小在创建任务时通过宏或参数指定。协程的帧是按需分配的每次调用一个协程函数编译器生成一个帧里面包含参数拷贝、局部变量、挂起点状态。如果一个协程函数内部又 co_await 另一个协程函数两个帧是独立的。这种模型的好处是内存利用率高——你不会因为任务里某个分支有一块 1KB 的栈数组就给整个任务配 2KB 栈。坏处是你不一定能静态算出这个任务总共需要多少栈。特别是递归协程、生成器风格的任务帧的个数是动态的。ZerOS 在 GD32F103 上的典型做法是每个任务默认的底层栈给 512B~1KB用于执行普通函数调用和中断处理协程帧全部从指定的内存池分配池子在编译期固定大小提供ZEROS_CHECK_STACK_USAGE编译选项运行时在任务栈顶放置金丝雀值canary栈溢出时抓死。这样配合后ZerOS 的内存占用和 FreeRTOS 相比不会明显膨胀但能处理更复杂的任务语义。说一个具体的教训我同事第一次把一个 2KB 栈的 FreeRTOS 任务改成 ZerOS 协程任务时把局部变量数组放在一个 co_await 之后。结果这个数组被放进了协程帧而不是栈帧导致协程帧暴涨到 2.3KB。之后我们总结出一条直观判断依据如果一个局部变量只需要在挂起点之间存活放进协程帧没问题如果一个局部变量必须在整个任务生命周期存活建议显式把它提成任务级对象比如放在 Task 类的成员里否则内存去向很不清。这算是协程化 RTOS 设计里最有价值的实战经验之一。5.3 关中断的粒度、优先级反转和确定性RTOS 的实时性底线是中断响应时间和调度确定性。C 协程和 constexpr 都不应该破坏这个底线。ZerOS 内部临界区仍然使用关中断或std::atomic_flag的自旋锁保护只是临界区范围被大幅压缩就绪队列优先级队列的 push/pop 操作是 O(log n) 的且没有回调函数在临界区内调用所以关中断时间是可预测的微秒级。优先级反转问题ZerOS 采用了一种相对现代的实现方式信号量对象内置 Priority Inheritance 协议但通过 concepts 约束只有优先级可动态修改的任务类型才被允许参与。这比传统 RTOS 把所有任务都加上优先级继承的调度开销要省一些。实时性方面还有一个和老内核不同的点因为协程的挂起点明确系统可以统计每个任务在一个周期内实际执行的时间并在协程从挂起点恢复时触发执行超时检测这是一种可以集成进调试器的运行时诊断能力——FreeRTOS 里你要实现同样效果得往 tick hook 里塞不少代码。6. 和 FreeRTOS、Zephyr 摆在一起看ZerOS 的位置在哪6.1 对比维度语言表达力、内存模型、生态先做一个直观的表格对比。对比维度FreeRTOSZephyrZerOS本设计内核语言C可配 C 兼容C少量 C 可用于驱动C23 核心C 提供 ABI 兼容边界任务模型无限循环函数 阻塞调用线程k_thread 同步原语协程任务 / 普通任务并存错误处理返回错误码返回错误码 errnostd::expected 类型安全错误同步原语Semaphore/Mutex/QueueSemaphore/Mutex/Pipe/MessageQueueRAII 信号量、span 队列内存分配静态或 heap_4静态/内核堆编译期分配 显式内存池中断处理ISR 线程安全 APIISR 线程安全 APIISR 只负责唤醒协程调度延后到线程上下文编译期配置宏定义Kconfig 宏constexpr consteval 概念约束典型芯片8 位~Cortex-MCortex-M/A/RISC-VCortex-M / RISC-V设计目标这几个维度里ZerOS 最明显的优势不是性能而是类型安全和代码可读性。最明显的劣势是生态和资料FreeRTOS 十几年的移植文档、Zephyr 的驱动模型和 DTS 体系这些都不是语言表达力能弥补的。所以我认为 ZerOS 的定位不是替代 FreeRTOS而是内核设计与语言实验。6.2 从传统 RTOS 迁移到 ZerOS 的可行路径如果真有项目想迁移最顺畅的路是先把所有任务函数原封不动搬到 ZerOS以普通函数任务方式运行同步原语暂时用兼容层映射到信号量/队列的 RAII 封装逐一确认每个任务的主要等待点是什么如delay改成co_await kernel::sleep_for串口等待改成co_await uart.read()当体内不再有长时间阻塞调用后把任务改成协程任务此时任务栈可以大幅缩小最后替换内存管理去掉全局 new/delete把所有动态分配收敛到固定块池。前两个阶段里ZerOS 基本能在不改变系统行为的情况下跑起来第三阶段是收益最明显的转折点——栈占用会直接降一个量级。6.3 面试视角怎么理解 RTOS 本质现在很多 RTOS 面试题还停留在说说信号量和互斥锁的区别什么是优先级反转。这种问答训练映射到 ZerOS 的视角后会变成更有深度的思考方向为什么 C 协程能天然表达任务因为二者的本质都是可控的执行流挂起与恢复为什么 std::expected 比错误码更适合内核 API因为内核 API 的失败路径也一样需要确定性处理expected 在类型层保证你不可能忽略它consteval 配置和宏配置的本质区别是什么宏是文本替换constexpr 是带类型的值计算后者能被编译器做范围校验。这些问题的答案恰恰是把 RTOS 从背 API 名字提升到理解设计取舍的关键。所以 ZerOS 虽然是个概念型项目但它最大的产出可能是培养一种从语言层看系统的习惯。7. 写在最后这不一定是个好主意但值得一问说实话我还没把 ZerOS 完整地在 GD32F103 上跑通过全部设计。有些部分落地了比如协程任务和 sleep_for 挂起确实比 FreeRTOS 的任务切换写起来舒服有些部分还在琢磨比如协程帧分配在多优先级任务下会不会造成优先级反转——这是传统 RTOS 完全没有的问题。但探索的价值恰恰在这些琢磨里。C23 对嵌入式的意义不是用新语法把旧代码重写一遍而是把以前要靠约定、靠注释、靠经验传导的安全约束变成编译器能检查和验证的代码结构。任务挂起时有明确的协程帧信号量通过 RAII 自动释放错误通过 expected 强制处理配置在编译期完成静态校验——每一条都在降低嵌入式系统里不可复现的偶发 Bug出现的机会。如果你对这类方向感兴趣我的建议是不要急着把 ZerOS 这样的大设计搬到产品里先在一个小项目里用 C 写写状态机用协程处理一个串口协议用 std::expected 替换你手写的错误码判断。当你能感受到类型系统在帮我省时间而不是C 在给我添乱的时候再回头看今天的议题你会有自己的答案。ZerOS 的下一步我打算重点做三件事一是把协程任务在 ARM Cortex-M 上的上下文切换微基准测完整二是把信号量优先级继承和协程调度做一套确定性证明三是整理一份从 FreeRTOS 迁移到 C23 协程的最小 Demo。到时候有了实测数据再来和大家聊第二轮。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价