XV6 Homework - Stack

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件事情

  1. 设置段寄存器
  2. 打开 A201
  3. 设置 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 地址会越来越小。

栈的相关操作

stack

栈初始化 %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

func

%ebp一个作用是可以界定函数栈,另一个作用是可以获取函数参数。函数调用的过程如下:

  1. 参数从右向左依次压栈
  2. 函数调用上下文压栈 push %eip
  3. 保存函数栈上下文 push %ebp
  4. mov %esp %ebp 新函数栈开始生效
  5. 保存函数会修改的寄存器
  6. 开辟一定字节的栈空间,存放函数的局部变量

所以 %ebp+8 获取函数的第一个参数,依次类推,可获取函数的全部参数。例如 readseg 获取参数的指令:

mov    0x8(%ebp),%ebx
mov    0x10(%ebp),%esi
add    0xc(%ebp),%ed

函数返回

当函数返回时,栈的变化正好和调用过程相反

  1. 回收栈空间
  2. 恢复函数修改的寄存器
  3. 恢复函数栈上下文 pop %ebp
  4. ret 恢复 %eip

恢复%esp

bootmain 中,压栈函数用到的寄存器后,通过 sub $0x2c,%esp 开辟了栈空间。在函数结束的时候,通过 add $0x2c,%esp 恢复了 %esp 的值。

如果函数结束的时候,没有恢复 %esp 的值,有时候通过 leave 指令恢复 %esp 的值,这个指令相当于

mov %ebp, %esp
pop %ebp

Reference