NOTE 2. 인자 전달 과정 확인해보기 2편 - Week9-11-team03/Pintos-User-Program GitHub Wiki
pintos --fs-disk=10 -v -k -T 6000 -m 20 --gdb -p tests/userprog/args-none:args-none -- -q -f run args-none
자, 지난 시간에 put 연산을 처리해주고, 보시는 것처럼 argv가 가리키는 포인터를 이미 처리한 것만큼 increment시켜줍니다.
/* Invoke action and advance. */
a->function (argv);
argv += a->argc;
그 결과 argv를 보면 run, args-none 두 개가 run에 매핑된 연산인 run_task로 넘어갑니다.
/* 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);
}
자, 이게 run_task입니다. 다른거 다 안 중요하고, task에 두 번째 인자인 args-none을 넣은 채로 process_create_initd로 넘겨준 다음, process_wait으로 메인 쓰레드는 넘어갑니다.
-
이것은 자식 프로세스로 응용 프로그램을 실행하고, 부모 프로세스는 자식이 죽을 때까지 기다리겠다는 의미입니까? 맞습니다!
네, 맞습니다!
당신이 작성하신 설명은 정확하게 부모 프로세스(메인 쓰레드)가 자식 프로세스(실행 파일)를 실행시키고, 자식 프로세스가 종료할 때까지 부모 프로세스가 기다린다는 의미입니다.
아래에 각각의 흐름을 정리해 보겠습니다.
-
const char *task = argv;
→ 명령줄의 두 번째 인자를 가져와 실행할 응용 프로그램 이름을 task에 저장합니다. (예: "args-none")
-
process_create_initd(task)
→ 주어진 실행파일 이름으로 자식 프로세스를 생성합니다.
이 함수가 반환하는 값은 자식 프로세스의 식별자(TID, thread id)입니다.
-
process_wait(...)
→ 자식 프로세스가 종료될 때까지 부모 프로세스(즉, 호출한 스레드)가 기다립니다.
따라서,
- 부모 프로세스(또는 메인 스레드)는
process_create_initd
로 자식을 만들고, - 이어서
process_wait
을 호출함으로써 - 자식이 완전히 종료될 때까지 대기합니다.
이런 구조는 리눅스/유닉스 계열에서
fork()
후wait()
를 호출하는 것과 유사하며, 일반적으로 shell의 명령어 실행 방식과도 같습니다.요약:
"자식 프로세스로 응용 프로그램을 실행하고, 부모 프로세스는 자식이 죽을 때까지 기다리겠다는 의미입니까?"
→ 네, 맞습니다! 바로 그것이 이 코드와 흐름이 의미하는 바입니다.
추가로 궁금한 점이나 pintos의 내부 동작에 대해 더 알고 싶으시면 언제든 질문해주세요!
Perplexity로부터의 답변: pplx.ai/share
-
자, 이제 다음으로 넘어갑시다.
-
토막 트러블 이슈! cannot be temporal
jungle@ff1c877267a2:/workspaces/Pintos-User-Program/pintos-kaist/userprog/build$ pintos -v -k -T 6000 -m 20 --gdb --fs-disk=10 -p tests/userprog/args-none:args-none -- -q -f run args-none os.dsk cannot be temporal.
os.dsk 파일이 없어서 이런 문제가 나는데, pintos-mkdisk os.dsk 20이런 식으로 디스크 파일을 생성해주면 cannot be temporal 문제는 해결할 수 있습니다. 하지만…디스크를 포맷하고…해야 해서 일단은 그냥 빌드 다시 하는게 속 편합니다
이제 task를 실행하러, process_create_initd로 넘어왔습니다.
/* 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);
/* 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는 뭐 하는 친구일까요? 넘겨 받은 응용프로그램 executable object file 이름으로 쓰레드를 생성해서 프로세스처럼 리턴시켜주는, 그런 핀토스만의 프로세스 생성 함수일까요?
네, 귀하의 이해가 정확합니다.
process_create_initd
함수는 핀토스(Pintos)에서 첫 번째 사용자 수준 프로그램인 "initd"를 시작하는 함수입니다.이 함수의 주요 기능은 다음과 같습니다:
- 전달받은 실행 파일 이름(
file_name
)을 복사합니다. 이는 호출자와load()
함수 사이의 경쟁 상태(race condition)를 방지하기 위함입니다. - 복사된 파일 이름으로 새 스레드를 생성합니다. 이 스레드는
initd
함수를 실행합니다. - 생성된 스레드의 ID(tid)를 반환하거나, 스레드 생성에 실패하면
TID_ERROR
를 반환합니다.
핀토스의 특징적인 부분은 일반적인 OS와 달리 프로세스와 스레드의 구분이 다소 모호하다는 점입니다. 핀토스에서는 새로운 프로세스를 생성할 때 실제로는 스레드를 생성하고, 이 스레드가 프로세스의 역할을 수행합니다. 즉,
thread_create
를 통해 생성된 스레드가 사용자 프로그램을 실행하는 프로세스처럼 동작합니다.주석에도 언급되어 있듯이, 이 함수는 오직 한 번만 호출되어야 합니다. 이는 시스템 시작 시 최초의 사용자 프로그램을 로드하기 위한 것입니다.
따라서, 이 함수는 핀토스 환경에서 응용 프로그램 실행 파일 이름을 받아 해당 프로그램을 실행할 스레드(사실상 프로세스)를 생성하는 핀토스 고유의 프로세스 생성 함수라고 볼 수 있습니다.
Perplexity로부터의 답변: pplx.ai/share
PintOS에서
process_create_initd()
함수는 최초의 사용자 모드 프로세스(initd)를 생성하는 핵심 함수입니다. 주로 다음 기능을 수행합니다:cfn_copy = palloc_get_page(0); *// 커널 페이지 할당* strlcpy(fn_copy, file_name, PGSIZE); *// 파일명 복사*
-
페이지 할당: 커널 페이지 풀(
palloc
)에서 메모리를 할당해 파일명 저장 - 레이스 컨디션 방지: 원본 파일명과 로드 과정 간의 경쟁 상태(race condition) 차단
ctid = thread_create(file_name, PRI_DEFAULT, initd, fn_copy);
- PintOS의 프로세스 모델: 유닉스와 달리 프로세스=쓰레드로 구현
-
initd
함수: ELF 실행 파일을 메모리에 로드하고 사용자 모드 전환 - 실행 흐름: 새 쓰레드가 즉시 스케줄링될 수 있음(비동기 동작)
- 1회 호출 제약: 시스템 최초 사용자 프로세스 생성용(주석 참조)
- TID 반환: 생성된 프로세스(쓰레드)의 식별자 반환
-
실패 시 정리: 쓰레드 생성 실패 시
palloc_free_page(fn_copy)
로 페이지 반환 - 커널 메모리 사용: 사용자 프로그램과는 별도의 메모리 풀 관리
PintOS 유닉스 프로세스 구현 단일 쓰레드 프로세스 테이블 메모리 보호 기본 페이지 보호 MMU 기반 격리 초기 프로세스 직접 ELF 로딩 fork()+exec() 분리 이 함수는 PintOS가 사용자 프로그램을 프로세스처럼 다루기 위한 추상화 계층으로, 커널의 쓰레드 메커니즘을 활용하면서도 전통적인 프로세스 개념을 에뮬레이션합니다. 실제 프로세스 생성은 이후
initd()
에서 ELF 로딩과 사용자 스택 설정을 통해 완성됩니다.
Perplexity로부터의 답변: pplx.ai/share
- 전달받은 실행 파일 이름(
-
1회 호출 제약: 시스템 최초 사용자 프로세스 생성용(주석 참조) 이렇다는 말은 커널이 응용 프로그램을 한 번만 실행할 수 있다는 뜻인가요?
아니요, "1회 호출 제약"이라는 의미는
process_create_initd
함수가 시스템에서 단 한 번만 호출되어야 한다는 것이지, 커널이 응용 프로그램을 한 번만 실행할 수 있다는 의미는 아닙니다.이 함수는 특별히 시스템 부팅 과정에서 최초의 사용자 프로세스("initd"라고 불리는)를 생성하기 위해 설계되었습니다. 이 초기 프로세스는 일반적으로 다른 응용 프로그램들을 실행하는 역할을 합니다.
핀토스에서 다른 응용 프로그램들을 실행하려면 별도의 함수(일반적으로
process_execute
같은)를 사용합니다. 이 함수들은 필요한 만큼 여러 번 호출될 수 있어 다수의 사용자 프로세스를 생성할 수 있습니다.요약하자면:
-
process_create_initd
는 최초의 사용자 프로세스를 시작하기 위해 한 번만 호출됩니다. - 이후 다른 응용 프로그램들은 다른 함수들을 통해 필요한 만큼 실행될 수 있습니다.
- 커널은 여러 응용 프로그램을 실행할 능력이 있습니다.
Perplexity로부터의 답변: pplx.ai/share
-
-
왜 최초의 사용자 프로그램을 생성하는 함수와 execve가 구분돼 있는 거죠?
핀토스에서
process_create_initd
함수와 일반적인 프로세스 생성 함수(예:process_execute
또는execve
유사 함수)가 구분되어 있는 이유는 다음과 같습니다:-
초기화 목적의 특수성:
-
process_create_initd
는 시스템 부팅 과정에서 최초의 사용자 프로세스를 생성하는 특수 목적을 가집니다. - 이 함수는 시스템 초기화 단계에서 호출되며, 시스템이 아직 완전히 준비되지 않은 상태일 수 있습니다.
-
-
실행 컨텍스트 차이:
- 최초 프로세스는 커널 컨텍스트에서 직접 생성됩니다.
- 반면 일반
execve
와 유사한 함수들은 이미 실행 중인 사용자 프로세스의 컨텍스트에서 호출됩니다.
-
상속 관계:
- 일반적인 프로세스 생성은 부모-자식 관계를 형성하지만, 최초 프로세스는 커널이 직접 생성하므로 특별한 처리가 필요합니다.
-
교육적 목적:
- 핀토스는 교육용 OS이므로, 이러한 구분을 통해 시스템 초기화와 일반적인 프로세스 생성의 차이를 명확히 보여줍니다.
-
단순화된 설계:
- 최초 프로세스 생성은 더 간단한 메커니즘으로 처리될 수 있으며, 모든 프로세스 생성에 필요한 복잡성을 포함할 필요가 없습니다.
이러한 구분은 운영체제의 부팅 과정과 일반적인 실행 시간 동안의 동작 차이를 반영하며, 각 상황에 맞는 최적화된 접근 방식을 제공합니다.
Perplexity로부터의 답변: pplx.ai/share
-
초기화 목적의 특수성:
그렇군요. 이렇게 대강 첫 프로세스를 만드는 함수가 유별나게 구분돼 있는 이유는 알 거 같습니다. 커널이 바로 프로세스를 만드는 과정을 볼 수 있군요.
우선, 파일 이름으로 넘겨 받은 포인터에 대해 경쟁이 생길 것을 방지하기 위해 재빠르게 지역 변수로 카피해줍니다. 그 과정에 페이지 할당을 해 줍니다.
-
페이지 할당과 메모리 할당의 차이는 대체 뭐죠?
페이지 할당과 메모리 할당은 컴퓨터 시스템의 메모리 관리에서 사용되는 개념으로, 서로 다른 수준과 목적을 가지고 있습니다.
- 정의: 프로그램이나 프로세스에 메모리 공간을 제공하는 일반적인 개념
- 범위: 바이트 단위부터 임의의 크기까지 다양한 크기로 할당 가능
- 수준: 다양한 수준(사용자 수준, 커널 수준)에서 발생 가능
-
예시:
malloc()
,calloc()
,new
등의 함수를 통한 할당
- 정의: 가상 메모리 시스템에서 페이지 단위로 메모리를 할당하는 것
- 크기: 고정된 크기(일반적으로 4KB, 8KB 등)의 페이지 단위로 할당
- 수준: 주로 운영체제 커널 수준에서 관리
- 목적: 가상 메모리와 물리 메모리 간의 매핑을 위함
-
예시: 핀토스의
palloc_get_page()
함수
-
단위와 크기:
- 메모리 할당: 바이트 단위부터 임의의 크기까지 다양함
- 페이지 할당: 페이지라는 고정된 크기의 단위로 할당
-
관리 주체:
- 메모리 할당: 프로그래머가 직접 관리하거나 라이브러리가 관리
- 페이지 할당: 주로 운영체제 커널이 관리
-
용도:
- 메모리 할당: 프로그램의 데이터 구조나 객체를 위한 공간 확보
- 페이지 할당: 가상 메모리 시스템 운영, 프로세스 주소 공간 구성
-
추상화 수준:
- 메모리 할당: 상대적으로 높은 추상화 수준
- 페이지 할당: 하드웨어와 더 가까운 낮은 수준의 추상화
핀토스와 같은 운영체제에서
palloc_get_page()
는 페이지 단위의 메모리를 할당하는 함수로, 가상 메모리 시스템을 관리하기 위해 사용됩니다. 이는 일반적인 메모리 할당보다 더 낮은 수준에서 작동하며, 운영체제의 메모리 관리 메커니즘과 직접적으로 연관되어 있습니다.추가 질문하기
Perplexity로부터의 답변: pplx.ai/share
-
커널에서 메모리 할당받을 때는 메모리 할당을 쓸 수 없어서 그런 건가요?
커널에서 메모리 할당을 받을 때 일반적인 사용자 수준의 메모리 할당 함수(예: malloc, new 등)를 사용하지 않는 이유는 여러 가지가 있습니다:
-
의존성 문제:
- 커널은 독립적으로 동작해야 하며, 사용자 수준 라이브러리에 의존할 수 없습니다.
- malloc과 같은 함수들은 보통 사용자 공간 라이브러리의 일부입니다.
-
낮은 수준 제어 필요성:
- 커널은 메모리 관리의 기본 인프라를 제공하는 역할을 합니다.
- 페이지 단위 할당은 하드웨어 메모리 관리 단위(MMU)와 직접 상호작용하기 위해 필요합니다.
-
안정성과 예측 가능성:
- 커널은 메모리 할당 실패 시 정확히 어떤 일이 발생하는지 명확히 알고 통제해야 합니다.
- 페이지 할당은 더 단순하고 예측 가능한 동작을 제공합니다.
-
메모리 관리 구조:
- 커널은 가상 메모리 시스템을 직접 관리합니다.
- 페이지 단위 할당은 이 시스템의 기본 구성 요소로 작동합니다.
-
자원 추적 용이성:
- 페이지 단위 할당은 시스템 자원 사용을 추적하고 관리하기 더 쉽게 만듭니다.
따라서 커널에서는 일반적인 메모리 할당 함수를 사용할 수 없어서라기보다는, 커널의 특수한 요구사항과 작동 환경에 더 적합한 페이지 할당 메커니즘을 사용하는 것이 더 효율적이고 안전하기 때문입니다. 핀토스에서
palloc_get_page()
와 같은 함수는 이러한 요구사항을 충족시키기 위해 설계되었습니다.
Perplexity로부터의 답변: pplx.ai/share
-
의존성 문제:
페이지 할당을 쓰는 이유가 여전히 깔끔하게 이해된 건 아니지만 fn_copy에 제대로 들어갔군요.
그리고 이제, 쓰레드를 생성합니다. 쓰레드 생성 인자는 아래와 같습니다.
tid = thread_create (file_name, PRI_DEFAULT, initd, fn_copy);
-
아니, 경쟁 상태를 방지하기 위해 이렇게 fn_copy에 file_name을 복사했건만, 또 쓰레드를 생성할 때는 file_name과 fn_copy를 같이 쓰네요? 이건 왜 이렇죠?
이 코드의 혼란스러운 부분을 명확히 설명해 드리겠습니다.
thread_create
함수 호출에서file_name
과fn_copy
가 다른 목적으로 사용되고 있습니다:-
첫 번째 인자
file_name
:- 이 인자는 생성될 스레드의 이름으로 사용됩니다.
- 스레드 이름은 디버깅이나 식별 목적으로만 사용되며, 실제 실행 파일을 로드하는 데 사용되지 않습니다.
- 이 값은 스레드 구조체 내에 복사되므로, 원본 문자열이 나중에 변경되거나 해제되어도 안전합니다.
-
네 번째 인자
fn_copy
:- 이 인자는 스레드 함수(
initd
)에 전달되는 인자입니다. - 이것이 실제로 실행 파일을 로드하는 데 사용될 문자열의 복사본입니다.
- 이 복사본은
initd
함수가 실행 파일을 로드할 때 사용됩니다.
- 이 인자는 스레드 함수(
경쟁 상태를 방지하는 이유:
- 원래
file_name
문자열은process_create_initd
함수의 호출자가 소유하므로, 이 함수가 반환된 후 변경되거나 해제될 수 있습니다. - 그러나 새로 생성된 스레드의
initd
함수는 이 문자열을 사용해 실행 파일을 로드해야 합니다. - 따라서
fn_copy
라는 복사본을 만들어서 새 스레드에게 전달합니다.
요약하자면,
file_name
은 스레드의 이름으로 사용되고,fn_copy
는 실제 실행 파일 경로의 복사본으로서 새 스레드의 함수에 전달됩니다. 두 가지는 서로 다른 목적으로 사용되므로 경쟁 상태 문제는 발생하지 않습니다.
Perplexity로부터의 답변: pplx.ai/share
-
첫 번째 인자
아하! 그러니까 그림으로 그려보면 이렇군요. 쓰레드 루틴이 여전히 파일 이름을 써야 하는데 커널 입장에서는 더 이상 알바가 아니어야 하니까 그렇습니다.
자, 이렇게 해서 initd로 넘어가 봅시다.
/* A thread function that launches first user process. */
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 ();
}
여기부터는 동시성으로 넘어가기 때문에, 흐름을 보고 싶으면 중단점을 많이 걸어야 합니다.
우선 해 주는 것이 processs_init()이군요. 이 코드를 살펴볼까요?
/* General process initializer for initd and other process. */
static void
process_init (void) {
struct thread *current = thread_current ();
}
아하, 일반적인 쓰레드 초기화 함수군요. 현재 쓰레드를 current 라는 이름의 쓰레드 구조체에 담아줍니다. (이게 어디 선언 됐는지는 모르겠습니다.)
자세히는 잘 모르겠고, 이제 process_exec으로 넘어가서 로드 과정을 빠르게 보도록 합시다.
/* 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 ();
/* And then load the binary */
success = load (file_name, &_if);
/* If load failed, quit. */
palloc_free_page (file_name);
if (!success)
return -1;
/* Start switched process. */
do_iret (&_if);
NOT_REACHED ();
}
이게 바로 대망의 process_exec 함수입니다. 넘겨받은 파일 이름을 통해 몇 가지 루틴을 실행합니다.
/* Switch the current execution context to the f_name.
-
이게 무슨 뜻일까요?
이 코드는 Pintos 운영체제의
process_exec
함수로, 프로세스 실행 컨텍스트를 전환하는 중요한 함수입니다.함수 주석
/* Switch the current execution context to the f_name. */
의 의미는 다음과 같습니다:현재 실행 중인 프로세스의 컨텍스트(실행 환경)를 인자로 받은
f_name
이라는 파일로 전환한다는 뜻입니다. 즉, 새로운 프로그램(f_name)을 현재 프로세스의 컨텍스트에서 실행하도록 만드는 것입니다.이 함수는 다음과 같은 작업을 수행합니다:
- 인터럽트 프레임(
_if
)을 설정합니다. 이 프레임은 새 프로그램이 실행될 때 CPU 레지스터의 초기 상태를 정의합니다.- 세그먼트 레지스터(ds, es, ss)를 사용자 데이터 세그먼트로 설정
- 코드 세그먼트(cs)를 사용자 코드 세그먼트로 설정
- 플래그 레지스터를 인터럽트 활성화 상태로 설정
-
process_cleanup()
을 호출하여 현재 프로세스의 컨텍스트를 정리합니다. -
load()
함수를 호출하여 새 프로그램(file_name)을 메모리에 로드합니다. - 로드가 성공하면
do_iret()
을 호출하여 새 프로그램으로 제어를 전환합니다.
이 함수는 본질적으로 프로세스 교체(process replacement)를 수행하는 것으로, UNIX 계열 운영체제의
exec
시스템 콜과 유사한 기능을 합니다. 현재 실행 중인 프로세스의 메모리 공간을 새로운 프로그램으로 대체하고 실행을 시작하는 것입니다."Switch the current execution context"는 현재 프로세스의 실행 환경(메모리 공간, 레지스터 상태 등)을 새로운 프로그램의 것으로 전환한다는 의미입니다.
Perplexity로부터의 답변: pplx.ai/share
- 인터럽트 프레임(
그리고, process_cleanup을 실행해줍니다. 사실 자세한 내용은 잘 모르겠지만, 일단 현재 프로세스의 리소스를 해제한다 정도만 알고 넘ㄴ어가죠.
/* Free the current process's resources. */
static void
process_cleanup (void) {
struct thread *curr = thread_current ();
#ifdef VM
supplemental_page_table_kill (&curr->spt);
#endif
uint64_t *pml4;
/* Destroy the current process's page directory and switch back
* to the kernel-only page directory. */
pml4 = curr->pml4;
if (pml4 != NULL) {
/* Correct ordering here is crucial. We must set
* cur->pagedir to NULL before switching page directories,
* so that a timer interrupt can't switch back to the
* process page directory. We must activate the base page
* directory before destroying the process's page
* directory, or our active page directory will be one
* that's been freed (and cleared). */
curr->pml4 = NULL;
pml4_activate (NULL);
pml4_destroy (pml4);
}
}
그리고, 말도 많고 탈도 많은 로드를 수행합니다
success = load (file_name, &_if);
-->
/* Loads an ELF executable from FILE_NAME into the current thread.
-
Stores the executable's entry point into *RIP
-
and its initial stack pointer into *RSP.
-
Returns true if successful, false otherwise. */
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;
/* Allocate and activate page directory. */
t->pml4 = pml4_create ();
if (t->pml4 == NULL)
goto done;
process_activate (thread_current ());
/* Open executable file. */
file = filesys_open (file_name);
if (file == NULL) {
printf ("load: %s: open failed\n", file_name);
goto done;
}
/* Read and verify executable header. */
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;
}
/* Read program headers. */
file_ofs = ehdr.e_phoff;
for (i = 0; i < ehdr.e_phnum; i++) {
struct Phdr phdr;
if (file_ofs < 0 || file_ofs > file_length (file))
goto done;
file_seek (file, file_ofs);
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:
/* Ignore this 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;
}
}
/* Set up stack. */
if (!setup_stack (if_))
goto done;
/* Start address. */
if_->rip = ehdr.e_entry;
/* TODO: Your code goes here.
- TODO: Implement argument passing (see project2/argument_passing.html). */
success = true;
done:
/* We arrive here whether the load is successful or not. */
file_close (file);
return success;
}
-
아이구 복잡하군요. 이걸 어떻게 하면 더 잘 이해할 수 있을지 인스트럭션을 부탁해 봅시다.
load
함수는 ELF(Executable and Linkable Format) 실행 파일을 메모리에 로드하는 핵심 함수입니다. 복잡해 보이지만 단계별로 나누어 이해해 보겠습니다.-
초기 설정
- 현재 스레드 정보 가져오기
- 페이지 디렉토리 할당 및 활성화 (메모리 관리 준비)
-
실행 파일 열기
-
filesys_open(file_name)
으로 실행할 프로그램 파일 열기 - 파일 열기 실패 시 에러 메시지 출력 후 종료
-
-
ELF 헤더 검증
- 파일에서 ELF 헤더 읽기
- 유효한 ELF 파일인지 검사 (매직 넘버, 타입, 아키텍처 등 확인)
- 올바르지 않은 형식이면 에러 메시지 출력 후 종료
-
프로그램 헤더 처리
- ELF 파일의 프로그램 헤더를 순회하며 처리
- 각 세그먼트 타입에 따라 다른 처리:
-
PT_LOAD
: 메모리에 로드해야 하는 세그먼트 - 무시해도 되는 세그먼트들:
PT_NULL
,PT_NOTE
등 - 지원하지 않는 세그먼트:
PT_DYNAMIC
,PT_INTERP
,PT_SHLIB
-
-
로드 가능한 세그먼트 처리
-
PT_LOAD
타입의 세그먼트를 메모리에 로드 - 세그먼트 검증 후 메모리 페이지에 매핑
- 파일에서 데이터를 읽어 메모리에 복사하고, 필요시 0으로 채움
-
-
스택 설정
-
setup_stack()
함수로 사용자 프로그램의 스택 초기화
-
-
시작 주소 설정
- 인터럽트 프레임의 RIP(프로그램 카운터)를 ELF 파일의 엔트리 포인트로 설정
-
인자 전달 (TODO 부분)
- 프로젝트에서 구현해야 할 부분: 프로그램 인자를 스택에 전달하는 코드
-
ELF 파일 구조 이해하기
- ELF 파일은 헤더와 여러 세그먼트로 구성됨
- 헤더는 파일의 메타데이터를 포함
- 세그먼트는 실제 코드와 데이터를 포함
-
가상 메모리 개념 숙지
- 프로그램은 가상 주소 공간에 로드됨
-
pml4_create()
와process_activate()
는 페이지 테이블 설정 관련
-
세그먼트 로딩 과정 이해
-
load_segment()
함수가 실제 파일 내용을 메모리로 복사 - 읽기/쓰기 권한 설정, 페이지 정렬 등 처리
-
-
스택 설정의 중요성
- 사용자 프로그램이 실행되려면 스택이 필요함
- 인자 전달도 스택을 통해 이루어짐
-
goto 문 사용 패턴 이해
- 이 코드에서
goto done
은 오류 처리와 자원 해제를 위한 패턴 - 어떤 단계에서든 실패하면
done
레이블로 점프하여 정리 작업 수행
- 이 코드에서
이 함수는 운영체제가 사용자 프로그램을 메모리에 로드하고 실행 준비를 하는 핵심 과정을 보여줍니다. 프로세스 생성의 가장 중요한 부분이라고 볼 수 있습니다.
Perplexity로부터의 답변: pplx.ai/share
-
초기 설정
-
아하! 그래도 친절하게 어디를 수정해야 하는지 TODO로 알려주는 군요! 참고할 자료도 알려줬습니다. 지금까지 한 것은 프로그램 헤더를 읽는 일이었습니다. 그렇다면, 헤더 정보는 지금 ehdr에 들어가 있겠군요.
맞습니다! 코드에서 친절하게 TODO 주석으로 구현해야 할 부분을 명시해주고 있습니다:
`c*/* TODO: Your code goes here.
- TODO: Implement argument passing (see project2/argument_passing.html). /`
이 주석은 인자 전달(argument passing) 기능을 구현해야 한다는 것을 알려주며, 참고할 문서까지 제공하고 있습니다. 이는 사용자 프로그램을 실행할 때 명령줄 인자를 전달하는 중요한 기능입니다.
그리고 맞게 이해하셨습니다! ELF 파일의 헤더 정보는
ehdr
구조체에 저장되어 있습니다. 이 구조체에는 다음과 같은 중요한 정보들이 포함되어 있습니다:- 파일이 유효한 ELF 파일인지 확인하는 식별자(e_ident)
- 파일 타입(e_type)
- 대상 아키텍처(e_machine)
- 프로그램의 시작 주소(e_entry) - 이 값이
if_->rip
에 설정됨 - 프로그램 헤더 테이블의 위치(e_phoff)
- 프로그램 헤더의 개수(e_phnum)
코드에서는 이 헤더 정보를 읽고 검증한 후, 프로그램 헤더를 순회하면서 실제 코드와 데이터 세그먼트를 메모리에 로드하는 작업을 수행합니다. 그리고 마지막으로 스택을 설정하고 시작 주소를 설정한 후, 인자 전달 부분을 구현해야 합니다.
이제 TODO 부분에 인자 전달 코드를 구현하여 사용자 프로그램이 명령줄 인자를 받을 수 있도록 해야 합니다.
Perplexity로부터의 답변: pplx.ai/share
좋아요, 헤더에 파싱이 잘 된 건 알겠다 이겁니다. 하지만 이게 다 도통 무엇을 의미하며 프로세스 스택에 어떻게 집어 넣어야 하는지 모르겠군요
ehdr: <unknown>
e_ident: "\\177ELF\\002\\001\\001\\000\\000\\000\\000\\00...
e_type: 2
e_machine: 62
e_version: 1
e_entry: 4197503
e_phoff: 64
e_shoff: 50488
e_flags: 0
e_ehsize: 64
e_phentsize: 56
e_phnum: 3
e_shentsize: 64
e_shnum: 13
e_shstrndx: 12
-
위 질문에 대한 답변
ELF 헤더 정보는 실행 파일의 구조를 설명하는 메타데이터이지만, 이 정보 자체를 프로세스 스택에 넣는 것은 아닙니다. 헤더 정보는 운영체제가 프로그램을 메모리에 로드하는 방법을 결정하는 데 사용됩니다.
- e_ident: ELF 파일임을 식별하는 매직 넘버와 기타 정보
- e_type: 실행 파일(2)임을 나타냄
- e_machine: 대상 아키텍처(62 = AMD64/x86-64)
- e_entry: 프로그램의 시작 주소(4197503) - 프로그램 실행이 시작될 메모리 위치
- e_phoff: 프로그램 헤더 테이블의 파일 내 위치(64바이트)
- e_phnum: 프로그램 헤더의 개수(3개)
ELF 헤더 자체가 아니라, 명령줄 인자(command-line arguments)를 스택에 넣어야 합니다. 이것이 TODO 부분에서 구현해야 하는 작업입니다.
프로세스 스택에 인자를 넣는 방법:
- 스택 포인터 준비: 스택은 높은 주소에서 낮은 주소로 자라기 때문에, 스택의 최상단에서 시작
- 인자 문자열 푸시: 각 인자 문자열을 스택에 푸시(문자열 끝에 NULL 포함)
- 정렬 조정: 스택 포인터를 8바이트 경계에 맞게 정렬
- argv 배열 푸시: 각 인자 문자열의 주소를 배열로 푸시(역순으로)
- argc와 argv 푸시: 인자 개수(argc)와 인자 배열 주소(argv)를 푸시
- 반환 주소 푸시: 가짜 반환 주소(보통 0)를 푸시
예를 들어,
/bin/ls -l foo bar
를 실행한다면:높은 주소 +----------------+ | 0 (return) | <- 가짜 반환 주소 | argv 주소 | <- argv 포인터 | 4 (argc) | <- 인자 개수 | "bar\\0" 주소 | <- argv[3] | "foo\\0" 주소 | <- argv[2] | "-l\\0" 주소 | <- argv[1] | "/bin/ls\\0" 주소| <- argv[0] | 0 (NULL) | <- argv[4] (NULL 종료) | 정렬 패딩 | | "bar\\0" | <- 문자열 데이터 | "foo\\0" | | "-l\\0" | | "/bin/ls\\0" | +----------------+ 낮은 주소
이 구조를 만들고 나면, 프로그램이 시작될 때
main(int argc, char *argv[])
에서 인자를 올바르게 받을 수 있게 됩니다.ELF 헤더의 정보는 프로그램을 메모리에 로드하는 방법을 알려주는 것이지, 스택에 직접 넣는 데이터가 아닙니다. 스택에는 프로그램 실행에 필요한 인자와 환경 변수 등이 들어갑니다.
Perplexity로부터의 답변: pplx.ai/share
아무튼, 어찌어찌 바이너리 파일이 잘 로드됐다고 생각하고, do_iret을 한다고 해 봅시다.
/* Use iretq to launch the thread */
void do_iret(struct intr_frame *tf)
{
__asm __volatile(
"movq %0, %%rsp\\n"
"movq 0(%%rsp),%%r15\\n"
"movq 8(%%rsp),%%r14\\n"
"movq 16(%%rsp),%%r13\\n"
"movq 24(%%rsp),%%r12\\n"
"movq 32(%%rsp),%%r11\\n"
"movq 40(%%rsp),%%r10\\n"
"movq 48(%%rsp),%%r9\\n"
"movq 56(%%rsp),%%r8\\n"
"movq 64(%%rsp),%%rsi\\n"
"movq 72(%%rsp),%%rdi\\n"
"movq 80(%%rsp),%%rbp\\n"
"movq 88(%%rsp),%%rdx\\n"
"movq 96(%%rsp),%%rcx\\n"
"movq 104(%%rsp),%%rbx\\n"
"movq 112(%%rsp),%%rax\\n"
"addq $120,%%rsp\\n"
"movw 8(%%rsp),%%ds\\n"
"movw (%%rsp),%%es\\n"
"addq $32, %%rsp\\n"
"iretq"
: : "g"((uint64_t)tf) : "memory");
}
-
이건 뭡니까? → 인터럽트 처리 후 원래 실행 컨텍스트로 돌아가거나, 새로운 사용자 프로그램을 시작하기 위해 사용되는 저수준 함수입니다
do_iret
함수는 인터럽트 처리 후 원래 실행 컨텍스트로 돌아가거나, 새로운 사용자 프로그램을 시작하기 위해 사용되는 저수준 함수입니다. 이 함수는 인라인 어셈블리를 사용하여 CPU 레지스터들을 직접 조작합니다.이 함수는
intr_frame
구조체에 저장된 CPU 상태를 복원하고,iretq
명령어를 사용하여 인터럽트나 예외 처리에서 복귀합니다. 프로세스 실행 컨텍스트에서는 새 프로그램의 실행을 시작하는 데 사용됩니다.-
스택 포인터 설정:
tf
구조체의 주소를 스택 포인터(rsp)로 설정합니다.textmovq %0, %%rsp
-
범용 레지스터 복원: 스택에서 값을 읽어 r15부터 rax까지 모든 범용 레지스터를 복원합니다.
textmovq 0(%%rsp),%%r15 movq 8(%%rsp),%%r14 ... movq 112(%%rsp),%%rax
-
스택 포인터 조정: 레지스터 값을 모두 읽은 후 스택 포인터를 120바이트 증가시킵니다.
textaddq $120,%%rsp
-
세그먼트 레지스터 복원: 데이터 세그먼트(ds)와 확장 세그먼트(es) 레지스터를 복원합니다.
textmovw 8(%%rsp),%%ds movw (%%rsp),%%es
-
스택 포인터 추가 조정: 스택 포인터를 32바이트 더 증가시킵니다.
textaddq $32, %%rsp
-
인터럽트 복귀:
iretq
명령어를 실행하여 인터럽트 처리에서 복귀합니다.textiretq
process_exec
함수에서do_iret
을 호출할 때, 이 함수는 새로 로드된 프로그램의 실행을 시작하는 역할을 합니다.load
함수에서 설정한 인터럽트 프레임(_if
)에는 새 프로그램의 시작 주소(RIP)와 초기 레지스터 값들이 포함되어 있습니다.iretq
명령어가 실행되면 CPU는 사용자 모드로 전환되고, 새 프로그램의 시작 지점부터 코드 실행을 시작합니다. 이것이 사용자 프로그램이 실제로 실행되기 시작하는 순간입니다.간단히 말해,
do_iret
은 커널 모드에서 사용자 모드로 전환하고 새 프로그램의 실행을 시작하는 "발사 버튼"과 같은 역할을 합니다.
Perplexity로부터의 답변: pplx.ai/share
-
-
do_iret은 뭘 줄인 말인 거지? → interrupt return
IRET은 "Interrupt Return"의 약자로, 인터럽트 처리를 완료하고 원래 실행 중이던 코드로 돌아가기 위한 어셈블리 명령어입니다148.
인터럽트가 발생하면 CPU는 현재 실행 중인 작업을 중단하고 인터럽트 서비스 루틴(ISR)으로 제어를 넘깁니다. 이때 CPU는 현재 상태(레지스터 값, 프로그램 카운터 등)를 스택에 저장합니다. x86 아키텍처에서는 인터럽트 발생 시 EFLAGS, CS(코드 세그먼트), EIP(명령어 포인터) 등이 스택에 자동으로 푸시됩니다7.
ISR이 인터럽트 처리를 완료한 후, IRET 명령어를 실행하면:
일반 함수 호출에서는
ret
명령어를 사용하여 복귀하지만, 인터럽트 처리에서는iret
을 사용해야 합니다. 이는ret
이 단순히 반환 주소만 복원하는 반면,iret
은 인터럽트 발생 시 저장된 모든 CPU 상태(EFLAGS, CS, EIP 등)를 복원하기 때문입니다7.do_iret
함수는 이러한 IRET 명령어를 사용하여 새로운 프로그램의 실행을 시작하거나 인터럽트 처리 후 원래 컨텍스트로 복귀하는 역할을 합니다. 이 함수를 통해 커널 모드에서 사용자 모드로 전환하고, 새 프로그램의 실행을 시작할 수 있습니다.- https://monsangter.tistory.com/172
- https://velog.io/@becooq81/운영체제-인터럽트
- https://vmilsh.tistory.com/392
- https://velog.io/@chullll/인터럽트
- https://dar0m.tistory.com/257
- https://ko.wikipedia.org/wiki/인터럽트
- https://yohda.tistory.com/entry/인터럽트작성중
- https://doh-an.tistory.com/31
- https://wowcat.tistory.com/category/코스웨어/15년 스마트컨트롤러?page=3
Perplexity로부터의 답변: pplx.ai/share
-
그렇다면 do_iret은 이전에 스택에 바이너리 파일을 잘 로드했다면 그 정보를 바탕으로 레지스터를 업데이트해서 새로운 프로세스를 시작하는 함수, 이렇게 이해하면 되나? → 맞다고 한다.
맞습니다.
do_iret
함수는 정확히 그런 역할을 합니다. 이 함수는 인터럽트 프레임(intr_frame
구조체)에 저장된 정보를 CPU 레지스터에 복원하여 새로운 프로세스의 실행을 시작하는 핵심 함수입니다.구체적으로
do_iret
이 하는 일은 다음과 같습니다:- 인자로 받은 인터럽트 프레임(
tf
)에 저장된 레지스터 값들을 CPU의 실제 레지스터로 복원합니다12. - 범용 레지스터(r15부터 rax까지), 세그먼트 레지스터(ds, es), 그리고
iretq
명령어를 통해 rip(명령어 포인터), cs(코드 세그먼트), eflags(플래그), rsp(스택 포인터), ss(스택 세그먼트)를 복원합니다23. -
iretq
명령어를 실행함으로써 커널 모드에서 사용자 모드로 전환하고, 새 프로그램의 시작 지점(rip 레지스터가 가리키는 위치)부터 코드 실행을 시작합니다15.
process_exec
함수에서는load
함수를 통해 바이너리 파일을 메모리에 로드하고 인터럽트 프레임(_if
)을 설정한 후,do_iret(&_if)
를 호출하여 새로운 프로세스의 실행을 시작합니다6. 이때load
함수에서 설정한if_->rip = ehdr.e_entry;
가 중요한데, 이는 새 프로그램의 시작 주소(엔트리 포인트)를 인터럽트 프레임의 rip에 설정하는 것입니다.따라서
do_iret
은 "이전에 메모리에 로드된 바이너리 파일의 정보를 바탕으로 CPU 레지스터를 업데이트하여 새로운 프로세스의 실행을 시작하는 함수"라고 정확히 이해하시면 됩니다57.- https://stay-present.tistory.com/98
- https://velog.io/@susuggang/PintOS-doiret
- https://yunchan97.tistory.com/72
- https://velog.io/@phw1996/Project-2-User-Program
- https://malgun-gothic.tistory.com/110
- https://woonys.tistory.com/148
- https://joong-sunny.github.io/swjungle survive/week9-1/
- https://straw961030.tistory.com/267
- https://nullbyte.tistory.com/34
- https://roll-over-program.tistory.com/62
Perplexity로부터의 답변: pplx.ai/share
- 인자로 받은 인터럽트 프레임(
-
do_iret 이후로 디버거로 따라가기가 안 된다. 별도의 프로세서로 레지스터가 업데이트 돼버려서 그런가보다.
맞습니다.
do_iret
함수 이후로 디버거로 따라가기가 어려운 이유는 정확히 그 때문입니다.do_iret
함수는iretq
어셈블리 명령어를 실행하는데, 이 명령어는 여러 중요한 변화를 일으킵니다:- 특권 수준 전환: 커널 모드(ring 0)에서 사용자 모드(ring 3)로 전환됩니다25. 이는 코드 세그먼트 선택자(CS)가 사용자 모드 권한 레벨로 변경되기 때문입니다.
-
레지스터 컨텍스트 완전 교체:
iretq
는 스택에서 여러 중요한 레지스터 값을 복원합니다2:- RIP (명령어 포인터) - 실행할 다음 명령어 위치
- CS (코드 세그먼트)
- RFLAGS (플래그 레지스터)
- RSP (스택 포인터)
- SS (스택 세그먼트)
- 실행 흐름 변경: 새로운 프로그램의 시작 지점으로 실행 흐름이 완전히 전환됩니다2.
이런 급격한 변화로 인해 디버거가 실행 흐름을 추적하기 어려워집니다. 특히:
- 디버거가 커널 모드에서 동작하다가 갑자기 사용자 모드 컨텍스트로 전환되면 추적이 끊어집니다
- 모든 레지스터가 한꺼번에 변경되므로 이전 실행 컨텍스트와의 연속성이 사라집니다
- 실행 주소가 완전히 다른 메모리 영역(사용자 공간)으로 점프합니다
디버깅을 계속하려면 사용자 모드 디버깅을 위한 별도의 설정이나 브레이크포인트가 필요하며, 일반적으로 커널 디버거와 사용자 공간 디버거를 함께 사용해야 합니다34.
이는
iretq
명령어가 정확히 이런 목적(예외 처리 후 복귀 또는 새 프로세스 시작)을 위해 설계되었기 때문입니다2.- https://github.com/microsoft/vscode-react-native/issues/2254
- https://os.phil-opp.com/returning-from-exceptions/
- http://fdbg.x86asm.net/debugging-in-long-mode-in-theory.html
- https://www.intel.com/content/dam/support/us/en/documents/processors/pentium4/sb/253669.pdf
- https://nfil.dev/kernel/rust/coding/rust-kernel-to-userspace-and-back/
- https://www.outsystems.com/forums/discussion/50857/debugging-issue/
- https://www.mathworks.com/matlabcentral/answers/1902980-appdesigner-doesn-t-follow-the-current-line-in-debug-mode
- https://stackoverflow.com/questions/7647298/segmentation-fault-when-running-but-success-when-debugging
- https://e2e.ti.com/support/processors-group/processors/f/processors-forum/877778/ccs-am3352-debug-trace-issue
- https://en.wikipedia.org/wiki/Direct_memory_access
Perplexity로부터의 답변: pplx.ai/share
pintos --fs-disk=10 -v -k -T 6000 -m 20 --gdb -p tests/userprog/args-none:args-none -- -q -f run args-none
자, 지난 시간에 put 연산을 처리해주고, 보시는 것처럼 argv가 가리키는 포인터를 이미 처리한 것만큼 increment시켜줍니다.
/* Invoke action and advance. */
a->function (argv);
argv += a->argc;
그 결과 argv를 보면 run, args-none 두 개가 run에 매핑된 연산인 run_task로 넘어갑니다.
/* 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);
}
자, 이게 run_task입니다. 다른거 다 안 중요하고, task에 두 번째 인자인 args-none을 넣은 채로 process_create_initd로 넘겨준 다음, process_wait으로 메인 쓰레드는 넘어갑니다.
-
이것은 자식 프로세스로 응용 프로그램을 실행하고, 부모 프로세스는 자식이 죽을 때까지 기다리겠다는 의미입니까? 맞습니다!
네, 맞습니다!
당신이 작성하신 설명은 정확하게 부모 프로세스(메인 쓰레드)가 자식 프로세스(실행 파일)를 실행시키고, 자식 프로세스가 종료할 때까지 부모 프로세스가 기다린다는 의미입니다.
아래에 각각의 흐름을 정리해 보겠습니다.
-
const char *task = argv;
→ 명령줄의 두 번째 인자를 가져와 실행할 응용 프로그램 이름을 task에 저장합니다. (예: "args-none")
-
process_create_initd(task)
→ 주어진 실행파일 이름으로 자식 프로세스를 생성합니다.
이 함수가 반환하는 값은 자식 프로세스의 식별자(TID, thread id)입니다.
-
process_wait(...)
→ 자식 프로세스가 종료될 때까지 부모 프로세스(즉, 호출한 스레드)가 기다립니다.
따라서,
- 부모 프로세스(또는 메인 스레드)는
process_create_initd
로 자식을 만들고, - 이어서
process_wait
을 호출함으로써 - 자식이 완전히 종료될 때까지 대기합니다.
이런 구조는 리눅스/유닉스 계열에서
fork()
후wait()
를 호출하는 것과 유사하며, 일반적으로 shell의 명령어 실행 방식과도 같습니다.요약:
"자식 프로세스로 응용 프로그램을 실행하고, 부모 프로세스는 자식이 죽을 때까지 기다리겠다는 의미입니까?"
→ 네, 맞습니다! 바로 그것이 이 코드와 흐름이 의미하는 바입니다.
추가로 궁금한 점이나 pintos의 내부 동작에 대해 더 알고 싶으시면 언제든 질문해주세요!
Perplexity로부터의 답변: [pplx.ai/share](https://www.perplexity.ai/search/pplx.ai/share)
-
자, 이제 다음으로 넘어갑시다.
-
토막 트러블 이슈! cannot be temporal
jungle@ff1c877267a2:/workspaces/Pintos-User-Program/pintos-kaist/userprog/build$ pintos -v -k -T 6000 -m 20 --gdb --fs-disk=10 -p tests/userprog/args-none:args-none -- -q -f run args-none os.dsk cannot be temporal.
os.dsk 파일이 없어서 이런 문제가 나는데, pintos-mkdisk os.dsk 20이런 식으로 디스크 파일을 생성해주면 cannot be temporal 문제는 해결할 수 있습니다. 하지만…디스크를 포맷하고…해야 해서 일단은 그냥 빌드 다시 하는게 속 편합니다
이제 task를 실행하러, process_create_initd로 넘어왔습니다.
/* 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);
/* 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는 뭐 하는 친구일까요? 넘겨 받은 응용프로그램 executable object file 이름으로 쓰레드를 생성해서 프로세스처럼 리턴시켜주는, 그런 핀토스만의 프로세스 생성 함수일까요?
네, 귀하의 이해가 정확합니다.
process_create_initd
함수는 핀토스(Pintos)에서 첫 번째 사용자 수준 프로그램인 "initd"를 시작하는 함수입니다.이 함수의 주요 기능은 다음과 같습니다:
- 전달받은 실행 파일 이름(
file_name
)을 복사합니다. 이는 호출자와load()
함수 사이의 경쟁 상태(race condition)를 방지하기 위함입니다. - 복사된 파일 이름으로 새 스레드를 생성합니다. 이 스레드는
initd
함수를 실행합니다. - 생성된 스레드의 ID(tid)를 반환하거나, 스레드 생성에 실패하면
TID_ERROR
를 반환합니다.
핀토스의 특징적인 부분은 일반적인 OS와 달리 프로세스와 스레드의 구분이 다소 모호하다는 점입니다. 핀토스에서는 새로운 프로세스를 생성할 때 실제로는 스레드를 생성하고, 이 스레드가 프로세스의 역할을 수행합니다. 즉,
thread_create
를 통해 생성된 스레드가 사용자 프로그램을 실행하는 프로세스처럼 동작합니다.주석에도 언급되어 있듯이, 이 함수는 오직 한 번만 호출되어야 합니다. 이는 시스템 시작 시 최초의 사용자 프로그램을 로드하기 위한 것입니다.
따라서, 이 함수는 핀토스 환경에서 응용 프로그램 실행 파일 이름을 받아 해당 프로그램을 실행할 스레드(사실상 프로세스)를 생성하는 핀토스 고유의 프로세스 생성 함수라고 볼 수 있습니다.
Perplexity로부터의 답변: [pplx.ai/share](https://www.perplexity.ai/search/pplx.ai/share)
PintOS에서
process_create_initd()
함수는 최초의 사용자 모드 프로세스(initd)를 생성하는 핵심 함수입니다. 주로 다음 기능을 수행합니다:cfn_copy = palloc_get_page(0); *// 커널 페이지 할당* strlcpy(fn_copy, file_name, PGSIZE); *// 파일명 복사*
-
페이지 할당: 커널 페이지 풀(
palloc
)에서 메모리를 할당해 파일명 저장 - 레이스 컨디션 방지: 원본 파일명과 로드 과정 간의 경쟁 상태(race condition) 차단
ctid = thread_create(file_name, PRI_DEFAULT, initd, fn_copy);
- PintOS의 프로세스 모델: 유닉스와 달리 프로세스=쓰레드로 구현
-
initd
함수: ELF 실행 파일을 메모리에 로드하고 사용자 모드 전환 - 실행 흐름: 새 쓰레드가 즉시 스케줄링될 수 있음(비동기 동작)
- 1회 호출 제약: 시스템 최초 사용자 프로세스 생성용(주석 참조)
- TID 반환: 생성된 프로세스(쓰레드)의 식별자 반환
-
실패 시 정리: 쓰레드 생성 실패 시
palloc_free_page(fn_copy)
로 페이지 반환 - 커널 메모리 사용: 사용자 프로그램과는 별도의 메모리 풀 관리
PintOS 유닉스 프로세스 구현 단일 쓰레드 프로세스 테이블 메모리 보호 기본 페이지 보호 MMU 기반 격리 초기 프로세스 직접 ELF 로딩 fork()+exec() 분리 이 함수는 PintOS가 사용자 프로그램을 프로세스처럼 다루기 위한 추상화 계층으로, 커널의 쓰레드 메커니즘을 활용하면서도 전통적인 프로세스 개념을 에뮬레이션합니다. 실제 프로세스 생성은 이후
initd()
에서 ELF 로딩과 사용자 스택 설정을 통해 완성됩니다.
Perplexity로부터의 답변: [pplx.ai/share](https://www.perplexity.ai/search/pplx.ai/share)
- 전달받은 실행 파일 이름(
-
1회 호출 제약: 시스템 최초 사용자 프로세스 생성용(주석 참조) 이렇다는 말은 커널이 응용 프로그램을 한 번만 실행할 수 있다는 뜻인가요?
아니요, "1회 호출 제약"이라는 의미는
process_create_initd
함수가 시스템에서 단 한 번만 호출되어야 한다는 것이지, 커널이 응용 프로그램을 한 번만 실행할 수 있다는 의미는 아닙니다.이 함수는 특별히 시스템 부팅 과정에서 최초의 사용자 프로세스("initd"라고 불리는)를 생성하기 위해 설계되었습니다. 이 초기 프로세스는 일반적으로 다른 응용 프로그램들을 실행하는 역할을 합니다.
핀토스에서 다른 응용 프로그램들을 실행하려면 별도의 함수(일반적으로
process_execute
같은)를 사용합니다. 이 함수들은 필요한 만큼 여러 번 호출될 수 있어 다수의 사용자 프로세스를 생성할 수 있습니다.요약하자면:
-
process_create_initd
는 최초의 사용자 프로세스를 시작하기 위해 한 번만 호출됩니다. - 이후 다른 응용 프로그램들은 다른 함수들을 통해 필요한 만큼 실행될 수 있습니다.
- 커널은 여러 응용 프로그램을 실행할 능력이 있습니다.
Perplexity로부터의 답변: [pplx.ai/share](https://www.perplexity.ai/search/pplx.ai/share)
-
-
왜 최초의 사용자 프로그램을 생성하는 함수와 execve가 구분돼 있는 거죠?
핀토스에서
process_create_initd
함수와 일반적인 프로세스 생성 함수(예:process_execute
또는execve
유사 함수)가 구분되어 있는 이유는 다음과 같습니다:-
초기화 목적의 특수성:
-
process_create_initd
는 시스템 부팅 과정에서 최초의 사용자 프로세스를 생성하는 특수 목적을 가집니다. - 이 함수는 시스템 초기화 단계에서 호출되며, 시스템이 아직 완전히 준비되지 않은 상태일 수 있습니다.
-
-
실행 컨텍스트 차이:
- 최초 프로세스는 커널 컨텍스트에서 직접 생성됩니다.
- 반면 일반
execve
와 유사한 함수들은 이미 실행 중인 사용자 프로세스의 컨텍스트에서 호출됩니다.
-
상속 관계:
- 일반적인 프로세스 생성은 부모-자식 관계를 형성하지만, 최초 프로세스는 커널이 직접 생성하므로 특별한 처리가 필요합니다.
-
교육적 목적:
- 핀토스는 교육용 OS이므로, 이러한 구분을 통해 시스템 초기화와 일반적인 프로세스 생성의 차이를 명확히 보여줍니다.
-
단순화된 설계:
- 최초 프로세스 생성은 더 간단한 메커니즘으로 처리될 수 있으며, 모든 프로세스 생성에 필요한 복잡성을 포함할 필요가 없습니다.
이러한 구분은 운영체제의 부팅 과정과 일반적인 실행 시간 동안의 동작 차이를 반영하며, 각 상황에 맞는 최적화된 접근 방식을 제공합니다.
Perplexity로부터의 답변: [pplx.ai/share](https://www.perplexity.ai/search/pplx.ai/share)
-
초기화 목적의 특수성:
그렇군요. 이렇게 대강 첫 프로세스를 만드는 함수가 유별나게 구분돼 있는 이유는 알 거 같습니다. 커널이 바로 프로세스를 만드는 과정을 볼 수 있군요.
우선, 파일 이름으로 넘겨 받은 포인터에 대해 경쟁이 생길 것을 방지하기 위해 재빠르게 지역 변수로 카피해줍니다. 그 과정에 페이지 할당을 해 줍니다.
-
페이지 할당과 메모리 할당의 차이는 대체 뭐죠?
페이지 할당과 메모리 할당은 컴퓨터 시스템의 메모리 관리에서 사용되는 개념으로, 서로 다른 수준과 목적을 가지고 있습니다.
- 정의: 프로그램이나 프로세스에 메모리 공간을 제공하는 일반적인 개념
- 범위: 바이트 단위부터 임의의 크기까지 다양한 크기로 할당 가능
- 수준: 다양한 수준(사용자 수준, 커널 수준)에서 발생 가능
-
예시:
malloc()
,calloc()
,new
등의 함수를 통한 할당
- 정의: 가상 메모리 시스템에서 페이지 단위로 메모리를 할당하는 것
- 크기: 고정된 크기(일반적으로 4KB, 8KB 등)의 페이지 단위로 할당
- 수준: 주로 운영체제 커널 수준에서 관리
- 목적: 가상 메모리와 물리 메모리 간의 매핑을 위함
-
예시: 핀토스의
palloc_get_page()
함수
-
단위와 크기:
- 메모리 할당: 바이트 단위부터 임의의 크기까지 다양함
- 페이지 할당: 페이지라는 고정된 크기의 단위로 할당
-
관리 주체:
- 메모리 할당: 프로그래머가 직접 관리하거나 라이브러리가 관리
- 페이지 할당: 주로 운영체제 커널이 관리
-
용도:
- 메모리 할당: 프로그램의 데이터 구조나 객체를 위한 공간 확보
- 페이지 할당: 가상 메모리 시스템 운영, 프로세스 주소 공간 구성
-
추상화 수준:
- 메모리 할당: 상대적으로 높은 추상화 수준
- 페이지 할당: 하드웨어와 더 가까운 낮은 수준의 추상화
핀토스와 같은 운영체제에서
palloc_get_page()
는 페이지 단위의 메모리를 할당하는 함수로, 가상 메모리 시스템을 관리하기 위해 사용됩니다. 이는 일반적인 메모리 할당보다 더 낮은 수준에서 작동하며, 운영체제의 메모리 관리 메커니즘과 직접적으로 연관되어 있습니다.추가 질문하기
Perplexity로부터의 답변: [pplx.ai/share](https://www.perplexity.ai/search/pplx.ai/share)
-
커널에서 메모리 할당받을 때는 메모리 할당을 쓸 수 없어서 그런 건가요?
커널에서 메모리 할당을 받을 때 일반적인 사용자 수준의 메모리 할당 함수(예: malloc, new 등)를 사용하지 않는 이유는 여러 가지가 있습니다:
-
의존성 문제:
- 커널은 독립적으로 동작해야 하며, 사용자 수준 라이브러리에 의존할 수 없습니다.
- malloc과 같은 함수들은 보통 사용자 공간 라이브러리의 일부입니다.
-
낮은 수준 제어 필요성:
- 커널은 메모리 관리의 기본 인프라를 제공하는 역할을 합니다.
- 페이지 단위 할당은 하드웨어 메모리 관리 단위(MMU)와 직접 상호작용하기 위해 필요합니다.
-
안정성과 예측 가능성:
- 커널은 메모리 할당 실패 시 정확히 어떤 일이 발생하는지 명확히 알고 통제해야 합니다.
- 페이지 할당은 더 단순하고 예측 가능한 동작을 제공합니다.
-
메모리 관리 구조:
- 커널은 가상 메모리 시스템을 직접 관리합니다.
- 페이지 단위 할당은 이 시스템의 기본 구성 요소로 작동합니다.
-
자원 추적 용이성:
- 페이지 단위 할당은 시스템 자원 사용을 추적하고 관리하기 더 쉽게 만듭니다.
따라서 커널에서는 일반적인 메모리 할당 함수를 사용할 수 없어서라기보다는, 커널의 특수한 요구사항과 작동 환경에 더 적합한 페이지 할당 메커니즘을 사용하는 것이 더 효율적이고 안전하기 때문입니다. 핀토스에서
palloc_get_page()
와 같은 함수는 이러한 요구사항을 충족시키기 위해 설계되었습니다.
Perplexity로부터의 답변: [pplx.ai/share](https://www.perplexity.ai/search/pplx.ai/share)
-
의존성 문제:
페이지 할당을 쓰는 이유가 여전히 깔끔하게 이해된 건 아니지만 fn_copy에 제대로 들어갔군요.
그리고 이제, 쓰레드를 생성합니다. 쓰레드 생성 인자는 아래와 같습니다.
tid = thread_create (file_name, PRI_DEFAULT, initd, fn_copy);
-
아니, 경쟁 상태를 방지하기 위해 이렇게 fn_copy에 file_name을 복사했건만, 또 쓰레드를 생성할 때는 file_name과 fn_copy를 같이 쓰네요? 이건 왜 이렇죠?
이 코드의 혼란스러운 부분을 명확히 설명해 드리겠습니다.
thread_create
함수 호출에서file_name
과fn_copy
가 다른 목적으로 사용되고 있습니다:-
첫 번째 인자
file_name
:- 이 인자는 생성될 스레드의 이름으로 사용됩니다.
- 스레드 이름은 디버깅이나 식별 목적으로만 사용되며, 실제 실행 파일을 로드하는 데 사용되지 않습니다.
- 이 값은 스레드 구조체 내에 복사되므로, 원본 문자열이 나중에 변경되거나 해제되어도 안전합니다.
-
네 번째 인자
fn_copy
:- 이 인자는 스레드 함수(
initd
)에 전달되는 인자입니다. - 이것이 실제로 실행 파일을 로드하는 데 사용될 문자열의 복사본입니다.
- 이 복사본은
initd
함수가 실행 파일을 로드할 때 사용됩니다.
- 이 인자는 스레드 함수(
경쟁 상태를 방지하는 이유:
- 원래
file_name
문자열은process_create_initd
함수의 호출자가 소유하므로, 이 함수가 반환된 후 변경되거나 해제될 수 있습니다. - 그러나 새로 생성된 스레드의
initd
함수는 이 문자열을 사용해 실행 파일을 로드해야 합니다. - 따라서
fn_copy
라는 복사본을 만들어서 새 스레드에게 전달합니다.
요약하자면,
file_name
은 스레드의 이름으로 사용되고,fn_copy
는 실제 실행 파일 경로의 복사본으로서 새 스레드의 함수에 전달됩니다. 두 가지는 서로 다른 목적으로 사용되므로 경쟁 상태 문제는 발생하지 않습니다.
Perplexity로부터의 답변: [pplx.ai/share](https://www.perplexity.ai/search/pplx.ai/share)
-
첫 번째 인자
아하! 그러니까 그림으로 그려보면 이렇군요. 쓰레드 루틴이 여전히 파일 이름을 써야 하는데 커널 입장에서는 더 이상 알바가 아니어야 하니까 그렇습니다.
자, 이렇게 해서 initd로 넘어가 봅시다.
/* A thread function that launches first user process. */
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 ();
}
여기부터는 동시성으로 넘어가기 때문에, 흐름을 보고 싶으면 중단점을 많이 걸어야 합니다.
우선 해 주는 것이 processs_init()이군요. 이 코드를 살펴볼까요?
/* General process initializer for initd and other process. */
static void
process_init (void) {
struct thread *current = thread_current ();
}
아하, 일반적인 쓰레드 초기화 함수군요. 현재 쓰레드를 current 라는 이름의 쓰레드 구조체에 담아줍니다. (이게 어디 선언 됐는지는 모르겠습니다.)
자세히는 잘 모르겠고, 이제 process_exec으로 넘어가서 로드 과정을 빠르게 보도록 합시다.
/* 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 ();
/* And then load the binary */
success = load (file_name, &_if);
/* If load failed, quit. */
palloc_free_page (file_name);
if (!success)
return -1;
/* Start switched process. */
do_iret (&_if);
NOT_REACHED ();
}
이게 바로 대망의 process_exec 함수입니다. 넘겨받은 파일 이름을 통해 몇 가지 루틴을 실행합니다.
/* Switch the current execution context to the f_name.
-
이게 무슨 뜻일까요?
이 코드는 Pintos 운영체제의
process_exec
함수로, 프로세스 실행 컨텍스트를 전환하는 중요한 함수입니다.함수 주석
/* Switch the current execution context to the f_name. */
의 의미는 다음과 같습니다:현재 실행 중인 프로세스의 컨텍스트(실행 환경)를 인자로 받은
f_name
이라는 파일로 전환한다는 뜻입니다. 즉, 새로운 프로그램(f_name)을 현재 프로세스의 컨텍스트에서 실행하도록 만드는 것입니다.이 함수는 다음과 같은 작업을 수행합니다:
- 인터럽트 프레임(
_if
)을 설정합니다. 이 프레임은 새 프로그램이 실행될 때 CPU 레지스터의 초기 상태를 정의합니다.- 세그먼트 레지스터(ds, es, ss)를 사용자 데이터 세그먼트로 설정
- 코드 세그먼트(cs)를 사용자 코드 세그먼트로 설정
- 플래그 레지스터를 인터럽트 활성화 상태로 설정
-
process_cleanup()
을 호출하여 현재 프로세스의 컨텍스트를 정리합니다. -
load()
함수를 호출하여 새 프로그램(file_name)을 메모리에 로드합니다. - 로드가 성공하면
do_iret()
을 호출하여 새 프로그램으로 제어를 전환합니다.
이 함수는 본질적으로 프로세스 교체(process replacement)를 수행하는 것으로, UNIX 계열 운영체제의
exec
시스템 콜과 유사한 기능을 합니다. 현재 실행 중인 프로세스의 메모리 공간을 새로운 프로그램으로 대체하고 실행을 시작하는 것입니다."Switch the current execution context"는 현재 프로세스의 실행 환경(메모리 공간, 레지스터 상태 등)을 새로운 프로그램의 것으로 전환한다는 의미입니다.
Perplexity로부터의 답변: [pplx.ai/share](https://www.perplexity.ai/search/pplx.ai/share)
- 인터럽트 프레임(
그리고, process_cleanup을 실행해줍니다. 사실 자세한 내용은 잘 모르겠지만, 일단 현재 프로세스의 리소스를 해제한다 정도만 알고 넘ㄴ어가죠.
/* Free the current process's resources. */
static void
process_cleanup (void) {
struct thread *curr = thread_current ();
#ifdef VM
supplemental_page_table_kill (&curr->spt);
#endif
uint64_t *pml4;
/* Destroy the current process's page directory and switch back
* to the kernel-only page directory. */
pml4 = curr->pml4;
if (pml4 != NULL) {
/* Correct ordering here is crucial. We must set
* cur->pagedir to NULL before switching page directories,
* so that a timer interrupt can't switch back to the
* process page directory. We must activate the base page
* directory before destroying the process's page
* directory, or our active page directory will be one
* that's been freed (and cleared). */
curr->pml4 = NULL;
pml4_activate (NULL);
pml4_destroy (pml4);
}
}
그리고, 말도 많고 탈도 많은 로드를 수행합니다
success = load (file_name, &_if);
-->
/* Loads an ELF executable from FILE_NAME into the current thread.
* Stores the executable's entry point into *RIP
* and its initial stack pointer into *RSP.
* Returns true if successful, false otherwise. */
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;
/* Allocate and activate page directory. */
t->pml4 = pml4_create ();
if (t->pml4 == NULL)
goto done;
process_activate (thread_current ());
/* Open executable file. */
file = filesys_open (file_name);
if (file == NULL) {
printf ("load: %s: open failed\n", file_name);
goto done;
}
/* Read and verify executable header. */
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;
}
/* Read program headers. */
file_ofs = ehdr.e_phoff;
for (i = 0; i < ehdr.e_phnum; i++) {
struct Phdr phdr;
if (file_ofs < 0 || file_ofs > file_length (file))
goto done;
file_seek (file, file_ofs);
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:
/* Ignore this 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;
}
}
/* Set up stack. */
if (!setup_stack (if_))
goto done;
/* Start address. */
if_->rip = ehdr.e_entry;
/* TODO: Your code goes here.
* TODO: Implement argument passing (see project2/argument_passing.html). */
success = true;
done:
/* We arrive here whether the load is successful or not. */
file_close (file);
return success;
}
-
아이구 복잡하군요. 이걸 어떻게 하면 더 잘 이해할 수 있을지 인스트럭션을 부탁해 봅시다.
load
함수는 ELF(Executable and Linkable Format) 실행 파일을 메모리에 로드하는 핵심 함수입니다. 복잡해 보이지만 단계별로 나누어 이해해 보겠습니다.-
초기 설정
- 현재 스레드 정보 가져오기
- 페이지 디렉토리 할당 및 활성화 (메모리 관리 준비)
-
실행 파일 열기
-
filesys_open(file_name)
으로 실행할 프로그램 파일 열기 - 파일 열기 실패 시 에러 메시지 출력 후 종료
-
-
ELF 헤더 검증
- 파일에서 ELF 헤더 읽기
- 유효한 ELF 파일인지 검사 (매직 넘버, 타입, 아키텍처 등 확인)
- 올바르지 않은 형식이면 에러 메시지 출력 후 종료
-
프로그램 헤더 처리
- ELF 파일의 프로그램 헤더를 순회하며 처리
- 각 세그먼트 타입에 따라 다른 처리:
-
PT_LOAD
: 메모리에 로드해야 하는 세그먼트 - 무시해도 되는 세그먼트들:
PT_NULL
,PT_NOTE
등 - 지원하지 않는 세그먼트:
PT_DYNAMIC
,PT_INTERP
,PT_SHLIB
-
-
로드 가능한 세그먼트 처리
-
PT_LOAD
타입의 세그먼트를 메모리에 로드 - 세그먼트 검증 후 메모리 페이지에 매핑
- 파일에서 데이터를 읽어 메모리에 복사하고, 필요시 0으로 채움
-
-
스택 설정
-
setup_stack()
함수로 사용자 프로그램의 스택 초기화
-
-
시작 주소 설정
- 인터럽트 프레임의 RIP(프로그램 카운터)를 ELF 파일의 엔트리 포인트로 설정
-
인자 전달 (TODO 부분)
- 프로젝트에서 구현해야 할 부분: 프로그램 인자를 스택에 전달하는 코드
-
ELF 파일 구조 이해하기
- ELF 파일은 헤더와 여러 세그먼트로 구성됨
- 헤더는 파일의 메타데이터를 포함
- 세그먼트는 실제 코드와 데이터를 포함
-
가상 메모리 개념 숙지
- 프로그램은 가상 주소 공간에 로드됨
-
pml4_create()
와process_activate()
는 페이지 테이블 설정 관련
-
세그먼트 로딩 과정 이해
-
load_segment()
함수가 실제 파일 내용을 메모리로 복사 - 읽기/쓰기 권한 설정, 페이지 정렬 등 처리
-
-
스택 설정의 중요성
- 사용자 프로그램이 실행되려면 스택이 필요함
- 인자 전달도 스택을 통해 이루어짐
-
goto 문 사용 패턴 이해
- 이 코드에서
goto done
은 오류 처리와 자원 해제를 위한 패턴 - 어떤 단계에서든 실패하면
done
레이블로 점프하여 정리 작업 수행
- 이 코드에서
이 함수는 운영체제가 사용자 프로그램을 메모리에 로드하고 실행 준비를 하는 핵심 과정을 보여줍니다. 프로세스 생성의 가장 중요한 부분이라고 볼 수 있습니다.
Perplexity로부터의 답변: [pplx.ai/share](https://www.perplexity.ai/search/pplx.ai/share)
-
초기 설정
-
아하! 그래도 친절하게 어디를 수정해야 하는지 TODO로 알려주는 군요! 참고할 자료도 알려줬습니다. 지금까지 한 것은 프로그램 헤더를 읽는 일이었습니다. 그렇다면, 헤더 정보는 지금 ehdr에 들어가 있겠군요.
맞습니다! 코드에서 친절하게 TODO 주석으로 구현해야 할 부분을 명시해주고 있습니다:
`c*/* TODO: Your code goes here.
- TODO: Implement argument passing (see project2/argument_passing.html). /`
이 주석은 인자 전달(argument passing) 기능을 구현해야 한다는 것을 알려주며, 참고할 문서까지 제공하고 있습니다. 이는 사용자 프로그램을 실행할 때 명령줄 인자를 전달하는 중요한 기능입니다.
그리고 맞게 이해하셨습니다! ELF 파일의 헤더 정보는
ehdr
구조체에 저장되어 있습니다. 이 구조체에는 다음과 같은 중요한 정보들이 포함되어 있습니다:- 파일이 유효한 ELF 파일인지 확인하는 식별자(e_ident)
- 파일 타입(e_type)
- 대상 아키텍처(e_machine)
- 프로그램의 시작 주소(e_entry) - 이 값이
if_->rip
에 설정됨 - 프로그램 헤더 테이블의 위치(e_phoff)
- 프로그램 헤더의 개수(e_phnum)
코드에서는 이 헤더 정보를 읽고 검증한 후, 프로그램 헤더를 순회하면서 실제 코드와 데이터 세그먼트를 메모리에 로드하는 작업을 수행합니다. 그리고 마지막으로 스택을 설정하고 시작 주소를 설정한 후, 인자 전달 부분을 구현해야 합니다.
이제 TODO 부분에 인자 전달 코드를 구현하여 사용자 프로그램이 명령줄 인자를 받을 수 있도록 해야 합니다.
Perplexity로부터의 답변: [pplx.ai/share](https://www.perplexity.ai/search/pplx.ai/share)
좋아요, 헤더에 파싱이 잘 된 건 알겠다 이겁니다. 하지만 이게 다 도통 무엇을 의미하며 프로세스 스택에 어떻게 집어 넣어야 하는지 모르겠군요
ehdr: <unknown>
e_ident: "\177ELF\002\001\001\000\000\000\000\00...
e_type: 2
e_machine: 62
e_version: 1
e_entry: 4197503
e_phoff: 64
e_shoff: 50488
e_flags: 0
e_ehsize: 64
e_phentsize: 56
e_phnum: 3
e_shentsize: 64
e_shnum: 13
e_shstrndx: 12
-
위 질문에 대한 답변
ELF 헤더 정보는 실행 파일의 구조를 설명하는 메타데이터이지만, 이 정보 자체를 프로세스 스택에 넣는 것은 아닙니다. 헤더 정보는 운영체제가 프로그램을 메모리에 로드하는 방법을 결정하는 데 사용됩니다.
- e_ident: ELF 파일임을 식별하는 매직 넘버와 기타 정보
- e_type: 실행 파일(2)임을 나타냄
- e_machine: 대상 아키텍처(62 = AMD64/x86-64)
- e_entry: 프로그램의 시작 주소(4197503) - 프로그램 실행이 시작될 메모리 위치
- e_phoff: 프로그램 헤더 테이블의 파일 내 위치(64바이트)
- e_phnum: 프로그램 헤더의 개수(3개)
ELF 헤더 자체가 아니라, 명령줄 인자(command-line arguments)를 스택에 넣어야 합니다. 이것이 TODO 부분에서 구현해야 하는 작업입니다.
프로세스 스택에 인자를 넣는 방법:
- 스택 포인터 준비: 스택은 높은 주소에서 낮은 주소로 자라기 때문에, 스택의 최상단에서 시작
- 인자 문자열 푸시: 각 인자 문자열을 스택에 푸시(문자열 끝에 NULL 포함)
- 정렬 조정: 스택 포인터를 8바이트 경계에 맞게 정렬
- argv 배열 푸시: 각 인자 문자열의 주소를 배열로 푸시(역순으로)
- argc와 argv 푸시: 인자 개수(argc)와 인자 배열 주소(argv)를 푸시
- 반환 주소 푸시: 가짜 반환 주소(보통 0)를 푸시
예를 들어,
/bin/ls -l foo bar
를 실행한다면:높은 주소 +----------------+ | 0 (return) | <- 가짜 반환 주소 | argv 주소 | <- argv 포인터 | 4 (argc) | <- 인자 개수 | "bar\0" 주소 | <- argv[3] | "foo\0" 주소 | <- argv[2] | "-l\0" 주소 | <- argv[1] | "/bin/ls\0" 주소| <- argv[0] | 0 (NULL) | <- argv[4] (NULL 종료) | 정렬 패딩 | | "bar\0" | <- 문자열 데이터 | "foo\0" | | "-l\0" | | "/bin/ls\0" | +----------------+ 낮은 주소
이 구조를 만들고 나면, 프로그램이 시작될 때
main(int argc, char *argv[])
에서 인자를 올바르게 받을 수 있게 됩니다.ELF 헤더의 정보는 프로그램을 메모리에 로드하는 방법을 알려주는 것이지, 스택에 직접 넣는 데이터가 아닙니다. 스택에는 프로그램 실행에 필요한 인자와 환경 변수 등이 들어갑니다.
Perplexity로부터의 답변: [pplx.ai/share](https://www.perplexity.ai/search/pplx.ai/share)
아무튼, 어찌어찌 바이너리 파일이 잘 로드됐다고 생각하고, do_iret을 한다고 해 봅시다.
/* Use iretq to launch the thread */
void do_iret(struct intr_frame *tf)
{
__asm __volatile(
"movq %0, %%rsp\n"
"movq 0(%%rsp),%%r15\n"
"movq 8(%%rsp),%%r14\n"
"movq 16(%%rsp),%%r13\n"
"movq 24(%%rsp),%%r12\n"
"movq 32(%%rsp),%%r11\n"
"movq 40(%%rsp),%%r10\n"
"movq 48(%%rsp),%%r9\n"
"movq 56(%%rsp),%%r8\n"
"movq 64(%%rsp),%%rsi\n"
"movq 72(%%rsp),%%rdi\n"
"movq 80(%%rsp),%%rbp\n"
"movq 88(%%rsp),%%rdx\n"
"movq 96(%%rsp),%%rcx\n"
"movq 104(%%rsp),%%rbx\n"
"movq 112(%%rsp),%%rax\n"
"addq $120,%%rsp\n"
"movw 8(%%rsp),%%ds\n"
"movw (%%rsp),%%es\n"
"addq $32, %%rsp\n"
"iretq"
: : "g"((uint64_t)tf) : "memory");
}
-
이건 뭡니까? → 인터럽트 처리 후 원래 실행 컨텍스트로 돌아가거나, 새로운 사용자 프로그램을 시작하기 위해 사용되는 저수준 함수입니다
do_iret
함수는 인터럽트 처리 후 원래 실행 컨텍스트로 돌아가거나, 새로운 사용자 프로그램을 시작하기 위해 사용되는 저수준 함수입니다. 이 함수는 인라인 어셈블리를 사용하여 CPU 레지스터들을 직접 조작합니다.이 함수는
intr_frame
구조체에 저장된 CPU 상태를 복원하고,iretq
명령어를 사용하여 인터럽트나 예외 처리에서 복귀합니다. 프로세스 실행 컨텍스트에서는 새 프로그램의 실행을 시작하는 데 사용됩니다.-
스택 포인터 설정:
tf
구조체의 주소를 스택 포인터(rsp)로 설정합니다.textmovq %0, %%rsp
-
범용 레지스터 복원: 스택에서 값을 읽어 r15부터 rax까지 모든 범용 레지스터를 복원합니다.
textmovq 0(%%rsp),%%r15 movq 8(%%rsp),%%r14 ... movq 112(%%rsp),%%rax
-
스택 포인터 조정: 레지스터 값을 모두 읽은 후 스택 포인터를 120바이트 증가시킵니다.
textaddq $120,%%rsp
-
세그먼트 레지스터 복원: 데이터 세그먼트(ds)와 확장 세그먼트(es) 레지스터를 복원합니다.
textmovw 8(%%rsp),%%ds movw (%%rsp),%%es
-
스택 포인터 추가 조정: 스택 포인터를 32바이트 더 증가시킵니다.
textaddq $32, %%rsp
-
인터럽트 복귀:
iretq
명령어를 실행하여 인터럽트 처리에서 복귀합니다.textiretq
process_exec
함수에서do_iret
을 호출할 때, 이 함수는 새로 로드된 프로그램의 실행을 시작하는 역할을 합니다.load
함수에서 설정한 인터럽트 프레임(_if
)에는 새 프로그램의 시작 주소(RIP)와 초기 레지스터 값들이 포함되어 있습니다.iretq
명령어가 실행되면 CPU는 사용자 모드로 전환되고, 새 프로그램의 시작 지점부터 코드 실행을 시작합니다. 이것이 사용자 프로그램이 실제로 실행되기 시작하는 순간입니다.간단히 말해,
do_iret
은 커널 모드에서 사용자 모드로 전환하고 새 프로그램의 실행을 시작하는 "발사 버튼"과 같은 역할을 합니다.
Perplexity로부터의 답변: [pplx.ai/share](https://www.perplexity.ai/search/pplx.ai/share)
-
-
do_iret은 뭘 줄인 말인 거지? → interrupt return
IRET은 "Interrupt Return"의 약자로, 인터럽트 처리를 완료하고 원래 실행 중이던 코드로 돌아가기 위한 어셈블리 명령어입니다[1](https://monsangter.tistory.com/172)[[4](https://velog.io/@chullll/%EC%9D%B8%ED%84%B0%EB%9F%BD%ED%8A%B8)](https://velog.io/@chullll/%EC%9D%B[8](https://doh-an.tistory.com/31)%ED%84%B0%EB%9F%BD%ED%8A%B8)[8](https://doh-an.tistory.com/31).
인터럽트가 발생하면 CPU는 현재 실행 중인 작업을 중단하고 인터럽트 서비스 루틴(ISR)으로 제어를 넘깁니다. 이때 CPU는 현재 상태(레지스터 값, 프로그램 카운터 등)를 스택에 저장합니다. x86 아키텍처에서는 인터럽트 발생 시 EFLAGS, CS(코드 세그먼트), EIP(명령어 포인터) 등이 스택에 자동으로 푸시됩니다[7](https://yohda.tistory.com/entry/%EC%9D%B8%ED%84%B0%EB%9F%BD%ED%8A%B8%EC%9E%91%EC%84%B1%EC%A[4](https://velog.io/@chullll/%EC%9D%B8%ED%84%B0%EB%9F%BD%ED%8A%B8)%91).
ISR이 인터럽트 처리를 완료한 후, IRET 명령어를 실행하면:
- 스택에 저장된 프로그램 카운터(PC/EIP) 값을 복원하여 이전 실행 위치로 돌아갑니다4[5](https://dar0m.tistory.com/257)[[6](https://ko.wikipedia.org/wiki/%EC%9D%B8%ED%84%B0%EB%9F%BD%ED%8A%B8)](https://ko.wikipedia.org/wiki/%EC%9D%B8%ED%84%B0%EB%9F%BD%ED%8A%B8)
- 스택에 저장된 세그먼트 레지스터와 플래그 레지스터도 함께 복원됩니다[7](https://yohda.tistory.com/entry/%EC%9D%B8%ED%84%B0%EB%9F%BD%ED%8A%B8%EC%9E%91%EC%84%B1%EC%A4%91)
일반 함수 호출에서는
ret
명령어를 사용하여 복귀하지만, 인터럽트 처리에서는iret
을 사용해야 합니다. 이는ret
이 단순히 반환 주소만 복원하는 반면,iret
은 인터럽트 발생 시 저장된 모든 CPU 상태(EFLAGS, CS, EIP 등)를 복원하기 때문입니다[7](https://yohda.tistory.com/entry/%EC%9D%B8%ED%84%B0%EB%9F%BD%ED%8A%B8%EC%9E%91%EC%84%B1%EC%A4%91).do_iret
함수는 이러한 IRET 명령어를 사용하여 새로운 프로그램의 실행을 시작하거나 인터럽트 처리 후 원래 컨텍스트로 복귀하는 역할을 합니다. 이 함수를 통해 커널 모드에서 사용자 모드로 전환하고, 새 프로그램의 실행을 시작할 수 있습니다.- https://monsangter.tistory.com/172
- https://velog.io/@becooq81/%EC%9A%B4%EC%98%81%EC%B2%B4%EC%A0%9C-%EC%9D%B8%ED%84%B0%EB%9F%BD%ED%8A%B8
- https://vmilsh.tistory.com/392
- https://velog.io/@chullll/%EC%9D%B8%ED%84%B0%EB%9F%BD%ED%8A%B8
- https://dar0m.tistory.com/257
- https://ko.wikipedia.org/wiki/%EC%9D%B8%ED%84%B0%EB%9F%BD%ED%8A%B8
- https://yohda.tistory.com/entry/%EC%9D%B8%ED%84%B0%EB%9F%BD%ED%8A%B8%EC%9E%91%EC%84%B1%EC%A4%91
- https://doh-an.tistory.com/31
- https://wowcat.tistory.com/category/%EC%BD%94%EC%8A%A4%EC%9B%A8%EC%96%B4/15%EB%85%84%20%EC%8A%A4%EB%A7%88%ED%8A%B8%EC%BB%A8%ED%8A%B8%EB%A1%A4%EB%9F%AC?page=3
Perplexity로부터의 답변: [pplx.ai/share](https://www.perplexity.ai/search/pplx.ai/share)
-
그렇다면 do_iret은 이전에 스택에 바이너리 파일을 잘 로드했다면 그 정보를 바탕으로 레지스터를 업데이트해서 새로운 프로세스를 시작하는 함수, 이렇게 이해하면 되나? → 맞다고 한다.
맞습니다.
do_iret
함수는 정확히 그런 역할을 합니다. 이 함수는 인터럽트 프레임(intr_frame
구조체)에 저장된 정보를 CPU 레지스터에 복원하여 새로운 프로세스의 실행을 시작하는 핵심 함수입니다.구체적으로
do_iret
이 하는 일은 다음과 같습니다:1. 인자로 받은 인터럽트 프레임(
tf
)에 저장된 레지스터 값들을 CPU의 실제 레지스터로 복원합니다1[2](https://velog.io/@susuggang/PintOS-doiret). 2. 범용 레지스터(r15부터 rax까지), 세그먼트 레지스터(ds, es), 그리고iretq
명령어를 통해 rip(명령어 포인터), cs(코드 세그먼트), eflags(플래그), rsp(스택 포인터), ss(스택 세그먼트)를 복원합니다[2](https://velog.io/@susuggang/PintOS-doiret)[[3](https://yunchan97.tistory.com/72)](https://yunchan97.tistory.com/72). 3.iretq
명령어를 실행함으로써 커널 모드에서 사용자 모드로 전환하고, 새 프로그램의 시작 지점(rip 레지스터가 가리키는 위치)부터 코드 실행을 시작합니다[1](https://stay-present.tistory.com/98)[[5](https://malgun-gothic.tistory.com/110)](https://malgun-gothic.tistory.com/110).process_exec
함수에서는load
함수를 통해 바이너리 파일을 메모리에 로드하고 인터럽트 프레임(_if
)을 설정한 후,do_iret(&_if)
를 호출하여 새로운 프로세스의 실행을 시작합니다[6](https://woonys.tistory.com/148). 이때load
함수에서 설정한if_->rip = ehdr.e_entry;
가 중요한데, 이는 새 프로그램의 시작 주소(엔트리 포인트)를 인터럽트 프레임의 rip에 설정하는 것입니다.따라서
do_iret
은 "이전에 메모리에 로드된 바이너리 파일의 정보를 바탕으로 CPU 레지스터를 업데이트하여 새로운 프로세스의 실행을 시작하는 함수"라고 정확히 이해하시면 됩니다[5](https://malgun-gothic.tistory.com/110)[[7](https://joong-sunny.github.io/swjungle%20survive/week9-1/)](https://joong-sunny.github.io/swjungle%20survive/week9-1/).- https://stay-present.tistory.com/98
- https://velog.io/@susuggang/PintOS-doiret
- https://yunchan97.tistory.com/72
- https://velog.io/@phw1996/Project-2-User-Program
- https://malgun-gothic.tistory.com/110
- https://woonys.tistory.com/148
- https://joong-sunny.github.io/swjungle%20survive/week9-1/
- https://straw961030.tistory.com/267
- https://nullbyte.tistory.com/34
- https://roll-over-program.tistory.com/62
Perplexity로부터의 답변: [pplx.ai/share](https://www.perplexity.ai/search/pplx.ai/share)
-
do_iret 이후로 디버거로 따라가기가 안 된다. 별도의 프로세서로 레지스터가 업데이트 돼버려서 그런가보다.
맞습니다.
do_iret
함수 이후로 디버거로 따라가기가 어려운 이유는 정확히 그 때문입니다.do_iret
함수는iretq
어셈블리 명령어를 실행하는데, 이 명령어는 여러 중요한 변화를 일으킵니다:-
특권 수준 전환: 커널 모드(ring 0)에서 사용자 모드(ring 3)로 전환됩니다[2](https://os.phil-opp.com/returning-from-exceptions/)[[5](https://nfil.dev/kernel/rust/coding/rust-kernel-to-userspace-and-back/)](https://nfil.dev/kernel/rust/coding/rust-kernel-to-userspace-and-back/). 이는 코드 세그먼트 선택자(CS)가 사용자 모드 권한 레벨로 변경되기 때문입니다.
2. 레지스터 컨텍스트 완전 교체:
iretq
는 스택에서 여러 중요한 레지스터 값을 복원합니다2:- RIP (명령어 포인터) - 실행할 다음 명령어 위치
- CS (코드 세그먼트)
- RFLAGS (플래그 레지스터)
- RSP (스택 포인터)
- SS (스택 세그먼트)
- 실행 흐름 변경: 새로운 프로그램의 시작 지점으로 실행 흐름이 완전히 전환됩니다[2](https://os.phil-opp.com/returning-from-exceptions/).
이런 급격한 변화로 인해 디버거가 실행 흐름을 추적하기 어려워집니다. 특히:
- 디버거가 커널 모드에서 동작하다가 갑자기 사용자 모드 컨텍스트로 전환되면 추적이 끊어집니다
- 모든 레지스터가 한꺼번에 변경되므로 이전 실행 컨텍스트와의 연속성이 사라집니다
- 실행 주소가 완전히 다른 메모리 영역(사용자 공간)으로 점프합니다
디버깅을 계속하려면 사용자 모드 디버깅을 위한 별도의 설정이나 브레이크포인트가 필요하며, 일반적으로 커널 디버거와 사용자 공간 디버거를 함께 사용해야 합니다[3](http://fdbg.x86asm.net/debugging-in-long-mode-in-theory.html)[[4](https://www.intel.com/content/dam/support/us/en/documents/processors/pentium4/sb/253669.pdf)](https://www.intel.com/content/dam/support/us/en/documents/processors/pentium4/sb/[2](https://os.phil-opp.com/returning-from-exceptions/)53669.pdf).
이는
iretq
명령어가 정확히 이런 목적(예외 처리 후 복귀 또는 새 프로세스 시작)을 위해 설계되었기 때문입니다2.- https://github.com/microsoft/vscode-react-native/issues/2254
- https://os.phil-opp.com/returning-from-exceptions/
- http://fdbg.x86asm.net/debugging-in-long-mode-in-theory.html
- https://www.intel.com/content/dam/support/us/en/documents/processors/pentium4/sb/253669.pdf
- https://nfil.dev/kernel/rust/coding/rust-kernel-to-userspace-and-back/
- https://www.outsystems.com/forums/discussion/50857/debugging-issue/
- https://www.mathworks.com/matlabcentral/answers/1902980-appdesigner-doesn-t-follow-the-current-line-in-debug-mode
- https://stackoverflow.com/questions/7647298/segmentation-fault-when-running-but-success-when-debugging
- https://e2e.ti.com/support/processors-group/processors/f/processors-forum/877778/ccs-am3352-debug-trace-issue
- https://en.wikipedia.org/wiki/Direct_memory_access
Perplexity로부터의 답변: [pplx.ai/share](https://www.perplexity.ai/search/pplx.ai/share)
-
특권 수준 전환: 커널 모드(ring 0)에서 사용자 모드(ring 3)로 전환됩니다[2](https://os.phil-opp.com/returning-from-exceptions/)[[5](https://nfil.dev/kernel/rust/coding/rust-kernel-to-userspace-and-back/)](https://nfil.dev/kernel/rust/coding/rust-kernel-to-userspace-and-back/). 이는 코드 세그먼트 선택자(CS)가 사용자 모드 권한 레벨로 변경되기 때문입니다.
2. 레지스터 컨텍스트 완전 교체: