오늘의 질문
#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는 컴파일하니까 빠른 거죠. 끝." 도현이 의자를 뒤로 젖힙니다. 모니터 두 대를 켜 둔 윤 조교가 묻습니다. "컴파일하면 뭐가 되는데?" "기계어요." "기계어는 어떻게 생겼어?" 이번에는 둘 다 대답이 없습니다.
"추측하지 말고, 한 줄씩 실행해 보자. 오늘은 제일 작은 함수부터."
두 사람이 잰 함수는 이렇습니다. 하는 일은 한 글자도 다르지 않습니다.
def sum_to(n):
s = 0
i = 1
while i <= n:
s += i
i += 1
return slong 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.py3.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/itersum_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/bench0n=100000000 s=5000000050000000 0.0270 s 0.270 ns/iter
n=100000000 s=5000000050000000 0.2037 s 2.037 ns/iter과제는 이 차이를 기계어로 설명하는 것입니다. 그러려면 먼저 기계어가 무엇인지 알아야 합니다. 앞으로 여덟 번은 C가 어떤 기계어가 되는지를, 마지막 두 번은 파이썬이 무엇을 실행하는지를 봅니다. 오늘은 반복문도 없는 가장 작은 함수 하나로 시작합니다.
메모리는 아주 긴 바이트의 줄입니다. 바이트 하나는 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. 명령어는 메모리에 틈 없이 붙어 있고, 길이를 알아야 다음 명령어가 어디서 시작하는지 압니다.
CPU가 하는 일은 놀랄 만큼 단순합니다. 세 가지를 끝없이 되풀이합니다. 실습대에서 add(2, 40)을 한 단계씩 넘겨 보세요.
rip = 0x401004
해독: 48 89 7d f8 = mov QWORD PTR [rbp-0x8],rdi. 길이 4바이트이므로 다음 명령어는 0x401008에 있습니다.
디스어셈블러가 붙인 이름표
add:0:55 push rbp1:48 89 e5 mov rbp,rsp4:48 89 7d f8 mov QWORD PTR [rbp-0x8],rdi8:48 89 75 f0 mov QWORD PTR [rbp-0x10],rsic: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,rdx17:5d pop rbp18:c3 ret
CPU 안에는 rip(instruction pointer)라는 칸이 있어서 다음에 실행할 명령어의 주소를 담고 있습니다. 한 바퀴는 이렇습니다.
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가 됩니다.
48 89 7d f8이 어떻게 mov QWORD PTR [rbp-0x8],rdi가 될까요? x86-64 명령어는 정해진 칸을 앞에서부터 채운 모양입니다. 실습대에서 명령어를 바꿔 가며 칸이 어떻게 나뉘는지 보세요.
48 89 7d f8 · 4바이트 · 출처: first.O0 add+0x4
reg 비트(굵은 테두리)를 눌러 바꿔 보세요.
| REX 앞붙이 | 48 | 0100 WRXB: W=1 (64비트 피연산자), R=0, X=0, B=0 |
| 옵코드 | 89 | MOV r/m ← reg (reg 칸이 원본, rm 칸이 목적지) |
| ModRM | 7d | mod=01 (메모리 + 8비트 변위), reg=111, rm=101 |
| 변위 disp8 | f8 | 0xf8 = -8 |
x86-64 명령어 하나는 이런 칸으로 되어 있습니다. 대괄호 칸은 없을 수도 있습니다.
[앞붙이] [REX] 옵코드(1–3바이트) [ModRM] [SIB] [변위 0/1/4바이트] [즉값 0/1/2/4/8바이트]0100 WRXB. W = 1이면 64비트로 계산합니다. R, X, B는 레지스터 번호에 8을 더해 r8–r15를 쓰게 합니다.mov도 방향과 피연산자 종류에 따라 89, 8b, c7, b8 등으로 갈립니다.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.[바탕 + 색인 × 배율] 모양일 때 붙습니다(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 한 줄이 되는 것도 확인해 보세요.
return a + b;는 -O0에서 명령어 3개(mov, mov, add), -O2에서 1개(lea)입니다. 컴파일러와 옵션에 따라 달라집니다.mov rbp,rsp 뒤에도 rsp는 그대로입니다.48 8b 45 f0과 48 89 45 f0은 옵코드 한 바이트만 다릅니다. 각각 무슨 명령어이고, 하는 일은 어떻게 다른가요?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개.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비트 변위)는 같습니다.은재의 디버거 노트
프로그램은 메모리에 놓인 바이트다. 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인지도 그때 풀립니다.
새 교시·새 강의 소식 받기
이 강의에 새 교시가 더해지거나 이어지는 강의가 열리면 이메일로 알려 드려요.
R
ReflexKR
눌러 보며 배우는 파이썬과 Reflex. 모든 예제는 Reflex 0.10.0에서 직접 실행해 확인했습니다.
ReflexKR은 배너 광고 없이 무료로 운영돼요. 도움이 되셨다면 커피 한 잔으로 응원해 주세요.
© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
질문과 답변
이 교시에서 궁금한 점을 남겨 주세요. 모든 언어 페이지의 질문이 함께 모입니다.