MIT6.828操作系统工程实验学习笔记(二)

前言

这篇文章是接上文的内容,依然是对Lab1的记录

如何启动保护模式

要启动保护模式,需要完成以下三个步骤:

  1. 在内存中加载GDT,设置GDTR
  2. 设置CR0寄存器的PE(Protected Enable)位,启用保护模式
  3. 通过一个far jump来重置段寄存器

内存中加载GDT

注意看boot.S的末尾部分

# Bootstrap GDT
.p2align 2                                # force 4 byte alignment
gdt:
  SEG_NULL                              # null seg
  SEG(STA_X|STA_R, 0x0, 0xffffffff)     # code seg
  SEG(STA_W, 0x0, 0xffffffff)           # data seg

gdtdesc:
  .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
(考虑到小端存放)
而从0x7D000x7D18这块内存区域里面存放的就是GDT的内容。
所以只需要把0x7D18,即gdtdesc这个标签的地址传递给LGDT指令,就可以实现GDT的设定,即指定GDT的基地址为0x7D00,并且大小为0x18
所以在进入保护模式的汇编代码里面,有这么一句指令:

  lgdt    gdtdesc

设置CR0寄存器

CR系列寄存器是CPU专用的控制寄存器(Control Register),其中CR0的第0位是PE位,当这一位是1的时候表示CPU处于保护模式。
所以才有下面这段汇编代码:

  movl    %cr0, %eax
  orl     $CR0_PE_ON, %eax
  movl    %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    gdtdesc
  movl    %cr0, %eax
  orl     $CR0_PE_ON, %eax
  movl    %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 disk
	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)
	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 reaady
	while ((inb(0x1F7) & 0xC0) != 0x40)
		/* do nothing */;
}

void
readsect(void *dst, uint32_t offset)
{
	// wait for disk to be ready
	waitdisk();

	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
	insl(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 boundary
	pa &= ~(SECTSIZE - 1);

	// translate from bytes to sectors, and kernel starts at sector 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);
		pa += SECTSIZE;
		offset++;
	}
}

按照注释,这个函数是从磁盘的第1个扇区往后数offset个字节处,读取count字节的数据,存储到pa这个内存区域中(从第一个扇区开始是因为,第0个扇区的512字节存储的是bootloader)。

最后再回顾一下bootmain函数,这个函数首先加载elf头,然后根据elf头加载各个程序段,需要注意的是,每个程序段被加载的内存地址是它的LMA,这也就使得我们可以通过链接器来很方便地控制kernel在内存中的分布情况(例如,知道text段的具体物理地址等)。

  • 18
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值