xv6 第一个 Homework 比较简单,主要讲怎么搭建环境,怎么 build,怎么在 qemu 模拟器上运行 xv6 的。如果没有图形界面,可以用命令 make qemu-nox-gdb
启动 qemu。
Boot
xv6 boot 涉及2个文件,bootasm.S、bootmain.c
。
机器启动后,BIOS 会将 boot loader 载入到物理地址 0x7c00 处,以实模式运行。
bootasm.S
分两个部分,实模式代码和保护模式代码
实模式
主要做3件事情
- 设置段寄存器
- 打开 A201
- 设置 GDT,并开启保护模式,然后跳转到保护模式下的代码
虽然启用了保护模式,但是段寄存器存放的还是实模式的内容,代码实际上还是按实模式运行,所以 ljmp
这条指令还能继续执行。 ljmp
指令重新载入了 %cs, %eip
,此后代码就运行在保护模式下。
xv6 并没有用到分段的很多特性,而是简单的把所有段都当成平坦模式
,代码段和数据段的范围都是 0 ~ 4GB。所以代码段以及后面的数据段不需要做特殊转换,offset 就是实际的物理地址。
保护模式
主要做如下事情
- 重新设置段寄存器
因为启用了保护模式,段寄存器就不是段地址,而应该是段选择子。代码段在前面已经重新设置了,其它段也需要重新设置。
- 设置
%esp
寄存器为0x7c00
- 跳转到
bootmain.c:bootmain()
bootmain.c
把 kernel 载入到物理地址 0x10000 处,然后开始运行 kernel。kernel 是 ELF 格式的二进制可执行程序,程序的入口地址为 0x0010000c
。
Homework
Homework 问题:程序运行 kernel 第一条指令时,栈上有哪些内容,非0的内容代表什么意思?
(gdb) b * 0x0010000c
Breakpoint 3 at 0x10000c
(gdb) c
Continuing.
=> 0x10000c: mov %cr4,%eax
Breakpoint 3, 0x0010000c in ?? ()
(gdb) x/24x $esp
0x7bbc: 0x00007dc2 0x00000000 0x00000000 0x00000000
0x7bcc: 0x00000000 0x00000000 0x00000000 0x00000000
0x7bdc: 0x00010074 0x00000000 0x00000000 0x00000000
0x7bec: 0x00000000 0x00000000 0x00000000 0x00000000
0x7bfc: 0x00007c4d
栈相关寄存器
栈相关寄存器主要有3个,%ss、%esp、%ebp
,其中 %ss
是栈段选择子,前面说到 xv6 用的是平坦模式,分段基本上没有用,而且分页还没有开启,所以逻辑地址=线性地址=物理地址。这意味着 %esp
就是栈顶的物理地址。
%esp
在 bootasm.S 中初始化为 0x7c00
,栈是向低地址方向增长的(因为0x7c00之上是 bootloader),压栈后 %esp
地址会越来越小。
栈的相关操作
栈初始化 %ss、%esp
后,调用了 call bootmain
指令跳转到 bootmain.c:bootmain()
,call
指令会将下一条指令的地址 0x00007c4d
压栈。
跳转到 bootmain() 后,会将函数将要用到的寄存器压栈,函数返回后将这些上下文相关的寄存器出栈,通过栈防止函数修改这些上下文寄存器。可以看到一共保存了3个寄存器,寄存器内容都是0。
函数也会在栈中开辟一段空间,用来存放函数内部用到的局部变量。bootmain.c 的汇编代码sub $0x2c,%esp
显示,函数开辟了 0x2c
个字节存放函数的局部变量。函数的局部变量只有 eph(0x00010074)
放到栈中,汇编代码 mov %eax,-0x1c(%ebp)
,这是第二个非0数值。
boot loader 运行结束后,调用 kernel 的入口地址,进入 kernel。entry()
对应的汇编代码是 call *0x10018
(*0x10018 的内容就是 0x0010000c
,同样需要将 call
下一条指令地址压栈,这就是栈上的第三个非0数值。
函数与栈
函数调用上下文
指令是顺序执行的,但是有很多指令会打乱这些顺序,函数调用 call
就是其中一种。 函数调用后,指令会离开原来的位置,跳转到被调用函数上,函数返回后,接着从函数调用的下一条指令开始运行。所以 call
需要将 %eip
压栈,ret
将 %eip
出栈,这样就能回到函数调用的下一条指令接着运行。
函数参数
栈除了保存函数调用的上下文(下一条指令地址),还保存函数参数。
函数参数是从右向左依次保存在栈中,bootmain.c
调用 readseg(0x10000, 0x1000, 0)
汇编代码如下
movl $0x0,0x8(%esp)
movl $0x1000,0x4(%esp)
movl $0x10000,(%esp)
call 7cec <readseg>
保存函数修改的寄存器
如果函数修改了寄存器,这些寄存器也需要压栈保存,如果不保存,将会破坏上下文环境。这也是 bootmain()
或者 readseg()
需要将一些寄存器压栈的原因。
但是这些寄存器数目是不固定的,函数仅仅依靠栈顶指针 %esp
是不能获取函数参数的。所以需要引入另一个寄存器 %ebp
来逻辑界定每个函数的栈空间。
当前函数的栈空间就可以表示为 %ebp -> %esp
,如果当前函数调用新函数,那就需要将当前函数的 %ebp
保存,并将 %esp
赋值给 %ebp
,代表新函数开始的栈。这也是函数最开始的两条指令的作用:
push %ebp
mov %esp, %ebp
%ebp
一个作用是可以界定函数栈,另一个作用是可以获取函数参数。函数调用的过程如下:
- 参数从右向左依次压栈
- 函数调用上下文压栈
push %eip
- 保存函数栈上下文
push %ebp
mov %esp %ebp
新函数栈开始生效- 保存函数会修改的寄存器
- 开辟一定字节的栈空间,存放函数的局部变量
所以 %ebp+8
获取函数的第一个参数,依次类推,可获取函数的全部参数。例如 readseg
获取参数的指令:
mov 0x8(%ebp),%ebx
mov 0x10(%ebp),%esi
add 0xc(%ebp),%ed
函数返回
当函数返回时,栈的变化正好和调用过程相反
- 回收栈空间
- 恢复函数修改的寄存器
- 恢复函数栈上下文
pop %ebp
ret
恢复%eip
恢复%esp
bootmain
中,压栈函数用到的寄存器后,通过 sub $0x2c,%esp
开辟了栈空间。在函数结束的时候,通过 add $0x2c,%esp
恢复了 %esp 的值。
如果函数结束的时候,没有恢复 %esp 的值,有时候通过 leave
指令恢复 %esp 的值,这个指令相当于
mov %ebp, %esp
pop %ebp