Home / Reference / Tools / Linked List Visualizer for Embedded Systems

Linked List Visualizer for Embedded Systems

pcbway

A linked list in embedded systems is a collection of data elements connected using pointers. Unlike an array, the elements do not need to occupy consecutive locations in memory. Each element stores its own data and a pointer that identifies the next element in the list.

Linked lists are common in general-purpose software, but using one in a microcontroller requires extra care. Dynamic memory allocation, memory fragmentation, pointer errors, and unpredictable execution time can all become important concerns when RAM is limited.

In this tutorial, I will explain how a linked list works, show how nodes are connected, and build a practical singly linked list in embedded C. You can also use the visualizer below to add, remove, and traverse nodes while watching the pointers change.

Contents

Interactive Linked List Visualizer

Use the controls below to add nodes, remove nodes, and move through the list. Each box represents one node. The arrow leaving a node represents its next pointer.

Pay particular attention to the first node, called the head, and to the final node whose next pointer is NULL.

Linked List

Singly linked nodes with head, tail, and next pointers.

HEAD: first node TAIL: last node next: pointer to the following node or NULL
Head: NULL Tail: NULL Nodes: 0 / 6 State: EMPTY

List is empty. Insert a node to begin.

Equivalent Embedded C
#define LIST_MAX_NODES 6

typedef struct Node
{
    uint8_t value;
    struct Node *next;
} Node;

typedef struct
{
    Node *head;
    Node *tail;
    uint8_t count;
} LinkedList;

bool list_push_front(LinkedList *list, Node *node, uint8_t value)
{
    if (list->count >= LIST_MAX_NODES)
    {
        return false;
    }

    node->value = value;
    node->next = list->head;
    list->head = node;

    if (list->tail == NULL)
    {
        list->tail = node;
    }

    list->count++;
    return true;
}

bool list_push_back(LinkedList *list, Node *node, uint8_t value)
{
    if (list->count >= LIST_MAX_NODES)
    {
        return false;
    }

    node->value = value;
    node->next = NULL;

    if (list->tail != NULL)
    {
        list->tail->next = node;
    }
    else
    {
        list->head = node;
    }

    list->tail = node;
    list->count++;
    return true;
}

bool list_pop_front(LinkedList *list, uint8_t *value)
{
    Node *old_head;

    if (list->head == NULL)
    {
        return false;
    }

    old_head = list->head;
    *value = old_head->value;
    list->head = old_head->next;

    if (list->head == NULL)
    {
        list->tail = NULL;
    }

    list->count--;
    return true;
}

What Is a Linked List?

A linked list is a data structure made from individual elements called nodes. Each node normally contains two parts:

  • The data stored by the node
  • A pointer to another node

A basic singly linked-list node in C may look like this:

typedef struct Node
{
    uint8_t value;
    struct Node *next;
} Node;

The value member stores the actual information. The next member stores the address of the following node.

Suppose three nodes contain the values 10, 20, and 30. Their relationship can be represented as:

head
  |
  v
[10 | next] -> [20 | next] -> [30 | NULL]

The program starts at head, follows the first node’s next pointer, and continues until it reaches NULL.

The final node points to NULL because there is no node after it.

Linked List Versus Array

An array stores its elements next to one another in memory.

uint8_t values[4] = {10, 20, 30, 40};

The elements occupy consecutive positions:

Address:  0x2000  0x2001  0x2002  0x2003
Value:       10      20      30      40

This makes accessing a specific array element fast. The processor can calculate the address of values[2] directly.

Linked-list nodes may be located in different parts of memory:

Node 1 at 0x2000
Node 2 at 0x2140
Node 3 at 0x2088

The nodes remain connected because each one stores the address of the next node.

Feature Array Linked List
Memory layout Contiguous Nodes may be separated
Direct element access Fast by index Requires traversal
Insert at beginning May require shifting Pointer update only
Remove from beginning May require shifting Pointer update only
Memory overhead Data only Each node requires a pointer
Cache locality Usually good Usually worse
Maximum size Often fixed Depends on node allocation

