Unit Testing Objects and Boundaries in Embedded C Projects

Introduction

In embedded system development, the implementation of unit testing has long been constrained by hardware dependencies, code structure limitations, and the difficulty of test case design. Many teams attempting to introduce automated testing often wonder: Which code is worth unit testing? How should the boundaries of testing be defined? What content is suitable for using Mock/Stub/Fake? This article will systematically outline the objects, boundaries, and common difficulties of unit testing in embedded C projects based on practical experience, helping developers scientifically and efficiently advance the construction of a unit testing system.

1. What is Unit Testing? — Understanding from an Embedded Perspective

Unit Testing refers to the verification of the smallest testable unit of software (usually a function or class) to ensure its behavior meets expectations. In the embedded field, a unit is typically a C function, an API of a module, or specific data processing logic.

The Three Elements of Unit Testing

  • Granularity: Focus on the smallest functional unit, making it easier to locate issues.
  • Isolation: During testing, it should be decoupled from external dependencies (hardware, RTOS, peripherals, etc.).
  • Automation: Can be run in batches without human intervention, producing results.

Note: In embedded development, hardware-related code, register operations, and logic that is heavily coupled with peripherals are often the difficulties and boundaries of unit testing.

2. Types of Embedded Code Suitable for Unit Testing

Embedded C projects often contain multiple layers of structure, and the objects that are actually suitable for unit testing mainly focus on the following aspects:

2.1 Pure Algorithms and Data Processing Logic

  • • CRC, encryption/decryption, checksums, etc.
  • • Numerical calculations, filtering, state machines
  • • Protocol parsing/framing, packet processing

Example 1: CRC Calculation Function

Tested Code (crc16.c)

#include <stdint.h>

uint16_t crc16_calc(const uint8_t *data, uint16_t len)
{
    uint16_t crc = 0xFFFF;
    for (uint16_t i = 0; i < len; i++) {
        crc ^= data[i];
        for (uint8_t j = 0; j < 8; j++) {
            if (crc & 1)
                crc = (crc >> 1) ^ 0xA001;
            else
                crc >>= 1;
        }
    }
    return crc;
}

Unit Test (test_crc16.c)

#include "unity.h"
#include "crc16.h"

void test_crc16_should_return_expected_value_for_known_data(void)
{
    uint8_t buf[] = {0x12, 0x34, 0x56, 0x78};
    // Assume known expected CRC value
    TEST_ASSERT_EQUAL_HEX16(0x30EC, crc16_calc(buf, sizeof(buf)));
}

This type of code has clear input and output, making it easy to Mock and verify.

2.2 Business Logic Layer and Protocol Stack

  • • Command parsing, middleware, business process control
  • • Communication protocol state machines
  • • Event handling, message scheduling

Example 2: Protocol Packet Validation Function

Tested Code (protocol.c)

#include <stdbool.h>
#include <stddef.h>

bool is_valid_packet(const uint8_t *buf, size_t size)
{
    if (size < 4) return false;
    // Simple protocol: first 2 bytes are header, last 2 bytes are checksum
    uint16_t header = (buf[0] << 8) | buf[1];
    uint16_t checksum = (buf[size-2] << 8) | buf[size-1];
    if (header != 0xAABB) return false;

    uint16_t sum = 0;
    for (size_t i = 0; i < size-2; i++) sum += buf[i];
    return sum == checksum;
}

Unit Test (test_protocol.c)

#include "unity.h"
#include "protocol.h"

void test_is_valid_packet_should_return_true_for_good_packet(void)
{
    uint8_t pkt[] = {0xAA, 0xBB, 0x01, 0x02, 0x01, 0x5E};
    // sum = 0xAA + 0xBB + 0x01 + 0x02 + 0x01 = 0x169, checksum=0x015E
    TEST_ASSERT_TRUE(is_valid_packet(pkt, sizeof(pkt)));
}

void test_is_valid_packet_should_return_false_for_bad_header(void)
{
    uint8_t pkt[] = {0x12, 0x34, 0x01, 0x02, 0x00, 0x00};
    TEST_ASSERT_FALSE(is_valid_packet(pkt, sizeof(pkt)));
}

This type of code usually depends on low-level driver interfaces, which can be isolated for unit testing through interface Mocking.

2.3 Device-Independent Driver Modules

  • • Virtual devices, software-simulated peripherals
  • • HAL abstraction layer (e.g., upper-level APIs for general GPIO, UART, SPI interfaces)

Example 3: Unit Testing of GPIO Abstraction Layer (Using Fake/Stub)

Tested Code (hal_gpio.c)

#include "hal_gpio.h"

static int last_pin = -1;

void hal_gpio_set(int pin, int value)
{
    // Actual implementation would manipulate registers/hardware
    last_pin = pin; // Assume for example
}

int hal_gpio_get_last_pin(void)
{
    return last_pin;
}

Test Code (test_hal_gpio.c)

#include "unity.h"
#include "hal_gpio.h"

void test_hal_gpio_set_should_record_last_pin(void)
{
    hal_gpio_set(5, 1);
    TEST_ASSERT_EQUAL_INT(5, hal_gpio_get_last_pin());
}

In actual projects, it is recommended to abstract the low-level driver interfaces and replace hardware operations with Fake/Stub during testing.

If well abstracted, good unit test coverage can be achieved by shielding specific hardware details through Stubs or Mocks.

2.4 Configuration Parsing and Parameter Management

  • • Configuration file/Flash parameter parsing
  • • Parameter validation, boundary checking

With controllable input and output, this is suitable for unit testing to validate robustness.

3. Defining Boundaries for Unit Testing

In actual engineering, not all code is suitable for unit testing. The following content is generally not recommended for direct unit testing:

3.1 Low-Level Drivers Tightly Coupled with Hardware

  • • Drivers that directly access registers
  • • Code that depends on clock, interrupts, or peripheral responses

Example 4: Mocking Peripheral Interfaces with CMock

Interface Definition (i2c_hal.h)

int i2c_write(uint8_t addr, const uint8_t *data, int len);

Business Code Depends on I2C Write Interface (sensor.c)

#include "i2c_hal.h"

int sensor_init(void)
{
    uint8_t cmd[] = {0x01, 0x02};
    return i2c_write(0x50, cmd, 2);
}

Generated Mock Code (usually auto-generated by CMock)

// Mocki2c_hal.h
void i2c_write_ExpectAndReturn(uint8_t addr, const uint8_t* data, int len, int to_return);

Test Code (test_sensor.c)

#include "unity.h"
#include "sensor.h"
#include "Mocki2c_hal.h"

void test_sensor_init_should_write_correct_command(void)
{
    uint8_t expected_cmd[] = {0x01, 0x02};
    i2c_write_ExpectAndReturn(0x50, expected_cmd, 2, 0);
    TEST_ASSERT_EQUAL_INT(0, sensor_init());
}

This type of code is recommended to be validated during integration testing or hardware-in-the-loop (HIL) testing.

3.2 Bootloader, Startup Assembly, Bare-Metal Initialization

  • • Hardware initialization, startup processes
  • • Code closely related to CPU startup and peripheral power-up

These are usually difficult to cover with unit testing frameworks and are suitable for validation through integration testing or specialized debugging tools.

3.3 Task/Interrupt Handling Tightly Coupled with RTOS Kernel

  • • Task scheduling, ISR servicing, semaphores, etc.
  • • Code involving multithreading race conditions and real-time performance

It is recommended to validate through integration testing or the RTOS’s own testing framework, with unit testing primarily focusing on the pure logic parts within tasks.

4. Common Testing Difficulties and Coping Strategies

4.1 Excessive External Dependencies

Problem: Functions depend on hardware registers and peripheral responses, making isolation difficult.

Solution: Abstract hardware access through interfaces, encapsulating them as Mockable APIs. Tools like Unity + CMock can automatically generate Mocks.

4.2 Global Variables and Singleton Modules

Problem: Global state is difficult to isolate between tests, easily causing interference between test cases.

Solution: Refactor into modules with injectable dependencies, or reset global state before and after tests.

Example 5: Isolation and Resetting of Global State

Tested Code (counter.c)

static int counter = 0;
void counter_inc(void) { counter++; }
int counter_get(void) { return counter; }
void counter_reset(void) { counter = 0; }

Test Code (test_counter.c)

#include "unity.h"
#include "counter.h"

void setUp(void)  // Standard initialization function for Unity framework
{
    counter_reset();
}

void test_counter_should_increase(void)
{
    counter_inc();
    TEST_ASSERT_EQUAL_INT(1, counter_get());
}

4.3 Dynamic Memory and Resource Constraints

Problem: Embedded projects often limit dynamic memory, requiring test cases to be streamlined.

Solution: Test code should also adhere to resource constraints, and if necessary, validate in a PC simulation environment before porting.

4.4 Real-Time and Timing Dependencies

Problem: Logic that depends on precise timing or hardware events is difficult to unit test.

Solution: Abstract timing and external events into mockable functions or callbacks, using Fakes to simulate in tests.

Example 6: Simulating External Events with Fakes

Interface Definition (button.h)

typedef int (*button_read_func_t)(void);
void button_set_read_func(button_read_func_t func);
int button_is_pressed(void);

Tested Code (button.c)

static button_read_func_t read_func = 0;
void button_set_read_func(button_read_func_t func) { read_func = func; }
int button_is_pressed(void) { return read_func ? read_func() : 0; }

Test Code (test_button.c)

#include "unity.h"
#include "button.h"

static int fake_button_state = 0;
static int fake_button_read(void) { return fake_button_state; }

void setUp(void)
{
    button_set_read_func(fake_button_read);
}

void test_button_is_pressed_should_reflect_fake_state(void)
{
    fake_button_state = 1;
    TEST_ASSERT_TRUE(button_is_pressed());

    fake_button_state = 0;
    TEST_ASSERT_FALSE(button_is_pressed());
}

5. Unit Testing Object Identification Process (Practical Suggestions)

  1. 1. Clarify Module Dependencies: Identify which depend on hardware and which are pure logic.
  2. 2. Define Testable Units: Prioritize pure logic parts such as algorithms, protocol parsing, and business processing.
  3. 3. Abstract External Dependencies: Separate hardware-related content from logic code through interfaces or module layers.
  4. 4. Plan Test Cases: Cover typical scenarios including normal paths, boundary conditions, and exceptional inputs.
  5. 5. Layered Advancement of Testing System: Unit testing for logic layers, Mock testing for driver/HAL layers, and integration testing for low-level hardware.

6. Conclusion

The core of unit testing in embedded C projects lies in scientifically defining testing boundaries and reasonably abstracting external dependencies, focusing on “controllable, isolatable, and automatable” code units. Through layered design, interface abstraction, and Mocking techniques, even in resource-constrained and complex embedded environments, a robust unit testing system can be gradually established. In the next article, we will delve into the principles of the Unity testing framework and its advantages for embedded adaptation, helping you take a crucial step in practice.

Appendix: Simple Unit Testing Object Identification Table

Code Type Suitable for Unit Testing Description/Suggestions
Algorithms/Business Logic Clear input and output, highly recommended
Protocol Parsing/State Machines Can isolate external dependencies through Mocking
HAL Abstraction Layer ✓/× Well-abstracted can be tested, not recommended for low-level
Direct Register Operations × Recommended for integration/end-to-end testing
Configuration/Parameter Management Controllable input and output
Boot/Initialization × Difficult to automate, requires specialized validation
RTOS Tasks/ISRs × Focus on integration/system-level testing

Leave a Comment