x86操作系统23——进程相关系统调用

一、系统调用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;   // 返回系统运行时间,单位秒
}
相关推荐
小猪佩奇TONY2 小时前
Linux 内核学习(16) --- linux x86-64 虚拟地址空间和区域
linux·运维·学习
L1624762 小时前
Docker 安装部署全流程使用指南(Linux 通用版)
linux·docker·容器
杰克崔2 小时前
kprobe及kretprobe的基于例子来调试分析其原理
linux·运维·服务器·车载系统
`林中水滴`2 小时前
Linux系列:Ubuntu 防火墙命令
linux·ubuntu
雾岛听蓝2 小时前
初识Linux
linux
听风吹雨yu2 小时前
YoloV11的pt模型转rknn模型适用于RK3588等系列
linux·python·yolo·开源·rknn
nihui1233 小时前
Kali Linux 中 Nmap 工具详细使用指南
linux·网络·web安全
生而为虫3 小时前
34-35.玩转Linux操作系统
linux·运维·服务器
枕咸鱼的猫3 小时前
Linux命令打包/压缩(tar)、通用压缩(zip)详解
linux·运维·服务器