Chapter 04

명령어 형식과 인코딩

add a0, a1, a2는 CPU에게 0x00c58533이라는 32비트 숫자 하나로 전달된다. 이 32비트 안 어디에 “무슨 명령인지”, “어느 레지스터인지”, “어떤 상수인지”가 들어 있을까? RISC-V의 기본 명령어는 R·I·S·B·U·J 여섯 가지 형식만으로 모든 것을 표현한다. 이 장에서는 그 비트 배치를 하나하나 해부하고, 언뜻 보면 이상하게 뒤섞인 즉시값 비트가 사실은 하드웨어를 단순하게 만들기 위한 치밀한 설계라는 것을 확인한다.

32비트 명령어와 길이 표시 비트

RISC-V 베이스 ISA의 명령어는 모두 정확히 32비트이고, 메모리에서 4바이트 경계에 정렬되어야 한다. 규격은 이 정렬 단위를 IALIGN(instruction-address alignment)이라 부르며 베이스 ISA에서는 32비트다. 길이가 고정되어 있으니 CPU는 다음 명령이 어디서 시작하는지 고민할 필요가 없다. x86처럼 1바이트에서 15바이트까지 길이가 제각각인 ISA에서는 명령의 경계를 찾는 일 자체가 큰 회로가 된다.

그렇다고 RISC-V가 길이를 영원히 32비트로 묶어 둔 것은 아니다. 처음부터 16비트 압축 명령(C 확장, 13장)과 더 긴 명령이 들어올 자리를 남겨 두었다. 그 표시가 바로 하위 2비트다. 베이스 ISA의 32비트 명령은 모두 하위 2비트가 11이고, 압축 명령은 00·01·10 중 하나다.

길이는 가장 낮은 비트들이 알려 준다 16비트 압축 명령 (C 확장) aa aa ≠ 11 inst[4:2] inst[1:0] 32비트 명령 bbb 11 bbb ≠ 111 더 긴 명령용 (예약) 111 11 > 32비트 비트가 모두 0인 명령과, 모두 1인 명령은 규격상 불법이다. → 지워진(0) 메모리나 비어 있는(1) 플래시로 잘못 뛰어들면 곧바로 예외가 난다. 메모리 속 add a0, a1, a2 0x00c58533 · 리틀 엔디언 A+033 A+185 A+2c5 A+300 0011 0011 하위 2비트 = 11 → 32비트 명령이다 가장 낮은 주소의 첫 바이트만 읽고도 길이를 안다 명령은 16비트 조각(parcel) 단위로, 낮은 주소에 낮은 번호 비트가 오도록 저장된다.
그림 4-1. 명령어 길이 인코딩. 왼쪽: 하위 2비트가 11이 아니면 16비트 압축 명령, 11이면 32비트 이상이다. opcode 지도에서 inst[4:2]=111 열은 32비트보다 긴 명령을 위해 비워 두었다. 오른쪽: 명령어는 메모리 엔디언과 상관없이 리틀 엔디언 16비트 조각으로 저장되므로, 길이를 결정하는 비트는 언제나 가장 먼저 읽히는 바이트에 있다.
2비트를 희생한 대가와 이득

32비트 중 2비트를 길이 표시에 쓰면 베이스 ISA가 쓸 수 있는 공간은 그만큼 좁아진다. 대신 얻는 것이 많다. 규격의 해설에 따르면 압축 명령을 지원하지 않는 구현은 명령어 캐시에 하위 2비트를 저장하지 않아도 되어 6.25%를 아낄 수 있고, RV32I 전체가 32비트 인코딩 공간의 8분의 1도 쓰지 않기 때문에 나머지는 표준 확장과 사용자 확장의 몫으로 남는다.

0x00000000은 언제나 불법 명령

규격은 하위 16비트가 모두 0인 인코딩을 불법 명령으로 정했다. 그래서 소프트웨어는 “여기서 반드시 예외를 내고 싶다”는 자리에 0을 써 넣을 수 있다. 모든 비트가 1인 ILEN 길이 인코딩도 불법이다. 이 두 값은 지워진 메모리, 연결이 끊긴 버스, 프로그래밍하지 않은 플래시에서 흔히 읽히는 값이기 때문이다.

여섯 가지 형식: R·I·S·B·U·J

규격은 베이스 ISA의 형식을 핵심 네 가지(R·I·S·U)와, 즉시값을 다루는 방식만 조금 다른 변형 두 가지(B·J)로 설명한다. 이름은 쓰임새에서 왔다. Register(레지스터끼리 연산), Immediate(즉시값 연산과 적재), Store(저장), Branch(분기), Upper immediate(상위 즉시값), Jump(점프)다.

