Files

8.7 KiB
Raw Permalink Blame History

Unikraft / MirageOS:库操作系统与 unikernel

这一族的模型是:
应用 + 被选中的 OS 库 + 驱动 + 运行时
        → 链接成单一 image / 单一地址空间

于是:

不要硬找 "user → syscall → kernel process" 边界

"系统调用"可能只是函数调用,"内核"可能就在同一个保护域里。

第二类坑:同一份应用代码可以构建成完全不同的目标,运行时的 I/O、boot、设备模型、地址结构随之全变——看到同一个应用名,不能假定 binary/运行时架构相同。 </CORE RULE>

一、Unikraft:库操作系统

  • 把 OS 功能拆成细粒度微库,用 Kconfig 式构建系统选择,与应用链接成单一二进制;支持 POSIX 兼容
  • 单地址空间、单一保护域,组件之间没有保护边界;内核函数可以被直接执行——专门的调用机制使进入"内核"不需要 trap/中断/特权级切换
  • 调度是可选的:可以构建单线程或 run-to-completion 的 unikernel

两条构建路线(区别对 RE 很关键):

路线 做法 结果
Native(API 兼容) 应用源码与 Unikraft 一起编译链接;系统调用经 shim 微库解析为处理函数 运行期 shim"让开",syscall 实际变成廉价函数调用
Binary-compatible(ABI 兼容) 直接运行未修改的 Linux ELF,无需重编译 系统调用被捕获并经 shim 重定向到各子系统;缺的 syscall 默认落到 ENOSYS 桩
  • 二进制兼容路线需要:ELF 格式、二进制兼容的函数接口与数据表示、Linux 系统调用、以及兼容的运行时环境
  • PIE 与否影响地址空间布局:非 PIE 可执行文件决定布局并限制为单个非 PIE 应用;PIE 提供布局灵活性、允许单地址空间内多应用、且上下文切换时不需要切换地址空间

RE 含义:

  • 看到"系统调用"别自动假设特权级切换——同一保护域里可能只是函数调用
  • 看到"内核函数"与"应用函数"混在一起也正常
  • 先判是 native 还是 binary-compatible 构建:两者的调用路径不同

二、MirageOS:把配置当接线图

  • 用 OCaml 编写,构建成独立的 unikernel;同一份源码可构建成多种目标:Unix 进程(本地开发)、Xen(PVH 模式)、Solo5 系列(hvt 硬件虚拟化、spt 严格 seccomp 沙箱、virtio、muen、genode)、Unikraft 目标等
  • 构建流程是"先写配置清单,再生成构建":
    1. 写配置描述组件(接线图,不是应用本身)——这里表达的是"应用 functor 的类型"与"把哪个具体实现插进去"
    2. 按目标执行配置,生成 Makefile/Dune 文件、主胶水代码与按目标区分的包描述
    3. 构建
  • 同一份源码换目标,差别只在一个被替换的模块(平台主模块)+ 目标对应的包集合;应用代码不变

关键推论:

源码里没有明确的 "new Driver()"
但 runtime 里出现了设备
  → 不奇怪:设备接线由构建期配置决定
  • 设备驱动按目标打包(网络、控制台、块设备、时钟各有对应包);某些目标支持多网络/多块设备,另一些限于单个
  • 代码跨目标的行为差异(I/O、boot、设备模型、地址结构)来自这一层,而不是应用逻辑

三、Unikraft 的启动表与 syscall 符号

多级初始化表(这是"0 xref 却在启动时执行"的正解):

表 阶段 说明
早期表 最早 在分配器可用之前,处理 bootinfo
构造表 紧随其后 同样在分配器之前,库级构造函数
初始化表 之后 调度器可用之后的标准库初始化

初始化层级(按序):构造 → 早期 → 平台 → 库 → rootfs → 系统 → 晚期;每级内有 10 个优先级(0 最高),条目放在按名字排序的专用链接段里,由起止符号界定。

所以:init 函数 0 xref 不等于 dead code。

syscall shim 的多个符号:同一操作往往同时存在 libc 风格与 raw-error 风格的包装(外加 shim 本体)——不要判"三份重复实现",它们是 ABI 包装层。

native 构建里没有特权转换:syscall 样式的 API 直接落进库实现(例如最终走到 VFS 写路径),write() → 普通调用 完全可能。

