[Libasm] 어셈블리어, 레지스터, NASM

Seoysauce·2021년 4월 17일
2

42Seoul/Libasm

목록 보기
1/1
post-thumbnail

이 프로젝트는 어셈블리어와 친숙해지는 것을 목표로 한다.

1. 어셈블리어(Assembly language)

  • 기계어와 1:1로 매칭되는 언어
  • 기계어와 고급 언어의 중간 단계의 언어
  • 2진수로 표현된 기계어를 사람이 알아볼 수 있게 번역한 언어

어셈블리어가 뭔지 모른다면? 이 유튜브 시청을 강추. 아주 쉽게 잘 소개되어 있다.

기계어는 실제로 컴퓨터의 CPU가 읽어서 실행할 수 있는 0과 1로 이루어진 명령어의 조합이다. 이러한 각 명령어에 대해 사람이 알아보기 쉬운 니모닉 기호(mnemonic symbol)를 정해 사람이 좀 더 쉽게 컴퓨터의 행동을 제어할 수 있도록 한 것이 어셈블리 언어이다.

예를 들어,

10110000 01100001

는 x86 계열 CPU의 기계어 명령이고, 이것을 어셈블리어로 옮겨쓰면 다음과 같다.

mov al, 061h

명령어 mov는 영어 move를 변형한 니모닉이고, al은 CPU안에 있는 변수를 저장하는 레지스터의 하나이다. 그리고, 061h는 16진수 61 (즉 십진수 97, 이진수 01100001)이다. 이 한 줄의 뜻은 '16진수 61을 al 레지스터에 넣으라'는 뜻이며, 1과 0의 반복인 기계어보다 사람이 혼동없이 이해하기 한결 쉽다. 어셈블리어는 이러한 문장들로 구성된다.

지금까지 우리가 작성한 C언어(고급어) 파일을 GCC(GNU Compiler Collection)를 이용해 '전처리, 컴파일, 어셈블, 링크' 하는 과정을 거쳐 실행파일을 만들어 왔다. 지금까지는 GCC가 C언어를 어셈블리어로 컴파일 해주었지만, 이 프로젝트는 우리가 직접 어셈블리 파일을 만드는 것이다!

1.1. 어셈블리어의 문법: Intel 문법 vs AT&T 문법

C나 Java 등과 다르게 어셈블리어는 정해진 표준이 없다.
그래서 여러 종류의 문법이 존재하는데, 그 중 가장 대표적인 문법이 AT&T 문법과 Intel 문법이다.
두 문법의 공통점은 opcode(명령어)operand(인자값)로 나뉜다는 점이다.

예)

 ADD EAX, 9
(ADD operand 1, operand 2)
  • ADD: opcode, EAX: operand 1, 9: operand 2

다만 두 문법의 차이점은 Intel 문법은 operand 1이 destination이고 operand 2가 source인 반면,
AT&T 문법은 operand 1이 source, operand 2가 destination이라는 점이다.

또 다른 차이점은 *Intel 문법은 숫자를 그냥 숫자 그대로(0,1,2,3..) 표기하지만,
AT&T 문법은 숫자 앞에 $를 붙여($0,$1,$2,$3..) 표기한다는 점이 있다.

레지스터를 표기할 때도 다른데, 레지스터가 무엇인지는 아래에서 설명할 것이다.
아무튼 Intel 문법은 레지스터를 레지스터 이름 그대로 (rax, rbx, rcx, rdx) 표기하지만,
AT&T 문법은 앞에 %를 붙여 (%rax, %rbx, %rcx, %rdx) 표기한다.

이외에도 메모리 주소를 참조할 때 Intel은 대괄호를 씌워서 [EAX] 이렇게 표기하고 At&T는 괄호를 씌워 (EAX) 이렇게 표기하는 등의 차이가 있다.
Offset(오프셋)의 경우도 Intel은 EAX 레지스터에서 +4 만큼 떨어진 메모리 주소를 표기할 때는 [EAX + 4] 와 같이 표현한다.
어셈블리어를 들여다보면 대충 Intel 식인지 AT&T 식인지 알아볼 수 있을 듯...
어쨌든 우리는 Intel 문법을 쓸 것이기 때문에 Intel 문법을 알아가면 되겠다.

