https://github.com/Blue-club/pintos_5/issues/16
1. Argument Passing
process_exec() 함수는 User program에서 입력한 명령을 수행할 수 있는 프로그램(=process)을 메모리에 적재하고 실행한다. 현재 문자열은 명령어에 대한 인자 passing이 제공되지 않은 상태이므로, 이를 구현해야 한다.
즉, 문장을 공백 기준으로 나누어 단어를 파싱하는 것이다.
예를 들어 bin/ls -l foo bar 라는 문장이 들어왔다면 첫번째 단어(bin/ls)는 프로그램 이름이고, l, foo, bar는 각각 인자가 된다.
x86-64 Calling Convention
Calling convention이란 서브루틴이나 함수가 호출자로부터 매개 변수를 받는 방법이나 결과를 반환하는 방법에 대해 다루는 것을 말한다. 우리가 코딩할 때, 따르는 camel case나 snake case 등도 이러한 컨벤션이다.
x86-64 컨벤션에서 정의하는 내용은 다음과 같다.
- User level application은 다음 시퀀스를 정리하기 위해 다음 정수 레지스터로 사용한다.
%rdi, %rsi, %rdx, %rcx, %r8 and %r9 - 만약 그 이상의 인자가 들어오면 스택으로 들어가서, callee(호출 당함)의 첫번째 instruction으로 이동한다.
- callee가 실행한다.
- callee의 반환값이 있다면 RAX 레지스터에 저장된다.
- callee는 stack에서 반환 주소를 꺼내고, x86-64 RET 명령으로 지정된 위치로 이동한다.
예를 들어, f()가 3개의 정수형 인자를 받는다면 f(1, 2, 3)이 된다.
Program Startup Details
User program 진입점은 lib/user/entry.c의 _start() 에서부터 시작된다.
void
_start (int argc, char *argv[]) {
exit (main (argc, argv));
}
커널은 사용자 프로그램 실행 전에 초기 함수에 대한 인수를 레지스터에 넣어준다.
/bin/ls -l foo bar 라는 커맨드를 어떻게 다루는지 살펴보자.
- 커맨드를 단어로 분리한다.
/bin/ls, -l, foo, bar - 단어들을 스택의 최상단에 올린다. pointer로 참조되기 때문에 순서는 중요하지 않다.
- 각 문자열의 주소와 null 포인터 센티널(반복 탐색을 끝내는 값) 스택의 오른쪽에서 왼쪽으로 넣는다.
- 1번에서 분리한 /bin/ls, -l, foo, bar 이 스택에 들어간다.
- null pointer sentinel은 argv[argc]가 널 포인터가 된다.
- 이 부분은 C언어의 표준적인 관례(ISO/IEC 9899:1999)이다. 표준에 따르면 argc 값이 1보다 작을 때는 argv[0] 만 존재하고 argc 값이 1보다 크거나 같을 때 argv[0] 부터 argv[argc-1] 까지 인수가 존재한다. 이때는 null 포인터로 설정되어야 한다.
- argv[0]은 가장 낮은 가상 주소에 있도록 보장한다.
- word는 정렬 액세스가 정렬되지 않은 액세스보다 빠르기 때문에 스택 포인터를 8의 배수를 더해서 8의 배수가 되도록한다. (malloc을 떠올리시오!)
- %rsi가 argv(argv[0]의 주소0)를 가리키도록 하고, %rdi는 argc를 가리키도록 한다.
- 가짜 return address를 넣어준다. entry function을 절대 반환될 수 없기 때문에, stack frame은 다른 함수와 동일한 구조를 가져가야 한다. 즉, 가짜 주소는 entry function의 스택 프레임이 다른 함수의 스택 프레임과 형태를 갖도록 추가한 요소이다.
그럼 /bin/ls -l foo bar stack에 어떻게 담기는지 살펴보자.
다음 표는 사용자 프로그램이 시작되기 직전의 스택 상태와 관련 레지스터를 보여준다.
stack pointer는 0x4747ffb8에서 초기화되었고, USER_STACK의 초기값은 include/threads/vaddr.h에 담겨있다.
userprog/process.c의 setup_stack 함수를 확인해보면, USER_STACK 주소에서 0으로 채워진 최소 스택을 만드는 것을 알 수 있다.
/* Create a minimal stack by mapping a zeroed page at the USER_STACK */
static bool
setup_stack (struct intr_frame *if_) {
uint8_t *kpage;
bool success = false;
kpage = palloc_get_page (PAL_USER | PAL_ZERO);
if (kpage != NULL) {
success = install_page (((uint8_t *) USER_STACK) - PGSIZE, kpage, true);
if (success)
if_->rsp = USER_STACK;
else
palloc_free_page (kpage);
}
return success;
}
더불어 setup_stack 함수는 process의 load 함수에서 실행된다.
2. 과제 구현
위에서 참고한 내용을 바탕으로 구현을 시작한다.
process_exec(userprog/process.c)
process_exec 함수는 file_name과 _if를 현재 프로세스에 로드한다.
자세한 내용은 이전 포스팅 참고!✔️
[Project2] User Program(2) - Passing the argument and creating a thread: process_exec 함수의 뿌리를 찾아서...
userprog/process.c
/* Switch the current execution context to the f_name.
* Returns -1 on fail. */
int
process_exec (void *f_name) {
char *file_name = f_name;
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 */
// 현재 프로세스에 할당된 page directory를 지운다.
process_cleanup ();
/* And then load the binary */
// load() 함수에서 file_nmae을 명령과 인자로 parsing하는 작업을 진행했다.
success = load (file_name, &_if);
/* If load failed, quit. */
// load에서 page allocation한 것을 free해준다.
palloc_free_page (file_name);
if (!success)
return -1;
/* Start switched process. */
// 생성된 프로세스로 context switching한다.
do_iret (&_if);
NOT_REACHED ();
}
struct intr_frame(userprog/process.c)
Interrupt Frame은 인터럽트와 같은 요청이 들어왔을 때, 기존에 실행 중이던 context(레지스터 값 포함)를 스택에 저장하기 위한 구조체를 말한다.
구조체를 살펴보면, 멤버 구조체인 gp_Register R과 여러 정수형 인자들을 갖는다.
process_exec 함수는 load후 page allocation을 free한 이후에 do_iret 함수로 생성된 프로세스를 context switching하는데, interrupt frame과 do_iret 함수에 자세히 알고 싶다면 다음 글을 추천한다.
[OS] pintos의 interrupt frame 이란 + do_iret 함수가 하는 일
load(userprog/process.c)
load 함수에서는 file_name을 intr_frame에 parsing한다.
userprog/process.c
static bool
load (const char *file_name, struct intr_frame *if_) {
struct thread *t = thread_current ();
struct ELF ehdr;
struct file *file = NULL;
off_t file_ofs;
bool success = false;
int i;
/* Project2: Command Line Parsing */
// file_name이 const 이므로 변경할 수 없기 때문에, 복사해준다.
char *copy_filename;
copy_filename = palloc_get_page (0);
// 생성한 copy_filename 문자열에 file_name을 복제해준다.
strlcpy (copy_filename, file_name, MAXIMUM_NUMBER);
/* Allocate and activate page directory. */
t->pml4 = pml4_create ();
if (t->pml4 == NULL)
goto done;
process_activate (thread_current ());
// command line parsing
char *argv[MAXIMUM_NUMBER/2];
char *token, *save_ptr;
int argc = 0;
// 첫번째 이름을 받아온다.
// save_ptr은 앞 문자열을 자르고 남은 문자열의 가장 앞을 가리키는 포인터 주소값을 말한다.
token = strtok_r(copy_filename, BLANK_DELIMETER, &save_ptr);
argv[argc] = token; // 첫번째 인자가 저장된다.(ex. args-single onearg라면, args-single 저장)
// 공백을 기준으로 문자열을 잘라서 argv 배열에 저장한다.
while (token != NULL){
token = strtok_r(NULL, BLANK_DELIMETER, &save_ptr);
argc++;
argv[argc] = token;
}
copy_filename = argv[0];
...
}
문자열을 한글자씩 이동하다가 공백을 만나면 NULL을 넣은 다음 그 앞까지의 문자열을 반환하는 strtok_r 함수를 사용한다. load 함수는 file name을 const 형식으로 받아오기 때문에, NULL을 추가할 수 없다. 따라서 문자열을 자르기 전에 file name을 복제하였다.
strtok와 strtok_r 차이는 strtok는 한글자를 자르고 뒤에 남은 문자열을 지역변수를 저장하고, strtok_r은 전역 변수를 선언하고 저장하는 방식이다. 두 함수의 차이는 다음 글에 자세히 나와있다.
C 언어 코딩 도장: 45.1 문자를 기준으로 문자열 자르기
이제 argv 리스트에 공백 기준으로 잘린 값들이 저장되었다.
다음은 인자값들을 interrupt_frame 구조체에 저장한다. 이 과정은 구조체 특정값에 인자를 넣어주는 것이고, 이후 do_iret()에서 인터럽트 프레임을 스택에 올릴 것이다.
argument_stack(userprog/process.c)
userprog/process.c
static bool
load (const char *file_name, struct intr_frame *if_) {
...
/* TODO: Your code goes here.
* TODO: Implement argument passing (see project2/argument_passing.html). */
// argv: list, argc: token_count, if_: interrupt_frame
argument_stack(argv, argc, if_); // 👉 작성해야하는 함수!
success = true;
done:
/* We arrive here whether the load is successful or not. */
file_close (file);
return success;
}
userprog/process.c
void
argument_stack(char **argv, int argc, struct intr_frame *if_)
{
// file_name 파싱 결과를 담을 배열을 생성한다.
char *arg_address[MAXIMUM_NUMBER];
// word-align 전까지
// argv 뒤에서부터 인자들을 userstack에 저장한다.(NULL 끝 값은 제외한다.)
// /bin/ls -l foo bar
for (int i = argc-1; i >= 0; i--)
{
int arg_len = strlen(argv[i]) + 1; // 1은 sentinel(\\0)을 의미한다.
if_->rsp = if_->rsp - (arg_len); // 인자 크기만큼 스택 포인터를 이동시킨다.
memcpy(if_->rsp, argv[i], arg_len); // 해당 공간이 인자를 복사 붙여넣기 한다.
arg_address[i] = if_->rsp; // arg_address 배열에 현재 문자열 시작 위치를 저장한다.
}
// word-algin -> 정렬하기 위해
while (if_->rsp % 8 != 0)
{
if_->rsp--;
*(uint8_t *) if_->rsp = 0; // 포인터 타입이 unit8_t이니까 rsp 데이터에 0을 넣는다.-> 패딩을 채운다.
}
// 주소값을 삽입한다.
for (int i = argc; i >= 0; i--)
{
if_->rsp = if_->rsp - 8;
if (i == argc) // null sentinel 자리
{
*(char **) if_->rsp = 0;
}
else
memcpy(if_->rsp, &arg_address[i], sizeof(char *));
}
// rdi, rsi
if_->R.rdi = argc;
if_->R.rsi = if_->rsp;
// fake address를 저장한다.
if_->rsp = if_->rsp - 8;
memset(if_->rsp, 0, sizeof(char *));
}
기타 함수 수정
테스트를 위해 함수를 일부 수정한다.
hex_dump() : 디버깅 툴
parsing 결과를 확인하기 위해 디버깅 툴을 추가한다.
userprog/process.c
/* Switch the current execution context to the f_name.
* Returns -1 on fail. */
// 사용자가 입력한 명령을 수행하도록 프로그램을 메모리에 올려 실행한다.
// 실행 프로그램 파일과 옵션을 구분하는 작업을 추가한다.
int
process_exec (void *f_name) {
...
/* Project2: Command Line Parsing 디버깅 */
hex_dump(_if.rsp, _if.rsp, USER_STACK - _if.rsp, true);
...
}
process_wait() : 반복문으로 대기
무한루프를 돌아야한다고 힌트에 작성되어있지만, 적당히 시간을 두어서 바로 종료되지 않도록 하였다.
+) 이 부분은 system call에서 구현할 것이다.
userprog/process.c
int
process_wait (tid_t child_tid UNUSED) {
/* XXX: Hint) The pintos exit if process_wait (initd), we recommend you
* XXX: to add infinite loop here before
* XXX: implementing the process_wait. */
for (int i = 0; i < 100000000; i++)
{
/* code */
}
return -1;
}
마지막으로 헤더 파일이 잘 선언되었는지 확인한다.
과제 테스트
userprog 경로에서 make 실행 후, 생성된 build 폴더에서 다음 명령을 실행하면 된다.
pintos -v -k -T 60 -m 20 --fs-disk=10 -p tests/userprog/args-single:args-single -- -q -f run 'args-single onearg'
실행 결과는 다음과 같다.
Passing 기능을 테스트할 수 있는 함수를 더 알아보고 싶다면, issue에서 확인하면 된다.