这里距离AtkOS-自己动手写操作系统的第一篇文章 裸机的Hello World 已经一个多月过去了,期间零零散散将自己的博客逐渐搭建完善起来,然后自己的玩具内核也终于完成了内核进程切换的测试,所以这里补上自己的一些总结以供参考,并附上 GitHub开源仓库代码地址。
内核引导
计算机加电启动到操作系统载入内存的这段过程说复杂也复杂,复杂在我们一直都是在别人那里听说怎样怎样,自己这里只有一个概念而已,没有自己真正动手去尝试过,将逻辑抽象的东西生硬的与客观事实对应起来本来就是一件复杂而又困难的事情。说简单也简单,其实就是硬件与软件的相互配合,之间达成一个共同的约定。
计算机加电后,CS、IP寄存器的值被强制置为0xFFFF、0x0000,这时形成指令的第一个地址0xFFFF0,指向计算机内固化的一段程序——BIOS,在32位的系统中4G内存不仅仅全都指向内存,其中还包括BIOS、显卡等外部设备。接下来BIOS进行加电自检操作,然后搜索可启动设备,约定若设备第一个扇区的最后两个字节是0x55AA,那么该设备是可启动的。当BIOS找到可启动设备后,便将该设备的第一个扇区加载到内存的0x7C00处,然后跳转过去开始执行。
这里可启动设备的第一个扇区就是我们常说的bootloader,只有512个字节,主要完成的任务就是设置好操作系统所要运行的环境,然后加载内核到内存并跳转过去开始执行。这里并没有自己实现bootloader,而是直接使用的grub来加载内核。
16位实模式到32位保护模式
在80386处理器时代,就拥有了32位地址总线,为了保持向上兼容,计算机启动时运行在实模式,需要自己手动打开A20线性地址进入保护模式
1.打开A20地址,将突破实模式下1MB地址回卷的限制,在保护模式下寻址空间可以达到4GB。
2.打开分页机制,在MMU的协助下完成'
虚拟地址->线性地址->物理地址'
的寻址方式。
在模式的转换中需要注意到两点的不同:
1.内存寻址方式不同
在实模式下是由'
段基址+段偏移'
进行内存寻址,从而能够16bit的寄存器达到20bit的寻址空间,即CS寄存器的值乘以16倍之后加上IP的值得到内存物理地址。
而在保护模式下内存可以达到32bit的寻址空间,即4GB。因为从80386开始就已经有了32bit地址总线,寻址空间可以达到4GB,但是为了保持向上兼容,计算机启动的时候是在实模式下的,需要打开A20地址限制之后,进入保护模式。这时的虚拟地址就要先经过段机制形成线性地址,从而段描述符表也伴随着产生了,如果开启了分页机制,就要再经过分页机制得到内存物理地址,这里也就需要页目录和页表记录的映射关系。
这里列出了在保护模式下的内存寻址示意图:
2.中断处理方式不同
在实模式下记录中断服务入口函数地址的是叫中断向量表,有固定的一段内存地址开始存放中断向量表,从0x00000开始存放,然后根据发生中断的中断向量号去执行中断的上半部分即保存现场,然后跳转到中断服务函数,最后执行中断的下半部分即恢复现场。
而在保护模式下取而代之的是中断向量描述符表,这两者基本一样,只是在内存的地址可以随意存放,只需要将描述符表的首地址加载到中断描述符表寄存器idtr,需要自己去构造。
这里列出了在保护模式下中断响应示意图:
这里需要自己做的是构造全局描述符表gdt、中断向量描述符表idt,并将gdt、idt的首地址加载到全局描述符表寄存器gdtr、中断向量描述符表寄存器idtr,如果开启了分页机制还需要完成线性地址到物理地址的映射关系,即构造页目录和页表。
代码分析
临时页表初始化
一个页表项可寻址的空间为4KB,一个页目录项可寻址的空间为4MB,这里采取的做法是将虚拟地址0xC0000000为起始地址映射到物理地址0x00000000,而段机制构造的是平坦模式即0x00000000~0xFFFFFFFF为一个段处理,从而跨过了段机制,虚拟地址=线性地址,这里需要注意五点:
1.页目录、页表的内存地址必须是页对齐的,assert(addr&0xF000 != 0),页大小为4KB。
2.内核初始化之前的这段代码是从1MB处开始编址的,因此物理地址的前16KB一定是空闲的,这里用来存放临时页表。
3.内核初始化之前只作为临时页表,在映射内核新地址后,旧内核地址的前4MB也一定要映射,因为这段代码虚拟地址是从0x00100000即1MB处开始编址的,而其余内核代码地址是从0xC0000000处开始编址的,在进入kern_init()之前代码地址还停留在0x00100000开始的前4MB范围内,在启用分页之后就已经按照分页机制去寻址,如果不映射前4MB,代码寻址马上就会page fault。
4.开启分页基址后,内核的起始虚拟地址为0xC0000000,所有代码都是从0xC0000000处开始编址。
5.在内核栈初始化之前一定不要调用函数!!!因为此时esp、ebp还没有赋值或者说没有被初始化为正确的值,调用函数需要栈的支持,如果在内核栈初始化之前调用函数就会发生传说中的爆栈。
// 我们使用1MB以下的地址来暂时存放临时页表和页目录
// 该地址必须是页对齐的地址,内存 0-640KB 肯定是空闲的
__attribute__((section(".init.data"))) p_dir_t *p_dir_temp = (p_dir_t *)0x1000;
__attribute__((section(".init.data"))) p_table_t *p_table_old = (p_table_t *)0x2000;
__attribute__((section(".init.data"))) p_table_t *p_table_new = (p_table_t *)0x3000;
// 内核入口函数
__attribute__((section(".init.text"))) void kern_entry()
{
//操作系统魔数
char *os_magic = (char *)0x0;
*os_magic++ = 'H';
*os_magic++ = 'e';
*os_magic++ = 'l';
*os_magic++ = 'l';
*os_magic++ = 'o';
*os_magic++ = ',';
*os_magic++ = 'a';
*os_magic++ = 't';
*os_magic++ = 'k';
*os_magic++ = 'o';
*os_magic++ = 's';
*os_magic++ = '!';
*os_magic++ = '\n';
*os_magic++ = '\0';
p_dir_temp[GET_DIR_INDEX(KERN_OLD)] = (uint32_t)p_table_old | PAGE_PRESENT | PAGE_WRITE;
//一个页目录最大映射4MB的物理内存大小,所以映射512MB共需要128个页目录项
//内核偏移页目录项的起始地址
p_dir_temp[GET_DIR_INDEX(KERN_OFFSET)] = (uint32_t)p_table_new | PAGE_PRESENT | PAGE_WRITE;
int i;
// 映射内核虚拟地址 4MB 到物理地址的前 4MB
// 一个页表项是4个字节,1k个表项正好是4kb大小的占用内存空间,填满上面申请的p_table_t
// 一个页面的大小是4Kb,1024=1k个页表项正好映射内核的4Mb空间
for (i = 0; i < 1024; ++i) {
p_table_old[i] = (i << 12) | PAGE_PRESENT | PAGE_WRITE;
}
//映射 0x00000000-0x00400000 的物理地址到虚拟地址 0xC0000000-0xC0400000
for (i = 0; i < 1024; i++) {
p_table_new[i] = (i << 12) | PAGE_PRESENT | PAGE_WRITE;
}
// 设置临时页表
asm volatile ("mov %0, %%cr3" : : "r" (p_dir_temp));
uint32_t cr0;
// 启用分页,将 cr0 寄存器的分页位置为 1 就好
asm volatile ("mov %%cr0, %0" : "=r" (cr0));
cr0 |= 0x80000000;
asm volatile ("mov %0, %%cr0" : : "r" (cr0));
// 在初始化内核函数栈之前一定不要有函数调用!!!
// 切换内核栈
uint32_t kern_stack_top = ((uint32_t)kern_stack + KERN_STACK_SIZE) & 0xFFFFFFF0;
asm volatile ("mov %0, %%esp\n\t"
"xor %%ebp, %%ebp" : : "r" (kern_stack_top));
// 更新全局 multiboot_t 指针
glb_mboot_ptr = glb_mboot_old + KERN_OFFSET;
// 调用内核初始化函数
kern_init();
}
中断响应
实现了中断响应的上半部分保护现场,调用中断服务函数,下半部分恢复现场的处理。
这段代码非常巧妙!!
将中断处理函数封装成了宏,还将irq%1处理函数导出供C文件里面调用,一石二鸟
; asm
; 构造中断请求的宏
%macro IRQ 2
[GLOBAL irq%1]
irq%1:
cli
push 0
push %2
jmp irq_stub
%endmacro
irq_stub:
;保存现场
...
call irq_handler
;恢复现场
...
利用上面构造的中断宏函数,声明中断处理代码
; asm
IRQ 0, 32 ; 电脑系统计时器
IRQ 1, 33 ; 键盘
声明汇编的’GLOBAL irq%1’,用于注册中断处理代码
// C
// 声明 IRQ 函数
// IRQ:中断请求(Interrupt Request)
void irq0(); // 电脑系统计时器
void irq1(); // 键盘
中断处理函数首先查看是否注册中断处理服务函数,然后根据中断号调用服务函数。
到这里中断处理代码流程如下:
[irq0,irq1]->irq_stub->irq_handler->具体的中断服务函数->irq_stub
//调用中断处理函数
void irq_handler(irq_regs_t *irq_regs)
{
if (interrupt_handler[irq_regs->int_no])
{
//调用中断处理服务函数
interrupt_handler[irq_regs->int_no]();
}
else
{
printk("Unhandled ISR: %d\n", irq_regs->int_no);
while(1);
}
}