Chapter 10

A 확장과 원자적 연산

하트가 하나뿐이라면 counter++는 아무 문제가 없다. 그런데 두 하트가 같은 변수를 동시에 올리면 결과가 2가 아니라 1이 되는 일이 생긴다. 읽기와 쓰기 사이에 다른 하트가 끼어들기 때문이다. A 확장은 “읽고-바꾸고-쓰기”를 아무도 끼어들 수 없는 한 덩어리로 만드는 명령을 제공한다. 이 장에서는 LR/SC와 AMO라는 두 가지 방법, 순서를 정하는 aq/rl 비트, 그리고 그 위에 쌓아 올린 스핀락과 최신 확장(Zacas·Zabha·Zawrs·Zalasr)을 차례로 살펴본다.

counter++ 하나가 깨지는 순간

C의 counter++는 RISC-V에서 세 명령이 된다. 메모리에서 읽고(lw), 레지스터에서 1을 더하고(addi), 다시 메모리에 쓴다(sw). 하트 하나만 있으면 이 세 명령은 늘 붙어서 실행되는 것처럼 보인다. 그러나 두 하트가 동시에 같은 counter를 올리면 세 명령 사이사이에 상대방의 명령이 끼어들 수 있다.

하트 0 하트 1 메모리 counter lw t0t0 = 0 addi t0t0 = 1 sw t0메모리 ← 1 lw t0t0 = 0 addi t0t0 = 1 sw t0메모리 ← 1 0 0 0 0 1 1 (기대: 2) 시간 두 하트 모두 0을 읽었기 때문에, 두 번의 ++ 가 메모리에는 한 번만 남는다
그림 10-1. 잃어버린 갱신(lost update). 두 하트가 같은 값 0을 먼저 읽어 두면, 각자 1을 계산해 1을 쓴다. 금색 세로선이 시간을 따라 움직이며 메모리 값 변화를 보여 준다. 하트 1의 마지막 sw가 하트 0의 결과를 덮어써 버린다.

이런 상황을 경쟁 상태(race condition)라 한다. 결과가 실행 순서라는 “운”에 좌우된다. 문제의 뿌리는 읽기와 쓰기가 떨어져 있다는 데 있다. 해결책은 둘 중 하나다.

  1. 낙관적으로 시도하고, 끼어들었으면 다시 한다 — 읽을 때 “찜”해 두고, 쓸 때 그동안 아무도 건드리지 않았는지 확인한다. 이것이 LR/SC다.
  2. 읽기-계산-쓰기를 메모리 쪽에서 한 번에 한다 — “이 주소에 1을 더하고 원래 값을 알려 달라”는 하나의 명령을 보낸다. 이것이 AMO다.
하트 하나에서도 원자성이 필요할 때가 있다

인터럽트 처리기가 같은 변수를 건드리거나, DMA 장치가 메모리를 쓰는 경우에도 같은 문제가 생긴다. 하지만 RISC-V 규격이 원자적 명령을 정의하는 주된 목적은 “같은 메모리 공간에서 도는 여러 하트 사이의 동기화”다.

A 확장의 구성: Zalrsc + Zaamo, 그리고 친척들

오랫동안 A 확장은 한 덩어리였다. 현재 규격(A v2.1)은 이것을 두 개의 하위 확장으로 나눠 정의한다. “The A extension comprises instructions provided by the Zaamo and Zalrsc extensions.” 나눈 이유는 구현 비용이 다르기 때문이다. LR/SC는 보통 캐시에서 예약을 추적해야 해서 캐시가 없는 마이크로컨트롤러에는 부담이고, AMO는 버스나 메모리 컨트롤러 쪽에서 처리할 수 있다. 그래서 작은 코어는 Zaamo만 구현할 수 있게 했다.

