,

Embedded System Programming Tricks: Mastering C for Embedded Systems

by

Embedded systems are everywhere—from your smartwatch to your car’s engine control unit. These systems are resource-constrained, requiring efficient and optimized code to perform tasks reliably. Programming for embedded systems often involves working with low-level hardware, real-time constraints, and limited memory. Here are some essential tricks and concepts to help you write better embedded system code in C.

1. Understand the Hardware

Before writing a single line of code, familiarize yourself with the hardware:

  • Microcontroller Architecture: Know the CPU, memory, and peripherals.
  • Datasheets: Study the microcontroller’s datasheet to understand registers, pin configurations, and timing requirements.
  • Memory Map: Understand the memory layout (RAM, Flash, EEPROM).

Trick: Use volatile keyword for hardware registers to prevent compiler optimizations that could break your code.

define GPIO_PORT ((volatile unsigned int)0x40020000)

2. Optimize Memory Usage

Embedded systems often have limited RAM and Flash memory. Use these tricks to save space:

  • Use const for constants: Store constants in Flash instead of RAM.
const uint8_t LED_PATTERN[] = {0x01, 0x03, 0x07, 0x0F};
  • Avoid dynamic memory allocation: Use static or stack allocation instead of malloc() to prevent fragmentation.
  • Pack data structures: Use #pragma pack or __attribute__((packed)) to minimize padding.
struct __attribute__((packed)) SensorData {
    uint8_t id;
    uint32_t value;
};

3. Bit Manipulation Tricks

Bitwise operations are essential for embedded systems to control hardware registers and save memory:

  • Set a bit:
PORT |= (1 << PIN);  // Set PIN high
  • Clear a bit:
PORT &= ~(1 << PIN); // Set PIN low

Toggle a bit:

PORT ^= (1 << PIN);  // Toggle PIN

Check a bit:

if (PORT & (1 << PIN)) { /* PIN is high */ }

Trick: Use bitfields for compact representation of flags or settings.

struct {
    uint8_t flag1 : 1;
    uint8_t flag2 : 1;
    uint8_t reserved : 6;
} status;

4. Use Inline Functions and Macros

Inline Functions

Inline functions are a way to suggest to the compiler to replace the function call with the actual code of the function. This reduces the overhead of function calls, which is especially useful in embedded systems where performance and memory are critical.

Example: Inline Function for Delay

static inline void delay(uint32_t ms) {
    for (uint32_t i = 0; i < ms * 1000; i++) {
        // Introduce a small delay by doing nothing
        __asm__ volatile ("nop"); // No operation (assembly instruction)
    }
}

Why use inline?

  • Reduces function call overhead.
  • Improves performance for small, frequently called functions.

When to use inline?

  • For small functions that are called frequently.
  • When the function is simple and doesn’t involve complex logic.

Macros

Macros are preprocessor directives that replace text before compilation. They are powerful but should be used carefully to avoid unexpected behavior.

Example: Macro for Minimum Value

#define MIN(a, b) ((a) < (b) ? (a) : (b))

Usage:

uint8_t x = 10, y = 20;
uint8_t min_value = MIN(x, y); // min_value will be 10

Caution with Macros:

  • Macros don’t perform type checking, which can lead to bugs.
  • Always use parentheses around arguments to avoid precedence issues.

Example: Debugging Macro

#define DEBUG_LOG(message) \
    do { \
        printf("[DEBUG] %s:%d: %s\n", __FILE__, __LINE__, message); \
    } while (0)

Usage:

DEBUG_LOG("Starting main loop"); // Output: [DEBUG] main.c:25: Starting main loop

5. Interrupt Handling

Interrupts are essential for real-time systems. They allow the microcontroller to respond immediately to external events without polling.

Best Practices for Interrupt Service Routines (ISRs):

  1. Keep ISRs Short: ISRs should execute quickly and return control to the main program.
  2. Use volatile for Shared Variables: This ensures the compiler doesn’t optimize away reads/writes to variables shared between the ISR and main code.
  3. Disable Interrupts in Critical Sections: Prevent race conditions by disabling interrupts when accessing shared resources.

Example: Simple ISR

volatile uint8_t button_pressed = 0;

void EXTI0_IRQHandler(void) {
    if (EXTI->PR & (1 << 0)) { // Check if interrupt is from EXTI0
        button_pressed = 1;    // Set flag
        EXTI->PR |= (1 << 0);  // Clear pending bit
    }
}

Main Code:

while (1) {
    if (button_pressed) {
        // Handle button press
        button_pressed = 0; // Clear flag
    }
}

6. Optimize Loops and Conditions

Loop Unrolling

Loop unrolling reduces the overhead of loop control by executing multiple iterations in a single loop cycle.

Example: Loop Unrolling

// Normal loop
for (int i = 0; i < 4; i++) {
    data[i] = 0;
}

// Unrolled loop
data[0] = 0;
data[1] = 0;
data[2] = 0;
data[3] = 0;

When to use loop unrolling?

  • When the loop has a small, fixed number of iterations.
  • When performance is critical.

Avoid Floating-Point Operations

Floating-point operations are computationally expensive on most microcontrollers. Use fixed-point arithmetic instead.

Example: Fixed-Point Arithmetic

int16_t temperature_raw = 512; // Raw ADC value
int16_t temperature_celsius = (temperature_raw * 100) >> 10; // Convert to Celsius (fixed-point)

7. Debugging and Logging

LED Debugging

Use LEDs to indicate the state of the program. This is a simple and effective way to debug hardware.

Example: LED Debugging

#define LED_ON()  (GPIO_PORT |= (1 << LED_PIN))
#define LED_OFF() (GPIO_PORT &= ~(1 << LED_PIN))

void indicate_error() {
    for (int i = 0; i < 3; i++) {
        LED_ON();
        delay(500);
        LED_OFF();
        delay(500);
    }
}

Serial Logging

Implement a lightweight logging mechanism using UART.

Example: UART Logging

void uart_send_char(char c) {
    while (!(UART->SR & UART_SR_TXE)); // Wait for TX buffer to be empty
    UART->DR = c; // Send character
}

void log(const char* message) {
    while (*message) {
        uart_send_char(*message++);
    }
    uart_send_char('\n'); // Newline
}

Usage:

log("System initialized");

8. Power Optimization

Sleep Modes

Use low-power modes to save energy when the system is idle.

Example: Entering Sleep Mode

void enter_sleep_mode() {
    SCB->SCR |= SCB_SCR_SLEEPONEXIT_Msk; // Enable sleep-on-exit
    __WFI(); // Wait for interrupt
}

Clock Gating

Disable clocks for unused peripherals to save power.

Example: Disabling Peripheral Clocks

RCC->AHB1ENR &= ~(RCC_AHB1ENR_GPIOAEN); // Disable GPIOA clock

9. Testing and Validation

Unit Testing

Test individual modules in isolation to ensure they work as expected.

Example: Unit Test for a Function

int add(int a, int b) {
    return a + b;
}

void test_add() {
    assert(add(2, 3) == 5);
    assert(add(-1, 1) == 0);
}

Hardware-in-the-Loop (HIL) Testing

Test your code on actual hardware to validate its behavior.

Static Analysis

Use tools like cppcheck or PC-lint to catch potential issues in your code.

Conclusion

By applying these detailed tricks and techniques, you can write efficient, reliable, and maintainable embedded system code. Always remember to test thoroughly and optimize for both performance and power consumption.