6 minute read

Chapter 3에서는 컴퓨터가 실행하는 머신 코드와 이를 사람이 읽을 수 있는 어셈블리 코드로 표현하는 방식에 대해 다룬다. 고급 언어인 C나 Java는 기계 수준의 세부 구현을 보호하지만, 어셈블리 코드는 저수준 명령어를 직접 지정해야 하며, 이는 프로그램의 런타임 동작을 깊이 이해할 수 있는 중요한 기술이다. 컴파일러는 프로그래밍 언어의 규칙과 대상 기계의 명령어 집합을 기반으로 여러 단계를 거쳐 어셈블리 코드를 생성하고, 이를 통해 효율적인 실행을 위한 최적화를 수행한다. 따라서 어셈블리 코드를 읽고 이해함으로써 컴파일러가 수행한 최적화를 분석하고 코드의 비효율성을 확인할 수 있다.

이 장에서는 C 코드가 어셈블리 코드로 변환되는 과정을 탐구하며, 컴파일러가 코드 구조를 변환하고 불필요한 연산을 제거하거나 느린 연산을 더 빠른 방식으로 대체하는 방법을 설명한다. 또한, 배열, 구조체, 유니온 같은 데이터 구조가 기계 수준에서 구현되는 방식을 배우며, 메모리 참조 문제와 버퍼 오버플로우 같은 보안 취약점이 발생하는 원인과 이를 방지하는 방법을 이해하게 된다. 이 과정에서 gdb 디버거를 사용하여 런타임 동작을 조사하는 실용적인 팁도 제공한다.

현대 운영 체제를 타겟으로 하는 C와 같은 언어로 작성된 프로그램의 머신 수준 코드 생성에 초점을 맞추며, x86-64 아키텍처의 주요 특징과 C의 제어 구조와 데이터 표현이 어떻게 구현되는지를 배운다. 더 나아가 32비트에서 64비트로의 기술적 전환과 메모리 용량 증가의 배경을 탐구하며, 현대 프로세서 아키텍처가 가지는 복잡성을 이해하는 데 필요한 기초를 다지게 된다.

3.1 A Historical Perspective

Intel의 x86 프로세서는 1978년 8086에서 시작되어 16비트에서 32비트, 그리고 64비트로 발전하며, 성능 향상과 운영 체제 지원을 위해 진화해왔다. 초기 프로세서는 제한된 메모리와 기능을 가졌지만, 이후의 발전을 통해 평면 주소 모델, 멀티코어, 하이퍼스레딩, AVX와 같은 현대적인 기능을 통합했다.

모든 프로세서는 이전 버전과의 호환성을 유지하도록 설계되었으머, 이로 인해 명령어 집합에 역사적 유산과 복잡성이 남아있다. AMD는 2000년대 이후 x86-64와 고성능 프로세서를 도입하여 Intel과 경쟁력을 강화했다.

현대의 x86-64 아키텍쳐는 주로 Linux와 gcc 컴파일러에서 사용되며, 오래된 기능들은 더 이상 중요한 역할을 하지 않는다.

3.2 Program Encodings

linux> gcc -Og -o p p1.c p2.c
  • gcc는 GCC 컴파일러를 나타내며, linux에서는 cc로도 호출이 가능하다.
  • -Og는 컴파일러에게 최적화를 적용하되, 생성된 머신코드가 원래 C코드의 전체구조를 따르도록 지시한다.
  • 더 높은 최적화 수준 -O1, -O2는 성능 향상에 더 유리하지만, 코드를 이해하기 어렵게 만들 수 있다.
  • gcc 명령은 소스 코드를 실행 가능한 코드로 변환하는 여러 단계를 거친다.
    1. 전처리 : #include 파일 포함 및 매크로 확장
    2. 컴파일 : 컴파일러가 어셈블리 코드 생성(p1.s, p2.s)
    3. 어셈블 : 어셈블러가 바이너리 객체 코드 생성(p1.o, p2.o)
    4. 링킹 : 링커가 객체 코드와 라이브러리 코드를 병합하여 실행 파일 생성(p)
  • 서로 다른 형태의 머신 코드의 관계와 링킹 과정은 chapter7에서 자세하게 다룬다.

3.2.1 Machine-Level Code

Machine-Level Programming에서 중요한 두 가지

  • 명령어 집합 아키텍쳐(ISA)는 machine-level program의 형식과 동작을 정의하며, 프로세서 상태, 명령어 형식, 명령어가 상태에 미치는 영향을 규정한다.
    • 대부분의 ISA(ex. x86-64)는 명령어가 순차적으로 실행되는 것처럼 보이도록 설계되었으나, 실제 하드웨어는 많은 명령어를 동시 실행하며 ISA의 순차적 동작과 일치하도록 보장한다.
  • machin-level programming에서 사용되는 메모리 주소는 가상 주소이다.
    • 머신 코드에서 메모리는 매우 큰 바이트 배열로 보여진다.
    • 메모리는 가상 주소로 접근하며, 운영 체제가 가상 주소를 실제 물리적 주소로 변환한다. (자세한 내용은 chapter9)

