CPU에서의 예외처리 방법과 RISC-V CPU의 인터럽트에 대해 알아보겠습니다.

CPU의 예외처리

CPU는 정상적으로 명령어를 실행하다가도, 즉시 대응해야하는 예외적 상황이 발생합니다. 주로 세 가지가 있습니다.

  • 첫째, 명령어 자체가 실패하여 완료될 수 없는 경우입니다. 예를 들어 0으로 나누기(divide by zero)나, 접근 권한이 없는 메모리에 접근하려는 경우가 이에 해당합니다.
  • 둘째, 외부 I/O 장치가 CPU 서비스를 요청하는 경우입니다. 키보드 입력이 들어오거나, 디스크 읽기가 완료된 상황 등이 이에 해당합니다.
  • 셋째, 타임 셰어링 시스템에서 할당된 시간(quantum)이 만료된 경우입니다. 운영체제가 다른 프로세스에 CPU를 넘겨야할 때 이런 상황이 발생합니다.

대표적으로, 각각의 파이프라인 단계에서 아래와 같은 예외가 일어날 수 있습니다.

  • IF: 인스트럭션 메모리가 유효하지 않거나 보호된 주소에 접근하는 경우
  • ID: Opcode가 잘못된 경우, 의도된 System call이나 Trap
  • EX: 0으로 나누기, 오버플로우(몇몇 아키텍처에서)
  • MEM: 유효하지 않거나 보호된 메모리 주소에 접근하는 경우

이런 상황을 처리하는 방법으로 아래와 같은 방법이 있습니다.

폴링

주기적으로 이벤트가 발생했는지 CPU가 직접 확인하는 방식입니다. 하지만, 이벤트가 있든 없든 매번 확인해야 하기 때문에 오버헤드가 상당할 수 있다는 치명적인 단점이 있습니다. 따라서 간단한 임베디드 시스템 등 제한적인 환경에서만 사용됩니다.

인터럽트

인터럽트는 CPU가 이벤트가 발생했을 때 하던 작업을 중단하고 다른 작업을 먼저 처리해달라는 알람을 받는 방식입니다. 인터럽트는 예외적 조건(exceptional condition) 이 발생했을 때 생성되며, CPU는 인터럽트가 발생하면 예외 핸들러(exception handler)에게 제어권을 넘깁니다. 인터럽트 처리가 끝나면 원래 프로그램으로 되돌아옵니다.

인터럽트 제어 이전

일반적인 프로그램에서, CPU의 "제어권"은 실행되고 있는 프로그램이 가지고 있습니다. 그런데 인터럽트가 발생하면, CPU는 하던 일을 멈추고 인터럽트 핸들러라는 별도의 루틴을 실행하러 갑니다. 즉 제어권이 원래 프로그램에서 인터럽트 핸들러로 넘어가는 것이죠. 이 과정을 인터럽트 제어 이전(Interrupt Control Transfer) 라고 부릅니다.

인터럽트는 "계획되지 않은" 함수 호출입니다. 일반적인 함수 호출은 프로그래머가 코드에 직접 call 명령어를 넣어서 의도적으로 실행하지만, 인터럽트는 그러한 예고 없이 발생합니다.

이 과정에서 인터럽트된 스레드는 제어 이전을 예측하거나 미리 대비할 수 없습니다. 일반 함수 호출에서는 호출 전에 인자를 준비하고 레지스터를 정리하지만, 인터럽트는 명령어를 아무 시점에나 걸리므로 프로그램 입장에서는 인터럽트 실행을 준비할 수 없습니다.

따라서 인터럽트가 실행되는 과정은 완전히 투명(transparent) 해야합니다. 인터럽트 핸들러가 실행되고 다시 원래 프로그램 흐름으로 돌아왔을 때, 프로그램은 인터럽트가 발생했다는 사실을 전혀 알아챌 수 없어야 합니다. 이를 위해 인터럽트 핸들러 진입 시 핸들러(혹은 CPU)는 레지스터 상태, 프로그램 카운터, 플래그 등을 모두 저장하고, 복귀 시 완벽하게 복원해야합니다.

동기적/비동기적 인터럽트

동기적 인터럽트(Synchronous Interrupts) 는 CPU가 명령어를 실행하는 과정에서 명령어 그 자체로 인해 발생하는 인터럽트입니다. 예를 들어 페이지 폴트(Page Fault), 0으로 나누기, 잘못된 메모리 접근 같은 것들입니다. 이런 것들은 해당 명령어를 실행할 때마다 항상 같은 지점에서 발생하기 때문에 재현이 가능하고, 같은 프로그램을 같은 조건에서 실행하면 같은 지점에서 다시 발생합니다. 이것들은 보통 예외(exception) 라고도 부릅니다.

비동기적 인터럽트(Asynchronous Interrupts) 는 CPU 외부에서 오는 신호로, 명령어 실행과는 무관하게 아무 때나 발생합니다. 키보드 입력, 디스크 IO 완료, 타이머 만료 같은 것들이 이에 해당합니다.

정밀 인터럽트

인터럽트를 실제 CPU에서 구현하려면, 앞서 설명한 "투명성"을 하드웨어가 보장해야 합니다. 현재 실행 상태를 정확히 저장하고, 핸들러 실행 후 완벽하게 복귀하는 과정이 필요한데, 이는 명령어를 하나씩 실행하는 CPU에서는 비교적 쉽지만, 파이프라인을 사용해 여러 명령어를 동시에 처리하는 구조에서는 훨씬 까다로운 문제가 됩니다.

파이프라인 CPU에서는 어떤 시점에 여러 명령어가 서로 다른 실행 단계에 걸쳐 동시에 진행되고 있습니다. 이 상황에서 인터럽트가 발생하면, 반쯤 실행된 명령어들을 어떻게 처리해야할까요? 이를 해결하는 방식이 정밀 인터럽트(precise interrupt) 입니다.

정밀 인터럽트란, 인터럽트 핸들러가 봤을 때 인터럽트가 정확히 두 명령어 사이에서 발생한 것처럼 만드는 방식입니다. 인터럽트가 발생하면 특정 명령어를 기준으로, 그보다 앞선(older) 명령어는 전부 완료시키고, 그보다 뒤의(younger) 명령어는 전부 취소해서 아예 시작하지 않은 것처럼 만듭니다.

동기적 인터럽트의 경우에는 인터럽트를 일으킨 명령어 바로 직전에서 멈춘 것처럼 처리하며, 핸들러가 실행된 뒤 복귀하면 그 명령어부터 다시 실행합니다.

가상화와 보호

인터럽트의 실제 구현을 알아보기 전에 먼저 알아야할 개념이 있습니다. 바로 CPU의 권한 수준(Privilege Level) 입니다. 컴퓨터에서는 수십, 수백 개의 프로세스가 동시에 실행됩니다. 하지만 CPU는 물리적으로 한정되어 있고, 메모리와 디스크를 많은 프로세스에서 공유해서 사용합니다.

하지만 만약 모든 프로그램이 하드웨어에 직접 접근할 수 있다면 어떤 일이 일어날까요? 어떤 프로그램이 실수로, 혹은 고의로 다른 프로그램의 메모리를 덮어쓸 수 있고, 악의적인 프로그램이 디스크의 다른 사용자 파일을 읽거나 삭제해버릴 수도 있습니다. 한 프로세스의 버그가 시스템 전체를 망가뜨릴 수도 있죠.

그래서 운영체제가 각 프로그램에 가상화(Virtualization)를 제공합니다. 각 프로세스가 각자의 CPU, 각자의 메모리가 있는 것 처럼 보여주는 것이죠. 실제로는 OS가 시간을 잘게 쪼개서 CPU를 번갈아 분배하고 (time-shared mulitprocessing), 메모리 주소도 가상 주소를 통해 각자 독립된 공간처럼 보이게 합니다.

권한 수준

여기서 질문이 생깁니다. OS도 결국 CPU 위에서 작동하는 소프트웨어인데, 어떻게 다른 프로그램을 통제할 수 있을까요? OS 위에서 동작하는 프로그램은 서로를 통제할 수 없고, OS만 다른 프로그램을 통제할 수 있게 하려면 OS를 특별한 프로그램으로 취급하는 하드웨어 차원의 지원이 필요합니다. 이것이 바로 권한 수준(Privilege Levels) 입니다.

현대 CPU는 "지금 실행 중인 코드가 얼마나 믿을만한가"를 구분하는 모드를 하드웨어적으로 내장하고 있습니다. 프로그램은 보통 아래 세 단계로 분리됩니다.

유저 모드

유저 모드(User Mode) 는 일반 응용 프로그램이 실행되는 영역입니다. 이 모드에서는 사용할 수 있는 명령어가 제한되어 있습니다. 덧셈, 뺄셈, 메모리 읽기와 쓰기 같은 일반적인 연산은 가능하지만, 디스크 컨트롤러에 직접 명령을 내리거나 다른 프로세스의 메모리에 접근하거나, 인터럽트를 끄는 등 전체 시스템에 파괴적인 영향을 주는 작업은 불가능합니다. 이런 명령어를 실행하려고 하면 CPU가 예외(exception)를 발생시켜 OS에게 알립니다.

커널 모드

커널 모드(Privileged/Kernel Mode) 는 OS의 커널 코드가 실행되는 영역입니다. 이 모드에서는 CPU의 모든 명령어를 사용할 수 있고, 모든 메모리 영역에 접근할 수 있으며, 시스템의 하드웨어 장치를 직접 제어할 수 있습니다. 만약 유저 프로그램이 파일을 읽고 싶으면, 시스템 콜(System Call) 을 통해 OS에게 요청해야 하고, 이때 CPU의 제어 흐름이 커널로 넘어가면서 CPU 모드가 유저 모드에서 커널 모드로 전환됩니다.

하이퍼바이저 모드

한편, 커널모드 아래쪽에도 하나의 계층을 둡니다. 바로 하이퍼바이저 모드(Hypervisor Mode) 입니다. (이를 지칭하는 용어는 CPU 마다 다릅니다.) 이는 OS 아래에 또 다른 가상화 계층을 두는 것으로, 하나의 물리 머신 위에서 여러 개의 OS를 동시에 실행할 수 있게 합니다. VMware나 VirtualBox, Proxmox VE 같은 가상 머신 환경이 이 계층에서 동작합니다.

RISC-V의 특권 모드

RISC-V에서는 지금까지 설명한 권한 수준을 네 가지 특권 모드(Privilege Mode) 로 정의합니다. 위에서 아래로 갈수록 특권이 높아집니다.

  • U 모드 (User): 일반 응용 프로그램이 실행되는 가장 낮은 특권 수준입니다. 앞서 설명한 유저 모드에 해당합니다.
  • S 모드 (Supervisor): OS의 커널 코드가 실행되는 모드입니다. 위에서 말한 커널 모드에 해당합니다. S 모드는 페이지 테이블을 다루고 프로세스들을 번갈아 스케줄링하는 등 OS의 주된 작업을 수행합니다. Linux, FreeBSD 같은 일반적인 OS 커널이 이 모드에서 동작합니다.
  • H 확장 (Hypervisor): S 모드를 확장하여 가상화를 지원하는 선택적 확장입니다. 엄밀히는 독립된 모드가 아니라 S 모드 위에 얹히는 확장으로, "HS 모드(Hypervisor-extended Supervisor Mode)" 라고 불리기도 합니다. 이 위에서 게스트 OS는 S 모드로 착각한 채 돌아갑니다. 위의 하이퍼바이저 모드에 해당합니다.
  • M 모드 (Machine): RISC-V에만 있는, 가장 높은 특권 수준 입니다. 머신 자체에 대한 완전한 통제권을 가지며, 부팅 직후 CPU가 시작하는 모드이자 어느 CPU에서든 반드시 구현해야 하는 필수 모드입니다. 하드웨어를 가장 가까이에서 다루는 펌웨어부트로더(OpenSBI 등) 가 여기서 동작하며, CSR에 대한 전면적인 접근 권한을 가집니다.

모든 RISC-V CPU가 네 모드를 전부 구현할 필요는 없습니다. 구현 예시를 나열하면:

  • M 전용: 아주 작은 마이크로컨트롤러(MCU). 가장 단순한 구성이고, OS 없이 펌웨어만 동작합니다.
  • M + U: 간단한 임베디드 시스템. OS는 없지만 어느 정도 응용 프로그램과 펌웨어를 분리하고 싶을 때.
  • M + S + U: Linux 같은 범용 OS를 돌리기 위한 일반적인 구성. 대부분의 RISC-V 데스크탑/서버 CPU가 이 구성입니다.
  • M + (HS) + S + U: 가상화까지 지원하는 고성능 구성. 서버급 RISC-V 프로세서에서 주로 보입니다.

다른 아키텍처와의 가장 큰 차이점은 M 모드의 존재입니다. x86에서는 하이퍼바이저 아래에 별도의 "머신 레벨"이 없고, 펌웨어(BIOS/UEFI)와 OS가 비교적 느슨하게 연결되어 있습니다. RISC-V는 이와 달리 M 모드를 ISA 차원에서 명시적으로 정의하여, 부팅부터 OS 실행, 가상화에 이르기까지의 모든 권한 전환을 하나의 일관된 모델로 설명할 수 있도록 했습니다. S 모드에서 수행하는 인터럽트 처리 역시 이 모델 위에서 동작하고, 앞으로 설명할 sstatus, sepc, scause 같은 CSR 이름들의 접두사 s-가 바로 Supervisor 모드를 의미합니다. (같은 동작을 M 모드에서 수행하는 CSR에는 mstatus, mepc, mcause처럼 m- 접두사가 붙습니다.)

RISC-V에서의 인터럽트

