Project 2. User Prgrograms
과제를 하기 전에 가장 중요한 것 과제에 대한 큰 틀을 먼저 이해하기! 과제를 찬찬히 이해해보고 이 과제가 왜 중요하고, 왜 해야하는지 생각해보자.
GOAL
: 사용자 프로그램을 실행할 수 있는 환경 구축하기
-> OS가 커널에서 벗어나서 실제 사용자 프로그램을 다룰 수 있도록
-> System Call 인터페이스 구현하기그렇다면 우리는 왜 시스템 콜을 구현해야 하는가?
: 사용자 프로그램은 os의 기능을 사용하기 위해 시스템 콜을 호출하고 이를 통해서 파일의 입출력, 프로세스 관리, 메모리 할당 등의 다양한 작업을 수행하기 때문에
=> 사용자 프로그램을 실행할 수 있는 환경 구축하기 !
: OS가 커널 코드 실행에서 벗어나서 실제 사용자 프로
그램을 다룰 수 있게끔
실행 파일을 디스크에서 읽어오기
: 운영체제는 디스크에서 실행 파일을 읽어와야 한다. Filesystem을 사용해서 파일을 찾아 읽어들인다. -> 파일 시스템은 파일의 위치를 추적하고 파일 내용을 메모리로 업로드한다.
=> 파일 시스템 issue 처리가 필요함 (예: 파일 권한 없거나 파일 손상 등)
프로그램 실행을 위한 메모리 할당
: 메모리는 프로그램 코드, 데이터, 스택, 힙 등에 사용
=> virtual memory allocation
: 가상 메모리는 실제 물리적 메모리 주소와 독립적으로 프로그램에 큰 주소 공간 제공
프로그램에 매개 변수 전달
: 프로그램이 실행될 때 명령줄 인자와 같은 매개변수를 전달해야 함
=> set up user stack
=> 프로그램이 실행될 때 사용할 스택 설정 - 함수 호출, 지역 변수 저장, 매개 변수 전달 등에 사용
context switch to the user program
: 운영체제는 커널 모드에서 사용자 모드로 전환해서 프로그램 실행
-> OS는 프로그램이 종료될 때까지 대기함. 종료되면 이를 처리하고 할당된 자원을 반납함
Stack grows downward
: 스택은 메모리의 높은 주소에서 낮은 주소로 확장된다.
함수 호출 시마다 스택 프레임이 추가되며 함수의 지역변수, 반환주소, 인자 등을 포함함
command line에서 명령어 실행하기
현재 Pintos 에서는 프로그램과 인자를 구분하지 못하는 구조다.
예를 들면 $ls -a
를 pintos에서는 하나의 프로그램명으로 인식한다.
=> 프로그램 이름과 인자를 구분해서 스택에 저장하고 인자를 프로그램에 전달해야 한다.
현재 pintos에서 사용자 프로그램을 실행하는 함수인
process_create_initd()
는 인자로 전달된 명령어 문자열을 받아 새로운 프로세스를 생성하고 실행함.
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 : 쓰레드의 id -> 시스템에서 각 쓰레드를 고유하게 식별하는 값*/
tid = thread_create (file_name, PRI_DEFAULT, initd, fn_copy);
if (tid == TID_ERROR)
palloc_free_page (fn_copy);
return tid;
}
filename이 명령어 문자열이고, 프로그램의 이름과 인자 모두 포함된 하나의 문자열로 처리되고 있다.
사용자 프로그램을 실행할 때 호출되는process_exec()
도 마찬가지로 file_name 변수를 통해 전달된 문자열을 그대로 load()
함수에 전달하고 있음.
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 ();
}
GOAL
: 프로그램 이름과 인자를 구분해서 user stasck에 저장하고, 인자를 프로그램에 전달하기!
user program이 실행될 때의 흐름 이해가 중요하다.
init.c
파일 안의 main()
함수
: 핀토스가 부팅되면 main()
함수 먼저 처리됨
run_actions(argv)
static void
run_actions (char **argv) {
/* An action. */
struct action {
char *name; /* Action name. */
int argc; /* # of args, including action name. */
void (*function) (char **argv); /* Function to execute action. */
};
/* Table of supported actions. */
static const struct action actions[] = {
{"run", 2, run_task}, /*action: run, argc: 2, function to execute: run_task*/
: main()
함수는 run_actions(argv)
함수 호출하여 argv 기반으로 동작 수행 ->
: command line에서 run
action을 준 경우 run_task
함수가 실행
run_task
함수 static const struct action actions[] = { {"run", 2, run_task}, /*action: run, argc: 2, function to execute: run_task*/
: run 동작이 요청되면
run_task
함수가 실행됨.
->process_create_initd()
함수를 호출하여 실제 사용자 프로그램 실행
process_create_initd()
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 ();
}
initd
함수는 process_exec()
함수 호출하여 사용자 프로그램 실행 success = load (file_name, &_if);
load()
함수 호출ELF(Executable and Linkable Format) Binary
: os에서 실행 가능한 파일 형식 중 하나
→ 실행 파일, 오브젝트 파일, 공유 라이브러리, 코어 덤프 파일을 포함한 다양한 바이너리 파일을 표현하는데 사용
ELF header : 파일의 시작 부분, 파일의 구조와 구성요소에 대한 기본 정보 → 파일 타입, 머신 타입, 엔트리 포인트 주소
프로그램 헤더 테이블: 실행 파일이나 공유 라이브러리의 경우, 이 헤더는 운영체제에 프로그램 실행을 위해 필요한 정보를 제공함. → 메모리 매핑을 위한 세그먼트 정보가 포함됨
섹션 헤더 테이블: 오브젝트 파일의 경우, 이 헤더는 링커에게 필요한 정보 제공 → 코드, 데이터, 심볼 테이블 등의 섹션 정보
섹션: 실제 코드와 데이터가 포함된 부분
.text : 실행 코드
.data : 초기화된 데이터
.bss: 초기화되지 않은 데이터
.rodata: 읽기 전용 데이터
.symtab : 심볼 테이블
.strtab : 문자열 테이블
운영체제는 elf 형식의 실행 파일을 읽고, 필요한 메모리 영역에 적재하며, 프로그램을 실행할 준비를 해야 한다.
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); //file_name의 복사본 저장 -> 페이지 크기의 메모리 할당
if (fn_copy == NULL) //메모리 할당에 실패하면
return TID_ERROR;
strlcpy (fn_copy, file_name, PGSIZE); //fn_copy에 file_name 복사
/*Argument Passing
file_name을 받아와서 null 기준으로 문자열 파싱
strtok_r(): 지정된 문자를 기준으로 문자열을 자름*/
char *parsing;
strtok_r(file_name, " ", &parsing);
/* Create a new thread to execute FILE_NAME. */
/*
tid : 쓰레드의 id -> 시스템에서 각 쓰레드를 고유하게 식별하는 값
initd: 새로 생성된 쓰레드가 실행할 함수, 전달될 인자: fn_copy
*/
tid = thread_create (file_name, PRI_DEFAULT, initd, fn_copy);
if (tid == TID_ERROR)
palloc_free_page (fn_copy);
return tid;
}
char *parsing; strtok_r(file_name, " ", &parsing);
:
strtok_r
함수를 사용하여 file_name을 공백을 기준으로 파싱한다. 첫 번째 토큰 file_name을 남기고 나머지 문자열을 parsing에 저장한다.
int
process_exec (void *f_name) {
char *file_name = f_name;
bool success;
/*Project2. Argument Parsing*/
char file_name_copy[128]; //원본을 복사할 배열의 크기는 최대 128 byte
memcpy(file_name_copy, file_name, strlen(file_name) + 1); //원본 문자열을 memcpy로 복사하고 1을 더해줘야 함(\0)
/* 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_copy, &_if);
/* If load failed, quit.
로드에 실패한 경우, 파일에 할당된 메모리 페이지 해제 */
palloc_free_page (file_name);
if (!success)
return -1;
hex_dump(_if.rsp, _if.rsp, USER_STACK - _if.rsp, true);
/* Start switched process. */
do_iret (&_if); //인터럽트 프레임에 저장된 상태 복원
NOT_REACHED ();
}
char file_name_copy[128]; //원본을 복사할 배열의 크기는 최대 128 byte memcpy(file_name_copy, file_name, strlen(file_name) + 1); //원본 문자열을 memcpy로 복사하고 1을 더해줘야 함(\0)
수정 가능한 복사본 file_name_copy를 만든다.
file_name을 file_name_copy 배열에 복사하는데, 문자열의 문자와 마지막 null 종료 문자까지 포함해서 +1을 해서 복사한다.
그리고 elf 실행 파일을 메모리에 로드하고, 프로세스 실행한다. load가 성공적으로 끝났다면,
do_iret(&_if)
를 호출하여 인터럽트 프레임에 저장된 새로운 프로세스의 상태로 cpu의 레지스터 값을 복원한다. 새로운 프로세스로의 context switch!
palloc_free_page(file_name_copy)가 아니라 palloc_free_page(file_name)?
file_name_copy
배열은 함수가 종료될 때 자동으로 스택에서 해제. 스택 메모리는 함수 호출 시 할당되고, 해당 함수가 리턴되면 자동으로 해제되는 특성이 있다
따라서 file_name_copy
에 대해 palloc_free_page()
를 호출할 필요가 없다.
char *token; //현재 토큰을 저장할 포인터
char *save_ptr; //strtok_r 함수가 내부적으로 사용하는 상태 정보를 저장할 포인터
char *argv[128]; //
uint64_t argc = 0; //토큰 개수
for (token = strtok_r(file_name, " ", &save_ptr); token != NULL; token = strtok_r(NULL, " ", &save_ptr)) {
argv[argc++] = token; // argv에 parsing된 현재 토큰 저장
...
argument_stack(argv, argc, if_);
}
파일 이름에서 인자를 추출해서 argv 배열에 저장한다.
argv에 추후 프로그램에 전달될 인자들이 담긴다.
마찬가지로 strtok_r()
함수를 사용하여 공백 문자를 기준으로 인자를 분리해준다.
이때 argc
는 파싱된 총 토큰의 개수가 된다.
argv 배열에 저장된 인자들을 스택에 역순으로 삽입,
이후 각 인자의 주소를 스택에 삽입, 마지막으로 리턴 주소 설정
argv: 인자 문자열 배열, argc: 인자의 개수, if_: interrupt frame 구조체
void argument_stack(char **argv, int argc, struct intr_frame *if_){
char *arg_address[128]; /*각 인자의 주소를 저장할 배열*/
//스택은 높은 주소에서 낮은 주소로 쌓이기 때문에 역순으로 삽입
//argc -1 부터 0까지 역순으로 반복
for (int i = argc - 1 ; i >= 0 ; i--) {
int argv_len = strlen(argv[i]); //현재 인자의 길이(스택에 복사할 떄 필요한 공간을 확보하기 위함)
if_->rsp = if_->rsp - (argv_len + 1);
//현재 스택 포인터 = 스택 포인터를 [ 인자의 길이 + 1(NULL 문자)] 만큼 감소시킴
memcpy(if_->rsp, argv[i], argv_len + 1 ); //인자를 스택에 복사
arg_address[i] = if_->rsp; //복사된 인자의 시작 주소를 arg_address[i]에 저장
}
//8 byte Padding
//스택 포인터가 8바이트로 정렬되지 않은 경우 while문으로 계속 패딩
while(if_->rsp % 8 != 0) {
if_->rsp--; //스택 포인터 1 byte 감소 -> 스택의 높은 주소에서 낮은 주소로 이동하는 것
*(uint8_t *)if_->rsp = 0; //현재 스택 포인터가 가리키는 위치에 0 저장
}
//주소값 Insert
//if_->rsp: stack pointer, 현재 스택의 최상단
// argc부터 1씩 감소시키며 0까지 반복
for (int i = argc; i >=0; i--){
if_->rsp = if_->rsp - 8; //각 반복마다 스택 포인터를 8바이트 감소시킴
if (i==argc) {
memset(if_->rsp, 0, sizeof(char **));
//sizeof(char **): 포인터의 크기
//i==argc일 때 => NULL 포인터로 채움(끝 표시)
}
//실제 인자값의 주소 복사
else {
//memcpy 사용하여 arg_address[i]의 주소를 if_->rsp위치에 복사
memcpy(if_->rsp, &arg_address[i], sizeof(char **)); //실제 인자의 주소를 스택에 복사 - 8바이트를 복사
/* sizeof(char *)와 같은 결과 but 이중 포인터를 달*/
}
}
//모든 인자가 스택에 설정된 후에 스택 포인터를 다시 8바이트 감소시키고
//fake return address 를 스택에 저장
if_->rsp = if_->rsp - 8;
memset(if_->rsp, 0, sizeof(void *));
if_->R.rdi = argc;
if_->R.rsi = if_->rsp + 8;
}
사용자 스택에 command line의 인자들을 8 byte(64 bit니까) 정렬에 맞추어서 insert, 그리고 인자의 주소들을 스택에 insert, fake return address를 마지막으로 스택에 insert하는 함수다.
그리고 인자로는 argc와 argv[0]의 주소, 인자 배열의 시작 주소를 넘겨준다.
헷갈리 부분
memcpy(if_->rsp, &arg_address[i], sizeof(char **))
&arg_address[i]
가char **
타입 ⇒arg_address
배열의i
번째 요소의 주소를 복사하는 것
arg_address[i]
는 char * 타입&arg_address[i]
는 char ** 타입
둘 다 포인터이기 때문에 size는 8바이트로 동일하긴 함.
uint8_t
포인터 캐스팅: 특정 메모리 주소를 다른 타입의 포인터로 변환하는 과정
void *ptr = some_address; // 어떤 메모리 주소를 가리키는 void 포인터 uint8_t *byte_ptr = (uint8_t *)ptr; // 이 주소를 uint8_t 타입의 포인터로 캐스팅
void 포인터는 어떤 타입의 데이터도 가리킬 수 있는 일반적인 포인터 하지만 직접 메모리 접근은 불가능하다.
void *
포인터는 타입이 없기 때문에 이를 통해 메모리에 접근하려면 특정 타입으로 캐스팅해야 함
(uint8_t *)ptr
는ptr
을uint8_t
타입의 포인터로 변환이렇게 하면 해당 메모리 주소를
uint8_t
타입의 데이터로 접근할 수 있게 된다.
uint8_t *byte_ptr
는 1바이트 크기의 부호 없는 정수를 가리키는 포인터. 이 포인터를 사용하면 메모리 주소를 1바이트 단위로 접근할 수 있다.
이렇게 하고,
함수를 수정해주어야 한다.
현재 핀토스에서는 사용자 프로세스를 생성하고 나서 프로세스 종료를 대기할 때, 자식 프로세스가 종료될 때까지 대기하는 기능이 구현되어 있지 않다.
따라서 임시로 코드를 넣어준다.
int
process_wait (tid_t child_tid UNUSED) {
/* XXX: Hint) The pintos exit if process_wait (initd), we recommend you
* XXX: to add infinite loop here before
* XXX: implementing the process_wait. */
for (int i = 0; i < 100000000; i++)
{
}
return -1;
}
그러면 process_exec에 넣어준 hex_dump 코드를 통해 현재 사용자 스택의 상태를 확인할 수 있게 된다!
참고
: 특정 메모리 영역을 주어진 값으로 채우는 데 사용
void *memset(void *s, int c, size_t n);
void *s
: 값을 설정할 메모리의 시작 주소
int c
: 메모리에 설정할 값. 이 값은 unsigned char
로 변환되어 사용된다.
size_t n
: 설정할 바이트의 수.
memset(buffer, 0, 10);
은 buffer
의 시작부터 10바이트를 0으로 채우는 것 -> 이는 주로 메모리를 초기화할 때 사용된다.
: 하나의 메모리 영역에서 다른 메모리 영역으로 데이터 복사하는데 사용
void *memcpy(void *dest, const void *src, size_t n);
void *dest
: 데이터를 복사할 대상 메모리 영역의 시작 주소.
const void *src
: 복사할 데이터의 원본 메모리 영역의 시작 주소
size_t n
: 복사할 바이트의 수
memcpy
의 경우, memcpy(dest, src, 10);
은 src
에서 dest
로 10바이트를 복사. 이는 데이터를 한 위치에서 다른 위치로 이동시키거나 복제할 때 사용된다
- memset: 메모리 영역을 특정 값으로 초기화하는데 사용
- memcpy: 메모리 영역에서 다른 메모리 영역으로 데이터를 복사하는데 사용