컴파일러는 C의 추상적 실행 모델을 프로세서가 실행할 수 있는 간단한 명령어로 변환한다. 어셈블러 코드는 머신 코드에 가깝지만 바이너리 코드보다는 읽기 쉬운 텍스트 형식으로 표현된다. 어셈블리 코드를 이해하고 이를 원래의 C 코드와 연결 지어 이해하는 것은 프로그램 실행 방식을 이해하는 데 중요한 단계이다.

Machine Code와 프로세서 상태

  • 머신 코드는 원래의 C 코드와 크게 다르다. C프로그래머에게 보이지 않던 프로세서 상태가 노출된다.

  • 프로그램 카운터(PC, Program Counter) : 다음에 실행될 명령어의 메모리 주소를 나타낸다. x86-64에서는 %rip로 불린다.
  • 정수 레지스터(Integer register) : 64비트 값을 저장하며, 주소나 정수 데이터를 보관한다. 프로시저의 인수, 지역 변수, 반환 값을 저장하는 데도 사용한다.
  • 조건 레지스터(Condition Code register) : 최근 실행된 산술/논리 명령어의 상태 정보를 저장하며, if와 while 같은 제어 흐름 구현에 사용한다.
  • 벡터 레지스터 : 하나 이상의 정수 또는 부동 소수점 값을 저장한다.

  • C는 다양한 데이터 타입을 선언하고 메모리에 할당할 수 있는 모델을 제공하지만, 머신 코드는 메모리를 단순한 바이트 배열로 간주한다.
  • 배열과 구조체 같은 집합 데이터 타입은 연속된 바이트로 표현되며, 어셈블리 코드는 부호있는 정수, 부호 없는 정수, 포인터 간 차이를 구분하지 않는다.

머신 명령어는 기본적인 작업만 수행하며(ex.레지스터 간 덧셈, 메모리와 레지스터 간 데이터 전송, 조건부 분기), 컴파일러는 이러한 기본 명령어를 시퀀스로 생성하여 산술 연산, 반복문, 프로시저 호출/반환 같은 고수준 프로그램 구조를 구현한다.

3.2.2 Code Examples

long mult2(long, long);
void multstore(long x, long y, long *dest) {
    long t = mult2(x, y);
    *dest = t;
}
  • C컴파일러를 통해 생성된 어셈블리 코드를 보려면 -S 옵션을 커맨드에 넣으면된다.
linux> gcc -Og -S mstore.c
  • 위 명령어는 GCC가 컴파일러를 동작시켜 어셈블리 파일(mstore.s)까지만 생성하도록 한다.(오브젝트 파일 생성 안함)
  • 위 C코드의 어셈블리 코드는 아래와 같다.
 multstore:
    pushq   %rbx 
    movq    %rdx, %rbx
    call    mult2
    movq    %rax, (%rbx)
    popq    %rbx
    ret
  • 각 들여쓰기된 줄은 하나의 기계 명령어이다. 예를 들어 pushq라는 명령어는 레지스터 %rbx를 스택에 push하라는 의미이다.

  • 커맨드-라인에 -c옵션을 사용하면 GCC는 코드를 컴파일하고 어셈블리 과정까지 수행한다.

linux> gcc -Og -c mstore.c
  • 이 명령은 바이너리 객체 코드 파일(mstore.o)를 생성한다. 이 파일은 직접 읽을 수 없는 형식이며, mstore.o 파일의 1368바이트 데이터 중에는 14바이트 길이의 시퀀스가 포함되어있으며, 16진수로 표현된다.
53 48 89 d3 e8 00 00 00 00 48 89 03 5b c3
  • 16진수로 표현된 바이트 시퀀스는 위의 어셈블리 명령어와 일치한다. 즉, 기계가 실행하는 프로그램은 단순히 명령어를 인코딩한 바이트들의 연속이다.
  • 기계는 이러한 명령어가 생성된 소스 코드에 대한 정보를 거의 가지고 있지 않다.

  • 머신 코드 파일의 내용을 확인하기 위해 디스어셈블러(disassembler)를 사용한다. 이 프로그램은 머신 코드에서 어셈블리 코드와 유사한 형식을 생성한다.
  • Linux 시스템에서는 objdump(object dump) 프로그램이 디스어셈블러 역할을 수행한다. (-d를 사용한다.)
linux> objdump -d  mstore.o

