391
ECE 391 · Checkpoint 4 · Spring 2026
Memory I/O,
Timer Alarms,
& Bug Hunting
What We're Covering Today
01
I/O Object Model
02
create_memio
03
memio_fetch & store
04
SBI — the RISC-V firmware API
05
Timer & Alarm Subsystem
06
Implementing Timer Funcs
07
Bug Hunt: memcpy Swap
slide 2 / 14
The I/O Object Model
Object-oriented C: nested structs for inheritance, function pointer tables for polymorphism
struct io
Base type. Holds: iointf* (dispatch table), block size, reference count.
struct seekio
Extends io. Adds: pos (current r/w cursor), end (total size). Wraps read/write as seek+fetch/store.
struct memio
Extends seekio. Adds: buf* (pointer to backing byte array), reclfn() (optional free callback).
💡 Why can you cast freely?
C guarantees that &struct.first_member == &struct. Since seekio embeds io as its very first member, a struct seekio * and struct io * point to the same address. Same logic for memio → seekio → io. This is the C inheritance trick.
struct seekio {� struct io base; // embedded base� unsigned long long pos;� unsigned long long end;�};��struct memio {� struct seekio base; // FIRST member� void *buf;� void (*reclfn)(void*,size_t);�};�
// all three are the same address:�struct memio *mio = ...;�struct seekio *sio = (struct seekio *)mio;�struct io *io = (struct io *) mio;
slide 3 / 14
struct iointf — The Interface / Dispatch Table
This is the C equivalent of a vtable. Every I/O object points to one of these.
struct iointf {� const char *implname;� void (*reclaim)(struct io*);� long (*read )(struct io*, void*, long);� long (*write )(struct io*, const void*, long);� long (*fetch )(struct io*, ull pos,� void* buf, long len);� long (*store )(struct io*, ull pos,� const void* buf, long len);� int (*ioctl )(struct io*, int, void*);�};��// memio's concrete table:�static const struct iointf memio_intf = {� .fetch = &memio_fetch, // YOUR CODE� .store = &memio_store, // YOUR CODE� .read = &seekio_read, // provided� .write = &seekio_write, // provided�};
Dispatch chain for ioread()
ioread(io, buf, len)
generic wrapper, sequential read
seekio_read(io, buf, len)
advances pos, calls fetch
io->intf->fetch(...)
dispatch via function pointer
memio_fetch(io, pos,...)
✅ your implementation runs here
seekio_read() already calls io->intf->fetch() internally — so ioread() on a memio reaches YOUR memio_fetch automatically. You never override read/write; you only supply fetch/store.
slide 4 / 14
Implementing create_memio
Allocate, wire, and return a memory-backed I/O object. Signature: struct io * create_memio(void *buf, size_t size, void(*reclfn)(void*,size_t))
1
Allocate with kcalloc
Call kcalloc(1, sizeof(struct memio)) to get zero-initialised heap memory. Using kcalloc (not kmalloc) ensures all fields start at 0, so you don't have stale pointer garbage in buf or reclfn.
struct memio *mio = kcalloc(1, sizeof(*mio));�
if (!mio) return NULL;
2
Store buf and reclfn
Copy the caller's buffer pointer and optional reclaim callback into the struct. The reclaim callback is invoked by memio_reclaim() (already implemented) when the last reference is dropped via ioclose().
mio->buf = buf;�
mio->reclfn = reclfn;
3
Call seekio_init
seekio_init(&mio->base, &memio_intf, size, blksz, refcnt) does all the bookkeeping: sets pos=0, end=size, wires up the intf pointer, block size, and reference count. Use blksz=1 (byte-granular) and refcnt=1 (caller holds the first reference).
return seekio_init(&mio->base, // &struct seekio
&memio_intf, // dispatch table
size, // end = size
1, // blksz: byte-level
1); // refcnt: caller owns
4
Return struct io *
seekio_init returns a struct io * (upcast of &mio->base). Return it directly to the caller. This is the public handle — callers use iofetch/iostore/ioclose through it, never touching struct memio internals.
slide 5 / 14
memio_fetch & memio_store — The Core Operations
These are the only two functions you write for the I/O layer. Everything else dispatches through them.
long memio_fetch(struct io *io, unsigned long long pos, void *buf, long len);
long memio_store(struct io *io, unsigned long long pos, const void *buf, long len);
memio_fetch
Goal: copy len bytes from position pos in the backing buffer into the caller's buf
① Cast
struct memio *mio = (struct memio*)io;
② Bounds
if (mio->base.end < pos ||
mio->base.end - pos < len)
return -EINVAL;
③ Copy
memcpy(buf, mio->buf + pos, len);
④ Return
return len;
memio_store
Goal: copy len bytes from the caller's buf into the backing buffer starting at position pos
① Cast
struct memio *mio = (struct memio*)io;
② Bounds
if (mio->base.end < pos ||
mio->base.end - pos < len)
return -EINVAL;
③ Copy
memcpy(mio->buf + pos, buf, len);
④ Return
return len;
slide 6 / 14
SBI — Supervisor Binary Interface
How your S-mode kernel asks M-mode firmware (OpenSBI) to do privileged things — including programming the timer
The privilege-level sandwich
User Program
U-mode (least privileged)
normal app code, uses syscalls
S-mode Kernel
Supervisor mode — YOUR CODE
handles syscalls, manages memory & devices
M-mode Firmware
Machine mode — OpenSBI
controls raw hardware; timer compare register lives here
Why can't S-mode program the timer directly?
The timer compare register (mtimecmp) is a Machine-mode CSR. S-mode code that tries to access it will fault. SBI gives you a clean, version-stable API to ask M-mode to do it for you, without your kernel needing to run in M-mode.
sbi.s — every SBI call follows the same 3-line pattern
sbi_set_timer:� li a7, 0x0 # Extension ID → a7� ecall # trap into M-mode� ret # firmware handled it��# a0 already holds stime_value (C calling convention)�# result comes back in a0�
SBI calls your kernel uses (from sbi.h)
sbi_set_timer(stime_value)
0x0
Program timer to fire interrupt at time stime_value
sbi_console_putchar(ch)
0x1
Write one character to debug console
sbi_console_getchar()
0x2
Read one character from debug console
sbi_shutdown()
0x8
Power off the machine
slide 7 / 14
sbi_set_timer — The Timer Programming API
The only way your S-mode kernel can schedule a future timer interrupt. Used in both timer_init and handle_timer_interrupt.
What sbi_set_timer(stime_value) does:
Programs the hardware timer compare register so that when the free-running time counter (readable via rdtime()) reaches stime_value, the CPU raises a timer interrupt. Your handle_timer_interrupt ISR then runs. Until you call sbi_set_timer again, no more timer interrupts will fire.
Concrete usage in CP4
Called from: timer_init()
sbi_set_timer(0);
Pass 0 to fire the first interrupt immediately. This bootstraps the interrupt chain — every subsequent handle_timer_interrupt call will schedule the next one.
Called from: handle_timer_interrupt()
sbi_set_timer(MIN(____, ____));
Schedule the next interrupt at whichever comes first: the next periodic bolt tick, or the earliest sleeping thread's wake time. After this call, no more interrupts until that time arrives.
rdtime() reads the current free-running counter (S-mode can do this directly). sbi_set_timer(t) sets when the next interrupt fires (M-mode only, hence the SBI call). These two together give you full timer control.
slide 8 / 14
The Timer & Alarm Subsystem — Big Picture
How sleep_ms(500) turns into a thread sleeping and waking at the right time, using your CP3 condition variables
End-to-end flow: sleep_ms(500)
1. sleep_ms(500)
Converts 500 ms to absolute tick count: twake = rdtime() + 500 * (freq/1000)
2. sleep_until(twake)
Checks if already past. Creates struct timer_alarm on the stack with twake + condition variable.
3. Sorted insert → sleep_list
The alarm is inserted into the global list sorted by wake time (smallest twake at head).
4. condition_wait(&alarm.woken)
Thread suspends here. Interrupts are managed carefully — see next slide.
5. Timer ISR fires
handle_timer_interrupt() walks the list, calls condition_broadcast for all expired alarms.
6. Thread resumes
Woken thread re-checks while(rdtime() < twake). If time has passed, returns from alarm_sleep_until.
Designing struct timer_alarm
struct timer_alarm { // YOU define this� unsigned long long twake; // wake time in ticks� struct condition woken; // CV from CP3� struct timer_alarm *next; // linked list ptr�};�
Why allocate on the stack, not the heap?
The sleeping thread cannot return from alarm_sleep_until until the alarm fires. Therefore the alarm struct's lifetime is exactly the function call's lifetime — perfectly safe on the stack. This avoids malloc/free overhead and eliminates the possibility of memory leaks if a thread is killed.
Why sorted by twake?
The ISR can stop scanning as soon as it finds the first unexpired alarm (since all later ones are also unexpired). The head's twake also directly tells you when to program the next interrupt — no scan needed.
slide 9 / 14
Implementing timer_init(freq)
Called once at boot. Sets up frequency constants, triggers the first interrupt, unmasks timer IRQs.
1
Store the frequency
Save freq into a global (e.g. timer_frequency). All future time arithmetic — converting milliseconds to ticks, computing bolt period — depends on knowing how many timer ticks happen per second.
timer_frequency = freq;
2
Compute the bolt period
The "bolt" is a periodic heartbeat used for preemptive scheduling (coming in later checkpoints). BOLT_FREQ is defined as 50 Hz. Dividing freq by BOLT_FREQ gives the number of ticks between bolts.
bolt_period = freq / BOLT_FREQ; // BOLT_FREQ=50
3
Set the initialized flag
Set timer_initialized = 1 so other subsystems can check whether the timer is ready before calling timer functions.
timer_initialized = 1;
4
sbi_set_timer(0) — fire immediately
Passing 0 means the timer fires as soon as possible. This bootstraps the interrupt chain. The first ISR call to handle_timer_interrupt will then schedule subsequent interrupts.
sbi_set_timer(0); // trigger first interrupt
5
enable_timer_interrupts()
Unmasks timer interrupts in the CPU's interrupt enable register. Without this, the timer compare register may be set but the CPU will ignore the interrupt. Call this AFTER sbi_set_timer to avoid missing the first tick.
enable_timer_interrupts();
The bolt guarantees preemptive scheduling fires ~50× per second even with zero pending alarms. Without it, timer interrupts would stop as soon as sleep_list empties.
slide 10 / 14
alarm_sleep_until — The Interrupt Discipline
The trickiest part of CP4: safely inserting into sleep_list while ensuring the ISR can still fire to wake you
void alarm_sleep_until(ull twake) {� �}�
Why check rdtime() first?
If the wake time is already past (e.g., from a very short sleep), skip all the locking and condition variable machinery. Fast path — avoids unnecessary lock/unlock overhead.
Phase 1 — disable timer interrupts only
We need the ISR to not run while inserting into sleep_list, or it could see a half-linked alarm. But we don't disable ALL interrupts yet — that would prevent condition_wait from working.
Phase 2 — the atomic swap
Disable ALL interrupts, then immediately re-enable timers. Now: list is consistent (timer IRQ was off during insert) AND the ISR can fire during sleep (timer re-enabled). condition_wait re-enables interrupts when it yields.
Why while, not if?
condition_broadcast wakes ALL waiters across ALL conditions. Another alarm firing might wake you before your time arrives. Re-check rdtime() on every wakeup — the while loop handles all spurious wakeups.
slide 11 / 14
handle_timer_interrupt — The ISR
Called on every timer interrupt. Wakes expired alarms, then schedules the next interrupt (bolt or earliest alarm).
void handle_timer_interrupt() {� ________________� while (p && p->twake <= tnow) {� ________________� }
�� // schedule next interrupt� _________________� sbi_set_timer(MIN(____, ____));�}�
Why condition_broadcast, not condition_signal?
signal wakes only ONE waiter. Multiple alarms could expire at the same tick. broadcast wakes all threads waiting on that alarm's condition variable — for a single alarm there's only one waiter, but broadcast is correct regardless.
Why can't the ISR call condition_wait?
condition_wait suspends the CURRENT thread. The ISR runs in interrupt context — there is no 'current thread' to suspend. Calling wait here would corrupt the scheduler. broadcast is safe: it only moves waiters to the ready queue without yielding.
The two-value next-interrupt decision
talarm: earliest sleeping thread's wake time (or ULLONG_MAX if none). tbolt: next 50 Hz heartbeat tick. We program MIN(talarm, tbolt) so neither a sleeping thread nor the scheduling heartbeat is missed.
What if sleep_list is empty?
talarm = ULLONG_MAX. MIN(tbolt, ULLONG_MAX) = tbolt. Even with no sleeping threads, the bolt ensures the ISR fires at 50 Hz for preemptive scheduling in later checkpoints. The chain never stops.
slide 12 / 14
🐛 Bug Hunt: The memcpy Argument Swap
A single line is wrong in each function. The bug is invisible at pos=0, crashes at large pos, and is surprisingly easy to write.
memcpy(dest, src, n) — the argument order is everything
memio_fetch:
✓ memcpy(buf, mio->buf + pos, len);
✗ memcpy(buf + pos, mio->buf, len);
The offset (+ pos) goes on mio->buf — always.
memio_store:
✓ memcpy(mio->buf + pos, buf, len);
✗ memcpy(mio->buf, buf + pos, len);
The offset (+ pos) goes on mio->buf — always.
slide 13 / 14