Skip to content

第十二章:内核架构与扩展

内核的可扩展性取决于代码位置与保护域的安排。调度、文件和设备服务必须有人实现,但并非所有实现都必须放在同一个内核地址空间中。组件放在哪里,会改变调用方式、故障范围与状态管理成本。

组织方式新代码在哪里执行与其他组件的主要联系需要关注的边界
编入内核或加载模块内核保护域函数调用、共享内存对象代码错误可能破坏整个内核
用户态服务或驱动独立进程IPC、共享缓冲区、授权设备接口地址隔离之外还需约束 DMA 与资源
eBPF 程序受约束的内核执行环境hook、map、获准的调用接口验证器与运行接口允许的能力

xv6 采用静态链接的宏内核。以下从这条基线比较不同组织方式。各方案仍须完成原有 OS 职责,区别在于职责由谁承担、状态放在哪里、跨边界操作付出什么代价。

内核模块、微内核与用户态驱动

先比较保护域、调用方式与故障传播,再讨论加载和部署。

内核模块

Linux 等宏内核将调度、内存、网络、文件系统和多数驱动置于同一内核地址空间。子系统之间可以直接调用函数和共享数据,但任一组件的越界写都可能破坏全局状态。

可加载内核模块允许运行时加入驱动、文件系统等代码,但加载后的模块通常拥有内核级权限。签名、权限和版本接口能控制来源与兼容,不能把模块 bug 限制在模块内部。

模块化解决部署与代码组织问题,不提供独立的安全隔离。运行不完全可信逻辑需要额外保护机制。

.ko 本质上是带重定位和符号信息的 ELF。用户态通过 finit_module() 把模块 FD 交给内核;module loader 验证格式与签名策略、解析对 EXPORT_SYMBOL 的引用、完成 relocation、调整代码页权限,再调用模块的 init function。此后模块指令就在 CPL 0 和内核地址空间中执行。loader 检查的是“能否装入”,不会在每次模块访存时建立另一层边界。

Microkernel

microkernel 尽量只在最高权限保留地址空间、线程、IPC 和最小硬件机制,把文件系统、驱动、网络等服务放到隔离进程。组件崩溃可被重启,接口和故障边界更清楚。

代价是原本一次函数调用可能变成跨地址空间 IPC、调度和数据传递。现代硬件和精心设计能显著降低成本,但服务拆分还带来协议、生命周期与分布式状态复杂度。

实际系统常同时使用用户态驱动、内核模块、专用服务和共享内存 fast path。架构选择需要确定哪些组件应位于不同保护域,以及跨域通信可接受的性能成本。

用户态系统组件

将驱动放到用户态,可以用进程隔离、普通调试工具和独立升级;IOMMU 限制 DMA,内核只保留中断和资源授权。高性能框架甚至把设备队列映射给应用,绕过通用内核数据路径。

这可以提高特定工作负载性能,同时将调度、公平性、网络协议和设备复位职责转移给应用或用户态运行库。若每个服务都独占核心轮询,整机资源利用率和多租户治理能力可能下降。

eBPF

允许代码进入内核后,需要明确可验证性质、可访问对象和运行成本。

eBPF 允许程序附着到网络、跟踪和安全等内核事件。程序先经过 verifier 静态检查,再由解释器执行或 JIT 编译为本机指令,不能直接加载任意本地机器码。

加载路径有明确 ABI:用户态调用 bpf(BPF_PROG_LOAD, &attr, sizeof(attr))attr 提供 program type、BPF instructions、license 和可选 verifier log buffer。成功返回 program FD;随后通过 link、perf event、tc、XDP 等接口把该 FD 引用的程序附着到 hook。内核入口和命令定义分别可见 kernel/bpf/syscall.cinclude/uapi/linux/bpf.h

verifier 试图证明诸如:

  • 控制流可终止,执行量有界;
  • 寄存器和栈在使用前已初始化;
  • 指针类型、偏移和范围安全;
  • helper 调用和上下文访问被允许;
  • 对共享 map 和内核对象遵守引用规则。

