实验开始

这个实验分为 3 部分,第一部分用来熟悉 x86 汇编,QEMU 和 PC 启动过程。第二部分实操 6.828 kernel 的启动加载。第三部分,探索 6.828 kernel 的初始化。

软件设置

实验使用 git 版本控制系统,仓库地址为 ,使用命令将仓库 clone 到本地。

在实验开始之前需要安装 qemugcc,参考链接为tool page

Part 1: PC Bootstrap

这个作业的目的是介绍 x86 汇编语言和 PC 启动过程,并熟悉 QEMU 和 QEMU/GDB 调试。

Getting Started with x86 assembly

下面是实验中提供的一些关于汇编的资源:

  1. PC Assembly Language
  2. Brennan’s Guide to Inline Assembly
  3. Intel® 64 and IA-32 Architectures Software Developer Manuals

这三份资源,第一个是对汇编语言的入门教材,由于这个课程是用的是 GUN assembler,而不是 NASM assenbler,所以提供了第二份资料来学习 Intel 与 AT&T 汇编语法的差别。

第三份资料则是最权威的 Intel 开发手册。

Simulating the x86

qemu 内置的 debug 功能受限,我们将换用 gdb 进行调试。

编译上面 clone 下来的仓库代码,注意前提是安装好了实验指定版本的 qemu 和 gcc。

编译完 JOS kernel 后,执行 make qemu,最终 qemu 窗口显示如下文本:

Booting from Hard Disk...
6828 decimal is XXX octal!
entering test_backtrace 5
entering test_backtrace 4
entering test_backtrace 3
entering test_backtrace 2
entering test_backtrace 1
entering test_backtrace 0
leaving test_backtrace 0
leaving test_backtrace 1
leaving test_backtrace 2
leaving test_backtrace 3
leaving test_backtrace 4
leaving test_backtrace 5
Welcome to the JOS kernel monitor!
Type 'help' for a list of commands.
K>

Booting from Hard Disk… 后所有的输出都来自于 JOS 内核。

JOS 内核目前只有两条命令 helpkerninfo

The PC’s Physical Address Space

PC 的物理地址布局通常如下:

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

最早的 PC,是基于 16 位的 Intel 8088 处理器,但是那时的地址线有 20 bit,所以能寻址到 2^20 byte(1MB),所以早期 PC 的内存地址范围为 0x00000000 ~ 0x000FFFFF(1MB),图中的 Low Memory 是早期 PC 能够使用的 RAM 范围。

从 0x000A0000 到 0x000FFFFF (1024KB - 640KB = 384KB)这个范围,主要是为了硬件保留的,其中最重要的是 BIOS ROM 这块区域,早期 PC 的 BIOS 是 ROM,现代的 PC 已经将其换成可更新的 Flash Memory 了。

BIOS 主要负责系统初始化,例如激活 Video Card 和检查内存容量等。完成初始化后,BIOS 将操作系统从软盘、硬盘、CD 或者网络加载进来,然后将控制权转交给操作系统。

虽然后来的 80286 和 80386 处理器分别已经将寻址范围扩大到 16MB 和 4GB,为了保持软件向后兼容,PC 架构仍然保留了上图中 0x00000000 ~ 0x00100000 中的布局结构。因为这些历史原因,RAM 被分割成了两部分:

  1. low/conventional memory(0x00000000 ~ 0x000A0000 640KB)
  2. extended memory(0x00100000 ~ )

这两部分被 0x000A0000 ~ 0x00100000 这块区域分割开来,我们将这块区域叫做 “hole”。另外,在 32 位 PC 架构的机器上,高地址处通常为 PCI 设备保留着。

最近的一些 x86 处理器已经能够支持超过 4GB 的 RAM 了。这种情况下,BIOS 应该预留第二个 “hole”,它位于高地址区域。因为 JOS 的设计受限,只会使用到上图中前 256MB 的物理内存空间,所以从现在开始,假设 PC 只有 4GB 的物理内存空间。

The ROM BIOS

在这部分,将会学习使用 QEMU 的 debug 功能来探索内核启动过程。

首先启动两个 shell,都进入 lab 目录,其中一个执行 make qemu-nox-gdb,另一个执行 make gdb 后,显示如下:

$ make gdb
gdb -n -x .gdbinit
GNU gdb (Ubuntu 8.1-0ubuntu3) 8.1.0.20180409-git
Copyright (C) 2018 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "x86_64-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
<http://www.gnu.org/software/gdb/documentation/>.
For help, type "help".
Type "apropos word" to search for commands related to "word".
+ target remote localhost:26000
warning: No executable has been specified and target does not support
determining executable automatically.  Try using the "file" command.
warning: A handler for the OS ABI "GNU/Linux" is not built into this configuration
of GDB.  Attempting to continue with the default i8086 settings.

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

下面这行:

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

是 GDB 对第一条指令的反汇编,从中我们有如下推断:

  1. IBM PC 从物理地址 0x000ffff0(稍后会介绍这个地址的计算方法) 处开始执行,这个位置位于 ROM BIOS 里面
  2. PC 开始执行时,CS = 0xf000,IP = 0xfff0
  3. 第一条指令是 jmp 指令,它跳转到 CS = 0xf000 IP = 0xe05b 处

这种做法源自于 Intel 对 8088 处理器的设计,因为 BIOS 永远都在 0x000f0000 ~ 0x000fffff 这个范围内,这就保证了只要 PC 一接通电源,BIOS 能第一时间控制到 PC,这是很关键的,因为在接通电源的瞬间,RAM 中除了 BIOS 没有其他的软件能够执行。

如上面所说,PC 在接通电源后,处理器进入 Real Mode(实模式),将 CS 和 IP 分别设置成 0xf000 和 0xfff0。实模式下,地址转换使用下面的公式:

Physical Address = 16 * Segment(CS) + offset(IP)

所以,第一条指令的地址计算为: 0xf000 * 16 + 0xfff0 = 0x000ffff0。

然而 0x000ffff0 距离 BIOS 的范围结束(0x00100000)只有 16bytes,由于 16bytes 所做的事情有限,所以第一条 jmp 指令跳到了 BIOS 段的较低地址处。

Exercise 2

使用 GDB 的 si(Step Instruction) 指令,来跟踪查看 ROM BIOS 中的更多指令。

The target architecture is assumed to be i8086
[f000:fff0]    0xffff0:	ljmp   $0xf000,$0xe05b
[f000:e05b]    0xfe05b:	cmpl   $0x0,%cs:0x6ac8
[f000:e062]    0xfe062:	jne    0xfd2e1
[f000:e066]    0xfe066:	xor    %dx,%dx
[f000:e068]    0xfe068:	mov    %dx,%ss
[f000:e06a]    0xfe06a:	mov    $0x7000,%esp
[f000:e070]    0xfe070:	mov    $0xf34c2,%edx
[f000:e076]    0xfe076:	jmp    0xfd15c
[f000:d15c]    0xfd15c:	mov    %eax,%ecx
[f000:d15f]    0xfd15f:	cli
[f000:d160]    0xfd160:	cld
[f000:d161]    0xfd161:	mov    $0x8f,%eax
[f000:d167]    0xfd167:	out    %al,$0x70
[f000:d169]    0xfd169:	in     $0x71,%al
[f000:d16b]    0xfd16b:	in     $0x92,%al
[f000:d16d]    0xfd16d:	or     $0x2,%al
[f000:d16f]    0xfd16f:	out    %al,$0x92
[f000:d171]    0xfd171:	lidtw  %cs:0x6ab8
[f000:d177]    0xfd177:	lgdtw  %cs:0x6a74
[f000:d17d]    0xfd17d:	mov    %cr0,%eax
[f000:d180]    0xfd180:	or     $0x1,%eax
[f000:d184]    0xfd184:	mov    %eax,%cr0
[f000:d187]    0xfd187:	ljmpl  $0x8,$0xfd18f
The target architecture is assumed to be i386
=> 0xfd18f:	mov    $0x10,%eax
=> 0xfd194:	mov    %eax,%ds
=> 0xfd196:	mov    %eax,%es
=> 0xfd198:	mov    %eax,%ss
=> 0xfd19a:	mov    %eax,%fs
=> 0xfd19c:	mov    %eax,%gs
...

当 BIOS 启动后,它会设置 Interrupt Descriptor Table(中断描述符表),然后初始化各种设备,例如 VGA(Video Graphics Array)。

在初始化完 PCI(Peripheral Component Interconnect) 总线后,BIOS 会找到可引导盘,读取可引导盘中的 Boot Loader,然后将控制权转交给 Boot Loader。

Part 2: The Boot Loader

软盘和硬盘被分成一个个扇区(sector),一个扇区大小为 512B,扇区是磁盘的最小单位,每次读写的大小必须大于等于一个扇区,如果该盘是可引导盘,那么第一个扇区被称为引导扇区(boot sector),引导扇区中装有 Boot Loader 的代码。

当 BIOS 完成系统初始化后,就会去寻找可引导盘,将可引导盘的第一个扇区(512B)的内容加载到物理内存地址为 0x7c00 ~ 0x7dff 之间恰好 512B 的区域,然后使用一个 jmp 指令,将 CS:IP 设置为 0000:7c00,并将控制权交给 Boot Loader。

到后来 Boot Loader 已经可以通过 CD-ROM 加载,这种实现比较复杂,但是也更有效。例如,CD-ROM 中扇区大小增加到了 2048B,并且一次也能加载进多个扇区。

对于 6.828 我们使用的是传统的方法,即 Boot Loader 装在一个 512B 大小的扇区里。Boot Loader 包含了一个汇编语言源文件 boot/boot.S 和一个 C 语言源文件 boot/main.c,首先确保理解这两个文件的内容。

这个 Boot loader 负责启动磁盘中的 kernel image。boot.S 和 main.c 合称为 bootloader,这两个文件应该存储在磁盘的第一个扇区,从第二个扇区开始,存放 kernel image,kernel image 必须是 ELF 格式的。

整个流程可总结如下:

当 CPU 启动后,它将 BIOS 加载进内存执行,BIOS 初始化一些设备,然后将装有 Boot Loader 的扇区加载进内存,并 jump 到它,开始执行 boot.S,在 boot.S 中完成实模式到保护模式到切换,然后调 bootmain(),最后完成加载磁盘中的 kernel image 的任务。

boot.S:

#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  DF 置零

  # 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.
  #   即下面的代码将会解除地址环绕现象,以获得更多的寻址能力
seta20.1:
  inb     $0x64,%al               # Wait for not busy  0x64 端口的内容拷贝进 %al
  testb   $0x2,%al                # 
  jnz     seta20.1                # 只要 %al 中不等于 0x2,就继续循环

  movb    $0xd1,%al               # 0xd1 -> port 0x64 此时 %al 中等于 0x2
  outb    %al,$0x64               #  0xd1 写入 0x64 端口

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

  movb    $0xdf,%al               # 0xdf -> port 0x60
  outb    %al,$0x60               #  0xdf 写入 0x60 端口,打开 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                 # gdtdesc地址值加载进GDTR寄存器中
  movl    %cr0, %eax              # cr0 寄存器,如果为 1,系统处于保护模式,否则处于实模式
  orl     $CR0_PE_ON, %eax
  movl    %eax, %cr0              # 这四条指令,将 cr0  1,启动保护模式,并设置了 GDT

  # Jump to next instruction, but in 32-bit code segment.
  # Switches processor into 32-bit mode.
  ljmp    $PROT_MODE_CSEG, $protcseg

  .code32                     # Assemble for 32-bit mode
protcseg:
  # Set up the protected-mode data segment registers 将各个段寄存器的值设置为 0x10
  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            #  start 处设置为栈指针,是因为栈向低地址生长,而程序代码则相反
  call bootmain                   # 下面进入 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

Boot Loader 必须完成下面两个功能:

  1. Boot Loader 将处理器从实模式切换到 32-bit 的保护模式,因为只有在保护模式下,才能寻址到 0x00100000 地址之上的物理地址空间(从 program header 中可以看到,segment 的地址都是在 0x00100000 之上)。在 32-bit 保护模式下,将 (Segment:offset) 转换为物理地址使用了完全不同的方法,例如,offset 从 16bit 增到了 32bit。参考PC Assembly Language sections 1.2.7 和 1.2.8。
  2. Boot Loader 通过专用的 I/O 指令来直接获取 IDE 磁盘设备寄存器,来达到从硬盘中读取 kernel 的目的。

理解了 boot.S 和 main.c 后,可以看一下 obj/boot/boot.asm,这个文件是 boot.S 和 main.c 的反汇编版本,通过这个文件可以很清楚到看到 Boot Loader 处于物理内存的哪个地方。同样,obj/kern/kernel.asm 也包括了 JOS kernel 的反汇编。