인터럽트 처리를 구현하려면 CPU는 몇 가지 상태 정보를 어딘가에 저장해 두어야 합니다. 인터럽트가 발생한 시점의 PC, 이벤트의 원인, 이전 권한 수준, 인터럽트 마스크 설정 같은 것들이죠. 이 정보들을 어디에 둘지 생각해 보면, 두 가지 후보가 자연스럽게 떠오릅니다. 일반 레지스터 파일(x0~x31) 에 둘지, 아니면 메모리의 특정 주소에 둘지입니다. 그런데 두 방법 모두 심각한 문제가 있습니다.

먼저 일반 레지스터를 쓰는 건 불가능에 가깝습니다. 인터럽트는 프로그램 실행 중 임의의 시점에 끼어드는데, 이 순간 모든 일반 레지스터에는 이미 원래 프로그램이 쓰던 값이 들어 있습니다. 인터럽트 처리에 쓰려고 x5, x6 같은 레지스터에 상태를 써 버리면 원래 값이 즉시 덮어써지고, 복귀한 프로그램은 레지스터가 망가진 상태로 실행을 이어가게 됩니다. 앞 절에서 강조한 투명성 (transparency) 이 바로 깨지는 것이죠. 게다가 인터럽트 핸들러 자체도 일반 레지스터를 써야 하는데, 상태 저장용으로 잡아 두면 핸들러가 쓸 수 있는 레지스터가 줄어 버립니다.

그렇다고 메모리에 두는 것도 만만치 않습니다. 인터럽트가 발생한 그 찰나에 CPU가 캐시나 MMU를 거쳐 메모리에 접근하여 PC와 원인을 기록하고 다시 읽는 작업을 수행하기에는 너무 느립니다. 인터럽트 처리가 단일 사이클에 거의 원자적으로 일어나야 하는데, 메모리 접근은 경로가 너무 깁니다. 더 근본적인 문제로는 메모리 접근 자체가 예외를 일으킬 수 있다는 점이 있습니다. 만약 인터럽트를 일으킨 원인이 바로 페이지 폴트였다면, 그 상태를 메모리에 저장하려 할 때 또 다른 페이지 폴트가 발생할 수 있고, 그것을 처리하려 다시 메모리에 접근하면... 이렇게 재귀적으로 무너지게 됩니다.

그래서 RISC-V는 제3의 선택지를 둡니다. 일반 레지스터 파일과도, 메모리와도 분리된 전용 레지스터 공간을 ISA 수준에서 따로 정의해 두는 것입니다. 이것이 바로 CSR(Control and Status Register) 입니다. CSR은 일반 레지스터 파일과 독립되어 있으므로 인터럽트 발생 시 원래 프로그램의 x0~x31을 전혀 건드리지 않고 상태를 저장할 수 있고, 메모리가 아니라 CPU 내부의 전용 하드웨어이므로 단일 사이클에 접근할 수 있으며, 접근 권한을 모드별로 통제할 수 있어 유저 프로그램이 인터럽트 벡터 같은 민감한 상태를 함부로 건드리지 못하게 막을 수 있습니다. CSR과 그것을 조작하는 전용 명령어들이 바로 RISC-V 인터럽트 구조의 뼈대를 이룹니다.

Privileged CSRs

CSR은 일반 레지스터 파일(x0~x31)과는 별개로 존재하는, 12비트 주소 공간을 갖는 특수 레지스터의 집합입니다. CSR 주소 공간에는 프로그램 성능 카운터부터 인터럽트 관련 상태, 메모리 보호 설정 등 수많은 레지스터가 정의되어 있습니다. 그리고 그중 상당수는 Privileged CSR로, 특권 모드 (S 모드나 M 모드)에서만 접근할 수 있습니다. 유저 모드 프로그램이 이런 CSR에 접근하려고 하면 CPU는 곧바로 illegal instruction 예외를 발생시킵니다. 덕분에 OS는 유저 프로그램이 인터럽트 벡터를 멋대로 바꾸거나, 인터럽트를 꺼버리거나, 다른 프로세스의 상태를 훔쳐보는 행위를 하드웨어 수준에서 차단할 수 있습니다.

CSR 접근 명령어: CSRRW

CSR은 일반 레지스터 파일과 분리되어 있으므로, lw/sw 같은 일반 메모리 명령으로 접근할 수 없습니다. 대신 RISC-V는 CSR 전용 명령어 집합을 제공합니다. 그중 가장 기본이 되는 것이 csrrw(Control and Status Register Read and Write) 입니다.

csrrw rd, csr, rs1
  • rd: 해당 CSR의 이전 값을 저장할 일반 레지스터
  • csr: 접근할 CSR의 주소
  • rs1: 해당 CSR에 새로 쓸 값이 담긴 일반 레지스터

