본문 바로가기

정글캠프-WIL/서브아이템

Pintos - 사용자 프로그램 흐름

https://github.com/IMGyuGo/pintos-w11-12

 

GitHub - IMGyuGo/pintos-w11-12: pintos-vm,filesys[팀원: 고명석, 김규민, 김민철, 김용]

pintos-vm,filesys[팀원: 고명석, 김규민, 김민철, 김용]. Contribute to IMGyuGo/pintos-w11-12 development by creating an account on GitHub.

github.com

https://gyumingomin.tistory.com/86

 

Pintos - 어떻게 부팅해서 운영체제가 되는가? [2]

https://gyumingomin.tistory.com/85 Pintos - 어떻게 부팅해서 운영체제가 되는가? [1]Pintos는 어떻게 부팅해서 운영체제가 되는가?start.S에서 main(), 그리고 thread_exit() 까지의 흐름 정리 [1] Pintos를 보면서 가장

gyumingomin.tistory.com

이 글은 Pintos의 사용자 프로그램의 흐름을 다시 보았을 때도 아! 이런 게 있었지 라는 느낌을 얻기 위해 다시 작성하는 글이다.

 

실행 액션이 들어오면 커널은 실행할 프로그램 이름과 인자를 하나의 문자열로 받아 initd라는 새 커널 스레드를 만든다. 이 스레드는 process_exec() 를 통해 새 사용자 주소 공간을 만들고, ELF 파일의 PT_LOAD segment들을 page 단위로 읽어 물리 메모리에 채운 뒤, stack에 argc/argv를 구성한다. 마지막으로 do_iret() 가 준비된 intr_frame을 CPU에 복원하면서 커널 모드에서 사용자 모드로 넘어간다.

 

흐름을 아래와 같이 진행할 예정이다.

1. 커널 명령줄에서 실행 액션 찾기
2. process_create_initd() : command line을 보존하고 새 thread 만들기
3. scheduler가 initd()를 실행
4. process_exec(): 사용자 모드로 돌아갈 intr_frame 준비
5. load(): argument 파싱, PML4 생성, ELF 파일 열기
6. Program Header 순회: 실제로 올릴 segment 고르기
7. read_bytes와 zero_bytes: file에 있는 부분과 0으로 채울 부분
8. load_segment(): page마다 frame을 잡고 파일 내용을 채움
9. setup_stack()과 argument passing
10. do_iret(): 커널에서 사용자 모드로 점프

 

1. 커널 명령줄에서 run 액션 찾기

Pintos는 부팅 과정에서 loader가 넘겨준 커널 command line을 읽는다. (자세한 코드 흐름은 너무 어려워 생략)

pintos --fs-disk=10 -p tests/userprog/args-single:args-single -- -q -f run 'args-single onearg'
예를 들어 위와 같은 실행이 있을 때, Pintos 커널 입장에서 중요한 부분은 -- 뒤의 커널 인자이다.
-q -f run 'args-single onearg' 가 커널로 들어간다.
threads/init.c의 main()은 BSS초기화, page allocator, paging, interrupt, syscall, file system 초기화를 끝낸 뒤 run_actions(argv)를 호출한다.

 

run_actions()는 action table에서 현재 argv의 첫 단어를 찾고, run은 run_task()에 연결되어 있다.

run_task()는 argv[1]을 실행할 task 문자열로 본다. userprog 빌드에선 thread test가 아니면 다음 형태로 사용자 프로세스를 시작한다.
즉, run 'args-single onearg'의 실제 출발점은 process_create_initd("args-single onearg")이다.

2. process_create_initd(): command line을 보존하고 새 thread 만들기

process_create_initd()의 첫 번째 중요한 일은 file_name 전체를 새 page에 복사하는 것이다.

이 복사가 필요한 이유는 parent thread와 child thread 사이의 race condition(동시성)을 막기 위해서다. 새 thread가 언제 schedule될지는 보장되지 않는다. 원본 문자열을 parent 쪽 stack이나 임시 버퍼에 그대로 두면, child가 load()에서 읽기 전에 내용이 바뀌거나 사라질 수 있다. 그래서 page 하나를 따로 잡아 command line 전체를 보존한다.

그 다음에는 thread 이름만 따로 뽑는다.
args-single onearg에서 실제 실행 파일 이름은 args-single 이므로 thread 이름도 args-single이 된다. 하지만 fn_copy에는 여전히 전체 문자열 args-single onearg가 남아 있다. 이 차이가 중요하다. thread 이름은 디버깅용이고, 실제 argument passing에는 전체 command line이 필요하기 때문이다.
마지막으로 thread_create(thread_name, PRI_DEFAULT, initd, fn_copy)를 호출한다. Pintos에서 사용자 프로세스도 일단은 커널 스레드 위에서 시작한다. 새 thread는 initd 함수를 실행할 준비가 된 상태로 ready list에 들어간다.

3. Scheduler가 initd()를 실행

thread_create()는 새 thread 구조체와 kernel stack을 담을 page를 할당하고, 초기 interrupt frame의 rip을 kernel_thread로 설정한다. 그리고 rdi에는 실행할 함수 포인터, rsi에는 aux 인자를 넣는다. 여기서는 함수가 initd, aux가 fn_copy다.

thread_create(..., initd, fn_copy)
  -> scheduler
  -> kernel_thread(initd, fn_copy)
  -> initd(fn_copy)

initd()는 현재 구현에서 VM이 켜져 있으면 supplemental page table을 초기화하지만, 현재는 제외(userprog만 볼 것)
이후 process_init()을 호출하고 곧바로 process_exec(f_name)으로 넘어감

4. process_exec(): 사용자 모드로 돌아갈 intr_frame 준비

process_exec()는 현재 thread의 실행 context를 새 사용자 프로그램으로 바꾸는 함수다. 가장 먼저 사용자 모드 복귀에 필요한 interrupt frame을 local 변수 _if로 만든다.

여기서 SEL_UDSEG, SEL_UCSEG는 사용자 data/code segement selector다. 나중에 do_iret(&if)가 실행되면 CPU는 이 frame을 기준으로 privilege level을 바꾸고 user mode로 복귀한다.
그 전에 process_cleanup() 으로 기존 주소 공간을 정리한다.

처음 실행되는 initd라면 아직 기존 user address space가 없을 가능성이 크지만, exec 시스템 콜처럼 기존 프로세스가 자기 이미지를 새 프로그램으로 갈아끼우는 상황에서는 이전 PML4를 제거해야 한다.

그 다음 load(file_name, &_if)가 실제 로딩을 수행한다.

5. load(): argument parsing, PML4 생성, ELF 파일 열기

load()는 file_name으로 받은 전체 command_line을 공백 기준으로 토큰화 한다.

예를 들어 args-single onearg라면 다음처럼 저장된다.
argc = 2
argv_kern[0] = "args-single"
argv_kern[1] = "onearg"

현재 thread에 새 page table을 만든다. 이 시점부터 현재 thread는 자기 user 주소 공간을 갖는데, 아직 사용자 프로그램의 page들은 거의 비어 있지만, 이후 load_segment()에서 install_page()를 하며 PML4에 user virtual address와 physical frame의 매핑을 추가한다.

실행 파일은 argv_kern[0]로 열어 command line 파싱의 첫 번째 토큰을 실행 파일 이름으로 사용한다.
파일을 열면 아래 검증을 하고
 - magic number가 ELF64 형식인지
 - executable type인지
 - machine이 AMD64인지
 - program header 크기와 개수가 정상 범위인지
이 검증이 실패하면 load()는 실패 경로로 빠진다.

6. Program Header 순회: 실제로 올릴 segment 고르기

ELF 파일 전체를 그대로 메모리에 복사하는 것은 아니다. ELF에는 header, section 정보, symbol 정보 등 실행 시 반드시 직접 매핑할 필요가 없는 데이터도 들어 있다. Pintos loader가 관심을 갖는 것은 program header 중 PT_LOAD 타입이다.

load()는 edhr.e_phoff부터 program header들을 하나씩 읽는다. 각 header의 p_type에 따라 동작이 달라진다.
- PT_NULL, PT_NOTE, PT_PHDR, PT_STACK 등은 무시
- PT_DYNAMIC, PT_INTERP, PT_SHLIB는 지원하지 않으므로 실패 처리
- PT_LOAD는 validate_segment()로 검증한 뒤 load_segment()로 메모리에 올림

validate_segment()는 segment가 user address space에 안전하게 올라갈 수 있는지 확인한다. 대표적으로 다음을 검사
 - p_offset과 p_vaddr의 page_offset이 같은가
 - p_offset이 file length 안에 있는가
 - p_memsz >= p_filesz 인가
 - segment 크기가 0이 아닌가
 - 시작과 끝 주소가 user virtual address 인가
 - 주소 덧셈이 overflow로 wrap around 되지 않는가
 - page 0을 매핑하려고 하지 않는가
page 0 매핑을 금지하는 이유는 null pointer 접근을 더 확실히 잡기 위해서다. 만약 page 0이 실제로 매핑되어 있으면 사용자 프로그램이 null pointer를 넘겼을 때 실수로 유효한 메모리처럼 다룰 수 있다.

7. read_bytes와 zero_bytes: file에 있는 부분과 0으로 채울 부분

PT_LOAD segment가 유효하면 load()는 page 단위 로딩에 필요한 값을 계산한다.

file_page: 파일에서 읽기 시작할 page-aligned offset
mem_page: 사용자 가상 주소의 page-aligned 시작점
page_offset: segment가 page 중간에서 시작하는 경우를 처리하기 위한 값
실제로 파일에서 읽을 byte 수와 0으로 채울 byte 수를 계산
read_bytes = page_offset + phdr.p_filesz[파일에 실제로 존재하는 데이터 크기]
zero_bytes = ROUND_UP (page_offset + phdr.p_memsz[메모리에 올라갔을 때의 크기], PGSIZE) - read_bytes
p_memsz가 더 큰 대표적인 경우는 BSS. 초기값이 0인 전역 변수들은 실행 파일에 0을 잔뜩 저장하지 않고, 메모리에 올릴 때 0으로 채우는 식으로 처리
loader의 일
1. 파일에 있는 read_bytes만큼은 disk에서 읽어 온다.
2. 파일에 없는 나머지 zero_bytes는 memory에서 0으로 채운다.

8. load_segment(): page마다 frame을 잡고 파일 내용을 채운다.

load_segment()는 사용자 프로그램의 loadable segment를 실제 메모리에 올린다.

먼저 file_seek(file, ofs)로 segment 시작 위치로 이동한다. 그 뒤 read_bytes와 zero_bytes가 모두 0이 될 때까지 page 단위 loop를 돈다.
각 반복에서 이번 page에 대해 read_bytes와 zero_bytes를 계산한다.
그 다음 user pool에서 물리 frame 하나를 할당한다.
kpage : [커널이 접근할 수 있는 주소지만, 용도는 사용자 페이지의 실제 물리 메모리 역할을 하는 것. 커널은 이 주소에 ELF 내용을 채운 뒤, install_page()로 사용자 가상 주소 upage와 연결]
그 frame에 파일 내용을 읽고 남은 부분을 0으로 채운 후
마지막으로 install_page를 호출해서 PML4에 매핑 정보를 등록
내부적으로 pml4_get_page()로 이미 매핑된 주소인지 확인하고, 비어 있으면 pml4_set_page()로 page table entry를 만듬
writeable이 false인 text/code segment는 read-only, true인 data segment는 writable로 들어감
성공하면 다음 페이지로 넘어감
이 루프가 끝났다는 것은 해당 PT_LOAD segment에 속한 모든 page가 물리 frame에 채워졌고, 사용자 가상 주소에 매핑되었다는 뜻

9. setup_stack()과 argument passing

ELF segment를 모두 올린 뒤에는 stack을 만듬. setup_stack()은 user stack 최상단인 USER_STACK 바로 아래한 page를 할당하고 매핑

처음 rsp는 stack의 맨 위인 USER_STACK을 가리킴 이후 load()는 rsp를 아래로 내리면서 argument를 쌓음

1. 문자열 자체를 뒤에서부터 stack에 복사 (역순)
2. 각 문자열이 놓인 user virtual address를 argv_user[i]에 저장
3. stack pointer를 8-byte alignment에 맞춤
4. argv[argc] == NULL [null sentinel을 쌓음 (null 보초)]
5. argv[i] 포인터들을 뒤에서부터 쌓음
6. argv 배열의 시작 주소를 argv_addr로 기억
7. fake return address로 NULL을 하나 더 쌓음
8. rdi = argc, rsi = argv_addr, rsp = 최종 stack pointer로 설정

 

x86-64 System V ABI에서 첫 번째 인자는 rdi, 두번째 인자는 rsi로 전달됨
Pintos user program의 _start(int argc, char *argv[])도 이 규칙에 맞게 호출됨
_start()는 다시 main(argc, argv)를 호출하고, main의 반환값을 exit() 시스템 콜로 넘김

10. do_iret(): 커널에서 사용자 모드로 점프

load()가 성공하면 process_exec()는 file_name page를 해제하고 do_iret(&_if)를 호출함
_if에 들어가 있는 정보
 - rip: ELF header의 e_entry
 - rsp: argument passing까지 끝난 user stack pointer
 - rdi: argc
 - rsi: argv
 - cs, ss, ds, es: user segment selector
 - eflags: interrupt enable bit 등

do_iret()은 어셈블리어로 작성되어 있고, 전달받은 intr_frame의 레지스터 값을 복원한 뒤 iretq를 실행
iretq는 code segment selector와 stack segment selector를 함께 복원하므로, CPU pivilege level이 kernel mode에서 user mode로 내려간다.
이 순간부터 커널이 아니라 사용자 프로그램의 첫 instruction이 실행된다. 사용자가 보는 관점에서는 이제 main(argc, argv)가 시작된 것처럼 보인다.
"메모리에 전부 올리는 Eager Loading"
ELF 파일 전체 byte를 통째로 복사한다는 의미는 아니고 실행에 필요한 PT_LOAD segment들의 메모리 이미지 전체를 page 단위로 구성한다는 뜻
파일에 실제로 존재하는 text/data byte는 file_read()로 읽어 오고, 파일에는 없지만 메모리에는 존재해야 하는 BSS 영역은 memset(..., 0, ...)으로 채운 다음 각 page를 PML4에 매핑함.
따라서 load()가 성공적으로 끝난 시점에는 프로그램 시작에 필요한 code, data, bss, 초기 stack이 모두 현재 thread의 user virtual address space 안에 준비
만약 VM/lazy loading을 구현하면 이 의미가 바뀜
그때는 load_segment()가 파일 내용을 즉시 읽지 않고 supplemental page table에 lazy page 정보를 등록함
실제 file read는 page fault가 난 순간으로 미뤄짐.
Pintos의 run은 커널 command line을 사용자 프로세스의 실행 이미지로 바꾸는 긴 파이프라인의 시작점
process_create_initd()는 전체 command line을 보존한 채 새 thread를 만들고, process_exec()는 그 thread의 주소 공간을 새 ELF 프로그램으로 교체함.
load()는 ELF header와 program header를 해석해 실제로 메모리에 올라가야 할 PT_LOAD segment만 골라냄.
마지막으로 load_segment()는 각 segment를 page 단위로 나누어 user pool frame에 읽고, 현재 process의 PML4에 유저 가상 주소와 frame의 매핑을 추가함
이 과정을 마치면 커널은 더 이상 C 함수 호출 프로그램을 시작하지 않고 준비된 intr_frame을 iretq로 복원하면서 CPU를 사용자 모드로 돌려보내고, 그 순간 ELF entry point부터 사용자 프로그램이 실행됨