In the modern landscape of embedded software development, efficiency and safety are paramount. As applications become more complex and demands on performance and security increase, developers turn to every tool in their arsenal to meet these challenges. Enter std::uintptr_t
, a data type that, while not new, is sadly overlooked in most embedded codebases. This guide aims to introduce std::uintptr_t
, showcasing its benefits and demonstrating how to use it effectively in your projects.
This article is written using C++ examples, but it is as applicable to modern C as C++. I have chosen to focus on C++ because, in general, C++ developers tend to be more type-aware than most C programmers. I’m aware that this is a gross generalisation, but I can only talk from experience.
Contents
Understanding std::uintptr_t: The Key to Portable Address Manipulation
At its core, std::uintptr_t
is a versatile unsigned integer type designed to store the entire address range of any object in C++, ensuring portability and precision. std::uintptr_t
is defined in the <cstdint>
header as part of the C Standard Library. It was introduced in C99 and C++11 respectively.
This data type is specifically designed to allow address manipulation in a portable and safe manner, which is especially useful in low-level programming tasks, such as interacting with hardware or performing memory management operations.
Why Use std::uintptr_t
?
Portability and Safety
The primary advantage of std::uintptr_t
is its portability. Since it’s guaranteed to hold any object’s address, developers can use it to write more portable code across different platforms and architectures. This is particularly important in embedded systems and applications requiring direct memory access and manipulation.
Some argue that using std::uintptr_t
enhances the safety of code. Using a type specifically designed to hold address values could reduce the risk of integer overflow and other bugs associated with pointer arithmetic. I’m not sure this argument holds water; we still have a fundamental problem on unsigned integer wrap, which std::uintptr_t
doesn’t address.
Interfacing with Hardware and OS
std::uintptr_t
is most appropriate in scenarios where direct interaction with the hardware or operating system is necessary. Whether you’re writing drivers, operating systems, or performance-critical software, std::uintptr_t
allows for precise control over memory addresses, facilitating efficient and direct manipulation of hardware registers or system resources.
Best Practices for Using std::uintptr_t
While std::uintptr_t
is a powerful tool, it’s essential to use it judiciously to avoid pitfalls. Here are some best practices to keep in mind:
- Use for Address Manipulation Only: Reserve
std::uintptr_t
when you need to interface with system-level resources. It’s not intended for general-purpose integer arithmetic. - Avoid Unnecessary Casts: Casting between pointers and integers can lead to loss of information and undefined behaviour if not done carefully. Ensure that casts are necessary and performed safely.
- Understand Platform Specifics: The size of
std::uintptr_t
is platform-dependent. Be mindful of this when writing portable code, especially when targeting 32-bit and 64-bit environments.
Practical Examples
Example 1: Hardware Register Access
This is probably the most common scenario. Let’s assume we’re programming a device driver for a 32-bit processor (such as the Arm Cortex-M family) and want to abstract it in a C++ class. We decide to pass the address of the device’s base hardware register to the class’s constructor.
- Note: Please don’t get distracted by the minutia of the example; I realise there are many different ways we could define this class API; this is just one viable alternative
Our initial abstraction might look something like this:
#include <cstdint>
namespace HAL {
class UART {
public:
explicit UART(std::uint32_t base_address);
void write(std::byte byte);
std::byte read() const;
// ...
private:
struct Registers* const registers;
};
}
constexpr std::uint32_t com1_based_address = 0x4002'0000U;
int main()
{
HAL::UART com1{com1_based_address};
com1.write(std::byte{0x5A});
auto val = com1.read();
...
}
And, of course, this is perfectly fine; it compiles and runs successfully on our target. Using std::uint32_t
as the parameter type for code targeting a 32-bit processor matches the fact our address range can be represented as an unsigned 32-bit integer.
Host-Based Unit Testing
Let’s assume we’d like to follow modern, good practices and, where possible, do host-based testing before delivering to our target.
So, for this example, we can abstract the actual hardware to static memory to simulate its behaviour and check register values during unit tests.
In our test file, we can go ahead and create out Test Double for the hardware registers:
namespace {
struct Registers // layout of hardware UART
{
std::uint32_t status; // status register
std::uint32_t data; // data register
std::uint32_t baud_rate; // baud rate register
...
std::uint32_t guard_prescaler; // Guard time and prescaler register
};
static_assert(sizeof(Registers) == 40, "Registers struct has padding");
Registers Mock_registers{};
} // namespace
We then code up our first unit test. Instead of passing in the fixed, absolute address for the hardware, we pass the address of a static object that maps to the layout as the hardware registers. Naturally, we have to cast the address of the object to pass as an integer:
// doctest
TEST_CASE("UART Construction") {
constexpr std::uint32_t baud_115k = 0x8b;
constexpr std::uint32_t b8_np_1stopbit = 0x200c;
HAL::UART com3 {reinterpret_cast<std::uint32_t>(&Mock_registers)};
CHECK(Mock_registers.baud_rate == baud_115k);
CHECK(Mock_registers.ctrl_1 == b8_np_1stopbit);
}
Unfortunately, on our 64-bit host system, this test will not build as the address of the Mock_registers
is a 64-bit address, and we are trying to map it to std::uint32_t
.
/workspaces/.../tests/test_UART.cpp:54:21: error: cast from '{anonymous}::Registers*' to 'uint32_t' {aka 'unsigned int'} loses precision [-fpermissive]
54 | HAL::UART com3 {reinterpret_cast<std::uint32_t>(&Mock_registers)};
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
gmake[2]: *** [tests/CMakeFiles/UARTTest.dir/build.make:76: tests/CMakeFiles/UARTTest.dir/test_UART.cpp.o] Error 1 ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Enter std::uintptr_t
; the code compiles and works if we rework the constructor parameters as std::uintptr_t
rather than std::uint32_t
.
#include <cstdint>
namespace HAL {
class UART {
public:
explicit UART(std::uintptr_t base_addr);
...
};
}
// doctest
TEST_CASE("UART Construction") {
constexpr std::uint32_t baud_115k = 0x8b;
constexpr std::uint32_t b8_np_1stopbit = 0x200c;
HAL::UART com3 {reinterpret_cast<std::uintptr_t>(&Mock_registers)};
CHECK(Mock_registers.baud_rate == baud_115k);
CHECK(Mock_registers.ctrl_1 == b8_np_1stopbit);
}
It is now portable between the host-based 64-bit test environment and the 32-bit target platform.
Example 2: Writing to Flash
It is common in embedded systems to write to Flash memory at runtime, whether for configuration parameters, logs, user data or updates.
Most modern microcontrollers have a manufacturer-supplied Hardware Abstract Layer (HAL) to abstract more complex operations.
For example, the STM32 family from STMicroelectronics has a very comprehensive HAL. Using the HAL, it becomes far easier to implement a routine to copy data to Flash.
For example, some code from a library writing to Flash (caveat: this not our code):
uint32_t offset = 0,
address = SLOT_ADDRESS(current_slot),
address_end = address + DEVICE_EEPROM_SIZE,
data = 0;
while (address < address_end) {
memcpy(&data, ram_eeprom + offset, sizeof(data));
status = HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, address, data); // HAL call
if (status == HAL_OK) {
address += sizeof(uint32_t);
offset += sizeof(uint32_t);
}
else {
// ... error handling
break;
}
}
The particular HAL call HAL_FLASH_Program
has the API:
HAL_StatusTypeDef HAL_FLASH_Program ( uint32_t TypeProgram,
uint32_t Address,
uint64_t Data )
Program double word or fast program of a row at a specified address.
Note that the parameter Address
specifies the address to be programmed and is specified as uint32_t
.
Ideally, the API for HAL_FLASH_Program
would use uintptr_t
for the Address
parameter type.
Signed Integer Address Type std::intptr_t
std::intptr_t
is a signed integer type that is guaranteed to be capable of holding a pointer to any object type. Like std::uintptr_t
, it’s defined in the header. The crucial difference lies in its signedness, making std::intptr_t
suitable for situations where arithmetic operations on pointers could result in negative values.
The choice between std::uintptr_t
and std::intptr_t
often comes down to the specific needs of your application, particularly regarding the nature of your pointer arithmetic:
- Negative Offsets: If you need to compute offsets that might be negative,
std::intptr_t
is the go-to choice. It allows for a more natural handling of negative values, which can be crucial in specific algorithms or memory manipulation techniques. - Comparisons and Differences: When calculating the difference between two pointers or when such calculations might result in a negative value,
std::intptr_t
provides a safer and more intuitive way to represent these values.
Conclusion
When people ask me about the difference between software engineering and programming, understanding type safety is one of the core tenets. std::uintptr_t
is a valuable tool in the modern C/C++ developer’s toolkit, especially for those working close to the metal or requiring fine-grained control over memory. By understanding its uses, advantages, and best practices, you can harness its power to write more efficient, portable, and safe code.
As we’ve seen, std::uintptr_t
is more than just a data type; it bridges high-level programming and the intricate world of hardware and memory management. Its judicious use can improve C++ codebases, ensuring they perform well and stand the test of time across various platforms and architectures.
As you embark on your next project involving direct memory manipulation, consider the robust capabilities of std::uintptr_t
to ensure your code is efficient, portable, and secure. How might std::uintptr_t
improve your current or upcoming projects?
- Navigating Memory in C++: A Guide to Using std::uintptr_t for Address Handling - February 22, 2024
- Embedded Expertise: Beyond Fixed-Size Integers; Exploring Fast and Least Types - January 15, 2024
- Disassembling a Cortex-M raw binary file with Ghidra - December 20, 2022
Co-Founder and Director of Feabhas since 1995.
Niall has been designing and programming embedded systems for over 30 years. He has worked in different sectors, including aerospace, telecomms, government and banking.
His current interest lie in IoT Security and Agile for Embedded Systems.