使用虚拟内存
为什么要使用虚拟内存?可以参考这篇博客:
简单的说可以总结为:
- 使比实际物理内存更大的程序能够运行
- 使每个进程拥有独立的虚拟地址空间,相互之间不能修改数据
- 访问虚拟地址时,如果虚拟地址所对应的物理地址不在物理内存中,则产生缺页中断,此时才真正分配物理地址,同时更新进程的页表
虚拟地址如何寻址?可以参考我之前博客中的保护模式寻址:
操作系统内核通常喜欢将其链接地址设置在非常高的虚拟地址处(关于链接地址, 中的“MIT-JOS系列2:bool loader过程”一节),以便将留下虚拟地址的低地址部分给用户程序使用。例如MIT-JOS的试验中就将内核载入到0xf0100000处运行。
但是许多机器在0xf0100000没有物理内存,所以我们必须建立映射表,利用处理器的内存管理硬件完成虚拟地址到物理地址的映射。在boot loader结束、kernel载入完成刚开始执行的时候,此时并没有完成页表目录和页表的建立,也没有开启分页选项 CR0_PG
,因此在kernel执行的一开始 kern/entry.S
中,我们首先使用kern / entrypgdir.c
中的 entry_pgtable
对前4MB的物理内存(0xf0000000 ~ 0xf0400000)的页表目录和页表进行静态初始化 ,然后设置 CR0_PG
标志
- 在设置
CR0_PG
标志之前,即在boot loader过程中,我们使用的都是物理地址(严格来说是线性地址,但由于boot.S
中没有开启分页选项,此时线性地址等同于物理地址) - 一旦设置
CR0_PG
标志,对内存的引用由虚拟地址通过虚拟内存硬件转换为物理地址,对虚拟地址0xf0000000 ~ 0xf0400000和0x00000000 ~ 0x00400000的访问被映射到物理地址0x00000000 ~ 0x00400000。若访问的虚拟地址不在这两个范围中将导致硬件异常, 由于此时还未设置中断处理,程序将异常退出(或进入spin
节无限循环)
此处通过MIT-JOS lab1的 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
inkern/entry.S
, trace into it, and see if you were right.
首先在 movl %eax, %cr0
处设置断点,使程序执行到这个位置,分别查看 0x00100000 和 0xf0100000 处内存的值:
(gdb) b *0x100025Breakpoint 1 at 0x100025(gdb) cContinuing.The target architecture is assumed to be i386=> 0x100025: mov %eax,%cr0Breakpoint 1, 0x00100025 in ?? ()(gdb) x/8x 0x001000000x100000: 0x1badb002 0x00000000 0xe4524ffe 0x7205c7660x100010: 0x34000004 0x2000b812 0x220f0011 0xc0200fd8(gdb) x/8x 0xf01000000xf0100000 <_start+4026531828>: 0x00000000 0x00000000 0x00000000 0x000000000xf0100010: 0x00000000 0x00000000 0x00000000 0x00000000
由于还未设置 CR0_PG ,因此此时内存 0xf0100000 处并没有值,内核被加载到 0x100000 处,此处有值。
继续向下执行,再次打印两者的值:
(gdb) si=> 0x100028: mov $0xf010002f,%eax0x00100028 in ?? ()(gdb) x/8x 0x001000000x100000: 0x1badb002 0x00000000 0xe4524ffe 0x7205c7660x100010: 0x34000004 0x2000b812 0x220f0011 0xc0200fd8(gdb) x/8x 0xf01000000xf0100000 <_start+4026531828>: 0x1badb002 0x00000000 0xe4524ffe 0x7205c7660xf0100010: 0x34000004 0x2000b812 0x220f0011 0xc0200fd8
在这里就能发现设置了 CR0_PG 后 0x00100000 与 0xf0100000 内存段的值相同,因为映射已经被建立,访问 0xf0100000 与访问 0x00100000 等同。
如果注解掉 movl %eax, %cr0
,再次编译执行,将会得到错误:
qemu-system-i386: Trying to execute code outside RAM or ROM at 0xf010002cThis usually means one of the following happened:(1) You told QEMU to execute a kernel for the wrong machine type, and it crashed on startup (eg trying to run a raspberry pi kernel on a versatilepb QEMU machine)(2) You didn't give QEMU a kernel or BIOS filename at all, and QEMU executed a ROM full of no-op instructions until it fell off the end(3) Your guest kernel has a bug and crashed by jumping off into nowhere
因为后续执行内核时企图访问内存 0xf0100000 开始的空间,但由于分页模式并未开启,将直接访问地址为 0xf0100000 的物理空间,而这段地址的值为0,不存在内核,因此执行出错
总的来说,kern/entry.S
做的事儿如下:
- 载入简易页表到
cr3
,映射虚拟地址0xf0000000-0xf0400000和0x00000000-0x00400000到物理地址0x00000000-0x00400000 - 修改
cr0
开启分页模式 - 初始化内核栈
- 跳转进入c函数
i386_init
进行其他初始化
Q:载入简易页表并开启分页模式后,寻址的过程发生了什么变化?为什么GDTR中一开始存放的物理地址在这个过程中没有改变,却还是能找到正确的GDT表进行地址变换?
A:之前在boot
阶段开启了保护模式并载入GDT表到GDTR
。由于载入是在保护模式开启之前,即仍处于实地址模式,因此载入到GDTR
中的地址是GDT表的物理地址,保护模式开启后被视为虚拟地址,但此时还没开启分页模式,因此虚拟地址等同于物理地址。在kern/entry.S
中开启分页模式后,对所有地址都认为是虚拟地址,并通过页表转换得到真实的物理地址。简易页表同时建立了虚拟内存0x00000000-0x00400000到物理地址0x00000000-0x00400000的映射,因此GDTR
中虽然存放的是物理地址一直没有变过,但在分段、分页后仍然可以作为虚拟地址找到正确的物理地址
栈
在内核进行虚拟内存映射和设置分页后,它初始化自己的栈空间,初始化方法如下:
relocated: # Clear the frame pointer register (EBP) # so that once we get into debugging C code, # stack backtraces will be terminated properly. movl $0x0,%ebp # nuke frame pointer # Set the stack pointer movl $(bootstacktop),%esp # now to C code call i386_init # Should never get here, but in case we do, just spin.spin: jmp spin.data#################################################################### boot stack################################################################### .p2align PGSHIFT # force page alignment .globl bootstackbootstack: .space KSTKSIZE .globl bootstacktop bootstacktop:
这里定义了全局变量 bootstack
作为临时堆栈,它有KSTKSIZE个字节(定义为32K)的区域作为栈空间; bootstacktop
指向栈空间后的第一个字节,由于初始栈为空,因此栈顶为 bootstacktop
指向的位置。栈由高地址向低地址生长,栈底地址最高,栈顶地址最低
栈的功能和使用
在C函数调用时,通过栈保存和恢复现场
栈相关的关键寄存器:
- esp:栈指针寄存器(extended stack pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的栈顶。push、pop指令会自动调整esp的值
- ebp:基址指针寄存器(extended base pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的底部
- eip:存储当前执行指令的下一条指令在内存中的偏移地址
C函数调用过程:
进入函数之前
- 将需要的参数压栈
- eip压栈(下一条指令在内存中的位置)
进入调用函数(函数内代码执行之前):
- ebp压栈
- 当前esp赋值给ebp(此时当前ebp指向刚刚压栈的ebp)
此时栈内空间示意如下图:
利用栈和STAB打印递归函数过程中的信息
STAB
首先先了解一下STAB
调试信息的传统格式被称为 STAB(符号表)。STAB 信息保存在 ELF 文件的 .stab
和 .stabstr
部分。
- .stab节:符号表部分,这一部分的功能是程序报错时可以提供错误信息,具体的在往后的博客中介绍
- .stabstr节:符号表字符串部分,具体的也会在往后的博客介绍
通过指令 objdump -G obj/kern/kernel
可以看到kernel的.stab节的内容,例如:
qxy@qxy-XPS-13-9360:~/1work/MIT-JOS/lab$ objdump -G obj/kern/kernelobj/kern/kernel: 文件格式 elf32-i386.stab 节的内容:Symnum n_type n_othr n_desc n_value n_strx String-1 HdrSym 0 1299 0000197d 1 0 SO 0 0 f0100000 1 {standard input}1 SOL 0 0 f010000c 18 kern/entry.S2 SLINE 0 44 f010000c 0 3 SLINE 0 57 f0100015 0 4 SLINE 0 58 f010001a 0 5 SLINE 0 60 f010001d 0 ......
MIT-JOS Exercise 12:
mon_backtrace
的代码如下:
intmon_backtrace(int argc, char **argv, struct Trapframe *tf){ // Your code here. uint32_t *ebp; // read得到的是ebp寄存器的值,这个值应该作为地址使用,因此进行转换 ebp = (uint32_t *)read_ebp(); cprintf("Stack backtrace:\n"); struct Eipdebuginfo info; // when there is nothing in stack, the first value of ebp is 0 while (ebp != 0) { // 第一行的当前ebp和栈环境是mon_backtrace的 cprintf("ebp %8x eip %8x args %08x %08x %08x %08x %08x ", ebp, ebp[1], ebp[2], ebp[3], ebp[4], ebp[5], ebp[6]); debuginfo_eip(ebp[1], &info); cprintf("%s:%d: %.*s+%d\n", info.eip_file, info.eip_line, info.eip_fn_namelen, info.eip_fn_name, ebp[1]-info.eip_fn_addr); ebp = (uint32_t *)ebp[0]; } return 0;}
debuginfo_eip
增加的的代码如下:
stab_binsearch(stabs, &lline, &rline, N_SLINE, addr);if (lline <= rline) { info->eip_line = stabs[lline].n_desc;} else { return -1;}
这里有个比较巧妙的用法:printf("%.*s", length, string);
用来打印string
字符串最多前length
个字符
running JOS: (1.1s) printf: OK backtrace count: OK backtrace arguments: OK backtrace symbols: OK backtrace lines: OK Score: 50/50
设置BSS段
进入i386_init
后,程序首先利用memset(edata, 0, end - edata)
将未初始化的BSS节的内存设置为0
edata表示bss节在内存中开始的位置,end表示内核可执行程序在内存中结束的位置。从上一篇博客对elf文件的介绍和节头表打印 objdump -h obj/kern/kernel
中可以看到,由于comment节不被载入内存,因此bss节是文件在内存中的最后一部分,因此edata和end之间的部分是bss节的长度。
其他初始化工作
在初始化bss节后,调用 cons_init
函数对系统进行一系列的初始化,包括显存初始化、键盘初始化等,此处不进行详细介绍