Lab1-Exercise1~7
前置知识
GDB调试命令见:https://blog.csdn.net/leikezhu1981/article/details/44831999
Exercise1
不幸的是,本书中的例子是为NASM汇编程序编写的,而我们将使用GNU汇编程序。NASM使用所谓的Intel语法,而GNU使用AT&T语法。虽然在语义上是等价的,但程序集文件可能会有很大的不同,至少表面上是不同的,这取决于所使用的语法。幸运的是,两者之间的转换非常简单,在Brennan's Guide to Inline Assembly中有介绍。
我们分析本实验汇编代码boot.asm(GNUmakefile编译引导加载程序后创建的引导加载程序的反汇编)的时候用的应该是AT&T语法:目标操作数在源操作数的右边
在AT&T语法中 mov ebx, eax 是把内存地址为ebx处的数据赋给eax;lea ebx,eax是把ebx的值直接赋给eax
Exercise2
使用 GDB 的si(Step Instruction)命令跟踪 ROM BIOS 以获取更多指令,并尝试猜测它可能在做什么。您可能需要查看 Phil Storrs I/O Ports Description以及6.828 参考资料页面上的其他资料 。无需弄清楚所有细节 - 只需了解 BIOS 首先执行的操作的总体思路。进入gdb调试页面后输入si可以让qemu执行下一条指令并显示在屏幕上。
具体指令:
[f000:fff0] 0xffff0: ljmp $0xf000,$0xe05b # jump to 0xfe05b
[f000:e05b] 0xfe05b: cmpl $0x0,%cs:0x6ac8 # compare 0xf6ac8 with 0
[f000:e062] 0xfe062: jne 0xfd2e1 # if != 0, jump to 0xfd2e1
[f000:e066] 0xfe066: xor %dx,%dx # set %dx = 0
[f000:e068] 0xfe068: mov %dx,%ss # set stack segment = 0
[f000:e06a] 0xfe06a: mov $0x7000,%esp # set stack pointer = 0x7000
[f000:e070] 0xfe070: mov $0xf34c2,%edx # set %edx = 0xf34c2
[f000:e076] 0xfe076: jmp 0xfd15c
[f000:d15c] 0xfd15c: mov %eax,%ecx
[f000:d15f] 0xfd15f: cli # clear interrupt flag
[f000:d160] 0xfd160: cld # clear direction flag
[f000:d161] 0xfd161: mov $0x8f,%eax # disable NMI
[f000:d167] 0xfd167: out %al,$0x70
[f000:d169] 0xfd169: in $0x71,%al # read CMOS 0xf
[f000:d16b] 0xfd16b: in $0x92,%al # enable A20 address line
[f000:d16d] 0xfd16d: or $0x2,%al
[f000:d16f] 0xfd16f: out %al,$0x92
[f000:d171] 0xfd171: lidtw %cs:0x6ab8 # load interrupt descriptor table from 0xf6ab8
[f000:d177] 0xfd177: lgdtw %cs:0x6a74 # load global descriptor table from 0xf6a74
[f000:d17d] 0xfd17d: mov %cr0,%eax # set PE flag to 1, enable protected mode
[f000:d180] 0xfd180: or $0x1,%eax
[f000:d184] 0xfd184: mov %eax,%cr0
[f000:d187] 0xfd187: ljmpl $0x8,$0xfd18f # enter protected mode
上述指令来自:MIT 6.828 labs walkthroughs: Lab 1 Booting a PC #OS - Qiita
为什么需要跳转到物理地址0xfe05b位置。这个也比较好理解,因为0xffff0比较接近0xfffff这个物理内存地址的最顶端,这么少的内存空间做不了什么事,这时候就转移一下代码的所在位置。
根据lab的描述,BIOS 实际上的操作如下:
当 BIOS 运行时,它会设置中断描述符表并初始化各种设备,如 VGA 显示器。这就是在 QEMU 窗口中看到的 "Starting SeaBIOS" 消息的来源。
在初始化 PCI 总线和 BIOS 所知的所有重要设备之后,它会搜索可引导的设备,如软盘、硬盘或 CD-ROM。最终,当找到可引导的磁盘时,BIOS 会从磁盘中读取引导加载程序并将控制权转移给它。
这里有一个问题:BIOS如何切换回实模式并将控制权交给引导加载程序?
Exercise3
练习内容
该练习主要分为两个部分:
1.在0x7c00处(启动扇区加载到内存中的首地址)设置断点,此后跟踪boot.S中执行的每一步,对比boot.S,boot.asm(位于obj/boot/boot.asm,是系统为你反汇编的),及使用x/i命令获得的反汇编代码,
2.跟踪到main.c中的Bootmain()函数及其子函数readSect()函数中,找到每一条c语句对应的汇编指令,找出读取内核文件到内存的for循环的开始指令和结束指令,以及循环结束后执行的语句。
练习第一部分
启动两个 terminal,第一个 执行 make qemu-nox-gdb,第二个执行 make gdb ,接下来开始调试。
在第二个窗口执行 b *0x7c00 ,在执行 c 将代码执行到断点处。接下来执行 x/Ni 将后续的 N 条指令反汇编出来(保存在了gdbForBootAndMain),并与 boot.asm 、boot.S 进行比较。
比较 gdbForBootAndMain,boot.S,boot.asm 三个文件,暂时发现有以下不同
- 可见这三者在指令上没有区别,只不过在源代码中,我们指定了很多标识符比如set20.1,.start,这些标识符在被汇编成机器代码后都会被转换成真实物理地址。比如set20.1就被转换为0x7c0a,那么在obj/boot/boot.asm中还把这种对应关系列出来了,但是在真实执行时,即第一种情况中,就看不到set20.1标识符了,完全是真实物理地址。
练习第二部分
在boot.asm中可以看到每一条c语句对应的汇编指令,所以直接对boot.asm进行分析
BIOS完成各种设置后调用了bootmain函数:
call bootmain
7c45: e8 cb 00 00 00 call 7d15 <bootmain>
call命令会将 00007c4a(下一条指令的所在位置)压入堆栈用以返回,且将bootmain的地址加载到EIP寄存器,此后EIP寄存器(eip寄存器存储着我们cpu要读取指令的地址)的值变成bootmain的起始地址 7d15。
跳转到7d15后:
00007d15 <bootmain>:
{
7d15: 55 push %ebp
7d16: 89 e5 mov %esp,%ebp
7d18: 56 push %esi
7d19: 53 push %ebx
这个四个命令涉及到汇编的函数调用,详情见连接。
以上这些都属于过程调用的常见指令,下面进入bootmain的c语言程序部分。
说明一下分析路径:bootmain--->readseg((uint32_t) ELFHDR, SECTSIZE*8, 0);--->readsect
首先看第一条C语言指令
readseg((uint32_t) ELFHDR, SECTSIZE*8, 0);
7d1a: 6a 00 push $0x0
7d1c: 68 00 10 00 00 push $0x1000
7d21: 68 00 00 01 00 push $0x10000
7d26: e8 b1 ff ff ff call 7cdc <readseg>
前三条指令的目的是为了把三个输入参数压入栈帧之中,以供readseg子过程访问,这三个参数分别是pa,count和offest
然后调用readseg函数,跳转到7cdc开始执行,从7cdc到7cf1(boot.asm)做了四件事:
- 保存调用过程bootmain的栈帧信息,保存被调用者寄存器的值(和前面7d15-7d19一样)
- 根据count计算处段结束地址
- 根据offest计算出目标段起始在哪个扇区
- 将pa设置为512字节对齐(低九位置0)
这里的汇编就不分析了(lazy)
从7cf7终于开始进入循环并且调用readsect,进行真正的磁盘读写操作:
while (pa < end_pa) {
7cf7: 39 f3 cmp %esi,%ebx
7cf9: 73 12 jae 7d0d <readseg+0x31>
readsect((uint8_t*) pa, offset);
7cfb: 57 push %edi
7cfc: 53 push %ebx
offset++;
7cfd: 47 inc %edi
pa += SECTSIZE;
7cfe: 81 c3 00 02 00 00 add $0x200,%ebx
readsect((uint8_t*) pa, offset);
7d04: e8 73 ff ff ff call 7c7c <readsect>
offset++;
7d09: 58 pop %eax
7d0a: 5a pop %edx
7d0b: eb ea jmp 7cf7 <readseg+0x1b>
}
7d0d: 8d 65 f4 lea -0xc(%ebp),%esp
7d10: 5b pop %ebx
7d11: 5e pop %esi
7d12: 5f pop %edi
7d13: 5d pop %ebp
7d14: c3 ret
这段循环的作用是把从offest扇区开始,每次读一个扇区到内存pa处,每读取一个扇区,变量pa=pa+512,直到变量pa>end_pa,循环结束。
**
- 7cf7~7cf9 :这里就是判断循环条件的地方,其中%esi存放的是Program Header Table表尾地址(end_pa),%ebx存放的是当前访问到Program Header Table(pa)中的位置。jae命令是两数比较,CF=0,目标操作数大于等于源操作数则跳转。则当pa>end_pa时发生跳转,跳转到7d0d,意味着循环结束。
- 7cfb~7fcf :分别将offset和pa两个参数入栈,为readsect函数调用做准备。(offset定义在7ce1,pa定义在7ce8)
- 7cfd :offset++
- 7cfe :pa += SECTSIZE(SECTSIZE为512,512的16进制为0x200)
- 7d08 :调用readseg函数
- 7d09~7d0a :当前的栈顶元素是上一轮while(pa < end_pa)循环中传递给readsect的两个输入参数,由于在下一轮调用时,这两个参数会改变,所以先把这两个值pop出来。
- 7d0b :跳转到7cf7进行条件判断,满足则跳出循环,不满足则继续循环
- 7d0d~7d14 :恢复寄存器和bootmain的栈帧,回到bootmain
有心情再继续分析readsect,即在7d08中跳转到7c7c之后的代码。。。
Exercise4
阅读K&R的《The C Programming Language》5.1~5.5章节,然后下载pointer .c的代码,运行它,并确保您理解所有打印值的来源。
详情见pointer.c
Exercise5
exercise5: 在 boot/Makefrag 文件中更改链接地址为错误的值,重新构建引导加载程序,然后追踪指令以观察链接地址不正确的后果。随后,将链接地址恢复为正确的值,并重新构建引导加载程序,确保其按预期功能。
在boot/Makefrag 的$(OBJDIR)/boot/boot: $(BOOT_OBJS)中,我们生成了obj/boot/boot二进制文件,并且使用了 -Ttext 0x7C00 选项,将boot的.text段(程序的实际执行指令)起始地址指定为0x7C00。打开boot的反汇编文件boot.asm可以见到,程序的实际执行指令正是从0x7C00开始:
#链接地址为0x7c01
00007c00 <start>:
.set CR0_PE_ON, 0x1 # protected mode enable flag
.globl start
start:
.code16 # Assemble for 16-bit mode
cli # Disable interrupts
7c00: fa cli
cld # String operations increment
7c01: fc cld
......
接下里,修改boot/Makefrag中的$(OBJDIR)/boot/boot: $(BOOT_OBJS),将-Ttext 0x7C00改为-Ttext 0x7C01试试:
$(OBJDIR)/boot/boot: $(BOOT_OBJS)
@echo + ld boot/boot
$(V)$(LD) $(LDFLAGS) -N -e start -Ttext 0x7C01 -o $@.out $^
$(V)$(OBJDUMP) -S $@.out >$@.asm
$(V)$(OBJCOPY) -S -O binary -j .text $@.out $@
$(V)perl boot/sign.pl $(OBJDIR)/boot/boot
然后运行make clean ,后运行 make qemu,观察boot.asm的变化:
#链接地址为0x7c01
00007c01 <start-0x3>:
7c01: 90 nop
7c02: 90 nop
7c03: 90 nop
00007c04 <start>:
.set CR0_PE_ON, 0x1 # protected mode enable flag
.globl start
start:
.code16 # Assemble for 16-bit mode
cli # Disable interrupts
7c04: fa cli
cld # String operations increment
7c05: fc cld
可以发现多了三行'nop'指令,这是因为考虑到一些体系结构要求指令在内存中的特定地址对齐。假设某个处理器要求指令在4字节边界上对齐,如果生成的机器代码的起始地址不是4的倍数,就可能导致性能下降。为了满足这个对齐要求,编译器可能会插入一些 nop 指令。
而且,make quem之后会发现卡住了:

卡在这里了。
接下来我们对其进行调试,看看里面发生了什么。
在一个终端运行make qemu-gdb,新建一个终端运行make gdb。
+ symbol-file obj/kern/kernel
(gdb) b *0x7c00
Breakpoint 1 at 0x7c00
(gdb) c
Continuing.
[ 0:7c00] => 0x7c00: nop
Breakpoint 1, 0x00007c00 in ?? ()
(gdb) ni
[ 0:7c01] => 0x7c01: nop
0x00007c01 in ?? ()
(gdb) ni
[ 0:7c02] => 0x7c02: nop
0x00007c02 in ?? ()
(gdb) ni
[ 0:7c03] => 0x7c03: cli
0x00007c03 in ?? ()
(gdb) ni
[ 0:7c04] => 0x7c04: cld
0x00007c04 in ?? ()
...............................................中间太长省略
0x00007c26 in ?? ()
(gdb) ni
[ 0:7c29] => 0x7c29: or $0x1,%eax
0x00007c29 in ?? ()
(gdb) ni
[ 0:7c2d] => 0x7c2d: mov %eax,%cr0
0x00007c2d in ?? ()
(gdb) ni
[ 0:7c30] => 0x7c30: ljmp $0x8,$0x7c36
0x00007c30 in ?? ()
(gdb) ni
[ 0:7c30] => 0x7c30: ljmp $0x8,$0x7c36
0x00007c30 in ?? ()
(gdb)
可以发现实际执行地址和asm文件中的执行地址不一样,而且在0x7c30处卡住。这是因为系统并没有将boot按照其要求放在0x7c01处开始运行,而是放在了0x7c00。这因为在x86架构中,当计算机启动时,BIOS(Basic Input/Output System)会加载引导扇区(Boot Sector)的内容到内存的 0x7C00 地址,所以我们的-Ttext 0x7C01并没有起到作用。
当执行ljmp $0x8,$0x7c36的时候,实际上物理地址0x7c36对应的是asm文件中0x7c35的内容,是一个不完整的指令,错误由此开始产生。
完成exercise5后记得将链接地址改回来。
Exercise6
在BIOS启动boot loader和进入内核时,地址0x00100000处的连续8个字有什么不同?为什么?回答这个问题不需要运行qemu,请思考这个问题。
虽然题目要求是“just think” 但是我看到exercise6的时候已经开始发懵了,还是借练习6重新梳理一下吧。
首先 boot loader 包括两个文件:boot.S和main.C,当jos启动后,首先运行的是BIOS,然后BIOS调用boot.S,boot.S调用main.C。
系统启动流程:
- 系统启动自动执行BIOS,BIOS初始化设备。
- BIOS将磁盘的首512个字节(也就是第一个扇区)读取到内存0x7c00处(第一个扇区存放的是boot.S和main.C),并将系统控制权交给boot.S和main.C
- boot.S将CPU工作模式从实模式切换到保护模式,关闭了系统中断,并且在最后调用main.C中的bootmain函数
- main.C为了将内核的ELF文件读入内存,首先将磁盘首4096个字节(也就是第一页)读取到内存0x10000处(暂存地址),然后判断ELF文件是否一个合法的ELF文件,然后根据PROGRAM HEADER TABLE将指定的 segments 加载到内存指定的位置中去(也就是加载内核),最后跳转0x10000c进入内核。
回到练习,我们要在0x7c00和0x10000c处设置断点,并观察此时0x100000开始8个字节的变化。
为什么要观察0x100000的内存变化呢?猜测应该是系统在加载ELF segment的时候将部分segment加载到了这个地址,所以这个地址处8个字节的变化应该是从0变成了有内容。
Exercise7
使用QEMU和GDB跟踪到JOS内核,并在movl %eax, %cr0处停止。检查movl %eax, %cr0 执行前后0x00100000和0xf0100000的内存数据以及它们的变化。使用 b命令 在kernel的入口处 0x10000c 打断点,使用 c 命令 执行到断点处。然后使用 si 命令 单步执行到 movl %eax, %cr0 处停止。使用 x 命令查看0x00100000和0xf0100000的内存数据。
执行 movl %eax, %cr0 ,然后再次使用使用 x 命令查看0x00100000和0xf0100000的内存数据。
如下图所示:
。
可以看出在执行 movl %eax, %cr0 后 0xf0100000处的数据发生了变化:由0变成了0x1badb002。
首先明确cr0是什么。cr0全称是control register 0.下面是wiki中的解释。
The CR0 register is 32 bits long on the 386 and higher processors. On x86-64 processors in long mode, it (and the other control registers) is 64 bits long. CR0 has various control flags that modify the basic operation of the processor.

查看 eax 寄存器的值

movl %eax, %cr0 将0x80010011赋值给cr0寄存器,且0x80010011转换成二进制为10000000000000010000000000010001,末位为1,对应图表中的PE,这行命令执行之后系统进入了保护模式。(在boot.S中也执行了一样的命令,为什么这里又执行了一次?)
练习的第二个问题:在新映射建立后,如果映射没有到位,第一个无法正常工作的指令是什么?注释掉 kern/entry.S 中的movl %eax, %cr0。S,追踪它,看看你是否正确。
把该指令删除后重新编译并使用gdb调试

该指令就位于 movl %eax, %cr0 之后:

发现运行到0x10002a的跳转命令时,程序崩溃了。这是因为没有开启保护模式,没有地址映射,操作系统并没有将0xf010002c映射到真实物理地址,所以0xf010002c内存中存储的是空值导致跳转失败。

浙公网安备 33010602011771号