Microprocessors and Microcontrollers

Chapter 7
Embedded C and Firmware Development

Diploma in Computer Architecture and Networking
Microprocessors and Microcontrollers   •   Diploma

Embedded C and Firmware Development

Introduction and Context

Embedded systems are computing systems designed to perform a dedicated function within a larger product. Examples include motor controllers, network routers, washing machines, medical instruments, automotive control units, and industrial sensors. Unlike desktop applications, embedded software must operate with limited memory, limited processing power, strict timing requirements, and direct interaction with electronic hardware.

Firmware is the software stored in non-volatile memory and executed by a microcontroller or processor. It commonly initializes hardware, reads inputs, controls outputs, communicates with other devices, and responds to faults. Embedded C is widely used because it combines the efficiency and hardware access of C with structured programming techniques.

A practical firmware developer must understand more than C syntax. The developer must also understand memory maps, registers, interrupts, startup code, cross-compilation, linking, hardware abstraction, timing, concurrency, and debugging.

Learning Objectives

By the end of this chapter, the learner should be able to:

  1. Explain the structure and execution flow of embedded firmware.
  2. Apply bitwise operators to hardware-control problems.
  3. Use pointers safely to access memory-mapped registers.
  4. Design modular firmware using functions, libraries, and hardware-abstraction layers.
  5. Describe the roles of compilers, assemblers, linkers, startup code, and flashing tools.
  6. Develop a small embedded C application using professional coding practices.
  7. Identify limitations, safety risks, and verification requirements in firmware development.

1. Embedded C as a Systems Programming Language

Standard C provides data types, expressions, control structures, functions, arrays, structures, and pointers. Embedded C uses these features while adding implementation-specific facilities such as interrupt declarations, special memory regions, compiler attributes, and device-header files.

A typical embedded program differs from a desktop program in several ways:

CharacteristicDesktop softwareEmbedded firmware
ExecutionOften controlled by an operating systemOften begins directly after reset
MemoryRelatively abundantLimited and explicitly organized
Input/outputFiles, user interfaces, network APIsRegisters, pins, sensors, buses
TimingFrequently flexibleMay require deterministic response
Failure responseApplication restart may be acceptableFailure may damage equipment or endanger users
Build systemNative compilationCross-compilation for another processor

The C standard defines language behavior, but it does not define a particular microcontroller's registers, interrupt controller, or startup process. These are supplied by the toolchain, device vendor, board support package, or operating system.

1.1 A Typical Firmware Structure

A small firmware application commonly contains:

  • Startup code: establishes the initial processor and memory state.
  • Hardware initialization: configures clocks, pins, timers, communication units, and interrupts.
  • Application logic: implements the required product behavior.
  • Drivers: control individual hardware peripherals.
  • Hardware-abstraction layer (HAL): provides a consistent interface above hardware details.
  • Libraries: reusable code for communication, mathematics, protocols, or data structures.
  • Diagnostic and fault-handling code: detects and reports abnormal conditions.

A simple bare-metal program often follows this pattern:

#include <stdint.h>

int main(void)
{
    system_init();
    gpio_init();
    timer_init();

    for (;;) {
        service_inputs();
        control_outputs();
        service_communication();
    }
}

The infinite loop is intentional. Unlike a normal application, firmware usually remains active until power is removed or the processor is reset. The functions shown above are examples of modular decomposition; their definitions would normally be located in separate source files.

2. Data Types, Portability, and Memory

The size of C types such as int and long can vary between processors and compilers. For hardware registers and communication protocols, fixed-width types are usually safer:

#include <stdint.h>

uint8_t  status_byte;
uint16_t adc_result;
uint32_t system_ticks;
int16_t  temperature;

The header <stdint.h> defines types when the implementation provides them. Common types include uint8_t, uint16_t, uint32_t, int8_t, and bool from <stdbool.h>.

The const qualifier indicates that a program should not modify an object through a particular name. The volatile qualifier tells the compiler that a value may change for reasons not visible in the current program flow, such as hardware activity or an interrupt service routine.

