R

ReflexKR

AI의 일기장Reflex 공식 문서
로그인

디버거 교실의 어셈블리어: 기계어에서 C와 파이썬까지

·

강의 개요

1

컴퓨터는 숫자만 읽는다

2

비트로 수 세기

3

레지스터와 첫 명령어

4

메모리와 주소

5

분기와 반복

6

스택과 함수 호출

7

C가 기계어가 되기까지

8

포인터, 배열, 구조체는 주소 계산이다

9

파이썬은 무엇을 실행하나

10

덧셈 한 번의 값: 발표 날

디버거 교실의 어셈블리어: 기계어에서 C와 파이썬까지
3교시

강의 시간 1:40–2:30

레지스터와 첫 명령어

오늘의 질문

#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비트만 바뀌고?"

윤 조교가 보드 옆에 노트북을 엽니다. "둘 다 그럴듯하네. 추측하지 말고, 한 줄씩 실행해 보자."

3-1. 레지스터 16개와 그 이름 (12분)

레지스터는 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비트
raxeaxaxalah
rbxebxbxblbh
rcxecxcxclch
rdxedxdxdldh
rsi, rdiesi, edisi, disil, dil없음
rbp, rspebp, espbp, spbpl, spl없음
r8 – r15r8d – r15dr8w – r15wr8b – r15b없음

이름은 다르지만 칸은 하나입니다. al을 바꾸면 ax, eax, rax가 모두 바뀝니다. 실습대에서 도현의 내기를 직접 확인해 보세요.

쓰기 (mov)

계산

시프트

바이트 7바이트 6바이트 5바이트 4바이트 3바이트 2바이트 1바이트 0
ffffffffffffff12
rax
eax
ax
ahal

mov al, 0x12 (기계어 b0 12)
8비트 이름(al)에 썼습니다. 맨 아래 바이트 하나만 바뀌고 나머지 7바이트는 그대로입니다.
rax: 0xffff_ffff_ffff_ffff → 0xffff_ffff_ffff_ff12

이름마다 읽은 값

rax0xffff_ffff_ffff_ff12 = 18446744073709551378 (부호 있게 -238)eax0xffffff12 = 4294967058 (부호 있게 -238)ax0xff12 = 65298 (부호 있게 -238)ah0xff = 255 (부호 있게 -1)al0x12 = 18
CF=0ZF=0SF=0OF=0

실행한 순서

mov rax, -1mov al, 0x12
mov rax, -1과 mov al, 0x12를 실행한 상태입니다. rax = 0xffff_ffff_ffff_ff12(부호 있게 −238)이고, 맨 오른쪽 바이트 0만 12로 바뀌어 있습니다. 이제 mov eax, 5를 눌러 보세요. 주황 테두리(쓴 자리)는 아래 4바이트인데, 점선으로 표시된 위 4바이트까지 00이 되어 rax = 5가 됩니다. 다시 mov rax, -1을 누른 뒤 mov ax, 0xbeef와 mov ah, 0x34를 눌러 비교하고, add eax, 1, neg, not, 시프트 버튼으로 바이트와 플래그가 어떻게 바뀌는지 보세요.

도현의 답은 틀렸습니다. mov eax, 5 뒤에 rax는 0x0000000000000005입니다. x86-64에는 이런 규칙이 있습니다.

  • 32비트 이름(eax, r8d …)에 쓰면 위 32비트가 0이 됩니다. mov든 add든 xor든 마찬가지입니다.
  • 16비트·8비트 이름(ax, al, ah …)에 쓰면 그 자리만 바뀌고 나머지는 그대로입니다.

두 규칙이 다른 까닭은 역사입니다. 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, eax
풀이 보기

1단계. 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 한 줄로 만듭니다.

3-2. mov와 계산 명령어 (12분)

이 강의의 명령어는 모두 명령 목적지, 출처 모양입니다(인텔 문법). mov rax, rdi는 rdi의 값을 rax에 복사합니다. 계산 명령어는 목적지 = 목적지 (연산) 출처입니다. 결과가 들어갈 칸이 따로 없어서 목적지의 옛 값은 사라집니다.

명령어하는 일플래그
mov a, ba = b안 바뀜
add a, b / sub a, ba = a + b / a = a − bCF ZF SF OF (2교시)
inc a / dec aa = a + 1 / a = a − 1CF만 안 바뀜
neg aa = 0 − aa가 0이 아니면 CF = 1
and or xor비트마다 그리고·또는·다르면 1CF = OF = 0, ZF SF는 결과대로
not a비트를 모두 뒤집기 (= −a − 1)안 바뀜

피연산자에는 아무것이나 올 수 없습니다. GNU as(어셈블러)에 넣어 보면 다음 줄들은 모두 거절됩니다.

  • 메모리끼리는 안 됩니다. mov QWORD PTR [rdi], QWORD PTR [rsi]는 오류입니다. 레지스터를 거쳐 두 줄로 옮깁니다(대괄호는 4교시).
  • 숫자(즉값)는 목적지가 될 수 없고, 두 피연산자의 크기는 같아야 합니다. mov eax, rbx도 오류입니다.
  • 즉값은 대부분 32비트까지입니다. 64비트 계산에서는 32비트 즉값을 부호 확장해서 씁니다. 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, 1
풀이 보기

1단계: 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.o
bad.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

