Microcontrollers and Interfacing

Chapter 3
Embedded C/C++ and Low-Level Programming

Bachelor Program in Mechatronics Engineering
Microcontrollers and Interfacing   •   Bachelor

Embedded C/C++ and Low-Level Programming

Introduction

Microcontrollers interact directly with electrical signals, memory locations, timers, communication peripherals, and interrupts. Firmware must therefore be both expressive enough to implement algorithms and precise enough to control hardware. Embedded C remains the dominant language for this purpose because it combines structured programming with low-level access to memory and processor operations. Embedded C++ adds abstraction mechanisms such as classes, templates, and namespaces, but these must be used with an understanding of timing, memory, and code-size constraints.

Low-level programming is not simply writing shorter code. It means understanding how source statements are translated into machine instructions, how data is represented in memory, how registers control peripherals, and how program execution begins after reset. Good firmware is maintainable, deterministic, testable, and portable across related devices.

Learning Objectives

After studying this chapter, the reader should be able to:

  1. Select appropriate C/C++ data types for embedded applications.
  2. Use bitwise operations to manipulate hardware registers safely.
  3. Explain pointers, arrays, structures, and memory-mapped I/O.
  4. Apply const and volatile correctly.
  5. Organize firmware into reusable modules and functions.
  6. Describe stack, static, heap, and read-only memory.
  7. Explain startup code, interrupts, and basic assembly-language concepts.
  8. Apply coding standards, defensive techniques, and version-control practices.

1. Embedded Software as a Hardware Interface

A conventional desktop program usually operates through an operating system. A microcontroller program may execute without an operating system and may read or write hardware registers directly. A useful conceptual chain is:

application logic → driver module → register access → peripheral circuit → physical signal

For example, a temperature-control application may call heater_set(). That function modifies a timer or GPIO register, which changes a transistor output and ultimately changes heater power.

This separation is important. Application code should express system behavior, while driver code should contain device-specific register details. Such separation improves portability and makes testing possible without the physical circuit.

Determinism and resource constraints

Embedded systems commonly have limited RAM and nonvolatile memory. They may also have deadlines. A control loop executing every 1 ms has a period

T = 1 ms

where T is the interval between successive executions. If the worst-case execution time is C, a basic single-task feasibility condition is

C ≤ T

This condition is necessary but not sufficient for a complete real-time system, because interrupts, multiple tasks, blocking, and scheduling overhead also matter. It illustrates why unbounded operations and unpredictable dynamic allocation can be inappropriate in time-critical firmware.

2. Data Types and Object Representation

The C language defines minimum ranges for fundamental types, but the exact size of int, long, and pointers depends on the target architecture. Portable embedded code should use fixed-width types from <stdint.h> when an exact width is required.

TypeTypical purpose
uint8_t8-bit register or byte value
int16_tsigned measurement or intermediate result
uint32_ttimer count, address-sized data on many MCUs
boollogical state, when supported through <stdbool.h> or C++
size_tobject or buffer size
enumnamed operating states

Signed integers represent negative and positive values; unsigned integers represent nonnegative values. For an unsigned type with n bits, the representable range is

0 ≤ x ≤ 2n − 1

For a two's-complement signed type with n bits, the usual range is

−2^(n−1) ≤ x ≤ 2^(n−1) − 1

These ranges assume the conventional binary representations used by contemporary microcontrollers. Arithmetic overflow of unsigned integers is defined modulo 2ⁿ, but signed overflow in C is undefined behavior. Consequently, code should not rely on signed overflow.

Integer promotions and conversions

Small integer types are often promoted to int during expressions. Mixing signed and unsigned values can produce unexpected comparisons. For example, a negative signed value may be converted to a large unsigned value before comparison. Explicitly choose types and casts at hardware boundaries, and avoid casts that merely suppress compiler warnings without proving correctness.

Floating-point arithmetic may be expensive or unavailable in small microcontrollers. Fixed-point arithmetic is often preferable for control and signal-processing applications. If a physical quantity is represented as an integer scale factor, the scale must be documented. For example, a temperature value of 253 may represent 25.3 °C under a scale of 0.1 °C per count.

3. Bitwise Operations and Register Manipulation

Bitwise operators act on the binary representation of integer operands:

OperatorMeaning
&bitwise AND
``
^bitwise exclusive OR
~bitwise complement
<<left shift
>>right shift

A bit mask selects one or more bits. To set bit k in register r:

r |= (uint32_t)1u << k;

To clear it:

r &= ~((uint32_t)1u << k);

To toggle it:

r ^= (uint32_t)1u << k;

To test it:

if ((r & ((uint32_t)1u << k)) != 0u) {
    /* bit is set */
}

The shift count must be valid for the operand width. Shifting by a negative amount or by an amount greater than or equal to the width is erroneous or undefined depending on the expression and language rules. Unsigned operands are generally safer for masks.

Field replacement

Suppose bits 4–6 form a three-bit field. A field mask is

#define MODE_MASK (0x7u << 4)

A safe replacement first clears the old field and then inserts the new value:

reg = (reg & ~MODE_MASK) | ((mode << 4) & MODE_MASK);

The value of mode must fit the field. If it does not, masking truncates higher bits; robust interfaces may instead validate and reject the value.

A common error is using && or ||, which are logical operators producing 0 or 1, instead of & or |, which operate on each bit. Parentheses are also essential because operator precedence can make an apparently correct expression behave differently from its intended meaning.

4. Pointers, Arrays, and Memory-Mapped I/O

A pointer stores the address of an object. The expression *p accesses the object designated by p, while &x obtains the address of x.

uint32_t value = 0u;
uint32_t *p = &value;
*p = 25u;

The pointer type determines how dereferencing interprets memory and how pointer arithmetic advances. Incrementing a uint32_t * advances by sizeof(uint32_t) bytes, not necessarily one byte.

Arrays occupy contiguous elements, and in most expressions an array name is converted to a pointer to its first element. Bounds are not automatically checked in C. A buffer of N elements has valid indices from 0 through N − 1; writing outside that range can corrupt unrelated memory.

Memory-mapped registers

Many microcontrollers assign peripheral registers to addresses in the processor address space. A simplified definition might be:

#define GPIO_OUT (*(volatile uint32_t *)0x40020014u)

The address and register width must come from the manufacturer’s reference manual. The cast converts the integer address to a pointer, and dereferencing accesses the register. The volatile qualifier tells the compiler that the value may change for reasons not visible in ordinary program flow.

Production code should usually place register definitions in a device header supplied or generated for the exact part. Direct numeric addresses are useful for explanation but are fragile when copied between devices.

Alignment and aliasing

Some processors require multi-byte objects to be aligned at particular address boundaries. Unaligned access may be slower or may cause a fault. C also restricts how an object may be accessed through unrelated pointer types. Violating these aliasing rules can allow the compiler to optimize away apparently necessary operations. Use memcpy, unions only where implementation behavior is understood, or compiler-supported access mechanisms for representation conversion.

5. Structures, Enumerations, and Interfaces

Structures group related data:

typedef struct {
    uint16_t raw;
    int16_t temperature_c10;
    bool valid;
} SensorSample;

A structure improves readability and allows a function to pass a coherent record. However, structure layout may contain padding, and its size should not be assumed for communication protocols unless packing and byte order are explicitly controlled.

Enumerations express named states:

typedef enum {
    SYSTEM_IDLE,
    SYSTEM_RUN,
    SYSTEM_FAULT
} SystemState;

State names are clearer than unexplained integer constants. The underlying representation and range should still be considered when data is transmitted or stored.

A module should expose a small public interface in a header and keep implementation details private in a source file. For example, motor.h may declare motor_init() and motor_set_duty(), while motor.c contains register details and internal state. Include guards or #pragma once prevent repeated declarations.

6. Functions, Modularity, and Embedded C++

Functions reduce duplication and establish testable units. A function interface should specify valid input ranges, units, side effects, error behavior, and timing expectations.

bool adc_read(uint8_t channel, uint16_t *result);

This interface returns success status and writes the measurement through a pointer. It must define what happens when result is null or the channel is invalid. Defensive implementations validate arguments where the cost is acceptable.

A useful module has high cohesion—its contents serve one purpose—and low coupling—other modules depend on as few implementation details as possible. Layering commonly separates hardware abstraction, device drivers, services, and application logic.

Embedded C++ can encapsulate hardware resources:

class Led {
public:
    void on();
    void off();
private:
    uint32_t mask_;
};

C++ features should be evaluated against generated code, memory use, exception behavior, and toolchain support. Many safety-oriented systems disable exceptions and unrestricted dynamic allocation. Templates and constexpr can provide zero-runtime-cost abstraction when used carefully. Object construction order and static initialization must be understood because startup code executes before main().

