포스트

pintOS › pintOS Argument Passing 정리(Week9_day2)

오늘은 USER PROGRAMS의 Argument Passing에 대해 공부한다.

Argument Passing

process_exec()에 있는 유저 프로그램을 위한 인자를 설정한다.

x86-64 Calling Convention

이 섹션에서는 Unix의 64비트 x86-64 구현에서 일반 함수 호출에 사용되는 규칙의 중요점을 요약한다.

  • 인수 전달 방식
    User-level 어플리케이션들은 정수 레지스터를 통해 %rdi, %rsi, %rdx, %rcx, %r8, %r9 시퀸스를 전달한다(만약 6개 이상의 인자를 받으면 스택에 쌓여서 전달 됨).
  • CALL 명령어
    함수를 호출할 때 사용되며, 호출자의 다음 명령어 주소(리턴주소)를 스택에 저장한 후, 호출하려는 함수의 첫 번째 명령어로 점프한다. 즉 함수 호출 시 스택에 리턴 주소를 저장한다.
  • 피호출 함수 실행
    레지스터에 전달 된 인자를 사용하여 작업을 수행한다.
  • 리턴 값 저장
    피호출자가 리턴 값이 있으면 rax 레지스터에 저장한다.
  • RET 명령어
    함수가 종료되면 RET 명령어가 실행 되어 스택에 저장된 리턴 주소를 꺼내서 해당 주소로 점프하는 방식으로 호출자에게 돌아가고, 호출자는 계속에서 자신의 작업을 이어서 수행한다.

프로그램 시작의 디테일

pintOS에서 유저 프로그램이 실행 될 때, 진입점 함수는 _start()이다.
이 함수는 main()함수를 호출하고, main()이 종료 되면 exit()를 호출하여 프로그램을 종료한다.

1
2
3
void _start (int argc, char *argv[]) {
    exit(main(argc, argv));
}

argc는 명령줄 인자의 개수, argv는 명령줄 인자 배열의 시작 주소이다.

명령어 인자의 처리

사용자 프로그램이 시작되기 전에, 커널은 명령어 인자를 레지스터에 저장해야 한다. 명령어 인자는 일반적인 호출 규약과 같은 방식으로 전달된다.

예를 들어 /bin/ls -l foo bar라는 명령을 입력했다고 가정하겠다.

이 명령은 프로그램 이름과 3개의 인자로 나뉜다.

  • /bin/ls
  • -l
  • foo
  • bar 이를 명령어 단위로 쪼개어 스택에 올려야 한다(포인터를 통해 접근할 것이기 때문에 순서는 관계 없다).

명령어 주소를 스택에 저장

각 명령어 문자열의 주소를 스택에 차례대로 저장한다. 이 때 NULL 포인터를 추가하여, argv[argc] == NULL 이라는 조건을 충족할 수 있도록 한다.
스택에 저장하는 순서는 명령어의 오른쪽에서 왼쪽으로 저장되며, 가장 먼저 NULL 포인터를 푸시하고, 이를 통해 argv[0]이 가장 낮은 가상주소가 될 수 있도록 한다. 이후 각 명령어의 주소를 스택에 넣어 argv 배열을 완성한다(스택은 성장할수록 주소값이 낮아지기 때문에 명령어의 오른쪽에서부터 스택에 푸시하게 되면, 가장 나중에 저장한 값이 가장 낮은 주소에 위치하게 된다. 때문에 argv[0]이 가장 낮은 주소에, NULL 포인터가 가장 높은 주소에 있을 수 있다).

스택 정렬(8byte)

스택 정렬은 성능에 중요한 영향을 준다.
x86-64 아키텍쳐에서는 8바이트 단위로 정렬한 메모리 접근이 더 빠르기 때문에, 스택 포인터는 항상 8의 배수로 정렬되어야 한다. 이를 위해 스택 포인터를 8바이트의 경계로 맞춘다.

메모리의 정렬
운영체제가 64비트이기 때문에 주소 크기가 64비트로 관리되고, 한 번에 처리할 수 있는 데이터 양 또한 레지스터가 64비트로 8바이트이다 만약 데이터를 8의 배수로 정렬하지 않는다면 4바이트의 데이터만 찾으려면 CPU는 8바이트 단위로 데이터를 읽기 때문에 8바이트의 경계를 넘어 4바이트의 데이터가 있을 수 있다. 그럼 앞 뒤 메모리를 모두 접근한 뒤 필요한 데이터를 추출하는 과정이 필요하게 되고, 이는 추가적인 연산이나 메모리 접근을 야기한다.
반면 정렬된 메모리는 한 번의 접근으로 데이터를 처리할 수 있어 메모리 대역폭을 더 효율적으로 사용하며, 추가적인 연산이 필요 없게 된다.

해당 예시에서는 word-align이라는 빈 공간을 추가하여 스택 포인터를 8의 배수로 맞추었다.

레지스터 설정

명령어와 명령어 주소가 스택에 올려진 후, 레지스터 설정이 필요하다.

  • %rdi : 명령어 인수의 개수 (argc)
  • %rsi : 명령어 배열의 시작 주소 (argv[0]의 주소 = argv의 주소)

가짜 반환 주소

이 시작함수는 절대 리턴하지 않겠지만, 해당 스택 프레임 또한 다른 것과 동일한 구조를 가지도록 가짜 반환 주소를 푸시한다.

인자 전달의 구현

현재, process_exec()는 새로운 프로세스에 인자를 전달하는 것을 지원하지 않는다.
그렇기 때문에 더 나아가서 단순히 프로그램 파일 이름만을 인자로 받아서 실행하는 것이 아니라, 명령줄 인자도 단어 단위로 구분해서 처리하도록 해야 한다.

목표

  • 명령줄 인자 처리
    process_exec() 가 프로그램을 실행할 때, 명령줄 인자를 받아서 새 프로세스에 전달하도록 해야 한다.
  • 명령줄 인자 파싱
    입력된 문자열을 공백으로 분리하여 각 단어를 인자로 처리한다.
  • 스택에 인자 배치
    각 인자를 스택에 배치하고 스택 포인터와 관련 레지스터를 올바르게 설정해야 한다.

구현

  • 명령줄 인자 분리
    입력된 문자열에서 인자를 공백을 기준으로 분리한다.
    잘 모르겠으면 strtok_r()과 같은 문자열 분리 함수를 참고한다.
  • 스택에 인자 배치
    분리된 인자를 스택에 배치한다. 각 인자는 스택의 특정 위치에 저장되고, 인자들의 주소를 포인터 배열로 스택에 배치한다.
  • 스택 포인터 및 레지스터 설정
    스택 포인터를 적절히 설정하고, 인자의 포인터를 %rsi 레지스터에, 인자의 개수를 %rdi 레지스터에 설정한다.

명령줄에서는 여러 공백은 하나의 공백과 같게 처리되며, 명령줄의 인자의 길이에 제한을 줄 수도 있다.
pintOS에서는 128바이트의 제한이 있다.

User Memory Access

시스템 호출을 구현하기 위해서는 사용자 가상 주소 공간에 읽기 쓰기 작업을 할 수 있는 수단이 필요하다.
단순히 인자를 받을 때는 이 작업이 필요 없지만, 시스템 콜의 인자로서 받은 포인터의 데이터를 읽을 땐 다르다.
이는 유저가 제공한 포인터가 유효하지 않을 수도 있고, 커널 메모리 영역을 가리킬 수도 있고, 해당 영억을 살짝 걸친 블록일 수도 있다.
이 유저 프로그램을 종료함으로서 이런 경우를 다루어야 한다.

System Calls

시스템 호출 인프라를 구축하는 것은 운영체제가 사용자 프로그램의 요청을 처리할 수 있도록 하는 작업이다.
현재 제공된 스켈레톤 구현은 프로세스를 종료하는 것으로 시스템 콜을 처리하고 있는데, 이를 시스템 콜 번호와 인자를 회수해서 적절한 행동을 취해야 한다.

이전 프로젝트에서는 timer와 I/O devices의 interrupt를 통해서 유저 프로그램으로 부터 제어를 운영체제로 다시 가져왔었다. CPU 외부의 요소에 의해 발생했기 때문에 이들은 외부 interrupt 이다.

운영체제는 프로그램 코드에서 발생하는 이벤트인 소프트웨어 예외(페이지 오류 또는 0으로 나누기)도 처리한다.
예외는 사용자 프로그램이 운영체제로 부터 서비스(시스템 호출)를 요청할 수 있는 수단이기도 하다.

기존의 x86 아키텍쳐에서는 시스템 호출도 여타 소프트웨어 예외들과 같이 처리를 했었으나, x86_64 에서는 시스템 호출 만을 위한 특별한 명령어인 syscall을 도입했다.
syscall 명령어는 CPU에게 현재 실행 중인 프로그램의 시스템 호출을 처리하도록 지시한다. 이를 통해서 시스템 호출을 빠르게 처리할 수 있다.
또한 x86-64 아키텍쳐에서는 시스템 콜 번호를 레지스터에 저장하여 시스템 콜을 호출할 때마다 다시 번호를 입력하지 않아도 된다.

현대 x86-64 아키텍쳐에서 syscall 인스트럭션은 시스템 호출을 사용하는 가장 일반적인 방법으로, pintOS에서도 해당 명령어를 사용한다.
syscall 인스트럭션을 호출하기 전에 콜 번호와 추가 적인 인자들은 일반적인 방식으로 레지스터에 설정되어야 한다. 하지만 두 가지 예외가 있다.

  • %rax 는 시스템 콜 번호를 저장하는데 사용한다.
  • 네번재 인자는 %rcx대신에 %r10 레지스터에 저장된다.

따라서 시스템 호출 핸들러인 syscall_handler()이 제어를 얻었을 땐, 시스템 호출 번호는 %rax에 있고, 인자들은 %rdi, %rsi, %rdx, %r10, %r8, %r9의 순서로 전달된다.

호출자의 레지스터들은 struct intr_frame을 통해 접근할 수 있다(해당 구조체는 커널 스택에 위치한다).

x86-64 에서의 관례로 함수의 리턴값들은 %rax 레지스터에 저장하고, 값을 반환하는 시스템 호출은 struct intr_frame의 rax 멤버 값을 수정하여 수행할 수 있다.

