HyperNews Linux KHG 讨论页面

Linux 内核源代码导览

作者:Alessandro Rubini, rubini@pop.systemy.it

本章尝试以有条理的方式解释 Linux 源代码,旨在帮助读者很好地理解源代码的布局以及最相关的 unix 功能是如何实现的。目标是帮助那些不熟悉 Linux 的经验丰富的 C 程序员熟悉 Linux 的整体设计。这就是为什么内核导览选择的入口点是内核自身的入口点:系统启动。

理解本文档需要良好的 C 语言基础,以及对 Unix 概念和 PC 架构的一些熟悉。但是,本章不会出现 C 代码,而是指向实际代码的指针。内核设计的精细问题在指南的其他章节中解释,而本章倾向于保持非正式的概述。

本章中引用的文件的任何路径名均指主源代码树目录,通常为 /usr/src/linux。

[NEW]此处报告的大部分信息取自 Linux 1.0 版本的源代码。尽管如此,有时也会提供对更高版本的引用。导览中任何段落前面带有 [NEW] 图像,都旨在强调内核在 1.0 版本之后经历的更改。如果不存在此类段落,则在 1.0.9-1.1.76 版本之前未发生任何更改。

[MORE]有时,文本中会出现像这样的段落。它是指向正确来源以获取有关刚刚涵盖主题的更多信息的指针。不用说,源代码是主要来源。

启动系统

当 PC 开机时,80x86 处理器处于实模式,并执行地址 0xFFFF0 处的代码,该地址对应于 ROM-BIOS 地址。PC BIOS 对系统执行一些测试,并在物理地址 0 处初始化中断向量。之后,它将可启动设备的第一个扇区加载到 0x7C00,并跳转到该地址。该设备通常是软盘或硬盘驱动器。前面的描述非常简化,但这足以理解内核的初始工作原理。

Linux 内核的最开始部分是用 8086 汇编语言编写的 (boot/bootsect.S)。运行时,它将自身移动到绝对地址 0x90000,从启动设备加载接下来的 2 kBytes 代码到地址 0x90200,并将内核的其余部分加载到地址 0x10000。消息 ``正在加载...'' 在系统加载期间显示。然后,控制权传递给 boot/Setup.S 中的代码,另一个实模式汇编源文件。

设置部分识别主机系统的一些功能和 vga 板的类型。如果被要求,它会要求用户选择控制台的视频模式。然后,它将整个系统从地址 0x10000 移动到地址 0x1000,进入保护模式并跳转到系统的其余部分(地址 0x1000)。

下一步是内核解压缩。地址 0x1000 处的代码来自 zBoot/head.S,它初始化寄存器并调用decompress_kernel(),它又由 zBoot/inflate.c、zBoot/unzip.c 和 zBoot/misc.c 组成。解压缩的数据转到地址 0x100000 (1 Meg),这就是 Linux 无法在小于 2 megs 内存的情况下运行的主要原因。[在 1 MB 内存中使用未压缩的内核已经实现;请参阅 内存拯救者--ED]

[MORE]将内核封装在 gzip 文件中是通过 Makefile 和 zBoot 目录中的实用程序完成的。它们是值得一看的有趣文件。

[NEW]内核 1.1.75 版本将 boot 和 zBoot 目录向下移动到 arch/i386/boot。此更改旨在允许为不同的架构构建真正的内核。尽管如此,我将坚持 i386 特有的信息。

解压缩的代码在地址 0x1010000 处执行 [也许我在这里迷失了物理地址的踪迹,因为我对 gas 源代码不是很了解],所有 32 位设置都在这里完成:IDT、GDT 和 LDT 被加载,处理器和协处理器被识别,并且分页被设置;最终,例程start_kernel被调用。上述操作的源代码在 boot/head.S 中。它可能是整个内核中最棘手的代码。

请注意,如果在前面的任何步骤中发生错误,计算机将锁定。当操作系统尚未完全运行时,无法处理错误。

