오늘 하루에 집중하자
  • [CS] PintOS Project 2 - User Program(1) - Introduction
    2022년 12월 24일 19시 12분 44초에 업로드 된 글입니다.
    작성자: nickhealthy

    PintOS에 이미 user program을 load하고 실행할 수 있는 베이스 코드를 제공하고 있지만, I/O나 interactivity는 제공하지 않는다. 이 프로젝트에선  시스템 콜을 통해 프로그램이 OS와 interact(상호작용) 하도록 만드는 것이 목표이다.

    이번 과제에서 작업할 주요 디렉토리는 userprog 이지만, 거의 모든 PintOS의 내부 파트마다 상호작용할 것이다.(다른 디렉토리도 참고할 것이란 것) 아래에 관련있는 파트를 설명한다.

     

    프로젝트 1에서 제출한 과제 위에서 프로젝트 2를 빌드해야 한다. 프로젝트 1의 코드는 프로젝트 2의 코드에 영향을 미치진 않지만, 프로젝트 1은 증분 프로젝트이므로 여전히 테스트 사례를 통과해야 합니다.

     

    확장 과제는 옵션이며, 해당 과제는 뼈대를 포함한 모든 코드는 직접 구현해야 한다. 테스트 케이스 외에는 구현된 것이 없다. 추가 요구 사항을 제출하고 테스트하려면 다음을 편집해야 한다. userprog/Make.vars

     

    Background


    지금까지 PintOS에서 실행한 모든 코드는(Project 1) 커널의 일부였다. 즉, Project 1의 모든  테스트 코드는 커널의 파트로 실행되어 시스템의 권한이 필요한 모든 부분에서 full access(접근)가 가능했다. 하지만 OS에서(kernel이 아닌) user program을 시랳ㅇ한다면, 이는 더이상 해당되는 이야기가 아니다. user program이 시스템 접근을 위해서는 반드시 시스템 콜을 사용해야 한다. Project 2에선 이 내용을 다루게 된다.

     

    PintOS는 한번에 둘 이상의 프로세스를 실행할 수 있다. PintOS에서 각 process마다 하나의 thread를 가지고 있다.(멀티스레드를 가진 프로세스는 지원하지 않는다.) user program은 전체 시스템을(machine) 가지고 있다는 착각을 한다. 이는 multiple processes을 load하고 run할 때, 다음과 같은 영역들을 관리해야 하는 것을 의미한다.(memory, scheduling, and other state correctly to maintain this illusion)

     

    이전 프로젝트에서 우리는 테스트 코드를 커널에 직접 컴파일했기 때문에, 커널 내에 특정 함수 인터페이스가 필요했다. 하지만 Project 2 부터는 user program을 실행하여 OS를 테스트합니다. 이는 훨씬 더 큰 자유도를 제공한다. user program interface가 여기 설명된 사양을 충족하는지 확인해야 하지만, 사양만 충족된다면 원하는대로 커널 코드를 재구성하거나 다시 작성할 수 있다.

     

    참고 사항

    • ifdef VM 에 블록에 코드를 작성하면 안됨
      • 해당 블록은 project3에서 구현할 가상 메모리 서브시스템을 활성화한 후 포함될 것이다.

     

    Virtual Addresses


    64비트 가상 주소는 다음과 같이 구성됩니다.

    63          48 47            39 38            30 29            21 20         12 11         0
    +-------------+----------------+----------------+----------------+-------------+------------+
    | Sign Extend |    Page-Map    | Page-Directory | Page-directory |  Page-Table |  Physical  |
    |             | Level-4 Offset |    Pointer     |     Offset     |   Offset    |   Offset   |
    +-------------+----------------+----------------+----------------+-------------+------------+
                  |                |                |                |             |            |
                  +------- 9 ------+------- 9 ------+------- 9 ------+----- 9 -----+---- 12 ----+
                                              Virtual Address
    • Header - include/threads/vaddr.h, include/threads/mmu.h
      • 가상 주소로 작업하기 위한 다음 함수 및 매크로를 정의합니다.

     

    함수 및 매크로 구성 요소

    유저 가상메모리 영역

    #define PGSHIFT { /* Omit details */ }
    #define PGBITS { /* Omit details */ }
    • 가상 주소를 나타냄
      • 오프셋 부분의 비트 인덱스(0) 및 비트 수(12).

     

    #define PGMASK { /* Omit details */ }
    • 페이지 오프셋의 비트가 1로 설정되고 나머지는 0(0xfff)으로 설정된 비트 마스크.

     

    #define PGSIZE { /* Omit details */ }
    • 바이트 단위의 페이지 크기(4,096).

     

    #define pg_ofs(va) { /* Omit details */ }
    • 가상 주소 va에서 페이지 오프셋을 추출하여 반환합니다.

     

    #define pg_no(va) { /* Omit details */ }
    • 가상 주소 va에서 페이지 번호를 추출하고 반환합니다.

     

    #define pg_round_down(va) { /* Omit details */ }
    • va가 가리키는 가상 페이지의 시작, 즉 페이지 오프셋이 0으로 설정된 va를 반환합니다.

     

    #define pg_round_up(va) { /* Omit details */ }
    • 가장 가까운 페이지 경계로 반올림된 va를 반환합니다.

     

    PintOS에서 가상 메모리는 두 개의 영역으로 나누어져 있다.

    • user virtual memory
    • kernel virtual memory

     

    커널 가상 메모리 영역

    그들 사이의 경계는 KERN_BASE다음과 같습니다.

    #define KERN_BASE { /* Omit details */ }
    • 커널 가상 메모리의 기본 주소입니다. 기본값은 0x8004000000입니다.
    • 사용자 가상 메모리의 범위는 가상 주소 0에서 까지 KERN_BASE입니다.
    • 커널 가상 메모리는 나머지 가상 주소 공간을 차지합니다.

     

    #define is_user_vaddr(vaddr) { /* Omit details */ }
    #define is_kernel_vaddr(vaddr) { /* Omit details */ }
    • va 각각 사용자 또는 커널 가상 주소이면 true를 반환하고 그렇지 않으면 false를 반환합니다.

     

    가상메모리를 통한 물리적 메모리 접근

    x86-64 아키텍처에서 직접적으로 물리 메모리 주속에 접근할 수 있는 방법은 제공하지 않는다. 이 기능은 종종 운영체제 커널을 이용해야하므로, PintOS는 커널 가상 메모리를 일대일로 물리적 메모리에 매핑하여 문제를 해결한다.

    물리적 주소를 얻는 방법은 KERN_BASE에 물리적 주소를 더하면 물리적 메모리 주소에 엑세스할 수 있는 커널 가상 주소를 얻는다. 예를 들어 가상 메모리 주소의 KERN_BASE + 0x1234를 하면 물리 메모리 주소인 0x1234에 접근이 가능하다. 반대로 KERN_BASE 커널 가상 주소에서 빼면 물리적 메모리 주소를 얻을 수 있다.

     

    Header - include/threads/vaddr.h

    다음과 같은 변환을 수행하는 한 쌍의 함수를 제공합니다.

    #define ptov(paddr) { /* Omit details */ }
    • 물리적 주소 pa에 해당하는 커널 가상 주소를 반환합니다. 이 주소는 0과 물리적 메모리의 바이트 수 사이여야 합니다.
    #define vtop(vaddr) { /* Omit details */ }
    • 커널 가상 주소여야 하는 va에 해당하는 물리적 주소를 반환합니다.

     

    Header - include/threads/mmu.h

    페이지 테이블에 대한 작업을 제공합니다.

    #define is_user_pte(pte) { /* Omit details */ }
    #define is_kern_pte(pte) { /* Omit details */ }
    • 페이지 테이블 항목(Page Table Entry, PTE)이 각각 사용자 또는 커널 소유인지 쿼리합니다.
    #define is_writable(pte) { /* Omit details */ }
    • 페이지 테이블 항목(Page Table Entry, PTE)이 가리키는 가상 주소가 쓰기 가능한지 여부를 쿼리합니다.
    typedef bool pte_for_each_func (uint64_t *pte, void *va, void *aux);
    bool pml4_for_each (uint64_t *pml4, pte_for_each_func *func, void *aux);
    • PML4 아래의 각 유효한 항목에 대해 보조 값(AUX)과 함께 FUNC를 적용합니다.
    • VA는 각 가상 주소 항목의(Entry)를 나타냅니다.
    • pte_for_each_func가 false를 반환하면 반복을 중지하고 false를 반환합니다.

     

    Source Files


    프로그램의 개요를 얻는 가장 쉬운 방법은 작업할 부분들을 살펴보는 것이다.

    우리가 작업할 디렉토리는 userprog/ 이다.

     

    • process.c, procses.h
      • ELF binaries를 load하고, process를 시작한다.
    💡

    ELF binaries

    리눅스에서 실행 가능(Executable)하고 링크 가능(Linkable)한 File의 Format을 ELF(Executable and Linkable Format)라고 한다.

    즉, 실행 가능한 바이너리 또는 오브젝트 파일 등의 형식을 규정한 것이다.

    • syscall.c, syscall.h (프로젝트에서 해당 파일 수정)
      • user process가 kernel 기능에 access 하고자 할 때마다, 시스템 콜을 호출한다.
      • 이 파일들은 skeleton system call handler로, 현재는 메시지를 print하고 user process를 종료한다.
      • Project 2에서 시스템 콜이 필요로 하는 모든 코드를 추가해야 한다.
    • syscall-entry.S (프로젝트를 수행하기 위해 해당 부분을 이해할 필요 X)
      • 작은 규모의 어셈블리 코드 - 시스템 콜을 처리하는 부분(syscall handler 를 알아서 실행시키는 어셈블리 코드)
    • exception.c, exception.h (프로젝트에서 해당 파일 수정)
      • project2의 일부 솔루션에서는 page_fault() 를 수정할 것이다.
      • user process가 privileged(권한이 있거나) prohibited(금지된) 작업을 수행하면 커널에서 'Exception' 또는 'fault'로 커널에 트랩(인터럽트)된다.
      • 이 파일들은 예외 처리를 하는 파일이다.
      • 현재 모든 exception은 단순히 메시지를 띄우고, 프로세스를 종료시킨다.
    • gdt.c, gdt.h (어떤 프로젝트에서도 건드리지 않음)
      • x86-64는 segmented architecture. GDT(Global Descriptor Table)은 segment를 describe(기술하는) 테이블이며, 이 파일들은 GDT를 설정하는 파일이다. 어떤 프로젝트에서도 여기 파일 코드는 건드리지 않으나, GDT가 어떻게 동작하는지 이해하고 싶다면 읽어보면 좋다.
      • *참고: segmentation 기법 - 프로그램을 논리적 의미 단위인 세그먼트의 집합으로 구성하는 방식
    • tss.c, tss.h (어떤 프로젝트에서도 건드리지 않음)
      • Task-State Segment(TSS)는 x86 아키텍처 task를 switching 하는데 사용한다. 하지만 x86-64에서 task switching이 쓰이지 않는다. 그럼에도 불구하고 TSS는 ring-switching 과정에서 여전히 stack pointer를 찾기 위해 존재한다.
      • 어떤 프로젝트에서도 여기 파일 코드는 건드리지 않는다.
      • 요약하면 유저 프로세스가 인터럽트 핸들러를 들어갈 때 그 하드웨어는 커널의 스택 포인터를 찾기 위해 tss에 문의한다.

     

    Using the File System(파일 시스템 사용하기)


    User program이 파일 시스템에서 load되고, 파일 시스템을 다뤄야 하는 시스템 콜이 많기 때문에, 이번 프로젝트에서는 파일 시스템 코드들을 인터페이스화 하는 것이 필요하다.

    하지만 이 프로젝트에서의 초점은 파일 시스템이 아니므로, PintOS에서는 간단하지만 완벽한 파일 시스템을 filesys 디렉토리에서 제공한다. filesys.hfile.h 을 살펴봄으로써, 인터페이스를 통해 어떻게 파일 시스템을 사용하는지 이해하고, 특히 제한사항을 잘 이해할 수 있다.(루틴을 지금 적절히 사용해보면 Project 4에서 편하게 만들어줄 것임)

    • 이 프로젝트에서 파일 시스템 코드를 수정할 필요는 없다. 파일 시스템에 초점을 두는 것이 아닌 user program을 OS위에서 돌리기 위한 해당 프로젝트에 집중해라.
    • File system routine을 적절하게 사용하면 project4 를 수행하는데 훨씬 수월할 것이다. 그때까지는 다음과 같은 제약 사항이 따른다.
      • 내부 동기화가 없습니다. 동시 액세스는 서로를 간섭합니다. 동기화를 사용하여, 한 번에 하나의 프로세스만 파일 시스템 코드를 실행하도록 해야합니다.
      • 파일 크기는 생성 시 고정됩니다. Root 디렉토리는 파일로 표시되므로, 생성할 수 있는 파일 수도 제한됩니다.
      • 파일 데이터는 single extent로 할당됩니다. 즉, 단일 파일의 데이터는 디스크의 섹터의 연속적인 범위를 차지해야 합니다. External fragmentation이 심각한 문제가 될 수 있습니다.
      • 하위 디렉토리를 생성할 수 없습니다. (No subdirectories)
      • 파일 이름은 14 character로 제한됩니다.
      • 작동 중 시스템 충돌이 나면, 자동으로 복구 할 수 없는 방식으로 디스크를 손상시킬 수 있습니다. 파일 시스템을 복구 할 수 있는 툴이 없습니다.

    중요한 한 가지 사실이 있다. Unix와 같은 시스템에서는 filesys_remove() 가 구현됐는데 파일이 삭제된 상태에서 파일이 열린다면 그 블럭은 deallocated되지 않고 마지막 쓰레드가 닫힐 때까지 어떤 쓰레드도 여기에 접근할 수 있다.

     

    파일에 대한 standard Unix semantics을 구현하면 파일이 삭제됐는데도 그 파일에 대한 file descriptor를 갖고 있는 프로세스면 그 디스크립터를 계속 사용할 수 있다. 즉, 그 파일로부터 읽고 쓰기를 계속할 수 있다. 그 파일은 이름도 없고 프로세스들은 그 파일을 열 수도 없지만, 그 파일을 가리키는 모든 파일 디스크립터가 닫히거나 머신 자체가 꺼지기 전까지는 그 파일은 계속 존재한다.

     

    커널 이미지에 모든 테스트 프로그램이 이미 있었던 Project 1과는 달리 (유저 영역에서 실행되는) 테스트 프로그램을 PintOS 가상 머신에 넣어야 한다. 테스팅 스크립트(make check 같은 거)가 자동적으로 처리해주긴하지만 알아두면 개별 테스트 케이스를 돌리는 데 많은 도움 될 것이다.

     

    • PintOS 가상 머신에 넣기 위해 파일 시스템 파티션과 simulated된 디스크를 만들 수 있어야 한다.
    • pintos-mkdisk 프로그램이 이 기능을 해준다.
    • pintos-mkdisk filesys.dsk 2 명령은 PintOS 파일 시스템 파티션을 포함한(2MB) filesys.dsk 이름의 simulated disk를 만든다.
      • --fs-disk filesys.dsk 명령을 통해 디스크를 specify 해준다.
      • ----fs-disk가 simulate된 커널이 아니라 PintOS 스크립트를 위한 것이기 때문에 필요하다.
      • 그리고 나서 커널 명령어 라인에 -f -q 을 넣음으로써 파일 시스템 파티션을 포멧시켜준다.
      • -f는 파일 시스템을 포멧시켜주고
      • -q는 포맷되자마자 PintOS를 끝나게 해준다.
      • -p("put") 와  -g("get") 옵션은 파일을 simulated된 파일 시스템으로 복사시킨다.
      • 새로운 이름으로 복사시키고 싶으면 pintos -p file:newname -- -q
      • VM에서부터 복사시키고 싶으면 명령어 똑같이 쓰고 -g-p로 바꿔준다.

    테스트 케이스를 이미 만들었고 현재 디렉토리 위치가 userprog/build 라는 가정하에서 파일 시스템 파티션으로 디스크를 만들고 파일 시스템을 포맷하고, args-single program 을 새로운 디스크로 복사하고 'onearg'  인자를 주어 실행시키는 명령어는 아래와 같다.

    $ pintos-mkdisk filesys.dsk 10
    $ pintos --fs-disk filesys.dsk -p tests/userprog/args-single:args-single -- -q -f run 'args-single onearg'

     

    위의 명령어를 한번에 실행시키는 명령어는 아래와 같다.(파일 시스템 디스크도 안보이게 함)

    $ pintos --fs-disk=10 -p tests/userprog/args-single:args-single -- -q -f run 'args-single onearg'

     

    How User Programs Work(유저 프로그램이 어떻게 일하는가)


    PinOS는 메모리 사이즈가 알맞게 들어맞고, 이번 프로젝트에서 구현할 시스템 콜을 이용하는 프로그램이라면, 일반적인 C 프로그램을 실행시킬 수 있다. 하지만 해당되는 시스템 콜이 없는 경우에는 PintOS 내에서 작동하지 않을 것이다. 예를 들어 malloc() 동작하지 않는데, memory allocation을 허용할 수 있는 시스템 콜이 없기 때문이다.

    또한 PintOS는 floating-point operations을 사용하는 프로그램도 실행할 수 없는데, thread를 switch할 때(context switch) 커널이 프로세서의 floating-point unit을 저장하고 복구할 수 없기 때문이다.

     

    PintOS는 userprog/process.c 에서 제공하는 'loader'를 사용해서 ELF 실행 파일을 load할 수 있다. ELF는 Linux, Solaris 및 기타 운영체제에서 object 파일, 공유 라이브러리 및 실행 파일에 사용하는 파일 형식이다.

     

    실제로 x86-64 ELF 실행 파일을 출력하는 모든 컴파일러 및 링커를 사용하여 PintOS용 프로그램을 생성할 수 있다.(우리는 제대로 작동하는 컴파일러와 링커를 제공했다.) Simulated file system에 테스트 프로그램을 복사하기 전까지는 PintOS가 유용한 작업을 수행할 수 없다는 점에 주의해라. 다양한 프로그램을 파일 시스템에 복사하기 전까지는 흥미로운 작업을 수행할 수 없다. 

     

    Virtual Memory Layout


    PintOS에서 가상메모리는 두 영역으로 분할된다.

    • User Virtual Memory
    • Kernel Virtual Memory

    User Virtual Memory 가상 주소 범위가 0~KERN_BASE까지다. include/threads/vaddr.h에 정의되어 있으며, KERN_BASE의 default 값은 0x8004000000이다. Kernel Virtual Memory는 여기서부터 시작해서 나머지 가상 주소 영역을 점유하고 있다.

     

    유저 가상 메모리는 각 프로세스마다 존재한다. 커널이 다른 프로세스로 전환할 때(context switch), User Virtual Memory 영역 역시 전환하게 되는데 processor's page directory base register를 바꿈으로서 전환 작업을 진행하게 된다. struct thread는 process의 페이지 테이블(page table)에 대한 포인터가 포함되어 있다.

     

    커널 가상 메모리는 전역(global)에 해당한다. 어떤 user process나 kernel thread가 실행 중인지 관계없이 항상 동일한 방식으로 매핑된다. PintOS에서 Kernel Virtual Memory는 physical memory와 일대일로 매핑되는데, 이는 KERN_BASE에서 시작한다. 즉, 가상 주소 KERN_BASE 는 physical memory 0에 접근하고, KERN_BASE + 0x1234(가상주소)는 physical memory 0x1234에 접근한다.

     

    user program은 오직 own user virtual-memory에만 접근 가능하다. 만약 kernel virtual memory에 접근을 시도한다면 page fault가 발생하는데, 이것은 userprog/exception.cpage_fault() 함수에 의해 처리된다. 그리고 해당 user program은(프로세스) 종료하게 된다.

    반면, kernel thread는 Kernel Virtual Memory와(만약 사용자 프로세스가 동작 중이면) 실행중인 process의 User Virtual Memory에도 접근 가능하다. 하지만 커널 안에서 조차도, 매핑되지 않은 user virtual address에 접근을 시도하게 된다면 page fault가 발생한다.

     

    Typical Memory Layout


    각각의 process는 자신의 user virtual memory를 자유롭게 배치할 수 있다. 실제 user 가상 메모리는 다음과 같이 배치된다.

     

    USER_STACK +----------------------------------+
               |             user stack           |
               |                 |                |
               |                 |                |
               |                 V                |
               |           grows downward         |
               |                                  |
               |                                  |
               |                                  |
               |                                  |
               |           grows upward           |
               |                 ^                |
               |                 |                |
               |                 |                |
               +----------------------------------+
               | uninitialized data segment (BSS) |
               +----------------------------------+
               |     initialized data segment     |
               +----------------------------------+
               |            code segment          |
     0x400000  +----------------------------------+
               |                                  |
               |                                  |
               |                                  |
               |                                  |
               |                                  |
           0   +----------------------------------+

     

    Project 2에선 user stack의 사이즈는 고정된 사이즈로 수행하지만, project 3에서는 확장을 허용한다. 기존에는 시스템 콜을 통해 초기화되지 않은 data segment의 크기를 조정할 수 있지만, 이를 직접 구현할 필요는 없다.

    • code segment
      • 해당 영역의 user virtual address는 0x400000부터 시작하고, 이 영역의 주소 공간은 대략 128MB이다.
      • 위에서 언급한 128MB의 값은 ubuntu에서 일반적인 값이였으며, 별다른 의미는 없다.
    • Linker는 user program의 메모리 레이아웃을 설정한다. linker script 는 직접적으로 다양한 program segment의 이름과 위치를 알려준다.
      • linker script에 대해 보다 자세히 알고 싶다면 linker manual 참고(info ld)
      • 특정 실행 파일의 레이아웃을 보려면 -p 옵션을 사용하여 objdump를 실행해라
      • *참고: objdump - ELF 파일을 어셈블리어로 보여주는 역어셈블러로 사용될 수 있다.

     

    Accessing User Memory(유저 메모리 접근)


    시스템 콜의 일부분으로, 커널은 종종 user program이 제공해준 포인터를 통해서 가상 메모리(User Virtual Memory)에 접근 해야한다. 커널은 이 작업을 조심히 수행해야 하는데, 만약 null pointer, 매핑되지 않은 가상메모리 포인터 또는 커널 virtual address space을(KERN_BASE 부분)을 전달할 수 있기 때문인다. 이러한 유효하지 않은 포인터를 전달받게 되면 커널 또는 다른 실행 중인 프로세스에 해를 끼치지 않고 거부되어야 한다. 또한 문제가 되는 프로세스를 종료하고 리소스를 확보해야한다.

     

    이를 올바르게 수행하는 방법으로는 2가지 이상의 방법이 있다.

    첫 번째 방법사용자가 제공한 포인터의 유효성을 확인한 다음, 이를 역참조(dereference) 하는 것이다. 이 방법을 선택한다면 userprog/pagedir.c 및 thread/vaddr.h 의 함수들을 살펴보면 좋다. 이는 유저 메모리 엑세스를 처리하는 가장 간단한 방법이다.

     

    두 번째 방법user pointer가 KERN_BASE 아래를 가리키는 지 확인한 후, 역참조(dereference) 하는 것이다. 유효하지 않은 user pointer는 userprog/exception.c 에서 page_fault()의 함수를 수정함으로써 'page fault'를 발생시킨다. 이 테크닉은 일반적으로 processor's MMU(하드웨어)를 활용하기 때문에 더 빠르고, 실제 커널(including Linux)에서 사용되는 경향이 있다.

     

    어느 경우든지 리소스를 'leak(유실)'하지 않도록 해야한다. 예를 들어 시스템 콜이 lock을 획득하거나 malloc()을 통해 메모리를 할당했다고 가정해보자. 나중에 잘못된 user pointer가 발견되는 경우, lock을 해제하거나 메모리 페이지를 free해야 할 것이다. 만약 user pointer를 역참조하기 전에 확인하도록 선택한다면, 이것은 간단하다. 하지만 유효하지 않은 포인터가 page fault를 발생시킨 이후라면, 메모리 엑세스에서 에러코드를 반환할 방법이 없기 때문에 처리하기 어려워진다. 

     

     

    댓글