1. 인텔의 역사 (CPU, ISA)
1-1. 인텔 x86 CPU
인텔의 CPU는 현재까지도 노트북, 데스크탑, 서버의 시장을 지배하고 있을 만큼 많은 곳에서 사용이 된다. 인텔 CPU의 중요한 특징 중 하나는 바로 하위 호환성(Backward Compatibility)이다. 즉 과거에 출시된 CPU에 맞춰서 개발된 소프트웨어들도 여전히 정상적으로 실행될 수 있게끔 기존 인터페이스를 유지하면서, 새로운 기능을 조금씩 추가하는 것이다. 인텔의 CPU는 무려 1978년에 출시된 8086 CPU까지도 지원할 수 있도록 하위 호환성을 지켜오고 있다. 참고로 x86이란 8086 CPU를 포함하여 그 이후 출시된 인텔의 CPU와 ISA들을 통틀어서 부르는 말이다. 따라서 현재 사용되는 인텔의 CPU와 ISA도 x86에 해당한다.
1-2. CISC, RISC (Complex/Reduced Instruction Set Computer)
CISC(Complex Instruction Set Computer)란 ISA 체계와 CPU 내부 회로의 형태가 복잡한 컴퓨터, RISC(Reduced Instruction Set Computer)는 ISA 체계와 CPU 내부 회로의 형태가 단순한 컴퓨터를 의미한다. CISC는 다양한 포맷을 가진 다양한 명령어들이 존재하기 때문에 그러한 명령어들을 읽고 해석하기 위한 CPU 내부 회로도 상당히 복잡할 수밖에 없다. 하지만 그러한 명령어들 중에서 실제로 프로그램에 의해 사용되는 건 아주 일부분이다. 그래서 명령어 체계를 최대한 단순화하고 이에 맞춰 CPU 내부 회로도 단순화한 것이 바로 RISC이다. RISC는 가벼운 동작을 수행하는 단순한 명령어들로 구성이 되어 있고, 복잡한 동작은 그러한 명령어들을 적절히 조합하여 구현하게 된다. 따라서 RISC가 CISC보다 높은 성능을 보인다. 그렇다면 인텔의 CPU는 CISC일까, RISC일까?
인텔의 CPU는 겉으로는 CISC지만, 내부적으로는 RISC이다. 이게 무슨 말일까? 인텔의 CPU가 탑재하는 ISA는 명백하게 CISC이다. 복잡한 동작을 수행하는 다양한 종류의 명령어들이 정의되어 있기 때문이다. 하지만 인텔의 CPU는 그러한 명령어들을 내부적으로 RISC 형식의 명령어로 변환한다. 즉 복잡한 연산들을 단순한 연산들로 쪼개는 것이다. 이로써 성능은 RISC에 근접할 수 있게 된다. 단 변환 과정이 필요하기 때문에 전력 소모의 측면에서는 그다지 좋지 못하다.
1-3. x86-64의 역사
인텔은 첫 번째 16비트 CPU로서 x86-IA16이라는 ISA를 탑재한 8086을 출시하였다. 이것이 IBM PC에 사용되면서 인텔의 CPU 역사가 시작되었다고 봐도 무방하다. 참고로 이때부터 출시되는 인텔의 CPU와 ISA들을 통틀어서 x86이라고 부른다고 앞서 말한 바 있다.
그리고 시간이 흘러 인텔은 32비트 CPU인 386을 출시하는데, 이는 x86-IA32라는 ISA를 탑재하고 있었다. 이때부터 32비트 컴퓨터가 대중화되어 x86-IA16은 이제 거의 사용되지 않았고, x86은 암묵적으로 x86-IA32를 가리키는 말이 되었다.
그러다가 인텔은 x86-IA32를 완전히 버리고 64비트 버전의 ISA를 개발하는 일에 착수하게 된다. 이렇게 탄생한 ISA가 x86-IA64이며, 이를 탑재하여 출시한 CPU가 바로 아이테니엄(Itanium)이다(2001). 하지만 이는 생각보다 큰 관심을 얻지 못했다. 성능이 기대만큼 좋지도 않았고, 무엇보다 기존에 x86-IA32를 기반으로 개발된 많은 소프트웨어들이 이 CPU에서는 호환되지 않았기 때문이다.
그런데 이때 당시 인텔과 CPU 시장에서 경쟁하던 AMD가 x86-64(나중에 AMD64라 불림)라는 ISA를 선보이고 이를 탑재한 옵테론(Opteron)이라는 CPU를 출시한다(2003). 이는 x86-IA64와 달리 x86-IA32를 기반으로 개발된 소프트웨어들을 전부 지원할 수 있었기 때문에 큰 인기를 누리게 된다.
그럼에도 불구하고 한동안 x86-IA64에 고집하던 인텔은, 결국 AMD로부터 라이선스를 받아서 EM64T(INTEL64)라는 ISA를 개발하게 된다. 이는 사실상 x86-64와 똑같이 만든 카피본이다. 그리하여 이후부터 인텔이 출시하는 CPU들은 x86-64에 기반한 이 ISA를 탑재하게 되고, x86-IA64는 역사 속으로 사라지게 된다.
우리가 앞으로 공부할 ISA는 현대의 많은 CPU가 탑재하고 있는 x86-64라는 ISA이다. 위는 x86-64의 탄생 배경이다. 참고로 현대에 와서 x86은 32비트를, x64는 64비트를 가리키는 말로 통용되고 있다. 이는 위의 역사와 아주 관련이 깊다. x86-64라는 이름을 단순하게 부르고 싶어 x64라고 부르게 된 것이고, x86-IA32도 간단하게 x86이라 부르게 된 것이다.
2. C 언어, 어셈블리어, 기계어
2-1. 기본 용어
아키텍처(Architecture)란 CPU 디자인의 일부로서, 어셈블리어를 작성하기 위해 알아야 하는 것이다. 실행할 수 있는 명령어들의 집합이 어떠한지, 레지스터와 메모리의 조직은 어떠해야 하는지 등을 정의한다. 아키텍처는 명령어 체계를 정의한다는 측면에서 ISA(Instruction Set Architecture)라고도 부른다. 인텔의 ISA로는 IA32, IA64, x86-64 등이 있다. 한편, 마이크로아키텍처(Microarchitecture)는 아키텍처를 물리적으로 구현하는 부분을 말한다. 예를 들어, 마이크로아키텍처를 설계하기 위해서는 캐시의 사이즈, 코어의 진동수 등을 결정해야 할 것이다. 사실상 CPU와 동일한 의미라고 생각해도 문제없다.
머신 코드(Machine Code)는 바이트 수준의 프로그램을 의미하며, 프로세서가 읽고 실행하는 대상이다. 그리고 어셈블리 코드(Assembly Code)는 머신 코드를 인간이 이해할 수 있는 형태의 심볼 등으로 나타낸 것을 의미한다. 어셈블리어 코드는 머신 코드와 완벽하게 일대일 대응이 된다. 단순히 0과 1의 배열을 인간이 이해할 수 있는 형태로 바꿔 표현한 것일 뿐이기 때문이다.
ISA에서 정의하는 명령어들은, ISA에서 정의하는 컴퓨터 시스템의 상태를 조작하기 위한 수단에 해당한다. 그렇다면 컴퓨터 시스템의 상태라는 것은 구체적으로 무엇을 말하는 것일까? 첫 번째, 다음에 실행할 명령어를 저장하는 PC(x86-64에서는 RIP) 레지스터이다. 두 번째, 프로그램이 내부적으로 가장 많이 사용하는 여러 개의 범용 레지스터들로 이뤄진 레지스터 파일(Register File)이다. 세 번째, 가장 최근에 실행한 산술/논리 연산의 결과에 대한 정보를 저장하고 있는 컨디션 코드(Condition Code) 레지스터이다. 여기에 저장되는 정보를 바탕으로 조건 분기를 수행할 수 있다. 마지막으로 네 번째는 메모리(Memory)이다. 메모리는 바이트 단위로 접근이 가능한(한 로케이션의 크기가 1바이트임을 의미함) 배열 형태로, 코드와 데이터를 저장하며 프로시저를 지원하기 위한 스택도 갖추고 있다.
2-2. C 언어 코드의 번역 과정
p1.c
와 p2.c
가 있을 때, gcc -Og p1.c p2.c -o p
커맨드를 실행하면 p
라는 실행 파일이 만들어진다. -Og
는 기본적인 최적화 기능을 사용하겠다는 것을 선언하는 옵션이다. gcc
는 내부적으로는 대략 다음과 같은 절차를 걸쳐서 실행 파일을 만든다. 먼저 컴파일러가 C 언어 코드를 어셈블리 코드로 번역하면, 어셈블러는 이를 다시 머신 코드로 번역한다. 그렇게 만들어지는 오브젝트 파일들은 링커에 의해 이미 컴파일이 되어 있는 정적 라이브러리 파일들과 연결이 되어, 최종적으로 하나의 실행 파일이 만들어진다.2-3. 어셈블리어/ISA 수준의 자료형
바이너리 실행 파일은 여러 종류의 데이터 및 코드들로 구성이 된다. 데이터는 특정 자료형(Data Type)을 가지는 값에 해당하고, 코드는 일련의 명령어들을 표현하는 바이트 열에 해당한다. 어셈블리어/ISA 수준에서 지원하는 자료형으로는 크게 정수(Integer)와 부동 소수점 실수(Floating Point Number)가 있다. 정수는 1바이트, 2바이트, 4바이트, 또는 8바이트로 표현이 되며, 단순 데이터 값 혹은 메모리 주소 값에 해당한다. 그리고 부동 소수점 실수는 4바이트, 8바이트, 10바이트, 또는 16바이트로 표현이 되는 단순 데이터 값에 해당한다. 참고로 C 언어의 배열이나 구조체를 지원하는 자료형은 특별히 존재하지 않는다. 이러한 Aggregate 자료형은 어셈블리어/ISA 수준에서 메모리에 연속적인 바이트의 열을 할당하는 방식으로 구현되기 때문이다.
참고로 다음에 나타난 표는 C 언어가 제공하는 각각의 자료형이 어셈블리어/ISA 수준에서 어떤 크기의 데이터로 처리되는지를 보여준다. 표에 나타난 예시 ISA는 각각 IA32와 x86-64이다. 우리가 앞으로 공부할 ISA는 x86-64임을 기억하자.
2-4. 어셈블리어/ISA 명령어의 종류
어셈블리어/ISA 수준에서 제공하는 명령어들은 크게 세 부류로 나눌 수 있다. 첫 번째, 레지스터나 메모리의 값을 대상으로 특정 연산을 수행하는 계산 명령어이다. 예를 들어, 덧셈 등의 산술 연산이나 AND 등의 논리 연산을 수행하는 명령어가 이에 해당한다. 두 번째, 메모리와 레지스터 사이에서 데이터를 이동시키기 위한 데이터 이동 명령어이다. 마지막으로 세 번째, CPU의 제어를 옮기기 위한 흐름 제어 명령어이다. 컨디션 코드 레지스터를 참조하여 조건 분기를 수행하는 명령어와 무조건 분기를 수행하는 점프 명령어가 이에 해당한다.
2-5. C 언어 코드의 번역 예시
아래는
gcc -Og -S mstore.c1
커맨드를 실행하여 C 언어 코드를 x86-64 어셈블리어 코드로 번역한 결과를 보여준다. 참고로 gcc
의 버전이나 컴파일러 세부 설정에 따라서 결과는 이것과 조금 다르게 나올 수도 있다.위 그림에서 어셈블리어 코드에 주목하자. 파란색으로 표시된 부분은 Callee-save 레지스터를 백업하고 복원하는 코드이다. Callee-save 레지스터는 건드리면 안 되는 레지스터이므로, 건드리고 싶다면 미리 백업을 해두고 나중에 반드시 복원을 해야 한다. 참고로 x86-64에서는 레지스터의 백업 장소를 스택으로 지정하고 있다. 그리고 빨간색으로 표시된 부분은 Caller-save 레지스터를 Callee-save 레지스터에 안전하게 옮겨 놓는 코드이다. Caller-save 레지스터는 건드려도 되는 레지스터이므로,
mult2
루틴에 의해 값이 손상될 가능성이 있다. 따라서 mult2
리턴 후에도 값이 유지되기를 바란다면, 그 값을 안전하게 Callee-save 레지스터에 옮겨둬야 한다. 마지막으로, 초록색으로 표시된 %rax
는 함수의 반환 값이 저장되는 레지스터이다. 이는 해당 ISA에서 정한 규칙에 의거하는 것이다.아래는 위의 어셈블리 코드를 머신 코드로 번역한 결과를 나타낸다. 어셈블리 코드(
.s
)는 어셈블러에 의해 오브젝트 파일(.o
)로 번역이 된다. 오브젝트 파일은 실행 파일과 거의 근접한 형태라고 볼 수 있다. '거의'라고 표현한 이유는, 다른 파일에 존재하는 변수나 함수를 참조하는 부분의 경우 당장은 제대로 채워지지 못하기 때문이다(Missing Linkages). 나중에 링커가 여러 개의 오브젝트 파일을 읽어서 링킹을 진행할 때 각 파일의 심볼 테이블을 참조함으로써 그러한 부분들을 올바른 값으로 채워주게 된다(Resolve Reference). 보통의 경우엔 실행 파일을 만드는 단계에서 정적 라이브러리 파일들에 의해 그러한 참조들이 해결이 되지만, 어떤 경우에는 프로그램이 실행될 때 혹은 실행되는 도중에 동적으로 해결되기도 한다.2-6. 디스어셈블리 (Disassembly)
이름이 의미하듯 어셈블러의 번역 과정을 역으로 수행하는 것으로, 머신 코드를 어셈블리어 코드로 역 번역하는 것을 말한다. 읽기 어려운 머신 코드들을 읽을 수 있는 형태로 바꿈으로써 코드를 세밀하게 분석할 수 있다. 그러나 이러한 작업을 실제로 해볼 만한 경험은 관련 종사자가 아니면 거의 없기 때문에 여기서는 간단한 예시 하나로 설명을 대신하도록 하겠다. 아래는 위에서 설명했던
multstore
의 머신 코드를 다시 어셈블리 코드로 역 번역한 결과를 보여준다.3. x86-64 데이터 이동 명령어
3-1. 레지스터
x86-64에는 다음과 같은 16개의 레지스터들이 존재한다. 64비트 ISA이므로 각 레지스터의 크기도 64비트이다. 그럼에도 불구하고 기존 ISA에 대한 하위 호환성을 지키기 위해, 각 레지스터의 하위 비트들도 참조할 수 있게 하였다. 즉, 아래의 왼쪽 그림에서 회색으로 표시된 부분들이 실제로 IA32에서 사용되던 16개의 32비트 레지스터들이다. 그리고 그중
%eax
부터 %ebp
까지의 레지스터들을 확대한 것이 오른쪽 그림인데, 여기서 회색으로 표시된 부분들이 실제로 IA16에서 사용되던 8개의 16비트 레지스터들이다. 그리고 %ax
부터 %bx
까지의 레지스터들 각각은 다시 반으로 쪼개져서 8비트 레지스터 8개를 이루게 된다. 참고로 각 레지스터의 이름은 그 용도와 밀접한 관련이 있는데, 예를 들어 %rsp
는 스택 포인터로 사용되는 레지스터임을 의미한다. 아래의 오른쪽 그림에서 레지스터 오른쪽에 해당 레지스터의 용도가 나타나 있다.3-2. 데이터 이동 명령어: movq
movq Source, Dest
x86-64는 대표적인 데이터 이동 명령어로
movq
를 제공한다. movq
의 쓰임새는 위와 같다. 이름이 의미하듯, movq
는 Source
에 해당하는 값을 Dest
가 나타내는 공간에 이동(저장)시킨다. Source
와 Dest
자리에 들어갈 수 있는 값의 유형은 다음과 같이 세 종류이다.유형 | 설명 |
상수 (Immediate) | 레지스터나 메모리에 있는 값이 아니라, 명령어 비트 자체에 적는 값을 의미한다. 16진수 혹은 10진수 정수 앞에 $ 기호를 붙여서 표현한다. 이러한 상수들은 명령어 비트 자체에 1바이트, 2바이트, 또는 4바이트로 인코딩된다. 물론 당연하게도 상수는 Dest 자리에는 올 수 없다.
EX) $0x400 , $-533 |
레지스터 (Register) | 위에서 설명했던 x86-64의 16개 레지스터들 중 하나를 의미한다. 단, %rsp 는 스택 포인터로만 사용되기 때문에 사용 범위가 상대적으로 제한적이다.
EX) %rax , %r13 |
메모리 (Memory) | 레지스터에 의해 주어지는 메모리 주소에서 시작하는 연속적인 8개의 바이트를 의미한다. 한편 x86-64는 메모리의 값을 참조하기 위한 주소지정방식(Addressing Mode)이 굉장히 다양한데, 이에 대해서는 바로 이어서 설명하도록 할 것이다.
EX) (%rax) : %rax 가 가리키는 메모리 주소에서 시작하는 연속적인 8개의 바이트 |
정리하면
movq
는 다음과 같이 사용될 수 있다. 여기서 주목할 점은 메모리와 메모리 사이의 데이터 이동은 불가하다는 것이다. 이를 위해서는 메모리에서 값을 읽어와서 레지스터에 저장한 뒤, 그것을 다시 메모리에 적도록 해야 한다. 하나의 명령어로 메모리 내 이동을 구현하기에는 하드웨어 구현이 상당히 복잡해지고 비효율적이기 때문이다.참고로
mov
뒤에 붙은 q
는 64비트를 의미한다. 즉, 8바이트 데이터의 이동을 위한 명령어라는 뜻이다. 반면에 movb
, movw
, movl
도 존재한다. 이들은 각각 1바이트, 2바이트, 4바이트 데이터의 이동을 위한 명령어이다. mov
뒤에 붙은 것이 무엇이냐에 따라서 64비트 레지스터에서 어느 부분을 읽고 쓸지, 또는 주어지는 메모리 주소에서 시작하여 몇 바이트를 읽고 쓸지 달라진다.3-3. 메모리 주소지정방식 (Adderssing Mode)
D(Rb, Ri, S)
→ 의미:Mem(Reg[Rb] + S * Reg[Ri] + D]
x86-64에는 메모리에 접근하기 위한 주소지정방식(Addressing Mode)이 굉장히 다양하다. 즉,
(%rax)
보다 더욱 복잡하고 다양한 방식으로 메모리에 접근이 가능한 것이다. x86-64가 제공하는 주소지정방식은 위와 같은 구조를 가지고 있다. Rb
는 메모리 시작 주소를 나타내는 베이스 레지스터이고, D
는 메모리 시작 주소로부터의 오프셋을 나타내는 상수이다. 또한 Ri
는 인덱스 레지스터(%rsp
레지스터 사용 불가), S
는 인덱스 레지스터의 값을 몇 배 하여 더할지 결정하는 스케일 상수(1, 2, 4, 또는 8)이다. 다음 예시를 참고하자.4. x86-64 계산 명령어
4-1. 주소 계산 명령어: leaq
leaq Src, Dst
x86-64가 제공하는
leaq
의 쓰임새는 위와 같다. 이는 메모리에 직접 접근하지 않고 특정 메모리 주소를 계산해 내는 명령어이다. Src
에는 위에서 설명한 주소지정방식의 표현식이 들어간다. 그렇게 계산되는 주소 값이 Dst
가 나타내는 공간에 저장이 되는 것이다. leaq
의 기본적인 용도는 다음과 같은 C 언어 코드를 번역하는 데에 있다. 메모리에 접근하지 않고 원하는 메모리 주소를 계산해 내기 때문이다.p = &x[i];
또는
x+k*y
(k = 1, 2 ,4, or 8) 형태의 표현식을 계산하는 데에도 사용이 된다. 다음 예를 참고하자.4-2. 산술 & 논리 연산 명령어
x86-64가 제공하는 산술 & 논리 연산 명령어들은 다음과 같다. 더 자세한 내용은 서적을 참고하길 바란다.
명령어 | 피연산자 | 동작 | 의미 |
addq | Src , Dest | Dest = Dest + Src | Dest 의 값에 Src 의 값을 더한다. |
subq | Src , Dest | Dest = Dest - Src | Dest 의 값에 Src 의 값을 뺀다. |
imulq | Src , Dest | Dest = Dest * Src | Dest 의 값에 Src 의 값을 곱한다. |
salq | Src , Dest | Dest = Dest << Src | Dest 의 값을 Src 의 값만큼 왼쪽으로 Shift 한다. |
sarq | Src , Dest | Dest = Dest >> Src | Dest 의 값을 Src 의 값만큼 오른쪽으로 Shift 한다. |
shlq | Src , Dest | Dest = Dest << Src | Dest 의 값을 Src 의 값만큼 왼쪽으로 Shift 한다. |
shrq | Src , Dest | Dest = Dest >> Src | Dest 의 값을 Src 의 값만큼 오른쪽으로 Shift 한다. |
xorq | Src , Dest | Dest = Dest ^ Src | Dest 의 값에 Src 의 값과 XOR 연산을 시킨다. |
andq | Src , Dest | Dest = Dest & Src | Dest 의 값에 Src 의 값과 AND 연산을 시킨다. |
orq | Src , Dest | Dest = Dest | Src | Dest 의 값에 Src 의 값과 OR 연산을 시킨다. |
incq | Dest | Dest = Dest + 1 | Dest 의 값을 1만큼 증가시킨다. |
decq | Dest | Dest = Dest - 1 | Dest 의 값을 1만큼 감소시킨다. |
negq | Dest | Dest = -Dest | Dest 의 값의 부호를 바꾼다. |
notq | Dest | Dest = ~Dest | Dest 의 값을 반전시킨다. |
다음은 C 언어의 코드를 컴파일러가 위의 명령어들로 최적화하여 번역한 결과를 보여준다. 곧이곧대로 번역하는 게 아니라 '최적화'를 했다는 점에 유의하며 다음 코드를 꼼꼼히 이해해보기를 바란다.