서론

이번에 소개해드릴 내용은 xv6의 Trap에 관한 내용입니다. Trap에 대해서는 아래에서 자세하게 설명드리겠지만 간단하게 말씀드리자면 system call, exception, device interrupt를 통합하여 부르는 단어입니다.

본 글에서는 risc-v의 xv6 운영체제에서 Trap 중에서 system call에 집중하여 Trap이 어떻게 발생되고 핸들링되는지 설명해드리겠습니다.

개념

우선 xv6에서 사용되는 용어들에 대해서 말씀을 드리겠습니다.

Mode

Application과 OS는 강력하게 ‘분리’되어있습니다. 왜냐하면 어떤 Application에서 문제가 발생했다하더라도 운영체제가 터지거나 다른 프로세스에 영향이 가면 안되니말입니다. 이러한 분리는 Mode라는 것을 통해 이뤄집니다.

RISC-V같은 경우 세가지 모드가 있습니다. 이 모드들은 각각 machine mode(m-mode), supervisor mode(s-mode), user mode(u-mode)입니다.

단어에서 느낌이 오시겠지만 m-mode는 모든 권한을 가지고 있는 가장 강력한 모드로, 컴퓨터가 시작될 때 세팅을 위해 진입하게되는 모드입니다. s-mode는 m-mode보다는 약하지만 privileged instructions를 실행할 수 있는 모드입니다. 예를 들면 레지스터에 읽고 쓰기를 한다거나 interrupt를 허용/비허용 하는 등 말이죠. 그렇다면 u-mode는 이러한 권한들이 없는 가장 약한 단계이겠죠.

그런데 우리가 컴퓨터를 사용할 때는 보통 u-mode에 있습니다. 그렇다면 어떻게 u-mode에서 system call같은 kernel function들을 부를 수 있을까요? 답은 CPU가 mode를 바꿀 수 있는 방법을 마련해뒀기때문입니다.

RISC-V의 경우 ecall이라는 instruction이 있습니다. ecall을 통해 u-mode에서 s-mode로 이동하고, system call을 처리한 뒤 다시 u-mode로 돌아오는 과정을 통해 system call을 처리할 수 있는 것입니다.

Trap

CPU가 실행하던 instructions들을 잠시 놔두고 특별한 코드를 실행시키게하는 세가지 종류의 이벤트가 있습니다. 첫번째는 바로 system call이고 두번째는 Exception, 세번째는 device interrupt입니다. 그리고 이 세가지의 이벤트를 통틀어서 Trap이라고 부릅니다.

이런 Trap들은 모두 커널에서 핸들링됩니다. 물론 생각해보면 커널에서 핸들링되는게 자연스럽습니다. 왜냐하면 예를 들어 write()를 불렀을 때 메모리에 접근하여야 하는데 커널만이 디바이스에 접근할 수 있죠. 그리고 exception이 발생했을 때 xv6는 exception이 발생한 프로그램을 죽이는데, 그러기 위해선 커널이 exception을 핸들링하는게 맞겠죠.

그래서 Trap이 발생하게되면 커널로 이동하게 되고, 커널은 Trap을 처리한 후 Trap이 발생하기 전 상황으로 되돌아가야하므로 여러 레지스터값과 상태들을 저장해둡니다.

Control registers

RISC-V CPU에는 Trap이 발생했을 때 그에 관한 정보를 적어두는 control registers가 있습니다. 아래는 그 예시입니다

Registers Description
stvec 커널이 Trap handler의 주소를 적어두는 곳입니다. RISC-V는 Trap 발생 시 이곳으로 점프합니다.
sepc Trap이 발생했을 때 RISC-V는 이곳에 기존의 program counter를 저장해둡니다.
scause Trap이 발생했을 때 RISC-V는 이곳에 발생한 이유에 관한 숫자를 적어둡니다.
sscratch user registers 값을 저장하기 전에 잃지않도록 사용할 수 있는 추가적인 레지스터입니다.
sstatus sstatus의 SIE bit이 device interrupt를 허용할지 말지를 결정합니다.

Trap이 발생하게되면 이렇게 control register들에 값들이 저장되게 됩니다. 그리고 이와 비슷하게 machine mode에서 사용되는 mepc, mtvec와 같은 레지스터들도 있습니다.

앞에서 말씀드렸다시피 이 레지스터의 값들은 user mode에서는 읽을 수 없습니다. {: .prompt-warning }

본론 : Trap 핸들링

Trap은 어떻게 발생될까요? system call을 통해 알아보겠습니다. 예를 들어 저희가 write 시스템 콜을 불렀다고 한다면 다음과 같이 assembly 함수를 호출합니다. 그러면 a7 레지스터에 시스템 콜 번호를 적어두고 ecall instruction을 실행하게 되죠. ecall이란 앞에서 말씀드렸듯 exception을 발생시켜 kernel보고 그 exception을 처리해달라고 요구하는 것입니다.

