第1章 简介与概述
1.1 内核的任务
(1) 内核代替应用程序与硬件联系
(2) 内核作为资源管理器,分配CPU时间、内存、磁盘空间、网络连接资源
(3) 应用程序可以通过系统调用向内核发送请求
1.2 实现策略
1. 微内核
(1) 微内核只实现了最核心的功能,其他所有的操作都委托给一些独立进程,这些进程通过明确定义的通信接口与中心内核通信
(2) 该方法有很强的动态扩展性,系统的各个部分彼此都很清楚的划分开来
(3) 缺点是进程间通信需要额外的CPU时间,由进程来委托执行操作还会造成额外的调度成本,运行效率低
2. 宏内核
(1) 宏内核将核心功能与所有子系统打包在一起,内核中的每个函数都可以访问内核中所有其他部分
(2) 宏内核的灵活性相对较差,每次更新子系统都要重新编译整个内核
(3) 但模块的引入弥补了宏内核的缺陷,通过动态的插入和移除模块提升了宏内核的灵活性,模块通过内核全局符号表访问内核关键函数,编译时通过与内核联合编译从而使用内核结构
1.3 内核的组成部分
1.3.1 进程、进程切换、调度
(1) UNIX操作系统下运行的应用程序、服务器及其他程序都称为进程,进程是操作系统最关键也是最核心的概念,一切机制都围绕进程展开
(2) 各个进程地址空间相互独立,因此进程不会意识到彼此的存在,进程间的通信需要IPC机制来实现
(3) Linux是多任务系统,它支持并发执行若干进程,系统中同时运行的进程不会超过CPU数量,调度器会按照较短的时间在不同进程之间切换,这样就造成了同时处理多进程的假象
[1] 进程在被抢占前要保存现场,并在重新激活运行时原样恢复从而继续执行,营造了进程独占CPU的假象
[2] 调度器还必须确定如何在现存进程之间共享CPU时间,确定哪个进程执行多长时间的过程称为调度
1.3.2 UNIX进程
(1) Linux对进程采用了一种层次结构,每个进程都依赖一个父进程,内核启动init进程作为第一个进程
(2) UNIX操作系统有两种创建新进程的机制,分别是fork和exec:
[1] fork:创建一个新的进程,拥有独立的task_struct,并且是当前进程的副本,与当前进程之间只有PID不同,其他依赖的内核结构按需进行复制、共享、写时复制和初始化,
写时复制的原理是将内存复制操作延迟到父进程和子进程向某内存页面写入数据之前,在只读访问模式下父进程和子进程可以共用同一内存页
[2] exec:将一个新进程加载到当前进程的内存中执行,旧程序的内存页被刷出,并运行新程序
1. 线程
(1) 线程是除进程之外的另一种程序执行形式,一个进程可能由多个线程组成,他们共享地址空间,线程可以被看作是进程的一个执行流
(2) 在调度器眼里,线程和进程没有本质区别,都是调度实体
(3) 线程通过clone系统调用创建,拥有独立的task_struct。fork和clone都会调用体系结构无关的do_fork函数,之所以行为不同是因为clone_flag不同,fork的clone_flag
默认是SIGCHLD。clone则可以更自由的配置clone_flag,按照配置来决定哪些资源与父进程共享,哪些资源为线程独立创建,从而实现细粒度的资源分配
(4) 因为同一进程的线程之间共享地址空间,因此需要同步和互斥机制来避免冲突
2. 命名空间
(1) 命名空间使得不同的进程看到不同的系统视图,启用命名空间之后,以前的全局资源现在拥有不同分组,每个命名空间可以包含一个特定PID集合或者提供文件系统的不同视图
(2) 通过称为容器的命名空间建立系统的多个视图,从容器内部看来这是一个完整的Linux系统,而且与其他容器没有交互
(3) 容器是彼此分离的,每个容器实例看起来就像是运行Linux的一台计算机,一台物理机器可以运行多个这样的容器实例
1.3.3 地址空间和特权级别
(1) CPU的位数决定了计算机地址空间的大小,地址空间大小与实际可用的物理内存无关,因此被称为虚拟地址空间
(2) 每个用户进程都有自己的地址空间,分为用户空间和内核空间两部分,各个进程的用户空间是彼此分离的,而内核空间则都是同样的
(3) 在64位计算机中,倾向于使用更小的位数管理地址空间,比如42位或47位,这样会为管理地址空间节省一些工作量,也会导致地址空间中有无法寻址的空洞
1. 特权级别
(1) 内核把虚拟地址分成两部分,因此能保护多个系统进程,使其彼此隔离。同样为了保护系统,CPU提供了几种特权级别,限制访问硬件的指令或限制访问虚拟地址空间某一特定部分
(2) Linux有两种不同状态,内核态和用户态,分别对应英特尔处理器的 Ring 0 和 Ring 3。处于用户态的进程禁止访问虚拟地址的内核空间,也禁止执行访问硬件的指令
(3) 从用户态到核心态的切换需要通过系统调用完成,每当用户进程想要执行任何能影响整个系统的操作,只能通过系统调用向内核发起请求,内核首先检查进程是否允许执行想要的操作,
然后代表进程执行所需的操作。
(4) 内核的核心职责是 “管理整个计算机系统的硬件资源与软件资源”,并为用户进程提供一个 “安全、有序、高效的运行环境”。如果没有系统调用,用户进程像调用函数一样执行所需的
操作,那说明同样可以编写出修改内核空间甚至随意访问硬件的恶意程序,这样的程序会破坏内核资源管理的稳定性,随意访问共享资源还会发生冲突。系统调用执行的是一系列
固定的、安全的逻辑,它能过滤非法请求,关键是所有核心操作都交给内核来做,没有任何未知的操作,临界情况交给内核线程去处理,这样就避免了上述的攻击
(5) 除了系统调用之外,内核还可以由异步硬件中断激活,进入中断上下文。在中断上下文中,内核无权访问用户空间,并且内核必须比正常情况更加谨慎,比如不能进入睡眠状态。况且也
无法睡眠,因为中断程序没有对应的调度实体,它只是执行了一段程序。中断的优先级很高,要求关键操作快速处理,耗时的次要操作交给中断下半部处理
(6) 下半部机制:中断机制有一个矛盾,即中断需要快速响应,并且中断处理过程中会屏蔽同级以下的中断,但是耗时操作会延长中断处理时间,导致同级中断不能快速响应。为了解决这个
问题引入下半部机制,上半部快速处理硬件相关操作,尽快恢复中断响应;下半部执行耗时操作,不影响中断响应。下半部提供了四种实现方式:
[1] 软中断:静态分配,最多32个,在硬中断里注册软中断,硬中断返回时检查并执行软中断,或者由内核线程 ksoftirqd 集中处理软中断
[2] Tasklet:基于软中断实现(HI_SOFTIRQ),动态创建(创建的Tasklet会被放入一个链表中),不可在不同CPU并发(执行时打标记)
[3] 工作队列:本质上是可休眠的内核线程,上半部会负责唤醒下半部内核工作线程
[4] 延迟工作:上半部会延迟一段时间唤醒下半部内核工作线程,该延迟时间基于 jiffies 或 ktime 实现,不会阻塞上半部处理
(7) 除用户进程外,系统中还有内核线程在运行。内核线程只能访问内核空间,无权访问用户空间。内核线程可以睡眠,也可以像用户进程一样被调度器跟踪,部分内核线程被限制了只能在
某个CPU上运行(这和CPU亲和性与调度迁移相关)。内核线程可用于各种用途:从内存和块设备之间的数据同步,到帮助调度器在CPU上分配进程
(8) 内核线程是直接由内核创建并运行在内核态的特殊进程,它们没有独立的用户态地址空间,只负责执行内核级的周期性任务、异步处理、资源管理等工作,以下是一些典型的内核线程:
1. 系统核心管理类
[1] kthreadd:内核线程管理器,是所有其他内核线程的父进程,接受来自kthread_create()的请求,创建新的内核线程并调度其运行
[2] swapper:每个CPU都有一个swapper线程,系统idle时的“空转”进程。当CPU没有其他可运行进程时,swapper会被调度并执行cpu_idle()函数,降低CPU功耗
2. 内存管理类
[1] kswap:每个内存节点都有一个kswap线程,当系统物理内存不足时,kswap会周期性唤醒,通过页框回收(如将不常用的页面换出到swap分区,释放缓存页等)维持
系统内存平衡,避免内存耗尽导致OOM
[2] kcompactd:每个内存节点都有一个kcompactd线程,当系统中连续内存快不足时,kcompactd会将分散的小内存块合并为连续的大内存块
[3] khugepaged:自动将进程普通页面(如4KB)合并为大页(如 2MB 或 1GB),减少页表项数量,提升CPU缓存利用率和内存访问效率
3. 块设备与文件系统类
[1] bdi-default:将文件系统的“脏页”异步刷新到块设备,保证内存缓存与磁盘数据的一致性,避免突然断电导致数据丢失
[2] jbd2/*:ext3/ext4文件系统的日志管理线程,负责日志的提交、恢复等操作,确保文件项系统在崩溃后能通过日志恢复一致性,属于“日志型文件系统”的核心组件
[3] md/*:软件 RAID 的控制线程,处理 RAID 阵列的同步、重构、故障恢复等任务,确保 RAID 设备的可靠性
4. 中断与异步处理类
[1] ksoftirqd/0:每个CPU都有一个ksoftirqd线程,负责集中处理下半部软中断
[2] kworker/*:工作队列是内核中最常见的异步任务调度机制,驱动或内核模块可通过queue_work()提交任务,kworker线程负责执行这些任务。不同的kworker线程
可能绑定到特定CPU核心,或处理特定类型的任务
5. 其他专用线程
[1] watchdog/0:每个CPU都有一个watchdog线程,定期向硬件watchdog发送“喂狗”信号,若内核崩溃导致线程停止运行,watchdog会触发系统重启,用于提高系统
可靠性(常见于嵌入式设备)
[2] 自定义内核线程:内核补丁或模块可通过kthread_create()创建自定义内核线程,完成特定任务
6. 内核线程是内核实现“异步处理”和“后台任务”的核心机制,它们围绕资源管理(内存、文件系统)、硬件交互(块设备、中断)、系统可靠性(watchdog、日志)等核心需求设计
2. 虚拟和物理地址空间
(1) 系统是多进程的,并且物理内存一般要比虚拟内存小,出于对内存管理方便、内存连续性、进程隔离性的需求,使用页表建立虚拟内存和物理内存间的映射是一个绝佳的选择
(2) 虚拟地址关系到进程的用户空间和内核空间,而物理地址则用来寻址实际可用的内存
(3) 虚拟地址空间和物理地址空间都被分成等长的页,物理内存页称作页帧,虚拟地址页称作页
(4) 页映射的引入使得进程之间的内存隔离有一点点松动,即两个页可以映射到同一页帧,而是否共享页帧是内核完全可控的,并扩展为更高级的机制,比如进程间通信、内核与用户共享页
(5) 部分页没有映射的页帧,可能因为页没有使用,或者数据尚不需要使用而没有载入到内存中,还可能是页已经换出磁盘,将在需要时再换回内存
1.3.4 页表
(1) 将虚拟地址映射到物理地址的数据结构叫做页表
(2) 线性页表需要占据连续的大块内存,并且每个进程都有一个页表,如果使用线性页表则系统中所有的内存都要用来保存页表,这是不切实际的
(3) 虚拟地址空间的大部分区域都没有使用,因而也没有关联的页帧,基于这一规律可以使用更加节省内存的页表结构:多级页表
(4) 多级页表将虚拟地址分为5个部分,分别对应全局页目录(PGD),中间页目录(PMD),上层页目录(PUD),页表(PTE),页帧的偏移量,只要有PGD的地址和虚拟地址就可以找到对应的物理地址
(5) 多级页表对虚拟地址空间中不需要的区域,不必创建中间页目录或页表,从而大量节省内存
(6) CPU中的MMU单元会自动根据页表进行虚拟地址的映射,这大大节省了内核的工作。地址转换中频繁出现的地址,保存在称为地址转换后备缓冲器(TLB)的CPU高速缓存中,进程切换或者更新页表项都
要刷新TLB,这个工作在许多体系结构中是自动的,在少部分体系结构中要内核执行,因此内核凡涉及操作页表之处都必须调用相应的指令,如果针对不需要此类操作的体系结构编译内核,则相应
的宏调用自动变为空操作
1. 与CPU的交互
(1) 部分体系结构只有两层或三层页表,而Linux默认是四层页表,因此体系结构相关的代码需要将多出来的页表设置为空表从而适配体系结构无关的代码
2. 内存映射
(1) 内存映射是一种重要的抽象手段,在内核中大量使用,也可用于用户应用程序。映射方法可以将任意来源的数据传输到进程的虚拟地址空间中。作为映射目标的地址空间区域,可以像普通
内存那样用通常的方法访问,但任何修改都会自动传输到原数据源
(2) 内核在实现设备驱动程序时直接使用了内存映射,外设的输入/输出可以映射到虚拟地址空间的区域上,可以直接访问。并且由于Linux将所有硬件设备都看作是文件,因此对相关内存区域
的修改会通过写回机制重定向到硬件设备,大大简化了驱动程序的实现
(3) 我认为内存映射其实就是页帧共享,内核与设备共享页帧,通过写回机制同步;内核与用户进程共享页帧;进程和进程之间共享页帧;进程与文件之间共享页帧等等
1.3.5 物理内存的分配
(1) 内核分配内存时,必须记录页帧的已分配或空闲状态,以免两个进程分配同样的内存区域。由于内存分配和释放非常频繁,内核还必须保证相关操作尽快完成。内核可以只分配完整的页帧,标准库将
来源于内核的页帧拆分为小的区域,并为进程分配内存
1. 伙伴系统
(1) 伙伴系统是分配连续空闲页帧的绝佳方法
(2) 空闲内存块总是两两分组,每组的两个内存块称为伙伴,伙伴的分配是彼此独立的,如果两个伙伴都是空闲的,内核会将其合并为一个更大的内存块
(3) 内核对所有大小相同的内存块放在同一列表中管理,每一层的内存块大小都是2的指数,在已知内存块大小和其中一个伙伴的地址的情况下,可以通过位运算快速得出另一个伙伴的地址
(4) 分配内存时,大的内存块会分裂直至获得所需的内存块,其余的内存块放回伙伴系统
(5) 释放内存时,内核可以检查地址,来判断是否能够创建一组伙伴,并合并为一个更大的内存块放回到伙伴系统中
(6) 系统长期运行会导致内存碎片增加,即系统中有若干页帧是空闲的,但是却分散在物理地址空间的各处。比如一个大内存块中间的一个页帧被使用,那么两侧的空闲内存块是没办法合并的,
系统运行的时间越长这种情况发生的就越多,伙伴系统可以减少这种效应,并且内核还增加了一些有效措施来防止内存碎片
2. slab缓存
(1) 内核本身经常需要比完整页帧小得多的内存块。由于内核无法使用标准库中的函数,因而必须在伙伴系统基础上自性定义额外的内存管理层,将伙伴系统提供的页划分为更小的部分,即slab
缓存,该方法不仅可以分配内存,还为频繁使用的小对象实现了一个一般性的缓存
(2) 对频繁使用的对象,内核定义了只包含所需类型对象实例的缓存。每次需要每种对象时,可以从对应的缓存中快速分配,还可以进行对象预初始化等优化工作
(3) 对通常情况下小内存块的分配,内核针对不同大小的对象定义了一组slab缓存,分配内存时会根据对象的大小选择合适的slab缓存
(4) slab分配器在各种工作负荷下的性能都很好,但在真正规模庞大的超级计算机上使用时,出现了一些可伸缩性问题;在真正微小的嵌入式设备上(物理内存很小),slab分配器的开销又太大,
内核提供了两种slab分配器的备用方案从而适配以上两种场景。以上三种方案的接口相同,因此不用关注内核实际编译进来的底层分配器是哪一个
3. 页面交换和页面回收
(1) 页面交换通过利用磁盘空间作为扩展内存,从而增加了可用的内存
(2) 在内核需要更多内存时,不常用的页可以写入硬盘。如果再需要访问相关数据,内核会将相应的页切换回内存
(3) 通过缺页异常机制,这种切换操作对应用程序是透明的。换出的页可以通过特殊的页表项标识,在进程试图访问此类页帧时,CPU会启动一个可被内核截取的缺页异常,此时内核可以将硬盘上
的数据切换到内存中,接下来用户进程恢复运行。用户进程无法感知到缺页异常,因此页面交换对进程来说是透明的
(4) 页面回收用于将内存映射被修改的内容与底层的块设备同步,有时也叫数据回写。数据刷出后,内核即可将页帧用于其他用途
1.3.6 计时
(1) 内核必须能够测量时间以及不同时间点的时差,比如进程调度就会用到该功能,可以使用jiffies和高精度定时器
(2) 内核有两个全局变量,jiffies_64和jiffies,jiffies是jiffies_64的低32位,对jiffies_64加一就是等效地对jiffies加一,这是一个天才般的设计
(3) 定时器中断会根据HZ设定的频率触发,每次定时器中断都会递增jiffies_64,处理到期的软件定时器,触发周期调度器,更新系统时间(墙上时间)
(4) 定时器内置在CPU中,有其固定的频率,定时器内部的计数器设置为 (定时器频率 / HZ),每周期递减计数器,计数器归零时触发定时器中断,中断处理结束后重置计数器
(5) 高精度定时器不是周期定时器,而是将所有已注册的到期时间放入到一个红黑树中,每次有新定时器注册时,都会更新红黑树
(6) 内核根据最早到期的定时器时间,通过硬件接口设置下一次中断的触发时间,当硬件定时器触发中断时,内核会遍历红黑树,执行所有到期定时器的回调函数,并更新红黑树
(7) 虽然高精度定时器不是周期定时器,但是可以在回调函数中设置下一周期的到期时间
1.3.7 系统调用
(1) 系统调用是内核与用户交互的经典方法,传统的系统调用按照不同类别分组:
[1] 进程管理:创建新进程,查询信息,调试
[2] 信号:发送信号,定时器以及相关处理机制
[3] 文件:创建、打开和关闭文件,从文件读取或向文件写入
[4] 目录和文件系统:创建、删除和重命名目录,查询信息,链接,变更目录
[5] 保护机制:读取和变更UID/GID,命名空间的处理
[6] 定时器:定时器函数和统计信息
(2) 系统调用不能以标准库形式实现,因为需要特别的保护机制保证系统稳定性和安全不受危及。此外许多调用依赖内核内部的结构或函数,这也导致了无法在用户空间实现
(3) 发出系统调用时,处理器必须改变特权级别,从用户态进入内核态。这一操作有很多步骤,比如sp指向内核栈、用户态寄存器压栈、更新特权级别等等,体系结构一般会提供专用的汇编指令实现从
用户态到内核态的转变(或者直接利用中断指令,int 0x80h)
1.3.8 设备驱动程序、块设备和字符设备
(1) 设备驱动程序用于与系统连接的输入/输出设备通信。硬件连接到计算机上,就会与计算机发生交互,也就是输入/输出
(2) 在Linux眼中,万物皆文件,包括外部设备。应用程序可以通过访问/dev下的文件和设备进行交互。设备驱动程序的任务就是支持应用程序经由设备文件与设备通信
(3) 外部设备分成以下两类:
[1] 字符设备:提供连续的数据流,应用程序可以顺序读取,通常不支持随机存取
[2] 块设备:应用程序可以随机访问设备数据,程序可自行取定读取数据的位置。硬盘是典型的块设备,应用程序可以寻址磁盘的任何位置,但是只能以块为单位
(4) 应用程序与外部设备交互时,只会对设备文件使用read/write系统调用,设备驱动程序负责接受用户请求,并与外设交互
(5) 当read调用时无数据可读,进程会进入“可中断睡眠”状态,并将进程加入设备驱动的等待队列,设备驱动程序接受到数据后,会唤醒等待队列中的进程
(7) 编写块设备的驱动程序比字符设备困难得多,因为内核为提高系统性能广泛使用了缓存机制
1.3.9 网络
(1) 网卡也通过设备驱动程序控制,但在内核中属于特殊情况,因为不能直接通过网卡设备文件访问网络,因为应用程序发送或接受的数据要经过协议层打包或拆包
(2) 为支持用户进程通过文件接口处理网络链接,Linux引入了套接字抽象。用户进程通过套接字文件访问网络,在套接字处理程序中打包数据,然后通过网卡设备驱动程序发送数据,反之亦然
1.3.10 文件系统
(1) Linux系统由成千上万的文件组成,这些文件被存储在块设备中。存储使用了层次式文件系统
(2) Linux支持许多不同的文件系统,包括Ext2, Ext3, ReiserFS, XFS, VFAT,不同文件系统的概念抽象可以说是南辕北辙。文件系统归根结底还是软件逻辑,依赖块设备驱动程序
(3) Ext2基于inode,inode中存储了文件所有的元信息,以及指向相关数据块的指针。目录可以表示为普通文件,其数据包括了指向目录下所有文件的inode的指针,从而建立层次结构
(4) 内核还必须提供一个额外的软件层,将各种底层文件系统的具体特性与应用层(和内核自身)隔离起来,该软件层称为VFS(虚拟文件系统)
(5) VFS既是向下的接口(所有文件系统都必须实现该接口),同时也是向上的接口(系统调用程序通过VFS接口访问文件系统功能)
1.3.11 模块和热拔插
(1) 模块用于在运行时动态向内核添加功能,包括设备驱动、文件系统、网络协议等,实际上内核任何除基本功能的子系统都可以模块化。这消除了宏内核与微内核相比一个重要的不利之处
(2) 模块本质上不过是普通的程序,只不过运行在内核空间,模块必须提供某些代码段 在模块初始化(和终止)时执行,以便向内核注册和注销模块,模块和普通内核代码一样拥有最高权限,理论上
能够访问所有内核函数和数据,但是标准的做法是模块只能访问内核暴露在全局符号表中的函数和数据,否则会编译失败或者无法被社区承认,并且模块编译需要用到内核的头文件
(3) 对于支持热拔查而言,模块在本质上是必需的。系统检测到新设备时,通过加载对应的模块,可以将必要的驱动程序自动添加到内核中。模块特性使得内核可以支持种类繁多的设备,而内核
自身在内存的大小不会发生膨胀
(4) 因为模块时运行在内核态的,拥有最高权限,理论上可以任意修改内核数据,因此恶意模块或者未经社区审查的模块可能会造成系统崩溃
1.3.12 缓存
(1) 内存作为块设备的缓存,缓存按页组织,也叫页缓存
1.3.13 链表处理
(1) 出于对扩展性和最大化利用内存空间的需求,内核普遍使用链表结构
(2) 内核对链表的设计十分巧妙,通过将链表结构嵌入到数据结构中,可以将任何类型的数据用链表连接起来,用container_of机制获取链表元素的位置
(3) 链表都有一个表头,并且是双向循环链表,可以在O(1)时间内访问第一个或最后一个元素
(4) 内核还提供了若干函数操作链表,包括插入、删除元素,检查空链表,合并链表,查找链表元素,遍历链表
1.3.14 对象管理和引用计数
(1) 内核中许多地方需要跟踪C语言中的结构,为此内核使用一般性的方法管理内核对象,从而避免代码复制,同时也为内核不同部分管理的对象提供了一致的视图
(2) 一般性的内核对象机制需要以下操作:
[1] 引用计数
[2] 管理对象链表(集合)
[3] 集合加锁
[4] 将对象属性导入到用户空间(通过sysfs文件系统)
1. 一般性的内核对象
(1) kobject结构嵌入到其他数据结构中,作为内核对象的基础
(2) kobject结构的成员含义如下所示:
[1] k_name:对象的名称,可利用sysfs导出到用户空间,sysfs对应的就是根目录的sys文件夹
[2] kref:用以简化引用计数的管理
[3] entry:链表节点,用于将若干内核对象放置到一个链表中
[4] kset:将对象与其他对象放置在一个集合时,需要用到kset
[5] parent:指向父对象的指针,用于在sysfs中建立层次结构
[6] ktype:提供了包含kobject的数据结构的更多详细信息
(3) 内核提供了若干函数管理kobject,实际上是作用于包含kobject的结构:
[1] kobject_get, kobject_put:对kobject的引用计数器加1或减1
[2] kobject_(un)register:从层次结构中注册或删除对象,对象被添加到父对象中现存的集合中(如果有的话),同时在sysfs文件系统中创建一个对应项
[3] kobject_init:初始化一个kobject,即将引用计数器设置为初始值,初始化对象的链表元素
[4] kobject_add:初始化一个内核对象,并使之显示在sysfs中
[5] kobject_cleanup:在不需要kobject(以及包含kobject的对象)时,释放分配的资源
(4) kref结构将一个原子类型变量封装在结构中,防止直接操纵值。必须使用kref_init初始化kref;如果要使用某个对象,必须调用kref_get对引用计数器加1;如果对象不再使用,
则必须调用kref_put使引用计数器减1
2. 对象集合
(1) 内核对象集合机制使用kset结构,其成员含义如下:
[1] ktype:指向kset中各个内核对象公用的kobj_type结构
[2] list:属于当前集合的内核对象的链表表头
[3] uevent_ops:提供了若干函数指针,用于将集合的状态信息传递给应用层
[4] kobj:管理集合的结构只能是内核对象,因此嵌入kobject管理kset对象本身。也就是说集合下面仍然可以有集合,组成了一个类似文件系统的层次结构
3. 引用计数
(1) 引用计数用于检测内核中有多少地方使用了某个对象,每次新的引用都要对计数器加1,不再需要引用则计数器减1,引用计数为0时释放对象
(2) 引用计数的数据结构很简单,只提供了一个原子引用计数。这里的“原子”意味着对该变量的加1和减1操作在多处理器系统上也是安全的
(3) 使用kref_init、kref_get和kref_put用于对引用计数器进行初始化、加1、减1操作
1.3.15 数据类型
1. 类型定义
(1) 内核使用typedef来定义各种数据结构,以避免依赖于体系结构相关的特性。为此内核定义了若干整数数据类型,不仅明确了是否有符号,还指定了相关类型的精确位数
2. 字节序
(1) 现代计算机采用大端序或小端序,内核提供了各种函数和宏,可以在CPU使用的格式与特定的表示法之间转换
3. per-cpu变量
(1) 通过DEFINE_PER_CPU(name, type)为每个CPU创建一个type类型的实例,通过get_cpu(name, cpu)获取对应的实例,smp_processor_id()返回当前活动处理器的ID
(2) 适用于需要为每个CPU创建实例的场景,比如就绪队列
4. 访问用户空间
(1) 内核代码权限很高,理论上可以访问地址空间上任意位置,但是为了避免内核代码破坏用户空间,使用__uesr标识防止内核代码随意访问用户空间
(2) 来自用户空间的地址必须加上__user标识,内核不能直接访问带有该标识的地址,必须通过特定的函数访问,否则会编译错误
(3) 地址对应的用户空间页帧可能不在物理内存中,这也是内核不能直接访问用户空间的原因,使用特殊的函数可以在访问之前进行详细的检查
1.3.16 本书的局限性
(1) 内核开发是一个高度动态的过程,内核获得新特性和持续改进的速度有时简直是不可思议
1.4 为什么内核是特别的
(1) 内核很神奇,但归根结底它只是一个巨大的C程序,带有一些汇编代码(不时出现很少量的“黑巫术”)
(2) 内核是由世界上最好的程序员编写的,源代码可以证实这一点,其结构良好,细节一丝不苟,巧妙的解决方法在代码中处处可见。一言以蔽之:内核应该是什么样子,它现在就是什么样子
1.5 行文注记
(1) 待在孤岛上并不有趣
1.6 小结
(1) 在人类编写过的所有软件中,Linux内核无疑是最有趣和最吸引人的一种软件了
第2章 进程管理和调度
(1) 所有操作系统都能够同时运行多个进程,至少用户错觉上是这样。真正并行运行的进程数量取决于物理CPU数量
(2) 内核和处理器建立了多任务的错觉,通过在短时间内切换进程,在感官上觉得计算机并行执行多个进程。这引出了如下两个问题:
[1] 进程之间不能彼此干扰
[2] CPU时间必须在各个进程之间尽可能公平的共享,其中一些进程比其他进程更重要
(3) 对于第一个问题可以通过页表+虚拟内存实现,第二个问题则由调度器处理,调度器有如下两个任务:
[1] 调度策略:调度器要决定为进程分配多长时间,何时切换到下一进程。这又引出了哪个进程是下一个进程的问题。此类决策时体系结构无关的
[2] 任务切换:在内核从进程A切换到进程B时,必须保证进程B的执行环境与上一次撤销其处理器时完全相同。执行环境是指寄存器的内容和虚拟地址空间的结构,这个工作与体系结构相关
2.1 进程优先级
(1) 进程可以根据关键度分为三类:
[1] 硬实时进程:有严格的时间限制,某些任务必须在规定的时限内完成。Linux本身不支持硬实时进程,修改过后的内核通过类似微内核的方式实现了硬实时,将次要的内核工作交给独立进程处理,
硬实时进程拥有最高的优先级,将抢占其他所有类型进程并执行
[2] 软实时进程:是硬实时进程的一种弱化形式。软实时进程优先级优于普通进程,任务超时会写入内核日志,不会导致内核崩溃或者世界末日
[3] 普通进程:没有特殊约束,使用完全公平调度策略,根据重要性分配优先级
[4] 在设计调度策略时,要考虑到如下几个问题:
(1) 重要的进程要比次要的进程分配到更多的CPU时间
(2) 因为等待资源而睡眠的进程不应该被调度
(3) 必须支持不同的调度类别(如实时调度和完全公平调度)
(4) 在某些情况下,更重要的进程会抢占次重要的进程(如实时进程抢占普通进程)
[5] 完全公平调度器(CFS):尽可能模仿理想情况下的公平调度,并且调度更加一般性的调度实体。比如在调度器分配时间时,可以首先在不同用户之间分配,接下来在各个用户的进程之间分配
2.2 进程生命周期
(1) 进程并不总是可以立即运行,有时它必须等待来自外部信号源,不受其控制的事件。这里的等待不是主动等待,而是被动唤醒
(2) 进程有以下几种状态:
[1] 运行:此刻进程正在运行
[2] 等待:进程能够运行,但是没有分配CPU。调度器可以在下一次任务切换时选择该进程
[3] 睡眠:进程正在睡眠无法运行,它在等待外部事件唤醒
(3) 系统将所有进程保存在一个进程表中,无论其状态是运行、睡眠还是等待。睡眠进程会分类到对应的队列中,发生事件时将进程唤醒,这就是事件驱动的原理
(4) 进程还有一个特殊状态就是“僵尸”状态,即进程的资源(内存、与外设的连接,等等)已经释放,它们无法也决不能再次运行,但是进程表中仍然有对应的表项
(5) 进程的父进程在子进程终止时必须调用或已经调用wait4(wait for)系统调用,因为要想内核证实父进程已经确认子进程的终结。为什么要采用这么奇怪的方式呢?因为由于进程的隔离性,父进程看不到
子进程的状态,它只知道子进程的PID,因此子进程终结后会向父进程发送信号,父进程回收资源并申请wait4系统调用彻底删除子进程。也就是说,进程终结后,由内核来回收内核层面的资源,由父进程
回收用户层面的资源,然后才能在进程表中彻底删除进程并回收task_struct结构。假如进程终结后直接回收全部资源,那么由于父进程只知道子进程的PID,而该PID被新的进程占用,导致父进程的错误,
这也是使用wait4系统调用的原因之一
(6) 如果父进程不使用wait4系统调用,那么僵尸进程将稳定地存在于进程表中,但由于僵尸进程残余的数据在内核中占据的空间极少,只在需要长时间运行的网络服务器上才会有问题
(7) 如果父进程终结,其子进程(包括僵尸进程)会将init进程作为自己的父进程,init进程会发起wait4系统调用回收终结的子进程
抢占式多任务处理:
(1) Linux中执行的代码三种执行上下文,初始化上下文、进程上下文、中断上下文,进程上下文又有两种状态,即用户态和内核态,初始化全程在内核态中执行,它们有不同的抢占级别:
[1] 用户态:用户态进程总是可能被抢占,其可能在任意位置被中断抢占,并且在内核态返回用户态时被其他进程抢占
[2] 内核态:如果系统处于内核态,那么系统中的其他进程是无法将其抢占的,调度器必须等待内核代码执行结束返回用户态时,才能切换到另一个进程执行,中断可以抢占内核态进程
[3] 中断上下文:中断可以暂停处于用户态或内核态的进程,中断拥有最高优先级,因为在中断触发后要尽快处理
(2) 上面不同的抢占级别是有其原理的。CPU接收到中断信号会自动保存现场并跳转到对应的代码,因此中断的抢占优先级最高;之所以在内核态返回用户态时才能切换到下一个进程,是因为在那个时刻
会调用主调度器函数,该函数会检查该进程是否应该被抢占,如果是则选择下一个进程并进行上下文切换,因此在内核态代码执行时不能抢占;而用户态进程随时可能因为系统调用、定时器中断等
原因进入内核态,并在内核态返回用户态时切换到另一个进程。
(3) 在内核态代码进入关键的临界区时,可能会禁用中断。并且系统在进入中断上下文时会禁用同等级及以下的中断,硬件中断要快速处理上半部关键操作,下半部耗时操作交给软中断、工作线程等机制
延时处理,从而避免阻塞后面的硬件中断
(4) Linux引入了更加平滑的抢占机制,即内核抢占。内核态代码每次退出临界区都会先检查抢占计数,从而判断是否真正退出临界区,然后检查当前进程是否应该被调度,如果条件符合则进入主调度函数
并进行上下文切换。进程调度都是在主调度函数中进行的,进程恢复也是继续执行主调度函数的后续代码,可以说所有被调度的进程的IP都指向同一位置
2.3 进程表示
(1) Linux内核涉及进程和程序的所有算法都围绕一个名为task_struct的数据结构建立,该结构定义在include/sched.h中。task_struct中有很多成员,将进程各个子系统联系起来
(2) 可以将task_struct分为以下几个部分:
[1] 状态和执行信息,如待决信号、使用的二进制格式、进程ID号、到父进程及其他有关进程的指针、优先级和程序执行有关的时间信息
[2] 有关已经分配的虚拟内存的信息
[3] 进程身份凭据,如用户ID、组ID以及权限等。可使用系统调用查询(或修改)这些数据
[4] 使用的文件包含程序代码的二进制文件,以及进程处理的文件的所有文件系统信息
[5] 线程信息记录该进程特定于CPU的运行时间数据
[6] 在与其他应用程序协作时所需的进程间通信有关的信息
[7] 该进程所用的信号处理程序,用于响应到来的信号
(3) task_struct的state变量指定了进程的当前状态,可使用下列值:
[1] TASK_RUNNING:进程处于可运行状态。但并不意味着分配了CPU,进程可能会一直等到调度器选中它。该状态确保进程可以立即运行,而无需等待外部事件
[2] TASK_INTERRUPTIBLE:针对等待某事件或其他资源的睡眠进程。它们被放在对应事件的队列中,事件发生则唤醒这些进程,将状态设置为TASK_RUNNING,并将进程放入调度器的就绪队列
[3] TASK_UNINTERRUPTIBLE:因内核指示而停用的睡眠进程。它们不能由外部信号唤醒,只能由内核亲自唤醒
[4] TASK_STOPPED:进程特意停止运行
[5] TASK_TRACED:因调试停止运行。用于从停止的进程中,将当前被调试的那些与常规的进程区分开来
[6] EXIT_ZOMBIE:僵尸状态
[7] EXIT_DEAD:指wait4系统调用已经发出,而进程完全从系统移除之前的状态。只有多个进程对同一个进程发起wait系统调用时,该状态才有意义
(3) Linux提供资源限制机制,对进程使用系统资源施加限制,该机制利用了struct rlimit类型的rlim数组,该类型有两个成员,rlim_cur是进程当前的资源限制,rlim_max是限制的最大容许值,数组中
每一项都对应一个资源类型:
[1] 按毫秒计算的最大CPU时间
[2] 允许的最大文件长度
[3] 数据段的最大长度
[4] (用户状态) 栈的最大长度
[5] 内存转储文件的最大长度
[6] 进程使用页帧的最大数目
[7] 与进程真正UID关联的用户可以拥有的进程的最大数目
[8] 打开文件的最大数目
[9] 不可换出页的最大数目
[10] 进程占用的虚拟内存的最大尺寸
[11] 文件锁的最大数目
[12] 待决信号的最大数目
[13] 信息队列的最大数目
[14] 非实时进程的优先级
[15] 最大的实时优先级
(4) 每用户的最大进程数,定义为max_threads/2。max_threads是一个全局变量,指定了在把八分之一可用内存用于管理线程信息的情况下,可以创建的进程数目。内核提前给定了20个线程的最小可能内存用量
2.3.1 进程类型
(1) 典型的UNIX进程包括:由二进制代码组成的应用程序,单线程,分配给应用程序的一组资源。新进程是使用fork和exec系统调用产生的
[1] fork生成当前进程的一个副本,该副本称为子进程。原进程的资源都以适当的方式复制到子进程,因此该系统调用之后,原来的进程就有了两个独立的实例。这两个实例的联系包括:同一组
打开文件、同样的工作目录、内存中同样的数据等等。此外,二者别无关联
[2] exec从一个可执行的二进制文件中加载另一个应用程序,来代替当前运行的进程。exec并不创建新进程,因此必须使用fork复制一个旧的程序,然后用exec加载一个新的程序
[3] Linux还提供了新的clone系统调用,clone用于实现线程。clone的原理与fork基本相同,但新进程不是独立与父进程的,而可以与其共享某些资源。clone可以指定需要共享和复制的资源
种类,相较于fork更加自由,理论上实现线程只是clone的使用场景之一,clone肯定还可以实现更多、更高级的功能
2.3.2 命名空间
(1) 命名空间提供了虚拟化的一种轻量级形式,使得我们可以从不同的方面查看系统的全局属性
1. 概念
(1) 命名空间的背景
[1] 传统上,在Linux中许多资源是全局管理的,比如全局PID列表、系统属性、用户ID等。
[2] 全局ID使得内核可以有选择的允许或拒绝某些特权,比如UID为0的用户拥有无限特权,其他UID的用户则不能修改其他用户的进程。
[3] 虽然无法进行修改,但用户仍然可以看到其他用户的进程。
[4] 这没什么问题,但在有些情况下,这种效果是不想要的,比如Web主机向用户提供Linux的全部访问权限时,向每个用户提供一个计算机成本太高,使用虚拟环境则资源分配做得
不是非常好,每个用户都需要一个独立的内核,和一份完全安装好的配套的应用层应用。
[5] 命名空间提供了一种不同的解决方案,所需资源较少。其只使用一个内核,并将所有全局资源都通过命名空间抽象起来。本质上命名空间建立了系统的不同视图
(2) PID命名空间举例
[1] 考虑三个PID命名空间的情况,命名空间组织为层次结构,一个命名空间是父命名空间,衍生了两个子命名空间
[2] 每个命名空间都有自己PID为0的init进程,其他进程的PID以递增次序分配。PID在命名空间中是唯一的,但不是全局唯一的
[3] 虽然子容器不了解系统中的其他容器,但父容器知道子命名空间的存在,也可以看到其中执行的所有进程。子命名空间的PID映射到父命名空间的PID,一个进程可能有多个PID
[4] 如果命名空间包含的是比较简单的量,也可以是非层次的
(3) 创建新的命名空间
[1] 引入命名空间之前,Linux支持简单形式的命名空间,使用chroot系统调用能将进程限制到文件系统的某一部分,真正的命名空间的能控制的功能远远超过文件视图
[2] 使用fork或clone系统调用创建新进程时,有特定的选项可以控制是与父进程共享命名空间,还是建立新的命名空间
[3] unshare系统调用可以将当前进程的某些部分从父进程分离,其中包括命名空间
[4] 命名空间彼此隔离,从进程的角度来看,改变全局属性不会传播到父命名空间,父命名空间的修改也不会传播到子进程。但对文件系统来说,情况则更为复杂,其中的共享机制
非常强大,带来了大量的可能性
2. 实现
(1) 命名空间数据结构
[1] 子系统的全局属性封装到命名空间中,每个进程关联到一个命名空间。每个可以感知到命名空间的子系统都必须提供一个数据结构,将所有通过命名空间形式提供的对象集中起来
[2] struct nsproxy用于汇集指向特定子系统的命名空间包装器的指针:
1. UTS命名空间包含了运行内核的名称、版本、底层体系结构类型等信息
2. 保存在struct ipc_namespace中所有与进程间通信IPC有关的信息
3. 已经装载的文件系统视图,在struct mnt_namespace中给出
4. 有关进程ID的信息,由struct pid_namespace提供
5. struct user_namespace保存的用于限制每个用户资源使用的信息
6. struct net_ns包含所有网络相关的命名空间参数
(2) 将进程关联到命名空间
[1] 每个进程都内嵌一个struct nsproxy *nsproxy,从而关联到自身的命名空间视图。因为使用了指针,多个进程可以共享一组子命名空间
[2] 如果内核编译时没有指定对具体命名空间的支持,则使用默认命名空间,其作用类似于不启用命名空间,所有的属性相当于全局的
[3] init_nsproxy定义了初始的全局命名空间,其中维护了指向各子系统初始的命名空间对象的指针
UTS命名空间
(1) UTS命名空间数据结构
[1] UTS命名空间只需要简单变量,没有层次组织
[2] uts_namespace内含多个字符串,分别存储了系统的名称、内核发布版本、机器名等,初始设置保存在init_uts_ns中
(2) 创建一个新的UTS命名空间
[1] 使用copy_utsname函数创建新的UTS命名空间,原理是生成当前uts_namespace实例的一份副本,当前进程nsproxy实例内部的指针指向这个实例
用户命名空间
(1) 用户命名空间数据结构
[1] 用户命名空间数据结构内含引用计数器、哈希表和root用户的user_struct指针
[2] 命名空间的每个用户,都有一个user_struct的实例负责记录其资源消耗,各个实例可通过uidhash_table获取
[3] task_struct结构的user成员指向所属用户命名空间的、所属用户UID对应的user_struct实例
(2) 创建一个新的用户命名空间
[1] 每个用户命名空间对其用户资源统使用的统计,与其它命名空间无关,对root用户的统计也是如此。这是因为在克隆一个用户命名空间时,为当前用户和
root都创建了新的user_struct实例
[2] alloc_uid是clone_user_ns函数中使用的一个辅助函数,对当前命名空间中给定UID的一个用户,如果该用户没有对应的user_struct实例,则分配一个新的实例
[3] 在为root和当前用户分别设置了user_struct实例后,switch_uid确保从现在开始将新的user_struct实例用于资源统计,实质上就是将task_struct的user
成员指向新的user_struct实例。用户命名空间是非层次结构,子进程命名空间的所有值都是新初始化的,与父进程命名空间的值没有任何关系
2.3.3 进程ID号
1. 进程ID
(1) 进程ID分类
[1] PID:用于在其命名空间中唯一地标识进程
[2] TGID:线程组的所有线程的统一线程组ID,与线程组长的PID相同。通过clone创建的所有线程的task_struct的group_leader成员,会指向组长的task_struct实例
[3] PGID:独立进程可以合并成进程组,进程组成员的task_struct的pgrp属性值是相同的,即进程组长的PID。进程组简化了向组内所有成员发送信号的操作,用管道连接
的进程包含在同一进程组中
[4] SID:几个进程可以合并成一个会话,会话中的所有进程都有同样的会话ID,保存在task_struct的session成员中,会话可用于终端程序设计
(2) 局部ID与全局ID
[1] 命名空间增加了PID管理的复杂性,PID命名空间按层次组织。在建立一个新的命名空间时,该命名空间中的所有PID对父命名空间都是可见的,但子命名空间无法看到父命名空间
的PID。这意味着某些进程可能有多个PID,凡可以看到该进程的命名空间,都会为其分配一个PID。因此,我们必须区分全局ID和局部ID
[2] 全局ID:在内核本身和初始命名空间中的唯一ID号。对每个ID类型,都有一个给定的全局ID,保证在整个系统中是唯一的
[3] 局部ID:属于某个特定的命名空间,只在所属的命名空间有效,不具备全局有效性
(3) 全局ID的存储位置
[1] 全局PID和TGID直接保存在task_struct中,分别是task_struct的pid和tgid成员
[2] 全局PGID和SID不是直接包含在在task_struct中,而是用于信号处理的结构中。分别是task_struct->signal->__pgrp和task_struct->signal->__session
2. 管理PID
数据结构
(1) PID命名空间基本结构
[1] PID命名空间是一个层次结构,通过parent指针指向父命名空间,level描述命名空间深度,child_reaper指向命名空间的init进程。其中父命名空间可以看到
子命名空间进程的PID,也就是说一个进程可能有多个PID。命名空间也可以理解成一个树结构,下面用树结点来代PID命名空间,从而更加抽象的理解这一概念:
从根结点到进程所属结点的路径上的所有结点,都可以看到该进程,并且路径上每个结点都会为进程分配一个在其命名空间中唯一的ID值
[2] 一个进程可能有多个PID,虽然它们ID值不同,但是它们所指的都是同一个进程,只是命名空间不同。因此内核将一个进程的所有ID值汇总在pid结构中,level表示
该PID的命名空间最大深度,upid类型的numbers数组则保存不同命名空间的局部ID值,numbers数组的下标代表命名空间深度。numbers数组形式上只有一个
数组项,但其位于pid结构末尾,因此只要分配更多空间,即可项数组添加附加项,通过level可以计算出结构的真实大小,这样做既灵活也节省了空间
[3] 在upid结构中,ns指向对应的命名空间,nr保存了局部ID数值。表示进程在指定命名空间的ID数值
(2) PID命名空间扩展结构
[1] 上面已经搭建好PID命名空间的基本框架,但是由于具体需求,还需要对该结构进行扩展
[2] 对于task_struct结构来说,ID有三种类型,即PID、PGID、SID,因此至少需要内嵌三个pid指针。而且必须在已知pid实例的情况下,能够获取到使用该pid的
task_struct实例,但是由上可知,可能会有多个task_struct共享同一个pid实例。为了实现以上需求,内核在pid结构中嵌入一个散列表,用于保存所有使用
该pid实例的task_struct实例,散列表的每个数组项都代表一个ID类型,这样就可以在已知ID类型和pid实例的情况下,找到使用该pid实例的task_struct
实例了;内核在task_struct中内嵌了pid_link数组,pid_link有两个成员,pid指向对应的pid实例,node用作pid散列表元素,每个数组项都代表一个ID
类型,将task_struct和pid连接起来,并且可以在已知ID类型和task_struct实例的情况下,找到对应的pid实例,然后还可以通过task_struct->ns_proxy
->pid_namespace.level找到当前命名空间的局部ID值
[3] 内核还有一个需求,即给出局部数字ID和命名空间,返回task_struct实例。内核中有局部ID(upid)到命名空间的联系,但是命名空间却没有保存局部ID。内核将
所有的upid实例保存在一个名为pid_hash的哈希散列表中,散列表的大小在16~4096之间,pidhash_init用于计算恰当的容量并分配所需的内存。并且upid
结构中也要加入一个散列表结点。内核根据(命名空间地址, 局部ID)这个全局唯一的二元组计算哈希键值,然后对比命名空间地址和局部ID找到准确的upid实例。
通过upid实例和命名空间深度,可以找到pid实例,进程和PID是一一对应的关系,因此pid散列表中TYPE_PID散列表的第一项就是对应进程的散列表结点,从而
可以找到对应的task_struct实例。
函数
(1) 核心需求
[1] 给出局部数字ID和命名空间,查找task_struct实例
[2] 给出task_struct、ID类型、命名空间,取得命名空间的局部ID
(2) 给出task_struct、ID类型、命名空间,取得命名空间的局部ID
[1] 获得与task_struct关联的pid实例,辅助函数task_pid、task_tgid、task_pgrp、task_session分别用于取得不同类型的pid实例
[2] 在获得pid实例之后,从struct pid的numbers数组中的upid信息,即可获得局部数字ID。即pid->numbers[ns->level].nr
[3] pid_ns_nr用于获取pid的指定命名空间的局部ID,pid_vnr用于获取pid的最深层命名空间的局部ID,pid_nr用于获取pid的全局PID
(3) 给出局部数字ID和命名空间,查找task_struct实例
[1] 首先使用find_pid_ns函数,根据局部ID和命名空间指针计算在pid_hash中的索引,然后遍历散列表直至找到所要的元素,通过container_of机制获取对应的upid
实例,然后在获取到pid实例
[2] pid_task函数取出pid->tasks[type]散列表的第一个task_struct实例
[3] find_task_by_pid_type_ns函数在给出局部数字ID和命名空间的情况下,返回对应的task_struct实例。基于此函数,内核还创建了若干辅助函数:
1. find_task_by_pid_ns:默认ID类型为PID类型,给定局部ID和命名空间,返回对应的task_struct实例
2. find_task_by_vpid:默认ID类型为PID类型,默认命名空间为current进程的命名空间,给定局部ID,返回对应的task_struct实例
3. find_task_by_pid:默认ID类型为PID类型,默认命名空间为初始命名空间,给定全局ID,返回对应的task_struct实例
3. 生成唯一的PID
(1) 生成唯一的PID
[1] 内核需要为PID生成在命名空间中唯一的数值。为跟踪已经分配和仍然可用的PID,每个命名空间都使用一个大的位图,其中每个PID由一个比特标识。分配PID就是将位图第一个
值为0的比特位设置为1;反之,释放PID可通过将对应的比特位从1切换为0来实现。
(2) 创建新的pid实例
[2] 在复制一个新进程时,也需要创建新的pid实例。进程可能在多个命名空间中都是可见的,对每个这样的命名空间,都需要生成一个局部PID。这是在alloc_pid中处理的。起始于
建立进程的命名空间,一直到初始的全局命名空间,内核会为此间的每个命名空间分别创建一个局部PID。包含在struct pid中的所有upid都用重新生成的PID更新其数据。
每个upid实例都必须置于PID散列表中
2.3.4 进程关系
(1) 进程家族关系
[1] 进程A分支形成进程B,则进程B是进程A的子进程。task_struct的children是链表表头,该链表中保存进程中的所有子进程
[2] 进程A分支形成进程B1, B2...Bn,各个Bi进程之间的关系是兄弟关系。task_struct的sibling用于将兄弟进程连接起来,并连接在父进程的children表头上
2.4 进程管理相关的系统调用
2.4.1 进程复制
(1) 进程复制相关的系统调用
[1] fork是重量级调用,它创建了父进程的一个完整副本,然后作为子进程执行。Linux使用了写时复制技术降低了复制工作量
[2] vfork类似与fork,但是它不创建父进程的副本。相反,父子进程之间共享数据,vofrk主要用于子进程形成后立即执行execve系统调用加载新进程的场景,内核保证在子进程
退出或者开始新程序之前,父进程处于堵塞状态。引入了写时复制机制后,vfork已经不在具有速度优势,应避免使用它
[3] clone产生线程,可以对父子进程之间的共享、复制进行精确控制
1. 写时复制
(1) 写时复制技术背景
[1] 内核使用写时复制技术,以防止fork执行时将父进程的所有数据复制到子进程。该技术利用了下述事实:进程通常只使用了内存页的一小部分;在调用fork时,内核通常
对父进程的每个内存页,都为子进程创建一个相同的副本。这会有两种不好的负面效应:(1) 使用了太多内存 (2) 内存页复制耗费时间
[2] 如果子进程在进程复制之后立即调用exec系统调用加载新程序,那么负面效应会更严重。这实际意味着内核此前做的复制操作都是多余的,因此子进程地址空间会重新初
始化,复制的数据不再需要了
(2) 写时复制实现原理
[1] 使用写时复制(COW)技术,内核可以不复制父进程的地址空间,而只复制其页表。fork之后,父进程和子进程的地址空间指向同样的物理内存页
[2] 父子进程不允许修改彼此的页,这也是两个进程将页表对页表标记为只读访问的原因。加入两个进程只能读取内存页,那么两个进程的数据共享就不是问题
[3] 假如有一个进程试图向复制的页写入,处理器会向内核报告访问错误(也被称作缺页异常,虚拟地址到物理地址的切换、页表项权限检查都是处理器的MMU做的,所以
出现问题后,处理器会向内核报告缺页异常错误,内核会跳转到提前设置好的错误处理代码位置)。内核然后查看额外的内存管理数据结构,检查该页是否是可以
用读写模式访问,还是只能以只读模式访问。如果是后者,则必须向进程报告段错误(即试图修改只读内存页);如果页表项将一个页标记为“只读”,但通常情况下
该页应该是可写的,内核可根据此条件判断该页实际上是COW页。因此内核会创建该页专用于当前进程的副本,当然也可以用于写操作
[4] 我有一个疑问,内核会不会修改另一个进程的页表,从而将已经发生过写时复制的内存页标记为可写?如果是的话,内核是如何找到“另一个进程”的呢?或者在子进程
调用exec加载新程序后,是否会将父进程的页表的某些项恢复为可写状态?还是说同样延后处理,父进程修改内存页时发现另一个进程不在共享该页了,然后顺便
将页表项修改了。总是关于写时复制还有很多疑问,而对于这些疑问,我都能提出很多可行的解决方案,作为一名内核开发者,我必须能找到所有可能的解决方案,
并且有能够实现这些方案的能力,以及选出最佳方案的判断力
[5] COW机制时的内核可以尽可能延迟内存页的复制,更重要的是,很多情况下不需要复制。这节省了大量时间
2. 执行系统调用
(1) do_fork参数介绍
[1] 体系结构相关的sys_fork、sys_vfork、sys_clone都会调用体系结构无关的do_fork函数
[2] clone_flag是一个标志集合,用来指定控制复制过程的一些属性
[3] stack_start是用户状态下栈的起始地址,加入该参数的目的是为了创建线程,因此线程之间虽然共享地址空间,但是栈却不在同一个位置
[4] regs是一个指向寄存器集合的指针
[5] stack_size是用户状态下栈的大小
[6] parent_tidptr和child_tidptr是指向用户空间地址的两个指针,分别指向父子进程的PID
(2) sys_fork、sys_vfork、sys_clone函数介绍
[1] sys_fork使用的参数集合是(SIGCHLD, regs.esp, s, 0, NULL, NULL),只使用了一个SIGCHLD标志,这意味着在子进程终止后发送SIGCHILD信号到
父进程;父子进程使用相同的用户栈地址
[2] sys_vfork相较于sys_fork增加了几个标志,即CLONE_VFORK和CLONE_VM,具体的工作由do_fork处理
[3] sys_clone使用的复制标志不是静态的,而是可以通过各个寄存器参数传递到系统调用。另外,也不再复制父进程的栈,而是可以指定新的栈地址。另外还指定了用户
空间的两个指针,parent_tidptr和child_tidptr,用于与线程库通信
3. do_fork的实现
(1) do_fork函数的前半流程
[1] do_fork函数从copy_process开始,后者执行生成新进程的实际工作,并根据指定的标志选择重用还是复制父进程数据
[2] 由于fork需要返回新进程的PID,因此必须获得PID。注意返回的PID必须是父进程命名空间中的ID数值,因为返回的子进程PID是给父进程看的
[3] 如果要使用Ptrace监控新的进程,那么在创建新进程后会立即向其发送SIGSTOP信号,以便附接的调试其检查其数据
(2) do_fork函数唤醒新进程
[1] 子进程使用wake_up_new_task唤醒,换言之,就是将其task_struct添加到调度器队列。调度器也有机会对新启动的进程给予特别处理,这可以实现一种机制以便
新进程有较高的机率尽快开始运行
[2] 如果子进程在父进程之前运行,则可以大大减少复制内存页的工作量,尤其是子进程在fork之后立即执行exec调用的情况下。将进程排到调度器数据结构中并不意味着
该子进程可以立即执行,而是此时调度器可以选择它运行。但调度器还是会特别处理,使其有更高的机率尽快运行
(3) do_fork函数针对vfork的处理
[1] 如果使用vfork机制,必须启用子进程的完成机制。子进程task_struct的vfork_done成员用于该目的,借助于wait_for_completion函数,父进程在该变量上
进入睡眠状态,直至子进程退出。子进程退出或者使用execve加载新程序后,内核自动调用complete(vfork_done),这会唤醒所有因该变量睡眠的进程。其
时就是将睡眠进程的task_struct放入一个队列中,唤醒时就将这些task_struct放进调度器队列,和事件驱动机制很像
[2] 通过这种方法,内核可以确保使用vfork生成的子进程的父进程一直处于不活动状态,知道子进程退出或者加载新程序。父进程的临时睡眠状态,也确保了两个进程不会
彼此干扰或操作对方的地址空间。但这样说真的准确吗,除非用户进程在调用vfork后立即执行exec,否则子进程还是有可能修改共享地址空间的,而父进程对此
一无所知。所以使用vfork一定要小心,创建子进程后必须立即加载新程序,否则一不小心就会出现bug
4. 复制进程
(1) 复制标志自洽
[1] 复制进程受到标志的控制,但内核要保证标志组合没有矛盾,想要捕获这种组合并不困难,直接检查即可
[2] 如果标志组合自相矛盾,内核就会返回错误码。内核要求函数成功时返回指针,失败时返回错误码,但是C语言函数只能返回一个值,正常的话无法判断返回值是指针还是
错误码。因此内核保留了地址空间中0~4KB的部分,该区域中没有任何有意义的信息,引用该范围内指针的会报错,内核重用该地址范围来编码错误码。这样函数成
功时正常返回指针,函数失败时返回0~4KB范围内指针,调用者通过指针数值判断错误原因。ERR_PTR用于将错误数值编码为0~4KB范围内指针
(2) 创建父进程task_struct副本
[1] 内核建立了自洽的标志集合后,则用dup_task_struct创建父进程task_struct的副本,新进程的task_struct实例可以在任何空闲的内核内存位置分配
[2] dup_task_struct后,父子进程实例只有一个成员不同:新进程分配了一个新的内核栈,即task_struct->stack。内核栈与thread_info保存在一个联合中,
thread_info是线程所需的所有特定于处理器的底层信息
[3] 在大多数体系结构上,都使用一或两个内存页保存thread_union实例。在IA-32上,两个内存页是默认配置,因此可用的内核栈长度略小于8KB,其中一部分被thread_info
占据。配置选项4KSTACKS会将内核栈长度降低到4KB,即一个页面。如果系统中有很多进程在运行,这样做是有利的,每个进程都节省了一个页面;另一方面,对于经常
趋向于使用过多栈空间的外部驱动程序来说,这可能导致问题。某些外部的二进制驱动程序习于向可用的栈空间乱塞数据,这会导致内核栈溢出,从而发生未知的错误
[4] thread_info中存储了特定于体系结构的汇编代码需要访问的那部分进程数据:
1. task是指向进程task_struct实例的指针,内核栈、thread_info、task_struct是一一对应的关系
2. exec_domain用于实现执行区间,例如在AMD64的64bit模式下运行32bit程序
3. flags保存特定于进程的标志,我们对其中两个感兴趣:
(1) TIF_SIGPENDING代表进程有待决信号
(2) TIF_NEED_RESCHED代表进程应该或者想要调度器选择另一个进程代替自己运行
4. cpu说明进程正在其上执行的cpu编号
5. preempt_count用于实现内核抢占的一个计数器。进入临界区加1,退出临界区减1,值为0时代表可以进行内核抢占
6. addr_limit指定了进程可以使用的虚拟地址的上限,该限制适用于普通进程,不适用于内核线程
7. restart_block用于实现信号机制
[5] 在内核的某个特定组件使用了过多栈空间时,内核栈会溢出到thread_info部分,这可能会导致严重问题。因此内核提供可kstack_end函数,用于判断给出的地址是否位于栈
的有效部分之内
[6] dup_task_struct会复制父进程task_struct的thread_info内容,但stack则与新的thread_info位于同一内存区域,也就是新建的内核栈区域。dup_task_struct结束
后,父子进程的task_struct除了栈指针之外是完全相同的
[7] 所有体系结构都必须定义名为current和current_thread_info的宏或函数。current_thread_info用于获取当前进程的thread_info的地址,可以根据CPU栈指针确定,
因为thread_info总是位于内核栈顶。每个进程都有自己的内核栈,内核栈中有thread_info实例,因此进程、内核栈、thread_info的映射是唯一的。current指定
了当前进程task_struct实例的地址,可以使用current_thread_info来确定,即current_thread_info()->task
(3) 检查用户创建进程数是否超限
[1] 父子进程同属于一个用户,内核会检查当前用户的进程数是否超过允许的最大进程数目
[2] 用户创建的进程数保存在user_struct->processes中。我有一个模型能够更加直观地理解用户命名空间:
1. 进程属于用户,一个用户会有多个进程
2. 一个用户可能存在于多个用户命名空间中,其进程也会分布在这些用户命名空间中。用户命名空间彼此独立,没有层级关系。用户在每个所属的命名空间中都有一个
user_struct结构,用于统计当前用户命名空间的用户属性
3. 每个用户命名空间都有一个独立的user_struct用于统计当前命名空间中root用户的属性
4. 默认情况下,子进程和父进程属于同一个用户,并且属于同一个用户命名空间
5. 可以想像这样一个表格,横轴是各个用户,纵轴是各个用户命名空间,表格中是进程,root用户比较特殊,上面所说的属性在表中也有体现:
——————————————————————————————————————————————————————————————————————
| | 用户1 | 用户2 | 用户3 |
| 用户命名空间1 | 进程1 | | |
| 用户命名空间2 | | 进程2 | |
| 用户命名空间3 | 进程3, 进程4 | | 进程5 |
——————————————————————————————————————————————————————————————————————
[3] 如果用户在当前用户命名空间创建的进程数量超过限制,并且该用户不是当前用户命名空间的root用户,而且该用户也没有被分配特殊权限,满足上述条件则放弃创建进程。上面
所说的“当前用户命名空间”指的是父进程用户命名空间,用户创建进程数量限制保存在task_struct->signal->rlim[RLIMIT_NPROC].rlim_cur
(4) 初始化调度器相关字段
[1] 如果资源限制无法防止进程建立,则调用函数sched_fork,以便内核有机会对新进程进行设置
[2] 本质上是初始化一些调度器相关的统计字段,如果有必要还会在各个CPU之间对可用的进程均衡一下,同时将进程的状态设置为TASK_RUNNING,由于新进程还没有创建完成,并且
该进程也没有被加入到调度器队列,因此该状态是不真实的。但这样可以防止内核的其他部分试图将进程的状态从非运行改为运行,并在进程的设置彻底完成之前调度进程,
这个防止干扰的思路非常巧妙
(5) 根据标志复制或共享各个子系统资源
[1] 接下来会调用诸多形如clone_xyz的例程,以便复制或共享特定的内核子系统的资源
[2] 如果设置CLONE_XYZ置位,则两个进程会共享资源,对应资源的引用计数加一,如果父进程或子进程修改了资源,则两个进程都可以看到。如果CLONE_XYZ没有置位,则会为
子进程创建资源一个副本,新的副本的引用计数为1,如果父进程或子进程修改了资源,变化不会传播到另一个进程
[3] 下面是我们感兴趣的资源:
1. 如果CLONE_SYSVSEM置位,则copy_semundo使用父进程的System V信号量
2. 如果CLONE_FILES置位,则copy_files使用父进程的文件描述符;否则创建新的files实例,其中包含的信息与父进程相同,该信息的修改可以独立于原结构
3. 如果CLONE_SIGHAND或CLONE_THREAD置位,则copy使用父进程的信号处理程序
4. 如果CLONE_THREAD置位,则copy_signal与父进程共同使用信号处理中不特定于处理程序的部分
5. 如果COPY_MM置位,则copy_mm让父子进程使用同一地址空间,即两个进程使用同一个mm_struct实例;如果COPY_MM没有置位,也不必复制父进程的整个地址空间,
而是创建父进程页表的一个副本,使用写时复制机制,仅当其中一个进程写入页时,才会进行实际复制
6. copy_namespace用于创建子进程的命名空间,如果CLONE_NEWxyz置位,则为子进程创建一个新的命名空间,否则父进程共享命名空间
7. copy_thread是一个特定与体系结构的函数,它复制执行上下文中特定于体系结构的所有数据。其实就是填充task_struct->thread的各个成员。这是一个
thread_struct类型的结构,其定义是体系结构相关的。它包含了所有寄存器(和其他信息),内核在进程之间切换时需要保存和恢复进程的内容,该结构可
用于此
(6) 处理对父子进程不同的各个成员
[1] 新进程的线程组ID与分支进程(调用fork/clone)的进程相同
[2] 线程的父进程是分支进程的父进程,非线程的普通进程使用CLONE_PARENT触发同样的行为
[3] 普通进程的线程组长是其本身,对线程来说,其组长是当前进程的组长
[4] 将子进程的children链表连接到父进程的sibling链表上
[5] 如果新进程的tgid等于pid,即该进程是线程组的组长,则其要将新进程归入到ID数据结构体系中:
1. 如果置位了CLONE_NEWPID创建一个新的PID命名空间,那么必须要由新进程承担该命名空间init进程的角色
2. 新进程必须被加到当前进程组和会话,首先设置全局SID和PGID,然后建立task_struct和pid实例的双向连接
[6] 最后将PID本身加到ID数据结构的体系中,创建新进程的工作就此完成
5. 创建线程时的特别问题
(1) 在Linux内核中,线程和一般进程之间的差别不是那么刚性,本质上都是task_struct,在调度器眼中都是调度实体,最明显的差别就是线程与分支进程共享地址空间,而进程的地址空间是
独立于分支进程的。还有一些进程关系、信号、ID、共享策略方面的不同,但没有太大区别
(2) 用户线程库在实现多线程时,需要用到几个特殊的标志:
1. CLONE_PARENT_SETTID将生成线程的PID复制到clone调用指定的parent_tidptr地址,进程复制时会执行此操作,告知用户空间线程正在创建
2. CLONE_CHILD_SETTID将clone调用指定的用户空间指针child_tidptr保存在task_struct->set_child_tid中,进程第一次执行时,会将进程PID复制到该地址,告知用户
空间线程创建完毕,并开始执行
3. CLONE_CHILD_CLEARTID将clone调用指定的用户空间指针child_tidptr保存在task_struct->clear_child_tid中,进程终止时,向该地址写入0,告知用户空间线程已经
终止
(3) 线程终止时还会调用sys_futex函数,这是一个快速的用户空间互斥量,用户唤醒等待线程结束事件的进程
(4) 上述标志可用于从用户空间检测线程的产生和销毁。CLONE_PARENT_SETTID和CLONE_CHILD_SETTID用于检测线程的生成,CLONE_CHILD_CLEARTID用于检测线程的终止。在多处理器
系统上可以检测真正地并行执行
2.4.2 内核线程
(1) 内核线程的概念
[1] 内核线程就是内核本身启动的线程,其实就是将内核函数委托给独立的进程,并与系统中其他进程并行执行,内核线程常被称为守护进程
[2] 内核线程用于执行下列任务:
1. 周期性地将修改的页与页来源块设备同步(如mmap文件映射)
2. 如果内存页很少使用,则写入交换区
3. 管理延时动作(如工作队列)
4. 实现文件系统的任务日志
[3] 内核线程分为以下两类:
1. 线程启动后一直等待,知道内核请求线程执行某一特定操作
2. 线程启动后按周期性间隔运行,检测特定资源的使用,在用量超出或低于预制的限制值时采取行动。内核使用这类线程用于连续监测任务
(2) 创建内核线程
[1] 内核使用kernel_thread启动一个新的内核线程,该函数有三个参数,即fn, arg, flags,产生的内核线程将执行fn执行的函数,而用arg指定的参数将自动传递给该函数,flag指定了
CLONE标志。内核线程也是进程,Linux创建新的进程的唯一方法就是do_fork,因此要使用CLONE标志
[2] kernel_thread函数首先会构建一个pt_regs的实例,对其中的寄存器指定适当的值。接下来调用do_fork函数创建新进程
(3) 内核线程的惰性TLB处理
[1] 内核线程相较于普通进程,有两个特别之处:
1. 内核线程在内核态执行,而不是用户态
2. 内核线程只能访问内核空间,而不能访问用户空间
[2] 计算机的虚拟地址被分成两个部分,底部可以由用户程序访问,上部则专供内核使用。进程的虚拟地址的用户空间部分由task_struct->mm指定。每当内核执行上下文切换时,虚拟地址空间
的用户层部分都会切换,并清空TLB缓存,以便与当前运行的进程匹配
[3] 内核线程不与任何特定的用户层程序相关,内核并不需要倒虚拟地址空间的用户层部分,保留原设置即可。由于内核线程之前可能是任何进程在运行,因此用户层的内容本质上是随机的,内核
线程绝不能修改其内容。为强调用户层不能访问,将mm设置为空指针。但内核需要知道用户空间当前包含了什么,所以使用task_staruct->active_mm描述当前的用户层。也就是说,
mm指向的是进程的用户层,active_mm指向的是当前的用户层,对于普通进程来说,mm和active_mm是相同的
[4] 内核根据如上特性实现惰性TLB处理,加入内核线程之后运行的进程和之前是同一个,那么就不用更换页表,TLB中的内容依然有效。否则要更换页表,并且清除TLB缓存。TLB缓存就是用于
提升访存速度的,清除了缓存必定会导致新进程访存速度降低,因此使用惰性TLB机制毫无疑问可以提升系统性能,线程切换比进程切换更轻量也是这个原因
(4) 内核线程实现函数
[1] daemoniza用于将新创建的进程变为内核线程:
1. 释放父进程的所有资源(用户层、文件描述符等),因为内核线程会运行到系统关机为止,因此必须清理其多余资源。而且内核线程只访问内核空间,甚至都用不到这些资源
2. 阻塞信号的接受
3. 将init进程作为内核线程的父进程
[2] 内核提供了更便捷的函数,使用kthread_create创建内核线程,然后用wake_up_process唤醒它;使用kthread_run创建内核线程并立即唤醒它;kthread_create_cpu创建内核线程,
并将它绑定到特定的CPU
[3] 内核线程会出现在系统进程列表中,但在ps的输出中由方括号包围,以便与普通进程区分
2.4.3 启动新程序
(1) 通过新代码替换现存程序,即可启动新程序。Linux提供的execve系统调用可用于该目的
1. evecve的实现
(1) do_execve参数介绍
[1] 该系统调用的入口是体系结构相关的sys_execve函数,该函数很快将其工作委托给体系结构无关的do_execve函数。对于后者,参数传递了可执行文件的文件名、寄存器集合、
还传递了指向程序参数和环境的指针,这两个指针分别指向用户空间的两个指针数组
(2) do_execve的打开可执行文件和bprm_init流程
[1] 首先打开要执行的文件,内核找到相关的inode并生成一个文件描述符,用于寻址该文件
[2] bprm_init接下来处理若干管理性任务:mm_alloc生成一个新的mm_struct实例来管理进程地址空间,init_new_context是一个特定于体系结构的函数,用于初始化该实例,
而__bprm_mm_init则建立初始的栈
(3) do_execve的prepare_binprm流程
[1] 新进程的各个参数,包括euid, egid, 参数列表, 环境, 文件名等,合并成一个类型位linux_binprm的结构
[2] prepare_binprm用于提供一些父进程相关的值(特别是euid和egid),剩余的数据则直接复制到linux_binprm实例中,该函数该维护了SUID和SGID的处理
[3] uid和gid是进程所属的用户ID和用户组ID,在进程创建时就已经确定了。euid和egid时进程的有效用户ID和有效用户组ID,文件都是有权限标识的,分别对应创建文件的用户,
同一个用户组的用户,其他用户对该文件的读、写和执行的权限。而euid和egid就是进程用来权限检查的有效ID,默认情况下它们与uid和gid相同,但是某些特殊情况下
由可能不相同。比如一个只有root用户才能执行的文件,设置了SUID标志,那么属于其他用户的进程执行该文件是就会将自己的euid更新为该文件所属的用户ID,也就是
root用户ID,这样一个普通用户就能够执行root用户的文件了,由于文件的内容是固定的,因此不用担心安全问题。SGID也是同理,只不过是将进程egid更新为文件所属
的用户组ID
(4) do_execve的search_binary_handler流程
[1] Linux支持可执行文件的各种不同组织格式,标准格式是ELF
[2] 尽管ELF格式尽量设计得与体系结构无关,但也不意味着同一个ELF格式可执行文件可以在不同体系结构中运行,因为不同体系结构使用的二进制指令集不同,二进制格式只表示
如何在可执行文件和内存中组织程序的各个部分
[3] search_binary_handler查找一种适当的二进制格式,用于所要执行的特定文件。各种格式使用不同的特点来识别,通常是文件起始处的一个“魔数”
[4] 二进制格式处理程序负责将新程序的数据加载到旧的地址空间中:
1. 释放原进程使用的所有资源
2. text段包含程序的可执行代码,start_code和end_code指定该段在地址空间中驻留的区域
3. data段包含预先初始化的数据,start_data和end_data指定该段在地址空间中驻留的区域
4. 堆用于动态内存分配,start_brk和brk指定了其边界
5. 栈的位置由start_stack定义,栈自动向下增长
6. 程序的参数和环境也映射到虚拟地址空间,分别位于arg_start和arg_end之间,以及env_start和env_end之间
7. 设置进程指令指针和其他特定于体系结构的寄存器,以便在调度器选择该进程时开始执行其main函数
2. 解释二进制格式
(1) linux_binfmt结构
[1] Linux的所有二进制格式都通过linux_binfmt实例表示,不同二进制格式的linux_binfmt实例用链表连接起来
[2] 每个linux_binfmt结构都包含以下三个函数:
1. load_binary用于加载普通程序
2. load_shlib用于加载共享库
3. core_dump用于在程序错误的情况下输出内存转储。所谓内存转储就是在程序异常终止时,操作系统将进程的内存状态(包括代码、数据、堆栈、寄存器等信息)保存到
磁盘文件中。执行路径是程序错误 -> CPU异常中断 -> 内核异常处理函数 -> 判定错误类型 -> 发送信号 -> 进程接收信号 -> 执行默认动作 ->
打开core文件 -> 写入内存数据(关键的代码、堆、栈信息,根据栈可确定调用路径) -> 写入寄存器和状态信息 -> 关闭core文件 -> 进程终止 ->
释放进程资源,这就是内存转储的全过程。内存转储的核心目的是为调试提供“崩溃瞬间的快照”,生成的core文件可以通过gdb工具分析,定位错误原因。
从很多地方都能看出来,内核在很努力的兼容gdb,因此一定要学好gdb这个工具
[3] 使用register_binfmt注册新的二进制,其实就是将新的linux_binfmt结构连接到链表上
2.4.4 退出进程
(1) 退出进程机制
[1] 所有进程都通过exit系统调用退出, 这使得内核有机会将进程使用的资源释放回系统,本质上是将各个资源的引用计数减1,如果引用计数为0则将内返还给内存管理模块
2.5 调度器的实现
(1) 在我看来,调度器有三大任务:
[1] 当前任务要执行多长时间
[2] 如何选择下一个任务
[3] 执行上下文切换
2.5.1 概观
(1) 调度器概念
[1] 内核必须提供一种方法,在各个进程之间尽可能公平地共享CPU时间,而同时又要考虑不同的任务优先级
[2] 调度器的一般原理是,按所能分配的计算能力,向系统中的每个进程提供最大的公平性。或者从另一个角度来说,它试图确保进程没有被亏待
2.5.2 数据结构
1. task_struct 的成员
(1) task_struct的调度器相关成员
[1] 并非系统上的所有进程都同样重要,为确定进程的重要性,我们给进程增加了相对优先级属性
[2] static_prio表示进程的静态优先级,静态优先级是进程启动时分配的优先级,他可以用nice和sched_setscheduler系统调用修改,否则它将保持恒定
[3] norlmal_prio是根据静态优先级和调度策略计算出的优先级,即使普通进程和实时进程拥有相同的静态优先级,其普通优先级也是不同的。进程分支时,子进程会继承普通优先级
[4] prio是调度期真正考虑的优先级,因为进程有可能临时调整优先级,而这种调整不能影响普通和静态优先级,因此加入了prio成员
[5] rt_priority表示实时进程的优先级,最低的实时优先级是0,最高的是99,值越大优先级越高,与nice相反
[6] sched_class表示该进程所属的调度类
[7] entity是调度实体,调度器不仅可以调度进程。还可以用于实现组调度:可用的CPU时间可以首先在一般的进程组之间分配,接下来的时间在组内再次分配。这种一般性要求调度器不直接操作进程,
而是操作可调度实体,可调度实体由嵌入到结构的sched_entity实例表示
[8] policy保存了进程的调度策略,即使同属于一个调度器类,针对他们也有着不同的调度策略,Linux支持5个可能的值:
1. SCHED_NORMAL用于普通进程,他们通过完全公平调度器处理
2. SCHED_BATCH用于非交互、CPU密集型的批处理程序,也是由完全公平调度器处理。该类进程不会抢占CFS调度器上的进程,因此不会干扰交互式进程
3. SCHED_IDIE进程的相对权重总是最小的,重要性比较低。虽然名称是SCHED_IDIE,但SCHED_IDIE不负责调度空闲进程,关于空闲进程内核提供单独的机制实现
3. SCHED_RR和SCHED_FIFO用于实现软实时进程,他们由实时调度器类处理,SCHED_RR实现了一种循环方法,SCHED_FIFO实现了一种先进先出方法,rt_policy用于判断进程的调度策略是否是实时类
[9] cpus_allowed是一个位掩码,用于表示进程允许在哪些CPU上运行,就是CPU亲和性
[10] run_list和time_slice用于循环实时调度器,run_list是就绪队列的链表节点,time_slice进程可用的CPU剩余时间
2. 调度器类
(1) 调度器类的功能和结构
[1] 调度器类是通过调度器和各个调度方法之间的关联。调度器类由若干个函数指针组成,通过函数指针访问对应调度方法。这使得通用调度器可以兼容多种调度方法,而无需了解它们的原理
[2] 每个调度器类都对应一个sched_class的实例,所有的sched_class实例都被连接在一个链表上。他们是上下层级关系,即链表前面的调度器类优先级比后面的高。该链表在编译期就已经确定,不能在运行时更改
(2) 调度器类的成员
[1] void (*enqueue_task) (struct rq *rq, struct task_struct *p, int wakeup);
enqueue_task向就绪队列添加一个新进程,当进程由睡眠状态转为可运行状态时,即发生该操作
[2] void (*dequeue_task) (struct rq *rq, struct task_struct *p, int sleep);
dequeue_task将一个进程从就绪队列中去除,在进程从可运行状态转为不可运行状态时就会发生该操作;内核还有可能因为其他理由将进程从继续
[3] void (*yield_task) (struct rq *rq);
当进程自愿放弃对处理器的控制权时,会调用sched_yield
[4] void (*check_preempt_curr) (struct rq *rq, struct task_struct *p);

《深入Linux内核架构》学习笔记 -- 持续更新中的更多相关文章

  1. GOF 的23种JAVA常用设计模式 学习笔记 持续更新中。。。。

    前言: 设计模式,前人总结下留给后人更好的设计程序,为我们的程序代码提供一种思想与认知,如何去更好的写出优雅的代码,23种设计模式,是时候需要掌握它了. 1.工厂模式 大白话:比如你需要一辆汽车,你无 ...

  2. Pig基础学习【持续更新中】

    *本文参考了Pig官方文档以及已有的一些博客,并加上了自己的一些知识性的理解.目前正在持续更新中.* Pig作为一种处理大规模数据的高级查询语言,底层是转换成MapReduce实现的,可以作为MapR ...

  3. 数据分析之Pandas和Numpy学习笔记(持续更新)<1>

    pandas and numpy notebook        最近工作交接,整理电脑资料时看到了之前的基于Jupyter学习数据分析相关模块学习笔记.想着拿出来分享一下,可是Jupyter导出来h ...

  4. Linux内核入门到放弃-页面回收和页交换-《深入Linux内核架构》笔记

    概述 可换出页 只有少量几种页可以换出到交换区,对其他页来说,换出到块设备上与之对应的后备存储器即可,如下所述. 类别为 MAP_ANONYMOUS 的页,没有关联到文件,例如,这可能是进程的栈或是使 ...

  5. Linux内核入门到放弃-时间管理-《深入Linux内核架构》笔记

    低分辨率定时器的实现 定时器激活与进程统计 IA-32将timer_interrupt注册为中断处理程序,而AMD64使用的是timer_event_interrupt.这两个函数都通过调用所谓的全局 ...

  6. Linux内核入门到放弃-网络-《深入Linux内核架构》笔记

    网络命名空间 struct net { atomic_t count; /* To decided when the network * namespace should be freed. */ a ...

  7. Linux内核入门到放弃-无持久存储的文件系统-《深入Linux内核架构》笔记

    proc文件系统 proc文件系统是一种虚拟的文件系统,其信息不能从块设备读取.只有在读取文件内容时,才动态生成相应的信息. /proc的内容 内存管理 系统进程的特征数据 文件系统 设备驱动程序 系 ...

  8. [读书]10g/11g编程艺术深入体现结构学习笔记(持续更新...)

    持续更新...) 第8章 1.在过程性循环中提交更新容易产生ora-01555:snapshot too old错误.P257 (这种情况我觉得应该是在高并发的情况下才会产生) 假设的一个场景是系统一 ...

  9. [Hadoop] Hadoop学习历程 [持续更新中…]

    1. Hadoop FS Shell Hadoop之所以可以实现分布式计算,主要的原因之一是因为其背后的分布式文件系统(HDFS).所以,对于Hadoop的文件操作需要有一套全新的shell指令来完成 ...

  10. Linux内核入门到放弃-页缓存和块缓存-《深入Linux内核架构》笔记

    内核为块设备提供了两种通用的缓存方案. 页缓存(page cache) 块缓存(buffer cache) 页缓存的结构 在页缓存中搜索一页所花费的时间必须最小化,以确保缓存失效的代价尽可能低廉,因为 ...

