Chapter 16

작지만 중요한 확장들

M·A·F·V처럼 한 글자 이름을 가진 큰 확장 말고도, RISC-V에는 이름이 Z로 시작하는 작은 확장이 수십 개 있다. 명령어가 두 개뿐인 것도 있고, 아예 새 명령 없이 기존 인코딩의 빈자리에 뜻만 얹은 것도 있다. 하지만 이 작은 확장들이 없으면 운영체제는 CSR을 못 건드리고, JIT 컴파일러는 새 코드를 못 돌리고, 공격자는 반환 주소를 마음대로 바꾼다. 이 장은 Z 확장 하나하나가 “왜” 필요했는지를 따라가며, 작은 인코딩 결정이 어떤 큰 문제를 푸는지 본다.

Z 확장 지도: 작게 쪼갠 이유

RISC-V의 확장 이름 규칙(2장)에서 Z 다음 글자는 그 확장이 기대는 한 글자 확장을 가리킨다. Zi*는 베이스 정수 ISA(I) 쪽, Zb*는 비트 조작(B) 쪽이다. 이 장에서 다루는 확장은 대부분 Zi로 시작한다. 즉 정수 베이스에 붙는 작은 기능이다.

왜 이렇게 잘게 나눌까? 이유는 두 가지다. 첫째, 필요 없는 구현은 안 만들어도 되게 하려는 것이다. 운영체제 없는 마이크로컨트롤러에는 shadow stack이 필요 없고, 캐시가 없는 칩에는 캐시 관리 명령이 의미가 없다. 둘째, 원래 베이스에 있던 것을 떼어 내기 위해서다. 카운터 CSR과 fence.i는 예전에 RV32I 안에 있었지만, 모든 구현에 강요하기엔 부담이 커서 Zicntr와 Zifencei로 분리되었다.

Zicsr CSR 읽기·수정·쓰기 명령 6개 SYSTEM opcode · csr[11:0] Zicntr · Zihpm cycle · time · instret · 성능 카운터 CSR 0xC00–0xC1F (+0xC80–) Zifencei 새로 쓴 코드를 인출에 보이게 MISC-MEM · fence.i Zihintpause · Zihintntl 스핀 대기 힌트 · 캐시 힌트 FENCE · ADD x0 힌트 공간 Zimop · Zcmop 나중에 뜻이 붙는 예약 자리 SYSTEM · C.LUI 예약 공간 Zicond 분기 없는 조건부 선택 OP · czero.eqz / czero.nez Zicbom · Zicboz · Zicbop 캐시 블록 관리 · 0 채우기 · 프리페치 MISC-MEM · ORI(rd=x0) Zicfilp · Zicfiss 착지 패드 · 그림자 스택 AUIPC(rd=x0) · MOP 재정의 Zilsd · Zclsd RV32의 레지스터 짝 load/store RV64 ld/sd 인코딩 재사용 베이스 정수 ISA (RV32I · RV64I) 위 확장 대부분은 새 opcode를 만들지 않고 기존 인코딩의 빈자리·예약 자리를 빌려 쓴다
그림 16-1. 이 장에서 다루는 Z 확장 지도. 상자 맨 아랫줄은 각 확장이 빌려 쓰는 인코딩 공간이다. 파란 무리는 “기본 도구”, 청록 무리는 “힌트와 예약 자리”, 보라·빨강·금색은 각각 캐시, 보안, RV32 성능을 위한 확장이다.
확장규격 버전새 명령·CSR한 줄 요약
Zicsr2.0csrrw·csrrs·csrrc·csrrwi·csrrsi·csrrciCSR 하나를 원자적으로 읽고 고쳐 쓴다
Zicntr / Zihpm2.0cycle·time·instret / hpmcounter3–31읽기 전용 64비트 카운터
Zifencei2.0fence.i자기 하트의 store를 명령 인출에 보이게
Zihintntl1.0ntl.p1·ntl.pall·ntl.s1·ntl.all다음 메모리 접근은 다시 안 쓴다는 힌트
Zihintpause2.0pause스핀 대기 중 잠깐 쉬어 가라는 힌트
Zimop / Zcmop1.0mop.r.n·mop.rr.n / c.mop.n나중 확장이 뜻을 붙일 자리, 기본은 rd ← 0
Zicond1.0.0czero.eqz·czero.nez조건이 0이면(아니면) 0을 쓴다
CMO (Zicbom·Zicboz·Zicbop)1.0.0cbo.clean·flush·inval·zero, prefetch.i·r·w캐시 블록 단위 관리
Zicfilp / ZicfissCFI 장 (비준됨)lpad / sspush·sspopchk·ssrdp·ssamoswap, ssp CSR간접 점프·반환 주소 보호
Zilsd / Zclsd1.0RV32용 ld·sd / c.ld·c.sd·c.ldsp·c.sdsp짝수·홀수 레지스터 짝으로 64비트 접근

Zicsr: CSR을 원자적으로 읽고 고쳐 쓰기

각 하트에는 제어·상태 레지스터(CSR, Control and Status Register)를 위한 별도 주소 공간이 4096칸 있다. 일반 레지스터 x0–x31과 달리 CSR은 하드웨어 동작을 조절하거나(예: 인터럽트 허용) 상태를 알려 준다(예: 부동소수점 플래그, 카운터). 특권 아키텍처(17장)가 대부분의 CSR을 정의하지만, CSR에 접근하는 명령은 비특권 규격의 Zicsr 확장이 정한다.

Zicsr의 명령은 여섯 개뿐이고 모두 같은 일을 한다. CSR의 옛 값을 읽어 rd에 넣고, 동시에 새 값을 CSR에 쓴다. 새 값을 만드는 방법이 세 가지(그대로 쓰기·비트 켜기·비트 끄기)이고, 피연산자가 레지스터인지 5비트 즉시값인지에 따라 두 벌이 있다.