volatile uint32_t timer_ticks;
const uint16_t calibration_limit = 950U;

volatile does not make an operation atomic, protect data from races, or guarantee correct timing. It only prevents certain inappropriate optimizations. Shared data may additionally require atomic access, interrupt masking, or synchronization mechanisms.

2.1 Memory Regions

A typical embedded executable uses several memory regions:

RegionTypical contentsCommon property
Flash/ROMProgram instructions and constantsNon-volatile, usually executable
RAMVariables, stack, and heapVolatile, read/write
.dataInitialized writable variablesInitial values copied to RAM at startup
.bssZero-initialized or uninitialized static variablesCleared at startup
StackFunction calls and local automatic variablesLimited size, grows during execution
HeapDynamic allocationMay fragment and is often avoided

The exact arrangement depends on the processor and linker script. Embedded systems often avoid dynamic memory allocation because allocation failure, fragmentation, and unpredictable execution time can be difficult to control. Static allocation is preferred where memory requirements can be determined in advance.

3. Bitwise Operations and Register Manipulation

Microcontroller peripherals are controlled through registers. A register is a group of binary storage bits, each of which may represent a mode, status condition, enable flag, or data value. Bitwise operators allow firmware to modify selected bits without disturbing others.

OperatorMeaningExample
&Bitwise ANDx & mask selects bits
``Bitwise OR
^Bitwise XORx ^ mask toggles bits
~Bitwise complement~x inverts bits
<<Left shiftMoves bits toward the most significant position
>>Right shiftMoves bits toward the least significant position

3.1 Setting, Clearing, and Testing Bits

For a mask M:

register_value |= M;       /* Set selected bits */
register_value &= ~M;      /* Clear selected bits */
register_value ^= M;       /* Toggle selected bits */
if ((register_value & M) != 0U) {
    /* At least one selected bit is set */
}

The expression x &= ~M clears the bits represented by M, but the type and width of M must be considered. Unsigned constants and explicit casts reduce unintended signed arithmetic or width conversions.

To write a multi-bit field, first clear the field and then insert the new value:

#define MODE_MASK  (0x03U << 4)
#define MODE_SHIFT 4U

uint32_t value = peripheral_reg;
value &= ~MODE_MASK;
value |= ((mode << MODE_SHIFT) & MODE_MASK);
peripheral_reg = value;

The mask ensures that bits outside the field are preserved. The input mode should be range-checked before this operation if values outside the field width are invalid.

3.2 Worked Bitwise Example

Suppose an 8-bit control register initially contains 1010 0001₂. Bit 3 is an enable bit, and bits 5–4 form a two-bit mode field.

To set bit 3, use the mask 0000 1000₂:

1010 0001
OR 0000 1000
= 1010 1001

To write mode 01₂ into bits 5–4, clear the field with 1100 1111₂, shift the mode left by four positions, and combine:

old value       1010 1001
clear mask      1100 1111
result          1000 1001
mode value      0001 0000
new value       1001 1001

This procedure changes only the intended field and preserves unrelated bits.

3.3 Shift Limitations

Shifting by an amount greater than or equal to the width of the promoted operand can produce undefined behavior in C. Signed shifts also require care, especially for negative values. Hardware-register operations should generally use unsigned types and valid, named shift constants.

4. Pointers and Memory-Mapped Registers

A pointer stores the address of an object. The * operator dereferences a pointer, while & obtains an object's address.

uint32_t value = 25U;
uint32_t *p = &value;
*p = 30U;

Microcontrollers commonly map peripheral registers into the processor's address space. A simplified register definition might be:

#define GPIO_BASE       (0x40020000UL)
#define GPIO_DIR_OFFSET (0x00UL)
#define GPIO_OUT_OFFSET (0x04UL)

#define GPIO_DIR (*(volatile uint32_t *)(GPIO_BASE + GPIO_DIR_OFFSET))
#define GPIO_OUT (*(volatile uint32_t *)(GPIO_BASE + GPIO_OUT_OFFSET))

