实验一分成三部分:

  • 第一部分集中精力熟悉 x86 汇编语言、qemu x86 仿真器和 PC 的开机引导过程
  • 第二部分学习 6.828 内核的引导加载程序,它位于 ./boot 中
  • 第三部分深入研究 6.828 内核本身的初始模板,名为 JOS,它位于 ./kernel 中

    🎆PC Bootstrap

    🍕Getting Started with x86 assembly

    x86 汇编的详细内容,因为学过 CMU 的 CSAPP 所以比较熟悉,就不再介绍了;直接附一份 MIT 6.828 提供的文档。
    pcasm-book.pdf
    Exercise 1

    🍔Simulating the x86

    我们使用 qemu 进行 kernel 的仿真与调试。这里使用 MIT 提供的 qemu,以便更好的调试,需要注意的是,我们需要将 qemu 源码放在 $HOME/MIT 文件夹下才能成功构建和安装。
    使用 make 命令进行编译,构建最小的 JOS 的 boot loader 和 kernel: ```bash wangjq@ubuntu:~/MIT/lab$ make
  • as kern/entry.S
  • cc kern/entrypgdir.c
  • cc kern/init.c
  • cc kern/console.c
  • cc kern/monitor.c
  • cc kern/printf.c
  • cc kern/kdebug.c
  • cc lib/printfmt.c
  • cc lib/readline.c
  • cc lib/string.c
  • ld obj/kern/kernel ld: warning: section ‘.bss’ type changed to PROGBITS
  • as boot/boot.S
  • cc -Os boot/main.c
  • ld boot/boot boot block is 390 bytes (max 510)
  • mk obj/kern/kernel.img
    1. 现在,我们已经可以使用 qemu 进行模拟了,在 makefile 中已经写好了启动的命令。我们使用上面创建的文件 ./obj/kern/kernel.img,作为模拟 PC 的“虚拟硬盘”的内容;这个文件包含了我们的 boot loader kernel。<br />运行 make qemu 进入虚拟调试环境,目前我们的内核只有 help kerninfo 命令:
    2. ```bash
    3. wangjq@ubuntu:~/MIT/lab$ make qemu
    4. sed "s/localhost:1234/localhost:26000/" < .gdbinit.tmpl > .gdbinit
    5. qemu-system-i386 -drive file=obj/kern/kernel.img,index=0,media=disk,format=raw -serial mon:stdio -gdb tcp::26000 -D qemu.log
    6. 6828 decimal is XXX octal!
    7. entering test_backtrace 5
    8. entering test_backtrace 4
    9. entering test_backtrace 3
    10. entering test_backtrace 2
    11. entering test_backtrace 1
    12. entering test_backtrace 0
    13. leaving test_backtrace 0
    14. leaving test_backtrace 1
    15. leaving test_backtrace 2
    16. leaving test_backtrace 3
    17. leaving test_backtrace 4
    18. leaving test_backtrace 5
    19. Welcome to the JOS kernel monitor!
    20. Type 'help' for a list of commands.
    21. K> help
    22. help - Display this list of commands
    23. kerninfo - Display information about the kernel
    24. K> kerninfo
    25. Special kernel symbols:
    26. _start 0010000c (phys)
    27. entry f010000c (virt) 0010000c (phys)
    28. etext f0101871 (virt) 00101871 (phys)
    29. edata f0112300 (virt) 00112300 (phys)
    30. end f0112940 (virt) 00112940 (phys)
    31. Kernel executable memory footprint: 75KB

    🍟The PC’s Physical Address Space

    本节我们会探讨 PC 启动的更多细节;PC 的物理地址空间是硬划分的,32 位计算机具有以下总体布局: ``` +—————————+ <- 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

  1. 第一批基于 16 位的 Intel 8088 处理器的 PC 只能寻址 1MB 的物理内存。因此,早期 PC 的物理地址空间以 0x00000000 开始,以 0x000FFFFF 结束,而不是 0xFFFFFFFF。标记为“低内存”的 640KB 区域是早期 PC 可以使用的 RAM 区域;事实上,最早的 PC 只能配置 16KB32KB 64KB RAM!<br />从 0x000A0000 0x000FFFFF 384KB 区域由硬件保留,用于特殊用途,如视频显示缓冲区和非易失性内存中的固件:
  2. - 这个保留区域中最重要的是 BIOS,它占用从 0x000F0000 0x000FFFFF 64KB 区域
  3. - 在早期的个人电脑中,BIOS 保存在 ROM 中,但当前的个人电脑将 BIOS 存储在可更新的闪存中
  4. - BIOS负责执行基本的系统初始化,如激活显卡和检查安装的内存量
  5. - 执行此初始化后,BIOS 将从软盘、硬盘、CD-ROM 或网络等适当位置加载操作系统,并将机器的控制权传递给操作系统
  6. 当英特尔最终用 80286 80386 处理器“打破了 1MB 的壁垒”时,PC 架构师们仍然保留了原来的 1MB 物理地址空间布局,以确保与现有软件的向后兼容性。因此,现代 PC 在物理内存中有一个从 0x000A0000 0x00100000 的空闲空间,将 RAM 分为“低内存”或“常规内存”(前 640KB)和“扩展内存”(其他所有内存)。此外,在 PC 32 位物理地址空间的最上面的一些空间,现在通常由 BIOS 保留供 32 PCI 设备使用。<br />最新的 x86 处理器可以支持超过 4GB 的物理 RAM,因此 RAM 可以扩展到 0xFFFFFFFF 以上。
  7. <a name="PMDYs"></a>
  8. ## 🌭The ROM BIOS
  9. 本部分,我们将使用 qemu 调试工具来研究 IA-32 兼容计算机是如何启动的。
  10. 1. 打开两个终端窗口,并进入实验目录中
  11. 1. 在其中一个终端中输入 make qemu-gdb make qemu-nox-gdb,这将启动 qemu,但 qemu 在处理器执行第一条指令并等待来自 gdb 的调试连接之前停止
  12. 1. 在第二个终端中,运行 make gdb 命令
  13. 我们可以在第二个终端中看到如下的现象:
  14. ```bash
  15. wangjq@ubuntu:~/MIT/lab$ make gdb
  16. gdb -n -x .gdbinit
  17. # ...other info has been ignored
  18. The target architecture is assumed to be i8086
  19. [f000:fff0] 0xffff0: ljmp $0xf000,$0xe05b
  20. 0x0000fff0 in ?? ()
  21. + symbol-file obj/kern/kernel
  22. (gdb)

