4. 内核初始化与内存布局:main函数调用链、控制台初始化、物理内存分配器初始化、页表初始化、进程表初始化
当 xv6 的引导加载程序将内核从磁盘加载到内存并跳转到 entry 汇编入口后,系统进入 C 语言世界的大门——main 函数。本章将沿着 main 的调用链,逐一剖析内核启动初期最关键的五项初始化工作:控制台、物理内存分配器、页表、进程表以及陷阱处理。这些初始化共同奠定了 xv6 内核的内存布局与运行基础。
4.1 main 函数调用链全景
main 函数位于 kernel/main.c,是内核启动的 C 语言入口。它按照严格的依赖顺序调用各个子系统初始化函数,任何顺序错误都可能导致系统崩溃。下图展示了 main 的完整调用链与各模块的依赖关系:
4.2 控制台初始化:consoleinit
consoleinit() 位于 kernel/console.c,负责初始化 UART 串口设备,使内核能够通过串口输出字符和接收输入。这是内核与外部世界通信的第一扇门。
| 初始化步骤 | 函数/操作 | 说明 |
|---|---|---|
| 1. UART 硬件初始化 | uartinit() |
设置波特率、数据位、停止位、FIFO 使能 |
| 2. 输入缓冲区初始化 | cons.buf 清零 |
环形缓冲区用于暂存键盘输入 |
| 3. 中断注册 | plic_complete 等 |
使能 UART 接收中断,绑定处理函数 |
| 4. 控制台设备注册 | devsw[CONSOLE].read/write |
将控制台注册为字符设备,供文件系统调用 |
初始化完成后,内核即可通过 printf 输出调试信息。在 main 中,consoleinit() 之后的第一条 printf 调用通常输出启动横幅,标志着内核已具备基本交互能力。
4.3 物理内存分配器初始化:kinit
kinit() 位于 kernel/kalloc.c,负责初始化 xv6 的物理内存分配器。xv6 采用 空闲链表(free list) 管理物理页,每个空闲页的起始 8 字节存储指向下一个空闲页的指针。
struct run 是空闲链表节点,仅包含一个 struct run *next 指针。所有空闲页通过 next 指针串联成链表,kmem.freelist 指向链表头。
kinit() 的执行流程如下:
- 确定物理内存范围:通过链接器符号
end获取内核代码结束地址,作为可用物理内存的起始点。 - 调用
freerange:将end到PHYSTOP(xv6 中通常为 128MB)之间的所有物理页逐页加入空闲链表。 - 逐页释放:
freerange对范围内的每一页调用kfree,kfree将页的next指针指向当前freelist头,然后更新freelist。
// kernel/kalloc.c 核心代码片段
void
kinit()
{
// 从内核结束地址 end 到 PHYSTOP 初始化空闲链表
freerange(end, (void*)PHYSTOP);
}
void
freerange(void *pa_start, void *pa_end)
{
char *p;
// 按页对齐起始地址
p = (char*)PGROUNDUP((uint64)pa_start);
for(; p + PGSIZE <= (char*)pa_end; p += PGSIZE)
kfree(p);
}
void
kfree(void *pa)
{
struct run *r;
// 物理地址必须在有效范围内
if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP)
panic("kfree");
// 填充已释放页,帮助检测野指针
memset(pa, 1, PGSIZE);
r = (struct run*)pa;
r->next = kmem.freelist;
kmem.freelist = r;
}
4.4 页表初始化:kvminit 与 kvminithart
页表初始化分为两个阶段:kvminit() 建立内核页表的数据结构,kvminithart() 将页表加载到当前 CPU 核心的页表基址寄存器(satp),并刷新 TLB。
4.4.1 kvminit:构建内核页表
kvminit() 位于 kernel/vm.c,主要完成以下工作:
- 分配根页表页:调用
kalloc分配一个物理页作为三级页表的根节点(L2 页表)。 - 映射内核文本段:将
KERNBASE到KERNBASE+PHYSTOP的虚拟地址空间映射到物理地址0到PHYSTOP,采用恒等映射(虚拟地址 = 物理地址 + KERNBASE 偏移)。 - 映射外设内存:将 UART、PLIC、CLINT 等外设的物理地址范围映射到对应的虚拟地址空间。
- 映射陷阱向量:将
trampoline和trapframe页映射到内核虚拟地址空间的高端。
// kernel/vm.c 核心代码片段
void
kvminit()
{
kernel_pagetable = (pagetable_t) kalloc();
memset(kernel_pagetable, 0, PGSIZE);
// 映射 UART、VIRTIO、PLIC 等外设
kvmmap(UART0, UART0, PGSIZE, PTE_R | PTE_W);
kvmmap(VIRTIO0, VIRTIO0, PGSIZE, PTE_R | PTE_W);
kvmmap(PLIC, PLIC, 0x400000, PTE_R | PTE_W);
// 映射内核代码和数据
kvmmap(KERNBASE, KERNBASE, (uint64)etext - KERNBASE, PTE_R | PTE_X);
kvmmap((uint64)etext, (uint64)etext, PHYSTOP - (uint64)etext, PTE_R | PTE_W);
// 映射陷阱相关页
kvmmap((uint64)trampoline, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);
}
4.4.2 kvminithart:激活页表
kvminithart() 将 kernel_pagetable 的物理地址写入 satp 寄存器,并执行 sfence.vma 指令刷新 TLB,使新的页表生效。
void
kvminithart()
{
// 将内核页表的物理地址写入 satp 寄存器
w_satp(MAKE_SATP(kernel_pagetable));
// 刷新 TLB,确保旧映射失效
sfence_vma();
}
kvminit 负责构建页表数据结构,可以在任意阶段调用;kvminithart 负责激活页表,必须在所有内存映射完成后调用,且每个 CPU 核心在启动时都需要调用一次(多核场景)。
4.5 进程表初始化:procinit
procinit() 位于 kernel/proc.c,负责初始化进程表 ptable 以及每个进程所需的固定资源——尤其是陷阱帧(trapframe)页。
| 初始化项 | 数据结构 | 说明 |
|---|---|---|
| 进程表数组 | struct proc ptable[NPROC] |
静态数组,NPROC 默认为 64 |
| 进程状态锁 | ptable.lock |
保护进程表并发访问的自旋锁 |
| 陷阱帧分配 | proc->trapframe |
为每个进程分配一页物理内存,保存用户/内核切换时的寄存器 |
| 进程状态 | proc->state = UNUSED |
所有进程初始状态为 UNUSED |
// kernel/proc.c 核心代码片段
void
procinit(void)
{
struct proc *p;
// 初始化进程表锁
initlock(&ptable.lock, "ptable");
for(p = ptable; p < &ptable[NPROC]; p++) {
// 为每个进程分配陷阱帧页
p->trapframe = (struct trapframe *)kalloc();
if(p->trapframe == 0)
panic("procinit: kalloc trapframe");
// 初始化进程锁
initlock(&p->lock, "proc");
// 初始状态为 UNUSED
p->state = UNUSED;
}
}
trapframe 页存储了这些寄存器值,确保用户程序能在陷阱返回后恢复执行。
4.6 内核内存布局总览
经过上述初始化后,xv6 内核的虚拟地址空间布局如下表所示:
| 虚拟地址范围 | 映射目标 | 权限 | 说明 |
|---|---|---|---|
0x00000000 – 0x0FFFFFFF |
未映射 | — | 用户空间(低 256MB) |
0x10000000 – 0x10000FFF |
UART0 | R/W | 串口控制寄存器 |
0x10001000 – 0x10001FFF |
VIRTIO0 | R/W | 磁盘控制器 |
0x0C000000 – 0x0FFFFFFF |
PLIC | R/W | 平台级中断控制器 |
0x80000000 – 0x80000XXX |
内核文本段 | R/X | 内核代码(KERNBASE 起始) |
0x80000XXX – 0x87FFFFFF |
内核数据段 + 堆 | R/W | 内核全局变量、空闲链表 |
0x88000000 – 0x8FFFFFFF |
物理内存映射 | R/W | 剩余物理内存(PHYSTOP 以内) |
0x90000000 – 0xFFFFFFFF |
未映射 | — | 保留区域 |
KERNBASE(0x80000000)开始,用户空间从 0 开始。这种设计简化了地址转换,但也限制了可管理的物理内存大小。
4.7 初始化后的系统状态
当 main 执行完上述初始化函数后,系统处于以下状态:
- 控制台就绪:可通过
printf输出调试信息,通过串口接收输入。 - 物理内存分配器就绪:
kalloc和kfree可正常分配和释放物理页。 - 内核页表激活:CPU 使用 Sv39 分页模式,内核虚拟地址空间完整映射。
- 进程表初始化:64 个进程槽位全部标记为 UNUSED,每个槽位已分配陷阱帧页。
- 陷阱机制未就绪:
trapinit和trapinithart尚未调用,此时发生异常将导致系统崩溃。
后续的初始化步骤(trapinit、plicinit、scheduler 等)将在此基础上进一步构建中断处理、进程调度等核心机制,最终启动第一个用户进程 init。