A linked list is useful when elements must frequently be inserted or removed without shifting the remaining data. An array is usually better when fast indexed access and compact memory usage are more important.

The Head Pointer

The head pointer identifies the first node in the list.

Node *head = NULL;

When head is NULL, the list is empty.

After adding a node:

Node first_node;

first_node.value = 10;
first_node.next = NULL;

head = &first_node;

The head now stores the address of first_node.

head -> [10 | NULL]

The head is not a node itself. It is a pointer that tells the program where the list begins.

How Linked-List Traversal Works

Unlike an array, a linked list cannot directly jump to an arbitrary element by index. The program must begin at the head and follow each next pointer.

void linked_list_print(const Node *head)
{
    const Node *current = head;

    while (current != NULL)
    {
        printf("%u\n", current->value);
        current = current->next;
    }
}

The temporary pointer current begins at the first node. After processing that node, it becomes the address stored in current->next.

The traversal ends when current becomes NULL.

For a list containing four nodes, reaching the final node may require following three pointers. This means finding an element becomes slower as the list grows.

Creating a Basic Singly Linked List

A singly linked list allows movement in one direction only. Each node points to the node after it.

We can begin with this data structure:

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

typedef struct Node
{
    uint8_t value;
    struct Node *next;
} Node;

typedef struct
{
    Node *head;
    uint16_t count;
} LinkedList;

The LinkedList structure stores:

  • A pointer to the first node
  • The number of nodes currently in the list

Initializing the List

void linked_list_init(LinkedList *list)
{
    list->head = NULL;
    list->count = 0U;
}

An initialized list contains no nodes, so the head is set to NULL.

Adding a Node at the Beginning

Adding a node at the beginning is one of the simplest linked-list operations.

Suppose the current list is:

head -> [20] -> [30] -> NULL

To insert a node containing 10:

  1. Make the new node point to the current head.
  2. Change the head so it points to the new node.
bool linked_list_push_front(
    LinkedList *list,
    Node *node,
    uint8_t value)
{
    if ((list == NULL) || (node == NULL))
    {
        return false;
    }

    node->value = value;
    node->next = list->head;

    list->head = node;
    list->count++;

    return true;
}

After the operation:

head -> [10] -> [20] -> [30] -> NULL

The existing nodes do not move in memory. Only two pointer values change.

Adding a Node at the End

Adding at the end requires finding the final node unless the list also stores a tail pointer.

bool linked_list_push_back(
    LinkedList *list,
    Node *node,
    uint8_t value)
{
    Node *current;

    if ((list == NULL) || (node == NULL))
    {
        return false;
    }

    node->value = value;
    node->next = NULL;

    if (list->head == NULL)
    {
        list->head = node;
        list->count++;
        return true;
    }

    current = list->head;

    while (current->next != NULL)
    {
        current = current->next;
    }

    current->next = node;
    list->count++;

    return true;
}

If the list is empty, the new node becomes the head. Otherwise, the function traverses the list until it finds the node whose next pointer is NULL.

This operation takes longer as the list grows. Storing a separate tail pointer can make insertion at the end much faster.

Removing the First Node

To remove the first node:

  1. Save a pointer to the current head.
  2. Move the head to the second node.
  3. Disconnect the removed node.
Node *linked_list_pop_front(LinkedList *list)
{
    Node *removed;

    if ((list == NULL) || (list->head == NULL))
    {
        return NULL;
    }

    removed = list->head;
    list->head = removed->next;

    removed->next = NULL;
    list->count--;

    return removed;
}

If the original list is:

head -> [10] -> [20] -> [30] -> NULL

the result becomes:

head -> [20] -> [30] -> NULL

removed -> [10] -> NULL

The function returns the removed node so the application can reuse it or release it through the appropriate memory-management method.

Removing a Node by Value

Removing a node from the middle requires keeping track of both the current node and the node before it.

Node *linked_list_remove(
    LinkedList *list,
    uint8_t value)
{
    Node *current;
    Node *previous = NULL;

    if (list == NULL)
    {
        return NULL;
    }

    current = list->head;

    while (current != NULL)
    {
        if (current->value == value)
        {
            if (previous == NULL)
            {
                list->head = current->next;
            }
            else
            {
                previous->next = current->next;
            }

            current->next = NULL;
            list->count--;

            return current;
        }

        previous = current;
        current = current->next;
    }

    return NULL;
}

If the first node matches, the head changes. If a middle node matches, the previous node is connected directly to the node after the removed one.

For example:

Before:

[10] -> [20] -> [30] -> NULL

Remove 20:

[10] ---------> [30] -> NULL

Finding a Value

Node *linked_list_find(
    LinkedList *list,
    uint8_t value)
{
    Node *current;

    if (list == NULL)
    {
        return NULL;
    }

    current = list->head;

    while (current != NULL)
    {
        if (current->value == value)
        {
            return current;
        }

        current = current->next;
    }

    return NULL;
}

The function returns a pointer to the matching node or NULL when the value is not present.

Searching a linked list requires checking nodes one at a time. For large collections requiring frequent searches, another data structure may be more suitable.

Complete Dynamic Allocation Example

On a desktop computer, linked-list nodes are commonly created using dynamic allocation.

#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>

typedef struct Node
{
    uint8_t value;
    struct Node *next;
} Node;

typedef struct
{
    Node *head;
    uint16_t count;
} LinkedList;

bool linked_list_add(
    LinkedList *list,
    uint8_t value)
{
    Node *node;

    if (list == NULL)
    {
        return false;
    }

    node = malloc(sizeof(Node));

    if (node == NULL)
    {
        return false;
    }

    node->value = value;
    node->next = list->head;

    list->head = node;
    list->count++;

    return true;
}

bool linked_list_remove_first(
    LinkedList *list,
    uint8_t *value)
{
    Node *removed;

    if ((list == NULL) ||
        (list->head == NULL))
    {
        return false;
    }

    removed = list->head;
    list->head = removed->next;

    if (value != NULL)
    {
        *value = removed->value;
    }

    free(removed);
    list->count--;

    return true;
}

This code works, but using malloc() and free() may not be the best option for every embedded system.

Why Dynamic Allocation Can Be a Problem

Microcontrollers often have limited RAM and may need to operate continuously for months or years. Repeated allocation and release can divide the available heap into small separated areas.

This condition is called memory fragmentation.

Suppose a device has several free memory regions:

Free blocks: 16 bytes, 12 bytes, 20 bytes

The total free memory is 48 bytes, but an attempt to allocate one 32-byte block may fail because no individual free region is large enough.

Dynamic allocation can introduce other concerns:

  • Allocation may fail at runtime.
  • Execution time may be difficult to predict.
  • Memory leaks can slowly consume the heap.
  • Double-free errors can corrupt memory.
  • Using freed nodes can cause unpredictable behavior.
  • Debugging heap corruption on a microcontroller can be difficult.

Dynamic memory is not automatically forbidden in embedded software. It can be acceptable when allocation is controlled, occurs only during initialization, or runs on a system with enough memory and a suitable allocator.

However, a fixed node pool is often a safer choice for small microcontrollers and long-running firmware.

Using a Static Node Pool

A static pool reserves all possible nodes at compile time. The application takes nodes from the pool and returns them when they are no longer needed.

#define NODE_POOL_SIZE 8U

typedef struct Node
{
    uint8_t value;
    struct Node *next;
} Node;

typedef struct
{
    Node nodes[NODE_POOL_SIZE];
    Node *free_list;
} NodePool;

The pool can use a second linked list to track available nodes.

Initializing the Pool

void node_pool_init(NodePool *pool)
{
    uint8_t index;

    if (pool == NULL)
    {
        return;
    }

    for (index = 0U;
         index < NODE_POOL_SIZE - 1U;
         index++)
    {
        pool->nodes[index].next =
            &pool->nodes[index + 1U];
    }

    pool->nodes[NODE_POOL_SIZE - 1U].next =
        NULL;

    pool->free_list = &pool->nodes[0];
}

After initialization, every available node is connected through the pool’s free_list.

Allocating a Node from the Pool

