Chapter 03

레지스터와 프로그래머 모델

어셈블리 프로그래머나 컴파일러가 보는 CPU는 놀랄 만큼 단순하다. 32개의 정수 레지스터와 pc 하나, 그리고 바이트 단위로 번지가 매겨진 메모리가 전부다. 조건 플래그도, 전용 스택 레지스터도, 누산기도 없다. 이 장에서는 RISC-V 베이스 ISA의 프로그래머 모델을 하나씩 뜯어보고, 그중 가장 독특한 레지스터 x0가 어떻게 수많은 명령을 “공짜로” 만들어 내는지 살펴본다.

프로그래머가 보는 상태: x0–x31과 pc

규격은 베이스 정수 ISA의 비특권 상태를 표 하나로 정리한다. x0부터 x31까지 32개의 정수 레지스터는 모두 폭이 XLEN 비트다(RV32I는 32, RV64I는 64). 여기에 현재 명령의 주소를 담는 pc가 하나 더 있다. 그것이 전부다.

정수 레지스터 파일 XLEN-10 XLEN-10 x0zero0000 … 0000 (고정) x1ra x2sp x3gp x4tp x5t0 x6t1 x7t2 x8s0/fp x9s1 x10a0 x11a1 x12a2 x13a3 x14a4 x15a5 x16a6 x17a7 x18s2 x19s3 x20s4 x21s5 x22s6 x23s7 x24s8 x25s9 x26s10 x27s11 x28t3 x29t4 x30t5 x31t6 pc 지금 실행 중인 명령의 주소 인자·반환 저장(s) 임시(t) 포인터 복귀 주소 항상 0 색은 ABI 관례일 뿐, 하드웨어에겐 모두 같다
그림 3-1. 베이스 정수 ISA의 비특권 상태. 하드웨어 입장에서 x1–x31은 모두 똑같은 범용 레지스터이고, x0만 모든 비트가 0으로 고정되어 있다. 색으로 표시한 역할(ra, sp, a0 …)은 ISA가 아니라 호출 규약(ABI)이 정한 약속이다.
규격 원문에서
“There is no dedicated stack pointer or subroutine return address link register in the Base Integer ISA; the instruction encoding allows any x register to be used for these purposes. However, the standard software calling convention uses register x1 to hold the return address for a call, with register x5 available as an alternate link register.”

Volume I, RV32I — Programmers' Model for Base Integer ISA

왜 32개인가, 왜 5비트 칸이 고정되어 있나

레지스터가 32개이니 레지스터를 가리키는 칸은 5비트다. 규격 해설은 16개로도 컴파일된 정수 코드에는 충분하다고 인정하면서도, 32개를 고른 이유를 이렇게 설명한다. 16비트 명령 안에 3-주소 형식(rd, rs1, rs2)을 넣으면 16개 레지스터로도 완전한 ISA를 만들 수 없고, 24비트 같은 어중간한 길이는 피하고 싶었다. 일단 32비트 명령을 택하자 32개 레지스터를 지원하기는 쉬웠고, 레지스터가 많으면 루프 펼치기나 소프트웨어 파이프라이닝 같은 고성능 코드에도 유리하다.

더 중요한 것은 칸의 위치다. 모든 형식에서 rs1·rs2·rd가 같은 비트 위치에 있다. 덕분에 하드웨어는 명령의 종류를 다 해독하기 전에 레지스터 파일부터 읽기 시작할 수 있다. 명령 형식은 4장에서 자세히 다룬다.

3124191170 R형 funct7 rs2 rs1 funct3 rd opcode I형 imm[11:0] rs1 funct3 rd opcode S형 imm[11:5] rs2 rs1 funct3 imm[4:0] opcode B형 imm rs2 rs1 funct3 imm opcode U형 imm[31:12] rd opcode J형 imm[20|10:1|11|19:12] rd opcode rs2 [24:20] rs1 [19:15] rd [11:7]
그림 3-2. 여섯 가지 32비트 명령 형식. 점선 기둥 안의 레지스터 칸(청록)은 형식이 바뀌어도 자리가 움직이지 않는다. 5비트이므로 x0–x31을 가리킬 수 있다. RV32E에서는 x16–x31을 가리키는 값이 예약되어 있다.

