[pintos] Week2~3: User Program Part.2

이전 포스팅에서 pintos 2~3 주차의 주요 목표와 pintos에서 시스템 콜이 발생하는 과정에 대해 알아보았다. 

2025.05.19 - [크래프톤 정글] - [pintos] Week2~3: User Program Part.1

 

[pintos] Week2~3: User Program Part.1

1주 차에 Alarm Clock, Priority Scheduling과 같이 동기화와 스케줄링에 대해 다루었다면 2~3주 차는 User Program이 Pintos에서 돌아갈 수 있도록 구현을 진행한다.User Program을 OS에 로드하고, System Call을 통해 U

www.gowoong.com

이번 시간에는 ELF와 Load에 대해 알아보고 유저 프로그램을 pintos에서 실행하기 위한 과정을 알아보도록 하겠다.


ELF (Executable and Linkable Format)

ELF (Executable and Linkable Format)은 리눅스를 포함한 유닉스 계열 시스템에서 실행 파일, 오브젝트 파일, 라이브러리 등을 표현하는 표준 포맷이다. Pintos는 사용자 프로세스를 실행하기 위해 ELF 포맷을 해석하여 메모리에 적재(load)하고 실행하는 기능을 구현하고 있다.

ELF 주요 구성

구성 요소 설명
ELF Header 파일 전체 구조 설명 (매직 넘버, 아키텍처, 엔트리 주소 등)
Program Header Table 로딩 시 필요한 세그먼트 정보들 (ex: .text, .data)
Section Header Table 링크 및 디버깅용 (Pintos에서는 거의 사용 안 함)

출처 : 위키피디아 https://en.wikipedia.org/wiki/Executable_and_Linkable_Format


Pintos 에서의 ELF

Pintos는 유저 프로그램을 실행하기 위해 ELF 포맷을 직접 파싱 하고, 그 안의 세그먼트를 유저 주소 공간에 적재한 뒤 엔트리 포인트에서 실행을 시작한다.

Pintos의 ELF 로딩

/* 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() 함수는 Pintos의 사용자 프로세스를 실행하기 위해 ELF 파일을 메모리에 적재(load)하고, 초기 실행 환경을 준비하는 핵심 함수 d이다.

이 함수는 ELF 포맷 해석, 세그먼트 로딩, 스택 설정, 엔트리 포인트 전달 등의 단계를 수행한다.

1. 페이지 테이블 생성 및 활성화

t->pml4 = pml4_create();
process_activate(thread_current());
  • 프로세스 전용 페이지 테이블 생성 (pml4)
  • 생성 실패 시 goto done
  • 이후 process_activate()로 해당 프로세스의 주소 공간을 활성화

2. 실행 파일 열기

file = filesys_open(file_name);
  • 파일 시스템에서 실행 파일 열기
  • 실패 시 에러 메시지 출력 후 종료

3. ELF 헤더 검증

/* 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;
	}
  • ehdr: ELF 헤더를 나타내는 구조체
  • e_ident: ELF 매직 넘버, 클래스(64비트), 엔디안 등 확인
  • e_type: 실행 파일인지 확인
  • e_machine: 아키텍처가 x86_64 (0x3E)인지 확인
  • e_phentsize, e_phnum: Program Header Table 정상 여부

4. Program Header Table 반복

file_ofs = ehdr.e_phoff;
for (i = 0; i < ehdr.e_phnum; i++) {
    ...
}
  • ELF 헤더에서 Program Header 테이블 위치(e_phoff)와 개수(e_phnum)를 이용
  • 각 헤더(Phdr)를 읽고 p_type에 따라 분기

5. PT_LOAD 세그먼트 처리

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;
  • .data, .text → 파일에서 읽고 일부는 0 초기화
  • .bss → 전체가 0 초기화
  • load_segment() 함수로 해당 세그먼트를 유저 주소 공간에 로딩;

6. 사용자 스택 설정

/* Set up stack. */
if (!setup_stack (if_))
    goto done;
  • 초기 유저 스택 생성 (보통 PHYS_BASE 근처에 생성됨)
  • rsp 값을 if_에 설정

7. 시작 주소 (rip) 설정

/* Start address. */
if_->rip = ehdr.e_entry;
  • ELF 헤더의 e_entry 필드를 읽어 프로그램 시작 주소로 설정
  • do_iret()를 통해 유저 모드로 복귀할 때 이 주소에서 실행 시작

Pintos에 유저 프로그램 실행 시키기

이번 프로젝트의 궁극적인 프로젝트는 Pintos라는 운영체제에 유저 프로그램이라고 할 수 있는 테스트 코드를 실행시키는 것이다. 그렇기 위해 ELF에 대해 알아봤다.

ELF는 e_entry에서 시작할 사용자 프로그램의 코드를 적재해 준다. 하지만 이는 단순히 적재만 한 것이고 이 프로그램을 int main(int argc, char *argv[])와 같은 형식으로 시작하려면 운영체제가 스택에 인자들을 C 언어 관례에 맞게 올려줘야 한다. 그래서 우리는 테스트를 실행하며 어떤 프로그램을 시작할 것이며 어떤 실행 조건을 줄 것인지 인자들을 포함시킨 것이다. 그렇다면 우리는 Pintos에 해당 프로그램을 실행시키기 위해 인자와 파일 이름 등 여러 조건들을 파싱(Parssing) 해야 한다.


