Skip to content

第十二章:内核可扩展性

xv6 是静态链接的宏内核。增加系统调用、文件系统代码或驱动后,需要重新编译整个内核;新增代码与原内核处于同一 S-mode 地址空间。这给出一条清楚的基线:扩展最直接的办法就是把更多可信代码编进内核。

系统调用允许用户程序请求内核预先实现的服务。新的文件系统、驱动、网络策略、性能观测和安全审计需求则要求内核具备扩展能力。直接修改内核会增加部署成本;让任意插件以内核权限运行又会扩大故障范围。

扩展机制需要规定代码所在的保护域、可访问对象、执行时间上限和故障影响范围。

内核模块

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 能力接口

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. 可加载模块为什么解决了部署,却没有提供故障隔离?
  2. microkernel 把直接调用换成了哪些新成本?
  3. eBPF verifier 证明的是哪些安全性质,而不是哪些业务性质?
  4. helper 列表为什么可以看作 capability 边界?
  5. 将驱动搬到用户态后,哪些 OS 问题并没有消失?

上一章:虚拟机 · 下一章:全路径安全 →