Tour 37 · Devices and putting it all together · about 37 minutes · 19 steps
The shell has printed $ and is waiting. You press l, then s, then Enter. Within a
fraction of a millisecond each letter appears on your screen, and after Enter the shell
has the line "ls\n" in its buffer. No program put those letters on the screen: the
kernel did, from inside an interrupt handler, before any process had seen them.
This tour follows those three keystrokes from the wire into the shell’s memory: the
UART raising its interrupt line, the PLIC handing it to one of the
three harts, uartintr pulling the byte out of the chip, and
consoleintr echoing it and editing the line under cons.lock. Then the line
completes, wakeup finds the sleeping shell, and consoleread copies
the bytes out, one per system call.
The input side of the console is a small producer/consumer problem. The producer is an
interrupt handler that may run on any hart at any moment; the consumer is a
process that may run on a different hart. One spinlock and the
sleep/wakeup protocol keep them from losing a byte or a wakeup. Output
through write is the subject of Tour 5: Life of a system call and Tour 38: Output to the console from three harts; here we only print the echo.
Best after: 5. Life of a system call, 9. Device interrupts and the PLIC, 16. sleep and wakeup, and the lost-wakeup problem
The machine has three harts. When the tour starts:
| Hart | What it is doing |
|---|---|
| 0 | Running the shell sh (pid 2), which has just printed $ : the process this tour follows |
| 1 | Idle in its scheduler, waiting in wfi |
| 2 | Idle in its scheduler, waiting in wfi |
init (pid 1) is asleep in kwait. This is the first command typed since boot,
so the console’s input buffer is empty and its three indices r, w and e are all 0.
Step 1 of 19
getcmd writes the prompt to file descriptor 2 (standard error, which is the
console, like 0 and 1). That write is the journey of Tour 5: Life of a system call. Then it clears the
100-byte buf and calls gets to fill it with one line.
The shell has no idea a keyboard exists. Its descriptor 0 is the console device
that user/init.c opened at boot, and every process inherited it. For the shell, a
line of typing is whatever read(0, …) returns.
If gets comes back with an empty buffer, getcmd returns -1 and the shell exits. You
will see how the kernel produces that “empty” answer when we reach control-D.
Step 2 of 19
gets calls read(0, &c, 1): one byte per system call. For "ls\n" that is
three read calls, each a full trip into the kernel and back.
Why not ask for 100 bytes at once? Because gets must stop exactly at the newline, and
it cannot know where that is in advance. For the console this costs little, because
the kernel hands back at most one line per read anyway (you will see why). For a
file or a pipe it would matter: reading one byte at a time is the simplest way to
never consume bytes that belong to the next line.
The read stub puts SYS_read (5) in a7 and executes ecall
(user/usys.S:24). From here to sys_read the path is the one in Tour 5: Life of a system call.
If read returns less than 1 (0 at end of file, -1 on error), gets stops.
ld sp, 8(a0) in uservec (kernel/trampoline.S:76) when the shell executed ecallStep 3 of 19
sys_read fetched its arguments and found ofile[0], the shell’s console
open file (struct file). fileread sees FD_DEVICE with major number 1 (CONSOLE) and
calls devsw[1].read, a function pointer that consoleinit set to
consoleread at boot (kernel/console.c:201).
The first argument, 1, says “the destination is a user address”. The same driver
function can be called with 0 and a kernel address; here it is the address of the
shell’s local variable c in gets.
No lock is held, and interrupts are on, as they are for most of every system call.
Hart 0’s sp now points into the shell’s kernel stack (The stacks of xv6),
the page that belongs to its process slot (slot 1, at 0x3fffffb000 in this build; its
top is 0x3fffffc000). It was empty when the shell executed ecall; uservec loaded its
top (kernel/trampoline.S:76), and it now holds usertrap, syscall, sys_read
and fileread. The shell’s user stack, with gets’s c on it, waits unchanged.
acquire found SIE on (the system call ran intr_on in usertrap), so it saved intena 1 (Locks and interrupt state)cons.lockStep 4 of 19
consoleread's first act is acquire(&cons.lock) (kernel/console.c:95), so
look at what that lock protects. cons is one global structure: a 128-byte circular
buffer and three ever-increasing indices.
| Index | Meaning | Advanced by |
|---|---|---|
r |
next byte a reader will take | consoleread |
w |
end of the text that is committed (a whole line, readable) | consoleintr, at Enter |
e |
end of the text typed so far, still editable | consoleintr, at every key |
Always r ≤ w ≤ e. Bytes between r and w belong to readers; bytes between w and
e are a line still being edited, which backspace may take back. The indices are
uints that only grow; % INPUT_BUF_SIZE turns them into array positions, the same
trick the pipe uses (Tour 39: Pipes).
Right now r = w = e = 0: nothing typed. And cons.lock is a spinlock, so
hart 0 now has interrupts off. The irq strip shows how: acquire’s push_off
found SIE on (the system call turned it on in usertrap), so it recorded
intena = 1 and set noff to 1; the release will turn interrupts back on
(Locks and interrupt state). Remember that 1: in step 9 an interrupt handler takes the
same lock and records a 0.
killed and sleep_prepare each take p->lock for a moment: noff 2, then back to 1; line 105 drops it to 0 and turns SIE back oncons.lockStep 5 of 19
cons.r == cons.w: no committed line. The shell must wait, and it does so with the
two-step sleep of this version of xv6 (sleep and wakeup):
kkill both sets killed and makes a sleeping victim
RUNNABLE, so a killed shell gets here again promptly.sleep_prepare(&cons.r) records “I am waiting on channel &cons.r” in
p->chan, while cons.lock is still held.cons.lock (line 105) and call sleep (line 106).The channel is the address of cons.r. Any unique address would do; this one says
what is being waited for: r to become different from w.
killed (line 100) and sleep_prepare each take the shell’s p->lock for a moment
inside the cons.lock critical section. That nesting, cons.lock then p->lock, is the same
order consoleintr uses when it calls wakeup, so the two can never deadlock
(Tour 18: Lock ordering: how xv6 avoids deadlock).
p->lock, as sched demands; the 1 came from SIE being on again after line 105, and sched saves it across swtch (Locks and interrupt state)sh's p->lockStep 6 of 19
sleep takes the shell’s p->lock. p->chan is still &cons.r (no key yet), so it
marks the shell SLEEPING and calls sched, which switches to hart 0’s
scheduler thread. sched insists that p->lock is the only lock held
(noff == 1, kernel/proc.c:487). That is why cons.lock had to be released first:
a hart that slept holding cons.lock would leave every keystroke handler spinning
forever.
The shell is now frozen inside consoleread, its kernel stack preserved. It occupies
no hart. Hart 0’s scheduler releases the shell’s p->lock, finds nothing RUNNABLE,
and executes wfi, like the others.
All three harts are idle. The machine is waiting for you.
Here is what “frozen” means. swtch stores the shell’s sp and ra in its
p->context and loads hart 0’s scheduler sp (kernel/swtch.S:26). The shell’s
frames stay exactly where they were:
sh's kernel stack (slot 1) hart 0's scheduler stack (stack0, slice 0)
top ─► usertrap top ─► start, main (from boot)
syscall scheduler ◄─ sp, after swtch
sys_read
fileread
consoleread
sleep
sched ◄─ saved in sh's p->context.sp
Nothing runs on the shell’s kernel stack until some scheduler swtches back to it.
stack0hart 2’s slice of stack0, with a kernelvec frame on topStep 7 of 19
Your terminal sends the byte 0x6c (l). QEMU’s emulated UART puts it in its receive
FIFO, sets bit 0 of LSR (“data ready”), and, because uartinit enabled receive
interrupts, raises its interrupt line, IRQ 10 (UART0_IRQ). The PLIC has
that IRQ enabled for every hart (plicinithart), so it signals a supervisor external
interrupt to all three.
All three are in wfi with interrupts off. wfi still wakes up, because the RISC-V
spec says it resumes when a locally enabled interrupt is pending, whatever the global
SIE bit says. Each woken hart goes round its loop to intr_on() (kernel/proc.c:441).
The first to get there takes the trap; a hart that arrives after that hart’s claim has
lowered the signal takes no trap at all and just rescans (often only one hart traps,
Tour 9: Device interrupts and the PLIC). Here hart 2 gets there first: kernelvec, kerneltrap
(Tour 8: Traps taken inside the kernel), and devintr.
scause is 0x8000000000000009, supervisor external interrupt. plic_claim asks
the PLIC which device: 10, the UART. So uartintr runs, on a hart that has no
process at all. Interrupt handlers serve the machine, not whoever happens to be
running.
Which stack does the handler run on? xv6 has no separate interrupt stack
(The stacks of xv6): kernelvec pushes a 256-byte frame of saved registers onto whatever
stack sp points into (kernel/kernelvec.S:14). So a keystroke lands on whichever
stack the claiming hart happened to be using:
| The hart was… | Trap path | The handler runs on |
|---|---|---|
idle in scheduler (this tour) |
kernelvec → kerneltrap |
that hart’s scheduler stack, above scheduler’s frame |
| in the kernel for a process, interrupts on | kernelvec → kerneltrap |
that process’s kernel stack, above its system-call frames |
| running a program in user mode | uservec → usertrap |
that process’s kernel stack, empty until uservec’s ld sp |
Here all three harts are idle, so it is the first case:
hart 2's scheduler stack (its 4 KiB slice of stack0, top 0x8000a890)
top ─► start, main (from boot)
scheduler interrupted in its intr_on()/intr_off() window
kernelvec frame 256 bytes: the interrupted code's registers
kerneltrap
devintr ◄─ sp
In a gdb run of this build that typed ls | wc at an idle machine, all eight keystrokes
were handled this way, on harts 0, 1 and 2, and sp on entry to consoleintr was always
496 bytes below the top of that hart’s slice (0x8000a6a0 on hart 2), 528 once
consoleintr had pushed its own 32-byte frame.
stack0hart 2’s slice of stack0, with a kernelvec frame on topStep 8 of 19
The UART has one interrupt line for both directions, so uartintr does both jobs
every time:
ISR is the “acknowledge” the comment means, but on a 16550 that read only
clears a transmitter-empty request; a received-data request goes away only when
the receive FIFO is emptied, below.wakeup(&tx_chan) wakes any writer waiting in
uartwrite (Tour 38: Output to the console from three harts). Nobody is writing now, so this visits all 64 process
slots and finds no one asleep on it. It is harmless, and the simplest correct thing: the handler
cannot cheaply tell why it was called.uartgetc checks LSR bit 0 and, while a byte is waiting, reads
RHR, which removes it from the chip’s FIFO. Each byte goes to consoleintr.The loop drains the FIFO, so if you typed two keys faster than the interrupt was
handled, one interrupt delivers both. Here it delivers one: 0x6c.
After uartintr returns, devintr calls plic_complete(10). Until then the
PLIC will not signal IRQ 10 again, which is why a second keystroke can never start a
second uartintr on another hart while this one runs.
One comes almost at once anyway: the echo in consoleintr wrote THR, and since
uartinit enabled transmit interrupts, the UART raises a “transmitter empty”
request when that byte has gone. The PLIC holds it until plic_complete(10) and then
signals again. That second uartintr, on whichever hart claims it, finds no input:
its ISR read clears the transmit request, it runs the wakeup(&tx_chan) scan, and
returns. In a gdb run of this build, every keystroke caused exactly two uartintr
calls.
stack0hart 2’s slice of stack0, with a kernelvec frame on topcons.lock as in step 4, but taken inside a trap handler with SIE already off, so intena is 0 (Locks and interrupt state)cons.lockStep 9 of 19
consoleintr takes cons.lock. l is not a control character, so it reaches the
default case:
c != 0 and e - r < 128: there is room. A full buffer drops the key silently.\r would become \n (Enter, in a moment).consputc echoes it: the l you see on screen comes from here, not from any
program. That is line editing (cooked input): the kernel shows you what you are typing even
though nobody has read it yet.cons.buf[0] = 'l', and e becomes 1.w does not move. The l is typed but not committed, so no wakeup: the shell sleeps
on. Apart from control-D and a full buffer (next step), a reader never sees half a
line, which is what lets backspace work.
Compare the irq strip with step 4. Same lock, but this acquire ran inside a trap
handler, where SIE was already off, so it recorded intena = 0, and the release on
line 189 will leave interrupts off, as kerneltrap needs. And because SIE is
always off while noff > 0, this interrupt could never have landed on hart 0 while
the shell held cons.lock there: a handler never spins on a lock held by the code it
interrupted (Locks and interrupt state, Locks and interrupt state).
stack0hart 2’s slice of stack0, with a kernelvec frame on topcons.lock plus the push_off in uartputc_sync (Locks and interrupt state)cons.lockStep 10 of 19
consputc sends the byte with uartputc_sync: spin until LSR says the
transmitter can take a byte, then write THR.
Why spin rather than sleep like uartwrite? Because there is nothing to put to
sleep. This code runs in an interrupt handler, on whatever stack the hart was using
(here the scheduler’s; often an unrelated process’s kernel stack), with cons.lock
held. sleep would need
a process of its own and would have to drop every spinlock first. A byte takes about a
quarter of a millisecond at 38,400 baud on real hardware, and much less on QEMU, so
spinning is the cheap option.
push_off here nests: acquire(&cons.lock) already did one (noff 1), this makes
2, and pop_off brings it back to 1. Interrupts stay off throughout
(Locks and interrupt state).
Note what the echo does not take: tx_lock, the sleep-lock that orders write
output. So your echoed letters can land between the bytes of another process’s
output. Tour 38: Output to the console from three harts shows that happening.
stack0hart 1’s slice of stack0, with a kernelvec frame on topcons.lockStep 11 of 19
You press s. This time hart 1 claims the interrupt, and the same path stores
buf[1] = 's', e = 2, and echoes s. Which hart handles a key does not matter: they
all share cons, and cons.lock serializes them.
Had you mistyped, this is where it would be fixed, before any program sees it:
^H, or the Delete key, 0x7f, which most terminals send): if
e != w, step e back one and erase the character on screen with \b, space,
\b. The test e != w stops it at the start of the uncommitted text: a line already
handed to a reader cannot be taken back.e reaches w or the character
before e is a newline.Neither touches r or w, so neither needs to wake anyone. Erasing is cheap because
the line is still entirely the kernel’s.
stack0hart 2’s slice of stack0, with a kernelvec frame on topcons.lockStep 12 of 19
You press Enter. The terminal sends \r (13), carriage return; our trace of a real run
shows exactly 108, 115, 13 arriving. Hart 2 claims it.
Line 171 turns \r into \n, so programs see Unix newlines. The echo moves the
cursor to the next line, buf[2] = '\n', e = 3. Then line 179: a newline completes
the line, so w = e = 3. The three bytes l, s, \n are now committed, and
wakeup(&cons.r) tells anyone waiting on that channel.
Two other conditions also commit: control-D (end of file, below) and a full buffer
(e - r == 128). The full-buffer rule makes sure a reader can always drain the
buffer, so a 200-character line cannot jam the console forever.
stack0hart 2’s slice of stack0, with a kernelvec frame on topcons.lockeach p->lock in turnStep 13 of 19
wakeup visits all 64 slots of the process table. For each it takes p->lock (so
hart 2 now holds two spinlocks, cons.lock then a p->lock) and compares p->chan
with &cons.r.
The shell matches. Its chan is cleared and, since it is fully SLEEPING, it becomes
RUNNABLE. A waiter can be in one of three states when the wakeup comes, and each is
handled:
| Shell’s state | What wakeup does | What happens next |
|---|---|---|
| not registered yet | nothing | it will check r != w under cons.lock and not sleep |
| registered, still running | clears chan |
its sleep() returns at once |
SLEEPING |
clears chan, sets RUNNABLE |
a scheduler will run it |
Then consoleintr releases cons.lock, uartintr finds no more bytes, and hart 2
returns from the trap into its scheduler loop.
stack0hart 0’s slice of stack0ld sp, 8(a1) in swtch (kernel/swtch.S:26), when the shell slept in step 6p->lock after intr_off() (line 442), so intena 0; after swtch, sched gives the shell back its own saved intena 1sh's p->lockStep 14 of 19
The shell is RUNNABLE, but no hart is told. Schedulers are not notified; each one
notices on its next scan of the table. Hart 2 will scan as soon as it returns from the
trap. Hart 0 scans whenever its wfi ends, on its next timer tick (about every
0.1 s) or on an external-interrupt signal like the one that just went to all harts.
And one such signal is due: Enter’s echo went out before the wakeup, so its
“transmitter empty” interrupt re-signals all harts right after plic_complete, just
after the shell became RUNNABLE. That is a likely reason an idle hart picks the
shell up within microseconds rather than at the next tick.
In this telling hart 0 gets there first: it takes the shell’s p->lock, sees
RUNNABLE, marks it RUNNING, and switches to it (Tour 12: One scheduler per hart). The shell
resumes inside sleep on hart 0, and since sleep releases p->lock on the way
out, it returns into consoleread with no locks. In our traced runs the shell
resumed on hart 1 in one run and hart 2 in another: which hart wins is a race, and it
does not matter.
On the stacks, the switch at line 453 is the mirror of step 6. swtch saves hart 0’s
scheduler sp in cpus[0].context and loads the shell’s saved sp
(kernel/swtch.S:26), which points into the shell’s kernel stack at sched’s frame,
exactly where step 6 left it. Had hart 1 won the race instead, the same kernel stack would
simply have been resumed from hart 1: the stack belongs to the shell’s process slot, not to a hart.
ld sp, 8(a1) in swtch (kernel/swtch.S:26), in hart 0’s schedulersched restored the shell’s intena 1, sleep’s release turned SIE on, and line 107’s acquire recorded 1 againcons.lockStep 15 of 19
Back in consoleread, the shell retakes cons.lock (line 107) and checks again.
It always re-checks, because a wakeup only means “something may have changed”. Now
r = 0, w = 3, so it takes buf[0], l, and r becomes 1.
either_copyout with user_dst = 1 calls copyout, which walks the shell’s page
table in software and stores the byte into c on the shell’s user stack
(Tour 28: Crossing the user/kernel boundary in memory). This happens with cons.lock held and interrupts off. That is allowed
because copyout never sleeps: at worst it allocates a page with kalloc, which
only takes another spinlock.
n drops to 0, so the loop ends after one byte (had gets asked for more, it would
have stopped at the \n instead, line 129). consoleread releases the lock and
returns 1. Tour 5: Life of a system call's return path carries the 1 back to user mode.
ld sp, 48(a0) in userret (kernel/trampoline.S:118) and sret (kernel/trampoline.S:153)Step 16 of 19
gets stores l and loops. The second read finds r = 1 < w = 3 at once,
takes s, returns 1. The third takes \n, returns 1, and gets stops at the
newline. buf is now "ls\n" followed by a 0 byte.
So typing one command cost: six UART interrupts (three bringing your bytes, and three
more raised when the transmitter finished sending each echo), each with a fruitless
64-slot wakeup(&tx_chan) scan, three echoes, three read system calls, and exactly
one sleep and one wakeup of the shell.
For a longer command the arithmetic is the same: ls | wc plus Enter is 8 bytes, so
8 one-byte read calls: the first sleeps until Enter commits the line, and the other
seven return at once.
On the way out, userret loaded the shell’s user sp from the trapframe
(kernel/trampoline.S:118). From that instruction until sret the hart is still in
supervisor mode, which cannot use user pages, so it has no usable stack; sret makes
the user stack usable. After it, the shell’s kernel stack holds
nothing at all, and each of the next two reads starts it again from the top.
ld sp, 8(a1) in swtch (kernel/swtch.S:26): the read slept until ^D committed the line, and a scheduler switched back to the shellcons.lockStep 17 of 19
A keyboard has no end, so the console needs a way to say “end of file”. In xv6 it is
control-D (byte 4). consoleintr stores it like a character and commits the line
at once (kernel/console.c:179), even with no newline.
When consoleread meets it:
n == target), the ^D is consumed
and read returns 0, the end-of-file answer.r-- puts the ^D back, so this read returns
just those bytes and the next one returns 0.Type hi then control-D to cat: its read(0, buf, 512) returns 2, the next returns
0, and cat ends. To the shell, which reads one byte at a time, control-D at the start
of a line gives gets an empty buffer; getcmd returns -1, the shell exits, and
init prints init: starting sh and starts a new one (user/init.c).
stack0hart 2’s slice of stack0, with a kernelvec frame on topprintk calls; inside each one pr.lock makes 2 and each character’s uartputc_sync makes 3, tying the deepest nesting in the kernel (Locks and interrupt state)cons.lockStep 18 of 19
Control-P (byte 16) never reaches cons.buf. consoleintr calls procdump
straight from the interrupt handler, still holding cons.lock. That is why it works
even when every process is stuck: it needs no process and no scheduler. From one of
our runs:
1 sleep init
2 sleep sh
procdump reads p->state and p->name without p->lock, on purpose (its
comment says so): a debugging tool for a wedged machine must not wait for a lock a
stuck process may hold. The price is that a line can be slightly stale.
Each printk takes pr.lock (Tour 38: Output to the console from three harts), so during each call hart 2 holds
cons.lock then pr.lock. Between the two calls of each line (line 700, then 701), it
holds only cons.lock. Inside each character, uartputc_sync's own push_off
adds a third level: noff 3, tying the deepest nesting in the kernel, and the only place
cons.lock → pr.lock appears in the lock order (Locks and interrupt state,
Locks and interrupt state).
This is the deepest call chain in the tour, and it sits on hart 2’s scheduler stack:
kernelvec’s frame, then kerneltrap, devintr, uartintr, consoleintr, procdump,
printk and its helpers. Measured under gdb in this build, the deepest point is 960 bytes
below the top, nearly a quarter of the slice’s 4096 bytes. The margin matters: a stack0 slice has no guard page, so
an overflow would silently run into hart 1’s slice below it.
ld sp, 48(a0) in userret (kernel/trampoline.S:118) and sret (kernel/trampoline.S:153), at the end of the last readStep 19 of 19
getcmd returns 0. The shell skips leading blanks, sees that the line is not cd,
forks a child to parse and run it, and waits. What follows (fork, exec, and for
ls | wc a pipe) is Tour 40: Capstone: the shell running ls | wc.
Look back at what made this work across three harts:
cons.lock covers buf, r, w and e, for
the interrupt handler and the reader alike, on whichever harts they run.consoleintr echoes by spinning, with
interrupts off, because it has no process to put to sleep.sleep_prepare inside cons.lock, wakeup inside
cons.lock: no lost wakeup, and the reader always re-checks.The shell slept on hart 0 and woke on whichever hart scanned first; your keys were handled on harts 2, 1 and 2. None of that changed what the shell read.
Tour 37 · wrap-up
| Lock | Taken in | Protects |
|---|---|---|
cons.lock (spinlock) | consoleread, consoleintr | The input buffer cons.buf and its indices r, w, e |
p->lock (spinlock) | sleep_prepare, sleep, wakeup, the schedulers | p->chan and p->state: who is waiting on what, who may run. When both are held, cons.lock comes first, never the reverse |
pr.lock (spinlock) | printk, via procdump on control-P | Keeps one printk call’s output together; taken after cons.lock |
tx_lock (no: not taken by the echo) | uartputc_sync | The echo bypasses the writers’ sleep-lock, since an interrupt handler cannot sleep; echoed keys can land inside other output |
PLIC claim (no kernel lock) | plic_claim, plic_complete | The PLIC hands each pending IRQ to one claimer and will not re-signal it until completion |
process table in procdump (no lock) | procdump | Deliberately unlocked so control-P works even on a wedged machine; output may be stale |
The shell calls sleep_prepare(&cons.r) before releasing cons.lock. Describe an interleaving that would lose your Enter if it registered after releasing the lock instead.
Hart 0 sees r == w and releases cons.lock. Before it registers, hart 2 handles Enter, sets w = 3 and calls wakeup(&cons.r), which finds no one registered. Hart 0 then registers and sleeps; no further wakeup comes until another line is committed, so the shell sits asleep with your complete line in the buffer.
Why does consoleintr echo with uartputc_sync (spinning) instead of uartwrite (sleeping)?
It runs inside an interrupt handler, often with no process of its own, and with cons.lock held. Sleeping needs a process and must not happen while holding a spinlock other than p->lock, so the only option is to spin for the transmitter.
You type lx, press backspace, then s and Enter. Which bytes does the shell’s read see, and why could backspace not erase a character from a previous line?
It sees l, s, \n: backspace moved e back over the uncommitted x. Backspace only acts while e != w; once Enter set w = e, those bytes belong to readers and cannot be taken back.
Two keystroke interrupts arrive in quick succession. Can consoleintr run on hart 1 and hart 2 at the same moment? What prevents a problem if it could?
Not for the UART: the PLIC does not signal IRQ 10 again until plic_complete, after uartintr has drained the FIFO. Even if two calls overlapped, cons.lock would serialize their updates of buf and e.
gets reads one byte per system call. Why does the shell’s first read sleep, but the next two return immediately?
The first read starts with r == w and must wait for Enter to commit the line. After that, r = 1 < w = 3, so the next reads find committed bytes under cons.lock and return without sleeping.
You press control-D at the start of the line while cat is reading the console, and at the start of a line while the shell is waiting. What happens in each case?
For cat, consoleread consumes the ^D with nothing copied and returns 0, so cat’s loop ends and it exits. For the shell, gets gets 0 bytes, getcmd returns -1, the shell exits, and init starts a new shell.
Keys: ← → step · Home start