前言
这篇文章是接上文的内容,依然是对Lab1的记录
如何启动保护模式
要启动保护模式,需要完成以下三个步骤:
- 在内存中加载GDT,设置GDTR
- 设置CR0寄存器的PE(Protected Enable)位,启用保护模式
- 通过一个far jump来重置段寄存器
内存中加载GDT
注意看boot.S的末尾部分
# Bootstrap GDT
.p2align 2 # force 4 byte alignment
gdt:SEG_NULL # null segSEG(STA_X|STA_R, 0x0, 0xffffffff) # code segSEG(STA_W, 0x0, 0xffffffff) # data seggdtdesc:.word 0x17 # sizeof(gdt) - 1.long gdt # address gdt
gdt标签后的内容就是GDT的内容,写在汇编语言里的GDT会被编译到二进制可执行文件中,而这个二进制可执行文件会被原封不动地加载到内存中,从而实现了把GDT加载到内存中这个目的。
gdtdesc标签后的数据是用于设置GDTR寄存器,这里的word是2个字节,long是4个字节,具体可见下图的Intel白皮书对于LGDT指令的描述
举例来说,如果gdt标签开始的物理地址是0x7D00
,由于每个段描述符占8字节,所以gdtdesc的地址是0x7D18
。
那么0x7D18处往后6字节的数据就是
0x17 0x00 0x00 0x7D 0x00 0x00
(考虑到小端存放)
而从0x7D00
到0x7D18
这块内存区域里面存放的就是GDT的内容。
所以只需要把0x7D18
,即gdtdesc这个标签的地址传递给LGDT指令,就可以实现GDT的设定,即指定GDT的基地址为0x7D00
,并且大小为0x18
。
所以在进入保护模式的汇编代码里面,有这么一句指令:
lgdt gdtdesc
设置CR0寄存器
CR系列寄存器是CPU专用的控制寄存器(Control Register),其中CR0的第0位是PE位,当这一位是1的时候表示CPU处于保护模式。
所以才有下面这段汇编代码:
movl %cr0, %eaxorl $CR0_PE_ON, %eaxmovl %eax, %cr0
这里面常量CR0_PE_ON=0x1
far jump重置段寄存器
寻址问题
在设置完CR0寄存器之后,CPU就已经在保护模式运行了,但是这个时候CS段寄存器仍然是之前的值,这个时候就有个问题了:观察下面这段代码,为什么CPU依然能够寻址到并能执行最下面那条ljmp
指令呢?
# 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.lgdt gdtdescmovl %cr0, %eaxorl $CR0_PE_ON, %eaxmovl %eax, %cr0# Jump to next instruction, but in 32-bit code segment.# Switches processor into 32-bit mode.ljmp $PROT_MODE_CSEG, $protcseg
这里我给出一个我个人的猜想,如果有错误还恳请批评指正:
CPU很懒,对代码进行访存的时候,使用的都是CS寄存器用户不可见的部分,这部分在启动的时候默认是0,所以段基址部分也就是0,所以CPU就以0位代码段基址来对代码进行寻址(即0+IP寄存器的值);而恰好,运行这段汇编代码的时候,就是以0位段基址来运行的,所以不会产生冲突。
(后来我去stackoverflow上提出了这个问题,热心网友的解答也和这个猜想大致重合,意思是设置CR0寄存器不会更新CS寄存器里面的不可见部分,所以CPU仍然按照之前的段基址来寻址的,问题链接:https://stackoverflow.com/questions/78105088/)
指令长度问题
此外,还有一个问题:这里的ljmp
指令仍然是在.code16
的管辖范围的,既然CR0寄存器已经设置了,那么为什么这里还能正常执行这条ljmp
指令呢?
首先查看反汇编后的boot.asm,发现这条指令的完整十六进制是EA 32 7C 08 00
通过查阅Intel白皮书,发现以EA开头的JMP指令有2种形式,如下图:
那么CPU是怎么确定该如何解读EA开头的JMP指令呢?这个答案还得去白皮书中找。
在Volume 2的Chapter 3.1.1.3记录了怎么解读这两者的区别,如下图:
这其中提到了一个关键词:operand-size attribute。这部分的内容在Volume 1 Chapter 3.6中有提到,如下图所示:
这段话的意思就是:CPU选择哪种解读方式取决于段描述符里面的D字段(当处于保护模式下时,如果处于实模式,那么始终都是取operand-size为16),如果D字段被设置了,那么operand-size就取32,否则就取16。
我们可以看一下代码段的段描述符:FFFF 0000 009A CF00
,其中D位是1。但是按照上文的说法,在设置了CR0寄存器以后,CS寄存器的隐藏部分并未被改动,所以这个时候用的还是实模式下的“段描述符”,所以这个时候是按照operand-size为16来解读指令的。
重置CS寄存器
回归正题,我们最好还是使用段选择符重新设置一下CS和DS,SS寄存器,所以设置CR0之后就紧跟了一个far jump,这条指令中,段选择符PROT_MODE_CSEG
是个提前定义好的常量,其值为0x8
,根据下图的段选择符结构进行解读
可以得知,这个段是GDT的下标为1的段,即第二个段,回顾一下之前gdt的结构,可以发现这个段只有执行和读的权限,是很适合做代码段的。
在执行这个ljmp
过程中,CPU会根据这个段选择符去加载对应的段描述符,从而达到设置CS寄存器的目的。
关于GDT的设计
如果仔细观察GDT的内容,就会发现,代码段和数据段的基址都是0
,大小都是0xffffffff
。这样设计其实是为了方便,因为在这样的设定下,访问的逻辑地址就是线性地址,就不必要考虑分段了。例如,MOV 0X7C04 %eax
,这条指令操作的内存空间的物理地址就直接是0x7C04
(在不考虑分页的情况下)
一些反汇编错误
由于boot.S同时有16位和32位的指令,而反汇编时都是按照32位来解读的,所以反汇编出来的文件obj/boot/boot.asm
里面是存在一些错误反汇编的。
例如,下图所示的反汇编结果:
根据Intel白皮书,LGDT的指令如下图
0x0F 0X01
和上图里的对应上了,这里如果按照16bit的解读方式的话,如下表
后面应该是紧跟一个16位的立即数,对应二进制指令里面的0x64 0x7C
,而后面的那个0x0F
是下一条指令的Opcode了。
关于白皮书的解读方法可以参考这篇文章:https://www.cnblogs.com/scu-cjx/p/6879041.html,主要需要注意Volume 2中的2.1.5和3.1.1里面的内容,这些内容对于解读指令会起到重要作用。
关于指令的解读,再补充一个例子,如下图
这里的反汇编是出错了的。看jmp的指令说明
这里很明显应该对应EA cd
的情况,看3.1.1.3这一章,如下图
ptr16:16应该是视为一个oprand,所以0x32 0x7C 0x08 0x00
是以小端存放的操作数,高2字节0x0008
是段选择符,0x7C32
是段内的偏移量。
为什么要关中断?
因为原有的中断向量表是实模式下使用的,其寻址方式也是实模式的方式,在进入保护模式以后,就不能使用实模式下的地址进行寻址了,所以必须关中断。
main.c的分析
在boot.S中,使用call bootmain
指令跳转到main.c里面的bootmain函数中(通过链接器找到bootmain这个符号的地址)。所以我们从bootmain入手分析。
代码段如下:
void
bootmain(void)
{struct Proghdr *ph, *eph;// read 1st page off diskreadseg((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)ph = (struct Proghdr *) ((uint8_t *) ELFHDR + ELFHDR->e_phoff);eph = ph + ELFHDR->e_phnum;for (; ph < eph; ph++)// p_pa is the load address of this segment (as well// as the physical address)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 */;
}
可以大致分析出bootmain是尝试借助elf文件格式,从磁盘中读取一个elf可执行程序并加载到内存中,最后跳转到这个程序的入口地址。
下面来具体分析一下程序的实现:
首先看看引用头文件部分:
#include <inc/x86.h>
#include <inc/elf.h>
elf.h定义了elf文件的结构,这个不再赘述。x86.h定义和实现了一些x86硬件相关的函数,这些函数通常用汇编语言实现,例如:向一个IO端口输出数据的函数实现如下:
static inline void
outb(int port, uint8_t data)
{asm volatile("outb %0,%w1" : : "a" (data), "d" (port));
}
接下来是两个常量的定义:
#define SECTSIZE 512
#define ELFHDR ((struct Elf *) 0x10000) // scratch space
其中SECTSIZE
是一个扇区(Sector)的大小,ELFHDR
是这个elf头在内存中的物理地址。
接下来是2个用于读取磁盘的辅助函数:
void readsect(void*, uint32_t);
void readseg(uint32_t, uint32_t, uint32_t);
其中readsect
是读取磁盘上的一个扇区,readseg
是读取一定大小的数据(借助readsect
来实现)
具体而言,readsect
的实现如下所示:
void
waitdisk(void)
{// wait for disk reaadywhile ((inb(0x1F7) & 0xC0) != 0x40)/* do nothing */;
}void
readsect(void *dst, uint32_t offset)
{// wait for disk to be readywaitdisk();outb(0x1F2, 1); // count = 1outb(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 readywaitdisk();// read a sectorinsl(0x1F0, dst, SECTSIZE/4);
}
(关于x86磁盘访问部分可以参考博客文章https://blog.csdn.net/fjlq1994/article/details/49472827)
这里的waitdisk
做的事就是进行循环等待,直到0x1F7端口的第7位不再是1,这时候表示磁盘读取任务结束了。
readsect
函数则是写IO端口,把第offset个扇区的数据读入到内存区域dst中
readseg
的实现如下:
// Read 'count' bytes at 'offset' from kernel into physical address 'pa'.
// Might copy more than asked
void
readseg(uint32_t pa, uint32_t count, uint32_t offset)
{uint32_t end_pa;end_pa = pa + count;// round down to sector boundarypa &= ~(SECTSIZE - 1);// translate from bytes to sectors, and kernel starts at sector 1offset = (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);pa += SECTSIZE;offset++;}
}
按照注释,这个函数是从磁盘的第1个扇区往后数offset个字节处,读取count字节的数据,存储到pa这个内存区域中(从第一个扇区开始是因为,第0个扇区的512字节存储的是bootloader)。
最后再回顾一下bootmain函数,这个函数首先加载elf头,然后根据elf头加载各个程序段,需要注意的是,每个程序段被加载的内存地址是它的LMA,这也就使得我们可以通过链接器来很方便地控制kernel在内存中的分布情况(例如,知道text段的具体物理地址等)。