让我们将 Go 程序嵌入到 Linux 内核中

今天,我们想介绍 Linux 内核的一个鲜为人知的功能。 也可以将用户空间程序直接嵌入到内核映像本身并从那里启动,而不是从文件系统启动程序,无论它是否是虚拟的。

介绍

乍一看,这可能听起来很奇怪并且没什么用处,但在某些情况下内核需要执行帮助程序。 突出的例子包括模块加载器助手, /sbin/modprobe,或 Linux 802.1d 以太网桥接子系统的生成树协议 (STP) 帮助程序, /sbin/bridge-stp。 在 Linux 上,程序通常使用以下命令从文件系统启动 execve()系统调用。 内核读取指定文件的初始部分并将执行移交给用户空间。 此系统调用之上存在各种辅助函数,但它们的共同点是要执行的程序是从文件启动的。 这同样适用于 Linux 用户模式帮助程序 API,它允许从驱动程序执行程序。 这两种情况都需要安装并加载程序。 在大多数情况下,如果您可以控制所有用户空间(就像典型的 Linux 发行版一样),那么安装程序就是一个小问题。

然而,如果您是董事会支持包 (BSP) 供应商(无论是外部还是内部),并且并不总是能够轻松访问确定在根文件系统中安装哪些内容的工作流程,那么事情就会变得复杂。 确保程序实际上可以从当前根文件系统加载是另一回事。 使用网络存储(例如 NFS 或 iSCSI)附加根文件系统的设置中会出现一个常见问题。 如果网络连接不可用,则对根文件系统的所有访问都会失败,并且内核无法再运行帮助程序。 这就是我们想在这篇博文中介绍的机制可以提供帮助的地方。

假设我们有一个名为的设备驱动程序 embedded_prog这需要一个用户空间帮助程序,该程序必须随时可执行。 首先,我们需要要嵌入的程序。 严格来说,任何程序都可以,但是我们需要确保相关程序不依赖于文件系统。 静态链接它有好处。 Go 程序默认是静态链接的,为了说明以下方法适用于任何类型的程序,我们选择将 Go 程序嵌入到内核中。 驱动程序本身非常简单。 它所做的只是在驱动程序加载后立即执行帮助程序,并且当程序向管道中写入内容时,内容会直接记录到内核日志缓冲区中。

Golang计划

为了简单起见,程序本身将很简单。 它会写“你好,世界!” 每五秒一次到标准输出。

package main<
>>
import (
        "fmt"
        "time"
)
func main() {
        for {
                fmt.Println("Hello, world!")
                time.Sleep(5 * time.Second)
        }
}


由于我们想要独立于根文件系统,所以我们需要确保程序不会访问根文件系统中的文件。 我们可以通过使用 strace 跟踪其系统调用来实现这一点。

$ strace -fe trace=file ./eprog_user
execve("./eprog_user", ["./eprog_user"], 0x7fff743fbd50 /* 61 vars */) = 0
openat(AT_FDCWD, "/sys/kernel/mm/transparent_hugepage/hpage_pmd_size", O_RDONLY) = 3
strace: Process 17908 attached
strace: Process 17909 attached
strace: Process 17910 attached
strace: Process 17911 attached
[pid 17905] --- SIGURG {si_signo=SIGURG, si_code=SI_TKILL, si_pid=17905, si_uid=1000} ---
Hello, world!
Hello, world!
^C
strace: Process 17905 detached
strace: Process 17908 detached
strace: Process 17909 detached
strace: Process 17910 detached
strace: Process 17911 detached

看起来不错! 访问文件在 /proc 或者 /sys很好。 这些是虚拟文件系统,可以假设它们始终存在并工作。 它们是 Linux ABI 接口的一部分。

Linux 的用户模式帮助程序 API

如果您的设备驱动程序需要执行用户空间程序,则可以使用用户模式帮助程序 API。 使用API​​的核心功能是 call_usermodehelper(),它具有以下签名:

int call_usermodehelper(const char *path, char **argv, char **envp, int wait);


类似于 execve()系统调用, call_usermodehelper()接受参数,例如要执行的程序的文件名、提供的参数、环境以及表示调用是否应该异步的标志。 然而,这并不是我们想要在内核映像本身中运行嵌入式程序的情况。

从 Linux v4.18 开始,可以使用称为用户模式驱动程序的更高级 API 来运行用户模式帮助程序。 该 API 背后的基本思想是,可以提供任意缓冲区,而不是指定根文件系统上的文件路径。 该缓冲区的内容将像常规程序一样在用户空间中执行。

int umd_load_blob(struct umd_info *info, const void *data, size_t len);
int fork_usermode_driver(struct umd_info *info);

使用 umd_load_blob() ,从缓冲区创建程序上下文,然后由 fork_usermode_driver()。 除了运行程序之外,用户模式驱动程序 API 还在程序和内核之间建立管道,从而实现它们之间廉价的通信。

但是我们如何将 Go 程序放入该缓冲区呢? 我们不想要一个新的用户空间接口,其中程序必须首先加载到内核中。 我们希望它成为内核映像的一部分。

GNU 汇编来救援!

内核构建系统,特别是 GNU 汇编器(gas),可以帮助我们在构建过程中将 Go 程序嵌入到生成的内核映像中。 通过使用气体文件, eprog_user_blob.S,我们可以指示汇编器生成带有两个符号的 C 目标文件: embedded_umh_start和 embedded_umh_end。 这些符号将包含由指定的二进制文件的内容 .incbin命令,并且该文件可以是任意的。

        .section .init.rodata, "a"
        .global embedded_umh_start
embedded_umh_start:
        .incbin "drivers/misc/embedded_prog/eprog_user"
        .global embedded_umh_end
embedded_umh_end:

如果我们链接生成的目标文件, eprog_user_blob.o ,通过我们的 Linux 设备驱动程序,我们可以利用这些符号来定位内核映像中 Go 程序的内容。 这使得从缓冲区加载和执行程序变得简单。 在这种情况下,缓冲区传递给 umd_load_blob()是内存中内核映像的只读部分中的内存位置。 链接器在编译时已经为我们准备好了一切!

下面提供的代码片段演示了设备驱动程序的基本部分, eprog_kern.c,负责加载并执行Go程序:

 struct umd_info eprog_ctx = {
        .driver_name = "eprog_user",
};
umd_load_blob(&eprog_ctx, &embedded_umh_start, &embedded_umh_end - &embedded_umh_start);
fork_usermode_driver(&eprog_ctx);

使用 nm 命令,我们可以观察驱动程序中的符号。 如果驱动程序构建为可加载模块,则可以运行 nm 在 eprog.o 。 否则,如果它直接构建到内核中,则可以运行 nm 在 vmlinux。

$ nm eprog.o | grep embedded_umh_
00000000001bc4e4 R embedded_umh_end
0000000000000000 R embedded_umh_start
$ nm vmlinux | grep embedded_umh_
0000000060688a00 d embedded_umh_end
00000000604cc51c d embedded_umh_start

将它们粘合在一起

到目前为止,我们有以下组件:

  • eprog_kern.c :运行的内核驱动程序 umd_load_blob()和 fork_usermode_driver()。
  • eprog_user_blob.S ,我们用来将 Go 程序嵌入到 C 对象中的汇编源文件。
  • gohello/hello.go ,我们的 Go 程序,位于驱动程序的子目录中。

使用以下 Makefile,构建所有组件并将其连接在一起:

 $(obj)/eprog_user_blob.o: $(obj)/eprog_user
$(obj)/eprog_user: $(srctree)/drivers/misc/embedded_prog/gohello/hello.go
        # TODO: Add support for cross builds
        go build -o $(obj)/eprog_user $(srctree)/drivers/misc/embedded_prog/gohello/hello.go
obj-$(CONFIG_EMBEDDED_PROG) += eprog.o
eprog-objs += eprog_kern.o eprog_user_blob.o

Linux 的基于 GNU make 的构建系统相当灵活:它允许我们在构建内核的同时构建 Go 程序。

下图概述了所有组件的连接方式:

演示

$ insmod eprog.ko
$ ps fax
  PID TTY      STAT   TIME COMMAND
    2 ?        S      0:00 [kthreadd]
[..]
   25 ?        Sl     0:00  \_ eprog_user
[..]
   30 ?        R      0:00 ps fax
$ dmesg -w
[   53.300000] eprog: From userspace: Hello, world!
[   58.310000] eprog: From userspace: Hello, world!
[...]

我们观察到一个新的过程, eprog_user ,加载驱动程序模块后。 它不是内核线程,名称不在方括号中,也不是子线程 PID 1 但 kthreadd 。 Go 程序每五秒就会写一次 Hello, world!通过标准输出 到内核​​驱动程序,该驱动程序将字符串打印到内核日志缓冲区。 一旦模块被卸载,进程就会被终止。

概括

我们已经看到,使用小型汇编文件和用户模式驱动程序机制,可以将任何类型的可执行文件嵌入到内核映像中,并稍后从那里运行它。 它适用于任何类型的程序; 我们使用 Go 二进制文件,因为它是静态链接的并且不依赖于根文件系统。 如果您使用自己的程序并且希望完全独立于根文件系统,请确保您详细了解它将处理哪些文件。 虽然所提供的功能的有效用例数量可以说很小,但它仍然很有趣,并为新的可能性打开了大门。 示例驱动程序相当简单,但还有许多其他方法可以利用此功能。 请记住,向内核映像添加程序是有代价的:整个程序将始终保留在内存中。

完整的示例驱动程序可在 git.kernel.org 上找到。

© GVGNN 2013-2026