Step 65. CPU와 레지스터 — 실행의 심장 박동을 보다

Step 65. CPU와 레지스터 — 실행의 심장 박동을 보다

Level 1 — 프로그래밍과 컴퓨터 내부 | 난이도 ★★★★☆ | 예상 소요 시간 4시간

전제: Step 56~64를 마쳤다. 어셈블리를 뽑아 보고 읽는 연습을 했다.

  • 준비물: WSL 우분투 터미널, gcc, gdb. 실측 환경은 Ubuntu 24.04, gcc 13.3.0, gdb 15.1, x86-64입니다.
  • 주의: 오늘 실습은 100% 안전합니다. 내가 쓴 프로그램을 디버거로 멈추고 들여다볼 뿐입니다. 일부러 내는 사고(세그폴트)도 내 프로그램 안에서만 일어납니다.

지난 시간 어셈블리에서 rax, rdi 같은 이름들이 계속 나왔습니다. 오늘 그 상자들을 정식으로 만납니다. 이 상자들을 레지스터(register)라고 하고, CPU의 모든 계산은 이 상자들 위에서 일어납니다. 그리고 오늘의 가장 중요한 등장인물은 rip라는 상자 — "다음에 실행할 명령의 주소"를 담고 있는 이정표입니다. 만약 누군가 이 상자의 내용을 마음대로 바꿔 치면 프로그램은 그 사람이 원하는 곳으로 갑니다. 오늘은 그 이정표를 직접 손으로 움직여 보는 날입니다.


1. 학습 목표

이 챕터를 끝내면 다음을 할 수 있습니다:

  • CPU가 계산을 메모리가 아닌 레지스터에서 하는 이유를 설명한다
  • RAX, RSP, RBP, RIP 네 레지스터의 역할을 말한다
  • gdb로 실행 중인 프로그램을 멈추고 레지스터를 들여다본다
  • si로 한 명령씩 걸으며 rip의 행진을 관찰한다
  • 인자(rdi, rsi)와 결과(rax)가 레지스터로 오가는 장면을 확인한다

2. 배경 지식 — 오늘의 도구와 개념

오늘의 도구 한눈에 보기

구분 내용
언어·환경 C 언어, WSL 우분투 bash, gdb 15.x (실측: 15.1, x86-64)
오늘의 명령어 gcc -g -O0(디버깅용 컴파일), gdb 안에서 b(멈출 지점), r(실행), c(계속), si(한 명령 진행), i r(레지스터 보기), bt(온 길 추적), q(나가기)
필요한 개념 레지스터, 메모리와 레지스터의 관계, RAX·RSP·RBP·RIP의 역할, 디버거

2-1. 창고와 책상 — 메모리와 레지스터

메모리(RAM)는 크지만 CPU에서 멉니다. 레지스터는 작지만 CPU 바로 옆입니다. 그래서 CPU는 창고(메모리)에서 재료를 꺼내 책상(레지스터)에 올리고, 책상 위에서 계산하고, 결과를 다시 창고에 넣습니다.

지난 시간의 mov가 "옮겨라"였던 이유가 이것입니다. 어셈블리 프로그램은 책상 위 상자들의 분주한 이사입니다.

2-2. 주요 레지스터 명단

64비트 환경(x86-64)의 얼굴들입니다. 이름 앞의 R은 64비트를 뜻하고, 32비트 환경에서는 E로 시작합니다(RAX → EAX).

레지스터 역할
RAX 계산 결과와 함수의 반환값이 담기는 대표 상자 — "일의 성적표"
RBX, RCX, RDX 범용 상자들. RCX는 반복 횟수(counter)로 자주 쓰임
RSI, RDI 함수에 인자를 건네는 상자 (첫 인자 RDI, 둘째 RSI)
RSP 스택의 꼭대기 위치 — 뭔가 쌓이거나 빠질 때마다 이 숫자가 움직임
RBP 현재 함수의 기준점 — 지역 변수는 "RBP에서 몇 칸 떨어졌나"로 찾음
RIP 다음에 실행할 명령의 주소 — 프로그램의 이정표

