- Published on
笔记01-深入理解Linux内核-章2内存寻址
- Authors

- Name
- i Joe
内存地址
逻辑地址
组成方式:【段选择符:偏移量】
段选择符,也称段标识符。段选择符:16位,偏移量:32位
指定一个操作数或一条指令地址。
线性地址
即虚拟地址
物理地址
即实际地址
内存控制单元(MMU)通过分段单元,把逻辑地址转换为线性地址,再用分页单元将线性地址转换为物理地址
链接
分段
硬件中的分段
段选择符和段寄存器
分为:cs, ss, ds, es, fs和gs。后三个没有强制指定,一般来说。
- cs:代码段寄存器,指向包含程序指令的段。
- ss:栈段寄存器,指向包含当前程序栈的段。
- ds:数据段寄存器,指向包含静态数据或者全局数据段。
- es:字符串
- fs:线程
- gs:结构体
一般指令中根据需求会显式隐式提供,例如:mov eax, cs:[0x8040000],如果是隐式,一般默认ds,例如:mov [0x8040000], eax,其中的ds:0x8040000指的就是逻辑地址。
段选择符是16位,结构如下:
链接
综上,段寄存器中的值就是段选择符。对于寄存器,如DS的值是由操作系统在进程创建和上下文切换时设置的,并且通常在整个进程生命周期中保持不变。
段描述符
而段描述符是由8个字节表示,存在全局描述符表(GDT)和局部描述符表(LDT)中。具体是哪一个由段选择符中TI决定,TI=0则段描述符在GDT中,TI=1在LDT中。 段描述符组成如下:
链接
- Base(24-31):包含段的首字节的线性地址。
- G():粒度标志:如果该位清0,则段大小以字节为单位,否则以4096字节的倍数计。
- Limit:存放段中最后一个内存单元的偏移量,从而决定段的长度。如果G被置为0,则一个段的大小在1个字节到1MB之间变化;否则,将在4KB到4GB之间变化
- S:系统标志:如果它被清0,则这是一个系统段,存储诸如LDT这种关键的数据结构,否则它是一个普通的代码段或数据段
- Type:描述了段的类型特征和它的存取权限(请看表下面的描述)
- DPL:描述符特权级(Descriptor Privilege Level)字段:用于限制对这个段的存取。它表示为访问这个段而要求的CPU最小的优先级。因此,DPL设为0的段只能当CPL为0时(即在内核态)才是可访问的,而DPL设为3的段对任何CPL值都是可访问的
- P:Segment-Present标志:等于0表示段当前不在主存中。Linux总是把这个标志(第47位)设为1,因为它从来不把整个段交换到磁盘上去
- D或B:称为D或B的标志,取决于是代码段还是数据段。D或B的含义在两种情况下稍微有所区别,但是如果段偏移量的地址是32位长,就基本上把它置为1,如果这个偏移量是16位长,它被清0
- AVL标志:可以由操作系统使用,但是被Linux忽略
假如描述符字段如下:
字节0: Limit 7:0 = 0x45
字节1: Limit 15:8 = 0x23
字节2: Base 7:0 = 0x56
字节3: Base 15:8 = 0x34
字节4: Base 23:16 = 0x12
字节5: Access = 0xF2 (P=1, DPL=3, S=1, E=0, ED=0, W=1, A=0)
字节6: Flags & Lim = 0xC1 (G=1, D=1, L=0, AVL=0, Limit 19:16=0x1)
字节7: Base 31:24 = 0x00
完整64位描述符: 0x00C1F212345612345
快速访问段描述符
一般从逻辑地址->线性地址->物理地址,每次都需要分段,浪费时间。
为了快速访问段描述符:程序运行时,段寄存器基本不变,描述符缓存持续有效。只有上下文切换时或者段寄存器被修改,描述符缓存更新。这种设计在保证安全隔离的同时,最大化性能。
分段单元
逻辑地址->线性地址的步骤:
- 先检测描述符中的TI。
- 计算描述符地址,index*8.
- 再取出GDT或LDT中的描述符
- 将偏移量和描述符中base相加得到线性地址
Linux中的分段
链接
GDT
链接
LDT
大多数用户态下的Linux程序不使用局部描述符表,但在某些情况下,进程仍然需要创建自己的局部描述符表。这对有些应用程序很有用。
分页
硬件中的分页
cr寄存器
- cr0:模式控制寄存器
PE:是否启用保护模式
PG:是否启用分页模式
WP:是否启用写保护 - cr2:页故障线性地址寄存器
发生页错误时,将其线性地址存到cr2寄存器中 - cr3:页目录基址寄存器
由于页大小为4kb,低12位用于标志位,高20位用于页目录的物理地址 - cr4:扩展功能寄存器(高级特性开关)
PSE:页面大小扩展(启用4MB大页)
PAE:物理地址扩展(让32位系统支持超过4GB物理内存)
PGE:全局页面支持
SMEP:管理模式执行保护
SMAP:管理模式访问保护
常规分页
链接
cr3保存了页目录的物理地址,然后再从线性地址前10位找到页表的物理地址,再从21-12位找到页表中的页的地址,再用偏移量+地址就是实际的物理地址。
页目录和页表中的标志位
标志位都是由操作系统和cpu控制,一般在进程被创建时就决定,后面也有可能被修改。
- P (Present):页面是否在物理内存中,如:发生缺页,则将线性地址存入cr2寄存器中
- R/W (Read/Write):读写权限
- U/S (User/Supervisor):用户/管理员权限
- A (Accessed):是否被访问过
- D (Dirty):是否被写入过
- PCD/PWT:缓存控制
- PAGE SIZE:只设用于页目录,为1时,页目录为4MB的页框。
扩展分页
扩展分页,是将页框大小设为4MB,意味着,偏移地址达到22位
链接
物理扩展机制(PAE)
为了将RAM从4GB扩展到64GB,引入了PAE,此时,4kb的页框,共有224个,页表的物理地址需要从20位扩展到24位。所以PAE下的页表从32位扩展到了64位,其中28位暂时保留。
传统分页 vs PAE分页:
传统32位: CR3 → 页目录(1024项) → 页表(1024项) → 4KB页
PAE分页: CR3 → PDPT(4项) → 页目录(512项) → 页表(512项) → 4KB页
但由于线性地址只有32位,虽然页表达到了36位。实际上,用户态只能使用最大4GB的空间,但内核态可以达到64GB,后面分析如何整的。
高速缓存
由于RAM和CPU之间频率差过大,中间就加了一共缓存,之间的缓存单位为缓存行,如下图
链接
高速缓存缓存了缓存行,而分页单元记录缓存行的地址,在cpu找数据时,会先通过高速缓存控制器,拿物理地址与分页单元中的高位地址做对比,如果对比上了,就直接取高速缓存中的缓存行的数据。
对缓存行做读操作,不会通知ram。但写操作,存在两种策略。
- 通写:修改高速缓存时,同时修改ram值。
- 回写:修改高速缓存时,不修改ram值,只有cpu要求时,或者flush触发才会修改。
如果对两个cpu,其中一个的高速缓存修改了缓存行值,会通知其他cpu更新。
链接
转换后援缓冲器(TLB)
相当于,记录线性地址到物理地址的表,每个CPU都有一个独立的TLB。
Linux中的分页
链接 每一个进程都有自己的页全局目录和页表集,当发生进程切换时,linux会把下一个进程的描述符的值放在cr3中。
线性地址字段
PAGE_SHIFT:
- 指定offest的位数。如:12位
- 被PAGE_SIZE使用。如:212
- 生成,PAGE_MASK。如:0xFFFF F000
PMD_SHIFT:
- 指定offest+table的总位数,也是页中间项对应大小的映射对数。
- PMD_SIZE、PMD_MASK同上。
- 当PAE未激活时,值为22,或31(大页);反之值为21,或30(大页)
- LARGEPAGE*:大页,也是页尺寸,但是不使用最后一级页表,所以LARGE_PAGE_SIZE=PMD_PAGE_SIZE。
PUD_SHIFT:
- 上级目录能映射的区域大小对数,即:mid dir + offest+table。
- PUD_SIZE、PUD_MASK同理。
- 80x86的PUD_SHIFT=PWD_SHIF**T。
PGDIR_SHIFT:
- 全局的映射区域对数,即up dir+mid dir+table+offest。
- PAE被禁用时,PGDIR_SHIFT=PMD_SHIFT=PUD_SHIFT。
- PAE被激活时,PGDIR_SHIFT=30(12位Offset+9位Table+9位Middle Air) PTRS_PER_PTE, PTRS_PER_PMD, PTRS_PER_PUL以及PTRS_PER_PGD:
- 分别用于计算页表、页中间目录、页上级目录和页全局目录表中表表项的个数。
- PAE禁用时,1024,1,1,1024。
- PAE激活时,512,512,1,4。
页表处理
pte_t、pmd_t、pud_t和pgd_t分别描述页表项、页中间目录项、页上级目录和页全局项类型。pgprot_t则表示标志位类型。
五个类型转换宏(**pte、**pmd、pud、pgd和pgd和pgpirot)把一个无符号整数转换对应类型。后面主要说pte。
- pte_val:反向转换宏。
- pte_none:表示响应表项值为0时,值为1,反之为0。
- pte_clear:清除对应表项值。
其他:
- pte_same(a, b):对比两个页表,指向同一页,同时指定相同访问优先级,此时返回1。
- pud_bad 和 pgd_bad :总是返回 0,表示无需检查错误。
- pte_bad :未定义,因为页表项引用不在主存、不可写或无法访问的页是合法行为。
- pte_present :如果页表项的 Present 标志或 PageSize 标志为 1,则返回 1(表示页在主存),否则为 0。PageSize 标志对硬件分页单元无意义,但内核利用它结合 Present=0 来表示页在主存但无权限,从而触发缺页异常,并通过检查 PageSize 来区分异常原因。
- pmd_present :如果表项的 Present 标志为 1,则返回 1(表示页或页表已载入主存)。
- pud_present 和 pgd_present :总是返回 1。
- 标志查询函数:除
pte_file()外,其他函数只有在 pte_present 返回 1 时才能正确读取页表项标志。
......太多了,详见原书
物理内存布局
在初始化阶段,内核必须建立一个物理地址映射来指定哪些物理地址范围对内核可用而哪些不可用。
一般来说,Linux内核安装在RAM中从物理地址0x00100000开始的地方,也就是第二个MB开始。
链接
进程页表
进程的线性地址分为两个部分:
- 0x0000 0000到0xBFFF FFFF用于用户态或内核态的进程。
- 0xC000 0000到0xFFFF FFFF只能用于内核态。
内核页表
内核维持着一组自己使用的页表,驻留在所谓的主内核页全局易目录(master kernel Page Global Directory)中。系统初始化后,这组页表还从未被任何进程或任何内核线程直接使用;更确切地说,主内核页全局目录的最高目录项部分作为参考模型,为系统中每个普通进程对应的页全局目录项提供参考模型。
第一个阶段,内核创建一个有限的地址空间,包括内核的代码段和数据段、初始页表和用于存放动态数据结构的共128KB大小的空间。这个最小限度的地址空间仅够将内核装入RAM和对其初始化的核心数据结构。
第二个阶段,内核充分利用剩余的RAM并适当地建立分页表。下一节解释这个方案是怎样实施的。
临时内核页表
临时页全局目录放在swapper_pg_dir变量中。临时页表在pg0变量处开始存放,紧接在内核未初始化的数据段后面。为简单起见,我们假设内核使用的段、临时页表和128KB的内存范围能容纳于RAM前8MB空间里。为了映射RAM前8MB的空间,需要用到两个页表。
分页第一个阶段需要保证实模式和保护模式下都能寻址,所以将0和0x300置为pg0的物理地址,1和0x301置为pg0下一个地址。(这里的0x300指第301个4MB,因为页表大小为4MB)
接下来设置标志位:Present(存在位):1,表示页表项有效。Read/Write(读写位):1,表示可读写。User/Supervisor(用户/超级用户位):这里设置为0,超级用户才能访问。
接下来,汇编语言startup_32()初始化,启用分页单元,通过向cr3控制寄存器装入swapper_pg_dir的地址及设置cr0控制寄存器的PG标志来达到这一目的。
当RAM小于896MB最终内核页表
链接
实际上,32位物理地址足以对896MB的RAM进行寻址。
当RAM大于896MB并小于4096MB最终内核页表
简单来说就是动态重映射,不再直接映射,间接的同时,有新的就需要替换旧的。
当RAM大于4096MB最终内核页表
使用PAE,和前面不同的是,使用了三级分页。
页全局目录前三项与用户空间地址相对于,内核用一个空页地址对这三项初始化。第四项,由页中间目录初始化,页中间目录一共512项,其中448项由896MB的地址填充,后64项提供于非连续分配内存地址。然后再将第四项拷贝到第一项中,作为镜像以用于初始化。
固定映射的线性地址
内核线性地址初始部分,映射系统的物理内存,而后至少128MB用作非连续内存和固定映射的物理内存。
固定映射物理内存看作为常量线性地址,每个固定映射的线性地址都由定义于enumfixed_addreesses数据结构中的整型索引来表示:
链接
固定映射物理内存都是从第4GB的线性地址末端开始,但物理地址可以是任意一个。fix_to_virt(idx)会计算出,从引索开始对应的常量地址。set_fixmap和set_fixmap_nocache会将固定的线性地址和一个物理地址联系起来。但第二个会将对应的PCD标志位会置位,即禁用高速缓存。
硬件高速缓存
由前得知硬件高速缓存是缓存行,内核会对其优化:
- 将常用的数据放在低偏移处。
- 有分配内存时,将这组数据尽可能分配到连续相近的内存下。
由于处理器会自动处理硬件高速缓存,所以内核不会处理,但提供了刷新接口。
TLB
处理器不能对TLB进行同步,因为决定线性地址到物理地址的是内核。 内核提供了很多刷新接口,flush_tlb_*。但处理器上会执行一些使TLB失效的汇编语言指令。(注:刷新和失效都是同一个操作,只是其中一个为过程,其中一个为结果)
链接
最常见会使TLB失效的事件:
- 中断
- 进程切换:本地页表项必须刷新;这个过程会在内核将新的页全局目录存放在cr3时自行执行。(本地TLB是指,由于有多级缓存,一般L1,L2由一个cpu所有,此为本地,而L3会由多个CPU共享,此为非本地)
- 当用户态新分配物理地址,并新增到页表时,必须刷新TLB,包括所有cpu共享的页表。
由于当某个cpu的页表被修改会涉及到其他cpu,可能会出现问题,所以就有新的机制,懒惰tlb。
懒惰TLB
处于懒惰TLB模式的cpu,不会立即刷新TLB,只有在不同页表集时,当切换到普通进程时,硬件会自动刷新,并置位非懒惰TLB模式。如果在同一个页表集下,切换普通进程时(即:cr3不变),内核会干预,强制刷新cpu中非全局的TLB。
数据结构上,cpu_tlbstate有两个字段:active_mm:指向当前进程的内存描述符。cpu_tlbstate数组:记录每个CPU的TLB状态(正常/懒惰模式)。
每个内存描述符包含一个cpu_vm_mask:记录需要接收TLB刷新中断的CPU集合。
当一个CPU开始执行内核线程时,内核把该CPU的cpu_tlbstate元素的state字段置为TLBSTATE_LAZY;此外,活动(active)内存描述符的cpu_vm_mask字段存放系统中所有CPU(包括进入懒惰TLB模式的CPU)的下标。
当一个cpu需要使TLB无效时,会下发给这些cpu。而当一个cpu接收到这个刷新中断时,会判断是否处于懒惰TLB模式,如果处于就无视掉,并删除cpu_vm_mask中的该cpu。