2. 레지스터

  • 레지스터는 CPU 내부에 있는(= 속도가 매우 빠른) 임시 저장장치이다.
  • 레지스터는 메모리에 액세스하지 않고도 처리할 데이터 요소를 저장한다.
  • 제한된 수의 레지스터가 프로세서 칩에 내장된다.

CPU가 요청을 처리하는 데 필요한 데이터를 일시적으로 저장하는 기억장치가 이 그림의 가운데에 있는 레지스터이다.

실제로 컴퓨터에서 데이터를 영구적으로 저장하기 위해서는 하드디스크를 이용해야하고, 임시적으로 저장하는 장소를 메모리(RAM)라고 알고있을 것이다.
하지만 메모리로 연산의 결과를 보내고 영구적으로 저장할 데이터를 하드디스크에 저장하는 등의 명령을 처리하기 위해서는 이들에 대한 주소와 명령의 종류를 저장할 수 있는 기억 공간이 하나 더 필요하다.
그리고 이 공간은 무리없이 명령을 수행하기 위해 메모리보다 빨라야 한다. 바로 이런 역할을 하는 것이 레지스터이다.
레지스터는 공간은 작지만 CPU와 직접 연결되어 있으므로 연산 속도가 메모리보다 수십 배에서 수백 배까지 더 빠르다.
때문에 연산을 위해서는 반드시 레지스터를 거쳐야 하며, 이를 위해서 레지스터는 특정 주소를 가리키거나 값을 읽어올 수 있다.

CPU에서 변수랑 비슷한 역할을 하는 것이라 생각하면 쉽다.
몇가지 레지스터를 살펴보자.

2.1. 레지스터의 종류

레지스터는 CPU 당 한개만 존재하는 것이 아니라 필요와 용도에 따라 여러 종류가 있고, 각 레지스터의 사용 방식에 따라 이름을 구분해 사용해왔다.

  1. 범용 레지스터 (64bit - 32bit - 16bit - up 8bit - down - 8bit)

    • rax (- eax - ax - ah - al)
      - 산술, 논리연산
      - syscall 함수 주소 저장
      - 함수 return값 저장
    • rbx (- ebx - bx - bh - bl)
      - 메모리 주소 저장
    • rcx (- ecx - cx - ch - cl)
      - 반복 작업에 루프 카운트를 저장(반복 횟수)
    • rdx (- edx - dx - dh - dl)
      - 입력 / 출력 작업
      - 큰 값 곱하기, 나누기 연산을 위해 DX, AX 사용
  2. 포인터 레지스터

    • rsp (스택 포인터의 최고점)
    • rbp (스택 포인터의 최저점)
  3. 인덱스 레지스터

    • rsi (src index)
      - 문자열 처리를 위한 소스 인덱스
    • rdi (dest index)
      - 문자열 작업을위한 대상 인덱스
  4. 세그먼트 레지스터

    • cs
    • ds
    • ss
    • es

우리 프로젝트에서는 범용 레지스터와 인덱스 레지스터만 사용된다고 한다. 그런데 왜 데이터 크기별로 이름이 다른거지? -> 2.3을 참고!

2.2. 64 bit vs 32 bit?

32 비트, 혹은 64비트 시스템 컴퓨터라는 말을 많이 들어봤을 것이다.
여기서 말하는 비트 수는 명령을 한 번에 처리할 수 있는 레지스터의 비트 수를 말한다.
쉽게 말해 하나의 레지스터가 저장 가능한 공간의 크기가 32비트인지 64비트인지를 나타내는 거라 보면 된다.