2-3. gdb — 실행을 멈추고 들여다보는 현미경

gdb(GNU debugger)는 디버거, 실행 중인 프로그램을 원하는 지점에서 멈추고 속을 들여다보는 도구입니다. 오늘 우리는 일곱 개의 명령만 씁니다.

gdb 명령 뜻 (원형)
b main 멈출 지점 찍기 (break)
r 실행 (run)
c 계속 달리기 — 다음 멈춤 지점까지 (continue)
si 기계어 한 명령 진행 (step instruction)
i r 레지스터 보기 (info registers)
bt 여기까지 온 경로 보기 (backtrace)
q 나가기 (quit)

이 일곱 개면 오늘은 충분합니다.

2-4. RIP — 실행의 이정표

프로그램이 위에서 아래로 흘러가는 이유는 RIP가 앞으로 걷기 때문이고, 함수를 불렀다 돌아오는 이유는 RIP가 잠시 다녀오기 때문입니다.

그렇다면 누군가 이 상자의 내용을 바꿔 치면? 프로그램의 행선지가 바뀝니다. 익스플로잇(exploit) — 취약점을 이용해 프로그램을 제 멋대로 움직이는 기술 — 의 최종 목표가 바로 이 RIP 탈취입니다. 오늘은 그 목표의 얼굴을 봅니다.


3. 따라 하기

3-1. 실험용 프로그램 준비

입력 (reg.c)

#include <stdio.h>

int add(int a, int b) {
    int result = a + b;
    return result;
}

int main(void) {
    int x = 40;
    int y = 2;
    int z = add(x, y);
    printf("z = %d\n", z);
    return 0;
}

컴파일

gcc -g -O0 reg.c -o reg

읽는 법: -g는 "디버깅 정보를 담아라"라는 옵션입니다. 이것이 있어야 gdb가 변수 이름과 줄 번호를 알아봅니다. 공부용 컴파일의 기본 옵션으로 기억해 두세요.

3-2. gdb 진입과 첫 멈춤

gdb ./reg
(gdb) b main
Breakpoint 1 at 0x1173: file reg.c, line 9.
(gdb) r
Starting program: .../reg

Breakpoint 1, main () at reg.c:9
9	    int x = 40;

(2026-09-09 실측. 주소와 줄 번호는 소스에 따라 달라집니다. 처음 실행하면 debuginfod를 켤지 묻는 안내가 나올 수 있습니다 — n으로 답하면 됩니다.)

출력 읽는 법: b main은 "main에서 멈춰라", r은 "달려라". 프로그램이 main의 첫 줄에서 얼어붙었습니다. 시간이 멈춘 상태에서 우리는 속을 들여다볼 수 있습니다. 이 "시간을 멈추는 능력"이 디버거의 본질입니다.

3-3. 레지스터 들여다보기

(gdb) i r
rax            0x555555555167      93824992235879
rbx            0x7fffffffe648      140737488348744
rcx            0x555555557dc0      93824992247232
rdx            0x7fffffffe658      140737488348760
rsi            0x7fffffffe648      140737488348744
rdi            0x1                 1
rbp            0x7fffffffe520      0x7fffffffe520
rsp            0x7fffffffe510      0x7fffffffe510
r8             0x0                 0
...
rip            0x555555555173      0x555555555173 <main+12>
eflags         0x202               [ IF ]

(2026-09-09 실측. 모든 값은 실행할 때마다 다릅니다.)

