지난번에는 간단한 C언어 예제를 objconv 및 nasm/yasm을 통해서 x86 어셈블리어로 변환하는 방식을 살펴본 바 있다. objconv 프로그램은 상당히 완성도가 높았으며, MASM 혹은 인텔 어셈블리어 문법에 익숙한 사용자에게 쓸만한 고급언어 -> x86 어셈블리어 변환 용도로 활용될 수 있을 것이고, 어셈블리어를 최적화하는 용도 혹은 기타 어셈블리어를 배우는 등의 활용을 할 수 있다는 점을 살펴 보았다. 이번에는 인라인 어셈블리어에 대해 살펴보려고 한다.

상당히 많은 수의 유명 라이브러리는 고급 언어로 작성되어있지만, 게중에 일부는 특정 CPU에 더욱 최적화되어 있는데, 최적화된 부분은 보통 어셈블리어를 통해 이루어지고 있다. 프로그램 소스 전체가 모두 어셈블리어로 작성된 경우는 거의 찾아보기 어렵고 보통은 해당 라이브러리의 일정 부분이 어셈블리어를 통해 최적화 된 형태이다. 또한 어셈블리어가 별도의 소스로 완전히 분리되어 있는 경우도 있지만, C언어(혹은 다른 고급 언어)와 어셈블리어를 섞어서 사용하는 경우가 상당수인데, C 소스에 어셈블리어를 섞어서 사용하는 기법을 인라인 어셈블리(Inline Assembly)라고 부른다.

gcc는 내장된 GAS(GNU Assembler) 어셈블러를 가지며, 이것이 기본으로 지원하는 어셈블리어는 다음과 같은 AT&T 스타일의 어셈블리어이다.

mov $0x05, %eax # 주석

이것은 16진수 0x5값을 eax 레지스터에 넣으라는 명령이다. 이 명령에 대응하는 인텔 어셈블리어 스타일은 다음과 같다.

mov eax, 0x05

인텔 스타일과 AT&T 스타일을 비교해보면, 이 경우에는 명령코드의 소스와 대상의 위치가 바뀌고, 레지스터를 구분하는 prefix가 %, 숫자를 구분시켜주는 prefix로 $를 붙이는 것이 다를 뿐 거의 다를바가 없다. 최신 gcc의 경우에는 인텔 스타일 및 AT&T 스타일 모두 사용하는 것이 가능하나, 전통적으로(?) AT&T 스타일로 인라인 에셈블리어를 사용한다. 이미 x86 어셈블리어에 익숙한 사용자라면 조금 헷갈릴 수는 있으나, 이하 예제에서 나올 인라인 에셈블리 스타일은 모두 AT&T 스타일임에 유의하자.)

첫번째 예제

이제 다음의 아주 간단한 인라인 어셈블러 예제를 살펴보자.

int main() {
        int dst = 0;
        __asm__ volatile (
                "mov $100, %%eax\n\t"
                "add $10, %%eax\n\t"
                "mov %%eax, %0\n\t"
                : "=m" (dst)
        );
        return dst;
}

소스를 보면 대충 예측할 수 있을 정도로 간단하지만, 이 간단한 인라인 어셈블리 프로그램이 하는 것은 다음과 같다.

1) 숫자 100을 %eax 레지스터에 넣고, 2) 숫자 10을 %eax 레지스터에 더하여 얻어진 것을 0번째 출력 인자 dst에 넣는다. 그러므로 dst값은 110이 될 것이고 main() 함수는 110을 리턴하게 된다. 위의 경우 어셈블리어를 세줄로 나누어 보기 좋게 하였으나, 다음과 같이 한줄로 넣는 것도 물론 가능하다.

"mov $100, %%eax\n\tadd $10, %%eax\n\tmov %%eax, %0\n\t"

그러나 이렇게 소스를 만들면 조금 보기 불편하므로 여러줄로 보통 나누어 쓴다. 또한 각 줄은 "\n\t" 문자 즉, 줄넘김(\n) 문자를 필수적으로 넣어주어야 하고, 여기에 탭(\t) 문자를 추가적으로 넣어주어서 gcc -S 옵션으로 컴파일 할 경우 얻을 수 있는 어셈블리어 소스에 들여쓰기가 되도록 해준다.

위 소스코드 예제를 test.c라는 이름으로 저장하고 이를 다음과 같은 명령으로 컴파일을 해서 어셈블러 소스코드 test.s를 얻어보면 다음과 같다. (실행 환경 우분투 16.04.4 / gcc 버전 5.4.0)
$ gcc -O2 -m32 -S test.c

        .file   "test.c"
        .section        .text.unlikely,"ax",@progbits
.LCOLDB0:
        .section        .text.startup,"ax",@progbits
.LHOTB0:
        .p2align 4,,15
        .globl  main
        .type   main, @function
main:
.LFB0:
        .cfi_startproc
        leal    4(%esp), %ecx
        .cfi_def_cfa 1, 0
        andl    $-16, %esp
        pushl   -4(%ecx)
        pushl   %ebp
        .cfi_escape 0x10,0x5,0x2,0x75,0
        movl    %esp, %ebp
        pushl   %ecx
        .cfi_escape 0xf,0x3,0x75,0x7c,0x6
        subl    $20, %esp
        movl    %gs:20, %eax
        movl    %eax, -12(%ebp)
        xorl    %eax, %eax
#APP
# 3 "test.c" 1
        mov $100, %eax
        add $10, %eax
        mov %eax, -16(%ebp)

# 0 "" 2
#NO_APP
        movl    -12(%ebp), %edx
        xorl    %gs:20, %edx
        movl    -16(%ebp), %eax
        jne     .L5
        addl    $20, %esp
        popl    %ecx
        .cfi_remember_state
        .cfi_restore 1
        .cfi_def_cfa 1, 0
        popl    %ebp
        .cfi_restore 5
        leal    -4(%ecx), %esp
        .cfi_def_cfa 4, 4
        ret
.L5:
        .cfi_restore_state
        call    __stack_chk_fail
        .cfi_endproc
.LFE0:
        .size   main, .-main
        .section        .text.unlikely
.LCOLDE0:
        .section        .text.startup
.LHOTE0:
        .ident  "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.4) 5.4.0 20160609"
        .section        .note.GNU-stack,"",@progbits

인라인 어셈블리 소스코드 예제는 10줄밖에 안되는데 어셈블리어로 변환시키니 58줄이나 되어버려 복잡해 보이는데, 잘 살펴보면 인라인 어셈블리어 부분은 다음과 같은 부분에 해당된다. 원본 인라인 어셈블리 소스가 3줄이었는데 이것이 gcc에 의해 변환해서 얻어진 어셈블리어 소스에서도 마찬가지로 3줄이다.

...(생략)
#APP
# 3 "test.c" 1
        mov $100, %eax
        add $10, %eax
        mov %eax, -16(%ebp)

# 0 "" 2
#NO_APP
...(이하 생략)

gcc 대신에 clang을 사용하면 어떤 결과가 나올까? clang으로 변환해서 얻어진 어셈블리어 소스는 다음과 같다. 출력 소스를 최대한 간단히 보기 위해서 -O2 옵션을 주어 보았다. (clang 3.8.0)

$ clang -O2 -m32 -S test.c
        .text
        .file   "test.c"
        .globl  main
        .align  16, 0x90
        .type   main,@function
main:                                   # @main
# BB#0:
        pushl   %eax
        movl    $0, (%esp)
        #APP
        movl    $100, %eax
        addl    $10, %eax
        movl    %eax, (%esp)

        #NO_APP
        movl    (%esp), %eax
        popl    %ecx
        retl
.Lfunc_end0:
        .size   main, .Lfunc_end0-main


        .ident  "clang version 3.8.0-2ubuntu4 (tags/RELEASE_380/final)"
        .section        ".note.GNU-stack","",@progbits

자세히 살펴보면 인라인 어셈블리 부분의 명령 코드가 조금 달라졌음도 엿볼 수 있는데, 원래의 명령 코드는 mov였는데 movl로 바뀌는 등의 차이점도 있음을 알 수 있다. (movl의 l suffix는 long (32비트) 오퍼랜드에 대응하는 mov OP임을 뜻한다. l 이외에 b, s, w, q, t등의 suffix가 올 수 있다. 좀 더 자세한 내용은 https://en.wikibooks.org/wiki/X86_Assembly/GAS_Syntax 문서를 참고할 수 있다.

아무튼 clang의 경우 그 결과물이 훨씬 간단하니 이에 간단한 설명을 달아보자면 다음과 같다.

.text .file "test.c" .globl main .align 16, 0x90 .type main,@function main: # @main # BB#0: pushl %eax # %eax 레지스터를 스택에 넣어 스택이 하나 쌓았다. 하나의 스택 공간 확보. movl $0, (%esp) # 스택 최상위 주소"(%esp)"에 0을 넣음. # 해당 스택위치 자리를 0으로 초기화 한 것은 지역 변수 dst를 0으로 초기화 한 것에 해당 #APP movl $100, %eax # %eax 레지스터에 100을 넣음. addl $10, %eax # %eax 레지스터에 10을 더함 movl %eax, (%esp) # %eax 레지스터의 내용을 "(%esp)" 스택 최상위 주소에 넣음. # 해당 위치는 0으로 초기화 되어있었음 #NO_APP movl (%esp), %eax # 스택 최상위 주소의 내용"(%esp)"을 %eax 레지스터에 넣음. popl %ecx # 최상위 스택을 비움. 하나 쌓여있던 스택을 버림. retl # main() 함수 리턴/종료. %eax 레지스터에 리턴값이 보존되어 있다. .Lfunc_end0: .size main, .Lfunc_end0-main .ident "clang version 3.8.0-2ubuntu4 (tags/RELEASE_380/final)" .section ".note.GNU-stack","",@progbits

여기에 조금 더 부연설명을 추가하자면, 메인 함수가 시작하면서 pushl %eax를 실행하여 스택을 하나 늘리고 해당 스택을 0으로 초기화 하고 있는데, 이는 "int dst = 0;" 소스가 어셈블리어로 변환된 것에 해당한다. 즉, c 소스 main()함수에서 지정된 지역 저장소 "int dst"를 위해 스택을 하나 늘리고 이것을 0으로 초기화 한 것이다.
그런 다음에 이어지는 인라인 어셈블리 부분이 실행되고 나면 해당 지역 저장소 dst의 위치인 스택 값이 %eax 레지스터에 옮겨서 저장되는데, 메인함수 main()이 리턴하는 값은 %eax 레지스터에 최종적으로 전달된다.

(스택은 함수의 인자 전달 및 지역 변수의 용도 등등으로 활용되는데, push를 하면 스택이 쌓이며, 스택 레지스터 esp는 감소를 하게 되며, ebp 주소를 기준으로 [ebp + offset] 형태의 주소로 스택을 지정하여 사용하게 된다. 초보자는 조금 헷갈릴 수도 있지만 스택이 쌓이는 방향에 유의해야 하는데, 메모리 상위 주소가 아래쪽인 경우 스택이 위쪽으로 쌓이고, 상위 주소가 위쪽이면 스택은 아래 방향으로 쌓인다.)

얻어진 어셈블리어 소스를 gcc 명령으로 컴파일해서 실행파일을 얻을 수도 있고, 이러한 과정을 거치지 않고 곧바로 실행을 해도 마찬가지 결과를 얻게 된다.

$ clang -m32 -O2 test.c
$ ./a.out ; echo $?
110
$
혹은
$ clang -m32 -O2 test.s
$ ./a.out ; echo $?
110
$

다시 처음으로 돌아가서 test.c 소스를 다음과 같이 조금 바꿔보자.

int main() {
        int dst = 0;
        __asm__ volatile (
                "mov $100, %%eax\n\t"
                "add $10, %%eax\n\t"
                "mov %%eax, %0\n\t"
                : "=r" (dst)
        );
        return dst;
}

즉, 출력을 지정하던 부분이 원래는 "=m" (dst) 였던 것을 "=r" (dst)로 바꾼 것인데, 여기서 "="는 출력을 뜻하고 "=m"는 메모리 유형의 출력을, "=r"은 레지스터 유형의 출력을 의미한다. 즉, 출력 유형이 메모리였던 것을 레지스터 유형으로 제한(Constraint)을 바꾼 것이다.
새로 바꾼 소스를 clang으로 다시 컴파일해서 얻은 어셈블러 소스는 다음과 같다. (소스파일 이름을 2.c로 저장해 clang -O2 -m32 -S 2.c 명령으로 컴파일 하였다.)

        .text
        .file   "2.c"
        .globl  main
        .align  16, 0x90
        .type   main,@function
main:                                   # @main
# BB#0:
        #APP
        movl    $100, %eax
        addl    $10, %eax
        movl    %eax, %eax

        #NO_APP
        retl
.Lfunc_end0:
        .size   main, .Lfunc_end0-main


        .ident  "clang version 3.8.0-2ubuntu4 (tags/RELEASE_380/final)"
        .section        ".note.GNU-stack","",@progbits

와우! 어셈블리 소스가 매우 간단해졌다. 추가적으로 들어간 어셈블리어 소스가 전혀 없이 인라인 어셈블리 소스 단 3줄만 남아있다. 이 경우에는 gcc로 컴파일을 하더라도 대동소이한 결과물을 얻을 수 있었는데, 아래와 같았다. (add가 addl로 변환되는 등의 약간의 차이점이 있으나 이는 gcc/clang 컴파일러가 동일하게 해석한다.) gcc -O2 -m32 -S 2.c

...(생략)
        .globl  main
        .type   main, @function
main:
.LFB0:
        .cfi_startproc
#APP
# 3 "2.c" 1
        mov $100, %eax
        add $10, %eax
        mov %eax, %eax

# 0 "" 2
#NO_APP
        ret
...(생략)

인자의 유형 지정

그러면 이번에도 간단한 다른 예제를 살펴보자.

int main() {
    int src = 3;
    int dst = 5;
    __asm__ volatile (
        "imul %1, %0"
        : "=r" (dst) /* 출력 인자 지정 */
        : "r" (src) /* 입력 인자 지정 */
    );
    return dst;
}

이번에는 3과 5를 곱하고(imul 정수 곱셈 OP) 그 결과를 dst로 저장하려는 것이다. 이번에는 출력 인자와 입력 인자가 각각 하나씩 이며, src와 dst 유형 모두 레지스터로 지정하였고, 컴파일러에서 각 레지스터를 자동으로 지정할 수 있도록 해 보았다.
$ gcc -m32 -O2 -S test2.c 명령으로 컴파일해서 test2.s 소스를 살펴보니 다음과 같이 조금 이상하다. 컴파일을 하고 얻어진 어셈블리 소스가 의도한 대로 얻어지지 않고 있다. src와 dst 모두 %eax로 동일하게 지정되어 버린 것이다. (gcc / clang 모두 동일하며 아래는 gcc의 출력 결과.)

...생략
main:
.LFB0:
        .cfi_startproc
        movl    $3, %eax
#APP
# 4 "test2.c" 1
        imul %eax, %eax
# 0 "" 2
#NO_APP
        ret
...생략

그 이유는 출력 인자에 대한 제약 유형을 잘 못 지정했기 때문인 것인데, 출력 인자를 "=r" (dst)라고 하면 출력을 쓰기전용으로 지정하겠다는 뜻이기 때문이다. 의도대로 작동되려면 dst 값은 5이므로 읽기/쓰기가 모두 가능해야 한다. 따라서 출력 인자를 "=r"가 아닌 읽기/쓰기가 모두 가능한 변경자(Modifier) "+"를 써서 "+r"로 지정해야 한다. 즉, 소스를 조금 고쳐서 다음과 같이 해야 한다.

int main() { int src = 3; int dst = 5; __asm__ volatile ( "imul %1, %0" : "+r" (dst) /* 출력 인자를 읽기/쓰기 가능한 출력("+") 레지스터로 제한 */ : "r" (src) /* 입력 인자를 레지스터로 제한 */ ); return dst; }

이를 컴파일해서 소스코드를 얻으면 다음과 같다. (clang/gcc 모두 동일한 결과를 얻으며, 아래는 clang의 출력 결과)

...생략
main:                                   # @main
# BB#0:
        movl    $3, %ecx
        movl    $5, %eax
        #APP
        imull   %ecx, %eax
        #NO_APP
        retl
...생략

최종 확인하기 위해 컴파일 하고 실행을 하면 그 결과가 맞다는 것을 알 수 있다.

$ clang -m32 -O2  test2.c
$ ./a.out ; echo $?
15

정리

여기까지 인라인 어셈블리를 넣는 방식을 간단히 살펴보았는데, 정리해보면 인라인 어셈블리는 다음과 같은 형식으로 요약할 수 있다.

__asm__ volatile (
"인라인 어셈블리어 #1\n\t"
"인라인 어셈블리어 #2\n\t"
"인라인 어셈블리어 #3\n\t"
"인라인 어셈블리어 #4"
: 출력 인자1, 출력 인자 2, 출력 인자 3,
: 입력 인자4, 입력 인자5,
);

출력 인자의 유형을 "=r"라고 지정했을 경우 r은 레지스터 유형으로 제약(Constraint)을 하겠다는 것을 뜻하며, "+"는 읽기/쓰기 가능하게끔 제약 변경자(Constraint Modifier)를 걸어준다는 것을 뜻한다.

입출력 유형에 대한 제한/제약(Constraint)은 https://gcc.gnu.org/onlinedocs/gcc/Simple-Constraints.html 문서를 참고할 수 있으며,
추가적인 제약 변경자(Constraint Modifier)는 https://gcc.gnu.org/onlinedocs/gcc/Modifiers.html 문서를 통해 자세히 살펴볼 수 있다.

인라인 어셈블리에 대해 더 자세히 알고싶다면 다음 문서를 참고하길 바란다.

https://wiki.kldp.org/wiki.php/DocbookSgml/GCC_Inline_Assembly-KLDP

'입문하기' 카테고리의 다른 글

기타 케이블의 커패시턴스 효과  (4) 2022.07.11
x86 어셈블리어 배우기 - 리버스엔지니어링 기초  (0) 2017.03.01
아멘 샬롬  (0) 2015.04.21
아가페 프로토스  (1) 2015.04.20
by dumpcookie 2017. 3. 5. 16:53