컴퓨터는 기계어 코드를 실행한다.
기계어 코드란 컴퓨터의 여러가지 동작(데이터 처리, 메모리 관리, 데이터 읽기 쓰기 등) 을 인코딩한 연속된 바이트이다. 그러니까 01010111001010101010101001010001010101001010101010000 ... 와 같은 코드를 실행하는데 이 이진수 코드는 바이트 단위로 이루어지고 컴퓨터의 여러가지 동작들을 수행하게 한다는 것이다. 이 기계어 코드는 너-무 읽기 어렵기 때문에 이를 텍스트 형식으로 변환한 코드가 어셈블리 코드이다. 즉 어셈블리 코드는 기계어 코드를 단순히 텍스트로 변환한, 가장 기계어에 가까운 (= 저수준의) 형태의 프로그래밍 언어이다.
리눅스의 기본 C 컴파일러인 GCC C 컴파일러는 C언어로 작성된 C 소스 파일을 어셈블리 코드를 출력한다. GCC는 어셈블러와 링커를 호출하여 어셈블리 코드로부터 실행 가능한 기계어 코드를 생성한다. 이제 기존의 C 언어 소스는 실행 가능한 기계어 코드 형태로 변환되어 컴퓨터에 의해 실행될 수 있다.
최신 컴파일러들이 최적화된 형태로 컴파일을 아주 잘 해줄 수 있음에도 불구하고, 어셈블리 코드를 배워야 하는 이유는 어셈블리코드를 쓰기 위함이 아닌, 어셈블리 코드를 읽고 이해할 수 있는 능력을 기르기 위함이다. 이를 통해 컴파일러의 최적화 성능을 알고, 코드의 비효율성을 분석하고, 심지어는 시스템에 대한 공격을 막는 방법을 이해할 수 있다.
💡 본 포스팅의 참고 서적 (CS:APP) 3장 및 본 포스팅에서는 인텔 x86-64 에 기반을 두고 어셈블리 코드를 다룬다.
💡32bit 컴퓨터는 2^32, 즉 4GB의 램을 사용할 수 있는 반면, 64bit 컴퓨터는 최대 2^48, 즉 256TB의 램을 사용할 수 있다.
프로그램의 인코딩 (컴파일)
gcc -Og -o p p1.c
위 커맨드 라인은 C 프로그램을 p1.c 라는 소스 파일에 작성한 후 해당 파일을 컴파일 하는 명령어이다. 한 부분씩 살펴보자.
gcc : 컴파일러로 gcc C 컴파일러를 지정한다는 의미. gcc 컴파일러는 리눅스의 기본 컴파일러이므로 gcc 대신 cc로 쓸 수도 있다.
-Og : 최적화 수준 옵션으로 -Og 를 선택하겠다는 의미. 더 높은 단계의 최적화가 아닌 -Og 수준을 적용하는 이유는 '학습 목적' 이다. 더 높은 단계로 최적화를 적용하면 컴파일러가 만들어낸 어셈블리 코드가 본래의 소스코드와 너무 많이 달라지기 때문에 소스코드와 어셈블리 코드를 비교하면서 학습하기 어렵다.
-o p : 실행파일 이름을 p로 하겠다는 의미.
p1.c : 컴파일 할 소스 파일의 이름
위와 같은 gcc 명령은 전처리기, 어셈블러, 링커와 같이 소스코드(.c)를 실행코드로 변환하기 위한 일련의 프로그램들을 호출한다.
전처리기는 C소스에 #include 으로 명시된 파일이 있는 경우 이를 코드에 삽입해주고, #define 으로 선언된 매크로가 있는 경우 이를 확장해준다. 이후 컴파일러는 소스 코드 p1.c를 어셈블리 코드로 변환한 p1.s를 생성한다. 이후 어셈블러는 어셈블리 코드를 바이너리 목적코드(0110101...) p1.o 로 변환한다. 이후 링커는 이 목적코드 파일(p1.o)을 라이브러리 함수들을 구현한 코드와 합쳐서 최종 실행파일 p를 생성한다. 이 실행 파일 p가 프로세서(CPU)가 실행할 수 있는 형태의 코드이다.
기계수준 코드
컴퓨터 시스템들은 모든 세부적인 구현 단계를 숨기는 '추상화'를 사용하는데, 이 중 두 가지가 기계수준 프로그래밍에서 특히 중요하다.
첫 번째는 기계 수준의 프로그램의 형식과 동작이 ISA, 즉 '인스트럭션 집합 구조(Instruction Set Architecture)'에 의해서 정의 된다는 것이다. ISA는 프로세서의 상태, 인스트럭션들의 형식, 각각의 인스트럭션들이 프로세서 상태에 어떤 영향을 미치는 지에 대한 내용들을 정의하고 있다.
실제 프로세서의 하드웨어는 여러가지 인스트럭션들을 동시에 실행할 수 있음에도 대부분의 ISA는 각각의 인스트럭션들이 (동시가 아닌) 순차적으로 실행하는 것처럼 보이게 프로그램의 동작을 묘사한다. 다행히(?) 하드웨어는 동시에 실행되는 전체 동작들이 ISA에서 묘사된 것처럼 순차적인 연산처럼 보일 수 있게 하는 안전 장치를 제공하고 있다.
두 번째는 기계 수준 프로그램에서 사용되는 메모리 주소는 물리적인 메모리 주소가 아닌, 가상 메모리 주소라는 것이다. 가상 메모리 주소는 메모리를 거대한 바이트들의 배열인 것처럼 보이게 하는(= 추상화) 메모리 모델을 제공한다. 메모리시스템은 실제로 사용할 때는 여러 개의 메모리 하드웨어와 운영체제 소프트웨어를 사용해야 한다.
컴파일러는 C로 작성된 추상적인 프로그램을 기계어 코드에 가까운 어셈블리 코드 표현으로 변환해준다. 어셈블리 코드는 본래의 C코드와는 아주 다른 형태를 띄고 있는데, 어셈블리 코드에서는 아래와 같은 (C에서는 잘 드러나지 않던) 일부 프로세서의 상태들이 드러나 있다.
프로그램 카운터 (program counter ; PC; %rip)
: 다음으로 실행될 인스트럭션이 있는 메모리 주소를 가리킨다
정수 레지스터 파일 (integer register file)
: 16개의 레지스터를 담고있는 파일(배열)이다. 각각의 레지스터는 이름이 있고(e.g. %rax, %rbx, %rcx, ...) 64비트 값을 저장하고 있다. 레지스터가 저장하는 값들은 C의 포인터처럼 주소가 될 수도 있고 정수 데이터가 될 수도 있다. 어떤 레지스터들은 프로그램의 상태를 추적하기 위해 사용되기도 하고, 어떤 레지스터들은 함수가 반환하는 값이나, 지역변수 등의 임시 데이터들을 저장하기 위해 사용된다.
조건 코드 레지스터들 (condition code registers)
: 가장 최근에 실행된 산술 또는 놀니 인스트럭션에 대한 상태 정보를 저장한다. if 조건문이나 while 반복문 등을 실행할 때 데이터 흐름을 변경하기 위해 사용된다.
벡터 레지스터 집합 ( A set of vecter registers)
: 하나 이상의 정수 또는 부동 소수점 값들을 저장한다.
C로 작성된 프로그램에서는 다양한 데이터 타입이 존재하고 메모리에 할당되지만, 어셈블리 코드는 C와 달리 메모리를 그저 바이트 주소지정이 가능한 (byte-addressable) 배열로 보기 때문에 부호가 있는 정수, 부호가 없는 정수, 여러 타입의 포인터들 등.. 다양한 데이터 타입들을 구분하지 않는다. 배열이나 구조체와 같은 데이터 타입들도 어셈블리코드에서는 연속적인 바이트들로 표현될 뿐이다.
프로그램 메모리
프로그램 메모리는 컴파일된 소스 프로그램이 저장되는 메모리(RAM)를 의미하는데, 실행가능한 기계어 코드, 운영체제에서 요구하는 정보, 프로시저 호출과 리턴을 관리하는 런타임 스택과 유저에 의해 할당된 메모리 블록(e.g. malloc) 등을 포함하고 있다.
메모리에는 가상주소가 붙어있는데, 가상메모리의 제한된 범위 만이 유효한 주소이다. 64비트 컴퓨터의 가상 주소는 64bit words로 표현되는데, 현대의 컴퓨터들은 상위 16비트까지는 0으로 되어있고, 나머지 숫자들로만 주소를 명시하고 있다 (2^48 = 64TB까지) 운영체제는 가상 주소 공간을 실제 메모리의 물리 주소로 번역해준다.
하나의 기계어 인스트럭션은 레지스터에 저장된 두개의 수를 더하고, 메모리와 레지스터 간 데이터를 교환하는 등 아주 작은, 기초적인 동작을 수행하고 컴파일러는 이렇게 단순한 작업을 하는 인스트럭션들을 통해 프로그램 구문을 구현하게 된다.
코드 예제
long mult2(long, long);
void mulstore(long x, long y, long *dest) {
long t = mult2(x,y);
*dest = t;
}
위 코드는 practice.c 파일의 코드이다. 이 c 파일이 컴파일 후 어떤 모습의 어셈블리 코드가 되는지 확인해보자.
gcc -Og -S practice.c
위와 같이 컴파일 시 명령어에서 -S 옵션을 사용하면 컴파일러가 어셈블리 코드 파일 .s 를 생성한 후 이후 단계 (어셈블러, 링커 호출) 를 실행하지 않는다. 컴파일 후 생성된 practice.s 파일을 확인해보자 (gcc 버전마다 조금씩 다른 결과가 나올 수 있음)
mulstore :
pushq %rbx
movq %rdx, %rbx
call mult2
movq %rax, (%rbx)
popq %rbx
ret
교재에 있는 예시는 위와 같이 나오고, 내 ubuntu에서 실행했을 때는 아래와 같이 나온다.
mulstore:
.LFB0:
.cfi_startproc
endbr64
pushq %rbx
.cfi_def_cfa_offset 16
.cfi_offset 3, -16
movq %rdx, %rbx
call mult2@PLT
movq %rax, (%rbx)
popq %rbx
.cfi_def_cfa_offset 8
ret
.cfi_endproc
위 어셈블리 코드 파일에서 줄 하나가 기계어 인스트럭션 하나에 대응한다.
e.g. pushq %rbx : 레지스터 %rbx가 프로그램 스택에 저장(push)된다.
이 어셈블리코드가 어셈블러를 거쳐서 practice.o 목적파일이 되면 아래와 같은 코드를 가진다. (목적파일은 텍스트 파일이 아닌 바이너리 파일이어서 직접 볼 수는 없다.) 본래의 c 코드와는 너무 나도 다른 아래의 코드를 보면서 깨달아야 할 점은, 컴퓨터에 의해 실행되는 실제 프로그램은 결국 일련의 바이트들 이라는 점이다.
53 48 89 d3 e8 00 00 00 00 48 89 03 5b c3
위와 같은 기계어 코드 파일 내용을 조사하려면 역어셈블러(disassembler) 프로그램들을 사용할 수 있다.
objdump -d practice.o
위의 명령어로 목적 파일에 대해 역어셈블러를 사용한 결과는 아래와 같다. (기울임체는 주석)
practice.o의 바이트 값(53, 48, 89 , ...) 들이 바이트 그룹들로 나뉘어있고, 각 그룹은 하나의 기계어 인스트럭션들을 의미한다. 각각의 그룹은 1바이트, 3바이트, 5바이트, 3바이트, 1바이트, 1바이트의 길이를 갖고 있다. 각 바이트 줄이 같은 줄의 우측 Equivalent assembly language 에 나타나 있는 어셈블리 코드(기계어 인스트럭션)에 대응하는 것이다.
기계어 인스트럭션은 (x86-64 기준) 1에서 15 바이트의 길이를 가진다. 인스트럭션들이 인코딩 될 때 (위의 역어셈블러의 결과에서 왼쪽 부분) 자주 사용되는 인스트럭션들 또는 피연산자의 수가 적은 것들이 더 작은 바이트 크기를 갖도록 되어있다.
Reference
Randal E. Bryant and David R. O'Hallaron, Computer Systems: A Programmer's Perspective, 3/E (CS:APP3e), Carnegie Mellon University, 2015, p163-170
댓글