【MIT 6.828 Lab1 】探寻PC一键启动背后的机制

1,158 阅读11分钟

MIT 6.828 Lab1 PC启动

MIT 6.828 Lab1涵盖PC启动的各个流程:BIOS->Bootloader->Kernel

PC的物理地址布局如下,PC开机之后为实模式,地址宽度为20位,可寻址空间为1M,地址划分如下:

image-20210728182700206

img

1. BIOS

其中PC启动的第一站是BIOS,按lab1的指示,运行make qemu-gdb与make gdb后,可以看到首地址为:[f000:fff0],地址换算:physical address = 16 * segment + offset,得最终地址 0xffff0

0xffff0 对应物理内存布局表中第一列的起始地址:FFFF0,其指令类型指令为:ljmp

image-20210722102523210

bios会初始化一些中断向量表,然后会初始化一些重要设备比如vga等等,当它找到可启动磁盘时,BIOS将引导加载程序从磁盘读取。随后转移到引导启动程序上去。

2. 前置知识

i386因向后兼容,有实模式与保护模式,以及对应的分段寻址 Segmentation;

image-20210730181710510

2.1 实模式

  • 地址形式:[A:B],寄存器大小:16 bits,寻址空间:64k;

  • 地址翻译:实模式使用逻辑地址 [A:B] 来寻址内存,采用如下的等式来得到对应的物理地址:Physical address = (A * 0x10) + B

实模式中寄存器限制为16 bit,若 A 固定,B 可以寻址 64k 大小的内存区域,这被称为一个段 Segment

A = A 64k segment B = Offset within the segment

EVERY time you form an address on an x86 processor there will be a segment register involved.

在 x86 处理器中,每个地址都有一个与之相关的寄存器,在未显示声明的情况下,会有指令相关的默认寄存器

MOV [SI], AX will write the word contained in ax to the address DS:SI

MOV ES:[DI], AX will write the word contained in ax to the address es:di

image-20210730110316225

段寄存器读写

  • 除 CS 外,其余段寄存器的值可以使用通用寄存器加载,或者使用栈赋值
mov ds, ax
pop ds
  • 代码段寄 CS 的值无法直接修改,在代码进行控制转移时,进行更新,相关的指令为:

image-20210730110921542

2.2 保护模式

  • 地址形式:[A:B],寄存器大小:32 bits,寻址空间:4G

在保护模式中,A 称为段选择器 selector,代表在 GDT表 中的索引,对应的 GDT Entry 用来描述一个段的特征,GDT Entry 会指明段的:base,limit,type

  • 地址翻译:
 Physical address = Segment Base (Found from the descriptor GDT[A]) + B

image-20210730111905615

2.3 GDT

GDT 全称 Global Descriptor Table,与分段寻址相关;详细介绍可参考:wiki.osdev.org/GDT

全局描述符表GDT 以及 segment 相关的知识,是 x86 体系架构产物;

gdt 通过 gdt descriptor 进行说明,主要为数组 A T[N] 中的 N(对应Size) 与 A (对应offset)

有专用的寄存器存储其地址,使用lgdt汇编指令加载 gdt descriptor,其格式如下:

img

  • size字段值为sizeof(gdt) - 1,原因是size最大可为2的16次方65536,-1用来做一个size的映射
  • offset字段为gdt的地址

GDT Entry结构如下,为一个64位的结构体

struct gdt_entry {
  uint16 limit_low;
  uint16 base_low;
  uint8  base_middle;
  uint8  access;
  uint8  attributes;
  uint8  base_high;
} __attribute__((packed));
typedef struct gdt_entry gdt_entry_t;

img

Entry中最明显的部分为:

  • limit,20位的limit表示最大的可寻址单位(maximum addressable unit)
  • base,32位的base表示段的开始地址

其余部分为状态位:

img

image-20210728205337488

补充:

与GDT类似的还有LDT(本地描述符表,Interrupt Descriptor Table),IDT(中断描述符表,Interrupt Descriptor Table)

2.3 总结

gdt entry 中limit为20位,可寻址2^20大小的内存单元

image-20210730181710510

参考资料:

3. BootLoader

BootLoader 的内容在 boot 文件夹下,包括 Boot.s 与 main.c

其中 Boot.s 负责:

  • 状态设置,禁用中断,主要寄存器设置(DS,SS,ES)

  • A20 Gate设置(轮询设置,保持兼容)

  • 从实模式切换至保护模式,加载 GDT & 重载段寄存器(保证切换期间内存映射不变)

  • 设置栈指针,调用 bootmain 函数

boot.S 中通过 gdtdesc 表示 gdt 描述符,并使用宏 SEG 来设置 GDT Entry:

image-20210728192809072

image-20210728192955787

