R

ReflexKR

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

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

·

강의 개요

1

컴퓨터는 숫자만 읽는다

2

비트로 수 세기

3

레지스터와 첫 명령어

4

메모리와 주소

5

분기와 반복

6

스택과 함수 호출

7

C가 기계어가 되기까지

8

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

9

파이썬은 무엇을 실행하나

10

덧셈 한 번의 값: 발표 날

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

강의 시간 0:00–0:50

컴퓨터는 숫자만 읽는다

오늘의 질문

#01 · 첫 디버거 교실

컴퓨터는 숫자만 읽는다

질문

은재: "1부터 1억까지 더하는 데 파이썬은 4.4초, C는 0.027초가 걸렸어요. 둘 다 결국 CPU가 돌리는 거잖아요. 그럼 프로그램은 컴퓨터 안에서 대체 어떤 모양이에요?"

오늘 알아낼 것

C 함수 add(a, b)를 컴파일한 25바이트를 메모리에 놓고, CPU가 인출–해독–실행을 아홉 번 되풀이해 rax = 42를 만드는 과정을 한 단계씩 따라간다. 바이트를 명령어로 읽는 법(REX, 옵코드, ModRM)을 익히고 기계어, 어셈블리어, ISA가 각각 무엇을 가리키는지 정리한다.

새솔대 공학관 5층 실습실 · 화요일 밤 디버거 교실

9월 8일 화요일 오후, 시스템 프로그래밍 첫 시간입니다. 강 교수는 출석을 부르자마자 화면에 한 줄을 띄웁니다. "기말 과제. 같은 일을 하는 파이썬 코드와 C 코드가 왜 속도가 다른지, 기계어 수준에서 설명할 것. 발표는 11월 10일." 질문은 받지 않고 바로 강의로 넘어갑니다.

그날 밤 7시, 공학관 5층 실습실. 같은 팀이 된 은재와 도현이 '디버거 교실'이라고 적힌 문을 엽니다. 둘은 오기 전에 일단 재 보았습니다. 1부터 1억까지 더하는 함수를 파이썬과 C로 똑같이 짜서 돌렸더니 파이썬은 4.4초, gcc -O2로 컴파일한 C는 0.027초였습니다.

"C는 컴파일하니까 빠른 거죠. 끝." 도현이 의자를 뒤로 젖힙니다. 모니터 두 대를 켜 둔 윤 조교가 묻습니다. "컴파일하면 뭐가 되는데?" "기계어요." "기계어는 어떻게 생겼어?" 이번에는 둘 다 대답이 없습니다.

"추측하지 말고, 한 줄씩 실행해 보자. 오늘은 제일 작은 함수부터."

1-1. 같은 덧셈, 160배 (7분)

두 사람이 잰 함수는 이렇습니다. 하는 일은 한 글자도 다르지 않습니다.

def sum_to(n):
    s = 0
    i = 1
    while i <= n:
        s += i
        i += 1
    return s
long sum_to(long n) {
    long s = 0;
    for (long i = 1; i <= n; i++) {
        s += i;
    }
    return s;
}

실습실 컴퓨터(인텔 12세대 Core i5, 윈도 11과 WSL의 우분투 24.04)에서 n = 1억으로 두세 번씩 잰 값입니다. 답은 모두 5000000050000000으로 같습니다.

방법시간한 바퀴
파이썬 3.14 while약 4.4 s약 44 ns
C, gcc -O0 (최적화 없음)약 0.20 s약 2.0 ns
C, gcc -O2약 0.027 s약 0.27 ns

파이썬 ÷ C -O2는 약 160배입니다. 같은 C 코드도 최적화를 끄면 약 7배 느려집니다. 그러니 '컴파일 언어라서 빠르다'는 말로는 반만 설명됩니다.

한 바퀴 시간으로 바꿔 보면 감이 옵니다. 요즘 CPU는 1초에 대략 30억–50억 번 박자(클럭)를 칩니다. 박자 한 번이 0.2–0.3 ns쯤이니, C -O2는 수 하나를 더하는 데 박자 한 번 안팎을 씁니다. 파이썬은 같은 일에 박자 130–220번쯤을 씁니다. 그 시간에 CPU가 무엇을 하는지가 이 강의의 질문입니다.

