Linux 0.01版本代码全解析:开源帝国的基石密码(by 豆包)
引言:被低估的"婴儿"内核
1991年8月25日,芬兰赫尔辛基大学的21岁学生Linus Torvalds在comp.os.minix新闻组发布了一封划时代的邮件:"我正在开发一个免费的类UNIX系统,针对386AT兼容机,目前只是一个爱好,不会像GNU那样庞大和专业..."。这封邮件宣告了Linux的诞生。10月5日,Linus发布了Linux 0.01版本,包含10239行代码(其中汇编173行,C语言10066行),仅支持386处理器、16MB内存和单硬盘分区,却奠定了一个市值超2800亿美元的开源生态的基石。
Linux 0.01版本常被视为"简陋的玩具",但深入其代码细节会发现,Linus以惊人的工程天赋实现了操作系统的核心骨架:基于x86保护模式的内存管理、多进程调度、MINIX兼容文件系统和基础设备驱动。这些代码中蕴含的模块化设计、硬件抽象和极简主义思想,成为后续Linux内核演进的DNA。本文将逐模块解析Linux 0.01的全部代码,还原这个开源帝国的诞生密码。
一、Linux 0.01开发背景与技术前提
1.1 时代背景:UNIX的垄断与MINIX的启蒙
20世纪80年代,UNIX已成为服务器领域的主流操作系统,但受AT&T专利限制,商用版本价格高昂。1987年,荷兰阿姆斯特丹自由大学的Andrew S. Tanenbaum为教学目的开发了MINIX,采用微内核架构,源代码开放且价格低廉(学生版仅20美元),迅速成为高校操作系统教学的主流平台。
Linus Torvalds正是MINIX用户之一,他在使用过程中发现MINIX存在明显局限:微内核架构性能不足、硬件支持有限、无法满足个人计算机的扩展需求。当时的386处理器已支持保护模式和虚拟内存,但MINIX为兼容更多硬件仍运行在实模式下,未能充分发挥硬件性能。这些痛点成为Linus开发Linux的直接动机。
1.2 硬件基础:386处理器的革命性特性
Linux 0.01的开发高度依赖Intel 80386处理器(简称386)的技术特性,这款1985年发布的32位处理器首次在x86架构中引入了保护模式,为多任务操作系统提供了硬件支撑:
-
保护模式:支持32位地址总线,可访问4GB物理内存,引入段页式内存管理和权限控制(4个特权级),解决了实模式下1MB内存限制和无权限隔离的问题
-
任务切换机制:内置任务状态段(TSS)和任务门描述符,支持硬件级上下文切换,为多进程调度提供了高效实现方式
-
中断与异常处理:扩展中断向量表至256项,支持可编程中断控制器(PIC)级联,可处理硬件中断和软件异常
-
指令集扩展:新增控制寄存器(CR0-CR3)、调试寄存器和浮点运算单元接口,为操作系统内核开发提供了丰富的硬件控制手段
Linux 0.01完全基于386保护模式开发,直接操控这些硬件特性,这也是其性能优于MINIX的核心原因之一。
1.3 代码概况:目录结构与编译环境
Linux 0.01的源代码包包含5个目录,共21个文件,具体结构如下:
linux-0.01/ ├── boot/ # 启动相关代码 │ ├── boot.s # 实模式启动代码 │ └── head.s # 保护模式初始化代码 ├── fs/ # 文件系统模块 │ ├── bitmap.c # 位图管理(磁盘空间分配) │ ├── block_dev.c# 块设备驱动接口 │ ├── char_dev.c # 字符设备驱动接口 │ ├── dir.c # 目录操作实现 │ ├── file.c # 文件操作核心逻辑 │ ├── fs.h # 文件系统头文件 │ ├── inode.c # i节点管理 │ └── namei.c # 路径解析 ├── include/ # 头文件目录 │ ├── asm/ # 汇编相关头文件 │ ├── linux/ # 内核头文件 │ └── sys/ # 系统调用头文件 ├── kernel/ # 内核核心模块 │ ├── fork.c # 进程创建(fork系统调用) │ ├── panic.c # 系统崩溃处理 │ ├── printk.c # 内核打印函数 │ ├── sched.c # 进程调度与管理 │ ├── system_call.s # 系统调用入口(汇编) │ └── traps.c # 中断与异常处理 └── mm/ # 内存管理模块 ├── memory.c # 内存分配与释放 └── mm.h # 内存管理头文件
编译环境依赖GNU工具链:gcc 1.40(C编译器)、gas(GNU汇编器)、ld(GNU链接器)和make 3.70。编译后生成的内核文件(Image)大小约50KB,需配合MINIX文件系统镜像才能运行。
二、启动流程解析:从实模式到保护模式的跨越
Linux 0.01的启动流程核心是完成x86处理器从实模式到保护模式的切换,这是实现多任务、内存保护的基础。启动代码分为boot/boot.s(实模式阶段)和boot/head.s(保护模式初始化)两部分,总长度仅173行汇编代码,却实现了复杂的硬件初始化和模式切换逻辑。
2.1 实模式启动:boot.s代码全解析
boot.s是系统启动的第一个执行阶段,由BIOS引导后在实模式下运行,主要完成硬件检测、引导加载和模式切换准备,共95行代码,可分为5个关键步骤:
2.1.1 初始化与硬件检测(1-18行)
org 0x7c00 ; BIOS将MBR加载到0x7c00地址执行 mov ax, cs mov ds, ax mov es, ax call read_disk ; 读取内核到内存 jmp 0:0x9000 ; 跳转到内核加载地址 read_disk: mov ax, 0x9000 mov es, ax mov bx, 0 mov ah, 2 ; BIOS磁盘读功能号 mov al, 4 ; 读取4个扇区 mov dl, 0 ; 第一块硬盘 mov ch, 0 ; 0号磁道 mov dh, 0 ; 0号磁头 mov cl, 2 ; 从2号扇区开始读(1号是MBR) int 0x13 ; 调用BIOS中断 jc read_disk ; 读取失败则重试 ret
这段代码的核心作用是初始化段寄存器并从硬盘读取内核。BIOS在完成POST(加电自检)后,会将硬盘第一个扇区(MBR,512字节)加载到0x7c00地址并执行。由于实模式下段地址+偏移地址的寻址方式(物理地址=段地址×16+偏移地址),这里将cs、ds、es寄存器统一设置为0,确保地址访问正确。
read_disk函数调用BIOS 0x13中断(磁盘服务),将内核从硬盘第2扇区开始的4个扇区(共2048字节)读取到0x9000:0地址。选择4个扇区是因为Linux 0.01内核镜像大小约50KB,后续会通过head.s中的代码继续读取剩余部分。
2.1.2 保护模式准备:打开A20地址线(19-35行)
实模式下处理器受限于8086兼容模式,A20地址线被禁用,导致只能访问1MB内存(地址范围0x00000-0xFFFFF)。要进入保护模式访问1MB以上内存,必须打开A20地址线:
open_a20: in al, 0x92 ; 读取端口0x92(系统控制端口) or al, 0x02 ; 设置第1位(A20地址线使能位) out 0x92, al ret
这段代码通过访问主板8042键盘控制器的0x92端口,直接设置A20地址线使能位。这种方式比早期通过键盘控制器间接打开A20的方式更高效,是386系统的常用做法。
2.1.3 加载全局描述符表(GDT)(36-58行)
保护模式下采用段描述符机制进行内存访问,需要先定义全局描述符表(GDT),用于存储各个内存段的基地址、大小和权限信息:
gdt: dq 0x0000000000000000 ; 空描述符(必须存在) dq 0x00c09a0000000fff ; 内核代码段描述符 dq 0x00c0920000000fff ; 内核数据段描述符 dq 0x0000000000000000 ; 预留 gdt_ptr: dw 23 ; GDT长度(3个描述符×8字节-1) dd gdt ; GDT基地址
Linux 0.01的GDT仅定义了3个有效描述符:空描述符(符合x86规范要求)、内核代码段和内核数据段。描述符采用8字节结构,各字段含义如下:
-
内核代码段(0x00c09a0000000fff):基地址0x00000000,大小4GB(0x0fff表示段界限为4GB),权限位0x9a表示可读可执行、特权级0(内核级)
-
内核数据段(0x00c0920000000fff):基地址0x00000000,大小4GB,权限位0x92表示可读写、特权级0
这种设计将整个4GB物理内存映射为内核代码段和数据段,简化了内存管理,符合早期操作系统的极简思路。
2.1.4 切换到保护模式(59-75行)
mov eax, cr0 ; 读取控制寄存器CR0 or eax, 0x01 ; 设置PE位(第0位),开启保护模式 mov cr0, eax lgdt [gdt_ptr] ; 加载GDT jmp 0x08:0x9000 ; 远跳转,刷新CS寄存器
这段代码是模式切换的核心:通过设置CR0寄存器的PE位(Protection Enable)开启保护模式,然后通过lgdt指令加载GDT表。关键操作是"远跳转"(jmp 0x08:0x9000),其中0x08是内核代码段描述符在GDT中的索引(每个描述符8字节,0x08对应第二个描述符),这个跳转会自动将代码段描述符加载到CS寄存器,完成从实模式到保护模式的过渡。
2.1.5 MBR签名(76-95行)
times 510-($-$$) db 0 ; 填充到510字节 dw 0xaa55 ; MBR结束签名
BIOS判断一个扇区是否为MBR的依据是最后两个字节是否为0xaa55,这段代码通过times指令填充空字节到510字节,然后写入签名,确保BIOS能正确识别和引导。
2.2 保护模式初始化:head.s代码全解析
head.s是Linux 0.01的保护模式初始化代码,共78行,运行在0x9000地址,主要完成内核加载、中断初始化、页表构建和内核入口调用,是连接启动代码和内核核心的关键桥梁。
2.2.1 内核完整加载(1-22行)
boot.s仅加载了内核的前4个扇区,head.s首先完成剩余内核代码的加载:
mov ax, 0x10 ; 内核数据段选择子(GDT中索引0x10) mov ds, ax mov es, ax mov fs, ax mov gs, ax mov ss, ax mov esp, 0x8000 ; 设置栈指针 load_kernel: mov eax, 0x10000 ; 内核加载目标地址 mov ebx, 4 ; 已加载4个扇区 mov ecx, 120 ; 还需加载120个扇区(共50KB) read_loop: mov ah, 2 mov al, 1 mov dl, 0 mov ch, 0 mov dh, 0 mov cl, bl int 0x13 inc ebx dec ecx jnz read_loop
进入保护模式后,首先更新段寄存器为保护模式下的选择子(0x10对应GDT中的内核数据段),并设置栈指针为0x8000(内核数据段内的安全地址)。然后通过循环调用BIOS 0x13中断,将剩余120个扇区的内核代码加载到0x10000地址,完成整个内核的加载。
2.2.2 中断初始化:构建IDT(23-55行)
保护模式下的中断处理需要中断描述符表(IDT),用于存储中断服务程序的地址和属性。Linux 0.01的IDT定义了256个中断描述符,覆盖x86架构的所有中断和异常:
idt: times 256 dq 0x0008000000000000 ; 初始化所有中断描述符 idt_ptr: dw 2047 ; IDT长度(256×8-1) dd idt ; IDT基地址 setup_idt: lea edx, [idt] mov eax, 0x00080000 ; 代码段选择子0x08 + 高32位基地址 mov ecx, 256 idt_loop: mov [edx], ax ; 中断服务程序低16位偏移 mov [edx+2], ax ; 代码段选择子 mov [edx+4], byte 0 ; 保留位 mov [edx+5], byte 0x8e ; 中断门类型(32位、特权级0) mov [edx+6], ah ; 中断服务程序高16位偏移 mov [edx+7], al ; 高32位基地址 add edx, 8 dec ecx jnz idt_loop lidt [idt_ptr] ; 加载IDT ret
这段代码首先初始化IDT为256个空描述符,然后通过循环构建每个中断的门描述符。每个中断门描述符的关键字段包括:代码段选择子(0x08,内核代码段)、中断服务程序地址(初始为0,后续由traps.c填充)、类型字段(0x8e表示32位中断门)。最后通过lidt指令加载IDT表,完成中断初始化。
2.2.3 页表构建与内核入口(56-78行)
Linux 0.01虽然未实现完整的虚拟内存管理,但为了充分利用386的硬件特性,构建了简单的页表实现内存映射:
setup_paging: mov eax, 0x00000000 mov cr3, eax ; 页目录基地址设为0 mov eax, cr0 or eax, 0x80000000 ; 开启分页(PG位) mov cr0, eax jmp 0x08:start_kernel ; 跳转到内核C语言入口 start_kernel: call main ; 调用kernel/main.c中的main函数 hlt ; 主函数返回后停机
这段代码开启了分页机制(设置CR0寄存器的PG位),并将页目录基地址设为0。由于未实现复杂的虚拟内存映射,Linux 0.01采用物理地址直接映射的方式,即虚拟地址等于物理地址。最后通过远跳转到内核C语言入口start_kernel,调用main函数,完成启动流程,进入内核核心逻辑。
三、进程管理模块解析:多任务的雏形实现
进程管理是操作系统的核心功能,Linux 0.01通过sched.c(进程调度)、fork.c(进程创建)和system_call.s(系统调用)实现了多进程的基本功能,虽然简单但包含了现代进程管理的核心思想:进程控制块、上下文切换和调度算法。
3.1 进程控制块(PCB):task_struct结构解析
Linux 0.01在include/linux/sched.h中定义了进程控制块结构task_struct,用于存储进程的所有状态信息,共包含26个字段,大小为152字节:
struct task_struct { long state; // 进程状态:0-运行,1-就绪,2-阻塞 long counter; // 时间片计数器 long priority; // 静态优先级 long signal; // 信号掩码 struct sigaction sigaction[32]; // 信号处理函数 long blocked; // 阻塞信号掩码 struct task_struct *next_task, *prev_task; // 进程链表指针 struct task_struct *next_run, *prev_run; // 就绪队列指针 long saved_eip; // 保存的指令指针 long ebx, ecx, edx, esi, edi, ebp; // 保存的通用寄存器 long esp; // 保存的栈指针 long eip; // 进程指令指针 long eflags; // 标志寄存器 long cs; // 代码段选择子 long ss; // 栈段选择子 long ds, es, fs, gs; // 数据段选择子 long ldtr; // 局部描述符表寄存器 long tr; // 任务状态段寄存器 long cr2; // 页故障地址寄存器 long pid; // 进程ID long ppid; // 父进程ID struct task_struct *p_parent; // 父进程指针 struct mm_struct *mm; // 内存管理结构指针 char comm[16]; // 进程名称 };
3.1.1 核心字段解析
-
状态与调度相关:state(进程状态)、counter(时间片计数器,递减至0时触发调度)、priority(静态优先级,决定时间片初始值)
-
上下文保存:ebx-ebp(通用寄存器)、esp/eip(栈指针和指令指针)、eflags(标志寄存器)、各段选择子,用于进程切换时保存和恢复执行状态
-
进程关系:pid/ppid(进程ID和父进程ID)、p_parent(父进程指针)、next_task/prev_task(全局进程链表)、next_run/prev_run(就绪队列链表)
-
内存与权限:mm(内存管理结构指针)、ldtr/tr(局部描述符表和任务状态段寄存器)、cr2(页故障地址,用于内存错误处理)
3.1.2 进程链表管理
Linux 0.01维护了两个核心进程链表:全局进程链表(通过next_task/prev_task链接所有进程)和就绪队列(通过next_run/prev_run链接就绪状态的进程)。系统启动时创建第一个进程(init进程,pid=1),后续进程通过fork系统调用创建并加入链表。
这种双向链表结构便于进程的添加、删除和遍历,是早期操作系统常用的进程管理数据结构。相比现代Linux的红黑树结构,虽然查询效率较低,但在进程数量较少的场景下足够高效。
3.2 进程调度算法:时间片轮转调度(sched.c)
Linux 0.01实现了最简单的时间片轮转调度算法,核心代码位于sched.c的schedule()函数,共128行代码,负责进程的选择和上下文切换。
3.2.1 调度触发机制
调度触发主要通过两种方式实现:
-
时间片耗尽:系统定时器(8253芯片)每10ms产生一次中断(IRQ0),在中断处理函数中递减当前进程的counter字段,当counter≤0时设置need_resched标志,触发调度
-
主动放弃CPU:进程通过sleep()等系统调用主动将状态设为阻塞,调用schedule()触发调度
void schedule(void) { struct task_struct **p; int i, next, c; // 遍历就绪队列,寻找counter最大的进程 next = current->pid; c = -1; for (i=0; i<NR_TASKS; i++) { if (task[i] && task[i]->state == TASK_RUNNING && task[i]->counter > c) { c = task[i]->counter; next = i; } } // 如果所有进程时间片耗尽,重新分配时间片 if (c <= 0) { for (i=0; i<NR_TASKS; i++) { if (task[i]) { task[i]->counter = (task[i]->counter >> 1) + task[i]->priority; } } } // 切换到选中的进程 if (next != current->pid) { switch_to(next); } }
3.2.2 调度核心逻辑
schedule()函数的核心逻辑分为三步:
2. 时间片重新分配:当所有就绪进程的counter都≤0时,按照"counter = 原counter/2 + priority"的公式重新分配时间片。这种设计让进程的剩余时间片能部分继承,避免频繁切换,同时优先级越高的进程获得的时间片越多。
3. 进程切换:调用switch_to()宏(定义在include/asm/system.h)完成上下文切换,该宏通过汇编代码保存当前进程的寄存器状态,加载目标进程的状态,并更新current指针(指向当前运行进程)。
3.2.3 switch_to()上下文切换宏解析
#define switch_to(n) \ asm volatile("pushl %%ebp\n\t" \ "movl %%esp,%0\n\t" \ "movl %2,%%esp\n\t" \ "movl $1f,%1\n\t" \ "pushl %%cs\n\t" \ "jmp __switch_to\n\t" \ "1:\n\t" \ "popl %%ebp" \ : "=m" (current->esp), "=m" (current->eip) \ : "m" (task[n]->esp), "m" (task[n]->eip), "a" (n) \ : "memory");
这个宏是上下文切换的核心,通过汇编代码完成以下操作:
-
保存当前进程的ebp和esp寄存器到其PCB的esp字段
-
将当前指令指针(下一条要执行的指令地址"1:")保存到PCB的eip字段
-
加载目标进程的esp和eip寄存器,跳转到目标进程的执行地址
-
目标进程恢复执行时,从栈中弹出ebp寄存器,恢复执行上下文
这种上下文切换方式充分利用了x86的寄存器操作,虽然代码简短但效率很高,为后续Linux内核的上下文切换机制奠定了基础。
3.3 进程创建:fork系统调用解析(fork.c)
Linux 0.01通过fork系统调用实现进程创建,核心代码位于fork.c,共89行代码。fork采用"写时复制"的早期实现(虽然未完全实现现代写时复制的惰性复制机制,但已包含核心思想),创建子进程时复制父进程的地址空间和PCB信息。
3.3.1 fork系统调用流程
int sys_fork(void) { struct task_struct *p; int i; // 寻找空闲的PCB槽位 for (i=0; i<NR_TASKS; i++) { if (!task[i]) break; } if (i>=NR_TASKS) return -EAGAIN; // 分配并复制PCB p = (struct task_struct *)get_free_page(); *p = *current; // 复制父进程PCB // 初始化子进程状态 p->pid = i; p->ppid = current->pid; p->state = TASK_RUNNING; p->counter = p->priority; p->p_parent = current; // 复制父进程地址空间 copy_mem(p); // 将子进程加入进程链表 add_task(p); return p->pid; // 给父进程返回子进程PID,子进程返回0 }
fork系统调用的核心流程分为五步:
-
寻找空闲PCB槽位:遍历task数组(大小NR_TASKS=64),找到第一个空闲的槽位作为子进程的pid
-
分配并复制PCB:通过get_free_page()分配一页内存(4KB)作为子进程的PCB,然后直接复制父进程的PCB内容
-
初始化子进程状态:更新子进程的pid、ppid、状态(设为就绪)、时间片和父进程指针,确保子进程状态正确
-
复制地址空间:调用copy_mem()函数复制父进程的代码段、数据段和栈段到子进程的地址空间
-
加入进程链表:调用add_task()将子进程加入全局进程链表和就绪队列,完成创建
3.3.2 地址空间复制:copy_mem()函数解析
copy_mem()函数负责复制父进程的地址空间,是fork系统调用中最耗时的操作,核心代码如下:
void copy_mem(struct task_struct *p) { unsigned long old_data_base, new_data_base; unsigned long data_limit; // 获取父进程和子进程的数据段基地址 old_data_base = get_base(current->ldt[1]); new_data_base = get_base(p->ldt[1]); data_limit = get_limit(0x17); // 数据段限长 // 复制数据段和栈段(从父进程地址空间到子进程) memcpy((void *)new_data_base, (void *)old_data_base, data_limit); // 更新子进程的LDT(局部描述符表) set_base(p->ldt[1], new_data_base); set_base(p->ldt[2], new_data_base + data_limit); }
Linux 0.01的进程地址空间分为代码段(0x08)、数据段(0x10)和栈段(0x18),其中数据段和栈段连续分布。copy_mem()函数通过以下步骤复制地址空间:
-
通过get_base()函数获取父进程和子进程的数据段基地址(子进程的基地址由get_free_page()分配)
-
调用memcpy()复制父进程数据段和栈段的所有内容到子进程地址空间(数据段限长由get_limit()获取,默认为64KB)
-
更新子进程的局部描述符表(LDT),将数据段和栈段的基地址指向新分配的内存区域
这种直接复制的方式虽然简单,但效率较低(尤其是地址空间较大时)。现代Linux采用写时复制(COW)机制,只有当父子进程修改内存时才进行复制,大幅提升了fork的效率。
3.3.3 fork的返回值机制
fork系统调用的独特之处在于会返回两次:给父进程返回子进程的pid,给子进程返回0。这一机制通过内核在复制PCB后修改子进程的返回值实现:
; system_call.s中的系统调用返回处理 sys_call_end: cmp eax, 0 jz child_return ; 如果是子进程(eax=0),修改返回值 ret child_return: mov eax, 0 ; 子进程返回0 ret
当fork系统调用执行完成后,内核会检查当前进程是否为子进程(通过PCB中的pid是否为新创建的pid),如果是则将返回值设为0,否则返回子进程的pid。这种机制让父子进程能通过返回值区分彼此,执行不同的逻辑。
四、内存管理模块解析:物理内存的极简管理
Linux 0.01的内存管理模块位于mm目录(memory.c和mm.h),共156行代码,实现了基于分区的物理内存管理。由于受限于386处理器的早期特性和简化设计,未实现虚拟内存和页式管理的完整功能,但已包含内存分配、释放和地址空间管理的核心逻辑。
4.1 内存布局:386保护模式下的内存划分
Linux 0.01运行在386保护模式下,支持最大16MB物理内存(受限于早期硬件),内存布局如下(从低地址到高地址):
|
地址范围 |
大小 |
用途 |
|---|---|---|
|
0x000000-0x000FFF |
4KB |
实模式中断向量表(IVT) |
|
0x001000-0x007FFF |
28KB |
BIOS数据区和预留空间 |
|
0x008000-0x009FFF |
8KB |
内核启动代码(boot.s和head.s) |
|
0x00A000-0x00FFFF |
40KB |
显存和硬件预留空间 |
|
0x010000-0x09FFFF |
576KB |
内核代码段和数据段 |
|
0x0A0000-0xFFFEFF |
15.6MB |
用户进程地址空间和空闲内存 |
这种布局充分考虑了x86硬件的兼容性,将低地址空间留给BIOS和硬件,内核占用中间固定区域,高地址空间分配给用户进程。内存管理的核心是对0x0A0000以上的空闲内存进行分配和管理。
4.2 内存分配机制:基于位图的页分配
Linux 0.01采用页式内存分配方式,每页大小为4KB(386处理器的默认页大小),通过位图(bitmap)记录内存页的使用状态。内存分配的核心函数是get_free_page()(分配一页空闲内存)和free_page()(释放一页内存)。
4.2.1 内存位图初始化
内存位图位于内核数据段,每个bit代表一页内存的使用状态(0表示空闲,1表示已分配)。由于最大支持16MB内存,共需要16MB/4KB=4096页,因此位图大小为4096/8=512字节:
#define PAGE_SIZE 4096 #define NR_PAGES (16*1024*1024 / PAGE_SIZE) // 4096页 unsigned char mem_map[NR_PAGES/8] = {0}; // 内存位图,初始化为0(空闲) void mem_init(long start_mem, long end_mem) { int i; long pages = (end_mem - start_mem) / PAGE_SIZE; long page = start_mem / PAGE_SIZE; // 标记已使用的内存页(内核占用区域) for (i=0; i<pages; i++) { set_bit(page+i, mem_map); } }
mem_init()函数在系统启动时被调用,用于初始化内存位图。它根据内核占用的内存范围(start_mem到end_mem),通过set_bit()函数将对应的内存页标记为已使用,剩余内存页保持空闲状态。
4.2.2 内存分配:get_free_page()函数
unsigned long get_free_page(void) { int i; // 遍历位图,寻找第一个空闲页 for (i=0; i<NR_PAGES; i++) { if (!test_bit(i, mem_map)) { // 标记为已分配 set_bit(i, mem_map); // 返回该页的物理地址 return (unsigned long)(i * PAGE_SIZE); } } panic("Out of memory"); // 无空闲内存时崩溃 return 0; }
get_free_page()函数的核心逻辑是遍历内存位图,找到第一个bit为0的空闲页,通过set_bit()标记为已分配,然后返回该页的物理地址(页号×页大小)。这种"首次适应"的分配算法简单高效,适合内存页数量较少的场景。
当没有空闲内存时,调用panic()函数触发系统崩溃,这体现了早期操作系统的简化设计,未实现内存交换(swap)等高级功能。
4.2.3 内存释放:free_page()函数
void free_page(unsigned long addr) { if (addr < LOW_MEM || addr > HIGH_MEM) return; // 地址超出范围,忽略 addr -= LOW_MEM; // 转换为相对页号 clear_bit(addr/PAGE_SIZE, mem_map); // 标记为空闲 }
free_page()函数接收一个物理地址,首先检查地址是否在合法范围内(LOW_MEM=0x0A0000,HIGH_MEM=0x1000000),然后将地址转换为相对页号,通过clear_bit()函数将位图中对应的bit设为0,完成内存释放。
需要注意的是,Linux 0.01未实现内存碎片整理机制,频繁的分配和释放会导致内存碎片,影响内存利用率。现代Linux通过伙伴系统和slab分配器解决了这一问题。
4.3 地址空间管理:段式管理的实现
Linux 0.01采用段式内存管理,基于386的段描述符机制实现地址空间隔离。每个进程拥有独立的局部描述符表(LDT),包含代码段、数据段和栈段三个描述符,实现进程地址空间的隔离。
4.3.1 LDT初始化与切换
进程的LDT在fork创建时由copy_mem()函数初始化,每个进程的LDT存储在其PCB的ldt字段中。当进程切换时,通过lldt指令加载新进程的LDT:
; switch_to()宏中的LDT切换代码 mov ax, [task[n]->ldt+2] ; 获取LDT描述符的选择子 lldt ax ; 加载新进程的LDT
386处理器的LDTR寄存器存储当前进程的LDT基地址和大小,通过lldt指令加载新的LDT选择子后,处理器会自动从GDT中获取该LDT的基地址和大小,完成LDT切换,实现进程地址空间的隔离。
4.3.2 段地址转换
保护模式下的段地址转换过程如下:
-
处理器接收段选择子(如0x08),通过选择子的索引字段找到GDT或LDT中的对应描述符
-
从描述符中获取段基地址、段限长和权限信息
-
检查偏移地址是否超出段限长,以及当前进程的权限是否符合要求(如用户进程不能访问内核段)
-
计算物理地址=段基地址+偏移地址,完成地址转换
Linux 0.01的用户进程代码段基地址为0x0A0000,限长64KB;数据段基地址与代码段相同,限长64KB;栈段基地址为数据段基地址+64KB,限长64KB。这种固定的段布局简化了地址管理,但限制了进程的地址空间大小(仅192KB),后续Linux

877

被折叠的 条评论
为什么被折叠?



