Chapter 13

C와 Zc*: 압축 명령어

RISC-V의 기본 명령은 모두 32비트다. 그런데 프로그램을 들여다보면 addi sp, sp, -16, mv a0, s0, ret처럼 정보가 몇 비트 안 되는 명령이 절반을 넘는다. C 확장은 이런 명령에 16비트짜리 짧은 이름을 붙여 코드 크기를 25–30% 줄인다. 핵심은 “새 명령을 만들지 않는다”는 것이다. 모든 16비트 명령은 이미 있는 32비트 명령 하나로 정확히 펼쳐진다. 이 장에서는 그 약속이 어떤 비트 배치로 실현되는지, 그리고 C를 잘게 나누고 늘린 Zc* 확장이 무엇을 더했는지 직접 압축해 보며 익힌다.

왜 16비트 명령인가

32비트 고정 길이는 해독기를 단순하게 만든다(4장). 대가는 코드 크기다. ret은 “ra로 점프”라는 정보뿐인데도 32비트를 다 차지한다. 코드가 커지면 마이크로컨트롤러는 플래시 메모리를 더 사야 하고, 큰 서버 프로그램은 명령어 캐시가 넘쳐 느려진다. RISC-V는 이 문제를 압축 명령어(compressed instructions, RVC)로 푼다. 자주 쓰이고 정보가 적은 명령에 한해 16비트 인코딩을 하나 더 정의하는 것이다.

RV32I — 여덟 명령이 모두 32비트 32 바이트 048121620242832 addisp,sp,-16 swra,12(sp) luit1,0x12345 lwa1,0(a0) adda1,a1,t1 sltt0,a1,a2 lwra,12(sp) ret(jalr) RV32IC — 자주 쓰는 명령은 16비트로 32 바이트 20 바이트 (−37.5%) 12 바이트가 비었다 addi sw lui lw add slt lw ret 16비트로 줄일 수 있는 명령 (6개) 32비트로 남는 명령 (2개): 큰 즉시값, x8–x15 밖 레지스터 조합
그림 13-1. 같은 여덟 명령의 명령어 스트림이 압축되며 당겨지는 모습. lui t1, 0x12345는 즉시값이 커서, slt는 대응하는 16비트 명령이 없어서 32비트로 남는다. 16비트와 32비트 명령은 자유롭게 섞이며, 32비트 명령도 2바이트 경계(예: 4번지가 아닌 2·6번지)에서 시작할 수 있다.
규격 원문에서
“Typically, 50%-60% of the RISC-V instructions in a program can be replaced with RVC instructions, resulting in a 25%-30% code-size reduction.”

Volume I, "C" Extension for Compressed Instructions — 같은 장의 비고는 Waterman의 측정을 인용해, RVC가 인출하는 명령어 비트를 25–30% 줄이고 명령어 캐시 미스를 20–25% 줄인다고, 이는 명령어 캐시를 두 배로 키운 것과 비슷한 효과라고 적는다.

압축 명령어는 RISC-V가 처음 생각한 아이디어가 아니다. 그러나 앞선 RISC들은 처음에 opcode 자리를 남겨 두지 않았기 때문에, 압축 명령을 별도의 ISA로 만들어야 했다. RISC-V는 “25년 뒤늦게 본 덕분에” 처음부터 자리를 비워 두었다.

방식예특징결과
16비트 전용 모드ARM Thumb, MIPS16모드를 바꿔 16비트 명령만 실행코드는 작지만 실행 명령 수가 늘어 느려짐
16/32비트 혼합 (별도 ISA)Thumb-2, microMIPS섞어 쓸 수 있지만 원래 ISA와 인코딩이 다름성능은 회복, 대신 컴파일러·문서·구현이 세대마다 따로
처음부터 계획한 확장RISC-V C기본 ISA 위에 얹는 확장, 언제든 섞어 씀, 16비트 하나 = 32비트 하나도구와 하드웨어 변경이 최소

섞여 있어도 길이를 아는 법: 사분면과 IALIGN

16비트와 32비트 명령이 아무 순서로나 섞여 있으면, 하드웨어는 명령이 어디서 끝나는지 어떻게 알까? 답은 가장 아래 두 비트다. RISC-V의 모든 32비트 명령은 inst[1:0] = 11로 끝난다(4장의 opcode가 모두 …11인 이유). 따라서 아래 두 비트가 00, 01, 10이면 16비트 명령이다. RVC는 이 세 값을 사분면(quadrant) 0·1·2라 부르고, 각 사분면 안에서 inst[15:13](funct3)로 명령을 다시 나눈다.

