[JUNGLE] TIL_14. CSAPP 3.1 ~ 3.4

모깅·2025년 9월 18일

JUNGLE

목록 보기
15/56
post-thumbnail

3.2.1 기계 수준 코드 (Machine-Level Code)

컴퓨터 시스템은 구현의 세부 사항을 단순한 추상 모델로 숨기는 여러 형태의 추상화를 사용합니다. 기계 수준 프로그래밍에서는 다음 두 가지 추상화가 특히 중요합니다.

  1. 명령어 집합 구조 (Instruction Set Architecture, ISA): 프로세서의 상태, 명령어의 형식, 각 명령어의 효과 등을 정의합니다. ISA는 마치 프로그램의 각 명령어가 순차적으로 하나씩 실행되는 것처럼 동작 방식을 정의합니다. (실제 하드웨어는 여러 명령어를 동시에 처리하지만, 순차적으로 실행된 것과 동일한 결과를 보장합니다.)
    • 개발자: 고급 언어(C, Java 등)로 소스 코드를 작성합니다.
    • 컴파일러: ISA(사용 설명서)를 참조하여 소스 코드를 CPU가 알아들을 수 있는 기계어로 번역합니다.
    • CPU (하드웨어): 번역된 기계어 명령어를 하나씩 가져와 읽습니다.
    • 실행: CPU는 가져온 명령어가 ISA에 정의된 것과 일치하면, 미리 설계된 내부 회로를 동작시켜 명령을 수행합니다.
  2. 가상 주소 (Virtual Address): 기계어 프로그램이 사용하는 메모리 주소입니다. 이 주소는 프로그램에게 메모리가 매우 큰 바이트 배열인 것처럼 보이게 하는 메모리 모델을 제공합니다. (실제 메모리 시스템은 여러 하드웨어와 운영체제 소프트웨어의 조합으로 구현됩니다.)

C언어와 기계어 코드의 차이점

컴파일러는 C언어의 추상적인 실행 모델을 프로세서가 실행하는 매우 기본적인 명령어들로 변환하는 대부분의 작업을 수행합니다. 어셈블리어는 기계어와 매우 가깝지만, 사람이 더 읽기 편한 텍스트 형식이라는 특징이 있습니다.

C언어와 달리 기계어 코드에서는 C 프로그래머에게는 일반적으로 보이지 않던 프로세서의 여러 상태 정보가 드러납니다.

  • 프로그램 카운터 (Program Counter, PC): 다음에 실행할 명령어의 메모리 주소를 가리킵니다. (x86-64에서는 %rip라고 불림)
  • 정수 레지스터 파일 (Integer Register File): 64비트 값을 저장하는 16개의 이름 있는 저장 공간입니다. 주소(포인터)나 정수 데이터를 담을 수 있으며, 일부는 프로그램의 중요 상태를 추적하고 다른 일부는 함수의 인자, 지역 변수, 반환 값 등 임시 데이터를 저장하는 데 사용됩니다.
  • 조건 코드 레지스터 (Condition Code Registers): 가장 최근에 실행된 산술/논리 명령어의 상태 정보(결과가 양수인지, 0인지 등)를 저장합니다. ifwhile문과 같은 조건부 흐름 변경을 구현하는 데 사용됩니다.
  • 벡터 레지스터 (Vector Registers): 여러 개의 정수 또는 부동소수점 값을 한 번에 담을 수 있는 레지스터입니다.

또한, C언어에서는 다양한 자료형을 선언할 수 있지만, 기계어는 메모리를 단순히 거대한 바이트 주소 배열로만 봅니다. 배열이나 구조체 같은 C의 복합 데이터 타입은 기계어에서 연속된 바이트 덩어리로 표현될 뿐이며, 부호 있는 정수와 없는 정수, 심지어 포인터와 정수 사이에도 구분이 없습니다.

결론적으로, 하나의 기계어 명령어는 매우 기초적인 작업(두 숫자 더하기, 데이터 전송 등)만 수행하므로, 컴파일러는 C언어의 복잡한 구문(산술식, 반복문, 함수 호출 등)을 구현하기 위해 이러한 기본 명령어들의 연속을 생성해야 합니다.

Q. 왜 64비트 워드에서 48비트만 사용할까?

2482^{48} = 256테라를 표현할 수 있으며 데이터를 표현할 때 이 정도면 충분합니다. 16비트는 나중에 확장성을 위해서 존재한다고 생각하면 됩니다.

3.2.2 코드 예시

C언어 소스 코드가 어떤 과정을 거쳐 최종 실행 파일이 되는지, 그리고 각 단계의 파일이 어떻게 생겼는지 구체적인 예시를 통해 살펴보겠습니다.

1. 소스 코드에서 어셈블리어 코드로

먼저, C 소스 코드(mstore.c)를 컴파일하여 어셈블리어 파일(.s)을 생성할 수 있습니다.
linux> gcc -Og -S mstore.c

  • S 옵션: 컴파일러가 소스 코드를 어셈블리어로 변환한 후, 그 다음 단계(어셈블, 링킹)를 진행하지 않고 멈추도록 합니다.

