查看原文
其他

深入理解 Linux 内核之内存寻址

CPP开发者 2023-07-27

推荐关注↓

说明: 本文基于第三版《深入理解 Linux 内核》,该部分以 80x86 处理器为基准进行介绍,并且略过了原文中详细介绍32位扩展分页部分。

目录

一、内存地址


1.1 逻辑地址 (logic address)

在机器语言指令中用来指定一个操作数或一条指令的地址,每一个逻辑地址都由以下两部分组成

  • 段 (segment)指明段位置

  • 偏移量 (offset)指明段开始处到实际地址的距离

1.2 虚拟地址 (virtual address)

根据机器的位数不同而不同,32位机器即32位无符号整数、64位即64位无符号整数,这里取32位为例。

  • 可用于表达 即 4GB 的地址空间

  • 通常用16进制表示,值的范围从 0x00000000 ~ 0xffffffff

1.3 物理地址 (physical address)

内存芯片级的内存单元寻址,从CPU的地址引脚发送到内存总线上的电信号相对应,由 32 位或 36 位无符号整数表示

1.4 内存控制单元 MMU

内存控制单元以下简称MMU,其集成在CPU上进行地址翻译,转换过程为两阶段

  • 分段:由逻辑地址到虚拟地址

  • 分页:由虚拟地址到物理地址

1.5 内存仲裁器 MA

在多核系统中,所有的CPU核心共享同一内存,则代表着CPU可以并发的访问内存。而内存的读写必须是串行执行,所以需要专用元器件对内存访问进行排序,其称为内存仲裁器。

内存仲裁器是在内存总线和RAM芯片之间的硬件电路

  • 若内存空闲:允许访问

  • 若内存被占用:延迟CPU访问

注:由于单处理器上存在一个叫做DMA控制器的特殊处理器,因此其实单处理器上也有内存仲裁器

1.6 分段和分页的意义

分段和分页是用于划分进程的物理地址空间的

  • 分段:每个进程分配不同的虚拟地址空间

  • 分页:把同一虚拟地址空间映射到不同的物理地址

Linux更多使用分页的方式

  • 不同进程共享同一组虚拟地址空间,内存管理简单

  • 跨平台,因为RISC体系结构对分段支持有限


二、内存分段


2.1 硬件分段

2.1.1 实模式和保护模式

从 80286 模型开始,Intel处理器采用两种不同方式进行地址转换,称为实模式(real mode)和保护模式(protected mode)

  • 实模式

    其作用是为维持处理器和早期模型的兼容,因为早期寄存器位数太少,物理地址有20位,最多1MB的内存空间。而段基址寄存器有16位,最多只能访问64kb。为了访问64kb以上的空间,需要对内存进行分段,使用段基址+段偏移的模式寻址。

    通过 物理地址 = 段基址 << 4 + 段内偏移 的方式表示物理地址。这个实模式的 “实” 体现在其反应的是真实物理地址。

    但是由于实模式没有区分代码和数据,如果用户程序的一个指针如果指向了系统程序区域或其他用户程序区域,并修改了内容,那么后果就很可能是灾难性的。

  • 保护模式

随着寄存器硬件的扩展,地址位数和寄存器位数都变成了32/64位,现代CPU已经不需要使用上述实模式了,当然为了兼容老版本所以还是得支持实模式。

同时由于实模式不安全,我们通过一些手段来实现比较安全的寻址,这也是保护模式的命名的由来。

  1. 地址保护:程序内部的地址(虚拟地址)要由操作系统转化为物理地址去访问,程序对此一无所知

  2. 边界保护: 段寄存器中不再储存的是段地址而是段索引。我们将数据放在一个叫做全局描述符表(GDT) 的结构中,其中表项称为段描述符,段描述符存放了段基址、段界限、内存段类型属性,用来索引段地址和标记段边界。

