Project 1은 커널을 만드는 것이었다.
쓰레드의 상태를 변환시켜주는 함수를 통해, 각 쓰레드들의 우선순위에 따라 CPU 스케줄링을 구현했다. 또, 타이머 인터럽트 핸들러를 통해 sleep 상태를 구현하면서 하드웨어 인터럽트와의 상호작용을 이해했다.
Project 2는 유저 프로그램이 실행되었을 때 커널이 해주어야 하는 일을 정의한다.
첫 과제는 "Argument Passing"이다.
CS:APP 3장에서 배웠던 내용들이 실제로 코드로써 적용된다.
3장에서는 실행가능 목적 파일(.o)에서 C언어 프로그램이 어떤 인스트럭션으로 번역되는지, 그리고 그 인스트럭션은 어떻게 수행되는지, 어떤 레지스터를 사용하는지를 배웠다.
이번 과제에서는 프로그램을 실행하는 시점에서,
파일명/인자를 커맨드 라인에서 입력받았을 때, 커널이 각 argument를 잘 처리할 수 있도록 어떻게 Passing해줄 것인지 고민하고, 커널 코드를 적절히 수정하는 것이다.
user program이 커멘드 라인드로 실행되는 과정을 보자.
부팅이 되면 최초로 thread/init.c 가 실행된다.

user program은 위와 같이 run_tack() 에서 분기되며, 부모 프로세스는 자식 프로세스를 만들고 기다린다.
만든 자식 프로세스에서는 우리가 요청한 user program이 실행될 예정이다.
process_wait(process_create_initd(tack));
여기에서 process.c 의 process_create_initd() 가 수행되며, 새로운 프로세스의 스레드를 생성하고 실행할 준비를 한다.

호출하는 함수를 따라가보면 위와 같이 load()라는 함수가 최종적으로 호출되는 것을 알 수 있다.
첫 과제에서는 process_exec()을 수정하라는 디렉션이 있다.
process_exec() 안에는 load() 함수가 호출되며, load() 는 중요한 역할을 한다.
ELF 프로그램의 segment를 로딩한 후, 유저 스택을 구성한다.
새로운 프로세스로 실행 단위를 넘기기 위해서는 새로운 프로세스가 할당받은 메모리, 유저 스택을 잘 세팅해주어야 한다.
그리고 CPU가 실행할 때, register를 읽어들이면서 인스트럭션을 수행하기 때문에 커멘드 라인으로 받은 인자들을 적절하게 스택 메모리에 담고, register에도 적절한 값들을 초기화해주어야 한다.
이 과정이 load()에서 수행되어야 한다.
정리하자면,
1. 프로그램을 실행할 때 필요한 arguments를 user stack에 push한다.
2. 프로세스의 레지스터를 적절하게 초기화한다.
단, 이 과정에서 'x86-64 Calling Convention'을 따라야 한다.
자, 프로그램 load에 성공했다면
어떤 흐름으로 프로세스가 실행될까?

thread_create()를 통해 새롭게 만들어진 프로세스(child process)에는 user program이 실행된다.
이는 do_iret()이라는 함수가 실행되며 사용자 모드로의 전환이 이루어진다.
그렇다면 기존에 수행되던 부모 프로세스는 어떻게 될까?
init.c를 따라 child process가 실행되고,
return 되는 과정에서 위처럼 process_wait()을 만나게 된다.
process_wait()의 인자는 process_create_initd()의 결과로 자식 프로세스의 tid가 들어간다.
따라서, 부모 프로세스는 자식 프로세스가 exti() 또는 종료되기를 기다리게 된다.
흐름을 이해했으니, load()를 잘 완성해보아야 한다.
우선, x86-64 호출 규칙을 잘 이해하고 pintos에서는 어떻게 적용하고 있는 지 잘 확인해야 한다.
x86-64 Calling Convention
- 사용자 수준 애플리케이션은 정수 인자를 전달할 때 다음 순서의 레지스터를 사용합니다.
%rdi, %rsi, %rdx, %rcx, %r8, %r9- 호출자(caller)는 자신이 다음에 실행할 명령어의 주소(즉, 리턴 주소)를 스택에 푸시(push)하고, 피호출자(callee)의 첫 번째 명령어로 점프합니다.
이 두 동작은 x86-64의 단일 명령어인 CALL을 통해 수행됩니다.- 피호출자(callee)가 실행됩니다.
- 피호출자가 반환 값을 갖는 경우, 이를 RAX 레지스터에 저장합니다.
- 피호출자는 RET 명령어를 통해 스택에서 리턴 주소를 팝(pop)하고, 해당 주소로 점프하여 복귀합니다.
피호출자가 f(1, 2, 3)의 함수를 실행했다면 아래와 같이 설정될 것이다.
여기서 레지스터의 역할을 한번 상기하고 가자!
%rip
Instruction Pointer(PC)
실행할 명령어의 주소를 가리킨다.
CPU는 늘 %rip(PC)가 가리키는 주소에 있는 명령어를 수행한다.
CPU의 명령어 실행과 동시에 %rip는 다음 주소로 이동한다.
▶︎ load() 내에서 - 프로세스 시작 주소(ELF entry)
%rsp
Stack Pointer
현재 스택의 가장 위(top)을 가리킨다.
유저 스택은 함수 호출/인자 전달 등에서 스택 자료구조로 관리되어야 한다.
스택은 아래 방향으로 성장하기 때문에 push, pop의 작업을 수행할 때마다 %rsp도 변한다.
▶︎ load() 내에서 - 유저 스택의 시작점(argument 포함)
그 외 레지스터의 용도
| 64비트 | 32비트 | 16비트 | 8비트 하위 | 자주 쓰이는 용도 |
|---|---|---|---|---|
| %rax | %eax | %ax | %al | 연산 결과 저장, return 값 |
| %rbx | %ebx | %bx | %bl | 임시 저장 등 |
| %rcx | %ecx | %cx | %cl | 루프 카운터 등 |
| %rdx | %edx | %dx | %dl | 곱셈/나눗셈 결과 저장 등 |
| %rsi | %esi | %si | %sil | 함수 인자 2번째 |
| %rdi | %edi | %di | %dil | 함수 인자 1번째 |
| %rbp | %ebp | %bp | %bpl | 베이스 포인터 (stack frame) |
| %rsp | %esp | %sp | %spl | 스택 포인터 (stack top) |
| %r8 ~ %r15 | %r8d ~ %r15d | … | … | 추가 레지스터들 (함수 인자 3~6번째, 임시값 등) |
.
.
다시 돌아가서,
command line으로 입력받은 명령어를 처리하는 예시를 한번 보자.
/bin/ls -l foo bar 의 커멘드를 처리하려면 아래와 같은 과정을 거쳐야 한다.
1. 명령어 분해
명령어를 단어들로 나눕니다
C 프로그램 기준argv[]는 다음과 같습니다.argv[0] = "/bin/ls" argv[1] = "-l" argv[2] = "foo" argv[3] = "bar" argv[4] = NULL2. 문자열들을 스택 상단에 저장
- 문자열들은 스택 맨 위에 배치됩니다.
- 저장 순서는 상관없습니다.
- 각 문자열은
argv[]배열에서 포인터로 참조됩니다.3.
argv[]포인터 배열 구성
- 문자열들의 주소 + NULL 포인터를 스택에 오른쪽에서 왼쪽 순서로 push합니다.
- 이는 argv[0]이 가장 낮은 주소에 위치하게 하기 위함입니다.
- 마지막에 NULL 포인터를 넣어 argv[argc] == NULL을 만족시킵니다.
- 스택 정렬을 위해 8의 배수로 맞춰서 시작합니다.
4. 레지스터 설정
레지스터 값 %rdi argc (인자의 개수) %rsi argv[0]의 주소 (argv 배열 시작 주소) 5. 가짜 리턴 주소 push
main() 함수는 실제로 돌아오지 않지만,
일반 함수 호출과 동일한 스택 프레임 구조를 유지해야 하므로 가짜 return address를 스택에 push합니다.
위의 표와 같이 우리의 user stack을 채워나가야 한다.
그래야 CPU가 읽고 user program을 실행할 수 있다.
하지만,
현재 구현되어 있는 process_exec()는 새로운 프로세스를 실행할 때 인자를 전달하지 못한다.
이를 확장하여,
공백을 기준으로 명령어를 단어로 나누고, 인자로 전달할 수 있도록 해야한다.

위의 사진과 같이 PintOS의 메모리 구조가 설계되어있다.
user stack의 주소는 0x47480000 부터 시작해서 아래로 증가한다는 점을 기억하자.

