User Program _ Argument Passing

일단 인자파싱을 해야 시스템콜이 가능해질겁니다요

Why?

유저 프로그램은 기본적으로 이렇게 실행됩니다

int main(int argc, char *argv[]) {
    // 예시: 시스템 콜 호출
    write(1, argv[1], strlen(argv[1]));
    exit(0);
}

그러나!

  • 만약 인자 파싱이 실패하거나,
  • argv[i]가 잘못된 주소를 가리키거나,
  • 스택에 복사가 안 되어 있거나,

→ 그 상태에서 시스템 콜을 호출하게 되면?

 

→ 유저가 넘긴 포인터를 따라가다 page fault 발생

→ 잘못된 메모리 접근 → 커널 패닉 or 프로세스 종료

 

저도 정말 많은 페이지폴트와 커널 패닉을 겪고 울고싶었습니다

 

아무튼간에

인자 파싱은 시스템콜의 전제조건이라고 볼 수 있습니다

  • 유저 프로그램이 syscall을 올바르게 호출하려면,
  • argv[]에 인자가 제대로 들어 있어야 하고,
  • 그 인자들이 가리키는 주소가 유저 주소 공간에 존재해야 하며,
  • 스택이 올바르게 세팅되어 있어야 한다.

즉, parse_args()load() → 스택 구성

이게 실패하면 syscall이 아니라 프로그램 시작도 못 한다고 볼 수 있죠

 

그러니까 지금 할게 뭐냐

  • PintOS에서 run 'prog arg1 arg2'처럼 명령어를 실행했을 때,
  • 유저 프로그램이 어떻게 main(int argc, char* argv[])로 인자를 받는가?
  • 이를 위한 전체 흐름은 다음 3개의 핵심 함수로 이뤄져 있습니다:
    • process_exec()
    • load()
    • parse_args()
  • 명령어 문자열 "prog arg1 arg2"는 커널 모드에서 파싱되어서
  • 유저 스택에 인자 문자열, 포인터 배열, argc, fake return address까지 직접 구성됩니다
  • 아무튼간 최종적으로 유저 모드로 진입하면서 main(argc, argv) 호출이 가능해진다는 것....

그럼 일단 process_exec부터 봅시다

우선 이 함수는 유저프로그램 실행의 시작점입니다

현재 커널 스레드를 유저 프로그램으로 전환합니다

즉, ELF 실행파일을 로드하고 초기 유저 스택 구성 후 진입합니다

 

/* 현재 실행 컨텍스트를 f_name으로 전환합니다.
 * 실패 시 -1을 반환합니다. */
int process_exec(void *f_name)
{
	char *file_name = f_name;
	char cp_file_name[MAX_BUF];
	memcpy(cp_file_name, file_name, strlen(file_name) + 1);
	bool success;

	/* intr_frame을 thread 구조체 안의 것을 사용할 수 없습니다.
	 * 이는 현재 스레드가 재스케줄될 때,
	 * 그 실행 정보를 해당 멤버에 저장하기 때문입니다. */
	struct intr_frame _if;
	_if.ds = _if.es = _if.ss = SEL_UDSEG;
	_if.cs = SEL_UCSEG;
	_if.eflags = FLAG_IF | FLAG_MBS;

	/* 유저 스택 페이지 할당 */
	// setup_stack(&_if);

	/* 현재 컨텍스트를 제거합니다. */
	process_cleanup();

	// memset(&_if, 0, sizeof _if);

	/* 그리고 이진 파일을 로드합니다. */
	ASSERT(cp_file_name != NULL);
	success = load(cp_file_name, &_if);

	/* 로드 실패 시 종료합니다. */
	palloc_free_page(file_name);
	if (!success)
		return -1;

	// hex_dump(_if.rsp, _if.rsp, USER_STACK - (uint64_t)_if.rsp, true);
	/* 프로세스를 전환합니다. */
	do_iret(&_if);
	NOT_REACHED();
}
  • 새로운 실행 파일 경로와 인자들을 담은 문자열 f_name을 받아서
  • 현재 실행 중인 커널 스레드의 실행 컨텍스트를 새로운 유저 프로세스로 교체하는 함수

복사본 만들기

char *file_name = f_name;
char cp_file_name[MAX_BUF];
memcpy(cp_file_name, file_name, strlen(file_name) + 1);
  • f_name은 커맨드라인 전체 문자열 (예: "args-multiple foo bar").
  • load()에서 이걸 파싱할 거니까, 원본을 보호하려고 복사본 cp_file_name을 만들어 사용하는 것임

유저 모드용 인터럽트 프레임 초기화

struct intr_frame _if;
_if.ds = _if.es = _if.ss = SEL_UDSEG;
_if.cs = SEL_UCSEG;
_if.eflags = FLAG_IF | FLAG_MBS;
  • 유저 프로그램의 초기 상태를 담는 구조체 _if를 준비
  • 세그먼트 레지스터 (ds, es, ss, cs)와 플래그를 유저 모드로 셋팅

기존 프로세스 메모리 / 리소스 제거

process_cleanup();
  • 현재 스레드가 기존에 실행하던 유저 프로그램을 완전히 제거 (page table, open file 등 정리)

프로그램 로드

ASSERT(cp_file_name != NULL);
success = load(cp_file_name, &_if);
  • load()는 cp_file_name을 파싱해서 ELF 실행파일을 로드하고, 유저 스택도 구성하고, 인자도 셋팅함
  • _if에 진입점과 초기 스택 포인터도 설정해줌

로드 실패시 종료

palloc_free_page(file_name);
if (!success)
    return -1;

유저 모드 진입

do_iret(&_if);
NOT_REACHED();
  • do_iret()은 커널에서 유저 모드로 진입하는 실제 작업을 함 (iretq를 내부적으로 실행함)
  • 이후는 절대 도달하면 안 되므로 NOT_REACHED() (여기 도달하면 버그)

parse_args

공백으로 분리된 문자열을 파싱해서,

각 토큰(단어)의 포인터를 argv[] 배열에 저장하는 함수

/* parse_args - 공백(" ")으로 구분된 문자열을 파싱하여 argv[] 배열에 각 인자의 포인터를 저장합니다.
 *
 * 입력:
 *   - target: 전체 명령어 문자열 (예: "prog a b c")
 *   - argv: 파싱된 각 인자의 시작 주소를 저장할 포인터 배열
 *
 * 출력:
 *   - argc: 파싱된 인자의 개수
 *
 * 주의:
 *   - 이 함수는 문자열을 직접 수정합니다. (strtok_r이 내부적으로 '\0' 삽입)
 *   - 따라서 원본 문자열을 보존하려면 복사본을 만들어야 합니다.
 */
static int parse_args(char *target, char *argv[])
{
	int argc = 0; // 인자 개수를 셀 변수

	char *token;     // 현재 파싱 중인 토큰 (단어)
	char *save_ptr;  // strtok_r이 내부 상태를 기억하기 위한 포인터

	/* 첫 번째 토큰부터 시작하여 반복적으로 분리해냄
	 * strtok_r은 첫 호출 시 target을 넣고, 이후에는 NULL을 넣어 이어서 처리함
	 * 공백을 기준으로 문자열을 분리하여 token에 저장
	 */
	for (token = strtok_r(target, " ", &save_ptr);
		 token != NULL;
		 token = strtok_r(NULL, " ", &save_ptr))
	{
		/* 파싱된 토큰의 시작 주소를 argv 배열에 저장
		 * 복사하지 않고 포인터만 저장하므로 매우 효율적임
		 */
		argv[argc++] = token;
	}

	/* 마지막에는 C 관례에 따라 NULL 포인터를 추가해 종료 표시
	 * 이는 exec 시 argv[argc] = NULL을 요구하는 표준에 맞춤
	 */
	argv[argc] = NULL;

	/* 최종적으로 파싱된 인자 개수를 반환 */
	return argc;
}

 

이 함수를 거치면 예를 들어

 

Index argv[i] 실제 메모리 내 문자열
0 argv[0] → “prog” ‘p’‘r’‘o’‘g’’\0’
1 argv[1] → “a” ‘a’’\0’
2 argv[2] → “b” ‘b’’\0’
3 argv[3] → “c” ‘c’’\0’
4 argv[4] = NULL  

raw 문자열은 파괴되어 "prog\0a\0b\0c\0" 형태가 됨

이렇게 파싱이 됩니다

 

주의할 점은

  • strtok_r()는 입력 문자열 자체를 파괴합니다
  • → 따라서 f_name을 바로 쓰지 말고, memcpy()로 복사해 사용해야 합니다
  • 하지만 저희는 strtok_r을 썼슴다
  • argv[]포인터 배열입니다
  • → 문자열 내용을 복사하지 않기 때문에, 진짜 스택 위에 문자열을 올려야 할 땐 별도 복사가 필요합니다
  • 어디에서? load에서!~~

하지만

 

과제 설명서에 이렇게나 친절하게 나와있어서 저희는 strtok_r을 사용했답니다


load 함수

load함수는 사용자 프로그램을 메모리에 적재하고, 사용자 스택을 직접 구성하는 핵심 함수랍니다

크게 4단계로 나뉘는데

  • 인자파싱(parse_args())
  • ELF 실행 파일 업로드
  • 사용자 스택 할당
  • 사용자 스택 구성

으로 나뉩니다

결론적으론 load()는 ELF 바이너리를 메모리에 올리고, main(argc, argv)가 제대로 동작하도록 인자 배열을 스택에 직접 구성하는 함수

인자 파싱부터 스택 구성까지 모든 걸 커널이 수동으로 해주는 구조

암튼 load함수를 보면

/* FILE_NAME에서 현재 스레드로 ELF 실행 파일을 로드합니다.
 * 실행 진입점은 *RIP에, 초기 스택 포인터는 *RSP에 저장됩니다.
 * 성공 시 true, 실패 시 false를 반환합니다. */
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;

	char *argv[MAX_ARGS];
	int argc = parse_args(file_name, argv);
	uint64_t rsp_arr[argc];

	/* 페이지 디렉터리를 할당하고 활성화합니다. */
	t->pml4 = pml4_create();
	if (t->pml4 == NULL)
		goto done;
	process_activate(thread_current());

	/* 실행 파일을 엽니다. */
	file = filesys_open(file_name);
	if (file == NULL)
	{
		printf("load: %s: open failed\n", file_name);
		goto done;
	}

	/* 실행 헤더를 읽고 검증합니다. */
	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;
	}

	/* 프로그램 헤더들을 읽습니다. */
	file_ofs = ehdr.e_phoff;
	for (i = 0; i < ehdr.e_phnum; i++)
	{
		struct Phdr phdr;

#ifdef WSL
		// WSL 전용 코드
		off_t phdr_ofs = ehdr.e_phoff + i * sizeof(struct Phdr);
		file_seek(file, phdr_ofs);
		if (file_read(file, &phdr, sizeof phdr) != sizeof phdr)
			goto done;
#else
		// docker(기본) 전용 코드
		if (file_ofs < 0 || file_ofs > file_length(file))
			goto done;
		file_seek(file, file_ofs);
#endif

		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:
			/* 이 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;
		}
	}

	/* 스택을 설정합니다. */
	if (!setup_stack(if_))
		goto done;

	/* 시작 주소를 설정합니다. */
	if_->rip = ehdr.e_entry;

	/* TODO: 여기에 코드를 작성하세요.
	 * TODO: 인자 전달을 구현하세요 (project2/argument_passing.html 참고). */
	for (int i = argc - 1; i >= 0; i--)
	{
		if_->rsp -= strlen(argv[i]) + 1;
		rsp_arr[i] = if_->rsp;
		memcpy((void *)if_->rsp, argv[i], strlen(argv[i]) + 1);
	}

	while (if_->rsp % 8 != 0)
	{
		if_->rsp--;				  // 주소값을 1 내리고
		*(uint8_t *)if_->rsp = 0; // 데이터에 0 삽입 => 8바이트 저장
	}

	if_->rsp -= 8; // NULL 문자열을 위한 주소 공간, 64비트니까 8바이트 확보
	memset(if_->rsp, 0, sizeof(char **));

	for (int i = argc - 1; i >= 0; i--)
	{
		if_->rsp -= 8; // 8바이트만큼 rsp감소
		memcpy(if_->rsp, &rsp_arr[i], sizeof(char **));
	}

	if_->rsp -= 8;
	memset(if_->rsp, 0, sizeof(void *));

	if_->R.rdi = argc;
	if_->R.rsi = if_->rsp + 8;

	success = true;

done:
	/* load의 성공 여부와 상관없이 여기로 도달합니다. */
	file_close(file);
	return success;
}

파싱과 메모리 구조 준비

struct thread *t = thread_current();
...
int argc = parse_args(file_name, argv);
uint64_t rsp_arr[argc];
  • 현재 스레드를 불러오고
  • file_name 문자열을 공백 기준으로 잘라 argv[]에 인자 주소 저장 (parse_args)
    • 예: "args-multiple foo bar" → argv[0]="args-multiple", argv[1]="foo", ...
  • rsp_arr[i]는 이후 유저 스택에 각 문자열이 복사된 위치를 저장하는 배열

페이지 디렉토리 생성 및 활성화

t->pml4 = pml4_create();
if (t->pml4 == NULL)
    goto done;
process_activate(thread_current());
  • 새 주소 공간(Page Table)을 생성하여 이 스레드에 연결
  • process_activate()는 해당 페이지 테이블을 현재 프로세스에 적용함

ELF 파일 열기

file = filesys_open(file_name);
if (file == NULL)
{
    printf("load: %s: open failed\\n", file_name);
    goto done;
}
  • 실행 파일을 파일 시스템에서 염
  • 실패하면 load() 중단하고 false 반환

ELF헤더 읽고 검증

if (file_read(file, &ehdr, sizeof ehdr) != sizeof ehdr || ... )
{
    printf("load: %s: error loading executable\\n", file_name);
    goto done;
}
  • ELF 헤더를 읽고, 형식이 올바른 ELF 64비트 실행파일인지 확인함

프로그램 헤더(세그먼트 테이블)읽기

for (i = 0; i < ehdr.e_phnum; i++) {
    ...
    if (phdr.p_type == PT_LOAD) {
        ...
        load_segment(...);
    }
}
  • ELF 파일에는 여러 개의 “세그먼트”(LOAD 가능한 영역)가 정의돼 있음
  • PT_LOAD 타입만 메모리에 로딩하고, 읽을 바이트 수, 0으로 채울 바이트 수 계산 후 load_segment() 호출

유저 스택 설정

if (!setup_stack(if_))
    goto done;
  • 유저 주소 공간의 스택 페이지를 맨 위에 할당하고, rsp를 초기화

진입점(rip)설정

if_->rip = ehdr.e_entry;
  • ELF 헤더에 정의된 e_entry 값을 rip에 저장 → 유저 모드에서 실행 시작할 주소

과제 설명서에 이런식으로 나와있을텐데 스택구성을 해줘야만 사용자 프로그램이 정상적으로 실행됩니다
아마 이과정에서 수많은 커널패닉과 페이지폴트를 겪으시겠조

  • 문자열은 역순으로 스택에 복사
  • 포인터 배열도 역순으로 스택에 삽입
  • 항상 8바이트 정렬을 맞춰야 함
  • argv[argc] = NULL 포인터 필요
  • 가장 마지막에 fake return address = 0 삽입
  • rdi = argc, rsi = argv 포인터 배열 시작 주소

인자 문자열들을 유저 스택에 복사

for (int i = argc - 1; i >= 0; i--) {
    if_->rsp -= strlen(argv[i]) + 1;
    rsp_arr[i] = if_->rsp;
    memcpy((void *)if_->rsp, argv[i], strlen(argv[i]) + 1);
}
  • 스택에 문자열을 역순으로 복사
  • 각 문자열의 주소를 rsp_arr[i]에 저장해놓음

