工学1号馆

home

Linux0.11内核剖析--内核体系结构

By Wu Yudong on September 09, 2016

一个完整可用的操作系统主要由 4 部分组成:硬件、操作系统内核、操作系统服务和用户应用程序,如下图所示:

用户应用程序是指那些字处理程序、 Internet 浏览器程序或用户自行编制的各种应用程序;

操作系统服务程序是指那些向用户所提供的服务被看作是操作系统的部分功能的程序。

在 Linux 操作系统上,这些程序包括 X 窗口系统、 shell 命令解释系统以及那些内核编程接口等系统程序;操作系统内核程序即是本书所感兴趣的部分,它主要用于对硬件资源的抽象和访问调度。

Linux 内核的主要用途就是为了与计算机硬件进行交互,实现对硬件部件的编程控制和接口操作,调度对硬件资源的访问,并为计算机上的用户程序提供一个高级的执行环境和对硬件的虚拟接口。

在本文内容中,我们首先基于 Linux 0.11 版的内核源代码,简明地描述 Linux 内核的基本体系结构、主要构成模块。然后对源代码中出现的几个重要数据结构进行说明。

本文地址:http://wuyudong.com/2016/09/09/2677.html,转载请注明源地址。

1、Linux 内核模式

目前,操作系统内核的结构模式主要可分为整体式的单内核模式和层次式的微内核模式。而 Linux 0.11 内核,则是采用了单内核模式。

单内核模式的主要优点是内核代码结构紧凑、执行速度快,不足之处主要是层次结构性不强。

在单内核模式的系统中,操作系统所提供服务的流程为:应用主程序使用指定的参数值执行系统调用指令(int x80),使 CPU 从用户态( User Mode)切换到核心态( Kernel Model),然后操作系统根据具体的参数值调用特定的系统调用服务程序,而这些服务程序则根据需要再底层的一些支持函数以完成特定的功能。在完成了应用程序所要求的服务后,操作系统又从核心态切换回用户态,返回到应用程序中继续执行后面的指令。

因此概要地讲,单内核模式的内核也可粗略地分为三个层次:调用服务的主程序层、执行系统调用的服务层和支持系统调用的底层函数。如下图所示:
单内核模式的简单结构模型

2、Linux 内核系统体系结构

Linux 内核主要由 5 个模块构成,它们分别是:进程调度模块、内存管理模块、文件系统模块、进程间通信模块和网络接口模块。

进程调度模块用来负责控制进程对 CPU 资源的使用。所采取的调度策略是各进程能够公平合理地访问 CPU,同时保证内核能及时地执行硬件操作。

内存管理模块用于确保所有进程能够安全地共享机器主内存区,同时,内存管理模块还支持虚拟内存管理方式,使得 Linux 支持进程使用比实际内存空间更多大的内存容量。并可以利用文件系统把暂时不用的内存数据块会被交换到外部存储设备上去,当需要时再交换回来。

文件系统模块用于支持对外部设备的驱动和存储。虚拟文件系统模块通过向所有的外部存储设备提供一个通用的文件接口,隐藏了各种硬件设备的不同细节。从而提供并支持与其它操作系统兼容的多种文件系统格式。

进程间通信模块子系统用于支持多种进程间的信息交换方式。

网络接口模块提供对多种网络通信标准的访问并支持许多网络硬件。

这几个模块之间的依赖关系见图 所示。其中的连线代表它们之间的依赖关系,虚线和虚框部分表示 Linux 0.11 中还未实现的部分(从 Linux 0.95 版才开始逐步实现虚拟文件系统,而网络接口的支持到 0.96版才有)。
Linux 内核系统模块结构及相互依赖关系:
若从单内核模式结构模型出发,我们还可以根据 linux 0.11 内核源代码的结构将内核主要模块绘制成下图所示的框图结构:

3、Linux 内核进程控制

