Showing

[CS:APP] 컴퓨터 시스템 3장(1) : 어셈블리어 기초와 gcc, gnu, gdb 본문

컴퓨터 공학, 전산학/컴퓨터시스템

[CS:APP] 컴퓨터 시스템 3장(1) : 어셈블리어 기초와 gcc, gnu, gdb

RabbitCode 2023. 3. 27. 20:37

 

0.  Hello👋  어셈블리어

 안녕하세요! FlyDuck Dev🦢입니다.

오늘은 CS:APP 3장 프로그램의 기계수준 표현에 나온 용어들(gcc, gnu, gdb)과 어셈블리어를 이해하기 위해 찾아본 바를 정리해보는 식으로 포스팅을 진행해보고자합니다.😊 특히 아래의 어셈블리어 코드를 읽고 이해하는 것을 목표로 본 포스팅을 진행하였습니다.

 

이번 포스팅에서 읽어볼 어셈블리어 예제

 


1. gcc

 

gcc는 GNU Compiler Collection의 약자로, C, C++, Objective-C, Fortran, Ada 등 여러 언어로 작성된 소스 코드를 컴파일하는 데 사용되는 컴파일러입니다.

gcc는 오픈 소스이며, 리눅스와 같은 유닉스 기반 운영 체제에서 흔히 사용됩니다. gcc는 표준 C 및 C++ 규격을 준수하며, 다양한 플랫폼에서 실행 가능한 코드를 생성할 수 있습니다. 또한 gcc는 최적화 옵션을 포함하여 다양한 컴파일러 플래그를 제공하여 개발자가 컴파일 프로세스를 더욱 세밀하게 제어할 수 있도록 합니다.

 

2. gnu

 

GNU는 "GNU's Not Unix"의 재귀적인 약어로, 1980년대 초기에 리처드 스톨만(Richard Stallman)이 시작한 자유 소프트웨어 프로젝트입니다.

GNU 프로젝트의 목표는 사용자들에게 자유로운 소프트웨어를 제공하는 것이었습니다. 그것은 사용자들이 소프트웨어를 실행, 복사, 배포, 연구, 수정하고 개선할 수 있는 권리를 포함했습니다. GNU는 오픈 소스 소프트웨어의 기반을 형성하고, GPL(GNU General Public License)과 같은 자유 소프트웨어 라이선스의 개발을 촉진하였습니다.

GNU 프로젝트는 여러 가지 도구와 소프트웨어를 개발했습니다. 이러한 도구들은 GCC(GNU Compiler Collection), GNU Emacs, GDB(GNU Debugger), Bash shell 등을 포함하며, 대부분의 리눅스 배포판과 다른 자유 및 오픈 소스 소프트웨어에서 사용됩니다.

 

3. gdb

 

GDB는 GNU Debugger의 약어로, C, C++, Ada, Fortran 등의 프로그래밍 언어로 작성된 프로그램의 디버깅에 사용되는 오픈 소스 디버거입니다.

GDB는 프로그램의 실행 중간에 멈추고, 변수의 값이나 메모리의 상태 등을 살펴볼 수 있는 기능을 제공합니다. 또한 프로그램 실행 도중에 브레이크 포인트를 설정하거나, 단계별 실행, 함수 호출 추적, 스택 트레이스 등 다양한 디버깅 기능을 제공합니다.

GDB는 리눅스와 유닉스 기반 운영 체제에서 주로 사용되며, 다양한 플랫폼에서 사용 가능합니다. GDB는 커맨드 라인 인터페이스(CLI)를 제공하지만, GUI 프론트엔드를 사용할 수도 있습니다. GDB는 자유 소프트웨어이므로, 사용자들은 소스 코드를 검토하고 수정할 수 있습니다.

 

4. 어셈블리어 읽는 방법

 

