Ch3.2 Program Encodings

Park Choong Ho·2021년 4월 22일
0

3.2 Programming Encodings

C언어로 p1.c, p2.c 2개 파일을 만들었다고 해봅시다. 이 코드들을 아래 유닉스 명령어를 통해 컴파일 할 수 있습니다.

linux> gcc -Og -o p p1.c p2.c

gcc 명령어는 gcc C 컴파일러를 나타냅니다. 이 컴파일러는 리눅스 기본 컴파일러이며 앞으로 간단히 cc라 칭하겠습니다. 명령어 옵션-Og는 컴파일러가 기계어를 생성할 때 최적화 정도를 나타내는 옵션입니다. 최적화 레벨이 높을수록 기계어와 소스 코드간의 관계가 더 멀어집니다. -Og 옵션을 통해 최적화를 진행하고 레벨을 높임에 따라 어떤 변화가 나타나는지 살펴보도록 하겠습니다. 더 높은 레벨의 최적화 옵션 -O1 -O2는 프로그램 성능 측면에서 더 좋은 결과를 만들어냅니다.

gcc 명령어는 소스코드를 실행가능한 기계어로 변환해줍니다. 몇가지 단계를 걸칩니다. 첫째, C언어 전처리기가 소스코드를 #include로 지시된 파일을 포함하고 #define으로 지시된 매크로까지 확장시킵니다. 두번째, 컴파일러가 p1.s, p2.s 라는 이름을 가진 어셈블리 코드 파일을 생성합니다. 그 다음, 어셈블러가 어셈블리코드를 binary object-code로 구성된 p1.o p2.o로 변환합니다. Object 코드는 기계어의 한 형태입니다.(모든 instruction에 해당하는 binary 값을 가지고 있습니다. 하지만 아직 전역변수의 주소는 포함하지 않습니다.) 마지막으로, 링커가 두 object-file과 라이브러리 함수를 실행하는 코드(예를 들어, printf)를 합쳐 최종적으로 실행가능한 파일 p를 생성합니다. 실행가능한 코드는 기계어의 또 다른 형태입니다.(정확하게는 CPU에 의해 실행될 수 있는 형태입니다.) 이러한 서로 다른 기계어들과 링킹 과정 사이 관계는 7장에서 살펴보도록 하겠습니다.

3.2.1 Machine-Level Code

컴퓨터 시스템은 서로 다른 추상화를 활용해, 실제 실행의 세부 사항을 감춥니다. Machine-level 프로그래밍을 할때 중요한 두가지 요소가 있습니다.

첫째, 기계어 프로그램 동작 및 형태는 instruction set architecture(ISA)에 정의되어 있고 여기서 프로세서 상태, 인스트럭션 형태, 그리고 각 인스트럭션이 실행됐을 때 발생하는 결과 등을 정의하고 있습니다. x86-64를 포함해, 대부분 ISA는 각 인스트럭션이 연속적으로 실행되는 것처럼(인스트럭션 하나가 끝나고 그 다음 인스트럭션이 실행되는) 프로그램 동작을 설명합니다. 프로세서는 굉장히 정교해 여러개 인스트럭션을 concurrent하게 실행하는데, 사실 프로세서는 보호장치를 통해 전체적인 동작을 ISA에 의해 정의된 연속적인 인스트럭션 실행에 맞게 보장하고 있습니다.

둘째, machine-level 프로그램에서 사용하는 메모리 주소는 가상메모리(virtual address)입니다. 가상메모리는 크고 연속된 바이트 배열 형태의 메모리 모델을 제공합니다. 실제 가상메모리 시스템 동작은 여러 운영체제 소프트웨어와 여러 하드웨어 메모리 간의 결합을 포함합니다. 이는 9장에서 살펴보도록 하겠습니다.

컴파일러는 컴파일 과정(C로 작성된 추상적인 프로그램을 프로세서가 실행하는 기초 인스트럭션으로 바꾸는 과정)에서 대부분을 차지하는 요소입니다. 어셈블리는 기계어에 매우 가깝습니다. 어셈블리 주요 특징은 binary 기계어와 비교해 상대적으로 사람이 읽을 수 있는 텍스트 형태라는 점입니다. 어셈블리 코드와 C코드가 어떻게 연관되는지 이해하는 것은 컴퓨터가 프로그램을 어떻게 실행하는지를 이해하는데 있어 핵심적인 부분입니다.

