x86 平台使用 Gitea Actions 构建多架构应用 (binfmt_misc)

binfmt_misc 简介

binfmt_misc 是 Linux 内核提供的一个机制,它允许用户空间定义新的二进制格式,并将它们与相应的解释器关联起来。这个机制使得在 Linux 上能够动态地注册并运行不同架构的二进制可执行文件,从而支持交叉编译和多架构环境。

具体来说,binfmt_misc 的功能可以通过 /proc/sys/fs/binfmt_misc/ 目录下的文件系统接口实现。这个目录下的文件用于注册和管理二进制格式和相应解释器之间的关联关系。

下面是一些与 binfmt_misc 相关的重要概念和文件:

  1. 注册表文件:/proc/sys/fs/binfmt_misc/ 目录下,每个注册的二进制格式都有一个对应的注册表文件。这些文件的命名通常遵循格式 <格式名称>,例如 qemu-riscv64
  2. 注册和注销: 用户空间可以通过在注册表目录下创建文件来注册新的二进制格式。这可以通过写入注册表文件的方式完成。相反,通过删除这些文件,可以注销二进制格式的支持。
  3. 解释器: 对于每种注册的二进制格式,需要指定相应的解释器,即用于执行这种格式的程序。在注册表文件中,通过 interpreter 字段指定解释器的路径。
  4. 参数: 除了解释器,还可以为每个注册的格式指定一些参数。这些参数可以影响如何运行二进制文件。

内核如何通过 binfmt_misc 机制添加新架构的支持?

可以通过向 /proc/sys/fs/binfmt_misc/register 文件写入注册信息来注册新的二进制格式。告诉内核某一格式的文件用什么解释器来执行。写入的格式如下:

:name:type:offset:magic:mask:interpreter:flags

各个字段以冒号分隔,部分字段可以缺省,但是冒号需要保留。

字段含义如下:

  • name:二进制格式的名称,比如qemu-riscv64

  • type:类型为 E 或 M。

    • 如果是 E,可执行文件格式由其文件扩展名标识:magic 是要与二进制格式相关联的文件扩展名;offset 和 mask 将被忽略。
    • 如果是 M,格式由文件中绝对偏移(默认为 0)处的魔数标识,并且 mask 是一个位掩码(默认为全 0xFF),表示数字中哪些位是有效的。
  • interpreter:是要作为匹配文件的参数运行的解释器,使用解释器的绝对路径,比如/usr/bin/qemu-riscv64-static

  • flags:可选字段,控制 interpreter 打开文件的行为。共支持 POCF 四种 flag。

    • P 表示 preserve-argv[0],保留原始的 argv[0] 参数。
    • O 表示 open-binary,binfmt-misc 默认会传递文件的路径,而启用这个参数时,binfmt-misc 会打开文件,传递文件描述符。
    • C 表示 credentials,即会传递文件的 setuid 等权限,这个选项也隐含了 O
    • F 表示 fix binary,binfmt-misc 默认的行为在 spwan 进程时会延迟,这种方式可能会受到 mount 命名空间和 chroot 的影响,设置 F 时会立刻打开二进制文件。

举个例子,如果要在 x86_64 架构的系统上运行 RISC-V 架构的二进制文件,可以通过以下方式注册 RISC-V 二进制格式:

首先需要添加解释器,通常使用 QEMU 的静态二进制文件作为解释器,在 Ubuntu 系统中我们可以使用以下命令安装:

sudo apt install qemu-user-static

注册二进制格式

echo ':qemu-riscv64:M:0:7f454c460201010000000000000000000200f300::/usr/libexec/qemu-binfmt/riscv64-binfmt-P:POCF' > /proc/sys/fs/binfmt_misc/register

表示将 RISC-V 二进制格式注册到 /proc/sys/fs/binfmt_misc/qemu-riscv64 文件中,使用 /usr/libexec/qemu-binfmt/riscv64-binfmt-P 作为解释器,同时传递 POCF 参数。执行了以上命令,内核会自动创建一个 /proc/sys/fs/binfmt_misc/qemu-riscv64 文件,内容如下:

$ sudo cat  /proc/sys/fs/binfmt_misc/qemu-riscv64
enabled
interpreter /usr/libexec/qemu-binfmt/riscv64-binfmt-P
flags: POCF
offset 0
magic 7f454c460201010000000000000000000200f300
mask ffffffffffffff00fffffffffffffffffeffffff

这就完成了 RISC-V 二进制格式的注册。此时,你就可以在 x86_64 架构的系统上运行 RISC-V 架构的二进制文件了。

使用 Docker 运行 RISC-V 的容器:

$ docker run --rm -it devops/openeuler-builder:23.09-riscv64  uname -m
riscv64

使用封装好的程序简化注册过程

以上的写入 register 文件的方式比较繁琐,可以使用封装好的程序来简化注册过程。

方式一:

sudo apt install qemu-user-binfmt

可以安装所有 QEMU 支持的架构。

方式二:

# 安装解释器
sudo apt install qemu-user-static
# 安装binfmt操作支持
sudo apt install binfmt-support
# 开启异构支持
sudo update-binfmts --package=qemu-user-static --enable

同上。

注销

echo -1 >/proc/sys/fs/binfmt_misc/status # 注销所有注册的条目
echo -1 >/proc/sys/fs/binfmt_misc/qemu-riscv64 # 注销单个条目

或者通过命令行工具完成:

# 安装binfmt操作支持
sudo apt install binfmt-support
# 禁用qemu-riscv64,再次查看/proc/sys/fs/binfmt_misc/发现qemu-riscv64已被删除
sudo update-binfmts --disable qemu-riscv64

Gitea 如何实现多架构应用构建?

Gitea 不会自己运行 Job,而是将 Job 委托给 Runner。Gitea Actions 的 Runner 被称为 act runner,它是一个独立的程序。在接收到 Job 后,act runner 会根据 Job 的内容,启用不同的 Container 来运行 Job。

为了避免消耗过多资源并影响 Gitea 实例,Gitea 和 Runner 一般运行在不同的机器上。但是同一个 Runner 启动的容器一定在同一台机器上。我这里演示的统一都在同一台 x86 架构的机器上。

因为都运行在 x86 架构的机器上,所有执行任务的 Container 也一定是 x86 架构的。但是我们了解了上面的 binfmt_misc 机制后,就可以在容器内部通过注册不同架构的二进制格式,从而在 x86 架构的机器上运行不同架构的二进制文件。就可以实现多架构应用构建。

打开 Gitea 的 tea 的项目源码找到它的 release workflow 文件,可以看到它使用了docker/setup-qemu-action@v3这个 action 来实现多架构构建。

- name: Set up QEMU
uses: docker/setup-qemu-action@v3

查阅action 的源码,可以发现底层实现是通过binfmt_misc来实现的。

func init() {
    // 定义了一些全局变量
	flag.StringVar(&mount, "mount", "/proc/sys/fs/binfmt_misc", "binfmt_misc mount point")
	flag.StringVar(&toInstall, "install", "", "architectures to install")
	flag.StringVar(&toUninstall, "uninstall", "", "architectures to uninstall")
	flag.BoolVar(&flVersion, "version", false, "display version")
}
func install(arch string) error {
	cfg, ok := configs[arch]
    // 拼接路径为/proc/sys/fs/binfmt_misc/register,打开这个文件检查是否能够打开成功
	register := filepath.Join(mount, "register")
	file, err := os.OpenFile(register, os.O_WRONLY, 0)

	binaryBasename, binaryFullpath, err := getBinaryNames(cfg)
	if err != nil {
		return err
	}
    // 向/proc/sys/fs/binfmt_misc/register 写入 line,注册二进制格式
	line := fmt.Sprintf(":%s:M:0:%s:%s:%s:%s", binaryBasename, cfg.magic, cfg.mask, binaryFullpath, flags)
	_, err = file.Write([]byte(line))
	if err != nil {
		e, ok := err.(*os.PathError)
		if ok && e.Err == syscall.EEXIST {
			return errors.Errorf("%s already registered", binaryBasename)
		}
		return errors.Errorf("cannot register %q to %s: %s", binaryFullpath, register, err)
	}
	return nil
}

Q & A

为何/proc/sys/fs/binfmt_misc/register 文件是只读的?

/proc/sys/fs/binfmt_misc/register 文件是只写的,这是因为在 Linux 中,/proc 文件系统下的很多文件都是通过对文件进行写入来进行配置和控制的。这些文件通常代表内核参数或控制接口,提供了一种方便的方式来与内核进行交互。

对于 /proc/sys/fs/binfmt_misc/register 文件来说,通过写入注册信息,用户可以向内核注册新的二进制格式,告知内核如何执行特定的二进制文件。这种只写的设计是为了保持简单性和安全性。允许用户在运行时动态地注册新的格式,而不是从文件中读取注册信息,可以提供更大的灵活性。

虽然 /proc/sys/fs/binfmt_misc/register 文件是只写的,但是通过向文件中写入正确格式的注册信息,用户仍然能够有效地配置新的二进制格式。这种设计符合 Linux 中的文件系统和权限模型。

参考资料

Kernel Support for miscellaneous Binary Formats (binfmt_misc)