csrrw는 한 명령어 안에서 CSR을 읽고 동시에 새 값으로 덮어쓰는 연산을 수행합니다. 읽기와 쓰기가 원자적(atomic) 이라서, 그 사이에 다른 코드가 CSR 값을 변경할 틈이 없다는 점이 중요합니다. 이 외에도 특정 비트만 세팅하는 csrrs(CSR Read and Set), 비트를 클리어하는 csrrc(CSR Read and Clear), 그리고 각각의 5비트 이미디엇 버전인 csrrwi / csrrsi / csrrci까지 총 6가지 CSR 명령이 있습니다.

인터럽트 관련 주요 CSR

RISC-V의 인터럽트 처리 흐름에서 핵심적으로 사용되는 S 모드 CSR 몇 개를 정리하면 다음과 같습니다. (M 모드에도 mstatus, mepc, mcause, mtvec, mtval처럼 이름만 바뀐 대응 CSR이 존재하며, 동작은 거의 동일합니다.)

  • sstatus (Supervisor Status Register): 현재 CPU의 상태를 담는 레지스터입니다. 가장 중요한 필드는 SIE(Supervisor Interrupt Enable) 비트로, 1이면 S 모드에서 인터럽트를 받을 수 있고 0이면 인터럽트가 비활성화됩니다. 또 SPP(Supervisor Previous Privilege) 비트는 인터럽트가 발생하기 직전의 권한 수준을 기록해두어, 나중에 복귀할 때 어느 모드로 돌아가야 할지를 알려줍니다.
  • sepc (Supervisor Exception Program Counter): 인터럽트나 예외가 발생한 시점의 PC를 저장하는 레지스터입니다. 핸들러 실행이 끝나면 CPU는 이 주소로 되돌아가 원래 프로그램의 실행을 이어갑니다. 동기적 예외라면 그 예외를 일으킨 명령어의 주소가, 비동기적 인터럽트라면 아직 실행되지 않은 다음 명령어의 주소가 저장됩니다.
  • scause (Supervisor Cause Register): 이번에 발생한 인터럽트/예외의 원인을 나타내는 레지스터입니다. 최상위 비트가 1이면 비동기적 인터럽트, 0이면 동기적 예외이고, 하위 비트들은 구체적인 exception code를 담습니다. 핸들러는 이 값을 보고 어떤 처리를 할지 결정합니다. 자세한 비트 구조는 바로 아래에서 더 다룹니다.
  • stvec (Supervisor Trap Vector Base Address Register): 인터럽트가 발생했을 때 CPU가 점프해 갈 핸들러의 시작 주소가 저장되는 레지스터입니다. OS는 부팅 시 이 CSR에 자신의 인터럽트 핸들러 주소를 써 두고, 이후에는 CPU가 인터럽트를 받을 때마다 자동으로 이 주소로 점프하게 됩니다.
  • stval (Supervisor Trap Value): 예외의 부가 정보를 담습니다. 예를 들어 잘못된 메모리 접근에서는 문제가 된 주소가, illegal instruction 예외에서는 문제가 된 명령어 인코딩이 들어갑니다. 핸들러가 추가 진단에 활용합니다.

scause 레지스터의 구조

앞서 나열한 CSR 중 scause는 인터럽트 처리 흐름에서 가장 자주 읽게 되는 레지스터이기 때문에, 그 비트 구조를 조금 더 자세히 뜯어보겠습니다.

scause의 너비는 XLEN 비트로, RV32에서는 32비트, RV64에서는 64비트입니다. 어느 쪽이든 구조는 본질적으로 동일합니다. 최상위 비트 1개가 Interrupt 비트이고, 나머지 모든 하위 비트가 Exception Code 영역입니다. 그림으로 나타내면 RV32에서는 다음과 같습니다.

  31  30                                                       0
 ┌───┬──────────────────────────────────────────────────────────┐
 │ I │                 Exception Code (31 bits)                 │
 └───┴──────────────────────────────────────────────────────────┘
   │                               │
   │                               └── 구체적인 원인 번호
   │                                   (인터럽트 종류 혹은 예외 종류)

   └── Interrupt 비트
       1 = 비동기적 인터럽트
       0 = 동기적 예외

Interrupt 비트(I) 는 MSB 한 비트입니다. 이 비트를 보고 핸들러는 "이번에 들어온 것이 외부 장치나 타이머에서 온 비동기적 인터럽트인지, 아니면 현재 실행 중이던 명령어 자체가 일으킨 동기적 예외인지"를 가장 먼저 구분합니다. 보통 둘은 처리 경로가 전혀 다르기 때문입니다. 예를 들어 동기적 예외라면 sepc가 가리키는 명령어를 다시 실행할지, 건너뛸지, 프로세스를 죽일지를 결정해야 하고, 비동기적 인터럽트라면 해당 장치의 컨트롤러에 응답을 보낸 뒤 원래 프로그램으로 복귀하면 됩니다.

Exception Code 영역은 남은 하위 비트 전체입니다. 이 값이 구체적으로 어떤 사건인지를 번호로 나타냅니다. 같은 번호여도 I 비트가 0일 때와 1일 때의 의미가 서로 다르기 때문에, 엄밀히는 (I, code) 쌍으로 해석해야 합니다.

I = 1(인터럽트)일 때 자주 보는 코드:

Code의미
1Supervisor software interrupt (SSI)
5Supervisor timer interrupt (STI)
9Supervisor external interrupt (SEI)

I = 0(예외)일 때 자주 보는 코드:

Code의미
0Instruction address misaligned
1Instruction access fault
2Illegal instruction
3Breakpoint
4Load address misaligned
5Load access fault
6Store/AMO address misaligned
7Store/AMO access fault
8Environment call from U-mode (시스템 콜)
9Environment call from S-mode
12Instruction page fault
13Load page fault
15Store/AMO page fault

흥미로운 점은 Interrupt 비트가 최상위 비트라는 선택입니다. C 코드에서 scause 값을 부호 있는 정수로 읽으면, 인터럽트의 경우 값이 음수가 되므로 "scause < 0이면 인터럽트, 아니면 예외"라는 식으로 분기를 간결하게 작성할 수 있습니다. 간단한 S 모드 핸들러는 보통 다음과 비슷한 형태가 됩니다.

void trap_handler(void) {
    uintptr_t cause = read_csr(scause);
    uintptr_t code  = cause & ~((uintptr_t)1 << (XLEN - 1));

    if ((intptr_t)cause < 0) {
        // 인터럽트
        switch (code) {
            case 5: handle_timer();    break;
            case 9: handle_external(); break;
            // ...
        }
    } else {
        // 예외
        switch (code) {
            case 2:  handle_illegal_instruction(); break;
            case 8:  handle_syscall();             break;
            case 13: handle_load_page_fault();     break;
            // ...
        }
    }
}

이렇듯 scause"지금 왜 여기 와 있는가" 에 대한 답을 단 한 워드 안에 담고 있는 레지스터이고, 인터럽트 핸들러는 이 값을 읽는 것으로 첫 번째 판단을 내리게 됩니다.

인터럽트가 일어나면 실제로 어떤 일이 일어나는지

인터럽트나 예외가 발생했을 때 CPU가 하드웨어적으로 수행하는 일련의 동작은 다음과 같습니다. 이 모든 단계는 단일 사이클 안에 하드웨어가 자동으로 끝내는 일이고, 소프트웨어 코드는 한 줄도 개입하지 않습니다.

  1. 원인 기록: 발생한 이벤트의 종류를 scause에 기록합니다. 최상위 비트로 인터럽트/예외를 구분하고, 하위 비트에 exception code를 써 넣습니다.
  2. PC 저장: 인터럽트가 발생한 시점의 PC를 sepc에 저장합니다. 정밀 인터럽트가 보장되므로, 이 PC는 "이 명령어 앞까지는 전부 완료되었고 이 명령어부터는 전혀 실행되지 않았다"는 경계선에 해당합니다.
  3. 부가 정보 기록: 필요하면 stval에 추가 정보(잘못된 접근 주소, 잘못된 명령어 인코딩 등)를 함께 기록합니다.
  4. 권한 전환: CPU 모드를 S 모드(혹은 M 모드)로 올립니다. 이전 권한 수준은 sstatus.SPP에 백업되어, 나중에 복귀할 때 참조됩니다.
  5. 인터럽트 비활성화: sstatus.SIE를 0으로 내려 핸들러 실행 중에 또 다른 인터럽트가 들어와 상태를 망가뜨리지 않도록 합니다. 이전 SIE 값은 sstatus.SPIE(Supervisor Previous Interrupt Enable)에 백업됩니다.
  6. 핸들러로 점프: 마지막으로 PC를 stvec이 가리키는 주소로 설정합니다. 이제 CPU는 OS가 미리 등록해 둔 인터럽트 핸들러 코드를 실행하기 시작합니다.

핸들러가 시작되는 순간의 CPU 상태를 정리하면 이렇습니다. scause에는 원인이, sepc에는 복귀할 주소가, 필요하면 stval에는 추가 정보가 이미 채워져 있습니다. 현재 모드는 S 모드이고, SIE는 0으로 꺼져 있어 중첩 인터럽트가 차단된 상태입니다. 핸들러 코드는 이 정보들을 읽어서 어떤 인터럽트가 왜 발생했는지를 판단하고, 적절한 처리를 수행한 뒤, 마지막에 sret(Supervisor Return) 명령어로 복귀합니다. sret이 실행되면 CPU는 sepc에 저장된 주소로 PC를 되돌리고, sstatus.SPP에 저장해 두었던 이전 권한 수준으로 모드를 복원하며, sstatus.SPIESIE로 되돌려 인터럽트를 다시 활성화합니다. 원래 프로그램 입장에서는 아무 일도 일어나지 않은 것 처럼 정확히 중단된 지점부터 실행이 이어지는 것이죠.