윤 조교의 한마디

잰 시간은 늘 환경과 함께 적어. 같은 컴퓨터에서 WSL의 파이썬 3.12로 돌리면 while이 약 3.3초야. 버전과 빌드만 바꿔도 30% 넘게 달라져. 그리고 한 번만 재지 말고 두세 번 재서 비슷한지 봐.

직접 해 보기

시간 재기 프로그램은 tools/asm/에 있습니다. 파이썬은 윈도의 파이썬 3.14.2로, C는 WSL에서 컴파일해 돌렸습니다. 잴 때마다 조금씩 다릅니다.

python py_bench.py
3.14.2
sum_to     s=5000000050000000 4.346 s 43.46 ns/iter
sum_for    s=5000000050000000 2.969 s 29.69 ns/iter
<lambda>   s=5000000050000000 0.789 s 7.89 ns/iter

sum_to가 위의 while, sum_for는 for i in range(...), <lambda>는 내장 함수 sum(range(1, n + 1))입니다. 셋의 차이는 10교시에 설명합니다.

gcc -O2 -fcf-protection=none bench.c sum.c -o /tmp/bench2 && /tmp/bench2
gcc -O0 -fcf-protection=none bench.c sum.c -o /tmp/bench0 && /tmp/bench0
n=100000000 s=5000000050000000 0.0270 s 0.270 ns/iter
n=100000000 s=5000000050000000 0.2037 s 2.037 ns/iter

과제는 이 차이를 기계어로 설명하는 것입니다. 그러려면 먼저 기계어가 무엇인지 알아야 합니다. 앞으로 여덟 번은 C가 어떤 기계어가 되는지를, 마지막 두 번은 파이썬이 무엇을 실행하는지를 봅니다. 오늘은 반복문도 없는 가장 작은 함수 하나로 시작합니다.

1-2. 메모리에는 바이트만 있다 (9분)

메모리는 아주 긴 바이트의 줄입니다. 바이트 하나는 0–255 사이의 수 하나이고, 칸마다 0, 1, 2, …로 매긴 주소가 있습니다. 숫자, 글자, 그림, 그리고 프로그램까지 메모리에 들어가는 것은 모두 이런 바이트입니다.

프로그램이 바이트라는 것이 오늘의 핵심입니다. 명령어와 데이터를 같은 메모리에 함께 두고 CPU가 명령어를 메모리에서 읽어 실행하는 방식을 저장 프로그램 방식이라 부릅니다. 지금 쓰는 컴퓨터는 거의 모두 이 방식입니다.

윤 조교가 고른 함수는 이것입니다.

long add(long a, long b) { return a + b; }

gcc -O0으로 컴파일하고 objdump로 펼친 진짜 출력(tools/asm/out/first.O0.bytes)입니다.

0000000000000000 <add>:
   0:	55                   	push   rbp
   1:	48 89 e5             	mov    rbp,rsp
   4:	48 89 7d f8          	mov    QWORD PTR [rbp-0x8],rdi
   8:	48 89 75 f0          	mov    QWORD PTR [rbp-0x10],rsi
   c:	48 8b 55 f8          	mov    rdx,QWORD PTR [rbp-0x8]
  10:	48 8b 45 f0          	mov    rax,QWORD PTR [rbp-0x10]
  14:	48 01 d0             	add    rax,rdx
  17:	5d                   	pop    rbp
  18:	c3                   	ret

세 칸으로 읽습니다. 맨 왼쪽은 함수 첫 바이트에서 몇 바이트 떨어졌는지(16진수), 가운데는 메모리에 실제로 들어 있는 바이트, 오른쪽은 그 바이트를 사람이 읽기 좋게 풀어 쓴 글자입니다. CPU가 보는 것은 가운데 칸뿐입니다. 마지막 c3이 0x18번째 바이트이므로 add는 모두 0x19 = 25바이트, 명령어 9개입니다.

이 강의의 흉내 CPU는 코드를 주소 0x401000부터 놓습니다. 그래서 push rbp의 55는 0x401000에, add rax,rdx의 첫 바이트 48은 0x401014에 있습니다. 화면에는 objdump처럼 첫 명령어에서의 거리(0:, 1:, 4: …)만 씁니다.

직접 풀어 보기

예제 1-1

add의 25바이트가 0x401000부터 놓여 있습니다. 바이트 5d는 어느 주소에 있고, 무슨 명령어의 일부일까요? 그 바로 앞 명령어는 몇 바이트이고 어디서 시작하나요?

풀이 보기

1단계: 거리 읽기. 목록에서 5d는 17: 줄에 있습니다. 0x401000 + 0x17 = 0x401017입니다.

2단계: 명령어. 5d 한 바이트가 통째로 pop rbp입니다. 바이트 하나짜리 명령어도 있습니다.

3단계: 앞 명령어. 바로 앞은 14:의 48 01 d0(add rax,rdx), 3바이트이고 0x401014에서 시작합니다. 0x14 + 3 = 0x17이라 빈틈없이 이어집니다.

답: 0x401017, pop rbp. 앞 명령어는 0x401014에서 시작하는 3바이트 add rax,rdx. 명령어는 메모리에 틈 없이 붙어 있고, 길이를 알아야 다음 명령어가 어디서 시작하는지 압니다.

1-3. 인출–해독–실행 (14분)

CPU가 하는 일은 놀랄 만큼 단순합니다. 세 가지를 끝없이 되풀이합니다. 실습대에서 add(2, 40)을 한 단계씩 넘겨 보세요.

1. 인출→2. 해독→3. 실행실행한 명령어 2 / 9

메모리 0x401000–0x401018: add의 기계어 25바이트

550x401000
481
89
e5
484
89
7d
f8
488
89
75
f0
48c
8b
55
f8
4810
8b
45
f0
4814
01
d0
5d17
c318

rip = 0x401004

48 89 7d f8 → mov QWORD PTR [rbp-0x8],rdi

해독: 48 89 7d f8 = mov QWORD PTR [rbp-0x8],rdi. 길이 4바이트이므로 다음 명령어는 0x401008에 있습니다.

단계 8 / 27

디스어셈블러가 붙인 이름표

add:
0:55 push rbp
1:48 89 e5 mov rbp,rsp
4:48 89 7d f8 mov QWORD PTR [rbp-0x8],rdi
8:48 89 75 f0 mov QWORD PTR [rbp-0x10],rsi
c:48 8b 55 f8 mov rdx,QWORD PTR [rbp-0x8]
10:48 8b 45 f0 mov rax,QWORD PTR [rbp-0x10]
14:48 01 d0 add rax,rdx
17:5d pop rbp
18:c3 ret

레지스터

rdi2
rsi40
rax0
rdx0
rbp0x7fffffffdff0
rsp0x7fffffffdff0
rip0x401004

스택 메모리 (8바이트씩)

…dff80x401ff0돌아갈 곳 (프로그램 끝)
…dff00x0저장한 rbprbprsp
…dfe80[rbp-0x8] = a
…dfe00[rbp-0x10] = b
add(2, 40)의 25바이트가 0x401000부터 놓여 있고(rdi = 2, rsi = 40), 8단계까지 와 있습니다. 세 번째 명령어 48 89 7d f8을 해독한 참이라, 길이 4바이트를 알고 다음 명령어가 0x401008에 있다는 것까지 압니다. '다음'을 누르면 실행되어 스택의 [rbp-0x8] 칸에 2가 써지고 rip가 0x401008로 바뀝니다. 27단계까지 가면 rax = 42, 명령어 9개입니다. '처음'으로 돌아가 '재생'을 눌러 rip가 25바이트 위를 건너뛰며 가는 모습을 보세요.

CPU 안에는 rip(instruction pointer)라는 칸이 있어서 다음에 실행할 명령어의 주소를 담고 있습니다. 한 바퀴는 이렇습니다.

  1. 인출(fetch): rip가 가리키는 주소에서 바이트를 읽어 온다.
  2. 해독(decode): 바이트를 보고 무슨 명령어인지, 몇 바이트짜리인지 알아낸다. rip를 그 길이만큼 늘린다.
  3. 실행(execute): 명령어가 시키는 대로 레지스터나 메모리를 바꾼다.