생성된 mstore.s 파일에는 다음과 같은 어셈블리어 코드가 포함됩니다. 각 줄은 하나의 기계어 명령어에 해당하며, 변수명이나 자료형 같은 C언어의 추상적인 정보는 모두 사라진 것을 볼 수 있습니다.

multstore:
    pushq   %rbx
    movq    %rdx, %rbx
    call    mult2
    movq    %rax, (%rbx)
    popq    %rbx
    ret

2. 어셈블리어에서 오브젝트 코드로

다음으로, 컴파일과 어셈블을 함께 진행하여 오브젝트 파일(.o)을 생성할 수 있습니다.
linux> gcc -Og -c mstore.c

  • c 옵션: 어셈블 단계까지만 수행하여 오브젝트 파일을 생성합니다.

생성된 mstore.o 파일은 이진(binary) 형식이므로 텍스트 편집기로는 내용을 볼 수 없습니다. 하지만 이 파일 안에는 위 어셈블리어 코드에 해당하는 14바이트 길이의 기계어 코드(16진수로 53 48 89 ... c3)가 포함되어 있습니다.

3. 기계어 코드 확인하기: 디스어셈블러

기계어 파일의 내용을 사람이 읽을 수 있는 형태로 보기 위해 디스어셈블러(disassembler)를 사용합니다. 리눅스의 objdump 명령어가 이 역할을 합니다.
linux> objdump -d mstore.o

  • d 옵션: 오브젝트 파일의 코드 부분을 디스어셈블하여 보여줍니다.

결과:

0: 53                    push   %rbx
1: 48 89 d3              mov    %rdx,%rbx
4: e8 00 00 00 00        callq  9 <multstore+0x9>
9: 48 89 03              mov    %rax,(%rbx)
c: 5b                    pop    %rbx
d: c3                    retq

왼쪽에는 16진수 기계어 코드가, 오른쪽에는 그에 해당하는 어셈블리어 명령어가 표시됩니다. 이로부터 다음과 같은 특징을 알 수 있습니다.

  • x86-64 명령어는 길이가 1에서 15바이트까지 다양합니다.
  • 기계어 바이트 순서만 보고도 원래의 어셈블리 명령어를 유일하게 해독할 수 있습니다.

Q. 명령어가 1~15바이트라면 CPU는 어디서부터 어디까지가 하나의 명령어인지 어떻게 알 수 있을까?

CPU는 명령어의 첫 번째 바이트(Opcode)를 먼저 읽고, 그 내용에 따라 '다음에 몇 바이트를 더 읽어야 하는지'를 스스로 판단하여 명령어의 끝을 알게 됩니다.

  • Opcode 읽기: CPU는 PC가 가리키는 위치의 첫 바이트를 읽습니다. 이 바이트가 바로 핵심 명령어(Opcode)입니다.
  • 길이 결정: 이 Opcode는 CPU에게 "나는 MOV 명령어다. 내 뒤에는 나를 보충 설명하는 바이트가 하나 더 필요하다"와 같은 정보를 알려줍니다.
  • 추가 정보 읽기: CPU는 그 정보에 따라 다음 바이트를 읽습니다. 이 두 번째 바이트는 "나는 레지스터 두 개를 사용할 것이고, 추가 주소 정보는 필요 없다" 와 같은 세부 정보를 담고 있을 수 있습니다.
  • 명령어 완성: 이렇게 필요한 바이트를 모두 읽고 나면, CPU는 하나의 완전한 명령어가 완성되었음을 알게 됩니다. 그리고 PC는 지금까지 읽은 바이트 수만큼 증가하여, 정확히 다음 명령어의 시작 위치를 가리키게 됩니다.

Q. 명령어, 필요한 값들은 모두 1워드에 다 표현이 가능할까?

아니요, 하나의 워드에 모든 것을 담는 것은 불가능한 경우가 많습니다. 특히 명령어에 큰 숫자나 메모리 주소 같은 추가 정보가 필요할 때 그렇습니다.

이것은 CPU의 설계 방식인 RISCCISC 아키텍처의 차이와 관련이 깊습니다.


RISC 아키텍처: '1워드 = 1명령어'를 지향

RISC (Reduced Instruction Set Computer, 예: ARM) 방식은 단순함을 추구합니다.

  • 원칙: 대부분의 명령어를 고정된 크기인 1워드에 담으려고 노력합니다. 예를 들어, 32비트 RISC CPU는 대부분의 명령어가 32비트입니다.
    add r1, r2, r3 (r2와 r3 레지스터를 더해 r1에 저장하라)
  • 한계: 하지만 32비트짜리 상수 값을 레지스터에 넣는 경우, "상수 값을 로드하라"는 명령어와 32비트 상수 값을 하나의 32비트 워드에 담을 수 없습니다. 공간이 부족하기 때문이죠. 이럴 때는 여러 개의 명령어를 조합하거나 메모리에서 값을 읽어오는 방식을 사용합니다.

CISC 아키텍처 (x86): 가변 길이 명령어