参考2.3,在MIT 6.828 中 boot.S加载 gdt,并且生成三个 gdt entry;

  • SEG 函数传入参数 limit 为32位,而 gdt entry 中 limit 为20位,所以低位的12个位会被跳过,然后取值;
  • FLAG 位为 0xC(1100),Sz 位为1(定义32位保护模式),Gr 位为1(代表页的粒度为4 Kib);
  • Access Byte 根据传入参数 type 指定,并与 0x90(10010000,为 Pr 与 Ac 置位,代表有效选择器,且可访问) 取或操作;

代码段传参为 STA_X | STA_R 代表可读可执行,数据段传 STA_W,代表可写

image-20210729094208656

boot.S 中通过 lgdt gdtdesc 加载 gdt 描述符,然后重载段寄存器;

  • cs 段寄存器初始化为 kernel code 段。注意 cs 寄存器的值不能直接通过 mov 指令设置,而是必须通过跳转语句隐式地被设置。

  • 其余段均被设置为 kernel data

MIT 6.828 中 Bootloader 执行时处于实模式: %cs=0 %ip=7c00,在转换为保护模式时,通过将 代码段对应的selector 0x8 加载到代码段寄存器 CS,该 entry 对应的 base 为 0x0,这样的话,逻辑地址 [A:B] 与物理地址的映射是等效的,保证模式切换之后,指令连续;

实模式:Physical address = (A * 0x10) + B
保护模式: Physical address = Segment Base (Found from the descriptor GDT[A]) + B

指令寻址:

实模式:(0 * 0x10) + B = B
保护模式:内核代码段 selector 0x8,对应 entry 的 base 为0,0 + B = B

image-20210729200025346

MIT 6.828中 Excercise3 可以观察到实模式与保护模式的切换,虚拟内存映射

image-20210722171833027

3.1 ELF

ELF 代表 Executable Linkable Format,编译成的 Kernel 镜像就是这种格式,它通过指针与数组来提供灵活的代码组织方式,A T[N],例如 Section Header,分别对应于:e_shoff e_shentsize[e_shnum]

image-20210730093512608

bootmain 函数主要负责将内核加载到内存,首先它会从磁盘当前磁头的偏移0处读取8个 sector 内容,然后验证 magic number,之后循环加载程序段,然后将控制转移给 e_entry 处;

其中 readsect 负责与磁盘控制器交互,每次读一个扇区,写到目标地址;端口的含义如下,可参考:bochs.sourceforge.io/techspec/PO…

  • 0x01F3,扇区号
  • 0x01F4,低柱面
  • 0x01F5,高柱面
  • 0x01F6,驱动头
  • 0x01F7,读的时候:状态寄存器,写的时候,命令寄存器

image-20210729202552359

其中 wait_disk 函数通过忙轮询来实现,主要是通过磁盘控制器的状态寄存器标志位进行判断

image-20210730094546831

参考资料:

3. 内核Kernel

MIT 6.828 Part 3,在 Bootloader 将 Kernel 加载入内存后,控制就转移到了 ELF Header 中的 e_entry 部分;

对照反汇编内容,通过 gdb 追踪 bootloader 控制转移至内核的过程,可以发现 kernel 开始执行的地址与 ELF中标明的一致;

image-20210730170113493

image-20210730172601955

objdump -f obj/kern/kernel
image-20210730170139745

3.1 虚拟地址

内核这边还涉及到虚拟地址与链接地址的映射:

image-20210731121615437

内核的加载地址为:0x10000,在 main.c 中定义

image-20210730151110412

内核的链接地址为:0xF0100000,在kernel.ld 中定义

image-20210730151216044

因地址未匹配,且虚拟内存尚未建立,所以 JOS 代码中预先通过 RELOC 宏进行一次映射,让控制跳转至映射后的物理地址;f0100000 - f0000000 = 00100000

image-20210730180344844

Entry.S 开启了分页机制,包括:赋值页目录基址寄存器 cr3,然后更新 cr0 的标志位

image-20210730175346639

image-20210730175351768

控制寄存器的标志位如下:

image-20210730162434681

之后跳转 relocated 时,虚拟内存已开启; 0xf010002f 已被映射至

image-20210731120348921

Exercise 7

通过对比 cr0 寄存器更新前后的内存数据对比,可以看到虚拟内存已开启,0xf0100000的地址被映射至0x00100000;

image-20210730173806297

若注释掉 movl %eax, %cr0,qemu的虚拟器会提示错误,访问 0xf010002c 时提示超出内存边界;

image-20210730180851468

Exercise 8

Excercise 8讨论控制台输出相关的内容,函数调用关系为

cprintf 调用 vprintfmt,vprintfmt 用函数指针的方式传入负责与设备交互的 cons_putc 函数