rip는 '지금' 명령어가 아니라 '다음' 명령어를 가리킵니다. 보통은 길이만큼 늘어나서 바로 뒤 명령어로 가지만, 점프와 call, ret는 rip에 다른 주소를 넣습니다. if와 반복문, 함수 호출은 모두 이 방법으로 만듭니다(5·6교시). 맨 끝의 ret도 그렇습니다. 흉내 CPU는 처음에 스택에 '프로그램 끝' 주소 0x401ff0을 넣어 두었고, ret가 그 주소를 rip에 넣으면서 멈춥니다.

리눅스의 약속(System V 호출 규약)으로 첫째 인자 a는 rdi, 둘째 인자 b는 rsi에 담겨 오고, 돌려줄 값은 rax에 둡니다. -O0 코드는 인자를 일단 메모리(스택의 [rbp-0x8], [rbp-0x10])에 썼다가 다시 읽어서 더합니다. 최적화를 하지 않으면 변수마다 메모리 자리를 하나씩 주기 때문입니다(7교시).

윤 조교의 한마디

실습대 스택 칸을 보면 a와 b가 rsp보다 아래 주소에 써져. 이 함수는 다른 함수를 부르지 않아서, 리눅스 약속이 rsp 아래 128바이트(red zone)를 rsp를 옮기지 않고 써도 된다고 허락하거든. 스택 이야기는 6교시에 제대로 하자. 그리고 진짜 디버거 gdb에서는 si(stepi)를 한 번 칠 때마다 명령어가 하나씩 실행돼. 실습대의 '다음' 세 번이 si 한 번이야.

직접 풀어 보기

예제 1-2

add(2, 40)에서 여섯 번째 명령어 mov rax,QWORD PTR [rbp-0x10]까지 실행했습니다. 이때 rax, rdx, rip는 얼마일까요?

풀이 보기

1단계: 메모리에 무엇이 있나. 세 번째·네 번째 명령어가 [rbp-0x8]에 rdi = 2를, [rbp-0x10]에 rsi = 40을 썼습니다.

2단계: 다섯·여섯 번째. mov rdx,[rbp-0x8]로 rdx = 2, mov rax,[rbp-0x10]으로 rax = 40.

3단계: rip. 여섯 번째 명령어는 0x10에서 시작하는 4바이트이므로 다음은 0x14, 곧 0x401014(add rax,rdx)입니다.

답: rax = 40, rdx = 2, rip = 0x401014. 실습대에서 18단계(여섯 번째 명령어의 실행)와 같습니다. 다음 add rax,rdx 한 번으로 rax = 42가 됩니다.

1-4. 기계어와 어셈블리어 (12분)

48 89 7d f8이 어떻게 mov QWORD PTR [rbp-0x8],rdi가 될까요? x86-64 명령어는 정해진 칸을 앞에서부터 채운 모양입니다. 실습대에서 명령어를 바꿔 가며 칸이 어떻게 나뉘는지 보세요.

명령어 고르기

48
REX 앞붙이
89
옵코드
7d
ModRM
f8
변위 disp8

디스어셈블 결과

mov QWORD PTR [rbp-0x8],rdi

48 89 7d f8 · 4바이트 · 출처: first.O0 add+0x4

비트 풀이

REX
0100
0100
1
W
R
0
X
0
B
ModRM
01
mod +disp8
reg rdi
101
rm rbp

reg 비트(굵은 테두리)를 눌러 바꿔 보세요.

칸 풀이

REX 앞붙이480100 WRXB: W=1 (64비트 피연산자), R=0, X=0, B=0
옵코드89MOV r/m ← reg (reg 칸이 원본, rm 칸이 목적지)
ModRM7dmod=01 (메모리 + 8비트 변위), reg=111, rm=101
변위 disp8f80xf8 = -8
기본은 add의 세 번째 명령어 48 89 7d f8입니다. 48은 REX 앞붙이(W = 1, 64비트), 89는 옵코드(mov, reg 칸 → rm 칸), 7d는 ModRM(mod = 01: 메모리 + 8비트 변위, reg = 111: rdi, rm = 101: rbp), f8은 변위 −8. 굵은 테두리의 reg 비트를 눌러 111을 110으로 바꾸면 48 89 75 f8 = mov QWORD PTR [rbp-0x8],rsi가 됩니다. 위의 단추로 진짜 명령어 8개(1바이트 push rbp부터 8바이트 mov [rbp-0x10],0까지, SIB가 있는 lea, 점프 jle)를 골라 보세요.

