[시스템프로그래밍] Machine-Level Program Running - Macine Language, Calling Functions

Machine-Level 프로그래밍과 함수 호출 규약 (IA32 아키텍처 중심)

이번에는 시스템 프로그래밍의 핵심이라 할 수 있는 Machine-Level 프로그래밍의 기본 개념과 IA32 (x86) 아키텍처에서의 함수 호출 규약(Function Call Convention)에 대해 심도 있게 정리해보고자 한다. 특히, C 코드가 어셈블리어(Assembly)로 변환되는 과정, IA32 아키텍처의 주요 레지스터(Register) 역할, 스택(Stack)의 활용, 그리고 Procedure Call Convention에서의 스택 및 레지스터 사용법을 상세히 설명하여 시스템 수준 이해도를 한층 끌어올리는 것을 목표로 한다.


Machine-Level 프로그래밍 개요

Machine-Level 프로그래밍은 중앙 처리 장치(CPU)가 직접적으로 이해하고 실행할 수 있는 기계어(Machine Code) 또는 기계어와 일대일로 대응되는 어셈블리어를 사용하여 소프트웨어를 개발하는 행위를 의미한다. IA32 아키텍처는 대표적인 CISC (Complex Instruction Set Computing) 아키텍처 중 하나로, 복잡하고 다양한 명령어 세트를 특징으로 한다. 어셈블리 프로그래머의 관점에서 CPU는 레지스터, 메모리, 스택, 조건 코드 등의 내부 상태를 가지며, 이러한 요소들을 직접 제어함으로써 프로그램의 실행 흐름과 데이터 처리를 정밀하게 관리할 수 있다.

참고로 CISC는 곱연산과 합연산을 같이 수행하는 MAD 연산처럼 여러 논리 회로를 하나로 묶는다. (Complex함) 따라서 기본 논리 회로를 사용하는 RISC (Reduced Instruction Set Computing)에 비해 상대적으로 적은 수의 명령어를 필요로 하며, 이는 곧 실행 파일의 크기가 작음을 의미한다.

과거에는 메모리 절약이 중요했기에 CISC가 인기있었으나, 복잡한 명령어는 잘 사용되지 않아 낭비되는 문제로 명령어가 규격화되어 있는 RISC가 등장했다.


C 코드에서 실행 파일까지: 컴파일 과정 심층 분석

고수준 언어인 C로 작성된 코드는 CPU가 직접 실행할 수 없다. 따라서 컴파일러를 통해 일련의 변환 과정을 거쳐 실행 가능한 형태로 바뀌게 된다. 이 과정은 크게 네 단계로 나눌 수 있다:

  1. 컴파일 (Compilation): C 컴파일러 (gcc -S)는 C 소스 코드 (.c)를 어셈블리어 코드 (.s)로 변환한다. 이 단계에서는 프로그래머가 작성한 고수준의 명령들이 IA32 아키텍처의 어셈블리 명령어로 번역된다. 예를 들어, 다음 C 코드를 살펴보자:

    C
    int sum(int x, int y) {
        int t = x + y;
        return t;
    }
    

    이 코드는 gcc -O1 -S code.c 명령어를 통해 다음과 같은 IA32 어셈블리어로 변환될 수 있다:

    IA32 Assembly
    sum:
        pushl %ebp
        movl %esp,%ebp
        movl 12(%ebp),%eax     # Load y from stack
        addl 8(%ebp),%eax        # Add x from stack to %eax
        popl %ebp
        ret
    

    여기서 각 어셈블리 명령어는 CPU가 수행할 기본적인 연산을 나타낸다. pushl %ebp는 베이스 포인터 레지스터의 값을 스택에 저장하고, movl %esp,%ebp는 스택 포인터의 값을 베이스 포인터에 복사하는 등의 동작을 수행한다.

  2. 어셈블 (Assembly): 어셈블러 (gcc 또는 as)는 어셈블리어 코드 (.s)를 기계어 형태의 목적 코드 (.o, Object Code)로 변환한다. 목적 코드는 CPU가 이해할 수 있는 이진 형태로 표현되지만, 아직 다른 목적 파일이나 라이브러리와 연결되지 않아 완전한 실행 파일은 아니다. 이러한 목적 파일들은 .text (코드), .data (초기화된 전역 변수), .bss (초기화되지 않은 전역 변수) 등의 섹션과 함께 재배치 정보 (Relocation Information) 를 포함하고 있으며, 이 재배치 정보는 이후 링커가 여러 개의 목적 파일을 결합하고 최종 실행 파일의 메모리 주소를 결정할 때 필요하다.
    위의 sum 함수에 대한 목적 코드는 다음과 같은 바이트 시퀀스로 표현될 수 있다:

    0x401040 <sum>:
    0x55       # push   %ebp
    0x89       # mov    %esp,%ebp
    0xe5
    0x8b       # mov    0xc(%ebp),%eax
    0x45
    0x0c
    0x03       # add    0x8(%ebp),%eax
    0x45
    0x08
    0x5d       # pop    %ebp
    0xc3       # ret
    

    각 바이트는 하나 또는 여러 개의 어셈블리 명령어에 대응된다. 예를 들어, 0x55push %ebp 명령어에 해당한다.

  3. 링킹 (Linking): 링커 (gcc 또는 ld)는 하나 이상의 목적 파일 (.o)과 필요에 따라 정적 라이브러리 (.a)를 결합하여 최종 실행 파일 (Executable File)을 생성한다. 이 단계에서는 여러 개의 코드 조각들이 하나의 주소 공간으로 통합되고, 외부 함수 호출에 대한 참조가 해결된다.
    참고로 symbol에 관해 덧붙이자면, symbol은 하나의 object file에서 쓰이는 변수나 함수들을 통칭하는 말이다. symbol을 세분화 하면 Strong symbol(초기화된 전역변수, static, 함수 등)과 Weak symbol(초기화되지 않은 전역변수 등)으로 구분된다. 링킹 과정에서 strong symbol이 여러 개 정의되어 있으면 어떤 definition을 사용할지 알 수 없어 링킹 에러(Link error)가 발생한다.

  4. 디스어셈블 (Disassembling): 반대로, 실행 파일이나 목적 코드를 분석하기 위해 디스어셈블러 (objdump -d)를 사용하여 기계어 코드를 어셈블리어 형태로 역변환할 수 있다. 이는 프로그램의 저수준(low-level) 동작을 이해하는 데 매우 유용한 도구이다.


IA32 아키텍처의 주요 레지스터 역할

IA32 아키텍처에서 CPU는 다양한 종류의 레지스터를 사용하여 데이터를 임시로 저장하고 연산을 수행한다. 어셈블리 프로그래머가 주로 사용하는 범용 레지스터(General-Purpose Registers)는 다음과 같다:

  • %eax (Accumulator): 주로 함수의 반환 값(Return Value) 저장 및 산술 연산에 사용된다. (Caller-Save)
  • %ecx (Counter): 루프 카운터 등으로 사용된다. (Caller-Save)
  • %edx (Data Register): 곱셈, 나눗셈 연산 등에서 %eax와 함께 사용되거나, I/O 연산에 사용된다. (Caller-Save)
  • %ebx (Base Register): 메모리 주소 지정에 사용될 수 있으며, Callee-Save 레지스터이다.
  • %esi (Source Index): 문자열 연산 등에서 소스 데이터의 주소를 가리키는 데 사용된다. (Callee-Save)
  • %edi (Destination Index): 문자열 연산 등에서 목적지 주소를 가리키는 데 사용된다. (Callee-Save)

다음은 특수 레지스터(Special-Purpose Registers)이다:
  • %esp (Stack Pointer): 현재 스택의 최상단 주소 (Top of Stack)를 가리킨다. 스택에 데이터를 push하거나 pop할 때 값이 변경된다. 스택은 낮은 주소 방향으로 자란다. (Callee-save의 특별한 형태)
  • %ebp (Base Pointer / Frame Pointer): 현재 함수의 스택 프레임(Stack Frame)의 시작 주소(기준점)를 가리킨다. 함수 호출 시 이전 프레임의 %ebp 값을 저장하고, 현재 프레임의 기준 주소로 사용된다. (Callee-save의 특별한 형태)
  • %eip (Instruction Pointer): 다음에 실행될 명령어의 메모리 주소를 가리킨다.

또한, 연산 결과의 상태를 나타내는 조건 코드 (Condition Codes, CF, ZF, SF, OF) 레지스터도 중요한 역할을 수행한다. 이 값들을 적당히 조합함으로써 jump instruction을 수행할 수 있다.


스택과 함수 호출의 관계

스택(Stack)은 IA32 아키텍처에서 함수 호출 및 실행 시 매우 중요한 역할을 담당하는 메모리 영역이다. 스택은 LIFO (Last-In, First-Out) 원칙에 따라 관리되며, %esp 레지스터가 스택의 최상단(가장 작은 메모리 주소)을 가리킨다. 여기서 스택은 메모리의 높은 주소에서 시작하여 낮은 주소로 데이터를 저장한다.

IA32/Linux Stack frame


C
void bar(int a, int b) {
    int x, y;

    x = 555;
    y = a+b;}void foo(void) {
    bar(111, 222);}

IA32 Assembly
bar:		                                # --------- start of the function bar()
	pushl	%ebp		             # save the incoming frame pointer
	movl	%esp, %ebp	       # set the frame pointer to the current top of stack
	subl	$16, %esp	             # increase the stack by 16 bytes (stacks grow down)
	movl	$555, -4(%ebp)	# x=555 a is located at [ebp-4]
	movl	12(%ebp), %eax	# 12(%ebp) is [ebp+12], which is the second parameter
	movl	8(%ebp), %edx	# 8(%ebp) is [ebp+8], which is the first parameter
	addl	%edx, %eax	      # add them
	movl	%eax, -8(%ebp)	# store the result in y
	leave			                   #
	ret			                         #
foo:		                               # --------- start of the function foo()
	pushl	%ebp		            # save the current frame pointer
	movl	%esp, %ebp	      # set the frame pointer to the current top of the stack
	subl	$8, %esp	            # increase the stack by 8 bytes (stacks grow down)
	movl	$222, 4(%esp)	     # this is effectively pushing 222 on the stack
	movl	$111, (%esp)	     # this is effectively pushing 111 on the stack
	call	bar		                        # call = push the instruction pointer on the stack and branch to foo
	leave			                 # done
	ret			                       #


함수가 호출될 때, 다음과 같은 동작이 스택을 중심으로 이루어진다:

Before call to bar


  1. 매개변수 전달(Parameter Passing): 호출자(Caller)인 함수(예: foo)는 피호출자(Callee)인 함수(예: bar)에 전달할 매개변수들을 스택에 push한다. (일반적으로 역순으로 push)
  2. 리턴 주소 저장(Return Address Saving): call 명령어 실행 시, 현재 실행 중인 명령어의 다음 주소 (리턴 주소)가 스택에 자동으로 push된다. 피호출 함수가 종료된 후 이 주소로 돌아가 실행을 계속한다.
  3. 프레임 포인터 설정(Frame Pointer Setup): 피호출 함수는 먼저 이전 프레임의 베이스 포인터 (%ebp) 값을 스택에 push하고, 현재의 %esp 값을 %ebp에 복사하여 현재 함수의 스택 프레임을 설정한다. 이를 통해 함수 내에서 지역 변수 및 매개변수에 대한 접근이 용이해진다 (%ebp 기준 오프셋 사용).

    At entry to bar

  4. 지역 변수 할당(Local Variable Allocation): 함수 내에서 필요한 지역 변수들을 위해 스택 포인터 (%esp)를 감소시켜 스택 공간을 확보한다.
  5. 레지스터 저장 (선택 사항): 피호출 함수에서 값을 보존해야 하는 Callee-Save 레지스터 (%ebx, %esi, %edi)의 값을 스택에 push하여 저장한다.

    In bar

  6. 함수 실행: 피호출 함수의 코드가 실행된다. 매개변수는 %ebp를 기준으로 양수 오프셋(예: [ebp+8], [ebp+12] 등)을 통해 접근하고, 지역 변수는 %ebp를 기준으로 음수 오프셋(예: [ebp-4], [ebp-8] 등)을 통해 접근한다.

    In bar

  7. 반환 값 전달(Return Value Passing): 함수의 반환 값은 일반적으로 %eax 레지스터를 통해 호출자에게 전달된다.
  8. 스택 복원(Stack Restoration): 피호출 함수가 종료되기 전에, 저장했던 Callee-Save 레지스터의 값을 스택에서 pop하여 복원하고, 스택 포인터 (%esp)를 원래대로 되돌린다 (지역 변수 공간 해제). popl %ebp를 통해 이전 프레임의 %ebp 값을 복원하고, ret 명령어는 스택에 저장된 리턴 주소를 pop하여 %eip에 저장함으로써 호출자에게 프로그램의 실행 흐름을 되돌린다.
IA Stack: Pop



Procedure Call Convention에서의 스택과 레지스터 사용

Procedure Call Convention (PCC)은 함수 호출 시 매개변수 전달, 리턴 값 처리, 레지스터 사용, 스택 관리 등에 대한 규칙들의 집합이다. IA32 아키텍처에서 사용되는 일반적인 호출 규약에서는 스택과 레지스터가 다음과 같은 방식으로 상호작용한다:

  • 매개변수 전달: 매개변수들은 일반적으로 역순으로 스택에 push되어 피호출 함수에 전달된다. 피호출 함수는 %ebp를 기준으로 정해진 오프셋 (예: 8(%ebp), 12(%ebp), ...)을 통해 이 매개변수들에 접근한다.
  • 리턴 값: 함수의 리턴 값은 주로 %eax 레지스터를 통해 전달된다.
  • 레지스터 보존:
    • 호출자 저장 (Caller-Save) 레지스터 (%eax, %ecx, %edx): 호출자가 값을 보존해야 하는 경우 함수 호출 전에 미리 저장해야 한다. 피호출자는 이 레지스터들의 값을 자유롭게 변경할 수 있다.
    • 피호출자 저장 (Callee-Save) 레지스터 (%ebx, %esi, %edi): 피호출자가 이 레지스터들을 사용하기 전에 스택에 저장하고, 함수 종료 전에 반드시 복원해야 한다. 이는 호출자가 함수 호출 전후에 이 레지스터들의 값이 변경되지 않음을 보장하기 위함이다.
    • %esp%ebp 레지스터는 스택 프레임 관리에 특별한 용도로 사용되므로, 호출 규약에 따라 적절히 관리된다.
%esp vs %ebp


예시: swap 함수 호출

두 정수의 값을 서로 교환하는 swap 함수를 호출하는 과정을 통해 스택과 레지스터의 사용 방식을 구체적으로 살펴보자:

C
void swap(int *xp, int *yp) {
    int t0 = *xp;
    int t1 = *yp;
    *xp = t1;
    *yp = t0;
}

int course1 = 15213;
int course2 = 18243;

void call_swap() {
    swap(&course1, &course2);
}

call_swap 함수에서 swap 함수를 호출하기 전에, course2의 주소와 course1의 주소를 순서대로 스택에 push한다 (IA32에서는 역순으로 push):

IA32 Assembly
# call_swap 부분
subl $8, %esp                     # 스택 포인터 감소 (매개변수 공간 8 바이트 확보)
movl $course2, 4(%esp)    # course2의 주소를 스택의 두 번째 위치(yp)에 저장
movl $course1, (%esp)      # course1의 주소를 스택의 최상단(xp)에 저장
call swap                            # swap 함수 호출 (리턴 주소를 스택에 push)
# ... swap 함수 반환 후 ...
addl $8, %esp                    # 호출자가 스택 정리 (매개변수 공간 해제)

swap 함수 내에서는 다음과 같은 방식으로 스택 프레임을 설정하고 매개변수에 접근한다:

IA32 Assembly
swap:
    pushl %ebp                    # 1. 이전 프레임의 %ebp 저장
    movl %esp, %ebp          # 2. 현재 스택 포인터를 새로운 베이스 포인터로 설정 (프레임 설정)
    pushl %ebx                     # 3. Callee-Save 레지스터 %ebx 저장 (필요시)

    # 함수 본체 (매개변수 접근: 8(%ebp)는 xp, 12(%ebp)는 yp)
    movl 8(%ebp), %edx     # xp (course1의 주소)를 %edx에 로드
    movl 12(%ebp), %ecx   # yp (course2의 주소)를 %ecx에 로드
    movl (%edx), %ebx       # t0 = *xp (course1의 값)을 %ebx에 로드
    movl (%ecx), %eax       # t1 = *yp (course2의 값)을 %eax에 로드
    movl %eax, (%edx)       # *xp = t1 (%eax 값 저장)
    movl %ebx, (%ecx)       # *yp = t0 (%ebx 값 저장)

    # 함수 종료 전 정리
    popl %ebx                     # 4. 저장했던 %ebx 값 복원
    popl %ebp                    # 5. 이전 프레임의 %ebp 복원 (movl %ebp, %esp와 동일 효과 + %ebp 복원)
    ret                                  # 6. 리턴 주소를 pop하여 복귀

이 예시를 통해 함수 호출 시 스택을 이용한 매개변수 전달, 리턴 주소 저장, 스택 프레임 관리, 그리고 레지스터 저장 및 복원 과정을 명확히 이해할 수 있기를 바란다.


결론

이 게시글에서는 Machine-Level 프로그래밍의 기본적인 개념부터 IA32 아키텍처에서의 함수 호출 규약까지 상세하게 살펴보았다. C 코드가 어셈블리어와 기계어 코드로 변환되는 과정, 주요 레지스터의 역할, 스택의 중요성, 그리고 Procedure Call Convention에서의 스택과 레지스터 활용 방식을 이해하는 것은 시스템 프로그래밍 능력을 향상시키는 데 필수적이다. 이러한 low-level의 동작 원리에 대한 깊이 있는 이해는 프로그램의 성능 최적화, 디버깅, 그리고 시스템 보안과 관련된 다양한 문제 해결 능력 향상에 기여할 것이다.



추천글:

hyeon_B

안녕하세요! AI 기술을 이용해 더 나은 세상을 만들어 나가고 싶은 과기원생 Hyeon이라고 합니다. 저는 앞으로 인공지능 시대에는 지식을 '활용'하는 능력이 중요해질 것이라고 생각합니다. 대부분의 일들은 인공지능이 뛰어난 모습을 보이지만, 인공지능은 데이터로 부터 연관관계를 학습하기 때문에 지식들을 새로 통합해서 활용하는 능력이 부족합니다. 인공지능이 뉴턴 전에 만들어졌다면 사과가 떨어지는 이유에 대답하지 못했을 것이고, 아인슈타인 전에 만들어졌다면 중력이 어떻게 생기는지 설명하지 못했을 것입니다. 따라서 앞으로 우리는 '본질'을 탐구하고 그 본질로부터 다른 곳에 적용하며 인공지능을 현명하게 활용해야 할 것입니다. 함께 인공지능 시대를 준비합시다!

댓글 쓰기

다음 이전

POST ADS1

POST ADS 2