binary-compatible 构建里真的会出现 Linux syscall ABI:所以要先用"native 链接还是加载 Linux ELF"分流——两者不能共用同一套 CFG 心智模型。

缺 syscall 返回 ENOSYS 而应用照常工作:文档明确指出这种情况存在——不少应用会调用自己实际不依赖的 syscall。"syscall 缺失"不能直接判"应用不兼容"。

四、MirageOS 的 Lwt 与目标指纹

  • 没有 OS 线程却像有几百个"线程":Mirage 大量使用 Lwt——协作式轻量线程 + 事件循环,不是抢占式原生线程
  • 一个函数进入死循环会让整个 unikernel 像死了一样:Lwt 线程会一直执行直到主动让出;CPU 密集路径不让出就能饿死同一事件循环上的全部任务——不需要互斥量,也不需要内核死锁:
所有网络定时器停 / 所有异步任务停 / CPU 100%
  → 先查有没有协作式让出
  • 后台任务失败但主程序行为奇怪:Lwt 的后台异常交给 async 异常处理器,是否终止 unikernel 取决于该处理器的配置——既不是必然崩溃,也不是必然被忽略;必须恢复 async 异常策略
  • 目标指纹:不同目标的隔离机制完全不同(硬件虚拟化 / 沙箱进程 / virtio 兼容 hypervisor / Xen PVHv2)——"这是 MirageOS unikernel"不足以决定你该找什么(VM-exit?Linux syscall?Xen grant?virtio 队列?),必须先确定目标

五、这一族的共同 RE 策略

  1. 先确认构建目标/构建类型(native vs binary;Unix vs hvt vs Xen…)——它决定 syscall 路径、地址布局与设备模型
  2. 不要假设存在特权边界:单一地址空间是设计
  3. 设备的出现与接线来自构建期配置,不在应用源码里
  4. 现象"应用跑得好好的但找不到它调用内核的痕迹"——在 native 构建里是正常的

决策树

拿到 unikernel 目标
├─ 先判构建类型/目标
│    ├─ Unikraft:native(源码编译)还是 binary-compatible(跑 Linux ELF)
│    ├─ MirageOS:unix / xen / hvt / spt / virtio / …
│    └─ 目标决定 I/O、boot、设备模型、地址布局
├─ 找不到 syscall 的 trap/特权切换
│    └─ 单保护域下可能就是函数调用(native 构建)
├─ 设备的接线找不到来源
│    └─ 来自构建期配置(配置清单/DSL),不是应用源码
├─ 地址布局相关
│    └─ 非 PIE 决定布局且限单应用;PIE 允许单地址空间内多应用
└─ 同一个应用名、行为不同
     └─ 别假定 binary/运行时架构相同,先确认目标

工具与验证

  • 构建类型:构建系统的配置项、是否有 ELF 加载器(binary-compatible 路线)、包集合
  • 调用路径:shim 微库的使用点;native 路线的"让开"行为
  • 设备接线:构建期配置清单/DSL 与生成的主胶水代码
  • 验证:能同时说清「这是哪种构建/哪个目标 + 调用进入内核走的是函数调用还是捕获 + 设备接线来自哪份配置」

该平台的坑(汇总)

  • 硬找 user/kernel 边界:单地址空间、单保护域是设计
  • 把"syscall"默认当特权级切换:native 构建下可能只是函数调用
  • 假定同一应用名对应同一运行时架构:构建目标决定一切
  • 在应用源码里找设备初始化:接线来自构建期配置
  • 忽略 PIE 与否对地址布局的限制:非 PIE 决定布局并限单应用
  • 忽略 binary-compatible 路线的桩语义:缺失的系统调用可能落到 ENOSYS 桩,行为与会返回错误不同
  • 按"动态库/动态链接器"模型理解 image:这一族通常是静态整链
  • 把 0 xref 的启动函数当死代码:多级初始化表通过专用链接段注册
  • 把 shim 的多个符号当重复实现:libc 风格 / raw-error 风格 / shim 本体是三层包装
  • 把 ENOSYS 当"应用不兼容":不少应用会调用自己并不依赖的 syscall
  • 把"所有任务一起卡死"当内核死锁:协作式线程不让出就会饿死整个事件循环
  • 把后台任务异常当必然崩溃或必然被忽略:取决于 async 异常处理策略
  • 不确认目标就去找 VM-exit / syscall:不同构建目标的隔离机制完全不同