6.s081
内存管理:
kinit(); // physical page allocator
void
kinit()
{
initlock(&kmem.lock, "kmem");
printf("kinit called\n");
freerange(end, (void*)PHYSTOP);
}
为什么要管理?
**硬件: **内存的本质是 一块连续的物理空间
但是对于OS来说:
操作系统是资源的调度者,对内存来说,它必须解决两个问题:
1. 哪些内存能用?
一些页存放内核代码、数据
一些页映射了设备
只有“空闲页”才能分配出去
所以你要记录:“哪些页是空的?”
2. 怎么分配、怎么回收?
kalloc() 要找一个空闲页 怎么找?
kfree(p) 要回收到系统中 怎么定位回收?
所以你要一个“可操作的数据结构”来支撑分配和回收
同时需要锁的支持 避免同时修改内存中的链表结构!
虚拟内存管理:
当一个进程请求内存时(如sbrk()系统调用):
步骤1:扩展虚拟地址空间
// 假设进程请求扩大堆空间 char *new_addr = sbrk(4096); // 分配4096字节的虚拟地址 操作系统_仅在进程的页表中标记一段新的虚拟地址范围(如0x1000~0x2000)可用_。
此时无物理内存分配,页表项被标记为"无效"(Present=0)。
malloc的内存分配机制
- 虚拟地址分配:
操作系统在进程的页表中标记一段虚拟地址范围可用(如0x1000~0x3000)。
此时不分配物理内存,页表项标记为"无效"(Present=0)。
- 首次访问触发缺页:
p[0] = 'A'; // 第一次写入 CPU发现虚拟地址0x1000无物理页 → 触发缺页异常。
- 内核处理异常:
调用kalloc()分配物理页。
更新页表,建立0x1000→物理页的映射。
- 恢复用户进程执行。
后续访问:
页表已有映射 → 直接访问物理内存。
#define PGROUNDUP(sz) (((sz)+PGSIZE-1) & ~(PGSIZE-1))
#define PGROUNDDOWN(a) (((a)) & ~(PGSIZE-1))
大体的意思是 :
让我们以一个例子来说明,如果在一个 PGSIZE 为 1KB 的系统上调用 PGROUNDUP,地址为 620:
PGROUNDUP(620) ==> ((620 + (1024 -1)) & ~(1023)) ==> 1024
地址620被向上舍入到1024。
类似地,对于 PGROUNDDOWN 考虑:
PGROUNDDOWN(2400) ==> (2400 & ~(1023)) ==> 2048
地址2400被向下舍入到2048。
这也就是 页对齐
CPU 的 MMU(内存管理单元) 在管理虚拟内存时,要求 **页表项(PTE)指向的物理地址必须是页对齐的**(即低 12 位全 0,如果是 4KB 页)。
如果不对齐,CPU 可能无法正确映射内存,导致 缺页异常(Page Fault) 或 数据错误。
// Fill with junk to catch dangling refs.
memset(pa, 1, PGSIZE);
密码/密钥泄漏:如用户进程的内存页曾存储密码,释放后未清空,被恶意进程分配到。
隐私数据暴露:如浏览器缓存的历史记录残留。
内存初始化流程
- 初始化内存分页 并 添加锁
- 调用宏计算将地址对齐
- 寻找空闲物理页 并将其 添加到 freelist中!供后续分配使用
Why:
内存回收: 当内核或用户程序不再需要某块物理内存时,通过 kfree 将其归还给系统,避免内存泄漏。
支持动态分配: kfree 和 kalloc 配合实现物理内存的分配与释放(类似 malloc/free,但更底层)
寄存器
简单理解了一下寄存器: 通过电路的设计 使电路具有了记忆能够记住上一时刻的状态 也就保存了数据 !
RISC-V中通常是32个寄存器 一些主要的寄存器
SP
PC
MODE
SATP
STVEC
SEPC
SSRATCH
具体示例:
当执行系统调用 write(1, "hello", 5) 时
一个进程的地址空间:
高地址 ┌───────────────────────┐ │ 内核空间 │ ← 所有进程共享相同映射 │ - 内核代码和数据 │ │ - 设备内存映射 │ │ - 当前进程的内核栈 │ ├───────────────────────┤ ← 用户空间上限(如0x00007FFFFFFFFFFF) │ 栈空间(向下增长) │ ├───────────────────────┤ │ 共享库映射区 │ ├───────────────────────┤ │ 堆空间(向上增长) │ ├───────────────────────┤ │ 数据段(.data/.bss) │ ├───────────────────────┤ │ 代码段(.text) │ └───────────────────────┘ 低地址(0x0000000000400000)
关键: 所有进程共享相同映射 也就是内核部分的内存映射都是相同的
然后寄存器的状态开始发生改变:
PC = 0x1010 (指向ecall)
MODE = U (用户态)
SATP = 用户页表基址
STVEC = 0xC0000000 (内核trap入口)
SP = 0x7FFFFFFF (用户栈顶)
- 0x1010 处的指令是 ecall,也就是用户程序请求内核服务
- ecall 指令会从用户态触发陷入内核态(S-mode)。
- SATP = 用户页表基址
- stvec 是内核指定 "trap 入口地址" 的寄存器。发生 trap(如 ecall、中断、异常)时,CPU 就跳到 stvec 这个地址开始执行。
注意: 当发生ecall时 虽然会切换模式 为 内核态 但是 并不会自动切换页表,仍然使用的是 当前用户的页表,所有用户页表中都会映射这个 trampoline 页
_sepv_寄存器 和 _stvec_寄存器
sepc 保存 Trap 发生时的指令地址 Trap 发生时自动写入,sret 时恢复。
stvec 定义 Trap 处理程序的入口地址 Trap 发生时,硬件将 pc 设置为 stvec 的值,跳转到内核处理代码。
目前的理解 _SATP_寄存器
- 可以将内核设置一个页表 当陷入内核时 SATP切换为内核页表(开销很大)
- 将内核和用户页表放在一起 有不同的权限控制 当需要陷入内核时提升权限执行内核代码
进程对于寄存器的使用:
共享的是硬件寄存器(物理只有一套)。
独立的是寄存器状态(操作系统用内存保存不同进程的寄存器值)。
trapframe
主要解决问题:
1)内核必须使处理器能够从用户态转换到内核态(并且再转换回用户态)
2)内核和设备必须协调好他们并行的活动。
3)内核必须知道硬件接口的细节。
trapframe 是内核分配的一块内存空间,内核在 trap 入口把所有用户寄存器(包括 sepc 寄存器的值)保存到这里。
csrrw rd, csr, rs1
将 CSR 的当前值读取到目标寄存器 rd,同时将 rs1 的值写入该 CSR。
用处:
csrrw sp, sscratch, sp # 交换 sp 和 sscratch
在 Trap 入口处,内核需要从 用户栈 切换到 内核栈,同时保存用户栈指针:
- 执行前:
sp = 用户栈指针
sscratch = 内核栈指针(由内核预先设置)
- 执行后:
sp = 内核栈指针(用于内核代码)
sscratch = 用户栈指针(被保存,后续可恢复)
Trampoline 页的作用:
所有进程共享同一段 Trampoline 代码,但通过页表映射到各自的地址空间
uint64 fn = TRAMPOLINE + (userret - trampoline);
怎么理解?
理解Trap的流程
一.
当用户态程序触发 Trap(如系统调用 ecall 或中断)时,RISC-V 硬件会:
自动切换特权级:从用户态(U-mode)切换到内核态(S-mode)。
保存关键状态:将 pc 保存到 sepc,原因码保存到 scause。
跳转到 Trap 处理程序:通过 stvec 寄存器指定的地址进入内核。
注意:
但硬件不会自动完成以下操作:
保存所有用户寄存器(如 x0-x31)。
切换页表(仍使用用户页表,但内核需要访问自己的内存)。
提供安全的返回路径(如何从内核态跳回用户态?)。
引出为什么需要trapframe:
_userret_ 是一段汇编函数,作用是:
从 trapframe 恢复用户寄存器状态。
切换回用户态(执行 sret)。
它需要知道 trapframe 在内存中的地址(因为要从这里恢复寄存器)。
内核调用 trampoline 时,会把 当前进程的 trapframe 地址 放到 a0,即:
a0 = trapframe 的地址
然后 userret 通过 a0 读这个地址,从而恢复用户态寄存器状态。
注意:
uint64 fn = TRAMPOLINE + (userret - trampoline);
怎么理解?
a0 是第一个参数寄存器
在 userret 中:a0 用于传递 当前进程的 Trapframe 用户虚拟地址,告诉 userret 从哪里恢复用户寄存器。
具体含义:
trampoline 代码在磁盘/内存中是从 0 地址(或低地址)链接的。
操作系统把这段代码统一映射到所有进程虚拟地址空间的固定高地址(TRAMPOLINE)。
userret - trampoline → userret 在 trampoline 源文件里的偏移。
TRAMPOLINE + offset → userret 的实际虚拟地址。
汇总:
- trampoline 是一个固定地址
当执行ecall时:
ecall 触发异常,硬件自动:
将当前 PC 存到 sepc。
设置 scause 等异常信息。
将 PC 设置为 stvec。 让处理流程跳转到 trampoline 入口,从而执行统一的 trap 处理逻辑
- trapframe 和 a0 传参的关系:
第一个函数调用时 a0 指向 trapframe,是为了:
userret(trapframe, satp) 需要知道:
trapframe: 要恢复的寄存器信息。
satp: 用户页表地址。
- 什么时候执行 fn(userret)?
明确,机器总是从内核开始执行的!
无论是进程第一次启动还是从第一个系统调用返回 进入用户态的方法 sret
指令
转换特权 -> user 在执行用户代码之前 内核会执行fn函数设置好所有东西 例如:STVEC寄存器等
- ecall指令都做了什么
设置supervisor mode标志 保存程序计数器到SEPC寄存器 将程序计数器设置成STVEC的内容(STVEC寄存器在进入用户空间之前就会设置好 STVEC = trapoline page 的起始位置)
- trapframe中保存的寄存器访问问题:
usertrapret
_作用:_完成用户态所需寄存器设置(sepc, satp, sp, sscratch 等)然后“跳转”到用户态
Trampoline 页(跳板页):Trampoline 就是一页被**映射到用户页表中(低地址)和内核页表中(高地址)**的内存,里面写的是一段固定的汇编代码,作用是:安全地完成从内核态跳回用户态,通常包括恢复寄存器 + 执行 sret。把用户页表写入 satp,完成页表切换,
内核栈:
当用户态程序调用 ecall、发生中断或异常后,CPU 会自动切换到 内核栈,让操作系统有一块干净、安全、私有的空间执行内核逻辑。
csrrw a0, sscratch, a0 # 交换 a0 和 sscratch
,下一次 trap(系统调用/中断)时,CPU 会自动把 sscratch 赋值给 sp(让你重新拥有内核栈)所以这是为下次进入内核做准备