A 확장 v2.1 = Zalrsc + Zaamo ZalrscLR.W · SC.WLR.D · SC.D (RV64)예약 기반 · 캐시 필요 ZaamoAMOSWAP · AMOADDAND·OR·XOR·MAX·MIN.W / .D · 캐시 없어도 됨 Zawrs v1.01WRS.NTO · WRS.STOLR 예약 위에서 잠들기 Zacas v1.0AMOCAS.W/D/Q비교-교환 · Zaamo 필요 Zabha v1.0AMO*.B · AMO*.H바이트·하프워드 · Zaamo 필요 Zalasr v1.0L{B|H|W|D}.AQS{B|H|W|D}.RL독립 구현 가능 LR 예약을 기다림 A의 공백을 메움
그림 10-2. A 확장과 그 주변. 실선 화살표는 규격이 밝힌 의존 관계(Zacas·Zabha → Zaamo), 점선은 “함께 써야 쓸모 있음”(Zawrs는 LR이 만든 예약 집합을 기다린다)을 뜻한다. Zalasr는 다른 원자 확장 없이도 구현할 수 있다.
확장버전 (v20260120)주요 명령한 줄 요약
A2.1Zalrsc + Zaamo 전부G(= IMAFD_Zicsr_Zifencei)에 포함되는 표준 원자 확장
Zalrsc(A의 일부)lr.w/d, sc.w/d예약을 걸고, 아무도 안 건드렸으면 쓴다
Zaamo(A의 일부)amo<op>.w/d읽기-연산-쓰기를 한 명령으로
Zawrs1.01wrs.nto, wrs.sto예약 집합이 바뀔 때까지 저전력 대기
Zacas1.0amocas.w/d/q비교 후 같으면 교환 (128비트까지)
Zabha1.0amo<op>.b/h, amocas.b/h8·16비트 원자 연산
Zalasr1.0lw.aq, sw.rl 등순서 표시가 붙은 단독 원자 load·store

aq와 rl: 원자 명령에 붙는 순서 표시

원자성만으로는 부족하다. 잠금을 얻은 뒤의 읽기·쓰기가 잠금을 얻기 전으로 당겨져 보이면 잠금이 아무 소용이 없다. RISC-V의 메모리 모델(RVWMO, 11장)은 느슨해서, 서로 다른 주소에 대한 접근은 다른 하트가 보기에 순서가 바뀔 수 있다. 그래서 모든 원자 명령에는 순서를 조이는 두 비트, aq(acquire)와 rl(release)가 있다.

aq = 1 (acquire) 앞선 메모리 접근 amoswap.w.aq 뒤따르는 메모리 접근 아래로 OK 위로 ✕ 뒤의 접근은 앞으로 못 넘어온다 rl = 1 (release) 앞선 메모리 접근 amoswap.w.rl 뒤따르는 메모리 접근 아래로 ✕ 위로 OK 앞의 접근은 뒤로 못 넘어간다 aq = rl = 1 앞선 메모리 접근 amoadd.w.aqrl 뒤따르는 메모리 접근 ✕ ✕ 양쪽 모두 막힌다 (순차 일관)
그림 10-3. aq·rl은 “한쪽 방향 울타리”다. 녹색 흐르는 화살표는 다른 하트가 보기에 허용되는 재배치, 빨간 화살표는 금지되는 재배치다. acquire는 뒤의 접근이 앞으로 새는 것을, release는 앞의 접근이 뒤로 새는 것을 막는다.
aqrl의미대표 용도
00추가 순서 제약 없음 (원자성만)통계 카운터, 병렬 합산
10acquire: 이 하트의 뒤따르는 메모리 접근은 이 명령보다 먼저 일어난 것으로 관찰될 수 없다잠금 획득
01release: 이 명령은 이 하트의 앞선 메모리 접근보다 먼저 일어난 것으로 관찰될 수 없다잠금 해제
11순차 일관(sequentially consistent): 앞뒤 어느 쪽과도 재배치되지 않는다C++ memory_order_seq_cst RMW
규격 원문에서
“If only the aq bit is set, the atomic memory operation is treated as an acquire access, i.e., no following memory operations on this RISC-V hart can be observed to take place before the acquire memory operation.”