其中的这一行:

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

我们可以了解到下面的信息:

  • PC 启动时,在 0x000ffff0 处执行第一条指令,它位于位 BIOS 保留的 RAM 的顶部
  • 开始执行时,处理器的 CS 寄存器和 IP 寄存器分别为:CS = 0xf000 和 IP = 0xfff0
  • 第一条指令是无条件跳转指令,跳转到 0x000fe05b 即 CS = 0xf000、IP = 0xe05b

由于使用 Intel 设计 8088 处理器,所以 BIOS 的物理地址被硬性指定为:0x000f0000-0x000fffff,这种设计确保了 BIOS 总是启动后首先获得对机器的控制。
qemu 模拟器自带 BIOS,它将 BIOS 放在处理器的模拟物理地址空间中的这个位置。处理器复位时,处理器进入实模式,并将 CS 设置为 0xf000,将 IP 设置为 0xfff0,以便从 CS:IP 段地址开始执行。
在实模式下 CS:IP 的地址转换公式如下:

  1. # 物理地址=16*CS+IP, 所以上面的转换如下:
  2. 16 * 0xf000 + 0xfff0 # 十六进制数乘 16 是很简单的
  3. = 0xf0000 + 0xfff0 # 只需附加一个0
  4. = 0xffff0

0x000ffff0 是 BIOS 结束前的 16 个字节,它可以完成的功能很有限,所以跳转到前面的地址中也就可以理解了。
BIOS 的工作主要如下:

  • 当 BIOS 运行时,它会设置一个中断描述符表,并初始化各种设备,如 VGA 显示器等
  • 初始化 PCI 总线和 BIOS 知道的所有重要设备后,它会搜索可引导设备,如软盘、硬盘或 CD-ROM
  • 当找到可引导磁盘时,BIOS 会从磁盘读取引导加载程序,并将控制权传输给它

Exercise 2

🧨The Boot Loader

PC 的软盘和硬盘被分成 512 字节的区域,称为扇区。扇区是磁盘的最小传输单位:每个读或写操作的大小必须是一个或多个扇区,并在扇区边界上对齐。如果磁盘是可引导的,则第一个扇区称为引导扇区,引导加载程序的代码就位于这里。当 BIOS 找到可引导软盘或硬盘时,它将 512 字节的引导扇区加载到物理地址从 0x7c00 到 0x7dff 的内存中,然后使用 jmp 指令将 CS:IP 设置为 0000:7c00,将控制权传递给引导加载程序。与 BIOS 加载地址一样,这些地址是任意的,没有任何设计的原因和理由,但是它们对于计算机是固定的,形成了现行的计算机标准。
以 CD-ROM 形式存在的引导加载程序,要复杂一些,在这里不再赘述。
在本实验中,使用传统的硬盘引导机制,这意味着我们的引导加载程序必须小于 512 字节。引导加载程序由一个汇编语言源文件 boot/boot.s 和一个 C 源文件 boot/main.c 组成。引导加载程序必须执行两个主要功能:

  1. 引导加载程序将处理器从实模式切换到 32 位保护模式,因为只有在这种模式下,软件才能访问处理器物理地址空间中 1MB 以上的所有内存
  2. 引导加载程序通过 x86 的特殊 I/O 指令直接访问 IDE 磁盘设备寄存器,从硬盘读取内核

    🍳boot.s

    ```c

    include

// 启动 CPU: 切换到 32 位保护模式, 跳转 C 代码. // BIOS 从硬盘的第一个扇区加载这份代码 // 从物理地址 0x7c00 处开始执行, 并且在实模式下运行 // %cs=0; %ip=7c00.

.set PROT_MODE_CSEG, 0x8 // 内核代码段选择器 .set PROT_MODE_DSEG, 0x10 // 内核数据段选择器 .set CR0_PE_ON, 0x1 // 保护模式开启标识位

.globl start start: .code16 // 声明是 16-bit 汇编 cli // 关闭中断 cld // 指定指针移动方向

// 将寄存器中原有的值清零 (DS, ES, SS). xorw %ax,%ax // Segment number zero movw %ax,%ds // -> Data Segment movw %ax,%es // -> Extra Segment movw %ax,%ss // -> Stack Segment

// 打开 A20: // 为了与早期的 PC 机兼容, 物理地址的低 20 位是低地址, // 所以高于 1MB 会默认滑倒 0. 这部分代码开启保护模式. // 具体的可以参考 ‘http://bochs.sourceforge.net/techspec/PORTS.LST‘ // 这里不再赘述. seta20.1: inb $0x64,%al // 循环等待 testb $0x2,%al jnz seta20.1

movb $0xd1,%al // 0xd1 -> port 0x64 outb %al,$0x64

seta20.2: inb $0x64,%al // 循环等待 testb $0x2,%al jnz seta20.2

movb $0xdf,%al // 0xdf -> port 0x60 outb %al,$0x60

// 从实模式切换到保护模式, 使用 GDT 和段翻译器使得虚拟地址可以 // 找到他们对应的物理地址, 使有效内存映射在切换过程中不发生变化. lgdt gdtdesc // 加载 GDT 在 76-83 行中 movl %cr0, %eax orl $CR0_PE_ON, %eax movl %eax, %cr0 // 将 %cr0 的最低为置为 1 开启保护模式

// 跳转到下一条指令, 下一条指令在 32-bit 代码段. // 切换处理器进入 32-bit 模式. ljmp $PROT_MODE_CSEG, $protcseg

.code32 // 声明下面的代码是 32-bit protcseg: // 设置保护模式的段寄存器 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

// 设置栈指针并调用 C 代码. movl $start, %esp call bootmain

// 如果 bootmain 执行结束(不会发生), 陷入循环. spin: jmp spin

// Bootstrap GDT .p2align 2 // 对齐 // 这里的 SEG 是定义在头文件中的宏代码, 参数依次为 // 访问权限 起始地址 内存界限 // 可以看到采用的并不是分段机制, 都可以访问所有的内存空间 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

  1. mmu 中的宏定义如下,用于简化设置代码段的操作:
  2. ```c
  3. #define SEG(type, base, lim) \
  4. .word(((lim) >> 12) & 0xffff), ((base)&0xffff); \
  5. .byte(((base) >> 16) & 0xff), (0x90 | (type)), \
  6. (0xC0 | (((lim) >> 28) & 0xf)), (((base) >> 24) & 0xff)

🧇main.c

  1. #include <inc/elf.h>
  2. #include <inc/x86.h>
  3. /**********************************************************************
  4. * 这是一个非常简单的引导加载程序,
  5. * 它唯一的工作就是从第一个IDE硬盘引导ELF内核映像.
  6. *
  7. * 硬盘结构
  8. * * 这个程序(boot.S and main.c)是引导加载程序.
  9. * 它应该被存储在硬盘的第一个扇区.
  10. *
  11. * * 第二个扇区存储内核镜像.
  12. *
  13. * * 内核镜像必须是 ELF 格式的.
  14. *
  15. * 启动步骤
  16. * * 当 CPU 启动时, 它加载 BIOS 到内存中并且执行它
  17. *
  18. * * BIOS 初始化设备, 设置中断优先级,
  19. * 引导可引导设备的第一个扇区(例如硬盘驱动器)
  20. * 读入内存并跳转到该位置执行.
  21. *
  22. * * 假设本引导加载程序存储在硬盘的第一个扇区中,
  23. * 那么这个代码将接管电脑的控制...
  24. *
  25. * * 首先执行 boot.S -- 它开启保护模式,
  26. * 设置执行栈一边运行 C 代码, 然后调用 bootmain()
  27. *
  28. * * bootmain() 在这个文件中, 它将内核读取到内存中并且跳转执行.
  29. **********************************************************************/
  30. #define SECTSIZE 512
  31. #define ELFHDR ((struct Elf*)0x10000) // 暂时空间
  32. void readsect(void*, uint32_t);
  33. void readseg(uint32_t, uint32_t, uint32_t);
  34. void bootmain(void) {
  35. struct Proghdr *ph, *eph;
  36. // 从磁盘读取第一页
  37. readseg((uint32_t)ELFHDR, SECTSIZE * 8, 0);
  38. // 判断是否是有效的 ELF?
  39. if (ELFHDR->e_magic != ELF_MAGIC)
  40. goto bad;
  41. // 计算 Program Header Table 的偏移
  42. ph = (struct Proghdr*)((uint8_t*)ELFHDR + ELFHDR->e_phoff);
  43. // 计算 Program Header Table 的结束位置
  44. eph = ph + ELFHDR->e_phnum;
  45. for (; ph < eph; ph++)
  46. // 循环取出所有的内核的段
  47. readseg(ph->p_pa, ph->p_memsz, ph->p_offset);
  48. // 调用 ELF 头部的入口点
  49. // note: does not return!
  50. ((void (*)(void))(ELFHDR->e_entry))();
  51. bad:
  52. outw(0x8A00, 0x8A00);
  53. outw(0x8A00, 0x8E00);
  54. while (1)
  55. /* do nothing */;
  56. }
  57. // 在内核的 'offset' 处读取 'count' 字节到物理地址 'pa'.
  58. // 获取读取的比要求的多
  59. void readseg(uint32_t pa, uint32_t count, uint32_t offset) {
  60. uint32_t end_pa;
  61. end_pa = pa + count;
  62. // 四舍五入到扇区边界
  63. pa &= ~(SECTSIZE - 1);
  64. // 从字节转换到扇区,内核从扇区1开始
  65. offset = (offset / SECTSIZE) + 1;
  66. /*
  67. * 如果速度太慢, 我们可以一次读取很多扇区.
  68. * 我们在内存中写的比要求的要多,
  69. * 但不要太在意这个问题, 因为按递增顺序加载.
  70. */
  71. while (pa < end_pa) {
  72. /*
  73. * 因为我们还没有启用分页, 而且我们正在使用
  74. * 一个标识段映射(参见boot.S), 所以我们可以直接使用物理地址.
  75. * 一旦 JOS 启用 MMU, 就不可以直接试用了.
  76. */
  77. readsect((uint8_t*)pa, offset);
  78. pa += SECTSIZE;
  79. offset++;
  80. }
  81. }
  82. void waitdisk(void) {
  83. // 等待硬盘就绪
  84. while ((inb(0x1F7) & 0xC0) != 0x40)
  85. /* do nothing */;
  86. }
  87. void readsect(void* dst, uint32_t offset) {
  88. // 等待硬盘就绪
  89. waitdisk();
  90. /*
  91. * 系统是先想 0x1F2 端口送入一个值 1, 代表取出 1 个扇区
  92. * 然后向 0x1F3~0x1F6 中送入你要读取的扇区编号的 32bit 表示形式
  93. * 最后向 0x1F7 端口输出 0x20 指令表示要读取这个扇区
  94. * 这些 C 的函数是对汇编语言的封装, 有兴趣的可以查查 C 如何调用汇编
  95. */
  96. outb(0x1F2, 1);
  97. outb(0x1F3, offset);
  98. outb(0x1F4, offset >> 8);
  99. outb(0x1F5, offset >> 16);
  100. outb(0x1F6, (offset >> 24) | 0xE0);
  101. outb(0x1F7, 0x20);
  102. // 等待硬盘就绪
  103. waitdisk();
  104. // 读取一个扇区
  105. insl(0x1F0, dst, SECTSIZE / 4);
  106. }

🥞几个问题

下面所提到的函数对应上面两个文件分析中的行数。

🧈No1

在什么时候处理器开始运行于 32bit 模式?到底是什么把 CPU 从 16 位切换为 32 位工作模式?