Alt text

  • x86-64 명령어 길이
    • x86-64 명령어는 1~15 바이트 길이로 다양하다.
    • 자주 사용되는 명령어나 피연산자가 적은 명령어는 바이트 길이가 짧고, 드물게 사용되거나 연산자가 많은 명령어는 더 많은 바이트를 사용한다.
  • 명령어 형식의 고유성
    • 명령어 형식은 특정 시작 위치에서 바이트를 고유하게 디코딩할 수 있도록 설계되었다.
    • ex. 바이트 값 53으로 시작하는 명령어는 pushq *rbx만 가능하다.
  • 디스어셈블러의 동작
    • 디스어셈블러는 머신 코드 파일의 바이트 시퀀스만을 기반으로 어셈블리 코드를 생성한다.
    • 프로그램의 소스 코드나 어셈블리 코드 버전에 접근할 필요가 없다.
  • 명령어 명명 규칙
    • 디스어셈블러는 gcc가 생성한 어셈블리 코드와 약간 다른 명명 규칙을 사용한다.
      • 많은 명령어에서 q 접미사 생량
      • 반대로, call및 ret 명령어에는 q접미사 추가
    • 이러한 접미사는 대부분의 경우 생략해도 안전하다.
  • 실행 가능한 코드를 생성하려면 object-code files 집합에 대해 Linker를 실행해야 하며, 이 파일 중 하나에는 반드시 main 함수가 포함되어야 한다.

#include <stdio.h>
void multstore(long, long, long *);
int main() {
    long d;
    multstore(2, 3, &d);
    printf("2 * 3 --> %ld\n", d);
    return 0;
}

long mult2(long a, long b) {
    long s = a * b;
    return s; 
}
  • 실행 프로그램 “prog”는 아래 커맨드로 만들 수 있다.
linux> gcc -Og -o prog main.c mstore.c
  • 파일 prog는 8655바이트로 커졌다. 이는 우리가 제공한 프로시저의 머신 코드 뿐만 아니라, 프로그램을 시작하고 종료하며 운영 체제와 상호 작용하는 데 사용되는 코드도 포함되어 있기 때문이다.

  • prog 파일을 디스어셈블하면

linux> objdump -d prog

Alt text

  • 위에서 디스어셈블하여 본 코드와 바로 위에 코드와 비교하면 몇가지 차이점이 있다.

    1. 왼쪽에 나열된 주소가 다름 : 이는 링커가 이 코드의 위치를 다른 주소 범위로 이동시켰기 때문이다.

    2. 링커가 callq 명렁어가 mult2 함수를 호출할 때 사용할 주소를 채워 넣은 점 : 링커의 주요 작업 중 하나는 함수 호출과 해당 함수의 실행 가능한 코드 위치를 연결하는 것이다.

    3. 8,9번 줄에 추가된 두 줄 : 이 명령어들은 반환 명령어 이후에 위치하므로 프로그램에 아무런 영향을 미치지 않는다. 이러한 코드는 함수 크기를 16바이트로 늘려, 메모리 시스템 성능 측면에서 다음 코드 블록의 배치를 더 효율적으로 하기 위해 삽입되었다.

3.2.3 Notes on Formatting

GCC로 생성된 assembly code는 사람이 읽기 어렵다. 고려하지 않아도 되는 정보가 존재하며, 프로그램이 어떻게 동작하는지에 대한 설명이 전혀 제공되지 않는다. 아래 명령어를 작성해보자.

linux> gcc -Og -S mstore.c
  • 아래는 생성된 mstore.s의 내용이다.
        .file   "010-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
        .ident  "GCC: (Ubuntu 4.8.1-2ubuntu1~12.04) 4.
  • ’.’으로 시작되는 줄은 어셈블러와 링커를 위한 가이드이고 일반적으로 무시해도된다.

Alt text

  • 위 코드는 어셈블러와 링커를 위한 지시문을 생략한 코드이다.
  • 또한 왼쪽에 참조를 위한 번호가 있으며, 오른쪽에는 명령어의 효과와 그것이 원래 C 코드의 계산과 어떻게 관련되는지에 대한 간단한 설명이 주석으로 추가된다. 이는 어셈블리 언어 프로그래머들이 코드를 형식화하는 방식의 정형화된 버전이다.

3.3 Data Formats

Alt text

1. 데이터 타입 표현

  • Inter은 16비트 데이터를 “워드(word)”, 32비트 데이터를 “double word”, 64비트 데이터를 “quad word”로 부른다.
  • x86-64에서 int는 32비트, 포인터와 long 데이터 타입은 64비트(쿼드 워드)로 저장된다.

2. 부동소수점 데이터

  • 단정밀도(4바이트) : float
  • 배정밀도(8바이트) : double
  • 전통적인 80비트 부동소수점 형식(long double)은 이식성과 성능 문제로 사용을 권장하지 않는다.

3. 어셈블리 명령어 접미사

  • gcc가 생성한 명령어는 피연산자 크기를 나타내는 접미사를 가진다.
    • movb(1바이트)
    • movw(2바이트)
    • movl(4바이트)
    • movq(8바이트)
  • l 접미사는 4바이트 정수와 8바이트 부동소수점에 모두 사용되며, 혼동이 없다.