1. 개요

마이크로컴퓨터 시스템의 구성요소가 무엇인가? 마이크로컴퓨터 시스템은 마이크로프로세서 장치(microprocessor unit, MPU), 버스 시스템, 메모리 하위시스템, 입출력 하위시스템, 모든 구성요소들간의 인터페이스로 구성된다. 전형적인 대답이다.

이는 하드웨어만을 고려한 것이다. 모든 마이크로컴퓨터 시스템은 하드웨어 구성요소들의 작업을 지시할 소프트웨어가 필요하다. 컴퓨터 소프트웨어는 시스템측(시스템 소프트웨어)과 사용자측(사용자 소프트웨어)으로 구분할 수 있다.

프로그램을 실행하기위해 필요한 함수들을 모아둔 기본 라이브러리나 사용자가 만든 라이브러리는 사용자 소프트웨어에 포함된다.

고급언어 변환기, 어셈블러, 편집기, 다른 프로그램을 만드는 작업을 돕는 프로그램들이 시스템 소프트웨어에 속한다. 우리는 이미 프로그래밍에는 기계어, 어셈블리어, 고급언어 세 단계가 있음을 안다.

기계어 프로그램은 컴퓨터가 이해하고 직접 실행할 수 있는 프로그램이다. 어셈블리어 명령어는 기계어 명령어와 보통 일대일 관계로 대응하지만, 우리가 쉽게 이해할 수 있는 문자열을 사용한다. 고급언어 명령어는 영어에 매우 가까워서 프로그래머가 생각하는 방식과 자연스럽게 대응한다. 결국 어셈블리어나 고급언어 프로그램은 변환기라는 프로그램에 의해 기계어로 변환되야 한다. 이 변환기를 각각어셈블러(assembler)컴파일러(compiler) 혹은 인터프리터(interpreter)라고 한다.

C/C++같은 고급언어의 컴파일러는 고급언어를 어셈블리코드로 변환할 수 있다. GNU C/C++ 컴파일러의 -S 옵션은 프로그램 소스에 해당하는 어셈블리코드를 생성한다. 반복, 함수 호출, 변수 선언과 같은 기본적인 구조가 어셈블리어로 어떻게 대응하는지 알면 C 내부를 이해하기 쉽다. 이 글을 이해하기위해서는 컴퓨터구조와 Intel x86 어셈블리어에 익숙해야 한다.

2. 시작

먼저 hello world를 출력하는 간단한 C 프로그램을 작성하고, -S 옵션으로 컴파일한다. 입력파일에 대한 어셈블리코드를 얻을 수 있다. GCC는 기본적으로 확장자 `.c'를 `.s'로 변경하여 어셈블러파일명을 짓는다. 어셈블러파일 끝의 몇줄을 해석해보자.

80386 이상 프로세서에는 많은 레지스터와 명령어, 주소지정방법이 있다. 그러나 간단한 명령어 몇개만 좀 알아도 GNU 컴파일러가 만드는 코드를 충분히 이해할 수 있다.

일반적으로 어셈블리어 명령어는 라벨(label)연상기호(mnemonic)연산수(operand)로 구성된다. 연산수 표시방법에서 연산수의 주소지정방식을 알 수 있다. 연상기호는 연산수에 저장된 정보에 작업을 한다. 사실 어셈블리어 명령어는 레지스터와 메모리위치에 작업을 한다. 80386계열은 eaxebxecx등의 (32비트) 범용레지스터를 가진다. 두 레지스터, ebp와 esp는 스택을 조작할때 사용한다. GNU Assembler (GAS) 문법으로 작성한 전형적인 명령어는 다음과 같다:

movl $10, %eax

이 명령어는 eax 레지스터에 값 10을 저장한다. 레지스터명 앞의 `%'와 직접값(immediate value) 앞의 '$'는 필수 어셈블러 문법이다. 모든 어셈블러가 이런 문법을 따르는 것은 아니다.

목록 1은 first.s 파일에 저장한 우리의 첫번째 어셈블리어 프로그램이다.

#목록 1
.globl main
main:
  movl $20, %eax
  ret

cc first.s 명령어를 실행하면 이 파일을 어셈블하고 링크하여 a.out을 만든다. GNU 컴파일러 앞단cc가 `.s' 확장자를 어셈블리어 파일로 인식하여, 컴파일단계를 생략하고 어셈블러와 링커를 부른다.

프로그램의 첫번째 줄은 주석이다. 어셈블러 지시어 .globl은 심볼 main을 링커가 볼 수 있도록 만든다. 그래야 main을 호출하는 C 시작라이브러리를 프로그램과 같이 링크하므로 중요하다. 이 줄이 없다면 링커는 'undefined reference to symbol main' (심볼 main에 대한 참조가 정의되지않음)을 출력한다 (한번 해봐라). 프로그램은 단순히 레지스터 eax에 값 20을 저장하고 호출자에게 반환한다.

3. 산술계산, 비교, 반복