在 boot.s 第 56 行后进入 32-bit 模式。
54 行的 ljmp $PROT_MODE_CSEG, $protcseg 指令就是让 CPU 进入保护模式的指令;其中前面,的两组 outb、inb 指令是对切换为 32-bit 保护模式的准备。

🍞No2

Boot Loader 中执行的最后一条语句是什么?内核被加载到内存后执行的第一条语句又是什么?

执行的最后一条指令是:

  1. ((void (*)(void))(ELFHDR->e_entry))();

其中的 e_entry 是内核入口点的地址,将其转换为函数指针进行调用实现跳转。
第一条语句:

  1. movw $0x1234, 0x472

🥐No3

内核的第一条指令在哪里?

在 ./kern/entry.S 中,每次具体的物理地址是不一样的。

🥨No4

Boot Loader 是如何知道它要读取多少个扇区才能把整个内核都送入内存的呢?在哪里找到这些信息?

关于操作系统一共有多少个段,每个段又有多少个扇区的信息位于操作系统文件中的 Program Header Table 中。
这个表中的每个表项分别对应操作系统的一个段,并且包括:

  • 段的大小
  • 段起始地址
  • 偏移

所以可以通过这个表中表项所提供的信息来确定内核占用多少个扇区。那么关于这个表存放在哪里的信息,则是存放在操作系统内核映像文件的 ELF 头部信息中。
Exercise 6

🎉The Kernel

同样内核的入口文件也是使用汇编代码编写的。

🥖Using virtual memory to work around position dependence

在程序的执行过程总,我们可以发现引导加载程序跳转的内核地址和实际加载的地址是完美匹配的,但是我们检查一下由 objdump 打印的地址会发现有着不小的差异。
操作系统内核通常倾向于链接在非常高的虚拟地址(如:0xf0100000)上运行,以便将虚拟地址空间的较低部分留给用户程序使用。但是许多机器在 0xf0100000 处没有任何物理内存,因此不能在那里存储内核。
将使用处理器的内存管理硬件将虚拟地址 0xf0100000,即内核代码预期运行的链接地址映射到物理地址 0x00100000 ,即 Boot Loader 将内核加载到物理地址。通过这种方式,为用户进程留下足够的地址空间而内核具有较高的虚拟地址,但它将被加载到物理地址为 0x00100000 处,即 1MB 处,正好位于 BIOS ROM 的上方。
在之后的实验中,将映射 PC 物理地址空间的整个底部的 256MB,分别从物理地址 0x00000000 到 0x0fffffff,即虚拟地址从 0xf0000000 到 0xffffffff。
现在,我们将只映射前 4MB 的物理内存,这将足以让我们启动并运行。我们使用 kern/entrypgdir.c 中手工编写、静态初始化的页目录和页表来实现这一点。在 kern/entry.s 设置 CR0_PG 标志之前,内存引用被视为物理地址。一旦设置了 CR0_PG,内存引用会使用虚拟内存硬件将虚拟地址转换为物理地址。
entry_pgdir 将虚拟地址 0xf0000000 到 0xf0400000 转换为物理地址 0x00000000 到 0x00400000。任何不在这两个范围内的虚拟地址都将导致硬件异常,因为我们还没有设置中断处理,这将导致 qemu 转储机器状态并退出。
Exercise 7

🧀Formatted Printing to the Console

大多数人认为 printf() 之类的函数是理所当然的,有时甚至认为它们是 C 语言的“原语”。但是在操作系统内核中,我们必须自己实现所有的 I/O。
通读 kern/printf.c、lib/printfmt.c 和 kern/console.c,确保理解它们之间的关系。
Exercise 8

🥪No1

解释 printf.c 和 console.c 之间的接口。具体来说,console.c 导出什么函数?printf.c 如何使用这个函数?

printf.c 中使用了 console.c 中的 cputchar 函数,并封装为 putch 函数。并以函数形参传递到 printfmt.c 中的 vprintfmt 函数,用于向屏幕上输出一个字符。
更多导出的函数可以查看 inc/stdio.h 里面有详细的函数导出:

  1. #ifndef JOS_INC_STDIO_H
  2. #define JOS_INC_STDIO_H
  3. #include <inc/stdarg.h>
  4. #ifndef NULL
  5. #define NULL ((void*)0)
  6. #endif /* !NULL */
  7. // kern/console.c
  8. void cputchar(int c);
  9. int getchar(void);
  10. int iscons(int fd);
  11. // lib/printfmt.c
  12. void printfmt(void (*putch)(int, void*), void* putdat, const char* fmt, ...);
  13. void vprintfmt(void (*putch)(int, void*),
  14. void* putdat,
  15. const char* fmt,
  16. va_list);
  17. int snprintf(char* str, int size, const char* fmt, ...);
  18. int vsnprintf(char* str, int size, const char* fmt, va_list);
  19. // kern/printf.c
  20. int cprintf(const char* fmt, ...);
  21. int vcprintf(const char* fmt, va_list);
  22. // lib/fprintf.c
  23. int printf(const char* fmt, ...);
  24. int fprintf(int fd, const char* fmt, ...);
  25. int vfprintf(int fd, const char* fmt, va_list);
  26. // lib/readline.c
  27. char* readline(const char* prompt);
  28. #endif /* !JOS_INC_STDIO_H */

🌮No2

解释 console.c 中的一段代码

  1. if (crt_pos >= CRT_SIZE) {
  2. int i;
  3. memmove(crt_buf, crt_buf + CRT_COLS, (CRT_SIZE - CRT_COLS) * sizeof(uint16_t));
  4. for (i = CRT_SIZE - CRT_COLS; i < CRT_SIZE; i++)
  5. crt_buf[i] = 0x0700 | ' ';
  6. crt_pos -= CRT_COLS;
  7. }

首先我们先来看几个宏定义:

  1. #define CRT_ROWS 25
  2. #define CRT_COLS 80
  3. #define CRT_SIZE (CRT_ROWS * CRT_COLS)

