OS架构整理

发布于:2025-08-03 ⋅ 阅读:(10) ⋅ 点赞:(0)

引导启动部分

bios bootloader区别

[BIOS] (硬件开机,里面有写好的固件,上电自检)
   ↓ 读取硬盘第0扇区 → 加载到内存地址 0x7C00
[boot] (512字节以内的 MBR/bootloader阶段1)(因为只有512字节,太小了所以用了二级引导)
   ↓ 加载并跳转到更复杂的 loader(通常是 2阶)
[loader] (多段程序,支持文件系统、内核加载等)
   ↓ 加载操作系统内核(kernel)
[OS内核] (操作系统正式启动)

在这里插入图片描述
前三步是我们自己控制不了的
我的代码是从磁盘加载
在这里插入图片描述

启动流程(x86 BIOS 启动):

在这里插入图片描述
这个第0扇区的代码需要自己去写
首先Bios上电自检,然后将磁盘的第一个扇区512字节放入到内存地址0x7c00,检查是否以0x55 0xaa结束,从而判断这是否是有效的MBR
,如果是的话就进行引导

bios

  1. 加电启动(Power On)
  2. BIOS 执行 POST(Power-On Self-Test)
  3. 搜索可启动设备(硬盘、U盘、光盘…)
  4. 找到启动设备后:
    • 读取该设备的 第 1 个扇区(LBA 0,大小 = 512 字节)
    • 把它加载到内存地址 0x0000:0x7C00(实地址 = 0x7C00
  5. 检查最后两个字节是否为 0x55AA(有效的引导扇区签名)
  6. 如果正确 → 跳转到 0x7C00 执行

在 BIOS 启动模式中,BIOS 会将硬盘的 第 0 扇区(MBR)加载到 0x7C00,并从那里开始执行。

读取磁盘又两种方式
一种是int13(bios)
一种是LAB(复杂一点,在loader中实现)

x86在上电后自动进入实模式,1m内存 无分页机制 寄存器也只能用16位
但是1m内存的话要访问完需要20位地址,我们就是将段基址<<4+偏移构成20位
段基址的值是存在CS/DS/SS/ES/FS/GS(段寄存器)中
这个实模式下1M大小的内存映射情况:
在这里插入图片描述
我的boot是在start.s中写的
boot的初始化: 主要就是将段寄存器先赋初值0,简化代码,栈顶指针赋值0x7c00,表示我的boot在0x7c00地址以下的栈区,大概30kb左右是满足这个大小的
boot跳转到loader二级引导
在这里插入图片描述
在这里插入图片描述

读取多个磁盘加载到内存地址上:用了bios中断向量表 0x13 从第一个扇区开始 分配64个扇区(大概32kb) 如果读取磁盘正确后进入C环境并跳转到loader(jmp或者call)
在这里插入图片描述
.extern C函数名字(boot的一个跳转函数)

boot_loader

在这里插入图片描述

cmake的链接器表示我loader 加载到0x8000的地址,start.s放在cmake加入的工程文件的最开头,这样就可以保证加载到0x8000时在start.s
在这里插入图片描述

因为512字节显然比较小,没办法完成这么多功能,所以我做了一个二级引导
1.内联汇编显示字符串
2.检测内存容量 0x15(boot_info)
在这里插入图片描述
检测10块可用内存区域

3.切换进保护模式

实模式的限制

1.只能访问1MB内存,内核寄存器最大为16位宽
2.所有的操作数最大为16位宽
3.没有任何保护机制
4.没有特权级支持
5.没有分页机制和虚拟内存的支持

如何切换进保护模式

在这里插入图片描述

首先保证过程原子性,禁用中断,然后打开A20地址线让其访问1m以上的内存地址,然后初始化加载GDT表保证开启保护模式寄存器值正常,再设置CR0 PE位,开启保护模式。

这个禁用中断的函数我也写了一个函数,保存关中断前的各个寄存器的状态(eflags等),完成实模式到保护模式切换后恢复到原来的状态,这个函数再后面的也可以用到。函数中我也用到了内联汇编函数 sti cli进行开关中断.

加载kernel到内存地址1M

在loader中实现LAB读取磁盘,(一次两字节读取)(通常512字节一次性读取)

#define SYS_KERNEL_LOAD_ADDR		(1024*1024)		// 内核加载的起始地址(此时打开了A20地址线和保护模式,可以访问1M以上空间
static void read_disk(int sector, int sector_count, uint8_t * buf)


名称 含义 单位
sector 起始扇区号(LBA) 扇区(512 字节)
sector_count 连续读取的扇区数量 扇区(512 字节)

创建kernel文件夹,同样cmake设置起始位置1M
在这里插入图片描述
在这里插入图片描述
栈的作用:C的局部变量,函数调用中的参数
在保护模式下 push pop的一个栈都是4个字节
esp是栈顶指针 ebp相对比较固定。ESP 指向当前栈顶,EBP 保存的是上一个调用帧的基地址(上一个函数的栈底基准)

我在loader32.c中写了 ((void (*)(boot_info_t *))kernel_entry)(&boot_info); 然后进入kernel1M地址,

		push %ebp
     	mov %esp, %ebp
	    mov 0x8(%ebp), %eax
	    push %eax
	    call kernel_init

取出boot_info参数传给kernel_init函数void kernel_init (boot_info_t * boot_info)(函数指针) 我没用全局变量不依赖任何外部状态,bootloader 和 kernel解耦,可移植性强。

我再讲解下这个loader的这个函数指针:(为了跳到kernel_entry(裸地址0x100000)并传入参数boot_info

部分 解释
void (*)(boot_info_t *) 一个函数指针类型,指向接受一个 boot_info_t * 参数、返回 void 的函数
(void (*)(boot_info_t *))kernel_entry kernel_entry 强制转换为这种函数指针类型
((...))(&boot_info) 把这个函数指针当成函数调用,传入参数 &boot_info

加载内核映像文件

在这里插入图片描述
改一下kernel.lds和loader32.c中跳转到kernel的函数指针的裸地址改一下0x100000 改为0x1000

elf

elf更小,并且可以进行权限设置。
elf是一种通用的可执行文件格式,操作系统内核是程序逻辑,elf是其封装形式,方便bootloader加载执行。
这个elf文件是我的c、汇编写的内核编译出来的最终可执行映像。
在这里插入图片描述
查看手册把elf_header和program_header的结构放在elf.h中
在loader32.c中写了一个加载elf的函数,首先要进行魔术验证,0x7F ‘E’ ‘L’ 'F’开头,判断其是否合法。将elf里面的内容提取到指定的物理地址,做好初始化(比如bss通常是未初始化的参数,初始化为0),最后跳转到elf的入口地址(return elf_hdr->e_entry;)。然后将e_entry这个elf入口地址作为loader的跳转地址(刚刚我们改的裸地址就可以改成e_entry)
从而跳转到kernel内核。
在这里插入图片描述

异常和中断的实现

异常:CPU 内部 引起的中断(比如程序除0,地址越界)
中断外部 事件引种的中断(比如磁盘中断)(与现在执行的指令无关)
当异常或中断发生时,CPU会中断当前任务,转而执行相应事件处理程序。

中断描述符表(IDT)

和GDT格式相似,但有以下不同:

项目 GDT(Global Descriptor Table) IDT(Interrupt Descriptor Table)
用途 描述内存段(代码段、数据段、TSS等) 描述中断/异常服务程序的入口地址
表项类型 段描述符(Segment Descriptor) 中断门 / 陷阱门(Interrupt/Trap Gate)
加载指令 LGDT LIDT
控制寄存器 GDTR IDTR
表项数量 一般自定义(最多8192个) 固定256个(中断向量号0~255)
是否相关 完全独立 无嵌套、无包含关系

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

中断描述符表寄存器(IDTR)

IDTR 是一个寄存器,保存了中断描述符表(IDT)的地址和大小,CPU 在响应中断或异常时会查表(IDT)找到对应的中断处理程序入口地址。

代码实现

人为控制压栈顺序+构建结构体+传递栈指针

在汇编中实现的,因为只有汇编才可以用iret返回函数,c语言中函数返回反汇编用的ret。
iret保存了更多的信息(eflags恢复中断标志 cs恢复代码段 eip哪条指令触发的异常 如果特权级发生切换还会压栈ss esp)
在这里插入图片描述
在这里插入图片描述
我通过严格按照上图的结构体,通过

kernel/init/start.s
		// 保存所有寄存器
		pushal //pushal(或 pusha)是 x86 指令,用于一次性压入所有通用寄存器的值到栈中(32 位下使用 pushad 是同义指令)。
		push %ds
		push %es
		push %fs
		push %gs

		// 调用中断处理函数
		push %esp
		call do_handler_\name
		add $(1*4), %esp		// 丢掉esp
irq.h
typedef struct _exception_frame_t {
    // 结合压栈的过程,以及pusha指令的实际压入过程
    int gs, fs, es, ds;
    int edi, esi, ebp, esp, ebx, edx, ecx, eax;
    int num;
    int error_code;
    int eip, cs, eflags;
    int esp3, ss3;
}exception_frame_t;

此时%esp指向栈顶地址就是gs的地址,将上述结构体传入下面的函数,只要压栈顺序和结构体字段严格一致(因为我是通过栈顶gs的地址访问结构体字段,通过地址+偏移来进行访问),你就能在 C 函数中方便地访问中断现场的所有寄存器和信息。

irq.c
static void do_default_handler (exception_frame_t * frame, const char * message) {
    log_printf("--------------------------------");
    log_printf("IRQ/Exception happend: %s.", message);
    dump_core_regs(frame);
    
    // todo: 留等以后补充打印任务栈的内容

    log_printf("--------------------------------");
    if (frame->cs & 0x3) {
        sys_exit(frame->error_code);
    } else {
        for (;;) {
            hlt();
        }
    }
}

①【场景引入】为什么要这么做?
你可以说:

“中断发生时,CPU 会自动压入一些寄存器(如 EIP、CS、EFLAGS),但为了统一处理中断,还需要手动压入通用寄存器和段寄存器,并最终调用 C 函数进行处理。为了让 C 函数能访问这些数据,我们需要用一个结构体来描述现场。”

②【技术难点】哪里巧妙?
“C 函数访问结构体成员时是通过 ‘指针 + 偏移’ 的方式来的,不能动态感知压栈顺序。因此我在汇编中手动控制压栈顺序,使其严格匹配结构体字段布局。”

“最巧妙的一点是:我最后 push esp,把中断现场的起始地址(栈顶)作为参数传给 C 函数,这样结构体指针就和栈完美对齐,不需要 pop/pop/pop 逐个传递,大大简化了接口。”

③【效果与好处】
“这个设计的好处是统一、结构化、易维护、支持多种中断共享一个处理函数。它将底层寄存器状态抽象成高层结构体接口,在裸机或内核开发中很实用。”

宏重用异常处理代码
.macro exception_handler name num with_error_code

一些基础知识

链接脚本与代码数据段

先看一个具体的
在这里插入图片描述
有下面的一个顺序
在这里插入图片描述
为什么会这么放呢?
下面是一个测试
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
当我们建立了kernel.lds就不用在每个文件夹下的cmake -text进行设置 告诉编译器这些分别放在哪
在这里插入图片描述

创建GDT表

段描述符 结构的主要是和cpu有关,就放在kernel的include文件下cpu.h里面。
GDT表就放在cpu.c中
然后使用lgdt指令将GDT的地址和大小加载仅GDTR寄存器(16位limit,所以GDT最大64kb的样子,32位base地址)中

段页式内存管理

在这里插入图片描述
在这里插入图片描述

我们先关注分段存储
在这里插入图片描述
段页式内存管理第一部分:逻辑地址 → 线性地址
我采用的是平坦模型(base address = 0,offset的limit可以覆盖整个4GB空间),选择子在GDT查表得到基地址,然后和偏移量相加得到线性地址。
换而言之:线性地址 = offset (因为 base = 0)
在这里插入图片描述
但是我仍需要设置GDT表:

gdt:(参考下面段描述符)
    .quad 0                       ; 空描述符
    .quad 0x00CF9A000000FFFF      ; 代码段:base=0, limit=4GB
    .quad 0x00CF92000000FFFF      ; 数据段:base=0, limit=4GB

其中limit有20位(高 4 + 低 16),虽然 limit 字段最大只能表示 0xFFFFF(1MB),但段描述符中还有一个 “粒度位(G bit)”,它决定 limit 的单位是字节还是 4KB。如果是4KB*1MB=4GB 因此段限长可以设置为4GB,从而实现平坦系统。

总结一下内存访问整体流程:
在这里插入图片描述

下面是对段描述符,GDT结构 处理上的详细介绍:

GDT表结构

段描述符:
在这里插入图片描述
在这里插入图片描述

/**
 * GDT描述符(其实就是段描述符)
 */
typedef struct _segment_desc_t {
	uint16_t limit15_0;
	uint16_t base15_0;
	uint8_t base23_16;
	uint16_t attr;
	uint8_t base31_24;
}segment_desc_t;

GDT表主要由三个部分limit base attr组成

/**
 * 设置段描述符
 */
void segment_desc_set(int selector, uint32_t base, uint32_t limit, uint16_t attr) {
    segment_desc_t * desc = gdt_table + (selector >> 3);

	// 如果界限比较长,将长度单位换成4KB
	if (limit > 0xfffff) {
		attr |= 0x8000;
		limit /= 0x1000;
	}
	desc->limit15_0 = limit & 0xffff;
	desc->base15_0 = base & 0xffff;
	desc->base23_16 = (base >> 16) & 0xff;
	desc->attr = attr | (((limit >> 16) & 0xf) << 8);
	desc->base31_24 = (base >> 24) & 0xff;
}

/**
 * 然后利用set函数初始化GDT
 */
void init_gdt(void) {
	// 全部清空
    for (int i = 0; i < GDT_TABLE_SIZE; i++) {
        segment_desc_set(i << 3, 0, 0, 0);
    }

    //数据段
    segment_desc_set(KERNEL_SELECTOR_DS, 0x00000000, 0xFFFFFFFF,
                     SEG_P_PRESENT | SEG_DPL0 | SEG_S_NORMAL | SEG_TYPE_DATA
                     | SEG_TYPE_RW | SEG_D | SEG_G);

    // 只能用非一致代码段,以便通过调用门更改当前任务的CPL执行关键的资源访问操作
    segment_desc_set(KERNEL_SELECTOR_CS, 0x00000000, 0xFFFFFFFF,
                     SEG_P_PRESENT | SEG_DPL0 | SEG_S_NORMAL | SEG_TYPE_CODE
                     | SEG_TYPE_RW | SEG_D | SEG_G);

    // 调用门
    gate_desc_set((gate_desc_t *)(gdt_table + (SELECTOR_SYSCALL >> 3)),
            KERNEL_SELECTOR_CS,
            (uint32_t)exception_handler_syscall,
            GATE_P_PRESENT | GATE_DPL3 | GATE_TYPE_SYSCALL | SYSCALL_PARAM_COUNT);

    // 加载gdt
    lgdt((uint32_t)gdt_table, sizeof(gdt_table));
}
选择子(GDT表中的索引,查找GDT表的具体的某个)

其中selector >> 3才能表示GDT的索引,因为选择子selector 是16位,结构包括 Index(段描述符索引)、TI(表选择标志1位)和 RPL(请求者特权级2位),所以通过选择子查找GDT中的索引需要右移3位。

有的同学可能会疑惑,进入保护模式CS/DS/SS等端寄存器不是从16位变成32位了吗?
事实上每个段寄存器(如CS、DS、SS、ES、FS、GS)在保护模式下实际上分为两部分,其中“隐藏部分”,保存了段基址、限长、权限等完整段描述符信息,这部分可能是32 位甚至 64 位。

部分 说明
可见部分 16 位选择子(selector)
隐藏部分 段描述符缓存(base、limit、access rights)

在这里插入图片描述

显示字符串

bios中断向量表中来显示

0x10

内联汇编来显示

也是用的bios中断进行显示的