31 25 24 20 19 15 14 12 11 7 6 0 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[12] imm[10:5] rs2 rs1 funct3 imm[4:1] imm[11] opcode U imm[31:12] rd opcode J imm[20] imm[10:1] imm[11] imm[19:12] rd opcode opcode 레지스터 번호 funct (기능 선택) 즉시값 청록 괄호: rs2 · rs1 · rd 는 어느 형식에서나 같은 비트 자리에 있다
그림 4-2. RV32I의 여섯 가지 명령어 형식. 위의 숫자는 비트 번호다. 즉시값 칸의 imm[x]는 “명령어의 몇 번째 비트”가 아니라 만들어질 즉시값의 몇 번째 비트인지를 뜻한다(규격 그림과 같은 표기). opcode는 늘 맨 아래 7비트, funct3은 늘 [14:12]에 있고, 레지스터 번호 칸(청록)은 형식이 달라도 자리가 움직이지 않는다.
형식들어 있는 것쓰는 명령 (RV32I)예
Rrd, rs1, rs2, funct3, funct7add, sub, sll, slt, sltu, xor, srl, sra, or, andadd a0, a1, a2
Ird, rs1, 12비트 즉시값, funct3addi 등 즉시값 연산, 시프트(즉시값 자리에 shamt), 적재 lb…lhu, jalr, fence, ecall, ebreaklw a0, 8(sp)
Srs1, rs2, 12비트 즉시값(둘로 나뉨), funct3sb, sh, swsw a0, 8(sp)
Brs1, rs2, 13비트 오프셋(짝수), funct3beq, bne, blt, bge, bltu, bgeubeq a0, zero, loop
Urd, 상위 20비트 즉시값lui, auipclui a0, 0x12345
Jrd, 21비트 오프셋(짝수)jaljal ra, func

한 명령을 손으로 풀어 보기

add a0, a1, a2를 R 형식에 채워 넣어 보자. a0·a1·a2는 레지스터 x10·x11·x12이고(3장), add의 opcode는 0110011(OP), funct3과 funct7은 모두 0이다.

3125
funct7
0000000
2420
rs2 = 12
01100
1915
rs1 = 11
01011
1412
f3
000
117
rd = 10
01010
60
OP
0110011

비트를 이어 붙이면 0000 0000 1100 0101 1000 0101 0011 0011, 16진수로 0x00c58533이다. 어셈블러가 하는 일의 대부분은 이렇게 칸을 채우는 것이다. 이 장 가운데의 인코더 시뮬레이터에서 다른 명령도 직접 채워 볼 수 있다.

레지스터 칸을 고정한 이유

그림 4-2에서 청록색 칸이 위아래로 가지런히 줄 선 것은 우연이 아니다. 규격은 rs1·rs2·rd를 모든 형식에서 같은 자리에 둔다고 못 박았다. 이유는 하드웨어의 시간표에 있다. 명령어를 받은 CPU는 가능한 한 빨리 레지스터 파일에서 피연산자를 읽어야 다음 단계(연산)로 넘어갈 수 있다. 만약 형식마다 rs1의 자리가 다르다면, 먼저 opcode를 해독해 형식을 알아낸 다음에야 어느 비트를 레지스터 번호로 쓸지 고를 수 있다. 해독과 레지스터 읽기가 직렬로 이어지는 것이다.

RISC-V에서는 형식을 몰라도 inst[19:15]는 rs1, inst[24:20]은 rs2라고 믿고 일단 레지스터를 읽어 버린다. I 형식처럼 rs2가 없는 명령이라면 그 자리에 있던 즉시값 비트로 엉뚱한 레지스터를 읽게 되지만 상관없다. 해독이 끝난 뒤 그 값을 쓰지 않으면 그만이다. 이렇게 해독·레지스터 읽기·부호 확장 세 가지가 같은 클럭 안에서 동시에 시작된다.