对于 linux 0.11 内核来讲, 系统最多可有 64 个进程同时存在。 系统除了第一个进程是“手工”建立以外, 其余的都是进程使用系统调用 fork 创建的新进程。内核程序使用进程标识号(process ID, pid)来标识每个进程。进程由可执行的指令代码、数据和堆栈区组成。进程中的代码和数据部分分别对应一个执行文件中的代码段、数据段。每个进程只能执行自己的代码和访问自己的数据及堆栈区。进程之间相互之间的通信需要通过系统调用了进行。对于只有一个 CPU 的系统,在某一时刻只能有一个进程正在运行。内核通过调度程序分时调度各个进程运行(分时间片)。
Linux 系统中,一个进程可以在内核态( kernel mode)或用户态( user mode)下执行,因此, linux 内核栈和用户栈是分开的。用户栈用于进程在用户态下临时保存调用函数的参数、局部变量等数据。内核栈则含有内核程序执行函数调用时的信息。内核程序是通过进程表对进程进行管理的,每个进程在进程表中占有一项。在 linux 系统中,进程表项是一个 task 结构。

当一个进程在执行时, CPU 的所有寄存器中的值、进程的状态以及堆栈中的内容被称为该进程的上下文。当内核需要切换( switch)至另一个进程时,它就需要保存当前进程的所有状态,也即保存当前进程的上下文,以便在再次执行该进程时,能够恢复到切换时的状态执行下去。在发生中断时,内核就在被中断进程的上下文中,在内核态下执行中断服务例程。但同时会保留所有需要用到的资源,以便中断服务结束时能恢复被中断进程的执行。

一个进程在其生存期内,可处于一组不同的状态下,称为进程状态。见下图所示:
当进程正在被 CPU 执行时,被称为处于执行状态( running)。当进程正在等待系统中的资源而处于等待状态时,则称其处于睡眠等待状态。在 linux 系统中,还分为可中断的和不可中断的等待状态。当系统资源已经可用时,进程就被唤醒而进入准备运行状态,该状态称为就绪态。当进程已停止运行,但其父进程还没有询问其状态时,则称该进程处于僵死状态。当进程被终止时,称其处于停止状态。只有当进程从“内核运行态”转移到“睡眠状态”时,内核才会进行进程切换操作。在内核态下运行的进程不能被其它进程抢占,而且一个进程不能改变另一个进程的状态。为了避免进程切换时造成内核数据错误,内核在执行临界区代码时会禁止一切中断。

4、Linux 内核对内存的使用方法

在 linux 0.11 内核中,为了有效地使用系统的物理内存,内存被划分成几个功能区域,见下图所示:

其中,linux 内核程序占据在物理内存的开始部分,接下来是用于供硬盘或软盘等块设备使用的高速缓冲区部分。当一个进程需要读取块设备中的数据时,系统会首先将数据读到高速缓冲区中;当有数据需要写到块设备上去时,系统也是先将数据放到高速缓冲区中,然后由块设备驱动程序写到设备上。最后部分是供所有程序可以随时申请使用的主内存区部分。内核程序在使用主内存区时,也同样要首先向内核的内存管理模块提出申请,在申请成功后方能使用。对于含有 RAM 虚拟盘的系统,主内存区头部还要划去一部分,共虚拟盘存放数据。

由于计算机系统中所含的实际物理内存容量是有限制的。为了能有效地使用这些物理内存,Linux 采用了 Intel CPU 的内存分页管理机制,使用虚拟线性地址与实际物理内存地址映射的方法让所有同时执行的程序共同使用有限的内存。

内存分页管理的基本原理是将整个主内存区域划分成 4096 字节为一页的内存页面。程序申请使用内存时,就以内存页为单位进行分配。在使用这种内存分页管理方法时,每个执行中的进程(任务)可以使用比实际内存容量大得多的线性地址空间。对于 Intel 80386 系统,其 CPU 可以提供多达 4G 的线性地址空间。

对于 linux 0.11 内核,系统设置全局描述符表 GDT 中的段描述符项数最大为 256,其中 2 项空闲、 2 项系统使用,每个进程使用两项。因此,此时系统可以最多容纳(256-4)/2 + 1=127 个任务,并且虚拟地址范围是 ((256-4)/2)* 64MB 约等于8G。 但 0.11 内核中人工定义最大任务数 NR_TASKS = 64 个, 每个进程虚拟地址(或线性地址)范围是 64M,并且各个进程的虚拟地址起始位置是(任务号-1)*64MB。 因此所使用的虚拟地址空间范围是 64MB*64 =4G,见下图所示。 4G 正好与 CPU 的线性地址空间范围或物理地址空间范围相同,因此在 0.11 内核中比较容易混淆三种地址概念。

