깃헙 링크
https://github.com/ddubbu-dev/pintos-1-9th-9team/tree/2-userprog-without-process-pass
FYI. Project 과제별로 브랜치를 save해두었습니다. 필요하신 분은 편히 참고하세요.
틀린 부분 제보는 언제든 환영입니다.
테스트케이스 통과 현황
FAIL : system call process fork, wait, exec 파트는 이해가 덜 되어서 PR draft로만 남겨두었습니다.
발표 자료
2024.10.08 WEEK08 발표
여기서 질문! AWS EC2 서버 메모리 크기보다(1GB) PINTOS 가상 메모리 크기가(4GB) 더 크다. 어떻게 프로그램이 실행될 수 있을까? 그 전에 한가지를 공부해야한다.
수요 기반 페이지 로딩 방식은 가상 메모리 관리의 한 기법으로, 프로세스가 필요한 페이지만 메모리에 로드해 실행한다. 이를 위해 페이지 테이블을 사용해 가상 주소와 물리 주소 간의 매핑을 관리하고, 페이지 폴트 현상이 (프로그램이 특정 가상 주소에 접근할 때, 페이지 테이블에 가상 주소가 올바른 물리주소로 매핑되어 있지 않거나, 해당 페이지가 메모리에 없으면 발생) 발생할 수 있다.
QEMU 는 수요 기반 페이지 로딩 방식으로 실제 서버 메모리 크기보다 큰 프로그램을 돌릴 수 있다.
PintOS 메인 프로그램에서 kernel command line을 읽어와 run_actions 함수를 실행한다. 그 중 user program을 실행하기 위한 cmd만 파싱해서 thread_create 를 진행한다.
해당 함수를 살펴보기 전에, 사전 지식이 필요하다. OS는 메모리 분리로 시스템 안정성을 확보하기 위해 User Pool에서만 User Stack을 할당하고, Kernel Pool에서만 Kernel Stack을 할당한다. 참고로 커널 스택은 스레드마다 독립적으로 갖고 있고, 커널 모드로 진입 시 해당 커널 스택을 사용한다. 모드 전환을 위해서는 tss 구조체를 이용하는데, 이는 시스템콜 파트에서 이어서 발표하겠다.
앞서 말한 것처럼, 스레드는 커널 스택을 갖고 있어 Kernel Pool에서 페이지를 할당한다. 이때 palloc_get_page 함수는 flag 인자가 PAL_USER 일때만 User Pool에서 페이지를 할당하고, 그 외에는 Kernel Pool에서 할당한다.
그럼 User Pool에서 페이지를 할당하는 예시는 어디에 있을까?
이어서 User Program 실행과정을 함수 단위로 살펴보겠다.
/* Set up stack. */
if (!setup_stack(if_))
goto done;
/* Start address. */
if_->rip = ehdr.e_entry;
* ELF(Executable and Linkable Format)는 실행 파일, 목적 파일, 공유 라이브러리 그리고 코어 덤프를 위한 표준 파일 형식
* 세그먼트 타입이란?
gcc -S 옵션으로 컴파일 후 얻은 어셈블리어 파일을 열어보면, data / text 등 segment가 나누어져 있음을 볼 수 있다.
위 사전지식을 갖고, load 함수를 살펴보자. ELF 파일의 프로그램 헤더를 읽고 각 세그먼트 타입을 확인한다. 각 세그먼트 메모리에 맵핑 작업을 하는데 특히 PT_LOAD 세그먼트 처리 시 load_segment 함수를 주의해서 살펴보자. 네트워크 주소를 넷마스크를 통해 알아내는 것처럼 (uint64_t mem_page = phdr.p_vaddr & ~PGMASK) ELF 세그먼트의 가상 주소에서 페이지 오프셋을 제거하고 페이지의 시작 주소를 계산한다. 이를 페이지 테이블에 설치한다.
그리고 ELF 파일 헤더에 정의된 프로그램의 시작 주소를 인터럽트 프레임의 rip (instruction pointer register)로 지정한다.
그리고 유저 프로그램 실행을 위해 User Pool에서 유저 스택을 할당받고, rsp 값을 USER_STACK 주소로 초기화한다. 그리고 PGSIZE 만큼 위치를 다운시켜 페이지 테이블에 설치한다.
그리고 최종적으로 유저 프로그램 실행을 위한 Virtual Memory 세팅이 완료되었다.
그럼, 유저 프로그램을 실행해보자. 그 전에 인자들을 어떻게 어셈블리어로 전달할 수 있을까? 바로 인터럽트 프레임의 RSP (스택 포인터 레지스터)를 이용한다. 추후 컨텍스트 스위칭 (do_iret) 될 때 해당 값을 잘 복구하기 때문이다.
Q. 이때, 인자들을 역순으로 stack에 넣는 이유가 궁금할 수 있다.
A. 프로그램 실행시 rsp에서 up_stack하며 데이터를 꺼내기 때문에 거꾸로 넣는 점을 유의하자!
마지막 관문이다. 이제 커널 모드에서 벗어나 유저 프로그램을 실행할 차례다. 앞서 if → rsp에 잘 저장해둔 값들을 실제 CPU 레지스터로 복원할 필요가 있다.
[1] movq %0 %%rsp : RSP는 struct intr_frame *tf (interrupt frame 구조체 시작 주소)를 복사 후 순회를 준비한다.
[2] addq $120 %%rsp : tf→R 전부 복원 후 120byte(= 8byte*15) 만큼 이동한다.
[3] addq $32 %%rsp : RSP는 rip 주소에 도달한다.
[4] load 함수에서 설정한 rip 값은 실행 프로그램의 시작 주소이다. iretq 명령어를 실행하면, 미복원된 레지스터 값들과 (cs, eflags, rsp, ss) 함께 유저 프로그램의 다음 실행 명령을 실행하게 된다.
** tovalds linux 에서 iretq 관련 주석을 발견
유저 프로그램 실행 중에 커널 서비스를 요청하고 싶으면 어떻게 해야할까? 이때 OS 가 제공하는 인터페이스가 바로 system call이다. 이는 저번 Project1 에서 발생한 Timer Interrupt 와 달리 sw 상에서 발생하는 예외이다.
system call 플로우를 간략히 살펴보자.
[1][2] system call, read를 호출했다. 이는 syscall wrapper로 내부적으로 instruction syscall 를 호출한다.
[3] 이는 syscall-entry.S 를 실행한다.
syscall-entry.S
[1] tss 구조체로부터 kernel stack 시작 주소를 찾아 모드를 전환한다.
[2] context가 사라지기 전에 kernel stack에 register 값들을 저장하고,
[3] syscall_handler 로 진입해 요청 사항을 처리한다.
[4] 처리가 끝나면, kernel stack에서 다시 register를 복원해서
[5] sysretq 유저모드로 복귀한다.
이때, syscall_handler 함수 인자는 어디로부터 읽고, 반환값은 어디에 넣어야할까?
리눅스 x86-64 시스템 콜 매뉴얼을 보면 system call #는 rax, 그리고 arguments는 rdi ~ r9에 담겨진다.
그리고 리턴값은 rax를 사용함을 볼 수 있다. (저번 발표부터 OS 실제 구현부를 살펴보는건 꽤 좋은 동기부여인 것 같다)
좀 더 세세하게 살펴보자. tss 구조체로부터 kernel stack을 찾는다고 했는데, main 프로그램 초기화 혹은 유저 프로그램 load 시 (process_activate 내에서 호출함) tss_update 함수를 통해 kernel stack을 업데이트하는 것을 확인할 수 있다.
마지막으로 syscall-entry.S 파일을 살펴보며 마무리하겠다.
QnA
9조
- HW가 유저모드인지 커널모드인지 파악하는 방법은?
- syscall: 유저모드 -> 커널모드 sysretq : (retq : call의 리턴)
- iretq : (인터럽트 call 리턴)
- context switch vs syscall 차이점
- call vs jump call : 복귀할 다음 명령어 주소
- jump : 이동하고 끝
8조
- syscall instruction - sys_entry 등록 (write_msr 사용)
- 주소에 핸들러를 다는 것 이미 익숙함 Project1 : intr_register_ext 를 이용해 타이머 인터럽트 핸들러를 달아놓음
7조
- file descriptor table idx 빈슬롯을 매번 찾으면, next_fd가 불필요한 것 같은데? Yes
6조
- syscall handler 혹은 main(argc, argv) 인자 순서
5조
PML4 : 4단계로 컨버팅을 하는 이유는?
- 가상 주소 가능 크기 256T
- data structure : radix 트리
- 256T를 감당하기에 메모리가 부족함, 없는 주소의 경우 child가 없어도 되니깐,
* 트리 종류는 무지막지하게 많다.
4조
- fork 리턴값 : 자식 process는 0 / 부모는 child_pid 이거 언제 보았나?
- Proxy Lab : Multi-Process 공부할 때
3조
- void page_fault exit(-1) 만 넣으면 된다?
- duplicate_pte 함수 실제 동작 과정 (TODO: 좀 더 이해하기)
1조
- duplicate_pte : 전체를 복사하는거고, child가 없는 경우는 복사 안한다 그 개념일 뿐
- fork 에서 왜 sema_down을 할까?? 복제 과정에서 parent 가 변화하면 안되니깐
코치님 칭찬!
- 일주일만에 잘했다
- x86-64 syscall 명령어, 각기 다른 architecture 마다 어떻게 정의되어있을까? man syscall 확인해보시오
- 흐음..
- 그리고 질문 채널 활용을 잘해보자. 까먹었네
- exec
- linux fork는 사실 muli-thread라고 보는게 맞음 : parent process fork 후 child process 덮어씌워서 실행함
(개인적인 생각, 이번에 Virtual Address 발표 많이해서, Project3에서 좀 더 디깅할거를 고민해보쟈)
'2️⃣ 개발 지식 B+ > OS' 카테고리의 다른 글
[OS] QnA로 알아보는 Virtual Memory (1) | 2024.10.09 |
---|---|
[OS] Virtual Memory 강의 정리 - 반효경 교수님 (v2017) (4) | 2024.10.09 |
[PintOS Project1] THREADS (1) | 2024.10.08 |
어셈블리어 기초 (1) | 2024.10.02 |
[네트워크] echo 예제로 이해하는 소켓 인터페이스 (0) | 2024.09.16 |