· 번역) CPU 만들기(2): 하드웨어 및 어셈블러 구현
· 번역) CPU 만들기(3): 주변 기기 상세와 Q&A
목차
도입 및 시연
하드웨어 구성 요소 및 사양
CPU의 내부 작동 원리
명령어 사이클과 파이프라인
{하드웨어 제약과 클럭 속도
{레지스터와 RAM의 구현과 최적화
{소프트웨어 및 어셈블러 작동 방식
{프로그램 시연:피보나치 수열
{파일 시스템과 FactOS
{깃헙 레포지토리
주변 기기 상세
Q&A
번역 후기
하드웨어 제약과 클럭 속도
이제 하드웨어 설계에 대해 조금 더 살펴보고 팩토리오에서 실제로 이 모든 것을 어떻게 구현하는지 살펴보겠습니다. 그러기 위해 아마 게임 틱(Game ticks)에 대해 짧게 설명해야 할 것 같습니다. 팩토리오는 초당 60회의 게임 틱을 가지며 하나의 조합기는 값을 계산하는 데 1틱이 필요합니다. 맵 에디터의 멋진 기능 중 하나는 게임의 속도를 느리거나 빠르게 할 수도 있다는 점인데, 이 기능을 많이 활용했습니다. 최종 CPU는 표준 게임 속도의 64배로 작동하므로 초당 약 3,700번의 업데이트를 처리할 수 있습니다. 이를 통해 정상 속도로 게임을 돌리는 것보다 훨씬 더 많은 연산을 수행할 수 있습니다.
얼마나 많이 계산할 수 있을까요? 파이프라인 단계는 명령어당 5게임 틱을 사용합니다. 그래서 완벽한 파이프라이닝을 가정한다면, 매 5게임 틱마다 하나의 명령어를 끝낸다는 의미이며 이는 초당 약 700개의 명령어를 처리할 수 있다는 것을 의미합니다. 이에 비해 순차적 CPU는 명령어 하나당 약 20게임 틱이 필요합니다.
흥미로운 사실 중 하나는 하드웨어가 더 많아진다면, 즉 전체 CPU를 복사해 듀얼 코어나 쿼드 코어 CPU를 가지게 된다면, 같은 하드웨어를 사용하더라도 코어당 초당 명령어 처리량은 다시 떨어진다는 점입니다. 그리고 우리는 "음, 왜 그럴까?"라는 질문을 던질 수 있습니다.
그 이유는 제 컴퓨터, 즉 제 실제 하드웨어가 팩토리오에서 구현되는 이 모든 복잡한 논리 동작들을 그 속도로 시뮬레이션할 수 없기 때문입니다. 팩토리오는 보통 초당 60틱으로 돌아가게끔 만들어졌으니 3,700틱은 이미 매우 많은 것입니다. 그런데 이제 너무 많은 하드웨어를 가지게 되면 그것은 불가능합니다. 작동하지 않죠. 즉, 하드웨어 한계가 존재합니다. 팩토리오에서 CPU나 일반적인 하드웨어를 얼마나 복잡하게 만들 수 있는지에 대한 적정 지점(sweet spot)이 있습니다. 그래서 기본적으로 간단한 파이프라인 CPU를 만들되 더 큰 규모로 확장하지는 않는 것이 목표였고, 최종적으로 제가 만든 것이 그것이었습니다.
레지스터와 RAM의 구현과 최적화
좋습니다. 이제 게임 틱에 대해 알았으니 레지스터에 대해 살펴보고 팩토리오에서 이 레지스터를 어떻게 구현할 수 있는지 봅시다.
다시 상기시켜 드리자면 레지스터는 데이터 입력이 있고(지금은 42라고 해 보죠), 클럭에 펄스를 주면 값이 레지스터로 이동해 저장되는 방식으로 작동합니다. 그래서 펄스를 한 번 주면 레지스터 내부의 상태는 이제 42가 되며 입력값이 바뀌더라도 42로 유지됩니다. 다시 클럭 신호를 보내야만 상태가 새로운 값으로 업데이트되며 이것이 레지스터가 작동하는 방식입니다. 팩토리오에서는 메모리 셀을 사용해 구현되며 신호의 암묵적(implicit) 합산을 사용하는데, 두 개의 회로 선이 같은 입력에 연결되면 그것들이 자동으로 더해지는 성질입니다.
이전 신호를 제거하는 한 가지 방법은 수량 비교 조합기를 사용하는 것입니다. '클럭이 작동하지 않는다면 출력을 입력으로 전달하라'는 조건이 걸려 있고 따라서 클럭에 펄스가 있으면 이전 값을 통과시키지 않게 됩니다. 따라서 클락 펄스는 이전 값을 삭제하게 되고, 동시에 AND 게이트를 통과해 데이터 펄스를 만들기도 합니다. 이 데이터 펄스는 메모리 셀로 들어가 거기 저장됩니다.
더 명확하게 하기 위해 예시를 봅시다.
여기 우리의 데이터 신호와 클럭 신호가 있습니다.
첫 번째 단계는 아까 말한 AND 게이트를 만드는 것입니다. 두 신호를 수량 비교 조합기에 연결하고 클럭이 0보다 크면 데이터 신호를 통과시키도록 합니다. 그리고 메모리 셀을 설정합니다. 여기 두 번째 수량 비교 조합기의 출력이 다시 입력으로 들어가는 것을 볼 수 있고 이러면 루프가 닫힙니다. 이제 우리에게 남은 것은 "이전 것을 저장하는 것을 멈춰라"라고 말하는 로직을 설정하는 것 뿐이며... 잘 작동합니다.
값을 출력하고 클럭을 누르면 데이터 신호가 저장됩니다. 우리는 데이터 신호를, 말하자면 44로 업데이트하고 클럭을 누를 수 있으며, 이렇게 작동합니다.
좋습니다. 이제 우리는 팩토리오에서 작동하는 레지스터를 만들었습니다.
이 레지스터는 아주 많이 사용됩니다. 여기를 보시면 이것들이 모두 레지스터이며 사실 우리의 RAM은 이것들을 64개 사용합니다. 64개의 레지스터는 많은 양이지만, "64개의 레지스터라면 RAM에 64개의 워드만 저장할 수 있다는 건가? 그건 많은 양이 아닌데." 라고 물을 수 있습니다. 실제 프로그램을 작성하고 더 복잡한 것들을 계산하기 위해 10,000 워드 정도의 RAM 저장 공간을 갖고 싶다면 어떻게 해야 할까요?
음, 순진한 답변은 그냥 'RAM을 더 다운로드'하는, 즉 RAM 스틱을 여러 번 복사하는 겁니다. 실제 하드웨어에서 하는 것과 비슷하게 말이죠. 하지만 이 방법은 한계가 있습니다. 만약 100번 복사해야 한다면, 이 모든 하드웨어를 시뮬레이션해야 하므로 속도가 너무 느려지고 비효율적입니다.
그래서 이 방법은 너무 느리며, 그렇게 할 수는 없습니다. 대안을 찾아야 합니다.
제가 찾은, 혹은 제가 생각해낸 해결책은 팩토리오에서 여러 개의 신호를 사용할 수 있다는 점입니다. 이를 이용하면 하나의 레지스터에 동시에 256개의 신호를 저장하는 것이 가능합니다. 따라서 레지스터는 이제 RAM 블록에 가까운 역할을 합니다. 그리고 우리는 256개의 신호를 블록 단위로 한 번에 읽을 수 있고, 새로운 256개의 신호를 거기에 쓸 수 있습니다.
이로써 RAM을 아주 많이 압축했습니다. 이제 이 모든 작업을 수행하기 위해 메모리 컨트롤러가 필요합니다.
예를 들어 주소 14,850에 쓰고 싶다고 가정해 봅시다.
그러면 우리는 먼저 이 주소를 이진수로 변환하고, 첫 번째 바이트(8비트, 즉 8개의 1과 0) 즉 상위 바이트가 블록 ID가 됩니다.
따라서 우리는 블록 58에 써야 하고 그 블록 내부의 신호 2를 업데이트해야 합니다.
쓰기 작업의 한 가지 문제는 블록에서 신호 2만 직접 업데이트할 수는 없다는 점입니다. 블록 전체를 한 번에 써야만 합니다. 그래서 우리가 해야 할 일은 전체 블록을 메모리 컨트롤러로 로드하는 것입니다. 여기 내부에서 우리가 업데이트하고 싶은 이 하나의 신호를 업데이트하고 업데이트된 블록을 다시 RAM에 씁니다. 이게 압축된 RAM에 데이터를 쓰는 방법입니다.
이것이 핵심 기술이라고 생각합니다. 산술 연산 및 수량 비교 조합기의 '각각(Each)' 연산을 사용하며, 나중에 세이브 파일을 보시면 제가 이걸 어떻게 구현했는지 직접 확인할 수 있습니다.
소프트웨어 및 어셈블러 작동 방식
네, 여기까지가 하드웨어에 대한 주요 소개였습니다.
이제 그 CPU에서 프로그래밍은 어떻게 할까요? 명령어 집합은 무엇이며 어셈블러는 어떻게 작동할까요? 명령어 집합은 기본적으로 RISC-V 아키텍처이기 때문에 세부 사항에 대해서는 자세히 설명하지 않겠습니다.
하지만 음, 우리는 몇 가지 다른 유형의 명령어를 가지고 있습니다.
우리는 레지스터-레지스터 연산을 가지고 있으며 여기서는 일반적으로 우리가 함께 더하고 싶은 두 개의 레지스터와 결과를 저장할 곳까지 해서 항상 세 개의 레지스터를 지정합니다. 또한 입력 중 하나가 명령어에서 직접 지정하는 값인 레지스터-즉시값 명령어도 있습니다.
그다음 저장 장치와 상호작용하는 단어 불러오기(Load Word) 및 단어 저장하기(Store Word) 명령어가 있습니다.
마지막으로 점프 혹은 조건부 점프가 있습니다. 그것들은 대개 고수준 프로그래밍 언어의 이른바 'goto 문'이라고 하는 것과 같은 역할을 할 뿐이지만, CPU에게 프로그램의 어디로 이동할지 지시하는 데 사용할 수 있는 유일한 수단입니다. 이 점을 감안해야 합니다.
여기서 bgt는 'Branch Greater Than'의 약자로, 만약 t0가 t2보다 크다면 end가 어디에 있든 end로 점프하라는 의미입니다.
또 다른 중요한 명령어는 ecall입니다. 이것은 환경 호출(Environment Call)의 약자입니다. 여러분의 프로그램과 상호작용하고 싶은 하드웨어나 주변 기기가 있을 때 사용합니다. 예를 들어 특정 문자열을 디스플레이에 출력하는 함수를 만들고 나면, 실제 구현 방법에 대해서는 신경 쓰지 않아도 됩니다.
그래서 저는 어셈블리로 환경 호출 즉 ecall을 많이 작성했습니다.
여기 그 일부분이 있습니다. 그리고 그것은 꽤 긴 텍스트 파일입니다.
기본적으로 입출력(I/O) 컴포넌트와 상호 작용합니다. 출력도 가능하고, 키보드 상호 작용도 가능하니 키보드 입력을 요청할 수도 있습니다. 예를 들어, 운영 체제에 키가 눌렸는지 묻고, 눌렸다면 어떤 키가 눌렸는지 확인할 수 있습니다. 그리고 파일 시스템과 상호 작용하여 새로운 파일을 만들고, 데이터를 파일에 복사하고, 그런 종류의 작업을 수행할 수 있습니다. 네.
그리고 무엇이든 프로그래밍하려면 이 모든 명령어를 사용하는 어셈블러가 필요합니다.
저는 파이썬으로 어셈블러를 작성했습니다. 입력으로 텍스트 파일을 주면 출력으로 단순히 32비트 워드들로 구성된 명령어 목록을 얻게 됩니다
어셈블러는 대략 어떻게 작동할까요?
기본적으로 먼저 프로그램과 관련이 없는 모든 공백과 주석 등을 제거합니다.
그다음 의사 명령어(Pseudo instructions)를 대체합니다. 예를 들어 즉시값 로드(Load Immediate)는 매우 흔한 명령어로, 단지 레지스터에 값을 설정하는 기능을 합니다. 하지만 하드웨어에는 직접적인 즉시값 로드 명령어가 없습니다. 대신 0과 즉시값을 더하는(add immediate) 방식을 사용할 수 있으며 그럼 그것은 즉시값 로드와 동일하게 작동합니다.
의사 명령어를 교체한 후에는 레이블(Labels)도 대체합니다. RISC-V에서는 상대 점프만 수행할 수 있는데, 이는 위치 독립적 코드(Position independent code)를 작성할 수 있게 해주므로 좋습니다. 따라서 점프할 위치를 알 수 있도록 모든 절대 레이블들을 상대 점프 주소로 교체해야 합니다.
마지막으로 모든 명령어를 32비트 기계어로 번역해야 합니다. 어떤 연산 코드가 어떤 레지스터 주소를 사용하는지 파악하고, 이 모든 것을 32비트 워드에 팩킹합니다.
네, 이렇게 하면 우리의 워드 목록이 만들어집니다.
프로그램 시연: 피보나치 수열
예시 하나 보고 가겠습니다.
여기 피보나치 수열을 계산하기 위한 어셈블리 코드 프로그램이 있습니다. 그리고 무슨 일이 일어나는지 보기 위한 동등한 파이썬 코드도 있습니다. 분명 매우 간단한 프로그램이지만, 어셈블리 코드에서 몇 가지 중요한 부분을 강조하고 싶습니다.
for 루프는 반복문이 없고 점프문만 있기 때문에 더 복잡합니다. 그럼 루프를 어떻게 만들까요? 먼저 루프의 시작 부분에서 조건이 이미 충족되었는지 확인합니다. 충족되었다면 루프의 끝으로 점프합니다. 그렇지 않다면 루프의 내부를 실행하고 카운터를 1 증가시킨 다음 루프의 시작 부분으로 점프합니다. 이런 식으로 구현할 수 있습니다.
또한 출력 부분도 좀 더 복잡합니다. 우리의 경우 ecall을 호출해야 하며 레지스터 A7에 특정 숫자를 전달함으로써 이를 수행합니다. 그래서 레지스터 A7에 19를 로드합니다.
'ecall 19'는 정수를 출력하라는 의미고 이게 실행되면 현재 정수를 출력합니다.
그리고 우리는 줄 바꿈도 출력하고 싶으니 숫자 33을 호출합니다. 'ecall 33'은 줄 바꿈입니다.
이를 하려면 해당 숫자들이 특정 ecall을 나타낸다는 것을 미리 알고 있거나 이 CPU를 위해 제가 작성한 문서를 찾아봐야 합니다.
어셈블러에게 이 코드를 어셈블하도록 요청하면 다음과 같은 기계어 코드가 생성됩니다. 그것은 기본적으로 부호 있는 32비트 정수들의 목록입니다.
이제 여러분은 "그래, CPU가 이걸 어떻게 이해해? 이건 그냥 텍스트 파일이잖아."라고 물을 수 있습니다.
우리가 이 숫자들로 무엇을 할까요? 제가 처음에 이미 언급했듯이 어셈블을 하면 긴 문자열이 생성되며, 이 문자열을 팩토리오에서 불러오면(import) 일정 신호 조합기들의 청사진을 얻게 되며 이 조합기들 안에 제 어셈블러가 다음 값들을 써두었습니다.
아마 익숙해 보일 겁니다. 이것들은 우리가 방금 전에 본 것과 정확히 같은 프로그램입니다.
따라서 프로그램은 이 일정 신호 조합기들(ROM)에 하드코딩되어 있으며 팩토리오 내부에 어떤 프로그램이든 불러올 수 있습니다.
자, 이제 프로그램을 실행해 보겠습니다. 우리가 ROM 0에 로드했으므로 R0를 호출해야 합니다.
피보나치 수열의 10개 항이 출력됩니다.
멋지네요.
이것이 바로 어셈블러가 작동하는 방식입니다.
파일 시스템과 FactOS
좋습니다.
소프트웨어 및 예시 섹션에서는 파일 시스템과 커맨드 라인 환경을 설명해보겠습니다.
파일 시스템의 경우, 제가 파일 시스템을 구현해 본 건 이번이 처음인데, 생각보다 간단합니다. 대략 어떻게 작동하느냐 하면, 전체 시스템은 많은 블록으로 구성되어 있고, 파일을 쓸 때 블록 하나를 사용하게 되지만 하나의 파일이 블록 하나보다 긴 경우도 있습니다. 그럴 경우 우리는 새로운 블록을 요청하고 이 블록들을 연결(Link)합니다. 그리고 블록의 마지막 부분에 '참고로 다음 블록은 어디어디에 있다'고 적어둡니다.
덕분에 서로 다른 크기의 파일들에 대해 공간을 동적으로 할당해 파일의 크기를 마음대로 늘리거나 줄일 수 있습니다.
저희 예제에서는 각각 256개의 워드가 들어있는 128개의 블록을 사용합니다. 이를 통해 간단한 디렉토리(파일) 구조를 구현할 수 있습니다. 디렉토리는 존재하긴 하지만 매우 단순합니다. 메타데이터도 없고, 권한도 없고, 사용자도 없고, 그런 것들이 전혀 없으며 이름으로는 최대 8개의 ASCII 심볼만 지정할 수 있습니다.
그리고 제가 두 번째로 구현한 것은 FactOS입니다.
FactOS는 커맨드 라인 환경이며, 사용자가 CPU와 상호작용하며 리눅스 스타일과 비슷할 수도 혹은 아닐 수도 있는 다양한 명령어를 입력할 수 있게 해줍니다. 새로운 디렉토리를 만들 수 있고, 파일을 생성할 수 있고, 이미 저장된 파일들을 실행하는 등 CPU로 수행할 수 있는 간단한 일들을 할 수 있습니다.
데모를 통해 살펴봅시다. 먼저 CPU를 활성화하거나 로드할 수 있고 그런 다음 binary(BIN) 폴더에서 수행할 수 있는 명령어 목록을 확인합니다.
TITLE이라는 파일이 있는 것 보이시죠? 그럼 TITLE 파일을 실행해 봅시다.
이 독일어로 된 텍스트를 출력한 걸 보니 실행 파일이었던 것 같습니다. 그냥 제가 39C3에서 했던 발표의 제목입니다.
우리는 스네이크(SNAKE) 게임도 실행할 수 있습니다.
스네이크는 이 컬러 디스플레이를 사용하며 로드되면 화살표 키로 플레이할 수 있고 우리가 일반적인 스네이크를 플레이하는 것과 비슷한 방식으로 할 수 있습니다. 이게 CPU가 스네이크 게임을 실행할 수 있는 최대 속도라서, 여기서 이기는 건 그렇게 어렵지 않아요.
하지만 이것은 여기서 실제로 프로그램을 돌릴 수 있다는 걸 보여주기 위한 증명(Proof of concept)일 뿐이며 솔직히 꽤 멋지다고 생각합니다. 이게 미니 게임의 한 예라고 할 수 있겠죠.
깃헙 레포지토리
좋습니다.
이제 팩토리오 게임플레이를 좀 더 살펴보겠습니다. 제가 카오스 커뮤니케이션 컨그레스에서 했던 발표 내용은 여기서 끝인데요. 하지만 네, 여러분이 원하신다면 이제 CPU에 대해 조금 더 보여드릴 수 있습니다. CPU를 시작하는 법이나 맵을 다운로드하는 법을 알고 싶으시다면, 다음 섹션에서 보여드리겠습니다.
여기 제 깃헙 리포지토리에 왔습니다. 링크는 설명란에 있습니다.
https://github.com/PhilippDahlinger/byte_factory
GitHub - PhilippDahlinger/byte_factory: Repository for my Factorio CPURepository for my Factorio CPU. Contribute to PhilippDahlinger/byte_factory development by creating an account on GitHub.github.com세이브 파일을 로드하고 CPU를 확인하고 싶은 플레이어들을 위한 방법이 여기 적혀 있습니다. 어떻게 파일을 저장하는지, 그리고 여러분이 상호작용하기 위해 무엇을 변경해야 하는지도 설명합니다. 궁금한 점이 있으면 댓글로 물어봐 주세요.
이게 CPU를 로드하는 방법입니다. FactOS 명령줄에 어떤 명령들이 구현되어 있는지에 대한 추가 설명도 있고 어셈블러 사용 방법에 대한 최소한의 지침도 있어요.
여러분이 확인해 볼 만한 것 중 하나는 제가 여기서 구현한 ISA에 대한 이 엑셀 시트인데 우리가 사용할 수 있는 모든 명령어들이 있습니다. 또한 레지스터와 같은 다른 내용들도 있고 여러분이 수행할 수 있는 모든 ecall 목록도 있지만, 이것은 약간 오래된 자료라 대신 ecall의 소스 코드를 직접 확인하시는 것을 추천합니다. 어셈블러 혹은 어셈블리의 소스 코드는 factorisco_v_a.ssembly 아래에 있으며 커널로 들어가면 인터럽트 핸들러가 있는데 여기서 현재 CPU에서 실제로 구현된 모든 ecall을 보여줍니다.
음, RAM에서 더 많은 공간을 요청하기 위한 sbrk 같은 것을 찾아보고 이것이 어떻게 구현되었는지 볼 수 있습니다. 또한 확인해 보실 만한 또 다른 것은 커널 안에 os라는 것이 있는데 이건 커맨드 라인 인터페이스입니다. 어떻게 cmd_run이나 cmd_rumrom과 같은 명령들을 구현하는지, 모든 서로 다른 명령들이 어떻게 구현되었는지 원하신다면 여기서 확인하실 수 있습니다.
그리고 여러분 스스로 프로그래밍을 해보고 싶으시다면, 어셈블러의 문법이 정확히 어떻게 되는지에 대한 직접적인 지침은 없지만, 기본적으로 RISC-V 어셈블리입니다. 아마 약간의 차이는 있겠지만... 저는 파이썬 출신이라 해시태그(#)를 주석으로 사용하는 구문이 마음에 듭니다.
하지만 네, 여러분은 예시 프로그램들을 확인하실 수 있습니다. 이것은 피보나치 수열 프로그램이고 또한 우리가 처음에 본 지뢰찾기를 위한 것도 있으며 여러분이 살펴보고 프로그램이 어떻게 작동하는지 아이디어를 얻는데 도움이 될 만한 예제들이 많이 있습니다. 또한 데이터 세그먼트가 어떻게 보여야 하는지도요.
네, 멋지네요. 프로그래밍을 직접 해보고 싶으시다면 한번 확인해 보세요.
댓글 0