[pintos] Week2~3: User Program Part.3

이전 포스팅들에서 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비트 시스템의 경우

위 이미지는 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!!이라는 출력을 확인해서 완전하지 않지만 유저 프로그램을 실행할 최소한의 준비가 되었다는 것을 의미한다. 이제 본격적으로 시스템 콜을 구현하며 테스트 코드를 통과하는 과정을 거칠 것이다.