The cast converts an integer address into a pointer to a volatile 32-bit object. The macro then provides register-like access. In production systems, vendor-supplied device headers are preferred because they include the correct addresses, reserved fields, access permissions, and device-specific naming.

A safer style for a register block is:

typedef struct {
    volatile uint32_t DIR;
    volatile uint32_t OUT;
    volatile uint32_t IN;
} GpioRegisters;

#define GPIO ((GpioRegisters *)0x40020000UL)

The address and layout must be verified against the device reference manual. Incorrect offsets can write to an unrelated peripheral or cause a bus fault.

4.1 Pointer Hazards

Important risks include:

  • Dereferencing a null or invalid pointer.
  • Using an incorrectly aligned address.
  • Applying the wrong data width to a register.
  • Accessing a register that is read-only or write-only.
  • Assuming that a read-modify-write operation is safe for a hardware register.
  • Omitting volatile for values changed by hardware or interrupts.
  • Allowing pointer arithmetic to exceed an array's bounds.

Some registers have special behavior: writing one to a status bit may clear it, reading may acknowledge an event, or reserved bits may require fixed values. The reference manual is authoritative.

5. Functions, Modules, and Libraries

Modular firmware separates responsibilities into manageable units. A module normally has a header file containing its public interface and a source file containing its implementation.

/* led.h */
#ifndef LED_H
#define LED_H

#include <stdbool.h>

void led_init(void);
void led_set(bool on);

#endif
/* led.c */
#include "led.h"

void led_init(void)
{
    /* Configure the LED pin */
}

void led_set(bool on)
{
    /* Drive the output according to on */
}

Include guards prevent repeated inclusion. Functions and variables that are private to a source file should be declared static, reducing namespace pollution and making interfaces clearer.

A useful layering model is:

  1. Application layer: product behavior and state machines.
  2. Service layer: logging, scheduling, communication protocols, and data management.
  3. HAL: portable operations such as gpio_write() or uart_send().
  4. Peripheral drivers: device-specific register operations.
  5. Hardware: processor, pins, sensors, actuators, and buses.

This structure allows application logic to be tested with simulated HAL functions instead of physical hardware.

6. Cross-Compilation, Linking, and Startup Code

Embedded development usually uses a host computer to build software for a different target processor. This is cross-compilation.

The toolchain normally includes:

  • Preprocessor: expands macros and includes header files.
  • Compiler: translates C into assembly or an intermediate representation.
  • Assembler: converts assembly into object code.
  • Linker: combines object files and libraries into an executable image.
  • Linker script: defines memory regions and placement rules.
  • Post-build tools: create formats such as ELF, Intel HEX, or binary images.
  • Programmer or debugger: transfers the image to target memory.

The linker resolves symbols such as function calls and assigns addresses. It also places code and data into Flash and RAM. A linker script may define a Flash origin, Flash length, RAM origin, and RAM length. Exact values are device-specific and must come from the hardware documentation.

6.1 Reset-to-Application Sequence

After reset, a typical sequence is:

  1. The processor obtains an initial stack pointer and reset-handler address from a vector table.
  2. Startup code establishes low-level processor state.
  3. The .data initial values are copied from non-volatile memory to RAM.
  4. The .bss region is cleared to zero.
  5. Clock and runtime initialization are performed.
  6. The C library initialization routine may run.
  7. main() is called.
  8. If main() returns, a controlled embedded system normally enters a fault or shutdown loop rather than relying on desktop operating-system behavior.

A simplified conceptual startup routine is:

extern uint32_t _sidata, _sdata, _edata;
extern uint32_t _sbss, _ebss;

void reset_handler(void)
{
    uint32_t *src = &_sidata;
    uint32_t *dst = &_sdata;

    while (dst < &_edata) {
        *dst++ = *src++;
    }

    for (dst = &_sbss; dst < &_ebss; ++dst) {
        *dst = 0U;
    }

    main();
    for (;;) {
        /* Fail-safe behavior */
    }
}

This is illustrative, not a universal startup implementation. Actual startup code must respect the processor architecture, ABI, vector-table format, floating-point initialization, cache configuration, and vendor requirements.

