3.1
- GCC 컴파일러
- GNU Compiler Collection
- GNU에서 만든 컴파일러
- 리눅스, 유닉스 계열 시스템에서 많이 사용됨
- C언어로 작성된 코드를 컴파일해서 실행 가능한 바이너리 프로그램으로 만들어주는 도구
- 전처리(.c->.s) 컴파일 (.s->.i) 어셈블 (.i->.o) 링크 (.o->.exe)
- 어셈블리 코드는 하드웨어에 의존적이다
- CPU 아키텍처 마다 해석할 수 있는 Instruction들이 각자 다름
- 따라서 하드웨어에 맞게 어셈블리 코드로 변환시켜야 그 위에서 애플리케이션 실행 가능
- 기계어 코드를 배워야 하는 이유?
- 고급 언어의 추상화 계층으로 인해 감춰진 런타임 동작을 이해할 수 있음
- 즉 Java와 같은 언어로는 보지 못하는 것들 (데이터가 어떻게 공유되고 있는지, 데이터가 정확히 어디서 어떻게 접근되고 있는지, 함수 호출 시 스택에 어떤 일이 일어나는지, 포인터가 실제로 메모리 주소를 어떻게 다루는지 등)을 알 수 있음
- 컴파일러가 프로그램이 효율적으로 동작하도록 어셈블리어를 만들었는지 확인함으로써 성능을 개선할 수 있음
- 컴파일러가 항상 효율적인 방식으로 프로그램이 돌아가도록 컴파일하는 건 아니다.
- 때로는 불필요하게 느린 코드를 생성하거나 성능상 비효율적인 동작을 하도록 어셈블리어를 만들 수 있다.
- 예를 들어 단순 곱셈을 하는 코드가 있다고 하면
- 고급 언어의 추상화 계층으로 인해 감춰진 런타임 동작을 이해할 수 있음
int square(int x){
return x*x;
}
컴파일 결과 이런 어셈블리 코드가 나올 수 있음
mov eax, edi # 값을 eax 레지스터에 복사
mov ecx, edi # 값을 ecx 레지스터에도 복사
mul ecx # 두 값을 곱셈하여 결과를 저장
ret # 반환
그러나 아래와 같이 더 빠른 방식으로 곱셈 연산을 수행할 수 있음
불필요하게 두 개의 레지스터를 쓰던 방식에서 하나의 레지스터만 쓰도록 바꾸었고
mul에서 imul이라는 더 간단한(부호 없는 곱셈) 명령어를 사용하도록 함
mov eax, edi # 값을 eax 레지스터에 복사
imul eax, edi # eax = eax * edi 곱셈 수행
ret # 반환
- 이번 장(3장)에서 배울 것?
- C 프로그램이 어떻게 기계어(어셈블리어) 형태로 컴파일되는지 배울 예정
- 어셈블리 코드를 읽고 해석하는 것은 어셈블리 코드를 직접 작성하는 것과 다른 종류의 기술을 요함
- 소스코드와 이를 통해 생성된 어셈블리 코드의 관계를 이해하는 것은 어려운 일이기 때문
- 실제로 소스코드를 컴파일할 때 컴파일러는 실행 순서를 조정하고, 불필요한 계산을 제거하고, 느린 연산을 빠른 연산으로 교체하고, 재귀적인 연산들을 반복 연산으로 바꾸기도 함 (이를 역엔지니어링 reverse engineering이라고 함
- 역엔지니어링은 시스템(여기서는 컴파일러가 만들어낸 어셈블리 프로그램)이 만들어진 과정을 시스템을 역방향으로 분석하여 이해하려는 작업을 의미
- 컴파일러는 사람과 다르게 일관된 방식으로 어셈블리어를 만든다. 그래서 개발자가 직접 비슷한 고급언어를 여러 번 짜보면서 컴파일러가 어떤 패턴으로 어셈블리어를 만드는지 실험해볼 수 있음
- 이렇게 역엔지니어링을 하다 보면 본인이 짠 고급 언어가 컴파일러에 의해 어떤 패턴으로 어셈블리어로 만들어지는지 예측 가능해지고 이걸 알면 성능을 향상시킬 수 있음 (더 빠르고 가볍고 효율적인 코드로 바꿀 수 있음)
- 예1) 코드를 복잡하게 짜면 동일한 결과를 반환해도 더 느리게 동작한다는 것을 알 수 있음
- 예2) 성능 병목이 어디서 일어나는지 알 수 있음 -> 어디를 리팩토링하는 게 좋을지 알게됨
- 예3) 증감 연산자가 앞에 붙으면 단순히 값 증가만 하지만 뒤에 붙으면 이전 값을 저장해놓고 증가해야 해서 처리가 더 복잡함
//예1
int sum(int a, int b) { //빠름
return a + b;
}
int sum(int a, int b) { //느림
int temp = a;
temp += b;
return temp;
}
//예2
for (int i = 0; i < n; i++) { //루프 연산 -> 메모리 반복 접근
result += arr[i];
}
//예3
++i
i++
- 이번 장은 x86-64라는 cpu 아키텍처를 배울 거임
- x86이란?
- Intel 8086 마이크로프로세서에서 시작한 cpu 아키텍처
- 이름에 86이 반복되어 x86 아키텍처라고 불리고 있음
- Intel, AMD에서 만든 대부분의 pc용 cpu가 이 아키텍처를 기반으로 하고 있음
- ISA는 CISC 구조임 - 하나의 명령어로 여러 동작 수행 가능 (메모리에서 값을 바로 연산한다든가)
- cpu 아키텍처란?
- cpu가 명령을 어케 알아듣고, 계산하고, 어디 저장하고, 어떻게 다음 일을 처리할지에 대해 정의한 규칙
- ISA, 레지스터 구조, 연산 방식, 메모리 접근 방식, 파이프라인 구조, 캐시 구조 등을 정의함
- x86-64란?
- 32bit 구조인 x86의 64bit 확장 버전
- 요즘은 주로 64bit를 많이 씀
- AMD가 만들어서 AMD64라고 부르기도 하는데 나중에 Intel도 이걸 받아들여서 대부분 x8-64라고 함
- 둘은 머가 다름?
- 32bit 아키텍처와 64bit 아키텍처는 cpu가 한 번에 처리하는 데이터 크기(bit수), 메모리를 다룰 수 있는 주소 범위가 다름
- 예) 숫자 451,234,567,890을 다룰 때 64bit cpu는 한 번에 처리 가능, 32bit는 두 번 나누어 처리해야 함
- C언어에서 int는 보통 4byte = 32bit이므로 -2,147,483,648 ~ 2,147,483,647 범위 정수를 표현할 수 있는데 해당 숫자가 32bit cpu에서의 int 표현 범위를 넘어가므로 두 번 나누어 처리해야 함
- 32bit 컴퓨터는 4GB 램을 사용할 수 있고 64bit 컴퓨터는 256TB 램을 사용할 수 있음
- 32bit 아키텍처와 64bit 아키텍처는 cpu가 한 번에 처리하는 데이터 크기(bit수), 메모리를 다룰 수 있는 주소 범위가 다름
- x86이란?
3.2
- 프로그램의 인코딩 - 컴파일 시 최적화
- c 프로그램 두 개(p1.c, p2.c)를 유닉스 커맨드라인에서 gcc c컴파일러를 통해 컴파일하려면 다음과 같이 명령어를 입력한다.
- > gcc -Og -o p p1.c p2.c
- 컴파일할 때 옵션으로 -Og, -O1, -O2 등을 주면 그에 따라 최적화 수준이 결정된다.
- 최적화(Optimization)란 컴파일러가 소스코드를 어셈블리어로 바꿀 때 코드를 더 효율적으로 변경해서 실행 속도와 성능을 개선하는 것을 말한다.
- 개발자가 소스코드를 컴파일할 때 커맨드라인 옵션으로 최적화 수준을 지정해주면 그에 맞게 어셈블리어가 생성된다.
- 종류는 -O0, -O1, -O2, -O3, -Os, -Ofast, -Og 등이 있다.
- 최적화 수준을 높이면 프로그램은 빠르게 실행되지만 컴파일 속도가 느리고 디버깅 도구를 실행하기 어려워질 수 있다.
- 최적화를 하면서 코드의 모양이나 실행 흐름이 개발자가 작성한 고급 언어와 다르게 바뀌게 되기 때문에 디버깅이 어려워진다.
- 높은 수준의 최적화를 하면 불필요한 변수가 제거되거나 코드가 재배치되거나 변수들이 메모리가 아니라 레지스터에 저장되기 때문에 디버깅 도구에서 값을 확인하기 어려워질 수 있음
- 따라서 본래 코드와 생성된 어셈블리 코드 간의 관계를 이해하기 어려워짐
- 기계수준 코드
- 컴퓨터 시스템은 추상화 모델을 이용하여 세부 구현 내용을 감추며 동작함
- ISA (Instruction Set Architecture)
- cpu가 따르는 명령어 집합 구조, 즉 cpu의 공통 언어
- x86 ISA는 CISC 구조 (명령어 수가 많고 복잡하지만 한 명령어로 여러 일을 할 수 있음)
-> 데스크탑, 노트북, 서버, 클라우드 - ARM ISA는 RISC 구조 (명령어 수가 적고 간단하고 빠르게 실행됨)
-> 스마트폰, 태블릿, 임베디드, iot - 요즘에는 클라우드 서버, 애플 MAC이 성능과 전력 효율 때문에 ARM 기반으로 넘어가고 있는 중
- 가상 주소
- 기계수준 프로그램이 사용하는 주소는 '가상 주소'임
- 물리 주소(Physical Address) : 실제 컴퓨터의 RAM 메모리 주소
- 가상 주소(Virtual Address) : 프로그램 입장에서 보이는 주소
- OS는 프로그램 마다 독립된 가상 주소 공간을 준다.
- 그래서 여러 프로그램이 동시에 같은 이름의 주소를 써도 충돌이 발생하지 않음
- 예를 들어 int a = 10;이라는 코드에서 변수 a는 가상 주소를 사용하지만 실제로 RAM에 접근할 때 쓰는 물리 주소가 따로 있다.
- 이 매핑 정보를 OS가 가지고 있고 CPU가 접근할 때 MMU가 자동으로 번역해준다.
- ISA (Instruction Set Architecture)
- 어셈블리 코드를 이해하면 컴퓨터가 프로그램을 어떻게 실행하는지 이해할 수 있음
- 왜냐면 고수준 언어는 프로세서의 상태와 같은 정보들이 감추어져있기 때문
- PC(Program Counter, 실행할 다음 인스트럭션의 메모리 주소를 가리킴) 등의 정보
- c언어에서 배열이나 구조체와 같은 연결된 데이터 타입을 선언하면 어셈블리 코드에서는 연속적인 바이트 블록으로 나타나고 이는 실제로 메모리의 인접한 위치에 저장됨
- 어셈블리 명령어 하나는 기초적인 동작만을 수행
- 레지스터에 저장된 두 수를 더하기
- 메모리와 레지스터 간의 데이터를 교환하기
- 새로운 인스트럭션 주소로 조건에 따라 분기하기
- 컴파일러는 이러한 일련의 인스트럭션 집합을 생성하여 산술연산, 반복문 프로시저 호출, 리턴 등의 프로그램을 구현함
- 컴퓨터 시스템은 추상화 모델을 이용하여 세부 구현 내용을 감추며 동작함
- 기계수준 코드 보기 (예제)
//mstore.c
long mult2(long, long);
void multstore(long x, long y, long *dest){
long t = mult2(x, y);
*dest = t;
}
위 c언어를 gcc 컴파일러로 컴파일해보자.
> gcc -Og -S mstore.c # 어셈블리 파일(.s)까지만 만들기
# mstore.s
multstore:
pushq %rbx
movq %rdx, %rbx
call mult2
movq %rax, (%rbx)
popq %rbx
ret
> gcc -Og -c mstore.c # 목적 파일(.o)까지만 만들기
# mstore.o
53 48 98 d3 e8 00 00 00 00 48 89 03 5b c3
- 이를 통해 알 수 있는 점은 컴퓨터에 의해 실제로 실행되는 프로그램은 일련의 인스트럭션을 인코딩한 바이트라는 것이다.
- 컴퓨터는 소스코드가 어떻게 짜여져있는지 모른다.
- 역어셈블러를 사용하면 바이트 코드를 다시 어셈블리어 비슷한 형태로 만들 수 있다.
- 리눅스에서 OBJDUMP라는 도구를 사용하고 -d옵션을 주면 역어셈블 가능
> objdump -d mstore.o # 기계어로 된 목적파일(.o)을 역어셈블
# .o 역어셈블
0000000000000000 <multstore>:
0: 53 pushq %rbx
1: 48 89 d3 movq %rdx, %rbx
4: e8 00 00 00 00 call mult2
9: 48 89 03 movq %rax, (%rbx)
c: 5b popq %rbx
d: c3 ret
- 역어셈블된 코드 해석 :
- 함수 이름 : multstore
- 맨 왼쪽에 있는 0, 1, 4, 9, c, d
- 코드상 multstore함수의 시작 위치를 기준으로 한 각 명령어들의 시작 위치
- 상대적 위치
- 바이트 오프셋이라고 부름. 바이트 단위로 표시
- 이 오프셋 정보는 디버깅할 때 해당 명령어가 파일이나 메모리에서 어디에 위치하는지 알려줌
- 역어셈블러는 이 위치를 기준으로 바이트들을 분석해서 명령어로 해석
- 0: 53 pushq %rbx
- 코드상 해당 명령어의 위치 : 0번째 바이트부터 1바이트
- 현재 %rbx 값을 스택에 저장 (백업용)
- 함수 안에서 %rbx를 쓸 거니까 나중에 되돌리기 위해 스택에 저장
- 1: 48 89 d3 movq %rdx, %rbx
- 코드상 해당 명령어의 위치 : 1번째 바이트부터 3바이트
- rdx에 있는 값을 rbx에 복사
- 인자로 들어온 값을 rbx에 저장
- 4: e8 00 00 00 00 call mult2
- 코드상 해당 명령어의 위치 : 4번째 바이트부터 5바이트
- mult2 함수 호출
- 실제 주소는 링크 단계에서 채워지므로 지금은 00 00 00 00으로 비어있음 (이를 재배치 정보라고 함)
- 9: 48 89 03 movq %rax, (%rbx)
- 코드상 해당 명령어의 위치 : 9번째 바이트부터 3바이트
- mult2의 리턴값이 %rax에 들어있으니 그 값을 rbx가 가리키는 메모리 주소에 저장
- 즉 *ptr = result; 등의 코드에 해당
- c: 5b popq %rbx
- 코드상 해당 명령어의 위치 : 12번째 바이트부터 1바이트
- 아까 pushq로 저장한 rbx값을 스택에서 복구
- d: c3 ret
- 코드상 해당 명령어의 위치 : 13번째 바이트부터 1바이트
- 함수 종료, 호출한 곳으로 복귀
- 어떻게 역어셈블했을까?
- 역어셈블러가 바이너리 코드를 어셈블리 코드로 복구시킬 때는 전적으로 바이너리 코드의 나열 순서만 보고 복구시킴
- 즉 ISA랑 바이트 순서만 보고 어떤 어셈블리어였는지 추측하는 것
- 기계어 인스트럭션은 주어진 바이트 순서만으로도 유일하게 어떤 명령인지 디코딩할 수 있도록 설계되어 있다.
- 즉 컴퓨터가 어떤 인스트럭션이 어떤 명령인지 혼동 없이 해석할 수 있어야 하기 때문에 각 명령어의 바이트 패턴은 중복되지 않게 잘 설계되어 있음
- 예) pushq %rbx 는 x86-64 ISA에서 기계어로는 16진수로 0x53으로 표현됨
- 즉 0x53이라는 바이트가 나오면 cpu는 무조건 pushq %rbx 명령어임을 알 수 있음
- 이게 가능한 이유?
- x86 ISA에서는 opcode 테이블을 엄청 세밀하게 나누어놔서 특정 명령어마다 일일이 바이트값이 다 정해져 있음
- 인스트럭션 마다 고유한 바이트 시퀀스(이진수 자료형)를 가지기 때문에 cpu나 역어셈블러는 바이트만 보고 정확히 어떤 명령인지 알 수 있음
- ISA 안에 설계도에서 opcode를 유일하게 지정해뒀기 때문에 가능한 것
- 역어셈블 과정을 통해 알 수 있는 것
- 기계어 인스트럭션은 바이트 코드로 쓰인 암호문이지만 ISA를 안다면 항상 정확히 해독할 수 있게 설계된 언어임
- 인코딩할 때 인스트럭션 길이?
- x86-64 인스트럭션들은 가변적인 길이를 가짐 (1~15 byte) 즉 어떤 인스트럭션은 1 byte로 끝나고 어떤 것은 10byte 이상임
- 자주 쓰이는 인스트럭션은 길이를 짧게, 복잡한 명령의 경우 길이를 길게 인코딩 해야 성능 면에서 효율적임
- 자주 쓰이는 명령어가 짧으면 실행 파일 크기가 작아짐 -> 명령어를 캐시에 더 많이 담을 수 있음 -> cpu 실행 속도 증가
- 복잡한 명령은 길게 인코딩하는 것을 허용해서 명령어가 한 번에 처리하는 일이 더 많도록 만들기
- 근데 ISA에 이미 각 바이트 코드들이 어떤 어셈블리 인스트럭션에 해당되는지 미리 다 설계되어 있다며..?
어떻게 언제는 짧은 바이트 코드로 생성하고 언제는 긴 바이트 코드로 생성함?- 같은 동작이라도 오퍼랜드(연산 대상)이나 접근 방식에 따라 여러 가지 인코딩 방식이 존재
- ISA는 여러 인코딩 방식 중 어떤 것을 쓸 수 있는지 정해놓은 명세서일 뿐, 단순히 특정 바이트 코드는 특정 명령어만 의미하도록 정의해둔 게 아님
- 그보다 더 정교하게, 이런 방식으로 명령어를 구성하면 이 바이트 시퀀스로 인코딩된다는 규칙을 정의해둠
- 따라서 같은 동작이라도 상황에 따라 ISA에 정의된 여러 인코딩 중 하나가 선택될 수 있음
- 어셈블러, 컴파일러는 ISA 규칙을 보고 가장 최적의 인코딩을 선택하여 짧게 혹은 길게 바이트 코드를 만드는 것
#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 함수가 존재하는 파일이 한 개 포함되어야 함
> gcc -Og -o prog main.c mstore.c # 실행 가능 프로그램(.exe)을 만들기
> objdump -d prog # 실행 파일(.exe)을 역어셈블
# .exe 역어셈블
0000000000000000 <multstore>:
400540: 53 pushq %rbx
400541: 48 89 d3 movq %rdx, %rbx
400544: e8 42 00 00 00 call mult2
400549: 48 89 03 mov %rax, (%rbx)
40054c: 5b pop %rbx
40054d: c3 retq
40054e: 90 nop
40054f: 90 nop
- .o를 역어셈블한 결과와 .exe을 역어셈블한 결과를 비교해보자
- 바이트 오프셋이 달라졌음 : 링커가 이 코드의 위치를 다른 주소 영역으로 이동시킴
- 00 00 00 00으로 비어뒀던 주소가 채워짐 : 링커가 함수 mult2를 호출할 때 사용해야 하는 주소를 채워줌
- 파일의 크기가 늘어남 : OS와 상호작용하기 위한 코드, 프로그램을 시작하고 종료하기 위한 코드가 포함됨
- 참고 : ISA와 cpu 아키텍처라는 말이 계속 혼동되어 정리함
- ISA : cpu의 공통 언어
- 명령어 집합
- cpu 동작 규칙
- 레지스터 이름, 개수 (x86은 %rax, %rbx, ARM은 x0, x1)
- 메모리 주소 체계 (가상/물리, 정렬 규칙)
- 데이터 크기, 형식 (32bit, 64bit)
- 함수 호출 규약 (인자 전달 방식, 스택 구조)
- cpu 아키텍처 : cpu의 하드웨어 구조
- 파이프라인 구조
- 분기 예측
- 캐시 계층 구조
- 병렬 실행
- 전력 소모, 열 설계
- 같은 ISA를 따르더라도 cpu 내부 구조는 다르게 설계되어 있음
- 같은 ISA를 쓰는 cpu끼리는 동일한 바이너리 파일을 실행할 수 있음
- 왜냐하면 ISA가 명령어, 레지스터, 데이터 형식을 표준화해놓았기 때문
- 어떤 바이너리 파일이든 특정 ISA에 맞춰 만들어졌다면 그 ISA를 따르는 모든 cpu는 그 명령어를 이해하고 실행할 수 있음
- cpu가 바이너리 파일로 된 명령어를 하나 하나 해석하고 실행할 때
- 이 명령어들은 전부 ISA 규칙을 따르고 있으므로 ISA를 정확히 따르는 cpu라면 어떤 제조사건 그 명령어 이해 가능
- 다른 ISA를 쓰는 cpu 위에서 실행시키려면 크로스 컴파일 해야 함
- ISA : cpu의 공통 언어
3.3
- 기본적으로 워드(word)는 16비트 데이터 타입을 지칭 (처음에 16비트 구조를 사용했기 때문)
- 쿼드워드 : 64bit
- 더블워드 : 32bit
- 워드 : 16bit
- 바이트 : 8bit
- x86-64 cpu에서는 c언어 데이터 타입이 어셈블리어에서 어떻게 표현되는가?
- 접미사 b/w/l/q는 명령어가 다루는 데이터의 크기 (1, 2, 4, 8 바이트)
- 참고로 l은 주로 32비트 정수 명령어의 접미사로 쓰이지만 64비트 부동 소수점(double)에도 사용됨
-> 실제 의미와 문맥이 다르기 때문에 혼동되지는 않음


- movw라는 어셈블리어는 16비트짜리 데이터를 복사(move)하라는 의미
'정글 > 컴퓨터 시스템' 카테고리의 다른 글
| 동적 메모리 할당 (Dynamic Memory Allocation) (2) | 2025.04.14 |
|---|---|
| 가상화(Virtualization) (2) | 2025.04.14 |