7. const, volatile, and Related Qualifiers

const means that code using a particular expression cannot modify the qualified object through that access path. It does not necessarily mean the object is physically stored in read-only memory.

const uint16_t calibration_table[4] = {100, 102, 99, 101};

volatile means every observable read and write through the qualified access must be treated as significant. It is appropriate for memory-mapped registers, variables changed by an interrupt service routine, and hardware status locations.

volatile bool conversion_complete;

volatile does not make an operation atomic, does not provide mutual exclusion, and does not establish full inter-core memory ordering. If an interrupt updates a multi-byte object while foreground code reads it, the access may need to be protected or designed using an atomic protocol.

The qualifiers can be combined. A read-only hardware register is conceptually volatile const: software must read each time, but must not write.

8. Memory Organization and Memory Management

A typical embedded image contains:

  • Text/code: executable instructions, often stored in flash.
  • Read-only data: constants and lookup tables.
  • Initialized data: variables copied from flash to RAM at startup.
  • Zero-initialized data (BSS): variables cleared to zero at startup.
  • Stack: automatic variables, return addresses, and saved context.
  • Heap: dynamically allocated objects, if used.

The exact arrangement depends on the linker script and processor. Stack overflow can corrupt variables or cause a fault. Recursion, large local arrays, and deep call chains should therefore be justified and bounded.

Dynamic allocation through malloc or new may cause fragmentation, allocation failure, nondeterministic timing, and difficult ownership bugs. Static allocation, fixed-size pools, or region-based allocation are often preferable. If dynamic allocation is required, define ownership, lifetime, maximum usage, failure behavior, and concurrency rules.

9. Startup Code, Reset, and Interrupts

After reset, a microcontroller typically obtains an initial stack pointer and a reset-handler address from a vector table. Startup code then performs processor initialization, copies initialized data from nonvolatile memory to RAM, clears the BSS region, optionally initializes clocks and runtime libraries, and calls main().

The conceptual sequence is:

reset → vector lookup → stack setup → data/BSS initialization → runtime initialization → main()

The linker script supplies addresses and section boundaries. Errors in startup or linker configuration can produce failures before application code executes.

An interrupt service routine (ISR) should be short, deterministic, and clear about shared data. A common design is for the ISR to capture a timestamp or byte into a buffer and set an event flag; foreground code performs lengthy processing later. Shared variables may need volatile, but synchronization additionally requires atomic operations or critical sections.

10. Assembly Fundamentals

Assembly language exposes processor instructions and registers. A typical instruction conceptually contains an operation and operands, such as loading a value, adding registers, or branching to a label. Assembly is useful for startup code, context switching, timing-critical routines, and examining compiler output.

Important concepts include:

  • registers: fast processor storage;
  • program counter: address of the next instruction;
  • stack pointer: top of the current stack;
  • status register: condition flags and control bits;
  • load/store operations: transfer between memory and registers;
  • branch instructions: alter control flow;
  • calling convention: rules for arguments, return values, and preserved registers.

Most firmware should be written in C or C++ and inspected through compiler-generated assembly when necessary. Inline assembly is difficult to maintain and can interfere with compiler optimization unless constraints are specified correctly. Use it only when the compiler cannot express a required operation and document the processor assumptions.

11. Coding Standards, Testing, and Version Control

Coding standards reduce defects caused by ambiguity. A practical standard should define naming, indentation, brace placement, type usage, error handling, comment style, module boundaries, and allowed language features. MISRA C and MISRA C++ are influential examples for safety-related automotive and embedded software, although compliance requires a defined project process rather than copying selected rules.

Good comments explain intent, units, hardware assumptions, or safety rationale—not merely what an obvious statement does. Every register operation should identify the relevant reference-manual section or device abstraction.

Testing should proceed at several levels:

  1. Unit tests: verify functions in isolation.
  2. Integration tests: verify interactions between modules.
  3. Hardware-in-the-loop tests: exercise firmware with real or simulated signals.
  4. System tests: verify complete behavior and fault responses.

Static analysis can detect out-of-bounds access, unreachable code, suspicious conversions, and uninitialized variables. Compiler warnings should be treated seriously, ideally with warnings elevated to errors where practical.

Version control provides history, review, branching, and reproducible releases. A professional workflow includes small focused commits, descriptive messages, peer review, tagged firmware versions, reproducible build settings, and continuous integration. Generated binaries should be traceable to source revision, compiler version, configuration, and calibration data.