CISC (Complex Instruction Set Computer, 예: 인텔/AMD) 방식은 유연성을 추구합니다. 명령어의 길이가 필요에 따라 1바이트에서 최대 15바이트까지 늘어날 수 있습니다.

  • 1바이트 명령어: inc eax (eax 레지스터의 값을 1 증가시켜라)
    • 이 명령어는 매우 단순해서 1바이트만으로 표현이 가능합니다.
  • 여러 바이트 명령어 (1워드 초과): add eax, 12345678h (eax 레지스터에 16진수 12345678을 더해라)
    • 이 명령어는 다음과 같이 구성됩니다.
      • Opcode: "eax 레지스터에 상수를 더하라"는 의미의 명령어 코드 (1~2 바이트)
      • 상수 값: 숫자 12345678h 자체 (4 바이트)
    • 전체 명령어의 길이는 5바이트 이상이 되어, 32비트(4바이트) 워드 하나에 절대 담을 수 없습니다.

결론적으로, '1워드 = 1명령어'는 RISC의 이상적인 목표일 뿐, 모든 경우에 성립하지 않으며, x86 같은 CISC 아키텍처에서는 명령어의 길이가 매우 유연하여 하나의 워드를 훌쩍 넘는 경우가 많습니다.

4. 오브젝트 코드에서 최종 실행 파일로

이제 링커를 사용하여 여러 오브젝트 파일과 라이브러리를 묶어 최종 실행 파일을 만듭니다.
linux> gcc -Og -o prog main.c mstore.c

prog라는 이름의 실행 파일이 생성됩니다. 이 파일에는 우리가 작성한 코드 외에, 프로그램을 시작하고 종료하며 운영체제와 상호작용하는 데 필요한 다른 코드들이 포함되어 용량이 더 커집니다.

objdump -d prog 명령어로 실행 파일을 디스어셈블하면, .o 파일의 결과와 거의 동일한 코드를 볼 수 있지만 다음과 같은 차이점이 있습니다.

  1. 주소 변경: 링커가 코드의 메모리상 위치를 재배치했기 때문에 주소 값이 달라집니다.
  2. 주소 확정: callq 명령어에서 호출할 함수(mult2)의 정확한 주소가 채워집니다. 링커의 주요 역할 중 하나가 바로 이것입니다.

주소 변경(재배치)은 여러 개의 분리된 코드 조각들을 하나의 큰 프로그램으로 합치면서 주소를 재조정하는 것이고, 주소 확정은 그 과정에서 '나중에 찾아갈게'라고 표시해 둔 미완성된 함수 호출 주소를 완성시켜주는 것입니다.

비유: 여러 원고를 모아 책 한 권 만들기 📚

이 과정을 여러 저자가 쓴 원고를 모아 한 권의 책으로 엮는 편집자의 일에 비유할 수 있습니다.

  • 오브젝트 파일 (main.o, mstore.o): 각자 독립적으로 작성된 '1장 원고''2장 원고'. 각 원고는 페이지 번호가 1쪽부터 시작합니다.
  • 링커 (Linker): 이 원고들을 모아 책을 만드는 편집자.
  • 실행 파일 (prog): 페이지 번호가 모두 매겨진 최종 완성본 책.

1. 주소 변경 (재배치): 페이지 번호 다시 매기기

문제점: '1장 원고'의 5쪽과 '2장 원고'의 5쪽은 서로 다른 내용이지만 페이지 번호가 같습니다. 이대로는 책을 만들 수 없습니다.

편집자의 일 (링커의 역할):
편집자는 1장 원고(100쪽 분량)를 책의 맨 앞에 놓고, 2장 원고를 그 뒤에 붙이기로 결정합니다.
그리고 2장 원고의 모든 페이지 번호를 다시 매깁니다.

  • '2장 원고'의 1쪽 → 책 전체의 101쪽
  • '2장 원고'의 5쪽 → 책 전체의 105쪽

이것이 바로 주소 변경(재배치, Relocation)입니다. 컴파일될 때 0번지부터 시작했던 각 오브젝트 파일(main.o, mstore.o)의 상대 주소들을, 최종 실행 파일이라는 큰 공간 안에서 각자 들어갈 위치에 맞게 절대 주소로 재조정해주는 과정입니다.


2. 주소 확정: 상호 참조 완성하기

문제점: 1장 원고를 쓴 저자는 2장의 내용이 필요해서 다음과 같이 메모를 남겼습니다.

"(자세한 내용은 [2장]을 참고하세요)"

저자는 원고를 쓸 당시에는 2장이 최종적으로 몇 쪽에 들어갈지 알 수 없었습니다. 컴파일 시점의 callq 0 (주소가 미정인 함수 호출)이 바로 이 상태입니다.

편집자의 일 (링커의 역할):
편집자는 이제 2장이 101쪽부터 시작한다는 것을 압니다. 그래서 1장의 메모로 돌아가 빈칸을 채워 넣습니다.

"(자세한 내용은 101쪽을 참고하세요)"

이것이 바로 주소 확정(Resolution)입니다. 링커는 main.o에 있던 "나중에 mult2 함수를 호출할게"라는 표시를 찾습니다. 그리고 재배치를 통해 확정된 mult2 함수의 최종 주소(예: 0x40058b)를 가져와 빈칸에 정확히 채워 넣어, 완전한 함수 호출 명령(callq 40058b)을 완성시킵니다.

3.2.3 형식에 대한 설명

GCC 컴파일러가 생성하는 어셈블리어 파일은 사람이 읽기 어렵습니다. 불필요한 정보가 많고, 프로그램이 어떻게 동작하는지에 대한 설명도 전혀 없기 때문입니다.

예를 들어, gcc -Og -S mstore.c 명령어로 생성된 mstore.s 파일의 전체 내용은 다음과 같습니다.

    .file   "mstore.c"
    .text
    .globl  multstore
    .type   multstore, @function
multstore:
    pushq   %rbx
    movq    %rdx, %rbx
    call    mult2
    movq    %rax, (%rbx)
    popq    %rbx
    ret
    .size   multstore, .-multstore
    ...

여기서 .으로 시작하는 줄들은 어셈블러와 링커를 위한 지시어(directives)로, 우리는 일반적으로 이 부분들을 무시해도 됩니다.

학습을 위한 어셈블리어 코드 형식

이 책에서는 어셈블리어 코드를 더 명확하게 보여주기 위해, 대부분의 지시어는 생략하고 줄 번호와 주석을 추가한 형태로 제공합니다.

  • 주석이 달린 어셈블리어 예시:
// void multstore(long x, long y, long *dest)
// x는 %rdi, y는 %rsi, dest는 %rdx 레지스터에 저장됨
1 multstore:
2   pushq   %rbx        // %rbx 값 저장
3   movq    %rdx, %rbx  // dest를 %rbx로 복사
4   call    mult2       // mult2(x, y) 함수 호출
5   movq    %rax, (%rbx) // 결과를 *dest에 저장
6   popq    %rbx        // %rbx 값 복원
7   ret                 // 반환

이러한 형식은 각 명령어가 어떤 역할을 하고 원본 C 코드와 어떻게 관련되는지 쉽게 파악할 수 있도록 도와줍니다.


C 코드에 어셈블리어 통합하기

경우에 따라 프로그래머는 저수준(low-level) 기능을 사용하기 위해 직접 어셈블리 코드를 작성해야 할 때가 있습니다.

  1. 별도 파일 작성: 함수 전체를 어셈블리어로 작성한 뒤, 링킹 단계에서 C 함수와 결합하는 방법.
  2. 인라인 어셈블리 (Inline Assembly): GCC가 지원하는 기능을 사용하여 C 코드 내부에 직접 어셈블리 코드를 삽입하는 방법.

3.3 데이터 형식 (Data Formats)

인텔 x86 아키텍처는 16비트에서 시작하여 32비트로 확장된 역사 때문에 용어 사용에 특징이 있습니다.

  • 워드 (word): 16비트 (2바이트)
  • 더블 워드 (double words): 32비트 (4바이트)
  • 쿼드 워드 (quad words): 64비트 (8바이트)

C언어 자료형과 x86-64 표현

x86-64 아키텍처에서 C언어의 기본 자료형은 다음과 같이 표현됩니다.

  • int: 더블 워드 (32비트)
  • 포인터 (char *) 및 long: 쿼드 워드 (64비트)
  • float: 단정밀도 (4바이트)
  • double: 배정밀도 (8바이트)

어셈블리어 명령어의 크기 지정 접미사

GCC 컴파일러가 생성하는 대부분의 어셈블리어 명령어는 처리할 데이터의 크기를 나타내는 한 글자짜리 접미사(suffix)를 가집니다.

접미사명칭크기C 자료형 예시
bByte1 바이트char
wWord2 바이트short
lLong4 바이트int, float
qQuad8 바이트long, double, 포인터

예를 들어, 데이터 이동 명령어인 mov는 다루는 데이터의 크기에 따라 movb, movw, movl, movq 네 가지 형태로 나뉩니다.

여기서 32비트(4바이트)를 의미하는 접미사로 l을 사용하는 이유는, 과거 16비트 시절에 32비트를 '긴 워드(long word)'로 간주했기 때문입니다. int(4바이트)와 double(8바이트)에 서로 다른 크기임에도 불구하고 같은 접미사 l이 사용될 때도 있는데, 이는 부동소수점 연산이 완전히 다른 명령어와 레지스터 세트를 사용하기 때문에 혼동의 여지가 없습니다.

3.4 정보 접근하기

x86-64 CPU는 64비트 값을 저장하는 16개의 범용 레지스터를 가지고 있습니다. 이 레지스터들은 정수 데이터와 포인터(메모리 주소)를 저장하는 데 사용됩니다.

레지스터의 발전 과정과 이름 규칙

레지스터들의 이름은 모두 %r로 시작하지만, 명령어 집합의 역사적인 발전 과정 때문에 여러 다른 이름 규칙을 따릅니다.

  1. 16비트 (8086 시절): %ax부터 %bp까지 8개의 16비트 레지스터가 있었습니다. 각 레지스터는 특정 목적을 가졌으며 그에 맞는 이름이 붙었습니다.
  2. 32비트 (IA32): 기존 레지스터들이 32비트로 확장되면서 %eax부터 %ebp까지로 이름이 바뀌었습니다.
  3. 64비트 (x86-64): 기존 8개의 레지스터가 64비트로 확장되면서 %rax부터 %rbp로 이름이 바뀌었고, 추가로 8개의 새로운 레지스터(%r8 ~ %r15)가 도입되었습니다.

레지스터 접근 방식

명령어는 이 16개 레지스터의 하위 바이트에 저장된 다양한 크기의 데이터에 접근할 수 있습니다.

  • 1바이트 연산: 가장 낮은 1바이트에 접근 (예: %al)
  • 2바이트 연산: 가장 낮은 2바이트에 접근 (예: %ax)
  • 4바이트 연산: 가장 낮은 4바이트에 접근 (예: %eax)
  • 8바이트 연산: 64비트 레지스터 전체에 접근 (예: %rax)

레지스터 값 변경 규칙

8바이트보다 작은 값을 레지스터에 쓸 때, 나머지 상위 바이트들은 다음과 같은 규칙에 따라 처리됩니다.

  • 1 또는 2바이트 값을 쓸 때: 나머지 상위 바이트들은 변경되지 않고 그대로 유지됩니다.
  • 4바이트 값을 쓸 때: 나머지 상위 4바이트들은 모두 0으로 설정됩니다. (IA32 → x86-64 확장 시 채택된 규칙)

레지스터의 역할

16개의 레지스터는 프로그램에서 각기 다른 역할을 수행합니다.

  • %rsp (스택 포인터): 런타임 스택의 끝 위치를 가리키는 특별한 용도의 레지스터입니다.
  • 나머지 15개: 더 유연하게 사용되지만, 표준 프로그래밍 규약에 따라 다음과 같은 용도로 사용됩니다.
    • 함수 인자 전달
    • 함수 반환 값 저장
    • 지역 변수 및 임시 데이터 저장

3.4.1 피연산자 지정자 (Operand Specifiers)

대부분의 기계어 명령어는 연산에 사용할 소스(source) 값과 결과값을 저장할 목적지(destination)를 지정하는 하나 이상의 피연산자(operand)를 가집니다.

x86-64는 세 가지 유형의 피연산자 형식을 지원합니다.

1. 즉시 값 (Immediate)

상수 값을 직접 사용하는 방식입니다. 어셈블리어에서는 $ 기호 뒤에 숫자를 붙여 표현합니다.

  • 예시: $577, $0x1F

2. 레지스터 (Register)

CPU 내부의 레지스터에 저장된 값을 사용하는 방식입니다. 16개의 범용 레지스터 중 하나를 지정하여 그 안의 값을 직접 사용합니다.

  • 예시: %rax, %rdi

3. 메모리 참조 (Memory Reference)

메모리 주소를 계산하여 해당 위치의 값을 사용하는 방식입니다. 이 계산된 주소를 유효 주소(effective address)라고 합니다. x86-64는 다양한 주소 지정 모드(addressing modes)를 제공하는데, 가장 일반적인 형태는 다음과 같습니다.

Imm(rb, ri, s)

이 형식은 네 가지 요소로 구성됩니다.

  • Imm: 즉시 값 오프셋 (상수)
  • rb: 베이스(base) 레지스터 (시작 주소)
  • ri: 인덱스(index) 레지스터 (상대적 위치)
  • s: 스케일(scale) 인수 (1, 2, 4, 8 중 하나)

유효 주소는 다음 공식으로 계산됩니다.

유효 주소 = Imm + R[rb] + R[ri] * s

  • R[r]는 레지스터 r에 저장된 값을 의미합니다.

이러한 복잡한 주소 지정 모드는 주로 배열이나 구조체의 특정 요소에 접근할 때 매우 유용합니다. 예를 들어, rb에 배열의 시작 주소를 넣고, ri에 인덱스 i를, s에 배열 요소의 크기를 넣어 array[i]에 효율적으로 접근할 수 있습니다.

3.4.2 데이터 이동 명령어

가장 많이 사용되는 명령어는 데이터를 한 위치에서 다른 위치로 복사하는 것들입니다. x86-64에서는 피연산자를 다양하게 지정할 수 있어, 간단한 이동 명령어 하나로도 여러 가지 상황을 처리할 수 있습니다.

1. 단순 이동: MOV 클래스

MOV 클래스는 데이터를 변환 없이 그대로 복사하는 가장 기본적인 명령어 그룹입니다. 데이터 크기에 따라 네 가지 변형(movb, movw, movl, movq)이 있습니다.

  • 소스(Source): 상수(Immediate), 레지스터, 또는 메모리 위치
  • 목적지(Destination): 레지스터 또는 메모리 위치
  • 제약사항: 소스와 목적지가 동시에 메모리일 수는 없습니다. 메모리 간의 복사는 레지스터를 거치는 두 단계(메모리→레지스터, 레지스터→메모리)로 이루어집니다. → 메모리-메모리 직접 복사가 금지된 이유

    규칙: mov [주소1], [주소2] 와 같이 메모리에서 메모리로 직접 데이터를 복사할 수 없다.

    이유: CPU의 설계 철학 때문입니다. CPU는 모든 연산과 데이터 처리가 자신의 내부(레지스터)를 거치도록 설계되었습니다. 이는 CPU의 제어 회로를 단순하게 만들고, 데이터의 흐름을 예측 가능하게 하여 성능을 높이는 데 도움이 됩니다.
  • 특별 규칙: movl 명령어로 4바이트 값을 레지스터에 쓸 경우, 해당 레지스터의 상위 4바이트는 0으로 채워집니다. → 32비트에서 64비트 아키텍처로 옮기면서 발생하는 일