.
.
그러면 load() 코드를 보고 감을 잡아보자.
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;
/* Allocate and activate page directory. */
t->pml4 = pml4_create();
if (t->pml4 == NULL)
goto done;
process_activate(thread_current()); // context switching 유발
/* Open executable file. */
file = filesys_open(file_name);
if (file == NULL) {
printf("load: %s: open failed\n", file_name);
goto done;
}
/* Read and verify executable header. */
if (file_read(file, &ehdr, sizeof ehdr) != sizeof ehdr
|| memcmp(ehdr.e_ident, "\177ELF\2\1\1", 7)
|| ehdr.e_type != 2
|| ehdr.e_machine != 0x3E // amd64
|| ehdr.e_version != 1
|| ehdr.e_phentsize != sizeof(struct Phdr)
|| ehdr.e_phnum > 1024) {
printf("load: %s: error loading executable\n", file_name);
goto done;
}
/* Read program headers. */
file_ofs = ehdr.e_phoff;
for (i = 0; i < ehdr.e_phnum; i++) {
struct Phdr phdr;
if (file_ofs < 0 || file_ofs > file_length(file))
goto done;
file_seek(file, file_ofs);
if (file_read(file, &phdr, sizeof phdr) != sizeof phdr)
goto done;
file_ofs += sizeof phdr;
switch (phdr.p_type) {
case PT_NULL:
case PT_NOTE:
case PT_PHDR:
case PT_STACK:
default:
/* Ignore this segment. */
break;
case PT_DYNAMIC:
case PT_INTERP:
case PT_SHLIB:
goto done;
case PT_LOAD:
if (validate_segment(&phdr, file)) {
bool writable = (phdr.p_flags & PF_W) != 0;
uint64_t file_page = phdr.p_offset & ~PGMASK;
uint64_t mem_page = phdr.p_vaddr & ~PGMASK;
uint64_t page_offset = phdr.p_vaddr & PGMASK;
uint32_t read_bytes, zero_bytes;
if (phdr.p_filesz > 0) {
/* Normal segment.
* Read initial part from disk and zero the rest. */
read_bytes = page_offset + phdr.p_filesz;
zero_bytes = (ROUND_UP(page_offset + phdr.p_memsz, PGSIZE)
- read_bytes);
}
else {
/* Entirely zero.
* Don't read anything from disk. */
read_bytes = 0;
zero_bytes = ROUND_UP(page_offset + phdr.p_memsz, PGSIZE);
}
if (!load_segment(file, file_page, (void*)mem_page,
read_bytes, zero_bytes, writable))
goto done;
}
else
goto done;
break;
}
}
/* Set up stack. */
if (!setup_stack(if_)) // rsp (스택 시작 지점)
goto done;
/* Start address. */
if_->rip = ehdr.e_entry;
/* TODO: Your code goes here.
* TODO: Implement argument passing (see project2/argument_passing.html). */
success = true;
done:
/* We arrive here whether the load is successful or not. */
file_close(file);
return success;
}
file_name으로 file을 다루기 때문에,
인자로 받은 문자열의 연속을 파싱하여 프로그램명과 인자들을 분리한다.
이 때는 strtok_r 을 활용하면 된다.
string.c를 보면 친절하게 사용 예시도 제공한다.

사용법을 알았다면 적용해보자.
argc 인자의 수 정보 또한 %rdi 레지스터에 저장해야 하기에 별도로 저장해준다.
argv 배열에는 공백으로 구분된 인자들이 차례로 담기게 된다.
/* arg 파싱 */
int argc = 0;
char* argv[16]; // 인자 최대 크기로 제한(인자 스트링 저장)
char* token, * save_ptr;
for (token = strtok_r(file_name, " ", &save_ptr); token != NULL;
token = strtok_r(NULL, " ", &save_ptr)) {
argv[argc++] = token;
}
file_name = argv[0]; // file name 변경
이렇게 file_name을 잘 넘겼다면,
인자들을 규칙에 맞게 user stack에 push 해주면 되겠다.
순서를 다시 생각해보자면,
1. char 형식의 각 인자(\0를 포함하여 종료됨을 알 수 있게 한다.)
2. word 크기 정렬을 위한 패딩
3. 문자열 종료를 의미하는 null pointer이자, argv[argc] == null을 충족
4. argv[argc-1] ~ argv[0] 포인터
5. fake return address
이렇게 아래 방향으로 push하면 된다.
.
.
.
1번부터 해보자.
/* Push data into stack */
char* stack_ptr = (char*)if_->rsp;
char* argv_ptr[16]; // argv 원소의 포인터
for (i = (argc - 1); i >= 0; i--) {
// string 길이 얻기
size_t len = strlen(argv[i]) + 1; // \0 포함
// 길이만큼 스택 포인터 아래 방향으로 이동
stack_ptr -= len;
// 메모리에 쓰기
memcpy(stack_ptr, argv[i], len);
// 해당 위치 포인터 저장
argv_ptr[i] = stack_ptr;
}
// word align (8바이트)
while ((uint64_t)stack_ptr % 8 != 0) {
stack_ptr--;
memset(stack_ptr, 0, 1);
}
// argv[argc] == 0
stack_ptr -= sizeof(char*);
memset(stack_ptr, 0, sizeof(char*));
// Push argv[argc-1] ~ argv[0]
for (i = (argc - 1); i >= 0; i--) {
stack_ptr -= sizeof(char*);
memcpy(stack_ptr, &argv_ptr[i], sizeof(char*));
}
// fake return address
stack_ptr -= sizeof(void*);
memset(stack_ptr, 0, sizeof(void*));
if_->rsp = stack_ptr;
.
.
.
이제 레지스터 설정만 해주면 된다.
%rdi는 argc
%rsi는 argv[0] 시작 주소
또 하나 추가해주어야 할 것은
%rsp이다.
%rsp는 setup_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;
}
이렇게 초기화된 %rsp의 값으로 우리는 user stack을 차츰차츰 쌓아왔다.
모든 데이터를 Push 했다면 다시 %rsp값을 재설정 해주어야 한다.
%rsp는 스택 프레임의 가장 끝부분을 포인트하기 때문이다.
.
.
따라서 나는 argv[]까지 push한 직후에
%rsi, %rdi를 먼저 업데이트 해주었다.
그런 다음, return address를 push하고 전체 스택 프레임이 완성되었다면 그 시점에서
%rsp를 다시 한 번 업데이트 한다.
// register
if_->R.rsi = stack_ptr;
if_->R.rdi = argc;
// fake return address
stack_ptr -= sizeof(void*);
memset(stack_ptr, 0, sizeof(void*));
// register
if_->rsp = stack_ptr;
이렇게 말이다.
잘 된 건지 확인하고자 한다면, 깃북에서 추천한대로 hex_dump() 함수를 통해 디버깅해볼 수 있다.

이렇게 arguments, padding 등이 의도대로 잘 들어갔고,
그 직후 system call!이 호출된다면 성공이다.
hex_dump() 활용법
사실 엄청 괴로운 디버깅을 거쳤다..
그놈의 커널 패닉이 엄청 생겼다.

이렇게 매 코드마다 printf로 디버깅했다.
그런데 hex_dump()로 본 메모리 값들은 야무지게 잘 들어가있었다.
도대체 왜 왜왜 안 되는 걸까?
포인터를 저장할 때, 그 포인터가 가리키는 값이 유효하지 않은 걸까?
그래서 아래처럼 디버깅 했다.

가리키는 값(argv 주소)도 유효했다.
page fault로 인한 커널 패닉으로 프로세스가 kill 된다는 에러는 변하지 않았다.

TMI이지만, 어제는 길에서 네잎클로버를 발견했다.
그래서 진짜 진짜 진짜로 argument passing 과제를 마무리할 수 있을 것만 같았다ㅠ
근데 어처구니없고 말도 안 되는, 납득도 안 되는 에러만 마주하니까 괴로움에 끙끙 앓다가 잠들었다 😩 ..
그리고 오늘 아침부터 권호님에게 내 코드의 실수를 봐달라고 부탁드렸는데
그제서야 발견하고야 말았다..
문제는 hex_dump()의 범위를 잘못 설정한 것이었다.
_if.rsp 부터 128Bytes 만큼을 확인하도록 했는데
변경한 _if.rsp는 내가 실제로 사용한 스택 프레임의 크기에 맞게 변경되어, 유효하지 않은 주소(더 높은 주소)를 참조하게 된 것이다.
따라서 아래처럼 hex_dump()를 안전하게 수정하였더니 바로 해결되었다 🥲

홧팅 빠샤 :-]