1. 内核实现策略
1.1 Microkernel(微内核)
- 只有最基本的功能由微内核实现,其他一些功能交给独立进程.所以微内核比较小.但是由于其他功能是独立的进程来实现,相互之间就需要IPC进行通信,所以微内核执行效率相对较慢.
- 微内核模块化的好处就是动态可扩展性.
1.2 Monolithic Kernel(单内核,不是很喜欢宏内核这个名称)
与微内核相反.单内核包含所有的子系统(如内存管理、文件系统、设备驱动程序). 内核中每个函数都可以访问所有其他部分.执行效率比微内核要高,所以Linux是依旧这种范型实现,其中已经引进了一个重要革新.那就是在系统运行时,模块可以插入到内核代码中,也可以移除,这可以向内核动态添加功能,弥补了单内核的一些缺陷.
2. 内核组成部分
2.1. fork和exec
Linux系统有两种创建新进程的机制,分别是fork和exec
fork创建当前进程的副本,父进程和子进程的pid不同.父进程内存的内容将被复制,从程序的角度来看是这样.Linux使用写时复制(copy on wirte),原理是将内存复制操作延迟到父进程或子进程向某内存页面写入数据之前,在只读访问情况下,父进程和子进程公用同一内存页.
exec将新程序加载到当前进程内存中执行.
2.2. clone
Linux用clone创建线程.工作方式类似于fork,但启用了精确的检查确认哪些资源与父进程共享、哪些资源为线程独立创建.这种细粒度的资源分配扩展了一般的线程概念,在一定程度上允许线程与进程之间的连续转换.
2.3 命名空间
用于隔离系统资源,使得同一系统上运行的进程能够拥有独立的视图,不受其他进程的影响.
2.4 地址空间和特权级别
Linux将虚拟地址空间分为两个部分,分别为内核空间和用户空间:
- 系统中每个用户进程都有自身的虚拟地址范围,从0到TASK_SIZE.TASK_SIZE到2^32或2^64保留给内核专用,用户进程不能访问.TASK_SIZE与体系结构相关.
- 在32位系统中,用户进程可用的虚拟地址空间是3GB,内核空间有1GB可用.
- 在64位系统中,实际使用的位数不是64,而是要小于的位数,如42位或47位.因此,地址空间中实际可寻址的部分要小于理论长度.但是,该值仍然大于计算机上实际可能的内存数量,因此是完全够用的.
这样,虚拟地址空间会包含一些不可寻址的洞,所以上图并不是完全正确的.
特权级别
IA32体系结构使用4种特权级别构成的系统,内环能够访问更多的功能,外环则更少.
Linux只使用两种不同的状态: 内核态和用户态.两种状态差别在于高于TASK_SIZE的内存区域的访问.简而言之,在用户状态下禁止访问内核空间.
- 从用户态到内核态的切换通过系统调用来完成.如果普通进程想要执行任何影响整个系统的操作(例如IO),则只能借助于系统调用向内核发出请求.内核首先检查进程是否允许执行想要的操作,然后代表进程执行所需的操作,接下来返回到用户状态.
- 除了代表用户进程执行代码以外,内核还可以由异步硬件中断激活,然后在中断上下文运行.与在进程上下文运行的区别是,在中断上下文运行不能访问虚拟地址空间的用户空间部分.因为中断可能随机发生,中断发生可能是任一用户进程处理活动状态,由于该进程基本上与中断原因无关,因此内核无权访问当前用户空间内容.在中断上下文运行时,内核必须比正常情况更加谨慎,例如: 不能进入睡眠状态.
- 除了普通进程,系统中还有内核线程在运行.内核线程也不予任何特定的用户空间进程相关联.因此也无权处理用户空间.不过在其他许多方面,内核线程更像是普通的用户层进程.在与中断上下文运转的内核相比,内核线程可以进入睡眠状态,也可以像系统中的普通进程一样被调度器跟踪.内核线程可以用于各种用途: 从内存和块设备之间的数据同步,到帮助调度器在CPU上分配进程.
2.5 内存映射
内存映射是一种重要的抽象手段.在内核中大量使用,也可以用于应用程序.映射方法可以将任意来源的数据传输到进程的虚拟地址空间中,作为映射目标的地址空间区域,可以像普通内存那样用通常的方法访问.但任何修改都会自动传输到源数据源.这样就可以使用相同的函数来处理完全不同的目标对象.
例如, 文件的内容可以映射到内存中.处理只需读取相应的内存即可访问文件内容,或向内存写入数据来修改文件的内容.内核将保证任何修改都会自动同步到文件中.
内核在实现设备驱动程序时直接使用了内存映射,外设的输入/输出可以映射到虚拟地址空间的区域中.对相关区域的读写会由系统重定向到设备.
2.6 物理内存的分配
在内核分配内存时,必须记录页帧的已分配或空闲状态,以免两个进程使用同样的内存区域.由于内存分配和释放非常频繁,内核还必须保证相关操作尽快完成.内核可以只分配完整的页帧.将内存划分更小的部分的工作,则委托给用户空间中的标准库.标准库将来源于内核的页帧分拆分成小的区域,并为进程分配内存.
伙伴系统
内核很多时候要求分配连续页.为快速检测内存中的连续区域,内核采用了古老而历经检验的技术: 伙伴系统.
系统中的空闲内存块总是两两分组,每组中的两个内存块称为伙伴.伙伴的分配可以是彼此独立的.但如果两个伙伴都是空闲的,内核会将其合并为一个更大的内存块,作为下一层次上某个内存块的伙伴.
根据上图所示,如果系统需要8个页帧,则将16个页帧组成的块拆分为两个伙伴.其中一块满足应用程序需求,剩余8个页帧则放置为2^3(8页)大小内存块的列表中
在应用程序释放内存时,内核可以直接检查地址,来判断是否能够创建一组伙伴,并合并为一个更大的内存块放回伙伴列表中,这刚好是内存块分裂的逆过程.
在系统长期运行中,服务器运行几个星期乃至几个月是很正常的,许多桌面系统也趋于长期开机运行,那么会发生内存碎片的内存管理问题.频繁的分配和释放页帧可能导致这一情况:系统中有若干页帧是空闲的,但却散布在物理地址空间的各处.换句话说,系统中缺乏连续页帧组成的较大的内存块.通过伙伴系统可以在某种程度上减少这种效应,但无法完全消除.
slab缓存
内核本身经常需要比完整页帧小得多的内存块.由于内核无法使用标准库的函数,因俄日必须在伙伴系统基础上自行定义额外的内存管理层,将伙伴系统提供的页划分为更小的部分.该方法不仅可以分配内存,还为频繁使用的小对象实现了一个一般性的缓存---slab缓存.它可以用两种方法分配内存
- 对频繁使用的对象,内核定义了只包含所需类型对象实例的缓存.每次需要某种对象时,可以从对应的缓存快速分配(使用后释放到缓存).slba缓存自动维护与伙伴系统的交互,在缓存用尽时会请求新的页帧.
- 对通常情况下小内存块的分配,内核针对不同大小的对象定义了一组slab缓存,可以像用户空间编程一样,用相同的函数访问这些缓存.不同之处是这些函数都增加了前缀k,表明是与内核相关联的: kmalloc和free.
2.7 链表处理
内核代码中经常出现list_head这个链表.内核提供的标准链表可用于将任何类型的数据结构彼此连接起来.很明确,它不是类型安全的.加入链表的数据结构必须包含一个类型为list_head的成员,其中包含了正向和反向指针.如果有若干链表涉及同一数据结构,这也是比较常见的,那么结构中就需要同样数目的list_head成员.
<list.h>
struct list_head {
struct list_head *next, *prev;
};
该成员可以如下放置在数据结构中:
struct task_struct {
...
struct list_head run_list;
...
};
链表的起点同样是list_head的实例,通常用
LIST_HEAD(list_name)宏来声明并初始化.如下图所示,内核建立了一个循环链表.这种链表的第一个和最后一个元素都能达到O(1)的访问时间.也就是说,不管链表的大小如何,访问这两个元素花费的时间是一个常数.
有若干处理链表的标准函数
- list_add(new, head) 用于现存的head之后,紧接着插入new元素
- list_add_tail(new, head) 用于在head元素之前,紧接着插入new元素.如果指定head为表头,由于链表是循环的,那么new元素就插入到链表的末尾
- list_del(entry) 从链表中删除一项
- list_empty(head) 检测链表是否为空,也就是链表是否没有包含元素
- list_splice(list, head) 负责合并两个链表,把list插入到另一个现存链表的head元素之后
- list_entry(ptr, type, member) 查找链表元素.初看起来,调用语法相当复杂.ptr是指向数据结构中list_head成员实例的一个指针,type是该数据结构的类型,而member则是数据结构中表示链表元素的成员名.如果在链表中查找task_struct的实例,则需要下列示例调用: struct task_struct = list_entry(ptr, struct task_struct, run_list)
因为链表的实现不是类型安全的,所以需要显示指定类型.如果数据结构包含在多个链表中,必须指定所要查找的链表元素,才能找到正确的链表元素.- list_for_each(pos, head) 遍历链表的所有元素.pos表示链表中当前位置,head指定了表头
struct list_head* p;
list_foreach(p, &list)
if (condition)
return list_entry(p, struct task_struct, run_list);
return NULL;