x86-64 기계어는 C코드와 큰 차이를 보입니다. 프로세스 상태 등과 같은 정보는 대개 C 프로그래머가 확인할 수 있는 요소가 아닙니다.

  • program counter(대개 PC라 부르고 x86-64에서는 %rip라고 합니다.) 다음에 실행할 인스트럭션 메모리 주소를 가리킵니다.
  • 정수 register file은 64-bit를 저장하는 16개 레지스터를 의미하고, 각 레지스터는 자신만의 이름을 가지고 있습니다. 레지스터는 C포인터 처럼 주소값 또는 정수 값을 가지고 있습니다. 어떤 레지스터들은 프로그램 상태와 같은 중요한 정보를 저장하는데 사용되지만 다른 레지스터들의 경우 임시 변수(argument, 지역 변수, 함수가 반환한 값 등)를 저장하는데 사용됩니다.
  • condition code register들은 가장 최근에 실행된 산술 논리 인스트럭션에 대한 상태 정보를 가지고 있습니다. 이들은 if while문과 같이 프로세스 흐름상 조건적인 변화를 실행하는데 사용됩니다.
  • vector register 집합은 정수나 floating-point 값을 하나 또는 여러개 가질 수 있습니다.

C언어가 메모리상에서 할당되고 선언될 수 있는 여러 데이터 타입에 관한 오브젝트 모델을 제공하는 반면에, 기계어는 메모리를 단순히 큰 바이트 주소 배열이라 해석합니다. 따라서 배열이나 구조체 같은 Aggregate 데이터 타입들을 기계어는 연속되는 바이트 모음으로 읽어들입니다. 방향 구별이 없는 scalar data type에서도, 어셈블리 코드는 (signed, unsigned), (서로 다른 타입의 포인터) 그리고 심지어( 포인터와 정수)도 구분하지 않습니다.

프로그램 메모리는 프로그램 실행에 필요한 기계어, 운영체제가 요청한 정보, 프로시저 호출과 반환을 관리하는 런타임 stack, 유저에 의해 할당된 메모리를 포함합니다. 앞서 언급한것 처럼, 프로그램 메모리는 가상메모리를 통해 주소화됩니다. 가상 메모리 특정 부분만이 유효한 메모리입니다. 예를 들어, x86-64 가상 메모리들은 64-bit word로 표현됩니다. 이 컴퓨터를 실행하면, 처음 16 bit들은 0으로 설정되어야하고 따라서 주소는 2의 48승(64 terabyte)까지 표현할 수 있습니다. 보통 프로그램들은 오직 몇 메가바이트 또는 기가 바이트 정도까지만 사용합니다. 운영체제는 가상메모리 공간을 관리하고 있으며, 가상메모리를 실제 물리 메모리로 변환합니다.

기계 인스트럭션 하나는 오직 기초적인 동작 하나만을 수행합니다. 예를 들어, 레지스터에 저장된 두 숫자를 더하거나, 메모리와 레지스터 사이 데이터를 전달하거나, 또는 새로운 인스트럭션 주소로 옮겨가는 등의 동작을 수행합니다. 컴파일러는 수학적인 표현식, 반목문, 프로시저 호출 반환등과 같은 동작을 수행하기 위해 연속된 인스트럭션을 생성합니다.

Aside The ever-changing forms of generated code

이 장에서는 특정 gcc 버전과 명령어 옵션을 바탕으로 생성된 코드를 활용합니다. 만약 개인 컴퓨터로 코드를 컴파일하면, 다른 컴파일러 또는 버전의 gcc를 사용했을 가능성이 있기에 코드가 다소 다를 수 있습니다. gcc를 지원하는 오픈소스 커뮤니티는 마이크로프로세서 생산자가 제공하는 코드 가이드 라인이 변화함에 따라 더 효율적인 코드를 생성할 수 있도록 계속해서 코드 생성기를 업데이트하고 있습니다.

목표는 이 장에서 공부하는 예시를 통해 어떻게 어셈블리 코드를 읽고 어떻게 이를 더 높은 레벨 언어 코드와 맵핑하는지 이해하는데 있습니다. 특정 컴퓨터가 생성한 코드 스타일과 이 기술들을 접목해야 합니다.

3.2.2 Code Examples

아래와 같은 코드가 있습니다.

long mult2(long, long);

