TECH 으로 돌아가기
TECH HACKER NEWS 어제 8분 읽기 39 READS

x86의 '일부러 만든 잘못된 명령어' UD2, 왜 이름에 2가 붙었을까

x86의 '일부러 만든 잘못된 명령어' UD2, 왜 이름에 2가 붙었을까
SOURCE IMAGE · HACKER NEWS
x86의 '일부러 만든 잘못된 명령어' UD2, 왜 이름에 2가 붙었을까

무슨 이야기냐면

마이크로소프트의 레이먼드 첸(Raymond Chen)이 자신의 블로그 The Old New Thing에서 재미있는 질문 하나를 다뤘어요. x86 CPU에는 UD2라는 명령어가 있는데, 이름이 Undefined Instruction 2라는 뜻이거든요. 그런데 왜 하필 2일까요? 1은 어디 갔고, 0은 뭘까요? 이 사소해 보이는 질문을 따라가다 보면 CPU 명령어 집합이 어떻게 진화하고, 컴파일러와 운영체제가 왜 "확실히 실패하는 명령어"를 필요로 하는지 알게 돼요.

일부러 잘못된 명령어를 만든다고?

먼저 UD2가 뭔지부터 볼게요. CPU는 메모리에서 바이트를 읽어서 그걸 명령어로 해석해요. x86에서 UD2는 두 바이트, 16진수로 0F 0B예요. CPU가 이 바이트를 만나면 "이건 정의되지 않은 명령어"라며 #UD라는 예외(invalid opcode exception, 인터럽트 벡터 6번)를 발생시켜요. 즉, 하는 일이라곤 "여기 오면 무조건 죽어라"뿐인 명령어예요.

이게 왜 필요하냐면, 프로그램이 절대 도달하면 안 되는 지점에 왔을 때 확실하게 멈추기 위해서예요. 예를 들어 C 컴파일러의 __builtin_trap(), Rust의 패닉 후 abort, 리눅스 커널의 BUG() 매크로가 전부 x86에서 UD2로 컴파일돼요. 널 포인터 역참조나 배열 범위 초과처럼 계속 진행하면 더 큰 사고가 나는 상황에서, 조용히 이상한 동작을 하는 대신 즉시 예외를 내서 디버거나 커널이 잡을 수 있게 하는 거죠.

"그냥 아무 안 쓰는 바이트나 넣으면 되지 않나?" 싶은데, 옛날에는 실제로 그랬어요. 그런데 문제가 생겨요. 오늘 정의되지 않은 opcode가 내년 CPU에서는 새 명령어로 정의될 수 있거든요. 실제로 x86 역사에서 빈 opcode 공간은 SSE, AVX 같은 확장이 나올 때마다 계속 채워졌어요. 그래서 인텔은 펜티엄 프로 세대부터 "이 opcode는 앞으로 영원히 정의하지 않겠다고 약속한다"는 명령어를 공식 문서에 넣었고, 그게 UD2예요. 약속된 빈자리인 셈이죠.

그래서 왜 2냐면

여기서 이름의 비밀이 나와요. 인텔 내부에는 UD2가 문서에 실리기 전부터 정의되지 않은 명령어로 쓰던 opcode가 더 있었어요. 0F FF는 UD0, 0F B9는 UD1이라고 불렸는데, 이건 오랫동안 인텔 문서에 없는 비공개 명령어였어요. 내부 검증이나 테스트 용도로 0번과 1번이 이미 쓰이고 있었기 때문에, 외부 개발자에게 공개하는 "공식 보증된" 명령어가 세 번째, 즉 2번이 된 거예요. 문서에 처음 등장한 게 UD2라서 개발자들 눈에는 2가 먼저 보였고, 그래서 "1과 0은 어디 갔지?"라는 질문이 생긴 거죠.

