Linux内核机制自学笔记
摘抄于大学期间记录在QQ空间的一篇自学笔记,当前清理空间,先搬移过来,也不知道到底是对是错了。
1、Linux内存管理
在计算机的世界,内存犹如一条长河,在这条长河中,cpu将这条长河划分成了段和页。cpu要将一个逻辑地址(程序被编译成的汇编代码中所出现的地址)转换成为物理地址(计算机内存条的地址)需要两步:首先cpu利用段式内存管理单元将逻辑地址转换成线性地址(又称为虚拟地址,因为这个地址是想象的,不存在的),在利用页式内存管理单元将线性地址最终转换成物理地址。
1.1 段式内存管理器
在x86中,cpu为了能够访问这条长河更多的空间,采用了内存分段的管理模式,并且加入了段寄存器。例如:16位的cpu将1M内存空间分成若干个段(注意:这些段的起始地址既段地址必须是16的倍数),那么需要确定某一个地址需要知道它所处于的段的基地址、偏移地址(相对于段的起始地址的偏移量)。
因此逻辑地址的公式如下:
逻辑地址 = 段基地址 + 段内偏移量 //段基地址和段内偏移量存放在两个不同的寄存器中
因此由逻辑地址得到的线性地址PA公式如下:
线性地址PA = 段寄存器的值*16 + 逻辑地址偏移部分 //其中段寄存器的值*16是因为16位的cpu只能访问64k的空间(2的16次方),因此乘16就能访问1M的空间
X86的段寄存器是为了对内存进行分段管理而增加的。16位cpu有4个段寄存器,可使程序同时访问4个不同含义的段如下:
- CS+IP:用于访问代码段,CS指存放程序的段基地址,IP指向下条要执行的指令在CS段的偏移量。通过这两个寄存器能够得到下一条要执行的指令的所在线性地址(虚拟地址)。
- SS+SP:用于访问堆栈段,SS指向堆栈段的基地址,SP指向栈顶。通过这两个寄存器能够直接访问栈顶单元的内存物理位置。
- DS+BX:用于访问数据段,DS中的值左移4位为数据段起始地址,BX为其偏移量。通过这两个寄存器能够得到一个存储单元的地址
- ES+BX:用于访问附加段,ES中的值左移4位为附加段起始地址,BX为其偏移量。通过这两个寄存器能够得到一个存储单元的地址
- X86的32位cpu内存管理任然采用分段的管理模式,逻辑地址同样由段地址和偏移量两部分组成,但采用两种不同的工作方式:实模式和保护模式。
- 实模式:在实模式下,32位的cpu内存管理与16位cpu一致。
- 保护模式:段基地址长达32位,段寄存器的值是段地址的“选择器”的地址,用该选择器从内存得到一个32位的段地址。
x86通过段式内存管理器得出的地址全是线性地址(虚拟地址),这个地址表示cpu能够访问的内存地址的能力,但是不能表示存在真正的物理地址,例如cpu能够访问某一个虚拟地址,但是内存条大小容量没有这么大,所以cpu使用的是真正存在的物理地址,而不是线性地址。
1.2 页式内存管理器
从管理和效率的角度出发,线性地址被分为固定长度的组,称为页。例如32位的cpu,线性地址最大可为4G(2的32次方),如果4kb(2的12次方)为一个页,这样整个线性地址就被划分为2的20次方个页。同样,分页单元把所有的物理内存也划分为固定长度的管理单元,它的长度一般和线性地址页是相同的,他们两者之间存在一种映射关系。cpu通过段式内存管理器将逻辑地址转换成线性地址,线性地址被划分成一个一个的页,cpu访问这些页时通过页式管理器的映射关系,最后得到到物理地址的页。这种映射关系如下:
由上图可知,线性地址(32位)可以分成三个部分,其中高10位和寄存器CR3(cpu创建一个进程时就会将这个进程的页表页目录等信息保存到CR3寄存器中)相加得到一个地址,这个地址存放的是页目录,页目录里面存放的是一个页表的基地址;页目录的基地址和第二部分10位相加得到一个地址,这个地址存放的是一个页表项,页表项里面存放的是一个物理页的基地址;一个物理页的基地址加上后面的12位得到一个物理单元的地址,从而cpu通过线性地址找到物理地址完成映射。
1.3 内存管理
linux内核的设计并没有全部采用intel提供的段机制,仅仅是有限度地使用分段机制,完全的采用了页式管理机制。因为linux内核将所有段的基地址均为0,因此逻辑地址与线性地址保持一致(即逻辑地址的偏移量字段与线性地址的值总是相同),因此在linux中所提到的逻辑地址和线性地址(虚拟地址)可以认为是一致。linux巧妙的把段机制绕了过去,完全利用了分页机制。
2、Linux进程地址空间
linux操作系统采用虚拟内存管理技术,使得每个进程都有独立的进程地址空间,该空间大小为3G,用户看到和接触的都是虚拟地址,无法看到实际的物理地址。利用这种虚拟地址不仅能起到保护操作昔日的作用还能让用户程序可使用比实际物理内存更大的地址空间。
2.1 虚拟内存管理
linux将4G的线性地址(虚拟地址)空间划分为两个部分,虚拟地址从0到0xbfffffff(从0到3G的空间范围)为用户空间,从3G到4G的虚拟地址范围为内核空间。用户进程通常情况下只能访问用户空间的虚拟地址(即通常情况下程序中只能用地址即逻辑地址也是线性地址)的范围是0到0xbfffffff,不能访问3g到4g的虚拟地址,因为3g到4g的线性地址属于内核空间,只能由内核访问。除此之外,用户程序可以通过系统调用访问内核空间。
2.1.1 进程空间
用户空间对应进程,每当进程切换的时候,用户空间就会跟着变化。实际上变化的页表页目录等信息在变化,因为每个进程的页表页目录页是转换的信息是不相同的。因此每个进程的用户空间都是完全独立、互不相干的。例如:把同一个程序同时运行10次,用命令 cat /proc/<pid>/maps会看到10个进程使用的线性地址一模一样。如果这10个进程访问的物理地址一样的话将会出现严重冲突,但是这10个进程的CR3寄存器(cpu用来计算一个进程的页目录页表等信息的寄存器)存储的值不同,因此他们10个进程都有自己的页目录页表,因此访问的物理地址也完全不同,这样实现了每个进程都拥有独立的用户空间。
2.1.2 分页管理
分页单元中,页目录的地址放在CPU的CR3寄存器中,是进行地址转换的开始点;每一个进程,都有其独立的虚拟地址空间,运行一个进程,首先需要将它的页目录地址放在CR3寄存器中,将其他进程保存下来;每一个32位的线性地址都被划分为3部分:页目录索引(10位)、页表索引(10位)、偏移(12位)。
分页管理依据以下步骤进行地址转换:
- 装入进程的页目录地址(操作系统在调度进程时,把这个地址装入CR3);
- 根据线性地址前十位,在页目录中,找到对应的索引项,页目录中的每一项对应着一个页表的地址;
- 根据线性地址的中间十位,在页表找到页的起始地址;
- 将页的起始地址与线性地址的最后12位相加,得到物理地址 。
2.2 内存分配
2.2.1 内核内存分配kmalloc
应用程序常使用malloc函数进行动态内存分配,而在linux内核中,通常使用kmalloc来动态分配内存,其原型如下:
#include <linux/slab.h>
void *kmalloc(size_t size,int flags)
// 参数size为要分配的内存大小;
// 参数flags为分配标志,控制kmalloc的行为,如下:
GFP_ATOMIC:用来在进程上下午之外的代码(包括中断处理)中分配内存,无论分配失败还是成功都不会睡眠。
GFP_KERNEL:进程上下文中的分配,分配失败可能导致睡眠 。(分配的实际物理地址为16M到896M范围)
__GFP_DMA:要求分配能够DMA的内存区。(只有16M以下的页帧即0到16M的物理地址能够DMA传输)
__GFP_HIGHMEM:表示分配的内存位于高端内存。(896M以上)
2.2.2 内核按页分配内存
如果模块需要分配大块的内存,使用面向页的分配技术,函数如下:
get_zeroed_page(unsigned int flags); //返回指向新页的指针,并且将此页清零。其中参数flags为分配标志,分配一个物理页即4K大小的物理空间
__get_free_page(unsigned int flags); //和get_free_page类似,但不清零
__get_free_pages(unsigned int flags,unsigned int order); //分配若干个连续的页面,返回指向该内存区域的指针,但也不清零这段物理内存区域,分配4k整数倍大小的物理空间
2.2.3 内存释放
当程序用完这些页,可以使用下列函数之一来释放它们:
void free_page(unsigned long addr) // 释放一个页面
void free_pages(unsignde long addr,unsigned long order) //释放若干个页面
//注意:如果释放的和先前分配数目不等的页面,将会导致系统错误
// kfree用来释放kmalloc分配的物理地址
// malloc分配的是虚拟地址,kmalloc分配的是真实存在的物理地址
2.2.4 内存使用
由上图可知分配空闲页框(空闲的物理内存)有三种途径:
- 用户空间的用户程序通过系统调用函数(malloc、fork、excute、mmap)分配虚拟地址,此时并没有分配真正的实际页框,只有当进程真的去访问新获取的这个虚拟地址的时候,才会由“请页机制”产生“缺页”异常,从而来进入分配实际页框的程序,通过get_free_page(s)函数来分配空闲的页框。该异常是虚拟内存机制赖以存在的基本保证,它会告诉内核去为进程分配物理页,并建立对应的页表,在这之后虚拟地址才实实在在的映射到了物理地址上。
- 内核空间的内核程序通过vmalloc分配内核页表,由请页机制产生请页异常,通过get_free_page(s)函数来分配空闲的页框(跟a相似)。
- 内核空间的内核程序通过kmalloc将虚拟地址映射到slab管理器上。slab管理器首先从空闲页框(物理页)里面分出一部分来划分为不同字节的区间并组成链表,假设以n字节的区间作为链表的一个节点,kmalloc分配3n个字节,就从slab管理器中拿出三个链表节点,若分配m(m<n)字节,就从slab管理器中拿出一个链表字节。如果slab管理器的链表用完了,它将会重新从空闲页框分一部分出来组成链表。当kfree释放了内存后,又会将这些空间还给链表。
3、Linux内核地址空间
inux内核空间是由内核负责映射,它并不会跟着进程改变,是固定的。内核空间地址范围为3G到4G(其中0到3G为用户空间),内核空間分布图如下:
其中物理 内存896mb以上部分称为高端内存,注意是物理内存不是线性地址。
3.1 直接内存映射区
线性地址从3G开始到3G+896mb的地址区间,我们称为直接内存映射区,这是因为该区域的线性地址和物理地址之间存在线性转换关系:
线性地址=3G+物理地址(即内存直接映射到线性地址3G到3G+896MB范围)
例:物理地址空间0X100000-0X200000映射到线性空间就是3G+0X100000到3G+0X200000
3.2 动态内存映射区
线性地址从3G+896M开始到3G+896M+120M的地址区间叫做动态内存映射区,该区域的地址由内核函数vmalloc来进行分配的,其特点是线性空间连续,但对应的物理空间不一定连续,vmalloc分配的线性地址所对应的物理页可能区域的高端内存(物理内存896M以上)也可能处于低端内存(896M以下)。
注意:vmalloc分配地址是假分配,由请页机制产生请页异常,通过get_free_page(s)函数来分配空闲的页框。
3.3 永久内存映射区(KMAP)
从动态内存映射区后4M的区间(从3G+896M+120M到3G+896M+120M+4M)为永久内存映射区,属于高端内存,专门用来访问高端内存的窗口。访问方法先使用alloc_page(__GFP_HIGHMEM)分配高端内存页,再使用kmap函数将分配到得高端内存映射到该区域(访问高端内存的窗口)。即先分配好高端内存(物理内存的896M以上的地址区间),将其映射在线性地址永久内存映射区(从3G+896M+120M到3G+896M+120M+4M),我们访问这一段线性地址就等同于访问了物理内存的高端地址。
3.4 固定映射区
线性地址的最后4M区间称为固定映射区,与KMAP不同的是KMAP可以映射到所有物理内存的高端地址部分,即可以通过KMAP来访问到所有的高端内存,但是固定映射区只能访问已经指定好的某些高端内存。如:ACPI_BASE
4、Linux内核链表
5、Linux内核定时器
5.1 度量时间差
整个linux系统的时间是由时间中断来维护的。时间中断由系统的定时硬件以周期性的时间间隔产生,这个间隔(即频率)由内核根据HZ来确定,HZ是一个与体系结构无关的常数,课配置(50-1200),在x86平台默认为1000。每当时钟中断发生时,全局变量jiffies(unsigned long) 就加1,因此jiffies记录了自linux启动后时钟中断发生的次数。驱动程序常利用jiffies来计算不同事件间的时间间隔。如下最简单的延时实现:
unsigned long j = jiffies + jir_delay * HZ;
while(jiffies < j) {
// do nothing
}
5.2 内核定时器
定时器用于控制某个函数(定时器处理函数)在未来的某个特定时间执行。内核定时器注册的处理函数只执行一次,不是循环执行的。 内核定时器被组织成双向链表,并统一使用如下结构体来描述。
5.2.1 内核定时器结构描述
struct timer_list {
struct list_head entry; //内核使用,用来形成链表
unsigned long expires; //超时的jiffies值
void (*function)(unsigned long); //超时处理函数
unsiged long data; //超时处理函数参数
struct tvec_base *base; //内核使用
};
5.2.2 内核定时器初始化
初始化了两个成员,另外三个成员需要自己去指定,当定时器初始化好了之后就可以启动定时器
5.2.3 内核定时器启动和删除
// 启动内核定时器
void add_timer(struct timer_list *timer);
// 删除内核定时器, 定时器超时前将它删除,当定时器超时后,系统会自动的将它删除
int del_timer(struct timer_list *timer);
5.3 内核定时操作
6、Linux进程控制
6.1 进程的概念
6.1.1 进程和程序
程序是存放在磁盘上的一系列代码和数据的可执行映像,是一个静止的实体;进程是一个执行中的程序,是动态的实体。
6.1.2 进程四要素
- 有一段程序供其执行,这段程序不一定是某个进程所专有,可以与其他进程共用
- 有进程专用的内核空间堆栈
- 在内核中有一个task_struct数据结构,即进程控制块,有了这个数据结构,进程才能成为内核调度的一个基本单位接受内核的调度
- 有独立的用户空间
6.2 进程的描述
linux使用struct task_struct结构体(进程控制块)来表示进程、线程,它包含了大量描述进程、线程的信息,其中比较重要的如下:
进程ID
Linux世界中的进程唯一标识,最大值为10亿,通常被定义为:pid_t pid
进程状态
Linux中用来描述进程的状态,其变量为:volatile long state。有如下取值
- TASK_RUNNING:此状态对应两种即就绪和执行状态,进程正在被cpu执行,或者已经准备就绪随时可以执行。当一个进程刚被创建时,就处于此状态。(如果cpu只有一个,那么在任意时刻处于此状态的就只有一个进程控制块)
- TASK_INTERRUPTIBLE:此状态是堵塞状态的一种:可中断堵塞,进程处于堵塞时,可以被信号或者中断唤醒。
- TASK_UNINTERRUPTIBLE :此状态是堵塞状态的一种: 不可中断堵塞,进程处于堵塞时,向其发送信号或者中断不能将它唤醒。
- TASK_STOPPED :进程中止执行,当接收到SIGSTOP和SIGSTP信号时,进程进入到此状态,只有接收到SIGCONT信号后,进程重新回到TASK_RUNNING执行状态。
- TASK_KILLABLE: 此状态是堵塞状态的一种:linux2.6.25新引入的进程睡眠状态,原理类似不可中断堵塞 ,区别在于它可以被致命信号SIGKILL唤醒。
- TASK_TRACED :正处于被调试状态的进程。 例如用gdb调试一个进程的时候,那个进程处于此状态。
- TASKI_DEAD:进程退出(do_exit)时,state字段被设置为此状态。
进程退出时状态
- 僵死进程状态EXIT_ZOMBIE:表示进程的执行被终止,但是父进程并没有发布waitpid()系统调用来收集有关死亡的进程的信息。
- 僵死撤销状态EXIT_DEAD: 表示进程的最终状态,父进程已经使用wait4()或waitpid()系统调用来收集了信息,因此该进程将有系统删除。
进程用户空间描述指针
被定义为:struct mm_struct *mm。内核进程用此指针指向用户空间的实体,如果没有对应内核空间实体,此指针变量为NULL
进程的调度策略 unsigned int pollcy
优先级 int prio
静态优先级 int static_prio // 注意:linux中数值越大优先级越小
时间片 struct sched_rt_entity rt //关于时间片的结构体,其中成员rt->time_slice为时间片
6.3 进程控制块task_struct位置
当创建一个新的进程或者线程,系统将会在内核空间中给这个进程或者线程分配两个连续的物理页面,这两个页面被划分为两部分,其中一部分用来作为系统空间的堆栈,另一部分用来保存进程或者线程的task_struct结构信息。
上图为linux2.4版本的进程堆栈示意图,其中系统空间堆栈占7kb,task_struct占1kb,然而因为进程信息的扩展增大,linux2.6对其进行了改进,用 结构体thread_info structure来取。代了task_struct,然后用thread_info structure中的一个指针task去指向task_struct。如下图:
6.4 current指针
curremt指针是全局变量,指向当前正在运行的进程或线程的task_struct
6.5 进程的创建
6.6 进程的撤销
进程销毁可以通过如下事件驱动:通过正常的进程结束;通过信号;通过对exit函数的调用。不管进程如何退出,进程的结束都要调用内核函数do_exit进行对进程的销毁
7、Linux进程
从就绪的进程中选出最合适的一个来执行叫做调度,其中包括调度策略(调度的依据)、调度时机(什么时候去调度)、调度步骤(调度的时候完成了什么工作)。
7.1 调度策略
- SCHED_NORMAL(SCHED_OTHER) 普通的分时进程
- SCHED_ FIFO 先入先出的实时进程
- SCHED_RR 时间片轮转的实时进程
- SCHED_BATCH 批处理进程
- SCHED_IDLE 只在系统空闲时才能够被调度执行的进程
一个进程只能选择上面五种其中一种作为这个进程的调度策略,其中 SCHED_NORMAL、SCHED_BATCH、SCHED_IDLE属于CFS调度类(在kernel/sched_fair.c中实现),其中SCHED_RR、SCHED_FIFO属于实时调度类(在kernel/sched_rt.c实现)。
注意:调度类struct sched_calss中有个成员pick_next_task指针,表示选择下一个要运行的进程。
7.2 调度时机
调度时机即调度什么时候发生,也就是说schedule()函数什么时候被调用?主要有下两种:
7.2.1 主动式
在内核中直接调用schedule()。即一个运行的进程想睡眠了,自己申请调度,进入内核空间调用schedule函数,发生调度,将控制权交给等待的进程。例如:
current->state=TASK_INTERRUPTIBLE; //主动放弃cpu的进程一般先将自己设置为睡眠状态
schedule(); //然后调用schedule函数进行调度将控制权转交出去
7.2.2 被动式(抢占)
一个正在运行的进程,被其他进程抢占,被逼进行调度。且有用户抢占(linux2.4、linux2.6)和内核抢占(linux2.6)。
用户抢占发生在从系统调用返回用户空间或者从中断处理程序返回用户空间,即内核即将返回用户空间的时候,如果need_resched标志被设置(如果标志为允许被用户抢占,者后面调度允许被用户抢占),会导致schedule()被调用,此时就会发生用户抢占。
内核抢占:在不支持内核抢占的系统中,进程线程一旦运行于内核空间,就可以一直执行,直到它主动放弃或时间片耗尽为止,这样一些非常紧急的进程或者线程将长时间得不到运行,在支持内核抢占的系统中,更高优先级的进程线程可以抢占正在内核间运行的低优先级进程线程。
在支持内核抢占的系统中,以下特例不允许内核抢占:内核正在进行中断处理,进程调度函数schedule()会对此作出判断,如果是中断调用,会打印出错信息;内核正在进行中断上下文的Bottom Half(中断的底半部)处理,硬件中断返回前会执行软中断,此时仁然处于中断上下文中;进程持有spinlock自旋锁、writelock/readlock读写锁等,当持有这些锁时不应该不被抢占,否则由于抢占导致其他cpu长期不能获得锁而死等;内核正在执行调度程序scheduler,抢占的原因就是为了新的调度,没有理由将调度程序抢占掉再运行调度程序。
注意: 为了保证linux内核在以上情况下不会被抢占,抢占内核使用了一个变量preempt_count(内核抢占计数)。这变量被设置在进程的thread_info结构中,每当内核要进入以上几种状态时,变量preempt_count加1,指示内核不允许被抢占,每当内核从以上几种状态退出时,变量preempt_count就减1,同时进行可抢占的判断与调度。
内核抢占发生在两个时候:中断处理程序完成,返回内核空间之前;当内核代码再一次具有可抢占性的时候,如解锁及使能软断。
7.3 调度标志
内核提供了一个need_resched标志来表明是否需要重新执行一次调度。在下面几处会设置此标志:当某个进程耗尽它的时间片时,会设置这个标志;当一个优先级更高的进程进入可执行状态的时候,也会被设置这个标志。
7.4 调度步骤
内核调度步骤主要是schedule函数工作流程:
- 清理当前运行中的进程
- 选择下一个要运行的进程(pick_next_task)
- 未完