IA-32/Linux下逻辑地址-线性地址-物理地址

CPU内部的存储器就是寄存器

分层存储器存在的意义:让存储器又快又大

存储器越靠近CPU速度越大,容量越小

局部性原理达到分层的效果

英特尔32位架构(英语:Intel Architecture, 32-bit,缩写为IA-32),常被称为i386、或x86,由英特尔公司于1985年推出的指令集架构。它是8086架构的延伸版本,可支持32位运算,首次应用在Intel 80386芯片中。

IA-32架构属于复杂指令集,由英特尔公司开发,1985年,随着Intel 80386的上市,被公之于世。接下来20年的时间,虽然后继的新型芯片运算速度不断增加,但IA-32架构大体上都没有改变。对许多编程语言来说,IA-32与i386是同义词。

英特尔也是世界上最大的IA-32芯片供应商,AMD则是第二大的供应商。2011年,英特尔与AMD同时采用了新的x86-64架构,但是x86架构仍然被应用在如Intel Atom(N2xx与Z5xx系列)、AMD Geode等芯片上。威盛电子生产的VIA C3/C7,也仍然采用IA-32架构。

IA-32微处理器支持实模式和保护模式。

实模式

相当于高性能的16位8086微处理器,但进行了功能扩充,能够使用8086所没有的寻址方式和32位通用寄存器以及大部分指令。不具有保护机制,不能使用部分特权指令。实模式下只有20条地址线有效,存储空间为1MB。

保护模式

充分发挥IA-32微处理器的存储管理功能和硬件支持的保护机制,为多任务操作系统设计提供支持。该模式下每个任务的存储空间为4GB。

在保护模式下还具有一种子模式——虚拟8086模式(V86模式),可以在保护模式的多任务环境中以类似实模式的方式运行16位8086软件。

按字节编址(通用计算机大都是)

保护模式下,IA-32采用段页式虚拟存储管理方式

逻辑地址和线性地址是虚拟地址,是编程用的地址;描述的都是4GB虚拟地址空间中的一个存储地址。

物理地址是访问存储器的地址,是真实地址。