출력 읽는 법: 상자들의 현재 내용이 전부 보입니다. 세 가지만 짚으면 됩니다.

  • rip 옆의 <main+12> — 지금 멈춘 곳이 "main 함수 시작에서 12바이트 지점"이라는 뜻입니다. gdb는 주소 옆에 이렇게 이름표를 붙여 줍니다.
  • rsprbp0x7fffff...으로 시작 — 스택 구역의 주소입니다. Step 60의 지도와 연결되는 순간입니다.
  • rdi가 1 — main의 첫 인자(인자 개수)가 벌써 실려 있습니다. 약속대로 첫 인자는 rdi에 오니까요.

예측: si를 한 번 누를 때마다 rip는 어떻게 될까요? 커질까요, 작아질까요? 예측하고 다음 절에서 확인하세요.

3-4. 한 명령씩 걷기 — rip의 행진

(gdb) si
(gdb) i r rip
rip            0x55555555517a      0x55555555517a <main+19>
(gdb) si
(gdb) i r rip
rip            0x555555555181      0x555555555181 <main+26>
(gdb) si
(gdb) i r rip
rip            0x555555555184      0x555555555184 <main+29>

(2026-09-09 실측.)

출력 읽는 법: 한 명령(si)을 실행할 때마다 rip가 다음 명령의 주소로 걸어갑니다. main+12main+19main+26main+29. 걸음의 폭이 일정하지 않은 것은 각 명령이 차지하는 바이트가 다르기 때문입니다(3~7바이트씩 전진).

프로그램의 실행이란, 이 rip의 행진이었습니다. 눈에 보이지 않던 "실행 흐름"이 숫자로 보입니다.

: 이 행진을 눈으로 본 사람은 "흐름을 꺾는다"는 말의 의미를 압니다. rip를 다른 곳으로 보낼 수 있다면, 행진의 행선지가 바뀝니다.

3-5. 함수 호출 따라가기 — 인자와 결과의 배달

(gdb) b add
Breakpoint 2 at 0x1149: file reg.c, line 4.
(gdb) c
Continuing.

Breakpoint 2, add (a=40, b=2) at reg.c:4
4	    int result = a + b;
(gdb) i r rdi rsi
rdi            0x28                40
rsi            0x2                 2

(2026-09-09 실측.)

출력 읽는 법: c는 "계속 달려라(다음 멈춤 지점까지)". add에서 멈췄더니 rdi에 0x28(16진수로 40), rsi에 0x2가 들어 있습니다. 호출 규약 그대로, 첫 인자 40이 rdi에, 둘째 2가 rsi에 실려 도착했습니다. gdb는 16진수 옆에 십진수도 같이 보여 줍니다.

이어서 몇 걸음 더 걸으면:

(gdb) si
(gdb) si
(gdb) si
(gdb) i r rax
rax            0x2a                42

(2026-09-09 실측.)

출력 읽는 법: rax에 0x2a — 16진수로 42가 들어왔습니다. 40 + 2의 결과가 약속대로 rax에 담겨 돌아갈 준비를 합니다. "결과는 rax로"라는 약속의 실황입니다.

복습: 16진수 2a를 십진수로 바꾸면? Step 50의 기술로 계산해 보고, python3 -c "print(0x2a)"로 확인하세요.

3-6. rip를 바꿔 보기 — 금지된 실험의 합법판

gdb는 레지스터를 바꿀 수도 있습니다. main에서 멈춘 뒤 몇 걸음 걷고 나서, 이정표를 main의 처음으로 되돌려 봅니다.

(gdb) i r rip
rip            0x555555555181      0x555555555181 <main+26>
(gdb) set $rip = main
(gdb) i r rip
rip            0x555555555167      0x555555555167 <main>

(2026-09-09 실측.)

출력 읽는 법: set $rip = main은 "다음 명령 주소를 main의 처음으로 되돌려라". rip가 main+26에서 <main>(시작 지점)으로 돌아갔습니다. 행진의 이정표를 우리가 손으로 움직인 것입니다. 계속 달리면(c) 프로그램은 main의 처음부터 다시 실행됩니다.