파싱 (Parssing)

파싱은 유저 프로그램의 인자(argv)를 올바르게 분리하고, 유저 스택에 적절히 적재하기 위해 필요한다. 즉, "echo hello world"라는 단일 문자열을 → "echo", "hello", "world"로 나누기 위해 파싱이 필요하다.


스택에 기록

위에서 요청에서 들어온 문자열을 파싱 해서 프로그램 이름과 인자들로 분리를 진행했다. 그렇다면 이대로 끝인가? 아닐 것이다. 그냥 실행할 프로그램 이름만 보낼 수는 있지만 특정 인자 혹은 입력이 없다면 실행되지 않는 프로그램들이 있을 것이다.

그렇다면 어떻게 pintos에서 프로그램을 실행시킬 때 인자를 같이 넘겨줄 수 있을까?

커널에서 사용자 프로그램을 시작하면 다음의 과정을 밟는다.

  1. 인터럽트 프레임 생성 (struct intr_frame) 생성
  2. ELF 실행 파일을 메모리에 로딩 + 인터럽트 프레임 초기화
  3. 유저 스택에 인자들 설정
  4. intr_exit()로 유저 모드 진입

  • 유저에서 커널로 진입하기 위해 32비트 시스템에서는 int 0x30으로 진행했지만 64비트 시스템에서는 syscall을 실행한다.
  • 커널에서 유저로 복귀를 위해서 32비트 시스템에서는 iret 64 비트 시스템은 sysretq를 실행한다.
  • 이때 커널은 intr_frame에 유저 레지스터 상태를 저장/복원한다.

현재 이미지들은 32비트 시스템을 기준으로 하고 있어 별도로 설명하면 유저 모드에서 syscall 명령어를 실행한다. 이후 커널 모드로 진입한다. 스택 프레임이 성장하면서 여러 값들을 저장한다. 이 후 sysretq로 복귀를 한다.

나는 이 과정이 이해가 잘 가지 않았다. process_exec()에서 실행하고 있는 스택 조작? 함수의 경우는 유저 프로그램을 위한 것이다. 이 함수는 유저가 실행한 프로그램이 main(int argc, char *argv[] 형태로 시작될 수. 있도록 유저 스택에 인자들을 설정해 주는 역할을 한다.

그러니까 더 쉽게 말하면

  • process_exec() 함수는 현재 커널 스레드(즉, 기존 유저 프로세스)의 실행 문맥을 완전히 지우고, 새로운 ELF 실행 파일을 적재하고, 유저 스택을 설정하여 새 유저 프로그램으로 전환하는 함수라는 것이다. 
  • 기존 프로세스를 죽이고 -> 유저가 요청한 프로그램을 새롭게 실행하기 위해 리셋을 하는 단계라는 것이다.
  • 지금은 단일 테스트 실행 환경으로 보일 수 있지만 실제로는 여러 개의 유저 프로그램을 순차적으로, 또는 동시 실행할 수 있는 구조를 갖추고 있는 것이다.
  • 그럼 exec를 하면 커널도 죽는 것 아닌가?라는 생각을 했지만 아니라고 한다. 현재 커널 스레드는 그대로 유지하면서 그 스레드에 연결된 유저 프로세스의 실행 컨택스트(코드, 주소 공간 등)만 교체를 하는 것이다.

요약

proccess_exec()에 있는 Load()와 스택 설정 요구 사항은 커널이 현재 실행 중인 스레드의 유저 실행 환경(유저 주소 공간, 스택, 코드 등)을 싹 갈아엎고, 새로운 유저 프로그램을 실행할 수 있도록 유저 스택 + intr_frame에 맞춰 설정해 주는 기능을 구현하고 있는 것이다.

구체적으로 process_exec()가 하는 일은 아래와 같다.

동작 내용 적용 내용
기존 pml4 (유저 주소 공간) 제거 유저 공간 삭제
새 pml4 생성 새로운 유저 공간 확보
ELF 실행 파일 열기 load() 호출
ELF의 .text, .data 등 세그먼트 유저 주소 공간에 로딩 유저 코드, 데이터 설정
setup_stack() 혹은 set_stack_data() 호출 유저 스택 설정 (argv[], argc 등)
intr_frame.rip, rsp 설정 유저 프로그램의 시작 주소로 진입 준비
do_iret() 호출 유저 모드로 복귀 — 새 프로그램 실행 시작

‼️ 주의

  • 이 과정에서 유저 스택은 완전히 새롭게 초기화되며,
    이전 프로그램의 스택/변수는 모두 사라진다.
  • ELF의 e_entry 주소는 rip에 저장되어
    do_iret() 복귀 시 유저 프로그램이 정확히 시작된다.
  • 커널 스택은 건드리지 않는다.

이제 다음 포스팅에서 진짜 코딩을 진행해 보겠다.