프로그램의 메모리는 보통 운영체제(Operating System, OS)가 다음과 같은 일반적인 배치로 매핑해요:
| 주소 | 메모리 영역 |
|---|---|
| 높음 | 스택 |
| ... | |
| 힙 | |
| 읽기-쓰기 세그먼트 | |
| 코드/읽기 전용 세그먼트 | |
| 낮음 | 예약됨 |
세그먼트로 나뉜 메모리는 서로 다른 권한을 가진 섹션들로 구성돼요.
지금까지 정의한 함수는 모두 section .text에 있었어요. 이 섹션은 읽기 전용의 실행 가능한 데이터를 담아요. 다른 섹션은 데이터 변수를 선언하는 데 사용되는데, 읽기 전용일 수도 있고 읽기-쓰기가 가능할 수도 있지만 실행할 수는 없어요.
초기화된 데이터는 section .data에 선언해요.
NASM(The Netwide Assembler, 이 트랙에서 사용하는 어셈블러)에서는 초기화된 변수가 이름, 데이터 크기를 나타내는 지시어, 그리고 쉼표로 구분된 값들의 목록으로 이루어져 있어요.
이 요소들은 서로 공백으로 구분하고, 레이블 뒤에는 선택적으로 :를 붙일 수 있어요.
주요 지시어와 그에 대응하는 데이터 크기는 다음과 같아요:
| 지시어 | 크기 |
|---|---|
| db | 1바이트 |
| dw | 2바이트 |
| dd | 4바이트 |
| dq | 8바이트 |
예를 들어, 다음은 space라는 이름의 바이트 변수를 값 10으로 선언해요:
section .data
space db 10
section .data에 선언한 변수는 가변적이에요. 즉, 읽기-쓰기가 가능해요.
또한 정적 저장 기간을 가지는데, 이는 프로그램이 실행되는 동안 내내 존재한다는 뜻이에요.
section .rodata는 section .data와 비슷해요.
두 섹션 모두 초기화된 데이터를 담고, 같은 방식으로 선언하며 저장 기간도 같아요.
둘의 가장 큰 차이는 section .rodata의 데이터가 불변이며, 즉 읽기 전용이라는 점이에요.
equ로 정의한 상수는 section .rodata에 정의한 상수와 달라요.
equ로 정의한 상수는 메모리 공간을 차지하지 않고, 어셈블러가 그 값을 직접 대입해요.
사실 그 값의 자리 표시자일 뿐이에요.
반면에 section .rodata에 정의한 상수는 실제로 메모리에 저장되고 주소를 가져요.
선언한 데이터에는 반드시 이름이 연결되어 있어야 해요. 이 이름을 레이블이라고 해요.
레이블은 메모리에서 데이터의 구체적인 주소를 나타내는 기호예요. x86-64에서 주소는 64비트 값이에요.
NASM에서는 레이블로 데이터에 직접 접근하려고 하면 할당된 메모리가 아니라 그 주소를 얻어요:
section .data
example dq 27 ; this declares a 8-byte variable initialized with 27
section .text
fn:
mov rax, example ; this stores the address of the declared variable in rax, not its contents
...
메모리 주소의 내용에 접근하려면 그것을 _역참조_해야 해요. 이것을 간접 참조라고 해요.
NASM에서는 []로 해요:
section .data
example dq -27 ; this declares a 8-byte variable initialized with -27
section .text
fn:
mov rax, [example] ; this dereferences example and access the value stored in memory (-27)
...
그런데 역참조하는 메모리의 크기가 모호해질 수 있는 상황도 있어요. 그럴 때는 이 크기를 지정하는 접두사를 사용해야 해요.
일반적인 x86-64 프로그램에서 가장 중요한 접두사와 그 크기는 다음과 같아요:
| 접두사 | 크기 |
|---|---|
| byte | 1바이트 |
| word | 2바이트 |
| dword | 4바이트 |
| qword | 8바이트 |
같은 로드를 크기를 명시해서 쓸 수도 있어요:
mov rax, qword [example] ; same dereference, size stated explicitly
메모리를 역참조할 때는 항상 접두사를 사용하는 것이 좋아요.
메모리에 쓰는 것도 주소를 역참조하는 같은 방식으로 해요:
section .data
example1 db 10 ; example1 is a 1-byte memory location initialized with value 10
example2 dq -456 ; example2 is a 8-byte memory location initialized with value -456
example3 dd 54 ; example3 is a 4-byte memory location initialized with value 54
section .text
fn:
mov byte [example1], 20 ; example1 now has value 20
mov qword [example2], rdx ; example2 now has value equal to the contents in rdx
mov dword [example3], eax ; example3 now has value equal to the contents in eax
대부분의 명령어에서 내용을 먼저 레지스터에 로드하지 않고도 메모리 피연산자를 사용할 수 있다는 점을 기억해 둬요. 하지만 보통은 원본 피연산자와 목적지 피연산자 양쪽에 모두 사용할 수는 없고, 둘 중 하나에만 쓸 수 있어요:
section .data
example4 dw 4
example5 dq -8
example6 dd 15
section .text
fn:
add word [example4], 5 ; example4 is now a 2-byte memory location with the value 4 + 5 = 9
imul rax, qword [example5] ; rax = rax * (-8)
; this is not possible -> sub dword [example6], dword [example6]
mov로도 변수의 주소를 레지스터에 저장할 수 있지만, 이 목적만을 위한 명령어가 있어요: lea.
이 명령어는 메모리 형식의 피연산자를 사용하지만, 메모리를 읽지는 않아요. 대신 유효 주소 식을 계산해서 그 결과를 목적지 피연산자에 써요:
lea rax, [example] ; this stores the address of 'example' in rax
메모리 주소를 계산해서 레지스터에 저장할 때는 lea를 사용하는 편이 더 자연스러워요.
메모리 위치에 접근할 때 NASM의 기본 동작은 절대 주소, 즉 고정된 메모리 주소를 생성하는 거예요.
보안상의 이유로 실행 파일은 흔히 **PIE(Position Independent Executable)**로 빌드되는데, 이때 메모리 영역은 무작위로 정해진 위치에 놓여요.
PIE에서는 링크할 때 변수의 최종 주소를 알 수 없어요.
그래서 코드는 다음에 실행할 명령어를 가리키는 rip라는 특별한 레지스터의 값으로부터의 오프셋으로 주소를 계산해요.
이것을 보통 RIP 상대 주소 지정이라고 해요.
NASM에서는 rel 연산자로 RIP 상대 접근을 요청할 수 있어요:
mov rax, qword [rel variable]
소스 파일 맨 위에 default rel을 두면 상대 주소 지정을 기본값으로 만들 수도 있어요.
이 트랙의 모든 연습 문제는 PIE로 컴파일하고 링크하기 때문에, 상대 주소를 생성하려면 rel을 사용해야 해요.
어떤 섹션(예: .text, .data, .rodata)에 정의한 레이블(함수와 데이터)이든 같은 소스 파일 안에서는 보여요.
global로 선언하면 다른 소스 파일에서도 보여요.
반대로 다른 소스 파일에 정의한 레이블은 extern으로 선언하면 현재 소스 파일에서 볼 수 있어요.
이 경우 어셈블리에는 데이터 크기를 나타내는 표시가 없으니, 크기를 미리 알고 있어야 해요.
default rel
section .data
global number1 ; 'number1' is a variable visible to other source files
number1 db 200
extern number2 ; 'number2' is a variable visible to the current source file, but defined in another
section .text
extern sum ; sum is a function visible to the current source file, but defined in another
fn:
mov dil, byte [number1]
mov sil, byte [number2]
call sum
...
친구 José는 동네 학교의 선생님이에요. 색을 조합해서 다른 색을 만드는 방법을 보여 줄 재미있는 실험 아이디어가 떠올랐어요.
그래서 그 실험들을 함께 도와달라고 부탁했어요.
이 연습 문제에서 색은 RGB 값을 담은 32비트(4바이트) 숫자로 표현돼요.
RGB 값은 Red, Green, Blue 세 개의 채널로 이루어져 있고, 각 채널은 8비트(1바이트)를 차지해요.
네 번째 바이트는 보통 Alpha 채널을 위해 남겨 두지만, 이 연습 문제에서는 값이 비어 있어요(0).
각 색의 값은 다른 소스 파일에 정의된 표에 이미 저장되어 있어요. 색은 이 표에서 고유한 주소로 식별돼요.
색의 32비트 값을 반환하는 함수 get_color_value를 정의해요.
이 함수는 색 표에서 해당 색의 유효한 주소를 매개변수로 받아요.
get_color_value(black)
// => 0
힌트 - 32비트는 4바이트와 같아요.
여러 색을 섞기 위해 José는 먼저 기준이 될 색을 정하고, 그 색과 조합할 보조 색만 바꿀 거예요.
색의 32비트 값을 변수 base_color에 저장해서 나중에 쓸 수 있게 하는 함수 add_base_color를 정의해요.
이 함수는 반환값이 없고, 색 표에서 색의 주소를 매개변수로 받아요.
변수 base_color는 직접 정의하고, 다른 소스 파일에서도 접근할 수 있어야 해요.
기본 색은 한 번에 하나만 있을 수 있어요. 새로운 기본 색을 추가하면 이전 색은 버려져요.
기본적으로 프로그램이 시작될 때 base_color는 _white_의 32비트 값인 0xFFFFFF00으로 초기화되어야 해요.
힌트 - NASM은 0xFFFFFF00처럼 앞에 0x를 붙여 정의한 16진수를 받아들여요.
José는 원색을 사용해 많은 조합을 만들 계획이라, 빠르게 접근할 수 있도록 원색을 따로 두고 싶어 해요.
색을 표현하는 데 RGB를 사용하므로, 원색은 다음과 같아요:
RED, 값은 0xFF000000.GREEN, 값은 0x00FF0000.BLUE, 값은 0x0000FF00.이 색들 각각에 대해 상수를 하나씩 정의해요. 이 상수들은 다른 소스 파일에서도 접근할 수 있어야 해요.
색은 다른 소스 파일에 정의된 combining_function에 따라 조합되어야 해요.
이 함수는 base_color의 32비트 값과 함께 섞을 보조 색의 32비트 값을 매개변수로 받아요.
그리고 조합된 색의 32비트 값을 반환해요.
두 색을 조합하고 그 결과를 메모리에 저장하는 함수 make_color_combination을 정의해요.
이 함수는 반환값이 없고, 다음 순서로 매개변수를 받아요:
combining_function이 사용 중인 레지스터의 값을 바꿀 수 있다는 점에 주의해요.
함수를 호출하기 전에 필요한 변수를 반드시 메모리에 저장해 둬요.
Exercism에 가입하고 x86-64 Assembly 트랙을 개념 22개연습 문제 130개, 그리고 실제 사람의 멘토링과 함께 배우고 익혀 보세요. 모두 무료예요.