void multstore(long x, long y, long *dest){
	long t = mult2(x, y);
	*dest = t;
}

컴파일러가 생성한 어셈블리 코드를 보기 위해, -S 옵션을 사용하겠습니다.

linux> gcc -Og -S mstore.c

gcc가 컴파일러를 통해 어셈블리 파일인 mstore.s 파일을 생성하게 했습니다. (-S옵션이 없으면, gcc는 어셈블러가 object-code 파일까지 생성하게합니다.) 생성한 어셈블리 코드를 보면 아래와 같은 코드를 포함하고 있습니다.

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

각 줄은 기계 인스트럭션 하나와 대응합니다. 예를 들어 pushq 인스트럭션은 %rbx에 있는 데이터가 스택에 올라가야함을 의미합니다. 또 다른 특징으로 지역변수 이름과 데이터 타입 같은 정보들은 사라진 것을 확인할 수 있습니다.

-c 옵션을 사용하면 GCC는 컴파일과 어셈블을 모두 수행합니다.

linux> gcc -Og -c mstore.c

object-code 파일인 mstore.o를 생성합니다. 이 파일은 binary 형태이기에 직접 확인할 수 없습니다. 1,368 바이트로 된 mstore.o 파일은 아래와 같은 16진법으로 표현된 14개 바이트를 포함합니다.

53 48 89 d3 e8 00 00 00 00 48 89 03 5b c3

위에서 확인한 어셈블리 인스트럭션에 대응되는 object code입니다. 핵심은 컴퓨터에 의해 실행되는 프로그램은 연속적인 인스트럭션을 인코딩한 단순한 바이트의 연속이라는 점입니다. 컴퓨터는 해당 인스트럭션을 만든 소스코드에 대해 최소한의 정보만을 가지고 있습니다.

기계어를 확인하기 위해, disassemblers라는 프로그램을 활용해 보겠습니다. 이 프로그램은 기계어로부터 어셈블리 코드와 유사한 형태의 코드를 생성합니다. 리눅스에서는 objdump-d flag와 함께 이러한 역할을 수행합니다.

linux> objdump -d mstore.o

결과는 아래와 같습니다.

mstore.o:     file format elf64-x86-64


Disassembly of section .text:

0000000000000000 <multstore>:  
 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

결과의 왼쪽을 보면 앞서 확인한 연속된 14개 바이트들을 확인할 수 있습니다. 이 값들은 1부터 5바이트만큼 그룹화됩니다. 각 그룹은 하나의 인스트럭션입니다.(오른쪽 어셈블리와 일치합니다.) Disassemble에 있어 몇가지 유의사항을 확인합시다.

  • x86-64 인스트럭션은 길이가 1 부터 15 바이트입니다. 인스트럭션 인코딩에 따른 결과입니다. 따라서 자주 사용하는 인스트럭션과 적은 피연산자를 사용하는 인스트럭션은 필요한 바이트가 적습니다.
  • 특정 기계어와 일치하는 바이트 decoding이 있습니다. 예를 들어, push %rbx 인스트럭션만이 값이 53인 byte로 시작합니다.
  • Disassembler는 단순히 기계어 바이트만 보고 어셈블리를 생성합니다. Disassembler는 프로그램 소스 코드나 어셈블리 코드 버전 등 기타 정보를 요구하지 않습니다.
  • Disassembler는 인스트럭션에 대해 gcc가 생성하는 어셈블리코드와 다소 다른 naming convention을 사용합니다. 위 예시에서, 인스트럭션에 많은 접미사 q가 생략된 것을 볼 수 있습니다. 이 접미사들은 크기를 나타내는 것으로 대부분 생략 가능힙니다. 반대로, disassembler가 call, ret 인스트럭션에 접미사 q를 붙였습니다. 이들 또한 생략가능합니다.

실행가능한 코드를 생성하려면 링커에게 object-code 파일들을 넘겨야합니다. 그리고 이 object-code 파일에는 main 함수가 반드시 있어야합니다. 아래와 같은 main.c 파일이 있다고 하면,

#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;
}

main.cmstore.c를 합쳐 실행가능한 prog를 만들어 보겠습니다.

linux> gcc -Og -o prog main.c mstore.c

prog 파일은 8,655 바이트까지 늘어났습니다. 왜냐하면 프로시저 기계어 뿐 아니라, 운영체제와 상호작용하며 프로그램을 시작 및 종료하는 코드까지 포함했기 때문입니다. 위 prog 파일을 disassemble 해보겠습니다.

