Part 2: The Boot Loader
1. 从 Boot Loader 开始
BOIS从磁盘读取Boot Loader到指定内存区域0x7c00
到0x7dff
(512KB),然后执行jmp
指令,跳转到Boot Loader的第一条指令所在地址0x7c00
。
(gdb) b *0x7c00 #在地址0x7c00处打断点
Breakpoint 1 at 0x7c00
(gdb) c #执行到下一个断点后停止
Continuing.
The target architecture is set to "i8086".
[ 0:7c00] => 0x7c00: cli #0x7c00地址处的指令
Boot Loader并非是操作系统的一部分,各类操作系统可以有自己的Boot Loader,但一种Boot Loader也可以支持加载多种操作系统,如GNU GRUB
。
2. boot.S:从实模式切换到保护模式
练习 3(1) 在地址 0x7c00 处设置断点,这是加载引导扇区的位置。继续执行直到该断点。跟踪boot/boot.S
中的代码,使用源代码和反汇编文件 obj/boot/boot.asm
来跟踪您的位置。还使用 GDB 中的x/i
命令反汇编引导加载程序中的指令序列,并将原始引导加载程序源代码与obj/boot/boot.asm
和 GDB 中的反汇编进行比较。
加载 JOS 的 Boot Loader 的源程序代码位于/lab/boot/boot.S
和/lab/boot/main.c
中。在boot.S
的末尾会调用main.c
的代码以继续执行。
首先执行的是boot.S
。其中cli
一行即是被加载到0x7c00
处的Boot Loader第一条指令。.
开头的指令是汇编伪指令,没有对应的机器码,只用于提供汇编信息。
boot.S
中实现从实模式到保护模式的切换,大致分为 4 个步骤:关中断→使能 A20 地址线 → 加载 GDT → 将控制寄存器中CR0段的PE位置1,切换到保护模式。
2.1 关中断
#include <inc/mmu.h>
# Boot Loader,完成从实模式到保护模式的切换,并最终跳转至内核加载程序
# .set 给一个全局变量或局部变量赋值
.set PROT_MODE_CSEG, 0x8 # kernel code segment selector
.set PROT_MODE_DSEG, 0x10 # kernel data segment selector
.set CR0_PE_ON, 0x1 # protected mode enable flag
# 使符号start对整个工程可见
.globl start
start:
# 第一步,关中断(保证以下工作不会被其他程序打断,若被打断,会导致CPU发生异常)
.code16 # Assemble for 16-bit mode
cli # Disable interrupts 关中断
cld # String operations increment
# Set up the important data segment registers (DS, ES, SS).
# 将 ds/es/ss 寄存器全部置零
xorw %ax,%ax # Segment number zero
movw %ax,%ds # -> Data Segment
movw %ax,%es # -> Extra Segment
movw %ax,%ss # -> Stack Segment
2.2 使能 A20 地址线
为什么要使能 A20 地址线?为什么是通过向键盘控制器端口写入数据来使能 A20 地址线?其实基本上都是历史原因,是因为当年的程序员根据当时的硬件条件这么设计的。
对于8086,20根地址线为A0~A19,A20固定为0,这样使得超出0xFFFFF范围的地址会自动对0xFFFFF取模,如地址0x100000会自动变为0x00000,这称为地址环绕。
要使用 1MB 以外的内存空间,需要使能 A20 地址线,以突破地址环绕。
由于历史原因,A20线由8042键盘控制器芯片(这个芯片仍在如今的计算机主板上)控制。
0x64是8042的状态寄存器(这里可能需要一点CPU与I/O端口交互的前置知识),寄存器的最低位(第0bit)代表输出缓冲区状态,第1bit代表输入缓冲区状态,0空1满
CPU通过8042启用A20的固定流程是:CPU将命令0xd1写入0x64端口→将启用A20的固定值0xdf写入0x60端口
seta20.1: # 启用A20的第一步
inb $0x64,%al # Wait for not busy
# in指令,从0x64端口读取低8位数据到寄存器al(即ax的低8位)
testb $0x2,%al
# testb即对操作数的与运算,这里是判断al的第1个bit是否为0
# 即检查8042的输入缓冲区状态是否为空
# 若为0,则状态寄存器中ZF位置1;若为1,则ZF置0
jnz seta20.1
# 根据ZF位结果得知testb结果是否为0
# 若8042输入缓冲区不为空,则跳转回起始位置,循环检查,直到8042输入缓冲区为空
movb $0xd1,%al # 0xd1 -> port 0x64
outb %al,$0x64
# 8042输入缓冲区空,写入命令0xd1(当CPU操作为写入时,0x64代表8042的命令寄存器;为读入时,0x64代表8042的状态寄存器),
# 0xd1命令表示将下一个字节写入输出端口
seta20.2: # 启用A20的第二步
inb $0x64,%al # Wait for not busy
testb $0x2,%al
jnz seta20.2
# 同上,循环判断8042输入缓冲区是否为空
movb $0xdf,%al # 0xdf -> port 0x60
outb %al,$0x60
# 将数据0xdf写入8042的数据端口0x60(中的输出端口)
# --------- 完成对A20的使能 ----------
2.3 加载 GDTR
lgdt
指令:将自定义的GDT(gdtdesc定义在本文件最后)加载到GDT寄存器(GDTR)中。后面还会用到 GDT,所以对 GDT 内容的解释放在后文。
gdtdesc需要有两个属性:GDT 基址与限制(表格大小,单位字节)
# boot.S 末尾定义GDT表
# Bootstrap GDT
.p2align 2 # force 4 byte alignment
gdt:
SEG_NULL # null seg
SEG(STA_X|STA_R, 0x0, 0xffffffff) # code seg
SEG(STA_W, 0x0, 0xffffffff) # data seg
gdtdesc:
.word 0x17 # sizeof(gdt) - 1
.long gdt # address gdt
这一步代码可以理解为将全局段表加载到段表基址寄存器中。
lgdt gdtdesc
注意:到这一步为止,GDT加载仍未全部完成。boot.S
在切换到保护模式后会继续进行这项工作。
2.4 PE位置1,切换到保护模式
在80386中,控制寄存器CR大小16字节,均分为4部分:CR0、CR1、CR2、CR3,如下图所示(详见Intel 80386 Reference Programmer's Manual Table of Contents - 4.1 Systems Registers)
其中CR0的第0~4位、第31位是系统控制标志,其余位为保留位
CR0的第0位为PE位,将PE置1,从实模式切换到保护模式
movl %cr0, %eax # 不能直接修改CR0的值,只能通过MOV到通用寄存器修改
orl $CR0_PE_ON, %eax # 全局变量CR0_PE_ON=0x1,在文件开头定义;将PE位置1
movl %eax, %cr0 # 赋值给CR0
# --------- 完成CR0中PE位的置位 ----------
# ---------- 现在已处于保护模式 ----------
2.5 可能会有的问题:这些步骤是必须的吗?顺序可以颠倒吗?
我们发现,(从形式上)从实模式切换到保护模式其实只有一步:将PE位置1。
那么另外两个步骤呢?它们是必须的吗?一定要在PE位置1前完成吗?
我Google完得出的初步结论是:
-
关中断
cli
是必须的,且要首先执行。 -
lgdt gdtdesc
是必须的,且一定要在PE位置1前进行,但GDT加载的后续工作(如跳转和重新加载段寄存器)可以在之后再做。 -
使能 A20 不是必须的,且在实模式下和保护模式下都可以做。但如果不使能,会导致奇数兆字节无法访问,如访问1-2mb会变成访问0-1mb,访问3-4mb会变成访问2-3mb
参考:
- Why enable A20 line in Protected Mode?
- How to switch from real mode to protected mode after bootloader?
- 16位实模式切换32位保护模式过程详解
3. boot.S:GDT加载-跳转与段式地址转换
3.1 为什么必须跳转
在 lgdt gdtdesc
之后,还要继续进行 GDT 加载工作。因为这一指令只是将自定义的 GDT 表信息保存到了 GDTR。切换到保护模式之后,要指定一段代码的起始地址,不能像实模式那样直接给出一个完整的物理内存地址,而是要用到 GDT 进行地址转换。
为了让 CPU 寻址时能够使用 GDT ,必须重新加载所有的段寄存器。CS 是其中之一。但 CS 是特别的,它不能简单地通过mov
指令修改,而需要通过jump
。所以boot.S
在此处有一个ljmp
指令,后面是一堆mov
指令以修改其他的段寄存器。
ljmp $PROT_MODE_CSEG, $protcseg
# PROT_MODE_CSEG = 0x08 为段选择子,protcseg 为段内偏移量0x7c32(由编译器给出?)
# CS 中的值被置为 0x08
# 由于处在保护模式下,CPU不进行实模式下地址=CS<<4+IP的计算,而是进行段式的地址转换:根据0x08从GDT找到1号描述符,对应段基址为0x0,与段内偏移量0x7c32相加即完整地址0x7c32
# 跳转至地址为0x7c32
3.2 利用GDT完成段式地址转换
这里进一步研究地址转换具体是如何完成的:
如果学过段式内存管理,就很容易理解这么一个笼统的转换过程:程序给出一个逻辑地址,再根据逻辑地址的某几位,在段表中找到对应的项(物理地址),再与逻辑地址中的段内偏移量拼接起来,得到完整的物理地址。
在这行代码中,protcseg
是段内偏移量,它由编译器给出(?)
PROT_MODE_CSEG = 0x8
在文件开头定义,这是一个 Segment Selector (段选择器/段选择子),是逻辑地址的一部分,需要根据它在 GDT(段表) 中找到对应的项。
Segment Selector 固定16位长,各位含义如下图所示:(详见Intel 80386 Reference Programmer's Manual Table of Contents - 5.1 Segment Translation)
![img](1.Lab 1/fig5-6.gif)
- INDEX:理解为段号即可,要根据段号在段表中找到对应的段表项。正式地,CPU 根据这个 12 位长的 INDEX 在描述符表(GDT或LDT)中找到对应的 8 字节长的一个描述符。描述符表最多可包含 8192 个描述符,所以 INDEX 有 12 位。查询时,CPU 简单地将 INDEX 乘 8 ,再加上描述符表的基址,就得到了段表项的地址。
- TABLE INDICATOR:指明应该在哪个描述符表里找描述符。0 = GDT,1 = LDT。
- RPL:用于内存保护的字段,0 = 内核级,1 = 系统服务级,2 = 自定义扩展级, 3 = 应用级
PROT_MODE_CSEG = 0x8
,即 0000 1000
,INDEX = 1,TI = 0,RPL=0。可知对应描述符是 GDT 的 1 号描述符(注:GDT 中 INDEX=0 的表项是不使用的)。
GDT 的 1 号描述符的内容是什么?在切换到保护模式之前,我们加载了 GDT ,而 GDT 的内容是 boot.S
自己初始化的,即文件末的这段定义:
gdt:
SEG_NULL # null seg 0号表项,不使用
SEG(STA_X|STA_R, 0x0, 0xffffffff) # code seg 1号表项
SEG(STA_W, 0x0, 0xffffffff) # data seg 2号表项
1 号描述符的内容是SEG(STA_X|STA_R, 0x0, 0xffffffff)
,实际上是使用了/lab/inc/mmu.h
中的宏定义:
#define SEG(type,base,lim) \
.word (((lim) >> 12) & 0xffff), ((base) & 0xffff); \
.byte (((base) >> 16) & 0xff), (0x90 | (type)), \
(0xC0 | (((lim) >> 28) & 0xf)), (((base) >> 24) & 0xff)
这段宏定义的功能是根据type
(段类型)、base
(段基址)、lim
(段长)这三个参数去构造实际的 8 字节长的段描述符。
之所以要写成这么难看的样子,是因为段描述符的格式就是这么难看……比如32位的段基址不是连续的32位存储,而是分散在三个部分。这属于历史遗留问题。完整的 8 字节段描述符格式见下图:
![image-20230113220346132](1.Lab 1/image-20230113220346132.png)
这里我们知道了 1 号描述符中保存的段基址是0x0
。与段内偏移量0x7c32
拼接,即得到完整内存地址0x7c32
,即 GDB 给出的结果:
(gdb) x/i
0x7c2d: ljmp $0x8,$0x7c32
(gdb) x/i
0x7c32: mov $0xd88e0010,%eax
4. boot.S:GDT加载-重新加载段寄存器
80386 有 6 个段寄存器:CS、DS、SS、ES、FS、GS。
其中 CS 已通过ljmp $PROT_MODE_CSEG, $protcseg
指令将值重新加载为0x08
,即内核代码段基址。
剩下的5个寄存器都可通过mov
指令将值重新加载为PROT_MODE_DSEG = 0x10
,这是内核数据段基址。
段寄存器重新加载完毕后,后续的指令地址、数据地址都可以通过 GDT 完成地址转换了以正确访问了。
.code32 # Assemble for 32-bit mode
protcseg:
# Set up the protected-mode data segment registers
movw $PROT_MODE_DSEG, %ax # Our data segment selector
movw %ax, %ds # -> DS: Data Segment
movw %ax, %es # -> ES: Extra Segment
movw %ax, %fs # -> FS
movw %ax, %gs # -> GS
movw %ax, %ss # -> SS: Stack Segment
# Set up the stack pointer and call into C.
movl $start, %esp # ?完全不明白为什么要把0x7c00作为栈顶
call bootmain # 执行/lab/boot/main.c的代码,继续Boot Loader
# If bootmain returns (it shouldn't), loop.
spin:
jmp spin
5. main.c:加载内核
练习3(2) 跟踪到boot/main.c
中的bootmain()
,然后跟踪到readsect()
。确定与readsect()
中的每个语句相对应的确切汇编指令。跟踪readsect()
的其余部分并返回到bootmain()
,并确定从磁盘读取内核剩余扇区的for
循环的开始和结束。找出循环结束时将运行的代码,在那里设置断点,然后执行到该断点。然后逐步执行引导加载程序的其余部分。
boot.S
完成了无法用高级语言代码完成的工作(应该?),当 GDT 及段寄存器准备好之后,C语言代码运行的环境也就准备好了,Boot Loader的工作由main.c
接力完成。(什么?继续用汇编?不要为难自己……)
5.1 读取、校验、执行ELF文件
一个C程序经过编译和链接后会产生二进制可执行文件,ELF是二进制文件的一种格式。
首先来看 ELF 文件的结构:
名称 | 用途 |
---|---|
ELF 首部 | 校验、指出Section Header table(以下各个字段都是这个表格的一部分)相对于文件起始的偏移量、Section Header table的大小等 |
.text 字段 | 保存程序的源代码 |
.rodata字段 | 保存只读的变量 |
.data 字段 | 保存已经初始化的全局变量和局部变量 |
.bss 字段 | 保存未初始化的全局变量和局部变量 |
.... | .... |
在本实验中,JOS 内核(一个C程序)在执行make
之后编译产生了可执行文件/lab/obj/kern/kernel.img
,它是 ELF 格式的。在 QEMU 模拟出的硬件环境中,这个ELF文件被视为存储在了硬盘上。(就好比用U盘重装系统,得事先拷个系统镜像在U盘上才能装)
bootmain
函数要做的第一件事,是调用readseg
来读入硬盘上的ELF文件的第一页数据(大小为 512*8B = 4KB)(将 ELF 首部包含在内,可能读多了,但不影响)。
从下面代码可以看到,读入的 ELF 数据在内存中的起始地址为0x10000
.
#define SECTSIZE 512
#define ELFHDR ((struct Elf *) 0x10000) // scratch space
// read 1st page off disk
readseg((uint32_t) ELFHDR, SECTSIZE*8, 0);
// 将ELF文件从起始地址偏移0个字节后的连续512*8个字节数据读入到以ELFHDR为起始物理地址的内存中
根据ELF规范,对读入的数据进行校验,判断是否为合法的ELF文件:
// is this a valid ELF?
if (ELFHDR->e_magic != ELF_MAGIC)
// 判断文件是否以规定的固定值 0x464C457FU 开头
// (注:/ "\x7FELF" in little endian */*)
goto bad;
如果是合法的ELF文件,则继续读取ELF的余下内容:
-
找到并读取Program Header Table。这个表格保存了程序所有段的信息,读取这个表,就是读取程序的指令段、数据段等等。
// load each program segment (ignores ph flags) ph = (struct Proghdr *) ((uint8_t *) ELFHDR + ELFHDR->e_phoff); // e_phoff是ELF文件中的一个字段,用于指出Program Header Table的起始位置 eph = ph + ELFHDR->e_phnum; // e_phnum用于指出Program Header Table中有多少个entries // 计算得出eph,即Program Header Table的结束位置 for (; ph < eph; ph++) // p_pa is the load address of this segment (as well // as the physical address) readseg(ph->p_pa, ph->p_memsz, ph->p_offset); // 读取所有段到内存中
-
开始执行ELF中的指令,正式运行内核。
// call the entry point from the ELF header // note: does not return! ((void (*)(void)) (ELFHDR->e_entry))(); // e_entry字段指向的是这个文件的执行入口地址 // CPU开始执行这个二进制文件,也即计算机开始运行JOS
这里还可以进一步看看内核的第一条指令在哪里:
上面一行代码对应的汇编结果是(在/lab/obj/boot/boot.asm
中):7d71: ff 15 18 00 01 00 call *0x10018
。以内存地址0x10018
所存储的值为目标地址进行跳转。
为什么是0x10018
?前面加粗过,读入的 ELF 文件以0x10000
为起始地址。根据 ELF 规范,从起始地址向后偏移0x18
个字节,即地址0x10018
为e_entry
字段,保存的是程序执行的入口点(entry point)。
在 GDB 中查看内存地址0x10018
中的值:
(gdb) b *0x7d71
(gdb) c # 记得先执行到已经把ELF文件加载完毕的位置
(gdb) x/1x 0x10018
0x10018: 0x0010000c
即内核的第一条指令位于0x10000c
中。
5.2 readsect
函数分析
bootmain
函数调用readseg
函数进行ELF文件的读取,每调用一次读取一段(eg. ELF文件首部、代码段、数据段等),而readseg
又调用了readsect
进行一个扇区的读取(一个程序段可能存储在连续多个扇区)。
练习 3 要求我们深入readsect
函数,将 C 源代码与汇编指令对应起来。
从/lab/obj/boot/boot.asm
文件可知,readsect
函数的起始地址为0x00007c78
。补充的注释是汇编指令对应的C源码。
(gdb) b *0x7c78
Breakpoint 1 at 0x7c78
(gdb) c
Continuing.
The target architecture is set to "i386".
=> 0x7c78: push %ebp
Breakpoint 1, 0x00007c78 in ?? ()
(gdb) x/37i
# readsect函数开始,以下是汇编中call指令前后的固定的栈操作,用于分隔调用者和被调用者的栈空间
0x7c79: mov %esp,%ebp
0x7c7b: push %edi
0x7c7c: push %eax
0x7c7d: mov 0xc(%ebp),%ecx
# // wait for disk to be ready
# waitdisk();
0x7c80: call 0x7c6a # 调用 waitdisk 函数
# outb(0x1F2, 1); // count = 1
0x7c85: mov $0x1,%al
0x7c87: mov $0x1f2,%edx
0x7c8c: out %al,(%dx)
# outb(0x1F3, offset);
0x7c8d: mov $0x1f3,%edx
0x7c92: mov %ecx,%eax
0x7c94: out %al,(%dx)
# outb(0x1F4, offset >> 8);
0x7c95: mov %ecx,%eax
0x7c97: mov $0x1f4,%edx
0x7c9c: shr $0x8,%eax
0x7c9f: out %al,(%dx)
# outb(0x1F5, offset >> 16);
0x7ca0: mov %ecx,%eax
0x7ca2: mov $0x1f5,%edx
0x7ca7: shr $0x10,%eax
0x7caa: out %al,(%dx)
# outb(0x1F6, (offset >> 24) | 0xE0);
0x7cab: mov %ecx,%eax
0x7cad: mov $0x1f6,%edx
0x7cb2: shr $0x18,%eax
0x7cb5: or $0xffffffe0,%eax
0x7cb8: out %al,(%dx)
# outb(0x1F7, 0x20); // cmd 0x20 - read sectors
0x7cb9: mov $0x20,%al
0x7cbb: mov $0x1f7,%edx
0x7cc0: out %al,(%dx)
# // wait for disk to be ready
# waitdisk();
0x7cc1: call 0x7c6a # 调用 waitdisk 函数
# // read a sector
# insl(0x1F0, dst, SECTSIZE/4);
0x7cc6: mov $0x80,%ecx
0x7ccb: mov 0x8(%ebp),%edi
0x7cce: mov $0x1f0,%edx
0x7cd3: cld
0x7cd4: repnz insl (%dx),%es:(%edi)
# readsect函数返回,以下是汇编中call指令前后的固定的栈操作,用于分隔调用者和被调用者的栈空间
0x7cd6: pop %edx
0x7cd7: pop %edi
0x7cd8: pop %ebp
0x7cd9: ret
顺便也看一下readsect
函数具体是如何读磁盘的。可以简单概括为4步:
- 查询磁盘状态是否空闲(具体实现是判断磁盘I/O状态端口
01F7
的bit 7<为1则磁盘正在执行命令,忙碌中>与bit 6<为1则表示设备就绪>是否分别为0和1); - 若空闲,则将要读的扇区信息(从第几号扇区开始读、读几个等)写入端口
0x1F3
到0x1F6
,并将命令0x20
(表示读扇区)写入磁盘的I/O命令端口01F7
; - 查询磁盘状态是否空闲;
- 若空闲,则从磁盘I/O数据端口
0x1F0
读入数据;
关于此处用到的磁盘I/O端口号及对应用途,详见XT, AT and PS/2 I/O port addresses(搜索关键词1st Fixed Disk Controller)
练习3的问题:
-
处理器什么时候开始执行 32 位代码?究竟是什么导致从 16 位模式切换到 32 位模式?
在
boot.S
的ljmp $PROT_MODE_CSEG, $protcseg
处开始执行代码。从 16 位模式切换到 32 位保护模式,既要求 PE 标志位的修改,也要求 GDT 加载完毕(否则只是在形式上切换到了保护模式下而已,没办法实际进行保护模式下的寻址、执行代码),所以是以下指令合起来完成了从 16 位模式到 32 位模式的切换。
# 加载GDT到GDTR lgdt gdtdesc # 修改PE标志位 movl %cr0, %eax orl $CR0_PE_ON, %eax movl %eax, %cr0 # Jump to next instruction, but in 32-bit code segment. # Switches processor into 32-bit mode. # 更新全部的段寄存器 ljmp $PROT_MODE_CSEG, $protcseg movw $PROT_MODE_DSEG, %ax # Our data segment selector movw %ax, %ds # -> DS: Data Segment movw %ax, %es # -> ES: Extra Segment movw %ax, %fs # -> FS movw %ax, %gs # -> GS movw %ax, %ss # -> SS: Stack Segment # 更新栈顶指针 movl $start, %esp
-
Boot Loader执行 的最后一条指令是什么,它刚刚加载的内核的第一条指令是什么?
Boot Loader最后一条指令是:
((void (*)(void)) (ELFHDR->e_entry))();
对应的汇编指令是:
7d71: ff 15 18 00 01 00 call *0x10018 # call *0x10018 意为以0x10018这个内存地址中存储的值为目标地址跳转(函数调用),而非直接跳转到0x10018
内核的第一条指令是:
movw $0x1234,0x472 # warm boot
这个可以在
obj/kern/kernel.asm
看到,也可以通过 GDB 单步调试得到:(gdb) b *0x7d71 Breakpoint 1 at 0x7d71 (gdb) c Continuing. The target architecture is set to "i386". => 0x7d71: call *0x10018 Breakpoint 1, 0x00007d71 in ?? () (gdb) si => 0x10000c: movw $0x1234,0x472 # 内核第一条指令 0x0010000c in ?? ()
-
内核的第一条指令在哪里?
Boot Loader最后一条指令为
call *0x10018
,而0x10018
的值为0x0010000c
;所以内核的第一条指令在
0x0010000c
。 -
Boot Loader如何决定它必须读取多少个扇区才能从磁盘中获取整个内核?它在哪里找到这些信息?
在
boot/main.c
中可以看到:ph = (struct Proghdr *) ((uint8_t *) ELFHDR + ELFHDR->e_phoff); eph = ph + ELFHDR->e_phnum; for (; ph < eph; ph++) // p_pa is the load address of this segment (as well // as the physical address) readseg(ph->p_pa, ph->p_memsz, ph->p_offset);
其中
ph->p_pa
为Program Header Table中每一段的起始物理地址,eph
为Program Header Table的结束地址,读完了这个Program Header Table就是读完了整个内核。至于每一段该读多少字节,从ph->p_memsz
得到,而一个扇区大小为 512 字节,换算一下即可得知是多少个扇区(不过源码中虽然是以扇区为单位读的,但起始和终止是以字节数计的,也没做这个换算)
5.3 VMA与LMA
留意一个问题:内核的第一条指令到底在哪里?
GDB 调试结果显示,内核的第一条指令地址为0x10000c
;但obj/kern/kernel.asm
又显示,内核从0xf0100000
开始执行.
要搞清楚0xf0100000
与0x10000c
之间的联系,首先需要了解 VMA 和 LMA。
VMA 为虚拟地址(Virtual Memory Address)/链接地址,是段期望开始执行的地址。
LMA 为加载地址(Load Memory Address),是段实际加载到内存中的地址(在本Lab中可以直接理解为内存物理地址?)。
作为一个简单的例子,先来看看 Boot Loader 的 .text 段的 VMA 与 LMA。
$ objdump -h obj/boot/boot.out
...
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 0000018c 00007c00 00007c00 00000074 2**2
CONTENTS, ALLOC, LOAD, CODE
...
这里 VMA 和 LMA 是相同的,都是0x7c00
,这个地址我们也很熟悉,Boot Loader 的第一条指令就是从0x7c00
开始执行的。
这个地址是文件boot/Makefrag
中通过-Ttext 0x7C00
指定的(作为make
的编译参数?)。显然,修改这个值后重新编译生成的obj/boot/boot.out
中,VMA 和 LMA 应该会发生变化。
练习 5. 尝试将boot/Makefrag
中的链接地址改成别的,之后make clean
,再make
,然后重新跟踪 Boot Loader 以查看发生了什么。找到中断或出错的第一条指令。最后记得把链接地址改回来,然后make clean && make
。
将-Ttext 0x7C00
改为-Ttext 0x7C10
。重新编译后进入GDB调试:
(gdb) b *0x7c10
(gdb) c
Continuing.
Program received signal SIGTRAP, Trace/breakpoint trap.
[ 0:7c2d] => 0x7c2d: ljmp $0x8,$0x7c42
0x00007c2d in ?? ()
GDB 显示出错的第一条指令为 0x7c2d: ljmp $0x8,$0x7c42
。
再次查看此时的 VMA 与 LMA,会发现都变成了0x7c10
。
$ objdump -h obj/boot/boot.out
obj/boot/boot.out: file format elf32-i386
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 0000018c 00007c10 00007c10 00000074 2**2
CONTENTS, ALLOC, LOAD, CODE
接下来再来查看内核的 VMA 与 LMA:
$ objdump -x obj/kern/kernel # 查看ELF文件各部分的名称、大小、VMA、LMA等
obj/kern/kernel: file format elf32-i386
obj/kern/kernel
architecture: i386, flags 0x00000112:
EXEC_P, HAS_SYMS, D_PAGED
start address 0x0010000c
Program Header:
LOAD off 0x00001000 vaddr 0xf0100000 paddr 0x00100000 align 2**12
filesz 0x00006dc2 memsz 0x00006dc2 flags r-x
# LOAD标记 表示这是需要加载到内存的数据,上面描述的是.text段
LOAD off 0x00008000 vaddr 0xf0107000 paddr 0x00107000 align 2**12
filesz 0x0000b6c1 memsz 0x0000b6c1 flags rw-
# LOAD标记 表示这是需要加载到内存的数据,上面描述的是.data段
STACK off 0x00000000 vaddr 0x00000000 paddr 0x00000000 align 2**4
filesz 0x00000000 memsz 0x00000000 flags rwx
Sections: # 省略了一些无需关心的字段
Idx Name Size VMA LMA File off Algn
0 .text 00001a21 f0100000 00100000 00001000 2**4
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .rodata 000006d4 f0101a40 00101a40 00002a40 2**5
CONTENTS, ALLOC, LOAD, READONLY, DATA
...
4 .data 00009300 f0107000 00107000 00008000 2**12
CONTENTS, ALLOC, LOAD, DATA
...
9 .bss 00000661 f0112060 00112060 00013060 2**5
CONTENTS, ALLOC, LOAD, DATA
注意到,内核文件 .text 字段的 LMA 与 VMA 完全不同。事实上,链接内核比链接Boot Loader程序要复杂得多,相关的链接配置在kern/kernel.ld
文件中。
可以在kern/kernel.ld
文件中找到内核 .text 字段的 LMA 链接地址被指定的地方:
/* Link the kernel at this address: "." means the current address */
. = 0xF0100000;
操作系统内核偏向于运行在很高的 VMA 上,比如上面这个0xF0100000
,而将低位的虚拟空间地址留给用户使用(Lab 2 会解释原因)。
同时我们还知道,虚拟内存一般是远大于物理内存的,在确定了内核 VMA 为0xF0100000
的情况下,不能简单粗暴地也将 LMA 定为0xF0100000
,因为很多机器在这个物理地址上没有物理内存。实际做法是,通过硬件将这个 VMA 映射到较低的 LMA 上。显然,这个 LMA 越低,对物理内存大小的要求也越低。最低能到哪里呢?答案是0x100000
,即保留的1MB
以外的能用的第一个地址(显然这样的方案只考虑了那些至少有几MB物理内存的机器,8086这种太古早的是用不了的)。
+------------------+ <- 0x00100000 (1MB) (注:1MB空间最后一个地址0x000FFFFF)
| BIOS ROM |
+------------------+ <- 0x000F0000 (960KB)
| 16-bit devices, |
| expansion ROMs |
+------------------+ <- 0x000C0000 (768KB)
| VGA Display |
+------------------+ <- 0x000A0000 (640KB)
| |
| Low Memory |
| |
+------------------+ <- 0x00000000
如此便能理解为何 .text 字段的 VMA 为0xF0100000
,而 LMA 却为 0x100000
。
(至于为什么 GDB 调试显示第一条指令的地址是0x10000c
还没搞明白……)
练习 6. 重新启动 QEMU 和 GDB,在 Boot Loader 刚开始执行时停止,查看 0x00100000
开始的 8 个字;再在进入内核时停止,再次查看这 8 个字。它们为什么不同?
显然 Boot Loader 刚开始执行时0x00100000
开始的 8 个字还没有任何内容,值被初始化为0
,而在进入内核时已经将 ELF 文件读入到这个位置,内容被更新为 ELF 文件的前 8 个字。