博客 / 詳情

返回

xv6:從第一個用户程序trap返回kernel態

二、書接上文,上一節大概弄清了從通電到第一個程序運行的脈絡。本節將深入探討上節最後一部分:從 Kernel(內核態)切換到 User(用户態)的執行邏輯,並詳細解析 從 User 返回 Kernel 的全過程。

kexec 進程加載與啓動流程

閲讀kexec所需聲明:用户棧大小、程序頭結構體定義、proc_pagetable和copyout用處

#define USERSTACK    1     // user stack pages

// Program section header
struct proghdr {
  uint32 type;
  uint32 flags;
  uint64 off;
  uint64 vaddr;
  uint64 paddr;
  uint64 filesz;
  uint64 memsz;
  uint64 align;
};

// Create a user page table for a given process, with no user memory,
// but with trampoline and trapframe pages.
pagetable_t proc_pagetable(struct proc* p);

// Copy from kernel to user.
// Copy len bytes from src to virtual address dstva in a given page table.
// Return 0 on success, -1 on error.
int copyout(pagetable_t pagetable, uint64 dstva, char* src, uint64 len);

kexec代碼塊

int kexec(char* path, char** argv) {
  char *s, *last;
  int i, off;
  uint64 argc, sz = 0, sp, ustack[MAXARG], stackbase;
  struct elfhdr elf;
  struct inode* ip;
  struct proghdr ph;
  pagetable_t pagetable = 0, oldpagetable;
  struct proc* p = myproc();

  begin_op();

  // Open the executable file.
  if ((ip = namei(path)) == 0) {
    end_op();
    return -1;
  }
  ilock(ip);

  // Read the ELF header.
  if (readi(ip, 0, (uint64)&elf, 0, sizeof(elf)) != sizeof(elf)) goto bad;

  // Is this really an ELF file?
  if (elf.magic != ELF_MAGIC) goto bad;

  if ((pagetable = proc_pagetable(p)) == 0) goto bad;

  // Load program into memory.
  for (i = 0, off = elf.phoff; i < elf.phnum; i++, off += sizeof(ph)) {
    if (readi(ip, 0, (uint64)&ph, off, sizeof(ph)) != sizeof(ph)) goto bad;
    if (ph.type != ELF_PROG_LOAD) continue;
    if (ph.memsz < ph.filesz) goto bad;
    if (ph.vaddr + ph.memsz < ph.vaddr) goto bad;
    if (ph.vaddr % PGSIZE != 0) goto bad;
    uint64 sz1;
    if ((sz1 = uvmalloc(pagetable, sz, ph.vaddr + ph.memsz, flags2perm(ph.flags))) == 0) goto bad;
    sz = sz1;
    if (loadseg(pagetable, ph.vaddr, ip, ph.off, ph.filesz) < 0) goto bad;
  }
  iunlockput(ip);
  end_op();
  ip = 0;

  p = myproc();
  uint64 oldsz = p->sz;

  // Allocate some pages at the next page boundary.
  // Make the first inaccessible as a stack guard.
  // Use the rest as the user stack.
  sz = PGROUNDUP(sz);
  uint64 sz1;
  if ((sz1 = uvmalloc(pagetable, sz, sz + (USERSTACK + 1) * PGSIZE, PTE_W)) == 0) goto bad;
  sz = sz1;
  uvmclear(pagetable, sz - (USERSTACK + 1) * PGSIZE);
  sp = sz;
  stackbase = sp - USERSTACK * PGSIZE;

  // Copy argument strings into new stack, remember their
  // addresses in ustack[].
  for (argc = 0; argv[argc]; argc++) {
    if (argc >= MAXARG) goto bad;
    sp -= strlen(argv[argc]) + 1;
    sp -= sp % 16;  // riscv sp must be 16-byte aligned
    if (sp < stackbase) goto bad;
    if (copyout(pagetable, sp, argv[argc], strlen(argv[argc]) + 1) < 0) goto bad;
    ustack[argc] = sp;
  }
  ustack[argc] = 0;

  // push a copy of ustack[], the array of argv[] pointers.
  sp -= (argc + 1) * sizeof(uint64);
  sp -= sp % 16;
  if (sp < stackbase) goto bad;
  if (copyout(pagetable, sp, (char*)ustack, (argc + 1) * sizeof(uint64)) < 0) goto bad;

  // a0 and a1 contain arguments to user main(argc, argv)
  // argc is returned via the system call return
  // value, which goes in a0.
  p->trapframe->a1 = sp;

  // Save program name for debugging.
  for (last = s = path; *s; s++)
    if (*s == '/') last = s + 1;
  safestrcpy(p->name, last, sizeof(p->name));

  // Commit to the user image.
  oldpagetable = p->pagetable;
  p->pagetable = pagetable;
  p->sz = sz;
  p->trapframe->epc = elf.entry;  // initial program counter = ulib.c:start()
  p->trapframe->sp = sp;          // initial stack pointer
  proc_freepagetable(oldpagetable, oldsz);

  return argc;  // this ends up in a0, the first argument to main(argc, argv)

bad:
  if (pagetable) proc_freepagetable(pagetable, sz);
  if (ip) {
    iunlockput(ip);
    end_op();
  }
  return -1;
}