31 30 25 24 20 19 15 14 12 11 7 6 0 ± imm · funct7 rs2 rs1 funct3 rd opcode rd는 결과를 쓸 때 사용 즉시값 생성 inst[31]로 부호 확장 레지스터 파일 읽기 rs1·rs2 번호로 곧바로 읽는다 해독기 opcode·funct3·funct7 → 무슨 명령인가? ↑ 세 일이 같은 클럭 안에서 동시에 시작한다. 형식을 알아내기 전에 레지스터부터 읽는다. 실행 (ALU) 해독 결과로 필요한 값만 고른다
그림 4-3. 명령어가 들어오면 rs1·rs2 칸은 형식과 상관없이 곧바로 레지스터 파일로, 부호 비트 inst[31]은 즉시값 생성기로, opcode·funct는 해독기로 동시에 흘러간다(금색 점). 레지스터 번호와 부호 비트의 자리가 형식마다 달랐다면 해독이 끝날 때까지 기다려야 했을 것이다.
규격 원문에서
“Decoding register specifiers is usually on the critical paths in implementations, and so the instruction format was chosen to keep all register specifiers at the same position in all formats at the expense of having to move immediate bits across formats …”

Volume I, RV32I · Base Instruction Formats — 레지스터 칸을 지키는 대가로 즉시값 비트를 형식마다 옮겨 다니게 했다는 고백이다. 이 대가가 다음 두 절의 주제다.

MIPS와 비교하면

MIPS의 R 형식은 목적지 레지스터를 rd(비트 15–11)에 두지만, I 형식은 목적지를 rt(비트 20–16)에 둔다. 그래서 MIPS 하드웨어는 “이 명령의 목적지가 어느 칸인가”를 opcode로 골라 주는 멀티플렉서가 필요했다. RISC-V는 rd를 언제나 inst[11:7]에 두어 이 선택 자체를 없앴다.

즉시값: 부호 비트는 언제나 inst[31]

명령어 안에 박힌 상수를 즉시값(immediate)이라 한다. RISC-V의 즉시값에는 두 가지 큰 규칙이 있다. 첫째, CSR 명령의 5비트 즉시값을 빼면 모든 즉시값은 부호 확장된다. MIPS처럼 어떤 명령은 0 확장, 어떤 명령은 부호 확장하는 식의 구분이 없다. 둘째, 부호 비트는 어느 형식에서나 inst[31]이다.

32비트 즉시값의 상위 비트 20여 개는 모두 부호 비트의 복사본이다. 부호 비트의 자리가 고정되어 있으니, 하드웨어는 형식을 해독하기 전부터 inst[31] 한 가닥을 상위 비트 전체로 퍼뜨려 둘 수 있다. RV64에서는 이 복사가 52비트 이상으로 늘어나므로 더 중요해진다(6장).

즉시값의 비트 번호 imm[31] … imm[0] — 칸 안의 글자는 그 비트를 공급하는 명령어 비트 inst[ ] 31 30 25 24 21 20 19 12 11 10 5 4 1 0 I inst[31] — 부호 확장 inst[30:25] inst[24:21] 20 S inst[31] — 부호 확장 inst[30:25] inst[11:8] 7 B inst[31] — 부호 확장 7 inst[30:25] inst[11:8] 0 J inst[31] — 부호 확장 inst[19:12] 20 inst[30:25] inst[24:21] 0 U 31 inst[30:25] inst[24:21] 20 inst[19:12] 0 inst[31] 부호 inst[30:25] inst[24:21] inst[20] inst[19:12] inst[11:8] inst[7] 상수 0
그림 4-4. 다섯 형식이 만드는 32비트 즉시값의 각 비트가 명령어의 어느 비트에서 오는지 보여 준다(규격의 “Types of immediate produced by RISC-V instructions” 그림을 색으로 다시 그림). 세로로 읽으면 핵심이 보인다. imm[10:5]는 U를 빼면 언제나 inst[30:25]에서, imm[19:12]는 U와 J가 같은 inst[19:12]에서 오고, 금색 부호 확장은 모두 inst[31] 한 곳에서 온다. 즉시값 비트 하나에 들어올 수 있는 후보가 대부분 2–3개뿐이다.

그림 4-4를 세로 열 하나씩 보면, 즉시값의 한 비트를 만들기 위해 하드웨어가 고를 후보가 몇 개인지 셀 수 있다. 예를 들어 imm[0]은 I에서 inst[20], S에서 inst[7], B·U·J에서 상수 0이므로 후보가 세 개다. 이 후보 수가 바로 즉시값 생성기 안의 멀티플렉서 입력 수다. 후보가 적을수록 회로가 작고, 명령어 비트 하나가 연결되는 곳(팬아웃)도 줄어든다.

