ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [xv6] - fork
    프로젝트/운영체제 만들기 2023. 7. 27. 19:38

    글의 참고

    - https://github.com/mit-pdos/xv6-public/tree/eeb7b415dbcb12cc362d0783e41c3d1f44066b17


    글의 전제

    - 밑줄로 작성된 글은 강조 표시를 의미한다.

    - 그림 출처는 항시 그림 아래에 표시했다.


    글의 내용

    : 유저 레벨의 모든 시스템 콜의 정의는 `usys.S` 파일에 정의되어 있다.

    #include "syscall.h"
    #include "traps.h"
    
    #define SYSCALL(name) \
      .globl name; \
      name: \
        movl $SYS_ ## name, %eax; \
        int $T_SYSCALL; \
        ret
    
    SYSCALL(fork)
    SYSCALL(exit)
    SYSCALL(wait)
    SYSCALL(pipe)
    SYSCALL(read)
    SYSCALL(write)
    SYSCALL(close)
    SYSCALL(kill)
    SYSCALL(exec)
    SYSCALL(open)
    SYSCALL(mknod)
    SYSCALL(unlink)
    SYSCALL(fstat)
    SYSCALL(link)
    SYSCALL(mkdir)
    SYSCALL(chdir)
    SYSCALL(dup)
    SYSCALL(getpid)
    SYSCALL(sbrk)
    SYSCALL(sleep)
    SYSCALL(uptime)

     

    : 위의 코드에서 `SYSCALL(fork)`는 어떻게 만들어질까? `SYS_fork`라는 상수는 시스템 콜을 호출할 때, `eax` 레지스터에 저장되서 시스템 콜 번호로 사용된다.

    // usys.S
    .globl fork
    exec:
        movl $SYS_fork
        int $T_SYSCALL
        ret
    // syscall.h
    // System call numbers
    #define SYS_fork    1
    #define SYS_exit    2
    #define SYS_wait    3
    #define SYS_pipe    4
    #define SYS_read    5
    #define SYS_kill    6
    #define SYS_exec    7
    #define SYS_fstat   8
    #define SYS_chdir   9
    #define SYS_dup    10
    #define SYS_getpid 11
    #define SYS_sbrk   12
    #define SYS_sleep  13
    #define SYS_uptime 14
    #define SYS_open   15
    #define SYS_write  16
    #define SYS_mknod  17
    #define SYS_unlink 18
    #define SYS_link   19
    #define SYS_mkdir  20
    #define SYS_close  21

     

    : `fork`의 시스템 콜은 `sys_fork`로 매핑되어 있다.

    // sysproc.c
    int sys_fork(void)
    {
      return fork();
    }

     

    : 아래의 코드는 커널 레벨의 `fork` 함수다. 이 함수의 세 번째 문장의 `struct proc *curproc = myproc()`를 보자. 유저 레벨에서 `fork` 함수를 호출할 하면 `sys_fork` 함수가 호출된다. 그러면, 유저 레벨에서 커널 레벨로 오게된다. 이 때, 프로세스는 바꼈을까? 즉, `컨택스트 스위칭`이 발생했을까? 이게 무슨 말이냐면, 유저 레벨에서 `fork` 함수를 호출했을 때와 커널 레벨에서 `fork` 함수를 호출했을 때, 동일한 프로세스이냐를 묻는 것이다. 일단, 무조건 동일해야 한다. 왜? `fork`는 부모 프로세스를 복제해서 만들어지기 때문에, 유저 레벨에서 `fork` 함수를 호출했을 때의 프로세스가 커널 레벨 `fork` 함수까지 동일함을 보장해야 한다.

     

    : 시스템 콜은 `트랩`으로 설정되기에 중간에 다른 인터럽트들이 껴들 수 있다. 그래도, 크게 문제는 없을 것으로 보인다. `allocproc` 함수에서 좀 위험한데, 락이 이미 걸려있는 것 같고, 나머지는 괜찮을 것 같다. 

     

    : 그런데, 커널 레벨 `fork` 함수에는 락 코드가 하나도 없다. 이래도 되는것인가?

    // proc.c
    // Create a new process copying p as the parent.
    // Sets up stack to return as if from system call.
    // Caller must set state of returned proc to RUNNABLE.
    int fork(void)
    {
      int i, pid;
      struct proc *np;
      struct proc *curproc = myproc();
    
      // Allocate process.
      if((np = allocproc()) == 0){
        return -1;
      }
    
      // Copy process state from proc.
      if((np->pgdir = copyuvm(curproc->pgdir, curproc->sz)) == 0){
        kfree(np->kstack);
        np->kstack = 0;
        np->state = UNUSED;
        return -1;
      }
      np->sz = curproc->sz;
      np->parent = curproc;
      *np->tf = *curproc->tf;
    
      // Clear %eax so that fork returns 0 in the child.
      np->tf->eax = 0;
    
      for(i = 0; i < NOFILE; i++)
        if(curproc->ofile[i])
          np->ofile[i] = filedup(curproc->ofile[i]);
      np->cwd = idup(curproc->cwd);
    
      safestrcpy(np->name, curproc->name, sizeof(curproc->name));
    
      pid = np->pid;
    
      acquire(&ptable.lock);
    
      np->state = RUNNABLE;
    
      release(&ptable.lock);
    
      return pid;
    }

    : `np`는 자식 프로세스를 의미한다. `allocproc` 함수를 통해 프로세스가 가져야 할 필수적인 정보들이 채워진 새로운 프로세스를 할당받게 된다. 그리고 `copyuvm` 함수를 통해서 부모 프로세스와 동일한 페이지 테이블 복사본을 할당받는다. 이 때, 당연히 부모 프로세스의 페이지 테이블 주소를 주는것이 아닌, 데이터를 복사한 새로운 페이지 테이블을 준다.

     

    : `copyuvm` 함수에는 단순히 `페이지 테이블을 복사` 라는 내용보다 더 중요한 의미가 있다. 먼저, 반환값을 보니 `페이지 디렉토리 테이블`을 반환한다는 것을 알 수 있다. 그리고, 제일 처음 만나는 문장부터 `xv6` 프로세스 메모리 구조를 엿볼 수 있는 코드다. 커널 레벨 `fork` 함수는 커널 레벨에서만 실행하는 코드가 아니다. 유저 레벨 `fork`가 호출되었을 때, 그에 대응하는 시스템 콜 함수이기도 하다. 즉, 새로운 유저 프로세스를 생성하는 코드이기도 한다. 그런데, 새로운 유저 프로세스를 만드는데, 커널 영역에 대한 페이지 테이블을 하나 만들고 있다(`if((d = setupkvm()) == 0)`). 이유가 뭘까? `xv6`의 모든 프로세스는 4GB 영역에 대한 페이지 테이블을 별도로 가지고 있다. 그런데, 이 페이지 테이블에서 0B ~ 2GB 까지는 유저 레벨에 매핑, 2GB ~ 4GB는 커널에 매핑되어 있다.

     

    : 이 말은, `xv6`의 프로세스는 유저 레벨과 커널 레벨에 대한 별도의 페이지 테이블을 갖고 있지 않다는 말이다. `xv6`의 모든 프로세스는 유저 모드에 있을 때 자신의 페이지 테이블 `0B ~ 2GB`를 사용하고, 커널 모드에 있을 때는 `2GB ~ 4GB`를 사용하는 것이다.  그래서, 유저 프로세스를 생성하더라도 커널 영역이 매핑되어 있는 페이지 테이블을 할당받는 것이다.

     

    : 이런 구조는 유절 레벨에서 코드 레벨 코드에 접근하기가 너무 쉽지 않나? 예를 들어, 유저 레벨에서 `*0xF000_0000 = 34`와 같은 짓을 하면 되지 않나? 불가능하다. 페이징이 활성화되면, `*0xF000_0000 = 34` 코드에서 `0xF000_0000`은 가상 주소가 된다. 그런데, 이 시점에 `CR3`레지스터에 로딩되어 있는 페이지는 현재 동작중인 프로세스의 페이지 테이블일 것이다. `setupkvm` 함수를 통해서 만들어진 페이지 테이블의   [0x8000_0000:0xFFFF_FFFF] 범위는 `유저 레벨`에서 접근할 수 없는 플래그가 설정된다. 즉, 유저 레벨에 진입하는 순간 코드 레벨이 CPL이 3이 되는데, `*0xF000_0000 = 34` 과 같이 링 레벨이 0인 위치에 접근할 경우 #GP가 발생할 것이다. 

     

    : 그러면, 임의의 커널 영역을 조작하기는 힘드니, 모든 프로세스는 하나의 페이지 테이블을를 가지고 있으니, 그 페이지 테이블을 조작하면 되지 않을까? `fork` 함수를 보면 알겠지만, 프로세스에게 할당되는 페이지 테이블을 그래서 커널 영역에서 만든다. 페이지 테이블의 주소가 커널 영역이니 유저 프로그램은 여기에도 접근을 할 수가 없다.

    // Given a parent process's page table, create a copy
    // of it for a child.
    pde_t* copyuvm(pde_t *pgdir, uint sz)
    {
      pde_t *d;
      pte_t *pte;
      uint pa, i, flags;
      char *mem;
    
      if((d = setupkvm()) == 0)
        return 0;
      for(i = 0; i < sz; i += PGSIZE){
        if((pte = walkpgdir(pgdir, (void *) i, 0)) == 0)
          panic("copyuvm: pte should exist");
        if(!(*pte & PTE_P))
          panic("copyuvm: page not present");
        pa = PTE_ADDR(*pte);      // PTE_ADDR(pte)   ((uint)(pte) & ~0xFFF)
        flags = PTE_FLAGS(*pte);  // PTE_FLAGS(pte)  ((uint)(pte) &  0xFFF)
        if((mem = kalloc()) == 0)
          goto bad;
        memmove(mem, (char*)P2V(pa), PGSIZE);
        if(mappages(d, (void*)i, PGSIZE, V2P(mem), flags) < 0) {
          kfree(mem);
          goto bad;
        }
      }
      return d;
    
    bad:
      freevm(d);
      return 0;
    }

    : 루프가 어떻게 순환하는지 알아보자. `fork` 함수가 호출됬다는 것은 메모리를 완전히 동일하게 복사하는 것을 의미한다. 즉, 0부터 시작해서 현재 부모 프로세스가 할당받은 영역까지 전부 복사해야 한다. 그래서 초기값 조건이 `i = 0`이 된다. 그리고,  페이지 단위로 복사가 이루어 질 것이기 때문에, 증감 조건은 `i += PGSIZE`이 될 것이다. 루프가 순환되는 조건은 더 간단하다. 0 부터 부모 프로세스의 마지막 주소` 까지 순환하면 된다(`i < sz`).

     

    : 내부에 PTE 관련해서 2가지 조건문이 보인다. 일단, 전제를 알고 가야한다. `copyuvm` 함수는 전달받은 페이지 테이블을 `sz`만큼 새로운 페이지 테이블에 복사한다. 여기서 복사의 시작 지점은 0 번지로 가정한다. 왜 가정할 수 있을까? `sz`가 유저 영역에 대한 사이즈만 관리하기 때문이다. 커널에서 새로운 페이지 테이블 엔트리가 추가되어도 `sz`가 증가하지는 않는다. 중요한 건 `sz`만큼 복사 한다는 것이다. 즉, `sz` 만큼을 복사한다는 것은 `sz` 안에 영역은 전부 유효해야 한다는 것이다. 예를 들어, 0x20000 만큼 복사한다고 치자. 그런데, 부모 프로세스의 0x8000에서 PTE가 유효하지 않다고 한다. 부모 프로세스는 0x20000 만큼을 할당받았기 때문에, 반드시 0x1F000 까지는 PTE가 유효해야한다. 2개의 조건문은 모두 부모 프로세스의 `sz` 범위안에 PTE는 반드시 존재해야하고 유효해야 한다는 것을 전제한다.

    0" if((pte = walkpgdir(pgdir, (void *) i, 0)) == 0) : PTE 자체가 생성되어 있지 않을 경우
    1" if(!(*pte & PTE_P)) : PTE는 생성되어 있지만, `PRESETN` 비트가 0 인 경우. 즉, 디스크로 `스왑-아웃` 된 경우. 그런데, `xv6`에서는 이런 경우는 없을 것이다. 왜냐면, 오리지널 `xv6`는 `스왑핑` 및 `디맨딩-페이지`가 구현되어 있지 않기 때문이다.

    : 그리고 나서, `PTE_ADDR(*pte)`를 통해서 페이지 오프셋을 제거하고, 페이지 번호만 추출해낸다. 사실, 이건 페이지 단위 내림 정렬과 같다. 왜 이걸 하는걸까? `xv6`의 모든 메모리 관리는 페이지 단위로 할당, 정렬, 복사, 해제 되기 때문이다. 

     

    : 그리고 나서 `(mem = kalloc())` 자식 프로세스에게 데이터를 복사해줘야 하므로, 페이지 단위의 새로운 주소를 할당받는다. `memmove(mem, (char*)P2V(pa), PGSIZE)`을 통해서 기존 부모프로세스의 `pa`에서 페이지 크기만큼 자식 프로세스의 `mem`으로 복사한다. 그런데, 왜 `P2V(pa)` 와 같이 가상 주소로 변환할까? 32비트 x86에서 페이지 단위를 4KB로 설정할 경우, PTE의 상위 20비트 페이지 번호는 `물리 주소`를 할당해야 한다. 이 물리 주소를 커널에서 그대로 사용할 경우, #PF가 발생할 것이다. 그래서 가상 주소로 변환해줘야 한다.

     

    : 이제 최종적으로 물리 주소를 가상 주소로 매핑시켜주는 `mappages(d, (void*)i, PGSIZE, V2P(mem), flags)` 함수를 호출함으로써, 부모 프로세스에서 복사해온 데이터를 자식 프로세서에서 가상 주소에 매핑한다.

     

    : 그런데, 의문있다. 왜 `copyuvm` 함수에서는 `kalloc` 함수를 호출할까? 유저 프로세스에서 `fork`함수를 호출했는데, 커널 영역의 메모리를 할당해서 자식 프로세스에게 복사해주고 있다. 유저 프로세스는 유저 영역의 동적 메모리를 사용하면 안되나? `xv6`에서 커널 영역에서 할당한 가상 주소 영역은 0x8000_0000 이상에만 접근이 가능하다. 이 말은, 프로세스는 가상 주소 `0 ~ 0x8000_0000` 은 완전 자유롭게 가능하다는 걸로 해석이 된다. 언젠가 커널에 메모리가 부족할 경우, 유저 메모리를 사용해야 될 텐데, 그래서 `malloc`, `free` 같은 함수들은 `유저 라이브러리`라고 하는건가?

     

    : `copyuvm` 함수가 끝나면, 모든 메모리가 복사되었을 것이다. 그렇면, 부모 프로세스와 자식 프로세스의 유저 영역 사이즈가 동일해졌을 것이다(`np->sz = curproc->sz`). 그런 다음, 부모 프로세세의 `트랩 프레임`을 자식 프로세스에게 복사한다(`*np->tf = *curproc->tf`). 이 코드의 의미는 뭘까? 트랩 프레임에 무엇이 들어있길래 저것도 복사하는 것일까? 현재, 부모 프로세스는 유저 레벨에서 `fork` 함수를 호출하면서, 커널 레벨의 `fork 시스템 콜` 실행 중이다. 이 시스템 콜 시점에 `권한 변경`이 발생하면서, 부모 프로세스의 여러 가지 하드웨어 자료 구조들이 스택에 저장된다. 이 트랩 프레임에는 `EIP`가 들어있다. 즉, 복귀 지점이 들어있다. `fork` 함수를 호출한 후에는 자식 프로세스도 부모 프로세스와 동일한 지점에서 실행을 이어나가야 한다. 이 때, `EIP` 뿐만이 아니다. 우리가 `trapret` 프로시저를 통해서 시스템 콜을 마무리하고 유저 모드로 돌아가게 되는데, 이 때, 여러 가지 하드웨어 자료구조들이 복구된다. 이 자료 구조들이 모두 부모 프로세스(initproc)에 들어있다. 

     

    : 그리고 유저 레벨의 `fork` 함수는 반환값에 따라 부모 프로세스와 자식 프로세스를 구분한다. 자식 프로세스는 `fork` 함수에서 0을을 반환한다(`np->tf->eax = 0`).

     

    : 프로세스의 파일 복사는 `PIPE` 복사와 같다. 이 부분은 설명이 필요하다. 

     

    : 마지막으로, 새로운 프로세스의 상태를 `RUNNABLE`로 변경한다. 이 때, `스핀-락`으로 잠금을 한 것에 주목해야 한다. `ptable.lock`을 잠그기 때문에, 모든 프로세서들은 프로세스 테이블에 접근하지 못한다. 그런데, 만약 락을 풀자마자 아직 `fork`를 리턴하지도 못했는데, 선점당하면 어떻게 될까? 이 시점에는 이미 새로운 프로세스가 `RUNNABLE` 상태가 되었기 때문에 상관없다. 

     

    : 그런데, 만약 상태를 `RUNNABLE`로 변경할 때, 락을 잠금지 않으면 어떻게 될까? 나는 그래도 상관이 없을 것으로 보인다. 왜냐면, `allocproc` 함수에서 새로운 프로세스를 할당받는 시점에 프로세스의 상태는 `EMBRYO`가 된다. 이 상태에서 바뀔 수 있는 방향이 `RUNNABLE` 밖에 없다. 즉, `EMBRYO` 상태에 있는 프로세스가 바뀔 수 있는 상태가 `RUNNABLE` 밖에 없다. 그래서 저기에 락을 잠그지 않아도 안전할 것이라 생각이든다.

     

    : 새로운 프로세스가 `RUNNABLE` 상태가 되면, 이제 스케줄러에게 선택되기만을 기다리면 된다.

    '프로젝트 > 운영체제 만들기' 카테고리의 다른 글

    [xv6] mkfs  (0) 2023.07.30
    [xv6] Boot-loader  (0) 2023.07.29
    [xv6] - sbrk  (0) 2023.07.26
    [xv6] Log  (0) 2023.07.25
    [xv6] Buffer Cache  (0) 2023.07.24
Designed by Tistory.