물론 이것은 디버거라는 합법적 도구 안에서의 실험입니다. 공격자는 디버거 없이, 버퍼 오버플로우 같은 취약점으로 이것과 같은 일 — rip 바꿔치기 — 을 노립니다. 오늘 여러분은 그 일이 "무엇"인지를 직접 해 본 것입니다.

: "RIP를 탈취한다"는 문장이 더 이상 수식어가 아닙니다. 저 상자의 내용을 바꾸는 것. 그것이 전부이고, 그래서 그렇게까지 지키려 하는 것입니다.

3-7. 세그폴트 수사 — 디버거의 진가

일부러 사고를 내고, gdb로 "어디서 죽었는지"를 찾는 표준 수사를 해 봅시다.

입력 (crash.c)

#include <stdio.h>

void boom(void) {
    int *p = (int *)0;
    *p = 1;
}

int main(void) {
    printf("사고 전\n");
    boom();
    printf("사고 후\n");
    return 0;
}

컴파일과 수사

gcc -g -O0 crash.c -o crash
gdb ./crash
(gdb) r
Starting program: .../crash
사고 전

Program received signal SIGSEGV, Segmentation fault.
0x000055555555515d in boom () at crash.c:5
5	    *p = 1;
(gdb) bt
#0  0x000055555555515d in boom () at crash.c:5
#1  0x0000555555555182 in main () at crash.c:10

(2026-09-09 실측.)

출력 읽는 법: 평소라면 "Segmentation fault" 한 줄에 원인 미상이지만, gdb 안에서 죽이면 현장이 보존됩니다. "boom 함수의 5번째 줄, *p = 1에서 죽었습니다." bt(뒤로 추적)는 경로까지 보여 줍니다 — #0이 현장(boom의 5번 줄), #1이 그 부름의 출발(main의 10번 줄). "main → boom 순으로 왔습니다"라는 겁니다.

: 죽은 프로그램의 현장을 보존하고 경로를 거슬러 올라가는 것. 이것이 printf 찍기와 디버거의 차이이고, 앞으로 풀 모든 어려운 버그의 시작점입니다. r로 죽을 때까지 달리고 bt로 경로를 보는 이 두 걸음이 죽음 수사의 표준 첫걸음입니다.


4. 미션과 연습문제

미션 — 레지스터 관찰 일지

  1. reg.c를 변형해 함수를 하나 더 만든다 (예: int mul(int a, int b) { return a * b; }를 추가하고 main에서 호출)
  2. gcc -g -O0로 컴파일하고 gdb로 들어가, 두 함수(add와 mul)에 각각 멈춤을 찍는다
  3. 각 함수에서 인자가 rdi/rsi에 도착하는 장면을 둘 다 기록한다 (16진수와 십진수 함께)
  4. 각 함수가 끝날 때 rax에 담긴 값을 기록하고, 16진수를 십진수로 바꿔 검산한다
  5. si로 열 걸음 걸으며 rip를 매 걸음 적어, 그 행진을 노트에 화살표로 그린다
  6. 레지스터 아홉 개(RAX, RBX, RCX, RDX, RSI, RDI, RSP, RBP, RIP)의 역할을 표로 정리한다. 책을 보지 않고 채울 수 있을 때까지 반복한다

연습문제

문제 1. CPU는 왜 메모리에서 바로 계산하지 않고 레지스터로 옮겨 와서 계산하나요?

문제 2. 3-4에서 rip가 main+19main+26main+29로 일정하지 않은 폭으로 걸었습니다. 왜 걸음의 폭이 다를까요?

문제 3. 3-5에서 add가 불렸을 때 rdi에 0x28, rsi에 0x2가 있었고, 끝날 때 rax에 0x2a가 있었습니다. 이 세 관찰이 증명하는 약속은 무엇인가요?

문제 4. 공격자가 rip를 자기가 원하는 주소로 바꿀 수 있다면 무슨 일이 일어나나요? 그래서 방어 기술들(ASLR, 스택 카나리)이 지키려는 것은 결국 무엇인가요?