계속적으로 멀티미디어가 발전하고 사용자가 늘어남에 따라 프로그램의 용량은 커져야 했고 더욱 많은 메모리 공간을 필요로 하게 되었다.
그래서 나온 것인 64비트 체제이다.
네트워크 주소에서의 IPv4와 IPv6의 경우를 생각하면 이해가 쉽다.

2.3. 데이터의 단위: Bit, Byte, word, dword

어셈블리 변수의 자료형으로 사용된다.

data type (자료형)크기
BYTE부호 없는 1 byte (= 8 bit)
WORD부호 없는 2 byte
DWORD부호 없는 4 byte
QWORD부호 없는 8 byte

AL, AH, AX, EAX, RAX가 모두 다른 레지스터라고 이해하는 경우가 있는데, RAX가 EAX를 포함하고, EAX가 AX를 포함하고.. 이런 형태이다. AX의 상위 8 bit를 al이 사용하고, 하위 8 bit를 ah가 사용하는 식.
이는 하위 비트 시스템(32비트, 16비트 시스템) 호환을 위한 설계이기도 하고, 레지스터 공간의 낭비를 없애기 위함이기도 하며, 효율적인 알고리즘을 위한 설계이기도 하다.

3. Calling convention(함수 호출 규약)

  • 함수를 호출하는 규약으로 스택을 이용하여 파라미터를 전달할 때 인자 전달 방법, 인자 전달 순서, 전달된 파라미터가 해제되는 곳, 리턴 값 전달 등을 명시한다.''

어셈블리 프로그램을 짜려면 먼저 함수 호출 규약을 알아야 한다.

우리에게 필요한 x86_64 calling convention은 여기에 정리되어 있다.

archsyscall NRreturnarg0arg1arg2arg3arg4arg5
x86_64raxraxrdirsirdxr10r8r9

즉, x86_64 아키텍처에서의 syscall 넘버(NR)와 리턴값은 rax에 담기고, 함수에서 매개변수(arg0, arg1, ...)는 rdi, rsi, rdx, rcx, r8, r9 순으로 넘어오게 된다.

자주쓰는 명령어(opcode)들

1. 조작 명령어

  • call : 함수 호출

  • ret : call로 호출된 함수를 종료하고 그 다음 명령줄로 이동

  • nop : 아무것도 하지 않음

  • jmp : 분기(라벨) 실행.

    • 조건 점프 명령어 : cmp 연산 결과에 따라 jmp

      • je : cmp A B 에서 A = B 일때 특정 라벨로 jmp
      • jne : cmp A B 에서 A != B 일때 특정 라벨로 jmp
      • ja : cmp A B 에서 A > B 일때 특정 라벨로 jmp
      • jb : cmp A B 에서 A < B 일때 특정 라벨로 jmp
      • jae : A >= B
      • jbe : A <= B
    • 플래그 점프 명령어: flag의 값을 확인하고 그에 따라 jmp

      • jc : Jump If Carry, CF = 1 이면 jmp
      • jnc : Jump If No Carry CF, CF = 0 이면 jmp

2. 데이터 전송 명령어

  • push : 스택에 값을 넣음
  • pop : 스택에서 값을 가져옴
  • mov : 인자2 값을 인자1에 대입(전달)
  • lea : 인자2 주소를 인자1에 대입(전달)

3. 산술 명령어

  • inc : 인자의 값을 1 증가
  • dec : 인자의 값을 1 감소
  • add : 인자2 값을 인자1에 더함
  • sub : 인자2 값을 인자1에서 뺌
  • cmp : 인자1,2의 값을 비교. 주로 위의 조건점프 명령어와 세트로 사용한다.
  • test : 인자1과 인자2를 AND 연산한다. 이 연산의 결과는 ZF(zero flag)에만 영향을 미치고 Operand 자체에는 영향을 미치지 않는다.
    보통 rax의 값이 0인지 확인할 때 rax 0, 0 이런 식으로 사용된다.
    만약 TEST의 연산결과가 0이라면 ZF는 1로, 연산결과가 1이라면 ZF는 0으로 세트된다.

4. syscall

  • syscall 명령어를 통해 시스템 상에 미리 선언되어 있는 함수를 호출할 수 있다.
  • 이 때 syscall 할 함수의 번호를 rax에 미리 넣어줘야한다.
  • syscall 함수의 반환값 또한 rax에 저장된다.
  • 자주 사용하는 syscall numbers (macOS 기준)
0x2000001 - exit()
0x2000002
0x2000003 - read()
0x2000004 - write()
0x2000005 - open()
0x2000006 - close()
  • syscall 코드는 시스템 마다 다르며 맥은 이 문서를 참고하자.
    syscall은 클러스터에서 아래 위치에 있다.

/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/System/Library/Frameworks/Kernel.framework/Versions/A/Headers/sys/syscall.h

테이블 상 1,2,3… 이라고 넘버링 되어있는데 실제로 사용할 때는 0x2000003 (=read) 호출하는 이유?
Mac은 syscall 번호를 여러 클래스로 나눠 뒀다. write, read는 unix 클래스로 분류, 최상단 비트를 2로 설정해둠.

4.1. error 함수

  • syscall 후 에러 발생 시 이는 __error 함수를 이용해 처리해야한다.
  • __error 함수는 sys/errno.h 에 선언되어 있다.
  • syscall 함수는 오류가 있을 경우 rax작은 음수(-1 ~ -4095)를 반환하며 동시에 CF(carry flag)가 참이 된다. 따라서 jc(carry flag가 1일 때 점프)를 활용하면 에러 처리 구문(err)으로 넘어갈 수 있다.
  • 이 리턴값은, C언어에서 에러에 대한 정보를 나타내는 정보인 errno(쉽게 말해 미리 정의된 에러 번호)의 음수 값이다. 즉, syscall 후 에러 발생 시 rax에는 -errno(-4095 ~ -1)가 반환된다.
  • 어셈블리로 작성한 _err 구문이 올바르게 errno를 출력할 수 있도록 하기 위해서는 rax에 음수 리턴값, [rax]에 errno에 해당하는 숫자가 들어갈 수 있도록 작성해야한다.

예를 들어... 아래 write.s 예제를 살펴보자.

section .text
    global  _ft_write
    extern  ___error

_ft_write:
    mov rax, 0x2000004  ; rax에 write함수(0x2000004) 담음
    syscall             ; rax 값을 보고 해당하는 함수를 시스템콜
    jc  _err            ; CF = 1(오류 발생 시) 이면, _err 함수로 점프
                        ; syscall 함수는 오류가 있을 경우 rax에 해당하는 오류(음의 정수)값 반환하며
                        ; 동시에 CF = 1이 된다.
    ret

_err:
    push rax            ; rax 값을 스택에 백업
    call ___error       ; ___error 함수 호출(rax = 에러 주소값)
    pop rdx             ; 스택에 저장된 값을 rdx로 꺼내옴
    mov [rax], rdx      ; rax가 가리키는 주소의 값으로 rdx 값을 대입
    mov rax, -1         ; 반환할 값(-1)
    ret
  • syscall 한 write 함수에서 잘못된 파일 번호에 의한 에러가 생겼을 경우, (잘못된 파일 번호의 errno9이다)
  • write 함수에서 에러가 생기면 CF = 1 이 되면서, rax = -9가 된다.
  • 우리는 [rax]-9를 담아야 하고, rax에는 write 함수의 오류 반환값인 -1를 담아야 한다.
  • 따라서 rax에 담겨있는 error value를 push해 스택에 백업해둔다. 그 다음에 ___error 함수를 호출하면 에러 주소값이 rax에 담긴다. rdx에 스택에 저장시켜 놨던 error value를 팝해온다.
  • rdx에 담긴 error value를 에러 주소를 변경하지 않고 고대로 집어 넣기 위해 mov [rax], rdx 해준다. rax에 []를 씌워주면 주소는 건들지 않고 값만 바꿀 수 있다.

