CSAPP 3.4장을 읽으면서 새롭게 안 내용들 정리

낚시하는 곰·2025년 4월 7일

krafton jungle

목록 보기
47/52

eax에 32비트짜리 데이터를 할당하면 rax의 상위32비트는 왜 0으로 채워지나요?

상세 설명

  • rax는 64비트 레지스터야.
  • eaxrax의 하위 32비트를 나타내.
  • 만약 eax에 값을 쓰면, 자동으로 rax의 상위 32비트가 0으로 채워져.
    • 즉, eax에 값을 쓸 때는 zero-extension이 일어나.
    • 반대로, ax, al 등에 값을 써도 그런 일은 일어나지 않아. (그땐 하위 비트만 바뀌고, 상위 비트는 그대로 유지돼.)

예시

mov eax, 0xFFFFFFFF   ; eax = 0xFFFFFFFF, rax = 0x00000000FFFFFFFF
  • 여기서 eax에 32비트 최대값을 넣으면,
  • rax의 상위 32비트는 0으로 자동 초기화되어 rax = 0x00000000FFFFFFFF가 돼.

스택 포인터와 베이즈 포인터가 왜 각각 존재하는 지 알고 있나요?

스택 포인터(rsp)란?

  • 이름: Stack Pointer (x86-64에선 rsp, 32비트에선 esp)
  • 의미: 현재 스택의 최상단(Top)을 가리켜.
  • 용도: 데이터를 push/pop하거나 함수 호출 시 return address 저장 등에 사용돼.
  • 자동으로 조절됨: 예를 들어 pushcall을 하면 rsp가 자동으로 감소하고, pop이나 ret을 하면 증가해.

✅ 즉, 스택에서 실제 데이터를 넣고 빼는 위치를 나타내는 포인터야.


베이스 포인터(rbp)란?

  • 이름: Base Pointer (x86-64에선 rbp, 32비트에선 ebp)
  • 의미: 함수가 시작될 때 스택 프레임의 기준점(= 기준 주소)을 저장해두는 포인터야.
  • 용도: 지역 변수나 함수 인자를 고정된 오프셋으로 접근하기 위해 사용돼.
  • 함수가 시작되면 보통 다음과 같은 코드가 실행돼:
push rbp         ; 이전 함수의 rbp 저장
mov rbp, rsp     ; 현재 함수의 기준점을 rbp로 설정

✅ 즉, 스택 프레임의 기준점을 잡아주는 포인터야. rbp - 4, rbp + 8 같은 식으로 쉽게 변수에 접근할 수 있도록 도와줘.


시각화 (간단한 예)

rsp → [ 지역변수 2 ]
     [ 지역변수 1 ]
     [ return address ]
rbp → [ 이전 rbp ]

함수가 호출되는 과정을 어셈블리어로 어떻게 바뀔까요?

흐름 정리 (고수준 → 저수준)

  1. ⚙️ 어셈블리어 수준에서:
    • call 명령어로 함수 호출
    • push로 return address 저장
    • mov로 인자 복사 (rdi, rsi, rdx, ... 레지스터 사용)
    • rbprsp를 조작해 스택 프레임 설정

어셈블리 함수 호출 기본 예시 (x86-64 기준)

mov rdi, rax        ; 첫 번째 인자
call greet_function ; 함수 호출

함수 내부에서는:

push rbp
mov rbp, rsp        ; 스택 프레임 설정
...
pop rbp
ret                 ; 호출한 곳으로 복귀

flow chart로 보여줄게!

Python 코드: greet("Alice")
        ↓
CPython 해석기
        ↓
C 함수: PyEval_EvalFrameEx
        ↓
C 함수: PyObject_CallFunction
        ↓
어셈블리:
  - 인자 설정 (rdi 등)
  - call 함수
  - push return address
  - rbp, rsp 조정
  - ret 복귀

왜 rbp, rsp 두 개의 포인터가 필요할까요?

스택에서 무슨 일이 일어날까?

함수가 호출되면 다음 순서로 스택이 움직여:

  1. 호출자의 return address 저장됨 (자동적으로 call 명령어가 push)
  2. 호출 함수의 rbp 저장 (push rbp)
  3. rbprsp에 맞춤 (mov rbp, rsp)
  4. 지역 변수 공간 확보 (sub rsp, N)

rsprbp의 역할 비교

항목rsp (Stack Pointer)rbp (Base Pointer)
의미현재 스택의 최상단스택 프레임의 기준점
변화함수 실행 중 계속 변함함수 실행 중 고정됨
용도지역 변수 공간 확보, push/pop함수 인자, 지역 변수 접근
주소 기준상대적 주소가 어렵다 (rsp+32는 변함)항상 같은 위치 (rbp-4)

예시로 볼까?

int foo(int a) {
    int b = a + 3;
    return b;
}

이 코드의 어셈블리 스택 프레임을 보자:

push rbp          ; 이전 rbp 저장
mov rbp, rsp      ; 현재 스택 기준점 설정
sub rsp, 16       ; 지역변수 공간 확보

mov DWORD PTR [rbp-4], edi   ; 매개변수 a 저장
mov eax, DWORD PTR [rbp-4]   ; a 불러오기
add eax, 3
mov DWORD PTR [rbp-8], eax   ; b 저장

여기서 보면 모든 변수 접근이 rbp를 기준으로 일어남. 왜냐?

rsp는 계속 바뀌기 때문이야. push, call, alloca, signal 등으로 바뀔 수 있어.
✅ 반면에 rbp는 함수 시작할 때 한 번만 mov rbp, rsp로 설정되고 절대 안 바뀜.


왜 그냥 rsp만 쓰면 안 될까?

요즘 컴파일러는 사실 rbp 없이도 코드를 만들 수 있어. 이를 frame pointer omission (FPO) 이라고 해.

  • 속도와 공간 절약에는 좋아.
  • 하지만 디버깅이 어려워져.
    gdb 같은 디버거는 rbp를 따라 스택 프레임을 추적하니까, rbp가 없으면 call stack 복원이 어려워.

그래서 디버깅이나 오류 추적을 고려하면 rbp를 쓰는 게 낫고,
최적화된 릴리즈 빌드는 rbp 없이 컴파일되기도 해.


💬 다시 정리하면:

  • rsp = 스택에서 push/pop을 위한 조작 포인터 (계속 변함)
  • rbp = 스택 프레임의 기준점 (함수 실행 내내 고정됨)

스택에 데이터를 push할 때마다 8byte씩 이동하는 이유가 뭘까요?

64비트 시스템에서, 데이터를 8바이트 단위로 정렬(aligned)하면 CPU가 더 빠르게 읽고 쓸 수 있기 때문이야.


1. CPU는 정렬된 데이터를 더 빠르게 처리해

  • 현대 CPU는 aligned access를 선호해.
  • 예를 들어, 64비트 시스템에서는 8바이트(64비트) 경계에 맞춰 메모리 접근하는 게 가장 빠름.
좋은 정렬 (aligned):
주소 0x100, 0x108, 0x110 ← 8바이트 경계

나쁜 정렬 (misaligned):
주소 0x101, 0x105 ← 경계 어긋남 → 느림

2. x86-64 시스템은 기본 데이터 단위가 8바이트야

  • 64비트 레지스터: rax, rbp, rsp 등은 8바이트 크기
  • 함수 인자, return address, 포인터 등도 모두 8바이트
  • 따라서 push/pop도 자연스럽게 8바이트 단위로 작동해
push rax   ; rsp -= 8 → 8바이트 감소
pop rbx    ; rsp += 8 → 8바이트 증가

3. ABI (Application Binary Interface) 규약

