V o s a m o


Lab 1:Booting a PC

介绍

本实验分成三部分:第一部分聚焦熟悉x86汇编语言、QEMU x86仿真器以及PC上电启动过程;第二部分检验我们6.828内核的boot loader,它存在于boot目录下;最后第三部分深入研究6.828内核名为JOS的初始化模板,它存在于kernel目录下。

软件安装

安装开发环境、工具链和QEMU虚拟机,不再赘述,参考Tools

使用git获取实验代码:git clone https://pdos.csail.mit.edu/6.828/2014/jos.git lab。需要使用x86或x64架构的机器。

第一部分:PC启动

了解x86汇编

练习1:阅读Brennan’s Guide to Inline Assembly的语法部分。

模拟x86

我们使用QEMU虚拟机来模拟x86。通过QEMU和GDB配合可以对PC启动进行跟踪调试。

进入实验代码目录,编译源码:

liushaolin@centos$ cd lab
liushaolin@centos$ make
+ as kern/entry.S
+ cc kern/init.c
+ cc kern/console.c
+ cc kern/monitor.c
+ cc kern/printf.c
+ cc lib/printfmt.c
+ cc lib/readline.c
+ cc lib/string.c
+ ld obj/kern/kernel
+ as boot/boot.S
+ cc -Os boot/main.c
+ ld boot/boot
boot block is 414 bytes (max 510)
+ mk obj/kern/kernel.img

如果出现“undefined reference to __udivdi3”,你可能缺少32-bit gcc multilib。

运行虚拟机:进入lab目录,在终端中输入make qemu命令。

启动虚拟机

当前的kernel监视器中仅有两个命令:helpkerninfo

K> help
help - display this list of commands
kerninfo - display information about the kernel
K> kerninfo
Special kernel symbols:
  entry  f010000c (virt)  0010000c (phys)
  etext  f0101a75 (virt)  00101a75 (phys)
  edata  f0112300 (virt)  00112300 (phys)
  end    f0112960 (virt)  00112960 (phys)
Kernel executable memory footprint: 75KB
K>

PC的物理地址空间

接下来我们深入了解一点PC是如何启动的。一台PC的物理地址空间的基本布局大致如下:

物理地址空间布局

第一代的PC是基于16位intel 8088处理器,仅能寻址1MB的物理内存。因此早期PC的物理地址空间起于0x00000000,但是止于0x000FFFFF,而不是0xFFFFFFFF。640KB的空间标记为“低地址”,是早期PC仅能使用的唯一RAM。事实上,非常早期的PC仅能分配16KB,32KB,64KB的RAM。

0x000A0000到0x000FFFFF的384KB空间被硬件保留用于诸如视频播放缓冲等特殊用途。这片保留区域最重要的部分就是Basic Input/Output System(BIOS),BIOS占据了从0x000F0000到0x000FFFFF的64KB空间。在早期PC中,BIOS被保存在真正的ROM中,但是现在计算机把BIOS保存在可更新的flash存储器中。BIOS负责执行诸如激活显卡以及检查已装入的内存总量这些基本的系统初始化。执行完初始化之后。BIOS从某个合适的地方,比如软盘/硬盘/光盘以及网络中载入操作系统并将机器的控制权转交给操作系统。

当intel打破了“1MB障碍”之后,为了后向兼容已有的软件,PC设计师保留了最低的1MB地址空间布局。因此,现代PC在物理地址0x000A0000到0x00100000有一个“洞”,把RAM分成了“低的”或“传统的内存”(前面的640KB)和“扩展内存”(所有剩下的)。另外,在32位物理地址空间最顶部,在所有物理RAM之上的空间被BIOS保留用于32位PCI设备。

现在x86处理器可以支持高达4GB的物理RAM,因此RAM可以扩展到0xFFFFFFFF。在这种情况下,BIOS必须留出第二个“洞”,也就是在32位可寻址空间的顶部,留出需要映射的32位设备的地址空间。由于设计上的限制,JOS仅使用开始的256MB物理内存。

只读存储(ROM)BIOS

