컴퓨터는 어떻게 코드를 실행할까

2022-02-06

개요

이번 포스트의 주제는 ‘컴퓨터는 어떻게 코드를 실행할까?’ 입니다

왜 뜬금없이 이런 주제를 공부했냐? 라고 물으신다면,

유튜브를 돌아다니다 우연히 발견한 한 영상 때문입니다

<center> <iframe width="560" height="315" src="https://www.youtube.com/embed/YBYI7E2PqWE?start=330" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe> </center>

오징어 게임을 패러디한 이 영상은 프로그래머들이 어셈블리어로 Fizzbuzz 프로그램을 작성하는 상황이 연출됩니다

그 중 한 장면이 제 마음을 불편하게 했습니다

A: 나 어셈블리어 할줄몰라
B: 컴퓨터구조 시간에 배웠던걸 기억해내봐
A: 몰라 난 부트캠프 출신이라고

안타깝게도 저는 부트캠프 출신이고, 컴퓨터구조 강의도 수강했지만 어셈블리어를 할 줄 몰랐습니다

이에 양심이 크게 아려와 어셈블리어를, 나아가 컴퓨터가 우리 코드를 실행하는 과정을 공부해봤습니다

어셈블리어와 기계어

컴퓨터는 데이터를 비트 단위의 이진수로 처리합니다

이 이진수를 통틀어 기계어라 부릅니다

기계어는 CPU가 직접 해독하고 실행할 수 있으므로 CPU의 종류에 따라서 다른 형태를 가집니다

기계어와 같이 컴퓨터가 이해할 수 있는 언어는 저급 언어라 불리며, 우리가 작성한 코드가 실행되기 위해선 이러한 저급 언어로 변환하는 과정이 필요합니다

어셈블리어는 대표적인 저급 언어입니다

어셈블리어는 기계어와 1대1로 대응되는 언어로, 기계어와 마찬가지로 CPU의 종류에 따라 코드가 달라집니다

명령어

어셈블리어에서 기계어로의 번역은 어셈블 이라 부르고, 어셈블러 라는 프로그램을 이용합니다

제가 컴퓨터구조 강의시간에 배운 MIPS 아키텍처를 예시로 어셈블리어가 어떻게 기계어로 변환되는지 알아보겠습니다

MIPS 아키텍처는 밉스 테크놀로지에서 개발한 RISC ISA로, R, I, J 의 명령어 포맷이 있습니다

MIPS ISA R format

1.png

MIPS Green Sheet 에서 가져온 R format 구조입니다

각 파트가 의미하는 바는 아래와 같습니다

또한 레지스터별 번호 할당은 아래 표와 같습니다

2.png

add $t0, $s1, $s2 의 명령어를 기계어로 변환해보겠습니다

3.png

add 는 rd = rt + rs 를 나타내는 명령어고 opcode = 0, funct = 0x20 이므로

opcode: 0 rs: 17 rt: 18 rd: 8 shamt: 0 funct: 32 (0x20 = 32) 입니다

따라서 이를 비트수에 맞게 이진수로 변환하면

opcode rs    rt    rd    shamt funct
000000 10001 10010 01000 00000 100000

가 되므로,

결국 add $t0, $s1, $s2 명령어는 00000010001100100100000000100000 로 표현됩니다

x86_64

제 인텔 맥북의 명령어 아키텍처는 64비트 x86 명령어셋 으로, x86_64 라 불립니다

이 명령어셋은 최초로 AMD에 의해 AMD64라는 이름으로 고안되었습니다

우리가 사용하는 macos의 명령어셋은 Intel 64라는 이름으로 인텔이 AMD로부터 라이선스를 받아 만들어낸 명령어 셋입니다

흔히 i3, i5 등으로 표현하는 인텔 코어 i3, 인텔 코어 i5 등등의 CPU가 이 아키텍처를 사용합니다

컴파일

어셈블리어로 프로그램을 작성해보기 전, c언어로 쓴 코드가 기계어로 만들어지기까지의 과정을 알아보겠습니다

어셈블리어로 코드를 쓴다면 위 과정에서 전처리와 컴파일 과정을 건너뛰게 됩니다

어셈블 작업을 위한 다양한 어셈블러가 있지만, 저는 nasm 이라는 프로그램을 사용했습니다

nasm 은 netwide assembler로 intel x86 아키텍처에서 많이 사용되는 어셈블러입니다

또한 링크 작업을 위해 ld 를 사용했습니다

ld 는 GNU 컴파일러 모음(gcc)의 일부로, 목적파일들로부터 실행 파일을 생성하는 링커를 실행해줍니다

Hello world

어셈블리어로 hello world 프로그램을 작성해보겠습니다

global _main

	section .text

_main:
	mov     rax, 0x2000004
	mov     rdi, 1
	mov     rsi, msg
	mov     rdx, 14
	syscall

	mov     rax, 0x2000001
	mov     rdi, 0
	syscall

	section .data

msg:    db      "Hello, world!", 10

위 어셈블리 코드를 hello.asm 에 저장합니다

c언어의 컴파일 과정에서 컴파일 단계까지 마무리된 것과 같은 상황이므로, nasm 을 이용해 object 파일로 만들어줍니다

$ nasm -f macho64 hello.asm

저는 64비트 macos 를 사용하므로, 출력 object 파일 포맷을 macho64 로 지정합니다

이 명령어를 수행하면 hello.o 라는 object 파일이 생성됩니다

이를 실행가능하도록 만들기 위해선 링크과정을 거쳐야합니다

$ ld -L /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/lib -lSystem hello.o -o a.out

GNU ld 를 이용해 하나의 바이너리로 만들어주는 명령어입니다

이 과정에서 macos 플랫폼에 맞도록 시스템 라이브러리를 링크해 a.out 이라는 이름으로 결과물을 만들어냅니다

이 명령어를 계속 반복해서 치는것은 정말 귀찮으므로, 간단하게 쉘 스크립트로 자동화해보겠습니다

nasm -f macho64 "$@.asm" \
&& ld -L /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/lib \
-lSystem "$@.o" -o a.out

위 코드를 build.sh 파일에 붙여넣은 뒤, 쉘에 ./build.sh hello을 입력하면 hello.asm 파일의 내용물을 이용해 a.out 이라는 이름의 바이너리가 만들어집니다

이제 영역별로 코드와 어떤 역할을 하는지 살펴보겠습니다

global

다른 모듈에서 심볼에 접근할 수 있도록 하기 위해 사용됩니다

어셈블리어에서는 기본적으로 모든 코드가 private 이므로 링크과정에서 올바른 심볼을 찾을수 있도록 하기 위한 부분입니다

아쉽지만 이번 예제에서는 모든 코드를 하나의 파일로 작성하였기 때문에 활용하지 못했습니다

section

레퍼런스에 따르면 section에는 data, bss, text section이 있습니다

그 중 text 영역은 실행가능한 코드가 들어가는 영역입니다

레퍼런스에서는 global _start 라는 코드로 시작해야 한다고 설명하고 있지만, 예제 코드가 이상없이 작동되는 것을 보아 플랫폼별로 스펙이 다른 것으로 생각됩니다

data 영역은 initialized data or constants, 즉 상수 또는 리터럴이 들어가는 영역입니다

data 영역에 들어간 값은 런타임에 변경되지 않는 값들입니다

상수값, 파일이름, 버퍼 사이즈 등이 이 영역에 들어갈 수 있습니다

bss 영역은 변수를 선언하는 영역입니다

위 hello world 예제에서는 나오지 않았지만, 두번째 예제에서 살펴보도록 하겠습니다

system call

mov 명령어를 이용해 특정 규칙대로 레지스터에 값을 할당하고 syscall 명령어를 통해 system call 을 호출하고 있습니다

system call은 커널이 제공하는 서비스를 응용 프로그램이 호출할 수 있는 인터페이스입니다

커널은 부팅과정에서 전체 메모리의 일부를 점유하고 동작하지만 응용 프로그램은 커널이 제공하는 자원을 사용하므로 메모리에서 상황에 따라 다른 위치를 점유하고 동작하게 됩니다

