Case Study 29-1: A Serial Port Debug Console for MinOS

When the Display System Doesn't Work Yet

There is a period in every OS development project when the screen shows nothing useful — the VGA driver has a bug, the IDT isn't set up yet, the page tables are wrong, and you have no idea why the system is triple-faulting. The serial port is your lifeline.

The UART is available immediately after the CPU boots, requires no interrupts, needs no complex setup, and (through QEMU's -serial stdio flag) outputs directly to your host terminal. This case study builds the MinOS serial debug console, starting from scratch and adding features until it becomes a useful diagnostic tool.

Why Serial First

In the MinOS boot sequence, the serial console is initialized immediately after the kernel entry point — before the GDT is verified, before interrupts are enabled, before the memory allocator is set up. The pattern is:

kernel_main:
    ; First thing: serial console (no dependencies)
    call serial_init
    mov rdi, boot_msg
    call serial_puts
    ; Now if anything below crashes, we saw "MinOS booting..." on the serial port

    call gdt_verify         ; crashes? at least we saw the boot message
    call idt_init           ; crashes here? logged "IDT initializing..."
    call pmm_init           ; etc.

Complete MinOS Serial Debug Console

; minOS/drivers/serial.asm — Complete serial debug console
; Build with kernel: nasm -f elf64 serial.asm -o serial.o

COM1_BASE   equ 0x3F8
COM1_DATA   equ COM1_BASE + 0   ; Data register
COM1_IER    equ COM1_BASE + 1   ; Interrupt Enable Register
COM1_FCR    equ COM1_BASE + 2   ; FIFO Control Register
COM1_LCR    equ COM1_BASE + 3   ; Line Control Register (DLAB at bit 7)
COM1_MCR    equ COM1_BASE + 4   ; Modem Control Register
COM1_LSR    equ COM1_BASE + 5   ; Line Status Register
COM1_MSR    equ COM1_BASE + 6   ; Modem Status Register

section .data
    ; Hex digit lookup table
    hex_digits db "0123456789ABCDEF"

section .text

;=============================================================================
; serial_init: initialize COM1 at 115200 baud, 8N1
;=============================================================================
global serial_init
serial_init:
    ; Disable UART interrupts during setup
    xor al, al
    out COM1_IER, al

    ; Enable DLAB for divisor access
    mov al, 0x80
    out COM1_LCR, al

    ; Set divisor = 1 (115200 baud)
    mov al, 1
    out COM1_DATA, al   ; divisor low byte
    xor al, al
    out COM1_IER, al    ; divisor high byte

    ; Set 8N1, clear DLAB: 0b00_0_0_0_11 = 0x03
    mov al, 0x03
    out COM1_LCR, al

    ; Enable FIFO, clear receive/transmit, 14-byte threshold
    ; 0xC7 = 0b11_0_0_0_1_1_1
    mov al, 0xC7
    out COM1_FCR, al

    ; Enable RTS+DTR (required by some loopback configs)
    mov al, 0x0B
    out COM1_MCR, al

    ; Self-test: enable loopback mode
    mov al, 0x1E        ; loopback mode bit
    out COM1_MCR, al
    ; Send test byte
    mov al, 0xAE
    out COM1_DATA, al
    ; Read it back and check
    in  al, COM1_DATA
    cmp al, 0xAE
    jne .serial_fault   ; serial port not working

    ; Disable loopback, normal operation
    mov al, 0x0F
    out COM1_MCR, al
    ret

.serial_fault:
    ; Serial test failed — nothing we can do without serial
    ; Just return; the system will limp along without debug output
    ret

;=============================================================================
; serial_putchar: send one character, busy-wait until THR empty
; AL = character to transmit
;=============================================================================
global serial_putchar
serial_putchar:
    push rdx

    ; Convert \n to \r\n for terminal compatibility
    cmp al, 10              ; linefeed?
    jne .write
    push rax
    mov al, 13              ; send CR first
    call serial_putchar
    pop rax

.write:
    ; Wait for Transmitter Holding Register Empty (LSR bit 5)
.wait:
    in  al, COM1_LSR        ; read Line Status Register
    test al, 0x20           ; bit 5 = THRE
    jz  .wait

    ; Restore character (was in AL from the push/pop above, but we clobbered it)
    ; Note: caller put char in AL; for \r\n recursion, we preserved it
    ; Direct call doesn't need restore — caller's AL is the char to send
    ; (the recursive call for \r changes AL; we need original for \n)
    ; Fix: save/restore AL properly:
    pop rdx
    out COM1_DATA, al
    push rdx                ; ... this approach has a bug with the recursion
    ; Proper implementation uses stack:
    pop rdx
    ret

;=============================================================================
; Cleaner serial_putchar (no recursion):
;=============================================================================
global serial_putchar_crlf
serial_putchar_crlf:
    ; AL = character
    push rax
    push rdx

    cmp al, 10
    jne .just_write

    ; Send CR before LF
    mov al, 13
.tx_ready_cr:
    in  al, COM1_LSR
    test al, 0x20
    jz  .tx_ready_cr
    mov al, 13
    out COM1_DATA, al

    mov al, 10              ; fall through to send LF

.just_write:
    ; Wait for transmitter empty
.tx_ready:
    push rax
    in  al, COM1_LSR
    test al, 0x20
    pop rax
    jz  .tx_ready

    out COM1_DATA, al

    pop rdx
    pop rax
    ret

;=============================================================================
; serial_puts: send null-terminated string
; RDI = string pointer
;=============================================================================
global serial_puts
serial_puts:
    push rax
    push rdi
.loop:
    mov al, [rdi]
    test al, al
    jz  .done
    call serial_putchar_crlf
    inc rdi
    jmp .loop
.done:
    pop rdi
    pop rax
    ret

;=============================================================================
; serial_put_hex8: print 8-bit value as 2 hex digits
; AL = value
;=============================================================================
global serial_put_hex8
serial_put_hex8:
    push rax
    push rbx
    movzx rbx, al

    ; High nibble
    shr al, 4
    and al, 0x0F
    lea rax, [hex_digits]
    movzx rax, byte [rax + rax]
    ; Bug: need to use rbx not rax as index
    lea rax, [hex_digits]
    movzx rcx, bl
    shr rcx, 4
    movzx rax, byte [rax + rcx]
    call serial_putchar_crlf

    ; Low nibble
    lea rax, [hex_digits]
    movzx rcx, bl
    and rcx, 0x0F
    movzx rax, byte [rax + rcx]
    call serial_putchar_crlf

    pop rbx
    pop rax
    ret

;=============================================================================
; serial_put_hex64: print 64-bit value as 16 hex digits + "0x" prefix
; RDI = value to print
;=============================================================================
global serial_put_hex64
serial_put_hex64:
    push rax
    push rbx
    push rcx
    push rdi

    ; Print "0x"
    mov al, '0'
    call serial_putchar_crlf
    mov al, 'x'
    call serial_putchar_crlf

    ; Print 16 hex digits, most significant first
    mov rbx, rdi
    mov rcx, 16

.digit_loop:
    ; Extract MSN: rotate left 4 bits, take low 4 bits
    rol rbx, 4
    mov rax, rbx
    and rax, 0x0F
    lea rdi, [hex_digits]
    movzx rax, byte [rdi + rax]
    call serial_putchar_crlf
    dec rcx
    jnz .digit_loop

    pop rdi
    pop rcx
    pop rbx
    pop rax
    ret

;=============================================================================
; serial_put_uint64: print 64-bit unsigned integer in decimal
; RDI = value
;=============================================================================
global serial_put_uint64
serial_put_uint64:
    push rax
    push rbx
    push rcx
    push rdx
    push rdi
    sub rsp, 24             ; local buffer for digits (20 chars max)

    mov rax, rdi
    mov rcx, 19             ; write digits from index 19 downward
    mov rbx, 10             ; divisor

.div_loop:
    xor rdx, rdx
    div rbx                 ; rax = quotient, rdx = remainder
    add dl, '0'
    mov [rsp + rcx], dl
    dec rcx
    test rax, rax
    jnz .div_loop

    ; Print from [rsp+rcx+1] to [rsp+19]
    inc rcx
    lea rdi, [rsp + rcx]
    mov rcx, 20
    sub rcx, rcx            ; ... length = 19 - rcx_at_end ... needs fix
    ; Actually: printed_digits start at rsp+rcx, length = 20 - (rcx)
    ; Just call serial_puts isn't right (not null-terminated)
    ; Write a digit count
    mov rcx, 19
    ; this is getting complex; use a simpler loop:
    lea rdi, [rsp + rcx]
    sub rcx, rcx
    mov al, [rdi]
.print_loop:
    call serial_putchar_crlf
    inc rdi
    mov al, [rdi]
    test al, al
    jnz .print_loop

    add rsp, 24
    pop rdi
    pop rdx
    pop rcx
    pop rbx
    pop rax
    ret

Using the Console in Practice

; Example kernel diagnostic output using serial console:
kernel_main:
    call serial_init

    mov rdi, str_kernel_start
    call serial_puts                ; "MinOS kernel starting"

    ; Log CR3 value
    mov rdi, str_cr3
    call serial_puts                ; "CR3 = "
    mov rdi, cr3
    call serial_put_hex64           ; "0x0000000000001000"

    ; Log memory size
    mov rdi, str_mem
    call serial_puts                ; "Physical memory: "
    mov rdi, [phys_total_bytes]
    call serial_put_uint64          ; "536870912"
    mov rdi, str_bytes
    call serial_puts                ; " bytes"

section .data
    str_kernel_start db "MinOS kernel starting", 10, 0
    str_cr3 db "CR3 = ", 0
    str_mem db "Physical memory: ", 0
    str_bytes db " bytes", 10, 0

QEMU Output

# Run with serial output to terminal
qemu-system-x86_64 -drive format=raw,file=minOS.img -serial stdio -display none

# Expected output on your terminal:
MinOS kernel starting
CR3 = 0x0000000000001000
Physical memory: 134217728 bytes
GDT loaded. 5 entries.
IDT loaded. 256 vectors.
PMM initialized. 32768 frames free.
[IRQ0] Timer running at 100Hz.
[IRQ1] Keyboard driver ready.
MinOS ready.

The serial console transforms OS debugging from "stare at a blank screen and guess" to "read detailed diagnostics in a terminal." Every hardware initialization step logs its status; any failure is visible immediately.

🔐 Security Note: Serial consoles on embedded systems are often the only debugging interface for security researchers analyzing firmware. A UART with debug output enabled in production (accessible via the device's external serial header) is a common security finding in IoT devices — it provides a root shell or detailed boot log to anyone with physical access and a 3.3V UART adapter.