x86-64 명령어 하나는 이런 칸으로 되어 있습니다. 대괄호 칸은 없을 수도 있습니다.

[앞붙이] [REX] 옵코드(1–3바이트) [ModRM] [SIB] [변위 0/1/4바이트] [즉값 0/1/2/4/8바이트]
  • REX(0x40–0x4f): 0100 WRXB. W = 1이면 64비트로 계산합니다. R, X, B는 레지스터 번호에 8을 더해 r8–r15를 쓰게 합니다.
  • 옵코드: 무슨 일을 할지. 같은 mov도 방향과 피연산자 종류에 따라 89, 8b, c7, b8 등으로 갈립니다.
  • ModRM: mod(2비트) reg(3비트) rm(3비트). mod = 11이면 rm도 레지스터, 아니면 rm은 메모리 주소의 바탕 레지스터입니다(예외 둘: rm = 100이면 SIB가 뒤따르고, mod = 00이면서 rm = 101이면 rip 기준 주소). 레지스터 번호는 rax 0, rcx 1, rdx 2, rbx 3, rsp 4, rbp 5, rsi 6, rdi 7.
  • SIB: 주소가 [바탕 + 색인 × 배율] 모양일 때 붙습니다(4교시).
  • 변위·즉값: 주소에 더할 수와 명령어에 박힌 수. 여러 바이트면 작은 자리 바이트가 먼저 옵니다(리틀 엔디언, 4교시).

그래서 x86-64 명령어는 1바이트에서 15바이트까지 길이가 제각각입니다. add 안에서도 1, 3, 4바이트가 섞여 있습니다. CPU가 해독해 보기 전에는 다음 명령어가 어디서 시작하는지 모르는 이유입니다.

기계어는 CPU가 읽는 바이트 자체이고, 어셈블리어는 그 바이트에 붙인 사람용 이름표입니다. 명령어 하나에 이름표 한 줄이 대응합니다. 이름표를 바이트로 바꾸는 프로그램이 어셈블러(as), 바이트를 이름표로 되돌리는 프로그램이 디스어셈블러(objdump -d)입니다. gcc는 C를 어셈블리어로 번역한 뒤 어셈블러를 불러 바이트를 만듭니다(7교시).

이름표 문법은 두 가지가 있습니다. 이 강의는 mov rbp,rsp처럼 목적지를 앞에 쓰는 인텔 문법(objdump -M intel)을 씁니다. 리눅스 도구의 기본값인 AT&T 문법은 같은 명령어를 mov %rsp,%rbp로 순서를 뒤집어 씁니다.

직접 풀어 보기

예제 1-3

add의 네 번째 명령어 48 89 75 f0을 실습대 없이 손으로 풀어 보세요.

풀이 보기

1단계: REX. 0x48 = 0100 1000. W = 1이니 64비트, R = X = B = 0.

2단계: 옵코드. 0x89는 'reg 칸의 레지스터를 rm 칸에 쓴다'는 mov입니다.

3단계: ModRM. 0x75 = 01 110 101. mod = 01이면 메모리 + 8비트 변위, reg = 110은 rsi, rm = 101은 rbp.

4단계: 변위. 0xf0을 부호 있는 8비트로 읽으면 −16, 곧 −0x10입니다(2교시).

답: mov QWORD PTR [rbp-0x10],rsi, 4바이트. objdump 출력과 같습니다. 세 번째 명령어 48 89 7d f8과는 reg 칸(rdi ↔ rsi)과 변위(−8 ↔ −16)만 다릅니다.

같은 C 코드라도 기계어는 컴파일러가 정합니다. 최적화를 켜면(-O2) add는 이렇게 바뀝니다(first.O2.bytes).

0000000000000000 <add>:
   0:	48 8d 04 37          	lea    rax,[rdi+rsi*1]
   4:	c3                   	ret

