Step 64. 어셈블리 첫 만남 — 내 코드의 최종 실체
Level 1 — 프로그래밍과 컴퓨터 내부 | 난이도 ★★★★☆ | 예상 소요 시간 4시간
전제: Step 56~63을 마쳤다. 컴파일 네 단계를 손으로 재현해 봤고,
.s파일이 어느 단계의 산출물인지 안다.
- 준비물: WSL 우분투 터미널, gcc, objdump. 실측 환경은 Ubuntu 24.04, gcc 13.3.0, objdump 2.42, x86-64입니다.
- 주의: 오늘 실습은 100% 안전합니다. 내가 쓴 프로그램의 어셈블리를 뽑아 읽을 뿐입니다.
지난 시간 우리는 .s 파일을 잠깐 열어 봤습니다. mov, call 같은 이상한 단어들. 오늘은 그 파일을 정면으로 읽습니다. 어셈블리어(assembly language)는 기계어를 사람이 읽을 수 있게 기호로 옮긴, 가장 낮은 층의 언어입니다. 세상의 모든 프로그램 — 게임도, 운영체제도, 파이썬도 — 은 결국 이 작은 명령들의 행진으로 실행됩니다. 오늘의 목표는 어셈블리로 코딩하는 것이 아니라, "내 C 코드가 어떤 명령들이 되는가"를 대조해 보는 감각입니다.
1. 학습 목표
이 챕터를 끝내면 다음을 할 수 있습니다:
- 어셈블리어가 무엇인지(기계어의 인간 판독판) 설명한다
mov,add,cmp,jmp/jle,call,ret여섯 명령을 알아본다- C 코드(함수, if, for)와 그 어셈블리를 나란히 대조한다
gcc -S -masm=intel -O0로 읽기 좋은 어셈블리를 뽑는다objdump -d로 이미 컴파일된 실행 파일에서 어셈블리를 꺼낸다
2. 배경 지식 — 오늘의 도구와 개념
오늘의 도구 한눈에 보기
| 구분 | 내용 |
|---|---|
| 언어·환경 | 어셈블리어(x86-64, 인텔 문법), C 언어, WSL 우분투 bash (실측: gcc 13.3.0) |
| 오늘의 명령어 | gcc -S -masm=intel -O0(읽기용 어셈블리 뽑기), objdump -d -M intel(실행 파일 역어셈블), grep -A 15 "<함수이름>:"(함수 부분만 보기) |
| 필요한 개념 | 기계어와 어셈블리의 대응, 레지스터(rax, rdi, rsi 등), 명령어 6종, 호출 규약, 최적화(-O0 대 -O2) |
2-1. 기계어와 어셈블리 — 0과 1의 번역판
CPU가 직접 아는 것은 0과 1의 패턴(기계어, machine code)뿐입니다. 10111000 ... 같은 것을 사람이 읽을 수 없으니, 각 패턴에 이름을 붙인 것이 어셈블리입니다.
기계어와 어셈블리는 거의 일대일 대응입니다. 그래서 어셈블리를 보면 곧 기계어를 보는 것입니다.
2-2. 레지스터 — CPU의 손바닥 위 상자
CPU 안에는 아주 빠른 초소형 상자 몇 개가 있습니다. 레지스터(register)입니다. 계산은 메모리가 아니라 이 상자들 위에서 일어납니다.
오늘 등장하는 x86-64 환경의 얼굴들: rax(결과가 주로 담기는 곳), rdx, rdi(함수의 첫 인자), rsi(둘째 인자), rsp(스택 꼭대기 표시), rbp(현재 함수의 기준점). eax, edi처럼 e로 시작하는 이름은 같은 상자의 아래쪽 32비트만 쓰는 표기입니다. 오늘은 "이름 있는 손바닥 상자들" 정도로만 알아두세요.
2-3. 명령어 몇 개면 충분하다
어셈블리 명령은 수백 개지만, 오늘 필요한 것은 여섯 개입니다.
| 명령 | 뜻 |
|---|---|
mov a, b |
b를 a에 복사 (대입) |
add a, b |
a에 b를 더함 |
cmp a, b |
a와 b를 비교 (결과는 플래그라는 표식에 남음) |
jmp / jle / je |
뛰기 / 작거나 같으면 뛰기 / 같으면 뛰기 |
call 이름 |
함수 부르기 |
ret |
돌아가기 |
이 여섯 개가 if, for, 함수 호출 전부의 실체입니다.
2-4. 인텔 문법과 AT&T 문법 — 두 가지 표기
같은 어셈블리를 적는 두 가지 필기법이 있습니다. 인텔 문법(mov rax, 1 — "1을 rax에")과 AT&T 문법(movq $1, %rax — 순서가 반대, %와 $ 표시)입니다.
gcc의 기본 출력은 AT&T 문법이지만, 오늘 우리는 읽기 쉬운 인텔 문법(-masm=intel)으로 통일합니다. %와 $가 보이는 출력을 만나면 "아, AT&T구나, 순서 반대구나" 하고 경계등을 켜면 됩니다.
2-5. 함수의 서문과 호출 규약
함수가 시작할 때마다 나오는 push rbp, mov rbp, rsp 같은 준비 동작을 서문(prologue)이라 합니다. "함수의 준비 운동"으로 통째로 넘겨도 됩니다.
반대로 꼭 기억할 약속이 있습니다. 호출 규약(calling convention) — 첫 인자는 rdi로, 둘째 인자는 rsi로 건네고, 결과는 rax로 돌려받는다는 약속입니다. 오늘 실습에서 이 약속이 실제로 지켜지는 것을 봅니다.
3. 따라 하기
3-1. 함수 하나의 어셈블리 — 가장 작은 대조
입력 (add.c)
int add(int a, int b) {
return a + b;
}
컴파일 (어셈블리까지만)
gcc -S -masm=intel -O0 add.c -o add.s
cat add.s
add:
endbr64
push rbp
mov rbp, rsp
mov DWORD PTR -4[rbp], edi
mov DWORD PTR -8[rbp], esi
mov edx, DWORD PTR -4[rbp]
mov eax, DWORD PTR -8[rbp]
add eax, edx
pop rbp
ret
(2026-09-09 실측. .cfi로 시작하는 조립 지시 줄들과 .LFB0 표지는 읽기 쉽게 생략했습니다.)
옵션 읽는 법: -masm=intel은 "인텔 문법으로", -O0는 "최적화 끄기 — 배운 그대로 번역해 줘".
출력 읽는 법: 차례를 따라가 봅시다. 첫 인자 a는 edi 상자로, 둘째 b는 esi로 도착했고(호출 규약), 둘을 메모리(-4[rbp], -8[rbp] — rbp 기준 몇 칸 떨어진 곳)에 내려놓은 뒤 다시 읽어 add eax, edx로 더했습니다. 결과는 eax(rax의 아랫부분)에 — "함수는 rax로 결과를 돌려준다"는 약속의 실물입니다. 맨 위 endbr64는 최신 CPU의 분기 보호용 명령으로, 지금은 "함수 시작 표지" 정도로 읽고 넘기면 됩니다.
예측:
return a - b;로 바꾸면 어느 한 줄이 바뀔까요? 예측하고 다시 뽑아 확인하세요.
왜: 한 줄짜리 C 함수가 아홉 줄의 작은 명령들로 번역되는 모습 — 이 대비가 오늘의 출발점입니다.
3-2. if의 실체 — 비교하고 뛰기
입력 (ifex.c)
int check(int score) {
if (score >= 60) {
return 1;
} else {
return 0;
}
}
컴파일과 확인
gcc -S -masm=intel -O0 ifex.c -o ifex.s
cat ifex.s
check:
endbr64
push rbp
mov rbp, rsp
mov DWORD PTR -4[rbp], edi
cmp DWORD PTR -4[rbp], 59
jle .L2
mov eax, 1
jmp .L3
.L2:
mov eax, 0
.L3:
pop rbp
ret
(2026-09-09 실측. 조립 지시 줄은 생략했습니다.)
출력 읽는 법: if가 사라지고 cmp + jle가 되었습니다. "score와 59를 비교하고, 작거나 같으면(jle) .L2라는 표지로 뛰어라." .L2는 else 덩어리, .L3는 함수의 끝입니다. 59와 비교하는 이유는 ">= 60"을 뒤집으면 "<= 59"이기 때문입니다.
조건문이란 "비교 한 번 + 표지판 사이의 점프"였습니다.
왜: "if는 cmp와 점프" — 이 한 문장이 오늘 가장 중요한 수확입니다. 소스 없는 프로그램에서 if를 알아보는 방법이 바로 이것입니다.
3-3. for의 실체 — 점프로 만든 고리
입력 (forex.c)
int sum(int n) {
int total = 0;
for (int i = 1; i <= n; i++) {
total += i;
}
return total;
}
컴파일과 확인
gcc -S -masm=intel -O0 forex.c -o forex.s
cat forex.s
sum:
endbr64
push rbp
mov rbp, rsp
mov DWORD PTR -20[rbp], edi
mov DWORD PTR -8[rbp], 0
mov DWORD PTR -4[rbp], 1
jmp .L2
.L3:
mov eax, DWORD PTR -4[rbp]
add DWORD PTR -8[rbp], eax
add DWORD PTR -4[rbp], 1
.L2:
mov eax, DWORD PTR -4[rbp]
cmp eax, DWORD PTR -20[rbp]
jle .L3
mov eax, DWORD PTR -8[rbp]
pop rbp
ret
(2026-09-09 실측. 조립 지시 줄은 생략했습니다.)
출력 읽는 법: .L2(조건 검사)와 .L3(본문) 사이를 jle가 오가며 고리를 만듭니다. for의 세 칸이 어디에 갔는지 찾아보세요. 초기값 1 → mov DWORD PTR -4[rbp], 1. 증가 i++ → add DWORD PTR -4[rbp], 1. 조건 i <= n → cmp + jle.
for는 "검사 → 본문 → 증가 → 다시 검사"의 점프 고리였습니다.
예측: for를 while로 고쳐 쓴 C 코드의 어셈블리는 이것과 다를까요, 같을까요? 실험해 보세요. 컴파일러에게 둘은 사실 같은 생각입니다.
3-4. main과 call — 함수 부르기의 실체
입력 (callex.c)
#include <stdio.h>
int square(int n) {
return n * n;
}
int main(void) {
int r = square(7);
printf("결과: %d\n", r);
return 0;
}
컴파일과 확인
gcc -S -masm=intel -O0 callex.c -o callex.s
grep -A 16 "^main:" callex.s
main:
endbr64
push rbp
mov rbp, rsp
sub rsp, 16
mov edi, 7
call square
mov DWORD PTR -4[rbp], eax
mov eax, DWORD PTR -4[rbp]
mov esi, eax
lea rax, .LC0[rip]
mov rdi, rax
mov eax, 0
call printf@PLT
mov eax, 0
leave
ret
(2026-09-09 실측. 조립 지시 줄은 생략했습니다.)
출력 읽는 법: square(7)을 부르기 직전, 7을 edi에 넣었습니다 — "첫 인자는 rdi로 건넨다"는 약속대로입니다. call square로 불렀고, 돌아와서는 eax에서 결과를 꺼내 -4[rbp](변수 r)에 저장했습니다. 이어서 printf를 부를 때도 결과값을 esi(둘째 인자)에, 문자열 주소를 rdi(첫 인자)에 싣는 것이 보입니다.
인자와 결과가 오가는 길 — 이 약속들이 2-5의 호출 규약입니다.
3-5. 실행 파일에서도 — objdump
지금까지는 .s를 만들어 봤습니다. 이미 컴파일된 실행 파일에서도 어셈블리를 꺼낼 수 있습니다.
gcc -O0 callex.c -o callex
objdump -d -M intel callex | grep -A 8 "<square>:"
0000000000001149 <square>:
1149: f3 0f 1e fa endbr64
114d: 55 push rbp
114e: 48 89 e5 mov rbp,rsp
1151: 89 7d fc mov DWORD PTR [rbp-0x4],edi
1154: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
1157: 0f af c0 imul eax,eax
115a: 5d pop rbp
115b: c3 ret
(2026-09-09 실측. 주소(1149 등)는 환경에 따라 달라질 수 있습니다.)
출력 읽는 법: objdump는 실행 파일을 어셈블리로 되돌려 보여 주는 도구(디스어셈블러, disassembler)입니다. 세 갈래로 읽습니다. 맨 왼쪽(1149, 114d …)은 각 명령의 메모리 주소. 가운데(55, 48 89 e5 …)가 진짜 기계어 바이트, 오른쪽이 그것을 번역한 어셈블리입니다. push rbp가 기계어로는 단 한 바이트 55라는 것도 보이죠. 우리가 만든 square가 실행 파일 안에 살아 있고, 곱셈은 imul eax, eax(eax × eax)로 번역됐습니다.
objdump의 인텔 문법은 [rbp-0x4]처럼 대괄호 안에 빼기를 쓰고, gcc -S의 출력은 -4[rbp]처럼 씁니다. 같은 뜻의 다른 필기입니다.
왜: "소스가 없는 실행 파일도, 어셈블리로는 읽을 수 있다." 오늘은 우리가 소스를 아는 프로그램이라 답을 맞힐 수 있습니다. 이 대조 훈련이 쌓이면 정답지 없이도 읽는 날이 옵니다.
3-6. 최적화 전후 비교 — 번역가의 두 얼굴
같은 C를 -O0와 -O2로 각각 뽑아 비교해 봅시다.
gcc -S -masm=intel -O0 add.c -o add_O0.s
gcc -S -masm=intel -O2 add.c -o add_O2.s
grep -v cfi add_O2.s
add:
endbr64
lea eax, [rdi+rsi]
ret
(2026-09-09 실측.)
출력 읽는 법: -O0에서는 아홉 줄이던 함수가 사실상 두 줄이 되었습니다. 메모리에 내려놓았다 다시 읽는 충실한 절차를, 최적화는 "어차피 바로 더하면 되잖아" 하고 생략한 것입니다. 심지어 add 대신 주소 계산 명령 lea로 더하기를 해치웠습니다.
번역가가 두 명이라고 생각하면 됩니다. -O0는 충실한 직역가, -O2는 똑똑한 의역가. 공부할 때는 직역가가 좋고, 실제 배포되는 프로그램들은 대부분 의역가로 번역됩니다.
생각해 보기: 그렇다면 실제 배포 프로그램을 objdump로 열었을 때, 오늘 배운 "충실한 구조"가 그대로 보일까요? 답은 "아니오에 가깝다"입니다. 같은 C도 컴파일 옵션에 따라 다른 어셈블리가 된다 — 어셈블리를 읽을 때 "이것이 원래 코드의 유일한 모습"이 아님을 아는 겸손을 얻는 대목입니다.
4. 미션과 연습문제
미션 — C-어셈블리 대조 카드 세 장
세 장의 대조 카드를 만드세요. 각 카드는 "C 코드 / 어셈블리(핵심 줄) / 대응 관계 한 줄"을 담습니다.
- 카드 1(함수):
int triple(int n) { return n * 3; }의 어셈블리를 뽑고, 곱셈이 일어나는 줄을 표시한다 - 카드 2(if): 짝수면 1, 홀수면 0을 돌려주는 함수를 직접 쓰고, 그 어셈블리에서
cmp와 점프를 찾아 각각 무슨 뜻인지 주석을 단다 (힌트: 2로 나눈 나머지는and연산으로 번역될 수 있습니다. 어떻게 번역됐는지 관찰이 과제입니다) - 카드 3(for): 1부터 10까지 곱하는 함수를 직접 쓰고, 그 어셈블리에서 고리(두 표지판과 점프)를 찾아 화살표로 표시한다
- 각 카드에 "C 한 줄 ↔ 어셈블리 몇 줄"의 대응을 손으로 적는다
- 카드 1의 함수가 들어간 실행 파일을 objdump로 열어 같은 함수를 찾아 본다
연습문제
문제 1. 어셈블리와 기계어의 관계를 설명하고, objdump 출력에서 둘이 나란히 보이는 위치를 말해 보세요.
문제 2. 3-2에서 if (score >= 60)이 왜 cmp ..., 59와 jle로 번역됐나요?
문제 3. -O0로 뽑은 add는 아홉 줄, -O2는 두 줄이었습니다. 공부할 때 어느 쪽을 써야 하며, 실제 배포 프로그램은 대부분 어느 쪽으로 번역됐을까요?
문제 4. 어셈블리를 읽다가 movq $1, %rax 같은 줄을 만났습니다. 이것은 어느 문법이며, "1을 rax에 넣는다"와 "rax를 1에 넣는다" 중 어느 쪽으로 읽어야 하나요?
5. 모범 답안과 완료 기준
미션 모범 답안
카드 1의 예:
int triple(int n) { return n * 3; }
gcc -S -masm=intel -O0 triple.c -o triple.s
곱셈이 일어나는 줄 (2026-09-09 실측): imul eax, DWORD PTR -4[rbp], 3 — "메모리의 n에 3을 곱해 eax에"라는 한 줄로 번역됩니다. 카드에는 이렇게 적습니다: "C n * 3 ↔ imul ..., 3 한 줄. 결과는 eax로."
검증하는 법: ① 세 카드 모두 -O0로 뽑혔는지 확인합니다(-O0를 빼면 계산이 통째로 사라질 수 있습니다). ② 카드 2에서 and 또는 test 같은 명령으로 짝수 판별이 번역된 것을 찾아 적습니다. ③ 카드 3에서 두 표지판(.L 번호 두 개)과 그 사이를 오가는 점프 하나를 화살표로 연결합니다. ④ objdump로 찾은 triple이 .s의 그것과 같은 모양인지 비교합니다.
연습문제 해답
문제 1 해답. 기계어는 CPU가 직접 아는 0과 1의 패턴이고, 어셈블리는 그 패턴에 이름을 붙인 인간 판독판입니다. 거의 일대일 대응입니다. objdump 출력에서는 가운데 열(예: 48 89 e5)이 기계어 바이트, 오른쪽 열(예: mov rbp,rsp)이 그 번역인 어셈블리입니다.
문제 2 해답. 컴파일러는 조건을 뒤집어 "else로 뛸 조건"으로 번역했기 때문입니다. ">= 60"의 반대는 "<= 59"이고, 그래서 59와 비교해 작거나 같으면(jle) else 표지(.L2)로 뛰는 형태가 됩니다.
문제 3 해답. 공부할 때는 -O0입니다. 배운 구조(서문, 메모리에 내리고 다시 읽기)가 그대로 보이기 때문입니다. 실제 배포 프로그램은 대부분 -O2 이상으로 번역되므로, 모양이 상당히 달라진다는 것을 염두에 둬야 합니다.
문제 4 해답. AT&T 문법입니다(%와 $ 표시가 신호). AT&T는 "출발 → 도착" 순서라 인텔 문법과 반대입니다. 그래서 "1을 rax에 넣는다"로 읽어야 합니다.
완료 기준 체크리스트
- [ ] 어셈블리와 기계어의 관계(일대일 대응)를 설명할 수 있다
- [ ]
mov,add,cmp,jmp/jle,call,ret의 뜻을 안다 - [ ] C의 if와 for가 어셈블리에서 어떤 모습인지(cmp+점프, 표지판 고리) 찾을 수 있다
- [ ] 인자와 결과가 오가는 길(rdi, rsi, rax — 호출 규약)을 설명할 수 있다
- [ ]
gcc -S -masm=intel -O0와objdump -d -M intel을 쓸 수 있다 - [ ]
-O0와-O2출력이 왜 다른지 설명할 수 있다 - [ ] 미션: C-어셈블리 대조 카드 세 장을 완성했다
6. 흔한 실수와 해결
벽 1. 최적화 때문에 코드가 통째로 사라진다
증상: 어셈블리를 뽑았더니 함수 내용이 한두 줄뿐이거나, 계산 결과가 이미 상수로 박혀 있습니다.
원인: -O0를 빼먹었습니다. 컴파일러가 "계산할 필요 없는 것"을 미리 다 계산해 버립니다.
해결: 공부용으로는 언제나 -O0. 배운 구조 그대로 번역됩니다.
벽 2. AT&T와 인텔 문법을 섞어 읽는다
증상: movq $1, %rax를 "rax를 1에 넣는다"로 읽어 반대로 이해합니다.
원인: AT&T 문법은 "출발 → 도착" 순서라 인텔과 반대입니다.
해결: 오늘 우리는 -masm=intel로 통일했습니다. 다른 문서나 도구의 출력을 볼 때는 %와 $가 있으면 "AT&T구나, 순서 반대구나" 하고 경계등을 켜세요.
벽 3. 전부 이해하려다 지친다
증상: push rbp, .cfi 같은 줄 하나하나를 파헤치다가 진도가 안 나갑니다.
원인: 오늘의 목표는 완역이 아니라 대응 감각입니다. 서문과 .cfi 지시 줄은 "준비 운동"으로 통째로 넘겨도 됩니다.
해결: 읽는 순서를 정하세요. (가) 함수 이름 찾기 (나) 내가 쓴 연산(add, imul, cmp) 찾기 (다) call과 ret 찾기. 세 개만 찾아도 그 함수의 뼈대는 읽힙니다.
벽 4. objdump 출력이 너무 길다
증상: 수백 줄이 쏟아져 어디가 내 코드인지 모르겠습니다.
원인: 실행 파일에는 우리 코드 외에 링크된 부품들이 잔뜩 들어 있습니다.
해결: grep으로 함수 이름을 찾으세요. objdump -d -M intel 파일 | grep -A 20 "<main>:". 표준 라이브러리 부분은 지금은 건너뛰어도 됩니다.
벽 5. gcc -S와 objdump의 표기가 미묘하게 다르다
증상: gcc 출력은 -4[rbp], objdump는 [rbp-0x4]로 보입니다 (2026-09-09 실측).
원인: 같은 인텔 문법 안에서도 도구마다 대괄호 표기가 다릅니다.
해결: 둘 다 "rbp에서 4바이트 아래"라는 같은 뜻입니다. 표기 차이일 뿐이니 내용(누구에서 얼마만큼 떨어진 곳인가)을 읽으면 됩니다.
7. 정리
오늘의 개념
| 개념 | 한 줄 설명 |
|---|---|
| 기계어 | CPU가 직접 아는 0과 1의 패턴 |
| 어셈블리 | 기계어의 인간 판독판 — 거의 일대일 대응 |
| 레지스터 | CPU 안의 초소형 상자 — 계산은 여기서 일어난다 |
| 호출 규약 | 인자는 rdi, rsi로, 결과는 rax로 — 지키기로 한 약속 |
| 서문(prologue) | 함수 시작의 준비 운동 (push rbp 등) |
| 디스어셈블러 | 실행 파일을 어셈블리로 되돌려 보여 주는 도구 (objdump) |
오늘의 명령어
| 명령 | 하는 일 |
|---|---|
gcc -S -masm=intel -O0 파일.c |
읽기 좋은 어셈블리 뽑기 |
objdump -d -M intel 실행파일 |
실행 파일을 어셈블리로 되돌려 보기 |
objdump ... | grep -A 15 "<main>:" |
내 함수 부분만 보기 |
명령어보다 중요한 감각
오늘부터 어셈블리는 "읽는 언어"이지 "쓰는 언어"가 아닙니다. 쓰는 일은 컴파일러가 우리보다 잘합니다. 소스가 없는 실행 파일을 읽어야 하는 사람들 — 악성코드 분석가, 취약점 연구원 — 의 첫 동작이 오늘의 objdump였고, 그들의 눈에 필요한 것이 오늘의 대조 감각입니다.
아직 모든 줄이 읽히지는 않을 것입니다. 괜찮습니다. 오늘 필요한 것은 다 읽는 것이 아니라, if 하나와 for 하나를 어셈블리 속에서 알아본 경험입니다. 그 경험이 쌓여 시력이 됩니다. 대조 카드 세 장을 잘 보관해 두세요 — 정답지(소스)를 옆에 두고 읽는 이 연습이, 정답지 없이 읽는 날의 밑천입니다.
전부 체크되면 Step 64 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.