[CSAPP] C언어에서 기계어까지: 레지스터, 데이터 이동, 스택의 모든 것

1. 문제 제기 (Introduction & Problem Statement)

  • 관찰 현상 또는 질문:
    우리가 작성하는 C, Java와 같은 고급 언어 코드는 어떻게 컴퓨터가 실제로 실행할 수 있는 명령어로 변환될까? int x = y + z; 라는 간단한 한 줄의 코드는 CPU 내부에서 어떤 연산들의 조합으로 이루어질까? 컴파일러가 코드를 최적화한다는 것은 기계 수준에서 무엇을 바꾼다는 의미이며, 해커는 어떻게 프로그램의 보안 취약점을 찾아내는 걸까? 이 모든 질문의 답은 컴퓨터의 가장 낮은 수준의 언어, 즉 어셈블리 코드와 기계어에 있다.

  • 탐구 목표:
    본 아티클에서는 C 코드가 컴파일되어 최종적으로 실행되는 기계 수준의 프로그램을 심층 분석한다. x86-64 아키텍처를 기준으로, CPU의 작업 공간인 레지스터의 종류와 역할, 명령어의 재료가 되는 피연산자 지정 방식, 데이터 이동의 핵심인 MOV 명령어 계열, 그리고 함수 호출의 기반이 되는 스택(Stack) 의 동작 원리를 단계적으로 학습한다. 이를 통해 추상화 뒤에 가려진 컴퓨터의 실제 동작 방식을 이해하는 것을 목표로 한다.


2. 기술 분석 및 핵심 원리 (Technical Deep Dive)

2-1. C에서 기계어까지: 변환 과정과 데이터 표현

고급 언어 코드는 어셈블러와 링커를 거쳐 프로세서가 실행할 수 있는 바이트 시퀀스(기계어)로 변환된다.

  • 컴파일 파이프라인: C 코드 -> (컴파일러) -> 어셈블리 코드 -> (어셈블러) -> 객체 파일 -> (링커) -> 실행 파일
  • C와 어셈블리의 데이터 표현: C언어는 int, char, double 등 다양한 데이터 타입을 갖지만, 기계 수준에서는 타입 구분이 거의 없다. 대신 데이터의 크기가 중요하며, 명령어에 크기를 명시하는 접미사를 붙인다.
C 데이터 타입 어셈블리 접미사 크기 (바이트)
char b (byte) 1
short w (word) 2
int l (long) 4
long q (quadword) 8
char * q (quadword) 8
float s (single) 4
double d (double) 8

2-2. CPU의 작업 공간: x86-64 레지스터

x86-64 CPU는 연산과 주소 계산을 위해 16개의 64비트 범용 레지스터를 사용한다. 이 레지스터들은 하위 호환성을 위해 크기별로 다른 이름으로 접근할 수 있다.

64비트 (q) 32비트 (l) 16비트 (w) 8비트 (b) 주 용도
%rax %eax %ax %al 함수 반환 값
%rdi %edi %di %dil 1번째 인자
%rsi %esi %si %sil 2번째 인자
%rdx %edx %dx %dl 3번째 인자
%rcx %ecx %cx %cl 4번째 인자
%r8 %r8d %r8w %r8b 5번째 인자
%r9 %r9d %r9w %r9b 6번째 인자
%rsp %esp %sp %spl 스택 포인터 (Stack Pointer)
%rbp %ebp %bp %bpl 프레임 포인터 (Frame Pointer)
%rbx, %r10 - %r15 ... ... ... 피호출자/호출자 저장 (callee/caller saved)

2-3. 명령어의 구성 요소와 데이터 이동 (MOV)

명령어는 '무엇을 할지'와 '무엇을 가지고 할지'로 구성된다. 후자를 피연산자(Operand)라고 하며, 소스(Source)와 목적지(Destination)를 지정한다.

  • 피연산자의 종류:

    • 즉시값 (Immediate): 코드에 박힌 상수 값 (예: $5).
    • 레지스터 (Register): 16개 레지스터 중 하나 (예: %rax).
    • 메모리 참조 (Memory): Imm(rb, ri, s) 형태로 유효 주소를 계산하여 메모리 값에 접근. 유효주소 = Imm + R[rb] + R[ri] * s.
  • 데이터 이동 (MOV 계열): MOV 명령어는 소스의 데이터를 목적지로 복사한다.

    movq %rax, %rbx         # 레지스터 -> 레지스터
    movb $10, (%rax)        # 즉시값 -> 메모리
    movl -4(%rbp), %eax     # 메모리 -> 레지스터
    • 핵심 제약: 소스와 목적지 피연산자는 동시에 메모리일 수 없다. 메모리 간 데이터 이동은 반드시 레지스터를 경유해야 한다.
  • 크기 확장 이동 (movz vs. movs): 작은 데이터를 큰 공간으로 옮길 때, 남는 비트를 채우는 방식에 따라 나뉜다. 이는 부호 유무에 따른 캐스팅과 직결된다.

구분 movz (Zero-Extending) movs (Sign-Extending)
목적 부호 없는(unsigned) 값 확장 부호 있는(signed) 값 확장
동작 남는 상위 비트를 모두 0으로 채운다. 남는 상위 비트를 원본의 부호 비트로 채운다.
예시 movzbl (%rax), %edx (byte to long) movsbl (%rax), %edx (byte to long)
C언어 대응 unsigned char c; unsigned int i = c; signed char c; int i = c;
특별 명령어 - cltq: movslq %eax, %rax와 동일. 더 효율적.

2-4. 함수 호출의 기반: 스택 (Stack)

스택은 함수 호출 시 지역 변수, 인자, 복귀 주소 등을 임시로 저장하는 LIFO(Last-In, First-Out) 구조의 메모리 영역이다.

  • 성장 방향: x86-64에서 스택은 낮은 주소 방향으로 자란다. 데이터를 스택에 넣으면(push) 스택 포인터 주소 값이 감소한다.
  • 스택 포인터 (%rsp): 항상 스택의 최상단(가장 최근에 저장된 데이터의 위치)을 가리키는 레지스터다. 함수 호출과 반환에 따라 %rsp 값이 변하며 스택 프레임을 관리한다.

3. 결론 및 고찰 (Conclusion & Takeaways)

  • 핵심 요약:
    • C 코드는 컴파일러와 어셈블러를 거쳐 데이터 크기 접미사가 붙은 어셈블리 명령어로, 최종적으로는 기계어 바이트 시퀀스로 변환된다.
    • CPU는 16개의 범용 레지스터를 핵심 작업 공간으로 사용하며, 데이터 이동의 기본인 MOV 명령어는 메모리 간 직접 이동이 불가능하다는 제약이 있다.
    • unsignedsigned 타입의 확장은 각각 movzmovs 명령어를 통해 기계 수준에서 명확히 구분되어 처리된다.
  • 기술적 통찰 및 나의 생각:
    고급 언어의 추상화 덕분에 개발자는 하드웨어의 복잡한 제약을 신경 쓰지 않아도 되지만, 그 이면에는 레지스터와 메모리 간의 끊임없는 데이터 이동, 스택 포인터의 정교한 움직임이 숨어 있었다. 특히 '메모리 간 직접 이동 불가'라는 규칙은 CPU가 레지스터 중심 아키텍처라는 본질을 명확히 보여준다. 어셈블리 코드를 이해하는 것은 단순히 코드를 번역하는 것을 넘어, 컴파일러가 왜 특정 최적화를 수행하는지, 그리고 프로그램이 메모리를 어떻게 사용하고 관리하는지에 대한 근본적인 통찰을 제공한다는 것을 깨달았다.
  • 향후 과제 / 추가 질문:
    • if-else문이나 for 루프와 같은 제어 흐름은 어셈블리에서 jmp, cmp 같은 조건부 분기 명령어를 통해 어떻게 구현될까?
    • 함수 호출 시 생성되는 '스택 프레임'의 정확한 구조는 무엇이며, %rbp%rsp 레지스터는 이를 어떻게 관리하는가?

4. 참고 자료 (References)

  • Randal E. Bryant, David R. O'Hallaron, "Computer Systems: A Programmer's Perspective"