어셈블리어는 기계어에 가까운 저수준의 프로그래밍 언어이기 때문에 컴퓨터의 하드웨어 구조와 동작 방식에 대한 이해가 필요하며, 일반적인 프로그래밍 언어와는 조금 다른 문법과 구조를 가지고 있습니다. 

  1. 어셈블리어는 주로 명령어(instruction)와 오퍼랜드(operand)로 구성되어 있습니다. 명령어는 CPU가 수행할 동작을 나타내며, 오퍼랜드는 명령어가 적용될 대상(레지스터, 메모리 위치 등)을 나타냅니다.
어셈블리어에서 "오퍼랜드(operand)"는 명령어가 작동하는 데 필요한 값을 나타내는 용어.
즉, 레지스터, 메모리 위치, 상수 등 명령어가 수행될 때 필요한 값이나 위치.

예를 들어, "movq %rax, %rbx"라는 명령어에서 "%rax"와 "%rbx"가 오퍼랜드.
이 명령어는 "%rax"에 저장된 값을 "%rbx"에 복사합니다.
소스 오퍼랜드 vs 목적 오퍼랜드
소스 오퍼랜드는 명령어에서 데이터를 읽어올 위치나 값으로, 명령어에서 소스 오퍼랜드가 나타내는 값이 계산의 대상이 되는 경우가 많습니다.
목적 오퍼랜드는 명령어에서 데이터를 쓸 위치나 값으로, 명령어에서 목적 오퍼랜드가 나타내는 위치에 연산 결과를 저장하거나 메모리에 쓰는 등의 작업을 수행하는데 사용됩니다.
mov %eax, (%ebx)
%eax는 소스 오퍼랜드로서 데이터를 가져올 위치나 값이 되고, (%ebx)는 목적 오퍼랜드로서 데이터를 쓸 위치가 됩니다. 따라서 이 명령어는 %eax가 가리키는 값을 %ebx가 가리키는 메모리 주소에 저장하는 역할을 합니다.
예시1 : movq %rax, %rbx
%rax 레지스터에 들어있는 값을 %rbx 레지스터에 복사하는 명령어입니다. movq는 64비트 데이터를 복사하는 명령어이기 때문에 %rax와 %rbx 레지스터 모두 64비트 크기를 가지고 있어야 합니다. 이 명령어는 아래와 같은 과정으로 동작합니다.
  1. %rax 레지스터에 있는 값을 읽어옵니다.
  2. 읽어온 값을 %rbx 레지스터에 쓰기 위해 복사합니다.
  3. %rbx 레지스터에 값을 씁니다.
이렇게 되면 %rax와 %rbx 레지스터에는 같은 값이 들어있게 됩니다.
예시2 : mov %eax, (%ebx)
EAX 레지스터에 저장된 값을 EBX 레지스터가 가리키는 메모리 위치에 저장합니다. 즉, EAX 레지스터의 값을 메모리 위치로 복사합니다.
괄호는 간접참조 연산자로 사용되어 EBX 레지스터가 가리키는 메모리 위치를 나타냅니다. 이 경우에는 EBX 레지스터가 가리키는 메모리 위치에 EAX 레지스터의 값을 저장하게 됩니다.
따라서, "mov %eax, (%ebx)" 명령어는 EAX 레지스터의 값을 EBX 레지스터가 가리키는 메모리 위치에 저장하는 명령어입니다.
  1. 어셈블리어로 작성된 코드가 어떤 동작을 수행하는지 이해하기 위해서는 해당 CPU 아키텍처의 명령어 세트와 동작 방식에 대한 이해가 필요합니다.
  2. 디버거 등의 도구를 사용하여 코드를 실행하고 디버깅하면서 읽을 수 있습니다. 디버거를 사용하면 코드를 단계별로 실행하거나, 레지스터나 메모리의 값을 살펴볼 수 있습니다.
  3. 자주 사용되는 패턴과 명령어를 숙지하고, 이를 활용하여 코드를 분석해봅니다. 예를 들어, 함수 호출, 분기(branch) 및 반복(loop) 구조 등은 어셈블리어 코드에서 자주 사용되는 패턴입니다.
예를 들어, x86 아키텍처에서 "mov eax, 0"은 "eax" 레지스터에 0을 대입하는 명령어입니다. 이 경우 "eax"는 오퍼랜드이며, 0은 상수(constant)로 사용됩니다.
 
