MIT_JOS_学习笔记_Lab1

jieguoThe BIOS is responsible for performing basic system initialization such as activating the video card and checking the amount of memory installed.

关闭 qemu , Ctrl+a x.

PC architects nevertheless preserved the original layout for the low 1MB of physical address space in order to ensure backward compatibility with existing software.

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

解释一下, 从0x000000000x00100000 这 1MB 的内存, 是最初的计算机使用的, 目前不再使用.

从 0x000A0000 到 0x000FFFFF, 这 384KB 是被硬件保存使用的, 这一部分最重要的是 BIOS(Basic Input/Output System) 占 64KB, 目前的架构中, 内存中的最低的 1MB仍然保存,是为了向下兼容,

The ROM BIOS

BIOS到底是什么呢?

在我们的电脑上, BIOS 是以固件的形式嵌入在我们的主板上的, 也就是PC 中的ROM固件, 是PC 开启时运行的第一个程序, 注意, 此时内存中什么都没有, 因为内存断电之后就清空了, 所以 BIOS 是存储在 ROM 中的, 此外我们还要注意到, 以上面的 32 位内存结构为例, 实际中的内存结构, 并不是 4GB 都在内存中, 比如说, BIOS 就要放在 ROM 中,

我们使用 GNU 来调试 QEMU 的执行过程, 具体操作很简单, 打开两个 Lab 的Terminals, 然后在其中一输入 make qemu-gdb, 另一个输入 make gdb 即可实现对 QEMU 的调试, 输出的结果我们得到下面一段输出,

+ target remote localhost:26000
The target architecture is assumed to be i8086
[f000:fff0] 0xffff0:	ljmp   $0xf000,$0xe05b
0x0000fff0 in ?? ()
+ symbol-file obj/kern/kernel

第一条指令就是:

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

这也就是 GDB 要执行的第一条反汇编指令, 从这条指令我们可以得到以下信息:

  1. 指令开始执行的地址是 0x000ffff0 , 这个地址就在 BIOS ROM 的64KB之中
  2. 第一条指令是一个跳转指令, 表示 PC 开始执行的地址, 这个地址就是 CS 0xf000, IP 0xe05b, 这两个寄存器是 x86结构中的, CS是段寄存器, 存储的是段的基地址, IP 存储的是地址偏移量,
  3. 与我们现在的的 PC 不同的是, 在 IBM 最初的 PC, 以及 QEMU 模拟器中, BIOS 的位置是在地址为 0x000ffff0 地方. IBM 当时使用的是 "hard-wired" 连接到这个物理地址, 在 QEMU中只是模拟这一过程.

操作系统的实模式:

我们先来解释一下上面的 从0x000000000x00100000 这 1MB 的内存, 对于传统的 CPU, 地址总线只有 20根, 也就是只能访问 1MB 的空间, (这里地址空间都是以 B(字节)为单位). 在这 1MB 空间中, 比如 BIOS 就是存储在 ROM 中的, 那么对这 1MB 空间的访问需要 20为的地址, 这里程序运行的地址必须是实地址, 也就是 1MB 空间内的地址.

另外一个问题是, X86 寄存器中的位数是 16 位, 那么怎么用 16 位来表示 20 位的地址空间呢?

在实模式中, (寄存器存储的地址都是虚拟地址或者说是偏移), 虚拟地址向实地址转换的规则为:

[physical quad address = 16 * segment + offset ]

也就是将 16 位向左移 4 位, 然后加上偏移, 就构成了20位,

我们将上面的 CS 与 IP 计算一下:

16 * 0xf000 + 0xe05b   # in hex multiplication by 16 is
   = 0xf0000 + 0xe05b     # easy--just append a 0.
   = 0xfe05b 

结果是 0xfe05b .

然后这里插入 Exercise 2, 这个练习的目的就是在运行 QEMU 的时候单步调试 xv6 的启动过程, 从上面我们知道第一条指令, 那么接下来发生了什么呢? 我们根据启动步骤猜想到发生的事总结起来就是:

  1. 关闭中断, 启动是原子操作, 肯定不允许中断, 还要加载中断向量表,
  2. 初始化一些寄存器
  3. 检测底层的设备, 通常是检测一些端口

