컴퓨터는 데이터를 처리하고, 메모리를 관리하고, 저장장치에 데이터를 읽거나 쓰는 다양한 행위들을 인코딩한 연속된 바이트인 기계어 코드를 실행함으로써 이루어진다.
- 컴파일러는 프로그램 언어의 규칙, 대상 컴퓨터의 인스트럭션 집합, 운영체제의 관례 등에 따라 기계어 코드를 생성한다.
- GCC C 컴파일러는 기계어 코드를 문자로 표시한 어셈블리 코드의 형태로 출력을 만들어 프로그램의 각 인스트럭션을 만들어낸다. 그러고 나서, GCC는 어셈블러, 링커를 호출하여 어셈블리 코드로부터 실행 가능한 기계어 코드를 생성한다.
- 개발 수단은 점점 고수준 언어화 되었다. 자바같은 자연어스러운 것으로 개발하는 것은 편리하다. 프로그램 에러를 검출하는데 상당한 도움이 되기도 한다.
- 근데 우리가 기계어를 배워야 하는 이유가 있나?
- 컴파일러가 어셈블리 코드를 만들어 내는 대부분의 일을 하고 있지만, 그 코드들을 읽고 이해 할 수 있는 기술은 진짜 프로그래머들에게 필요하다.
- 여러 예시 중 하나로, 시스템의 취약성이 어떻게 발생하는지, 이런 공격을 어떻게 막을 수 있는지를 이해하려면 프로그램의 기계수준 표현에 대한 지식이 필요하다. + 성능 최적화에 대한 감각을 키우기도 적합하다. 고수준 언어만 논하면 “어떤 코드가 더 빠른지” 판단하기 어렵다.
- 컴파일러가 생성한 어셈블리 코드를 읽는 것은 손으로 작성하는 것과는 다른 종류의 기술이다. 소스 코드와 생성된 어셈블리 코드 간의 관계를 이해하는 일도 어려운 일이다. 상자에 그려진 그림과는 다른 디자인의 퍼즐을 맞추는 것과 비슷하다. reverse engineering의 일종이다.
- 근데 우리가 기계어를 배워야 하는 이유가 있나?
3.1 역사적 관점
x86 (32비트)라고 불리는 인텔 프로세서 제품군은 긴 기간에 걸쳐 진화되고 개발되어왔다.
- 기존엔 16비트에서 출발했는데, 8086부터 386, 펜티엄에 이어 우리가 요즘 다루는 i3 i5 i7 (또는 인텔 울트라2)에 대해 차례로 논 할 수 있다.
- 각각의 인접한 프로세서는 이전 버전과 호환성을 갖도록 설계되어있다.
- 이전의 모든 버전을 위해 컴파일된 코드가 실행 가능하다는 것이다. 인스트럭션 집합에는 이러한 유산을 상속하기 위해 이상한 잔재기능들이 남아있다.
- 인텔은 이런부분에 있어 별도의 이름을 붙여왔는데, IA32 : Intel Architecture 32-bit. 이런 식이다.
- 초기 8086과 그 확장형인 286에서 사용하는 메모리 모델은 i386에서는 더 이상 사용 되지 안흔ㄴ다.
- 오리지널 부동소수점 인스트럭션들은 SSE2가 도입된 이후로 사용하지 않게 되었다.
3.2 프로그램의 인코딩
linux> gcc -0g -i p p1.c p2.c
-0g 옵션을 주면 컴파일러는 본래 C 코드의 전체 구조를 따르는 기계어 코드를 생성하는 최적화 수준을 적용한다.
3.2.1 기계 수준 코드
컴퓨터 시스템은 보다 간단한 추상화 모델을 이용해서 세부 구현내용을 감추고 여러 다른 형태를 사용한다.
- 기계수준 프로그램의 형식과 동작은 인스트럭션 집합구조, ISA에 의해 정의된다.
- 이 ISA라는 것은 프로세서의 상태, 인스트럭션의 형식, 프로세서 상태에 대한 각 인스트럭션들의 영향들을 말한다.
- 하드웨어는 정교해서 여러 인스트럭션을 동시에 실행하는 와중에도, ISA에 의한 순차적 동작과 일치하는 전체 동작을 보이도록 해주는 안전 장치를 사용한다.
- 이 ISA라는 것은 프로세서의 상태, 인스트럭션의 형식, 프로세서 상태에 대한 각 인스트럭션들의 영향들을 말한다.
- 기계수준 프로그램이 사용하는 주소는 가상 주소이며, 메모리가 매우 큰 바이트 배열인것처럼 보이게 하는 메모리 모델을 제공한다.
- 실제 메모리 시스템은 여러 개의 메모리 하드웨어와 운영체제 소프트웨어로 구현되어 있다.
컴파일러는 전체 컴파일 순서에서 언어에서 제공하는 추상화 된 실행모델이다.
- 표현된 프로그램을 프로세서가 실행하는 매우 기초적 인스트럭션, 즉 어셈블러 코드로 변환하는 대부분의 일을 수행한다. 어셈블리 코드 표현은 기계어 코드와 매우 유사하다. 특징은 바이너리 기계어 코드 형식과 비교 할 때 더 읽기 쉬운 텍스트 형식이라는 것이다.
- 어셈블리 코드를 이해 할 수 있고, 어떻게 그들이 본래의 언어 코드와 연관되었는지 이해 할 수 있는 것이 컴퓨터가 어떻게 프로그램을 실행하는지 이해하는 데 중요하다.
- Program Counter(x86-64에선 %rip이라고 명칭)는 실행 할 다음 인스트럭션의 메모리 주소를 가리킨다.
- 정수 레지스터 파일은 64비트 값을 저장하기 위한 16개의 이름을 붙인 위치를 갖는다. 이들 레지스터는 주소나 정수 데이터를 저장 할 수 있다. 일부 레지스터는 프로그램의 중요한 상태를 추적하는데 사용 되기도 하며, 다른 레지스터들은 함수의 리턴 값 뿐만 아니라 임시 값을 저장하는 데 사용한다.
- 조건코드 레지스터들은 가장 최근에 실행한 산술 또는 논리 인스트럭션에 관한 상태 정보를 저장한다. 이들은 if/while 문을 구현 하면서 생기는 데이터 흐름의 변경을 구현하기 위해 사용한다.
- 벡터 레지스터들의 집합은 하나 이상의 정수나 부동소수점 값들을 각각 저장 할 수 있다.
C가 다른 종류의 데이터 타입을 선언하고 메모리에 할당 할 수 있는 모델을 제공하고 있다. 하지만..
- 기계어 입장에서는 그냥 바이트가 나열된 큰 배열일 뿐이다. 포인터와 정수형 사이 조차 구분을 하지 않는다.
프로그램 메모리는 프로그램의 실행 기계어 코드 부터 시작하여 사용자에 의해 할당된 메모리 블록들을 포함하고 있다.
- 언제나 가상 주소의 일부 제한된 영역만이 유효하다.
- 일부 제한은 운영체제의 정책 때문에 의하고, 운영체제가 메모리 맵을 설계해서 영역 크기를 조정한다.
- 다룰 수 있는 64비트 중 16비트를 0으로 지정해 일단 빼면, 48비트가 남는다. 이걸로 표시 할 수 있는 주소의 경우의 수를 따져보면 2의 48승이 가능은 하다. (256테라바이트) 현실적으로 대부분의 프로그램은 몇 MB, 몇 GB 정도만 쓰긴 한다.
- 운영체제는 이 가상 주소공간을 관리해서 가상주소를 실제 프로세서 메모리 상의 물리적 주소 값으로 번역해준다.
- 하나의 기계어 인스트럭션은 매우 기초적인 동작만을 수행한다. 컴파일러는 일련의 인스트럭션을 생성해서 산순연산식 계산, 반복문 프로시저 호출과 리턴등의 프로그램 구문을 구현하게 된다.
3.2.2 코드 예제
// mstore.c
long mult2(long, long);
void multstore(long x, long y, long *dest)
{
long t = mult2(x, y);
*dest = t;
}
이런 코드가 있다고 하자. 이걸 리눅스에 이러한 코드를 통해 어셈블리 코드를 확인 할 수 있다 :
linux> gcc -0g -S mstore.c # GCC를 통해 mstore.s 를 만들고 더 진행하지 않는다.
multstore:
pushq %rbx ; 1. %rbx 백업
movq %rdx, %rbx ; 2. %rdx → %rbx (rdx는 저장 공간 주소)
call mult2 ; 3. mult2 호출 (결과 → %rax)
movq %rax, (%rbx) ; 4. 결과값 메모리에 저장
popq %rbx ; 5. %rbx 복원
ret ; 6. 복귀
위 코드의 각 라인은 하나의 기계어 인스트럭션에 대응된다.
- pushq는 레지스터 %rbx가 프로그램 스택에 저장되어야한다는 것을 의미한다.
- 지역변수 이름, 데이터 타입에 관한 모든 내용은 삭제되었다.
linux> gcc -0g -c mstore.c
이것은 바이너리 형식이어서 직접 볼 수 없는 목적코드 파일 mstore.o를 생성한다. 내용은 이렇다 :
53 48 89 d3 e8 00 00 00 00 48 89 03 5b c3
나열된 어셈블리 인스트럭션에 대응되는 목적코드를 보고있다. 실제 실행된 프로그램은 단순히 일련의 인스트럭션을 인코딩한 일련의 바이트라는 점을 알아야한다. 컴퓨터는 소스 코드에 대한 정보를 거의 갖고 있지 않다.
- 들여쓰기를 하던 말던, 어쨌든 컴파일 한창 하던 중에 오류만 없다면 컴퓨터는 찰떡까지 알아듣긴 한다는 것. (물론 정확히 작성했을 소스코드에 대해)
linux> objdump -d mstore.o
를 수행하면 이러한 내용을 볼 수 있다.
- x86-64 인스트럭션들은 1-15바이트 길이를 갖는다. 인스트럭션 인코딩은 자주 사용되는 인스트럭션, 오퍼랜드가 적은 것들이 짧은 길이를 갖도록 하고, 그 반대의 경우에는 좀 더 긴 인스트럭션 길이를 갖도록 인코딩한다.
- 시키는 내용이 많으면 긴 길이, 반대로는 짧은 길이를 가진다.
- 인스트럭션의 형식은 주어진 시작 위치에서부터 바이트들을 기계어 인스트럭션으로 유일하게 디코딩 할 수 있도록 설계한다. pushq %rbx 인스트럭션만 바이트 값 53으로 지정이 가능하다.
- 역어셈블러는 기계어 코드 파일의 바이트 순서에만 전적으로 의존하며 어셈블리 코드를 결정한다.
- 역어셈블러는 GCC가 생성한 어셈블리 코드와는 약간 다른 명명법을 인스트럭션에 사용한다.
실제 실행 가능 코드를 생성하기 위해 링커를 목적코드들에 대해 실행해야 하며, 이 중 한 개의 파일은 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;
}
앞에서 언급한 방법 과 동일하게 시도하면 역 어셈블러는 여러 가지 코드를 추출해 낸다 :
거의 비슷한 모양새이다. 하지만..
- 첫번째 차이점. 왼쪽의 나타난 주소가 다르다. 링커가 이 코드의 위치를 다른 주소 영역으로 이동시킨 것이다.
- 두 번째 차이점은 링커가 callq 인스트럭션이 함수 mult2를 호출 할 때 (사진 4번 줄) 사용해야하는 주소를 채웠다는 점이다. 링커의 해야 할 일은 이들 함수를 위한 실행 코드 위치와 함수 호출을 일치시키는 것이다.
- 세번째는 마지막 두 줄의 추가 된 라인이다. 7번 줄 이후에 발생하는 이 내용들은 프로그램에 아무 효과가 없을 것이다. 코드의 다음 블록을 메모리 시스템 성능 면에서 더 잘 배치하기 위해 이들이 삽입 되었다.
3.2.3 형식에 대한 설명
GCC가 생성하는 어셈블리 코드는 사람이 읽기 어렵다. 한편으로는 우리가 걱정 할 필요 없는 정보를 포함하고 있지만, 다른 한편으로는 프로그램이 어떻게 동작하고, 설명도 제공하지도 않는다. 이러한 예제를 보자.
linux> gcc -0g -S mstore.c
이 파일의 전체 내용은 다음과 같다.
‘.’으로 시작하는 모든 라인은 어셈블러와 링커에 지시하기 위한 디렉티브들이다. 일반적으로 이들은 무시해도 된다.
- 왜냐하면, 인스트럭션들이 무엇을 하고, 이들이 어떻게 소스 코드와 연관되는지에 대한 설명이 없는게 크다.
어셈블리 코드를 보다 깔끔히 나타내기 위해 대부분의 디렉티브를 생략하겠지만, 라인 번호와 주석들은 이와 같이 책에 포함되어있다.
어셈블리어 프로그래머들이 코드를 작성 할 때 사용하는 정형화 된 버전이다.
3.3 데이터의 형식
인텔은 16비트에서 32비트로 확장했던 기존의 이력때문에, 인텔은 “word”라는 단어를 16비트 데이터 타입으로써 지칭한다. 이후, 32비트를 “double word”라고 부르는 계기가 되고, 64비트는 “quad word”라고 부른다.
C declaration Intel Data Type Assembly-code suffix Size (Bytes)
char | Byte | b | 1 |
short | Word | w | 2 |
int | Double Word | l | 4 |
long | Quad Word | q | 8 |
char * | Quad Word | q | 8 |
float | Single precision | s | 4 |
double | Double precision | l | 8 |
위 표는 C언어에서 기본 데이터 타입에 사용되는 x86-64 표시를 보여준다.
- 표준 int 값들은 더블워드로 저장된다. (32비트)
- 포인터들은 64비트 머신에서 예상 할 수 있는 것처럼 8바이트 쿼드워드로 저장된다.
- x86-64의 long은 64비트로 구현되어 있으며, 매우 넓은 범위의 값이 허용된다.
부동소수점 숫자에는 두 개의 기본 형태가 있다.
- 첫째, C의 float 타입에 대응되는 단일 정밀도(4바이트) 값 : C언어의 double 타입에 대응되는 이중 정밀도(8바이트) 값.
- 둘째, x86 계열의 microprocessor들은 역사적으로 특별한 80비트(10바이트) 부동소수점 형식으로 동작하는 부동소수점 연산을 구현하였다.
- 이 형식은 long double을 선언해서 명시 할 수 있다. 하지만 이것은 호환성이 없어 이용하지 않는 것을 추천한다.
- 진짜로 다른 PC로 갔다고 값이 생판 달라지는 경우도 있다고 한다. 요즘은 float128 같은게 있다고.
- 이 형식은 long double을 선언해서 명시 할 수 있다. 하지만 이것은 호환성이 없어 이용하지 않는 것을 추천한다.
표에서 봤듯, GCC가 생성한 대부분의 어셈블리 코드 인스트럭션들은 오퍼랜드의 크기를 나타내는 단일문자 접미어를 가지고 있다. 예를 들어 데이터 이동 인스트럭션은 4가지가 있다 :
movb 바이트 이동
movw | 워드 이동 |
movl | 더블 워드 이동 |
movq | 쿼드 워드 이동 |
- 접미어 ‘l’이 8바이트 더블 정밀도 부동소수점 수를 나타내기 위해서도 사용한다는 점을 주의하자.
- 사실 크게 혼란을 만들지는 않는 것이, 부동소수점의 경우에는 완전히 다른 인스트럭션과 레지스터들을 사용하기 때문이다.
'컴퓨터 이론 > CS:APP' 카테고리의 다른 글
[CS:APP] 링커 7.1 - 7.14 (0) | 2025.04.18 |
---|---|
[CS:APP] Chap 3.10 - 11 (1) | 2025.04.09 |
[CS:APP] Chap 3.7 - 9 (1) | 2025.04.09 |
[CS:APP] Chap 3.4 - 6 (0) | 2025.04.09 |
[CS:APP] 1 : 컴퓨터 시스템으로의 여행 일부 (0) | 2025.03.24 |