재미있는 건 UD0와 UD1이 2017년 무렵에야 인텔 공식 매뉴얼에 실렸다는 거예요. 그것도 그냥 실린 게 아니라 ModR/M 바이트(명령어 뒤에 붙어서 피연산자를 지정하는 바이트)를 갖는 명령어로 정의됐어요. 이게 왜 문제냐면, 명령어 길이가 뒤에 오는 바이트에 따라 달라진다는 뜻이거든요. 리눅스 커널은 한동안 WARN() 매크로에 UD0를 써서 BUG()의 UD2와 구분했는데, 문서가 바뀌면서 디코더가 UD0 다음 바이트를 피연산자로 해석해버릴 수 있게 됐어요. 결국 커널은 두 경우 모두 길이가 확실한 UD2로 통일하고, 대신 별도의 테이블로 어느 위치가 WARN이고 어느 위치가 BUG인지 구분하는 방식으로 바꿨어요. 이름 하나에 이런 뒷이야기가 숨어 있는 거예요.

다른 아키텍처는 어떻게 하나

이런 "보증된 불법 명령어"는 x86만의 개념이 아니에요. ARM에는 UDF(Undefined)라는 명령어가 있고, 64비트 ARM에서는 BRK로 브레이크포인트 예외를 내기도 해요. RISC-V는 아예 0으로 채워진 32비트 워드가 정의상 불법 명령어라서, 초기화 안 된 메모리로 점프하면 자연스럽게 잡혀요. 요즘 컴파일러들은 이걸 적극 활용해서, 예를 들어 값을 반환하지 않는 함수 다음 자리나 switch의 도달 불가능한 케이스에 이런 명령어를 심어놔요. 스펙터(Spectre) 취약점 이후에는 간접 점프 뒤에 UD2를 넣어서 CPU의 추측 실행이 엉뚱한 곳으로 흘러가지 않게 막는 용도로도 써요.

한국 개발자에게는

당장 어셈블리를 짤 일이 없어도 알아두면 좋은 이유가 있어요. 프로그램이 죽었을 때 크래시 덤프나 디버거에 Illegal instruction 또는 SIGILL이라고 뜨면, 거의 대부분 컴파일러가 심어둔 UD2에 도달했다는 뜻이에요. 즉, CPU가 고장 난 게 아니라 코드가 "여기 오면 안 되는데 왔다"고 스스로 신고한 거예요. Rust에서 unreachable_unchecked()를 잘못 쓰거나, C++에서 값을 반환 안 하는 함수의 끝에 도달했을 때 이런 걸 보게 되죠. 원인을 찾을 때 "왜 SIGILL이지?"에서 멈추지 않고 "어떤 불변 조건이 깨졌지?"로 바로 넘어갈 수 있어요.

그리고 이 이야기는 하위 호환성이라는 주제의 축소판이기도 해요. x86은 40년 넘게 옛날 코드를 그대로 돌려야 했기 때문에, 앞으로 절대 쓰지 않을 자리를 문서로 약속하는 게 중요했어요. 우리가 API를 설계할 때 예약 필드나 예약 값을 남겨두는 것과 같은 원리거든요.

정리하면

UD2는 확실히 실패하기 위해 존재하는 명령어이고, 2라는 숫자는 공개되기 전에 이미 0번과 1번이 내부용으로 쓰이고 있었기 때문이에요. 여러분은 절대 도달하면 안 되는 코드를 어떻게 처리하시나요? assert로 죽이는 편인가요, 아니면 방어 코드로 살려두는 편인가요?


🔗 출처: Hacker News

SOURCE · HACKER NEWS
원문 전체 보기 → https://devblogs.microsoft.com/oldnewthing/20260910-00/?p=11...
SHARE
NEXT · CHOOSE

변화를 읽었다면,
내가 만들 수익 구조를 고릅니다.

정보를 더 모으는 데서 멈추지 않고, 광고·외주·판매·중개·구독 중 내 상황에 맞는 출발점을 정해보세요.

21가지 수익 구조 살펴보기
처리 중...