Kbuild:Linux 内核构建系统

关于 Linux,一件令人惊叹的事情是,相同的代码库被用于各种不同的计算系统,从超级计算机到非常小的嵌入式设备。如果您停下来思考一下,Linux 可能是唯一拥有统一代码库的操作系统。例如,微软和苹果为其桌面和移动操作系统版本使用了不同的内核(Windows NT/Windows CE 和 OS X/iOS)。Linux 之所以能够做到这一点,有两个原因:内核具有许多抽象层和间接级别,以及其构建系统允许创建高度定制的内核二进制镜像。

Linux 内核具有单内核架构,这意味着整个内核代码在内核空间中运行并共享相同的地址空间。由于这种架构,您必须在编译时选择内核将包含的功能。从技术上讲,Linux 不是一个纯粹的单内核,因为它可以在运行时使用可加载的内核模块进行扩展。要加载模块,内核必须包含模块中使用的所有内核符号。如果这些符号在编译时未包含在内核中,则由于缺少依赖项,模块将无法加载。模块只是一种延迟特定内核功能编译(或执行)的方法。一旦加载内核模块,它就成为单内核的一部分,并与内核编译时包含的代码共享相同的地址空间。即使 Linux 支持模块,您仍然需要在内核编译时选择大多数将构建到内核镜像中的功能,以及允许您在内核执行后加载特定内核模块的功能。

因此,能够选择您想要在 Linux 内核中编译(或不编译)哪些代码非常重要。实现此目的的方法是使用条件编译。有大量的配置选项可用于选择是否包含特定功能。这转化为决定是否将特定的 C 文件、代码段或数据结构包含在内核镜像及其模块中。

因此,需要一种简单而有效的方法来管理所有这些编译选项。管理此过程的基础设施——构建内核镜像及其模块——被称为内核构建系统(kbuild)。

我不会在这里详细解释 kbuild 基础设施,因为 Linux 内核文档提供了很好的解释(Documentation/kbuild)。相反,我将讨论 kbuild 的基础知识,并展示如何使用它将您自己的代码包含到 Linux 内核树中,例如设备驱动程序。

Linux 内核构建系统有四个主要组件

  • 配置符号:编译选项,可用于在源文件中有条件地编译代码,并决定将哪些对象包含在内核镜像或其模块中。

  • Kconfig 文件:定义每个配置符号及其属性,例如其类型、描述和依赖项。生成选项菜单树的程序(例如,make menuconfig)从这些文件中读取菜单条目。

  • .config 文件:存储每个配置符号的选定值。您可以手动编辑此文件,或使用许多 make 配置目标之一,例如 menuconfig 和 xconfig,它们调用专用程序来构建树状菜单并自动更新(和创建).config 文件。

  • Makefile:正常的 GNU Makefile,描述源文件之间的关系以及生成每个 make 目标(例如内核镜像和模块)所需的命令。

现在,让我们更详细地了解这些组件中的每一个。

编译选项:配置符号

配置符号是用于决定哪些功能将包含在最终 Linux 内核镜像中的符号。两种类型的符号用于条件编译:布尔型和三态型。它们仅在每个符号可以采用的值的数量上有所不同。但是,这种差异比看起来更重要。布尔符号(毫不奇怪)可以取两个值之一:true 或 false。另一方面,三态符号可以取三个不同的值:yes、no 或 module。

内核中的并非所有内容都可以编译为模块。许多功能都具有侵入性,您必须在编译时决定内核是否支持它们。例如,您不能向正在运行的内核添加对称多处理(SMP)或内核抢占支持。因此,对于这些类型的功能,使用布尔配置符号是有意义的。大多数可以编译为模块的功能也可以在编译时添加到内核中。这就是三态符号存在的原因——决定您是希望将功能内置(y)、作为模块(m)还是完全不编译(n)。

除了这两种符号之外,还有其他配置符号类型,例如字符串和十六进制。但是,由于它们不用于条件编译,因此我在这里不介绍这些。有关配置符号、类型和用途的完整讨论,请阅读 Linux 内核文档。

定义配置符号:Kconfig 文件

配置符号在称为 Kconfig 文件的文件中定义。每个 Kconfig 文件可以描述任意数量的符号,也可以包含(source)其他 Kconfig 文件。构建内核编译选项配置菜单的编译目标,例如 make menuconfig,会读取这些文件以构建树状结构。内核中的每个目录都有一个 Kconfig 文件,其中包含其子目录的 Kconfig 文件。在内核源代码目录的顶部,有一个 Kconfig 文件,它是选项树的根。menuconfig (scripts/kconfig/mconf)、gconfig (scripts/kconfig/gconf) 和其他编译目标调用程序,这些程序从根 Kconfig 开始,并递归读取每个子目录中的 Kconfig 文件以构建其菜单。要访问哪个子目录也在每个 Kconfig 文件中定义,并且还取决于用户选择的配置符号值。

存储符号值:.config 文件

所有配置符号值都保存在一个名为 .config 的特殊文件中。每次您想要更改内核编译配置时,您都执行一个 make 目标,例如 menuconfig 或 xconfig。这些目标读取 Kconfig 文件以创建菜单,并使用 .config 文件中定义的值更新配置符号的值。此外,这些工具还会使用您选择的新选项更新 .config 文件,并且如果之前不存在,还可以生成一个。

由于 .config 文件是纯文本,您也可以在不需要任何专用工具的情况下对其进行更改。它对于保存和恢复以前的内核编译配置也非常方便。

编译内核:Makefile

kbuild 系统的最后一个组件是 Makefile。这些用于构建内核镜像和模块。与 Kconfig 文件一样,每个子目录都有一个 Makefile,它仅编译其目录中的文件。整个构建是递归完成的——顶层 Makefile 进入其子目录并执行每个子目录的 Makefile,以生成该目录中文件的二进制对象。然后,这些对象用于生成模块和 Linux 内核镜像。

整合所有内容:添加 Coin 驱动程序

现在您已经更了解 kbuild 系统的基础知识,让我们考虑一个实际示例——向 Linux 内核树添加设备驱动程序。示例驱动程序用于一个非常简单的字符设备,称为 coin。驱动程序的功能是模拟抛硬币,并在每次读取时返回两个值之一:正面或反面。该驱动程序有一个可选功能,可以使用特殊的 debugfs 虚拟文件公开以前的翻转统计信息。列表 1 显示了与 coin 设备交互的示例。

列表 1. Coin 字符设备语义

root@localhost:~# cat /dev/coin
tail
root@localhost:~# cat /dev/coin
head
root@sauron:/# cat /sys/kernel/debug/coin/stats
head=6 tail=4

要向 Linux 内核添加功能(例如 coin 驱动程序),您需要执行三件事

  1. 将源文件放在有意义的位置,例如 Wi-Fi 设备的 drivers/net/wireless 或新文件系统的 fs。

  2. 更新您放置文件的子目录(或子目录)的 Kconfig 文件,其中包含允许您选择包含功能的配置符号。

  3. 更新您放置文件的子目录的 Makefile,以便构建系统可以有条件地编译您的代码。

由于此驱动程序用于字符设备,请将 coin.c 源文件放在 drivers/char 中。

下一步是为用户提供编译 coin 驱动程序的选项。为此,您需要向 drivers/char/Kconfig 文件添加两个配置符号:一个用于选择将驱动程序添加到内核,第二个用于决定是否提供驱动程序统计信息。

像大多数驱动程序一样,coin 可以构建到内核中,作为模块包含或根本不包含。因此,第一个配置符号,称为 COIN,是三态类型 (y/n/m)。第二个符号 COIN_STAT 用于决定是否要公开统计信息。显然,这是一个二元决策,因此符号类型为 bool (y/n)。此外,如果您选择不包含 coin 驱动程序本身,则向内核添加 coin 统计信息是没有意义的。这种行为在内核中非常常见——例如,如果您没有首先启用块层,则无法添加基于块的文件系统,例如 ext3 或 fat32。显然,符号之间存在某种依赖关系,您应该对此进行建模。幸运的是,您可以使用 depends on 关键字在 Kconfig 文件中描述配置符号的关系。例如,当 make menuconfig 目标生成编译选项菜单树时,它会隐藏所有不满足符号依赖项的选项。这只是用于在 Kconfig 文件中描述符号的众多关键字之一。有关 Kconfig 语言的完整描述,请参阅 Linux 内核文档目录中的 kbuild/kconfig-language.txt。

列表 2 显示了 drivers/char/Kconfig 文件的一个片段,其中添加了 coin 驱动程序的符号。

列表 2. Coin 驱动程序的 Kconfig 条目

#
# Character device configuration
#

menu "Character devices"

config COIN
       tristate "Coin char device support"
       help
         Say Y here if you want to add support for the 
         coin char device.

         If unsure, say N.

         To compile this driver as a module, choose M here: 
         the module will be called coin.

config COIN_STAT
        bool "flipping statistics"
        depends on COIN
       help
        Say Y here if you want to enable statistics about 
        the coin char device.

那么,如何使用您最近添加的符号呢?

如前所述,构建包含所有编译选项的树状菜单的 make 目标使用此配置符号,因此您可以选择在内核及其模块中编译什么。例如,当您执行


$ make menuconfig