1 2 3 4 5 31 20 12 11 5 4 1 0 즉시값 비트 번호 (I·S·B·U·J 다섯 형식을 합쳐, 그 비트에 들어올 수 있는 서로 다른 입력의 수) 실제 RISC-V 배치 — 입력 합계 70 S·U 를 그대로 한 칸 민 가상의 B·J — 합계 98 입력 수
그림 4-5. 즉시값 비트마다 들어올 수 있는 서로 다른 입력의 수. 파란 막대는 실제 RISC-V 배치, 붉은 막대는 B와 J를 S와 U의 즉시값을 통째로 한 칸 왼쪽으로 민 “상식적인” 배치로 바꿨을 때다. 이 책이 단순하게 센 모형에서도 입력 합계가 70 대 98으로 차이가 난다. 규격 해설은 실제 회로 기준으로 팬아웃과 멀티플렉서 비용이 “약 2배” 줄었다고 말한다.
규격 원문에서
“Sign extension is one of the most critical operations on immediates (particularly for XLEN>32), and in RISC-V the sign bit for all immediates is always held in bit 31 of the instruction to allow sign extension to proceed in parallel with instruction decoding.”

Volume I, RV32I · Immediate Encoding Variants

12비트 + 20비트라는 비대칭

일반 명령의 즉시값은 12비트, lui·auipc만 20비트다. 규격은 “실제 프로그램의 즉시값은 대부분 아주 작거나, 아니면 XLEN 비트가 통째로 필요하다”는 관찰을 근거로 든다. 작은 값은 12비트로 충분하고, 큰 값은 어차피 두 명령으로 만들어야 하니, 일반 명령의 즉시값을 줄여 opcode 공간을 넓히는 쪽을 택했다.

B형과 J형: 뒤섞인 비트 풀기

B 형식은 처음 보면 가장 당혹스럽다. imm[12|10:5] … imm[4:1|11]이라니, 왜 11번 비트가 맨 끝에 붙어 있을까? 답은 S 형식과의 차이를 최소로 만들기 위해서다.

분기 오프셋은 늘 2의 배수다(명령어가 최소 16비트 단위로 놓이므로). 그러니 imm[0]은 언제나 0이고 인코딩할 필요가 없다. 비트 하나를 아꼈으니 S 형식과 같은 12비트 칸으로 imm[12:1], 즉 ±4 KiB를 표현할 수 있다. 흔한 방법은 “S의 12비트 값을 하드웨어에서 왼쪽으로 한 칸 민다”이다. 그러면 S와 B의 즉시값 비트가 모두 한 칸씩 어긋난다. RISC-V는 대신 S와 똑같이 두고, S에서 imm[0]을 담던 inst[7] 단 한 자리에 imm[11]을 넣었다.

31 30 25 24 15 14 12 11 8 7 6 0 S imm[11] imm[10:5] rs2 · rs1 funct3 imm[4:1] imm[0] opcode 부호 = = = = ≠ B imm[12] imm[10:5] rs2 · rs1 funct3 imm[4:1] imm[11] opcode 실제 B 형식: 부호 비트를 빼면, S 형식과 비교해 뜻이 바뀌는 즉시값 비트는 inst[7] 단 하나 B* imm[12:6] rs2 · rs1 funct3 imm[5:1] opcode 가상의 B*: S의 12비트를 그대로 한 칸 민 배치 — 부호 비트를 뺀 11개 비트의 뜻이 전부 S와 어긋난다
그림 4-6. 부호 비트 inst[31]을 빼면 S 형식과 B 형식은 inst[7] 한 비트(깜빡이는 칸)만 뜻이 다르다. S에서는 imm[0], B에서는 imm[11]이다. 맨 아래 B*는 “즉시값을 한 칸 밀기”라는 흔한 방법을 썼을 때의 가상 배치로, 즉시값 비트 하나하나에 새 연결이 필요해진다.

J와 U의 관계도 똑같다. U 형식은 inst[31:12]를 즉시값의 imm[31:12]로 그대로 쓴다. J 형식은 20비트 칸으로 imm[20:1]을 표현해야 하는데, 이때 imm[19:12]는 U와 같은 inst[19:12]에, imm[10:1]은 I 형식과 같은 inst[30:21]에 두었다. 남은 imm[11]은 I 형식에서 imm[0]이 있던 inst[20]으로, 부호 비트 imm[20]은 inst[31]로 갔다. 결과적으로 J 형식에서 “새로 생긴 연결”은 거의 없다.

아래 애니메이션은 명령어 비트가 즉시값 자리로 날아가 모이는 과정을 보여 준다. 묶음 하나가 움직일 때마다, 그 연결이 다른 어느 형식과 똑같은지(즉 하드웨어가 이미 가진 배선을 다시 쓰는지) 아래 설명에 나타난다.

ANIMATION

즉시값 비트 모으기