시스템 콜의 구현

  • 운영체제의 종료

    1
    
    void halt(void);
    

    power_off()를 호출하여 pintOS를 종료시킨다. 이 호출은 자주 사용되지 않고, 운영체제가 종료 될 때 발생할 수도 있는 deadlock 상황 등에 대한 정보를 일부 잃을 수도 있다.

  • 실행 중인 사용자 프로그램을 종료

    1
    
    void exit(int status)
    

    현재 실행 중인 사용자 프로그램을 종료하고, 종료 상태(status)를 커널에 반환한다.
    만약 부모 프로세스가 자식 프로세스를 wait하고 있다면 부모 프로세스가 자식 프로세스의 종료 상태를 확인 할 수 있도록 자식 프로세스가 종료 될 때 반환값을 부모에게 전달한다. 0은 성공, 그 외의 값은 오류를 의미한다.

  • 현재 프로세스를 복제하여 자식 프로세스 생성

    1
    
    pid_t fork (const char *thread_name);
    

    부모 프로세스와 자식 프로세스는 동일한 메모리 공간과 파일 디스크립터를 공유 하지만, 자식은 별도의 프로세스로 독립적으로 실행(다른 PID를 가짐)된다. 또한 새로운 프로세스의 이름도 THREAD_NAME으로 정해 줄 수 있다. 복제된 프로세스의 자식은 0을 반환하고 부모는 자식의 PID를 반환한다.
    자식 프로세스 생성에 실패하면 부모는 오류 값을 반환해야 한다.
    또한 레지스터의 값들을 복제하지 않아도 되지만 %rbx, %rsp, %rbp, %r12 ~ %r15의 레지스터들은 함수 호출 시 호출된 함수가 유지해야 하는 callee-saved registers이기 때문에 fork() 함수가 호출 될 때도 이 값들은 복제 되어야 한다.
    이는 부모 프로세스와 자식 프로세스가 동일한 코드를 실행할 때, 동일한 레지스터 값을 가지게 되면 다른 프로세스임에도 같은 동작을 하게 되기 때문이다. 그렇기에 callee-saved registers를 제외하고는 복제하지 않아 다른 동작이 가능하게 한다.
    때문에 부모 프로세스는 자식 프로세스가 성공적으로 복제되었는지 확인하기 전까지는 fork()를 리턴하지 않아야 한다.
    만약 실패한다면 TID_ERROR을 반환해야 한다. 이는 자식 프로세스가 부모 프로세스의 자원을 사용하기 때문에 실패한다면 동일한 자원을 사용하지 못해 정상적으로 동작하지 않을 수 있기 때문이다.

    제공된 템플릿에서는 pml4_for_each()를 사용해서 전체 유저 메모리 공간을 복사하지만 pte_for_each_func는 내가 채워야 한다.

    PID(프로세스 식별자) : 앞에서 계속 언급하던 프로세스 번호를 의미한다.

  • 현재 프로세스를 종료하고 새로운 프로그램을 실행

    1
    
    int exec (const char *cmd_line);
    

    현재 프로세스를 cmd_line에 주어진 실행 파일로 변경하고, 주어진 인자를 전달하는 함수이다. 성공적으로 실행 되었다면 현재 프로세스는 종료되고(리턴되지 않는다) 새로운 프로세스가 실행되며, 실패할 경우 -1을 반환하고 프로세스를 종료한다.
    해당 함수는 프로그램의 실행 흐름만 바뀔 뿐 기존에 열려있던 파일 디스크립터나 쓰레드의 이름등은 그대로 유지 된다.

  • 자식 프로세스가 종료될 때까지 대기하고 종료상태를 반환

    1
    
    int wait(pid_t pid);
    

    전달하는 pid는 자식 프로세스이며, 이 함수는 자식 프로세스가 종료 될 때까지 대기하고 자식 프로세스가 exit 함수를 호출하여 전달한 상태를 반환한다.
    만약 자식 프로세스가 exit 함수를 호출 하지 않고 커널에 의해 종료 된 경우(예외로 인한 등) wait()는 -1을 반환한다.
    자식 프로세스가 이미 종료 된 후에도 wait()를 호출할 수 있다. 이를 통해 자식 프로세스의 종료 상태를 확인하거나, 프로세스가 커널에 의해 종료되었다는 것을 알 수 있다.

    다음은 wait 함수가 실패하고 -1을 반환하는 조건이다.

    • pid가 호출하는 프로세스의 직접적인 자식 프로세스를 참조하지 않는 경우
      pid가 fork 함수의 성공적인 호출로부터 얻은 반환값으로 받은 자식 프로세스인 경우에만 호출하는 프로세스의 직접적인 자식으로 간주된다.
      자식은 상속되지 않을므로 A가 자식 B를 생성하고 B가 자식 C를 생성한 경우 A는 C를 기다릴 수 없으며 B가 죽더라도 이는 마찬가지이다. 즉 A 가 C에 대해 wait(C)를 호출하면 실패해야 하고, 마찬가지로 부모가 자식보다 먼저 죽은 고아 프로세스는 새로운 부모에게 할당되지 않는다.
    • 특정 pid에 대해 이미 wait을 호출한 프로세스는 중복하여 기다릴 수 없다. 한 자식에 대해 한 번만 기다릴 수 있다.

    프로세스는 자식을 원하는 만큼 생성할 수 있고, 어떤 순서로든 이들을 wait 할 수 있다. 거기다 일부 또는 모든 자식을 기다리지 않고 종료할 수도 있다.
    자식 프로세스가 부모보다 먼저 또는 나중에 종료되는지 관계없이 자식 프로세스의 모든 자원, 쓰레드 구조체 까지 모두 할당 해제되어야 한다. 물론 부모가 이 프로세스를 wait하고 있어도 마찬가지이다.

    pintOS는 초기 프로세스(부모 프로세스)가 종료 될 때까지 종료되지 않도록 보장되어야 한다.
    이는 제공된 main()에서 process_wait()를 호출하여 시도하고 있으며, 자식 프로세스가 종료될 때까지 대기하고 자식 프로세스의 종료 상태를 반환한다.

  • 주어진 이름으로 새로운 파일을 생성하고, 파일 크기를 설정

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

    주어진 파일 이름과 초기 크기를 사용하여 새로운 파일을 생성하고, 성공하면 true, 실패하면 false를 반환한다.
    이 함수는 파일을 생성만 할 뿐, 파일을 열지는 않는다. 파일을 열기 위해서는 별도의 open 시스템 콜이 필요하다.

  • 주어진 파일을 삭제

    1
    
    bool remove(const char *file)
    

    파일을 삭제하는 함수로 파일 이름을 인자로 받아 삭제한다. 파일이 열려있더라도 삭제할 수 있으며, 삭제 후에도 파일은 열려있는 상태로 유지된다.

  • 주어진 파일을 엶

    1
    
    int open(const char *file)
    

    파일을 여는 함수로 파일 이름을 인자로 받아 해당 파일을 연다. 파일이 성공적으로 열리면 파일 디스크립터를 반환하고, 이는 0부터 시작하며 0, 1, 2는 이미 예약 되어있다.
    각각의 프로세스는 독립적인 파일 디스크립터 세트를 자기며, 자식 프로세스는 부모의 파일 디스크립터를 상속받는다.
    이 함수는 파일을 열기만 할 뿐 파일을 읽거나 쓰지는 않는다. 파일을 읽기 위해서는 별도의 read, write 시스템 콜을 사용해야 한다.
    만약 열 수 없는 파일이라면 -1을 리턴한다.
    단일 파일에 대한 다른 파일 디스크립터는 독립적인 콜을 통해 닫히고, 파일 포지션을 공유하지 않는다.

  • 열린 파일의 크기를 반환

    1
    
    int filesize(int fd)
    

    열린 파일의 크기를 바이트 단위로 반환하는 함수로 파일 디스크립터를 인자로 받아 해당 파일의 크기를 바이트 단위로 반환한다. 파일이 열려있는 상태에서만 사용하능하다.

  • 열린 파일에서 데이터를 읽어 버퍼에 저장

    1
    
    int read(int fd, void *buffer, unsigned size)
    

    파일 디스크립터에서 size 바이트를 읽어서 buffer에 저장하는 함수이다.
    이 함수는 파일 디스크립터를 인자로 받아 해당 파일에서 데이터를 읽어 몇 바이트를 읽었는지를 반환하고, 파일의 끝에 도달하면 0을 반환한다. 파일을 읽을 수 없다면(끝에 도달한 경우를 제외) -1을 반환한다.
    fd 0은 표준 입력으로 키보드에서 input_getc()를 통해 읽는다.

  • 버퍼의 데이터를 파일에 씀

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

    버퍼에서 size 바이트를 읽어서 fd에 데이터를 쓰는 함수이다.
    몇 바이트를 썼는지를 반환하고 실제로 쓰인 데이터는 몇 바이트를 읽지 못했을 수도 있기 때문에 size보다 작을 수도 있다.
    원래대로라면 파일의 끝을 넘어서 써도 파일이 연장되었겠지만, 여기선 구현이 되어 있지 않기 때문에 파일의 끝까지 최대한 적을 수 있는 만큼 적고 얼마나 적었는지를 반환하고, 쓸 수 없으면 0을 반환한다.
    fd 1은 표준 출력을 나타내는 파일 디스크립터로 이 함수를 호출하면 콘솔에 데이터가 출력된다. 콘솔에 출력되는 데이터(putbuf())는 한 번에 모두 출력되어야 하며, 크기가 큰 버퍼는 몇 백 바이트 이하로 나누는 것이 좋다.
    그렇지 않으면 다른 프로세스에서 출력된 텍스트가 섞여서 혼란스러울 수 있다.
    따라서 write 함수를 호출 할 때는 콘솔에 출력되는 데이터의 크기를 고려하여 적절한 크기의 버퍼를 사용해야 한다.

  • 파일의 읽기/쓰기 위치를 변경

    1
    
    void seek(int fd, unsigned position)
    

    fd의 다음 읽을 또는 쓸 바이트의 위치를 position으로 변경한다(position이 0이면 파일의 시작부이다).
    파일의 끝을 넘어서는 seek를 호출하더라도 에러가 발생하지 않고, 이후의 읽기 작업이 0바이트를 반환하여 파일의 끝임을 나타낸다.
    이후 쓰기 작업은 파일을 확장하고, 쓰이지 않은 빈 공간은 0으로 채워진다(프로젝트4 이전의 쓰기 작업은 파일의 크기가 고정되어 있기 때문에 파일의 크기를 넘어서면 에러가 발생함).
    파일 시스템에 구현되어 있는 기능이라 신경 안써도 된다.

  • 파일에서 다음으로 읽거나 쓸 위치를 반환

    1
    
    unsigned tell(int fd)
    

    이 함수는 파일의 시작부터 현재위치까지를 바이트 단위로 반환한다. 파일의 끝을 지나면 0을 반환한다.

  • 열린 파일 디스크립터를 닫음

    1
    
    void close(int fd)
    

    열린 파일 디스크립터를 닫는다. 프로스세가 종료될 때 모든 열린 파일 디스크립터가 자동으로 닫힌다.
    이 또한 파일 시스템에 구현되어 있으므로 신경 안써도 된다.

여러 사용자 프로세스가 시스템 콜을 수행할 수 있도록 시스템 콜을 동기화 하여야 한다.
제공된 파일 시스템 코드는 여러 쓰레드에서 동시에 콜하는 것은 안전하지 않다. 우리의 시스템 콜 구현은 파일 시스템 코드를 임계영역 처럼 취급해야 한다. process_exec()도 파일에 접근한다는 것을 기억할 것!
일단 지금으로선느 파일시스템 코드를 수정하는 것을 비추천한다.

각 시스템 콜에 유저 레벨의 함수를 제공하였다. 이는 유저 프로세스들이 C 프로그램에서 각 시스템 호출을 호출 하는 방법을 제공한다. 각각은 작은 인라인 어셈블리 코드를 사용하여 시스템 호출을 호출하고 반환값을 반환한다.

각 테스트 들은 어떻게든 시스템을 부시려고 노력할 테니 사용자 프로그램이 OS를 충돌시키거나 패닉, 실패 또는 오작동을 하지 않도록 구현하여야 한다.
만약 시스템 호출이 유효하지 않은 인자를 전달한다면, 에러를 반환하거나(리턴값이 존재하는 콜에 대해선), undefined value를 반환하거나, 프로세스를 종료하는 방법이 있다.

Process Termination Message

사용자 프로세스가 종료될 때는 프로세스의 이름과 종료 코드를 출력해야 한다(exit 호출, 다른이유 등).

1
printf("%s: exit(%d)\n", ...)

포맷은 위와 같고, 사용자 프로세스가 아닌 커널 쓰레드가 종료되거나 halt 시스템 콜이 호출 될 때는 이 메세지를 출력하면 안된다. 오직 사용자 프로세스가 종료될 때만 출력하도록 한다.

Deny Write on Executables

실행 파일에 대한 쓰기를 금지한다. 실행파일에 쓰기를 금지하는 코드를 추가해야 한다.
이는 변경 중인 코드를 프로세스가 실행하려하여 예상치 못한 결과를 야기할 수 있고, 특히 프로젝트 3 에서 가상 메모리를 적용하고 나면 특히 중요한 점이다.

file_deny_write()를 사용하여 열린 파일에 대한 쓰기를 방지할 수도 있고, file_allow_write()를 호출하면 다시 쓸 수 있다. 파일을 닫으면 쓰기가 다시 허용되기 때문에 프로세스가 작동하는 동안에는 파일을 열어놔야 쓰기를 막을 수 있다.

Extend File Descriptor(Extra)

pintOS에서 stdin, stdout, dup2 시스템 콜을 지원하도록 확장해보자.
현재는 stdin, stdout의 fd를 닫는 것이 금지되어 있다. 이를 가능하도록 하고 dup2 시스템 콜을 구현 하라는 것이다. 즉 프로세스는 stdin이 닫혔을 때 input 을 읽으면 안되고, stdout이 닫힌다면 출력을 하면 안된다.

이는 해당 과제를 다 수행하고 시간이 남으면 더 자세히 보도록 하자.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.