linux 0.11 中,在进行地址映射时,我们需要分清 3 种地址之间的变换:

a. 进程虚拟地址,是从虚拟地址 0 开始计,最大 64M; b. CPU 的线性地址空间( 0--4G); c. 实际物理内存地址。

进程的虚拟地址需要首先通过其局部段描述符变换为 CPU 整个线性地址空间中的地址,然后再使用页目录表 PDT(一级页表)和页表 PT(二级页表)映射到实际物理地址页上。因此两种变换不能混淆。为了使用实际物理内存,每个进程的线性地址通过二级内存页表动态地映射到主内存区域的不同内存页上。因此每个进程最大可用的虚拟内存空间是 64MB。每个进程的逻辑地址通过加上任务号*64M,即可转换为线性地址。不过在注释中,我们通常将进程中的地址简单地称为线性地址。

5、Linux 内核源代码的目录结构

由于 Linux 内核是一种单内核模式的系统,因此,内核中所有的程序几乎都有紧密的联系,它们之间的依赖和调用关系非常密切。所以在阅读一个源代码文件时往往需要参阅其它相关的文件。因此有必要在开始阅读内核源代码之前,先熟悉一下源代码文件的目录结构和安排。

这里我们首先列出 Linux 内核完整的源代码目录,包括其中的子目录。然后逐一介绍各个目录中所包含程序的主要功能,使得整个内核源代码的安排形式能在我们的头脑中建立起一个大概的框架,以便于后面开始的源代码阅读工作。当我们使用 tar 命令将 linux-0.11.tar.gz 解开时,内核源代码文件被放到了 linux 目录中。其中的目录结构为:

该内核版本的源代码目录中含有 14 个子目录,总共包括 102 个代码文件。下面逐个对这些子目录中的内容进行描述。

1、内核主目录 linux
linux 目录是源代码的主目录,在该主目录中除了包括所有的 14 个子目录以外,还含有唯一的一个makefile 文件。该文件是编译辅助工具软件 make 的参数配置文件。 make 工具软件的主要用途是通过识别哪些文件已被修改过,从而自动地决定在一个含有多个源程序文件的程序系统中哪些文件需要被重新编译。因此, make 工具软件是程序项目的管理软件。linux 目录下的这个 makefile 文件还嵌套地调用了所有子目录中包含的 makefile 文件,这样,当 linux 目录(包括子目录)下的任何文件被修改过时, make 都会对其进行重新编译。因此为了编译整个内核所有的源代码文件,只要在 linux 目录下运行一次 make 软件即可。

2、引导启动程序目录 boot
boot 目录中含有 3 个汇编语言文件,是内核源代码文件中最先被编译的程序。这 3 个程序完成的主要功能是当计算机加电时引导内核启动,将内核代码加载到内存中,并做一些进入 32 位保护运行方式前的系统初始化工作。 其中 bootsect.s 和 setup.s 程序需要使用 as86 软件来编译, 使用的是 as86 的汇编语言格式(与微软的类似),而 head.s 需要用 GNU as 来编译,使用的是 AT&T 格式的汇编语言。这两种汇编语言在下一章的代码注释里以及代码列表后面的说明中会有简单的介绍。bootsect.s 程序是磁盘引导块程序,编译后会驻留在磁盘的第一个扇区中(引导扇区, 0 磁道(柱面),0 磁头,第 1 个扇区)。在 PC 机加电 ROM BIOS 自检后,将被 BIOS 加载到内存 0x7C00 处进行执行。setup.s 程序主要用于读取机器的硬件配置参数,并把内核模块 system 移动到适当的内存位置处。head.s 程序会被编译连接在 system 模块的最前部分,主要进行硬件设备的探测设置和内存管理页面的初始设置工作。

3、文件系统目录 fs
是文件系统实现程序的目录,共包含 17 个 C 语言程序。这些程序之间的主要引用关系所示图中每个方框代表一个文件,从上到下按基本按引用关系放置。其中各文件名均略去了后缀.c,虚框中是的程序文件不属于文件系统,带箭头的线条表示引用关系,粗线条表示有相互引用关系。
由图可以看出,该目录中的程序可以划分成四个部分:高速缓冲区管理、低层文件操作、文件数据访问和文件高层函数,对于文件系统,我们可以将它看成是内存高速缓冲区的扩展部分。所有对文件系统中数据的访问,都需要首先读取到高速缓冲区中。本目录中的程序主要用来管理高速缓冲区中缓冲块的使用分配和块设备上的文件系统。
4、头文件主目录 include
头文件目录中总共有 32 个.h 头文件。其中主目录下有 13 个, asm 子目录中有 4 个, linux 子目录中有10 个, sys 子目录中有 5 个:

<a.out.h>  //a.out 头文件,定义了 a.out 执行文件格式和一些宏。
<const.h>  //常数符号头文件,目前仅定义了 i 节点中 i_mode 字段的各标志位。
<ctype.h>  //字符类型头文件。定义了一些有关字符类型判断和转换的宏。
<errno.h>  //错误号头文件。包含系统中各种出错号。 (Linus 从 minix 中引进的)。
<fcntl.h>  //文件控制头文件。用于文件及其描述符的操作控制常数符号的定义。
<signal.h>  //信号头文件。定义信号符号常量,信号结构以及信号操作函数原型。
<stdarg.h>  //标准参数头文件。以宏的形式定义变量参数列表。主要说明了 -个类型(va_list)和三个宏(va_start, va_arg 和 va_end),用于vsprintf、vprintf、vfprintf 函数
<stddef.h>  //标准定义头文件。定义了 NULL, offsetof(TYPE, MEMBER)。
<string.h>   //字符串头文件。主要定义了一些有关字符串操作的嵌入函数。
<termios.h>  // 终端输入输出函数头文件。主要定义控制异步通信口的终端接口。
<time.h>   //时间类型头文件。其中最主要定义了 tm 结构和一些有关时间的函数原形。
<unistd.h>   //Linux 标准头文件。定义了各种符号常数和类型,并申明了各种函数。如定义了__LIBRARY__,则还包括系统调用号和内嵌汇编_syscall0()等。
<utime.h>  //用户时间头文件。定义了访问和修改时间结构以及 utime()原型

体系结构相关头文件子目录 include/asm
这些头文件主要定义了一些与 CPU 体系结构密切相关的数据结构、宏函数和变量。共 4 个文件。

<asm/io.h>  //io 头文件。以宏的嵌入汇编程序形式定义对 io 端口操作的函数。
<asm/memory.h>  //内存拷贝头文件。含有 memcpy()嵌入式汇编宏函数。
<asm/segment.h>  // 段操作头文件。定义了有关段寄存器操作的嵌入式汇编函数。
<asm/system.h>  // 系统头文件。定义了设置或修改描述符/中断门等的嵌入式汇编宏。

Linux 内核专用头文件子目录 include/linux

<linux/config.h> //内核配置头文件。定义键盘语言和硬盘类型(HD_TYPE)可选项。
<linux/fdreg.h> //软驱头文件。含有软盘控制器参数的一些定义。
<linux/fs.h> //文件系统头文件。定义文件表结构(file,buffer_head,m_inode 等)。
<linux/hdreg.h> //硬盘参数头文件。定义访问硬盘寄存器端口,状态码,分区表等信息。
<linux/head.h> //head 头文件,定义了段描述符的简单结构,和几个选择符常量。
<linux/kernel.h> //内核头文件。含有一些内核常用函数的原形定义。
<linux/mm.h> //内存管理头文件。含有页面大小定义和一些页面释放函数原型。
<linux/sched.h> //调度程序头文件,定义了任务结构 task_struct、初始任务 0 的数据,
        //还有一些有关描述符参数设置和获取的嵌入式汇编函数宏语句。
<linux/sys.h> //系统调用头文件。含有 72 个系统调用 C 函数处理程序,以'sys_'开头。
<linux/tty.h> //tty 头文件,定义了有关 tty_io,串行通信方面的参数、常数。

系统专用数据结构子目录 include/sys

<sys/stat.h>    //文件状态头文件。含有文件或文件系统状态结构 stat{}和常量。
<sys/times.h>   //定义了进程中运行时间结构 tms 以及 times()函数原型。
<sys/types.h>   //类型头文件。定义了基本的系统数据类型。
<sys/utsname.h> //系统名称结构头文件。
<sys/wait.h>    //等待调用头文件。定义系统调用 wait()核 waitpid()及相关常数符号。