在这一部分,你将使用QEMU的调试功能研究一台兼容IA-32架构的PC是如何启动的。

使用make qemu-gdbmake gdb可以联调kernel。具体方法为:打开两个终端,都进入lab目录,其中一个输入make qemu-gdb,另一个输入make gdb。这时,QEMU启动,但是暂停在处理器执行第一条指令之前等待GDB的debug指令,效果如下:

联调kernel

可以看到第一条指令是:[f000:fff0] 0xffff0: ljmp $0xf000,$0xe05b。我们可以推断:

  • IBM PC从物理地址0x000ffff0开始执行,位于64KB保留的BIOS区域的顶端。
  • CS和IP寄存器的内容分别是:CS=0xf000,IP=0xfff0
  • 第一条指令是jmp指令,调转到CS=0xf000,IP=0xe05b指向的地址

为什么QEMU如此启动呢?这是因为intel的8088处理器就是这样设计的。因为一台PC的BIOS有固定的物理地址范围:0x000f0000-0x000fffff,这样设计可以保证机器开机或重启时BIOS能够立即获取机器的控制权。一旦处理器复位,处理器就进入实模式把CS和IP寄存器的值置为0xf000:0xfff0,根据公式physical address=16*segment+offset计算出物理地址0xffff0。从该地址到BIOS的结束位置(0x100000)还剩16字节,所以我们不必惊讶BIOS要干的第一件事就是跳转到BIOS更前面的位置,毕竟仅仅16字节能干什么呢?

练习2:使用GDB的si指令单步执行几条BIOS的指令,猜测一下BIOS可能做了什么。

当BIOS运行时,它建立一张中断描述符表并初始化VGA显示器之类的各种设备。完成初始化PCI总线和所有BIOS已知的重要设备之后,它便开始搜寻可启动设备,软盘,硬盘,光盘等等,从磁盘中载入引导加载器(boot loader)并将控制权转交给它。

进入gdb后,可以使用i r指令查看各寄存器的值,通过x指令查看内存。还可以用i r ax查看指定的寄存器。gdb调试的时候,step和next指令都是不能使用的,单行执行汇编指令stepi可用。

gdb查看命令

第二部分:Boot Loader

PC的磁盘被分成一个个大小位512字节的区域,称为扇区(sector)。一个扇区是磁盘的最小转移粒度:每一次读或写操作大小必须是一个或多个扇区并且要和扇区边界对齐。如果磁盘是可引导的,那么第一个扇区成为引导扇区(boot sector),这个扇区是存放boot loader代码的地方。当BIOS找到可引导磁盘之后,它把512字节的引导扇区加载到物理地址从0x7c00到0x7dff的内存中,并使用jmp指令将CS:IP设置为0000:7c00,将控制权转交给boot loader。和BIOS的载入地址类似,这段地址是随意的——但对于PC来说是固定的和标准化的。

在计算机发展过程中,从CD-ROM引导的能力要出现的晚得多。因此,设计师有机会重新思考引导过程。现在从CD-ROM引导的BIOS有一些复杂,CD-ROM的扇区大小是2048字节,而不是512字节,BIOS可以把更大的引导镜像载入内存。

6.828中,我们使用传统的硬盘引导方式,这意味着我们的boot loader必须满足可怜的512字节。xv6的boot loader由一个汇编源文件boot/boot.S和一个C源文件boot/main.c组成。仔细地通读这些源文件,确保你明白发生了什么。boot loader必须完成两个重要功能:

  • 首先,引导加载器把处理器从实模式切换到32位保护模式,因为只有在该模式下,软件才能访问物理地址空间中所有高于1MB的内存。你只要知道在保护模式下,分段地址(段地址:偏移地址对)到物理地址的转换有所不同,偏移地址是32位,而不再是16位。
  • 第二,引导加载器直接通过特殊的x86 I/O指令访问IDE磁盘设备寄存器来读取内核。

