图书节选:Linux 内核开发,第 3 版。

节选自Linux 内核开发,第 3 版
作者:Robert Love
由 Addison-Wesley Professional 出版
ISBN-10:0-672-32946-8
ISBN-13:978-0-672-32946-3

第 2 章:内核入门

在本章中,我们将介绍 Linux 内核的一些基础知识:从哪里获取其源代码、如何编译以及如何安装新内核。然后,我们将介绍内核与用户空间程序之间的差异以及内核中常用的编程结构。虽然内核在许多方面肯定是独一无二的,但归根结底,它与任何其他大型软件项目没有什么不同。

获取内核源代码

当前的 Linux 源代码始终以完整的 tarball(使用 tar 命令创建的存档)和来自 Linux 内核官方主页 https://linuxkernel.org.cn 的增量补丁形式提供。

除非您有特殊理由使用旧版本的 Linux 源代码,否则您始终需要最新的代码。kernel.org 上的存储库是获取它的地方,以及来自许多领先内核开发人员的其他补丁。

使用 Git

在过去的几年中,内核黑客(以 Linus 本人为首)已开始使用一种新的版本控制系统来管理 Linux 内核源代码。Linus 创建了这个名为 Git 的系统,其主要考虑因素是速度。与 CVS 等传统系统不同,Git 是分布式的,因此其用法和工作流程对许多开发人员来说是不熟悉的。我强烈建议使用 Git 下载和管理 Linux 内核源代码。

您可以使用 Git 获取 Linus 树的最新“推送”版本的副本

$ git clone git://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux-2.6.git

检出后,您可以将您的树更新为 Linus 的最新版本

$ git pull

使用这两个命令,您可以获取并随后保持与官方内核树的同步。要提交和管理您自己的更改,请参阅第 20 章“补丁、黑客和社区”。对 Git 的完整讨论超出了本书的范围;许多在线资源提供了出色的指南。

安装内核源代码

内核 tarball 以 GNU zip (gzip) 和 bzip2 格式分发。Bzip2 是默认且首选的格式,因为它通常比 gzip 压缩得更好。bzip2 格式的 Linux 内核 tarball 名为 linux-x.y.z.tar.bz2,其中 x.y.z 是该特定内核源代码版本的版本号。下载源代码后,解压缩和解 tar 非常简单。如果您的 tarball 使用 bzip2 压缩,请运行

$ tar xvjf linux-x.y.z.tar.bz2

如果它使用 GNU zip 压缩,请运行

$ tar xvzf linux-x.y.z.tar.gz

这会将源代码解压缩并解 tar 到目录 linux-x.y.z 中。如果您使用 git 获取和管理内核源代码,则无需下载 tarball。只需运行 git clone 命令,如上所述,git 就会下载并解压缩最新的源代码。

在哪里安装和修改源代码 - 内核源代码通常安装在 /usr/src/linux 中。您不应将此源代码树用于开发,因为您的 C 库编译所针对的内核版本通常链接到此树。此外,您不应需要 root 权限才能更改内核——相反,请在您的主目录中工作,并且仅在安装新内核时才使用 root 权限。即使在安装新内核时,/usr/src/linux 也应保持不变。

使用补丁

在整个 Linux 内核社区中,补丁是沟通的通用语言。您将以补丁的形式分发您的代码更改,并以补丁的形式接收来自其他人的代码。增量补丁提供了一种从一个内核树移动到下一个内核树的简便方法。您可以简单地应用增量补丁从一个版本升级到下一个版本,而无需下载每个大型内核源代码 tarball。这节省了每个人的带宽和您的时间。要应用增量补丁,请从您的内核源代码树内部运行

$ patch –p1 < ../patch-x.y.z

通常,给定版本的内核的补丁是针对先前版本应用的。

在后面的章节中将更深入地讨论生成和应用补丁。

内核源代码树

内核源代码树分为许多目录,其中大多数目录包含更多的子目录。源代码树根目录中的目录及其描述列在表 2.1 中。

表 2.1  内核源代码树根目录中的目录

目录

描述

arch

特定于体系结构的源代码

block

块 I/O 层

crypto

加密 API

Documentation

内核源代码文档

drivers

设备驱动程序

firmware

使用某些驱动程序所需的设备固件

fs

VFS 和各个文件系统

include

内核头文件

init

内核引导和初始化

ipc

进程间通信代码

kernel

核心子系统,例如调度程序

lib

辅助例程

mm

内存管理子系统和 VM

net

网络子系统

samples

示例性演示代码

scripts

用于构建内核的脚本

security