5. 모범 답안과 완료 기준

미션 모범 답안

관찰 일지의 예 (2026-09-09 실측 형식):

[add 도착]  rdi = 0x28 (40),  rsi = 0x2 (2)   ← 인자 배달 확인
[add 직후]  rax = 0x2a (42)                    ← 40 + 2 = 42 검산 완료
[mul 도착]  rdi = 0x6 (6),    rsi = 0x7 (7)   ← 두 번째 함수도 같은 약속
[mul 직후]  rax = 0x2a (42)                    ← 6 × 7 = 42 검산 완료

[rip의 행진] main+12 → main+19 → main+26 → main+29 → ...
            (각 명령의 크기만큼씩 전진)

검증하는 법: ① 두 함수 모두에서 rdi/rsi에 인자가 도착한 것이 기록돼 있어야 합니다. ② rax 값의 십진수 검산이 실제 연산 결과와 일치해야 합니다. ③ 행진 그림의 화살표가 오른쪽(주소가 커지는 쪽)으로만 향해야 합니다 — 함수 호출이나 점프를 만나면 방향이 바뀔 수 있는데, 그 지점을 표시해 두면 더 좋은 기록입니다.

연습문제 해답

문제 1 해답. 메모리는 크지만 CPU에서 멀어(느려)서입니다. 레지스터는 CPU 바로 옆의 초고속 상자라서, CPU는 메모리에서 재료를 레지스터로 옮겨 와 계산하고 결과를 다시 메모리에 넣습니다. 이것이 어셈블리에 mov가 그렇게 많은 이유입니다.

문제 2 해답. 각 기계어 명령이 차지하는 바이트 수가 다르기 때문입니다. rip는 "다음 명령의 주소"이므로, 방금 실행한 명령의 크기만큼 전진합니다. 3바이트짜리 명령을 지나면 +3, 7바이트짜리를 지나면 +7이 됩니다.

문제 3 해답. 호출 규약입니다. "첫 인자는 rdi, 둘째 인자는 rsi로 건네고, 결과는 rax로 돌려받는다"는 약속이 살아 있는 실행에서 그대로 지켜지는 것을 확인한 것입니다. 0x28 = 40, 0x2 = 2, 0x2a = 42.

문제 4 해답. 프로그램이 공격자가 지정한 코드로 가서 실행됩니다 — 실행 흐름의 완전한 탈취입니다. 방어 기술들은 결국 이 한 가지를 지킵니다. ASLR은 "바꿔 칠 주소를 모르게" 주소를 섞고, 스택 카나리는 "주소가 적힌 곳까지 덮기"를 탐지합니다. 둘 다 rip를 지키기 위한 것입니다.

완료 기준 체크리스트

  • [ ] 레지스터와 메모리의 관계를 창고와 책상으로 설명할 수 있다
  • [ ] RAX, RSP, RBP, RIP의 역할을 말할 수 있다
  • [ ] gdb로 프로그램을 멈추고(b, r) 레지스터를 볼 수 있다(i r)
  • [ ] si로 한 명령씩 걸으며 rip의 행진을 관찰했다
  • [ ] 인자(rdi, rsi)와 결과(rax)가 레지스터로 오가는 장면을 기록했다
  • [ ] 세그폴트 수사 절차(rbt)를 직접 해 봤다
  • [ ] 미션: 레지스터 관찰 일지(인자·결과·행진 기록 + 역할 표)를 완성했다

6. 흔한 실수와 해결

벽 1. 변수 이름과 줄 번호가 안 나온다

증상: b main은 되는데, 멈춰도 이렇게만 나옵니다 (2026-09-09 실측):

Breakpoint 1, 0x000055555555516f in main ()

원인: -g 없이 컴파일했습니다. 디버깅 정보가 없어 gdb가 줄 번호와 변수 이름을 모릅니다.
해결: gcc -g -O0로 다시 컴파일하세요. -g가 디버거의 눈을 만들어 줍니다.