① 하위 비트만 보고 길이 판정 inst[1:0] ≠ 11 16비트 (사분면 0·1·2) [1:0] = 11, [4:2] ≠ 111 32비트 (기본 ISA) inst[4:0] = 11111 48비트 이상 (긴 명령용) ② RVC opcode 지도 (규격 Table 4, RV32 기준) 000 001 010 011 100 101 110 111 inst[15:13] → 00 사분면 0 ADDI4SPN FLD LW FLW (32) LD (64) 예약 → Zcb FSD SW FSW (32) SD (64) 01 사분면 1 ADDI JAL (32) ADDIW (64) LI LUI · ADDI16SP MISC-ALU J BEQZ BNEZ 10 사분면 2 SLLI FLDSP LWSP FLWSP (32) LDSP (64) JR · MV JALR · ADD FSDSP SWSP FSWSP (32) SDSP (64) 11 16비트보다 긴 명령 — RV32I·RV64I의 32비트 명령 전부 회색 칸은 부동소수점 적재·저장, 주황 칸은 C에서 비워 둔 자리(지금은 Zcb가 사용)
그림 13-2. 왼쪽: 길이는 아래 몇 비트만으로 정해진다. 해독기가 명령 전체를 보기 전에 “다음 명령이 어디서 시작하는지”를 알 수 있어야 여러 명령을 한꺼번에 인출·정렬할 수 있다. 오른쪽: 세 사분면 × funct3 여덟 칸의 지도. 금색 테두리가 사분면을 차례로 훑는다. RV32와 RV64가 같은 자리를 다른 명령에 쓰는 곳(예: JAL/ADDIW)이 있다.

IALIGN = 16: 명령이 2바이트 경계에서 시작한다

C 확장이 없을 때 명령은 4바이트 경계에만 놓인다(IALIGN=32). C가 있으면 32비트 명령도 2바이트 경계에서 시작할 수 있다(IALIGN=16). 규격은 이렇게 하면 32비트 명령 앞에 2바이트 빈칸(패딩)을 넣지 않아도 되므로 코드 밀도가 크게 좋아진다고 설명한다. 덤으로, C가 있는 시스템에서는 분기·점프 목표 주소가 짝수이기만 하면 되므로 명령 주소 정렬 예외(instruction-address-misaligned)가 아예 일어날 수 없다.

규칙 하나: 16비트 명령은 32비트 명령 하나로 펼쳐진다

RVC의 가장 중요한 설계 제약은 “모든 RVC 명령은 기본 ISA(또는 F·D)의 32비트 명령 정확히 하나로 펼쳐진다”이다. 그래서 하드웨어는 해독 단계 앞에 작은 확장기(expander, decompressor)만 붙이면 되고, 그 뒤의 파이프라인(8장)은 C가 있는지조차 모른다. 컴파일러도 C를 몰라도 된다. 어셈블러와 링커가 알아서 줄여 준다.

인출 2바이트 묶음 단위 길이 판정 inst[1:0] 확장기 (작은 표) c.li a0, 1 = 0x4505 → 0x00100513 그대로 통과 add = 0x00b50533 32비트 해독기 C를 모른다 실행 c.li a0, 1 은 addi a0, zero, 1 과 완전히 같은 명령이 된다
그림 13-3. 해독 앞단의 확장기. 금색 점(16비트 명령)은 확장기에서 32비트 명령으로 바뀌고, 파란 점(32비트 명령)은 그대로 지나간다. 뒤쪽 해독기와 실행 장치는 두 경우를 구별하지 못한다. 유일한 차이는 다음 pc를 +2로 셀지 +4로 셀지뿐이다.
모든 비트가 0인 16비트는 영원히 불법 명령

규격은 0x0000을 “정의된 불법 명령”으로 영구히 예약했다. 지워진 메모리나 존재하지 않는 영역(대개 0으로 채워진다)으로 잘못 뛰어들었을 때 곧바로 트랩이 나도록 하기 위해서다. 같은 이유로 모든 비트가 1인 값도 불법 명령으로 남겨 두었다. 비표준 확장도 이 값들을 다른 뜻으로 쓰면 안 된다.

아홉 가지 압축 형식

16비트에 opcode·레지스터·즉시값을 다 넣으려면 무언가를 포기해야 한다. RVC는 세 가지 방법을 섞는다. 첫째, 목적지와 첫 원본을 같게 한다(add rd, rd, rs2처럼 2-주소 형식). 둘째, 레지스터를 3비트로 줄여 가장 많이 쓰는 8개만 가리킨다. 셋째, 즉시값을 짧게 하고 자료 크기만큼 배율을 곱한다. 이 조합에 따라 형식이 아홉 가지로 나뉜다.