在理解了boot loader的源代码之后,查看一下obj/boot/boot.asm文件,这个文件是GNUMakefile在编译完boot loader之后创建的可执行文件的反汇编。通过这个文件可以清楚的知道boot loader的代码在物理内存的具体位置,也更易于知道在GDB中单步执行boot loader时发生了什么。同样,obj/kern/kernel.asm 是JOS kernel的反汇编,这个文件在调试内核的时候很有帮助。

你可以在GDB中使用b命令设置地址断点。举个例子,b *0x7c00就在地址0x7c00处设置了一个断点。在断点处,你可以使用csi命令继续执行:c命令使QEMU继续运行直到遇到下一个断点;si N可以一次跳过N条指令。

使用x/i命令可以查看内存中的指令。语法格式为:*x/N ADDR*,表示查看从地址ADDR开始的N条指令。

练习3:阅读实验工具指导,尤其是GDB部分。

在0x7c00处设置断点。继续执行,然后单步调试,和obj/boot/boot.asm反汇编进行对照。

跳到bootmain()中的readsect()函数,跟踪这个函数,跳回bootmain()函数,确定从磁盘读取剩余内核扇区的for循环的头和尾。找出for循环结束之后将要执行的代码,在此处设置断点,然后单步执行完剩余的boot loader代码。

思考下列问题:

1.在什么地方,处理器开始执行32位代码?是什么导致了从16位模式到32位模式的转变?

我们知道,当CPU一开始上电复位之后,是在实模式运行。打开lab1/boot/boot.S,其中有一行ljmp $PROT_MODE_CSEG, $protcseg,从实模式跳到保护模式的时候开始执行32位代码。

2.Bootloader执行的最后一条指令是什么,kernel加载的第一条指令是什么?

打开lab1/boot/main.c,找到bootmain()函数中的最后一条语句为((void (*)(void)) (ELFHDR->e_entry))();,ELFHDR是指向0x10000(被强制转换为struct Elf类型)的指针,通过readseg函数初始化ELFHDR,这个初始化的数据来源就是硬盘上的内核镜像。怎么看ELFHDR->e_entry指向的位置呢?反汇编kernel镜像!

反汇编kernel的命令:objdump -x ./obj/kern/kernel

[liushaolin@localhost kern]$ objdump -x kernel

kernel:     文件格式 elf32-i386
kernel
体系结构:i386,标志 0x00000112:
EXEC_P, HAS_SYMS, D_PAGED
起始地址 0x0010000c

可以看出kernel的起始地址是0x0010000c,GDB设置断点调试,可以看到kernel的第一条指令就是:movw $0x1234, 0x472。在kern/entry.S中可以找到这条语句:

.globl entry
entry:
        movw $0x1234,0x472    # warm boot

3.Bootloader是怎么确定要读取多少扇区以保证可以从磁盘获取整个内核的?它是在哪里找到该信息的呢?

Bootloader是通过ELF文件存储的信息确定并读取的。具体见后面的加载内核部分。

下面是obj/kern/kernel的ELF头:

[liushaolin@localhost kern]$ objdump -h kernel

kernel:     文件格式 elf32-i386

节:
Idx Name          Size      VMA       LMA       File off  Algn
  0 .text         00001907  f0100000  00100000  00001000  2**4
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .rodata       00000730  f0101920  00101920  00002920  2**5
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  2 .stab         00003871  f0102050  00102050  00003050  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  3 .stabstr      000018ba  f01058c1  001058c1  000068c1  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  4 .data         0000a300  f0108000  00108000  00009000  2**12
                  CONTENTS, ALLOC, LOAD, DATA
  5 .bss          00000644  f0112300  00112300  00013300  2**5
                  ALLOC
  6 .comment      0000002c  00000000  00000000  00013300  2**0
                  CONTENTS, READONLY

下面是obj/boot/boot.out的ELF头:

[liushaolin@localhost boot]$ objdump -h boot.out

boot.out:     文件格式 elf32-i386

