In embedded systems design, C++’s object-oriented features are typically introduced only after the platform hardware and RTOS have been fully initialized. In this article, I will demonstrate the significant benefits of using C++—instead of plain C—for low-level embedded tasks such as hardware register access.
Access Hardware Register in C Style
Following is how to update an hardware register, such as writing an fixed value to it, in C code:
#define REG_ADDR ((volatile uint32_t*)0x1000)
*REG_ADDR = 0x1; // writes 1 to address 0x1000
This works fine by using an raw macros. But, if you want the code safer and type-checked, you should use what modern C++ code provided mechanism, such as constexpr inline function.
Access Hardware Register in C++ Style
Instead of using raw #define, the memory-mapped register access can be wrapped in a constexpr inline function or even a small class template.
#include <cstdint>
constexpr std::uintptr_t REG_BASE = 0x1000;
inline volatile std::uint32_t& REG_ADDR()
{
return *reinterpret_cast<volatile std::uint32_t*>(REG_BASE);
}
int main() {
REG_ADDR() = 0x1; // Assign 1 to address 0x1000
std::uint32_t val = REG_ADDR(); // Read from address 0x1000
}
Here, reinterpret_cast ensures type-safe conversion from integer to pointer. Returning a reference volatile std::uint32_t& makes the usage look like a normal variable and the compiler still enforces correct type.
The above code works great for 32 bits register. C++ can use template to extend your code to provide safer register access for any register width.
#include <cstdint>
template<std::uintptr_t address, typename T = std::uint32_t>
struct reg {
static volatile T& value() {
return *reinterpret_cast<volatile T*>(address);
}
};
int main() {
// Write to register at 0x1000
reg<0x1000>::value() = 0x1;
// Read back
std::uint32_t val = reg<0x1000>::value();
}
The code is safer and extendible. For example, if you initialize an reg<0x1000, std::uint8_t>, but assign a uint32_t value to it, the compiler will warn this kind of mismatch. Also, in real driver code, you usually need manually provide the bit or bits set, clear, read, shift macro in C code. C++ can wrap all those into class to avoid the possible errors of those manual macro operation:
#include <cstdint>
#include <cstddef>
template<std::uintptr_t address, typename T = std::uint32_t>
class reg {
public:
using value_type = T;
// Read the whole register
static value_type read() {
return *ptr();
}
// Write the whole register
static void write(value_type val) {
*ptr() = val;
}
// Set specific bit(s)
static void set_bit(std::size_t bit) {
*ptr() |= (value_type(1) << bit);
}
// Clear specific bit(s)
static void clear_bit(std::size_t bit) {
*ptr() &= ~(value_type(1) << bit);
}
// Toggle specific bit(s)
static void toggle_bit(std::size_t bit) {
*ptr() ^= (value_type(1) << bit);
}
// Read a specific bit
static bool read_bit(std::size_t bit) {
return (*ptr() >> bit) & value_type(1);
}
private:
static volatile value_type* ptr() {
return reinterpret_cast<volatile value_type*>(address);
}
};
// Define a 32-bit hardware register at address 0x1000
using MyReg = reg<0x1000, std::uint32_t>;
int main() {
// Write entire register
MyReg::write(0x1234);
// Read entire register
std::uint32_t val = MyReg::read();
// Set bit 3
MyReg::set_bit(3);
// Clear bit 1
MyReg::clear_bit(1);
// Toggle bit 7
MyReg::toggle_bit(7);
// Read bit 0
bool b = MyReg::read_bit(0);
}
C++ code encapsulated all your needed register access APIs in a clean class. The volatile declaration in the class ensures the compiler does not optimize away reads and writes operations. Template enforces type safety (uint8_t, uint16_t, uint32_t). The result is a clear, reusable interface for hardware peripheral registers manipulation.
Next, we want to extend above class to be able to manipulate multiple bit fields of a register instead of just one bit, which is a common requirement to manipulate a register with fields like BAUD, ENABLE, MODE of UART control register:
#include <cstdint>
#include <cstddef>
#include <type_traits>
template<std::uintptr_t address, typename T = std::uint32_t>
class reg {
public:
using value_type = T;
// ---- Whole register operations ----
static value_type read() {
return *ptr();
}
static void write(value_type val) {
*ptr() = val;
}
static void set_bit(std::size_t bit) {
*ptr() |= (value_type(1) << bit);
}
static void clear_bit(std::size_t bit) {
*ptr() &= ~(value_type(1) << bit);
}
static void toggle_bit(std::size_t bit) {
*ptr() ^= (value_type(1) << bit);
}
static bool read_bit(std::size_t bit) {
return (*ptr() >> bit) & value_type(1);
}
// ---- Nested class for bitfields ----
template<std::size_t pos, std::size_t width>
class field {
static_assert(width > 0, "Bitfield width must be > 0");
static_assert(pos + width <= sizeof(value_type) * 8,
"Bitfield exceeds register size");
public:
using field_type = typename std::conditional<
(width == sizeof(value_type) * 8),
value_type,
typename std::conditional<(width <= 8), std::uint8_t,
typename std::conditional<(width <= 16), std::uint16_t,
std::uint32_t>::type>::type>::type;
// Generate mask for this field
static constexpr value_type mask =
((value_type(1) << width) - 1) << pos;
// Read the field value
static field_type read() {
return (reg::read() & mask) >> pos;
}
// Write a new value to the field
static void write(field_type val) {
value_type tmp = reg::read();
tmp &= ~mask; // clear field
tmp |= (value_type(val) << pos) & mask;
reg::write(tmp);
}
};
private:
static volatile value_type* ptr() {
return reinterpret_cast<volatile value_type*>(address);
}
};
// Example to manipulate and UART control register:
//UART control register at 0x2000 with the following layout:
// ENABLE → bit 0
// MODE → bits 1–2 (2 bits wide)
// BAUD → bits 4–7 (4 bits wide)
using UART_CTRL = reg<0x2000, std::uint32_t>;
// Define bitfields
using ENABLE = UART_CTRL::field<0,1>; // 1 bit
using MODE = UART_CTRL::field<1,2>; // 2 bits
using BAUD = UART_CTRL::field<4,4>; // 4 bits
int main() {
// Enable UART
ENABLE::write(1);
// Set MODE = 2
MODE::write(2);
// Set BAUD = 9
BAUD::write(9);
// Read back values
bool enabled = ENABLE::read();
auto mode = MODE::read();
auto baud = BAUD::read();
}
The above code is Type-safe: You can’t accidentally write a huge number into a small field; Self-documenting: The code clearly expresses intent (BAUD::write(9) instead of bit shifts); No runtime overhead: Everything compiles down to simple read-modify-write bit operations.
Further more, we can add enum support for fields like MODE (so you can write MODE::write(MODE::ASYNC) instead of raw numbers:
#include <cstdint>
#include <cstddef>
#include <type_traits>
template<std::uintptr_t address, typename T = std::uint32_t>
class reg {
public:
using value_type = T;
// ---- Whole register operations ----
static value_type read() {
return *ptr();
}
static void write(value_type val) {
*ptr() = val;
}
static void set_bit(std::size_t bit) {
*ptr() |= (value_type(1) << bit);
}
static void clear_bit(std::size_t bit) {
*ptr() &= ~(value_type(1) << bit);
}
static bool read_bit(std::size_t bit) {
return (*ptr() >> bit) & value_type(1);
}
// ---- Nested class for bitfields ----
template<std::size_t pos, std::size_t width, typename Enum = void>
class field {
static_assert(width > 0, "Bitfield width must be > 0");
static_assert(pos + width <= sizeof(value_type) * 8,
"Bitfield exceeds register size");
public:
using raw_type = typename std::conditional<
(width == sizeof(value_type) * 8),
value_type,
typename std::conditional<(width <= 8), std::uint8_t,
typename std::conditional<(width <= 16), std::uint16_t,
std::uint32_t>::type>::type>::type;
static constexpr value_type mask =
((value_type(1) << width) - 1) << pos;
// Read as raw integer
static raw_type read() {
return (reg::read() & mask) >> pos;
}
// Read as enum if provided
static Enum read_enum() {
static_assert(!std::is_same<Enum, void>::value,
"Enum type not specified for this field");
return static_cast<Enum>(read());
}
// Write raw integer
static void write(raw_type val) {
value_type tmp = reg::read();
tmp &= ~mask;
tmp |= (value_type(val) << pos) & mask;
reg::write(tmp);
}
// Write enum value
static void write(Enum val) {
write(static_cast<raw_type>(val));
}
};
private:
static volatile value_type* ptr() {
return reinterpret_cast<volatile value_type*>(address);
}
};
using UART_CTRL = reg<0x2000, std::uint32_t>;
// Define enums for MODE
enum class Mode : std::uint8_t {
Idle = 0,
Async = 1,
Sync = 2,
IrDA = 3
};
// Define bitfields
using ENABLE = UART_CTRL::field<0,1>; // no enum
using MODE = UART_CTRL::field<1,2, Mode>; // with enum
using BAUD = UART_CTRL::field<4,4>; // no enum
int main() {
// Enable UART
ENABLE::write(1);
// Set MODE using enum
MODE::write(Mode::Async);
// Set BAUD to 9
BAUD::write(9);
// Read back
bool enabled = ENABLE::read();
Mode mode = MODE::read_enum();
auto baud = BAUD::read();
}
Everything looks great so far. But, you may have noticed that there is no address size checking in all those above implementation. Address type :std::uintptr_t already adapts to platform pointer width automatically. It is the C++ standard unsigned integer type guaranteed to be able to hold a pointer. Its width depends on the platform: On a 64-bit CPU (x86-64, Arch64), it is uint64_t. On an 8-bit MCU , it is uint16_t (if address space is 64 KB) and on a 32-bit MCU (ARM Cortex-M, AVR32, etc.) , it is an uint32_t.
Want to explicit control the address size ? No problem. Just add another template parameter to the class as following:
template<typename AddrType = std::uintptr_t, AddrType address = 0, typename T = std::uint32_t>
class reg {
public:
using value_type = T;
using addr_type = AddrType;
private:
static volatile value_type* ptr() {
return reinterpret_cast<volatile value_type*>(address);
}
public:
static volatile value_type& value() {
return *ptr();
}
};
// Example usage
// 8-bit address space (rare, but imagine tiny MCU with <256 bytes mapped regs)
using REG8ADDR = reg<std::uint8_t, 0x10, std::uint8_t>;
// 16-bit address space
using REG16ADDR = reg<std::uint16_t, 0x1000, std::uint8_t>;
// Normal (auto-detects platform, usually 32-bit or 64-bit)
using REGDEFAULT = reg<>;
We’ve seen how C++ can offer real benefits even in low-level embedded projects. If those gains matter more to you than a small increase in code size, then C++ is probably the smarter choice over plain C.