"add eax, ebx"는 "eax"와 "ebx"의 값을 더하여 "eax"에 저장하는 명령어입니다. "jmp label"은 "label"로 분기하는 명령어이며, "cmp eax, 0"은 "eax"와 0을 비교하는 명령어입니다.

어셈블리어에서는 대개 레지스터(register)와 메모리(memory)를 사용하여 데이터를 처리합니다. 레지스터는 CPU 내부에 있는 소량의 데이터 저장 공간으로, 매우 빠른 데이터 접근 속도를 가지고 있습니다. 메모리는 느리지만 더 많은 데이터를 저장할 수 있습니다

 

(1) %rdx  vs %rbx

d와 b 단 한글자만 다르지만 그 역할은 서로 다릅니다. 

%rdx와 %rbx는 둘 다 64비트 범용 레지스터로서, x86 아키텍처에서 사용됩니다.

%rdx는 "데이터 레지스터"로서 함수 호출 시 정수형 인자를 전달하는 데 사용됩니다. 또한 곱셈 및 나눗셈 연산에서 나머지 값을 저장하는 데에도 사용됩니다.

반면 %rbx는 "베이스 레지스터"로서 주로 메모리 주소 계산에 사용됩니다. 예를 들어, %rbx는 배열의 시작 주소를 저장하는 데에 자주 사용됩니다.

 

(2) 일반적인 목적 레지스터 rdi, rsi, rdx

rdi, rsi, rdx 레지스터는 모두 일반적인 목적 레지스터입니다. 이들 레지스터는 함수 호출에서 인자 전달에 사용되는 레지스터들입니다.

하지만, rdi와 rsi 레지스터는 첫 번째와 두 번째 인자를 전달하는 데에 특별히 사용됩니다. 이들은 호출 규약에 따라 인자를 전달할 때 사용되는 기본 레지스터로 지정되어 있습니다.

반면에 rdx 레지스터는 시스템 콜 호출에서 전달하는 인자 중 하나를 전달하는데에 특별히 사용됩니다. 예를 들어, rdx 레지스터는 write() 시스템 콜 호출에서 데이터 길이를 지정하는 데에 사용됩니다.

따라서, 이 세 개의 레지스터는 모두 다른 목적으로 사용됩니다. 그러나, 일반적으로 함수 호출에서 rdi와 rsi 레지스터가 가장 많이 사용되고, rdx 레지스터는 시스템 콜 호출에서 사용됩니다.

이는 System V ABI(호환성 인터페이스)에서 정의된 규칙 중 하나입니다. 따라서 함수 호출 시, 첫 번째 인자는 %rdi, 두 번째 인자는 %rsi, 세 번째 인자는 %rdx, 네 번째 인자는 %rcx 레지스터를 통해 전달됩니다. 다섯 번째 인자부터는 스택을 통해 전달됩니다.

 

(3) %rax

%rax는 x86 아키텍처에서 64비트 범용 레지스터 중 하나로, 함수 호출 반환값을 저장하는 레지스터로 주로 사용됩니다.  산술 명령어나 데이터 이동 명령어에서도 자주 사용됩니다.

movq $10, %rax : 상수 10을 %rax에 저장

addq %rbx, %rax : %rax와 %rbx의 값을 더한 후 결과를 다시 %rax에 저장

 

아래와 같은 add 함수를 작성한 c코드가 있다면,

long add(long a, long b) {
    return a + b;
}

 

add:
    movq    %rdi, %rax  # a 값을 %rax 레지스터에 저장
    addq    %rsi, %rax  # b 값을 더함
    ret

코드에서 movq %rdi, %rax : 함수의 번째 인자인 a  %rax 레지스터에 저장하는 부분입니다

addq %rsi, %rax  함수의 번째 인자인 b  %rax 레지스터에 더하는 부분입니다. 마지막으로 ret 명령어를 통해 %rax 레지스터에 저장된 값이 반환됩니다.

 

 