逻辑地址:48位,包含16位段选择符和32位段内偏移量(即有效地址

线性地址:32位(其位数由虚拟地址空间大小决定)

32位处理器,所以虚拟成32位

物理地址:32位(其位数由存储器总线中的地址线条数决定)

对于物理地址:(实际的不知道)假设就是32位,那外面的物理存储器可能就是4GB(2^32B=4GB)

分段过程实现 逻辑地址 —> 线性地址

分页过程实现 线性地址—>物理地址

逻辑地址到线性地址(分段)

8086处理器的所有内部寄存器都是16位(AX, BX, CX, DX, SP, BP, SI, CS, DS

(CS、DS是段寄存器)

8086处理器支持的存储器寻址方式有哪些?

寻址方式 说明
位移 EA = A
基址寻址 EA = (B)
基址加位移 EA = (B) + A
比例变址加位移 EA = (I) * S + A
基址加变址加位移 EA = (B) + (I) + A
基址加比例变址加位移 EA = (B) + (I) * S + A

A — 地址段偏移量

B — 基址寄存器

I — 变址寄存器(除SP)

S — 比例因子

EA — 有效地址

8086指令(AT&T格式)举例:

1
movw 8(%bp, %dx, 4), %ax   //R[ax] <-- M[R[bp] + R[dx]*4 + 8]

此时的寻址空间是 2^16B = 64KB,太小了!

于是8086引入段寄存器开辟更大的寻址空间:

16位段寄存器(CS, SS, DS, ES等)

物理访存地址 = (段寄存器 << 4) + 有效地址

于是寻址空间变为 1MB了(2^16<< 4 B = 2^20 B = 1MB)

此时的物理地址称为线性地址

于是就变成了:

寻址方式 说明
位移 LA = (SR << 4) + A
基址寻址 LA = (SR << 4) + (B)
基址加位移 LA = (SR << 4) + (B) + A
比例变址加位移 LA = (SR << 4) + (I) * S + A
基址加变址加位移 LA = (SR << 4) + (B) + (I) + A
基址加比例变址加位移 LA = (SR << 4) + (B) + (I) * S + A
1
movw [ds:8(%bp, %dx, 4)], %ax   //R[ax] <-- M[R[ds]<<4 + R[bp] + R[dx]*4 + 8]

访问数据段就是和DS绑定的,访问代码段就是和CS绑定的。取指令要用到CS和IP,访问堆栈用到SS,等等。

物理访存地址 = (段寄存器 << 4) + 有效地址—这个访问模式称为实模式!

实模式

8086处理器在实模式下工作:

物理地址直接访存

可访问 1MB 主存空间

需要20根地址线(2^20,因为加上了(SR>>4))

每次访存必须和某个段寄存器绑定

存在的问题:

寻址空间有限

存在安全隐患

IA-32,即80386,标志着32位计算机的时代到来。

所有通用寄存器都是32位,寻址空间达到4GB!

正式支持虚拟存储器的概念,采用虚拟地址访存。

寻址空间已经达到4GB,是否可以去掉段寄存器?

不能。考虑兼容性!

于是IA-32处理器支持两种工作模式:

实模式:IA-32处理器加电或复位时处于这一模式,此时相当于8086/8088处理器,32位地址线中的A31~A20不起作用,所有访存地址都是物理地址(实地址)。

保护模式:完成系统初始化后,进入该模式,此时32位地址线全部起作用,访存地址为逻辑地址(虚拟地址),进入虚拟存储器管理方式。

保护模式

IA-32有一个”开关”,决定处理器处于哪种模式下:

CR0寄存器与通用寄存器不同,是另外一种寄存器。它叫控制状态寄存器。

对实模式和保护模式来说看哪一位呢?

最后一位。(第0位,即PE位)

计算机加电或复位时,PE = 0,IA-32处理器处于实模式。

PE = 1时,处于保护模式,并且一旦进入保护模式就不能再切换回到实模式了,除非…重启(重新开机复位)。

(所以只能从实模式进保护模式)

为什么IA-32分段机制更复杂?

与历史遗留问题有关:

寻址空间有限,仅1MB.

存在安全隐患。

段大小固定(64KB(2^16 B))

不灵活,无法设置访问权限。

……

进入IA-32时代,我们希望段地址也是32位的,还可以灵活设置各种段属性,但段寄存器只有16位,连32位段基址也放不下,怎么办?

段描述符

段描述符是用来描述一个段所有属性的数据结构。

每个段对应一个段描述符。

image-20201225173007530

一个段描述符占8个字节(64位),包括:

B31~B0:32位基地址

L19~L0:20位限界,表示段的大小

G:粒度。G = 1以页(4KB)为单位,G = 0以字节为单位。因为界限为20位,故当G = 0时最大的段为1MB;当G = 1时,最大段为4KB*2^20=4GB。

D:D = 1表示段内偏移量为32位宽,D = 0表示段内偏移量为16位宽。

P:present. P = 1表示段在主存里,P = 0表示段不在主存里。Linux总把P置1,不会以段为单位淘汰,因为Linux以页为单位。

S:S = 0表示系统控制描述符,S = 1表示普通的代码段或数据段描述符。

TYPE:段的访问权限或系统控制描述符类型。

A:A = 1表示已被访问过,A = 0表示未被访问过(TYPE一般是4位,A是其中的1位)

DPL:权限位。

段描述符的组织

(Q represents question, P represents preccrep)

Q:段描述符占64位,段寄存器才16位,根本放不下,怎么办?

P:放到主存中。

Q:可是怎么在主存中找到一个段描述符?

P:利用指针。

Q:IA-32中涉及到地址的,还是32位。所以段描述符的地址也一定是32位的,段寄存器放不下,只能放到主存里。IA-32中的指针也是32位的,段寄存器还是放不下;即使能放下,如果想切换到其他段,怎么知道段描述符在什么地方?

P:把所有的段描述符组织成一个数组啊。

Q:

P:段描述符只是一个数据结构,一旦找到段描述符,就能把段的基地址读出来,然后就能访问到相应的段了,问题就解决了。所以关键是怎么找到段描述符。没错,段描述符的地址一定是32位地址,因为它们是放在主存里的。在IA-32里,我们把所有段描述符都组成一个数组,数组索引用16位总能放下吧!用数组索引访问!就是——

IA-32把内存中的某一连续空间解释成一个数组,称为段描述符表,简称段表。数组中每个元素对应一个段描述符。

段表由OS负责填写。包括3种类型:

全局描述符表(GDT):只有一个,是所有进程共享的,用来保存系统中每个任务都可以访问的段描述符,如内核代码段、数据段,用户代码段、数据段等。

局部描述符表(LDT):存放某一用户进程专用的描述符(其他用户进程是访问不到的),但LDT不是一个独立的段表,它就保存在GDT中,甚至可以看作是GDT里的一个段描述符。

中断描述符表(IDT):独立于GDT的段表,包含中断门、陷阱门等描述符。例如系统调用的函数入口地址就要从IDT中获取。

GDT的首地址由全局描述符表寄存器(GDTR)提供。

GDTR这个寄存器只存放GDT的入口地址!

还有,段表不是一个大数组吗,我们是通过数组索引找到所需的描述符的,而该索引不就保存在段寄存器中吗,它称为段选择符

GDTR

GDTR的结构长这样:

BASE ADDR(基地址) | LIMIT(限界)

32-bit | 16-bit

高32位存放GDT的入口地址,低16位存放GDT的大小。

当然,GDTR最大也就64位。这里是48位。

Q:GDTR中保存的地址是线性地址,为什么不是逻辑地址?

P:我们分段机制用它(GDTR)不就是为了把逻辑地址变为线性地址吗。要是GDTR中也是逻辑地址,那这个首地址(GDT的首地址)谁来给它变?

GDTR对用户进程不可见,只有OS才能访问。仅可由OS内核通过一条特权指令(lgdt m16&32)将GDT的首地址和限界装载到GDTR中,启动分段机制。

段选择符和段寄存器

CS(代码段):程序代码所在段

SS(栈段):栈区所在段

DS(数据段):全局静态数据区所在段

其他3个段寄存器ES、GS和FS可指向任意数据段

段选择符各字段含义

15 14 … 3 | 2 | 1 0

INDEX | TI | RPL

TI = 0,选择GDT;TI = 1,选择LDT。

RPL请求特权级。

高13位索引用来确定当前使用的段描述符在描述表中的位置。

——> 由此可知,GDT中最多可容纳 2^13=8192=8K 个段描述符。

那么整个段表多大?(1个段描述符占8个字节)

8K*8 B = 64KB.

而段寄存器是16位,2^16 = 64K个字节——刚好相等呀!

没错,16位就是这么来的。

于是,回归正题,逻辑地址怎么变到线性地址?

先看TI位。若TI = 0,那就先从GDTR里把GDT首地址读出来,然后加上索引——注意,是索引乘上8!乘上字节的宽度!基地址+索引*8.

GDT和IDT只有一个,GDTR和IDTR指向各自起始处。

LDTR 16-bit

GDTR, IDTR 48-bit

每次段寄存器装入新选择符时,新描述符装入描述符cache,在逻辑地址到线性地址转换时,MMU直接用描述符cache中的信息,不必访问主存段表

LDTR存放LDT描述符的段选择符

LDT描述符在GDT中

image-20201225200258987

总之,逻辑地址转线性地址的过程就是这样:

  1. 逻辑地址高16位是段选择符,低32位是段内偏移。
  2. 通过段寄存器中的段选择符TI位决定在哪个表中查找。
  3. 根据GDTR读出段描述符表的首地址。
  4. 根据段寄存器中的段选择符index位在表中进行索引,找到一个段描述符。
  5. 在段描述符中读出段的基地址,和逻辑地址相加,得到线性地址。

在计算线性地址的过程中,可根据段描述符中的限界和访问权限判断是否“地址越界”或“访问越权”,以实现存储保护。

Q:索引 为什么乘以8?

P:1个段描述符占8个字节。

Q:被选中的段描述符存放在什么地方?

P:描述符cache里。

总结

  1. 逻辑地址起作用的前提:CR0的PE位为1,进入保护模式,启动分段机制。
  2. 根据段寄存器中的段选择符的TI位决定查GDT还是LDT
  3. 在GDTR中读出GDT首地址
  4. GDT首地址+段选择符中索引*8 — 得到段描述符首地址
  5. 在段描述符中读出段基址,和有效地址(逻辑地址后32位,即段内偏移)相加,得到线性地址

注意那个前提——否则实模式下有效地址就是线性地址,段寄存器也就用来左移4位加上有效地址得到物理地址了。

IA-32/Linux中的存储保护

IA-32的权限检查(基于环保护机制)

DPL:位于段描述符中,表示一个段所在的特权级别。例如,DPL为3说明该段可能是一个用户段,为0可能是内核段。

RPL:位于段选择符中,表示请求者所在的特权级别。

CPL:表示当前进程的特权级别,一般与CS寄存器指向的段描述符的DPL字段相同。

同时满足以下两个条件:

1
2
target_descriptor.DPL >= requestor.RPL   //请求者有权访问目标段
target_descriptor.DPL >= CPL //当前进程有权访问目标段

只要OS将GDT、页表等重要信息放在ring0段中,恶意程序将永远无法篡改它们,除非恶意程序获得了OS权限。

IA-32/Linux中的分段机制

为能被移植到绝大多数流行处理器平台,Linux简化了分段机制。

RISC对分段支持非常有限,因此Linux仅使用IA-32的分页机制,而对于分段,则通过在初始化时将所有段描述符的基址设为”0”来简化

每个段都被初始化在 0~4GB 的线性地址空间中。

PA2中的用户程序都是从 kernel/src/start.S 开始的。PA3也是如此。

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
67
68
69
70
71
72
#include "common.h"

#ifndef IA32_SEG

.globl start
start:
# Set up a stack for C code.
movl $0, %ebp
movl $(128 << 20), %esp
jmp init # never return

#else

# To understand macros here, see i386 manual.
#define GDT_ENTRY(n) ((n) << 3)

#define MAKE_NULL_SEG_DESC \
.word 0, 0; \
.byte 0, 0, 0, 0

# The 0xC0 means the limit is in 4096-byte units
# and (for executable segments) 32-bit mode.
#define MAKE_SEG_DESC(type,base,lim) \
.word (((lim) >> 12) & 0xffff), ((base) & 0xffff); \
.byte (((base) >> 16) & 0xff), (0x90 | (type)), \
(0xC0 | (((lim) >> 28) & 0xf)), (((base) >> 24) & 0xff)

#ifdef IA32_PAGE
# define KOFFSET 0xc0000000
# define va_to_pa(x) (x - KOFFSET)
#else
# define va_to_pa(x) (x)
#endif

.globl start
start:
lgdt va_to_pa(gdtdesc) # See i386 manual for more information
movl %cr0, %eax # %CR0 |= PROTECT_ENABLE_BIT
orl $0x1, %eax
movl %eax, %cr0

# Complete transition to 32-bit protected mode by using long jmp
# to reload %CS and %EIP. The segment descriptors are set up with no
# translation, so that the mapping is still the identity mapping.
ljmp $GDT_ENTRY(1), $va_to_pa(start_cond)

start_cond:
# Set up the protected-mode data segment registers
movw $GDT_ENTRY(2), %ax
movw %ax, %ds # %DS = %AX
movw %ax, %es # %ES = %AX
movw %ax, %ss # %SS = %AX

# Set up a stack for C code.
movl $0, %ebp
movl $(128 << 20), %esp
jmp init # never return

# GDT
.p2align 2 # force 4 byte alignment
gdt:
MAKE_NULL_SEG_DESC # empty segment
MAKE_SEG_DESC(0xA, 0x0, 0xffffffff) # code
MAKE_SEG_DESC(0x2, 0x0, 0xffffffff) # data

gdtdesc: # descriptor
.word (gdtdesc - gdt - 1) # limit = sizeof(gdt) - 1
.long va_to_pa(gdt) # address of GDT

# end of IA32_SEG
#endif

现在来逐段分析:

1
#ifndef IA32_SEG

这个是分段的宏,PA2里没考虑分段,也就没用到,所以直接跳到内核里去了。但是在PA3中,这个宏就要起作用了。在 kernel/include/common.h 中设置,把 define IA32_SEG 前的注释符号去掉。

除了 #define 中的 # 外,其他的 # 都是注释!

1
2
3
4
5
6
.globl start
start:
# Set up a stack for C code.
movl $0, %ebp
movl $(128 << 20), %esp
jmp init # never return

PA2没用分段(即没用 IA32_SEG 宏,not define,满足 #ifndef),就执行上面的部分;

PA3用了分段,就执行 #else 的部分.

1
2
3
#define MAKE_NULL_SEG_DESC   \
.word 0, 0; \
.byte 0, 0, 0, 0

这个宏是将 MAKE_NULL_SEG_DESC 替换为下面那两个东西…

.word.byte 就是汇编里的声明数据。声明为 .word 就是说那两个0都是 .word 类型,也就是16位宽,所以这里声明了两个16位的0。.byte 声明的是字节,相当于C语言中的 char 类型。2个16位的加上4个8位的,一共就是8个字节。所以——

这就是一个段描述符!只不过这是一个空的段描述符,因为都是0.

1
2
3
4
#define MAKE_SEG_DESC(type,base,lim)                        \
.word (((lim) >> 12) & 0xffff), ((base) & 0xffff); \
.byte (((base) >> 16) & 0xff), (0x90 | (type)), \
(0xC0 | (((lim) >> 28) & 0xf)), (((base) >> 24) & 0xff)

这个宏还带参数的啊—— typebaselim ——这不就是段描述符里的信息吗?

1
2
3
4
5
6
#ifdef IA32_PAGE
# define KOFFSET 0xc0000000
# define va_to_pa(x) (x - KOFFSET)
#else
# define va_to_pa(x) (x)
#endif

定没定义PAGE,自己决定用哪个函数。

最核心的部分:

1
2
3
4
5
6
.globl start
start:
lgdt va_to_pa(gdtdesc) # See i386 manual for more information
movl %cr0, %eax # %CR0 |= PROTECT_ENABLE_BIT
orl $0x1, %eax
movl %eax, %cr0

再来看这个——lgdt ,将全局描述符表的首地址和大小加载到了GDTR里。这里 va_to_pa 只有1个参数 gdtdesc ,这是一个地址,而由 #define va_to_pa(x) (x) 得知,这不就是 gdtdesc 本身吗。那这到底是什么东西呢,看下面:

1
2
3
gdtdesc:                      # descriptor
.word (gdtdesc - gdt - 1) # limit = sizeof(gdt) - 1
.long va_to_pa(gdt) # address of GDT

嗯,不就是把 gdtdesc 里面的 .word.long 加载过去吗? .word 是16位的, .long 是32位的。它们是什么注释里都写清楚了,要强调的是 gdtdesc - gdt - 1 的这个减一。

当然,再看看这个 gdtdesc ,不就刚好是48位吗?没错,lgdt 就是把48位的逻辑地址加载到GDTR中去。

继续看start的代码。先把CR0的值放到EAX里,然后EAX与1进行OR,再把得到的值放回CR0。我们分析一下,当 “CR0最低位” = 0时,这一番操作使得 “CR0最低位” = 1,这就是实模式 —> 保护模式。

线性地址到物理地址(分页)

IA-32中的控制寄存器

控制寄存器保存机器的各种控制和状态信息,操作系统进行任务控制或存储管理时使用控制寄存器。

CR0:PG位:

1 - 启用分页

0 - 禁止分页,此时线性地址被直接作为物理地址使用。

若启用分页机制,PE和PG位都要置1.

CR2:页故(page fault)障线性地址寄存器

当要访问的页不在主存里时,触发了缺页中断,中断处的线性地址保存在CR2里,等到页面被调入后再继续从这里开始执行。

CR3:页目录基址寄存器

保存页目录表的起始地址。

当然,CR2和CR3有效的前提都是PG = 1。

Linux中线性地址空间划分:4GB = 1K个子空间 1K个页面/子空间 4KB/页

image-20201226101431856

每一个页表就是一个子空间,页表中有1K项,每一项对应一个物理页;

页目录表只有1张,里面有1K个页目录项,每一项对应一个页表。

每1K个页面用一个页表进行组织,每个页表有1K个项,每项对应一个物理页(页框)

线性地址由3个字段组成,分别是10位页目录索引,10位页表索引,12位页内偏移

CR3中存放的就是页目录表的基地址,拿到基地址后加上DIR*4(为什么乘以4?因为一个页目录项是32位,即4字节),得到相应页表项的首地址。再加上PAGE*4,得到物理页的首地址。物理页首地址再加上页内偏移量,就是真正的物理地址。

页目录项和页表项的格式

image-20201226105427190

CPU发出地址先送到MMU,MMU完成分段和分页再送到cache。MMU中的分段解决逻辑地址到物理地址的变换,分页解决线性地址到物理地址的变换。

虚拟/逻辑/线性/物理地址到底是什么

虚拟地址

指的是由程序产生的由段选择符段内偏移地址两个部分组成的地址。为什么叫它是虚拟的地址呢?因为这两部分组成的地址并没有直接访问物理内存,而是要通过分段地址的变换机构处理或映射后才会对应到相应的物理内存地址。

逻辑地址

指由程序产生的与段相关的偏移地址部分。不过有些资料是直接把逻辑地址当成虚拟地址,两者并没有明确的界限。

线性地址

指的是虚拟地址到物理地址变换之间的中间层,是处理器可寻址的内存空间(称为线性地址空间)中的地址。程序代码会产生逻辑地址,或者说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。如果启用了分页机制,那么线性地址可以再经过变换产生物理地址。若是没有采用分页机制,那么线性地址就是物理地址。

物理地址

指的是现在CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果。

参考来源