7. Interrupts, Timing, and Concurrency

An interrupt temporarily transfers execution from normal code to an interrupt service routine (ISR). ISRs should be short, deterministic, and limited to urgent work. A common design is for the ISR to record an event and for the main loop to process it later.

volatile bool sample_due = false;

void timer_isr(void)
{
    timer_clear_flag();
    sample_due = true;
}

int main(void)
{
    system_init();
    for (;;) {
        if (sample_due) {
            sample_due = false;
            read_sensor_and_update_control();
        }
    }
}

The example is conceptually useful, but the assignment and test of a single Boolean may not be sufficient when events can occur faster than they are processed. A counter, queue, or atomic flag may be required.

The keyword volatile does not guarantee atomicity. If a multi-byte variable can be modified by both an ISR and main code, an interrupt may occur between its individual machine instructions. Critical sections, atomic types, or appropriate synchronization are needed.

Timing requirements can be expressed using a deadline. If an operation must finish within a period T, its worst-case execution time C must satisfy the relevant scheduling constraints. For a simple single periodic task with no interference, a necessary basic condition is C ≤ T; real-time systems with multiple tasks require more complete schedulability analysis.

8. Hardware-Abstraction Layers and Portability

A HAL hides device-specific implementation details behind stable interfaces:

void gpio_write(uint8_t pin, bool state);
bool gpio_read(uint8_t pin);
void uart_send(const uint8_t *data, uint16_t length);

The application should not need to know register addresses. The HAL may be implemented using direct register access, a vendor library, or an operating-system driver.

A good HAL should have:

  • Clear ownership of hardware resources.
  • Explicit initialization and error behavior.
  • Consistent units and naming.
  • Minimal hidden side effects.
  • Testable interfaces.
  • Documentation of timing and concurrency assumptions.

Abstraction is not free. Excessive layering may increase code size, execution time, or debugging complexity. The design should preserve the performance and determinism required by the application.

9. Firmware Development Workflow

A disciplined workflow reduces defects:

  1. Read the processor data sheet and reference manual.
  2. Define requirements, timing limits, interfaces, and safety conditions.
  3. Design modules and ownership of each peripheral.
  4. Configure the build system and linker script.
  5. Implement low-level drivers and verify them independently.
  6. Add HAL interfaces and application state machines.
  7. Compile with warnings enabled and treat important warnings as errors.
  8. Perform static analysis, code review, and unit testing.
  9. Test on hardware using a debugger, logic analyzer, oscilloscope, or protocol analyzer.
  10. Conduct integration, stress, fault-injection, and regression testing.
  11. Record versions of source code, tools, configuration, and firmware images.

Coding standards should address naming, indentation, integer conversions, pointer use, interrupt safety, error handling, and prohibited constructs. Standards such as MISRA C are widely used in safety- and security-conscious development, although compliance requires a defined process rather than simply following a style guide.

10. Applied Case: Temperature-Controlled Fan

Background

Consider an illustrative embedded controller for a small equipment enclosure. A microcontroller reads a temperature sensor every second and controls a fan using a digital output. The fan must start above 35 °C and stop below 30 °C.

Problem

If the same threshold is used for starting and stopping, small sensor fluctuations near the threshold can repeatedly switch the fan. This is called chattering. The controller also needs a safe response to an invalid sensor reading.

Analysis

The application requires:

  • Periodic sensor sampling.
  • A temperature conversion driver.
  • Hysteresis in the control algorithm.
  • A GPIO driver for the fan.
  • Fault detection and a defined fail-safe state.
  • Separation between hardware access and control logic.

Approach

typedef enum {
    FAN_OFF,
    FAN_ON,
    FAN_FAULT
} fan_state_t;

static fan_state_t fan_state = FAN_OFF;

