自制 Bootloader:从 MBR 到 64 位 C 内核与中断
本文目标:从 BIOS 加载 MBR 开始,手写一套完整的 Bootloader(MBR -> VBR -> Loader),经过保护模式进入长模式,进入Kernel,并在内核里重建 GDT64、配置 IDT64,最后通过
sti打开中断,得到时钟和键盘响应。
0. 验收目标
教程结束时,内核在 QEMU 里应做到:
64 位模式下通过串口(COM1, 0x3F8)打印字符串;
时钟中断(IRQ0 -> 向量 32)持续计数;
键盘敲键有响应(IRQ1 -> 向量 33)。
里程碑 | 位置 | 验证方式 |
|---|---|---|
活着进入长模式 | Loader 末尾 | 串口打出 |
C 世界接管 | entry.asm -> kmain | kmain 里 kprintf 正常输出 |
中断体系打通 | idt + pic 之后 |
|
工具链:
nasm # 汇编器
ld.lld # 链接器(内核 ELF)
zig cc / gcc # C 编译器
qemu-system-x86_64 # 无头启动 + debugcon 0xE9 回读日志
objcopy # ELF -> 裸二进制调试约定:实模式下用 BIOS int 0x10 打字符;进入长模式后 BIOS 不可用,改用 out 0xE9(debugcon)——QEMU 加 -debugcon stdio 就能在终端看到。每个阶段出口输出一个字符,卡住时可快速定位所在阶段。
磁盘布局:
扇区 LBA | 内容 |
|---|---|
0 | MBR(boot.asm,512 字节) |
2048 起 | FAT32 分区:保留区 -> FAT -> 数据区 |
数据区 |
|
1. MBR:第一段 512 字节
BIOS 把磁盘第一个扇区(512 字节)加载到 0x7C00,检查末尾 0x55 0xAA 后跳进来执行。此时 CPU 处于实模式 16 位,可用的只有 DL 寄存器中的启动驱动器号和 BIOS 中断。
MBR 只做一件事:找到活动分区,把 VBR 读进内存并跳转过去。VBR 加载到 0x600,与 0x7C00 不冲突。
org 0x7C00
cli
xor ax, ax
mov ss, ax
mov sp, 0x7C00
mov ds, ax
mov es, ax
mov [0x0FFE], dl ; 保存 BIOS 给的驱动器号,后面还要用
; 在 MBR 分区表(0x7BE 起,4 个 16 字节表项)里找活动分区 0x80
mov si, 0x7C00 + 0x1BE
mov cx, 4
.find:
cmp byte [si], 0x80
je .have
add si, 16
loop .find
mov si, 0x7C00 + 0x1BE
.have:
mov eax, [si + 8] ; 表项偏移 8 = 起始 LBA
mov [dap + 8], eax ; 填进 DAP 的起始 LBA 字段
; int 13h 扩展读(AH=42h):读 1 个扇区到 0x60:0
mov si, dap
mov dl, byte [0x0FFE]
mov ah, 0x42
int 0x13
jc err
jmp word 0x0000:0x0600 ; 跳进 VBR
dap: db 0x10, 0, 1, 0
dw 0x0060
dd 0, 0
times 510-($-$$) db 0
db 0x55, 0xAA踩坑
不要依赖 BIOS 留下的任何段寄存器值,先
cli+ 全部清零自设,栈也自己设。org 0x7C00告诉汇编器"我运行时在 0x7C00",与实际加载地址不一致会造成难以定位的错误。用 AH=42h(LBA 扩展读)而不是 AH=02h(CHS 读),现代盘的 CHS 几何是模拟值,不可靠。
末尾
0x55 0xAA缺失时 BIOS 不加载。
2. VBR:从 FAT32 读取 Loader
VBR 位于分区第一个扇区,被 MBR 加载到 0x600。它自带一份 FAT32 的 BPB(BIOS Parameter Block),从里面能算出文件系统布局:
FAT_LBA = 分区起始 + 保留扇区数
DATA_LBA = FAT_LBA + FAT份数 × 每FAT扇区数VBR 的任务:在根目录簇里逐扇区扫描 8.3 短文件名,匹配到 LOADER ASM 之类的文件后,顺着 FAT 簇链把整个文件读到 0xC200,然后 jmp 0xC200。
核心逻辑(简化骨架):
ORG 0x0600
LOAD_ADDR equ 0xC200
start:
cli
xor ax, ax
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0x7C00
sti
mov al, byte [0x0FFE] ; 从 MBR 那里接过的驱动器号
mov byte [drive], al
mov word [curclu], ROOT_CLUSTER
.dirscan: ; 逐簇读根目录
mov ax, word [curclu]
sub ax, 2
add ax, DATA_LBA
call readsec ; 读一个扇区到 DIR_BUF
jc fail
mov bx, DIR_BUF
.entry: ; 16 个 32 字节目录项/扇区
cmp byte [bx], 0x00 ; 0x00 = 后面没有更多项
je .nextroot
cmp byte [bx], 0xE5 ; 0xE5 = 已删除
je .next
; —— 用 11 字节短文件名逐字符比较,匹配则转读文件 ——
; 读文件:起始簇 -> FAT_LBA 表里查下一簇 -> 循环读到 0xFFFF(EOF)
...
.next: add bx, 32
; 扫完本扇区 -> 下一扇区;扫完本簇 -> FAT 查下一簇踩坑
目录项首字节
0x00表示其后无目录项,可提前结束;0xE5是已删除,要跳过。长文件名(LFN)是若干 0x0F 属性项的串,更可靠的做法是给文件起规范的 8.3 短名(如
LOADER ASM),只匹配短名。FAT 簇号从 2 开始计数:
LBA = DATA_LBA + (簇号 - 2) × 每簇扇区数。读扇区务必自己封一个
readsec(内部走 int 13h AH=42h),DAP 里的传输缓冲区必须按 16 字节对齐的段:偏移给出。
到这里,0xC200 处已经有一个功能完整的 Loader。此后 BIOS 只剩一个用途:设置显卡模式。
3. Loader:VBE 设屏 + 保存启动信息
Loader 开头仍是 16 位代码(org 0xC200),在退出实模式前做三件事:
① VBE 设图形模式。int 0x10; AX=4F01 查模式信息块,AX=4F02 + BX=模式号|0x4000 设置线性帧缓冲。把宽、高、VRAM 物理地址、总字节数存到约定内存(如 0x0FF0 附近),将来内核直接来取。失败就退化到 VGA mode 13h(320×200)。这一步是固定的"查—设—存"流程。
② 拷贝内核到最终物理地址。Loader 此前已经把 kernel.bin 也读进了低端内存临时区,这里 memcpy 到最终位置(如 0x280000)。做 KASLR 的话,用两次 rdtsc 异或出一个随机槽位。
③ 组装启动信息结构(类 Multiboot2 风格:magic + 总长 + 一串 tag),写到一个双方约定好的物理地址(如 0x5000),C 内核按约定解析。
4. 进保护模式:GDT32 + CR0.PE
现在关中断(cli),开始切换模式。先讲清楚段描述符:
段描述符是 8 字节的一条记录,描述"一段内存从哪开始、多长、能不能执行、特权级多少"。实模式的"段寄存器存基址"到保护模式变成了"段寄存器存描述符在 GDT 里的下标(选择子)"。操作系统根本不用分段,所以全部用平坦模型:所有段都从 0 开始、盖满 4GB、只是代码段可执行/数据段可写这个区别。
; GDTR 结构:2 字节长度 + 4 字节基地址
GDTR0:
dw GDT0_LEN - 1
dd GDT0
GDT0:
dq 0x0000000000000000 ; 0x00: 必须的空描述符
dq 0x000092000000FFFF ; 0x08: 数据段 base=0 limit=4GB 可读写
dq 0x00CF9A000000FFFF ; 0x10: 代码段 base=0 limit=4GB 可执行注意描述符字节序是故意打散的:limit 拆两截、base 拆三截,属性夹在中间。0x9A = Present + Ring0 + Code,0x92 = Present + Ring0 + Data,0xCF 里的 G=1 表示 limit 单位是 4KB。
切换动作三步:lgdt -> 置 CR0.PE -> 远跳转:
cli
call waitkbdout
mov al, 0xD1 ; 8042 键盘控制器命令:发 A20
out 0x64, al
call waitkbdout
mov al, 0xDF ; A20 = 1,地址线 21 根可用
out 0x60, al
lgdt [GDTR0]
mov eax, cr0
or eax, 0x00000001 ; CR0.PE = 1,进入保护模式
mov cr0, eax
jmp DWORD 2*8:pipelineflush ; 关键:远跳转刷新流水线
pipelineflush:
bits 32
mov ax, 1*8 ; 重载数据段寄存器
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ss, ax
mov esp, STACK_PHYS踩坑
lgdt之后必须远跳转(jmp 2*8:label)。CS 不能用mov改,远跳转让 CPU 用新选择子重新取指、清空预取队列——不做这步,CPU 还按旧的 16 位语义解码,直接崩。A20 没开的话,
0x100000以上的地址会回绕到 0,内核一进高地址就诡异挂掉。置 CR0.PE 之前 GDT 必须已在内存里。
从这一刻起 BIOS 中断全部作废。
5. 进长模式:页表 + EFER + GDT64
保护模式只是中间状态。长模式强制要求分页——不建页表无法进入,这也是整个流程中最容易出错的一步,分四步走。
5.1 建页表:恒等映射 + 高半区
x86_64 用四级页表:PML4 -> PDPT -> PD -> PT,每级 512 项,每项 8 字节,虚拟地址 48 位有效。教程里把页表页固定在物理 0x90000 一带,共 4 个 4KB 页。用 1GB 大页(PDTE 的 PS 位)最简洁:一个表项映射 1GB。
mov edi, 0x90000 ; 清零页表区
mov ecx, 0x9000 / 4
xor eax, eax
rep stosd
; PML4[0] -> PDPT@0x91000:覆盖低 512GB
mov dword [0x90000], 0x91007 ; 属性 0x007 = Present|RW|User
; PDPT[0] -> PD@0x92000,用 1GB 页恒等映射前 10GB
mov dword [0x91000], 0x92007
mov dword [0x92000 + 0*8], 0x000083 ; 0x083 = Present|RW|PS(1GB)
mov dword [0x92000 + 1*8], 0x200083 ; 1GB @ 0x40000000
mov dword [0x92000 + 2*8], 0x400083
mov dword [0x92000 + 3*8], 0x600083
mov dword [0x92000 + 4*8], 0x800083
mov dword [0x92000 + 5*8], 0xA00083
; 高半区:让 0xC0000000 处也能摸到内核物理页
; (把 PDPT 里指向内核物理地址的 1GB 页,挂到虚拟 3GB 位置)
mov dword [0x91000 + 3*8], <内核物理 1GB 页描述符>设计意图:恒等映射(虚拟地址 = 物理地址)保证切换页表时当前代码仍可执行;高半区(0xC0000000 起)是内核最终链接地址——KERNEL_VIRT = 0xC0000000 + KERNEL物理地址,所有内核符号链接在高地址,用户态无法访问。
5.2 GDT64
64 位的 GDT 反而更简单:分段机制基本弃用,起作用的只剩 L 位和兼容位:
GDT64:
dq 0x0000000000000000 ; 0x00 空
dq 0x00209A0000000000 ; 0x08 代码64: L=1(长模式) D=0
dq 0x00CF92000000FFFF ; 0x10 数据
dq 0x00CF9A000000FFFF ; 0x18 代码32(兼容模式用)
GDT64_LEN equ $ - GDT64
GDTR64:
dw GDT64_LEN - 1
dq GDT64 ; 64 位模式下 GDTR 基地址是 8 字节代码64 描述符 0x00209A0000000000 拆开看:type=0xA(可执行可读)、L 位=1(长模式)、D 位=0(长模式下必须 D=0)。base/limit 全 0——64 位下段基址强制为 0,limit 不检查。
5.3 切换序列
lgdt [GDTR64]
mov eax, cr4
or eax, 0x20 ; CR4.PAE = 1,长模式前提
mov cr4, eax
mov eax, 0x90000 ; CR3 = PML4 物理地址
mov cr3, eax
mov ecx, 0xC0000080 ; EFER MSR
rdmsr
or eax, 0x100 ; EFER.LME = 1,允许长模式
wrmsr
mov eax, cr0
or eax, 0x80000000 ; CR0.PG = 1,开分页
mov cr0, eax ; PG+PAE+LME 齐活 -> CPU 自动进入长模式
jmp 0x08:lg64 ; 用 64 位代码选择子远跳,正式落位
bits 64
lg64:
mov ax, 0x10 ; 重载全部数据段
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ss, ax
mov rsp, STACK_PHYS踩坑
顺序锁死:PAE -> CR3 -> EFER.LME -> CR0.PG。LME 没置就开 PG 是非法组合,直接 triple fault。
页表必须在
mov cr3之前建好、内存写完;切页表那一刻,当前正在执行的代码所在区域必须两种映射下都可访问(恒等映射就是为此存在的)。长模式下段描述符的 L 位和 D 位互斥:L=1 时 D 必须为 0。
32 位代码物理执行在低地址,但链接地址在高地址——链接脚本里要把 ELF 入口段映射成"虚拟高地址 + 物理低地址"双重 VMA/LMA,否则 objcopy 出来的二进制跳不过去。
jmp 0x08:lg64之后紧跟bits 64,nasm 不自动切。
5.4 跳转内核入口
64 位下最后一段汇编工作:
mov eax, 0x36D76289 ; 启动 magic(Multiboot2 风格)
mov ebx, MBI ; 启动信息结构地址
mov rcx, KERNEL_VIRT ; C 入口链接地址
mov edx, [l_kphys] ; 内核实际物理加载地址(KASLR 用)
jmp rcx四个寄存器携带四项信息,跳转到内核入口。
里程达成判定:串口输出 K!。
6. 进入内核
C 代码运行前需要两个前提:栈可用、BSS 段为零。内核入口汇编先处理这两件事(entry.asm):
bits 64
section .text
extern kmain
extern __bss_start
extern __bss_end
global entry_start
entry_start:
mov r8d, eax ; 留存 loader 传来的四个寄存器
mov r9, rbx
mov r10d, edx
; 初始化 COM1 串口 0x3F8,输出 "K!" 验证串口可用
mov dx, 0x3F8 + 1
xor al, al
out dx, al ; 关中断
inc dx
out dx, al
inc dx
out dx, al
inc dx
mov al, 0x03
out dx, al ; 8N1
...
mov dx, 0x3F8
mov al, 'K'
out dx, al
mov al, '!'
out dx, al
; 清 BSS:C 全局变量区必须从零开始
movabs rdi, __bss_start
movabs rax, __bss_end
sub rax, rdi
mov rcx, rax
xor eax, eax
cld
rep stosb
cli ; 环境就绪前禁止中断
mov edi, r8d ; SysV 调用约定传参
mov esi, r9d
mov edx, r10d
call kmain
.spin:
hlt
jmp .spin对应的链接脚本要保证:.text 的 VMA 在 0xC0000000 高地址,LMA 在低物理地址;导出 __bss_start / __bss_end 符号。
ENTRY(entry_start)
SECTIONS
{
. = 0xC0000000 + 0x280000;
__kernel_start = .;
.text : AT(ADDR(.text) - 0xC0000000) { *(.text*) }
.rodata : AT(ADDR(.rodata) - 0xC0000000) { *(.rodata*) }
.data : AT(ADDR(.data) - 0xC0000000) { *(.data*) }
__bss_start = .;
.bss : AT(ADDR(.bss) - 0xC0000000) { *(.bss*) *(COMMON) }
__bss_end = .;
}AT(ADDR(...) - 0xC0000000) 就是"链接时认为自己住在高地址、实际烧录在低物理地址"的关键。
kmain 接收启动信息并初始化帧缓冲控制台后,kprintf("[OK] C world") ——里程碑 ② 达成。
7. 重建 GDT64
Loader 的 GDT 是临时的:位于 loader 内存区,不含 TSS、TLS、per-CPU 表项。内核需要建立自己的正式 GDT:
struct gdt_desc {
uint16_t limit_low, base_low;
uint8_t base_mid, attr_low;
uint8_t limit_high_attr_high, base_high;
} __attribute__((packed));
struct gdt_desc gdt[GDT_ENTRIES];
void gdt_init(void) {
desc_init(&gdt[0], 0, 0, 0, 0); // 空
desc_init(&gdt[1], 0, 0, 0x9A, 0x20); // 内核代码 64 位 (attr_high 0x20 = L位)
desc_init(&gdt[2], 0, 0xFFFFF, 0x92, 0xCF); // 内核数据
desc_init(&gdt[3], 0, 0xFFFFF, 0x9A, 0xCF); // 内核代码 32 兼容
desc_init(&gdt[4], 0, 0xFFFFF, 0xF2, 0xCF); // 用户数据
desc_init(&gdt[5], 0, 0xFFFFF, 0xFA, 0xCF); // 用户代码 64
desc_init(&gdt[6], 0, 0, 0xFA, 0x20); // 用户代码 64 专用
gdtr0.limit = sizeof(gdt) - 1;
gdtr0.base = (uint64_t)gdt;
asm_lgdt((uint64_t)&gdtr0);
}选择子编号从此固定为内核约定:0x08 内核代码、0x10 内核数据、0x1B 用户代码、0x23 用户数据——后面 IDT 里的门全用 0x08。
踩坑框 ⑤:lgdt 之后 CS/SS 等段寄存器还是旧选择子的缓存值,严格来说要远跳 + 重载;实操中由于新旧 GDT 布局一致可暂缓,但 TSS 一旦挂载(ltr),每个核都必须重载。
8. IDT64 + 中断打通
8.1 门描述符:16 字节
64 位的 IDT 门描述符翻倍成 16 字节,因为要装得下 64 位处理程序地址:
struct IDTEntry {
uint16_t offset_low; // 处理程序地址 bit 0-15
uint16_t selector; // = 0x08 内核代码段
uint8_t ist; // 中断栈表,0 = 不切换
uint8_t type_attr; // 0x8E = Present|Ring0|Interrupt Gate
uint16_t offset_mid; // bit 16-31
uint32_t offset_high; // bit 32-63
uint32_t reserved;
} __attribute__((packed));
struct IDTEntry idt[256];
static void idt_set(uint8_t vec, void (*handler)(void), uint8_t type) {
uint64_t addr = (uint64_t)handler;
idt[vec].offset_low = (uint16_t)(addr & 0xFFFF);
idt[vec].selector = SELECTOR_KERNEL_CODE;
idt[vec].ist = 0;
idt[vec].type_attr = type;
idt[vec].offset_mid = (uint16_t)((addr >> 16) & 0xFFFF);
idt[vec].offset_high = (uint32_t)((addr >> 32) & 0xFFFFFFFF);
idt[vec].reserved = 0;
}8.2 stub:为什么异常入口必须用汇编
两个原因:① 中断发生瞬间 CPU 不保存通用寄存器,C 函数不懂保护现场;② 有的异常 CPU 自动压错误码,有的不压——栈布局不一致,C 没法统一处理。所以用宏生成 256 个入口代码:
[bits 64]
%macro ISR_NOERR 1 ; CPU 不压错误码的异常:手动补一个 0
global isr%1
isr%1:
push qword 0
push qword %1
jmp isr_common_stub
%endmacro
%macro ISR_ERR 1 ; CPU 已压错误码(如 #PF(14)、#DF(8)、#GP(13))
global isr%1
isr%1:
push qword %1
jmp isr_common_stub
%endmacro
ISR_NOERR 0
ISR_ERR 14
; …… 0~31 按手册逐个标定 ……
isr_common_stub:
push rax ; 保存全部易失寄存器
push rbx
push rcx
push rdx
push rsi
push rdi
push rbp
push r8 .. push r15
mov rdi, rsp ; 把栈帧指针作为第一个参数传给 C
call isr_handler
pop r15 .. pop rax ; 原路恢复
add rsp, 16 ; 弹掉 错误码+向量号
iretqC 侧注册就非常干净:
void idt_init(void) {
for (int i = 0; i < 256; ++i)
idt_set(i, default_handler, IDT_TYPE_INT_GATE64);
idt_set(0, isr0, IDT_TYPE_INT_GATE64); // 逐个挂 0~31 CPU 异常
...
idt_set(32, irq0, IDT_TYPE_INT_GATE64); // IRQ0~15 -> 32~47
idt_set(33, irq1, IDT_TYPE_INT_GATE64);
idtr0.limit = sizeof(idt) - 1;
idtr0.base = (uint64_t)idt;
__asm__ volatile("lidt %0" : : "m"(idtr0) : "memory");
}初始化顺序上的惯例:先把全部 256 个向量注册到 default_handler,再精细覆盖。任何未处理的异常都会进入"打印向量号 + 停机"的可观测路径,而不是无限 triple fault 重启,排查效率差别很大。
8.3 PIC 重映射:把硬件中断搬出异常区
8259 PIC 默认把 IRQ0~7 映射到向量 8~15——与 CPU 异常区重叠(8 是 #DF,14 是 #PF)。不重映射,缺页异常会与硬件中断冲突。重映射需要依次写入四组 ICW:
int pic_init(void) {
outb(PIC1_CMD, 0x11); // ICW1:级联 + 需要 ICW4
outb(PIC2_CMD, 0x11);
outb(PIC1_DATA, 0x20); // ICW2:主片 IRQ0 -> 向量 32
outb(PIC2_DATA, 0x28); // 从片 IRQ8 -> 向量 40
outb(PIC1_DATA, 0x04); // ICW3:主片 IR2 接从片
outb(PIC2_DATA, 0x02);
outb(PIC1_DATA, 0x01); // ICW4:8086 模式
outb(PIC2_DATA, 0x01);
outb(PIC1_DATA, 0xF8); // OCW1 掩码:只放行时钟/键盘等
outb(PIC2_DATA, 0xAF);
return 0;
}
void pic_send_eoi(uint8_t irq) {
if (irq >= 8) outb(PIC2_CMD, 0x20); // 从片要先 EOI
outb(PIC1_CMD, 0x20);
}8.4 开中断:sti
初始化完成后,在中断处理中计数并打印,然后:
kprintf("[OK] kernel init done, enable IRQs\n");
__asm__ volatile("sti");里程碑 ③ 达成判定:时钟中断计数每秒增长、键盘按下时 IRQ1 触发。三个里程碑全部达成,从 Bootloader 到长模式、C 内核、中断体系的整条启动链路验证完成。
9. 镜像打包与运行
最后一步是把各文件写入磁盘镜像:
# 1. 汇编
nasm -f bin arch/x86/boot/boot.asm -o build/boot.bin
nasm -f bin arch/x86/boot/vbr.asm -o build/vbr.bin
nasm -f bin arch/x86/boot/loader.asm -o build/loader.bin
# 2. C 内核
zig cc -ffreestanding -fno-pic -mcmodel=kernel -c ...
ld.lld -T linker/kernel.ld -o build/kernel.elf ...
objcopy -O binary build/kernel.elf build/kernel.bin
# 3. 打盘
dd if=/dev/zero of=build/disk.img bs=1M count=64
# 用 mtools 或自写脚本把 boot.bin 写入 LBA0、vbr.bin 写入分区头,
# loader.bin 和 kernel.bin 按短文件名放进 FAT32 根目录
# 4. 运行
qemu-system-x86_64 -drive format=raw,file=build/disk.img \
-display none -serial mon:stdio -debugcon stdio10. 踩坑速查
# | 阶段 | 坑 | 后果 |
|---|---|---|---|
1 | MBR | 依赖 BIOS 遗留段寄存器/栈 | 随机崩溃 |
2 | MBR |
| 指针计算错误 |
3 | VBR | 只匹配 LFN、忽略 8.3 短名 | 格式化方式一变就找不到文件 |
4 | VBR | 簇号忘了从 2 起算 | 读出垃圾数据 |
5 | PM |
| 按 16 位语义解码,立即出错 |
6 | PM | A20 未开 | 高地址回绕,系统挂死 |
7 | LM | LME 未置先开 PG | triple fault |
8 | LM | 页表没建好就切 CR3 | triple fault |
9 | LM | L=1 的描述符 D 位没清 0 | 非法描述符 |
10 | LM | 链接脚本 VMA/LMA 没分离 | 跳转后地址错误 |
11 | entry | BSS 没清零 | 全局变量初值随机,极难定位 |
12 | IDT | 64 位门当 8 字节写 | 地址截断,返回地址错误 |
13 | stub | 错误码有无没归一化 | 栈错位,返回地址错乱 |
14 | PIC | 不重映射就 sti | 硬件中断砸进 CPU 异常区 |
15 | PIC | EOI 顺序错(从片先于主片) | 中断只来一次 |
结语
请自行感悟
可以前去https://github.com/lightfl0w/CoreZ-OS查看一个正在做到OS()