이전 포스팅들에서 Pintos에 대한 이론을 알아보았다.
2025.05.19 - [크래프톤 정글] - [pintos] Week2~3: User Program Part.2
[pintos] Week2~3: User Program Part.2
이전 포스팅에서 pintos 2~3 주차의 주요 목표와 pintos에서 시스템 콜이 발생하는 과정에 대해 알아보았다. 2025.05.19 - [크래프톤 정글] - [pintos] Week2~3: User Program Part.1 [pintos] Week2~3: User Program Part.11주
www.gowoong.com
그렇다면 Pintos의 요구사항을 만족하기 위해 구현에 들어가 볼 예정이다. 그렇기 위해 먼저 커널이 올바른 유저 프로그램을 실행하기 위해 설정을 해주는 작업을 진행하겠다.
인자 파싱(Arguments Parssing) 방법
이 전 시간에 커널은 사용자가 실행을 요청한 프로그램을 실행하기 위해 실행하며 입력한 명령어 문자열을 분리하여 파일 이름, 인자값으로 나누는 과정을 거쳐야 한다고 했다. 그렇다면 어떻게 문자열을 나누어야 할까?
Pintos강의 ppt에는 strtok_r() 함수를 사용해서 파싱을 진행하라고 나와있다.
strtok_r() 함수
char *strtok_r(char *str, const char *delim, char **save_ptr);
이 함수는 문자열을 특정 구분자로 잘라서 파싱 하는 함수다.
- str: 처음 호출 시 분할할 문자열, 이후 호출 시 NULL
- delim: 분할할 구분자 (예: 공백 " ")
- save_ptr: 내부 상태를 저장할 포인터 (재진입-safe)
마치 python의 .split(" ") 기능을 수행하는 것이라고 보면 된다. 차이점이라면 파이썬이 한 번에 리스트로 변환한다면 C에서는 반복문으로 하나씩 꺼내야 한다는 점이다. 또 다른 차이점으로는 strtok_r()은 원본 문자열을 수정한다는 것이다.
char str[] = "echo hello";
strtok_r(str, " ", &save); // str → "echo\0hello"
" "을 '\0' 으로 대체 삽입했다. 상태 저장을 위해 save_ptr로 호출자가 상태를 유지해야 한다는 점도 있다. 어쨌든 문자열을 분할하는 작업이다.
인자 파싱(Arguments Parssing)
1. process_create_initd()
- threads/init.c
/* Runs the task specified in ARGV[1]. */
static void
run_task (char **argv) {
const char *task = argv[1];
printf ("Executing '%s':\n", task);
#ifdef USERPROG
if (thread_tests){
run_test (task);
} else {
process_wait (process_create_initd (task));
}
#else
run_test (task);
#endif
printf ("Execution of '%s' complete.\n", task);
}
먼저 커널의 main 함수에서 우리가 원하는 동작을 수행하는 부분을 먼저 확인해봐야 한다. run_task() 라는 함수에서 process_create_initd를 실행하며 이때 반환되는 tid_t 값을 이용해 process_wait()을 진행하고 있다.
- userprog/process.c
/* Starts the first userland program, called "initd", loaded from FILE_NAME.
* The new thread may be scheduled (and may even exit)
* before process_create_initd() returns. Returns the initd's
* thread id, or TID_ERROR if the thread cannot be created.
* Notice that THIS SHOULD BE CALLED ONCE. */
tid_t
process_create_initd (const char *file_name) {
char *fn_copy;
tid_t tid;
/* Make a copy of FILE_NAME.
* Otherwise there's a race between the caller and load(). */
fn_copy = palloc_get_page (0);
if (fn_copy == NULL)
return TID_ERROR;
strlcpy (fn_copy, file_name, PGSIZE);
// Argument Passing ~
char *save_ptr;
strtok_r(file_name, " ", &save_ptr);
// ~ Argument Passing
/* Create a new thread to execute FILE_NAME. */
tid = thread_create (file_name, PRI_DEFAULT, initd, fn_copy);
if (tid == TID_ERROR)
palloc_free_page (fn_copy);
return tid;
}
process_create_initd() 함수는 Pintos가 처음으로 유저 프로그램을 시작하기 위한 진입점으로 initd(첫 유저 스레드)를 생성하는 역할을 담당한다. 즉 이곳에서 유저 프로그램을 로딩 및 실행할 준비를 하는 함수다.
fn_copy = palloc_get_page (0);
strlcpy(fn_copy, file_name, PGSIZE);
- 실행할 명령어 문자열(file_name)을 페이지 크기만큼 새로 할당하고 복사
- palloc_get_page(flags) 는 1개의 물리 페이지(4KB)를 할당해서 그 시작 주소를 반환하는 함수다.
- fn_copy는 그 페이지의 시작 주소이다. 여기에다가 file_name 문자열을 복사해서 스레드 인자로 전달한다.
- thread_create()에 전달할 인자는 스레드가 독립적으로 접근해야 하므로 복사본이 필요
- Pintos는 동적 메모리 할당에 malloc 대신 페이지 단위의 할당을 사용한다.
- 스레드의 thread_create()는 인자를 void* 하나만 받을 수 있으므로, 한 페이지 안에 모든 인자 정보를 넣어서 전달
char *save_ptr;
strtok_r(file_name, " ", &save_ptr);
- "echo hello" → "echo" 추출
- 이 값은 스레드 이름 및 ELF 로딩용 파일 이름으로 사용됨
- 사실상 file_name의 첫 단어만 뽑아서 실행 파일 이름 식별
tid = thread_create(file_name, PRI_DEFAULT, initd, fn_copy);
새 커널 스레드 생성
- 이름: "echo" (or whatever 첫 단어)
- 우선순위: 기본 우선순위 (PRI_DEFAULT)
- 시작 함수: initd() → 이 함수에서 process_exec(fn_copy) 호출하여 ELF 실행
- 인자: fn_copy → 실행할 명령어 문자열
요약:
process_create_initd()는 유저 프로그램을 실행할 수 있도록 초기 설정을 수행하고, 이를 위한 유저 스레드를 생성하는 함수이고 스레드를 생성하기 위해 필요한 이름과 인자를 넘겨주는 것이다.
2. process_exec()
process_exec() 함수는 유저 프로그램이 main(int argc, char *argv []) 형식으로 실행되기 위해 필요한 과정으로 이곳에서 커널이 유저 프로그램을 실행하기 위한 정보들을 유저 저장 공간에 설정하는 것이다.
/* 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 */
process_cleanup ();
/** project2-Command Line Parsing */
char *ptr, *arg;
int argc = 0;
char *argv[64];
for (arg = strtok_r(file_name, " ", &ptr); arg != NULL; arg = strtok_r(NULL, " ", &ptr))
argv[argc++] = arg;
/* And then load the binary */
success = load(file_name, &_if);
if (!success)
return -1;
argument_stack(argv, argc, &_if);
palloc_free_page(file_name);
/* Start switched process. */
do_iret (&_if);
NOT_REACHED ();
}
process_cleanup();
- 현재 실행 중인 프로세스의 메모리 공간, 파일 디스크립터, 페이지 테이블 등을 제거
- 유저 주소 공간 초기화 → 새로운 프로그램으로 덮어쓰기 위해 준비
char *ptr, *arg;
int argc = 0;
char *argv[64];
for (arg = strtok_r(file_name, " ", &ptr); arg != NULL; arg = strtok_r(NULL, " ", &ptr))
argv[argc++] = arg;
파싱과 관련된 주요 코드다. 예를 들어 아래와 같이 파일 이름이 넘어왔다고 하자
file_name = "echo hello world"
위 코드는 이러한 file_name 정보를 파싱한다.
실행 인자 문자열을 공백으로 나누어 argv[] 배열에 저장하고, argc를 계산한다.
예: "echo hello world" → argv[0]="echo", argv[1]="hello", argv[2]="world"
그렇다면 이전에 process_create_initd() 에서 진행한 파싱과 process_exec()에서 진행하는 파싱은 무엇이 다른가?
🔹 process_create_initd() 의 파싱은 실행할 ELF 파일 이름만 추출하기 위한 것
🔹 process_exec() 의 파싱은 유저 프로그램에 전달할 인자(argv [])를 만들기 위한 것
즉 파싱의 대상은 같지만 목적이 다른 것이다.
success = load(file_name, &_if);
- "echo"라는 ELF 파일을 파일 시스템에서 열고
- load() 함수에서:
- ELF 헤더 파싱
- 세그먼트(.text, .data 등) 적재
- _if.rip에 e_entry 설정
- 스택 초기화 → setup_stack() 호출 포함
argument_stack(argv, argc, &_if);
argument_stack(argv, argc, &_if): 이 함수는 직접 작성한 함수로 아래와 같은 동작을 수행하고 있고 더 자세한 것은 뒤에 설명하겠다.
- 스택에 인자 문자열 ("echo\0", "hello\0" 등) 저장
- 포인터 배열 argv[] 저장
- argc 값을 위에서 _if.R.rdi = count로 저장
- argv []의 주소를 _if.R.rsi에 저장 → 이게 유저 프로그램 입장에선 argv
palloc_free_page(file_name);
process_exec 호출 당시 할당된 페이지 해제. 더 이상 사용하지 않기 때문이다.
do_iret(&_if);
NOT_REACHED();
- _if에 설정된 레지스터(rip, rsp, rdi, rsi)를 기반으로
- 현재 스레드가 유저 모드에서 새로운 프로그램으로 실행 시작
- 이후 커널로 절대 되돌아오지 않기 때문에 NOT_REACHED() (panic용 매크로)
유저 스택에 기록
set_stack_data() 함수
이 함수는 내 마음대로 지은 이름이기 때문에 작성자마다 달라진 이름을 가질 수는 있다. 이 함수의 필요성은 Pintos에서 유저 프로그램이 실행되기 전 유저 스택에 argc, argv[], 문자열 인자들을 정확한 형태로 적재하기 위해 사용된다.
스택은 LIFO 구조이기 때문에 역순으로 쌓아야 올바르게 인자 배열을 구성할 수 있다.
위 이미지는 32비트 기준의 설명이다.
Address
|
Name
|
Data
|
Type
|
0x4747fffc
|
argv[3][...]
|
'bar\0'
|
char[4]
|
0x4747fff8
|
argv[2][...]
|
'foo\0'
|
char[4]
|
0x4747fff5
|
argv[1][...]
|
'-l\0'
|
char[3]
|
0x4747ffed
|
argv[0][...]
|
'/bin/ls\0'
|
char[8]
|
0x4747ffe8
|
word-align
|
0
|
uint8_t[]
|
0x4747ffe0
|
argv[4]
|
0
|
char *
|
0x4747ffd8
|
argv[3]
|
0x4747fffc
|
char *
|
0x4747ffd0
|
argv[2]
|
0x4747fff8
|
char *
|
0x4747ffc8
|
argv[1]
|
0x4747fff5
|
char *
|
0x4747ffc0
|
argv[0]
|
0x4747ffed
|
char *
|
0x4747ffb8
|
return address
|
0
|
void (*) ()
|
RDI: 4 | RSI: 0x4747ffc0
쉽게 말해서 스택은 LIFO 방식으로 성장하니 유저 스택 프로그램에 반환 주소, 실행 인자(argc, argv [], 문자열 들)를 거꾸로 적재한다. 그리고 데이터 적재를 위해 스택의 메모리 주소를 변경해 가며 데이터를 직접 기록하는 방식으로 구현을 진행할 것이다.
void argument_stack(char **argv, int argc, struct intr_frame *if_){
char *arg_addr[100];
int argv_len;
for (int i = argc - 1; i >= 0; i--) {
argv_len = strlen(argv[i]) + 1;
if_->rsp -= argv_len;
memcpy(if_->rsp, argv[i], argv_len);
arg_addr[i] = if_->rsp;
}
while (if_->rsp % 8)
*(uint8_t *)(--if_->rsp) = 0;
if_->rsp -= 8;
memset(if_->rsp, 0, sizeof(char *));
for (int i = argc - 1; i >= 0; i--) {
if_->rsp -= 8;
memcpy(if_->rsp, &arg_addr[i], sizeof(char *));
}
if_->rsp = if_->rsp - 8;
memset(if_->rsp, 0, sizeof(void *));
if_->R.rdi = argc;
if_->R.rsi = if_->rsp + 8;
}
즉 코드의 위에서부터 시작하는데 실제 실행될 때는 코드의 아랫부분에 저장한 값부터 사용된다는 것이다.
char *arg_addr[100];
int argv_len;
- arg_addr는 각 인자 문자열이 복사된 위치를 기억하기 위한 배열이다.
- 최대 100개의 인자를 처리한다고 가정한다.
문자열 인자들을 스택에 복사
for (int i = argc - 1; i >= 0; i--) {
argv_len = strlen(argv[i]) + 1;
if_->rsp -= argv_len;
memcpy(if_->rsp, argv[i], argv_len);
arg_addr[i] = if_->rsp;
}
- 인자 문자열을 끝에서부터 스택에 복사한다.
- rsp는 위쪽으로 감소한다.
- arg_addr[i]에 해당 문자열이 스택 내 어느 위치에 복사되었는지를 기록한다.
스택 정렬 (8바이트)
while (if_->rsp % 8)
*(uint8_t *)(--if_->rsp) = 0;
- 스택은 8바이트 정렬이 되어야 한다. (%rsp가 8의 배수)
- 1바이트씩 0을 채워 정렬을 맞춘다.
argv[argc] = NULL 추가
if_->rsp -= 8;
memset(if_->rsp, 0, sizeof(char *));
- argv 배열의 끝은 NULL이어야 하므로 0을 추가한다.
여기서 보면 char* 데이터가 해당 주소에 들어가야 하는 것이다.
각 인자의 주소를 argv[]처럼 다시 스택에 복사
for (int i = argc - 1; i >= 0; i--) {
if_->rsp -= 8;
memcpy(if_->rsp, &arg_addr[i], sizeof(char *));
}
- 아까 저장해둔 각 인자의 시작 주소를 스택에 다시 복사한다.
- 이것이 결국 유저 코드에서 argv[i]로 접근할 수 있는 포인터 배열이다.
위에서 문자열을 메모리에 저장하고 해당 배열의 시작점은 다시 arg_addr 배열에 넣었다. 그 메모리 시작 주소를 다시 기록해서 해당 메모리를 읽어서 배열이 시작되는 주소를 찾아갈 수 있도록 저장하는 것이다.
argv의 시작 주소를 다시 한번 더 push
if_->rsp = if_->rsp - 8;
memset(if_->rsp, 0, sizeof(void *));
- 원래는 argv를 다시 한번 push해서 char **argv 포인터를 만들고,
- 그냥 NULL을 넣은 상태다.
레지스터 설정 (x86-64 Calling Convention)
if_->R.rdi = argc;
if_->R.rsi = if_->rsp + 8;
- rdi = argc
- rsi = argv 배열의 시작 주소 (rsp + 8 → 방금 NULL 넣기 전 주소)이 값을 기반으로 user program의 main(argc, argv)가 실행된다.
그러면 이 레지스터에 어떤 주소를 담아야 할까? 그 정보는 아래 이미지에 있다.
우리가 스택 주소 계산을 통해 얻은 현재 RSP는 0x4747ffb8을 가리키고 있다고 하면
- RDI 값에는 현재 인자의 개수를 : 문자열 파싱을 통해 얻은 count
- RSI 값에는 0x4747ffc0의 주소를 담으라고 하는데 이 값은 rsp에서 8바이트 위의 값이 될 것이다.
이렇게 설정을 하면 다른 기능을 아직 구현하지 않았다는 가정 아래
터미널에서 syscall!!이라는 출력이 발생한 것을 확인할 수 있을 것이다.
추가 : hex_dump 사용
hex_dump는 메모리의 내용을 사람이 읽을 수 있도록 16 잔수 및 ASCII로 출력해 주는 디버깅 함수이다. 스택에 정확히 어떤 값이 쌓였는지 확인할 때 사용하는 도구라고 한다.
hex_dump(_if.rsp, _if.rsp, USER_STACK - _if.rsp, true);
인자 | 설명 |
ofs | 주소/기준 오프셋 (출력용 표시, 보통 rsp 값 등) |
buf_ | 실제 메모리 버퍼의 시작 주소 |
size | 출력할 바이트 수 |
ascii | 오른쪽에 ASCII 문자 출력 여부 (true면 같이 보여줌) |
+ 000000004747ffc0 00 00 00 00 00 00 00 00 | ........|
+ 000000004747ffd0 ed ff 47 47 00 00 00 00-f9 ff 47 47 00 00 00 00 |..GG......GG....|
+ 000000004747ffe0 00 00 00 00 00 00 00 00-00 00 00 00 00 61 72 67 |.............arg|
+ 000000004747fff0 73 2d 73 69 6e 67 6c 65-00 6f 6e 65 61 72 67 00 |s-single.onearg.|
이런 예시를 통해 스택에 인자들을 잘 넣었는지 검증하는 것이다.
예:
- 0x4747ffed은 args-single의 시작 주소이며
- 0x4747fff9는 onearg의 시작 주소이다.
지금 단계는 syscall!!이라는 출력을 확인해서 완전하지 않지만 유저 프로그램을 실행할 최소한의 준비가 되었다는 것을 의미한다. 이제 본격적으로 시스템 콜을 구현하며 테스트 코드를 통과하는 과정을 거칠 것이다.
'크래프톤 정글' 카테고리의 다른 글
[pintos] Week2~3: User Program Part.4 (0) | 2025.05.20 |
---|---|
[pintos] Week2~3: User Program Part.2 (0) | 2025.05.19 |
[pintos] Week2~3: User Program Part.1 (2) | 2025.05.19 |
[pintos] Week1: Priority Scheduling - Part.3 (1) | 2025.05.12 |
[pintos] Week1: Priority Scheduling - Part.2 (0) | 2025.05.12 |