Lab 1
Part 1:PC Bootstrap
1、预备知识:
- BIOS:
- 第一代PC处理器是16位字长的Intel 8088处理器,这类处理器只能访问1MB的地址空间,即0x00000000-0x000FFFFF。这1MB中0x000F0000——0x00100000被只读内存(ROM)所使用,计算机通电后,第一件事就是读取它。ROM里的程序叫做"基本輸出輸入系統"(Basic Input/Output System),简称为BIOS。
- SeaBIOS是qemu和kvm的默认BIOS 。在SeaBIOS中会完成虚拟硬件的初始化,中断服务函数的设置,ACPI表、SMBIOS表、E820表等的创建,最后引导启动OS。
- 实模式与保护模式:
- 实模式是早期CPU,比如8088处理器的工作模式,这类处理器由于只有20根地址线,所以它们只能访问1MB的内存空间。实模式下的地址由段基址(segment)和偏移(offset)两部分组成,计算公式为:segment * 16 + offset。
- 但是CPU也在不断的发展,之后的80286/80386已经具备32位地址总线,能够访问4GB内存空间,为了能够很好的管理这么大的内存空间,保护模式被研发出来。在保护模式下,虽然段值仍然由原来的16位cs寄存器指定,但此时这些寄存器中存放的不再是段基址,而是一个索引。从这个索引,可以找到一个表项,里面存放了段基址等很多属性,这个表项称为段描述符,这个表就称为GDT。
- 现代处理器都是工作在保护模式下的。但是为了实现向后兼容性,即原来运行在8088处理器上的软件仍旧能在现代处理器上运行,现代的CPU都是在启动时运行于实模式,启动完成后运行于保护模式。
- BIOS就是PC刚启动时运行的软件,所以它必然工作在实模式下。
2、利用GDB跟踪ROM BIOS的指令
首先进入lab所在文件夹,分别在两个终端先后输入如下命令:
Make qemu-nox-gdb;
Make gdb;
执行的BIOS的第一条指令如下:
这条指令的地址是0xffff0,而最前面的[ f000:fff0 ]源于实模式下的取址方式:0xffff0=0xf0000<<4+0xfff0
在gdb的终端输入si
得到下一条指令:
输入i r esi cs
查看esi cs的值:
所以这条指令意思是将0xffc8和0xf0000地址处的值进行比较。
下一条指令是跳转指令:
如果ZF标志位为0的时候跳转,即上一条指令cmpw的结果不是0时跳转。
继续输入si
,得到下一条指令地址是0xfe066:
可见上面的跳转指令并没有跳转。这条指令的功能是把edx寄存器清零。
接下来的几条指令对部分寄存器做了一些处理:
[f000:e068] 0xfe068: mov %edx,%ss
[f000:e06a] 0xfe06a: mov $0x7000,%sp
[f000:e070] 0xfe070: mov $0x2d4e,%dx
[f000:e076] 0xfe076: jmp 0x5575ff02
[f000:ff00] 0xfff00: cli
最后一句cli表示clear Interrupt,作用是将EFLAGS标志寄存器的IF置为0。
EFLAGS标志寄存器有32位,不同位代表不同标志,如下图所示:
其中IF表示中断使能标志。
关于中断:中断对于CPU来说,分为外部中断和内部中断。将 IF标志设置为0,屏蔽的是来自硬件的可屏蔽中断请求。在 IF为0期间,只有CPU外部不可屏蔽中断(NMI)引脚上发来的硬件中断请求能得到响应,其它可屏蔽请求不被响应。
启动时的操作是比较关键的,所以肯定是不能被中断的,因此需要这个关中断指令用于关闭那些可以屏蔽的中断。
查看eflags寄存器:
意思是此时PF ZF位为1,其他位为0,验证了此时IF标志位为0。
接下来几条指令如下所示:
0xfff01: cld
0xfff02: mov %ax,%cx
0xfff05: mov $0x8f,%ax
0xfff0b: out %al,$0x70
0xfff0d: in $0x71,%al
0xfff0f: in $0x92,%al
0xfff11: or $0x2,%al
0xfff13: out %al,$0x92
0xfff15: mov %cx,%ax
便于理解,找到seaBios的源代码相应处:
transition32:
// Disable irqs (and clear direction flag)
cli
cld
// Disable nmi
movl %eax, %ecx
movl $CMOS_RESET_CODE|NMI_DISABLE_BIT, %eax
outb %al, $PORT_CMOS_INDEX
inb $PORT_CMOS_DATA, %al
// enable a20
inb $PORT_A20, %al
orb $A20_ENABLE_BIT, %al
outb %al, $PORT_A20
movl %ecx, %eax
根据注释,这些指令是为了准备进入32位保护模式:关闭了NMI中断,将A20位即第21个地址线使能。
接下来的源代码是:
transition32_nmi_off:
// Set segment descriptors
lidtw %cs:pmode_IDT_info
lgdtw %cs:rombios32_gdt_48
// Enable protected mode
movl %cr0, %ecx
andl $~(CR0_PG|CR0_CD|CR0_NW), %ecx
orl $CR0_PE, %ecx
movl %ecx, %cr0
关注lidt和lgdt两条指令:
- LGDT或LIDT的作用是将以源操作数位地址的6 字节的值加载到全局描述符表格寄存器 (GDTR) 或中断描述符表格寄存器 (IDTR)。这6个字节包含全局描述符表格 (GDT) 或中断描述符表格 (IDT) 的基址(线性地址)与限制(表格大小,以字节计)。 2 个低位字节代表限制,4 个高位字节代表基址。
- GDTR或IDTR寄存器的作用是在相应的全局描述符表格 (GDT) 或中断描述符表格 (IDT) 中进行定位。中断向量表:每一种中断都有自己对应的中断处理程序,这个中断的处理程序的首地址就叫做这个中断的中断向量。IDT表存放所有中断向量的。全局描述符表:与保护模式下32位寻址有关。
lidt这条指令在gdb中对应的是:
再次查看esi和cs的值:
所以这条指令的意思是把从0xf0000为起始地址处的6个字节的值加载到全局描述符表格寄存器IDTR中,查看这6个字节:
所以加载到全局描述符表寄存器(GDTR)的值是0x83c01901243c,并且GDT基址为0x83c01901。但是实模式下能访问的最大虚拟/物理地址为0xfffff,所以这个基址似乎是不对的。
接下来两条汇编指令:
0xfff2e: or $0x1,%cx
0xfff32: mov %ecx,%cr0
计算机中包含CR0~CR3四个控制寄存器,用来控制和确定处理器的操作模式。CR0寄存器的0bit是保护位,当该位被置1,代表开启了保护模式。这个操作明显是要把CR0寄存器的最低位(0bit)置1。
所以目前BIOS干了什么?
关闭中断、加载IDTR/GDTR、开启保护模式……
之后的过程比较复杂, BIOS完成一系列硬件,再进入到Boot phase找到可引导设备,并把操作系统的boot loader加载到内存中开始取址执行。
Part2:the Boot Loader
1、预备知识:
- 上一部分最后提到BIOS会找到可引导设备,具体地说,BIOS在运行的最后会去检测可以从当前系统的哪个设备中找到操作系统,通常来说是磁盘,也有可能是U盘等等。当BIOS确定了操作系统位于磁盘中,它就会把这个磁盘的第一个扇区,通常把它叫做启动区(boot sector)先加载到内存地址0x7c00~0x7dff中,这个启动区中包括一个非常重要的程序–boot loader,它会负责完成将整个操作系统从磁盘导入内存的工作,以及一些其他的非常重要的配置工作。在这之后操作系统才会开始运行。
2、跟踪 /boot/boot.S文件
由boot.s的开头说明可知这就是boot loader程序。
输入下面指令在地址0x7c00处设置断点,并让程序继续运行到这个断点:
b *0x7c00
c
终端显示:
说明bootloader的第一条指令是cli——关闭中断。
接下来设置了一些重要的数据段寄存器:
cld
xorw %ax,%ax # Segment number zero
movw %ax,%ds # -> Data Segment
movw %ax,%es # -> Extra Segment
movw %ax,%ss # -> Stack Segment
根据注释,下面又是熟悉的enable A20操作。
为什么又要enable A20?
进入Bootloader之前,有的BIOS可能会在短时间内切换到保护模式,这将允许其使用受保护模式的一些优点(例如32位作为默认地址大小),但是在进入bootloader程序之前会切换回来。
enable A20之后gdb显示下一条指令是:
意思是把从(%esi)地址起始的6个字节加载到gdtr寄存器中,但是查看esi的值却是0。
而在boot.asm这个反汇编文件,对应的指令是:
看起来和gdb显示的一样,只是下面接了一条奇怪的指令 fs jl
。
回到boot.S中,这里的汇编代码是 lgdt gdtdesc
,在boot.S最后定义了要加载到gdtr寄存器中的gdtdesc和全局描述符表gdt:
gdtdesc由一个word(2字节,值为0x17,代表gdt表的大小)和一个long(4字节,值为gdt的起始地址,代表gdt表的基址)组成,正好6字节。
而GDT表的初始化用到了SEG这个宏定义,在mmu.h定义如下:
所以GDT表中的一项就有8字节,64位。
所以实际执行的绝不是lgdtl (%esi)
,错误原因可能出在反汇编时,所以直接查看该处的机器码:
0f 01是操作码lgdtl,16是指16位模式,所以gdtdesc的地址由后面两个字节组成:64 7c。又因为小端法,所以地址是0x7c64。在内存中查看该地址的6个字节:
前2个字节是0x0017,对应.word 0x17
,后面四个字节组成的0x7c4c则是gdt表基址。根据前面分析,gdt表一项就有8个字节,而初始化时有3项,所以查看0x7c4c为起始的后24个字节:
关于gdt表的几个问题:
-
这些数字要如何理解呢?
1个gdt表项的64位有不同的含义,如下图所示:
在gdt表中的三个表项,分别代表三个段:null seg,代码段,数据段。由于jos没有使用分段机制,所以数据和代码都是写在一起的。所以我们可以看到,代码段和数字段的0-15:48-51bit(代表段限长)和16-39:56-64bit(代表段基址)的4都是相同的,代表着数据段和代码段的起始地址都是0x0,大小都是0xfffff=1GB。 -
为什么要设置GDT表呢?
GDT表的设置与保护模式下寻址有关。在保护模式下,cs作为段选择子,它的16位含义如下:
3-15位是在GDT表中的索引,找到对应的GDT表项,再取出段基址。 -
GDT表中的第一项为什么是空描述项?
与全局描述符表对应的,还存在局部描述符表LDT;同样地,还有LDTR寄存器。当一个任务使用的所有段都是系统全局的时,它不需要用LDT来存储私有段信息,因此,当系统切换到这种任务时,会将LDTR寄存器赋值成一个空选择子,选择子的描述符索引值为0,TI指示位为0,RPL可以为任意值,用这种方式表明当前任务没有LDT。将TI为0的LDT设置为指向GDT的第0项描述符,此时第0项的作用类似于C语言中NULL的用法,它虽然是一个描述符,但却只起到到了标志的作用,标志着LDT为空。如果把LDT的TI位改为1,则LDTR指向LDT中的0号描述符。
- 为什么GDT表的段基址和段界限在描述符中都不连续存放?
这是和CPU的发展历史分不开的,最先有保护模式的CPU是80286(现在已经不多见了),这个CPU只有24位的地址线,所以可以看到现在386的描述符中基地址的前24bits是连续存放的,后来在扩充时需要把基地址变成32位,为了和80286兼容,只好分开存放了,段界限也是同样原因造成的,所以在80286上,一个描述符的长度虽然也是8字节长,但它只用到了前6个字节,到了386就全都用上了。
- 为什么在gdb和反汇编文件中显示的是
lgtdl (%esi)
呢?
lgdtl ldtdsec
的机器码是0f 01 16 64 7c
,但反汇编时没有完整的机器码,而是“断章取义”,将0f 01 16
翻译成lgdtl (%esi)
,把多余的64 7c
再加上原本属于下一条汇编代码的机器码0f
联合翻译成错误的fs jl 7c33 <protcseg+0x1>
,而本来0f 20 c0应该对应着下一句movl %cr0, %eax
,但是反汇编时缺少了0f,于是就把20 c0翻译成了and %al,%al
在lgtd之后,是熟悉的PE置位操作。PE置位后,CPU已经按照32位开始取指令、译码,此时代码段选择子寄存器(cs)所指向的代码段还停留在实模式下所以无效且CPU流水线中存在无效结果,继续执行会发生错误,需要一个长跳转指令ljmp dword来处理:
ljmp $PROT_MODE_CSEG, $protcseg
在boot.S开头有预定义.set PROT_MODE_CSEG, 0x8
,但在反汇编文件中该句却是:
但出现了似曾相识的反汇编错误。依然先看boot.S中真正执行的代码:
已经开启保护模式,则取ljmp的第一个16位参数得3-15bit作为gdt表的索引,0x8=0000000000001000,所以得到的结果是1,也就是gdt表的第2项——代码段。由之前分析得到
代码段基地址为0,基地址+偏移
p
r
o
t
c
s
e
g
得
到
线
性
地
址
,
所
以
直
接
跳
转
到
protcseg得到线性地址,所以直接跳转到
protcseg得到线性地址,所以直接跳转到protcseg处执行。
- 为什么boot.s上ljmp $PROT_MODE_CSEG, $protcseg ,但.asm是ljmp $0xb866,$0x87c32呢?
在执行 ljmp 之前,虽然PE置位开启保护模式,但之前的地址、数据仍处于16位代码段中(可从boot.s该段不属于tag .code32看出)。ljmp指令对应的机器码为ea 32 7C 08 00 66 B8。 在反汇编时,ea表示ljmp。在32位地址空间中,将使用ea后面的四个字节作为右边的操作数,为0x087C32,但在16位地址空间中,仅使用2个字节,为0x7C32。取完右操作数后,会继续取2个字节作为代码段(段寄存器还是16位),在32位模式下为0xB866(实际上66 B8属于下一个指令的机器码),在16位模式下为0x0008。反汇编是以32的模式来的,所以得到的结果与实际的不一致。(有一种说法是,现在是16位保护模式)
在调用bootmain处设断点,继续执行。
bootmain
在进入bootmain()之前查看栈顶/底指针:
在进入bootmain并执行了下列指令之后:
查看esp、ebp:
说明bootmain这个函数的栈用的是在boot.S代码段下面的空间。继续把参数和返回地址压栈后调用readseg。
readseg
readseg(uint32_t pa, uint32_t count, uint32_t offset)的作用是从内核offset处读取count个字节到内存的pa处。
在readseg中有下列三条指令:
mov 0x10(%ebp),%edi;
mov 0xc(%ebp),%esi;
mov 0x8(%ebp),%ebx
由压栈顺序可知知道0x04(%ebp)存放的是bootmain的返回地址,0x08(%ebp)存放的是第1个输入参数0x10000,0xc(%ebp)存放的是第2个参数0x1000,0x10(%ebp)中存放的是第3个参数0x00,所以这是在取出参数到寄存器。
然后继续取址执行直到while循环,在循环中会调用readsect来读取内核到内存中,在调用之前,同样也要把参数压到栈中:
push %edi
push %ebx
readsect:
readsect(void *dst, uint32_t offset)的作用是从内核中第offset个扇区读到内存的dst地址处。
一句一句对照着源程序分析readsect:
1、waitdisk()
waitdisk()这个函数的功能从名字可以看出来是等待磁盘就绪。这个函数只有一个循环
while ((inb(0x1F7) & 0xC0) != 0x40)
,它的意思是先调用inb()函数从0x1F7(0号硬盘状态寄存器放在这个里面)中读取数据。因为在0号硬盘状态寄存器中最高两位为01时表示硬盘就绪,所以inb(0x1F7) & 0xC0的作用就是获取寄存器最高两位并且和0x40进行比较,如果相等,说明就绪了,跳出循环。
2、outb(0x1F2, 1);
这个函数对应的是一段内嵌汇编指令:asm volatile("outb %0,%w1" : : "a" (data), "d" (port));
outb 是intel x86的一条指令
%w1表示宽度为w的1号占位符,%0表示0号占位符
“a” (data), “d” (port)代表两个输入,分别对应0、1号占位符
意思是将data(1个字节)传输到port(端口)
之后的四条outb语句outb(0x1F3, offset);outb(0x1F4, offset >> 8);outb(0x1F5, offset >> 16);outb(0x1F6, (offset >> 24) | 0xE0);
都是表示向某端口送入长为4字节的offset变量的一个字节,最后outb(0x1F7, 0x20);
向0x1F7端口输出0x20指令表示要读取这个扇区。
3、waitdisk();
让磁盘自行读取数据。
4、insl(0x1F0, dst, SECTSIZE/4);
又是一段内嵌汇编,作用是从 0x1F0 端口读入一个扇区到dst处。
其中有一条指令:
- 关于rep指令:
rep指令又叫做重复串操作指令,它是一个前缀,位于一条指令之前,这条指令将会一直被重复执行,并且直到计数寄存器的值满足某个条件。repnz指令是当计数器%ecx的值不为零是就一直重复后面的串操作指令。那么被重复调用的指令就是insl指令。
- 关于insl指令:
ins指令可从第一个源操作数所指的外设端口输入n个字节到由第二个源操作数指定的存储器中。insl表示一次读4个字节。
(%dx)的值是端口0x1f0(代表0号硬盘数据寄存器),%ecx的值是0x80,所以这条指令的意思是从0x1f0端口进行128次读取操作来读取一个扇区(512byte)到目标地址。
readsect结束,回到readseg调用的地方,继续取址执行,然后ret回到bootmain。此时内存中分布大致如下:
0x00000000
+------------------------------------------------------------------------—+
| 0x7c00 0x7d00 0x10000 |
| 栈 | 引导程序 | | 内核 |
+-------------------------------------------------------------------------+
0xffffffff
需要解释从内核读入的是什么:
内核的第一个块,里面存放的是内核文件的elf文件头。
ELF文件
- ELF(Executable and Linking Format)是一个定义了目标文件内部信息如何组成和组织的文件格式,简单的说 ELF 文件格式是 Linux 下可执行文件的标准格式,就好像 Windows 操作系统里的可执行文件 .exe 一样。一个可执行程序可以有好几个代码段和好几个数据段和其他不同的段。当一个程序准备运行的时候,操作系统会将程序的这些段载入到内从中,再通知 CPU 程序代码段的位置已经开始执行指令的点即入口点。
- 操作系统在加载这些段的时候为了更好的组织利用内存,希望将一些列作用相同的段放在一起加载(比如多个代码段就可以一并加载),编译器为了方便操作系统加载这些作用相同的段,在编译的时候会刻意将作用相同的段安排在一起。而这些作用相同的段在程序中是如何组织的信息就被记录在 ELF 文件的程序头表中。
- ELF文件的构造如下:
- 一个 ELF 文件格式的可执行程序的加载运行过程是这样的:
通过读取 ELF 头表中的信息了解该可执行程序是否可以运行(版本号,适用的计算机架构等等)
通过 ELF 头表中的信息找到程序头表
通过读取 ELF 文件中程序头表的信息了解可执行文件中各个段的位置以及加载方式
内核就是一个ELF文件。bootmain()函数就是先将ELF Header加载到内存0x10000地址处,然后从ELF Header中获得所有Program Header的信息,再将这些Program段依次从磁盘加载到内存中。
另开一个终端输入readelf -l kernel
,就可以得到内核的ELF Header中的 Program Header Table:
ELF文件头读完之后,bootmain会验证读出的这个文件头是不是代表一个有效的elf文件。
接下来是获取Program Header Table的起始地址在ELF Header文件中的偏移,和Program Header Table表项数目,为下面的循环以此读入每个表项做准备。
把内核文件从磁盘读取到内存的循环
boot.asm中这一部分的指令如下:
其中读取内核的操作是 readseg(ph->p_pa, ph->p_memsz, ph->p_offset);
ph当前代表的是一个Program Header Table中一个表项,ph->p_pa是这个段将要被存放的物理地址,ph->p_memsz代表这个段的大小,ph->p_offset代表这个段的起始地址距离整个内核文件起始地址的偏移。这个语句的含义就是把这个表项所代表的段存放到ph->p_pa字段的所指定的内存地址处。
readseg会通过调用readsect来真正的进行读取操作。在call readsect处添加断点,查看栈:
栈顶就是readsect的第一个参数pa,说明内核要被放入到内存的0x100000处,与 readelf -l kernel
得到的程序投标中的PhysAddr相同;放入的内容是ELF文件的第9个扇区,与前面读取ELF文件头时count=4096bytes也就是8个扇区呼应。
循环结束后执行下面这条语句,将控制权交给内核:
既然控制权已经交给内核,则call的就是内核的第一条指令,查看该地址对应指令:
总结:
bootloader做了什么?
初始化GDT和IDT表、开启保护模式、读取内核的ELF文件头到内存0x7c00、加载磁盘中的系统内核elf文件到0x100000、 跳转到内核。
问题解答:
- 处理器在什么时候开始执行32位程序?到底是什么引起了16位到32位模式的切换?
在boot.S中将处理器切换为32-bit模式的指令是ljmp $PROT_MODE_CSEG, $protcseg,在这之后开始执行32位程序。PE置位让cpu开始以32位模式取指令、译码,但之前存储的的地址、数据仍处于16位代码段中,并且cs段选择子也是无效的,在ljmp之后才是真正开始32位保护模式。 - boot loader执行的最后一条指令是什么?它加载内核的第一条指令是什么?
boot loader的最后一条指令就是:
内核的第一条指令是:
Part 3:The Kernal
1、预备知识
-
虚拟地址和物理地址
在kern文件夹中有一个链接脚本kernel.ld。链接脚本的一个主要目的是描述输入文件中的各个段(数据段,代码段,堆,栈,bss)如何被映射到输出文件中,并控制输出文件的内存排布。链接脚本会用到两个地址:虚拟地址(VMA)和加载地址(LMA)。虚拟地址即section[*]在目标文件加载后供程序访问的虚拟地址,加载地址指section实际被加载到内存的物理地址。
下面是kerne.ld的一段代码:
”.”指定初值,表示section虚拟地址(VMA),AT关键字指定该section的加载地址(LMA),所以这里的意思就是将物理地址0x100000映射到虚拟地址的0xF0100000为什么要进行映射?
链接脚本会将物理地址链接到高的虚拟地址,为了能够让处理器的低地址部分能够被用户利用来进行编程。但是计算机又支持不了这么大的物理内存,所以把区分实际的物理地址和面向用户的虚拟地址,并将高的虚拟地址映射为真实的物理地址。
通常计算机所采用的分页管理来实现上面所讲述的地址映射,但是jos设计者实现映射的方式是自己手写了一个程序lab\kern\entrygdir.c用于进行映射:
把虚拟地址的 [0,4M) 和[KERNBASE,KERNBASE + 4M) 两个区间都映射到同一物理地址区间[0,4M)。其中KERNBASE预定义如下:
2、kern.asm调试
在boot loader把kernel加载到内存时,还没有启动映射,所以这时物理地址=虚拟地址,所以要先开启映射。控制这个开关的是CR0寄存器的最高位PG位,PG=1,启动分页机制。而CR3寄存器中含有页目录表物理内存基地址。于是先将页目录的物理地址复制到CR3寄存器,并且将PG设置为1,正式打开分页功能:
movl $(RELOC(entry_pgdir)), %eax
mov %eax,%cr3
# Turn on paging.
movl %cr0, %eax
orl $(CR0_PE|CR0_PG|CR0_WP), %eax
movl %eax, %cr0
第一句中的$(RELOC(entry_pgdir))
根据宏定义#define RELOC(x) ((x) - KERNBASE)
就是获得物理内存基地址。
在movl %cr0, %eax
处设断点,需要注意的是:虽然断点不是设在0xf010001d,而是设在0x10001d,因为还没开启映射。
在置位PG之前分别查看0x100000和0xf0100000两处地址的内容,得到的是不一样的:
置位PG之后再查看0xf0100000处的内容,和0x100000处内容一样:
进一步说明分页已经开启,接下来可以跳到高的虚拟地址处继续执行:
mov $relocated, %eax
jmp *%eax
Q1: 内核在哪里初始化它的栈,栈在内存的什么地方?内核是怎样给栈保存空间的?栈初始指针是指向保留区域的哪一端?
-
上面一步跳到高的虚拟地址后,执行下面两句:
这两个指令做的就是对ebp和esp的修改,由mov $0xf0110000,%esp
可知因此栈被初始化在内存的0xf0110000处 -
在entry.s最后的.data段中定义了全局变量 bootstack 作为临时堆栈:
.space size表示生成size个字节,说明内核给栈保存了KSTKSIZE的空间,由宏定义#define KSTKSIZE (8*PGSIZE)
和#define PGSIZE 4096
可知该空间大小为32k -
由于栈由高地址向低地址生长,所以指针指向高地址0xf0110000那一端
栈初始化完之后,通过call f01000a6 <i386_init>
调用init.c中的i386_init()函数,进入c code。
Q2: 修改程序,使程序能够正确输出%o (八进制数)。
这里提供了一些函数,用于将字符串输出到控制台,分布在kern/printf.c, lib/printfmt.c, kern/console.c中。
这些函数的调用关系如下:
(图源:https://www.cnblogs.com/gatsby123/p/9759153.html)
kern/monitor.c的作用是实现内核与用户的交互。阅读monitor.c,发现它是直接调用printf.c中的cprintf()来在屏幕上输出,接下来的调用顺序是:cprintf()->vcprintf()->printfmt.c中的vprintfmt()->printf.c中的putch()->console.c中的cputchar()->cons_putc()。由注释知最后这个cons_putc()函数的作用是输出一个字符到控制台。
- cprintf(const char *fmt, …):
第一个参数是显示信息的格式字符串,后面的省略号代表其他参数的集合,并保存到va_list类型的变量中,把格式化字符串和参数集合传递给vcprintf(),vcprintf()调用vprintfmt()。 - vprintfmt(void (*putch)(int, void*), void *putdat, const char *fmt, va_list ap):
四个参数:
1、 void (*putch)(int, void*):一个函数指针,这类函数包含两个输入参数int, void*,int参数代表一个要输出的字符的值。void* 则代表要把这个字符输出的位置的地址。
2、 putdat: 输出字符的目的地址的指针
3、fmt : 指向格式化字符串
4、 ap: 参数集合
vprintfmt()的大的框架是一个while循环,循环中将格式化字符串根据%分割成几块,对于每一块首先输出’%'之前的字符,对于%之后的格式化参数处理使用switch-case语句进行处理。
所以可以参照已有的显示无符号十进制的情况’u’,来书写八进制的。
在vprintfmt()中找到case 'o’的地方,补充如下代码:
num = getuint(&ap, lflag);
//只需修改基数为8即可
base = 8;
goto number;
然后`make clean & make; 重新编译。
屏幕输出对比原来的:
重新编译后变成:
说明修改成功。
Q3:运行如下代码,观察并解释输出
unsigned long long i = 0x5e2d5e6e61647546;
cprintf("H%x %s\n", 57616, &i);
将代码加在monitor.c文件的monitor()函数的相应位置,重新编译,得到的屏幕输出多了如下这行:
%x 是十六进制,将 57616 转为十六进制就是 e110。&i 表示将i转换为字符串来输出。因为x86是小端模式,并且是int类型,所以存放的方式是四个字节,所以就会将i拆分开来,变成 0x46->F, 0x75->u……
Q4:找到obj/kern/kern.asm中test_backtrace子程序的地址,设置断点,探讨在内核启动后,这个程序被调用时发生了什么?对于这个循环嵌套调用程序test_backtrace, 一共有多少信息压入到了堆栈之中?代表什么含义?
找到test_backtrace子程序的调用入口:
在init.c中找到test_backtrace():
这个函数是一个递归调用,在每一层调用中先打印信息 “entering test_backtrace x”,然后对test_backtrace进行调用。函数结束前打印信息 “leaving test_backtrace x”。所以最先进入的程序会最后退出。由于在init中对这个函数调用的次数为设置为5,所以启动时屏幕上会打印出下列信息:
在kernel.asm中找到test_backtrace的汇编代码,前面四句是进入函数的常规操作:
push %ebp
mov %esp,%ebp
push %esi
push %ebx
保存调用者的ebp,保存调用者的寄存器esi,ebx的值。
然后sub $0x8,%esp
分配一个大小为8个存储单元的额外的栈帧空间,存储一些临时变量的值。
然后push %esi
,push %eax
在栈中保存当前函数的参数和当前栈顶指针,调用cprintf函数。
add $0x10,%esp
,sub $0xc %esp
调整esp。
push %eax
保存当前栈顶指针,递归调用test_backtrace。
可画出每一次调用的栈的结构和含义如下:
+-----+<-调用者的ebp
| esi |<-上一个调用者的esi寄存器的值
+-----+
| ebx |<-上一个调用者的esi寄存器的值
+-----+
| ... |<-临时变量
+-----+
| ... |<-临时变量
+-----+
| esi |<-函数参数
+-----+
| eax |<-返回值
+-----+
| eip |<-返回地址
+-----+
| ebp |<-调用者的ebp
+-----+<-被调用者的ebp
|被调用者的栈帧 |
+-----+<-被调用者的esp
规律就是ebp(i)所指向的内存单元处存放着调用者的的ebp寄存器的值,即ebp(i+1)。
当断点设在test_backtrace第一句时,esp与ebp的差是0x1c个字节,实际上考虑到push %ebp
,一次调用所占用的栈的空间是0x20个字节。
可以验证:
在第一次调用test_backtrace(x=5)之前esp和ebp的值是:
第二次调用test_backtrace(x=4)之前:
esp和ebp相差0x1c。
查看此时栈的内容:
栈顶存储的就是递归调用结束后的下一句:
第二个就是递归调用参数,因为即将进行第二次调用test_backtrace,所以参数=4。
Q5:实现栈回溯程序mon_backtrace( ) (函数原型定义在kern/monitor.c)
要求的输出:
Stack backtrace:
ebp f010ff78 eip f01008ae args 00000001 f010ff8c 00000000 f0110580 00000000
kern/monitor.c:143: monitor+106
ebp f010ffd8 eip f0100193 args 00000000 00001aac 00000660 00000000 00000000
kern/init.c:49: i386_init+59
ebp f010fff8 eip f010003d args 00000000 00000000 0000ffff 10cf9a00 0000ffff
kern/entry.S:70: <unknown>+0
......
在上一问中分析过,eip是在call的时候压入的返回地址,就在ebp+4地址单元处,其他参数分别是ebp+8, ebp+12…所以只要知道当前运行程序的ebp寄存器的值就可以得到其他的值。
又因为ebp(i)所指向的内存单元处存放着调用者的的ebp寄存器的值,即ebp(i+1),所以调用者栈帧和被调用者栈帧通过ebp联系起来,可以从被调用者一直往上追溯调用者。
而要获取递归最深处的ebp则可通过给出的read_ebp()。
何时结束回溯?回顾调用过程:kernel将栈顶初始化为0,然后调用init_i386,在init_i386中调用test_backtrace,init_i386的栈顶就是0,所以当调用者的ebp的值是0时结束。
至于其他信息:当前正在执行的指令对应的文件名,所在行号,对应函数,以及在函数内的偏移:
实验提供了int debuginfo_eip(uintptr_t addr, struct Eipdebuginfo *info)函数,功能是输入一个指令地址addr,和一个Eipdebuginfo结构指针,该函数会查找addr处指令有关的信息,若查找成功则返回0,并把信息填充到该结构中,比如指令所在文件、行号、函数名、函数第一条指令地址等。
要理解并利用这个函数,要先理解stab表。
stab表
-
stab表是什么?
GCC把C语言源文件( ‘.c’ )编译成汇编语言文件( ‘.s’ ), 汇编器把汇编语言文件翻译成目标文件( ‘.o’ )。在目标文件中, 调试信息用 ‘.stab’ 打头的一类汇编指导命令表示, 这种调试信息格式叫’Stab’, 即符号表(Symbol table)。这些调试信息包括行号、变量的类型和作用域、函数名字、函数参数和函数的作用域等源文件的特性。
由此我们知道要求输出的信息就是调试信息。
-
那么是怎么把调试信息填入目标文件的呢?
在GCC编译源文件时, 如要生成Stab调试信息, 打开编译选项 ‘- gstabs’ 。汇编器处理 ‘.stab’ 打头指导命令, 把Stab中的调试信息填入 ‘.o’ 文件的符号表和串表(string table)中,最后由链接器链接所有的目标文件和有关的库生成可执行文件( ‘a.out’ ),这个可执行文件含有一个符号表和一个串表。
输入命令
objdump -h obj/kern/kernel
显示可执行文件kernal各个section的头部摘要信息。可以看到有.stab这个符号表和.stabstr这个串表。
进一步验证:
用如下命令:gcc -pipe -nostdinc -O2 -fno-builtin -I. -MD -Wall -Wno-format -DJOS_KERNEL -gstabs -c -S kern/init.c
即可生成包含init.c的调试信息的init.s。在init.s中找到init_i386部分:
然后对比objdump -G obj/kern/kernel
输出:
可看到调试信息是一一对应的,进一步验证在init.s中生成的.stab信息被放到了可执行文件.stab section中对应的位置。 -
.stab 这个表被链接文件kernel.ld原封不动的读入内存,debuginfo_eip函数是如何访问这些调试信息的呢?
打开kernel.ld:
PROVIDE在ld script中的作用是定义一个变量到.text中去, “.” 表示此处的地址
所以PROVIDE(__STAB_BEGIN__) = .
的效果就是定义__STAB_BEGIN__的值为当前的地址的值,即*(.stab)的值。
而kern/kdebug.c中用extern const struct Stab __STAB_BEGIN__[]
即可访问到这个.stab表的值。debuginfo_eip函数查找过程:
首先找到包含了该指令的源文件—>找到属于的函数—>指令在该源文件内的行号。
阅读代码可以看到源文件和函数名的查找都是通过调用stab_binsearch(stabs, region_left, region_right, type, addr)
进行二分查找,需要补充的是二分法查找行号的代码。
首先要关注的是type这个参数应该填什么。根据提示打开inc/stab.h,根据注释JOS只使用N_SO, N_SOL, N_FUN和N_SLINE 这几种types,所以查找行号type应该选择N_SLINE。
然后查看关于stab_binsearch函数的注释:*region_left 指向包含指令的stab起始地址,region_right 指向下一个stab之前的地址。如果region_left > *region_right, 则没有找到匹配的stab。如果匹配成功,只是锁定了stabs表中的一个项,而stab是一个结构体,定义如下:
还需要知道行号属于stab这个结构体的哪一个域。
事实上,这个结构体的定义和kernel这个可执行文件中的列是一一对应的。
而在汇编文件init.s中的stab格式如下:
.stabs “STRING”, TYPE, OTHER, DESC, VALUE
.stabn TYPE, OTHER, DESC,VALUE
并且在汇编文件中,当 TYPE=N-SLINE时,DESC表示源程序的语句行号。所以当stab被读入内存转换成一个结构体存储时,按照一一对应,n_desc与DESC对应,所以eip_line应该取stabs[lline].n_desc。
debuginfo_eip需要补充的完整代码如下:
stab_binsearch(stabs, &lline, &rline, N_SLINE, addr);
//如果匹配到stab
if(lline <= rline)
info->eip_line = stabs[lline].n_desc;
//查找失败
else
info->eip_line = -1;
在经过 debuginfo_eip函数的查找后,我们需要的信息和得到的结构体的内容的对应关系如下:
eip的文件名:info.eip_file
文件内的行号:eip_line
包含该指令的函数的名称:eip_fn_name
eip与函数的第一条指令的偏移量:eip - eip_fn_addr
并且根据提示,所以函数名应该下面这种方式输出:
printf格式字符串为打印非空终止的字符串(如STABS表中的字符串)提供了一种简便的方法:printf("%.*s",length, string)表示可打印字符string的最多length长度。
所以最终mon_backtrace()的实现代码是:
mon_backtrace(int argc, char **argv, struct Trapframe *tf)
{
struct Eipdebuginfo info;
//得到栈底指针ebp
uint32_t *ebp = (uint32_t *)read_ebp();
cprintf("Stack backtrace:\n");
//开始循环回溯
for(; ebp != 0; ebp = (uint32_t*)(*ebp))
{
//返回地址eip
uint32_t eip = *(ebp + 1);
cprintf(" ebp %x eip %x args %08x %08x %08x %08x %08x\n", ebp, eip, *(ebp + 2), *(ebp + 3), *(ebp + 4), *(ebp + 5), *(ebp + 6));
if(debuginfo_eip(eip,&info) == 0)
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);
}
return 0;
}
屏幕输出如下:
最终内存分布如下:
>0xffffffff|4GB
----------|32-bit memory mapped devices
0xf0110000|bootstacktop
0xf0100c04|debuginfo_eip
0xf0100b00|cprintf ==> 内核代码
0xf0100ac9|vcprintf
0xf0100883|mon_backtrace
0xf01000a6|i386_init
0xf0100040|test_backtrace
0xf010000c|entry kernel
0x00100000|**开始加载内核**
----------|
0x000F0000|BIOS ROM
----------|16-bit expansion ROMs
0x000C0000|
0x000A0000|VGA Display
----------|
0x00007d6b|**准备将控制权交给内核**
0x00007c45|调用bootmain ==> Low Memory 这里是boot loader引导程序
0x00007c2d|处理器切换为32-bit模式
0x00007c00|boot开始的位置
----------|
>0x00000000|0
references:
EFLAGS寄存器中状态标志
SeaBios源代码
elf文件格式分析
实模式和保护模式
GCC 生成的符号表调试信息剖析
jos学习笔记