一、系统调用brk
brk系统调用的作用是修改堆内存的上限。我们的操作系统从8M ~ 128M 是用户进程的内存空间。我们会把进程的ELF文件映射到 8M 开始的位置, 有text段、data段、bss段。这些段结束后就是堆内存。堆内存使用了多少就需要使用brk来标记。brk系统调用是 malloc/free 函数的基础。
c
int32 sys_brk(void *addr){
LOGK("task brk 0x%p\n", addr);
u32 brk = (u32)addr;
ASSERT_PAGE(brk); // 判断brk是否是页开始的位置
task_t *task = running_task();
assert(task->uid != KERNEL_USER); // 判断是否是用户
assert(KERNEL_MEMORY_SIZE < brk < USER_STACK_BOTTOM); // 判断brk是否属于用户内存空间
u32 old_brk = task->brk; // 获取进程当前的堆内存边界
// 如果当前边界大于新申请的边界,那就释放内存映射
if (old_brk > brk) {
for (; brk < old_brk; brk += PAGE_SIZE) {
unlink_page(brk);
}
}
else if (IDX(brk - old_brk) > free_pages) { // 如果新的增加brk大于了剩余的空闲页,就返回-1,没有可用内存了。
return -1; // out of memory
}
task->brk = brk;
return 0;
}
重点:
- 缩小堆时立即 unlink_page 回收物理页以释放资源并更新位图/引用计数。
- 延迟分配(在 sys_brk 只修改边界,不马上 link_page)节省内存并避免不必要的拷贝;只有真正访问才分配物理页(页错误触发 COW 或 link_page)。
访问尚未映射的虚拟页会触发 page fault:内核在 page_fault_handler 中读取 CR2、解析错误码。若地址在允许范围并且是"页面不存在"的情况,走按需分配分支。
c
void page_fault_handler(
u32 vector, // 中断向量号
u32 edi, u32 esi, u32 ebp, u32 esp, // 通用寄存器
u32 ebx, u32 edx, u32 ecx, u32 eax, // 通用寄存器
u32 gs, u32 fs, u32 es, u32 ds, // 段寄存器
u32 vector0, u32 error, u32 eip, u32 cs, u32 eflags // 中断信息
){
assert(vector == 0x0e); // 缺页异常中断号 14 (0x0e)
u32 vaddr = get_cr2(); // 获取导致缺页异常的线性地址
LOGK("Page fault at address 0x%p, eip 0x%p, error code 0x%p\n", vaddr, eip, error);
page_error_code_t *code = (page_error_code_t *)&error; // 解析错误代码
task_t *task = running_task();
assert(KERNEL_MEMORY_SIZE <= vaddr && vaddr <= USER_STACK_TOP); // 缺页地址必须在内核内存和用户栈顶之间
// 仅当页面不存在且访问地址在用户栈范围内时,才进行页面链接操作
if(!code->present && (vaddr < task->brk || vaddr >= USER_STACK_BOTTOM)){
u32 page = PAGE(IDX(vaddr)); // 计算出对应的页对齐地址
link_page(page); // 链接该页
return;
}
panic("Page fault can not be handled!!!");
}
二、任务id
这节涉及两个系统调用,获取任务id和父任务id。首先需要在task_t这个结构中增加 pid 和 ppid 两个元素。在创建任务时为其确定唯一pid:
c
task_t *get_free_task() {
// 遍历任务表寻找空闲槽,i 为索引
for (int i = 0; i < TASK_NR; i++) {
if (task_table[i] == NULL) {
task_t *task = (task_t *)alloc_kpage(1); // 为任务分配一页内核页并存入表中
memset(task, 0, sizeof(task_t)); // 清空任务结构体
task->pid = i; // 设置任务ID为索引值
task_table[i] = task; // 存入任务表
return task_table[i]; // 返回新分配的任务指针
}
}
panic("No Free Task!!!"); // 若无空闲任务则触发 panic
}
调用的实现比较简单, 直接返回任务的pid和ppid即可。
c
pid_t sys_getpid(){
task_t *current = running_task(); // 获取当前运行任务指针
return current->pid; // 返回当前任务的进程ID
}
pid_t sys_getppid(){
task_t *current = running_task(); // 获取当前运行任务指针
return current->ppid; // 返回当前任务的父进程ID
}
三、系统调用fork
fork的作用是创建一个子进程,子进程的返回值为0,父进程的返回值为子进程的id。除了内核直接创建的 1 号进程和内核线程外,绝大多数用户态进程都是由其他进程通过 fork 创建的,该函数是一次调用,两次返回。如下为系统调用fork的实现:
c
pid_t task_fork(){
bool intr = interrupt_disable(); // 关闭中断,确保原子操作
task_t *task = running_task(); // 获取当前运行任务指针
assert(task->node.next == NULL && task->node.prev == NULL); // 确保当前任务不在任何链表中
assert(task->state == TASK_RUNNING); // 确保当前任务处于运行状态
task_t *child = get_free_task(); // 分配一个空闲任务结构体作为子任务
pid_t pid = child->pid; // 获取子任务的进程ID
memcpy(child, task, PAGE_SIZE); // 复制父任务的任务结构体到子任务结构体
child->pid = pid; // 设置子任务的进程ID
child->ppid = task->pid; // 设置子任务的父进程ID
child->state = TASK_READY; // 将子任务状态设置为就绪
child->ticks = child->priority; // 重置子任务的时间片为其优先级值
child->vmap = kmalloc(sizeof(bitmap_t)); // 为子任务分配虚拟内存位图结构体
memcpy(child->vmap, task->vmap, sizeof(bitmap_t)); // 复制父任务的虚拟内存位图结构体到子任务
void *buf = (void *)alloc_kpage(1); // 为位图缓冲区分配一页内存
memcpy(buf, task->vmap->bits, PAGE_SIZE); // 复制父任务的位图缓冲区到子任务
child->vmap->bits = buf; // 更新子任务的位图缓冲区指针
child->pde = copy_pde(); // 复制父任务的页目录作为子任务的页目录
task_build_stack(child); // 构建子任务的栈帧
set_interrupt_state(intr); // 恢复之前的中断状态
return child->pid; // 返回子任务的进程ID
}
其中一次调用两次返回的核心在于task_build_stack(child);函数,其实现如下:
c
static void task_build_stack(task_t *task){
u32 addr = (u32)task + PAGE_SIZE; // 计算任务栈顶地址
addr -= sizeof(intr_frame_t); // 为中断帧留出空间
intr_frame_t *iframe = (intr_frame_t *)(addr); // 在栈顶创建中断帧
iframe->eax = 0; // 初始化 eax 寄存器值为0 ,也就是子进程的返回值为0
addr -= sizeof(task_frame_t); // 为任务帧留出空间
task_frame_t *frame = (task_frame_t *)addr; // 在中断帧下方创建任务帧
frame->ebp = 0xaa55aa55; // 初始化保存的 ebp 寄存器值
frame->ebx = 0xaa55aa55; // 初始化保存的 ebx 寄存器值
frame->edi = 0xaa55aa55;
frame->esi = 0xaa55aa55;
frame->eip = interrupt_exit; // 设置任务的返回地址为中断退出处理程序
task->stack = (u32 *)frame; // 设置任务的栈指针
}
memcpy(child, task, PAGE_SIZE) 拷贝了父进程的栈/上下文快照,但随后 task_build_stack(child) 覆盖了子进程的栈,设置 task_frame->eip = interrupt_exit 和在栈上的 intr_frame->eax = 0。
因此调用 task_fork() 的上下文有两份"返回"结果:
-
父进程在调用处继续执行并直接返回
child->pid(在父的上下文里正常执行那条 return)。 -
子进程不会在 fork 的调用处立刻顺序执行父后的指令;子进程会被放入就绪并等调度。被调度时会按照子栈上的设置先跳到
interrupt_exit完成寄存器恢复并执行IRET,然后在用户态从中断帧的 eip 处恢复执行,但是寄存器 eax在父进程fork时被设置为0,所以子进程看到 的 fork() 的返回 为 0 。
fork时还有一个事情需要做的就是拷贝页目录,如下为拷贝页目录的实现:
c
// 复制一页内存,返回新页的物理地址
static u32 copy_page(void *page) {
u32 paddr = get_page(); // 获取一个8M以上的物理页,物理地址存储在 paddr 中。
// 获取虚拟地址 0x00000000 对应的 页表项。也就是说,我们想 临时把虚拟地址 0 映射到 paddr 所指向的物理页
page_entry_t *entry = get_pte(0, false); // 获取虚拟地址 0x0 对应的页表
entry_init(entry, IDX(paddr)); // 初始化该页表项,指向新分配的物理页
memcpy((void *)0, (void *)page, PAGE_SIZE); // 将原页内容复制到新页中
entry->present = false; // 取消映射
flush_tlb(0); // 刷新 TLB,确保映射关系更新
return paddr;
}
// 复制当前任务的页目录
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 = 2; didx < 1023; didx++) { // 复制内核空间的页表项
dentry = &pde[didx]; // 遍历页目录的所有页目录项
if(!dentry->present) continue; // 如果该页目录项不存在,跳过
page_entry_t *table = (page_entry_t *)(PDE_MASK | (didx << 12)); // 找到可以修改的页表
for(size_t tidx = 0; tidx < 1024; tidx++) { // 遍历该页表的所有页表项
entry = &table[tidx];
if(!entry->present) continue; // 如果该页表项不存在,跳过
assert(memory_map[entry->index] >= 1); // 验证该物理页已被占用
entry->write = false; // 先将该页表项设置为只读,防止写时错误
memory_map[entry->index]++; // 增加该物理页的引用计数
assert(memory_map[entry->index] < 255); // 引用计数不能溢出
}
u32 paddr = copy_page(table); // 复制该页表对应的物理页
dentry->index = IDX(paddr); // 更新页目录项,指向新的页表物理地址
}
set_cr3(task->pde); // 切换回原任务的页目录
return pde;
}
巧妙了利用了第0页地址进行物理也数据的拷贝,因为第0页我们没有做映射。这里还有一个注意点,task_fork后用户页被设为只读,共享物理页,对子进程的写访问会触发页存在但不可写,因此需要对应处理分支的入口,用于复制独立物理页。
c
void page_fault_handler(
u32 vector, // 中断向量号
u32 edi, u32 esi, u32 ebp, u32 esp, // 通用寄存器
u32 ebx, u32 edx, u32 ecx, u32 eax, // 通用寄存器
u32 gs, u32 fs, u32 es, u32 ds, // 段寄存器
u32 vector0, u32 error, u32 eip, u32 cs, u32 eflags // 中断信息
){
assert(vector == 0x0e); // 缺页异常中断号 14 (0x0e)
u32 vaddr = get_cr2(); // 获取导致缺页异常的线性地址
LOGK("Page fault at address 0x%p, eip 0x%p, error code 0x%p\n", vaddr, eip, error);
page_error_code_t *code = (page_error_code_t *)&error; // 解析错误代码
task_t *task = running_task();
assert(KERNEL_MEMORY_SIZE <= vaddr && vaddr <= USER_STACK_TOP); // 缺页地址必须在内核内存和用户栈顶之间
// 写时复制缺页, task_fork后用户页被设为只读,共享物理页;
// 对子进程的写访问会触发页存在但不可写,这是对应处理分支的入口,用于复制独立物理页。
if (code->present) {
assert(code->write); // 必须是写访问引起的缺页异常
page_entry_t *pte = get_pte(vaddr, false); // 获取vaddr对应的页表
page_entry_t *entry = &pte[TIDX(vaddr)]; // 获取vaddr页框的入口
assert(entry->present); // 页面必须存在
assert(memory_map[entry->index] >= 1); // 物理页必须被占用
if(memory_map[entry->index] == 1){ // 仅被一个进程引用,直接提升写权限
entry->write = true;
LOGK("Write permission granted for address 0x%p\n", vaddr);
}
else{ // 被多个进程引用,执行写时复制
void *page = (void *)PAGE(IDX(vaddr)); // 获取该虚拟地址对应的页开始位置
u32 paddr = copy_page(page); // 复制该页内容到新页
memory_map[entry->index]--; // 减少原物理页的引用计数
entry_init(entry, IDX(paddr)); // 更新页表项,指向新的物理页
flush_tlb(vaddr); // 刷新该虚拟地址对应的 TLB
LOGK("Copy-on-write for address 0x%p\n", vaddr);
}
return;
}
// 仅当页面不存在且访问地址在用户栈范围内时,才进行页面链接操作
if(!code->present && (vaddr < task->brk || vaddr >= USER_STACK_BOTTOM)){
u32 page = PAGE(IDX(vaddr)); // 计算出对应的页对齐地址
link_page(page); // 链接该页
return;
}
panic("Page fault can not be handled!!!");
}
为了确保子进程不会在初始化未完成前被调度执行,task_fork创建期间禁用了中断。但是这会有另一个问题,alloc_kpage、kmalloc、copy_pde会修改共享全局结构(kernel_map、memory_map、free_pages 等),它们可能会等待其他内核组件或调度,必须能被调度器中断和恢复才能完成。当然我们到目前为止的内容并不会出现以上问题,但为了更稳妥,我做了一定的改进,当然,这个改进目前是完全的非必要操作,只是为了体会这个思想,各位酌情参考:
在临界区外完成可能耗时或者会触发复杂操作的工作(如页表复制、内存拷贝),临界区内只做最短的共享结构更新(分配槽、插入 task_table、写入子元数据)。
c
pid_t task_fork(){
task_t *parent = running_task();
assert(parent->node.next == NULL && parent->node.prev == NULL);
assert(parent->state == TASK_RUNNING);
// 先在临界区外分配会睡眠的资源
void *child_page = (void *)alloc_kpage(1); // 分配一页内核页作为子任务的任务结构体
if(!child_page) panic("alloc child page failed"); // 分配失败则触发 panic
bitmap_t *vmap = kmalloc(sizeof(bitmap_t)); // 分配子任务的虚拟内存位图结构体
if(!vmap) panic("kmalloc vmap failed");
void *vmap_bits = (void *)alloc_kpage(1); // 分配一页内核页作为子任务的虚拟内存位图缓冲区
if(!vmap_bits) panic("alloc vmap bits failed");
u32 child_pde = copy_pde(); // 复制页目录(可能会睡眠),提前完成
// 复制位图内容(使用父的 vmap->bits)
memcpy(vmap, parent->vmap, sizeof(bitmap_t)); // 复制父任务的虚拟内存位图结构体内容
memcpy(vmap_bits, parent->vmap->bits, PAGE_SIZE); // 复制父任务的虚拟内存位图缓冲区内容
vmap->bits = vmap_bits; // 设置子任务的虚拟内存位图缓冲区指针
// 在短临界区内把 child 插入任务表并初始化(避免在临界区内分配/睡眠)
bool intr = interrupt_disable();
int slot = -1;
for (int i = 0; i < TASK_NR; i++){
if (task_table[i] == NULL){
slot = i;
break;
}
}
if (slot == -1){ // 没有可用槽,回收已分配资源并报错
set_interrupt_state(intr);
free_kpage((u32)child_page, 1);
free_kpage((u32)vmap_bits, 1);
kfree(vmap);
panic("No Free Task!!!");
}
task_t *child = (task_t *)child_page;
task_table[slot] = child; // 注册到任务表
memcpy(child, parent, PAGE_SIZE); // 复制父任务的整个 page 到子任务页(保留栈快照)
// 修正子任务元数据
child->pid = slot; // 设置子任务的进程ID
child->ppid = parent->pid; // 设置子任务的父进程ID
child->state = TASK_READY; // 设置子任务状态为就绪
child->ticks = child->priority; // 重置子任务的时间片
child->vmap = vmap; // 设置子任务的虚拟内存位图指针
child->pde = child_pde; // 设置子任务的页目录地址
task_build_stack(child); // 构建子任务的栈帧
set_interrupt_state(intr);
return child->pid;
}
四、系统调用exit
exit的作用是终止当前进程。实现如下:
c
void task_exit(int status){
task_t *task = running_task();
assert(task->node.prev == NULL && task->node.next == NULL); // 任务不在任何阻塞队列中
assert(task->state == TASK_RUNNING); // 任务处于运行状态
task->state = TASK_DIED; // 设置任务状态为死亡
task->status = status; // 设置任务退出状态码
free_pde(); // 释放任务的页目录和所有内存映射
free_kpage((u32)task->vmap->bits, 1); // 释放任务的虚拟内存位图缓冲区
kfree(task->vmap); // 释放任务的虚拟内存位图结构体
for (size_t i = 0; i < TASK_NR; i++) {
task_t *child = task_table[i];
if (!child) continue;
if (child->ppid != task->pid) continue;
child->ppid = task->ppid; // 将子进程的父进程ID设置为当前任务的父进程ID
}
LOGK("Task %d exit with status %d\n", task->pid, status);
task_t *parent = task_table[task->ppid]; // 获取父任务指针
if(parent->state == TASK_WAITING &&
(parent->waitpid == -1 || parent->waitpid == task->pid)){
task_unlock(parent); // 若父任务在等待当前任务则解锁父任务
}
schedule(); // 调度器切换到下一个任务
}
五、系统调用waitpid
waitpid用来获取子进程的退出状态,也就是帮子进程收尸。实现如下:
c
pid_t task_waitpid(pid_t pid, int *status){
task_t *current = running_task(); // 获取当前运行任务指针
task_t *child = NULL; // 初始化子任务指针为空
while(true){
int found = 0; // 标记是否找到指定的子进程
for(size_t i = 0; i < TASK_NR; i++){
task_t *child = task_table[i];
if (!child) continue;
if (child->ppid != current->pid) continue; // 只检查当前任务的子进程
if (pid != -1 && child->pid != pid) continue; // 如果指定了 pid,则只检查该 pid 的子进程
if (child->state == TASK_DIED) {
task_table[i] = NULL; // 从任务表中移除已终止的子进程
*status = child->status; // 获取子进程的退出状态码
u32 ret = child->pid; // 保存子进程的 PID
free_kpage((u32)child, 1); // 释放子进程的任务结构体内存
return ret; // 返回已终止子进程的 PID
}
found = 1; // 找到符合条件的子进程
}
if (found) {
current->waitpid = pid; // 设置当前任务的等待 PID
task_block(current, NULL, TASK_WAITING); // 阻塞当前任务,等待子进程终止
continue;
}
break; // 没有找到符合条件的子进程,退出循环
}
return -1; // 没有符合条件的子进程,返回 -1
}
父调用 task_waitpid(pid, &status) 会循环检查 task_table 寻找符合条件的子进程。
如果发现子进程 child->state == TASK_DIED:
task_table[i] = NULL;--- 从任务表移除该子项(释放 PID 槽)。- *status = child->status; --- 返回子退出码。
- free_kpage((u32)child, 1); --- 释放子进程的 task_t 结构(子在 task_exit 中并未释放自身结构,变为僵尸直到父收割)。
返回子 PID,完成回收。
若父在 waitpid 时未找到死子但存在活子,则父会 task_block(current, NULL, TASK_WAITING) 阻塞,直到子 task_exit 时被 task_unlock 唤醒。
watipid 一次只会处理一个已经退出的子进程,其他的要通过再次调用 waitpid() 处理。
六、系统调用time
time的作用是获取当前时间戳,即从 1970-01-01 00:00:00 开始的秒数。实现比较简单,如下:
c
extern u32 startup_time; // 系统启动时间,单位毫秒
time_t sys_time(){
return startup_time + jiffies * JIFFY / 1000; // 返回系统运行时间,单位秒
}