그러면 s-mode로 넘어가서 a7에 적혀있는 정보를 통해 시스템 콜을 처리하게 되는 것이죠.

    .global write
    write:
      li a7, SYS_WRITE;
      ecall;
      ret;

1. CPU가 해주는 것

그러면 실제로 Trap이 발생했을 때 어떤 과정을 통해 Trap이 핸들링될까요? 우선 CPU가 다음과 같은 일들을 하게 됩니다.

  1. If the trap is a device interrupt, and the sstatus SIE bit is clear, don’t do any of the following.
  2. Disable interrupts by clearing the SIE bit in sstatus.
  3. Copy the pc to sepc.
  4. Save the current mode (user or supervisor) in the SPP bit in sstatus.
  5. Set scause to reflect the trap’s cause.
  6. Set the mode to supervisor.
  7. Copy stvec to the pc.
  8. Start executing at the new pc.

앞에서 말씀드렸다시피 Trap은 커널에서 처리되기때문에 u-mode에서 s-mode로 진입한 후 처리되어야합니다. 그런데 u-mode에서 실행하던 부분을 기억해둬야 s-mode에서 여러 함수들을 실행하고 난 뒤에도 다시 돌아올 수 있겠죠. 이를 위해 3번의 과정이 있습니다. pc(program counter)를 sepc 레지스터에 저장해두는 것이죠.

그리고 5번의 과정을 통해서 위의 표에서 봤던 scause 레지스터에 트랩의 원인을 적습니다. 그 후 s-mode로 진입하고, stvec이라는 trap handling code의 주소가 담겨있는 레지스터 값을 pc 옮겨줍니다. 그 후 새 pc를 실행함으로써 trap handling code를 실행합니다.

2. Kernel 내부로 들어가기

이제 stvec이 가리키고 있던 곳으로 가서 코드를 실행하려고 합니다. 그렇다면 stvec은 무엇을 가리키고 있을까요? uservec이라는 함수를 가리키고 있습니다. 그리고 이 uservec은 xv6의 경우 kernerl/trampoline.S에 적혀있고 그 내용은 아래와 같습니다.

Trap에 대해 소개해드릴 때 말씀드렸다시피 커널로 진입하게될 때는 u-mode에서 사용하던 정보들을 저장해놓게 됩니다. 그리고 그 정보들이 저장되는 곳은 프로세스 공간의 TRAPFRAME이라는 공간입니다.

그래서 아래의 코드를 보게되면 TRAPFRAME의 주소를 받아와서 user registers들을 전부 다 저장합니다. 그 후 커널로 이동하기 위한 세팅을 마치고, jr t0를 통해서 usertrap으로 점프하게 됩니다.

.globl trampoline
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.
        #

        # save user a0 in sscratch so
        # a0 can be used to get at TRAPFRAME.
        csrw sscratch, a0

        # each process has a separate p->trapframe memory area,
        # but it's mapped to the same virtual address
        # (TRAPFRAME) in every process's user page table.
        li a0, TRAPFRAME
        
        # save the user registers in TRAPFRAME
        sd ra, 40(a0)
        sd sp, 48(a0)
        sd gp, 56(a0)
        sd tp, 64(a0)
        sd t0, 72(a0)
        sd t1, 80(a0)
        sd t2, 88(a0)
        sd s0, 96(a0)
        sd s1, 104(a0)
        sd a1, 120(a0)
        sd a2, 128(a0)
        sd a3, 136(a0)
        sd a4, 144(a0)
        sd a5, 152(a0)
        sd a6, 160(a0)
        sd a7, 168(a0)
        sd s2, 176(a0)
        sd s3, 184(a0)
        sd s4, 192(a0)
        sd s5, 200(a0)
        sd s6, 208(a0)
        sd s7, 216(a0)
        sd s8, 224(a0)
        sd s9, 232(a0)
        sd s10, 240(a0)
        sd s11, 248(a0)
        sd t3, 256(a0)
        sd t4, 264(a0)
        sd t5, 272(a0)
        sd t6, 280(a0)

	    # save the user a0 in p->trapframe->a0
        csrr t0, sscratch
        sd t0, 112(a0)

        # initialize kernel stack pointer, from p->trapframe->kernel_sp
        ld sp, 8(a0)

        # make tp hold the current hartid, from p->trapframe->kernel_hartid
        ld tp, 32(a0)

        # load the address of usertrap(), from p->trapframe->kernel_trap
        ld t0, 16(a0)

        # fetch the kernel page table address, from p->trapframe->kernel_satp.
        ld t1, 0(a0)

        # wait for any previous memory operations to complete, so that
        # they use the user page table.
        sfence.vma zero, zero

        # install the kernel page table.
        csrw satp, t1

        # flush now-stale user entries from the TLB.
        sfence.vma zero, zero

        # jump to usertrap(), which does not return
        jr t0