这些步骤的最终目的是将操作系统的内核从硬盘导入到内存, 同时将CPU 的控制权交给操作系统,

The Boot Loader

磁盘存储与读取信息的基本单位是扇区, 在可引导操作系统内核的磁盘上, 第一块扇区称为引导扇区, 这里存放的是操作系统的加载程序(boot loader), 当 BIOS 发现了可引导的磁盘时, 他会将磁盘上第一个 512bits 的扇区加载入内存中, 其物理地址在 0x7c00 终点在 0x7dff , 然后使用一个 jmp 的跳转指令, 跳转到内存中物理地址为 0x7c00 的地方, BIOS 将引导权交给 boot loader.

上述的加载方式是传统的 PC 中使用的方法, 现在的磁盘可使用更大的扇区, BIOS 阶段可以加载更多的信息到内存中. 在第一个扇区的 boot loader 代码主要是boot/boot.S, 和 boot/main.c, 下面我们来仔细看下这两个文件:

在看的途中, 我们要搞清楚这两个问题:

  1. boot loader 将处理器从实模式切换到保护模式, 保护模式下虚拟地址到物理地址的转换方式不同, 我们需要搞清楚, 在切换模式之前做了什么, 以及如何切换,
  2. boot loader 读取硬盘内核文件的方式是通过特殊的 I/O 指令访问磁盘的设备寄存器,

为了理解 boot/main.c 我们需要先看一下 inc/x86.h 下定义的一些内联的汇编函数, 举个例子说明一下:

static inline uint8_t inb(int port)
{
	// port 是端口
	// inb 从I/O端口读取一个字节
	uint8_t data;
    // 8bits的数据
	asm volatile("inb %w1,%0" : "=a" (data) : "d" (port));
	return data;
    // %w1表示宽度为w的1号占位符
	//  : "=a" (out_var) 格式是用于指明输出操作数 "=", 表示only_write
	// "d" (port) 表示指明输入操作数是 port, 其中的 'a' 'd' 等参数是用于指明中间寄存器
}

static inline void insb(int port, void *addr, int cnt)
{
    // input string from a port
	asm volatile("cld
	repne
	insb"
		     : "=D" (addr), "=c" (cnt)
		     : "d" (port), "0" (addr), "1" (cnt)
		     : "memory", "cc");
    // 这里是插入内联代码的一些语法, 具体可以参考: https://blog.csdn.net/slvher/article/details/8864996
    // 根据使用的约束条件, 指定了addr 放到 %edi, cnt 放到 %ecx,port放到 %edx ,这些就是 "=D", 这些约束参数指定的
    // cld指令是和repne指令配合使用的,repne指令是连续执行下一条指令,直到%ecx为0.cld指令初始化%ecx减小的步长为1
    // 该函数是将端口的字符串流读入到内存中
}

所以在 boot/main.cvoid readsect(void *dst, uint32_t offset) 函数就比较好理解了.

控制的开始实在 boot.S 文件中的, 它开启了保护模式, 然后开启了一个栈, 然后让 C 程序运行, 然后调用 bootmain() 函数, 我们来看一些关键代码:

#include <inc/mmu.h>

# 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

.globl start
start:
  .code16                     # Assemble for 16-bit mode
  cli                         # Disable interrupts
  cld                         # String operations increment

  # Set up the important data segment registers (DS, ES, SS).
  # 初始化一些寄存器
  xorw    %ax,%ax             # Segment number zero
  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.

  #   in PortAddress, %al     把端口地址为PortAddress的端口中的值读入寄存器al中
  #   out %al, PortAddress    向端口地址为PortAddress的端口写入值,值为al寄存器中的值
  #   标准规定端口操作必须要用al寄存器作为缓冲。
seta20.1:
  inb     $0x64,%al               # Wait for not busy
  testb   $0x2,%al                # 判断数据是否写完
  jnz     seta20.1

  movb    $0xd1,%al               # 0xd1 -> port 0x64
  outb    %al,$0x64               