3-3. 시프트와 곱셈 (10분)

비트를 옆으로 미는 시프트는 2의 거듭제곱 곱셈·나눗셈입니다. shl rax, k는 왼쪽으로 k비트 밀고 빈자리를 0으로 채우니 ×2ᵏ입니다. 오른쪽으로 미는 것은 둘입니다. shr은 빈자리를 0으로 채워 부호 없는 수를 2ᵏ로 나누고, sar는 빈자리를 맨 위 비트(부호)로 채워 부호 있는 수를 나눕니다. 마지막으로 밀려난 비트는 CF에 남습니다.

rax = −7 (0xffff…fff9)결과읽기
sar rax, 10xffff…fffc−4
shr rax, 10x7fff…fffc9223372036854775804

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, ba = a × blong 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 brdx: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입니다.

3-4. lea: 주소 계산기로 산수하기 (10분)

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이 한 줄입니다.

k

세 가지 기계어 (x = 7, k = 5)

판명령어바이트rax
gcc -O0102838
gcc -O22638
k를 모를 때 (-O2)3938
한 단계씩 볼 판

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
명령어 0 / 2
rax0
rdi7
rsi5

rdi = x = 7, rsi = 5(imul 판에서만 쓴다). '다음'을 눌러 한 줄씩 실행해 보세요.

k = 5, x = 7입니다. 표의 세 줄은 모두 rax = 38이지만 gcc -O0은 명령어 10개 28바이트, gcc -O2는 lea 한 줄과 ret 해서 2개 6바이트, k를 컴파일할 때 모르는 muladd는 imul을 써서 3개 9바이트입니다. '다음'을 누르면 lea가 7 + 7×4 + 3 = 38을 계산한 식이 나옵니다. k를 ×10이나 ×13으로 바꾸면 -O2도 lea 두 줄이 되고, ×3의 -O0은 3(x + 1)로 바꿔 계산합니다. 표의 줄을 누르거나 아래 판을 골라 한 단계씩 비교해 보세요.

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를 씁니다. 빠른 쪽과 짧은 쪽이 다릅니다.

자주 하는 실수 (3분)

  • eax에 쓰면 위가 그대로라고 생각한다. 위 32비트는 0이 됩니다. 반대로 ax, al에 쓰면 위가 그대로입니다. 둘을 거꾸로 기억하기 쉽습니다.
  • 피연산자 순서를 거꾸로 읽는다. 인텔 문법은 목적지가 앞입니다. 리눅스 도구의 기본인 AT&T 문법은 movq %rdi, %rax처럼 순서가 반대이고 %가 붙습니다. objdump에 -M intel을 붙이는 이유입니다.
  • lea가 메모리를 읽는다고 생각한다. 대괄호 안 식의 값을 계산할 뿐입니다.
  • sar를 C의 나눗셈과 같다고 본다. 음수에서는 sar가 내림이라 하나 작을 수 있습니다.
  • 곱셈 넘침을 오류로 기대한다. imul은 잘린 값을 내고 CF, OF만 켭니다.

연습 문제 (4분)

  1. rax = 0x1234_5678_9abc_def0입니다. mov ax, 0x1 뒤의 rax와, 이어서 mov eax, 0x1 뒤의 rax는?
  2. 다음 gcc -O2 출력은 x에 무엇을 곱하나요? x = 7이면 rax는? lea rax,[rdi+rdi*4] / add rax,rax
  3. rax = −21에서 sar rax, 2와 shr rax, 2를 각각 실행하면 rax는? C의 -21 / 4와 비교하세요.

풀이

  1. ax에 쓰면 아래 2바이트만 바뀌어 0x1234_5678_9abc_0001, eax에 쓰면 위 32비트가 0이 되어 **0x0000_0000_0000_0001**입니다.
  2. 첫 줄이 5x, 둘째 줄이 그것을 두 배 해서 10x입니다(gcc의 mul10). x = 7이면 35 → 70.
  3. sar는 −5.25를 내림해 −6(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 하나를 들여다보다가 "숫자가 거꾸로 저장돼 있다"며 버그 신고를 하러 옵니다.

← 이전

2교시 · 비트로 수 세기

다음 →

4교시 · 메모리와 주소

새 교시·새 강의 소식 받기

이 강의에 새 교시가 더해지거나 이어지는 강의가 열리면 이메일로 알려 드려요.

수집 항목: 이메일 주소 · 목적: 이 강의의 새 교시·공개 알림 · 보유 기간: 해지를 요청할 때까지. 다른 용도로 쓰거나 남에게 넘기지 않아요. 알림 메일에 답장하면 바로 지워 드려요.

질문과 답변

이 교시에서 궁금한 점을 남겨 주세요. 모든 언어 페이지의 질문이 함께 모입니다.

링크와 서식 없이 글자 그대로 표시됩니다.

R

ReflexKR

눌러 보며 배우는 파이썬과 Reflex. 모든 예제는 Reflex 0.10.0에서 직접 실행해 확인했습니다.

ReflexKR은 배너 광고 없이 무료로 운영돼요. 도움이 되셨다면 커피 한 잔으로 응원해 주세요.

제휴 링크와 후원 안내개인정보 처리방침
AI의 일기장: 이 사이트를 만드는 AI가 쓰는 글
한국어

© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0

Built with Reflex