随机推荐

  1. window Visual studio 2019 系统下Node.js安装以及环境变量配置

    https://www.jianshu.com/p/957f5631faa9 一.Node.js安装 1.首先在Node官网上下载对应的安装包,我这里下载的是64位window系统的安装文件node- ...

  2. nginx反向代理,负载均衡和yeauty集成的websocket的使用

    被要求一个这样的需求:要求项目和websocket使用一个端口.经过一周激烈争论,领导终于同意可以可以开通一个端口,一个月了,端口还没有开. 正式环境已经通过此方法进行部署,没有问题. 前言 因涉及到 ...

  3. C# Avalonia动态加载xaml和cs实例

    扩展请参考 https://www.cnblogs.com/dalgleish/p/18972924 NonCompiledXaml.axaml代码 <Window xmlns="ht ...

  4. mysql事务隔离级别/脏读/不可重复读/幻读详解

    一.四种事务隔离级别 1.1 read uncommitted 读未提交 即:事务A可以读取到事务B已修改但未提交的数据. 除非是文章阅读量,每次+1这种无关痛痒的场景,一般业务系统没有人会使用该事务 ...

  5. win10专业版电脑出现假死的问题

    有不少深度系统的小伙伴,使用win10专业版时,不知道为何电脑经常假死,特别是在使用桌面时更是频繁发生,让人十分苦恼,要如何解决假死的问题呢?本文中,深度技术小编就来为大家分享详细的处理方法,有兴趣的 ...

  6. 使用 WXT 开发浏览器插件(上手使用篇)

    WXT (https://wxt.dev/), Next-gen Web Extension Framework. 号称下一代浏览器开发框架. 可一套代码 (code base) 开发支持多个浏览器的 ...

  7. 实操使用 go pprof 对生产环境进行性能分析(问题定位及代码优化)

    简介 最近服务器有个小功能 go 进程 内存占用突然变得很高,正好使用 go pprof 实操进行性能分析排查解决 这是个极小的服务,但是占用内存超过了 100MB,而且本身服务器内存就比较吃紧,因此 ...

  8. 锚框 anchor box

    博客地址:https://www.cnblogs.com/zylyehuo/ 代码总览 代码解释 从头到尾用"快递站管理系统"的比喻方式,完整解释每一行代码的功能和意义. 1. 导 ...

  9. Mysql的索引数量是否越多越好?为什么?

    什么是索引? 索引是存储引擎用于提高数据库表的访问速度的一种数据结构.它可以比作一本字典的目录,可以帮你快速找到对应的记录. 索引一般存储在磁盘的文件中,它是占用物理空间的. 索引的优缺点? 优点: ...

  10. 修改Element Slider 滑块 label选中样式 (自定义 Elementui Slider 滑块 样式)

    效果图: 1,加入slider滑块代码 <el-slider v-model="level" vertical :show-stops="true" :s ...