재생 중…
형식
재생
오프셋 (바이트)0
명령어0x00000000
어셈블리—
B에서 네 묶음 중 새 연결은 inst[7] → imm[11] 하나뿐이고 나머지는 모두 다른 형식에 이미 있는 연결이라는 설명을 확인하자. J로 바꾸면 inst[19:12]는 전혀 움직이지 않는다. U 형식과 같은 자리이기 때문이다. 끝으로 imm[0]에 0이 채워지고, inst[31]이 위쪽 비트로 복사되며 부호 확장된다.
사람에게는 불편하다

뒤섞인 배치는 16진수 덤프를 눈으로 읽는 사람과, 실행 중에 기계어를 만들어 내는 JIT 컴파일러에게는 조금 번거롭다. 규격도 이 점을 인정하면서, 정적 컴파일에는 무시할 만한 비용이고 가장 흔한 “짧은 앞쪽 분기”는 인코딩이 단순하다고 덧붙인다. 하드웨어는 수십억 번 해독하지만 어셈블러는 한 번만 인코딩한다. RISC-V는 그 비대칭에 맞춰 비용을 소프트웨어 쪽에 실었다.

opcode, funct3, funct7: 명령을 고르는 세 단계

32비트 명령의 하위 7비트가 opcode(major opcode)다. 그중 하위 2비트는 11로 정해져 있으니 실제로 고를 수 있는 것은 inst[6:2] 다섯 비트, 즉 32가지다. 규격은 이를 inst[6:5]를 행, inst[4:2]를 열로 하는 4×8 표로 그린다.

inst[4:2] → 000 001 010 011 100 101 110 111 (>32b) inst[6:5] ↓ 00 LOAD 0x03 LOAD-FP 0x07 custom-0 0x0b MISC-MEM 0x0f OP-IMM 0x13 AUIPC 0x17 OP-IMM-32 0x1b reserved 01 STORE 0x23 STORE-FP 0x27 custom-1 0x2b AMO 0x2f OP 0x33 LUI 0x37 OP-32 0x3b reserved 10 MADD 0x43 MSUB 0x47 NMSUB 0x4b NMADD 0x4f OP-FP 0x53 OP-V 0x57 custom-2 0x5b reserved 11 BRANCH 0x63 JALR 0x67 reserved JAL 0x6f SYSTEM 0x73 OP-VE 0x77 custom-3 0x7b reserved RV32I 가 쓰는 11개 RV64I 의 *W 명령 다른 표준 확장 (F·A·V …) 사용자 정의 custom-0~3
그림 4-7. RVG의 주요 opcode 지도(규격 “RISC-V base opcode map, inst[1:0]=11”). 칸 아래 숫자는 하위 2비트 11까지 포함한 7비트 opcode 값이다. RV32I의 40개 명령은 파란 칸 11개에 모두 들어간다. custom-0~3은 앞으로도 표준 확장이 쓰지 않기로 약속한 자리여서 회사·연구실이 자기 명령을 넣기 좋고, reserved 칸은 미래의 표준 확장 몫이므로 피해야 한다.

opcode가 큰 무리를 고르면, funct3(inst[14:12])이 그 안에서 연산을 고르고, R 형식에서는 funct7(inst[31:25])이 한 번 더 고른다. RV32I에서 funct7이 실제로 쓰는 값은 0000000과 0100000 둘뿐이고, 둘의 차이는 inst[30] 한 비트다. add와 sub, srl과 sra, srli와 srai가 이 비트 하나로 갈린다. 덧셈기는 inst[30]을 “두 번째 피연산자를 뒤집고 1을 더하라”는 신호로, 시프터는 “빈자리를 부호로 채워라”는 신호로 그대로 쓸 수 있다.

명령형식opcodefunct3funct7 / imm[11:5]
luiU0110111 LUI——
auipcU0010111 AUIPC——
jalJ1101111 JAL——
jalrI1100111 JALR000—
beq · bne · blt · bge · bltu · bgeuB1100011 BRANCH000 001 100 101 110 111—
lb · lh · lw · lbu · lhuI0000011 LOAD000 001 010 100 101—
sb · sh · swS0100011 STORE000 001 010—
addi · slti · sltiu · xori · ori · andiI0010011 OP-IMM000 010 011 100 110 111—
slliI0010011 OP-IMM0010000000
srli · sraiI0010011 OP-IMM101 1010000000 0100000
add · subR0110011 OP000 0000000000 0100000
sll · slt · sltu · xor · or · andR0110011 OP001 010 011 100 110 1110000000
srl · sraR0110011 OP101 1010000000 0100000
fenceI0001111 MISC-MEM000(fm·pred·succ)
ecall · ebreakI1110011 SYSTEM000imm = 0 / 1