다음 목록 2 프로그램은 eax에 저장된 값의 계승(factorial)을 계산한다. 결과를 ebx에 저장한다.

#목록 2
.globl main
main: 
	movl $5, %eax
	movl $1, %ebx
L1:	cmpl $0, %eax		//eax에 저장된 값과 0을 비교
	je L2			//0==eax 이면 L2로 건너뜀 (je - jump if equal)
	imull %eax, %ebx	// ebx = ebx*eax
	decl %eax		//eax 감소
	jmp L1			// L1으로 무조건 건너뜀
L2: 	ret

L1과 L2는 라벨이다. 제어흐름이 L2에 도달하면, ebx는 eax에 저장된 값의 계승을 저장하게 된다.

4. 함수(subroutine)

복잡한 프로그램을 만들때 우리는 해결할 문제를 체계적으로 나눈다. 그리고 필요할때마다 호출할 함수를 작성한다. 목록 3은 어셈블리어 프로그램의 함수 호출과 반환을 보여준다.

#목록 3
.globl main
main:
	movl $10, %eax
	call foo
	ret
foo:
	addl $5, %eax
	ret

call 명령어는 실행을 함수 foo로 옮긴다. foo의 ret 명령어는 실행을 다시 main의 호출 다음에 나오는 명령어로 옮긴다.

일반적으로 함수는 함수가 사용할 변수들을 정의한다. 이 변수들을 유지하려면 공간이 필요하다. 함수 호출시 변수값을 유지하기위해 스택을 사용한다. 프로그램 실행중에 반복되는 재귀호출시(recursive call) activation record가 유지되는 방법을 이해하는 것이 중요하다. esp나 ebp같은 레지스터 사용법과 스택을 다루는 push와 pop같은 명령어 사용법은 함수호출과 반환방식을 이해하는데 중요하다.

5. 스택 사용하기

프로그램의 메모리 일부를 스택으로 사용하기위해 비워두었다. Intel 80386 이상의 마이크로프로세서에는 스택 최상위 주소를 저장하는, 스택포인터(stack pointer)라는 esp 레지스터가 있다. 아래 그림 1은 스택에 저장된 세 정수값 49, 30, 72를 보여준다 (정수는 각각 4 바이트를 차지한다). esp 레지스터는 스택 최상위 주소를 저장한다.

그림 1

위로 쌓여가는 벽돌과 달리 Intel 컴퓨터의 스택은 아래방향으로 자란다. 그림 2는 명령어 pushl $15를 실행한후 스택을 보여준다.

그림 2

스택포인터 레지스터는 4만큼 감소하고, 숫자 15를 4 바이트(주소 1988, 1989, 1990, 1991)에 저장한다.

명령어 popl %eax는 스택 최상위에 있는 값(4 바이트)을 eax 레지스터에 복사하고 esp를 4만큼 증가한다. 만약 스택 최상위에 있는 값을 레지스터에 복사하고 싶지 않다면? 명령어 addl $4, %esp를 실행하여 스택포인터만 증가하면 된다.

목록 3에서 명령어 call foo는 호출을 마친후 실행할 명령어의 주소를 스택에 넣고 foo로 분기한다. 함수는 ret에서 끝나고, 실행을 스택 최상위에서 가져온 주소에 있는 명령어로 옮긴다. 물론 스택 최상위에 유효한 반환주소가 있어야 한다.

6. 지역변수(local variable) 공간 할당하기

C 프로그램은 수백 수천개의 변수를 다룰 수 있다. C 프로그램에 해당하는 어셈블리코드는 어떻게 변수를 저장하며 변수를 다루기위해 레지스터를 충돌없이 사용하는지 알려준다.

레지스터 개수가 적기때문에 프로그램의 모든 변수를 레지스터에 담을 수는 없다. 지역변수는 스택에 위치한다. 목록 4가 그 방법을 보여준다.

#목록 4
.globl main
main:
	call foo
	ret
foo:
	pushl %ebp
	movl %esp, %ebp
	subl $4, %esp
	movl $10, -4(%ebp)
	movl %ebp, %esp
	popl %ebp
	ret

먼저 스택포인터의 값을 기준포인터 레지스터(base pointer register) ebp에 복사한다. 기준포인터는 스택의 다른 위치를 접근할때 사용할 고정된 기준점이다. foo를 호출한 코드에서도 ebp를 사용하므로, 값을 esp 값으로 대체하기 전에 스택에 복사한다. 명령어 subl $4, %esp는 스택포인터를 감소하여 정수를 담기위한 (4 바이트) 공간을 만든다. 다음 줄은 값 10을 ebp에서 4를 뺀 (4 바이트) 주소에 복사한다. 명령어 movl %ebp, %esp는 스택포인터를 foo 시작시 가졌던 값으로 되돌리고, popl %ebp는 기준포인터 레지스터의 값을 되돌린다. 스택포인터는 이제 foo를 시작하기 전과 같은 값을 가진다. 아래 표는 main 시작과 목록 4의 (main에서 반환을 제외한) 각 명령어 실행후 레지스터 ebpesp와 3988에서 3999까지 스택 주소의 내용이다. 우리는 main의 첫 명령어 실행전에 ebp는 값 7000, esp는 값 4000을 가지며, 스택 주소 3988에서 3999까지 임의의 값 219986, 1265789, 86이 저장되있다고 가정한다. 또, main에서 call foo 다음에 나오는 명령어의 주소가 30000이라고 가정한다.

