练习1
首先是boot.S文件
.set PROT_MODE_CSEG, 0x8 # 内核代码段选择子
.set PROT_MODE_DSEG, 0x10 # 内核数据段选择子
.set CR0_PE_ON, 0x1 # 保护模式启用标志
选择子(Selector)是 x86 保护模式下用于选择段描述符的一个值。段描述符存储在全局描述符表(GDT)或局部描述符表(LDT)中,描述符包含段的基地址、段限长和访问权限等信息。
.set
是汇编的伪指令,在这里的作用类似于c语言的宏定义,所以这里是定义了
PROT_MODE_CSEG
的值为0x8
,即内核代码段选择子。这个值用于在切换到保护模式后设置代码段寄存器(CS
),以便处理器知道从哪里开始执行内核代码。该描述符定义了内核代码段的基地址和限长。PROT_MODE_DSEG
的值为0x10
,即内核数据段选择子。这个选择子用于在切换到保护模式后设置数据段寄存器。段选择子0x10
对应于 GDT 中的一个段描述符,该描述符定义了内核数据段的基地址和限长。CR0_PE_ON
的值为0x1
,表示控制寄存器 CR0 中的保护模式启用标志。
接下来看到程序入口
cli # 禁用中断
cld # 字符串操作使用增量方向
# 设置重要的数据段寄存器 (DS, ES, SS)。
xorw %ax,%ax # 段号设置为零
movw %ax,%ds # 将AX寄存器的值(0)移动到DS寄存器 -> 数据段
movw %ax,%es # 将AX寄存器的值(0)移动到ES寄存器 -> 额外段
movw %ax,%ss # 将AX寄存器的值(0)移动到SS寄存器 -> 堆栈段
-
cli
清除(Clear)中断标志(Interrupt Flag,IF)位,即将IF位设置为0,禁用(屏蔽)中断,以避免在初始化过程中发生中断。 -
cld
清除方向标志(Direction Flag,DF)位,即将DF位设置为0,这意味着字符串操作(如movsb
,movsw
等)将会向内存地址增加的方向进行,而不是减少。 -
cli
这条指令是被加载到0x7c00处的指令,也就是进入boot loader后执行的第一条指令。后面几行主要就是设置段寄存器ds, es, ss为0。
# 启用A20:
# 为了与最早的PC兼容,物理地址线20默认被拉低,
# 因此高于1MB的地址会回绕到零。此代码撤销此操作。
seta20.1:
inb $0x64,%al # 等待不忙
testb $0x2,%al
jnz seta20.1
movb $0xd1,%al # 0xd1 -> 端口0x64
outb %al,$0x64
seta20.2:
inb $0x64,%al # 等待不忙
testb $0x2,%al
jnz seta20.2
movb $0xdf,%al # 0xdf -> 端口0x60
outb %al,$0x60
其实这里的逻辑还是挺简单的,首先从端口 0x64
读取一个字节到 AL
寄存器。
然后测试 AL
寄存器的第 1 位(忙标志),如果忙标志被设置,则跳转回seta20.1
,继续等待键盘控制器空闲(循环)
那么看懂了这个逻辑之后,其整体的逻辑就很好懂。
因为端口0x64
通常用于与键盘控制器(Keyboard Controller)进行通信。
seta20.1
就是将0xd1
发送到键盘控制器的意思是告诉键盘控制器,请你将我等下发送的值发送给0x60
端口
发送的字节 [0xdf
](vscode-file://vscode-app/c:/Users/MSI-NB/AppData/Local/Programs/Microsoft VS Code/resources/app/out/vs/code/electron-sandbox/workbench/workbench.esm.html) 启用了 A20 地址线,从而允许访问超过 1MB 的内存地址。
# 使用引导GDT从实模式切换到保护模式,
# 并进行段转换,使虚拟地址与物理地址相同,
# 以便在切换期间有效内存映射不变。
lgdt gdtdesc
movl %cr0, %eax
orl $CR0_PE_ON, %eax
movl %eax, %cr0
gdtdesc:
.word 0x17 # GDT 的限长(sizeof(gdt) - 1)
.long gdt # GDT 的基地址
orl
是 x86 汇编语言中的一条指令,用于对两个操作数进行按位或,并将结果存储在目标操作数中。
所以这里实际上就是把orl $CR0_PE_ON, %eax
eax
的值的最后一位设置成1
这里其实也没有实现多复杂的东西,就是很简单的开启了保护模式。
# 跳转到下一条指令,但在32位代码段中。
# 切换处理器到32位模式。
ljmp $PROT_MODE_CSEG, $protcseg
.code32 # 为32位模式汇编
protcseg:
# 设置保护模式的数据段寄存器
movw $PROT_MODE_DSEG, %ax # 我们的数据段选择子
movw %ax, %ds # -> DS: 数据段
movw %ax, %es # -> ES: 额外段
movw %ax, %fs # -> FS
movw %ax, %gs # -> GS
movw %ax, %ss # -> SS: 堆栈段
# 设置堆栈指针并调用C代码。
movl $start, %esp
call bootmain
# 如果bootmain返回(不应该返回),则循环。
这里这里其实不用太纠结于细节,其大体上是将数据段寄存器初始化成保护模式的地址,
而保护模式(Protected Mode)和实模式(Real Mode)是 x86 处理器的两种不同的操作模式,它们在内存管理、特权级别和多任务处理等方面有显著的区别。
实模式(Real Mode)
- 内存管理:实模式下,处理器只能访问 1MB 的内存空间,使用的是段:偏移地址模式,每个段最大 64KB。
- 特权级别:实模式没有特权级别的概念,所有代码都运行在同一特权级别下,容易导致系统崩溃或安全问题。
- 多任务处理:实模式不支持硬件级别的多任务处理,所有任务都必须由软件来管理。
- 应用场景:实模式主要用于早期的操作系统和引导加载程序,如 MS-DOS。
保护模式(Protected Mode)
- 内存管理:保护模式支持 4GB 的内存地址空间,并且可以使用分页(Paging)和分段(Segmentation)来实现复杂的内存管理和保护。
- 特权级别:保护模式支持 4 个特权级别(Ring 0 到 Ring 3),可以实现更细粒度的权限控制,提高系统的安全性和稳定性。
- 多任务处理:保护模式支持硬件级别的多任务处理,可以通过任务状态段(TSS)和任务切换(Task Switching)来实现高效的多任务处理。
- 应用场景:保护模式是现代操作系统(如 Windows、Linux)运行的基础,提供了更强大的内存管理和安全机制。
为什么要用保护模式
- 内存管理:保护模式支持更大的内存空间和更复杂的内存管理机制,可以有效利用现代计算机的大内存。
- 安全性:通过特权级别和内存保护机制,保护模式可以防止非法访问和恶意代码的执行,提高系统的安全性。
- 多任务处理:保护模式支持硬件级别的多任务处理,可以提高系统的响应速度和资源利用率。
- 现代操作系统需求:现代操作系统需要保护模式提供的特性来实现其功能,如虚拟内存、用户态和内核态的分离等。
最后就直接启动调用c的代码了,接下来就可以看到main.c
首先看到几个比较重要的函数
readsect
函数
void readsect(void *dst, uint32_t offset)
{
// 等待磁盘准备好
waitdisk();
outb(0x1F2, 1); // 计数 = 1
outb(0x1F3, offset);
outb(0x1F4, offset >> 8);
outb(0x1F5, offset >> 16);
outb(0x1F6, (offset >> 24) | 0xE0);
outb(0x1F7, 0x20); // 命令 0x20 - 读取扇区
// 等待磁盘准备好
waitdisk();
// 读取一个扇区
insl(0x1F0, dst, SECTSIZE / 4);
}
这里的作用就是从磁盘的指定扇区读取一个扇区的数据到内存中的指定位置。
readseg
函数
// 从内核的'offset'处读取'count'字节到物理地址'pa'。
// 可能会读取比请求的更多的数据
void readseg(uint32_t pa, uint32_t count, uint32_t offset)
{
uint32_t end_pa;
end_pa = pa + count;
// 向下对齐到扇区边界
pa &= ~(SECTSIZE - 1);
// 从字节转换为扇区,并且内核从第1扇区开始
offset = (offset / SECTSIZE) + 1;
// 如果这样太慢,我们可以一次读取多个扇区。我们会向内存写入比请求的更多的数据,但这无所谓——我们按顺序加载。
while (pa < end_pa)
{
// 由于我们还没有启用分页,并且我们使用的是标识段映射(参见boot.S),我们可以直接使用物理地址。一旦JOS启用了MMU,这种情况就不会再发生。
readsect((uint8_t *)pa, offset);
pa += SECTSIZE;
offset++;
}
}
readseg
我们可以结合其注释很容易就能理解,整个函数就是实现了一个类似于复制的操作,将磁盘的某个部分的count
个字节读取到内存地址pa
里面。
bootmain
函数
void bootmain(void)
{
struct Proghdr *ph, *eph;
// 从磁盘读取第一页
readseg((uint32_t)ELFHDR, SECTSIZE * 8, 0);
// 这是一个有效的ELF文件吗?
if (ELFHDR->e_magic != ELF_MAGIC)
goto bad;
// 加载每个程序段(忽略ph标志)
ph = (struct Proghdr *)((uint8_t *)ELFHDR + ELFHDR->e_phoff);
eph = ph + ELFHDR->e_phnum;
for (; ph < eph; ph++)
// p_pa是这个段的加载地址(也是物理地址)
readseg(ph->p_pa, ph->p_memsz, ph->p_offset);
// 从ELF头部调用入口点
// 注意:不会返回!
((void (*)(void))(ELFHDR->e_entry))();
bad:
outw(0x8A00, 0x8A00);
outw(0x8A00, 0x8E00);
while (1)
/* 什么也不做 */;
}
结合注释
/**********************************************************************
* 这是一个非常简单的引导加载程序,其唯一任务是从第一个IDE硬盘引导
* 一个ELF格式的内核镜像。
*
* 磁盘布局
* * 这个程序(boot.S和main.c)是引导加载程序。它应该存储在磁盘的第一个扇区。
*
* * 从第二个扇区开始存储内核镜像。
*
* * 内核镜像必须是ELF格式。
*
* 引导步骤
* * 当CPU启动时,它会将BIOS加载到内存中并执行。
*
* * BIOS初始化设备,设置中断例程,并读取引导设备(例如硬盘)的第一个扇区
* 到内存中并跳转到该位置。
*
* * 假设这个引导加载程序存储在硬盘的第一个扇区,这段代码将接管控制...
*
* * 控制从boot.S开始——它设置保护模式和堆栈,以便C代码可以运行,然后调用bootmain()。
*
* * 这个文件中的bootmain()函数接管,读取内核并跳转到内核。
**********************************************************************/
总结一下,boot.S做了以下几件事
- 启用A20地址线
- 设置段寄存器
- 切换至保护模式
- 然后跳转到C代码执行(即
main.c
)
然后 main.c
就是加载内核到内存,并且调用内核的入口,即将权限移交给内核。
练习2
首先是从0xffff0
启动,然后跳转到bios的第一行0xfe05b
。
打上断点然后跳转,输入x\10i
就可以运行并且显示连续十行的代码。
可以看到,其与boot.asm
中的代码基本一致
练习3
首先通过boot.asm找到bootmain的地址
然后在bootmain地址下一个断点
当运行到call 7cda这一步的时候,就代表我们下一步就是进入 readseg函数了
那么首先下个断点,然后运行到断点处
接着我们可以看一下接下来20条指令
我们这里直接看boot.asm中的代码
可以看到,在前几行代码只是一些函数状态保存的操作,直到后面的call waitdisk才是进入了c语言中的第一行
接下来的代码也是能和汇编对的上的,那么接下来就可以进入for循环看看了,首先先回到主函数
在7d30处打一个断点,这样子就可以顺利跳出当前的while循环了
所以我们只看关键的就行
这里的代码对应着boot.asm中的
循环中比较关键的地方已经标出来了,可以很容易看出来,循环结束运行时的代码是 call *0x10018
那么我们就在0x7d71处打一个断点,就可以继续跟进了。
再继续往下走就是内核的代码了
那么可以回答问题了
(1)处理器在什么时候开始执行32位代码?究竟是什么促使CPU从16位模式切换到32位模式?
处理器在ljmp $PROT_MODE_CSEG, $protcseg
这条指令运行之后开始执行32位的代码。
是通过lgdt加载了GDT使得CPU切换了模式
(2)Boot Loader 执行的最后一条指令是什么?其加载的内核执行的第一条指令是什么?
最后一条指令是call *0x10018
内核执行的第一条指令是movw $0x1234,0x472
(3)内核的第一条指令在哪里?
位于0x10000c
处,在文件kern/entry.S中
(4)为了从磁盘获取整个内核,Boot Loader 如何决定它必须读取多少扇区?它从哪里找到这些信息?
在 bootmain
函数中,Boot Loader 首先从磁盘读取内核镜像的前 8 个扇区(共 4096 字节)到内存地址 0x10000
,该地址被定义为 ELFHDR
,用于存放 ELF 头部信息。
然后Boot Loader 通过 ELF 头部获取程序头表的偏移地址(e_phoff
)和程序头数量(e_phnum
),然后计算程序头表在内存中的起始和结束地址。
之后Boot Loader 遍历程序头表中的每个程序头(Proghdr
),根据其中的 p_offset
和p_pa
以及 p_memsz
,调用 readseg
函数将对应的段从磁盘加载到内存。
// 遍历程序头表,加载每个程序段
for (; ph < eph; ph++)
// p_pa 是该段的加载地址
// p_memsz 是段的大小
// p_offset 是段在文件中的偏移
readseg(ph->p_pa, ph->p_memsz, ph->p_offset);
最后通过readseg
来计算需要的扇区数量和起始扇区位置
void readseg(uint32_t pa, uint32_t count, uint32_t offset)
{
uint32_t end_pa;
end_pa = pa + count;
// 将地址向下取整到扇区边界
pa &= ~(SECTSIZE - 1);
// 计算起始扇区号
offset = (offset / SECTSIZE) + 1;
// 逐个扇区读取到内存
while (pa < end_pa) {
readsect((uint8_t *)pa, offset);
pa += SECTSIZE;
offset++;
}
}
练习4
把地址随便改一下
运行 make qemu-nox-gdb
显然是我们修改了bootloader的地址,导致其编译之后VMA和LMA对不上,而ljmp这一条指令还是按照LMA的地址跳转,所以就导致了错误。
练习5
进入内核之前
进内核之后
进入内核之前这片的地址是空的状态,是因为,CPU处于实模式状态,这一片往上的地址都是不能使用的,当加载完内核之后,A20地址线开启,系统进入保护模式,所以这部分内存开始启用,所以里面有了数据
练习6
如图所示
在运行了movl %eax,%cr0
之后
0x00100000的地址被映射到了0xf0100000上
这里注释不能用分号;
,需要用井号#
坑死我了,找半天原因!!
运行之后到这一步报错了,显然是因为地址映射没有成功的原因,跳转到了奇怪的地方,
qemu: fatal: Trying to execute code outside RAM or ROM at 0xf010002c
可以看到是在非预期的地方执行代码,但是因为我们没有任何的异常处理,所以导致我们的qemu直接关闭了。
所以证明这一步就是开启了地址映射。
练习7
relocated:
# 清除帧指针寄存器 (EBP),以便在调试 C 代码时,栈回溯能够正确终止。
movl $0x0,%ebp # 清除帧指针
# 设置栈指针
movl $(bootstacktop),%esp
# 现在进入 C 代码
call i386_init
# 不应该到这里,但如果到了,就进入无限循环。
spin: jmp spin
.data
###################################################################
# 引导栈
###################################################################
.p2align PGSHIFT # 强制页对齐
.globl bootstack
bootstack:
.space KSTKSIZE
.globl bootstacktop
bootstacktop:
从这里代码可以看到,内核通过在 .data
段中定义一个连续的内存区域来为栈保留空间,使用 .space
指令分配了一块大小为 KSTKSIZE
的内存区域。
ESP被初始化指向bootstacktop
,也就是栈的顶端,即内存低地址。
练习 8
不难发现,内核每次启动的时候都会调用这个函数,其参数为5,代表其递归5次
(gdb) x/16x $esp
0xf010efbc: 0xf0100076 0x00000004 0x00000005 0x00000000
0xf010efcc: 0xf010004a 0xf0110308 0x00010094 0xf010eff8
0xf010efdc: 0xf01000f4 0x00000005 0x00001aac 0x00000660
0xf010efec: 0x00000000 0x00000000 0x00010094 0x00000000
(gdb) c
Continuing.
=> 0xf0100040 <test_backtrace>: push %ebp
Breakpoint 1, test_backtrace (x=3) at kern/init.c:13
13 {
(gdb) x/16x $esp
0xf010ef9c: 0xf0100076 0x00000003 0x00000004 0x00000000
0xf010efac: 0xf010004a 0xf0110308 0x00000005 0xf010efd8
0xf010efbc: 0xf0100076 0x00000004 0x00000005 0x00000000
0xf010efcc: 0xf010004a 0xf0110308 0x00010094 0xf010eff8
(gdb) c
Continuing.
=> 0xf0100040 <test_backtrace>: push %ebp
Breakpoint 1, test_backtrace (x=2) at kern/init.c:13
13 {
(gdb) x/16x $esp
0xf010ef7c: 0xf0100076 0x00000002 0x00000003 0xf010efb8
0xf010ef8c: 0xf010004a 0xf0110308 0x00000004 0xf010efb8
0xf010ef9c: 0xf0100076 0x00000003 0x00000004 0x00000000
0xf010efac: 0xf010004a 0xf0110308 0x00000005 0xf010efd8
(gdb)
函数每次递归都是压入了36个字节,即9个字
分别是
-
返回地址:4 字节(1 个字)
-
保存的帧指针:4 字节(1 个字)
-
保存的寄存器:
ESI
和EBX
各 4 字节,共 8 字节(2 个字) -
局部变量和函数参数:
cprintf
调用时 8 字节(2 个字),递归调用时 12 字节(3 个字)
练习9
int mon_backtrace(int argc, char **argv, struct Trapframe *tf)
{
uint32_t *ebp;
ebp = (uint32_t *)read_ebp();
cprintf("Stack backtrace:\r\n");
while (ebp)
{
cprintf(" ebp %08x eip %08x args %08x %08x %08x %08x %08x\r\n", ebp, ebp[1], ebp[2], ebp[3], ebp[4], ebp[5], ebp[6]);
ebp = (uint32_t *)*ebp;
}
return 0;
}
练习10
int mon_backtrace(int argc, char **argv, struct Trapframe *tf)
{
uint32_t *ebp;
int result;
uint32_t eip;
ebp = (uint32_t *)read_ebp();
cprintf("Stack backtrace:\r\n");
while (ebp)
{
eip = *(ebp + 1);
cprintf(" ebp %08x eip %08x args %08x %08x %08x %08x %08x\r\n", ebp, ebp[1], ebp[2], ebp[3], ebp[4], ebp[5], ebp[6]);
ebp = (uint32_t *)*ebp;
struct Eipdebuginfo debug_info;
debuginfo_eip(eip, &debug_info);
cprintf("\t%s:%d: %.*s+%d\n",
debug_info.eip_file, debug_info.eip_line,
debug_info.eip_fn_namelen,
debug_info.eip_fn_name, eip - debug_info.eip_fn_addr);
}
return 0;
}
发表评论