5.어셈블리어 예제(p.166)

우선 컴파일 전 c의 코드

  long mult2(long, long); //함수의 프로토타입(prototype)을 선언
  void multstore(long x, long y, long *dest) {
                     long t = mult2(x, y);
                     *dest = t;
}

 

C 언어에서 함수의 프로토타입은 함수가 어떤 형태로 정의되어 있는지 미리 컴파일러에게 알려주는 역할을 합니다. 이를 통해 컴파일러는 함수를 호출하는 코드에서 전달하는 인자들과 반환값의 자료형이 함수 정의와 일치하는지 검사할 수 있습니다.

위 코드에서 long mult2(long, long);는 mult2 함수가 두 개의 long 형 인자를 받고, long 형 값을 반환한다는 것을 프로토타입으로 선언한 것입니다. 

multstore의 세번째 인자 long *dest는 dest가 long형 변수를 가리키는 포인터임을 나타내고, *dest는 dest가 가리키는 메모리 공간의 값을 나타냅니다. 따라서 *dest = t는 t의 값을 dest가 가리키는 메모리 공간에 저장하는 것을 의미합니다. 이 때 & 연산자는 t의 주소를 구하는 연산이므로 사용하지 않습니다.

간접 참조 연산자(*)와 포인터는 밀접한 관계가 있지만, 엄밀하게 말하면 다른 개념입니다.
포인터는 메모리 주소를 저장하는 일정한 크기의 메모리 공간(변수)이며, 포인터 변수를 선언할 때는 변수 타입 뒤에 *를 붙입니다. 예를 들어 int 타입 변수의 메모리 주소를 저장하는 포인터 변수는 int * 타입으로 선언됩니다.
간접 참조 연산자(*)는 포인터가 가리키는 메모리 영역에 저장된 값을 가져오거나 변경하는 연산자입니다. 포인터 변수 p가 있다면 *p는 p가 가리키는 메모리 영역에 저장된 값을 가져오거나 변경할 수 있습니다.
간접 참조 연산자(*)는 포인터를 사용하여 메모리에 접근하는 방법 중 하나이며, 포인터와 함께 사용될 때 자주 사용됩니다. 

 

 

위 어셈블리어를 읽으면 아래와 같습니다.

위의 코드는 어셈블리어로 작성된 함수인 multstore 입니다.

함수 호출 시, 첫 번째 인자는 %rdi, 두 번째 인자는 %rsi, 세 번째 인자는 %rdx, 네 번째 인자는 %rcx 레지스터를 통해 전달

우선 눈에 띄게 달라진 점은, 지역 변수 이름이나 데이터 타입에 관한 모든 정보는 삭제되었다는 점입니다.

multstore 함수는 C 언어 코드에서 선언된대로 인자 3개인데 어셈블리어 코드에서는  x, y에 대응되는 %rdi, %rsi가 생략되어있습니다.

multstore:
    pushq   %rbx
    movq    %rsi, %rbx    # y 값 저장
    movq    %rdi, %rsi    # x 값을 %rsi 레지스터에 저장
    call    mult2
    movq    %rax, (%rbx)  # 결과를 dest 메모리 위치에 저장
    popq    %rbx
    ret

우선 위의 스타일에서 multstore 함수를 구현하는데 있어서 %rdi %rsi 레지스터에 들어있는 값들은 mult2 함수가 요구하는 개의 인자(x y) 전달하는 역할을 하게 됩니다. 따라서 %rdi %rsi 레지스터 어느 레지스터에 x y 할당하더라도 함수의 기능은 변하지 않습니다

단지 첫 번째 코드에서는 x 인자를 %rdi 레지스터에, y 인자를 %rsi 레지스터에 전달하여 mult2 함수를 호출했고, 두 번째 코드(책)에서처럼  %rdi, %rsi 레지스터는 어셈블리어 코드에서는 별도로 표시하지 않을 수 있습니다. 이는 어셈블리어 코드에서 레지스터를 참조할 때 레지스터 이름을 생략할 수 있기 때문입니다.

