42서울 교육과정 3서클 libasm 학습 노트

15 Dec 2020

42서울 본과정의 세 번째 서클에 포함된 프로젝트, libasm을 진행하며 작성한 메모입니다. “놀리팝의 어셈블리어” 동영상 강좌와 “배은태 교수의 어셈블리어 언어 강의”를 주로 참고하며 작성했습니다. 특히 배은태 교수의 강의에서 많은 내용을 학습했으며, 대부분의 내용의 출처는 해당 강의의 발표자료입니다.

2020년 12월 11일부터 20일까지, 총 10일에 걸쳐 학습을 진행했습니다.

어셈블리(Assembly)란?

기계어 라인 하나를 어셈블리 라인 하나에 대응되도록 만든 저수준 언어(low level language). 컴퓨터에 명령을 직접 내리므로 빠르고 정확한 처리가 가능하다. 리버스 엔지니어링이나, 시스템 해킹 등에도 자주 쓰인다.

어셈블리의 대표적인 문법

어셈블리어는 기계 종속적이다. 따라서 어떤 프로세서에서 실행할 것인지에 따라 문법이 달라진다. 본 과제에서는 인텔의 x86 아키텍처를 기준으로 한다.

  • AT&T와 Intel 문법

인텔 문법의 특징은 아래와 같다.

ADD Operand1, Operand2

위와 같은 코드에서 인텔 문법은 Operand1이 Destination, Operand2가 Source이다. 인텔 문법은 숫자나 레지스터명을 사용할 때 그 값을 그대로 사용하지만, AT&T문법은 Prefix가 붙는다. 또한 메모리 주소를 표기하는 방법도 다르다.

레지스터

“레지스터 집합”이란 메모리 또는 입출력 장치에서 불러온 값이나 프로세서에서의 연산 중간 결과값들을 저장하는 고속의 저장장치이다. 범용 레지스터(General Purpose Register)와 같이 프로그래머가 임의로 사용할 수 있는 레지스터가 있는 반면, 특수한 목적을 위해 사용하는 레지스터들도 있다. 특수 목적 레지스터의 종류는 아래와 같다.

  • 누산기(AC, Accumulator): ALU에서의 연산 결과값 저장
  • 스택 포인터(SP): 메모리 스택의 Top 주소 저장
  • 프로그램 카운터(PC, Program Counter): 다음에 실행될 명령어의 주소 저장
  • 프로세서 상태 레지스터(Processor Status Register): ALU에서의 연산 결과를 반영하거나, 프로세서의 각종 상태들을 저장하는 레지스터. “플래그 레지스터”라고 부르기도 한다.
  • 명령어 레지스터(IR, Instruction Register): 메모리로부터 읽어들인 명령어를 저장
  • 메모리 주소 레지스터(MAR, Memory Address Register)
  • 메모리 버퍼 레지스터(MBR, Memory Buffer Register)

ALU(Arithmetic Logic Unit)는 CPU에서 두 숫자의 산술연산과 논리연산을 계산하는 디지털 회로이다.

레지스터 종류

레지스터의 종류를 나열하자면 아래와 같다.

  • AX(Accumulator Register): 덧셈/뺄셈의 연산에 주로 사용. C언어 프로그램의 리턴값을 저장
  • BX(Base Register): 연산에 주로 사용하지만, 리턴값을 저장하지 않는다는 점에서 AX와 차이가 있다.
  • CX(Counter Register): 숫자를 세는 레지스터. 반복하는 수로부터 1만큼 감소하며 동작한다.
  • DX(Data Register): AX, BX, CX 등이 부족할 때 주로 사용하는 여분의 레지스터
  • SI(Source Index): 데이터를 복사할 때 원본(source)의 주소를 저장하는 레지스터
  • DI(Destination Index): 데이터를 복사할 때 복사한 값이 저장될(destination) 주소를 저장하는 레지스터
  • SP(Stack Pointer): 스택프레임(StackFrame)의 끝지점 주소를 저장
  • BP(Base Pointer): 스택프레임의 시작지점 주소를 저장