x86-64 시스템에서는 System V AMD64 ABI 같은 표준이 있어.

  • 함수 호출 전 rsp16바이트 정렬이 되어 있어야 해.
  • 하지만 call 명령어는 return address를 8바이트만큼 push하니까,
  • 그 후엔 rsp8로 나눠 떨어지게 됨

✅ 그래서 스택에 변수를 더 push할 때도 8바이트 단위로 맞춰서 정렬하는 거야!


예시

push rbx       ; rsp -= 8
push rcx       ; rsp -= 8
call foo       ; rsp -= 8 (return address 저장)

→ 이제 rsp는 8바이트 단위로 잘 정렬되어 있음

🎨 그림으로 보면

Before push:
  rsp → 0x7fff_ffff_fff8

After push rbx:
  rsp → 0x7fff_ffff_fff0  (8 byte down)

After push rcx:
  rsp → 0x7fff_ffff_ffe8  (again 8 byte down)

rsp를 쓰면 되지 왜 esp가 있을까요?

결론부터 말하면:

esp과거 32비트 x86 코드와의 호환성을 위해 남아 있고,
때론 일부 명령어에서 더 짧은 인코딩으로 쓸 수 있기 때문이야.


CPU는 하위 호환성을 철저히 지킨다

x86 아키텍처는 40년 넘게 진화했어:

세대주요 레지스터
16비트 (8086)sp
32비트 (i386)esp
64비트 (x86-64)rsp
  • 64비트 CPU도 32비트 프로그램을 문제 없이 실행할 수 있어야 해.
  • 그러려면 옛날 코드에서 쓰는 esp, ebp, eax 같은 32비트 레지스터를 그대로 살려야 돼.
  • 따라서 하드웨어 수준에서 여전히 esp라는 이름의 레지스터가 존재하는 것처럼 보이게 해줌 (실제로는 rsp의 하위 32비트지만!)

32비트 접근 시, 자동으로 상위 32비트는 0으로 클리어된다

이건 아까 네가 잘 짚었던 부분이야:

mov esp, 0x11111111

이 명령은 사실상 아래와 같아:

rsp = 0x0000000011111111

즉, 32비트 연산은 상위 32비트를 0으로 만들어버려. 이건 의도된 동작이야.


명령어 길이(Instruction Encoding) 최적화

어셈블리어는 기계어로 번역될 때, 명령어 길이가 중요해.

  • mov rsp, imm64 → 길이가 길어 (9~10바이트)
  • mov esp, imm32 → 짧음 (5바이트 정도)

예를 들어 32비트 상수만 필요할 때는 esp를 쓰는 게 코드 크기를 줄이고, 성능에 유리할 수 있어.
컴파일러는 이런 점을 활용해서 일부러 esp를 선택하기도 해.


C 코드 컴파일 시에도 여전히 등장해

아래와 같은 간단한 C 코드:

int main() {
    int x = 10;
    return x;
}

gcc로 컴파일하면 64비트 모드에서도 esp, ebp, eax 같은 32비트 레지스터가 여전히 나와.
왜냐하면:

  • 정수형 int는 32비트니까 eax를 쓰고,
  • 지역 변수 접근도 32비트면 esp, ebp를 쓸 수 있어.

주소지정 방식으로 데이터를 저장하면 기존에 저장되어 있던 데이터를 덮어쓸 수 도 있지 않아? 왜 이런 위험한 방식을 사용하는 거야?

먼저 결론부터 말하면:

주소지정 방식은 위험할 수 있지만,
성능, 유연성, 메모리 직접 접근이 필요한 구조 때문에 반드시 필요해.


주소지정 방식이란?

예를 들어 이런 명령어:

mov [rax], 0x10

이건 rax가 가리키는 주소에 0x10저장하는 명령어야.

  • 이때 [rax] 위치엔 이미 어떤 값이 있을 수 있어.
  • 그래서 기존 값을 덮어쓴다, 즉 "메모리를 파괴한다"는 개념이 생기는 거야.

왜 위험할까?

  • 주소 계산이 잘못되면 → 전혀 엉뚱한 메모리 위치를 덮어쓸 수 있음
  • 예를 들어, 스택이나 코드 영역을 잘못 건드리면 프로그램이 크래시
  • 또는 보안상 위험한 동작을 유발할 수도 있어 (버퍼 오버플로우!)

그런데 왜 굳이 이걸 써?

CPU가 직접 메모리에 접근할 수 있어야 하니까

  • 프로그램은 결국 데이터를 메모리에 저장하고 꺼내는 걸 반복해.
  • mov [addr], value 같은 직접 메모리 접근이 없다면, 컴퓨터는 아무것도 못해.

레지스터 수는 적고, 메모리는 크다

  • x86-64에는 레지스터가 약 16개뿐이야.
  • 하지만 메모리는 수 GB~TB까지 존재해.
  • 레지스터만 가지고 프로그램을 돌릴 순 없어. 결국 메모리에 접근해야 해.

유연한 메모리 구조 구현이 가능

  • 구조체, 배열, 포인터, 스택, 힙 등 전부 주소 기반 메모리 접근이 핵심이야.
  • 주소지정 방식을 없애면 구조체 필드 접근이나 포인터 연산도 못해.

그럼 위험한 걸 막는 방법은?

이건 운영체제가 책임져!

보호 방법설명
✅ 가상 메모리각 프로세스에 독립적인 메모리 공간 부여
✅ 접근 권한 보호읽기/쓰기/실행 권한 제어 (segfault 유발)
✅ 메모리 보호 정책커널은 사용자 영역 접근 못함 등
✅ Stack canary, ASLR보안 보호 기법 추가

즉, 주소지정 자체는 위험하지만 필수고,
위험을 제어하는 건 하드웨어와 OS의 책임이야.


Calling Convention이란?

함수 호출 시 인자를 어떻게 전달하고,
리턴 값을 어디에 저장하며,
스택을 누가 정리하는지를 약속해 놓은 규칙이야.


왜 필요할까?

  • 컴파일러가 다 다르면 함수 호출 방식도 제각각이야.
  • 그럼 C에서 만든 함수를 Python이나 Rust에서 못 불러.
  • 그래서 전 세계적으로 표준을 정해놓은 거야.

📌 요약: 서로 다른 함수, 컴파일러, 언어끼리 함수 호출을 안전하게 연결하기 위한 약속이야!


Calling Convention에서 약속하는 것들

항목설명
인자는 어디에 저장?레지스터? 스택? 어떤 순서로?
리턴 값은 어디에 저장?보통 rax
스택 정리는 누가?호출자(caller)? 피호출자(callee)?
어떤 레지스터는 보존해야 해?rbx, rbp 등 (callee-saved)

대표적인 Calling Convention 종류

System V AMD64 ABI (Linux, macOS 등)

항목내용
인자 전달 순서rdi, rsi, rdx, rcx, r8, r9 (그 이후는 스택)
리턴 값rax
스택 정리호출자 (caller)
보존해야 하는 레지스터rbx, rbp, r12~r15 등 (callee-saved)

Microsoft x64 Calling Convention (Windows)

항목내용
인자 전달 순서rcx, rdx, r8, r9
리턴 값rax
스택 정리호출자 (caller)
보존 레지스터거의 같음, 약간 차이 있음

📎 두 규약은 비슷하지만 레지스터 순서가 다름!


예시: System V에서 함수 호출

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

int main() {
    int result = add(1, 2);
}

어셈블리에서는:

mov edi, 1       ; 첫 번째 인자 → rdi
mov esi, 2       ; 두 번째 인자 → rsi
call add         ; 함수 호출
mov [rbp-4], eax ; 리턴값 저장

profile
취업 준비생 낚곰입니다!! 반갑습니다!!

0개의 댓글