본 게시글은
서울대학교 데이터사이언스대학원 정형수 교수님의
데이터사이언스를 위한 컴퓨팅 시스템 강의를
학습을 목적으로 재구성하였습니다

이제 본격적으로 우리가 프로그래밍을 하면
컴퓨터가 내부에서 어떻게 작동하고
어떻게 코드들을 처리하는지 그 과정을 배워본다

우선 내가 짠 파이썬 코드를
컴퓨터가 어떻게 해석하는지 알기 위해선
하드웨어부터 이해할 필요가 있다
위 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 표현법을 배운다고한다