/*
当0x64端口准备好读入数据后,现在就可以写入数据了,
所以38, 39这两条指令是把0xd1这条数据写入到0x64端口中。当向0x64端口写入数据时,
则代表向键盘控制器804x发送指令。这个指令将会被送给0x60端口。
 */

seta20.2:
  inb     $0x64,%al               # Wait for not busy
  testb   $0x2,%al
  jnz     seta20.2
/*
等待上面的 D1 指令被读取到
D1指令代表下一次写入0x60端口的数据将被写入给804x控制器的输出端口。可以理解为下一个写入0x60端口的数据是一个控制指令。
 */
  movb    $0xdf,%al               # 0xdf -> port 0x60
  outb    %al,$0x60
/*
如果指令被读取了,指令会向控制器输入新的指令0xdf。通过查询我们看到0xDF指令的含义如下
这个指令的含义可以从图中看到,使能A20线,代表可以进入保护模式了
 */

  # 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.
  lgdt    gdtdesc
  # Load the Global/Interrupt Descriptor Table Register from memory address gdtdesc:
  # The GDT table contains a number of entries called Segment Descriptors.
  #  Each is 8 bytes long and contains information on the starting point of the segment, 
  #  the length of the segment, and the access rights of the segment.
  movl    %cr0, %eax
  # CR0寄存器的0 bit是PE位,启动保护位,当该位被置1,代表开启了保护模式
  orl     $CR0_PE_ON, %eax
  # $CR0_PE_ON 的值一定是 1,
  movl    %eax, %cr0
  # 所以这一步就是将寄存器 cr0 的 PE 位置为 1
  
  # Jump to next instruction, but in 32-bit code segment.
  # Switches processor into 32-bit mode.
  ljmp    $PROT_MODE_CSEG, $protcseg
  # 关于这个指令, 例子为下面这个, 上面的 PROT_MODE_CSEG 也就是 CS 寄存器, 后面的立即数是偏移
  # Long jump, use 0xfebc for the CS register and 0x12345678 for the EIP register:
  # ljmp $0xfebc, $0x12345678
  .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
  call bootmain

  # If bootmain returns (it shouldn't), loop.
spin:
  jmp spin

# 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

最后一段是声明了一个全局描述表寄存器的内容, 这个寄存器内容的大小为 6字节, 高32 位代表全局描述表在内存的地址, 低 16 位代表 GDT 的大小. 可以看出 GDT 的大小为 0x17 个字, 后面 gdt 是地址, 所以再往前面看一下, Label gdt 就是 GDT 表的真正的入口地址了, 对于内容, 我们要先看一下 inc/mmu.h 中的 SEG 函数,

// Normal segment
#define SEG(type, base, lim, dpl) 					
{ ((lim) >> 12) & 0xffff, (base) & 0xffff, ((base) >> 16) & 0xff,	
    type, 1, dpl, 1, (unsigned) (lim) >> 28, 0, 0, 1, 1,		
    (unsigned) (base) >> 24 }

这一函数是定义了一个 '段' 的格式, 根据该文件下的定义, 这三段的权限分别是: null 段, 无, 然后是 code segment, 可执行或者可读段, data Segment 可写入, 后面的 0x0 代表基地址, 在 xv6 中, GDT 可以看成是没有分段的, 所以基地址都是 0, 后面的 0xffffffff 表示段的大小.

然后我们来看下最重要的 /boot/main.c 是怎么将内核导入到硬盘的, 是如何导入的,

#define SECTSIZE	512
// 一个扇区的大小
#define ELFHDR		((struct Elf *) 0x10000) // scratch space
// 定义了一个 Elf 类型的指针, 这个指针的地址为 0x10000

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, 一页的大小是 4KB
	// 从内核的开头中读取一页到 0x10000 地址处
	readseg((uint32_t) ELFHDR, SECTSIZE*8, 0);
	// 这一页就是内核文件的 ELF头部, 因为内核是可执行文件

	// is this a valid ELF?
	// ELF 头部的魔数不正确
	if (ELFHDR->e_magic != ELF_MAGIC)
		goto bad;

	// load each program segment (ignores ph flags)
	ph = (struct Proghdr *) ((uint8_t *) ELFHDR + ELFHDR->e_phoff);
	// ph 是程序头部表(segment 描述表, 包含多个 Segment 头部信息)的位置
	eph = ph + ELFHDR->e_phnum;
	// ELFHDR->e_phnum 表示程序头部表表项的个数, 即程序 segment 的个数 
	// 所以 eph 就是程序头部表的结尾
	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!
	((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);
	// 这个向下舍入就是将低 9 位全部变成0, 这样才能使地址扇区对齐
	// translate from bytes to sectors, and kernel starts at sector 1
	offset = (offset / SECTSIZE) + 1;
	// 这一步就是将页大小变成扇区大小, 一页是 4 个扇区
	// 因为从硬盘每次读的是一个扇区的单位, 内核起始是第一个扇区, 而不是第 0 个
	// 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.
		readsect((uint8_t*) pa, offset);
		// 从硬盘读一个扇区
		pa += SECTSIZE;
		// 物理地址到一个扇区, 扇区位置加 1
		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
	// offset 太长了, 所以分段
	// 注意,  0x1F 是端口, 后面是数据
	outb(0x1F3, offset);
	outb(0x1F4, offset >> 8);
	outb(0x1F5, offset >> 16);
	outb(0x1F6, (offset >> 24) | 0xE0);
	outb(0x1F7, 0x20);	// cmd 0x20 - read sectors
	// 注意secno是通过不同的端口,分四次发给磁盘的。
	// 然后向0x1f7端口发出0x20,说明我要开始读扇区了。

	// wait for disk to be ready
	waitdisk();
	// 这里的实现方式和 boot.S 中的对端口的操作类似
	// waitdisk()函数就是向磁盘0x1f7端口读状态字,如果没有处于ready状态就一直等待:
	// read a sector
	insl(0x1F0, dst, SECTSIZE/4);
	// 注意 insl 函数中的 repne 就是重复读, 因为 inl 每次读 4 字节, 所以要读 SECTSIZE/4 次
}

从上面的代码注释中, Exercise 3 的问题就变得很简单了.这一步主要是熟悉 GNU 调试的工具, 其他的没有什么问题.
下面我们插入一下对 ELF 文件的说明:

ELF 的全称是 Executable and Linkable Format. 所以最重要的是文件格式, 他一般确定三种文件格式, 分别是

(1)可重定位目标文件:包含二进制代码和数据,其形式可以和其他目标文件进行合并,创建一个可执行目标文件

(2)可执行目标文件:包含二进制代码和数据,可直接被加载器加载执行

(3)共享目标文件:可被动态的加载和链接(本文暂时不讨论)

以编译型语言 C语言 为例, 对于一个 .c 文件, 编译后再汇编得到一个 .o 文件, 这个.o 文件有它的代码段, 数据段, 等. 在将 .o 文件链接得到可执行文件, 可执行文件也有它的代码段与数据段, 从链接文件与可执行文件的角度看 ELF 文件的格式如下图所示:

我们可以通过命令 objdump -h obj/kern/kernel 查看内核文件有多少个 sections, 以及 各个 sections 的信息

lab$ objdump -h obj/kern/kernel

obj/kern/kernel:     file format elf32-i386

Sections:
Idx Name          Size      VMA       LMA       File off  Algn
  0 .text         000019e9  f0100000  00100000  00001000  2**4
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .rodata       000006c0  f0101a00  00101a00  00002a00  2**5
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  2 .stab         00003b95  f01020c0  001020c0  000030c0  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  3 .stabstr      00001948  f0105c55  00105c55  00006c55  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  4 .data         00009300  f0108000  00108000  00009000  2**12
                  CONTENTS, ALLOC, LOAD, DATA
  5 .got          00000008  f0111300  00111300  00012300  2**2
                  CONTENTS, ALLOC, LOAD, DATA
  6 .got.plt      0000000c  f0111308  00111308  00012308  2**2
                  CONTENTS, ALLOC, LOAD, DATA
  7 .data.rel.local 00001000  f0112000  00112000  00013000  2**12
                  CONTENTS, ALLOC, LOAD, DATA
  8 .data.rel.ro.local 00000044  f0113000  00113000  00014000  2**2
                  CONTENTS, ALLOC, LOAD, DATA
  9 .bss          00000648  f0113060  00113060  00014060  2**5
                  CONTENTS, ALLOC, LOAD, DATA
 10 .comment      00000054  00000000  00000000  000146a8  2**0

