[Week6 - CSAPP] 3.4 - 여친 만드는 법 : 어셈블리어 배우기

2025. 10. 18. 06:46CSAPP

이제 CS:APP의 노잼 부분의 시작 부분인 3.4장이다.

 

이번 부분 부터는 어셈블리어의 구성에 대해 좀 더 깊게 다룬다.

 

하지만 이걸 읽는 당신이 어셈블리어를 마스터 한다면, 

 

당신은 그것만으로도 천재 프로그래머이자,

 

알파메일 그자체인 기가차드 같은 프로그래머가 될 수 있다.

 

만약 누군가가 당신의 주 언어에 대해 물어봤다고 하자.

 

그 때 당신이 파..파이썬이요....(물론 파이썬은 너무 좋은 언어다. 그리고 생각보다 문법이 어렵다.)

 

하는 것 보다

 

어셈블리.

 

 

 

https://www.youtube.com/watch?v=QJJYpsA5tv8

 

이러면 당신은 그 순간 애인이 생기게 된다. 그것도 코딩으로.

 

쩔지 않는가?

 

그럼 모두의 솔로 탈출을 기원하며 3.4장에 대해 설명해 보도록 하겠다.

 

3.4장은 데이터에 접근하고, 이동하는 방법에 대해 다룬다.

 

우선 이번 파트를 배우기 전에, 오퍼랜드에 대해 알아둬야 한다.

 

오퍼랜드(= 피연산자)란,

 

연산의 대상이 되는 존재로,

 

예시를 들어서 설명하자면,

 

3 + 6

 

이라는 수식에서 

 

연산자는 덧셈을 뜻하는 "+" 이고,

 

피연산자는 이 덧셈이라는 연산을 적용 받는 "3"과 "6"이 된다.

 

프로그래밍으로 예시를 들어보자면, 

int x;

x += 10;

 

정수형 변수 x에 10을 더하고, 그 값을 x에 저장하라는 이 코드에서,

 

연산자는 값을 더하고 저장하라는 뜻의 "+=" 이고,

 

피연산자는 그 연산을 적용 받는 변수인 "x"이다.

 

어셈블리어에서도 이러한 피연산자가 있는데,

movl $5, %eax

 

%eax라는 레지스터에 숫자 5를 저장하라는 이 명령어에서,

 

피연산자는 값이 저장될 목적지인 레지스터 %eax 와,  (이를 목적지 오퍼랜드라고 한다.)

 

값의 소스이자, 복사할 데이터가 되는 5  (이를 소스 오퍼랜드라고 한다.)

 

이렇게 2개가 된다.

 

당연하게 연산자는 값을 복사하라는 명령어인 movl이 된다.

 

요약하면,

 

연산자는 어떤 동작이고,

 

오퍼랜드(=피연산자는) 그걸 당하는 친구다.

 

영어에서의 동사(> 연산자)와 명사(> 피연산자)와 비슷하다.

 

 

불쌍한 친구다...

 

이제 이 불쌍한 친구를 숙지하고 3.4장을 공부해보자.


1. 16개의 범용 레지스터

x86-64 아키텍처 cpu에는 16개의 64비트 범용 레지스터가 존재한다.

 

%rax
%rbx
%rcx
%rdx
%rsi
%rdi
%rbp
%rsp
%r8
%r9
%r10
%r11
%r12
%r13
%r14
%r15

 

이렇게 16개가 있다.

 

각각 레지스터들은 이름 앞에 %를 가지고 있는데, 이는 이게 레지스터임을 표시해주는 일종의 기호이다.

 

즉, rax는 그냥 변수나 상수의 이름을 나타내지만, %rax는 레지스터 rax를 나타낸다.

 

이 레지스터들은 각각 1바이트, 2바이트, 4바이트, 8바이트 단위로 접근할 수 있는데,

  • %rax → 8바이트
  • %eax → 하위 4바이트
  • %ax → 하위 2바이트
  • %al → 하위 1바이트

이런식으로 각각 다른 역할을 가지며 접근한다.

 

예를 들어, 4바이트 값을 생성하는 명령은 64비트 레지스터의 상위 4바이트를 자동으로 0으로 설정한다.

 

이를 어셈블리 코드 예시로 표현하면,

movl $5, %eax    # %rax = 0x0000000000000005 (상위 4바이트 자동 0으로 설정)

 

