CS/개념

[CSAPP Chapter3 3.1 ~ 3.4]프로그램의 기계수준 표현

히퓨 2025. 5. 31. 14:15

Chapter 3 읽는 방법(챕터 서문)

“대충 원리는 알아, 디테일은 생략할래” 식으로 접근하면 안 되고, 디테일을 정확히 익히는 것이 필수다.
따라서, 예제를 직접 따라 해보고, 연습문제를 풀어보고, 해답과 비교하면서 학습하는 것이 중요하다

추가로, C의 pointer 개념을 알고 있어야 아래 내용을 이해하기 쉽다

 

3.1 역사적 관점 (생략)


3.2 머신 코드

Chapter 1에서 간략하게 서술하였듯이, 우리는 사람이 이해할 수 있는 '프로그래밍 언어'를 사용한다. 

 

프로그래밍 언어는 실제, 컴퓨터가 동작하는 세밀하고 복잡한 과정들을 '추상화'하였기 때문에, 컴퓨터가 어떻게 동작하는지를 '제대로' 이해하기 위해서는 3장의 어셈블리 언어를 학습해야하는 이다

 

 

 

어셈블리 언어란?
사람이 이해할 수 있는 '프로그래밍 언어'와 기계(컴퓨터)가 이해할 수 있는 머신 코드의 중간 단계에 있는 코드

 

 

 

머신코드는?

  1. CPU가 직접 이해하고 실행하는 최종 코드
  2. 사람이 0과 1로 나열된 코드를 이해하기 어려움
  3. 그래서 나온 것이 어셈블리 코드

 

 

즉, 어셈블리 코드는 사람이 읽고 디버깅하기 위해 필요하다
(코드 최적화 목적, 런타임 이해, 보안 취약점 확인)

 

 

머신 코드, 어셈블리 코드, 프로그래밍 코드는 다음과 같이 구성된다 :

 

 

앞서 서술하였듯이, 컴퓨터는 파일, 가상 메모리, 프로세스 등의 추상화 기법을 사용한다. (Chapter 1 내용 참고)

여기서 추상화란, 복잡한 개념을 핵심으로 집약한 것을 지칭한다.

 

 

머신 코드에서도 다음과 같은 추상화 기법들이 사용된다 :

 

 

1. ISA (Instruction Set Architecture)

CPU가 이해하고 실행할 수 있는 명령어들의 모음과 규칙

 

ISA는 하드웨어(CPU)와 소프트웨어(운영체제, 컴파일러 등) 사이의 인터페이스로
즉, 프로그래머는 ISA만 알면 하드웨어 내부를 몰라도 된다

 

 

예를 들어 add eax, 200이라는 명령어는 "더한다"는 동작만 알면 되고, 실제 내부 연산 방식은 몰라도 된다.

 

 

현대 CPU는 여러 명령어를 동시에 실행하지만, ISA는 마치 하나씩 순차적으로 실행되는 것처럼 보이도록 한다.

 

2. Virtual Memory (가상 메모리)

머신 코드에서 사용하는 주소는 실제 물리적 메모리가 아니라 가상 주소이다.

 

이로 인해 프로그래머는 물리적 위치를 신경 쓰지 않아도 된다. 운영체제가 이 가상 주소를 실제 물리 주소로 변환해주며, 메모리를 보다 효율적이고 안전하게 관리할 수 있게 한다.

 

 

 

 

+ 기계어 특징

I. x86-64 명령어는 1~15 바이트까지 다양한 길이를 가질 수 있다.

  • 컴퓨터는 기계어를 1바이트로 저장함
  • 명령어에 따라, 사용하는 바이트 수가 다름

II. 시작위치만 알면 어떤 명령어인지 알 수 있다!

  • 0x53 → "pushq"라는 명령어로 해석됨
  • 다른 명령어는 0x53으로 시작하지 않음 → 중복되지 않게 설계됨

 

 

3.3 데이터의 형식

목적

  • C 언어에서 사용되는 다양한 데이터 타입x86-64 어셈블리메모리에서 어떻게 표현되는지 이해하는 것

 

워드(word)의 정의

  • 워드는 시스템에서 레지스터 크기기본 주소 단위로 사용되는 기본 데이터 크기
Byte 1
Word 4
Double Word 8
Quad Word 8

 

 

  • 메모리는 바이트 단위로 구성되어 있지만, 대부분의 연산은 워드 단위로 수행
  • 명령어는 워드 크기에 따라 movb (1바이트), movw (2바이트), movl (4바이트), movq (8바이트) 같은 방식으로 나뉘어 있다 
  • => 몇 바이트 크기의 데이터를 옮길 것인가?

 

예:
movq는 8바이트(워드 하나)를 복사한다.
movb는 1바이트만 복사한다.

명령어 크기
movb 1byte
movw 2byte
movl 4byte
movq 8byte

 

 


3.4 정보 접근하기

3.4.1 레지스터

assembly 코드를 이해할 때, 산술적으로 이를 어떻게 해석해야하는가 골머리를 앓는 프로그래머들이 많으리라 생각하여, 3.4 내용을 심도 있고 쉽게 풀어서 설명을 써내려갈 예정이다

 

 

CPU는 64비트 값을 저장할 수 있는 16개의 범용 레지스터를 사용하고 있다

 

레지스터란?
CPU 안에 있는 아주 빠른 기억장소

  • 컴퓨터에서 계산을 하거나 데이터를 처리할 때,
    메모리(RAM)에서 가져오는 것보다 레지스터에 있는 값을 사용하는 게 훨씬 빠름.
  • 따라서, CPU는 자주 사용하는 값들을 레지스터에 저장해두고 바로바로 처리.

 

 

인스트럭션 역할 비고
%rax 반환값 저장 (Return value)  
%rdi, %rsi, %rdx, %rcx, %r8, %r9 함수 인자 전달 (1~6번째 인자)  
%rbx, %rbp, %r12 ~ %r15 Callee가 보존 (Callee-saved)  
%rsp 스택 포인터 (Stack pointer) 런타임 스택의 끝 부분을 가리킨다
%r10, %r11 Caller가 보존 (Caller-saved)  

 

위는 16개의 범용 레지스터를 지칭하는 표이다

 

범용 레지스터는 '범용'이라는 단어로 지칭되나, 실제로는 일정한 관례에 따라 특정한 용도로 자주 사용하는 레지스터들을 지칭 64비트, 32비트, 16비트, 8비트 순으로 레지스터가 중첩되어 있다

 

 

이는, 사용하는 레지스터에 따라서 연산을 적용하는 범위가 달라질 수 있음을 의미한다 예를 들어서 다음과 같다

 

 

mov 명령어란?
사용하는 데이터 크기에 따라 각각 1byte, 2byte, 4byte, 8byte 크기의
데이터를 한 위치(source)에서 다른 위치(dest)로 복사하는 명령
(movb, movw, movl, movq 등이 사용)

 

movq $0x0011223344556677, %rax    # %rax 전체에 64비트 값 저장

movq $-1, %rax                    # %rax = FFFFFFFFFFFFFFFF

movl $-1, %eax                    # %rax = 00000000FFFFFFFF
                                  # 상위 32비트는 자동으로 0이 됨 (zero extension)

movw $-1, %ax                     # %rax = 001122334455FFFF

movb $-1, %al                     # %rax = 00112233445566FF

 

%-1은 상수값을 지칭하며, FF..F를 나타낸다즉시값 $-1의 16진수 표기는 FF...F 형태를 가지며, 위 내용을 통해 사용하는 레지스터에 따라서, 하위 비트를 유동적으로 변경할 수 있음을 알 수 있다

 

 

부록
movl가 상위 32비트를 0으로 채우는 이유는 무엇일까?
  • x86-64 (64비트 아키텍처)는 기존의 x86 (32비트)에서 확장된 구조
  • 그래서 기존의 32비트 레지스터 명령어들 (movl, addl 등)이 여전히 사용될 수 있도록 설계되어야 했습니다.

