마우스를 인터럽트로
2025년 3월 30일
ref #
JHS의 IO 가상화
중국의 PCIe 상세 정리
APIC 정리
Interrupt #
이전에 구현한 마우스는 polling 방식으로 동작하여 CPU가 xHCI가 작성한 TRB를 계속 확인하는데 그렇게되면 마우스가 움직이지 않았을때도 동작하는 문제가 생긴다.
인터럽트라는건 다른 하드웨어나 소프트웨어가 CPU에게 하던 작업을 멈추고 어떤 코드(인터럽트 핸들러)를 실행시켜줘 라고 CPU의 동작을 강제하는것이다.
이렇게되면 데이터가 준비됐을때만 메모리를 확인할 수 있어서 쓸모없는 CPU 낭비가 줄어들 수 있다.
인터럽트 준비 #
- 인터럽트 핸들러 작성 (ISR: Interrupt Service Routine)
- 인터럽트 핸들러를 인터럽트 벡터/테이블에 등록한다
- x86: IDT(Interrupt Descriptor Table)에서 256개의 벡터를 사용
- arm: 고정된 주소를 인터럽트 벡터 테이블로 사용하고, IRQ, FIQ 모드로 나눠진다.
하드웨어 인터럽트 #
하드웨어 인터럽트는 키보드나 마우스같은 HID 입력, 타이머, 네트워크 카드(패킷 도착시), USB나 디스크의 읽기/쓰기 완료 시 비동기적으로 인터럽트가 발생한다.
장치에서 발생한 인터럽트가 PIC를 거쳐 하드와이어된 CPU의 인터럽트 선에 도착하고 CPU는 현재 명령어를 완료한 후(일반적으로는) 인터럽트를 처리한다.
PCI(2.1 이하) 장치 #
과거 PCI 장치의 경우 실제 물리적인 선으로 PIC(Programmable Interrupt Controller)를 통해 CPU의 INTx 핀에 전압을 입력하는 방식으로 인터럽트 요청을 보낸다.
IRQ(Interrupt Request) 핀 출력 → PIC → CPU의 INT핀에 입력
이 방식은 단 하나의 CPU(core 0번)에만 인터럽트가 전달되어 핸들러를 실행할 수 있고 나머지 core 는 하던 작업을 마저 진행하는 방식이기 때문에 core 0번에만 인터럽트가 걸려 부하가 가중되고, 여러 장치에서 동시에 인터럽트가 발생하면 병목현상이 발생한다.
PCI 2.2 이상(선택)과 PCIe 장치 #
MSI(Message Signaled Interrupts) 방식으로 변경됐는데 멀티 코어에서 병렬로 인터럽트를 처리할 수 있고 인터럽트를 위한 핀이 사라져서 장치의 회로 배선이 간단해졌다.
OS가 부팅될때 PCI(e) 장치를 스캔하며
PCI Config Space 0x34 오프셋에 있는 capability 링크드 리스트를 확인한다. MSI capability (id:0x5) 가 발견되면 장치가 MSI를 지원하는 것으로 판단해서 고유한 인터럽트 벡터 번호를 부여한다.
그리고 MSI Capability에 MSI Address와 MSI Data를 적어줘서 어떤 위치에 어떤 데이터(인터럽트 벡터 번호, 인터럽트 전달방식)를 포함시켜야 하는지 알려주고 MSI Capability 17번째 비트(enable)를 1로 세팅해 MSI 기능을 활성화 한다.
장치에서 인터럽트가 발생하면 OS가 MMIO를 통해 지정해준 MSI Address에 MSI Data를 써서 LAPIC 컨트롤러가 인터럽트가 발생했음을 알 수 있게 한다.
LAPIC는 코어마다 하나씩 가지고 있으며, MSI Address는 0xfee00000 주소를 베이스로 하고 Destination APIC ID 는 코어마다 달라져서 주소에 따라 어떤 코어로 인터럽트를 보낼지 알 수 있다.
여기에서 혼동하지 말아야 하는것은 장치입장에서 원하는 코어(LAPIC)를 선택하기 위해 Destination APIC ID를 MSI Address 에 합쳐서 넣는 것이지, CPU 입장에서는 모든 코어가 0xfee00000 ~ 0xfee00fff 에 LAPIC의 레지스터가 매핑되어 있다.
LAPIC는 감시하고 있는 MSI Address 주소에서 인터럽트가 발생하면 MSI Data 값을 꺼내온다.
그 값에서 벡터 번호를 추출한 뒤 CPU core의 제어유닛에게 내부적으로 신호를 보내 인터럽트가 발생했고 어떤 ISR(Interrupt Service Routine)을 실행시켜야 하는지 알려준다.
CPU Exception #
Devide by zero, Invalid opcode, Page fault, Protection fault 등 CPU가 연산 중 CPU 내부(연산장치, 디코더, MMU 등)에서 예외를 발생시키는 경우에도 하드웨어 인터럽트로 볼 수 있다.
프로세스의 명령을 CPU 내부에서 실행하던 중 발생하는 인터럽트이기 때문에 PIC 같은 장치를 통과할 필요가 없어서 발생 타이밍이나 처리 방식은 소프트웨어 인터럽트에 더 가깝지만 엄연한 하드웨어 인터럽트이다.
기타 인터럽트 #
PCI 장치가 아니더라도 내부에 있는 장치들(SATA, 타이머 등)이 인터럽트를 거는 경우도 있는데, 그 경우 역시 내부 장치들이 PCIe 처럼 구현되어 있어서 MSI를 사용할 수 있거나 IRQ로 직접 쏘는 경우도 있다.
PCIe가 메인이 되는 시스템에서도 하위 호환을 위해 일부 기기는 IRQ 핀을 가지고 있고, 그걸 IO APIC가 MSI 방식으로 변경해주는 역할을 한다.
MSI 이후에 MSI-X(PCI 3.0이상, PCIe) 라는 인터럽트 방식도 생겨났는데, 전달 방식은 동일하게 메모리 입출력을 통한 메시지 전달방식이고 그냥 확장되어 더 많은 인터럽트를 더 유연하게 처리한다.
소프트웨어 인터럽트 #
현재 실행중인 프로세스의 의도적인 특정 명령어 실행으로 인해 동기적으로 발생하는 인터럽트이다.
주로 시스템 콜, 디버깅 등 에서 사용된다.
시스템 콜 #
사용자 프로그램은 제한된 권한(user mode)을 가지고있는데, 시스템 콜 명령을 통해 커널에게 하드웨어 접근이나 시스템 접근 등 커널권한이 필요한 작업을 요청할 수 있다.
open(), read(), NtCreateFile(), NtReadFile() 등 OS의 API에서 하드웨어 접근이 필요하다면 내부적으로 시스템 콜을 호출해서 인터럽트를 이용해 처리한다.
int 0x80 으로 시스템 콜 인터럽트로 전환했지만, 요즘은 x86-64 CPU에서 지원하는 syscall 명령을 사용해서 IDT를 거치지 않고 CPU가 바로 커널 모드로 전환하기 때문에 더 빠르고 효율적으로 작동한다.
디버깅 #
디버깅을 위한 소프트웨어 인터럽트는 프로그램의 실행 흐름을 중단하고 디버거에게 제어권을 넘기기 위해 디버기가 실행시키는 명령으로 발생한다.
-
디버거가 ptrace로 디버기에 attach 하면 운영체제가 디버거에게 디버기 프로세스를 조작(메모리, 레지스터 등)할 수 있는 권한을 준다. 이때 디버기는 SIGSTOP 이 발생해서 일시중지가 된다.
ptrace(PTRACE_PEEKDATA, ...),ptrace(PTRACE_SINGLESTEP)같은 ptrace API 호출을 통해 디버기를 조작할 수 있게 된다. -
breakpoint 를 걸면 선택한 주소에 있던 원본 명령을 저장해두고
int 3이라는 명령을 삽입하게 된다. -
디버기 프로세스가
int 3명령을 실행하면 소프트웨어 인터럽트가 발생해서 인터럽트 벡터 3번의 핸들러가 실행되며 트랩이 발생하고, 커널이 감지해서 SIGTRAP을 디버기에게 보낸다. -
디버거가 붙어있는 상태라면 커널이 디버기는 SUSPEND 상태로 전환시키고 waitpid로 대기하고 있던 디버거에게 SIGTRAP을 전달하게 되면서 디버거에게 제거권이 넘어가게 된다.
-
디버기는 SUSPEND 상태이고 디버거의 waitpid가 반환된 상태니까 디버기를 마음대로 컨트롤할 수 있다. (singlestep 과정이라면 디버기 레지스터를 읽어서 업데이트함)
-
디버거가 continue 명령을 실행시키면 디버거는 다시 waitpid로 대기하게 되고 SUSPEND 상태였던 디버기가 실행되다가 다음 int 3 에서 위의 작업을 반복하게 되는 것이다.
int 3 은 자주 사용하기 때문에 빠른 실행을 위해 0xCC 1byte 짜리로 설계되어 있다.
인터럽트 처리 과정 #
인터럽트 발생 시 처리 방법 #
-
하드웨어, 소프트웨어 인터럽트 발생
소프트웨어 인터럽트나 CPU 인터럽트의 경우 특정 명령에서 정확히 발생하기 때문에 문제가 없지만 외부 장치의 하드웨어 인터럽트인 경우 구현에 따라 다르지만 일반적으로는 파이프라인에서 현재 진행중인 명령어를 마저 완료하고 인터럽트를 수용한다. -
CPU가 인터럽트 벡터를 조회한다.
IDTR 레지스터에 IDT의 베이스 주소가 있고,벡터번호 * 디스크립터 크기로 게이트 디스크립터를 찾아온다. 디스크립터에는 핸들러 함수 주소나 게이트 종류(트랩, 인터럽트) 등에 대한 정보가 담겨있다.- CPU 예외: 발생한 예외 타입에 따라 벡터 번호가 고정되어 있고 IDT에서 오프셋 계산 후 찾아올 수 있다.
- 소프트웨어 인터럽트: 실행한 인터럽트 명령어에 벡터번호가 포함되어 IDT에서 오프셋 계산 후 찾아올 수 있게된다.
- PIC 구형 하드웨어 인터럽트: 장치에서 인터럽트라인 신호(IRQ)를 PIC가 받으면 CPU에게 INT 핀으로 인터럽트가 발생함을 알려주고 IRQ 번호에 맞는 벡터 번호를 CPU에 같이 전달한다.
- MSI: 인터럽트가 발생됐을 때 장치레지스터에 미리 초기화된 MSI Address 위치에 MSI Data 값(벡터 번호)을 쓰게되면 CPU Local APIC 를 통해 CPU 에게 전달된다.
- MSI 에뮬레이션 방식: 장치는 PIC이기 때문에 IRQ를 보내게 되고, IO APIC가 그걸 받아서 MSI 방식처럼 변환해 LAPIC에 전달해준다.
-
조회한 디스크립터의 내용에 따라 ring 전환이 필요한지 확인하고 인터럽트 플래그를 클리어시켜 인터럽트 중첩을 방지한다.
-
ring 전환이 필요하다면 먼저 스택을 전환시키고, 아니라면 현재 스택에서 CPU 컨텍스트(RIP, CS, RFLAGS +@)를 스택에 푸시한다.
ring3(유저모드) → ring0(커널모드) 전환 시에는 스택도 커널스택으로 전환해야 하기 때문에 SS, RSP 를 추가로 푸시한다. -
특권레벨 전환 후 IDT에 명시된 CS:RIP (ISR 주소) 로 레지스터를 업데이트하고 점프하여 핸들러를 실행시킨다.
-
IRET 명령으로 원래 컨텍스트를 복원해서 중단됐던 프로세스 코드 부터 계속 실행시킨다.
인터럽트 처리의 오버헤드? #
이제 마우스를 인터럽트 방식으로 변환할 것이다. 1~10ms마다 발생하는 마우스 인터럽트를 처리하기 때문에 오버헤드가 클 것이라고 생각할 수 있다.
인터럽트 과정 중 IDT를 조회하고 컨텍스트를 푸시하고 링 전환을 하는건 사실 INT N 명령어 단 한개의 실행인 것이고 CPU의 내부적 하드웨어적으로 동작하기 때문에 아주 빠르게 처리할 수 있다.
INT N 명령은 비용이 높고 분기예측이나 캐시미스일때 더 비싸지는 명령어지만, 100~300 사이클에 핸들러 함수의 비용 정도로 처리가 가능하다
단순계산 #
내 CPU(ryzen 5700x) 기준으로 클럭이 4.0~4.6GHz 정도이고 8코어이기 때문에 초당 32GHz를 처리할 수 있게되며 총 320억 명령어 사이클을 실행할 수 있게된다.
(사실 정확히 따지면 실행중인 프로세스 수나 코어 컨텍스트 스위칭에 대한 캐시미스 등의 오버헤드는 있다)
마우스의 폴링레이트(마우스가 감당할 수 있는 컨트롤러의 폴링주기 스펙)는 게이밍 마우스 기준으로 보통 1000Hz 정도이고, 마우스 ISR은 큰 작업을 하지 않기 때문에 인터럽트 처리는 400~1000 사이클 정도 사용된다.
단순하게 계산해서 게이밍 마우스의 처리는 최대 초당 1000*1000 사이클이 필요하고, 이것에 비해 CPU의 코어 하나가 처리할 수 있는 40억 정도의 사이클은 아주 높기 때문에 오버헤드를 신경쓰지 않아도된다.
마우스를 인터럽트 방식으로 #
인터럽트 핸들러 #
attribute 지시어로 c++ 함수가 아니라 interrupt를 구현한 것이라고 컴파일러에게 알려주는 역할을 한다.
1__attribute__((interrupt))
2void IntHandlerXHCI(InterruptFrame* frame) {
3 while (xhc->PrimaryEventRing()->HasFront()) {
4 if (auto err = ProcessEvent(*xhc)) {
5 Log(kError, "Error while ProcessEvent: %s at %s:%d\n",
6 err.Name(), err.File(), err.Line());
7 }
8 }
9 NotifyEndOfInterrupt();
10}
attribute((interrupt)) #
clang 문서에서 볼 수 있듯 interrupt attribute를 사용한 경우 X86 ISR은 CPU가 직접 점프해서 들어오기 때문에 함수 내에서 caller-saved 레지스터가 손상되면 복구할 수 없게된다. 그래서 사용하는 caller saved 레지스터는 전부 프롤로그에서 push 해두고, 에필로그에서 복원한다.
핸들러에 진입한 순간 레지스터 상태를 핸들러가 iret 까지 보존해야 하는 의무가 있다.
attribute((no_caller_saved_registers)) #
이렇게 많은 레지스터를 callee-saved 하면 인터럽트를 호출할때마다 느려질 수 있기 때문에 컴파일러에서 여러 attribute를 지원한다.
해당 함수가 caller-saved 레지스터를 망가트리지 않는다는 의미를 전달하는 no_caller_saved_registers를 달아두면 이 함수만 호출하는 함수(인터럽트 핸들러)는 이 키워드를 보고 caller-saved 레지스터를 굳이 저장하지 않아도 된다는 것을 알 수 있다.
이 attribute 가 달린 함수 안에서는 caller-saved를 사용하게 되면 callee에서 자체적으로 백업할 것이다 라는 의미이다.
그래서 이 attribute는 해당 함수 외에도 그 함수가 내부적으로 호출하는 모든 함수도 지켜야 하고, 만약 내부 함수에서 저장하지 않았던 caller-saved 레지스터를 조작하는 경우 ISR의 레지스터 상태 저장 의무가 지켜지지 않아서 런타임 에러가 발생하기도 한다.
iretq (64bit 인터럽트복귀) #
그리고 인터럽트핸들러 호출 전에 CPU가 컨텍스트(RIP, CS, RFLAGS…)를 저장하기 때문에 리턴할때도 iretq 명령으로 리턴해서 컨텍스트 복원까지 진행하도록 컴파일된다.
일반 ret는 pop rip; 명령만 수행한다.
코드에서 LAPIC 접근 #
LAPIC의 레지스터는 0xfee00000 부터 시작하는데, 0xb0 오프셋의 레지스터는 EOI로 인터럽트가 끝난 것을 LAPIC에게 알리기 위해 쓰게된다. 위에서 설명했듯 CPU에서는 0xfee00000 ~ 0xfee00fff 범위에서 자신의 LAPIC 레지스터를 접근할 수 있다.
핸들러 함수 맨 아래에선 NotifyEndOfInterrupt() 함수를 호출하고 0xfee000b0 에 데이터를 쓰면서 LAPIC에게 인터럽트의 처리를 알린다.
1void NotifyEndOfInterrupt() {
2 // volatile은 변수가 최적화 대상이 되지 않게 하기 위해 사용한다.
3 volatile auto end_of_interrupt = reinterpret_cast<uint32_t*>(0xfee000b0);
4 *end_of_interrupt = 0;
5}
인터럽트 벡터 #
인터럽트 벡터는 인터럽트 핸들러와 매핑하기 위한 구조체이다.
0 ~ 31 번은 CPU 내부 처리용으로 CPU 아키텍쳐 수준에서 고정되어 있고 나머지 인터럽트는 펌웨어나 커널에서 32 ~ 255 번에 매핑할 수 있다.
1enum class DescriptorType {
2 kUpper8Bytes = 0,
3 kLDT = 2,
4 kTSSAvailable = 9,
5 kTSSBusy = 11,
6 kCallGate = 12,
7 kInterruptGate = 14,
8 kTrapGate = 15,
9};
10
11union InterruptDescriptorAttribute {
12 uint16_t data;
13 struct {
14 uint16_t interrupt_stack_table : 3;
15 uint16_t : 5;
16 DescriptorType type : 4;
17 uint16_t : 1;
18 uint16_t descriptor_privilege_level : 2;
19 uint16_t present : 1;
20 } __attribute__((packed)) bits;
21} __attribute__((packed));
22
23struct InterruptDescriptor {
24 uint16_t offset_low;
25 uint16_t segment_selector;
26 InterruptDescriptorAttribute attr;
27 uint16_t offset_middle;
28 uint32_t offset_high;
29 uint32_t reserved;
30} __attribute__((packed));
31
32extern std::array<InterruptDescriptor, 256> idt;
idt는 전역 array로 256개 고정되어 있고, 디스크립터를 보면 아래와 같은 구조로 되어있다.
idt[n].attr.bits.type 에 접근해 어떤 타입의 게이트 디스크립터인지 알 수 있고(아래 예시는 Interrupt/Trap Gate), attr의 descriptor_privilege_level 로는 인터럽트 핸들러의 실행 권한을 지정하게 된다.
과거 x86 16bit 시절에는 세그먼트를 사용해서 CS:DI 같이 세그먼트에 오프셋을 사용해 물리주소를 얻었지만 페이지 테이블이 생기면서 주소지정으로는 사용하지 않게됐다.
인터럽트가 발생했을때 현재 CS가 ring 3의 세그먼트 값이였다면 IDT 엔트리의 segment_selector 에서 가져온 ring 0의 코드세그먼트 값으로 CS를 업데이트하며 자동으로 커널스택 전환 후 ISR에 진입하게된다.
오프셋은 16bit, 32bit, 64bit 로 업데이트 되면서 하위호환 때문에 필드가 나뉘어 있는데, 세 필드를 합쳐서 ISR 주소를 찾을 수 있다.
인터럽트 벡터 등록 #
SetIDTEntry 함수로 idt는 전역 array로 선언해뒀는데, 원하는 인터럽트 디스크립터에 핸들러를 세팅한다. GetCS로는 현재 코드세그먼트를 가져오는데, 커널이기 때문에 커널의 코드세그먼트가 저장될 것이다.
1global GetCS ; uint16_t GetCS(void);
2GetCS:
3 xor eax, eax
4 mov ax, cs
5 ret
6
7void SetIDTEntry(InterruptDescriptor& desc,
8 InterruptDescriptorAttribute attr,
9 uint64_t offset,
10 uint16_t segment_selector) {
11 desc.attr = attr;
12 // 오프셋을 나눠서 등록한다.
13 desc.offset_low = offset & 0xffffu;
14 desc.offset_middle = (offset >> 16) & 0xffffu;
15 desc.offset_high = offset >> 32;
16 desc.segment_selector = segment_selector;
17}
18
19enum InterruptVector {
20 kXHCI = 0x40,
21};
22
23extern "C" void KernelMain(...) {
24 const uint16_t cs = GetCS();
25 // idt[0x40] 위치에 IntHandlerXHCI를 등록한다. 특권레벨(DPL)은 일단 0으로 세팅한다.
26 SetIDTEntry(idt[InterruptVector::kXHCI], MakeIDTAttr(DescriptorType::kInterruptGate, 0),
27 reinterpret_cast<uint64_t>(IntHandlerXHCI), cs);
28 LoadIDT(sizeof(idt) - 1, reinterpret_cast<uintptr_t>(&idt[0]));
29}
lidt를 통해 CPU가 사용할 IDT를 지정하는데, limit은 IDT의 마지막 인덱스(size-1)이고 offset은 IDT의 주소이다.
1global LoadIDT ; void LoadIDT(uint16_t limit, uint64_t offset);
2LoadIDT:
3 push rbp
4 mov rbp, rsp
5 sub rsp, 10
6 mov [rsp], di
7 mov [rsp + 2], rsi
8 lidt [rsp] // 이 명령은 CPU에게 IDT가 어디에 있는지 알려주는 명령이다.
9 mov rsp, rbp
10 pop rbp
11 ret
MSI 인터럽트 발생 설정 #
과거 PCI 규격에서는 INT#A~D 까지 4개의 인터럽트 신호선밖에 없었기 때문에 여러 IRQ가 인터럽트 선을 공유할수밖에 없었다.
MSI 방식은 LAPIC의 레지스터(MSI Address)에 데이터(MSI Data)를 직접 써넣어 인터럽트를 트리거한다. 요청 주소가 MMIO로 매핑되어 있기 때문에 PCI버스를 통해 Root Complex가 직접 LAPIC로 전달한다.
인터럽트 처리 후 EOI(EndOfInterrupt)에 데이터를 쓰면 인터럽트 처리가 완료됨을 알릴 수 있다.
장치에서 인터럽트를 발생할 때 어디로 보내야할지 알려줘야하기 때문에 인터럽트를 등록하는 코어의 LAPIC ID를 찾아서 전달해준다. ID는 0x20 오프셋에서 찾아올 수 있다.
0xfee01020 에 접근한다고 1번 LAPIC가 아니다 로컬에서 접근은 무조건 0xfee00020
멀티코어 CPU에서 처음 부트스트랩을 켰을땐 하나의 코어(BSP 코어라고 부름)만 동작한다.
1namespace pci {
2 Error ConfigureMSIFixedDestination(
3 const Device& dev, uint8_t apic_id,
4 MSITriggerMode trigger_mode, MSIDeliveryMode delivery_mode,
5 uint8_t vector, unsigned int num_vector_exponent) {
6 // MSI Address 주소값 계산. 0xfee00000 + LAPIC ID
7 uint32_t msg_addr = 0xfee00000u | (apic_id << 12);
8 // MSI Data 값 계산. 모드랑 인터럽트 벡터 세팅
9 uint32_t msg_data = (static_cast<uint32_t>(delivery_mode) << 8) | vector;
10 if (trigger_mode == MSITriggerMode::kLevel) {
11 msg_data |= 0xc000;
12 }
13 // PCI Config Space의 0x34 오프셋에 있는 Capability List에서 MSI Capability를 가져온 후
14 // MSI Address, MSI Data 위치에 값을 써서 장치의 Register에 알려준다.
15 return ConfigureMSI(dev, msg_addr, msg_data, num_vector_exponent);
16 }
17}
18
19extern "C" void KernelMain(...) {
20 const uint8_t bsp_local_apic_id =
21 *reinterpret_cast<const uint32_t*>(0xfee00020) >> 24;
22 pci::ConfigureMSIFixedDestination(
23 *xhc_dev, bsp_local_apic_id,
24 // 레벨방식 인터럽트 사용. 디바이스가 deassert를 보내야만 처리된 것으로 판단하는 방식
25 pci::MSITriggerMode::kLevel, pci::MSIDeliveryMode::kFixed,
26 InterruptVector::kXHCI, 0);
27
28 // CPU의 EFLAGS 레지스터에서 Interrupt Flag(IF)를 1로 설정
29 // = 외부 하드웨어의 인터럽트를 수신하도록 설정
30 __asm__("sti");
31}
ConfigureMSI는 MSI Capability를 설정하는 함수이다.
xHCI PCI 장치의 PCI Config Space의 0x34 오프셋을 확인하여 Capability 리스트에서 Capability ID를 검색한 후 MSI Capability가 검색된다면 장치에서 MSI 방식의 인터럽트를 지원하는 것이 된다.
MSI(X) Capabiltiy 의 주소를 획득 후 MSI Address, MSI Data, msi enable bit를 쓴다.
1 const uint8_t kCapabilityMSI = 0x05;
2 const uint8_t kCapabilityMSIX = 0x11;
3
4 Error ConfigureMSI(const Device& dev, uint32_t msg_addr, uint32_t msg_data,
5 unsigned int num_vector_exponent) {
6 uint8_t cap_addr = ReadConfReg(dev, 0x34) & 0xffu;
7 uint8_t msi_cap_addr = 0, msix_cap_addr = 0;
8 while (cap_addr != 0) {
9 auto header = ReadCapabilityHeader(dev, cap_addr);
10 if (header.bits.cap_id == kCapabilityMSI) {
11 msi_cap_addr = cap_addr;
12 } else if (header.bits.cap_id == kCapabilityMSIX) {
13 msix_cap_addr = cap_addr;
14 }
15 cap_addr = header.bits.next_ptr;
16 }
17
18 if (msi_cap_addr) {
19 return ConfigureMSIRegister(dev, msi_cap_addr, msg_addr, msg_data, num_vector_exponent);
20 } else if (msix_cap_addr) {
21 return ConfigureMSIXRegister(dev, msix_cap_addr, msg_addr, msg_data, num_vector_exponent);
22 }
23 return MAKE_ERROR(Error::kNoPCIMSI);
24 }
인터럽트 기능 업그레이드 #
인터럽트 처리 중 새로운 인터럽트가 들어온 경우 인터럽트가 유실될 수도 있기 때문에 인터럽트 핸들러에서는 인터럽트를 받아서 큐에 넣고, 인터럽트의 처리는 따로 수행할 수 있도록 한다.
큐를 사용해야 하는데, std::queue는 동적메모리 할당이 필요하기 때문에 배열을 큐처럼 동작하도록 만든 ArrayQueue 를 구현했다.
인터럽트 핸들러 #
전역 공간에서 접근 가능한 큐인 main_queue를 선언하고 여기에 인터럽트 메시지를 담는다.
어차피 인터럽트가 왔는지만 확인하고 처리할때 이벤트링에서 꺼내오기 때문에 메시지는 내용이 없어도 된다.
1struct Message {
2 enum Type {
3 kInterruptXHCI,
4 } type;
5};
6
7ArrayQueue<Message>* main_queue;
8
9__attribute__((interrupt))
10void IntHandlerXHCI(InterruptFrame* frame) {
11 main_queue->Push(Message{Message::kInterruptXHCI});
12 NotifyEndOfInterrupt();
13}
14
15extern "C" void KernelMain(...) {
16 // 배열을 사용해서 ArrayQueue 초기화
17 std::array<Message, 32> main_queue_data;
18 ArrayQueue<Message> main_queue{main_queue_data};
19 ::main_queue = &main_queue;
20
21 // 위에서 했던 인터럽트 벡터와 핸들러를 등록하는 코드
22 // IDT에 벡터-핸들러 쌍을 등록한다.
23 const uint16_t cs = GetCS();
24 SetIDTEntry(idt[InterruptVector::kXHCI], MakeIDTAttr(DescriptorType::kInterruptGate, 0),
25 reinterpret_cast<uint64_t>(IntHandlerXHCI), cs);
26 LoadIDT(sizeof(idt) - 1, reinterpret_cast<uintptr_t>(&idt[0]));
27
28 // MSI Address, MSI Data 등록 코드
29 uint8_t bsp_local_apic_id =
30 *reinterpret_cast<const uint32_t*>(0xfee00020) >> 24;
31 pci::ConfigureMSIFixedDestination(
32 *xhc_dev, bsp_local_apic_id,
33 pci::MSITriggerMode::kLevel, pci::MSIDeliveryMode::kFixed,
34 InterruptVector::kXHCI, 0);
35}
이벤트루프 #
이벤트 루프를 돌면서 인터럽트 메시지를 하나씩 꺼내 처리한다.
1extern "C" void KernelMain(...) {
2 // event loop
3 while (true) {
4 // IF 플래그를 꺼놔야 한다. (크리티컬 섹션 시작)
5 __asm__("cli");
6 if (main_queue.Count() == 0) {
7 // 인터럽트가 아직 없다면 인터럽트를 받도록 세팅하고 절전모드로 들어간다.
8 // 인터럽트가 들어오면 절전모드에서 깨어난다.
9 // sti 는 명령어를 하나 더 실행한 이후에 인터럽트를 받게 구현되어 있다.
10 __asm__("sti\n\thlt"); // \n\t 로 여러 명령어를 넣을 수 있다.
11 continue;
12 }
13 // 메시지를 하나 꺼내고 다시 인터럽트를 받을 수 있게 한다.
14 // 메시지가 처리되는 동안 인터럽트는 계속해서 들어올것이다.
15 Message msg = main_queue.Front();
16 main_queue.Pop();
17 __asm__("sti");
18
19 switch (msg.type) {
20 case Message::kInterruptXHCI:
21 // 메시지 타입이 kInterruptXHCI 인 경우 이벤트링에서 처리
22 while (xhc.PrimaryEventRing()->HasFront()) {
23 if (auto err = ProcessEvent(xhc)) {
24 Log(kError, "Error while ProcessEvent: %s at %s:%d\n",
25 err.Name(), err.File(), err.Line());
26 }
27 }
28 break;
29 default:
30 Log(kError, "Unknown message type: %d\n", msg.type);
31 }
32 }
33}
cli와 sti 사이 #
메시지를 꺼낼때 까지 cli 로 인터럽트를 막아두는 이유는 메시지를 꺼내면서 count나 큐의 read_pos가 조작되는데,
어셈블리 관점에서는 여러단계로 나뉘어있기 때문에 레이스컨디션이 발생할 수 있다.
이미 count를 레지스터로 옮겨놓고 계산하는 도중에 인터럽트가 발생하면 핸들러가 먼저 처리돼서 count가 증가하지만,
돌아오면 eax에는 과거의 count가 저장되어 있기 때문에 큐의 구조가 깨질 수 있다.
1mov eax, count
2add eax, 1
3mov count, eax
정리하면 인터럽트 핸들러는 원래 프로그램의 동작 중간에 언제든 끼어들 수 있고, 같은 자원을 두 곳에서 접근(읽기, 쓰기) 하기 때문에 멀티스레드처럼 레이스컨디션이 발생할 수 있게 되어 원래 프로그램에서 접근할땐 인터럽트가 발생하지 않도록 크리티컬섹션으로 지정하는 것이다.
UEFI 펌웨어 부팅이후 UEFIMain 이전에 인터럽트를 받을 수 있도록 구현된 기기도 있지만, 그렇지 않은 기기도 있기 때문에 sti는 명시적으로 호출하는게 좋다.
sti는 어셈 명령어를 하나 더 실행하고 인터럽트를 받도록 구현되어 있다.
그렇기 때문에 sti → hlt → 인터럽트 발생 → 저전력 모드에서 깸 이 순서가 무조건 보장된다.
루프 진행중에 cli → main_queue.Pop() → sti → while ( HasFront() ) 순서로 처리하는데
sti 이후에 인터럽트가 발생하면 큐에는 남아있고 이벤트링에 들어온 인터럽트는 while문에 의해 처리되는 것을 알 수 있다.
인터럽트 큐와 실제 인터럽트 요청이 불균형이 발생하지만, 어차피 메시지를 다시 꺼내서 while 문에서 false 가 리턴될 것이기 때문에 상관없다.