第5章 Linux内核体系结构
目录
本章是对内核源代码的总结概述。
操作系统组成:
- 用户应用程序:字处理程序、Internet浏览器程序或用户自己编制的应用程序;
- 操作系统服务:向用户提供的服务被看做是操作系统部分功能的程序。例如X窗口系统、shell命令解释系统以及内核编程接口等系统程序;
- 操作系统内核:主要用于对硬件资源的抽象和访问调度。
5.1 Linux内核模式
操作系统结构可以分为:
- 整体式单内核结构
- 层次式微内核结构
linux0.11:单内核
- 优点:代码结构紧凑,执行速度快;
- 缺点:层次结构性不强。
操作系统提供服务的流程为:
因此内核可以分为三个层次:
- 调用服务的主程序层
- 执行系统调用的服务层
- 支持系统调用的底层函数
5.2 Linux内核系统体系结构
Linux由5个模块构成:
-进程调度模块 负责控制进程对CPU资源的使用。
- 内存管理模块 确保进程能够安全共享主内存区,支持虚拟内存管理
- 文件系统模块 对外部设备的驱动和存储
- 进程间通信模块 用于支持多种进程间的信息交换方式。
- 网络接口模块 提供对多种网络通信标准的访问并支持许多网络硬件。
5.3 Linux内核对内存的管理和使用
5.3.1 物理内存
Linux0.11 中系统初始化时内存被划分为几个功能区域:
- 头部是Linux内核程序
- 高速缓冲区:供硬盘和软盘等设备使用,(扣除显示卡内存和ROM BIOS所占用的内存地址范围640K-1MB)。当进程读取块设备中的数据时,系统首先将数据读取到高速缓冲区中;当有数据要写到块设备上去时,系统也将数据放到高速缓冲区中,然后由相应的驱动写出。
- 最后是所有程序可以随时申请和使用的主内存区。
- RAM虚拟盘:将内存虚拟成硬盘。可参考内存虚拟硬盘(Ramdisk)优点及主要用途。
CPU提供内存管理机制,386提供了两种,
- 分段管理(Segmentation System)
- 分页管理(Paging System)
Linux同时使用了分段和分页。
5.3.2 内存地址空间概念
Linux0.11 中的三种地址空间
- 程序(进程)的虚拟地址(Virtual Address)和逻辑地址(Logical Address) 虚拟地址是由程序员产生的由段选择符和段内偏移地址两个部分组成的地址。由GDT映射的全局地址空间和LDT映射的局部地址空间组成。参考第4.3.3 节,可知共可索引214,个,即16384个,每个最长4G,则共 16384 × 4 G = 64 T 16384\times4G=64T 16384×4G=64T。逻辑地址是值程序产生的与段相关的偏移地址部分。在Intel保护模式下即是指程序执行代码段限长内的偏移地址(假定代码段、数据段完全一样)。分段与分页机制对程序员是透明的。
- CPU的线性地址(Linear Address) 虚拟地址到物理地址变换的中间层。是处理器可寻址的内存空间(称为线性地址空间)中的地址。段基址+逻辑地址=线性地址。不分页,线性地址等于物理地址;分页,再进行页表变换可以转化为物理地址。
- 实际物理内存地址,即物理地址(Phydical Address) 指出现在CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。
虚拟存储或虚拟内存(Virtual Memory) 指计算及呈现出比实际拥有的内存大得多的内存量。
Linux0.11中 每个进程的虚拟内存空间为64MB。
5.3.3 内存分段机制
逻辑地址通过分段机制自动映射到4GB线性地址空间中。
逻辑地址+段基地址=线性地址。
CPU进行地址变换(映射)的目的是解决虚拟地址空间到物理内存空间的映射问题。
虚拟存储管理,缺页加载机制:
以上机制在Linux0.11内核中的mm/memory.c中实现。
Intel CPU使用段(Segment)的概来对程序进行寻址,每个段定义了内存中的某个区域以及访问的优先级等信息(参见第4章)。
实模式与保护模式下寻址方式的比较:
实模式下,段值+偏移值,段值放在段寄存器中,偏移值放在任何一个可用于寻址的寄存器中。段的长度固定为64KB。
保护模式下,段寄存器中存放的是段描述符表(Segment Descriptor Table)的索引值。段长度由描述符确定,可变。段选择符->段描述符->线性地址。段最大定义长度为4GB(参见第4章)。
描述符表有三种GDT、IDT和LDT。具体内容参见第4章。
当CPU要寻址一个段时,就会使用16位的段寄存器中的选择符来定位一个段描述符。段选择符结构参见4.3.3节。
idt保存在内核代码段中。在linux 0.11内核中,内核和各任务的代码段和数据段都分别被映射到线性地址空间的相同基地址,且段限长也一样,因此内核的代码段和数据段是重叠的,各任务的代码段和数据段分别也是重叠的。如图5-11
在4.9节的多任务内核的例子就是这样
TSS段的使用见图4-37。
在linux0.11中,每个任务的TSS段内容被保存在该任务的任务数据结构中。
所以是在进程的数据结构中有保存?
这算是个历史遗留问题吧。
5.3.4 内存分页管理
使用分页机制当系统内存实际上被分成很多凌乱的块时,它可以建立一个大而连续的内存空间映象。分页机制增强了分段机制的性能。
分页是将CPU整个线性内存区域划分成4096字节为1页的内存页面,分配内存以内存页为单位进行划分。开启分页将CR0的位31置1。参考图4-3。
每个页目录项或者是页表项必须只能包含1024个页表项,占一页内存。
线性地址到物理地址的变化过程如下。CR3中保存的是当前页目录表在物理内存中的基地址(注意是物理内存,直接是物理地址,不需要进行转换的)。
如果不是的话呢?则可以由虚拟地址空间映射到相同的线性地址空间,但是由于页表不同,因此最后寻址也不同。
Linux0.11中把每个进程最大可用虚拟内存空间定义为64MB。
Linux0.11系统 内核设置GDT中段描述符最多有256项,2项空闲,2项系统使用,最多可以有
(
256
−
4
)
/
2
=
126
(256-4)/2=126
(256−4)/2=126项任务,虚拟地址范围是
126
∗
64
M
B
126*64MB
126∗64MB,大约是8G。
任务0和任务1是内核里面写好的吗?
5.3.5 CPU多任务和保护方式
Intel 80386共有4个保护级,0级具有最高优先级,3级具有最低优先级。Linux0.11使用饿了0和3两个保护级。
- 当任务(进程)执行系统调用陷入内核代码中执行,此时进程处于内核运行态,处理器处于特权级0级内核代码中执行,使用当前进程的内核栈。
- 当进程执行用户自己的代码时,处于用户态,处理器处于特权级3级的代码中运行。若此时程序被中断程序中断,中断程序(内核代码)也会使用当前进程的内核栈,与内核态较为类似。
5.3.6 虚拟地址、线性地址和物理地址之间的关系
内核代码和数据的地址
Linux默认管理16MB内存,16MB之上的物理内存将不会用到。通过对init/main.c程序进行修改可以扩展。
这边涉及到具体linux0.11中head.s的代码。后面可以回来看。
任务0的地址对应关系
任务0是系统中人工启动的第一个任务。代码段和数据段长度为640KB。线性地址0-640KB,可以直接使用内核代码已经设置好的页目录和页表进行分页地址变换。TSS0段是手工预设好的,在shed.h和sched.c程序的代码中。
参考4.4.2节,111表示任意特权级可读、写、执行且在内存中存在。
任务1的地址对应关系
任务1的用户态堆栈直接共享处于线性地址0-640K的任务0的用户堆栈空间user_stack[],(参见kernel/sched.c,第67-72行)
其他任务的地址对应关系
从任务2开始,所有任务的父进程都是init(任务1)。
对任务nr,线性空间起始地址为nr*64MB,长度为64MB,共占用16个页目录项。
任务2被创建出来后,将在其中运行execve()函数执行shell程序。
需求加载(Load on demand) :在需要时引发缺页异常才真正分配并映射一页物理内存。
LInux0.99之后,每个进程可以单独享用整个4G的地址空间单位。
5.3.7 用户申请内存的动态分配
C函数库中国malloc()申请内存,内存由malloc()管理,内核不参与。但是内核会维护brk,保存在进程的数据结构中,指出进程代码和数据在进程地址空间中的末端位置。malloc会通过系统调用brk()来更新brk的值。
free()时,C库中的内存管理函数把所释放的内存块标记为空闲,物理内存并不会被释放,当进程结束时内核才全面回收。
在今后的学习中要搞清楚哪些是内核做的事情,哪些是C库做的事情。
malloc()和free()具体代码参见内核库中的lib/malloc.c程序。
5.4 Linux系统的中断机制
5.4.1 中断操作原理
处理器对输入输出设备提供服务的两种方法:
- 轮询方法,编程简单,但是消耗处理器资源,影响系统性能。
- 中断方法(Interrupt)。中断请求(IRQ:Interrupt Request);中断服务程序(ISR-Interrupt Service Routine)。
可编程中断控制器(PIC-Programmable Interrupt Controller)。
中断请求与处理过程
这边存在的问题是,中断完成之后,CPU是不是会向PIC发送完成信号呢?
以上是外部中断,硬件进入中断。
还有软件,int指令。
5.4.2 80x86微机的中断子系统
8259A可编程中断控制芯片。
8259A具有编程状态和操作状态。
5.4.3 中断向量表
256个中断,实模式下每个中断向量4个字节,因此总长度为1024个字节,参见4.6.7节中断描述符表。
启动时,ROM BIOS程序在物理内存0x0000:0x0000处初始化设置中断向量表,中断程序由BIOS给出。
32位保护模式下使用中断描述符表IDT来管理中断或异常。
5.4.4 Linux内核的中断处理
对Linux内核来说,中断信号分为两类:软件中断和硬件中断。
int0-31 Intel公司固定设定或保留,软件中断。参见表4-8
int32-255 用户自定义
IRQ和中断向量号的连接是如何设定的呢?在4.9节中使用的是BIOS给定的对应,也就是IRQ0对应8号中断,猜测在linux中应该有专门的代码进行了设置
系统初始化时,在boot/head.s,78行中对256个描述符进行了默认设置。在init/main.c中设置了系统需要使用的中断描述符。异常处理中断int0-int31在kernl/traps,181行进行了设置。系统调用中断int128在kernel/sched.c 384行进行了设置。
注意IF的复位是由CPU自动实现还是软件实现。在后面学习的时候搞清楚。按照前面来看,应该是CPU自动实现
5.4.5 标志寄存器的中断标志
为了避免竞争条件和中断对临界代码区的干扰,在Linux0.11内核diamante中许多地方使用了cli和sti指令。
之前面试的时候有面试官问过我Linux不能保证实时性,当时他给出的一个回答是因为有许多不可中断的代码,这个得再深入学习一下
cli:复位IF,关闭中断
sti:置位IF,恢复中断
给出的例子是文件超级块。
什么是文件超级块?
5.5 Linux的系统调用
5.5.1 系统调用是接口
系统调用(syscalls)是Linux内核与上层应用程序进行交互通信的唯一接口。
用户程序直接或间接(通过库函数)调用中断int 0x80,在eax寄存器指定系统调用功能号,进行调用。通常使用C库函数间接调用
系统调用以函数的形式,可以带有参数。
返回值负值表示错误,0表示成功。
出错时,错误码放在全局变量errno中,可通过调用库函数perror()打印。
错误码errno应该是每个进程一个?但是是在内核区,应该是只要一个?后面好好看看。
系统调用功能号在include/unistd.h中第60行开始。这些功能号对应于include/linux/sys.h中定义的系统调用处理程序指针数组表sys_call_table[]中的索引值。
5.5.2 系统调用处理过程
当应用程序通过库函数向内核发送一个中断调用int 0x80时,开始执行系统调用。系统调用号在eax中,参数在寄存器ebx、ecx和edx中,因此最多三个参数。处理系统调用中断的int 0x80的过程是程序kernel/system_call.s中的system_call。
在include/unistd.h的133-183行定义了宏函数_syscalln(),n代表携带参数个数。
直接使用系统调用的方法。
这个的解读要看3.3.2节嵌入汇编。
至于为什么是_sys_call_table,猜测是由于C语言编译后使用的地址在原变量名之前加入了_
5.5.3 Linux系统调用的参数传递方式
参数传递采用通用寄存器传递法。
这段不是很懂,进入的时候应该只保存了SS、ESP、EFLAGS、CS、EIP。可能是我过程理解错了?后面分析源代码的时候再看
进行有效性检查。
5.6 系统时间和定时
5.6.1 系统时间
Linux 0.11内核通过init/main.c中的time_init()函数读取时间,并使用kernel/mktime.c程序中的kernel_mktime()函数转换,保存在内核startup_time中。用户程序可使用系统调用time()来读取startup_time的,超级用户可通过stime()修改。
也就是说开机后有一个自己的计时变量jiffies。
5.6.2 系统定时
Linux0.11设置10ms发出一个IRQ0的信号,为一个滴答时间,每过一个滴答时间,系统就会调用一次timer_interrupt。
timer_interrupt:大致是先把jiffies加1,然后调用do_timer()。
do_timer大致过程:
也就是linux0.11进程在内核态程序中运行是不可抢占式的(nonpreemptive),但是在用户态是可以被抢占的(preemptive)。
时钟中断算是系统的心跳,任务切换等都是在这个中断中完成的。
5.7 Linux进程控制
程序是一个可执行的文件,而进程(process)是一个执行中的程序实例。
程序和进程的定义
利用分时技术,在Linux操作系统上同时可以运行多个程序。每个程序运行一个时间片(time slice)。对单个CPU的系统,实际上某一时刻只能运行1个进程。
Linux0.11最多64个进程,除了第一个是手工建立的之外,其他的都是先用进程利用系统调用fork创建的。被创建的叫做子进程(child process),创建者叫做父进程(parent process)。每个进程有进程标识号(process ID,pid)。
进程的用户堆栈用于在用户态下临时保存调用函数的参数、局部变量等;内核堆栈则含有内核程序执行函数调用时的信息。
后面进程和任务会混用。
5.7.1 任务数据结构
内核程序通过进程表对进程进行管理,每个进程在进程表中占用一项。在Linux系统中,进程表项是一个task_struct任务结构指针。
任务数据结构定义在头文件include/linux/sched.h中,称为进程控制块PCB(Process Control Block)或者是进程描述符PD(Processor Descriptor)。
包括的信息有:
struct task_struct {
/* these are hardcoded - don't touch */
long state; /* -1 unrunnable, 0 runnable, >0 stopped */
long counter; // 时间片计数记录在PCB中
long priority;
long signal;
struct sigaction sigaction[32];
long blocked; /* bitmap of masked signals */
/* various fields */
int exit_code;
unsigned long start_code,end_code,end_data,brk,start_stack;
long pid,father,pgrp,session,leader;
unsigned short uid,euid,suid;
unsigned short gid,egid,sgid;
long alarm;
long utime,stime,cutime,cstime,start_time;
unsigned short used_math;
/* file system info */
int tty; /* -1 if no tty, so it must be signed */
unsigned short umask;
struct m_inode * pwd;
struct m_inode * root;
struct m_inode * executable;
unsigned long close_on_exec;
struct file * filp[NR_OPEN];
/* ldt for this task 0 - zero 1 - cs 2 - ds&ss */
struct desc_struct ldt[3]; //ldt信息
/* tss for this task */
struct tss_struct tss; //TSS段信息
};
这段为PCB的结构介绍,可以当做表来查找。
进程组和会话:参考搞懂进程组、会话、控制终端关系,才能明白守护进程如何创建。
当一个进程在执行时,CPU所有的寄存器中的值、进程的状态以及堆栈中的内容被称为该进程的上下文。在Linux中,当前进程的上下位机均保存在进程的任务数据结构中。
PCB中还有很多的内容不是很懂,得在之后的学习中慢慢理解
5.7.2 进程运行状态
进程状态保存在state中。
- 运行状态 TASK_RUNNING 可分为用户运行态、内核运行态和就绪态。就绪态和运行态在linux0.11中用同一个状态码表示。
- 可中断睡眠状态(TASK_INTERRUPTIBLE)。
- 不可中断睡眠状态(TASK_UNINTERRUPTIBLE)。
- 暂停状态(TASK_STOPPED)。
- 僵死状态(TASK_ZOMBIE)。
5.7.3 进程初始化
系统初始化程序在init/main.c中。初始化过程:
可参考4.9节中的多任务内核实例。
5.7.4 创建新进程
Linux系统中创建新进程使用fork()系统调用。所有进程都是通过复制进程0得到的,都是进程0的子进程。创建新进程的过程
新建进程时使用写时复制技术(Copy On Write)。
这里有一个问题,CPU是如何知道该从块设备的哪里去加载新的代码呢?这个跟运行程序有关系,后面好好看看
5.7.5 进程调度
内核中的调度程序用于选择系统中国下一个要运行的进程。在Linux0.11中采用了基于优先级排队的调度策略。
调度程序
在schedule()函数中。调度规则是:选择就绪态任务counter值最大的任务运行。
选择出来之后调用switch_to()来执行实际的进程切换操作。
没有其他进程可运行,系统会选择进程0运行。Linux0.11中进程0调用pause()。
进程切换
schedule()调用定义在include/linux/sched.h中的switch_to()宏进行进程切换操作。
switch_to源代码:
/*
* switch_to(n) should switch tasks to task nr n, first
* checking that n isn't the current task, in which case it does nothing.
* This also clears the TS-flag if the task we switched to has used
* tha math co-processor latest.
*/
#define switch_to(n) {\
struct {long a,b;} __tmp; \
__asm__("cmpl %%ecx,current\n\t" \
"je 1f\n\t" \
"movw %%dx,%1\n\t" \
"xchgl %%ecx,current\n\t" \
"ljmp *%0\n\t" \
"cmpl %%ecx,last_task_used_math\n\t" \
"jne 1f\n\t" \
"clts\n" \
"1:" \
::"m" (*&__tmp.a),"m" (*&__tmp.b), \
"d" (_TSS(n)),"c" ((long) task[n])); \
}
这边没有太看懂,后面再看
5.7.6 终止进程
在进程终止时,内核需要释放其所占用的资源,包括运行时打开的文件,申请的内存等。执行内核参数do_exit()
书里这里很详细的描述了进程中止的过程。可以作为查表。
do_exit源代码
程序退出处理函数。
// 该函数将把当前进程置为TASK_ZOMBIE状态,然后去执行调度函数schedule(),不再返回。
// 参数code是退出状态码,或称为错误码。
int do_exit(long code)
{
int i;
// 首先释放当前进程代码段和数据段所占的内存页。函数free_page_tables()的第一个参数
// (get_base()返回值)指明在CPU线性地址空间中起始基地址,第2个(get_limit()返回值)
// 说明欲释放的字节长度值。get_base()宏中的current->ldt[1]给出进程代码段描述符的
// 位置(current->ldt[2]给出进程代码段描述符的位置);get_limit()中0x0f是进程代码段
// 的选择符(0x17是进城数据段的选择符)。即在取段基地址时使用该段的描述符所处地址作为
// 参数,取段长度时使用该段的选择符作为参数。free_page_tables()函数位于mm/memory.c
// 文件中。
free_page_tables(get_base(current->ldt[1]),get_limit(0x0f));
free_page_tables(get_base(current->ldt[2]),get_limit(0x17));
// 如果当前进程有子进程,就将子进程的father置为1(其父进程改为进程1,即init进程)。
// 如果该子进程已经处于僵死(ZOMBIE)状态,则向进程1发送子进程中止信号SIGCHLD。
for (i=0 ; i<NR_TASKS ; i++)
if (task[i] && task[i]->father == current->pid) {
task[i]->father = 1;
if (task[i]->state == TASK_ZOMBIE)
/* assumption task[1] is always init */
(void) send_sig(SIGCHLD, task[1], 1);
}
// 关闭当前进程打开着的所有文件。
for (i=0 ; i<NR_OPEN ; i++)
if (current->filp[i])
sys_close(i);
// 对当前进程的工作目录pwd,根目录root以及执行程序文件的i节点进行同步操作,放回
// 各个i节点并分别置空(释放)。
iput(current->pwd);
current->pwd=NULL;
iput(current->root);
current->root=NULL;
iput(current->executable);
current->executable=NULL;
// 如果当前进程是会话头领(leader)进程并且其有控制终端,则释放该终端。
if (current->leader && current->tty >= 0)
tty_table[current->tty].pgrp = 0;
// 如果当前进程上次使用过协处理器,则将last_task_used_math置空。
if (last_task_used_math == current)
last_task_used_math = NULL;
// 如果当前进程是leader进程,则终止该会话的所有相关进程。
if (current->leader)
kill_session();
// 把当前进程置为僵死状态,表明当前进程已经释放了资源。并保存将由父进程读取的退出码。
current->state = TASK_ZOMBIE;
current->exit_code = code;
// 通知父进程,也即向父进程发送信号SIGCHLD - 子进程将停止或终止。
tell_father(current->father);
schedule(); // 重新调度进程运行,以让父进程处理僵死其他的善后事宜。
// 下面的return语句仅用于去掉警告信息。因为这个函数不返回,所以若在函数名前加关键字
// volatile,就可以告诉gcc编译器本函数不会返回的特殊情况。这样可让gcc产生更好一些的代码,
// 并且可以不用再写return语句也不会产生假警告信息。
return (-1); /* just to suppress warnings */
}
5.8 Linux系统中堆栈的使用方法
大致浏览一下。
四种堆栈:
- 系统引导初始化时临时使用的堆栈
- 进入保护模式之后提供内核程序初始化使用的堆栈
- 任务的内核态堆栈
- 任务的用户态堆栈
5.8.1 初始化阶段
开机初始化时(bootsect.s,setup.s)
bootsect.s的61,62行。
进入保护模式时
** 初始化时 **
5.8.2 任务的堆栈
内核态堆栈就是在1页内部玩耍。
** 在用户态运行时 **
在内核态运行时
任务0和任务1的堆栈
任务0和任务1的堆栈写时复制到底是怎么执行的还是不清楚,到时候好好看看代码
跳转到任务0执行的过程与4.9节中是基本一致的。
5.8.3 任务内核态堆栈与用户态堆栈之间的切换
内核是可中断的?前面提到过Linux内核是不可抢占的,当然这个不可抢占是针对任务切换来说的,工作在内核态的任务是不可以切换的。在进行do_timer的时候已经进入了定时器中断,所以内核态肯定是可以中断的。
发生中断的时候,只在栈中保护了前面的五个寄存器值,至于其他的寄存器,中断返回之前要恢复原貌。
任务切换时,所有的寄存器值都保存在了TSS中。