정의되지 않은 동작으로 간주되는 것들

정의되지 않은 동작으로 간주되는 것들 (Behavior Considered Undefined)

Rust 코드가 아래 목록에 있는 동작 중 하나라도 보이면, 그 코드는 올바르지 않은(incorrect) 코드예요. 이건 unsafe 블록이나 unsafe 함수 안의 코드도 마찬가지인데요, unsafe는 undefined behavior를 피할 책임이 프로그래머에게 있다는 뜻이지, Rust 프로그램이 undefined behavior를 절대 일으키지 말아야 한다는 사실 자체가 바뀌는 건 아니에요.

unsafe 코드를 작성할 때는 그 unsafe 코드와 상호작용하는 안전한 코드가 위 동작들을 절대 촉발할 수 없게 하는 게 프로그래머의 책임이에요. 임의의 안전한 클라이언트에 대해 이 속성을 만족하는 unsafe 코드를 sound(건전) 하다고 부르고, 안전한 코드가 오용해서 undefined behavior를 일으킬 수 있는 unsafe 코드는 unsound(불건전) 하다고 불러요.

출처: Rust Reference

본문

⚠️ 경고 아래 목록은 완전하지 않아요. 늘어날 수도, 줄어들 수도 있어요. Rust 의미론에는 unsafe 코드에서 무엇이 허용되고 허용되지 않는지에 대한 공식 모델이 없으므로, 더 많은 동작이 unsafe로 간주될 수 있어요. 또한 그 목록에 있는 일부 동작을 미래에 "정의된" 것으로 만들 권리도 우리(컴파일러)가 갖고 있어요. 즉, 이 목록은 어떤 것이 앞으로 모든 Rust 버전에서 반드시 undefined일 거라고 단언하는 게 아니에요. unsafe 코드를 작성하기 전에 반드시 Rustonomicon을 읽어주세요.

  • 데이터 레이스(Data races).
  • 댕글링(dangling) 상태이거나 정렬이 잘못된(misaligned) 포인터에 기반한 place에 접근(로드하거나 저장)하는 것.
  • 경계 안 포인터 산술(in-bounds pointer arithmetic)의 요구를 위반하는 오프셋 place projection을 수행하는 것. 오프셋 place projection이란 필드 표현식, 튜플 인덱스 표현식, 배열/슬라이스 인덱스 표현식이에요.
  • 포인터 별칭 규칙(pointer aliasing rules)을 위반하는 것. 정확한 별칭 규칙은 아직 확정되지 않았지만, 일반 원칙을 정리하면 다음과 같아요. &T는 살아 있는 동안(live) 변경되지 않는 메모리를 가리켜야 해요(UnsafeCell<U> 안의 데이터는 제외). &mut T는 살아 있는 동안 그 참조에서 파생되지 않은 어떤 포인터도 읽거나 쓰지 않고, 다른 어떤 참조도 가리키지 않는 메모리를 가리켜야 해요. Box<T>는 이런 규칙의 목적상 &'static mut T와 비슷하게 취급돼요. 정확한 liveness 기간은 명시되어 있지 않지만, 몇 가지 경계는 존재해요. 참조의 liveness 기간은 borrow checker가 부여한 구문적 수명(syntactic lifetime)을 상한으로 가지며, 그 수명보다 오래 살 수 없어요. 참조나 박스가 역참조되거나 reborrow될 때마다 live로 간주되고, 함수로 전달되거나 함수에서 반환될 때마다 live로 간주돼요. 참조(Box는 아님!)가 함수로 전달되면 그 함수 호출이 끝나는 동안은 최소한 live로 유지되는데, 역시 &TUnsafeCell<U>가 포함된 경우는 예외예요. 이 모든 규칙은 이런 타입의 값들이 복합 타입의 (중첩된) 필드로 전달될 때도 적용되지만, 포인터 간접 참조 뒤에 있을 때는 적용되지 않아요.
  • 불변 바이트(immutable bytes)를 변경하는 것. const 프로모션된 표현식을 통해 도달 가능한 모든 바이트는 불변이고, 'static으로 수명이 확장된 static/const 초기화자의 borrow를 통해 도달 가능한 바이트도 불변이에요. 불변 바인딩이나 불변 static이 소유한 바이트는 불변인데, 그 바이트가 UnsafeCell<U>의 일부인 경우는 예외예요. 게다가 공유 참조가 가리키는 바이트(다른 참조들(공유든 가변든)과 Box를 통해 전이적으로 가리키는 것을 포함)는 불변이에요. 전이성에는 복합 타입의 필드에 저장된 참조들도 포함돼요. 변경(mutation)이란 관련 바이트 중 어느 하나라도 겹치는 0보다 큰 어떤 바이트의 쓰기(write)를 말해요(그 쓰기가 메모리 내용을 바꾸지 않더라도).
  • 컴파일러 내장 함수(intrinsics)로 undefined behavior를 일으키는 것.
  • 현재 플랫폼이 지원하지 않는 플랫폼 기능으로 컴파일된 코드를 실행하는 것(target_feature 참고). 플랫폼이 이것이 안전하다고 명시적으로 문서화한 경우는 예외예요.
  • 잘못된 호출 ABI로 함수를 호출하거나, unwinding을 허용하지 않는 스택 프레임 너머로 unwinding하는 것. 예를 들어 "C-unwind" 함수를 "C" 함수나 함수 포인터로 import하거나 transmute해서 호출하는 경우가 있어요.
  • 유효하지 않은 값(invalid value)을 만들어내는 것. 값이 place에 할당되거나 place에서 읽힐 때, 함수/기본 연산에 전달되거나 함수/기본 연산에서 반환될 때마다 값을 "만들어내는" 거예요.
  • 인라인 어셈블리를 잘못 사용하는 것. 자세한 내용은 인라인 어셈블리를 사용하는 코드를 작성할 때 따라야 할 규칙을 참고해요.
  • Rust 런타임의 가정을 위반하는 것. Rust 런타임의 대부분 가정은 현재 명시적으로 문서화되어 있지 않아요. unwinding과 관련된 가정은 panic 문서를 참고해요. 런타임은 Rust 스택 프레임이 그 스택 프레임이 소유한 지역 변수의 소멸자(destructor)를 실행하지 않고 해제되지 않는다고 가정해요. 이 가정은 longjmp 같은 C 함수에 의해 위반될 수 있어요.

