一、内存布局
我们前面的内存布局是用户栈顶在128M的位置。在进行内存映射需要修改下内存布局。将文件映射的地址改到128M的位置,用户栈顶改到256M的位置。如下所示:
前面在进入用户态时,我们设置了用户内存虚拟位图只有一页,因此操作系统最大支持128M。这里我们修改了内存布局,最大可以使用256M内存。因为用户内存映射从128M开始,用户依然最大可以使用128M的内存。但是内核只使用了16M。中间的用户程序段(.text、.data、.bss)和用户堆(heap)是不需要位图管理的。程序段:固定大小,内核加载时映射,不需 bitmap。堆:顺序增长,通过 brk/sbrk 分配物理页,也不需 bitmap。
二、系统调用 mmap&munmap
mmap 是 Linux/Unix 系统中非常重要的一个系统调用,它的作用是将文件或设备映射到进程的虚拟内存空间中。通过 mmap,进程可以像访问普通内存一样直接访问文件内容,而不必显式地使用 read()、write() 等系统调用。这段映射关系可以实现:
- 高效文件读写(文件直接映射到内存)
- 共享内存通信(多个进程映射同一个文件或匿名页)
- 内存分配(堆外内存)(匿名映射)
- 动态库加载(如 ELF 可执行文件加载)
2.1、mmap的具体实现。
我们修改了内存布局,因此首先需要修改下内存布局的相关宏定义:
#define USER_EXEC_ADDR KERNEL_MEMORY_SIZE // 用户程序地址, 内核占用了16M, 因此用户程序地址是16M开始的位置。
#define USER_MMAP_ADDR 0x8000000 // 用户映射内存开始位置, 128M
#define USER_MMAP_SIZE 0x8000000 // 用户映射内存大小, 也是128M大小
#define USER_STACK_TOP 0x10000000 // 用户栈顶地址 256M
#define USER_STACK_SIZE 0x200000 // 用户栈最大 2M
#define USER_STACK_BOTTOM (USER_STACK_TOP - USER_STACK_SIZE) // 用户栈底地址
typedef struct page_entry_t
{
....
u8 shared : 1; // 共享内存页,与 CPU 无关
u8 privat : 1; // 私有内存页,与 CPU 无关
u8 flag : 1; // 该安排的都安排了,送给操作系统吧
u32 index : 20; // 页索引
} _packed page_entry_t;
enum mmap_type_t
{
PROT_NONE = 0, // 不允许访问,访问会触发异常
PROT_READ = 1, // 可读
PROT_WRITE = 2, // 可写
PROT_EXEC = 4, // 可执行
MAP_SHARED = 1, // 映射区对所有进程共享,对映射区的写会回写到文件
MAP_PRIVATE = 2, // 私有映射,对映射区的写不会影响原文件(写时拷贝
MAP_FIXED = 0x10, // 指定固定地址进行映射,如果地址冲突会覆盖原有映射
};
这里用户映射区虚拟地址范围是128M ~ 256M。 但是用户栈的范围是 254M ~ 256M。这2M的虚拟内存地址看似有重叠,这里我们会在代码实现中进行判断保证内存安全 ,理解即可。page_entry_t是页表结构,CPU是留了3位没有使用,这里我们的操作系统使用其中两位,分别用于表示共享内存页和私有内存页。枚举 mmap_type_t 定义了内存映射(mmap)相关的 保护权限标志和映射类型标志。
接下来需要修改用户的虚拟位图位置, 用户的虚拟位图然后使用1页,因此用户依然是最大使用128M的虚拟内存。只是需要调整位图的开始位置
void task_to_user_mode(target_t target)
{
task_t *task = running_task();
// 创建用户进程虚拟内存位图
task->vmap = kmalloc(sizeof(bitmap_t));
void *buf = (void *)alloc_kpage(1);
bitmap_init(task->vmap, buf, USER_MMAP_SIZE / PAGE_SIZE / 8, USER_MMAP_ADDR / PAGE_SIZE);
......
}
前面我们提到 中间的用户程序段(.text、.data、.bss)和用户堆(heap)是不需要位图管理的。这块内存在使用的时候直接使用物理页,通过brk系统调用来分配。因此这里在 将 vaddr 映射物理内存 和 去掉 vaddr 对应的物理内存映射。 即对应 link_page 和 unlink_page这两个函数时,不需要对用户进程的虚拟位图进行修改了。如果进行用户内存映射,会在mmap中修改位图,再调用link_page进行映射。 接下来就可以实现mmap和munmap了。
// addr 是希望映射到的虚拟地址,如果为0,表示让系统自己选择, length是映射的长度。 prot是权限标志、flags是映射类型、fd是文件描述符, offset是文件偏移量。
void *sys_mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset)
{
ASSERT_PAGE((u32)addr);
u32 count = div_round_up(length, PAGE_SIZE); // 计算需要映射多少页
u32 vaddr = (u32)addr; // 虚拟地址就是我们希望映射的地址
task_t *task = running_task(); // 获取当前进程
if (!vaddr) // 如果虚拟地址为0, 就去位图中查找连续的页
{
vaddr = scan_page(task->vmap, count); // 返回操作系统自动分配的虚拟地址。
}
assert(vaddr >= USER_MMAP_ADDR && vaddr < USER_STACK_BOTTOM); // 保证虚拟地址大于用户内存且小于栈底
for (size_t i = 0; i < count; i++) // 进行虚拟内存映射
{
u32 page = vaddr + PAGE_SIZE * i;
link_page(page);
bitmap_set(task->vmap, IDX(page), true); // 修改虚拟内存位图
page_entry_t *entry = get_entry(page, false); // 获取到页表项目
entry->user = true; // 设置属性为所有人
entry->write = false; // 设置不可写,然后根据传入的参数来判断是否可写
if (prot & PROT_WRITE)
{
entry->write = true;
}
if (flags & MAP_SHARED)
{
entry->shared = true;
}
if (flags & MAP_PRIVATE)
{
entry->privat = true;
}
flush_tlb(page);
}
if (fd != EOF) // 如果传入的文件描述符有效,就把文件读入内存
{
lseek(fd, offset, SEEK_SET);
read(fd, (char *)vaddr, length);
}
return (void *)vaddr;
}
int sys_munmap(void *addr, size_t length) // 解除内存映射
{
task_t *task = running_task();
u32 vaddr = (u32)addr;
assert(vaddr >= USER_MMAP_ADDR && vaddr < USER_STACK_BOTTOM);
ASSERT_PAGE(vaddr);
u32 count = div_round_up(length, PAGE_SIZE);
for (size_t i = 0; i < count; i++)
{
u32 page = vaddr + PAGE_SIZE * i;
unlink_page(page);
assert(bitmap_test(task->vmap, IDX(page)));
bitmap_set(task->vmap, IDX(page), false);
}
return 0;
}
到此就实现了用户内存映射。
三、内存映射测试
我们在shell中创建一个测试命令来进行测试,代码如下:
void builtin_test(int argc, char *argv[])
{
u32 status;
int *counter = (int *)mmap(0, sizeof(int), PROT_WRITE, MAP_SHARED, EOF, 0); // MAP_SHARED 可改为0进行测试
pid_t pid = fork();
if (pid)
{
while (true)
{
(*counter)++;
sleep(300);
}
}
else
{
while (true)
{
(*counter)++;
printf("counter %d\n", *counter);
sleep(100);
}
}
}
如上为测试函数。mmap当传入flag为0时,说明不是共享内存区,即父进程写内存时,子进程打印是没有变化的。当传入flag为MAP_SHARED时,说明是共享内存区,父进程写,子进程打印也会跟着变化。也就是子进程和父进程使用了同一块内存。
最后我们来梳理一下用户内存映射的过程
- test命令申请内存,申请4字节,即1个int类型的大小,申请的地址为 0。因为不是文件映射,因此fd传入EOF,
- mmap返回映射后的虚拟地址,并赋值给变量 counter
- 父进程fork一个子进程。子进程会拷贝父进程的页目录。为子分配新的页目录/页表结构(PDE/PTE 复制),但物理页不复制。
这里我们分两种情况讨论,如果是共享内存,那会执行如下流程:
- 拷贝页目录的函数 copy_pde 做判断。如果一块内存是共享内存,那这块物理页不会被设置为只读
- 因此这块虚拟内存,父进程在修改这个页,子进程读取的内容也在同步修改。
如果不是共享内存,那会执行如下流程:
- 对于非共享页,内核把父、子对应的 PTE 都置为只读, 因为先置为只读后进行拷贝
- 父进程第一次执行 (*counter)++, 执行写操作,发现这一页只读了,触发写保护异常,为父进程重新分配物理页。父进程继续写。
- 子进程继续访问原来的只读页,因此一直打印原来的值。
可以看到,子进程被fork出来时,如果父进程的内存是共享的,那子进程就会映射到同一个物理页,如果不是共享的,子进程当使用时候,就会触发写时赋值,申请一个新的物理页,这里重点在于copy_pde这个函数的实现。
page_entry_t *copy_pde()
{
task_t *task = running_task();
page_entry_t *pde = (page_entry_t *)alloc_kpage(1);
memcpy(pde, (void *)task->pde, PAGE_SIZE);
// 将最后一个页表指向页目录自己,方便修改
page_entry_t *entry = &pde[1023];
entry_init(entry, IDX(pde));
page_entry_t *dentry;
for (size_t didx = (sizeof(KERNEL_PAGE_TABLE) / 4); didx < 1023; didx++)
{
dentry = &pde[didx];
if (!dentry->present)
continue;
page_entry_t *pte = (page_entry_t *)(PDE_MASK | (didx << 12));
for (size_t tidx = 0; tidx < 1024; tidx++)
{
entry = &pte[tidx];
if (!entry->present)
continue;
assert(memory_map[entry->index] > 0); // 对应物理内存引用大于 0
// 若不是共享内存,则置为只读
if (!entry->shared)
{
entry->write = false;
}
// 对应物理页引用加 1
memory_map[entry->index]++;
assert(memory_map[entry->index] < 255);
}
u32 paddr = copy_page(pte);
dentry->index = IDX(paddr);
}
set_cr3(task->pde);
return pde;
}
如上所示,我们如果申请了一块共享内存,那会映射一页,并标记这一页是共享内存。在复制时,这一页标记为可写,因此子进程访问时直接访问。但是如果不是共享内存,子进程访问时就会触发缺页异常,因此就会重新映射一块物理内存。父进程在执行时会恢复父进程自身的页目录。
四、疑问
如果申请的内存是私有的,子进程和父进程都修改了counter值。那么子进程和父进程都会触发写保护异常并申请新页进行映射。那此时原来的页引用就会变为0,这里也没有任何释放机制。这个问题咨询了原作者,答复如下:单CPU页错误禁止中断,保证只有一个会触发写时复制,第二个进程就不需要了,直接写。另外,如果是多 CPU 的情况,应该对页面加锁,保证写时复制只发生一次!
这里看了下代码,假设子进程进行了写时复制,当父进程也触发页错误时发现自己是物理页的唯一引用,就把页改成了可写状态。来源于代码page_fault。
「真诚赞赏,手留余香」
真诚赞赏,手留余香
使用微信扫描二维码完成支付