linux> objdump -d prog

결과는 아래 코드를 포함합니다.

0000000000000741 <multstore>:  
 741: 53                    push   %rbx  
 742: 48 89 d3              mov    %rdx,%rbx  
 745: e8 ef ff ff ff        callq  739 <mult2>  
 74a: 48 89 03              mov    %rax,(%rbx)  
 74d: 5b                    pop    %rbx  
 74e: c3                    retq  
 74f: 90                    nop

mstore.c 파일을 disassemble한 결과와 거의 동일합니다. 몇가지 다른점이 있는데, 하나는 옆에 나타난 주소 값이 달라졌습니다. 링커가 이 코드를 다른 주소로 옮겼기 때문에 나타난 현상입니다. 두번째는 링커가 callq 인스트럭션이 mult2 함수를 호출하는데 사용하는 주소를 채워넣었습니다. 링커의 역할중에는 함수 호출에 있어 함수들을 실제 실행되는 코드 위치에 맞추는 역할이 있습니다. 마지막으로 추가된 1개 줄을 확인할 수 있습니다 (line 8). 이 인스트럭션은 프로그램에 아무런 영향을 주지 않습니다. 왜냐하면 return 인스트럭션 다음에 동작하기 때문입니다. 이 인스트럭션은 코드를 16바이트까지 늘리기 위해 넣어졌습니다. 이렇게 하면 다음 코드들 위치가 더 좋아져 높은 메모리 성능을 기대할 수 있습니다.

3.2.3 Notes on Formatting

GCC에 의해 만들어진 어셈블리코드는 인간이 읽기 어렵습니다. 한편으로는 프로그래머에게 필요없는 정보를 포함하고 있고, 다른 한편으로는 프로그램이 어떻게 동작하는지에 대한 정보를 포함하고 있지 않습니다. 예를 들어, 우리가 아래와 같은 명령어를 사용했다고 해봅시다.

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.8.1"
        .section        .note.GNU-stack,"",@progbits

.으로 시작하는 모든 줄은 어셈블러와 링커에 가이드 라인을 제시합니다. 대개는 이를 무시할 수 있는 반면에, 해당 인스트력선이 무엇을 하는지 그들이 소스코드와 어떻게 연관되는지를 설명하는 요소들은 없습니다. 어셈블리를 더 명확하게 설명하기 위해, 이러한 directive들을 생략하겠습니다. 하지만 추가적으로 라인수와 설명을 위한 annotation들을 추가하겠습니다.

void mulstore(long x, long x, long *dest*)
x in %rdi, y in %rsi, dest in %rdx
1 multstore:  
2 pushq	%rbx          Save %rbx
3 movq 	%rdx, %rbx    Copy dest to %rbx
4 call 	mult2 		  Call mult2(x, y)
5 movq 	%rax, (%rbx)  Store result at *dest
6 popq 	%rbx    	  Restore %rbx
7 ret 				  Return

대개는 현재 설명 시점과 연관된 코드만 보여질 것입니다. 각줄은 왼쪽에는 번호가 오른쪽에는 간략한 설명이 들어갑니다. 설명에는 해당 인스트럭션 효과와 C 소스코드와의 연관성에 대한 내용이 포함됩니다. 이는 어셈블리 프로그래머가 해당 코드 형태를 정하는데 있어서 어셈블리 코드 버전에 따라 달라집니다.

기계어 저 레벨에 헤매는 사람들을 위한 Web aside 또한 제공합니다. 하나는 IA32를 설명합니다. x86-64의 배경지식은 해당 Web aside를 읽는데 도움이 될 것입니다. 다른 하나는 어셈블리 코드를 C코드로 통합하는 방법에 대한 설명을 담고 있습니다. 몇몇 어플리케이션에서, 프로그래머는 컴퓨터의 낮은 레벨에 접근하기위해 어셈블리 코드를 활용해야 합니다. 한가지 접근법은 모든 것을 어셈블리 코드로 작성하고 C 코드와 링킹 단계에서 결합하는 것 입니다. 다른 하나는 gcc를 활용해 어셈블리 코드를 바로 C 프로그램에 Embedding하는 방식입니다.

profile
백엔드 개발자 디디라고합니다.

0개의 댓글