x86体系架构
x86是因特尔8086代芯片的CPU总线位数以及寄存器种类的规范,大部分操作系统都是以该规范作为基准来生产的
计算机组成
-
CPU,可以根据程序计数器进行取指令操作,并根据指令执行运算(加、减、乘、除)。运算所需的操作数以及运算的结果将会被放在寄存器以及CPU高速缓存中,缓存中的数据可以通过总线与内存进行相互之间的传输
-
内存,保存计算的中间结果(数据段),保存指令(代码段)
-
总线,在CPU和内存、IO设备之间传输数据
-
IO设备,我们通过IO设备来向内存中写入数据以及看到内存中的数据
-
磁盘,持久化保存数据
操作系统的抽象
操作系统在上述计算机的组成的基础上,为我们的代码开发做了抽象:进程的抽象,虚拟内存的抽象,文件系统的抽象
我们开发代码不必再关心多个程序的执行顺序,而是交由操作系统为我们进行调度,操作系统通过保存和更新新旧进程CPU寄存器中的值帮助我们在进程之间来回切换。我们也不必直接操作有限的内存,而是直接使用“无限”的虚拟内存,由操作系统识别这个虚拟内存并帮我们映射到真正的内存上。
8086处理器寄存器种类
主要分三种:通用寄存器、指令指针寄存器、段寄存器
为了暂存数据,8086处理器内部有8个16位的通用寄存器,也就是刚才说的CPU内部的数据单元,分别是AX、BX、CX、DX、SP、BP、SI、DI。这些寄存器主要用于在计算过程中暂存数据。
IP寄存器就是指令指针寄存器(Instruction Pointer Register),指向代码段中下一条指令的位置。CPU会根据它来不断地将指令从内存的代码段中,加载到CPU的指令队列中,然后交给运算单元去执行。
每个进程都分代码段和数据段,为了指向不同进程的地址空间,有四个16位的段寄存器,分别是CS、DS、SS、ES。CS就是代码段寄存器(Code Segment Register),通过它可以找到代码在内存中的位置;DS是数据段的寄存器,通过它可以找到数据在内存中的位置。SS是栈寄存器(Stack Register),用于函数调用
因此,进程的执行就需要从数据段寄存器中获取数据段的开始地址,然后从通用寄存器中取出要找的某一个数据的偏移地址,得到精确地址通过地址总线即可从内存中拿到数据
由于8086的地址总线是20位的,所以最多可以访问的内存范围有 220 = 1M。但是段寄存器只有16位,也就是说如果以段寄存器中的数据作为起始地址的话,216是小于220的,那就不能做到访问整个内存。因此这里计算的逻辑是,取出段寄存器中的16位数之后,将其右侧补0左移动4位,这样就得到一个20位的数字,然后在加上偏移量。
实模式和保护模式
后来,出现了32位处理器,通用寄存器进行了扩展,使其在兼容旧的16位的前提下,改成了32位。而段寄存器的改动比较大了。如果将段寄存器也和通用寄存器一样改为32位,232可以访问所有的内存空间,就没有必要跟之前的逻辑一样左移4位了。因此干脆段寄存器依旧保持16位,只不过分为了两种模式:实模式和保护模式。实模式下和之前16位寄存器一样,而保护模式下,段寄存器中存储的不再是段的起始地址,而是段选择子,可以根据段选择子去段描述符中拿到一串32位的地址
操作系统的启动
CPU是通过读取内存中的指令并按照指令操作内存中的数据来完成计算的,因此,一个操作系统要想能够正常启动,必须要有一块区域能够持久化保存代码和数据。而在主板上,有一个东西叫ROM(Read Only Memory,只读存储器),上面早就固化了一些初始化的程序,也就是BIOS(Basic Input and Output System,基本输入输出系统)。
由于BIOS只有1M,此时操作系统处于实模式。
BIOS会搜索启动盘,启动盘有什么特点呢?它一般在第一个扇区,占512字节,而且以0xAA55结束。这是一个约定,当满足这个条件的时候,就说明这是一个启动盘,在512字节以内会启动相关的代码。BIOS会在启动盘中找到boot.img,并将其载入到内存执行。随后为了访问更多的地址空间,操作系统将从实模式切换为保护模式,启用分段和分页机制,建立段描述符表。boot.img会导入core.img并解压缩出kernal.img,进行内核的初始化。操作系统就是这样开始逐步启动了
系统调用
glibc 对系统调用进行封装,C语言中的open等都是glibc中对系统调用的封装。
为什么要封装?真正的系统调用代码是汇编的代码,使用汇编指令操作寄存器并且产生中断陷入内核态等,用户写起来门槛比较高,所以把这部分汇编封装起来,提供一个C语言的接口给使用者。
汇编做了什么事情呢?将系统调用的各个参数压入寄存器,根据系统调用的名称获取到系统调用号,压入寄存器,然后执行int $0x80号中断,陷入内核。
int $0x80号中断又做了什么呢?
保留当前寄存器中的值,然后根据系统调用号,在系统调用表中找到相应的函数进行调用,并将寄存器中保存的参数取出来,作为函数参数。系统调用结束之后,调用iret指令将原来用户态保存的现场恢复回来,包含代码段、指令指针寄存器等。这时候用户态进程恢复执行。
为什么要调用int 0x80
因为要通过中断陷入内核,在内核态进行系统调用的后续操作。
64位下,就没有通过中断陷入内核,而是通过syscall指令陷入内核。因此我觉得陷入内核不一定是要靠中断,中断是一种手段,陷入内核的核心要义是把用户态的寄存器保存,然后使用这些寄存器执行内核相关操作和运算,计算完之后再把用户态的寄存器返回回去。
不对,陷入内核就是要靠中断,只有中断或者异常才会将控制权交给内核,找到对应的handler进行回调。如果不靠中断的话,相当于不按照预先提供的接口(中断向量)进行操作了,就不安全了
那内核态和用户态的区别是什么?是一个标志位吗?那我用户态能不能修改这个标志位为内核态,然后破坏内核代码呢?
内核态和用户态是线程的区别,有内核级线程,也有用户级线程。CPU指令有不同的权限等级(ring0 - ring4),想要执行某个指令,就必须有对应权限的线程才能执行。因此从用户态切换为内核态其实是由用户线程切换为了内核线程,所产生的损耗是线程上下文切换的损耗。更确切的说,用户态用的是一个线程栈,内核态用的是另一套线程栈
只有做到这种线程级别的隔离,才能避免用户线程主动修改为内核线程。比如,客户端向服务器请求权限,客户端是不能修改服务端内部的逻辑的,客户端自己也不能变成服务端,只能通过进程通信的方式请求权限。我只能通过你开放给我的接口(系统调用)来改变你的内部逻辑,而不能直接改变
我的理解:中断是一个接口,客户端通过接口来影响服务端,用户态也只有通过中断才能将逻辑交给内核态。至于给内核态做什么,就要看是哪个中断了,对应起来就是调用了哪个接口。比如我要做系统调用,我就0x80号中断。系统调用是内核做的事情,所以只能通过中断来实现。用户态想要内核做事情,只能通过中断来实现。
疑问:中断是由谁发起的?前面提到过中断可以由用户态的系统调用指令发起,那时钟中断是谁发起的呢?
中断可以是内中断(软件通过指令,比如int80或者异常),也可以是外中断,比如时钟中断、IO中断。
既然外中断是CPU外部引起的,那外中断是如何执行自己的逻辑的呢?比如,时钟中断是每隔一段时间就去让CPU停下来,那这个逻辑是谁来控制的呢?
时钟硬件向CPU发送脉冲信号,让CPU停下来去执行对应的中断处理函数,总之是由硬件控制的
程序编译
文本文件 -> 二进制文件
在编译的时候,先做预处理工作,例如将头文件嵌入到正文中,将定义的宏展开,然后就是真正的编译过程,最终编译成为.o文件,这就是ELF的第一种类型,可重定位文件(Relocatable File)。
.text:放编译好的二进制可执行代码
.data:已经初始化好的全局变量
.rodata:只读数据,例如字符串常量、const的变量
.bss:未初始化全局变量,运行时会置0
.symtab:符号表,记录的则是函数和变量
.strtab:字符串表、字符串常量和变量名
可重定位文件不是可以直接执行的,而是需要进行静态链接,将函数变量等进行定位。链接完之后会生成ELF的第二种类型:可执行文件
这个格式和.o文件大致相似,还是分成一个个的section,并且被节头表描述。只不过这些section是多个.o文件合并过的
动态链接是在运行时根据地址将函数从动态链接库加载入内存。动态链接库,就是ELF的第三种类型,共享对象文件(Shared Object)
内核中有ELF文件加载相关的方法和数据结构,在exec系统调用中就有load_elf_binary方法,可以将可执行文件加载到进程的内存映像中执行。
exec会把传入的二进制文件加载到当前进程的内存中,那内存中当前进程的其他代码呢?被覆盖掉了吗?如果exec下面跟着其他的代码逻辑会被执行到吗?
exec执行完之后,原调用进程的内容除了进程号外,其他代码段数据段等都被exec加载的可执行文件替换了,exec后面的代码逻辑不会再被执行到了
进程数据结构
进程占用一系列资源:内存空间、端口、句柄等,而线程则是进程中负责执行任务的,需要交由CPU进行调度。每个进程在一开始就有一个主线程,后续可以在一个进程中创建多个线程,每一个线程都被内核使用一个数据结构task_struct来表示,这些数据结构被放在任务列表中进行调度
任务都有哪些属性字段?猜测:
- 进程id
- 唯一id
- 执行时间
- 栈地址
- PC指针
- 各个寄存器的值
疑问:信号处理是什么时候进行的?是由谁发起的?信号和中断的关系?
发送信号是一个系统调用,可以由一个进程向另一个进程发起信号,会将此信号挂载到目标进程的信号处理数据结构中(如上图),随后尝试唤醒该进程,在进程被唤醒时会执行信号处理钩子函数。信号处理时机:进程从 内核态 返回 用户态 时,会在操作系统的指导下,对信号进行检测及处理
函数调用栈
这样子的结构,被调用者是如何获取参数的呢?可以直接拿到返回地址的前面几个就可以了吧,那保留前面栈帧的栈基地址有什么用呢?
当前栈帧包含前面栈帧的栈基地址,是为了返回的时候把旧的栈基地址恢复到ebp寄存器中。因此入栈的作用不是为了找参数,而是为了保留。
ESP和EBP这两个栈顶栈基地址是存在寄存器当中的
用户态是如此,内核也是如此:进程在进入内核态之后,也要发生各种函数调用,此时也需要一个栈来维护函数的调用关系,这个栈就是内核栈。每一个task_struct都有一个内核栈。从用户态切换到内核态,发生了栈的变化:原来的CPU上下文以及栈顶栈底等寄存器被压入了内核栈中的pg_regs,内核代码将在内核栈中继续执行
发生系统调用,相当于进行了一次栈切换:从用户栈到内核栈
疑问:为什么内核态需要单独维护一个栈结构,从用户态到内核态进行栈切换,为什么要这么麻烦?为什么不能继续用用户态的栈进行内核函数的调用呢?
安全,避免高权限的栈空间被低权限修改。如果都保留在用户态栈里,那我在用户直接+4、+8就能访问到内核相关的地址空间,不安全。
CPU调度策略
在Linux里面,有多种调度类,其中比较重要的是一个基于CFS的调度算法。CFS全称Completely Fair Scheduling,叫完全公平调度。听起来很“公平”。那这个算法的原理是什么呢?我们来看看。
首先,你需要记录下进程的运行时间。CPU会提供一个时钟,过一段时间就触发一个时钟中断。就像咱们的表滴答一下,这个我们叫Tick。CFS会为每一个进程安排一个虚拟运行时间vruntime。如果一个进程在运行,随着时间的增长,也就是一个个tick的到来,进程的vruntime将不断增大。没有得到执行的进程vruntime不变。
显然,那些vruntime少的,原来受到了不公平的对待,需要给它补上,所以会优先运行这样的进程。新建的进程的vruntime会被初始化为当前选中的进程的vruntime,并进行一定的奖励和惩罚,避免不公平的情况出现。
在每个CPU上都有一个队列rq,这个队列里面包含多个子队列,例如rt_rq和cfs_rq,不同的队列有不同的实现方式,cfs_rq就是用红黑树实现的。
当有一天,某个CPU需要找下一个任务执行的时候,会按照优先级依次调用调度类,不同的调度类操作不同的队列。当然rt_sched_class先被调用,它会在rt_rq上找下一个任务,只有找不到的时候,才轮到fair_sched_class被调用,它会在cfs_rq上找下一个任务。这样保证了实时任务的优先级永远大于普通任务。
进程上下文切换
TSS(Task State Segment)即任务状态段。具体的说,在设计 “Intel 架构”(即 x86 系统结构)时,每个任务(进程or线程)都对应有一个独立的 TSS ,TSS 就是内存中的一个结构体,里面包含了 几乎所有的 CPU 寄存器的映像 。 TR(Task Register)即任务寄存器,指向当前进程对应的 TSS 结构体。进行进程切换时,只需要将当前寄存器中的值保存到TR所指的TSS中,然后TR指向要切换的进程的TSS,并将该TSS中的值恢复到寄存器中。
但是这样有个缺点。我们做进程切换的时候,没必要每个寄存器都切换,这样每个进程一个TSS,就需要全量保存,全量切换,动作太大了
所以优化是,TR指向的TSS不变,每次切换时只需要将要切换的进程中需要用到的寄存器写入TSS即可,这部分进程独有的寄存器被存在task_struct的thread变量中
这样一来,用户栈的栈顶指针、内核栈等都已经切换完了,而下一条要运行的指令,其实不用切换,因为下一条指令在两个进程看来都是在context_swich中切换完成之后的指令。
A -> B
当进程A在内核里面执行switch_to的时候,内核态的指令指针也是指向这一行的。但是在switch_to里面,将寄存器和栈都切换到成了进程B的,唯一没有变的就是指令指针寄存器。当switch_to返回的时候,指令指针寄存器指向了下一条语句finish_task_switch。
但这个时候的finish_task_switch已经不是进程A的finish_task_switch了,而是进程B的finish_task_switch了。
进程调度
进程调度分为主动调度(主动调用schedule,比如在进行io后没有拿到结果时)以及抢占式调度(进程执行时间片用完了)。
标记一个进程应该被抢占,都是调用resched_curr,它会调用set_tsk_need_resched,标记进程应该被抢占,但是此时此刻,并不真的抢占,而是打上一个标签TIF_NEED_RESCHED。
除了时间片用完之外,另外一个可能抢占的场景是当一个进程被唤醒的时候。我们前面说过,当一个进程在等待一个I/O的时候,会主动放弃CPU。但是当I/O到来的时候,进程往往会被唤醒。这个时候是一个时机。当被唤醒的进程优先级高于CPU上的当前进程,就会触发抢占。如果应该发生抢占,也不是直接踢走当然进程,而也是将当前进程标记为应该被抢占。真正的抢占还需要时机,也就是需要那么一个时刻,让正在运行中的进程有机会调用一下__schedule。
对于用户态的进程来讲,从中断中返回的那个时刻,是一个被抢占的时机。
因此,标记抢占和实际进行抢占是不一样的。标记抢占只是在时间片用完或者进程被唤醒时标记可被抢占,而真正的抢占是发生在中断(时钟中断,系统调用等)返回的时刻。
进程的创建
进程创建的系统调用是fork(),其实就干了两件事,第一件事,copy父进程的结构(内存空间写时复制)。第二件事是,尝试唤醒新进程
线程的创建
线程的创建函数是pthread_create,它并不是一个系统调用,而是glibc封装的一个函数,pthread_create首先会根据参数为线程在用户态分配一块内存空间作为栈,然后会进行clone系统调用。clone和我们原来熟悉的其他系统调用几乎是一致的。但是,也有少许不一样的地方。
如果在进程的主线程里面调用其他系统调用,当前用户态的栈是指向整个进程的栈,栈顶指针也是指向进程的栈,指令指针也是指向进程的主线程的代码。此时此刻执行到这里,调用clone的时候,用户态的栈、栈顶指针、指令指针和其他系统调用一样,都是指向主线程的。
但是对于线程来说,这些都要变。因为我们希望当clone这个系统调用成功的时候,除了内核里面有这个线程对应的task_struct,当系统调用返回到用户态的时候,用户态的栈应该是线程的栈,栈顶指针应该指向线程的栈,指令指针应该指向线程将要执行的那个函数。
所以这些都需要我们自己做,将线程要执行的函数的参数和指令的位置都压到栈里面,当从内核返回,从栈里弹出来的时候,就从这个函数开始,带着这些参数执行下去。
内核中的clone系统调用会调用do_fork,这个do_fork和fork系统调用的do_fork是一样的,只不过传入的标志位不同:clone的do_fork传入了clone_flags,它会使得新建的task_struct中的值都引用指向进程对应的值,而不是拷贝一份:
- 对于copy_fs,原来是调用copy_fs_struct复制一个fs_struct,现在因为CLONE_FS标识位变成将原来的fs_struct的用户数加一。
- 对于copy_sighand,原来是创建一个新的sighand_struct,现在因为CLONE_SIGHAND标识位变成将原来的sighand_struct引用计数加一。
- 对于copy_signal,原来是创建一个新的signal_struct,现在因为CLONE_THREAD直接返回了。
- 对于copy_mm,原来是调用dup_mm复制一个mm_struct,现在因为CLONE_VM标识位而直接指向了原来的mm_struct
根据__clone的第一个参数,回到用户态也不是直接运行我们指定的那个函数,而是一个通用的start_thread,这是所有线程在用户态的统一入口。
在start_thread入口函数中,才真正的调用用户提供的函数,在用户的函数执行完毕之后,会释放这个线程相关的数据。
总结来说,创建进程的话,调用的系统调用是fork,在copy_process函数里面,会将五大结构files_struct、fs_struct、sighand_struct、signal_struct、mm_struct都复制一遍,从此父进程和子进程各用各的数据结构。而创建线程的话,调用的是系统调用clone,在copy_process函数里面, 五大结构仅仅是引用计数加一,也即线程共享进程的数据结构
内存管理
虚拟内存
在进程的视角里可以使用全部的地址空间,32位下,可以使用232 = 4G的内存空间,64位下,实际可以使用248 = 256T的内存空间
进程是如何划分这些虚拟内存空间的呢?
从低位到高位依次是:
Text Segment(存放二进制可执行代码)、Data Segment(存放静态常量)、Data Segment(存放未初始化的静态常量)、堆(动态分配内存 )、Memory Mapping Segment(用于文件映射到内存使用,如果二进制的执行文件依赖于某个动态链接库,就是在这个区域里面将so文件映射到了内存中。)、栈(主线程的函数调用的函数栈)
内核空间和用户空间对于虚拟内存的划分
虚拟空间一切二,一部分用来放内核的东西,称为内核空间,一部分用来放进程的东西,称为用户空间。用户空间在下,在低地址;内核空间在上,在高地址。对于普通进程来说,内核空间的那部分虽然虚拟地址在那里,但是不能访问。
普通进程视角,整个虚拟地址空间(除了高地址内核区域)都是独占的。但是一旦通过系统调用到了内核里面,无论是从哪个进程进来的,看到的都是同一个内核空间,用的都是同一个内核代码段,同一个内核数据结构区,虽然内核栈是各用个的,但是如果想知道的话,还是能够知道每个进程的内核栈在哪里的
内核只能访问自己的高地址虚拟内存空间,不能访问低地址。因为内核也不知道这个低地址对应的是哪个进程的
分段机制
前面划分虚拟地址的时候,讲过进程将虚拟地址空间划分为代码段数据段等区域,所以对于虚拟地址向物理地址的映射机制,很容易就想到分段
分段机制下的虚拟地址由两部分组成,段选择子和段内偏移量。段选择子就保存在咱们前面讲过的段寄存器里面。段选择子里面最重要的是段号,用作段表的索引。段表里面保存的是这个段的基地址、段的界限和特权等级等。虚拟地址中的段内偏移量应该位于0和段界限之间。如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址。
分页机制
对于物理内存,操作系统把它分成一块一块大小相同的页,这样更方便管理,例如有的内存页面长时间不用了,可以暂时写到硬盘上,称为换出。一旦需要的时候,再加载进来,叫作换入。这样可以扩大可用物理内存的大小,提高物理内存的利用率
虚拟地址分为两部分,页号和页内偏移。页号作为页表的索引,页表包含物理页每页所在物理内存的基地址。这个基地址与页内偏移的组合就形成了物理内存地址。、
如果每一页都需要一个页表项的话,那一个进程的页表将会很大,多个进程将会更大,所以可以采用多级页表的方式来进行多级映射
疑问:
分页机制下,是如何划分代码段、数据段等概念的。如果仅仅只有分页的话,那代码和数据存储在什么位置呢?
所以是不是可以理解为,分页机制仍然要工作在分段的前提下,什么意思呢,就是虚拟地址空间依然要保证连续性,比如这一段都是代码,这一段都是数据,这一段都是堆,这一段都是栈
为什么操作系统不直接使用分段,而是另外使用了分页呢?分段每次要换进换出一大段,不够灵活?
猜测基本都是正确的。下面是完整的解释
首先,在没有分页、分段甚至虚拟内存空间的的情况下,程序直接被一整个装入物理内存,导致下面的问题:
- 地址空间不隔离:程序A会访问到程序B的内存空间
- 程序运行时候的地址不确定:程序每次运行装载到的内存位置可能不固定
- 内存使用率低下:每次只能同时存在有限的程序。
分段 + 虚拟地址空间的出现,解决了前两个问题。但是,由于段的大小也不固定,并且也是一段较大的连续空间,所以会存在碎片问题。因此最终出现了分页机制,程序在逻辑上仍然是分段的(代码段、栈区、堆区等),但是从物理存储的角度来说,整个程序中被分为一页一页来进行存储,这就是段页式的内存映射机制:虚拟内存由段号、页号、页内偏移共同组成
第一步:先通过查找段,例如你访问一个局部变量,那么就去程序的栈段中去找
第二步:找到了栈这个段之后,再根据你这个变量的地址开始对4KB进行取余操作,求出页号,然后在找到你这个数据所存储的页地址
第三步:当找到了指定的页之后,因为地址对4KB取余之后<=4KB,所以再根据取余的结果在指定的页中进行偏移,找到页内偏移地址,最终访问到实际地址
进程空间管理
task_struct中有一个mm_struct引用,它用来维护进程对内存空间的管理。mm_struct中的task_size用来定义虚拟地址下用户空间和内核空间的大小关系
用户态
上面也提到过,进程将用户态的虚拟地址划分为以下区域
内核使用vm_area_struct这个结构来表示不同区域,这里面记录了每个区域的起始和结束地址,以及该区域实际映射到的物理内存,并按照起始地址递增构成一个链表。为了能够快速根据一个地址查到该区域,vm_area_struct还被放入了一颗红黑树中。
虚拟内存区域可以映射到物理内存,也可以映射到文件,映射到物理内存的时候称为匿名映射,anon_vma中,anoy就是anonymous,匿名的意思,映射到文件就需要有vm_file指定被映射的文件。
vm_area_struct的映射关系是在exec的时候建立起来的
vm_area_struct的映射关系建立起来之后,当发生函数调用时,需要移动栈顶指针。当发生malloc时,底层会调用brk或者mmap。如果发现新堆顶小于旧堆顶,这说明不是新分配内存了,而是释放内存了,释放的还不小,至少释放了一页,于是调用do_munmap将这一页的内存映射去掉。如果堆将要扩大,就要调用find_vma。会通过红黑树找到原堆顶所在的vm_area_struct的下一个vm_area_struct,看当前的堆顶和下一个vm_area_struct之间还能不能分配一个完整的页。如果不能,没办法只好直接退出返回,内存空间都被占满了。
如果还有空间,就调用do_brk进一步分配堆空间,从旧堆顶开始,分配计算出的新旧堆顶之间的页数。接下来调用vma_merge,看这个新节点是否能够和现有树中的节点合并。如果地址是连着的,能够合并,则不用创建新的vm_area_struct了,直接更新统计值即可;如果不能合并,则创建新的vm_area_struct,既加到anon_vma_chain链表中,也加到红黑树中。
也就是说,堆这块区域可能存在多个vm_area_struct,这多个vm_area_struct都表示堆,只是指向了不同区域。他们共同存在于链表和红黑树中。所以malloc的时候需要判断要不要新建一个vm_area_struct
内核态
在32位下, 内核态可以使用的虚拟地址空间只有1G,其中由896M被直接映射到物理内存,相当于访问这0-896M虚拟地址等于直接访问0-896M物理地址。内核空间剩下的128M被用来做虚拟地址,可以映射到896M - 4G的物理地址空间,896M以上的物理地址被称为高端内存。假设物理内存里面,896M到1.5G之间已经被用户态进程占用了,并且映射关系放在了进程的页表中,内核vmalloc的时候,只能从分配物理内存1.5G开始,就需要使用这128M的虚拟地址进行映射,映射关系放在专门给内核自己用的页表里面。
task_struct等进程相关的数据结构以及内核的代码等会被创建在3G至3G+896M的虚拟空间中,当然也会被放在物理内存里面的前896M里面,相应的页表也会被创建。
为什么要有直接映射?
内核中的代码和数据结构是所有进程共享的,所以不易被动态映射到不同物理内存导致频繁的换入换出。
为什么不能全部作为直接映射?
32位下,全部作为直接映射的话,只能访问1G的物理内存了,就不能访问全部的物理内存。
64位下,虚拟内存空间只使用了48位来表示地址,寻址范围为 2^48 ,所能表达的虚拟内存空间为 256TB。用户进程空间占用了128T的虚拟地址,内核空间占用128T虚拟地址。其中低 128 T 表示用户态虚拟内存空间,虚拟内存地址范围为:0x0000 0000 0000 0000 - 0x0000 7FFF FFFF F000 。高 128 T 表示内核态虚拟内存空间,虚拟内存地址范围为:0xFFFF 8000 0000 0000 - 0xFFFF FFFF FFFF FFFF 。这样一来就在用户态虚拟内存空间与内核态虚拟内存空间之间形成了一段 0x0000 7FFF FFFF F000 - 0xFFFF 8000 0000 0000 的地址空洞,我们把这个空洞叫做 canonical address 空洞,方便后续扩展
由于虚拟内存空间足够的大,即便是内核要访问全部的物理内存,直接映射就可以了,不在需要用到高端内存那种动态映射方式。
这里我的疑惑主要在于,直接映射区,64T,那岂不是覆盖了市面上几乎所有的物理内存?我觉得这里的64T仅仅是虚拟内存的承受能力,不代表物理内存的承受能力。比如我访问0x FFFF 8000 0000 0000,那这个地址位于直接映射区的开始,会被直接映射到0这个物理内存,这没问题。但是如果我访问0x FFFF 8800 0000 0000,会被映射到64T物理内存。此时如果物理内存没有64T,则会直接报错。
还有一个问题是,既然直接映射区可以映射到所有的物理内存地址,那我的vmalloc这个区域还有什么价值呢?vmalloc区其实是为了malloc,malloc会分配一块连续的虚拟内存地址,而这段连续的地址不一定对应着连续的物理内存地址。如果使用直接映射区,则需要对应一段连续的物理内存地址。
那这就又有了一个问题:vmalloc可以访问到的物理内存地址,会被直接映射区映射到(这不废话吗,所有物理内存都会被这个64T的超大映射区直接映射到)。其实这二者并不影响,因为虚拟地址和物理地址并不是严格的一对一的关系,只要是在同一时刻一对一就行
物理内存管理
前面讲的都是虚拟内存的分区,以及怎么映射的,下面主要讲一下物理内存是如何管理的
CPU访问内存的两种方式:
左侧的总线会成为瓶颈,右侧的内存不是一整块。每个CPU都有自己的本地内存,CPU访问本地内存不用过总线,因而速度要快很多,每个CPU和内存在一起,称为一个NUMA节点。但是,在本地内存不足的情况下,每个CPU都可以去另外的NUMA节点申请内存,这个时候访问延时就会比较长。
NUMA模型:
节点 typedef struct pglist_data pg_data_t:代表一个内存节点
每个节点的内存空间被划分为许多的zone
ZONE_DMA是指可用于作DMA(Direct Memory Access,直接内存存取)的内存
ZONE_NORMAL是直接映射区
ZONE_HIGHMEM是高端内存区
ZONE_MOVABLE是可移动区域
每个zone里面就是存放的页page了
第一种模式,要用就用一整页。这一整页的内存,或者直接和虚拟地址空间建立映射关系,我们把这种称为匿名页(Anonymous Page)。或者用于关联一个文件,然后再和虚拟地址空间建立映射关系,这样的文件,我们称为内存映射文件(Memory-mapped File)
第二种模式,仅需分配小块内存。有时候,我们不需要一下子分配这么多的内存,例如分配一个task_struct结构,只需要分配小块的内存,去存储这个进程描述结构的对象。为了满足对这种小内存块的需要,Linux系统采用了一种被称为slab allocator的技术,用于分配称为slab的一小块内存。它的基本原理是从内存管理模块申请一整块页,然后划分成多个小块的存储池,用复杂的队列来维护这些小块的状态
对于要分配比较大的内存,例如到分配页级别的,可以使用伙伴系统(Buddy System)
Linux中的内存管理的“页”大小为4KB。把所有的空闲页分组为11个页块链表,每个块链表分别包含很多个大小的页块,有1、2、4、8、16、32、64、128、256、512和1024个连续页的页块。最大可以申请1024个连续页,对应4MB大小的连续内存。每个页块的第一个页的物理地址是该页块大小的整数倍。
当向内核请求分配(2(i-1),2i]数目的页块时,按照2^i页块请求处理。如果对应的页块链表中没有空闲页块,那我们就在更大的页块链表中去找。当分配的页块中有多余的页时,伙伴系统会根据多余的页块大小插入到对应的空闲页块链表中。
例如,要请求一个128个页的页块时,先检查128个页的页块链表是否有空闲块。如果没有,则查256个页的页块链表;如果有空闲块的话,则将256个页的页块分成两份,一份使用,一份插入128个页的页块链表中。如果还是没有,就查512个页的页块链表;如果有的话,就分裂为128、128、256三个页块,一个128的使用,剩余两个插入对应页块链表。
总结:如果有多个CPU,那就有多个节点。每个节点用struct pglist_data表示,放在一个数组里面。
每个节点分为多个区域,每个区域用struct zone表示,也放在一个数组里面。
每个区域分为多个页。为了方便分配,空闲页放在struct free_area里面,使用伙伴系统进行管理和分配,每一页用struct page表示。
小内存块缓存:对于缓存来讲,其实就是分配了连续几页的大内存块,然后根据缓存对象的大小,切成小内存块。比如,task_struct_cachep缓存就是专门用于分配task_struct对象的缓存。缓存区中每一块的大小正好等于task_struct的大小,也即arch_task_struct_size。有了这个缓存区,每次创建task_struct的时候,我们不用到内存里面去分配,先在缓存里面看看有没有直接可用的。当一个进程结束,task_struct也不用直接被销毁,而是放回到缓存中
页面换出
每个进程都有自己的虚拟地址空间,无论是32位还是64位,虚拟地址空间都非常大,物理内存不可能有这么多的空间放得下。所以,一般情况下,页面只有在被使用的时候,才会放在物理内存中。如果过了一段时间不被使用,即便用户进程并没有释放它,物理内存管理也有责任做一定的干预。例如,将这些物理内存中的页面换出到硬盘上去;将空出的物理内存,交给活跃的进程去使用。
什么情况下会触发页面换出呢?
可以想象,最常见的情况就是,分配内存的时候,发现没有地方了,就试图回收一下。例如,咱们解析申请一个页面的时候,会调用get_page_from_freelist,接下来的调用链为get_page_from_freelist->node_reclaim->__node_reclaim->shrink_node,通过这个调用链可以看出,页面换出也是以内存节点为单位的。
当然还有一种情况,就是作为内存管理系统应该主动去做的,而不能等真的出了事儿再做,这就是内核线程kswapd。这个内核线程,在系统初始化的时候就被创建。这样它会进入一个无限循环,直到系统停止。在这个循环中,如果内存使用没有那么紧张,那它就可以放心睡大觉;如果内存紧张了,就需要去检查一下内存,看看是否需要换出一些内存页。
mmap
目的:进程想映射一个文件到自己的虚拟内存空间
创建一个新的vm_area_struct对象,将vm_area_struct的内存操作设置为文件系统操作,也就是说,读写内存其实就是读写文件系统。此时还没有真正的访问内存,一旦访问到这个vm_area_struct,触发缺页中断,缺页中断有下面几种情况:
- 页表项从来没出现过
a.该页映射到物理内存:do_anonymous_page
b.该页映射到文件:do_fault - 页表项出现过:do_swap_page
调用do_fault时,由于上面对vm_area_struc做了更改,所以会调用文件系统的do_fault,将文件读入
mmap和普通文件IO的区别
常规文件操作需要从磁盘到页缓存再到用户主存的两次数据拷贝。而mmap操控文件,只需要从磁盘到用户主存的一次数据拷贝过程。说白了,mmap的关键点是实现了用户空间和内核空间的数据直接交互而省去了空间不同数据不通的繁琐过程。因此mmap效率更高。
可重复读下:select for update,相当于是当前都,百分百不会出现幻读,因为会加锁(每一行加读锁,并且加加间隙锁,这样其他事务根本插不进去)
select,相当于是快照读,此时读的是快照。如果当前事务update了其他事务commit的那一行数据,那行数据就会被作为当前事物的快照(Innodb的write committed机制),再次select时就会看见,出现幻读