
이번 4주차부터 4주간은 Assembly 언어와 매우 가까운 C언어를 사용하여 좀 더 컴퓨터의 본질에 가까이 가면서 pint OS를 위한 기반을 다지는 주차이다.
c언어 기본 자료구조 미션을 해결하며 자료구조와 친해지고 CSAPP 책을 읽으며 CS지식을 다지는 기간이었다.
프로그램 속 명령어와 데이터 실행 전후로 반드시 레지스터에 저장되는데 따라서 레지스터에 저장된 값만 잘 관찰해도 프로그램의 실행 흐름을 파악할 수 있음
메모리에서 읽어 들일 명령어의 주소를 저장하고 다음에 실행될 명령어의 위치를 가리킴CPU가 수행해야할 동작을 결정하고 제어 신호를 생성
x86-64 아키텍처에서 데이터에 어떻게 접근하고 이동시키는지
x86-64 CPU에는 16개의 64비트 범용 레지스터가 있습니다. 이 레지스터들은 정수 데이터와 포인터를 저장하는 데 사용됩니다.

주요 특징:
예를 들어, %rax 레지스터의 경우
- %rax: 전체 64비트
- %eax: 하위 32비트
- %ax: 하위 16비트
- %al: 최하위 8비트
이러한 구조 덕분에 다양한 크기의 데이터를 효율적으로 다룰 수 있게되었다.
x86-64에서의 세 가지 주요 연산자 지정자가 존재
| 유형 | 형식 | 설명 | 예시 |
|---|---|---|---|
| 즉시값(Immediate) | $Imm | 상수 값 | $0x100 |
| 레지스터(Register) | ra | 레지스터 내용 | %rax |
| 메모리(Memory) | Imm(rb,ri,s) | 계산된 메모리 주소 | 8(%rbp,%rsi,4) |
메모리 참조의 일반적인 형식: Imm + R[rb] + R[ri] * s
- Imm: 즉시값 오프셋
- rb: 베이스 레지스터
- ri: 인덱스 레지스터
- s: 스케일 팩터 (1, 2, 4, 8 중 하나)
이 복잡해 보이는 형식 덕분에 배열이나 구조체의 요소에 효율적으로 접근할 수 있음
x86-64는 다양한 데이터 이동 명령어를 제공합니다. 크게 mov, movz, movs 계열로 나눌 수 있음
소스에서 목적지로 데이터를 그대로 복사하는 명령어
| 명령어 | 설명 | 예시 |
|---|---|---|
| movb | 1바이트 이동 | movb $0x12, (%rax) |
| movw | 2바이트 이동 | movw (%rax), %dx |
| movl | 4바이트 이동 | movl $0x12345678, %eax |
| movq | 8바이트 이동 | movq %rax, (%rsp) |
작은 크기의 데이터를 큰 크기로 확장할 때, 상위 비트를 0으로 채우는 명령어
| 명령어 | 설명 |
|---|---|
| movzbw | 바이트를 워드로 제로 확장 |
| movzbl | 바이트를 더블워드로 제로 확장 |
| movzwl | 워드를 더블워드로 제로 확장 |
| movzbq | 바이트를 쿼드워드로 제로 확장 |
| movzwq | 워드를 쿼드워드로 제로 확장 |
작은 크기의 데이터를 큰 크기로 확장할 때 사용하는 명령어, 부호를 유지함
| 명령어 | 설명 |
|---|---|
| movsbw | 바이트를 워드로 부호 확장 |
| movsbl | 바이트를 더블워드로 부호 확장 |
| movswl | 워드를 더블워드로 부호 확장 |
| movsbq | 바이트를 쿼드워드로 부호 확장 |
| movswq | 워드를 쿼드워드로 부호 확장 |
| movslq | 더블워드를 쿼드워드로 부호 확장 |
네, 이해했습니다. 요약 대신 스택 연산에 대한 전체적인 설명을 드리겠습니다.
x86-64 아키텍처에서 스택은 프로그램 실행에 핵심적인 역할을 한다.
스택은 후입선출(LIFO: Last In, First Out) 구조의 메모리 영역이다. x86-64에서 스택은 다음과 같은 특징을 가진다.
x86-64에서 스택을 조작하는 두 가지 주요 명령어가 있다.
| 명령어 | 설명 | 동작 |
|---|---|---|
| pushq S | 쿼드워드를 스택에 추가 | R[%rsp] ← R[%rsp] - 8; M[R[%rsp]] ← S |
| popq D | 스택에서 쿼드워드를 제거 | D ← M[R[%rsp]]; R[%rsp] ← R[%rsp] + 8 |
여기서 R[%rsp]는 스택 포인터의 값을, M[addr]은 메모리 주소 addr의 내용을 나타낸다.
pushq 명령어는 다음 두 단계로 이루어진다.
pushq %rbp
; 동등한 동작:
subq $8, %rsp ; 스택 포인터 8 감소
movq %rbp, (%rsp) ; 값을 새 스택 top에 저장
이 과정을 통해 스택에 새로운 데이터가 "쌓이게" 된다.
popq 명령어는 pushq의 역순으로 동작한다.
popq %rax
; 동등한 동작:
movq (%rsp), %rax ; 스택 top 값을 레지스터로 복사
addq $8, %rsp ; 스택 포인터 8 증가
다음 다이어그램은 push와 pop 연산의 효과를 시각적으로 보여줍니다:
초기 상태 pushq %rax 실행 후 popq %rdx 실행 후
높은 주소
+---------------+ +---------------+ +---------------+
| | | | | |
| | | | | |
| | | | | |
+---------------+ +---------------+ +---------------+
| %rsp -> 0x108 | | 0x108 | | %rsp -> 0x108 |
+---------------+ +---------------+ +---------------+
| | | %rsp -> 0x100 | | 0x123 |
+---------------+ +---------------+
| 0x123 |
낮은 주소
레지스터 상태:
%rax: 0x123 %rax: 0x123 %rax: 0x123
%rdx: 0 %rdx: 0 %rdx: 0x123
%rsp: 0x108 %rsp: 0x100 %rsp: 0x108
이 다이어그램은 다음을 보여줍니다:
1. push 연산이 스택 포인터를 감소시키고 값을 저장하는 과정
2. pop 연산이 값을 읽고 스택 포인터를 증가시키는 과정
3. 스택이 높은 주소에서 낮은 주소로 성장하는 방식
x86-64 아키텍처에서 스택 연산을 이해하는 것은 저수준 프로그래밍과 시스템 최적화에 필수적이다. 이 지식을 바탕으로 더 효율적인 코드를 작성하고, 복잡한 프로그램의 동작을 더 잘 이해할 수 있다.
x86-64 아키텍처에서 산술 및 논리 연산이 이루어지는 과정
x86-64는 다양한 산술 및 논리 연산을 지원한다. 이들은 크게 네 그룹으로 나눌 수 있다.
| 명령어 | 효과 | 설명 |
|---|---|---|
| leaq S, D | D ← &S | 유효 주소 로드 |
| inc D | D ← D + 1 | 증가 |
| dec D | D ← D - 1 | 감소 |
| neg D | D ← -D | 부정 |
| not D | D ← ~D | 보수 |
| add S, D | D ← D + S | 덧셈 |
| sub S, D | D ← D - S | 뺄셈 |
| imul S, D | D ← D * S | 곱셈 |
| xor S, D | D ← D ^ S | 배타적 논리합 |
| or S, D | D ← D | S |
| and S, D | D ← D & S | 논리곱 |
| sal k, D | D ← D << k | 왼쪽 산술 시프트 |
| shl k, D | D ← D << k | 왼쪽 논리 시프트 (sal과 동일) |
| sar k, D | D ← D >>A k | 오른쪽 산술 시프트 |
| shr k, D | D ← D >>L k | 오른쪽 논리 시프트 |
여기서 S는 소스 오퍼랜드, D는 목적지 오퍼랜드를 나타냄
leaq (Load Effective Address) 명령어는 movq 명령어의 변형입니다. 메모리에서 읽는 것처럼 보이지만, 실제로는 메모리를 참조하지 않고 유효 주소만을 계산하여 목적지 레지스터에 저장한다.
예를 들어:
// `5*%rdx + 7`의 값을 `%rax`에 저장한다.
leaq 7(%rdx,%rdx,4), %rax
leaq는 단순한 산술 연산을 수행하는 데도 자주 사용된다.
long scale(long x, long y, long z) {
long t = x + 4 * y + 12 * z;
return t;
}
이 함수는 다음과 같은 어셈블리 코드로 변환된다.
scale:
leaq (%rdi,%rsi,4), %rax # x + 4*y
leaq (%rdx,%rdx,2), %rdx # z + 2*z = 3*z
leaq (%rax,%rdx,4), %rax # (x+4*y) + 4*(3*z) = x + 4*y + 12*z
ret
이처럼 leaq를 사용하면 복잡한 산술 연산을 효율적으로 수행할 수 있다.
단항 연산은 하나의 오퍼랜드에 대해 연산을 수행한다.
incq (%rsp)
이 명령은 스택의 최상위 8바이트 값을 1 증가시킨다.
이항 연산은 두 개의 오퍼랜드를 사용합니다.
subq %rax, %rdx
이 명령은 %rdx에서 %rax의 값을 뺀다.
시프트 연산은 비트를 왼쪽이나 오른쪽으로 이동시킨다. 시프트 양은 즉시 값이나 %cl 레지스터로 지정할 수 있다.
예를 들어:
salq $4, %rax # %rax를 왼쪽으로 4비트 시프트
sarq %cl, %rbx # %rbx를 오른쪽으로 %cl에 저장된 값만큼 산술 시프트
sar은 산술 시프트로, 부호 비트를 보존한다. shr은 논리 시프트로, 항상 0으로 채운다.
x86-64는 128비트 연산을 위한 특별한 명령어도 제공한다.
| 명령어 | 효과 | 설명 |
|---|---|---|
| imulq S | R[%rdx]:R[%rax] ← S × R[%rax] | 부호 있는 전체 곱셈 |
| mulq S | R[%rdx]:R[%rax] ← S × R[%rax] | 부호 없는 전체 곱셈 |
| cqto | R[%rdx]:R[%rax] ← SignExtend(R[%rax]) | 옥트 워드로 변환 |
| idivq S | R[%rdx] ← R[%rdx]:R[%rax] mod S; R[%rax] ← R[%rdx]:R[%rax] ÷ S | 부호 있는 나눗셈 |
| divq S | R[%rdx] ← R[%rdx]:R[%rax] mod S; R[%rax] ← R[%rdx]:R[%rax] ÷ S | 부호 없는 나눗셈 |
이 명령어들은 128비트 곱셈과 나눗셈을 수행할 수 있게 해준다.
x86-64 아키텍처는 다양하고 강력한 산술 및 논리 연산을 제공하는데 이러한 연산들을 이해하고 활용하면 효율적인 저수준 프로그래밍이 가능해진다.
leaq를 이용한 효율적인 주소 계산과 간단한 산술 연산안x86-64 아키텍처에서 제어 흐름이 어떻게 구현되는지 정리
x86-64 CPU는 산술 및 논리 연산의 결과를 나타내는 단일 비트 조건 코드 레지스터를 유지한다.
이러한 조건 코드는 조건부 분기를 수행하는 데 사용된다.
조건 코드를 사용하는 세 가지 일반적인 방법이 있다.
다음은 조건부 설정 명령어의 예입니다:
| 명령어 | 동작 | 설명 |
|---|---|---|
| sete D | D ← ZF | 같음 / 0 |
| setne D | D ← ~ZF | 다름 / 0이 아님 |
| sets D | D ← SF | 음수 |
| setns D | D ← ~SF | 음수가 아님 |
| setg D | D ← ~(SF^OF) & ~ZF | 더 큼 (부호 있는 >) |
| setge D | D ← ~(SF^OF) | 크거나 같음 (부호 있는 >=) |
| setl D | D ← SF^OF | 더 작음 (부호 있는 <) |
| setle D | D ← (SF^OF) | ZF |
조건부 점프 명령어는 다음과 같다.
| 명령어 | 동작 | 설명 |
|---|---|---|
| je Label | if (ZF) goto Label | 같음 / 0 |
| jne Label | if (~ZF) goto Label | 다름 / 0이 아님 |
| js Label | if (SF) goto Label | 음수 |
| jns Label | if (~SF) goto Label | 음수가 아님 |
| jg Label | if (~(SF^OF) & ~ZF) goto Label | 더 큼 (부호 있는 >) |
| jge Label | if (~(SF^OF)) goto Label | 크거나 같음 (부호 있는 >=) |
| jl Label | if (SF^OF) goto Label | 더 작음 (부호 있는 <) |
| jle Label | if ((SF^OF) | ZF) goto Label |
조건부 제어 전송 대신 조건부 데이터 전송을 사용하여 조건부 연산을 구현할 수 있다. 이 방법은 조건부 연산의 두 결과를 모두 계산한 다음 조건에 따라 하나를 선택한다.
조건부 이동 명령어의 예:
| 명령어 | 동작 | 설명 |
|---|---|---|
| cmove S, R | if (ZF) R ← S | 같음 / 0일 때 이동 |
| cmovne S, R | if (~ZF) R ← S | 다름 / 0이 아닐 때 이동 |
| cmovs S, R | if (SF) R ← S | 음수일 때 이동 |
| cmovns S, R | if (~SF) R ← S | 음수가 아닐 때 이동 |
이 방법은 분기 예측 실패로 인한 성능 저하를 줄일 수 있다.
C 언어의 do-while, while, for 루프는 조건부 테스트와 점프의 조합으로 구현된다.
do-while 루프의 일반적인 형태:
do
body-statement
while (test-expr);
이는 다음과 같은 goto 코드로 변환됩니다:
loop:
body-statement
t = test-expr;
if (t)
goto loop;
while 루프의 일반적인 형태:
while (test-expr)
body-statement
이는 두 가지 방법으로 변환될 수 있다.
goto test;
loop:
body-statement
test:
t = test-expr;
if (t)
goto loop;
t = test-expr;
if (!t)
goto done;
loop:
body-statement
t = test-expr;
if (t)
goto loop;
done:
for 루프의 일반적인 형태:
for (init-expr; test-expr; update-expr)
body-statement
이는 while 루프로 변환된 후, 위의 두 가지 방법 중 하나로 구현된다.
switch 문은 정수 인덱스 값에 따라 다중 분기를 제공하는데 효율적인 구현을 위해 점프 테이블이라는 데이터 구조를 사용한다.
점프 테이블은 각 항목이 해당 케이스를 처리하는 코드 세그먼트의 주소인 배열이다. 프로그램은 switch 인덱스를 사용하여 점프 테이블을 참조하고, 해당 주소로 점프한다.
예를 들어:
switch(n) {
case 100: /* code for case 100 */
case 102: /* code for case 102 */
case 103: /* code for case 103 */
case 104:
case 106: /* code for cases 104 and 106 */
default: /* default case */
}
이러한 switch 문은 점프 테이블을 사용하여 효율적으로 구현될 수 있다.
x86-64 아키텍처는 조건 코드, 조건부 점프, 조건부 이동 등 다양한 제어 흐름 메커니즘을 제공하는데 이를 통해 C 언어의 if-else 문, 루프, switch 문 등을 효율적으로 구현할 수 있.
이러한 메커니즘을 이해하면 저수준에서 프로그램의 동작을 더 잘 이해하고 최적화할 수 있다.
x86-64 아키텍처에서 프로시저(함수) 호출이 어떻게 구현되는지 정리를 정리한 단원이다.

