手把手图解:Linux 0.11 启动时那场关键的‘内存大搬家’(从 0x10000 到 0x0)
手把手图解:Linux 0.11 启动时那场关键的‘内存大搬家’(从 0x10000 到 0x0)
当计算机从通电到操作系统完全启动的短短几秒内,内存中发生着一场精密的数据迁移。这场迁移不仅关乎系统能否正常启动,更体现了早期操作系统设计者对硬件资源的极致掌控。本文将带您深入Linux 0.11启动过程中那段关键的汇编代码,揭示为何需要将0x10000至0x90000的数据搬迁到物理内存起始处,以及这一操作如何为后续的保护模式切换铺平道路。
1. 启动初期的内存布局
在理解"大搬家"之前,我们需要先了解计算机刚完成自检时的内存状态。此时BIOS已经完成了硬件检测,并将控制权交给了位于0x7C00处的bootsect.s引导扇区代码。这段512字节的代码随后将自己复制到0x90000处,并加载setup.s和system模块到0x90200和0x10000位置。
此时的内存布局如下:
| 内存地址范围 | 内容描述 |
|---|---|
| 0x00000-0x003FF | BIOS中断向量表 |
| 0x00400-0x004FF | BIOS数据区 |
| 0x00500-0x07BFF | 可用内存 |
| 0x07C00-0x07DFF | 原始bootsect.s |
| 0x10000-0x8FFFF | system模块 |
| 0x90000-0x901FF | 移动后的bootsect.s |
| 0x90200-0x90FFF | setup.s |
这种布局看似合理,但存在一个关键问题:当系统准备进入保护模式时,需要重新配置全局描述符表(GDT)和中断描述符表(IDT),而这些数据结构最好放置在内存起始位置。
2. 为何需要内存搬迁
2.1 保护模式的内存需求
实模式下,程序通过段寄存器:偏移地址的方式访问内存,每个段最大64KB。而保护模式下,内存管理完全由操作系统控制,需要通过GDT和IDT来定义内存段的属性和权限。
Linux 0.11需要:
- 在0x00000处放置新的GDT和IDT
- 确保system模块位于连续的低地址空间
- 保留BIOS获取的硬件信息(存储在0x90000附近)
如果保持原有布局,0x00000处仍被BIOS中断向量表占据,无法满足这些需求。
2.2 搬迁的具体目标
setup.s中的do_move循环执行以下操作:
- 将0x10000-0x8FFFF的内容下移到0x00000-0x7FFFF
- 保留0x90000-0x90FFF的硬件信息
- 腾出0x00000-0x0FFFF空间用于GDT/IDT
这样调整后,内存布局变为:
| 内存地址范围 | 内容描述 |
|---|---|
| 0x00000-0x0FFFF | 新GDT/IDT空间 |
| 0x10000-0x8FFFF | system模块(原位置) |
| 0x90000-0x90FFF | 硬件信息区 |
3. 深入do_move汇编实现
让我们逐行分析这段经典的汇编代码:
mov ax,#0x0000 ; 目标段初始化为0x0000 cld ; 清除方向标志,确保movs向前移动 do_move: mov es,ax ; 设置目标段地址 add ax,#0x1000; 每次移动4KB(0x1000字节) cmp ax,#0x9000; 是否到达0x90000(段地址表示) jz end_move ; 完成则跳转 mov ds,ax ; 设置源段地址 sub di,di ; 目标偏移清零 sub si,si ; 源偏移清零 mov cx,#0x8000; 设置计数器(0x8000字=64KB) rep movsw ; 重复移动字数据 jmp do_move ; 继续下一块 end_move:这段代码的精妙之处在于:
- 批量传输:使用
rep movsw指令每次传输64KB数据 - 段地址递增:每次循环源段和目标段都增加0x1000(4KB)
- 高效循环:通过简单的比较和跳转控制流程
实际传输过程如下表所示:
| 循环次数 | 源地址范围 | 目标地址范围 | 传输量 |
|---|---|---|---|
| 1 | 0x10000-0x1FFFF | 0x00000-0x0FFFF | 64KB |
| 2 | 0x20000-0x2FFFF | 0x10000-0x1FFFF | 64KB |
| ... | ... | ... | ... |
| 8 | 0x80000-0x8FFFF | 0x70000-0x7FFFF | 64KB |
4. 搬迁后的关键操作
内存搬迁完成后,系统紧接着执行以下关键步骤:
4.1 加载段描述符
end_move: mov ax,#SETUPSEG mov ds,ax lidt idt_48 ; 加载IDT lgdt gdt_48 ; 加载GDT这里idt_48和gdt_48是预先定义好的描述符表指针,其结构如下:
IDT描述符:
idt_48: .word 0 ; 界限 .long 0 ; 基址GDT描述符:
gdt_48: .word 0x800 ; 界限(2KB) .long 0x00000 ; 基址4.2 切换到保护模式
mov ax,#0x0001 lmsw ax ; 加载机器状态字(CR0) jmpi 0,8 ; 跳转到保护模式代码这个jmpi 0,8指令中的8是段选择子,指向GDT中的代码段描述符。
5. 实际调试技巧
如果想在模拟器(Bochs/QEMU)中观察这一过程,可以:
- 在
do_move循环开始前设置断点 - 使用内存查看命令观察0x10000和0x00000处内容
- 单步执行每条汇编指令
- 特别注意ES、DS、SI、DI寄存器的变化
调试示例命令(Bochs):
b 0x90200:0x00 # 在setup.s开始处设断点 c # 继续执行 s # 单步执行 x /16x 0x10000 # 查看源内存 x /16x 0x00000 # 查看目标内存 info registers # 查看寄存器状态6. 历史背景与现代对比
这一设计反映了早期PC硬件的限制:
- 实模式下1MB内存限制
- BIOS服务的依赖
- 保护模式切换的特殊要求
现代Linux启动过程已经大为简化:
- 使用GRUB等引导加载程序直接进入保护模式
- 内存管理更灵活
- 不再需要手动搬迁系统代码
然而理解这一经典机制仍有价值:
- 学习x86架构的演变
- 理解操作系统与硬件的交互
- 掌握底层内存操作技巧
在开发嵌入式系统或微内核时,类似的低层次内存操作仍然常见。