참고 undefined behavior는 프로그램 전체에 영향을 미쳐요. 예를 들어 C에서 undefined behavior를 보이는 C 함수를 호출하면, 그건 Rust 코드에도 영향을 줄 수 있는 undefined behavior를 프로그램 전체에 포함시킨다는 뜻이에요. 반대로 Rust의 undefined behavior는 다른 언어로의 FFI 호출이 실행하는 코드에 부정적인 영향을 줄 수 있어요.

가리켜지는 바이트 (Pointed-to bytes)

포인터나 참조가 "가리키는" 바이트의 범위는 포인터 값과 pointee 타입의 크기(size_of_val 사용)로 결정돼요.

정렬이 잘못된 포인터에 기반한 Places

place가 "정렬이 잘못된 포인터에 기반" 한다고 말하는 건, place 계산 중 마지막 * projection이 자기 타입에 맞게 정렬되지 않은 포인터에 대해 수행된 경우예요. (place 표현식에 * projection이 없으면 이건 지역 또는 static의 필드에 접근하는 것이고, rustc가 올바른 정렬을 보장해요. * projection이 여러 개면 그 각각이 역참조할 포인터 자체를 메모리에서 로드하는 것이며, 이 각각의 로드가 정렬 제약을 받아요. 자동 역참조로 인해 표면 Rust 문법에서 일부 * projection이 생략될 수 있는데, 여기서는 완전히 확장된 place 표현식을 고려하고 있어요.)

예를 들어 ptr*const S 타입이고 S의 정렬이 8이라면, ptr은 8-정렬이 되어야 하고 그렇지 않으면 (*ptr).f는 "정렬이 잘못된 포인터에 기반"한 것이 돼요. 이는 필드 f의 타입이 u8(정렬 1인 타입)인 경우에도 마찬가지예요. 즉, 정렬 요구 사항은 접근하는 필드의 타입이 아니라 역참조된 포인터의 타입에서 파생돼요.

정렬이 잘못된 포인터에 기반한 place는 로드되거나 저장될 때만 undefined behavior로 이어져요.

그런 place에 대한 &raw const / &raw mut는 허용돼요.

place에 대한 & / &mut는 필드 타입의 정렬을 요구하는데(그렇지 않으면 프로그램이 "유효하지 않은 값을 만들어내는" 것이 됨), 이는 일반적으로 정렬된 포인터에 기반하는 것보다 덜 제한적인 요구 사항이에요.

필드 타입이 그 필드를 포함하는 타입보다 더 정렬될 수 있는 경우, 즉 repr(packed)의 경우 참조를 취하면 컴파일러 오류가 나요. 이는 정렬된 포인터에 기반하는 것이 새 참조의 정렬을 보장하기에 항상 충분하지만, 항상 필요한 것은 아니라는 뜻이에요.

댕글링 포인터 (Dangling pointers)

참조/포인터가 가리키는 모든 바이트가 같은 live 할당의 일부가 아닐 때, 그 참조/포인터는 "dangling" 하다고 해요(따라서 특히 그 모든 바이트는 어떤 할당의 일부여야 해요).

크기가 0이면 그 포인터는 (null 포인터라도) 사소하게 절대 "dangling"이 되지 않아요.

동적 크기 타입(슬라이스, 문자열 등)은 전체 범위를 가리키므로, 길이 메타데이터가 절대 너무 크지 않은 게 중요해요.

특히 Rust 값의 동적 크기(size_of_val로 결정됨)는 단일 할당이 isize::MAX보다 클 수 없으므로 절대 isize::MAX를 초과해서는 안 돼요.

유효하지 않은 값 (Invalid values)

Rust 컴파일러는 프로그램 실행 중 생성되는 모든 값이 "유효(valid)"하다고 가정하며, 따라서 유효하지 않은 값을 만들어내는 것은 즉시 UB예요.

