Writing an operating system is something I have always wanted to do. From the idea of bootstrapping from real mode to the semantics of memory management, it all drew me in the moment I read or heard about it. Fortunately, there are many incredible resources on OS development, ranging from Tannenbaum and Woodhull’s Operating Systems: Design and Implementation to the essential OSDev Wiki. Unfortunately, while these resources gave me the parts I needed to put together, they did not provide instructions on how to do so. Of course, this is a given; if everybody could agree on the best way to write an operating system, we’d all be running Linux. This blog series aims to combine the varied and incredible efforts of every source I’ve used to learn kernel development into a journal that can serve as a point of reference for others.

Table of Contents

  • The OSDev Wiki - The OS developer’s manual. It has information on nearly every functionality or peripheral you’ll need when writing an OS.
  • Tanenbaum, Andrew S., and Albert S. Woodhull. Operating Systems: Design and Implementation. (Google Books Link) - If OSDev.org is the OS developer’s manual, then this is the bible. Covers the entirety of OS design, both in theory and in practice using MINIX 3 for the examples.
  • Philipp Oppermann’s Writing an OS in Rust series, both the first and second editions - An extremely well-written and beginner-friendly guide to writing an OS in Rust.

What this post is

This series aims to walk through the creation of a computer operating system for processors compatible with the amd64 architecture. This post in particular will start from scratch, and will end with Rust code execution by sending “Hello, World!” through a serial port.

What this post is not

This article (and all following articles in this series) is not:

  • an assembly tutorial.
  • a Rust tutorial.
  • a safe/advanced concurrency tutorial.
  • a tutorial on how to create and/or use compilation and development toolchains.
  • a comprehensive guide to all types and styles of OS design.
  • meant to explain everything (do your own reading, I provide links!)

Please do not raise issues on the GitHub repository because these subjects are not covered or explained.

Prerequisites

  • You should already be proficient in Rust, as well as used to using Cargo and rustup.
  • You should be doing this on Linux. OSDev on Windows and OSX is possible, but will not be covered by this guide.
  • You should be familiar with command line tools such as make.
  • You should be able to at least read x86(_64) assembly. This series will use the NASM assembler.

Disclaimer

Most of this section (code and text) has been adapted both with and without modifications from Philipp Oppermann’s OSDev Blog. For more information, see Licensing

Boot Code

Multiboot

“The Multiboot specification is an open standard that provides kernels with a uniform way to be booted by Multiboot-compliant bootloaders. The reference implementation of the Multiboot specification is provided by GRUB.” -OSDev Wiki. While there are various pros and cons to using the multiboot specification, I have chosen to use it here to avoid having to write my own or use an alternate bootloader compatible only with this OS.

The Multiboot Header

According to the Multiboot 2 Specification, in order to be recognized by a multiboot-compatible bootloader we need to include a compatible header within the first 32kb of the boot image. This is easily expressed with some assembly:

; src/arch/amd64/multiboot_header.asm
; Multiboot 2 - Compliant Header
section .multiboot_header
header_start:
    dd 0xe85250d6                ; Magic number identifying this as a header
    dd 0                         ; Specify the CPU as amd64 (32 bit)
    dd header_end - header_start ; Size of the Header
    ; Checksum - Must have value of uint32(0) when added to the value of the other magic fields.
    dd 0x100000000 - (0xe85250d6 + 0 + (header_end - header_start))

    ; Other Multiboot tags will go here.

    ; Required end tag
    dw 0    ; type
    dw 0    ; flags
    dd 8    ; size
header_end:

We can do many useful things by adding additional Multiboot tags, but for now this will suffice to boot our OS.

Execution Starting Point

Even though our OS image provides all the information necessary to boot, GRUB will still give us an error message. Our bootloader will expect a place to start execution, so let’s add in a basic boot entry point:

; src/arch/amd64/boot.asm
global start

section .text
bits 32    ; By default, GRUB sets us to 32-bit mode.
start:
    ; Print `OK` to screen
    mov dword [0xb8000], 0x2f4b2f4f
    hlt ; Halt the processor.

We write the data for OK (it’s little endian, so it appears backwards) into memory: 4F for ASCII O and 4B for ASCII K. The two extra bytes are color information, to set the foreground color to white, and the background color to green (how and why this works will be explained in a later post).

Linker Scripts

In addition to the header, GRUB expects our boot image to be in the ELF format. To create an ELF executable we need to provide a linker script to put each part of our binary in the right place - for one, this will make sure our Multiboot header is actually in the first 32KiB of our executable.

; src/arch/amd64/linker.ld
ENTRY(start) /* Tell GRUB to start execution at this label */

SECTIONS {
    . = 1M;  /* Tells GRUB to load the kernel starting at the 1MiB mark */

    .boot :
    {
        /* Ensure that the multiboot header is at the beginning */
        *(.multiboot_header)
    }

    .text :
    {
        *(.text)
    }
}

We choose to load our kernel at 1MiB into memory, as opposed to the very start. This is because several portions of memory between byte 0x00 and the 1MiB mark are mapped to various physical devices. Writing our kernel to these locations could cause unintended side effects, so we avoid it altogether.

Compiling

Creating the binary is simple enough:

$ mkdir -p build/arch/amd64
$ nasm -f elf64 -o build/arch/amd64/multiboot_header.o src/arch/amd64/multiboot_header.asm
$ nasm -f elf64 -o build/arch/amd64/boot.o src/arch/amd64/boot.asm
$ ld -n -o build/kernel.bin -T \
    src/arch/amd64/linker.ld \
    build/arch/amd64/multiboot_header.o \
    build/arch/amd64/boot.o

Direct quote from Opperman’s blog: “It’s important to pass the -n (or –nmagic) flag to the linker, which disables the automatic section alignment in the executable. Otherwise the linker may page align the .boot section in the executable file. If that happens, GRUB isn’t able to find the Multiboot header because it isn’t at the beginning anymore.”

Creating a disk image is a bit harder. We need to create a grub configuration file to tell GRUB how to load our image at run time:

# src/arch/amd64/grub.cfg
set timeout=0
set default=0

menuentry "os" {
    multiboot2 /boot/kernel.bin
    boot
}

Now we create a working directory, copy over the necessary files, and create a boot image with grub:

$ mkdir -p build/isofiles/boot/grub
$ cp src/arch/amd64/grub.cfg build/isofiles/boot/grub/grub.cfg
$ cp build/kernel.bin build/isofiles/boot/kernel.bin
$ grub-mkrescue -o build/os.iso build/isofiles

With this, we now have a bootable image.

Running

We’ll use QEMU to test-run our OS.

$ qemu-system-x86_64 -cdrom build/os.iso

Lo and behold, we can see OK in the top-left corner:

Moving to 64 Bits

When GRUB passes execution to our binary, it places us in 32-bit protected mode. This is not ideal for many reasons, but mainly because we want access to address spaces greater than 4GB. We want to switch to 64-bit Long Mode as soon as possible. To do so, we need to add some bootstrap code to our entrypoint.

Setting up a stack

Anyone who has worked with assembly language has more than likely used the stack. While a stack is provided when running a userspace program, though, our kernel needs to set one up for itself. Append the following to the end of the boot.asm file.

; src/arch/amd64/boot.asm
section .bss
stack_bottom:
    resb 4096 * 4 ; Reserve this many bytes
stack_top:

Having this much stack space might seem overkill for now, but it’ll prevent us from having to increase it later.

Now, let’s load the stack pointer into esp:

; src/arch/amd64/boot.asm
global start

section .text
bits 32    ; By default, GRUB sets us to 32-bit mode.
start:
    mov esp, stack_top

    ; Print `OK` to screen
    mov dword [0xb8000], 0x2f4b2f4f
    hlt ; Halt the processor.

; stack stuff below

Errors

There are several capabilities we need to test before we can set up long mode. Before all that, though, we should provide some rudimentary error reporting. Below is Oppermann’s error checking subroutine. We’ll put it underneath the start routine, but before the stack declaration (irrelevant parts have been removed for brevity).