위의 레지스터는 16비트 아키텍처의 레지스터들로써, 32비트 아키텍처에는 앞에 “E”가, 64비트 아키텍쳐에는 “R”이 붙는다. 과제는 64비트 아키텍처를 기준으로 한다. 따라서 앞으로 코드에서는 “RAX”, “RBX”처럼 “R”을 붙여 사용한다.

64비트에는 R8부터 R15까지 8개의 레지스터가 더 있다. 이 레지스터는 앞의 레지스터들과는 달리 이름이 없고 번호만 갖고 있다.

레지스터의 이름

8-byte register Bytes 0-3 Bytes 0-1 Byte 0
%rax %eax %ax %al
%rcx %ecx %cx %cl
%rdx %edx %dx %dl
%rbx %ebx %bx %bl
%rsi %esi %si %sil
%rdi %edi %di %dil
%rsp %esp %sp %spl
%rbp %ebp %bp %bpl
%r8 %r8d %r8w %r8b
%r9 %r9d %r9w %r9b
%r10 %r10d %r10w %r10b
%r11 %r11d %r11w %r11b
%r12 %r12d %r12w %r12b
%r13 %r13d %r13w %r13b
%r14 %r14d %r14w %r14b
%r15 %r15d %r15w %r15b

(표 출처 - x64 Cheat Sheet)

마치 영어 공부할 때, “현재 - 과거 - 과거분사”로 동사의 변형을 외우던 것처럼, 몇 비트 레지스터에 접근하지는지에 따라 이름이 특정 규칙에 맞춰 바뀌는 것을 볼 수 있다. 아래는 8바이트: 4바이트 -> 2바이트 -> 1바이트 레지스터의 이름을 형태별로 그룹지어본 목록이다.

  • %r_x 형태: %e_x -> %_x -> %_l
  • %r_i 형태: %e_i -> %_i -> %_il
  • %r_p 형태: %e_p -> %_p -> %_pl
  • %rN 형태: %rNd -> %rNw -> %rNb

%r_i 형태는 인덱스를 위한 레지스터임을 가리키기 위해 i가 계속 이름에 따라붙는다. 마찬가지로 %r_p 형태도 포인터를 위한 레지스터이므로 p를 계속 표기해준다. 그 외에 이름이 없이 번호로 구분하는 %rN 형태의 레지스터는 각각 뒤에 해당 레지스터의 크기를 d(DWORD), w(WORD), b(BYTE)로 표기해준다. 그럼 다른 유형에서 l은 무엇일까? l은 “LOW”를 의미한다. 이는 2바이트 크기에서 좌우 1바이트를 각각 “HIGH”와 “LOW”로 나눈 것이다. 예를 들면 rdx의 절반 크기가 edx, 그리고 그것보다 절반 작은 크기의 레지스터는 dx다. 이 2바이트 크기의 dx에서, 우측 절반 1바이트는 dh, 남은 좌측 절반이 dl이다.

이름이 중요한 건 아니다. 그냥 암기하려면 힘드니까 이해를 돕기 위해 서술해봤다.

명령어

기초 명령어

명령어는 크게 네 가지 그룹으로 나눠볼 수 있다.

  • 산술, 논리, 시프트 명령어: +, >>, %
  • 데이터 전송 명령어: 변수에 대입하는 명령 등
  • 분기 명령어: 조건문, 반복문 등
  • 기타 제어 명령어들: 입출력, CPU 제어 등

기초 명령어의 예시를 순서없이 나열해보면 아래와 같다.

  • PUSH/POP: 스택에 갑을 넣고/가져오는 명령어
  • MOV: 값을 넣는 명령어
  • LEA: MOV처럼 값을 넣는 명령어지만, 값 대신 주소를 넣는다.
  • ADD/SUB: 더하고/빼는 명령어
  • INC/DEC: 값을 증가시키고/감소시키는 명령어
  • CMP: 두 값을 비교하는 명령어
  • JMP: 지정한 레이블로 이동하는 명령어
  • JE(JZ): 제로 플래그가 1이면 지정한 레이블로 이동하는 명령어
  • JC: 캐리 플래그가 1이면 지정한 레이블로 이동하는 명령어
  • CALL: 함수를 호출하는 명령어
  • RET: 호출한 함수를 종료하고 호출한 다음 줄로 이동시키는 명령어
  • NOP: 아무 것도 하지 않는 명령어

인자의 순서

