wordpress链接数据库出错,wordpress搜索优化,影院网站怎么做,学校门户网站建设研究综述x86体系架构
x86是因特尔8086代芯片的CPU总线位数以及寄存器种类的规范#xff0c;大部分操作系统都是以该规范作为基准来生产的
计算机组成 CPU#xff0c;可以根据程序计数器进行取指令操作#xff0c;并根据指令执行运算#xff08;加、减、乘、除#xff09;。运算所…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是通过读取内存中的指令并按照指令操作内存中的数据来完成计算的因此一个操作系统要想能够正常启动必须要有一块区域能够持久化保存代码和数据。而在主板上有一个东西叫ROMRead Only Memory只读存储器上面早就固化了一些初始化的程序也就是BIOSBasic 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上找下一个任务。这样保证了实时任务的优先级永远大于普通任务。
进程上下文切换
TSSTask State Segment即任务状态段。具体的说在设计 “Intel 架构”即 x86 系统结构时每个任务进程or线程都对应有一个独立的 TSS TSS 就是内存中的一个结构体里面包含了 几乎所有的 CPU 寄存器的映像 。 TRTask 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至3G896M的虚拟空间中当然也会被放在物理内存里面的前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区其实是为了mallocmalloc会分配一块连续的虚拟内存地址而这段连续的地址不一定对应着连续的物理内存地址。如果使用直接映射区则需要对应一段连续的物理内存地址。
那这就又有了一个问题vmalloc可以访问到的物理内存地址会被直接映射区映射到这不废话吗所有物理内存都会被这个64T的超大映射区直接映射到。其实这二者并不影响因为虚拟地址和物理地址并不是严格的一对一的关系只要是在同一时刻一对一就行
物理内存管理
前面讲的都是虚拟内存的分区以及怎么映射的下面主要讲一下物理内存是如何管理的
CPU访问内存的两种方式 左侧的总线会成为瓶颈右侧的内存不是一整块。每个CPU都有自己的本地内存CPU访问本地内存不用过总线因而速度要快很多每个CPU和内存在一起称为一个NUMA节点。但是在本地内存不足的情况下每个CPU都可以去另外的NUMA节点申请内存这个时候访问延时就会比较长。
NUMA模型 节点 typedef struct pglist_data pg_data_t代表一个内存节点 每个节点的内存空间被划分为许多的zone ZONE_DMA是指可用于作DMADirect 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时就会看见出现幻读