MIT 6.828 Lab1 PC启动
MIT 6.828 Lab1涵盖PC启动的各个流程:BIOS->Bootloader->Kernel
PC的物理地址布局如下,PC开机之后为实模式,地址宽度为20位,可寻址空间为1M,地址划分如下:
1. BIOS
其中PC启动的第一站是BIOS,按lab1的指示,运行make qemu-gdb与make gdb后,可以看到首地址为:[f000:fff0],地址换算:physical address = 16 * segment + offset,得最终地址 0xffff0
0xffff0 对应物理内存布局表中第一列的起始地址:FFFF0,其指令类型指令为:ljmp
bios会初始化一些中断向量表,然后会初始化一些重要设备比如vga等等,当它找到可启动磁盘时,BIOS将引导加载程序从磁盘读取。随后转移到引导启动程序上去。
2. 前置知识
i386因向后兼容,有实模式与保护模式,以及对应的分段寻址 Segmentation;
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
段寄存器读写
- 除 CS 外,其余段寄存器的值可以使用通用寄存器加载,或者使用栈赋值
mov ds, ax
pop ds
- 代码段寄 CS 的值无法直接修改,在代码进行控制转移时,进行更新,相关的指令为:
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
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,其格式如下:
- 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;
Entry中最明显的部分为:
- limit,20位的limit表示最大的可寻址单位(maximum addressable unit)
- base,32位的base表示段的开始地址
其余部分为状态位:
补充:
与GDT类似的还有LDT(本地描述符表,Interrupt Descriptor Table),IDT(中断描述符表,Interrupt Descriptor Table)
2.3 总结
gdt entry 中limit为20位,可寻址2^20大小的内存单元
参考资料:
- [Segmentation][wiki.osdev.org/Segment]
- [GDT Tutorial][wiki.osdev.org/GDT_Tutoria…]
3. BootLoader
BootLoader 的内容在 boot 文件夹下,包括 Boot.s 与 main.c
其中 Boot.s 负责:
-
状态设置,禁用中断,主要寄存器设置(DS,SS,ES)
-
A20 Gate设置(轮询设置,保持兼容)
-
从实模式切换至保护模式,加载 GDT & 重载段寄存器(保证切换期间内存映射不变)
-
设置栈指针,调用 bootmain 函数
boot.S 中通过 gdtdesc 表示 gdt 描述符,并使用宏 SEG 来设置 GDT Entry:
参考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,代表可写
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
MIT 6.828中 Excercise3 可以观察到实模式与保护模式的切换,虚拟内存映射
3.1 ELF
ELF 代表 Executable Linkable Format,编译成的 Kernel 镜像就是这种格式,它通过指针与数组来提供灵活的代码组织方式,A T[N],例如 Section Header,分别对应于:e_shoff e_shentsize[e_shnum]
bootmain 函数主要负责将内核加载到内存,首先它会从磁盘当前磁头的偏移0处读取8个 sector 内容,然后验证 magic number,之后循环加载程序段,然后将控制转移给 e_entry 处;
其中 readsect 负责与磁盘控制器交互,每次读一个扇区,写到目标地址;端口的含义如下,可参考:bochs.sourceforge.io/techspec/PO…
- 0x01F3,扇区号
- 0x01F4,低柱面
- 0x01F5,高柱面
- 0x01F6,驱动头
- 0x01F7,读的时候:状态寄存器,写的时候,命令寄存器
其中 wait_disk 函数通过忙轮询来实现,主要是通过磁盘控制器的状态寄存器标志位进行判断
参考资料:
- [从零开始写 OS 内核 - BIOS 启动到实模式][segmentfault.com/a/119000004…]
- [从零开始写 OS 内核 - GDT 与保护模式][segmentfault.com/a/119000004…]
- [从零开始写 OS 内核 - 全局描述符表 GDT][segmentfault.com/a/119000004…]
- [Global Descriptor Table][wiki.osdev.org/GDT]
- [扇区(sector),块(block),簇(cluster)][blog.csdn.net/william_mun…]
- [Linux系统中编译、链接的基石-ELF文件:扒开它的层层外衣,从字节码的粒度来探索][juejin.cn/post/696713…]
- [计算机那些事(4)——ELF文件结构][juejin.cn/post/684490…]
3. 内核Kernel
MIT 6.828 Part 3,在 Bootloader 将 Kernel 加载入内存后,控制就转移到了 ELF Header 中的 e_entry 部分;
对照反汇编内容,通过 gdb 追踪 bootloader 控制转移至内核的过程,可以发现 kernel 开始执行的地址与 ELF中标明的一致;
objdump -f obj/kern/kernel
3.1 虚拟地址
内核这边还涉及到虚拟地址与链接地址的映射:
内核的加载地址为:0x10000,在 main.c 中定义
内核的链接地址为:0xF0100000,在kernel.ld 中定义
因地址未匹配,且虚拟内存尚未建立,所以 JOS 代码中预先通过 RELOC 宏进行一次映射,让控制跳转至映射后的物理地址;f0100000 - f0000000 = 00100000
Entry.S 开启了分页机制,包括:赋值页目录基址寄存器 cr3,然后更新 cr0 的标志位
控制寄存器的标志位如下:
之后跳转 relocated 时,虚拟内存已开启; 0xf010002f 已被映射至
Exercise 7
通过对比 cr0 寄存器更新前后的内存数据对比,可以看到虚拟内存已开启,0xf0100000的地址被映射至0x00100000;
若注释掉 movl %eax, %cr0,qemu的虚拟器会提示错误,访问 0xf010002c 时提示超出内存边界;
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)
上述代码段的作用为:若累计crt_pos大于crt窗口的所能容纳的row*cloumn,则将buf的内容整体上移一行,然后将光标至于最后一行的开始;
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 来迭代获取可变参数
8进制输出部分,因 vprintfmt 中已有封装的 printnum 函数,可以直接设定进制为8,然后将控制转移至 number 处进行输出
Exercise 9
在反汇编.asm中查看 Entry.S,可以看到初始化栈指针的指令地址;
栈初始化,.data下面为 bootstack 分配空间部分
3.3 动态链接
MIT 6.828 中 test_backtrace 函数反汇编代码中,有__i686.get_pc_thunk.bx 这种调用方式,这实际上是 i386 实现动态链接的一种方式
例如:通过调用 __i686.get_pc_thunk.cx,会将当前的固定地址,存入ecx中,可参考:[PLT and GOT - the key to code sharing and dynamic libraries][www.technovelty.org/linux/plt-a…]
Exercise 10
通过 gdb debug 可以发现栈帧是定长的,若为动态栈的话,需保存esp寄存器的值,栈帧大小可由两次调用的ebp计算得出
通过 gdb 追踪 test_backtrace 的调用得到栈帧大小为:0x20 - 0x4 = 0x1c,其中减去的为压栈的 prev_ebp 的长度,所以除ebp,eip外,还有五个参数位置
Exercise 11
因为每一个函数开始时,都将 prev_ebp 压栈,所以可以通过迭代的方式,将栈信息展开,截止条件为 MIT 6.828 中设定的 nuke_frame_pointer 0x0;
3.4 符号表
ELF 中存储着程序相关符号表信息 ,通过 objdump -h 可以看到符号表相关的两个Section,.stab与.stabstr,通过 objdump -G 可以看到详细的符号表信息
Exercise 12
MIT 6.828 中提供了 debuginfo_eip 函数,可以通过 eip 地址查找相关的符号信息;
可按粒度:源文件N_SO,方法N_FUN,行N_SLINE的方式定位,类似于反射机制;
debuginfo_eip 中使用二分查找算法,stab数组按类型过滤;
- 符号表起始地址
符号表的开始与结束地址由链接脚本这边赋值;
- 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 字段中
- 增加 debuginfo 后的函数
- 指令注册在 commands 数组中新增一项即可,将函数指针传入
若 make grade 不通过,可能是格式化字符串问题,直接按lab输出的格式来匹配
补充资料:
- [从零开始写 OS 内核 - 加载并进入 kernel][segmentfault.com/a/119000004…]
- [CPU Registers x86][wiki.osdev.org/CPU_Registe…]
- [8086汇编 栈操作][www.cnblogs.com/xiangsikai/…]
- [符号表STABS][sourceware.org/gdb/onlined…]
- [PLT and GOT - the key to code sharing and dynamic libraries][www.technovelty.org/linux/plt-a…]