Skip to content

Latest commit

 

History

History
181 lines (128 loc) · 9.07 KB

File metadata and controls

181 lines (128 loc) · 9.07 KB

X86_64 어셈블리와 친해지기 [파트 1]

소개

우리 사이에는 많은 개발자들이 있습니다. 우리는 매일 엄청난 양의 코드를 작성합니다.

때때로 나쁘지 않은 코드도 작성하죠 :) 우리 모두는 다음과 같은 가장 간단한 코드를 쉽게 작성할 수 있습니다:

#include <stdio.h>

int main() {
  int x = 10;
  int y = 100;
  printf("x + y = %d", x + y);
  return 0;
}

우리 모두 이 C 코드가 무엇을 하는지 이해할 수 있습니다. 하지만... 이 코드가 저수준에서 어떻게 작동하는지 아는 사람은 많지 않을 것입니다. 저도 그랬습니다.

Haskell, Erlang, Go와 같은 고수준 프로그래밍 언어로 코드를 작성할 수는 있었지만, 이 코드가 컴파일된 후 저수준에서 어떻게 동작하는지 전혀 몰랐습니다.

그래서 어셈블리 언어로 몇 가지 깊은 단계를 밟아가면서 배운 내용을 설명하기로 했습니다. 이 과정이 저뿐만 아니라 여러분에게도 흥미로울 거라 생각합니다.

약 5~6년 전, 대학교에서 간단한 프로그램을 작성하기 위해 어셈블리를 사용한 적이 있습니다. 당시에는 Turbo Assembly와 DOS 운영 체제를 사용했습니다. 지금은 Linux-x86-64 운영 체제를 사용하고 있습니다. 그렇다면 Linux 64비트와 DOS 16비트 사이에는 큰 차이가 있을 것입니다. 시작해봅시다.

준비

시작하기 전에 몇 가지 준비를 해야 합니다. 앞서 언급했듯이, 저는 Ubuntu (Ubuntu 14.04.1 LTS 64비트)를 사용하고 있으며, 따라서 이 글에서는 이 운영 체제와 아키텍처를 기준으로 설명할 것입니다.

각 CPU는 서로 다른 명령어 집합을 지원합니다. 저는 Intel Core i7 870 프로세서를 사용하며, 모든 코드는 이 프로세서를 기준으로 작성될 것입니다. 또한 NASM 어셈블리를 사용할 것입니다. NASM을 설치하려면 다음 명령어를 실행하면 됩니다:

$ sudo apt-get install nasm

NASM의 버전은 2.0.0 이상이어야 합니다. 저는 2013년 12월 29일에 컴파일된 NASM 2.10.09 버전을 사용하고 있습니다.

마지막으로 어셈블리 코드를 작성할 텍스트 편집기가 필요합니다. 저는 Emacs와 nasm-mode.el을 사용합니다. 물론, 다른 편집기를 사용해도 상관없습니다. Emacs를 사용한다면, nasm-mode.el을 다운로드하고 Emacs를 다음과 같이 설정할 수 있습니다:

(load "~/.emacs.d/lisp/nasm.el")
(require 'nasm-mode)
(add-to-list 'auto-mode-alist '("\\.\\(asm\\|s\\)$" . nasm-mode))

현재로서는 이 정도 준비가 필요합니다. 다른 도구는 다음 게시물에서 설명하겠습니다

NASM 어셈블리 문법

여기서는 전체 어셈블리 문법을 설명하지 않을 것이며, 이번 게시물에서 사용할 문법의 일부만을 다룰 것입니다. 일반적으로 NASM 프로그램은 여러 섹션으로 나누어집니다. 이번 게시물에서는 다음 두 섹션을 다룰 것입니다:

  • 데이터 섹션
  • 텍스트 섹션

데이터 섹션은 상수를 선언하는 데 사용됩니다. 이 데이터는 실행 중에 변경되지 않습니다. 수학 상수나 기타 상수 등을 선언할 수 있습니다. 데이터 섹션을 선언하는 문법은 다음과 같습니다:

    section .data

텍스트 섹션은 코드에 사용됩니다. 이 섹션은 프로그램의 시작 지점을 정의하는 global _start로 시작해야 합니다.

    section .text
    global _start
    _start:

주석은 ; 기호로 시작합니다. 모든 NASM 소스 코드 라인은 다음 네 가지 필드의 조합을 포함합니다:

[label:] instruction [operands] [; comment]

대괄호 안의 필드는 선택적입니다. 기본 NASM 명령어는 두 부분으로 구성됩니다. 첫 번째는 실행할 명령어의 이름이고, 두 번째는 이 명령어의 피연산자입니다. 예를 들어:

    MOV COUNT, 48 ; Put value 48 in the COUNT variable

Hello world

첫 번째 NASM 어셈블리 프로그램을 작성해 봅시다. 물론, 전통적인 "Hello, World!" 프로그램입니다. 코드 예제는 다음과 같습니다:

section .data
    msg db      "hello, world!"

section .text
    global _start
_start:
    mov     rax, 1
    mov     rdi, 1
    mov     rsi, msg
    mov     rdx, 13
    syscall
    mov    rax, 60
    mov    rdi, 0
    syscall

네, printf("Hello world")처럼 보이지는 않습니다. 이 코드가 어떻게 작동하는지 이해해 봅시다. 1~2행을 살펴보세요. 데이터 섹션을 정의하고, 여기에서 "Hello world" 값을 가지는 msg 상수를 정의했습니다.

이제 이 상수를 코드에서 사용할 수 있습니다. 다음은 텍스트 섹션과 프로그램의 시작 지점을 선언한 것입니다. 프로그램은 7행부터 실행됩니다. 이제 가장 흥미로운 부분이 시작됩니다. mov 명령어는 두 개의 피연산자를 받아서 두 번째 값을 첫 번째에 넣습니다. 그런데 rax, rdi 등이 무엇인지 궁금할 것입니다. 위키피디아에 따르면:

중앙 처리 장치(CPU)는 컴퓨터 프로그램의 명령어를 수행하고 시스템의 기본 산술, 논리, 입출력 작업을 수행하는 하드웨어입니다.

좋습니다, CPU는 어떤 작업을 수행하고 있습니다. 하지만 이 작업을 위한 데이터는 어디에서 가져올까요? 첫 번째 대답은 메모리입니다. 그러나 메모리에서 데이터를 읽고 저장하는 과정은 복잡하기 때문에 CPU는 레지스터라는 자체 내부 메모리 저장 위치를 가지고 있습니다:

registers

따라서 mov rax, 1이라고 쓸 때는 rax 레지스터에 1을 넣는다는 의미입니다. 이제 rax, rdi, rbx 등이 무엇인지 알게 되었습니다. 그러나 언제 rax를 사용하고 언제 rsi를 사용하는지 알아야 합니다.

  • rax - 임시 레지스터; syscall을 호출할 때 rax에는 syscall 번호가 있어야 합니다.
  • rdx - 함수에 3번째 인자를 전달하는 데 사용됩니다.
  • rdi - 함수에 1번째 인자를 전달하는 데 사용됩니다.
  • rsi - 함수에 2번째 인자를 전달하는 데 사용되는 포인터입니다.

다시 말해, 우리는 sys_write 시스템 호출을 호출하고 있습니다. sys_write는 다음과 같습니다:

size_t sys_write(unsigned int fd, const char * buf, size_t count);

이 함수는 3개의 인자를 받습니다:

  • fd - 파일 디스크립터. 표준 입력, 표준 출력 및 표준 오류를 위해 각각 0, 1 및 2를 사용할 수 있습니다.
  • buf - 파일 디스크립터가 가리키는 파일로부터 얻은 내용을 저장할 수 있는 문자 배열을 가리킵니다.
  • count - 파일에서 문자 배열로 쓰여질 바이트 수를 지정합니다.

sys_write 시스템 호출이 3개의 인자를 받으며, syscall 테이블에서 1번 번호를 가진다는 것을 알았습니다. 이제 우리의 "Hello world" 구현을 다시 살펴보겠습니다. rax 레지스터에 1을 넣으면 sys_write 시스템 호출을 사용할 것이라는 의미입니다.

다음 줄에서는 rdi 레지스터에 1을 넣습니다. 이는 sys_write의 첫 번째 인자인 표준 출력(1)을 의미합니다. 다음으로 msg에 대한 포인터를 rsi 레지스터에 저장합니다.

이는 sys_write의 두 번째 인자인 버퍼를 의미합니다. 그리고 문자열의 길이(13)를 rdx에 전달하여 sys_write의 세 번째 인자로 사용합니다. 이제 sys_write의 모든 인자를 설정했으므로, 11행에서 syscall 명령어로 호출합니다.

"Hello world" 문자열을 출력한 후, 프로그램을 올바르게 종료해야 합니다. rax 레지스터에 60을 넣습니다. 60은 종료 시스템 호출 번호입니다.

또한 rdi 레지스터에 0을 넣어 오류 코드를 설정합니다. 0은 성공적인 종료를 의미합니다. 이것으로 "Hello world" 프로그램이 완료됩니다. 꽤 간단하죠 :) 이제 프로그램을 빌드해 보겠습니다.

예를 들어, 이 코드를 hello.asm 파일에 코드를 작성했다고 가정합시다. 그러면 다음 명령어를 실행하여 프로그램을 빌드할 수 있습니다:

$ nasm -f elf64 -o hello.o hello.asm
$ ld -o hello hello.o

이후, ./hello를 실행하면 터미널에서 "Hello world" 문자열이 출력됩니다.

옮긴이의 말

이 튜토리얼을 번역하며 원문의 기술적 정확성을 유지하면서도 내용을 보다 쉽게 전달하려고 노력했습니다. 어셈블리 언어는 저수준에서 컴퓨터가 어떻게 동작하는지 이해하는 데 큰 도움이 되지만, 그만큼 도전적인 부분도 많습니다.

이 번역이 x86_64 어셈블리 언어를 이해하고자 하는 분들에게 유용한 가이드가 되기를 바랍니다. 번역 과정에서 발생한 오류나 부정확한 부분에 대한 피드백은 언제든 환영합니다. 이 튜토리얼을 읽어주셔서 감사합니다.