CSAPP(3) - Machine-Level Representation of Programs

업데이트:

컴퓨터는 기계 코드를 실행한다.

  • 기계 코드는 바이트 시퀀스로 인코딩한 저수준의 연산이다.
    • 데이터 조작, 메모리 관리, 저장 장치에서 데이터 읽고 쓰기, 네트워크 통신 등의 연산

컴파일러는 프로그래밍 언어의 규칙, 대상 머신의 명령어 집합, 운영 체제에 따른 규약에 기반해서 몇 단계에 거쳐 기계 코드를 생성한다.

GCC C 컴파일러는 어셈블리 코드 형식의 출력을 생성한다.

  • 어셈블리 코드 : 프로그램에서 개별 명령을 제공하는 기계 코드의 텍스트 표현

GCC는 이후 어셈블러와 링커를 호출하여 어셈블리 코드에서 실행 가능한 기계 코드를 생성한다.

이번 챕터에서는 기계 코드와 인간이 읽을 수 있는 표현인 어셈블리 코드를 살펴본다.


고수준 언어는 높은 수준의 추상화를 통해 생산성있고 신뢰할 수 있다. 또한 고수준 언어로 작성한 프로그램은 다수의 다른 머신에서 컴파일되고 실행될 수 있다. 반면 어셈블리 코드는 크게 머신 특정적이다.

그럼 왜 기계어를 배워야 하나?

  • 컴파일러의 최적화를 이해할 수 있고 코드의 근본적인 비효율성을 분석할 수 있다.
  • 외부 공격 발생 시 취약점 분석하고 막기 위해

1. A Historical Perspective

x86이라 불리는 인텔 프로세서 라인은 단일 칩 16비트 마이크로세서에서 부터 시작해서 많은 일들이 있었다는 역사적인 이야기들. 이후 AMD가 등장해서 x86이 호환되는 64비트 칩을 내놨다는 이야기 등.

2. Program Encodings

프로그램이 인코딩되는 과정에 대한 내용

  • gcc 명령은 소스 코드를 실행 가능한 코드로 변경한다.
    1. 전처리기는 매크로 등 처리
    2. 컴파일러는 어셈블리 코드를 생성
    3. 어셈블러는 어셈블리 코드를 이진 목적 코드 파일로 변환
    4. 링커는 목적 코드 파일을 병합하여 최종 실행 코드 파일을 생성

2.1. Machine-Level Code

  • 머신레벨의 프로그램의 포맷과 행동은 ISA(instruction set architecture)에 따라 정의된다.
    • 대부분은 각 명령이 순차적으로 실행된다.(동시성 수행 시에도 순차 실행을 보장)
  • 머신레벨의 메모리 주소는 가상 주소다.
    • 가상 주소란 매우 큰 바이트 배열처럼 보이는 메모리 모델을 제공한다.
    • 메모리 시스템의 실제 구현은 다양한 하드웨어 메모리와 os의 조합으로 이루어진다.

64비트 머신의 가상 주소는 64비트 워드로 표현되는데, 현재 구현에서는 상위 16비트는 0으로 고정되어있다. 때문에 주소는 $2^{48}$ 범위(64테라바이트)까지만 나타낼 수 있다.

단일 머신 명령은 매우 기초적인 연산(레지스터에 있는 두개의 수 덧셈, 메모리와 레지스터 간 데이터 교환, 새로운 명령 주소로 전환 등)이기에 컴파일러는 산술 표현식, 루프, 프로시저 콜과 리턴을 머신 레벨의 명령으로 바꾸는 역할을 한다.

3. Data Formats

  • 64비트 머신에서는 포인터가 8byte다.
  • 인텔에서는 16비트 데이터 타입을 word라고 했음.
    • 때문에 어셈블리 코드에서 movq 명령은 “64비트를 옮겨라”는 뜻이다.

4. Accessing Information

  • x86-64 cpu는 64비트 를 저장하는 16개의 general-pupose 레지스터 집합을 가진다.
    • %r로 시작하는 이름
    • 각 레지스터의 역할에 대한 소개들..

아래는 상세 내용이므로 필요할 때 보기..

4.1. Operand Specifiers

4.2. Data Movement Instructions

4.3. Data Movement Example

4.4. Pushing and Popping Stack Data

5. Archimetic and Logical Operations

5.1. Load Effective Address

5.2. Unary and Binary Operations

5.3. Shift Operations

5.4. Discussion

5.5. Special Arithmetic Operations

6. Control

조건절, 루프, 스위치 등은 기계어의 조건절인 실행을 필요로 한다. 기계어 집합의 실행 순서는 ‘jump’ 명령어로 변경될 수 있다.

6.1. Condition Codes

CPU는 단일 비트의 condition code 레지스터 집합을 유지한다.

6.2. Accessing the Condition Codes

6.3. Jump Instructions

6.4. Jump Instruction Encodings

6.5. Implementing Conditional Branches with Conditional Control

6.6. Implementing Conditional Branches with Conditional Moves

6.7. Loops

6.8. Switch Statements

7. Procedures

프로시저는 소프트웨어에서 주요 추상화이다. 함수, 메서드, 서브루틴, 핸들러 등은 프로시저의 일반적인 특징을 따른다. 프로시저는 다음과 같은 메커니즘을 따른다.

  • 제어 전달 : 프로그램 카운터는 프로시저의 시작점으로 주소를 설정한다. 이후 프로시저를 수행하고 호출자로 제어를 반환한다.
  • 데이터 전달 : 호출자는 프로시저에 파라미터를 전달하고, 프로시저는 호출자에 리턴값을 반환한다.
  • 메모리의 할당과 해제 : 프로시저 시작 시 지역 변수를 위한 공간 할당하고 리턴 이전에 공간을 해제한다.

