Skip to Content
Course content

147: Memory-Mapped I/O and Low-Level Access

Click on the "Edit" button in the top corner of the screen to edit your slide content.

How do I actually point my C++ code at a specific hardware address?

In a standard program, you let the OS handle memory allocation. But when you're doing MMIO, the hardware designer has already decided that a specific memory address—say, 0x40021000—is actually a gateway to a hardware peripheral, like a GPIO controller. To talk to it, you just cast that raw address to a pointer of the appropriate type.

I usually recommend creating a struct that mirrors the hardware register map. It's much cleaner than managing a dozen individual pointers. Here is how I'd set up a basic register block for a hypothetical timer:

struct TimerRegisters {
    uint32_t CONTROL; // Offset 0x00
    uint32_t STATUS;  // Offset 0x04
    uint32_t RELOAD;  // Offset 0x08
    uint32_t COUNTER; // Offset 0x0C
};

// Cast the hard-coded physical address to our struct type
auto* const timer = reinterpret_cast<TimerRegisters*>(0x40001000);

// Now we can access registers as if they were members of a class
timer->RELOAD = 1000;
timer->CONTROL = 0x1; // Start the timer

Just be careful: the order of members in your struct must exactly match the hardware manual's memory map. If the manual says there's a 4-byte gap between two registers, you'll need to add a padding array to keep things aligned.

Why does my code work in Debug mode but completely ignore the hardware in Release mode?

This is the single most common "rite of passage" when doing low-level C++. You'll write a loop that waits for a status bit to change, and the compiler—trying to be helpful—will look at your code and say, "Wait, this loop reads the same memory address over and over, and nothing in the code ever changes it. I'll just read it once and cache it in a register."

The compiler doesn't know that the hardware can change that value independently of your program. To stop this, you must use the volatile keyword. It tells the compiler: "This value can change at any moment; do not optimize reads or writes to this address."

// BAD: The compiler might optimize this into an infinite loop or a single read
while (*status_reg == 0) { 
    // Wait for hardware to set the ready bit
}

// GOOD: The compiler will generate a load instruction every single time
volatile uint32_t* status_reg = reinterpret_cast<volatile uint32_t*>(0x40001004);
while (*status_reg == 0) {
    // Now we are actually polling the hardware
}

I've spent hours debugging "ghost" bugs that were actually just the optimizer being too aggressive. Always mark your MMIO pointers as volatile.

How do I change one setting in a register without overwriting everything else?

Hardware registers are often "packed," meaning a single 32-bit word contains eight different configuration flags. If you just write timer->CONTROL = 0x1;, you might be turning on the timer, but you're also accidentally turning off the interrupt enable or resetting the clock divider.

The professional way to handle this is via bitmasking. You read the current state, modify only the bits you care about using bitwise OR (to set) or AND NOT (to clear), and then write the result back. It's a read-modify-write cycle.

// Let's say bit 5 is the "Enable" bit
const uint32_t ENABLE_BIT = (1 < 5);

// SETting a bit: Use OR (|)
// This turns on bit 5 and leaves all other bits exactly as they were
timer->CONTROL |= ENABLE_BIT;

// CLEARing a bit: Use AND (&) with a bitwise NOT (~)
// This turns off bit 5 and leaves all other bits alone
timer->CONTROL &= ~ENABLE_BIT;

If you find yourself doing this constantly, I suggest wrapping these operations in small inline functions. It makes the intent of your code much clearer than a sea of ampersands and pipes.




📋 Practical Task

Exercise: Implementing a Hardware Status Poller

You are writing a driver for a simulated UART (Universal Asynchronous Receiver-Transmitter) device. The device has a status register at address 0x50002000. Bit 3 of this register is the TX_READY flag. If bit 3 is 1, the device is ready to accept a new character. If it is 0, the device is busy.

Your Task: Write a function void send_char(char c) that does the following:

  1. Defines a volatile pointer to the status register at 0x50002000.
  2. Defines a volatile pointer to the data register at 0x50002004.
  3. Implements a polling loop that waits until the TX_READY bit (bit 3) becomes 1.
  4. Once ready, writes the character c to the data register.

Make sure you use the correct bitmasking logic to check the status bit without assuming the other bits in the register are zero.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.