Exercise 3

  1. 设置一个断点在0x7c00(Boot loader执行的第一条指令),跳到断点处,接着单步执行,利用 boot/boot.S 和 boot.asm 来判断当前执行到了什么地方。使用 x/i 命令,比较 boot loader 源文件、boot.asm 和 GDB 时的反汇编,看看这三者有什么区别。
  2. 进入到 readsect(),确认汇编指令与 C 代码之间的对应关系。
  3. 返回到 bootmain(),确定 for 循环读取了剩下所有的扇区。当 for 循环完成后,将会执行什么代码,在那里设置好断点,单步执行完剩余的 boot loader。

并回答下面的问题:

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

从 boot.S 中可以看出,执行 movl %eax, %cr0 后,%cr0 被置 0,此时 CPU 从 16-bit 切换到 32-bit,即从实模式切换到了保护模式。

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

第一个问题名,通过查看 boot.asm 得知 boot loader 最后一条指令为 7d6b: ff 15 18 00 01 00 call *0x10018 对应 main.c 中的 ((void (*)(void)) (ELFHDR->e_entry))();,这个地址也即是Kernel ELF中的的entry point地址。

后面两个问题,通过查看 obj/kern/kernel.asm,截取文件开头几行:

1

可以看出,里面有两个 entry,那么到底哪一个才是 kernel 的入口呢?最好的办法就是通过 gdb 调试,将断点打在 0x7d6b,也即是 boot loader 的最后一条指令处,再执行一步,如下:

2

可知,kernel 中执行的第一条指令是 0x10000c: movw $0x1234,0x472,那么加载的第一条指令自然就是 movw $0x1234,0x472 #warm boot

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?

这个问题在前面 main.c 的代码注释中已经有所提及,首先将 kernel 的 (512 * 8)B 的大小拷贝进物理内存的 low memory(0x10000 地址处于 low memory 中),这样就可以读取 ELF header 的内容了,如何加载的描述在 program header 中。

可以看到这里加载 (512*8)B 是一个浪费的行为,因为完全可以通过查看 kernel 可执行文件的 header 的相应字段来决定。

Loading the kernel

根据实验指导,阅读 K&R 5.1 ~ 5.5,通过下面的一个例子来熟悉 C 语言中的指针:

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

void
f(void)
{
    int a[4]; // 定义了一个栈上的数组
    int *b = malloc(16); // 在堆上分配了 16 字节
    int *c;
    int i;
    // 打印结果: 1: a = 0x7ffc987628f0, b = 0x55a9a0338260, c = 0xf0b2ff
    printf("1: a = %p, b = %p, c = %p\n", a, b, c);

    c = a; // c 指针指向栈上的数组
    for (i = 0; i < 4; i++)
    a[i] = 100 + i;
    c[0] = 200; // 上面给 a 初始化后,这一句又将数组第一个值改变
    // 打印结果: 2: a[0] = 200, a[1] = 101, a[2] = 102, a[3] = 103
    printf("2: a[0] = %d, a[1] = %d, a[2] = %d, a[3] = %d\n",a[0], a[1], a[2], a[3]);

    c[1] = 300;
    *(c + 2) = 301;
    3[c] = 302; // 这种写法看着有点奇怪,等价于 *(3 + c)
    // 打印结果: 3: a[0] = 200, a[1] = 300, a[2] = 301, a[3] = 302
    printf("3: a[0] = %d, a[1] = %d, a[2] = %d, a[3] = %d\n",a[0], a[1], a[2], a[3]);

    c = c + 1; // c 指针指向数组第二个元素
    *c = 400;
    // 打印结果: 4: a[0] = 200, a[1] = 400, a[2] = 301, a[3] = 302
    printf("4: a[0] = %d, a[1] = %d, a[2] = %d, a[3] = %d\n",a[0], a[1], a[2], a[3]);

    // 首先通过 readelf 命令查看文件为小端序
    // 执行之前,数组以 16 进制显示(): C8000000 90010000 2D010000 2E010000,c 指向第二个元素开头
    c = (int *) ((char *) c + 1); // 执行这条语句,将 c 指向第二个元素的第二个字节,并且将指针转型为 int *
    *c = 500; //0xF4010000
    // 执行后,16 进制显示为: C8000000 90F40100 00010000 2E010000
    // 打印结果: 5: a[0] = 200, a[1] = 128144, a[2] = 256, a[3] = 302
    printf("5: a[0] = %d, a[1] = %d, a[2] = %d, a[3] = %d\n",a[0], a[1], a[2], a[3]);

    b = (int *) a + 1; // 此时 b 为 a 数组中第二个元素的地址,增加 4 字节
    c = (int *) ((char *) a + 1); // c 为 a 数组中第一个元素,第二字节的地址,增加 1 字节
    // 打印结果: 6: a = 0x7ffe53887c50, b = 0x7ffe53887c54, c = 0x7ffe53887c51
    printf("6: a = %p, b = %p, c = %p\n", a, b, c);
}

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

