오늘의 질문
#03 · 화이트보드의 내기
질문
도현: "레지스터는 그냥 CPU 안의 변수죠. rax에 −1을 넣고 eax에 5를 넣으면, 아래 절반만 바뀌니까 0xffffffff00000005. 내기할래?"
오늘 알아낼 것
x86-64의 레지스터 16개와 그 아래 이름(eax, ax, al, ah)이 같은 칸의 어디를 덮는지, 32비트 이름에 쓰면 위가 0이 되는 규칙을 실습대로 확인한다. mov, 산술·논리·시프트 명령어, imul로 계산해 보고, gcc가 x*5+3을 lea 한 줄로 바꾸는 까닭을 진짜 출력으로 본다.
새솔대 공학관 5층 실습실 · 화요일 밤 디버거 교실
9월 22일 화요일, 지난주 플래그 덧셈기를 붙잡고 있던 도현이 오늘은 화이트보드 앞에 서 있습니다. 보드에는 두 줄이 적혀 있습니다. mov rax, -1 그리고 mov eax, 5. 그 아래에 도현의 답, rax = 0xffffffff00000005.
"eax는 rax의 아래 32비트잖아. 아래만 5로 바뀌고 위는 그대로지." 은재는 고개를 끄덕이다가 멈칫합니다. "그럼 al에 쓰면 아래 8비트만 바뀌고?"
윤 조교가 보드 옆에 노트북을 엽니다. "둘 다 그럴듯하네. 추측하지 말고, 한 줄씩 실행해 보자."
레지스터는 CPU 안에 있는 64비트짜리 저장 칸입니다. 메모리와 달리 주소가 없고, 명령어 바이트 안의 몇 비트짜리 번호로 고릅니다. 그래서 가장 빠르지만 개수가 적습니다. x86-64의 범용 레지스터는 16개입니다.
rax rbx rcx rdx rsi rdi rbp rsp r8 r9 r10 r11 r12 r13 r14 r15
여기에 1교시의 rip(다음 명령어 주소)와 2교시의 플래그 레지스터 rflags가 따로 있습니다. 범용이라고는 해도 쓰임이 정해진 칸이 있습니다. rsp는 스택 꼭대기를 가리키고, 함수의 첫째·둘째 인자는 rdi, rsi로, 돌려줄 값은 rax로 갑니다. 이 약속은 6교시에 자세히 봅니다.
한 레지스터에는 이름이 여러 개 있습니다. 16비트 시절(8086)의 ax가 32비트(80386)에서 eax(extended)로, 64비트(AMD64)에서 rax로 넓어지면서 옛 이름을 아래쪽 자리에 그대로 남겼기 때문입니다.
| 64비트 | 아래 32비트 | 아래 16비트 | 아래 8비트 | 8–15비트 |
|---|---|---|---|---|
| rax | eax | ax | al | ah |
| rbx | ebx | bx | bl | bh |
| rcx | ecx | cx | cl | ch |
| rdx | edx | dx | dl | dh |
| rsi, rdi | esi, edi | si, di | sil, dil | 없음 |
| rbp, rsp | ebp, esp | bp, sp | bpl, spl | 없음 |
| r8 – r15 | r8d – r15d | r8w – r15w | r8b – r15b | 없음 |
이름은 다르지만 칸은 하나입니다. al을 바꾸면 ax, eax, rax가 모두 바뀝니다. 실습대에서 도현의 내기를 직접 확인해 보세요.
mov al, 0x12 (기계어 b0 12)
8비트 이름(al)에 썼습니다. 맨 아래 바이트 하나만 바뀌고 나머지 7바이트는 그대로입니다.
rax: 0xffff_ffff_ffff_ffff → 0xffff_ffff_ffff_ff12
도현의 답은 틀렸습니다. mov eax, 5 뒤에 rax는 0x0000000000000005입니다. x86-64에는 이런 규칙이 있습니다.
두 규칙이 다른 까닭은 역사입니다. 16·8비트 동작은 옛 프로그램과 맞추려고 그대로 두었고, 32비트 결과를 0으로 넓히는 규칙은 AMD64를 설계할 때 새로 정했습니다. 흔히 드는 이유는 옛 값의 위쪽에 기대지 않게 해서 CPU가 계산을 빨리 시작하게 하려는 것입니다. 이 규칙 덕분에 gcc는 값이 32비트에 들어가면 짧은 32비트 명령어를 즐겨 씁니다. int is_neg(int x)의 -O2 출력 mov eax,edi도, 5교시에 볼 fact의 mov eax,0x1도 rax 64비트 전체를 정합니다.
윤 조교의 한마디
ah, bh, ch, dh는 옛날 이름이라 REX 앞붙이(1교시의 48 같은 바이트)가 붙는 명령어와 함께 못 써. 그래서 `mov ah, sil`은 as가 "can't encode register 'ah' in an instruction requiring REX/REX2 prefix"라며 거절해. 실제 코드에서 ah를 볼 일은 드물어.
직접 풀어 보기
예제 3-1
모든 레지스터가 0인 상태에서 다음 네 줄을 차례로 실행합니다. 줄마다 rax를 16진수로 쓰세요. 마지막 줄은 아무 일도 안 하는 것처럼 보입니다.
mov rax, -1
mov al, 0x12
mov ah, 0x34
mov eax, eax1단계. mov rax, -1: 64비트 전체가 1입니다. 0xffff_ffff_ffff_ffff.
2단계. mov al, 0x12: 바이트 0만 바뀝니다. 0xffff_ffff_ffff_ff12.
3단계. mov ah, 0x34: ah는 바이트 1입니다. 0xffff_ffff_ffff_3412.
4단계. mov eax, eax: 아래 32비트를 자기 자신에 다시 씁니다. 값은 그대로지만 32비트 이름에 썼으므로 위 32비트가 0이 됩니다.
답: 마지막 rax = 0x0000_0000_ffff_3412. 흉내 CPU로 실행한 값과 같습니다. 컴파일러는 이런 32비트 mov를 "부호 없이 64비트로 넓히기"에 씁니다. gcc -O2는 unsigned long zx(unsigned long x) { return (unsigned)x; }를 mov eax,edi 한 줄로 만듭니다.
이 강의의 명령어는 모두 명령 목적지, 출처 모양입니다(인텔 문법). mov rax, rdi는 rdi의 값을 rax에 복사합니다. 계산 명령어는 목적지 = 목적지 (연산) 출처입니다. 결과가 들어갈 칸이 따로 없어서 목적지의 옛 값은 사라집니다.
| 명령어 | 하는 일 | 플래그 |
|---|---|---|
mov a, b | a = b | 안 바뀜 |
add a, b / sub a, b | a = a + b / a = a − b | CF ZF SF OF (2교시) |
inc a / dec a | a = a + 1 / a = a − 1 | CF만 안 바뀜 |
neg a | a = 0 − a | a가 0이 아니면 CF = 1 |
and or xor | 비트마다 그리고·또는·다르면 1 | CF = OF = 0, ZF SF는 결과대로 |
not a | 비트를 모두 뒤집기 (= −a − 1) | 안 바뀜 |
피연산자에는 아무것이나 올 수 없습니다. GNU as(어셈블러)에 넣어 보면 다음 줄들은 모두 거절됩니다.
mov QWORD PTR [rdi], QWORD PTR [rsi]는 오류입니다. 레지스터를 거쳐 두 줄로 옮깁니다(대괄호는 4교시).mov eax, rbx도 오류입니다.add rax, 0x123456789는 안 되고, 64비트 즉값은 mov 하나만 받습니다(movabs, 10바이트).메모리끼리 바로 옮기는 movs 같은 문자열 명령어도 있지만 이 강의에서는 다루지 않습니다.
윤 조교의 한마디
0을 만들 때 gcc -O2는 거의 늘 `xor eax, eax`를 써. 2바이트(31 c0)라 `mov eax, 0`(b8 00 00 00 00)보다 3바이트 짧고, 32비트 규칙 덕분에 rax 전체가 0이 돼. 인텔 최적화 설명서도 이 줄을 옛 값에 기대지 않는 '0 만들기 관용구'로 따로 다뤄. 단, xor는 플래그를 바꾸니까 플래그를 지켜야 하는 자리에서는 mov를 써.
직접 풀어 보기
예제 3-2
rax = 0x1111_1111_ffff_ffff에서 시작해 다음 세 줄을 각각 따로 실행합니다. 결과 rax와 CF, ZF를 쓰세요.
add al, 1
add eax, 1
add rax, 11단계: add al, 1. al은 0xff라 0x100이 되는데 8비트에 들어가지 않으니 0x00이 남고 올림이 생깁니다. CF = 1, ZF = 1. 나머지 7바이트는 그대로라 rax = 0x1111_1111_ffff_ff00.
2단계: add eax, 1. eax 0xffffffff + 1 = 0x00000000, CF = 1, ZF = 1. 32비트 이름에 썼으므로 위 32비트도 0입니다. rax = 0x0000_0000_0000_0000.
3단계: add rax, 1. 64비트 덧셈이라 올림이 바이트 4로 넘어갑니다. rax = 0x1111_1112_0000_0000, CF = 0, ZF = 0.
답: 같은 "+1"인데 폭에 따라 결과가 셋 다 다릅니다. 플래그는 그 명령어의 폭을 기준으로 정해집니다.
직접 해 보기
명령어 몇 줄을 파일에 적고 as(GNU 어셈블러)로 기계어를 만든 뒤 objdump로 바이트를 봅니다. WSL 우분투에서 실행합니다.
cat > try.s <<'EOF'
.intel_syntax noprefix
mov rax, -1
mov eax, 5
mov al, 0x12
xor eax, eax
mov eax, 0
movabs rax, 0x0102030405060708
lea rax, [rdi+rdi*4+3]
EOF
as try.s -o try.o && objdump -d -M intel try.o(줄임)
0000000000000000 <.text>:
0: 48 c7 c0 ff ff ff ff mov rax,0xffffffffffffffff
7: b8 05 00 00 00 mov eax,0x5
c: b0 12 mov al,0x12
e: 31 c0 xor eax,eax
10: b8 00 00 00 00 mov eax,0x0
15: 48 b8 08 07 06 05 04 movabs rax,0x102030405060708
1c: 03 02 01
1f: 48 8d 44 bf 03 lea rax,[rdi+rdi*4+0x3]mov rax, -1은 32비트 즉값 ff ff ff ff를 부호 확장해 7바이트로 끝냅니다. movabs의 즉값 8바이트가 거꾸로(08 07 … 01) 적힌 까닭은 4교시에 봅니다. 이번에는 안 되는 줄을 넣어 봅니다.
cat > bad.s <<'EOF'
.intel_syntax noprefix
mov QWORD PTR [rdi], QWORD PTR [rsi]
add rax, 0x123456789
mov [rdi], 1
lea rax, [rdi+rsi*3]
EOF
as bad.s -o bad.obad.s: Assembler messages:
bad.s:2: Error: operand type mismatch for `mov'
bad.s:3: Error: operand type mismatch for `add'
bad.s:4: Error: ambiguous operand size for `mov'
bad.s:5: Error: `[rdi+rsi*3]' is not a valid base/index expression비트를 옆으로 미는 시프트는 2의 거듭제곱 곱셈·나눗셈입니다. shl rax, k는 왼쪽으로 k비트 밀고 빈자리를 0으로 채우니 ×2ᵏ입니다. 오른쪽으로 미는 것은 둘입니다. shr은 빈자리를 0으로 채워 부호 없는 수를 2ᵏ로 나누고, sar는 빈자리를 맨 위 비트(부호)로 채워 부호 있는 수를 나눕니다. 마지막으로 밀려난 비트는 CF에 남습니다.
| rax = −7 (0xffff…fff9) | 결과 | 읽기 |
|---|---|---|
sar rax, 1 | 0xffff…fffc | −4 |
shr rax, 1 | 0x7fff…fffc | 9223372036854775804 |
sar의 −7 ÷ 2는 −3.5를 내림한 −4입니다. 그런데 C의 -7 / 2는 0 쪽으로 버려 −3입니다. 그래서 gcc -O2는 x / 2를 sar 한 줄로 끝내지 못합니다. 아래가 진짜 출력입니다.
0000000000000040 <half>: ; long half(long x) { return x / 2; }
40: mov rax,rdi
43: shr rax,0x3f ; 맨 위 비트(음수면 1)만 남긴다
47: add rax,rdi ; 음수면 1을 더해 둔다
4a: sar rax,1
4d: ret
0000000000000050 <uhalf>: ; unsigned long uhalf(unsigned long x)
50: mov rax,rdi
53: shr rax,1
56: ret몇 비트를 밀지는 즉값이나 cl로만 줄 수 있습니다(shl rax, dl은 as가 거절합니다). 맨 위 비트를 꺼내는 요령은 is_neg(int x)의 -O2 출력 mov eax,edi; shr eax,0x1f에도 있습니다. 32비트 값을 31비트 밀면 부호 비트 하나만 남아 x < 0이면 1입니다.
상수가 아닌 수를 곱할 때는 imul을 씁니다. 모양이 셋이고, 기계어로도 다른 명령어입니다.
| 모양 | 뜻 | 진짜 예 |
|---|---|---|
imul a, b | a = a × b | long mulxy(long x, long y) -O2의 imul rax,rsi, fact -O0의 imul rax,QWORD PTR [rbp-0x8] |
imul a, b, 즉값 | a = b × 즉값 | imul rax,rdi,0x7 (48 6b c7 07, gcc -Os의 x*7) |
imul b | rdx:rax = rax × b (128비트) | 이 강의의 흉내 CPU에는 없다 |
앞의 두 모양은 곱을 목적지 폭으로 자릅니다. 결과가 들어가지 않으면 CF = OF = 1이 될 뿐 멈추지 않습니다. 2교시에 예고한 fact(21)이 그렇습니다. fact(20) = 2432902008176640000에 21을 imul하면 흉내 CPU의 rax는 −4249290049419214848, CF = OF = 1입니다. 자른 아래 64비트는 부호 있는 곱셈과 부호 없는 곱셈이 똑같아서, gcc는 unsigned 곱셈에도 이 imul을 씁니다.
나눗셈 div, idiv는 rdx:rax를 통째로 나누는 특별한 모양이고 느린 편입니다. 그래서 gcc -O2는 상수 나눗셈을 위의 half처럼 되도록 시프트와 덧셈으로 바꿉니다.
직접 풀어 보기
예제 3-3
위 half를 x = −7(rdi)로 한 줄씩 따라가 rax를 쓰세요. C의 -7 / 2와 같은 값이 나오나요?
1단계. mov rax,rdi: rax = −7 = 0xffff_ffff_ffff_fff9.
2단계. shr rax,0x3f: 63비트를 밀면 맨 위 비트 하나만 남습니다. 음수라 rax = 1.
3단계. add rax,rdi: 1 + (−7) = −6.
4단계. sar rax,1: −6 ÷ 2 = −3. 나누어떨어지니 내림이든 버림이든 같습니다.
답: rax = −3, C의 -7 / 2와 같습니다. 음수일 때만 1을 미리 더해 두면 "내림"이 "0 쪽으로 버림"이 됩니다. x = 7이면 2단계가 0이라 그냥 7 sar 1 = 3입니다.
1교시의 add(a, b)를 gcc -O2로 컴파일하면 add 명령어가 없습니다. 대신 lea rax,[rdi+rsi*1] 한 줄입니다. lea(load effective address)는 대괄호 안의 식 기준 + 색인×배율 + 변위를 계산해 그 숫자를 목적지에 넣습니다. 이름은 "주소를 불러온다"지만 메모리는 읽지 않습니다. 대괄호가 원래 주소 계산용이라는 이야기는 4교시에 합니다.
그래서 lea는 쓸모 많은 계산기가 됩니다. 결과를 다른 레지스터에 넣을 수 있고, 덧셈 둘과 곱셈 하나를 한 번에 하며, 플래그를 건드리지 않습니다. 배율은 1, 2, 4, 8만 됩니다([rdi+rsi*3]은 as가 거절합니다). 그래서 x×3 = x + x×2, x×5 = x + x×4, x×9 = x + x×8이 한 줄입니다.
| 판 | 명령어 | 바이트 | rax |
|---|---|---|---|
| gcc -O0 | 10 | 28 | 38 |
| gcc -O2 | 2 | 6 | 38 |
| k를 모를 때 (-O2) | 3 | 9 | 38 |
long times5plus3(long x) { return x * 5 + 3; } // gcc -O2
times5plus3:0:48 8d 44 bf 03 lea rax,[rdi+rdi*4+0x3]5:c3 ret
rdi = x = 7, rsi = 5(imul 판에서만 쓴다). '다음'을 눌러 한 줄씩 실행해 보세요.
times5plus3(x) = x*5+3의 진짜 출력입니다. -O0은 C를 거의 그대로 옮깁니다. x를 4배(shl 2)하고 x를 더하고 3을 더합니다. 인자를 스택에 저장했다 다시 읽는 앞뒤 줄은 6교시에 봅니다.
; gcc -O0 (줄임: 앞뒤의 push rbp / mov [rbp-0x8],rdi / pop rbp / ret)
35: 48 8b 55 f8 mov rdx,QWORD PTR [rbp-0x8]
39: 48 89 d0 mov rax,rdx
3c: 48 c1 e0 02 shl rax,0x2
40: 48 01 d0 add rax,rdx
43: 48 83 c0 03 add rax,0x3
; gcc -O2
20: 48 8d 44 bf 03 lea rax,[rdi+rdi*4+0x3]
25: c3 ret-O2의 한 줄은 rdi + rdi×4 + 3, 곧 5x + 3입니다. imul 한 줄에 add 한 줄을 더한 것보다 lea 한 줄이 대개 빠르거나 같아서 컴파일러는 작은 상수 곱셈을 lea와 시프트, 덧셈으로 쪼갭니다. 상수를 모르면 쪼갤 수 없으니 실습대의 muladd처럼 imul을 씁니다.
직접 풀어 보기
예제 3-4
gcc -O2가 long mul13(long x) { return x * 13; }에 만든 두 줄입니다. x = 2일 때 줄마다 rax를 쓰고, 왜 13이 되는지 설명하세요.
lea rax,[rdi+rdi*2]
lea rax,[rdi+rax*4]1단계. rax = x + x×2 = 3x. x = 2면 6.
2단계. rax = x + (3x)×4 = 13x. x = 2면 2 + 24 = 26.
답: 6, 26. 13 = 1 + 3×4로 쪼갠 것입니다. 같은 요령으로 gcc -O2는 x×37을 lea rax,[rdi+rdi*8]; lea rax,[rdi+rax*4](37 = 1 + 9×4)로, x×7은 lea rax,[rdi*8+0x0]; sub rax,rdi(8x − x)로 만듭니다. x×7의 lea에 굳이 +0x0이 붙은 까닭은 기준 레지스터가 없는 주소 식은 변위 4바이트를 반드시 달아야 하는 인코딩 규칙 때문입니다(그 줄은 8바이트입니다).
직접 해 보기
상수 곱셈과 나눗셈이 -O2에서 무엇이 되는지 봅니다. mul.c:
long mul3(long x) { return x * 3; }
long mul7(long x) { return x * 7; }
long mul10(long x) { return x * 10; }
long mulxy(long x, long y) { return x * y; }
long half(long x) { return x / 2; }
unsigned long uhalf(unsigned long x) { return x / 2; }gcc -O2 -fcf-protection=none -c mul.c
objdump -d -M intel --no-show-raw-insn mul.o | grep -v 'nop\|xchg'0000000000000000 <mul3>:
0: lea rax,[rdi+rdi*2]
4: ret
0000000000000010 <mul7>:
10: lea rax,[rdi*8+0x0]
18: sub rax,rdi
1b: ret
0000000000000020 <mul10>:
20: lea rax,[rdi+rdi*4]
24: add rax,rax
27: ret
0000000000000030 <mulxy>:
30: mov rax,rdi
33: imul rax,rsi
37: ret
0000000000000040 <half>:
40: mov rax,rdi
43: shr rax,0x3f
47: add rax,rdi
4a: sar rax,1
4d: ret
0000000000000050 <uhalf>:
50: mov rax,rdi
53: shr rax,1
56: ret(맨 위 파일 이름 줄은 줄임.) 함수 사이를 16바이트 경계로 맞추려고 넣은 nop 줄은 grep으로 뺐습니다. -Os(크기 우선)로 바꾸면 mul7과 mul10이 imul rax,rdi,0x7, imul rax,rdi,0xa 한 줄이 되고, half는 idiv를 씁니다. 빠른 쪽과 짧은 쪽이 다릅니다.
movq %rdi, %rax처럼 순서가 반대이고 %가 붙습니다. objdump에 -M intel을 붙이는 이유입니다.0x1234_5678_9abc_def0입니다. mov ax, 0x1 뒤의 rax와, 이어서 mov eax, 0x1 뒤의 rax는?lea rax,[rdi+rdi*4] / add rax,raxsar rax, 2와 shr rax, 2를 각각 실행하면 rax는? C의 -21 / 4와 비교하세요.0x1234_5678_9abc_0001, eax에 쓰면 위 32비트가 0이 되어 **0x0000_0000_0000_0001**입니다.mul10). x = 7이면 35 → 70.0xffff_ffff_ffff_fffa), shr는 부호 없는 수로 보아 4611686018427387898(0x3fff_ffff_ffff_fffa)입니다. C의 -21 / 4는 0 쪽으로 버려 −5라 sar 한 줄과 다릅니다. gcc가 half처럼 보정 줄을 넣는 이유입니다.은재의 디버거 노트
범용 레지스터는 16개(rax … r15). rax ⊃ eax ⊃ ax ⊃ al, ah는 ax의 위 바이트. 이름이 달라도 칸은 하나.
32비트 이름에 쓰면 위 32비트가 0. 16·8비트에 쓰면 나머지는 그대로. 도현의 0xffffffff00000005는 틀렸다.
계산은 '목적지 = 목적지 (연산) 출처'. 메모리끼리 안 되고, 즉값은 대부분 32비트까지. 0 만들기는 xor eax, eax (2바이트).
shl = ×2ᵏ, shr = 부호 없는 ÷2ᵏ, sar = 부호 있는 ÷2ᵏ(내림). imul은 넘치면 잘라 버리고 CF·OF만 켠다.
lea는 메모리를 읽지 않는 계산기. x*5+3 → lea rax,[rdi+rdi*4+0x3] 한 줄(-O0은 명령어 10개).
발표 슬라이드에 한 줄: C의 x*5+3은 -O2에서 명령어 한 줄(lea)이 된다. 컴파일러는 곱셈까지 주소 계산기로 바꾼다.
다음 주(9월 29일)에는 오늘 lea 안에 숨어 있던 대괄호의 진짜 쓰임, 메모리 주소를 다룹니다. 도현이 메모리 속 long 하나를 들여다보다가 "숫자가 거꾸로 저장돼 있다"며 버그 신고를 하러 옵니다.
새 교시·새 강의 소식 받기
이 강의에 새 교시가 더해지거나 이어지는 강의가 열리면 이메일로 알려 드려요.
R
ReflexKR
눌러 보며 배우는 파이썬과 Reflex. 모든 예제는 Reflex 0.10.0에서 직접 실행해 확인했습니다.
ReflexKR은 배너 광고 없이 무료로 운영돼요. 도움이 되셨다면 커피 한 잔으로 응원해 주세요.
© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
질문과 답변
이 교시에서 궁금한 점을 남겨 주세요. 모든 언어 페이지의 질문이 함께 모입니다.