Worked Example: Safe Register Configuration

Assume an eight-bit control register has the following fields:

  • bit 0: enable;
  • bits 1–2: operating mode;
  • bit 7: interrupt enable.

The definitions are:

#define ENABLE_MASK       (1u << 0)
#define MODE_MASK         (3u << 1)
#define IRQ_ENABLE_MASK   (1u << 7)

static inline void configure(uint8_t mode, bool enable, bool irq)
{
    uint8_t value = PERIPH_CTRL;

    value &= (uint8_t)~(MODE_MASK | ENABLE_MASK | IRQ_ENABLE_MASK);
    value |= (uint8_t)((mode & 0x03u) << 1);

    if (enable) value |= ENABLE_MASK;
    if (irq)    value |= IRQ_ENABLE_MASK;

    PERIPH_CTRL = value;
}

The old values of the selected fields are cleared before new values are inserted. The mode is limited to two bits, so only values 0 through 3 are representable. In a real device, the register’s read-modify-write behavior must be checked: some status registers clear on read, and some control registers require atomic set/clear registers instead.

Applied Case: Motor-Control Firmware Refactoring

Background

Consider an illustrative small robotic actuator whose original firmware directly manipulated GPIO and timer registers throughout main(). The code worked in laboratory tests but occasionally failed when a communication interrupt occurred.

Problem

The firmware mixed application decisions, PWM configuration, emergency-stop handling, and serial parsing in one large function. Register masks were duplicated, a shared 16-bit speed command was updated without protection, and a heap-allocated message buffer occasionally failed after extended operation.

Analysis and approach

The design was divided into a PWM driver, serial driver, safety manager, and motion-control application. Register definitions were centralized. The speed command was transferred using a protected critical section, and the message buffer became a fixed-size ring buffer. The emergency-stop input was handled by an interrupt that immediately disabled the output and posted a fault event.

The team added unit tests for duty-cycle limits, invalid commands, and state transitions. Static analysis detected an implicit signed-to-unsigned conversion. Version control tags linked each production image to its source and configuration.

Outcome

The refactored system became easier to test and the intermittent command corruption disappeared. Timing measurements showed that the control loop remained within its deadline under the maximum interrupt load. The design also made it possible to replace the microcontroller-specific PWM driver without changing motion-control logic.

Learning points

The principal improvement was architectural rather than syntactic. Correct bit manipulation matters, but maintainability comes from clear interfaces, explicit ownership of shared data, bounded resources, and traceable engineering practice.

Challenges and Limitations

Low-level access improves control but reduces portability. Compiler behavior, processor architecture, linker scripts, interrupt latency, register side effects, and memory ordering all affect correctness. volatile is often overused as a substitute for synchronization. Dynamic allocation is not inherently forbidden, but it requires stronger evidence and control than static allocation. Coding standards reduce risk but cannot replace testing, review, or hardware validation.

Security is also relevant. Firmware should validate external inputs, protect update mechanisms, prevent unintended debug access in deployed products, and preserve audit records for safety-critical changes. Regulations and standards vary by industry; automotive, medical, industrial, and aerospace projects may require formal lifecycle, traceability, and verification processes.

Contemporary Developments

Modern toolchains increasingly provide static analysis, sanitizers for host-based tests, automated unit testing, and profile-guided optimization. Rust and other memory-safe languages are being evaluated for embedded development, while C and C++ remain dominant because of mature toolchains and hardware support. AI-assisted coding can suggest register code or tests, but generated code must be checked against the device reference manual, compiler behavior, timing requirements, licensing constraints, and security policy. Responsibility for firmware correctness remains with the engineering team.

Chapter Summary

Embedded C and C++ provide a bridge between structured software and microcontroller hardware. Reliable firmware depends on deliberate data-type selection, safe bitwise operations, correct pointer use, and disciplined register access. Structures, enumerations, functions, and modular interfaces make code comprehensible and testable. const communicates immutability, while volatile handles externally changing objects but does not provide atomicity or synchronization. Understanding memory sections, startup code, interrupts, and assembly fundamentals helps engineers diagnose failures below the application layer. Finally, coding standards, static analysis, testing, and version control convert individual programming skill into a professional development process.

Key Terms

Data type: A rule defining representation, range, and permitted operations.

Bit mask: A binary pattern used to select or modify particular bits.

Pointer: An object that stores the address of another object or function.

Memory-mapped I/O: Hardware control through addresses in the processor memory space.