Unprivileged ISA, "A" Extension — Specifying Ordering of Atomic Instructions — 순서 표시는 원자 명령이 접근하는 주소 영역(메모리 또는 I/O) 한쪽에만 적용된다. 두 영역을 함께 묶으려면 FENCE를 쓴다.

왜 FENCE 대신 비트로?

규격 해설에 따르면 fence r,rw로도 acquire를, fence rw,w로도 release를 만들 수 있다. 하지만 FENCE는 앞뒤의 모든 읽기/쓰기를 묶어 버려서 필요 이상으로 순서를 조인다. aq/rl은 “이 명령 하나를 기준으로 한쪽 방향만” 막으므로 하드웨어가 더 자유롭게 재배치할 수 있다. RVWMO 쪽 이야기는 11장에서 자세히 다룬다.

LR/SC: 찜해 두고, 아무도 안 건드렸으면 쓴다

lr.w rd, (rs1)(load-reserved)는 주소의 워드를 읽어 rd에 부호 확장해 넣고, 그 워드를 포함하는 예약 집합(reservation set)을 등록한다. sc.w rd, rs2, (rs1)(store-conditional)는 예약이 아직 유효할 때만 rs2를 메모리에 쓴다. 성공하면 rd에 0을, 실패하면 0이 아닌 값을 쓰고 메모리는 건드리지 않는다. 성공이든 실패든 SC를 실행하면 이 하트의 예약은 사라진다.

3127 2625 2420 1915 1412 117 60 funct5연산 종류 aq rl rs2원본 값 (LR은 0) rs1주소 funct3폭 rd결과(옛 값) opcode0101111 funct3 (폭) 000 = .B (Zabha) 001 = .H (Zabha) 010 = .W 011 = .D (RV64) 100 = .Q (amocas.q 전용) 예 lr.w t0, (a0) = 0x100522af funct5=00010 aq=0 rl=0 rs2=00000 rs1=a0 010 rd=t0 amoadd.w.aq a0, a1, (a2) = 0x04b6252f funct5=00000 aq=1 rl=0 rs2=a1 rs1=a2 010 rd=a0
그림 10-4. AMO 주 opcode(0101111)를 쓰는 원자 명령의 형식. R형과 같지만 funct7 자리를 funct5 + aq + rl로 쪼갰다. LR은 rs2 자리를 0으로 둔다. 폭(funct3)은 일반 load/store와 같은 규칙으로 B·H·W·D를 고른다.

예약 집합은 “워드 하나”보다 클 수 있다

규격은 예약 집합의 크기를 정하지 않는다. 주소한 워드(또는 더블워드)의 바이트를 모두 포함하기만 하면 얼마든지 크게 잡아도 된다. 실제 구현은 보통 캐시 줄 하나를 예약한다. 그러면 같은 캐시 줄의 다른 변수에 다른 하트가 써도 SC는 실패한다. 이를 거짓 공유(false sharing)에 의한 실패라 부르며, 잠금 변수를 캐시 줄 단위로 띄워 두는 이유다.

메모리 하트 0의 예약 집합 (예: 캐시 줄 하나) A A+4 A+8 A+12 … A+60 A+64 ① 하트 0: lr.w t0, (A)값을 읽고 예약 등록 ② 하트 1: sw t1, (A+8)다른 변수지만 같은 집합 ③ 하트 0: sc.w t2, t0, (A)예약 무효 → 실패, t2 ≠ 0 다른 하트가 예약 집합에 쓴 것이 LR과 SC 사이에 관찰되면 SC는 반드시 실패한다
그림 10-5. 예약 집합과 SC 실패. 하트 1은 A+8에 썼을 뿐이지만, 그 바이트가 하트 0의 예약 집합 안에 있으므로 하트 0의 SC는 실패한다. 반대로 집합 밖(A+64)에 쓴 것은 SC에 영향을 주지 않는다.
규격 원문에서
“If the SC.W succeeds, the instruction writes the word in rs2 to memory, and it writes zero to rd. If the SC.W fails, the instruction does not write to memory, and it writes a nonzero value to rd.”

Unprivileged ISA, "Zalrsc" Extension — 실패 코드 1은 “이유 불명의 실패”로 정해져 있고 다른 값은 예약되어 있다. 이식성 있는 소프트웨어는 “0이 아니면 실패”로만 판단해야 한다.

SC가 반드시 실패하는 경우

또한 구현은 아무 이유 없이 SC를 가끔 실패시켜도 된다(아래의 진행 보장만 지키면). 그래서 SC는 늘 재시도 루프 안에 둔다. 규격이 예로 든 비교-교환(CAS) 함수가 그 전형이다.

인라인으로 넣으면 비교-교환의 핵심은 lr.w, bne, sc.w, bnez 네 명령뿐이다.

제약된 LR/SC 루프와 진행 보장

LR/SC의 약점은 라이브락(livelock)이다. 두 하트가 서로의 예약을 계속 깨면 둘 다 영원히 실패할 수 있다. 규격은 다음 조건을 만족하는 제약된 LR/SC 루프(constrained LR/SC loop)에 한해 “결국 누군가는 빠져나간다”고 보장한다.

조건내용
길이루프는 LR/SC 시퀀스와 재시도 코드로만 이루어지며, 메모리에 연속으로 놓인 최대 16개 명령(기본 ISA 기준 64바이트)
LR과 SC 사이기본 I 명령만. 단, load·store, 뒤로 가는 점프, 성립하는 뒤로 가는 분기, JALR, FENCE, SYSTEM 명령은 금지. C(Zca)·Zcb의 압축형은 허용
재시도 코드시퀀스를 반복하기 위한 뒤로 가는 점프·분기는 허용, 그 밖에는 위와 같은 제약
메모리 영역LR/SC 주소가 LR/SC eventuality 속성을 가진 영역에 있어야 함 (실행 환경이 알려 줌)
주소와 크기SC는 같은 하트가 실행한 가장 최근 LR과 같은 유효 주소·같은 데이터 크기

이 조건을 지키면, 하트 H가 루프에 들어간 뒤 결국 다음 중 하나가 일어난다: H 또는 다른 하트가 그 예약 집합에 성공적인 SC를 하거나, 다른 하트·장치가 그 집합에 일반 store나 AMO를 하거나, H가 루프를 빠져나가는 분기를 하거나, H가 트랩된다. 곱셈·나눗셈과 부동소수점을 금지한 이유는 그 명령이 없는 구현에서 운영체제가 에뮬레이션할 때 예약이 깨지기 때문이다.

제약을 벗어난 LR/SC는 영원히 실패할 수도 있다

조건을 어긴 비제약(unconstrained) LR/SC는 어떤 구현에서는 성공하지만, 다른 구현에서는 한 번도 성공하지 않아도 규격 위반이 아니다. 이식성 있는 코드는 반복 실패를 감지해 다른 방법으로 넘어가야 한다. 예를 들어 LR과 SC 사이에서 함수를 호출하거나 다른 변수를 읽으면 이미 제약을 벗어난다.

직접 경쟁시켜 보기

아래 시뮬레이터에서 두 하트가 같은 counter(처음 값 0)를 하나씩 올린다. 어느 하트를 먼저 한 걸음 움직일지 여러분이 정한다. 세 가지 방법(일반 load/add/store, amoadd.w, LR/SC 루프)을 바꿔 가며, 어떤 순서에서 갱신이 사라지는지, LR/SC에서는 SC가 언제 실패하는지 확인해 보자.

SIMULATOR

두 하트 경쟁 시뮬레이터

공유 메모리
counter
0
기대값: 2
방법을 고르고 각 하트의 한 단계 버튼을 원하는 순서로 눌러 보자.
방법
순서 자동
통계
최종 counter—
SC 실패 횟수0
무작위 실험: 갱신 손실 비율—
“lw · addi · sw”에서 하트 0 → 하트 1 → 하트 0 → 하트 1 … 순서로 눌러 그림 10-1을 재현하자. 같은 순서를 LR/SC로 해 보면 하트 1의 SC가 실패하고(t2 = 1) 루프를 한 번 더 돈 뒤 결과가 2가 된다. 무작위 실험 버튼으로 세 방법의 손실 비율을 비교하자.

