Rust 코드의 panic!은 커널에서 BUG()로 이어져 실행 중인 커널을 멈춘다. 그런데 Rust의 정수 곱셈과 슬라이스 인덱싱에도 패닉 경로가 들어간다. 커널의 Rust 코딩 가이드라인은 패닉을 드물게 쓰고 거의 모든 경우에 Result를 쓰라고 적는다. 구체적인 코드를 리뷰할 때 쓸 판단 기준은 없다.
Rust for Linux 콘퍼런스 Kangrejos 2026에서 알렉상드르 쿠르보(Alexandre Courbot)가 실제 리뷰 사례로 이 기준을 물었다. 이 글에서는 논의에 나온 네 단계 기준과 사례를 정리한다. 각 사례는 rustc 1.97.1(x86-64)의 사용자 공간 코드로 재현했다. 커널 소스는 7.4-rc2 기준이다.
커널에서 Rust 패닉이 하는 일
패닉 핸들러는 메시지를 찍고 C의 BUG()를 부른다. 커널은 -Cpanic=abort로 빌드하므로 스택 되감기(unwinding)도 없다.
#[panic_handler]
fn panic(info: &core::panic::PanicInfo<'_>) -> ! {
pr_emerg!("{}\n", info);
// SAFETY: FFI call.
unsafe { bindings::BUG() };
}패닉 발생 여부는 아래 Kconfig 옵션에 따라서도 달라진다.
| 옵션 | 기본값 | 켰을 때 패닉하는 경우 |
|---|---|---|
CONFIG_RUST_OVERFLOW_CHECKS | y | 정수 연산 오버플로(overflow) |
CONFIG_RUST_DEBUG_ASSERTIONS | n | debug_assert! 실패 |
CONFIG_RUST_BITMAP_HARDENED | n | Bitmap API의 인자 검사 실패 |
패닉을 피하는 네 단계
쿠르보는 선호도 순서로 아래 단계(ladder of desirability)를 제시했다. 위 단계로 해결할 수 있으면 아래 단계를 쓰지 않는다.
| 단계 | 방법 | 오류를 발견하는 시점 | 도구 또는 사례 |
|---|---|---|---|
| 1 | 실패할 수 없는 코드 | 오류가 생기지 않음 | Nova 드라이버의 enum VgpuState |
| 2 | 컴파일 타임 검사 | 빌드 | static_assert!, build_assert! |
| 3 | 런타임 검사 | 호출자의 오류 처리 | Result |
| 4 | 패닉 | 실행 중 커널 중단 | assert!, panic! |
1단계: 잘못된 상태를 타입에서 없애기
Nova 드라이버의 vGPU 상태는 원래 플래그와 VF(virtual function) 개수를 따로 뒀다. enum으로 바꾸면 “켜졌는데 VF가 0개”인 상태를 만들 수 없어 런타임 검사가 사라진다.
use std::mem::size_of;
use std::num::NonZero;
#[allow(dead_code)]
struct VgpuStateFlag {
enabled: bool,
total_vfs: u16,
}
#[allow(dead_code)]
enum VgpuState {
Disabled,
Enabled { total_vfs: NonZero<u16> },
}
fn total_vfs(state: &VgpuState) -> u16 {
match state {
VgpuState::Disabled => 0,
VgpuState::Enabled { total_vfs } => total_vfs.get(),
}
}
fn main() {
println!("struct: {} bytes", size_of::<VgpuStateFlag>());
println!("enum: {} bytes", size_of::<VgpuState>());
println!("zero rejected: {:?}", NonZero::<u16>::new(0));
let s = VgpuState::Enabled { total_vfs: NonZero::new(16).unwrap() };
println!("total_vfs: {}", total_vfs(&s));
}$ rustc --edition 2021 -O state.rs && ./state
struct: 4 bytes
enum: 2 bytes
zero rejected: None
total_vfs: 16
NonZero가 0을 쓰지 않으므로 컴파일러가 0을 Disabled 표시로 재사용해 크기도 줄어든다.
2단계: 컴파일 타임에 잡기
static_assert!는 상수 평가(const evaluation)로 검사한다. 커널 매크로의 본체는 const _: () = assert!(...)다.
const MAX_UDELAY_MS: u32 = 5;
const REQUEST_MS: u32 = 8;
const _: () = assert!(REQUEST_MS <= MAX_UDELAY_MS, "delay is too long");
fn main() {}$ rustc --edition 2021 static_fail.rs
error[E0080]: evaluation panicked: delay is too long
--> static_fail.rs:3:15
|
3 | const _: () = assert!(REQUEST_MS <= MAX_UDELAY_MS, "delay is too long");
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ evaluation of `_` failed here
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0080`.
build_assert!는 함수 인자처럼 상수가 아닌 값도 검사한다. 실패 경로의 호출이 최적화로 사라지지 않으면 빌드가 깨지는 구조다. 아래는 같은 원리를 정의되지 않은 심볼로 줄인 코드다.
extern "C" {
fn __build_error() -> !;
}
macro_rules! build_assert {
($cond:expr) => {
if !$cond {
unsafe { __build_error() }
}
};
}
#[inline(always)]
fn read_reg(offset: usize) -> u32 {
build_assert!(offset < 0x100);
offset as u32
}
fn main() {
println!("{}", read_reg(0x10));
}for attr in 'inline(always)' 'inline' 'inline(never)'; do
sed "s/@ATTR@/#[$attr]/" ba.rs.in > ba.rs
for o in 0 1 2; do
if rustc --edition 2021 -C opt-level=$o ba.rs 2>/dev/null; then r=OK; else r=LINK-FAIL; fi
printf '%-16s opt-level=%s %s\n' "$attr" "$o" "$r"
done
done$ bash matrix.sh
inline(always) opt-level=0 OK
inline(always) opt-level=1 OK
inline(always) opt-level=2 OK
inline opt-level=0 LINK-FAIL
inline opt-level=1 OK
inline opt-level=2 OK
inline(never) opt-level=0 LINK-FAIL
inline(never) opt-level=1 OK
inline(never) opt-level=2 OK
opt-level=0에서는 컴파일러가 인라인되지 않은 호출의 조건을 평가하지 못해 링크가 실패한다. 커널 문서가 #[inline(always)]를 “Important”로 표시한 이유다.
인라인해도 컴파일러가 값을 모르면 같은 결과가 나온다. main()만 아래처럼 바꿔 opt-level=2로 빌드했다.
fn main() {
let offset = std::env::args().count() * 0x10;
println!("{}", read_reg(offset));
}$ rustc --edition 2021 -C opt-level=2 ba_var.rs 2>&1 | grep -o 'undefined symbol: __build_error' | sort -u
undefined symbol: __build_error
쿠르보는 build_assert!가 많은 문제의 답처럼 보이지만 곧 0-day 알림을 받게 된다고 말했다. 드문 빌드 설정에서 최적화 결과가 달라져 Intel 0-day CI가 빌드 실패를 보고하기 때문이다.
3단계와 오류 동작: udelay()
udelay()에 아키텍처 최댓값보다 긴 시간을 넘기는 것은 버그다. 이 버그를 처리하는 방법은 네 가지다.
const MAX_MS: u32 = 5;
fn udelay_panic(ms: u32) -> u32 {
assert!(ms <= MAX_MS, "delay {ms} ms is out of range");
ms
}
fn udelay_result(ms: u32) -> Result<u32, &'static str> {
if ms > MAX_MS { return Err("EINVAL"); }
Ok(ms)
}
fn udelay_clamp(ms: u32) -> u32 {
if ms > MAX_MS {
eprintln!("warn: delay {ms} ms is out of range, clamped");
return MAX_MS;
}
ms
}
fn udelay_split(ms: u32) -> Vec<u32> {
let mut left = ms;
let mut calls = Vec::new();
while left > 0 {
let step = left.min(MAX_MS);
calls.push(step);
left -= step;
}
calls
}
fn main() {
let ms = 12;
println!("result: {:?}", udelay_result(ms));
println!("clamp: {}", udelay_clamp(ms));
println!("split: {:?}", udelay_split(ms));
println!("panic: {}", udelay_panic(ms));
}$ rustc --edition 2021 -O udelay.rs && ./udelay; echo "exit=$?"
result: Err("EINVAL")
warn: delay 12 ms is out of range, clamped
clamp: 5
split: [5, 5, 2]
thread 'main' (3321) panicked at udelay.rs:4:5:
delay 12 ms is out of range
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
exit=101
| 방법 | 결과 | 비용 |
|---|---|---|
Result 반환 | EINVAL | 모든 호출자가 오류를 처리한다 |
| 범위로 제한(clamp) | 최댓값만큼 지연 | 요청보다 짧게 지연한다 |
| 여러 번 호출 | 요청한 시간만큼 지연 | C udelay()를 반복해서 호출한다 |
| 패닉 | 커널 중단 | 시스템이 멈춘다 |
미겔 오헤다(Miguel Ojeda)는 범위 제한 방식을 오류 동작(erroneous behavior)이라고 불렀다. 호출은 버그지만 결과는 정의되어 있고 안전하다. 쿠르보도 리뷰에서 이 방식을 권했다. 현재 커널 코드는 이 방식에 경고를 더했다.
pub fn udelay(delta: Delta) {
const MAX_UDELAY_DELTA: Delta = Delta::from_millis(bindings::MAX_UDELAY_MS as i64);
debug_assert!(delta.as_nanos() >= 0);
debug_assert!(delta <= MAX_UDELAY_DELTA);
let delta = if (Delta::ZERO..=MAX_UDELAY_DELTA).contains(&delta) {
delta
} else {
pr_warn_once!("attempted to udelay() with out of range delta\n");
MAX_UDELAY_DELTA
};
// ...
}안전한 대안이 없는 패닉: Binder
Rust Binder 드라이버의 add_death()는 NodeDeath가 자기 노드의 것인지 검사한다.
assert!(
core::ptr::eq(self, &**death.node),
"attempt to add NodeDeath to the wrong death list"
);
self.inner.access_mut(guard).death_list.push_back(death);삽입을 건너뛰면 리소스를 엉뚱한 시점에 정리한다. 쿠르보는 이 경우 Result도 쓸모가 없다고 봤다. 게리 궈(Gary Guo)는 Android 개발자들이 시스템 중단을 택할 것이라고 말했다. 오헤다는 메인테이너가 시스템 중단을 택하면 괜찮다고 답했다.
Kconfig로 갈리는 패닉: Bitmap
bitmap_assert_return!은 설정에 따라 패닉하거나 오류를 찍고 돌아온다.
macro_rules! bitmap_assert_return {
($cond:expr, $($arg:tt)+) => {
#[cfg(CONFIG_RUST_BITMAP_HARDENED)]
assert!($cond, $($arg)*);
#[cfg(not(CONFIG_RUST_BITMAP_HARDENED))]
if !($cond) {
pr_err!($($arg)*);
return
}
}
}사용자 공간에서 pr_err!를 eprintln!으로 바꿔 두 빌드를 비교한다.
macro_rules! bitmap_assert_return {
($cond:expr, $($arg:tt)+) => {
#[cfg(hardened)]
assert!($cond, $($arg)*);
#[cfg(not(hardened))]
if !$cond {
eprintln!($($arg)*);
return;
}
};
}
struct Bitmap { bits: Vec<bool> }
impl Bitmap {
fn set_bit(&mut self, index: usize) {
bitmap_assert_return!(index < self.bits.len(),
"Bit `index` must be < {}, was {}", self.bits.len(), index);
self.bits[index] = true;
}
}
fn main() {
let mut b = Bitmap { bits: vec![false; 8] };
b.set_bit(9);
println!("still running, bits set: {}", b.bits.iter().filter(|x| **x).count());
}$ rustc --edition 2021 --check-cfg 'cfg(hardened)' bitmap.rs && ./bitmap; echo "exit=$?"
Bit `index` must be < 8, was 9
still running, bits set: 0
exit=0
$ rustc --edition 2021 --check-cfg 'cfg(hardened)' --cfg hardened bitmap.rs && ./bitmap; echo "exit=$?"
thread 'main' (3387) panicked at bitmap.rs:17:9:
Bit `index` must be < 8, was 9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
exit=101
참석자들은 서브시스템별 Kconfig로 패닉 여부를 바꾸는 방식이 틀렸다고 합의했다. 다만 이 코드는 C 구현을 옮긴 것이어서 최소한으로 허용했다. 궈는 C 쪽의 잘못된 인덱스는 정의되지 않은 동작(UB)이므로 두 경우가 같지 않다고 지적했다.
산술 오버플로: Hertz::from_ghz()
커널의 from_ghz()는 const fn이고 본체는 곱셈 한 줄이다. 같은 곱셈이 호출 위치에 따라 빌드 오류나 런타임 패닉이 된다.
#![allow(dead_code)]
type CUlong = u64;
#[derive(Debug)]
struct Hertz(CUlong);
impl Hertz {
const GHZ_TO_HZ: CUlong = 1_000_000_000;
const fn from_ghz(ghz: CUlong) -> Self {
Self(ghz * Self::GHZ_TO_HZ)
}
}
#[cfg(build_fail)]
const BAD: Hertz = Hertz::from_ghz(18_446_744_074);
fn main() {
let ghz: CUlong = std::env::args().nth(1).unwrap().parse().unwrap();
println!("{:?}", Hertz::from_ghz(ghz));
}$ rustc --edition 2021 --check-cfg 'cfg(build_fail)' --cfg build_fail hertz.rs
error[E0080]: attempt to compute `18446744074_u64 * 1000000000_u64`, which would overflow
--> hertz.rs:15:20
|
15 | const BAD: Hertz = Hertz::from_ghz(18_446_744_074);
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ evaluation of `BAD` failed inside this call
|
note: inside `Hertz::from_ghz`
--> hertz.rs:10:14
|
10 | Self(ghz * Self::GHZ_TO_HZ)
| ^^^^^^^^^^^^^^^^^^^^^ the failure occurred here
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0080`.
$ rustc --edition 2021 -C opt-level=2 -C overflow-checks=on --check-cfg 'cfg(build_fail)' hertz.rs -o hertz_on
$ ./hertz_on 18446744073
Hertz(18446744073000000000)
$ ./hertz_on 18446744074
thread 'main' (3438) panicked at hertz.rs:10:14:
attempt to multiply with overflow
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
$ rustc --edition 2021 -C opt-level=2 -C overflow-checks=off --check-cfg 'cfg(build_fail)' hertz.rs -o hertz_off
$ ./hertz_off 18446744074
Hertz(290448384)
c_ulong이 64비트면 18446744073GHz가 상한이다. 32비트 커널에서는 5GHz부터 오버플로한다. 궈는 C에서 UB가 될 자리라면 패닉해도 된다고 봤고, 여기서는 wrapping이나 saturating도 괜찮다고 말했다.
패닉 경로는 생성된 코드에서 직접 셀 수 있다. Nova 드라이버에서는 패닉 지점 170개 중 140개가 산술 오버플로 또는 언더플로였다.
#![crate_type = "lib"]
#[no_mangle]
pub fn hz_mul(ghz: u64) -> u64 {
ghz * 1_000_000_000
}
#[no_mangle]
pub fn hz_checked(ghz: u64) -> Option<u64> {
ghz.checked_mul(1_000_000_000)
}
#[no_mangle]
pub fn hz_saturating(ghz: u64) -> u64 {
ghz.saturating_mul(1_000_000_000)
}
#[no_mangle]
pub fn get_index(v: &[u32], i: usize) -> u32 {
v[i]
}
#[no_mangle]
pub fn get_checked(v: &[u32], i: usize) -> Option<u32> {
v.get(i).copied()
}for f in hz_mul hz_checked hz_saturating get_index get_checked; do
n=$(awk -v F="$f:" '$1==F{p=1;next} p&&/\.cfi_endproc/{p=0} p&&/call.*panic/' sites.s | wc -l)
printf '%-14s %s\n' "$f" "$n"
done$ rustc --edition 2021 -C opt-level=2 -C overflow-checks=on -C panic=abort --emit=asm sites.rs -o sites.s && bash count.sh
hz_mul 1
hz_checked 0
hz_saturating 0
get_index 1
get_checked 0
쿠르보는 대부분 피할 수 있다고 봤지만 인라인된 core 함수에서 오는 패닉도 있다. 널 종료 문자열을 변환할 때 생기는 오버플로 검사가 그 예다. 앨리스 륄(Alice Ryhl)은 이 문제를 고치는 표준 라이브러리 PR을 받겠다고 했다. 륄은 동적 크기 타입의 인덱싱을 더 어려운 사례로 꼽았고, 하나의 답은 없다고 말했다.
개발자별 입장
| 개발자 | 입장 |
|---|---|
| 리누스 토발즈(Linus Torvalds) | 쿠르보의 전언: 커널을 멈출 수 있는 버그에 강하게 반대한다 |
| 미겔 오헤다 | 패닉 여부는 메인테이너가 정한다. 드라이버는 코어 코드보다 패닉 근거를 더 엄격히 제시한다 |
| 알렉상드르 쿠르보 | 디바이스 드라이버에서 호출되는 코드는 패닉하지 않는 편이 맞다 |
| 게리 궈 | 패닉을 완전히 없애는 것은 나쁜 생각이다. 자체 완결(self-contained) 컴포넌트는 잘못된 입력에 패닉할 수 있다 |
| 켄트 오버스트리트(Kent Overstreet) | 규칙을 엄격하게 강제하면 코드가 더 지저분해진다 |
전원이 동의한 내용은 두 가지다. 컴파일 타임 오류가 런타임 오류보다 낫다. 도달 가능한 패닉은 버그다. 그 밖의 사항은 시간 부족으로 결론을 내지 못했다. LWN 기사 작성 시점까지 코딩 가이드라인은 바뀌지 않았다.
주의사항
- 사용자 공간에서 값을 받는 경로에
assert!를 두지 말 것. 패닉이 커널 중단으로 이어진다. build_assert!를 쓰는 함수에#[inline(always)]를 빠뜨리지 말 것. 최적화 수준에 따라 빌드가 깨진다.build_assert!의 인자에 런타임 값을 넘기지 말 것. 컴파일러가 값을 모르면 링크가 실패한다.CONFIG_RUST_OVERFLOW_CHECKS를 끈 빌드에서 패닉을 기대하지 말 것. 위 실행에서 곱셈 결과가 조용히 wrap됐다.- 값을 범위로 제한할 때 경고를 생략하지 말 것. 경고가 없으면 호출자의 버그를 찾기 어렵다. 커널의
udelay()는pr_warn_once!를 쓴다. - 패닉 가능한 함수에는
/// # Panics문서를 쓸 것. 커널 코딩 가이드라인의 필수 항목이다.
마무리
이번 논의는 규칙을 확정하지 못했다. 다만 판단 순서는 분명하다. 타입으로 오류를 없앨 수 있는지 먼저 본다. 다음으로 빌드 검사와 Result를 검토한다. 패닉은 마지막에 근거를 적어 쓴다. 오류 동작은 안전한 결과를 정의할 수 있을 때 쓸 수 있는 중간 선택지다.