系列综述:
💞目的:本系列是个人整理为了秋招面试
的,整理期间苛求每个知识点,平衡理解简易度与深入程度。
🥰来源:材料主要源于《操作系统 真象还原》及各大佬博客
进行的,每个知识点的修正和深入主要参考各平台大佬的文章,其中也可能含有少量的个人实验自证。
🤭结语:如果有帮到你的地方,就点个赞和关注一下呗,谢谢🎈🎄🌷!!!
🌈【C++】秋招&实习面经汇总篇
🌈 操作系统开发过程专栏
😊点此到文末惊喜↩︎
概述
0.项目描述
- 背景:做一个更难的事情,会对整体的软件工程能力有一个比较大的提升,而且操作系统也是软件运行的容器,可以更深入的理解软件的运行原理。
- 技术上:主要通过c语言、汇编语言和bochs虚拟机实现的。
- bochs虚拟机在linux环境下模拟x86架构的裸机,能进行
从加电到启动运行
的内存和寄存器的监测,这个部分您需要的话,我可以展开讲一下。 - 汇编语言主要是在外设的初始化,和相关驱动的一个编写。像可编程的中断控制芯片8259a、键盘接口芯片8042等
- c语言主要是调用基本汇编函数接口实现更完善的功能和加快开发速度,像汇编文件中定义_printChar函数(向端口0xb800写入单个字符),然后c中的printf就使用可变参数列表vlist和循环拆解字符调用_printChar,实现字符串的屏幕输出
- 结构上,主要包含
- 中断管理
- 进程管理
- 内存管理
- 系统调用
- 文件管理
- shell程序
- 详细解析:【内核基础】按调用流程分析linux内核
内核构建
1.加电启动
- 计算机从加电启动到运行
重置CPU内部状态
在计算机POST加电的一瞬间, 计算机主板和其他设备供电,BIOS的控制芯片组会向CPU发出并保持一个RESET(重置)信号,重置CPU内部状态,直到芯片组检测到电源已经开始稳定供电后,便撤去RESET信号cs : ip 寄存器初始化为BIOS 的入口地址0xFFFF0
CPU 的cs : ip 寄存器被强制初始化为0xF00 : 0xFFF0 。由于开机的时候处于实模式,段基址要左移4 位,于是0xF00 : 0xFFF0 转换地址为BIOS 的入口地址0xFFFF0 (Shadow技术:由于ROM的执行速度远比RAM要低,所以加电后在ROM中的BIOS会被装载到Shadow RAM中的指定区域里。由于Shadow RAM的物理编址与对应的ROM相同,所以当需要访问BIOS时,只需访问Shadow RAM而不必再访问ROM,这就能大大加快计算机系统的运算时间)检查和初始化外设,并在内存中建立中断向量表
CPU到地址0xFFFF0处取出跳转指令jmp far f000:e05b,即再跳向0xfe05b处执行BIOS代码,检测外设并进行初始化,完成后在内存中0x000 ~0x3FF 处建立中断向量表并填写中断例程校验并加载MBR到0x7c00处,然后开始执行MBR
BIOS最后会校验启动盘的0 盘0 道1 扇区(CHS 方式中扇区的编号是从1 开始的,LBA从0开始的),如果此扇区末尾的两个字节分别是魔数0x55 和0xaa, BIOS 便认为此扇区中确实存在可执行的主引导记录MBR ,便加载到物理地址0x7c00 ,随后跳转到此地址继续执行,此时,CPU控制权由BIOS交给MBR。这里有个小细节, BIOS 跳转到0x7c00 是用jmp 0: 0x7c00实现的,这是jmp 指令的直接绝对远转移用法,段寄存器cs 会被替换,这里的段基址 由之前的0xf00 变成了0
2.加载内核
- MBR的作用
加载loader
:封装一个将硬盘n个扇区加载到内存
的功能模块,在MBR中调用将 loader 加载到内存地址0x900 处。
- loader的操作
- 完成保护模式
- 打开A20:打开后cpu可以进行32位寻址
- 加载gdt:构建并初始化全局描述符表GDT,初始化GDTR
- 打开保护模式:将控制寄存器cr0的PE位置1
- 完成分页机制
- 在内存中初始化页目录表及页表。
- 将页目录表基址写入控制寄存器cr3(页目录基址寄存器)
- 寄存器cr0的最高位PG置1(打开分页)
- 加载和初始化内核:将整个ELF内核文件动态链接并加载到内存后,再将不同的段复制到对应的内存空间。
上述过程详述
- 段选择子selector
- 来源:在实模式下时,段寄存器中存储的是段基地址,即内存段的起始物理地址。在保护模式下时,段寄存器(代码段CS、数据端DS、堆栈段SS等)中存放16位的段选择子
- 组成(数据(目录索引)+ 控制部分)
13位的段索引
:确定GDT或LDT中的一个具体的段描述符2位的请求特权级(RPL)
:表示当前进程的访问特权级CPL。RPL值越小,表示访问该段所需的特权级越高。1位的全局/局部描述符表标识
:0表示所选段描述符位于局部描述符表中,1表示位于全局描述符表中
- 全局描述符表GDT和段描述符LDT表
- GDTR:存储GDT在内存中的入口地址和大小的寄存器,由
lgdt
指令加载 - LDTR:存储段选择子selector,包括段描述符索引和属性,由
lldt
指令加载 - LDTR模式:在该模式下,LDTR索引GDT中LDT,再由LDT索引到具体段描述符,这种二级索引开销较大,所以优化后将LDT直接嵌入到GDT表中形成直接索引(将LDT描述进程的代码段、数据段和堆栈段描述符,直接存放在GDT中。GDT中由每个任务的代码段描述符、数据段描述符、堆栈段描述符和TSS任务段描述符为一组,组成的进程描述符数组。)所有段描述符中的段基址都是0,由线性地址=偏移地址,是一种绕过分段机制的平坦模型。
- GDT是全局描述符表,由所有进程共享,而每个进程有各自的LDT,形成进程虚拟地址空间的隔离
- 加载GDT
lgdt [gdt ptr]
- lgdt指令:专用于将GDT起始地址加载到cpu的GDTR寄存器上
- GDTR寄存器:存储全局描述符表在内存中的地址和大小
- 基本知识:在
保护模式
下,由全局描述符表GDT
存储各个段的段描述符(8个字节)
。
- 保护模式:x86架构处理器的一种工作模式,利用软硬件机制将物理内存空间划分为多个虚拟地址空间,每个进程只能访问自己的虚拟地址空间,从而实现进程隔离。
- 段:一个段标识一块连续的内存区域,用于存储特定类型的数据,如代码段、数据段等。
- 段描述符:每个段描述符定义一个段的属性,如起始地址、大小、特权级别等。
- 页:段是以内存页为基本单位的
- 作用
- 内存访问隔离:通过在描述符中设置访问权限和段属性等信息,GDT
确保不同进程或线程只能访问其权限内的内存区域
,防止程序误操作或恶意攻击导致内存损坏或数据泄露。 - 实现特权级隔离:GDT中的描述符可以设置不同特权级别,允许操作系统内核运行在最高特权级别,用户进程运行在较低特权级别,从而保证操作系统内核能够对系统资源进行有效和安全的管理。
- 实现多任务处理:操作系统中同时运行多个进程,GDT可以为每个进程定义独立的描述符,从而允许它们在不同的虚拟地址空间中运行,而在物理上互相隔离,确保系统的稳定性和安全性。
- 段描述符的解释超链接
- GDT和LDT的联系
- 打开A20
- 打开保护模式
- 保护模式概述
- 支持
动态链接技术
,可以减少应用程序和系统的启动时间,提高系统的响应速度。 - 支持
分页机制
,将虚拟地址映射到物理地址,并能按页(4KB)的单位来将内存换入换出 - 支持
32位地址访问
,保护模式下使用选择子索引的段描述符中的段基址再加上段内偏移地址即为要访问的内存地址 - 支持
内存保护
,通过段描述符的属性进行内存访问的权限限制 - 支持
内存虚拟化
,使得每个进程可以有自己的虚拟地址空间,并且能够将硬盘的部分空间虚拟成内存进行使用。
- 二级页表机制
- 容量:页目录表中有1024个页表索引,每个页表有1024页索引,即总容量为
1024*1024*4KB = 4GB
- 作用:将虚拟地址转换成物理地址的映射表
- 寻址过程(10+10+12):从
CR3 寄存器
中获取页目录表物理地址
,然后使用32位的虚拟地址中,高10 位(表示页目录表索引的页目录项PDE的偏移个数)乘以4(一个页目录表项是4个字节)的积作为在页目录表中的偏移量去寻址目录项 pde
,从 pde 中读出页表物理地址
,然后再用虚拟地址的中间 10 位乘以4的积作为在该页表中的偏移量去寻址页表项
,从该 pte 中读出页框物理地址
,用虚拟地址的低 12 位作为该物理页框的偏移量 - 在保护模式下,打开PG后:线性地址到物理地址的转换,是由CPU中的MMU根据页表、页目录表等设定由硬件自动实现。
- IA-32体系下,将4GB的线性地址空间等分成64份,即每个进程拥有64MB的逻辑地址空间
- 访问页表内的任何数据都要使用物理地址,页目录表项和页表项都是4字节,所以真正的表内物理地址需要*4
- 进程地址空间构成:在32位的保护模式下,所有用户进程都有4G的虚拟地址空间,低3G是进程各自的虚拟地址空间,高1G是所有进程共享的内核空间。
- 快表机制:TLB存储了最近使用过的部分页表项,可以加速地址转换的速度。当处理器访问一个虚拟地址时,它首先检查TLB中是否有对应的页表项,如果有,处理器就可以直接从TLB中读取对应的物理地址。如果没有,处理器就需要访问内存中的页表来进行地址转换,同时将新的页表项存储到TLB中,以便下次使用。
- 计算机地址空间的组成
- 内存地址空间:用来表示计算机中的内存区域,可以通过内存地址进行访问和操作。
- IO端口地址空间:用来表示计算机中输入/输出端口的区域,可以通过IO端口地址进行访问和操作。
- 寄存器地址空间:用来表示计算机中各种寄存器的区域,包括CPU内部寄存器、外部设备寄存器等。
完善内核
1.打印子功能
- 构建打印字符
put_char
子功能
pushad
:保存寄存器环境(压入所有双字长的寄存器值)- 从IO设备端口获得光标位置
- 在栈中获取待打印的字符
- 通过条件判断是否为
回车符CR
或换行符LF
或退格符
及其他字符
,并分别编写对应的子模块(类似函数内的匿名函数) popad
:恢复寄存器环境
- 构建打印字符串
put_str
子功能
- 根据函数调用约定,从栈中获取待打印的字符串地址
- 循环拆解字符串,调用put_char子功能进行打印
- 构建打印数字
put_int
子功能
- 数字的打印类似字符串,但是需要将数字转换成asscii,然后调用put_char子功能进行打印
- 在汇编文件中将函数入口点
_start:
声明为global属性,然后在c语言文件中声明相同函数名void _start();
,最后将两个文件都编译成.o
文件,再通过ld
进行链接即可。
- 其中函数的传递符合x86手册标准,基本类型的形参默认使用寄存器存储,而超过4字节的通常压入函数栈中。
上述过程详述
- 函数调用约定
// c文件中
int subtract(int a, int b); //被调用者
int sub = subtract(3,2); //主调用者
// 汇编文件中
;主调用者:
; 从右到左将参数入栈
push 2 ;压入参数b
push 3 ;压入参数a
call subtract ;调用函数subtract
add esp, 8 ;回收栈空间
;被调用者:
push ebp ;压ebp备份
mov ebp,esp ;将esp赋值给ebp
mov eax,[ebp+0x8] ;偏移8字节处为第一个参数a
add eax,[ebp+0xc] ;偏移12字节处为第二个参数b
mov esp,ebp ;恢复主调函数栈顶
pop ebp ;恢复主调函数栈底
ret
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 向显存写入字符
- 从显存端口获取光标的位置放入寄存器bx中
- gs表示显存首地址,bx表示偏移量。一个字符由两个字节表示
[字符属性,ascii值]
,默认情况下屏幕上的内容是从显存的首地址(物理地址)0xb8000
起一直到以该地址向上偏移3999 字节的地方。
mov [gs:bx], cl ; gs是视频段基址,bx为光标偏移位置
; cl存储要打印的字符
inc bx; 显存地址+1为字符显示属性
mov byte [gs:bx],0x07; 0x07表示字符属性:黑底白字
2.中断的实现
- 用内联汇编封装
端口I/O函数
,并通过static inline避免函数调用的堆栈开销 - 初始化中断描述符表(由中断描述符构成的数组)
- 注册中断处理函数
- 用C语言建立中断处理函数数组idt_table,数据元素是C编写的中断处理函数的入口地址
- 将汇编中的中断描述符表指向对应的中断处理函数数组元素
- 初始化可编程中断控制器8259A ,使得可以正常处理外中断
- 通过内联汇编指令
lidt
将中断描述符表IDT的地址和大小加载到IDTR寄存器,以便CPU的寻址和访问
上述过程详述
- 中断类型
- 外中断(请求):外部设备的中断由中断代理芯片8259A接收,处理后将该中断的中断向量号发送到 CPU,cpu根据中断向量号执行对应中断处理程序
可屏蔽中断
:来源于INTR 引脚
,收到的中断不影响系统运行,可以通过eflags的IF位将外设中断屏蔽不可屏蔽中断
:来源于NMI 引脚
,收到的中断会引发重大系统运行错误,如电压不稳定、核心外设崩溃等- 两个引脚都是CPU的
- 内中断(异常):CPU 执行到程序中的中断指令,然后调用中断向量号对应的中断处理程序。如gdb调试的
int 0x03
- 中断控制器8259A能够处理多个外部设备的中断请求。其中断处理流程如下:
- 配置8259A:设置相关的寄存器,包括
ICW1~ICW4、OCW1和OCW2
。ICW1~ICW4是初始化命令字,用于告诉8259A如何初始化;OCW1是操作命令字1,用于指定中断控制方式;OCW2是操作命令字2,用于指定中断控制器的工作方式。 - 连接外部设备:将外部设备的中断引脚IRQ连接至8259A的中断请求输入端INT。
- 中断请求:当外部设备需要请求中断时,会将相应的中断请求引脚IRQ拉低。此时,8259A会检测到中断请求并将其转发给CPU。
- 中断响应:CPU收到中断请求后,会发送中断向量号到8259A。8259A会根据中断控制方式,将中断请求的优先级和中断向量号发送给CPU。
- 中断处理:CPU根据中断入口地址
= IDTR的中断描述符表基址 + 中断向量号*8(每个中断描述符为8字节)
找到相应的中断处理程序,并开始执行中断处理程序。中断处理程序会保存当前的程序状态,执行相应的中断服务程序,处理完后恢复程序状态并返回。 - 中断结束:中断处理程序执行完毕后,会发送中断结束信号给8259A。8259A会根据中断控制方式,通知CPU中断已结束。
- 中断清除:中断处理程序执行期间,可能会有其他设备发出中断请求。为防止错过其他中断,必须在中断处理程序执行后,清除8259A中的中断状态。
- 处理下一个中断:如果此时有其他设备发出中断请求,则重复以上步骤,直到所有中断请求都被处理完毕。
- 系统调用
- 本质:内中断中
陷阱门
的执行 - 调用方式
- 将系统调用指令封装成C库函数,通过库函数调用
- 直接通过汇编指令
int
与操作系统通信
- 中断表的类型
- 中断描述符表IDT:
保护模式
下用于存储中断处理程序入口地址的表,由中断描述符表寄存器IDTR
进行索引。 - 中断向量表IVT:
实模式
下用于存储中断处理程序入口的表
- 中断描述符表包含的门描述符类型
- 中断门描述符:用于处理
外部中断
,进入中断后IF会置0,避免中断嵌套。当一个中断门被触发,CPU会进入内核态,执行相应的中断处理程序,处理完成后返回到中断门的下一条指令。 - 陷阱门描述符:用于处理
内部中断
,进入中断不会改变IF位。常用于断点调试。int80系统调用就是通过陷阱门实现的
- 任务门描述符:用于在
多任务的切换
,配合TSS任务状态段
使用。TR寄存器索引当前进程的tss,TSS描述符存储在GDT中 - 调用门描述符:用于实现跨代码段的函数或过程调用。
- 进程发起软中断需要进行特权级检查
- 下限检查:进程当前特权级大于门描述符特权级。进程当前特权级CPL≤门描述符特权级DPL在数值上,
进程当前特权级
C
P
L
≤
门描述符特权级
D
P
L
进程当前特权级CPL \leq 门描述符特权级DPL
进程当前特权级CPL≤门描述符特权级DPL(数值越小权限越大,0为内核态,3为用户态)
- 上限检查:进程当前特权级大于等于段描述符特权级。在数值上,
C
P
L
<
目标段
D
P
L
CPL < 目标段DPL
CPL<目标段DPL,即代码段执行的权限只能由高特权级向低特权级转移,除了返回指令从高特权级返回到低特权级。
- 发生中断需要保存
进程上下文
(相关寄存器)
- 入栈顺序:err_code、eip、cs、eflags、esp和ss
- [ss:esp]:分别存放进程的
栈基址
和栈顶地址
- eflags:标志寄存器,存放cpu的指令行为方式
- [cs:eip]:分别存放进程的
代码段基址
和将要运行的下一条指令的偏移地址
TR寄存器来指定当前任务使用的TSS。 - err_code:软中断(异常)编码
- gdb 调试程序原理
- 调试器fork 了一个子进程,子进程用于运行被调试的程序。调试器中经常要设置断点,其原理就是父进程修改了子进程的指令,将其用int3指令替换,从而子进程调用了
int3 指令
触发中断 - 断点本质上是指令的地址,调试器(父进程〉将被调试进程(子进程〉断点起始地址的第1 个字节备份好之后,原地将该指令的第1 字节修改为0xcc。这样指令执行到断点处时,会去执行
机器码为0xcc 的int3 指令
,该指令会触发3 号中断,从而会去执行3 号中断对应的中断处理程序 - 将当前的寄存器和相关内存单元压栈保存,用户在查看寄存器和变量时就是从栈中获取
- 当恢复执行所调试的进程时,中断处理程序需要将之前备份的1字节还原至断点处,然后恢复各寄存器和内存单元的值,修改返回地址为断点地址,用iret指令退出中断,返回到用户进程继续执行。
- 标志寄存器eflags中的IF位
- 指令
cli
将IF位置0,这称为关中断 - 指令
sti
将IF位置1,这称为开中断 - 中断门在执行时会将IF位置为0,即屏蔽中断,执行完视情况而定
- 陷阱门会保存上下文,执行完恢复上下文(包含eflag寄存器值)
断言和字符串处理函数
- 实现ASSERT断言机制
- 利用对于eflags寄存器的IF位的操作,实现中断开关的函数
- 实现PANIC宏
intr_disable () ;
先将调用中断关闭函数将中断关闭put_str("message")
再打印相关信息- 再通过
while(1){}
悬停在此处
- 实现基本的字符串增删改查和拷贝等相关的功能函数
- 不需要使用底层的字符功能汇编接口
- 使用兼容性的类型,如void*表示地址指针,uint8_t表示一个字节的数据等等
内存管理
- 申请和初始化位图函数:动态申请一块内存并使用memset函数将位图的所有字节用0填充
- 实现位图的基本功能函数:如位图的增删改查等基本函数,注意其中需要进行二进制的位操作
- 实现向虚拟内存池申请虚拟页的函数
- 扫描虚拟内存池位图找到可用空间,并置为1
- 返回内存虚拟页偏移首地址(位图内的下标值 * 4KB(页大小))
- 实现向物理内存池申请1个物理页
- 扫描物理内存池位图找到可用空间,并置为1
- 返回内存物理页偏移首地址
- 实现malloc_page函数,能够连续分配指定数量的页并返回虚拟地址
- 先通过页目录中的P位确认页目录中的页目录项存在,若不存在,则先创建页目录项,再创建页表项。
- 通过vaddr_get函数在虚拟内存池中申请虚拟页
- 通过palloc函数在物理内存池中申请物理页
- 通过page_table_add函数将以上两步得到的虚拟页地址和物理页地址在页表中完成映射(虚拟页地址连续,物理页地址可以不连续)
上述过程详述
- 位图的数据结构
- 使用位图大小+单字节的位图指针,可以让这样的数据结构进行动态变化,并以单字节的偏移进行位图的访问
struct bitmap {
uint32_t btmp_bytes_len;
uint8_t* bits;
};
struct virtual_addr {
struct bitmap vaddr_bitmap;
uint32_t vaddr_start;
};
struct pool {
struct bitmap pool_bitmap;
uint32_t phy_addr_start;
uint32_t pool_size;
};
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 位图的基本概念
- 位指bit,是计算机中存储的最小单位,可以表示0和1两种状态,避免资源浪费
- 图指map,表示1bit与一个单位的其他资源的映射关系,如使用位图管理内存,1位表示实际物理内存中的一个4KB页是否已使用
- 内核和用户进程分别运行在运行在自己的地址空间
- 在实模式下,程序的地址等于物理地址
- 保护模式下,程序地址是虚拟地址,而其对应的物理地址由分页机制进行映射
- 用户进程向内核的内存管理系统申请内存
- 操作系统先从用户进程自己的虚拟地址池中分配空闲虚拟地址,然后再从用户物理内存池(所有用户进程共享〉中分配空闲的物理内存,然后在该用户进程自己的页表将这两种地址建立好映射关系。
- 内核页表和进程页表
- 所有进程共享一个内核页表(内核虚拟地址空间),但是拥有各自的用户页表(进程虚拟地址空间)
- 内核页表管理的是内核地址空间,而进程页表管理的是进程的虚拟地址空间。此外,内核页表是全局可见的,进程页表只对所属进程可见
内存池虚拟页的偏移地址 = 位图内的下标 * 4KB(页大小)
- 进程虚拟空间的基地址通常是随机的(ASLR地址随机化),通过增加攻击者预测目的地址的难度,防止攻击者直接定位攻击代码
- MMU(Memory Management Unit)是一个硬件单元,用于处理CPU生成的虚拟地址和实际的物理地址之间的转换。MMU通过页表机制将虚拟地址转换为物理地址。
- 在页表项中,有一些位来管理虚拟内存和物理内存之间的映射关系,这些标志位通常包括:
- 有效位(Valid bit,V):用于标识该页表项所描述的虚拟页是否有效。如果该位为1,则表示该虚拟页是有效的;否则表示该虚拟页是无效的,即该页当前不在内存中。
- 存在位(Present bit,P):用于标识该虚拟页是否存在于物理内存中。如果该位为1,则表示该虚拟页已经分配了一个物理页框并存放在内存中;否则表示该虚拟页还没有分配物理页框或者被置换到磁盘的交换空间中。
- 访问位(Accessed bit,A):用于标识该虚拟页是否被访问过。在CPU访问该虚拟页时,硬件会将该位设置为1。
- 修改位(Dirty bit,D):用于标识该虚拟页是否被修改过。在CPU写入该虚拟页的时候,硬件会将该位设置为1。
- 通过页表机制将虚拟地址转换成物理地址
- CPU 通过指令将虚拟地址发送给内存管理单元(MMU);
- 进程通过PTBR(页表基址寄存器)获取页表基址
- MMU 根据虚拟地址中的页号找到进程页表中对应的页表项;
- 如果该页表项中的有效位(V)为0,或许触发缺页异常;
- 如果该页表项中的有效位(V)为1,且存在位§为1,表示该页已在物理内存中,则将页表项中的物理页号取出,并将虚拟地址中的页内偏移拼接到物理页号后面,即形成了物理地址;
- 如果该页表项中存在位§为0,表示该页所在的物理页框尚未分配给该进程,则触发缺页中断,并将控制权转交给操作系统;
- 操作系统根据缺页中断判断,将缺失的页面从磁盘中载入到物理内存中,并将其物理页框分配给该进程,并在进程页表中更新该页表项;
- 返回到进程执行的指令,并重新执行该指令。
- 重复步骤1-7,直到最终将虚拟地址转化为物理地址,然后将该物理地址发送给内存控制器从物理内存中取出数据。
线程的实现
从进程的状态转换入手
- 概述
- 创建初始化PCB:在
thread_start
内部,先是通过get_kemel_pages(1)
,在内核内存池中获取1个物理页做线程的 pcb。然后调用init_thread
初始化该线程 pcb 中的信息,然后再用thread_create
创建线程运行的栈 - 调用工作函数:最后
thread_create
会将kernel_thread
的函数地址pop到eip中,然后kernel_thread
调用工作函数fun()
- 定义中断栈、线程栈、进程的task_struct,均用于保存进程或线程的状态
- 获取当前线程的pcb指针函数
running_thread
- PCB 是在自然页的起始地址,即通过
0xfffff000& (&PCB.general_tag)
可以通过结构体元素的指针获得结构体整体的地址
- 实现线程创建函数
thread_create
- 使用指针下移,预留出中断栈和内核栈空间
- 初始化task_struct的其他数据元素,如线程运行的函数指针、函数参数、相关寄存器等
- 实现初始化线程
init_thread
- 实现线程调用任务函数
kernel thread
- 调用
intr_enable
开中断,避免无法调度其他进程 - 调用目标任务函数
function (func_arg);
- 实现线程启动函数
thread_start
- 通过
get_kernel_pages
动态申请一页空间,并用首地址初始化一个task_struct指针 - 调用
thread_create
和init_thread
进行线程的初始化和创建 - 将该线程加入就绪线程队列和全部线程队列
- 通过内联汇编将
thread_create
函数栈中的内核栈结构体中ebp、ebx、edi和esi的值pop到对应寄存器,然后使用ret将kernel_thread
函数地址pop到eip中,开始执行该函数。
- 创建队列的基本接口——双向链表及增删改查、插入、链头和链尾的插删、判空等相关链表操作
- 线程调度函数
schedule
- 正常调度:时间片执行完,按照调度算法,将当前线程的寄存器映像压入栈,将就绪队列中下个可运行线程的栈指针指向的栈中寄存器映像换上CPU
- 中断调度:将其加入阻塞队列,等待某个事件发生后再唤醒
自顶向下:主要是以调度处理和线程创建两部分,线程根据传入的参数完成自己task_struct的创建和初始化,然后加入到全部线程队列和就绪队列中等待调度。schedule根据调度算法选择下一个线程,然后将上一个线程的cpu寄存器映像保存,再将下一个线程的cpu寄存器映像换上。
上述过程详述
- 线程的核心是进行函数的运行,线程栈中必存在着指向运行函数的指针
- 线程与进程的最大区别就是进程独享自己的地址空间,即进程有自己的页表,而线程无自己的页表共享所在进程的页表,因为同一个进程内的线程共享一个task_struct,通过线程私有数据TSD(存储线程ID,和栈寄存器等)进行区分。
- 线程调度
- 时钟中断处理函数
- 调度器schedule
- 任务切换函数switch to
- 访问结构体成员的访问原理是
结构体变量首地址 + 成员偏移量
- 线程的
寄存器映像
的装入表示处理器开始运行该c线程 - task_struct数据结构
struct task_struct (
uint32_t* self_kstack;
enum task_status status;
char name[l6];
uint8_t priority;
uint8_t ticks;
uint32_t elapsed_ticks;
struct list_elem general_tag;
struct list_elem all_list_tag;
uint32_t* pgdir;
uint32_t stack_magic;
- 进程调度后,旧函数的堆栈处理
如果旧函数的堆栈没有被释放并且时间长了,就有可能会出现堆栈溢出的情况。当一个进程的时间片用完后,操作系统会将CPU的控制权交给另一个进程,当前进程将会被挂起,堆栈也将会保留在内存中。如果存在大量这样的进程,堆栈占用的内存将会不断累积,最终可能会导致堆栈溢出的情况。为了避免这种情况,操作系统会定期释放已经被挂起的进程的堆栈,以保证系统的稳定性和可靠性。 - 正在执行的进程陷入内核执行,实际并没有发生进程的切换,因为切换是指CPU的寄存器和内存资源都发生变化,但进程陷入内核执行后还是执行的是进程本身的任务。
输入输出系统
互斥锁的实现
- 实现线程阻塞函数
thread_block
- 检查线程当前状态是否为运行态,运行态才能执行到该代码
- 设置线程状态为阻塞态
- 调用schedule函数,换下当前线程,运行新线程
- 待当前线程被解除阻塞后,调用 intr_set_status恢复原有线程堆栈
- 实现线程解除阻塞函数
thread_unblock
(被其他线程调用)
- 将阻塞的线程添加到就绪队列的队首,保证优先调用
- 再将线程的 status 置为就绪态
TASK READY
- 实现锁和信号量的数据结构,并进行初始化
- 互斥锁
- 无阻塞情况:在时间片轮转算法下,获得自旋锁的线程在就绪队列中排队,而其他线程获得CPU会空转,实现了100%的资源浪费
- 无阻塞特点:
- 快的时候很快:锁的争抢比较少,只需要一条原子指令的开销即可获得锁
- 慢的时候很慢:多个线程争抢时,只有一个线程获得锁,其他的均会自旋等待。如果持有自旋锁的线程切换或睡眠,会发生100%的cpu资源浪费-
- 因为阻塞操作需要内核态进行,所以用户态下的自旋锁性能较差
- 吸优去慢(工程上的优化,通常只优化常见的80%或者最短的木桶板)
- Fast path:一条原子指令,上锁成功立即返回,执行结束唤醒阻塞的进程
- Slow path:上锁失败,立即执行系统调用,阻塞进程,从而减少cpu资源的占用
- 实现终端功能
- 从键盘缓冲区获取字符串,并进行解析,然后在屏幕输出运行结果
从键盘获取输入
- 过程
- 8048键盘控制芯片
- 每个键有两个编码,按下为通码,松开为断码
- 监控键盘的按键操作,通过按键编码映射表,进行编码输出到8042芯片
- 8042键盘控制芯片:
- 按键编码由 8042 处理后保存在自己的寄存器中,然后向 8259A 发送中断信号,cpu执行中断处理程序,将 8042 处理过的扫描码从它的寄存器中读取出来,继续进行下一步处理。
- 键盘缓冲区是由主板上的8042芯片处理和控制的,端口
0x60
是输入输出缓冲区,端口0x64
是控制寄存器
- 键盘的中断处理程序
- “硬件”扫描码转换成对应的“软件” ASCII 码
- 编写键盘驱动
- 驱动时硬件和软件的桥梁,是一个中间翻译和控制者
- 使用有意义的宏名称定义硬件的魔数
- 根据从端口获取的数据通过算法进行转换处理成软件可调用函数
- 基于
环形队列
的生产者消费者
(类似于面向对象)
- 定义环形缓冲区的数据结构和属性
- 定义环形缓冲区的相关操作函数
- 键盘缓冲区使用该环形队列的生产者消费者模型进行构建
上述过程详述
- 值得注意的是线程阻塞是线程执行时的“动作”,因此线程的时间片还没用完,在唤醒之后,线程会
继续在剩余的时间片内运行,调度器并不会将该线程的时间片“充满”,也就是不会再用线程的优先级
priority 为时间片 ticks 赋值。因为阻塞是线程主动的意愿,它也是“迫于无奈”才“慷慨”地让出处理器
资源给其他线程,所以调度器没必要为其“大方”而“赏赐”它完整的时间片。 - 自旋的时间和被锁住的代码执行的时间是成「正比」的关系,即被锁的代码执行越长,自旋锁性能开销越大,因为其他线程也要进行忙等待。
- 互斥锁只能保证线程之间的互斥,但是不能保证线程之间的执行顺序,而引入条件变量,就是控制线程之间的执行顺序,以生产者消费者为例,就是生产者生产完消息之后,消费者才去消费消息。而不是消费者盲目的去循环或者sleep。
用户进程
- 定义并初始化TSS
- 更新 tss esp0 字段的值为 pthread 级栈
- gdt 中创建 tss 并重新加载 gdt
- 进程创建页表相关函数
vaddr_get
:互斥的虚拟内存池申请指定数量的虚拟页,并返回第一个虚拟内存页的起始地址get_a_page
:可以指定一个虚拟地址绑定到申请的物理页上addr_v2p
:将虚拟地址转换成其所映射的物理地址
- 进程初始化函数
start_process
- 初始化进程的task_struct
movl pro_stack, esp
:将esp 填充为该进程pcb起始地址jmp intr_exit
:使程序流程跳转到中断出口地址intr_exit
,通过那里的一系列 pop 指令和 iretd 指令,将进程pcb中的数据载入CPU 的寄存器,从而使程序“假装”退出中断,进入用户态
- 激活进程页表
process_activate
- 通过当前线程或进程的task_struct属性更新页目录寄存器CR3
- 将tss的esp0设置为进程内核态的task_struct起始地址,用户态进程中断后从此处获得内核态栈
- 创建用户页目录表
create_page_dir
- 用户进程的页表不能让用户直接访问到,所以在内核空间来申请
- 要把内核的页目录项复制到用户进程使用的页目录表中 ,即将进程页目录表映射到内核物理页,方便进程的内核态访问内核空间
- 要把用户页目录表中最后 个页目录项更新为用户进程自己的页目录表的物理地址。
- 返回页表的虚拟地址。
- 用户进程的调度
schedule
上述过程详述
- TSS 是Task State Segment 的缩写,即任务状态段。
- LDT是局部描述符表,GDT是全局描述符表。描述符的功能是描述一段内存区域的边界和属性
- 给每个任务“关联”一个任务状态段,这就是TSS (Task State Segment ),用它来表示任务。TSS是由程序员为任务单独定义的一个结构体变量,CPU通过该结构体变量保存任务的状态。新旧任务的切换本质就是CPU寄存器加载的TSS对应的任务状态数据
- TR寄存器专门存放TSS的起始地址和偏移大小,始终指向当前正在运行的任务
- 任务嵌套调用链
- 栈是以
先进后出的数组
为数据结构 - TSS是以
指针链表
作为数据结构形成的调用链
- 任务状态快照:TSS中的字段基本全是寄存器在任务运行中的最新状态
- 任务运行的支持通过LDT和TSS进行
- LDT 中保存的是任务自己的实体资源,也就是数据和代码
- TSS 中保存的是任务的上下文状态及三种特权级的栈指针、I/O位图等信息。
- 中断发生时,通过任务门进行任务切换的过程如下
- 从该任务门描述符中取出任务的TSS 选择子。
- 用新任务的TSS 选择子在GDT 中索引TSS 描述符。
- 判断该TSS 描述符的P 位是否为1 ,为1 表示该TSS 描述符对应的TSS 己经位于内存中TSS 描述符指定的位置,可以访问。否则P 不为1 表示该TSS 描述符对应的TSS 不在内存中,这会导致异常
- 从寄存器TR 中获取旧任务的Tss 位置,保存旧任务(当前任务)的状态到旧TSS 中。其中,任务状态是指CPU 中寄存器的值,这仅包括TSS 结构中列出的寄存器: 8 个通用寄存器, 6 个段寄存器,指令指针eip,战指针寄存器esp,页表寄存器cr3 和标志寄存器eflags 等。
- 把新任务的TSS 中的值加载到相应的寄存器中。
- 使寄存器TR 指向新任务的TSS 。
- 将新任务(当前任务〉的TSS 描述符中的B 位置1 。
- 将新任务标志寄存器中eflags 的NT 位置1 。
- 将旧任务的TSS 选择子写入新任务TSS 中“上一个任务的TSS 指针”宇段中。
- 开始执行新任务。
- 当CPU 由低特权级进入高特权级时, CPU 会“自动”从TSS 中获取对应高特权级的栈指针
- CPU 不允许从高特权级转向低特权级,除非是从中断和调用门返回的情况下
- 任务的构成:
- LDT 中保存的是任务自己的实体资源,也就是数据和代码,
- TSS 中保存的是任务的上下文状态及三种特权级的栈指针、位图等信息
- 任务切换:将新任务对应的 LDT 信息加载到 LDTR寄存器,对应的 TSS 信息加载到 TR 寄存器
- 在GDT(全局描述符表)中通常包括三种类型的描述符:
- 代码段描述符:用于描述可执行代码段的属性,如段起始地址、段限长、访问权限等。
- 数据段描述符:用于描述可读写数据段的属性,如段起始地址、段限长、访问权限等。
- 系统段描述符:用于描述特殊的段,如任务状态段(TSS,Task State Segment)、局部描述符表(LDT,Local Descriptor Table)等。
- 进程PCB = 线程pcb + 进程页表指针
- 当前任务如果是线程,则pcb 中的页表指针为nullptr
- iretd
- 会用到栈中的数据作为返回地址
- 加载栈中eflags的值到 eflags 寄存器
- 用户从用户态到内核态
- 进程或线程在被中断信号打断时,处理器会进入内核态,并会在内核态栈中保存进程或线程的上下文环境。
- 中断用户态进程:处理器会自动到 tss 中获取 esp0的值作为用户进程在内核态( 特权级〉的栈地址
- 内核态中断:由于内核线程已经是,进入中断后不涉及特权级的改变,所以处理器并不会到 tss 中获取 esp0
- 编译器会在汇编阶段将汇编程序源码中的关键宇section或者segment,链接器将这些目标文件中属性相同的节( section )合并成段 segment),因此一个段是由多个节组成的,所以程序内存空间中的数据段、代码段就是指合并后的section
- 32位的操作系统每个进程拥有4GB的地址空间,其中每个进程都拥有各自的用户进程虚拟空间,而共享同一个内核进程虚拟空间。
中断调用
linux系统调用解析
- 初始化中断描述符表,并将系统调用入口
int 0x80
的特权级DPL设置为3 - 注册
int 0x80
的中断处理程序
- 保存上下文环境:将段基址寄存器和其他32位寄存器压入栈中
- 压入功能号0x80和系统调用参数
- 调用对应的子功能处理函数
- 将call调用后的返回值存入当前内核校中 eax 的位置
jmp intr_exit
:使用intr_exit 返回,恢复上下文环境
- 定义并初始化子功能处理函数指针数组
syscall_table
- 添加系统调用
getpid
- 实现
write
系统调用
- 在枚举类型中增加子功能号
- 在系统调用库中增加的用户调用的c接口
- 实现字符串打印系统函数
- 给系统调用函数指针数组进行赋值
syscall_table[SYS_WRITE] = sys_write;
- 使用
valist
实现可变参数的printf函数
- 数组链方式进行组织一个内存池处理小于1024字节的内存分配
- 定义内存块描述符,包含mem_block大小、mem_block数量、可用mem_block链表
- 初始化内存块描述符数组,其中的内存块规格大小分别是16、32、64、128、256、512 、1024 字节
- 在pcb描述符中增加
内存块描述符数组
,并在进程初始化函数中完成对该数组的初始化工作 sys_malloc函数
:在堆中申请 size 字节内存
- 健壮性检查:
- 判断所使用的目标内存池,是内核内存池还是用户内存池
- 判断申请的内存是否在内存池容量范围内
- 判断申请的内存块是否大于1024KB
- 超过最大内存块 1024KB ,就直接向上取整,分配足够数量的页框
- 小于等于1024KB,使用最佳适应算法匹配规格合适的 mem_block_desc,若有则分配并更新元信息,若没有创建新的mem_block。
- 返回内存块首地址
mfree_page函数
:页框级别内存块的释放
- 健壮性判断
- 确保待释放的物理内存的存在
- 判断位于内核物理内存池还是用户物理内存池
- 先将对应的物理页框归还到内存池,再从页表中清除此虚拟地址所在的页表项 pte
- 清空虚拟地址的位图中的相应位
sys_free函数
:内存释放的统一接口
- 判断是进程还是线程,切换到对应的内存池
- 判断要释放的空间是否大于1024KB
- 大于1024KB,调用
mfree_page函数
进行处理 - 小于等于1024KB,通过对于内存池的内存块描述符的链表和数量进行处理,从而逻辑上释放
- 实现malloc和free函数
- 完成系统调用号与子功能处理函数的关联:在中断处理函数指针数组
syscall_table
中添加对应的函数指针,sys_free函数
和sys_malloc函数
上述过程详述
- 系统调用是用户进程请求操作系统内核做的内核态函数,通常硬件资源相关
- 只支持三个参数的系统调用的实现
#define _syscall(NUMBER, ARG1, ARG2, ARG3) ({ \
int retval; \
asm volatile ( \
"int $0x80" \
: "=a"(retval) \
: "a"(NUMBER),"b"(ARG1),"c"(ARG2),"d"(ARG3) \
: "memory" \
); \
retval; \
})
- printf源码
uint32_t vsprintf(char* str, const char* format, va_list ap) {
char* buf_ptr = str;
const char* index_ptr = format;
char index_char = *index_ptr;
int32_t arg_int;
char* arg_str;
while(index_char) {
if (index_char != '%') {
*(buf_ptr++) = index_char;
index_char = *(++index_ptr);
continue;
}
index_char = *(++index_ptr);
switch(index_char) {
case 's':
arg_str = va_arg(ap, char*);
strcpy(buf_ptr, arg_str);
buf_ptr += strlen(arg_str);
index_char = *(++index_ptr);
break;
case 'c':
*(buf_ptr++) = va_arg(ap, char);
index_char = *(++index_ptr);
break;
case 'd':
arg_int = va_arg(ap, int);
if (arg_int < 0) {
arg_int = 0 - arg_int;
*buf_ptr++ = '-';
}
itoa(arg_int, &buf_ptr, 10);
index_char = *(++index_ptr);
break;
case 'x':
arg_int = va_arg(ap, int);
itoa(arg_int, &buf_ptr, 16);
index_char = *(++index_ptr);
break;
}
}
return strlen(str);
}
uint32_t sprintf(char* buf, const char* format, ...) {
va_list args;
uint32_t retval;
va_start(args, format);
retval = vsprintf(buf, format, args);
va_end(args);
return retval;
}
uint32_t printf(const char* format, ...) {
va_list args;
va_start(args, format);
char buf[1024] = {0};
vsprintf(buf, format, args);
va_end(args);
return write(1, buf, strlen(buf));
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
- 内存分配
- 在虚拟地址池中分配虚拟地址:操作的是内核虚拟内存池位图或者用户虚拟内存池位图,并返回首地址
- 在物理地址池中分配物理地址:操作的是内核物理内存池位图或者用户物理内存池位图,并返回首地址
- 在页表中完成虚拟地址到物理地址的映射
- 内存释放
- 在物理地址池中释放物理页地址
- 在页表中去掉虚拟地址的映射
- 在虚拟地址池中释放虚拟地址
- pagefault的作用:
- 将页表项pte中的P位置为0,表示当前物理内存不足,可以将该pte 指向的物理页框中的数据转储到外存上,节省4KB的物理内存空间。
- 访问该 pte 对应的虚拟地址时,由于 pte的P位为0,CPU 会抛出pagefault缺页异常。
- pagefault 异常的中断处理程序:将之前保存到外存的页框数据再次载入到物理内存中,由内存分配器分配新的物理内存页,然后把目标物理页地址更新到 pte 中,并将P位置为1。 pagefault中断处理程序退出后,CPU 自动会再次访问引起此pagefault的虚拟地址,这次发现 pte的P位为1,从而访问正常
编写硬盘驱动程序
- 简历一个磁盘文件,然后使用磁盘分区工具进行磁盘分区。
- 初始化可编程中断控制器 8259A
- 是在主片 8259A 上打开的中断 IRQ0的时钟、 IRQ1的键盘和级联从片的 IRQ2
- 打开从片上的 IRQ14 ,此引脚接收硬盘控制器的中断
- 定义硬盘相关数据结构
- 分区结构:主要是扇区信息和inode结点相关
- 硬盘结构:硬盘名称、归属和其他硬盘属性
- ata通道结构:通道连接端口号和中断号等相关信息
- 通过宏定义,定义硬盘各寄存器的端口号和寄存器的关键位代号
- 根据端口信息初始化硬盘数据结构
- 实现
thread_yield函数
- 先将当前任务重新加入到就绪队列队尾
- 将当前任务的 Sta 置为 TASK READY
- 最后调用 schedule 重新调度新任务
- 实现就绪队列为空时,执行的idle线程
- 实现任务休眠函数
ticks_to_sleep
(每个硬盘请求后要一定时间才会响应,所以在一开始让出cpu,直到超过某个时间周期)
- 要当前的 ticks 值减去第一次调用时的 ticks(这里是 start_tick ),所得的差小于 sleep_ticks (休眠的 ticks 数),就调用也read__yield 让出cpu ,直到不满足此条件为止(发生了足够多次数的时钟中断),从而达到了延时的目的。
ide_read函数
:从硬盘读取 sec_cnt 个扇区到buf
- 将硬盘通道上锁,从而保证一次操作通道上的一块硬盘
- 调用
select_disk
选择要操作的硬盘:dev寄存器存器中的 dev 位,为0表示主盘,为1表示从盘 - 调用
select sector
写入的扇区数和起始扇区号:分别向 Sector count 寄存器和LBA写入 - 调用
cmd_out
将执行命令写入 reg_cmd 寄存器 - 调用
sema_down
将自己阻塞,等待硬盘完成读操作后通过中断处理程序唤醒自己 - 调用
busy_wait
检测硬盘状态是否可读 - 调用
read_ from_sector
将数据从硬盘的缓冲区中读出 - 解锁通道
- 注册硬盘中断处理程序
intr_ hd _handler
- 如果 channel->expecting_intr 的值为true。则给通道的信号量 disk done 执行V操作,即阻塞在此信号量上的驱动程序便会醒来。
- inb(reg_status(channel)
显示通知硬盘控制器此次中断处理完成,重置硬盘控制器
上述过程详述
- 文件系统是运行在操作系统中的软件模块,是操作系统提供的一套管理磁盘文件读写的方法和数据组织、存储形式。管理对象是文件,管辖范围是分区,因此它建立在分区的基础上,每个分区都可以有不同的文件系统。
- 在硬盘的MBR 中有个 64 字节“固定大小”的数据结构,这就 著名的
分区表
,分区表中的每个表项就是一个分区的“描述符”,表项大小是 16 字节,因此 64 字节的分区表总共可容纳4个表项,这就是为什么硬盘仅支持4个分区的原因 - 磁盘分区表(Disk Partition Table )简称 DPT ,是由多个分区元信息汇成的表,表中每 个表项都对应一个分区,主要记录各分区的起始扇区地址,大小界限等,通常由分区软件写入,因为操作系统是建立在分区上运行的。
- 磁盘启动程序
MBR
(Master Boot Record)是硬盘的第一个扇区,用于存放启动加载程序和分区表
。MBR的大小为512字节,其中包括446字节的引导程序和64字节的分区表,以及两个字节的MBR签名。OBR
(Outer Boot Record)是指在磁盘的第二、三个扇区存放的引导加载程序,用来加载操作系统,并且含有相应的文件系统信息
。DBR
(DOS Boot Record)是指FAT文件系统上的引导扇区,通常是在分区的第一个扇区,用于加载DOS操作系统。EBR
(Extended Boot Record)是在扩展分区中的引导扇区,用于存储扩展分区的分区表信息
。每个扩展分区仅有一个EBR,并且EBR通过链表方式链接所有的扩展分区。每个EBR的大小为64字节。
- 常见文件系统格式
NTFS
(New Technology File System)是Windows操作系统中常用的文件系统格式
之一。NTFS支持文件和文件夹的权限、加密、压缩等功能,可以处理比FAT更大的文件和分区
,也可以更好地管理硬盘上的数据以及提高读写速度。FAT32
(File Allocation Table 32)是一种32位的文件系统格式
,支持在多个操作系统之间数据交换,例如在Windows和Mac OS之间共享文件。FAT32文件系统在处理较小的文件时表现良好
,但不适合用于处理大容量文件和分区,因为它只能支持文件大小不超过4GB的
。EXT2
(Extended Filesystem 2)是Linux操作系统上最常见的文件系统格式
之一。EXT2支持大容量的文件和分区
,而且在多个操作系统之间数据共享时也相对稳定可靠。EXT2不支持文件和文件夹的权限、加密等高级功能,但是可以通过添加插件等方式来实现这些功能。
- 子扩展分区的绝对扇区 LBA 地址=总扩展分区绝对扇区 LBA 地址+子扩展分区的偏移扇区
- 硬盘上有两个ata通道,也称为 IDE通道。第1个ata道上的两个硬盘(主和从)的中断信号挂在 8259A 从片的 IRQ14上。硬件发生中断的前提是完成cpu分配的任务,所以一个ata通道可以通过cpu命令知道执行的是哪个硬盘。
少年,我观你骨骼清奇,颖悟绝伦,必成人中龙凤。
不如点赞·收藏·关注一波
🚩点此跳转到首行↩︎
参考博客
- 第十二章 博客
- 技术部落
- Linux从头学12:读完这篇【特权级】文章,你就比别人更“精通”操作系统!
- 知乎——伙伴系统概述
- 待定引用
- 待定引用
- 待定引用
- 待定引用