目录
- 一次访问内存的完整路径
- 段寄存器里装的不再是"段值"
- 段选择子指向哪里:GDT 与 LDT
- 段描述符:8 字节属性打包
- 线性地址 = 段基址 + 偏移
- 特权级保护:CPL / DPL / RPL
- 限长检查:段不是无限大的
- 用户态读不到 GDT:UMIP(进阶,可先跳到两级分页)
- 两级分页:线性地址到物理地址
- 页级保护
- 实模式 / 保护模式 / 长模式 寻址对照
- 真机验证:上述论断来自以下实测
- 与 Linux 的关系
- 社区里常被混淆的几个概念
- 小结
- 附:相关高频面试题与参考解析
- 问题一:保护模式下段寄存器里装的是什么,它和实模式的段值有什么不同?
- 问题二:段选择子的 index、TI、RPL 各是什么意思?
- 问题三:保护模式一次访存,从选择子到物理地址经历了哪些步骤?
- 问题四:线性地址和物理地址是一回事吗?
- 问题五:保护模式的段限长是怎么算的,和实模式 64 KB 有何不同?
- 问题六:CPL、DPL、RPL 分别是什么,特权检查怎么判?
- 问题七:为什么用户态执行 mov %cr0 会 SIGSEGV?
- 问题八:UMIP 是做什么的,为什么用户态 SGDT 读到的不是真实 GDT 地址?
- 问题九:32 位保护模式的两级分页怎么把线性地址映射到物理地址?
- 问题十:Linux 几乎不用分段,为什么还要讲保护模式的段机制?
- 参考资料
实模式用”段值 × 16 + 偏移”最多够到 1 MB 出头,且对段的大小、访问权限、特权级一无所知。
保护模式最早随 1982 年的 80286 登场,1985 年的 80386 把它扩展为 32 位并引入分页。段寄存器里装的是 16 位段选择子(segment selector),指向描述符表中的一项;真正的段基址、段限长、特权级从段描述符里读出,不再由”段值左移 4 位”直接算出。
本文主线是讲解 legacy 32 位保护模式:段选择子与 GDT/LDT、段描述符、线性地址、特权与限长,以及 PAE=0 时的两级分页概念。
本文中数值来自 IA-32e 兼容模式(32 位用户进程)的实测。兼容模式的分段语义接近上述主线,分页却走 IA-32e 多级表,不是两级表。本文中相关的程序也只验证到线性地址与段保护,不打印物理地址。64 位长模式的差异内容会集中在后文对照章节,不会与主线混写。
实模式的”段值 × 16 + 偏移”是理解这套机制的前置知识,详见《实模式寻址:CPU 怎么用”段值×16+偏移”算出物理地址》。
一次访问内存的完整路径
段选择子好比书架编号,段描述符是对应位置上的卡片(写着基址与限长),偏移是这一排里第几本。
下面按 32 位保护模式的分段路径模拟执行一遍(全景见图1)。
基址 0x0804B420 与限长 0x3F 取自后文两组兼容模式实测(“线性地址”与”限长检查”两节),这里把两条实测合成同一条路径来讲。
- 指令给出的是段内偏移,也叫有效地址,例 0x10,它本身不是最终地址。用哪个段由指令与寻址方式决定:取指令走 CS,取数据走 DS,访问栈走 SS,线程局部存储走 FS。
- 段寄存器(例 FS)里装的是 16 位段选择子,例 0x0063。选择子是描述符表里的下标。
- CPU 用这个下标到 GDT 或 LDT 中取出对应的 8 字节(常称 64 位宽)段描述符。0x0063 分解后 index=12(下标从 0 计)、TI=0,即取 GDT 的第 12 项。装入段寄存器时,基址、限长与属性写入该寄存器的隐藏部分;之后用该段访存走这份缓存,不每次重查描述符表。
- 描述符里写着段基址(例 0x0804B420)、限长(例 0x3F)与 DPL 等。装入段寄存器时做特权检查;之后每次用该段访问时再查偏移是否越限。
- 段基址加上偏移得到线性地址,例 0x0804B420 + 0x10 = 0x0804B430。若 CR0 的 PG 位为 1,架构上还要把线性地址经页表翻成物理地址;演示程序停在线性地址与段保护,不测页表遍历。
图1:一次访存的分段路径:从偏移到选择子、描述符与线性地址(分页到物理见后文概念)
把上述五步压缩成两行,就是这套寻址机制的公式:
第一步(分段):线性地址 = 段基址(来自描述符)+ 偏移
第二步(分页):线性地址 → 物理地址(仅当 CR0.PG = 1;本文实证不测本步)
段基址与段限长都来自段选择子查到的段描述符,而不是实模式里”段值 × 16”算出的定值。
段寄存器里装的不再是"段值"
实模式下 CS/DS 等段寄存器直接存 16 位段值,段基址由它左移 4 位算出。保护模式里,同样的寄存器位置装的是段选择子,段基址改为从描述符表里查出来。
段选择子只有 16 位,拆成三段(位级结构见图2):
- index(bit15~bit3,13 位):在描述符表里的下标,从 0 计到 8191,可索引 8192 个描述符。
- TI(bit2,Table Indicator):0 查 GDT(全局描述符表),1 查 LDT(局部描述符表)。
- RPL(bit1~bit0,Requested Privilege Level):请求特权级,0 最高、3 最低。
图2:段选择子 16 位位级结构:13 位索引、1 位 TI、2 位 RPL
段选择子指向哪里:GDT 与 LDT
GDT(Global Descriptor Table)全局可见,概念上整个系统一张(多核下通常每 CPU 各备一份);LDT(Local Descriptor Table)每个任务各一张,用于任务内私有分段。TI 位决定查哪张表。32 位保护模式下,GDTR 给出 32 位基址 + 16 位限长。进入 IA-32e 后,GDTR 基址扩展为 64 位。LDTR 里是一个选择子,指向 GDT 中描述 LDT 的那一项,CPU 把 LDT 的基址与限长缓存在隐藏部分。查 GDT 时用”GDTR.base + index×8”(每项 8 字节)。
实证程序 seg_selectors32 在 32 位兼容模式下运行,实测各段寄存器的选择子如下:CS=0x23、DS/ES/SS=0x2B、FS/GS=0x0000。把 0x23 按位分解:二进制 0000 0000 0010 0011,index=4、TI=0、RPL=3,说明它指向 GDT 的第 4 项、请求特权级 3(用户态)。DS 的 0x2B 同样 TI=0,index=5。FS/GS=0x0000 是 null 选择子(index=0 且 TI=0),不能拿来访存;装入合法后才能用 FS/GS。
本节实测程序 seg_selectors32.c 完整代码:
// 32 位保护模式:读取段寄存器中的选择子,
// 分解出 index / TI / RPL;
// 再用 SGDT 取全局描述符表(GDT)的基址与界限,
// 算出描述符条目数。
//
// 编译(无需 32 位 C 运行库,自己实现最小输出):
// gcc -m32 -nostdlib -static -ffreestanding -fno-stack-protector \
// -fno-pie -no-pie -o seg_selectors32 seg_selectors32.c
// 运行环境需内核打开 IA32 兼容(CONFIG_IA32_EMULATION)。
//
// 全程只用 int 0x80 系统调用,不依赖 libc。
typedef unsigned short u16;
typedef unsigned int u32;
// 32 位 ABI 的 write:eax=4, ebx=fd, ecx=buf, edx=len
static int sys_write(int fd, const void *buf, unsigned len)
{
int r;
__asm__ volatile ("int $0x80"
: "=a"(r)
: "a"(4), "b"(fd), "c"(buf), "d"(len)
: "memory");
return r;
}
static void sys_exit(int code)
{
__asm__ volatile ("int $0x80" :: "a"(1), "b"(code));
__builtin_unreachable();
}
static char out[1024];
static int n;
static void putc_(char ch) { out[n++] = ch; }
static void puts_(const char *p) { while (*p) out[n++] = *p++; }
// 以固定宽度打印十六进制,便于对照选择子的位段
static void puthex(u32 v, int digits)
{
static const char d[] = "0123456789ABCDEF";
for (int i = (digits - 1) * 4; i >= 0; i -= 4)
putc_(d[(v >> i) & 0xF]);
}
static void putdec(u32 v)
{
char t[12];
int k = 0;
if (v == 0) { putc_('0'); return; }
while (v) { t[k++] = (char)('0' + v % 10); v /= 10; }
while (k) putc_(t[--k]);
}
// 读取各段寄存器的 16 位选择子
#define RDSEG(fn, reg) \
static u16 fn(void) { \
u16 v; \
__asm__ volatile ("movw %%" reg ", %0" : "=r"(v)); \
return v; \
}
RDSEG(rd_cs, "cs")
RDSEG(rd_ds, "ds")
RDSEG(rd_es, "es")
RDSEG(rd_ss, "ss")
RDSEG(rd_fs, "fs")
RDSEG(rd_gs, "gs")
// 选择子结构:bit15~bit3 为索引,bit2 为表指示位
// (0=GDT,1=LDT),bit1~bit0 为请求特权级 RPL。
// 段寄存器本身只存 16 位。
static void show(const char *name, u16 sel)
{
puts_(name);
putc_(' ');
puthex(sel, 4);
puts_(" index=");
putdec(sel >> 3);
puts_(" TI=");
putdec((sel >> 2) & 1);
puts_(" RPL=");
putdec(sel & 3);
putc_('\n');
}
struct gdtr {
u16 limit;
u32 base;
} __attribute__((packed));
void _start(void)
{
u16 cs = rd_cs();
u16 ds = rd_ds();
u16 es = rd_es();
u16 ss = rd_ss();
u16 fs = rd_fs();
u16 gs = rd_gs();
puts_("32-bit protected mode segment selectors\n");
show("CS", cs);
show("DS", ds);
show("ES", es);
show("SS", ss);
show("FS", fs);
show("GS", gs);
// 当前特权级 CPL 取自 CS 选择子的 bit1~bit0
puts_("CPL=");
putdec(cs & 3);
putc_('\n');
// SGDT 本意是取 GDTR 的基址与界限;
// UMIP 开启时内核返回伪值,
// 用户态拿不到真实描述符表位置。
struct gdtr g;
__asm__ volatile ("sgdt %0" : "=m"(g));
puts_("sgdt: base=0x");
puthex(g.base, 8);
puts_(" limit=0x");
puthex(g.limit, 4);
putc_('\n');
sys_write(1, out, (unsigned)n);
sys_exit(0);
}
图3:段选择子经 TI 位选表,由 GDTR 基址 + index×8 定位到 64 位描述符
段描述符:8 字节属性打包
每个段描述符占 8 字节(常称 64 位宽,指描述符宽度,不是 CPU 长模式),把基址、限长、特权与类型等塞进这 8 个字节(字节级布局见图4)。基址被拆成三段存放(byte2、byte3 拼出低 16 位,byte4 是 1623 位,byte7 是 2431 位),限长也被拆成两段(byte01 是低 16 位,byte6 的低 4 位是 1619 位)。其中几个字段直接决定”寻址”与”保护”:
- 段基址(32 位):段在线性地址空间里的起点,任意 32 位值,不再受”×16 对齐”约束。
- 段限长(20 位):段内最后合法偏移。G 位(Granularity)决定粒度:G=0 时有效限长等于该 20 位值,最大约 1 MB;G=1 时有效限长 = (limit << 12) | 0xFFF,最大覆盖整个 4 GB。
- DPL(2 位):描述符特权级,段的”门禁等级”。
- Type / S:区分代码段、数据段、系统段、门等。
图4:段描述符 64 位布局:32 位基址、20 位限长、DPL、G 位分散在字节内
线性地址 = 段基址 + 偏移
保护模式下,段寄存器给出选择子,查表得到 32 位段基址后,与指令算出的偏移(有效地址)相加,得到线性地址(linear address):
线性地址 = 段描述符中的 32 位段基址 + 有效地址(偏移)
与实模式相比:实模式段基址钉在”段值 × 16”上、只有 20 位;保护模式段基址是描述符里的任意 32 位值,段可以重叠也可以错开。实模式那种由 ×16 被迫产生的大规模别名不再成立,但重叠段仍可能映射到同一线性地址。
32 位保护模式里最常见的是平坦模型(flat model):把 CS/DS/ES/SS 基址都设成 0、限长覆盖整个 4 GB 空间,于是对这些段而言线性地址等于偏移。FS/GS 仍可设非零基址。长模式与 Linux 上的用法见后文对照节,此处不混写。
兼容模式实证程序 seg_base32 通过 set_thread_area 给 FS 装入非零基址,再经 fs:0x10 写入、从”基址 + 0x10”读回:
set_thread_area rc=0 entry=12
loading FS with selector 0x0063
base=0x0804B420
write 0x12345678 via fs:0x10
read at base+0x10 = 0x12345678
写入与读回一致,说明在该兼容进程上”线性地址 = 段基址 + 偏移”成立。选择子 0x0063 分解后 index=12、TI=0、RPL=3。本实测跑在 x86-64 内核上的 32 位兼容进程,故 entry=12:arch/x86/include/asm/segment.h 在 64 位内核配置下定义 GDT_ENTRY_TLS_MIN 为 12(同头文件在 32 位内核配置下该宏为 6,与本次宿主无关)。内核把 TLS 描述符放进 GDT 而非 LDT。
本节实测程序 seg_base32.c 完整代码:
// 验证"线性地址 = 段基址 + 偏移":
// 给 FS 装一个基址非零的段,再经 FS 写入。
//
// 段的基址由 set_thread_area 建立的描述符给出。
// 内核把 TLS 描述符放在 GDT 的 TLS 槽位
// (get_free_idx() 返回 GDT_ENTRY_TLS_MIN + idx),
// 选择子为 (idx << 3) | 3,即 TI=0、RPL=3。
//
// 编译:
// gcc -m32 -nostdlib -static -ffreestanding -fno-stack-protector \
// -fno-pie -no-pie -o seg_base32 seg_base32.c
typedef unsigned short u16;
typedef unsigned int u32;
static int sys_write(int fd, const void *buf, unsigned len)
{
int r;
__asm__ volatile ("int $0x80"
: "=a"(r)
: "a"(4), "b"(fd), "c"(buf), "d"(len)
: "memory");
return r;
}
static void sys_exit(int code)
{
__asm__ volatile ("int $0x80" :: "a"(1), "b"(code));
__builtin_unreachable();
}
static char out[1024];
static int n;
static void putc_(char ch) { out[n++] = ch; }
static void puts_(const char *p) { while (*p) out[n++] = *p++; }
// 把已拼好的内容立刻写出去,
// 避免后续异常导致缓冲内容丢失。
static void flush(void)
{
if (n > 0) {
sys_write(1, out, (unsigned)n);
n = 0;
}
}
static void puthex(u32 v, int digits)
{
static const char d[] = "0123456789ABCDEF";
for (int i = (digits - 1) * 4; i >= 0; i -= 4)
putc_(d[(v >> i) & 0xF]);
}
static void putdec(u32 v)
{
char t[12];
int k = 0;
if (v == 0) { putc_('0'); return; }
while (v) { t[k++] = (char)('0' + v % 10); v /= 10; }
while (k) putc_(t[--k]);
}
static void show_sel(const char *name, u16 sel)
{
puts_(name);
putc_(' ');
puthex(sel, 4);
puts_(" index=");
putdec(sel >> 3);
puts_(" TI=");
putdec((sel >> 2) & 1);
puts_(" RPL=");
putdec(sel & 3);
putc_('\n');
}
static u16 rd_fs(void)
{
u16 v;
__asm__ volatile ("movw %%fs, %0" : "=r"(v));
return v;
}
// 内核 set_thread_area 的入参结构(32 位)
struct user_desc {
u32 entry_number;
u32 base_addr;
u32 limit;
u32 seg_32bit : 1;
u32 contents : 2;
u32 read_exec_only : 1;
u32 limit_in_pages : 1;
u32 seg_not_present: 1;
u32 useable : 1;
};
static int set_thread_area(struct user_desc *u)
{
int r;
__asm__ volatile ("int $0x80"
: "=a"(r)
: "a"(243), "b"(u)
: "memory");
return r;
}
// 目标缓冲区:把段基址指向它,
// 再经 FS 写入,看数据是否落在 base+offset。
static unsigned char target[64];
void _start(void)
{
puts_("FS before: ");
show_sel("", rd_fs());
flush();
u32 base = (u32)(unsigned int)target;
struct user_desc u;
// 让内核自选条目;
// limit 与 limit_in_pages 配合得到 4 GiB 跨度;
// 数据段、可写。
u.entry_number = 0xFFFFFFFF;
u.base_addr = base;
u.limit = 0xFFFFF;
u.seg_32bit = 1;
u.contents = 0;
u.read_exec_only = 0;
u.limit_in_pages = 1;
u.seg_not_present= 0;
u.useable = 1;
int rc = set_thread_area(&u);
puts_("set_thread_area rc=");
putdec((u32)rc);
puts_(" entry=");
putdec(u.entry_number);
putc_('\n');
flush();
if (rc < 0) {
puts_("set_thread_area failed, abort\n");
flush();
sys_exit(1);
}
// 选择子 = (条目号 << 3) | (TI << 2) | RPL。
// 内核把 TLS 描述符放在 GDT,
// 所以 TI=0、RPL=3,选择子即 (idx << 3) | 3。
u16 sel = (u16)(((u.entry_number << 3) | 3u) & 0xFFFF);
puts_("loading FS with selector 0x");
puthex(sel, 4);
putc_('\n');
flush();
__asm__ volatile ("movw %0, %%fs" :: "r"(sel));
puts_("FS after: ");
show_sel("", rd_fs());
puts_("base=0x");
puthex(base, 8);
putc_('\n');
flush();
// 经 FS 写入一个可辨认的值,偏移取 0x10
__asm__ volatile ("movl $0x12345678, %%fs:0x10" ::: "memory");
volatile u32 *p = (volatile u32 *)(target + 0x10);
u32 back = *p;
puts_("write 0x12345678 via fs:0x10\n");
puts_("read at base+0x10 = 0x");
puthex(back, 8);
putc_('\n');
flush();
sys_exit(0);
}
图5:线性地址 = 段基址(来自描述符)+ 偏移;平坦时 CS/DS/ES/SS 基址为 0,图中例为 FS 非零基址
特权级保护:CPL / DPL / RPL
保护模式引入四环(ring 0~3)特权模型。CPL、DPL、RPL 主要用于装载段寄存器与部分控制转移时的特权判定;页能否读写另由页表 R/W、U/S 等位管理。
- CPL(Current Privilege Level):当前特权级,即 CS 选择子的低 2 位。用户程序 CPL=3,内核 CPL=0。
- DPL(Descriptor Privilege Level):段或门的特权级,写在描述符里。
- RPL(Requested Privilege Level):选择子里的请求特权级,用来”降级”请求,防止用户态借内核给的选择子越权。
装载数据段(DS/ES/FS/GS)时,常见规则是 max(CPL, RPL) ≤ DPL。装载栈段 SS 更严:必须 CPL = RPL = DPL。向一致代码段做控制转移时,要求 CPL ≥ DPL(与装载数据段的规则不同;转移不抬升当前 CPL)。访问控制寄存器、执行 LGDT/LIDT 等要求 CPL=0。兼容模式实证程序 priv_fault32 让进程在 CPL=3 下执行 mov %cr0, %eax,处理器抛出 #GP,进程收到 SIGSEGV:
CPL=3 (user mode)
execute: mov %cr0, %eax -> expect SIGSEGV
(进程以 exit code 139 / SIGSEGV 结束)
本节实测程序 priv_fault32.c 完整代码:
// 验证保护模式的特权检查:用户态(CPL=3)执行特权指令,
// 读取控制寄存器 CR0 会触发通用保护异常 #GP,
// 进程收到 SIGSEGV。
//
// MOV 到/从控制寄存器要求 CPL=0;
// 在实模式下则没有这一层检查,
// 任何代码都能改段寄存器、直接触碰硬件相关资源。
//
// 编译:
// gcc -m32 -nostdlib -static -ffreestanding -fno-stack-protector \
// -fno-pie -no-pie -o priv_fault32 priv_fault32.c
typedef unsigned short u16;
typedef unsigned int u32;
static int sys_write(int fd, const void *buf, unsigned len)
{
int r;
__asm__ volatile ("int $0x80"
: "=a"(r)
: "a"(4), "b"(fd), "c"(buf), "d"(len)
: "memory");
return r;
}
static void sys_exit(int code)
{
__asm__ volatile ("int $0x80" :: "a"(1), "b"(code));
__builtin_unreachable();
}
static char out[256];
static int n;
static void puts_(const char *p) { while (*p) out[n++] = *p++; }
static void putc_(char ch) { out[n++] = ch; }
static void putdec(u32 v)
{
char t[12];
int k = 0;
if (v == 0) { putc_('0'); return; }
while (v) { t[k++] = (char)('0' + v % 10); v /= 10; }
while (k) putc_(t[--k]);
}
void _start(void)
{
u16 cs;
__asm__ volatile ("movw %%cs, %0" : "=r"(cs));
puts_("CPL=");
putdec(cs & 3);
puts_(" (user mode)\n");
puts_("execute: mov %cr0, %eax -> expect SIGSEGV\n");
sys_write(1, out, (unsigned)n);
// 控制寄存器只能由 CPL=0 的代码访问,此处应触发 #GP
u32 cr0;
__asm__ volatile ("movl %%cr0, %0" : "=r"(cr0));
// 若未被拦截,程序会走到这里
puts_("no fault: CR0 read succeeded\n");
sys_write(1, out, (unsigned)n);
sys_exit(0);
}
实模式里没有这一层:代码可以随意改段寄存器、直接触碰硬件相关资源。保护模式把”谁能访问什么”变成了硬件强制的检查。
图6:四环特权模型与 CPL/DPL/RPL 检查;CPL=3 时访问特权资源被拦截
限长检查:段不是无限大的
8086 式实模式靠 16 位偏移自然封顶,没有描述符限长字段;386 起的实地址模式里,偏移超过 0xFFFF 会触发 #GP/#SS。在 32 位保护模式(及分段语义与之接近的兼容模式)下,用描述符里的 limit,偏移越限即 #GP。长模式是否做限长检查见后文对照,不在此套用。兼容模式实证程序 seg_limit32 给 FS 装一个限长 64 字节(limit=0x3F)的数据段:
segment limit = 0x3F (64 bytes), selector=0x0063
write fs:0x10 (in limit) -> ok
write fs:0x40 (over limit) -> expect SIGSEGV
(进程以 exit code 139 / SIGSEGV 结束)
限长内的 fs:0x10 写入成功,越过限长的 fs:0x40 立即被处理器拦截。这说明在该模式下保护模式的”段”是有边界的隔离单元,而不只是地址前缀。
本节实测程序 seg_limit32.c 完整代码:
// 验证保护模式的限长检查:
// 给 FS 装一个限长为 64 字节的数据段,
// 先在限长内访问(应成功),
// 再越界访问(应触发 #GP,进程收到 SIGSEGV)。
//
// 对照:8086 式实模式靠 16 位偏移自然封顶、无描述符 limit;
// 386 起的实地址模式对偏移超过 0xFFFF 会 #GP/#SS,
// 与保护模式的描述符限长不是同一套机制。
// 保护模式的段描述符带限长字段,越界即被拦截。
//
// 编译:
// gcc -m32 -nostdlib -static -ffreestanding -fno-stack-protector \
// -fno-pie -no-pie -o seg_limit32 seg_limit32.c
typedef unsigned short u16;
typedef unsigned int u32;
static int sys_write(int fd, const void *buf, unsigned len)
{
int r;
__asm__ volatile ("int $0x80"
: "=a"(r)
: "a"(4), "b"(fd), "c"(buf), "d"(len)
: "memory");
return r;
}
static void sys_exit(int code)
{
__asm__ volatile ("int $0x80" :: "a"(1), "b"(code));
__builtin_unreachable();
}
static char out[512];
static int n;
static void putc_(char ch) { out[n++] = ch; }
static void puts_(const char *p) { while (*p) out[n++] = *p++; }
static void flush(void) { if (n) { sys_write(1, out, (unsigned)n); n = 0; } }
static void puthex(u32 v, int digits)
{
static const char d[] = "0123456789ABCDEF";
for (int i = (digits - 1) * 4; i >= 0; i -= 4)
putc_(d[(v >> i) & 0xF]);
}
static void putdec(u32 v)
{
char t[12];
int k = 0;
if (v == 0) { putc_('0'); return; }
while (v) { t[k++] = (char)('0' + v % 10); v /= 10; }
while (k) putc_(t[--k]);
}
struct user_desc {
u32 entry_number;
u32 base_addr;
u32 limit;
u32 seg_32bit : 1;
u32 contents : 2;
u32 read_exec_only : 1;
u32 limit_in_pages : 1;
u32 seg_not_present: 1;
u32 useable : 1;
};
static int set_thread_area(struct user_desc *u)
{
int r;
__asm__ volatile ("int $0x80"
: "=a"(r)
: "a"(243), "b"(u)
: "memory");
return r;
}
static unsigned char target[64];
void _start(void)
{
struct user_desc u;
u.entry_number = 0xFFFFFFFF;
u.base_addr = (u32)(unsigned int)target;
// 限长按字节计:0x3F 表示 64 字节,合法偏移为 0x00~0x3F
u.limit = 0x3F;
u.seg_32bit = 1;
u.contents = 0;
u.read_exec_only = 0;
u.limit_in_pages = 0;
u.seg_not_present= 0;
u.useable = 1;
int rc = set_thread_area(&u);
if (rc < 0) {
puts_("set_thread_area failed rc=");
putdec((u32)rc);
putc_('\n');
flush();
sys_exit(1);
}
u16 sel = (u16)(((u.entry_number << 3) | 3u) & 0xFFFF);
__asm__ volatile ("movw %w0, %%fs" :: "r"(sel));
puts_("segment limit = 0x3F (64 bytes), selector=0x");
puthex(sel, 4);
putc_('\n');
// 限长内:偏移 0x10,应成功
__asm__ volatile ("movl $0xabcdef01, %%fs:0x10" ::: "memory");
puts_("write fs:0x10 (in limit) -> ok\n");
flush();
// 限长外:偏移 0x40,越过限长,应触发通用保护异常
puts_("write fs:0x40 (over limit) -> expect SIGSEGV\n");
flush();
__asm__ volatile ("movl $0xdeadbeef, %%fs:0x40" ::: "memory");
// 越界写入若被放行,程序会走到这里
puts_("no fault: limit not enforced\n");
flush();
sys_exit(0);
}
图7:偏移越过段限长时处理器抛出 #GP,进程收到 SIGSEGV
用户态读不到 GDT:UMIP(进阶,可先跳到两级分页)
既然地址翻译依赖 GDT,用户态能否直接把 GDT 位置读出来?现代 x86 用 UMIP(User-Mode Instruction Prevention,CR4 的 bit 11)堵住这条路。开启后,CPL>0 时执行 SGDT / SIDT / SLDT / STR / SMSW 会触发 #GP;Linux 在异常路径里调用 fixup_umip_exception,模拟指令并把结果写回用户态,用户态通常收不到 SIGSEGV。SGDT/SIDT 给出固定伪基址;SLDT/STR 则按内核维护的 LDT/TSS 选择子回填。兼容模式实测 SGDT:
sgdt: base=0xFFFE0000 limit=0x0000
这不是真实 GDT 位置。内核 tag v6.14 的 arch/x86/kernel/umip.c 里 UMIP_DUMMY_GDT_BASE = 0xfffffffffffe0000,对 32 位进程只拷贝低 4 字节得到 0xFFFE0000,限长填 0(未逐行核对 6.8.0 源码,行为以本机实测为准)。Linux 在 arch/x86/kernel/cpu/common.c 中默认开启 CR4.UMIP。
两级分页:线性地址到物理地址
段机制给出线性地址后,若 CR0.PG(bit 31)开启,再经分页得到物理地址。下面讲的是 legacy 32 位保护模式且 PAE=0 时的两级页表(SDM Volume 3 Chapter 4.3)。当代 Linux x86-64 上的 32 位兼容进程跑在 IA-32e 兼容模式里,分页走的是 4 级(或 5 级)表,并不切回这条两级路径;实证覆盖的是分段、选择子与 UMIP,不是两级页表本身。
- CR3 指向页目录(Page Directory),含 1024 个页目录项(PDE);
- 每个 PDE 指向一个页表(Page Table),含 1024 个页表项(PTE);
- 每个 PTE 指向一个 4 KB 物理页帧(page frame)。
4 KB 页面的线性地址被切成三段:高 10 位查 PDE、中 10 位查 PTE、低 12 位作页内偏移,覆盖 4 GB 虚拟空间(1024×1024×4KB)。CR4.PSE(bit 4)开启后,PDE 可置 PS 位直接指向 4 MB 大页,省去一级查表。物理地址超过 4 GB 时,常见路径是开 PAE(CR4 bit 5)或进入长模式;在仍保持 PAE=0 的 legacy 两级分页下,若 CPU 支持 PSE-36,也可让 4 MB 大页映射到更高物理地址(4 KB 页仍限在 4 GB 内)。PAE 与 PSE-36 细节这里不展开。
图8:两级分页:CR3 → 页目录 → 页表 → 4 KB 页帧;PSE 可走 4 MB 大页
线性地址的拆分如图9所示(4 KB 页按 10/10/12、4 MB 大页按 10/22):
图9:线性地址按 10/10/12 或 10/22 拆分,逐级查表得到物理地址
页级保护
分页不止做地址翻译,也做保护。PDE 与 PTE 里的 R/W 位决定页是否可写,U/S 位决定页是 supervisor(仅 ring 0~2 可访问)还是 user(ring 3 也可访问)。用户态写一块标记为只读或 supervisor 的页,同样会触发页错误(#PF)。在做段限长检查的模式下,分段管装段特权与偏移是否越界,分页管”这一页具体能不能读、写”,两者叠加。非 PAE 的 32 位分页没有 NX 位,页级管不了”可执行”,执行区分仍靠代码段/数据段的段类型。
实模式 / 保护模式 / 长模式 寻址对照
保护模式往上是长模式(64 位,即 x86-64 / IA-32e),往下接实模式。三者对照见图10。实模式是”段值 << 4 + 偏移”、无特权保护、段长 64 KB、存在 ×16 别名。legacy 32 位保护模式是”段基址 + 偏移”、经选择子查 GDT/LDT、带特权级与限长检查,PAE=0 时常见两级页表到物理。64 位长模式里:CS/DS/ES/SS 基址按 0 处理;处理器不做代码/数据段的运行时限长检查;FS/GS 基址仍可参与线性地址计算(访存不做限长/属性检查);选择子仍决定 CPL;分页走 4 级或 5 级并做规范地址检查。上述长模式限长结论来自 SDM,未对原生 64 位进程做限长实测。
Linux 在 x86-64 上主要运行于长模式(IA-32e 分页,常见 4 级,也可开 5 级;详见《Linux 内存寻址原理:分段、分页与地址翻译》)。开启 IA32_EMULATION 后,32 位进程处于兼容模式:分段语义接近 32 位保护模式,但线性地址翻译仍用 IA-32e 页表,不是上文的两级分页。实模式只在开机引导极早期出现。
图10:实模式 / 保护模式 / 长模式三级寻址对照
真机验证:上述论断来自以下实测
下列输出在 Ubuntu 24.04、kernel 6.8.0、gcc 13.3.0、x86_64 上以 32 位兼容模式 freestanding 程序测得(已开 32 位兼容与 UMIP),覆盖选择子、FS 基址、限长与特权指令;宿主分页为 IA-32e,本机 CONFIG_PGTABLE_LEVELS=5。行为以各实证节实测为准;文末 tls/umip 等内核路径引用 tag v6.14,未断言与 6.8.0 源码逐字相同。
四组实测输出与源码已分别放在对应实证节,此处只做索引,避免重复粘贴:
- 选择子 / CPL / SGDT(UMIP 伪值):见《段选择子指向哪里:GDT 与 LDT》中 seg_selectors32
- 线性地址 = 基址 + 偏移(FS 非零基址):见《线性地址 = 段基址 + 偏移》中 seg_base32
- 特权指令拦截(CPL=3 读 CR0 → SIGSEGV):见《特权级保护:CPL / DPL / RPL》中 priv_fault32
- 限长越界拦截(limit=0x3F → SIGSEGV):见《限长检查:段不是无限大的》中 seg_limit32
与 Linux 的关系
在 x86-64 长模式进程里,CS/DS/ES/SS 基址按 0 处理,处理器不做段限长运行时检查;FS/GS 基址仍可用于 TLS。在 32 位兼容进程里,分段语义接近 32 位保护模式:CS/DS/ES/SS 仍可按平坦配置(基址 0、限长覆盖可寻址空间),TLS 经 set_thread_area 写入 GDT 槽位(本宿主上 entry 12 起),且限长检查可被上文 seg_limit32 触发。UMIP 默认开启,用户态读不到真实 GDT/IDT 位置。内存隔离主要靠分页(详见《Linux 内存寻址原理:分段、分页与地址翻译》)。系统调用进出内核时也会切换段寄存器与特权级。
社区里常被混淆的几个概念
下面这些误区在 Stack Overflow、OSDev 等社区反复出现:
- 误解”保护模式不用分段”:平坦模型下 CS/DS/ES/SS 基址为 0,但选择子与 GDT 仍在;兼容模式下 TLS 可靠 FS/GS 非零基址。
- 误解”段选择子就是段基址”:选择子是索引,基址在 GDT/LDT 描述符里。
- 误解”线性地址就是物理地址”:仅当 CR0.PG=0 时二者相等;开启分页后还要查页表。legacy 32 位(PAE=0)走两级;Linux x86-64 上(含 32 位兼容进程)走 IA-32e 多级页表。
- 误解”32 位就能寻址 4 GB 物理内存”:4 GB 是该模式下线性地址宽度上限;物理地址超过 4 GB 的常见路径是 PAE 或长模式,legacy 下若支持 PSE-36 也可经 4 MB 大页触及更高物理地址(4 KB 页仍限 4 GB)。
- 困惑”为什么用户态 SGDT 读到 0xFFFE0000”:UMIP 触发 #GP 后由内核模拟 SGDT 并填入伪基址。
- 误解”保护模式段长也是 64 KB”:在 32 位保护/兼容模式下,限长由描述符给出,G=1 时有效限长可达 4 GB−1,越界触发 #GP;勿直接套到 64 位长模式。
小结
- 段寄存器存 16 位段选择子 = index(13 位)+ TI(1 位)+ RPL(2 位),经 GDT/LDT 查到 8 字节段描述符 [1][2]。
- 线性地址 = 段基址(描述符中 32 位)+ 偏移;32 位保护模式平坦模型下 CS/DS/ES/SS 基址为 0,FS/GS 可非零 [1][2][3]。
- 数据段装载常见规则为 max(CPL, RPL) ≤ DPL;SS 要求 CPL = RPL = DPL;在 32 位保护/兼容模式下限长越界触发 #GP [1][2]。64 位长模式不做段限长运行时检查(SDM;本文未实测)[1]。
- UMIP(CR4 bit 11)下 CPL>0 执行 SGDT 等会 #GP,Linux 用 fixup_umip_exception 模拟并写回结果 [4]。
- legacy 32 位保护模式(PAE=0)下,CR0.PG=1 时线性地址经两级页表翻译:4 KB 页按 10/10/12 拆分,PSE 可走 4 MB 大页(SDM Volume 3 Chapter 4.3)。IA-32e 下(含兼容模式)改用 Chapter 4.5 的多级分页 [1][5]。
- 分段、选择子、限长与 UMIP 相关论断在 Ubuntu 24.04 / kernel 6.8.0 / gcc 13.3.0 上以 32 位兼容 freestanding 程序实测,输出与源码见上文各实证节;“真机验证”一节仅作索引与环境说明。
附:相关高频面试题与参考解析
下列供复习自测,第一遍可跳过。题目提炼自操作系统与 x86 保护模式方向在知乎、CSDN、SegmentFault、Stack Overflow、OSDev 等社区被反复问到的问题,选取与本文内容直接相关的十题。参考答案回引前文与官方资料。
问题一:保护模式下段寄存器里装的是什么,它和实模式的段值有什么不同?
**答题思路:**先点明实模式段值可直接左移得基址,再说明保护模式存的是选择子,须查表取基址。
**参考答案:**实模式的段寄存器直接存 16 位段值,左移 4 位即得段基址。保护模式存的是 16 位段选择子(index、TI、RPL),经 GDT/LDT 查到段描述符后取出 32 位段基址 [1][2]。
问题二:段选择子的 index、TI、RPL 各是什么意思?
**答题思路:**按位拆分解释三段:13 位索引定位描述符,TI 选 GDT/LDT,RPL 是请求特权级。
**参考答案:**index(bit15bit3,13 位)是描述符表中的下标,可索引 8192 个描述符;TI(bit2)为 0 查 GDT、为 1 查 LDT;RPL(bit1bit0)是请求特权级,与 CPL 一起参与特权检查 [1][2]。
问题三:保护模式一次访存,从选择子到物理地址经历了哪些步骤?
**答题思路:**按”选择子→描述符→线性地址→(分页)→物理地址”串起来,并分清 legacy 两级与 IA-32e 多级。
**参考答案:**先用选择子查 GDT/LDT 得到段基址与限长,段基址加有效地址得到线性地址;若 CR0.PG=1,再经页表翻译成物理地址。legacy 32 位保护模式(PAE=0)走两级页表(页目录→页表→页帧);Linux x86-64 上(含 32 位兼容进程)走 IA-32e 多级页表 [1][2][5]。
问题四:线性地址和物理地址是一回事吗?
**答题思路:**区分”分段”与”分页”两件事:分段产出线性地址,分页才把它映射到物理地址。
**参考答案:**不是。线性地址是分段的结果,物理地址是分页的结果。只有关闭分页(CR0.PG=0)时二者相等;开启分页后线性地址还需经页表翻译 [1][5]。
问题五:保护模式的段限长是怎么算的,和实模式 64 KB 有何不同?
**答题思路:**说明限长来自描述符的 20 位字段、受 G 位控制,并指出越界会被拦截。
**参考答案:**段限长由描述符中 20 位字段给出:G=0 时有效限长等于该值(最大约 1 MB);G=1 时有效限长 = (limit << 12) | 0xFFF(最大 4 GB−1)。偏移越界触发 #GP。实模式的 64 KB 来自 16 位偏移;386 实地址模式对偏移超过 0xFFFF 会异常,与保护模式的描述符 limit 不是同一套机制 [1][2]。
问题六:CPL、DPL、RPL 分别是什么,特权检查怎么判?
**答题思路:**分别解释三者含义;数据段用 max(CPL,RPL) ≤ DPL,栈段单独说 CPL=RPL=DPL。
**参考答案:**CPL 是当前特权级(CS 选择子低 2 位),DPL 是描述符特权级,RPL 是选择子里的请求特权级。装载 DS/ES/FS/GS 等数据段要求 max(CPL, RPL) ≤ DPL;装载 SS 要求 CPL = RPL = DPL。向一致代码段转移时要求 CPL ≥ DPL(不抬升当前 CPL);改控制寄存器等要求 CPL=0 [1][2]。
问题七:为什么用户态执行 mov %cr0 会 SIGSEGV?
**答题思路:**从特权检查出发:CR0 是控制寄存器,只能 CPL=0 访问,用户态触发 #GP。
**参考答案:**访问控制寄存器要求 CPL=0;用户程序 CPL=3,执行 mov %cr0, %eax 触发通用保护异常 #GP,进程收到 SIGSEGV。该行为已在上文经实测确认 [1][6]。
问题八:UMIP 是做什么的,为什么用户态 SGDT 读到的不是真实 GDT 地址?
**答题思路:**说明硬件先 #GP,再由内核模拟指令并写回结果(含伪基址)。
**参考答案:**UMIP(CR4 bit 11)开启后,CPL>0 执行 SGDT/SIDT/SLDT/STR/SMSW 会 #GP。Linux 在 fixup_umip_exception 里模拟指令并写回结果。实测 sgdt 得到 base=0xFFFE0000、limit=0,来自 UMIP_DUMMY_GDT_BASE 的 32 位截断 [4]。
问题九:32 位保护模式的两级分页怎么把线性地址映射到物理地址?
**答题思路:**讲清 CR3、页目录、页表三级结构,以及 4 KB 页的 10/10/12 拆分;点明这是 PAE=0 的 legacy 路径。
**参考答案:**在 legacy 32 位保护模式且 PAE=0 时:CR3 指向页目录(1024 个 PDE),PDE 指向页表(1024 个 PTE),PTE 指向 4 KB 页帧。线性地址拆为高 10 位查 PDE、中 10 位查 PTE、低 12 位作页内偏移;PSE 可让 PDE 直指 4 MB 大页。Linux x86-64 兼容模式不走这套两级表 [1][5]。
问题十:Linux 几乎不用分段,为什么还要讲保护模式的段机制?
**答题思路:**分清长模式与兼容模式:平坦的 CS/DS/ES/SS、仍在用的 FS/GS TLS,以及限长检查只在兼容/32 位保护路径上被本文实测。
**参考答案:**长模式下 CS/DS/ES/SS 基址按 0,且不做段限长运行时检查,但选择子与 CPL 仍在,FS/GS 基址可用于 TLS。32 位兼容进程分段语义接近 32 位保护模式,经 set_thread_area 把 TLS 写入 GDT 槽位(本宿主 entry 12 起),限长检查可被实测触发。UMIP 隐藏真实描述符表位置,系统调用进出也要切段寄存器与特权级 [1][3][4][6]。
参考资料
[1] Intel 64 and IA-32 Architectures Software Developer’s Manual, Volume 3: System Programming Guide,Chapter 3 Protected-Mode Memory Management(Segment Selectors、Descriptor Tables、Segment Descriptors)与 Chapter 4 Paging(4.3 32-bit Two-Level Paging、4.5 IA-32e Paging、PSE、PAE)。https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html
[2] OSDev Wiki:Global Descriptor Table(GDT)、Local Descriptor Table(LDT)、Segment Selector、Segment Descriptor 位级布局与字段含义。https://wiki.osdev.org/Global_Descriptor_Table https://wiki.osdev.org/Segment_Selector https://wiki.osdev.org/Segment_Descriptor
[3] Linux 内核 arch/x86/kernel/tls.c(tag v6.14):get_free_idx() 返回 idx + GDT_ENTRY_TLS_MIN,set_tls_desc() 写入 GDT,modified_sel = (idx << 3) | 3(TI=0、RPL=3);arch/x86/include/asm/segment.h 在 64 位配置下定义 GDT_ENTRY_TLS_MIN 12(32 位配置为 6)。https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git
[4] Linux 内核 arch/x86/kernel/umip.c(tag v6.14):UMIP_DUMMY_GDT_BASE 0xfffffffffffe0000,emulate 时 dummy_limit = 0;arch/x86/kernel/cpu/common.c:cr4_set_bits(X86_CR4_UMIP) 默认开启;traps 路径调用 fixup_umip_exception。https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git
[5] Intel SDM Volume 3, Chapter 4.3 32-bit Paging 与 Chapter 4.5 IA-32e Paging:CR3、页表项结构,4 KB / 大页拆分,R/W 与 U/S;兼容模式仍使用 IA-32e 分页。https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html
[6] 维基百科:Protected mode(80286 首次引入保护模式、80386 扩展为 32 位并引入分页、段选择子、GDT/LDT、特权级)、x86 memory segmentation(平坦模型)、Control register(CR0.PG、CR4.PSE/PAE/UMIP)。https://en.wikipedia.org/wiki/Protected_mode https://en.wikipedia.org/wiki/X86_memory_segmentation https://en.wikipedia.org/wiki/Control_register
[7] Stack Overflow 上关于保护模式寻址的高赞讨论(Peter Cordes、Michael Petch 等):
- Why does the x86 segment selector have both CPL and RPL. https://stackoverflow.com/questions/457365/current-privilege-level-vs-requested-privilege-level
- Why does Linux use a flat memory model. https://stackoverflow.com/questions/5470794/why-does-linux-use-a-flat-memory-model
- What is the difference between linear and physical addresses. https://stackoverflow.com/questions/2095602/what-is-the-difference-between-logical-linear-and-physical-addresses