x86-64 레지스터와 명령어를 읽는 법
부분 레지스터, 데이터 크기, mov와 lea부터 기계어 인코딩까지
x86-64 레지스터와 명령어를 읽는 법
본 블로그 작성 글들은 강의를 목표로 제작되었습니다. CPU 세대, 운영체제, ABI, 어셈블러와 컴파일러 옵션에 따라 실제 코드는 달라질 수 있으니 대상 환경을 함께 확인해주세요.
x86-64 어셈블리를 처음 열면 rax, eax, ax, al이 서로 다른 레지스터처럼 보이고, mov라는 이름도 수십 가지 형태로 나타난다. 그러나 핵심은 명령어 이름을 전부 암기하는 것이 아니다. 어떤 폭의 값을 어디에서 읽어 어디에 쓰며, 그 결과 어떤 상태가 바뀌는가를 추적하는 것이다.
이 글은 Intel 문법을 기준으로 일반 목적 레지스터, 부분 레지스터, 피연산자 크기, mov, movzx, movsx, lea, 산술 명령과 기계어 인코딩의 기본을 연결한다. 예제는 사용자 모드의 일반 C/C++ 프로그램을 읽는 데 필요한 범위로 제한한다.
먼저 세 가지 층을 구분한다
다음 세 줄은 비슷해 보이지만 서로 다른 층에 있다.
C 표현식 total += values[i];
어셈블리 표기 add rax, qword ptr [rcx + rdx*8]
기계어 바이트 48 03 04 D1
- C 표현식은 타입과 프로그램의 의도를 표현한다.
- 어셈블리 표기는 CPU 명령과 피연산자를 사람이 읽게 표현한다.
- 기계어는 CPU가 해석하는 바이트다.
어셈블리 한 줄이 반드시 C 한 줄과 대응하지는 않는다. 컴파일러는 상수를 접고, 코드를 합치고, 레지스터를 재사용한다. 반대로 어셈블리의 한 명령도 opcode, 접두사, ModR/M, SIB, displacement, immediate 같은 여러 필드로 인코딩될 수 있다.
16개의 일반 목적 레지스터
x86-64의 대표적인 64비트 일반 목적 레지스터는 다음과 같다.
| 레지스터 | 자주 보이는 역할 |
|---|---|
rax | 정수 반환값, 산술 임시값 |
rbx | 일반 값, 일부 ABI에서 비휘발 |
rcx, rdx | 일반 값, shift count 또는 ABI 인자 |
rsi, rdi | 일반 값, 문자열 명령·System V 인자 |
rbp | 일반 값 또는 선택적 frame pointer |
rsp | 현재 스택의 꼭대기 |
r8~r15 | x86-64에서 추가된 일반 목적 레지스터 |
rip | 다음에 실행할 명령 위치와 관련된 instruction pointer |
표의 역할은 하드웨어가 강제하는 경우와 ABI가 정하는 경우가 섞여 있다. 예를 들어 rsp는 스택 명령이 암묵적으로 사용하지만, 첫 번째 함수 인자를 rdi에 둘지 rcx에 둘지는 운영체제의 ABI가 정한다.
같은 저장 공간을 여러 폭으로 본다
rax 계열은 하나의 저장 공간을 아래쪽부터 다른 폭으로 부르는 이름이다.
| 이름 | 접근하는 부분 |
|---|---|
rax | 64비트 전체 |
eax | 아래 32비트 |
ax | 아래 16비트 |
al | 아래 8비트 |
ah | 8~15번째 비트, 오래된 high-byte 이름 |
가장 중요한 규칙은 64비트 모드에서 32비트 일반 목적 레지스터에 쓴 결과가 상위 32비트를 0으로 만든다는 점이다.
mov rax, 0FFFFFFFFFFFFFFFFh
mov eax, 7
; RAX = 0000000000000007h
반면 8비트와 16비트 쓰기는 나머지 상위 비트를 그대로 둔다.
mov rax, 1122334455667788h
mov al, 0AAh
; RAX = 11223344556677AAh
따라서 mov al, 1 뒤에 rax 전체를 사용하려면 상위 비트가 무엇인지 증명해야 한다. 컴파일러가 xor eax, eax로 먼저 0을 만든 뒤 setcc al을 쓰는 패턴도 이 때문이다.
ah, bh, ch, dh는 REX 접두사가 필요한 명령에서 사용할 수 없다. 새 코드를 작성할 때는 특별한 이유가 없다면 high-byte 레지스터를 피하는 편이 단순하다.
데이터 폭은 명령 의미의 일부다
다음 세 명령은 같은 주소를 읽지만 읽는 바이트 수가 다르다.
mov al, byte ptr [rcx] ; 1바이트
mov ax, word ptr [rcx] ; 2바이트
mov eax, dword ptr [rcx] ; 4바이트
mov rax, qword ptr [rcx] ; 8바이트
레지스터가 크기를 명확히 하면 어셈블러가 ptr 표기를 생략하도록 허용하기도 한다. 그러나 메모리에 즉시값을 쓸 때는 목적지 폭이 드러나지 않으므로 명시해야 한다.
mov dword ptr [rcx], 1
폭을 틀리면 단지 숫자의 정밀도가 떨어지는 것이 아니다. 옆 필드까지 읽거나 쓰고, 페이지 경계를 넘고, 구조체 레이아웃 계약을 깨뜨릴 수 있다.
mov는 복사가 기본이다
mov rax, rbx
mov rax, qword ptr [rcx]
mov qword ptr [rcx], rax
mov eax, 42
일반적인 mov는 원본을 지우지 않는다. 또한 대부분의 일반 mov 형태는 메모리에서 메모리로 직접 복사하지 못한다.
; mov [rdi], [rsi] ; 일반 MOV 형태로는 불가
mov rax, [rsi]
mov [rdi], rax
이 제한을 “CPU가 한 번에 메모리 하나만 접근한다”라는 보편 법칙으로 확대하면 안 된다. x86에는 문자열 명령과 특수 명령이 있고, read-modify-write 명령도 존재한다. 정확한 규칙은 각 명령의 허용 피연산자 형식을 확인해야 한다.
작은 정수를 넓힐 때 부호를 결정한다
메모리의 한 바이트 0xFF는 uint8_t라면 255이고 int8_t라면 -1이다. 비트는 같지만 넓히는 방법이 다르다.
movzx eax, byte ptr [rcx] ; 000000FFh, 255
movsx eax, byte ptr [rcx] ; FFFFFFFFh, -1을 32비트로 표현
movsxd rax, eax ; 32비트 signed 값을 64비트로 부호 확장
movzx는 빈 상위 비트를 0으로, movsx는 원래 부호 비트로 채운다. 어셈블리에는 C 타입 이름이 남지 않으므로 주변의 확장 명령과 비교·분기를 함께 읽어야 signedness를 추정할 수 있다.
lea는 주소를 계산하지만 메모리를 읽지 않는다
lea rax, [rcx + rdx*4 + 16]
이 명령은 대괄호 안의 유효 주소를 계산해 rax에 넣는다. 그 주소의 메모리 값은 읽지 않는다. 그래서 다음 두 명령은 다르다.
lea rax, [rcx + 8] ; rax = rcx + 8
mov rax, [rcx + 8] ; rax = 해당 주소에 저장된 8바이트
컴파일러는 lea를 포인터 계산뿐 아니라 플래그를 바꾸지 않는 덧셈·상수배 조합에도 사용한다.
lea eax, [rcx + rcx*2] ; eax = rcx * 3
이 코드를 볼 때 “주소를 반드시 사용한다”고 해석하면 안 된다. lea의 입력 표기만 메모리 주소 형식을 빌릴 뿐, 출력은 계산된 정수다.
산술 명령은 목적지를 덮어쓴다
Intel 문법의 두 피연산자 산술 명령은 보통 destination = destination op source 형태다.
add rax, rdx ; rax = rax + rdx
sub rcx, 8 ; rcx = rcx - 8
and eax, 0FFh ; eax = eax & 0xFF
xor edx, edx ; edx = 0, 따라서 rdx 전체도 0
add, sub, and, xor는 결과뿐 아니라 조건 플래그도 바꾼다. lea는 일반적으로 산술 플래그를 바꾸지 않는다. 뒤에 오는 jcc, setcc, cmovcc가 어느 명령이 만든 플래그를 소비하는지 확인해야 한다.
Intel 문법과 AT&T 문법을 섞지 않는다
같은 명령도 도구에 따라 다르게 보인다.
; Intel: destination이 먼저
mov rax, qword ptr [rbx + 8]
# AT&T: source가 먼저, 레지스터에 %, 크기 접미사 사용
movq 8(%rbx), %rax
GDB, objdump, GCC의 옵션에 따라 AT&T 문법이 기본일 수 있다. 문법이 다르다고 CPU 명령이 달라진 것은 아니다. 다만 피연산자 순서를 잘못 읽으면 데이터 흐름이 완전히 반대가 되므로 파일 단위로 문법을 먼저 확인한다.
명령어 길이는 고정이 아니다
x86-64 명령은 가변 길이이며 최대 15바이트다. 같은 mov라도 레지스터, 즉시값의 크기, 주소 형식에 따라 길이가 달라진다.
48 89 D8 mov rax, rbx
B8 2A 00 00 00 mov eax, 42
48 8B 44 D1 10 mov rax, [rcx + rdx*8 + 16]
대표 필드는 다음과 같다.
- legacy·REX·VEX·EVEX 같은 접두사
- 동작을 고르는 opcode
- 레지스터와 주소 지정 형태를 담는 ModR/M
- scale, index, base를 담는 SIB
- 주소에 더할 displacement
- 명령 자체에 포함된 immediate
처음부터 바이트를 손으로 외울 필요는 없다. 디스어셈블러에서 바이트 경계와 명령 경계가 일치하는지, 패치나 손상으로 시작 위치가 한 바이트 밀리지 않았는지를 확인할 때 이 구조가 중요하다.
짧은 함수를 끝까지 추적해 본다
다음은 int transform(int x) { return x * 3 + 5; }에서 나올 수 있는 코드다.
transform:
lea eax, [rdi + rdi*2]
add eax, 5
ret
System V ABI라고 가정하면 입력 x는 edi, 정수 반환값은 eax다.
lea eax, [rdi + rdi*2]가x * 3을 계산한다.eax에 썼으므로rax상위 32비트는 0이 된다.add eax, 5가 32비트 덧셈을 수행한다.ret가 호출자로 돌아가며 호출자는eax를 반환값으로 본다.
단, C의 signed int가 범위를 넘는 입력까지 이 어셈블리와 같은 의미라고 단정하면 안 된다. C의 부호 있는 오버플로는 정의되지 않은 동작이고, 컴파일러 최적화의 전제가 달라질 수 있다.
디스어셈블리를 읽는 체크리스트
한 명령마다 다음 질문을 적으면 복잡한 함수도 좁혀진다.
- 문법은 Intel인가 AT&T인가?
- 피연산자 폭은 8, 16, 32, 64비트 중 무엇인가?
- 읽는 상태와 덮어쓰는 상태는 무엇인가?
- 32비트 쓰기로 상위 절반이 0이 되는가?
- 메모리 피연산자의 유효 주소는 어떻게 계산되는가?
- 플래그를 만들거나 소비하는가?
- ABI가 부여한 인자·반환·보존 역할이 있는가?
자주 하는 실수
eax와rax를 독립 레지스터로 생각한다.mov al, 1이rax전체를 1로 만든다고 생각한다.- 대괄호가 있는
lea도 메모리를 읽는다고 생각한다. mov가 C++ 이동 의미론처럼 원본을 비운다고 생각한다.- 모든 상수를 64비트로 처리해 불필요한 명령과 의존성을 만든다.
- 디스어셈블 결과만 보고 원래 C 타입과 의도를 확정한다.
직접 확인하는 실습
#include <stdint.h>
uint64_t widen_u8(const uint8_t* p) { return *p; }
int64_t widen_i8(const int8_t* p) { return *p; }
uintptr_t offset(const int* p, size_t i) {
return (uintptr_t)&p[i];
}
GCC 또는 Clang에서는 -O2 -S -masm=intel, MSVC에서는 /O2 /FAs로 결과를 만든다. 다음을 비교한다.
widen_u8와widen_i8의 zero/sign extension 차이offset이 실제 메모리를 읽는지, 주소만 계산하는지- 반환값 폭에 따라
eax와rax중 무엇을 쓰는지
정리
x86-64 읽기의 출발점은 레지스터 이름 암기가 아니라 상태 변화다. 부분 레지스터는 같은 저장 공간을 공유하고, 32비트 쓰기는 상위 32비트를 0으로 만든다. mov는 값을 복사하고, movzx와 movsx는 정수의 해석을 드러내며, lea는 메모리를 읽지 않고 유효 주소를 계산한다.
다음 글에서는 [base + index*scale + displacement]가 실제 배열과 구조체 주소로 어떻게 이어지는지, rsp가 call, push, 지역 변수 때문에 어떻게 변하는지 살펴본다.