PintOS project 2 :: User Program
Index
- Argument Passing : user program의 인자를 함수에 전달하는 과정을 만든다.
- User Memory: user의 virtual address space에 데이터를 read/write 하는 방법을 제공한다.
- System Calls: user program이 kernel mode에서만 허용되는 것들을 하기 위해 호출하는 인터페이스를 만든다.
- Process Termination Messages: user process가 종료시 프로세스 이름과 exit 코드를 제공한다.
- Denying Writes to Executabels: 실행파일로 사용 중인 파일에 대한 쓰기를 거부하
- Extend File Descriptor(Extra): 파일 디스크립터를 확장하기
Introduction
https://casys-kaist.github.io/pintos-kaist/project2/introduction.html
it's time to start working on the parts of the system that allow running user programs...
I/O / interactivity 가 구현되지 않았으니 구현하자.
이 프로젝트 이전까지는 운영 체제 커널의 일부였으며 시스템에 접근하는 '특권'을 가졌다.
여기서부터는 OS위의 user program을 실행하게 되기에 특권 사용이 불가능하다.
동기화와 가상주소(virtual address)를 사전 필독 요망.
Argument Passing
Setup the argument for user program in process_exec()
arugment passing이란?
user program을 실행하기 전에 프로그램이 필요한 정보/데이터를 해당 프로그램에게 전달하는 과정.
현재 프로젝트 2에서 pintOS에서는 테스트케이스를 돌릴 때 process_exec() 를 통해 user program의 process가 실행되는데 여기에 인자가 전달되지 않고 있다. 따라서 아래의 x86-64 의 호출 컨벤션에 맞추어 제대로 argument를 전달할 수 있도록 코드를 짜야 한다.
x86-64 Calling Convention
- user level application(user의 프로그램)은 %rdi, %rsi, %rdx, %rcx, %r8 and %r9.순으로 integer register를 사용한다.
- caller(호출하는 함수)는 다음 명령어의 주소(return address)를 스택에 넣어두고, callee(수신자)의 명령어로 jump한다.
- 호출자가 실행된다.
- 호출자가 return value를 가진다면 rax에 저장한다.
- 호출자는 2에서 저장한 return address를 pop하고, RET 명령어를 이용하여 지정된 위치로 점프하여 리턴한다.
Program Startup Details
/bin/ls -l foo bar 예시 명령어의 인자(arguments)를 처리하는 코드를 구현해야한다!
[예시] command(명령어)가 /bin/ls -l foo bar 일 때
- command를 단어로 분리한다. (띄어쓰기를 기준으로 자른다.) /bin/ls, -l, foo, bar
- [C 상식?] command 를 칠 때, 첫번째 word는 실행할 프로그램의 이름이고 그 이후부터 argv[argc-1]까지는 프로그램에 전달되는 인자들이다.
- 단어들을 stack의 top에 둔다. (pointer를 통해서 참조될 것이라 순서는 상관 없다.)
- 각 문자열과 널 포인터 센티널(null pointer sentinel) 오른쪽에서 왼쪽 순서로 stack에 넣는다(argv의 요소들)
- argv[0]이 가상 주소의 가장 아래에 오도록 하려고 오른쪽에서 왼쪽 순서로 stack에 넣는다.
- 널 포인터 센티널은 argv[argc]가 null pointer인지 확인한다.
- [c 상식?] argc 는 main함수로 전달되는 인자의 갯수를 나타내는 변수, argv는 인자의 값을 나타내는 문자열 배열의 포인터이다.
- null pointer sentinel :argv 배열의 끝을 알리는 null pointer로, 이것이 있어야 끝이 어딘지를 프로그램이 알 수 있다.
- push전에 stack pointer를 8의 배수로 반올림해 정렬한다.
- 8바이트 간격(워드 단위)으로 배치align되었기 때문인 것 같다. 이걸 맞추지 않으면, 즉 비정렬 접근으로 접근할 시 cpu는 데이터를 가져오기 위해 복잡한 과정을 거치고(불필요한 데이터 엑세스 발생) 성능이 낮아지므로 그걸 방지하기 위함이다. align과 성능에 대한 외부 포스트)
- 이 표의 이 부분까지가 3번까지 했을 때의 상태이다.
- %rsi 를 argv으로 지정하고 %rdi 를 argc로 설정한다
- 정수 레지스터에서, 일반적으로 %rsi는 두번째 인자가, %rdi는 첫번째 인자가 전해진다.
- 깃북에서 %rsi 이 가리키는 것이 argv인데(Point %rsi to argv) , 이것은 argv[0]의 주소(char *argv[])와도 같다.
- 즉, %rsi에는 프로그램의 이름이 들어간다.
- %rdi가 가리키는 건 argc, 인자의 갯수가 들어간다.
- 아, 이게 register에 값을 담는 현장이구나!
- 정수 레지스터에서, 일반적으로 %rsi는 두번째 인자가, %rdi는 첫번째 인자가 전해진다.
- fake return address를 push한다. (동일한 스택 프레임을 맞추기 위해서)
- stack이 점점 아래로 자라난다! 그리고 오른쪽의 word부터 stack에 넣었더니, 프로그램의 이름이 되는 게 아랫부분에 놓이게 되었다. padding을 준 뒤에 위에 쌓았던 것들의 주소를 담는다. 위 예시에서는 스택 포인터는 맨 마지막 return address의 주소로 초기화 될 것이다.
- hex_dump()를 사용하면 이런 식으로, 잘 들어갔는지 디버깅 결과를 볼 수 있다.
Putting 'args-multiple' into the file system...
Executing 'args-multiple some arguments for you!':
000000004747ffa0 00 00 00 00 00 00 00 00-da ff 47 47 00 00 00 00 |..........GG....|
000000004747ffb0 e8 ff 47 47 00 00 00 00-ed ff 47 47 00 00 00 00 |..GG......GG....|
000000004747ffc0 f7 ff 47 47 00 00 00 00-fb ff 47 47 00 00 00 00 |..GG......GG....|
000000004747ffd0 00 00 00 00 00 00 00 00-00 00 61 72 67 73 2d 6d |..........args-m|
000000004747ffe0 75 6c 74 69 70 6c 65 00-73 6f 6d 65 00 61 72 67 |ultiple.some.arg|
000000004747fff0 75 6d 65 6e 74 73 00 66-6f 72 00 79 6f 75 21 00 |uments.for.you!.|
args-multiple s: exit(-1)
pintOS 부팅부터 process_exec 까지
최대한 맥락을 이해하려고 노력했다...
pintOS 부팅
pintos -v -k -T 60 -m 20 --fs-disk=10 -p tests/userprog/args-multiple:args-multiple --swap-disk=4 -- -q -f run 'args-multiple some arguments for you!'
qemu-system-x86_64 : qemu 에뮬레이터 실행
pintOS를 띄우기 에뮬레이터로, 리눅스 위에 있다.
Kernel command line: -q -f put args-multiple run 'args-multiple some arguments for you!' : main 실행 (init.c)
init.c의 main에서 pintOS 메인 프로그램이 실행된다. 이 안의 read_command_line 함수에서 kernel command line을 출력한다. pintOS를 테스트하기 위한 명령어를 잘라내는 부분 같다.
thread_init
main 이라는 이름의 thread를 실행
이하 각종 init (초기화) 함수들
console, malloc, paging, interrupt, timer ....
운영체제가 booting될 때 미리 세팅하는 것들을 이 때 초기화한다.
run_caction (argv) -> run_task
여기서 Executing 으로 출력되는 부분이 나온다.
Executing 'args-multiple some arguments for you!' 을 보면, 이 앞의 모든 command들이 앞에서 다 쓰였다.
userprog는 process_wait이 걸린 채인데,
process_create_initd() -> thread_create(), by initd()
tid_t process_create_initd (const char *file_name);
thread_create (file_name, PRI_DEFAULT, initd, fn_copy)
static void initd (void *f_name)
initd 의 주석을 보면 첫 번째 user process를 launch하는 스레드 함수라고 한다. 왜냐하면 이 다음부터는 fork를 사용하기 때문이다. 최초 부팅 시에는 실행중인 process가 존재하지 않으므로 thread_create()에 필요한 함수 인자로 init()을 전달해 최초로 proces_exec을 통해 프로세스를 실행하고, 이후에는 thread를 create하거나 process_fork, system call 의 fork로 clone하는 것 같다. 사용 빈도를 검색해보면 실제로 process_exec는 최초 부팅 시와 syscall 중 exec에서 사용된다.
process_exec
process_exec (void *f_name)
바로 여기서 이론으로만 배웠던, user stack을 생성하고 디스크의 정보를 스택에 올리는 load가 발생하는 것 같다. 새 프로세스에 argument을 전달하는 곳이기도 하다. 또한 context switching을 위한 사전준비를 한 뒤 do_iret을 호출하여 process를 전환한다.
load (file_name, &_if);
이 안에서 user stack이 setup된 뒤, 우리가 구현해야 하는 stack을 쌓는 코드로 들어온 argument들을 위의 예시처럼 분해하고 컨벤션에 맞춰 _if (인터럽트 프레임) 안에 넣어야 한다. 이진 파일을 디스크에서 메모리로 올리는 곳이다.
do_iret
이곳에서 문맥 전환이 이루어진다. load에서 정보를 입력해둔 interrupt frame의 주소가 do_iret의 인자로 들어가는데, 이것은 do_iret 내부에서 현재 스레드의 정보가 저장된 뒤 범용 레지스터에 이 인터럽트 프레임의 정보들이 입력될 것이다.
Imprements
process_exec
/* Switch the current execution context to the f_name.
* Returns -1 on fail. */
int
process_exec (void *f_name) {
char *file_name = (char *)palloc_get_page(PAL_ZERO);
strlcpy(file_name, (char *)f_name, strlen(f_name) + 1);
bool success;
/* We cannot use the intr_frame in the thread structure.
* This is because when current thread rescheduled,
* it stores the execution information to the member. */
struct intr_frame _if;
_if.ds = _if.es = _if.ss = SEL_UDSEG;
_if.cs = SEL_UCSEG;
_if.eflags = FLAG_IF | FLAG_MBS;
/* We first kill the current context */
process_cleanup ();
/* project 2: argument passing */
char *argv[MAX_ARGS];
int argc = 0;
tokenizer(file_name, argv, &argc);
/* project 2: argument passing */
/* And then load the binary */
success = load (file_name, &_if);
/* If load failed, quit. */
if (!success) {
palloc_free_page (file_name);
return -1;
}
/* project 2: argument passing */
stacker(argv, argc, &_if);
_if.R.rdi = argc;
_if.R.rsi = _if.rsp + 8;
// hex_dump(_if.rsp, _if.rsp, USER_STACK-_if.rsp, true);
/* project 2: argument passing */
palloc_free_page (file_name);
/* Start switched process. */
do_iret (&_if);
NOT_REACHED ();
}
이 함수를 호출하는 구간에서 filename을 저장할 공간을 마련하는 방식으로도 구현할 수 있다. 나는 process_exec내부에서 메모리를 할당하고 복사해 사용할 준비를 했다.
뒤에 바로 이어서 나오지만, 이 함수에 전달된 인자인 f_name 데이터는 일종의 공유 자원이다. 이 이름은 뒤에 나오는 load()에서도 사용하지만, 이 함수(exec)을 호출할 때 사용된다.
그런데 이 뒤에서, 우리는 user stack에 위에적어둔 x86-64 호출 컨벤션을 따라서 넣을 때 이 데이터를 조각조각 자르게(tokenize)하게 된다. 따라서 만약 이 이름을 원본(주소에 있는) 그대로 사용한다면 race condition이 발생할 수 있다.
그렇기 때문에 strlcpy 함수를 사용해 인자로 받은 주소에 있는 데이터를 복사해야 한다. 여기서 내가 c에 익숙하지 않아 놓친 부분이 있다. filename을 따로 선언하여 palloc_get_page로 메모리를 할당해주어야 한다. 그렇지 않으면 터져버린다....
void tokenizer(char *file_name, char **argv, int *argc) {
char *token, *save_ptr;
token = strtok_r(file_name, " ", &save_ptr);
while (token != NULL) {
argv[*argc] = token;
token = strtok_r(NULL, " ", &save_ptr);
(*argc)++;
}
}
pintOS에서 제공하는 strtok_r 함수의 주석에 친절하게 사용 방법이 적혀 있다. 그것을 활용하여 토큰화하여 잘라서 인자로 받은 주소에 저장한다.
void stacker(char **argv, int argc, struct intr_frame *if_) {
/* stacking variables */
char *addrs[MAX_ARGS];
int i = argc-1;
while (i >= 0) {
int arglen = strlen(argv[i]);
if_->rsp -= arglen + 1;
strlcpy(if_->rsp, argv[i], arglen + 1);
addrs[i--] = if_->rsp;
}
/* padding aligning */
while (if_->rsp % 8 != 0) {
if_->rsp--;
*(uint8_t *)if_->rsp = 0;
}
/* null pointer sentiel */
if_->rsp -= 8;
*(uint64_t *)if_->rsp = 0;
/* stacking addresses */
i = argc-1;
while (i >= 0) {
if_->rsp -= 8;
*(uint64_t *)if_->rsp = (uint64_t)addrs[i--];
}
/* fake return address */
if_->rsp -= 8;
*(uint64_t *)if_->rsp = 0;
}
token 화 해서 저장해둔 정보를 기반으로 호출 컨벤션과 요구 조건에 맞추어 구현한다.
stack push시 역순으로 push한다. (stack은 아래 방향으로 성장한다!)
%8은 align(성능 향상) 을 맞추려 반올림할 때 사용하고, 마지막에 0을 넣어 준다.(문자열 종료를 알림)
if_rsp, 즉 스텍 포인터를 -8씩 내리면서 addr의 주소를 삽입한다.(위의 표의 하단 노란색 부분이 이렇게 삽입된다)
fake address용으로 또다시 -8만큼 내리고 0을 넣는다. process_exec는 처음 부팅 시 프로세스 생성 시 사용되는 함수이기 때문에, 이 다음 명령어가 없어 리턴할 주소가 없다.(맨 땅에 집 짓고 있는 상황이다. 이 일이 끝나면 돌아갈 자리를 push해서 넣어줘야 하는데 지금 아무것도 없어서 갈 곳이 없다.)
process_wait
무한 루프로 wait 흉내를 내 둔다.
User memory access
syscall을 구현하려면 사용자 가상 주소 공간(user vitual address space)에서 데이터를 읽고 써야 하는데 만약 시스템이 엉뚱한 주소를 가지고 와서 호출하면 OS는 어떻게 해야 할까? 접근조차 못 하게 막아야 한다. 이건 향후 virtual memory에서 더 깊게 다룰 것 같은데, 일단은 user space를 넘는, 즉 stack pointer의 주소가 잘못되어 유저가 접근할 수 없는 공간(메모리)에 접근했을 때 프로그램을 종료 시켜야 한다.
Bad Pointers: NULL, pointer into kernel memory
- Null pointer
- Kernel memory 영역의 주소
- 해당 영역 중 하나에 부분적으로 블록을 제공함
/include/threads/vaddr.h 를 살펴보면 user stack의 주소 위치와 특정 v(irtual) addr(ess)가 맞는지 등을 판별하는 매크로들이 이미 구현되어있다! 이곳의 is)kernel)vaddr(vaddr)을 본다면.... KERN_BASE보다 크거나 같은 주소일 때(즉, kernel virtual memory 영역을 가리킬 때) user의 addr가 아니라고 판정한다. 여기에 null pointer가 주어졌을 때도 user program을 exit( -1 )시켜야 한다. (싷행중인 프로세스&스레드를 종료한다) 세번째 조건은 pml4_get_page으로 판별하는 것 같다. 이건 다음에 포스팅하기로.
void check_address(void *addr) {
struct thread *t = thread_current();
if(!is_user_vaddr(addr) || addr == NULL ) {
//printf("[check addr] fail in 1 or 2\n");
exit(-1);
}
// if (!is_user_vaddr(addr) || addr == NULL || pml4_get_page(t->pml4 , addr) == NULL) {
// printf("[check addr] addr is failed!\n");
// exit(-1);
// }
}
'공부기록 > OS' 카테고리의 다른 글
[TIL][Setting] ubuntu Setting for PintOS (0) | 2023.12.18 |
---|---|
[WIL / PintOS] [Project 2] System Call | others (0) | 2023.12.16 |
[WIL / PintOS] [Project 2] User Program | Keywords (0) | 2023.12.14 |
[TIL / WIL] [Project 1] MLFQS + Advanced Scheduler in PintOS (0) | 2023.12.04 |
[TIL][Project 1] Priority Donation (0) | 2023.12.04 |