2부에서는 misc device의 상태를 Mutex로 보호했다. 그런데 Mutex::lock()은 경합이 발생하면 현재 태스크를 재웠다가 나중에 깨우는 방식으로 동작한다. 하드웨어 인터럽트 핸들러(hardirq context)는 애초에 “태스크”라는 개념이 없는 컨텍스트라 이 방식이 통하지 않는다. 예를 들어 인터럽트가 몇 번 발생했는지 세는 카운터를 인터럽트 핸들러와 misc device의 read() 양쪽에서 공유하려면, sleep하지 않는 락과 여러 곳에서 동시에 들고 있을 수 있는 참조 카운팅이 둘 다 필요하다. 이 글에서는 SpinLock과 Arc/ARef를 정리하고, 2부의 드라이버에 인터럽트 카운터를 추가하는 방식으로 실제 쓰임을 살펴본다.
Mutex로는 안 되는 이유 — SpinLock
SpinLock은 경합 시 스케줄러에 양보하지 않고 CPU를 계속 돌며(spin) 락이 풀리기를 기다린다. 재울 태스크가 없어도 되므로 인터럽트 핸들러처럼 sleep이 금지된 컨텍스트에서도 안전하다. 대신 그 사이 다른 CPU가 통째로 멈춰서 기다리는 셈이라, 임계 구역은 최대한 짧게 유지해야 한다. 사용법은 2부에서 본 Mutex와 거의 동일하게 new_spinlock! 매크로로 pin-init한다.
use kernel::prelude::*;
use kernel::sync::{new_spinlock, Arc, SpinLock};
#[pin_data]
struct IrqCounter {
#[pin]
count: SpinLock<u64>,
}
impl IrqCounter {
fn new() -> impl PinInit<Self> {
pin_init!(Self {
count <- new_spinlock!(0u64),
})
}
}count.lock()은 가드를 반환하고, 가드가 스코프를 벗어나면 락이 자동으로 풀린다는 점은 Mutex와 동일하다. 차이는 오직 “경합 시 재우느냐, 스핀하느냐”뿐이다.
Arc와 ARef — 두 가지 참조 카운팅
인터럽트 핸들러와 misc device가 같은 IrqCounter 인스턴스를 공유하려면 소유권을 하나로 못 박을 수 없다. kernel::sync::Arc는 표준 라이브러리의 Arc와 거의 같은 원자적 참조 카운팅 스마트 포인터로, 커널 전용 할당자를 쓰기 때문에 생성 시 GFP_KERNEL 같은 할당 플래그를 함께 받는다는 점만 다르다.
struct Example {
a: u32,
b: u32,
}
let obj = Arc::new(Example { a: 10, b: 20 }, GFP_KERNEL)?;
let cloned = obj.clone();
assert!(core::ptr::eq(&*obj, &*cloned));
drop(obj);
assert_eq!(cloned.a, 10);ARef<T>는 이와 결이 다르다. 2부의 ARef<Device>에서 이미 썼던 것처럼, Device처럼 커널 객체 자신이 이미 refcount 필드를 갖고 있고 AlwaysRefCounted 트레이트로 증가·감소 방법이 정의되어 있는 타입을 감싸는 래퍼다. Arc가 “Rust 쪽에서 새로 참조 카운팅을 붙이는” 범용 타입이라면, ARef는 “C 쪽 커널 객체가 이미 갖고 있는 참조 카운팅에 올라타는” 타입이라고 구분하면 된다.
인터럽트 핸들러 등록 — irq::Handler와 Registration
kernel::irq 모듈은 Handler 트레이트 하나로 하드irq 핸들러를 표현한다. handle()은 인터럽트 컨텍스트에서 실행되므로 sleep 가능한 API는 절대 호출할 수 없다는 제약이 트레이트 문서에 명시되어 있다.
use kernel::device::{Bound, Device};
use kernel::irq::{Handler, IrqReturn};
impl Handler for IrqCounter {
fn handle(&self, _device: &Device<Bound>) -> IrqReturn {
*self.count.lock() += 1;
IrqReturn::Handled
}
}Device<Bound>의 Bound는 “이 디바이스가 지금 드라이버에 바인딩된 상태”임을 타입 자체에 새겨 넣은 표식이다. 드라이버가 언바인드된 뒤에도 남은 참조로 디바이스를 건드리는 use-after-unbind류 버그를 컴파일 타임에 걸러내려는 설계다. 등록은 probe 단계에서 irq::Registration::new()로 이뤄지고, 핸들러 인스턴스는 Arc로 감싸 등록 객체와 드라이버 나머지 부분이 동시에 소유할 수 있게 한다.
use kernel::irq::{self, flags::Flags};
let counter = Arc::pin_init(IrqCounter::new(), GFP_KERNEL)?;
let registration = Arc::pin_init(
irq::Registration::new(
irq_request,
Flags::SHARED,
c"rust_irq_counter",
counter.clone(),
),
GFP_KERNEL,
)?;irq_request는 실제 드라이버라면 PCI/플랫폼 디바이스 추상화가 probe 콜백 인자로 넘겨주는 값이다. counter.clone()으로 넘긴 Arc는 참조 카운트만 하나 늘어날 뿐 같은 IrqCounter를 가리키므로, 이후 counter 쪽에서 값을 읽으면 인터럽트 핸들러가 갱신한 최신 값을 그대로 볼 수 있다.
misc device에서 카운터 읽기
2부의 RustMiscDevice에 Arc<IrqCounter> 필드 하나만 추가하면, read_iter에서 그 값을 유저스페이스로 그대로 흘려보낼 수 있다.
struct RustMiscDevice {
// ... 2부의 필드들
counter: Arc<IrqCounter>,
}
fn read_iter(mut kiocb: Kiocb<'_, Self::Ptr>, iov: &mut IovIterDest<'_>) -> Result<usize> {
let me = kiocb.file();
let value = *me.counter.count.lock();
let bytes = value.to_le_bytes();
iov.simple_read_from_buffer(kiocb.ki_pos_mut(), &bytes)
}cat /dev/rust-misc-device를 실행할 때마다 인터럽트 핸들러가 SpinLock 안에서 증가시킨 카운트를 그 시점 값 그대로 읽어오는 셈이다. 락을 잡는 순간은 정수 하나를 복사하는 짧은 구간뿐이라, 인터럽트 핸들러 쪽 임계 구역과 경합해도 대기 시간이 길어지지 않는다.
주의사항
SpinLock임계 구역 안에서는GFP_KERNEL할당이나Mutex::lock()처럼 sleep 가능한 API를 호출하면 안 된다. C에서might_sleep()경고로 잡히던 문제가 Rust에서도 런타임에 그대로 발생할 수 있다.- 프로세스 컨텍스트에서 잡은
SpinLock을 그 CPU에 다시 인터럽트가 들어와 잡으려 하면 자기 자신과 데드락이 난다. 인터럽트 핸들러와 공유하는 락이라면 로컬 인터럽트를 함께 비활성화하는 irq-safe 변형을 써야 한다. - 하드irq 핸들러에서는 카운터 증가처럼 아주 짧은 작업만 해야 한다. 로그 출력, 네트워크 처리처럼 시간이 걸리는 작업은
ThreadedRegistration으로 스레드 컨텍스트로 미뤄야 한다. - 이 글의
irq/syncAPI는 최근 커널 트리 기준이며, 실제 드라이버에 통합하려면 PCI/플랫폼 버스가 제공하는IrqRequest획득 경로를 해당 서브시스템 문서에서 확인해야 한다.
마무리
1부에서 최소 모듈로 R4L의 기본 골격을, 2부에서 misc device로 open/read/write/ioctl을, 이번 3부에서 참조 카운팅과 인터럽트 컨텍스트 동기화를 훑었다. 세 편을 관통하는 흐름은 결국 하나다 — C에서 사람이 규칙을 기억해서 지켜야 했던 것들(락 해제 짝 맞추기, sleep 불가 컨텍스트 구분, 참조 카운트 관리, 유저 포인터 검증)을 Rust에서는 타입 시스템과 소유권 규칙이 컴파일 타임에 대신 강제한다는 점이다. 실제 GPIO, PHY, Binder 같은 서브시스템의 Rust 코드를 읽어보면 이 패턴들이 형태만 바뀐 채 계속 반복되는 걸 확인할 수 있다.