Linux 安全模块

sound

声音子系统

usr

早期用户空间代码(称为 initramfs)

tools

有助于 Linux 开发的工具

virt

虚拟化基础设施

源代码树根目录中的许多文件值得一提。COPYING 文件是内核许可证(GNU GPL v2)。CREDITS 是内核中包含大量代码的开发人员列表。MAINTAINERS 列出了内核中子系统和驱动程序的维护人员姓名。Makefile 是基本内核 Makefile。

构建内核

构建内核很容易。它比编译和安装其他系统级组件(例如 glibc)更容易。2.6 内核系列引入了新的配置和构建系统,这使工作变得更加容易,并且是对早期版本的可喜改进。

配置内核

由于 Linux 源代码是可用的,因此可以在编译之前对其进行配置和自定义。实际上,可以将支持编译到您的内核中,仅用于您需要的特定功能和驱动程序。配置内核是在构建内核之前必需的步骤。由于内核提供了无数功能并支持各种各样的硬件,因此有很多需要配置。内核配置由配置选项控制,配置选项以 CONFIG 为前缀,形式为 CONFIG_FEATURE。例如,对称多处理 (SMP) 由配置选项 CONFIG_SMP 控制。如果设置了此选项,则启用 SMP;如果未设置,则禁用 SMP。配置选项既用于决定要构建哪些文件,也用于通过预处理器指令来操作代码。

控制构建过程的配置选项可以是布尔值或三态值。布尔选项为是或否。内核功能(例如 CONFIG_PREEMPT)通常是布尔值。三态选项是是、否或模块之一。模块设置表示已设置但要编译为模块(即,单独的动态可加载对象)的配置选项。在三态的情况下,是选项明确表示将代码编译到主内核映像中,而不是作为模块。驱动程序通常由三态表示。

配置选项也可以是字符串或整数。这些选项不控制构建过程,而是指定内核源代码可以作为预处理器宏访问的值。例如,配置选项可以指定静态分配数组的大小。

供应商内核,例如 Canonical 为 Ubuntu 或 Red Hat 为 Fedora 提供的内核,在发行版中预编译。此类内核通常启用所需内核功能的良好横截面,并将几乎所有驱动程序编译为模块。这为广泛的硬件作为单独的模块提供了出色的基本内核支持。无论好坏,作为内核黑客,您都需要编译自己的内核并了解要包含哪些模块。

值得庆幸的是,内核提供了多种工具来简化配置。最简单的工具是基于文本的命令行实用程序

$ make config

此实用程序逐个遍历每个选项,并要求用户交互式选择是、否或(对于三态)模块。由于这需要很长时间,因此除非您按小时付费,否则您应该使用基于 ncurses 的图形实用程序

$ make menuconfig

或基于 gtk+ 的图形实用程序

$ make gconfig

这三个实用程序将各种配置选项分为几类,例如“处理器类型和功能”。您可以浏览这些类别,查看内核选项,当然也可以更改它们的值。

此命令基于您体系结构的默认值创建配置

$ make defconfig

尽管这些默认值有些随意(在 i386 上,据说是 Linus 的配置!),但如果您从未配置过内核,它们会提供一个良好的开端。为了快速启动并运行,请运行此命令,然后返回并确保已启用您硬件的配置选项。

配置选项存储在内核源代码树根目录中名为 .config 的文件中。您可能会发现直接编辑此文件更容易(就像大多数内核开发人员所做的那样)。搜索和更改配置选项的值非常容易。在对您的配置文件进行更改之后,或者在新内核树上使用现有配置文件时,您可以验证和更新配置

$ make oldconfig

您应该始终在构建内核之前运行此命令。

配置选项 CONFIG_IKCONFIG_PROC 将完整的内核配置文件压缩后放置在 /proc/config.gz 中。这使得在构建新内核时可以轻松克隆您当前的配置。如果您当前的内核启用了此选项,您可以从 /proc 中复制配置并使用它来构建新内核

$ zcat /proc/config.gz > .config
$ make oldconfig

设置内核配置后(无论您如何设置),您都可以使用单个命令构建它

$ make

与 2.6 之前的内核不同,您不再需要在构建内核之前运行 make dep——依赖树是自动维护的。您也不需要像旧版本那样指定特定的构建类型(例如 bzImage)或单独构建模块。默认的 Makefile 规则将处理所有事情。

最小化构建噪音

最小化构建噪音,但仍然看到警告和错误的一个技巧是将 make 的输出重定向

$ make > ../detritus

