Skip to content

11.1 Linux 兼容层架构

FreeBSD 的 Linux 兼容层常被误认为虚拟机。

该兼容层不会引入显著的性能开销,某些场景下部分软件的性能甚至超过原生 Linux 环境。该兼容层既非指令集模拟器,也非二进制转译层,而是 Linux 应用程序二进制接口(Application Binary Interface,ABI)的系统级实现。

11.1.1 何为 Linuxulator

FreeBSD 对 Linux 应用程序的兼容机制依赖于 Linuxulator 这一核心组件。“Linuxulator”的字面含义为“Linux Emulator”,该名称容易与传统指令集模拟器相混淆。Linuxulator 并非传统模拟器,也不是独立的 FreeBSD 用户空间程序,仅为 FreeBSD 官方文档中对某一特定内核模块的非正式称谓,该内核模块的正式标识符为 linux

Linuxulator 与 WSL2 的虚拟机方案和 Wine 的二进制兼容层解释机制截然不同。

具体而言,其核心原理如下:FreeBSD 内核能够识别并拦截 Linux 进程的系统调用请求,将其映射到功能等价的 FreeBSD 系统调用,并以 FreeBSD 内核的实现响应这些请求。

技巧

换言之,Linuxulator 利用 FreeBSD 内核的系统调用来处理 Linux 进程的系统调用。

通过 Linuxulator 模块,FreeBSD 内核在系统调用接口上模拟 Linux 内核行为,但实际进程调度和执行仍由 FreeBSD 内核负责;处理系统调用的代码同样为 FreeBSD 原生实现。

通过 Linuxulator 运行的 Linux 进程在 FreeBSD 内核中按标准 FreeBSD 进程处理,与原生进程无异。

11.1.2 什么是 Linux 兼容层

FreeBSD 系统中不存在真实的 Linux 内核,FreeBSD 所声明的 Linux 内核版本号仅具有标识意义,而不具备实际的内核功能约束;该版本号甚至可设置为任意值,例如 255.255

不同的 Linux 软件对内核版本的最低要求不同,其依赖和使用的系统调用接口也存在差异。例如,声明的 Linux 内核版本过低时,Arch Linux 的 chroot 环境可能无法正常初始化,并返回 kernel too old 的错误信息。

下文据 Terry Lambert(tlambert@primenet.com)发往 FreeBSD 邮件列表的邮件(邮件 ID:199906020108.SAA07001@usr09.primenet.com)整理而成,介绍 Linux 二进制兼容层的工作原理。原文可能已经佚失,本节进行了适当增补更新。

sh
用户执行命令 (shell)
         |
         v
    +------------+
    | execve(2)  |
    +------------+
         |
         v
  +------------------------+
  | 检查文件幻数 Magic Number |
  +------------------------+
         |
         v
调用对应加载器 (ELF, a.out, PE...)
         |
         v
检查 ELF Note Brand
         |
         v
Linux Brand
         |
         v
Linux ABI Loader
         |
         v
线程 PCB / syscall context 切换到 Linux 系统调用表
 sys/amd64/linux/linux_sysent.c
         |
         v
查找依赖二进制 /compat/linux/... -> fallback /...
         |
         v
执行 Linux 二进制程序

FreeBSD 拥有抽象层“可执行类加载器(execution class loader)”,该抽象层直接嵌入在系统调用 execve(2) 的处理逻辑中。

传统上,UNIX 加载器依赖检查幻数(Magic Number)来判断文件格式。若无法匹配任何已知的二进制格式(如 ELF 或现已过时的 a.out),内核将返回 ENOEXEC 错误。此时,发起调用的 Shell 将接管该文件,并尝试将其作为该 Shell 类型的脚本进行解释执行。

思考题

sh
$ file 701.pdf
701.pdf: PDF document, version 1.4
$ hexdump -C -n 32 701.pdf
00000000 25 50 44 46 2d 31 2e 34 0d 0a 25 a1 b3 c5 d7 0d |%PDF-1.4..%.....|
00000010 0a 34 31 20 30 20 6f 62 6a 0d 0a 3c 3c 2f 44 65 |.41 0 obj..<</De|
00000020

结合上述信息,阅读源代码 /contrib/file/magic/Magdir/pdf,请思考 file 命令的工作原理。

sys/sys/elf_common.h 示例片段,判断是否属于 ELF 文件:

c
#define	ELFMAG0		0x7f
#define	ELFMAG1		'E'
#define	ELFMAG2		'L'
#define	ELFMAG3		'F'
#define	ELFMAG		"\177ELF"	/* ELF 幻数字符串 */
#define	SELFMAG		4		/* ELF 幻数字符串大小 */

FreeBSD 并非硬编码单一加载器,而是维护了一个加载器链表(execsw)。系统会按序尝试不同的加载器,包括用于处理脚本的 #!(Shebang)加载器。这意味着在现代 FreeBSD 中,绝大多数脚本的解析路径在内核态即已完成,而非依赖 Shell 的报错回退。

为了支持 Linux ABI,FreeBSD 的 ELF 加载器在识别出标准 ELF 幻数后(通过 sys/sys/elf_common.h 中定义的幻数),会进一步校验 ELF Note 段中的专用 Brand 标记。由于 Linux 与原生 FreeBSD 程序共享相同的 ELF 基础格式,这一标记是区分 ABI 目标的决定性特征(SVR4/Solaris ELF 则不具备此类标记)。

sys/compat/linux/linux_elf.c 示例片段,用于进一步判断该 ELF 是否是 Linux 程序。

c
bool
linux_trans_osrel(const Elf_Note *note, int32_t *osrel)
{
    const Elf32_Word *desc;   // 指向 note 描述段的数据指针
    uintptr_t p;               // 用于计算描述段起始位置的临时指针

    // note + 1 指向 note 结构之后的位置(通常是 name 字段的起始)
    p = (uintptr_t)(note + 1);
    // 跳过 name 字段,按 Elf32_Addr 对齐
    p += roundup2(note->n_namesz, sizeof(Elf32_Addr));

    // desc 指向实际存放版本信息的描述段
    desc = (const Elf32_Word *)p;

    // 检查描述段第一个元素是否为 Linux ABI 标识
    if (desc[0] != GNU_ABI_LINUX)
        return (false);

    /*
     * 对于 Linux,我们使用以下方式编码操作系统版本号:
     *  (version << 16) | (major << 8) | minor
     * 具体宏定义在 linux_mib.h 中
     */
    *osrel = LINUX_KERNVER(desc[1], desc[2], desc[3]);

    return (true);  // 成功解析
}

sys/compat/linux/linux_mib.h 示例:

c
#define LINUX_KVERSION       5       // 内核主版本号(version),这里表示 Linux 内核主版本为 5
#define LINUX_KPATCHLEVEL    15      // 内核次版本号(major),这里表示次版本为 15
#define LINUX_KSUBLEVEL      0       // 内核修订号(minor),这里表示修订号为 0

// 将 Linux 内核版本号编码为单个整数
// 公式:高 16 位放 version,中间 8 位放 major,低 8 位放 minor
// 例如:LINUX_KERNVER(5,15,0) => (5 << 16) + (15 << 8) + 0 = 0x00050F00
#define LINUX_KERNVER(a,b,c) (((a) << 16) + ((b) << 8) + (c))

在确认 Linux Brand 后,加载器会通过修改进程执行上下文中的 sysentvec 指针,将默认的系统调用表切换为 Linux ABI 系统调用表。此后,该进程发起的所有系统调用均通过此表索引。相关的信号跳板(Signal Trampoline)和陷阱向量也随之切换。该系统调用表由内核模块提供,对于 amd64,其条目由 sys/amd64/linux/linux_sysent.c 生成,负责将 Linux 系统调用号映射至相应的内核包装函数(Wrappers)。

sys/compat/linux/linux_util.c 对默认根路径的定义代码:

c
char linux_emul_path[MAXPATHLEN] = "/compat/linux";
// 定义字符串变量,存储 Linux 兼容环境的路径,初始值为 "/compat/linux"

SYSCTL_STRING(_compat_linux, OID_AUTO, emul_path, CTLFLAG_RWTUN,
    linux_emul_path, sizeof(linux_emul_path),
    "Linux runtime environment path");
// 注册 sysctl 节点,使该路径可以通过 sysctl 查看或修改
// 参数解释:
// _compat_linux      -> sysctl 所在的父节点
// OID_AUTO           -> 系统自动分配一个 OID
// emul_path          -> sysctl 名称
// CTLFLAG_RWTUN      -> 可读写,且可以在系统启动时通过 tunable 设置
// linux_emul_path    -> 绑定的变量
// sizeof(linux_emul_path) -> 变量长度
// "Linux runtime environment path" -> 描述信息