C 언어에서는 함수 호출 시 인자가 스택에 저장되고, 함수 내부에서는 스택에 저장된 인자를 사용합니다. 하지만 어셈블리어 코드에서는 함수 호출 시 레지스터에 인자를 저장하고, 함수 내부에서는 레지스터에 저장된 인자를 사용할 수도 있습니다.

이러한 차이 때문에 C 언어 코드에서는 함수 호출 시 인자가 스택에 저장되어 있지만, 어셈블리어 코드에서는 함수 호출 시 레지스터에 인자가 저장되어 있을 수 있습니다. 따라서 multstore 함수의 C 언어 코드에서는 인자가 3개로 선언되어 있지만, 어셈블리어 코드에서는 레지스터를 통해 인자를 전달하므로 아래와 같이 인자가 2개로 보일 수 있습니다.

그러나 실제로는 %rdi, %rsi, %rdx 레지스터가 각각 x, y, dest에 대응되므로, multstore 함수는 C 언어 코드에서 선언된대로 인자 3개를 받아들이고 있습니다.

책 예제
multstore:
   pushq   %rbx
   movq    %rdx, %rbx    //#%rdx 레지스터의 값을 %rbx 레지스터에 복사 : multstore 함수가 %rdx 레지스터에 저장된 값에 의존하는 작업을 수행
   call    mult2
   movq    %rax, (%rbx)
   popq    %rbx
   ret

 

%rdx 레지스터에 저장된 값을 %rbx 레지스터로 복사한 후, mult2 함수를 호출하고, mult2 함수의 반환값을 %rax 레지스터로 받아들인 후, %rbx 레지스터에 저장된 값이 가리키는 메모리 위치에 %rax 레지스터에 저장된 값을 저장합니다.

  long mult2(long, long); //함수의 프로토타입(prototype)을 선언
  void multstore(long x, long y, long *dest) {
                     long t = mult2(x, y);
                     *dest = t;
}

 

위의 코드는 어셈블리어로 작성된 함수인 multstore는 두 개의 인자를 받아들이는데, 첫 번째 인자는 함수 mult2를 호출할 때 사용되고 두 번째 인자는 mult2가 반환한 값을 저장할 메모리 주소입니다.

이 함수의 기능은 인자로 받은 값을 mult2 함수에 전달하여 그 결과를 메모리에 저장하는 것입니다. 이를 수행하는 방법은 다음과 같습니다.

  1. pushq %rbx: 현재 %rbx 레지스터에 저장된 값을 스택에 저장하고 %rbx를 스택에 push. 이는 함수 호출 시 %rbx 레지스터를 사용하므로, 현재 %rbx에 저장된 값이 나중에 필요할 때 복구하기 위함입니다.
  2. movq %rdx, %rbx: %rdx 레지스터에 저장된 값을 %rbx 레지스터로 복사합니다. 이는 두 번째 인자로 받은 메모리 주소를 %rbx 레지스터에 저장하기 위함입니다.
  3. call mult2: mult2 함수를 호출. 이 때, 첫 번째 인자는 이전 단계에서 %rbx 레지스터에 저장된 값이 전달됩니다.
  4. movq %rax, (%rbx): %rax 레지스터에 저장된 값을 %rbx가 가리키는 메모리 위치에 저장합니다. 이는 mult2 함수에서 반환된 결과값을 저장하기 위함입니다.
  5. popq %rbx: 스택에 저장된 %rbx 레지스터 값을 꺼내어 복원. 이는 함수 호출 이전의 %rbx 레지스터 값을 복구하기 위함입니다.
  6. ret: 함수 실행을 종료하고, 호출 이전으로 돌아갑니다. 이 때, %rbx 레지스터 값이 복구되므로 이전 상태로 돌아갈 수 있습니다.


 

 

 

지금까지 어셈블리어 기초를 정리해보고 어셈블리어를 읽어보았습니다.

 

포스팅 읽어주셔서 감사합니다

틀린 정보에 대한 수정 요청 댓글은 늘 환영합니다!