如果您需要查看构建输出,可以读取该文件。但是,由于警告和错误会输出到标准错误,因此您通常不需要这样做。实际上,我只是这样做

$ make > /dev/null

这会将所有无用的输出重定向到那个巨大的、不祥的无底洞 /dev/null。

生成多个构建作业

make 程序提供了一项功能,可以将构建过程拆分为多个并行作业。然后,这些作业中的每一个都单独且并发地运行,从而显着加快了多处理系统上的构建过程。它还提高了处理器利用率,因为构建大型源代码树的时间包括大量的 I/O 等待时间(进程空闲等待 I/O 请求完成的时间)。

默认情况下,make 仅生成一个作业,因为 Makefile 通常具有不正确的依赖信息。由于不正确的依赖关系,多个作业可能会互相干扰,从而导致构建过程中出现错误。内核的 Makefile 具有正确的依赖信息,因此生成多个作业不会导致失败。要使用多个 make 作业构建内核,请使用

$ make -jn

此处,n 是要生成的作业数。通常的做法是每个处理器生成一个或两个作业。例如,在 16 核机器上,您可以这样做

$ make -j32 > /dev/null

使用 distcc 或 ccache 等出色的实用程序也可以显着缩短内核构建时间。

安装新内核

构建内核后,您需要安装它。它的安装方式取决于体系结构和引导加载程序——请查阅您的引导加载程序的说明,了解将内核映像复制到哪里以及如何设置它以进行引导。始终保留一两个已知的安全内核,以防您的新内核出现问题!

例如,在使用 grub 的 x86 系统上,您会将 arch/i386/boot/bzImage 复制到 /boot,将其命名为 vmlinuz-version 之类的名称,并编辑 /boot/grub/grub.conf,为新内核添加一个新条目。使用 LILO 进行引导的系统将改为编辑 /etc/lilo.conf,然后重新运行 lilo。

值得庆幸的是,模块的安装是自动化的并且与体系结构无关。以 root 用户身份,只需运行

% make modules_install

这会将所有已编译的模块安装到它们在 /lib/modules 下的正确位置。

构建过程还在内核源代码树的根目录中创建文件 System.map。它包含一个符号查找表,将内核符号映射到它们的起始地址。这在调试期间用于将内存地址转换为函数和变量名称。

本质不同的野兽

与普通用户空间应用程序相比,Linux 内核具有几个独特的属性。虽然这些差异不一定使开发内核代码比开发用户空间代码更难,但它们肯定使这样做变得不同。

这些特性使内核成为本质不同的野兽。一些常用的规则被打破了;其他规则是全新的。虽然有些差异是显而易见的(我们都知道内核可以做任何它想做的事情),但其他差异则不那么明显。这些差异中最重要的是

  • 内核既无法访问 C 库,也无法访问标准 C 头文件。

  • 内核使用 GNU C 编码。

  • 内核缺乏用户空间提供的内存保护。

  • 内核无法轻松执行浮点运算。

  • 内核具有小的每进程固定大小堆栈。

  • 由于内核具有异步中断、是抢占式的并且支持 SMP,因此同步和并发是内核内的主要问题。

  • 可移植性很重要。

让我们简要地看一下这些问题,因为所有内核开发人员都必须牢记它们。

没有 libc 或标准头文件

与用户空间应用程序不同,内核未与标准 C 库或任何其他库链接。这有多种原因,包括先有鸡还是先有蛋的情况,但主要原因是速度和大小。完整的 C 库——甚至是一个像样的子集——对于内核来说都太大且效率太低。

不要担心:许多常用的 libc 函数都在内核内部实现。例如,常用的字符串操作函数在 lib/string.c 中。只需包含头文件 <linux/string.h> 并使用它们即可。

头文件 - 当我在本书中谈论头文件时,我指的是作为内核源代码树一部分的内核头文件。内核源文件不能包含外部头文件,就像它们不能使用外部库一样。

基本文件位于内核源代码树根目录的 include/ 目录中。例如,头文件 <linux/inotify.h> 位于内核源代码树中的 include/linux/inotify.h。

一组特定于体系结构的头文件位于内核源代码树中的 arch/<architecture>/include/asm 中。例如,如果为 x86 体系结构编译,您的特定于体系结构的头文件位于 arch/x86/include/asm 中。源代码通过 asm/ 前缀(例如 <asm/ioctl.h>)包含这些头文件。

在缺少的函数中,最熟悉的是 printf()。内核无法访问 printf(),但它提供了 printk(),它的工作原理与它更熟悉的表亲几乎相同。printk() 函数将格式化的字符串复制到内核日志缓冲区中,该缓冲区通常由 syslog 程序读取。用法类似于 printf()