想要深入 boot/main.c 的功能就要对 ELF 文件格式有一个详细的了解,下面是一些参考资料:

  1. the ELF specification
  2. Wikipedia page

An ELF binary starts with a fixed-length ELF header, followed by a variable-length program header listing each of the program sections to be loaded.

执行 objdump -h obj/kern/kernel,来看 kernel 文件有哪些 section: kernel-sections 这些 section 只有一小部分是会被加载进内存的,其余是一些 debug 信息。

LMA(Load address): The load address of a section is the memory address at which that section should be loaded into memory.

LMA 是 section 加载到内存中的地址。

VMA(Link address): The link address of a section is the memory address from which the section expects to execute.

VMA 是程序运行起来,实际的地址。

通常情况下,这两个地址是一样的,例如查看 objdump -h obj/boot/boot.out : boot.out-section 这个文件中的 LMA 和 VMA 就是相等的,即 boot loader 的加载地址与实际运行的地址是一样的,那为什么 kernel 的 VMA 与 LMA 不相等呢?我猜想这是因为进入实模式后,采用的虚拟内存,所以 kernel 运行时的地址实际上是虚拟地址,所以 VMA 又作 Virtual Memory Address。

the kernel is telling the boot loader to load it into memory at a low address (1 megabyte), but it expects to execute from a high address.

现在我们知道了,section 会被加载进内存,那么哪些 section 会被加载呢?这些 section 又会被加载都内存的什么地方呢?

这是通过 ELF 文件的 program header 知道的: kernel program header

The areas of the ELF object that need to be loaded into memory are those that are marked as “LOAD”.

Exercise 4

这个作业是根据要求修改 boot loader 的 VMA,由于:

The BIOS loads the boot sector into memory starting at address 0x7c00, so this is the boot sector’s load address.

这时,boot loader 的 LMA 与 VMA 就不相等了,在 boot loader 中是没有虚拟内存的概念的,所以一旦有指令设置到 memory reference,就一定会出错。

除了 program header,ELF file header 中也有一个重要的概念,那就是 entry point:

kernel file header

kernel 的 entry point address 为 0x10000c。

the entry point address holds the link address of the entry point in the program: the memory address in the program’s text section at which the program should begin executing.

You should now be able to understand the minimal ELF loader in boot/main.c. It reads each section of the kernel from disk into memory at the section’s load address and then jumps to the kernel’s entry point.

Exercise 5

下面是根据作业要求完成的截图: exercise5

内容不一样,很显然,就是因为第二次的断点是打在了 kernel 第一条指令处,此时 kernel 已经被加载到 0x00100000 朝上的位置。

代码中的注释结合图片内容更容易理解。

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

#define SECTSIZE    512
#define ELFHDR      ((struct Elf *) 0x10000) // scratch space

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