CGA 代表 Color Graphic Adapter,彩色图形适配器,与之相关的还有:

VGA,EGA,分别代表 Viedo,Enhanced,A分为阵列 Array 与适配器 Adapter;

Exercise 8中跟阴极射线管 CRT 相关的参数如下:

# CRT相关的
#define CRT_ROWS  25
#define CRT_COLS  80
#define CRT_SIZE  (CRT_ROWS * CRT_COLS)

image-20210722175227324

上述代码段的作用为:若累计crt_pos大于crt窗口的所能容纳的row*cloumn,则将buf的内容整体上移一行,然后将光标至于最后一行的开始;

image-20210722175020067

3.2 可变参数

以下参考C文档:Variadic Functions

带有可变参数的函数,参数列表以...结尾。编译器提供了一定机制,帮助我们取出...中包含的参数。由于给函数的参数在编译时就已经确定,这样的语言特性才是可以实现的。

使用可变参数的模式非常固定,如下:

va_list args;               // 准备接受参数的列表对象
va_start(args, fmt);        // 从`...`中取出参数到args中,并指定...之前的参数
vprintf(fmt, args);         // 将取出的参数列表传给真正的实现函数
va_end(args);               // 释放参数列表

在实现函数中,使用va_arg(args, int)取出变量,指定了类型int,代表以int类型解析当前参数。再次调用va_arg时,取出的参数是传入参数中的下一个。

getunit 通过调用 var_arg 来迭代获取可变参数

image-20210730184055858

8进制输出部分,因 vprintfmt 中已有封装的 printnum 函数,可以直接设定进制为8,然后将控制转移至 number 处进行输出

image-20210722182250833

Exercise 9

在反汇编.asm中查看 Entry.S,可以看到初始化栈指针的指令地址;

image-20210722192504630

栈初始化,.data下面为 bootstack 分配空间部分

image-20210726182027711

3.3 动态链接

MIT 6.828 中 test_backtrace 函数反汇编代码中,有__i686.get_pc_thunk.bx 这种调用方式,这实际上是 i386 实现动态链接的一种方式

image-20210730192143580

例如:通过调用 __i686.get_pc_thunk.cx,会将当前的固定地址,存入ecx中,可参考:[PLT and GOT - the key to code sharing and dynamic libraries][www.technovelty.org/linux/plt-a…]

image-20210730195858617

image-20210727163103530

Exercise 10

通过 gdb debug 可以发现栈帧是定长的,若为动态栈的话,需保存esp寄存器的值,栈帧大小可由两次调用的ebp计算得出

通过 gdb 追踪 test_backtrace 的调用得到栈帧大小为:0x20 - 0x4 = 0x1c,其中减去的为压栈的 prev_ebp 的长度,所以除ebp,eip外,还有五个参数位置

img

image-20210728111142909

Exercise 11

因为每一个函数开始时,都将 prev_ebp 压栈,所以可以通过迭代的方式,将栈信息展开,截止条件为 MIT 6.828 中设定的 nuke_frame_pointer 0x0;

image-20210731112443568

image-20210730203321282

image-20210728112523616

3.4 符号表

ELF 中存储着程序相关符号表信息 ,通过 objdump -h 可以看到符号表相关的两个Section,.stab.stabstr,通过 objdump -G 可以看到详细的符号表信息

image-20210728145953582

image-20210728150253414

Exercise 12

MIT 6.828 中提供了 debuginfo_eip 函数,可以通过 eip 地址查找相关的符号信息;

可按粒度:源文件N_SO,方法N_FUN,行N_SLINE的方式定位,类似于反射机制;

image-20210731125413997

debuginfo_eip 中使用二分查找算法,stab数组按类型过滤;

  • 符号表起始地址

符号表的开始与结束地址由链接脚本这边赋值;

image-20210728121113989

  • JOS 中二分查找示例
//	For example, given these N_SO stabs:
//		Index  Type   Address
//		0      SO     f0100000
//		13     SO     f0100040
//		117    SO     f0100176
//		118    SO     f0100178
//		555    SO     f0100652
//		556    SO     f0100654
//		657    SO     f0100849
//	this code:
//		left = 0, right = 657;
//		stab_binsearch(stabs, &left, &right, N_SO, 0xf0100184);
//	will exit setting left = 118, right = 554.
  • 行号信息补全

指令相关的行号在 desc 字段中

image-20210728153533862

image-20210731123733387

  • 增加 debuginfo 后的函数

image-20210728155319868

  • 指令注册在 commands 数组中新增一项即可,将函数指针传入

image-20210728161647269

若 make grade 不通过,可能是格式化字符串问题,直接按lab输出的格式来匹配

image-20210728155023949

补充资料: