Linux 内存管理(1):分段(segment)

基础概念

逻辑地址

指程序中看到的地址,之后我们会看到,在Intel的分段架构中,每个逻辑地址都由一个 段(segment)加上一个偏移量(offset)组成。 对于每一个进程来说,它看到的都是一整个虚拟内存空间,它并不知道实际情况是很多个 进程共用一块小得多的物理内存。实现这种效果的技术,就是内存管理。

线性地址

或者说是线性地址空间,表示CPU能够寻址的整个地址空间。通常情况下线性地址空间 比物理内存大。对于32位系统,线性地址空间就有4G大小。

物理地址

就是真实的内存,通过芯片控制存储和读取,显然空间有限。CPU通过前端总线( Front Side Bus)连接到北桥(Northbridgh),再连接到内存条。在FSB中交换的内存 地址都是真实的内存地址。

程序使用逻辑地址,操作内存使用的是物理地址,所以必须把逻辑地址转换为物理地址。 主要的转换流程如下图(来自 Memory Translation and Segmentation ):

硬件中的分段–处理器怎么访问物理内存

分段(Segmentation)

Intel处理器以两种不同的方式进行地址转换:实模式(real-mode)和保护模式( protected-mode)。一般情况下处理器工作在protected-mode,real-mode存在的原因一是 与早期的处理器兼容,二是在系统启动(自举)的过程中会使用到。

历史

原始的8086芯片具有16位的寄存器,于是它可以使用2^16=64K字节的内存。为了使用更多 的内存,Intel引入了“段寄存器(segment register)”,该寄存器的作用就是告诉CPU 到底应该使用哪个64K的“段”。段寄存器中指示了一个64K段的起始地址,于是程序中的16 位地址就相当于一个偏移(offset)。一共有4个段寄存器,一个给Stack使用(ss),一个 给程序代码使用(cs),还有两个给程序数据使用(ds, es)。

现在

段寄存器在现在仍然在使用,其中存着16位的Segment selectors。 其使用的方法在real-mode和protected-mode下不太一样。

real-mode

在real-mode下,段寄存器中的内容需要标识一个段的起始地址。基于硬件成本等的考虑, Intel使用段寄存器中的4位,这样就能分出2^4=16个64K的段,一共就是1M。

protected-mode

在32位的protected-mode下,段寄存器中的内容叫做段选择符(segment selectors), Segment selector代表了一个叫做“段描述符(segment descriptors)”的表的索引。 这个表实际上就是一个数组,每个元素有8个字节,每个元素(segment descriptor)表示 一个段。Segment descriptor的结构如下图:

其中,

  • Base Address中的32位地址代表了这个段的起始地址
  • Limit表示这个段有多大
  • DPL代表段描述符的等级,控制对段的访问。可以是0到3的数字。0代表最高等级(内核态),3代表最低等级(用户态)

这些段描述符被存在两个表里面:GDT(Global Descriptor Table)和LDT (Local Descriptor Table)。每个CPU核都有一个叫做gdtr的寄存器,里面放着GDT的 地址。所以在16位的段寄存器中的内容(segment selector)就有如下的结构:

其中,

  • TI代表是GDT或者是LDT
  • Index代表这个段在表中的索引
  • PRL指Requested Privilege Level

总结下来就是,CPU通过段寄存器存储segment selector,通过segment selector在GDT表 或者LDT表中找到对应的segment 的segment descriptor,获取到这个段的Base Address、 大小等信息。如图:

途中的“Noprogrammable Reigster”表示一组非编程寄存器,它存储segment selector指定 的segment descriptor,这样不用每次都去查GDT,能够更快地进行逻辑地址到线性地址的 转换。

把Base Address和逻辑地址(logical address)相加,就得到了线性地址(linear address) 。也就是说,程序使用的是逻辑地址,经过分段机制,CPU把逻辑地址转换成了线性地址 (linear address),再经过分页机制,最后得到了物理地址。

整个过程可以通过下图表示:

分段单元(segment unit)执行的过程如下:

  • 从段寄存器获取segment selector
  • 根据segment selector的内容从GDT或者LDT中获取segment descriptor,GDT的位置由 gdtr寄存器存储
  • 根据segment descriptor获取到这个段的Base Address,把Base Address和逻辑地址 (logical address)相加,就得到了线性地址(linear address)。

Flat Mode

这里就有个问题:如果寄存器还在是16位的,那么每个程序的逻辑地址就只有64K大小。 但我们又想使用更大的物理内存,于是使用了分段机制,让逻辑地址加上了一个段地址, 得到一个线性地址,代表某个段中的内存,最后在通过分页机制转换为物理地址。但是 在32位CPU中,寄存器和指令本身就能对整个线性地址进行寻址,为什么 还要做这个分段呢?直接把Base Address置为0不就好了,这样逻辑地址和线性地址实际上 就相等了。事实上的确可以,Intel把这种模式叫做flat model,这种模式也正是内核所 使用的。

地址转换概览

下面的图代表了一个用户态中的程序,发出一个JMP命令时发生的情况:

Linux主要使用的就是4个段:用户态下的数据段和代码段,内核态下的数据段和代码段。 运行过程中,每个CPU内核都有自己的GDT。所以主要有4个GDT:两个给内核态的 code和data用,另外两个给用户态下的code和data使用。

值得注意的是,在GDT中的数据都是以cache line的大小对齐的。

底层的权限和等级

介绍

这里所指的权限和等级(privilege)不是root和普通用户这些等级,而是对系统底层资源 的权限控制。所谓底层资源,主要包括三种:内存、I/O端口、对特定指令的执行权限。

Intel架构中,包括了4个等级,用数字0到3代替,0的等级最高,3的等级最低。在任意一 个时刻,CPU都运行在某个等级中,这就限制了CPU不能做某些事情。

对于Linux内核以及大多数其它内核,事实上只使用了两个等级:0和3。

大概有15个指令处于等级0中,比如对内存和I/O端口的操作相关的指令。 试图在其他等级运行这些指令,比如当程序试图操作不属于它的内存时。就会导致一些错 误。

正是因为有这些限制,用户态的程序不能直接对内存、I/O等进行操作,而只能通过系统 调用实现。

CPU如何知道运行于哪个等级下

通过前面的讨论,我们知道,在段寄存器中存着叫做“段选择器(segment selector)”的 内容,里面有个字段叫RPL或者CPL:

  • RPL,Requested Privilege Level,对于数据段寄存器ds或者stack段寄存器ss。RPL 的内容不能被mov等指令修改,而只能通过那些对程序运行流程进行修改的指令,比如 call等, 来进行设置。
  • CPL,Current Privilege Level,对于代码段寄存器cs。前面说到,RPL可以通过代码 进行修改,而CPL的内容被CPU自己维护,它总是等于CPU当前的等级。

所以,在任何时候,通过查看cs的CPL,就可以知道CPU工作在哪个等级下。

CPU对内存的保护

当一个段选择器(segment selector)加载到寄存器,或者当通过线性地址访问一页内存 时,CPU都会对内存进行保护,其保护的原理如下图:

MAX()选择RPL和CPL中最小的一个等级,把它和段描述符(segment descriptor)中的DPL进 行比较,当大于DPL时(即当前的CPU的等级CPL或者需要的等级RPL小于这个段的等级时) ,就触发错误。

但由于现在的内核都是使用的是flat模式,意味着用户态的段可以使用整个线性地址 空间,所以CPU对内存真正的保护体现在分页,即线性地址转换为物理地址的时候。 内存以页为单位进行管理,每个页由页表项(page table entry)进行描述,PTE中有 两个字段和保护有关系:一个是supervisor flag,另一个是read/write flag。 当supervisor flag被标记时,这页的内容就不能在等级3下进行访问。

Linux的fork使用“写时复制”技术,当子进程被fork出来时,父进程的内存页通过 read/write flag被标记为只读,并且和子进程共享,当任何一个进程试图修改其内容时, 就触发一个错误来通知内核,复制一份内容并标记为可可读/写。

等级的切换通过中断机制进行。


参考资料: