본문 바로가기

인터럽트와 예외

실행 중인 명령어와 상관없이 바깥에서 오는 인터럽트와, 명령어를 실행하다 생기는 예외를 구분하고, 처리 루틴으로 갔다가 돌아올 때 무엇을 저장해야 하는지 모의 실행으로 확인합니다. 원인에 맞는 처리 위치를 찾는 벡터, 잠시 막는 마스크, 우선순위와 중첩을 다룹니다.

CPU가 긴 계산을 하는 도중에 네트워크로 데이터가 도착했습니다. 계산이 모두 끝날 때까지 기다렸다가 확인한다면, 그동안 장치의 버퍼가 넘쳐 데이터를 잃을 수도 있습니다. 그래서 CPU는 하던 일을 잠시 멈추고 그 사건을 먼저 처리한 뒤, 멈춘 곳으로 돌아와 하던 일을 이어 갈 수 있어야 합니다. 이 글에서는 I/O와 DMA에서 장치가 CPU에 알리는 방법으로 소개한 인터럽트가 실제로 어떻게 처리되는지 다룹니다.

바깥에서 오는 사건과 명령어가 만든 사건

CPU의 정상적인 흐름을 끊고 정해진 처리 코드로 가게 만드는 사건은 원인에 따라 나눌 수 있습니다. 장치나 타이머처럼 CPU 바깥에서 오는 사건은 인터럽트(interrupt)라고 하며, 지금 어떤 명령어를 실행하고 있는지와 상관없이 아무 때나 생기므로 비동기적(asynchronous)입니다. 0으로 나누기, 허용되지 않은 메모리 접근, 알 수 없는 명령어처럼 명령어를 실행하다 생기는 사건은 예외(exception)라고 하며, 같은 프로그램을 같은 입력으로 다시 실행하면 같은 명령어에서 다시 생기므로 동기적(synchronous)입니다.

프로그램이 운영체제에 일을 부탁하려고 일부러 일으키는 사건도 있는데, 이것을 트랩(trap) 또는 시스템 콜(system call)이라고 하며 RISC-V의 ecall, x86-64의 syscall 같은 명령어가 이 일을 합니다. 다만 인터럽트, 예외, 트랩이라는 말을 어떻게 나누어 쓰는지는 ISA마다 조금씩 다르므로, 실제 CPU를 다룰 때는 그 규격의 정의를 확인해야 합니다. 시스템 콜로 커널에 들어갔다가 돌아오는 과정은 운영체제 교재의 커널과 시스템 콜에서 다룹니다.

이 교재의 모델은 인터럽트 요청을 명령어와 명령어 사이에서만 확인합니다. 명령어 하나를 반쯤 실행한 상태로 멈추면 돌아왔을 때 이어서 실행하기가 어렵기 때문입니다. 여러 명령어를 겹쳐 실행하는 CPU에서도, 사건을 처리하러 갈 때는 어느 명령어까지 끝났고 어느 명령어부터는 시작하지 않은 것으로 볼지가 분명해야 합니다. 이렇게 처리하는 것을 정밀 예외(precise exception)라고 합니다.

돌아올 곳과 상태를 저장한다

인터럽트를 받아들이면 CPU는 대체로 다음 순서로 움직입니다. 먼저 돌아올 명령어의 주소와, 처리 코드가 바꿀 수 있는 상태를 저장합니다. 그다음 그 사건을 처리하는 코드인 인터럽트 처리 루틴(interrupt handler)으로 가서, 어떤 장치가 무슨 일로 알렸는지 확인하고 필요한 처리를 한 뒤, 장치에 처리했다고 알려 요청을 내리게 합니다. 끝나면 저장한 상태를 되살리고, 저장한 주소로 돌아가 원래 프로그램을 이어서 실행합니다.

여기서 돌아올 주소만 저장하면 충분하지 않습니다. 처리 루틴도 레지스터를 써야 하는데, 원래 프로그램이 쓰던 레지스터의 값을 바꾸어 놓고 돌아가면, 원래 프로그램은 자기가 모르는 사이에 값이 바뀐 채로 계속 계산하게 됩니다. R에 5씩 세 번 더하는 프로그램이 두 번 더한 뒤 인터럽트를 받고, 처리 루틴이 R을 99로 바꾸는 상황으로 확인해 보겠습니다.

program = [("ADDI", 5), ("ADDI", 5), ("ADDI", 5), ("HALT", None)]


def handler(r):
    return 99


def run(interrupt_before, save_r):
    pc, r, pending = 0, 0, True
    while True:
        if pending and pc == interrupt_before:
            pending = False
            return_pc, saved_r = pc, r
            r = handler(r)
            print(f"PC={pc}, R={saved_r}에서 인터럽트 처리 뒤 R={r}")
            pc = return_pc
            if save_r:
                r = saved_r
        op, arg = program[pc]
        if op == "HALT":
            return r
        r += arg
        pc += 1


print(run(2, save_r=True))
print(run(2, save_r=False))
PC=2, R=10에서 인터럽트 처리 뒤 R=99
15
PC=2, R=10에서 인터럽트 처리 뒤 R=99
104

run은 명령어를 실행하기 전마다 처리할 인터럽트가 있는지 확인하고, 있으면 돌아올 PC와 R을 저장한 뒤 처리 루틴을 실행합니다. 두 번 모두 PC는 2로 제대로 돌아오지만, R까지 되살린 첫 실행은 원래대로 15를 내고, PC만 되살린 둘째 실행은 처리 루틴이 남긴 99에 5를 더해 104를 냅니다. 프로그램 자신은 아무 잘못이 없는데도 결과가 틀린 것입니다. 어떤 상태를 하드웨어가 자동으로 저장하고 어떤 상태를 처리 루틴이 직접 저장할지는 ISA와 운영체제의 약속으로 정해집니다.

처리 루틴이 장치에 처리했다고 알리지 않고 돌아가면, 장치가 계속 요청을 올리고 있어 곧바로 같은 인터럽트가 다시 들어옵니다. 또 처리 루틴은 다른 일을 오래 막지 않도록 짧게 만들고, 시간이 걸리는 후속 작업은 나중에 따로 처리하게 나누는 경우가 많습니다.

벡터, 마스크, 우선순위

인터럽트의 원인은 여러 가지이므로, 원인마다 어느 처리 루틴으로 갈지 정해 두어야 합니다. 원인 번호마다 처리 루틴의 주소를 적어 둔 표를 인터럽트 벡터 표(interrupt vector table)라고 하며, CPU는 원인 번호로 이 표를 찾아 해당 루틴으로 갑니다.

어떤 일을 하는 동안에는 인터럽트를 잠시 받지 않아야 할 때도 있습니다. 예를 들어 처리 루틴이 상태를 저장하는 도중에 또 인터럽트가 들어오면 저장하던 상태가 엉킬 수 있습니다. 이럴 때 특정 인터럽트를 잠시 막는 것을 마스크(mask)라고 합니다. 막는다고 해서 요청이 사라지는 것은 아니며, 컨트롤러가 요청을 보류해 두었다가 마스크가 풀리면 처리하게 합니다. 하드웨어 고장처럼 아주 급한 사건을 알리는 일부 인터럽트는 마스크할 수 없게 만들기도 합니다.

여러 요청이 동시에 오면 우선순위가 높은 것부터 처리합니다. 우선순위가 낮은 인터럽트를 처리하는 도중에 더 급한 인터럽트를 받아들이도록 허용하면 인터럽트가 겹쳐 들어오는 중첩(nesting)이 생기고, 그때는 앞서 처리하던 인터럽트의 상태도 저장해야 합니다. 실제 ISA가 이런 규칙을 어떻게 정하는지는 RISC-V 특권 구조 규격에서 볼 수 있는데, 이 글의 모델이 그 규격의 저장과 복귀 방식과 같다는 뜻은 아닙니다.

더 깊이: RISC-V에서 트랩이 들어올 때 하드웨어가 하는 일

RISC-V 특권 구조 규격의 머신 모드 장을 보면, 하드웨어가 자동으로 저장하는 상태가 생각보다 적습니다. 머신 모드로 트랩이 들어오면 하드웨어는 중단된 명령어나 예외를 일으킨 명령어의 주소를 mepc에 쓰고, 원인을 mcause에 적습니다. mcause의 Interrupt 비트는 인터럽트로 생긴 트랩인지를, 예외 코드 필드는 어떤 사건이었는지를 나타냅니다. 일반 레지스터는 하나도 저장하지 않으므로, 앞의 모델에서 R을 지켜야 했던 일은 처리 루틴이 직접 합니다.

마스크도 트랩과 함께 바뀝니다. 머신 모드로 트랩이 들어오면 mstatus의 인터럽트 허용 비트 MIE의 값이 MPIE로 옮겨지고 MIE는 0이 되며, 트랩 전의 권한 모드는 MPP에 남습니다. 그래서 처리 루틴은 인터럽트가 막힌 상태로 시작해, 상태를 저장하는 도중에 다른 인터럽트가 끼어들지 않습니다. mret으로 돌아갈 때 이 값들이 되돌려지며, 중첩을 허용하려면 처리 루틴이 이 정보를 따로 저장한 뒤 인터럽트를 다시 켜야 합니다.