표 1

6. 파라미터 전달과 값 반환

함수로 파라미터를 전달하기위해 스택을 사용할 수 있다. 우리는 함수가 eax 레지스터에 저장한 값이 함수의 반환값이라는 (우리가 사용하는 C 컴파일러의) 규칙을 따른다. 함수를 호출하는 프로그램은 스택에 값을 넣어서 함수에게 파라미터를 전달한다. 목록 5는 sqr이라는 간단한 함수로 이를 설명한다.

#목록 5
.globl main
main:
	movl $12, %ebx
	pushl %ebx
	call sqr
	addl $4, %esp       //esp를 push 이전 값으로 조정
	ret
sqr:
	movl 4(%esp), %eax
	imull %eax, %eax    //eax * eax를 계산하여, 결과를 eax에 저장
	ret

sqr의 첫번째 줄을 주의있게 살펴라. 함수를 부르는 측은 ebx의 내용을 스택에 넣고 명령어 call을 실행한다. 호출시 반환주소를 스택에 넣는다. 그리고 sqr는 스택 최상위에서 4 바이트 떨어진 곳에서 파라미터를 읽을 수 있다.

8. C와 어셈블러 섞기

목록 6은 C 프로그램과 어셈블리어 함수를 보여준다. 파일 main.c에 C 함수가 있고 sqr.s에 어셈블리어 함수가 있다. cc main.c sqr.s를 입력하여 파일들을 컴파일하고 같이 링크한다.

반대도 매우 간단하다. 목록 7은 C 함수 print와 이 함수를 호출하는 어셈블리어를 보여준다.

#목록 6
//main.c
main()
{
	int i = sqr(11);
	printf("%d\n",i);
}

//sqr.s
.globl sqr
sqr:
	movl 4(%esp), %eax
	imull %eax, %eax
	ret

#목록 7
//print.c
print(int i)
{
	printf("%d\n",i);
}

//main.s
.globl main
main:
	movl $123, %eax
	pushl %eax
	call print
	addl $4, %esp
	ret

9. GNU C가 만드는 어셈블러 출력

나는 이 글이 gcc가 만드는 어셈블러 출력을 이해하기에 충분하길 기대한다. 목록 8은 gcc -S add.c로 만든 파일 add.s를 보여준다. add.s를 편집하여 많은 (대부분 정렬(alignment) 등의 목적의) 어셈블러 지서어를 삭제하였음을 밝힌다.

#목록 8
//add.c
int add(int i,int j)
{
	int p = i + j;
	return p;
}

//add.s
.globl add
add:
	pushl %ebp
	movl %esp, %ebp
	subl $4, %esp		//정수 p의 공간 생성
	movl 8(%ebp),%edx	//8(%ebp)는 i를 지칭
	addl 12(%ebp), %edx	//12(%ebp)는 j를 지칭
	movl %edx, -4(%ebp)	//-4(%ebp)는 p를 지칭
	movl -4(%ebp), %eax	//반환값을 eax에 저장
	leave			//즉, movl %ebp, %esp; popl %ebp ret

이 프로그램은 C 문장 add(10,20)이 다음과 같은 어셈블러코드로 변환됨을 확인하면 명확해진다:

pushl $20
pushl $10
call add

두번째 파라미터를 먼저 넣는 것을 주목하라.

10. 전역변수(global variable)

지역변수의 공간은 스텍포인터를 감소하여 스택에 확보하고, 단순히 스택포인터를 늘려서 할당된 공간을 되돌린다. 그러면 GNU C가 전역변수에 대해서는 어떤 코드를 생성할까? 목록 9가 해답을 준다.

#목록 9
//glob.c
int foo = 10;
main()
{
	int p = foo;
}

//glob.s
.globl foo
foo:
	.long 10
.globl main
main:
	pushl %ebp
	movl %esp,%ebp
	subl $4,%esp
	movl foo,%eax
	movl %eax,-4(%ebp)
	leave
	ret

문장 foo: .long 10은 foo라는 4 바이트 덩어리를 정의하고, 이 덩어리를 10으로 초기화한다. 지시어.globl foo는 다른 파일에서도 foo를 접근할 수 있도록 한다. 이제 이것을 살펴보자. 문장 int foostatic int foo로 수정한다. 어셈블리코드가 어떻게 살펴봐라. 어셈블러 지시어 .globl이 빠진 것을 확인할 수 있다. (double, long, short, const 등) 다른 storage class에 대해서도 시도해보라.

11. 시스템호출(system call)

프로그램이 어셈블리로 수학 알고리즘만을 구현하지 않는다면, 입력을 받고, 출력하고, 종료하는 등 어떤 작업이 필요하다. 이를 위해 운영체제 서비스를 호출해야 한다. 사실 운영체제 서비스를 제외하고는 여러 운영체제간의 어셈블리어 프로그래밍이 매우 비슷하다.

리눅스에는 시스템호출을 하는 두가지 일반적인 방법이 있다: C 라이브러리 (libc) wrapper를 통하거나, 직접.

Libc wrapper는 시스템호출 규칙이 변경되는 경우 프로그램을 보호하고, 커널에 그런 시스템호출이 없는 경우 POSIX 호환 인터페이스를 제공하기위해 만들어졌다. 그러나, 유닉스 커널은 보통 거의 POSIX에 호환한다: 즉 대부분의 libc "시스템콜"의 문법은 실제 커널 시스템호출의 문법과 (반대로도) 정확히 일치한다. 그러나 libc를 버리지않는 이유는 시스템콜 wrapper외에 printf(), malloc() 등 함수도 있기때문이다.

리눅스 시스템호출은 int 0x80을 통해 한다. 리눅스는 일반적인 유닉스 호출 규칙과 다른 "fastcall" 규칙을 사용한다. 시스템함수 번호는 eax에, 아규먼트는 스택이 아닌 레지스터를 통해 전달한다. 따라서 ebx, ecx, edx, esi, edi, ebp에 아규먼트 6개까지 가능하다. 아규먼트가 더 있다면 간단히 구조체를 첫번째 아규먼트로 넘긴다. 결과는 eax로 반환하고, 스택을 전혀 건드리지 않는다.

아래 목록 10을 살펴보자.

#목록 10
#fork.c
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>

int main()
{
	fork();
	printf("Hello\n");
	return 0;
}

명령어 cc -g fork.c -static으로 프로그램을 컴파일한다. gdb 도구에서 명령어 disassemble fork를 입력한다. fork에 해당하는 어셈블리코드를 볼 수 있다. -static은 GCC의 정적 링커 옵션이다 (manpage 참고). 다른 시스템호출도 테스트해보고 실제 어떻게 함수가 동작하는지 살펴봐라.

리눅스 시스템호출에 대한 최신 문서가 많아서 여기에 반복하지 않겠다.

11. 인라인 어셈블리 프로그래밍

GNU C는 x86 아키텍쳐를 매우 잘 지원하며, C 프로그램에 어셈블리코드를 삽입할 수 있다. 레지스터 할당은 직접 지시하거나 GCC에 맡겨둘 수 있다. 물론, 어셈블리 명령어는 아키텍쳐마다 다르다.

asm 명령어를 사용하여 어셈블리 명령어를 C나 C++ 프로그램에 삽입할 수 있다. 예를 들어:

asm ("fsin" : "=t" (answer) : "0" (angle));

는 다음 C 문장을 x86 식으로 코딩한 것이다:

answer = sin(angle);

일반적인 어셈블리코드 명령어와 달리 asm 문장은 C 문법으로 입력과 출력 연산수를 지정할 수 있다.Asm 문장은 아무때나 사용하면 안된다. 그러면 언제 사용해야 하나?

  • Asm 문장은 프로그램이 컴퓨터 하드웨어에 직접 접근하게 한다. 그래서 빨리 실행되는 프로그램을 만들 수 있다. 하드웨어와 직접 상호작용하는 운영체제 코드를 작성할때 사용할 수 있다. 예를 들어, /usr/include/asm/io.h에는 입출력 포트를 직접 접근하기위한 어셈블리 명령어가 있다.
  • 또, 인라인 어셈블리 명령어는 프로그램의 가장 안쪽 반복문의 속도를 빠르게한다. 예를 들어, 어떤 같은 각도에 대한 sine과 cosine은 fsincos x86 명령어로 얻을 수 있다. 아마도 아래 두 목록은 이 점을 잘 이해하도록 도와줄 것이다.
#목록 11
#이름 : bit-pos-loop.c 
#설명 : 반복문을 사용하여 비트 위치 찾기

#include <stdio.h>
#include <stdlib.h>

int main (int argc, char *argv[])
{
	long max = atoi (argv[1]);
	long number;
	long i;
	unsigned position;
	volatile unsigned result;

	for (number = 1; number <= max; ; ++number) {
		for (i=(number>>1), position=0; i!=0; ++position)
			i >>= 1;
		result = position;
	}
	return 0;
}

#목록 12
#이름 : bit-pos-asm.c
#설명 : bsrl을 사용하여 비트 위치 찾기

#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[])
{
	long max = atoi(argv[1]);
	long number;
	unsigned position;
	volatile unsigned result;

	for (number = 1; number <= max; ; ++number) {
		asm("bsrl %1, %0" : "=r" (position) : "r" (number));
		result = position;
	}
	return 0;
}

다음과 같이 최상의 최적화로 두 코드를 컴파일한다:

$ cc -O2 -o bit-pos-loop bit-pos-loop.c
$ cc -O2 -o bit-pos-asm bit-pos-asm.c

최소한 몇 초동안 실행되도록 큰 값을 명령행 아규먼트로 주고 time 명령어를 사용하여 두 코드의 실행시간을 잰다.

$ time ./bit-pos-loop 250000000

and

$ time ./bit-pos-asm 250000000

결과는 컴퓨터마다 다를 것이다. 그러나 인라인 어셈블리를 사용한 코드가 매우 빠르게 실행됨을 확인할 수 있다.

GCC의 최적화는 asm 표현이 있더라도 실행시간을 최소화하기위해 프로그램 코드를 재배열하고 재작성하려고 시도한다. asm의 출력값을 사용하지 않는다고 판단하면, asm과 아규먼트 사이에 키워드volatile이 없는 한 최적화는 명령어를 생략한다. (특별한 경우로 GCC는 출력 연산수가 없는 asm을 반복문 밖으로 옮기지 않는다.) asm은 예측하기 힘든 방식으로, 심지어 호출간에도, 옮겨질 수 있다. 특별한 어셈블리 명령어 순서를 보장하는 유일한 방법은 모든 명령어를 모두 같은 asm에 포함하는 것이다.

컴파일러가 asm의 의미를 모르기때문에 asm을 사용하면 컴파일러의 효율성을 제한할 수 있다. GCC는 어떤 최적화를 막을 수 있는 보수적인 추측을 하게 된다.

12. 연습문제

  1. 목록 6의 C 프로그램에 대한 어셈블리코드를 해석하라. 어셈블리코드를 생성할때 -Wall 옵션이 출력하는 오류가 없도록 수정하라. 두 어셈블리코드를 비교하라. 어떤 차이가 있는가?
  2. 여러 작은 C 프로그램을 (-O2 같은) 최적화 옵션을 주고 또 안주고 컴파일해보라. 결과 어셈블리코드를 읽고 컴파일러가 사용한 공통된 최적화 기법을 찾아라.
  3. switch 문장에 대한 어셈블리코드를 해석하라.
  4. 인라인 asm 문장이 있는 여러 작은 C 프로그램을 컴파일해보라. 이런 프로그램의 어셈블리코드에는 무슨 차이가 있는가?
  5. 감싸인 함수(nested function)는 다른 함수 ("감싸는 함수(enclosing function") 안에서 정의되며, 다음과 같다:
    • 감싸인 함수는 감싸는 함수의 변수에 접근할 수 있고,
    • 감싸인 함수는 감싸는 함수에 지역적이다(local). 즉, 감싸는 함수가 감싸인 함수의 포인터를 제공하지 않는다면 감싸는 함수 밖에서 감싸인 함수를 호출할 수 없다.

    감싸인 함수는 함수의 범위(visibility)를 조절하기때문에 유용하게 사용할 수 있다.

    아래 목록 13을 참고하라:

    #목록 13 /* myprint.c */ #include <stdio.h> #include <stdlib.h> int main() { int i; void my_print(int k) { printf("%d\n",k); } scanf("%d",&i); my_print(i); return 0; }

    이 프로그램을 cc -S myprint.c로 컴파일하고 어셈블리코드를 해석하라. 또, 명령어 cc -pedantic myprint.c로 프로그램을 컴파일해보라. 무엇이 보이는가? 


조인씨 어셈블리 기초다지기 http://www.joinc.co.kr/modules/moniwiki/wiki.php/Site/Assembly/Documents/Assembly_Basic

이재범님의 PC Assembly 번역 소개, PDF / PC Assembly 원본

철학이 있는 곳 - 어셈러브 http://www.asmlove.co.kr/blog/textyle/133

8086 에뮬레이터 http://ziplib.com/emu8086/


 


출처 : http://echosf.net/lecture/

Intel® 64 and IA-32 Architectures Software Developer's Manuals

출처 : http://reznoa.wo.tc/blog/118


Linux Assembly Code


글쓴이 : 이호 (i@flyduck.com)
최신 글이 있는 곳 : http://linux.flyduck.com/

v0.1.0 2000년 3월 28일


차례
0. 서문
1. GAS와 AT&T 문법
2. Inline Assembly
3. Reference



0. 서문
이 문서는 리눅스에서 사용하는 어셈블리 문법에 대해서 (특히 x86에서) 간략히 요약한 글입니다. GAS와 AT&T 문법에서는 어셈블리 코드의 형식과 이것이 인텔에서 사용하는 문법과 어떤 차이가 있는지를 나타냅니다. 이 부분은 인텔에서 사용하는 어셈블리(Macro Assembler나 Turbo Assembler)를 알고 있다면 많은 도움이 될 것입니다. Inline Assembly는 C 코드내에서 어셈블리 코드를 사용하는 방법에 대한 글입니다. 커널 코드에서 CPU에 의존적인 부분들의 상당수는 inline 어셈블리 코드로 작성되어 있는데, 이 형식에 낯선 사람들이 이를 이해하는데 도움이 되리라 생각합니다. 이 글은 x86 어셈블리 코드에 대해 기본적인 지식이 있다고 가정하고 있습니다.
이 문서는 Linux Assembly HOWTO 문서와 Brennen's Guide to Inline Assembly, DJGPP QuickASM Programming Guide, GCC Manual, GAS Manual에 있는 내용을 요약 정리한 것입니다. 그대로 사용한 코드들도 많아서 어떤 면에서는 정리했다기 보다는 그냥 옮겼다고 해도 무방할 듯 합니다 (^^;). 여기서 다루는 내용외에 더 자세한 것을 바란다면 마지막 장 Reference에 나오는 문서들을 보시기 바랍니다. 특히 x86 Assembly Language FAQ 문서는 어셈블리에 대해서 낯선 분에게도 큰 도움이 될 것입니다. 실제 CPU 명령어에 대해서는 각 CPU 제조회사에서 제공하는 매뉴얼을 참조하시기 바랍니다. 그럼 리눅스를 공부하시는 분께 도움이 되길 바랍니다.




1. GAS와 AT&T 문법
GAS는 GNU Assembler로서 GCC와 함께 쌍으로 사용되는 Assembler이다. 이는 32-bit UNIX Compiler를 위해 만들어졌으므로, UNIX에서 일반적으로 사용되는 AT&T 문법을 따른다. 이 문법은 Intel에서 사용하는 문법과는 많이 다르다. 이를 비교해보면 :

Register 이름 : 모든 레지스터 이름에는 %가 앞에 붙는다. 이는 레지스터와 다른 심볼들을 혼동하지 않게 하는 장점이 있다. 예를 들어 eax 레지스터를 나타낼 때 :
AT&T : %eax
Intel : eax


참고로 Intel 80386에는 다음과 같은 레지스터가 있다 :



일반 register : %eax (%ax, %ah, %al), %ebx (%bx, %bh, %bl), %ecx (%cx, %ch, %cl), %edx (%dx, %dh, %dl), %esi (%si), %edi (%di), %ebp (%bp), %esp (%sp) : 32-bit, 16-bit, 8-bit 레지스터로 사용가능
section register : %cs, %ds, %es, %fs, %gs, %ss
processor control register : %cr0, %cr1, %cr3
debug register : %db0, %db1, %db2, %db3, %db6, %db7
test register : %tr6, %tr7
floating point register stack : %st => %st(0), %st(1), %st(2), %st(3), %st(4), %st(5), %st(6), %st(7)

Operand 순서 : 원본(source)이 왼쪽, 목적지(destination)가 오른쪽에 위치한다. Intel 문법에서는 이와 반대로 되어 있다. 예를 들어 eax에서 ebx 레지스터로 데이터를 복사하는 것은 :
AT&T : movl %eax, %ebx
Intel : mov ebx, eax



Operand 크기지정 : operand의 크기를 지정할 때 크기에 따라 b (byte), w (word), l (long) 접미사를 명령어에 붙인다. operand로 지정된 레지스터를 가지고 크기를 판단할 수 있을 때는 이를 생략할 수 있으며, 판단할 수 없는 경우 32-bit 연산으로 가정하게 된다. operand가 레지스터가 아니라 메모리인 경우는 반드시 접미사를 붙이는게 좋다. Intel에서는 byte ptr, word ptr, dword ptr 같은 지시자(specifier)를 사용하여 이를 나타낸다. 예를 들어 word크기의 foo 값을 bx로 복사하는 것은 :
AT&T : movw foo, %ax
Intel : mov ax, word ptr foo


AT&T와 Intel에서 사용하는 명령어들은 대부분 비슷하지만 몇가지 다른게 있다. sign extend 명령어와 zero extend 명령어는 약간의 차이를 보이는데, AT&T 문법에서는 이러한 extend 명령어에 원래의 크기와 확장할 크기를 지정하게 한다 :

movsSD (sign extend, Intel에서는 movsx)
movzSD (zero extend, Intel에서는 movzx)


여기서 S는 원래의 크기 D는 목적하는 크기이다. 예를 들어 ax를 ecx로 sign extend를 한다면 :

AT&T : movswl %ax, %ecx
Intel : movsx ecx, ax


또한 몇가지 변환함수에도 차이가 있다 :

cbtw (sign extend byte (%al) to word (%ax), Intel에서는 cbw)
cwtl (sign extend word (%ax) to long (%eax), Intel에서는 cwde)
cwtd (sign extend word (%ax) to long (%dx:%ax), Intel에서는 cwd)
cltd (sign extend dword (%eax) to quad (%edx:%eax), Intel에서는 cdq)



상수(constant)와 immediate 값 : 모든 상수와 immediate 값에는 $가 붙는다. 예를 들어 숫자 5를 나타내려면 $5라고 한다. 변수의 주소를 나타낼 때에도 앞에 $를 붙인다. 예를 들어 foo란 변수의 주소는 $foo이다. 그냥 foo라고 하면 변수의 값을 나타내게 된다. foo 변수의 주소를 eax 레지스터로 복사하는 것은 :
AT&T : movl $foo, %eax
Intel : mov eax, foo


foo의 값을 eax 레지스터로 복사하는 것은 :

AT&T : movl foo, %eax
Intel : mov eax, [foo]


숫자 0xd00d를 eax 레지스터로 복사하는 것은 :

AT&T : movl $0xd00d, %eax
Intel : mov eax, 0d000h



메모리 참조 : AT&T와 Intel에서 메모리 주소를 참조하는 방법(indexing, indirection)에는 표기법상 약간의 차이가 있다.
AT&T : section:immed32(base, index, scale)
Intel : sectioin:[base + index * scale + immed32]


이는 base + index * scale + immed32 주소를 나타내게 된다. 이들 모두를 반드시 지정해야 하는 것은 아니지만 immed32나 base 중의 하나는 반드시 지정해야 한다. 주소 지정의 예를 들어보자. eax 레지스터가 가리키는 주소의 값을 참조하는 경우 :

AT&T : (%eax)
Intel : [eax]


레지스터에 변수(var)의 옵셋을 합한 주소를 참조하는 경우 :

AT&T : var(%eax)
Intel : [eax + var]


4바이트 단위로 된 정수 배열(int array[])의 eax번째 값을 참조하는 경우 (int array[eax]) :

AT&T : array(, %eax, 4)
Intel : [eax * 4 + array]


위 배열에서 ebx 인덱스에서 시작하여 eax번째 값을 참조하는 경우 (int array[ebx + eax]) :

AT&T : array(%ebx, %eax, 4)
Intel : [ebx + eax * 4 + array]



jump/call/return : long jump나 long call에서는 다음과 같은 차이가 있다 :
AT&T : ljmp/lcall $section, $offset
Intel : jmp/call far section:offset


far retrun의 경우 :

AT&T : lret $stack-adjust
Intel : ret far stack-adjust



그밖에 : AT&T Assembler에서는 여러개의 section을 지원하지 않는다. UNIX 프로그램에서는 모든 프로그램이 하나의 section에 있다고 생각을 한다.



2. Inline Assembly
inline assembly는 high-level 언어로 된 코드 중간에 넣어서 사용하는 어셈블리 코드로, 네가지 항목으로 구성되며, 다음과 같은 형식으로 사용한다.
__asm__(어셈블리 문장 : 출력 : 입력 : 변경된 레지스터);

각 항목은 콜론(':')으로 구분되며, 어셈블리 문장은 반드시 들어가야 하지만, 뒤의 세 항목은 필요에 따라서 넣거나 생략할 수 있다. 각 항목은 다음과 같은 의미를 가진다.


어셈블리 문장 (assembly statement) : AT&T 형식으로 만들어진 어셈블리 코드로 "" 안에 넣어서 작성하며 각각의 줄은 newline으로 구분된다.
출력 (output) : 어셈블리 코드에서 출력용으로 사용하는 레지스터/메모리를 변수와 연결시켜준다. 여러개를 지정할 수 있으며 각 항목은 쉼표(',')로 구분된다. 각 항목은 "=g"(var)같은 형식을 가진다.
입력 (input) : 어셈블리 코드에서 입력으로 사용하는 레지스터/메모리를 변수와 연결시켜준다. 여러개를 지정할 수 있으며 각 항목은 쉼표(',')로 구분된다. 각 항목은 "g"(var)같은 형식을 가진다.
변경된 레지스터 (registers-modified 또는clobbered regiser) : 어셈블리 코드에서 컴파일러가 모르는 사이에 바뀔 수 있는 레지스터의 목록을 기술한다. 각 항목은 "" 안에 들어가며, 여러개의 항목을 넣을 때에는 쉼표(',')로 구분한다. 메모리에 있는 변수의 값을 수정하는 경우 "memory"라고 기술해주어야 한다.
예제 코드를 보면 :

__asm__ ("pushl %eaxn"
"movl $1, %eaxn "
"popl %eax"
);

이 코드는 eax 레지스터를 저장하고 여기에 1을 입력했다가 eax 레지스터를 원래의 값으로 복구하는 코드이다. 여기서는 아무런 입력이나 출력이 없으며, 변경되는 레지스터도 없으므로 어셈블리 코드만 존재한다. 이제 i라는 변수를 하나 증가시키는 코드를 만들어보자.


int i = 0;

__asm__ ("pushl %%eaxn"
"movl %0, %%eaxn"
"addl $1, %%eaxn"
"movl %%eax, %0n"
"popl %%eax"
: /* no output variable */
: "g" (i)
);

우선 이 코드에서 모든 레지스터 앞에 %가 두개가 붙어있는데, 입력이나 출력, 변경된 레지스터 중의 하나라도 기술을 하는 경우, 레지스터 이름에는 %를 하나가 아니라 두개를 붙여야 한다. 이는 내부에서 %0, %1 하는 식의 기호가 사용되는데 이것과 혼동되는 것을 막기 위해서이다. 이 코드에서는 출력이 없으므로 출력은 비워 두었다. 입력에는 "g"(i)라고 적혀 있는데, 이는 i라는 변수를 %0과 연결시켜주는 역할을 한다. 즉 코드내에서 %0은 변수 i와 같은 의미로 사용된다. 따옴표 안에 있는 것은 변수와 어떤것이 연결되는지를 말하는데 g는 이경우 컴파일러가 알아서 레지스터에 넣던지 메모리에 두던지 하라고 지시하는 것이다. 따옴표 안에는 다음과 같은 것을 지정할 수 있다.


a eax
b ebx
c ecx
d edx
S esi
D edi
I 상수 (0에서 31) ("I"라고 사용하는게 아니라 "0" 처럼 숫자를 넣어서 사용)
q eax, ebx, ecx, edx 중 동적으로 할당된 레지스터
r eax, ebx, ecx, edx, esi, edi 중 동적으로 할당된 레지스터
g eax, ebx, ecx, edx 또는 메모리에 있는 변수. 컴파일러가 선택
A eax 와 edx를 결합한 64-bit 정수

%0은 입력에서 지정한 변수를 가리킨다. 즉 여기서는 i라는 변수를 가리키게 된다. 입력에서 여러개를 기술하면, 기술한 순서대로 차례로 %0, %1, ... 의 이름을 갖게 된다.


int x = 1, x_times_5;

__asm__ ("leal (%1, %1, 4), %0"
: "=r" (x_times_5)
: "r" (x)
);

위 코드는 x라는 변수를 다섯배 곱하여 x_times_5에 저장한다. ((%1, %1, 4) = %1 + %1 * 4 = %1 * 5, lea는 주소를 저장하라는 명령이므로 %0에 %1을 다섯배한 값이 들어가게 된다). 여기서는 결과를 저장해야 하므로 출력에 "=r"(x_times_5)라고 출력되는 변수를 지정하였다. 따옴표안에 =가 들어가는 것은 출력임을 나타내기 위해서이다. 이 코드를 조금 수정하여 x를 다섯배 곱하여 x에 이 값을 넣는다면 :


__asm__ ("leal (%1,%1,4), %0"
: "=r" (x)
: "0" (x)
);

여기서 입력에 "0"이라고 숫자로 썼는데, 이는 앞에서 지시한 것을 다시 가리키는 경우이다. 순서에 따라 출력 "=r"은 %0, 입력 "0"은 %1이 되는데, 이 둘을 같은 것을 가리키게 하고 싶은 경우 "0"이라고 하여 %0과 같은 것이라고 지시해주는 것이다. 즉 여기서 %1은 %0과 같은 것이 된다. 그 래서 이 코드는 x를 다섯배를 곱하여 결과를 자기 자신에서 돌려주게 된다. 입출력을 같이 하는 예로 k = i + j를 예로 들면 :


int i = 1, j = 2, k;

__asm__ __volatile__ ("pushl %%eaxn"
"movl %1, %%eaxn"
"addl %2, %%eaxn"
"movl %%eax, %0n"
"popl %%eax"
: "=g" (k)
: "g" (i), "g" (j)
);

순서에 따라 k = %0, i = %1, j = %2가 되고, %1 + %2를 %0에 저장하여 k = i + j 값이 들어가게 된다. 여기서 __asm__ 다음에 __volatile__가 있는데, 이는 이 코드를 지정한 위치에 그대로 두라는 것이다. 컴파일러는 최적화(optimization)를 하는 과정에서 코드의 위치를 옮길 수 있는데 이를 막는 것이다.


#define rep_movsl(src, dest, numwords)
__asm__ __volatile__ (
"cldn"
"repn"
"movsl"
:
: "S" (src), "D" (dest), "c" (numwords)
: "%ecx", "%esi", "%edi"
);

위 코드는 src에서 dest로 지정한 길이만큼 복사하는 것이다. 이 코드를 실행하면 edx, esi, edi 레지스터가 변경되게 되므로, 마지막에 변경된 레지스터 목록에 이 세개를 지정해주었다.




3. Reference
Linux Assembly HOWTO :

영문판 : http://linuxdoc.org/HOWTO/Assembly-HOWTO.html
한글판 : http://kldp.org/HOWTO/Assembly-HOWTO
Brennen's Guide to Inline Assembly :

http://www.delorie.com/djgpp/doc/brennan/brennan_att_inline_djgpp.html
DJGPP QuickASM Programming Guide :

http://www.castle.net/~avly/djasm.html
x86 Assembly Language FAQ :

http://www.faqs.org/faqs/assembly-language/x86/
GCC Manual (GCC Inline Assembly) :

http://gcc.gnu.org/onlinedocs/gcc_toc.html
GAS Manual :

http://www.gnu.org/manual/gas-2.9.1/as.html

http://www.ibm.com/developerworks/kr/library/l-gas-nasm.html
리눅스 어셈블러 : GAS 와  NASM 비교(한글)

+ Recent posts