64비트 레지스터의 하위 32비트인 %eax, %ecx, %edx 등에 값을 쓰면, 자동으로 상위 32비트는 0으로 클리어

 

 

 


 

Caller-saved vs Callee-saved

위 레지스터 표에 Caller-saved 및 Callee-saved라는 명칭이 사용된다

이는, 누가 책임지고 레지스터를 보존할 것이냐에 대한 규약을 나타낸다

 

  • Caller : 호출자
  • Callee : 피호출자
  • Caller-saved : 호출자가 함수 호출 전 레지스터 값 저장 (main이 foo 호출 전에 저장)
  • Callee-saved : 피호출자가 함수 종료 전 레지스터 복원 (foo가 종료 전 복원)
#include <stdio.h>

void foo() {
    // 이 함수는 레지스터를 쓸 수도 있음
}

int main() {
    int x = 10;
    foo();     // foo 호출 후에도 x 값이 유지되어야 함
    printf("%d\n", x);
    return 0;
}

 

 

위 코드에서 foo는 메인에서 호출되는 피호출자(callee)이며, main은 foo를 호출하는 호출자(caller)이다

 

위 코드를 어셈블리어로 번역하면 다음과 같다 :

 

 

 

 

 

Caller-saved 레지스터

main:
    mov $10, %r10         # x 값을 %r10에 저장
    call foo              # foo 호출 

==> main이 call 전에 %r10을 스택에 저장하고, foo 호출 후 다시 복원하면 된다

 

 

  • caller가 $10을 %r10 레지스터에 저장한다
  • foo 호출이 끝난 뒤 다시, %r10에서 값을 복원한다

 

Callee-saved 레지스터

foo:
    push %rbx             # callee-saved인 %rbx 백업 후
    mov $123, %rbx        # %rbx 사용
    ...
    pop %rbx              # 함수 끝나기 전 복원
    ret

 

 


3.4.2 Operand

인스트럭션이 어떻게 연산되는지는 다음 표를 통해 이해할 수 있다

연산의 기본적인 구성요소는 연산을 수행할 소스 값(Source)과 결과를 저장할 목적지(Destination)를 명시한다

 

Immediate $imm 상수값(imm) 자체 즉시값
Register r_a 레지스터의 값 레지스터
Memory 다양한 형식들 메모리 주소로부터 값 간접, 인덱싱 등

 

 

 

다음 문제를 통해서 위 연산을 이해할 수 있다 :

 

문제 3.1

 

 

1. 상수값(Imm)

 

  • 형식: $imm
  • 의미: 상수 값을 그대로 사용 (메모리에서 읽는 게 아님)
  • 예시: %0x108 -> 0x108 (그대로 지칭)

 

2. 절대 주소

  • 형식 : imm
  • 의미 : 메모리 주소 imm에 있는 값
  • 예시 : 0x104 -> 0xAB 

 

3. 간접 주소

  • 형식 : (%reg)
  • 의미 : %reg가 가리키는 메모리 주소
  • 예시 : (%rax) -> 0x100이 가리키는 값 즉, 0xFF

 

다음 괄호 안 연산은 아래 표를 통해서 설명 :

 

 

 

괄호 안에서 3가지 인자를 사용할 수 있다.

 

(괄호 안)

  • 첫 2가지 인자 사용 : 덧셈 연산을 수행
  • 3가지 인자 모두 사용 : (첫번째 인자) + (두번째 인자 * 세번째 인자)를 반환
  • 마지막 2가지 인자 사용 : 괄호 안의 인자를 비울 수 있으며, 곱셈 연산 수행

 

괄호 밖 인자는 상수값을 더한다

 

 


문제 3.2

 

 

위 문제는 어떻게 풀 수 있을까?

 

mov 연산은 옮기려는 데이터 크기에 따라서 사용하는 명령어가 달라진다고 언급했었다.

