Embedded C and Firmware DevelopmentIntroductionEmbedded firmware is the software permanently or semi-permanently stored in a microcontroller, processor, or system-on-chip. In an Internet of Things (IoT) device, firmware connects sensors, actuators, communication interfaces, memory, power-management circuits, and cloud-facing protocols. It must therefore be efficient, predictable, maintainable, and capable of interacting directly with hardware registers. C remains important in embedded systems because it provides structured programming features while allowing precise control over memory and processor instructions. Unlike application software running on a desktop operating system, embedded firmware often executes without an operating system, has limited memory, must meet timing requirements, and interacts with hardware through memory-mapped registers. This chapter introduces the principal language and development concepts required to build reliable embedded firmware for IoT devices. Learning ObjectivesAfter completing this chapter, the learner should be able to: - select suitable C data types for embedded applications;
- use operators, expressions, functions, arrays, structures, and pointers safely;
- manipulate individual bits and access hardware registers;
- explain the roles of preprocessors, compilers, assemblers, linkers, and libraries;
- organize firmware projects using an IDE and a suitable toolchain;
- apply coding standards, version control, and basic testing practices;
- design a small sensor firmware module using professional techniques.
1. Embedded C in the IoT ContextA typical firmware system has four layers: - Hardware abstraction: definitions for registers, pins, timers, and peripheral drivers.
- Device services: sensor acquisition, filtering, communication, storage, and power management.
- Application logic: decisions such as detecting temperature thresholds or controlling an actuator.
- Communication and management: network protocols, diagnostics, configuration, and firmware updates.
C supports this architecture through functions, modules, header files, structures, and controlled access to memory. However, C does not automatically prevent errors such as invalid pointers, buffer overflows, integer overflow, or race conditions. Embedded development therefore requires both language knowledge and disciplined engineering practice. A firmware program usually contains an initialization phase followed by an infinite main loop: int main(void)
{
system_init();
sensor_init();
communication_init();
while (1) {
sensor_task();
communication_task();
watchdog_refresh();
}
}The loop is appropriate for a simple bare-metal system. More complex products may use a real-time operating system (RTOS), where independent tasks, queues, timers, and interrupt services are scheduled by the kernel. 2. Data Types and RepresentationA data type determines the possible values of an object, its storage requirements, and the operations permitted on it. Exact sizes can vary between processors and compilers, so portable embedded code should use fixed-width types from <stdint.h> when the width matters. | Type | Typical purpose |
|---|
uint8_t | 8-bit unsigned values, bytes, register fields | int8_t | Signed 8-bit values | uint16_t | ADC results, counters, protocol fields | uint32_t | Timers, addresses represented as integers, larger counters | int32_t | Signed measurements and calculations | size_t | Array and object sizes returned by library operations | bool | Logical true/false values, using <stdbool.h> | float | Floating-point measurements where supported | enum | Named set of related states or modes |
The types char, short, int, and long are implementation-dependent in size. They remain useful, but fixed-width types are preferable for communication packets, register maps, and non-portable hardware interfaces. Unsigned integer arithmetic is performed modulo 2ⁿ for an n-bit type. For example, an 8-bit unsigned value incremented from 255 becomes 0. This wraparound is defined for unsigned types but can cause logic errors if not intended. Signed overflow, in contrast, is not safely usable in portable C and should be prevented. The const qualifier indicates that a program must not modify an object through a particular reference. The volatile qualifier tells the compiler that a value may change outside the normal flow of the program, for example because of an interrupt or hardware peripheral. A hardware register is commonly declared both volatile and, where appropriate, const. volatile uint32_t * const status_register = (uint32_t *)0x40001000u; This declaration means that the pointer itself cannot be redirected, while the pointed-to value may change unexpectedly. Actual register addresses must come from the microcontroller reference manual; the example address is illustrative. 3. Operators and ExpressionsC provides arithmetic, relational, logical, assignment, increment/decrement, conditional, and bitwise operators. | Category | Operators | Embedded use |
|---|
| Arithmetic | + - * / % | Scaling, counters, time calculations | | Relational | < <= > >= == != | Threshold and state comparisons | | Logical | `&& | | | Bitwise | `& | ^ ~ << >>` | | Assignment | = += -= *= /= | Updating variables | | Conditional | condition ? a : b | Compact value selection |
The operators && and || use short-circuit evaluation. In a && b, expression b is evaluated only if a is true. This can be useful when checking a pointer before dereferencing it: if ((buffer != NULL) && (length > 0u)) {
process(buffer, length);
}Operator precedence can make expressions difficult to read. Parentheses should be used whenever the intended order is not immediately obvious. Integer division discards the fractional part, so 5 / 2 produces 2 when both operands are integers. Conversions should be deliberate, particularly when mixing signed and unsigned values. 4. Functions and Modular FirmwareFunctions divide a firmware system into testable units. A function should have one clear purpose, a meaningful name, documented inputs and outputs, and a defined error behaviour. bool adc_read(uint16_t *result)
{
if (result == NULL) {
return false;
}
*result = ADC_DATA_REGISTER;
return true;
}The declaration of a function is normally placed in a header file, while its implementation is placed in a source file. /* sensor.h */
#ifndef SENSOR_H
#define SENSOR_H
#include <stdint.h>
#include <stdbool.h>
bool sensor_read_temperature(int16_t *temperature_centi_degree);
#endif The include guard prevents repeated inclusion. Functions should avoid hidden global state unless that state is justified. If a function modifies a global variable or hardware register, the interface and side effects should be documented. A static function has file scope and cannot be called from another translation unit. This supports information hiding. A static local variable retains its value between calls, but it should be used carefully because it introduces persistent state. 5. Pointers and Memory SafetyA pointer stores the address of another object. The address-of operator & obtains an address, and the dereference operator * accesses the object at that address. uint16_t sample = 0u;
uint16_t *p_sample = &sample;
*p_sample = 1234u; Pointers are essential for modifying caller data, passing arrays efficiently, accessing memory-mapped registers, and implementing driver interfaces. They also create risks: - dereferencing
NULL or an uninitialized pointer; - accessing memory after an object has ceased to exist;
- writing beyond an array boundary;
- using an incorrectly aligned address;
- confusing a pointer with the value it points to.
Every pointer should be initialized before use. If no valid object exists, it should be assigned NULL. Functions receiving pointers should validate them when failure is possible. Buffer length should accompany a buffer pointer, because a pointer alone does not contain the array size. void copy_bytes(uint8_t *destination,
size_t destination_size,
const uint8_t *source,
size_t count)
{
if ((destination == NULL) || (source == NULL) ||
(count > destination_size)) {
return;
}
for (size_t i = 0u; i < count; ++i) {
destination[i] = source[i];
}
}The const qualifier on source documents that the function will not modify the source data. 6. Arrays, Strings, and StructuresAn array stores elements of the same type in contiguous memory. Its valid indices range from 0 to one less than its number of elements. uint16_t samples[8];
for (size_t i = 0u; i < 8u; ++i) {
samples[i] = 0u;
}C strings are arrays ending with a null character \0. A buffer intended for ten visible characters must have space for at least eleven bytes if it is to store a null-terminated string. In constrained firmware, binary messages are often preferable to formatted strings because they use less memory and execute more predictably. A structure groups related fields, possibly of different types: typedef struct {
int16_t temperature_centi_degree;
uint16_t humidity_centi_percent;
uint32_t timestamp_ms;
} sensor_record_t;Structures improve readability and represent sensor records, configuration data, protocol packets, and driver state. Padding may be inserted between members to satisfy alignment requirements. Therefore, a structure should not automatically be transmitted as a network packet without specifying its layout and endianness. Serialization should explicitly place fields into a byte buffer. 7. Bit Manipulation and Register AccessMicrocontroller peripherals are controlled through registers. A register may contain several independent fields. Bitwise operations allow firmware to set, clear, toggle, and test those fields. For a mask m: - set bits:
register_value |= m; - clear bits:
register_value &= ~m; - toggle bits:
register_value ^= m; - test bits:
(register_value & m) != 0u
#define GPIO_OUTPUT_ENABLE (1u << 3)
#define GPIO_OUTPUT_VALUE (1u << 3)
GPIO_DIR |= GPIO_OUTPUT_ENABLE;
GPIO_OUT |= GPIO_OUTPUT_VALUE;
GPIO_OUT &= ~GPIO_OUTPUT_VALUE; The expression 1u << 3 creates a mask with bit 3 set, assuming the type is wide enough for the shift. Shifting by a negative amount or by an amount greater than or equal to the width of the promoted operand is invalid and must be avoided. For a multi-bit field, clear the field before inserting a new value: #define MODE_MASK (0x03u << 4)
#define MODE_SHIFT 4u
uint32_t value = CONTROL_REGISTER;
value &= ~MODE_MASK;
value |= ((uint32_t)new_mode << MODE_SHIFT) & MODE_MASK;
CONTROL_REGISTER = value; This read-modify-write method can be unsafe if an interrupt or another hardware agent changes the same register between the read and write. The device reference manual may provide atomic set/clear registers, or the critical section may need protection. Register definitions should be supplied by the vendor's device header where possible. Direct addresses should be used only with authoritative documentation. Incorrect addresses, widths, reset values, or access permissions can damage functionality and may create security vulnerabilities. 8. Preprocessor Directives and Conditional CompilationThe preprocessor handles directives before compilation. Common directives include #include, #define, #if, #ifdef, and #error. #define ARRAY_LENGTH(x) (sizeof(x) / sizeof((x)[0]))
#if defined(BOARD_REVISION_B)
#define SENSOR_I2C_ADDRESS 0x48u
#else
#define SENSOR_I2C_ADDRESS 0x49u
#endif Macro arguments should be parenthesized to reduce precedence errors. Macros have no type checking and may evaluate an argument more than once. For typed constants and operations, const, enum, and inline functions are often safer. Conditional compilation is useful for board variants, debug instrumentation, and optional drivers. It should not create many incompatible versions that are impossible to test. Build configuration should be documented and reproducible. 9. The Embedded ToolchainThe toolchain transforms source code into an executable image: - Preprocessor expands headers and macros.
- Compiler translates C into assembly or an intermediate representation and performs diagnostics and optimization.
- Assembler converts assembly into object code.
- Linker combines object files and libraries, resolves symbols, and places code and data into memory.
- Object-copy or image tools may produce formats such as HEX or binary.
- Programmer/debugger transfers the image to target memory and supports breakpoints, watchpoints, and register inspection.
A linker script defines memory regions such as flash and RAM and determines where sections are placed. Common sections include: .text: executable code;.rodata: read-only constants;.data: initialized writable variables copied to RAM at startup;.bss: zero-initialized variables;- stack and heap regions, if used.
A firmware image may link successfully yet fail at run time because of stack exhaustion, incorrect clock configuration, incompatible startup code, or an invalid peripheral setting. Compilation is therefore only one stage of verification. An IDE generally combines an editor, project manager, compiler configuration, debugger, serial monitor, and version-control integration. Command-line builds are valuable because they support automation and reproducibility. A professional project should record compiler versions, optimization settings, device definitions, linker scripts, and required libraries. 10. Libraries, Drivers, and Hardware AbstractionA library provides reusable functions. A hardware abstraction layer (HAL) hides device-specific register details behind a stable interface. A driver implements a peripheral or external component, such as an I²C temperature sensor. Abstraction improves portability and testing, but excessive abstraction may hide timing, memory, or register behaviour. The appropriate interface should expose important constraints. For example, a non-blocking driver should indicate whether an operation is complete rather than silently waiting for an indefinite period. Third-party libraries require evaluation of licensing, maintenance, code quality, vulnerability history, memory usage, and hardware compatibility. A small IoT device should not include a large library merely because it is convenient. 11. Interrupts, Timing, and ConcurrencyAn interrupt allows hardware to request immediate service. An interrupt service routine (ISR) should be short, deterministic, and free from blocking operations. A common pattern is for the ISR to record an event while the main loop performs the longer processing. volatile bool sample_ready = false;
void TIMER_ISR(void)
{
sample_ready = true;
}
int main(void)
{
system_init();
while (1) {
if (sample_ready) {
sample_ready = false;
acquire_and_process_sample();
}
}
}The volatile qualifier prevents the compiler from assuming that sample_ready is unchanged. It does not make a multi-byte operation atomic and does not replace synchronization. If a variable can be modified concurrently and its access is not naturally atomic, interrupts may need to be disabled briefly or an atomic mechanism used. Timing requirements should be expressed clearly. If a task must execute every 100 ms, its scheduling mechanism should account for timer rollover and execution time. Blocking delays are simple but reduce responsiveness and waste energy. Event-driven state machines and hardware timers are usually preferable in battery-powered IoT devices. 12. Coding Standards, Testing, and Version ControlEmbedded coding standards promote consistency and reduce defects. A practical standard should specify naming, indentation, comment style, error handling, integer conversions, pointer use, interrupt rules, and permitted library functions. Standards such as MISRA C provide extensive guidance, although project teams must define which rules are mandatory and how deviations are documented. Important practices include: - compile with warnings enabled and treat significant warnings as defects;
- initialize variables deliberately;
- keep functions small and interfaces explicit;
- avoid unexplained numeric constants by using named definitions;
- check array bounds and communication lengths;
- avoid dynamic memory allocation in time-critical or long-running firmware unless carefully justified;
- separate hardware-dependent code from application logic;
- use static analysis and code review;
- test drivers with hardware-in-the-loop and application logic with host-based unit tests.
Version-control systems such as Git provide history, branching, review, tagging, and recovery. Commits should represent coherent changes and include meaningful messages. A release should identify source revision, compiler version, configuration, binary checksum, and test results. Continuous integration can automatically build multiple board configurations and run static-analysis checks. 13. Case Study: Battery-Powered Environmental NodeBackgroundThis illustrative case concerns a battery-powered node that measures temperature and humidity every minute and transmits a record over a low-power radio link. ProblemAn initial prototype used floating-point calculations, blocking delays, global variables, and direct string formatting. It consumed excessive energy, occasionally transmitted corrupted records, and was difficult to test. AnalysisThe team identified four causes: sensor access was not protected by a clear driver interface; a shared buffer could be modified while transmission was active; the device remained awake during delays; and the packet layout depended on compiler-specific structure padding. Approach and SolutionThe firmware was reorganized into modules: sensor.c, radio.c, power.c, packet.c, and application.c. The sensor driver returned scaled integers, such as temperature in hundredths of a degree, avoiding floating-point operations. A state machine represented SLEEP, MEASURE, ENCODE, and TRANSMIT states. Packet encoding explicitly stored each field in a defined byte order: void encode_u16_le(uint8_t *buffer, uint16_t value)
{
buffer[0] = (uint8_t)(value & 0xffu);
buffer[1] = (uint8_t)((value >> 8) & 0xffu);
}The timer interrupt only set an event flag. The main loop performed sensor acquisition and transmission. Before sleep, the firmware disabled unused peripherals and configured a wake-up timer. Each operation returned a status code, and communication timeouts caused a controlled retry rather than an indefinite wait. OutcomeThe design reduced active time, removed packet-layout ambiguity, and made the sensor driver testable with simulated data. Fault logs could identify sensor, radio, timeout, and checksum errors separately. The resulting architecture was also easier to adapt to a second board revision. Learning PointsThe case demonstrates that embedded quality depends on architecture as much as syntax. Explicit data representation, bounded operations, event-driven timing, modular interfaces, and recorded build information are essential professional practices. 14. Challenges, Limitations, and Security ConsiderationsEmbedded C gives control but provides limited automatic safety. Memory corruption can alter control flow, disable safety functions, or expose credentials. IoT firmware should validate all external input, enforce packet-length limits, protect keys, and avoid printing sensitive data through debug interfaces. Secure boot can verify that only authorized firmware executes. Signed over-the-air updates can provide authenticity and integrity, but they require secure key storage, rollback protection, update recovery, and careful handling of interrupted transfers. A device should fail safely if an update is invalid or incomplete. Other limitations include hardware-specific code, compiler differences, limited debugging visibility, resource constraints, electromagnetic interference, and difficult field servicing. Environmental and regulatory requirements may impose restrictions on radio operation, safety, data protection, and product traceability. Developers must follow the applicable device, radio, cybersecurity, and privacy requirements for the deployment region. Artificial intelligence can assist with code search, documentation, test generation, and anomaly detection, but generated firmware must be reviewed, compiled with strict diagnostics, tested on real hardware, and checked for licensing and security problems. AI output is not evidence of correctness. Best-Practice ChecklistBefore releasing embedded firmware, confirm that: - integer widths and signedness are intentional;
- all hardware registers match the reference manual;
- pointers, buffers, and lengths are validated;
- interrupts and shared data have defined synchronization;
- compiler warnings and static analysis have been reviewed;
- memory-map and stack usage have been checked;
- error and timeout paths have been tested;
- the build is reproducible from version-controlled sources;
- firmware authenticity and update recovery are addressed;
- release binaries can be traced to source and configuration.
Chapter SummaryEmbedded C combines structured programming with direct control of microcontroller resources. Reliable firmware depends on deliberate data-type selection, safe expressions, modular functions, bounded arrays, validated pointers, and explicit structures. Bitwise operations and memory-mapped registers permit control of GPIO, timers, converters, and communication peripherals, but register access must follow the device reference manual and concurrency rules. The preprocessor, compiler, assembler, linker, libraries, IDE, debugger, and programmer form a complete toolchain. Linker scripts determine how code and data occupy flash and RAM. Professional development adds coding standards, static analysis, unit and hardware testing, Git-based version control, reproducible builds, and secure update practices. The environmental-node case shows how these principles produce firmware that is more efficient, testable, portable, and maintainable. Key TermsData type: A specification of the values and operations associated with an object. Fixed-width integer: An integer type with a specified bit width, such as uint16_t. Volatile: A qualifier for an object that may change outside ordinary program flow. Const: A qualifier that prevents modification through a particular access path. Pointer: An object containing the address of another object or function. Array: A contiguous sequence of elements of the same type. Structure: A user-defined type grouping related fields. Bit mask: A value used to select or modify particular bits. Register: A hardware-controlled storage location used to configure or observe a peripheral. Preprocessor: The stage that expands directives and macros before compilation. Compiler: A program that translates source code into lower-level code. Linker: A tool that combines object files and assigns symbols and memory locations. Linker script: A description of target memory regions and section placement. Interrupt service routine: A function executed in response to a hardware interrupt. HAL: A hardware abstraction layer that hides device-specific implementation details. ISR: Interrupt service routine. Static analysis: Automated examination of source code for defects and rule violations. Firmware image: The binary representation programmed into a device. Secure boot: A mechanism that verifies firmware authenticity before execution. Review QuestionsShort Answer Questions- Why are fixed-width integer types useful in embedded firmware?
- What is the purpose of the
volatile qualifier? - State two common operations performed with bit masks.
- What are the principal responsibilities of a linker?
- Why should an interrupt service routine usually be short?
Long Answer Questions- Explain how pointers, arrays, and buffer lengths should be used together to improve memory safety.
- Describe the stages through which C source code becomes a programmed firmware image.
- Explain read-modify-write register operations and discuss when they may be unsafe.
- Compare blocking delays with event-driven state-machine design in a battery-powered IoT node.
- Discuss how coding standards, static analysis, testing, and version control contribute to firmware quality.
Analytical / Application Questions- Design a register interface for a peripheral with a two-bit mode field at bits 4 and 5. Show how firmware would clear the field and insert a new mode value.
- A sensor driver receives a pointer to a data buffer and a requested length. Identify the checks required before writing data and explain the consequences of omitting each check.
- Propose a modular firmware architecture for an IoT node that measures a sensor, sleeps, wakes periodically, and transmits a packet. Justify the responsibilities of each module.
Multiple Choice Questions with AnswersQuestion 1Which qualifier is most appropriate for a hardware register that may change independently of normal program execution? A. volatile B. static C. register D. typedef Correct Answer: A. volatile Question 2What operation clears all bits selected by mask m in register r? A. r |= m B. r &= ~m C. r ^= m D. r = m << 1 Correct Answer: B. r &= ~m Question 3Which tool resolves symbols and places program sections into target memory regions? A. Preprocessor B. Compiler C. Linker D. Debugger Correct Answer: C. Linker Question 4What is the principal risk of treating a pointer as if it were the object it references? A. The program may access an invalid address or fail to modify the intended object B. The compiler automatically changes the pointer into an array C. The linker removes the pointer from the image D. The processor converts the pointer to a floating-point value Correct Answer: A. The program may access an invalid address or fail to modify the intended object Question 5Why should structure objects not automatically be transmitted as protocol packets? A. Structures cannot contain integer fields B. Compiler padding and byte order may differ from the protocol layout C. Structures are always stored in flash D. The preprocessor deletes structure members Correct Answer: B. Compiler padding and byte order may differ from the protocol layout Question 6What is a suitable primary responsibility for a timer interrupt service routine? A. Perform lengthy formatted printing B. Wait indefinitely for a radio response C. Set an event indication for processing outside the ISR D. Allocate unlimited dynamic memory Correct Answer: C. Set an event indication for processing outside the ISR Question 7If an 8-bit unsigned counter has value 255 and is incremented by one, what value results under standard unsigned arithmetic? A. 254 B. 255 C. 256 D. 0 Correct Answer: D. 0 Question 8Which practice most directly improves reproducibility of a firmware release? A. Changing compiler options manually for each build B. Recording source revision, toolchain, configuration, and binary identity C. Removing linker scripts from version control D. Disabling all compiler diagnostics Correct Answer: B. Recording source revision, toolchain, configuration, and binary identity Question 9What is the main purpose of a hardware abstraction layer? A. To hide all application logic from the compiler B. To replace the microcontroller clock C. To provide an interface that reduces dependence on device-specific details D. To guarantee that firmware contains no defects Correct Answer: C. To provide an interface that reduces dependence on device-specific details Question 10Which approach is generally safer for inserting a multi-bit field into a register? A. Set the new bits without clearing the old field B. Clear the field using its mask, shift the value, mask it, and then combine it C. Invert the entire register before every write D. Convert the register to a string first Correct Answer: B. Clear the field using its mask, shift the value, mask it, and then combine it
|