EMBEDDED SOFTWARE AND HARDWARE ARCHITECTURE
Data Structures, Memory, Registers & Debugging
A Study Guide
Data Structures • Bit Manipulation • Memory & Registers
Pointers & Buffers • Compiler Attributes • Debugging
Contents
Part 1 — Data Structures & Memory Fundamentals
1.1 Why Data Structures Matter in Embedded Systems
1.2 Accessing and Manipulating Memory
1.3 The Load-Store Architecture & Read-Modify-Write
1.4 Memory Alignment and Packing
1.5 Endianness
Part 2 — Bitwise Techniques & Small Data Types
2.1 Bit Manipulation Basics
2.2 Bit Masks and Read-Modify-Write
2.3 Bit-Banded Regions
2.4 Bit Fields
2.5 Unions
2.6 Enumerations
Part 3 — Structures, Registers & Hardware Interfaces
3.1 Structures and Encapsulation
3.2 Abstract Data Types vs. Composite Structures
3.3 The Microcontroller Memory Map
3.4 Core CPU Registers & the Program Status Register
3.5 Peripheral Registers: A Timer Example
3.6 Register Definition Files
3.7 Building a Hardware Abstraction Layer
3.8 Worked Example: Configuring a GPIO Pin
Part 4 — Pointers, Buses & Function Pointers
4.1 Advanced Pointer Types: Void, Double & Restrict
4.2 Function Pointers
4.3 The Interrupt Vector Table
4.4 On-Chip Buses: AHB and APB
Part 5 — Classic Embedded Data Structures
5.1 The LIFO Buffer (Stack)
5.2 The Circular Buffer (Ring/FIFO)
5.3 The Linked List
Part 6 — Compiler Control & Debugging
6.1 Compiler Attributes and Pragmas
6.2 Debugger Fundamentals
6.3 A Debugging Walkthrough
Part 1 — Data Structures & Memory Fundamentals
1.1 Why Data Structures Matter in Embedded Systems
Every embedded system has to store data — sensor readings, motor control values, status flags for running processes, and so on. A single variable is just one typed storage container, but real programs need many related variables grouped and organized together. C's derived and enumerated types (structures, unions, arrays, pointers, and enums) are the tools used to build these groupings into custom, application-specific data types.
A structure lets you bundle several variables of possibly different types into one named container. Combined with unions (reinterpreting the same memory as different types) and bit fields (packing sub-byte members), structures become the building blocks for data structures proper — organized, reusable ways of managing data such as messages between systems, sensor logs, or program control flow.
Three data structures come up again and again in embedded work and are covered later in this guide:
• The LIFO buffer (a stack)
• The circular buffer (a ring/FIFO buffer)
• The linked list
Each of these typically pairs a structure definition with a matching set of functions that operate on it — the data structure is never “just” the layout, it's the layout plus its interface.
1.2 Accessing and Manipulating Memory
Microcontrollers within the same chip family vary in memory size and peripheral count, so portable code needs a clear set of tools for accessing memory that still allow platform-specific tuning. Two building blocks recur throughout this guide: the register definition file (a software map of peripheral/core registers) and the linker file (which places code and data in memory).
Beyond the ordinary pointers used for standard C types and structures, embedded code regularly relies on a handful of specialized pointers — void pointers, double pointers, function pointers, and restrict-qualified pointers — covered in Part 4. Most day-to-day register work, however, comes down to reading a chip's Technical Reference Manual (the general behavior of a whole chip family) alongside its datasheet (the specifics of one chip variant) to find a peripheral's registers, then using pointers and bit manipulation to configure them.
Two other memory concerns show up constantly once you start writing real firmware: reducing function-call overhead in code memory (inline functions, macro functions) and dynamically selecting which code runs via function pointers. Both are explored later in this guide.
1.3 The Load-Store Architecture & Read-Modify-Write
The CPU cannot operate on memory directly. Every arithmetic, logical, or comparison operation happens on values sitting in the CPU's general-purpose registers, so data first has to be loaded from memory into a register, processed, and then stored back — this is a load-store architecture. Repeated loads and stores are overhead, so compilers try to keep values in registers as long as possible before writing them back.
When only part of a value needs to change — a handful of bits in a peripheral control register, for example — you still have to load the whole register, modify the relevant bits, and store the whole register back so the untouched bits are preserved. This pattern is called read-modify-write, and it underlies almost all peripheral configuration.
Behind the single “memory bus” picture used early in a course, real Cortex-M microcontrollers actually route data through several buses (see Part 4.4), and different memory types (flash, SRAM, registers, peripherals) are reached over different paths at different speeds.
1.4 Memory Alignment and Packing
Memory is byte-addressable: a 32-bit address space gives roughly 4 billion unique byte addresses. But many operations work on half-words, words, or larger types, and the ARM instruction set only provides byte, half-word, and word load/store instructions — there's no direct instruction for loading or storing an arbitrary number of bits.
Multi-byte types have alignment requirements tied to their size:
• Bytes can sit at any address — they're always “aligned.”
• Half-words must sit on 2-byte boundaries (addresses ending 0, 2, 4, 6, 8, A, C, E).
• Words must sit on 4-byte boundaries (addresses ending 0, 4, 8, C).
• Double words must sit on 8-byte boundaries (addresses ending 0 or 8).
An aligned layout is the most CPU-efficient (fewer instructions, no extra shifting/combining) but can waste space through padding — unused filler bytes inserted so a following member lands on the correct boundary. A packed layout removes that padding to save memory, at the cost of extra instructions to read or write the resulting unaligned data. On an architecture that can't perform unaligned accesses at all, reading an unaligned word might require two half-word loads plus shifting and combining — several extra instructions compared to a single aligned load. Compiler attributes (Part 6.1) let you force alignment or packing explicitly on variables and structures.
1.5 Endianness
Endianness describes the byte order used to store a multi-byte value in memory. It only matters once a value spans more than one byte — a single byte has no ordering ambiguity.
• Big endian: the most significant byte is stored at the lowest address.
• Little endian: the least significant byte is stored at the lowest address.
For example, the 32-bit value 0xABCD1234 stored starting at address 0 would appear as AB CD 12 34 (addresses 0–3) in big endian, but as 34 12 CD AB in little endian.
Endianness affects only the byte order inside each multi-byte item — not the order of elements in an array, nor the layout of structures or unions. ARM cores are configurable: code memory on Cortex-M is always little endian, while data memory defaults to little endian but can be switched to big endian via a bit in the Application Interrupt and Reset Control Register (AIRCR); changing it requires a processor reset. Endianness mismatches matter most when two systems communicate over a byte-oriented link — if their endianness differs, a byte swap routine is needed to reorder the bytes of each multi-byte value before it can be interpreted correctly.
Part 2 — Bitwise Techniques & Small Data Types
2.1 Bit Manipulation Basics
Embedded code frequently needs to read or change data smaller than a full C type — individual bits inside a peripheral register, for instance. C's bitwise operators (left shift, right shift, AND, OR, XOR, and the ones’-complement NOT) are the tools for this, and are typically written using hexadecimal constants rather than raw binary, since long binary literals are error-prone and hard to read.
Setting, clearing, and toggling bits
• Set bits: OR with a mask that has 1s in the target positions, e.g. foo |= 0x30; sets bits 4–5.
• Clear bits: AND with a mask that has 0s in the target positions (often the complement of a “set” mask), e.g. foo &= 0x3F; clears bits 6–7.
• Toggle bits: XOR with a mask that has 1s in the target positions, e.g. foo ^= 0x0E; toggles bits 1–3.
Hand-typed hex constants like these are called bit masks. Rather than hard-coding them inline, they're normally defined as preprocessor macros so the intent is documented and the code stays readable — register definition files often ship with premade masks for this purpose, though you'll regularly define your own as well.
2.2 Bit Masks and Read-Modify-Write
Setting a multi-bit field to a specific value (rather than just setting or clearing individual bits) usually needs a clear step followed by a set step, since a plain assignment would overwrite bits you didn't intend to touch. For example, setting a 4-bit field spanning bits 4–7 to the value 3 while leaving bits 0–3 untouched means: clear bits 4–7 with an AND-mask, then OR in the new value shifted into position. Conceptually this is a load, a clear, a set, and a store — several CPU operations to change what is logically “one field.”
2.3 Bit-Banded Regions
ARM Cortex-M devices offer a hardware shortcut called bit-banding for the first megabyte of SRAM and the first megabyte of peripheral memory. Each bit in a bit-banded region has its own dedicated “alias” address; writing the least-significant bit of that alias address sets or clears the corresponding bit in the real memory location in a single atomic instruction — no separate load, mask, and store needed.
Bit-banding only supports single-bit set/clear/read operations, and it trades away part of the address space (the alias region) for the convenience. Its main benefit besides speed is atomicity: a single-instruction read-modify-write on one bit avoids the race condition that could otherwise occur if another thread or interrupt touches the same word between the read and the write.
2.4 Bit Fields
Structure and union members can be declared with a bit-field width — a colon followed by a number of bits — which lets you pack several small values into a single byte, half-word, or word instead of giving each its own full-sized member. A bit field's width must be greater than zero and no larger than the underlying type; assigning a value that doesn't fit the field's width is undefined behavior.
Consecutive bit fields of the same underlying type are packed together into one allocation as long as they fit; once they overflow that type, or a non-bit-field member follows, allocation moves on to the next properly aligned location — in a struct, unused bits are simply padding, while in a union all bit fields still overlap the same address. An unnamed, zero-width bit field can be used purely to force the next member to start on a new alignment boundary.
Packing data into bit fields decreases memory footprint but increases the instructions needed to read and write it — the same space/speed trade-off seen with structure packing. ARM's CMSIS header files use unions of bit-field structures and plain word types for core registers such as the Application Program Status Register (APSR), letting code read/write the whole register as a word or address individual flag bits by name.
2.5 Unions
A union looks like a structure but all of its members share the same starting address, so writing one member overwrites whatever was stored by the others. The union's total size is the size of its largest member. Unions are declared with the union keyword and accessed exactly like structures (the dot operator, or arrow through a pointer).
Typical uses include:
• Interpreting the same small variable as several types (e.g., a byte accessed as three uint8_t aliases).
• A shared block of memory reused by different parts of a program (e.g., a byte array vs. a movement-tracking structure) that never need the interpretation at the same time — useful for two threads that never run simultaneously and would otherwise each need their own statically allocated buffer.
• Register definitions in CMSIS headers, where a union lets a register be read/written as a plain word or as a bit-field structure.
Unions combined with bit fields (Part 2.4) give a compact, readable way to model hardware registers that need both a word-level and a bit-level view.
2.6 Enumerations
An enum defines a named, integer-only type built from a list of constant identifiers. Unlike preprocessor constants, enums get compiler type-checking, and unlike plain #define'd constants they don't automatically allocate memory in code space — only an actual enum variable does. Enum members get automatically incrementing values by default, or you can assign specific values (including duplicates) explicitly.
Because enum constants have global scope, naming collisions across large projects are handled with a short type prefix, e.g. CB_EMPTY, CB_FULL, CB_ERROR, CB_NULL for a circular-buffer status enum. Some projects also add a trailing “count” or “last” member so software can loop over all values or know how many there are — this only works if the enum values were left to auto-increment.
Enum storage size is architecture- and ABI-dependent (commonly a 4-byte signed integer, e.g. on the MSP432); GCC's -fshort-enums flag can shrink this when a smaller type is enough, which matters if an enum's value could otherwise be negative and is being used to index an array. Style conventions to be aware of: enum constants are usually written in all capitals, and some teams append a suffix like _e to a typedef'd enum type name (mirroring the _t suffix convention from standard C/POSIX types) — this is a team convention rather than a language rule.
Part 3 — Structures, Registers & Hardware Interfaces
3.1 Structures and Encapsulation
A structure (struct keyword, a tag, and a member list) groups related data — standard types, enums, pointers, function pointers, even other structures — under one type. The typedef keyword gives that type a shorter alias so instances don't need the struct keyword repeated. A structure type is usually declared in a header file if it needs to be shared publicly, or at the top of a source file if it's private to that file.
Passing a pointer to a structure into a function (rather than the structure itself) both reduces argument-copying overhead and lets the function's implementation hide — or encapsulate — how the structure is actually read or written. A simple print_weather(WeatherSample *sample) function is a basic example: callers don't need to know the structure's internal layout, and if that layout changes later, only the function needs updating rather than every call site.
Encapsulation matters more once a structure needs several correlated operations — adding a new data point to a position-tracking structure, for instance, means updating x/y/z arrays, a timestamp, and a count together. Wrapping that sequence in a function avoids repeating (and potentially getting wrong) the same multi-step update everywhere it's needed.
3.2 Abstract Data Types vs. Composite Structures
A composite data structure is built entirely from built-in C types for one specific purpose (e.g., a ball-position tracker) and generally isn't reusable elsewhere. An abstract data type, by contrast, is designed to work with any underlying data — it pairs a generic structure definition with a matching set of interface functions (initialize, add, remove, clear, and so on), each taking a pointer to the structure they operate on.
Buffers, linked lists, trees, and hash tables (Part 5 covers the first three) are the classic abstract data types used repeatedly in embedded software, and each is really “structure + functions,” not the structure alone.
3.3 The Microcontroller Memory Map
A microcontroller's various memory regions — flash/code, SRAM/data, peripherals, and CPU-internal registers — are mapped into one unified address space so that all of them can be reached uniformly through pointers. This unified layout is the memory map. Code and SRAM regions are placed by the compiler and linker based on your program; peripheral and system-control regions instead have fixed, manufacturer-defined addresses tied to specific hardware functions.
On Cortex-M parts, register memory generally falls into four groups:
• Internal CPU registers — the 16 general/special-purpose registers (R0–R12, link register, stack pointer, program counter), reachable only via assembly, not the normal address map.
• Internal private peripherals — things like the NVIC (interrupt priority configuration) and system control block, reached over an internal private peripheral bus.
• External private peripherals — debug hardware such as the trace macrocell.
• General peripheral memory — a large region (512 MB on Cortex-M) holding ordinary peripherals such as timers, serial interfaces, and ADCs, each given its own 4 KB block.
3.4 Core CPU Registers & the Program Status Register
Because the core CPU registers live outside the normal memory map, they're read and written with dedicated assembly instructions (MRS/MSR) rather than ordinary pointers. Among them, the Program Status Register (PSR) tracks the CPU's current execution state and is made up of three parts:
• APSR (Application PSR) — condition flags N (negative), Z (zero), C (carry), V (overflow), and Q (saturation), set automatically by arithmetic/comparison operations and used for conditional branches.
• IPSR (Interrupt PSR) — records which exception/interrupt number, if any, is currently being handled.
• EPSR (Execution PSR) — additional execution-state bits.
Related special registers include the exception mask registers (PRIMASK, FAULTMASK, BASEPRI, which enable/disable handling of exceptions, optionally by priority) and the control register (which switches the processor between privileged/unprivileged execution and selects which stack pointer is active). ARM's CMSIS headers expose these as a union of a full-word view and a bit-field structure view, similar to the bit-field pattern from Part 2.4/2.5.
3.5 Peripheral Registers: A Timer Example
Each peripheral occupies its own small address block (4 KB on Cortex-M) containing a handful of registers, each further divided into bit fields for status/control/configuration. Using the MSP432's Timer A0 as an example, its 16-bit control register packs eight separate bit fields — clock source selection, clock divider, interrupt enable, interrupt flag, and several reserved bits — into that one register.
To configure it, you don't need to understand every bit up front: you look up the field layout in the reference manual, build the value you need (e.g., 0x0202 to select a clock source and enable an interrupt), and write it through a pointer to the register's address, or through a name provided by a register definition file (Part 3.6).
3.6 Register Definition Files
Tracking a distinct pointer variable for every peripheral register in a chip would waste memory and tie your code to one specific platform. A register definition file solves this by giving every register a meaningful name, using one of two common techniques:
Direct-dereference macros
A preprocessor macro (e.g., TA0CTL) expands to a cast-and-dereference of a fixed address, so writing TA0CTL = 0x0202; both reads readably and compiles down to a raw memory write — with no extra variable, and no runtime overhead. Read/write helper macros of the form *(volatile uintN_t *)(x) are typically layered underneath these named macros. The volatile qualifier is important here: it stops the compiler from optimizing away or reordering the access, guaranteeing the register really is written when the code says so.
Structure overlays
A structure is defined whose members exactly mirror a peripheral's real register layout and offsets — preserving order, size, and adding explicit reserved-byte padding for any gaps, and marking read-only registers const. A pointer to the peripheral's base address is cast to this structure type, after which every register can be reached by name through one pointer (e.g., TIMER_A0->CTL = 0x0202;) instead of tracking a separate address per register.
Both approaches avoid consuming data memory for pointer storage, and both can be swapped between chip variants using a compile-time platform switch that selects which register definition header gets included.
3.7 Building a Hardware Abstraction Layer
Application code shouldn't need to know low-level register details; a hardware abstraction layer should present a simple, stable interface (like an API) while the platform-specific details — register definition files, macros, or dedicated functions — stay underneath it. ARM's own CMSIS library follows this pattern across its supported architectures.
Three mechanisms are commonly combined to build this layer:
• Preprocessor macros — fast (no call overhead) but offer no type checking, can nest confusingly, and duplicate code at every call site.
• Macro functions — parameterized macros that avoid intermediate variables (e.g., a macro that dereferences an address and sets it to a given value in one step) but share the same lack of type checking.
• Inline and static functions — the inline keyword asks the compiler to substitute the function body at the call site rather than performing a full call/return, avoiding call overhead while keeping C's type checking; it's only a suggestion the compiler can decline for large, recursive, or variadic functions. static gives a function or global internal linkage, restricting visibility to its own translation unit; inline and static together are common in header-only hardware interfaces so the compiler can both inline the call and treat all linkage as local.
A typical low-level GPIO interface is written as small inline static functions in a header, with a compile-time platform flag selecting which platform's implementation gets compiled in — keeping the interface efficient without sacrificing portability or readability.
3.8 Worked Example: Configuring a GPIO Pin
A recurring example throughout this material is turning on an LED connected to a general-purpose I/O (GPIO) pin — on the MSP432 board used in the course, pin 0 of port 1 (P1.0). Pins are grouped into ports (up to eight pins per port, ten ports on the MSP432), and each pin can have secondary functions such as serial or analog modes described in the datasheet's pin-diagram and signal-description sections.
Configuring an output pin takes two steps, each governed by its own register:
• Direction register (P1DIR) — writing a 1 to a bit configures that pin as an output (0 is input, and is also the power-on default, which is why explicit configuration in code matters).
• Output register (P1OUT) — once a pin is an output, writing 1 drives it high (toward VCC) and 0 drives it low (toward ground).
The guide shows three equivalent ways to perform this configuration in code, from most to least explicit:
// 1) Raw pointer to a known address
volatile uint8_t *p1dir = (volatile uint8_t
*)0x40004C0A;
*p1dir |= 0x01;
// 2) Register-definition-file macro + bit mask
P1DIR |= BIT0;
// 3) Structure overlay
P1->DIR |= BIT0;
Toggling P1OUT bit 0 inside a loop then produces the blink. The point of walking through all three forms is that they compile down to the same read-modify-write on the same memory location — the differences are purely about readability, portability, and maintainability, the themes running through this whole Part.
Part 4 — Pointers, Buses & Function Pointers
4.1 Advanced Pointer Types: Void, Double & Restrict
Void (generic) pointers
A void pointer stores an address without committing to a type. It's the same size as any other pointer and can hold a null value, but it can't be dereferenced directly (it must be cast to a concrete type first) and no pointer arithmetic is allowed on it, since the compiler has no size to step by. malloc() returns a void pointer for exactly this reason — allocation doesn't care what the memory will ultimately hold. Void pointers are also useful when a received message's type isn't known until its header has been parsed.
Double pointers
A double pointer (declared with two asterisks) points to another pointer. Because a plain pointer argument is passed into a function by value, changes the function makes to its own copy of the pointer don't propagate back to the caller — to let a function change what the caller's pointer variable itself points to (for example, allocating a structure and handing the new address back through the parameter), the caller passes the address of its pointer, i.e. a double pointer.
Restrict-qualified pointers
The C99 restrict keyword (placed after the asterisk, before the variable name) tells the compiler that the memory a pointer refers to is not aliased by any other pointer in scope. This lets the compiler skip certain safety checks and optimize more aggressively, particularly in loops over large arrays, sometimes producing a meaningful speedup.
4.2 Function Pointers
A function pointer stores the address of executable code rather than data, and dereferencing it causes that code to run rather than returning a value from memory. Like other pointers it's word-sized, but its declaration syntax is distinctive: the return type, then (*name)(parameter list) — the parentheses around *name are required to distinguish “a pointer to a function” from “a function that returns a pointer.”
// declare a function-pointer type via typedef
typedef int (*FuncPtr)(int, int);
int add(int a, int b) { return a + b; }
FuncPtr fp = add; // or: FuncPtr fp = &add;
int result = fp(2, 3); // calls add(2, 3)
A function pointer's declared signature must match the function it's assigned to. Arrays of function pointers are common for dispatch tables: an enum can be used to index into the array to select which function runs, which is a natural fit for state machines and simple APIs.
The most important real-world example is the interrupt vector table — an array of (void)(void) function pointers, one per interrupt/exception source, placed at the very start of code memory (address 0) via a linker-script section. It's typically declared const so nothing can alter it at runtime, and it begins with the initial stack pointer value followed by the highest-priority handlers (reset, non-maskable interrupt, fault handlers), then the general peripheral interrupt handlers.
4.3 The Interrupt Vector Table
Because the vector table has to live at a fixed, linker-controlled location, its definition combines a #pragma (associating the array with a specific linker section, e.g. .intvecs) with the function-pointer-array syntax from 4.2. The linker script places that section at address 0 and everything else (the normal code/text segments) after it. Each slot is filled with the address of the handler function that should run for that interrupt/exception source; unused slots are typically filled with a default handler or left as a defined placeholder.
4.4 On-Chip Buses: AHB and APB
The simplified “one bus to memory” picture used early on hides real complexity: ARM's AMBA (Advanced Microcontroller Bus Architecture) specification defines several bus types Cortex-M chips actually use, most commonly:
• AHB (AMBA High-Performance Bus) — used for the CPU's connections to code memory (often split into separate I-Code and D-Code buses so instruction fetches and constant-data reads can happen at once), to SRAM (the “system bus”), and to internal/external private peripherals over the private peripheral bus (PPB), which usually requires privileged access.
• APB (Advanced Peripheral Bus) — a lower-bandwidth bus for general peripherals (timers, communication blocks, ADCs). It isn't wired directly to the CPU; traffic travels out on the system bus first and crosses a bridge onto the APB.
Different buses can run at different clock frequencies tuned for their attached memory type, which is one more reason peripheral register access can be noticeably slower than SRAM access. All of this bus routing is transparent to C code — a pointer's address alone determines which path the access takes.
Part 5 — Classic Embedded Data Structures
The three data structures in this Part share a common shape: a piece of memory (contiguous for the two buffers, scattered for the linked list) plus a small tracking structure and a matching set of functions that safely add, remove, and query data. They exist to mediate between a producer and a consumer of data that don't run at the same speed — an interrupt handler filling a buffer that a slower main loop later drains, for instance.
5.1 The LIFO Buffer (Stack)
A LIFO (last-in, first-out) buffer adds and removes data from the same end — often called push (add) and pop (remove). It's conceptually similar to the processor's call stack, but a LIFO buffer you define yourself only ever holds one consistent data type, unlike the processor stack, which holds whatever a function's calling convention needs at that moment.
A minimal LIFO buffer structure needs, at minimum: a base pointer (or address) marking the start of the backing memory, a length, and either a current element count or a “head”/“tip” pointer marking the next free slot.
typedef struct {
uint8_t *base; // start of
backing memory
uint8_t *head; // next free slot
uint32_t length; // total capacity in elements
} LIFOBuf;
typedef enum { LB_NO_ERROR, LB_FULL, LB_EMPTY,
LB_NULL } LBStatus;
The backing memory itself is allocated separately (on the heap with malloc, or statically) and its base/length are recorded in the structure; the head pointer starts equal to the base pointer when the buffer is empty. A buffer_full() check compares the head against base + (length × element size); a buffer_add() function checks for a null buffer and a full buffer before copying the new item to *head and advancing head — skipping the fullness check risks a buffer overflow, since writing past the allocated region corrupts whatever memory follows it.
5.2 The Circular Buffer (Ring/FIFO)
A circular (ring) buffer behaves like a FIFO (first-in, first-out): items are added at a head and removed from a separate tail, and both pointers wrap back to the start of the backing region once they reach the end — hence “circular.” Reaching the end of the allocated region does not by itself mean the buffer is full; fullness has to be tracked separately (e.g., by comparing head and tail positions, or maintaining an explicit element count).
Its structure looks much like the LIFO buffer's, but with two moving pointers instead of one:
typedef struct {
uint8_t *base;
uint8_t *head; // next write
position
uint8_t *tail; // next read
position
uint32_t length;
} CBuf;
typedef enum { CB_NO_ERROR, CB_FULL, CB_EMPTY,
CB_NULL } CBStatus;
A buffer_full() check typically compares whether head sits one slot before tail (accounting for wraparound at the array's boundary). A buffer_add() function validates the pointer, checks for fullness, copies the new item to *head, then either advances head normally or wraps it back to base if it just wrote the last slot. Designers can choose whether a full buffer rejects new writes (returning an error) or overwrites the oldest unread item — both are legitimate policies depending on the application.
5.3 The Linked List
Buffers need one contiguous block of memory, which can be a problem if that block is large or memory is fragmented. A linked list instead scatters independent, individually allocated nodes across memory and connects them with pointers — each node stores a data item plus a pointer to the next node (a singly linked list). The list's tail is identified by a next pointer of NULL; an empty list has a null (or unallocated) head.
typedef struct Node {
uint32_t data;
struct
Node *next;
} Node;
Because roughly half of each node's memory (on a 32-bit system) goes toward the next pointer rather than the data itself, linked lists trade memory efficiency for flexible, non-contiguous allocation. Variants include the doubly linked list (each node also points to its previous node, so traversal works in either direction and you no longer must retain the head specifically) and ordered or circularly linked lists.
Because the list is dynamic, nodes are created and destroyed at runtime with malloc()/free() as items are inserted or deleted — unlike the two buffer types, which are usually allocated once up front. Appending a node to the end means walking from the head until a NULL next pointer is found, allocating a new node with malloc(), and linking it in; inserting or deleting from the middle (as in an ordered list) is more involved. This dynamic behavior brings real trade-offs: allocation/deallocation overhead on every insert or delete, slower access since finding an item means walking the list from one end, and the risk of memory leaks if callers forget to free nodes they remove. A common refinement is to take the head pointer as a double pointer (Part 4.1) so the function can also create the very first node when the list doesn't exist yet.
Part 6 — Compiler Control & Debugging
6.1 Compiler Attributes and Pragmas
Ordinary C keywords and types don't give a programmer full control over how the compiler lays out or optimizes code, so compilers add non-standard extensions — attributes and pragmas — for that finer control. Because they aren't part of the C standard, attributes and pragmas are not portable between compilers, and projects often wrap them in a preprocessor guard (e.g., testing for __GNUC__) so the same source can compile cleanly elsewhere with the attribute simply defined away.
GCC-style attributes are written as __attribute__((keyword)) and can be applied to variables, structure members, whole structures, or functions:
• aligned(N) — forces alignment to N bytes (N must be a power of two); applied to a member it aligns just that member, applied to a whole structure it aligns the structure's start (and can grow its overall size up to the next power of two).
• packed — removes padding so a structure occupies the minimum possible bytes, at the cost of extra instructions to access unaligned members.
• always_inline — combined with the inline keyword, forces inlining even when compiler optimizations would otherwise skip it.
Pragmas offer another route to fine control — for example, pushing a GCC optimization level for a specific region of code and popping it back afterward so the rest of the file compiles normally:
#pragma GCC push_options
#pragma GCC optimize ("O0")
// code that must not be optimized
#pragma GCC pop_options
6.2 Debugger Fundamentals
A debugger connects to a running (or paused) executable and lets you observe and control it. Embedded debugging usually needs supporting hardware (a programmer/debug probe) plus host software driving it — GDB is a common command-line example, and IDEs like Code Composer Studio wrap a similar debugger with a graphical front end. ARM cores expose a built-in Debug Access Port (DAP) supporting standards such as JTAG and Serial Wire Debug (SWD).
To debug an application, it must first be compiled with debugging symbols enabled so the debugger can map running code back to source lines and variable names, then flashed onto the target through the debug probe. Core debugger features include:
• Breakpoints — pause execution at a specific line so you can inspect state; the program must be resumed or stepped to continue.
• Watchpoints — pause execution automatically when a specific variable or expression changes value, wherever that happens in the program.
• Variable/expression inspection — view (and often edit) local variables, globals, and arbitrary memory expressions, but only while execution is paused.
• Register views — inspect and sometimes edit core CPU registers and peripheral registers directly, useful for testing behavior without recompiling.
• Memory browser — view and edit raw memory at any address, formatted as bytes, words, or other representations.
• Stack trace — shows the current call chain, most valuable once multiple threads or deeply nested calls make it unclear which context led to the current function.
One caveat worth remembering: while execution is paused at a breakpoint or watchpoint, the microcontroller isn't servicing anything else either — incoming external data arriving during that pause can be missed.
6.3 A Debugging Walkthrough
A typical debug session (illustrated in the course using Code Composer Studio, an Eclipse-based IDE) proceeds roughly as follows: build the project and resolve any compile errors first, then launch the debugger, which rebuilds, flashes the target, and pauses execution at the program's entry point. From there, variables can be added to an expressions view and displayed in a chosen format (e.g., hex); uninitialized locals show no meaningful value until they're actually assigned, while globals placed in the BSS section already read as zero at this point.
From the entry breakpoint, a typical flow sets a breakpoint on a specific configuration line (e.g., where a GPIO direction register is set), runs to it, inspects the register's before value in a register view, steps over the line, and confirms the register's after value reflects the change — registers can also be edited directly from this view to test behavior without recompiling. Breakpoints combined with a memory browser make it possible to watch a buffer or array's contents change step by step, for instance confirming that a memmove() call actually copied the expected bytes from one array to another.
A watchpoint set on a loop counter will pause execution every time that counter changes, which is useful for confirming a loop is iterating as expected without single-stepping through every instruction. Using “step over” executes a line (including any function call within it) without descending into it, while “step into” follows execution down into a called function — useful, for example, to watch an XOR-based pin-toggle routine execute inside the loop that produces an LED blink.

No comments:
Post a Comment