에러넘버는 여기에 정리되어 있다. (혹은 man 2 errno)

c언어 파일에서는 #include <errno.h>를 선언하면 errno라는 변수(별도의 선언 x)를 사용할 수 있으며 이 변수를 통해 에러코드를 불러 올 수 있게 된다.

5. NASM(Netwide Assembler)

어셈블리 언어는 NASM, MASM 등과 같은 어셈블러라고하는 유틸리티 프로그램에 의해 실행 가능한 기계어 코드로 변환된다.
그 중 넷와이드 어셈블러(Netwide Assembler, NASM)은 인텔 x86 아키텍처용 어셈블러이자 역어셈블러이다.

  • 어셈블러(Assembler) : 어셈블리어를 기계어로 바꿔주는 프로그램
  • 역어셈블러(Disassembler) : 기계어를 어셈블리어로 번역해 보여주는 프로그램(Ollydbg, IDA, GDB)

쉽게 말해 어셈블리 파일(.s)을 컴파일해주는 프로그램이라 할 수 있겠다.

이제 NASM을 설치해보자.

  1. brew설치
    curl -fsSL https://rawgit.com/kube/42homebrew/master/install.sh | zsh
  2. NASM 설치
    brew install nasm

설치를 완료했다면, 이제 어셈블리 파일(.s)을 작성하고 컴파일 해보자.

6. 어셈블리어로 Hello World 프로그램 짜기

6.1. Section

어셈블리파일은 세 부분으로 나뉜다.

  1. text section
    section .text
  • 실제 코드 작성 부분
  1. BSS section(Block Starting Symbol)
    section.bss
  • 변수 선언 부분(변하는 값들)
  1. data section
    section.data
  • 상수 선언, 초기화 부분(안 변하는 값들)

6.2. Hello World 출력 프로그램 짜기

helloworld.s 라는 파일을 만들고 아래와 같이 코드를 작성해보자.

section .text
	global _main    ; _main함수를 global로 설정하여, 전역에서 접근 가능하도록 함.
                   
_main :
	mov rax, 0x2000004	; syscall(시스템콜)쓰기 위해 rax에 syscall 번호 넣어줌
                	    	; 0x2000004 : write 함수
    ; 매개변수 순서대로 넣어줌, 순서 정해져있음.(rdi - rsi - rdx - r10 - r8 - r9)
    mov rdi, 1          ; rdi : arg0	fd = 1
	mov rsi, msg        ; rsi : arg1	buf에 들어가는 변수 명
	mov rdx, 11         ; rdx : arg2	출력할 문자열(hello world)의 바이트 수
	syscall             ; syscall 호출(write)
	/* 아래는 return 0과 비슷함. 종료된 것을 알려줘야 함.*/
	mov rax, 0x2000001  ; exit
	mov rdi, 0    	    ; rdi: arg0 = 0
	syscall		    ; exit 호출

section .data
	msg db "Hello World"

어셈블리 파일 내부 함수에 _(언더바)를 붙이는 이유?

일종의 약속(코딩 컨벤션)으로, 언더바를 붙이지 않으면 C에서 어셈블리어로 작성한 함수를 사용할 수 없다.
어셈블리에서는 기본적으로 모든 코드가 private이다. 이때 다른 모듈이 해당 코드에 접근할 수 있게 하기 위해서 global instruction을 이용하여 심볼에 다른 코드가 접근할 수 있도록 해 준다. 이렇게 명시하지 않는다면 링커에서 아무런 심볼을 찾을 수 없다는 오류가 발생한다.

6.3. 컴파일하기

  1. 컴파일
$ nasm -f macho64 hello.s
  1. 링킹
$ ld -lSystem hello.o
  1. 실행
$ ./a.out

출처

https://dongdd.tistory.com/862
NASM 튜토리얼
레지스터 내용 블로그
어셈블리 명령어 정리
42 wiki - Libasm
https://yechoi.tistory.com/17

profile
42Seoul, Front-end

0개의 댓글