节:
Idx Name          Size      VMA       LMA       File off  Algn
  0 .text         0000017e  00007c00  00007c00  00000074  2**2
                  CONTENTS, ALLOC, LOAD, CODE
  1 .eh_frame     000000b0  00007d80  00007d80  000001f4  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  2 .stab         000007b0  00000000  00000000  000002a4  2**2
                  CONTENTS, READONLY, DEBUGGING
  3 .stabstr      00000846  00000000  00000000  00000a54  2**0
                  CONTENTS, READONLY, DEBUGGING
  4 .comment      0000002c  00000000  00000000  0000129a  2**0
                  CONTENTS, READONLY

加载内核

现在我们进一步研究一下bootloader C语言部分的细节,对应的文件为boot/main.c。在此之前,来回顾一下C的基本知识。

练习4:阅读指针代码pointers.c

想要弄明白boot/main.c,你首先要知道什么是ELF二进制文件。当你编译、连接一个类似于JOS kernel的C程序时,编译器把每个C源文件转换为包含以二进制格式编码的汇编语言指令的目标文件(‘.o’文件),然后连接器把所有的目标文件组合成一个单一的二进制镜像,比如obj/kern/kernel,这个镜像文件就是以ELF格式存储的二进制文件,它表示“可执行和可连接的格式”。

你可以认为一个ELF可执行文件带有一个包含加载信息的头,后面跟着几个*程序段(program section)*,其中的每一个段都是一个将要加载到内存中指定地址的连续代码块或数据块。Bootloader不会修改代码或数据,它把代码或数据加载到内存中然后执行。

一个ELF二进制文件有一个固定长度的ELF头,后面跟着一个可变长度的*程序头(program header)*,列出每一个将要载入的程序段。C语言对这些ELF头的定义在inc/elf.h中。我们感兴趣的程序段包括:

  • .text:程序的可执行指令
  • .rodata:只读数据。比如C编译器生成的ASCII字符串常量
  • .data:数据段,包含程序的初始化数据。比如初始化声明的全局变量

当连接器计算出一个程序的内存布局,它会为那些未初始化的全局变量比如int x在被称为.bss的段上保留空间,.bss段在内存中紧跟.data段。C语言要求“未初始化”的全局变量初始值为0,因此没有必要在ELF二进制文件中储存.bss段的内容,连接器只是记录.bss段的地址和大小。加载器或程序自己需要将.bss段的内容置零。

需要特别注意的是:.text段的VMA(或者link address)和LMA(或者load address)。load address是该段要加载到内存中的地址。程序段的link address是该段期望开始执行的内存地址(也就是虚拟地址)。关于VMA和LMA可以参考LMA和VMALinker Script,LMA,VMA为什么要有VMA和LMA两个地址?

一般来说,段的VMA和LMA是相同的,可以查看bootloader的.text段。凡是讲到代码段、数据段等等,指的就是虚拟内存。 对于普通的应用程序来说,load address和link address也都是指虚拟内存,LMA和VMA是相同的,这也就是所说的大多数情况下二者等价。在载入内核开启分页机制之前,虚拟地址是直接映射为物理地址的。

Bootloader通过ELF程序头(prrogram headers)来决定如何加载各个段。程序头指明了哪些段将要被加载到内存以及各个段占据的目的地址。可以通过下面的命令查看程序头:

[liushaolin@localhost lab]$ objdump -x obj/kern/kernel

obj/kern/kernel:     文件格式 elf32-i386
obj/kern/kernel
体系结构:i386,标志 0x00000112:
EXEC_P, HAS_SYMS, D_PAGED
起始地址 0x0010000c

程序头:
    LOAD off    0x00001000 vaddr 0xf0100000 paddr 0x00100000 align 2**12
         filesz 0x0000717b memsz 0x0000717b flags r-x
    LOAD off    0x00009000 vaddr 0xf0108000 paddr 0x00108000 align 2**12
         filesz 0x0000a300 memsz 0x0000a944 flags rw-
   STACK off    0x00000000 vaddr 0x00000000 paddr 0x00000000 align 2**4
         filesz 0x00000000 memsz 0x00000000 flags rwx