Node *node_pool_allocate(NodePool *pool)
{
    Node *node;

    if ((pool == NULL) ||
        (pool->free_list == NULL))
    {
        return NULL;
    }

    node = pool->free_list;
    pool->free_list = node->next;

    node->next = NULL;

    return node;
}

The first available node is removed from the free list and returned to the caller.

Returning a Node to the Pool

void node_pool_release(
    NodePool *pool,
    Node *node)
{
    if ((pool == NULL) || (node == NULL))
    {
        return;
    }

    node->next = pool->free_list;
    pool->free_list = node;
}

The released node is placed back at the beginning of the free list.

This approach has several useful properties:

  • The maximum memory usage is known at compile time.
  • Allocation does not depend on the general-purpose heap.
  • No external fragmentation occurs inside the pool.
  • Allocation and release are fast and predictable.
  • Pool exhaustion can be detected when no free nodes remain.

Complete Static Linked List Example

The following example combines a linked list with a fixed node pool.

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

#define NODE_POOL_SIZE 8U

typedef struct Node
{
    uint8_t value;
    struct Node *next;
} Node;

typedef struct
{
    Node *head;
    uint16_t count;
} LinkedList;

typedef struct
{
    Node nodes[NODE_POOL_SIZE];
    Node *free_list;
} NodePool;

void node_pool_init(NodePool *pool)
{
    uint8_t index;

    for (index = 0U;
         index < NODE_POOL_SIZE - 1U;
         index++)
    {
        pool->nodes[index].next =
            &pool->nodes[index + 1U];
    }

    pool->nodes[NODE_POOL_SIZE - 1U].next =
        NULL;

    pool->free_list = &pool->nodes[0];
}

Node *node_pool_allocate(NodePool *pool)
{
    Node *node;

    if (pool->free_list == NULL)
    {
        return NULL;
    }

    node = pool->free_list;
    pool->free_list = node->next;

    node->next = NULL;

    return node;
}

void node_pool_release(
    NodePool *pool,
    Node *node)
{
    node->next = pool->free_list;
    pool->free_list = node;
}

void linked_list_init(LinkedList *list)
{
    list->head = NULL;
    list->count = 0U;
}

bool linked_list_add(
    LinkedList *list,
    NodePool *pool,
    uint8_t value)
{
    Node *node;

    node = node_pool_allocate(pool);

    if (node == NULL)
    {
        return false;
    }

    node->value = value;
    node->next = list->head;

    list->head = node;
    list->count++;

    return true;
}

bool linked_list_remove_first(
    LinkedList *list,
    NodePool *pool,
    uint8_t *value)
{
    Node *removed;

    if (list->head == NULL)
    {
        return false;
    }

    removed = list->head;
    list->head = removed->next;

    if (value != NULL)
    {
        *value = removed->value;
    }

    list->count--;
    node_pool_release(pool, removed);

    return true;
}

The maximum number of active nodes is limited to NODE_POOL_SIZE. When all nodes are in use, linked_list_add() returns false.

This fixed limit is often desirable in embedded firmware because the system’s worst-case memory requirement is known before the program runs.

Example Application

LinkedList event_list;
NodePool event_pool;
uint8_t event_value;

int main(void)
{
    node_pool_init(&event_pool);
    linked_list_init(&event_list);

    linked_list_add(
        &event_list,
        &event_pool,
        10U);

    linked_list_add(
        &event_list,
        &event_pool,
        20U);

    linked_list_add(
        &event_list,
        &event_pool,
        30U);

    while (1)
    {
        if (linked_list_remove_first(
                &event_list,
                &event_pool,
                &event_value))
        {
            process_event(event_value);
        }

        run_other_tasks();
    }
}

Because nodes are added at the beginning, the values in this example are removed in reverse order: 30, 20, then 10.

This behavior is a stack, not a FIFO queue. To preserve insertion order, add new nodes at the end or store a tail pointer.

Linked List with Head and Tail Pointers

Adding a tail pointer makes it possible to insert at the end without traversing the entire list.

typedef struct
{
    Node *head;
    Node *tail;
    uint16_t count;
} LinkedList;

