Understanding the volatile Qualifier
The volatile keyword in C is essential for preventing the compiler from optimizing accesses to variables that can change unexpectedly, such as memory-mapped hardware registers. Consider the following example:
void configure_device() {
extern unsigned char _end_marker;
volatile unsigned char *reg_ptr = &_end_marker;
*reg_ptr = 0x00;
while (*reg_ptr != 0xff);
*reg_ptr = 0x33;
*reg_ptr = 0x34;
*reg_ptr = 0x86;
}
Compile this code with -O2 optimization. Then, remove the volatile qualifier and recompile. Comparing the disassembled output will show that without volatile, the compiler may assume the value at reg_ptr does not change externally, potentially removing the read checks or caching the value in a register.
The symbol _end_marker is typically provided by the linker, marking the end of the program's allocated sections. In bare-metal contexts, it often serves as a reference point for heap boundaries or, as in this example, a dummy address that could be remapped to a hardware register.
If reg_ptr points to a memory-mapped device register and volatile is omitted, the compiler might optimize the loop while (*reg_ptr != 0xff); in to an infinite loop if it cached the initial zero value, or skip the writes entirely. This breaks the intended hardware interaction.
Runing a Bare-Metal Hello World
In the am-kernels/kernels/hello/ directory, execute:
make ARCH=$ISA-nemu run
This program runs directly on the NEMU emulator with MMIO support. Unlike a standard user-space Hello World that relies on OS system calls, this version interacts directly with the serial device through the abstract machine (AM) layer.
Serial Port MMIO Implementation
The serial output in NEMU is handled via a callback mechanism. When the guest writes to the serial port's memory-mapped address, the folllowing logic is triggered:
// nemu/src/device/serial.c
static void uart_transmit(char c) {
MUXDEF(CONFIG_TARGET_AM, putch(c), putc(c, stderr));
}
static void serial_mmio_callback(uint32_t reg_offset, int size, bool write_op) {
assert(size == 1);
if (reg_offset == DATA_REG_OFFSET) {
if (write_op) uart_transmit(serial_buffer[0]);
else panic("Serial read not supported");
} else {
panic("Unsupported offset = %d", reg_offset);
}
}
This callback is invoked through the MMIO dispatch layer. The relevant read/write operations are defined in nemu/src/device/io/mmio.c:
// nemu/src/device/io/mmio.c
uint64_t mmio_read(paddr_t phys_addr, int access_len, IOMap *region) {
assert(access_len >= 1 && access_len <= 8);
check_region_bounds(region, phys_addr);
uint32_t offset = phys_addr - region->low;
invoke_callback(region->callback, offset, access_len, false);
return host_read(region->space + offset, access_len);
}
void mmio_write(paddr_t phys_addr, int access_len, uint64_t value, IOMap *region) {
assert(access_len >= 1 && access_len <= 8);
check_region_bounds(region, phys_addr);
uint32_t offset = phys_addr - region->low;
host_write(region->space + offset, access_len, value);
invoke_callback(region->callback, offset, access_len, true);
}
These MMIO handlers are called from the physical memory access layer:
// nemu/src/memory/paddr.c
uint64_t physical_mem_read(paddr_t addr, int len) {
if (likely(is_in_physical_mem(addr))) return memory_buffer_read(addr, len);
IFDEF(CONFIG_DEVICE, return mmio_read(addr, len));
out_of_bound(addr);
return 0;
}
void physical_mem_write(paddr_t addr, int len, uint64_t data) {
if (likely(is_in_physical_mem(addr))) {
memory_buffer_write(addr, len, data);
return;
}
IFDEF(CONFIG_DEVICE, mmio_write(addr, len, data); return);
out_of_bound(addr);
}
Virtual address accesses in nemu/src/memory/vaddr.c wrap these physical memory operations. The RISC-V instruction implementation uses macros:
// nemu/src/isa/riscv32/inst.c
#define MEM_READ vaddr_read
#define MEM_WRITE vaddr_write
Thus, when a store instruction targets the serial port's MMIO region, the chain of calls—MEM_WRITE → vaddr_write → physical_mem_write → mmio_write—ultimately triggers serial_mmio_callback, which outputs the character.
Additional I/O Topics
- Implementing
printffor bare-metal output - Running ALU and device tests
- Handling command-line arguments in bare-metal
main - Clock and timer device integration
- Dynamic tracing (dtrace) for I/O operations
- Keyboard input via MMIO
- VGA display output and framebuffer management
- Von Neumann architecture considerations for I/O
- Integrating serial and clock peripherals into a custom NEMU-based system