명령어 9개, 25바이트가 2개, 5바이트가 되었습니다. lea는 원래 주소를 계산하는 명령어인데, 컴파일러는 이것을 '더해서 다른 레지스터에 넣기'로 씁니다(3교시). 메모리에 쓰고 다시 읽는 일도 사라졌습니다.

이렇게 CPU가 알아듣는 명령어의 목록, 레지스터, 바이트 모양을 정한 약속을 ISA(명령어 집합 구조)라 부릅니다. 이 강의는 대부분의 PC와 서버가 쓰는 x86-64를 봅니다. 스마트폰과 요즘 맥은 ARM64를 씁니다. ARM64는 명령어가 모두 4바이트이고, 이 add는 add x0, x0, x1과 ret 두 줄이 됩니다. 다르게 생겼지만 같은 생각입니다. 인자를 레지스터로 받고, 더하고, 돌아갑니다. 같은 x86-64라도 윈도는 인자를 rcx, rdx에 담는 다른 약속을 씁니다.

직접 풀어 보기

예제 1-4

sum_to를 -O1로 컴파일한 코드 안에 48 01 c2라는 3바이트가 있습니다. add의 48 01 d0(add rax,rdx)과 한 바이트만 다릅니다. 무슨 명령어일까요?

풀이 보기

1단계: 같은 것. 48(64비트)과 01(rm ← rm + reg인 add)은 같습니다.

2단계: ModRM 비교. 0xd0 = 11 010 000: reg = rdx, rm = rax. 0xc2 = 11 000 010: reg = rax, rm = rdx.

3단계: 방향. 옵코드 01은 결과를 rm 칸에 씁니다. 그러니 rdx ← rdx + rax.

답: add rdx,rax. 실제로 sum.O1.bytes의 0x13에 있는 반복문 첫 줄이고, s(rdx)에 i(rax)를 더하는 명령어입니다(5교시). 레지스터 두 개의 자리만 바꿔도 바이트가 달라집니다.

직접 해 보기

WSL(우분투 24.04, gcc 13.3)에서 직접 컴파일하고 펼쳐 봅니다. -c는 링크하지 않고 기계어 파일(.o)만 만듭니다.

cd tools/asm
gcc -O0 -c first.c -o /tmp/first.o
objdump -d -M intel /tmp/first.o | head -20
/tmp/first.o:     file format elf64-x86-64


Disassembly of section .text:

0000000000000000 <add>:
   0:	f3 0f 1e fa          	endbr64
   4:	55                   	push   rbp
   5:	48 89 e5             	mov    rbp,rsp
   8:	48 89 7d f8          	mov    QWORD PTR [rbp-0x8],rdi
   c:	48 89 75 f0          	mov    QWORD PTR [rbp-0x10],rsi
  10:	48 8b 55 f8          	mov    rdx,QWORD PTR [rbp-0x8]
  14:	48 8b 45 f0          	mov    rax,QWORD PTR [rbp-0x10]
  18:	48 01 d0             	add    rax,rdx
  1b:	5d                   	pop    rbp
  1c:	c3                   	ret

000000000000001d <add32>:
  1d:	f3 0f 1e fa          	endbr64

맨 앞에 본문에 없던 4바이트 endbr64가 붙었습니다. 우분투의 gcc는 기본으로 함수 입구마다 이 표시를 넣습니다. 엉뚱한 곳으로 점프하는 공격을 막는 CPU 기능(CET)이 '여기는 들어와도 되는 곳'으로 알아보는 표시이고, 그 기능이 없는 CPU에서는 아무 일도 하지 않습니다. 강의에서는 -fcf-protection=none으로 빼고 봅니다.

gcc -O0 -fcf-protection=none -c first.c -o /tmp/first.o
objdump -d -M intel /tmp/first.o | head -20
(줄임)
0000000000000000 <add>:
   0:	55                   	push   rbp
   1:	48 89 e5             	mov    rbp,rsp
   4:	48 89 7d f8          	mov    QWORD PTR [rbp-0x8],rdi
(줄임)
  18:	c3                   	ret

-M intel을 빼면 AT&T 문법으로 나옵니다. -O2로 바꿔 lea 한 줄이 되는 것도 확인해 보세요.

