1. 从Linux 0.11进程调度看内存分段的本质
当我在大学第一次接触Linux 0.11源码时,最让我困惑的不是进程调度算法本身,而是为什么每次切换进程都要操作那些奇怪的段寄存器。直到某天深夜调试时突然意识到——原来进程调度和内存分段是硬币的两面。让我们从一个真实的场景开始:
假设进程A正在执行时,它的代码中有一条指令
mov [0x1234], eax
。如果没有分段机制,这个地址0x1234会直接指向物理内存,那么当切换到进程B时,如果B也往同一个地址写数据,就会导致A的数据被覆盖。这就是早期操作系统面临的"地址冲突"噩梦。
Linux 0.11采用的解决方案是通过分段建立虚拟地址空间。具体来说:
- 每个进程拥有独立的代码段(CS)、数据段(DS)、堆栈段(SS)
- 段寄存器中存储的不是物理地址,而是段选择子(Segment Selector)
- 实际物理地址 = 段基址 + 偏移地址
这样当进程A访问[0x1234]时,实际访问的是DS_A.base + 0x1234;而进程B的同一条指令访问的则是DS_B.base + 0x1234,二者互不干扰。
关键点:分段机制本质是通过基址重定位实现进程间地址空间隔离,这是多任务运行的基础保障。
2. Linux 0.11中的段描述符详解
在Linux 0.11的源码中,段描述符的定义位于include/asm/segment.h:
struct desc_struct {
unsigned long a,b;
};
这个看似简单的结构体却藏着精妙的设计。每个描述符实际占用8字节,其中包含:
- 段基址(32位,分三部分存储)
- 段限长(20位)
- 段类型(4位)
- 特权级DPL(2位)
- 存在位P(1位)
在head.s的初始化代码中,我们可以看到GDT(全局描述符表)的建立过程:
gdt:
.word 0,0,0,0 # 空描述符
.word 0x07FF # 8Mb - limit=2047 (2048*4096=8Mb)
.word 0x0000 # base address=0
.word 0x9A00 # code read/exec
.word 0x00C0 # granularity=4096, 386
.word 0x07FF # 8Mb - limit=2047 (2048*4096=8Mb)
.word 0x0000 # base address=0
.word 0x9200 # data read/write
.word 0x00C0 # granularity=4096, 386
这里有几个值得注意的细节:
- 第一个描述符必须为空描述符,这是CPU的规定
- 代码段和数据段的限长都是8MB,这与当时物理内存大小有关
- 粒度位G=1表示限长以4KB为单位计算
- 特权级设置为0(内核态)
3. 进程切换时的段寄存器操作
在schedule()函数中,当需要切换进程时,最关键的操作是加载新进程的LDT(局部描述符表)。相关代码在sched.c中:
// 切换到任务n
#define switch_to(n) {\
struct {long a,b;} __tmp; \
__asm__("cmpl %%ecx,_current\n\t" \
"je 1f\n\t" \
"movw %%dx,%1\n\t" \
"xchgl %%ecx,_current\n\t" \
"ljmp %0\n\t" \
"1:\t" \
::"m" (*&__tmp.a),"m" (*&__tmp.b), \
"d" (_TSS(n)),"c" ((long) task[n])); \
}
这段内联汇编完成了几个关键操作:
- 比较当前任务是否已经是目标任务
- 将新任务的TSS选择子存入__tmp.b
- 通过ljmp指令触发任务切换
但隐藏在这背后的分段机制才是真正的魔法:
- 每个任务有自己的LDT,存储在线性地址空间的不同位置
- ljmp指令会触发CPU自动加载新的CS段寄存器
- 后续的内存访问会自动使用新任务的DS/SS段寄存器
实测技巧:在Bochs模拟器中,可以通过"info gdt"和"info ldt"命令查看描述符表状态,这对调试进程切换问题非常有用。
4. 分段与分页的演进关系
虽然现代Linux主要使用分页机制,但理解分段对掌握操作系统原理仍然重要。通过对比可以更深入理解二者的设计哲学:
| 特性 | 分段机制 | 分页机制 |
|---|---|---|
| 隔离粒度 | 以段为单位(代码/数据/堆栈) | 以页为单位(通常4KB) |
| 地址转换 | 基址+偏移 | 多级页表查询 |
| 优势 | 天然隔离不同用途的内存区域 | 更灵活的内存分配 |
| 缺点 | 容易产生内存碎片 | TLB命中率影响性能 |
| 典型应用 | Linux 0.11的多任务实现 | 现代操作系统的虚拟内存 |
在Linux发展过程中,从0.11到2.4内核有一个有趣的过渡:
- 0.11版本:纯分段
- 1.0版本:分段+分页混合
- 2.4以后:基本以分页为主
但即使在现代Linux中,分段机制仍然以某种形式存在:
- 用户态和内核态的隔离仍然依赖段权限检查
- x86架构强制要求使用分段(虽然可以设置为平坦模式)
- 一些安全扩展(如SMEP)基于段机制实现
5. 调试实践:跟踪一次真实的进程切换
让我们用GDB实际观察一次进程切换时的段寄存器变化。首先在schedule()函数设置断点:
gdb vmlinux
(gdb) b schedule
(gdb) c
当断点触发时,查看当前段寄存器:
(gdb) info registers cs ds ss
cs 0x10 16
ds 0x18 24
ss 0x18 24
这些数值实际上是段选择子,其二进制格式为:
15 3 2 1 0
┌───────────────┬───┬───┐
│ Index │TI │RPL│
└───────────────┴───┴───┘
- 0x10 = 10000b → Index=2, TI=0(GDT), RPL=0
- 0x18 = 11000b → Index=3, TI=0(GDT), RPL=0
继续执行到切换完成,再次查看段寄存器,会发现它们已经指向了新进程的LDT描述符。
常见问题:如果在调试时发现段寄存器值意外变化,很可能是没有正确处理LDT切换。检查switch_to宏的实现是否正确加载了新任务的TSS。
6. 从硬件角度理解分段保护
分段机制提供的保护功能常常被忽视。CPU在每次内存访问时都会进行以下检查:
- 段存在性检查(P=1?)
- 特权级检查(CPL ≤ DPL?)
- 类型检查(写只读段?)
- 限长检查(offset ≤ limit?)
这些检查在Linux 0.11中发挥了重要作用:
- 防止用户进程直接访问内核数据(DPL=0)
- 防止代码段被意外修改(类型=只读执行)
- 防止栈溢出(SS段有固定限长)
一个典型的保护异常场景是:当用户程序试图执行
cli
指令(清除中断标志)时,由于该指令的CPL必须=0,而用户程序运行在CPL=3,会导致通用保护故障(GP)。
在代码中可以看到对这种情况的处理(trap.c):
void do_general_protection(long esp, long error_code) {
die("general protection",esp,error_code);
}
7. 现代系统中的分段机制遗产
虽然现代Linux主要使用分页机制,但分段的影响仍然无处不在:
- 权限控制:用户态/内核态隔离源于段特权级设计
- 安全扩展:SMAP/SMEP依赖段机制实现
- 调试支持:硬件断点使用调试寄存器与段机制配合
- 虚拟化:VMX操作涉及段状态保存/恢复
在Linux 5.x内核中,我们仍然能看到段相关的初始化代码(arch/x86/kernel/head_64.S):
/* Setup GDT */
lgdt early_gdt_descr(%rip)
movl $__KERNEL_DS, %eax
movl %eax, %ds
movl %eax, %es
movl %eax, %ss
这段代码将内核数据段选择子加载到各个段寄存器,虽然采用的是平坦内存模型(基址=0,限长=最大),但仍然需要维持段机制的基本框架。
我在研究Linux内存管理演进史时发现一个有趣的现象:许多现代操作系统的教材会刻意淡化分段机制,但实际上不理解分段就很难真正理解保护模式的工作原理。这就像试图理解高楼大厦却忽视地基的存在一样。

411

被折叠的 条评论
为什么被折叠?



