在开始之前,建议先熟悉一下实验代码的整体框架。
第零步: 从 BIOS 到 MBR #
这一部分是刻录在内存中的BIOS在发力,OS 不需要有任何代码实现,所以定为第零步。
1. 历史背景:早期 PC 的物理地址空间 #
图示:现代计算机的内存布局,以32位机器为例。
要理解操作系统启动,首先需要了解早期计算机的内存布局。
第一代 PC 基于 16 位的 Intel 8086 处理器,其物理寻址能力仅为 1MB(从 0x00000000
到 0x000FFFFF
)。这时候的内存布局大致如下:
- 低端内存 (Low Memory):地址空间的前 640KB(
0x00000000
-0x0009FFFF
)是当时 PC 能使用的唯一随机存取存储器 (RAM)。 - 保留区域:从 640KB 到 1MB 的 384KB 区域(
0x000A0000
-0x000FFFFF
)被硬件保留,用于视频显存、硬件固件等。该保留区域中最重要的部分是 基本输入/输出系统 (BIOS),它位于0x000F0000
到0x000FFFFF
的 64KB 空间内。
尽管后来的 80286 和 80386 处理器支持了更大的物理地址空间(16MB 和 4GB),但为了保持软件兼容性,PC 架构师保留了这最初 1MB 的地址布局。这导致了现代 PC 物理内存中存在一个从 0x000A0000
到 0x00100000
的“空洞”(即上文提到过的保留区域),将 RAM 划分为“低端内存”和“扩展内存”,也就是上图中的"Low Memory"和"Extended Memory"。
内存划分:
2. 启动第一阶段:BIOS (基本输入/输出系统) #
计算机按下开机键后,首先执行的就是固化在主板芯片上的 BIOS 程序。
-
BIOS 的职责:
- 开机自检 (POST):进行基本的硬件初始化和检查,例如激活显卡、检测内存容量等。
- 引导加载:在完成自检后,BIOS 会从指定的启动设备(如硬盘、U盘)加载操作系统。
- 移交控制权:将计算机的控制权交给加载的程序。
-
BIOS 的工作流程:
- BIOS 会依次检查每个存储设备。
- 它将设备的第一个扇区(512 字节)加载到内存的固定地址
0x7c00
处。 - 然后检查该扇区最后的两个字节是否为魔数 (Magic Number)
0x55AA
。 - 如果魔数匹配,BIOS 就认为找到了一个可启动的设备,并跳转到
0x7c00
地址,开始执行刚刚加载的代码。至此,BIOS 的任务完成。 - 如果魔数不匹配,BIOS 会尝试下一个设备。如果所有设备都失败,则会报告“找不到启动设备”。
现代计算机多使用 UEFI 替代传统 BIOS,UEFI 可视为其功能更强大的升级版。
3. CPU 的初始状态:实模式 (Real Mode) #
为了保持向后兼容,现代 CPU 在启动时会工作在实模式下,这是一个模拟早期 16 位处理器的状态。其主要特点如下:
- 16 位寄存器:只会使用 32 位寄存器的低 16 位,如
AX
,BX
,CS
,IP
等(而不是EAX
,EBX
,ECS
,EIP
)。 - 寻址方式:物理地址的计算方式为
(段寄存器 << 4) + 偏移地址
。由于段寄存器和偏移地址都是 16 位的,这种方式可以访问2^20 = 1MB
的地址空间。例如,CPU 启动时的第一条指令位于CS:IP
=0xf000:0xfff0
,其物理地址为(0x0000f000 << 4) + 0x0000fff0 = 0x000ffff0
, 这正好是 1MB 地址空间的末尾,即 BIOS 代码的入口点。 - 中断处理:中断由 BIOS 提供的服务来处理。
4. 启动第二阶段:主引导扇区 (MBR) #
被 BIOS 加载到 0x7c00
并执行的代码,就是存储在磁盘第一个扇区中的主引导扇区 (MBR)。
- MBR 的结构:它是一个 512 字节的数据块,包含两部分:
- 启动加载器 (Bootloader):一小段可执行代码,其核心任务是加载真正的操作系统,并把控制权交给操作系统。
- 魔数
0x55AA
:位于最后两个字节,作为可启动扇区的标志。
- MBR 的职责:当 BIOS 跳转到
0x7c00
后,Bootloader 开始执行,接管系统的启动流程。可以说,计算机的启动完成了从 BIOS 到 MBR 的交接。
5. MBR 的构建与编译 #
MBR 本质上就是一段符合特定格式的二进制机器码。其编译过程如下:
- 编译:使用
gcc
将汇编源文件 (mbr.S
) 编译成可重定位的目标文件 (.o
)。 - 链接:使用链接器
ld
将目标文件链接成 ELF 格式的可执行文件。- 为什么用
ld
而不是gcc
链接? 因为gcc
会默认链接一些依赖特定操作系统(如 Linux)环境的标准库,而 MBR 是运行在“裸机”(无操作系统)环境上的。 ld
的关键参数:-m i386
: 指定生成 32 位 i386 架构的 ELF 文件。-Ttext 0x7c00
: 告知链接器,代码的加载和运行地址是0x7c00
。-e _start
: 指定程序的入口点为_start
标签。
- 为什么用
- 提取:使用
objcopy
工具,从 ELF 文件中抽取出纯二进制的机器指令(.text
段),并存为一个.bin
文件。 - 格式化:通过脚本(如
genboot.pl
)将.bin
文件填充至 510 字节,并在末尾附加两个字节的魔数0x55AA
,最终生成 512 字节的 MBR 文件。
关于入口地址 _start
的补充说明:
-e _start
参数对于 ELF 文件格式是必需的,但对最终的 MBR 二进制文件没有直接影响。真正重要的是,_start
标签必须位于汇编代码的最开始。这样可以确保它对应的机器码被放置在二进制文件的最前面,从而在被加载到 0x7c00
后,成为 CPU 执行的第一条指令。如果将其他函数放在 _start
之前,那么 BIOS 将会错误地从那个函数开始执行。
第一步:从实模式切换到保护模式 #
我们知道,计算机启动时,BIOS 首先进行硬件初始化,然后将磁盘第一个扇区(MBR)加载到内存地址 0x7c00
并跳转至此执行。MBR 的核心任务是找到并加载操作系统内核。
然而,在S02E00提到过,BIOS 在完成自举并将控制权交给 MBR 之前,会将 CPU 置于实模式 (Real Mode)。实模式只有约 1MiB 的内存寻址能力,对于现代操作系统来说远远不够;此外,实模式使用16位指令集,我们更熟悉的是 32 位或 64 位的指令集。
因此,在加载内核之前,MBR 必须先将 CPU 从实模式切换到保护模式 (Protected Mode)。这个过程主要分为两步:
- 设置全局描述符表 (Global Descriptor Table, GDT)
- 修改 CR0 寄存器
后一步比较好理解:CR0 寄存器的第 0 位是保护模式启用(PE)标志位,这是由CPU硬件设计的。将该位置1是正式进入保护模式的必要步骤。但是,前一步中的GDT是什么?带着疑问继续。
核心理论:保护模式下的寻址机制 #
1. 逻辑地址->线性地址->物理地址 #
开启保护模式后,内存寻址方式发生了根本变化。程序给出的地址不再是直接的物理地址,而是一个逻辑地址 (Logical Address),有时也称为虚拟地址。
一个逻辑地址由两部分组成:
Segment Selector
16位段选择子:存放在段寄存器中(如CS
,DS
,SS
)。Offset
32位段内偏移量:由程序直接提供。
CPU 需要将这个 48 位的逻辑地址转换为线性地址 (Linear Address);尚未开启分页机制时,线性地址等同于物理地址。转换公式如下:
Linear Address = Base Address + Offset
其中,Base Address
段基地址是一个 32 位地址,定义了一个内存段的起始位置。他由GDT和段选择子共同决定。
2. GDT #
GDT 是一个由连续的段描述符 (Segment Descriptor) 构成的数组。在开启保护模式前,操作系统必须在内存中创建好 GDT。
段描述符是一个 64 位(8字节)的数据结构,用于描述一个内存段的各种属性,如段基址、段限长、权限级别等。
CPU 通过 GDTR 寄存器找到 GDT。该寄存器存储了 GDT 在内存中的物理基地址和表的大小。我们需要使用 LGDT
指令来加载它。
以下是段描述符的结构,以及我们在这次实验下的配置:
关键字段配置如下:
Base Address
段基址 (共32位): 这就是我们要找的!设置为0
。Limit
段限长 (共20位): 设置为0xFFFFF
(全1)。G Granularity
: 设为1
。此时段限长的单位是 4KB,0xFFFFF * 4KB
恰好是 4GB,即最大寻址空间。X
: 设为1
,表示使用 32 位操作数。P (Present)
: 设为1
,表示该段存在于内存中。DPL (Descriptor Privilege Level)
: 设为0
,表示最高特权级(内核级)。S (Descriptor Type)
: 设为1
,表示这是一个代码段或数据段描述符。TYPE
: 用于区分代码段和数据段及其读写权限。
为了简化地址转换,我们采用一种特殊的模式——扁平模型。在该模型下,我们将所有段的段基地址 (Base Address) 设置为 0,并将段的界限设置为最大值 (4GB)。这样,地址转换公式就变为:
物理地址 = 线性地址(未分页) = 0 + 段内偏移量
。此时,程序给出的 32 位偏移量就直接对应了 32 位的物理地址,极大地简化了内存管理。
段选择子 #
段选择子本质上是指向 GDT 中某个段描述符的索引。
15 3 2 1 0
+-------------------------+---+-----+
| INDEX | T | RPL |
| | I | |
+-------------------------+---+-----+
INDEX
:13 位,指定了要使用的段描述符在 GDT 中的索引(下标)。TI
:表格指示位。0 表示在 GDT 中查找,1 表示在 LDT 中查找(本次实验只使用 GDT)。RPL
:请求者特权级。
总结:地址转换是如何发生的? #
- CPU 根据段寄存器中的段选择子,获取 GDT 的索引 (Index)。
- 使用该索引在 GDT 中找到对应的段描述符。
- 从段描述符中读取段基地址(在扁平模式下为0)。
- 将段基地址与指令中的Offset相加,得到最终的线性地址(即物理地址)。
动手实践:修改 MBR 代码 (boot/start.S
)
#
我们的任务是补全 MBR 的代码,使其能成功进入保护模式。相关代码位于 boot/start.S
文件中。
.code16
.text
.global _start
_start:
cli # clear interuption
xorw %ax, %ax
movw %ax, %ds
movw %ax, %es
movw %ax, %ss
inb $0x92, %al # Fast setup A20 Line with port 0x92, necessary or not?
orb $0x02, %al
outb %al, $0x92
lgdt gdt_desc # loading gdt
# TODO: WEEK1-os-start, set the lowest bit of cr0
ljmp $0x08, $start32 # reload code segment selector and ljmp to start32
.code32
start32:
movw $0x10, %ax # setting data segment selector
movw %ax, %ds
movw %ax, %es
movw %ax, %fs
movw %ax, %ss
movl $0x1ffffc, %esp # setting esp
call load_kernel # call load_kernel in boot.c
.L0:
jmp .L0 # should never come back
.p2align 2
gdt: # 8 bytes for each table entry, at least 1 entry
# .word limit[15:0], base[15:0]
# .byte base[23:16], (0x90|(type)), (0xc0|(limit[19:16])), base[31:24]
# empty entry
.word 0, 0
.byte 0, 0, 0, 0
# TODO: WEEK1-os-start, code segment entry
# TODO: WEEK1-os-start, data segment entry
gdt_desc: # 6 bytes in total
.word (gdt_desc - gdt - 1) # sizeof(gdt) - 1
.long gdt # offset, i.e. linear address of the table itself
start.S
中的执行流程如下:
- 关中断 (
cli
) - 设置实模式下的数据段寄存器
- 开启 A20 地址线
- 加载 GDTR (
lgdt
) - 开启保护模式 (设置 CR0 寄存器) <– 代码任务2
- 长跳转以更新 CS 寄存器,正式在保护模式下执行
- 设置保护模式下的数据段和栈寄存器
- 跳转到 C 函数
load_kernel
(位于boot.c
)
代码任务 1:补全 GDT #
在 start.S
的末尾,GDT 的定义不完整,需要我们补充。GDT 由多个段描述符构成,按照规范,第一个(索引为0)是空描述符。我们需要额外定义一个代码段和一个数据段。
根据扁平模式的要求,这两个段的段基址都为0,段限长都为 4GB。在汇编中,我们可以使用 .byte
(1字节), .word
(2字节), .long
(4字节)(这里用不到) 来精确构造一个 8 字节的段描述符。i386 架构是小端序。
完成 GDT 定义后,代码中会将代码段选择子
0x8
(索引1) 加载到 CS,将数据段选择子0x10
(索引2) 加载到 DS、ES、SS。
代码任务 2:设置 CR0 寄存器 #
在加载 GDTR 之后,需要将 CR0 寄存器的最低位(PE - Protection Enable)置为 1。这可以通过三条指令完成:
- 将 CR0 的值移动到一个通用寄存器(如 EAX)。
- 使用
or
指令将该寄存器的最低位置为 1。 - 将修改后的值写回 CR0。
在
mov eax, cr0
指令执行后,CPU 已进入保护模式,但 CS 寄存器仍然是实模式的值。CPU 是如何取到下一条ljmp
指令的呢?这是因为 CPU 的指令预取机制,它会在执行当前指令时提前读取下一条指令。执行完 CR0 的修改后,它会继续执行这条被预取好的ljmp
指令,从而正确刷新 CS 并清空流水线。
第二步:加载 ELF 可执行文件 #
成功进入保护模式后,MBR的第二个、也是最后一个核心任务,便是从磁盘加载操作系统的内核文件,并将计算机的控制权彻底移交给它。
在我们的设计中,操作系统内核被编译成一个标准的 ELF (Executable and Linkable Format) 文件,并存储在磁盘的第 1 至 255 扇区。因此,MBR 的任务本质上就演变成了一个ELF 加载器 (ELF Loader)。注意本文之后提到的加载器就是指 MBR 的这部分代码。
在我们编写代码之前,必须对 ELF 文件格式有一个清晰且深入的理解。
理论部分:ELF 文件 #
一个可执行的 ELF 文件,从加载器的视角来看,可以被精确地划分为几个关键部分。虽然其内部结构远比下图复杂,但对于加载任务而言,我们只需关注以下核心组件:
+--------------+------------------------+---------------------------------+
| ELF Header | Program Header Table | File Data (Segments) | ... 其他部分 ...
+--------------+------------------------+---------------------------------+
^ ^
| |
+-- 文件起始 +-- e_phoff
- ELF 头 (ELF Header): 始终位于文件的最开始处。它像是一个文件的“身份证”和“目录”,描述了文件的基本属性,并提供了定位其他重要数据结构(如程序头表)的指针。
- 程序头表 (Program Header Table): 这是一个至关重要的数组。它告诉加载器,文件中的哪些部分(称为 Segments)需要被加载到内存的什么位置,以及它们的属性(如读、写、执行权限)。加载器的工作完全依赖于这张表。
- 文件数据 (Segments): 这是 ELF 文件的主体,包含了实际的机器代码(如
.text
段)和已初始化的数据(如.data
段)。程序头表会精确地指向这部分数据。
ELF 头 (Elf32_Ehdr
):加载的起点
#
ELF 头的具体结构由 C 语言的 struct
定义。对于 32 位系统,它是 Elf32_Ehdr
:
#define EI_NIDENT 16
typedef struct {
unsigned char e_ident[EI_NIDENT]; // ELF 识别信息 (Magic Number)
uint16_t e_type; // 文件类型 (如可执行文件)
uint16_t e_machine; // 目标架构 (如 x86)
uint32_t e_version; // 文件版本
Elf32_Addr e_entry; // 程序的虚拟入口地址
Elf32_Off e_phoff; // 程序头表在文件中的偏移量
Elf32_Off e_shoff; // 节头表在文件中的偏移量 (加载器不关心)
uint32_t e_flags; // 处理器特定标志
uint16_t e_ehsize; // ELF 头自身的大小
uint16_t e_phentsize; // 程序头表中每个条目的大小
uint16_t e_phnum; // 程序头表中条目的数量
uint16_t e_shentsize; // 节头表中每个条目的大小
uint16_t e_shnum; // 节头表中条目的数量
uint16_t e_shstrndx; // 节名称字符串表的索引
} Elf32_Ehdr;
为了完成加载任务,我们必须关注以下几个成员:
e_entry
: 这是程序的入口点虚拟地址。当所有数据都成功加载到内存后,MBR 必须跳转到这个地址,才能开始执行内核代码。e_phoff
: 指明了程序头表相对于文件开头的字节偏移量。通过它,我们可以找到加载指令的集合。e_phnum
: 指明了程序头表中有多少个条目。这告诉我们的加载循环需要执行多少次。e_phentsize
: 指明了程序头表中每一个条目(即一个Elf32_Phdr
结构体)的大小。这对于在循环中定位下一个条目至关重要。
3. 程序头表 (Elf32_Phdr
):加载操作的详细指南
#
程序头表是一个由 e_phnum
个 Elf32_Phdr
结构体组成的数组。每一个结构体描述了一个 Segment。一个 Segment 是从装载视角看的一块连续内存区域,它可能包含了一个或多个 Section (如 .text
, .data
等)。加载器只关心 Segment。
Elf32_Phdr
结构如下:
typedef struct {
uint32_t p_type; // Segment 类型
Elf32_Off p_offset; // Segment 数据在文件中的偏移量
Elf32_Addr p_vaddr; // Segment 在内存中的目标虚拟地址
Elf32_Addr p_paddr; // Segment 在内存中的目标物理地址
// (在简单系统中与 vaddr 相同)
uint32_t p_filesz; // Segment 在文件中的大小
uint32_t p_memsz; // Segment 在内存中应占的大小
uint32_t p_flags; // Segment 的权限 (读/写/执行)
uint32_t p_align; // Segment 在内存中的对齐要求
} Elf32_Phdr;
加载过程的核心就是遍历这张表,并对特定类型的条目进行操作:
p_type
: 我们只关心类型为PT_LOAD
的 Segment。这表示该 Segment 包含了需要被加载到内存中才能执行的代码或数据。p_offset
: 指明了该 Segment 的内容从 ELF 文件的哪个位置开始。p_vaddr
: 极其重要,它指定了这段数据必须被拷贝到内存中的目标虚拟地址。p_filesz
和p_memsz
: 这两个字段解释了为什么加载不仅仅是简单的复制。p_filesz
: 需要从 ELF 文件中实际复制的数据大小。p_memsz
: 该 Segment 加载后在内存中应该占据的总大小。- 为什么
p_memsz
>p_filesz
? 这通常是为了处理像.bss
这样的节。.bss
存储了未初始化的全局变量和静态变量。它们在程序运行时需要占据内存空间 (p_memsz
),但在可执行文件中并不需要存储它们的初始值(因为都是零),所以不占文件空间 (p_filesz
)。因此,加载器的任务是: 1. 从文件p_offset
处复制p_filesz
字节到内存p_vaddr
处。 2. 在内存p_vaddr + p_filesz
处,用 0 填充p_memsz - p_filesz
字节。
下图生动地描绘了这个过程:
4. ELF 文件加载算法总结 #
结合以上知识,我们可以总结出 MBR 加载内核的精确算法:
- 读取文件: 将整个内核 ELF 文件(磁盘 1-255 扇区)一次性读入到内存中一个临时的、安全的位置(例如
0x8000
)。 - 定位 ELF 头: 将
0x8000
地址强制转换为Elf32_Ehdr*
指针,这样我们就可以方便地访问 ELF 头的各个成员。 - 定位程序头表:
- 从 ELF 头中读取
e_phoff
,得到程序头表相对于文件开头的偏移。 - 程序头表的起始内存地址为
0x8000 + e_phoff
。
- 从 ELF 头中读取
- 遍历程序头表:
- 从 ELF 头中读取
e_phnum
,得知需要循环的次数。 - 写一个循环,从 0 到
e_phnum - 1
。 - 在循环中,计算当前程序头条目(
Elf32_Phdr
)的地址。
- 从 ELF 头中读取
- 处理可加载段:
- 在循环的每一步,检查当前
Elf32_Phdr
条目的p_type
成员。 - 如果
p_type == PT_LOAD
,则执行加载操作: a. 复制数据: 使用memcpy
,将数据从源地址(0x8000 + p_offset
)复制到目标地址(p_vaddr
),复制的长度为p_filesz
。 b. 清零 BSS: 使用memset
,将目标地址从p_vaddr + p_filesz
开始的区域清零,清零的长度为p_memsz - p_filesz
。
- 在循环的每一步,检查当前
- 跳转至内核入口:
- 所有
PT_LOAD
段都处理完毕后,从 ELF 头中读取e_entry
成员,这就是内核的入口地址。 - 将
e_entry
转换为一个函数指针,并直接跳转过去。至此,MBR 的历史使命完成,操作系统正式接管计算机。
- 所有
以上的步骤大部分也是一个 ELF 运行的通用流程。不同的操作系统可能会有细微差别,但核心思想是一致的。
动手实践:实现 load_kernel
函数
#
完成 boot/boot.c
中的 load_kernel
函数,从 ELF 文件加载操作系统并跳转。
将 255 个扇区的内核文件加载到
0x8000
,是否会覆盖其他重要数据? 答案是不会,我们可以通过一个简单的内存地图来确认:
地址范围 内容 状态 0x00007c00 - 0x00007e00 MBR 代码 (我们当前正在执行的代码) 安全 0x00008000 - 0x00027E00 临时加载的内核 ELF 文件 (255 * 512 字节) 安全 … 未使用区域 安全 0x000a0000 - 0x00100000 BIOS 和硬件保留的 ROM 区域 安全 0x00101000 - … 内核的最终目标加载地址 安全 0x001FFFFC 栈顶指针 (ESP) 的初始位置 安全 从上图可见,我们临时存放 ELF 文件的
[0x8000, 0x27E00)
区域是完全空闲的;它既不会覆盖 MBR 自身,也不会影响硬件保留区。 内核最终要被加载到的目标地址0x101000
远高于这个临时区域。我们的栈指针设置得非常高,完全不会与加载过程冲突。因此,这个方案是安全且可行的。
第三步:实现初步的进程管理 #
通过前一阶段的努力,我们已经成功地将操作系统的内核(一个 ELF 文件)加载到内存并开始执行。内核的本质,就是一个 C 语言程序。现在,我们的操作系统已经启动,它的核心职责之一就是管理和运行其他程序。在现代操作系统中,一个正在运行的程序被抽象为一个更为强大和灵活的概念——进程 (Process)。
在我们的操作系统能够加载并运行第一个真正的用户程序之前,我们必须先构建起进程管理的基础设施。
理论部分:进程管理基础 #
1. 核心概念:为何需要进程? #
进程并不仅仅是“一个正在运行的程序”,它是对程序执行过程的一次完整抽象。一个成熟的进程应包含:
- 执行状态: 它拥有独立的执行流,包括程序计数器(即 EIP 寄存器)、栈指针(ESP)以及其他 CPU 寄存器的当前值。
- 资源集合: 它“拥有”一系列计算资源,例如独立的虚拟内存空间、打开的文件句柄、网络连接等。
- 隔离性: 操作系统确保一个进程的运行不会直接干扰到另一个进程的资源,为系统的稳定性和安全性提供了保障。
在我们的起步阶段,我们还未实现虚拟内存、中断上下文切换等复杂功能。因此,暂时只需牢记进程的核心思想:它是一个程序执行实例的抽象,是操作系统用来管理和分配资源的基本单位。 随着课程的推进,我们会逐步为这个抽象填充更多具体的实现细节。
2. 进程的“身份证”:进程控制块 (Process Control Block, PCB) #
操作系统是如何追踪和管理众多进程的呢?答案是通过为每个进程维护一个专属的数据结构——进程控制块 (PCB)。PCB 存储了关于一个进程的所有关键信息。
您可以在 kernel/include/proc.h
中找到我们早期版本的 PCB 定义,即 proc_t
结构体:
typedef struct proc {
size_t entry; // 进程的入口地址 (在实现中断前使用的临时方案)
size_t pid; // 进程ID
enum { UNUSED,
UNINIT,
RUNNING,
READY,
ZOMBIE,
BLOCKED } status; // 进程状态
// 其他成员将在后续实验中逐步启用
} proc_t;
让我们来深入解析每个成员的含义:
pid
(Process ID): 进程号,一个独一无二的数字,作为进程的唯一标识符。操作系统通过 PID 来区分不同的进程。status
: 进程状态,这是一个枚举类型,描述了进程当前所处的生命周期阶段:UNUSED
: 表示这个 PCB 结构体是空闲的,不代表任何进程,可以被分配给一个新创建的进程。UNINIT
: 表示这个 PCB 已经被分配给一个新进程,但其相关的资源(如内存、内核栈等)尚未初始化完毕,因此还不能被调度执行。RUNNING
: 表示该进程当前正在 CPU 上执行。由于我们的系统目前是单核 CPU,所以在任何时刻,最多只能有一个进程处于RUNNING
状态。READY
: 表示该进程已经万事俱备,可以被执行,但由于 CPU 正在执行其他进程,它只能在就绪队列中等待被调度。ZOMBIE
,BLOCKED
: 其他状态,我们将在后续的实验中详细讨论。
entry
: 入口地址。这是一个临时性的成员。在一个真正的操作系统中,当进程切换发生时,CPU 的所有寄存器状态(包括指令指针 EIP)都会被保存在内核中。但由于我们尚未实现中断和上下文切换机制,我们无法保存和恢复 EIP。因此,我们用entry
这个成员来简单地记录进程的起始执行地址,作为一种权宜之计。当实现了中断机制后,这个成员将被废弃掉。
3. 全局进程管理 #
在 kernel/src/proc.c
文件中,我们定义了两个关键的全局变量来管理系统中的所有进程:
proc_t pcb[64]
: 一个包含 64 个 PCB 的数组。这构成了我们系统的进程表 (Process Table),理论上我们的系统最多可以同时存在 64 个进程。proc_t *curr
: 一个指针,它始终指向当前正处于RUNNING
状态的进程的 PCB。
一个特别的概念:“内核进程”
从操作系统启动完成到第一个用户程序运行之前,CPU 实际上也在执行代码流(即内核自身的代码)。为了统一管理模型,我们把这段内核的执行流也视作一个特殊的“进程”。这样做的好处是,可以确保
curr
指针在任何时候都指向一个有效的、正在“运行”的进程,从而简化了调度和其他管理逻辑。我们约定,这个特殊的内核进程使用pcb[0]
这个 PCB。
4. 进程管理核心 API #
接下来,我们需要实现一组与 PCB 相关的核心 API 函数,它们同样位于 kernel/src/proc.c
。
void init_proc();
#
- 目的: 此函数用于在操作系统启动时,对进程管理系统进行初始化。其核心任务是设置好代表“内核进程”的
pcb[0]
。 - 实现细节:
- 将
pcb[0]
的status
成员设置为RUNNING
,因为在main
函数执行时,内核进程已经在运行了。 - 注意: 在未来的实验中,当
proc_t
结构体增加新成员时,您需要回到这个函数,为pcb[0]
的新成员添加合适的初始化代码。
- 将
- 调用位置: 实现此函数后,您需要前往
kernel/src/main.c
中的main
函数,取消对init_proc();
调用的注释。
proc_t *proc_alloc();
#
- 目的: 此函数用于从进程表
pcb
数组中寻找一个空闲的 PCB,并对其进行基础初始化,为创建新进程做准备。 - 实现细节:
- 遍历查找: 编写一个循环,从
pcb[1]
开始遍历整个pcb
数组(因为pcb[0]
已被内核进程永久占用)。 - 找到空闲项: 如果找到一个
pcb[i].status == UNUSED
的条目,说明找到了一个可用的 PCB。 - 细致初始化: 对找到的 PCB 进行初始化。至关重要的一点是,由于 PCB 可能被回收并重新使用(即从
ZOMBIE
状态变回UNUSED
),您不能假设其成员的初始值为 0。 必须显式地初始化所有成员:- 从全局变量
next_pid
获取新的 PID,并将其赋给该 PCB 的pid
成员。 - 将
next_pid
的值自增 1,为下一个进程做准备。 - 将
status
设置为UNINIT
,表示该 PCB 已被占用,但进程尚未完全准备好运行。
- 从全局变量
- 返回结果: 返回指向这个已初始化 PCB 的指针。如果遍历完整个数组都没有找到空闲的 PCB,则返回
NULL
。 - 未来扩展: 同样,当 PCB 结构增加成员时,您需要在此函数中添加对新成员的初始化。
- 遍历查找: 编写一个循环,从
void proc_run(proc_t *proc);
#
- 目的: 将 CPU 的控制权转移给
proc
指针所指向的进程。 - 实现细节:
- 将目标进程的状态
proc->status
设置为RUNNING
。 - 更新全局当前进程指针
curr = proc
。 - 执行跳转: 通过
((void(*)())curr->entry)();
这行代码执行关键的跳转。这行代码的含义是:curr->entry
: 获取存储的入口地址(一个整数)。(void(*)())
: 将这个整数强制类型转换为一个“无参数、无返回值”的函数指针。()
: 通过函数调用操作符()
来执行这个地址处的代码。
- 注意: 当我们实现了真正的中断和上下文切换后,这个函数将被完全重写。
- 将目标进程的状态
动手实践 #
- 实现
init_proc
函数: 在kernel/src/proc.c
中,将pcb[0]
的status
设置为RUNNING
。 - 实现
proc_alloc
函数: 在kernel/src/proc.c
中,实现遍历查找空闲 PCB、进行初始化并返回指针的逻辑。 - 激活初始化: 前往
kernel/src/main.c
,找到main
函数,并取消对init_proc();
的注释。
第四步:从内核到第一个用户程序 #
随着 MBR 成功加载并跳转到我们的操作系统内核,计算机的最高控制权已经正式移交。现在,我们的内核(OS Kernel)成为了系统的管理者。本次实验,我们将聚焦于内核的核心职责之一:加载并运行第一个用户程序。
理论部分:内核源码结构与加载流程 #
在深入代码之前,我们先来熟悉一下内核的源码结构。主要代码位于 kernel/src/
目录下。尽管文件众多,但现阶段您只需要关注两个核心文件:
main.c
: 内核的入口和主流程控制。loader.c
: 负责加载用户程序的模块。
1. 内核的启动与执行环境 #
一个很自然的问题是:作为一个大型 C 程序,我们的内核是从哪里开始执行的?
答案隐藏在项目的构建配置中。通过查阅 Makefile
的第 121 行,我们可以发现一个关键的链接器参数:-e main
。这个参数明确地告诉链接器,将 main
函数作为整个内核程序的入口点。
打开 kernel/src/main.c
,我们可以看到 main
函数的执行流程:
- 执行一系列初始化函数(如
init_proc()
等)。 - 调用
init_user_and_go()
函数,其目标是加载并启动第一个用户程序。
深入探索:关于库函数与裸机环境
您可能会注意到
main.c
中使用了printf
这样的标准库函数。这似乎与“操作系统是运行在裸机(Bare-metal)上,不能使用标准 C 库”的原则相悖。这里的关键在于,我们自己实现了这些函数。在裸机环境下,我们不能依赖操作系统提供的服务(因为我们就是操作系统),但我们可以自己编写功能相同的函数。
- 通用库 (
lib
): 在lib/
目录下,我们实现了一系列不依赖任何操作系统的、纯粹的 C 语言函数,例如string.h
中的memcpy
,memset
和stdlib.h
中的部分函数。您可以在lib/include/lib.h
中查看可用函数列表。- 内核专用库 (
klib
): 在kernel/include/klib.h
中,定义了专供内核使用的函数,例如我们重写的printf
。这个printf
知道如何直接与硬件(如串口)交互来显示字符。- 头文件 (
stdint.h
等): 您可能还会看到我们include
了一些标准的头文件,如stdint.h
。这是被允许的,因为这类头文件通常只包含类型定义(typedef
)、宏定义(#define
)和结构体声明,它们不包含任何需要链接的已编译代码,因此可以安全地用于任何 C 环境。
2. 新的加载方式:利用文件系统 #
在 main.c
的 init_user_and_go
函数中,核心任务是调用 loader.c
中的 load_elf
函数来加载名为 “loaduser” 的程序。
// 函数原型
// pgdir 参数目前无意义,可忽略。
// 功能:从文件系统中加载名为 name 的用户程序,并返回其执行入口地址。
// 失败则返回 -1。
uint32_t load_elf(PD *pgdir, const char *name);
与 MBR 加载内核的方式相比,我们现在有了一个更强大的工具:一个简单的文件系统。这意味着我们不再需要像 MBR 那样,将整个 ELF 文件一次性地、不加区分地读入内存的某个临时位置。取而代之,我们可以按需、精确地读取文件的任意部分。
这个文件系统提供了两个核心 API:
inode_t *iopen(const char *path, int type);
- 功能类似于标准 C 库的
fopen
。 - 根据提供的文件名
path
打开一个文件。 - 成功时返回一个
inode_t*
指针,它扮演着文件句柄(类似于FILE*
)的角色。 - 如果文件不存在,返回
NULL
。
- 功能类似于标准 C 库的
int iread(inode_t *inode, uint32_t off, void *buf, uint32_t len);
- 功能类似于
fread
,但更加灵活。 - 从
inode
所代表的文件的指定偏移量off
处开始,读取len
个字节到内存中的buf
缓冲区。 - 这个
off
参数赋予了我们对文件进行“随机访问”的能力。
- 功能类似于
3. 实现 load_elf
:精确加载用户程序
#
利用这个文件系统,加载用户 ELF 程序的逻辑变得更加清晰和高效:
- 打开文件: 使用
iopen
打开指定的用户程序文件,获取其inode_t*
句柄。 - 读取 ELF 头: 我们首先需要 ELF 头来了解整个文件的结构。使用
iread
从文件偏移量0
处,读取sizeof(Elf32_Ehdr)
(即52字节) 的数据到一个局部的elf
变量中。注意,这里是直接读入一个栈上的结构体变量,而不是像 MBR 那样读入一个大的内存缓冲区。 - 遍历程序头表:
- 从刚刚读取的
elf
变量中,获取程序头表的偏移量e_phoff
和条目数量e_phnum
。 - 编写一个循环,从
i = 0
到e_phnum - 1
。 - 在循环的每一步,计算出第
i
个程序头在文件中的确切偏移量:elf.e_phoff + i * sizeof(Elf32_Phdr)
。 - 使用
iread
从该偏移量处读取一个程序头的数据,存入一个局部的ph
变量中。
- 从刚刚读取的
- 加载可加载段 (Segment):
- 在循环中,检查局部变量
ph
的p_type
成员。 - 如果
p_type == PT_LOAD
,则执行加载操作: a. 复制数据: 使用iread
,从文件的ph.p_offset
偏移处,直接读取ph.p_filesz
字节的数据到内存的最终目标地址ph.p_vaddr
。这一步直接将文件内容放置到了它应该在的内存位置。 b. 清零 BSS 段: 使用memset
(在lib.h
中提供),将内存中从ph.p_vaddr + ph.p_filesz
开始的区域清零,长度为ph.p_memsz - ph.p_filesz
字节。
- 在循环中,检查局部变量
动手实践:完成load_elf
#
补全 kernel/src/loader.c
中 load_elf
函数的第一个 TODO,将用户程序加载到内存中。(第二个 TODO 暂时忽略)
init_user_and_go
函数在 load_elf
成功返回入口地址 eip
后,会执行以下步骤来启动用户程序:
void init_user_and_go() {
// 1. 加载 ELF 文件,获取入口地址
uint32_t eip = load_elf(NULL, "loaduser");
assert(eip != -1);
// 2. 为新程序分配一个 PCB
proc_t* proc = proc_alloc();
// 3. 在 PCB 中记录程序的入口地址 (临时方案)
proc->entry = eip;
// 4. 运行这个新创建的进程
proc_run(proc);
}
这完美地将我们之前实现的进程管理机制与现在的程序加载机制连接了起来。
用户程序的内存地址
用户程序会被加载到哪里?答案同样在
Makefile
中。第 141 和 158 行显示,用户程序在链接时使用了参数-Ttext $(USER_ADDR)
,而USER_ADDR
当前被定义为0x1001000
。因此,用户程序的代码段将从这个地址开始。load_elf
中读取到的p_vaddr
也将是这个地址附近的值。
当您正确地实现了 load_elf
函数后,重新编译并运行 make qemu
。如果一切正常,您应该会看到类似下面的输出:
Hello from OS!
Hello, I am at 0x010010fc
loaduser test: start
loaduser test: passed!
Hello from OS!
是内核打印的。- 后面三行则是由我们刚刚加载并运行的
loaduser
程序打印的。 - 这标志着您的操作系统已经成功地完成了从内核空间启动并运行第一个用户程序的历史性跨越!