MIT 6.828 Lab 1 Booting a PC

Intro. macOS 环境搭建

6.828 / Fall 2018 (mit.edu) 官方在 21 年已经更新了 macOS 工具链的支持。但是经过测试在 macOS Monterey 环境中课程修改过的 qemu 无法编译通过,于是使用官方的 qemu 代替。

brew install qemu
brew tap liudangyi/i386-jos-elf-gcc
brew install i386-jos-elf-gcc i386-jos-elf-gdb

然后将 i386-jos-elf-gcc 和 i386-jos-elf-gdb 的 binary files 添加进 PATH 即可。

Part1. PC Bootstrap

解除 qemu 的鼠标锁定使用 control + option + g,退出 qemu 使用 control + a, x。

The PC’s Physical Address Space

物理内存空间表:


+------------------+  <- 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

The ROM BIOS

使用 make gdb 调试最初的代码,第一条指令是:

[f000:fff0] 0xffff0:	ljmp   $0xf000,$0xe05b

IBM PC 在物理地址的 0x000ffff0 开始执行,这个地址在 BIOS ROM 的 64K 的区域。事实上从这开始的 16 个字节差不多只能存储就这一条 jmp 指令了。

BIOS 最先做的是初始化,比如激活显卡、检测安装内存大小。初始化完成后会从正确的位置加载 OS。

这第一个指令是 ljmp,执行完之后从 IP=0xfff0 会跳到 0xe05b

物理地址等于 16 * segment + offset16 * CS + IP

Exercise 2

[f000:cf2b]    0xfcf2b: cli

关闭中断指令,现在是 booting 的关键时期,需要屏蔽中断。

[f000:cf2c]    0xfcf2c: cld

设置方向标志位为 0,跟串操作的方向有关。

[f000:cf30]    0xfcf30: mov    $0x8f,%ax
[f000:cf36]    0xfcf36: out    %al,$0x70

0x8f 写入 0x70 端口,根据这个(已经不知道叫啥了)提供的各个 I/O 端口的解释,可以看到,此时 0x70 端口的 bit 7 为 0,NMI(Non-maskable Interrupt,不可屏蔽中断)禁用,然后 bit 6-0 被叫做 CMOS RAM index,此处写入了最大值。

[f000:cf38]    0xfcf38: in     $0x71,%al

这一句看似多余啊,其实端口的文档在 0x70 端口最后有一句话是:

any write to 0070 should be followed by an action to 0071 or the RTC will be left in an unknown state.

目的是为了更新 RTC(real time clock) 寄存器。

[f000:cf3a]    0xfcf3a: in     $0x92,%al
[f000:cf3c]    0xfcf3c: or     $0x2,%al
[f000:cf3e]    0xfcf3e: out    %al,$0x92

0x92 端口是 PS/2 system control port A,bit 1 = 1 时指示 A20 激活。根据 Memory basics (archive.org) 中关于 Address line A20 的说明 A20 是第 21 条地址线,这里应该就是激活 A20 地址线,增加可寻址范围。A20 地址线被激活系统工作在保护模式下。

[f000:cf43]    0xfcf43: lidtl  %cs:(%esi)
[f000:cf49]    0xfcf49: lgdtl  %cs:(%esi)

加载 IDTR(中断向量表寄存器)和 GDTR(全局描述符表格寄存器)。

[f000:cf4f]    0xfcf4f: mov    %cr0,%ecx
[f000:cf52]    0xfcf52: and    $0xffff,%cx
[f000:cf59]    0xfcf59: or     $0x1,%cx
[f000:cf5d]    0xfcf5d: mov    %ecx,%cr0

额,为啥我这里会多一个 and $0xffff,%cx ,不是很懂这个操作有什么实际意义。开启 CR0 寄存器的最低位 PE 位,置 1 是开启保护模式。因为 bootloader 开始的时候需要工作在实模式,所以这里可能只是为了测试保护模式是否能正常工作。

[f000:cf60]    0xfcf60: ljmpw  $0xf,$0xcf68
The target architecture is set to "i386".

Part 2. The Boot Loader

PC 的软盘硬盘被分成 512 byte 的区域被叫做扇区(sectors),一个是硬盘的最小传输粒度,读写操作都必须是扇区大小的倍数且与扇区边界对齐。磁盘的第一扇区被叫做 boot sector,存储 boot loader,BIOS 会将这第一个引导扇区读取进 0x7c00 的内存中。

为什么是 0x7c00?系统最少需要 32KB 内存,内存范围是 0x0000~0x7fff,8088 芯片本身需要占用 0x0000~0x03ff,用来保存各种终端处理程序和储存位置,剩下 0x0400~0x7fff 可用。为了留尽量多的连续内存给操作系统,MBR 会被保存到内存地址的尾部,MBR 程序本身留出一个扇区保存,还需要一个扇区用来保存数据,所以 MBR 的起始位置就变成了 0x7fff - 512 - 512 + 1 = 0x7c00

Exercise 3

使用 gdb 的 b *0x7c00 停在 boot.S 的入口处,此时 qemu 中显示 Booting from Hard Disk..,接着使用 gdb step in 代码,顺带参考源汇编代码 boot/boot.S 和反汇编的 obj/boot/boot.asm,所有的代码其实都有注释,大致看下:

boot/boot.S

#include <inc/mmu.h>

# boot.S 的作用是切换到 32 位保护模式,然后后面的代码就可以使用 C 语言编写了
# Start the CPU: switch to 32-bit protected mode, jump into C.
# The BIOS loads this code from the first sector of the hard disk into
# memory at physical address 0x7c00 and starts executing in real mode
# with %cs=0 %ip=7c00.

.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

# 这里是 0x7c00 开头的 sector 的开始,也就是一个 bootable disk 的 boot loader
.globl start
start:
  .code16                     # Assemble for 16-bit mode      16 位汇编模式,这时候 A20 总线还没有开启吧
  cli                         # Disable interrupts            禁用中断,防止妨碍 boot loader 运行
  cld                         # String operations increment   清空 DF,设置串操作方向

  # Set up the important data segment registers (DS, ES, SS).
  xorw    %ax,%ax             # Segment number zero       段寄存器没办法直接赋值直接量,将 DS、ES、SS 三个段寄存器赋值
  movw    %ax,%ds             # -> Data Segment
  movw    %ax,%es             # -> Extra Segment
  movw    %ax,%ss             # -> Stack Segment

  # Enable A20:
  #   For backwards compatibility with the earliest PCs, physical
  #   address line 20 is tied low, so that addresses higher than
  #   1MB wrap around to zero by default.  This code undoes this.
  # 这里启动 A20 的操作为什么要这样不是很明白
seta20.1:
  # 0064 端口是 KB controller read status
  # bit1=1 表示 input buffer full
  inb     $0x64,%al               # Wait for not busy
  # test 指令求与,如果结果为 0,将 ZF 位置 1
  testb   $0x2,%al
  # 即等待 bit1 为 1, "wait for not busy"
  jnz     seta20.1

  movb    $0xd1,%al               # 0xd1 -> port 0x64
  # 0xd1 = 0b1101'0001
  # bit 7 = 1 parity error on transmission from keyboard
	# bit 6 = 1 receive timeout
  # bit 4 = 0 keyboard inhibit
  # bit 0 = 1 output buffer full (output 60 has data for system)
  outb    %al,$0x64

seta20.2:
  inb     $0x64,%al               # Wait for not busy
  testb   $0x2,%al
  jnz     seta20.2

  movb    $0xdf,%al               # 0xdf -> port 0x60
  # 0xdf = 0b1101'1111
  outb    %al,$0x60

  # Switch from real to protected mode, using a bootstrap GDT
  # and segment translation that makes virtual addresses 
  # identical to their physical addresses, so that the 
  # effective memory map does not change during the switch.
  # 从实模式切换到保护模式,从 gdtdesc 中读取初始的 GDT
  # gdtdesc 在这段代码的最后面定义
  lgdt    gdtdesc
  # 开启 CR0 的 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.
  # 切换到 32 位模式,这里是一个跨段的大长跳
  # 我这里实际用 gdb 调试出来的绝对地址是 [   0:7c2d] => 0x7c2d:  ljmp   $0xb866,$0x87c32
  # 所以不知道为啥这里不是 0x10
  ljmp    $PROT_MODE_CSEG, $protcseg

  .code32                     # Assemble for 32-bit mode
protcseg:
  # Set up the protected-mode data segment registers
  # 设置保护模式的寄存器
  # mov $0x10, %ax
  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.
  # mov $0x7c00, %esp,将 0x10:0x7c00 往小端的地址设置为栈空间,开始 call 了
  movl    $start, %esp
  call bootmain
  # 这里调用了完了之后看一下寄存器值,
  # esp            0x7bfc
  # ss             0x10
  # 上面说的没有问题

  # If bootmain returns (it shouldn't), loop.
  # bootmain 如果 ret 了,就在这里无限循环
spin:
  jmp spin

# Bootstrap GDT
# 强制使用 4 字节对齐
.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

# 这是一个 6 字节的空间,一个 word 是 2 byte,一个 long 是 4 byte
# gdt 是上面的 gdt 的地址
gdtdesc:
  .word   0x17                            # sizeof(gdt) - 1
  .long   gdt                             # address gdt

boot/main.c

#include <inc/x86.h>
#include <inc/elf.h>

/**********************************************************************
 * This a dirt simple boot loader, whose sole job is to boot
 * an ELF kernel image from the first IDE hard disk.
 * 超级简单的 boot loader,唯一的任务的是从第一个 IDE 硬盘启动 ELF 内核映像
 *
 * DISK LAYOUT
 *  * This program(boot.S and main.c) is the bootloader.  It should
 *    be stored in the first sector of the disk.
 * 这个就是 boot loader 程序,存储在磁盘的启动扇区中
 *
 *  * The 2nd sector onward holds the kernel image.
 * 然后后面的扇区就是 kernel 映像了
 *
 *  * The kernel image must be in ELF format.
 * kernel 映像必须是 ELF 格式的
 *
 * BOOT UP STEPS
 *  * when the CPU boots it loads the BIOS into memory and executes it
 *
 *  * the BIOS intializes devices, sets of the interrupt routines, and
 *    reads the first sector of the boot device(e.g., hard-drive)
 *    into memory and jumps to it.
 *
 *  * Assuming this boot loader is stored in the first sector of the
 *    hard-drive, this code takes over...
 *
 *  * control starts in boot.S -- which sets up protected mode,
 *    and a stack so C code then run, then calls bootmain()
 * boot.S 帮我们启动了 32 位保护模式,设置了 C 语言所需要用到的寄存器(比如 SS)
 * 这样 bootmain 的代码才可以运行
 *
 *  * bootmain() in this file takes over, reads in the kernel and jumps to it.
 **********************************************************************/

// 一个扇区的大小是 512 B
#define SECTSIZE	512
// 启动扇区是 0x0000~0xffff
// 所以对应的第二扇区的地址就是 0x10000 (0d512)
#define ELFHDR		((struct Elf *) 0x10000) // scratch space

void readsect(void*, uint32_t);
void readseg(uint32_t, uint32_t, uint32_t);

void
bootmain(void)
{
	struct Proghdr *ph, *eph;

	// read 1st page off disk
	// 一个 page 的大小是 8*SECTSIZE
	readseg((uint32_t) ELFHDR, SECTSIZE*8, 0);

	// is this a valid ELF?
	// 判断是否是 EFL 格式
	if (ELFHDR->e_magic != ELF_MAGIC)
		goto bad;

	// load each program segment (ignores ph flags)
	// 加载 kernel 中的程序段
	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);

	// call the entry point from the ELF header
	// note: does not return!
	// 调用 kernel 的入口
	((void (*)(void)) (ELFHDR->e_entry))();

bad:
	outw(0x8A00, 0x8A00);
	outw(0x8A00, 0x8E00);
	while (1)
		/* do nothing */;
}

// Read 'count' bytes at 'offset' from kernel into physical address 'pa'.
// Might copy more than asked
void
readseg(uint32_t pa, uint32_t count, uint32_t offset)
{
	uint32_t end_pa;

	end_pa = pa + count;

	// round down to sector boundary
	// 之前有说扇区是操作的最小粒度,要与扇区边界对齐
	pa &= ~(SECTSIZE - 1);

	// translate from bytes to sectors, and kernel starts at sector 1
	// 将地址的 offset 转换为扇区的 offset
	offset = (offset / SECTSIZE) + 1;

	// If this is too slow, we could read lots of sectors at a time.
	// We'd write more to memory than asked, but it doesn't matter --
	// we load in increasing order.
	while (pa < end_pa) {
		// Since we haven't enabled paging yet and we're using
		// an identity segment mapping (see boot.S), we can
		// use physical addresses directly.  This won't be the
		// case once JOS enables the MMU.
		// 这里应该说的是在 boot.S 中已经设置了 DS 偏移,所以不用考虑地址映射的问题
		// 可以直接使用 pa a.k.a. physical address
		readsect((uint8_t*) pa, offset);
		pa += SECTSIZE;
		offset++;
	}
}

void
waitdisk(void)
{
	// wait for disk reaady
	while ((inb(0x1F7) & 0xC0) != 0x40)
		/* do nothing */;
}

void
readsect(void *dst, uint32_t offset)
{
	// wait for disk to be ready
	waitdisk();

	outb(0x1F2, 1);		// count = 1
	outb(0x1F3, offset);
	outb(0x1F4, offset >> 8);
	outb(0x1F5, offset >> 16);
	outb(0x1F6, (offset >> 24) | 0xE0);
	outb(0x1F7, 0x20);	// cmd 0x20 - read sectors

	// wait for disk to be ready
	waitdisk();

	// read a sector
	insl(0x1F0, dst, SECTSIZE/4);
}

Be able to answer the following questions:

  1. At what point does the processor start executing 32-bit code? What exactly causes the switch from 16- to 32-bit mode?

    答:boot.S 有写注释。从那个 ljmp 开始的。

  2. What is the last instruction of the boot loader executed, and what is the first instruction of the kernel it just loaded?

    答:分别是 ELFHDR->e_entry() 的调用和其第一个指令。

  3. Where is the first instruction of the kernel?

    答:使用 gdb 按指令调试看到 ELFHDR->e_entry 的调用是 0x7c45: call 0x7d0b,所以 kernel 的第一条指令在 0x7d0b。这个是根据机器定的,我看网上有些估计是 64 位机器跑出来是 0x10018,这不影响。

  4. 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?

    答:这就体现出 ELF 格式的重要性了。ELF header 中存储了这些信息,然后通过 struct Elf 读取。整个 ELF 的空间是不确定的,但是正数第二个扇区一定是 ELF header 的区域,于是 main.c 中可以通过读取 ELF header 的方式加载整个 ELF。

Loading the Kernel

Exercise 4

pointers.c

#include <stdio.h>
#include <stdlib.h>

void
f(void)
{
    int a[4];
    int *b = malloc(16);
    int *c;
    int i;

    printf("1: a = %p, b = %p, c = %p\n", a, b, c); 

    c = a;
    for (i = 0; i < 4; i++)
	      a[i] = 100 + i;
    c[0] = 200;
    printf("2: a[0] = %d, a[1] = %d, a[2] = %d, a[3] = %d\n",
	      a[0], a[1], a[2], a[3]);  // 200 101 102 103

    c[1] = 300;
    *(c + 2) = 301;
    3[c] = 302;
    printf("3: a[0] = %d, a[1] = %d, a[2] = %d, a[3] = %d\n",
	      a[0], a[1], a[2], a[3]);  // 200 300 301 302

    c = c + 1;
    *c = 400;
    printf("4: a[0] = %d, a[1] = %d, a[2] = %d, a[3] = %d\n",
	      a[0], a[1], a[2], a[3]);  // 200 400 301 302

    c = (int *) ((char *) c + 1);  // 一个整型是 4 位然后这里强转 char 移动了 1 位
    *c = 500;
    printf("5: a[0] = %d, a[1] = %d, a[2] = %d, a[3] = %d\n",
	      a[0], a[1], a[2], a[3]);  // i can't this calculate by myself

    b = (int *) a + 1;
    c = (int *) ((char *) a + 1);
    printf("6: a = %p, b = %p, c = %p\n", a, b, c);   // c == (void*)c + 1
}

int
main(int ac, char **av)
{
    f();
    return 0;
}

Exercise 5

使用 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         0000017e  00007c00  00007c00  00000054  2**2
                  CONTENTS, ALLOC, LOAD, CODE

可以看到 .text 段的大小、VMA(virtual memory address)、LMA(load memory address)等信息。

使用 objdump -x obj/kern/kernel 可是看到 program header:

Program Header:
    LOAD off    0x00001000 vaddr 0xf0100000 paddr 0x00100000 align 2**12
         filesz 0x0000efe1 memsz 0x0000efe1 flags r-x
    LOAD off    0x00010000 vaddr 0xf010f000 paddr 0x0010f000 align 2**12
         filesz 0x0000a948 memsz 0x0000a948 flags rw-

修改 bool/Makefrag 中的 -Ttext 0x7C00,让 boot sector 加载到其他位置。比如我把它改成 0x0000。

比较一下 call bootmain 之前的指令,可以看到如下区别:

22c22
<    0x7c2d:      ljmp   $0xb866,$0x80032
---
>    0x7c2d:      ljmp   $0xb866,$0x87c32
29c29
<    0x7c40:      mov    $0x0,%esp
---
>    0x7c40:      mov    $0x7c00,%esp

比如说我这个修改导致的问题就很大,%esp 初始为 0,栈没有办法再进行负增长,否则会突破栈区。因为 BIOS 设计将 boot loader 读取到 0x7c00 的位置,一些空间自然是被安排好了,不应该随便修改。