중첩 인터럽트

지금까지의 설명에서는 "인터럽트가 발생하면 핸들러가 끝날 때까지 새 인터럽트는 들어오지 않는다"고 가정했습니다. 실제로 sstatus.SIE가 0으로 내려가기 때문에 하드웨어가 강제로 그렇게 만드는 것이고, 이는 핸들러가 자기 자신을 망가뜨리지 않도록 해 주는 안전 장치입니다. 그러나 항상 이렇게만 동작하는 것은 현실적으로 문제가 있습니다.

가장 큰 문제는 응답 지연입니다. 어떤 인터럽트 핸들러가 길게 수행되는 동안 새 인터럽트가 차단되면, 그 시간만큼 외부 장치나 타이머의 응답이 밀립니다. 예를 들어 디스크 I/O 완료를 처리하는 핸들러가 오래 걸리는 동안 타이머 인터럽트가 막혀 있으면, OS는 그 시간 동안 스케줄링 결정을 내리지 못하게 되고, 다른 모든 프로세스가 일시적으로 멈추게 됩니다. 반응성이 중요한 시스템(실시간 OS, 네트워크 장비 등)에서는 이런 지연이 심각한 문제가 됩니다.

이를 해결하는 방법이 중첩 인터럽트(Nested Interrupts) 입니다. 핸들러가 어느 정도 초기 작업을 마친 뒤, 스스로 sstatus.SIE를 1로 되돌려 새 인터럽트를 다시 받아들일 수 있는 상태로 만드는 것입니다. 그러면 핸들러가 실행 중이던 도중에 더 급한 인터럽트가 들어오면, 현재 핸들러를 일시 중단하고 새로운 핸들러로 제어가 넘어가며, 새 핸들러가 끝나면 원래 핸들러로 복귀해 남은 작업을 마저 수행합니다. 마치 함수가 다른 함수를 호출했다가 돌아오는 것과 비슷한 모습입니다.

다만 여기에는 중요한 주의사항이 있습니다. RISC-V의 트랩 관련 CSR ( sepc, scause, stval, sstatus.SPP, sstatus.SPIE ) 은 모두 단 하나의 값만 저장한다는 것입니다. 인터럽트가 또 발생하면 이 CSR들이 새 값으로 덮어써지기 때문에, 원래 핸들러가 가지고 있던 정보는 사라지게 됩니다. 따라서 핸들러가 중첩 인터럽트를 허용하기 전에, 현재 CSR 값들을 자기 스택이나 레지스터에 미리 저장해 두어야 하고, 복귀 시에는 sret을 호출하기 전에 이 값들을 다시 CSR로 복원해야 합니다. 이 순서를 잘못 짜면 원래 인터럽트가 복귀하지 못하거나 엉뚱한 곳으로 돌아가게 됩니다. 전형적인 중첩 허용 핸들러의 흐름은 다음과 같습니다.

  1. 핸들러 진입. (이 시점에 sstatus.SIE = 0, CSR에는 이번 인터럽트의 정보가 들어 있음)
  2. 현재 sepc, scause, sstatus 등의 값을 핸들러 스택에 push.
  3. 반드시 해야 하는 급한 작업(예: 인터럽트 컨트롤러 응답, 플래그 클리어)을 먼저 수행.
  4. sstatus.SIE를 1로 세팅해 중첩 인터럽트를 허용. 이제 새 인터럽트가 들어와도 됨.
  5. 남은 긴 작업을 수행. 도중에 새 인터럽트가 들어오면 재귀적으로 중첩 처리.
  6. 작업 완료. sstatus.SIE를 다시 0으로 내려 중첩을 차단.
  7. 스택에 저장해 두었던 sepc 등을 CSR로 복원.
  8. sret으로 복귀.

이 흐름은 개념적으로는 간단하지만 실제 구현에서 버그를 일으키기 쉬운 부분이라, Linux 같은 범용 OS에서도 중첩 인터럽트를 매우 제한적으로만 허용하거나, 아예 허용하지 않는 경우가 많습니다. 대신 핸들러를 "급한 작업만 하는 짧은 상반부(top half)"와 "나머지 처리를 나중에 하는 하반부(bottom half, 혹은 softirq, tasklet)"로 나누어, 긴 작업을 인터럽트 컨텍스트 바깥에서 처리하는 방식을 많이 씁니다.