; src/arch/amd64/boot.asm

--- snip ---

; Prints `ERR: ` and the given error code to screen and hangs.
; parameter: error code (in ascii) in al
error:
    mov dword [0xb8000], 0x4f524f45
    mov dword [0xb8004], 0x4f3a4f52
    mov dword [0xb8008], 0x4f204f20
    mov byte  [0xb800a], al
    hlt

--- snip ---

Runtime Bookkeeping

Before we can jump to 64-bit long mode, we need to do some bookkeeping. This involves three things:

We can take care of these using some snippets from Oppermann and the OSDev Wiki:

check_multiboot:
    cmp eax, 0x36d76289 ; If multiboot, this value will be in the eax register on boot.
    jne .no_multiboot
    ret
.no_multiboot:
    mov al, "0"
    jmp error

check_cpuid:
    ; Check if CPUID is supported by attempting to flip the ID bit (bit 21)
    ; in the FLAGS register. If we can flip it, CPUID is available.

    ; Copy FLAGS in to EAX via stack
    pushfd
    pop eax

    ; Copy to ECX as well for comparing later on
    mov ecx, eax

    ; Flip the ID bit
    xor eax, 1 << 21

    ; Copy EAX to FLAGS via the stack
    push eax
    popfd

    ; Copy FLAGS back to EAX (with the flipped bit if CPUID is supported)
    pushfd
    pop eax

    ; Restore FLAGS from the old version stored in ECX (i.e. flipping the
    ; ID bit back if it was ever flipped).
    push ecx
    popfd

    ; Compare EAX and ECX. If they are equal then that means the bit
    ; wasn't flipped, and CPUID isn't supported.
    cmp eax, ecx
    je .no_cpuid
    ret
.no_cpuid:
    mov al, "1"
    jmp error

check_long_mode:
    ; test if extended processor info in available
    mov eax, 0x80000000    ; implicit argument for cpuid
    cpuid                  ; get highest supported argument
    cmp eax, 0x80000001    ; it needs to be at least 0x80000001
    jb .no_long_mode       ; if it's less, the CPU is too old for long mode

    ; use extended info to test if long mode is available
    mov eax, 0x80000001    ; argument for extended processor info
    cpuid                  ; returns various feature bits in ecx and edx
    test edx, 1 << 29      ; test if the LM-bit is set in the D-register
    jz .no_long_mode       ; If it's not set, there is no long mode
    ret
.no_long_mode:
    mov al, "2"
    jmp error

These can go right below our error routine.


Once those are written in, we can call them right after one another in our main routine:

global start

section .text
bits 32    ; By default, GRUB sets us to 32-bit mode.
start:
    mov esp, stack_top

    call check_multiboot
    call check_cpuid
    call check_long_mode

    ; Print `OK` to screen
    mov dword [0xb8000], 0x2f4b2f4f
    hlt ; Halt the processor.

Paging

Switching to long mode immediately activates paging. This is good because it gives us some really amazing flexibility and control over our memory. This is bad because it would render our entire kernel address space inaccessible, since the space our kernel occupies in memory wouldn’t be mapped to the virtual address space. We don’t need to implement paging right now (and doing so in assembly would be tedious), but we do need to map our kernel into the eventual page tables so we can still access it.

Remapping the Kernel

Traditional page tables work in four tiers, so that’s what we’ll define. Each page table will be 4kb, and we only need one of each to hold our kernel as it is right now (the P1 table doesn’t exist because level one refers to real, not virtual memory).

; src/arch/amd64/boot.asm

; rest of the code
--- snip ---

section .bss
align 4096
p4_table:
    resb 4096
p3_table:
    resb 4096
p2_table:
    resb 4096
stack_bottom:
    resb 4096 * 4 ; Reserve this many bytes
stack_top:

The align directive tells the assembler to align our page tables to the correct boundaries, so we can re-map them in a less primitive way later.

