강의/system programming

[system programming] Program의 표현과 실행(Basic of Assembly Code) - 1

하기싫지만어떡해해야지 2025. 3. 19. 14:51

본 게시글은

서울대학교 데이터사이언스대학원 정형수 교수님의

데이터사이언스를 위한 컴퓨팅 시스템 강의를

학습을 목적으로 재구성하였습니다


이제 본격적으로 우리가 프로그래밍을 하면

컴퓨터가 내부에서 어떻게 작동하고

어떻게 코드들을 처리하는지 그 과정을 배워본다

 

 

 

 

우선 내가 짠 파이썬 코드를

컴퓨터가 어떻게 해석하는지 알기 위해선

하드웨어부터 이해할 필요가 있다

 

위 ppt에 소개되어있는 하드웨어는

Intel의 x86 프로세서로

서버시장에서는 굉장히 dominate한 하드웨어라고한다

본 수업에서는 이 하드웨어를 예제로 수업을 진행한다

 

프로세서 칩의 종류는 크게

Complex Instruction Set Computer(CISC)와

Reduced Instruction Set Computer(RISC)로 나뉘는데

보통 Intel 계열의 칩을 CISC라고 하고

ARM과 같은 칩을 RISC 계열이라고 한다

 

CISC와 RISC는 명령어가 들어왔을 때

이를 처리하는 방식에서 차이가 있는데

CISC는 하나의 instruction에서 많은 기능을

수행할 수 있도록 프로그래밍 되어있다

 

instruction이 가변적이고

한 cycle에 끝나지 않고 여러 번에 걸쳐 수행하는 경우가 많은데

register를 많이 사용하지 않는다는 장점이 있다

하지만 내부적으로 구현이 복잡하기 때문에

하드웨어의 circuit이 복잡하다는 단점이 있다

 

반면에 RISC는 한 cycle에 모든 것을

끝내도록 설계가 되어있다

따라서 하드웨어 구현이 단순하고

처리가 어렵지 않다는 장점이 있지만

필요한 register의 개수가 CISC에 비해서

훨씬 많다는 단점이 있다

 

 

 

Intel x86 processor의 발전 과정이다

 

 

Intel x86 processor는 계속해서 발전하면서

core 자체의 개수가 증가하였고

이에 따라 그 안에 들어있는 cache 메모리도

각각의 core에서 사용할 수 있도록 설계되었다

 

 

 

가운데에 Shared L3 Cache를 두는

Ringbus 형식으로 설계를 했는데

이렇게 설계하면 모든 core들이 Cache 메모리를 사용할 수 있다

 

 

 

이제 또 저명한 메모리인 AMD에 대해서 살펴보자

AMD는 intel의 뒤를 따라서 개발되었는데

x86 계열과 호환 가능하게 개발되었다고한다

 

intel보다 조금 더 성능이 안좋지만

가격이 많이 저렴해서 수요가 있는 제품이라고 한다

 

AMD 프로세서는 L3 cache가 512메가 정도가 되는데

성능이 좋은 편에 속한다고 한다

 

 

이렇게 하드웨어 종류를 잠깐 살펴봤으면

이제 본격적으로 우리의 코드가

컴퓨터 내부에서 어떻게 작동하는지 알아보자

 

위 ppt는 우리가 작성한 코드가

어떤 흐름을 거쳐서 동작하게 되는지를

전반적으로 설명한 것이다

 

가장 위에 있는 것은

우리가 흔히 보는 c언어 프로그램이다

보통 사람들이 c언어를 low level language라고 하지만

c언어는 high level language에 속한다

(교수님의 생각)

왜냐면 사람이 봤을 때 이해할 수 있기 때문이다

사람이 봤을 때 직관적으로 이해하기 어려운 언어가

low level language에 속한다고한다

 

우리는 위와 같이 작성된 c언어 코드에서

변수도 선언하고 printf라는 내장함수도 사용한다

 

이 코드를 우리가 실행시키면

Memory에서 우리가 작성한 코드를 바탕으로

우리들이 작성한 변수들을

register로 넘겨준다

 

그러다가 printf함수는 함수이기 때문에

function call이 필요하고

function call을 수행하기 위해서는

지금까지 진행됐던 사항을 따로 저장을 해야한다

이렇게 하지 않으면 register에 printf function call을 위해

printf에 관한 내용을 올려야하는데

이 과정에서 기존에 올려놨던 변수들을 날려야하기 때문이다

따라서 function call 이전에 지금까지의 내용을 저장한 다음

function call을 올려서 수행하고

function call에 대한 Return 값이 날라오면

다시 이전에 저장했던 것들을 register로 돌려놓은 다음

다음 단계를 수행한다

 

그래서 function call을 많이 하면

프로그램의 퍼포먼스가 안좋아지게 된다

이런 퍼포먼스 저하를 막기 위해서 사용하는 것이

c언어의 inline 키워드이다

 

c언어의 inline 키워드는 함수의 오버헤드를 줄이기 위해 사용되는데

inline으로 선언된 함수는 함수 자체를 복사하여 사용하기 때문에

위에서 설명한 register에서 발생하는 오버헤드를 줄일 수 있다

 

하지만 inline을 사용하는 것이 무조건 좋으냐?

당연히 그렇지 않다

 

c언어에서 함수를 inline으로 처리하면

함수의 코드 내용만 복사하는 것이 아닌

내부에 있는 로컬 변수들도 모두 복사하게된다

그럼 내가 사용가능한 register의 크기보다

가져와야할 데이터의 크기가 더 많아질 수도 있다

 

이렇게 되면 앞으로 필요 없을 것 같은 값과

필요한 값을 적재적소에 배치해주는

register allocation을 진행해야하는데

이는 어떤 변수들이 제일 자주 사용될지 예측해서

register에 올려놓는 작업이기 때문에

굉장히 까다로운 task이다

 

아무튼 이런 과정을 통해서

컴퓨터의 메모리와 register는 우리가 작성한 코드를 처리하고

이는 더 컴퓨터 내부로 가서

게이트와 트랜스지터와 같은 것들에 의해 실행되게 된다

 

 

각 용어들에 대한 기본적인 정의들이다

 

여기서 주의깊게 살펴봐야하는건

machine code와 assembly code이다

 

assembly code는 우리가 이번 시간에 보게 될

add, call sub... 등과 같은 코드인데

machine code와 1대 1로 대응이 되는

사람이 보기에 좋은 text representation을 뜻한다

 

compiler는 assembly code를 가지고 optimizing을 하고

최종 마지막 code generation 때에

assembly code에 대응되는 intel machine code, arm machine code처럼 바꿔준다

 

그래서 assembly까지는 하드웨어에 국한되지않는

general한 형태이며

실제 돌아갈 때는 machine code를 이용해서 돌아간다

 

 

우리가 시스템 프로그래밍 수업을 들으며

많이 보게 될 정말 중요한 그림이다

 

CPU와 Memory가 있고

둘은 계속해서 왔다갔다하며 정보를 주고받는다

 

대표적으로 주고받는 정보는

Addresses, Data, Instructions이 있는데

data는 양방향으로 서로 주고받으며

이걸 data movement라고 한다

 

instruction은 compiler에서 나온 optimizing을 수행한 코드로

이 코드가 memory에서 CPU로 넘어가서 실행되게 된다

 

CPU에 코드가 넘어가면

현재 어떤 instruction이 실행되고 있으며

다음에 실행될 instruction은 무엇인지를 알아야하는데

이걸 가리키고 있는 것이 PC(Program Counter)이다

PC는 다음에 실행할 instruction의 주소를 가리키며

지금 실행되고있는 instruction의 size를 보고

다음 Instruction의 주소를 계산한다

이 과정을 Instruction Fetching(IF)라고 한다

 

IF의 과정은 전체 instruction을 가져오면

그 가져온 instruction을 바탕으로 코드를 해석하는

instruction decoding 과정을 거치고

어떤 것이 필요한지 알게 된 다음, 뒤에 있는 영역까지 확인하여

