安装环境编译qemu

image.png

1. PC启动

打开两个窗口,在第一个窗口中 make qemu-gdb,会启动内核,但在执行第一个指令之前停下;
在第二个窗口中make gdb,实时观察第一个窗口中的执行过程。

image.png

从这里可以观察到:

  • IBM PC 在物理地址 0x000ffff0 开始执行, 位于为 ROM BIOS 保留的 64KB 区域的最顶部。
  • PC 的第一个指令执行的是 CS=0xf000 IP=0xfff0
  • 第一条指令是 jmp 指令, 跳转到分段地址 CS = 0xf000 和 IP = 0xe05b。

image.png

## 为什么第一个指令在这个位置?
这是因为 8088的BIOS 是“硬连线”的 到物理地址范围 0x000f0000-0x000fffff, 从而确保BIOS首先获得对机器的控制
0xffff0 是 BIOS 结束前的 16 个字节 (0x100000),BIOS做的第一件事是向后jmp 到 BIOS 中较早的位置;

2. bootloader

bootloader 的开始
bootsec 如果磁盘是可启动的, 第一个扇区称为 boot sector, 因为这是引导加载程序代码所在的位置。

当 BIOS 找到可启动软盘或硬盘时, 会将其加载(512字节)至物理地址的内存的0x7c00 0x7dff。然后64KB大小的BOIS的最后一句话即是:
jmp $0x0000,$0x7c00
将控制转交给了 bootloader

image.png

boot loader 的任务有两个:

  1. 将处理器从实模式切换到保护模式。因为实模式最多只能访问1MB的内存。
  2. 从硬盘读取内核,加载到内存。bootstrap使用特殊I/O指令,直接访问IDE磁盘设备存储器来读取。

boot loader 的实现:
 一个汇编语言源文件,boot/boot.S
 一个 C 源文件 boot/main.c
 反汇编文件: obj/boot/boot.asm

先看代码、然后看反汇编、再调试,摸清楚 boot loader 的流程

阅读源码

boot/boot.S的内容:

  1. 加载全局描述符表 GDT
  2. 开启保护模式:将CR0寄存器的PE_ON位置1
  3. 通过ljmp进入保护模式
  4. 加载各个段描述符
  5. 跳转至 bootmain.c

boot/bootmain.c的内容

  1. 加载kernel的elf文件头:从硬盘1号扇区(第二个扇区)的起始处读取4KB大小的内容至 0x0010_0000处,并将其视为ELF结构体
  2. 将 kernel 的各个段加载至内存

boot/boot.S

boot.S 中有一个令人迷惑的代码:

image.png

在即将跳转到C语言实现的bootmain的时候,居然将 start标号 给了esp,那么 start 代表了什么?

image.png

啊,start位于代码的一开始的地方,这里不是应该存代码吗?给了esp,后面栈不得把这下面的代码的都给覆盖了?
稍等下,栈是从高地址向低地址生长的,这里boot.S的代码在ide里看虽然写在start下面,但是在内存里是start更高的地方。从 obj/boot.asm 里来看:

image.png

start 位于 0x7C00,之后的代码位于0x7C00之上,而栈则向0x7C00下方生长

image.png

boot/main.c

boot/bootmain.c的内容

  1. 加载kernel的elf文件头:从硬盘1号扇区(第二个扇区)的起始处读取4KB大小的内容至 0x0010_0000处,并将其视为ELF结构体
  2. 将 kernel 的各个段加载至内存

image.png

其中的循环会逐个将 /obj/kern/kernl 的段加载至对应的物理地址(注意,readseg 的第一个参数是 ph->p_pa),可以通过 objdump -l kernel 查看:

image.png

最终内存视图如下:

image.png


##### 看反汇编发现了一些有趣的事情:
1. 循环中,调用函数后的递增操作,在汇编层面会在调用之前发生

![image.png](https://pic-bed-1258913394.cos.ap-nanjing.myqcloud.com/20240501213701.png)


2. 调用前,调用者负责传参,被调者负责保护现场,还原现场;返回后,调用者负责将传参占用的空间还原


关于ELF和编译链接

在开发者完成一个C语言程序程序 xxx.c ,为了让他跑起来,需要由编译器将其编译成 xxx.o 的对象文件,然后由链接器将所有已经编译的对象文件链接成 xxx 可执行文件。


3. 内核

目的:理解lab1的简易内核的工作过程

任务:阅读 /kern 下的代码。

lab1的内核功能十分简单,如上文中运行起来的那样,他的shell只提供两个功能,help和kerninfo。
内核相关的代码位于 /kern 之下。

entry.S:初始化内存映射,设置页表、栈指针
entrypgdir.c:页表设计

init.c:初始化shell,初始化终端设备、启动shell
console.h, console.c:终端功能的实现
printf.c:打印功能的实现
monitor.h, monitor.c:shell功能的实现

挺好,为了理解 lab1 的内核,接下来就沿着 entry.S 和 init.c 去分析内核。
即,分析entry.S对内存映射的处理、init.c 中终端设备的初始化shell的处理

内存映射的处理

关于内存的处理,lab1目前没有内存管理,只是用起来了虚拟内存,将4MB物理内存映射到原位和高处。即:

  • 0x00000000 至 0x00400000 的物理地址 -> 0x00000000 至 0x00400000 的虚拟地址
  • 0x00000000 至 0x00400000 的物理地址 -> 0xf0000000 至 0xf0400000 的虚拟地址
    毕竟这么大的内存已经足够映射当前内核了。

先来看看怎么映射的

entry.S:加载页表

在 boolloader 阶段,bootmain 最后通过 ((void (*)(void)) (ELFHDR->e_entry))();
将控制转交给了 /kern/entry.S,然后来看看entry.S

image.png

关于数组 entry_pgdir

entry.S 首先读取了页表 entry_pgdir,这个变量在 /kern/entrypgdir.c 中定义:

pte_t entry_pgtable[NPTENTRIES];

__attribute__((__aligned__(PGSIZE)))
pde_t entry_pgdir[NPDENTRIES] = {
	// Map VA's [0, 4MB) to PA's [0, 4MB)
	[0]
		= ((uintptr_t)entry_pgtable - KERNBASE) + PTE_P,
	// Map VA's [KERNBASE, KERNBASE+4MB) to PA's [0, 4MB)
	[KERNBASE>>PDXSHIFT]
		= ((uintptr_t)entry_pgtable - KERNBASE) + PTE_P + PTE_W
};

__attribute__((__aligned__(PGSIZE)))
pte_t entry_pgtable[NPTENTRIES] = {
	0x000000 | PTE_P | PTE_W,
	0x001000 | PTE_P | PTE_W,
	0x002000 | PTE_P | PTE_W,
	0x003000 | PTE_P | PTE_W,
	0x004000 | PTE_P | PTE_W,
	0x005000 | PTE_P | PTE_W,
	0x006000 | PTE_P | PTE_W,
	0x007000 | PTE_P | PTE_W,
	0x008000 | PTE_P | PTE_W,
	0x009000 | PTE_P | PTE_W,
	0x00a000 | PTE_P | PTE_W,
	//省略...
}

其中 [0] = ((uintptr_t)entry_pgtable - KERNBASE) + PTE_P, 实现了
0x00000000 至 0x00400000 的物理地址 -> 0x00000000 至 0x00400000 的虚拟地址
[KERNBASE>>PDXSHIFT] = ((uintptr_t)entry_pgtable - KERNBASE) + PTE_P + PTE_W 实现了
0x00000000 至 0x00400000 的物理地址 -> 0xf0000000 至 0xf0400000 的虚拟地址

关于页表的映射和计算方法,见另一个单独的笔记 "lab1 关于页表的知识"

关于宏 RELOC

从代码中可以看到,在页表加载之前,所有的符号都需要使用宏 RELOC ,其含义是将符号的地址减去 0xF000_0000,即,将虚拟地址转化为真实的物理地址。
这就说明 entry.S 被链接到了 0xF000_0000 上。
通过 objdump -h 来看也确实如此

image.png

但是对应的makefile是将其指定到 0xf000_0000 上的,可以从 /kern/kernel.ld 中找到

image.png

关于 bootstack

把目光回到 entry.S 的代码,在代码的最后通过标号 bootstack 和 bootstacktop定义了栈的位置,话说,这里究竟对应的物理地址是哪里呢?

image.png

可以看到 bootstack 紧邻 .data 段
通过 readelf -s kernel 查看

image.png

结合 objdump -h kernel

image.png

确实如此,bootstack 和 .data都位于 0xf010_8000 ,那么物理地址就是 0x0010_8000
栈顶 bootstacktop 的物理地址则是 0x0011_0000
在内存里看呢?

image.png


init.c:内核初始化

init.c 中最核心的函数是 i386_init

image.png

关于 清空BSS段

edata[]end[] 是在哪里定义的?这两个变量看起来指的是bss段的开始和结束。
这种问题当然要去看链接脚本了,查看 kern/kernel.ld

image.png

显示输出的处理

这里涉及的代码有

kern:
	console.h, console.c :涉及终端设备的初始化
	printf.c :涉及printf的实现
lib:
	printfmt.c:支撑printf的实现
	readline.c:实现从终端读取
	string.c:涉及字符串的处理,支撑printf的实现
inc:
	string.h:涉及字符串的处理,支撑printf的实现
关于 cons_init

这里主要用于初始化终端显示器的硬件设置,其中代码使用汇编,通过in out指令与设备交互,不过多深究了。
image.png

关于 printf 的实现

printf 的实现这里借大佬的说明图示意:

image.png

往控制台写字符串,本质还是往物理地址0xB8000开始的显存写数据

jos 的练习提到 printf 的实现需要补充,具体位于 /lib/printfmt.c : vprintfmt 中

image.png

image.png

shell的处理

这里涉及的代码有

kern:
	monitor.h, monitor.c :命令的解析、各种命令的实现
关于monitor的实现

先看看 monitor.h

image.png

然后看看 monitor.c

image.png

这么看,只要在 commands[] 中填充 backtrace 的数据就可以补充这个功能了。

image.png

monitor 是怎么实现的呢?,比较短,直接放代码了

void
monitor(struct Trapframe *tf)
{
	char *buf;

	cprintf("Welcome to the JOS kernel monitor!\n");
	cprintf("Type 'help' for a list of commands.\n");


	while (1) {
		buf = readline("K> ");
		if (buf != NULL)
			if (runcmd(buf, tf) < 0)
				break;
	}
}

本质就是一个循环,打印出 K> 然后接受输入,然后根据输入执行命令。看起来就像是大一C语言课设的XXX管理系统一样。
看看 runcmd 如何实现:

image.png

挺好,那么现在我们要做的就是实现 backtrace。

堆栈

涉及到的代码:

kern:
	kdebug.h、kdebug.c:涉及Eipdebuginfo和debuginfo_eip的实现
inc:
	stab.h:涉及Stab表的数据结构
	x86.h:涉及读取寄存器的内敛汇编

这里我们回归到jos的学习任务,研究关于栈帧的处理。并补充一些函数:
/kern/monitor.c:mon_backtrace
/kern/kdebug.c:debuginfo_eip、stab_binsearch

关于backtrace的实现

关于栈帧
栈帧,就是调用函数的时候,处理形参传递和实参存储的数据结构。
在调用函数时,调用者负责传递形参,被调者负责保护现场、恢复现场,最后调用者将形参释放掉。
这之中需要调用者和被调者的约定:
比如 函数列表中的参数,是从右至左的顺序入栈的之类的。

这里继续借用大佬 gatsby123 博客中的图,简单示意,不做深究

image.png

jos的练习11 让我们完成 mon_backtrace,希望我们将每个栈帧按照这样的格式输出:

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

不过好在 jos 已经实现了一些函数,供我们调用了,位于 /inc/x86.h
这里提供了一些内联汇编,用于读取各种寄存器的值

image.png

完成这一步也是很简单啦

image.png

但是 jos 的练习12上了强度,让我们打印出这样的效果:

image.png

就是在上面的基础上,显示当前栈帧所在的文件和,以及调用在文件的所在函数的第几行发生。
为了实现这一功能,jos 在kern/kdebug.h 和 kern/kdebug.c 中提供了支持:

image.png

可以看到 Eipdebuginfo 用于存储当前eip的相关信息。这种功能的背后当然需要编译器的支持,为了方便debug,编译器可以通过stab将这些信息保存下来,

关于stab

按照 exercise12 的提示,通过 kernel.ld 可以看到.stab和 .stabstr 的相关连接选项

image.png

可以看到其中定义了 __STAB_BEGIN__ __STAB__END__ __STABSTR_BEGIN__ __STABSTR_END__

通过 objdump -h obj/kern/kernel 可以看到 stab 表

image.png

通过 objdump -G obj/kern/kernel 可以看到stab的内容

image.png

其中包含1213项,每项包括

symnum:序号
n_type:类型
n_othor:杂项信息
n_desc:描述信息
n_value:表示地址。特别要注意的是,这里只有FUN类型的符号的地址是绝对地址,SLINE符号的地址是偏移量,
n_strx:stabstr表中对应的字符串的序号
string:stabstr表中对应的字符串

在 stab.h中有对应的数据结构:

mit6.828 – lab1笔记-小白菜博客
那么这些信息要怎么使用呢,看看kdebug.c


stab_binsearch(stabs, region_left, region_right, type, addr)

某些符号表项类型按指令地址递增顺序排列。 例如,标记函数的 N_FUN 符号表项(n_type == N_FUN 的符号表项)和标记源文件的 N_SO 符号表项。

给定指令地址后,该函数会查找包含该地址的 "type "类型的符号表项。

搜索范围为[*region_left, *region_right]。

因此,要搜索一整套 N 个符号表项,可以执行以下操作

// left = 0;
// right = N - 1; /* 最右边的符号表项 */
// stab_binsearch(stabs, &left, &right, type, addr);

搜索会修改 *region_left 和 *region_right 以括住 "addr"。 *region_left 指向包含'addr'的匹配符号表项,*region_right 指向下一个符号表项之前。 如果 *region_left > *region_right,则表示 "addr "不包含在任何匹配的符号表项中。

// 例如,给定这些 N_SO 符号表项:
// 索引类型 地址
// 0 SO f0100000
// 13 SO f0100040
// 117 SO f0100176
// 118 SO f0100178
// 555 SO f0100652
// 556 SO f0100654
// 657 SO f0100849
// 此代码:
// left = 0, right = 657;
// stab_binsearch(stabs, &left, &right, N_SO, 0xf0100184);
// 将退出设置 left = 118, right = 554.

这里给出了 stab_binsearch 的使用说明,从函数名可以看出来他是使用二分查找算法从stab中查找addr指定的type类型的符号,然后通过left返回出来。来简单看看代码:

image.png

然后来看看要处理的 debuginfo_eip

image.png

image.png

到现在为止,已经找到了所在文件名、所在函数名、所在函数地址、所在函数名长度、相对函数的偏移
就差所在行号了,找行号的代码很好写啊,照着写就行了,这个函数调用,将范围改一下,然后类型改成代码段的行就行了,因为eip只会在代码段里移动。

stab_binsearch(stabs, &lline, &rline, N_SLINE, addr);

但是,行号究竟是stab中的哪个成员提供的啊?
image.png

观察一波 objdump -G 的输出

image.png

目测 n_value对应的是SLINE的内存地址,而n_desc看着更像行号一些,于是:

image.png

补充一下 monitor.c

image.png

编译测试一下:

image.png

看着好像成功了,试试评分

image.png

收工。