printk("Hello world! A string '%s' and an integer '%d'\n", str, i);

printf() 和 printk() 之间一个值得注意的区别是 printk() 允许您指定优先级标志。syslogd 使用此标志来决定在何处显示内核消息。以下是这些优先级的示例

printk(KERN_ERR "this is an error!\n");

请注意,KERN_ERR 和打印的消息之间没有逗号。这是故意的;优先级标志是一个预处理器定义,表示一个字符串文字,在编译期间将其连接到打印的消息。我们在本书中通篇使用 printk()。

GNU C

像任何有自尊心的 Unix 内核一样,Linux 内核是用 C 编程的。也许令人惊讶的是,内核不是用严格的 ANSI C 编程的。相反,在适用的情况下,内核开发人员利用 gcc(GNU 编译器集合,其中包含用于编译内核和 Linux 系统上用 C 编写的大多数其他内容的 C 编译器)中可用的各种语言扩展。

内核开发人员使用 ISO C991 和 GNU C 扩展 C 语言。这些更改将 Linux 内核与 gcc 结合在一起,尽管最近另一个编译器 Intel C 编译器也充分支持了足够的 gcc 功能,因此它也可以编译 Linux 内核。最早支持的 gcc 版本是 3.2;建议使用 gcc 4.4 或更高版本。内核使用的 ISO C99 扩展没有什么特别之处,并且由于 C99 是 C 语言的正式修订版,因此正慢慢地出现在许多其他代码中。与标准 ANSI C 更不熟悉的偏差是 GNU C 提供的偏差。让我们看一下您将在内核中看到的一些更有趣的扩展;这些更改使内核代码与您可能熟悉的其他项目区分开来。

内联函数

C99 和 GNU C 都支持内联函数。内联函数顾名思义,是内联插入到每个函数调用站点中的。这消除了函数调用和返回的开销(寄存器保存和恢复),并允许进行可能更大的优化,因为编译器可以将调用者和被调用函数作为一个整体进行优化。作为缺点(生活中没有什么是免费的),代码大小会增加,因为函数的内容被复制到所有调用者中,这会增加内存消耗和指令缓存占用空间。内核开发人员将内联函数用于小型时间关键型函数。将大型函数内联,尤其是一次以上使用或并非极其时间关键的函数,是不赞成的。

当关键字 static 和 inline 用作函数定义的一部分时,将声明内联函数。例如

static inline void wolf(unsigned long tail_size)

函数声明必须先于任何用法,否则编译器无法使函数内联。常见的做法是将内联函数放在头文件中。由于它们被标记为静态,因此不会创建导出的函数。如果内联函数仅由一个文件使用,则可以将其放置在该文件的顶部附近。

在内核中,出于类型安全性和可读性的原因,使用内联函数优于复杂的宏。

内联汇编

gcc C 编译器允许在其他正常的 C 函数中嵌入汇编指令。当然,此功能仅在内核中特定于给定系统体系结构的部分中使用。

asm() 编译器指令用于内联汇编代码。例如,此内联汇编指令执行 x86 处理器的 rdtsc 指令,该指令返回时间戳 (tsc) 寄存器的值

unsigned int low, high;
asm volatile("rdtsc" : "=a" (low), "=d" (high));
/* low and high now contain the lower and upper 32-bits of the 64-bit tsc */

Linux 内核是用 C 和汇编混合编写的,其中汇编仅限于低级体系结构和快速路径代码。绝大多数内核代码是用纯 C 编程的。

分支注释

gcc C 编译器具有内置指令,可将条件分支优化为很可能被采用或很不可能被采用。编译器使用该指令来适当地优化分支。内核将该指令包装在易于使用的宏 likely() 和 unlikely() 中。

例如,考虑如下所示的 if 语句

if (error) {
        /* ... */
}

要将此分支标记为很不可能被采用(即,可能不被采用)

/* we predict 'error' is nearly always zero ... */
if (unlikely(error)) {
        /* ... */
}

相反,要将分支标记为很可能被采用

/* we predict 'success' is nearly always nonzero ... */
if (likely(success)) {
        /* ... */
}

您应该仅在分支方向事先绝大多数已知时,或者当您想以另一个案例为代价优化特定案例时才使用这些指令。这是一个重点:当分支被正确标记时,这些指令会导致性能提升,而当分支被错误标记时,则会导致性能损失。正如这些示例所示,unlikely() 和 likely() 的常见用法是错误条件。正如您可能期望的那样,unlikely() 在内核中发现的用途更多,因为 if 语句倾向于指示特殊情况。

