首页 > 其他分享 >【MIT CS6.828】Lab 1: Booting a PC - Part 2: The Boot Loader

【MIT CS6.828】Lab 1: Booting a PC - Part 2: The Boot Loader

时间:2023-01-29 23:22:51浏览次数:59  
标签:GDT ELF Boot boot Lab Loader 地址 指令 加载

Part 2: The Boot Loader

1. 从 Boot Loader 开始

BOIS从磁盘读取Boot Loader到指定内存区域0x7c000x7dff(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

image

其中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

参考:

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的余下内容:

  1. 找到并读取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);
    // 读取所有段到内存中
    
  2. 开始执行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个字节,即地址0x10018e_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步:

  1. 查询磁盘状态是否空闲(具体实现是判断磁盘I/O状态端口01F7的bit 7<为1则磁盘正在执行命令,忙碌中>与bit 6<为1则表示设备就绪>是否分别为0和1);
  2. 若空闲,则将要读的扇区信息(从第几号扇区开始读、读几个等)写入端口0x1F30x1F6,并将命令0x20(表示读扇区)写入磁盘的I/O命令端口01F7
  3. 查询磁盘状态是否空闲;
  4. 若空闲,则从磁盘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开始执行.

要搞清楚0xf01000000x10000c之间的联系,首先需要了解 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 个字。

标签:GDT,ELF,Boot,boot,Lab,Loader,地址,指令,加载
From: https://www.cnblogs.com/streamazure/p/17074080.html

相关文章