Blog / IoT Development
IoT Development firmware developmentembedded CMCU programming

Firmware Development Basics: Everything You Need to Know Before Writing Your First Line

A practical introduction to firmware development for IoT — covering C for MCUs, memory management, RTOS basics, peripheral drivers, and the mindset shift from application software.

UABit Team
· · 10 min read
Firmware Development Basics: Everything You Need to Know Before Writing Your First Line

Firmware development is unlike any other form of software engineering. There’s no operating system to catch your memory bugs, no garbage collector to clean up after you, no stack overflow exception to politely inform you of the problem before it corrupts your entire application state. Firmware runs directly on hardware, communicates directly with physical peripherals, and must operate reliably under conditions that would crash any general-purpose application. If you’re coming from web or mobile development, this guide will prepare you for the mindset shift. If you’re brand new to software entirely, it will give you the fundamental concepts you need before writing your first line of IoT firmware.

What Is Firmware, Really?

Firmware is software that runs directly on a microcontroller (MCU) or other embedded processor, typically stored in non-volatile flash memory and executing without a full operating system. The term distinguishes it from both hardware (the physical chips and circuits) and “software” in the traditional sense (applications running on an OS).

Firmware has direct access to hardware peripherals — GPIO pins, UART interfaces, SPI buses, timers, ADCs — through memory-mapped registers. Writing 0x01 to a specific memory address might turn on a GPIO pin. Reading from another address might return the latest ADC conversion result. This intimacy with hardware is what makes firmware both powerful and demanding.

Firmware responsibilities in a typical IoT device:

  • Hardware initialization (clocks, peripherals, communication interfaces)
  • Sensor reading and calibration
  • Local data processing and filtering
  • Wireless protocol stack management (BLE, Wi-Fi, LoRa)
  • Cloud connectivity (MQTT, TLS)
  • OTA update management
  • Power management (entering and exiting sleep modes)
  • Error detection and recovery (watchdog, assertion handling)
  • Configuration management (reading/writing persistent settings)

C is the Language of Embedded: Here’s Why

While C++ and Rust are gaining adoption in embedded systems, C remains the dominant firmware language for IoT devices. Understanding why helps you understand how to use it well.

C’s dominance comes from:

  • Explicit memory management — no hidden allocations or garbage collection
  • Predictable code generation — C code maps closely to the machine instructions the MCU executes
  • Universal MCU support — every MCU toolchain supports C; C++ and Rust support is less universal
  • Minimal runtime overhead — C has almost no runtime library requirements

Key C concepts that are especially important in firmware:

Pointers and memory:

uint8_t buffer[256];          // Array on the stack
uint8_t *ptr = buffer;        // Pointer to the buffer's first byte
ptr[5] = 0xAA;               // Write to buffer[5]
uint32_t *reg = (uint32_t*)0x40020010; // Point to a hardware register
*reg = 0x00000001;           // Write to the hardware register

Direct hardware register manipulation through pointers is fundamental to firmware. Understanding pointer arithmetic, pointer-to-array conversions, and function pointers is non-negotiable for embedded C.

Volatile qualifier:

volatile uint32_t * const GPIOA_IDR = (uint32_t*)0x40020010;

The volatile keyword tells the compiler not to optimize away reads or writes to a variable, because the value may change outside of the program’s normal flow — either by hardware or by interrupt service routines. Forgetting volatile on hardware registers is a classic embedded bug that causes mysteriously unreproducible problems.

Bit manipulation: Firmware constantly reads and writes individual bits in hardware registers. C’s bitwise operators (&, |, ^, ~, <<, >>) are used constantly:

// Set bit 5 of register
REG |= (1U << 5);

// Clear bit 5
REG &= ~(1U << 5);

// Toggle bit 5
REG ^= (1U << 5);

// Check if bit 5 is set
if (REG & (1U << 5)) { /* bit is set */ }

Fixed-width integer types: Always use uint8_t, int16_t, uint32_t etc. from <stdint.h> rather than int, char, or long — the latter have implementation-defined widths that vary across compilers and architectures.

Memory Organization in Embedded Systems

Embedded memory organization is fundamentally different from application software, and misunderstanding it is the source of many firmware bugs.

Flash memory: Non-volatile. Stores the compiled firmware binary (code and read-only data). On power-up, the MCU begins executing from a known address in flash. Flash can be written (erased in blocks, then written) but not as quickly or frequently as RAM.

RAM (SRAM): Volatile (lost on power loss). Stores variables, the stack, and heap (if used). RAM is limited — often 16–256 KB on typical IoT MCUs. Every variable, every function call (stack frame), every buffer must fit within this space.

Stack: The stack stores local variables and function call frames. The stack pointer starts at the top of RAM and grows downward on ARM Cortex-M. Stack overflow — when the stack grows past the allocated stack space — is a serious bug that corrupts other data. Many RTOSes support stack watermark monitoring to detect near-overflow situations.

Heap: Dynamic memory allocation using malloc() and free(). In embedded systems, the heap is used cautiously or avoided entirely. Heap fragmentation — where available memory exists but not in contiguous blocks — can cause malloc() to fail unexpectedly. Many embedded projects use static memory allocation exclusively, allocating all buffers at compile time.

Linker script: The linker script defines where different sections of the program are placed in memory — code in flash at specific addresses, initialized data in flash to be copied to RAM, zero-initialized BSS data, stack size, heap size. Understanding the linker script demystifies startup behavior and helps diagnose memory issues.

Embedded memory map showing flash, RAM, stack, and heap organization for an ARM Cortex-M device

Interrupts: The Fundamental Real-Time Mechanism

Interrupts are the primary mechanism for handling asynchronous hardware events in embedded systems. When a hardware event occurs (a byte arrives on UART, an ADC conversion completes, a timer expires, a GPIO pin changes state), the MCU suspends its current execution, saves its state, and jumps to an interrupt service routine (ISR) that handles the event.