이렇게 usertrap()으로 가게 되면 아래와 같은 코드를 볼 수가 있고, 이 부분에서 system call이 어떻게 처리되고, device interrupt가 어떻게 처리되는지 파악할 수 있습니다. 가장 중요하게 보셔야할 부분은 중간의 if(r_scause() == 8) 부분입니다. 위에 있는 레지스터의 표를 다시 보시면 scause는 Trap의 원인을 저장하는 부분이라는 것을 아실 수 있습니다.

if문을 통해 Trap이 system call로 인한 것인지를 따져서 syscall()이라는 함수를 통해 시스템 콜을 실행하게 됩니다. 그게 아니라면 else if 부분을 통해 키보드 입려과 같은 device interrupt로 생각해보고 처리하게 되고 그것도 아니라면 에러이므로 프로세스를 죽이게 됩니다.

// handle an interrupt, exception, or system call from user space.
void
usertrap(void)
{
  int which_dev = 0;

  if((r_sstatus() & SSTATUS_SPP) != 0)
    panic("usertrap: not from user mode");

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

  struct proc *p = myproc();
  
  // save user program counter.
  p->trapframe->epc = r_sepc();
  
  if(r_scause() == 8){
    // system call

    if(killed(p))
      exit(-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();
  } else if((which_dev = devintr()) != 0){
    // ok
  } else {
    printf("usertrap(): unexpected scause %p pid=%d\n", r_scause(), p->pid);
    printf("            sepc=%p stval=%p\n", r_sepc(), r_stval());
    setkilled(p);
  }

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

  // give up the CPU if this is a timer interrupt.
  if(which_dev == 2)
    yield();

  usertrapret();
}

system call 파트에 좀 더 집중해서 보자면 syscall()과 write()의 경우 아래와 같이 생겼습니다. syscall 함수 안에서는 유저가 어떤 system call을 불렀는지 파악해서 그에 해당하는 함수를 부르게 되는 것이죠.

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

  num = p->trapframe->a7; // system call 종류
  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
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);
}

3. user space로 돌아가기

이렇게 system call이라는 소기의 목적을 달성하게되면 다시 원래 있던 곳으로 돌아가야겠죠. 위의 usertrap()의 맨 마지막 줄을 보게되면 usertrapret()이라는 함수가 있습니다. 커널에서는 이 함수를 호출함으로써 다시 원래 있던 user space로 돌아가게 됩니다.

void
usertrapret(void)
{
  struct proc *p = myproc();

  // we're about to switch the destination of traps from
  // kerneltrap() to usertrap(), so turn off interrupts until
  // we're back in user space, where usertrap() is correct.
  intr_off();

  // send syscalls, interrupts, and exceptions to uservec in trampoline.S
  uint64 trampoline_uservec = TRAMPOLINE + (uservec - trampoline);
  w_stvec(trampoline_uservec);

  // set up trapframe values that uservec will need when
  // the process next traps into the kernel.
  p->trapframe->kernel_satp = r_satp();         // kernel page table
  p->trapframe->kernel_sp = p->kstack + PGSIZE; // process's kernel stack
  p->trapframe->kernel_trap = (uint64)usertrap;
  p->trapframe->kernel_hartid = r_tp();         // hartid for cpuid()

  // set up the registers that trampoline.S's sret will use
  // to get to user space.
  
  // set S Previous Privilege mode to User.
  unsigned long x = r_sstatus();
  x &= ~SSTATUS_SPP; // clear SPP to 0 for user mode
  x |= SSTATUS_SPIE; // enable interrupts in user mode
  w_sstatus(x);

  // set S Exception Program Counter to the saved user pc.
  w_sepc(p->trapframe->epc);

  // tell trampoline.S the user page table to switch to.
  uint64 satp = MAKE_SATP(p->pagetable);

  // jump to userret in trampoline.S at the top of memory, which 
  // switches to the user page table, restores user registers,
  // and switches to user mode with sret.
  uint64 trampoline_userret = TRAMPOLINE + (userret - trampoline);
  ((void (*)(uint64))trampoline_userret)(satp);
}

마무리

지금까지 xv6를 분석해보면서 Trap이 어떻게 처리되는지 살펴보았습니다. 여러분들도 보셨다시피 system call하나를 처리하기 위해서 page table을 전부 바꾸고, 레지스터 값들을 저장했다가 되돌리는 등 많은 작업들이 필요했습니다. 이렇기때문에 write()라는 단순한 함수가 일반적인 함수들보다 큰 부하를 일으키고 권한이 필요한 함수인 것이었습니다. 다음 글로 제 custom system call을 만드는 과정과 machine mode로 진입하는 방법에 대해 다루고 합니다.

감사합니다.