지난 글에서 살펴본 rust_minimal.rs는 로드되면 로그 몇 줄을 찍고 언로드되면 사라지는, 사실상 아무 일도 하지 않는 모듈이었다. 실제 드라이버라면 얘기가 다르다. 유저스페이스가 open()으로 열고, read()/write()로 데이터를 주고받고, ioctl()로 제어 명령을 보내는 흐름을 갖춰야 한다. 커널 소스 트리의 samples/rust/rust_misc_device.rs는 이 흐름을 가장 단순한 디바이스 종류인 misc device로 구현한 예제다. 이 글에서는 이 샘플을 바탕으로 misc device 하나를 등록하고, Mutex로 보호한 내부 상태에 값을 넣고 빼는 실전 드라이버를 만들어본다.
Misc Device란 무엇인가
문자 디바이스를 새로 등록하려면 원래 major number를 할당받고 cdev_add()로 직접 등록해야 한다. 하드웨어와 무관하게 커널-유저스페이스 간 간단한 채널 하나만 필요한 경우 이 과정은 번거롭다. misc device(miscdevice.h)는 major number 10번 하나를 여러 디바이스가 minor number로 나눠 쓰는 방식으로 이 번거로움을 없앤다. MiscDeviceRegistration::register() 한 번이면 /dev 아래 노드가 자동으로 생성되고, major number 관리는 커널이 대신 해준다. 실제 하드웨어가 없는 커스텀 IPC 채널, 설정 인터페이스, 테스트용 드라이버 등에 misc device가 자주 쓰이는 이유다.
모듈 등록과 Pinning
Rust 커널 코드에서 자주 보이는 #[pin_data], Pin<...>, try_pin_init!은 처음 보면 낯설다. 커널 내부 객체 중에는 Mutex의 대기열처럼 자기 자신의 메모리 주소를 참조하는 구조가 있는데, 이런 객체는 초기화 이후 메모리상에서 이동하면 그 참조가 깨진다. Pin은 “이 값은 초기화된 자리에서 다시는 옮겨지지 않는다”는 것을 타입 시스템으로 보증하는 장치이고, pin_data/try_pin_init!은 그런 값을 힙에 바로(in-place) 초기화할 수 있게 해주는 매크로다. 모듈 진입점도 마찬가지로 InPlaceModule을 구현해 misc device 등록 자체를 pin-init 절차 안에 넣는다.
// SPDX-License-Identifier: GPL-2.0
use kernel::{
miscdevice::{MiscDevice, MiscDeviceOptions, MiscDeviceRegistration},
prelude::*,
};
module! {
type: RustMiscDeviceModule,
name: "rust_misc_device",
authors: ["Junorion"],
description: "Rust misc device sample",
license: "GPL",
}
#[pin_data]
struct RustMiscDeviceModule {
#[pin]
_miscdev: MiscDeviceRegistration<RustMiscDevice>,
}
impl kernel::InPlaceModule for RustMiscDeviceModule {
fn init(_module: &'static ThisModule) -> impl PinInit<Self, Error> {
pr_info!("Rust Misc Device 초기화\n");
let options = MiscDeviceOptions {
name: c"rust-misc-device",
};
try_pin_init!(Self {
_miscdev <- MiscDeviceRegistration::register(options),
})
}
}module! 매크로가 이름과 라이선스를 등록하면, InPlaceModule::init()이 MiscDeviceOptions에 디바이스 이름(/dev/rust-misc-device)을 담아 MiscDeviceRegistration::register()를 호출한다. <- 문법은 일반 대입이 아니라 “이 필드는 이 자리에서 직접 초기화하라”는 pin-init 전용 표기다.
상태 보호 — Mutex와 open()
디바이스가 유지할 상태(정수 값 하나, 바이트 버퍼 하나)는 Mutex로 감싼 Inner 구조체에 둔다. 유저스페이스가 파일을 열 때마다 MiscDevice::open()이 호출되고, 여기서 실제 인스턴스를 KBox(커널 전용 할당자를 쓰는 Box)에 pin-init한다. ARef<Device>는 참조 카운팅되는 디바이스 핸들로, 로그 출력(dev_info!)에 어떤 디바이스에서 난 로그인지 자동으로 붙여준다.
struct Inner {
value: i32,
buffer: KVVec<u8>,
}
#[pin_data(PinnedDrop)]
struct RustMiscDevice {
#[pin]
inner: Mutex<Inner>,
dev: ARef<Device>,
}
#[vtable]
impl MiscDevice for RustMiscDevice {
type Ptr = Pin<KBox<Self>>;
fn open(_file: &File, misc: &MiscDeviceRegistration<Self>) -> Result<Pin<KBox<Self>>> {
let dev = ARef::from(misc.device());
dev_info!(dev, "Rust Misc Device 열림\n");
KBox::try_pin_init(
try_pin_init! {
RustMiscDevice {
inner <- new_mutex!(Inner {
value: 0_i32,
buffer: KVVec::new(),
}),
dev: dev,
}
},
GFP_KERNEL,
)
}
}#[vtable]은 MiscDevice 트레이트의 메서드들을 커널의 file_operations 구조체로 자동 연결해주는 매크로다. C에서 함수 포인터 테이블을 손으로 채우던 부분을 트레이트 구현으로 대체한 셈이다.
유저스페이스 입출력 — read_iter/write_iter
read/write는 단일 버퍼가 아니라 IovIter(scatter-gather 방식의 유저 버퍼 반복자)를 인자로 받는다. readv()/writev() 같은 벡터 I/O 호출까지 자동으로 지원하기 위해서다. Kiocb는 파일 오프셋(ki_pos) 등 I/O 요청 컨텍스트를 담고 있다.
fn read_iter(mut kiocb: Kiocb<'_, Self::Ptr>, iov: &mut IovIterDest<'_>) -> Result<usize> {
let me = kiocb.file();
let inner = me.inner.lock();
let read = iov.simple_read_from_buffer(kiocb.ki_pos_mut(), &inner.buffer)?;
Ok(read)
}
fn write_iter(mut kiocb: Kiocb<'_, Self::Ptr>, iov: &mut IovIterSource<'_>) -> Result<usize> {
let me = kiocb.file();
let mut inner = me.inner.lock();
inner.buffer.clear();
let len = iov.copy_from_iter_vec(&mut inner.buffer, GFP_KERNEL)?;
*kiocb.ki_pos_mut() = 0;
Ok(len)
}write_iter는 매번 버퍼를 비우고 새로 채운 뒤 위치를 0으로 되돌리는데, 이렇게 하면 바로 이어지는 read()가 방금 쓴 값을 처음부터 다시 읽을 수 있다. 잠금은 me.inner.lock() 한 줄로 끝나고, 스코프를 벗어나면 Mutex 가드가 자동으로 풀린다 — C에서 mutex_lock()/mutex_unlock() 짝을 손으로 맞추다 하나를 빠뜨리는 실수가 구조적으로 사라진다.
ioctl로 값 주고받기
read/write와 별개로, 값을 원자적으로 조회·설정하는 제어 명령은 ioctl로 처리한다. 명령 번호는 C와 동일하게 _IO/_IOR/_IOW 매크로로 만들고, 유저 포인터로 전달된 인자는 UserSlice의 reader/writer를 거쳐야만 접근할 수 있다 — 검증되지 않은 포인터를 커널이 직접 역참조하는 경로 자체가 타입 시스템에서 막혀 있다.
const RUST_MISC_DEV_HELLO: u32 = _IO('|' as u32, 0x80);
const RUST_MISC_DEV_GET_VALUE: u32 = _IOR::<i32>('|' as u32, 0x81);
const RUST_MISC_DEV_SET_VALUE: u32 = _IOW::<i32>('|' as u32, 0x82);
fn ioctl(me: Pin<&RustMiscDevice>, _file: &File, cmd: u32, arg: usize) -> Result<isize> {
let arg = UserPtr::from_addr(arg);
let size = _IOC_SIZE(cmd);
match cmd {
RUST_MISC_DEV_GET_VALUE => me.get_value(UserSlice::new(arg, size).writer())?,
RUST_MISC_DEV_SET_VALUE => me.set_value(UserSlice::new(arg, size).reader())?,
RUST_MISC_DEV_HELLO => me.hello()?,
_ => return Err(ENOTTY),
};
Ok(0)
}
impl RustMiscDevice {
fn set_value(&self, mut reader: UserSliceReader) -> Result<isize> {
let new_value = reader.read::<i32>()?;
let mut guard = self.inner.lock();
guard.value = new_value;
Ok(0)
}
fn get_value(&self, mut writer: UserSliceWriter) -> Result<isize> {
let value = self.inner.lock().value;
writer.write::<i32>(&value)?;
Ok(0)
}
}알 수 없는 명령 번호가 들어오면 C에서처럼 임의의 값을 리턴하는 대신 Err(ENOTTY)를 명시적으로 반환한다. 매치 구문이 모든 분기를 강제로 처리하게 만드는 Rust의 특성상, 새 명령을 추가하고 처리 분기를 깜빡하면 컴파일 단계에서 걸러지지는 않지만 최소한 기본 분기(_)를 빠뜨리는 실수는 컴파일러가 잡아준다.
빌드와 테스트
먼저 커널 설정에서 샘플을 활성화하고 빌드한다.
$ make LLVM=1 menuconfig
# Kernel hacking -> Sample kernel code -> Rust samples
# Rust misc device sample 항목 체크
$ make LLVM=1 samples/rust/
빌드된 모듈을 로드하면 misc device가 자동으로 /dev 아래 노드를 만든다.
$ sudo insmod rust_misc_device.ko
$ ls -l /dev/rust-misc-device
crw------- 1 root root 10, 122 7월 18 09:00 /dev/rust-misc-device
$ dmesg | tail -2
[ 123.456789] rust_misc_device: Rust Misc Device 초기화
[ 124.001122] rust_misc_device: Rust Misc Device 열림
ioctl 호출까지 확인하려면 유저스페이스에서 open() 후 ioctl(fd, RUST_MISC_DEV_SET_VALUE, &value) 같은 C 코드가 필요한데, 커널 소스의 rust_misc_device.rs 파일 맨 위 문서 주석에 RUST_MISC_DEV_HELLO/GET_VALUE/SET_VALUE 세 명령을 순서대로 호출하는 전체 테스트 프로그램이 포함되어 있으니 그대로 가져다 쓰면 된다.
주의사항
GFP_KERNEL로 할당하는 코드는 sleep이 가능한 컨텍스트에서만 호출해야 한다. 인터럽트 핸들러 등 sleep 불가 경로에서 같은 패턴을 쓰면GFP_ATOMIC으로 바꿔야 한다.KVVec는 표준Vec이 아니라 커널 전용 할당자를 쓰는 타입이다.alloccrate의 일반 컬렉션을 그대로 쓰면 컴파일이 안 되거나(커널은 std를 쓸 수 없다) 커널 메모리 관리 정책을 우회하게 되므로kernel::prelude가 제공하는 타입을 사용해야 한다.- misc device는 학습·간단한 IPC 채널용으로는 적합하지만, 실제 하드웨어를 다루는 프로덕션 드라이버라면 PCI/플랫폼 드라이버 등 해당 버스에 맞는 추상화를 쓰는 게 맞다. major number를 공유하는 misc 방식은 디바이스별 세부 제어가 필요한 경우 한계가 있다.
- 이 예제는 커널 6.12 이후 트리 기준이다.
Kiocb/IovIter기반read_iter/write_iterAPI는 비교적 최근에 정리된 인터페이스라 더 오래된 커널 문서를 참고하면 시그니처가 다를 수 있다.
마무리
misc device 하나를 등록하는 것만으로도 pin-init, Mutex 보호 상태, IovIter 기반 read/write, 타입 안전한 ioctl 인자 처리까지 Rust for Linux의 핵심 패턴을 대부분 짚어볼 수 있다. C로 같은 드라이버를 작성했다면 락 해제를 빠뜨리거나 유저 포인터를 검증 없이 역참조하는 실수가 코드 리뷰에 의존해야 잡히는 반면, 여기서는 타입 시스템이 그 상당 부분을 컴파일 타임에 걸러준다. 다음 편에서는 Arc/ARef 기반 참조 카운팅과 인터럽트 컨텍스트에서의 동기화 프리미티브를 다룰 예정이다.