프로시저 호출: 프로시저 호출은 프로그램에서 특정 코드 블록(프로시저 또는 함수라고 함)을 실행하기 위해 사용되는 프로그래밍 기법
프로시저 호출 메커니즘의 핵심은 스택 데이터 구조를 사용하는 것. 스택은 "후입선출(Last-In-First-Out, LIFO)" 원칙으로 작동하며, 다음과 같은 용도로 사용된다.
x86-64에서 스택은 메모리의 높은 주소에서 낮은 주소로 성장하는데 스택 포인터 %rsp는 스택의 최상단(가장 낮은 주소)을 가리킨다.
높은 주소
+------------------+
| 이전 프레임 |
+------------------+
| 반환 주소 |
+------------------+
| 저장된 레지스터 |
+------------------+
| 지역 변수 |
+------------------+
| 인자 빌드 영역 |
+------------------+ <- 스택 포인터 (%rsp)
낮은 주소
프로시저 호출과 반환은 두 명령어로 구현된다.
call <label>: 다음 명령어의 주소(반환 주소)를 스택에 푸시하고 로 점프한다.ret: 스택에서 주소를 팝하고 그 주소로 점프한다.예를 들어:
call multstore ; 함수 호출
...
ret ; 함수에서 반환
call 명령어의 동작을 자세히 살펴보면:
ret 명령어의 동작:
이 메커니즘은 함수 호출의 중첩을 자연스럽게 지원한다.
x86-64에서는 최대 6개의 정수 또는 포인터 인자를 레지스터를 통해 전달할 수 있다.
7번째 이후의 인자는 스택을 통해 전달됩니다. 스택을 통해 전달되는 인자는 오른쪽에서 왼쪽으로(즉, 마지막 인자부터) 푸시됩니다.
반환 값은 %rax 레지스터를 통해 전달됩니다. 64비트보다 작은 정수 값을 반환할 때는 %eax, %ax, %al 레지스터의 해당 부분이 사용됩니다.
예를 들어, 다음과 같은 함수가 있다고 가정해 보면
long func(long a, long b, long c, long d, long e, long f, long g, long h) {
return (a + b + c + d + e + f + g + h);
}
이 함수를 호출하는 어셈블리 코드는 다음과 같다.
movq $8, %r9 ; f를 %r9에 저장
movq $7, %r8 ; e를 %r8에 저장
movq $6, %rcx ; d를 %rcx에 저장
movq $5, %rdx ; c를 %rdx에 저장
movq $4, %rsi ; b를 %rsi에 저장
movq $3, %rdi ; a를 %rdi에 저장
pushq $10 ; h를 스택에 푸시
pushq $9 ; g를 스택에 푸시
call func
addq $16, %rsp ; 스택 정리
함수 내에서 지역 변수를 위한 공간이 필요한 경우, 스택 프레임에 할당한다.
예를 들어:
long swap_add(long *xp, long *yp) {
long x = *xp;
long y = *yp;
*xp = y;
*yp = x;
return x + y;
}
위 함수의 어셈블리 코드
swap_add:
movq (%rdi), %rax # x = *xp
movq (%rsi), %rdx # y = *yp
movq %rdx, (%rdi) # *xp = y
movq %rax, (%rsi) # *yp = x
addq %rdx, %rax # return x + y
ret
이 예제에서는 지역 변수를 위한 추가적인 스택 공간이 필요하지 않지만 더 복잡한 함수에서는 스택에 지역 변수를 할당해야 할 수 있다.
complex_func:
pushq %rbp
movq %rsp, %rbp
subq $16, %rsp # 16바이트 스택 공간 할당
...
movq %rbp, %rsp
popq %rbp
ret
x86-64는 레지스터 사용에 대한 규약을 가지고 있다.
호출자 저장 레지스터: %rax, %rdi, %rsi, %rdx, %rcx, %r8, %r9, %r10, %r11
피호출자 저장 레지스터: %rbx, %rbp, %r12, %r13, %r14, %r15
예시
P:
pushq %rbp # 피호출자 저장 레지스터 보존
pushq %rbx
... # 함수 본문
popq %rbx # 원래 값 복원
popq %rbp
ret
이 규약을 통해 함수 호출 간의 상호 작용이 원활해지고, 재귀 호출도 가능해진다.
재귀 함수는 일반적인 함수 호출 메커니즘을 그대로 사용한다. 각 재귀 호출은 새로운 스택 프레임을 생성하므로, 지역 변수와 반환 주소가 독립적으로 유지된다.
예시 : 팩토리얼 함수
long rfact(long n) {
if (n <= 1)
return 1;
else
return n * rfact(n-1);
}
어셈블리 코드
rfact:
pushq %rbx # 피호출자 저장 레지스터 보존
movq %rdi, %rbx # n을 %rbx에 저장
movl $1, %eax # 반환 값을 1로 초기화
cmpq $1, %rdi # n과 1 비교
jle .L2 # n <= 1이면 반환
leaq -1(%rdi), %rdi # n-1 계산
call rfact # rfact(n-1) 재귀 호출
imulq %rbx, %rax # n * rfact(n-1) 계산
.L2:
popq %rbx # 원래 %rbx 값 복원
ret
이 코드에서 각 재귀 호출은 새로운 스택 프레임을 생성하고, 반환 주소와 %rbx의 값을 저장합니다. 이를 통해 각 호출의 상태가 독립적으로 유지됩니다.
x86-64 ABI(Application Binary Interface)는 함수 호출 시점에 스택 포인터가 16바이트 경계에 정렬되어야 한다고 규정합니다. 이는 특정 SSE(Streaming SIMD Extensions) 명령어의 성능을 최적화하기 위함입니다.
예를 들어:
func:
pushq %rbp
movq %rsp, %rbp
subq $24, %rsp # 24바이트 할당 (16의 배수로 정렬)
...
movq %rbp, %rsp
popq %rbp
ret
x86-64 아키텍처는 스택을 사용하여 지역 변수와 반환 주소를 관리하고, 레지스터를 통해 빠른 인자 전달을 실현하고 또한 재귀 호출도 자연스럽게 지원한다.
총 메모리 사용량이 1GB를 초과하여 시스템이 불안정해지고, SSH 연결이 끊어질 수 있음
AWS EC2 인스턴스와 같은 클라우드 환경에서 RAM이 부족할 때 스왑 공간을 추가해 사용
앞서 제시한 스왑 공간 생성 명령어를 사용
#2G의 swap 공간 할당
sudo fallocate -l 2G /swapfile
#system만 변경할수 있도록 권한설정
sudo chmod 600 /swapfile
#swap 연결
sudo mkswap /swapfile
#ram 스왑 시작
sudo swapon /swapfile
이렇게 하면
실제 사용 예
free -h 명령어로 메모리 상태를 확인 가능
$ free -h
total used free shared buff/cache available
Mem: 982Mi 823Mi 72Mi 1.0Mi 86Mi 158Mi
Swap: 2.0Gi 0B 2.0Gi
VS Code와 Flask를 실행한 후 다시 확인
$ free -h
total used free shared buff/cache available
Mem: 982Mi 957Mi 11Mi 1.0Mi 13Mi 24Mi
Swap: 2.0Gi 234Mi 1.8Gi
이 예시에서 볼 수 있듯이, 실제 RAM이 거의 다 사용되었지만 스왑 공간 덕분에 시스템이 계속 안정적으로 작동할 수 있음
주의할 점:
이런 방식으로 제한된 리소스의 AWS EC2 환경에서도 개발 작업을 수행할 수 있습니다. 하지만 프로젝트의 규모가 커지면 더 큰 인스턴스로의 이전을 고려해야 함