通过验证后,程序可直接在高频内核事件处执行,避免将每个数据包或事件复制到用户态。该设计在加载阶段承担静态分析成本,以降低运行时 fast path 的跨边界开销。

verifier 不是只扫一遍指令。它沿控制流维护每个寄存器的类型、标量取值范围、pointer id、offset、reference state 和栈槽初始化状态;分支会产生不同抽象状态,相容状态可被剪枝。通过后,x86 JIT 才把 BPF instruction 翻译成本机指令。可从 verifier 文档 对照错误日志中的 register state。

verifier 的保证范围

验证器主要保证一组内核安全属性,不知道业务意图。一个通过验证的程序仍可能错误丢包、记录敏感数据或造成可观的 CPU 开销。验证器本身和 JIT 也是复杂可信计算基,历史上同样可能有漏洞。

“控制流可终止”也不再等于早期版本简单禁止所有循环。现代 verifier 允许能证明有界的循环,并通过 complexity limit 限制状态探索和最坏验证成本。具体限制随内核版本和程序类型变化。

可验证语言必须限制表达能力或要求可分析结构。循环界限、指针别名、动态内存与并发会迅速增加分析难度。eBPF 能做的事情不断扩展,但“能验证”始终是比“图灵完备”更实用的边界。

eBPF 能力接口

eBPF 程序通过 map 在不同事件之间保存状态或与用户态共享状态,并通过 helper 请求内核操作。程序不能调用任意内核函数;允许的 helper 集合定义了其能力边界。

map 更新仍涉及并发、NUMA、内存计费和生命周期。per-CPU map 减少缓存争用,但读取全局值需要聚合;LRU map 有淘汰行为;ring buffer 需要背压与丢失语义。

tail call 允许在有界次数内跳转到另一段程序,实现可组合管线。限制跳转深度用于约束最坏执行时间。

可观测性开销

高频 probe、栈采样和数据上报会消耗 CPU、内存与带宽,并可能改变竞态的时序。将每个事件输出到用户态还可能使观测流量超过业务流量,因此应优先在内核侧聚合,只导出所需统计。

观测程序还可能看到路径、地址、参数和网络数据,必须受权限与脱敏约束。可观测性是生产能力,也是额外攻击面。

内核架构的设计取舍

把设计选择放回执行位置、对象权限、生命周期与通信成本。

无论模块、用户态服务、eBPF 还是专用虚拟机,都可以问:

  1. 代码在哪个保护域执行? 崩溃会带走谁?
  2. 能访问哪些对象? 能力是白名单还是默认全局?
  3. 最坏运行多久? 能否抢占、取消或限额?
  4. 状态如何升级和回收? 旧版本是否仍有引用?
  5. 数据跨边界几次? 是否值得为性能扩大信任?

这些问题用于比较不同扩展机制的边界和成本。扩展机制的设计目标是选择适当边界,而非消除所有边界。

实验

bash
# 已加载模块与 eBPF 能力取决于系统权限
lsmod | head
bpftool prog list 2>/dev/null
bpftool map list 2>/dev/null

# 若有 bpftrace,聚合系统调用次数而非输出每个事件
bpftrace -e 'tracepoint:raw_syscalls:sys_enter { @[comm] = count(); }'

运行观测工具时,应同时使用 perf stat 或系统监控测量工具本身的开销,避免将观测开销误归因于目标程序。

练习

先回答前两题,再用实现细节核对后面的题目。

  1. 把驱动移到用户进程后,哪些故障可以被地址空间隔离,哪些仍需要 DMA、资源额度或设备复位协议处理?

  2. 模块加载成功与 eBPF 验证通过分别说明什么?它们能否证明代码满足业务需求?

  3. 可加载模块为什么解决了部署,却没有提供故障隔离?

  4. microkernel 把直接调用换成了哪些新成本?

  5. eBPF verifier 证明的是哪些安全性质,而不是哪些业务性质?

  6. helper 列表为什么可以看作 capability 边界?

  7. 将驱动搬到用户态后,哪些 OS 问题并没有消失?

上次更新: