From Scratch: Exploring Bare Metal C++ — Part-6
I removed C from the title — after the previous posts, I believe we have built a solid foundation for using C++ in bare-metal development…
From Scratch: Exploring Bare Metal C++ — Part-6
I removed C from the title — after the previous posts, I believe we have built a solid foundation for using C++ in bare-metal development by comparing it with C. From now on, I will focus on C++ features themselves.
As discussed in the previous post, we can improve type safety by using enum class instead of a traditional enum. To better understand the difference, let's first look at what the C and C++ standards say about each of them.
The cppreference documentation for the C language states:
Each enumeration-constant that appears in the body of an enumeration specifier becomes an integer constant in the enclosing scope and can be used whenever integer constants are required. Enumerated types are integer types, and as such can be used anywhere other integer types can, including in implicit conversions and arithmetic operators.
In general, a traditional enum type is just a named integer — nothing more.
The cppreference documentation for the C++ language on scoped enum types states:
Each enumerator becomes a named constant of the enumeration’s type (that is, name), which is contained within the scope of the enumeration, and can be accessed using the scope resolution operator. There are no implicit conversions from the values of a scoped enumerator to integral types, although
static_castmay be used to obtain the numeric value of the enumerator.
This is quite important — it clearly states that there are no implicit conversions for scoped enum types. The only way to convert a scoped enum to an integer is through an explicit static_cast
Let’s implement scoped enum type in our code base. First, clone the repository, and go into post_5/cpp implementation. You will find two enum implementation in gpio.hpp file as gpio_mode_t and gpio_pin_t . Let’s convert them to scoped enum as follow;
// GPIO pin mode values (MODER field)
enum class gpio_mode_t{
MODE_INPUT = 0U,
MODE_OUTPUT = 1U,
MODE_ALT = 2U,
MODE_ANALOG = 3U,
};
// GPIO pin numbers
enum class gpio_pin_t {
PIN_0 = 0U,
PIN_1 = 1U,
PIN_2 = 2U,
PIN_3 = 3U,
PIN_4 = 4U,
PIN_5 = 5U,
PIN_6 = 6U,
PIN_7 = 7U,
PIN_8 = 8U,
PIN_9 = 9U,
PIN_10 = 10U,
PIN_11 = 11U,
PIN_12 = 12U,
PIN_13 = 13U,
PIN_14 = 14U,
PIN_15 = 15U,
};
Note: Since we are using scoped enum, I deleted GPIO prefix. You will see that how we access them.
Then update the class private members as follows:
private:
/*variables*/
GPIO_RegDef_t *gpio_base;
gpio_pin_t pin;
gpio_mode_t mode;
Note: Constructor function has already updated in last post. Therefore, you don’t need to change it.
Now, how can we access scoped enum member ? It is possible :: (scoped resolution operator), like gpio_pin_t::PIN_0 or gpio_mode_t::MODE_INPUT . According to me, it is much cleaner than traditional enum. It is obvious which enum we are accessing and what value we are using.
Let’s configure main.cpp file as below;
#include "gpio.hpp"
volatile static unsigned int led_flag = 0;
int main(void)
{
gpio_t green_led(GPIOA_BASE, gpio_pin_t::PIN_5, gpio_mode_t::MODE_OUTPUT);
/* ... */
If you run make command, you will see the following errors;
gpio.cpp:30:31: error: no match for 'operator*' (operand types are 'gpio_pin_t' and 'unsigned int')
30 | uint8_t shift = this->pin * GPIO_MODER_BITS_PER_PIN;
| ~~~~~~~~~ ^
| |
| gpio_pin_t
gpio.cpp:32:39: error: no match for 'operator&' (operand types are 'gpio_mode_t' and 'unsigned int')
32 | gpio_base->MODER |= ((this->mode & GPIO_MODER_PIN_MASK) << shift);
| ~~~~~~~~~~ ^
| |
| gpio_mode_t
...
Why didn’t we see this in the previous post? Because we were using a traditional enum, which is implicitly converted, so arithmetic operations were allowed without any explicit cast.
With enum class, there are no implicit conversions — the compiler forces us to be explicit. This is actually a feature, not a problem. The compiler is preventing us from accidentally performing arithmetic on enum values without being intentional about it.
As you remember from cppreference documentation for C++;
although
[static_cast](https://en.cppreference.com/w/cpp/language/static_cast.html) may be used to obtain the numeric value of the enumerator.
That’s what we should use the proper casting to use scoped enum in our GPIO driver. I will continue about this topic but I want to just pause in order to introduce type casting types in C++ and understand how they are different than C type casting.
C has a single syntax for explicit type casting, but it can be used for multiple different purposes.
(type) expression
Even though it has a single syntax, it can behaves differently based on context. For example;
// 1. Numeric conversion
int x = 10;
double d = (double)x; // Converts value
// 2. Pointer reinterpretation
int* x = &someInt;
char* c = (char*)x; // Reinterprets memory
// 3. Const removal
const int x = 10;
int* ptr = (int*)&x; // Removes const
C gives you what you want, but the intent is not always clearr for audit or review. That means, you can use casting wrongly, or need to explain explicitly why you remove const or do you want to re-interpret memory.
C++ provides a better approach for type-casting here. The official reference can be found links, static-cast, const-cast and reinterpret-cast, but I want to shortly explain casting type for C++.
static-cast: static-cast is used between related-types. It takes place at compile-time. There is no overhead via run-time.
// Numeric Conversions
int x = 10;
double d = static_cast<double>(x); // ✅ 10.0
int i = 65;
char c = static_cast<char>(i); // ✅ 'A'
float f = static_cast<float>(i); // ✅ 65.0f
You can also use enum class with static-cast.
enum class Color { RED, GREEN, BLUE };
// Enum to integer
int x = static_cast<int>(Color::RED); // ✅ x = 0
// Integer to enum
Color c = static_cast<Color>(1); // ✅ c = GREEN
But you cannot remove const qualifier or convert unrelated pointer types;
int* x = &someInt;
float* f = static_cast<float*>(x); // ❌ Compile error
const int x = 10;
int* ptr = static_cast<int*>(&x); // ❌ Compile error
That means the following conversion will fail via static_cast ;
// Memory-mapped peripheral registers
struct RCC_TypeDef {
volatile uint32_t CR;
volatile uint32_t CFGR;
volatile uint32_t CIR;
};
// ✅ Mapping struct to hardware address
RCC_TypeDef* RCC = static_cast<RCC_TypeDef*>(0x40023800U); // ❌ Compile error
RCC->CR |= 0x01; // Enable clock directly in hardware
That’s why reinterpret_cast is available in C++.
reinterpret_cast: reinterpret_cast is used for low-level memory reinterpretation. It tells the compiler "treat this memory as a different type" with no conversion, no runtime check, and no runtime cost.
So you can compile the code as;
// Memory-mapped peripheral registers
struct RCC_TypeDef {
volatile uint32_t CR;
volatile uint32_t CFGR;
volatile uint32_t CIR;
};
// ✅ Mapping struct to hardware address
RCC_TypeDef* RCC = reinterpret_cast<RCC_TypeDef*>(0x40023800U);
RCC->CR |= 0x01; // Enable clock directly in hardware
const-cast: As you remember, you cannot remove/add const qualifier with static_cast . You have to use const_cast instead.
const int x = 10;
int* ptr = const_cast<int*>(&x); // ✅ Removes const
Note: dynamic_cast is used for polymorphism and requires RTTI, which we explicitly disabled in post 3 — so it is not available in our embedded environment. You can check the details cpp-reference and usage in here.
So, C++ casts are not about doing something new actually. You can do all of this with C casting as well, but they provide clear intent and make dangerous operations visible.
From now on, we can use C++ casting types in our solution.
Let’s go back to our GPIO driver and fix the compilation errors. As you remember we got the following error;
gpio.cpp:30:31: error: no match for 'operator*' (operand types are 'gpio_pin_t' and 'unsigned int')
30 | uint8_t shift = this->pin * GPIO_MODER_BITS_PER_PIN;
| ~~~~~~~~~ ^
| |
| gpio_pin_t
gpio.cpp:32:39: error: no match for 'operator&' (operand types are 'gpio_mode_t' and 'unsigned int')
32 | gpio_base->MODER |= ((this->mode & GPIO_MODER_PIN_MASK) << shift);
| ~~~~~~~~~~ ^
| |
| gpio_mode_t
...
Now, we can use static_cast to get rid of errors. In gpio.cpp file, use the static_cast as below;
void gpio_t::init()
{
// 1. Enable the peripheral clock for this GPIO port
enable_gpio_clock(gpio_base);
// 2. Configure the MODER register (MODER_BITS_PER_PIN bits per pin)
// Clear the two mode bits, then write the requested mode
uint8_t shift = static_cast<uint8_t>(pin) * GPIO_MODER_BITS_PER_PIN;
gpio_base->MODER &= ~(GPIO_MODER_PIN_MASK << shift);
gpio_base->MODER |= ((static_cast<uint8_t>(mode) & GPIO_MODER_PIN_MASK) << shift);
}
void gpio_t::set()
{
gpio_base->ODR |= (1U << static_cast<uint32_t>(pin));
}
void gpio_t::clear()
{
gpio_base->ODR &= ~(1U << static_cast<uint32_t>(pin));
}
If you run now make command, you will see that the code compiles successfully.
arm-none-eabi-g++ -c -mcpu=cortex-m4 -mthumb -mfloat-abi=soft -std=c++20 -Os -ffunction-sections -fdata-sections -Wall -fno-exceptions -fno-rtti -o build/main.o main.cpp
arm-none-eabi-g++ -c -mcpu=cortex-m4 -mthumb -mfloat-abi=soft -std=c++20 -Os -ffunction-sections -fdata-sections -Wall -fno-exceptions -fno-rtti -o build/startup.o startup.cpp
arm-none-eabi-g++ -c -mcpu=cortex-m4 -mthumb -mfloat-abi=soft -std=c++20 -Os -ffunction-sections -fdata-sections -Wall -fno-exceptions -fno-rtti -o build/gpio.o gpio.cpp
arm-none-eabi-g++ -mcpu=cortex-m4 -mthumb -mfloat-abi=soft --specs=nosys.specs --specs=nano.specs -nostartfiles -Wl,--gc-sections -T memory.ld build/main.o build/startup.o build/gpio.o -o build/output.elf
Let’s also replace the GPIOX_BASE declarations with scoped enums. First, I will create a gpio_base_t type for port address declarations in stm32fxxx_regs.h file.
// -------------------------------------------------------
// GPIO register map
// -------------------------------------------------------
typedef struct {
volatile uint32_t MODER; /* 0x00 - Mode register */
volatile uint32_t OTYPER; /* 0x04 - Output type register */
volatile uint32_t OSPEEDR; /* 0x08 - Output speed register */
volatile uint32_t PUPDR; /* 0x0C - Pull-up/pull-down register */
volatile uint32_t IDR; /* 0x10 - Input data register */
volatile uint32_t ODR; /* 0x14 - Output data register */
volatile uint32_t BSRR; /* 0x18 - Bit set/reset register */
} GPIO_RegDef_t;
enum class gpio_base_t {
PORT_A = 0x40020000U,
PORT_B = 0x40020400U,
PORT_C = 0x40020800U,
PORT_D = 0x40020C00U,
PORT_E = 0x40021000U,
};
Then update the constructor and the gpio_port member variable as follows in gpio.hpp file:
class gpio_t {
public:
/* Parameterized constructor */
gpio_t(gpio_base_t gpio_base, gpio_pin_t pin, gpio_mode_t mode);
/* .. */
/** Enable GPIO clock */
inline void enable_gpio_clock(const uintptr_t gpio_base);
private:
/*variables*/
uintptr_t gpio_base;
gpio_pin_t pin;
gpio_mode_t mode;
};
We also need to update the constructor (gpio_t), enable_gpio_clock, set, and clear functions as follows in gpio.cpp file:
inline void gpio_t::enable_gpio_clock(uintptr_t gpio_base)
{
uint32_t port_idx = (gpio_base - static_cast<uintptr_t>(gpio_base_t::PORT_A)) / GPIO_PORT_STRIDE;
RCC_AHB1ENR |= (1U << port_idx);
}
gpio_t::gpio_t(gpio_base_t gpio_base, gpio_pin_t pin, gpio_mode_t mode)
{
this->gpio_base = static_cast<uintptr_t>(gpio_base);
this->pin = pin;
this->mode = mode;
}
void gpio_t::set()
{
reinterpret_cast<GPIO_RegDef_t *>(gpio_base)->ODR |= (1U << static_cast<uint32_t>(pin));
}
void gpio_t::clear()
{
reinterpret_cast<GPIO_RegDef_t *>(gpio_base)->ODR &= ~(1U << static_cast<uint32_t>(pin));
}
Finally, we can update main.cpp file to use proper scope enum for gpio_base_t, pin and mode as follow;
gpio_t green_led(gpio_base_t::PORT_A, gpio_pin_t::PIN_5, gpio_mode_t::MODE_OUTPUT);
Let’s build project with make command and flash it with make flash command. You should see that led blinks as expected.
You can also check size with make size command;
build/output.elf :
section size addr
.text 684 134217728
.rodata 0 134218412
.data 0 536870912
.bss 4 536870912
.comment 69 0
.ARM.attributes 46 0
Total 803
Summary
In this post, we completed the strong type story that we started in the previous post.
By replacing traditional enum with enum class, we gained a compile-time guarantee that is enforced by the C++11 language standard itself — not by a compiler flag, not by convention, and not by discipline. The compiler simply refuses to accept invalid values, making an entire class of hardware misconfiguration bugs impossible.
We also introduced C++ casting types — static_cast, reinterpret_cast, and const_cast. Compared to C's single-syntax casting, C++ casts make the intent explicit and visible. In embedded systems, where every memory access and type conversion matters, this clarity is invaluable during code review and debugging.
The final result speaks for itself:
gpio_t green_led(gpio_base_t::PORT_A, gpio_pin_t::PIN_5, gpio_mode_t::MODE_OUTPUT);
Compare this to where we started:
gpio_t green_led = { GPIOA_BASE, 5U, 1U };
With C++:
- The port, pin, and mode are strongly typed — wrong values are rejected at compile time
- The object is always fully initialized — the constructor enforces it
- The internal state is encapsulated — private members cannot be accidentally modified
- The type conversions are explicit and intentional —
static_castandreinterpret_castmake every dangerous operation visible
All of this with zero runtime cost — every guarantee is enforced at compile time. This is the true power of C++ in embedded systems.
Don’t forget that the best APIs don’t provide only easy to use, but provide hard to misuse!
Reference
메타데이터
- post_id
- 8f897a99fd0e
- slug
- from-scratch-exploring-bare-metal-c-part-6-8f897a99fd0e
- url
- https://medium.com/@ozgunkgunyeli/from-scratch-exploring-bare-metal-c-part-6-8f897a99fd0e
- canonical_url
- https://medium.com/@ozgunkgunyeli/from-scratch-exploring-bare-metal-c-part-6-8f897a99fd0e
- author_url
- https://medium.com/@ozgunkgunyeli
- status
- ok
- fetched_at
- 2026-06-09 15:37:30