start_kernel()位于 init/main.c 中,并且永不返回。从现在开始的所有内容都以 C 语言编写,除了中断管理和系统调用进入/离开(好吧,大多数宏也嵌入了汇编代码)。

旋转轮子

在处理完所有棘手的问题之后,start_kernel()初始化内核的所有部分,特别是

最后,内核准备好move_to_user_mode(),以便 forkinit进程,其代码在同一个源文件中。然后,进程号 0,即所谓的空闲任务,保持在一个无限空闲循环中运行。

进程init尝试执行 /etc/init、或 /bin/init、或 /sbin/init。

如果它们都不成功,则提供代码来执行 ``/bin/sh /etc/rc'' 并在第一个终端上 fork 一个 root shell。这段代码可以追溯到 Linux 0.01,当时操作系统仅由内核组成,并且没有login进程可用。

exec()从标准位置之一执行 init 程序之后(假设我们有一个),内核对程序流程没有直接控制权。从现在开始,它的作用是为进程提供系统调用,以及服务异步事件(例如硬件中断)。多任务处理已经设置好,现在由 init 通过fork()系统守护进程和登录进程来管理多用户访问。

由于内核负责提供服务,因此导览将通过查看这些服务(“系统调用”)以及提供有关底层数据结构和代码组织的一般概念来继续进行。

内核如何看待进程

从内核的角度来看,进程是进程表中的一个条目。仅此而已。

然后,进程表是系统中最重要的数据结构之一,与内存管理表和缓冲区缓存一起。进程表中的单个项是task_struct结构,这是一个相当庞大的结构,在 include/linux/sched.h 中定义。在task_struct中,既保留了低级信息也保留了高级信息——从某些硬件寄存器的副本到进程的工作目录的 inode。

进程表既是数组又是双向链表,也是树。物理实现是一个指针的静态数组,其长度为NR_TASKS,这是一个在 include/linux/tasks.h 中定义的常量,每个结构都驻留在保留的内存页中。列表结构通过指针next_taskprev_task实现,而树结构相当复杂,这里不再描述。您可能希望更改NR_TASKS默认值 128,但请确保具有正确的依赖文件以强制重新编译所有涉及的源文件。

启动结束后,内核始终代表某个进程工作,全局变量current,一个指向task_struct项的指针,用于记录正在运行的进程。current仅由调度程序在 kernel/sched.c 中更改。但是,当必须查看所有进程时,使用宏for_each_task。当系统负载较轻时,它比顺序扫描数组快得多。

进程始终在“用户模式”或“内核模式”下运行。用户程序的主体在用户模式下执行,系统调用在内核模式下执行。进程在两种执行模式下使用的堆栈是不同的——传统的堆栈段用于用户模式,而固定大小的堆栈(一页,由进程拥有)用于内核模式。内核堆栈页永远不会被换出,因为它必须在每次输入系统调用时都可用。

系统调用在内核中以 C 语言函数的形式存在,它们的“官方”名称以“sys_”为前缀。例如,一个名为burnout的系统调用会调用内核函数sys_burnout().

[MORE]本指南的第 3 章描述了系统调用机制。查看 include/linux/sched.h 中的for_each_taskSET_LINKS可以帮助理解进程表中的列表和树结构。

创建和销毁进程

unix 系统通过fork()系统调用创建进程,进程终止通过exit()或接收信号来执行。它们的 Linux 实现位于 kernel/fork.c 和 kernel/exit.c 中。

Forking 很简单,fork.c 很短且易于理解。它的主要任务是填充新进程的数据结构。除了填充字段外,相关的步骤还有

信息sys_fork()

还管理文件描述符和 inodes。fork()[NEW]1.0 内核提供了一些对线程的残余支持,并且

系统调用显示了一些这方面的提示。内核线程是主流内核之外正在进行的工作。从进程退出比较棘手,因为父进程必须收到任何子进程退出的通知。此外,进程可以通过被另一个进程kill()ed 来退出(这些是 Unix 功能)。因此,文件 exit.c 是sys_kill()和各种sys_wait()以及.

sys_exit()