这个宏定义规定了屏幕显示文字的行、列、总大小。
在回到需要我们解释的代码,可以发现要完成的工作是:清空已满的屏幕的最后一行,接下来我们逐行分析。

  1. // 判断屏幕是否已经充满
  2. if (crt_pos >= CRT_SIZE) {
  3. int i;
  4. // memmove 的函数原型可以参考 'https://www.runoob.com/cprogramming/c-function-memmove.html'
  5. // 这一行代码的意思是将字符向前移动一行, 空出最后一行
  6. memmove(crt_buf, crt_buf + CRT_COLS, (CRT_SIZE - CRT_COLS) * sizeof(uint16_t));
  7. // 将移动完的数组的最后一行都赋为空格
  8. for (i = CRT_SIZE - CRT_COLS; i < CRT_SIZE; i++)
  9. crt_buf[i] = 0x0700 | ' ';
  10. // 将屏幕总字符数减一行
  11. crt_pos -= CRT_COLS;
  12. }

🌯No3

使用 GDB 跟踪调试以下代码:

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

按执行顺序列出每个对 cons_putc、va_arg 和 vcprintf 的调用:

  • 对于 cons_putc,列出它的参数
  • 对于 va_arg,列出 ap 在调用前后指向的内容
  • 对于 vcprintf,列出两个参数的值

首先为了可以执行上面的代码,我们在 ./kern/init.c 的 i386_init 函数中加入上述代码,函数如下:

  1. void i386_init(void) {
  2. extern char edata[], end[];
  3. memset(edata, 0, end - edata);
  4. cons_init();
  5. cprintf("6828 decimal is %o octal!\n", 6828);
  6. test_backtrace(5);
  7. cprintf("Lab1_Exercise_8:\n");
  8. int x = 1, y = 3, z = 4;
  9. cprintf("x %d, y %x, z %d\n", x, y, z);
  10. while (1)
  11. monitor(NULL);
  12. }

执行结果如下:
image.png
从反编译的 ./obj/kern/kernel.asm 中寻找到我们添加的语句,如下:

  1. cprintf("Lab1_Exercise_8:\n");
  2. f01000d4: c7 04 24 f2 18 10 f0 movl $0xf01018f2,(%esp)
  3. f01000db: e8 34 08 00 00 call f0100914 <cprintf>
  4. int x = 1, y = 3, z = 4;
  5. cprintf("x %d, y %x, z %d\n", x, y, z);
  6. f01000e0: 6a 04 push $0x4
  7. f01000e2: 6a 03 push $0x3
  8. f01000e4: 6a 01 push $0x1
  9. f01000e6: 68 04 19 10 f0 push $0xf0101904
  10. f01000eb: e8 24 08 00 00 call f0100914 <cprintf>
  11. f01000f0: 83 c4 20 add $0x20,%esp