이렇게 되는데,  movl $5, %eax를 실행하면 %rax의 하위 4바이트에 5가 저장되고, 상위 4바이트는 0이 된다.


2. 오퍼랜드 지정자

인스트럭션은 데이터를 읽거나, 저장할 때 위에서 설명한 오퍼랜드를 지정한다.

 

이 오퍼랜드에는 아래와 같은 3가지 유형이 있다

유형 표기 예시 의미
즉시값 $5, $0x10 코드 안에 그 자체로 값이 적혀 있는 상수
레지스터 %rax, %rbx 레지스터에 저장된 값
메모리 참조 8(%rbp), (%rax, %rdx, 4) 메모리 주소를 계산해서 그 위치의 데이터를 사용함

 

여기서 메모리 참조는 수식들을 통해 유효 주소를 구해서 그 값을 읽는 방식으로,

 

8(%rbp)는 %rbp에서 8바이트 떨어진 메모리 주소를 참조하라는 뜻으로 유효주소는 8 + %rbp의 주소가 된다.

 

이런 방식은 기준 주소 + 오프셋을 통한 가장 단순한 메모리 참조 방식으로,

 

함수의 지역 변수나 매개변수 접근할 때 이런 식으로 많이 쓴다.

movq 8(%rbp), %rax ; %rbp 에서 8바이트 떨어진 메모리의 주소에 있는 값을 %rax에 저장

 

이런식으로 사용된다.

 

(%rax, %rdx, 4)는 %rax + (%rdx * 4) 위치를 참조하라는 뜻으로,  유효주소는 %rax 주소 + (%rdx(인덱스)* 4)가 된다.

 

이런 방식은 기준 주소(%rax), 인덱스(%rdx), 스케일(4)을 통한 방식으로,

 

주로 기준 주소를 배열의 시작 주소, 인덱스를 배열의 인덱스 값, 스케일을 각 원소의 크기로 사용한다.

 

(%rax, %rdx, 4)는 %rax로 부터 시작하고, 원소의 크기가 4인 배열에서 %rdx번째 원소를 참조 할 때 사용할 수 있다.

movl (%rax, %rdx, 4), %ecx : %rax + (%rdx * 4) 위치에 있는 값을 ecx에 넣어라.

 

이렇게 사용된다.

 

위 예시 코드를 c코드로 표현하면,

ecx = array[rdx]

 

가 된다.


3. 데이터 이동 명령어

여러 명령어중 가장 많이 쓰이는 명령어는 데이터를 한 위치에서 다른 위치로 복사하는 mov계열 명령어이다.

 

이 mov 계열 명령어는 다음과 같은 명령어들이 존재한다.

 

명령어 크기 설명
movb 1바이트 바이트 이동
movw 2바이트 워드 이동
movl 4바이트 더블워드 이동
movq 8바이트 쿼드워드 이동

 

이런 mov 명령어들에게는 메모리와 메모리 간 직접 이동은 불가능하고,

 

반드시 레지스터를 중간에 거쳐야 한다는 규칙이 있다.

 

또한 더블워드를 복사하는 movl은 피연산자가 레지스터 일 때, 상위 4바이트를 0으로 만든다.

 

이런 mov 계열 명령어는 아래와 같은 5가지 형태의 복사가 이루어진다.

  1. 즉시값 → 레지스터
  2. 레지스터 값 → 레지스터
  3. 메모리 값 → 레지스터
  4. 즉시 값 → 메모리
  5. 레지스터 값 → 메모리

4. 부호 확장과 제로 확장

작은 크기의 데이터를 큰 크기의 레지스터로 옮기려면 어떻게 해야할까?

 

이걸 해결하는 방법은 부호 확장과 제로 확장이 있다.

 

부호 확장은 +.-의 부호를 나타내는 최상위 비트인 부호 비트를 복사해서 채우는 방식으로,

 

기존 데이터가 음수 였다면, 음수를 유지할 수 있다.

0x7F → 0000 0000 0000 007F
0x80 → FFFF FFFF FFFF FF80

 

위와 같이 1바이트를 8바이트로 바꾼다고 하면,

 

127을 뜻하는 0x7F는 양수이기에, 양수를 뜻하는 0으로 가득 채워, 0000 0000 0000 007F로 바뀌고 

 

-128을 뜻하는 0x80은 음수이기에, 음수인 1을 뜻하는 1을 4개 묶은 F로 가득 채워 FFFF FFFF FFFF FF80으로 바뀐다.

 

아래와 같은 방식으로 쓰인다.

movsbq %dl, %rax ; 1바이트 값인 %dl을 8바이트 레지스터인 %rax로 부호 확장하여 복사

 

다른 방식인 제로 확장은 상위 바이트를 전부 0으로 채우는 방식으로, 

 

부호 비트 또한 0으로 채우기에, 항상 양수가 된다.

0x7F → 0000 0000 0000 007F
0x80 → 0000 0000 0000 0080

 

위와 같이 127을 뜻하는 0x7F를 0000 0000 0000 007F로 바꾸고,

 

-128을 뜻하는 0x80을 0000 0000 0000 0080으로 바꾸어, 128이 된다.

 

아래와 같은 방식으로 쓰인다.

movzbq %dl, %rax ; 1바이트 값 %dl을 쿼드워드 레지스터인 %rax에 제로 확장으로 복사

 

보통 signed 타입은 movs류 명령인 부호 확장을 사용하고,

 

unsigned 타입은 movz류 명령인 제로 확장을 사용한다.


5. 예제 - exchange 함수

지금 까지 배운 mov류 인스트럭션을 활용하여 아래의 c 코드를 어셈블리어로 바꿔보자.

long exchange(long *xp, long y) 
{
    long x = *xp;
    *xp = y;
    return x;
}

 

이 함수는 xp를 y로 바꾸고, 원래 xp 위치의 값을 리턴하는 함수이다.

 

이를 어셈블리어로 바꾸면,

# xp in %rdi, y in %rsi
exchange:
    movq (%rdi), %rax   # x = *xp   (메모리→레지스터)
    movq %rsi, (%rdi)   # *xp = y   (레지스터→메모리)
    ret                 # return x  (%rax로 반환)

 

이렇게 된다.

 

여기서 또한 한번 알아둬야 할 점은 바로 메모리에서 메모리로의 데이터 복사는 불가하다는 것이다.

 

반드시 레지스터를 거쳐야 한다.

 

그래서 이 코드 또한 %rax 레지스터를 통해 메모리 to 메모리로 전달 되도록 한다.


6. 스택 연산

후입선출 구조의 스택은 메모리에서 함수 호출을 관리하기 위한 영역으로,

 

함수가 실행될 때 지역 변수, 인자, 복귀 주소 등 함수 실행에 필요한 값들을 잠시 저장해두는 공간이다.

 

스택은 방금도 말했다시피, 후입선출 구조이기에, 

 

아래 방향(= 작은 주소)으로 자라나고 위쪽(= 큰 주소)으로 줄어든다.

 

push를 통해 스택에서 데이터를 저장하고,

 

pop을 통해 스택에서 데이터를 꺼낸다.

 

스택의 가장 위를 가리키는 레지스터는 %rsp인데, 이 레지스터를 스택 포인터 라고 부른다.

 

push, pop 명령은 항상 이 %rsp를 자동으로 업데이트 한다.

 

 예를들어, pushq S 라는 명령을 내리면, S를 스택에 저장하는데,

 

내부적으로는 %rsp는 %rsp - 8이 되고, 원래 %rsp위치에 S가 저장된다.

 

또한 popq D 라는 명령을 내리면, 스택의 값을 꺼내서 D에 저장하는데,

 

내부적으로는 D가 %rsp의 주소에 위치 하게 되고, %rsp는 %rsp + 8이 된다.

 

요약하면,

 

push는 스택 포인터를 8 감소시킨 후 값을 저장하고,


pop은 현재 위치의 값을 꺼내고 포인터를 8 증가시킨다.

 

이러한 함수의 지역 데이터를 임시 저장해주는 스택은 

 

함수 호출시 복귀 주소를 스택에 저장해두고,

 

지역변수와 임시 저장 값을 스택에 할당하여 저장해주고,

 

함수 종료시에 스택에 있던 주소로 점프시켜주기에,

 

함수 호출이 중첩(예: 재귀)돼도 각 함수의 상태가 독립적으로 보존되게 해준다.

 

위에서 말했듯 호출(call)과 종료(ret)또한 복귀주소를 push(call)하고, pop(ret)하는 형태로 스택을 사용한다.


오늘도 좀 다루는게 많았는데 긴 글 읽어줘서 고맙다.

 

즐코딩.