30天编写一个操作系统
汇编语言和C语言开发操作系统:从简单到细节
一、引言:为什么还需要汇编?
在高级语言泛滥的今天,探讨用汇编语言和C语言开发操作系统似乎有些“不合时宜”。然而,无论是Linux、Windows还是macOS,它们的内核深处都流淌着汇编与C的血液。这种组合不是技术演进中的偶然,而是计算机体系结构使然的必然选择。
汇编语言负责“唤醒”硬件,C语言负责“管理”硬件。一个提供对机器的绝对控制,一个提供对人类友好的抽象。本文将带你从最简单的引导扇区开始,逐步构建一个具备基本功能的小型操作系统内核。
二、起点:实模式下的汇编世界
2.1 计算机启动的第一条指令
当按下电源键,CPU处于实模式,仿佛回到1981年的8086时代:20位地址线,1MB寻址空间,没有保护,没有特权级。BIOS完成自检后,将磁盘第一个扇区(512字节)加载到物理地址0x7C00,并跳转执行。
这是我们的起点——一个只有512字节,必须以0x55AA结尾的引导扇区:
assembly
; boot.asm
[org 0x7c00]
[bits 16]
start:
; 初始化段寄存器
xor ax, ax
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0x7c00
; 清屏
mov ax, 0x0003
int 0x10
; 打印信息
mov si, msg
call print_string
; 加载内核到内存0x1000处
mov ax, 0x1000
mov es, ax
xor bx, bx
mov ah, 0x02 ; BIOS读扇区功能
mov al, 32 ; 读取32个扇区(16KB)
mov ch, 0 ; 柱面0
mov cl, 2 ; 起始扇区2(扇区1是引导程序)
mov dh, 0 ; 磁头0
mov dl, 0x80 ; 第一个硬盘
int 0x13
; 跳转到保护模式入口
jmp 0x1000:0x0000
print_string:
lodsb
or al, al
jz .done
mov ah, 0x0e
int 0x10
jmp print_string
.done:
ret
msg db "Booting TinyOS...", 13, 10, 0
times 510-($-$$) db 0
dw 0xaa55
这段代码完成了三件事:初始化运行环境、显示提示信息、从磁盘加载第二阶段引导程序。这里的关键在于理解:我们不是在“写汇编”,而是在“指挥硬件”。int 0x13不是函数调用,是CPU中断;mov ah, 0x02不是参数传递,是向寄存器赋值。
2.2 进入保护模式
实模式只能访问1MB内存,这对现代操作系统来说是牢笼。我们必须进入保护模式,开启32位地址线的广袤天地:
assembly
; protected_entry.asm
[bits 16]
enter_protected:
; 关闭中断
cli
; 加载GDT
lgdt [gdt_ptr]
; 开启A20地址线
in al, 0x92
or al, 0x02
out 0x92, al
; 设置保护模式标志
mov eax, cr0
or eax, 0x01
mov cr0, eax
; 跳转到32位代码段
jmp 0x08:protected_mode
[bits 32]
protected_mode:
; 初始化数据段寄存器
mov ax, 0x10
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ss, ax
mov esp, 0x7c00
; 跳转到C语言内核入口
jmp 0x08:0x1000
; GDT定义
gdt:
; 空描述符
dd 0, 0
; 代码段描述符
dw 0xffff
dw 0
db 0
db 0x9a
db 0xcf
db 0
; 数据段描述符
dw 0xffff
dw 0
db 0
db 0x92
db 0xcf
db 0
gdt_end:
gdt_ptr:
dw gdt_end - gdt - 1
dd gdt
GDT(全局描述符表)是保护模式的基石。每个描述符定义了内存段的基址、界限和属性。这不仅是技术细节,更是操作系统管理内存的哲学起点——从此,内存不再是简单的地址空间,而是可以赋予不同权限和属性的受保护区域。
三、融合:汇编为C铺路
3.1 调用约定与栈帧
当jmp 0x08:0x1000执行后,控制权移交给C语言。但C函数需要栈,需要正确的段寄存器,需要平坦的内存模型。这些,都由汇编预先准备好。
c
// kernel.c
void kernel_main() {
// 现在我们用C语言了!
char* video = (char*)0xb8000;
const char* msg = "Hello from C Kernel!";
for(int i = 0; msg[i]; i++) {
video[i*2] = msg[i];
video[i*2 + 1] = 0x07; // 黑底白字
}
while(1); // 停机
}
这三行C代码背后,是汇编栈帧的精心构建:
-
mov esp, 0x7c00设置了栈顶 -
平坦内存模型使
0xb8000直接可访问 -
保护模式使32位寻址成为可能
3.2 编译链接的默契
要让汇编和C协同工作,链接脚本是关键:
ld
/* link.ld */
OUTPUT_FORMAT("elf32-i386")
ENTRY(start)
SECTIONS {
. = 0x1000;
.text : {
*(.text)
}
.data : {
*(.data)
}
.bss : {
*(.bss)
}
}
这里将代码起始地址设为0x1000,正是汇编代码中jmp 0x1000:0x0000的目标地址。两个世界通过一个约定好的数字握手。
四、细节深入:中断与硬件交互
4.1 IDT:通往内核的门户
操作系统需要响应外部事件。键盘按下、时钟滴答,都需要中断机制。在保护模式下,中断描述符表(IDT)取代了BIOS中断向量:
c
// idt.c
struct idt_entry {
uint16_t base_low;
uint16_t selector;
uint8_t zero;
uint8_t flags;
uint16_t base_high;
} __attribute__((packed));
struct idt_ptr {
uint16_t limit;
uint32_t base;
} __attribute__((packed));
struct idt_entry idt[256];
struct idt_ptr idtp;
void set_idt_gate(int num, uint32_t handler) {
idt[num].base_low = handler & 0xFFFF;
idt[num].base_high = (handler >> 16) & 0xFFFF;
idt[num].selector = 0x08; // 代码段选择子
idt[num].zero = 0;
idt[num].flags = 0x8E; // 中断门,特权级0
}
但IDT条目中的handler是什么?是汇编函数地址:
assembly
; interrupt.asm
global isr_keyboard
extern keyboard_handler
isr_keyboard:
pusha
push ds
push es
push fs
push gs
mov ax, 0x10
mov ds, ax
mov es, ax
call keyboard_handler
pop gs
pop fs
pop es
pop ds
popa
iret ; 中断返回,不是ret
这是典型的“汇编封装C逻辑”模式。汇编负责保存恢复上下文,C负责业务逻辑。分工明确,各展所长。
4.2 内存管理:从物理到虚拟
操作系统最核心的职责是管理内存。当C语言需要malloc时,背后是页表、页目录和CR3寄存器:
c
// paging.c
#define PAGE_PRESENT 0x1
#define PAGE_WRITE 0x2
#define PAGE_USER 0x4
uint32_t page_directory[1024] __attribute__((aligned(4096)));
uint32_t page_tables[1024][1024] __attribute__((aligned(4096)));
void init_paging() {
// 映射前16MB内存
for(int i = 0; i < 1024; i++) {
page_directory[i] = 0x00000002; // 不存在的页表,只读,超级用户
}
// 设置第一个页表,映射0-4MB
for(int i = 0; i < 1024; i++) {
page_tables[0][i] = (i * 4096) | PAGE_PRESENT | PAGE_WRITE;
}
page_directory[0] = ((uint32_t)page_tables[0]) | PAGE_PRESENT | PAGE_WRITE;
// 加载页目录并启用分页
asm volatile("mov %0, %%cr3" : : "r" (page_directory));
uint32_t cr0;
asm volatile("mov %%cr0, %0" : "=r" (cr0));
cr0 |= 0x80000000; // 启用分页
asm volatile("mov %0, %%cr0" : : "r" (cr0));
}
这里的内联汇编是C和汇编融合的完美例证——C负责逻辑,汇编负责指令。asm volatile是两种语言之间的短程桥梁。
五、从简单到细节:理解真正的系统编程
5.1 为什么不是纯C,也不是纯汇编?
纯汇编开发操作系统是可能的——MenuetOS便是佐证,64KB代码实现了图形界面和网络协议栈。但代价是开发效率和维护难度。
纯C开发操作系统是不可能的——至少启动阶段不行。因为C语言假设了运行环境:栈指针有效、BSS段清零、main函数可调用。这些环境需要汇编来建立。
真实世界的操作系统采用分层策略:
-
引导阶段:纯汇编,最小化,只做必须的事
-
初始化阶段:汇编为主,C为辅,建立C运行时环境
-
核心阶段:C为主,汇编为辅,通过内联汇编或外部汇编函数访问特殊指令
-
驱动阶段:C为主体,特定时序敏感部分用汇编优化
5.2 细节中的魔鬼
当从“简单”走向“细节”时,以下陷阱几乎必然出现:
1. 链接顺序ld命令的对象文件顺序决定初始化函数调用顺序。通常,汇编对象必须在前,C对象在后。
2. 段对齐
页对齐(4096字节)对于分页是强制要求,但在链接脚本中很容易忽略。
3. 内联汇编的约束asm volatile("lgdt (%0)" : : "r" (addr))中的"r"是给编译器的承诺,告诉它addr是有效地址。错误的约束会导致生成错误代码。
4. 中断安全
C函数通常不是可重入的。在中断上下文中调用C函数必须格外小心,避免操作同一数据结构。
六、结语:两种语言的辩证统一
汇编语言和C语言在操作系统开发中的关系,不是替代,而是互补。汇编提供的是“可能性”,C提供的是“可维护性”。
当你用汇编写出一行mov cr0, eax时,你直接操作了CPU的核心控制寄存器,这是任何高级语言都无法提供的精确度。当你用C语言写出一行process->state = RUNNING时,你抽象了成千上万条指令,这是汇编难以企及的表达效率。
优秀的操作系统内核开发者的标志,不是熟练掌握某一种语言,而是知道何时用汇编、何时用C、以及如何让它们无缝协作。在启动时用汇编,在管理时用C;在关键路径上用汇编,在复杂逻辑上用C;在与硬件交互时用汇编,在策略决策时用C。
这,就是操作系统开发中“简单到细节”的全部秘密。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)