简介

MIT6.828 Operating System Engineering

6.828的目标是:1. 理解操作系统设计和实现、2. 实现小型操作系统

OS的目的是:1. 支持应用程序运行、2. 对硬件进行抽象、3. 在多个应用程序之间复用硬件、4. 隔离应用程序防止bugs、5. 允许共用多个应用程序、6. 高性能

操作系统内核一般提供:进程、内存分配、file contents、目录和文件名、安全、以及其他的例如,用户,IPC,网络,计时,终端

操作系统的抽象让应用程序只看到系统调用。

我实验使用的是 ubuntu-16.04.6-desktop-i386

Lab1: Booting a PC

Introduction

这个实验分为三个部分。第一部分会专注于熟悉x86汇编语言、QEMU x86模拟器、PC的开机引导程序。第二部分检查了我们的6.828内核的引导程序,该引导程序位于lab树的boot文件夹中。最后,第三部分深入研究6.828内核的初始模板,我们把它叫做JOS,它位于kernel文件夹中。

Software Setup

1
2
3
4
5
6
mkdir ~/6.828
cd ~/6.828
sudo apt-get install git
git clone https://pdos.csail.mit.edu/6.828/2018/jos.git lab
Cloning into lab...
cd lab

然后配置一下git,生成ssh密钥

1
2
3
ssh-keygen -t rsa -C "ris.qscf@gmail.com"
Generating public/private rsa key pair.
Enter file in which to save the key (/home/lin/.ssh/id_rsa):

然后登陆github打开Account settings -> SSH Keys 点击Add SSH Key填上Title,在Key文本框里粘贴id_rsa.pub的文件内容[需要全部复制进去]。(/home/lin/.ssh/id_rsa.pub.)

1
2
3
4
git remote add origin https://github.com/risuxx/JOS.git
假如这一步出错则先输入 git remote rm origin
git branch -M main
git push -u origin main

完成配置之后成功上传到github,由于该实验需要切换分支,而将remote库与自己的库绑定后无法切换分支,所以可以先不要这么做,等最终实验完成时再进行提交。

Simulating the x86

在6.828中我们将使用 QEMU Emulator。QEMU内建监视器只提供有限的debug支持,QEMU可以作为GDB的远程调试目标,我们将在这个lab中一步步的实现一个简单的启动程序。

首先我们安装qemu

1
sudo apt-get install qemu

然后编译我们的lab1

1
2
3
cd lab
make
make qemu

image-20201117175916581

假如需要在没有虚拟VGA的情况下使用串行控制台则输入

1
make qemu-nox

如果需要退出qemu输入(注意需要在ubuntu的shell中输入)

1
Ctrl+a x
1
2
3
4
5
6
7
8
9
10
11
K> help
help - display this list of commands
kerninfo - display information about the kernel
K> kerninfo
Special kernel symbols:
entry f010000c (virt) 0010000c (phys)
etext f0101a75 (virt) 00101a75 (phys)
edata f0112300 (virt) 00112300 (phys)
end f0112960 (virt) 00112960 (phys)
Kernel executable memory footprint: 75KB
K>

现在只有两条命令:help用来查看有哪些命令、kerninfo用来展示kernel的信息。

如果将obj/kern/kernel.img的内容复制到真实的硬盘的前几个扇区中,并且在真实的PC中打开它,会看到和QEMU中相同的内容(但是不建议进行这样的操作,因为复制kernel.img进入硬盘的开头将会破坏主引导记录和第一个分区的开头,从而导致硬盘上的所有内容丢失。)

ROM BIOS

在本部分的实验中,您将使用QEMU的调试工具来研究IA-32兼容计算机的启动方式。

打开两个terminal窗口并且这两个shell都进入lab目录。在其中一个窗口中输入make qemu-gdb(or make qemu-nox-gdb)。这个操作会启动QEMU但是QEMU会在执行第一个指令之前停下来,并且等待与GDB的连接。在第二个terminal中,在同一个文件夹下需要执行make命令,然后执行make gdb。你应该会看到如下的样子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
lin@lin-virtual-machine:~/6.828/lab$ make
lin@lin-virtual-machine:~/6.828/lab$ make gdb
gdb -n -x .gdbinit
GNU gdb (Ubuntu 7.11.1-0ubuntu1~16.5) 7.11.1
Copyright (C) 2016 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 "i686-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: 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)

我们提供了一个.gdbinit文件去设置GDB,让它在启动过程中去debug16位的代码,并且attach和监听QEMU。

下面的一行:

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

是GDB反汇编的第一个要被执行的命令。从这个输出中你可以确信一些事情:

  • IBM的PC从物理地址0x000ffff0开始执行指令,这个地址是保留给ROM BIOS的64KB区域的头部。
  • PC开始执行的时候CS = 0xf000以及IP = 0xfff0
  • 第一个要被执行的指令是一个jmp指令,将要跳转到的地址是CS = 0xf000IP = 0xe05b

为什么QEMU是像这样启动的?Intel是这样设计8088处理器的。因为在PC中BIOS是一个物理地址为0x000f000-0x000fffffhard-wired,这个设计确保了BIOS总是能在机器重启之后被控制,因为在起电后RAM中没有可以被执行的软件代码。

The PC's Physical Address Space

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

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

Exercise2

使用GDB的si指令去追踪几个BIOS执行的指令,并且尝试猜测它们在做什么。你可能想要看 Phil Storrs I/O Ports Description或者其他的资料 6.828 reference materials page。不需要弄清楚所有的细节--只需要有一个大致的了解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
[f000:fff0]    0xffff0: ljmp   $0xf000,$0xe05b
[f000:e05b] 0xfe05b: cmpl $0x0,%cs:0x6ac8 #go and check a 4-byte word at address 0xf6ac8
[f000:e062] 0xfe062: jne 0xfd2e1
[f000:e066] 0xfe066: xor %dx,%dx
[f000:e068] 0xfe068: mov %dx,%ss #set ss to zero
[f000:e06a] 0xfe06a: mov $0x7000,%esp #set %esp to 0x7000
[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
至此,通过一个长跳转进入保护模式,实模式结束。

cli屏蔽中断,cld控制字符流向,然后与IO设备交互进行一些初始化,打开A20,之后使用lidtwlgdtw加载idtr gdtr寄存器用来管理中断向量表以及进程表。之后设置cr0寄存器进入实模式,长跳转到内核。

当BIOS运行的时候,它设置了一个中断描述表以及初始化了一些设备例如VGA显示器。此时你能在QEMU窗口看到"Starting SeaBIOS"的提示。

在初始化PCI总线和所有BIOS知道的核心设备之后,它会茶轴可以启动的设备例如floppy,硬盘驱动,CD-ROM。最终当它找到可启动的硬盘之后,BIOS会从硬盘中读取boot loader,并跳转到boot loader中。

Part 2: The Boot Loader

Floppy和硬盘被划分为512b的区域,被称为sectors(扇区)。一个扇区是磁盘的最小传输单元:每一次读写操作大小必须是一个或者更多扇区那么大,并且要按照扇区的边界对齐。假如磁盘是可启动的,第一个扇区被称为启动扇区,因为boot loader的代码位于这一区域。当BIOS寻找一个可启动的floppy或者硬盘,它会将512b的启动扇区装载到内存中放到0x7c00-0x7dff上,之后使用jmp指令设置CS:IP为0000:7c00来将控制权交给boot loader。

从CD-ROM上启动的能力在PC发展到很晚的时候才出现,结果是PC结构拥有了一个能够略微重想启动过程的机会。因此现代BIOS从CD-ROM中启动由一些复杂。CD-ROMS使用2048b作为一个扇区大小而不是512b。BIOS能够在转移控制权之前,从磁盘中装载更大的boot image到内存中(而不仅仅是一个扇区)。

但是在6.828中,我们使用传统的硬盘驱动启动方式,也就意味这我们的boot loader必须小于512b。boot loader由一个汇编语言源文件boot/boot.S和一个C源文件boot/main.c组成。boot loader必须有两个主要功能。

  1. boot loader将处理器转换为32位保护模式,因为在实模式中软件只能访问1MB的物理空间。保护模式在PC Assembly Language的1.2.7和1.2.8节有简要描述。你必须理解在保护模式中段地址与物理地址的差异。
  2. boot loader使用x86的IO指令访问IDE磁盘设备寄存器,从硬盘上直接读取内核。

在你理解了boot loader的源码之后,看文件obj/boot/boot.asm。这个文件是boot loader的汇编形式。通过这个汇编文件可以更加情绪的看到所有的boot loader代码位于物理内存的哪个地方。另外obj/kern/kernel.asm包含了JOS内核的汇编代码,它在debugging的时候经常是很有用的。

Exercise3

看一下 lab tools guide,特别是GDB命令的章节。即使你很熟悉GDB,这中间也包含了一些在OS中有用但是不常见的命令。

GDB

image-20201118160738217

0x7c00处打断点,这个地址是boot sector被装载的地址。跟踪调试boot/boot.S使用obj/boot/boot.asm的代码去查看你调试到哪里了。使用x/i命令去比较反汇编的代码和原始的obj/boot/boot.asm中的代码的区别。

跟踪调试boot/main.c中的bootmain(),之后进入readsect()。辨别与readsect()中语句相关的汇编语句。之后跟踪剩下的readsect()并且回到bootmain()中,并且确定for循环的开始和结束,这个for循环会将内核剩余的部分从硬盘中读出。查看在loop循环结束之后什么代码将会执行,在那个位置打断点,继续到断点处。之后步出剩下的boot loader代码。

开启一个terminal执行make qemu-gdb然后在同一个文件夹下(lab下)执行make gdb。然后再gdb中设置断点b *0x7c00。代码的详细分析参考https://www.cnblogs.com/fatsheep9146/p/5115086.html

需要能够回答下面的问题:

  • 什么时候处理器开始执行32位代码?如何从16位跳转到的32位?

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    # 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
    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.
    ljmp $PROT_MODE_CSEG, $protcseg

    将cr0寄存器最低位设置为1就能从16位转为32位模式,之后使用长跳转。

  • boot loader执行的最后一个命令是什么?内核被装载之后的第一个命令是什么?

    1
    ((void (*)(void)) (ELFHDR->e_entry))(); // 最后一个命令,该命令用来调用内核的第一个指令

    第一条指令位于/kern/entry.S文件中,第一句 movw $0x1234, 0x472

  • 内核的第一个命令在哪?

    第一条指令位于/kern/entry.S文件中。

  • boot loader如何知道要从硬盘中读取多少扇区才能将内核从磁盘中取出?从哪里找到这个信息?

    查找Program Header Table

1
2
3
byte inb(word port); // 从I/O端口读取一个字节
// 例如下面语句从0x1F7端口读入一个数字
inb(0x1F7)

Loading the Kernel

Exercise4

关于C语言的指针编程,最好的参考是Brian Kernighan和Dennis Ritchie写的The C Programming Language(被称为“K&R”)。

从K&R中的5.1(指针和地址)读到5.5(字符指针和函数)。然后下载pointer.c,运行它,并确保您理解所有打印值的来源。特别是,确保您理解打印的第1行和第6行中的指针地址从哪里来,打印的第2行到第4行中的所有值是如何到达那里的,以及为什么在第5行中打印的值看起来是损坏的。

还有其他关于C中的指针的参考资料(例如,Ted Jensen的一个教程中大量引用了K&R),尽管不那么强烈推荐。

警告:除非你已经完全精通C语言,否则不要跳过甚至略读这篇阅读练习。如果您不能真正理解C中的指针,您将在随后的实验中遭受难以言表的痛苦和折磨,然后最终以艰难的方式理解它们。相信我们;你不会想知道“艰难之路”是什么。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// pointers.c
#include <stdio.h>
#include <stdlib.h>

void
f(void)
{
int a[4]; // 栈中的临时变量
int *b = malloc(16); // malloc会分配到heap中
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]);

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]);

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]);

c = (int *) ((char *) c + 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]);

b = (int *) a + 1;
c = (int *) ((char *) a + 1);
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二进制文件是什么。当编译和链接一个C程序(比如JOS内核)时,编译器会将每个C源代码('. c')文件转换为一个对象('.o')文件,其中包含用硬件所需的二进制格式编码的汇编语言指令。然后链接器将所有已编译的目标文件组合成一个单一的二进制镜像,比如obj/kern/kernel,在这个例子中是一个ELF格式的二进制镜像,它代表“可执行和可链接格式”。

关于这种格式的完整信息可以在我们的参考页面ELF规范中找到,但是您不需要在这个类中深入研究这种格式的细节。尽管整个格式非常强大和复杂,但大多数复杂的部分是为了支持共享库的动态加载,这在这个类中我们不会做。维基百科的页面有一个简短的描述。

在6.828中,您可以将ELF可执行文件看作是一个带有加载信息的头文件,后面是几个程序段,每个程序段是一个连续的代码块或数据,打算在指定的地址加载到内存中。引导加载程序不修改代码或数据;它将其加载到内存中并开始执行。

ELF二进制文件从一个固定长度的ELF头文件开始,然后是一个可变长度的程序头文件,其中列出了要加载的每个程序部分。这些ELF头文件的C定义在inc/ELF .h中。我们感兴趣的项目部分是:

  • .text : 可执行指令
  • .rodata: 只读数据段,例如字符串常量。(但是,我们不会费心设置硬件来禁止写入。)
  • .data : 存放已经初始化的数据,例如初始化的全局变量例如int x = 5;
  • .bss : 存放未初始化的变量, 但是在ELF中只需要记录.bss的起始地址和长度。Loader and program必须自己将.bss段清零。

通过下面的命令来检查内核可执行文件中所有部分的名称、大小和链接地址的完整列表:

1
2
objdump -h obj/kern/kernel
objdump -x obj/kern/kernel # 该命令可以用来查看kernel的文件头

BIOS从地址0x7c00开始将引导扇区加载到内存中,因此这是引导扇区的加载地址。这也是引导扇区执行的地方,所以这也是它的链接地址。我们通过将 -Ttext 0x7C00传递给boot/Makefrag中的链接器来设置链接地址,因此链接器将在生成的代码中生成正确的内存地址。

Exercise5

再次跟踪引导加载程序的前几条指令,并确定第一条指令,如果引导加载程序的链接地址错误,它将“中断”或执行错误操作。然后将boot/Makefrag中的链接地址更改为错误,运行make clean,用make重新编译实验室,并再次跟踪引导加载程序,看看会发生什么。别忘了把链接地址改回来,然后make clean!

image-20201123145615676

将0x7c00修改为0x7000,然后进行make clean

image-20201123150059759

似乎没有发生什么事情,然后试着将链接地址改为0x7cc0之后启动的提示句子反复在屏幕中出现

image-20201123150410231

回头看看内核的加载和链接地址。与引导加载程序不同,这两个地址并不相同:内核告诉引导加载程序以低地址(1兆字节)将其加载到内存中,但它期望从高地址执行。在下一节中,我们将深入研究如何使其工作。

除了片段信息之外,ELF头中还有一个对我们很重要的字段,即e_entry。这个字段保存程序入口点的链接地址:程序文本部分的内存地址,程序应该在这里开始执行。你可以看到入口点:

1
2
3
4
5
6
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

您现在应该能够理解boot/main.c中的最小ELF加载程序了。它将内核的每个部分从磁盘读入内存,读入该部分的加载地址,然后跳到内核的入口点。

Exercise6

重置机器(退出QEMU/GDB并再次启动它们)。在BIOS进入引导加载程序时,然后在引导加载程序进入内核时,检查0x00100000处的8个单词的内存。为什么会有不同?第二个断点是什么?(您实际上并不需要使用QEMU来回答这个问题。只是觉得)。

在进入boot loader的时候都是0,因为此时还是在实模式,之后进入kernel的时候已经有值了。

Part 3: The Kernel

现在,我们将开始更详细地研究最小JOS内核。(您终于可以编写一些代码了!)与引导加载程序一样,内核从一些汇编语言代码开始,这些代码进行了设置,以便后面的C语言代码能够正确执行。

