libasm: Introduction, 예시 연습

chaewonkang·2021년 1월 11일
0

42 seoul

목록 보기
6/9

3rd Circle 진입을 위한 libasm 시작.

Introduction

주로 asm으로 축약되는 어셈블리어 (혹은 어셈블러, assembler), 컴퓨터 또는 프로그래밍이 가능한 디바이스를 위한 위한 로우 레벨 프로그래밍 언어로, 주로 일대일이 아니긴 하지만 설계자의 기기 코드 지침과 언어 사이에 아주 강력한 호응을 자랑한다. 모든 어셈블리어는 특정한 컴퓨터 아키텍쳐에 구체적으로 대응된다. 반면에, 대부분의 하이레벨 프로그래밍 언어는 다양한 아키텍쳐에 걸쳐 일반적으로 포터블하지만, 컴파일링이나 인터프리팅 과정을 거쳐야 한다. 어셈블리어는 심볼릭 머신 코드로 불리기도 한다.

Common Instructions

  • 작성한 함수들이 갑자기 종료되어서는 안된다. (세그 폴트, 버스 에러, 더블 프리 등의 이유로). 만약 이런 일이 일어나면 당신의 프로젝트는 'functional(기능적)'이지 않다고 간주되며 평가에서 0점을 받게 될 것이다.

  • 당신의 Makefile은 적어도 규칙 $(NAME), all, clean, fclean 그리고 re를 포함하고 있어야 한다. 그리고 꼭 필요한 파일들만 recompile/relink 해야 한다.

  • 프로젝트에 보너스 파트를 제출하려면, Makefile에 bonus 규칙을 포함시켜야 하고, 그 안에는 메인 파트에서 허용되지 않는 다양한 헤더와 라이브러리 또는 함수들을 포함하게 될 것이다. 보너스 파트의 결과물들은 _bonus.{c/h} 로 작성된 다른 파일들에 위치해야 한다. Mandatory 파트와 bonus 파트는 개별적으로 채점된다.

  • 제출, 채점과는 무관하게 당신의 프로젝트에 대한 테스트 프로그램을 만들 것을 권장한다. 이는 쉽게 당신과 동료들의 작업을 테스트할 수 있게 해 줄 것이다. 디펜스 과정에서도 매우 유용하다는 것을 알게 될 것이다. 디펜스 동안에, 당연히 당신의 테스터 또는 동료들의 테스터를 자유롭게 이용할 수 있다.

  • 할당된 깃 저장소에 당신의 작업을 제출할 것. 리포지터리 내부에 있는 작업들만 채점될 것이다. 만약 Deepthought이 채점에 배정되면(기계 평가의 경우), 이는 동료 평가 이후에 채점될 것이다. 기계 채점동안 프로젝트 내부에서 어떤 에러이든 발견되면, 평가는 멈춘다.

  • 64비트 어셈블리어를 작성할 것. calling convention을 유의하시고!

  • 인라인 어셈블리어는 할 수 없다. '.s' 파일들을 작성하쇼.

  • nasm으로 어셈블리어를 컴파일 할 것.

  • AT&T 말고, Intel 문법을 이용할 것.

Missing Concepts of Instruction

모르는 개념들 먼저 공부해 봅시다.

What is assembly language...?

프로그래밍 언어에는 C/C++과 같은 고급 언어(High-Level Language)외에도 어셈블리어와 기계어가 존재한다.

위키백과에 따르면, "기계어와 일대일 대응이 되는 컴퓨터 프로그래밍의 저급 언어"이다. 어셈블리어는, 0과 1로만 이루어져 있는 기계에 MOV, ADD와 같은 명령어를 각각 대응시킨 프로그래밍의 로우 레벨 (저급) 언어이다. 컴퓨터의 구조 (CPU)마다 기계어가 다르기 때문에, 이에 대응하는 어셈블리어도 각각 달라진다.

이러한 단점을 개선하기 위해 만들어진 것이 C언어와 같은 고급 언어로, CPU에 종속적이지 않으면서 저급 언어처럼 메모리에 직접 접근할 수 있는 장점이 있다.

기타 자세한 내용에 대해서는 링크를 참조.

Assembler Syntax

어셈블리어 문법은 크게 Intel문법과 AT&T 문법으로 나뉜다. 우리 과제에서는 Intel 문법을 쓰고, NASM으로 컴파일하라고 되어있다. 시기별로 거의 하나의 표준을 지키고 있는 C언어와는 달리, 어셈블리어는 AT&T와 Intel의 두 가지 문법을 지킨다. 이들은 서로 호환되지 않는다.

Commands

대표적인 명령어들.

  • MOV
    A의 값을 B의 값으로 옮긴다. 아래는 EAX에 100이라는 값을 넣는 예시. 구체적인 연산은 포함할 수 없다.
