本文主要是对xv6中一些常用函数的解释,备查,毕竟有很多函数你不去读源码你是看不懂的。当然也要结合手册和授课内容。

参考的相关链接

[]: http://xv6.dgs.zone/tranlate_books/book-riscv-rev1/c2/s1.html “xv6教材中文”
[]: https://mit-public-courses-cn-translatio.gitbook.io/mit6-s081 “授课内容”

关于页表相关操作函数

其实大多跟虚拟内存的函数都定义在了vm.c中 感兴趣的可以看一下,这里介绍一些常用的。

walk函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
walk(pagetable_t pagetable, uint64 va, int alloc)
{
if(va >= MAXVA)
panic("walk");

for(int level = 2; level > 0; level--) {
pte_t *pte = &pagetable[PX(level, va)];
if(*pte & PTE_V) {
pagetable = (pagetable_t)PTE2PA(*pte);
} else {
if(!alloc || (pagetable = (pde_t*)kalloc()) == 0)
return 0;
memset(pagetable, 0, PGSIZE);
*pte = PA2PTE(pagetable) | PTE_V;
}
}
return &pagetable[PX(0, va)];
}

image-20240911141949939

从教材中我们可以看到这个是三级页表。

它的主要作用是查找虚拟地址 va 对应的页表项(PTE)

参数说明:pagetable 页表的起始地址

va 需要查找的虚拟地址。

alloc 分配的标志位 如果walk函数没有找到,且为1 那么会给他分配一个新的页表

image-20240911144429835

1
va >= MAXVA
MAXVA ?
1
#define MAXVA (1L << (9 + 9 + 9 + 12 - 1))

查阅资料得知:

在 RISC-V 架构中,虚拟地址由三级页表管理,每级页表有 9 位索引,加上页内偏移的 12 位,总共 39 位地址空间。具体计算如下:

每级页表有 9 位索引:9+9+9=27

页内偏移有 12 位:27+12=39

然而,虚拟地址空间的最高位是符号位,用于表示正负地址。因此,实际可用的地址位数是 38 位,而不是 39 位。

PX

我们看一下定义:

1
#define PX(level, va) ((((uint64) (va)) >> (PGSHIFT + (level) * 9)) & 0x1FF)

PX 的作用是从虚拟地址va中提取特定级别的页表索引

其中PGSHIFT是12 。个人理解是这样的

image-20240911150151223

从图中我们可以看出offset是12 也同时对应了一个页的大小是12 做偏移用。一个leave 对应 9 所以看你是几级 level 然后*9 右移之后 对0x1ff 取与 操作 ,就是去出对应级数的索引。

image-20240911150822005

去出对应的索引后 我们得到他的地址 对应是哪一个PTE。因为2的次方是512 ,对应512个PTE .

找到对应的PTE后 我们对标志位取 与操作,判断这个pte是否有效。 如果有效 ,则通过PTE2PA函数实现相关操作。

PTE2PA

PTE2PA 是一个宏,用于从页表项(PTE)中提取物理地址。页表项不仅包含物理地址,还包含一些标志位(如有效位、权限位等)。PTE2PA 宏的作用是从页表项中提取出物理地址部分。以下是 PTE2PA 的定义:

有效代表到了叶子节点,这个时候我们就可以从虚拟地址映射到物理地址了。

1
#define PTE2PA(pte) (((pte) >> 10) << 12)

我们可以看到 先往右位移10位,然后左移12位。

就是舍去低的10位flag 然后左移12位。

image-20240911151945808

关于位移12位?看了下上课的内容。

Frans教授:我们不会加上虚拟地址中的offset,这里只是使用了12bit的0。所以我们用44bit的PPN,再加上12bit的0,这样就得到了下一级page directory的56bit物理地址。这里要求每个page directory都与物理page对齐(也就是page directory的起始地址就是某个page的起始地址,所以低12bit都为0)。

貌似是这样解释的。

walkaddr

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
uint64
walkaddr(pagetable_t pagetable, uint64 va)
{
pte_t *pte;
uint64 pa;

if(va >= MAXVA)
return 0;

pte = walk(pagetable, va, 0);
if(pte == 0)
return 0;
if((*pte & PTE_V) == 0)
return 0;
if((*pte & PTE_U) == 0)
return 0;
pa = PTE2PA(*pte);
return pa;
}

我们观看源码可以得知 walk 函数的调用。

walkaddr的作用

walkaddr 函数用于将虚拟地址转换为物理地址。

主要就是通过walk函数的调用得到 一个pte 然后 进行相对应的判断。

1.检查虚拟地址是否有效:如果虚拟地址超出最大值 MAXVA,则返回 0。

2.查找页表项:使用 walk 函数查找页表项。如果页表项不存在或无效,则返回 0。

3.检查页表项的有效性:如果页表项无效(即没有设置 PTE_V 位)或不可用户访问(即没有设置 PTE_U 位),则返回 0。

4.返回物理地址:如果页表项有效且用户可访问,则返回对应的物理地址。

kvmmap

1
2
3
4
5
6
void
kvmmap(uint64 va, uint64 pa, uint64 sz, int perm)
{
if(mappages(kernel_pagetable, va, sz, pa, perm) != 0)
panic("kvmmap");
}

通过查看kvmmap 函数的实现,我们可以知道 ,他主要调用mappages函数,他将物理地址映射到虚拟地址 ,我们再看看mappages 函数即可。

mappages

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int
mappages(pagetable_t pagetable, uint64 va, uint64 size, uint64 pa, int perm)
{
uint64 a, last;
pte_t *pte;

a = PGROUNDDOWN(va);
last = PGROUNDDOWN(va + size - 1);
for(;;){
if((pte = walk(pagetable, a, 1)) == 0)
return -1;
if(*pte & PTE_V)
panic("remap");
*pte = PA2PTE(pa) | perm | PTE_V;
if(a == last)
break;
a += PGSIZE;
pa += PGSIZE;
}
return 0;
}

我们首先看看他的形参

