字数 7225,阅读大约需 37 分钟
第四章:80x86保护模式及编程
4.1 概述
在踏上Linux内核学习之旅前,你需要理解x86处理器的保护模式。保护模式是80386及后续处理器提供的一种高级运行模式,它为操作系统提供了内存保护、多任务支持和虚拟内存等现代操作系统必需的硬件基础。
Linux 0.12内核完全运行在保护模式下,深入理解保护模式的工作原理,对于我们后续分析内核的引导过程、内存管理、进程调度等核心机制至关重要。本章将带你系统学习x86保护模式的各个方面,从架构演进到具体实现,从分段机制到分页机制,从特权级保护到Linux内核中的实际应用。
4.2 x86架构的演进历程
x86架构的发展历程如同一座城市从小镇发展为国际大都市的过程。1978年Intel推出的8086处理器开启了x86架构的序幕,那时的处理器只能寻址1MB内存,工作在简单的实模式下。随着应用程序规模的增长和多任务需求的出现,单一的实模式已经无法满足需求,就像一个小镇的基础设施无法支撑大城市的运行一样。
1985年Intel推出80386处理器,这是x86架构史上的里程碑。80386不仅将地址总线扩展到32位(可寻址4GB内存),更重要的是引入了保护模式,提供了分段、分页、多任务和特权级保护等一系列现代操作系统必需的硬件机制。这就好比城市建设中引入了高楼大厦、地铁系统和分区规划,使得城市能够容纳更多人口并提供更好的服务。
图 4.1 x86 架构演进历史80386的设计哲学体现了向后兼容性和创新性的完美平衡。处理器启动时仍然工作在实模式下(为了兼容8086程序),但可以通过设置控制寄存器切换到保护模式。
4.3 实模式与保护模式的对比
实模式下,处理器就像生活在一个开放式的单层空间里,所有程序共享同一个物理地址空间,任何程序都可以访问任何内存位置,没有保护机制。这种简单直接的设计在单任务环境下工作良好,但在多任务环境中会导致灾难性的后果——一个程序的错误可能破坏其他程序甚至整个系统。
保护模式则引入了多层次的隔离和保护机制。程序运行在各自的"房间"里(地址空间),有明确的"门禁"(特权级),系统管理员(操作系统内核)拥有最高权限,而普通用户程序只能在受限的环境中运行。这种设计保证了系统的稳定性和安全性。
下表详细对比了实模式和保护模式的关键特性:
实模式下的地址计算非常简单直接。例如,当CS=0x1000,IP=0x0050时,物理地址=0x1000×16+0x0050=0x10050。这种计算方式就像在平面地图上用坐标定位,简单但有限制。
图 4.2 实模式地址计算保护模式下的地址转换则复杂得,涉及段选择符、段描述符表、页目录和页表等多个层次。段寄存器不再直接存储段地址,而是存储段选择符,这个选择符作为索引在段描述符表中查找段的详细信息(基地址、长度、权限等)。这种间接方式虽然复杂,但提供了灵活的保护机制和更大的寻址空间。
图 4.3 保护模式地址转换4.4 分段机制详解
分段机制是保护模式下内存管理的第一层抽象。想象一座大型图书馆,如果把所有书籍随意堆放,查找会非常困难。分段机制就像把图书馆划分为文学区、科学区、艺术区等不同区域,每个区域有自己的管理规则和访问权限。
在保护模式下,整个内存空间被划分为多个段,每个段有自己的基地址、长度限制和访问权限。段的信息存储在段描述符(Segment Descriptor)中,而所有段描述符组织成段描述符表(Descriptor Table)。系统中有两类段描述符表:全局描述符表(GDT,Global Descriptor Table)和局部描述符表(LDT,Local Descriptor Table)。GDT对所有任务可见,通常存储系统级别的段描述符;LDT属于特定任务,存储任务私有的段描述符。
4.4.1 段描述符结构
段描述符是一个8字节的数据结构,包含了段的所有重要信息。其结构相当精巧,将32位的基地址、20位的段限长、以及各种属性位紧凑地组织在一起。这种设计就像在有限的空间里精心安排各种信息,既节省空间又便于硬件快速解析。
图 4.4 段描述符段描述符的关键字段包括:
基地址(Base Address,32位)定义了段在线性地址空间中的起始位置。这个地址被分散存储在描述符的不同位置,硬件会自动将其组合成完整的32位地址。
段限长(Segment Limit,20位)定义了段的大小。实际段大小的计算取决于粒度位(G位):如果G=0,段限长以字节为单位,最大1MB;如果G=1,段限长以4KB为单位,最大4GB。这种设计巧妙地解决了用20位表示4GB大小的问题,就像用千米和米两种单位可以表示不同尺度的距离。
特权级(DPL,Descriptor Privilege Level,2位)指定了访问该段需要的最低特权级。这是分段保护机制的核心,就像建筑物的不同楼层需要不同级别的门禁卡。
类型字段(Type,4位)指示段的类型和访问权限。段可以是代码段或数据段,代码段可以是可读的或只执行的,数据段可以是可写的或只读的。这些细粒度的权限控制确保了程序只能以预期的方式访问内存。
4.4.2 段选择符
段选择符(Segment Selector)是一个16位的值,存储在段寄存器中,作为访问段描述符的索引。它的结构包含三部分:13位的索引(Index)、1位的表指示符(TI)和2位的请求特权级(RPL)。
图 4.5 段选择符索引字段指定了段描述符在描述符表中位置。由于每个描述符8字节,13位索引可以索引8192个描述符,对应64KB的描述符表。表指示符(TI)决定使用GDT(TI=0)还是LDT(TI=1)。请求特权级(RPL)与特权级检查相关,后面会详细讨论。
当程序通过段选择符访问内存时,处理器执行以下步骤:首先根据TI位选择GDT或LDT,然后用索引值在表中定位段描述符,接着检查访问权限(比较CPL、RPL和DPL),如果权限检查通过,从描述符中获取段基地址,最后将段基地址加上偏移地址得到线性地址。这个过程虽然看起来复杂,但由于硬件支持,执行速度非常快。
4.4.3 全局描述符表(GDT)
GDT是保护模式下最重要的数据结构之一,它就像系统的总目录,列出了所有全局可见的段。GDT存储在内存中的某个位置,GDTR(GDT Register)寄存器存储了GDT的基地址和长度。描述符表有两种类型:全局描述符表 (GDT)和局部描述符表 (LDT)
图 4.6 全局描述符表和局部描述符表在Linux 012中,GDT的设置是在引导过程中完成的。让我们看看实际代码:
.org 0x1000_pg_dir:startup_32: movl $0x10,%eax mov %ax,%ds mov %ax,%es mov %ax,%fs mov %ax,%gs lss _stack_start,%esp call setup_idt call setup_gdt movl $0x10,%eax # reload all the segment registers mov %ax,%ds # after changing gdt. CS was already mov %ax,%es # reloaded in 'setup_gdt' mov %ax,%fs mov %ax,%gs lss _stack_start,%esp xorl %eax,%eax1: incl %eax # check that A20 really IS enabled movl %eax,0x000000 # loop forever if it isn't cmpl %eax,0x100000 je 1b
这段代码在内核进入保护模式后首先设置GDT。setup_gdt函数加载新的GDT,然后重新加载所有段寄存器,确保它们使用新的GDT中的段选择符。这个过程就像搬进新房后重新标记所有房间的门牌号。
GDT中通常包含以下几种关键的段描述符:空描述符(索引0,必须存在),内核代码段描述符,内核数据段描述符,用户代码段描述符,用户数据段描述符,以及任务状态段(TSS)描述符。Linux内核采用了扁平内存模型,代码段和数据段的基地址都设为0,段限长都设为4GB,这样整个4GB地址空间都可以访问。这种设计简化了内存管理,主要依靠分页机制实现内存保护。
4.5 分页机制详解
如果说分段机制是对内存的粗粒度划分(如同城市的分区),那么分页机制就是细粒度划分(如同分区内的具体地块)。分页机制将线性地址空间和物理内存都划分为固定大小的页(Page),标准页大小为4KB。这种固定大小的设计简化了内存管理算法,就像用标准化的集装箱简化了物流管理。
分页机制的核心作用是实现虚拟内存。通过分页,每个进程都可以拥有独立的4GB虚拟地址空间,而物理内存可以远小于这个数值。操作系统根据需要动态地将虚拟页映射到物理页,将不常用的页换出到磁盘,需要时再换入。这就像一个魔术师的帽子,看起来很小,却能变出无穷无尽的东西。
4.5.1 两级页表结构
80386采用两级页表结构来实现地址转换。这种设计平衡了转换速度和内存开销。如果用单级页表,映射4GB地址空间需要1M个页表项(4GB/4KB),占用4MB内存。而两级页表结构只在需要时才创建二级页表,大大节省了内存。
两级页表结构包括页目录(Page Directory)和页表(Page Table)。页目录是顶层结构,包含1024个页目录项(PDE,Page Directory Entry),每个页目录项指向一个页表。每个页表也包含1024个页表项(PTE,Page Table Entry),每个页表项指向一个4KB的物理页。这种结构使得完整的页表系统可以映射4GB地址空间(1024×1024×4KB=4GB)。
CR3寄存器(也称为页目录基地址寄存器)存储当前页目录的物理地址。当进程切换时,操作系统会更新CR3寄存器,使其指向新进程的页目录,从而实现进程地址空间的切换。这个机制就像切换不同的地图册,每个进程都有自己的地图。
线性地址到物理地址的转换过程如下:首先将32位线性地址分为三部分:高10位作为页目录索引(DIR),中间10位作为页表索引(TABLE),低12位作为页内偏移(OFFSET)。处理器从CR3寄存器获取页目录基地址,用DIR索引页目录得到页表地址,用TABLE索引页表得到物理页地址,最后将物理页地址加上OFFSET得到最终的物理地址。
图 4.7 线性地址转换(4KB 页)4.5.2 页表项结构
页目录项和页表项的结构相似,都是32位(4字节)。虽然只有4字节,但设计得相当精巧,不仅存储了地址信息,还包含了多个控制位。
物理页地址字段占高20位,因为页对齐到4KB边界(低12位为0),所以只需要存储高20位。这20位与12位页内偏移组合,可以表示完整的32位物理地址。
图 4.8 4KB页的页目录项和页表项格式Present位(P位)指示页是否在物理内存中。如果P=0,访问该页会触发页错误异常,操作系统可以借此从磁盘加载页面(这是虚拟内存的基础)。就像图书馆的书可能在架上(P=1)或被借出(P=0),借出的书需要召回才能阅读。
Read/Write位(R/W位)指定页的读写权限。R/W=0表示只读,R/W=1表示可读可写。这个机制用于实现写时复制(Copy-On-Write)等高级特性。
User/Supervisor位(U/S位)指定页的访问特权级。U/S=0表示特权页(只有内核可访问),U/S=1表示用户页(用户程序也可访问)。这是保护内核空间的关键机制。
Accessed位(A位)由硬件自动设置,指示页是否被访问过。操作系统可以利用这个位实现页面替换算法(如LRU算法)。
Dirty位(D位)由硬件自动设置,指示页是否被写入过。这个位帮助操作系统优化页面换出——只有脏页需要写回磁盘。
下表总结了页表项的各个字段:
4.5.3 地址转换示例
让我们通过一个具体例子来理解地址转换过程。假设线性地址为0x12345678,我们来计算对应的物理地址。
首先分解线性地址:DIR=0x123(高10位),TABLE=0x145(中10位),OFFSET=0x678(低12位)。
假设CR3=0x10000(页目录在物理地址0x10000)。处理器访问页目录:地址=0x10000+0x123×4=0x1048C,读取该地址处的页目录项,假设内容为0x20001(P=1,页表地址=0x20000)。
接着访问页表:地址=0x20000+0x145×4=0x20514,读取该地址处的页表项,假设内容为0x30003(P=1,R/W=1,物理页地址=0x30000)。
最后计算物理地址:物理地址=0x30000+0x678=0x30678。
二级页表地址转换结构图示例: 线性地址 0x12345678 → 物理地址 0x30678┌─────────────────────────────────────────────────────────┐│ 线性地址 Linear Address ││ 0x12345678 ││ 31.............22│21............12│11............0│└─────────────────────────────────────────────────────────┘ │ │ │ ┌──────▼─────┐ ┌──────▼─────┐ ┌──────▼─────┐ │ DIR字段 │ │ TABLE字段 │ │ OFFSET字段 │ │ 0x123 │ │ 0x145 │ │ 0x678 │ │ (页目录索引)│ │ (页表索引) │ │ (页内偏移) │ └────────────┘ └─────────────┘ └─────────────┘ │ │ │ ▼ │ │┌──────────────────────────────────┼─────────────────┘│ CR3寄存器 (PDBR): 0x10000 │└──────────────────────────────────┘ │ ▼┌─────────────────────────────────────┐│ 页目录 Page Directory │├─────────────────────────────────────┤│ PDE地址 = CR3 + DIR × 4 ││ = 0x10000 + 0x123 × 4 ││ = 0x1048C ││ ││ PDE内容: 0x20001 ││ • P=1 (存在) ││ • 页表地址=0x20000 │└─────────────────────────────────────┘ │ ▼┌─────────────────────────────────────┐│ 页表 Page Table │├─────────────────────────────────────┤│ PTE地址 = 页表地址 + TABLE × 4 ││ = 0x20000 + 0x145 × 4 ││ = 0x20514 ││ ││ PTE内容: 0x30003 ││ • P=1 (存在) ││ • R/W=1 (可写) ││ • 物理页地址=0x30000 │└─────────────────────────────────────┘ │ ▼┌─────────────────────────────────────┐│ 物理地址 Physical Address │├─────────────────────────────────────┤│ 物理地址 = 物理页地址 + OFFSET ││ = 0x30000 + 0x678 ││ = 0x30678 ││ ││ 最终物理地址: 0x30678 │└─────────────────────────────────────┘
这个过程虽然涉及两次内存访问(查页目录和查页表),但由于TLB(Translation Lookaside Buffer,地址转换后备缓冲器)的存在,实际性能影响很小。TLB是一个小型的高速缓存,存储最近使用的地址转换结果。就像人们记忆常用的电话号码而不是每次都查电话簿,TLB缓存了常用的地址转换,大大提高了访问速度。
4.6 特权级与保护机制
特权级机制是保护模式的核心特性之一,它建立了一个多层次的安全体系,确保关键系统资源受到保护。80386定义了4个特权级(Privilege Level),编号从0到3,0级是最高特权级(也称为内核态或超级用户态),3级是最低特权级(也称为用户态)。这种设计就像建筑物的门禁系统,CEO可以进入任何房间,而普通员工只能进入有限的区域。
图 4.9 特权级Linux内核只使用了两个特权级:0级(内核空间)和3级(用户空间)。这种简化使得系统设计更加清晰,也符合大多数操作系统的需求。内核代码运行在0级,拥有完全的硬件访问权限;用户程序运行在3级,只能访问受限的资源,需要通过系统调用来请求内核服务。
4.6.1 特权级检查
处理器在以下几种情况下会进行特权级检查:访问数据段时,执行跳转或调用指令时,通过门描述符进行调用时。特权级检查涉及三个特权级值:当前特权级(CPL,Current Privilege Level)、请求特权级(RPL,Requestor Privilege Level)和描述符特权级(DPL,Descriptor Privilege Level)。
图 4.10 数据访问权限检查CPL存储在CS和SS寄存器的低两位,表示当前正在执行的代码的特权级。通常情况下,CPL等于当前代码段的DPL。当通过门描述符或任务切换改变特权级时,CPL会随之改变。
RPL存储在段选择符的低两位,表示发起访问请求的程序的特权级。RPL允许内核代码代表用户程序访问数据时,仍然受到用户程序特权级的限制。这就像公司里的秘书虽然可以接触机密文件,但代表普通员工传递信息时,只能访问普通员工有权访问的内容。
DPL存储在段描述符中,表示访问该段需要的最低特权级。访问数据段时,max(CPL, RPL) <= DPL必须满足。这个规则意味着只有足够高的特权级才能访问该段。
对于代码段,规则稍有不同。非一致性代码段(Conforming=0)要求CPL=DPL,即只能在相同特权级间进行调用。一致性代码段(Conforming=1)允许从低特权级调用高特权级代码,但调用后CPL不变,这种机制较少使用。
4.6.2 特权级转换
特权级转换是操作系统中的关键操作,通常发生在系统调用、中断处理和任务切换时。从低特权级转到高特权级需要通过特殊的门描述符(Gate Descriptor),包括调用门(Call Gate)、中断门(Interrupt Gate)和陷阱门(Trap Gate)。这些门就像受控的通道,确保特权级转换在安全的条件下进行。
以系统调用为例,用户程序执行int 0x80指令触发中断,处理器查找中断描述符表(IDT),找到0x80号中断的中断门描述符。中断门描述符包含目标代码段选择符(指向内核代码段,DPL=0)和偏移地址(指向系统调用处理程序)。
处理器检查权限:当前CPL(用户态,CPL=3)是否允许通过该门(检查门的DPL),如果检查通过,处理器执行特权级转换。这包括保存当前的SS和ESP(用户栈指针),切换到内核栈(从TSS中获取),将用户态的SS、ESP、EFLAGS、CS、EIP压入内核栈,将CPL改为0(内核态),跳转到中断处理程序执行。
系统调用处理完毕后,使用iret指令返回用户态。iret从栈中恢复EIP、CS、EFLAGS、ESP、SS,CPL恢复为3,返回到用户程序继续执行。这个过程就像员工通过特定的通道进入管理层办公室办理业务,办完后又返回自己的工作区域。
图 4.11 不同权限级别访问调用门的示例4.7 Linux 0.12中的保护模式实现
Linux 0.12充分利用了80386的保护模式特性,但在设计上采取了简化策略。内核使用扁平内存模型,所有段的基地址都设为0,段限长都设为4GB。这样做的好处是简化了地址计算,主要依靠分页机制来实现内存保护和虚拟内存。
4.7.1 GDT的设置
让我们看看Linux 0.12如何设置GDT。在引导过程中,head.s调用setup_gdt函数:
setup_idt: lea ignore_int,%edx movl $0x00080000,%eax movw %dx,%ax /* selector = 0x0008 = cs */ movw $0x8E00,%dx /* interrupt gate - dpl=0, present */ lea _idt,%edi mov $256,%ecxrp_sidt: movl %eax,(%edi) movl %edx,4(%edi) addl $8,%edi
这段代码设置了中断描述符表(IDT),为每个中断向量创建中断门。所有中断最初都指向ignore_int处理程序(简单地返回)。随后在内核初始化过程中,各个中断处理程序会被正确设置。
GDT在head.s中定义:
gdt: .quad 0x0000000000000000 /* NULL descriptor */ .quad 0x00c09a0000000fff /* 16Mb */ .quad 0x00c0920000000fff /* 16Mb */ .quad 0x0000000000000000 /* TEMPORARY - don't use */ .fill 252,8,0 /* space for LDT's and TSS's etc */.word 0idt_descr: .word 256*8-1 # idt contains 256 entries .long _idt.align 4
这个GDT定义了四个初始描述符:空描述符(必须存在),内核代码段(基地址0,限长16MB,可执行可读),内核数据段(基地址0,限长16MB,可读可写),临时描述符(未使用)。注意这里段限长设为16MB(0xfff,粒度为4KB),这是引导阶段的临时设置。后续在内核完全初始化后,会重新设置GDT,将段限长扩展到4GB。
4.7.2 分页的初始化
分页机制在head.s中启用。首先创建页目录和第一个页表:
movl $pg0+7,_pg_dir /* set present bit/user r/w */ movl $pg1+7,_pg_dir+4 /* --------- " " --------- */ movl $pg2+7,_pg_dir+8 /* --------- " " --------- */ movl $pg3+7,_pg_dir+12 /* --------- " " --------- */ movl $pg3+4092,%edi movl $0xfff007,%eax /* 16Mb - 4096 + 7 (r/w user,p) */ std1: stosl /* fill pages backwards - more efficient :-) */ subl $0x1000,%eax jge 1b xorl %eax,%eax /* pg_dir is at 0x0000 */ movl %eax,%cr3 /* cr3 - page directory start */ movl %cr0,%eax orl $0x80000000,%eax movl %eax,%cr0 /* set paging (PG) bit */ ret /* this also flushes prefetch-queue */
这段代码做了以下工作:首先在页目录中设置前4个页目录项,分别指向pg0、pg1、pg2、pg3四个页表,映射前16MB物理内存。然后填充页表项,每个页表包含1024个页表项,映射4MB内存。将页目录地址(0x0000)加载到CR3寄存器。最后设置CR0寄存器的PG位,启用分页机制。
启用分页后,所有地址都经过地址转换。由于采用了恒等映射(线性地址=物理地址),这个转换对执行中的代码是透明的。内核后续会为用户进程创建独立的页表,实现进程地址空间隔离。
4.7.3 段页式结合
Linux采用段页式结合的内存管理方式。分段机制提供了基本的保护框架(内核态和用户态的隔离),而分页机制提供了细粒度的内存管理和虚拟内存支持。这种结合充分利用了硬件提供的保护机制,同时保持了设计的简洁性。
图 4.13 分段和分页在实际运行中,每次内存访问都经过"逻辑地址→线性地址→物理地址"的两次转换。逻辑地址由段选择符和偏移地址组成,通过分段机制转换为线性地址,线性地址再通过分页机制转换为物理地址。虽然转换过程看起来复杂,但由于硬件优化(TLB缓存、流水线处理等),性能影响非常小。
4.8 本章总结
本章系统学习了80x86保护模式的各个方面。我们从x86架构的发展历程开始,理解了从实模式到保护模式的演进动机。实模式简单直接但缺乏保护机制,保护模式虽然复杂但提供了现代操作系统所需的各种硬件支持。
分段机制将内存划分为具有不同属性和权限的段,通过段描述符表和段选择符实现灵活的内存管理和访问控制。全局描述符表(GDT)是系统的核心数据结构,存储了所有重要段的描述符。
分页机制实现了虚拟内存,使得每个进程都可以拥有独立的地址空间。两级页表结构平衡了转换速度和内存开销,页表项中的各种标志位支持了写时复制、按需分页等高级特性。
特权级机制建立了多层次的安全体系,通过CPL、RPL和DPL的协同工作,确保只有授权的代码才能访问关键资源。特权级转换通过门描述符进行,保证了转换的安全性。
Linux 0.12充分利用了保护模式的特性,采用简化的扁平内存模型,主要依靠分页机制实现内存保护。内核的引导过程展示了GDT和分页机制的实际设置方法。
掌握了保护模式的知识,我们就拥有了理解Linux内核的钥匙。在后续章节中,我们将看到这些机制如何在内核的各个子系统中发挥作用——进程管理如何利用TSS和页表实现进程切换,内存管理如何利用分页机制实现虚拟内存,系统调用如何通过中断门实现用户态到内核态的转换。这些知识就像建筑的地基,虽然隐藏在地下,却支撑着整个系统的运行。
本章思考
- 1. 为什么80386要设计4个特权级,而Linux只使用了其中的2个?这种设计选择反映了怎样的权衡考虑?在什么情况下使用更多特权级会更有优势?
- 2. 段页式结合的内存管理方式相比纯分页或纯分段有什么优势?如果让你设计一个新的处理器架构,你会如何平衡简洁性和功能性?
- 3. 页表项中的Accessed位和Dirty位由硬件自动维护,这种设计有什么好处?如果由软件维护会有什么问题?这反映了硬件和软件协同设计的什么原则?
参考资料
- • Intel® 64 and IA-32 Architectures Software Developer's Manual Volume 3 (System Programming Guide)
- • 第2章:Protected-Mode Memory Management
- • 2.1节:Memory Management Overview
- • 2.5节:Page and Segment Protection
- • 3.1节:Enabling and Disabling Segment-Level Protection
- • 3.2节:Fields and Flags Used for Segment-Level and Page-Level Protection
- • 4.1节:Paging Modes and Control Bits
- • 4.6节:Page-Fault Exceptions
- • 第9章:Processor Management and Initialization
- • 9.10节:Initialization Example
- • Intel官方文档下载地址:https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html
- •
include/asm/segment.h:段定义