- A+
安装环境编译qemu
1. PC启动
打开两个窗口,在第一个窗口中 make qemu-gdb
,会启动内核,但在执行第一个指令之前停下;
在第二个窗口中make gdb
,实时观察第一个窗口中的执行过程。
从这里可以观察到:
- IBM PC 在物理地址 0x000ffff0 开始执行, 位于为 ROM BIOS 保留的 64KB 区域的最顶部。
- PC 的第一个指令执行的是 CS=0xf000 IP=0xfff0
- 第一条指令是 jmp 指令, 跳转到分段地址 CS = 0xf000 和 IP = 0xe05b。
## 为什么第一个指令在这个位置? 这是因为 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
boot loader 的任务有两个:
- 将处理器从实模式切换到保护模式。因为实模式最多只能访问1MB的内存。
- 从硬盘读取内核,加载到内存。bootstrap使用特殊I/O指令,直接访问IDE磁盘设备存储器来读取。
boot loader 的实现:
一个汇编语言源文件,boot/boot.S
一个 C 源文件 boot/main.c
反汇编文件: obj/boot/boot.asm
先看代码、然后看反汇编、再调试,摸清楚 boot loader 的流程
阅读源码
boot/boot.S的内容:
- 加载全局描述符表 GDT
- 开启保护模式:将CR0寄存器的PE_ON位置1
- 通过ljmp进入保护模式
- 加载各个段描述符
- 跳转至 bootmain.c
boot/bootmain.c的内容
- 加载kernel的elf文件头:从硬盘1号扇区(第二个扇区)的起始处读取4KB大小的内容至 0x0010_0000处,并将其视为ELF结构体
- 将 kernel 的各个段加载至内存
boot/boot.S
boot.S 中有一个令人迷惑的代码:
在即将跳转到C语言实现的bootmain的时候,居然将 start标号 给了esp,那么 start 代表了什么?
啊,start位于代码的一开始的地方,这里不是应该存代码吗?给了esp,后面栈不得把这下面的代码的都给覆盖了?
稍等下,栈是从高地址向低地址生长的,这里boot.S的代码在ide里看虽然写在start下面,但是在内存里是start更高的地方。从 obj/boot.asm 里来看:
start 位于 0x7C00,之后的代码位于0x7C00之上,而栈则向0x7C00下方生长
boot/main.c
boot/bootmain.c的内容
- 加载kernel的elf文件头:从硬盘1号扇区(第二个扇区)的起始处读取4KB大小的内容至 0x0010_0000处,并将其视为ELF结构体
- 将 kernel 的各个段加载至内存
其中的循环会逐个将 /obj/kern/kernl 的段加载至对应的物理地址(注意,readseg 的第一个参数是 ph->p_pa
),可以通过 objdump -l kernel 查看:
最终内存视图如下:
##### 看反汇编发现了一些有趣的事情: 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
关于数组 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 来看也确实如此
但是对应的makefile是将其指定到 0xf000_0000 上的,可以从 /kern/kernel.ld 中找到
关于 bootstack
把目光回到 entry.S 的代码,在代码的最后通过标号 bootstack 和 bootstacktop定义了栈的位置,话说,这里究竟对应的物理地址是哪里呢?
可以看到 bootstack 紧邻 .data 段
通过 readelf -s kernel 查看
结合 objdump -h kernel
确实如此,bootstack 和 .data都位于 0xf010_8000 ,那么物理地址就是 0x0010_8000
栈顶 bootstacktop 的物理地址则是 0x0011_0000
在内存里看呢?
init.c:内核初始化
init.c 中最核心的函数是 i386_init
关于 清空BSS段
edata[]
和 end[]
是在哪里定义的?这两个变量看起来指的是bss段的开始和结束。
这种问题当然要去看链接脚本了,查看 kern/kernel.ld
显示输出的处理
这里涉及的代码有
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指令与设备交互,不过多深究了。
关于 printf 的实现
printf 的实现这里借大佬的说明图示意:
往控制台写字符串,本质还是往物理地址0xB8000开始的显存写数据
jos 的练习提到 printf 的实现需要补充,具体位于 /lib/printfmt.c : vprintfmt 中
shell的处理
这里涉及的代码有
kern: monitor.h, monitor.c :命令的解析、各种命令的实现
关于monitor的实现
先看看 monitor.h
然后看看 monitor.c
这么看,只要在 commands[]
中填充 backtrace 的数据就可以补充这个功能了。
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 如何实现:
挺好,那么现在我们要做的就是实现 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 博客中的图,简单示意,不做深究
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
这里提供了一些内联汇编,用于读取各种寄存器的值
完成这一步也是很简单啦
但是 jos 的练习12上了强度,让我们打印出这样的效果:
就是在上面的基础上,显示当前栈帧所在的文件和,以及调用在文件的所在函数的第几行发生。
为了实现这一功能,jos 在kern/kdebug.h 和 kern/kdebug.c 中提供了支持:
可以看到 Eipdebuginfo 用于存储当前eip的相关信息。这种功能的背后当然需要编译器的支持,为了方便debug,编译器可以通过stab将这些信息保存下来,
关于stab
按照 exercise12 的提示,通过 kernel.ld 可以看到.stab和 .stabstr 的相关连接选项
可以看到其中定义了 __STAB_BEGIN__ __STAB__END__ __STABSTR_BEGIN__ __STABSTR_END__
通过 objdump -h obj/kern/kernel 可以看到 stab 表
通过 objdump -G obj/kern/kernel 可以看到stab的内容
其中包含1213项,每项包括
symnum:序号 n_type:类型 n_othor:杂项信息 n_desc:描述信息 n_value:表示地址。特别要注意的是,这里只有FUN类型的符号的地址是绝对地址,SLINE符号的地址是偏移量, n_strx:stabstr表中对应的字符串的序号 string:stabstr表中对应的字符串
在 stab.h中有对应的数据结构:
那么这些信息要怎么使用呢,看看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返回出来。来简单看看代码:
然后来看看要处理的 debuginfo_eip
到现在为止,已经找到了所在文件名、所在函数名、所在函数地址、所在函数名长度、相对函数的偏移
就差所在行号了,找行号的代码很好写啊,照着写就行了,这个函数调用,将范围改一下,然后类型改成代码段的行就行了,因为eip只会在代码段里移动。
stab_binsearch(stabs, &lline, &rline, N_SLINE, addr);
但是,行号究竟是stab中的哪个成员提供的啊?
观察一波 objdump -G 的输出
目测 n_value对应的是SLINE的内存地址,而n_desc看着更像行号一些,于是:
补充一下 monitor.c
编译测试一下:
看着好像成功了,试试评分
收工。