함수로부터 인자를 받을 때, 인자는 순서대로 각각 아래처럼 래지스터에 등록된다.

rdi, rsi, rdx, rcx, r8, r9

명령어의 구성 요소

연산자와 피연산자

  • 연산코드(Operation Code, Opcode): 기계(CPU)가 수행할 동작을 나타내는 코드. 명령어에서 ‘어떤 연산’인지를 나타내는 부분
  • 피연산자(Operand) 연산(동작)의 대상: 주로 레지스터나 메모리상의 데이터가 그 대상이 됨. 정확히 얘기하면 피연산자의 주소값이라고 할 수 있다.

명령어 코드의 형식

명령어 코드의 비트열은 필드라고 불리는 몇 개의 그룹으로 나뉜다. 명령어 필드에 들어가는 요소는 흔히 다음과 같다.

  • 수행해야할 명시한 연산 코드(Opcode) 필드(필수)
  • 메모리의 주소나 레지스터, 즉 피연산자의 주소를 지정하는 주소(Address) 필드(필수)
  • 피연산자의 유효 주소(effective address)가 결정되는 방법을 나타내는 모드 필드

CPU 설계에 따라 피연산자의 주소의 개수는 달라질 수 있고, 피연산자 주소를 나타내는 다양한 방식을 제공한다. 피연산자 주소를 나타내는 방식을 “주소지정방식(Addressing mode)”이라 한다.

어셈블리어 코드의 형식

어셈블리어 각 줄은 다음과 같이 필드라 불리는 세 개의 부분으로 구성된다.

  1. 레이블 필드: 심볼 주소를 나타내거나 빈칸이 될 수도 있다. Ex: _ft_strlen:
  2. 명령어 필드: 기계 명령어나 의사(pseudo) 명령어를 기술 Ex: jsr clrMem
  3. 주석(comment): 명령어에 대한 주석을 기술 한다. Ex: ; i = 0

의사 명령어(Pseudo Instruction)

어셈블러 디렉티브(Assembler Directive)라고도 부른다. 기계 명령어가 아니라 번역 과정에서 발생되는 어떤 상황에 관한 정보를 어셈블러에게 알려주는 명령어로, 의사 명령어는 어셈블리어 코드와 달리 기계 코드로 번역되지는 않는다. 어셈블러 구현체에 종속적이다.

예시

  • section .text
  • .org $0200
  • temp .db 0: .db(define byte)로 temp라는 레이블에 0을 할당하는 코드

피연산자의 주소를 표현하는 방법들

주소지정방식(Address mode)

CPU 설계에 따라 연산의 대상이 되는 피연산자(레지스터, 메모리, I/O 장치 등)를 지정하기 위한 다양한 어드레싱 기법을 제공한다.

  • 포인터, 카운터 인덱싱, 프로그램 재배치(relocation) 등의 편의를 프로그래머에게 제공함으로써 융통성 제공
  • 명령어 주소 필드의 비트 수를 줄인다.

유효주소(Effective Address): 주소지정방식에 의해 결정되는 피연산자의 실제 주소

묵시 주소지정방식(Implied addressing mode)

피연산자가 묵시적으로 명령어의 정의에 따라 정해져 있는 방식이다. “0 주소 명령어”라고 한다.

예시

  • inx ; x레지스터의 값을 1 증가

레지스터 주소지정방식

CPU 레지스터를 피연산자로 지정하는 방식

예시

  • add $t2, $t0, $t1; t2레지스터에 t0, t1의 합을 저장한다

즉시 주소지정방식

피연산자 그 자체가 명령어 주소 필드에 들어가는 방식

예시(6502 어셈블리어)

  • lda #$10 ; 누산기 레지스터 A에 0x10 적재
  • adc #$20 ; 누산기에 0x20의 값을 더해라

직접 주소와 간접 주소

  • 직접 주소(direct address): 주소 필드에 들어가는 주소가 피연산자의 유효주소인 경우를 직접주소라 한다.
  • 간접 주소(indirect address): 주소 필드에는 피연산자의 유효주소 대신 피연산자의 유효주소가 저장되어 있는 메모리 주소가 들어간다. 피연산자의 간접 참조를 위해 별도의 사이클이 필요하다.
    • 레지스터 간접 주소 방식: 주소 필드에서 지정한 레지스터는 피연산자의 유효주소를 저장하고 있다.
    • 간접 주소지정방식: 주소 필드에는 피연산자의 유효주소를 저장하고 있는 메모리 주소가 들어간다.