ISR best practices:

  • Keep ISRs short: ISRs run at elevated priority and block lower-priority interrupts. Long ISRs increase system latency.
  • No blocking operations in ISRs: Never call delay(), printf(), or any function that might block in an ISR.
  • Share data carefully: Variables shared between ISRs and main code must be declared volatile and protected against partial updates (use atomic operations or disable interrupts briefly for multi-byte updates).
  • Post to RTOS from ISR: With an RTOS, ISRs should do minimal work and post a notification or message to a task that handles the event in normal task context.
// ISR example: UART receive interrupt
void USART1_IRQHandler(void) {
    if (USART1->ISR & USART_ISR_RXNE) {
        uint8_t received_byte = USART1->RDR;
        // Minimal work in ISR: just put it in a buffer
        ring_buffer_put(&uart_rx_buffer, received_byte);
    }
}

Hardware Abstraction: Writing Portable Firmware

Hardware Abstraction Layers (HAL) and Board Support Packages (BSP) are software layers that wrap direct hardware register manipulation in functions with meaningful names. Instead of:

// Direct register access (ST LL library)
LL_GPIO_SetOutputPin(GPIOA, LL_GPIO_PIN_5);

You write:

// HAL function
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET);

And ideally a board-specific abstraction:

// BSP function
led_set(LED_STATUS, LED_ON);

Each layer of abstraction reduces portability concerns and makes code more readable at the cost of some overhead (usually negligible on modern MCUs). Writing firmware in terms of BSP functions allows porting to different hardware by updating only the BSP layer.

Vendor-provided HAL libraries:

  • STM32 HAL and LL libraries — comprehensive hardware abstraction for STM32
  • ESP-IDF driver APIs — peripheral drivers for ESP32 family
  • nRF5 SDK / nRF Connect SDK drivers — comprehensive peripheral abstractions for Nordic devices

RTOS Concepts Every Firmware Developer Should Know

Even if you don’t use an RTOS immediately, understanding these concepts shapes how you structure firmware:

Tasks (threads): Independent units of execution with their own stack. In FreeRTOS: xTaskCreate(task_function, "Name", stack_size, params, priority, &handle).

Task states: Ready (able to run), Running (currently executing), Blocked (waiting for event, timer, or resource), Suspended (deliberately halted).

Queues: Thread-safe FIFO buffers for passing data between tasks without race conditions. Use queues instead of global variables for inter-task communication.

Semaphores and mutexes: Synchronization primitives. Use a binary semaphore to signal an event. Use a mutex to protect a shared resource (hardware peripheral, shared buffer) from concurrent access by multiple tasks.

Task priorities: Assign priorities based on time-criticality. Safety/control tasks get highest priority. Logging and OTA get lowest priority. Be aware of priority inversion — use mutex with priority inheritance when sharing resources across priority levels.

For a practical FreeRTOS guide, see our article on Getting Started with FreeRTOS for IoT.

The Firmware Development Workflow

Toolchain setup:

  • Compiler: ARM GCC (arm-none-eabi-gcc) or vendor-specific (LLVM/Clang for some platforms)
  • IDE: VS Code with Cortex-Debug extension, STM32CubeIDE, SEGGER Embedded Studio, or Eclipse-based IDEs
  • Debugger interface: JTAG/SWD via ST-Link, J-Link, or Black Magic Probe
  • Build system: CMake (Zephyr, ESP-IDF), Make, or IDE-based

Debug techniques:

  • Printf debugging via UART: The simplest approach; serial terminal shows debug output
  • Semihosting: Printf output routed through the debug interface without a UART
  • SEGGER RTT: High-speed printf-like output over SWD with minimal timing impact
  • Hardware debugger + breakpoints: Step through code, inspect registers and memory
  • Logic analyzer: Capture hardware bus traffic (I2C, SPI, UART) to verify peripheral communication

Testing approach:

  • Unit tests for pure logic functions running on the host PC (no hardware needed)
  • Integration tests on real hardware, verifying peripherals work as expected
  • System tests verifying end-to-end behavior
  • See our IoT testing strategies guide for the full testing approach

Essential Firmware Safety Practices

Watchdog timer: A hardware timer that resets the MCU if firmware fails to “kick” it within a defined interval. Every IoT device should have the watchdog enabled in production. It’s the last line of defense against firmware hangs.

Assertion checks: Use assertions to catch impossible conditions early in development. In production, assertion failures should log a diagnostic and trigger a controlled restart rather than hanging silently.

Error propagation: All functions that can fail should return an error code. Callers must check return values. Ignoring error returns is one of the most common firmware reliability issues.

Stack overflow detection: Enable stack overflow checking in your RTOS (FreeRTOS has configCHECK_FOR_STACK_OVERFLOW). Monitor task stack watermarks during development to ensure adequate stack sizes.

At UABit, firmware safety and reliability practices are built into our development methodology. See our firmware and embedded development services to learn how we approach firmware quality.

Conclusion

Firmware development is a discipline that rewards patience, precision, and systematic thinking. The lack of safety nets means that bugs that would be caught early in application development can hide in firmware until they cause mysterious field failures. Understanding C’s memory model, hardware interrupts, the RTOS concurrency model, and the importance of defensive programming practices gives you the foundation to write firmware that actually works reliably in production.

The learning curve is real but surmountable. Start with good tools (a hardware debugger, a serial terminal), study your MCU’s reference manual, and build incrementally — one peripheral working before adding the next.

Further reading:

IoT & AIoT Weekly

Get the best IoT development content delivered weekly. No noise, just signal.

firmware developmentembedded CMCU programmingIoT firmwareRTOS basics