7.1. The Run-Time Stack

프로시저 호출 매커니즘의 핵심은 스택 자료구조를 활용한 last-in first out 메모리 관리 원칙이다.

7.2. Control Transfer

프로그램 카운터의 주소 공간을 변경하는 방식으로 동작한다.

7.3. Data Transfer

대부분의 데이터 전달은 register를 통해 구현된다.

7.4. Local Storage on the Stack

일반적으로 프로시저는 스택 포인터를 감소시켜 스택 프레임에 공간을 할당한다. figure 3.25에서 스택 프레임의 Local Variable에 해당하는 부분이다.

7.5. Local Storage in Registers

프로그램 레지스터들은 모든 프로시저가 공유하는 단일 자원이므로 callee는 caller가 나중에 쓰려고 저장한 레지스터를 덮어써서는 안된다. 때문에 x86-64는 레지스터 사용 규약을 채택하고 있다.

7.6. Recursive Procedues

프로시저는 자기 자신을 재귀적으로 호출하는 것을 허용한다. 각 프로시저 호출은 고유한 공간을 가지므로 서로 간섭하지 않는다. 스택의 원칙상 자연스럽게 프로시저 공간을 할당하고 해제하는 정책을 제공한다.

8. Array Allocation and Access

C에서의 배열은 스칼라 데이터를 더 큰 데이터 타입으로 집계하는 하나의 수단이다. C는 배열에 대해 간단한 구현을 사용하기 때문에 기계어로의 변환이 꽤 직관적이다.

  • C의 특징 중 하나는 포인터를 생성하고 포인터로 산술 연산이 가능하다는 것. 이것들은 기계어 상 주소의 계산으로 변환된다.

8.1. Basic Principles

자료형 $T$와 정수 상수값 $N$이 있고 다음과 같다고 하자$ T A[N]; $

시작 위치를 $x_A$라 하고 $L$이 자료형 $T$ 의 크기라 할때.

  • $L * N$ 바이트의 연속된 메모리를 할당한다.
  • 요소 $i$는 주소 $X_A + L * i$에 저장된다.

8.2. Pointer Arithmetic

8.3. Nested Arrays

8.4. Fixed-Size Arrays

C에서는 고정 크기 다차원 배열에 대해 많은 최적화를 수행할 수 있다.

8.5. Variable-Size Arrays

역사적으로 C는 컴파일 타임에 사이즈가 결정되어야 하는 다차원 배열만 지원했다. 프로그래머들은 가변 길이 배열을 사용하기 위해 malloc같은 동적 할당 함수를 활용하고 row기반 인덱싱을 통해 다차원 배열을 일차원으로 매핑하는 것을 명식적으로 인코딩해야 했으나, C99부터 배열이 할당될 때 계산되는 차원 표현식을 소개했다.

9. Heterogeneous Data Structures

9.1. Structures

9.2. Unions

9.3. Data Alignment

10. Combining Control and Data in Machine-Level Programs

10.1. Understanding Pointers

10.2. Life in Real World: Using the GDB Debugger

10.3. Out-of-Bounds Memory References and Buffer Overflow

C는 배열 참조를 위한 경계 검사를 수행하지 않으며 이로 인해 버퍼 오버플로가 발생할 수 있다. 이 경우 스택에서 호출자 주소와 같은 데이터가 손상될 수 있다. 또한 보안적인 문제가 발생할 수 있다.

버퍼 오버플로 vs 세그멘테이션 폴트

버퍼 오버플로는 프로세스 주소 공간 내에서 스택 상태 데이터를 오염시키는 것. 세그멘테이션 폴트는 프로세스의 주소 공간을 넘어가는 것.

10.4. Thwarting Buffer Overflow Attacks

  • Stack Randomization : 프로세스 실행마다 스탱의 위치를 다르게 하는 것.
  • Stack Corruption Detection: 모던 GCC 컴파일러는 stack protector를 포함하고 있음
  • Limiting Executable Code Regions : 메모리에서 코드 영역만 실행 가능하게 하고 다른 영역은 읽기와 쓰기만 허용하게 한다.

10.5. Supporting Variable-Size Stack Frames

11. Floating-Point Code

부동소수점이 기계어 수준에서 표현 및 처리되는 내용들 필요할 떄 보기로

12. Summary

기계어와 어셈블리 코드는 C프로그램과 다르다.

  • 다른 데이터 타입 간 최소화된 구별
  • 프로그램은 명령의 시퀀스로 표현됨
    • 각 명령은 단일 작업을 수행
  • 레지스터와 런타임 스택과 같은 프로그램 상태를 볼 수 있음
  • 데이터 조작과 프로그램 제어는 저수준 연산만 제공됨

C와 x86-64만 살펴봤으나 다른 언어와 머신도 유사하게 동작한다. 실제 C++ 초기에는 C로 변환 후 C 컴파일러로 목적 코드를 생성했다. C++의 객체의 필드는 구조체로, 메서드는 구현이 있는 코드 영역의 포인터로 표현된다.

출처

  • https://csapp.cs.cmu.edu/
  • 책 “Computer Systems : A Programmer’s Perspective”

태그:

카테고리:

업데이트:

댓글남기기