5、内核初始化程序目录 init
该目录中仅包含一个文件 main.c。用于执行内核所有的初始化工作,然后移到用户模式创建新进程,并在控制台设备上运行 shell 程序。程序首先根据机器内存的多少对缓冲区内存容量进行分配,如果还设置了要使用虚拟盘,则在缓冲区内存后面也为它留下空间。之后就进行所有硬件的初始化工作,包括人工创建第一个任务( task 0),并设置了中断允许标志。在执行从核心态移到用户态之后,系统第一次调用创建进程函数 fork(),创建出一个用于运行 init()的进程,在该子进程中,系统将进行控制台环境设置,并且在生成一个子进程用来运行 shell程序。

6、内核程序主目录 kernel
linux/kernel 目录中共包含 12 个代码文件和一个 Makefile 文件,另外还有 3 个子目录。由于这些文件中代码之间调用关系复杂,因此这里就不详细列出各文件之间的引用关系图,但仍然可以进行大概分类,如图所示:

asm.s      //程序是用于处理系统硬件异常所引起的中断,对各硬件异常的实际处理程序则是在 traps.c 文件中,在各个中断处理过程中,将分别调用traps.c 中相应的 C 语言处理函数
exit.c     //程序主要包括用于处理进程终止的系统调用。包含进程释放、会话(进程组)终止和程序退出处理函数以及杀死进程、终止进程、挂起进程等系统调用函数。
fork.c     //程序给出了 sys_fork()系统调用中使用了两个 C 语言函数: find_empty_process()和 copy_process()。
mktime.c   //程序包含一个内核使用的时间函数 mktime(),用于计算从 1970 年 1 月 1 日 0 时起到开机当日的秒数,作为开机秒时间。仅在 init/main.c 中被调用一次。
panic.     //程序包含一个显示内核出错信息并停机的函数 panic()。
printk.c   //程序包含一个内核专用信息显示函数 printk()。
sched.c    //程序中包括有关调度的基本函数(sleep_on、 wakeup、 schedule 等)以及一些简单的系统调用函数。另外还有几个与定时相关的软盘操作函数。
signal.c   //程序中包括了有关信号处理的 4 个系统调用以及一个在对应的中断处理程序中处理信号的函数 do_signal()。
sys.c      //程序包括很多系统调用函数,其中有些还没有实现。
system_call.s //程序实现了 linux 系统调用(int 0x80)的接口处理过程,实际的处理过程则包含在各系统调用相应的 C 语言处理函数中,这些处理函数分布在整个 linux 内核代码中
vsprintf.c   //程序实现了现在已经归入标准库函数中的字符串格式化函数。

块设备驱动程序子目录 kernel/blk_dev
通常情况下,用户是通过文件系统来访问设备的,因此设备驱动程序为文件系统实现了调用接口。在使用块设备时,由于其数据吞吐量大,为了能够高效率地使用块设备上的数据,在用户进程与块设备之间使用了高速缓冲机制。在访问块设备上的数据时,系统首先以数据块的形式把块设备上的数据读入到高速缓冲区中,然后再提供给用户。 blk_dev 子目录共包含 4 个 c 文件和 1 个头文件。头文件 blk.h 由于是块设备程序专用的,所以与 C 文件放在一起。这几个文件之间的大致关系,如图所示:

blk.h    //中定义了3个C程序中共用的块设备结构和数据块请求结构。 
hd.c    //程序主要实现对硬盘数据块进行读/写的底层驱动函数,主要是 do_hd__request()函数; 
floppy.c //程序中主要实现了对软盘数据块的读/写驱动函数,主要是 do_fd_request()函数。 
ll_rw_blk.c //中程序实现了低层块设备数据读/写函数 ll_rw_block(),内核中所有其它程序都是通过该函数对块设备进行数据读写操作。

你将看到该函数在许多访问块设备数据的地方被调用,尤其是在高速缓冲区处理文件 fs/buffer.c 中。

字符设备驱动程序子目录 kernel/chr_dev
字符设备程序子目录共含有 4 个 C 语言程序和 2 个汇编程序文件。 这些文件实现了对串行端口 rs-232、串行终端、键盘和控制台终端设备的驱动。下图(图 2.12)是这些文件之间的大致调用层次关系:

tty_io.c //程序中包含 tty 字符设备读函数 tty_read()和写函数 tty_write(),为文件系统提供了上层访问接
      //口。另外还包括在串行中断处理过程中调用的 C 函数 do_tty_interrupt(),该函数将会在中断类型为读字符
      //的处理中被调用。
console.c //文件主要包含控制台初始化程序和控制台写函数 con_write(),用于被 tty 设备调用。还包含
        //对显示器和键盘中断的初始化设置程序 con_init()。
 rs_io.s //汇编程序用于实现两个串行接口的中断处理程序。 该中断处理程序会根据从中断标识寄存器(端
       //口 0x3fa 或 0x2fa)中取得的 4 种中断类型分别进行处理,并在处理中断类型为读字符的代码中调用do_tty_interrupt()。
 serial.c //用于对异步串行通信芯片 UART 进行初始化操作,并设置两个通信端口的中断向量。
       //另外还包括 tty 用于往串口输出的 rs_write()函数。
 tty_ioctl.c //程序实现了 tty 的 io 控制接口函数 tty_ioctl()以及对 termio(s)终端 io 结构的读写函数,并会
        //在实现系统调用 sys_ioctl()的 fs/ioctl.c 程序中被调用。
 keyboard.S //程序主要实现了键盘中断处理过程 keyboard_interrupt。

协处理器仿真和操作程序子目录 kernel/math
该子目录中目前仅有一个 C 程序 math_emulate.c。其中的 math_emulate()函数是中断 int7 的中断处理程序调用的 C 函数。当机器中没有数学协处理器,而 CPU 却又执行了协处理器的指令时,就会引发该中断。因此,使用该中断就可以用软件来仿真协处理器的功能。本书所讨论的内核版本还没有包含有关协处理器的仿真代码。本程序中只是打印一条出错信息,并向用户程序发送一个协处理器错误信号 SIGFPE。
7、内核库函数目录 lib
内核库函数主要用于用户编程调用,是编译系统标准库的接口函数之一。其中共有 12 个 C 语言文件,除了一个由 tytso 编制的 malloc.c 程序较长以外,其它的程序很短,有的只有一二行代码。
8、内存管理程序目录 mm
该目录包括 2 个代码文件。主要用于管理程序对主内存区的使用,实现了进程逻辑地址到线性地址以及线性地址到主内存区中物理内存地址的映射,通过内存的分页管理机制,在进程的虚拟内存页与主内存区的物理内存页之间建立了对应关系。
page.s 文件包括内存页面异常中断( int 14)处理程序,主要用于处理程序由于缺页而引起的页异常中断和访问非法地址而引起的页保护。
memory.c 程序包括对内存进行初始化的函数 mem_init(),由 page.s 的内存处理中断过程调用的do_no_page()和 do_wp_page()函数。在创建新进程而执行复制进程操作时,即使用该文件中的内存处理函数来分配管理内存空间。
9、编译内核工具程序目录 tools
该目录下的 build.c 程序用于将 Linux 各个目录中被分别编译生成的目标代码连接合并成一个可运行的内核映象文件 image。具体的功能后面详细介绍

6、内核系统与用户程序的关系

在 Linux 系统中,内核为应用程序提供了两方面的接口。其一是系统调用接口,也即中断调用 int 0x80;另一方面是通过内核库函数,与内核进行信息交流。内核库函数是基本 C 函数库 libc 的组成部分。许多的系统调用是作为基本 C 语言函数库的一部分实现的。系统调用主要是提供给系统软件直接使用或用于库函数的实现。而一般用户开发的程序则是通过调用象 libc 等库中的函数来访问内核资源。通过调用这些库中的程序,应用程序代码能够完成各种常用工作,例如,打开和关闭对文件或设备的访问、进行科学计算、出错处理以及访问组和用户标识号 ID 等系统信息。系统调用是内核与外界接口的最高层。在内核中,每个系统调用都有一个序列号(在include/linux/unistd.h 头文件中定义),并常以宏的形式实现。

参考资料

Linux内核完全注释(赵炯)

如果文章对您有帮助,欢迎点击下方按钮打赏作者

Comments

No comments yet.
To verify that you are human, please fill in "七"(required)