15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0 CR 레지스터 funct4 rd/rs1 rs2 op CI 즉시값 funct3 imm rd/rs1 imm op CSS 스택 상대 저장 funct3 imm rs2 op CIW 넓은 즉시값 funct3 imm rd′ op CL 적재 funct3 imm rs1′ imm rd′ op CS 저장 funct3 imm rs1′ imm rs2′ op CA 산술 funct6 rd′/rs1′ funct2 rs2′ op CB 분기·산술 funct3 offset rd′/rs1′ offset op CJ 점프 funct3 jump target op rs1′ 자리 (9:7) rd′·rs2′ 자리 (4:2)
funct (명령 구별)레지스터즉시값·오프셋op = 사분면
그림 13-4. RVC의 아홉 형식(규격 Table 1). 청록 띠는 3비트 레지스터 칸이 어느 형식에서나 같은 자리(rs1′은 9:7, rd′·rs2′은 4:2)에 있다는 것을, CR·CI·CSS의 5비트 rd/rs1(11:7)과 rs2(6:2)도 서로 같은 자리에 있다는 것을 보여 준다. 5비트 rd/rs1이 있는 형식에서는 그 자리가 32비트 명령의 rd 자리(11:7)와 같다.
형식뜻레지스터대표 명령
CRRegister5비트 rd/rs1, rs2c.mv c.add c.jr c.jalr c.ebreak
CIImmediate5비트 rd/rs1c.li c.lui c.addi c.addi16sp c.slli c.lwsp
CSSStack-relative Store5비트 rs2c.swsp
CIWWide Immediate3비트 rd′c.addi4spn (8비트 즉시값)
CLLoad3비트 rd′, rs1′c.lw (c.flw c.fld c.ld)
CSStore3비트 rs1′, rs2′c.sw (c.fsw c.fsd c.sd)
CAArithmetic3비트 rd′/rs1′, rs2′c.sub c.xor c.or c.and
CBBranch/Arithmetic3비트 rs1′c.beqz c.bnez c.srli c.srai c.andi
CJJump없음c.j c.jal

3비트 레지스터는 왜 x8–x15인가

CIW·CL·CS·CA·CB 형식의 레지스터 칸(rd′·rs1′·rs2′, 프라임 기호를 붙여 구별한다)은 3비트라 8개밖에 못 가리킨다. 규격은 이 8개를 x8–x15로 정했고, 반대로 ABI 쪽에서 가장 자주 쓰는 레지스터들(s0·s1과 인자 레지스터 a0–a5)이 바로 이 번호에 오도록 호출 규약을 바꿨다(7장). 연속하고 자연스럽게 정렬된 번호 묶음이라 해독기는 3비트 앞에 01만 붙이면 된다. 레지스터가 16개뿐인 RV32E/RV64E와도 그대로 맞는다.

3비트 칸 rd′ · rs1′ · rs2′ 000 001 010 011 100 101 110 111 앞에 01을 붙인다 x8s0 / fp01000 x9s101001 x10a001010 x11a101011 x12a201100 x13a301101 x14a401110 x15a501111 부동소수점 압축 적재·저장도 같은 방식으로 f8–f15 (fs0 · fs1 · fa0–fa5)를 가리킨다
그림 13-5. 3비트 레지스터 번호(규격 Table 2). 해독기는 덧셈 없이 배선만으로 5비트 번호를 만든다. 반대로 말하면, t0–t6나 s2–s11을 쓰는 연산은 CA·CL·CS 형식으로 줄일 수 없다. 컴파일러가 뜨거운 값을 s0·s1·a0–a5에 배치할수록 코드가 작아지는 이유다.

C 명령어 한눈에 보기

RV32C의 정수 명령을 펼쳐지는 32비트 명령과 함께 정리하면 다음과 같다. “조건”을 벗어나면 그 16비트 인코딩은 다른 명령의 자리이거나, 예약되었거나, HINT(아무 상태도 바꾸지 않는 성능 힌트용 자리)다.

C 명령형식펼친 32비트 명령조건 · 범위
c.addi4spnCIWaddi rd′, sp, nzuimm4–1020, 4의 배수 (0은 예약)
c.lwCLlw rd′, uimm(rs1′)0–124, 4의 배수
c.swCSsw rs2′, uimm(rs1′)0–124, 4의 배수
c.nopCIaddi x0, x0, 0즉시값 ≠ 0 이면 HINT
c.addiCIaddi rd, rd, nzimmrd ≠ x0, −32–31, 0은 HINT
c.jalCJjal ra, offsetRV32 전용, ±2 KiB
c.liCIaddi rd, x0, imm−32–31, rd = x0 이면 HINT
c.addi16spCIaddi sp, sp, nzimm16의 배수, −512–496, 0은 예약
c.luiCIlui rd, nzimmrd ≠ x0·x2, nzimm[17:12] 6비트 부호 값 ≠ 0
c.srli · c.sraiCBsrli/srai rd′, rd′, shamtshamt ≠ 0 (RV32는 shamt[5] = 0)
c.andiCBandi rd′, rd′, imm−32–31
c.sub · c.xor · c.or · c.andCAop rd′, rd′, rs2′세 레지스터 모두 x8–x15
c.jCJjal x0, offset±2 KiB
c.beqz · c.bnezCBbeq/bne rs1′, x0, offset±256 B
c.slliCIslli rd, rd, shamtrd = x0 이나 shamt = 0 은 HINT
c.lwspCIlw rd, uimm(sp)rd ≠ x0, 0–252, 4의 배수
c.swspCSSsw rs2, uimm(sp)0–252, 4의 배수
c.jrCRjalr x0, 0(rs1)rs1 ≠ x0 (ret = c.jr ra)
c.jalrCRjalr ra, 0(rs1)rs1 ≠ x0, 링크 값은 pc + 2
c.mvCRadd rd, x0, rs2rs2 ≠ x0 (0이면 c.jr 자리)
c.addCRadd rd, rd, rs2rs2 ≠ x0 (0이면 c.jalr·c.ebreak 자리)
c.ebreakCRebreakc.add 자리에서 rd = rs2 = 0

표에는 정수 명령만 넣었다. F·D 확장이 함께 있으면 c.flw·c.fsw·c.flwsp·c.fswsp(RV32만)와 c.fld·c.fsd·c.fldsp·c.fsdsp도 반드시 제공해야 한다. RV64C는 같은 자리를 c.ld·c.sd·c.ldsp·c.sdsp(64비트 적재·저장)와 c.addiw(RV32의 c.jal 자리), c.addw·c.subw에 쓴다. 주소 공간이 넓은 RV64에서는 64비트 값 적재·저장이 더 중요하고, 작은 RV32 마이크로컨트롤러에서는 짧은 함수 호출이 더 흔하기 때문이다.

즉시값은 흩어져 있고, 배율이 곱해진다

적재·저장 오프셋은 부호 없는 값이고 자료 크기만큼 배율이 붙는다(워드 ×4, 더블워드 ×8). 정렬된 워드의 주소 하위 두 비트는 어차피 0이니 저장할 필요가 없다. 그 대신 같은 5비트로 4배 먼 곳까지 닿는다. 32비트 명령처럼 즉시값 비트는 흩어져 있는데, 규격은 “되도록 많은 비트가 모든 명령에서 같은 자리에 오도록” 하기 위해서라고 설명한다. 부호 확장이 필요한 즉시값의 부호 비트는 언제나 inst[12]다.

c.lw a0, 36(a1) 0x51c8 funct3 = 010 uimm[5:3] rs1′ = a1 [2] [6] rd′ = a0 op 0 15 1 14 0 13 1 12 0 11 0 10 0 9 1 8 1 7 1 6 0 5 0 4 1 3 0 2 0 1 0 0 0 uimm[6] 1 uimm[5] 0 uimm[4] 0 uimm[3] 1 uimm[2] 0 uimm[1] 0 uimm[0] 항상 0 → ×4 = 0b0100100 = 36 5비트만 저장하고 하위 두 비트는 0 → 0–124 바이트, 4의 배수
그림 13-6. c.lw a0, 36(a1) = 0x51c8을 해독하는 과정. inst[12:10]이 uimm[5:3], inst[6]이 uimm[2], inst[5]가 uimm[6]이 되고 하위 두 비트 0이 붙어 오프셋 36이 된다. 금색 점선이 비트의 이동 경로다.
명령저장 비트배율범위왜 이 범위인가
c.lw / c.sw5×40–124구조체 필드·작은 배열
c.lwsp / c.swsp6×40–252스택 프레임 안의 저장 슬롯
c.addi16sp6 (부호)×16−512–496ABI가 sp를 늘 16바이트 정렬로 유지
c.addi4spn8×44–1020스택에 있는 지역 변수의 주소 만들기
c.beqz / c.bnez8 (부호)×2±256 B짧은 루프와 조건문
c.j / c.jal11 (부호)×2±2 KiB같은 함수 안, 가까운 함수 호출
HINT 자리는 왜 남겨 두었나

c.addi a0, 0처럼 아무 효과가 없는 인코딩은 “하는 일 없는 계산”으로 실행해도 되므로, 단순한 구현은 신경 쓸 필요가 없다. 규격은 이런 자리를 성능 힌트용으로 모아 두었다. 실제로 c.add x0, x2–c.add x0, x5 네 자리는 Zihintntl의 c.ntl.*(이번 접근은 다시 안 쓸 데이터라는 힌트)로 쓰인다. RV32C의 HINT 공간 중 78%는 표준용, 나머지는 사용자 정의용이다.

직접 압축해 보기

아래 압축기에 RV32I 명령 하나를 넣으면, 그 명령이 될 수 있는 C 명령 후보를 모두 검사해 성공한 인코딩의 비트 배치를 보여 주고, 실패한 후보는 어떤 조건 때문에 안 되는지 알려 준다. 어셈블러가 실제로 하는 일과 같다. 이 압축기의 결과는 LLVM 어셈블러(llvm-mc)의 인코딩과 한 비트까지 같도록 검증해 두었다.

SIMULATOR

RV32I → RVC 압축기

예시
원래 크기4 B
압축 후—
형식—
16비트 인코딩—
lw a5, 8(a0)와 lw t3, 8(a0), lw a5, 128(a0)와 lw a5, 128(sp)를 차례로 넣어 보자. 레지스터 하나, 기준 주소 하나만 바뀌어도 결과가 달라진다. mv a0, a1은 addi인데도 c.mv(= add)가 되는 점도 눈여겨보자. 결과가 같으면 다른 32비트 명령으로 펼쳐져도 된다.

코드 밀도 실험

명령 하나하나보다 중요한 것은 프로그램 전체가 얼마나 줄어드느냐다. 아래 실험은 riscv.js로 프로그램을 어셈블한 뒤 각 명령을 압축기에 넣는다. 분기·점프는 조금 까다롭다. 앞쪽 명령이 줄면 목표까지의 거리도 줄기 때문에, 처음에는 범위를 벗어나던 분기가 압축 가능해질 수 있다. 그래서 실제 링커처럼 배치 → 거리 재계산 → 다시 압축을 더 바뀌지 않을 때까지 되풀이한다(링커 완화, linker relaxation).

ANIMATION

메모리 배치가 줄어드는 모습

예제 프로그램
실행
예제를 고르거나 소스를 고친 뒤 압축 애니메이션을 눌러 보자.
명령 수—
압축된 명령—
RV32I 크기—
RV32IC 크기—
줄어든 비율—
“배열 합 함수”는 호출 규약을 잘 지킨 코드라 거의 모든 명령이 줄어든다. “x8–x15 밖 레지스터”는 같은 일을 하더라도 레지스터 배치와 큰 즉시값 때문에 거의 줄지 않는다. 같은 프로그램에서 t0를 a5로 바꾸면 몇 바이트가 줄어드는지 소스를 고쳐 확인해 보자.
평균은 25–30%

16비트 명령은 크기가 절반이므로, 명령의 50–60%가 압축되면 코드 크기는 25–30% 준다. 규격 서두의 숫자가 바로 이 계산이다. 압축률은 컴파일러의 레지스터 배치에 크게 좌우된다. 그래서 컴파일러의 레지스터 할당기는 C가 켜져 있으면 압축하기 좋은 레지스터(x8–x15)를 고르도록 힌트를 주기도 한다(예: LLVM).

Zc*: C를 쪼개고, 늘리고

C 확장은 한 덩어리로 정의되었지만, 쓰다 보니 두 가지 요구가 생겼다. 하나는 나누기다. 부동소수점 압축 적재·저장은 F·D가 있을 때만 의미가 있으니 따로 부를 이름이 필요했다. 다른 하나는 늘리기다. 마이크로컨트롤러 업계는 코드 크기를 더 줄일 명령을 원했다. 그 결과가 Zc* 확장 묶음(v1.0.0)이다.

C 확장 = Zca (+ Zcf) (+ Zcd) Zca 정수 압축 명령 전부 c.addi · c.lw · c.j … C에서 c.f* 를 뺀 것 Zcf c.flw · c.fsw RV32 + F 일 때 Zcd c.fld · c.fsd + D 일 때 Zcb — 단순한 추가 명령 c.lbu · c.lhu · c.lh · c.sb · c.sh c.zext.b · c.sext.b · c.zext.h c.sext.h · c.zext.w · c.not · c.mul Zcmp cm.push · cm.pop cm.popret(z) · cm.mv* Zcmt cm.jt · cm.jalt + jvt CSR (0x017) 같은 인코딩 자리: 사분면 2 · funct3 = 101 (c.fsdsp 자리) ✕ 함께 못 씀 Zce (RV32, F 없음) = Zca + Zcb + Zcmp + Zcmt — 마이크로컨트롤러용 묶음
그림 13-7. Zc* 확장의 관계. 왼쪽 점선 상자가 기존 C 확장이고, 오른쪽이 새로 더해진 명령이다. Zcmp와 Zcmt는 Zcd의 c.fsdsp 인코딩 자리를 다시 쓰므로, C와 D를 함께 가진(따라서 Zcd를 가진) 구현에서는 쓸 수 없다.
확장내용의존 · 주의
ZcaC에서 부동소수점 적재·저장을 뺀 모든 명령C는 항상 Zca를 포함
Zcfc.flw c.flwsp c.fsw c.fswspRV32 전용, Zca·F 필요. C+F이면 자동 포함
Zcdc.fld c.fldsp c.fsd c.fsdspZca·D 필요. C+D이면 자동 포함
Zcb바이트·하프워드 적재·저장, 확장·반전, 곱셈의 16비트판Zca 필요. 인코딩 충돌 없음
Zcmp함수 프롤로그·에필로그용 push/pop, 레지스터 쌍 이동Zca 필요. Zcd와 양립 불가, 응용 프로세서 프로파일과 맞지 않음
Zcmt표를 거치는 점프(table jump)Zca·Zicsr 필요. Zcd와 양립 불가, RVA 프로파일과 맞지 않음
규격 원문에서
“This extension reuses some encodings from c.fsdsp. Therefore it is incompatible with Zcd, which is included when C and D extensions are both present.”

Volume I, "Zc*" Extension for Code Size Reduction — Zcmp 절과 Zcmt 절에 똑같은 문장이 있다. 실제로 LLVM 어셈블러에 Zcmp와 Zcd를 함께 켜면 “incompatible” 오류를 내며 거부한다.

Zcb: 기본 C가 빠뜨린 것들

기본 C는 워드 단위 적재·저장만 줄였다. 문자열과 바이트 배열을 많이 다루는 임베디드 코드에서는 아쉬운 부분이다. Zcb는 비어 있던 사분면 0의 자리(funct3 = 100)와 CA 형식의 남는 자리를 써서 다음 명령을 더한다. 모두 기존 32비트 명령의 16비트판이다.

Zcb 명령같은 일을 하는 32비트 명령조건함께 필요한 확장
c.lbulbu rd′, uimm(rs1′)uimm 0–3—
c.lhu · c.lhlhu/lh rd′, uimm(rs1′)uimm 0 또는 2—
c.sbsb rs2′, uimm(rs1′)uimm 0–3—
c.shsh rs2′, uimm(rs1′)uimm 0 또는 2—
c.zext.bandi rd′, rd′, 0xffrd = rs1—
c.sext.b · c.zext.h · c.sext.hsext.b · zext.h · sext.hrd = rs1Zbb (14장)
c.zext.wadd.uw rd′, rd′, zeroRV64 전용Zba
c.notxori rd′, rd′, -1rd = rs1—
c.mulmul rd′, rd′, rs2′CA 형식M 또는 Zmmul (9장)

Zcmp: 프롤로그·에필로그를 한 명령으로

함수 앞뒤에서 ra와 s 레지스터를 스택에 저장하고 되살리는 코드는 정적 코드 크기의 큰 몫을 차지한다. 기본 C를 설계할 때는 load-multiple/store-multiple을 일부러 뺐다. 가상 메모리에서 일부만 실행된 명령을 다시 시작하는 장치가 필요하고, 대응하는 32비트 명령도 없기 때문이다. 대신 공용 저장·복원 루틴을 호출하는 소프트웨어 방법(GCC의 -msave-restore)을 권했다. Zcmp는 임베디드용으로 그 일을 하드웨어 명령으로 되살렸다.

프롤로그 + 에필로그 크기 (규격의 picojpeg 예) RV32IC 일반 코드 (c.swsp 13개, c.lwsp 13개 …) 60 B -msave-restore (공용 루틴 호출) 14 B Zcmp: cm.push + cm.popretz 4 B cm.push {ra, s0-s11}, -96 = 0xb8facm.popretz {ra, s0-s11}, 96 = 0xbcfa cm.push {ra, s0-s2}, -64 직후 (RV32) s2−4 s1−8 s0−12 ra−16 추가 공간 48 B spimm = 3 → 3 × 16 −64 이전 sp 새 sp 0xb87e · rlist = 7, spimm = 3
그림 13-8. 왼쪽: 규격이 예로 든 함수(EMBench picojpeg의 processMarkers)의 프롤로그·에필로그는 60바이트에서, 공용 루틴 호출로 14바이트, push/pop으로 4바이트가 된다. 오른쪽: cm.push는 목록의 레지스터를 이전 sp 바로 아래부터 차례로 저장하고(s2가 −4, ra가 −16), 레지스터 몫(16 B)에 spimm×16을 더한 만큼 sp를 내린다.

cm.popret은 복원 후 ret까지, cm.popretz는 a0에 0을 넣고 돌아가는 일까지 한 명령으로 한다. 레지스터 목록은 {ra}, {ra, s0}, … {ra, s0-s9}, {ra, s0-s11}의 12가지이고, {ra, s0-s10}은 인코딩 공간을 아끼려고 뺐다. cm.mvsa01(a0·a1 → s 레지스터 두 개)과 cm.mva01s(s 레지스터 두 개 → a0·a1)는 인자를 저장 레지스터로 옮기는 흔한 두 줄을 한 줄로 만든다. 이 명령들은 여러 메모리 접근을 하므로, 도중에 트랩이 나면 처음부터 다시 실행된다. 그래서 sp가 가리키는 메모리는 여러 번 써도 안전한(idempotent) 메모리여야 한다.

Zcmt: 함수 주소를 표로 압축

멀리 있는 함수를 부르려면 auipc + jalr 8바이트가 필요하다. 같은 함수를 여러 곳에서 부른다면, 그 주소를 표에 한 번만 적어 두고 호출은 “표의 몇 번째”로만 하면 된다. 일종의 사전 압축이다. Zcmt의 cm.jt index(index 0–31, 링크 없음)와 cm.jalt index(index 32–255, ra ← pc + 2)가 그 일을 하고, 표의 위치는 새 CSR jvt(주소 0x017, 사용자 모드에서 읽고 쓸 수 있음)가 가리킨다.

cm.jalt 40 0xa0a2 · 16비트 jvt CSR 0x017 BASE[XLEN-1:6] | MODE 64바이트 정렬 · MODE 0 + 40 × 4 점프 표 (명령 메모리, 256칸 × XLEN비트) [0]0x0000_1200 [1]0x0000_1580 … [39]0x0000_2f10 [40]0x0000_3a80 [41]0x0000_4004 … [255]0x0000_8a3c 함수 시작 0x3a80 ra ← pc + 2 cm.jt: index 0–31 (링크 없음) cm.jalt: index 32–255
그림 13-9. cm.jalt 40의 동작. RV32에서는 jvt.base + index × 4 번지(RV64는 × 8)의 표 항목을 읽어 그 주소(비트 0은 지움)로 점프한다. 표 항목 읽기는 명령어 인출의 연장으로 취급되어 실행 권한이 필요하고, 표를 고친 뒤에는 fence.i가 있어야 인출 쪽에 보인다. 표의 주소 값은 그림을 위한 예시다.
MISA.C 비트는 언제 켜지나

misa의 C 비트(17장)는 “C 확장 전체”를 뜻한다. 그래서 Zca만 있고 F가 없거나, RV32에서 Zca·Zcf·F가 있거나, Zca·Zcd·D가 있을 때 켜진다. Zcmp·Zcmt를 쓰는 구현은 Zcd를 가질 수 없으므로, D가 있는 구현에서는 C 비트를 켤 수 없다는 점에 주의하자.

구현의 숙제: 경계에 걸친 명령어

IALIGN=16은 코드 밀도에는 좋지만 명령어 인출 하드웨어에는 숙제를 남긴다. 인출 장치는 보통 4·8·16바이트처럼 정렬된 덩어리(인출 블록, 캐시 라인)를 한 번에 읽는다. 32비트 명령이 2바이트 경계에서 시작하면 앞 절반은 이번 블록에, 뒤 절반은 다음 블록에 있을 수 있다. 그래서 C를 지원하는 코어에는 남은 절반을 잠시 들고 있다가 다음 블록과 이어 붙이는 정렬 버퍼(alignment buffer, instruction queue)가 있다.

SIMULATOR

인출 블록과 정렬 버퍼

한 번 인출을 눌러 0번지 블록부터 읽어 보자.
명령 배치
인출 블록 크기
실행
인출 횟수0
해독한 명령0
블록 경계에 걸친 명령0
총 바이트0
인출 블록이 4 B일 때 “섞인 예시”에서 빨간 점선으로 표시된 명령(경계에 걸친 명령)이 몇 개인지 세고, 8 B, 16 B로 늘리면 어떻게 바뀌는지 보자. 블록이 커질수록 걸침은 드물어지지만 사라지지는 않는다. “32비트만”을 고르면 4바이트 정렬이 지켜져 걸침이 하나도 없다.

페이지 경계에 걸치면: 반쪽짜리 페이지 폴트

더 곤란한 경우는 32비트 명령이 가상 메모리 페이지 경계에 걸칠 때다. 앞 페이지는 메모리에 있고 뒤 페이지는 아직 없으면, 명령의 뒤 절반을 가져오다가 페이지 폴트가 난다. 이때 특권 규격은 mepc(또는 sepc)에는 명령의 시작 주소를 남기고, mtval에 0이 아닌 값을 쓰는 구현이라면 mtval에는 실패한 부분의 주소를 남기도록 정한다. 운영체제는 mtval이 가리키는 페이지를 채운 뒤 mepc로 돌아가 명령 전체를 처음부터 다시 실행한다(18장, 19장).

페이지 A — 메모리에 있음 페이지 B — 아직 없음 c.addi lw (32) c.mv c.add jal (32) … 0x1000 (페이지 경계) 0x0ffe ① 앞 절반 인출 성공 ② 뒤 절반 인출 → 페이지 폴트 mepc = 0x0ffe (명령 시작) mtval = 0x1000 (실패한 부분)
그림 13-10. 0x0ffe에서 시작하는 32비트 jal은 페이지 A와 B에 걸쳐 있다. 앞 절반 인출은 성공하지만 뒤 절반에서 폴트가 난다. 트랩 처리기는 mtval로 어느 페이지를 채울지 알고, mepc로 명령의 처음부터 다시 실행한다. C가 없는(IALIGN=32) 시스템에서는 4바이트 정렬된 명령이 4 KiB 페이지 경계에 걸칠 일이 없다.
고성능 코어에서의 비용과 이득

여러 명령을 한 사이클에 해독하는 슈퍼스칼라 코어는 각 명령의 시작 위치를 먼저 찾아야 한다. RVC는 2바이트 묶음마다 하위 두 비트만 보면 길이를 알 수 있어 이 일이 x86처럼 어렵지는 않지만, 명령 경계를 찾는 회로와 정렬 버퍼는 분명히 추가 비용이다. 이 비용 때문에 응용 프로세서 쪽 일부에서는 C를 의무로 둘지 논쟁이 있었다. 반대로 인출 대역폭과 명령어 캐시 용량을 25–30% 아끼는 효과는 고성능 코어에도 그대로 이득이다. 현재 RVA 프로파일(23장)은 C를 포함한다.

핵심 정리

  1. C 확장은 자주 쓰는 명령에 16비트 인코딩을 더해 코드 크기를 보통 25–30% 줄인다. 명령어 캐시 효율과 인출 대역폭도 함께 좋아진다.
  2. inst[1:0] ≠ 11이면 16비트 명령이다. 세 값이 사분면 0·1·2이고, 각 사분면은 funct3로 다시 나뉜다. C가 있으면 IALIGN=16이 되어 32비트 명령도 2바이트 경계에서 시작할 수 있다.
  3. 모든 RVC 명령은 기본 ISA(또는 F·D)의 32비트 명령 하나로 펼쳐진다. 하드웨어는 해독 앞단의 확장기만, 컴파일러는 아무것도 바꾸지 않아도 된다.
  4. 아홉 형식(CR·CI·CSS·CIW·CL·CS·CA·CB·CJ)은 2-주소 형식, 3비트 레지스터(x8–x15), 짧고 배율이 붙은 즉시값을 조합한 결과다.
  5. Zca·Zcf·Zcd는 C를 쪼갠 이름이고, Zcb는 단순한 16비트 명령을, Zcmp는 push/pop을, Zcmt는 표 점프와 jvt CSR(0x017)을 더한다. Zcmp·Zcmt는 c.fsdsp 자리를 다시 쓰므로 Zcd와 함께 쓸 수 없다.
  6. 2바이트 정렬 때문에 32비트 명령이 인출 블록이나 페이지 경계에 걸칠 수 있다. 정렬 버퍼가 이를 이어 붙이고, 페이지 폴트가 나면 mepc는 명령의 시작, mtval은 실패한 부분을 가리킨다.

확인 퀴즈

메모리에서 읽은 16비트 묶음의 하위 두 비트가 10이다. 이 명령은?

32비트 명령은 하위 두 비트가 반드시 11이다. 00·01·10은 각각 RVC 사분면 0·1·2다.

CL 형식 c.lw의 rd′ 칸이 010이다. 실제 목적지 레지스터는?

3비트 레지스터 칸은 x8–x15를 가리킨다. 앞에 01을 붙이면 01010 = x10 = a0이다.

다음 중 압축할 수 없는 명령은?

c.lw의 오프셋은 5비트 × 4 = 0–124다. 128은 범위를 넘는다. 나머지는 각각 c.lw, c.swsp(0–252), c.addi16sp(−512–496, 16의 배수)로 줄어든다.

“모든 RVC 명령은 32비트 명령 하나로 펼쳐진다”는 제약이 주는 이득으로 맞지 않는 것은?

규격은 여러 명령을 하나로 합치는 더 촘촘한 인코딩의 이득보다 1:1 대응이 주는 단순함이 훨씬 크다고 판단했다. load/store-multiple을 기본 C에서 뺀 이유이기도 하다.

RV64C에서 RV32C의 c.jal 인코딩 자리는 무엇으로 쓰이나?

사분면 1의 funct3 = 001은 RV32에서는 c.jal, RV64에서는 c.addiw다. 짧은 호출은 작은 RV32 프로그램에서 더 흔하고, RV64에서는 32비트 덧셈 후 부호 확장이 자주 필요하기 때문이다.

RV64GC(= C와 D를 모두 가진) 프로세서에 Zcmp를 더할 수 없는 이유는?

C+D이면 Zcd가 포함되고, Zcd의 c.fsdsp 자리를 Zcmp(와 Zcmt)가 재사용하므로 둘은 공존할 수 없다.

16비트 값 0x0000을 실행하면?

모든 비트가 0인 16비트 명령은 0으로 채워진 메모리로 잘못 뛰어든 경우를 잡기 위해 영구히 불법 명령으로 정의되어 있다. 참고로 c.nop은 0x0001이다.

0x2ffe에서 시작하는 32비트 명령의 뒤 절반(0x3000)을 인출하다 페이지 폴트가 났다. mepc와 mtval은?

mepc는 다시 실행할 명령의 시작을, mtval(0이 아닌 값을 쓰는 구현이라면)은 폴트를 일으킨 부분의 주소를 가리킨다. C가 있는 시스템에서는 명령 주소 정렬 예외가 일어날 수 없다.

더 읽을거리