인덱스와 베이스 주소

피연산자의 유효주소를 베이스(기준) 주소에 인덱스 값을 더해서 구한다.

Effective Address = Base Address + Index

흔히 데이터 배열에 접근할 때 사용하는 방법. 배열의 시작 주소를 베이스 주소로 사용하고, 시작주소로부터 떨어진 인덱스 값을 인덱스 레지스터를 통해 지정하는 방식을 인덱스 주소지정방식(indexed addressing mode)라 한다. 즉, 명령어의 주소 필드에는 베이스 주소가 들어간다.

인덱스 주소지정방식과 달리 배열의 시작주소를 베이스 레지스터에 저장하고 그로부터 떨어진 인덱스 값을 명시하는 방식을 베이스 레지스터 주소지정방식(base register address mode)라 한다. 즉, 명령어의 주소 필드에는 인덱스 값이 들어간다.

예시(6502 어셈블리)

ldx #$00
lda arr1, x ; A <- $10
inx
lda arr1, x ; A <- $20
inx
lda arr1, x ; A <- $30

arr1 .db $10, $20, $30, $40, $50

상대 주소지정방식(Relative addressing mode)

프로그램 카운터가 명령어의 주소 필드와 합해져서 유효 주소가 결정되는 방식. 즉 주소 필드에 들어가는 값은 PC로부터 떨어진 오프셋이다. 근거리 분기(점프) 명령어에서 주로 사용된다. 주소 필드의 비트 수를 절약할 수 있다는 장점이 있다.

명령어 집합에 따른 컴퓨터 분류

CISC(Complex Instruction Set Computer) 구조의 특징

절대적인 것이 아니라 전형적인 특성을 나열한 것이다. 원래 CISC 용어는 없었으나 RISC의 등장으로 생겨났다.

  • 많은 수의 명령어
  • 다양한 주소지정방식
  • 가변 길이 명령어 형식
  • 메모리 피연산자를 처리하는 명령어

RISC(Reduced Instruction Set Computer) 구조의 특징

“단축된 명령어 집합의 컴퓨터”을 말한다.

  • 상대적으로 적은 수의 명령어
  • 상대적으로 적은 수의 주소지정방식
  • 메모리 참조는 load와 store 명령어만으로 제한된다.
  • 모든 연산은 CPU 레지스터 안에서 수행된다.
  • 고정 길이 명령어 형식
  • 단일 사이클의 명령어 실행(평균적인 값)

예시: MIPS. 어드레싱 방식에 따라 명령어가 R, I, J 3가지 타입로 나뉜다.

x86은?

오늘날 순수한 의미의 CISC나 RISC는 찾아보기 어렵다. x86과 같이 전형적인 CISC로 알려진 CPU도 내부적으로는 RISC 방식을 사용한다.

짧은 정보

  • ssize_t: signed size type으로 보통의 32bit machine에서는 간단히 말해 int다. IO 함수의 반환값으로 많이 사용되는데 그 이유는 해당 IO 함수의 실패를 알려주기 위해서이다.
  • 메모리에 저장할 때 빅엔디안과 리틀 엔디안에 따라 2바이트 데이터를 저장하는 순서가 다를 수 있다. 배은태 교수의 유튜브 강좌는 리틀 엔디안 방식을 따르고 있다고 한다.

폰 노이만 구조

어셈블리 언어를 이해하려면 이를 사용하는 폰 노이만 구조를 알아야 한다. 프로그램과 데이터를 하나의 메모리에 저장하는 폰 노이만 구조는 프로그램(코드) 메모리와 데이터 메모리가 구분돼 있지 않다. 따라서 프로세서와 메모리 사이에 하나의 버스를 갖고 있다. 어드레스 버스를 이용해 CPU에서 메모리로 주소를 전달하면, 데이터 버스를 이용해 메모리는 CPU에게 값을 전달해준다.

참고 링크

웹페이지

PDF

동영상 강의

TAGS: 42seoul forty-two 42cursus