void
bootmain(void)
{
    // 这个结构体是 Program Header 的缩写,在 elf.h 中
    // 这个结构体描述了可执行文件段与内存段之间的映射关系 - 见 Program Header Table
    struct Proghdr *ph, *eph;

    // read 1st page off disk
    // 从 file(kernel) 的第 0 个字节开始,拷贝 512 * 8 字节(即 8 个扇区到大小)到内存物理地址 0x10000 起始处
    // 其中肯定已经把 file header 和 program header 拷贝到了内存中以 0x10000 为起始处。
    // 那么这时候就可以通过读取内存中 header 的信息,来加载 kernel 了
    readseg((uint32_t) ELFHDR, SECTSIZE*8, 0);
    // is this a valid ELF?
    // 查看加载进来的 ELF 文件的魔数属否有效
    if (ELFHDR->e_magic != ELF_MAGIC)
        goto bad;
    // load each program segment (ignores ph flags)
    // 加载每一个 segment
    // Excutable Object File 是由多个 segment 组成的,把这些 segment 加载完了
    // 也就意味这 kernel image 加载完成
    // 计算 program header 的地址
    ph = (struct Proghdr *) ((uint8_t *) ELFHDR + ELFHDR->e_phoff);
    // program header 结束的地址
    eph = ph + ELFHDR->e_phnum;
    // 从 file header 看来,Number of program header = 3,所以这里循环 3 次
    for (; ph < eph; ph++)
        // p_pa is the load address of this segment (as well
        // as the physical address)
        // 将 file(kernel) 中相应的连续的 section 拷贝进内存相应位置
        readseg(ph->p_pa, ph->p_memsz, ph->p_offset);

    // call the entry point from the ELF header
    // the address of the first instruction to execute when the program runs - 见 program entry point
    // 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;
    // 物理内存中一个 segment 的结束地址
    end_pa = pa + count;
    // round down to sector boundary
    // 将 pa 向下舍入到扇区边界
    pa &= ~(SECTSIZE - 1);
    // translate from bytes to sectors, and kernel starts at sector 1
    // 找到 offset 所在的扇区,因为 kernel 从第二个扇区开始,所以要 +1
    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.
        // 因为这时候还没有启动使用分页,所以可以直接使用物理内存,使用分页后,
        // 就不能直接操作物理内存了,而是通过虚拟内存映射到物理内存,所以分页后,
        // 只能操作虚拟内存
        readsect((uint8_t*) pa, offset); 
        pa += SECTSIZE;
        offset++;
    }
}

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

// 根据 program header 的指示,将 kernel 中的 section 加载到 segment 中,
// section 是存在于 file 中,而 file 又存在于磁盘上,所以要读取磁盘上的扇区。
void
readsect(void *dst, uint32_t offset)
{
    // wait for disk to be ready
    waitdisk();
    // http://www.philipstorr.id.au/pcbook/book2/ioassign.htm
    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);
}

Part 3: The Kernel

Like the boot loader, the kernel begins with some assembly language code that sets things up so that C language code can execute properly.

链接 kernel 的文件 kern/kernel.ld

Operating system kernels often like to be linked and run at very high virtual address, such as 0xf0100000(3GB), in order to leave the lower part of the processor’s virtual address space for user programs to use.

这就解释了,为什么 kernel 的 VMA 以 0xF0100000 为起始位置。

exercise 7

Exercise 7. Use QEMU and GDB to trace into the JOS kernel and stop at the movl %eax, %cr0. Examine memory at 0x00100000 and at 0xf0100000. Now, single step over that instruction using the stepi GDB command. Again, examine memory at 0x00100000 and at 0xf0100000. Make sure you understand what just happened. What is the first instruction after the new mapping is established that would fail to work properly if the mapping weren’t in place? Comment out the movl %eax, %cr0 in kern/entry.S, trace into it, and see if you were right.

exercise7

通过调试可以很直观的看到,在执行 movl %eax, %cr0 之前,0xf0100000 处是没值的,执行完这条指令后,0x00100000 与 0xf0100000 处的内容就相等了,这是因为启动了保护模式。

Formatted Printing to the Console

Read through kern/printf.c, lib/printfmt.c, and kern/console.c, and make sure you understand their relationship. It will become clear in later labs why printfmt.c is located in the separate lib directory.

Exercise 8

We have omitted a small fragment of code - the code necessary to print octal numbers using patterns of the form “%o”. Find and fill in this code fragment.

case 'o':
        // Replace this with your code.
        num = getuint(&ap, lflag);
        if((long long) num < 0){
                puch('-', putdat);
                num = -(long long) num;
        }
        base = 8;
        goto number;

下面回答一些关于这几个文件的提问:

Q1: Explain the interface between printf.c and console.c. Specifically, what function does console.c export? How is this function used by printf.c?

console.c 向 printf.c 提供了 cputchar(),可以看到,console.c 中的函数基本都是由 inb() outb() 来完成 I/O。

Q2: Explain the following from console.c:

// What is the purpose of this?
if (crt_pos >= CRT_SIZE) {
        int i;

        memmove(crt_buf, crt_buf + CRT_COLS, (CRT_SIZE - CRT_COLS) * sizeof(uint16_t));
        for (i = CRT_SIZE - CRT_COLS; i < CRT_SIZE; i++)
                crt_buf[i] = 0x0700 | ' ';
        crt_pos -= CRT_COLS;
}

void *memmove(void *dest, const void *src, size_t n) 进行内存拷贝,并且在 console.h 中找到:

#define CRT_ROWS 25
#define CRT_COLS 80
#define CRT_SIZE (CRT_ROWS * CRT_COLS)

crt_pos 代表了当前屏幕光标的位置,如果光标位置超出屏幕,就将内存内容向前拷贝 CRT_COLS,屏幕显示为向前滚动一行。crt_buf[i] = 0x0700 | ' ' 将最后一行置空。

Q3: Trace the execution of the following code step-by-step:

int x = 1, y = 3, z = 4;
cprintf("x %d, y %x, z %d\n", x, y, z);

通过翻看 JOS 的源码,调用 cprintf() 会形成这个调用链: cprintf -> vcprintf -> vprintfmt -> putch -> cputchar。

In the call to cprintf(), to what does fmt point? To what does ap point? fmt指向的是 "x %d, y %x, z %d\n" 这个字符串, ap 指向的是第一个参数 x。

Q4: Run the following code. unsigned int i = 0x00646c72; cprintf(“H%x Wo%s”, 57616, &i);What is the output?

可以看到这个 fmt 将第二个参数当作 string 输出了,并输出第一个参数的 16 进制,通过查看 ASCII 表,0x00 64 6c 72 ,对应的字符串为 “rld “,所以最终的输出为 HE110 World,注意这是小端序的输出结果。

Q5: cprintf(“x=%d y=%d”, 3); 的输出是什么?

会取到栈桢中的其他值。

Q6: Let’s say that GCC changed its calling convention so that it pushed arguments on the stack in declaration order, so that the last argument is pushed last. How would you have to change cprintf or its interface so that it would still be possible to pass it a variable number of arguments?

把参数逆序一下就行了。

The Stack

写一个 kernel monitor 函数,来打印栈桢的 backtrace: 调用一直到当前函数,这期间保存的 IP 值。

exercise9

Determine where the kernel initializes its stack, and exactly where in memory its stack is located. How does the kernel reserve space for its stack? And at which “end” of this reserved area is the stack pointer initialized to point to?

kernel.asm 即 kernel stack 位于虚拟地址 0xf0110000 处。

x86 的栈指针是 %esp。在 32-bit 模式下,栈只能保存 32-bit 的值。很多指令都会用到 %esp,例如 call 指令。

On entry to a C function, the function’s prologue code normally saves the previous function’s base pointer by pushing it onto the stack, and then copies the current esp value into ebp for the duration of the function.

这样一来,在栈中回溯,就能找到找到调用链中的函数调用信息,这些信息是调用链中各个函数的栈桢大小,即能通过栈桢中保存的这些 %ebp 值,找到调用链中所有函数的栈桢起始位置。

exercise 10

To become familiar with the C calling conventions on the x86, find the address of the test_backtrace function in obj/kern/kernel.asm, set a breakpoint there, and examine what happens each time it gets called after the kernel starts. How many 32-bit words does each recursive nesting level of test_backtrace push on the stack, and what are those words?

要回答这个问题,首先看一下 test_backtrace 的代码:

void test_backtrace(int x)
{
        cprintf("entering test_backtrace %d\n", x);
        if (x > 0)
                test_backtrace(x-1);
        else
                // 注意这个函数在递归进入 else 的时候执行一遍,且只有一遍
                // 在这一遍里就要将所有调用链打印出来
                // 涉及到下面练习 11,
                mon_backtrace(0, 0, 0);
        cprintf("leaving test_backtrace %d\n", x);
}

可以看出这是一个递归调用,所以我们将断点打在 test_backtrace 的函数入口处,观察两次进入函数时,栈的变化即可:

栈指针变化

可以看到第二次进入 test_backtrace 函数的时候,栈指针增加了 0x20 的大小,对应里面的内容可以通过 x/Nx esp 来观察,因为每次递归调用参数都不同,自然栈桢内容也不同,但是大小是相同的。

exercise 11

实现 backtrace 函数 mon_bakctrace(),在 kern/monitor.c 里面,结合 inc/x86.h 的 read_ebp() 函数。输出形如下:

Stack backtrace:
  ebp f0109e58  eip f0100a62  args 00000001 f0109e80 f0109e98 f0100ed2 00000031
  ebp f0109ed8  eip f01000d6  args 00000000 00000000 f0100058 f0109f28 00000061
  ...

下面是实现代码:

int
mon_backtrace(int argc, char **argv, struct Trapframe *tf)
{
        // Your code here.
        // cause ebp is address
        uint32_t* ebp = (uint32_t*)read_ebp();
        cprintf("Stack backtrace:\n");
        // 根据 kernel.asm Oxf010002f(第 53 行) 处可知,在执行 i386_init 之前会将 %ebp 的值清零 
        // 所以函数调用第一个栈帧,即 test_backtrace 第一次调用的栈帧 saved %ebp 一定为 0
        // 所以我们可以通过下面这样一个循环来控制 trace 的条数
        while(ebp){
                cprintf(" ebp %08x", *ebp);
                cprintf(" eip %08x", *(ebp+1));
                cprintf(" args %08x %08x %08x %08x %08x\n", *(ebp+2), *(ebp+3), *(ebp+4), *(ebp+5), *(ebp+6));
                // 将 %ebp 的值设置为前一个栈帧里的 saved %ebp
                ebp =(uint32_t*)*ebp;
        }
        return 0;
}

下面是执行结果:

mon_backtrace执行结果

下面是 kernel.asm 中执行 i386_init 之前,对 %ebp 置零的操作:

桢指针置零

这个练习费了我不少时间,画一张简图来辅助下理解:

递归栈桢

exercise 12

Modify your stack backtrace function to display, for each eip, the function name, source file name, and line number corresponding to that eip.

在 stabs.h 中找到以下信息:

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
};

The constants below define some symbol types used by various debuggers and compilers. JOS uses the N_SO, N_SOL, N_FUN, and N_SLINE types.

#define N_GSYM          0x20    // global symbol
#define N_FNAME         0x22    // F77 function name
#define N_FUN           0x24    // procedure name ***
#define N_STSYM         0x26    // data segment variable
#define N_LCSYM         0x28    // bss segment variable
#define N_MAIN          0x2a    // main function name
#define N_PC            0x30    // global Pascal symbol
#define N_RSYM          0x40    // register variable
#define N_SLINE         0x44    // text segment line number ***
#define N_DSLINE        0x46    // data segment line number
#define N_BSLINE        0x48    // bss segment line number
#define N_SSYM          0x60    // structure/union element
#define N_SO            0x64    // main source file name ***
#define N_LSYM          0x80    // stack variable
#define N_BINCL         0x82    // include file beginning
#define N_SOL           0x84    // included source file name ***
#define N_PSYM          0xa0    // parameter variable
#define N_EINCL         0xa2    // include file end
#define N_ENTRY         0xa4    // alternate entry point
#define N_LBRAC         0xc0    // left bracket
#define N_EXCL          0xc2    // deleted include file
#define N_RBRAC         0xe0    // right bracket
#define N_BCOMM         0xe2    // begin common
#define N_ECOMM         0xe4    // end common
#define N_ECOML         0xe8    // end common (local name)
#define N_LENG          0xfe    // length of preceding entry

look in the file kern/kernel.ld for _STAB*

kernel.ld部分

可以看到链接脚本使用 PROVIDE 命令,定义了多个 __STAB_* 符号。

点击查看STABS format相关资料。

run objdump -h obj/kern/kernel. Display the contents of the section headers run objdump -G obj/kern/kernel. Display (in raw form) any STABS info in the file run gcc -pipe -nostdinc -O2 -fno-builtin -I. -MD -Wall -Wno-format -DJOS_KERNEL -gstabs -c -S kern/init.c, and look at init.s.

  1. -pipe Use pipes rather than intermediate files.
  2. -nostdinc Do not search the standard system directories for header files.
  3. -fno-builtin Don’t recognize built-in functions that do not begin with _builtin as prefix.

see if the bootloader loads the symbol table in memory as part of loading the kernel binary

向 mon_backtrace 中添加如下代码:

  unsigned int eip = ebp[1];
  struct Eipdebuginfo info;
  debuginfo_eip(eip, &info);
  cprintf("%s:%d: %.*s+%d\n", info.eip_file, info.eip_line, info.eip_fn_namelen, info.eip_fn_name
  eip-info.eip_fn_addr);

由于没看到 exercise12 中得这一句话: Complete the implementation of debuginfo_eip by inserting the call to stab_binsearch to find the line number for an address.

导致输出的 line_number 一直都为 0,在 debuginfo_eip() 中指示位置添加如下代码:

stab_binsearch(stabs, &lline, &rline, N_SLINE, addr);
info->eip_line = stabs[lline].n_desc;

make grade 得分如下:

lab1得分