AMO: 읽고-연산하고-쓰기를 한 명령으로

AMO(atomic memory operation)는 rs1의 주소에서 값을 읽어 rd에 넣고, 그 값과 rs2에 이항 연산을 적용한 결과를 같은 주소에 다시 쓴다. 이 전체가 하나의 메모리 연산으로 원자적으로 일어난다. RV64에서 .w AMO는 rd에 넣는 32비트 값을 부호 확장하고 rs2의 위쪽 32비트는 무시한다.

하트 amoadd.w a0, a1, (a2) a1 (rs2) = 3 a0 (rd) ← 5 요청: 주소, 연산(add), 값 3 응답: 옛 값 5 원자적 단위 (다른 접근이 끼어들 수 없음) ① 읽기mem = 5 ② 연산5 + 3 = 8 ③ 쓰기mem ← 8 락
그림 10-6. amoadd.w a0, a1, (a2)의 흐름. 하트는 “주소·연산·값”을 보내고, 읽기→연산→쓰기는 원자적 단위 안에서 한 번에 끝난다. rd로 돌아오는 것은 연산 전의 옛 값이다. 단순한 구현은 이것을 내부적으로 LR/SC처럼 처리하고, 큰 시스템은 메모리 컨트롤러나 공유 캐시에서 직접 처리하기도 한다.
명령 (.W / .D)funct5메모리에 남는 새 값비고
lr00010(쓰지 않음)rs2 = 0, 예약 등록
sc00011rs2 (성공 시에만)rd = 0 성공 / ≠0 실패
amoswap00001rs2잠금 획득·해제
amoadd00000mem + rs2카운터, 티켓 발급
amoxor00100mem ^ rs2비트 뒤집기
amoand01100mem & rs2비트 지우기
amoor01000mem | rs2비트 세우기
amomin10000min(mem, rs2) 부호 있음
amomax10100max(mem, rs2) 부호 있음
amominu11000min(mem, rs2) 부호 없음
amomaxu11100max(mem, rs2) 부호 없음
amocas (Zacas)00101mem == rd 이면 rs2funct3 010/011/100 = W/D/Q

모든 AMO는 주소가 피연산자 크기에 맞게 자연 정렬되어 있어야 한다(워드는 4바이트, 더블워드는 8바이트 경계). 정렬되지 않으면 주소 정렬 예외나 접근 폴트가 난다. 다만 특권 규격의 misaligned atomicity granule PMA가 있으면, 그 크기 안에 모두 들어가는 접근은 예외 없이 원자적으로 실행될 수 있다.

SIMULATOR

AMO 놀이터 (32비트)

실행 전후

인코딩

0x00000000
연산
값 (10진수 또는 0x16진수)
순서 비트
예제
rd (a0) ← 옛 값—
메모리 새 값—
signed 해석—
unsigned 해석—
“-1 과 1” 예제에서 연산을 max와 maxu로 바꿔 보자. 같은 비트 0xffffffff가 부호 있는 비교에서는 -1(가장 작음), 부호 없는 비교에서는 4294967295(가장 큼)로 해석되어 결과가 정반대가 된다. aq/rl을 켜면 인코딩의 비트 26·25가 바뀌는 것도 확인하자.
rd = x0 이면 “값은 필요 없다”

병렬 합산처럼 옛 값이 필요 없으면 amoadd.w x0, t1, (a0)로 쓴다. 규격 해설은 큰 구현이 이 경우 옛 값을 돌려보내는 과정을 생략하고 메모리 쪽에서 처리하는 최적화를 할 수 있다고 설명한다. AMO가 LR/SC보다 많은 코어에서 더 잘 확장되는 이유다.

잠금 만들기: 스핀락, 티켓 락, 그리고 잠들며 기다리기