Appending a node then becomes:

bool linked_list_push_back(
    LinkedList *list,
    Node *node,
    uint8_t value)
{
    if ((list == NULL) || (node == NULL))
    {
        return false;
    }

    node->value = value;
    node->next = NULL;

    if (list->tail == NULL)
    {
        list->head = node;
        list->tail = node;
    }
    else
    {
        list->tail->next = node;
        list->tail = node;
    }

    list->count++;

    return true;
}

When removing the first node, remember to clear the tail when the final element is removed.

Node *linked_list_pop_front(
    LinkedList *list)
{
    Node *removed;

    if ((list == NULL) ||
        (list->head == NULL))
    {
        return NULL;
    }

    removed = list->head;
    list->head = removed->next;

    if (list->head == NULL)
    {
        list->tail = NULL;
    }

    removed->next = NULL;
    list->count--;

    return removed;
}

This head-and-tail arrangement can be used to build a FIFO event queue.

Where Linked Lists Are Used in Embedded Systems

Linked lists can be useful when the number or ordering of elements changes while the program is running.

Event Queues

Each node can represent an event waiting to be processed.

typedef struct Event
{
    uint16_t event_id;
    uint32_t timestamp;
    struct Event *next;
} Event;

Software Timers

Timer nodes may be sorted by expiration time. The application only needs to check the first timer to find the next event that will expire.

Network Packet Lists

Network stacks may connect packet buffers without copying all packet data into one large contiguous block.

Device and Driver Registration

Drivers, callbacks, or peripheral objects can be registered in a list during system initialization.

Memory Free Lists

A memory allocator can link unused blocks together. The static node pool shown earlier is itself an example of a free list.

RTOS Kernel Objects

Real-time operating systems commonly use linked lists internally for task scheduling, blocked-task lists, timer lists, and queues.

When a Linked List Is Not the Best Choice

A linked list is not automatically better because elements can be inserted and removed easily.

Consider using an array, ring buffer, or another fixed structure when:

  • The maximum number of elements is known.
  • Fast indexed access is required.
  • Memory overhead must be minimized.
  • Cache locality matters.
  • The data is processed in strict FIFO order.
  • Deterministic memory usage is a priority.

For UART bytes, a ring buffer is normally more appropriate than a linked list. Every linked-list node would require a pointer in addition to the byte itself, creating unnecessary memory overhead.

A linked list becomes more attractive when elements are larger, insertions occur at different positions, or nodes already exist as application objects.

Memory Overhead in a Linked List

Each node requires space for at least one pointer.

Consider this structure on a 32-bit microcontroller:

typedef struct Node
{
    uint8_t value;
    struct Node *next;
} Node;

The data uses only one byte, but the pointer requires four bytes. Alignment and padding may increase the total node size to eight bytes.

As a result, storing eight one-byte values may require approximately 64 bytes instead of eight bytes.

This is why linked lists are rarely a good choice for individual UART characters or tiny sensor readings.

They become more memory-efficient when each node contains a larger payload or when the node is embedded inside an existing object.

Intrusive Linked Lists

An intrusive linked list places the pointer directly inside the application object.

typedef struct Sensor
{
    uint8_t address;
    uint16_t sample_rate;
    struct Sensor *next;
} Sensor;

The sensor object itself becomes a list node. No separate wrapper node is required.

This approach is common in embedded systems because it avoids additional allocation and gives the programmer direct control over memory.

The tradeoff is that the object becomes coupled to the list. A single next pointer also allows the object to belong to only one such list at a time unless more link fields are added.

Linked Lists and Interrupts

Modifying a linked list from an interrupt while the main program also modifies it can corrupt the list.

Consider adding a node at the head:

node->next = list->head;
list->head = node;

This operation involves more than one memory access. If another execution context changes the list between those two statements, a node may be lost or connected incorrectly.

Depending on the system, shared-list operations may require:

  • A short critical section
  • Temporarily disabling the relevant interrupt
  • An RTOS mutex
  • An RTOS queue
  • A carefully designed lock-free algorithm

Adding volatile to a pointer does not make the complete linked-list operation atomic.

