A userspace daemon that emulates virtual real-time-clock character devices
(/dev/rtc-emuN) on Linux using CUSE (Character device in Userspace, part
of FUSE). It exposes the familiar /dev/rtc0 ioctl/read interface but is a pure
software simulation: there is no real hardware clock behind it, and the real
/dev/rtc0 is never held or touched.
The hardware RTC (/dev/rtc0) can only be opened by one process at a time, and
its periodic-interrupt frequency for unprivileged users is capped by
max_user_freq (default 64 Hz). rtc-emu sidesteps both limits:
- multiple independent virtual devices, each with its own frequency,
- periodic interrupts up to 4096 Hz without root,
- timing driven by
timerfd(CLOCK_MONOTONIC), so it is drift-free and not bound to power-of-two hardware divider quirks.
The tradeoff is that the tick is delivered through a userspace round trip rather than a hardware IRQ, which adds a small amount of jitter (see Measured jitter).
A client can use the device much like /dev/rtc0:
read()blocks until the next periodic interrupt and returns anunsigned long(low byte = flagsRTC_IRQF | RTC_PF, upper bits = counter),select()/poll()report readiness between reads,- ioctls:
RTC_PIE_ON/RTC_PIE_OFF,RTC_IRQP_SET,RTC_IRQP_READ,RTC_RD_TIME.RTC_UIE_*/RTC_AIE_*are accepted as permissive aliases.
Not (yet) implemented: real update/alarm interrupt semantics (UF/AF),
RTC_SET_TIME, wakeup-from-suspend (impossible in pure userspace anyway).
- Linux with the
cusekernel module available (ships with the mainline kernel) - Rust toolchain (
cargo) libfuse3+ headers,clang(for bindgen),pkgconf
sudo pacman -S fuse3 clang pkgconf # Arch / Manjarosudo modprobe -v cuse
ls -l /dev/cuse # crw------- root root 10, 203To load it automatically on every boot:
echo cuse | sudo tee /etc/modules-load.d/cuse.confMost distros already ship a clock group (used by /dev/rtc0). Check first:
getent group clock || sudo groupadd --system clocksudo usermod -aG clock "$USER"
# log out and back in (or: newgrp clock) for the membership to take effect
groups | grep clockCUSE creates the device node as 0600 root:root. A udev rule relaxes that to
0660 root:clock so members of the clock group can use it without root. The
rule matches on the device name (the major number is assigned dynamically).
/etc/udev/rules.d/99-rtc-emu.rules:
KERNEL=="rtc-emu[0-9]*", GROUP="clock", MODE="0660"
Install and reload:
sudo cp 99-rtc-emu.rules /etc/udev/rules.d/
sudo udevadm control --reloadThe rule takes effect the next time the daemon (re)creates the node.
cargo build --release # or: cargo build --bins (debug)The daemon needs root to talk to /dev/cuse (it drops to the udev-defined
permissions on the created node). It runs in the foreground with -f:
sudo ./target/release/rtc-emuIt creates /dev/rtc-emu0. Stop it with Ctrl-C; the node disappears
automatically.
Verify (from another terminal, as your normal user):
ls -lha /dev/rtc-emu0 # crw-rw---- root clock ... /dev/rtc-emu0timer_test sets a frequency, enables periodic interrupts, and measures the
jitter over 10 intervals. No root required (thanks to the udev rule).
# read() path
./target/release/timer_test /dev/rtc-emu0 1024
# select()/poll() path
./target/release/timer_test /dev/rtc-emu0 1024 --poll
# maximum resolution (4096 Hz, ~244 us period)
./target/release/timer_test /dev/rtc-emu0 4096For comparison against the real hardware clock (note: RTC_IRQP_SET above 64 Hz
needs root, or raise max_user_freq):
sudo ./target/release/timer_test /dev/rtc0 1024Why CUSE and not a kernel module. The goal was a pure userspace daemon. CUSE
lets a normal process implement a character device's open/read/poll/ioctl
entirely in userspace; the only kernel component is the stock cuse module. A
kernel module would be more transparent (real RTC major/minor, lower jitter) but
was explicitly out of scope.
Why timerfd instead of holding /dev/rtc0. An earlier idea was to occupy the
real RTC at its max frequency and divide it down per virtual device. That forces
a wakeup on every master tick even for slow consumers, and inherits the
hardware's power-of-two and max_user_freq constraints. A per-device
timerfd(CLOCK_MONOTONIC) armed with it_interval is simpler, drift-free, wakes
only as often as needed, and its expiration counter gives the "missed ticks"
value for free.
The RTC_IRQP_SET incompatibility. The real rtc0 driver takes the frequency
in RTC_IRQP_SET by value even though the ioctl is encoded as
_IOW(..., unsigned long). Over CUSE the kernel pre-copies fixed-size ioctl
payloads based on the _IOC size bits, so passing a value (e.g. 1024) makes
the kernel dereference it as a user pointer and fault with EFAULT before the
handler ever runs. Therefore, for rtc-emu, clients must pass a pointer to the
frequency. This is the one place the emulation deviates from rtc0; RTC_IRQP_READ
and RTC_RD_TIME are naturally pointer-based and stay transparent.
Jitter target. The aim was < 0.5 ms. Reached comfortably at 1024 Hz with
SCHED_FIFO + mlockall. At 4096 Hz the period is only ~244 us, so the same
absolute jitter is a larger fraction of the period; RT priority and low system
load matter more there.
Deferred reads. A read() is not answered immediately. The request handle is
parked; the timer thread replies to it on the next tick. If an interrupt is
already pending (counted while no reader was blocked), the read returns it at once.
poll/select. rtc_poll stores the kernel poll handle and reports POLLIN
only when an interrupt is pending. On a tick with no blocked reader, fire_tick
increments pending and wakes the waiter via fuse_lowlevel_notify_poll.
Realtime setup. Done before the timer thread is spawned so the thread inherits the policy.
fn setup_realtime() {
unsafe {
if libc::mlockall(libc::MCL_CURRENT | libc::MCL_FUTURE) != 0 { /* warn */ }
let param = libc::sched_param { sched_priority: 80 };
if libc::sched_setscheduler(0, libc::SCHED_FIFO, ¶m) != 0 { /* warn */ }
}
}Drift-free tick source. it_interval is set so the kernel re-arms on an
absolute grid; the read returns the number of elapsed expirations.
fn arm_timer(fd: RawFd, freq: u32) {
let freq = freq.clamp(1, MAX_FREQ); // MAX_FREQ = 4096
let ns = (1_000_000_000u64 / freq as u64) as i64;
let spec = libc::itimerspec {
it_interval: libc::timespec { tv_sec: 0, tv_nsec: ns },
it_value: libc::timespec { tv_sec: 0, tv_nsec: ns },
};
unsafe { libc::timerfd_settime(fd, 0, &spec, ptr::null_mut()); }
}Tick handling. Blocked readers are served directly; otherwise the device becomes readable and a poll waiter is notified.
fn fire_tick(elapsed: u64) {
let mut st = STATE.lock().unwrap();
if !st.pie_enabled { return; }
st.irq_count += elapsed;
if !st.parked_reads.is_empty() {
let bytes = rtc_word(st.irq_count);
let reqs: Vec<_> = st.parked_reads.drain(..).collect();
st.pending = 0;
drop(st);
for req in reqs { unsafe { ffi::fuse_reply_buf(req, /* ... */); } }
return;
}
st.pending += elapsed;
let ph = std::mem::replace(&mut st.poll_handle, ptr::null_mut());
drop(st);
if !ph.is_null() {
unsafe { ffi::fuse_lowlevel_notify_poll(ph); ffi::fuse_pollhandle_destroy(ph); }
}
}ioctl frequency validation (power-of-two, within range), mirroring rtc0:
let freq = unsafe { *(in_buf as *const libc::c_ulong) } as u32;
if freq == 0 || freq > MAX_FREQ || !freq.is_power_of_two() {
unsafe { ffi::fuse_reply_err(req, libc::EINVAL); }
return;
}FFI / build. bindgen generates bindings from cuse_lowlevel.h /
fuse_lowlevel.h; build.rs links libfuse3 via pkg-config. The CUSE
callbacks are extern "C", and the device is created with a dynamic major
(dev_major = 0).
Quick, informal measurement on a single Manjaro machine (kernel 6.12, non-PREEMPT_RT), 10 intervals, light system load. Indicative only — not a rigorous benchmark.
| Device | Freq | Period | Mode | avg jitter | max jitter |
|---|---|---|---|---|---|
/dev/rtc-emu0 |
1024 Hz | 0.977 ms | read() | ~0.016 ms | ~0.034 ms |
/dev/rtc-emu0 |
4096 Hz | 0.244 ms | read() | ~0.021 ms | ~0.040 ms |
/dev/rtc-emu0 |
1024 Hz | 0.977 ms | select() | ~0.057 ms | ~0.120 ms |
/dev/rtc-emu0 |
64 Hz | 15.625 ms | select() | ~0.083 ms | ~0.202 ms |
/dev/rtc0 (HW) |
64 Hz | 15.625 ms | read() | ~0.029 ms | ~0.106 ms |
/dev/rtc0 (HW) |
64 Hz | 15.625 ms | select() | ~0.013 ms | ~0.034 ms |
Notable points from this short test:
- The emulated device's
read()path stays well under the 0.5 ms target and performed on par with (at 4096 Hz even ahead of) the real hardware clock. - Maximum resolution (4096 Hz, ~244 us period) barely raised the jitter, though the absolute jitter is then a larger share of the period, so results there depend more on RT scheduling and load.
On rtc-emu the select()/poll() path is consistently noisier than the
blocking read() path (~0.057 vs ~0.016 ms at 1024 Hz). This is architectural,
not a bug:
- A blocked
read()is parked in the daemon and answered directly fromfire_tickon the next tick — a single userspace round trip. select()instead takes two round trips per interrupt:fire_tickfirst wakes the waiter viafuse_lowlevel_notify_poll, the client returns fromselect(), and only its followingread()actually retrieves the word. The extra wakeup and scheduling gap add latency and variance.
Note the opposite holds for the hardware /dev/rtc0, where select() is
better than read(): there the interrupt is a direct kernel IRQ with no
userspace forwarding, so the poll wakeup carries no extra round trip. For
rtc-emu, prefer the blocking read() path when jitter matters; select() is
there for compatibility with programs built around it.
MIT status: working, experimental