규격은 AMO의 쓰임새를 보여 주려고 test-and-test-and-set 스핀락 예제를 싣고 있다. 먼저 평범한 lw로 잠금이 풀렸는지 보고(test), 풀려 보이면 그때 amoswap.w.aq로 1을 써 넣으며 옛 값을 확인한다(test-and-set). 옛 값이 0이었으면 내가 잠금을 얻은 것이다.

획득 쪽 AMO에는 aq가 붙어 임계 구역의 접근이 잠금 획득보다 앞당겨 보이지 않게 하고, 해제 쪽 AMO에는 rl이 붙어 임계 구역의 접근이 해제보다 늦게 보이지 않게 한다. 규격은 잠금 해제도 sw 대신 amoswap으로 하길 권한다. 그래야 투기적 잠금 생략(speculative lock elision) 같은 하드웨어 최적화가 잠금 패턴을 알아보기 쉽다.

티켓 락: 줄 서서 기다리기

단순 스핀락은 공정하지 않다. 운 좋은 하트가 계속 잠금을 가져갈 수 있다. 티켓 락은 은행 번호표처럼 순서를 보장한다. 들어올 때 amoadd.w로 번호표를 하나 뽑고, “지금 부르는 번호”가 내 번호가 될 때까지 기다린다.

잠금 구조체 (메모리) next_ticket7 now_serving5 하트 2 · 번호 5임계 구역 안 하트 0 · 번호 6now_serving 이 6이길 대기 하트 1 · 새로 옴amoadd.w → 7 번호표 뽑기 (원자적) lw 로 계속 확인 끝나면 now_serving++
그림 10-7. 티켓 락. 번호표를 뽑는 동작만 amoadd.w로 원자적이면 된다. now_serving은 잠금을 가진 하트만 올리므로 경쟁이 없다. 들어온 순서대로 잠금을 얻으니 굶주림(starvation)이 없다.

돌며 기다리기 vs 잠들며 기다리기 (Zawrs)

스핀락의 대기 루프는 아무 일도 하지 않으면서 명령을 계속 실행한다. 전력도 쓰고, SMT 코어라면 옆 하트의 실행 자원도 빼앗는다. Zawrs는 이 낭비를 줄인다. lr.w로 잠금 변수에 예약을 걸어 둔 뒤 wrs.nto(no timeout)를 실행하면, 하트는 예약이 유효하고 대기 중인 인터럽트가 없는 동안 저전력 상태로 멈춘다. 다른 하트가 잠금 변수에 쓰면 예약이 깨지고 하트가 깨어난다. wrs.sto(short timeout)는 구현이 정한 짧은 시간이 지나면 스스로 깨어난다.

ANIMATION

세 하트의 잠금 쟁탈전

대기 방식
재생
잠금 소유자없음
잠금 획득 횟수0
대기 중 실행한 읽기 (lw/lr)0
획득 1회당 대기 읽기—
스핀 모드에서 파란 점(대기 읽기)이 끝없이 오가는 모습과 “획득 1회당 대기 읽기” 숫자를 보고, Zawrs 모드로 바꿔 비교하자. Zawrs에서는 기다리는 하트가 흐리게 잠들어 있다가, 누군가 잠금 변수에 쓸 때(획득 amoswap이든 해제 amoswap이든) 깨어나 다시 확인한다. 실제 하드웨어에서 스핀 읽기는 대개 자기 캐시에서 처리되지만, 그동안 코어가 명령을 실행하며 전력을 쓰는 것은 같다.

최신 원자 확장: Zacas, Zabha, Zawrs, Zalasr

Zacas — 하드웨어 비교-교환

A 확장 설계자들은 처음에 CAS 대신 LR/SC를 택했다. 규격 해설은 그 이유로 CAS의 ABA 문제(ABA problem), 피연산자가 셋이라 새 형식이 필요한 점 등을 든다. 그러나 CAS 기반 알고리즘이 널리 쓰이고 많은 코어에서 더 잘 확장되기 때문에 Zacas가 추가되었다. amocas.w rd, rs2, (rs1)은 메모리 값을 읽어 rd의 기대값과 비교하고, 같으면 rs2를 쓴다. 어느 경우든 읽은 값은 rd에 돌아온다.