표를 세어 보면 4 + 6 + 5 + 3 + 9 + 10 + 1 + 2 = 40개로, 규격이 말하는 “RV32I의 40개 고유 명령”과 맞는다. 각 명령의 뜻은 5장에서 하나씩 실행하며 살펴본다.

시프트 즉시값은 I 형식의 특수형

32비트 값을 31칸보다 더 밀 일은 없으므로 RV32I의 slli·srli·srai는 I 형식의 12비트 즉시값 중 하위 5비트만 시프트 양(shamt)으로 쓴다. 남는 상위 7비트는 R 형식의 funct7처럼 쓰여서, 오른쪽 시프트의 종류를 역시 inst[30]으로 고른다. RV64에서는 shamt가 6비트로 늘어난다(6장).

직접 인코딩하고 해독하기

이제 손으로 해 보자. 첫 번째 시뮬레이터는 어셈블러가 하는 일, 두 번째는 CPU의 해독기가 하는 일이다. 즉시값 범위를 벗어나거나 분기 오프셋을 홀수로 주면 어셈블러처럼 오류를 낸다.

SIMULATOR

명령어 인코더

add a0, a1, a2
형식R
16진수0x00000000
즉시값 허용 범위—
beq를 고르고 오프셋을 -8, 2048, 4094, 4096, 7로 바꿔 보자. 범위를 넘거나 홀수면 오류가 난다. addi에서 rd만 바꾸면 inst[11:7]만, rs1만 바꾸면 inst[19:15]만 바뀌는 것도 확인하자.
SIMULATOR

명령어 디코더

필드비트2진수값뜻
무작위
예시
디스어셈블—
형식 · opcode—
조립된 즉시값—
위의 비트를 눌러 하나씩 뒤집어 보자. inst[30]을 바꾸면 add가 sub로 바뀐다. inst[0]을 0으로 만들면 32비트 명령이 아니게 된다. 무작위 명령을 여러 번 눌러 형식마다 즉시값이 어떻게 흩어지는지 살펴보자.

32비트 상수 만들기: lui + addi, 그리고 auipc

12비트 즉시값으로는 −2048부터 2047까지밖에 못 쓴다. 그보다 큰 상수는 두 명령으로 만든다. lui rd, imm20은 20비트 값을 레지스터의 상위 20비트에 놓고 하위 12비트를 0으로 채운다. 이어서 addi rd, rd, imm12로 하위 12비트를 더한다. 어셈블러의 li 의사 명령이 바로 이 일을 한다.

함정이 하나 있다. addi의 즉시값은 부호 확장된다. 하위 12비트의 맨 위 비트(bit 11)가 1이면 addi는 그것을 음수로 보고, 결과적으로 상위 부분에서 4096을 빼는 셈이 된다. 그래서 그런 경우에는 lui에 줄 상위 값을 미리 1 늘려 둔다. 식으로 쓰면 상위 = (값 + 0x800) >> 12, 하위 = 값 − (상위 << 12)이다.

목표: a0 = 0x12345FFF 상위 20비트 0x12345 하위 12비트 0xFFF addi에게 0xFFF는 −1 이다 ① 보정 없이 ② 상위에 +1 보정 lui a0, 0x12345 a0 = 0x12345000 addi a0, a0, -1 a0 = 0x12344FFF ✗ lui a0, 0x12346 a0 = 0x12346000 addi a0, a0, -1 a0 = 0x12345FFF ✓
그림 4-8. 0x12345FFF 만들기. 하위 12비트 0xFFF의 bit 11이 1이므로 addi는 이를 −1로 더한다. 상위 값을 그대로 쓰면 결과가 정확히 4096(0x1000) 모자란다. 상위에 1을 더해 0x12346으로 lui하면 −1을 더한 결과가 목표와 같아진다.
SIMULATOR

32비트 상수 빌더

예시
필요한 명령 수2
lui 즉시값—
addi 즉시값—
에뮬레이터 검증—
0x800, 0x7FFFF800, 0xFFFFF000처럼 하위 12비트가 경계에 걸린 값을 넣어 보자. 하위 12비트의 맨 위 비트(밑줄)가 1일 때만 +1 보정이 생긴다. 마지막 칸은 같은 li를 이 책의 RV32 에뮬레이터로 실제 실행해 a0 값이 목표와 같은지 확인한 결과다.