使用虚拟内存来解决位置依赖 当您检查上面的引导加载程序的链接和加载地址时,它们完全匹配,但是内核的链接地址(由objdump打印)和它的加载地址之间存在(相当大的)差异。回去检查一下,确保你能明白我们在说什么。(链接内核比引导加载程序更复杂,因此链接地址和加载地址位于kern/kernel.ld的顶部。)

操作系统内核通常喜欢在非常高的虚拟地址(比如0xf0100000)上链接和运行,以便将处理器虚拟地址空间的较低部分留给用户程序使用。这样安排的原因在下一个实验室会更清楚。

许多机器在地址0xf0100000处没有任何物理内存,因此我们不能指望能够在那里存储内核。相反,我们将使用处理器的内存管理硬件来将虚拟地址0xf0100000(内核代码预期运行的链接地址)映射到物理地址0x00100000(引导加载程序将内核加载到物理内存的位置)。这种方式,虽然内核虚拟地址高的足以让很多用户进程的地址空间,它将被加载到物理内存中1 mb的电脑的内存,略高于BIOS芯片。这种方法要求个人电脑至少有几兆字节的物理内存(物理地址0 x00100000作品),但是这可能是真正的电脑大约1990之后建立的。

实际上,在下一个实验室中,我们将分别映射PC的整个底层256MB物理地址空间,从物理地址0x00000000到0x0fffffff,到虚拟地址0xf0000000到0xffffffff。现在您应该看到为什么JOS只能使用前256MB的物理内存。

现在,我们只映射前4MB的物理内存,这将足以让我们启动并运行。我们使用kern/entrypgdir.c中手工编写的静态初始化页目录和页表来完成此操作。现在,您不需要了解它的工作细节,只需要了解它所实现的效果。直到kern/条目。当设置CR0_PG标志时,内存引用被视为物理地址(严格地说,它们是线性地址,但是是引导/引导)。我们建立了一个从线性地址到物理地址的身份映射,我们永远不会改变它)。一旦设置了CR0_PG,内存引用就是虚拟地址,虚拟内存硬件会将其转换为物理地址。entry_pgdir将范围0xf0000000到0xf0400000的虚拟地址转换为物理地址0x00000000到0x00400000,以及虚拟地址0x00000000到0x00400000的物理地址0x00000000。任何不在这两个范围内的虚拟地址都将导致硬件异常,因为我们还没有设置中断处理,这将导致QEMU转储机器状态并退出(或者,如果不使用补丁版本为6.828的QEMU,则会无休止地重新启动)(这里应该就是之前练习中修改链接地址导致不断重新启动的原因,因为我没有使用补丁版本的QEMU)。

Exercise7

使用QEMU和GDB跟踪jo内核,并在movl %eax, %cr0处停止。检查0x00100000和0xf0100000处的内存。现在,使用stepi GDB命令完成该指令的单步操作。同样,检查0x00100000和0xf0100000处的内存。确保你明白刚才发生了什么。

建立新映射后的第一条指令是什么?如果映射不到位,它将不能正常工作?注释掉内核/条目中的movl %eax, %cr0。追踪它,看看你是否猜对了。

首先启动qemu和gdb然后在0x100025处下断点,该地址就是movl %eax, %cr0

image-20201123152529929

在注释movl %eax, %cr0之后执行$relocated有关的指令会出错,因为这个地址是段地址+偏移,如果没有映射那么段地址就是0xf0100008,而实际地址并没有这个地址。

Formatted Printing to the Console

大多数人认为像printf()这样的函数是理所当然的,有时甚至认为它们是C语言的“原语”。但是在操作系统内核中,我们必须自己实现所有的I/O。

通读kern/printf.c,lib/printfmt.c和kern/console.c。确保你理解他们的关系。在以后的实验中,将会清楚为什么printfmt.c位于单独的lib目录中。

Exercise8

我们省略了一小段代码——使用“%o”形式的模式打印八进制数所必需的代码。查找并填充此代码片段。

image-20201123164959689

1
2
3
4
5
6
7
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;
}

crt_pos:当前输出位置指针,指向内存区中对应输出映射地址。

CRT_SIZE:是CRT_COLS和CRT_ROWS的乘积,即2000=80*25,是不翻页时一页屏幕最大能容纳的字数