MOVE EAX 100
  • LEA
    A의 값을 B의 값으로, 연산을 포함하여 복사한다. 아래는 EAX에 1000을 넣은 값을 다시 EAX에 넣는다.
LEA EAX, [EAX + 1000]
  • JMP
    특정한 위치로 건너뛰어 코드를 실행 한다. 아래는 A의 위치로 뛰어서 코드가 실행된다.
JMP A

JA, JB, JE 등의 명령어로 두 인자를 받아 비교한 뒤 결과에 따라 다른 방향으로 점프하는 조건 명령도 존재한다.

  • CALL
    함수를 호출했다가 원래 위치로 돌아올 때 사용한다. JMP와 다른 점은, 실행이 끝나면 RET에 저장하고 다시 원래 상태로 돌아온다.

  • NOP
    아무 작업도 하지 않는다. 1바이트의 빈 공간을 차지한다.

  • RET
    현재 함수가 끝난 뒤 돌아갈 주소를 지정하기 위해 사용한다.

  • PUSH
    스택에 해당 값을 넣는다.

  • POP
    스택에 있는 값을 빼낸다.

  • LEAVE
    현재까지의 메모리 스택을 비우고, EBP를 자신을 호출한 메모리 주소로 채운다. 실행 중인 함수를 종료하기 위해 정리하는 작업에 사용된다.

  • RAX, EAX
    함수의 리턴 값을 저장한다

  • RDI
    목적지

  • RSI
    출발지

  • RBP
    베이스 포인터 (Base Pointer)

  • RSP
    스택 포인터 (Stack Pointer)

  • RIP
    현재 실행할 코드의 주소

  • AND (Logical AND)
    Destination, Source 피연산자의 각 비트가 AND연산 된다. 각 비트가 모두 1일 때만 결과 값이 1이 된다.
    레지스터, 상수, 메모리를 사용할 수 있다.

  • OR (Inclusive OR)
    Destination, Source 피연산자의 각 비트가 OR 연산 된다. 비트 중 하나라도 1이면 결과 값이 1이 된다.
    레지스터, 상수, 메모리를 사용할 수 있다.

  • XOR (Exclusive OR)
    Destination, Source 피연산자의 각 비트가 XOR연산 된다. 각 비트가 서로 다른 값일 때만 결과가 1이고, 같은 값이라면 결과는 0이 된다. 레지스터, 상수, 메모리를 사용할 수 있다.

레지스터를 0으로 초기화하는 방법 중 하나로 자주 사용된다. MOV 명령어보다 SUB, XOR이 크기가 더 작아 자주 사용된다.

MOV AX, 0 ;일반적인 AX에 0값을 넣는 명령어
SUB AX, AX ;AX-AX. 자신끼리 뺄셈 계산
XOR AX, AX ;서로 같은 값이므로 0이 되는 방식을 이용

64 Bit Assembler?

레지스터는 시스템 성능이 좋아짐에 따라 2배씩 뛰기 때문에 하위 시스템과 높은 호환성을 가진다. 예를 들어 64비트 운영체제의 컴퓨터는 64비트 프로그램 및 32비트 프로그램도 실행할 수 있다. 32비트 운영체제의 컴퓨터는 32비트 이하의 프로그램만 실행할 수 있다.

Calling Convention?

Calling conventions are a standardized method for functions to be implemented and called by the machine.
출처

어셈블리어의 시스템 콜에 대해 이해해야 한다. 일반적으로 사용하는 C언어 라이브러리 내부의 printf, scanf 등은 시스템 콜을 이용하는 것이다. C++의 cout, cin도 마찬가지이다. 시스템 콜에서는 출력, 입력 등의 언어가 이미 커널단에서 구축이 되어 있다.

Example

global _start
_start:
		xor		rax, rax
        mov		rbx, rax
        mov		rcx, rax
        mov		rdx, rax
        
        sub		rsp, 0x40
        mov		rdi, 0x0
        mov		rsi, rsp
        mov		rdx, 0x3f
        
        call _syscall
        
        mov		rax, 0x1
        mov		rdi, 0x1
        mov		rsi, rsp
        mov		rdx, 0x3f
        
        call _syscall
        
        mov		rax, 60
        call _syscall
        
_syscall:
		syscall
        ret

일단.

		xor		rax, rax
        mov		rbx, rax
        mov		rcx, rax
        mov		rdx, rax

시스템 콜의 번호를 결정하는 rax 레지스터는 xor 명령어를 이용해 0으로 초기화.
이후 rax를 각각 rbx, rcx, rdx에 삽입하여 rax, rbx, rcx, rdx 모두 0이 됨.

시스템 콜 함수 내용은 다음과 같다.

