Operating Systems: Three Easy Pieces는 마지막에 실습 과제를 포함하고 있다. 예전에 책을 보기는 했지만 실습을 스킵했더니 놓치는 게 많았던 거 같아서 하나씩 진행해보려고 한다.

과제는 MIT에서 교육용으로 만든 RISC-V OS Xv6를 기반으로 진행된다.

H3 과제 링크

첫번째 태스크

H3의 첫번째 요구사항은 Null pointer를 dereference했을 때 예외가 발생하도록 하는 것이다.

여러 가지 방법이 있을 수 있지만 페이지 테이블에서 첫번째 테이블 엔트리(virtual address 0을 포함한 엔트리)를 할당하지 않는 식으로 구현하였다.

이를 구현하기 위해 Xv6가 페이지 테이블을 초기화하고 할당하는 과정을 살펴보았다.

int
exec(char *path, char **argv)
{
  char *s, *last;
  int i, off;
  uint64 argc, sz = 0, sp, ustack[MAXARG], stackbase;
...

sz 값은 이후에 유저 프로세스의 메모리 할당을 수행하는 uvmalloc()oldsz 인자로 넘어간다.

구체적으로 uvmalloc()이 초기에 어떤 식으로 호출되는지 살펴보자.

// 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;
}

ELF의 Program Header 엔트리를 순회하면서 ELF_PROG_LOAD 타입의 엔트리를 찾는다. (메모리에 로드해야 하는 세그먼트)

uvmalloc()을 호출할 때 szoldsz, ph.vaddr + ph.memsznewsz로 넘긴다.

sz의 초기화 값을 한 페이지 사이즈(0x1000)만큼만 올리면 될 것 같기도 하다. 하지만 newszoldsz의 차이만큼 메모리를 할당하는 식으로 uvmalloc()이 구현되어 있다. 따라서 첫번째 세그먼트의 vaddr 값도 0x1000만큼 올려줘야 한다.

그렇다면 ELF 프로그램 헤더가 어디서 생성되는지 알아야 한다. 찾아 보니 컴파일 최종 단계인 링크 단계에서 생성된다고 한다.

Xv6에서는 user.ld 파일을 통해 유저 프로그램을 컴파일할 때 링커가 섹션을 배치하는 기준 주소를 지정하고 있다.

OUTPUT_ARCH( "riscv" )

SECTIONS
{
 . = 0x1000; /* 시작 주소를 0x1000으로 지정 */
...

이렇게 하면 프로그램 로드 시 첫번째 세그먼트 주소가 0x1000이 되도록(첫 페이지 주소는 할당하지 않게) 설정된다.

남은 작업은 유저 프로세스의 가상 주소 영역이 주소 0부터 시작된다는 가정하에 작성된 코드를 찾아 수정하는 것이다.

첫번째는 kernel/syscall.cint fetchaddr() 함수이다.

// Fetch the uint64 at addr from the current process.
int
fetchaddr(uint64 addr, uint64 *ip)
{
  struct proc *p = myproc();
  if(addr >= p->sz || addr+sizeof(uint64) > p->sz) // both tests needed, in case of overflow
    return -1;
...

기존에 현재 할당된 메모리 크기(p->sz)를 기준으로 가상 주소 범위를 검사했지만 이제는 할당되지 않는 첫번째 페이지도 유효하지 않게 처리하도록 수정해야 한다.

그 다음으로는 kernel/vm.cvmfault() 함수이다. 여기서도 마찬가지로 va >= p->sz 조건만 검사하고 있지만 0x1000보다 작은 주소가 들어왔는지도 검사해야 한다. (아래와 같은 코드를 고치면 된다.)

uint64
vmfault(pagetable_t pagetable, uint64 va, int read)
{
  uint64 ka;
  struct proc *p = myproc();
  ka = 0;

  if (va >= p->sz)
    return 0;
...

그 외에는 kernel/vm.c에서 유효한 가상 주소 범위를 순회하는 코드들이 있다. 이 부분들은 보통 PTE가 유효하지 않으면 스킵하기 때문에 수정하지 않는다고 문제가 되는 것은 아니지만 불필요한 순회를 줄이면 좋으므로 수정하였다.

uvmfree()(유저 프로세스의 페이지 테이블을 정리하는 함수)의 if 블럭
void
uvmfree(pagetable_t pagetable, uint64 sz)
{
  
- if(sz > 0)
+ if(sz > USERVASTART) // `riscv.h`에 USERVASTART를 0x1000으로 정의하여 사용함
...
}
uvmcopy()(fork()호출 시 부모 프로세스의 페이지 테이블을 자식 프로세스의 페이지 테이블로 복사하는 함수)의 순회 범위
int
uvmcopy(pagetable_t old, pagetable_t new, uint64 sz)
{
  pte_t *pte;
  uint64 pa, va;
  uint flags;

-  for(va = 0; va < sz; va += PGSIZE) {
+  for(va = USERVASTART; va < sz; va += PGSIZE) {
...

// 중도 실패 시 맵핑 해제하는 부분
 err:
-  uvmunmap(new, 0, i / PGSIZE, 1);
+  uvmunmap(new, USERVASTART, i / PGSIZE, 1);
  return -1;
}

두번째 태스크

두번째 요구사항은 가상 주소와 길이를 받아 메모리를 read-only로 만드는 시스템콜(int mprotect(void *addr, int len))과 메모리를 read-write로 변경하는 시스템콜(int munprotect(void *addr, int len))을 구현하는 것이다.

기본 로직은 아래와 같다.

int
set_pages_readonly(uint64 va, uint64 npages) {
  uint64 a, pa0;
  pte_t *pte;
  struct proc *p = myproc();

  if(npages == 0)
    return -1;

  if((va % PGSIZE) != 0)
    return -1;

  if(va < USERVASTART || va >= p->sz || va+sizeof(uint64) > p->sz)
    return -1;

  for(a = va; a < va + npages * PGSIZE; a += PGSIZE) {
    pa0 = walkaddr(p->pagetable, a);
    if(pa0 == 0) {
      if(vmfault(p->pagetable, a, 0) == 0) {
        return -1;
      }
    }
    pte = walk(p->pagetable, a, 0);
    if(pte == 0)
      return -1;
    if((*pte & PTE_V) == 0)
      return -1;
    if((*pte & PTE_U) == 0)
      return -1;
    *pte = (*pte & ~PTE_W) | PTE_R;
  }

  sfence_vma();

  return 0;
}

하나씩 살펴보자.

우선 처음 부분에서 넘어온 가상 주소와 페이지 수가 유효한지 검사한다.

if(npages == 0) // 페이지 수가 0이면 비정상 리턴
  return -1;

if((va % PGSIZE) != 0) // 가상 주소가 페이지 크기의 배수가 아니면 비정상 리턴 (정렬 x인 케이스)
  return -1;

if(va < USERVASTART || va >= p->sz || va+sizeof(uint64) > p->sz) // 가상 주소가 유저 프로세스의 메모리 범위를 벗어나면 비정상 리턴
  return -1;

그 다음으로는 각 가상 주소 페이지 테이블 엔트리를 순회한다.

for(a = va; a < va + npages * PGSIZE; a += PGSIZE) {

먼저 walkaddr()을 통해 맵핑된 물리 페이지 주소를 가져온다.

pa0 = walkaddr(p->pagetable, a);

만약 맵핑된 물리 페이지 주소가 없다면 vmfault()를 통해 혹시 lazy allocation이 필요한 것이라면 수행하고, 그게 아니라면 비정상 리턴한다.

if(vmfault(p->pagetable, a, 0) == 0)
  return -1;

그 다음으로는 페이지 테이블 엔트리를 가져온다. 여기서 페이지 테이블 엔트리가 유효하지 않으면 비정상 리턴한다.

pte = walk(p->pagetable, a, 0);
if(pte == 0)
  return -1;

만약 valid 비트가 0이면 비정상 리턴한다.

if((*pte & PTE_V) == 0)
  return -1;

그 다음으로는 페이지 테이블 엔트리가 유저 페이지 테이블 엔트리인지 체크하고 유저 페이지 테이블 엔트리가 아니면 비정상 리턴한다.

if((*pte & PTE_U) == 0)
  return -1;

그 다음 write 비트를 0으로 설정하고 read 비트를 1로 설정한다.

*pte = (*pte & ~PTE_W) | PTE_R;

마지막으로는 TLB 무효화를 통해 캐시된 가상주소 변환 결과를 무효화한다.

sfence_vma(); // 내부에서 sfence.vma zero, zero 명령어를 실행하여 모든 TLB 엔트리를 무효화

이렇게 하면 주어진 가상 주소 범위를 read-only로 설정할 수 있다.

munprotect()의 로직인 set_pages_readwrite()도 위와 동일하지만 플래그 설정만 write 비트와 read 비트를 전부 1로 설정하면 된다.

*pte = *pte | PTE_W | PTE_R;

이렇게 하면 주어진 가상 주소 범위를 read-write로 설정할 수 있다.