값이 유효한지 여부는 타입에 따라 달라져요.

  • bool 값은 false(0) 또는 true(1)여야 해요.
  • fn 포인터 값은 non-null이어야 해요.
  • char 값은 서러게이트(surrogate)이면 안 되고(즉 0xD800..=0xDFFF 범위에 있으면 안 되고) char::MAX보다 작거나 같아야 해요.
  • ! 값은 절대 존재하면 안 돼요.
  • 정수(i*/u*), 부동소수점 값(f*), raw 포인터는 초기화되어야 해요, 즉 초기화되지 않은 메모리에서 얻은 것이면 안 돼요.
  • str 값은 [u8]처럼 취급돼요, 즉 초기화되어야 해요.
  • enum은 유효한 판별자(discriminant)를 가져야 하고, 그 판별자가 가리키는 변형(variant)의 모든 필드는 각 타입에 맞게 유효해야 해요.
  • struct, 튜플, 배열은 모든 필드/요소가 각 타입에 맞게 유효해야 해요.
  • union의 경우 정확한 유효성 요구 사항은 아직 결정되지 않았어요. 당연히 안전한 코드에서 전적으로 만들어질 수 있는 모든 값은 유효해요. union에 크기 0인 필드가 있으면 모든 가능한 값이 유효해요. 더 자세한 내용은 아직 논쟁 중이에요.
  • 참조나 Box<T>는 정렬되고 non-null이어야 하고, dangling이면 안 되며, 유효한 값을 가리켜야 해요(동적 크기 타입의 경우 메타데이터에 의해 결정되는 실제 동적 타입 사용). 마지막 요점(유효한 값을 가리키는 것)은 여전히 논쟁의 대상이라는 점에 유의해요.
  • 넓은(wide) 참조, Box<T>, raw 포인터의 메타데이터는 unsized tail의 타입과 일치해야 해요. dyn Trait 메타데이터는 Trait에 대한 컴파일러 생성 vtable을 가리키는 포인터여야 해요. (raw 포인터의 경우 이 요구 사항은 여전히 논쟁의 대상이에요.) 슬라이스([T])와 str 메타데이터는 유효한 usize여야 해요. 게다가 넓은 참조나 Box<T>의 경우, 그 메타데이터가 가리켜지는 값의 총 크기(size_of_val로 결정)를 isize::MAX보다 크게 만든다면 유효하지 않아요. 참고로 이 경계는 unsized tail만이 아니라 가리켜지는 값 전체의 크기에 대한 것이고, 슬라이스나 str 길이와 마찬가지로 dyn Trait 메타데이터도 제약해요. 유효한 vtable은 isize::MAX보다 크지 않은 지워진(erased) 타입을 설명하지만, 크기가 있는 접두부(sized prefix)가 여전히 총합을 한계를 넘길 수 있어요.
  • 타입에 유효한 값의 사용자 지정 범위가 있으면, 유효한 값은 그 범위 안에 있어야 해요. 표준 라이브러리에서 이는 NonNull<T>NonZero<T>에 영향을 줘요. 참고로 rustc는 이를 불안정한 rustc_layout_scalar_valid_range_* 속성으로 달성해요.
  • const 컨텍스트에서는: 위에 설명된 것에 더해, const 평가 중에는 provenance와 관련된 추가 요구 사항이 적용돼요. 순수 정수 데이터를 담는 값(i*/u*/f* 타입, boolchar, enum 판별자, 슬라이스 메타데이터)은 어떤 provenance도 지니면 안 돼요. 포인터 데이터를 담는 값(참조, raw 포인터, 함수 포인터, dyn Trait 메타데이터)은 provenance를 전혀 지니지 않거나, 모든 바이트가 같은 원래 포인터 값의 조각들이 올바른 순서로 된 것이어야 해요. 이는 포인터(참조, raw 포인터, 함수 포인터)를 비포인터 타입(정수 같은)으로 transmute하거나 재해석하는 것이, 그 포인터가 provenance를 지닌다면 undefined behavior라는 뜻이에요. 다음은 모두 UB인 예시예요.
#![allow(unused)]
fn main() {
use core::mem::MaybeUninit;
use core::ptr;

// We cannot reinterpret a pointer with provenance as an integer,
// as then the bytes of the integer will have provenance.
const _: usize = {
    let ptr = &0;
    unsafe { (&raw const ptr as *const usize).read() }
};

// We cannot rearrange the bytes of a pointer with provenance and
// then interpret them as a reference, as then a value holding
// pointer data will have pointer fragments in the wrong order.
const _: &i32 = {
    let mut ptr = &0;
    let ptr_bytes = &raw mut ptr as *mut MaybeUninit::<u8>;
    unsafe { ptr::swap(ptr_bytes.add(1), ptr_bytes.add(2)) };
    ptr
};
}

참고 유효한 값의 제한된 집합을 가진 어떤 타입에 대해서도 초기화되지 않은 메모리는 암묵적으로 유효하지 않아요. 다시 말해, 초기화되지 않은 메모리를 읽는 것이 허용되는 유일한 경우는 union 안과 "패딩"(타입의 필드 사이의 틈) 안이에요.

더 알아보기