스택 정렬(8byte 정렬-패딩넣기)

while (if_->rsp % 8 != 0) {
    if_->rsp--;
    *(uint8_t *)if_->rsp = 0;
}
  • x86-64 ABI에 따라 8바이트 정렬해야 함

argv배열 만들기

if_->rsp -= 8; // NULL 포인터
memset(if_->rsp, 0, sizeof(char **));

for (int i = argc - 1; i >= 0; i--) {
    if_->rsp -= 8;
    memcpy(if_->rsp, &rsp_arr[i], sizeof(char **));
}
  • argv[argc] = NULL 추가
  • 각 argv[i] 주소들을 스택에 역순으로 저장 (포인터 배열 생성)

fake return address 삽입

if_->rsp -= 8;
memset(if_->rsp, 0, sizeof(void *));
  • 유저 main() 함수 호출 후 돌아갈 주소지만, PintOS에선 사용 안 하므로 0

레지스터에 인자 전달(rdi, rsi)

if_->R.rdi = argc;              // 첫 번째 인자
if_->R.rsi = if_->rsp + 8;      // argv[0]의 주소 (rsp 바로 위에 있음)
  • x86-64 리눅스 ABI에 맞춰 인자 전달
  • rdi ← argc, rsi ← argv 배열 시작 주소

성공 표시

success = true;

정리 및 반환

done:
file_close(file);
return success;

 


아무튼간 정리하자면

1. process_exec(f_name)
   └─ 유저 프로세스를 시작하기 위한 진입점
   └─ 문자열 (ex: "prog a b c")을 load()에 전달

2. load(file_name, &if_)
   ├─ parse_args(): 공백 단위로 파싱 → argv[] 배열 구성
   ├─ ELF 실행파일을 열어서 segment들을 메모리에 로딩
   ├─ setup_stack(): 사용자 스택 할당
   └─ 인자들 스택에 아래 순서대로 넣기
         ① 인자 문자열 (ex: "a\0", "b\0" 등)
         ② 문자열 주소들 (argv[i])
         ③ argv 배열 주소
         ④ argc
         ⑤ fake return address

3. load() 성공 시
   └─ if_.rip ← 실행 시작 주소
   └─ if_.rsp ← 스택 포인터
   └─ if_.rdi ← argc, if_.rsi ← argv
   → 이 정보로 유저 모드 진입 가능

4. do_iret(&if_)
   └─ 유저 모드 전환 + main(argc, argv) 시작

이런 흐름인거고

이 과제는 왜하는 것인가 라고 한다면

 

“커널이 process_exec()을 호출하면
그 안에서 load()가 인자 파싱하고,

스택에 맞게 인자들을 복사한 뒤
레지스터 세팅을 하고 유저 모드로 진입하는 과정”

이라 할 수 있고!

 

왜 스택에 인자를 직접 넣어줘야 하는가! 라고 한다면

pintos는 일반적인 운영체제와 달리, 사용자 프로그램의 실행환경을 커널이 수동으로 구성해줘야 합니다

즉, main(int argc, char* argv[])이 호출되기 위해서는

  1. 문자열 복사 ("prog", "a", "b"…)
  2. 포인터 배열 (argv[i])
  3. 정렬 패딩
  4. NULL 포인터, argc, return address

 

까지 모두 커널이 직접 유저 스택에 push해줘야 한다~~!

 

이 작업들은 load()함수 내부에서 이루어지며,

최종적으로는 유저 스택의 구조는 C프로그램의 main함수가 기대하는 형태와 일치하게 된다는 겁니다잉

 

인자파싱에서 기억할 건

  • parse_args: 문자열 → 인자 배열로 분리 (포인터만 저장)
  • load: ELF 파일 로드 + 스택 구성 + 레지스터 설정
  • 유저모드 진입: do_iret()을 통해 실행

그리고 테스트를 통과하려면 process_wait에 무한루프를 걸어줘야한다고 하더라구요

참고하시고 그 뒤 수정본으로 포스팅 될 것 같네요

 

다음은 이제 시스템콜로 돌아오겠어요