따라서 응용 프로그램은 정확한 물리 주소를 확정할 수 없고, 유저 스페이스에서 동작하므로 특정한 기계어 실행이 불가능합니다 (물리주소 → 논리주소의 변환은 MMU 라는 하드웨어 부품이 담당합니다)

이에 응용 프로그램이 파일 시스템에 접근하는 등의 동작을 하기 위해선 커널에 의존해야만 합니다

이러한 상황에서 응용 프로그램이 커널 서비스를 호출하기 위한 방법을 system call 이라고 합니다

write system call

mov     rax, 0x2000004
mov     rdi, 1            ; STDOUT
mov     rsi, msg
mov     rdx, 14
syscall

위 코드는 표준출력을 통해 터미널에 문자를 출력하기 위한 system call 입니다

4	AUE_NULL	ALL	{ user_ssize_t write(int fd, user_addr_t cbuf, user_size_t nbyte); }

위 코드는 macos의 기반이 되는 운영체제 darwin의 커널 xnu 에서 사용가능한 시스템 콜의 시그니처입니다

레퍼런스에 따르면 전달하는 인자는 순서대로 rdi, rsi, rdx, rcx, r8, r9 레지스터에 할당하면 된다고 합니다

또한 syscall 과정마다 값의 보존이 보장되지 않습니다

write system call 에 할당된 코드는 4이고, system call 의 종류는 rax레지스터를 참조합니다

또한 커널에게 표준출력을 사용한다는것을 명시하기 위해 첫번째 인자인 rdi 레지스터에 1을 할당합니다

POSIX 표준에서 1이라는 상수는 표준 출력 스트림의 file descriptor 로 정의되어 있기 때문입니다

출력할 문자열의 주소를 커널에게 알려주기 위해 rsi 레지스터에 값을 할당합니다

마지막으로 출력할 문자열의 길이를 커널에게 알려주기 위해 rdx 레지스터에 값을 할당합니다

따라서 터미널에 msg 라 정의된 문자열을 출력하기 위해 위와 같은 코드를 사용하게 됩니다

exit system call

mov     rax, 0x2000001
mov     rdi, 0
syscall

위 코드는 프로그램을 종료시키기 위한 system call입니다

출력 system call 과 비슷하게 raxrdi 레지스터에 값을 할당해주고 있습니다

1	AUE_EXIT	ALL	{ void exit(int rval) NO_SYSCALL_STUB; }

exit system call 에 할당된 코드는 1이므로 rax 레지스터에 0x2000001을 할당합니다

exit code 에 0을 주기 위해 rdi레지스터에 0을 할당하고 system call을 호출합니다

xnu 커널에서 사용가능한 모든 시스템 콜은 레퍼런스에서 확인할 수 있습니다

macro

nasm은 전처리기가 존재하기 때문에 macro 기능이 존재합니다

이를 이용해 예제 코드를 좀 더 고급 언어에 가깝게 표현하면 아래와 같습니다

%macro write_stdout 2
	mov rax, 0x02000004
	mov rdi, 1
	mov rsi, %1
	mov rdx, %2
	syscall
%endmacro

%macro exit 1
	mov rax, 0x02000001
	xor rdi, %1
	syscall
%endmacro

global _main
	section .text

_main:
	write_stdout msg, 14
	exit 0

	section .data
msg: db "Hello, world!", 10

write_stdout 이라는 매크로를 정의해 함수처럼 출력문을 사용하고 있습니다

하지만 사실은 어셈블리어에도 함수를 사용할 수 있습니다

함수 사용하기

함수는 text section의 첫부분에 _main 영역을 선언한 것과 같이 선언할 수 있습니다

이렇게 레이블을 만들어두고, call 명령어를 이용하면 함수를 호출할 수 있습니다

global _main
	section .text

_main:
	call hello
	mov rax, 0x02000001
	mov rdi, 0
	syscall

hello:
	mov rax, 0x02000004
	mov rdi, 1
	mov rsi, message
	mov rdx, 14
	syscall
	ret

	section .data
message: db "Hello world!", 10

위 hello world 예제와 정확히 똑같은 일을 하는 어셈블리 코드입니다

계속 hello world 만 찍는 것은 지루하니, 이번 포스트를 쓰게 된 계기이기도 한 Fizzbuzz코드를 짜보겠습니다

