ZerOS:RTOS 在 C++23 的表达,可能是什么样的?

ZerOS 已经开源!一个拿 C++23 写的裸机 RTOS:Cortex-M3 上无堆、无 RTTI、无异常,任务工厂、Concept 内核边界、std::expected 错误通道,一套内核四处运行——桌面单测、QEMU、Renode 与 Blue Pill 真机。欢迎围观,喜欢点个⭐!

仓库地址:https://github.com/Charliechen114514/ZerOS

把堆、异常、RTTI、宿主操作系统一口气全拿走,C++ 还能剩下什么?咱们多半会脱口而出:剩个带类的 C。

啊,好像对了一点,但是其实不太对,来这里坐一会,且听笔者瞎唠唠。

笔者过去这段日子拿 ZerOS 验了这个问题。它是一个用 C++23 写的裸机 RTOS,跑在 Cortex-M3 上:Blue Pill(STM32F103)真机,加上 QEMU 和 Renode 仿真,编译参数 -fno-exceptions-fno-rtti,链接 -nostdlib,整个内核一行 new、一行 malloc 都没有(当然有的是MemoryAllocator的类型化Make和Destroy,逃)

先跑给咱们看

空口说没意思,咱们直接把 demo 跑起来。下面这份输出来自 QEMU 的 mps2-an385 机器,两个任务隔 500ms 交替说话,是仓库里心跳示例的原样串口:

ZerOS demo: heartbeat (mps2)
Hello! ZerOS
A heartbeat example!
Hello! ZerOS
A heartbeat example!
Hello! ZerOS
A heartbeat example!

很长嘛?不长,

#include "ZerOS/arch/arm_cortex_m3/switch.hpp"
#include "ZerOS/kernel/sched/task.hpp"
#include "ZerOS/kernel/sched/this_task.hpp"
#include "ZerOS/log/print.hpp"
#include "demo_setup.hpp"
#include <cstdint>

struct HeartBeatPackage {
    uint32_t sleepy;
    const char* banner;
};

HeartBeatPackage banner{.sleepy = 500, .banner = "Hello! ZerOS\r\n"};
HeartBeatPackage task{.sleepy = 500, .banner = "A heartbeat example!\r\n"};

void heartbeat(void* args) {
    HeartBeatPackage* package = reinterpret_cast<HeartBeatPackage*>(args);
    for (;;) {
        ZerOS::log::print(package->banner);
        ZerOS::ThisTask::sleep_for({package->sleepy});
    }
}

int main() {
    ZerOS::demo::setup("heartbeat (mps2)");

    auto shape = ZerOS::task::named("HeartBeat").prio(1).words(64);

    // clone the shapes
    ZerOS::arch::cortex_m3::launch(shape.entry(heartbeat, &banner));
    ZerOS::arch::cortex_m3::launch(shape.entry(heartbeat, &task));

    ZerOS::arch::cortex_m3::start_scheduler();
}

咱们盯住 auto shape = ... 那条链:任务的三项出生证明,名字、优先级、栈大小,像填表一样一路点下去,最后 entry(heartbeat, &banner) 把函数挂上,launch 放进启动区。同一个 shape 还能克隆:第二行 shape.entry(heartbeat, &task) 复用同一份配置,只换参数,就起了第二个任务。这条链凭什么敢这么设计?漏填一项会发生什么?咱们往下看。

链就是契约,孩子们再也不怕穿错几个参数原地爆炸了

C 风格 RTOS 的任务创建函数,签名一般是九个参数一路排开:任务名、入口、参数、栈深、优先级、句柄指针,传错一个位置,编译器一声不吭,到板子上死机才现形。ZerOS 的做法是把"到目前为止说过什么"直接做成类型。咱们看 task.hpp 里的台阶:

// stages: each struct is what the chain knows so far, nothing more
struct Named {
    const char* name;
};
struct Priorized {
    const char* name;
    TaskPriority_t prio;
};
struct Stacked {
    const char* name;
    TaskPriority_t prio;
    std::span<std::uint32_t> stack;
};