temp = mem[X(rs1)] if ( temp == X(rd) ) mem[X(rs1)] = X(rs2) X(rd) = temp

Zabha — 바이트와 하프워드 AMO

A 확장에는 8·16비트 AMO가 없어서 컴파일러가 워드 AMO나 LR/SC 루프로 흉내를 냈다. 이 방법은 코드가 길어지고, I/O 영역에서는 옆 바이트까지 건드리는 부작용이 있으며, 큰 시스템에서 공정성 문제가 생긴다. Zabha는 amo<op>.b/h를 funct3 = 000(B), 001(H)로 추가하고, Zacas도 있으면 amocas.b/h까지 제공한다. 결과는 늘 부호 확장된다. LR/SC의 바이트·하프워드 버전은 “쓸모가 적다”며 넣지 않았다.

Zawrs — 예약 집합 위에서 잠들기

lr.w t0, (a0)값 읽기 + 예약 beqz t0, done원하는 값이면 탈출 wrs.nto저전력 정지 깨어나는 조건예약 집합에 쓰기 (예약 무효)대기 인터럽트 관찰(.sto: 짧은 시간 경과) 깨어나면 처음부터 다시 확인 (거짓으로 깰 수도 있으므로) 인코딩 (SYSTEM opcode 1110011, funct3 = 0, rs1 = rd = 0) 31201915141211760 funct12 00000 000 00000 1110011 wrs.nto : funct12 = 0x00d → 0x00d00073 wrs.sto : funct12 = 0x01d → 0x01d00073
그림 10-8. Zawrs 대기 루프와 인코딩. WRS 명령은 예약 집합이 유효하고 인터럽트가 없는 동안에만 멈추며, 구현은 아무 때나 깨어나도 되므로 반드시 다시 확인하는 루프 안에서 쓴다. 인터럽트에 대한 재개 규칙은 WFI와 같다.

WRS는 모든 특권 모드에서 쓸 수 있다. WFI와 달리 사용자 모드에서 쓰라고 만든 명령이다. 다만 mstatus.TW가 1이면 M 모드가 아닌 모드에서 실행한 wrs.nto가 구현이 정한 시간 안에 끝나지 않을 때 illegal-instruction 예외가 난다(가상화 환경에서는 hstatus.VTW에 따라 virtual-instruction 예외). 운영체제가 “너무 오래 자는” 하트를 되찾을 수 있게 한 장치다.

Zalasr — 단독 load-acquire / store-release

A 확장의 LR과 SC는 짝으로만 쓰도록 설계되어 있다. 그래서 C++의 atomic_load(acquire)는 지금까지 lw; fence r,rw, atomic_store(release)는 fence rw,w; sw로 번역했다. Zalasr는 이를 한 명령으로 만든다.

명령funct5필수 비트규칙
l{b|h|w|d}.{aq|aqrl} rd, (rs1)00110aq = 1 (rs2 = 0)aq가 0인 인코딩은 예약. ld.aq는 RV64 전용
s{b|h|w|d}.{rl|aqrl} rs2, (rs1)00111rl = 1 (rd = 0)rl이 0인 인코딩은 예약. sd.rl은 RV64 전용

예를 들어 lw.aq a0, (a1)은 0x3405a52f, sw.rl a0, (a1)은 0x3aa5a02f다. 규격은 “Zaamo, Zabha, Zalasr를 함께 쓰면 모든 C++ 원자 연산을 단일 명령으로 지원할 수 있다”고 정리한다. 비트가 하나도 없는 버전은 정렬된 일반 load/store로 충분하고, load-release나 store-acquire는 언어 메모리 모델에서 쓰이지 않아 넣지 않았다.

v20260120에서 새로 들어온 확장

이 책이 기준으로 삼는 ISA 매뉴얼 v20260120 서문의 변경 사항 첫 줄이 “Addition of the Zalasr extension for Load-Acquire/Store-Release operations”다. 같은 문서의 확장 목록에서 Zalasr 1.0은 Ratified 상태로 실려 있다.

핵심 정리

  1. 읽기와 쓰기 사이에 다른 하트가 끼어들면 잃어버린 갱신이 생긴다. A 확장은 이를 막는 원자 명령을 제공하며, Zalrsc(LR/SC) + Zaamo(AMO)로 이루어진다.
  2. 모든 원자 명령에는 aq·rl 비트가 있다. aq는 뒤의 접근이 앞으로, rl은 앞의 접근이 뒤로 새는 것을 막고, 둘 다면 순차 일관이다.
  3. LR은 예약 집합을 등록하고, SC는 예약이 유효할 때만 쓴다. 성공하면 rd = 0, 실패하면 0이 아닌 값. SC는 성패와 무관하게 예약을 없앤다.
  4. 최대 16개 명령·기본 I 명령만·LR과 SC 사이에 load/store 없음 등 조건을 지킨 제약된 LR/SC 루프만 진행이 보장된다.
  5. AMO는 opcode 0101111, funct5로 연산을 고른다(swap 00001, add 00000, xor 00100, and 01100, or 01000, min 10000, max 10100, minu 11000, maxu 11100). rd에는 옛 값이 돌아온다.
  6. 스핀락은 amoswap.w.aq로 얻고 amoswap.w.rl x0, x0로 푼다. Zawrs의 wrs.nto/sto는 LR 예약 위에서 저전력으로 기다리게 한다.
  7. Zacas는 AMOCAS.W/D/Q(비교-교환), Zabha는 8·16비트 AMO, Zalasr는 단독 load-acquire/store-release를 더한다.

확인 퀴즈

sc.w t2, t0, (a0) 실행 후 t2가 0이었다. 무슨 뜻인가?

SC는 성공하면 rd에 0을, 실패하면 0이 아닌 값을 쓴다. 성공·실패와 무관하게 SC 실행 뒤에는 예약이 사라진다.

A 확장을 둘로 나눈 하위 확장의 조합으로 옳은 것은?

A v2.1은 LR/SC를 담은 Zalrsc와 AMO를 담은 Zaamo로 이루어진다. 캐시가 없는 작은 코어는 Zaamo만 구현할 수 있다.

메모리 값이 0xffffffff, rs2가 1일 때 amomaxu.w 후 메모리에 남는 값은?

부호 없는 비교에서 0xffffffff는 4294967295로 가장 크다. 부호 있는 amomax.w라면 -1 < 1이므로 1이 남는다.

잠금 해제에 amoswap.w.rl x0, x0, (a0)를 쓰는 이유로 가장 알맞은 것은?

rl(release)은 “앞선 접근이 이 명령 뒤로 새는 것”을 막는다. 임계 구역의 쓰기가 잠금이 풀린 뒤에 보이면 다른 하트가 반쯤 갱신된 데이터를 볼 수 있다.

다음 중 제약된 LR/SC 루프의 LR과 SC 사이에 둘 수 있는 명령은?

LR과 SC 사이에는 기본 I 명령만 허용되며 load·store, FENCE, SYSTEM, 뒤로 가는 분기 등은 빠진다. mul은 M 확장이라 허용되지 않는다.

Zawrs의 wrs.nto를 실행한 하트가 깨어나는 조건이 아닌 것은?

읽기는 예약을 깨지 않는다. 쓰기로 예약이 무효가 되거나 인터럽트가 대기 중이면 깨어나며, 구현은 아무 이유로든 가끔 깨어날 수 있다.

RV32에서 amocas.d가 64비트 기대값을 읽어 오는 곳은?

RV32의 AMOCAS.D와 RV64의 AMOCAS.Q는 기대값(rd, rd+1)과 새 값(rs2, rs2+1)을 레지스터 쌍으로 다루며, 첫 레지스터는 짝수여야 한다.

더 읽을거리