인터럽트 우선순위

여러 인터럽트가 거의 동시에 발생하거나, 핸들러가 실행 중일 때 새 인터럽트가 들어오면 어떤 것을 먼저 처리할지 결정해야 합니다. 이를 위해 인터럽트에는 우선순위(Priority) 가 부여됩니다. 우선순위가 높은 인터럽트는 낮은 인터럽트를 밀어내고 먼저 실행될 수 있고, 이 규칙은 앞서 본 중첩 인터럽트와 결합되어 "낮은 우선순위 핸들러가 실행 중일 때, 더 높은 우선순위 인터럽트가 들어오면 낮은 핸들러를 일시 중단하고 높은 쪽을 먼저 처리한다" 는 동작을 만듭니다.

RISC-V에서 인터럽트 우선순위는 세 층위에서 결정됩니다.

특권 모드 간의 우선순위

먼저 특권 모드 사이에 고정된 순서가 있습니다. M 모드 인터럽트가 가장 우선하고, 그 다음이 S 모드, 그 다음이 U 모드 인터럽트입니다. 머신 수준 인터럽트(예: 머신 타이머)는 언제든 슈퍼바이저 수준 인터럽트를 밀어낼 수 있습니다. 이 순서는 ISA에 박혀 있어 소프트웨어가 바꿀 수 없습니다.

같은 모드 안에서의 우선순위

같은 특권 모드 안에서 여러 인터럽트가 동시에 pending 상태일 때의 우선순위는 RISC-V 규격이 정의해 두고 있습니다. S 모드 기준으로는 다음 순서입니다(높은 쪽이 우선).

  1. SEI (Supervisor External Interrupt) — 외부 장치로부터의 인터럽트
  2. SSI (Supervisor Software Interrupt) — 소프트웨어가 명시적으로 거는 인터럽트
  3. STI (Supervisor Timer Interrupt) — 타이머 인터럽트

외부 장치 인터럽트가 가장 우선되는 것은, 외부 이벤트가 가장 지연에 민감하고 놓치면 데이터 유실로 이어질 수 있기 때문입니다. 타이머는 주기적이므로 조금 미뤄져도 큰 문제가 없죠.

외부 인터럽트의 세부 우선순위: PLIC

SEI는 "외부에서 온 인터럽트"를 통합해 전달하는 단일 채널이지만, 실제로는 수십~수백 개의 외부 장치(키보드, 디스크, 네트워크 카드, UART 등)가 각자 인터럽트를 올립니다. 이것들 사이의 우선순위는 CPU 코어가 아니라 별도의 하드웨어 블록인 PLIC(Platform-Level Interrupt Controller) 가 담당합니다.

PLIC의 역할은 다음과 같습니다.

  • 모든 외부 인터럽트 소스에서 올라오는 요청을 모읍니다.
  • 각 소스에 소프트웨어가 설정해 둔 priority 값을 참고해 가장 높은 우선순위의 한 건을 선택합니다.
  • 선택된 인터럽트를 해당 하트(hart, RISC-V의 CPU 코어)에 SEI로 전달합니다.
  • 핸들러가 PLIC에 "이 인터럽트를 받았다(claim)"고 통지하고, 처리가 끝나면 "완료했다(complete)"고 통지합니다. 이 과정을 통해 같은 인터럽트가 중복 처리되는 것을 막습니다.

즉 S 모드 핸들러가 SEI를 받고 진입하면, 이 핸들러가 해야 하는 첫 일은 PLIC을 읽어서 "어떤 외부 장치가 이 인터럽트를 올렸는지" 확인하는 것입니다. PLIC의 priority 값은 0~7(구현에 따라 더 넓을 수 있음) 범위의 정수로, OS가 런타임에 자유롭게 설정합니다. 덕분에 "네트워크 카드는 우선순위 7, UART는 우선순위 3"과 같이 시스템의 특성에 맞춘 정책을 소프트웨어가 유연하게 정할 수 있습니다.

타이머와 소프트웨어 인터럽트: CLINT

STI와 SSI는 외부 장치가 아니라 시스템 자체의 내부 이벤트이므로 PLIC가 아닌 별도의 블록에서 발생합니다. 이를 담당하는 것이 CLINT(Core-Local Interruptor) 입니다. CLINT는 각 하트에 대해 타이머 비교 레지스터(mtimecmp) 와 소프트웨어 인터럽트 트리거를 제공하고, 조건이 맞으면 해당 하트에 STI 또는 SSI를 올립니다. PLIC가 "여러 외부 장치들 중 누가 말할 차례인지"를 중재한다면, CLINT는 "타이머와 하트 간 통신"을 담당하는 더 작고 단순한 블록인 셈입니다.