에피소드 V: 명령어 디코더
지금까지 값을 저장하고 읽어 올 수 있는 레지스터 파일, 값에 연산을
적용할 수 있는 ALU, 그리고 값을 검사할 수 있는 술어(predicate)를
갖추었습니다. 프로세서의 명령어는 메모리에서 가져오게 되는데, 현재
명령어가 instr라는 32비트 레지스터에 저장되어 있다고
합시다. 각 명령어는 32비트 워드입니다. 이제 명령어에 따라 레지스터와
ALU로 무엇을 할지 결정하는 하드웨어 구성 요소를 정의해야 합니다. 주요
참고 자료는 이번에도 RISC-V
참조 매뉴얼 130쪽의 표입니다. opcode(비트 [6:0])에 따라
분기하는 switch 문이 들어가리라는 것은 이미 알고 있습니다:
wire[6:0] opcode = instr[6:0];
ALU 연산/분기 검사(비트 [14:12])를 추출해야 한다는 것도
이미 알고 있습니다:
wire [2:0] op = instr[14:12];
레지스터를 조작하는 연산들이 있습니다. 이 연산들은 소스 레지스터
하나(rs1) 또는 두 개(rs1,rs2)를
받아 결과를 목적지 레지스터(rd)에 저장합니다. 같은 표를
보면 rs1([19:15]),
rs2([24:20]),
rd([11:7])를 인코딩하는 데 항상
instr의 같은 비트가 사용된다는 것을 알 수 있으므로, 이를
위한 Verilog 코드를 작성할 수 있습니다:
wire [4:0] rd = instr[11:7];
wire [4:0] rs1 = instr[19:15];
wire [4:0] rs2 = instr[24:20];
또 하나 중요한 것은 130쪽 상단의 작은 표입니다. 이 표를 보면 명령어에 여러 유형이 있다는 것을 알 수 있습니다. 이것이 무엇을 뜻하는지 이해하기 위해 2장 16쪽으로 돌아가 봅시다. 서로 다른 명령어 유형은 명령어 안에 즉시값(immediate value)이 인코딩되는 방식에 대응합니다.
| 명령어 유형 | 설명 | 즉시값 인코딩 |
|---|---|---|
R-type |
레지스터-레지스터 ALU 연산. 자세한 내용은 여기를 참고하십시오 | 없음 |
I-type |
레지스터-즉시값 정수 ALU 연산 및 JALR. |
12비트, 부호 확장 |
S-type |
store | 12비트, 부호 확장 |
B-type |
Branch | 12비트, 부호 확장, 상위 [31:1](비트 0은 0) |
U-type |
LUI,AUIPC |
20비트, 상위 31:12 (비트 [11:0]은 0) |
J-type |
JAL |
12비트, 부호 확장, 상위 [31:1] (비트 0은 0) |
I-type과 S-type은 같은 종류의 값을
인코딩한다는 점에 유의하십시오(다만 값을 가져오는 위치는
instr의 서로 다른 부분입니다). B-type과
J-type도 마찬가지입니다.
명령어 워드에 흩어져 있는 비트들로부터 즉시값을 재구성해야 합니다. 이를 위해서는 RISC-V 참조 매뉴얼 17쪽 그림 2.4의 표가 큰 도움이 됩니다(즉시값 형식마다 각 비트를 어디에서 가져오는지 알려 줍니다). 이 표는 다음과 같이 Verilog로 그대로 옮길 수 있습니다:
wire [31:0] Iimm = {{21{instr[31]}}, instr[30:20]};
wire [31:0] Simm = {{21{instr[31]}}, instr[30:25], instr[11:7]};
wire [31:0] Bimm = {{20{instr[31]}}, instr[7], instr[30:25], instr[11:8], 1'b0};
wire [31:0] Jimm = {{12{instr[31]}}, instr[19:12], instr[20], instr[30:21], 1'b0};
wire [31:0] Uimm = {instr[31], instr[30:12], {12{1'b0}}};
또한 올바른 즉시값 형식을 선택하는 mux가 있어야 합니다(여기서는 보여
드리지 않습니다). 명령어 디코더의 여러 신호가 커다란 switch
문 안에서 어떻게 생성되는지는 나중에 보여 드리겠습니다.
곁가지 이야기: RISC-V의 우아함
이 단락은 건너뛰어도 됩니다. RISC-V 참조 매뉴얼에 이탤릭체로 실린 주석과 Q&A에서 영감을 받아, RISC-V 명령어 집합에 대해 제가 받은 인상과 생각을 담았을 뿐입니다.
이 시점에서 저는 명령어 집합 아키텍처가 무엇을 뜻하는지 깨달았습니다. 그것은 분명 어떤 비트 패턴이 무엇을 하는지에 대한 명세(Instruction Set)이면서, 동시에 그것이 어떻게 배선으로 옮겨질지에 따라 결정되는 것(Architecture)이기도 합니다. ISA는 추상적인 것이 아니라 구현과 독립적인 것이며, 구현을 강하게 염두에 두고 설계됩니다! 파이프라인, Branch 예측 유닛, 여러 실행 유닛, 캐시는 구현마다 다를 수 있지만, 명령어 디코더는 아마 모든 구현에서 매우 비슷할 것입니다.
처음에는 정말 이상해 보이는 것들이 있었습니다. 온갖 즉시값 형식 변형,
즉시값이 instr의 여러 비트에 뒤섞여 있다는 사실,
zero 레지스터, 그리고 이상한 명령어인
LUI,AUIPC,JAL,JALR가
그랬습니다. 명령어 디코더를 작성해 보면 그 이유를 더 잘 이해하게 됩니다.
이 ISA는 정말 영리하며, 오랜 진화의 결과물입니다(이전에 RISC-I, RISC-II,
...가 있었습니다). 제게는 이것이 증류의 결과물처럼 보입니다.
2020년 현재 ISA 측면에서 많은 것이 시도되었고, 이 ISA는 이전의 모든
시도에서 혜택을 받아 좋은 선택은 취하고 최적이 아닌 선택은 피한 것으로
보입니다.
이 ISA에서 정말 좋은 점은 다음과 같습니다:
- 명령어 크기가 고정되어 있습니다. 덕분에 일이 정말 쉬워집니다. (명령어 길이가 가변적인 확장도 있지만, 적어도 핵심 명령어 집합은 단순합니다);
rs1,rs2,rd는 항상instr의 같은 비트로 인코딩됩니다;- 부호 확장이 필요한 즉시값 형식은 모두 같은
비트(
instr[31])에서 부호 확장을 합니다; - 이상한 명령어인
LUI,AUIPC,JAL,JALR를 조합하면 더 높은 수준의 작업(레지스터에 32비트 상수 로드, 임의 주소로 점프, 함수 호출)을 구현할 수 있습니다. 이 명령어들은 설계를 더 쉽게 만들어 준다는 점에서 존재 이유가 있습니다. 그리고CALL,RET, ... 같은 의사 명령어가 어셈블리 프로그래머의 삶을 더 편하게 해 줍니다. RISC-V 어셈블리 매뉴얼 페이지 끝에 있는 두 표를 참고하십시오. 매개변수를 맞바꾸어 얻을 수 있는 비교/분기 명령어(예:a < b <=> b > a등...)도 마찬가지로, 이를 대신 처리해 주는 의사 명령어가 있습니다.
달리 말해, RISC-V ISA의 우아함을 제대로 이해하려면 여러분의 임무가 그것을 발명하는 것이라고 상상해 보십시오. 즉, 명령어 집합과 그 명령어들이 비트 패턴으로 인코딩되는 방식을 모두 발명하는 것입니다. 제약 조건은 다음과 같습니다.
- 고정된 명령어 길이(32비트)
- 가능한 한 단순할 것: 궁극의 정교함은 단순함입니다 [레오나르도 다 빈치] !!
- 소스 레지스터와 목적지 레지스터는 항상 같은 위치에 인코딩되어야 합니다
- 부호 확장이 있을 때는 언제나 같은 비트에서 이루어져야 합니다
- 임의의 32비트 즉시값을 레지스터에 적재하는 것이 간단해야 합니다(단, 여러 명령어가 필요할 수 있습니다)
- 임의의 메모리 위치로 점프하는 것이 간단해야 합니다(단, 여러 명령어가 필요할 수 있습니다)
- 함수 호출을 구현하는 것이 간단해야 합니다(단, 여러 명령어가 필요할 수 있습니다)
그러면 왜 즉시값 형식이 이렇게 여러 가지인지 이해할 수 있습니다. 예를
들어, 소스 레지스터가 없는 JAL을 소스 레지스터가 하나 있는
JALR과 비교해 보십시오. 둘 다 즉시값을 받지만,
JAL은 소스 레지스터를 인코딩할 필요가 없으므로 즉시값을
저장하는 데 5비트를 더 쓸 수 있습니다. 사용할 수 있는 비트는 단 하나라도
즉시값의 동적 범위를 넓히는 데 쓰입니다. 이것으로 즉시값 형식이 여러
가지인 이유뿐만 아니라, 즉시값이 instr의 여러 조각을 모아
조립되며, 경우에 따라 있기도 하고 없기도 한 고정된 세 개의 5비트
레지스터 인코딩 사이를 이리저리 피해 가는 이유도 설명됩니다.
이제 LUI,AUIPC,JAL,
JALR이라는 기묘한 명령어들이 존재하는 근거는, 조합하여
임의의 32비트 값을 레지스터에 적재하거나, 메모리의 임의 위치로
점프하거나, 함수 호출 규약을 가능한 한 단순하게 구현할 수 있는 기능
집합을 제공하는 데 있습니다. 제약 조건을 고려하면, 이렇게 내린
선택들은(처음에는 제게 기묘해 보였지만) 완벽하게 이치에 맞습니다. 게다가
이러한 선택 덕분에 명령어 디코더는 꽤 단순하며 논리 깊이도 낮습니다.
7비트 명령어 디코더를 제외하면, 디코더는 대부분 instr의
비트에서 끌어온 배선들과, 즉시값을 만들기 위해 부호 확장된 31번 비트를
복제하는 부분으로 이루어져 있습니다.
다음으로 넘어가기 전에, zero 레지스터에 대해 한 워드,
아니 한마디 하고 싶습니다. 저는 이것이 정말 영리한 선택이라고
생각합니다. 이 레지스터 덕분에 MOV rd rs 명령어가 필요
없고(ADD rd rs zero면 됩니다), NOP 명령어도
필요 없으며(ADD zero zero zero), 모든 분기 변형이
zero와 비교할 수 있습니다! 저는 zero가
0만큼 위대하지는 않더라도 훌륭한 발명이며, 명령어 집합을
확실히 더 간결하게 만들어 준다고 생각합니다.
자, 이제 전체 그림이 어느 정도 보이니, 9가지 서로 다른 명령어(레지스터-레지스터 ALU, 레지스터-즉시값 ALU, 분기, 4가지 기묘한 명령어, 로드와 스토어)를 해석하는 하드웨어를 만들 수 있습니다. 선형 실행 흐름만 지원하는 아주 단순한 설계에서 시작하여, 한 단계씩 기능을 보강해 나가겠습니다.