Navigating Memory in C++: A Guide to Using std::uintptr_t for Address Handling

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.

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 Code

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?

 

 

Niall Cooling
Dislike (0)
Website | + posts

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.

About Niall Cooling

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.
This entry was posted in ARM, C/C++ Programming, Testing and tagged , , , . Bookmark the permalink.

Leave a Reply