每调用一步,返回下一个台阶:报过名字才有 Priorized,报过优先级才轮到栈,栈落实了才轮到 entry。关键在漏步的时候:忘了 prio,后面的方法在类型上根本不存在,编译器给咱们的报错直截了当——Named 没有 stack 这个东西。这个错误发生在编译期,程序压根跑不到运行时。

这套写法有名字,叫类型状态(typestate):把对象的状态编进类型里,咱们把调用顺序写错,得到的就是一个类型错误。链条即契约,少说一个字,后面的话就无从说起。

栈从哪来,也在这条链上解决了。咱们走 words(64) 这条启动区的路:容量在编译期定死,launch 时从静态池里排版,零动态分配。想把一切握在自己手里的朋友还有另一条路:

constinit task::TaskSlot<64> a{};
spawn(task::named("a").prio(1).stack(a).entry(fn, arg).spawn_into());

咱们看 TaskSlot:任务控制块和栈住在同一个静态槽里,constinit 保证它们进 main 之前就位。两条路殊途同归:所有内存在编译期就有归属,内核跑起来之后,不需要一个字节来自堆。

内核不认识寄存器

嵌入式圈的老习惯,是把板级支持包架在内核屋檐下,led、uart、gpio 全是一家子。ZerOS 反着来:内核是组件,板子是宿主,边界拿 Concept 画。咱们看 kernel/system_concept.hpp 那份清单的节选:

template <typename System>
concept SystemContext = requires(System& s, BorrowedPtr<TCB> t, Ticks span) {
    // 这里吐个槽,感觉这样下去容易变成上帝概念,正在考虑分割ing
    { s.current_task() } -> std::same_as<BorrowedPtr<TCB>>;
    { s.ready(t) };
    { s.block(t) };
    { s.sleep_for(t, span) };   // how a sleeper gets woken is the system's secret
    { s.now() }   -> std::same_as<Ticks>;
    { s.notify(t, 42u) } -> std::same_as<bool>;
    { s.wait_notify(span) } -> std::same_as<std::expected<std::uint32_t, SyncError>>;
    s.lock();
    s.unlock();
};

翻译成人话,内核向宿主要的能力就咱们看到的这一张单子:谁在跑、把谁挂起、睡到几点、怎么加锁。概念里没有一个字提到 SCB、NVIC 或 CMSIS,寄存器的脏活全部压在 port 层。arm_cortex_m3 的实现满足它,host 上的 mock 也满足它。

这个划分带来一个特别实际的红利:内核逻辑能在咱们桌面当普通代码跑测试。笔者在 x86 上把完整套件跑了一遍:

100% tests passed out of 15
Total Test time (real) =   0.21 sec

15 个用例,0.21 秒,咱们不用买板子,也不用开仿真器。调度、就绪队列、超时唤醒这些最容易写错的部分,全部以单元测试的形态活在 CI 里。写 RTOS 第一次可以像写普通库那样,先在桌面跑测试,再上板验收。

检查不付钱:static_assert 当家

零开销抽象这四个字,咱们一般拿体积和速度去验,但 ZerOS 里更彻底的一幕在检查体系:别的生态放在运行时做的事,这里整个搬进了编译器。咱们看几条真的断言,全在仓库里躺着:

// 非空指针包装:不许比裸指针贵一个字节
static_assert(sizeof(BorrowedPtr<int>) == sizeof(int*));
static_assert(std::is_trivially_copyable_v<BorrowedPtr<int>>); // 你是不是真的几乎跟原生指针等价?

// 侵入式链表:就两个指针,一个不多
static_assert(sizeof(SelfList<Probe>) == 2 * sizeof(Probe*)); // 你是不是真的嵌入式链表?

// 对硬件 ABI 的布局断言:Cortex-M3 异常栈帧就是 8 个字
static_assert(sizeof(ExceptionFrame) == 8 * sizeof(std::uint32_t)); // 你是不是立马就是 8 words 大小

// 队列容量卡 2 的幂:运行期取模才敢用位与
static_assert((Capacity & (Capacity - 1)) == 0, "power of two required on M3");

咱们算下来,这些断言一分钱运行时代价都没有,却把好几类事故提前到了编译期。BorrowedPtr 是内核里到处跑的非空指针包装,断言它跟裸指针同尺寸、可平凡拷贝,它才住得进任务控制块、坐得上寄存器传参,安全性不收税。ExceptionFrame 那条对的是硬件行为:异常自动压栈正好 8 个字,布局错一位,上下文切换直接跑飞。队列容量卡 2 的幂,运行期取模就敢用位与,M3 没有除法器,这条断言保住的是每次入队出队的周期。

咱们再看 bitmap.hpp 里更漂亮的一手:连单元测试都搬进了 static_assert,lambda 当场跑,编译器当测试框架,源码注释管这叫 free unit tests, zero runtime cost:

// —— Compile-time self checks: free unit tests, zero runtime cost ——
static_assert([] {
    Bitmap<5> b;                      // tail word carries 3 padding bits
    for (std::size_t i = 0; i < 5; ++i) b.set(i);
    return b.find_first_zero() == Bitmap<5>::npos;  // padding must never fake a hit
}());

static_assert([] {
    Bitmap<8> b;
    b.set(7);
    return b.find_first_set() == 7 && b.test(7) && !b.test(0);
}());

位图咱们知道,是调度器挑任务的底层数据结构,置位、找零、找一这些逻辑在编译器里先自证一轮,带着证明进固件。概念的用法也有一手:port 层实现完,拿断言直接验概念满足:

static_assert(ZerOS::irq::CriticalSection<CortexM3CriticalSection>);
static_assert(ZerOS::clock::TimePortable<CortexM3Portable>);

宿主合不合格,编译说了算,咱们不用等链接,不用等上板。检查体系这么铺下来,运行时还剩什么要防?剩的是真故障:栈溢出、HardFault,那些有金丝雀和遗言机制接着,不劳编译器操心。

没有异常,错误走哪条路

-fno-exceptions 一关,throw 成了禁词。C 的老办法是返回错误码,int 一把抓,调用方拿返回值去查表,查表和查日志一样全凭自觉。C++23 在裸机上给了咱们一条更体面的路:std::expected。留意年份,它 2023 年才进标准,ZerOS 把它用在了最要紧的位置。咱们看内存这一侧,静态池上怎么带出带类型的对象,就是开场笔者的那句,类型化的 Make

template<typename ObjectType, MemoryPool PoolStuff, typename... CreationArgs>
std::expected<ObjectType*, MemoryAllocationError> Make(PoolStuff& pool,
                                                       CreationArgs&&... args) {
    if constexpr (requires { PoolStuff::BLOCK_SIZE; PoolStuff::BLOCK_ALIGN; }) {
        static_assert(sizeof(ObjectType) <= PoolStuff::BLOCK_SIZE, "block overflow");
        static_assert(alignof(ObjectType) <= PoolStuff::BLOCK_ALIGN, "block under-aligned");
    }

    auto raw_buffer = pool.raw_allocate();
    if (!raw_buffer) {
        return std::unexpected {raw_buffer.error()};
    }
    return ::new (*raw_buffer) ObjectType(std::forward<CreationArgs>(args)...);
}

咱们拿这一个函数,把前面聊的家当全串起来了。MemoryPool 概念管住池的类型。static_assert 在编译期查两件事:对象装不装得进块、站得直不直。尺寸超了或对不齐,placement new 落下去就是救不回来的未定义行为,现在这种错误活不过编译。池满了,错误原样递出去。拿到了块,placement new 把对象初始化到指定的块上。

同步族那边同一个味道,消息队列收包:

enum class QueueError { Ok, Timeout, Full };

std::expected<Message, QueueError> receive_one() noexcept;
std::expected<Message, QueueError> receive_one(clock::Milliseconds timeout) noexcept;

咱们拿到手的值两头都说得清:成功,里面是消息本身;失败,里面是 QueueError 枚举,队列空、超时还是满,类型交代得一清二楚。等待时长是参数类型的一部分:想等 100ms 就传 Milliseconds{100},不想等有不带超时的版本。信号量、事件组、任务通知全走这一套,连 wait_notify 拿到的也是 expected<std::uint32_t, SyncError>。没有异常,错误处理也没有退回到查表时代。

返回错误码的接口全部标了 [[nodiscard]],装看不见,编译器会替咱们念叨。这类约束不花一个字的运行时代价,编译完就是普通的寄存器传值。

抽象不付钱,拿数字验

零开销抽象这句话被说得太滥,咱们拿数字验。arm-none-eabi-size 量 QEMU 版心跳示例:text 段 3540 字节。一个双任务、毫秒级睡眠、日志打印俱全的 demo,连同内核,塞进 3.5KB 出头的 Flash,仓库里最大的 demo 也就 6KB 上下。

再来看更直观的一组数字。ZerOS 的动态 tick 开启后,SysTick 不再每毫秒傻打一次,而是按下一个到期点投 one-shot。咱们把同一段"睡 500ms、醒四次"的示例在 Renode 里跑了两遍,串口输出原样贴在下面:

# ZEROS_TICKLESS 关(周期 1kHz 打点)
nap 1 woke @ 500ms
nap 2 woke @ 1000ms
nap 3 woke @ 1500ms
nap 4 woke @ 2000ms
periodic ticks: 2000
one-shot fires: 0

# ZEROS_TICKLESS 开(one-shot 按到期点投递)
nap 1 woke @ 500ms
nap 2 woke @ 1000ms
nap 3 woke @ 1500ms
nap 4 woke @ 2000ms
periodic ticks: 0
one-shot fires: 12

两秒的睡眠,周期模式打了两千次中断,tickless 模式只投了十二次 one-shot,中断次数降 99.4%。更值得咱们看的是醒来的时刻:500、1000、1500、2000,一毫秒没漂。省掉的是中断,保住的是节拍,时间服务按到期点排队,不靠心跳计数撑着。

哪些 C++23 刻意没用

说了用的,咱们再看没用的。笔者觉得这部分比"用了什么"更能说明问题。协程第一阶段明确不进:co_await 的挂起帧和任务栈、和中断生命周期的关系,在 RTOS 里想不清楚就先不碰。虚函数允许在 port 层做接口抽象,调度热路径上一个不许有——PendSV、SysTick 里多一次虚调用,就多一次拿不准的开销。std::function 在 target 构建里禁用,它有堆分配的前科(不知道有没有栈上小对象的优化)

说到底,咱们拿 C++23 做表达,不等于把特性全招呼上。真正的功夫是挑出那批能在 -fno-exceptions-fno-rtti-nostdlib 底下活下来的特性:概念约束、类型状态、expectedspan、编译期尺寸。热路径反而要保持朴素,静态、可数、可预测,调度器里每一分开销都数得出来。

真的是一个小玩具QAQ

ZerOS 的定位就是个玩具,教学向,笔者不打算拿它骗任何人说生产可用。但玩具也可以用工程的认真来做:调度是 32 级抢占加同级时间片轮转,互斥量带即时优先级继承。每个阶段都要真机加仿真双验收才算数。切换延迟拿 DWT 实测归档,六轮优化从 489 磨到 333 cycles,中间还翻过一次负优化的车。这些数字的故事,笔者下一篇专门写,咱们到时候接着看。

仓库在这儿:https://github.com/Charliechen114514/ZerOS ,与 Tutorial_AwesomeModernCPP 教程联动,咱们这篇聊到的一切都在里面。觉得有意思的朋友点个 star,想一起折腾的,Issues 和 PR 都开着。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