Linux内核体系结构
硬件、操作系统内核、操作系统服务和用户应用程序
Linux内核的主要用途就是为了与计算机硬件进行交互,实现对硬件部分的编程控制和接口操作,调度对硬件资源的访问,并为计算机上的用户程序提供一个高级的执行环境和硬件的虚拟接口
1. Linux 内核模式
- 单内核模式
- os提供服务的流程
- 应用主程序使用指定的参数值执行系统调用,使CPU从用户态切换到核心态
- os根据具体的参数值调用特定的系统调用服务程序
- 服务程序根据需要再调用底层的一些支持函数以完成特定的功能
- 完成服务后,os又使CPU从核心态切换到用户态
2. Linux 内核结构
- 进程调度模块
- 负责控制进程对CPU资源的使用
- 内存管理模块
- 进程能够安全地共享机器主内存区
- 支持虚拟内存管理方式
- 文件系统模块
- 支持对外部设备的驱动和存储
- 虚拟文件系统模块通过向所有的外部存储设备提供一个通用的文件接口,隐藏了各种硬件的不同细节,从而提供支持与其他os兼容的多种文件系统格式
- 进程间通信模块
- 支持多种进程间的信息交换方式
- 网络接口模块
- 提供对多种网络通信标准的访问并支持许多网络硬件
3. 内存管理和使用
- 物理内存
- 虚拟地址
- 程序产生的由段选择符和段内偏移地址组成,需要映射成物理地址
- 虚拟地址空间由GDT映射的全局地址空间和LDT映射的局部地址空间组成
- 逻辑地址
- 由程序产生的与段相关的偏移地址部分
- 线性地址
- 虚拟地址到物理地址变换之间的中间层,是处理器可寻址的内存空间的地址
- = 段基址+段内偏移
- 物理地址
- CPU外部地址总线上的寻址物理内存的地址信号
CPU多任务和保护方式
- 全局地址空间
- 内核代码本身会由系统中的所有任务共享
- 局部地址空间
- 每个任务都有自己的代码和数据区,这两个区域保存于局部地址空间,系统中其他任务是不能访问的
- 内核态
- 当一个任务执行系统调用而陷入内核代码中执行时,进程处于内核态。使用当前进程的内核栈
- 处理器位于特权级最高(0级)的内核代码中运行
- 用户态
- 当进程执行用户自己的代码时所处的状态
- 处理器位于特权级最低(3级)的用户代码中运行
虚拟地址、线性地址和物理地址之间的关系
内核代码和数据的地址
head.s
程序初始化操作将内核代码段和数据段都设置成为长度为16MB的段- 内核代码段和数据段区域在线性地址空间和物理地址空间中是一样的(这样设置可以简化内核的初始化操作)
- 这两个段在线性地址空间的范围是重叠的(从线性地址
0
到0xFFFFFF
共16MB的范围)- 该范围含有内核所有的代码、内核段表、页目录表、二级页表、内核局部数据以及内核临时堆栈(将被用作第1个任务的用户堆栈)
任务0的地址对应关系
- 任务0是系统中一个人工启动的第一个任务
- 代码段和数据段长度被设置为640KB
- 线性地址空间也是重叠的
- 是从线性地址0开始的640KB内容
- 该任务的代码和数据直接包含在内核代码和数据中
- 所以不需要再为其另外分配内存页
- 因此可以直接使用内核代码已经设置好的页目录和页表进行分页地址变换
- 对应的任务状态段TSS0也是手工预设置好的
任务1的地址对应关系
- 任务1的代码也在内核代码区域中
- 系统使用
fork()
创建任务1,并为其在主内存区申请了一页内存来存放二级页表,并复制了父进程的页目录和二级页表项 - 线性地址空间范围
64MB--128MB
,被映射到物理地址0--640KB
- 任务1长度也是640KB
- 代码段和数据段相互重叠
- 系统也会为任务1在主内存区域申请一页内存来存放它的任务数据结构(PCB)和任务1的内核堆栈空间
其他任务的地址对应关系
- 父进程为init进程(任务1)
- 任务号为nr,则该任务在线性地址空间中的起始位置将被设为
nr*64MB
处,代码段和数据段的最大长度为64MB
- 系统在线性地址空间为该任务分配了
64MB
的空间范围,但是内核并不会立刻为其分配和映射物理内存页面(直到发生缺页异常)
- 系统在线性地址空间为该任务分配了
- 任务创建出来后,将执行
execve()
函数来执行shell程序- 该系统调用会释放掉从任务1复制的页目录和页表表项及相应内存页面,然后会新的执行程序shell重新设置相关页目录和页表表项
用户申请内存的动态分配
- 动态申请的内存容量或大小均由高层次的C库函数malloc()来进行管理,只要进程执行时寻址的范围在它的64MB范围内,内核同样会通过内存缺页管理机制自动为寻址对应的页面分配物理内存页面并进行映射操作
- 内核为进程使用的代码和数据空间维护一个当前位置brk
- 保存在每个进程的数据结构中
- brk指出了进程代码和数据在进程地址空间中的末端位置
- 内核代码会根据malloc()所提供的信息来更新brk的值
- free()动态释放已申请的内存块,C库中的内存管理函数就会把所释放的内存块标记为空闲,以被程序再次申请内存时使用
- 这个物理页面不会被释放掉,只有当进程最终结束时内核才会全面收回已分配和映射到进程地址空间范围的所有物理内存页面
- 参考内核库lib/malloc.c程序
4. 中断机制
- 微型计算机向设备提供服务的方法
- 轮询(太耗处理器资源,影响系统性能)
- 中断
- 中断请求(IRQ):设备需要服务时自己向处理器提出请求
- 中断服务程序(ISR):处理器响应请求而执行的设备相关程序
- 可编程中断控制器(PIC)
- 作用:管理设备中断请求
- 通过连接到设备的中断请求引脚接收设备发出的终端服务请求信号
- PIC向处理器的INT引脚发出一个中断信号,通过数据总线发送中断号
- 处理器根据读取的中断号查询中断向量表(或32位保护模式下的IDT)取得中断向量(中断服务程序的地址)并开始执行中断服务程序
- 当PIC同时收到多个中断服务请求时,会进行优先级比较
- 例:8259A芯片
- 作用:管理设备中断请求
中断向量表
为了让CPU由中断号查找到对应的中断向量,CPU需要在内存建立一张查询表,即中断向量表(在32位保护模式下成为中断描述符表)
- 80x86微机支持256个中断,对应每个中断需要安排一个中断服务程序
- 在实模式下运行时,每个中断向量由4字节组成,所有中断向量表的长度为1024字节
- 启动时,ROM BIOS中的程序会在物理内存开始地址
0x0000:0x0000
处初始化并设置中断向量表,而各中断的默认中断服务程序则在BIOS中给出 - 给定一个中断号N,则其对应的中断向量在内存中的位置就是
0x0000:N*4
- 启动时,ROM BIOS中的程序会在物理内存开始地址
- 对于Linux系统,除了在刚开始加载内核时需要用到BIOS提供的显示和磁盘读操作中断功能,在内核正常运行之前则会在setup.s程序中重新初始化8259A芯片并且在head.s程序中重新设置一张中断向量表(中断描述符表)。完成抛弃了BIOS所提供的中断服务功能
中断处理
每个中断是由0-255之间的一个数字标识。 对于中断int0--int31
,每个中断的功能由Intel公司固定设定或保留,属于软件中断,也称之为异常。 int32--int255
可以由用户自己设定 系统调用中断设置为int128
中断 | 英文名称 | 名称 | CPU检测方式 | 处理方式 |
---|---|---|---|---|
硬件 | Maskable | 可屏蔽中断 | CPU引脚INTR | 清标志寄存器EFLAGS的IF标志位可以屏蔽中断 |
硬件 | Nonmaskable | 不可屏蔽中断 | CPU引脚NMI | 不可屏蔽中断 |
软件 | Fault | 故障 | 在错误发生之前检测到 | CPU重新执行引起错误的指令 |
软件 | Trap | 陷阱 | 在错误发生之后检测到 | CPU继续执行后面的指令 |
软件 | Abort | 终止 | 在错误发生之后检测到 | 引起这种错误的程序应该被终止 |
系统初始化时,内核在head.s程序中首先使用一个哑中断向量对中断描述符中所有256个描述符进行了默认设置。这个哑中断向量指向一个默认的“无中断”处理过程。当发生了一个中断而又没有重新设置过该中断向量时就会显示信息“未知中断” 对于系统中需要使用的一些中断,内核会在其初始化的处理过程中(init/main.c)重新设置这些中断的中断描述符,让它们指向对应的实际处理过程
- 通常,异常中断处理过程(
int0--int31
)都在traps.c的初始化函数中进行了重新设置(kernel/traps.c); - 而系统调用中断
int128
则在调度程序初始化函数中进行了重新设置(kernel/sched.c)
标志寄存器的中断标志
cli
指令用来复位CPU标志寄存器中的中断标志sti
指令用来设置CPU标志寄存器中的中断标志 当进入可能引起竞争条件的代码区时,内核中国就会使用cli指令来关闭对外部中断的响应,而在执行完竞争代码区时内核就会执行sti指令以重新允许CPU响应外部中断- 防止对临界代码区的同时多重操作,引起超级块数据的不一致性
5. 系统调用(syscalls)
- 是Linux内核与上层应用程序进行交互通信的唯一接口
系统调用使用函数形式进行调用,可带有参数
- 执行结果
- 0表示成功
- 负值表示错误
- 错误的类型码存放在全局变量
errno
中 - 通过调用库函数
perror()
,可以打印出错误码对应的出错字符串信息
- 错误的类型码存放在全局变量
- 每个系统调用都具有唯一的一个系统调用功能号,作为系统调用处理程序指针数组表
sys_call_table[]
的索引值
处理过程
寄存器eax存放着系统调用号,携带的参数可依次存放在寄存器ebx、ecx和edx中。(最多传递3个参数)
- 处理器调用中断
int 0x80
的过程是程序Kernel/system_call.s
中的system_call
为了方便调用,内核源代码在include/unistd.h
中定义了宏函数_syscalln()
,其中n代表参数个数
- 每个系统调用宏都有2+2$*$n个参数
- 第1个参数对应系统调用返回值的类型
- 第2个参数是系统调用的名称
- 随后是系统调用所携带参数的类型和名称
- os实现系统调用
- 应用程序调用库函数(
API
)- 进行宏展开
_syscalln()
,该宏定义于include/unistd.h
- 进行宏展开
API
将系统调用号存入EAX
,然后通过中断调用(int 0x80
)使系统进入内核态IDT
中0x80
对应的中断描述符存放system_call
函数的地址。也就是在中断0x80
发生后, 自动调用函数system_call
(中断处理函数)
- 内核中的中断处理函数根据系统调用号,调用对应的内核函数
- 程序
system_call
根据sys_call_table
数组给出的地址,执行系统调用处理程序
- 程序
- 系统调用完成相应功能,将返回值存入
EAX
,返回到中断处理函数 - 中断处理函数返回到
API
API
将EAX
返回给应用程序
- 应用程序调用库函数(
6. 系统时间和定时
系统时间
使用用电池供电的实时钟RT电路。通常这部分电路与保存系统信息的少量CMOS RAM集成到一个芯片上,称为RT/CMOS RAM电路 初始化:time_init()函数读取这块芯片中保存的当前时间和日期信息,并通过kernel/mktime.c程序中的kernel_mktime()函数转换成从1970年1月1日午夜0时开始计起到当前的以秒为单位的时间,称为UNIX日历时间
- 该时间确定了系统开始运行的日历时间,被保存在全局变量
startup_time
中供内核所有代码使用 - 宏
CURRENT_TIME
=startup_time + jiffies/HZ
确定当前时间- HZ= 100 为内核系统时钟频率
- jiffies为系统滴答值(每个滴答定时值是10ms)
系统定时
- 时钟中断处理程序timer_interrupt
- 每当发生一时钟中断jiffies值就增1
7. 进程控制
分时技术、时间片
第一个进程”手工“建立,其余进程的使用系统调用fork
创建
任务数据结构
内核程序通过进程表对进程进行管理,每个进程在进程表中占有一项
- 进程表项是一个
task_struct
任务结构指针 - 任务数据结构也称为进程控制块PCB或进程描述符PD
- 保存着用于控制和管理进程的所有信息
- 进程当前运行的状态信息、信号、进程号、父进程号、运行时间累计值、正在使用的文本和本任务的局部描述符以及任务状态段信息
- 进程的上下文:(当前进程正在执行时)CPU所有寄存器的值、进程的状态以及堆栈中的内容
- 保存在任务数据结构中
进程状态
- 进程状态保存在进程任务结构的
state
字段 - 睡眠状态:进程正在等待系统中的资源而处于等待状态
- 运行状态
- 标号为0的状态
- 若进程没有被CPU执行,则处于就绪运行态;否则,处于用户态或内核态
- 进程被唤醒时进入就绪态;新进程刚被创建时也进入就绪态
- 可中断睡眠状态
- 当系统产生一个中断或者释放了进程正在等待的资源,或者进程收到一个信号,则可以唤醒进程转换到就绪态
- 不可中断睡眠状态
- 不会因为收到信号而被唤醒
- 只有使用
wake_up()
才能切换到就绪态 - 通常用在进程需要不受干扰地等待;或所等待事件会快就会发生
- 暂停状态
- 收到信号
SIGSTOP
、SIGTSTP
、SIGTTIN
或SIGTTOU
时进入该状态 - 收到
SIGCONT
可转换到运行态 - Linux 0.11 尚未实现该状态
- 收到信号
- 僵死状态
- 进程已停止运行
- 父进程调用
wait()
询问其状态时,该进程的PCB就会被释放掉
内核态下的运行的进程不能被其他进程抢占,而且一个进程不能改变另一个进程的状态。为了避免进程切换时造成内核数据错误,内核在执行临界区代码时会禁止一切中断
进程初始化
- 执行初始化程序
init/main.c
- 程序把”自己“手工移动到任务0(进程0)中运行
- 由宏
move_to_user_mode
完成:把运行特权级由内核态的0级变换到用户态的3级,但是仍然继续执行原来的代码指令流
- 由宏
- 使用
fork()
创建进程1 - 在进程1中程序将继续进行应用环境的初始化并执行shell登录程序
CPU允许低级别代码通过调用门、中断、陷阱门来调用或转移到高级别代码中运行,但反之不行。因此内核采用了模拟
IRET
返回低级别代码的方法。通过在堆栈中构筑中断返回指令需要的内容,把返回地址的段选择符设置成任务0代码段选择符,随后执行中断返回指令将导致系统从特权级0跳转到特权级3上运行。
创建新进程
创建新进程使用fork()系统调用,所有进程都是通过复制进程0而得到的,都是进程0的子进程
- 首先,系统在任务数组中找出一个空项,否则出错返回
- 然后,系统为新建进程在主内存区中申请一页内存来存放其任务数据结构,并复制当前进程任务数据结构中的所有内容作为新进程任务数据结构的模板。并将新进程设置为不可中断的睡眠状态
- 随后对复制的任务数据结构进行修改
- 此后系统设置新任务的代码和数据段基址、限长,并复制当前进程内存分页管理的页表
- 新进程共享父进程的内存页面
- 写时复制:只有当父进程或新进程中任意一个有写内存操作时,系统才会为执行写操作的进程分配相关的独自使用的内存页面
- 将父进程中打开的文件的对应打开次数加1,接着在GDT中设置新任务的TSS和LDT描述符项
- 最后将新任务设置成可运行状态
创建一个新的子进程(fork())和加载运行一个执行程序文件(exec())是不同的
进程调度
Linux进程是抢占式的,但被抢占的进程仍然处于运行状态
调度程序
为在所有处于运行状态的进程之间分配CPU运行时间的管理代码
schedule()
函数扫描任务数组,选出进程 若没有其他进程可运行,系统就会选择进程0运行
- 进程0会调用
pause()
把自己置为可中断的睡眠状态并再次调用schedule()
进程切换
switch_to()
宏执行实际进程切换操作
- 先把内核全局变量
current
置为新任务的指针,然后长跳转到新任务的任务状态段TSS组成的地址处,造成CPU执行任务切换操作
终止进程
- 用户程序调用
exit()
系统调用时,就会执行内核函数do_exit()
- 释放进程代码段和数据段占用的内存页面,关闭进程打开着的所有文件
- 对进程使用的当前工作目录、根目录和运行程序的i节点进行同步操作
- 子进程执行期间内,父进程通常使用
wait()
或waitpid()
函数等待其某个子进程终止
8. 堆栈的使用方法
linux0 0.11 系统中共使用了4中堆栈
- 系统引导初始化时临时使用的堆栈
- 直到bootsect被移动到
0x9000:0
处时,才把堆栈寄存器SS设置为0x9000
,堆栈指针esp寄存器设置为0xff00
- 直到bootsect被移动到
- 进入保护模式之后提供内核程序初始化使用的堆栈(任务0的用户态堆栈)
- 任务的内核态堆栈 其所在线性地址由该任务TSS段中ss0和esp0两个字段指定 内核栈被设置在位于其任务数据结构所在页面的末端
9. 文件系统
根文件系统:项内核提供最基本信息和支持的文件系统。
- Linux系统引导启动时,默认使用的文件系统是根文件系统
- 其中包括os最起码的一些配置文件和命令执行程序
子目录 | 包含的信息 |
---|---|
etc/ | 目录主要含有一些系统配置文件 |
dev/ | 含有设备特殊文件,用于使用文件操作语句操作设备 |
bin/ | 存放系统执行程序,例如sh、mkfs、fdisk等 |
usr/ | 存放库函数、手册和其他一些文件 |
usr/bin | 存放用户常用的普通命令 |
var/ | 存放系统运行时可变的数据或者日志等信息 |
Linux 0.11 内核所支持的文件系统时MINIX 1.0 文件系统 软盘上运行的Linux 0.11 系统
- bootimage盘:是引导启动Image文件,其中主要包括磁盘引导扇区代码、操作系统加载程序和内核执行代码
- rootimage盘:向内核提供最基本支持的根文件系统
10. Linux 内核源代码的目录结构
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
linux
│—— boot 系统引导汇编程序
│—— fs 文件系统
│—— include 头文件
| |—— asm 与CPU体系结构相关的部分
│ │—— linux linux内核专用部分
│ └── sys 系统数据结构部分
│—— init 内核初始化程序
|—— kernel 内核进程调度、信号处理、系统调用等程序
| |—— blk_drv 块设备驱动程序
| |—— chr_drv 字符设备驱动程序
| └── math 数学协处理器仿真处理程序
|—— lib 内核库函数
|—— mm 内存管理程序
└── tools 生成内核Image文件的工具程序
内核主目录linux
除了14个子目录外,还有唯一的一个Makefile文件
- 该文件是工具软件make的参数配置文件
- Makefile文件还嵌套地调用了所有子目录中包含的Makefile文件
- make通过识别哪些文件已经被修改过,从而自动地决定在一个含有多个源程序文件的程序系统中哪些文件需要被重新编译
引导启动程序目录boot
包含3个汇编语言文件,bootsect.s和setup.s需要用as86编译;而head.s需要用GNU as来编译 bootsect.s
- 磁盘引导块程序,编译后会驻留在磁盘的第一个扇区中。在PC机加电ROM BIOS自检后,将被BIOS加载到内存
0x7c00
处执行
setup.s
- 用于读取机器的硬件配置参数,把内核模块system移动到适当的内存位置
head.s
- 编译连接在system模块的最前部分,主要进行硬件设备的探测设置和内存管理页面的初始设置工作
文件系统目录fs
共包含17个C语言程序 所有对文件系统中数据的访问,都需要首先读到高速缓冲区的程序buffer.c,而其他程序则主要都是用于文件系统管理
头文件主目录include
总共有32个.h头文件,包括主目录下的13个,体系结构相关头文件子目录asm的4个,Linux内核专用头文件子目录linux的10个,系统专用数据结构子目录sys的5个
内核初始化程序目录init
仅包含一个文件main.c
- 用于执行内核所有的初始化工作,然后移动用户模式创建新进程,并在控制台设备上运行shell程序
内核程序主目录kernel
所有处理任务的程序都保存在这,包括fork、exit、调度程序以及一些系统调用程序等,还包括处理中断异常和陷阱的处理过程 块设备驱动程序子目录blk_drv
- hd.c:主要实现对硬盘数据块进行读/写的底层驱动函数
- floppy.c:主要实现了对软盘数据块的读/写驱动函数
- ll_rw_blk.c:实现底层块设备数据读/写函数ll_rw_block(),内核中所有其他程序都是调用该函数对块设备进行数据读写操作
字符设备驱动程序子目录chr_drv
- 实现对串行端口rs-232、串行终端、键盘和控制台终端设备的驱动
协处理器仿真和操作程序子目录math
- 只有一个C程序math_emulate.c,其中的math_emulate()函数是终端
int7
的中断处理程序调用的C函数
内核库函数目录lib
内核代码不能使用标准C函数库及其他一些库函数,因为完整的C库函数很大,这里只提供内核需要用到的一些函数
内存管理程序目录mm
主要用于管理程序对主内存区的使用,实现逻辑地址、线性地址、物理地址的转换。包括page.s和memory.c
编译内核工具程序目录tools
build.c程序用于将Linux各个目录中被分别编译生成的目标代码连接合并成一个可运行的内核映像文件image
11. 内核系统与应用程序的关系
内核为用户程序提供了两方面的支持
- 系统调用接口(中断调用
int 0x80
)- 应用程序不应该直接使用系统调用
- 一般通过调用像libc等库中函数来访问内核资源
- 这些库中的函数或资源通常被称为应用程序编程接口API
- 只要遵循同一个API标准,那么应用程序就可以在os之间具有可移植性
- 开发环境库函数或内核库函数
- 内核库函数仅供内核创建的任务0和任务1使用
12. linux/Makefile文件
主要作用是指示make程序最终使用独立编译链接成的tools/目录中的build执行程序将所有内核编译代码链接和合并成一个可运行的内核映像文件image
- 对boot/中的bootsect.s、setup.s使用8086汇编器进行编译,分别生成各自的执行模块
- 再对源代码中的其他所有程序使用GNU的编译器gcc/gas进行编译,并链接模块system
- 最后再用build工具将这三块组合成一个内核映像文件image