movabsq: 일반 movq가 32비트로 표현 가능한 상수만 다룰 수 있는 것과 달리, 이 명령어는 임의의 64비트 상수를 레지스터로 옮길 수 있습니다.

2. 0 확장 이동: MOVZ 클래스

MOVZ 클래스는 더 작은 크기의 소스 값을 더 큰 크기의 목적지 레지스터로 복사할 때, 남는 상위 비트들을 모두 0으로 채우는 명령어 그룹입니다. 주로 부호 없는(unsigned) 수를 확장할 때 사용됩니다.

  • 명령어 형식: movz + 소스 크기 접미사 + 목적지 크기 접미사
    • movzbw: byte(1) → word(2)로 0 확장 이동
    • movzbl: byte(1) → long(4)로 0 확장 이동
    • movzwl: word(2) → long(4)로 0 확장 이동

movzlq는 왜 없을까?: 4바이트(l)를 8바이트(q)로 0 확장하는 movzlq 명령어는 없습니다. 대신 movl 명령어를 사용하면 목적지 레지스터의 상위 4바이트가 자동으로 0으로 채워지기 때문에, 동일한 효과를 얻을 수 있습니다.

3. 부호 확장 이동: MOVS 클래스

MOVS 클래스는 더 작은 크기의 소스 값을 더 큰 크기의 목적지 레지스터로 복사할 때, 남는 상위 비트들을 소스의 부호 비트(MSB)로 채우는 명령어 그룹입니다. 주로 부호 있는(signed) 수를 확장할 때 사용됩니다.

  • 명령어 형식: movs + 소스 크기 접미사 + 목적지 크기 접미사
    • movsbw: byte(1) → word(2)로 부호 확장 이동
    • movslq: long(4) → quad(8)로 부호 확장 이동
    • movswl, movsbq, movswq 등 모든 조합이 가능합니다.

cltq: 이 명령어는 피연산자가 없습니다. 항상 %eax(4바이트 소스)의 값을 부호 확장하여 %rax(8바이트 목적지)에 저장합니다. movslq %eax, %rax 명령어와 기능은 동일하지만, 1바이트로 더 간결하게 표현됩니다.

cltq 명령어의 세부 사항

1. 이름의 의미

cltqConvert Long to Quad의 약자입니다.

  • l (Long): 32비트 더블 워드 (int)
  • q (Quad): 64비트 쿼드 워드 (long)

즉, "32비트 값을 64비트로 변환하라"는 뜻입니다.

2. 동작 방식: 부호 확장

cltq%eax(32비트)의 부호 비트 (31번째 비트)를 찾아서, %rax(64비트)의 비어있는 상위 32개 비트에 그대로 복사하여 채웁니다.

  • %eax가 양수일 때 (예: 0x00000005):
    부호 비트가 0이므로, %rax0x0000000000000005가 됩니다. (0 확장)
  • %eax가 음수일 때 (예: 0xFFFFFFFF):
    부호 비트가 1이므로, %rax0xFFFFFFFFFFFFFFFF가 됩니다. (부호 확장)

3. 왜 필요한가?

64비트 프로그래밍에서는 32비트 int 타입의 계산 결과를 64비트 포인터나 long 타입의 주소 계산에 사용해야 하는 경우가 매우 흔합니다. 예를 들어, 어떤 함수가 int 값을 반환하면 그 값은 %eax에 저장되는데, 이 값을 배열의 인덱스로 사용하려면 64비트로 안전하게 확장해야 합니다.

cltq는 이처럼 매우 빈번하게 일어나는 작업을 단 1바이트의 명령어로 처리하기 위해 만들어진 최적화 전용 명령어입니다.

3.4.3 데이터 이동 예시

데이터 이동 명령어가 실제 코드에서 어떻게 사용되는지 exchange라는 함수를 통해 살펴보겠습니다. 이 함수는 포인터가 가리키는 메모리의 값과 다른 변수의 값을 서로 교환합니다.

(a) C 코드

long exchange(long *xp, long y)
{
    long x = *xp; // 1. 포인터 xp가 가리키는 메모리의 값을 변수 x에 저장
    *xp = y;      // 2. xp가 가리키는 메모리에 변수 y의 값을 저장
    return x;     // 3. 원래 값이었던 x를 반환
}

(b) 어셈블리어 코드

GCC 컴파일러는 위 C 코드를 다음과 같은 단 3개의 기계어 명령어로 번역합니다.

// xp는 %rdi 레지스터에, y는 %rsi 레지스터에 저장되어 있음
1 exchange:
2   movq    (%rdi), %rax    // xp가 가리키는 값을 %rax로 가져온다. (반환값이 됨)
3   movq    %rsi, (%rdi)    // y의 값을 xp가 가리키는 곳에 저장한다.
4   ret                     // 반환한다.

코드 분석

  • 인자 전달: 함수가 시작될 때, 첫 번째 인자인 포인터 xp%rdi 레지스터에, 두 번째 인자인 y%rsi 레지스터에 저장되어 있습니다.
  • 2번 라인 movq (%rdi), %rax:
    • (%rdi)%rdi 레지스터에 저장된 주소가 가리키는 메모리 위치를 의미합니다. (C언어의 xp)
    • 이 명령어는 xp의 값을 읽어서 %rax 레지스터로 복사합니다.
    • %rax는 함수가 값을 반환할 때 사용하는 약속된 레지스터이므로, 이 명령어 하나로 C 코드의 long x = *xp;return x; 두 가지 역할을 동시에 수행합니다.
  • 3번 라인 movq %rsi, (%rdi):
    • %rsi 레지스터(변수 y의 값)의 값을 (%rdi) (즉, xp)가 가리키는 메모리 위치에 덮어씁니다.
    • 이는 C 코드의 xp = y;를 직접 구현한 것입니다.

이 예시의 핵심 교훈

  1. 포인터는 주소다: C언어에서 '포인터'라고 부르는 것은 기계어 수준에서는 단순히 메모리 주소입니다. 포인터를 역참조(xp)하는 것은 그 주소 값을 레지스터로 사용해 메모리에 접근하는 것과 같습니다.
  2. 지역 변수는 레지스터에 x와 같은 함수 내의 지역 변수는 느린 메모리(스택)가 아닌 빠른 레지스터에 저장되어 사용되는 경우가 많습니다. 이 예시에서는 지역 변수 x가 반환 값을 담는 %rax 레지스터에 바로 저장되어 효율을 높였습니다.

Q. 왜 지역 변수만 레지스터에 저장되어 효율을 높이는 걸까? 공유 변수는 그럴 수 없는 걸까?

공유 변수도 '아주 잠깐'은 레지스터에 올라갈 수 있지만, 지역 변수처럼 오랫동안 머물기는 매우 어렵습니다.

지역 변수만이 컴파일러 최적화를 통해 오랫동안 레지스터에 상주하며 성능을 높일 수 있는 거의 유일한 대상이기 때문입니다. 공유 변수는 '데이터 일관성(Consistency)' 문제와 '관리의 복잡성' 때문에 그렇게 할 수 없습니다.

공유 변수가 레지스터에 오래 머물 수 없는 이유

1. 데이터 일관성 문제 (캐시 일관성과 유사)

만약 CPU-A가 공유 변수를 자신의 레지스터에 올려두고 작업을 하는데, 그 사이에 CPU-B가 메모리에 있는 원본 공유 변수의 값을 바꿔버리면, CPU-A의 레지스터에 있는 값은 더 이상 최신 값이 아니게 됩니다. 이는 심각한 데이터 불일치 문제를 일으킵니다. 따라서 공유 변수는 항상 메모리에 있는 원본을 기준으로 다루어야 합니다.

2. 관리의 복잡성 (문맥 교환, 함수 호출)

레지스터는 함수가 호출되거나, 다른 스레드로 작업이 전환(문맥 교환)될 때마다 내용이 계속 바뀌는 매우 바쁜 공간입니다. 어떤 공유 변수가 특정 레지스터를 계속 독점하고 있다면, 다른 모든 작업들이 그 레지스터를 사용하지 못하게 되어 시스템 전체의 효율이 떨어집니다.

3.4.4 스택 데이터 PUSH & POP

pushpop은 프로그램 스택(stack)에 데이터를 넣고 빼는 데 사용되는 데이터 이동 명령어입니다.

1. 스택이란?

스택은 '마지막에 들어온 것이 가장 먼저 나가는(Last-In, First-Out)' 원칙으로 동작하는 데이터 구조입니다. x86-64에서 프로그램 스택은 메모리의 특정 영역에 위치하며, 주소가 낮은 쪽으로 자라납니다(grows downward).

  • 스택 포인터 (%rsp): 항상 스택의 가장 꼭대기(top)에 있는 요소의 주소를 가리키는 특별한 레지스터입니다. 스택의 꼭대기는 가장 낮은 주소를 가집니다.

2. pushq 명령어: 스택에 데이터 넣기

pushq 명령어는 8바이트(쿼드 워드) 데이터를 스택에 넣습니다. 이 과정은 두 단계로 이루어집니다.

  1. 스택 포인터(%rsp)를 8만큼 감소시켜 새 공간을 확보합니다.
  2. 새로 확보된 스택의 꼭대기 주소에 데이터를 씁니다.

따라서 pushq %rbp 명령어는 아래 두 명령어와 동일하게 동작합니다.

subq $8, %rsp      ; 1. 스택 포인터를 8 감소
movq %rbp, (%rsp)  ; 2. %rbp의 값을 새 스택 꼭대기에 저장

pushq는 이 두 동작을 1바이트의 더 간결한 명령어로 처리합니다.


3. popq 명령어: 스택에서 데이터 빼기

popq 명령어는 8바이트 데이터를 스택에서 꺼냅니다. 이 과정은 pushq와 반대로 이루어집니다.

  1. 현재 스택의 꼭대기(%rsp가 가리키는 주소)에서 데이터를 읽습니다.
  2. 스택 포인터(%rsp)를 8만큼 증가시켜 공간을 제거합니다.

따라서 popq %rax 명령어는 아래 두 명령어와 동일하게 동작합니다.

movq (%rsp), %rax  ; 1. 스택 꼭대기의 값을 %rax로 읽어옴
addq $8, %rsp      ; 2. 스택 포인터를 8 증가

