MIT6.828 Lab 1: C, Assembly, Tools, and Bootstrapping


前置准备

实现机器为VMWare的虚拟机,操作系统为 Debian-11(无桌面版本),内核版本为 5.10.0,指令集为 AMD64(i7 9700K),编译器为 GCC-10

QEMU 虚拟化支持

理论上只需要 qemu 提供软件虚拟化即可,所以硬件虚拟化非必要,libvirt 等相关组件也可以不需要;这里只安装 QEMU

apt install qemu-kvm

其它

使用 clangd 工具链,代码风格对齐 Linux。

Lab 1: C, Assembly, Tools, and Bootstrapping

Lab1 主要汇编、工具链及引导部分。汇编使用 GNU 风格,可以 CS:APP 书籍进行学习。

安装 Lab1 的流程,执行 make && make qemu 之后会有报错,由于装的操作系统无桌面,gtk 也就没有安装。

# qemu-system-i386 -drive file=obj/kern/kernel.img,index=0,media=disk,format=raw -serial mon:stdio -gdb tcp::25000 -D qemu.log
Unable to init server: Could not connect: Connection refused
gtk initialization failed

非图形版本修改如下:

-QEMUOPTS = -drive file=$(OBJDIR)/kern/kernel.img,index=0,media=disk,format=raw -serial mon:stdio -gdb tcp::$(GDBPORT)
+QEMUOPTS = -drive file=$(OBJDIR)/kern/kernel.img,index=0,media=disk,format=raw -nographic -gdb tcp::$(GDBPORT)

解释一下 qemu 的这条命令

qemu-system-i386 -drive file=obj/kern/kernel.img,index=0,media=disk,format=raw -nographic -gdb tcp::25000 -D qemu.log
  • drive 指定驱动类型
  • format=raw 文件格式,其他的如有 qcow2
  • nographic 无图形页面
  • gdb 接受 gdb 的远程连接,后续 make gdb 调试会使用到这个点

make qemu-gdbmake qemu 多了一个参数 -S,作用为 freeze CPU at startup。

启动

内存布局

+------------------+  <- 0xFFFFFFFF (4GB)
|      32-bit      |
|  memory mapped   |
|     devices      |
|                  |
/\/\/\/\/\/\/\/\/\/\

/\/\/\/\/\/\/\/\/\/\
|                  |
|      Unused      |
|                  |
+------------------+  <- depends on amount of RAM
|                  |
|                  |
| Extended Memory  |
|                  |
|                  |
+------------------+  <- 0x00100000 (1MB)
|     BIOS ROM     |
+------------------+  <- 0x000F0000 (960KB)
|  16-bit devices, |
|  expansion ROMs  |
+------------------+  <- 0x000C0000 (768KB)
|   VGA Display    |
+------------------+  <- 0x000A0000 (640KB)
|                  |
|    Low Memory    |
|                  |
+------------------+  <- 0x00000000

简而言之,最初的处理器最大寻址只有 0xFFFFF,然后预留 64KB 给 BIOS 作为保留使用,完全给用户使用的内存空间只有起始的 640KB(0x00000000 ~ 0x000A0000).

BIOS

通过 gdb 跟踪到第一条执行的指令为 ljmp,地址为 physical address = 16 * segment + offset[CS:IP] 为 [f000:fff0] 的情况下地址为 0xffff0 = 16 * 0xf000 + 0xfff0.

[f000:fff0]    0xffff0: ljmp   $0x3630,$0xf000e05b

为了使 BIOS 加电就被执行,约定好将 BIOS 放在 0xFFFF0 这个位置,机器加电后就将控制权交给 BIOS.

引导程序

You will not need to learn much about programming specific devices in this class: writing device drivers is in practice a very important part of OS development, but from a conceptual or architectural viewpoint it is also one of the least interesting.

像课程说的那样,和驱动的相关的东西,了解就略过。此处 描述了从硬盘控制器中读取数据的说明,对应 out*() 簇函数。

