SW 사관학교 정글(Jungle)/운영체제-PintOS

[PintOS Project 2 - User Program] 여러 자료 정리..(kaist 자료 등)

jinsang-2 2024. 10. 7. 17:12

kaist 자료

Overview

1. 시스템 호출 핸들러 테이블 채우기:

  • Pintos의 기본 설정에서는 시스템 호출 핸들러 테이블이 비어있습니다. 시스템 호출은 커널이 사용자 프로그램과 상호작용하는 중요한 수단이므로, 이 테이블을 채워서 필요한 서비스를 사용자에게 제공할 수 있도록 해야 합니다.

2. 추가해야 할 시스템 호출:

  • 프로세스 관련 호출:
    • halt: 이 호출은 시스템을 중지시키고 종료합니다.
    • exit: 프로세스가 종료될 때 호출되며, 이 호출은 자원을 정리하고 프로세스를 종료하는 역할을 합니다.
    • exec: 새로운 프로그램을 실행할 때 사용되며, 주어진 프로그램을 실행하면서 현재 프로세스를 대체하거나 새로운 프로세스를 생성합니다.
    • wait: 부모 프로세스가 자식 프로세스의 종료를 기다리기 위해 사용됩니다.
  • 파일 관련 호출:
    • create: 새로운 파일을 생성합니다.
    • remove: 기존 파일을 삭제합니다.
    • open: 파일을 열어 읽기 또는 쓰기를 할 수 있게 준비합니다.
    • filesize: 파일의 크기를 반환하는 시스템 호출입니다.
    • read: 파일로부터 데이터를 읽어오는 작업을 합니다.
    • write: 파일에 데이터를 쓰는 작업을 수행합니다.
    • seek: 파일의 읽기/쓰기 포인터를 지정한 위치로 이동합니다.
    • tell: 현재 파일 포인터의 위치를 반환합니다.
    • close: 파일을 닫아 시스템 자원을 해제합니다.

3. 수정할 파일:

  • pintos/src/threads/thread.*
    • thread.*: 쓰레드 관련 코드를 정의하는 파일입니다. 시스템 호출이 실행될 때 각 호출이 어떤 쓰레드에서 수행될지 등을 처리해야 하기 때문에 수정이 필요합니다.
  • pintos/src/userprog/syscall.*
    • syscall.*: 시스템 호출 관련 로직을 처리하는 곳입니다. 이 파일에서 실제로 핸들러를 구현하고, 핸들러 테이블에 호출을 등록합니다.
  • pintos/src/userprog/process.*
    • process.*: 프로세스의 생성, 실행, 종료와 같은 관리 기능을 담당하는 파일입니다. 새로운 프로세스를 생성하거나 실행하는 exec, wait, exit 같은 시스템 호출을 구현할 때 수정이 필요합니다.

System call

  • 운영체제가 제공하는 서비스를 위한 프로그래밍 인터페이스.
  • 사용자 모드 프로그램이 커널 기능을 사용할 수 있도록 허용.
  • 시스템 호출은 커널 모드에서 실행되고 사용자 모드로 복귀.
  • 시스템 호출의 핵심: 실행 모드의 우선순위가 높아지고, 하드웨어 인터럽트가 발생하여 시스템 호출이 실행됨.

아래와 같은 구조로 시스템 호출이 이루어집니다

  • User Program (사용자 프로그램): 사용자 영역에서 실행됨.
  • Operating System (운영 체제): 커널 영역에서 실행됨.

시스템 호출 시, 사용자 프로그램이 시스템 호출을 요청하면 커널 모드로 전환되고, 시스템 호출이 처리된 후 다시 사용자 모드로 복귀합니다.


시스템 콜 요청 과정

요약

  • 사용자 프로그램은 시스템 호출(write)을 요청하고, 이 요청은 커널의 syscall_handler에서 처리됩니다.
  • 커널은 시스템 호출 번호와 매개변수를 스택에서 확인 후 처리한 뒤 그 결과를 다시 사용자 프로그램에게 반환합니다.
  • 이 동작은 마치, 사용자가 서비스를 요청하면, 운영체제(커널)가 그 요청을 처리해주는 과정이라 보면 됩니다!
  • 1. 사용자 프로그램(User Program): 사용자 측
  • main() 함수에서 무언가 쓰기 작업을 하고 싶어서 write()라는 시스템 호출을 요청합니다.
    • 예를 들어, "파일에 글을 쓰고 싶어, 시스템아 도와줘!"라고 요청하는 거예요.
  • 이때 사용자 프로그램은 단순히 커널이 제공하는 시스템 호출 API를 호출할 뿐입니다. (syscall3 함수가 실제로는 syscall 명령어를 호출합니다).
    • 여기서 syscall 번호와 매개변수(arg0, arg1, arg2 등)를 스택에 저장하고, 0x30이라는 특별한 인터럽트를 실행하게 됩니다.2. 인터럽트 벡터 테이블(Interrupt Vector Table): 중간 관리자 개입

인터럽트가 발생하면 컴퓨터는 "아! 시스템 호출을 했구나!"라고 알아차리고 **인터럽트 벡터 테이블을 참조합니다.

  • 이 벡터 테이블은 특정 번호에 대응되는 행위를 정의한 목록이라고 보면 돼요.
    • 예를 들면, 어떤 상황이 발생했을 때 어떤 함수가 처리해야 하는지 지정해 둔 일종의 "긴급 대응 메뉴얼" 같은 건데, 여기서 0x30은 시스템 호출을 처리하는 함수(syscall_handler)를 호출하라고 기록돼 있습니다.3. syscall_handler() 호출: 커널 쪽

이제 커널 내부로 들어가 볼게요.

  1. syscall_handler()가 호출됩니다. 이 함수는 우리가 작성해야 할 부분입니다.
  2. 사용자는 write() 시스템 호출을 요청했지만, 커널은 처음에는 아무것도 모르기 때문에 먼저 "이게 무슨 시스템 호출인지" 파악해야 합니다.
    • 이 정보는 syscall_handler()로 전달된 intr_frame 구조체에 보면 있어요. 여기에는 사용자 프로그램이 전달한 시스템 호출 번호와 인수들이 담겨 있습니다.
    • 스택에는 호출 번호와 함께 arg0, arg1, arg2 같은 매개변수들이 쌓여 있어요.
  3. 커널은 syscall_handler에서 해당 번호(number 값)를 확인하고 어떤 시스템 호출인지 알아내죠.
    • 예를 들어, 0번이라면 halt 시스템 호출일 수도 있고, 1번이라면 exit, 2번이라면 write 같은 시스템 호출일 수 있겠죠.
  4. 확인한 후, 그 요청에 맞는 동작을 커널이 처리합니다. 예를 들어, 파일에 데이터를 쓰는 작업을 요청했으면, 파일 시스템 부분에서 해당 요청을 처리하게 됩니다.4. 응답 및 반환(Returning to User Program)
  • 작업이 끝나면 그 결과(성공했는지, 실패했는지)를 다시 커널이 사용자 프로그램에 알려줍니다.
  • 이 결과는 rax 레지스터에 담겨서 다시 사용자 프로그램으로 반환돼요.
    • 시스템 호출을 끝낸 뒤 사용자 프로그램으로 "아! 성공했어!" 혹은 "안타깝지만 오류가 났어"라고 말하듯이 전달하는 거죠.
  • 이제 사용자 프로그램으로 돌아와서 마치 일반 함수 호출처럼 호출된 결과에 맞게 계속 진행하면 됩니다.

system call handler

1. 시스템 호출 번호 확인

  • 시스템 호출을 처리하는 첫 단계는 시스템이 어떤 요청을 하는지 알아내는 것입니다.
    • 각 시스템 호출에는 번호가 있습니다. 예를 들어, SYS_HALT는 0번, SYS_EXIT는 1번과 같이 정해져 있죠.
    • 이 번호들은 보통 별도의 파일(syscall_nr.h)에서 정의되어 있습니다.
  • 이러한 번호를 통해, 커널은 지금 어떤 시스템 호출을 처리해야 하는지 결정하게 됩니다.
    • 예를 들어, 0번은 시스템 종료(halt()), 1번은 프로세스 종료(exit()), 5번은 파일 삭제(remove())와 같은 기능을 담당합니다.

2. 시스템 호출 핸들러 (커널 측에서 처리)

  • syscall_handler()는 시스템 호출이 들어왔을 때 중간에서 요청을 처리하는 함수라고 생각할 수 있어요.
    • 시스템 호출이 발생하면 핸들러는 intr_frame 구조체를 통해 시스템 호출 번호를 확인할 수 있습니다.
  • 이때 핸들러 안에서는 switch 문이 등장합니다.
    • switch 문을 통해 시스템 호출 번호에 맞는 적절한 기능을 실행합니다.

예를 들어 봅시다:

  1. 번호가 0번이라면 SYS_HALT가 호출된 것이므로, 커널에서는 halt() 함수를 호출해 시스템을 종료합니다.
  2. 번호가 1번이라면 SYS_EXIT가 호출되었고, 이를 위해 exit() 함수를 호출해 현재 프로세스를 종료합니다.
  3. 번호가 5번이라면 SYS_REMOVE 요청이므로, remove() 함수를 호출해 파일을 삭제합니다.

3. 요약

  • 시스템 호출 번호는 요청된 기능이 무엇인지를 알려주는 표지판입니다.
  • syscall_handler는 표지판을 보고 그 요청에 맞는 작업(halt(), exit(), exec(), remove(), 등)을 수행하는 길잡이 역할을 합니다.
  • 시스템 호출을 처리하는 실제 함수들을 미리 정의된 번호로 연결해주는 간단한 중개 작업이지만, 커널은 이 과정을 통해 사용자 프로그램의 요청에 적절히 대응하게 됩니다.

시스템 호출 핸들러의 구현 요구 사항

전체 설명 요약

  • syscall_handler()를 구현하여 시스템 호출 번호와 인자들을 처리합니다.
  • 각 시스템 호출 번호에 따라 적절한 함수가 실행될 수 있도록 분기 처리 합니다.
  • 인자로 전달받은 포인터 주소가 올바른 사용자 공간을 가리키는지 검증하여, 잘못된 메모리 접근 시 프로그램이 안전하게 종료되도록 합니다.
  • 사용자 스택에서 커널로 데이터를 안전하게 복사하여 커널이 인자들을 처리할 수 있게 합니다.
  • 시스템 호출이 완료되면 반환 값을 rax 레지스터에 저장하여 사용자 프로그램에 결과를 전달합니다.

시스템 호출 핸들러를 구현하기 위한 주요 요구 사항은 보안, 메모리 접근 검증, 그리고 핸들링 프로세스의 흐름을 명확히 이해하며 잘 구현하는 것입니다.

  • 시스템 호출 핸들러 구현
    • 시스템 호출 핸들러를 작성해서 시스템 호출 번호에 따라 해당 시스템 호출을 실행하도록 만든다.
  • 포인터의 유효성 검사
    • 매개변수 리스트에 있는 포인터들이 유저 영역을 가리키는지, 커널 영역을 가리키는지 확인해야 한다.
    • 이 포인터들이 유효한 주소를 가리키지 않으면 페이지 폴트(page fault) 오류가 발생한다.
  • 유저 스택의 인자를 커널로 복사
    • 유저 스택에 있는 인자들을 커널 영역으로 복사한다.
  • 시스템 호출의 반환값 저장
    • 시스템 호출의 결과값을 eax 레지스터에 저장한다

요구 사항 1: 시스템 호출 핸들러 구현

가장 기본적으로 syscall_handler()라는 함수를 구현해야 합니다. 이 함수는 시스템 호출이 발생할 때 커널과 사용자 프로그램 사이에서 중재하는 역할을 하며, 전달된 시스템 호출 번호와 관련된 작업을 수행하는 중추적인 함수입니다.

구현 과정:

  1. 사용자 요청 확인: 시스템 호출 번호와 그 인자들을 확인합니다. 보통 시스템 호출 번호는 %rax 레지스터에 저장되고, 나머지 인수는 %rdi, %rsi, %rdx, %r10, %r8, %r9 같은 레지스터에 전달됩니다.
  2. 핸들러 실행: 핸들러는 해당 시스템 호출 번호에 맞는 함수를 호출하여 적절한 작업을 수행합니다.
    • 예를 들어, write() 시스템 호출 번호가 넘어오면, 핸들러는 커널에서 해당 작업을 수행하는 write() 함수로 연결되어야 합니다.

요구 사항 2: 시스템 호출 번호에 따른 분기 처리

핸들러는 시스템 호출 번호에 맞춰 정확한 시스템 호출 함수를 실행해야 합니다. 이 과정에서 Switch 문 또는 분기문을 통해 시스템 호출 번호와 매개변수들을 핸들링합니다.

void syscall_handler(struct intr_frame *f) {
    // 시스템 호출 번호 확인
    uint64_t syscall_no = f->R.rax;

    switch (syscall_no) {
        case SYS_HALT:      // 시스템 종료
            halt();
            break;
        case SYS_EXIT:      // 프로세스 종료
            exit(f->R.rdi); // %rdi에 담긴 status 인수 사용
            break;
        case SYS_EXEC:      // 새로운 프로세스 실행
            exec(f->R.rdi); // %rdi에 담긴 cmd_line 인수 사용
            break;
        // 이후 추가적인 시스템 호출들에 대한 case
        default:
            printf("Unknown system call: %d\n", syscall_no);
            thread_exit();  // 알 수 없는 시스템 호출일 경우 종료
    }
}

요구 사항 3: 포인터 유효성 검증

시스템 호출 인자로 포인터가 전달될 때는, 해당 포인터가 올바른 사용자 주소 영역을 가리키는지 확인해야 합니다.

검증 이유:

때로는 사용자 프로그램이 잘못된 주소(사용자 영역이 아닌 커널 영역 또는 잘못된 메모리 페이지)에 접근하려 할 수 있습니다. 이럴 경우, 올바르지 않은 메모리 접근으로 인해 시스템 전체 안정성에 문제를 유발할 수 있기 때문에 반드시 주소 검증이 필요합니다.

검증 사항:

  1. 포인터가 사용자 영역을 가리키고 있는지 확인:
    • 커널 모드의 주소나 경계 바깥의 주소를 참조하면 안 됩니다.
    • 예를 들어, 커널에 있는 메모리 주소(높은 메모리 주소들)에 대해 접근하려는 잘못된 시도가 있을 수 있습니다. 이런 경우 page fault 예외가 발생하여 적절한 처리가 이루어져야 합니다.
  2. 주소가 유효한 페이지에 속해 있는지 확인:
    • 예를 들어, 특정 페이지가 할당되지 않았거나 접근 권한이 부족할 경우, 반드시 이를 검출하여 프로그램을 종료시키거나 오류 메시지를 반환해야 합니다.
