infra-whale 님의 블로그
PINTOS : 인터럽트 본격 탐구 본문
VM 테스트 케이스를 다 통과할 무렵, 우리 팀은 하나의 의문에 봉착했다.
2주동안 우리를 그토록 괴롭혔던 page fault.
그건 대체 어디에서 온 것일까?
솔직히 모른다고 해서 구현에 지장이 가는 것은 아니었다. 페이지 구조체는 만들었으나, 프레임이 할당되어 있지 않은 경우 page fault 가 알아서 잘 실행되어서 vm_try_handle_fault를 실행시킬 것이다. 그러면 우린 이 안에 필요로 하는 로직을 써놓기만 하면 된다. 실제로 몰라도 모든 테스트는 다 통과 했으니까 말이다.
하지만 프로젝트가 끝나는 마지막 날까지, page fault 더 나아가 interrupt가 어디서 오는 건지 모르고 떠나는 건 좀 아니란 생각이 들었고, 마지막 날 우린 여기서 삽질을 해보기로 하였다.
page fault 발생 시점
그럼 핀토스에서 page fault는 언제 발생하는걸까? 이를 알아보기 위해 우리가 구현한 코드를 살짝 보자. 우선 project 2에서 이미 구현되어 있던 코드다. 즉시 로딩으로 구현되어있다.
static bool
load_segment(struct file *file, off_t ofs, uint8_t *upage,
uint32_t read_bytes, uint32_t zero_bytes, bool writable)
{
ASSERT((read_bytes + zero_bytes) % PGSIZE == 0);
ASSERT(pg_ofs(upage) == 0);
ASSERT(ofs % PGSIZE == 0);
file_seek(file, ofs);
while (read_bytes > 0 || zero_bytes > 0)
{
size_t page_read_bytes = read_bytes < PGSIZE ? read_bytes : PGSIZE;
size_t page_zero_bytes = PGSIZE - page_read_bytes;
uint8_t *kpage = palloc_get_page(PAL_USER); // 로드 시점부터 프레임을 할당받는다.
if (kpage == NULL)
return false;
if (file_read(file, kpage, page_read_bytes) != (int)page_read_bytes)
{
palloc_free_page(kpage);
return false;
}
memset(kpage + page_read_bytes, 0, page_zero_bytes);
// 로드 시점부터 페이지 테이블에 매핑된다.
if (!install_page(upage, kpage, writable))
{
palloc_free_page(kpage);
return false;
}
read_bytes -= page_read_bytes;
zero_bytes -= page_zero_bytes;
upage += PGSIZE;
}
return true;
}
로딩 시점부터 프레임을 할당받고, pml4에 매핑도 되어서 page fault를 걱정할 일이 없었다. 하지만 지연 로딩을 구현하면서부터 조금 골치아파졌다.
static bool
load_segment(struct file *file, off_t ofs, uint8_t *upage,
uint32_t read_bytes, uint32_t zero_bytes, bool writable)
{
ASSERT((read_bytes + zero_bytes) % PGSIZE == 0);
ASSERT(pg_ofs(upage) == 0);
ASSERT(ofs % PGSIZE == 0);
file_seek (file, ofs);
while (read_bytes > 0 || zero_bytes > 0)
{
size_t page_read_bytes = read_bytes < PGSIZE ? read_bytes : PGSIZE;
size_t page_zero_bytes = PGSIZE - page_read_bytes;
struct load_info *aux = (struct load_info *)malloc(sizeof(struct load_info));
if(aux == NULL)
return false;
memset(aux, 0, sizeof(struct load_info));
aux->file = file;
aux->page_read_bytes = page_read_bytes;
aux->page_zero_bytes = page_zero_bytes;
aux->writable = writable;
aux->ofs = ofs;
if (!vm_alloc_page_with_initializer(VM_ANON, upage, writable, lazy_load_segment, aux))
return false;
read_bytes -= page_read_bytes;
zero_bytes -= page_zero_bytes;
upage += PGSIZE;
ofs += page_read_bytes;
}
return true;
}
프로젝트 3에서의 구현이다. 프레임 할당도, pml4 매핑도 여기에선 수행하지 않는다. 만약 해당 페이지에 접근하게 되면 페이지 테이블은 페이지의 가상 주소를 통해 물리 주소를 반환받으려 할 것이고, 매핑이 되어있지 않으니 아무것도 반환할 수 없을 것이다.
이 때, 인터럽트가, 더 정확히 말하면 page fault가 발생하게 된다. 해당 과정을 더 자세히 알아보자.
page fault(interrupt)의 시작
page fault의 감지는 cpu에 의해 일어나게 된다. 위와 같은 상황이 발생하게 된되면 cpu는 해당 interrupt의 vec_no가 14번임을 이미 알고 있다. 그래서 intr-stubs.S에 있는 어셈블리어 코드가 실행되는 것을 시작으로 interrupt가 실행된다.
우리의 가장 큰 의문 중 하나는, cpu가 하드웨어 레벨에서 이를 어떻게 감지하냐는 것이었다. 열심히 뒤져보았으나 찾지 못했고 결국 아래와 같이 결론이 났다.
아쉽게도 코드 레벨에서는 보지 못하는듯 하다. 바로 intr-stubs.S로 넘어가자.
cpu는 해당 파일 안에, 각 vec_no에 해당하는 0부터 255의 STUB를 가지고 있다. page fault가 발생할 경우 16진법 14에 해당하는 0e가 실행될 것이다.
여기까지만 보면 잘 모르겠다. 그럼 STUB을 까보자.
/* This implements steps 1 and 2, described above, in the common
case where we just push a 0 error code. */
#define zero pushq $0;
/* This implements steps 1 and 2, described above, in the case
where the CPU already pushed an error code. */
#define REAL
.section .data
.globl intr_stubs
intr_stubs:
/* Emits a stub for interrupt vector NUMBER.
TYPE is `zero', for the case where we push a 0 error code,
or `REAL', if the CPU pushes an error code for us. */
#define STUB(NUMBER, TYPE) \
.section .text; \
.globl intr##NUMBER##_stub; \
.func intr##NUMBER##_stub; \
intr##NUMBER##_stub: \
TYPE; \
push $0x##NUMBER; \
jmp intr_entry; \
.endfunc; \
.section .data; \
.quad intr##NUMBER##_stub;
STUB은 각 인터럽트에 대응하는 간단한 핸들러 역할을 하는 코드로 생각하면 된다. 실제 핸들러로 이동하기 전, 기본적인 처리를 담당할 것이다.
STUB(NUMBER, TYPE)은 2가지 인자를 받는다.
먼저 두 번째 인자는 zero 혹은 REAL이다.
zero의 경우 에러코드가 없어 0을 수동으로 push 하는 경우를 말하는 것이고, REAL은 CPU가 이미 에러코드를 스택에 push 하여 할 것이 없는 경우를 말하는 것이다.
첫 번째 인자는 해당 인터럽트의 vector number가 온다. 해당 숫자를 받아서 STUB 매크로의 각 요소가 정의되고, 이를 호출하는 것으로 인터럽트가 실행된다.
예를 들어 page fault에 해당하는 14번이라면 위 코드는 아래와 같이 바뀔 것이다.
.section .text // 텍스트 영역에 코드를 저장한다.
.globl intr14_stub // 이 함수를 전역으로 선언한다.
.func intr14_stub
intr14_stub:
REAL
push $0x14 // 14번 벡터 번호를 push 한다.
jmp intr_entry // intr_entry로 점프한다.
.endfunc
.section .data // 데이터 영역에
.quad intr14_stub // 이 스텁의 주소를 기록한다.
여기서 push란, 스택에 데이터를 저장함을 의미한다. 그 후, intr_entry로 점프한다 했으니 이 부분을 다시 보자.
.section .text
.func intr_entry
intr_entry:
/* Save caller's registers. */
// 현재 레지스터 상태를 스택에 저장한다.
// 인터럽트 처리가 끝난 후 현재 상태로 돌아올 수 있게 만든다.
subq $16,%rsp // 스택 포인터를 조정하여 16 바이트의 공간을 만든다.
movw %ds,8(%rsp) // ds 세그먼트 레지스터를 스택에 저장한다.
movw %es,0(%rsp) // es 세그먼트 레지스터를 스택에 저장한다.
// 레지스터 값들을 모두 스택에 저장한다.
subq $120,%rsp // 스택 포인터를 조정하여 120 바이트의 공간을 만든다.
movq %rax,112(%rsp)
movq %rbx,104(%rsp)
movq %rcx,96(%rsp)
movq %rdx,88(%rsp)
movq %rbp,80(%rsp)
movq %rdi,72(%rsp)
movq %rsi,64(%rsp)
movq %r8,56(%rsp)
movq %r9,48(%rsp)
movq %r10,40(%rsp)
movq %r11,32(%rsp)
movq %r12,24(%rsp)
movq %r13,16(%rsp)
movq %r14,8(%rsp)
movq %r15,0(%rsp)
// CPU 문자열을 처리할 때 앞쪽으로 처리하도록 한다.
cld /* String instructions go upward. */
// 세그먼트 레지스터를 설정한다.
//SEL_KDSEG 값을 rax 레지스터에 저장한다.
// SEL_KDSEG : 커널 데이터 세그먼트를 나타내는 상수 값.
movq $SEL_KDSEG, %rax
// ax 값을 ds에 저장한다. CPU가 데이터를 읽거나 쓸때 이 세그먼트를 사용한다.
movw %ax, %ds
movw %ax, %es
movw %ax, %ss
movw %ax, %fs
movw %ax, %gs
// 스택 포인터 rsp 값을 rdi 레지스터에 복사한다.
movq %rsp,%rdi
call intr_handler // 인터럽트 핸들러를 호출한다.
// 스택에서 레지스터 값을 복원한다.
movq 0(%rsp), %r15
movq 8(%rsp), %r14
movq 16(%rsp), %r13
movq 24(%rsp), %r12
movq 32(%rsp), %r11
movq 40(%rsp), %r10
movq 48(%rsp), %r9
movq 56(%rsp), %r8
movq 64(%rsp), %rsi
movq 72(%rsp), %rdi
movq 80(%rsp), %rbp
movq 88(%rsp), %rdx
movq 96(%rsp), %rcx
movq 104(%rsp), %rbx
movq 112(%rsp), %rax
// CPU 상태를 복원 한다.
addq $120, %rsp
movw 8(%rsp), %ds
movw (%rsp), %es
addq $32, %rsp
// 인터럽트를 종료한다.
iretq
.endfunc
요약하면 다음과 같다.
- 현재 레지스터 상태를 스택에 저장한다.
- 레지스터 값들을 스택에 모두 저장한다.
- 세그먼트 레지스터를 설정한다.
- 스택 포인터를 rdi 레지스터에 저장한다.
- 인터럽트 핸들러를 호출한다.
- 스택에서 레지스터 값들을 복원하고, 현재 레지스터 상태를 복원한다.
- 인터럽트를 종료한다.
중요한건 5번이다. 여기서 인터럽트 핸들러가 호출이 된다. 그럼 핀토스에서 어떤식으로 구현이 되어 있을까?
intr_handler
interrupt.c 파일에 들어가보면, 해당 함수를 확인할 수 있다.
긴 코드 중에서 우리가 주목할 부분은
/* Invoke the interrupt's handler. */
handler = intr_handlers[frame->vec_no];
if (handler != NULL)
handler (frame);
이 부분이다. handler는 함수 포인터고, page fault가 일어난다면, intr_handlers[] 배열의 14번째 요소를 받을 것이고 이를 호출하여 page fault가 일어날 것이다.
이 intr_handlers[] 가 등록되는 과정을 보려면, exception.c와 interrupt.c를 같이 볼 필요가 있다.
먼저 exception.c 부터 시작한다.
각각 intr_register_int를 호출하는 것을 볼 수 있다. 우선 맨 아래에 있는 page_fault 관련 코드를 기억해두자.
그 다음 부터는 모두 interrupt.c에서 확인하면 된다.
internal inturrupt를 등록하는 함수다. page fault의 경우 vec_no 14로 등록되었고, dpl 0이므로 커널 모드에서만 일어나는게 가능할 것이다. 그리고 INTR_OFF이므로 실행되는 동안 다른 interrupt는 비활성화 될 것이다.
해당 함수가 호출하는 register_handler()도 살펴보자.
드디어 찾는 부분이 나왔다.
intr_handlers[vec_no] = handler;
이 부분에서 해당 배열 요소에 hanler 함수 포인터에 저장된 함수가 들어간다. 앞서,
intr_register_int (14, 0, INTR_OFF, page_fault, "#PF Page-Fault Exception");
이 부분에서 실행되어야 할 함수로 page_fault를 넣어줬었다. 즉, intr_handler에서 intr_handlers[14] 가 호출되면 exception.c의 page_fault가 호출된다.
page_fault
이제 우리가 만나고 싶었던 page_fault를 볼 수 있다!!!
이후로는 우리가 구현한 vm.c 의 vm_try_handle_fault ()를 타고, 지연 로딩이 실행될 것이다.
다른 인터럽트들
우리가 주로 만났던 인터럽트는 project 1의 timer interrupt와 project 3의 page fault 였지만, 실제로는 더 많긴 하다. 우선 핀토스에 등록된 internal 인터럽트만 하더라도 다음과 같다.
intr_register_int 를 통하여 여러 인터럽트가 등록되는 과정도 이미 위에서 봤었다. 단지 대부분 handler를 등록해 놓지 않고 kill로 프로세스를 즉시 끝낼 뿐이다.
아무쪼록, 우리를 무기력하게도 하고 졸리게도 하고 미치게도 하였단 PINTOS는 이렇게 마무리되었다.
프로젝트 4를 넘기긴 했지만 시간이 허락한다면 이후에 그 부분까지도 도전해보고 싶다.