하드웨어 예외
고친 사람 github-actions[bot]
하드웨어 예외는 프로세서가 명령 하나를 끝까지 실행할 수 없을 때 그 명령에서 멈추고 운영체제에 뒤처리를 넘기는 일입니다. 없는 메모리를 읽거나 모르는 명령을 만났을 때 일어납니다. 운영체제는 원인을 고쳐 그 명령을 다시 실행하게 합니다. 고칠 수 없으면 프로그램에 알립니다. 그러면 대개 프로그램이 끝납니다.
쉽고 빠른 이해
하드웨어 예외는 프로세서가 「이 명령은 못 끝낸다」고 멈춰 서는 일입니다. C 프로그램이 아무것도 안 가리키는 포인터를 따라가 값을 읽으려 하면 그 읽기 명령에서 하드웨어 예외가 납니다.
이게 없으면 프로세서가 명령을 끝낼 수 없을 때 고를 길이 둘뿐입니다. 엉터리 값으로 계속 가거나 기계를 멈추는 것입니다. 하드웨어 예외 덕분에 프로세서는 알리기만 합니다. 무엇을 할지는 운영체제가 정합니다.
이렇게 돕니다:
- 프로세서가 명령을 실행하다 문제를 알아채고 그 명령에서 멈춥니다
- 돌아올 주소와 원인을 적어 두고, 운영체제가 미리 걸어 둔 처리 코드로 건너뜁니다
- 운영체제가 고칠 수 있으면 고치고 그 명령을 다시 돌립니다. 못 고치면 프로그램에 알립니다
대가는 시간입니다. 예외가 날 때마다 하던 일을 저장하고 운영체제로 넘어갔다 와야 합니다.
정상 동작 중에도 자주 나는 종류가 있습니다. 운영체제는 프로그램에 메모리를 내줄 때 실물 메모리를 바로 붙이지 않습니다. 프로그램이 처음 만질 때 예외를 받아 그제야 붙입니다. 그런 예외가 많아지면 프로그램이 느려집니다.
상세
조립 라인의 작업자가 부품이 빠진 상자를 받으면 라인을 멈추는 줄을 당깁니다. 그다음에 할 일은 작업자가 정하지 않습니다. 반장이 와서 부품을 채워 넣고 라인을 다시 돌리거나, 그 상자를 라인에서 빼냅니다.
하드웨어 예외는 그 줄을 당기는 일입니다. 작업자는 CPU(Central Processing Unit, 중앙 처리 장치), 곧 프로세서입니다. 반장은 운영체제의 핵심부인 커널입니다. 프로세서가 명령을 실행하다 끝낼 수 없는 상황을 만나면, 그 명령에서 멈추고 커널에 제어를 넘깁니다.
넘겨받는 코드를 예외 처리기라고 부릅니다. 커널이 원인마다 하나씩 미리 써서 등록해 둔 코드입니다. 프로세서는 원인을 알릴 뿐이고 무엇을 할지는 이 처리기가 정합니다.
이런 장치가 따로 있어야 하는 까닭은 프로그램이 스스로 검사할 수 없는 일이 많아서입니다. 읽으려는 메모리가 지금 준비돼 있는지, 이 주소를 만져도 되는지를 프로그램은 모릅니다. 프로세서는 명령을 실행하는 순간 그걸 알게 됩니다.
그래서 알아챈 쪽은 알리기만 합니다. 판단은 기계 전체를 아는 커널이 맡습니다.
하드웨어 예외를 일으키는 상황
원인은 몇 갈래로 나뉩니다. 공통점은 하나입니다. 지금 실행하려는 명령 자체가 문제의 출발점이라는 점입니다.
| 원인 | 예 | 흔한 결말 |
|---|---|---|
| 메모리를 읽거나 쓸 수 없다 | 실물 위치가 아직 없는 주소 · 허락 안 된 주소 | 앞의 것은 커널이 채우고 계속, 뒤의 것은 프로그램 종료 |
| 모르는 명령이다 | 명령이 아닌 데이터를 실행하려 함 | 프로그램 종료 |
| 권한이 모자란다 | 일반 프로그램이 커널 전용 명령을 실행 | 프로그램 종료 |
| 계산을 끝낼 수 없다 | 정수를 0으로 나누기 | 프로그램 종료 |
프로그램이 쓰는 메모리 주소는 실물 메모리의 위치가 아닙니다. 프로세서가 표를 보고 실물 위치로 바꿔 찾아갑니다. 이 방식이 가상 메모리입니다. 프로그램마다 표를 따로 두어 서로의 메모리를 못 건드리게 하려고 씁니다.
표에는 실물 위치가 아직 없는 주소도 있습니다. 운영체제는 메모리를 내줄 때 실물을 바로 붙이지 않습니다. 프로그램이 처음 만질 때 붙입니다. 원인 표에서 첫 줄이 가장 흔한 것도 이 때문입니다.
메모리가 모자라면 운영체제가 일부를 디스크로 내려 둡니다. 이 일을 스왑이라고 부릅니다. 내려 둔 주소도 표에서 실물 위치가 빠집니다.
그런 주소나 표가 막아 둔 주소를 만지면 하드웨어 예외가 납니다. 이 예외를 페이지 폴트라고 부릅니다. 실물 위치가 없었던 경우라면 커널이 실물 메모리를 붙여 표를 채웁니다. 이것이 표의 「채우고 계속」입니다.
둘째와 셋째는 프로그램이 잘못된 곳으로 건너뛰었을 때 자주 납니다. 망가진 포인터를 따라 엉뚱한 바이트를 명령으로 읽으면 프로세서는 그 바이트를 해석하지 못합니다. 일반 프로그램이 장치를 직접 만지는 명령을 실행해도 막힙니다. 그런 명령은 커널만 쓰도록 권한이 갈려 있기 때문입니다.
넷째는 프로세서마다 다릅니다. 정수를 0으로 나누면 예외를 일으키는 프로세서가 있습니다. 예외 없이 미리 정한 값을 결과로 내놓는 프로세서도 있습니다. 같은 C 코드가 기계에 따라 죽기도 하고 이상한 값으로 계속 돌기도 하는 까닭입니다.
처리되는 순서
이 소절은 예외가 난 순간부터 처리기가 돌기까지를 따라갑니다. 등장하는 것은 셋입니다. 문제를 일으킨 프로그램, 그걸 알아챈 프로세서, 뒤처리를 맡는 커널입니다. 프로세서는 예외의 원인마다 번호를 매겨 둡니다.
sequenceDiagram
participant 프로그램
participant CPU
participant 커널
프로그램->>CPU: 명령 하나를 실행한다
CPU->>CPU: 끝낼 수 없음을 알아챈다
CPU->>CPU: 돌아올 주소와 원인 번호를 적는다
CPU->>커널: 원인 번호가 가리키는 처리기로 건너뛴다
커널->>커널: 고칠지 알릴지 정한다
커널-->>프로그램: 고쳤으면 그 명령부터 다시 돈다
그림에서 볼 것은 프로그램이 이 흐름에 끼어들지 않는다는 점입니다. 프로그램 쪽에서는 명령 하나를 실행했을 뿐입니다. 그 사이에 제어가 커널로 넘어갔다 오는 일은 프로그램 눈에 보이지 않습니다.
어느 처리기로 갈지는 예외 벡터 테이블이 정합니다. 원인 번호마다 처리기 주소를 적어 둔 표입니다. 커널이 기계를 켤 때 이 표를 채워 둡니다. 프로세서는 원인 번호로 이 표에서 갈 곳을 찾습니다.
건너뛸 때 실행 권한도 함께 올라갑니다. 일반 프로그램은 메모리와 장치를 직접 못 만지는 낮은 권한으로 돕니다. 처리기는 커널 코드라 높은 권한이 필요합니다. 이 전환을 프로세서가 직접 해 주기 때문에 프로그램이 커널 권한을 훔칠 틈이 없습니다. 이 경계가 커널 모드와 유저 모드입니다.
고치고 다시 도는 예외와 거기서 끝나는 예외
처리기가 일을 마친 뒤 어디로 돌아가느냐에 따라 예외를 셋으로 가릅니다. 가르는 기준은 고칠 수 있느냐가 아닙니다. 예외가 명령을 끝내기 전에 났느냐, 끝낸 뒤에 났느냐입니다.
| 갈래 | 언제 나나 | 처리 뒤 돌아가는 곳 |
|---|---|---|
| 폴트(fault) | 명령을 끝내기 전에 문제가 걸렸다 | 멈춘 그 명령 |
| 트랩(trap) | 일부러 넣은 명령을 끝냈다 | 다음 명령 |
| 어보트(abort) | 무엇이 어긋났는지 짚을 수 없는 고장이 났다 | 안 돌아간다 |
하드웨어 예외 대부분은 폴트입니다. 커널이 원인을 없애면 프로세서가 멈췄던 명령을 처음부터 다시 실행합니다. 페이지 폴트가 대표입니다. 커널이 빠진 메모리를 채워 넣으면 같은 읽기 명령이 이번에는 성공합니다.
트랩은 같은 통로를 일부러 쓰는 갈래입니다. 디버거가 지정한 줄에서 멈출 때 심어 두는 멈춤 명령이 그렇습니다. 그 명령은 할 일을 이미 마쳤으므로 다음 명령부터 이어 갑니다.
트랩은 명령을 끝내지 못해서 나는 일이 아닙니다. 그래도 지금 실행한 명령에서 나와 같은 통로를 쓰므로 넓게는 하드웨어 예외의 한 갈래로 셉니다. 맨 앞의 정의는 그중 가장 흔한 폴트를 두고 한 말입니다.
어보트는 메모리 회로가 망가졌을 때처럼 무엇이 어긋났는지조차 짚을 수 없는 경우입니다. 명령이 아니라 기계 자체가 문제의 출발점이라, 앞의 원인 표가 말한 공통점에서 벗어납니다. 이어 갈 방법이 없어 프로그램이나 기계를 멈춥니다.
고칠 수 없으면 프로그램에 알린다
폴트라고 늘 고칠 수 있는 것은 아닙니다. 폴트라는 이름은 고치면 그 명령을 다시 돌릴 수 있게 멈췄다는 뜻일 뿐입니다. 허락 안 된 주소를 읽으려 했다면 채워 넣을 메모리가 없습니다. 이때 커널은 문제를 프로그램 쪽으로 돌려보냅니다.
돌려보내는 수단이 시그널입니다. 시그널은 커널이 실행 중인 프로그램, 곧 프로세스에 보내는 이름 붙은 짧은 알림입니다.
프로세스가 그 시그널을 받을 코드를 미리 걸어 두었으면 그 코드가 돕니다. 걸어 두지 않았으면 정해진 기본 동작이 일어납니다. 하드웨어 예외에서 온 시그널의 기본 동작은 대개 프로세스를 끝내는 것입니다.
flowchart TD
E["하드웨어 예외가 난다"] --> Q1{"커널이 고칠 수 있나"}
Q1 -->|예| F["고치고 그 명령을 다시 실행한다"]
Q1 -->|아니오| S["커널이 시그널을 보낸다"]
S --> Q2{"받을 코드를 걸어 두었나"}
Q2 -->|예| H["그 코드를 실행한다"]
Q2 -->|아니오| X["프로세스가 끝난다"]
그림의 두 갈림이 하드웨어 예외의 결말을 정합니다. 첫 갈림에서 「예」로 빠지면 프로세스는 예외가 났는지조차 모릅니다. 끝까지 「아니오」로 빠지면 프로세스가 사라집니다.
원인마다 보내는 시그널이 정해져 있습니다. 시그널 이름은 원인을 줄여 붙인 것입니다.
| 원인 | 시그널 | 이름이 줄인 말 |
|---|---|---|
| 허락 안 된 메모리 접근 | SIGSEGV | segmentation violation, 메모리 구역 위반 |
| 모르는 명령 | SIGILL |
illegal instruction, 잘못된 명령 |
| 계산 오류 | SIGFPE |
floating-point exception, 산술 예외 |
SIGFPE 는 이름에 부동소수점이 들어 있지만 정수를 0으로 나눌 때도 옵니다. 이름만 보고 부동소수점 문제로 좁혀 읽으면 원인을 잘못 짚습니다. 첫 줄의 시그널로 프로세스가 끝나는 일을 흔히 세그멘테이션 폴트라고 부릅니다.
인터럽트와 다른 점
하드웨어 예외와 자주 같이 나오는 이름이 인터럽트입니다. 둘 다 하던 일을 끊고 처리기로 건너뛰는 같은 통로를 씁니다. 갈리는 것은 끊는 쪽이 누구냐입니다.
인터럽트는 바깥에서 옵니다. 키보드가 눌렸거나 네트워크 카드에 데이터가 도착했다는 신호입니다. 지금 어느 명령을 실행하던 중이었는지와 상관이 없습니다.
하드웨어 예외는 지금 실행하던 명령에서 나옵니다. 그래서 하드웨어 예외를 동기 예외라고도 부릅니다. 인터럽트는 비동기라고 부릅니다.
여기서 동기는 예외가 나는 때가 특정 명령의 실행과 맞물려 있다는 뜻입니다. 코드에서 말하는 블로킹 여부와는 다른 말입니다. 때가 명령에 묶여 있으니 같은 입력으로 같은 명령을 다시 실행하면 같은 예외가 또 납니다. 이 성질 덕분에 하드웨어 예외로 죽은 프로그램은 대개 재현할 수 있습니다.
언어의 예외와 다른 점
백엔드 개발자가 매일 만나는 예외는 다른 물건입니다. Java 의 throw 나 Python 의 raise 로 던지는 예외는 프로그램이나 그 언어의 실행기가 직접 만듭니다. 던진 예외는 함수를 부른 길을 거슬러 올라가 받을 코드를 찾습니다. 프로세서는 이 과정을 모릅니다.
하드웨어 예외는 프로세서가 일으키고 커널이 받습니다. 프로그램 코드의 try 블록은 이걸 붙잡지 못합니다. 커널이 시그널로 바꿔 보내야 비로소 프로그램이 알게 됩니다.
Java나 Python으로 짠 서버에서는 하드웨어 예외로 프로세스가 죽는 일이 드뭅니다. 실행기가 메모리 접근을 대신 맡기 때문입니다. 실행기는 잘못이 보이면 언어의 예외로 바꿔 줍니다. 널 참조가 프로세스 종료가 아니라 예외 하나로 끝나는 것이 그 덕분입니다.
서버에서 하드웨어 예외를 만나는 순간
대부분의 하드웨어 예외는 보이지 않습니다. 프로그램이 새로 받은 메모리를 처음 만질 때마다 페이지 폴트가 납니다. 커널이 그때마다 조용히 채워 넣습니다. 고장이 아니라 가상 메모리가 원래 도는 방식입니다.
보이는 것은 횟수가 늘 때입니다. 채워 넣을 내용이 디스크에 있으면 처리 한 번에 디스크를 읽어야 합니다. 메모리가 모자라 디스크로 자주 내려 두는 서버, 곧 스왑을 자주 쓰는 서버에서 응답이 갑자기 느려지는 것이 이 탓입니다.
프로세스가 죽는 쪽은 C 나 C++ 로 쓴 코드에서 납니다. 아래는 아무것도 안 가리키는 포인터를 따라가는 두 줄입니다.
int *p = NULL; // 아무 데도 안 가리킴
int v = *p; // 여기서 SIGSEGV
둘째 줄의 읽기 명령에서 페이지 폴트가 납니다. 그 주소는 프로세스에 허락된 메모리가 아니라서 커널이 고칠 수 없습니다. 커널은 SIGSEGV 를 보냅니다. 받을 코드가 없으니 프로세스가 끝납니다.
Java·Python 서버에서도 이 일이 아주 없지는 않습니다. 실행기 자신이나 속도를 위해 C 로 쓴 확장 모듈은 메모리를 직접 다룹니다. 그쪽에 잘못이 있으면 언어의 예외가 아니라 프로세스 종료로 나타납니다.
이때 로그에는 시그널 이름이 남습니다. 죽던 순간의 메모리를 파일로 떠 둔 코어 덤프가 생기기도 합니다.
관련 항목
하드웨어 예외와 같은 통로로 커널에 들어오는 사건
인터럽트 · 시스템 콜 · 소프트웨어 인터럽트 · 예외 · 문맥 교환 · 모드 전환
하드웨어 예외의 하위 종류
페이지 폴트 · 폴트 · 트랩 · 어보트 · 일반 보호 오류 · 정의되지 않은 명령 · 0으로 나누기 · 정렬 오류 · 머신 체크 예외
하드웨어 예외를 알아채고 일으키는 하드웨어
CPU · MMU · 페이지 테이블 · TLB · 명령어 집합 · 특권 명령 · 레지스터 · 프로그램 카운터
하드웨어 예외가 건너뛸 처리기를 정하는 장치
예외 벡터 테이블 · 인터럽트 디스크립터 테이블 · 예외 처리기 · 트랩 벡터 · 커널 · 커널 모드와 유저 모드
하드웨어 예외가 프로세스에 전달되는 시그널
시그널 · SIGSEGV · SIGILL · SIGFPE · SIGBUS · 시그널 핸들러 · 세그멘테이션 폴트 · 코어 덤프
하드웨어 예외를 일으키는 코드의 실수
널 포인터 역참조 · 해제 후 사용 · 버퍼 오버플로 · 스택 오버플로 · 허상 포인터 · 정의되지 않은 동작
하드웨어 예외가 지키는 메모리 구조
가상 메모리 · 주소 공간 · 메모리 보호 · 스왑 · 요구 페이징 · 쓰기 시 복사
하드웨어 예외를 붙잡아 쓰는 도구와 기술
디버거 · 중단점 · 가상화 · 하이퍼바이저 · 트랩과 에뮬레이션 · ptrace
하드웨어 예외와 맞세워지는 소프트웨어 예외
예외 처리 · 런타임 오류 · NullPointerException · 스택 되감기 · try-catch
다른 이름: hardware exception · 프로세서 예외 · CPU 예외 · 동기 예외