컴퓨터 시스템은 구현의 세부 사항을 단순한 추상 모델로 숨기는 여러 형태의 추상화를 사용합니다. 기계 수준 프로그래밍에서는 다음 두 가지 추상화가 특히 중요합니다.
컴파일러는 C언어의 추상적인 실행 모델을 프로세서가 실행하는 매우 기본적인 명령어들로 변환하는 대부분의 작업을 수행합니다. 어셈블리어는 기계어와 매우 가깝지만, 사람이 더 읽기 편한 텍스트 형식이라는 특징이 있습니다.
C언어와 달리 기계어 코드에서는 C 프로그래머에게는 일반적으로 보이지 않던 프로세서의 여러 상태 정보가 드러납니다.
%rip라고 불림)if나 while문과 같은 조건부 흐름 변경을 구현하는 데 사용됩니다.또한, C언어에서는 다양한 자료형을 선언할 수 있지만, 기계어는 메모리를 단순히 거대한 바이트 주소 배열로만 봅니다. 배열이나 구조체 같은 C의 복합 데이터 타입은 기계어에서 연속된 바이트 덩어리로 표현될 뿐이며, 부호 있는 정수와 없는 정수, 심지어 포인터와 정수 사이에도 구분이 없습니다.
결론적으로, 하나의 기계어 명령어는 매우 기초적인 작업(두 숫자 더하기, 데이터 전송 등)만 수행하므로, 컴파일러는 C언어의 복잡한 구문(산술식, 반복문, 함수 호출 등)을 구현하기 위해 이러한 기본 명령어들의 연속을 생성해야 합니다.
= 256테라를 표현할 수 있으며 데이터를 표현할 때 이 정도면 충분합니다. 16비트는 나중에 확장성을 위해서 존재한다고 생각하면 됩니다.
C언어 소스 코드가 어떤 과정을 거쳐 최종 실행 파일이 되는지, 그리고 각 단계의 파일이 어떻게 생겼는지 구체적인 예시를 통해 살펴보겠습니다.
먼저, C 소스 코드(mstore.c)를 컴파일하여 어셈블리어 파일(.s)을 생성할 수 있습니다.
linux> gcc -Og -S mstore.c
S 옵션: 컴파일러가 소스 코드를 어셈블리어로 변환한 후, 그 다음 단계(어셈블, 링킹)를 진행하지 않고 멈추도록 합니다.생성된 mstore.s 파일에는 다음과 같은 어셈블리어 코드가 포함됩니다. 각 줄은 하나의 기계어 명령어에 해당하며, 변수명이나 자료형 같은 C언어의 추상적인 정보는 모두 사라진 것을 볼 수 있습니다.
multstore:
pushq %rbx
movq %rdx, %rbx
call mult2
movq %rax, (%rbx)
popq %rbx
ret
다음으로, 컴파일과 어셈블을 함께 진행하여 오브젝트 파일(.o)을 생성할 수 있습니다.
linux> gcc -Og -c mstore.c
c 옵션: 어셈블 단계까지만 수행하여 오브젝트 파일을 생성합니다.생성된 mstore.o 파일은 이진(binary) 형식이므로 텍스트 편집기로는 내용을 볼 수 없습니다. 하지만 이 파일 안에는 위 어셈블리어 코드에 해당하는 14바이트 길이의 기계어 코드(16진수로 53 48 89 ... c3)가 포함되어 있습니다.
기계어 파일의 내용을 사람이 읽을 수 있는 형태로 보기 위해 디스어셈블러(disassembler)를 사용합니다. 리눅스의 objdump 명령어가 이 역할을 합니다.
linux> objdump -d mstore.o
d 옵션: 오브젝트 파일의 코드 부분을 디스어셈블하여 보여줍니다.결과:
0: 53 push %rbx
1: 48 89 d3 mov %rdx,%rbx
4: e8 00 00 00 00 callq 9 <multstore+0x9>
9: 48 89 03 mov %rax,(%rbx)
c: 5b pop %rbx
d: c3 retq
왼쪽에는 16진수 기계어 코드가, 오른쪽에는 그에 해당하는 어셈블리어 명령어가 표시됩니다. 이로부터 다음과 같은 특징을 알 수 있습니다.
CPU는 명령어의 첫 번째 바이트(Opcode)를 먼저 읽고, 그 내용에 따라 '다음에 몇 바이트를 더 읽어야 하는지'를 스스로 판단하여 명령어의 끝을 알게 됩니다.
MOV 명령어다. 내 뒤에는 나를 보충 설명하는 바이트가 하나 더 필요하다"와 같은 정보를 알려줍니다.아니요, 하나의 워드에 모든 것을 담는 것은 불가능한 경우가 많습니다. 특히 명령어에 큰 숫자나 메모리 주소 같은 추가 정보가 필요할 때 그렇습니다.
이것은 CPU의 설계 방식인 RISC와 CISC 아키텍처의 차이와 관련이 깊습니다.
RISC (Reduced Instruction Set Computer, 예: ARM) 방식은 단순함을 추구합니다.
add r1, r2, r3 (r2와 r3 레지스터를 더해 r1에 저장하라)CISC (Complex Instruction Set Computer, 예: 인텔/AMD) 방식은 유연성을 추구합니다. 명령어의 길이가 필요에 따라 1바이트에서 최대 15바이트까지 늘어날 수 있습니다.
inc eax (eax 레지스터의 값을 1 증가시켜라)add eax, 12345678h (eax 레지스터에 16진수 12345678을 더해라)12345678h 자체 (4 바이트)결론적으로, '1워드 = 1명령어'는 RISC의 이상적인 목표일 뿐, 모든 경우에 성립하지 않으며, x86 같은 CISC 아키텍처에서는 명령어의 길이가 매우 유연하여 하나의 워드를 훌쩍 넘는 경우가 많습니다.
이제 링커를 사용하여 여러 오브젝트 파일과 라이브러리를 묶어 최종 실행 파일을 만듭니다.
linux> gcc -Og -o prog main.c mstore.c
prog라는 이름의 실행 파일이 생성됩니다. 이 파일에는 우리가 작성한 코드 외에, 프로그램을 시작하고 종료하며 운영체제와 상호작용하는 데 필요한 다른 코드들이 포함되어 용량이 더 커집니다.
objdump -d prog 명령어로 실행 파일을 디스어셈블하면, .o 파일의 결과와 거의 동일한 코드를 볼 수 있지만 다음과 같은 차이점이 있습니다.
callq 명령어에서 호출할 함수(mult2)의 정확한 주소가 채워집니다. 링커의 주요 역할 중 하나가 바로 이것입니다.→ 주소 변경(재배치)은 여러 개의 분리된 코드 조각들을 하나의 큰 프로그램으로 합치면서 주소를 재조정하는 것이고, 주소 확정은 그 과정에서 '나중에 찾아갈게'라고 표시해 둔 미완성된 함수 호출 주소를 완성시켜주는 것입니다.
이 과정을 여러 저자가 쓴 원고를 모아 한 권의 책으로 엮는 편집자의 일에 비유할 수 있습니다.
main.o, mstore.o): 각자 독립적으로 작성된 '1장 원고'와 '2장 원고'. 각 원고는 페이지 번호가 1쪽부터 시작합니다.prog): 페이지 번호가 모두 매겨진 최종 완성본 책.문제점: '1장 원고'의 5쪽과 '2장 원고'의 5쪽은 서로 다른 내용이지만 페이지 번호가 같습니다. 이대로는 책을 만들 수 없습니다.
편집자의 일 (링커의 역할):
편집자는 1장 원고(100쪽 분량)를 책의 맨 앞에 놓고, 2장 원고를 그 뒤에 붙이기로 결정합니다.
그리고 2장 원고의 모든 페이지 번호를 다시 매깁니다.
이것이 바로 주소 변경(재배치, Relocation)입니다. 컴파일될 때 0번지부터 시작했던 각 오브젝트 파일(main.o, mstore.o)의 상대 주소들을, 최종 실행 파일이라는 큰 공간 안에서 각자 들어갈 위치에 맞게 절대 주소로 재조정해주는 과정입니다.
문제점: 1장 원고를 쓴 저자는 2장의 내용이 필요해서 다음과 같이 메모를 남겼습니다.
"(자세한 내용은 [2장]을 참고하세요)"
저자는 원고를 쓸 당시에는 2장이 최종적으로 몇 쪽에 들어갈지 알 수 없었습니다. 컴파일 시점의 callq 0 (주소가 미정인 함수 호출)이 바로 이 상태입니다.
편집자의 일 (링커의 역할):
편집자는 이제 2장이 101쪽부터 시작한다는 것을 압니다. 그래서 1장의 메모로 돌아가 빈칸을 채워 넣습니다.
"(자세한 내용은 101쪽을 참고하세요)"
이것이 바로 주소 확정(Resolution)입니다. 링커는 main.o에 있던 "나중에 mult2 함수를 호출할게"라는 표시를 찾습니다. 그리고 재배치를 통해 확정된 mult2 함수의 최종 주소(예: 0x40058b)를 가져와 빈칸에 정확히 채워 넣어, 완전한 함수 호출 명령(callq 40058b)을 완성시킵니다.
GCC 컴파일러가 생성하는 어셈블리어 파일은 사람이 읽기 어렵습니다. 불필요한 정보가 많고, 프로그램이 어떻게 동작하는지에 대한 설명도 전혀 없기 때문입니다.
예를 들어, gcc -Og -S mstore.c 명령어로 생성된 mstore.s 파일의 전체 내용은 다음과 같습니다.
.file "mstore.c"
.text
.globl multstore
.type multstore, @function
multstore:
pushq %rbx
movq %rdx, %rbx
call mult2
movq %rax, (%rbx)
popq %rbx
ret
.size multstore, .-multstore
...
여기서 .으로 시작하는 줄들은 어셈블러와 링커를 위한 지시어(directives)로, 우리는 일반적으로 이 부분들을 무시해도 됩니다.
이 책에서는 어셈블리어 코드를 더 명확하게 보여주기 위해, 대부분의 지시어는 생략하고 줄 번호와 주석을 추가한 형태로 제공합니다.
// void multstore(long x, long y, long *dest)
// x는 %rdi, y는 %rsi, dest는 %rdx 레지스터에 저장됨
1 multstore:
2 pushq %rbx // %rbx 값 저장
3 movq %rdx, %rbx // dest를 %rbx로 복사
4 call mult2 // mult2(x, y) 함수 호출
5 movq %rax, (%rbx) // 결과를 *dest에 저장
6 popq %rbx // %rbx 값 복원
7 ret // 반환
이러한 형식은 각 명령어가 어떤 역할을 하고 원본 C 코드와 어떻게 관련되는지 쉽게 파악할 수 있도록 도와줍니다.
경우에 따라 프로그래머는 저수준(low-level) 기능을 사용하기 위해 직접 어셈블리 코드를 작성해야 할 때가 있습니다.
인텔 x86 아키텍처는 16비트에서 시작하여 32비트로 확장된 역사 때문에 용어 사용에 특징이 있습니다.
x86-64 아키텍처에서 C언어의 기본 자료형은 다음과 같이 표현됩니다.
int: 더블 워드 (32비트)char *) 및 long: 쿼드 워드 (64비트)float: 단정밀도 (4바이트)double: 배정밀도 (8바이트)GCC 컴파일러가 생성하는 대부분의 어셈블리어 명령어는 처리할 데이터의 크기를 나타내는 한 글자짜리 접미사(suffix)를 가집니다.
| 접미사 | 명칭 | 크기 | C 자료형 예시 |
|---|---|---|---|
b | Byte | 1 바이트 | char |
w | Word | 2 바이트 | short |
l | Long | 4 바이트 | int, float |
q | Quad | 8 바이트 | long, double, 포인터 |
예를 들어, 데이터 이동 명령어인 mov는 다루는 데이터의 크기에 따라 movb, movw, movl, movq 네 가지 형태로 나뉩니다.
여기서 32비트(4바이트)를 의미하는 접미사로 l을 사용하는 이유는, 과거 16비트 시절에 32비트를 '긴 워드(long word)'로 간주했기 때문입니다. int(4바이트)와 double(8바이트)에 서로 다른 크기임에도 불구하고 같은 접미사 l이 사용될 때도 있는데, 이는 부동소수점 연산이 완전히 다른 명령어와 레지스터 세트를 사용하기 때문에 혼동의 여지가 없습니다.
x86-64 CPU는 64비트 값을 저장하는 16개의 범용 레지스터를 가지고 있습니다. 이 레지스터들은 정수 데이터와 포인터(메모리 주소)를 저장하는 데 사용됩니다.
레지스터들의 이름은 모두 %r로 시작하지만, 명령어 집합의 역사적인 발전 과정 때문에 여러 다른 이름 규칙을 따릅니다.
%ax부터 %bp까지 8개의 16비트 레지스터가 있었습니다. 각 레지스터는 특정 목적을 가졌으며 그에 맞는 이름이 붙었습니다.%eax부터 %ebp까지로 이름이 바뀌었습니다.%rax부터 %rbp로 이름이 바뀌었고, 추가로 8개의 새로운 레지스터(%r8 ~ %r15)가 도입되었습니다.명령어는 이 16개 레지스터의 하위 바이트에 저장된 다양한 크기의 데이터에 접근할 수 있습니다.
%al)%ax)%eax)%rax)8바이트보다 작은 값을 레지스터에 쓸 때, 나머지 상위 바이트들은 다음과 같은 규칙에 따라 처리됩니다.
16개의 레지스터는 프로그램에서 각기 다른 역할을 수행합니다.
%rsp (스택 포인터): 런타임 스택의 끝 위치를 가리키는 특별한 용도의 레지스터입니다.대부분의 기계어 명령어는 연산에 사용할 소스(source) 값과 결과값을 저장할 목적지(destination)를 지정하는 하나 이상의 피연산자(operand)를 가집니다.
x86-64는 세 가지 유형의 피연산자 형식을 지원합니다.
상수 값을 직접 사용하는 방식입니다. 어셈블리어에서는 $ 기호 뒤에 숫자를 붙여 표현합니다.
$577, $0x1FCPU 내부의 레지스터에 저장된 값을 사용하는 방식입니다. 16개의 범용 레지스터 중 하나를 지정하여 그 안의 값을 직접 사용합니다.
%rax, %rdi메모리 주소를 계산하여 해당 위치의 값을 사용하는 방식입니다. 이 계산된 주소를 유효 주소(effective address)라고 합니다. x86-64는 다양한 주소 지정 모드(addressing modes)를 제공하는데, 가장 일반적인 형태는 다음과 같습니다.
Imm(rb, ri, s)
이 형식은 네 가지 요소로 구성됩니다.
Imm: 즉시 값 오프셋 (상수)rb: 베이스(base) 레지스터 (시작 주소)ri: 인덱스(index) 레지스터 (상대적 위치)s: 스케일(scale) 인수 (1, 2, 4, 8 중 하나)유효 주소는 다음 공식으로 계산됩니다.
유효 주소 = Imm + R[rb] + R[ri] * s
R[r]는 레지스터r에 저장된 값을 의미합니다.
이러한 복잡한 주소 지정 모드는 주로 배열이나 구조체의 특정 요소에 접근할 때 매우 유용합니다. 예를 들어, rb에 배열의 시작 주소를 넣고, ri에 인덱스 i를, s에 배열 요소의 크기를 넣어 array[i]에 효율적으로 접근할 수 있습니다.
가장 많이 사용되는 명령어는 데이터를 한 위치에서 다른 위치로 복사하는 것들입니다. x86-64에서는 피연산자를 다양하게 지정할 수 있어, 간단한 이동 명령어 하나로도 여러 가지 상황을 처리할 수 있습니다.
MOV 클래스MOV 클래스는 데이터를 변환 없이 그대로 복사하는 가장 기본적인 명령어 그룹입니다. 데이터 크기에 따라 네 가지 변형(movb, movw, movl, movq)이 있습니다.
Immediate), 레지스터, 또는 메모리 위치이유: CPU의 설계 철학 때문입니다. CPU는 모든 연산과 데이터 처리가 자신의 내부(레지스터)를 거치도록 설계되었습니다. 이는 CPU의 제어 회로를 단순하게 만들고, 데이터의 흐름을 예측 가능하게 하여 성능을 높이는 데 도움이 됩니다.규칙: mov [주소1], [주소2] 와 같이 메모리에서 메모리로 직접 데이터를 복사할 수 없다.
movl 명령어로 4바이트 값을 레지스터에 쓸 경우, 해당 레지스터의 상위 4바이트는 0으로 채워집니다. → 32비트에서 64비트 아키텍처로 옮기면서 발생하는 일movabsq: 일반 movq가 32비트로 표현 가능한 상수만 다룰 수 있는 것과 달리, 이 명령어는 임의의 64비트 상수를 레지스터로 옮길 수 있습니다.
MOVZ 클래스MOVZ 클래스는 더 작은 크기의 소스 값을 더 큰 크기의 목적지 레지스터로 복사할 때, 남는 상위 비트들을 모두 0으로 채우는 명령어 그룹입니다. 주로 부호 없는(unsigned) 수를 확장할 때 사용됩니다.
movz + 소스 크기 접미사 + 목적지 크기 접미사movzbw: byte(1) → word(2)로 0 확장 이동movzbl: byte(1) → long(4)로 0 확장 이동movzwl: word(2) → long(4)로 0 확장 이동movzlq는 왜 없을까?: 4바이트(l)를 8바이트(q)로 0 확장하는 movzlq 명령어는 없습니다. 대신 movl 명령어를 사용하면 목적지 레지스터의 상위 4바이트가 자동으로 0으로 채워지기 때문에, 동일한 효과를 얻을 수 있습니다.
MOVS 클래스MOVS 클래스는 더 작은 크기의 소스 값을 더 큰 크기의 목적지 레지스터로 복사할 때, 남는 상위 비트들을 소스의 부호 비트(MSB)로 채우는 명령어 그룹입니다. 주로 부호 있는(signed) 수를 확장할 때 사용됩니다.
movs + 소스 크기 접미사 + 목적지 크기 접미사movsbw: byte(1) → word(2)로 부호 확장 이동movslq: long(4) → quad(8)로 부호 확장 이동movswl, movsbq, movswq 등 모든 조합이 가능합니다.
cltq: 이 명령어는 피연산자가 없습니다. 항상 %eax(4바이트 소스)의 값을 부호 확장하여 %rax(8바이트 목적지)에 저장합니다. movslq %eax, %rax 명령어와 기능은 동일하지만, 1바이트로 더 간결하게 표현됩니다.
cltq명령어의 세부 사항1. 이름의 의미
cltq는 Convert Long to Quad의 약자입니다.
l(Long): 32비트 더블 워드 (int)q(Quad): 64비트 쿼드 워드 (long)즉, "32비트 값을 64비트로 변환하라"는 뜻입니다.
2. 동작 방식: 부호 확장
cltq는%eax(32비트)의 부호 비트 (31번째 비트)를 찾아서,%rax(64비트)의 비어있는 상위 32개 비트에 그대로 복사하여 채웁니다.
%eax가 양수일 때 (예:0x00000005):
부호 비트가0이므로,%rax는0x0000000000000005가 됩니다. (0 확장)%eax가 음수일 때 (예:0xFFFFFFFF):
부호 비트가1이므로,%rax는0xFFFFFFFFFFFFFFFF가 됩니다. (부호 확장)3. 왜 필요한가?
64비트 프로그래밍에서는 32비트
int타입의 계산 결과를 64비트 포인터나long타입의 주소 계산에 사용해야 하는 경우가 매우 흔합니다. 예를 들어, 어떤 함수가int값을 반환하면 그 값은%eax에 저장되는데, 이 값을 배열의 인덱스로 사용하려면 64비트로 안전하게 확장해야 합니다.
cltq는 이처럼 매우 빈번하게 일어나는 작업을 단 1바이트의 명령어로 처리하기 위해 만들어진 최적화 전용 명령어입니다.
데이터 이동 명령어가 실제 코드에서 어떻게 사용되는지 exchange라는 함수를 통해 살펴보겠습니다. 이 함수는 포인터가 가리키는 메모리의 값과 다른 변수의 값을 서로 교환합니다.
long exchange(long *xp, long y)
{
long x = *xp; // 1. 포인터 xp가 가리키는 메모리의 값을 변수 x에 저장
*xp = y; // 2. xp가 가리키는 메모리에 변수 y의 값을 저장
return x; // 3. 원래 값이었던 x를 반환
}
GCC 컴파일러는 위 C 코드를 다음과 같은 단 3개의 기계어 명령어로 번역합니다.
// xp는 %rdi 레지스터에, y는 %rsi 레지스터에 저장되어 있음
1 exchange:
2 movq (%rdi), %rax // xp가 가리키는 값을 %rax로 가져온다. (반환값이 됨)
3 movq %rsi, (%rdi) // y의 값을 xp가 가리키는 곳에 저장한다.
4 ret // 반환한다.
xp는 %rdi 레지스터에, 두 번째 인자인 y는 %rsi 레지스터에 저장되어 있습니다.movq (%rdi), %rax:(%rdi)는 %rdi 레지스터에 저장된 주소가 가리키는 메모리 위치를 의미합니다. (C언어의 xp)xp의 값을 읽어서 %rax 레지스터로 복사합니다.%rax는 함수가 값을 반환할 때 사용하는 약속된 레지스터이므로, 이 명령어 하나로 C 코드의 long x = *xp;와 return x; 두 가지 역할을 동시에 수행합니다.movq %rsi, (%rdi):%rsi 레지스터(변수 y의 값)의 값을 (%rdi) (즉, xp)가 가리키는 메모리 위치에 덮어씁니다.xp = y;를 직접 구현한 것입니다.xp)하는 것은 그 주소 값을 레지스터로 사용해 메모리에 접근하는 것과 같습니다.x와 같은 함수 내의 지역 변수는 느린 메모리(스택)가 아닌 빠른 레지스터에 저장되어 사용되는 경우가 많습니다. 이 예시에서는 지역 변수 x가 반환 값을 담는 %rax 레지스터에 바로 저장되어 효율을 높였습니다.공유 변수도 '아주 잠깐'은 레지스터에 올라갈 수 있지만, 지역 변수처럼 오랫동안 머물기는 매우 어렵습니다.
지역 변수만이 컴파일러 최적화를 통해 오랫동안 레지스터에 상주하며 성능을 높일 수 있는 거의 유일한 대상이기 때문입니다. 공유 변수는 '데이터 일관성(Consistency)' 문제와 '관리의 복잡성' 때문에 그렇게 할 수 없습니다.
만약 CPU-A가 공유 변수를 자신의 레지스터에 올려두고 작업을 하는데, 그 사이에 CPU-B가 메모리에 있는 원본 공유 변수의 값을 바꿔버리면, CPU-A의 레지스터에 있는 값은 더 이상 최신 값이 아니게 됩니다. 이는 심각한 데이터 불일치 문제를 일으킵니다. 따라서 공유 변수는 항상 메모리에 있는 원본을 기준으로 다루어야 합니다.
레지스터는 함수가 호출되거나, 다른 스레드로 작업이 전환(문맥 교환)될 때마다 내용이 계속 바뀌는 매우 바쁜 공간입니다. 어떤 공유 변수가 특정 레지스터를 계속 독점하고 있다면, 다른 모든 작업들이 그 레지스터를 사용하지 못하게 되어 시스템 전체의 효율이 떨어집니다.
push와 pop은 프로그램 스택(stack)에 데이터를 넣고 빼는 데 사용되는 데이터 이동 명령어입니다.

스택은 '마지막에 들어온 것이 가장 먼저 나가는(Last-In, First-Out)' 원칙으로 동작하는 데이터 구조입니다. x86-64에서 프로그램 스택은 메모리의 특정 영역에 위치하며, 주소가 낮은 쪽으로 자라납니다(grows downward).
%rsp): 항상 스택의 가장 꼭대기(top)에 있는 요소의 주소를 가리키는 특별한 레지스터입니다. 스택의 꼭대기는 가장 낮은 주소를 가집니다.pushq 명령어: 스택에 데이터 넣기pushq 명령어는 8바이트(쿼드 워드) 데이터를 스택에 넣습니다. 이 과정은 두 단계로 이루어집니다.
%rsp)를 8만큼 감소시켜 새 공간을 확보합니다.따라서 pushq %rbp 명령어는 아래 두 명령어와 동일하게 동작합니다.
subq $8, %rsp ; 1. 스택 포인터를 8 감소
movq %rbp, (%rsp) ; 2. %rbp의 값을 새 스택 꼭대기에 저장
pushq는 이 두 동작을 1바이트의 더 간결한 명령어로 처리합니다.
popq 명령어: 스택에서 데이터 빼기popq 명령어는 8바이트 데이터를 스택에서 꺼냅니다. 이 과정은 pushq와 반대로 이루어집니다.
%rsp가 가리키는 주소)에서 데이터를 읽습니다.%rsp)를 8만큼 증가시켜 공간을 제거합니다.따라서 popq %rax 명령어는 아래 두 명령어와 동일하게 동작합니다.
movq (%rsp), %rax ; 1. 스택 꼭대기의 값을 %rax로 읽어옴
addq $8, %rsp ; 2. 스택 포인터를 8 증가
pop을 하더라도 데이터가 메모리에서 물리적으로 지워지는 것은 아니지만, 스택 포인터가 이동했기 때문에 그 공간은 더 이상 유효한 스택 영역이 아니게 됩니다.
문맥 교환(Context Switch)이 일어날 때마다 스택 포인터를 포함한 모든 레지스터의 상태는 PCB(Process Control Block)에 저장되거나 PCB로부터 다시 복원됩니다.
%rsp)의 현재 주소 값도 포함됩니다.%rsp 레지스터로 들어갑니다.이제 CPU는 프로세스 B가 마지막으로 멈췄던 바로 그 상태 그대로, 아무 일 없었다는 듯이 실행을 재개합니다. 스택 포인터가 프로세스B의 것으로 바뀌었기 때문에, 프로그램은 이제 프로세스 B의 스택 공간을 사용하게 됩니다.
스택이 할당된 메모리 공간을 초과하여 계속 쌓이면 스택 오버플로우(Stack Overflow) 에러가 발생하며, 프로그램이 강제 종료됩니다.
운영체제는 프로그램이 시작될 때 미리 정해진 기본 크기(예: 리눅스 8MB)를 할당하지만, 실제 물리 메모리(RAM)는 필요해지는 순간에 조금씩 나눠서 할당합니다.
스택의 최대 크기는 운영체제나 컴파일러 설정에 따라 미리 정해져 있습니다.
이 기본 크기는 프로그래머가 컴파일러 옵션을 통해 변경하거나, 시스템 관리자가 ulimit 같은 명령어로 조절할 수 있습니다.
운영체제는 메모리를 효율적으로 사용하기 위해 가상 메모리와 요구 페이징(Demand Paging)이라는 매우 영리한 방식을 사용합니다.
프로그램이 처음 실행될 때, 운영체제는 8MB의 물리 메모리(RAM)를 통째로 할당하지 않습니다. 대신, 해당 프로세스의 가상 주소 공간에 "여기부터 여기까지 8MB는 스택 영역으로 사용할 예정"이라고 영역만 예약해 둡니다.
프로그램이 실행되면서 함수를 호출하고 스택을 실제로 사용하기 시작하면 다음과 같은 일이 벌어집니다.
이후 스택이 점점 더 깊어져서 할당된 4KB를 넘어서는 새로운 영역을 건드릴 때마다, 이 과정(페이지 폴트 → 물리 메모리 할당 및 매핑)이 반복됩니다.