2.5.3 进程初始化
boot/目录中引导程序
l 把内核从磁盘上加载到内存中
l 让系统进入保护模式下运行
系统初始化程序 init/main.c
l 确定如何分配使用系统物理内存
l 调用内核各部分的初始化函数分别对内存管理、中断处理、块设备和字符设备、进程管理以及硬盘和软盘硬件进行初始化处理
l (此时系统各部分已经处于可运行状态)
l 程序把自己“手工”移动到任务0(进程 0)中运行,并使用fork()调用首次创建出进程1
l 进程 1 中程序将继续进行应用环境的初始化 并执行 shell 登录程序
l 原进程 0在系统空闲时被调度执行,此时任务 0 仅执行 pause()系统调用, 并又会调用调度函数
宏 move_to_user_mode(include/asm/system.h)
//把 main.c 程序执行流从内核态移动到了用户态的任务 0 中运行
sched_init() //对调度程序的初始化过程,对任务 0 的运行环境进行了设置
l 设置好任务 0 数据结构各字段的值(include/linux/sched.h)
l 在全局描述符表中添入任务 0 的任务状态段(TSS)描述符和局部描述符表(LDT)的段描述符,并把它们分别加载到任务寄存器 tr 和局部描述符表寄存器 ldtr 中
这里需要强调的是,内核初始化是一个特殊过程,内核初始化代码也即是任务 0 的代码。从任务 0 数据结构中设置的初始数据可知,任务 0 的代码段和数据段的基址是 0、段限长是 640KB。而内核代码 段和数据段的基址是 0、段限长是 16MB,因此任务 0 的代码段和数据段分别包含在内核代码段和数据段中。内核初始化程序 main.c 也即是任务 0 中的代码,只是在移动到任务 0 之前系统正以内核态特权级0 运行着 main.c 程序。宏 move_to_user_mode 的功能就是把运行特权级从内核态的 0 级变换到用户态的3 级,但是仍然继续执行原来的代码指令流。???zzz
在移动到任务 0 的过程中,宏 move_to_user_mode 使用了中断返回指令造成特权级改变的方法。该方法的主要思想是在堆栈中构筑中断返回指令需要的内容,把返回地址的段选择符设置成任务 0 代码段 选择符,其特权级为 3。此后执行中断返回指令 iret 时将导致系统 CPU 从特权级 0 跳转到外层的特权级3 上运行。参见图 2-7 所示的特权级发生变化时中断返回堆栈结构示意图。
图 2-7 特权级发生变化时中断返回堆栈结构示意图
宏 move_to_user_mode 首先往内核堆栈中压入任务 0 数据段选择符和内核堆栈指针。然后压入标志 寄存器内容。最后压入任务 0 代码段选择符和执行中断返回后需要执行的下一条指令的偏移位置。该偏 移位置是 iret 后的一条指令处。
当执行 iret 指令时,CPU 把返回地址送入 CS:EIP 中,同时弹出堆栈中标志寄存器内容。由于 CPU 判断出目的代码段的特权级是 3,与当前内核态的 0 级不同。于是 CPU 会把堆栈中的堆栈段选择符和堆 栈指针弹出到 SS:ESP 中。由于特权级发上了变化,段寄存器 DS、ES、FS 和 GS 的值变得无效,此时 CPU 会把这些段寄存器清零。因此在执行了 iret 指令后需要重新加载这些段寄存器。此后,系统就开始以特权级 3 运行在任务 0 的代码上。所使用的用户态堆栈还是原来在移动之前使用的堆栈。而其内核态堆栈则被指定为其任务数据结构所在页面的顶端开始(PAGE_SIZE + (long)&init_task)。由于以后在创建 新进程时,需要复制任务 0 的任务数据结构,包括其用户堆栈指针,因此要求任务 0 的用户态堆栈在创 建任务 1(进程 1)之前保持“干净”状态。
2.5.4 创建新进程
Linux 系统中创建新进程使用 fork()系统调用。所有进程都是通过复制进程 0 而得到的,都是进程 0的子进程。 在创建新进程的过程中,系统首先在任务数组中找出一个还没有被任何进程使用的空项(空槽)。如果系统已经有 64 个进程在运行,则 fork()系统调用会因为任务数组表中没有可用空项而出错返回。然后系统为新建进程在主内存区中申请一页内存来存放其任务数据结构信息,并复制当前进程任务数据结构中的所有内容作为新进程任务数据结构的模板。为了防止这个还未处理完成的新建进程被调度函数执行, 此时应该立刻将新进程状态置为不可中断的等待状态(TASK_UNINTERRUPTIBLE)。
随后对复制的任务数据结构进行修改。把当前进程设置为新进程的父进程,清除信号位图并复位新进程各统计值,并设置初始运行时间片值为 15 个系统滴答数(150 毫秒)。接着根据当前进程设置任务状态段(TSS)中各寄存器的值。由于创建进程时新进程返回值应为 0,所以需要设置 tss.eax = 0。新建进程内核态堆栈指针 tss.esp0 被设置成新进程任务数据结构所在内存页面的顶端,而堆栈段 tss.ss0 被设 置成内核数据段选择符。tss.ldt 被设置为局部表描述符在 GDT 中的索引值。如果当前进程使用了协处理 器,把还需要把协处理器的完整状态保存到新进程的 tss.i387 结构中。
此后系统设置新任务的代码和数据段基址、限长并复制当前进程内存分页管理的页表。如果父进程中有文件是打开的,则应将对应文件的打开次数增 1。接着在 GDT 中设置新任务的 TSS 和 LDT 描述符 项,其中基地址信息指向新进程任务结构中的 tss 和 ldt。最后再将新任务设置成可运行状态并返回新进程号。
2.5.5 进程调度
由前面描述可知,Linux 进程是抢占式的。被抢占的进程仍然处于 TASK_RUNNING 状态,只是暂时没有被 CPU 运行。进程的抢占发生在进程处于用户态执行阶段,在内核态执行时是不能被抢占的。 为了能让进程有效地使用系统资源,又能使进程有较快的响应时间,就需要对进程的切换调度采用一定的调度策略。在 Linux 0.11 中采用了基于优先级排队的调度策略。
调度程序
schedule()函数首先扫描任务数组。通过比较每个就绪态(TASK_RUNNING)任务的运行时间递减滴答计数 counter 的值来确定当前哪个进程运行的时间最少。哪一个的值大,就表示运行时间还不长,于是就选中该进程,并使用任务切换宏函数切换到该进程运行。
如果此时所有处于 TASK_RUNNING 状态进程的时间片都已经用完,系统就会根据每个进程的优先权值 priority,对系统中所有进程(包括正在睡眠的进程)重新计算每个任务需要运行的时间片值 counter。
计算的公式是:
然后 schdeule()函数重新扫描任务数组中所有处于 TASK_RUNNING 状态,重复上述过程,直到选择出 一个进程为止。最后调用 switch_to()执行实际的进程切换操作。
如果此时没有其它进程可运行,系统就会选择进程 0 运行。对于 Linux 0.11 来说,进程 0 会调用 pause()把自己置为可中断的睡眠状态并再次调用 schedule()。不过在调度进程运行时,schedule()并不在意进程 0处于什么状态。只要系统空闲就调度进程 0 运行。
进程切换
执行实际进程切换的任务由 switch_to()宏定义的一段汇编代码完成。在进行切换之前,switch_to()首先检查要切换到的进程是否就是当前进程,如果是则什么也不做,直接退出。否则就首先把内核全局 变量 current 置为新任务的指针,然后长跳转到新任务的任务状态段 TSS 组成的地址处,造成 CPU 执行任务切换操作(???zzz)。此时 CPU 会把其所有寄存器的状态保存到当前任务寄存器 TR 中 TSS 段选择符所指向的当前进程任务数据结构的 tss 结构中,然后把新任务状态段选择符所指向的新任务数据结构中 tss 结构中 的寄存器信息恢复到 CPU 中,系统就正式开始运行新切换的任务了。这个过程可参见图 2-8 所示。
图 2-8 任务切换操作示意图
2.5.6 终止进程
当一个进程结束了运行或在半途中终止了运行,那么内核就需要释放该进程所占用的系统资源。这包括进程运行时打开的文件、申请的内存等。
当一个用户程序调用 exit()系统调用时,就会执行内核函数 do_exit()。该函数会首先释放进程代码段 和数据段占用的内存页面,关闭进程打开着的所有文件,对进程使用的当前工作目录、根目录和运行程序的 i 节点进行同步操作。如果进程有子进程,则让 init 进程作为其所有子进程的父进程。如果进程是 一个会话头进程并且有控制终端,则释放控制终端,并向属于该会话的所有进程发送挂断信号 SIGHUP, 这通常会终止该会话中的所有进程。然后把进程状态置为僵死状态 TASK_ZOMBIE。并向其原父进程发送 SIGCHLD 信号,通知其某个子进程已经终止。最后 do_exit()调用调度函数去执行其它进程。由此可见在进程被终止时,它的任务数据结构仍然保留着。因为其父进程还需要使用其中的信息。
在子进程在执行期间,父进程通常使用 wait()或 waitpid()函数等待其某个子进程终止。当等待的子进程被终止并处于僵死状态时,父进程就会把子进程运行所使用的时间累加到自己进程中。最终释放已终止子进程任务数据结构所占用的内存页面,并置空子进程在任务数组中占用的指针项。
2.6 Linux 内核对内存的使用方法
在 Linux 0.11 内核中,为了有效地使用机器中的物理内存,内存被划分成几个功能区域,见下图 2-9 所示。
图 2-9 物理内存使用的功能分布图
其中,Linux 内核程序占据在物理内存的开始部分,接下来是用于供硬盘或软盘等块设备使用的高速缓冲区部分。当一个进程需要读取块设备中的数据时,系统会首先将数据读到高速缓冲区中;当有数据需要写到块设备上去时,系统也是先将数据放到高速缓冲区中,然后由块设备驱动程序写到设备上。 最后部分是供所有程序可以随时申请使用的主内存区部分。内核程序在使用主内存区时,也同样要首先向内核的内存管理模块提出申请,在申请成功后方能使用。对于含有 RAM 虚拟盘的系统,主内存区头部还要划去一部分,供虚拟盘存放数据。
由于计算机系统中所含的实际物理内存容量是有限的,因此 CPU 中通常都提供了内存管理机制对系统中的内存进行有效的管理。在Intel CPU 中,提供了两种内存管理(变换)系统:内存分段系统(Segmentation System)和分页系统(Paging System)。而分页管理系统是可选择的,由系统程序员通过 编程来确定是否采用。为了能有效地使用这些物理内存,Linux 系统同时采用了 Intel CPU 的内存分段和分页管理机制。
在 Linux 0.11 内核中,在进行地址映射时,我们需要首先分清 3 种地址以及它们之间的变换概念:
a. 程序(进程)的逻辑地址;b. CPU 的线性地址;c. 实际物理内存地址。
逻辑地址(Logical Address)是指有程序产生的与段相关的偏移地址部分。在 Intel 保护模式下即是指程序执行代码段限长内的偏移地址(假定代码段、数据段完全一样)。应用程序员仅需与逻辑地址打交道,而分段和分页机制对他来说是完全透明的,仅由系统编程人员涉及。
线性地址(Linear Address)是逻辑地址到物理地址变换之间的中间层。程序代码会产生逻辑地址, 或者说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。如果启用了分页机制,那么线性地址可以再经变换以产生一个物理地址。若没有启用分页机制,那么线性地址直接就是物理地址。Intel 80386 的线性地址空间容量为 4G。
物理地址(Physical Address)是指出现在 CPU 外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。如果启用了分页机制,那么线性地址会使用页目录和页表中的项变换成物理地址。 如果没有启用分页机制,那么线性地址就直接成为物理地址了。
虚拟内存(Virtual Memory)是指计算机呈现出要比实际拥有的内存大得多的内存量。因此它允许程序员编制并运行比实际系统拥有的内存大得多的程序。这使得许多大型项目也能够在具有有限内存资 源的系统上实现。一个很恰当的比喻是:你不需要很长的轨道就可以让一列火车从上海开到北京。你只需要足够长的铁轨(比如说 3 公里)就可以完成这个任务。采取的方法是把后面的铁轨立刻铺到火车的前面,只要你的操作足够快并能满足要求,列车就能象在一条完整的轨道上运行。这也就是虚拟内存管理需要完成的任务。在 Linux 0.11 内核中,给每个程序(进程)都划分了总容量为 64MB 的虚拟内存空间。因此程序的逻辑地址范围是 0x0000000 到 0x4000000。
有时我们也把逻辑地址称为虚拟地址。因为与虚拟内存空间的概念类似,逻辑地址也是与实际物理内存容量无关的。
在内存分段系统中,一个程序的逻辑地址是通过分段机制自动地映射(变换)到中间层的线性地址 上。每次对内存的引用都是对内存段中内存的引用。当一个程序引用一个内存地址时,通过把相应的段基址加到程序员看得见的逻辑地址上就形成了一个对应的线性地址。此时若没有启用分页机制,则该线 性地址就被送到 CPU 的外部地址总线上,用于直接寻址对应的物理内存。
若采用了分页机制,则此时线性地址只是一个中间结果,还需要使用分页机制进行变换,再最终映射到实际物理内存地址上。与分段机制类似,分页机制允许我们重新定向(变换)每次内存引用,以适应我们的特殊要求。使用分页机制最普遍的场合是当系统内存实际上被分成很多凌乱的块时,它可以建立一个大而连续的内存空间的映象,好让程序不用操心和管理这些分散的内存块。分页机制增强了分段机制的性能。页地址变换是建立在段变换基础之上的。任何分页机制的保护措施并不会取代段变换的保护措施而只是进行更进一步的检查操作。
因此,CPU 进行地址变换(映射)的主要目的是为了解决虚拟内存空间到物理内存空间的映射问题。 虚拟内存空间的含义是指一种利用二级或外部存储空间,使程序能不受实际物理内存量限制而使用内存的一种方法。通常虚拟内存空间要比实际物理内存量大得多。
那么虚拟内存空间管理是怎样实现的呢?原理与上述列车运行的比喻类似。首先,当一个程序需要使用一块不存在的内存时(也即在内存页表项中已标出相应内存页面不在内存中),CPU 就需要一种方法来得知这个情况。这是通过 80386 的页错误异常中断来实现的。当一个进程引用一个不存在页面中的内存地址时,就会触发 CPU 产生页出错异常中断,并把引起中断的线性地址放到 CR2 控制寄存器中。 因此处理该中断的过程就可以知道发生页异常的确切地址,从而可以把进程要求的页面从二级存储空间(比如硬盘上)加载到物理内存中。如果此时物理内存已经被全部占用,那么可以借助二级存储空间的 一部分作为交换缓冲区(Swapper)把内存中暂时不使用的页面交换到二级缓冲区中,然后把要求的页面 调入内存中。这也就是内存管理的缺页加载机制,在 Linux 0.11 内核中是在程序 mm/memory.c 中实现。
Intel CPU 使用段(Segment)的概念来对程序进行寻址。每个段定义了内存中的某个区域以及访问的优先级等信息。而每个程序都可有若干个内存段组成。程序的逻辑地址(或称为虚拟地址)即是用于寻址这些段和段中具体地址位置。在 Linux 0.11 中,程序逻辑地址到线性地址的变换过程使用了 CPU 的 全局段描述符表 GDT 和局部段描述符表 LDT。由 GDT 映射的地址空间称为全局地址空间,由 LDT 映射的地址空间则称为局部地址空间,而这两者构成了虚拟地址的空间。具体的使用方式见图 2-10 所示。
图 2-10 Linux 系统中虚拟地址空间分配图
图中画出了具有两个任务时的情况。对于中断描述符表idt,它是保存在内核代码段中的。由于在 Linux 0.11 内核中,内核和各任务的代码段和数据段都分别被映射到线性地址空间中相同基址处,且段 限长也一样,因此内核和任务的代码段和数据段都分别是重叠的。另外,Linux 0.11 内核中没有使用系统段描述符。
内存分页管理的基本原理是将整个主内存区域划分成 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, 见图 2-11 所示。4G 正好与 CPU 的线性地址空间范围或物理地址空间范围相同,因此在 0.11 内核中比较 容易混淆三种地址概念。
图 2-11 Linux 0.11 线性地址空间的使用示意图
进程的虚拟地址需要首先通过其局部段描述符变换为 CPU 整个线性地址空间中的地址,然后再使用页目录表 PDT(一级页表)和页表 PT(二级页表)映射到实际物理地址页上。因此两种变换不能混淆。 为了使用实际物理内存,每个进程的线性地址通过二级内存页表动态地映射到主内存区域的不同内存页上。因此每个进程最大可用的虚拟内存空间是 64MB。每个进程的逻辑地址通过加上任务号*64M,即可转换为线性地址。不过在注释中,我们通常将进程中的地址简单地称为线性地址。 有关内存分页管理的详细信息,请参见第 10 章开始部分的有关说明,或参见附录。
从 Linux 内核 0.99 版以后,对内存空间的使用方式发生了变化。每个进程可以单独享用整个 4G 的 地址空间范围。由于篇幅所限,这里对此不再说明。
2.7 Linux 系统中堆栈的使用方法
本节内容概要描述了 Linux 内核从开机引导到系统正常运行过程中对堆栈的使用方式。这部分内容 的说明与内核代码关系比较密切,可以先跳过。在开始阅读相应代码时再回来仔细研究。
Linux 0.11 系统中共使用了四种堆栈。一种是系统初始化时临时使用的堆栈;一种是供内核程序自己使用的堆栈(内核堆栈),只有一个,位于系统地址空间固定的位置,也是后来任务 0 的用户态堆栈; 另一种是每个任务通过系统调用,执行内核程序时使用的堆栈,我们称之为任务的内核态堆栈,每个任 务都有自己独立的内核态堆栈;最后一种是任务在用户态执行的堆栈,位于任务(进程)地址空间的末 端。下面分别对它们进行说明。
2.7.1 初始化阶段
开机初始化时(bootsect.s,setup.s)
当 bootsect 代码被 ROM BIOS 引导加载到物理内存 0x7c00 处时,并没有设置堆栈段,当然程序也没有使用堆栈。直到 bootsect 被移动到 0x9000:0 处时,才把堆栈段寄存器 SS 设置为 0x9000,堆栈指针esp 寄存器设置为 0xff00,也即堆栈顶端在 0x9000:0xff00 处,参见 boot/bootsect.s 第 61、62 行。setup.s 程序中也沿用了 bootsect 中设置的堆栈段。这就是系统初始化时临时使用的堆栈。
进入保护模式时(head.s)
从 head.s 程序起,系统开始正式在保护模式下运行。此时堆栈段被设置为内核数据段(0x10),堆栈指针 esp 设置成指向 user_stack 数组的顶端(参见 head.s,第 31 行),保留了 1 页内存(4K)作为堆 栈使用。user_stack 数组定义在 sched.c 的 67--72 行,共含有 1024 个长字。它在物理内存中的位置可参 见下图 2-12 所示。此时该堆栈是内核程序自己使用的堆栈。
图 2-12 刚进入保护模式时内核使用的堆栈示意图
初始化时(main.c)
在 main.c 中 ,在执 行 move_to_user_mode() 代码之前,系统一直使用上述堆栈。 而在执行过 move_to_user_mode()之后,main.c 的代码被“切换”成任务 0 中执行。通过执行 fork()系统调用,main.c 中的 init()将在任务 1 中执行,并使用任务 1 的堆栈。而 main()本身则在被“切换”成为任务 0 后,仍然继 续使用上述内核程序自己的堆栈作为任务 0 的用户态堆栈。关于任务 0 所使用堆栈的详细描述见后面说明。
2.7.2 任务的堆栈
每个任务都有两个堆栈,分别用于用户态和内核态程序的执行,并且分别称为用户态堆栈和内核态堆栈。这两个堆栈之间的主要区别在于任务的内核态堆栈很小,所保存的数据量最多不能超过(4096 – 任务数据结构)个字节,大约为 3K 字节。而任务的用户态堆栈却可以在用户的 64MB 空间内延伸。
在用户态运行时
每个任务(除了任务 0)有自己的 64MB 地址空间。当一个任务(进程)刚被创建时,它的用户态堆栈指针被设置在其地址空间的末端(64MB 顶端),而其内核态堆栈则被设置成位于其任务数据结构 所在页面的末端。应用程序在用户态下运行时就一直使用这个堆栈。堆栈实际使用的物理内存则由 CPU 分页机制确定。由于 Linux 实现了写时复制功能(Copy on Write),因此在进程被创建后,若该进程及其父进程没有使用堆栈,则两者共享同一堆栈对应的物理内存页面。
在内核态运行时
每个任务有其自己的内核态堆栈,与每个任务的任务数据结构(task_struct)放在同一页面内。这是在建立新任务时,fork()程序在任务 tss 段的内核级堆栈字段 (tss.esp0和tss.ss0)中设置的,参见 kernel/fork.c,93 行:
p->tss.esp0 = PAGE_SIZE + (long)p;
p->tss.ss0 = 0x10;
其中 p 是新任务的任务数据结构指针,tss 是任务状态段结构。内核为新任务申请内存用作保存其task_struct 结构数据,而 tss 结构(段)是 task_struct 中的一个字段。该任务的内核堆栈段值 tss.ss0 也被设置成为 0x10(即内核数据段),而 tss.esp0 则指向保存 task_struct 结构页面的末端。见图 2-13 所示。
???zzz疯了~~~~~ 明白了,囧囧囧囧
图 2-13 进程的内核态堆栈示意图
为什么通过内存管理程序从主内存区分配得来的用于保存任务数据结构的一页内存也能被设置成内核数据段中的数据呢,也即 tss.ss0 为什么能被设置成 0x10 呢?这要从内核代码段的长度范围来说明。
在 head.s 程序的末端,分别设置了内核代码段和数据段的描述符。其中段的长度被设置成了 16MB。这 个长度值是 Linux 0.11 内核所能支持的最大物理内存长度(参见 head.s,110 行开始的注释)。因此,内核代码可以寻址到整个物理内存范围中的任何位置,当然也包括主内存区。到 Linux 0.98 版后内核段的限长被修改成了 1GB。
每当任务执行内核程序而需要使用其内核栈时,CPU 就会利用 TSS 结构把它的内核态堆栈设置成由这两个值构成。在任务切换时,老任务的内核栈指针(esp0)不会被保存。对 CPU 来讲,这两个值是只读的。因此每当一个任务进入内核态执行时,其内核态堆栈总是空的。
任务 0 的堆栈
任务 0 的堆栈比较特殊,需要特别予以说明。
任务 0 的代码段和数据段相同,段基地址都是从 0 开始,限长也都是 640KB。这个地址范围也就是内核代码和基本数据所在的地方。在执行了 move_to_user_mode()之后,它的内核态堆栈位于其任务数据结构所在页面的末端,而它的用户态堆栈就是前面进入保护模式后所使用的堆栈,也即 sched.c 的 user_stack 数组的位置。任务 0 的内核态堆栈是在其人工设置的初始化任务数据结构中指定的,而它的用 户态堆栈是在执行 movie_to_user_mode()时,在模拟 iret 返回之前的堆栈中设置的。在该堆栈中,esp 仍 然是 user_stack 中原来的位置,而 ss 被设置成 0x17,也即用户态局部表中的数据段,也即从内存地址 0 开始并且限长为 640KB 的段。参见图 2-7 所示。zzz???
2.7.3 任务内核态堆栈与用户态堆栈之间的切换
任务调用系统调用时就会进入内核,执行内核代码。此时内核代码就会使用该任务的内核态堆栈进 行操作。当进入内核程序时,由于优先级别发生了改变(从用户态转到内核态),用户态堆栈的堆栈段和 堆栈指针以及 eflags 会被保存在任务的内核态堆栈中。而在执行 iret 退出内核程序返回到用户程序时, 将恢复用户态的堆栈和 eflags。这个过程见图 2-14 所示。
图 2-14 内核态和用户态堆栈的切换
2.8 Linux 内核源代码的目录结构
由于 Linux 内核是一种单内核模式的系统,因此,内核中所有的程序几乎都有紧密的联系,它们之间的依赖和调用关系非常密切。所以在阅读一个源代码文件时往往需要参阅其它相关的文件。因此有必要在开始阅读内核源代码之前,先熟悉一下源代码文件的目录结构和安排。
这里我们首先列出 Linux 内核完整的源代码目录,包括其中的子目录。然后逐一介绍各个目录中所包含程序的主要功能,使得整个内核源代码的安排形式能在我们的头脑中建立起一个大概的框架,以便于下一章开始的源代码阅读工作。
当我们使用 tar 命令将 linux-0.11.tar.gz 解开时,内核源代码文件被放到了 linux/目录中。其中的目录 结构见图 2-15 所示:
图 2-15 Linux 内核源代码目录结构
该内核版本的源代码目录中含有 14 个子目录,总共包括 102 个代码文件。下面逐个对这些子目录中的内容进行描述。
2.8.1 内核主目录 linux
linux 目录是源代码的主目录,在该主目录中除了包括所有的 14 个子目录以外,还含有唯一的一个Makefile 文件。该文件是编译辅助工具软件 make 的参数配置文件。make 工具软件的主要用途是通过识别哪些文件已被修改过,从而自动地决定在一个含有多个源程序文件的程序系统中哪些文件需要被重新编译。因此,make 工具软件是程序项目的管理软件。
linux 目录下的这个 Makefile 文件还嵌套地调用了所有子目录中包含的 Makefile 文件,这样,当 linux 目录(包括子目录)下的任何文件被修改过时,make 都会对其进行重新编译。因此为了编译整个内核所有的源代码文件,只要在 linux 目录下运行一次 make 软件即可。
2.8.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 模块的最前部分,主要进行硬件设备的探测设置和内存管理页面的初始设置工作。
2.8.3 文件系统目录 fs
Linux 0.11 内核的文件系统采用了 1.0 版的 MINIX 文件系统,这是由于 Linux 是在 MINIX 系统上开发的,采用 MINIX 文件系统便于进行交叉编译(???zzz什么是交叉编译),并且可以从 MINIX 中加载 Linux 分区。虽然使用的是 MINIX 文件系统,但 Linux 对其处理方式与 MINIX 系统不同。主要的区别在于 MINIX 对文件系统采用单线程处理方式,而 Linux 则采用了多线程方式。由于采用了多线程处理方式,Linux 程序就必须处理多线程带来的竞争条件、死锁等问题,因此 Linux 文件系统代码要比 MINIX 系统的复杂得多。为了避免 竞争条件的发生,Linux 系统对资源分配进行了严格地检查,并且在内核模式下运行时,如果任务没有主动睡眠(调用 sleep()),就不让内核切换任务。
fs/目录是文件系统实现程序的目录,共包含 17 个 C 语言程序。这些程序之间的主要引用关系见图2-16 所示图中每个方框代表一个文件,从上到下按基本按引用关系放置。其中各文件名均略去了后缀.c, 虚框中是的程序文件不属于文件系统,带箭头的线条表示引用关系,粗线条表示有相互引用关系。
图 2-16 fs 目录中各程序中函数之间的引用关系。
由图可以看出,该目录中的程序可以划分成四个部分:高速缓冲区管理、低层文件操作、文件数据访问和文件高层函数,在对本目录中文件进行注释说明时,我们也将分成这四个部分来描述。
对于文件系统,我们可以将它看成是内存高速缓冲区的扩展部分。所有对文件系统中数据的访问, 都需要首先读取到高速缓冲区中。本目录中的程序主要用来管理高速缓冲区中缓冲块的使用分配和块设备上的文件系统。管理高速缓冲区的程序是 buffer.c,而其它程序则主要都是用于文件系统管理。
在 file_table.c 文件中,目前仅定义了一个文件句柄(描述符)结构数组。ioctl.c文件将引用 kernel/chr_drv/tty.c 中的函数,实现字符设备的 io 控制功能。exec.c 程序主要包含一个执行程序函数 do_execve(),它是所有 exec()函数簇中的主要函数。fcntl.c 程序用于实现文件 i/o 控制的系统调用函数。 read_write.c 程序用于实现文件读/写和定位三个系统调用函数。stat.c 程序中实现了两个获取文件状态的系统调用函数。open.c 程序主要包含实现修改文件属性和创建与关闭文件的系统调用函数。
char_dev.c 主要包含字符设备读写函数 rw_char()。pipe.c 程序中包含管道读写函数和创建管道的系统调用。file_dev.c 程序中包含基于 i 节点和描述符结构的文件读写函数。namei.c 程序主要包括文件系统中目录名和文件名的操作函数和系统调用函数。block_dev.c 程序包含块数据读和写函数。inode.c 程序中包含针对文件系统 i 节点操作的函数。truncate.c 程序用于在删除文件时释放文件所占用的设备数据空间。 bitmap.c 程序用于处理文件系统中 i 节点和逻辑数据块的位图。super.c 程序中包含对文件系统超级块的处理函数。buffer.c 程序主要用于对内存高速缓冲区进行处理。虚框中的 ll_rw_block 是块设备的底层读函数,它并不在 fs 目录中,而是 kernel/blk_drv/ll_rw_block.c 中的块设备读写驱动函数。放在这里只是让 我们清楚的看到,文件系统对于块设备中数据的读写,都需要通过高速缓冲区与块设备的驱动程序(ll_rw_block())来操作来进行,文件系统程序集本身并不直接与块设备的驱动程序打交道。 在对程序进行注释过程中,我们将另外给出这些文件中各个主要函数之间的调用层次关系。
2.8.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()及相关常数符号。
2.8.5 内核初始化程序目录 init
该目录中仅包含一个文件 main.c。用于执行内核所有的初始化工作,然后移到用户模式创建新进程, 并在控制台设备上运行 shell 程序。
程序首先根据机器内存的多少对缓冲区内存容量进行分配,如果还设置了要使用虚拟盘,则在缓冲 区内存后面也为它留下空间。之后就进行所有硬件的初始化工作,包括人工创建第一个任务(task 0), 并设置了中断允许标志。在执行从核心态移到用户态之后,系统第一次调用创建进程函数 fork(),创建 出一个用于运行 init()的进程,在该子进程中,系统将进行控制台环境设置,并且在生成一个子进程用来 运行 shell 程序。
2.8.6 内核程序主目录 kernel
linux/kernel 目录中共包含 12 个代码文件和一个 Makefile 文件,另外还有 3 个子目录。所有处理任务的程序都保存在 kernel/目录中,其中包括象 fork、exit、调度程序以及一些系统调用程序等。还包括处理中断异常和陷阱的处理过程。子目录中包括了低层的设备驱动程序,如 get_hd_block 和 tty_write 等。由于这些文件中代码之间调用关系复杂,因此这里就不详细列出各文件之间的引用关系图,但仍然可以进行大概分类,见图 2-17 所示。
图 2-17 各文件的调用层次关系
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_drv
通常情况下,用户是通过文件系统来访问设备的,因此设备驱动程序为文件系统实现了调用接口。
在使用块设备时,由于其数据吞吐量大,为了能够高效率地使用块设备上的数据,在用户进程与块设备之间使用了高速缓冲机制。在访问块设备上的数据时,系统首先以数据块的形式把块设备上的数据读入到高速缓冲区中,然后再提供给用户。blk_drv 子目录共包含 4 个 c 文件和 1 个头文件。头文件 blk.h 由于是块设备程序专用的,所以与 C 文件放在一起。这几个文件之间的大致关系,见图 2-18 所示。
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_drv
字符设备程序子目录共含有 4 个 C 语言程序和 2 个汇编程序文件。这些文件实现了对串行端口rs-232、串行终端、键盘和控制台终端设备的驱动。图 2-19 是这些文件之间的大致调用层次关系。
图 2-19 字符设备程序之间的关系示意图
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。
2.8.7 内核库函数目录 lib
内核库函数用于为内核初始化程序 init/main.c 运行在用户态的进程(进程 0、1)提供调用支持。它与普通静态库的实现方法完全一样。读者可从中了解一般 libc 函数库的基本组成原理。在 lib/目录中共有 12 个 C 语言文件,除了一个由 tytso 编制的 malloc.c 程序较长以外,其它的程序很短,有的只有一二 行代码,实现了一些系统调用的接口函数。
这些文件中主要包括有退出函数_exit()、关闭文件函数 close(fd)、复制文件描述符函数 dup()、文件 打开函数 open()、写文件函数 write()、执行程序函数 execve()、内存分配函数 malloc()、等待子进程状态 函数 wait()、创建会话系统调用 setsid()以及在 include/string.h 中实现的所有字符串操作函数。
2.8.8 内存管理程序目录 mm
该目录包括 2 个代码文件。主要用于管理程序对主内存区的使用,实现了进程逻辑地址到线性地址 以及线性地址到主内存区中物理内存地址的映射,通过内存的分页管理机制,在进程的虚拟内存页与主 内存区的物理内存页之间建立了对应关系。
Linux 内核对内存的处理使用了分页和分段两种方式。首先是将 386 的 4G 虚拟地址空间分割成 64 个段,每个段 64MB。所有内核程序占用其中第一个段,并且物理地址与该段线性地址相同。然后每个任务分配一个段使用。分页机制用于把指定的物理内存页面映射到段内,检测 fork 创建的任何重复的拷贝,并执行写时复制机制(???zzz)。
page.s 文件包括内存页面异常中断(int 14)处理程序,主要用于处理程序由于缺页而引起的页异常中断和访问非法地址而引起的页保护。
memory.c 程序包括对内存进行初始化的函数 mem_init(),由 page.s 的内存处理中断过程调用的 do_no_page()和 do_wp_page()函数。在创建新进程而执行复制进程操作时,即使用该文件中的内存处理函数来分配管理内存空间。
2.8.9 编译内核工具程序目录 tools
该目录下的 build.c 程序用于将 Linux 各个目录中被分别编译生成的目标代码连接合并成一个可运行的内核映象文件 image。其具体的功能可参见下一章内容。
2.9 内核系统与用户程序的关系
在 Linux 系统中,内核为应用程序提供了两方面的接口。其一是系统调用接口(在第 5 章中说明), 也即中断调用 int 0x80;另一方面是通过内核库函数(在第 12 章中说明)与内核进行信息交流。内核库函数是基本 C 函数库 libc 的组成部分。许多系统调用是作为基本 C 语言函数库的一部分实现的。
系统调用主要是提供给系统软件直接使用或用于库函数的实现。而一般用户开发的程序则是通过调用象 libc 等库中的函数来访问内核资源。通过调用这些库中的程序,应用程序代码能够完成各种常用工作,例如,打开和关闭对文件或设备的访问、进行科学计算、出错处理以及访问组和用户标识号 ID 等系统信息。
系统调用是内核与外界接口的最高层。在内核中,每个系统调用都有一个序列号(在 include/linux/unistd.h 头文件中定义),并常以宏的形式实现。应用程序不应该直接使用系统调用,因为这样的话,程序的移植性就不好了。因此目前 Linux 标准库 LSB(Linux Standard Base)和许多其它标准都不允许应用程序直接访问系统调用宏。系统调用的有关文档可参见 Linux 操作系统的在线手册的第 2 部 分。
库函数一般包括 C 语言没有提供的执行高级功能的用户级函数,例如输入/输出和字符串处理函数。 某些库函数只是系统调用的增强功能版。例如,标准 I/O 库函数 fopen 和 fclose 提供了与系统调用 open和 close 类似的功能,但却是在更高的层次上。在这种情况下,系统调用通常能提供比库函数略微好一 些的性能,但是库函数却能提供更多的功能,而且更具检错能力。系统提供的库函数有关文档可参见操 作系统的在线手册第 3 部分。
2.10 linux/Makefile 文件
从本节起,我们开始对内核源代码文件进行注释。首先注释 linux 目录下遇到的第一个文件 Makefile。 后续章节将按照这里类似的描述结构进行注释。
2.10.1 功能描述
Makefile 文件相当于程序编译过程中的批处理文件。是工具程序 make 运行时的输入数据文件。只要在含有 Makefile 的当前目录中键入 make 命令,它就会依据 Makefile 文件中的设置对源程序或目标代码文件进行编译、连接或进行安装等活动。
make 工具程序能自动地确定一个大程序系统中那些程序文件需要被重新编译,并发出命令对这些程序文件进行编译。在使用 make 之前,需要编写 Makefile 信息文件,该文件描述了整个程序包中各程序之间的关系,并针对每个需要更新的文件给出具体的控制命令。通常,执行程序是根据其目标文件进行更新的,而这些目标文件则是由编译程序创建的。一旦编写好一个合适的 Makefile 文件,那么在你每次修改过程序系统中的某些源代码文件后,执行 make 命令就能进行所有必要的重新编译工作。make 程序 是使用 Makefile 数据文件和代码文件的最后修改时间(last-modification time)来确定那些文件需要进行更 新,对于每一个需要更新的文件它会根据 Makefile 中的信息发出相应的命令。在 Makefile 文件中,开头为'#'的行是注释行。文件开头部分的'='赋值语句定义了一些参数或命令的缩写。
这个 Makefile 文件的主要作用是指示 make 程序最终使用独立编译连接成的 tools/目录中的 build 执行程序将所有内核编译代码连接和合并成一个可运行的内核映像文件 image 。具体是对 boot/中的 bootsect.s、setup.s 使用 8086 汇编器进行编译,分别生成各自的执行模块。再对源代码中的其它所有程序 使用 GNU 的编译器 gcc/gas 进行编译,并连接成模块 system。再用 build 工具将这三块组合成一个内核 映象文件 image. 基本编译连接/组合结构如图 2-20 所示。
2.10.2 代码注释
程序 2-1 linux/Makefile 文件
2.10.3 其它信息
Makefile 简介
makefile 文件是make 工具程序的配置文件。Make 工具程序的主要用途是能自动地决定一个含有很多源程序文件的大型程序中哪个文件需要被重新编译。makefile 的使用比较复杂,这里只是根据上面的makefile 文件作些简单的介绍。详细说明请参考GNU make 使用手册。
为了使用make 程序,你就需要makefile 文件来告诉make 要做些什么工作。通常,makefile 文件会告诉make 如何编译和连接一个文件。当明确指出时,makefile 还可以告诉make 运行各种命令(例如,作为清理操作而删除某些文件)。
make 的执行过程分为两个不同的阶段。在第一个阶段,它读取所有的makefile 文件以及包含的makefile 文件等,记录所有的变量及其值、隐式的或显式的规则,并构造出所有目标对象及其先决条件的一幅全景图。在第二阶段期间,make 就使用这些内部结构来确定哪个目标对象需要被重建,并且使用相应的规则来操作。
当 make 重新编译程序时,每个修改过的C 代码文件必须被重新编译。如果一个头文件被修改过了,那么为了确保正确,每一个包含该头文件的C 代码程序都将被重新编译。每次编译操作都产生一个与源程序对应的目标文件(object file)。最终,如果任何源代码文件被编译过了,那么所有的目标文件不管是刚编译完的还是以前就编译好的必须连接在一起以生成新的可执行文件。
简单的makefile 文件含有一些规则,这些规则具有如下的形式:
目标(target)... : 先决条件(prerequisites)...
命令(command)
...
|
其中'目标'对象通常是程序生成的一个文件的名称;例如是一个可执行文件或目标文件。目标也可以是所要采取活动的名字,比如'清除'('clean')。'先决条件'是一个或多个文件名,是用作产生目标的输入条件。通常一个目标依赖几个文件。而'命令'是make 需要执行的操作。一个规则可以有多个命令,每一个命令自成一行。请注意,你需要在每个命令行之前键入一个制表符!这是粗心者常常忽略的地方。
如果一个先决条件通过目录搜寻而在另外一个目录中被找到,这并不会改变规则的命令;它们将被如期执行。因此,你必须小心地设置命令,使得命令能够在make 发现先决条件的目录中找到需要的先决条件(???zzz)。这就需要通过使用自动变量来做到。自动变量是一种在命令行上根据具体情况能被自动替换的变量。自动变量的值是基于目标对象及其先决条件而在命令执行前设置的。例如,’$^’的值表示规则的所有先决条件,包括它们所处目录的名称;’$<’的值表示规则中的第一个先决条件;’$@’表示目标对象;另外还有一些自动变量这里就不提了。
有时,先决条件还常包含头文件,而这些头文件并不愿在命令中说明。此时自动变量’$<’正是第一个先决条件。例如:
foo.o : foo.c defs.h hack.h
cc -c $(CFLAGS) $< -o $@
|
其中的’$<’就会被自动地替换成 foo.c,而$@则会被替换为 foo.o
为了让 make 能使用习惯用法来更新一个目标对象,你可以不指定命令,写一个不带命令的规则或 者不写规则。此时 make 程序将会根据源程序文件的类型(程序的后缀)来判断要使用哪个隐式规则。 后缀规则是为 make 程序定义隐式规则的老式方法。(现在这种规则已经不用了,取而代之的是使用更通用更清晰的模式匹配规则)。下面例子就是一种双后缀规则。双后缀规则是用一对后缀定义的:源后缀和目标后缀。相应的隐式先决条件是通过使用文件名中的源后缀替换目标后缀后得到。因此,此时下 面的’$<’值是*.c 文件名。而正条 make 规则的含义是将*.c 程序编译成*.s 代码。