3120 1915 1412 117 60 csr[11:0] (CSR 주소) rs1 / uimm funct3 rd 1110011 SYSTEM funct3: 001 csrrw · 010 csrrs · 011 csrrc · 101 csrrwi · 110 csrrsi · 111 csrrci 한 명령 안에서 끼어들 틈 없이 CSR 옛 값을 읽는다 rs1 또는 uimm[4:0] uimm은 0으로 확장 새 값 만들기 csrrw: 새 값 = rs1 csrrs: 옛 값 | rs1 csrrc: 옛 값 & ~rs1 rd ← 옛 값 XLEN으로 0 확장 새 값을 같은 CSR에 다시 쓴다
그림 16-2. CSR 명령의 인코딩(위)과 동작(아래). CSR 주소는 명령의 위쪽 12비트(31–20)에 그대로 들어가므로 4096개의 CSR을 가리킬 수 있다. 즉시값 형식은 rs1 칸 5비트를 레지스터 번호 대신 0으로 확장한 값으로 쓴다. 옛 값 읽기와 새 값 쓰기가 한 명령 안에서 원자적으로 일어난다.
명령동작자주 쓰는 의사 명령
csrrw rd, csr, rs1rd ← CSR, CSR ← rs1 (맞바꾸기)csrw csr, rs1 = csrrw x0, csr, rs1
csrrs rd, csr, rs1rd ← CSR, CSR ← CSR | rs1csrr rd, csr = csrrs rd, csr, x0 · csrs csr, rs1
csrrc rd, csr, rs1rd ← CSR, CSR ← CSR & ~rs1csrc csr, rs1
csrrwi rd, csr, uimmrd ← CSR, CSR ← uimmcsrwi csr, uimm
csrrsi rd, csr, uimmrd ← CSR, CSR ← CSR | uimmcsrsi csr, uimm
csrrci rd, csr, uimmrd ← CSR, CSR ← CSR & ~uimmcsrci csr, uimm

읽지 않거나 쓰지 않는 경우

CSR 중에는 읽거나 쓰는 것만으로 부수 효과가 생기는 것이 있다. 그래서 규격은 언제 CSR을 실제로 읽고 쓰는지를 정확히 정해 두었다. 핵심은 x0과 0이다. csrrw의 rd가 x0이면 CSR을 아예 읽지 않고, csrrs/csrrc의 rs1이 x0이면(즉시값 형식은 uimm=0이면) CSR에 아예 쓰지 않는다. 덕분에 csrr은 읽기 전용 CSR에 써도 불법 명령 예외가 나지 않는다.

명령조건CSR 읽기CSR 쓰기
csrrw / csrrwird = x0안 함함
csrrw / csrrwird ≠ x0함함
csrrs / csrrcrs1 = x0함안 함
csrrsi / csrrciuimm = 0함안 함
csrrs / csrrc / csrrsi / csrrci그 밖함함
“레지스터에 0이 있다”와 “x0이다”는 다르다

csrrs의 rs1이 x0이 아닌 레지스터인데 값이 우연히 0이라면, 비트는 하나도 안 바뀌지만 규격상 쓰기는 일어난 것이다. 읽기 전용 CSR이었다면 불법 명령 예외가 날 수 있다. 쓰기를 피하려면 인코딩 자체가 x0(또는 uimm=0)이어야 한다.

규격 원문에서
“All CSR instructions atomically read-modify-write a single CSR, whose CSR specifier is encoded in the 12-bit csr field of the instruction held in bits 31-20.”

Zicsr, CSR Instructions — “원자적”이라는 말은 읽기와 쓰기 사이에 다른 명령이 끼어들 수 없다는 뜻이다. 인터럽트 허용 비트를 끄면서 옛 값을 저장하는 일이 한 명령으로 끝난다.

예를 들어 운영체제가 잠깐 인터럽트를 막았다가 원래대로 되돌릴 때 다음처럼 쓴다. csrrci 한 명령이 “옛 mstatus 저장”과 “MIE 비트(3번) 끄기”를 동시에 한다.

csrrci t0, mstatus, 8 # t0 ← 옛 mstatus, MIE(비트 3) 끄기 # ... 끼어들면 안 되는 일 ... csrw mstatus, t0 # 옛 값 그대로 되돌리기

Zicntr · Zihpm: 카운터와 타이머

성능을 재려면 “몇 사이클 걸렸나”, “명령 몇 개를 실행했나”를 알아야 한다. RISC-V는 사용자 모드에서도 읽을 수 있는 읽기 전용 64비트 카운터를 최대 32개 둔다. 주소는 0xC00–0xC1F이고, RV32에서는 위쪽 32비트를 0xC80–0xC9F로 따로 읽는다. 이 중 처음 세 개가 Zicntr, 나머지 29개가 Zihpm이다.

0xC00 0xC80 C00 C01 C02 Zicntr cycle · time · instret Zihpm hpmcounter3 – hpmcounter31 (0xC03–0xC1F) RV32 전용 상위 절반: cycleh · timeh · instreth · hpmcounter3h – hpmcounter31h 비트 63–32 (cycleh, 0xC80) 비트 31–0 (cycle, 0xC00) 64비트 RV64: rdcycle 한 번이면 64비트 전체 · RV32: 두 CSR을 따로 읽어야 한다 상위 두 비트 11 = 읽기 전용, 그다음 두 비트 00 = 사용자 수준 CSR (17장)
그림 16-3. 카운터 CSR 주소 배치. 0xC00–0xC1F의 32칸 중 앞의 세 칸이 Zicntr, 나머지 29칸이 Zihpm이다. RV32에서는 같은 카운터의 위쪽 32비트를 0x80만큼 떨어진 0xC80–0xC9F에서 읽는다. 아래 막대는 RV32에서 64비트 cycle 값이 두 CSR로 나뉘는 모습이다.
의사 명령CSR (주소)세는 것RV32 상위 절반
rdcycle rdcycle (0xC00)하트가 돌고 있는 코어의 클럭 사이클 수rdcycleh · cycleh (0xC80)
rdtime rdtime (0xC01)실시간 시계 틱 (벽시계 시간)rdtimeh · timeh (0xC81)
rdinstret rdinstret (0xC02)이 하트가 완료(retire)한 명령 수rdinstreth · instreth (0xC82)
csrr rd, hpmcounter3 …hpmcounter3–31 (0xC03–0xC1F)플랫폼이 정하는 사건 (캐시 미스 등)hpmcounter3h–31h (0xC83–0xC9F)