节:
Idx Name          Size      VMA       LMA       File off  Algn
  0 .text         00001907  f0100000  00100000  00001000  2**4
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .rodata       00000730  f0101920  00101920  00002920  2**5
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  2 .stab         00003871  f0102050  00102050  00003050  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  3 .stabstr      000018ba  f01058c1  001058c1  000068c1  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  4 .data         0000a300  f0108000  00108000  00009000  2**12
                  CONTENTS, ALLOC, LOAD, DATA
  5 .bss          00000644  f0112300  00112300  00013300  2**5
                  ALLOC
  6 .comment      0000002c  00000000  00000000  00013300  2**0
                  CONTENTS, READONLY

      .
      .
      .

ELF文件中需要载入内存的是那些被标记为“LOAD”的部分。同时给出了其他一些信息,比如虚拟地址(vaddr),物理地址(paddr)以及区域大小(memsz和filesz)。

回到boot/main.c中,每一个程序头的ph->p_pa字段包含了该段的目的物理地址(在这种情况下也是真正的物理地址)。

BIOS将引导扇区加载到从0x7c00开始的内存中,因此也是引导扇区的load address,也是引导扇区开始执行的地方,也就是引导扇区的link address。我们通过在boot/Makefrag中向连接器传递-Ttext 0x7c00来设置引导扇区的link address,因此连接器可以在生成的代码中给出正确的内存地址。

练习5: Trace through the first few instructions of the boot loader again and identify the first instruction that would “break” or otherwise do the wrong thing if you were to get the boot loader’s link address wrong. Then change the link address in boot/Makefrag to something wrong, run make clean, recompile the lab with make, and trace into the boot loader again to see what happens. Don’t forget to change the link address back and make clean again afterward!

回头看一下kernel的load address和link address。和bootloader不同的是,这两个地址是不同的:kernel告诉bootloader把它加载到内存的低地址(1M字节),但是希望从一个高地址开始执行。我们会在下一章节中研究如何实现。

除了段信息,还有一个对我们来说很重要的字段,就是e_entry。该字段是程序的入口地址:在程序的text段中程序开始执行的内存地址。可以用下面的命令查看程序入口:

[liushaolin@localhost lab]$ objdump -f obj/kern/kernel

obj/kern/kernel:     文件格式 elf32-i386
体系结构:i386,标志 0x00000112:
EXEC_P, HAS_SYMS, D_PAGED
起始地址 0x0010000c

第三部分:内核(kernel)

当你观察bootloader的link address和load address时,它们是完全一致的,但是kernel的这两个地址却大不一样。和bootloader类似,内核也以一些汇编代码开始,做好必要准备之后C代码才能正常运行。

使用虚拟内存解决位置依赖(position dependence)

为了脱离供用户程序使用的处理器虚拟地址空间较低的部分,操作系统的内核通常连接并运行在很高的虚拟地址上,比如0xf0100000。这样安排的原因在下一个实验中会详细说明。

许多机器在地址0xf0100000处并没有物理内存,因此我们不能指望能够在这种地方存储内核。相反,我们使用处理器的内存管理硬件将虚拟地址0xf0100000(内核代码运行的link address)映射到物理地址0x00100000(bootloader加载内核到内存的地址)。这样,尽管内核的虚拟地址的高度足够给用户进程留出充足的地址空间,内核还是会加载到物理内存RAM的1MB处,就在BIOS ROM之上。

实际上,我们在下一个实验中将把PC物理内存最低的256MB地址空间,从0x00000000到0x0fffffff映射到虚拟地址从0xf0000000到0xffffffff。现在你应该明白为什么JOS只能使用前256MB物理内存了。

到目前为止,我们只映射前4MB物理内存,这足够我们启动和运行了。我们使用kern/entrypgdir.c中手写的,固定初始化的页目录和页表来完成这一映射。到目前为止,你不必知道具体的细节,只需要明白达到的效果。直到kern/entry.S设置好CR0_PG标志之前,内存的引用都是作为物理地址对待(严格的讲,应该是线性地址,但是boot/boot.S建立了从线性地址到物理地址的等价映射,我们永远不会改变这一点)。一旦CR0_PG标志设置好,内存的引用就是通过把虚拟地址映射为物理地址来实现的。entry_pgdir将0xf0000000到0xf0400000范围的虚拟地址和0x00000000到0x00400000范围的虚拟地址映射为从0x00000000到0x00400000的物理地址。任何不在这两个范围内的虚拟地址都将引起硬件异常,因为我们还没建立异常处理,这会导致QEMU宕机或者无限重启。

练习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 in kern/entry.S, trace into it, and see if you were right.

exercise 7

movl %eax, %cr0处设置断点,执行到此处。在执行这条命令之前,发现0x00100000和0xf0100000两个地址的内容是不同的,执行完之后二者就变成相同的了。原因就是在执行这条指令之前,还没有建立分页机制,高地址的内核区域还没有映射到内核的物理地址,只有低地址是有效的;执行完这条指令之后,开启了分页,由于有静态映射表(kern/entrypgdir)的存在,两块虚拟地址区域都映射到同一块物理地址区域。

注释掉kern/entry.S中的movl %eax, %cr0,这样就无法开启分页,虚拟地址无法映射到物理地址。所以,此时第一条执行失败的命令是 0x100031: mov $0xf0110000,%esp,因为0xf0110000是高的虚拟地址,由于没有分页,CPU不知道访问哪一个物理地址。

终端格式化输出

大多数人都把类似于printf()的功能看成理所当然的,有时甚至认为他们是C语言的“基本”。但是,在一个OS内核中,我们不得不亲自实现所有的I/O。

通读kern/printf.clib/printfmt.ckern/console.c这三个文件,确保你理解它们之间的关系。在后续实验中你会明白为什么printfmt.c放在一个单独的lib目录下。

练习8:We have omitted a small fragment of code - the code necessary to print octal numbers using patterns of the form “%o”. Find and fill in this code fragment.

要求补全/lib/printfmt.c中“%o”部分的代码,这是八进制输出,仿照十进制“%d”代码如下:

case 'o':
    num = getuint(&ap, lflag);
    base = 8;
    goto number;

回答下列问题:

  1. 解释printf.c和console.c之间的接口。尤其是,console.c输出什么函数?printf.c是如何使用该函数的?

printf.c中的cprintf函数调用vcprintf,vcprintf调用lib/printfmt.c中的vprintfmt,vprintfmt调用printf.c中的cputchar函数,最终cputchar函数实现字符打印。

  1. 解释下面console.c的代码片段:
if (crt_pos >= CRT_SIZE) {
              int i;
              memcpy(crt_buf, crt_buf + CRT_COLS, (CRT_SIZE - CRT_COLS) * sizeof(uint16_t));
              for (i = CRT_SIZE - CRT_COLS; i < CRT_SIZE; i++)
                      crt_buf[i] = 0x0700 | ' ';
              crt_pos -= CRT_COLS;
      }

这段代码的作用是检测当前屏幕的输出buffer是否满了,注意这里的memmove其实就是把第二个参数指向的地址内容移动n字节到第一个参数指向的地址。n由第三个参数指定。如果buffer满了,把屏幕第一行覆盖掉逐行上移,空出最后一行,并由for循环填充以空格,最后把crt_pos置于最后一行的行首。

下面的问题可以参考Lecture 2

  1. 单步调试下面的代码:
int x = 1, y = 3, z = 4;
cprintf("x %d, y %x, z %d\n", x, y, z);

调用cprintf()时,fmt指向什么?ap指向什么?

cprintf的代码为:

int cprintf(const char *fmt, ...)
{
	va_list ap;
	int cnt;

	va_start(ap, fmt);
	cnt = vcprintf(fmt, ap);
	va_end(ap);

	return cnt;
}

fmt指向cprintf的第一个参数,也就是格式化字符串”x %d, y %x, z %d\n”的首地址。ap是一个va_list变量,在执行完va_start之后,ap指向第一个可变参数也就是x,在进入vcprintf之后会按顺序指向y,z。在执行完va_end(ap)之后,ap变成NULL。

  1. 执行下面的代码,查看输出是什么?为什么是这样?
unsigned int i = 0x00646c72;
cprintf("H%x Wo%s",57616,&i);

输出结果为:He110 world。原因就是57616的16进制形式为0xe110,第二个参数不是变量本身,而是变量地址,共四个字节。x86是little-endian,输出顺序是从低到高:72对应’r’,6c对应’l’,64对应’d’,00对应’\0’。

  1. 执行下面的语句,打印结果是什么?
cprintf("x=%d y=%d", 3);

打印结果为“x=3 y=xxx”,由于只有一个可变参数3,在打印完第一个参数之后ap会加4,但是内容是不可预知的。

  1. 假设GCC编译器改变了调用约定,按照声明的顺序将变量压栈,这样最后一个变量最后入栈。那么该如何修改cprintf或者它的接口以保证还可以正常打印呢?

这里涉及到变长参数,头文件定义如下:

说明调用了GCC里的函数,没有找到__builtin_函数的实现,参考inc/stdarg.h

va_arg 每次是以地址往后增长取出下一参数变量的地址的,而这个实现方式是默认编译器是以从右往左的顺序将参数入栈的,且栈是以从高往低的方向增长的。后压栈的参数放在了内存地址的低位置,所以如果要以从左到右的顺序依次取出每个变量,那么编译器必须以相反的顺序即从右往左将参数压栈。如果编译器更改了压栈的顺序,那么为了仍然能正确取出所有的参数,那么需要修改上面代码中的 va_start 和 va_arg 两个宏,即

#define va_start(ap, last) ((ap) = (va_list)&(last) - __va_size(last))
#define va_arg(ap, type)   (*(type *)((ap) -= __va_size(type), (ap) + __va_size(type)))

JOS提供了颜色打印功能,具体实现可以参考http://blog.csdn.net/woxiaohahaa/article/details/49702219

在本实验的最后一个练习中,我们将探索一些更多关于C语言在X86下使用栈的细节,并在这一过程中写一个新的可用的内核监视器函数来打印栈的回溯信息:一个保存的来自嵌套调用的指令指针值的列表。

以下资料帮助理解栈的原理:

练习9:确定内核是在何处初始化它的栈的,它的栈位于内核中何处?内核是怎么为自己预留栈空间的?初始化栈指针指向这块预留区域的哪个“端”?

打开kern/entry.S,可以发现内核栈的初始化:

我们发现栈有两部分,第一部分是实际栈空间,一共KSTKSIZE,其大小定义在inc/memlayout.h中,KSTKSIZE = 8 × PGSIZE = 8 × 4096B = 32KB,另一部分是栈底指针bootstacktop,因为它指向栈空间定义完以后的高地址位置。前面我们说过栈是向低地址增长的,所以最高位置就是栈顶,这个位置会作为初值传递给%esp。

X86栈指针(esp寄存器)指向当前正在使用的栈的最低位置。在这片栈区域上低于该位置的一切空间都是可用的。栈是向下生长的,出栈入栈具体就不多说了。在32位模式下,栈只能保留32位数据(并不是栈的大小只有32位,而是数据长度为4字节),esp总是能被4整除。

练习10: To become familiar with the C calling conventions on the x86, find the address of the test_backtrace function in obj/kern/kernel.asm, set a breakpoint there, and examine what happens each time it gets called after the kernel starts. How many 32-bit words does each recursive nesting level of test_backtrace push on the stack, and what are those words?

这个练习要求我们熟悉一下栈。打开obj/kern/kernel.asm,主要分析一下test_backtrace(),找到函数的调用入口:

我们打开qemu,使用gdb调试,在0xf01000de处设置断点。单步执行,查看栈帧和各寄存器的值,如下:

可以发现此时eip寄存器的值为0xf01000e5,也就是下一条指令的地址。找到kernel.asm中对应的指令为call f0100040 <test_backtrace>,也就是下一条指令就是调用test_backtrace。

继续单步执行call函数后,eip会指向0xf0100040,ebp不变,esp由于call函数压栈将下一条指令地址也就是0xf01000ea入栈。如下图中,eip的值被保存在0xf010ffdc,也就是当前esp指向的栈顶。在图中也可以看到,下一条要执行的指令就是push %ebp。进入test_backtrace函数发现第一条指令就是push %ebp

此时esp为0xf01000dc,ebp为0xf01000f8,函数i386_init()的栈帧从ebp~esp共32字节,继续执行到test_backtrace(x-1)时:

此时esp为0xf01000bc,ebp为0xf01000d8,也就是函数test_backtrace(x=5)的栈帧从0xf01000d8~0xf01000bc共32字节。我们可以类推,test_backtrace(x=4)的栈帧从0xf01000b8~0xf010009c。

我们可以发现每个 test_backtrace() 函数一共有4类栈空间使用:

1)入口处%ebp压栈。

2)将%ebx压栈,保存函数参数。

3)保留0x14个即20个byte的空间作为临时变量储存。

4)在call时,将%eip压栈。

一共4+4+20+4=32(byte),也即每次调用 test_backtrace() 函数,会压栈32字节的空间。

代码的结尾处,与压栈的过程刚好相反:

add    $0x14,%esp
pop    %ebx         #将栈里的临时变量空间出栈
pop    %ebp         #恢复上一个函数的栈底,栈大小减少4字节
ret                 #恢复eip,栈大小减少4字节

在函数的调用和返回过程中,压栈和出栈是对应的。关于函数调用过程中栈的变化可以参考:栈与函数调用

练习11: Implement the backtrace function as specified above. Use the same format as in the example, since otherwise the grading script will be confused. When you think you have it working right, run make grade to see if its output conforms to what our grading script expects, and fix it if it doesn’t. After you have handed in your Lab 1 code, you are welcome to change the output format of the backtrace function any way you like.

If you use read_ebp(), note that GCC may generate “optimized” code that calls read_ebp() before mon_backtrace()’s function prologue, which results in an incomplete stack trace (the stack frame of the most recent function call is missing). While we have tried to disable optimizations that cause this reordering, you may want to examine the assembly of mon_backtrace() and make sure the call to read_ebp() is happening after the function prologue.

这个练习让我们实现一个栈回溯函数mon_backtrace(),在kern/monitor.c中给出了函数原型,inc/x86.h中的read_ebp函数会有所帮助。该函数的打印结果应当如下所示:

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

第一行打印内容是当前正在执行的函数,也就是mon_backtrace本身的栈,第二行打印的是调用mon_backtrace的函数的栈,第三行以此类推。通过上面对栈的跟踪调试,我们知道栈的结构如下所示:

stack

图中每个框表示四个字节,arg1是最左边的函数参数,ebp指向栈底,在未调用函数时,esp指向arg1,发生函数调用时,esp-1,然后eip寄存器的值压栈。

补全kern/monitor.c的代码如下:

int
mon_backtrace(int argc, char **argv, struct Trapframe *tf)
{
        // Your code here.
    uint32_t *ebp = (uint32_t *)read_ebp();
    uint32_t eip = ebp[1];
    uint32_t arg1= ebp[2];
    uint32_t arg2 = ebp[3];
    uint32_t arg3 = ebp[4];
    uint32_t arg4 = ebp[5];
    uint32_t arg5 = ebp[6];
    cprintf("Stack backtrace:\n");
    while(ebp)
    {
        cprintf("ebp %08x eip %08x args %08x %08x %08x %08x %08x\n",ebp,eip,arg1,arg2,arg3,arg4,arg5);
        ebp = (uint32_t *)ebp[0];
        eip = ebp[1];
        arg1= ebp[2];
        arg2 = ebp[3];
        arg3 = ebp[4];
        arg4 = ebp[5];
        arg5 = ebp[6];
    }

        return 0;
}

通过调用函数read_ebp获得当前函数栈的ebp值,我们要将其以地址的方式存储起来,这时0x0[%ebp]可以读出上个函数的栈指针,0x4[%ebp]可以读出返回地址%eip,0x8[%ebp]可以读出第一个参数. . .

vosamo /
Published under (CC) BY-NC-SA in categories xv6  tagged with 操作系统