https://www.youtube.com/watch?v=QcQYEAr0Yos
목차
{도입 및 시연
{하드웨어 구성 요소 및 사양
{CPU의 내부 작동 원리
{명령어 사이클과 파이프라인
하드웨어 제약과 클럭 속도
레지스터와 RAM의 구현과 최적화
소프트웨어 및 어셈블러 작동 방식
프로그램 시연:피보나치 수열
파일 시스템과 FactOS
깃헙 레포지토리
주변 기기 상세
Q&A
번역 후기
도입 및 시연
여러분 안녕하세요.
이 영상은 제가 팩토리오에서 어떻게 CPU를 만들었는지에 대한 영상입니다. 제가 39C3*에서 했던 발표를 영어로 번역한 건데, 독일어를 할 줄 아시는 분이면 부디 그 영상을 확인해 보세요. 링크는 설명란에 있습니다. 열정이 훨씬 더 많이 들어가 있고 제작 품질도 좋습니다.
*제 39회 Chaos Communication Congress. 매년 연말 독일 함부르크에서 4일간 진행되는 국제 해커 집회.
이 영상은 팩토리오 플레이어들에게 제 프로젝트 진행 상황을 보여드리고, 독일어를 사용하지 않는 분들에게도 닿을 수 있도록 만들어졌습니다.
먼저 이 CPU가 무엇을 할 수 있는지 시연해 보겠습니다.
여기서 지뢰찾기를 플레이할 수 있는데요, 커서가 있고 화살표 키로 커서를 제어할 수 있습니다. 그리고 탐색 기능도 있네요. 타일을 클릭하면 자동으로 주변의 모든 타일들을 탐색하는 것을 볼 수 있습니다. 이제 시연을 마치기 위해, 이 지뢰를 클릭해 보겠습니다.
네, 보시는 것처럼 프로그램이 멈췄습니다.
좋습니다, 이걸 어떻게 할 수 있었을까요? 풀어낼 이야기가 아주 많다고 생각합니다. 그러니 아주 초기 단계인, 왜 이걸 만들고 싶어했는지부터 시작해 봅시다.
그러려면 제가 예전에 마인크래프트를 플레이하던 시절부터 이야기를 해야 합니다.
그때도 저는 이미 논리 회로와 레드스톤, 그리고 마인크래프트에서 무언가를 만드는 법에 관심이 많았거든요. 유튜브 튜토리얼을 많이 봤는데, 이처럼 7세그먼트 디스플레이를 만드는 법을 보여주는 영상이 있었어요. 그걸 따라 하면서 기본적인 논리 게이트를 만드는 법을 스스로 익혔죠. 저는 마인크래프트를 아주 좋아했고, 그러한 장치들이 매우 단순한 게이트들만으로 구성되어 있다는 점에 매료되었습니다. 당시에는 OR 게이트와 NOT 게이트밖에 없었거든요. 고작 그것만으로 복잡한 하드웨어를 만들 수 있다는 점이 저를 사로잡았죠.
하지만 구성 요소들이 너무 기본적이어서 무언가를 구현하는 것이 매우 복잡해지기도 했습니다. 그리고 이것은 '팩토리오에서는 훨씬 더 크고 다양한 기본 부품들로 설계를 시작할 수 있지 않을까?'라는 질문으로 이어졌습니다. 예를 들어, 산술 연산 조합기를 사용하여 신호를 직접 더하고, 빼고, 곱할 수 있는데, 이는 마인크래프트에서 곱셈기나 그런 기계들을 직접 만들지 않고서는 불가능한 일이며, 그것들은 아주 큰 기계이지 단순한 부품 하나가 아닙니다. 그래서 그 관점에서 봤을 때 팩토리오에서는 무엇을 할 수 있을까요? 하드웨어를 얼마나 복잡하게 만들 수 있을까요? 이것이 제가 대답하고 싶었던 질문입니다.
하드웨어 구성 및 사양
이제 CPU의 구성 요소를 간단히 살펴보겠습니다.
요약하자면, 팩토리오에는 산술 연산 조합기와 수량 비교 조합기가 있습니다. 이 두 가지가 가장 중요한 구성 요소입니다. 산술 연산 조합기는 신호를 결합할 수 있고, 수량 비교 조합기는 서로 다른 신호를 비교하고 그에 따른 논리 연산을 수행할 수 있습니다. 여러분이 팩토리오에 익숙하다고 가정하고 자세한 설명은 생략하겠습니다. 그리고 일정 신호 조합기도 있는데, 이는 일정한 신호를 지속적으로 내보내며 저는 이걸 주로 저장소의 ROM 인풋으로 사용했습니다. ROM에 대해서는 잠시 후에 설명하겠습니다.
하드웨어는 5단계 파이프라인 아키텍처로 구성되어 있습니다. 32개의 범용 레지스터, 약간의 RAM이나 하드 드라이브 같은 저장 공간을 가지고 있으며 인터럽트 핸들러도 있습니다. 이 CPU는 700Hz의 클럭 속도로 작동하는데, 이는 CPU 치고는 빠르지 않은.. 사실 그냥 매우 느린 속도지만, 팩토리오 안에서 구현했으니 무제한의 클럭 속도를 얻을 수는 없습니다. 클럭 속도에 대해서도 나중에 다시 살펴보겠습니다. 주변 기기로는 문자 디스플레이와 컬러 디스플레이, 그리고 키보드가 있으며 난수 생성기(RNG)도 있어 난수를 요청할 수 있습니다.
소프트웨어의 경우, 저는 텍스트 입력을 받아 CPU에 로드할 수 있는 팩토리오 청사진을 출력하는 어셈블러를 파이썬으로 작성했습니다. 예시는 나중에 보여드리겠습니다. 또한 프로그램 시뮬레이터도 만들었는데 이건 당연하게도 실제 CPU보다 훨씬 높은 클럭 속도를 가집니다. 이것은 주로 어셈블리 프로그램의 디버깅을 위해 사용되었습니다. 저는 커맨드 라인 환경을 만들었고 파일 시스템도 구축했는데, 이 작업에 많은 노력이 필요했습니다. 또한 몇 가지 미니 게임도 프로그래밍했습니다. 이 모든 것은 어셈블리로 작성되었으며 이 CPU에서 실행할 수 있습니다.
그리고 이 CPU의 핵심 개념은 RISC-V CPU를 구축하는 것입니다. RISC는 '축소 명령어 집합 컴퓨터reduced instruction set computer'의 약자이며 RISC-V는 현재 매우 인기 있는 오픈 소스 ISA*입니다. 저는 이것이 오픈 소스라는 점이 매우 좋았는데, 덕분에 모든 명령어를 직접 살펴보고 정확하게 구현하는 방법을 알 수 있어서 저에게 매우 유용했습니다. 구현 방법에 대한 문서와 지침도 많이 있었습니다. 또한 멋진 우연으로, 팩토리오의 신호는 부호 있는 32비트 정수이고 RISC-V 명령어 또한 32비트 워드 길이에서 작동합니다. 그래서 CPU의 워드 길이를 32비트로 선택했고, 모든 조합기가 이를 간단하게 처리할 수 있었습니다.
그리고 주변 기기는 메모리 맵 방식으로 되어 있는데 이는 모든 작업을 더 쉽게 처리할 수 있게 해 줍니다.
또한 빅 엔디안** 아키텍처를 사용하며 10진법을 받아들입니다. 결국 우리는 모든 신호가 십진수로 표시되는 팩토리오 안에 있으니까요. 그래서 저도 그렇게 했고, 재미삼아 십진수만 입력받는 어셈블러를 구현해 봤습니다.
*명령어 집합Instruction Set Architecture. 프로세서 자체가 가지고 있는 물리적인 구조라고 보면 된다. 하드웨어와 소프트웨어 사이의 약속이기 때문에, 이에 맞춰 신호를 넣어줘야 한다. 예를 들어 '첫 4글자와 그 다음 4글자에 대해 그 다음 3글자의 연산을 수행한다' 라는 ISA를 지닌 CPU에는 '08570033더하기'나 '00470035곱하기'라는 입력을 넣어야지, '857+33=' 라고 줘 봤자 원하는 결과값이 나오지 않게 된다.
**빅 엔디안Big-endian. 숫자를 자릿수가 큰 순서부터 저장하는 방식. 두 숫자의 대소관계를 파악할 때는 큰 자리부터 비교하기 때문에 빅 엔디안이 유리하고, 덧셈을 할 때는 작은 자릿수부터 연산하고 받아올림을 해야 하기 때문에 리틀 엔디안이 유리하다.
CPU의 내부 작동 원리
자, 이제 컴퓨터가 실제로 어떻게 작동하는지에 대한 질문에 답해 봅시다.
물론 그 답은 '매우 복잡하게'지만, 개략적으로 설명하자면 CPU의 가장 핵심적인 코어는 ALU, 즉 산술 논리 장치라고 할 수 있습니다. ALU는 기본적으로 계산기라고 할 수 있는데, 입력으로 두 개의 숫자를 받고 다른 숫자를 출력하며, 여러 모드를 가지고 있습니다. 그래서 덧셈, 뺄셈, 곱셈을 할 수 있고 AND, XOR, OR 등의 비트 연산도 수행할 수 있습니다.
하지만 이 모든 것을 하기 위해서는 요청된 숫자를 불러오고 다시 저장하기 위한 일종의 저장소가 필요합니다. 그리고 레지스터 파일*이 바로 그 역할을 합니다. 우리의 경우 이건 32워드 크기의 작은 저장 공간일 뿐이며, 이런 명령어들을 저장할 수 있게 해 줍니다.
*가장 빨리 접근할 수 있지만 용량이 매우 작은 저장 장치. 비유하자면 전화번호를 포스트잇에 적어 모니터에 붙여두는 게 레지스터, 공책에 적은 다음에 책상에 꽂아두는 게 캐시에 저장하는 거라고 볼 수 있다. 레지스터 -> 캐시 -> RAM -> SSD/HDD 순으로 용량은 커지지만 접근 속도는 떨어지게 된다.
t0와 t1의 숫자를 더하고 싶다고 합시다. t0과 t1은 모두 레지스터이며, 결과는 다른 레지스터인 a0로 출력됩니다.
이를 위해 t0를 포트 A에 넣고 t1을 읽기 포트 B에 넣으면 됩니다. 그런 다음 ALU를 덧셈(+) 모드로 설정하고 결과를 계산합니다. 그리고 결과를 writeback(WB) 포트에 놓고 a0에 기록합니다.
네, 멋지네요, 잘 작동합니다. 하지만 이것은 약간 너무 기초적인 것 같습니다.
그래서 예를 들어 레지스터 내부의 값을 증가시키는 것과 같은 명령어가 있다면 어떻게 해야 할까요? 그 경우에는 여기 있는 '1'과 같은 즉시값이 필요하고 그걸 입력하면 되지만 이제 ALU의 아래 부분에 B와 즉시값(Immediate Value, imm), 이렇게 두 개의 입력을 가지게 되었습니다.
그래서 우리는 둘 중 하나를 선택해야 하며, 이를 위해 멀티플렉서(Multiplexer, MUX)를 사용합니다. 멀티플렉서는 기본적으로 여러 입력 중 하나를 선택하여 출력으로 전달하는 장치입니다.
우리가 살펴봐야 할 또 다른 것은 RAM과 저장소입니다. 레지스터만으로는 충분하지 않습니다. 레지스터는 전체 프로그램을 저장할 만큼 충분한 워드를 가지고 있지 않으며, 그렇게 설계되지도 않았습니다. 따라서 더 많은 저장 공간이 필요하며, 이를 위해 ROM과 같은 저장 장치를 사용합니다.
우리 경우에는 ALU는 RAM에 접근하는 데 사용되는 주소를 계산하고, 값을 로드할 때 주소를 RAM에 제공하기도 합니다. 이렇게 하면 값이 로드됩니다.
그리고 두 번째 멀티플렉서를 추가해 ALU의 출력을 그대로 받아올지 RAM에서 값을 불러올지 선택할 수 있게 합시다.
RAM에 결과값을 저장할 수도 있습니다. 그 경우에는 사용되고 있지 않은 포트 B를 사용합니다.
그리고 이것은 이미 매우 도식적이고 간단한 개요입니다. 하지만 이 모든 것을 갖추면 기본적으로 CPU를 갖게 되는 것입니다.
이걸 제 CPU의 메인 청사진이라고 생각해도 됩니다.
명령어 사이클과 파이프라인
그럼 명령어 하나를 실행하는 과정을 살펴보겠습니다.
우리의 경우, 명령어 사이클은 하나의 명령어가 거쳐야 하는 다섯 가지 단계로 구성됩니다.
첫 번째 단계인 명령어 인출(Instruction Fetch, IF)에서는 실행하려는 명령어조차 가지고 있지 않고, 주소만 알고 있을 뿐입니다. 그래서 이 단계에서는 RAM에서 명령어를 불러오기만 합니다.
두 번째 단계에서는 명령어를 해독(Instruction Decode, ID)합니다. 디코딩이란 어떤 레지스터의 값을 불러와야 하는지 등을 계산하는 것을 의미합니다. 명령어 자체가 무엇인지, 어떤 유형인지 같은 것들을 모두 말입니다.
실행(Execute) 단계에서는 ALU에 명령을 실행하도록 요청합니다. 그래서 ALU가 적절한 모드로 설정되고 그대로 연산이 진행됩니다.
그리고 만약 명령어가 메모리 접근이나 그런 메모리 관련 명령어라면, 실행 단계에서 주소를 계산했으므로 메모리 접근(Memory Access) 단계에서 RAM에 접근해 원하는 값을 요청합니다.
마지막으로 레지스터 쓰기(R.egister Writeback) 단계에서는 ALU의 결과나 RAM에서 로드된 결과 중 하나를 취해 레지스터에 다시 씁니다.
팩토리오에서 이 과정이 어떻게 이루어지는지 예시를 살펴보겠습니다.
자, 여기 제 CPU의 스크린샷이 있습니다. 명령어 사이클을 하나씩 살펴보면서 각 단계를 확인해 보겠습니다.
먼저 프로그램 카운터(PC)에서 명령어 인출을 시작합니다. 이것은 우리가 지금 몇 번 명령어를 실행하려는지 그 주소를 담고 있는 특수한 범용 레지스터입니다. 오직 주소만 말이죠.
이 주소를 가지고 우리는 메모리 컨트롤러로 가서 "현재 명령어를 주세요"라고 말합니다. 즉, 데이터에서 명령어를 로드합니다. 명령어를 얻으면 그것을 첫 번째 파이프라인 레지스터에 넣습니다.
파이프라이닝에 관해서는 잠시 후에 설명하겠습니다. 지금은 '매 단계가 끝난 후에 현재 결과를 파이프라인 레지스터에 저장한다'는 것만 기억하면 됩니다.
좋습니다. 이제 IF/ID 레지스터에 명령어가 있고, 이제 이 명령어를 어떻게 처리해야 할까요? 당연히 디코딩해야 합니다.
그래서 그것을 디코더에 넣습니다. 디코더 자체는 우리가 가진 명령어의 종류를 살펴보고 레지스터 주소, 즉시값과 현재 사용되는 명령어의 종류를 나타내는 연산 코드(opcode)를 계산합니다. 기본적으로 이것은 32비트 숫자를 가져와서 더 작은 이진수들로 나누는 과정이고, 레지스터 주소를 이용해 레지스터 파일로 가서 원하는 레지스터를 요청합니다. 이러한 값들은 연산 코드 및 즉시값과 함께 다음 파이프라인 레지스터로 전달됩니다.
실행 단계에서는 모든 값을 ALU에 넣고, 연산 코드도 주어졌으므로 ALU가 모슨 모드로 설정되어야 하는지 알 수 있으며, 그대로 결과를 계산합니다.
우리 ALU는 일반적인 ALU들보다 더 많은 명령어를 처리할 수 있습니다. 곱셈과 정수 나눗셈, 심지어 거듭제곱까지 가능합니다. 그 이유는 산술 연산 조합기에 이미 이러한 연산들이 구현되어 있기 때문입니다. RISC-V 표준에서 조금 벗어나 하드웨어에서 이러한 명령어를 직접 구현하는 것이 좋겠다고 생각했는데, 왜냐하면 우리에게는 그렇게 할 수 있는 능력이 있기 때문입니다. 그리고 애초에 저는 표준 RISC-V 컴파일러를 사용할 계획이 없었고 CPU를 위한 저만의 어셈블러를 작성하고 싶었기 때문에 별로 상관이 없었습니다. 직접 저만의 것을 작성할 능력이 되면 그렇게 하면 되니까요, 네.
그 외에도 우리는 여기서 점프(Jump)와 분기(Branch)도 처리할 수 있습니다. 점프를 수행하면 프로그램 카운터가 그에 맞게 설정됩니다. 그리고 ALU의 결과는 다음 파이프라인 레지스터로 전달됩니다.
이제 메모리 컨트롤러로 넘어가겠습니다.
메모리 접근은 다음과 같이 작동합니다. '읽거나 쓰려는 주소를 가져오고, 메모리 컨트롤러에 전달한다'.
메모리 컨트롤러는 큰 '버스'에 연결되어 있습니다. 이건 메인 버스와 비슷한 구조인데 RAM, 하드 드라이브, 그리고 커널 ROM을 연결해 줍니다. 하지만 앞서 말씀드렸듯이 우리는 메모리 맵 되어있는 주변 기기도 있습니다. 따라서 이 버스는 디스플레이 컨트롤러, 키보드 컨트롤러 등 CPU에서 사용하는 모든 주변 기기에도 연결됩니다.
그리고 로드된 결과는 다음 파이프라인 레지스터로 전달됩니다.
그리고 마지막 단계는 레지스터에 값을 다시 쓰는 것입니다. 아주 간단하죠.
여기 있는 값을 가져와서 레지스터에 다시 기록하면 됩니다. 그게 다입니다.
좋습니다, 제가 아까 파이프라이닝을 언급했습니다.
왜 이 과정을 5단계로 나눌까요? 그 이유는, 예를 들어 명령어 인출 단계에 하나의 명령어가 있고, 그 작업이 완료되었다고 가정해 봅시다. 이제 그 명령어는 해독 단계로 넘어갑니다.
그러면 이제 인출을 담당하는 조합기가 놀고 있으니 다음 명령어의 인출 단계를 시작할 수 있고, 이런 식으로 계속 진행될 수 있습니다.
그래서 첫 번째 명령어가 실행(Execute)중일 때, 두 번째는 해독(Decode)중이고 세 번째는 인출(Fetch)중입니다.
따라서 우리는 다음 명령어를 미리 시작할 수 있으며, 저는 이것을 세 개의 명령어에 대해서만 시각화했지만 기본적으로 항상 동시에 다섯 개의 명령어를 처리할 수 있습니다.
이렇게 하면 당연히 속도가 훨씬 빨라지며 같은 시간에 더 많은 명령어를 처리할 수 있게 됩니다.
문제는 실제로는 그렇게 간단하게 작동하지 않는다는 점입니다.
레지스터 s0와 s1을 더해 결과를 t0에 저장하고, 그다음 그 결과를 s2를 더해 s3에 저장하고 싶은 이 의사 코드가 있다고 가정해 봅시다.
여기 파란색으로 표시된 명령어는 지금 실행 단계에 있습니다.
이 명령어는 이전 명령어에서 갱신된 t0의 값이 필요합니다. 하지만 이전 명령어는 이제 겨우 메모리 접근 단계에 있으며 아직 레지스터에 값을 다시 기록하지 않았습니다. 그래서 이 명령어는 t0의 옛날 값을 사용하게 되고 이는 s3의 계산이 잘못되는 결과로 이어질 것입니다. 이는 명백히 잘못된 것이니 이 문제를 해결해야 합니다.
그래서 어떻게 고치느냐 하면 '포워딩(Forwarding)'이라고 불리는 작업을 수행하는 것입니다.
CPU가 레지스터 t0에 대한 의존성이 있음을 감지하면 해당 값을 다음 명령어로 직접 전달하거나 늦지 않게 여기로 돌아와 메모리에 접근한 값을 실행 로직에 직접 전달합니다. 포워딩을 사용해야 하는 특수한 경우가 많이 있습니다. 이것은 단지 하나의 예일 뿐이며, 포워딩 로직은 CPU 개발에서 가장 복잡한 부분 중 하나입니다.
이 부분을 제대로 구현하고 모든 버그를 수정하는 데 몇 시간, 며칠이 걸렸습니다.
버그가 정말 많았습니다.
하지만 그 모든 것을 해냈다고 가정하면 파이프라인 처리 기능을 갖춘 제대로 작동하는 멋진 CPU를 갖게 된 것입니다.
싸움이 대충 정리되니까 무서운 게 올라왔엉....
올게 왔구나 직접 구현해보고 싶은 입장에선 어찌보면 스포일러일수도 있는데 자세히 읽을지 말지 고민되네..
교수님 일단 지뢰찾기를 할수 있다까지는 이해했어요 ㅠㅠ
ㅈㄴ무섭다 진짜ㅋㅋㅋ - dc App
회로글 말고 인간적인 글 봐서 좋다고 글쓴지 반나절도 안됐는데ㅋㅋㅋ 너무해ㅋㅋㅋㅋ
아니 읽다보니 컴구조 수업이었네
2편도 보셔야함 그게 진짜임