Skip to content

emrum/rtc-emu-git

Repository files navigation

RTC-EMU - a real-time-clock emulation for linux OS

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.

Why

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).

What it implements

A client can use the device much like /dev/rtc0:

  • read() blocks until the next periodic interrupt and returns an unsigned long (low byte = flags RTC_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).


Requirements

  • Linux with the cuse kernel module available (ships with the mainline kernel)
  • Rust toolchain (cargo)
  • libfuse3 + headers, clang (for bindgen), pkgconf
sudo pacman -S fuse3 clang pkgconf        # Arch / Manjaro

Setup

1. Load the CUSE kernel module

sudo modprobe -v cuse
ls -l /dev/cuse                            # crw------- root root 10, 203

To load it automatically on every boot:

echo cuse | sudo tee /etc/modules-load.d/cuse.conf

2. Create the clock group (if it does not exist)

Most distros already ship a clock group (used by /dev/rtc0). Check first:

getent group clock || sudo groupadd --system clock

3. Add your user to the clock group

sudo usermod -aG clock "$USER"
# log out and back in (or: newgrp clock) for the membership to take effect
groups | grep clock

4. Install the udev rule

CUSE 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 --reload

The rule takes effect the next time the daemon (re)creates the node.

5. Build

cargo build --release        # or: cargo build --bins   (debug)

Running

Start the server

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-emu

It 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-emu0

Run the client test

timer_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 4096

For 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 1024

Appendix

Project considerations

Why 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.

Implementation details

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, &param) != 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).

Measured jitter (rough overview)

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.

Why select()/poll() is worse than read() on rtc-emu

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 from fire_tick on the next tick — a single userspace round trip.
  • select() instead takes two round trips per interrupt: fire_tick first wakes the waiter via fuse_lowlevel_notify_poll, the client returns from select(), and only its following read() 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.

License

MIT status: working, experimental

About

written in Rust-Lang (uses C libs), emulates an RTC device using hi-res kernel-clock und CUSE-module.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors