<컴퓨터 구조에 대한 두 번째 이야기>
[컴퓨터 구조의 접근 방법]
컴퓨터 구조에 대한 근본적인 이해는 나중에 학습하게 될 프로세스와 쓰레드를 이해하는데 도움이 된다.
이번 챕터에서는 가장 일반적인 형태의 컴퓨터 구조에 대해서 정리해보겠다.
[컴퓨터를 설계해보자]
컴퓨터를 설계한다고는 했는데, 도대체 뭘 설계를 하겠다는걸까?
케이스는 뭐를 쓰고 크기는 어떻게 하고, CPU는 뭐를 쓰고...
이런 조립 PC 견적을 내듯이 설계를 하겠다는 것이 아니다.
엄밀히 따지면 컴퓨터에서 핵심이 되는 CPU를 설계해보려고 한다.
그렇다고 디지털 논리회러에서 쓰는 AND, OR, NOT와 같은 논리회로와 게이트 수준에서 만들겠다는 것은 아니다.
(지금 정리글을 쓰는 나도 실제로 그럴 능력이 안된다.)
https://sevenshards.tistory.com/34
이전 글에서 CPU를 구성하는 요소는 크게 ALU, 컨트롤 유닛, 그리고 레지스터와 버스 인터페이스가 있었다.
여기서는 레지스터를 대상으로 디자인을 하고자 한다.
ALU와 컨트롤 유닛, 버스 인터페이스는 이미 다 만들어져 있다고 치자.
우리는 시스템 프로그래밍을 배우는 입장이므로 시스템 프로그래머의 입장에서 생각해볼 필요가 있다.
시스템 프로그래머(특히 CPU에 종속적인 어셈블리 프로그래밍을 하시는 분들)의 입장에서 CPU를 보는 관점은 뭘까?
저자는 대부분 '레지스터'에 집중하게 된다고 말한다.
그만큼 CPU 디자인에 있어서 레지스터에 대한 이해는 아주 중요하다고 하는데, 그 이유를 하나하나 살펴보자.
[레지스터를 설계하자]
레지스터를 설계하는데 있어서 결정해야 할 중요한 요소는 다음과 같다.
1) 레지스터를 몇 비트로 구성할 것인가?
2) 몇 개의 레지스터를 구성할 것인가?
3) 각각의 레지스터를 무슨 용도로 사용할 것인가?
이 세 가지가 레지스터를 설계의 핵심이 된다.
실제로 우리가 쓰는 환경이 32비트 또는 64비트 운영체제 환경이므로 레지스터를 64비트로 설계해야 할텐데!
문제는 이걸 표현하는 것이 많이 어렵다.
특히 그림을 그려가면서 공부해야하는데 이게 64비트면 그리다가 질려서 때려치게 될 것이다.
그래서 CPU의 레지스터 설계에는 다음과 같은 전제조건을 가지고 설계를 진행해보려고 한다.
1) 16비트 시스템을 대상으로 설계한다.
2) 사용할 레지스터는 8개다.
3) 레지스터 이름은 r0부터 r7까지로 정한다.
대충 이렇게 생긴 레지스터가 있다.
그리고 위에서 정한 제약대로 r0부터 r7까지 이름을 붙여줬다.
그런데 r4부터 r7까지는 별도로 이름이 붙어있다.
이 넷은 좀 특수한 목적으로 쓰이는 놈들인데, 나중에 가서 어떻게 쓰이는지 확인해보자.
여담이자 참고사항으로, 책의 저자는 ARM 코어를 참조한 것이라고 한다.
나는 그게 뭔지 잘 모르지만 명령어를 디자인하는 과정에서 ARM 어셈블리 언어를 많이 참조했다고 한다.
이는 다른 어느 CPU보다 하드웨어 구성과 명령어 구조가 간단하다고.
그래서 간단한만큼 전력 소비가 아주 적기 때문에 휴대폰에 아주 적합한 코어라고 한다.
[명령어 구조 및 명령어를 설계하자]
CPU, 정확히 말하면 레지스터 설계는 다 끝났다.
이제 CPU를 굴리기 위해 명령어 구조 및 명령어의 종류를 설계할 차례다.
좀 더 정확히 다시 말하자면, "레지스터 설계가 다 끝났으니 이를 바탕으로 명령어를 설계할 차례다."
위에서도 굵은 글씨로 써놨지만, 이 파트에서 주목할 부분은 레지스터와 명령어의 상관관계다.
쉽게 말하면, CPU의 구성 형태(엄밀히 따지면 레지스터의 형태)에 따라서 명령어 구조가 달라진다는 것이다.
그래서 CPU가 달라지면 거기에 따른 명령어 구조도 달라진다.
그렇기 때문에 어셈블리 언어로 구현된 프로그램은 구조가 다른 CPU에서는 이식이 불가능하다.
그만큼 어셈블리 언어는 CPU에 종속적이라는 것이다.
여하튼, 명령어의 기본 구조를 설계해보고자 한다.
16비트로 레지스터 크기를 결정했으니, 명령어 길이도 16비트로 구성하면 된다.
그럼 이제 16비트를 어떻게 쓸까를 고민해봐야 한다.
일단 16비트니까 $2^{16}$만큼, 즉 65536개의 명령어를 만들어내는 것이 가능하다.
근데 생각해보면 우리가 프로그래밍 언어를 배우면서 연산자나 명령어는 끽해봐야 100개도 안됐다.
그리고 세상의 어떤 CPU도 명령어 개수가 세 자리수 이상을 넘어가는 경우는 없다고 한다.
어찌되었건, CPU에게 일을 시킬 때에는 다음과 같은 형태로 명령을 내리게 된다.
"레지스터 r1에 있는 값과 숫자 7을 더해서 레지스터 r2에 저장하라"
위의 명령을 분석해보면 다음과 같이 풀어볼 수 있다.
1) 덧셈 연산을 해야한다. - 연산 개념이 있다.
2) 연산의 대상이 되는 피연산자가 있다. - 연산에 사용되는 피연산자는 둘이다.
3) 연산의 결과를 저장하는 피연산자가 있다. - 저장의 대상이 필요하다.
그러니 이 모든 것을 16비트짜리 명령어 하나에 다 담을 수 있으면 된다.
일단 하나의 명령어에 담겨야 하는 정보는 총 4개다.
연산자, 저장할 피연산자, 연산 대상 피연산자1, 연산 대상 피연산자2
위의 명령을 16비트에 채워넣는다면 다음과 같이 된다.
저자가 만든 구성은 다음과 같다.
구조를 좀 달리 하고 싶다면 독자의 생각대로 그려보는것도 좋은 공부가 된다고 한다.
사실 어떻게 해야할지는 잘 모르겠으니, 배우는 입장이므로 이 모양 그대로 가겠다.
우선 연산자에는 3개의 비트를 할당했다.
그러므로 $2^3$개 만큼의 연산자를 만들어낼 수 있다.
그리고 저장소에는 레지스터 정보만 들어올 수 있다고 제한하고 3개의 비트를 할당했다.
레지스터의 개수가 8개이므로 $2^3$개를 표현할 수만 있으면 된다.
일반적인 ALU는 연산결과를 레지스터에 저장하기 때문에 이와 같이 설계를 했다고 한다.
다음은 피연산자1과 2는 4비트씩 할당을 했다.
그래서 표현 가능한 수는 $2^4$까지이므로 0~15(16진수라면 0~F)까지 표현이 가능하다.
마지막으로 남는 2개는 어떻게 쓰일지 모르니 예약용으로 남겨둔다.
이제 각자 사용할 비트를 나눴으니 하나하나 설계를 들어가야 한다.
가장 먼저 연산자다.
위에서도 말했지만 3비트를 할당했기 때문에 실제로 만들어낼 수 있는 연산은 최대가 8개다.
기본이 되는 사칙연산인 가감승제와 어셈블리 프로그램에서 사용될 심볼(Symbol)을 정한다.
다음은 저장소를 설계한다.
저장소도 최대 8개까지 표현이 가능하므로 다음과 같이 정의한다.
이제 남은 것은 피연산자 2개를 표현하는 방법이다.
맨 처음에 예시로 들었던 '레지스터 r1에 있는 값과 숫자 7을 더해서 레지스터 r2에 저장하라'를 보자.
r2 = r1 + 7이 된다.
다시 말해서 피연산자는 레지스터가 될 수도 있고, 숫자가 될 수도 있다.
이는 피연산자가 레지스터인지 숫자인지 구분할 필요가 있다는 것이다.
만약 0001이라고 하면 이게 r1을 가리키는 것인지, 숫자 1인지 분간이 안된다.
그래서 가장 첫 번째 비트를 사용해서 레지스터와 숫자를 분간하기로 하자.
0이면 숫자, 1이면 레지스터가 된다.
그래서 실질적으로 피연산자의 숫자는 $2^3$개인 0~7까지만 표현가능하게 되었다.
이제 '레지스터 r1에 있는 값과 숫자 7을 더해서 레지스터 r2에 저장하라'를 표현하면 다음과 같이 된다.
이제 구성한 명령어를 가지고 실제로 프로그램이 수행되는 과정을 그림으로 표현하면 다음과 같다.
1) 어셈블리 언어 기반의 프로그램 구현
ADD r2, r1, 7
어셈블리 언어에 작성된 어셈블리 코드는 CPU가 인식할 수 있는 2진 명령어로 변환이 된다.
2) 어셈블러에 의한 바이너리 코드(기계어) 생성
0000101010010111
이 명령어는 CPU에 있는 컨트롤 유닛에 의해 해석되고, 컨트롤 유닛은 CPU의 각 모듈에 명령을 내리게 된다.
그리고 각각의 모듈은 이 명령에 따라서 적절한 연산을 수행한다.
이게 위에서 진행한 전체 과정이다.
그리고 추가로 명령어는 r4, ir(Instruction Register)에 저장된다.
이름 그대로 ir에는 다음에 수행할 명령어를 미리 가져다 놓는 용도로 사용하게 된다.
그리고 지금까지 일련의 과정을 거치면서 한 가지 알 수 있는 사실이 있다.
구성하는 명령어의 형태에 의해서 컨트롤 유닛의 구조가 결정된다는 것이다.
컨트롤 유닛은 명령어를 해석하는 일을 담당한다.
그리고 이는 바꿔 말하면 컨트롤 유닛은 명령어 구성 및 해석방법을 정확히 알고 있어야 한다는 것이다.
그래서 명령어의 형태에 따라 컨트롤 유닛의 논리회로가 디자인되는 것이다.
다음은 추가적인 설명이 필요한 부분이다.
"첫 번째 피연산자 위치(저장소)에는 레지스터 이름이 와야 한다."
이 말은 "연산의 결과는 일단 레지스터에 저장되어야 한다"라는 제약사항이 있다는 것이다.
실제로 ARM이나 x86 계열의 어셈블리 프로그래밍을 공부하면 명령어 구성 과정에서 몇몇 제약사항이 존재한다고 한다.
굳이 레지스터에만 저장할 필요가 없이 바로 메모리 주소가 오게끔 설계를 할 수도 있다.
하지만 그렇게 설계하게 되면 명령어 구조가 복잡해지고, 하드웨어 구성도 복잡해진다.
그리고 명령어를 처리하는 데 걸리는 시간도 명령어마다 달라질 수 있다는 것이다. (이는 단점이 될 수 있다고 한다.)
그래서 다음과 같은 사실을 이해하는 것이 중요하다.
"이러한 제약사항들은 CPU의 종합적 측면(성능, 비용 등등)이 고려되는 가운데서 등장하게 된다.
그래서 우리는 이러한 제약사항을 지켜야 한다."
[RISC vs CISC]
[CISC (Complex Instruction Set Computer)]
지금까지 설명한 내용으로는 명령어 구성에는 제약사항이 따른다고 했다.
하지만 실제로 명령어 종류가 많고 다양한 조합이 가능한 CPU도 있다.
명령어의 개수가 세 자리수를 넘지 않는 CPU도 있지만 수 백개의 명령어 구성이 가능한 CPU도 있다.
명령어의 종류가 많다는 것은 다양한 조합이 가능하다는 것이며, 프로그램의 구현에 편리함을 가져다 준다.
수십줄에 걸쳐서 구현해야 하는 기능을 단 한 줄로 완성시키는 것도 가능하다.
그리고 필요에 따라서는 명령어 길이도 유동적이기 때문에 메모리를 효율적으로 사용할 수도 있다.
이와 같은 구조로 만든 CPU를 CISC(Complex Instruction Set Computer) 구조 CPU라고 한다.
말 그대로 '복잡한 명령어 체계를 가지는 컴퓨터'라는 뜻이다.
과거 Intel사에서 16비트 CPU까지는 CISC 구조로 만들어왔다.
[RISC (Reduced Instruction Set Computer)]
그런데 'Intel사에서는 16비트 CPU까지는 CISC구조로 만들어왔다'라는 말은 지금은 그렇게 안만든다는 말이다.
왜 그럴까? CISC 구조에는 단점이 있기 때문이다.
명령어가 많고, 그 크기가 일정하지 않기 때문에 CPU는 복잡하게 설계될 수 밖에 없다.
그래서 성능 향상에 있어서도 제한이 따르게 된다.
보다 높은 성능의 CPU를 디자인하기 위해서는 CISC보다는 단순한 CPU 구조가 보다 적합한 것이다.
이를 충족하는 구조가 바로 RISC(Reduced Instruction Set Computer) 구조다.
실제로 CISC 구조 CPU가 가진 전체 명령어 중 주로 사용하는 것은 10%도 안된다는 데에서 착안한 구조다.
어쩌다가 쓰는 명령어 90% 이상을 위해 CPU를 복잡하게 설계하는 것은 비효율적이기 때문이다.
그래서 이름 그대로 명령어 수를 대폭 줄이고 명령어 길이를 일정하게 디자인하는 RISC구조를 생각해낸 것이다.
쉽게 말하면 높은 성능을 낼 수 있는 RISC 구조가 나온 것이다.
실제로 Intel사의 32비트, 64비트 CPU만이 아니라 임베디드 환경에서도 RISC 구조의 CPU를 사용한다.
RISC가 높은 성능을 내는 이유는 과연 뭘까?
초당 클럭 수를 높이는 것보다 더 중요한 것은 클럭 당 처리할 수 있는 명령어의 개수다.
RISC는 명령어의 길이가 동일하고, 명령어를 처리하는 과정이 일정하다.
그래서 Pipelining 기법을 이용하면 클럭당 둘 이상의 명령어 처리도 가능하다.
이러한 부분이 RISC 구조가 지니는 가장 큰 장점이라고 볼 수 있다.
+추가 - 파이프라이닝 기법(Pipelining)
Pipelining 기법을 이용하면 클럭당 둘 이상의 명령어 처리도 가능하다라고 했다.
다음의 예시를 보자.
보다시피 명령어 3개를 처리하는데 무려 12클럭이 걸린다.
하지만 파이프라이닝 기법을 적용하면 어떻게 될까?
위처럼 효율적인 명령어 처리가 가능해진다.
앞에서는 3개의 명령어를 처리하는데 12클럭이나 걸렸는데, 여기서는 명령어 3개를 처리하는데 5클럭이면 충분하다.
RISC 구조에서는 명령어의 길이가 동일하기 때문에 위와 같이 한 번에 여러개의 명령어를 처리하는 것이 가능하다.
[LOAD & STORE 명령어 설계]
연산 과정에 대한 명령어 설계는 끝이 났다.
이제 다음으로 해야할 일은 메인 메모리에서 데이터를 가져오거나(LOAD) 저장(STORE)하는 명령어를 설계해야 한다.
[LOAD & STORE 명령어의 필요성]
앞에서 설계했던 연산 과정의 명령어는 "연산 결과를 레지스터에만 저장할 수 있다"라는 제약사항을 두었다.
달리 말하면 "모든 피연산자에는 메인메모리의 주소값이 올 수 없다"라는 제약사항이 들어가있는 것이다.
피연산자로 올 수 있는 것은 레지스터와 숫자만으로 제한했기 때문이다.
그래서 계산은 가능한데, 다음과 같은 경우는 어떻게 처리를 해야할까?
int a = 10; // 0x0010에 할당
int b = 20; // 0x0020에 할당
int c = 0; // 0x0030에 할당
c = a + b;
이런 경우에 'c = a +b'라는 연산을 수행하기 위해서는 아래의 문장을 명령어로 구성할 수 있어야 한다.
"0x0010번지(a)에 저장된 값과, 0x0020번지(b)에 저장된 값을 더해서 0x30번지(c)에 저장해라."
현재까지 설계한 명령어는 메모리 주소가 사칙연산의 피연산자로 올 수 없도록 제약을 둔 상황이다.
(이는 범용적으로 사용되는 RISC 방식의 특징이기도 하다)
그래서 메인 메모리에 저장된 데이터를 레지스터로 일단 옮겨다 놓은 다음에 덧셈을 진행할 필요가 있다.
따라서 레지스터와 메인 메모리 사이에서 데이터를 전송할 수 있는 명령어가 필요하다.
[LOAD & STORE 명령어의 설계]
일단 앞에서 명령어 심볼로 가감승제(ADD, MIN, MUL, DIV)로 4개를 사용했다.
아직 4개의 명령어를 더 만드는 것이 가능하니 LOAD와 STORE라는 심볼을 준다.
그리고 아래의 그림과 같이 설계한다.
이전에 연산 명령어에서는 저장소(레지스터), 피연산자1, 피연산자2와 같은 피연산자가 셋이었다.
LOAD와 STORE에서는 저장소(레지스터)와 메인 메모리의 정보를 담을 피연산자가 둘이라는 것이 차이점이다.
LOAD는 110, STORE는 111로 정의했다.
LOAD에는 목적지(destination)가 레지스터 정보, 가져오는 곳(source)을 메모리 주소 정보가 올 수 있게 했다.
STORE는 반대로 목적지(destination)가 메모리 주소 정보, 가져오는 곳(source)을 레지스터 정보가 올 수 있게 했다.
이제 위에서 정의했던 코드를 명령어로 바꿔서 수행하면 다음과 같이 된다.
LOAD와 STORE는 연산 명령어에서는 피연산자가 숫자가 레지스터가 되어야한다는 제약사항을 해결하는 방법이 된다.
숫자나 레지스터밖에 올 수 없다면?
값을 레지스터에 옮겨서 연산을 하면 되기 때문이다.
[Direct 모드와 Indirect 모드]
이제 명령어 설계가 다 끝났으니 문제가 해결된 것 같다.
그런데 애석하게도 또 문제가 있다.
[Direct 모드의 문제점과 Indirect 모드의 제안]
설계했던 LOAD와 STORE에서 메모리의 주소값을 받아오기 위해 사용한 비트수는 8이다.
문제는 지금 사용하는 시스템은 16비트 시스템이라는 것이다.
다시 말해서 주소값을 표현하는데 0x0000 ~ 0xFFFF(0~65535)까지 표현하는 것이 가능하다.
하지만 우리가 설계한 명령어는 0x0000~ 0x00FF(0~255)까지만 표현이 가능하다.
그럼 0x0100(256)에는 어떻게 접근을 해야할까?
우선 지금까지 구현하면서 메모리에 접근했던 방식을 'Direct Addressing 모드'(줄여서 Direct모드)라고 한다.
이는 주소값을 명령어에 '직접' 표현하기 때문에 Direct 모드라고 하는 것이다.
이 방법으로는 절대로 0x0100에 접근할 수가 없다.
그래서 이것을 해결하기 위해 Indirect 모드를 사용한다.
[Indirect 모드의 이해]
'Indirect Addressing 모드'(줄여서 Indirect 모드)는 위의 Direct 모드에서의 메모리 접근 방법을 해결하기 위해 사용한다.
위의 직접 표현하는 것과는 반대로 '간접'적으로 표현해서 접근하는 방식이기 때문에 Indirect 모드라고 한다.
말로만 하면 이해하기 어려우니까 그림을 보면서 이해하는 것이 좋다.
일단 Direct 모드와 Indirect 모드의 차이점을 확인해보면 아래의 그림과 같다.
Direct 모드는 레지스터에 저장할 데이터의 주소를 0x10으로 직접 표현해서 값을 참조한다.
Indirect 모드는 레지스터에 저장할 데이터의 주소를 0x10이 아닌 0x10에 저장된 주소값을 참조해서 값을 가져온다.
그리고 Indirect 모드는 피연산자 0x10을 [0x10]으로 '[]'로 감싸서 표현한다.
즉, [] 기호로 주소값을 감싸면 Indirect 모드 연산을 수행한다는 것이다.
이제 마지막으로 해결해야 할 문제는 Direct와 Indirect 모드 연산은 어떻게 구분할 것인가다.
이전에 연산 때는 레지스터와 숫자를 구분하기 위해서 비트를 하나 따로 사용하여 구분을 했었다.
그런데 여기서 주소값으로 사용하는 8비트 중 하나를 구분하기에 쓰는 것은 좀 그렇다.
그래서 지금까지 쓰고있지 않았던 '예약(Reserved)' 공간을 사용해서 Direct와 Indirect의 구분을 하면 된다.
[Indirect 모드 활용 예제]
이제 Indirect 모드에 대한 이해도 끝났고, 진짜 마지막으로 이를 활용하는 예제를 보일까 한다.
int a = 10; // 0x0010에 할당
int b = 20; // 0x0100에 할당
int c = 0; // 0x0020에 할당
c = a + b;
구성은 아까와 같다.
다만 다른 점이 있다면 b가 0x0100에 할당이 되어있는 상태다.
지금까지 설계했던 명령어만 가지고 이 문제를 충분히 해결할 수 있다.
사실 나도 이걸 풀면서 처음에는 이렇게 푸는게 맞나 했고, 답을 맞췄을 때는 잘 이해하고 있다는 생각이 들었다.
그리고 저자분의 답을 보고 나서는 이렇게 깔끔하게 풀 수도 있는데 나는 힘들게 풀었다는 생각에 허탈하기도 했다.
어찌되었건, 직접 푸신 분들도 있겠지만 우선 답만 쓰면 이렇게 된다.
// 값의 저장
LOAD r1, 0x0010
// 0x0100 (256)을 만드는 과정
MUL r0, 4, 4
MUL r2, 4, 4
MUL r3, r0, r2 (또는 MUL r3, r2, r0)
// 0x0100을 저장하고 Indirect 모드로 값을 참조
STORE r3, 0x0030
LOAD r2, [0x0030]
// 값을 더하고 0x0020에 저장
ADD r3, r1, r2
STORE r3, 0x0020
풀이 과정을 하나씩 풀어보면 다음과 같다.
LOAD r1, 0x0010
0x0010에 있는 값을 r1에 저장한다.
MUL r0, 4, 4
MUL r2, 4, 4
MUL r3, r0, r2 (또는 MUL r3, r2, r0)
0x0100=$2^8$(=256)을 만들기 위해 값을 연산.
그리고 연산한 값을 r3에 저장한다.
이 과정을 생각해내지 못하는 분들이 꽤 많았을 것이라고 본다.
사실 곱하는 것 외에도 256까지 계속 더하는 것도 답이 된다.
어떤 형태가 되었건 256을 만들어 내기만 하면 된다.
그 중에서 가장 간단한 답은 위와 같이 256을 만들어내는 것이다.
STORE r3, 0x0030
연산에 의해 만들어진 0x0100을 0x30에 저장한다.
LOAD r2, [0x0030]
그리고 Indirect 모드로 값을 참조하여 r2로 가져온다.
ADD r3, r1, r2
r1와 r2에 저장된 값을 더해서 r3에 저장한다.
STORE r3, 0x0020
마지막으로 계산된 값이 저장된 r3의 값을 0x0020으로 옮긴다.