的所在地。此处未描述属于 exit.c 的代码——它不是那么有趣。它处理大量细节,以使系统保持一致状态。然后,POSIX 标准对信号的要求很高,必须加以处理。

fork()执行程序exec()ing,同一个程序的两个副本正在运行。其中一个通常exec()另一个程序。系统调用必须找到可执行文件的二进制映像,加载并运行它。“加载”这个词不一定意味着“将二进制映像复制到内存中”,因为 Linux 支持按需加载。

Linux 的exec()实现支持不同的二进制格式。这是通过linux_binfmt结构完成的,该结构嵌入了指向函数的两个指针——一个用于加载可执行文件,另一个用于加载库,每种二进制格式都代表可执行文件和库。共享库的加载与exec()在同一个源文件中实现,但让我们坚持exec()本身。

Unix 系统为程序员提供了六种exec()函数。除了一个之外,所有函数都可以作为库函数实现,Linux 内核实现了sys_execve()。它执行一个非常简单的任务:加载可执行文件的头部,并尝试执行它。如果前两个字节是“#!'”,则解析第一行并调用解释器,否则按顺序尝试注册的二进制格式。

本机 Linux 格式在 fs/exec.c 中直接支持,相关函数为load_aout_binaryload_aout_library。对于二进制文件,加载“a.out”可执行文件的函数最终要么mmap()磁盘文件,要么调用read_exec()。前者使用 Linux 按需加载机制在访问程序页时将其换入,而后者在主机文件系统不支持内存映射时使用(例如“msdos”文件系统)。

[NEW]最新的 1.1 内核嵌入了一个修订后的 msdos 文件系统,它支持mmap()。此外,struct linux_binfmt是一个链表而不是数组,以允许将新的二进制格式作为内核模块加载。最后,该结构本身已扩展为访问与格式相关的核心转储例程。

访问文件系统

众所周知,文件系统是 Unix 系统中最基本的资源,如此基本和普遍,以至于它需要一个更方便的名称——我将坚持标准做法,简单地称其为“fs”。

我假设读者已经了解基本的 Unix fs 概念——访问权限、inodes、superblock、mountumount。这些概念比我更聪明的作者在标准的 Unix 文献中已经很好地解释过了,所以我不会重复他们的工作,而将坚持 Linux 特有的问题。

虽然早期的 Unices 曾经支持单一的 fs 类型,其结构在整个内核中广泛使用,但今天的实践是在内核和 fs 之间使用标准化的接口,以便于跨架构的数据交换。Linux 本身提供了一个标准化的层,用于在内核和每个 fs 模块之间传递信息。这个接口层称为 VFS,即“虚拟文件系统”。

因此,文件系统代码分为两层:上层负责管理内核表和数据结构,下层由一组与 fs 相关的函数组成,并通过 VFS 数据结构调用。所有与 fs 无关的材料都位于 fs/*.c 文件中。它们解决了以下问题

然后,VFS 接口由一组相对高级的操作组成,这些操作从与 fs 无关的代码中调用,并实际上由每种文件系统类型执行。最相关的结构是inode_operationsfile_operations,尽管它们不是唯一的:还存在其他结构。所有这些都在 include/linux/fs.h 中定义。

内核到实际文件系统的入口点是结构file_system_type。fs/filesystems.c 中包含一个file_system_type的数组,并且在每次发出挂载时都会引用它。相关 fs 类型的函数read_super然后负责填充struct super_block项,该项又嵌入了struct super_operationsstruct type_sb_info。前者为当前 fs 类型的通用 fs 操作提供指针,后者嵌入 fs 类型的特定信息。

[NEW]文件系统类型数组已转换为链表,以允许将新的 fs 类型作为内核模块加载。函数 (un-)register_filesystem在 fs/super.c 中编码。

文件系统类型的快速解剖

文件系统类型的作用是执行低级任务,用于将相对高级的 VFS 操作映射到物理介质(磁盘、网络或其他)。VFS 接口足够灵活,可以支持传统的 Unix 文件系统和诸如 msdos 和 umsdos 类型之类的特殊情况。

除了自己的目录外,每种 fs 类型都由以下项目组成

中的条目。fs 类型的自己的目录包含所有实际代码,负责 inode 和数据管理。

[MORE]本指南中关于 procfs 的章节揭示了该 fs 类型的低级代码和 VFS 接口的所有细节。阅读本章后,fs/procfs 中的源代码很容易理解。

我们现在将查看 VFS 机制的内部工作原理,并以 minix 文件系统源代码为例。我选择 minix 类型是因为它小而完整;此外,Linux 中的任何其他 fs 类型都源自 minix 类型。ext2 类型是最近 Linux 安装中事实上的标准,它比 minix 类型复杂得多,对其的探索留给聪明的读者作为练习。

当挂载 minix-fs 时,minix_read_super用从挂载设备读取的数据填充super_block结构。结构的s_op字段然后将保存指向minix_sops的指针,通用文件系统代码使用该指针来调度超级块操作。

将新挂载的 fs 链接到全局系统树中依赖于以下数据项(假设sbsuper_block结构,并且dir_i指向挂载点的 inode)

卸载最终将由do_umount执行,它又调用minix_put_super.

每当访问文件时,minix_read_inode就会发挥作用;它使用来自inode结构的字段填充系统范围的minix_inode结构。inode->i_op字段根据inode->i_mode填充,它负责对文件的任何进一步操作。刚刚描述的 minix 函数的源代码可以在 fs/minix/inode.c 中找到。

进程inode_operations结构用于将 inode 操作(您猜对了)调度到 fs 类型特定的内核函数;结构中的第一个条目是指向file_operations项的指针,它是数据管理等效于i_op。minix fs 类型允许 inode 操作集的三个实例(用于目录、文件和符号链接)和文件操作集的两个实例(符号链接不需要一个)。

目录操作 (minix_readdir单独) 可以在 fs/minix/dir.c 中找到;文件操作(读和写)出现在 fs/minix/file.c 中,符号链接操作(读取和跟踪链接)在 fs/minix/symlink.c 中。

minix 目录的其余部分实现了以下任务

控制台驱动程序

作为大多数 Linux 机器上的主要 I/O 设备,控制台驱动程序值得关注。与控制台以及其他字符驱动程序相关的源代码可以在 drivers/char 中找到,当我们命名文件时,我们将使用此目录作为我们的参考点。

控制台初始化由函数tty_init()在 tty_io.c 中执行。此函数仅关注获取主设备号和为每个设备集调用 init 函数。con_init(),然后是与控制台相关的函数,位于 console.c 中。

[NEW]控制台的初始化在 1.1 演进过程中发生了很大的变化。console_init()已从tty_init()中分离出来,并由 ../../main.c 直接调用。虚拟控制台现在是动态分配的,并且相当多的代码已经更改。因此,我将跳过初始化的细节、分配等等。

文件操作如何调度到控制台

此段落非常低级,可以愉快地跳过。

不用说,Unix 设备是通过文件系统访问的。本段详细介绍了从设备文件到实际控制台函数的所有步骤。此外,以下信息是从 1.1.73 源代码中提取的,可能与 1.0 源代码略有不同。

当设备 inode 打开时,函数chrdev_open()(或blkdev_open(),但我们将坚持字符设备) 在 ../../fs/devices.c 中执行。此函数通过结构def_chr_fops到达,该结构又被chrdev_inode_operations引用,所有文件系统类型都使用它(请参阅上一节关于文件系统的部分)。

chrdev_open通过替换设备特定的file_operations表在当前的filp中来负责指定设备操作,并调用特定的open()。设备特定表保存在数组chrdevs[]中,由主设备号索引,并由相同的 ../../fs/devices.c 填充。

如果设备是 tty 设备(我们不是以控制台为目标吗?),我们来到 tty 驱动程序,其函数位于tty_io.c中,由tty_fops索引。因此,tty_open()调用init_dev(),它基于次设备号分配设备所需的任何数据结构。

次设备号也用于检索设备的实际驱动程序,该驱动程序已通过tty_register_driver()注册。然后,驱动程序仍然是另一个用于调度计算的结构,就像file_ops一样;它负责写入和控制设备。在管理 tty 中使用的最后一个数据结构是行规程,稍后描述。控制台(和任何其他 tty 设备)的行规程由initialize_tty_struct()设置,由init_dev.

调用。我们在本段中接触到的一切都与设备无关。唯一的控制台特定之处在于 console.c 在con_init()期间注册了自己的驱动程序。相反,行规程与设备无关。

[MORE]tty_driver结构在<linux/tty_driver.h>中完全解释。.

[NEW]以上信息是从 1.1.73 源代码中提取的。您的内核很可能有所不同(“此信息如有更改,恕不另行通知”)。

写入控制台

当写入控制台设备时,函数con_write被调用。此函数管理用于为应用程序提供完整屏幕管理的所有控制字符和转义序列。实现的转义序列是 vt102 终端的转义序列;这意味着当您 telnet 连接到非 Linux 主机时,您的环境应该说TERM=vt102;但是,本地活动的最佳选择是TERM=console,因为 Linux 控制台提供了 vt102 功能的超集。

con_write()因此,主要由嵌套的 switch 语句组成,用于处理有限状态自动机,一次解释一个转义序列字符。在正常模式下,正在打印的字符直接写入视频内存,使用当前的attr-ibute。在 console.c 中,struct vc的所有字段都通过宏访问,因此任何对(例如)attr的引用,实际上都指的是结构vc_cons[currcons]中的字段,只要currcons是被引用的控制台的编号。

[NEW]实际上,在较新的内核中,vc_cons不再是结构数组,它现在是指针数组,其内容是kmalloc()ed。宏的使用大大简化了更改方法,因为大部分代码都不需要重写。

控制台内存到屏幕的实际映射和取消映射由函数set_scrmem()(它将数据从控制台缓冲区复制到视频内存)和get_scrmem(它将数据复制回控制台缓冲区)执行。当前控制台的私有缓冲区物理映射到实际的视频 RAM 上,以最大限度地减少数据传输次数。这意味着get- 和set-_scrmem()static到 console.c 的,并且仅在控制台切换期间调用。

读取控制台

读取控制台是通过行规程完成的。Linux 中的默认(也是唯一的)行规程称为tty_ldisc_N_TTY。行规程是“规范通过线路的输入”。它是另一个函数表(我们已经习惯了这种方法,不是吗?),它与读取设备有关。借助termios标志,行规程控制来自 tty 的输入:原始模式、cbreak 模式和 cooked 模式;select(); ioctl()等等。

行规程中的读取函数称为read_chan(),它读取 tty 缓冲区,而不管它来自何处。原因是字符通过 tty 的到达由异步硬件中断管理。

[MORE]行规程N_TTY可以在同一个 tty_io.c 中找到,尽管后来的内核使用不同的 n_tty.c 源文件。

控制台输入的最低级别是键盘管理的一部分,因此在 keyboard.c 中的函数keyboard_interrupt().

中处理。

键盘管理

键盘管理是一场噩梦。它被限制在 keyboard.c 文件中,该文件充满了十六进制数字,用于表示不同制造商键盘中出现的各种键码。

我不会深入研究 keyboard.c,因为那里没有与内核黑客相关的任何信息。

[MORE]对于那些真正对 Linux 键盘感兴趣的读者,研究 keyboard.c 的最佳方法是从最后一行向上看。最低级别的细节主要出现在文件的前半部分。

切换当前控制台当前控制台通过调用函数change_console()ioctl()切换,该函数位于 tty_io.c 中,并由 keyboard.c 和 vt.c 调用(前者响应按键切换控制台,后者在程序通过调用

调用时请求切换控制台)。complete_change_console()takes care of the second part of it. Splitting the switch is meant to complete the task after a possible handshake with the process controlling the tty we're leaving. If the console is not under process control,当前控制台通过调用函数调用complete_change_console()by itself. Process intervertion is needed to successfully switch from a graphic console to a text one and viceversa, and the X server (for example) is the controlling process of its own graphic console.

The selection mechanism

``selection'' is the cut and paste facility for the Linux text consoles. The mechanism is mainly handled by a user-level process, which can be instantiated by either selection or gpm. The user-level program usesioctl()on the console to tell the kernel to highlight a region of the screen. The selected text, then, is copied to a selection buffer. The buffer is a static entity in console.c. Pasting text is accomplished by `manually' pushing characters in the tty input queue. The whole selection mechanism is protected by#ifdefso users can disable it during kernel configuration to save a few kilobytes of ram.

Selection is a very-low-level facility, and its workings are hidden from any other kernel activity. This means that most#ifdef's simply deals with removing the highlight before the screen is modified in any way.

[NEW]Newer kernels feature improved code for selection, and the mouse pointer can be highlighted independently of the selected text (1.1.32 and later). Moreover, from 1.1.73 onward a dynamic buffer is used for selected text rather than a static one, making the kernel 4kB smaller.

ioctl()ling the device

进程ioctl()system call is the entry point for user processes to control the behaviour of device files. Ioctl management is spawned by ../../fs/ioctl.c, where the realsys_ioctl()resides. The standardioctlrequests are performed right there, other file-related requests are processed byfile_ioctl()(same source file), while any other request is dispatches to the device-specificioctl()function.

进程ioctlmaterial for console devices resides in vt.c, because the console driver dispatches ioctl requests tovt_ioctl().

[NEW]The information above refer to 1.1.7x. The 1.0 kernel doesn't have the ``driver'' table, andvt_ioctl()is pointed to directly by thefile_operations()table.

Ioctl material is quite confused, indeed. Some requests are related to the device, and some are related to the line discipline. I'll try to summarize things for the 1.0 and the 1.1.7x kernels. Anything happened in between.

The 1.1.7x series features the following approach: tty_ioctl.c implements only line discipline requests (namelyn_tty_ioctl(), which is the only n_tty function outside of n_tty.c), while thefile_operationsfield points totty_ioctl()in tty_io.c. If the request number is not resolved bytty_ioctl(), it is passed along totty->driver.ioctlor, if it fails, totty->ldisc.ioctl. Driver-related stuff for the console it to be found in vt.c, while line discipline material is in tty_ioctl.c.

In the 1.0 kernel,tty_ioctl()is in tty_ioctl.c and is pointed to by generic ttyfile_operations. Unresolved requests are passed along to the specific ioctl function or to the line-discipline code, in a way similar to 1.1.7x.

Note that in both cases, theTIOCLINUXrequest is in the device-independent code. This implies that the console selection can be set byioctlling any tty (set_selection()always operates on the foreground console), and this is a security hole. It is also a good reason to switch to a newer kernel, where the problem is fixed by only allowing the superuser to handle the selection.

A variety of requests can be issued to the console device, and the best way to know about them is to browse the source file vt.c.

Copyright (C) 1994 Alessandro Rubini, rubini@pop.systemy.it


Messages

8. Question: access a file from module by proy018@avellano.usal.es
7. Question: Which head.S? by Johnie Stafford
1. Feedback: Untitled by benschop@eb.ele.tue.nl
6. Question: STREAMS and Linux by Venkatesha Murthy G.
1. None: Re: STREAMS and LINUX by Vineet Sharma
5. None: Do you still need to run update ? by Chris Ebenezer
4. Question: Do you still need to run bdflush? by Steve Dunham
1. Note: Already answered... by Michael K. Johnson
3. Idea: Kernel Configuration and Makefile Structure by Steffen Moeller
1. More: Editing services available... by Michael K. Johnson
2. Feedback: Kernel configuration by Venkatesha Murthy G.
2. Note: Re: Kernel threads by Paul Gortmaker
1. None: More on usage of kernel threads. by David S. Miller
1. More: kernel startup code by Alan Cox
1. None: Untitled by Karapetyants Vladimir Vladimirovitch