본문 바로가기

프로그래밍/정글

[WEEK 9 - 10] 정글끝까지 - PintOS 2. USER PROGRAMS

1. 구현해야할 것들

  • argument passing
    C에서 main 함수는 argc, argv 인자를 받을 수 있다. 실행명령어 + 파일명 + 인자들 이런식으로 프로그램을 실행시킬 때 파일명과 인자들을 OS 함수 내에서 파싱하여 해당 프로그램의 유저 프로세스를 생성 및 실행한다.
  • system call
    여러가지 시스템콜 함수들을 구현한다. fork 함수가 가장 까다로웠다. fork는 다른 사람이 한 걸 참고하여 이해하는 방식으로 진행했다. 

2. PintOS에서의 스레드와 프로세스

  • 위 그림과 같이 PintOS에서의 thread는 kernel thread를 지칭한다. 메모리 시작부분에 struct thread 구조체가 할당되어 있고 interrupt frame이 들어있다. 커널 스택도 가지고 있다.
  • load함수를 수행하면 유저영역 페이지를 할당한다. (유저 프로세스 생성)
  • x86 아키텍처에는 Privilege level 이라는 개념이 있다.  PL = 0 supervisor level 부터 PL = 3 user level 까지 있으며 유저영역에서 하드웨어에 접근하는 인스터럭션을 수행하지 못하도록 protection 하는 개념이다.
  • 위 파란색글씨 userland, kernelland의 의미는 엄밀히 말하면 PL = 3 에서 실행중인 구간을 userland, PL = 0 에서 실행중인 구간을 kernelland라 한다.
  • PintOS kernel은 64MB의 가상주소 공간을 공유한다.(물리주소와는 1:1로 매핑되어 있음) thread create를 실행하여 커널 스레드를 생성하면 3번그림 pagepool에서 4KB짜리 페이지를 하나 떼어다가 쓰는것이다. 실제로 fork 상황에서 부모와 자식 스레드의 주소를 찍어보면 2^12 byte (= 4096 byte) 만큼 차이난다. 유저영역은 3GB 물리공간을 사용한다. 

3. SystemCall - Fork

(1) 시스템콜 호출전 tss 업데이트 작업

/* Sets up the CPU for running user code in the nest thread.
 * This function is called on every context switch. */
void
process_activate (struct thread *next) {
	/* Activate thread's page tables. */
	pml4_activate (next->pml4);

	/* Set thread's kernel stack for use in processing interrupts. */
	tss_update (next);
}

/* Sets the ring 0 stack pointer in the TSS to point to the end
 * of the thread stack. */
void
tss_update (struct thread *next) {
	ASSERT (tss != NULL);
	tss->rsp0 = (uint64_t) next + PGSIZE;
}
  • thread create 실행뒤에 context switch가 되고 만들어진 자식스레드는 load를 통해 유저영역을 생성한다고 했다.
  • 이 load 함수내부에서 process activate를 실행하여 tss에 들어있는 rsp0값을 업데이트 한다. 
  • next + PGSIZE(= 1<<12)를 할당하면 커널스레드의 맨 위를 가리키게 된다. 

(2) 유저영역에서 테스트 케이스 인스트럭션 수행 및 시스템콜 호출

__attribute__((always_inline))
static __inline int64_t syscall (uint64_t num_, uint64_t a1_, uint64_t a2_,
		uint64_t a3_, uint64_t a4_, uint64_t a5_, uint64_t a6_) {
	int64_t ret;
	register uint64_t *num asm ("rax") = (uint64_t *) num_;
	register uint64_t *a1 asm ("rdi") = (uint64_t *) a1_;
	register uint64_t *a2 asm ("rsi") = (uint64_t *) a2_;
	register uint64_t *a3 asm ("rdx") = (uint64_t *) a3_;
	register uint64_t *a4 asm ("r10") = (uint64_t *) a4_;
	register uint64_t *a5 asm ("r8") = (uint64_t *) a5_;
	register uint64_t *a6 asm ("r9") = (uint64_t *) a6_;

	__asm __volatile(
			"mov %1, %%rax\n"
			"mov %2, %%rdi\n"
			"mov %3, %%rsi\n"
			"mov %4, %%rdx\n"
			"mov %5, %%r10\n"
			"mov %6, %%r8\n"
			"mov %7, %%r9\n"
			"syscall\n"
			: "=a" (ret)
			: "g" (num), "g" (a1), "g" (a2), "g" (a3), "g" (a4), "g" (a5), "g" (a6)
			: "cc", "memory");
	return ret;
}
  • system call number를 rax에, 6개 인자를 레지스터 rdi, rsi, rdx, r10, r8, r9에 담는다.
  • 위 과정을 끝내고 syscall entry 인스트럭션으로 넘어간다.
  • 아래 docs convention에도 나와있지만 네번째 인자로 rcx가 아닌 r10을 사용한다. rcx는 rip를 저장할 때 사용하기 때문이다.

(3)  rcx에 rip, r11에 eflag 저장. 어디에서 수행하는지는 못찾음

  • 시스템콜을 호출할 때 어딘가에서(?) rcx에 현재 rip를 저장하고, r11에 eflag를 담는다.  
  • 위 내용은 docs에 있고 자세한 내용은 AMD64 linux kernel convention A.2.1에 calling convention에 표기되어 있다. 
  • 링크: https://refspecs.linuxfoundation.org/elf/x86_64-abi-0.99.pdf

(4)  read ring 0, 커널 스택으로 jump