For communication between a UART interrupt and a main loop, a single-producer, single-consumer ring buffer is normally simpler and safer than a shared linked list.

Common Linked-List Mistakes

Losing the Head Pointer

Changing the head before saving the old value may make the original node unreachable.

Forgetting to Update the Previous Node

When removing a middle node, the previous node must point to the removed node’s successor.

Dereferencing NULL

Always check whether a pointer is NULL before accessing its members.

if (current != NULL)
{
    value = current->value;
}

Using a Node After It Has Been Freed

A pointer does not automatically become invalid-looking after free(). It may still contain the old address even though that memory no longer belongs to the node.

free(node);
node = NULL;

Setting the local pointer to NULL can help prevent accidental reuse, although other copies of the same pointer must also be handled correctly.

Creating a Cycle Accidentally

If a node points to an earlier node instead of eventually reaching NULL, traversal may never terminate.

[10] -> [20] -> [30]
          ^         |
          |_________|

Returning a Node to the Pool Twice

Releasing the same node twice can corrupt the pool’s free list. The application must clearly define who owns each node.

Using Dynamic Allocation Without Handling Failure

Every allocation can fail. The return value of malloc() or a node-pool allocation function must be checked.

Singly Versus Doubly Linked Lists

A singly linked list stores only a pointer to the next node.

typedef struct Node
{
    uint8_t value;
    struct Node *next;
} Node;

A doubly linked list stores both next and previous pointers.

typedef struct Node
{
    uint8_t value;
    struct Node *next;
    struct Node *previous;
} Node;

A doubly linked list allows traversal in both directions and makes removal easier when the node address is already known. However, it requires another pointer in every node and more pointer updates during insertion and removal.

Feature Singly Linked List Doubly Linked List
Pointers per node One Two
Forward traversal Yes Yes
Backward traversal No Yes
Memory usage Lower Higher
Removal with node pointer May need previous node Direct pointer updates
Implementation complexity Lower Higher

Frequently Asked Questions

Are linked lists commonly used in embedded systems?

Yes, especially in operating systems, protocol stacks, event systems, driver registration, and memory managers. However, small bare-metal applications often prefer fixed arrays or ring buffers because they use less memory and are easier to analyze.

Should I use malloc for an embedded linked list?

It depends on the project. Dynamic allocation may be acceptable during initialization or on larger systems. For small or safety-sensitive microcontrollers, a fixed node pool usually provides more predictable behavior.

Is a linked list faster than an array?

Not in every operation. Inserting at the head of a linked list is fast, but finding the tenth element requires following the previous nine nodes. Arrays provide much faster indexed access and usually better memory locality.

Can linked-list nodes store structures?

Yes. A node can store sensor records, messages, tasks, timers, network packets, or other application-specific data.

What happens when a static node pool is full?

The allocation function returns NULL. The application must decide whether to reject the new item, report an error, reuse an older node, or increase the configured pool size.

Can I use a linked list inside an interrupt?

It is possible, but shared access must be designed carefully. Pointer updates can be interrupted halfway through, so critical sections or another synchronization method may be required.

Should UART data use a linked list?

Usually not. A fixed ring buffer is more compact and efficient for a stream of bytes. A linked list may be useful when each received item is a larger variable-size message rather than an individual byte.

Conclusion

A linked list connects separate data objects using pointers. The head identifies the first node, each node points to the next one, and the final node points to NULL.

The main linked-list operations are:

  • Adding a node
  • Removing a node
  • Traversing the list
  • Searching for a value
  • Returning unused nodes to their allocator or static pool

For embedded systems, the pointer logic is only part of the design. You must also consider RAM overhead, allocation failure, fragmentation, interrupt safety, and worst-case execution time.

The visualizer at the beginning of this tutorial shows the logical connections between nodes. In an actual microcontroller, those nodes may be stored at completely different memory addresses. The pointers are what preserve their order.

For small and predictable firmware, I recommend starting with a singly linked list backed by a fixed node pool. It keeps the flexibility of linked nodes while avoiding the uncertainty of repeated heap allocation.

Index