crt_buf:输出缓冲区内存映射地址

CRT_COLS:默认输出格式下整个屏幕的列数,为80

CRT_ROWS:默认输出格式下整个屏幕的行数,为25

unit16_t:typedef unsigned short 正好两字节,可以分别用来表示当前要打印的字符ASCII码和打印格式属性。

函数:

memmove(): memmove(void dst, const void src, size_t n).意为将从src指向位置起的n字节数据送到dst指向位置,可以在两个区域重叠时复制。

该功能是向下滚动一行。

The Stack

在这个实验室的最后练习中,我们将更详细地探讨C语言的方式使用x86上的堆栈,并在这一过程中写一个有用的新内核监控功能,输出一个堆栈回溯:保存指令指针(IP)的列表值从嵌套调用指令导致的当前点执行。

Exercise9

确定内核初始化其堆栈的位置,以及它的堆栈在内存中的确切位置。内核如何为它的堆栈保留空间?并且在这个保留区域的“结束”是堆栈指针初始化指向?

https://www.cnblogs.com/fatsheep9146/p/5079177.html

ebp(基指针)寄存器主要根据软件约定与堆栈相关联。在进入C函数时,函数的序言代码通常通过将前一个函数的基指针压入堆栈来保存它,然后在函数运行期间将当前的esp值复制到ebp中。如果所有的功能在程序遵守本公约,在任何给定的点在程序的执行期间,可以追溯链后通过堆栈保存ebp指针和决定什么嵌套的函数调用序列使这个程序中的特定点。此功能可能特别有用,例如,当某个特定函数由于传入了错误的参数而导致断言失败或恐慌时,但您不确定是谁传递了错误的参数。堆栈回溯让您找到出错的函数。

Exercise10

要熟悉x86上的C调用约定,可以在obj/kern/kernel.asm中找到test_backtrace函数的地址。在那里设置一个断点,并检查在内核启动后每次调用断点时会发生什么。

image-20201124184714586

https://blog.csdn.net/amgtgsh3150267/article/details/101834732

上面的练习应该为您提供实现堆栈回溯函数所需的信息,您应该调用mon_backtrace()。这个函数的原型已经在kern/monitor.c中等你了。您可以完全用C语言完成,但是您可能会发现inc/x86.h中的read_ebp()函数很有用。您还必须将这个新函数连接到内核监视器的命令列表中,以便用户可以交互地调用它。

backtrace函数应该以以下格式显示函数调用帧列表:

stack backtrace: ebp f0109e58 eip f0100a62 args 00000001 f0109e80 f0109e98 f0100ed2 00000031 ebp f0109ed8 eip f01000d6 args 00000000 00000000 f0100058 f0109f28 00000061 … 每行包含ebp、eip和args。 ebp值表示进入该函数使用的堆栈的基指针:在函数被输入和函数序言代码设置基指针后堆栈指针的位置。列出的eip值是函数的返回指令指针:当函数返回时,控件将返回的指令地址。rip通常指向call之后的一行的地址。最后,在args后面列出的5个十六进制值是所讨论的函数的前5个参数,它们将在调用函数之前被压入堆栈。当然,如果调用函数时参数少于5个,那么这5个值并不都有用。(为什么回溯代码不能检测实际有多少个参数?<这个是因为在函数内部代码中需要使用ebp的值来定位参数的位置,假如传入更少的参数会使得难以定位>这个限制是如何被修正的呢?<可以在传递参数的时候同时传递参数的个数,例如main函数的声明>)

打印的第一行反映当前执行的函数,即mon_backtrace本身,第二行反映调用mon_backtrace的函数,第三行反映调用该函数的函数,依此类推。您应该打印所有未完成的堆栈帧。通过研究kern/entry.S。你会发现有一个简单的方法告诉你什么时候停止。

