Post

[구현] Pintos Project 2 : Argument Passing

과제 설명은 이 링크에 정리해 두었다.
그럼 이제 Argument Passing 기능 구현해나가보자!

목표

유저 프로그램 실행 시 인자로 전달되는 문자열을 유저 프로그램에 전달하는 기능이다.

  • 커맨드 라인 파싱
    • strtok_r()을 이용해 공백 기준으로 문자열 분리
    • 프로그램 이름(argv[0])과 인자(argv[1], ...) 분리
    • 인자를 스택에 삽입
  • hex_dump 호출로 스택 상태 디버깅 하기

🧐 Argument Passing을 구현하기 전에 Pintos는 유저 프로그램을 어떻게 실행하는지 알아보자.

유저 프로그램 실행 흐름

유저가 run args-single onearg처럼 프로그램과 인자를 전달했을 때,
Pintos 내부에서 어떤 함수들이 호출되고, 어떤 흐름으로 argument가 전달되는지 알아보자.

main 프로그램의 일부분 코드를 보면 run_actions()
여기서부터 유저 프로그램이 실행된다.

1
2
3
4
5
6
7
// threads/init.c
/* Pintos main program. */
int
main (void) {
  /* Run actions specified on kernel command line. */
  run_actions (argv);
}

이 흐름을 따라가보자.

1
2
3
4
5
// threads/init.c/run_actions(char **argv)
static const struct action actions[] = {
  {"run", 2, run_task},
  // ... 생략
};

"run" 명령이 들어오면, 최소 2개의 인자 ("run" + "프로그램 이름")가 있는지 검증한 뒤,
해당 유저 프로그램을 실행하기 위해 run_task()함수가 호출된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// threads/init.c
/* Runs the task specified in ARGV[1]. */
static void
run_task (char **argv) {
  const char *task = argv[1]; //실행할 프로그램 이름을 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);
}

run_task()함수는 실제로 커맨드라인에서 입력받은 유저 프로그램 실행 요청을 수행하는 핵심 진입점이다.

run_task()함수는 argv[1]에서 실행할 유저 프로그램의 이름을 가져와서
process_create_initd()을 통해 유저 프로그램을 위한 커널 스레드를 생성한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// userprog/process.c
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);

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

file_name이라는 문자열을 기반으로, 유저 프로그램을 실행하는 스레드를 생성한다.

  • 문자열 복사
    • file_namefn_copy라는 새 공간에 복사한다
    • 나중에 스레드가 이 문자열을 사용할 수 있도록 메모리 한 페이지를 따로 확보해주는거다
  • 스레드 만들기
    • thread_create()를 호출해서 새로운 스레드를 하나 만든다
    • 이 스레드는 initd()함수에서 시작하고, 복사해둔 문자열을 들고 시작
  • 결과 반환
    • 새로 만든 스레드의 ID를 반환

진짜 유저 프로그램 실행은 initd() 함수부터 시작된다고 보면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// userprog/process.c
static void
initd(void *f_name)
{
#ifdef VM
  supplemental_page_table_init(&thread_current()->spt);
#endif

  process_init();

  if (process_exec(f_name) < 0)
    PANIC("Fail to launch initd\n");
  NOT_REACHED();
}

process_exec()는 유저 프로그램을 로딩하고 실행하는 함수이다.
여기서부터 ELF파일을 로딩하고, 유저 스택을 생성하며, argument도 전달할 준비를 한다.
정상적으로 실행되면 현재 커널 스레드는 유저 프로그램으로 전환된다.

유저 프로그램 실행 흐름 요약

main()run_actions()run_task()process_create_initd()thread_create()initd()process_exec()

1
2
3
4
5
6
// userprog/process.c
int
process_exec (void *f_name) {
  /* And then load the binary */
  success = load (file_name, &_if);
}

현재는 f_name"args-single hello pintos"와 같이 전체 문자열이 전달되고 있는데,
load()함수는 실제로 실행 파일 이름("args-single")만 필요하다.
strtok_r()함수를 활용해 파일 이름만 추출해서 load()에 넘겨주어야한다.


구현 아이디어

file_name에서 프로그램 이름과 인자들을 strtok_r()로 분리하고,
프로그램 이름은 load()에 전달한 다음에
나머지 인자들은 스택 위에 argv 형태로 push 하여 인자 전달을 구현해야한다.


1단계 : Argument Parsing (인자 분리) - strtok_r() 활용

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 1. file_name 복사본 할당
char *fn_copy = palloc_get_page(0);
if (fn_copy == NULL)
  return -1;

strlcpy(fn_copy, file_name, PGSIZE);

// 2. 토큰 분리 (strtok_r)
char *argv[64];
int argc = 0;
char *save_ptr;
char *token = strtok_r(fn_copy, " ", &save_ptr);
if (token == NULL)
{
  palloc_free_page(fn_copy);
  return -1;
}
argv[argc++] = token;

while ((token = strtok_r(NULL, " ", &save_ptr)) != NULL)
  argv[argc++] = token;

strtok_r()를 활용해 공백 " "을 기준으로 문자열을 분리한다.
첫 번째 토큰은 프로그램 이름이므로 argv[0]에 저장한다.
나머지 값들은 순서대로 argv[1], argv[2], ...에 저장되어 전체 인자 배열이 만들어진다.
argc를 선언하여 총 인자 개수도 저장시킨다.

1
file_name = args-single hello pintos
  • argv[0] : "args-single" (실행할 프로그램 이름)
  • argv[1] : "hello"
  • argv[2] : "pintos"
  • argc : 3

2단계 : load 호출 시 프로그램 이름 넘기기

load()함수는 유저 프로그램의 실행 파일을 메모리에 로딩하는 역할을 한다.
따라서 load()에 넘기는 문자열은 실행할 파일 이름만 있어야한다.

1
success = load(argv[0], &_if);

argv[0] 프로그램 이름을 load()함수에 넘겨서
해당 프로그램을 메모리에 올린다.

3단계 : 사용자 스택에 인자들 push - argument_stack()

직접 사용자 스택에 인자들을 push해주어야한다.
rsp 갱신해서 사용자 프로그램이 사용할 스택을 완성시킨다!
또한 rdi = argc, rsi = argv 레지스터 설정해준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
void argument_stack(char **argv, int argc, struct intr_frame *if_)
{
  void *rsp = (void *)if_->rsp;
  char *arg_addresses[64];

  // 1. 각 인자를 문자열 형태로 스택에 올림 (뒤에서부터)
  for (int i = argc - 1; i >= 0; i--)
  {
    size_t len = strlen(argv[i]) + 1; // \0 포함한 길이
    rsp -= len;
    memcpy(rsp, argv[i], len);
    arg_addresses[i] = rsp; // 이후 argv 배열 생성 시 주소 사용
  }

  // 2. word align (rsp가 8의 배수가 되도록 맞추기)
  uintptr_t rsp_uint = (uintptr_t)rsp;
  size_t padding = rsp_uint % 8;
  if (padding)
  {
    rsp -= padding;
    memset(rsp, 0, padding); // 패딩 부분을 0으로 채움
  }

  // 3. argv[i] 주소들 스택에 push
  rsp -= sizeof(char *); // argv[argc] = NULL
  memset(rsp, 0, sizeof(char *));

  for (int i = argc - 1; i >= 0; i--)
  {
    rsp -= sizeof(char *);
    memcpy(rsp, &arg_addresses[i], sizeof(char *));
  }

  // 4. argv 주소 (char **argv) 저장
  char **argv_addr = (char **)rsp;

  // 5. return address (dummy 0) - fake address
  rsp -= sizeof(void *);
  *(void **)rsp = 0;

  // 6. 최종 RSP 갱신
  if_->rsp = (uint64_t)rsp;

  // 7. rdi = argc, rsi = argv 주소 설정
  if_->R.rdi = argc;
  if_->R.rsi = (uint64_t)argv_addr;
}

스택은 높은 주소에서 낮은 주소로 이동하며 삽입한다

user_stack

그림으로 나타내면 다음과 같다.

4단계 : 유저 프로그램 시작하기

do_iret(&_if); 함수는 지금까지 준비한 intr_frame구조체를 기반으로
CPU 제어권을 유저 모드로 넘겨 유저 프로그램 실행을 시작한다.

1
2
do_iret(&_if);
NOT_REACHED(); // 정상적으로 유저프로그램에 진입하게 되면 여기로 다시 돌아오지 않는다

do_iret() 호출 이후에는 커널 모드로 복귀하지 않고, 유저 모드에서 프로그램이 실행된다.
만약 NOT_REACHED()가 실행된다면 이는 비정상적인 상황이다.


추가적으로 해주어야하는 부분

1
2
3
4
5
6
7
8
// userprog/process.c/process_wait(tid_t child_tid UNUSED)
int process_wait(tid_t child_tid UNUSED)
{
  for (int i = 0; i < 200000000; i++)
  {
  }
  return -1;
}

process_wait()함수에 무한루프를 넣어주어야한다.
아직 구현되지 않은 상태에서 부모 프로세스가 자식 프로세스 종료를 기다리는 동기화를 보장하지 않으면,
시스템이 예상치 못한 상태에 빠질 수 있기 때문이다.
따라서 정상적인 실행 흐름을 유지하기 위해 임시로 무한 루프를 넣어둔다.

1
2
3
4
5
6
7
8
9
// threads/thread.c/thread_test_preemption(void)
void thread_test_preemption(void)
{
  if (!list_empty(&ready_list) &&
      thread_current()->priority <
          list_entry(list_front(&ready_list), struct thread, elem)->priority)
    if (!intr_context())
      thread_yield();
}

if (!intr_context())를 넣어주어야한다.
현재 CPU가 인터럽트 컨텍스트일때, thread_yield()를 호출하면 시스템이 불안정해질 수 있기 때문이다.

테스트 중에 이 부분이 없다면 스케줄링 요청이 잘못 발생하여 정상 실행이 되지 않기에 추가해주어야한다.

1
2
3
4
5
6
7
8
9
10
// userprog/process.c/process_create_initd(const char *)
tid_t process_create_initd(const char *file_name)
{
  //thread_create() 함수 호출 전에 file_name 세팅
  char *save_ptr;
  file_name = strtok_r(file_name, " ", &save_ptr);

  /* Create a new thread to execute FILE_NAME. */
  tid = thread_create(file_name, PRI_DEFAULT, initd, fn_copy);
}

file_name은 파일 이름으로 인식되는 첫 단어가 필요하다.
strtok_r()로 분리를 해주지않으면 실행 파일을 제대로 찾지 못한다.
그러므로 thread_create()함수를 호출하기 전에 첫 번째 인자 file_name
옳게 들어갈 수 있도록 분리한다!


hex_dump() 함수

hex_dump()함수를 활용해 사용자 스택에 인자들이 제대로 쌓였는지 확인해보자.

argument_stack()함수는 사용자 스택에 인자들을 쌓는 작업을 수행하기 때문에,
argument_stack()호출 이후에 hex_dump()함수를 호출해야 스택 상태를 확인할 수 있다.

1
2
3
4
// userprog/process.c/process_exec(void *)
  argument_stack(argv, argc, &_if);

  hex_dump(_if.rsp, (void *)_if.rsp, USER_STACK - _if.rsp, true);

Argument Passing 출력 결과

argument_passing_result

순차적으로 args-none, args-single, args-multiple, args-many, args-dbl-space
이렇게 hex_dump()의 출력 결과이다.