Now, we need some code to initialize the page tables. To do this, we just need to map the first entry in each parent to its child. We set the huge page bit in the page flags, so our kernel is loaded in 2MiB pages instead of 4KiB pages. The following code initializes only the first entries in the first two page tables, and all 512 entries in the third (remember, we are counting down, and P1 is raw physical memory). Write the following code just before the .bss section in boot.asm.

; src/arch/amd64/boot.asm

; previous code

--- snip ---

set_up_page_tables:
    ; map first P4 entry to P3 table
    mov eax, p3_table
    or eax, 0b11 ; present + writable
    mov [p4_table], eax

    ; map first P3 entry to P2 table
    mov eax, p2_table
    or eax, 0b11 ; present + writable
    mov [p3_table], eax

    ; map each P2 entry to a huge 2MiB page
    mov ecx, 0         ; counter variable

.map_p2_table:
    ; map ecx-th P2 entry to a huge page that starts at address 2MiB*ecx
    mov eax, 0x200000  ; 2MiB
    mul ecx            ; start address of ecx-th page
    or eax, 0b10000011 ; present + writable + huge
    mov [p2_table + ecx * 8], eax ; map ecx-th entry

    inc ecx            ; increase counter
    cmp ecx, 512       ; if counter == 512, the whole P2 table is mapped
    jne .map_p2_table  ; else map the next entry

    ret

--- snip ---

; bss section

Now, simply by mapping only the first entry in the P4 and P3 tables (and mapping all the P2 entries), we’ve mapped the first gigabyte of our kernel into virtual memory.

Updating the CPU

Once we inform the CPU of our achievement by moving some values into specific registers, we can switch to long mode. Let’s do that by adding a new function to our boot code:

; src/arch/amd64/boot.asm

; previous code

--- snip ---

enable_paging:
    ; load P4 to cr3 register (cpu uses this to access the P4 table)
    mov eax, p4_table
    mov cr3, eax

    ; enable PAE-flag in cr4 (Physical Address Extension)
    mov eax, cr4
    or eax, 1 << 5
    mov cr4, eax

    ; set the long mode bit in the EFER MSR (model specific register)
    mov ecx, 0xC0000080
    rdmsr
    or eax, 1 << 8
    wrmsr

    ; enable paging in the cr0 register
    mov eax, cr0
    or eax, 1 << 31
    mov cr0, eax

    ret

--- snip ---

; bss section

And subsequently calling it in our start routine:

; src/arch/amd64/boot.asm
start:
    mov esp, stack_top

    call check_multiboot
    call check_cpuid
    call check_long_mode

    call set_up_page_tables ; new
    call enable_paging      ; new

    ; print `OK` to screen
    mov dword [0xb8000], 0x2f4b2f4f
    hlt
--- snip ---

; rest of code

; bss section

Setting up a new GDT

There is only one thing stopping us from entering long mode now, and it is that we haven’t set up segmentation. Segmentation is no longer used (it has been deprecated in favor of paging), but i386 and amd64 machines still require it. To resolve this, we need to create a Global Descriptor Table, or GDT

Let’s create a GDT in a read only data segment, all the way at the very bottom of our boot file:

; src/arch/amd64/boot.asm
section .rodata
gdt64:
    dq 0 ; zero entry
.code: equ $ - gdt64 
    dq (1<<43) | (1<<44) | (1<<47) | (1<<53) ; code segment
.pointer:
    dw $ - gdt64 - 1
    dq gdt64

These instructions set bits 43, 44, 47, and 53, which flags the segment as a valid executable 64-bit code segment. The .code label lets us get the address of (and jump to) the start of the code segment in virtual memory. The following .pointer sub-label points to a structure containing the length of the GDT, and a pointer to the GDT.

Finally. To enter 64-bit mode, this is all we need. Let’s replace our print OK instructions with code to load the GDT with a special instruction and jump to a not-yet-created long mode entry point.

; src/arch/amd64/boot.asm
global start
extern long_mode_start

section .text
bits 32
start:
    mov esp, stack_top
    mov edi, ebx       ; move Multiboot info pointer to edi

    call check_multiboot
    call check_cpuid
    call check_long_mode

    call set_up_page_tables
    call enable_paging

    ; load the 64-bit GDT
    lgdt [gdt64.pointer]

    ; jump to long mode / replaces OK code.
    jmp gdt64.code:long_mode_start

--- snip ---

; subroutines

; bss segment

It’s important to note here that the jump to long mode will only work correctly is if the cs register is reloaded. This is achieved with a far jump. The far jump we use to do this is coincidentally the same jump we use to enter long mode, so it all works out.

Note that we add the line extern long_mode_start at the top as well. This tells the assembler that while we don’t have such a label in this file, it can expect the linker to provide such a label in the final executable.

Long Mode Start

With that final jmp, we have finally left the world of protected mode, and entered the realm of the 64-bit address space. To herald such a momentous occasion, we’ll put our long mode code in its own file:

; src/arch/amd64/long_mode_start.asm
global long_mode_start

section .text
bits 64
long_mode_start:
    ; load 0 into all data segment registers
    mov ax, 0
    mov ss, ax
    mov ds, ax
    mov es, ax
    mov fs, ax
    mov gs, ax

    ; print `OKAY` to screen
    mov rax, 0x2f592f412f4b2f4f
    mov qword [0xb8000], rax
    hlt

With double the address space, we can write double the letters! To prove this, let’s write the word OKAY to the screen. If all goes well, you should see the confirmation coming from real mode:

We also clean up after ourselves once we switch to long mode. Leaving the segment registers with non-null data inside will cause errors down the line.

Setting up Rust

Now that we’ve laid the groundwork, we can create a new Crate, and call the Rust code within from our assembly. The first step is to create a new cargo project and fill out the cargo manifest.

$ cargo init --lib

We will set the crate-type to be static, so all the dependencies are bundled into one file.

; cargo.toml
[package]
name = "rust-os"
version = "0.1.0"
authors = ["Nicolas Suarez <[email protected]>"]
edition = "2018"

[lib]
crate-type = ["staticlib"]

[dependencies]

Obviously, replace the name and authors field with your own information.

Before proceeding, we want to be using Rust nightly, so we can leverage many features we will be needing during development.

$ rustup override add nightly

We now have the source code for Rust’s default library file waiting for us just inside the src directory. We’re going to reject the contents of this file, and substitute our own.

// src/lib.rs

#![no_std]
#![no_main]

use core::panic::PanicInfo;

#[no_mangle]
pub extern "C" fn rust_main() -> ! {
    loop {}
}

/// This function is called on panic.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {}
}

Let’s break this down, bit by bit:

!#[no_std]
!#[no_main]

These are the backbone of our Rust boot system. The no_std directive instructs the Rust compiler that the target we’re compiling to won’t have the Rust standard library, and the no_main directive tells the compiler that our entry point isn’t Rust (it’s assembly, which calls rust).

#[no_mangle]
pub extern "C" fn rust_main() -> ! {
    loop {}
}

The no_mangle directive tells the compiler to leave the name rust_main alone - otherwise it would convert it into some unknown symbol that we couldn’t jump to from our assembly code. rust_main itself is defined as extern "C", that is, in the C calling convention (as opposed to the Rust calling convention). This makes it actually possible to jump to from assembly. The -> ! means this function shouldn’t ever return.

use core::panic::PanicInfo;

// snip

/// This function is called on panic.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {}
}

This is a panic handler - whenever our code calls panic!, this function is called. We can do amazing things with this - including error recovery, but for now it simply loops endlessly.

But will it compile?

The short answer is no. Not without some work, that is. rustc still thinks this binary is for our own system, not for a bare metal machine. We need to add a new target for the compiler to cross-compile to. Create the file amd64-os.json (any filename will work) in the project root with the following content:

{
  "llvm-target": "x86_64-unknown-none",
  "data-layout": "e-m:e-i64:64-f80:128-n8:16:32:64-S128",
  "arch": "x86_64",
  "target-endian": "little",
  "target-pointer-width": "64",
  "target-c-int-width": "32",
  "os": "none",
  "executables": true,
  "linker-flavor": "ld.lld",
  "linker": "rust-lld",
  "panic-strategy": "abort",
  "disable-redzone": true,
  "features": "-mmx,-sse,+soft-float"
}

This is a file we can now pass to the Rust compiler to inform it of the specs of its new target (our own OS). Most of it is common sense - we want to output to a 64-bit x86 target, little endian, and we want to use the Rust linker. We choose the abort panic strategy because we don’t have a proper panic handler yet. Finally, we disable floating-point arithmetic hardware support for our kernel only to avoid nasty bugs.

Now, we should be able to use cargo-xbuild to cross-compile our Rust code into an assembled output file. This will take a while the first time, since it must compile rust’s core for the new target.

$ cargo xbuild --target amd64-os.json

If all goes well, you will find your library at target/amd64-os/debug/librust_os.a.

Build Automation

In order to add our new Rust lib to our project, we need to re-compile everything and link it all together, then get grub to make us a new disk image.

This doesn’t sound like too much fun. Fortunately, we can use a makefile to automate the process. Specifically, we make some minor modifications to Oppermann’s makefile from the first edition of his blog:

arch ?= amd64
kernel := build/kernel-$(arch).bin
iso := build/os-$(arch).iso

linker_script := src/arch/$(arch)/linker.ld
grub_cfg := src/arch/$(arch)/grub.cfg
assembly_source_files := $(wildcard src/arch/$(arch)/*.asm)
assembly_object_files := $(patsubst src/arch/$(arch)/%.asm, build/arch/$(arch)/%.o, $(assembly_source_files))

target ?= $(arch)-os
rust_os := target/$(target)/debug/librust_os.a # REPLACE WITH YOUR OS OUTPUT

.PHONY: all clean run iso kernel doc disk

all: $(kernel)

release: $(releaseKernel)

clean:
	rm -r build
	cargo clean

run: $(iso)
	qemu-system-x86_64 -cdrom $(iso) -boot menu=on -vga std -s -serial file:serial.log

iso: $(iso)
	@echo "Done"

$(iso): $(kernel) $(grub_cfg)
	@mkdir -p build/isofiles/boot/grub
	cp $(kernel) build/isofiles/boot/kernel.bin
	cp $(grub_cfg) build/isofiles/boot/grub
	grub-mkrescue -o $(iso) build/isofiles #2> /dev/null
	@rm -r build/isofiles

$(kernel): kernel $(rust_os) $(assembly_object_files) $(linker_script)
	ld -n --gc-sections -T $(linker_script) -o $(kernel) $(assembly_object_files) $(rust_os)

kernel:
	@RUST_TARGET_PATH=$(32shell pwd) cargo xbuild --target amd64-os.json

# compile assembly files
build/arch/$(arch)/%.o: src/arch/$(arch)/%.asm
	@mkdir -p $(shell dirname [email protected])
	nasm -felf64 $< -o [email protected]

This makefile supplies many conveniences, including the ability to launch a newly compiled disk image with QEMU. It does just one thing that hasn’t already been done in the compilation process; it adds the --gc-sections flag to our invocation of the linker. This allows the linker to discard unused program sections, which removes bloat from our binary. However, this also deletes the multiboot header, which is never referenced anywhere in the executable portion of our binary. We need to update the linker script to prevent the header from being deleted:

/* src/arch/amd64/linker.ld */
ENTRY(start)

SECTIONS {
  . = 1M;

  .rodata :
  {
    /* ensure that the multiboot header is at the beginning */
    KEEP(*(.multiboot_header))
    *(.rodata .rodata.*)
    . = ALIGN(4K);
  }

  .text :
  {
    *(.text .text.*)
    . = ALIGN(4K);
  }

  .data :
  {
    *(.data .data.*)
    . = ALIGN(4K);
  }

  .bss :
  {
    *(.bss .bss.*)
    . = ALIGN(4K);
  }

  .got :
  {
    *(.got)
    . = ALIGN(4K);
  }

  .got.plt :
  {
    *(.got.plt)
    . = ALIGN(4K);
  }

  .data.rel.ro : ALIGN(4K) {
    *(.data.rel.ro.local*) *(.data.rel.ro .data.rel.ro.*)
    . = ALIGN(4K);
  }

  .gcc_except_table : ALIGN(4K) {
    *(.gcc_except_table)
    . = ALIGN(4K);
  }
}

This new linker script does two things: first, it forces the linker to keep the multiboot header. Second, it forces the linker to align all sections of our kernel to (what will eventually become) pages in memory. This sets us up for stuff we’ll do in the next post.

Printing to Serial

A Serial Driver

We’re now executing 64-bit rust. To wrap up this post, let’s write a basic UART 16550 driver for our OS, so we can send debug messages through the QEMU serial port. (If you hadn’t noticed, the makefile above redirects QEMU’s serial output to a log file.) To do this, we need to add a new dependency to our project:

# cargo.toml
[dependencies]
x86 = "0.19.0"
// src/lib.rs
extern crate x86;

The x86 crate gives us access to many low-level registers and CPU instructions we wouldn’t normally be able to use in Rust.

Let’s create a new module for our serial driver.

// src/lib.rs
#[macro_use]
mod serial;

Serial uses CPU I/O ports for communication. A serial port will have a base address, which marks the start of a range of CPU ports it uses for control:

IO Port Offset Register mapped to this port
+0 Data register. Reading this registers read from the Receive buffer. Writing to this register writes to the Transmit buffer. When DLAB is set to 1, sets the lsb of the divisor value for determining baud rate.
+1 Interrupt Enable Register. When DLAB is set to 1, sets the msb of the divisor value for determining baud rate.
+2 Interrupt Identification and FIFO control registers
+3 Line Control Register. The most significant bit of this register is the DLAB.
+4 Modem Control Register.
+5 Line Status Register.
+6 Modem Status Register.
+7 Scratch Register.

This table has been taken from the OSDev Wiki.

Based on this chart, all we need to include in the SerialPort structure is the base port:

// src/serial.rs
pub struct SerialPort {
    base_port: u16
}

We can easily adapt the OSDev Wiki’s example initialization code. from C to Rust:

#define PORT 0x3f8   /* COM1 */
 
void init_serial() {
   outb(PORT + 1, 0x00);    // Disable all interrupts
   outb(PORT + 3, 0x80);    // Enable DLAB (set baud rate divisor)
   outb(PORT + 0, 0x03);    // Set divisor to 3 (lo byte) 38400 baud
   outb(PORT + 1, 0x00);    //                  (hi byte)
   outb(PORT + 3, 0x03);    // 8 bits, no parity, one stop bit
   outb(PORT + 2, 0xC7);    // Enable FIFO, clear them, with 14-byte threshold
   outb(PORT + 4, 0x0B);    // IRQs enabled, RTS/DSR set
}
// src/serial.rs
use x86::io::{outb, inb};

impl SerialPort {
    pub const unsafe fn new(base_port: u16) -> SerialPort {
        SerialPort {
            base_port
        }
    }

    pub fn init(&self) {
        unsafe {
            outb(self.base_port+1, 0x00); // Disable interrupts
            outb(self.base_port+3, 0x00); // Set baud rate divisor
            outb(self.base_port+0, 0x00); // Set baud rate to 38400 baud
            outb(self.base_port+1, 0x00); // 
            outb(self.base_port+3, 0x00); // 8 bits, no parity, one stop bit
            outb(self.base_port+2, 0x00); // Enable FIFO, clear them, with 14-byte threshold
            outb(self.base_port+4, 0x00); // Enable IRQs, RTS/DSR set
            outb(self.base_port+1, 0x00); // Disable Interrupts
        }
    }
}

SerialPort’s constructor is marked unsafe, which means the caller is responsible for guaranteeing the validity of the base port. Because of this, it’s fine to leave init as safe, since we can assume that subsequent ports are valid as long as the base port is valid.

Once we’ve initialized the UART controller, all we need to do is write a send() function:

// src/serial.rs

pub fn get_lsts(&self) -> u8 {
    unsafe {
        inb(self.base_port + 5) // line status register is on port 5.
    }
}

pub fn send(&self, data: u8) {
    unsafe {
        match data {
            8 | 0x7F => {
                while (!self.get_lsts() & 1) == 0 {}
                outb(self.base_port, 8);
                while (!self.get_lsts() & 1) == 0 {}
                outb(self.base_port, b' ');
                while (!self.get_lsts() & 1) == 0 {}
                outb(self.base_port, 8);
            }
            _ => {
                while (!self.get_lsts() & 1) == 0 {}
                outb(self.base_port, data);
            }
        }
    }
}

Why send() and not putchar()? The answer is simple: Serial ports can be used for more than just printing - you can send and recieve data of all kinds over a serial port. By leaving it as send and not putchar, we allow ourselves to add in additional functionality to our driver without breaking existing code.

This looks tough, but it’s quite simple - we wait until the status register indicates it’s OK to send a byte (while (!self.get_lsts() & 1) == 0 {}), and then we send the byte. In the case of the space character, we simply send a special sequence to indicate what to do.

Integrating with fmt

For convenience, let’s implement fmt::Write, so we can use Rust’s format! macros with this type:

// src/serial.rs
use core::fmt::{Write, Result};
impl Write for SerialPort {
    fn write_str(&mut self, s: &str) -> fmt::Result {
        for byte in s.bytes() {
            self.send(byte);
        }
        Ok(())
    }
}

Globally Acessible Macros

Also for convenience, let’s create a global serial controller, so we can adapt the print! and println! macros to work with our new output. To do this, we need two more crates: The spin crate to allow us to preserve safety in a parallel context, and the lazy_static crate to allow us to create mutable globals.

# cargo.toml
[dependencies]
x86 = "0.19.0"
spin = "0.5.0"

[dependencies.lazy_static]
version = "1.3.0"
features = ["spin_no_std"]

The spin_no_std feature of lazy_static adds no_std compatible mutexes implicitly.

// src/lib.rs

#[macro_use]
extern crate lazy_static;
extern crate spin;
// src/serial.rs
use spin::Mutex;

lazy_static! {
    pub static ref SERIAL1: Mutex<SerialPort> = {
        unsafe {
            let mut serial_port = SerialPort::new(0x3F8);
            serial_port.init();
            Mutex::new(serial_port)
        }
    };
}

Now we can refer to SERIAL1 from anywhere in the crate. Let’s finish the driver with a print function, as well as matching macros:

// src/serial.rs

pub fn print(args: ::core::fmt::Arguments) {
    use core::fmt::Write;
    SERIAL1
        .lock()
        .write_fmt(args)
        .expect("Printing to serial failed");
}

/// Prints to the host through the serial interface.
#[macro_export]
macro_rules! prints {
    ($($arg:tt)*) => {
        $crate::serial::print(format_args!($($arg)*));
    };
}

/// Prints to the host through the serial interface, appending a newline.
#[macro_export]
macro_rules! printsln {
    () => (prints!("\n"));
    ($fmt:expr) => (prints!(concat!($fmt, "\n")));
    ($fmt:expr, $($arg:tt)*) => (prints!(concat!($fmt, "\n"), $($arg)*));
}

Putting it all together

Now, we can revise our entry function:

// src/lib.rs
#[macro_use]
mod serial;

#[no_mangle]
pub extern "C" fn rust_main() -> ! {
    printsln!("Hello, World!");
    loop {}
}

A quick make run later, and we can see a serial.log file has been created in our project root!

Hello, World!

Success! We now have a fully functioning serial print driver! This will come in handy in debugging the subject of next post, which will cover memory management and a heap.

Licensing

This text includes software written by Philipp Oppermann and released under the MIT License, which is reproduced in full below:

The MIT License (MIT)

Copyright (c) 2015 Philipp Oppermann

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.