Rust 추상 머신의 보편적 성질과 최적화를 해치지 않으면서 인라인 어셈블리(및 FFI)를 설명하는 ‘스토리’ 코드 원칙을 제안하고, 다양한 사례로 그 가능성과 한계를 살펴본다.
* [Projects](https://www.ralfj.de/projects/)
* [RSS Feed](https://www.ralfj.de/blog/feed.xml)
Mar 13, 2026 • Rust • Edits • Permalink
Rust 추상 머신은 실제 하드웨어에는 존재하지 않는 멋진 기묘함들로 가득합니다. 그리고 이런 이야기를 할 때마다 누군가 inevitably 이렇게 묻습니다. “그런데 인라인 어셈블리를 쓰면요? provenance(기원)나 초기화되지 않은 메모리, Tree Borrows, 그리고 실제로는 존재하지도 않는다고 당신이 만들어낸 그 밖의 재미있는 것들은 어떻게 되죠?” 아주 좋은 질문이지만, 제대로 답하려면 노력이 좀 필요합니다. 이 글에서는 인라인 어셈블리가 Rust 추상 머신에 어떻게 들어맞는지에 대한 제 현재 생각을 정리하되, 순수 Rust의 의미론에 대해 우리가 내리는 어떤 결정이 인라인 어셈블리가 할 수 있는/없는 일에 어떻게 영향을 주는지 설명하는 _일반 원칙_을 제시하겠습니다.
여기서 논의하는 모든 것은 인라인 어셈블리뿐 아니라 FFI 호출에도 똑같이 적용됩니다. 이 메커니즘들은 근본적으로 매우 비슷합니다. Rust 코드가 Rust가 아닌 언어로 작성된 코드를 호출할 수 있게 해 주기 때문입니다.1 이 글 내내 “인라인 어셈블리 또는 FFI”를 반복해서 쓰지는 않겠지만, 인라인 어셈블리를 언급할 때마다 FFI도 포함한다고 이해하시면 됩니다.
시작하기에 앞서, 인라인 어셈블리조차도 근본적으로 해서는 안 되는 일이 왜 존재하는지부터 설명하겠습니다.
사람들은 인라인 어셈블리가 추상 머신의 복잡한 요구사항에서 자신을 해방해 준다고 생각하곤 합니다. 불행히도 그건 꿈에 불과합니다. 이를 보여주는 예시가 있습니다.
use std::arch::asm;
#[inline(never)]
fn innocent(x: &i32) { unsafe {
// Store 0 at the address given by x.
asm!(
"mov dword ptr [{x}], 0",
x = in(reg) x,
);
} }
fn main() {
let x = 1;
innocent(&x);
assert!(x == 1);
}
컴파일러가
main
을 분석할 때, 공유 참조만이
innocent
에 전달된다는 사실을 알아냅니다. 이는
innocent
가 무엇을 하든
*x
에 저장된 값을 바꿀 수 없다는 뜻입니다. 따라서 해당 어설션은 최적화로 제거될 수 있습니다.
하지만
innocent
는 실제로
*x
에 기록을 합니다! 결과적으로 최적화가 프로그램의 동작을 바꿔 버립니다. 그리고 실제로 현재 버전의 rustc에서는 정말로 이렇게 됩니다: 최적화 없이 컴파일하면 어설션이 실패하지만, 최적화 켜고 컴파일하면 통과합니다. 그러니 최적화가 잘못되었거나, 프로그램에 정의되지 않은 동작(UB)이 있었던 것입니다. 그리고 우리는 이 최적화를 정말 하고 싶으므로, 둘 중에서는 두 번째를 택할 수밖에 없습니다.2
그런데 UB는 어디서 온 걸까요? 프로그램 전체가 Rust로만 작성되었다면 답은 “aliasing 모델”입니다. Stacked Borrows든 Tree Borrows든, Rust에서 고려할 가치가 있는 어떤 aliasing 모델이라도 공유 참조에서 파생된 포인터로 쓰기를 수행하는 것은 UB가 됩니다. 하지만 이번에는 프로그램의 일부가 Rust로 작성되지 않았으니, 이야기가 그렇게 단순하지 않습니다. Tree Borrows와 비슷한 개념이 전혀 없는 언어로 작성된 인라인 asm 블록이 Tree Borrows를 위반했다고 어떻게 말할 수 있을까요? 이 글의 나머지는 바로 그 문제를 다룹니다.
이 예시가, 인라인 어셈블리가 Tree Borrows 같은 추상 머신 개념을 그냥 무시하도록 두고서는 도저히 빠져나갈 수 없다는 점을 분명히 보여주었기를 바랍니다. 인라인 asm 블록이 UB를 유발합니다. 이제 우리는 그 UB가 어떻게/왜 생기는지—그리고 더 중요하게는, 사람들이 자신의 인라인 asm 블록이 UB를 유발하지 않도록 어떻게 보장할 수 있는지—를 밝혀야 합니다.
이제 어셈블리 코드와 함께 동작하는 Tree Borrows의 버전을 정의해야 하는 것처럼 보일 수도 있습니다. 하지만 그것은 불가능한 과업일 겁니다(Tree Borrows는 어셈블리에는 존재하지 않는 포인터 provenance에 의존합니다).3 다행히도, 그런 일은 필요 없습니다.
대신, 우리는 이미 존재하는 Tree Borrows 및 추상 머신의 나머지 정의에 “얹어” 갈 수 있습니다. 이를 위해 프로그래머가 인라인 어셈블리 블록이 Rust 관점에서 무엇을 하는지에 대한 _이야기(story)_를 하도록 요구합니다.4 (이게 이상하게 들리더라도 잠시만 참아 주세요. 왜 이게 말이 되는지 설명하겠습니다.) 구체적으로, 모든 인라인 어셈블리 블록마다 순수 Rust 코드가 관측 가능한 상태에 관해서는 동일한 일을 하는 Rust 코드 조각이 하나 대응되어야 합니다. 전체 프로그램의 동작을 추론할 때는, 인라인 어셈블리 블록을 그 “이야기” 코드로 대체해서 생각합니다. 실제로 그 코드를 작성할 필요는 없습니다. 중요한 건 그 코드가 존재하고, 주변의 Rust 코드가 하는 일과 함께 일관된 이야기를 구성한다는 점입니다.
위의 예시에서는 이 원칙만으로도 무엇이 잘못됐는지가 즉시 드러납니다. 인라인 어셈블리 블록의 이야기 코드는 대략
(x as *const i32 as *mut i32).write(0)
같아야 할 텐데, 이 코드를 인라인 어셈블리 대신 끼워 넣으면 프로그램에 UB가 있음을 즉시 알 수 있고(미리가 확인해 줄 수도 있습니다). 인라인 어셈블리 블록은 여러 가능한 이야기를 가질 수 있으며, 모든 것이 성립하도록 만드는 이야기 _하나_만 찾으면 충분하지만, 이 경우에는 그런 것이 불가능합니다.
조금 더 자세히 말하면, 제가 생각하는 인라인 어셈블리의 규칙은 다음과 같습니다.
readonly
나
nomem
같은 속성이 asm 블록에 부과하는 모든 요구사항을 만족해야 하며,
in
피연산자를 변경하지 말아야 한다 같은 피연산자 제약도 준수해야 한다. 3. 실제 어셈블리 코드는 이야기 코드를 _정제(refine)_해야 한다. 즉, 어셈블리 코드가 추상 머신이 관측할 수 있는 상태(특히 피연산자와 전역 변수)에 대해 하는 일은, 이야기 코드도 할 수 있었던 일이어야 한다.
이 접근의 정당성을 증명하는 형식적 이론을 제가 갖고 있지는 않다는 단서를 달아두겠습니다. 하지만 저는 꽤 확신합니다. 왜냐하면 이 접근은 위 예시의 최적화 같은 것들의 정당성을 증명하는 방식과 매우 잘 맞기 때문입니다. 정당성 논증의 핵심에는 모든 Rust 코드가 어떤 보편적 성질들을 만족한다는 증명이 있습니다. 예컨대, 내부 가변성이 없는 공유 참조를 인자로 받는 어떤 Rust 함수도 그 인자에 쓸 수 없다는 주장을 형식화하고 증명할 수 있습니다. 이런 성질이 이것 하나뿐인 것도 아닙니다. 사실 이런 성질들의 집합은 아직 완전히 알려져 있지도 않습니다. 내일이 되면 모든 Rust 코드가 지키는 새로운 성질을 발견할 수도 있습니다. 중요한 점은 “모든 Rust 프로그램에 대해 …” 형태의 성질은 이야기 코드에도 반드시 성립해야 한다는 것입니다. 이야기 코드도 그냥 평범한 Rust 코드이니까요! 마지막으로, 실제 어셈블리 코드가 이야기 코드를 정제한다는 점 때문에, 우리는 프로그램을 추론하는 목적에서는 실제로 이야기 코드가 실행된다고 가정해도 되고, 컴파일 끝에서 이야기 코드를 원하는 어셈블리 코드로 바꿔치기해도 프로그램 동작이 바뀌지 않음을 알 수 있습니다.
그래서 이야기 코드가 작동하는 이유는 이렇습니다. 하지만 이러면 인라인 어셈블리는 완전히 쓸모없어지는 것 아닐까요? 인라인 어셈블리의 목적은 원래 순수 Rust만으로는 할 수 없는 일을 하는 것이니까요!
스토리텔링 접근이 실현 가능하다는 것을 설득하기 위해, 인라인 어셈블리 사용의 대표적인 몇 가지 예와 그에 대응하는 이야기가 어떤 모습일지 살펴보겠습니다.
가장 쉬운 경우는 언어에 노출되지 않은 새 하드웨어 연산을 접근하고 싶은 코드입니다. 예를 들어 인라인 어셈블리 블록이 레지스터에서 1로 설정된 비트 수를 반환하는 단일 명령어로 구성될 수 있습니다. 여기서는 스토리텔링이 아주 간단합니다. 1로 설정된 비트 개수를 세기 위해 비트 조작을 손으로 하는 Rust 코드를 작성하면 됩니다.
이건 쉬웠으니, 난이도를 올려서 페이지 테이블을 조작하는 OS 커널을 생각해 봅시다. Rust에는 페이지 테이블이라는 개념이 없습니다. 그럼 여기서 “이야기”는 대체 어떻게 생겼을까요?
답은, Rust에는 페이지 테이블에 새 페이지를 넣는 것과 매우 비슷한 것이 있다는 것입니다. 그것은
alloc
이라고 부릅니다. 페이지를 제거하는 것(
dealloc
), 주소 공간에서 다른 위치로 옮기는 것(
realloc
)과도 비슷한 것이 있습니다. 그래서 OS 커널이 컴파일러에게 들려줄 이야기는, 페이지 테이블 조작이 사실은 좀 웃긴 종류의 할당기라는 것입니다.
조금 더 구체적으로, 스토리텔링 접근과 양립하는 방식으로 페이지를 “할당”하는 것은 다음처럼 생길 수 있습니다.
plaintext with_exposed_provenance 사용).이 asm 블록의 이야기는, 우리가 아직 할당되지 않았음을 알고 있는6 주어진 주소에서 메모리 할당을 수행한다는 것입니다. 이는 새 할당을 나타내는 새로운 provenance를 생성합니다. 그리고 이 할당은 이야기 코드에 의해 즉시 노출(exposed)됩니다.
페이지 테이블 변경 뒤에 배리어가 필요 없는 아키텍처에서도 asm 블록은 여전히 중요합니다. 컴파일러가 새 페이지에 대한 접근을 페이지 테이블 조작보다 앞쪽으로 재정렬하지 못하게 막기 때문입니다! 보통의 Rust 프로그램 규칙만으로는, 컴파일러가 여기 어떤 의존성이 있다는 것을 알아낼 방법이 없습니다. 그래서 asm 블록은 컴파일러 펜스 역할을 합니다. 컴파일러 관점에서 이 블록은 우리가 만들어낸 이야기 코드를 실제로 호출할 수도 있으므로, 새 포인터와 그 포인터에 기반한 연산은 asm 블록 이전으로 옮길 수 없습니다.
이 때문에 사람들은 가끔 asm 블록을 컴파일러 펜스로 생각합니다. asm 블록은 컴파일러가 모르는 임의의 “이야기 코드”를 대신할 수 있으니, 컴파일러는 이 지점에서 임의의 코드가 실행될 수도 있는 것처럼 이 코드를 취급해야 하고, 그 결과 대부분의 재정렬이 방지됩니다. 하지만 여기서 강조할 점은 _대부분_이라는 것입니다. 컴파일러가
&mut
타입 같은 추가 aliasing 정보를 갖고 있으면, 알려지지 않은 함수 호출 및 인라인 asm 블록을 가로질러서도 메모리 접근을 추론하고 재정렬할 수 있습니다. 따라서 asm 블록이 모든 재정렬을 막는 배리어라고 말하는 것은 부정확합니다. 컴파일러 배리어라는 관점은 유용한 직관을 줄 수 있지만, 엄밀한 정당성 논증을 하려면 더 자세히 들어가야 합니다.
이 이야기에는 또 다른 주의점이 있습니다. 페이지 테이블 조작에서는 새 할당을 만들기만 하는 게 아니라, 기존 할당을 키우거나 줄이기도 합니다. 사실 사용자 공간에서도
mmap
으로 같은 일이 가능합니다. 할당을 _키우는 것_은 무해하므로, LLVM에서는 이것을 공식적으로 허용했고 Rust 쪽에서도 이를 노출할 방법을 찾아야 합니다. 하지만 할당을 _줄이는 것_은 문제가 됩니다. LLVM이 합리적으로 수행할 수 있는 간단한 최적화들이 할당을 줄이는 코드를 깨뜨릴 수 있기 때문입니다! 그래서 Rust 코드(그리고 C/C++ 코드)가
munmap
을 오컴파일 위험 없이 사용할 수 있게 하려면 추가 작업이 필요합니다. 이런 잠재적 문제를 놓치기 너무 쉬우므로, 언어 의미론과 정당성에 대해 원칙 있는 접근을 취하는 것이 매우 중요합니다.
이제 페이지 테이블 장난의 또 다른 사례를 봅시다. 물리 메모리의 한 페이지를 가상 메모리의 여러 위치에 매핑하는 것입니다. 그러면 페이지가 여러 곳에서 “미러”되고, 어느 미러에서든 변경하면 모두에 반영됩니다. 먼저, 일반적으로 이것은 평범하게 건전하지 않습니다. LLVM은
ptr
과
ptr.wrapping_offset(4096)
이 alias하지 않는다고 자유롭게 가정하므로, 같은 메모리를 여러 곳에 매핑해 두고 모두를 자유롭게 접근하면 미묘한 오컴파일이 발생할 수 있습니다. 하지만 추상 머신에 맞는 “이야기”를 인라인 어셈블리로 구성할 수 있는 제한된 형태가 있고, 그 경우에는 건전합니다.
핵심 제약은 프로그램이 이 “미러”된 메모리의 버전 중 하나만을 한 번에 사용할 수 있다는 것입니다. 어느 미러가 “활성”인지 바꾸려면 명시적 배리어가 필요하고, 이후 접근에 사용해야 하는 새 포인터를 반환해야 합니다. 이 배리어는 포인터를 바꾸지 않고 그대로 반환하는 빈 인라인 어셈블리 블록일 수도 있지만, 우리가 붙이는 이야기는 결코 비어 있지 않습니다. 우리는 이것이
realloc
처럼 동작하여, 논리적으로 할당을 한 미러에서 다른 미러로 “이동”시킨다고 말할 것입니다. 즉 Rust 추상 머신 관점에서는 미러된 메모리 중 오직 하나만 실제로 “존재”하며, 다른 것으로 전환하는 것은 기존 할당을 해제하고 새 할당을 만드는 것과 같습니다. 결정적으로,
realloc
에서 통상 그러하듯, 전환 후에는 그 메모리에 대한 기존 포인터들이 모두 무효가 되고, 전환이 반환한 새 포인터만이 그 메모리에 접근하는 유일한 방법이 됩니다.7 또한 이런 인라인 asm 블록은 LLVM이 서로 다른 “미러”에 대한 접근을 서로 가로질러 재정렬하지 못하게 하여, 앞서 언급한 오컴파일을 피하게 해 줍니다. 즉, 올바른 이야기를 할 수 있게 코드를 바꿨더니, 최적화기가 해서는 안 되는 일을 못 하게 하는 충분한 구조도 함께 도입된 것입니다.
조금 인위적으로 들릴 수도 있지만, 이런 “순수하게 논리적인”
realloc
은 여러 상황에서 실제로 등장합니다. 이를 언어 자체에 추가하자는 열려 있는 RFC도 있습니다.
이전 예시는 어떤 하드웨어 기능이 Rust 같은 고수준 언어 내부에서 자유롭게 사용되기에는 너무 침투적이라는 것을 보여주었습니다. 비시간적 저장도 또 다른 예입니다. 구체적으로는 x86의 “스트리밍” 저장 연산(
_mm_stream_ps
등)을 말합니다. 이 연산들의 주된 목적은 곧 다시 읽힐 가능성이 낮은 데이터로 캐시를 어지럽히지 않는 것이지만, 불행히도 x86의 일반적인 “total store order” 메모리 모델을 깨뜨리는 부작용이 있습니다. 이는 나쁜 소식인데, 프로그램의 나머지 부분을 컴파일하는 과정이 그 메모리 모델에 의존하기 때문입니다.
문제를 설명하기 위해, 비시간적 저장에 대한 “이야기”가 어떤 모습일지 생각해 봅시다. 가장 자연스러운 선택은 이것을 일반적인 쓰기 접근으로 보는 것입니다. 어차피 추상 머신은 캐싱을 모델링하지 않으니까요. 하지만 이는 동작하지 않습니다. 스트리밍 저장 다음에 원자적 release 쓰기가 오는 경우를 생각해 봅시다. x86의 total store order 모델 때문에, 이는 추가 펜스 없이 일반 쓰기 명령어로 컴파일됩니다. 하지만 스트리밍 저장은 실제로 올바른 동기화를 위해 펜스(
_mm_sfence
)가 필요합니다. 따라서 이야기(스토리) 기준으로는 데이터 레이스가 없다고 보이는 Rust 프로그램이, 실제로는 데이터 레이스를 가질 수 있습니다. 즉 규칙 3(인라인 asm 블록은 이야기 코드를 정제해야 한다)을 위반합니다.
원칙적인 해결책은, (Rust가 공유하는) C++ 메모리 모델을 비시간적 저장 개념으로 확장해서 동시성 프로그램에서 이것들이 다른 모든 것과 어떻게 상호작용하는지 추론할 수 있게 하는 것입니다. 하지만 누가 실제로 이를 해냈는지는 저는 알지 못합니다. C++ 메모리 모델을 확장하거나 약간이라도 조정하는 것은 엄청난 과업이기 때문입니다. 그러나 더 단순한 대안이 있습니다. 규칙 3을 위반하지 않도록 더 복잡한 이야기를 만들어 보는 것입니다. 비시간적 저장 문제가 발견되었을 때 여러 사람들이 바로 이 작업을 했습니다. 그 이야기는, 비시간적 저장이 실제 저장을 비동기적으로 수행할 _스레드를 생성_하는 것에 해당하고,
_mm_sfence
는 그 모든 스레드가 끝나기를 기다리는 것에 해당한다고 말합니다. 이렇게 하면 release-acquire 동기화가 실패하는 이유가 설명됩니다. 동기화는 release를 수행한 스레드가 수행한 모든 쓰기를 관측하지만, 스트리밍 저장은 개념적으로 다른 스레드에서 일어났기 때문입니다! 이 새 이야기 코드는 x86 스트리밍 저장에 대한 업데이트된 문서의 기반이 되었고, 코드 자체도 코드 주석에서 찾을 수 있습니다.
한 가지 주의점이 있습니다. 우리가 고른 이야기는, 스트리밍 저장을 수행한 스레드가
_mm_sfence
전에 그 메모리에서 로드를 수행하는 것이 UB임을 함의합니다. 기반 하드웨어에서는 그 연산이 정의되어 있더라도 말입니다. 이는 스트리밍 저장을 사용하는 코드가 오컴파일되지 않는다는 원칙적 논증을 얻기 위해 치르는 대가입니다. 하지만 그 대가는 크지 않습니다. 스트리밍 저장은 곧 읽지 않을 데이터를 위해 쓰는 것이고, 그게 전부이기 때문입니다. 우리가 실제 코드에서 찾은 스트리밍 저장 사용 예시들 중 이 제한에 걸리는 것은 없었습니다.8
인라인 어셈블리의 또 다른 용도는 스택 페인팅을 사용해 프로그램의 스택 소비량을 측정하는 것입니다. 이는 t-opsem Zulip에서 질문으로 나왔고, 스토리텔링 접근이 얼마나 큰 자유를 주는지 그리고 어떤 한계를 갖는지 잘 보여주는 사례라서 여기 포함합니다.
대략적으로, 스택 페인팅이란 프로그램 시작 전에 나중에 스택이 될 메모리 영역을 고정된 비트 패턴으로 채워 넣는 것입니다. 이후에는 그 비트 패턴이 어디까지 온전하게 남아 있고 어디부터 덮어써졌는지를 확인함으로써 프로그램의 최대 스택 사용량을 측정할 수 있습니다. 이는 스택에서 직접 읽는 인라인 어셈블리 코드로 할 수 있습니다.
첫 반응은 “당연히 UB다”라고 말하는 것일 수 있습니다. 그 스택 메모리는 noalias 제약의 대상일 수 있으니까요(스택을 가리키는 가변 참조가 있기 때문에). 읽을 권한이 없는 메모리를 그냥 읽을 수는 없습니다. 하지만 그것은 이 asm 블록의 이야기가 메모리를 읽는다는 것을 전제합니다. 대안적 이야기는, 이 asm 블록이 단지 임의의, 비결정적으로 선택된 값을 반환한다고 말하는 것입니다. 이 이야기의 장점은, 그 읽기가 트랩을 일으키지 않는 한, 우리의 규칙에 따르면 이야기가 항상 올바르다는 것입니다. 어셈블리 코드가 실제로 무엇을 하든, 임의의 값을 반환하는 것의 정제가 아닐 수는 없기 때문입니다. 하지만 단점도 있습니다. 코드를 추론할 때 우리가 읽은 값에 대해 어떤 가정도 할 수 없게 됩니다! 우리 프로그램의 정당성은 스토리텔링 의미론 아래에서 정의되므로, 인라인 asm이 어떤 값을 반환하든 프로그램이 올바라야 합니다. 문제가 될 것처럼 들릴 수 있지만, 이 사용 사례에서는 사실 전혀 괜찮습니다. 스택 페인팅은 어차피 실제 스택 사용량의 대략값만 제공합니다. 컴파일러는 이 방식이 만들어낸 측정이 정확하다는 _보장_을 전혀 하지 않지만, 실험상 실용적으로는 잘 동작합니다. 잘못된 측정이 건전성이나 정당성 문제로 이어지지 않으므로, 정확한 답을 제공하는 것은 “그저” 삶의 질 문제일 뿐입니다.
마지막으로 다룰 예시는 부동소수점 상태 레지스터와 제어 레지스터입니다. 이 예시는 스토리텔링 접근이, 이런 레지스터를 사용하는 것이 왜 불가능하거나 유용하지 않은지를 설명하는 데 큰 역할을 합니다.
프로그래머는 때때로 상태 레지스터를 읽어 부동소수점 예외가 발생했는지 확인하고 싶어 하며, 제어 레지스터를 써서 반올림 모드나 부동소수점 계산의 다른 측면을 조정하고 싶어 합니다. 하지만 제어 레지스터 변이를 실제로 지원하는 것은 최적화 관점에서 재앙입니다. 제어 레지스터는 전역(정확히는 스레드 로컬) 상태이므로, 레지스터가 다시 바뀔 때까지 이후의 모든 연산에 영향을 줍니다. 이는 반올림이 필요할 수 있는 모든 부동소수점 연산을 최적화하려면 컴파일러가 제어 레지스터의 값을 정적으로 예측해야 한다는 뜻입니다. 대개 그건 불가능하므로, 컴파일러는 제어 레지스터가 항상 기본 상태로 남아 있다고 가정해 버립니다. (가끔 이를 끄는 방법을 제공하기도 하지만, 잘 하기는 어렵고 Rust는 현재 이를 위한 수단이 없습니다.) 상태 레지스터는 덜 문제처럼 보일 수 있지만, 부동소수점 연산이 상태 레지스터를 바꿀 수 있다고 말해 버리면 그 연산은 더 이상 순수하지 않고, 따라서 자유롭게 재정렬할 수 없습니다. 컴파일러가 부동소수점 연산에 대해 공통 부분식 제거 같은 기본 최적화를 할 수 있게 하려면, 언어들은 일반적으로 상태 레지스터가 관측 불가능하다고도 말합니다.
이것이 이런 레지스터를 읽고/쓰는 인라인 어셈블리 코드에 무엇을 의미할까요? 상태 레지스터를 읽는 경우, 이야기 코드는 이것이 실제 부동소수점 연산과 관련이 있다고 말할 방법이 없습니다. 추상 머신에는 이야기 코드가 읽을 수 있는 부동소수점 상태 비트가 없으므로, 가능한 최선의 이야기는 비결정적 값을 반환하는 것입니다. 이는 컴파일러가 상태 레지스터에서 프로그램이 관측할 값에 대해 어떤 보장도 하지 않는다는 사실을 직접 반영합니다. 그리고 부동소수점 연산이 임의로 재정렬될 수 있으니, 이를 아주 문자 그대로 받아들여야 합니다.
제어 레지스터에 쓰는 경우에는, 가능한 이야기가 아예 없습니다. 이후의 부동소수점 연산들의 반올림 모드를 바꾸는 Rust 연산이 존재하지 않기 때문입니다. 따라서 반올림 모드를 바꾸는 어떤 인라인 asm 블록도 UB입니다.
이게 암울하게 들릴 수도 있지만, 반올림 모드를 바꾸고 부동소수점 연산을 수행한 뒤 다시 원래대로 되돌리는 인라인 asm 블록을 작성하는 것은 완전히 가능합니다! 이 블록의 이야기 코드는 소프트-플로트 라이브러리를 사용해 기본이 아닌 반올림 모드로 정확히 같은 부동소수점 연산을 수행하면 됩니다. 결정적으로, asm 블록 전체는 제어 레지스터를 원래대로 되돌려서 결과적으로 변경하지 않으므로, 이야기 코드는 그 레지스터에 대해 신경 쓸 필요조차 없습니다. 즉, 기본이 아닌 반올림 모드에서 부동소수점 연산을 수행하는 하나의 큰 asm 블록을 두는 것은 괜찮습니다. 이는 최적화 관점에서도 납득이 됩니다. 컴파일러가 반올림 모드가 다른 영역 안으로 부동소수점 연산을 옮겨 놓을 위험이 없기 때문입니다.
이 예시들이 스토리텔링 접근의 유연성과 한계를 모두 보여주는 데 도움이 되었기를 바랍니다. 많은 경우, 이야기를 만들어낼 수 없음은 오컴파일 가능성과 직접적으로 연결됩니다. 이건 아주 좋습니다! 그런 종류의 인라인 asm 블록은 우리가 부정확한 것으로 반드시 배제해야 하는 것들이기 때문입니다.9 하지만 어떤 경우에는 뚜렷한 오컴파일이 보이지 않을 때도 있습니다. 그리고 실제로, 컴파일러가 의존하는 Rust 프로그램의 보편적 성질들이 정확히 무엇인지 우리가 안다면, Rust 소스 코드로 표현 가능한 이야기가 전혀 없더라도 그 모든 보편적 성질을 만족하는 인라인 asm 코드는 허용할 수 있을 것입니다. 불행히도 이 접근은 컴파일러가 앞으로 영원히 사용할 수 있는 보편적 성질의 전체 집합에 우리가 커밋해야 함을 의미합니다. 내일 새로운 보편적 성질을 발견하면, 그 성질은 어떤 인라인 asm 블록에서는 성립하지 않을 수도 있으니 사용할 수 없게 됩니다.
그래서 저는 보수적 접근을 제안합니다. 실제 Rust 코드의 모든 보편적 성질과 명백히 양립함이 분명한 인라인 asm 블록만 허용하자는 것입니다. 그 기준은, 그 이야기가 실제 Rust 코드로 표현될 수 있느냐입니다. 현재 유효한 이야기가 없는 연산을 허용하고 싶다면, 그저 추가 새 언어 연산을 도입해야 합니다. 이는 컴파일러가 계속 존중할 연산으로 그 연산을 공식적으로 승인하는 것에 해당합니다.
현재로서는 인라인 asm 블록과 FFI가 Rust 수준의 UB와 어떻게 상호작용하는지에 대한 공식 문서나 가이드라인이 없습니다. 하지만 글 맨 위의
innocent
예시가 보여주듯, 인라인 asm 블록을 그런 식으로 제약 없이 둘 수는 없습니다. 스토리텔링 접근은 그 공백을 메우기 위한 제 제안입니다. 저는 궁극적으로 이것을 인라인 어셈블리에 대한 공식 규칙으로 제안할 계획입니다. 하지만 그러기 전에, 이 접근이 실제 세계의 대부분 시나리오를 정말로 다룰 수 있다는 확신을 더 갖고 싶습니다. 스토리텔링으로는 설명할 수 없지만 올바르며 따라서 지원되어야 한다고 확신하는 어셈블리 블록의 예가 있다면, 이 블로그 글의 즉각적인 토론에서든, (나중에 읽고 있다면) t-opsem Zulip 채널에서든 알려 주세요.
FFI에는 인라인 어셈블리에는 없는 추가 복잡성이 하나 있는데, 그것은 언어 간 LTO입니다. 이는 별도의 골칫거리이며 이 글의 범위를 벗어납니다.↩
비밀스러운 세 번째 선택지는 프로그램이 비결정적이라 두 동작을 모두 허용하는 것일 수 있다는 점이지만, 이 경우에는 분명히 해당하지 않습니다.↩
어떤 사람들은 CHERI를 겉보기의 반례로 들고 싶어 할 것임을 이미 느낄 수 있습니다. CHERI에는 capabilities가 있는데, 이는 포인터 provenance와 조금 비슷하게 보이고 느껴지지만, Tree Borrows에는 전혀 충분히 미세하지 않습니다. 그래서 capabilities와 provenance는 여전히 구분되는 개념이며 서로 혼동해서는 안 됩니다.↩
이 모델을 위해 “이야기를 한다(telling a story)”라는 용어를 제안해 준 Alice Ryhl에게 공을 돌립니다.↩
왜 여기서 volatile 접근을 고집하냐고요? 페이지 테이블이 일반 Rust 할당 안에 있다면, 그 페이지 테이블에 대한 쓰기는 “흥미로운” 효과를 낳을 수 있는데, 이는 보통의 Rust 할당에 쓰기를 할 때 일어날 수 있는 것과는 잘 대응되지 않습니다. 다시 말해, 그런 쓰기가 non-volatile일 수 있도록 해 주는 적절한 이야기를 저는 (아직) 떠올리지 못했습니다.↩
이는 Rust에서 메모리 할당이 작동하는 방식에 대한 명세를 더 정교하게 다듬어, 스택과 정적 변수 같은 “네이티브” Rust 할당이 사용하지 않는 메모리 영역이 존재하며 그 영역은 프로그램이 전적으로 제어한다는 점을 포함한다고 가정합니다. 언어가 제공하는 유일한 할당 연산이 “주소 공간 어디에나 비결정적으로 할당”이라면, 이 이야기는 성립하지 않습니다.↩
복제된 메모리 안으로의 장수 포인터는 잘 동작하지 않는데, 잘못된 복제본을 가리킬 수 있기 때문입니다. 하지만 그 점을 피할 수 있다면, 포인터를 정수로 저장해 두고 각 접근마다 포인터로 캐스트하면 됩니다. 이렇게 하면 컴파일러가 이 메모리에 대해 일반적인 “할당 기반 추론”을 적용할 수 있게 해 주는 장수 provenance를 피할 수 있습니다.↩
우리가 찾은 모든 예시가
_mm_sfence
를 넣는 것을 잊었고, 이는 명백히 건전하지 않았습니다. 이야기 덕분에 우리는 이제 왜 그것이 건전하지 않은지, 즉 Rust 언어의 어떤 규칙이 위반되었는지에 대한 명확한 관점을 갖게 되었습니다.↩
Mar 13, 2026에 Ralf's Ramblings에 게시됨.
댓글? 메일 보내기 또는 reddit에 메모 남기기!