void fan_control_update(int16_t temperature_c, bool sensor_valid)
{
    if (!sensor_valid) {
        fan_state = FAN_FAULT;
        gpio_write(FAN_PIN, true);       /* Illustrative fail-safe action */
        return;
    }

    if (fan_state == FAN_OFF && temperature_c >= 35) {
        fan_state = FAN_ON;
    } else if (fan_state == FAN_ON && temperature_c <= 30) {
        fan_state = FAN_OFF;
    }

    gpio_write(FAN_PIN, fan_state == FAN_ON || fan_state == FAN_FAULT);
}

The two thresholds implement hysteresis. The precise fail-safe output must be determined by the equipment's thermal and electrical safety analysis; turning the fan on is only an illustrative choice.

Outcome

The modular design permits the sensor driver, GPIO driver, and control algorithm to be tested separately. A software test can supply sequences such as 34 °C, 36 °C, 34 °C, and 29 °C and verify that the fan changes state only at the intended thresholds.

Learning Points

The case demonstrates state-based control, separation of concerns, defensive fault handling, and the importance of specifying behavior for invalid inputs. It also illustrates why firmware is an engineering product rather than merely a collection of C statements.

11. Limitations, Security, and Professional Responsibility

Firmware defects can cause loss of data, equipment damage, privacy violations, or physical injury. Important risks include buffer overflows, unchecked input, insecure boot processes, weak update mechanisms, default credentials, and incorrect privilege boundaries.

Secure development should include:

  • Bounds checking and input validation.
  • Protection of private keys and credentials.
  • Secure boot where appropriate.
  • Authenticated and recoverable firmware updates.
  • Watchdogs and fault logging.
  • Removal of debugging interfaces or protection of them in production.
  • Dependency and toolchain version control.
  • Traceability from requirements to tests.

For network-connected devices, firmware must be maintained after deployment. Security updates, vulnerability response, configuration management, and end-of-life procedures are part of the product lifecycle. Safety-critical applications may also be governed by sector-specific standards and certification processes; the applicable standard depends on the industry and risk classification.

Modern development increasingly uses model-based testing, continuous integration, static analysis, hardware-in-the-loop testing, and automated regression testing. Artificial intelligence may assist with code search, test generation, or documentation, but generated code must be reviewed against the processor manual, compiler behavior, security requirements, and project coding standard. Responsibility for the deployed firmware remains with qualified engineers.

Best Practices Checklist

  • Use fixed-width integer types for hardware and protocol data.
  • Use unsigned masks and named constants for register fields.
  • Preserve unrelated register bits during updates.
  • Mark hardware- and interrupt-shared objects appropriately with volatile, while recognizing its limits.
  • Keep ISRs short and avoid blocking operations within them.
  • Prefer static allocation when deterministic memory use is required.
  • Separate application logic from hardware access.
  • Document register assumptions, units, timing, and error behavior.
  • Inspect the map file to understand memory usage.
  • Test normal, boundary, invalid, and fault conditions.
  • Use version-controlled source, build tools, linker scripts, and release images.

Chapter Summary

Embedded C firmware combines the C language with direct control of processor hardware. Effective development requires understanding fixed-width types, memory regions, pointers, volatile access, bitwise register operations, interrupts, timing, and modular design. Cross-compilers translate source code for a target processor, while assemblers, linkers, linker scripts, startup code, and programming tools create and deploy the final image. A hardware-abstraction layer can improve portability and testing, provided that its performance and timing costs are controlled. Professional firmware development also requires defensive programming, static analysis, hardware testing, security maintenance, and documented fault behavior.

Key Terms

Embedded system: A computer system designed for a dedicated function within a larger product.

Firmware: Software stored in non-volatile memory and executed by embedded hardware.

Register: A processor or peripheral storage location used for control, status, or data.

Bit mask: A binary value used to select particular bits.

Bitwise operation: An operation that processes corresponding binary bits of operands.

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

Memory-mapped I/O: Peripheral access through addresses in the processor's address space.

Volatile: A qualifier indicating that an object may change outside ordinary program flow.

Interrupt: An event that transfers execution to an interrupt service routine.

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

Cross-compiler: A compiler that generates code for a target different from the host system.

Linker: A tool that combines object files, resolves symbols, and assigns addresses.

Linker script: A file specifying memory regions and placement of program sections.

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