我们用 gdb 在 0xf01000e0 处设置断点,然后单步运行:

  1. The target architecture is assumed to be i8086
  2. [f000:fff0] 0xffff0: ljmp $0xf000,$0xe05b
  3. 0x0000fff0 in ?? ()
  4. + symbol-file obj/kern/kernel
  5. (gdb) b *0xf01000e0
  6. Breakpoint 1 at 0xf01000e0: file kern/init.c, line 40.
  7. (gdb) c
  8. Continuing.
  9. The target architecture is assumed to be i386
  10. => 0xf01000e0 <i386_init+76>: push $0x4
  11. Breakpoint 1, i386_init () at kern/init.c:40
  12. 40 cprintf("x %d, y %x, z %d\n", x, y, z);
  13. # all orders are si, ignore
  14. (gdb) x/s 0xf0101904
  15. 0xf0101904: "x %d, y %x, z %d\n"
  16. # all orders are si, ignore
  17. (gdb) si
  18. => 0xf01008ee <vcprintf>: push %ebp
  19. vcprintf (fmt=0xf0101904 "x %d, y %x, z %d\n", ap=0xf010ffd4 "\001")
  20. at kern/printf.c:13
  21. 13 int vcprintf(const char* fmt, va_list ap) {
  22. (gdb) x/16b 0xf010ffd4
  23. 0xf010ffd4: 0x01 0x00 0x00 0x00 0x03 0x00 0x00 0x00
  24. 0xf010ffdc: 0x04 0x00 0x00 0x00 0xf2 0x18 0x10 0xf0

如上面的 gdb 调试过程所示,在 cprintf 中 fmt 和 ap 分别为 0xf0101904 和 0xf010ffd4,分别查看这两处内存的地址,我们可以发现:

  • fmt 是字符串 “x %d, y %x, z %d\n”
  • ap 附近存放了传入的 1、3、4 三个数字

通过对汇编代码的分析,我们也可以发现,其实在调用 cprintf 时,我们就将传入的参数入栈了,ap 只是将此参数的其实地址进行了运算入栈。具体代码如下(比较简单,不做进一步分析):

  1. int cprintf(const char* fmt, ...) {
  2. f0100914: 55 push %ebp
  3. f0100915: 89 e5 mov %esp,%ebp
  4. f0100917: 83 ec 10 sub $0x10,%esp
  5. va_list ap;
  6. int cnt;
  7. va_start(ap, fmt);
  8. f010091a: 8d 45 0c lea 0xc(%ebp),%eax
  9. cnt = vcprintf(fmt, ap);
  10. f010091d: 50 push %eax
  11. f010091e: ff 75 08 pushl 0x8(%ebp)
  12. f0100921: e8 c8 ff ff ff call f01008ee <vcprintf>
  13. va_end(ap);
  14. return cnt;
  15. }

对于 cons_putc 函数来说,它的参数分别是:120、32、49、44、32、121、32、51、44、32、122、32、52、10;分别对应的 ASCII 编码:x、\b、1、,、\b、y、\b、3、,、\b、z、\b、4、\n。

🥫No4

运行以下代码:

  1. unsigned int i = 0x00646c72;
  2. cprintf("H%x Wo%s", 57616, &i);

输出是什么? 输出的结果取决于 CPU 使用大端模式还是小端模式;如果 x86 是大端模式的,那么为了得到相同的输出,您会将 i 设置为什么?是否需要将57616更改为其他值?

结果将会输出 Hello World(He110 World):

  • 先看 57616 这个数字,其 16 进制数是 0xe110,所以以 16 进制位输出为“e110”
  • 因为 intel 采用小端方式存储,即低位字节排放在内存的低地址端,所以 0x00646c72 在内存中被存储为:0x72、0x6c、0x64、0x00 对应的 ASCII 为“rld\0”

    🍘No5

    在下面的代码中,“y=”之后将打印什么(不唯一)?为什么会发生这种情况?

  1. cprintf("x=%d y=%d", 3);

在我的程序中其打印了:y=-267321364。
因为压栈的时候,只向栈帧中压入了 3。真正打印的时候程序会先读出 3,再出栈,再次读取的时候会读出 3 内存地址的“+1”即物理地址加 4 的位置的值并以整型打印。

🍙No6

略,不是很好说明和验证

🥗The Stack

本实验的最后一部分,我们将更深入的探讨 C 语言在 x86 处理器上使用堆栈的方式;在此过程中将会编写一个有用的内核监视函数,该函数将打印堆栈的回溯:即打印每一条 call 命令的地址。
x86 的 %esp 寄存器指向当前正在使用的堆栈上的最低位置:

  • 在为堆栈保留的区域中,该位置下方的所有内容都是空闲的
  • 将值保存到堆栈上需要减小堆栈指针,然后将值写入堆栈指针所指向的位置
  • 从堆栈弹出一个值涉及读取堆栈指针指向的值,然后增加堆栈指针的值
  • 在 32 位模式下,堆栈只能容纳 32 位值,所以 %esp 总是可以被 4 整除

相反,%ebp 寄存器主要通过软件约定与堆栈相关联:

  • 在进入 C 函数时,函数的序言代码通常通过将前一个函数的基指针推到堆栈上来保存它
  • 然后在函数期间将当前 %esp 值复制到 %ebp 中

本节的其他内容请参考 No9-No12,主要是一步步引导实现堆栈跟踪函数。

🎃Exercise

🍖No1

🍗No2

使用 gdb 的 si 命令获取更多的 BIOS 代码,猜猜他们是干什么的

  1. (gdb) si
  2. [f000:e05b] 0xfe05b: cmpw $0xffc8,%cs:(%esi) # 比较大小,改变PSW
  3. 0x0000e05b in ?? ()
  4. (gdb) si
  5. [f000:e062] 0xfe062: jne 0xd241d416 # 不相等则跳转
  6. 0x0000e062 in ?? ()
  7. (gdb) si
  8. [f000:e066] 0xfe066: xor %edx,%edx # 清零edx
  9. 0x0000e066 in ?? ()
  10. (gdb) si
  11. [f000:e068] 0xfe068: mov %edx,%ss
  12. 0x0000e068 in ?? ()
  13. (gdb) si
  14. [f000:e06a] 0xfe06a: mov $0x7000,%sp
  15. 0x0000e06a in ?? ()

🥩No3-No6

这一部分练习旨在:

  • 引导查看 ./boot/boot.s 和 ./boot/main.c 中的内容
  • 学习 GDB 调式的相关内容

我们将在 The Boot Loader 中详细的分析,这里就不在赘述了。

🍠No7

使用 qemu 和 gdb 跟踪到 JOS 内核并在 movl %eax, %cr0 处停止。检查 0x00100000 和 0xf0100000 处的内存;使用 stepi gdb 命令在该指令上单步执行,再次检查 0x00100000 和 0xf0100000 处的内存,说一说刚才发生了什么。 建立新映射后,如果映射发生错误,将无法正常工作的第一条指令是什么?注释掉 kern/entry.s 中的movl %eax, %cr0,并跟踪它看看是否正确。

  1. (gdb) b *0x100020
  2. Breakpoint 1 at 0x100020
  3. (gdb) c
  4. Continuing.
  5. => 0x100020: or $0x80010001,%eax
  6. Breakpoint 1, 0x00100020 in ?? ()
  7. (gdb) si
  8. => 0x100025: mov %eax,%cr0
  9. 0x00100025 in ?? ()
  10. (gdb) x/8x 0x100000
  11. 0x100000: 0x1badb002 0x00000000 0xe4524ffe 0x7205c766
  12. 0x100010: 0x34000004 0x0000b812 0x220f0011 0xc0200fd8
  13. (gdb) x/8x 0xf0100000
  14. 0xf0100000 <_start+4026531828>: 0x00000000 0x00000000 0x00000000 0x00000000
  15. 0xf0100010 <entry+4>: 0x00000000 0x00000000 0x00000000 0x00000000
  16. (gdb) si
  17. => 0x100028: mov $0xf010002f,%eax
  18. 0x00100028 in ?? ()
  19. (gdb) x/8x 0x100000
  20. 0x100000: 0x1badb002 0x00000000 0xe4524ffe 0x7205c766
  21. 0x100010: 0x34000004 0x0000b812 0x220f0011 0xc0200fd8
  22. (gdb) x/8x 0xf0100000
  23. 0xf0100000 <_start+4026531828>: 0x1badb002 0x00000000 0xe4524ffe 0x7205c766
  24. 0xf0100010 <entry+4>: 0x34000004 0x0000b812 0x220f0011 0xc0200fd8

可以发现,在 movl %eax, %cr0 执行后,两者内容完全一致,虚拟地址 0xf0100000 已经被映射到 0x00100000 处了。
我们先看一段汇编代码:

  1. f0100020: 0d 01 00 01 80 or $0x80010001,%eax
  2. # movl %eax, %cr0
  3. mov $relocated, %eax
  4. f0100025: b8 2c 00 10 f0 mov $0xf010002c,%eax
  5. jmp *%eax
  6. f010002a: ff e0 jmp *%eax
  7. f010002c <relocated>:
  8. relocated:
  9. movl $0x0,%ebp # nuke frame pointer
  10. f010002c: bd 00 00 00 00 mov $0x0,%ebp

可以发现:

  1. movl %eax, %cr0 被注释掉了
  2. 将 relocated 过程的地址赋给 %eax
  3. 跳转到 %eax 中对应的地址
  4. 执行 mov $0x0, %ebp 指令

执行 mov $0x0, %ebp 指令在执行时会出错,因为此时程序强制跳转到了 0xf010002c 此时没有启动虚拟地址映射,所以 0xf010002c 处没有指令,所以执行报错。

🥟No8

我们省略了一小部分使用格式为“%o”的模式打印八进制数所需的代码。查找并填写此代码片段。

  1. case 'o':
  2. // Replace this with your code.
  3. putch('0', putdat);
  4. num = getuint(&ap, lflag);
  5. base = 8;
  6. goto number;

修改为如上代码即可,记得要输出前导的 0。重新编译运行,即 make && make qemu,我们将会看到原先的启动页面中的:

  1. 6828 decimal is XXX octal!

已经变成了:

  1. 6828 decimal is 015254 octal!

🥠No9

确定内核初始化堆栈的位置,以及堆栈在内存中的确切位置。内核如何为其堆栈保留空间?堆栈指针初始化指向这个保留区域的哪一端?

首先我们分析一段汇编代码,位于开启虚拟内存部分之后:

  1. // 已经开启了虚拟内存机制, 我们现在跳转到栈帧的设置代码中
  2. mov $relocated, %eax
  3. jmp *%eax
  4. relocated:
  5. // 清除帧指针寄存器 (EBP)
  6. // 这样一旦我们开始调试 C 代码, 堆栈回溯将被正确终止.
  7. movl $0x0,%ebp # nuke frame pointer
  8. // 设置栈顶指针
  9. movl $(bootstacktop),%esp
  10. // 执行 C 代码
  11. call i386_init
  12. # Should never get here, but in case we do, just spin.
  13. spin: jmp spin
  14. .data
  15. /******************************************************************
  16. * 内核栈
  17. *******************************************************************/
  18. .p2align PGSHIFT
  19. .globl bootstack
  20. bootstack:
  21. .space KSTKSIZE
  22. .globl bootstacktop
  23. bootstacktop:

可以看到上面的代码初始化了栈帧,我们回答了第一个问题。
接着我们查看反编译出的代码,可以了解到 %esp 的位置:

  1. // Set the stack pointer
  2. movl $(bootstacktop),%esp
  3. f0100034: bc 00 00 11 f0 mov $0xf0110000,%esp

可以看到 %esp 被设置为:0xf0110000,同时为进行编译的汇编代码中使用了 .space 指令留出了大小为 KSTKSIZE 的空间,我们查看 KSTKSIZE 的宏定义:

  1. // File: ./inc/memlayout.h
  2. // Kernel stack.
  3. #define KSTACKTOP KERNBASE
  4. #define KSTKSIZE (8 * PGSIZE) // size of a kernel stack
  5. #define KSTKGAP (8 * PGSIZE) // size of a kernel stack guard
  6. // File: ./inc/mmu.h
  7. #define PGSIZE 4096 // bytes mapped by a page
  8. #define PGSHIFT 12 // log2(PGSIZE)

可以看到 KSTKSIZE 的值为 8*4096 字节,即 32KB,所以栈的范围是:0xf0108000-0xf0110000,栈顶是 0xf0110000。

🥡No10

要熟悉 x86 上的 C 调用规则,请在 obj/kern/kernel.asm 中找到 test_backtrace 函数的地址,设置一个断点,并检查每次在内核启动后调用它时会发生什么。说明每次调用 test_backtrace 时在堆栈上压入多少 32 位字,分别是什么?

反汇编代码如下:

  1. // Test the stack backtrace function (lab 1 only)
  2. void test_backtrace(int x) {
  3. f0100040: 55 push %ebp
  4. f0100041: 89 e5 mov %esp,%ebp
  5. f0100043: 53 push %ebx
  6. f0100044: 83 ec 0c sub $0xc,%esp
  7. f0100047: 8b 5d 08 mov 0x8(%ebp),%ebx
  8. cprintf("entering test_backtrace %d\n", x);
  9. f010004a: 53 push %ebx
  10. f010004b: 68 20 18 10 f0 push $0xf0101820
  11. f0100050: e8 a3 08 00 00 call f01008f8 <cprintf>
  12. if (x > 0)
  13. f0100055: 83 c4 10 add $0x10,%esp
  14. f0100058: 85 db test %ebx,%ebx
  15. f010005a: 7e 11 jle f010006d <test_backtrace+0x2d>
  16. test_backtrace(x - 1);
  17. f010005c: 83 ec 0c sub $0xc,%esp
  18. f010005f: 8d 43 ff lea -0x1(%ebx),%eax
  19. f0100062: 50 push %eax
  20. f0100063: e8 d8 ff ff ff call f0100040 <test_backtrace>
  21. f0100068: 83 c4 10 add $0x10,%esp
  22. f010006b: eb 11 jmp f010007e <test_backtrace+0x3e>
  23. else
  24. mon_backtrace(0, 0, 0);
  25. f010006d: 83 ec 04 sub $0x4,%esp
  26. f0100070: 6a 00 push $0x0
  27. f0100072: 6a 00 push $0x0
  28. f0100074: 6a 00 push $0x0
  29. f0100076: e8 f3 06 00 00 call f010076e <mon_backtrace>
  30. f010007b: 83 c4 10 add $0x10,%esp
  31. cprintf("leaving test_backtrace %d\n", x);
  32. f010007e: 83 ec 08 sub $0x8,%esp
  33. f0100081: 53 push %ebx
  34. f0100082: 68 3c 18 10 f0 push $0xf010183c
  35. f0100087: e8 6c 08 00 00 call f01008f8 <cprintf>
  36. }

源代码如下:

  1. // Test the stack backtrace function (lab 1 only)
  2. void test_backtrace(int x) {
  3. cprintf("entering test_backtrace %d\n", x);
  4. if (x > 0)
  5. test_backtrace(x - 1);
  6. else
  7. mon_backtrace(0, 0, 0);
  8. cprintf("leaving test_backtrace %d\n", x);
  9. }

分析源代码我们可以看出这是有个对 test_backtrace 的递归调用,经过调试可以指导传入的初值为 5,所以每次调用压入一个 32-bit 数,依次为 5、4、3、2、1、0。

🍱No11

实现 mon_backtrace 函数

本题要求对栈帧的结构比较熟悉,根据我们上面的分析每个函数调用时,栈帧结构如下:

  1. +------------------+
  2. | args |
  3. \/\/\/\/\/\/\/\/\/\/
  4. /\/\/\/\/\/\/\/\/\/\
  5. | ret address |
  6. +------------------+
  7. | ret address |
  8. +------------------+
  9. | Last %ebp | <--- %ebp
  10. +------------------+

所以当前 %ebp 所指的为上一个函数的 %ebp 的值,再往前 4 个字节是返回值的地址,余下的为参数。所以自然而然编写代码如下:

  1. int mon_backtrace(int argc, char** argv, struct Trapframe* tf) {
  2. uint32_t ebp, *p;
  3. // get %ebp
  4. ebp = read_ebp();
  5. cprintf("Stack backtrace:\n");
  6. while (ebp != 0) {
  7. p = (uint32_t*)ebp;
  8. cprintf(" ebp %x eip %x args %08x %08x %08x %08x %08x\n", ebp, p[1], p[2],
  9. p[3], p[4], p[5], p[6]);
  10. ebp = p[0];
  11. }
  12. return 0;
  13. }

得到下面的运行结果:

  1. entering test_backtrace 5
  2. entering test_backtrace 4
  3. entering test_backtrace 3
  4. entering test_backtrace 2
  5. entering test_backtrace 1
  6. entering test_backtrace 0
  7. Stack backtrace:
  8. ebp f010ff18 eip f010007b args 00000000 00000000 00000000 00000000 f01008fd
  9. ebp f010ff38 eip f0100068 args 00000000 00000001 f010ff78 00000000 f01008fd
  10. ebp f010ff58 eip f0100068 args 00000001 00000002 f010ff98 00000000 f01008fd
  11. ebp f010ff78 eip f0100068 args 00000002 00000003 f010ffb8 00000000 f01008fd
  12. ebp f010ff98 eip f0100068 args 00000003 00000004 00000000 00000000 00000000
  13. ebp f010ffb8 eip f0100068 args 00000004 00000005 00000000 00010094 00010094
  14. ebp f010ffd8 eip f01000d4 args 00000005 00001aac 00000640 00000000 00000000
  15. ebp f010fff8 eip f010003e args 00111021 00000000 00000000 00000000 00000000
  16. leaving test_backtrace 0
  17. leaving test_backtrace 1
  18. leaving test_backtrace 2
  19. leaving test_backtrace 3
  20. leaving test_backtrace 4
  21. leaving test_backtrace 5
  22. Welcome to the JOS kernel monitor!

🍚No12

修改堆栈回溯功能,为每个 eip 显示与该 eip 对应的函数名称,源文件名和行号。

实验中的源文件已经为我们实现了大部分的功能,我们只要调用 ./kern/kdebug.c 中的 debuginfo_eip 函数即可获得对应的堆栈文件信息,主要的原理是利用了编译过程中的符号表,这里我没有进行深究,有兴趣的同学可以自己研究一下。
实验的注释给的很明确,我们通过注释就可以看懂完成的具体功能。
首先补充 debuginfo_eip 函数中缺少的部分,可以看到获取函数对应行数的代码是缺少的,所以需要补充这部分的代码,需要补充的部分也很简单,只需要调用 stab_binsearch 函数,传入正确的参数即可获得对应的信息,首先我们从 ./inc/stab.h 中获得对应的获取行数的类型的宏定义,然后模仿上下的其他部分的代码模仿补充即可:

  1. // ./inc/stab.h
  2. #define N_SLINE 0x44 // text segment line number
  3. // ./kern/kdebug.c
  4. int debuginfo_eip(uintptr_t addr, struct Eipdebuginfo* info) {
  5. // other code
  6. // Search within [lline, rline] for the line number stab.
  7. // If found, set info->eip_line to the right line number.
  8. // If not found, return -1.
  9. //
  10. // Hint:
  11. // There's a particular stabs type used for line numbers.
  12. // Look at the STABS documentation and <inc/stab.h> to find
  13. // which one.
  14. // Your code here.
  15. stab_binsearch(stabs, &lline, &rline, N_SLINE, addr);
  16. if (lline <= rline) {
  17. info->eip_line = stabs[rline].n_desc;
  18. } else {
  19. panic("Can't find");
  20. }
  21. // other code
  22. return 0;
  23. }

补充上面的代码,我们即可通过 eip 获取完整的信息,接着修改 mon_backtrace 函数,按照规定的格式打印信息即可:

  1. int mon_backtrace(int argc, char** argv, struct Trapframe* tf) {
  2. uint32_t ebp, eip, *p;
  3. struct Eipdebuginfo info;
  4. // get %ebp
  5. ebp = read_ebp();
  6. cprintf("Stack backtrace:\n");
  7. while (ebp != 0) {
  8. p = (uint32_t*)ebp;
  9. eip = p[1];
  10. // print mem address
  11. cprintf(" ebp %x eip %x args %08x %08x %08x %08x %08x\n", ebp, p[1],
  12. p[2], p[3], p[4], p[5], p[6]);
  13. // get file information
  14. if (debuginfo_eip(eip, &info) == 0) {
  15. int fn_offset = eip - info.eip_fn_addr;
  16. // print file infomation
  17. cprintf(" %s:%d: %.*s+%d\n", info.eip_file, info.eip_line,
  18. info.eip_fn_namelen, info.eip_fn_name, fn_offset);
  19. }
  20. ebp = p[0];
  21. }
  22. return 0;
  23. }

接着我们向 commands 数组中添加 backtrace 命令即可:

  1. static struct Command commands[] = {
  2. {"help", "Display this list of commands", mon_help},
  3. {"kerninfo", "Display information about the kernel", mon_kerninfo},
  4. {"backtrace", "Display backtrace info", mon_backtrace},
  5. };

至此所有的工作就都已经完成了。