자주 하는 실수 (3분)

  • 어셈블리어가 기계어보다 한 단계 아래라고 생각한다. 같은 것입니다. 어셈블리어는 기계어 바이트에 붙인 이름일 뿐이고, 한 줄이 명령어 하나입니다.
  • rip가 지금 실행 중인 명령어를 가리킨다고 본다. 해독이 끝나면 rip는 이미 다음 명령어의 주소입니다.
  • 명령어 하나 = 1바이트(또는 4바이트)로 센다. x86-64는 1–15바이트입니다. 4바이트 고정은 ARM64입니다.
  • C 한 줄 = 명령어 하나로 센다. return a + b;는 -O0에서 명령어 3개(mov, mov, add), -O2에서 1개(lea)입니다. 컴파일러와 옵션에 따라 달라집니다.
  • mov는 옮긴다(원본이 빈다)고 생각한다. mov는 복사입니다. mov rbp,rsp 뒤에도 rsp는 그대로입니다.

연습 문제 (5분)

  1. add(-O0)의 명령어 9개를 길이별로 나누세요. 가장 긴 명령어는 몇 바이트이고 몇 개인가요?
  2. 48 8b 45 f0과 48 89 45 f0은 옵코드 한 바이트만 다릅니다. 각각 무슨 명령어이고, 하는 일은 어떻게 다른가요?
  3. 같은 add를 rdi = 5, rsi = −3으로 실행하면 rax는 얼마이고, 명령어는 몇 개 실행될까요?

풀이

  1. 1바이트 3개(push rbp, pop rbp, ret), 3바이트 2개(mov rbp,rsp, add rax,rdx), 4바이트 4개(mov로 메모리에 쓰고 읽는 넷). 1×3 + 3×2 + 4×4 = 25. 가장 긴 것은 4바이트, 4개.
  2. 0x8b는 rm(메모리) → reg 방향이라 mov rax,QWORD PTR [rbp-0x10](메모리에서 읽기), 0x89는 reg → rm 방향이라 mov QWORD PTR [rbp-0x10],rax(메모리에 쓰기)입니다. ModRM 0x45 = 01 000 101(reg = rax, rm = rbp + 8비트 변위)는 같습니다.
  3. rax = 2, 명령어 9개. 이 함수에는 점프가 없어서 어떤 인자든 9개를 차례로 한 번씩 실행합니다. −3이 기계 안에서 어떤 바이트인지는 다음 주에 봅니다.

은재의 디버거 노트

  • 프로그램은 메모리에 놓인 바이트다. add(-O0)는 명령어 9개, 25바이트(0x401000–0x401018). -O2는 lea + ret, 5바이트.

  • CPU는 인출(rip가 가리키는 바이트 읽기) → 해독(무슨 명령어, 몇 바이트) → 실행을 끝없이 되풀이한다. rip는 '다음' 명령어의 주소.

  • 인자는 rdi, rsi로 받고 결과는 rax로 돌려준다(리눅스 System V). add(2, 40) → rax = 42.

  • x86-64 명령어는 [REX][옵코드][ModRM][SIB][변위][즉값], 1–15바이트. 48 89 7d f8 = 64비트 · mov · (rdi → [rbp+변위]) · −8.

  • 어셈블리어는 기계어 바이트의 이름표. 어셈블러는 이름표 → 바이트, objdump는 바이트 → 이름표. ISA는 그 약속이고, x86-64와 ARM64는 다르게 생겼지만 같은 생각.

  • 발표 슬라이드에 한 줄: C의 add는 CPU에게 25바이트짜리 숫자 줄일 뿐이고, CPU는 그 숫자를 rip를 따라 하나씩 꺼내 실행한다.

다음 주 화요일(9월 15일)에는 바이트 안을 들여다봅니다. 도현이 아두이노에서 버튼 횟수를 세다 겪은 일, 127 다음에 −128이 나온 이야기로 시작합니다. 오늘 예제의 f8이 왜 −8인지도 그때 풀립니다.

← 이전

강의 개요

다음 →

2교시 · 비트로 수 세기

새 교시·새 강의 소식 받기

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

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

질문과 답변

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

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

R

ReflexKR

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

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

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

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

Built with Reflex