欢迎光临
我们一直在努力

PA3.2用户程序和系统调用


  • 文章为本人自学记录的分享(非NJU学生),代码不保证正确,如果你是NJU的该实验课学员或者YSYX的A阶段学员,请自觉遵守学术诚信。
  • 如果本文造成不良影响请联系我撤回。
  • 实验为南京大学 计算机科学与技术系 计算机系统基础 课程实验。实验文档:https://ysyx.oscc.cc/docs/ics-pa/。

  • 加载第一个用户程序

    找出ELF文件每一个需要加载的segment的Offset, VirtAddr, FileSiz和MemSiz这些参数

    可执行文件在哪里?

    可执行文件位于ramdisk偏移为0处, 访问它就可以得到用户程序的第一个字节.

    代码和数据在可执行文件的哪个位置?

    ELF 用 Type = PT_LOAD 明确告诉 loader:“只有这些 segment 才是运行时必须加载的”
    位于ELF 文件的 Offset 位置

    代码和数据有多少?

    FileSize 字节

    "正确的内存位置"在哪里?

    内存的 VirtAddr 开始,长度为MemSiz

    实现loader

    size_t ramdisk_read(void *buf, size_t offset, size_t len);

    static uintptr_t loader(PCB *pcb, const char *filename) {
    // 利用<elf.h>里的结构体,类似ftrace
    Elf_Ehdr eh;
    ramdisk_read(&eh, 0, sizeof(eh));
    // 检查ELF文件的魔数
    assert(eh.e_ident[0] == 0x7f && eh.e_ident[1] == 'E' &&
    eh.e_ident[2] == 'L' && eh.e_ident[3] == 'F');

    for (int i = 0; i < eh.e_phnum; i++) {
    Elf_Phdr ph;
    size_t ph_off = eh.e_phoff + (size_t)i * eh.e_phentsize;
    ramdisk_read(&ph, ph_off, sizeof(ph));
    // 判断一个segment是否需要加载
    if (ph.p_type != PT_LOAD)
    continue;

    if (ph.p_filesz > 0) {
    ramdisk_read((void *)(uintptr_t)ph.p_vaddr, ph.p_offset, ph.p_filesz);
    }
    // .bss段置0
    if (ph.p_memsz > ph.p_filesz) {
    memset((void *)((uintptr_t)ph.p_vaddr + ph.p_filesz), 0,
    ph.p_memsz – ph.p_filesz);
    }
    }
    return (uintptr_t)eh.e_entry;
    }

    综上

    loader分配内存区间[VirtAddr, VirtAddr + MemSiz),从文件拷贝数据(从 ELF 文件的 Offset 位置拷贝 FileSiz 字节到 内存的 VirtAddr 开始),清零剩余部分[VirtAddr + FileSiz, VirtAddr + MemSiz)

    Q1:FileSiz通常不会大于相应的MemSiz,因为 内存里有一部分数据,在文件里根本不存在,比如.bss 段(未初始化data)。

    Q2:[VirtAddr + FileSiz, VirtAddr + MemSiz)为什么必须清零这段内存?

  • C 语言规定:未初始化的全局变量 / 静态变量,初始值必须为 0
  • 可重复性 & 可调试性:程序从一个“干净、确定”的初始状态开始
  • 安全性,如果不清零:新程序可以读到上一个进程留下的敏感数据(密码、密钥、内核指针)等,这是严重的安全漏洞
  • 系统调用

    识别系统调用

    EVENT_YIELD 和 EVENT_SYSCALL用的是同一条自陷指令ecall,区分它们的关键不是“指令不同”,而是“自陷时寄存器里带了什么标记”。首先用户态执行 yield() 或 syscall(),两者都会执行 ecall,但在执行前会把某个寄存器设置成不同的值。然后进入 CTE 的 trap 处理(abstract-machine 的 cte 实现):CTE 看到 trap 原因是“来自用户态的 ecall”后,再读取上下文里的某个通用寄存器(GPR1)。

    Context *__am_irq_handle(Context *c) {
    if (user_handler) {
    Event ev = {0};
    switch (c->mcause) {
    case 0xb: { // ecall
    c->mepc = c->mepc + 4;
    if (c->GPR1 == (uintptr_t)–1)
    ev.event = EVENT_YIELD;
    else // OS中分发
    ev.event = EVENT_SYSCALL;
    break;
    }
    default:
    ev.event = EVENT_ERROR;
    break;
    }

    c = user_handler(ev, c);
    assert(c != NULL);
    }

    return c;
    }

    目前Nanos-lite没有实现任何系统调用, 因此触发了panic.

    实现SYS_yield系统调用

    (1)在abstract-machine/am/include/arch/目录下的相应头文件中实现正确的GPR?宏, 让它们从上下文c中获得正确的系统调用参数寄存器.

    #ifdef __riscv_e
    #define GPR1 gpr[15] // a5
    #else
    #define GPR1 gpr[17] // a7
    #endif

    #define GPR2 gpr[10] // a0 = x10, arg0
    #define GPR3 gpr[11] // a1 = x11, arg1
    #define GPR4 gpr[12] // a2 = x12, arg2
    #define GPRx gpr[10] // a0 = x10, return value

    (2)添加SYS_yield系统调用.
    do_syscall里根据c->GPR1处理,具体见下文。
    (3)设置系统调用的返回值.
    返回给_syscall_()的调用者, 告知其系统调用执行的情况(如是否成功等).
    ret 的值是在内核态的 do_syscall() 里,通过改“上下文中的寄存器”设置的。

    实现SYS_exit系统调用

    void do_syscall(Context *c) {
    uintptr_t a[4];
    a[0] = c->GPR1;

    intptr_t ret = 0;
    switch (a[0]) {
    case SYS_yield:
    yield();
    ret = 0;
    break;
    case SYS_exit:
    halt(c->GPRx);
    break;
    default:
    panic("Unhandled syscall ID = %d", a[0]);
    }

    c->GPRx = ret;
    }
    }

    最后输出:

    static Context *do_event(Event e, Context *c) {
    switch (e.event) {
    case EVENT_YIELD: {
    printf("EVENT_YIELD!\\n");
    break;
    }
    case EVENT_SYSCALL: {
    printf("EVENT_SYSCALL!\\n");
    do_syscall(c);
    break;
    }
    default:
    panic("Unhandled event ID = %d", e.event);
    }
    return c;
    }

    分析三个打印:(在nanos-lite/src/irq.c的static Context *do_event(Event e, Context *c)里暂时添加的打印,用于分析):

    第一个打印EVENT_SYSCALL :sys_yield(作为 syscall 进入内核)
    第二个打印EVENT_YIELD!yield 事件:CTE yield 的自陷
    第三个打印EVENT_SYSCALL :sys_exit(程序结束,准备退出)

    RISC-V Linux为什么没有使用a0来传递系统调用号呢?
    原因可能是函数调用中a0是函数的第一个参数,同时也是返回值寄存器。如果用a0 既要承载返回值又原本存着 syscall 号,就会在返回时冲突。系统调用被刻意设计成“看起来像函数调用”,所以在riscv中用a7存 syscall 号(rv_e是a5)

    系统调用的踪迹 – strace(这节我是做到精彩纷呈的应用程序补的,debug用)

    为了避免系统调用->strace->系统调用套娃,只用了snprintf和putstr,而且写的很麻烦
    用的时候如

    make ARCH=riscv32-nemu run > build/log.txt 2>&1

    效果见下文

    #include "syscall.h"
    #include <common.h>
    #include <fs.h>

    void strace_write(int sysid, uintptr_t arg0, uintptr_t arg1, uintptr_t arg2,
    intptr_t ret) {
    char buf[256];

    switch (sysid) {
    case SYS_brk: {
    (void)snprintf(buf, sizeof(buf), "STRACE: sys_brk(increment:0x%lx)\\n",
    (unsigned long)arg0);
    break;
    }

    case SYS_open: {
    const char *path = (const char *)(uintptr_t)arg0;
    (void)snprintf(buf, sizeof(buf), "STRACE: fs_open(path:%s)\\n",
    (path ? path : "(null)"));
    break;
    }

    case SYS_lseek: {
    int fd = (int)arg0;
    const char *name = fd_to_name(fd);
    (void)snprintf(buf, sizeof(buf),
    "STRACE: fs_lseek(%s, offset:0x%lx, whence:%lu)\\n", name,
    (unsigned long)arg1, (unsigned long)arg2);
    break;
    }

    case SYS_read: {
    int fd = (int)arg0;
    const char *name = fd_to_name(fd);
    (void)snprintf(buf, sizeof(buf),
    "STRACE: fs_read(%s, buf:0x%lx, len:%lu) = %ld\\n", name,
    (unsigned long)arg1, (unsigned long)arg2, (long)ret);
    break;
    }

    case SYS_write: {
    int fd = (int)arg0;
    const char *name = fd_to_name(fd);
    (void)snprintf(buf, sizeof(buf),
    "STRACE: fs_write(%s, buf:0x%lx, len:%lu) = %ld\\n", name,
    (unsigned long)arg1, (unsigned long)arg2, (long)ret);
    break;
    }

    case SYS_close: {
    int fd = (int)arg0;
    const char *name = fd_to_name(fd);
    (void)snprintf(buf, sizeof(buf), "STRACE: fs_close(%s) = %ld\\n", name,
    (long)ret);
    break;
    }

    case SYS_exit: {
    (void)snprintf(buf, sizeof(buf), "STRACE: sys_exit(code:%ld)\\n",
    (long)arg0);
    break;
    }

    case SYS_yield: {
    (void)snprintf(buf, sizeof(buf), "STRACE: sys_yield()\\n");
    break;
    }

    case SYS_gettimeofday: {
    struct timeval *tv = (struct timeval *)(uintptr_t)arg0;
    (void)snprintf(buf, sizeof(buf),
    "STRACE: sys_gettimeofday(tv:{sec=%ld,usec=%ld})\\n",
    (long)tv->tv_sec, (long)tv->tv_usec);
    break;
    }

    default: {
    (void)snprintf(buf, sizeof(buf),
    "STRACE: unknown sys_%d(arg0=0x%lx, arg1=0x%lx, arg2=0x%lx) = %ld\\n",
    sysid, (unsigned long)arg0, (unsigned long)arg1,
    (unsigned long)arg2, (long)ret);
    break;
    }
    }

    putstr(buf);
    }

    操作系统之上的TRM

    实现标准输出:

    用户层的库函数_write():

    int _write(int fd, void *buf, size_t count) {
    return _syscall_(SYS_write, fd, (intptr_t)buf, count);
    }

    内核态的SYS_write:

    case SYS_write: {
    int fd = (int)a[1];
    const char *buf = (const char *)(uintptr_t)a[2];
    size_t len = (size_t)a[3];

    if (fd == 1 || fd == 2) {
    for (size_t i = 0; i < len; i++) {
    putch(buf[i]);
    }
    ret = (intptr_t)len;
    } else {
    ret = –1; // 返回失败
    }
    break;
    }

    输出:

    实现堆区管理:

    用户态库的 _sbrk()(在 navy-apps 的 libos 里):

    extern char _end; // abstract-machine/scripts/linker.ld
    void *_sbrk(intptr_t increment) {
    static intptr_t cur_brk = 0;
    if (cur_brk == 0)
    cur_brk = (intptr_t)&_end;

    intptr_t old = cur_brk;
    intptr_t new_brk = cur_brk + (intptr_t)increment;
    intptr_t ret = _syscall_(SYS_brk, (intptr_t)new_brk, 0, 0);

    if (ret == 0) {
    cur_brk = new_brk;
    return (void *)old;
    }
    return (void *)–1;
    }

    内核态的 SYS_brk(在Nanos-lite的 do_syscall() 里分发):

    case SYS_brk:
    ret = 0; // 这阶段:总是成功
    break;

    之前

    重新运行navy-apps/tests/hello/hello.c
    输出:

    分析输出:
    很显然,实现_sbrk前printf没法成功申请缓冲区,所以逐个字符地通过write()进行输出, 实现后变为将格式化完毕的字符串通过一次性进行输出.

    支持多个ELF的ftrace

    跳过

    赞(0)
    未经允许不得转载:171主机测评 » PA3.2用户程序和系统调用
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址