环境
ubuntu 20.04 64位系统
part2的内容是从./lab/obj/kernel.img中引导我们的系统JOS,本文很大一部分内容都是翻译Lab1
课程的原地址:MIT-6.828
预先需要的知识:
虽然mit这课提供了相关知识,但是我个人觉得,对保护模式有过实操以后,看相关的保护模式代码确实很容易懂很多。
- 汇编,要知道基本的语句是干嘛的,比如说jne,jl等等,推荐王爽《汇编原理》
- 了解x86保护模式的基本工作,这里推荐《x86-实模式到保护模式》
- 基本的C语言操作,了解编译连接等基础知识
本文涉及的Exercise:
- exercise 3:这个是和调试相关的,做了
- exercise 4: 这个是和指针(pointer)相关的学习,没做
- exercise 5:这个是和了解链接地址相关的,做了
- exercise 6:查看加载内核前后内存的区别,做了
正文
软盘或者硬盘的存储单元都是512字节的,每512一个字节称为一个扇区。512字节是disk最小的传输粒度(transfer granularity),也就是说每次往硬盘写入的或者读出的数据都是512字节的整数倍的,比如说1024字节(2个扇区)。如果一个硬盘是可引导的(bootable),那么他的第一个扇区叫做引导扇区(MBR就是这个东西),在这个扇区内部存放着boot loader。当BIOS发现一个软盘(floppy)或者一个硬盘是可引导的时候,BIOS会将引导扇区 (boot sector)内的bootloeader复制到内存的0x7c00处(cs:ip=0x0000:0x7c00),然后BIOS会执行一个jmp 0x0000:0x7c00,接着跳转到了引导扇区的代码去执行。
PS:上面这段基本是翻译lab1 part2的说明,实际情况应该和这里说的有点点不一样。因为Boot sector只有很小的一点,就512字节。可能是因为这个lab1的代码也很少,所以直接在boot sector里面引导内核。科学的做法应该是boot sector里面的代码引导boot loader,boot loader去将内核引导到内存当中
接上面正文,用CD-ROMs内的引导扇区能做工作更多,因为CD-ROMs一个扇区有2048字节,但是再MIT6.828这门课里面,我们就是使用的传统的512字节位扇区单位的硬盘。在我们的代码中boot loader 由两个文件组合而成,分别是./boot/boot.S和./boot/main.c,boot loader主要完成下面两个工作:
- Boot loader将处理器从实模式切换到保护模式,因为在实模式之下处理器只能访问0-1MB的内存,切换到保护模式才使得处理可以访问更大的内存。这里不详细说明保护模式和实模式的区别,保护模式的编程可参考《X86-从实模式到保护模式》这本书
- 接下来boot loader会通过汇编指令从硬盘当中将kernel读入到内存当中。具体的和交互的工作比较无聊,等做完所有的系列看看有时间再来补充吧。
接下来是本节课的重点,就是几个exercise的实验
Exercise 3
使用GDB调试下boot loader,设置breakpoint在0x7c00处,逐步调试boot loead的代码。并且对照着反编译的obj/boot/boot.asm来查看当前正在运行的到那一步了。接着观察在./boot/main.c的当中代码的运行,包括函数readsect(),追踪readsect(),看看从硬盘读取kernel的代码。
并且回答下列问题:
- 在哪里代码开始运行32位的代码? 是什么让处理器从16位切换到32位运行?
- 哪一条语句是boot loader最后一条执行的?哪一条语句是kernel最先被加载的?
- 哪一条语句是kernel中最先执行的语句?
- boot loader如何知道内核有多少个扇区大小?它是怎么知道的?
接下来一个一个实验解决这些问题吧。
在哪里代码开始运行32位的代码? 是什么让处理器从16位切换到32位运行?
首先,先使用b *0x7c00
设置一个断点,然后按下键盘上的c,GDB就会之习惯到0x7c00处并且停在那里,这是GDB调试设置断点的方式,前面我们说到0x7c00这个地址就会开始执行boot loader的代码。结果如下:
GDB截图:
通过截图可以看到,此时GDB执行的语句正是我们boot loader的第一条语句--cli (我不再赘述具体汇编指令的作用)
接下来在GDB输入si,他就会执行接下去的命令。
接下来先对boot.S这个文件做一些介绍注释
#include
# .set是AT&T的语法,和x86的equ是一个意思,就是生命一些常量,在变异的时候会被直接替换为对应的值
.set PROT_MODE_CSEG, 0x8 # kernel code segment selector
.set PROT_MODE_DSEG, 0x10 # kernel data segment selector
.set CR0_PE_ON, 0x1 # protected mode enable flag
.globl start
start:
.code16 # Assemble for 16-bit mode
cli # 清除中断标志
cld # 字符串复制的方向,递增复制
# Set up the important data segment registers (DS, ES, SS).
xorw %ax,%ax # Segment number zero
movw %ax,%ds # -> Data Segment
movw %ax,%es # -> Extra Segment
movw %ax,%ss # -> Stack Segment
# Enable A20:
# For backwards compatibility with the earliest PCs, physical
# address line 20 is tied low, so that addresses higher than
# 1MB wrap around to zero by default. This code undoes this.
#下面是处理键盘的一些工作以及开启A20地址线
seta20.1:
inb $0x64,%al # Wait for not busy
testb $0x2,%al
jnz seta20.1
movb $0xd1,%al # 0xd1 -> port 0x64
outb %al,$0x64
seta20.2:
inb $0x64,%al # Wait for not busy
testb $0x2,%al
jnz seta20.2
movb $0xdf,%al # 0xdf -> port 0x60
outb %al,$0x60
# Switch from real to protected mode, using a bootstrap GDT
# and segment translation that makes virtual addresses
# identical to their physical addresses, so that the
# effective memory map does not change during the switch.
# 加载GDT到gdtr寄存器,并且将cr0寄存器的最低位设置为1
# 这样就开启了保护模式
lgdt gdtdesc
movl %cr0, %eax
orl $CR0_PE_ON, %eax
movl %eax, %cr0
#跳转到保护模式下的代码去执行
ljmp $PROT_MODE_CSEG, $protcseg
上面的那几行是和键盘处理相关的,具体的意思看这里键盘处理。上面这几行代码就完成了保护模式的开启。关键是以下几条代码:
lgdt gdtdesc
movl %cr0, %eax
orl $CR0_PE_ON, %eax
movl %eax, %cr0
回到我们最初的问题,是在哪里完成了实模式到保护模式的切换呢? 答案就是下面一行代码完成了实模式到保护模式的跳转。
ljmp $PROT_MODE_CSEG, $protcseg
在boot.S当中,其他的代码,稍微解释下
#下面几个SET都调用了宏函数,具体的实现在./lab/inc/mmu.h当中
gdt:
SEG_NULL # null seg
SEG(STA_X|STA_R, 0x0, 0xffffffff) # code seg
SEG(STA_W, 0x0, 0xffffffff) # data seg
上面是初始化了几个需要的描述符,在这里代码段描述符和数据段描述符都是平坦(flat)的,也就是说他们的地址范围都是从0~4GB。因此eip寄存器当中的地址实际上就是真正的物理地址。还需要注意一点就是,cs:ip组成的地址在保护模式下叫做线性地址(linear address),在引入页式内存管理的时候,那个地址叫做虚拟地址virtual address。在那种情况下,真正的物理地址需要经过如下的转换: virtual address > linear address > physical address。
好,回到正题,我们现在已经知道了,通过对cr0寄存器的设置,在使用jmp跳转,就进入了保护模式,现在我们来观察下实验结果:
在ljmp这条指令前面的地址是[0:7c2d],我们可以到此时的寻址方式还是最简单的8086的cs:ip模式,在Jmp之后,下一条指令mov,前面的地址已经是0x7c32,成功了,我们已经完成了从实模式到保护模式的跳转,第一个问题解决完毕。
boot loader最后一条执行的命令是什么? kernel第一条执行的命令是什么?
按照boot.S的代码,我们来看下最后两条非常关键的代码
movl $start, %esp
call bootmain
call bootmain,这个就会去执行main.C当中的代码。好了接下来我们还一个参照物,对照文件./lab/obj/boot/boot.asm来查看我们代码的运行。
首先运行的是main.c中的bootmain()这个函数,在bootmain()函数中去调用了readseg函数
readseg((uint32_t) ELFHDR, SECTSIZE*8, 0)
它有三个参数,所以将三个参数都压入到栈当中,ELFHDR=0x10000,SECTSIZE*8=4096,也就是对应的
十六进制0x1000,第三个参数是0。根据C calling convention,右边的参数先压栈,所以在汇编代码中
我们可以看到第一个压栈的是0,第二个0x01000,第三个0x10000。
readseg((uint32_t) ELFHDR, SECTSIZE*8, 0);
7d2e: 52 push %edx
7d2f: 6a 00 push $0x0
7d31: 68 00 10 00 00 push $0x1000
7d36: 68 00 00 01 00 push $0x10000
7d3b: e8 a2 ff ff ff call 7ce2
本来想打算调试这一串的所有汇编语句的,后来一想工作量太大了,要逐个去看寄存器的内容来判断参数的变化。我打算对main.c当中的语句做一个详细的注解。
//扇区的大小
#define SECTSIZE 512
//ELF header在内存当中临时存放的地址
#define ELFHDR ((struct Elf *) 0x10000) // scratch space
void readsect(void*, uint32_t);
void readseg(uint32_t, uint32_t, uint32_t);
void bootmain(void)
{
struct Proghdr *ph, *eph;
//将系统kernel文件中偏移量的4096字节数据读到0x10000处,
//这是因为readelf -l kernel显示第一个需要被加载的段的地址在偏移两0x1000处,说明前面全部都是
//ELF header的内容,所以我们先将ELF header的内容读到内存当中
readseg((uint32_t) ELFHDR, SECTSIZE*8, 0);
// is this a valid ELF?
if (ELFHDR->e_magic != ELF_MAGIC)
goto bad;
// load each program segment (ignores ph flags)
// e_phoff得到的是0x34,ELFHDR + ELFHDR->e_phoff = 0x10034,这个地址就是第一个program header的地址
ph = (struct Proghdr *) ((uint8_t *) ELFHDR + ELFHDR->e_phoff);
//eph是program table entry的个数, ELFHDR->e_phnum
//这里决定了要循环多少次
eph = ph + ELFHDR->e_phnum;
for (; ph < eph; ph++)
// p_pa is the load address of this segment (as well
// as the physical address)
//根据readelf -l kernel的输出结果,第一个段的offset = 0x1000,p_pa = 0x00100000,size = 0x07dac
// 所以我们要做的就是,系统镜像偏移0x1000处读取0x07dac个字节的数据到0x00100000
// 在readseg函数中,我们将offset转为真正的扇区号,因为镜像是存放在第1扇区开始的,所以
//这个是可以计算的
readseg(ph->p_pa, ph->p_memsz, ph->p_offset);
// call the entry point from the ELF header
// note: does not return!
//当所有的段都加载到内存的当中的时候,下面这条语句完成了,跳转到内核当中去执行
((void (*)(void)) (ELFHDR->e_entry))();
bad:
outw(0x8A00, 0x8A00);
outw(0x8A00, 0x8E00);
while (1)
/* do nothing */;
}
readelf -l kernel 结果,结果显示3 program headers,第一个program header的offset是在0x1000,说明0x1000前面都是elf header的内容:
Elf file type is EXEC (Executable file)
Entry point 0x10000c
There are 3 program headers, starting at offset 52
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
LOAD 0x001000 0xf0100000 0x00100000 0x07dac 0x07dac R E 0x1000
LOAD 0x009000 0xf0108000 0x00108000 0x0b6a8 0x0b6a8 RW 0x1000
GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RWE 0x10
Section to Segment mapping:
Segment Sections...
00 .text .rodata .stab .stabstr
01 .data .got .got.plt .data.rel.local .data.rel.ro.local .bss
02
在解释一下另外两个函数
void readseg(uint32_t pa, uint32_t count, uint32_t offset)
{
uint32_t end_pa;
end_pa = pa + count;
// round down to sector boundary
//这里是因为pa不一定都是512字节对齐的,我们将pa做一个512字节对齐
//下面进行一个举例,例如pa=700,~(512-1) = 0x1110_0000_0000
//700 & 0x1110_0000_0000 = 0x200 = 512,这样我们就做到了对齐
pa &= ~(SECTSIZE - 1);
//使用偏移量来计算所要读取的扇区是哪个
//,比如说上面的offset = 0x1000,0x1000/512+1 = 9,就是读取9号扇区。
//扇区号+1这个应该是这样理解的,
//硬盘扇区默认就是从1扇区开始计算的。
//如果我们要读取511字节的内容,511/512=0,肯定是不对的。所以要+1
offset = (offset / SECTSIZE) + 1;
// If this is too slow, we could read lots of sectors at a time.
// We'd write more to memory than asked, but it doesn't matter --
// we load in increasing order.
while (pa < end_pa) {
// Since we haven't enabled paging yet and we're using
// an identity segment mapping (see boot.S), we can
// use physical addresses directly. This won't be the
// case once JOS enables the MMU.
readsect((uint8_t*) pa, offset);
//物理地址+512字节
pa += SECTSIZE;
//读下一个扇区
offset++;
}
}
void waitdisk(void)
{
// wait for disk reaady
while ((inb(0x1F7) & 0xC0) != 0x40)
/* do nothing */;
}
void readsect(void *dst, uint32_t offset)
{
// wait for disk to be ready
waitdisk();
//使用LBA模式的逻辑扇区方式来寻找扇区,这里和硬件相关
//就暂且不要管好了,涉及到的硬件细节太多了,确实很麻烦
outb(0x1F2, 1); // count = 1
outb(0x1F3, offset);
outb(0x1F4, offset >> 8);
outb(0x1F5, offset >> 16);
outb(0x1F6, (offset >> 24) | 0xE0);
outb(0x1F7, 0x20); // cmd 0x20 - read sectors
// wait for disk to be ready
waitdisk();
// read a sector
//一次读取的单位是四个字节,所以循环sectorsize/4 = 128次
insl(0x1F0, dst, SECTSIZE/4);
}
好了,终于要回归问题了,首先回答boot loader最后执行的一条指令是什么? 上面已经说了 ((void (*)(void)) (ELFHDR->e_entry))();
这条语句会跳转到内核当中去执行,那么这条语句就是boot loader最后一条语句了。我们来验证一下。
如图call 0x10018这里存放的就是entry,0x10018这个地址是这样得到的,在ELF header 当中偏移0x18存放的是entry,前面我们将ELF header放到了0x10000处,所以自然entry的地址就是0x10018了。在0x10018中存放的是0x10000c,call命令执行完后,我们就到了0x10000c执行了。我们使用
readelf -a kernel
命令查看以下:
用mit网站上哪个objdump -x也一样,我们可以看到,我们的镜像的entry确实是0x10000c。让我们乘胜追击,解决下一个问题,kernel当中第一句执行的命令是什么?
用一下上面的图,我们可以看到内核当中第一句执行的命令是movw 0x1234 0x472,如下图所示:
第三个问题有点多余,第二个搞定了第三个就懂了,下面我们回答第三个问题
boot loader是如何知道内核占据多少个扇区的?
其实答案就在上面的代码里面,在函数readseg()当中,我们使用条件pa < end_pa
来判断是否还要继续读取数据,如果条件成立就读下一个扇区的内容。并且我们使用offset来控制需要读的扇区号。
Exercise 5
回答下列问题:
- 修改boot/Makefrag中的link address,继续跟踪boot loader的一些代码,看看boot loader会做一些什么错误的事情呢?
一个程序由普通的文本文件.c到可执行文件,中间需要经过一个很重要的过程就是链接(link),说实话我对于链接需要做的工作为并不了解很多,我就指出一点比较重要的在汇编程序当中,标号的地址会因为连接地址的不同而发生变化,暂时不理解没关系,首先需要得到这样一个概念链接地址关乎着标号的地址,对于普通的汇编代码并没有直接的影响
开始实验:
首先先看下原来的boot/Makefrag文件中的内容(截取了关键的部分):
$(OBJDIR)/boot/boot: $(BOOT_OBJS)
@echo + ld boot/boot
$(V)$(LD) $(LDFLAGS) -N -e start -Ttext 0x7c00 -o [email protected] $^
$(V)$(OBJDUMP) -S [email protected] >[email protected]
$(V)$(OBJCOPY) -S -O binary -j .text [email protected] $@
$(V)perl boot/sign.pl $(OBJDIR)/boot/boot
上面的,-Ttext说明了链接的地址,即0x7c00,先用gdb调试下看下未修改的情况:
lgdt命令应该是将gdtdesc这个地址加载道gdtr寄存器,由于我这里有点问题,我运行的看不到gdtdesc标号的地址,如下:
但是这里的意思是如此的,我用其他相类似的地方来描述这个问题,我选了上面键盘处理的jz指令来说明:
可以看到jz经过编译后,就变成了jne指令了,跳转的目的地址就是0x7c14。好我们现在将上面boot/Makefrag当中的0x7c00修改为0x7e00在来看看这里的变化,注意别忘了make clean,然后重新make,如下图所是,不知道是不是因为系统环境的关系,gdb调试中Jne跳转的标号没有发生变化,但是jmp到保护模式确实发生了异常,我没有研究原因。但是在./boot/boot.asm当中标号的地址已经发生了变化,下面可以看到已经由0x7c14变成了0x7e14:
另外,因为我们现在link address的不同,所以lgdt加载的标号是错误的地址,所以应该无法正确的进入保护模式,实验结果观察到也确实是如此,结果如下图:
可以看到,现在根本无法正常进入保护模式,回到Bios的代码去了。所以我们的实验成功了!
Exercise 6
使用GDB调试命令,x/Nx,N是要查看的字(word)的个数,warning:字(word)的长度没有一个统一的标准,在GNU汇编当中(意思就是是说在AT&T的语法下,其实x86也是一样的),一个字(word)的长度应该是两个字节。问题是:查看以下在进入boot loader的时候,0x100000地址出的内容,以及进入内核后0x100000处的内容。
实际上这个问题并不需要我们去认真的debug,只要认真思考下,在刚进入boot loader的时候,x100000这个地方应该是空空如也的,因为内核还没有被加载。根据readelf -l kernel的结果,我们知道内核被加载到的地方是0x100000。所以当刚进入到内核后,0x100000这个地方就是内核的代码了。
结束
好了,到这里为止,lab 1 part2所有的exercise都已经做完了,通篇下来,想必对加载elf文件的内核有了很大的了解。接下来要做的就是lab 1 part3 了!!我在这个实验当中也有一些不了解的地方,比如说上面的lgdt指令和预期的结果不同。希望知道如何解决的老铁门相互交流,另外如果发现有错误的地方,希望各位提醒我修改~