1. 개념 요약
CPU 예외(exception)는 CPU가 명령을 실행하다 스스로 문제를 감지했을 때 일으키는 인터럽트다. 0으로 나누기, 잘못된 명령, 매핑되지 않은 주소 접근 같은 상황이 여기에 해당한다.
예외는 외부 장치가 보내는 IRQ와 같은 IDT(Interrupt Descriptor Table)를 통해 처리되지만 출처가 다르다. IRQ는 키보드나 타이머 같은 바깥 장치가 보내고 예외는 CPU 내부에서 발생한다. x86-64에서 벡터 0~31번이 예외 전용으로 예약돼 있다.
2. 왜 필요한가
예외 핸들러가 없으면 CPU는 문제를 처리할 곳을 찾지 못한다. 예외가 발생했는데 해당 벡터에 핸들러가 없으면 다시 예외가 나고 그 예외도 처리하지 못하면 트리플 폴트로 CPU가 리셋된다. 화면에는 아무 정보도 남지 않고 재부팅만 된다.
예외를 받아 처리하면 무슨 문제가 어디서 일어났는지 화면에 남기고 멈출 수 있다. 조용한 재부팅 대신 읽을 수 있는 정지 화면이 되므로 디버깅이 가능해진다.
3. 등장 배경
인터럽트 개념 정리에서 세운 IDT는 키보드와 타이머 같은 하드웨어 IRQ를 받기 위한 것이었다. 그때는 PIC를 리맵해 IRQ를 벡터 0x20부터로 옮겼다. 벡터 0~31번을 비워 둔 이유가 바로 예외였다.
이 단계 전까지는 0으로 나누기나 잘못된 메모리 접근이 일어나면 그대로 트리플 폴트로 재부팅됐다. 같은 IDT의 0~31번 자리에 핸들러를 채우면 이런 상황을 잡을 수 있다.
4. 필요한 조건
예외를 처리하려면 다음 조건을 만족해야 한다.
| 조건 | 설명 |
|---|---|
| IDT 등록 | lidt로 IDT를 CPU에 적재해 둬야 한다. |
| 게이트 채움 | 처리할 벡터(0~31)의 게이트에 핸들러 주소와 셀렉터가 들어 있어야 한다. |
| present 비트 | 게이트 타입 속성의 present 비트(0x80)가 1이어야 한다. 0이면 CPU가 없는 엔트리로 본다. |
세 가지는 IRQ 게이트를 채울 때와 같다. 예외 벡터도 같은 16바이트 게이트 구조를 쓴다.
5. 핵심 구성 요소
벡터마다 어떤 예외인지, 에러코드를 동반하는지가 정해져 있다. 자주 만나는 예외는 다음과 같다.
| 벡터 | 이름 | 에러코드 | 발생 상황 |
|---|---|---|---|
| 0 | #DE divide error | 없음 | 0으로 나누기 |
| 3 | #BP breakpoint | 없음 | int3 명령 |
| 6 | #UD invalid opcode | 없음 | 잘못된 명령 실행 |
| 8 | #DF double fault | 있음 | 예외 처리 중 또 예외 |
| 13 | #GP general protection | 있음 | 권한·세그먼트 위반 |
| 14 | #PF page fault | 있음 | 매핑되지 않은 주소 접근 |
예외는 복귀 동작에 따라 세 종류로 나뉜다. fault는 복귀하면 문제가 된 명령을 다시 실행한다. trap은 다음 명령부터 이어 간다. abort는 복구할 수 없다. #PF는 fault, #BP는 trap, #DF는 abort에 해당한다.
6. 동작 흐름
flowchart TD
A["명령 실행 중 CPU가 문제 감지"]
B["CPU가 RIP/CS/RFLAGS를 스택에 push
스택 전환 시 RSP/SS도 추가"]
C{"에러코드 있는 예외인가"}
D["CPU가 에러코드를 추가로 push"]
E["IDT[벡터] 게이트로 점프"]
F["핸들러 실행"]
G["정보 출력 후 정지"]
A --> B --> C
C -- 예 --> D --> E
C -- 아니오 --> E
E --> F --> G
CPU가 문제를 감지하면 현재 실행 흐름을 멈추고 복귀에 필요한 정보를 스택에 넣는다. 에러코드를 동반하는 예외면 그 값을 하나 더 넣는다. 그다음 IDT에서 해당 벡터의 게이트를 찾아 핸들러로 점프한다. go-os의 핸들러는 어떤 예외인지 화면에 출력하고 정지한다.
7. 상태 변화
에러코드 유무에 따라 핸들러 진입 시 스택 모양이 달라진다.
| 예외 종류 | 동일 권한·스택 전환 없음 | 권한 변경 또는 IST로 스택 전환 |
|---|---|---|
| 에러코드 없음 | RIP, CS, RFLAGS | RIP, CS, RFLAGS, RSP, SS |
| 에러코드 있음 | 에러코드, RIP, CS, RFLAGS | 에러코드, RIP, CS, RFLAGS, RSP, SS |
에러코드 유무와 스택 전환 여부에 따라 프레임 크기가 달라진다. 모든 예외를 공통 stub으로 처리하려면 이 차이를 구분해야 한다. 에러코드가 없는 벡터는 stub에서 더미 값을 하나 밀어 넣어 에러코드 위치를 통일할 수 있다.
8. 코드나 설정에서 나타나는 형태
다음은 에러코드가 없는 예외 stub이다. 더미 값을 하나 밀어 넣어 정렬을 맞춘 뒤 Go 핸들러를 부른다.
exc_0:
push 0 ; 더미 에러코드(정렬 통일)
mov edi, 0 ; 벡터 번호를 인자로
call go_0kernel.OnException
cli
hlt
에러코드가 있는 예외는 CPU가 이미 값을 넣었으므로 push 0을 하지 않는다. 핸들러는 벡터 번호를 받아 이름을 출력하고 멈춘다.
func OnException(vec uint32) {
puts("\n\n*** CPU EXCEPTION: ")
puts(excName(vec))
puts(" -- halted ***")
}
이 코드는 예외가 트리플 폴트로 이어지기 전에 가로채는 자리에 해당한다. 핸들러가 정지하므로 같은 예외가 반복되지 않는다.
9. 자주 헷갈리는 지점
에러코드를 동반하는 예외와 그렇지 않은 예외를 같은 stub으로 처리하면 스택 위치가 어긋난다. 에러코드 없는 벡터에서 더미 값을 넣지 않으면 핸들러가 읽는 위치가 한 칸씩 밀린다.
예외에는 EOI를 보내지 않는다. EOI는 PIC가 보낸 IRQ를 끝낼 때 쓰는 신호인데 예외는 PIC가 아니라 CPU가 일으킨 것이라 보낼 대상이 없다.
트리플 폴트는 별도의 예외 벡터가 아니다. 예외를 처리하다 또 예외가 나고 그것마저 처리하지 못하면 CPU가 리셋되는 상태를 가리킨다. 그래서 핸들러가 없을 때의 증상이 핸들러 화면이 아니라 재부팅으로 나타난다.
10. 확인 방법
| 확인 대상 | 확인 방법 |
|---|---|
| 예외 게이트 등록 | IDT 0~31번 엔트리에 핸들러 주소와 present 비트가 들어 있는지 확인한다. |
| 핸들러 동작 | 매핑되지 않은 주소를 일부러 읽어 #PF를 유발하고 정지 화면이 뜨는지 확인한다. |
| 트리플 폴트 여부 | 핸들러가 없을 때는 화면이 재부팅으로 돌아가는지로 구분한다. |
go-os에서는 Esc 키를 누르면 매핑되지 않은 주소를 읽어 페이지 폴트를 유발하도록 두었다. 화면에 *** CPU EXCEPTION: #PF page fault -- halted ***가 뜨면 핸들러가 동작한 것이다.
11. 다음에 볼 개념
지금 핸들러는 예외 이름만 출력하고 멈춘다. 페이지 폴트의 경우 어떤 주소에서 났는지는 CR2 레지스터에, 접근 종류는 에러코드에 담겨 있어 이를 함께 출력하면 더 정확한 정보를 남길 수 있다. 그다음에는 페이지 폴트를 정지가 아니라 복구로 처리하는 방향, 즉 요청된 주소에 페이지를 매핑해 주는 paging 관리로 이어진다.