Fizzbuzz

loop 만들기

ecx 레지스터와 jump 명령어를 이용해 반복문을 구성할 수 있습니다

아래 코드는 ecx 레지스터에 초기값을 할당하고 특정 숫자가 될때까지 해당 작업을 반복하는 방식입니다

_main:
	mov ecx, 0

	call loopstart

	mov rax, 0x02000001
	mov rdi, 0
	syscall

loopstart:
	push rcx

	mov rax, 0x02000004
	mov rdi, 1
	mov rsi, hello_world
	mov rdx, hello_world_len
	syscall

	pop rcx

	inc ecx
	cmp ecx, 10
	jnz loopstart
	ret

Hello world 를 10 회 출력하는 반복문입니다

system call 과정에서 rcx 레지스터의 값이 보존되지 않는다고 했습니다

현재 루프의 반복조건에 rcx 레지스터의 일부인 ecx 레지스터의 값을 사용하고 있으므로, rcx 레지스터에 system call 전후로 push pop을 해줘서 이전 값을 사용할 수 있도록 합니다

jnz 명령어는 ZF 라는 플래그 레지스터의 값이 0이면 해당 주소로 이동하고 그렇지 않으면 이동하지 않는 명령어입니다

플래그 레지스터에 관한 정보는 레퍼런스에서 확인할 수 있습니다

나눗셈 연산하기

div_three:
	mov eax, 7
	xor edx, edx

	mov ebx, 3
	div ebx
	add eax, '0'
	mov [result], eax

	mov rax, 0x02000004
	mov rdi, 1
	lea rsi, [result]
	mov rdx, 1
	syscall
	ret

	section .bss
default rel
result: resb 4

7으로 3을 나누는 연산에 피연산자 7을 하드코딩했습니다

여기서 section 단락에서 언급했던 .bss section 이 나옵니다

bss section은 변수를 선언하는 영역이라 했습니다

위 코드의 2번째 블록의 마지막줄에서 eax 레지스터의 값을 result 변수에 할당하고 있습니다

이때 result 라는 변수는 default rel 키워드를 이용해 선언했습니다

default rel은 라벨링된 메모리 주소를 참조할때 기본적으로 rel 명령어를 사용하겠다 라는 의미입니다

macos 에서는 기본적으로 절대주소 참조를 허용하지 않기 때문에 이렇게 사용해야 한다고 합니다

result 변수를 선언할때 이를 붙이지 않으면 코드에서 result 로 접근하기 위해 mov [rel result], eax 와 같이 써주면 됩니다

또 3번째 문단의 3번째줄에서 mov 명령어 대신 lea 명령어를 사용합니다

lea 명령어는 Load Effective Address로, mov 명령어와는 다르게 값이 아닌 주소값을 참조하게 됩니다

C언어에서 일반 변수와 포인터 변수와의 관계를 생각하면 쉬울 것 같습니다

연산 이후 나눗셈 결과는 edx 레지스터와 eax 레지스터에 담기게 됩니다

edx 레지스터에는 나머지값이, eax 레지스터에는 나눗셈의 몫이 할당되므로, 필요한 값을 선택해 가져올 수 있습니다

따라서 위 코드를 실행했을 때에는 eax 에 나눗셈의 몫 2가 저장됩니다

이를 출력하기 위해 add 명령어로 숫자 타입을 문자 타입으로 바꿔 result 에 할당해줬습니다

값에 따라 분기하기

global _main
	section .text

_main:
	call div_three
	mov rax, 0x02000001
	mov rdi, 0
	syscall

div_three:
	mov eax, 7
	xor edx, edx

	mov ebx, 6
	div ebx
	mov [result], edx

	cmp edx, 0
	je hello
	jnz bye

hello:
	mov rax, 0x02000004
	mov rdi, 1
	mov rsi, hello_world
	mov rdx, hello_world_len
	syscall
	ret

bye:
	mov rax, 0x02000004
	mov rdi, 1
	mov rsi, bye_world
	mov rdx, bye_world_len
	syscall
	ret

	section .data
hello_world: db "Hello world", 10
hello_world_len: equ $ - hello_world
bye_world: db "Bye world", 10
bye_world_len: equ $ - bye_world

	section .bss
default rel
result: resb 4

조금 코드가 길어졌습니다

hellobye 는 각각 “Hello world”“Bye world”를 출력하는 함수이고, 핵심은 이 부분입니다

	cmp edx, 0
	je hello
	jnz bye

edx 가 0이라면 hello를, 그렇지 않다면 bye를 실행하도록 하는 코드입니다

위 예시에서는 7 % 6 == 0 이므로 실행하면 Bye world 가 출력될 것입니다

div_three 함수 내부에서 나눗셈의 두번째 피연산자에 해당하는 ebx에 7을 넣으면 나머지값이 0이 될 것이고 Hello world 가 출력될 것입니다

이러한 방식으로 제어 흐름을 조절할 수 있습니다

논리 AND 연산

Fizzbuzz 함수를 만들기 위해선 3으로도, 5로도 나누어떨어지는지 체크하기 위해 논리 AND 연산이 필요합니다

어셈블리어에서는 논리 AND 연산 대신 bitwise AND 연산을 사용할 수 있습니다

두 나눗셈 결과를 비교하는 코드를 작성해보겠습니다

global _main
	section .text

_main:
	call div
	mov rax, 0x02000001
	mov rdi, 0
	syscall

div:
	mov eax, 7
	xor edx, edx
	mov ebx, 3
	div ebx

	mov [result], edx ; temp

	mov eax, 7
	xor edx, edx
	mov ebx, 6
	div ebx

	cmp edx, [result]
	je hello
	jne bye

hello:
	...

bye:
	...

7을 3으로 나눈 결과를 result 에 임시로 저장하고 두번째 연산 결과와 비교한 뒤 같은 결과면 hello, 다르면 bye 를 실행하고 있습니다

엄밀한 논리 연산은 아니지만 적어도 Fizzbuzz 예제에서는 이 정도면 충분할 것 같습니다

표준 입력을 통해 입력받기

마지막으로 응용 프로그램의 사용자로부터 입력을 받는 방법을 알아보겠습니다

사용자의 입력은 read system call을 통해 받을 수 있습니다

3	AUE_NULL	ALL	{ user_ssize_t read(int fd, user_addr_t cbuf, user_size_t nbyte); }

read system call은 위와 같은 시그니처를 가지므로, 아래 코드처럼 간단하게 호출할 수 있습니다

read_input:
	mov	rax, 0x2000003
	mov	rdi, 0          ; STDIN
	mov	rsi, count
	mov	rdx, 1
	syscall
	ret

두번째 인자인 rsi 레지스터에 사용자의 입력을 전달받을 버퍼 변수를 지정하면 해당 변수에 입력값이 바이트단위로 저장됩니다

Fizzbuzz

최종적으로 사용자에게 숫자 하나를 입력받아 그 숫자까지의 fizzbuzz를 출력하는 프로그램은 아래와 같이 작성할 수 있습니다

입력 버퍼의 크기가 2 이상일 때의 경우는 해결하지 못해 한 자리수 까지만 입력받을 수 있지만, 얼추 동작하는 듯 합니다

global _main
	section .text

_main:
	call read_input

	mov ecx, [user_input]
	sub ecx, '0'
	add ecx, 1
	mov [number], ecx

	mov ecx, 1
	call start_loop

	mov rax, 0x02000001
	mov rdi, 0
	syscall

read_input:
	mov	rax, 0x02000003
	mov	rdi, 0
	mov	rsi, user_input
	mov	rdx, 1
	syscall
	ret

start_loop:
	mov [counter], ecx
	push rcx

	call div_three

	pop rcx
	inc ecx

	cmp ecx, [number]
	jne start_loop
	ret

div_three:
	mov eax, [counter]
	mov edx, 0
	mov ebx, 3
	div ebx

	cmp edx, 0
	je check_five ; divisible by three
	jne not_three ; not divisible by three
	ret

check_five:
	mov eax, [counter]
	mov edx, 0
	mov ebx, 5
	div ebx

	cmp edx, 0
	je fizzbuzz ; fizzbuzz
	jne fizz ; fizz
	ret

not_three:
	mov eax, [counter]
	mov edx, 0
	mov ebx, 5
	div ebx

	cmp edx, 0
	je buzz ; buzz
	jne just_number ; number
	ret

just_number:
	mov r10, [counter]
	add r10, '0'

	mov rax, 0x02000004
	mov rdi, 1
	mov rsi, r10
	mov rdx, 1
	syscall

	mov rax, 0x02000004
	mov rdi, 1
	mov rsi, newline
	mov rdx, 1
	syscall
	ret

fizz:
	mov rax, 0x02000004
	mov rdi, 1
	mov rsi, fizz_msg
	mov rdx, fizz_msg_len
	syscall
	ret

buzz:
	mov rax, 0x02000004
	mov rdi, 1
	mov rsi, buzz_msg
	mov rdx, buzz_msg_len
	syscall
	ret

fizzbuzz:
	mov rax, 0x02000004
	mov rdi, 1
	mov rsi, fizzbuzz_msg
	mov rdx, fizzbuzz_msg_len
	syscall
	ret

	section .data
fizz_msg: db "fizz", 10
fizz_msg_len: equ $ - fizz_msg
buzz_msg: db "buzz", 10
buzz_msg_len: equ $ - buzz_msg
fizzbuzz_msg: db "fizzbuzz", 10
fizzbuzz_msg_len: equ $ - fizzbuzz_msg
newline: db 10

	section .bss
default rel
number: resb 4
default rel
counter: resb 4
default rel
user_input: resb 4

마치며

고백하자면, 사실 위 Fizzbuzz 예제는 제대로 동작하지 않습니다

4.png

변수에 저장된 숫자 타입 값을 char 타입으로 출력해줘야하는데, 도저히 그 방법을 찾을 수 없었습니다

무책임하지만 여러 레지스터들을 올바르게 사용한것인지도 확실하지 않습니다

제가 포스트의 첫부분에서 언급한 오징어게임에 참가했다면 바로 총살당했을지도 모르겠습니다

이번 포스트는 컴퓨터가 고급 언어 코드를 이해하고 실행하는 과정에 대해 다루기로 했는데, 후반부에는 어셈블리어 코드를 쓰는데 중점을 뒀던 것 같아 아쉽기도 합니다

push와 pop 같은 스택 연산들이 정확히 어떤 일을 하는지, CPU 내부에 레지스터가 어떤 역할을 가지고 있는지, 어셈블리 코드에서 주소값 접근은 어떤 메커니즘으로 이루어지는지 등을 더 알아보면 좋겠습니다

레퍼런스

wikipedia - netwide assembler(NASM): https://en.wikipedia.org/wiki/Netwide_Assembler

NASM docs - chap4 (preprocessor): https://www.nasm.us/xdoc/2.15.05/html/nasmdoc4.html#section-4.3

wikipedia - Mach-O: https://en.wikipedia.org/wiki/Mach-O

wikipedai - system call: https://ko.wikipedia.org/wiki/시스템_호출

linux system call table for x86_64: https://blog.rchapman.org/posts/Linux_System_Call_Table_for_x86_64/

darwin-xnu system calls: https://github.com/opensource-apple/xnu/blob/master/bsd/kern/syscalls.master

NASM 으로 Hello World 출력하기: https://velog.io/@jbae/nasmhelloworld

Stack overflow - What is .word, .data and .text in assembly?: https://stackoverflow.com/questions/60403872/what-is-word-data-and-text-in-assembly

assembly basic syntax: https://www.tutorialspoint.com/assembly_programming/assembly_basic_syntax.htm

x86 & amd64 instruction reference: https://www.felixcloutier.com/x86/

learning nasm on macos: http://sevanspowell.net/posts/learning-nasm-on-macos.html

x86 flags register: http://judis.me/wordpress/x86-flags-register/

x86 어셈블리 / x86 아키텍처: https://ko.wikibooks.org/wiki/X86어셈블리/x86아키텍처

Stack overflow - Whats the purpose of the lea instruction - https://stackoverflow.com/questions/1658294/whats-the-purpose-of-the-lea-instruction

x86 Architecture guide: http://6.s081.scripts.mit.edu/sp18/x86-64-architecture-guide.html