PintOS 프로젝트 첫 번째 날! 오늘은 timer_sleep()
함수를 호출하면 해당 스레드를 Block 상태로 변경하고, 정해진 sleep 시간이 경과하면 Timer Interrupt가 그 스레드를 다시 Ready 상태로 변경해주는 Thread Context Switch 기능을 구현했다.
그런데 잠들었던 스레드를 불러와서 다시 실행하는 것은 어떤 메커니즘으로 가능한 걸까? 잠들기 전에 어디까지 진행했는지를 어떻게 알 수 있을까? 이를 이해하기 위해 오늘은 프로그램의 기계 수준 표현에 대한 공부를 병행했다.
%rip
기계 수준 프로그램의 형식과 동작은 인스트럭션 집합 구조(instruction set architecture, ISA)에 의해 정의된다.
프로그램 카운터(일반적으로 PC라고 하며, x86-64에서는 %rip
라고 함)는 실행할 다음 인스트럭션의 메모리 주소를 가리킨다.
정수 레지스터 파일은 아래 그림과 같이 16개의 이름을 붙인 위치를 갖는다.
레지스터는 C 언어의 포인터에 해당하는 주소, 정수 데이터 등 64비트 값을 저장할 수 있다.
%rsp
정수 레지스터 파일에서 가장 주목해서 봐야할 레지스터는 %rsp
이다.
%rsp
는 스택 포인터 레지스터로, 런타임 스택의 끝 부분을 가리키기 위해 사용된다.
x86-64의 스택은 작은 주소 방향으로 성장하고, 스택 포인터 %rsp
는 스택의 최상위 원소를 가리킨다.
데이터는 pushq
와 popq
인스트럭션을 이용해서 스택에 저장하고 읽어올 수 있다.
데이터를 위한 공간을 할당할 때는 스택 포인터를 감소시키면 되고(스택은 작은 주소 방향으로 설장하므로), 공간을 반납할 때에는 스택 포인터를 증가시키면 된다.
프로시저의 스택 프레임은 스레드의 함수 호출 및 로컬 변수와 관련된 데이터를 저장하는 데에 사용된다.
스택 프레임의 맨 상단에는 항상 현재 실행 중인 프로시저에 대한 프레임이 위치하는데, 프로시저 P가 프로시저 Q를 호출할 때에는 return address를 스택에 푸시해서 Q가 리턴할 때 P에서 프로그램이 실행을 재시작할 위치를 가리키게 한다.
그러면 돌아와서 오늘 기계 수준 코드를 공부하게 됐던 계기에 대해 다시 생각해보자.
🤔 Thread Context Switch를 하면 스레드가 잠들었다가 다시 실행되는 상황이 생길 텐데, 스레드는 잠들기 전의 상태를 어떻게 알 수 있을까?
Thread Context Switch가 발생하면 스택 포인터(%rsp
)가 가리키는 위치가 다음 Running 스레드로 변경된다.
그리고 Thread Context Switch가 발생할 때에는 현재 실행중인 스레드의 프로그램 카운터 값(%rip
) 값을 저장해놔서, 그 스레드가 나중에 다시 실행될 때 이전에 실행 중이던 위치로 복원할 수 있도록 한다.
PintOS의 경우 Thread Context Switch를 어떻게 하고 있는지 살펴보자.
아래의 thread_launch
는 현재 스레드 running_thread()
의 상태를 저장하고 새로운 스레드 th
로 전환하는 함수이다.
각 코드의 역할은 주석으로 작성해두었다.
/* Switching the thread by activating the new thread's page
tables, and, if the previous thread is dying, destroying it.
At this function's invocation, we just switched from thread
PREV, the new thread is already running, and interrupts are
still disabled.
It's not safe to call printf() until the thread switch is
complete. In practice that means that printf()s should be
added at the end of the function. */
static void thread_launch(struct thread *th)
{
uint64_t tf_cur = (uint64_t)&running_thread()->tf;
uint64_t tf = (uint64_t)&th->tf;
ASSERT(intr_get_level() == INTR_OFF);
/* The main switching logic.
* We first restore the whole execution context into the intr_frame
* and then switching to the next thread by calling do_iret.
* Note that, we SHOULD NOT use any stack from here
* until switching is done.
*/
__asm __volatile(
/*
Store registers that will be used.
현재 스레드의 레지스터 상태를 나중에 복원할 수 있도록 스택에 푸시하여 백업
*/
"push %%rax\n"
"push %%rbx\n"
"push %%rcx\n"
/* Fetch input once */
// tf_cur 변수의 값을 rax로 복사
"movq %0, %%rax\n"
// tf 변수의 값을 rcx로 복사
"movq %1, %%rcx\n"
// 아래 movq %%rdx, 88(%%rax)\n까지는 현재 레지스터의 값을 rax 레지스터에 복사하는 코드
// r15 레지스터의 값을 rax 레지스터의 오프셋 0 위치, 즉 tf_cur 메모리의 시작 부분에 저장
"movq %%r15, 0(%%rax)\n"
"movq %%r14, 8(%%rax)\n"
"movq %%r13, 16(%%rax)\n"
"movq %%r12, 24(%%rax)\n"
"movq %%r11, 32(%%rax)\n"
"movq %%r10, 40(%%rax)\n"
"movq %%r9, 48(%%rax)\n"
"movq %%r8, 56(%%rax)\n"
"movq %%rsi, 64(%%rax)\n"
"movq %%rdi, 72(%%rax)\n"
"movq %%rbp, 80(%%rax)\n"
"movq %%rdx, 88(%%rax)\n"
// 스택 맨 위의 값, 즉 rcx 레지스터 백업값을 rbx 레지스터로 옮김
"pop %%rbx\n" // Saved rcx
// rbx 레지스터로 옮긴 값을 rax 레지스터에 복사
"movq %%rbx, 96(%%rax)\n"
"pop %%rbx\n" // Saved rbx
"movq %%rbx, 104(%%rax)\n"
"pop %%rbx\n" // Saved rax
"movq %%rbx, 112(%%rax)\n"
"addq $120, %%rax\n"
"movw %%es, (%%rax)\n"
"movw %%ds, 8(%%rax)\n"
"addq $32, %%rax\n"
// 현재 실행 중인 명령의 주소, 즉 프로그램 카운터(rip) 가져오기
"call __next\n" // read the current rip.
"__next:\n"
"pop %%rbx\n"
"addq $(out_iret - __next), %%rbx\n"
"movq %%rbx, 0(%%rax)\n" // rip
"movw %%cs, 8(%%rax)\n" // cs
"pushfq\n"
"popq %%rbx\n"
"mov %%rbx, 16(%%rax)\n" // eflags
"mov %%rsp, 24(%%rax)\n" // rsp
"movw %%ss, 32(%%rax)\n"
// do_iret 함수 호출을 위한 파라미터 설정 (앞서 저장한 인터럽트 프레임의 주소를 파라미터로 전달)
"mov %%rcx, %%rdi\n"
// do_iret 함수를 호출하여 인터럽트 프레임을 복원하고 새로운 스레드를 시작
"call do_iret\n"
"out_iret:\n"
: : "g"(tf_cur), "g"(tf) : "memory");
}