没有内存保护

当用户空间应用程序尝试非法内存访问时,内核可以捕获错误,发送 SIGSEGV 信号并终止该进程。但是,如果内核尝试非法内存访问,则结果不太受控制。(毕竟,谁来照顾内核?)内核中的内存违规会导致 oops,这是一个主要的内核错误。毋庸置疑,您绝不能非法访问内存,例如解引用 NULL 指针——但在内核中,风险要高得多!

此外,内核内存不可分页。因此,您消耗的每个字节的内存都会使可用物理内存减少一个字节。下次您需要向内核添加更多功能时,请记住这一点!

没有(容易的)浮点数使用

当用户空间进程使用浮点指令时,内核管理从整数模式到浮点模式的转换。内核在使用浮点指令时必须执行的操作因体系结构而异,但内核通常会捕获陷阱,然后启动从整数模式到浮点模式的转换。

与用户空间不同,内核没有无缝支持浮点数的奢侈,因为它无法轻松地陷入自身。在内核内部使用浮点数需要手动保存和恢复浮点寄存器,以及其他可能的杂务。简短的回答是:不要这样做!除了极少数情况外,内核中没有浮点运算。

小型、固定大小的堆栈

用户空间可以在堆栈上静态分配许多变量,包括巨大的结构和包含数千个元素的数组,而不会有问题。这种行为是合法的,因为用户空间具有可以动态增长的大型堆栈。(在较旧、不太先进的操作系统(例如 DOS)上进行开发的开发人员可能还记得,即使是用户空间也曾有过固定大小的堆栈。)

内核堆栈既不大也不动态;它很小且大小固定。内核堆栈的确切大小因体系结构而异。在 x86 上,堆栈大小可在编译时配置,可以是 4KB 或 8KB。从历史上看,内核堆栈是两页,这通常意味着在 32 位体系结构上为 8KB,在 64 位体系结构上为 16KB——此大小是固定的和绝对的。每个进程都收到自己的堆栈。

内核堆栈将在后面的章节中进行更详细的讨论。

同步和并发

内核容易受到竞争条件的影响。与单线程用户空间应用程序不同,内核的许多属性允许并发访问共享资源,因此需要同步以防止竞争。具体来说

  • Linux 是抢占式多任务操作系统。进程由内核的进程调度程序随意调度和重新调度。内核必须在这些任务之间进行同步。

  • Linux 支持对称多处理 (SMP)。因此,在没有适当保护的情况下,在两个或多个处理器上同时执行的内核代码可以并发访问同一资源。

  • 中断相对于当前正在执行的代码异步发生。因此,在没有适当保护的情况下,可能会在访问资源的过程中发生中断,然后中断处理程序可能会访问同一资源。

  • Linux 内核是抢占式的。因此,在没有保护的情况下,内核代码可能会被抢占,以支持然后访问同一资源的不同代码。

竞争条件的典型解决方案包括自旋锁和信号量。后面的章节将全面讨论同步和并发。

可移植性的重要性

尽管用户空间应用程序不必以可移植性为目标,但 Linux 是一个可移植的操作系统,并且应该保持可移植性。这意味着独立于体系结构的 C 代码必须在各种系统上正确编译和运行,并且依赖于体系结构的代码必须正确地隔离在内核源代码树中特定于系统的目录中。

少数规则(例如保持字节序中立、保持 64 位清洁、不要假定字或页面大小等)会大有帮助。可移植性将在后面的章节中深入讨论。

结论

可以肯定的是,内核具有独特的品质。它执行自己的规则,并且管理整个系统的风险当然更高。也就是说,Linux 内核的复杂性和入门门槛与其他任何大型软件项目在性质上没有区别。Linux 开发道路上最重要的步骤是认识到内核不是什么可怕的东西。不熟悉,当然。难以逾越?一点也不。

本章和前一章为我们将在本书其余章节中介绍的主题奠定了基础。在随后的每一章中,我们都会介绍特定的内核概念或子系统。在此过程中,您必须阅读和修改内核源代码。只有通过实际阅读和试验代码,您才能理解它。源代码是免费提供的——请使用它!

脚注

1. ISO C99 是 ISO C 标准的最新主要修订版。C99 为之前的重大修订版 ISO C90 添加了许多增强功能,包括指定的初始化程序、可变长度数组、C++ 风格的注释以及 long longcomplex 类型。但是,Linux 内核仅使用 C99 功能的子集。

© 版权所有 Pearson Education。保留所有权利。

加载 Disqus 评论