Structure: A composite type containing named members.

Enumeration: A type representing a set of named integral values.

API: A defined interface through which software components communicate.

Cohesion: The degree to which a module’s contents serve one purpose.

Coupling: The degree of dependency between modules.

const: A qualifier restricting modification through an access path.

volatile: A qualifier requiring observable reads and writes to be preserved.

Stack: Memory used for automatic storage and call context.

Heap: A region used for dynamic allocation.

Linker script: Configuration describing memory regions and image sections.

Startup code: Code that prepares processor and memory state before main().

ISR: Interrupt service routine executed in response to an interrupt.

Calling convention: Rules governing function arguments, returns, and registers.

Static analysis: Automated examination of source or binaries for defects.

Ring buffer: A fixed-size circular data structure commonly used for streams.

Critical section: Code executed with controlled access to shared resources.

Review Questions

Short Answer Questions

  1. Why are fixed-width integer types useful in embedded firmware?
  2. Distinguish bitwise AND from logical AND.
  3. What problem does volatile address?
  4. Name the principal memory regions found in a typical embedded image.
  5. Why should an interrupt service routine usually be short?

Long Answer Questions

  1. Explain how a register field can be safely replaced without changing unrelated bits.
  2. Discuss the advantages and limitations of dynamic memory allocation in microcontrollers.
  3. Describe the sequence from reset to execution of main().
  4. Compare modular C programming with object-oriented embedded C++.
  5. Explain how coding standards, static analysis, testing, and version control work together.

Analytical / Application Questions

  1. Design a register interface for a control register containing an enable bit, a three-bit mode field, and an interrupt-enable bit. Explain your masks and validation strategy.
  2. An ISR updates a 32-bit measurement while foreground code reads it. Analyze the possible race condition and propose two safe designs.
  3. A firmware product occasionally fails after several hours because it allocates and releases variable-sized messages. Analyze the likely causes and propose a bounded-memory redesign.

Multiple Choice Questions with Answers

Question 1

Which qualifier is most appropriate for a memory-mapped status register that may change independently of program flow?

A. volatile

B. static

C. register

D. typedef

Correct Answer: A. volatile

Question 2

Which expression sets bit k in an unsigned register r without intentionally changing other bits?

A. r &= (1u << k)

B. r |= (1u << k)

C. r ^= ~(1u << k)

D. r = (1u >> k)

Correct Answer: B. r |= (1u << k)

Question 3

For an unsigned integer with n bits, what is its maximum representable value?

A. 2ⁿ

B. 2ⁿ − 1

C. 2ⁿ⁻¹ − 1

D. n² − 1

Correct Answer: B. 2ⁿ − 1

Question 4

Which memory region normally stores automatic local variables?

A. Flash text section

B. Stack

C. Read-only data section

D. Vector table only

Correct Answer: B. Stack

Question 5

What is the main purpose of a module interface header?

A. To hide all public functions

B. To define the compiler’s optimization level

C. To declare services available to other modules

D. To allocate every variable dynamically

Correct Answer: C. To declare services available to other modules

Question 6

What does clearing a bit generally require?

A. OR with the bit mask

B. AND with the complemented bit mask

C. XOR with zero

D. Left shifting the register only

Correct Answer: B. AND with the complemented bit mask

Question 7

Which statement about volatile is correct?

A. It guarantees atomic multi-byte access

B. It prevents all interrupts

C. It tells the compiler that accesses have externally observable significance

D. It automatically places data in flash

Correct Answer: C. It tells the compiler that accesses have externally observable significance

Question 8

What normally occurs before main() in a bare-metal program?

A. The heap is always expanded indefinitely

B. The stack and data sections are initialized

C. All interrupts are permanently disabled

D. Every peripheral begins operating automatically

Correct Answer: B. The stack and data sections are initialized

Question 9

Why can unrestricted dynamic allocation be problematic in long-running embedded systems?

A. It always makes code execute faster

B. It can cause fragmentation and unpredictable failure or timing

C. It prevents use of pointers

D. It removes the need for testing

Correct Answer: B. It can cause fragmentation and unpredictable failure or timing

Question 10

Which practice best supports reproducible firmware releases?

A. Keeping source files without version history

B. Changing compiler settings manually for each build

C. Tagging source revisions and recording build configuration

D. Removing warnings from the build output

Correct Answer: C. Tagging source revisions and recording build configuration

Microcontrollers and Interfacing
Chapter 3 of 14