RV64I: W 명령어, 부호 확장 규칙, 32비트와 다른 점

Sunhong··10 min read·0 views

이 글의 핵심 3줄

  • RV64I는 레지스터를 64비트로 넓히고 명령어 12개(ld sd lwu와 W 명령어 9개)를 더한다. W 명령어는 아래 32비트만으로 계산한 뒤 bit 31을 위 32비트에 복사한다. QEMU에서 0x7fffffff + 1은 add로 0x0000000080000000, addw로 0xffffffff80000000이 됐다.
  • 이 규칙의 목적은 "32비트 값은 언제나 부호 확장된 모양으로 레지스터에 둔다"는 약속이다. 그래서 C의 int→long 변환, 비교, 논리 연산에는 명령어가 따로 필요 없고, 덧셈과 시프트에만 W 버전이 있다.
  • lui도 결과를 부호 확장하므로 64비트 상수는 비싸다. 무작위 64비트 상수 5,000개를 li로 펼치니 평균 7.887개, 최대 8개 명령어였다. 32비트 상수는 언제나 2개였다. 실행·인코딩 결과 34개가 기준 모델과 모두 일치했다.

2~4편에서 RV32I 40개 명령어를 모두 봤다. 5편은 같은 ISA를 64비트로 넓힌 RV64I다. 리눅스가 도는 응용 프로세서는 거의 다 RV64라서, 나중에 RV64GC 코어를 만든다면 이 편의 규칙이 데이터패스 곳곳에 들어간다. 명세의 RV64I 장(4장)은 RV32I와 다른 점만 적는다. 이 글도 그렇게, 무엇이 넓어지고 무엇이 더해지는지, 그리고 왜 그렇게 만들었는지를 본다.

실습은 qemu-system-riscv64(11.1.3)의 virt 보드에서 베어메탈로 했다. 컴파일은 riscv64-elf-gcc 16.2, -march=rv64i -mabi=lp64다.

1. 무엇이 넓어지고 무엇이 더해지나

XLEN이 64가 된다. 레지스터 x1~x31과 pc가 64비트이고, 주소 공간도 64비트다. RV32I 명령어는 이름과 인코딩을 그대로 쓰되, 연산 폭이 64비트가 된다. 예를 들어 add는 64비트 덧셈이고, slt는 64비트 비교다.

새로 생기는 명령어는 12개다.

종류 명령어 하는 일
64비트 메모리 ld, sd 8바이트 로드·스토어
32비트 로드 lwu 4바이트를 영 확장 (lw는 부호 확장)
W 즉시값 addiw, slliw, srliw, sraiw 32비트로 계산 후 부호 확장
W 레지스터 addw, subw, sllw, srlw, sraw 32비트로 계산 후 부호 확장

W 명령어는 RV32I에 없던 opcode 두 개(OP-IMM-32 0011011, OP-32 0111011)를 쓴다. ld·sd는 funct3 011, lwu는 110이다. 4편의 "funct3[1:0]은 크기, funct3[2]는 영 확장" 규칙이 그대로 이어진다. 크기 11이 8바이트가 됐을 뿐이다.

눈여겨볼 것은 없는 명령어다. andw·orw·xorw도, sltw도, beqw 같은 분기도 없다. 이유는 2절의 약속에 있다.

2. W 명령어와 "부호 확장해 둔다"는 약속

명세는 W 명령어를 이렇게 정의한다. 입력의 위 32비트는 무시하고, 아래 32비트로 계산한 32비트 결과를 64비트로 부호 확장해 rd에 쓴다. 결과의 bit 63부터 bit 31까지가 모두 같아진다.

QEMU rv64에서 0x7fffffff+1은 add로 0x0000000080000000, addw로 0xffffffff80000000이 되고, li 0x7fffffff는 lui 0x80000 뒤 addiw -1로 맞는 값이 된다

QEMU에서 실제로 실행한 결과다.