引导进程位于 boot/boot.Sboot/main.c 中,阅读代码后回答以下几个问题:

  • At what point does the processor start executing 32-bit code? What exactly causes the switch from 16- to 32-bit mode?
    使用 ljmp 切换为保护模式,ljmp 随后的 movw 指令为在 32-bit 执行的第一条指令。

  • What is the last instruction of the boot loader executed, and what is the first instruction of the kernel it just loaded?
    call *0x10018 为 bootloader 最后一条执行的指令(也就是 ((void (*)(void)) (ELFHDR->e_entry))(); 这行代码); repnz insl 为读取内核的第一条指令,从磁盘文件中读取出内核的数据。

  • Where is the first instruction of the kernel?
    地址为 0x10018 这条指令 movw 为内核第一条执行的指令。

  • How does the boot loader decide how many sectors it must read in order to fetch the entire kernel from disk? Where does it find this information?
    先从第1(下标为 0)个扇区读取8个扇区的数据,然后通过 ELF 的格式进行解析,通过 ELF 文件头中的 e_phoff 字段拿到程序段表的文件偏移,再通过这个偏移取到每一个段的大小和偏移。

内核

内核在硬盘中的分布紧跟着 bootloader,在 bootloader 中将内核镜像读取至物理内存 0x100000 处,由于内核镜像的是 ELF 格式,直接通过 ELF 找到 e_entry(.txt) 段,然后进项跳转,进入内核的代码段中;

内核的代码段起始位置通过 kern/entry.S 中的 .global _start 指定了入口。由于我们一般把内核放在高内存区域,尽量和用户使用的内存部分错开。可以在链接的情况下指定虚拟地址(通过 kern/kernel.ld),观察 obj/kern/kernel.asm 中每一条指令的地址,起始地址为 0xF0100000 指令为 add 0x1bad(%eax),%dh,该地址在 kern/kernel.ld 中被指定,其中 0xF0100000 为虚拟地址,0x100000 为物理地址(bootloader 读取)

readelf -h obj/kern/kernel 可以看到程序段头 Number of program headers 的值为 3. 内核的入口地址为 Entry point address 值为 9x10000c.
readelf -l obj/kern/kernel 可以看到详细的信息 PhysAddr 为物理地址,VirtAddr 为虚拟地址,MemSiz 为段的大小。以此部分内容返回查看 boot/main.c 的逻辑更清晰。

kern/entry.S 中做了一个简单的映射,通过 _start = RELOC(entry)entry 的虚拟地址设置为了 0xF0100000

// readelf -h  obj/kern/kernel
ELF Header:
  Magic:   7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00 
  Class:                             ELF32
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              EXEC (Executable file)
  Machine:                           Intel 80386
  Version:                           0x1
  Entry point address:               0x10000c
  Start of program headers:          52 (bytes into file)
  Start of section headers:          91220 (bytes into file)
  Flags:                             0x0
  Size of this header:               52 (bytes)
  Size of program headers:           32 (bytes)
  Number of program headers:         3
  Size of section headers:           40 (bytes)
  Number of section headers:         14
  Section header string table index: 13