ready를 시키는 과정이다

 

당연하겠지만 memory와 CPU가 정보를 주고받을 때는

travel time이 소요되며

거리가 가깝지 않기 때문에 매번 정보들을 주고받을 수는 없다

 

따라서 메모리의 code 영역에 있는 instruction들은

한 번 접근이 되면 그 옆에 있는 instruction들을 한꺼번에 올려버린다

이걸 CPU 내부에 있는 cache 메모리에 올리고

이렇게 Instruction을 저장하는 cache 메모리를

Instruction Cache(ICache)라고 한다

 

Data같은 경우도 저장하는 Cache메모리가 있고

이는 DCache이다

ICache와 DCache는 절대 섞이지 않으며

크기는 ICache가 더 작다

 

 

 

위는 x86-64의 아키텍처이다

가상메모리부터 차근차근 한 번 살펴보자

 

가상 메모리 부분의 가장 위는 운영체제의 영역이고

code는 기계어로 변경된 코드가 저장되는 부분이다

 

CPU의 CP의 %rip 영역에는

이 code부분에서 현재 실행하고 있는 위치가 들어가있다

cycle을 돌 때마다 rip가 가리키고 있는 곳에서

instruction을 fetching해오고

ICache에 있으면 바로 가져오고 없으면

memory에서 다시 가져온다

그렇게 code를 가져오면 rip는 증가하는데

next instruction을 fetching해야하기 때문이다

 

Data는 우리가 선언한 global 변수들이 저장되는 곳이고

Heap은 우리가 c언어에서 자주쓰는 malloc으로 할당한

동적 변수들이 저장되는 영역이다

Stack은 자료구조 stack의 형식으로 쌓이는데

우리가 선언한 local 변수들이 stack에 저장된다

 

Heap은 데이터가 쌓일 수록 아래로 확장되고

Stack은 데이터가 쌓일 수록 위로 확장되는데

이게 중간에서 충돌하게되면

그 유명한 stack overflow가 발생한다

 

우리가 코드를 사용할 때 library를 많이 사용하면

Heap 영역이 계속 밑으로 쌓이게되는데

이게 공간이 굉장히 넓어서 괜찮다고(?)한다

 

 

이제 CPU 부분을 한 번 살펴보자

PC는 위의 메모리 영역을 설명하면서 설명했고

condition code 영역을 살펴보자

 

condition code는 결과가

음수인지, 0인지 아닌지, overflow가 발생하는지 등을 계산한다

이를 기반으로 우리가 자주쓰는

if문을 실행시킬 수 있는 것인데

condition code의 결과를 기반해서

if문에서 else로 갈지 아니면 어떤 명령어를 실행할지

결정하게 된다고 한다

 

그다음 위 그림에는 없지만

CPU에서 가장 중요한 부분 중 하나인

연산을 담당하는 ALU가 있다

ALU는 더하기, 빼기, 곱하기 등 계산에 관련된 연산을 담당하며

이 ALU는 core안에 여러 개가 들어있다

 

 

Assembly의 data type이다

내부에서 사용하는 data type은

integer와 floating point를 사용한다

 

integer는 그 비트 그대로 사용하지만

floating point는 앞에 1, 2강에서 배웠던

부동 소수점 표현 방식을 사용한다

 

 

이제 Assembly 코드의 기본적인 구조를 살펴보자

위 노란색 박스 안에 있는

addq %rbx, %rax

와 같은 구절이 assembly code이다

 

보통 가장 왼쪽이 operation을 나타낸다

위 예시의 경우 addq이므로 더하기 연산을 나타낸다

그 다음 %rbx와 %rax는 register name인데

register에 있는 주소의 이름이다

그리고 보통 통상적으로 두 register names이 있으면

앞이 source, 뒤가 destination이다

위의 예시같은 경우는 %rbx + %rax이니깐

source + deestination을 한 다음

destination에 해당하는 %rax 값에다가 

더하기 연산을 한 값을 저장하게 된다

 

이게 통상적으로 적용되는 convention이다

 

그리고 register names 앞에 r이 붙어있다면

64 bit full register라는 의미이다

 

 

앞에 r이 붙은게 64비트, 앞에 e가 붙은게 32비트를 뜻한다

 

당연히 64비트 아키텍처를 가진 하드웨어에서만

%rax, %rbx를 사용할 수 있겠지만

64비트 머신에서 e를 쓰는 것은 문제 없다

 

r을 쓰는 하드웨어, e를 쓰는 하드웨어가

따로 있다는 것이 아니라

그냥 convention하게 앞에 e가 붙어있으면

64비트 머신에서도 그냥 32비트를 사용하겠다는 뜻이 된다

 

이렇게 되도록 해놓은 이유는

이전 버전과의 호환성을 위해서이다

 

 

32비트 머신인 IA32 레지스터도 잠깐 살펴보자

오른쪽에 보면 %ah, %al이 있는데

이게 h는 high, l은 low를 나타낸다

 

네이밍이 이렇게 되어있고

정확하게 저 위치에 사용해서 알아서 값을

다 읽고 쓰고 해주는데

우리가 설정해줄 필요가 없이

컴파일러가 알아서 다 해주는거라고 한다..

(이 부분 설명을 놓쳐서 뭔소린지 잘)

 

 

Assembly의 operations을 살펴보자

 

첫 번째는 memory와 register 간의 데이터 이동인데

이를 data movement라고 한다

보통 source data를 destination으로 보내고

register와 memory의 주소값이 필요하거나

혹은 그냥 전송하려는 value 자체를 보낼 수도 있다

 

그 다음 3번째에 있는 Transfer control이 굉장이 중요한데

if문에서 조건에 맞춰서

procedure를 jump하는 것,

아니면 function을 호출해야하는 function call과 같은 것들이

여기에 해당된다

 

 

가장 먼저 Data Movement에 대해 알아보자

보통 이런 data movement operation은 언제쓰이냐?

우리가 코딩할 때 흔히 하는

assignment에서 사용된다

 

기본 assembly 문법은

movq Source, Dest

이고

mov뒤에 q가 붙은 것은

quad word인 full byte를 보내달라는 뜻이다

 

이 mov operand의 타입은 3가지가 있는데

1. Immediate

2. Register

3. Memory

이다

 

Immediate는 그냥 숫자 value 그대로를 보내는 것이다

앞에 $ 표시를 붙여서 Immediate임을 표시하고

숫자는 hex로 표기하든 decimal로 표기하든 상관없다

 

register는 %rax, %r13과 같이 사용하는데

해당 레지스터에 있는 값을 이용하라는 의미이다

 

Memory는 해당 register가 가리키고 있는 value를

address로 해석해서 들어가서

그 곳에 담겨져있는 value를 가져오라는 의미이다

 

이는 c언어의 pointer dereferencing의 개념과 유사하며

c언어에서 c = &p일 때 *이 dereference의 역할을 한다

그럼 그 *역할을 해주는 작업이 필요하는데

이를 assembly에서는 괄호로 표현한다

 

위 예시를 보면 (%rax)와 같이 사용하는데

그럼 %rax에 담겨있는 정보는 주소값이라는 뜻이고

그 주소값에 담겨져있는 정보를 가져와야겠다고 인식하는 것이다

 

 

 

 앞에서 살펴봤듯이 movq를 하려면

source와 destination이 필요한데

각각에 어떤 타입이 들어갈 수 있는지 알아보자

 

source에는 우선 Immediate, Register, Memory

모두 들어갈 수 있다

그리고 뒤에 dest에는 source에 어떤 타입이

들어가있냐에 따라 들어올 수 있는 타입이 달라진다

 

먼저 dest에는 Imm이 올 수 없다

당연한데 mov는 source에 있는 숫자를

어딘가에 적어달라는 의미인데

dest가 그냥 value가 온다면

어디에 적을지 불분명해지기 때문이다

 

source에 Imm, Reg가 올 때는

dest에 Reg, Mem이 들어갈 수 있다

 

하지만 한 가지 기억해야하는건

source에 Mem이 들어가면

deest에는 Mem이 올 수 없다는 것이다

memory to memory는 안된다는 것인데

왜 그렇냐하면 전기전자 전공 쪽에 가면 배우는 내용인데

이 memory to memory를 하기 위해선

내부 하드웨어에서 bus를 2개를 잡아야한다고한다

그래서 이걸 어떤 걸 Lock을 걸고 어떤건 Lock을 풀고의 과정이 필요한데

이게 굉장히 복잡하기 때문에 non-support라고 한다

 

위 예시들을 각각 assembly code와

c언어 코드를 보면 이해가 훨씬 쉬울 것이다

 

 

 

이번엔 Displacement Addressing Mode

(변위 주소 지정 모드)와 

우리가 앞에서 했던

Noraml Addressing Mode

(일반 주소 지정 모드)의 차이점에 대해서 살펴보자

 

아까 앞에서 Memory를 나타낼 때

레지스터에 괄호를 치면 (R)

레지스터 R에 저장된 값은 어떤 메모리 주소값을 가리킨다고 했다

 

이걸 Mem[Reg[R]] 이렇게도 표현할 수 있는데

이 개념은 c언어 포인터의 dereferencing 개념과 동일하다

아까 앞에서 본대로

movq (%rcx), %rax와 같은 방식으로 작성한다

이를 c언어로 표현하면

some_value = *ptr

이렇게 표현할 수 있다

이게 Normal Addressing Mode(일반 주소 지정 모드)이다

 

그렇다면 Displace Addressing Mode는 어떤 방식일까?

 

D(R) 이렇게 작성을 하면 D값 만큼의

offset이 들어간 것이다

D(Displacement)는 memory에서의 offset값을 나타낸다

즉, 주소에서 일정한 거리만큼

떨어진 메모리에 접근할 때 사용하는 방식이다

이는 Mem[Reg[R]+D]와 같이도 표현할 수 있고

assembly에서는

movq 8(%rbp), %rdx

이렇게 표현한다

memory값 앞에 offset인 D값을 넣어

얼만큼 떨어진 주소에 접근해야하는지 알려준다

 

c언어 코드를 보면 이해가 더 쉬울텐데
value = *(ptr + 1); 

이런식으로 작성되는 c언어 코드가

Displace Addressing Mode를 이용해서

data movement operation을 수행하는 것이다

 

 

 

그렇다면 c언어에서 포인터를 배울 때

가장 많이 사용하는 함수인

swap()을 예시로 이해해보자

 

뒤에 가면 나올테지만

보통 function call을 할 때

첫 번째 argument는 %rdi에

두 번째 argument는 %rsi에 저장된다고한다

이는 coding convention이며 general한 약속이다

 

이제 assembly 코드를 살펴보며 해석해보자

우선 argument로 들어오는 *xp와 *yp는 각각

주소값의 형태로 %rdi와 %rsi에 저장된다

 

그런 다음 첫 번째로 할당해준 변수가 t0인데

이 t0의 register는 %rax에 존재하고

long t0 = *xp;에서

*xp가 들어있는 (%rdi)를 source로 해서 t0의 %rax dest에

이동시켜줘야한다

 

이게 assembly 코드에서 제일 위에 나와있는

movq (%rdi), %rax

부분이다.

 

그 밑에 t1을 할당하는 부분도 마찬가지다

t1d이 들어있는 %rdx를 dest로 *yp가 들어있는

(%rsi)의 값을 옮겨주면된다

 

이제 *xp와 *yp의 값을 바꿔주는 부분을 보자

처음은 *xp에 t1에 있는 값을 옮겨줘야한다

따라서 *xp의 위치인 (%rdi)를 dest로

t1이 들어있는 %rdx를 source로 해서 move해준다

이를 assembly로 작성하면

movq %rdx, (%rdi)

가 나오게 되는 것이다

 

*yp = t0;도 동일한 방식으로 작성해주면된다

 

그리고 마지막의 ret은 return 값을 말하는데

swap함수는 void함수라서 return이 없기때문에

아무것도 return값을 저장해주지않고 끝난다

 

 

 

본 프로세스를 메모리 그림을 통해

좀 더 이해하기 쉽게 나타낸 것이다

 

%rdi와 %rsi에 argument로 들어간

포인터 변수들이 각각 들어가있다

 

%rdi에는 0x120이라는 주소값이 들어가있고

해당 주소값에 저장되어 있는 데이터는 123이다

그런데 t0 = *xp를 해주었기때문에

123이 t0이 저장될 %rax에 들어가게 되는 것이다

 

t1과 *yp도 마찬가지이다

 

 

그래서 결론적으로는 이렇게 swap이 된다

 

아까 앞에서 잠깐 봤던

Memory Addressing Modes에 대해서 조금 더 알아보려고한다

 

가장 일반적인 형태는

D(Rb, Ri,S)의 형태인데

Rb는 Base Register라고 해서

어떤 Index의 기준이 되는 register이다

 

이 기준이 되는 Rb에 다가 

Index Register인 Ri만큼 더해줘서

값에 접근하게 되는데

여기서 S는 Scale의 약자인데 Ri에다가

S만큼을 곱해주라는 뜻이다

 

한 마디로, 원래는 Mem[Reg[Rb] + Reg[Ri]]인데

Scale 값인 S를 곱하게 되면

Mem[Reg[Rb] + S * Reg[Ri]]가 되는 것이다

 

이걸 assembly code에서는

movq (%rdi, %rsi, 4), %rdx

와 같이 표현한다

 

Mem[%rdi + (%rsi × 4)]가 되는 것이고

즉, %rdi가 가리키는 기본 주소에서

%rsi * 4 만큼 떨어진 곳의 값을 %rdx에 저장하라는 뜻이다

 

여기에 앞에서 봤던 D(Displacement)까지 붙으면

D만큼 또 offset을 더한 주소값에 접근하라는 뜻이 된다

Mem[Reg[Rb] + S * Reg[Ri] + D]가 되는 것이다

assembly code로는

movq  16(%rbx, %rcx, 8), %rax

이렇게 사용할 수 있다

 

이러한 방식은 c언어에서 주로

배열의 index에 접근할 때 많이 사용된다

 

 

마지막으로 LEA(Load Effective Address)를 살펴보고

이번 수업을 마무리하고자한다

 

LEA는 메모리 주소 결과를 레지스터에 저장하게 하는 연산이다

실제로 메모리에 접근하지는 않고

주소 연산만 수행한다

 

이 명령어를 사용하는 경우는

memory의 값에 접근하는 referencing이 필요없이

단순히 주소만 연산하면 되는 경우인데

c언어에서 보통

p = &x[i];와 같이

x[i]의 주소만을 할당할 때 사용된다

 

또한, 중요한 부분 중에 하나인데

이러한 lea 명령어는 덧셈, 곱셈과 같이

단순 산술 연산에서도 활용할 수 있다

 

어떻게 이게 가능하냐하면

lea는 메모리에 있는 값을 접근하지 않고

오직 주소값만 가지고 연산한다는 것이 핵심이다

 

예를 들어

lea 8(%rax), %rbx

처럼 assembly code가 작성된다고 하면

%rax에 들어있는 어떤 주소값에서

그냥 단순히 8만큼 더한 값을

%rbx에 담아달라는 뜻이 된다

 

주소값에 있는 값에 참조하지 않고

단순 주소에 대한 연산만 하기 때문에

그냥 덧셈 연산으로 활용할 수 있는 것이다

 

따라서 ppt에 있는 예시도 살펴보자

leaq (%rdi, %rdi, 2), %rax

와 같은 assembly code는

%rdi(x)에서 %rdi(x)값에서 2를 곱한 값을

%rax(t)에 할당해주는 것이다

 

t = x + 2*x와 같이 간단하게 나타낼 수 있다


이렇게 Assembly Code와 함께

program의 표현과 실행 1강에 대한

내용 정리 마무리-!

 

다음 시간에는 function call과 

conditional에 대한

assembly code 표현법을 배운다고한다