以下是你在<K&R>第5章中读到的一些要点,值得在接下来的练习和未来的实验中记住。

  • 如果int * p = (int*)100,那么(int)p + 1(int)(p + 1)是不同的数字:第一个是101,第二个是104。在向指针添加整数时(如第二种情况),该整数被隐式地乘以指针指向的对象的大小。
  • p[i]被定义为与*(p+i)相同,指的是p指向的内存中的第i个对象。当对象大于一个字节时,上面的加法规则有助于这个定义。
  • &p[i](p+i)相同,产生内存中p指向的第i个对象的地址。 尽管大多数C程序从不需要在指针和整数之间进行转换,但操作系统经常需要。每当您看到一个涉及到内存地址的加法时,问问自己它是整数加法还是指针加法,并确保所添加的值是否被适当地相乘。

Exercise11

借助x86提供的read_ebp()在kern/monitor.c的mon_backtrace中打印出函数调用的栈中的ebp和eip的信息,实现前面提到的打印ebp eip args的效果

1
2
3
4
5
6
7
8
9
10
11
12
13
int
mon_backtrace(int argc, char **argv, struct Trapframe *tf)
{
// Your code here.
uint32_t *ebp = (uint32_t *)read_ebp(); // get the value of ebp reg
uint32_t eip = 0;
while(ebp){
eip = *(ebp+1); // the return addr<eip> is near the ebp
cprintf("ebp %08x eip %08x args %08x %08x %08x %08x %08x\n", ebp, eip, *(ebp+2), *(ebp+3), *(ebp+4), *(ebp+5), *(ebp+6));
ebp = (uint32_t *)*ebp;
}
return 0;
}

image-20201124204641734

此时,您的backtrace函数应该为您提供导致执行mon_backtrace()的堆栈上的函数调用者的地址。但是,在实践中,您通常希望知道与这些地址对应的函数名。例如,您可能想知道哪些函数可能包含导致内核崩溃的错误。

为了帮助您实现此功能,我们提供了函数debuginfo_eip(),它在符号表中查找eip并返回该地址的调试信息。这个函数在kern/kdebug.c中定义。

Exercise12

kern/kernel.ld中的__STAB_*

image-20201201190846006

分别指定了stab和stabstr段的起始和结束地址

image-20201201191352796

image-20201201191832700

这里解释一下上面这个表的意思:

  • Symnum是这个表的索引
  • n_type是符号的类型,SO 表示主函数的文件名,SOL 表示包含进的文件名,SLINE 表示代码段的行号,FUN 表示函数的名称
  • n_othr目前没被使用,其值固定为0
  • n_value表示的是地址。FUN类型中是绝对地址,SLINE的地址表示偏移量它的实际地址等于函数入口地址加上偏移量。

查看init.s

image-20201201192920590

查看符号表在加载内存的时候有没有加载,根据前面的查看的信息可以看到.stabstr加载到了0xf0105871于是按照如下的方式查看

image-20201201194616070

我们需要利用stab的信息,/lab/kern/kdebug.c/stab_binsearch来找到某个地址对应的行号。我们需要通过插入对stab_binsearch的调用完成/lab/kern/kdebug.c/debuginfo_eip的实现。

image-20201201201316983

然后需要给内核添加backtrace命令,并且在mon_backtrace中添加打印文件名、函数名和行号的功能。

首先在kern/monitor.c中添加命令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
mon_backtrace(int argc, char **argv, struct Trapframe *tf)
{
// Your code here.
uint32_t *ebp = (uint32_t *)read_ebp(); // get the value of ebp reg
uint32_t eip = 0;
int result;
struct Eipdebuginfo info;

cprintf("Stack backtrace:\n");

while(ebp){
eip = *(ebp+1); // the return addr<eip> is near the ebp
cprintf("ebp %08x eip %08x args %08x %08x %08x %08x %08x\n", ebp, eip, *(ebp+2), *(ebp+3), *(ebp+4), *(ebp+5), *(ebp+6));

memset(&info, 0, sizeof(struct Eipdebuginfo));
result = debuginfo_eip(eip, &info);
if(result)
{
cprintf("failed to get debuginfo for eip %x.\r\n", eip);
}
else
{
cprintf("\t%s:%d %.*s+%u\r\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;
}

实际效果:

image-20201201202638626

printf("%.*s", length, string) prints at most length characters of string. 因此可以将函数名称后面的字符省略不打印。

lab2: Memory Management

参考资料

https://zhuanlan.zhihu.com/p/74028717