上述代码实际上是对 sysctl compat.linux.emul_path 的默认定义。

在文件系统层面,Linux 兼容层实现了一套备用根路径重定向机制。当 Linux 进程请求路径查找时,系统会优先尝试在 /compat/linux/ 路径下定位文件;若未命中,则回退至宿主系统的原生路径。这使得 Linux 程序能无缝加载其专有的共享库,同时在必要时仍能访问 FreeBSD 的系统资源。结合 sysctl compat.linux.osname(默认值“Linux”)与 compat.linux.osrelease,再通过在 /compat/linux 中提供定制的 uname(1) 等工具,环境伪装得以完整实现。

sys/compat/linux/linux_util.c 对备用根目录的设置代码:

c
int
linux_pwd_onexec(struct thread *td)
{
    struct nameidata nd;  // 用于描述文件查找操作的数据结构
    int error;            // 存储函数调用返回的错误码

    NDINIT(&nd, LOOKUP, FOLLOW, UIO_SYSSPACE, linux_emul_path);  // 初始化 nd 查找 linux_emul_path
    error = namei(&nd);  // 执行查找
    if (error != 0) {
        pwd_altroot(td, NULL);  // 查找失败,调用 pwd_altroot 设置为 NULL
        return (0);
    }
    NDFREE_PNBUF(&nd);          // 释放 nd 中路径缓冲
    pwd_altroot(td, nd.ni_vp);  // 查找成功,将 ni_vp 传给 pwd_altroot
    vrele(nd.ni_vp);            // 释放 ni_vp 引用
    return (0);
}

sys/sys/namei.h 定义的部分回退机制:

c
struct nameidata {
    // …省略一部分…

    /*
     * Arguments to lookup.
     */
    struct vnode *ni_startdir;  /* 起始目录 */
    struct vnode *ni_rootdir;   /* 逻辑根目录 */
    struct vnode *ni_topdir;    /* 逻辑顶目录 */
    int ni_dirfd;               /* *at 函数使用的起始目录文件描述符 */
    int ni_lcf;                 /* 本地调用标志 */

    // …省略一部分…
};

// …省略一部分…

#define namei_setup_rootdir(ndp, cnp, pwd) do {                      \
    if (__predict_true((cnp->cn_flags & ISRESTARTED) == 0))          \
        ndp->ni_rootdir = pwd->pwd_adir;  /* 如果不是重启调用,使用 pwd_adir 作为根目录 */ \
    else                                                               \
        ndp->ni_rootdir = pwd->pwd_rdir;  /* 否则使用 pwd_rdir 作为根目录 */ \
} while (0)
#endif

// …省略一部分…

FreeBSD 内核为 Linux ABI 提供了原生级的支持。绝大多数系统调用(如 VFS、VM、IPC 操作)在底层直接共享 FreeBSD 的内核实现。两者的差异仅在于系统调用边界的参数重组(Argument Marshaling):FreeBSD 程序使用原生胶水函数,而 Linux 程序通过兼容层包装器接入。

严格来说,这是一种系统调用级的 ABI 翻译实现,而非指令集层面的“仿真”。CPU 直接执行 Linux 二进制文件的原生机器码,不存在中间指令翻译损耗。早期文献使用“仿真”一词,更多是受限于当时的技术术语体系,而非对其技术本质的准确描述。

11.1.3 为什么使用 Linux 兼容层并非“苦难哲学”

通过 kldload linux 加载模块的做法,不应受到质疑,正如荀子所言:“君子生非异也,善假于物也。”

使用 Linux 兼容层也是如此。类似的技术包括 Linux 上使用 Wine 或 CrossOver,乃至 ReactOS;以及 Windows 平台上的 Linux 兼容层和 Android 兼容层,这些方案都已得到广泛应用。

11.1.4 参考文献

11.1.5 课后习题

  1. 阅读 FreeBSD 源代码中 sys/compat/linux/ 目录下的 linux_file.c 文件,分析 Linux 系统调用到 FreeBSD 系统调用的映射机制,选取 3 个关键系统调用追踪其处理流程。

  2. 修改 compat.linux.osrelease 为 2 个不同的 Linux 内核版本号,测试不同版本号下 Linux 软件的兼容性表现。