연산   rs1               rs2  결과
add    000000007fffffff  1    0000000080000000
addw   000000007fffffff  1    ffffffff80000000
addw   1234567800000001  1    0000000000000002
subw   0000000000000000  1    ffffffffffffffff
addiw  1234567880000000  0    ffffffff80000000

C의 int로 생각하면 addw가 맞는 답이다. int의 0x7fffffff + 1은 32비트에서 한 바퀴 돌아 −2,147,483,648이 되고, 64비트 레지스터에서 그 값의 모양은 0xffffffff80000000이다. 셋째 줄은 위 32비트에 쓰레기(0x12345678)가 있어도 addw가 무시한다는 것을 보여 준다. addiw rd, rs, 0은 아래 32비트를 부호 확장만 하는 명령어가 되어, 의사 명령어 sext.w라는 이름이 붙어 있다.

왜 부호 확장인가

명세의 해설에 핵심이 있다. 컴파일러와 호출 규약은 32비트 값을 언제나 부호 확장된 모양으로 64비트 레지스터에 둔다. 부호 없는 32비트 정수(unsigned)조차 bit 31을 위로 복사해 둔다. 이 약속 하나로 많은 것이 공짜가 된다.

  • int → long 변환: 이미 부호 확장돼 있으니 명령어가 필요 없다.
  • int와 unsigned 사이 변환: 레지스터 모양이 같으니 명령어가 필요 없다.
  • 비교와 분기: 부호 확장된 두 32비트 값을 64비트 slt·blt로 비교해도 답이 같다. 부호 없는 비교(sltu·bltu)도 마찬가지다. 위 32비트가 같은 규칙으로 채워져 있어 순서가 보존되기 때문이다.
  • 논리 연산: 부호 확장된 값끼리 and·or·xor를 하면 결과도 부호 확장된 모양이다.

반면 덧셈·뺄셈·시프트는 32비트에서 넘친 비트가 bit 31 위로 올라가 약속을 깬다. 그래서 이것들만 W 버전이 있다. 명세의 표현대로 W 명령어는 "32비트 값에서 적당한 성능을 내는 데 필요한 몇 개"만 더한 것이다.

실제 컴파일 결과로 확인했다(-O2).

C 함수 RV64 명령어
int add(int a, int b) addw a0,a0,a1
unsigned add(unsigned, unsigned) addw a0,a0,a1
long i2l(int a) 없음 (ret만)
int lt(int a, int b) slt a0,a0,a1 (64비트 비교 그대로)
unsigned long u2ul(unsigned a) slli a0,a0,32 + srli a0,a0,32

마지막 줄이 이 약속의 비용이다. unsigned를 64비트 unsigned long으로 바꾸려면 위 32비트를 0으로 지워야 하는데, RV64I에는 그 명령어가 없어 시프트 두 개를 쓴다. unsigned 인덱스로 배열을 읽을 때도 같은 일이 생긴다(slli 32 + srli 29로 영 확장과 ×8을 한 번에). 이 비용을 줄이려고 Zba 확장에 zext.w(= add.uw rd, rs, zero)가 들어 있다.

1편의 utoa10이 RV64에서 커진 이유

1편에서 utoa10(부호 없는 32비트를 10진 문자열로)이 RV64에서 더 커졌다. 이번에 C 확장 없이 rv32im과 rv64im으로 다시 컴파일해 명령어를 셌다. 33개 대 39개였다. 차이는 세 곳에서 났다.

  • 나눗셈을 곱셈으로 바꾼 상수 0xcccccccd: RV32는 lui+addi 2개, RV64는 4개. 이 값은 bit 31이 1이라 부호 확장된 32비트 상수로는 만들 수 없기 때문이다(4절).
  • uint32_t v를 64비트 곱셈에 넣기 전 영 확장: slli+srli 2개. Zba를 켜면 zext.w 1개가 되어 38개로 줄었다.
  • 나머지 2개는 레지스터 배치와 반복문 모양 차이였다.