// readelf -S obj/kern/kernel
Section Headers:
  [Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf Al
  [ 0]                   NULL            00000000 000000 000000 00      0   0  0
  [ 1] .text             PROGBITS        f0100000 001000 0024ed 00  AX  0   0 16
  [ 2] .rodata           PROGBITS        f01024f0 0034f0 000533 00   A  0   0  4
  [ 3] .stab             PROGBITS        f0102a24 003a24 004519 0c   A  4   0  4
  [ 4] .stabstr          STRTAB          f0106f3d 007f3d 0017aa 00   A  0   0  1
  [ 5] .data             PROGBITS        f0109000 00a000 009500 00  WA  0   0 4096
  [ 6] .got.plt          PROGBITS        f0112500 013500 00000c 04  WA  0   0  4
  [ 7] .data.rel.local   PROGBITS        f0113000 014000 001044 00  WA  0   0 4096
  [ 8] .data.rel.ro[...] PROGBITS        f0114044 015044 00001c 00  WA  0   0  4
  [ 9] .bss              PROGBITS        f0114060 015060 000661 00  WA  0   0 32
  [10] .comment          PROGBITS        00000000 0156c1 000027 01  MS  0   0  1
  [11] .symtab           SYMTAB          00000000 0156e8 000890 10     12  78  4
  [12] .strtab           STRTAB          00000000 015f78 000463 00      0   0  1
  [13] .shstrtab         STRTAB          00000000 0163db 000078 00      0   0  1

// readelf -l obj/kern/kernel
Program Headers:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  LOAD           0x001000 0xf0100000 0x00100000 0x086e7 0x086e7 R E 0x1000
  LOAD           0x00a000 0xf0109000 0x00109000 0x0b6c1 0x0b6c1 RW  0x1000
  GNU_STACK      0x000000 0x00000000 0x00000000 0x00000 0x00000 RWE 0x10

由于涉及到了虚拟内存,故需要使用 CPU 特性,开启内存分页。参考Intel文档Volume 3: 4.1 PAGING MODES AND CONTROL BITS。

Software enables paging by using the MOV to CR0 instruction to set CR0.PG. Before doing so, software should ensure that control register CR3 contains the physical address of the first paging structure that the processor will use for linear-address translation.

增加编译选项 -no-pie -fno-pic 来避免产生的位置无关的指令,如 __x86.get_pc_thunk.bx,对阅读汇编代码更友好一些。
调整优化等级 O1O0,直接让 C 和汇编对应。比如在 i386_init() 中,开启优化后栈空间占用了 0x0c 的大小,但是我们只用了两个变量,应该为 0x08.

调试可以使用 gdb 的 i r edp 来查看 edp 寄存器的值,p/x addr 对 addr 进行十六进制的输出。

晚上上面的修改动作后,通过阅读 obj/kern/kernel.asm,定位到栈大小为 32768(8*PGSIZE),最前设置的栈底为 0x00,栈顶为 bootstacktop,进入到 i86_init() 中后,栈栈底为 bootstacktop+4

之前有一篇读CS:APP的,描述的是x86-64下的函数调用过程。对于函数的调用链关键点在于这几个元素

  • 返回地址
    • 在执行 call 指令时,会将call指令的下一条指令地址压栈
    • 在执行 ret 指令时,从栈弹出并且跳转(大概的逻辑)
  • 栈基寄存器,为当前函数的栈底地址,在进入一个函数中压栈,返回前退栈

backtrace 要求输出每一个函数的的 ebp eip args,在一行中显示。

  • ebp 直接从 edp 寄存器中读取
  • eip 为函数的返回地址,在栈底的底下(ebp+4)
  • args 参数,和 x64 不同的是 x86 全部使用栈传递参数,对寄存器的利用不高。这样来看backtrace变得更轻松一些。

本质上为 上一个栈底地址作为元素被压入当前栈中,所以获取到当前的 ebp 寄存器的再进行反复的回溯就可以解决获取到 edp rip args.
回溯的终点为 entry.S 里面设置的 movl $0x0,%ebp

int mon_backtrace(int argc, char **argv, struct Trapframe *tf)
{
	// Your code here.
	cprintf("Stack backtrace:\n");

	uint32_t ebp = *(uint32_t *)read_ebp();
	while (ebp != 0x00) {
		cprintf("  ebp %08x", ebp);

		uint32_t eip = *(uint32_t *)(ebp + 4);
		cprintf("  eip %08x", eip);

		cprintf("  args");
		struct Eipdebuginfo info;
		debuginfo_eip(eip, &info);
		// for (int i = 0; i < info.eip_fn_narg; i++) {
		for (int i = 0; i < 5; i++) {
			cprintf(" %08x", *(uint32_t *)(ebp + 8 + i * 4));
		}
		cprintf("\n         %s:%d: %.*s+%d\n", info.eip_file, info.eip_line, info.eip_fn_namelen, info.eip_fn_name, eip - info.eip_fn_addr);

		ebp = *(uint32_t *)ebp;
	}
	
	return 0;
}

在实现的时候,理论上 read_ebp() 返回的值应该指针,但是需要解地址才能够得到和 gdb 中 info reg ebp 相同的值
后续调试的时候结果是在 mon_backtrace 处的断点还未运行 mov %esp %ebp

Stack backtrace:
  ebp f010ff18  eip f01000a1  args 00000000 00000000 00000000 f010004a f0111308
  ebp f010ff38  eip f0100076  args 00000000 00000001 f010ff78 f010004a f0111308
  ebp f010ff58  eip f0100076  args 00000001 00000002 f010ff98 f010004a f0111308
  ebp f010ff78  eip f0100076  args 00000002 00000003 f010ffb8 f010004a f0111308
  ebp f010ff98  eip f0100076  args 00000003 00000004 00000000 f010004a f0111308
  ebp f010ffb8  eip f0100076  args 00000004 00000005 00000000 f010004a f0111308
  ebp f010ffd8  eip f0100102  args 00000005 00001aac 00000660 00000000 00000000
  ebp f010fff8  eip f010003e  args 00000003 00001003 00002003 00003003 00004003

目前只有一堆和地址相关的东西,没有可读性,所以更进一步,补充函数名称,文件及返回地址所在行号。

JOS预先提供了一个帮助函数 debuginfo_eip,和一个结构体 struct Eipdebuginfo。函数名称,文件名,及返回地址所在行号都定义在结构体内,只需要补充实现完这个 debugifo_eip 就可以获取到相关的信息。目前的输出信息如下:

eip_file=kern/init.c eip_fn_name=i386_init:F(0,1) eip_fn_addr=f010009a eip_line=0, eip_fn_narg=0

需要对 eip_fn_name 进行修改,eip_line 补充获取。

根据实验提供的方向,我们可以通过读取 .stab 的内容读取相关信息,使用命令 objdump -G obj/kern/kernel 可以得到

// 这里截取截取部分输出
obj/kern/kernel:     file format elf32-i386

Contents of .stab section:

Symnum n_type n_othr n_desc n_value  n_strx String
...
obj/kern/kernel:     file format elf32-i386
Contents of .stab section:
Symnum n_type n_othr n_desc n_value  n_strx String
-1     HdrSym 0      1477   000017fa 1     
0      SO     0      0      f0100000 1      {standard input}
1      SOL    0      0      f010000c 18     kern/entry.S
...   
13     SLINE  0      83     f010003e 0      
14     SO     0      2      f0100040 31     kern/entrypgdir.c
15     OPT    0      0      00000000 49     gcc2_compiled.
16     GSYM   0      0      00000000 64     entry_pgtable:G(0,1)=ar(0,2)=r(0,2);0;4294967295;;0;1023;(0,3)=(0,4)=(0,5)=r(0,5);0;4294967295;
17     LSYM   0      0      00000000 160    pte_t:t(0,3)
18     LSYM   0      0      00000000 173    uint32_t:t(0,4)
19     LSYM   0      0      00000000 189    unsigned int:t(0,5)
20     GSYM   0      0      00000000 209    entry_pgdir:G(0,6)=ar(0,2);0;1023;(0,7)=(0,4)
21     LSYM   0      0      00000000 255    pde_t:t(0,7)
22     SO     0      0      f0100040 0      
23     SO     0      2      f0100040 268    kern/init.c
24     OPT    0      0      00000000 49     gcc2_compiled.
25     FUN    0      0      f0100040 280    test_backtrace:F(0,1)=(0,1)
26     LSYM   0      0      00000000 308    void:t(0,1)
27     PSYM   0      0      00000008 320    x:p(0,2)=r(0,2);-2147483648;2147483647;
28     LSYM   0      0      00000000 360    int:t(0,2)
29     SLINE  0      13     00000000 0      
30     SLINE  0      14     00000006 0      
31     SLINE  0      15     00000019 0
...

对照符号表的结构体 struct Stab

// Entries in the STABS table are formatted as follows.
struct Stab {
	uint32_t n_strx;	// index into string table of name
	uint8_t n_type;         // type of symbol
	uint8_t n_other;        // misc info (usually empty)
	uint16_t n_desc;        // description field
	uintptr_t n_value;	// value of symbol
};

inc/stab.h 中使用到的 n_type

  • SO 主源文件,可以通过这个字段来找到对应的源文件
  • FUN 函数名,对应的函数名称
  • SLINE 代码段行号

。stab 符号表的内容依次按照源文件/函数名称/代码段行号排布。比如 test_backtrace 的实现在 kern/init.c 内,行号为 13.

 10 // Test the stack backtrace function (lab 1 only)
 11 void
 12 test_backtrace(int x)
 13 {
 14         cprintf("entering test_backtrace %d\n", x);
 15         if (x > 0)
 16                 test_backtrace(x-1);
 17         else
 18                 mon_backtrace(0, 0, 0);
 19         cprintf("leaving test_backtrace %d\n", x);
 20 }

对应 test_backtrace 的查找算法,在本实验中使用二分查抄,关键点为 eip 地址

  • 全局范围内,比较 eip 的地址找到类型为 SO 对应的源文件的行范围,这里为 kern/init.c
  • 缩小范围为该源文件内的符号表,找到类型 FUN 找到对应的函数行范围,这里为 test_backtrace
  • 缩小范围为函数范围的符号表,找到类型为 SLINE 的行,取字段 n_desc 这里为 13
  • 对应的返回地址在函数的偏移计算,直接 eip 地址减去 eip_fn_addr 即可

函数参数个数准确输出

这个还是解析 .stab 内容得到结果。实现非常简单,只需要从 fun 开始找到 PSYM 类型行的个数就可以

for (lline = lfun + 1; lline < rline && stabs[lline].n_type != N_SLINE; lline++)
	if (stabs[lline].n_type == N_PSYM)
		info->eip_fn_narg++;
info->eip_line = stabs[lline].n_desc;

最终输出结果如下,由于这个结果不能通过 case 的校验,所以只能作为扩展

K> backtrace
Stack backtrace:
  ebp f0110f38  eip f0100f2b  args 00000001 f0110f58
         kern/monitor.c:96: runcmd+323
  ebp f0110fa8  eip f0100fbb  args f01142c9
         kern/monitor.c:135: monitor+95
  ebp f0110fd8  eip f010012d  args
         kern/init.c:24: i386_init+128
  ebp f0110ff8  eip f010003e  args
         kern/entry.S:44: +0

TODO

虚拟内存的映射逻辑,在下一个 Lab 中展开。
控制台颜色输出,非操作系统核心内容,暂时跳过。

LAB1 总结

一个关于内核启动的内存布局图

              +------------------+  <- 0xFFFFFFFF (4GB)
              |      32-bit      |
              |  memory mapped   |
              |     devices      |
              |                  |
              +------------------+  <- (2GB+Kernel Program Size)
              |   (JOS) Kernel   |
   +--------> +------------------+  <- 0xF0100000 (2GB+1MB)
   |          |                  |
   |          +------------------+  <- 0xF0000000 (2GB)
   |          /\/\/\/\/\/\/\/\/\/\
   |          
   |          /\/\/\/\/\/\/\/\/\/\
   |          |                  |
   3          |      Unused      |
   |          |                  |
   |          ------------------+  <- depends on amount of RAM
   |          |                  |
   |          |                  |
   |          | Extended Memory  |
   |          |                  |
   |          +------------------+  <- 0x00100000 (1MB+4KB)
   +--------- | (JOS) 1st Page   |
      +-----> +------------------+  <- 0x00100000 (1MB)
      |   +-- |     BIOS ROM     |
      |   |   +------------------+  <- 0x000F0000 (960KB)
      |   |   |  16-bit devices, |
      2   |   |  expansion ROMs  |
      |   1   +------------------+  <- 0x000C0000 (768KB)
      |   |   |   VGA Display    |
      |   |   +------------------+  <- 0x000A0000 (640KB)
      +-- v - |  (JOS) 1st Sec   |
          +-> +------------------+  <- 0x00007C00 (31KB)
              |                  |
              |    Low Memory    |
              |                  |
              +------------------+  <- 0x00000000
  1. BIOS 加载硬盘镜像的第一个扇区至 0x7C00,并且跳转
  2. 1扇区将后面的操作系统加载进内存 0x100000 及将操作系统的程序段加载至高内存区域 0xF00100000,然后跳转至内核的入口地址 e_entry
  3. 设置内核内存空间运行环境,跳转