세 의사 명령은 모두 csrrs rd, counter, x0으로 바뀐다. 몇 가지 눈여겨볼 규칙이 있다. ecall·ebreak처럼 동기 예외를 일으킨 명령은 완료된 것으로 치지 않아 instret이 늘지 않는다. 모든 하트의 실시간 시계는 서로 한 틱 안으로 맞아야 한다. 그리고 Zihpm 카운터가 어떤 사건을 셀지는 플랫폼이 정하며, 특권 소프트웨어가 mcounteren/scounteren으로 사용자 모드의 접근을 막을 수도 있다(17장). 타이밍 부채널 공격을 막기 위해 카운터 접근을 막는 실행 환경도 있다고 규격이 적고 있다.

RV32에서 64비트 카운터 읽기

RV32에서 cycle을 읽으려면 두 번 나눠 읽어야 한다. 문제는 그 사이에도 카운터가 계속 증가한다는 점이다. 아래쪽 32비트가 0xFFFFFFFF에서 0으로 넘어가는 순간 위쪽이 1 늘어나는데, 하필 두 읽기 사이에 그 일이 일어나면 위·아래가 서로 다른 시점의 값이 되어 약 43억(232)만큼 틀린 값을 얻는다. 규격은 이를 피하는 짧은 루프를 제시한다.

again: rdcycleh x3 # 위쪽 절반 rdcycle x2 # 아래쪽 절반 rdcycleh x4 # 위쪽 절반을 한 번 더 bne x3, x4, again # 그 사이 자리올림이 있었으면 다시

위쪽을 앞뒤로 두 번 읽어 같으면, 그 사이에 자리올림이 없었다는 뜻이므로 x3:x2가 한 시점의 올바른 값이다. 다르면 처음부터 다시 읽는다. 아래 애니메이션으로 “자리올림까지 남은 사이클”을 바꿔 가며 직접 확인해 보자.

ANIMATION

RV32에서 64비트 cycle 읽기

한 명령을 눌러 시작하자. 카운터는 명령 하나마다 1씩 늘어난다고 가정한다.
읽는 방법
실행
하위를 읽은 순간의 참값—
읽어 낸 값 (x3:x2)—
오차—
실행한 명령 수0
남은 사이클을 1로 두고 “상위→하위 한 번씩”을 실행해 보자. 상위는 자리올림 전, 하위는 자리올림 후의 값을 읽어 232만큼 작은 값이 나온다. 같은 조건에서 “규격의 루프”는 한 바퀴를 더 돌고 올바른 값을 얻는다. 남은 사이클이 3 이상이면 루프도 한 바퀴로 끝난다.
규격 원문에서
“We required the counters be 64 bits wide, even when XLEN=32, as otherwise it is very difficult for software to determine if values have overflowed.”

Zicntr and Zihpm — 32비트 카운터는 수 GHz 클럭에서 몇 초 만에 한 바퀴를 돈다. 64비트로 정해 두면 현실적으로 넘칠 일이 없고, 대신 RV32에는 위의 짧은 루프라는 비용만 남는다.

fence.i와 힌트: Zifencei · Zihintpause · Zihintntl

Zifencei: 내가 쓴 코드를 내가 실행하려면

JIT 컴파일러는 실행 중에 기계어를 만들어 메모리에 store로 쓴 뒤 그 주소로 점프한다. 그런데 많은 코어는 명령어 캐시(I-캐시)와 데이터 캐시(D-캐시)가 따로 있고, 둘이 서로를 자동으로 맞춰 주지 않는다. store는 D-캐시에 들어갔는데 인출 장치는 I-캐시에 남은 옛 명령을 가져올 수 있다. RISC-V는 store가 명령 인출에 보이는 것을 fence.i를 실행하기 전까지 보장하지 않는다.

① store로 새 기계어 쓰기 sw / sd 로 코드 버퍼에 ② fence.i store → 인출 순서 보장 ③ jalr 로 새 코드 실행 이제 새 명령이 인출된다 하트 하나 명령 인출 장치 load/store 장치 I-캐시 옛 명령이 남아 있음 D-캐시 새 명령이 들어옴 L2 메모리 fence.i 전: 옛 명령일 수 있다 금색 점: store가 D-캐시 → L2 → I-캐시로 전달되는 길
그림 16-4. 자기 수정 코드와 JIT의 문제. store한 새 명령은 D-캐시에 있지만 인출 장치는 I-캐시의 옛 명령을 볼 수 있다. fence.i는 그 앞의 store가 그 뒤의 명령 인출에 보이도록 보장한다. 단순한 구현은 I-캐시와 파이프라인을 통째로 비우는 식으로 이를 지킨다.

fence.i의 인코딩은 MISC-MEM opcode(0001111)에 funct3=001이고, 나머지 칸(imm, rs1, rd)은 앞으로 더 정밀한 펜스를 위해 남겨 두었다. 표준 소프트웨어는 이 칸을 0으로 두고, 구현은 무시해야 한다.

fence.i는 “내 하트”만 맞춘다

fence.i는 실행한 하트 자신의 인출만 동기화한다. 다른 하트도 새 코드를 실행하려면, 쓴 쪽이 데이터 fence를 한 뒤 다른 하트들에게 각자 fence.i를 하라고 요청해야 한다. 게다가 운영체제는 사용자 스레드를 언제든 다른 하트로 옮길 수 있다. 그래서 리눅스는 사용자 모드의 fence.i에 기대지 않고, 명령 캐시를 맞추는 시스템 호출(riscv_flush_icache)을 두었다. 이것이 fence.i가 베이스 ISA에서 빠져 Zifencei로 분리된 이유 중 하나다.

Zihintpause: 스핀 대기 중 잠깐 쉬기

잠금(lock)이 풀리기를 기다리며 같은 메모리를 계속 읽는 스핀 대기 루프는 아무 일도 안 하면서 에너지와 실행 자원을 쓴다. pause는 “명령 완료 속도를 잠깐 늦춰도 된다”는 힌트다. SMT 코어라면 그동안 다른 하트에게 자원을 양보할 수 있다. 효과가 지속되는 시간은 유한해야 하고, 0이어도 된다.

재미있는 점은 인코딩이다. pause는 새 opcode가 아니라 FENCE 명령의 쓸모없는 조합이다. pred=W(앞의 쓰기), succ=0(뒤의 아무것도 아님)인 FENCE는 순서를 강제할 대상이 없으므로 아무 의미가 없다. 그 자리를 힌트로 쓴다.

pause = 0x0100000F FENCE 형식에 pred=W, succ=0 3128 2724 2320 1915 1412 117 60 fm0000 pred (IORW)0001 = W succ (IORW)0000 rs100000 funct3000 rd00000 opcode MISC-MEM0001111 ntl.* = 결과를 버리는 ADD (rd = x0, rs1 = x0) ntl.p1 add x0, x0, x2 0x00200033 ntl.pall add x0, x0, x3 0x00300033 ntl.s1 add x0, x0, x4 0x00400033 ntl.all add x0, x0, x5 0x00500033 x0에 쓰는 ADD는 원래 아무 효과가 없다 → 모르는 코어는 그냥 실행하고, 아는 코어는 힌트로 읽는다 C 확장이 있으면 c.add x0, x2 … x5 가 c.ntl.* 이다
그림 16-5. 힌트의 인코딩. 위: pause는 pred=W(0001), succ=0인 FENCE다. 아래: 네 가지 NTL 힌트는 rd=x0, rs1=x0인 ADD에서 rs2만 x2–x5로 바꾼 것이다. 둘 다 원래는 “아무 일도 안 하는” 인코딩이라 힌트를 모르는 코어에서도 안전하다.
spin: lw t0, 0(a0) # 잠금 변수 읽기 beqz t0, try_lock # 0이면(풀렸으면) 잡으러 간다 pause # 잠깐 쉬어 가기 (FENCE 힌트) j spin

스핀 루프마다 pause는 하나만 넣으라고 규격은 권한다. 여러 개를 넣으면 어떤 구현에서는 필요 이상으로 오래 멈출 수 있다. 메모리 값이 바뀔 때까지 아예 저전력으로 잠들고 싶다면 Zawrs 확장의 lr.w + wrs.nto를 쓴다(10장).

Zihintntl: “이 데이터는 다시 안 쓴다”

큰 배열을 한 번 훑고 지나가는 스트리밍 코드는 캐시를 쓸모없는 데이터로 채워, 정작 자주 쓰는 데이터를 밀어낸다. NTL(Non-Temporal Locality) 힌트는 바로 다음 명령의 메모리 접근이 시간 지역성이 낮다고 알려 준다. 구현은 이 힌트로 캐시에 아예 넣지 않거나, 교체 우선순위를 낮출 수 있다. 네 가지 변형은 “어느 캐시 수준까지 지역성을 기대할 수 없는가”를 나타낸다.

예: 개인 L1·L2, 공유 L3인 시스템 (규격 표 2의 한 행) L1 (코어 개인) L2 (코어 개인) L3 (코어들이 공유) 메모리 ntl.p1 가장 안쪽 개인 캐시(L1)에선 재사용 없음 ntl.pall 개인 캐시 전체(L1–L2)에서 재사용 없음 ntl.s1 가장 안쪽 공유 캐시(L3)까지 재사용 없음 ntl.all 모든 캐시에서 재사용 없음 → 이 시스템에선 L3 힌트일 뿐이므로 무시해도 되고, 캐시가 없어도 된다
그림 16-6. NTL 변형과 캐시 수준의 대응. 규격의 예시 표에서 “개인 L1·L2, 공유 L3” 계층은 P1→L1, PALL→L2, S1→L3, ALL→L3로 대응한다. 캐시 구성이 다르면 대응도 달라지므로, 이식성 있는 코드는 쓰임새(스트리밍이면 ntl.all, 경쟁이 심한 동기화 변수면 ntl.pall)에 따라 고르라고 규격은 권한다.
힌트는 왜 이렇게 만드나

HINT는 아키텍처 상태를 바꾸지 않는 인코딩(대개 rd=x0)에 성능 정보를 실은 것이다. 힌트를 모르는 옛 코어는 그냥 아무 일도 안 하는 명령으로 실행하므로, 같은 바이너리가 어디서나 돈다. NTL 힌트는 ADD로 인코딩되어 있어서 LR/SC 루프 안에 넣어도 전진 보장을 깨지 않는다는 장점도 있다. 반면 pause는 FENCE이므로 LR/SC 루프 안에 넣으면 전진 보장이 사라진다.

Zimop: “어쩌면 연산”이라는 예약 자리

새 보안 확장을 만들었다고 하자. 컴파일러가 그 명령을 함수마다 넣으면, 그 확장이 없는 옛 칩에서는 불법 명령 예외로 프로그램이 죽는다. 그렇다고 칩마다 바이너리를 따로 만들 수는 없다. Zimop은 이 문제를 미리 풀어 둔 확장이다. MOP(May-Be-Operation)은 “지금은 rd에 0을 쓰는 것 말고 아무 일도 안 하지만, 나중 확장이 뜻을 붙일 수 있는” 명령이다.

모두 합쳐 32비트 MOP 40개다. MOP는 SYSTEM opcode에 들어 있는데, 나중에 특권 CSR 상태(예: “이 모드에서 기능이 켜졌는가”)에 따라 동작이 달라질 것으로 예상했기 때문이다.

mop.r.n 인코딩 3130 29–2827–26 25–2221–20 19–1514–12 11–76–0 1 n4 00 n3 n2 0111 n1 n0 rs1 100 rd 1110011 SYSTEM mop.rr.n은 비트 25가 1이고 rs2 칸(24–20)이 있으며 n은 30·27·26번 비트(3비트)에 들어간다 sspush ra (= mop.rr.7) 하나의 바이너리 Zimop만 있는 옛 하트 rd(=x0) ← 0 : 사실상 아무 일 없음 프로그램은 그대로 돈다 (보호만 없음) Zicfiss가 켜진 하트 ra를 그림자 스택에 push 반환 주소 보호가 동작한다
그림 16-7. MOP의 인코딩과 쓰임. 위: mop.r.n의 번호 n(5비트)은 30·27·26·21·20번 비트에 흩어져 있다. 아래: Zicfiss의 sspush는 mop.rr.7 자리를 재정의한 것이어서, 확장이 없거나 꺼진 하트에서는 rd=x0에 0을 쓰는 무해한 명령이 된다.
규격 원문에서
“It is sometimes desirable to define instruction-set extensions whose instructions, rather than raising illegal-instruction exceptions when the extension is not implemented, take no useful action (beyond writing x[rd]).”