pop을 하더라도 데이터가 메모리에서 물리적으로 지워지는 것은 아니지만, 스택 포인터가 이동했기 때문에 그 공간은 더 이상 유효한 스택 영역이 아니게 됩니다.

Q. 문맥 교환이 일어날 때마다 스택 포인터와 같은 레지스터들을 PCB를 보고 다시 복원시키는 걸까?

문맥 교환(Context Switch)이 일어날 때마다 스택 포인터를 포함한 모든 레지스터의 상태는 PCB(Process Control Block)에 저장되거나 PCB로부터 다시 복원됩니다.


1. 현재 프로세스 A 중단 → 문맥 저장

  1. 문맥 교환 발생: 타이머 인터럽트 등으로 운영체제가 A 프로세스를 중단하고 B 프로세스를 실행하도록 합니다.
  2. 상태 저장: 운영체제는 현재 CPU의 모든 레지스터 값을 읽어옵니다. 여기에는 당연히 스택 포인터(%rsp)의 현재 주소 값도 포함됩니다.
  3. PCB에 기록: 이 모든 상태 정보를 PCB에 그대로 기록(저장)합니다.

2. 새로운 프로세스 B 실행 → 문맥 복원

  1. 세이브 파일 로드: 운영체제는 이제 실행할 B의 PCB을 찾습니다. 이 파일에는 프로세스 B를 마지막으로 멈췄을 때의 모든 상태 정보가 기록되어 있습니다.
  2. 상태 복원: 운영체제는 프로세스 B의 PCB에 저장된 값들을 가져와 CPU의 실제 레지스터에 그대로 덮어씁니다.
    • 프로세스 B의 마지막 PC 값이 CPU의 PC 레지스터로 들어갑니다.
    • 프로세스 B의 마지막 레지스터 값들이 CPU의 레지스터들로 들어갑니다.
    • 프로세스 B의 마지막 스택 포인터 값이 CPU의 %rsp 레지스터로 들어갑니다.

이제 CPU는 프로세스 B가 마지막으로 멈췄던 바로 그 상태 그대로, 아무 일 없었다는 듯이 실행을 재개합니다. 스택 포인터가 프로세스B의 것으로 바뀌었기 때문에, 프로그램은 이제 프로세스 B의 스택 공간을 사용하게 됩니다.

Q. 스택이 계속 쌓일 수 있을텐데, 어떻게 될까?

스택이 할당된 메모리 공간을 초과하여 계속 쌓이면 스택 오버플로우(Stack Overflow) 에러가 발생하며, 프로그램이 강제 종료됩니다.

Q. 운영체제가 해당 프로그램에 스택 영역을 얼만큼, 어떻게 할당할까?

운영체제는 프로그램이 시작될 때 미리 정해진 기본 크기(예: 리눅스 8MB)를 할당하지만, 실제 물리 메모리(RAM)는 필요해지는 순간에 조금씩 나눠서 할당합니다.


얼만큼? (Stack Size)

스택의 최대 크기는 운영체제나 컴파일러 설정에 따라 미리 정해져 있습니다.

  • 리눅스(Linux): 일반적으로 기본 스택 크기는 8MB입니다.
  • 윈도우(Windows): 일반적으로 1MB를 기본값으로 사용합니다.

이 기본 크기는 프로그래머가 컴파일러 옵션을 통해 변경하거나, 시스템 관리자가 ulimit 같은 명령어로 조절할 수 있습니다.


어떻게? (Allocation Mechanism)

운영체제는 메모리를 효율적으로 사용하기 위해 가상 메모리요구 페이징(Demand Paging)이라는 매우 영리한 방식을 사용합니다.

1단계: 가상 메모리 공간 '예약'

프로그램이 처음 실행될 때, 운영체제는 8MB의 물리 메모리(RAM)를 통째로 할당하지 않습니다. 대신, 해당 프로세스의 가상 주소 공간에 "여기부터 여기까지 8MB는 스택 영역으로 사용할 예정"이라고 영역만 예약해 둡니다.

2단계: 실제 메모리 할당 (요구 페이징)

프로그램이 실행되면서 함수를 호출하고 스택을 실제로 사용하기 시작하면 다음과 같은 일이 벌어집니다.

  1. 페이지 폴트(Page Fault) 발생: 스택 포인터가 아직 실제 물리 메모리가 할당되지 않은 '예약' 영역을 처음으로 건드리는 순간, 페이지 폴트라는 인터럽트가 발생합니다.
  2. 물리 메모리 할당: 운영체제는 이 페이지 폴트를 보고, "아, 이 프로그램이 이제 진짜로 이 스택 공간을 쓰려고 하는구나"라고 인지합니다.
  3. 매핑(Mapping): 운영체제는 비어있는 물리 메모리(RAM) 한 페이지(보통 4KB)를 가져와서, 프로그램이 접근하려던 가상 주소 공간에 연결(매핑)해 줍니다.
  4. 작업 재개: 연결이 끝나면, 프로그램은 아무 일 없었다는 듯이 스택에 데이터를 쓰며 작업을 계속합니다.

이후 스택이 점점 더 깊어져서 할당된 4KB를 넘어서는 새로운 영역을 건드릴 때마다, 이 과정(페이지 폴트 → 물리 메모리 할당 및 매핑)이 반복됩니다.

profile
멈추지 않기

0개의 댓글