4. 内核初始化与内存布局:main函数调用链、控制台初始化、物理内存分配器初始化、页表初始化、进程表初始化

当 xv6 的引导加载程序将内核从磁盘加载到内存并跳转到 entry 汇编入口后,系统进入 C 语言世界的大门——main 函数。本章将沿着 main 的调用链,逐一剖析内核启动初期最关键的五项初始化工作:控制台、物理内存分配器、页表、进程表以及陷阱处理。这些初始化共同奠定了 xv6 内核的内存布局与运行基础。

4.1 main 函数调用链全景

main 函数位于 kernel/main.c,是内核启动的 C 语言入口。它按照严格的依赖顺序调用各个子系统初始化函数,任何顺序错误都可能导致系统崩溃。下图展示了 main 的完整调用链与各模块的依赖关系:

main 函数调用链与初始化依赖关系 main 函数调用链与初始化依赖关系 main() consoleinit() kinit() kvminit() kvminithart() procinit() 初始化依赖说明 ① consoleinit 必须最先执行, 提供早期调试输出能力。 ② kinit 依赖控制台就绪, 初始化物理内存分配器。 ③ kvminit 建立内核页表, 需要物理内存分配。 ④ kvminithart 将页表加载 到当前 hart 的 satp 寄存器。 ⑤ procinit 初始化进程表, 依赖页表与陷阱机制就绪。 后续:trapinit、plicinit、 scheduler 等。
关键观察: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() 的执行流程如下:

  1. 确定物理内存范围:通过链接器符号 end 获取内核代码结束地址,作为可用物理内存的起始点。
  2. 调用 freerange:将 end 到 PHYSTOP(xv6 中通常为 128MB)之间的所有物理页逐页加入空闲链表。
  3. 逐页释放: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;
}
注意:xv6 的物理内存分配器不处理碎片问题,也不支持合并相邻空闲页。它采用最简单的 LIFO(后进先出)策略,分配和释放均为 O(1) 时间复杂度。这种设计在教学场景下足够高效,但生产级内核需要更复杂的伙伴系统或 slab 分配器。

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;
  }
}
陷阱帧的作用:当用户程序执行系统调用或发生中断时,CPU 需要保存用户态的寄存器状态,切换到内核栈,然后执行内核陷阱处理函数。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 未映射 — 保留区域
重要约束:xv6 使用 Sv39 分页机制,虚拟地址只有 39 位有效,最大虚拟地址空间为 512GB。但 xv6 仅使用低地址部分,内核映射从 KERNBASE(0x80000000)开始,用户空间从 0 开始。这种设计简化了地址转换,但也限制了可管理的物理内存大小。

4.7 初始化后的系统状态

当 main 执行完上述初始化函数后,系统处于以下状态:

  • 控制台就绪:可通过 printf 输出调试信息,通过串口接收输入。
  • 物理内存分配器就绪:kalloc 和 kfree 可正常分配和释放物理页。
  • 内核页表激活:CPU 使用 Sv39 分页模式,内核虚拟地址空间完整映射。
  • 进程表初始化:64 个进程槽位全部标记为 UNUSED,每个槽位已分配陷阱帧页。
  • 陷阱机制未就绪:trapinit 和 trapinithart 尚未调用,此时发生异常将导致系统崩溃。

后续的初始化步骤(trapinit、plicinit、scheduler 等)将在此基础上进一步构建中断处理、进程调度等核心机制,最终启动第一个用户进程 init。