pintos를 유랑하는 히치하이커를 위한 안내서 (Project 2)
0. 큰 그림
현재 핀토스 코드는 user program을 load, run할 수는 있지만, user program에 인자를 함께 넘긴달지, file을 열고 읽는 일이 불가능한 상태입니다. 커널 접근을 지원하는 system call 함수가 짜여있지 않기 때문입니다. 이번 프로젝트에서는 userprog
폴더 안에 새로운 함수를 만들어, argument passing, system call 등의 기능을 구현할 것입니다.
먼저 Project 2의 요구사항을 더 잘 이해하기 위해서 알아야하는 것들을 정리해보겠습니다. Pintos 아키텍쳐 내에서 process는 어떤 과정을 거쳐 execute 되는지, system call은 어떻게 불리는지, file system은 어떤 구조를 가지고 있는지 알아보겠습니다.
1) Process execution 과정 추적하기
먼저 init.c : main()
함수로부터 시작합니다. main() 함수 내부의 run_actions()은 차례로 run_task()을 호출하고, 이 함수는 다시 process_wait(process_execute (task))를 호출하는 식입니다. 현재 process_wait을 호출하는 스레드는 커널스레드로, task를 수행하는 자식 스레드를 만들고 자식 프로세스가 종료될 때까지 wait합니다. process_wait은 기존 pintos에는 구현되어있지 않습니다. 추후 우리가 구현해야할 부분입니다.
process_execute(const char *file_name)
는 새로운 thread를 생성해 함수 인자로 넘겨진 file_name에 해당하는 유저 프로그램을 로드하고 실행시키는 함수입니다. file_name은 커맨드 라인을 통해 입력된 string이고, (뒤에서 서술할) argument passing을 위해서는 string을 parsing하고 스택에 PUSH해야 합니다. race condition을 막기 위해 file_name을 fn_copy라는 값으로 strlcpy를 진행하고, file_name과 fn_copy를 동시에 인자로 넘겨 thread_create(file_name, PRI_DEFAULT, start_process, fn_copy)함수를 호출합니다.
thread_create(const char *name, int priority, thread_func *function, void *aux)
함수는 인자로 넘겨진 function(aux)를 실행하는 name이라는 이름의 kernel thread를 만들고 run queue에 추가하는 함수입니다. process_execute에서 함수 호출 시 인자로 넘긴 file_name은 kernel thread의 이름을 설정하는데 사용되고, fn_copy는 start_process()의 인자로 넘어갑니다.
start_process()
함수는 유저 프로세스를 메모리에 로드하고 시작시키는 함수입니다. 함수 내부에서는 interrupt stack frame(if_)을 선언하고 적절히 초기화합니다. 그리고 실행하고자 하는 process의 이름과 interrupt stack frame의 eip, esp를 함께 load()의 인자로 넘깁니다. load(file_name, &if_.eip, &if_esp);
load (const char *file_name, void (**eip) (void), void **esp)
함수는 file_name이라는 이름의 ELF 바이너리 파일을 현재 메모리에 로드하는 함수입니다. load함수는 먼저 현재 실행중인 thread의 page directory를 초기화하고, file_name executable 파일을 오픈합니다. 파일이 오류가 없는 파일인지 검사한 이후, validity가 확인되면 setup_stack 함수를 통해 user virtual memory에 스택을 생성합니다. 인자로 전달된 interrupt stack frame eip와 esp에는 각각 executable의 entry point(프로세스의 시작 코드), stack pointer의 시작 위치가 저장됩니다. load 가 성공적으로 이루어지면 true를, 실패할 시에는 false를 리턴합니다.
start_process(void *file_name_)
로 다시 돌아온 코드는 load가 실패한 경우 thread_exit ()을 호출해 현재 thread를 삭제합니다. load가 성공한 경우에는 어셈블리 명령어를 수행합니다. asm volatile (“movl %0, %%esp; jmp intr_exit” : : “g” (&if_) : “memory”)는 유저 프로세스를 실행시키는데, 이는 intr_exit 함수를 이용해 interrupt를 마치고 return한 것처럼 행동했기 때문입니다..
intr_exit
함수는 intr_stubs.S 파일 내에 어셈블리어로 구현되어 있으며, caller의 레지스터를 복구하고 intr_frame의 vec_no, error code, frame_pointer member 를 버리고(discard) caller로 return하는 코드로 구성되어 있습니다.
2) System call 과정 추적하기
제공받은 pintos는 현재 system call handler
가 구현되어 있지 않아 시스템 콜이 호출될 수 없어서 user program이 정상적으로 동작하지 않습니다.
System call이란 user program이 작동하는데 있어, 커널 기능을 사용할 수 있도록 운영 체제가 제공하는 인터페이스입니다. 예를 들어, 특정 user program이 작동하면서 메모리 읽기 및 쓰기와 같은 커널 기능에 대한 request를 보내면, 커널 영역에서 시스템 콜이 실행되어 처리 후 결과 값을 넘겨주는 식입니다. 즉, 프로젝트1에서 외부 timer 및 I/O device로부터의 interrupt를 처리하는 것과 같이, 소프트웨어 내부에서 발생하는 interrupt 및 exception를 처리하기 위한 기능인 것입니다.
Pintos 내 시스템 콜 호출 기능에 있어서 현재 구현된 부분은 threads/init.c
에서 실행되는 syscall_init()
함수입니다. syscall_init() 함수는 userprog/syscall.c
내 구현되어 있으며, syscall_handler() 함수를 syscall handler로서 세팅해 줍니다. 현재 syscall_handler() 함수는 “system call!”을 출력한 후 thread_exit() 을 호출하도록 구현되어 있다. 하라는 핸들링은 안하고 thread를 종료시켜버리는 것이죠.
유저 프로세스의 시스템 콜 함수들은 lib/user/syscall.c
에 정의되어 있습니다. 이 파일에 정의된 시스템콜 함수는 아규먼트의 개수에 따라 각각 syscall0, syscall1, syscall2, syscall3를 호출하고, 아규먼트와 시스템 콜 넘버를 유저 스택에 push한 이후, int $0x30 신호로 인터럽트를 발생시켜 커널의 syscall_handler()를 실행시킵니다. (여기에서 int는 정수형 데이터타입이 아닌, inturrupt의 약자입니다.) syscall_handler()는 시스템 콜 넘버에 해당하는 시스템 콜을 호출합니다. 시스템 콜 넘버는 lib/syscall-nr.h
에 enum
타입으로 정의되어 있습니다.
System call이 처리되는 동안 커널에서 유저 프로세스로 접근이 필요합니다. 이 과정에서 유저 프로세스가 넘겨준 address가 유저 영역을 벗어난 주소인지 확인해야합니다. 이를 이해하기 위해 virtual address 개념에 대해 알아야 합니다. 유저 프로그램 동작을 위해 process_execute() 함수가 실행되면서 프로세스가 실행될 자식 thread가 생성되고, pagedir_create() 함수는 자식 thread 내 pagedir에 유저 프로세스 page를 생성합니다. 그리고 setup_stack() 함수 내 palloc_get_page()을 통해 유저 스택을 할당받습니다. 이때 반환되는 주소는 virtual kernel address로, kpage 변수에 저장됩니다. pintos는 physical memory로 직접 접근을 허용하지 않기 때문에, virtual kernel memory를 physical memory 에 대응시켜서 사용해야 합니다.
그리고 install_page(upage, kpage, writable) 함수가 실행되면서 유저 프로세스가 넘긴 주소가 유효한 지 검증합니다. 함수의 인자 중 upage는 user virtual address로 upage는 kpage가 가리키는 page가 user virtual memory에서 어느 address에 대응할지 지정해 줍니다. install_page()함수 내의 pagedir_set_page가 thead structure 의 pagedir 에 지금 매핑되고 있는 page의 정보를 입력해 줍니다. 이제 어떤 user address가 유효한 지 검증할 때, pagedir 내에서 검색하면 해당 thread에 할당된 page 에 접근하고 있는 것이 맞는지 확인할 수 있습니다.
3) File system 분석하기
핀토스에서 유저 프로그램은 파일 시스템을 통해 로드됩니다. pintos에는 간단한 형태의 파일 시스템이 filesys 폴더 안에 구현되어 있습니다. 그 중에서도 filesys.h와 file.h의 함수를 이해하고 적재적소에 사용하는 것이 중요합니다.
핀토스 가이드에서는 이 파일 시스템의 한계에 대해서도 언급하고 있는데요. 먼저 internal synchronization이 구현되어 있지 않아, 동시에 여러 프로세스가 접근할 시에 예상치 못한 에러가 날 수 있고, 파일 사이즈가 생성 시간에 고정되어 있고, 루트 디렉토리가 파일 형태로 표현되어 있어 생성할 수 있는 파일의 수가 제한되어 있습니다. 또, 서브디렉토리 생성이 되지 않고, 파일 이름이 14자를 넘어갈 수 없다는 점, 파일 시스템 repair tool이 없다는 점 등이 한계입니다. 단, 프로젝트 2를 진행하는 데에는 무리가 없습니다.
1. Process termination message
exit()
system call이 호출(유저 프로세스가 직접 호출, 유저 프로세스가 정상적으로 종료)될 때 exit된 프로세스의 이름과 exit code를 출력하는 것이 목표입니다. 유저 프로세스가 int $0x30 코드를 통해 system call을 발생시키면, 커널에서 0x30 주소가 가리키는 syscall_handler() 함수가 실행됩니다.
syscall_handler()
함수에 인자로 들어오는 intr_frame은 system call 발생 시점의 실행 정보를 담고 있는데, 이 중 esp
를 통해 유저 스택에 존재하는 인자들을 4byte 크기로 꺼내어 읽을 수 있습니다. 따라서 유저 스택에서 system call number(SYS_EXIT)와 arg(status)를 얻을 수 있는 것이다. 프로세스 이름은 exit()함수를 호출한 thread에 저장되어 있으므로 current_thraed() -> name을 통해 얻을 수 있습니다. 이 정보를 바탕으로 termination message를 출력하면 됩니다.
혹은 thread.h::struct thread
내부에 process 이름과 exit code를 저장하는 변수를 만들어 userprog/syscall.c::sys_exit() 함수(exit system call)이 호출될 때 exit되는 프로세스의 이름과 exit code를 출력하도록 하는 방법도 있습니다.
2. Argument passing
process_execute()
에서 thread_create()
함수를 호출하기 이전, start_process()
에서 load()
함수를 호출하기 이전에, 인자로 넘긴 file_name 스트링을 스페이스바를 delimiter로 파싱합니다. 파싱은 C언어 라이브러리가 제공하는 string.c::strtok_r()
함수를 사용합니다.
파싱된 아규먼트를 인자로 passing하기 위해서는 유저 스택에 삽입해야 합니다. 기존 pintos는 유저 스택에 아규먼트를 삽입하는 코드가 구현되어있지 않으므로, 별도의 함수로 구현해야 한다. load() 함수에서 빈 스택을 가리키도록 초기화된 esp(=stack pointer)를 통해 접근한 유저 스택에, 파싱된 아규먼트의 문자열을 right-to-left order로 스택에 PUSH합니다. 예를 들어 f(1, 2, 3)을 호출하면 stack에는 다음과 같이 들어가는 것이죠.
/bin/ls -l foo bar
라는 명령어를 execute한다고 해봅시다. /bin/ls는 실행하는 함수의 이름이 될 것이고, -l, foo, bar는 argument가 될 것입니다.
- 먼저 문자열을 파싱합니다. ‘/bin/ls’, ‘-l’, ‘foo’, ‘bar’ 순서에 상관없이 스택 위에 넣어둡니다.
- argv[argc]를 먼저 push합니다. argv[argc]는 무조건 널포인터겠죠. index에 벗어난 위치를 참조하고 있으니까요. 그리고 오른쪽 아규먼트부터 쭉쭉 push합니다.
- 그리고 argv와 argc를 차례로 푸시합니다. argv에는 argv[0]의 주소값이 담겨있고, argc에는 아규먼트의 개수가 담겨있습니다.
- 그리고 fake return address를 push함. 첫 함수는 return 할 일이 없지만 그래도 형식을 맞추기 위해 넣습니다.
3. System calls
System call을 구현하기 전에, 유저 프로세스로부터 넘어온 포인터의 주소가 유저 영역을 가리키는지 검증하는 코드를 구현해야합니다. thread의 pagedir를 살펴보면, 넘어온 포인터의 주소가 유저 영역을 가리키는지 검사할 수 있습니다. 이 함수는 파라미터로 넘겨진 addr이 null pointer이거나, kernel virtual address space를 가리키거나, unmapped virtual memory이면 sys_exit(-1) 함수를 호출해 종료시킵니다.
userprog/syscall.c::syscall_handler()는 interrupt frame의 esp로부터 얻어낸 system call number를 이용해 switch문으로 어떤 system call 함수를 호출할 것인지 결정합니다. 그리고 lib/syscall-nr.h 파일 안에 enum 타입 system call number가 정리된 대로 syscall 함수를 별도로 구현하면 됩니다. syscall_handler()에서 구현한 system call 함수를 호출할 때 esp로부터 argument를 받아와야 하는데, 이때마다 구현한 check_address_validation
함수를 호출해 validate한 주소인지 확인합니다.
이 과정에서 process_wait()함수를 호출한 부모 스레드를 위해 synchronization을 진행해야합니다. 이를 위해 child가 load될 때, exit될 때 struct 변수로 가지고 있는 file descriptor를 모두 초기화하거나 free할 수 있도록 lock을 통해 관리해주어야 합니다.
4. Denying Writes to Executable
프로그램은 디스크에 binary executable 파일로 저장되어 있습니다. load() 함수는 프로그램을 메모리에 로드하는데, 운영체제는 실행중인 유저 프로그램의 데이터가 변경되지 못하도록 막아야 합니다.
이를 위해 start_process()가 load()함수를 호출하기 이전에, 핀토스가 제공하는 file_deny_write()
함수를 배치해 파일에 write하지 못하게 막아야 합니다. load()가 끝나고 file_close()된 이후, file_allow_write()
를 삽입해 write 가능하도록 변경합니다.
위 내용은 2020 Fall, POSTECH CSED312 운영체제 수업에서 진행한 내용을 바탕으로 하였으며, 수업자료와 Stanford Pintos Guide에 기초를 두고 있습니다.