그렇다면, mov_의 접미사(b, w, l, q)명시하는 기준은 무엇일까?

 

결론적으로, 피연산자 중 "크기가 명확한 쪽"을 기준으로 명렁어의 접미사(b,w,l,q)를 선택하면 된다

 

 

문제 :

mov_ %eax, (%rsp)

 

위 문제에서 %eax(32bit) 레지스터와 %rsp(64bit) 레지스터 중 무엇을 기준으로 해야할까?

 

(%rsp)는 메모리 참조 표현이다. 그렇기 때문에 현재 가리키고 있는 메모리가 어떤 크기의 데이터를 가리키는지 알 수 없다

그렇기 때문에, 32bit를 기준으로 접미사(l)을 사용하면 된다

 

 

 


 

레지스터 명령 사용 시, 메모리 -> 메모리 직접 이동은 불가능하다

 

예시 :

mobw (%rax), 4(%rsp)

 

 

 


3.4.3 자료형 변환(Casting)

 

우리는 char에서 int, int에서 long으로의 변환처럼 다양한 형 변환이 어셈블리에서 어떻게 이루어지는지 살펴볼 것이다.

 

 

부록에서 movl은 상위 4바이트를 0으로 변경한 뒤, 하위 4바이트 값을 덮어 씌운다고 설명했다.

 

위 두 표는 movl과 같이 각 데이터 이동 시, 상위 바이트를 unsigned or signed 방식으로 나타내는 방법을 기술한 것이다

 

 

 

movsX : signed extension

movzX : zero extension (unsigned)

 

* X는 b,w,l,q로 구성

 

예를 들어 다음과 같은 상황에서는 movsbl을 사용한다.

 

char(1byte) -> int(4byte, signed) 

 

1byte를 옮기되, 상위 3byte는 부호를 나타낸다

 

 

 


 

 

3.4.4 스택 데이터의 저장과 추출

스택은 Last-in-Frist-Out (LIFO) 구조이다

 

다음과 같은 특성을 가진다 :

  • 마지막에 들어간 값이 먼저 나옴
  • 스택은 아래 방향(낮은 주소)으로 성장한다
  • 스택의 맨 위(top)는 %rsp 레지스터(stack pointer)가 가리킨다

 

위 내용에서 두드러지는 특징은, 스택이 아래 방향으로 성장한다는 이다

이게 무슨 의미일까? 

 

위는 가상메모리의 단편적인 구조이다.

높은 주소는 스택이 사용하고 있고, 아래에는 heap 영역이 있다.

 

이때, 스택에 데이터가 들어온다면(push) 무슨 일이 일어날까?

 

 

(위 사진의 %esp가 현재 무엇을 가리키는지만 확인)

 

스택 포인터(%rsp)는 스택의 가장 마지막 부분(낮은 주소 쪽)을 가리키고 있다. (위 사진은 32bit 구조이기에, %esp, 32bit 스택 포인터 사용)

 

데이터가 삽입되면 스택포인터를 1바이트만큼 감소시킨 뒤, 값을 저장해야 기존의 스택포인터가 가리키고 있는 값(기존 데이터)을 삭제하지 않을 수 있다

 

어셈블리 코드로 표현하면 다음과 같다

 

subq $8, %rsp
movq %rbp, (%rsp)

 

현재 스택 포인터의 값을 1바이트만큼 감소 시킨 뒤, 입력받은 데이터 %rbp를 현재 스택포인터가 가리키는 메모리 (%rsp)에 저장한다

그러나, 위 명령어는 pushq를 사용하면 한 줄로 사용될 수 있다

 

pushq %rbp

 

 

위 두 코드는 동일한 동작을 수행한다

 

 


pop연산은 다음과 같이 수행된다

 

movq ($rsp), %rax
addq $8, %rsp

 

1. 스택 포인터가 가리키는 메모리를 레지스터(%rax)로 가져옴

2. 스택 포인터를 위로 이동시킨다. (꺼낸 값을 스택에서 제거하는 동작)