console init

1st hart calls lots of init things other 2 harts basic stuffs only

There is a console buffer we have:

The xv6 console uses a 128-byte circular buffer (cons.buf) with three indices —

  • r (read),
  • w (write), and
  • e (edit) — to decouple keyboard input from process reading. When you press a key, the UART hardware fires an interrupt that calls consoleintr, which stores the character at position e and advances it; editing keys like backspace simply move e backward without the reading process ever knowing. Characters between w and e are “in-flight” — typed but not yet committed — and become visible to consoleread only when you press enter (or Ctrl-D, or the buffer fills), at which point consoleintr sets w = e and calls wakeup. On the other side, consoleread runs in process context, sleeps while r == w (nothing to read), and when woken copies characters from position r forward into the user’s buffer. A spinlock protects the struct because consoleintr runs from interrupt context and consoleread runs from process context, and on a multi-hart system these can overlap. This three-index design is what gives xv6 line-at-a-time editing — the reader only sees complete lines, and everything before enter is editable in-place.

You’re right, I should have. Both functions use kernel stack buffers to cross the user/kernel boundary, and they do it differently.

In consolewrite, it’s a 32-byte array. It copies a batch of up to 32 bytes from userspace into this buffer, sends them to the UART, then loops. Batching makes sense here because the process might be writing a long string and you don’t want to call either_copyin for every single character.

In consoleread, it’s a single char:

char cbuf;

One byte. It pulls a character from the circular buffer into c, puts it into cbuf, then calls either_copyout to copy that one byte to userspace. One byte at a time. No batching.

Why the asymmetry? Because consoleread has to inspect every character individually — it needs to check if it’s a newline (stop reading, return the line), if it’s Ctrl-D (handle end-of-file logic), and count bytes delivered. It can’t just grab 32 characters blindly because any one of them might be a special character that changes what happens next.

consolewrite doesn’t care about the content. Bytes are bytes — just shove them to the UART. So batching is safe and efficient.

Both buffers serve the same purpose though — they’re the kernel-side landing zone for data crossing the userspace/kernel boundary. The page tables are different on each side, so you can’t just pass a pointer across. You need a kernel address to copy into or out of, and a local variable on the kernel stack is the simplest way to get one.

Note: this cbuf is in stack of that that kernel function cool right?

This is the lowest level of the console stack — the driver that talks directly to the UART hardware chip. Everything above (console, printf) eventually comes through here to send or receive bytes.

The hardware

The UART is a 16550a — a classic serial port chip that’s been around since the 1980s. It’s memory-mapped at address UART0 (0x10000000 on QEMU’s virt platform). It’s not accessed through special instructions — you just read and write to specific memory addresses and the hardware responds. Each register is one byte at a fixed offset from the base address. The ReadReg and WriteReg macros cast these addresses to volatile unsigned char * so the compiler doesn’t optimize away the reads and writes.

The chip has a handful of registers: THR (you write a byte here and the UART transmits it), RHR (you read from here to get an incoming byte), LSR (status bits telling you if it’s ready to send or has received something), IER (controls which events generate interrupts), FCR (controls the small hardware FIFOs), and LCR (configures word length, parity, baud rate).

uartinit

Configures the chip from scratch. It disables interrupts first (so nothing fires mid-setup), enters a special baud rate configuration mode by setting a bit in LCR, writes the baud rate divisor (38400 baud) across two registers, exits that mode and sets 8-bit words with no parity, resets and enables the FIFOs, then enables both transmit and receive interrupts. After this, the chip is live — it can send bytes and will fire interrupts when it receives one or finishes sending one.

The two output paths

uartputc_sync is the dumb, reliable path. It spins in a tight loop reading the LSR register until the transmit holding register is empty, then writes the byte to THR. The CPU does nothing useful while waiting — it just polls. This is wasteful but it works everywhere, including inside interrupt handlers and during early boot before sleep/wakeup exists. The push_off/pop_off calls disable interrupts while it runs, preventing a deadlock where a UART interrupt tries to do something while we’re mid-send. The panic checks make the function freeze forever if another hart has panicked — so the panic message stays readable and doesn’t get overwritten.

uartwrite is the smart path. It sends bytes one at a time but instead of busy-waiting, it uses the interrupt system. It writes a byte to THR, sets tx_busy = 1, and then on the next byte it hits the inner loop — if tx_busy is still 1, the UART hasn’t finished sending yet, so the process sleeps. When the UART finishes transmitting, it fires an interrupt, uartintr runs, sees LSR_TX_IDLE, sets tx_busy = 0, and calls wakeup. The sleeping process wakes up and sends the next byte. This way the CPU can run other processes while waiting for the slow serial hardware.

The input path

uartgetc just checks if a byte is available (LSR has the RX_READY bit set) and reads it from the receive register. Returns -1 if nothing is waiting.

uartintr is the interrupt handler — called by the trap handler when the UART fires an interrupt. It does two things. First, it checks if the UART finished transmitting (and if so, wakes up any sleeping writer). Second, it loops calling uartgetc to drain all available input characters, passing each one to consoleintr — which is where they enter the circular buffer we talked about earlier.

The full chain

For output: process → consolewriteuartwrite → THR register → wire → terminal. Or: kernel printfconsputcuartputc_sync → THR register → wire → terminal.

For input: keyboard → wire → RHR register → UART interrupt → uartintruartgetcconsoleintr → circular buffer → consoleread → process.

The whole file is about 130 lines. That’s the entire hardware driver for serial I/O. It configures the chip, provides two ways to send (blocking and interrupt-driven), one way to receive (interrupt-driven), and handles interrupts. Everything else in the console stack is built on top of these functions.

In xv6’s case, there is no keyboard. Not in the way you’re thinking.

When you run xv6 on QEMU, you’re typing into a terminal emulator — your regular terminal app (Terminal, iTerm, whatever). QEMU connects that terminal to the virtual machine’s UART serial port. So when you press a key, the path is:

Your keyboard → your host OS → your terminal app → QEMU → UART chip (emulated) → xv6

From xv6’s perspective, it has no idea a keyboard exists. It just sees bytes arriving on a serial port. The UART’s receive register has a byte in it, the chip fires an interrupt, and xv6 reads it. Whether that byte came from a keyboard, a script piping text into QEMU, or another machine connected over an actual serial cable — xv6 can’t tell and doesn’t care.

This is how things worked historically too. Early Unix machines often didn’t have a keyboard directly attached to the CPU. They had serial terminals — separate devices (like the VT100) with their own keyboard and screen, connected to the computer by a serial cable. The computer’s UART received bytes from the terminal and sent bytes back to it. The terminal was responsible for taking keystrokes and turning them into bytes on the wire, and taking bytes from the wire and rendering characters on screen.

QEMU is simulating exactly this setup. Your terminal emulator plays the role of the VT100. The emulated UART plays the role of the serial port. xv6 is written as if it’s talking to a serial terminal, because that’s the simplest possible I/O model — bytes in, bytes out, one at a time, through a single chip.

A real modern computer with a physical keyboard uses a completely different path — USB HID protocol, a USB host controller, a much more complex driver stack. xv6 avoids all of that by pretending it’s 1978 and everything is a serial port.

printfinit is one line:

initlock(&pr.lock, "pr");

It initializes the spinlock inside the pr struct. That’s all it does.

The reason it exists as a separate function is about when it gets called. In main():

consoleinit();
printfinit();
printf("xv6 kernel is booting\n");

consoleinit sets up the UART hardware so bytes can physically reach the terminal. printfinit sets up the lock so multiple harts don’t garble each other’s output. Both have to happen before the first printf.

Why the lock matters

Say hart 0 is printing "hello" and hart 1 is printing "world" at the same time. printf sends characters one at a time through consputc. Without the lock, the outputs interleave and you get "hweolrlold" or worse. The lock ensures one hart finishes its entire printf call before another can start.

Look at how printf uses it:

if(panicking == 0)
    acquire(&pr.lock);
// ... print everything ...
if(panicking == 0)
    release(&pr.lock);

The panicking check is important. When panic runs, it sets panicking = 1 before calling printf. This makes printf skip the lock entirely. Why? Because the hart that’s panicking might already hold pr.lock from a previous printf call that was interrupted. If panic’s printf tried to acquire the same lock, it would deadlock — spinning forever waiting for itself to release a lock it can’t release because it’s stuck spinning. So during a panic, you just print without the lock and accept that the output might be messy. Getting the panic message out is more important than clean formatting.

The rest of the file

printf itself is a minimal format string parser. It walks the format string character by character, and when it hits % it looks ahead to figure out the specifier — %d, %s, %x, %p, %ld, %lld, etc. For integers it calls printint, which divides repeatedly by the base (10 or 16), builds digits in reverse into a stack buffer, then prints them. For pointers it calls printptr, which prints all 16 hex digits including leading zeros. For strings it loops through each character. Every single character ultimately goes through consputcuartputc_sync — the synchronous, blocking, works-anywhere path.

panic is the kernel’s death scream. It sets panicking, prints the message, sets panicked (which makes uartputc_sync on other harts freeze forever), and enters an infinite loop. The machine is done. The two-flag system (panicking vs panicked) creates a small window where the panic message can get out before everything freezes.

a little bit about print:

xv6’s printf is the kernel’s own debug output function — not for user processes, just for the kernel itself. It’s protected by a spinlock (pr.lock, initialized by printfinit) so that when multiple harts print simultaneously, their output doesn’t interleave into garbage. The function walks the format string character by character, and when it hits a % specifier, it pulls the corresponding argument off the variadic argument list — integers go through printint which divides repeatedly by the base to build digits in reverse, pointers go through printptr which prints all 16 hex digits with leading zeros, strings get walked character by character, and single chars pass straight through. Every single character, regardless of type, goes directly to the UART hardware through consputcuartputc_sync, which busy-waits until the transmit register is empty and shoves the byte in. There’s no intermediate buffer — it’s the equivalent of O_DIRECT for console output, deliberately bypassing any buffering so that output is immediately visible even during early boot or a crash. During a panic, the lock is skipped to avoid deadlock (the panicking hart might already hold it), and after the panic message is printed, the panicked flag freezes all other harts’ UART output so the message stays readable. It’s slow, simple, and indestructible — exactly what you want for kernel diagnostics.