Zimop — 규격은 바로 이어서 제어 흐름 무결성 검사를 예로 든다. 검사를 무시해도 프로그램은 올바르게 돈다면, MOP로 만들어 두는 것이 옳다.

MOP 자리재정의한 명령 (Zicfiss)확장이 없거나 꺼졌을 때
mop.rr.7sspush x1, sspush x5x0 ← 0 (아무 일 없음)
mop.r.28sspopchk x1, sspopchk x5, ssrdp rdrd ← 0
c.mop.1c.sspush x1아무 레지스터도 안 씀
c.mop.5c.sspopchk x5아무 레지스터도 안 씀

MOP가 “아무 일도 안 함”이 아니라 rd에 0을 쓰는 것으로 정의된 데에도 이유가 있다. 해독이 단순해지고, 결과가 0인지로 기능이 켜졌는지 알아낼 수 있기 때문이다. 예를 들어 ssrdp t0은 그림자 스택이 켜져 있으면 ssp 값을, 아니면 0을 준다. 그래서 운영체제는 그림자 스택을 0번지에 두지 않기로 약속한다.

Zicond: 분기 없는 조건부 연산

x = c ? a : b 같은 코드는 보통 분기로 번역된다. 분기 예측기가 맞히면 빠르지만, c가 무작위에 가까운 데이터라면 예측은 자주 틀리고, 틀릴 때마다 파이프라인에 들어온 명령을 버리는 벌점을 낸다(8장). 다른 ISA에는 조건부 이동(cmov) 명령이 있지만, 그 명령은 레지스터를 세 개 읽어야 해서 RISC-V의 “읽기 포트 두 개” 원칙과 맞지 않는다. Zicond는 두 개만 읽는 더 작은 부품을 준다.

명령동작인코딩 (R형, opcode OP=0110011)
czero.eqz rd, rs1, rs2rs2 == 0 이면 rd ← 0, 아니면 rd ← rs1funct7=0000111, funct3=101
czero.nez rd, rs1, rs2rs2 ≠ 0 이면 rd ← 0, 아니면 rd ← rs1funct7=0000111, funct3=111

“조건에 따라 0으로 만들기” 하나만 있으면 나머지는 조합으로 만든다. 한쪽을 0으로 지운 두 값을 더하면(또는 OR하면) 선택이 되고, 0으로 지운 값을 더하면 조건부 덧셈이 된다. 두 명령 모두 Zkt가 있으면 데이터 값과 무관한 시간에 실행되어야 하므로, 비밀 데이터로 분기하면 안 되는 암호 코드에도 쓸 수 있다.

a3 = (a2 ≠ 0) ? a0 : a1 — 예: a2 = 7, a0 = 10, a1 = 20 a0 = 10 a2 = 7 (조건) a1 = 20 czero.eqz a3, a0, a2 a2 ≠ 0 → a3 = 10 czero.nez t0, a1, a2 a2 ≠ 0 → t0 = 0 add a3, a3, t0 10 + 0 a3 = 10 세 명령 모두 분기가 아니므로 예측 실패가 없다. 둘 중 하나는 반드시 0이라 add 대신 or를 써도 같다.
그림 16-8. 규격의 “조건부 선택” 관용구. 조건이 0이 아니면 a0 쪽은 살리고 a1 쪽은 0으로 지운 뒤 더한다. 조건이 0이면 반대로 된다. 어느 경우든 같은 세 명령이 같은 순서로 실행된다.
연산 (규격 예시)명령 순서
rd = (rc ≠ 0) ? rs1 + rs2 : rs1czero.eqz rd, rs2, rc → add rd, rs1, rd
rd = (rc == 0) ? rs1 + rs2 : rs1czero.nez rd, rs2, rc → add rd, rs1, rd
rd = (rc ≠ 0) ? rs1 | rs2 : rs1czero.eqz rd, rs2, rc → or rd, rs1, rd
rd = (rc ≠ 0) ? rs1 : rs2czero.eqz rd, rs1, rc → czero.nez rtmp, rs2, rc → add rd, rd, rtmp
rd = (rc == 0) ? rs1 : rs2czero.nez rd, rs1, rc → czero.eqz rtmp, rs2, rc → add rd, rd, rtmp
SIMULATOR

분기 없는 선택 vs 분기 코드

값을 고르고 한 단계를 눌러 두 코드를 나란히 실행해 보자.
선택식
분기 예측
실행
Zicond 코드 사이클—
분기 코드 사이클—
분기 코드 평균 (예측 50%)—
결과 a3—
아래 그림은 5단 파이프라인(IF·ID·EX·MEM·WB)에서 각 명령이 어느 사이클에 어느 단계에 있는지 보여 준다. 예측을 “실패”로 바꾸고 벌점을 키워 보자. 5단 파이프라인에서 EX 단계에 분기를 판정하면 벌점은 2사이클이지만, 단계가 깊은 고성능 코어에서는 훨씬 커진다. 조건 값이 예측하기 어려울수록 Zicond 쪽이 유리하다.

CMO: 캐시 블록 다루기

캐시는 보통 소프트웨어 눈에 보이지 않는다. 하지만 캐시 일관성 밖에 있는 장치와 데이터를 주고받을 때는 이야기가 다르다. 예를 들어 DMA 장치가 하트의 캐시를 보지 못하고 메모리만 읽는다면, 하트가 쓴 데이터가 아직 캐시에만 있을 때 장치는 옛 값을 읽는다. 반대로 장치가 메모리에 새 데이터를 쓰면, 하트의 캐시에 남은 옛 사본이 그 값을 가린다. CMO(Cache-Management Operation) 확장은 이런 상황을 위해 캐시 블록 단위로 동작하는 명령을 정의한다.