2.1.2 段选择符和段寄存器

  • 段选择符

    逻辑地址 = 段标识符 (16位) + 段偏移量 (32位),我们又将段标识符称为段选择符,其结构如下图所示


    • index 描述符的入口,在2.1.4节中会详细讲解

    • TI (Table Indicator)标明段在GDT还是LDT中,在GDT中为0,LDT中为1

    • RPL 请求特权级,cs寄存器改变时指示出CPU当前特权级

  • 段寄存器

    段寄存器存放段选择符,段寄存器共有 cs,ss,ds,es,fs和gs六个,其作用如图三所示。

    注:cs寄存器中还有一个两位的字段,指明CPU当前特权级别(CPL)0~3,Linux只用0和3,代表内核态和用户态  


2.1.3 段描述符

每个段被一个8字节的段描述符(Segment Descriptor)表示,描述了段的特征。

其放在 全局描述符表(GDT - Global Descriptor Table) 或 局部描述符表 (LDT - Local Descriptor Table) 中

  • 全局描述符表(GDT - Global Descriptor Table)

    - 特点:进程共享
    - 存放:gdtr寄存器(基址+大小)
  • 局部描述符表 (LDT - Local Descriptor Table)

    • 特点:进程独享

    • 存放:ldtr寄存器(基址+大小)

  • 段描述符字段


字段名描述
Base包含段首字节的虚拟地址
G粒度标志,如果为0,则以字节为单位,否则以4096字节的倍数计算
Limit存放段中最后一个内存单元的偏移量,来决定段的长度。G为0则段在1到1MB,否则在4KB到4GB
S系统标志,如果为0,则代表该段为系统段,储存LDT或其他关键数据结构,否则则是普通text或data段
Type描述段的类型特征和存取权限
DPL描述符特权级(DPL)字段;限制对该段的存取。表示访问该段需要的最小 CPL (Linux 0/3)
PSegement-Present标志,0代表不在主存中,Linux总将此设为1,因为整个段一直都会在主存中
D or B取决于是代码段还是数据段
AVL 标志可被操作系统使用,但Linux忽略该标志
  • 描述符段分类

                                         

    • 代表任务状态段(TSS)用于保存寄存器内容,仅在GDT中

    • 代码段描述符

    • 数据段描述符

    • 任务状态段描述符 (TSSD)


2.1.4 快速访问段描述符

  • 段描述符的索引规则:

    段基址 + 段选择符 index [13位] << 3

    因此描述符最大数目为2^13-1

2.1.5 分段单元

图六已经较为清楚的展示了分段单元把逻辑地址转为虚拟地址的过程,段选择符在段寄存器中,offset存储在ip寄存器中

逻辑地址翻译

2.2 Linux 分段


2.2.1 Linux中的段结构

2.6版的Linux只有在 80x86 结构下才进行分段,下图为Linux的分段结构

Linux分段

所有段都从0x00000000开始,所以在Linux下,逻辑地址和虚拟地址相同

相应端选择符由宏 __USER_CS__USER_DS__KERNEL_CS__KERNEL_DS 定义

CPU的CPL存储在 cs 寄存器的 RPL 字段中,特权级别改变,某些段寄存器必须更新

例如当CPL由 3 变为 0 时 ds寄存器必须从含有用户态数据段的段选择符变为含有内核数据段的段选择符,ss类似


2.2.2 Linux GDT

每个CPU对应一个GDT,所有的GDT都存放在 cpu_gdt_table 里,所有的GDT地址和大小被存放在 cpu_gdt_descr数组中。

这些符号在 arch/i386/kernel/head.S 中被定义

每个GDT包含 18个段描述符和14个空的保留项,保留项保证了常用的描述符可以在同一个32字节的 Cache 中,防止 Cache 抖动。

Linux GDT结构

三、内存分页


3.1 硬件分页

分页单元(Paging Unit)是将虚拟地址转化为物理地址

 关键任务:是将所请求的访问类型和虚拟地址访问权限相比较,如果访问无效,则产生缺页异常

 页:一组虚拟地址,又指包含在这组地址中的数据。把RAM分成固定长度的页框(Page Frame)每个页框(结构)包含一个页(数据)

 页表:将虚拟地址映射到物理地址的数据结构

 cr0寄存器:PG标志为0,虚拟地址就解释为物理地址,否则如果 PG = 1 代表启用分页。

3.2 Linux 分页

