1 of 14

391

ECE 391 · Checkpoint 4 · Spring 2026

Memory I/O,

Timer Alarms,

& Bug Hunting

2 of 14

  • MP2CP4 testcase list posted on Piazza
  • Reminder that MP2CP3 is due next week, MP2CP4 due one week after
  • Reminder that there are style points for MP2CP3 and MP2CP4 (see handout)
  • Reminder not to talk about exam until after we release grades (conflicts, etc.)
  • We will make a Piazza post once all exams have been taken/graded which contains:
    • Exam average / standard deviation
    • How to collect your exam
    • How to submit a regrade
  • Grades will also be uploaded to Canvas once all exams have been taken
  • Also, in general each member of course staff grades a small number of questions (e.g. they might be assigned to question 5 and only grade that). You can try to ask course staff about exam questions, but there’s no guarantee that they will have the answer.

3 of 14

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

4 of 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

5 of 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

6 of 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

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

8 of 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

9 of 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

10 of 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

11 of 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

12 of 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

13 of 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

14 of 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