x0: 언제나 0인 레지스터

규격의 정의는 한 줄이다. “레지스터 x0는 모든 비트가 0으로 고정되어 있다.” x0를 읽으면 항상 0이 나오고, x0에 쓰는 결과는 그냥 버려진다. 레지스터 하나를 통째로 상수 0에 바친 셈인데, 이 덕분에 ISA는 훨씬 작아진다. 0이 필요한 곳에 언제든 x0를 쓰면 되니, “레지스터 복사”, “부호 반전”, “무조건 점프”, “0과 비교해 분기” 같은 명령을 따로 만들 필요가 없다. 어셈블러가 의사 명령(pseudoinstruction)으로 이름만 붙여 주면 된다.

ANIMATION

레지스터와 ALU 사이의 데이터 흐름

명령마다 원천 레지스터 값이 ALU로 흘러가고, 결과가 목적 레지스터로 돌아온다.
재생
네 번째 명령 add zero, a1, a2를 눈여겨보자. ALU는 12를 계산해 x0로 보내지만, x0는 그대로 0이다. 결과를 버리고 싶을 때(예: 반환 주소가 필요 없는 점프) 목적지를 x0로 두는 이유다.

x0를 이용하는 대표적인 의사 명령을 정리하면 다음과 같다. 모두 규격의 명령 설명이나 어셈블리 매뉴얼에 나오는 표준 확장이다.

의사 명령실제 명령x0의 역할
nopaddi x0, x0, 0읽기도 쓰기도 x0 → 아무 효과 없음
li rd, immaddi rd, x0, imm0 + 즉시값 (12비트 범위일 때)
mv rd, rsaddi rd, rs, 0(x0 없이 즉시값 0으로 만든다)
neg rd, rssub rd, x0, rs0 − rs
snez rd, rssltu rd, x0, rs0 <u rs ⇔ rs ≠ 0
beqz rs, Lbeq rs, x0, L0과 비교
bgtz rs, Lblt x0, rs, L0 < rs
j Ljal x0, L복귀 주소를 버림
jr rsjalr x0, 0(rs)복귀 주소를 버림
retjalr x0, 0(x1)복귀 주소를 버리고 ra로 점프
SIMULATOR

x0 마법: 의사 명령 펼쳐 보기

직접 입력 (riscv.js 어셈블러가 이해하는 한 줄)
실제 명령 수—
x0를 쓰는 레지스터 칸—
기계어—
금색 테두리 칸이 값 0, 곧 x0를 가리키는 레지스터 칸이다. li a0, 5와 li a0, 0x12345678을 비교해 보자. 작은 상수는 x0에 더해 한 명령으로 끝나지만, 큰 상수는 lui가 상위 비트를 직접 만들기 때문에 x0가 필요 없다.
rd = x0 인 명령은 HINT 공간이 된다

x0에 쓰는 명령은 결과가 버려지므로 아키텍처 상태를 바꾸지 않는다. 규격은 이런 인코딩 가운데 정식 nop(addi x0, x0, 0)을 뺀 나머지 상당수를 HINT로 지정해, 성능 힌트용으로 쓸 수 있게 남겨 두었다. 예를 들어 Zihintpause의 pause는 후행 집합이 비어 있는(succ=0) fence 인코딩을, Zicbop의 프리페치는 ori의 rd=x0 변형을 쓴다. 힌트를 모르는 옛 코어에서는 그냥 nop처럼 동작하므로 호환성이 깨지지 않는다.

플래그 레지스터가 없다

x86과 Arm에는 비교나 산술 결과의 성질(0인지, 음수인지, 올림수가 났는지, 넘쳤는지)을 기록하는 조건 코드(condition codes, flags) 레지스터가 있다. 분기 명령은 그 플래그를 읽는다. RISC-V에는 이런 레지스터가 없다. 대신 분기 명령이 두 레지스터를 직접 비교한다.

x86-64 cmp eax, ebx EFLAGS (숨은 상태) CF · ZF · SF · OF · … jl label 대부분의 산술 명령도 플래그를 바꾼다 Arm A64 cmp x0, x1 NZCV (숨은 상태) N · Z · C · V b.lt label adds 처럼 s가 붙은 명령만 플래그를 바꾼다 RISC-V blt a0, a1, label 비교와 분기가 한 명령 플래그 없음 숨은 상태가 없다 slt t0, a0, a1 비교 결과가 필요하면 레지스터에 결과는 언제나 보이는 레지스터에 남는다
그림 3-3. 비교와 분기. x86과 Arm은 비교 결과를 숨은 플래그 레지스터에 남기고 다음 분기가 그것을 읽는다(흐르는 점선). RISC-V는 비교와 분기를 한 명령에 묶었고, 비교 결과 자체가 필요하면 slt로 일반 레지스터에 0/1을 쓴다.

규격 해설은 이 결정의 이유를 여러 가지로 든다. 비교-분기를 합친 명령은 일반 파이프라인에 잘 들어맞고, 추가 조건 코드 상태나 임시 레지스터가 필요 없으며, 정적 코드 크기와 명령어 가져오기 트래픽을 줄인다. 분기가 명령어 흐름 앞쪽에서 일찍 보이므로 예측도 빨리 할 수 있다. 플래그는 비순차 실행 코어에서 특히 골칫거리다. 거의 모든 명령이 플래그를 쓰면 그 플래그도 레지스터처럼 이름 바꾸기(renaming)를 해야 하기 때문이다.

대가도 있다. 올림수(carry)나 넘침(overflow)을 알고 싶으면 명령을 더 써야 한다. 규격은 그 비용이 크지 않다며 직접 예를 든다.

규격 원문에서
“This design was motivated by the observation that a combined compare-and-branch instruction fits into a regular pipeline, avoids additional condition code state or use of a temporary register, and reduces static code size and dynamic instruction fetch traffic.”

Volume I, RV32I — Conditional Branches

ABI 이름과 역할

하드웨어에게 x10과 x18은 똑같은 레지스터다. 그러나 따로 컴파일된 함수끼리 서로를 부르려면 “인자는 어디에 넣고, 어떤 레지스터는 호출 뒤에도 값이 남아 있어야 하는가”를 약속해야 한다. 이 약속이 호출 규약이고, RISC-V psABI 규격이 정한다. 어셈블리에서는 번호 대신 역할이 드러나는 ABI 이름을 주로 쓴다. 자세한 규칙과 스택 프레임은 7장에서 다룬다.

EXPLORER

레지스터 파일 탐색기

항상 0복귀 주소포인터인자·반환임시 (호출자 저장)저장 (피호출자 저장)
보기
베이스
레지스터를 눌러 역할과 특이 사항을 읽어 보자. “C의 3비트 칸”을 고르면 압축 명령의 짧은 레지스터 칸이 가리킬 수 있는 8개(x8–x15)만 남는다. 자주 쓰는 s0·s1·a0–a5가 이 범위에 모인 것은 우연이 아니다. RV32E로 바꾸면 x16–x31이 사라진다.
레지스터ABI 이름뜻 (psABI)호출 뒤 보존?
x0zero항상 0— (바뀌지 않음)
x1ra복귀 주소아니오
x2sp스택 포인터예
x3gp전역 포인터— (할당 대상 아님)
x4tp스레드 포인터— (할당 대상 아님)
x5–x7t0–t2임시아니오
x8–x9s0–s1피호출자 저장 (s0 = 프레임 포인터 fp)예
x10–x17a0–a7인자 (a0·a1은 반환값도)아니오
x18–x27s2–s11피호출자 저장예
x28–x31t3–t6임시아니오
gp와 tp는 건드리지 않는다

psABI는 “표준 ABI에서 프로시저는 정수 레지스터 tp와 gp를 수정해서는 안 된다. 시그널 처리기가 그 값에 의존할 수 있기 때문이다”라고 적는다. gp는 링커가 전역 변수 접근을 짧게 만드는 데(7장의 링커 완화), tp는 스레드 지역 저장소(TLS)를 가리키는 데 쓴다.

확장이 더하는 레지스터: f·v·CSR

베이스 ISA의 상태는 x 레지스터와 pc뿐이지만, 확장을 얹으면 상태가 늘어난다. 각각 뒤의 장에서 자세히 다루니 여기서는 지도만 그려 두자.

f0–f31 (F·D·Q) 폭 FLEN: F=32 · D=64 · Q=128 … 32개 fcsr = frm | fflags 반올림 모드와 예외 플래그 (fcsr은 CSR 0x003) 12장 v0–v31 (V) 폭 VLEN: 구현이 정함 (V는 ≥ 128) … 32개, 원소로 나뉨 vl · vtype · vstart … 벡터 길이·원소 폭 설정 CSR psABI: 호출 뒤 보존 안 됨 15장 CSR (Zicsr) 12비트 주소 → 4096칸 0x001–0x003 fflags·frm·fcsr 0xC00–0xC02 cycle·time·instret 0x300 mstatus · 0x301 misa … 특권 모드별 수백 개 x 레지스터와는 다른 별도 공간 csrrw · csrrs · csrrc 로만 접근 주소 비트가 접근 권한도 나타냄 16·17장
그림 3-5. 확장이 더하는 상태. F를 얹으면 32개의 부동소수점 레지스터와 fcsr이, V를 얹으면 VLEN 비트짜리 벡터 레지스터 32개와 설정 CSR이 생긴다. CSR은 x 레지스터와 전혀 다른 4096칸짜리 주소 공간으로, Zicsr의 전용 명령으로만 읽고 쓴다.
f 레지스터가 없는 부동소수점

Zfinx 계열 확장은 f 레지스터 없이 x 레지스터로 부동소수점 연산을 한다. 레지스터 파일을 통째로 아끼므로 작은 코어와 문맥 전환 비용에 유리하다. 대신 F와는 소프트웨어 호환이 되지 않는다(12장).

RV32E: 레지스터를 16개로 줄이면

RV32E와 RV64E는 RV32I·RV64I에서 단 하나, 정수 레지스터 수만 16개(x0–x15)로 줄였다. 명령 인코딩은 그대로이고, x16–x31을 가리키는 인코딩은 예약된다. 왜 이런 변형이 필요할까? 규격 해설이 숫자로 답한다.

작은 RV32I 코어 (메모리 제외 면적) 나머지 로직 + x0–x15 (약 3/4) x16–x31 약 1/4 RV32E 코어 같은 기능 · 면적과 전력 약 25% 감소 없음
그림 3-6. 규격 해설: “작은 RV32I 코어 구현에서 위쪽 16개 레지스터는 메모리를 뺀 전체 코어 면적의 약 4분의 1을 차지하므로, 이를 없애면 약 25%의 코어 면적과 그에 따른 전력을 줄인다.” 레지스터 파일은 플립플롭 덩어리라 작은 코어에서 비중이 크다.

레지스터가 16개면 호출 규약도 달라져야 한다. psABI의 ILP32E 호출 규약은 인자 레지스터를 a0–a5 여섯 개, 피호출자 저장을 s0–s1 두 개, 임시를 t0–t2 세 개만 쓰고, 스택 정렬도 16바이트가 아니라 4바이트로 완화한다(psABI 1.0에서 ILP32E의 상태는 Draft다). 위 탐색기에서 “RV32E”를 눌러 무엇이 남는지 확인해 보자.

메모리: 바이트 주소, 리틀 엔디언, 정렬

하트(hart)는 2XLEN 바이트짜리 하나의 주소 공간을 본다. 주소 하나가 바이트 하나를 가리킨다. 규격은 워드(word)를 32비트(4바이트)로 정하고, 하프워드는 16비트, 더블워드는 64비트, 쿼드워드는 128비트다. x86의 “워드 = 16비트”와 다르니 주의하자.

0x00000000 0xFFFFFFFF 0x40000000 0x80000000 0xC0000000 0xFFFFFFFE + 4 = 0x00000002 RV32: 2³² 바이트 주소 계산은 넘침을 무시한다 a1 = 0xFFFFFFFE lw a0, 4(a1) 유효 주소 = (a1 + 4) mod 2³² = 0x00000002 그 주소에 실제로 접근할 수 있는지는 실행 환경(EEI)이 정한다. 접근할 수 없는 주소면 보통 예외가 난다.
그림 3-7. 순환하는 주소 공간. 규격은 “메모리 주소 공간은 원형이어서, 주소 2XLEN−1의 바이트는 주소 0의 바이트와 이웃한다”고 정한다. 하드웨어의 주소 계산은 넘침을 무시하고 2XLEN을 법으로 감싸 돈다.

리틀 엔디언

여러 바이트로 된 값을 메모리에 놓을 때, 가장 낮은 자리 바이트를 가장 낮은 주소에 두면 리틀 엔디언(little-endian), 반대면 빅 엔디언(big-endian)이다. RISC-V는 리틀 엔디언으로 시작했다. 규격 해설은 그 이유로 상업적으로 리틀 엔디언이 지배적이라는 점(x86 전부, Arm 위의 iOS·Android·Windows)과 하드웨어 설계자에게 더 자연스럽다는 점을 든다.

sw 로 0x12345678 을 0x100 번지에 저장하면 0x1000x1010x1020x103 리틀 엔디언RISC-V 기본 78 56 34 12 낮은 자리 바이트가낮은 주소에 빅 엔디언특권 규격이 선택 정의 12 34 56 78 높은 자리 바이트가낮은 주소에 lbu a0, 0x100 → 리틀: 0x78 / 빅: 0x12
그림 3-8. 같은 워드, 다른 바이트 순서. 진한 칸이 가장 낮은 자리 바이트(0x78)다. 바이트 하나만 읽으면 차이가 바로 드러난다.

엄밀히 말해 규격은 “RISC-V 베이스 ISA의 메모리 시스템은 리틀 엔디언이거나 빅 엔디언이며, 특권 아키텍처가 바이 엔디언 동작을 추가로 정의한다”고 적는다. 특권 규격의 mstatus에는 모드별 데이터 엔디언을 고르는 MBE·SBE·UBE 비트가 있다(RV64 mstatus의 비트 37·36·6, RV32에서는 MBE·SBE가 mstatush의 비트 5·4). 다만 RVI20·RVA 프로파일은 리틀 엔디언을 요구하고, psABI도 빅 엔디언 호출 규약을 아직 정의하지 않았으므로 현실의 RISC-V 소프트웨어는 거의 모두 리틀 엔디언이다. 명령어는 메모리 엔디언과 상관없이 언제나 16비트 리틀 엔디언 파슬로 저장된다(2장).

정렬과 비정렬 접근

크기가 N 바이트인 접근의 주소가 N의 배수면 자연 정렬되었다고 한다. 규격은 “EEI와 관계없이 자연 정렬된 적재·저장은 주소 비정렬 예외를 일으키지 않는다”고 보장한다. 정렬되지 않은 접근은 실행 환경(EEI)에 따라 다르다.

EEI의 선택비정렬 적재·저장은
완전 지원을 보장하드웨어가 처리하거나, 보이지 않는 트랩(invisible trap)으로 소프트웨어가 대신 처리한다. 프로그램은 예외를 보지 않는다. 느릴 수는 있다.
보장하지 않음성공할 수도, address-misaligned 예외가 날 수도 있다. 부작용이 있는 영역처럼 흉내 내면 안 되는 곳이라면 access-fault 예외가 대신 날 수도 있다.

비정렬 접근이 성공하더라도 원자성은 보장되지 않는다. 자연 정렬된 접근만 원자적으로 실행됨이 보장된다. 참고로 RVA20U64부터의 응용 프로세서 프로파일은 주 메모리에 대한 비정렬 적재·저장 지원을 요구(Zicclsm)하지만, “아주 느릴 수 있으니 정확성을 위해서만 기대하라”고 덧붙인다.

SIMULATOR

엔디언과 부호 확장 실험실 (RV32)

a0 = —
저장 명령
엔디언
적재 명령
실행
유효 주소—
정렬—
a0 (16진)—
a0 (부호 있는 10진)—
0x8badf00d를 0x1000에 sw로 저장한 뒤, 같은 주소에서 lb와 lbu를 차례로 해 보자. 맨 아래 바이트 0x0d는 양수라 둘이 같지만, 주소를 0x1003으로 옮겨 0x8b를 읽으면 lb는 위쪽 24비트를 1로 채운다(금색). 엔디언을 빅으로 바꾸어 다시 저장하면 바이트 순서가 뒤집힌다.

핵심 정리

  1. 베이스 정수 ISA의 비특권 상태는 XLEN 비트짜리 x0–x31과 pc뿐이다. 전용 스택 포인터나 링크 레지스터는 하드웨어에 없고, 역할은 ABI가 정한다.
  2. 레지스터 칸(rs1·rs2·rd)은 5비트이고 모든 형식에서 같은 위치에 있어, 해독 전에 레지스터를 읽을 수 있다.
  3. x0는 항상 0이고 쓰기는 버려진다. 덕분에 nop·li·neg·j·jr·ret·beqz 같은 의사 명령이 새 인코딩 없이 생긴다.
  4. RISC-V에는 조건 플래그가 없다. 분기가 두 레지스터를 직접 비교하고, 비교 결과가 필요하면 slt로 레지스터에 남긴다.
  5. ABI 이름: zero, ra, sp, gp, tp, t0–t6, s0–s11, a0–a7. sp·s 레지스터는 호출 뒤 보존, ra·t·a는 보존되지 않는다.
  6. x1과 x5는 링크 레지스터로, jal/jalr의 rd·rs1 번호가 반환 주소 스택의 push/pop 힌트가 된다.
  7. F는 f0–f31과 fcsr을, V는 v0–v31과 벡터 CSR을 더한다. CSR은 12비트 주소의 별도 공간이다. RV32E는 레지스터를 16개로 줄여 코어 면적을 약 25% 아낀다.
  8. 메모리는 바이트 주소, 2XLEN 크기의 순환 공간이며, 사실상 리틀 엔디언이다. 자연 정렬된 접근은 비정렬 예외를 내지 않는다.

확인 퀴즈

add x0, a1, a2를 실행하면?

x0에 쓰는 것은 합법이며 결과는 버려진다. 이런 인코딩 상당수는 HINT로 예약되어 있다.

ret 의사 명령의 실제 인코딩은?

ra(x1)로 점프하고, 새 복귀 주소는 필요 없으니 rd = x0로 버린다. rs1이 링크 레지스터이고 rd는 아니므로 RAS에서 pop하라는 힌트도 된다.

RISC-V에 조건 플래그 레지스터가 없어서 생기는 장점이 아닌 것은?

올림수 플래그가 없으므로 다중 정밀도 덧셈은 sltu 같은 명령을 더 써서 올림수를 계산해야 한다. 그것이 플래그 없는 설계의 대가다.

psABI에서 함수 호출 뒤에도 값이 보존되어야 하는 레지스터 묶음은?

sp와 s 레지스터는 피호출자가 보존한다. ra·a·t는 보존되지 않고, gp·tp는 아예 할당 대상이 아니다.

jalr x1, 0(x5)는 반환 주소 스택에 어떤 힌트를 주는가?

rd와 rs1이 모두 링크 레지스터(x1/x5)이면서 서로 다르므로 pop 후 push다. 코루틴 전환을 위한 규칙이다.

리틀 엔디언 RV32에서 0x1000 번지에 sw로 0x8BADF00D를 저장한 뒤 lb a0, 0x1003을 실행하면 a0는?

리틀 엔디언이면 0x1003에 가장 높은 자리 바이트 0x8B가 있다. lb는 부호 확장하므로 최상위 비트 1이 위로 복사되어 0xFFFFFF8B가 된다.

RV32E가 RV32I와 다른 점은?

규격은 “유일한 변화는 정수 레지스터 수를 16개로 줄인 것”이라고 적는다. 인코딩은 같고, x16–x31을 가리키는 인코딩은 예약된다.

더 읽을거리