1. ELF 文件解析與內存佈局
kexec 的任務是讀取磁盤上的可執行文件(ELF 格式),並把它佈置到內存中。ELF 文件由 ELF Header(elfhdr)、Program Header Table、Sections 三部分組成。其中 elfhdr 包含用於判斷文件有效性的 magic,並存放了程序頭表地址 phoff。通過 phoff 定位程序頭後,根據其中 Segment 包含的信息,識別類型為 ELF_PROG_LOAD 的段。系統按 filesz 計算出所需的虛擬內存大小 memsz,並將其讀入從 vaddr 開始的對應區域,完成用户進程代碼和數據的加載。

2. 用户棧初始化與參數傳遞
隨後,系統為用户分配 2 頁內存,分別作為 userstack 和 guard 頁。加載過程將參數逐個存入 userstack 中,並遵循 16B 對齊要求。為了讓用户程序能夠定位這些參數,系統還會將這些參數的地址同樣保存到 userstack 中。最後將 a1 寄存器指向棧指針 sp,使得程序進入用户態後能根據地址找到對應的字符串。

3. 進程狀態更新與硬件跳轉
最後,更新用户進程的 name、pagetable 和 sz,並令 epc 指向 elf.entry。在準備返回階段,epc 的值被賦給 sepc。當執行 userret 中的 sret 指令後,硬件執行 PC = sepc,處理器便從 elf.entry 開始正式執行用户態程序。

2. 從elf.entry到main

使用user.ld把程序+庫鏈接成一個用户態ELF可執行文件

_%: %.o $(ULIB) $U/user.ld
    $(LD) $(LDFLAGS) -T $U/user.ld -o $@ $< $(ULIB)
//
// wrapper so that it's OK if main() does not call exit().
//
void start(int argc, char** argv) {
  int r;
  extern int main(int argc, char** argv);
  r = main(argc, argv);
  exit(r);
}

使用反彙編得到如下結果

objdump -f user/_init

user/_init:	file format elf64-littleriscv
architecture: riscv64
start address: 0x00000000000000bc

在得到ELF可執行文件的過程中,在鏈接環節,得到start的地址為0xbc,將0xbc賦值給了elf.entry,最後這個sret執行,PC指向start函數。

void start(int argc, char** argv) {
  int r;
  extern int main(int argc, char** argv);
  r = main(argc, argv);
  exit(r);
}

start函數會調用init下的main函數

char* argv[] = {"sh", 0};

int main(void) {
  int pid, wpid;

  if (open("console", O_RDWR) < 0) {
    mknod("console", CONSOLE, 0);
    mknod("statistics", STATS, 0);
    open("console", O_RDWR);
  }
  dup(0);  // stdout
  dup(0);  // stderr

  for (;;) {
    printf("init: starting sh\n");
    pid = fork();
    if (pid < 0) {
      printf("init: fork failed\n");
      exit(1);
    }
    if (pid == 0) {
      exec("sh", argv);
      printf("init: exec sh failed\n");
      exit(1);
    }

    for (;;) {
      // this call to wait() returns if the shell exits,
      // or if a parentless process exits.
      wpid = wait((int*)0);
      if (wpid == pid) {
        // the shell exited; restart it.
        break;
      } else if (wpid < 0) {
        printf("init: wait returned an error\n");
        exit(1);
      } else {
        // it was a parentless process; do nothing.
      }
    }
  }
}

1. 文件描述符與子進程創建
系統初始化時,將 console 對應的文件描述符設置為 0,並將標準輸出與標準錯誤重定向到 console 中。隨後通過 fork 創建子進程,子進程得到的 pid 為 0,並開始執行 sh 程序。子進程在執行完指定的命令後,通過 exit 退出 shell。

2. 父進程的監控與循環
與此同時,父進程拿到子進程的真實 pid。父進程進入循環狀態,持續等待並檢查子進程是否結束。一旦子進程結束,父進程則退出當前循環並重啓一個新的 shell,從而實現交互界面的持續存在。

3. sh 程序的功能實現
sh 程序的核心功能是解析用户輸入的命令。在解析完成後,它通過調用相應的系統調用並傳遞必要的參數,驅動內核完成具體的任務執行。

3. 系統調用從用户態到內核態的流轉

以最常見的write命令為例:

#!/usr/bin/perl -w

# Generate usys.S, the stubs for syscalls.

sub entry {
    my $prefix = "sys_";
    my $name = shift;
    if ($name eq "sbrk") {
	print ".global $prefix$name\n";
	print "$prefix$name:\n";
    } else {
	print ".global $name\n";
	print "$name:\n";
    }
    print " li a7, SYS_${name}\n";
    print " ecall\n";
    print " ret\n";
}

entry("fork");
entry("exit");
entry("wait");
entry("pipe");
entry("read");
entry("write");

批量生成usys.S,write如下:

.global write
write:
 li a7, SYS_write
 ecall
 ret

uservec部分流程,其中 t0 指向 kernel/usertrap 函數。

.section trampsec
.globl trampoline
.globl usertrap
trampoline:
.align 4
.globl uservec
uservec:    
    # trap.c sets stvec to point here, so
    # traps from user space start here,
    # in supervisor mode, but with a
    # user page table.
    # load the address of usertrap(), from p->trapframe->kernel_trap
    ld t0, 16(a0)

    # call usertrap()
    jalr t0

構建系統調用函數的函數指針數組

extern uint64 sys_fork(void);
extern uint64 sys_exit(void);
extern uint64 sys_wait(void);
...

#define SYS_fork    1
#define SYS_exit    2
#define SYS_wait    3
...

static uint64 (*syscalls[])(void) = {
[SYS_fork]    sys_fork,
[SYS_exit]    sys_exit,
[SYS_wait]    sys_wait,
...
}

write系統調用到sys_write

uint64 sys_write(void) {
  struct file* f;
  int n;
  uint64 p;

  argaddr(1, &p);
  argint(2, &n);
  if (argfd(0, 0, &f) < 0) return -1;

  return filewrite(f, p, n);
}

void syscall(void) {
  int num;
  struct proc* p = myproc();

  num = p->trapframe->a7;
  if (num > 0 && num < NELEM(syscalls) && syscalls[num]) {
    // Use num to lookup the system call function for num, call it,
    // and store its return value in p->trapframe->a0
    p->trapframe->a0 = syscalls[num]();
  } else {
    printf("%d %s: unknown sys call %d\n", p->pid, p->name, num);
    p->trapframe->a0 = -1;
  }
}

uint64 usertrap(void) {
  int which_dev = 0;

  // send interrupts and exceptions to kerneltrap(),
  // since we're now in the kernel.
  w_stvec((uint64)kernelvec);  // DOC: kernelvec

  struct proc* p = myproc();

  // save user program counter.
  p->trapframe->epc = r_sepc();

  if (r_scause() == 8) {
    // system call

    if (killed(p)) kexit(-1);

    // sepc points to the ecall instruction,
    // but we want to return to the next instruction.
    p->trapframe->epc += 4;

    // an interrupt will change sepc, scause, and sstatus,
    // so enable only now that we're done with those registers.
    intr_on();

    syscall();
  }
}

1. 異常觸發與現場保存
執行流程首先將系統調用號寫入 a7 寄存器,隨後通過 ecall 指令觸發一次異常(trap)。硬件自動記錄 trap 原因為 user ecall 並存入 scause,同時將返回地址存入 sepc。此時權限提升至 S mode,硬件跳轉到 uservec 進行異常處理。在 uservec 中,系統首先將當前進程的運行快照保存到 trapframe 中,最後跳轉至寄存器 t0 所指向的 kernel/usertrap 函數。

2. 內核態異常處理與跳轉
進入 usertrap 函數後,首先將異常向量表地址從 uservec 切換為 kernelvec,以處理內核態可能發生的異常。隨後保存返回用户態時所需的指令地址,並正式進入 syscall 處理環節。

3. 函數分發與內核執行
在 syscall 函數內部,系統通過 a7 寄存器中的 num 確定本次調用的具體命令類型。接着利用該編號訪問函數指針數組,精準跳轉到對應的內核函數。例如,若本次調用號為 SYS_write,系統將獲取相應參數並執行 filewrite 內核函數,最終完成實際的寫操作。

從用户態到內核態的參數傳遞:

static uint64 argraw(int n) {
  struct proc* p = myproc();
  switch (n) {
  case 0:
    return p->trapframe->a0;
  case 1:
    return p->trapframe->a1;
  case 2:
    return p->trapframe->a2;
  case 3:
    return p->trapframe->a3;
  case 4:
    return p->trapframe->a4;
  case 5:
    return p->trapframe->a5;
  }
  panic("argraw");
  return -1;
}

// Fetch the nth 32-bit system call argument.
void argint(int n, int* ip) {
  *ip = argraw(n);
}

根據參數位次,使用p->trapframe用户態寄存器快照信息進行傳參,從a0到a5都可用作傳參。

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.