命令行实用程序 scripts/kconfig/mconf 将启动并读取所有 Kconfig 文件以构建基于菜单的界面。然后,您可以使用这些程序来更新 COINCOIN_STAT 编译选项的值。图 1 显示了当您导航到设备驱动程序→字符设备时菜单的外观;请参阅如何设置 coin 驱动程序的选项。

图 1. 菜单示例

完成编译选项配置后,退出程序,如果您进行了某些更改,系统将提示您保存新配置。这会将配置选项保存到 .config 文件中。对于每个符号,.config 文件中都会附加 CONFIG_ 前缀。例如,如果符号类型为布尔型,并且您选择了它,则在 .config 文件中,符号将像这样保存


CONFIG_COIN_STAT=y

另一方面,如果您未选择该符号,则它不会在 .config 文件中设置,您将看到类似这样的内容


# CONFIG_COIN_STAT is not set

三态符号在选择或不选择时具有与布尔类型相同的行为。但是,请记住,三态还具有将功能编译为模块的第三个选项。例如,您可以选择将 COIN 驱动程序编译为模块,并在 .config 文件中得到类似这样的内容


CONFIG_COIN=m

以下是 .config 文件的一个片段,显示了为 coin 驱动程序符号选择的值


CONFIG_COIN=m
CONFIG_COIN_STAT=y

在这里,您告诉 kbuild 您希望将 coin 驱动程序编译为模块并激活翻转统计信息。如果您选择将驱动程序内置编译且不包含翻转统计信息,您将得到类似这样的内容


CONFIG_COIN=y
# CONFIG_COIN_STAT is not set

有了 .config 文件后,您就可以编译内核及其模块了。当您执行编译目标以编译内核或模块时,它首先执行一个二进制文件,该文件读取所有 Kconfig 文件和 .config


$ scripts/kconfig/conf Kconfig

此二进制文件使用您为所有配置符号选择的值更新(或创建)C 头文件。此文件是 include/generated/autoconf.h,每个 gcc 编译指令都包含它,因此符号可以在内核中的任何源文件中使用。

该文件由数千个 #define 宏组成,这些宏描述了每个符号的状态。让我们看一下宏的约定。

值为 true 的布尔符号和值为 yes 的三态符号被同等对待。对于它们两者,都定义了三个宏。

例如,值为 true 的布尔 CONFIG_COIN_STAT 符号和值为 yes 的三态 CONFIG_COIN 符号将生成以下内容


#define __enabled_CONFIG_COIN_STAT 1
#define __enabled_CONFIG_COIN_STAT_MODULE 0
#define CONFIG_COIN_STAT 1

#define __enabled_CONFIG_COIN 1
#define __enabled_CONFIG_COIN_MODULE 0
#define CONFIG_COIN 1

同样,值为 false 的布尔符号和值为 no 的三态符号具有相同的语义。对于它们两者,都定义了两个宏。例如,值为 false 的 CONFIG_COIN_STAT 和值为 no 的 CONFIG_COIN 将生成以下宏组


#define __enabled_CONFIG_COIN_STAT 0
#define __enabled_CONFIG_COIN_STAT_MODULE 0

#define __enabled_CONFIG_COIN 0
#define __enabled_CONFIG_COIN_MODULE 0

对于值为 module 的三态符号,定义了三个宏。例如,值为 module 的 CONFIG_COIN 将生成以下内容


#define __enabled_CONFIG_COIN 0
#define __enabled_CONFIG_COIN_MODULE 1
#define CONFIG_COIN_MODULE 1

好奇的读者可能会问,为什么需要这些 __enabled_option 宏?仅有 CONFIG_optionCONFIG_option_MODULE 是否就足够了? 为什么即使对于类型为 bool 的符号也声明了 _MODULE

嗯,__enabled_ 常量被三个宏使用


#define IS_ENABLED(option) \
        (__enabled_ ## option || __enabled_ ## option ## _MODULE)

#define IS_BUILTIN(option) __enabled_ ## option

#define IS_MODULE(option) __enabled_ ## option ## _MODULE

因此,__enabled_option__enabled_option_MODULE 始终被定义,即使对于布尔符号也是如此,以确保此宏适用于任何配置选项。

第三步也是最后一步是更新您放置源文件的子目录的 Makefile,以便 kbuild 可以在您选择时编译您的驱动程序。

但是,您如何指示 kbuild 有条件地编译您的代码呢?

内核构建系统有两个主要任务:创建内核二进制镜像和内核模块。为此,它维护两个对象列表:obj-y 和 obj-m。前者是将构建到内核镜像中的所有对象的列表,后者是将编译为模块的对象的列表。

来自 .config 的配置符号和来自 autoconf.h 的宏与一些 GNU make 语法扩展一起使用,以填充这些列表。Kbuild 递归地进入每个目录并构建列表,添加每个子目录的 Makefile 中定义的对象。有关 GNU make 扩展和对象列表的更多信息,请阅读 Documentation/kbuild/makefiles.txt。

对于 coin 驱动程序,您唯一需要做的就是在 drivers/char/Makefile 中添加一行


obj-$(CONFIG_COIN)    += coin.o

这告诉 kbuild 从源文件 coin.c 创建一个对象,并将其添加到对象列表中。由于 CONFIG_COIN 的值可以是 y 或 m,因此 coin.o 对象将根据符号值添加到 obj-y 或 obj-m 列表中。然后它将被构建到内核中或作为模块构建。如果您未选择 CONFIG_COIN 选项,则符号未定义,并且根本不会编译 coin.o。

现在您知道如何有条件地包含源文件了。最后一个难题是如何有条件地编译源代码段。这可以通过使用 autoconf.h 中定义的宏轻松完成。列表 3 显示了完整的 coin 字符设备驱动程序。

列表 3. Coin 字符设备驱动程序示例

#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/uaccess.h>
#include <linux/device.h>
#include <linux/random.h>
#include <linux/debugfs.h>

#define DEVNAME "coin"
#define LEN  20
enum values {HEAD, TAIL};

struct dentry *dir, *file;
int file_value;
int stats[2] = {0, 0};
char *msg[2] = {"head\n", "tail\n"};

static int major;
static struct class *class_coin;
static struct device *dev_coin;

static ssize_t r_coin(struct file *f, char __user *b,
                      size_t cnt, loff_t *lf)
{
        char *ret;
        u32 value = random32() % 2;
        ret = msg[value];
        stats[value]++;
        return simple_read_from_buffer(b, cnt,
                                       lf, ret,
                                       strlen(ret));
}

static struct file_operations fops = { .read = r_coin };

#ifdef CONFIG_COIN_STAT
static ssize_t r_stat(struct file *f, char __user *b,
                         size_t cnt, loff_t *lf)
{
        char buf[LEN];
        snprintf(buf, LEN, "head=%d tail=%d\n",
                 stats[HEAD], stats[TAIL]);
        return simple_read_from_buffer(b, cnt,
                                       lf, buf,
                                       strlen(buf));
}

static struct file_operations fstat = { .read = r_stat };
#endif

int init_module(void)
{
        void *ptr_err;
        major = register_chrdev(0, DEVNAME, &fops);
        if (major < 0)
                return major;

        class_coin = class_create(THIS_MODULE,
                                  DEVNAME);
        if (IS_ERR(class_coin)) {
                ptr_err = class_coin;
                goto err_class;
        }

        dev_coin = device_create(class_coin, NULL,
                                 MKDEV(major, 0),
                                 NULL, DEVNAME);
        if (IS_ERR(dev_coin))
                goto err_dev;

#ifdef CONFIG_COIN_STAT
        dir = debugfs_create_dir("coin", NULL);
        file = debugfs_create_file("stats", 0644,
                                   dir, &file_value,
                                   &fstat);
#endif

        return 0;
err_dev:
        ptr_err = class_coin;
        class_destroy(class_coin);
err_class:
        unregister_chrdev(major, DEVNAME);
        return PTR_ERR(ptr_err);
}

void cleanup_module(void)
{
#ifdef CONFIG_COIN_STAT
        debugfs_remove(file);
        debugfs_remove(dir);
#endif

        device_destroy(class_coin, MKDEV(major, 0));
        class_destroy(class_coin);
        return unregister_chrdev(major, DEVNAME);
}

在列表 3 中,您可以看到 CONFIG_COIN_STAT 配置选项用于注册(或不注册)特殊的 debugfs 文件,该文件向用户空间公开抛硬币统计信息。

图 2 总结了内核构建过程,git diff --stat 命令的输出显示了您为包含驱动程序而修改的文件


drivers/char/Kconfig  |   16 +++++++++
drivers/char/Makefile |    1 +
drivers/char/coin.c   |   89 ++++++++++++++++++++++++++++++++++++++++
3 files changed, 106 insertions(+), 0 deletions(-)

图 2. 内核构建过程

结论

Linux 虽然是单内核,但具有高度模块化和可定制性。您可以在从高性能集群到桌面电脑,再到移动电话的各种设备中使用相同的内核。这使得内核成为一个非常庞大而复杂的软件。但是,即使内核有数百万行代码,其构建系统也允许您轻松地使用新功能对其进行扩展。过去,要访问操作系统的源代码,您必须为一家大公司工作并签署大量的 NDA 协议。如今,可能是最现代操作系统的源代码是公开可用的。您可以使用它、研究其内部结构并以您想要的任何创造性方式对其进行修改。最好的部分是您甚至可以分享您的工作并从活跃的社区获得反馈。祝您编程愉快!

加载 Disqus 评论