반대로 n++, v - q*10, '0' + r 같은 32비트 연산은 W 명령어 6개(addiw 2, slliw 2, addw, subw)로 RV32와 같은 개수에 끝났다. W 명령어가 없었다면 이 연산마다 부호 확장(sext.w)이 하나씩 더 붙어야 했을 것이다(추정). W 명령어는 RV32 코드를 RV64로 옮겼을 때 커지는 것을 이 정도에서 막아 준다.

3. 시프트 양: 6비트와 5비트

64비트 시프트는 0~63을 밀 수 있어야 하므로 시프트 양이 6비트가 된다. W 시프트는 32비트 값을 미니까 5비트 그대로다.

연산   rs1               양  결과
sllw   0000000000000001  31  ffffffff80000000
sllw   0000000000000001  32  0000000000000001
sll    0000000000000001  32  0000000100000000
sll    0000000000000001  64  0000000000000001
srliw  ffffffff80000000  4   0000000008000000
srli   ffffffff80000000  4   0ffffffff8000000
sraiw  ffffffff80000000  4   fffffffff8000000

sllw로 1을 31번 밀면 bit 31이 1이 되어 위가 모두 1로 채워진다. 32번 밀면 아래 5비트(0)만 보므로 그대로 1이다. sll은 6비트를 보므로 32는 진짜 32번이고, 64는 0번이 된다. 오른쪽 시프트 세 줄을 비교하면 W의 뜻이 분명해진다. srliw는 아래 32비트 0x80000000만 0을 채워 밀고(0x08000000), 결과의 bit 31이 0이라 위도 0이다. 64비트 srli는 위 32비트의 1들까지 함께 밀려 내려온다.

인코딩에서는 RV32에서 "inst[25]=1이면 예약"이던 자리가 RV64의 slli·srli·srai에서 shamt[5]가 된다(2편). 반대로 slliw·srliw·sraiw에서 imm[5]가 1인 인코딩은 예약돼 있다. 디코더가 RV32/RV64를 함께 지원한다면 이 한 비트의 의미가 XLEN에 따라 바뀐다.

4. 로드, lui·auipc의 부호 확장, 64비트 상수

메모리에 워드 0x80000000, 그 다음 워드에 0x00000001을 두고 읽었다. lw는 ffffffff80000000, lwu는 0000000080000000, ld는 두 워드를 합쳐 0000000180000000이었다.

lui와 auipc도 RV64에서 바뀐다. 20비트 즉시값을 12비트 밀어 만든 32비트 값을 64비트로 부호 확장한다.

명령어 실행 결과 설명
lui 0x80000 ffffffff80000000 bit 31이 1 → 위가 1
lui 0x7ffff 000000007ffff000 bit 31이 0 → 위가 0
auipc 0xfffff pc − 0x1000 pc 0x80000398 → 0x7ffff398
auipc 0x80000 pc − 2 GiB pc 0x800003b8 → 0x000003b8

auipc가 부호 확장되기 때문에 pc 기준 주소는 RV64에서도 앞뒤로 ±2 GiB다. 넷째 줄은 0x800003b8에서 2 GiB 아래인 0x3b8을 정확히 가리켰다.

li 0x7fffffff와 addiw

RV64에서 0x7fffffff를 만드는 과정은 3편의 RV32와 다르다. 어셈블러는 lui a0, 0x80000과 addiw a0, a0, -1을 골랐다. lui가 0xffffffff80000000을 만들고, addiw가 32비트에서 −1을 더해 0x7fffffff로 한 바퀴 돈 뒤 부호 확장한다. bit 31이 0이니 위가 0이 되어 정답 0x000000007fffffff다. 여기서 64비트 addi를 썼다면 0xffffffff7fffffff가 됐을 것이다. RV64의 상수 생성에서 addiw가 필요한 이유다.

같은 이유로 bit 31이 1인 양수, 예를 들어 0x80000000은 lui 하나로 만들 수 없다. 어셈블러는 addiw a0, zero, 1과 slli a0, a0, 31로 만들었다.

64비트 상수는 몇 명령어인가

li가 펼친 결과를 모았다(-march=rv64i, C 없음).

0x7ff                addi
0x800                lui + addiw
0x7fffffff           lui + addiw
0x80000000           addiw + slli
0xffffffff           addiw + slli + addi
0xcccccccd           lui + addiw + slli + addi
0x8000000000000000   addiw + slli
0x123456789abcdef0   lui + addiw + (slli + addi) x3

규칙은 이렇다. 32비트 부호 있는 범위의 값은 3편처럼 lui + addiw(최대 2개)로 끝난다. 그 밖의 값은 위쪽 부분을 먼저 만들고 "12비트 안팎씩 왼쪽으로 밀고 아래 12비트를 더하기"를 되풀이한다. 아래쪽에 0이 많으면 밀기 한 번으로 끝나고, 비트가 꽉 찬 값은 최대 8개까지 간다.

li로 펼친 명령어 수는 12비트 이하 상수가 언제나 1개, 32비트 상수가 언제나 2개, 무작위 64비트 상수는 평균 7.887개(8개가 4,718개)였다

상수 종류별로 5,000개씩 무작위로 뽑아 어셈블했다. 12비트 이하는 모두 1개, 32비트 부호 있는 값은 모두 2개였다. 무작위 64비트 값은 4,718개가 8개, 281개가 6개, 1개가 7개로 평균 7.887개였다. 펼친 명령어를 Python으로 다시 실행해 15,000개 모두 원래 상수가 되는 것을 확인했다.

컴파일러는 다른 방법을 쓴다. GCC -O2는 0x123456789abcdef0을 위 절반과 아래 절반을 두 레지스터에서 따로 만든 뒤 slli 35와 add로 합쳤다. 명령어는 7개지만 두 줄기가 서로 기다리지 않아 슈퍼스칼라 코어에서 빨리 끝난다. 위·아래 32비트가 같은 0x1234567812345678은 한 번 만들어 slli 32 + add로 4개에 끝냈다. 그래도 64비트 상수는 비싸므로, 주소처럼 자주 쓰는 큰 값은 auipc 기준 상대 주소나 메모리의 상수 풀에서 읽는 편이 낫다.

5. 실습: QEMU 실행 결과와 기준 모델 대조

W 명령어는 인라인 어셈블리로 한 개씩 실행하고 64비트로 찍었다.

#define R(op, a, b) ({ uint64_t r_;            \
  __asm__ volatile(op " %0,%1,%2"              \
                   : "=r"(r_) : "r"(a), "r"(b)); r_; })

volatile uint64_t big = 0x7fffffff, one = 1;
show("add",  big, one, R("add",  big, one));
show("addw", big, one, R("addw", big, one));

run.sh는 -march가 rv64로 시작하면 qemu-system-riscv64를 고른다. 기준 모델은 Python으로 몇 줄이다. W 연산은 "아래 32비트로 계산 → 32비트로 자르기 → 부호 확장"이다.

sx = lambda v, b: ((v & ((1 << b) - 1))
                   ^ (1 << (b - 1))) - (1 << (b - 1))
w = lambda v: sx(v, 32) & M64      # 32비트 결과 → 64비트
REF['addw'] = lambda a, b: w(a + b)
REF['srlw'] = lambda a, b: w((a & M32) >> (b & 31))
REF['lui']  = lambda a, b: sx(a << 12, 32) & M64
시험 개수 일치
QEMU 실행 (W·시프트·로드·lui·auipc) 21 21
li 예시 10개를 다시 실행해 원래 값 10 10
무작위 상수 묶음 3개 (각 5,000개) 3 3

모두 34개, 전부 일치했다. srlw의 기준 모델을 처음에 w(a >> (b & 31))로 썼다면 위 32비트가 아래로 밀려 들어와 틀렸을 것이다. 명세의 "입력의 위 32비트는 무시"를 그대로 옮겨 a & M32를 먼저 해야 한다. 하드웨어도 마찬가지다.

6. 설계 체크리스트: RV64 데이터패스

항목 규칙
W 결과 아래 32비트 결과의 bit 31을 bit 63:32에 복사
W 입력 rs1·rs2의 bit 63:32는 무시. 특히 srlw·sraw는 아래 32비트만 민다
시프트 양 64비트는 rs2[5:0], W는 rs2[4:0]
즉시값 시프트 slli류는 inst[25] = shamt[5]. slliw류의 imm[5]=1은 예약
lui·auipc 32비트 값을 부호 확장. auipc는 그다음 64비트 pc에 더함
lw·lwu·ld 부호 확장·영 확장·그대로. funct3 010·110·011
없는 W 논리·비교·분기는 64비트 그대로 쓴다
ALU 구조 64비트 덧셈기 결과의 아래 32비트를 부호 확장하는 선택기 하나로 W 처리(흔한 선택)
W 시프터 sraw는 bit 31을 부호로 써야 한다. 64비트 시프터에 넣기 전 입력을 부호/영 확장해 두는 것이 흔한 방법
RV32 모드 RV64 코어가 mstatus.UXL 등으로 32비트 모드를 지원하면 결과 부호 확장 규칙이 추가된다(선택 기능)

ALU 구조 줄을 풀어 보자. addw는 64비트 덧셈을 그대로 한 뒤 결과 아래 32비트만 골라 부호 확장하면 된다. 입력 위 32비트가 쓰레기여도 아래 32비트 합은 달라지지 않기 때문이다. 시프트는 다르다. srlw·sraw는 위 32비트가 밀려 내려오면 안 되므로, 시프터에 넣기 전에 입력을 영 확장(srlw)이나 부호 확장(sraw)해 둬야 한다. 3절의 srliw 결과 0x0000000008000000이 이 처리의 정답이다.

정리

  • RV64I는 XLEN을 64로 넓히고 ld·sd·lwu와 W 명령어 9개를 더한다. W 명령어는 입력 위 32비트를 무시하고 32비트 결과를 부호 확장한다(0x7fffffff + 1 → addw 0xffffffff80000000).
  • 32비트 값은 언제나 부호 확장해 둔다는 약속 덕분에 int→long 변환, 비교, 논리 연산은 명령어가 필요 없다. unsigned→unsigned long만 영 확장(slli+srli, Zba의 zext.w)이 든다.
  • 시프트 양은 64비트가 6비트, W가 5비트다. lui·auipc는 32비트 값을 부호 확장하므로 li 0x7fffffff에는 addiw가 필요하다.
  • 64비트 상수는 li로 평균 7.887개(무작위 5,000개), 32비트 상수는 언제나 2개였다. 1편 utoa10이 RV64에서 33개 → 39개로 커진 것도 상수 생성과 영 확장 때문이었다.

다음 글: 6편에서는 어셈블리와 ABI를 다룬다. a0~a7·s0~s11 같은 레지스터 이름이 호출 규약에서 어떤 약속인지, 스택 프레임과 프롤로그·에필로그가 어떻게 생기는지 실제 C 함수를 컴파일해 따라간다. 인자가 8개를 넘을 때, 구조체를 돌려줄 때, ilp32·lp64d 같은 ABI 이름의 차이, call·tail·la 같은 의사 명령어의 펼침, 그리고 ELF 재배치와 링커 완화를 readelf·objdump로 본다.

참고 문헌

  • The RISC-V Instruction Set Manual, Volume I: Unprivileged Architecture, Version 20240411. 4장 RV64I(4.2 정수 연산, 4.3 로드·스토어), 2.3 즉시값 인코딩. https://github.com/riscv/riscv-isa-manual
  • RISC-V Bit-Manipulation ISA-extensions (Zba의 add.uw·zext.w), Version 1.0.0
  • RISC-V Assembly Programmer's Manual (li, sext.w). https://github.com/riscv-non-isa/riscv-asm-manual
  • RISC-V ELF psABI (LP64 정수 인자의 부호 확장 규칙). https://github.com/riscv-non-isa/riscv-elf-psabi-doc
  • D. Patterson, A. Waterman, The RISC-V Reader: An Open Architecture Atlas, 2017. 9장 RV64

비슷한 글 추천