|%rax|System call|%rdi|%rsi|%rdx|
|----|------------------|-----------|-----------|-----------|
|0|sys_read|unsigned int fd|char *buf|size_t count|
|1|sys_write|unsigned int fd|const char *buf|size_t count|
|..|..|..|..|..|
|60|sys_exit|int error_code|||

시스템 콜의 세 인자 값은 차례대로 rid, rsi, rdx로 설정되어 있다.
즉, 첫번 째 시스템 콜이 실행되었을 때 사용자에게 입력(READ)을 받게 된다. 이후 두번 째 시스템 콜은 rax가 1인 상태이므로 출력(WRITE)이 실행되어 사전에 입력받은 해당 버퍼의 포인터를 찾아가서 그대로 출력이 이루어지게 된다.

        sub		rsp, 0x40
        mov		rdi, 0x0
        mov		rsi, rsp
        mov		rdx, 0x3f

맨 처음 상태에서는 rsp, rbp가 동일한 위치를 가리키므로, sub 연산을 통해 rsp를 0x40만큼 빼 줌으로써 스택 공간을 64바이트로 만든다.
이후 mov 연산으로 목적지인 rdi에 0을 넣어서, 시스템 콜(READ)의 첫번 째 인자 값인 0으로 입력 모드(READ)로 설정을 한다.
그리고 같은 방식으로 출발지인 rsi에 rsp(스택 포인터, char *buf)를 지정하여 현재 입력받은 데이터를 받을 버퍼의 포인터를 지정해 준다.
이후 rdx에 0x3f(deximal 63, size_t count)을 넣어, 삽입받을 데이터의 크기를 63으로 잡는다.

        call _syscall

이후 call을 통해 _syscall을 호출하면, 입력(READ)시스템 콜이 실행되어 사용자에게 입력을 받는다. (위에서 설명한 첫번 째 호출. rax가 0인 상태). 입력을 받고 나서 다음 블럭을 실행한다.

        mov		rax, 0x1
        mov		rdi, 0x1
        mov		rsi, rsp
        mov		rdx, 0x3f

같은 방식으로 mov 연산을 이용해서 rax에 1대입, 같은 방식으로 rdi(목적지)에도 1을 대입한다. rsi(출발지)에는 rsp(스택 포인터, char *buf)를 대입한다. 그리고 rdx에 0x3f를 넣어, 위에서와 같이 삽입받을 데이터의 크기를 63으로 잡는다.

        call _syscall

이후 call을 통해 _syscall을 호출하면, 두번 째 시스템 콜인 출력(WRITE)이 실행되어 사용자에게 사전에 입력 받은 해당 버퍼의 포인터를 찾아가서 그대로 출력이 이루어지게 된다.

        mov		rax, 60
        call _syscall

이후 mov 연산을 통해 rax에 60번을 설정해 주고 _syscall을 호출하면 위에서 본 표와 같이 종료(EXIT) 시스템 콜이 호출되어 프로그램이 종료된다.

Mandatory Part

  • 라이브러리는 libasm.a로 호출되어야 한다.
  • 함수들을 테스트할 메인 파일을 제출해야 하고, 이는 메인 파일이 기능적임을 보여주는 라이브러리와 함께 컴파일 될 것이다.
  • 다음의 함수들을 어셈블리어로 재작성해야 한다
    • ft_strcpy (man 3 strcpy)
    • ft_strcmmp (man 3 strcmp)
    • ft_write (man 2 write)
    • ft_read (man 2 read)
    • ft_strdup (man 3 strdup, malloc 허용
    • ft_strlen (man 3 strlen)
  • 시스템 콜 도중에 에러를 체크해야 하고, 필요한 경우 적절히 세팅한다.
  • variable errno 를 코드에 적절하게 세팅해야 한다.
  • 이를 위해, extern __error 호출이 허용된다.

Bonus Part

  • 아래 함수들을 어셈블리어로 재작성 해보세유. linked list 함수는 다음 구조를 따른다:
typedef struct			s_list
{
	void				*data;
    struct s_list		*next;
}						t_list;
  • ft_atoi_base
  • ft_list_push_front
  • ft_list_size
  • ft_list_sort
  • ft_list_remove_if

위 다섯 개 함수 모두 피씬 때 했던 것 처럼!

마치며...

절차적이고, 단순해 보인다. 개별 행위들을 선언적으로 프로그래밍 할 수 없어서 절차를 잘 지키면서 전체 구조를 설계해야 할 듯하다. C언어를 안한 지 오래되어 난관이 예상되지만 또 잘 해내보자. 다음 번 시리즈에서는 Mandatory Part부터 순차적으로 작성을 해 보겠다.

profile
Creative Technologist

0개의 댓글