如前面的图所说的, 有一些信息是不加载到内存的, VMA 是link address 也就是链接地址, "LMA" (or load address) 是这一段应该被载入内存的地址, link address 是这一段期望开始执行的地址, 对于我们对于 boot loader 来说, 链接地址就是物理地址, 这时我们还没有引入分页机制, 因为 boot loader 在内存的地址是在低 1MB 的内存中, 直接用虚拟地址表示物理地址. 但是我们要注意到, 对于内核程序来说就不是这样了.

下面还有一个重要的目录就是 ELF结构体的 e_entry, 表示程序开始执行的地址, 对于我们的 kernel 文件, 可以通过下面指令获得这个地址:

lab$ objdump -f obj/kern/kernel

obj/kern/kernel:     file format elf32-i386
architecture: i386, flags 0x00000112:
EXEC_P, HAS_SYMS, D_PAGED
start address 0x0010000c

可以看出这个地址是 0x0010000c, 我们再结合 main.cboot.asm 文件对应的指令如何得到这个地址, 在

main.c:
// call the entry point from the ELF header
	// note: does not return!
	((void (*)(void)) (ELFHDR->e_entry))();
	
	
boot.asm:
((void (*)(void)) (ELFHDR->e_entry))();
    7d6b:	ff 15 18 00 01 00    	call   *0x10018
    
而在 ELF 中, 
struct Elf {
	uint32_t e_magic;	// must equal ELF_MAGIC
	uint8_t e_elf[12];
	uint16_t e_type;
	uint16_t e_machine;
	uint32_t e_version;
	uint32_t e_entry;		// 程序的入口
	…………………………………………
}
// 在这个结构体中, e_entry 的前面几项加起来正好是 24 byte, 而在 main.c 里面,
//我们定义的 ELF 的地址为 0x10000, 所以 call   *0x10018 表示跳转到内核的入口

Exercise 6.

Reset the machine (exit QEMU/GDB and start them again). Examine the 8 words of memory at 0x00100000 at the point the BIOS enters the boot loader, and then again at the point the boot loader enters the kernel. Why are they different? What is there at the second breakpoint? (You do not really need to use QEMU to answer this question. Just think.)

我们知道, BIOS 的最后一步是跳转到 boot loader, 但是这时内存中 0x00100000 地址还未初始化, 所以什么都没有, 那么在从 boot loader 到 kernel 的这一点发生了什么呢? 从文件 ./obj/boot/boot.asm 中得到, 最后跳转到 kernel 的entry 的指令为, 7d6b: ff 15 18 00 01 00 call *0x10018, 我们使用断点调试到达这里, b *0x7d6b, 然后查看此时的 0x00100000 地址处的数据, x 0x00100000, 得到这里存的指令是, 0x100000: add 0x1bad(%eax),%dh, 为什么说我们可以不调试也知道这条指令呢?

(gdb) b *0x7d6b
Breakpoint 2 at 0x7d6b
(gdb) c
Continuing.
The target architecture is assumed to be i386
=> 0x7d6b:      call   *0x10018

Breakpoint 2, 0x00007d6b in ?? ()
(gdb) x 0x00100000
   0x100000:    add    0x1bad(%eax),%dh
(gdb) x16 0x00100000
Undefined command: "x16".  Try "help".
(gdb) x 0x00100006
   0x100006:    add    %al,(%eax)

我们知道, BIOS 跳转的地址为 0x7c00, 这是 boot loader 的起始地址, 而 boot loader 将 kernel 加载到内存的地址恰恰是 0x100000, 所以这个地址的数据就是内核文件的第一条指令, 我们直接从文件中看, 在 ./obj/kern/kernel.asm 文件中, 第一条指令恰好就是 add 0x1bad(%eax),%dh.

至此完成了 Lab1 的前两部分, 第三部分加总结部分打算放在下一篇.

原文地址:https://www.cnblogs.com/wevolf/p/12453764.html