函数参数

  • pagetable (pagetable_t): 页表指针。
  • va (uint64): 起始虚拟地址。
  • size (uint64): 映射的大小(以字节为单位)。
  • pa (uint64): 起始物理地址。
  • perm (int): 权限标志,定义了映射页面的访问权限(例如读、写、执行权限

首先a被初始化向下取整到页面的边界值

last 被初始化va+size-1 向下取整到页面边界值。

使用walk函数 查找对应页表项,如果不存在 则创建页表项。如果walk返回0 表示创建失败,函数返回-1。

之后:判断是否有效 如果已经有效 则 panic。

否则继续执行下去 将物理地址对应的页表项 设置相应的权限,并给V赋值。

判断a == last 是因为 如果你从某地址开始映射的地方,它对应的对齐内存,跟你要映射的大小所对应的边界如果是一样,那么就映射完了。

否则加一个PGSIZE 大小的页面。然后继续循环。

image-20240912192812695

kvmpa

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
uint64
kvmpa(uint64 va)
{
uint64 off = va % PGSIZE;
pte_t *pte;
uint64 pa;

pte = walk(kernel_pagetable, va, 0);
if(pte == 0)
panic("kvmpa");
if((*pte & PTE_V) == 0)
panic("kvmpa");
pa = PTE2PA(*pte);
return pa+off;
}

kvmpa 函数在 xv6 操作系统中用于将内核虚拟地址转换为物理地址。

off 是计算 虚拟地址的偏移量。

然后用walk函数,找va对应的页表项。

如果不存在 则panic “kvmpa”

如果页表项无效 也调用panic。

之后利用PTE2PA 提取ppn

之后pa + off 最终的物理地址。

uvmunmap

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void
uvmunmap(pagetable_t pagetable, uint64 va, uint64 npages, int do_free)
{
uint64 a;
pte_t *pte;

if((va % PGSIZE) != 0)
panic("uvmunmap: not aligned");

for(a = va; a < va + npages*PGSIZE; a += PGSIZE){
if((pte = walk(pagetable, a, 0)) == 0)
panic("uvmunmap: walk");
if((*pte & PTE_V) == 0)
panic("uvmunmap: not mapped");
if(PTE_FLAGS(*pte) == PTE_V)
panic("uvmunmap: not a leaf");
if(do_free){
uint64 pa = PTE2PA(*pte);
kfree((void*)pa);
}
*pte = 0;
}
}

uvmunmap 函数用于取消映射一段虚拟地址空间,并可选择性地释放对应的物理内存。这在进程终止或释放内存时非常重要,以确保内存资源的正确管理和回收。

下面来解释这段流程。

函数参数

  • pagetable (pagetable_t): 页表指针。
  • va (uint64): 起始虚拟地址。
  • npages (uint64): 要取消映射的页面数量。
  • do_free (int): 是否释放物理内存的标志。如果为非零值,则释放物理内存。

首先判断 va % PGSIZE 是否是对齐的。

不是则 panic

然后开始 for循环 每次一个页面。

a = va a < va + npages*PGSIZE a += PGSIZE

进入第一个 if 找第一个pte 如果 为 0 表示页表项不存在。那么 就panic

第二个if 就是 判断pte_V 标志位 如果 无效 则panic

第三个if 我们注意到 有一个 PTE_FLAGS

1
#define PTE_FLAGS(pte) ((pte) & 0x3FF)

定义如上。

因为0x3ff 是 11 1111 1111 刚好10 位 对应pte 的10个符号位。 将pte 只保留低位的10位 如果 刚好 等于PTE_V 那么表示他不是叶子结点。触发panic

第四个if 判断是否要进行 释放对应的内存操作

如果等于1 表示确定。 PTE2PA 将pte 转换到对应的物理地址。 然后通过kfree 释放。

kfree
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void
kfree(void *pa)
{
struct run *r;

if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP)
panic("kfree");

// Fill with junk to catch dangling refs.
acquire(&ref.lock);
if(--ref.refcount[(uint64)pa / PGSIZE] > 0) {
release(&ref.lock);
return;
}
release(&ref.lock);
memset(pa, 1, PGSIZE);

r = (struct run*)pa;

acquire(&kmem.lock);
r->next = kmem.freelist;
kmem.freelist = r;
release(&kmem.lock);
}
1
2
if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP)
panic("kfree");

这一段主要判断 地址是否合法。

之后 acquire 加锁。

然后进行减少引用 –ref.ref.count 如果大于0 表示这个页面还在被使用。

再release 释放锁。

填充垃圾数据:
memset(pa, 1, PGSIZE);
将页面填充为 1,以捕捉悬空引用(dangling references)。

将页面添加到空闲列表:
r = (struct run*)pa;
将页面地址转换为 struct run 类型。
acquire(&kmem.lock);
获取内存管理锁。
r->next = kmem.freelist;
将页面添加到空闲列表的头部。
kmem.freelist = r;
更新空闲列表头指针。
release(&kmem.lock);
释放内存管理锁。

1
2
extern char end[]; // first address after kernel.
// defined by kernel.ld.

extern char end[]; 是一个外部变量声明,用于指示内核代码和数据段的结束地址。这个变量在 kernel.ld 链接脚本中定义。

主要用途

  1. 内存管理初始化
    • 在内核启动时,end 用于确定内核占用的内存范围,从而知道从哪里开始分配空闲内存。例如,在 kinit 函数中,freerange 函数使用 end 作为起始地址来初始化空闲内存列表。
  2. 内存分配
    • end 变量帮助内核知道哪些内存区域是可用的,哪些是已经被内核代码和数据占用的。这对于内存分配器(如 kallockfree)非常重要。

在 kernel.ld 链接脚本中,end 通常定义为内核代码和数据段的结束地址。

kalloc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void *
kalloc(void)
{
struct run *r;

acquire(&kmem.lock);
r = kmem.freelist;
if(r){
kmem.freelist = r->next;
acquire(&ref.lock);
ref.refcount[(uint64)r / PGSIZE] =1;
release(&ref.lock);
}
release(&kmem.lock);

if(r)
memset((char*)r, 5, PGSIZE); // fill with junk
return (void*)r;
}

大致逻辑跟kfree是一样的 都是 acquire 然后release 。

copyin

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int
copyin(pagetable_t pagetable, char *dst, uint64 srcva, uint64 len)
{
uint64 n, va0, pa0;

while(len > 0){
va0 = PGROUNDDOWN(srcva);
pa0 = walkaddr(pagetable, va0);
if(pa0 == 0)
return -1;
n = PGSIZE - (srcva - va0);
if(n > len)
n = len;
memmove(dst, (void *)(pa0 + (srcva - va0)), n);

len -= n;
dst += n;
srcva = va0 + PGSIZE;
}
return 0;
}

copyin 是将数据从用户空间复制到内核空间。

函数参数

  • pagetable (pagetable_t): 页表指针,用于转换虚拟地址到物理地址。
  • dst (char*): 目标地址,内核空间中的缓冲区。
  • srcva (uint64): 源地址,用户空间中的虚拟地址。
  • len (uint64): 要复制的数据长度。
主要步骤
  1. 计算页面对齐地址
    • va0 = PGROUNDDOWN(srcva);
    • 将源虚拟地址 srcva 向下取整到页面边界。
  2. 转换虚拟地址到物理地址
    • pa0 = walkaddr(pagetable, va0);
    • 使用 walkaddr 函数将虚拟地址 va0 转换为物理地址 pa0
    • 如果转换失败(pa0 == 0),返回 -1。
  3. 计算要复制的字节数
    • n = PGSIZE - (srcva - va0);
    • 计算当前页面内剩余的字节数。
    • if(n > len) n = len;
    • 如果剩余字节数大于要复制的长度,则只复制需要的长度。
  4. 执行内存复制
    • memmove(dst, (void *)(pa0 + (srcva - va0)), n);
    • 将数据从物理地址 pa0 + (srcva - va0) 复制到目标地址 dst
  5. 更新指针和长度
    • len -= n;
    • 减少剩余要复制的长度。
    • dst += n;
    • 更新目标地址指针。
    • srcva = va0 + PGSIZE;
    • 更新源虚拟地址到下一个页面。
  6. 循环直到复制完成
    • 重复上述步骤,直到所有数据都被复制。

题外话: 向下取整和向上取整
在操作系统和内存管理中,向下取整和向上取整是常见的操作,主要用于对齐地址。以下是它们的区别和使用场景:

向下取整(Round Down):
将地址向下取整到最近的页面边界。

使用场景:通常用于计算页面的起始地址。例如,在 copyin 函数中,PGROUNDDOWN(srcva) 将虚拟地址 srcva 向下取整到页面边界,以确保从页面的起始位置开始操作。

示例:PGROUNDDOWN(0x1234) 结果是 0x1000。

向上取整(Round Up):
将地址向上取整到最近的页面边界。
使用场景:通常用于分配内存时,确保分配的内存大小是页面大小的整数倍。例如,在分配内存时,如果需要分配 3000 字节,向上取整到页面大小(通常是 4096 字节)以确保分配足够的空间。
示例:PGROUNDUP(0x1234) 结果是 0x2000。

栈的生长方向
在大多数计算机系统中,栈是从高地址向低地址生长的。这意味着栈顶的地址会随着数据的压入而减小。

低地址:内存地址较小的部分。
高地址:内存地址较大的部分。
内存布局示意
高地址
+——————+
| |
| 栈 |

| |
| 堆 |
| |
——————
| 数据段 |
——————
| 代码段 |
——————
低地址

在这个布局中,栈从高地址向低地址生长,而堆从低地址向高地址生长。