개발/프로젝트

Pintos · Project 2 User Programs · 1편

cedis 2026. 5. 2. 01:14

운영체제 · Pintos · Project 2 User Programs · 1편

Pintos Argument Passing: 프로그램 실행 인자를 argc와 argv로 넘기기

Project 1에서는 thread를 언제 재우고, 어떤 순서로 깨우고, priority를 어떻게 다룰지 봤다. Project 2로 넘어오면 질문이 바뀐다. 이제 커널은 단순히 thread를 돌리는 것을 넘어서, 유저 프로그램을 실행 가능한 형태로 시작시켜야 한다. 이번 글은 그 첫 단계인 Argument Passing을 정리한다.

이번 글에서 다루는 것

  • args-single onearg 같은 문자열을 그냥 실행 파일 이름으로 쓰면 안 되는지
  • 실행 파일 이름과 프로그램 인자를 어떻게 분리했는지
  • user stack에 문자열, argv 포인터, NULL, fake return address를 어떤 순서로 쌓는지
  • rdirsi에 값을 넣어야 하는지
  • args 테스트를 위해 넣은 sema_down(), sema_up() 구조가 왜 임시인지
  • 파일별 최종 코드 블록을 어떤 순서로 붙이면 되는지
  • 최종본 코드를 리뷰할 때 통과로 볼 부분과 다음 단계에서 반드시 갈아엎어야 할 부분

이 글의 완성 범위

이 글은 Project 2 전체 구현 글이 아니다. 범위를 정확히 말하면 args 계열 테스트를 통과시키기 위한 Argument Passing 완성본이다. 그래서 본문에는 유지할 코드와 임시로 둘 코드를 의도적으로 분리해 둔다.

이번 글에서 완성하는 것

실행 명령을 argc, argv로 바꾸고 user stack에 올리는 Argument Passing 흐름.

이번 글에서 임시로 두는 것

args 출력 확인을 위한 최소 SYS_WRITE, SYS_EXIT, 전역 세마포어 기반 wait.

아직 완성이 아닌 것

정식 wait(pid), user pointer 검증, file descriptor, file syscall, exec, fork.

핵심부터 말하면

Argument Passing은 커널이 받은 실행 명령을 유저 프로그램의 main(argc, argv)가 이해할 수 있는 모양으로 바꿔주는 작업이다.

커널이 처음 받는 것

"args-single onearg"

공백이 들어 있는 하나의 문자열

실행 파일

argv[0]

"args-single"

인자 개수

argc

2

실행 인자

argv[1]

"onearg"

끝 표시

argv[2]

NULL

즉 이번 구현은 하나의 문자열실행 파일 이름, 인자 개수, 인자 배열로 번역해서 user stack과 레지스터에 배치하는 일이다.

1. 왜 이걸 해야 할까?

테스트는 이런 식으로 유저 프로그램을 실행한다.

run 'args-single onearg'

이때 커널에 들어오는 문자열은 사실상 args-single onearg 전체다. 그런데 실제 실행 파일 이름은 args-single이다. onearg는 파일 이름이 아니라 프로그램에 넘겨야 할 인자다.

잘못 보면

load("args-single onearg")처럼 전체 문자열을 파일 이름으로 본다.

제대로 보면

load("args-single")로 파일을 열고, oneargargv[1]로 넘긴다.

그래서 Argument Passing의 첫 질문은 “스택에 뭘 넣을까?”가 아니라, 이 문자열에서 어디까지가 실행 파일 이름이고 어디부터가 인자인가?였다.

2. 전체 실행 흐름

먼저 흐름을 하나로 보면 다음과 같다. 이 흐름을 잡고 나면 process_create_initd()process_exec()가 왜 나뉘어 있는지 이해하기 쉬워진다.

run 명령 process_create_initd initd process_exec do_iret main
process_create_initd("args-single onearg")
    |
    | fn_copy     = "args-single onearg"   // argument parsing용
    | thread_name = "args-single"          // thread 이름, 종료 메시지용
    v
process_exec("args-single onearg")
    |
    | argv[0] = "args-single"
    | argv[1] = "onearg"
    | argc = 2
    | user stack 구성
    v
_start(argc, argv) -> main(argc, argv)

3. 첫 번째 수정: 실행 파일 이름과 전체 명령줄을 분리하기

처음에 헷갈렸던 지점은 file_name이라는 이름이었다. 이름만 보면 실행 파일 이름처럼 보이지만, 실제로는 공백이 포함된 전체 명령줄이 들어올 수 있다.

내용 용도
fn_copy "args-single onearg" 나중에 process_exec()에서 argc/argv를 만들기 위해 전체 문자열 보관
thread_name "args-single" thread 이름과 종료 메시지에 사용
strlcpy (fn_copy, file_name, PGSIZE);

strlcpy (thread_name, file_name, sizeof thread_name);
strtok_r (thread_name, " ", &save_ptr);

tid = thread_create (thread_name, PRI_DEFAULT, initd, fn_copy);

여기서 핵심은 fn_copythread_name의 역할을 분리한 것이다. fn_copy까지 첫 단어로 잘라버리면 뒤의 인자를 잃어버린다. 반대로 thread_name에 전체 명령줄을 넣으면 종료 메시지가 args-single one: exit(0)처럼 어긋난다.

4. 두 번째 수정: user stack에 argc/argv 만들기

커널 안에서 argv 배열을 만들었다고 해서 유저 프로그램이 바로 볼 수 있는 것은 아니다. 커널 메모리와 유저 메모리는 구분된다. 그래서 유저 프로그램이 시작할 때 볼 수 있도록 user stack 위에 문자열과 포인터 배열을 직접 쌓아야 한다.

예: args-single onearg

argc

2

argv[0]

"args-single"

argv[1]

"onearg"

argv[2]

NULL

Stack을 쌓는 순서

user stack은 높은 주소에서 낮은 주소 방향으로 내려가며 쌓인다. 그래서 문자열도 마지막 인자부터 복사하고, 포인터도 뒤에서부터 넣는다.

높은 주소
  "args-single\0"
  "onearg\0"
  padding
  NULL                 // argv[argc]
  &"onearg"
  &"args-single"
  fake return address
낮은 주소  <- rsp
순서 무엇을 넣나 왜 필요한가
1 문자열 자체 유저 프로그램이 실제 인자 문자열을 읽을 수 있어야 한다.
2 8바이트 정렬 padding x86-64 호출 규약에 맞게 stack 정렬을 맞춘다.
3 argv[argc] = NULL 인자 배열의 끝을 표시한다.
4 각 문자열의 주소 argv[0], argv[1]이 문자열을 가리키게 한다.
5 rdi = argc, rsi = argv x86-64에서 첫 번째, 두 번째 함수 인자를 레지스터로 전달한다.
6 fake return address 일반 함수 호출처럼 보이도록 stack 모양을 맞춘다.

5. 핵심 코드: 문자열 복사, 정렬, 포인터 배치

실제 구현에서 가장 실수하기 쉬웠던 부분은 while 위치였다. padding을 넣는 while은 문자열 복사 for문 밖에 있어야 한다. 안쪽에 들어가면 정렬 상태에 따라 argv 포인터를 아예 안 넣거나, padding 1바이트마다 반복해서 stack을 망가뜨릴 수 있다.

for (int i = argc - 1; i >= 0; i--) {
	size_t len = strlen (argv[i]) + 1;
	_if.rsp -= len;
	memcpy ((void *) _if.rsp, argv[i], len);
	arg_addr[i] = (char *) _if.rsp;
}

while (_if.rsp % 8 != 0) {
	_if.rsp--;
	*(uint8_t *) _if.rsp = 0;
}

_if.rsp -= sizeof (char *);
*(char **) _if.rsp = NULL;

for (int i = argc - 1; i >= 0; i--) {
	_if.rsp -= sizeof (char *);
	*(char **) _if.rsp = arg_addr[i];
}

_if.R.rdi = argc;
_if.R.rsi = _if.rsp;

_if.rsp -= sizeof (void *);
*(void **) _if.rsp = 0;

여기서 기억할 점

_if.rsp는 주소값을 담은 정수처럼 다루어진다. 그래서 memcpy()에 넘길 때는 (void *)로 캐스팅하고, 나중에 argv 포인터로 보관할 때는 (char *)로 캐스팅했다.

6. fake return address는 왜 넣을까?

이 부분은 처음 보면 가장 이상하다. 실제로 돌아갈 주소도 아닌데 왜 stack에 0을 넣을까?

_if.rsp -= sizeof (void *);
*(void **) _if.rsp = 0;

이유는 Pintos가 유저 프로그램의 _start를 일반적인 call 명령으로 호출하지 않기 때문이다. 커널은 CPU 상태를 만들어 놓고 유저 모드로 넘어간다. 일반적인 함수 호출이라면 stack에는 return address가 자동으로 들어가지만, 여기서는 그런 호출이 없었다.

일반 함수 호출

call 명령이 return address를 stack에 자동으로 넣는다.

Pintos 유저 프로그램 시작

커널이 rip, rsp, rdi, rsi를 세팅하고 유저 모드로 넘어간다.

fake return address는 실제로 돌아가기 위한 주소가 아니다. _start(argc, argv)가 평범한 함수처럼 시작된 것처럼 stack 모양을 맞추기 위한 자리다. 정상 흐름에서는 _start()main()을 호출하고, 그 결과를 exit()로 넘기므로 fake return address로 돌아갈 일이 없다.

7. args 테스트를 위해 넣은 임시 wait 구조

Argument Passing만 맞게 만들어도 테스트가 바로 통과하지 않을 수 있었다. 이유는 process_wait()가 바로 return -1하면 부모 흐름이 자식 유저 프로그램을 기다리지 않고 끝날 수 있기 때문이다.

부모

자식 프로그램이 끝날 때까지 sema_down()으로 기다린다.

자식

종료 시 sema_up()으로 “끝났다”는 신호를 보낸다.

static struct semaphore initd_wait_sema;

int
process_wait (tid_t child_tid UNUSED) {
	sema_down (&initd_wait_sema);
	return 0;
}

void
process_exit (void) {
	sema_up (&initd_wait_sema);
	process_cleanup ();
}

중요: 이건 정식 wait(pid) 구현이 아니다

현재 구조는 args 테스트를 보기 위한 임시 대기 장치다. 전역 세마포어 하나로 처음 실행한 유저 프로그램 종료만 기다린다. 정식 wait(pid)에서는 특정 자식 pid 확인, exit status 반환, 중복 wait 방지, 부모와 자식의 종료 순서 처리까지 해야 한다. 나중에는 child_info 같은 부모-자식 관리 구조로 교체해야 한다.

8. 원본 대비 실제 코드 조립본

여기부터는 설명용 조각이 아니라, 이번 글 범위에서 원본 대비 추가하거나 교체해야 하는 코드 블록 전체다. 단, 이 블록은 Argument Passing과 args 테스트 통과를 위한 최소 구현이다. 정식 wait(pid), 전체 syscall, user pointer 검증은 다음 단계에서 다시 확장해야 한다.

조립 순서

  1. process.c에 세마포어 include와 전역 세마포어를 추가한다.
  2. process_create_initd()에서 thread 이름과 전체 명령줄을 분리한다.
  3. process_exec()에서 명령줄 parsing과 user stack 구성을 넣는다.
  4. process_wait(), process_exit()에 임시 대기 흐름을 넣는다.
  5. syscall.c에서 최소 SYS_WRITE, SYS_EXIT를 처리한다.
파일 수정할 함수/위치 역할
userprog/process.c include, 전역 세마포어, process_create_initd(), process_exec(), process_wait(), process_exit() 실행 명령 parsing, user stack 구성, 임시 대기 흐름
userprog/syscall.c include, syscall_handler() args 테스트 출력용 write와 종료용 exit 최소 처리
include/threads/thread.h 이번 최종본 기준 추가 없음 이전에 실험으로 넣은 wait_sema가 남아 있다면 이번 커밋에서 제외하거나 제거한다.

8-1. process.c 상단 준비

sema_init(), sema_down(), sema_up()을 쓰려면 threads/synch.h가 필요하다. 그리고 args 테스트용 임시 대기 장치로 전역 세마포어를 하나 둔다.

#include <string.h>
#include "threads/palloc.h"
#include "threads/thread.h"
#include "threads/synch.h"
#include "threads/mmu.h"
#include "threads/vaddr.h"

static struct semaphore initd_wait_sema;

이미 같은 include가 있으면 중복해서 넣지 않는다. initd_wait_sema는 정식 wait 구현이 아니라, 현재 args 테스트에서 부모가 자식 종료 전에 먼저 끝나는 것을 막는 임시 장치다.

8-2. process_create_initd() 최종 블록

이 함수의 핵심은 fn_copythread_name을 분리하는 것이다. 하나는 argument parsing용 전체 명령줄이고, 다른 하나는 thread 이름과 종료 메시지용 첫 단어다.

tid_t
process_create_initd (const char *file_name) {
	char *fn_copy;
	char thread_name[16];
	char *save_ptr;
	tid_t tid;

	sema_init (&initd_wait_sema, 0);

	/* 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);

	strlcpy (thread_name, file_name, sizeof thread_name);
	strtok_r (thread_name, " ", &save_ptr);

	/* Create a new thread to execute FILE_NAME. */
	tid = thread_create (thread_name, PRI_DEFAULT, initd, fn_copy);
	if (tid == TID_ERROR)
		palloc_free_page (fn_copy);
	return tid;
}
코드 역할
fn_copy 전체 명령줄을 보존한다. 예: "args-single onearg"
thread_name 첫 단어만 남긴다. 예: "args-single"
thread_create(thread_name, ..., fn_copy) thread 이름은 실행 파일 이름으로, 실제 인자 처리는 전체 명령줄로 진행한다.

8-3. process_exec() 최종 블록

이 함수가 Argument Passing의 본체다. 전체 명령줄을 토큰으로 나누고, 첫 토큰만 load()에 넘긴 뒤, user stack에 argc/argv 모양을 직접 만든다.

int
process_exec (void *f_name) {
	char *cmd_line = f_name;
	char *file_name;
	char *save_ptr;
	char *argv[64];
	int argc = 0;
	char *token;
	char *arg_addr[64];
	bool success;

	file_name = strtok_r (cmd_line, " ", &save_ptr);
	if (file_name == NULL) {
		palloc_free_page (cmd_line);
		return -1;
	}

	argv[argc++] = file_name;

	while ((token = strtok_r (NULL, " ", &save_ptr)) != NULL) {
		if (argc >= 64) {
			palloc_free_page (cmd_line);
			return -1;
		}
		argv[argc++] = token;
	}

	/* 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 ();

	/* And then load the binary */
	success = load (file_name, &_if);
	if (success) {
		for (int i = argc - 1; i >= 0; i--) {
			size_t len = strlen (argv[i]) + 1;
			_if.rsp -= len;
			memcpy ((void *) _if.rsp, argv[i], len);
			arg_addr[i] = (char *) _if.rsp;
		}

		while (_if.rsp % 8 != 0) {
			_if.rsp--;
			*(uint8_t *) _if.rsp = 0;
		}

		_if.rsp -= sizeof (char *);
		*(char **) _if.rsp = NULL;

		for (int i = argc - 1; i >= 0; i--) {
			_if.rsp -= sizeof (char *);
			*(char **) _if.rsp = arg_addr[i];
		}

		_if.R.rdi = argc;
		_if.R.rsi = _if.rsp;

		_if.rsp -= sizeof (void *);
		*(void **) _if.rsp = 0;
	}

	/* If load failed, quit. */
	palloc_free_page (cmd_line);
	if (!success)
		return -1;

	/* Start switched process. */
	do_iret (&_if);
	NOT_REACHED ();
}

여기서 가장 많이 틀렸던 지점

while (_if.rsp % 8 != 0) 블록은 문자열 복사 for문 밖에 있어야 한다. 이 블록 안에는 padding만 들어가야 하고, argv[argc] = NULL이나 argv 포인터 push가 들어가면 stack이 깨진다.

8-4. process_wait() / process_exit() 최종 블록

이 코드는 args 테스트를 진행하기 위한 임시 대기 구조다. 부모는 process_wait()에서 기다리고, 자식은 process_exit()에서 깨운다.

int
process_wait (tid_t child_tid UNUSED) {
	sema_down (&initd_wait_sema);
	return 0;
}

void
process_exit (void) {
	sema_up (&initd_wait_sema);

	process_cleanup ();
}

비전공자식으로 말하면 sema_down()은 “기다리기”이고, sema_up()은 “기다리던 쪽 깨우기”다. 하지만 이 구조는 특정 pid의 자식을 기다리는 정식 wait가 아니다.

8-5. syscall.c 최소 구현

args 테스트는 단순히 stack만 맞는다고 끝나지 않는다. 유저 프로그램이 printf()로 출력하려면 내부적으로 write(1, buffer, size) syscall이 처리되어야 한다. 또한 main()이 끝나면 exit()도 호출된다.

먼저 syscall.c 상단에서 아래 include를 확인한다. 특히 SYS_WRITE, SYS_EXIT 번호는 syscall-nr.h에 있고, putbuf()는 콘솔 출력 함수다.

#include "userprog/syscall.h"
#include <stdio.h>
#include <syscall-nr.h>
#include "threads/interrupt.h"
#include "threads/thread.h"
#include "lib/kernel/console.h"
void
syscall_handler (struct intr_frame *f) {
	switch (f->R.rax) {
		case SYS_EXIT:
			printf ("%s: exit(%d)\n", thread_name (), (int) f->R.rdi);
			thread_exit ();
			break;

		case SYS_WRITE:
			if ((int) f->R.rdi == 1) {
				const char *buffer = (const char *) f->R.rsi;
				unsigned size = (unsigned) f->R.rdx;

				putbuf (buffer, size);
				f->R.rax = size;
			} else {
				f->R.rax = -1;
			}
			break;

		default:
			thread_exit ();
			break;
	}
}
레지스터 SYS_WRITE에서 의미 예시
rax syscall 번호 SYS_WRITE
rdi fd 1이면 stdout
rsi buffer 주소 출력할 문자열 위치
rdx size 출력할 바이트 수

9. 테스트 결과와 커밋 기준

현재 단계에서는 args 계열 테스트가 통과하면 Argument Passing 요구사항은 큰 틀에서 완료로 볼 수 있다. 이번 작업에서는 관련 테스트 9개 통과를 확인했고, 확인한 핵심 요구는 다음과 같다.

요구 확인한 내용
명령줄 분리 공백 기준으로 argc, argv 구성
실행 파일 로드 첫 단어만 load()에 전달
user stack 구성 문자열, padding, NULL, argv 포인터, fake return address 배치
레지스터 전달 rdi = argc, rsi = argv
출력/종료 흐름 최소 SYS_WRITE, SYS_EXIT, 임시 wait 흐름으로 args 테스트 출력 확인

커밋 메시지 예시

프로그램 실행 시 argc와 argv가 전달되도록 구현

팀원에게 설명할 때는 “프로그램 실행 시 인자를 넘기는 기능을 구현했다”고 말하면 된다. 다만 wait 관련 코드는 최종 구현이 아니라 args 테스트를 위한 임시 대기 처리라는 점을 같이 말해야 한다.

검증 명령

구현 후에는 전체 make check보다 먼저 args 묶음만 좁게 확인하는 편이 좋다. 이 단계에서 봐야 하는 것은 Project 2 전체 통과가 아니라, argc/argv 전달과 출력/종료 흐름이 맞는지다.

cd pintos/userprog
make
cd build
source ../../activate

make tests/userprog/args-none.result
make tests/userprog/args-single.result
make tests/userprog/args-multiple.result
make tests/userprog/args-many.result
make tests/userprog/args-dbl-space.result

for f in tests/userprog/args-*.result; do
  echo "==== $f ===="
  cat "$f"
done

PASS로 판단하는 기준

argc, argv[0], argv[1...], argv[argc] = null 출력이 기대값과 맞고, 마지막 종료 메시지가 args-single: exit(0)처럼 실행 파일 이름만 포함하면 된다.

10. 최종본 코드 리뷰