벽 2. 16진수 읽기가 버겁다

증상: 0x2a가 42라는 것이 바로 안 나옵니다.
원인: 16진수에 아직 손이 안 익은 것뿐입니다. 정상입니다.
해결: python3 -c "print(0x2a)"를 계산기로 쓰세요. 반대는 python3 -c "print(hex(42))". 당분간은 이 계산기에 기대도 됩니다.

벽 3. si와 ni를 혼동한다

증상: 한 줄 진행하려는데 함수 안으로 들어가 버리거나 건너뜁니다.
원인: si는 "기계어 한 명령" 진행, ni는 "소스 한 줄" 진행(함수 호출을 한 줄로 건너뜀)입니다.
해결: 오늘은 si만 씁니다. 함수 안으로 들어가고 싶을 때도 si면 충분합니다. 긴 함수를 건너뛰고 싶은 날 ni를 기억하세요.

벽 4. 레지스터를 통째로 외우려 한다

증상: 아홉 개의 역할을 시험처럼 달달 외우려다 막힙니다.
원인: 순서가 틀렸습니다. 외우기는 이해의 결과지 방법이 아닙니다.
해결: 네 개(RAX=성적표, RSP=스택 꼭대기, RBP=기준점, RIP=이정표)만 확실히 하고, 나머지는 실험할 때마다 표를 보세요. 쓰다 보면 외워집니다.

벽 5. set $rip 명령이 묵묵부답이다

증상: set $rip = main을 쳤는데 rip가 안 바뀐 것처럼 보입니다.
원인: 확인을 안 했을 뿐일 수 있습니다. 이 명령은 아무것도 출력하지 않습니다 (2026-09-09 실측).
해결: 명령 직후에 반드시 i r rip로 확인하세요. <main> 이름표로 돌아가 있으면 성공입니다.


7. 정리

오늘의 개념

개념 한 줄 설명
레지스터 CPU 안의 초소형 상자 — 모든 계산은 여기서
RAX 결과·반환값의 성적표
RSP / RBP 스택 꼭대기 / 현재 함수의 기준점
RDI / RSI 인자 배달 상자 (첫째, 둘째)
RIP 다음에 실행할 명령의 주소 — 실행의 이정표
디버거 실행을 멈추고 들여다보는 현미경 (gdb)

오늘의 명령어

명령 하는 일
gcc -g -O0 디버깅용 컴파일 (디버거의 눈을 만든다)
gdb ./프로그램 디버거 진입
b 이름 / r / c 멈출 지점 찍기 / 실행 / 계속
si 기계어 한 명령 진행
i r (또는 i r rax) 레지스터 보기
set $rip = main 이정표 되돌리기 (디버거 안에서만)
bt 여기까지 온 경로 보기
q 나가기

명령어보다 중요한 감각

오늘 실험에서 가장 오래 남을 장면은 si를 누를 때마다 rip가 걷는 모습입니다. 그 몇 걸음이 "실행"의 전부입니다. 컴퓨터가 빠르다는 것은 그 걸음을 초당 수십억 번 걷는다는 뜻이고, 프로그램이 이상한 곳으로 갔다는 것은 그 걸음의 행선지가 바뀌었다는 뜻입니다. 복잡해 보이던 현상이 이정표 하나의 이야기로 환원됩니다 — 큰 것을 작은 것의 나열로 보는 이 눈이 이 바닥의 진짜 실력입니다.

하나 더. 이 레지스터들의 이름과 "인자는 rdi, rsi로"라는 약속은 자연법칙이 아니라 사람이 정해 문서로 남긴 약속입니다. 그래서 약속이 다르면(ARM 같은 다른 CPU) 모양도 달라집니다. 원리는 같고 약속만 다르다 — 이 시선을 가지면 낯선 환경도 두렵지 않습니다.


전부 체크되면 Step 65 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.