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가 직접 실행할 수 없다. 따라서 컴파일러를 통해 일련의 변환 과정을 거쳐 실행 가능한 형태로 바뀌게 된다. 이 과정은 크게 네 단계로 나눌 수 있다:
-
컴파일 (Compilation): C 컴파일러 (
gcc -S
)는 C 소스 코드 (.c)를 어셈블리어 코드 (.s)로 변환한다. 이 단계에서는 프로그래머가 작성한 고수준의 명령들이 IA32 아키텍처의 어셈블리 명령어로 번역된다. 예를 들어, 다음 C 코드를 살펴보자:Cint sum(int x, int y) { int t = x + y; return t; }
이 코드는
gcc -O1 -S code.c
명령어를 통해 다음과 같은 IA32 어셈블리어로 변환될 수 있다:IA32 Assemblysum: 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
는 스택 포인터의 값을 베이스 포인터에 복사하는 등의 동작을 수행한다. -
어셈블 (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
각 바이트는 하나 또는 여러 개의 어셈블리 명령어에 대응된다. 예를 들어,
0x55
는push %ebp
명령어에 해당한다. -
링킹 (Linking): 링커 (
gcc
또는ld
)는 하나 이상의 목적 파일 (.o)과 필요에 따라 정적 라이브러리 (.a)를 결합하여 최종 실행 파일 (Executable File)을 생성한다. 이 단계에서는 여러 개의 코드 조각들이 하나의 주소 공간으로 통합되고, 외부 함수 호출에 대한 참조가 해결된다.
참고로 symbol에 관해 덧붙이자면, symbol은 하나의 object file에서 쓰이는 변수나 함수들을 통칭하는 말이다. symbol을 세분화 하면 Strong symbol(초기화된 전역변수, static, 함수 등)과 Weak symbol(초기화되지 않은 전역변수 등)으로 구분된다. 링킹 과정에서 strong symbol이 여러 개 정의되어 있으면 어떤 definition을 사용할지 알 수 없어 링킹 에러(Link error)가 발생한다. -
디스어셈블 (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)
%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 |
void bar(int a, int b) {
int x, y;
x = 555;
y = a+b;
}
void foo(void) {
bar(111, 222);
}
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 |
- 매개변수 전달(Parameter Passing): 호출자(Caller)인 함수(예: foo)는 피호출자(Callee)인 함수(예: bar)에 전달할 매개변수들을 스택에
push
한다. (일반적으로 역순으로 push) - 리턴 주소 저장(Return Address Saving):
call
명령어 실행 시, 현재 실행 중인 명령어의 다음 주소 (리턴 주소)가 스택에 자동으로push
된다. 피호출 함수가 종료된 후 이 주소로 돌아가 실행을 계속한다. - 프레임 포인터 설정(Frame Pointer Setup): 피호출 함수는 먼저 이전 프레임의 베이스 포인터 (
%ebp
) 값을 스택에push
하고, 현재의%esp
값을%ebp
에 복사하여 현재 함수의 스택 프레임을 설정한다. 이를 통해 함수 내에서 지역 변수 및 매개변수에 대한 접근이 용이해진다 (%ebp
기준 오프셋 사용).At entry to bar - 지역 변수 할당(Local Variable Allocation): 함수 내에서 필요한 지역 변수들을 위해 스택 포인터 (
%esp
)를 감소시켜 스택 공간을 확보한다. - 레지스터 저장 (선택 사항): 피호출 함수에서 값을 보존해야 하는 Callee-Save 레지스터 (
%ebx
,%esi
,%edi
)의 값을 스택에push
하여 저장한다.In bar - 함수 실행: 피호출 함수의 코드가 실행된다. 매개변수는
%ebp
를 기준으로 양수 오프셋(예: [ebp+8], [ebp+12] 등)을 통해 접근하고, 지역 변수는%ebp
를 기준으로 음수 오프셋(예: [ebp-4], [ebp-8] 등)을 통해 접근한다.In bar - 반환 값 전달(Return Value Passing): 함수의 반환 값은 일반적으로
%eax
레지스터를 통해 호출자에게 전달된다. - 스택 복원(Stack Restoration): 피호출 함수가 종료되기 전에, 저장했던 Callee-Save 레지스터의 값을 스택에서
pop
하여 복원하고, 스택 포인터 (%esp
)를 원래대로 되돌린다 (지역 변수 공간 해제).popl %ebp
를 통해 이전 프레임의%ebp
값을 복원하고,ret
명령어는 스택에 저장된 리턴 주소를pop
하여%eip
에 저장함으로써 호출자에게 프로그램의 실행 흐름을 되돌린다.
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
레지스터는 스택 프레임 관리에 특별한 용도로 사용되므로, 호출 규약에 따라 적절히 관리된다.
예시: swap 함수 호출
두 정수의 값을 서로 교환하는 swap
함수를 호출하는 과정을 통해 스택과 레지스터의 사용 방식을 구체적으로 살펴보자:
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):
# 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
함수 내에서는 다음과 같은 방식으로 스택 프레임을 설정하고 매개변수에 접근한다:
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의 동작 원리에 대한 깊이 있는 이해는 프로그램의 성능 최적화, 디버깅, 그리고 시스템 보안과 관련된 다양한 문제 해결 능력 향상에 기여할 것이다.