Linux采用4级分页模式,节省内存空间花费,页表基址寄存器cr3

  • 页全局目录 (Page Global Directory)

  • 页上级目录(Page Upper DIrectory)

  • 页中间目录(Page Middle Directory)

  • 页表 (Page Table)

Linux多级分页

如图所示,虚拟地址翻译过程,其将虚拟地址分为五部分,标准页大小4kb,所以offset占12位,剩下 52 位 每 13 位代表相应目录偏移量,先取出cr3寄存器中页全局目录的基址,和偏移量相加,索引到下级页上级目录的极致,如此重复,直到索引到页表取出 PPN,由于物理地址偏移量和虚拟地址相同,所以直接和虚拟地址偏移量 VPO 拼接得到物理地址.

为什么要使用多级页表来完成映射呢?


用来将虚拟地址映射到物理地址的数据结构称为页表, 实现两个地址空间的关联最容易的方式是使用数组, 对虚拟地址空间中的每一页, 都分配一个数组项. 该数组指向与之关联的页帧, 但这会引发一个问题, 例, IA-32体系结构使用4KB大小的页, 在虚拟地址空间为4GB的前提下, 则需要包含100万项的页表. 这个问题在64位体系结构下, 情况会更加糟糕. 而每个进程都需要自身的页表, 这回导致系统中大量的所有内存都用来保存页表.

设想一个典型的32位的X86系统,它的虚拟内存用户空间(user space)大小为3G, 并且典型的一个页表项(page table entry, pte)大小为4 bytes,每一个页(page)大小为4k bytes。那么这3G空间一共有(3G/4k=)786432个页面,每个页面需要一个pte来保存映射信息,这样一共需要786432个pte!

如何存储这些信息呢?一个直观的做法是用数组来存储,这样每个页能存储(4k/4=)1K个,这样一共需要(786432/1k=)768个连续的物理页面(phsical page)。而且,这只是一个进程,如果要存放所有N个进程,这个数目还要乘上N! 这是个巨大的数目,哪怕内存能提供这样数量的空间,要找到连续768个连续的物理页面在系统运行一段时间后碎片化的情况下,也是不现实的。

假设每个进程都占用了4G的线性地址空间,页表共含1M个表项,每个表项占4个字节,那么每个进程的页表要占据4M的内存空间。为了节省页表占用的空间,我们使用两级页表。每个进程都会被分配一个页目录,但是只有被实际使用页表才会被分配到内存里面。一级页表需要一次分配所有页表空间,两级页表则可以在需要的时候再分配页表空间,而Linux根据实际情况(64位CPU,内存大小,查询效率),选择4级页表


3.2.1 分页机制的优势

虚拟地址到物理地址的自动转换使得下述设计目标变得现实

  • 给每个进程分配不同的物理地址空间,防止寻址错误

  • 区别页和页框不同,允许页被装入不同的页框中,是虚拟内存机制的基本要素

每个进程有自己的页全局目录和页表集合,每次进行进程上下文切换时,Linux内核把前一个进程的cr3寄存器值存入在前一个进程的进程描述符中,并载入新进程的全局目录基址进入cr3寄存器中。

3.2.2 进程页表

进程的虚拟内存空间被分为两部分

  • 用户态寻址部分:0x00000000 ~ 0xbfffffff

  • 内核态寻址部分:0xc0000000 ~ 0xffffffff

进程运行在用户态时,其产生的线性地址小于 0xc0000000,在内核态则随意

3.2.3 内核页表

内核有自己的一组页表,存放在主内核页全局目录中。主内核页全局目录的最高目录项部分作为参考模型,为系统中每个普通进程对应的页全局目录项提供参考模型。

作者:ZiYang-Xie

链接:https://xcraft.tech/2021/07/10/OS/MemAddressing

- EOF -

推荐阅读  点击标题可跳转

1、一个内核网络漏洞详解:容器逃逸

2、用户态 tcpdump 如何实现抓到内核网络包的?

3、Linux 中的各种栈:进程栈 线程栈 内核栈 中断栈


关注『CPP开发者』

看精选C/C++技术文章

点赞和在看就是最大的支持❤️

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存