操作系统基础 | 3.3 系统调用实现

UAPI 文件位置

问题背景:
内核头文件中,内联函数往往需要引用其他头文件的结构体或常量,但这些头文件之间又存在相互依赖,导致无法直接引用,只能用 #define 代替,降低了代码质量。

解决方案:
David 提出将内核头文件中的用户空间 API 内容(即用户空间可见的定义)拆分到新的 uapi/ 子目录下的对应头文件中。这样做有以下好处:

  • 简化内核专用头文件,减少体积。
  • 明确区分用户空间与内核空间的 API,减少头文件间复杂的相互依赖。
  • 便于追踪用户空间 API 的变更,方便 C 库维护者、脚本语言绑定、测试、文档等相关项目。

拆分方法:
一般头文件结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* 头部注释 */

#ifndef _XXXXXX_H
#define _XXXXXX_H

[用户空间定义]

#ifdef __KERNEL__

[内核空间定义]

#endif /* __KERNEL__ */

[用户空间定义]

#endif /* _XXXXXX_H */
  • 所有未被 #ifdef __KERNEL__ 包裹的内容,移动到 uapi/ 目录下的新头文件。
  • #ifdef __KERNEL__ 内的内容保留在原头文件,但移除 #ifdef#endif
  • 头部注释保留并复制到新文件。
  • 原头文件需添加 #include <include/uapi/path/to/header.h>,放在已有 #include 之后。
  • 若原文件没有 #ifdef __KERNEL__,则直接重命名为 uapi/ 文件。

技术实现要点

  • 头文件结构调整:将所有未被 #ifdef __KERNEL__ 包裹的内容迁移到 uapi/ 目录下的新头文件,原文件只保留内核专用部分,并通过 #include 引用新的 uapi 头文件。
  • 自动化脚本辅助:由于头文件风格多样,David Howell 编写了大量 shell/Perl 脚本自动完成拆分,并对特殊情况通过预处理标记进行“辅导”。
  • 兼容性与构建保证:拆分后,内核和用户空间的构建流程保持兼容,确保包含关系和功能不变。

社区讨论与挑战

  • 大规模变更的审查难题:一次性修改数千文件、数十万行代码,难以人工逐行审查。社区建议主要审查思路和脚本实现,并通过自动化构建和对比二进制产物来验证正确性。
  • 头文件包含路径的争议:有开发者质疑 uapi 头文件是否应包含内核头文件,实际实现中为兼容性和构建需要,uapi 头文件在内核构建时会包含内核头文件,但用户空间只见到 uapi 头文件。
  • 隐式依赖与编译优化:有建议借此机会清理隐式包含、优化编译速度,但这属于更大范围的重构,超出了本次拆分的目标。
  • 自动化工具的局限:与 Java 等语言不同,C 语言的预处理器和头文件机制更为复杂,自动化脚本难以覆盖所有边界情况,仍需人工干预和后续维护。

系统调用源码指引(以 ARM 架构为例)

系统调用的实现高度依赖于具体架构,包括调用方式和可用的系统调用种类。以下是 ARM 架构下与系统调用密切相关的核心源码文件:

  • include/linux/syscalls.h
    提供所有内核系统调用的架构无关的前向声明。该文件定义了内核内部调用系统调用函数的接口。

  • arch/arm/include/uapi/asm/unistd.h
    定义了 ARM 架构下系统调用号的相关内容。

  • arch/arm/kernel/entry-common.S
    提供 ARM 架构下系统调用的入口汇编实现。

  • arch/arm/tools/syscall.tbl
    负责将系统调用号及其对应的函数地址注册到 ARM 硬件的系统调用表中。

此外,以下文件虽然与系统调用无关,但涉及系统启动(这是内核执行的另一种方式):

  • init 目录
    包含内核初始化相关代码。

  • init/main.c
    提供了 start_kernel 函数,这是 Linux 内核启动后执行的第一个 C 语言函数,且一旦调用永不返回。在此之前,内核仅通过架构相关的汇编代码和固件运行。
    (参考:《Linux 设备驱动》第16章对 main.c 和 start_kernel 的介绍)