How to Use Pointers Properly in Embedded Development? What Issues Can Pointer Operations Cause?

In embedded development, pointers are core tools for directly accessing hardware and optimizing memory access. However, due to the limited resources of embedded systems (small memory, limited computing power) and the nature of direct hardware interaction, improper use of pointers can lead to serious issues such as system crashes and hardware anomalies. This article will elaborate on the “principles of proper pointer usage” and “common issues and their causes.”

01

Principles of Using Pointers in Embedded Development

The core value of pointers is “direct access to memory/hardware addresses” and “efficient data manipulation.” In the context of embedded systems, the following principles should be followed:

1. Clearly define the pointer’s target scenario to avoid meaningless pointer operations.

Typical uses of pointers in embedded systems include fixed address access (hardware registers), array/buffer operations, and function pointer jumps (interrupts/state machines). Design should be targeted:

  • When accessing hardware registers, use volatile modifier: The value of hardware registers may be asynchronously modified by peripherals (e.g., UART receive buffer, timer count register). The volatile keyword prevents the compiler from optimizing (avoiding “merging” or “omitting” register reads/writes). For example:

// Accessing the UART transmit register at address 0x40001000 (32-bit), macro definition is an efficient way to access embedded registers#define UART_TX_REG (*(volatile uint32_t*)0x40001000)

  • When performing array/buffer operations, strictly control boundaries: Embedded memory is usually allocated in blocks (e.g., stack, static buffers). When accessing arrays with pointers, the range should be limited by a length variable to avoid overflow. For example:

uint8_t buf[128];uint8_t* p = buf;uint32_t len = 128; // Record buffer length// Check boundary during writing: p < buf + lenwhile (p < buf + len && data_available) {    *p++ = get_data();}

  • Function pointers are used for fixed logical jumps: In interrupt service routines (ISRs) and state machines, function pointers should point to specific functions (avoiding dynamic modification) and ensure that the functions comply with calling conventions (e.g., ARM’s __irq modifier). For example:

// Interrupt vector table (constant function pointer array, const modifies the pointer itself to ensure it points to an immutable address)void (* const irq_handlers[])(void) = {    NULL,    // Reset    timer_isr, // Timer interrupt    uart_isr  // UART interrupt};

2. Avoid dynamic memory misuse; prioritize managing static pointers.

Embedded systems have small memory (usually in KB), and malloc/free may lead to memory fragmentation and decreased real-time performance (allocation time is uncertain). In systems with high real-time requirements or extremely limited resources, malloc/free should be completely avoided, and pointers should preferably point to static memory:

  • Use static arrays/global variables instead of dynamic allocation: Pointers should point to predefined static buffers to avoid memory leaks. For example:

// Static buffer (allocated at compile time, lifecycle same as program)static uint8_t rx_buf[64];uint8_t* rx_ptr = rx_buf; // Pointer points to static memory, no need to free

  • If dynamic allocation is necessary, prefer using memory pools: Memory pools can avoid fragmentation and improve allocation efficiency. For example:

// Static memory pool (256 bytes)static uint8_t memory_pool[256];// Memory pool allocation functionuint8_t *allocate_memory(size_t size) {    static uint8_t *pool_ptr = memory_pool; // Points to the current available memory starting address    if (pool_ptr + size <= memory_pool + sizeof(memory_pool)) {        uint8_t *result = pool_ptr;        pool_ptr += size; // Move pointer to mark as allocated        return result;    }    return NULL; // Insufficient memory}
  • When malloc is necessary, strictly check the return value: malloc returns NULL on failure, and validity should be checked before use, and free should be called promptly (to avoid double freeing). For example:

uint8_t* tmp = malloc(32);if (tmp == NULL) {    // Handle memory shortage (e.g., reset, error)    return -1;}// Free after usefree(tmp);tmp = NULL; // Avoid dangling pointer

3. Limit pointer scope to reduce accidental modifications.

The smaller the visibility of a pointer, the lower the probability of erroneous operations:

  • Prefer using local pointers: Pointers within a function are only valid during the function’s execution, avoiding global pointers being accidentally modified by multiple modules.

  • Use const to protect data and the pointer itself: The position of const determines the protected object, and should be chosen based on the scenario:

const uint8_t* p (or uint8_t const* p): pointer to constant data (data cannot be modified, pointer can be modified);

uint8_t* const p: the pointer itself is constant (points cannot be modified, data can be modified); for example:

const uint8_t config[] = {0x01, 0x02, 0x03};const uint8_t* const cfg_ptr = config; // Both data and pointer itself cannot be modified, double protection

4. Pay attention to pointer types and alignment requirements.

Embedded processors (e.g., ARM, RISC-V) usually have strict memory alignment requirements (e.g., 32-bit data must be 4-byte aligned), and pointer type conversions must match hardware characteristics:

  • Avoid arbitrary pointer type conversions: For example, forcibly converting char* (1-byte aligned) to uint32_t* (4-byte aligned) may trigger hardware exceptions (e.g., ARM’s Data Abort). If conversion is necessary, ensure address alignment:

uint8_t buf[8] __attribute__((aligned(4))); // Force 4-byte alignment (GCC attribute)uint32_t* p = (uint32_t*)buf; // Safe: buf address is a multiple of 4

  • Control structure alignment through compiler options: Complex data structures can specify alignment using #pragma pack to avoid wasted space or alignment errors caused by natural alignment. For example:

#pragma pack(push, 4) // Force 4-byte alignmentstruct PeripheralRegs {    uint32_t ctrl;  // Control register    uint32_t status;// Status register};#pragma pack(pop) // Restore default alignmentvolatile struct PeripheralRegs* periph = (volatile struct PeripheralRegs*)0x40003000;
  • Pointer arithmetic must match types: The offset of pointer p++ is determined by the type (offset 4 bytes for uint32_t*, offset 1 byte for char*), to avoid address calculation errors due to type mismatches.

02

Common Issues and Causes of Pointer Operations

In embedded systems, the consequences of pointer errors are often more severe (directly related to hardware). Common issues include:

1. Dangling pointers (wild pointers)

  • Definition: Uninitialized pointers (value is undefined) or pointers pointing to released memory (value is an invalid address), whose target addresses are illegal.

  • Hazard: Accessing dangling pointers may modify arbitrary memory (e.g., overwriting function return addresses, hardware registers), leading to program crashes or incorrect hardware configurations (e.g., incorrectly writing GPIO registers causing peripheral anomalies).

  • Common causes:

Uninitialized pointer: uint8_t* p; *p = 0x55; (p’s value is residual data from the stack, undefined);

Not nullifying after release: free(p); *p = 0x55; (p points to released invalid memory);

Returning a pointer to a local variable:

uint8_t* get_local_ptr() {    uint8_t local_var = 0x55; // Local variable on the stack, released after function exits    return &amp;local_var; // Error: returned pointer points to released stack memory, becoming a dangling pointer}

2. Memory leaks

  • Definition: Dynamically allocated memory that has not been released, causing the pointer to lose its reference, leading to memory being unreusable.

  • Hazard: Embedded memory is small (e.g., 64KB RAM), and leaks can quickly exhaust memory, causing subsequent allocations to fail and return NULL. Directly using NULL will trigger null pointer dereference.

  • Example:

void func() {    uint8_t* p = malloc(16);// Forgetting to free(p), after function exits, pointer p (stack variable) is destroyed, and the 16-byte heap memory it pointed to is lost and cannot be released}

3. Array out-of-bounds

  • Definition: Pointer accessing an array exceeds its actual range (e.g., p >= buf + len).

  • Hazard: Out-of-bounds writes may overwrite adjacent memory (e.g., function parameters on the stack, return addresses), leading to incorrect program jumps; if the out-of-bounds address is a hardware register, it may directly damage peripherals (e.g., incorrectly writing Flash control registers causing erasure).

  • Avoidance methods: Use safe functions (e.g., prefer using strncpy for string operations instead of strcpy):

char buf[10];strncpy(buf, "HelloWorld!", sizeof(buf) - 1); // Limit copy length, reserve space for null terminatorbuf[sizeof(buf) - 1] = '\0'; // Ensure string ends

4. Alignment errors

  • Definition: The address pointed to by the pointer does not meet the processor’s alignment requirements (e.g., 32-bit data address is not a multiple of 4).

  • Hazard: Most embedded processors will trigger hardware exceptions (e.g., ARM’s HardFault), directly causing system resets; some processors (e.g., Cortex-M0) allow unaligned access but will reduce efficiency (requiring multiple bus operations).

  • Example:

uint8_t buf[5];uint32_t* p = (uint32_t*)(buf + 1); // Address is 0xXXXXXXX1 (not 4-byte aligned)*p = 0x12345678; // Trigger HardFault

5. Missing volatile leading to register access errors

  • Definition: Accessing hardware registers without using volatile modifier on the pointer, causing the compiler to optimize and omit actual reads/writes.

  • Hazard: Peripherals cannot respond correctly (e.g., UART sending data is skipped, timer start commands are optimized away).

  • Using volatile pointers in functions: When passing register pointers, the volatile modifier must be retained to ensure operations take effect:

// Writing value to register (retain volatile modifier to avoid compiler optimization)void write_register(volatile uint32_t *reg, uint32_t value) {    *reg = value; // Ensure actual write to hardware register}// Call: write start command to timer register at address 0x40002000write_register((volatile uint32_t*)0x40002000, 1);

Previous Articles:

Introduction to Qt for MCUs Tools

Explanation of several terms related to Brushless DC Motors (BLDC): pole pairs, electrical angle, electrical frequency, phase voltage, line voltage, back EMF

What is bare-metal development? How does it differ from RTOS-based development?

Usage of semaphores in FreeRTOS

Compilation of coding standards for embedded software

Sharing an open-source automation code generation tool – XRobot

Differences between Embedded C and Standard C

Common embedded processor architectures

Open-source project sharing: ESP32-S3 large model AI desktop robot

Leave a Comment