핀토스 프로젝트 2는 유저 프로그램에 관한 과제다. 첫 번째 프로젝트는 모두 커널 영역에서 진행이 되었다.(이거 덕분에 나름 디버깅이 편했던 편...)
두 번째 프로젝트는 크게 두 가지 파트로 나뉘는데, 이번 포스팅에서는 그 첫 번째를 다뤄보고자 한다.
Argument Passing
첫번째로 argument passing이다. 우리가 함수를 사용할 때, 함수에 인자를 넘겨주는 방식을 구현하는 과제다! 우리가 인자를 함수로 넘겨줄 때 스택에 넣어두었다가 함수에 진입하고 나면 스택에서 argument를 꺼내 사용한다.
예를 들어 다음과 같은 코드가 있다고 생각해보자.
void add(int a, int b)
{
printf("%d\n", a + b);
}
위의 add 함수는 인자로 a와 b를 넘겨받고, 각각의 인자들은 int 자료형이기 때문에 4byte의 크기를 가질 것이다.
이제 우리가 add 함수를 실행시키기 위해 다음과 같은 코드를 입력했다고 생각해 보자.
...
int a = 2;
int b = 3;
add(a, b);
...
위의 코드에서 add(a, b)를 실행하면 현재 실행되고 있는 스레드 혹은 프로세스의 스택 영역에 a와 b를 넣어주게 된다. 그림과 함께 살펴보자.
처음에는 위의 그림과 같이 스택이 텅 비어있다고 하자. 나중에는 스택의 아래에서 위로 올라가면서 인자들을 꺼낼 것이므로, 꺼낼 때 a가 먼저 나오고 뒤에 b가 나올 수 있도록 b를 먼저 넣어주자.
a와 b를 스택에 넣어주게 되면 위의 그림과 같아질 것이다. 그렇다면 이제 바로 add 함수로 넘어가서 인자들을 꺼내줄 수 있을까? 아니다!
위에 써둔 add 함수의 경우 인자의 개수가 정해져있기 때문에 그럴 일은 없지만, 어떤 함수들은 인자의 개수가 정해져 있지 않을 수 있기 때문에 개수를 알려주어야 한다! 또한 스택에서 바로 값을 빼와서 읽어오기에는, 인자의 크기를 정확히 알지 못하는 경우가 있을 수 있다. 이 또한 위의 add 함수는 int 자료형의 인자를 받고, 개수도 정해져 있기 때문에 해당하지 않을 수 있지만 여러 개의 문자열을 인자로 받는 함수는 스택에서 pop을 하는 시점에서 인자의 크기를 명확하게 알지 못한다.
따라서 함수가 스택에서 값을 읽어올때에는, 직접적으로 스택에서 pop을 하는 것이 아닌 스택의 주소값을 통해 접근한다. 따라서 우리는 값이 담겨있는 위치의 주소값 또한 스택에 넣어주어야 한다!
다 넣어주고 나면 위의 그림과 같다. rdi에는 인자의 개수를, rsi에는 첫 번째 argument의 주소값이 담겨있는 위치의 주소를 넣어준다. 마지막에는 함수가 모두 실행되고 나면 다시 돌아갈 코드의 주소를 넣어준다. 이 값은 add 함수가 불린 이후 코드의 주소가 될 것이다.
여기까지가 argument passing의 진행 과정을 정말 간략하게 살펴보는 과정이었다. 핀토스 프로젝트에서 argument passing은 모두 문자열을 대상으로 진행된다. 우리가 쉘에서 특정 명령어를 입력하여 프로그램을 실행하는 상황을 생각해 보자. 보통 쉘에서 실행하는 프로그램들은 인자를 함께 넘겨주는 경우가 대부분이다. 예를 들어 git 명령어를 실행한다고 해보자.
git add pintos/userprog/process.c
위의 명령어는 쉘에게 우리가 git 프로그램을 실행시켜 add 와 pintos/userprog/process.c를 git 프로그램에게 인자로 넘겨달라는 요청의 의미이다. 이렇게 쉘은 총 세 개의 인자를 받게 되고, 이 인자들을 통해 파일을 실행시키며, 그 파일에게 위의 argument passing 과정을 통해 인자를 넘겨주게 된다.
하지만 쉘이 받는 인자는 arg[0] = git, arg[1] = add, arg[2] = pintos/userprog/process.c 이런 식으로 친절하게 들어오지 않는다... 위의 명령어는 통째로 하나의 문자열로 입력이 들어오며, 우리는 띄어쓰기를 기준으로 args 배열에 넣어주어야 한다.
또한 스택에 모든 값들을 넣어주었을 때, rsp가 8의 배수로 align 되어있어야 best performance가 나오기때문에 우리는 중간에 padding을 추가해주어야 한다.
따라서 위의 그림과 같이 중간에 padding이 추가되어야한다. 우리가 구현할 passing 과정에서는 프로그램이 처음 실행되는 상황에 인자를 넘겨주는 것이기 때문에, return address로는 0을 넘겨주면 된다.
정리하자면,
1. 인자가 "git add pintos/userprog/process.c" 형태로 들어왔을때, 이 문자열을 "git", "add", "pintos/userprog/process.c"로 나눈 다음에 위의 그림에서 values 자리에 복사해 준다.
2. padding을 추가해 values + padding의 크기가 8의 배수가 되게끔 해준다.
3. values 위치에서 "git", "add", "pintos/userprog/process.c"가 들어있는 위치의 주소를 args 자리에 넣어준다.
4. 마지막으로 return address에 0을 넣어준다.
위의 과정을 따라 구현하면 된다!
훗날 핀토스 때문에 고생하고 있을 누군가를 위해 argument passing 과정에서 알아두면 좋을 것들을 써두려고 한다!
strtok_r
첫 번째로 strtok_r 함수이다. 위에서 인자는 하나의 문자열로 들어오고 이를 띄어쓰기를 기준으로 나누어야 한다고 했는데, 바로 strtok_r 함수를 사용해서 나눠준다.
strtok_r(char *string, const chat *separate, char **last)
파라미터는 위와 같이 넘겨준다.
- string : 토큰화를 해주고 싶은 문자열이다. 위의 예시에서는 "git add pintos/userprog/process.c"이다.
- separate : 토큰화를 진행하는 기준을 말한다. 여러 개의 문자가 될 수도 있으며, 예를 들어 a와 b를 기준으로 문자열을 나누고 싶으면 separate로 "ab"를 넘겨주면 된다. 우리는 띄어쓰기를 기준으로 나눌 것이기 때문에 " "를 넘겨주면 된다.
- last : string 문자열에서 separate를 기준으로 나눈 뒤 나머지 문자열을 저장하는 용도이다.
마지막 last가 왜 있는지 이해가 잘 가지 않을 수도 있는데, 위에서 든 예시로 설명을 하겠다.
char *cmd_line = "git add pintos/userprog/process.c"
char *next_ptr;
char *ret_ptr = strtok_r(cmd_line, " ", &next_ptr);
우리가 위와 같이 함수를 사용하게 되면, 먼저 cmd_line에서 공백을 찾아 모두 NULL 문자열로 바꿔준다. 바꾸고 나면 cmd_line은 다음과 같이 변한다.
"git\0add\0pintos/userprog/process.c"
이후 ret_ptr에는 git의 시작 주소가 담기게 되며, next_ptr에는 add의 시작 주소가 담기게 된다. 이렇게 ret_ptr에 첫 번째 토큰인 "git"이 담기게 된 것이다! 계속해서 다음 토큰을 읽어나가고 싶다면 다음과 같이 쓰면 된다.
ret_ptr = strtok_r(NULL, " ", &next_ptr);
이렇게 써주면 next_ptr에서 첫번째 토큰을 읽어오게 되고, next_ptr은 그다음 토큰을 가리키게 된다. 마지막에 더 이상 가리킬 토큰이 없다면, ret_ptr에 NULL이 담기게 된다.
hex_dump
다음은 hex_dump 함수이다. git book에는 디버깅을 위해 hex_dump 함수를 사용하라고 한다. printf도 있고 테스트도 돌려보면 되는데 이걸 왜 굳이 써야 하지?라는 생각이 들 수 있다. 여기서 아주 중요한 점이 하나 있는데, 프로젝트 2에서 argument passing 다음에 진행하는 것이 시스템 콜 구현이다. 따라서 시스템 콜 구현을 하기 전까지는 printf도 못쓰고, 출력문을 통해 테스트의 pass와 fail을 결정하기 때문에 당연히 테스트에서도 확인을 할 수 없다!
이러한 점 때문에 hex_dump를 사용해서 스택에 인자들이 잘 들어갔나 확인을 해야 한다.
hex_dump(if_->rsp, if_->rsp, USER_STACK - if_->rsp, true);
함수는 위와 같이 사용하면 된다. 여기서 if_는 인터럽트 프레임이다.
인자들이 잘 들어갔다면 위와 같이 출력될 것이다. 위의 테스트는 args-single 테스트이다. 아마 카이스트 핀토스를 진행한다면 사용하는 USER_STACK의 값 또한 같을 테니(업데이트가 되지 않는다면!) 옆에 나오는 주소값 또한 같게 나오면 된다!
왼쪽에 적혀있는 주소값은 스택의 주소값을 의미한다. 맨 아래에 4747fff0은 바로 옆에 있는 73의 주소값이라고 보면 된다. 한 줄에 16byte씩 출력이 되며, 따라서 왼쪽의 주소값 또한 16씩 차이가 나는 것을 볼 수 있다.
스택에 들어간 주소값들을 보면 4747ffed는 61(args-single에서 a)의 주소를, 4747fff9는 6f(onearg에서 o)의 주소임을 알 수 있다.
테스트 실행
사실 이게 hex_dump보다 앞에 들어가야 하지 않았나 싶은데... 프로젝트 2는 프로젝트 1과 테스트 실행 방식이 조금 다르다. 일단 가장 큰 차이점으로 가상 디스크를 사용한다는 점이다. 가상 디스크를 사용하는 방식도 두 가지가 있는데, 일단 두 가지 모두 소개하겠다.
첫 번째는 build 폴더에 가상 디스크를 만들어두고 활용하는 방식이다. 이를 위해서는 테스트를 실행하기 전에 먼저 가상 디스크를 만들어야 한다.
pintos-mkdisk filesys.dsk 10
위와 같은 명령어를 입력하면, 10MB 크기의 filesys.dsk 가상 디스크를 만들어준다.
이후 테스트를 실행할 때는 다음과 같이 입력하면 된다.
pintos --fs-disk filesys.dsk 10 -p tests/userprog/args-single:args-single -- -q -f run 'args-single onearg"
먼저 --fs-disk 옵션을 통해 가상 디스크를 사용한다는 것을 알려줘야 하고, 뒤에는 사용하고 있는 가상 디스크의 경로를 입력해야 한다. 또한 가상 디스크가 비어있으면 파일을 실행할 수 없으므로, 테스트에서 이용할 파일을 가상 디스크에 복사하는 작업이 필요하다. 이는 -p tests/userprog/args-single:args-single을 통해 진행한다. 뒤에는 [파일의 경로]:[가상 디스크에 복사할 이름]을 의미한다. 그다음부터는 프로젝트 1 에서와 동일하다.
pintos --fs-disk=10 -p tests/userprog/args-single:args-single -- -q -f run 'args-single onearg"
물론 이렇게만 바꾸면, 이번 테스트에서만 10MB 크기의 가상 디스크를 임시로 만들어 활용할 수도 있다. 이렇게 하면 위에서와는 달리 pintos-mkdisk 명령어를 통해 가상 디스크를 만들어주지 않아도 테스트를 진행할 수 있다.
make tests/userprog/args-single.result VERBOSE=1
물론 가장 간단하게 실행하는 방법은 build 폴더에서 make를 활용하여 진행하는 것이다. make 다음에 실행할 테스트의 위치를 인자로 넘겨주면 위의 pintos 명령어를 알아서 입력하고 테스트를 진행해 준다. pintos 명령어가 기억나지 않을 때는 make를 통해 먼저 테스트를 실행하고, 실행했을 때 나온 pintos 명령어를 복사하면 된다.
'Operating System' 카테고리의 다른 글
[Pintos] Project 3 - Memory Management (0) | 2023.05.16 |
---|---|
[Pintos] System Call - File I/O (1) | 2023.05.09 |
[Pintos] Multi-Level Feedback Queue Scheduler(mlfqs) (1) | 2023.05.08 |
[OS] CPU Scheduling (1) | 2023.05.06 |
[Pintos] Project 1 Threads (0) | 2023.05.06 |