从零写OS(八):堆分配器,内核的 malloc

PMM 负责整页分配,每次给你 4KB。但内核里经常需要分配几十、几百字节的小块——存一个结构体、一条字符串。每次都要一整页,太浪费。 这一章实现 kmalloc / kfree:按需分配任意大小的内存块,用完归还,可以复用。 设计:空闲链表 最经典的堆实现:空闲链表(Free List)。 在堆空间里,每块内存前面加一个块头(header)记录元数据,所有块串成双向链表: typedef struct block { uint64_t size; // 这块数据区的大小(不含 header 本身) uint8_t is_free; // 1=空闲,0=已分配 struct block *next; // 链表后继 struct block *prev; // 链表前驱 } block_t; 内存布局长这样: [block_t header | ← size → 数据区 ][block_t header | ← size → 数据区 ] ... ↑ ↑ kmalloc 返回这个指针之前的地址 下一块 header kmalloc 返回的指针指向数据区起始,而不是 header。kfree 时把指针往前偏移 sizeof(block_t),找回 header,再改 is_free = 1。 堆在哪里 堆从虚拟地址 0x30000 开始,链表头指针存在 0x29000(一个固定位置,方便任何地方都能找到它): #define HEAP_START 0x30000ULL #define HEAP_HEAD_ADDR 0x29000ULL #define heap_head (*(block_t **)HEAP_HEAD_ADDR) 初始化时预先映射 4 页(16KB)作为起始堆空间,不够了再动态扩展。 ...

May 6, 2026 · 3 min · 大飞

从零写OS(七):虚拟内存,给每个程序一个假的地址空间

上一章内核能分配物理页了,但用的全是物理地址。 物理地址有个问题:全局唯一,谁都能访问。进程 A 如果知道进程 B 的物理地址,直接就能读写它的数据。这不行。 解决方案是虚拟内存:每个程序看到的地址都是"假的",CPU 访问时由硬件自动翻译成真实的物理地址。程序互相隔离,谁也看不见谁。 地址翻译的硬件机制:四级页表 x86-64 的地址翻译靠 MMU(Memory Management Unit,内存管理单元,CPU 内部硬件,负责把虚拟地址翻译成物理地址)完成,翻译规则写在页表里,页表的根地址放在 CR3 寄存器里。 一个 64 位虚拟地址被这样拆开: 63 48 47 39 38 30 29 21 20 12 11 0 [ 符号扩展 | PML4_IDX | PDPT_IDX | PD_IDX | PT_IDX | 页内偏移 ] 9 bit 9 bit 9 bit 9 bit 12 bit 翻译过程是四级查表: CR3 → PML4[PML4_IDX] → PDPT[PDPT_IDX] → PD[PD_IDX] → PT[PT_IDX] → 物理页帧 每级页表是一个 512 项的数组,每项 8 字节,刚好占满一个 4KB 页。每项的低 12 位是 flags,高位是下一级页表(或最终物理页)的地址。 ...

May 6, 2026 · 2 min · 大飞

从零写OS(六):内存管理,知道自己有多少地可种

内核跑起来之后,第一个绕不开的问题就是内存。 你不知道这台机器有多少内存,哪些地址段可以用,哪些是硬件保留的。如果随便往一个地址写数据,轻则数据损坏,重则触发异常直接重启。 这一章解决一件事:让内核知道内存的全貌,然后有序地分配和回收物理页。 先问 BIOS:你有多少内存? 我们用 E820(BIOS INT 0x15 AX=E820h,一种标准接口,用来查询物理内存的分布和类型)来探测内存。 这事必须在实模式下做——切换到长模式之后就再也访问不到 BIOS 服务了。所以探测代码放在 boot.asm 里,在进入长模式之前完成。 每次调用 INT 0x15,BIOS 填一条 24 字节的记录: base (8字节) — 这段内存的起始物理地址 length (8字节) — 这段内存的长度(字节) type (4字节) — 类型:1=可用,2=保留,其它=别动 ACPI (4字节) — 扩展属性,一般忽略 循环调用直到 EBX 变成 0,表示枚举完毕。所有记录写入固定地址 0x8000,第一个 4 字节存条目数量。 mov di, MMAP_ADDR + 4 ; 从 0x8004 开始存条目 xor ebx, ebx xor bp, bp ; bp 记条目数量 .e820_loop: mov eax, 0xE820 mov ecx, 24 mov edx, 0x534D4150 ; "SMAP" 魔数,BIOS 验证用 int 0x15 jc .e820_done ; CF=1 表示出错或结束 inc bp add di, 24 test ebx, ebx jz .e820_done ; ebx=0 表示最后一条 jmp .e820_loop .e820_done: mov dword [MMAP_ADDR], ebp ; 把条目数量写到 0x8000 QEMU 上跑出来的内存地图一般长这样: ...

May 6, 2026 · 3 min · 大飞

从零写OS(五):中断,内核的神经系统

键盘按下一个键,CPU 是怎么知道的? 不是轮询——CPU 不会没事一直问"键盘有没有按键"。而是靠中断(Interrupt,硬件或软件触发的信号,让 CPU 暂停当前任务去处理紧急事件)。发生了什么事,硬件主动通知 CPU,CPU 暂停手头的事,跳去处理,处理完再回来。 这一章要让内核有能力响应中断。没有这个机制,内核什么都干不了。 中断的分类 x86 的 256 个中断号分三段: 范围 类型 例子 0~31 CPU 异常 除零、缺页、非法指令 32~47 硬件中断 时钟、键盘 48~255 软件中断 系统调用 CPU 异常是 CPU 自己触发的,比如访问了不该访问的内存,CPU 就抛一个缺页异常(Page Fault,访问未映射的虚拟地址时 CPU 触发的异常,操作系统据此做内存按需分配)。 硬件中断是外设触发的,经过 PIC(Programmable Interrupt Controller,可编程中断控制器,负责管理外设中断请求并转发给 CPU)发给 CPU。 IDT:中断的路由表 要处理中断,首先要告诉 CPU:每种中断发生时,去哪个函数处理? 这张路由表叫 IDT(Interrupt Descriptor Table,中断描述符表,256 项,每项对应一个中断号及其处理函数地址)。跟之前的 GDT 一样,是内存里的一张表,用 lidt 指令告诉 CPU 位置。 每个表项里存的是处理函数的地址,还有权限信息。CPU 一旦收到中断,就查这张表,跳到对应函数去执行。 中断处理的细节 CPU 进入中断处理函数之前,会自动把当前的执行状态压栈:rip(下一条指令地址)、cs、rflags、rsp、ss。处理完之后用 iretq(64 位中断返回指令)恢复状态,接着跑。 有一个坑:某些异常 CPU 会自动压入错误码(比如缺页异常),某些不会(比如除零)。为了让处理函数收到的栈结构一致,对没有错误码的异常,要手动压一个 0 占位。这件事用汇编宏做,每种异常一行,干净利落。 第一个测试:故意除以零 IDT 装好之后,最简单的验证方式是故意触发一个异常,看内核能不能捕获到: ...

May 6, 2026 · 1 min · 大飞

从零写OS(四):汇编交棒给 C,最关键的一跳

前三章全是汇编。到这里,CPU 已经跑在 64 位长模式下了,但还是什么都做不了。 这一章要完成最关键的一步:把控制权从汇编交给 C。之后的内核全部用 C 写,汇编只在最必要的地方出现。 磁盘要怎么布局 512 字节的 Bootloader 放不下 C 内核,所以内核单独编译成一个二进制文件,放在磁盘的后续扇区(扇区,Sector,磁盘读写的最小单位,传统上每个 512 字节): 扇区 0(512B):Bootloader 扇区 1~8 :内核二进制 Bootloader 启动后,用 BIOS 的磁盘读取中断把扇区 1 开始的内容读到内存地址 0x10000,然后跳过去执行。 一个容易踩的坑:内核入口不在 0x10000 把内核编译好、加载到 0x10000,然后 call 0x10000——看起来没问题,实际上很可能直接崩。 用 objdump(反汇编工具,可以查看二进制文件的结构和函数地址)一查才发现:kernel_main 的实际地址是 0x10109,不是 0x10000。0x10000 开头是另一个函数 outb,跳过去执行完全是错的。 解决方案:加一个 entry.asm 作为内核最开头,内容只有一行:跳转到 kernel_main。然后在链接脚本(Linker Script,告诉链接器如何拼装各个目标文件、每段放在哪个地址)里把 entry.o 排在最前面,保证 _start 恰好落在 0x10000。 这样 Bootloader 跳到 0x10000,第一条指令就是 jmp kernel_main,稳了。 还有一个坑:栈 进入 64 位模式之后,rsp(栈顶指针寄存器)是未初始化的。C 函数一用栈就崩。 在跳入内核之前,要先设好栈: mov rsp, 0x90000 选 0x90000 是因为这块内存没有被占用,往下增长也不会覆盖内核代码。 内核编译参数 内核不能用普通方式编译,要加几个关键选项: ...

May 6, 2026 · 1 min · 大飞

从零写OS(三):进入 64 位,CPU 真正醒了

上一章切到了保护模式,32 位,最多 4GB 内存。 听起来挺多,但现代系统动不动就几十 GB 内存,4GB 根本不够用。所以还得再往前走一步:进入长模式(Long Mode,x86_64 的 64 位工作模式,支持 128TB 虚拟地址空间)。 现在跑在你电脑上的 Linux、macOS、Windows,全都在长模式里。 长模式比保护模式多了什么 最直接的:寻址空间从 4GB 扩展到理论上的 16EB(16 × 10¹⁸ 字节),实际当前 CPU 支持 128TB,够用很久了。 但更重要的变化是强制引入了分页(Paging,把物理内存切成固定大小的页,通过页表做虚拟地址到物理地址的映射)。 保护模式下分页是可选的,长模式下分页是必须开启的。没有页表,CPU 根本不让你进长模式。 先建页表 切换之前,必须在内存里建好页表(Page Table,记录虚拟地址到物理地址映射关系的数据结构)。 x86_64 用四级页表,一个虚拟地址的翻译路径是: 虚拟地址 → PML4 → PDPT → PD → PT → 物理地址 每级都是一张表,每张表 512 项,每项 8 字节。 启动阶段不需要映射所有内存,先映射前 2MB 就够了——把四张表放在物理地址 0x1000 开始的位置,让虚拟地址和物理地址一一对应(恒等映射,虚拟地址和物理地址相同,是启动阶段最简单的映射策略)。 切换步骤 在 32 位保护模式下,按顺序操作: 建好页表,把 PML4 地址写入 CR3(页表根寄存器) 开启 PAE(Physical Address Extension,物理地址扩展,让 32 位系统支持超过 4GB 的物理地址,长模式必须开启):CR4.PAE=1 设置 EFER.LME=1(通过 MSR 寄存器(Model Specific Register,CPU 内部的特殊寄存器,用 rdmsr/wrmsr 读写)开启长模式使能位) 开启分页:CR0.PG=1,CPU 正式进入长模式兼容态 far jump 跳入 64 位代码段,彻底进入 64 位模式 顺序不能乱。尤其是第 3 步必须在开启分页之前,不然 CPU 会拒绝。 ...

May 6, 2026 · 1 min · 大飞

从零写OS(二):跨过这道门,CPU 才算醒了

上一章我们让机器打出了第一行字。但那时候 CPU 还处于一种"出厂状态"——实模式,最多只能用 1MB 内存,没有任何权限隔离,任何代码想写哪块内存就写哪块。 这对一个操作系统来说是不可接受的。 所以这一章要做一件事:把 CPU 从实模式切换到保护模式(Protected Mode,32 位工作模式,支持内存保护和权限隔离)。 为什么叫"保护"模式 名字来源很直接——这个模式下,内存是被保护的。 实模式里,程序可以随意读写任何内存地址,一个 bug 就能把整个系统搞崩。保护模式引入了两个机制: 内存段的权限描述:每块内存都有自己的访问权限,越界访问直接触发异常 特权级别(Ring):内核跑在 Ring 0,用户程序跑在 Ring 3,用户程序没法直接碰内核内存 现代操作系统全都建立在保护模式之上。 切换的关键:GDT 切到保护模式之前,必须先准备好 GDT(Global Descriptor Table,全局描述符表,内存里的一张表,描述每个内存段的地址、大小和权限)。 可以把 GDT 理解成一个"内存段登记簿"。CPU 进入保护模式后,所有内存访问都要查这张表,看看你有没有权限。 GDT 最少需要三项: 索引 用途 0 空描述符(CPU 规定必须全零,不能用) 1 代码段(可执行,Ring 0) 2 数据段(可读写,Ring 0) GDT 写好之后,用 lgdt 指令告诉 CPU 表在哪里,CPU 才知道去哪查。 切换步骤 整个切换过程就四步,缺一不可: 关中断(cli)—— 切换过程不能被打断,否则 CPU 状态会乱 加载 GDT(lgdt)—— 告诉 CPU 描述符表在哪 设置 CR0 寄存器(CR0.PE=1)—— CR0(控制寄存器0,控制CPU工作模式的关键寄存器)第0位置1,CPU 正式进入保护模式 far jump 刷新流水线 —— 跳转的同时加载新的代码段选择子,清空 CPU 的指令预取缓存 最后这一步很容易漏掉。CPU 有指令流水线,切换模式后如果不强制刷新,可能还在用旧模式的指令译码方式,导致莫名其妙的错误。 ...

May 6, 2026 · 1 min · 大飞

从零写OS(一):让机器听你的第一句话

电脑开机之后,CPU 做的第一件事是什么? 不是运行 Linux,不是加载驱动,甚至不是执行你写的任何代码——而是执行一段固化在主板上的程序:BIOS(Basic Input/Output System,主板上的固件,负责硬件自检和引导启动)。 BIOS 做完硬件自检之后,会去找一个可以启动的磁盘。找到之后,它做了一件很朴素的事:把磁盘最开头的 512 字节复制到内存地址 0x7C00,然后跳过去执行。 就这样。没有操作系统,没有文件系统,没有任何框架。你写的代码,就从这 512 字节开始。 512 字节能干什么 说实话,不多。但够用来在屏幕上打一行字。 这一章的目标很简单:从磁盘启动,打印 Hello, OS!。 这段代码叫 Bootloader(引导程序,操作系统启动前最先运行的代码),是你第一次真正掌控机器的代码。 有两个硬性要求必须满足,BIOS 才会认你: 代码必须放在内存 0x7C00 开始的位置 这 512 字节的最后两个字节必须是 0x55 0xAA(引导魔数,BIOS 用来识别可启动扇区的标志) 少一个,BIOS 直接忽略你。 实模式:CPU 刚醒来的状态 启动的时候,CPU 处于实模式(Real Mode,x86 最原始的工作模式,16 位寻址,最多访问 1MB 内存)。 实模式有一个好处:可以直接调用 BIOS 提供的中断服务(INT,软中断,通过中断号调用 BIOS 内置功能)。打印字符用的是 int 0x10,往 ah 里放功能号 0x0E,往 al 里放字符,中断一触发,字符就出现在屏幕上。 这是整个计算机世界里最"低级"的打印方式,但也是最直接的。 两个坑 第一次写这段代码,我卡在两个地方: 段寄存器没初始化。 实模式下内存寻址是通过"段:偏移"来算的,ds、es、ss(数据段、附加段、栈段寄存器)这些上来没初始化,地址算出来是错的。在代码开头手动清零就好。 栈没设置。 汇编代码里调用任何东西之前,栈(Stack,后进先出的内存区域,用于保存临时数据和返回地址)要先指到一个安全的地方。把 sp(栈顶指针)设成 0x7C00 就够了——往下增长,不会覆盖代码。 跑起来 写好汇编,用 NASM(汇编器,把汇编代码编译成机器码)编译成二进制,扔给 QEMU(开源的硬件模拟器,可以模拟一台完整的 x86 计算机)模拟器: ...

May 6, 2026 · 1 min · 大飞
京ICP备14031575号-3