void validate_address(const void *user_ptr) {
    // 포인터가 유효한 사용자 영역을 가리키는지 확인
    if (user_ptr == NULL || !is_user_vaddr(user_ptr)) {
        exit(-1);  // 유효하지 않다면, 프로그램을 종료
    }
}

요구 사항 4: 사용자 스택에서 커널로 인수 복사

사용자 프로그램에서 넘겨준 인자 값들은 사용자 스택이나 레지스터에 저장되어 있는데, 이 값을 처리하기 위해 커널로 복사해야 합니다. 사용자 스택에 접근할 때 유효성 검증을 마친 후, 인자들을 커널에서 처리해야 합니다.

과정:

  1. 스택에 저장된 인자 읽기:
    • 시스템 호출이 발생할 때, 인자들은 사용자 스택에 저장되는데, 메모리 주소가 올바른지 확인 후 그 값을 커널로 가져옵니다.
  2. 커널 공간으로 복사:
    • 사용자가 다른 메모리 공간을 침범하지 못하도록 보호된 상태에서 스택으로부터 인자를 복사합니다.
      void copy_arguments(void *src, void *dest, size_t size) {
      // 사용자에서 커널로 복사하면서 올바른 메모리인지 확인
      if (!validate_address(src)) {
        exit(-1);  // 유효성 통과 못 하면 종료
      }
      memcpy(dest, src, size);  // 정상 검사 후 복사
      }
      요구 사항 5: 반환 값을 eax 레지스터에 저장

시스템 호출의 실행 결과(예를 들어, 동작이 성공했는지, 실패했는지)를 사용자 프로그램에게 전달해야 합니다. 이때, 반환 값은 RAX 레지스터를 사용합니다.

  • 시스템 호출 함수가 성공하게 되면 해당 결과를 RAX 레지스터에 저장하고, 이를 통해 사용자 프로그램으로 반환합니다.
  • 시스템 호출의 반환값이 정수(int)일 경우, 이를 RAX에 저장하여 호출자에게 그 값을 돌려줄 수 있습니다.
    int exec(const char *cmd) {
      ...
      int success = 1;  // 가정상 exec 함수 성공을 나타냄
      // 실행 후, RAX 레지스터에 결과값 저장
      f->R.eax = success;  // f는 syscall_handler로 넘겨진 intr_frame 구조체
    }
    Address Validation(주소 검증)

사용자 포인터의 문제점

사용자 프로그램은 때때로 잘못된 포인터를 전달할 수 있어요. 이러한 포인터들은:

  • 널 포인터 또는 비할당 가상 메모리 포인터일 수 있습니다.
  • 또는 커널 메모리 영역을 가리키는 유효하지 않은 포인터일 수도 있습니다. 이 영역은 보통 PHYS_BASE보다 높은 주소입니다.

이처럼 잘못된 포인터는 커널이나 다른 프로세스에 문제를 일으킬 수 있으므로, 커널은 이를 감지하고 처리할 필요가 있습니다.


주소 검증의 필요성

잘못된 메모리 접근을 방지하려면, 커널은 유효하지 않은 포인터를 식별하고, 이를 안전하게 처리하여 시스템이 오작동하거나 충돌하지 않도록 해야 합니다. 이는 시스템의 안정성과 보안에 직결됩니다.

  • 유효하지 않은 포인터가 감지되면, 프로그램을 안전하게 종료시켜야 합니다. 커널이 손상되거나 다른 프로세스들에 해를 끼치는 일을 막기 위해서입니다.

두 가지 검증 방법

방법 1: 포인터의 유효성 검사

  • 이 방법은 사용자가 제공한 포인터가 올바른 사용자 메모리 주소인지 검사하는 것입니다.
  • 사용자 메모리 액세스를 처리하는 가장 간단한 방법입니다.
  • userprog/pagedir.cthreads/vaddr.h에 정의된 함수를 활용해 구현합니다.
bool is_valid_user_pointer(const void *user_ptr) {
    // 포인터가 사용자 메모리의 유효한 영역을 가리키는지 체크
    return user_ptr != NULL && is_user_vaddr(user_ptr) && pagedir_get_page(thread_current()->pagedir, user_ptr) != NULL;
}

방법 2: PHYS_BASE 아래의 사용자 포인터 확인

  • 이 방법은 포인터가 PHYS_BASE보다 낮은지 여부를 확인하는 것입니다.
  • 잘못된 포인터는 page_fault를 유발합니다. 이를 page_fault 함수를 수정하여 처리합니다.
  • MMU(Memory Management Unit)를 이용하므로 첫 번째 방법보다 보통 빠릅니다. 실제 커널에서는 이 방식을 자주 사용합니다.
    bool is_below_phys_base(const void *user_ptr) {
      // 포인터가 커널 영역을 침범하지 않는지 확인
      return user_ptr < PHYS_BASE;
    }

사용자 메모리 접근(Accessing User Memory)

  1. 리소스 누출 방지:
    • 메모리 할당(malloc)이나 잠금(lock) 후, 오류가 발생해도 반납해야 함.
    • 페이지 폴트 발생 시에도 리소스를 해제하여 시스템 성능 유지.
  2. 직접적 검사:
    • 포인터 유효성 먼저 검사 후 리소스 할당.
    • 유효하지 않은 포인터 접근 방지.
  3. 페이지 폴트 처리:
    • 페이지 폴트는 잘못된 메모리 접근 시 발생.
    • 미리 정의된 함수 사용으로 안전하게 처리.
    • 예: lock_release()free()로 자원 해제프로세스 계층 구조(Process Hierarchy)

프로세스 계층 구조

  • Pointer to parent process: struct thread*
  • Pointers to the sibling. struct list
  • Pointers to the children: struct list_elem
  1. 부모-자식 관계:
    • 부모 프로세스는 여러 자식을 가질 수 있고, 자식은 부모를 가리키는 포인터(struct thread*)를 가집니다.
  2. 형제 간 연결 (Sibling)
    • 자식 프로세스들은 더블 링크드 리스트로 서로 연결됩니다.
    • 각 자식은 prevnext 포인터로 형제들과 연결되어 있습니다.
  3. 자식 관리 리스트
    • 부모는 child_list를 통해 첫 번째(child_list.head)부터 마지막 자식(child_list.tail)까지 상태를 관리합니다.

시스템 콜 함수 구현

process 관련 함수

1. void halt(void)

  • 기능: halt()Pintos 운영체제를 종료시킵니다. 구체적으로 power_off() 함수를 호출하여 시스템을 완전히 종료합니다.
  • 사용 시기: 이 함수는 웬만하면 사용되지 않는 것이 좋습니다. 운영체제를 강제로 종료하기 때문에 디버깅 중 발생한 데드락(deadlock) 상황 등의 정보를 잃어버릴 수 있기 때문입니다.
  • 구현 방법:
    1. syscall.c에서 syscall_handler()에서 시스템 콜 번호를 확인한 뒤, halt() 시스템 콜이 호출되면 power_off()를 호출하여 Pintos를 종료합니다.
    2. power_off() 함수는 이미 구현되어 있으므로 추가적인 복잡한 작업은 필요하지 않습니다.

2. void exit(int status)

  • 기능: exit()는 현재 실행 중인 사용자 프로그램을 종료하고, status 상태 값을 커널에 반환합니다.
  • 상태 값: 이 상태 값은 부모 프로세스가 대기(wait())할 때 반환됩니다. 관례적으로 상태 값이 0이면 성공, 0이 아니면 오류를 나타냅니다.
  • 구현 방법:
    1. exit() 함수가 호출되면 현재 프로세스를 종료시키고, 인자로 전달된 status 값을 운영체제에 알려줍니다.
    2. 부모 프로세스는 이후 wait() 시스템 콜을 통해 자식 프로세스가 종료되었을 때 이 상태 값을 확인할 수 있습니다.
    3. 프로세스를 종료하면서, 파일 디스크립터나 메모리 등의 자원도 함께 해제해야 합니다.
    4. process_exit() 함수를 호출하여 실제 프로세스의 종료를 처리합니다.

3. pid_t fork(const char *thread_name)

  • 기능: fork()는 thread_name 이라는 이름을 가진 현재 프로세스를 복제(clone)하여 새로운 자식 프로세스를 생성합니다. 이 자식 프로세스는 호출한 프로세스의 복사본이며, 별도의 독립적인 프로세스로 실행됩니다.
  • 피호출자(callee) 저장 레지스터인 %RBX, %RSP, %RBP와 %R12 - %R15를 제외한 레지스터 값을 복제할 필요가 없습니다.
  • 상세 설명:
    • 자식 프로세스는 부모 프로세스의 파일 디스크립터와 가상 메모리 공간을 복제받습니다.
    • 자식 프로세스에서는 반환 값이 0이고, 부모 프로세스는 자식 프로세스의 PID를 반환받습니다.
    • 자식 프로세스가 복제를 실패하면 부모 프로세스는 TID_ERROR를 반환합니다.
  • 구현 방법:
    1. thread_create()를 사용하여 자식 프로세스를 생성합니다.
    2. 부모 프로세스의 메모리 공간을 복사해야 합니다. 이를 위해 pml4_for_each()를 사용하여 메모리 공간을 복제합니다.
    3. 자식 프로세스가 성공적으로 생성(복제)되었는지 꼭 먼저 확인하고, 성공하면 자식의 PID를 반환, 실패하면 TID_ERROR를 반환합니다.
    4. 자식 프로세스가 실행을 시작하기 전에 부모 프로세스는 자식의 성공 여부를 반드시 알아야 합니다.

템플릿은 threads/mmu.cpml4_for_each를 사용하여 해당되는 페이지 테이블 구조를 포함한 전체 사용자 메모리 공간을 복사하지만, 전달된 pte_for_each_func의 누락된 부분을 채워야 합니다.


4. int exec(const char *cmd_line)

  • 기능: exec()는 현재 프로세스를 새로운 프로그램으로 대체합니다. cmd_line 인자로 전달된 프로그램을 실행하며, 인자들도 함께 전달됩니다.
  • 상세 설명:
    • 성공적으로 새로운 프로그램을 실행하면 아무 것도 반환되지 않습니다.
    • 실행에 실패하면 프로세스는 1 상태 값으로 종료됩니다. (exit state -1을 반환하며 프로세스가 종료)
    • 실행 중인 파일 디스크립터는 exec 함수 호출 시에도 여전히 열려 있어야 합니다.
  • 구현 방법:
    1. cmd_line에 지정된 프로그램을 로드하고 실행합니다. 프로그램을 로드하는 데는 process_execute()와 비슷한 작업이 필요합니다.
    2. 로드에 성공하면 프로세스는 새로운 프로그램으로 대체되며, 실행에 실패하면 종료 상태를 1로 설정하고 프로세스를 종료합니다.
    3. exec()를 호출한 프로세스는 종료되므로, 반환하지 않고 새 프로그램이 실행됩니다.

5. int wait(pid_t pid) (이게 할게 많은가 봄)

  • 기능: wait()는 특정 자식 프로세스가 종료될 때까지 기다린 후, 자식의 종료 상태를 반환합니다.→ 부모 프로세스가 wait 함수를 호출한 시점에서 이미 종료된 자식 프로세스가 기다리도록 할 수는 있지만. 커널은 부모에게 자식의 종료 상태를 알려주든지, 커널에 의해 종료되었다는 사실을 알려주든지 해야함.
  • → 자식 프로세스 (pid) 를 기다려서 자식의 종료 상태(exit status)를 가져옵니다. 만약 pid (자식 프로세스)가 아직 살아있으면, 종료 될 때 까지 기다립니다. 종료가 되면 그 프로세스가 exit 함수로 전달해준 상태(exit status)를 반환합니다.
  • 상세 설명:
    • pid가 아직 종료되지 않았으면, wait()는 자식 프로세스가 종료될 때까지 대기합니다.
    • 자식 프로세스가 exit()을 호출하면, 해당 상태 값을 부모 프로세스에 반환합니다.
    • 만약 pid가 호출자 프로세스의 자식이 아니거나, 이미 대기한 적이 있으면 1을 반환합니다.
  • 구현 방법:
    1. pid가 부모 프로세스의 직접적인 (직속)자식인지 확인합니다. 자식 프로세스는 부모 프로세스가 fork() 호출 시 성공적으로 반환한 PID를 통해 식별됩니다.
    2. → 직속이 아니어도 실패해야함. -1
    3. 자식 프로세스가 종료되었으면, 해당 종료 상태를 반환합니다.
    4. 자식이 정상적으로 종료하지 않았거나 커널에 의해 종료되었다면 -1을 반환합니다.
    5. → pid (자식 프로세스)가 exit() 함수를 호출하지 않고 커널에 의해서 종료된다면 (e.g exception에 의해서 죽는 경우), wait(pid) 는 -1을 반환해야 합니다.
    6. 이미 wait()가 호출된 자식 프로세스에 대해 다시 wait()가 호출되면, 이는 실패로 처리하고 -1을 반환해야 합니다.

추가 고려 사항

  • 발생할 수 있는 기다림의 모든 경우를 고려해야합니다. 한 프로세스의 (그 프로세스의 struct thread 를 포함한) 자원들은 꼭 할당 해제되어야 합니다.
  • 부모-자식 관계 관리: 자식 프로세스의 상태는 부모 프로세스가 기다릴 수 있도록 관리되어야 합니다. 따라서 부모가 종료되기 전까지 자식의 종료 상태를 추적해야 합니다.
  • 프로세스 자원 관리: 모든 프로세스는 종료 시 모든 자원을 적절하게 해제해야 합니다. 부모 프로세스가 자식 프로세스를 기다리지 않고 종료하더라도, 자식의 자원은 적절히 해제되어야 합니다.
  • 이 시스템 콜을 구현하는 것이 다른 어떤 시스템콜을 구현하는 것보다 더 많은 작업을 요구합니다.

이렇게 각 시스템 콜의 기본적인 동작을 이해하고 구현해야 합니다. 특히 프로세스 간의 관계나 리소스 관리는 신중하게 설계해야 합니다.

File Descriptor(fd) 관련 함수

1. bool create (const char *file, unsigned initial_size)

  • 기능: 이 함수는 file이라는 이름의 새로운 파일을 만들고, 그 파일의 초기 크기를 initial_size로 설정합니다.
  • 반환값: 파일 생성이 성공하면 true, 실패하면 false를 반환합니다. 파일을 생성하는 것과 여는 것은 별개이므로, 파일을 사용하려면(열려면) 나중에 open()을 호출해야 합니다.
  • 구현 방법:
    1. 파일 시스템의 filesys_create() 함수를 호출하여 새 파일을 만듭니다.
    2. 파일 이름이 NULL이거나 유효하지 않은 경우, 파일 생성에 실패하므로 false를 반환해야 합니다.
    3. 파일을 생성할 수 있으면 true를 반환하고, 그렇지 않으면 false를 반환합니다.

2. bool remove (const char *file)

  • 기능: 이 함수는 file이라는 이름의 파일을 삭제합니다.
  • 반환값: 파일 삭제가 성공하면 true, 실패하면 false를 반환합니다. 파일이 열려 있는 상태에서 삭제해도 파일은 여전히 사용 가능하며, 삭제한 후 파일을 닫을 때 실제로 시스템에서 제거됩니다.
  • 구현 방법:
    1. 파일 시스템의 filesys_remove() 함수를 호출하여 파일을 삭제합니다.
    2. 파일 이름이 NULL이거나 파일을 삭제할 수 없는 경우, false를 반환합니다.
    3. 파일이 성공적으로 삭제되면 true를 반환합니다.

3. int open (const char *file)

  • 기능: 이 함수는 file이라는 이름의 파일을 엽니다.
  • 반환값: 파일을 성공적으로 열면 그 파일의 파일 디스크립터(파일 식별자, fd→ 0이상의 정수)를 반환하고, 실패하면 -1을 반환합니다.
  • 파일 디스크립터: 0번(표준 입력)과 1번(표준 출력) 파일 디스크립터는 콘솔 입출력을 위해 예약되어 있습니다. 즉, open()은 0번 또는 1번 파일 디스크립터를 반환하지 않습니다.
  • 구현 방법:
    1. 파일을 열기 위해 filesys_open()을 호출합니다.
    2. 파일을 열지 못하면 -1을 반환합니다.
    3. 파일을 열면 해당 파일에 대한 고유한 파일 디스크립터(0,1이 아닌)를 생성하고, 이를 반환합니다.
    4. 각 프로세스는 독립적인 파일 디스크립터 테이블을 가집니다.하나의 프로세스에 의해서든 다른 여러개의 프로세스에 의해서든, 하나의 파일이 두 번 이상 열리면 그때마다 open 시스템콜은 새로운 식별자를 반환합니다.
    5. 하나의 파일을 위한 서로 다른 파일 식별자들은 개별적인 close 호출에 의해서 독립적으로 닫히고 그 한 파일의 위치를 공유하지 않습니다. 당신이 추가적인 작업을 하기 위해서는 open 시스템 콜이 반환하는 정수(fd)가 0보다 크거나 같아야 한다는 리눅스 체계를 따라야 합니다.
    6. fd는 자식 프로세스들에게 상속된다.

4. int filesize (int fd)

  • 기능: 이 함수는 파일 디스크립터 fd로 열린 파일의 크기를 바이트 단위로 반환합니다.
  • 반환값: 파일의 크기를 반환하고, 파일을 찾을 수 없으면 에러를 처리해야 합니다.
  • 구현 방법:
    1. 먼저 파일 디스크립터 fd로 열린 파일을 찾아야 합니다.
    2. 해당 파일이 존재하면 file_length()를 호출하여 파일 크기를 반환합니다.
    3. 파일이 열려 있지 않으면 에러로 처리하고, 적절한 값을 반환합니다.

5. int read (int fd, void *buffer, unsigned size)

  • 기능: 이 함수는 fd로 열린 파일에서 size만큼의 데이터를 읽어와 buffer에 저장합니다.
  • 반환값: 실제로 읽은 바이트 수를 반환하고, 읽기 실패 시 -1을 반환합니다.
  • 특수 처리: 만약 fd가 0번이면, 키보드에서 입력을 받아 읽어야 합니다. 이때는 input_getc() 함수를 사용하여 키보드 입력을 처리합니다.
  • 구현 방법:
    1. 파일 디스크립터가 0인 경우(파일 끝에서 시도한 경우)는 키보드 입력을 받아야 하므로, input_getc()를 호출하여 buffer에 저장합니다.
    2. 파일 디스크립터가 0이 아닌 경우는 파일에서 데이터를 읽어와야 합니다. 이를 위해 file_read() 함수를 호출하여 파일에서 데이터를 읽어옵니다.
    3. 읽은 바이트 수를 반환하고, 실패하면 -1을 반환합니다.

6. int write (int fd, const void *buffer, unsigned size)

  • 기능: 이 함수는 fd로 열린 파일에 buffer로부터 size만큼의 데이터를 씁니다.
  • 반환값: 실제로 쓴 바이트 수를 반환하고, 실패 시 -1을 반환합니다.
    • 일부 바이트가 적히지 못했다면 size보다 더 작은 바이트 수가 반환될 수 있습니다. 파일의 끝을 넘어서 작성하는 것은 보통 파일을 확장하는 것이지만, 파일 확장은 basic file system에 의해서는 불가능합니다.
    • 파일의 끝까지 최대한 많은 바이트를 적어주고 실제 적힌 수를 반환하거나, 더 이상 바이트를 적을 수 없다면 0을 반환합니다
  • 특수 처리: fd가 1이면 콘솔로 데이터를 출력해야 하므로, putbuf()를 사용해 데이터를 출력합니다.
  • 구현 방법:
    1. 파일 디스크립터가 1인 경우, 콘솔로 데이터를 출력합니다. putbuf() 함수를 사용하여 buffer에 있는 데이터를 출력합니다.→ 그렇지 않다면, 다른 프로세스에 의해 텍스트 출력 라인들이 콘솔에 끼게 (interleaved)되고, 읽는 사람과 우리 채점 스크립트가 헷갈릴 것입니다.
    2. → 콘솔에 작성한 코드가 적어도 몇 백 바이트를 넘지 않는 사이즈라면, 한 번의 호출에 있는 모든 버퍼를 putbuf()에 적어주는 것입니다.(더 큰 버퍼는 분해하는 것이 합리적입니다!!)
    3. 파일 디스크립터가 1이 아닌 경우는 파일에 데이터를 써야 합니다. 이를 위해 file_write() 함수를 호출하여 파일에 데이터를 씁니다.
    4. 쓴 바이트 수를 반환하고, 실패하면 -1을 반환합니다.

7. void seek (int fd, unsigned position)

  • 기능: 이 함수는 fd(우리가 작업하려는 파일)로 열린 파일내에서 읽거나 쓸 때 어느 위치부터 시작할지 position으로 설정(변경)합니다.
  • 세부 설명: position파일의 시작부터 몇 바이트 떨어진 위치인지 나타냅니다. 파일의 끝을 넘는 위치로 이동하는 것은 허용되지만, 그 위치에서 읽으려고 하면 0바이트를 읽게 됩니다.
    • position 값을 파일 크기보다 크게 지정하면 어떻게 될까요? 이 경우 에러가 발생하지 않습니다. 즉, 파일의 끝을 넘어서는 위치로 이동할 수 있지만, 이 상태에서 읽기(read)를 시도하면 0바이트를 읽게 됩니다. 이는 파일 끝에 도달했음을 의미합니다. 읽을 데이터가 없는 것이죠. → seek() 이후 쓰기(write)를 시도하면, 만약 그 위치가 파일의 끝을 넘어섰을 때, 그 파일의 크기가 자동으로 확장됩니다. 이때 파일 중간에 빈 부분이 생기면, 그 부분을 0으로 채우게 됩니다. (이건 핀토스에선 해당사항 아님)
    • Pintos에서의 제한 사항*:
    • 파일 크기 고정: 하지만 Pintos의 기본 파일 시스템에서는 파일 크기가 고정되어 있어서, 파일 끝을 넘어가는 위치에서 쓰기를 시도하면 에러가 발생합니다. 따라서 프로젝트 4가 완료되기 전까지는 파일을 확장할 수 없으며, 파일의 끝을 넘어 쓰는 것은 허용되지 않습니다.
  • 구현 방법:
    1. 파일 디스크립터 fd로 열린 파일의 위치를 position으로 설정하려면 file_seek() 함수를 호출합니다.
    2. 파일이 존재하지 않으면 에러를 처리해야 합니다.
    3. 파일 끝을 넘어가는 위치로 이동하면 읽기는 0바이트를 반환하며, 쓰기는 파일 시스템의 제한에 따라 에러를 발생

8. unsigned tell (int fd)

  • 기능: 이 함수는 파일 디스크립터 fd로 열린 파일의 현재 읽기 또는 쓰기 위치를 반환합니다. 읽히거나 써질 다음 바이트의 위치를 반환
  • 구현 방법:
    1. file_tell() 함수를 사용하여 파일의 현재 위치를 가져옵니다.
    2. 파일 디스크립터가 유효하지 않으면 에러 처리를 해야 합니다.

9. void close (int fd)

  • 기능: 이 함수는 파일 디스크립터 fd를 닫습니다. 프로세스가 종료되면 그 프로세스의 열려있는 파일 디스크립터는 자동으로 닫히지만, 이 함수는 특정 파일식별자를 수동으로 닫을 때 사용합니다.
  • 구현 방법:
    1. file_close() 함수를 사용하여 파일을 닫습니다.
    2. 파일이 이미 닫혀 있거나 유효하지 않은 파일 디스크립터인 경우는 에러로 처리합니다.