확장명령하는 일인코딩
Zicbom
(관리)
cbo.clean (rs1)수정된 사본이 있으면 메모리로 써 보낸다. 사본은 남는다.MISC-MEM, funct3=010, rd=0, imm=1
cbo.flush (rs1)clean을 한 뒤 사본을 없앤다 (원자적으로).… imm=2
cbo.inval (rs1)사본을 없앤다. 다음 load는 메모리에서 읽는다.… imm=0
Zicbozcbo.zero (rs1)블록 전체 바이트에 0을 store한다.… imm=4
Zicbop
(프리페치 힌트)
prefetch.i off(rs1)곧 명령 인출할 블록이라는 힌트ORI, rd=0, rs2 칸=00000
prefetch.r off(rs1)곧 읽을 블록이라는 힌트ORI, rd=0, rs2 칸=00001
prefetch.w off(rs1)곧 쓸 블록이라는 힌트ORI, rd=0, rs2 칸=00011

캐시 블록은 메모리를 2의 거듭제곱 크기로 자연 정렬한(NAPOT) 조각이다. 블록 크기는 구현마다 다르고, 소프트웨어는 실행 환경(예: 장치 트리)을 통해 알아내야 한다. 첫 CMO 확장들에서는 시스템 전체의 블록 크기가 같아야 한다. cbo.*는 rs1이 가리키는 주소가 블록 안 어디든 그 블록 전체에 작용하며, 정렬되지 않았다고 예외를 내지 않는다. 프리페치는 ORI의 rd=x0 힌트 자리를 빌렸으므로 오프셋의 하위 5비트(imm[4:0])는 0이어야 한다.

cbo.clean 캐시: V=1 D=1 → D=0 값 B (그대로 남음) write back 메모리 A → B 장치가 하트의 최신 값을 본다 cbo.inval 캐시: 사본 버림 (V=0) 옛 값 A ✕ 다음 load 메모리 (장치가 씀) 값 D 하트가 장치의 새 값을 본다 cbo.flush clean 다음 inval 값 B → 메모리, 사본 버림 메모리 A → B 양쪽 모두 같은 값에서 다시 시작
그림 16-9. 캐시 블록 관리 세 가지. clean은 하트 → 장치 방향(하트가 쓴 것을 장치가 보게), inval은 장치 → 하트 방향(장치가 쓴 것을 하트가 보게), flush는 둘 다다. 구현은 clean이나 inval 대신 flush를 해도 된다. 결과가 소프트웨어 입장에서 구별되지 않기 때문이다.
SIMULATOR

캐시 블록 연산 실험실

블록을 고르고 버튼을 눌러 보자. 블록 1은 하트가 고쳐 쓴 상태(D=1)라 메모리 값이 낡아 있다.
대상 블록
하트
CMO
DMA 장치 (비일관)
하트가 보는 값—
장치가 보는 값 (메모리)—
두 시각이 일치?—
블록 0x1040에서 “메모리 읽기”를 먼저 눌러 장치가 낡은 값을 읽는 것을 보고, cbo.clean 뒤에 다시 읽어 보자. 그다음 dirty 상태에서 cbo.inval을 누르면 하트가 쓴 값이 메모리에 가지 못한 채 사라진다. 그래서 규격은 낮은 특권 모드에 inval을 허락하기 전에 높은 특권 소프트웨어가 clean이나 flush를 해 두거나, xenvcfg.CBIE로 inval을 flush로 바꾸라고 권한다.
특권 모드별 허용

사용자·감독자 모드에서 이 명령들을 쓸 수 있는지는 menvcfg·senvcfg·henvcfg의 CBIE(inval), CBCFE(clean·flush), CBZE(zero) 필드가 정한다. 허락되지 않으면 불법 명령 예외(가상화 중이면 가상 명령 예외)가 난다. CBIE가 01이면 inval이 flush로 바뀌어 실행된다. 프리페치는 힌트이므로 어떤 경우에도 예외를 내지 않는다.

CFI: 착지 패드와 그림자 스택

메모리 오류(버퍼 넘침, use-after-free)를 이용하는 공격자는 새 코드를 넣는 대신, 이미 있는 코드 조각(가젯)을 이어 붙여 원하는 일을 시킨다. 반환 주소를 바꿔 가젯을 이어 가는 ROP와, 함수 포인터를 바꿔 엉뚱한 곳으로 점프하는 JOP/COP가 대표적이다. 직접 분기(beq, jal)는 목적지가 명령 안에 박혀 있어 바꿀 수 없으므로, 노리는 것은 레지스터 값으로 점프하는 jalr뿐이다. RISC-V의 CFI(Control-Flow Integrity)는 이 jalr을 두 방향에서 지킨다.

Zicfilp — 앞 방향(forward-edge)Zicfiss — 뒤 방향(backward-edge)
지키는 것간접 호출·간접 점프의 목적지함수 반환(ret)의 목적지
방법목적지 첫 명령이 lpad여야 한다 (라벨 비교 선택)반환 주소를 그림자 스택에 한 벌 더 두고 비교
새 상태ELP(예상 착지 패드) 1비트ssp CSR (0x011), 그림자 스택 페이지
인코딩lpad = rd=x0인 AUIPC (HINT 자리)sspush·sspopchk·ssrdp = MOP 재정의, ssamoswap = AMO
위반 시software-check 예외(cause 18), xtval = 2software-check 예외(cause 18), xtval = 3

Zicfilp: 착지 패드

Zicfilp가 켜지면 하트는 ELP(Expected Landing Pad) 상태를 추적한다. jalr·c.jr·c.jalr로 간접 점프하는데 rs1이 x1·x5(링크 레지스터)도 x7도 아니면 ELP가 LP_EXPECTED가 된다. 그러면 도착한 곳의 첫 명령은 반드시 lpad여야 하고 4바이트 정렬되어 있어야 한다. lpad에는 20비트 라벨(LPL)이 있어서, 0이 아니면 호출하는 쪽이 x7의 비트 31–12에 넣어 둔 기대 라벨과 같아야 한다. 하나라도 어기면 software-check 예외가 나고 xtval에는 “landing pad fault”를 뜻하는 2가 기록된다.

호출하는 쪽 lui t2, 0x12345 lw a5, 0(a0) jalr ra, 0(a5) t2 = x7 · 비트 31–12가 기대 라벨 ELP LP_EXPECTED handler: (주소를 가진 함수) lpad 0x12345 라벨 일치 → ELP = NO_LP_EXPECTED handler+4: 함수 중간 (가젯) addi a0, a0, 1 lpad가 아님 → software-check 예외 cause = 18, xtval = 2 (landing pad fault) 포인터 조작 라벨(LPL)이 0인 lpad는 라벨 검사를 하지 않는다 — 한 가지 라벨만 쓰는 거친 단계의 보호
그림 16-10. 착지 패드 검사. 간접 호출이 ELP를 LP_EXPECTED로 만들고, 도착한 명령이 라벨이 맞는 lpad일 때만 실행이 이어진다. 함수 포인터를 조작해 함수 중간으로 뛰어들게 하면 첫 명령이 lpad가 아니므로 즉시 예외가 난다. lpad는 AUIPC의 HINT 자리라 Zicfilp가 없거나 꺼진 하트에서는 아무 일도 하지 않는다.

x7로 점프하는 경우가 예외인 이유도 있다. 컴파일러가 범위를 검사한 읽기 전용 점프 테이블(switch 문)처럼 목적지를 완전히 통제하는 경우를 위해 “소프트웨어가 지키는 분기”로 남겨 둔 것이다. 또 rs1이 링크 레지스터인 jalr은 반환이거나, auipc ra와 짝을 이룬 사실상 직접 호출이라 착지 패드가 필요 없다.

Zicfiss: 그림자 스택

함수를 호출하면 반환 주소(ra)가 일반 스택에 저장된다. 일반 스택은 보통 메모리라서 버퍼 넘침으로 덮어쓸 수 있다. Zicfiss는 반환 주소를 보호된 두 번째 스택에도 저장해 두고, 반환하기 직전에 둘을 비교한다. 그림자 스택은 페이지 테이블에서 R=0, W=1, X=0인 특별한 페이지(원래 예약된 조합)로 표시되며, sspush·ssamoswap 말고 일반 store로는 쓸 수 없다. 일반 store가 그 페이지에 쓰려 하면 store/AMO access-fault가 난다.

일반 스택 (읽기·쓰기 페이지) 호출한 함수의 프레임 저장된 ra = 0x1004 넘친 데이터로 0x4000 이 될 수 있다 char buffer[8] 넘침 sp ↓ 낮은 주소로 자란다 그림자 스택 (SS 페이지, xwr=010) 이전 사본 ra 사본 = 0x1004 ← ssp 일반 store 금지 → access fault sspopchk ra 같으면 ssp += XLEN/8, 그다음 ret 정상 실행 다르면 software-check 예외 (cause 18) xtval = 3 (shadow stack fault)
그림 16-11. 두 개의 스택. 함수 머리에서 ra를 일반 스택에 저장하고(sw ra) 그림자 스택에도 저장한다(sspush ra). 꼬리에서는 일반 스택에서 ra를 되읽은 뒤 sspopchk ra로 그림자 사본과 비교한다. 버퍼 넘침이 일반 스택의 ra를 바꿔도 그림자 사본은 바꿀 수 없다.
function_entry: addi sp, sp, -8 # 일반 스택에 sd x1, (sp) # 링크 레지스터 x1 저장 sspush x1 # 그림자 스택에도 x1 저장 # ... 함수 본문 (다른 함수를 부른다) ... ld x1, (sp) # 일반 스택에서 x1 복원 addi sp, sp, 8 sspopchk x1 # 그림자 사본과 다르면 예외 ret
규격 원문에서
“A mismatch of the two values is indicative of a subversion of the return address control variable and causes a software-check exception.”

Control-flow Integrity (CFI), Shadow Stack — 예외 번호 18(software check)과 xtval 값 2·3은 특권 규격의 mcause 표에 정의되어 있다.

SIMULATOR

공격과 방어: 그림자 스택과 착지 패드

한 단계를 눌러 프로그램을 따라가 보자.
장면
CFI
실행
ra—
ssp—
ELP—
예외없음
“버퍼 넘침” 장면을 CFI를 켠 채로 끝까지 실행하고, 다시 CFI를 끈 채로 실행해 보자. 끈 경우 sspush/sspopchk는 MOP로서 아무 일도 하지 않고, ret은 공격자가 심은 0x4000으로 간다. 착지 패드 장면에서도 마찬가지로 CFI를 끄면 lpad는 그냥 지나가는 명령이 된다.
켜고 끄는 것은 특권 소프트웨어

Zicfilp·Zicfiss는 모드마다 따로 켤 수 있다. 켜는 스위치(menvcfg·senvcfg의 LPE·SSE 비트, M 모드는 mseccfg.MLPE)와 트랩 때 ELP를 보존하는 방법은 특권 규격이 정한다. Zicfiss는 Zicsr·Zimop·Zaamo에 의존하고, 사용자 모드에서 쓰려면 S 모드가 있어야 하며, M 모드에서는 지원되지 않는다. 동적 로더는 실행 파일과 모든 공유 라이브러리가 CFI로 컴파일되었을 때만 기능을 켜야 한다.

Zilsd: RV32의 레지스터 짝 load/store

RV32에서 64비트 값(예: long long, double 값을 정수 레지스터로 옮기는 경우)을 옮기려면 lw 두 번, sw 두 번이 필요하다. Zilsd는 RV64에만 있던 ld·sd 인코딩을 RV32에서 재사용해, 짝수 번호 레지스터와 그다음 홀수 레지스터를 한 쌍으로 묶어 64비트를 한 명령에 옮긴다. 엔디안과 상관없이 낮은 번호 레지스터가 하위 32비트를 갖는다.