auipc: pc를 기준으로 한 주소

auipc rd, imm20(add upper immediate to pc)는 lui와 같은 U 형식이지만, 20비트 값을 상위에 놓은 뒤 이 명령 자신의 주소(pc)를 더한다. 여기에 12비트 즉시값을 가진 명령을 하나 더 붙이면 pc에서 32비트 범위 안의 어디든 가리킬 수 있다. addi를 붙이면 주소를 만들고(la), 적재·저장 명령을 붙이면 그 주소를 바로 읽고 쓰며, jalr를 붙이면 그곳으로 점프한다(call). 프로그램이 메모리 어디에 올라가도 상대 거리는 변하지 않으므로 위치 독립 코드를 만드는 핵심 도구다.

la t0, msg ⇒ auipc t0, 0x3 ; addi t0, t0, -1584 pc = 0x00010040 msg = 0x00012A10 0x00013040 auipc: pc + (0x3 << 12) = pc + 0x3000 addi: −1584 (−0x630) 오프셋 0x29D0 의 하위 12비트 0x9D0 은 bit 11 이 1 → 상위 2 에 +1 → 3
그림 4-9. la t0, msg가 펼쳐진 모습. auipc는 pc에 0x3000을 더해 목표 근처로 크게 뛰고, addi가 남은 거리(−1584)를 조정한다. 여기서도 하위 12비트의 bit 11이 1이어서 상위 값에 +1 보정이 들어갔다. 어셈블러는 %pcrel_hi(msg)·%pcrel_lo(...) 같은 재배치 연산자로 이 계산을 링커에게 맡긴다.
%hi와 %lo도 같은 보정을 한다

절대 주소를 만들 때 쓰는 lui a0, %hi(symbol) · addi a0, a0, %lo(symbol)에서 %hi는 단순히 상위 20비트를 잘라 오는 것이 아니라, %lo가 음수가 될 것을 감안해 0x800을 더한 뒤 자른 값이다. 이 보정 규칙은 psABI의 재배치 정의에 들어 있다(7장).

분기와 점프는 얼마나 멀리 갈 수 있나

즉시값 비트 수가 곧 도달 거리다. B 형식은 imm[12:1]의 12비트에 암묵적인 imm[0]=0을 더한 13비트 부호 있는 값이므로 −4096부터 +4094바이트까지, 규격 표현으로 ±4 KiB다. J 형식은 imm[20:1]로 −1,048,576부터 +1,048,574바이트, ±1 MiB다. 두 경우 모두 오프셋은 분기·점프 명령 자신의 주소를 기준으로 한다.

pc auipc + jalr : 32비트 pc 상대 주소 어디든 (약 ±2 GiB) jal : ±1 MiB (−1,048,576 … +1,048,574) 분기 : ±4 KiB 분기: −4096 … +4094 바이트 · 모두 2바이트 단위 (축척은 실제와 다름)
그림 4-10. 제어 이동 명령의 도달 범위. 실제 비율로는 ±4 KiB 막대가 ±1 MiB 막대의 256분의 1밖에 안 된다. 그보다 먼 곳은 auipc로 상위를 만든 뒤 jalr의 12비트로 마무리한다.
명령즉시값단위범위기준
beq 등 분기B: 12비트 + 암묵 02바이트−4096 … +4094분기 명령의 주소
jalJ: 20비트 + 암묵 02바이트−1,048,576 … +1,048,574jal 명령의 주소
jalrI: 12비트1바이트 (결과의 bit 0은 0으로)rs1 −2048 … rs1 +2047레지스터 rs1
auipc + jalrU 20비트 + I 12비트1바이트32비트 pc 상대 전 범위auipc의 주소

jalr는 즉시값을 2배 하지 않는다. 규격은 그 이유를 “즉시값 형식을 하나 더 늘리지 않기 위해서”라고 설명한다. jalr는 대개 즉시값이 0이거나 lui·auipc와 짝을 이루므로 범위가 조금 줄어도 문제가 없다. 대신 계산된 주소의 bit 0을 0으로 지워, 함수 포인터의 최하위 비트를 다른 정보에 쓸 여지를 남겼다.

2바이트 단위인데 4바이트 정렬?

인코딩은 2바이트 단위 오프셋을 허용하지만, 압축 명령이 없는 구현(IALIGN=32)에서는 명령이 4바이트 경계에 있어야 한다. 이때 분기가 실제로 성립해서 4의 배수가 아닌 주소로 뛰려 하면 명령어 주소 정렬 예외(instruction-address-misaligned)가 난다. 규격은 이 예외를 목표 명령이 아니라 분기·점프 명령 자신에게서 보고하도록 정했고, 성립하지 않은 분기는 예외를 내지 않는다.

링커 완화(relaxation)

컴파일러는 함수가 얼마나 멀리 있을지 모르므로 call을 일단 auipc ra, … + jalr ra, …(ra) 두 명령으로 내보낸다. 링크할 때 실제 거리가 ±1 MiB 안이면 링커가 이를 jal ra, … 한 명령으로 줄인다. 즉시값 범위가 코드 크기와 직결되기 때문에 생긴 최적화다.

핵심 정리

  1. 베이스 ISA의 명령은 모두 32비트이고 하위 2비트가 11이다. 이 비트들로 16비트 압축 명령과 더 긴 명령의 자리를 미리 확보했다.
  2. 형식은 R·I·S·U 네 가지와, 즉시값 처리만 다른 변형 B·J 두 가지다. opcode는 언제나 [6:0], funct3은 [14:12]에 있다.
  3. rs1·rs2·rd는 모든 형식에서 같은 자리에 있어서, 해독이 끝나기 전에 레지스터 파일을 읽을 수 있다.
  4. CSR의 5비트 즉시값을 빼면 즉시값은 모두 부호 확장되며, 부호 비트는 언제나 inst[31]이어서 부호 확장을 해독과 동시에 할 수 있다.
  5. B는 S와, J는 U·I와 최대한 겹치도록 비트를 “회전”시켰다. S와 비교해 B에서 뜻이 바뀐 것은 부호 비트를 빼면 inst[7](imm[11]) 하나이고, imm[0]은 언제나 0이라 인코딩하지 않는다.
  6. opcode가 큰 무리를, funct3과 funct7이 세부 연산을 고른다. add/sub, srl/sra는 inst[30] 한 비트로 갈린다.
  7. 32비트 상수는 lui+addi로 만들고, 하위 12비트의 bit 11이 1이면 상위 값에 +1 한다. auipc는 같은 방식으로 pc 상대 주소를 만든다.
  8. 분기는 ±4 KiB, jal은 ±1 MiB, auipc+jalr는 32비트 pc 상대 전 범위에 닿는다.

확인 퀴즈

메모리에서 읽은 명령의 첫 바이트가 0x13이다. 이 명령의 길이는?

0x13 = 0b0001_0011. 하위 2비트가 11이고 inst[4:2]가 100(≠111)이므로 32비트 명령이다. 실제로 OP-IMM(addi 등) opcode다.

rs1·rs2·rd를 모든 형식에서 같은 자리에 둔 가장 큰 이유는?

레지스터 번호 해독은 구현의 임계 경로에 있다. 자리가 고정되어 있으면 해독과 레지스터 읽기를 병렬로 할 수 있다. 그 대가로 즉시값 비트가 형식마다 옮겨 다닌다.

B 형식에서 inst[7]이 담는 것은?

S 형식에서 imm[0]이던 자리에 B는 imm[11]을 넣었다. 분기 오프셋의 imm[0]은 항상 0이라 인코딩할 필요가 없고, 나머지 비트는 S와 같은 자리에 남는다.

모든 즉시값의 부호 비트를 inst[31]에 둔 이유로 가장 알맞은 것은?

부호 확장은 즉시값 처리 중 가장 시간이 걸리는 일 중 하나다. 부호 비트 자리가 고정되어 있으면 해독 결과를 기다릴 필요가 없다.

li a0, 0x00012800을 lui+addi로 펼치면 lui의 즉시값은?

하위 12비트 0x800은 bit 11이 1이라 addi에게 −2048이다. 그래서 상위를 0x12+1 = 0x13으로 두면 0x13000 − 2048 = 0x12800이 된다.

add와 sub를 구별하는 비트는?

funct7이 0000000이면 add, 0100000이면 sub이다. 같은 비트가 srl/sra, srli/srai도 구별한다.

beq로 바로 갈 수 있는 가장 먼 앞쪽 목표는 분기 명령으로부터 몇 바이트인가?

B 즉시값은 13비트 부호 있는 짝수 값이므로 최댓값은 212 − 2 = 4094다. +1,048,574는 jal의 최대 앞쪽 거리다.

opcode 지도에서 custom-0~3으로 표시된 칸의 뜻은?

미래 표준 확장을 위해 비워 둔 것은 reserved 칸이다. custom 칸은 표준이 피하기로 했으므로 회사·연구실이 자기 명령을 넣기에 안전하다.

더 읽을거리