처리 루틴이 레지스터를 저장하려면 저장할 곳을 가리킬 레지스터가 하나 필요한데, 그 레지스터마저 원래 프로그램의 것입니다. 그래서 RISC-V는 머신 모드 전용의 mscratch 레지스터를 두어, 처리 루틴이 시작하자마자 일반 레지스터 하나와 값을 맞바꾸게 합니다. 규격은 MIPS처럼 일반 레지스터 두 개를 운영체제 몫으로 떼어 두는 방식은 사용자가 쓸 레지스터를 줄인다는 이유 등으로 택하지 않았다고 설명합니다.

처리 루틴의 위치는 mtvec이 정합니다. Direct 모드에서는 모든 트랩이 BASE 한 주소로 가고, Vectored 모드에서는 예외는 BASE로, 인터럽트는 BASE에 원인 번호의 4배를 더한 주소로 가므로 앞에서 본 벡터 표처럼 동작합니다. 예를 들어 머신 모드 타이머 인터럽트는 BASE + 0x1c로 갑니다.

정리

  • 인터럽트는 장치나 타이머처럼 바깥에서 아무 때나 오는 비동기적 사건이고, 예외는 명령어를 실행하다 생기는 동기적 사건이며, 트랩은 프로그램이 일부러 일으키는 사건입니다.
  • 이 교재의 모델은 인터럽트를 명령어 사이에서만 받아들이며, 정밀 예외는 어느 명령어까지 끝났는지 분명한 상태로 처리하러 가는 것입니다.
  • 인터럽트를 처리할 때는 돌아올 주소와 함께 처리 루틴이 바꿀 수 있는 레지스터도 저장했다가 되살려야 합니다.
  • 처리 루틴은 장치에 처리했다고 알려야 같은 인터럽트가 다시 들어오지 않습니다.
  • 벡터 표는 원인마다 처리 루틴을 찾게 하고, 마스크는 인터럽트를 잠시 막아 보류하며, 우선순위와 중첩은 여러 요청의 처리 순서를 정합니다.

연습 문제

1. 돌아올 주소만 저장하면

명령어 4를 마쳐 PC = 5, R = 10이 된 순간 타이머 인터럽트를 받았고, 처리 루틴이 R을 99로 바꾸고 끝났습니다. PC만 5로 되살리면 무엇이 문제이고, 이 모델에서 최소한 무엇을 저장해야 하나요?

해설 보기

PC는 맞게 돌아오지만 R이 99로 바뀌어 있으므로, 다음 명령어부터 R이 10이라고 생각하고 계산하던 원래 프로그램의 결과가 틀어집니다. 본문의 둘째 실행이 바로 이 경우입니다. 그래서 적어도 돌아올 PC = 5와 처리 루틴이 바꾼 R = 10을 저장했다가 되살려야 하고, 조건 분기에 쓰이는 플래그 같은 다른 상태도 처리 루틴이 바꾼다면 함께 저장해야 합니다. 처리 루틴이 쓰지 않는 레지스터까지 모두 저장할지는 약속에 따라 다릅니다.

2. 사건 나누기

타이머의 주기적인 알림, 0으로 나누는 명령어, 파일을 읽어 달라고 운영체제에 부탁하는 명령어, 키보드 입력은 각각 인터럽트, 예외, 트랩 가운데 무엇에 가까운가요?

해설 보기

타이머의 알림과 키보드 입력은 지금 실행하는 명령어와 상관없이 바깥에서 오므로 인터럽트입니다. 0으로 나누는 명령어는 그 명령어를 실행할 때 생기므로 예외이고, 같은 입력으로 다시 실행하면 같은 자리에서 다시 생깁니다. 운영체제에 부탁하는 명령어는 프로그램이 일부러 일으키는 것이므로 트랩, 곧 시스템 콜입니다. 예외와 트랩은 둘 다 명령어가 원인이라 동기적이지만, 의도했는지가 다릅니다.

3. 막아 둔 사이에 온 요청

인터럽트를 마스크해 둔 동안 장치가 요청을 올렸다가 곧 내렸습니다. 마스크를 풀었을 때 이 요청이 처리되려면 무엇이 필요하나요?

해설 보기

요청을 보류해 두는 장치가 필요합니다. 인터럽트 컨트롤러가 요청이 들어왔다는 사실을 보류 레지스터에 기록해 두면, 장치가 신호를 내린 뒤에도 마스크가 풀리는 순간 그 요청을 처리할 수 있습니다. 이런 기록 없이 신호가 올라와 있는 동안만 요청으로 본다면, 마스크한 사이에 올라왔다 내려간 요청은 사라집니다. 마스크는 처리를 미루는 것이지 요청을 버리는 것이 아니어야 하므로, 보류 규칙을 함께 설계해야 합니다.

인터럽트의 흐름을 그릴 때는 사건의 원인, 처리 루틴으로 가는 길, 돌아올 때 되살릴 상태를 따로 적어 보세요. 다음 글 파이프라인과 위험에서는 여러 명령어를 겹쳐 실행할 때 생기는 문제를 다룹니다.