#include "threads/loader.h"

.text
.globl syscall_entry
.type syscall_entry, @function
syscall_entry:
	movq %rbx, temp1(%rip)
	movq %r12, temp2(%rip)     /* callee saved registers */
	movq %rsp, %rbx            /* Store userland rsp    */
	movabs $tss, %r12
	movq (%r12), %r12
	movq 4(%r12), %rsp         /* Read ring0 rsp from the tss */
	/* Now we are in the kernel stack */
  • rbx와 r12를 임시로 저장해두고 rbx에 userland rsp 저장
  • task state segment 의 주소를 r12에 담고 r12가 가리키는 실제 테이블에 + 4 * 8byte(movq는 8바이트)의 값을 rsp가 가리키도록 한다. tss 구조체를 보면 정확히 32바이트 offset에 rsp변수가 담겨 있다.

(5)  userland context push 

	push $(SEL_UDSEG)      /* if->ss */
	push %rbx              /* if->rsp */
	push %r11              /* if->eflags */
	push $(SEL_UCSEG)      /* if->cs */
	push %rcx              /* if->rip */
	subq $16, %rsp         /* skip error_code, vec_no */
	push $(SEL_UDSEG)      /* if->ds */
	push $(SEL_UDSEG)      /* if->es */
	push %rax
	movq temp1(%rip), %rbx
	push %rbx
	pushq $0
	push %rdx
	push %rbp
	push %rdi
	push %rsi
	push %r8
	push %r9
	push %r10
	pushq $0 /* skip r11 */
	movq temp2(%rip), %r12
	push %r12
	push %r13
	push %r14
	push %r15
  • 커널스레드에 올라와서 바로 userland context를 스택에 push한다. 이 push한 값들을 그대로 인자로 넘기기 위해서 interrupt frame 구조체와 동일한 크기와 변수 순서대로 push한다.  

(6)  syscall handler 호출

	movq %rsp, %rdi

check_intr:
	btsq $9, %r11          /* Check whether we recover the interrupt */
	jnb no_sti
	sti                    /* restore interrupt */
no_sti:
	movabs $syscall_handler, %r12
	call *%r12
  • 유일한 인자인 rdi에 rsp를 복사한다. 이 rdi가 interrupt frame 구조체 포인터와 동일하다. 
  • 실제로 syscall handlerd에서 현재 스레드 구조체에 있는 interrupt frame을 printf해보면 유저 context가 아닌 kernel context가 찍힌다.
  • 팀원들과 얘기해 봤을 때 여기서 두 가지 가능성을 떠올렸다.
    1. 호출중에 다른스레드와 context switch가 돼서 interrupt frame에 들어 있던 user context 를 kernel context로 덮어 씌운 것이다.
    2. 애초에  시스템콜을 호출할 때 interrupt frame에 user context를 담지 않는다. 그저 user context를 갖고 있는 레지스터의 정보를 커널스택에 push하고 나중에 pop하면 user 영역으로 다시되돌아 갈 수 있기 때문이다. 애초에 커널스택에 push 된 정보 자체가 interrupt frame 이다.

현재 스레드의 IF와 인자로 들어온 IF를 비교하기 위한 코드
시점에 따른 IF의 상태

  • 스레드를 생성할때 Interrupt frame을 초기화 한다. 그런데 시스템콜을 호출할때 현재 스레드의 Interrupt frame을 확인해보면 같은 값을 그대로 들고 있는 것을 확인할 수 있다. 
  • 1번 가능성을 증명하려면 현재 user context를 스레드 구조체 안 interrupt frame에 저장하는 부분이 있어야 하는데 찾을 수 없었다. 그리고 애초에 userland에서 커널 스레드 data인 interrupt frame에 접근하는 것 자체가 말이 안된다.
  • 접근하려면 ring 0 상태가 되어야 하는데 이때는 이미 위의 (4) ~ (5) 상태이다. 여기에는 interrupt frame 구조체에 뭔가를 하는 부분이 없고 할 필요가 없다. 따라서 2번 가능성이 맞는 것 같다.

(7)  pop해서 유저 context 레지스터 값 복구

	popq %r15
	popq %r14
	popq %r13
	popq %r12
	popq %r11
	popq %r10
	popq %r9
	popq %r8
	popq %rsi
	popq %rdi
	popq %rbp
	popq %rdx
	popq %rcx
	popq %rbx
	popq %rax
	addq $32, %rsp
	popq %rcx              /* if->rip */
	addq $8, %rsp
	popq %r11              /* if->eflags */
	popq %rsp              /* if->rsp */
	sysretq
  • push 했던 유저영역 context 레지스터를 복구한다. 
  • sysretq를 통해 유저영역으로 context를 복구하고 syscall을 종료한다.

(PintOS 구현 부분)  

  • 실제로 구현하는 부분은 위 (6) 단계에서 호출하는 syscall handler를 타고들어가면 나오는 시스템콜 함수들을 구현하는 것이다. 
  • Fork 에서 어려웠던 부분은 일단 자료가 없어서 어려웠고.. 핵심은 생성된 유저스레드의 유저영역으로 iret을통해 jump 하려면 부모 스레드의 유저 context interrupt frame을 넘겨줘야 하는데, interrupt frame을 찍었을 때 예상하는 값이 안나와서 어려웠다. 먼저 어셈블리단을 조금이나마 이해하니까 도움이 되었다. 

?

참고자료: https://uchicago-cs.github.io/mpcs52030/switch.html