HAL: Hardware-abstraction layer that hides hardware-specific implementation details.

Driver: Software component that controls a hardware peripheral.

Hysteresis: Use of different switching thresholds to prevent repeated state changes.

Watchdog: A hardware or software timer used to detect a non-responsive program.

Static analysis: Automated examination of source code without executing it.

Review Questions

Short Answer Questions

  1. Why are fixed-width integer types useful in embedded firmware?
  2. What is the purpose of the volatile qualifier?
  3. State the bitwise operations used to set, clear, and toggle selected bits.
  4. What are the main responsibilities of a linker?
  5. Why should an interrupt service routine usually be short?

Long Answer Questions

  1. Explain the memory regions commonly present in an embedded application and describe the initialization of .data and .bss.
  2. Describe how a hardware-abstraction layer improves modularity, portability, and testing.
  3. Explain the reset-to-main() execution sequence of a typical microcontroller.
  4. Discuss pointer hazards associated with memory-mapped registers.
  5. Describe a professional firmware development workflow from requirements through deployment.

Analytical / Application Questions

  1. A control register contains unrelated status bits and a two-bit mode field. Design a safe procedure to update only the mode field and explain why a direct assignment may be unsafe.
  2. Design a firmware architecture for a sensor node that samples periodically, sends data over UART, and enters a safe state when the sensor is invalid. Identify the modules and shared-data risks.
  3. Analyze the temperature-controlled fan case and propose tests for normal operation, threshold boundaries, sensor failure, reset behavior, and recovery.

Multiple Choice Questions with Answers

Question 1

Which operator is normally used to set selected bits in a register without changing other bits?

A. | with a mask

B. & with a mask

C. ^ with a zero mask

D. ~ with a mask only

Correct Answer: A. | with a mask

Question 2

Why is volatile commonly applied to a memory-mapped hardware register?

A. It makes all access atomic

B. It prevents the compiler from treating the value as unchanging

C. It encrypts the register contents

D. It places the register in Flash memory

Correct Answer: B. It prevents the compiler from treating the value as unchanging

Question 3

Which tool combines object files and assigns program sections to memory addresses?

A. Preprocessor

B. Assembler

C. Linker

D. Debugger

Correct Answer: C. Linker

Question 4

What is the main purpose of startup code?

A. To draw the user interface

B. To initialize memory and processor state before main()

C. To replace all device drivers

D. To compress network packets

Correct Answer: B. To initialize memory and processor state before main()

Question 5

Which expression clears all bits represented by mask M in an unsigned register variable x?

A. x |= M

B. x ^= M

C. x &= ~M

D. x = ~M

Correct Answer: C. x &= ~M

Question 6

What problem does hysteresis solve in the fan-control case?

A. Insufficient Flash memory

B. Repeated switching near a single threshold

C. Incorrect pointer alignment

D. Slow cross-compilation

Correct Answer: B. Repeated switching near a single threshold

Question 7

Which statement about an interrupt service routine is generally best practice?

A. It should perform long blocking operations

B. It should be short and defer non-urgent work

C. It should allocate memory repeatedly

D. It should always disable interrupts permanently

Correct Answer: B. It should be short and defer non-urgent work

Question 8

What is the primary purpose of a hardware-abstraction layer?

A. To hide hardware-specific details behind a consistent interface

B. To eliminate the need for testing

C. To convert C into assembly at run time

D. To increase the physical RAM size

Correct Answer: A. To hide hardware-specific details behind a consistent interface

Question 9

Which memory region typically contains zero-initialized static variables after startup?

A. .bss

B. .text

C. .data only

D. Heap code section

Correct Answer: A. .bss

Question 10

Why can dynamic memory allocation be avoided in many embedded systems?

A. It is never supported by C

B. It may introduce fragmentation and unpredictable failure or timing

C. It always uses Flash instead of RAM

D. It prevents functions from being compiled

Correct Answer: B. It may introduce fragmentation and unpredictable failure or timing

Microprocessors and Microcontrollers
Chapter 7 of 14