ld x14, 0(a0) (RV32 + Zilsd) 메모리 (a0부터 8바이트, 리틀 엔디안) b0 b1 b2 b3 b4 b5 b6 b7 x14 = b3 b2 b1 b0 x15 = b7 b6 b5 b4 하위 32비트 → 짝수 레지스터(rd), 상위 32비트 → rd+1 홀수 번호를 rd로 쓰는 것은 예약 · rd=x0이면 결과를 버리고 x1도 쓰지 않는다
그림 16-12. Zilsd의 ld. RV32에서 ld x14, 0(a0)은 8바이트를 읽어 x14(하위)와 x15(상위)에 나눠 넣는다. sd는 반대로 레지스터 짝을 8바이트로 쓴다.
유효 주소 정렬각 워드 접근이 원자적인가정렬 예외가 날 수 있나
8의 배수예아니요
4의 배수지만 8의 배수는 아님예예
그 밖아니요예

8바이트 정렬이어도 64비트 전체가 원자적으로 접근된다는 보장은 없다. 4바이트 정렬이면 두 개의 4바이트 접근으로 나뉠 수 있고, 각 워드만 원자적이다. load 도중 트랩이 나면 베이스 레지스터는 원래 값을 유지해야 다시 실행할 수 있다(짝의 다른 레지스터는 이미 바뀌었을 수 있다). 압축판 Zclsd는 c.ld·c.sd·c.ldsp·c.sdsp를 주는데, RV32의 Zcf(c.flw 등)와 같은 인코딩을 쓰므로 둘은 함께 쓸 수 없다(13장).

작은 확장이 바꾸는 것

Zilsd는 새 기능이라기보다 코드 크기와 성능을 위한 확장이다. 함수 머리·꼬리에서 레지스터를 저장·복원할 때, 64비트 값을 복사할 때 명령 수가 절반으로 준다. 레지스터 파일이 짝 단위로 쓰기를 지원하는 구현이라면 원자성 조건도 자연스럽게 만족한다.

핵심 정리

  1. Zicsr의 6개 명령은 CSR 하나를 원자적으로 읽고(rd ← 옛 값) 고쳐 쓴다. rd=x0이면 읽지 않고, rs1=x0(uimm=0)이면 쓰지 않는다. csrr은 csrrs rd, csr, x0이다.
  2. Zicntr는 cycle·time·instret(0xC00–0xC02), Zihpm은 hpmcounter3–31(0xC03–0xC1F)이다. 모두 64비트이며 RV32는 0xC80–0xC9F로 위쪽을 읽고, 자리올림 때문에 상위-하위-상위 3회 읽기 루프가 필요하다.
  3. fence.i는 자기 하트의 store를 이후 명령 인출에 보이게 한다. pause는 pred=W·succ=0인 FENCE, ntl.*은 add x0, x0, x2–x5로 인코딩된 힌트다.
  4. Zimop의 MOP는 기본으로 rd에 0만 쓰는 예약 자리다. Zicfiss의 sspush(mop.rr.7)·sspopchk(mop.r.28)처럼 나중 확장이 재정의해도, 옛 하트에서는 무해하게 돈다.
  5. Zicond의 czero.eqz·czero.nez 두 명령과 add/or를 조합하면 분기 없는 조건부 선택·연산을 만든다. 예측하기 어려운 조건에서 분기 예측 실패 벌점을 없앤다.
  6. CMO: clean(써 보내기), inval(사본 버리기), flush(둘 다), zero(블록에 0), prefetch(힌트). 블록 크기는 구현마다 다르며 실행 환경에서 알아낸다.
  7. CFI: Zicfilp는 간접 점프 목적지에 lpad(라벨 비교)를, Zicfiss는 그림자 스택(ssp CSR 0x011, xwr=010 페이지)을 요구한다. 위반하면 software-check 예외(cause 18)가 나고 xtval은 착지 패드 2, 그림자 스택 3이다.
  8. Zilsd는 RV32에서 ld·sd로 짝수·홀수 레지스터 짝에 64비트를 옮긴다. 낮은 번호가 하위 절반이다.

확인 퀴즈

의사 명령 csrr a0, mstatus는 어떤 실제 명령으로 바뀌는가?

csrrw는 rs1=x0이어도 0을 써 버린다. csrrs는 rs1=x0이면 CSR에 아예 쓰지 않으므로 순수한 읽기가 된다.

RV32에서 64비트 cycle을 읽을 때 rdcycleh를 두 번 읽어 비교하는 이유는?

상위를 앞뒤로 읽어 같아야 그 사이 자리올림이 없었다는 것이 보장된다. 다르면 다시 읽는다.

pause 명령의 인코딩으로 옳은 것은?

succ가 비어 있는 FENCE는 순서를 강제할 대상이 없어 원래 의미가 없다. 그 자리를 힌트로 재활용했다. 인코딩은 0x0100000F다.

Zicfiss의 sspush가 Zimop의 MOP 자리를 쓰는 가장 큰 이유는?

MOP는 기본으로 rd에 0만 쓴다. sspush는 rd=x0이라 옛 하트에서는 아무 효과가 없고, 프로그램은 보호만 없을 뿐 정상 동작한다.

czero.nez a3, a0, a2에서 a2 = 5, a0 = 9이면 a3은?

czero.nez는 조건(rs2)이 0이 아니면(nez) 0을 쓴다. a2 = 5 ≠ 0이므로 a3 = 0이다.

DMA 장치가 메모리에 새 데이터를 쓴 뒤, 하트가 그 데이터를 읽기 전에 해야 할 CMO는?

하트 캐시에 남은 옛 사본을 없애야 다음 load가 메모리의 새 값을 가져온다. flush도 사본을 없애므로 같은 목적을 이룬다. clean은 하트 → 장치 방향이다.

sspopchk ra에서 그림자 사본과 ra가 다를 때 일어나는 일은?

xtval 3은 shadow stack fault, 2는 landing pad fault다. 불일치일 때 ssp는 증가하지 않는다. access fault는 일반 store가 그림자 스택 페이지에 쓰려 할 때 난다.

RV32 + Zilsd에서 ld x14, 0(a0)의 결과로 옳은 것은?

rd는 짝수여야 하고, 엔디안과 상관없이 낮은 번호 레지스터가 하위 비트를 갖는다.

더 읽을거리