深入理解Linux内核 读书笔记
一、概论
操作系统基本概念
- 多用户系统
- 允许多个用户登录系统,不同用户之间的有私有的空间
- 用户和组
- 每个用于属于一个组,组的权限和其他人的权限,和拥有者的权限不一样。对应的是Linux的文件权限系统
- 进程
- 和程序的区别。几个进程能并发执行同一个程序,一个进程能顺序执行几个程序
- 程序更像是代码片段,进程是执行代码的容器
- linux是抢占式操作系统,也就是一个进程只能占用CPU一段时间。非抢占式系统中,进程如果不释放CPU,可以一直占用
- 内核体系结构
- Linux是单块内核,同时提供模块(module)功能
- 模块是指:例如一个程序,引用了一个系统模块,这个系统模块不会是这个进程单独拥有,当其他程序也需要这个模块时,内核会把这个模块链接到其他程序。这样可以节省内存,也就是这个模块只会在内存中存在一份。模块就是一组函数,或者一段代码。
文件系统
- 文件
- 文件是以字节序列组成的信息载体(container)
- 文件目录是树结构
- 每个进程都有一个工作目录,通过pwdx 进程ID 命令可以查看
- 硬链接和软连接
- 链接类似window的快捷方式,创建一个文件,指向另一个文件
- ln p1 p2 就是创建一个文件p2,指向p1
- 硬链接只能指向文件,不能指向目录,因为会导致循环指向
- 硬链接只能指向同一个文件系统的文件(文件系统是物理划分,例如不同硬盘)
- 软链接没有硬链接这些限制,创建方法是加-s参数
- 文件类型
- 普通文件
- 目录
- 符号链接
- 面向块的设备文件
- 面向字符的设备文件
- 管道和命名管道(pipe named pipe)
- 套接字(socket)
- 文件描述符与索引节点
- 每个文件都有一个索引节点(inode)的数据结构,用来存储文件的描述信息,和文件的内容是区分开的。
- inode有(通过ll命令看到的):
- 文件类型
- 硬链接个数
- 文件长度
- 文件拥有者的uid
- 用户组的id
- 修改时间等
- 访问权限
- inode有(通过ll命令看到的):
- 每个文件都有一个索引节点(inode)的数据结构,用来存储文件的描述信息,和文件的内容是区分开的。
- 访问权限和文件模式
- 拥有者,组,其他人,各有读写执行3种权限
- 文件操作
- 打开文件
- 读
- 写
- 移动光标
- 关闭
Unix内核概述
- 进程/内核模式
- 进程有用户态和内核态
- 用户态不能访问内核的数据结构和内核程序
- 两种态会经常切换,例如在时刻A,进程在用户态,在时刻B,进程在内核态
- 从用户态切换到内核态的情况:
- 调用系统调用
- 执行进程的CPU发送异常
- 外围设备向CPU发出中断
- 内核线程被执行
- 进程
- 每个进程有一个进程ID,pid
- 内核切换执行的进程时,会保存旧进程的信息,包括:
- 程序计数器和栈指针寄存器
- 通用寄存器
- 浮点寄存器
- CPU状态
- 内存管理寄存器
- 可重入内核
- unix内核都是可重入的
- 可重入是指,可以被重复进入,也就是可以同时有多个进程处于内核态
- 进程地址空间
- 每个进程有自己私有的地址空间
- 同步和临界区
- 类似锁
- linux是抢占式内核,所以需要同步
- 信号量
- 每个资源都有一个信号量,类似int类型,初始值是1
- 每个进程访问资源,调用down方法,信号量减1,如果减1后,信号量小于0,进程被加入到访问队列中。如果大于等于0,进程可以访问资源
- 每个进程访问完资源,调用up方法,信号量加1,如果信号量大于等于0,激活访问队列的第一个进程
- 进程锁,线程锁的机制,应该都是这样的
- 这里要保证down和up的操作都是原子性的,不能并发
- 要防止死锁
- 锁里面的区域就是临界区,也就是acquire和release之间的代码
- 信号和进程间通信
- 信号和信号量是不一样的
- linux有20多种不同的信号,例如kill -9 中的 9就是一种信号
- 进程收到信号后,可以
- 忽略
- 异步执行指定程序(新开一个线程?),这种需要事先定义信号处理函数。
- 内核收到信号后,可以
- 终止进程(例如kill - 9)
- 忽略信号
- 挂起进程
- 恢复进程
- 进程间通信(IPC)
- 信号
- 消息(msgget(),msgsnd())两个系统调用,发信息和收信息,Python里面的进程间Queue应该就是用这个实现的
- 共享内存(shmget shmdt)两个系统调用
- 进程管理
- fork来启动一个子进程,一般在启动的时候复制父进程的数据和代码,但是这样效率较低,所以会使用写时复制,也就是一开始父子进程共享内存,当其中一个进程需要修改数据时,才执行复制操作
- exec用于启动子进程
- exit用于结束子进程
- wait4用于父进程等待子进程结束
- 内存管理
- 虚拟内存,在物理内存(MMU)和程序之间的抽象,相当于访问内存的代理。
- 内核内存分配器,KMA,用于管理内存
- 高速缓存 由于内存比硬盘快很多,所以从硬盘读取得数据会缓存在内存,使下次可以快速访问
二、内存寻址
- 内存地址
- 内存地址有3种
- 逻辑地址,由一个段(segment)和偏移量(offset)组成,用来指明一个操作数,或者一条指令的地址
- 线性地址。是一个32位无符号整数(在32位系统中是这样),从0x00000000到0xffffffff。内存相当于一个超大的列表,下标(地址)是一个32位整数,值就是内存的内容,值得大小是1字节
- 物理地址。内存芯片级的地址
- 逻辑地址,经过分段单元,转换为线性地址,线性地址,经过分页单元,转换为物理地址
- 内存地址有3种
- 分段单元(用于把逻辑地址,转换为线性地址)
- 概念
- 段选择符,也叫段标识符,也就是上面说的段,程序传入给分段单元。有字段:
- index,表示段描述符在GDT或者LDT中下标
- TI,表示段描述符在GDT中还是LDT中
- RPL,特权级
- 段描述符,8字节,存放在GDT或者LDT中,有字段
- Base表示段在内存中首字节的线性地址
- S,0表示系统段,1表示普通段
- DPL,特权级,0表示只有内核态才能访问,3表示内核态和用户态都能访问。(cs寄存器中,有一个两位的字段,指明CPU的当前特权级,0表示内核级,3表示用户级。所以通过这个机制,可以限制用户态的进程不能访问内核态的内存数据)
- D或者B,表示这是代码段,还是数据段
- GDT,是全局段列表,item是段描述符
- LDT,是局部段列表,item是段描述符
- 段选择符,也叫段标识符,也就是上面说的段,程序传入给分段单元。有字段:
- 转换流程
- 传入逻辑地址给分段单元,逻辑地址包含段选择符和偏移量
- 查看段选择符的TI字段,决定是从GDT中还是LDT中获取段描述符,假如是GDT
- 查看段选择符的index字段,假如是2,从gdtr寄存器中获取GDT列表的首字节地址,假如是0x00002000,计算段描述符的位置=0x00002000+2*8,=0x00002016 (每个段描述符8字节),所以段描述符在内存的0x00002016-0x00002024位置
- 查看段描述符的Base字段,假如是0x00003000,加上偏移量,假如是100,得到线性地址是0x00003100
- 概念
三、进程
进程,轻量级进程(LWP)和线程
- 进程是程序执行时的一个实例
- 线程 是进程里面的一个执行流,线程的切换时在用户态进行的。但是这样就不能做到并发了
- 轻量级进程,类似线程,但是切换时在内核态进行
所以Linux的做法是(TODO 这一块还不是很明白)
- 把线程和轻量级进程关联起来,所以线程和轻量级进程是等价的
- 对内核来说,进程和LWP是一样的,使用同样的调度方法
- LWP之间可以共享部分数据
进程描述符
进程描述符是一个数据结构(c的struct,类似Python的字典)
- 进程描述符有字段:
- state 状态
- 可运行状态(TASK_RUNNING),要么在运行,要么准备运行
- 可中断的等待状态(TASK_INTERRUPTIBLE)进程被挂起(睡眠),表示它在等待一个事件的发生,例如等待某个系统资源。当这个系统资源可用,内核会产生一个硬件中断,来唤醒进程
- 不可中断的等待状态(TASK_UNINTERRUPTIBLE),和可中断的等待状态类似,这个状态较少用到
- 暂停状态(TASK_TOPPED)进程被暂停执行,当进程收到信号SIGSTOP,SIGSTP,SIGTTIN SIGTTOU信号后,会进入暂停状态
- 跟踪状态(TASK_TRACED)当进程被另一个进程跟踪,例如执行ptrace命令,
- 僵死状态(EXIT_ZOMBIE)进程的执行被终止,但是父进程还没有发布wait4或者waitpid命令来获取进程信息。这时内核不会自动丢弃进程的信息,因为父进程可能还需要这些信息
10.僵死撤销状态
- thread_info 进程的基本信息
- fs_struct 当前目录
- signal_struct 收到的信号
- pid 进程的ID。顺序递增,最大是32767,超过后,从1开始获取闲置的PID值。进程里面的线程,也拥有自己的pid,同时每个线程有一个tgid(thread group id),表示线程组ID,这个ID等于进程中第一个线程的pid。
- 一个进程里面至少有一个线程
- state 状态
进程链表
- 一个进程描述符表示一个进程
- Linux把所有进程放在一个双向链表里面,每个item是一个进程描述符
- TASK_RUNNING状态的进程链表
- 由于CPU在进行进程切换时,需要快速知道下一个执行的进程是什么,所以Linux把所有可以执行的进程都放在一个单独的链表。
- 由于不同进程有不同的优先级,所以linux的做法是
- 由于有140种优先级(优先级用prio表示,0-139),所以用140个链表来保存
- 用一个140长度的位图(bitmap)来表示140个链接中,哪些有数据
- 所以获取下一个优先级最高的进程的做法是:
- 查看位图,看第一个=1的位的下标是多少,例如是15
- 访问第15个链表,queue[15],获取第一个元素
进程间的关系
进程描述符里面有特定的字段,记录每个进程的父进程,兄弟进程和子进程
- real_parent 父进程的描述符指针,如果父进程不存在,指向进程1
- parent 当前父进程,通常和real_parent一致,指引当进程被追踪时不一致
- children 链表,记录所有子进程
- sibling 有prev和next两个元素,表示上一个兄弟进程,和下一个兄弟进程
pidhash
有时候内核需要根据pid来获取进程描述符
所以内核会保存一个pidhash数据结构,是个hash表(c里面的hash表的实现和redis的hash表实现类似),key是pid,value是进程描述符
进程切换
进程切换,任务切换,上下文切换是一样的
每个进程都有自己的地址空间(在内存),但是进程之间是共享寄存器的,所以进程的切换需要(硬件上下文是寄存器的数据):
- 保存prev进程的硬件上下文
- 用next硬件上下文替换prev
上面的操作使用一个switch_to宏来实现,传入参数prev,next,prev。传入两次prev是怕切换上下文后,把第一个prev丢了。
创建进程
Linux进程的特性:
- 写时复制
- 轻量级进程允许父子进程共享很多数据结构
创建进程的系统调用:
-
close()
- fn 子进程创建后执行的函数,函数结束,子进程终止
- arg 传给函数的数据
- 其他还有很多参数
- fork close函数的封装
vfork close函数的封装
内核进程
内核进程是一直运行在内核态的
进程0
进程0是linux启动后的第一个进程,由它创建进程1
进程1
进程1也叫init进程,进程1会一直运行知道linux关闭
撤销进程
进程执行完指定的代码后,就会终止,这时必须通知内核回收进程的资源。
一般是exit系统调用,c编译程序会自己动把exit函数插入到main函数最后
内核可以强迫整个线程组死掉(例如收到kill -9)
进程删除
当进程终止后,进程会进入僵死状态,直到父进程调用wait4来获取进程的状态数据,然后进程就会被删除。
如果父进程已经不存在,进程会交给init进程托管,init进程会定期执行wait4命令来查看进程的状态,如果进程已经终止,就会删除这个进程