테스트가 통과했다고 해서 모든 코드가 최종 설계라는 뜻은 아니다. 이번 커밋은 Argument Passing을 통과시키는 코드args 테스트를 보기 위해 잠시 넣은 실행 보조 코드가 섞여 있다. 그래서 리뷰 기준을 분리해서 봐야 한다.

리뷰 대상 판단 이유
thread_name 분리 유지 실행 파일 이름과 전체 명령줄을 분리해야 종료 메시지와 argument parsing이 동시에 맞는다.
load(argv[0]) 유지 실제 파일 이름은 첫 번째 토큰이다. 전체 명령줄을 파일 이름으로 넘기면 로드 대상이 틀어진다.
user stack 구성 유지 문자열, 정렬 padding, NULL sentinel, argv 포인터, fake return address가 모두 들어가야 _start(argc, argv)가 기대한 모양이 된다.
rdi, rsi 설정 유지 x86-64 호출 규약에서 첫 번째 인자는 rdi, 두 번째 인자는 rsi로 전달된다.
전역 initd_wait_sema 임시 args 테스트에서 부모가 너무 빨리 끝나지 않게 하는 발판이다. 여러 자식, 특정 pid, exit status, 중복 wait는 처리하지 못한다.
최소 SYS_WRITE, SYS_EXIT 범위 제한 현재는 args 출력 확인을 위한 최소 기능이다. user pointer 검증과 전체 syscall 정책은 다음 단계에서 보강해야 한다.

리뷰 결론

이번 커밋에서 믿어도 되는 부분

명령줄을 나누고, 실행 파일 이름만 로드하고, user stack에 argc/argv를 배치하는 Argument Passing 흐름은 유지해도 된다.

다음 단계에서 바꿔야 하는 부분

전역 세마포어 기반 wait는 정식 wait(pid) 구현 전에 child_info 기반 구조로 교체해야 한다.

커밋 전 확인할 것

  • pintos/.vscode/pintos-test-history.json 같은 개인 테스트 기록 파일은 커밋하지 않는다.
  • 실제로 쓰지 않는 wait_sema 필드를 thread.h에 추가했다면 이번 커밋에서 제거하거나 별도 커밋으로 분리한다.
  • #include "threads/vaddr.h" 같은 중복 include가 남아 있으면 정리한다.
  • 팀원에게 공유할 때는 “Argument Passing은 완료, wait는 임시”라는 경계를 반드시 같이 적는다.

이번 구현에서 특히 조심할 점

  • padding while 위치: 문자열 복사 for문 밖에 있어야 한다.
  • thread 이름: 종료 메시지를 맞추려면 전체 명령줄이 아니라 첫 단어만 넘긴다.
  • fn_copy: 인자 파싱을 위해 전체 명령줄을 보존해야 한다.
  • fake return address: 실제 복귀용 주소가 아니라 호출 규약상 stack 모양을 맞추는 자리다.
  • 임시 wait: 전역 세마포어 방식은 args 테스트용 발판이고, 정식 wait(pid) 구현은 아니다.

스스로 점검

  1. args-single onearg에서 왜 load("args-single onearg")가 아니라 load("args-single")이어야 할까?
  2. 커널에 있는 argv 배열을 그대로 쓰지 않고 user stack에 다시 올리는 이유는 무엇일까?
  3. argv[argc] = NULL은 어떤 역할을 할까?
  4. 지금의 process_wait()가 정식 wait(pid)가 아닌 이유는 무엇일까?

이번 글에서 기억할 것

Argument Passing은 단순히 문자열을 자르는 작업이 아니다. 커널이 받은 실행 명령을 유저 프로그램이 기대하는 호출 규약과 메모리 구조로 바꾸는 작업이다.

이번 단계의 핵심은 세 가지다. 첫째, 실행 파일 이름과 인자를 분리한다. 둘째, user stack에 argc/argv 구조를 만든다. 셋째, 현재 wait 처리는 임시이므로 System Calls의 정식 wait(pid) 구현에서 반드시 다시 설계한다.

정리 기준: Pintos Project 2 Argument Passing 구현 기록, args 계열 테스트 진행 과정, 팀원 공유용 설명 메모.