Step 64. 어셈블리 첫 만남 — 내 코드의 최종 실체

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 <= ncmp + 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. 카드 1(함수): int triple(int n) { return n * 3; }의 어셈블리를 뽑고, 곱셈이 일어나는 줄을 표시한다
  2. 카드 2(if): 짝수면 1, 홀수면 0을 돌려주는 함수를 직접 쓰고, 그 어셈블리에서 cmp와 점프를 찾아 각각 무슨 뜻인지 주석을 단다 (힌트: 2로 나눈 나머지는 and 연산으로 번역될 수 있습니다. 어떻게 번역됐는지 관찰이 과제입니다)
  3. 카드 3(for): 1부터 10까지 곱하는 함수를 직접 쓰고, 그 어셈블리에서 고리(두 표지판과 점프)를 찾아 화살표로 표시한다
  4. 각 카드에 "C 한 줄 ↔ 어셈블리 몇 줄"의 대응을 손으로 적는다
  5. 카드 1의 함수가 들어간 실행 파일을 objdump로 열어 같은 함수를 찾아 본다

연습문제

문제 1. 어셈블리와 기계어의 관계를 설명하고, objdump 출력에서 둘이 나란히 보이는 위치를 말해 보세요.

문제 2. 3-2에서 if (score >= 60)이 왜 cmp ..., 59jle로 번역됐나요?

문제 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 * 3imul ..., 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 -O0objdump -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) 찾기 (다) callret 찾기. 세 개만 찾아도 그 함수의 뼈대는 읽힙니다.

벽 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 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.