앞선 편들에서 module! 매크로로 init/exit을 가진 모듈을 만들고 misc device까지 붙여봤다. 그런데 실제 하드웨어를 다루는 드라이버는 insmod 시점에 일을 시작하지 않는다. 모듈은 “이런 하드웨어를 담당할 수 있다”고 등록만 해두고, 그 하드웨어가 실제로 있다고 커널이 판단할 때 probe()가 불린다. 임베디드 ARM 환경에서 “실제로 있다”를 알려주는 것이 디바이스 트리(device tree)다. 이 글에서는 platform 버스에 붙는 Rust 드라이버의 표준 구조를 잡고, QEMU virt 머신의 디바이스 트리 노드에 직접 바인딩해서 MMIO 레지스터를 읽는 데까지 확인한다.
이 시리즈의 다른 글
- Rust 커널 프로그래밍 가이드 (1) — 커널이 Rust를 받아들인 이유와 최소 모듈 작성
- Rust 커널 프로그래밍 가이드 (2) — Misc Device 드라이버로 배우는 open, read/write, ioctl
- Rust 커널 프로그래밍 가이드 (3) — Arc/SpinLock으로 인터럽트 컨텍스트에서 상태 공유하기
- Rust 커널 프로그래밍 가이드 (4) — 새 모듈을 시작할 때 쓰는 템플릿과 체크리스트
- Rust 커널 프로그래밍 가이드 — Ubuntu에서 Rust for Linux 빌드 환경 만들기
모듈과 드라이버는 등록 계층이 다르다
(1)편의 rust_minimal과 이번 드라이버는 같은 .ko지만 커널에 등록되는 대상이 다르다.
module! | module_platform_driver! | |
|---|---|---|
| 등록 대상 | module_init/module_exit | platform_driver_register() |
| 코드 실행 시점 | insmod 즉시 | 매칭되는 디바이스가 있을 때 probe() |
| 매칭 디바이스가 없으면 | 무관하게 init 실행 | 로드만 되고 아무 일도 안 일어남 |
| 해제 | exit에서 직접 정리 | 언바인드 → unbind() → Drop |
module_platform_driver!는 별도 매크로가 아니라 module_driver!에 platform 버스 어댑터를 끼운 얇은 껍데기다. 그래서 module!과 같은 필드(name, authors, license 등)를 그대로 받는다.
// rust/kernel/platform.rs
macro_rules! module_platform_driver {
($($f:tt)*) => {
$crate::module_driver!(<T>, $crate::platform::Adapter<T>, { $($f)* });
};
}드라이버 뼈대 — platform::Driver 트레이트
드라이버가 구현해야 하는 것은 platform::Driver 하나다. 연관 타입 두 개와 ID 테이블 상수, 그리고 콜백 두 개로 구성된다.
| 항목 | 역할 |
|---|---|
type IdInfo: 'static | ID 테이블 항목마다 붙여두는 드라이버 private 데이터. 매칭된 항목의 값이 probe()로 전달된다 |
type Data<'bound>: Send | probe()가 만들어 커널에 넘기는 디바이스별 상태. 바인딩이 유지되는 동안 살아 있다 |
OF_ID_TABLE | 디바이스 트리 compatible 매칭 테이블 |
ACPI_ID_TABLE | ACPI _HID 매칭 테이블. 둘 다 Option이라 필요한 쪽만 채우면 된다 |
probe() | 디바이스가 붙었을 때 초기화. Data를 만들어 반환한다 |
unbind() | 언바인드 시점 훅. 기본 구현이 있어 생략 가능하다 |
드라이버 타입 자체(RustDtDemo)는 상태를 담지 않는 단위 구조체이고, 실제 상태는 Data가 가진다. 아래가 이번 예제의 전체 뼈대다.
// SPDX-License-Identifier: GPL-2.0
//! Device-tree bound Rust platform driver sample.
use kernel::{
device::Core,
io::{mem::IoMem, Io},
of, platform,
prelude::*,
str::CString,
sync::aref::ARef,
};
/// PL031 레지스터 오프셋.
const RTC_DR: usize = 0x000; // Data Register: 현재 시각(epoch seconds)
const RTC_CR: usize = 0x00c; // Control Register
/// ioremap할 레지스터 영역 크기.
const REG_SIZE: usize = 0x1000;
/// compatible 항목마다 드라이버에 넘길 private 데이터.
struct DeviceInfo {
variant: &'static CStr,
}
/// 드라이버 그 자체. 상태는 담지 않는다.
struct RustDtDemo;
/// probe()가 만들어 커널에 넘기는 디바이스 인스턴스 상태.
struct Instance<'a> {
pdev: ARef<platform::Device>,
io: IoMem<'a, REG_SIZE>,
label: CString,
probed_at: u32,
}Instance에 라이프타임 파라미터가 붙은 이유는 IoMem이 디바이스를 빌려 쓰는 타입이기 때문이다. 매핑을 구조체에 보관하려면 type Data<'bound> = Instance<'bound>처럼 그 수명을 그대로 넘겨야 한다.
of::IdTable로 디바이스 트리 노드에 바인딩
매칭 테이블은 of_device_table! 매크로로 만든다. 첫 인자가 테이블 상수 이름, 두 번째가 모듈 alias용 심볼이다.
kernel::of_device_table!(
OF_TABLE,
MODULE_OF_TABLE,
<RustDtDemo as platform::Driver>::IdInfo,
[(
of::DeviceId::new(c"blog,rust-dt-demo"),
DeviceInfo { variant: c"demo-v1" }
)]
);
impl platform::Driver for RustDtDemo {
type IdInfo = DeviceInfo;
type Data<'bound> = Instance<'bound>;
const OF_ID_TABLE: Option<of::IdTable<Self::IdInfo>> = Some(&OF_TABLE);
// ...
}이 매크로는 IdArray 상수를 만들고 이어서 module_device_table!("of", ...)를 호출한다. 후자가 .ko에 modalias를 심어주기 때문에, 부팅 중 해당 compatible을 가진 노드가 발견되면 modprobe가 이 모듈을 자동으로 끌어올 수 있다.
매칭 대상이 되는 디바이스 트리 노드는 이런 모습이다. compatible이 테이블의 문자열과 일치하고, reg가 probe()에서 받을 MMIO 리소스가 된다.
rust-demo@9010000 {
reg = <0x00 0x9010000 0x00 0x1000>;
compatible = "blog,rust-dt-demo";
blog,label = "pl031-rtc";
blog,sample-count = <0x2a>;
blog,debug;
};probe() — 속성 읽기와 MMIO 매핑
probe()는 Data를 바로 반환하지 않고 impl PinInit<Self::Data, Error>를 반환한다. 실제로는 Ok(...)를 그대로 돌려주면 되고, ? 연산자로 중간에 빠져나가면 그대로 probe 실패가 된다.
fn probe<'bound>(
pdev: &'bound platform::Device<Core<'_>>,
info: Option<&'bound Self::IdInfo>,
) -> impl PinInit<Self::Data<'bound>, Error> + 'bound {
let dev = pdev.as_ref();
let info = info.ok_or(ENODEV)?;
dev_info!(dev, "probe: variant={:?}\n", info.variant);
// 1) 디바이스 트리 속성 읽기
let fwnode = dev.fwnode().ok_or(ENODEV)?;
let label: CString = fwnode.property_read(c"blog,label").required_by(dev)?;
let count: u32 = fwnode.property_read(c"blog,sample-count").required_by(dev)?;
let debug = fwnode.property_read_bool(c"blog,debug");
dev_info!(
dev,
"of properties: label={label:?} sample-count={count} debug={debug}\n"
);
// 2) reg 리소스 확인 후 ioremap
let res = pdev.resource_by_index(0).ok_or(ENODEV)?;
dev_info!(
dev,
"reg resource: start={:#x} size={:#x}\n",
res.start(),
res.size()
);
let io = pdev
.io_request_by_index(0)
.ok_or(ENODEV)?
.iomap_sized::<REG_SIZE>()?;
// 3) 실제 MMIO 레지스터 읽기
let cr = io.try_read32(RTC_CR)?;
let probed_at = io.try_read32(RTC_DR)?;
dev_info!(dev, "mmio: CR={cr:#010x} DR={probed_at} (epoch seconds)\n");
Ok(Instance {
pdev: pdev.into(),
io,
label,
probed_at,
})
}속성 읽기에서 눈여겨볼 부분은 required_by(dev)다. 값이 없으면 Err를 돌려주는 동시에 어느 노드의 어떤 속성이 빠졌는지 dev_err!로 직접 찍어준다. 없어도 되는 속성이면 optional()이나 or(기본값)을 쓴다.
| 호출 | 속성이 없을 때 |
|---|---|
required_by(dev) | Err + 에러 로그 자동 출력 |
optional() | None, 로그 없음 |
or(default) | 기본값 사용, 로그 없음 |
MMIO는 io_request_by_index(0)로 리소스를 요청하고 iomap_sized::<SIZE>()로 매핑한다. C의 devm_platform_ioremap_resource()에 대응하는데, 반환된 IoMem이 스코프를 벗어나면 iounmap과 영역 반납이 자동으로 일어난다는 점이 다르다.
해제 경로 — unbind와 Drop
C 드라이버의 remove() 하나가 Rust에서는 둘로 갈린다.
unbind() | Drop | |
|---|---|---|
| 호출 시점 | 언바인드 시작, 디바이스가 아직 살아 있음 | Data가 폐기될 때 |
| 디바이스 접근 | 가능 (&Device<Core>를 받는다) | 보관해둔 ARef로만 |
| 용도 | 하드웨어 정지 등 I/O가 필요한 마무리 | 자원 반납 |
| 구현 필요성 | 선택 (기본 구현 있음) | 대개 불필요 |
fn unbind<'bound>(dev: &'bound platform::Device<Core<'_>>, this: Pin<&Self::Data<'bound>>) {
// 아직 디바이스에 접근할 수 있는 시점이다.
match this.io.try_read32(RTC_DR) {
Ok(now) => dev_info!(
dev.as_ref(),
"unbind: {} seconds since probe\n",
now.wrapping_sub(this.probed_at)
),
Err(e) => dev_err!(dev.as_ref(), "unbind: read failed: {e:?}\n"),
}
}
impl Drop for Instance<'_> {
fn drop(&mut self) {
// iounmap과 release_mem_region은 여기서 자동으로 일어난다.
dev_info!(self.pdev, "drop: releasing '{:?}'\n", self.label);
}
}여기서 핵심은 Drop에 아무것도 안 써도 된다는 점이다. 위 Drop은 순전히 로그를 남기려고 구현한 것이고, IoMem과 CString의 실제 해제는 필드가 소멸하면서 알아서 처리된다. C에서 remove()에 해제 코드를 빠뜨려 생기는 누수가 구조적으로 발생하지 않는다.
QEMU virt에서 실제로 바인딩해보기
QEMU의 virt 머신은 DTB를 스스로 만들어 게스트에 넘긴다. 그 DTB를 꺼내 노드 하나를 우리 드라이버용으로 바꾸면 실제 바인딩을 확인할 수 있다. 여기서는 PL031 RTC 노드를 재활용해서, 매핑한 레지스터에서 진짜 값이 읽히는지까지 본다.
# 툴체인 (커널이 요구하는 정확한 버전을 scripts/min-tool-version.sh로 확인)
rustup toolchain install 1.85.0 --profile minimal
rustup component add rust-src --toolchain 1.85.0
cargo install --locked --version 0.71.1 bindgen-cli
git clone --depth 1 https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git
cd linux
export ARCH=arm64 LLVM=1
make rustavailable
make defconfig
scripts/config --enable RUST --enable SAMPLES --enable SAMPLES_RUST \
--module SAMPLE_RUST_DT_DEMO --enable MODULE_UNLOAD
make olddefconfig
make -j$(nproc) Image modulesDTB는 QEMU가 dumpdtb로 그대로 뱉어준다. 이를 dtc로 풀어 노드를 고치고 다시 컴파일한다.
qemu-system-aarch64 -machine virt,dumpdtb=virt.dtb -cpu cortex-a57 -m 512 -nographic
dtc -I dtb -O dts -o virt.dts virt.dtb
# pl031@9010000 노드를 위의 rust-demo@9010000 으로 교체
dtc -I dts -O dtb -o virt-demo.dtb virt-demo.dts루트 파일시스템은 정적 busybox와 .ko만 담은 initramfs면 충분하다. init이 하는 일은 모듈을 올리고 sysfs를 확인한 뒤 내리는 것뿐이다.
#!/bin/sh
/bin/busybox --install -s /bin
mount -t proc none /proc
mount -t sysfs none /sys
echo "### platform device present before insmod:"
ls -d /sys/bus/platform/devices/*rust-demo* 2>/dev/null || echo " (none)"
echo "### driver dir before insmod:"
ls -d /sys/bus/platform/drivers/rust_dt_demo 2>/dev/null || echo " (none)"
echo "### insmod"
insmod /rust_dt_demo.ko
echo "### bound devices:"
ls -l /sys/bus/platform/drivers/rust_dt_demo/ | grep -v total
echo "### of_node compatible:"
cat /sys/bus/platform/devices/9010000.rust-demo/of_node/compatible; echo
echo "### guest date:"
date +%s
echo "### rmmod after 3s"
sleep 3
rmmod rust_dt_demo
poweroff -fqemu-system-aarch64 -machine virt -cpu cortex-a57 -m 512 -nographic \
-kernel arch/arm64/boot/Image \
-dtb virt-demo.dtb \
-initrd initramfs.cpio.gz \
-append "console=ttyAMA0 rdinit=/init loglevel=7"모듈을 올리기 전에 이미 9010000.rust-demo 디바이스가 존재한다는 점이 이 구조의 핵심이다. 디바이스는 디바이스 트리를 읽은 커널이 만들어 두고, 드라이버는 나중에 와서 거기에 붙는다.
$ date +%s # QEMU를 띄우기 직전 호스트 시각
1786697773
### platform device present before insmod:
/sys/bus/platform/devices/9010000.rust-demo
### driver dir before insmod:
(none)
### insmod
[ 1.393514] rust_dt_demo 9010000.rust-demo: probe: variant="demo-v1"
[ 1.394783] rust_dt_demo 9010000.rust-demo: of properties: label="pl031-rtc" sample-count=42 debug=true
[ 1.395652] rust_dt_demo 9010000.rust-demo: reg resource: start=0x9010000 size=0x1000
[ 1.396496] rust_dt_demo 9010000.rust-demo: mmio: CR=0x00000001 DR=1786697775 (epoch seconds)
### bound devices:
lrwxrwxrwx 1 0 0 0 Jan 1 00:00 9010000.rust-demo -> ../../../../devices/platform/9010000.rust-demo
--w------- 1 0 0 4096 Jan 1 00:00 bind
lrwxrwxrwx 1 0 0 0 Jan 1 00:00 module -> ../../../../module/rust_dt_demo
--w------- 1 0 0 4096 Jan 1 00:00 uevent
--w------- 1 0 0 4096 Jan 1 00:00 unbind
### of_node compatible:
blog,rust-dt-demo
### guest date:
1
### rmmod after 3s
[ 4.484739] rust_dt_demo 9010000.rust-demo: unbind: 3 seconds since probe
[ 4.486236] rust_dt_demo 9010000.rust-demo: drop: releasing '"pl031-rtc"'
DR=1786697775가 매핑이 진짜라는 증거다. QEMU를 띄우기 직전 호스트 시각이 1786697773이었으니 부팅에 걸린 2초를 더한 값을 PL031 레지스터에서 그대로 읽어온 셈이다. 반면 게스트의 date +%s는 1을 돌려주는데, RTC 노드를 우리 드라이버가 가져가버려 커널에 시각을 알려줄 RTC 드라이버가 남아있지 않기 때문이다.
속성 값도 선언한 대로 들어왔다. sample-count은 DTS의 <0x2a>가 42로, 값 없이 이름만 적은 blog,debug는 true로 읽힌다. rmmod 시점에는 unbind()가 먼저 불려 아직 살아 있는 레지스터에서 경과 시간을 읽고, 그다음 Drop이 돌면서 매핑이 반납된다.
주의사항
compatible에arm,primecell이 남아 있으면 platform 드라이버가 아예 호출되지 않는다.of_platform_bus_create()가 그 문자열을 보면 platform 디바이스 대신of_amba_device_create()로 AMBA 디바이스를 만들어버린다. QEMU virt의 원본 PL031 노드가"arm,pl031\0arm,primecell"이므로, 노드를 재활용할 때 이 문자열을 반드시 지워야 한다.IdInfo는'static제약이 있다. ID 테이블 항목에 붙이는 값에는String같은 런타임 할당 타입을 넣을 수 없고,&'static CStr이나 정수 같은 정적 데이터만 담긴다.IoMem을 드라이버 상태에 보관하려면 구조체에 라이프타임 파라미터가 필요하다.type Data<'bound> = Self로 두면 매핑을 보관할 수 없으니, 상태 구조체를 따로 두고Instance<'bound>를 넘기는 형태가 된다.- Rust 추상화 API는 릴리스마다 바뀐다. 이 글의 코드는 7.2-rc7 기준이며,
probe()시그니처만 해도impl PinInit반환으로 바뀐 지 오래되지 않았다. 다른 커널 버전에서 빌드한다면 그 트리의samples/rust/와rust/kernel/platform.rs를 기준으로 삼는 편이 안전하다. - 배포 커널로는 이 예제를 로드할 수 없다. Ubuntu 24.04의 6.8 계열 커널은
CONFIG_RUST=y이긴 하지만kernel::platform추상화 자체가 없던 시절이라, 직접 빌드한 커널로 부팅하거나 이 글처럼 QEMU에서 확인해야 한다.
마무리
platform 드라이버까지 오면 Rust 커널 모듈의 표준 골격이 대체로 갖춰진다. ID 테이블로 어떤 하드웨어를 담당할지 선언하고, probe()에서 리소스를 획득해 상태 구조체로 반환하고, 해제는 타입 시스템에 맡기는 흐름이다. C 드라이버와 비교하면 remove()에 대응하는 해제 코드를 거의 쓰지 않는다는 점이 가장 크게 달라지는 부분이다.