// Defined in user.h
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t
offset);
// TODO: kernel mmap executed in sysfile.c
uint64
sys_mmap(void)
{
}
#define VMASIZE 16
struct vma {
uint64 addr; // 映射的虚拟地址起始位置
int len; // 映射区域的长度(字节数)
int prot; // 保护标志(可读、可写、可执行)
int flags; // 映射标志(MAP_SHARED 或 MAP_PRIVATE)
int fd; // 文件描述符
int offset; // 文件偏移量
struct file* f; // 指向文件的指针
};
对应的,在sys_mmap的开始也要知道这些。
uint64 addr;
int len, prot, flags, fd, offset;
struct file* file;
struct vma* vma = 0;
// Step 1: Fetch arguments
argaddr(0, &addr);
argint(1, &len);
argint(2, &prot);
argint(3, &flags);
argfd(4, &fd, &file);
argint(5, &offset);
为什么读取这些参数
在 sys_mmap
中读取这些参数(如 addr
、len
、prot
、flags
、fd
、offset
)是因为这些信息是执行 mmap
操作所需的基本配置。通过这些参数,mmap
系统调用可以理解用户希望如何映射文件到虚拟内存的请求。每个参数的作用如下:
-
addr
:用户指定的虚拟地址位置。一般来说,mmap
可以在指定的地址开始映射文件内容。如果地址为 0,内核会自动选择地址。 -
len
:映射的内存长度。需要知道映射的大小,以便分配和调整虚拟内存区域的大小。 -
prot
:保护标志(如只读、可写、可执行)。这是指定映射区域的权限信息,决定了程序在该区域上可以进行的操作。例如,只读的区域无法进行写操作。 -
flags
:映射标志,通常用于指定是否是共享映射(MAP_SHARED
)还是私有映射(MAP_PRIVATE
)。共享映射会把修改写回到文件,而私有映射则不影响原文件。 -
fd
:文件描述符,表示要映射的文件。mmap
需要知道要映射哪个文件的数据,文件描述符就是文件的标识。 -
offset
:文件的偏移量,从文件的哪个位置开始映射。例如,可以指定从文件的第 1000 字节开始映射。
这些参数综合在一起可以帮助内核为用户进程正确地设置虚拟地址空间,使其与文件内容建立映射关系。在 sys_mmap
中读取这些参数就是为了初始化 vma
(虚拟内存区域)结构体,并使用这些参数正确地配置映射。
在 sys_mmap
的实现中,vma
(虚拟内存区域)并不是作为参数直接从用户空间传递过来的,而是内核中的一种数据结构,用于管理和记录进程的虚拟内存映射信息。所以这里不需要从用户传递的参数中获取 vma
,而是由内核自己在 sys_mmap
中分配或查找合适的 vma
结构。
具体原因:
-
VMA 是内核内部的数据结构:
vma
(虚拟内存区域)是内核为了跟踪每个进程的内存映射信息而定义的结构体。它不直接暴露给用户,而是由内核在sys_mmap
中动态管理。 -
用户只传递基本参数:用户进程调用
mmap()
时,用户态代码只传递基本参数,比如地址、长度、权限、映射标志、文件描述符等。内核会根据这些参数在进程的 VMA 表中找到或分配一个合适的vma
,以便记录该映射。 -
内核动态分配或查找 VMA:在
sys_mmap
中,内核会根据传入的参数查找进程的 VMA 表,看是否有可用的空位;如果找到,就将该空位赋值给vma
指针,并填充它的字段记录映射信息(如地址、长度、权限等)。这一步骤和用户传递的参数无关,完全是内核的内部管理逻辑。
在操作系统中,虚拟内存和物理内存的关系其实是通过一种“映射”机制建立的。这里的“映射”就是把应用程序看到的虚拟地址(逻辑地址)翻译成物理内存中的实际地址。这种机制让应用程序可以使用更大的、连续的地址空间(虚拟地址),而不需要了解背后物理内存的布局。以下是一些详细说明和通俗解释。
1. 虚拟内存和物理内存的关系
-
虚拟内存:虚拟内存是操作系统提供的一个抽象概念,让每个程序(进程)拥有独立的、连续的内存空间。这个空间不需要实际存在于物理内存中,甚至可以比物理内存更大。虚拟内存空间是每个进程独有的,所以不同进程的虚拟地址可以是相同的,但它们会被映射到不同的物理内存区域。
-
物理内存:物理内存是真实的硬件内存(RAM),有一个固定的大小,比如 4GB、8GB 等。物理内存的地址空间是共享的资源,操作系统负责管理哪些数据存在于物理内存中,哪些可以暂时被交换到磁盘中(即“虚拟内存”扩展的效果)。
-
映射关系:虚拟内存通过页表(Page Table)来映射到物理内存。页表是操作系统中的一个数据结构,用于保存每个虚拟页(通常是 4KB 大小的单位)对应的物理页地址。这种映射使得操作系统可以灵活地分配和管理内存。
2. 为什么使用虚拟内存?
-
隔离和安全性:每个进程都有独立的虚拟地址空间,所以一个进程无法直接访问另一个进程的数据。这种隔离机制增强了系统的安全性。
-
简化内存管理:程序可以认为它的内存是连续的,不需要考虑物理内存的碎片化问题。操作系统可以将物理内存分散分配给不同的进程,并将相邻的虚拟地址映射到不连续的物理地址。
-
扩展性:当物理内存不足时,操作系统可以将一些不活跃的数据从物理内存中移到磁盘上(即“页面置换”),腾出物理内存空间给活跃的进程使用。这种机制可以让程序在物理内存不足的情况下依然运行。
3. 虚拟内存与堆栈区域的关系
-
堆区域:堆是程序动态分配内存的区域,比如通过
malloc
等函数分配的内存。堆的地址范围通常从低地址向高地址增长,申请新内存时,堆会向高地址扩展。 -
栈区域:栈是用于函数调用的区域,存储局部变量、函数参数等。栈的地址范围通常从高地址向低地址增长,每次函数调用时,栈会“下沉”,释放内存时栈会“上浮”。
-
在虚拟内存空间中,堆和栈是两个不同的区域,堆在较低的地址,而栈在较高的地址。它们之间的区域可以用于加载共享库或动态链接库。
4. 页面错误的原因和处理
页面错误(Page Fault)是当程序访问的虚拟地址没有映射到物理地址时触发的。页面错误通常出现在以下几种情况:
- 程序访问还没有分配物理内存的虚拟地址。
- 访问被 mmap 映射但还没有加载文件内容的区域。
- 由于懒加载机制,虚拟内存中的某些数据只在访问时才分配和加载到物理内存。
在你的代码中,mmap
是将文件内容映射到虚拟内存的一种方式。文件的内容会在需要访问时通过页面错误机制“懒加载”到物理内存中。
5. 通俗解释
想象虚拟内存就像一个虚拟的仓库,程序员可以认为这个仓库空间是无限的,也不需要考虑仓库里物品的具体位置。物理内存就是这个虚拟仓库的真实存储空间,相当于“真正的货架”。当程序需要用到某个货架(内存地址)时,操作系统会检查是否已经有对应的实际物理存储(货物),如果没有(比如 mmap
刚分配的虚拟内存),会触发页面错误,然后分配一个实际的货架。
- 堆:是动态分配的货物区,可以随时放新东西;当需要新存储空间时,会给堆分配新货架。
- 栈:用于存放临时物品(局部变量、参数等),会自动回收不再使用的空间。
页面错误机制就像一个仓库管理员,它在程序尝试访问还没有实际存储的区域时,及时给你安排一个货架,这就是页面错误处理的作用。
Initialize vma, 将指针初始化为 0
//Initialize vma
struct vma* vma = 0;
在 C 语言中,将指针(如 struct vma* vma
)初始化为 0
(或 NULL
)表示该指针目前没有指向任何有效的内存地址。这样做有几个目的和好处:
-
标记空指针状态:将指针初始化为
0
(即NULL
)可以清晰地表明它还没有指向任何有效的内存地址。这对于后续检查和处理很有用。例如,如果试图访问或解引用一个未初始化的指针,可能会导致程序崩溃或出现意外行为。初始化为0
后,可以在代码中检测是否为NULL
,避免未定义的行为。 -
方便错误检查:在代码执行过程中,可以通过检查
vma
是否为NULL
来判断是否已经找到并指向有效的vma
对象。初始化为NULL
便于在循环或条件语句中判断是否已经找到匹配的对象。 -
避免使用未初始化变量:未初始化的指针会包含随机的内存地址值,容易导致难以调试的错误。将
vma
初始化为0
有助于避免未初始化变量带来的潜在问题。
赋值为0和赋值为null的区别?
2
//Validate arguments
if (!file->writable && (prot & PROT_WRITE) && flags == MAP_SHARED) {
// 如果文件不可写,但请求写权限且使用 MAP_SHARED 标志,返回错误
return -1;
}
逐步解析
-
file->writable
:这表示文件是否可写。如果文件是只读的,那么file->writable
为false
,即!file->writable
为true
。下面是file.h的file结构体部分。struct file { enum { FD_NONE, FD_PIPE, FD_INODE, FD_DEVICE } type; int ref; // reference count char readable; char writable; struct pipe *pipe; // FD_PIPE struct inode *ip; // FD_INODE and FD_DEVICE uint off; // FD_INODE short major; // FD_DEVICE };
-
prot & PROT_WRITE
:这个表达式检查prot
参数中是否包含写权限PROT_WRITE
标志。prot
参数用于指定内存映射的权限,比如是否可读、可写。PROT_WRITE
是一个标志,表示写权限。- 使用位运算符
&
可以检查prot
是否包含PROT_WRITE
标志。 - 如果
prot
包含PROT_WRITE
,则(prot & PROT_WRITE)
结果为非零(即true
)。
-
flags == MAP_SHARED
:flags
参数表示映射类型(例如共享MAP_SHARED
或私有MAP_PRIVATE
)。- 当
flags == MAP_SHARED
时,表示这是一个共享映射,即映射区域的更改会直接影响文件本身。
- 当
条件逻辑
整个条件逻辑可以用通俗语言表达为:
- **如果文件是只读的(
!file->writable
为真),但用户请求写权限(prot
包含PROT_WRITE
),且映射类型是共享映射(flags == MAP_SHARED
),则认为这是一个非法请求。**因为文件不可写,但用户试图在共享映射中进行写操作,这会导致潜在的非法操作或系统崩溃,因此返回-1
表示错误。
为什么使用 &&
使用 &&
是为了确保只有在所有条件都成立的情况下才会返回错误。这种组合条件判断语句的目的是保证:
- 文件是只读的,且
- 用户请求写权限,且
- 映射类型是共享的。
如果上述条件之一不满足,则映射操作不会被拒绝。
3
struct proc* p = myproc(); // 获取当前进程
struct proc* p = myproc();
这行代码的作用是获取当前正在运行的进程,并将其结构体指针赋值给 p
,从而允许在接下来的代码中访问和修改当前进程的相关信息。
为什么需要获取当前进程?
在操作系统中,每个进程都包含了一些与其自身相关的状态信息(如地址空间、打开的文件、堆栈指针等)。当我们编写一个系统调用(例如 mmap
或 munmap
)时,通常需要操作当前进程的资源或状态。而这些操作都需要先定位到当前进程的数据结构(即 proc
结构体实例)。
通过调用 myproc()
,我们能够获得当前执行的进程实例的指针,这样就可以访问并修改这个进程的数据,例如:
- 访问当前进程的 页表,为新的内存映射做准备;
- 检查和操作当前进程的 虚拟内存区域(VMA);
- 获取或更新当前进程的 内存使用情况(如
p->sz
); - 检查进程的 状态(如是否被杀死,是否在运行)。
举个例子
假设我们在 sys_mmap
系统调用中要给当前进程添加一个内存映射区域,那么我们需要访问当前进程的 VMA
(虚拟内存区域)列表,这在进程的 proc
结构体中。通过 p = myproc()
,我们可以方便地访问 p->vma
,并根据 mmap
的需求操作 vma
列表,找到空闲的区域并进行相应配置。
通俗解释
想象你正在管理一台电脑上的多个应用程序,每个应用程序的内存布局(代码区、数据区、堆、栈等)都是独立的。操作系统需要知道当前正在运行的是哪个程序,以便分配或回收这个程序的资源。因此,myproc()
就像一个“查询当前程序”的函数,通过它,你可以访问当前程序的所有信息,并对其进行操作。
4
// 将映射长度向上对齐到页大小的倍数
len = PGROUNDUP(len);
// 检查映射大小是否超过最大虚拟地址限制
if (p->sz + len > MAXVA) return -1;
// 检查偏移量是否为页大小的倍数
if (offset < 0 || offset % PGSIZE) return -1;
// Step 3: Find an available VMA
for (int i = 0; i < VMASIZE; i++) {
if (p->vma[i].addr == 0) { // 找到空闲的 VMA 条目
vma = &p->vma[i];
break;
}
}
// 如果没有可用的 VMA,返回错误
if (!vma) return -1;
1. 将映射长度对齐到页大小的倍数
通过 len = PGROUNDUP(len);
,将请求的映射长度 len
调整为页大小的倍数,以确保它符合分页的要求。操作系统内存管理通常基于页大小(比如 4KB)来分配内存,因此请求的长度需要对齐到这个大小。
2. 检查映射长度是否超过了最大虚拟地址空间限制
接下来,通过 if (p->sz + len > MAXVA) return -1;
,检查当前进程的虚拟地址空间(p->sz
)加上请求的映射长度 len
是否超过了操作系统设定的最大虚拟地址限制 MAXVA
。如果超出了这个限制,说明无法完成这个映射请求,于是返回 -1
表示失败。
3. 检查偏移量的有效性
然后,通过 if (offset < 0 || offset % PGSIZE) return -1;
检查偏移量 offset
是否是页大小的倍数。偏移量必须符合页对齐要求,否则会导致内存映射不正确。offset % PGSIZE
为 0
表示偏移量是页的倍数。
4. 查找一个空闲的 VMA 条目
接着,在 for (int i = 0; i < VMASIZE; i++)
循环中遍历当前进程的 VMA 列表(即 p->vma
数组),试图找到一个 addr
为 0 的空闲 VMA 条目,表示它尚未被使用。一旦找到空闲条目,我们将其地址赋给 vma
,然后 break;
跳出循环。
5. 检查是否找到了可用的 VMA
最后,通过 if (!vma) return -1;
判断是否找到空闲 VMA。如果没有找到(vma
仍然为 0
),返回 -1
,表示无法完成这个映射请求。
综上所述
这部分代码的目的是在当前进程中找到一个空闲的虚拟内存区域(VMA)来处理新的映射请求。通过前面步骤中对长度和偏移量的检查,确保请求合理且符合系统的内存管理要求。
通俗解释
想象你在一个图书馆想找一张空桌子放书,首先你会确定书的总面积要符合桌子的大小(这里就是对齐到页大小的倍数),然后你要检查这个图书馆的桌子总面积是否允许你再加一个桌子(即总内存不能超出最大限制)。接下来,你确保书的边缘是整齐的,不然书会掉下来(偏移量对齐)。最后,你在图书馆里找一张空桌子,找到了就用这张桌子,找不到就只能返回失败。
5
// Step 4: Record map information to VMA
vma->addr = p->sz;
vma->len = len;
vma->prot = prot;
vma->flags = flags;
vma->fd = fd;
vma->offset = offset;
vma->f = file;
这里使用 p->sz
作为 vma->addr
的初始地址,是因为在这段代码中,系统并不直接使用传入的 addr
参数作为映射的起始地址,而是依赖当前进程的地址空间大小 p->sz
来确定新映射的地址。这有几个原因:
1. 自动分配地址
通过 p->sz
,系统可以自动为映射分配一个连续且未使用的虚拟地址区域,而不必依赖用户传入的 addr
。这样设计简化了映射的管理,减少了地址冲突的可能性。
2. 用户输入 addr
可以为 0
在很多实现中,用户可以传入 addr
为 0,表示让系统自己选择一个合适的地址。在这种情况下,系统会忽略 addr
参数并选择当前进程的 p->sz
作为映射起始位置,然后逐步增加 p->sz
。这样可以确保内存布局的连续性。
3. p->sz
是当前进程已使用的最高地址
使用 p->sz
表示当前进程的已用虚拟地址空间的末尾位置,所以将 vma->addr
设置为 p->sz
可以确保新的映射区域在当前地址空间之上,不会与已有区域冲突。
代码设计思路
系统会自动分配地址时:
- 使用当前进程的
p->sz
作为vma->addr
。 - 将
p->sz
增加len
,确保未来分配的区域不会与当前映射区域重叠。
这样做不仅简化了系统内存管理,也保证了分配的内存地址连续。
总结
虽然用户传入了 addr
参数,但在这里系统自己选择地址,所以使用 p->sz
来分配新的地址空间起点,确保映射的地址是唯一、连续且不冲突的。
6
// Step 5: Increase file duplicate
filedup(file); // 增加文件引用计数
这一步是为了增加文件的引用计数,以确保在内存映射区域被使用期间,文件不会被意外关闭或删除。
具体原因
当一个文件被映射到内存时,进程实际上并没有真正读取文件的内容到内存中,而是通过内存映射的方式将文件内容“映射”到虚拟地址空间中。这样一来,文件的内容会被按需加载到内存,甚至可能由多个进程共享。
作用
调用 filedup(file)
增加文件的引用计数,目的是在该内存映射区域被使用期间保持文件的有效性,即防止在其他地方意外关闭文件。当文件的引用计数增加后,即便其他代码调用 close()
,文件也不会真的被关闭,除非所有对该文件的引用都被释放。
工作流程
- 在
filedup(file)
之后,文件的引用计数会加 1。 - 当不再需要该映射区域(比如调用
munmap
或进程结束)时,内存映射区域会被释放,且文件引用计数会相应减少。 - 当文件引用计数降为 0 时,文件才会真正关闭。
7
// 扩展进程的虚拟地址空间
p->sz += len;
// 返回分配的虚拟地址
return vma->addr;
这段代码的作用是更新进程的虚拟地址空间大小并返回分配的虚拟地址。
详细解析
-
扩展进程的虚拟地址空间
p->sz += len
:p->sz
表示当前进程p
的虚拟地址空间大小。len
是此次映射的长度。- 将
p->sz
增加len
,意味着扩展了该进程的虚拟地址空间,使得新的映射区域成为进程地址空间的一部分。 - 这样做的原因是,
mmap
请求通常会分配一段新的虚拟地址空间,用于映射文件或共享内存。在这个过程中,通过更新p->sz
来记录新的进程地址空间的边界,以确保其他内存分配不会覆盖到这段映射区域。
-
返回分配的虚拟地址
return vma->addr
:vma->addr
是分配给mmap
区域的起始虚拟地址。- 这个地址在之前被设置为
p->sz
的旧值(即扩展前的边界)。 - 返回该地址,使得调用
mmap
的用户程序知道新分配的内存映射区域的起始地址,可以用这个地址访问映射的内容。
通俗解释
可以把 p->sz
理解为进程“已经使用的内存的终点”。这一步通过增大 p->sz
来记录新映射区域的存在。返回 vma->addr
作为分配区域的起始地址,用户程序会用这个地址来操作新分配的内存区域。
然后下一次用p->sz就是这个的结尾,然后依次类推?
是的,正是这个思路!每次 mmap
分配内存时,p->sz
都会被更新到新的结尾位置,以便下次 mmap
请求可以从新的结尾位置开始分配。
具体流程
- 初始状态:
p->sz
表示当前进程已使用虚拟地址空间的边界(即“终点”)。 - 每次
mmap
调用:- 当前的
p->sz
用作新映射的起始地址vma->addr
。 - 新的映射长度
len
会加到p->sz
上,将其推到新的终点。 - 返回
vma->addr
,即本次分配的区域起始地址。
- 当前的
- 依次累积:
p->sz
会随着每次映射增长,始终表示进程地址空间的边界。
这样设计的好处
- 地址连续性:内存映射的区域按顺序分配在
p->sz
之后,避免了地址冲突。 - 内存管理简单:进程的地址空间布局明确,便于管理。
- 未来映射起点已知:下次
mmap
时直接使用p->sz
作为起点。
8.是否漏了什么?
在实际的系统设计中,mmap
的实现通常会返回一个错误码(例如 -1
),而不是直接引发 panic
。panic
一般用于系统出现严重错误时,而 mmap
中找不到可用 VMA 并不属于这种情况。这种设计选择是因为:
-
避免过度使用
panic
:panic
通常意味着系统无法恢复的严重错误,比如内核数据结构的严重损坏或设备硬件问题。对于找不到可用 VMA 这样的情况,直接返回错误码比引发panic
更合理,因为这只是内存分配不足的情况,不是系统崩溃的严重问题。 -
mmap
是一种普通的系统调用: 系统调用的返回机制会将问题通知给用户态程序,而不会中断整个系统。这样,用户程序可以根据返回值采取相应的操作(例如处理错误或释放资源后重试)。所以mmap
中如果没有找到可用 VMA,通常只会返回-1
,表示分配失败,用户可以根据需要处理错误。 -
提升稳定性: 通过返回错误码而不是
panic
,操作系统能够更稳定地处理资源不足的情况,不会因为普通的资源不足问题中断服务,确保用户程序和系统的正常运行。
如果需要强制 panic
在调试或开发阶段,有时确实可能希望在这种情况下直接 panic
,以便于排查问题。在这种情况下,可以在代码中手动加入 panic或
替换成 -1。
// 如果没有可用的 VMA,触发 panic
if (!vma) {
panic("syscall mmap");
return -1;
}