Marcio Cunha

How to automate testing and flashing in embedded firmware

Learn how to build CI/CD pipelines for embedded systems using GitHub Actions and local runners for automated testing and board flashing.

Marcio Cunha•6 min
Also available in:PortuguêsEspañol
Summary
  • Firmware automation eliminates manual bench errors and accelerates the hardware development lifecycle.
  • Local runners connected via USB enable direct interaction with physical development boards in the CI/CD process.
  • Automated unit tests ensure low-level logic works correctly before deployment to the microcontroller.
  • Automated flashing transfers the compiled binary to the chip without direct human intervention.
  • Serial port monitoring validates the real behavior of the device right after flashing.

Why automate embedded firmware development?

Developing software for physical devices is often a slow process full of manual steps at the workbench. Traditionally, the engineer writes code, connects the microcontroller via USB, compiles the project, and presses a physical button to flash the program onto the board. If something fails, the cycle repeats exhaustively. In practice, this means wasting precious hours on repetitive tasks that could be executed automatically.

When scaling production or working in a team, this manual approach becomes unsustainable and introduces human errors that are hard to trace. This is where CI/CD (Continuous Integration and Continuous Delivery) pipelines come in, acting as automated workflows capable of building, testing, and deploying code to hardware as soon as a new commit is pushed. Whether you are building small sensors or industrial systems, removing workbench friction completely transforms delivery speed.

Many developers think CI/CD is only for web applications and cloud servers, but modern hardware engineering demands the same rigor. For those looking to explore more about optimizing complex workflows, it is worth checking out our article on how to identify performance bottlenecks in computing systems. The major difference in the embedded world is that software must interact with the real world, requiring local runners physically connected to development boards.

The crucial role of local runners on the test bench

Traditional cloud tools, such as standard GitHub Actions servers, run in distant datacenters that lack USB ports, chip programmers, or connected oscilloscopes. To solve this physical limitation, we use a local runner, which is a dedicated computer (such as a Raspberry Pi or an old mini PC) installed right at the developer's workbench and registered with GitHub. In practice, it acts as a bridge between the cloud and the real hardware.

When you push code to the repository, GitHub triggers the local runner in your workshop or lab. This local computer receives the command, pulls the updated code, executes cross-compilation (generating the specific binary for your microcontroller), and performs steps dependent on physical contact with the board. This hybrid architecture guarantees the best of both worlds: cloud organization and history combined with local physical manipulation.

Furthermore, to ensure your system is resilient against unexpected power failures or software crashes, automation can be combined with advanced strategies, as discussed in the guide on how to design resilient IoT firmware with watchdog and dual-boot. Having the runner on the bench makes it possible to test these recovery features in a 100% automated way with every change in the source code.

How to structure unit tests for low-level code

Before flashing any program onto a physical board, validating software logic through unit tests is essential. Unit tests consist of small code blocks verifying whether isolated functions of your program return expected results. In embedded development, we usually use lightweight frameworks like Unity or Google Test executed directly on the runner computer, simulating peripheral behaviors.

The major challenge of testing embedded code is that many functions depend directly on microcontroller registers and manufacturer-specific libraries. To bypass this, we use dependency injection and mocking techniques, creating hardware simulations in pure software. In practice, this allows you to run hundreds of logical tests in mere seconds without needing to physically reboot any development board.

Running automated tests before flashing prevents wasting write cycles on the microcontroller's flash memory with buggy code. Additionally, if your application deals with heavy processing or complex algorithms, ensuring the mathematical integrity of the logic avoids catastrophic crashes in the field, keeping the device stable and secure during long periods of continuous operation.

Configuring automated flashing via serial port

Automated flashing is the process of transferring the compiled binary directly into the microcontroller's flash memory without human intervention. To perform this magic in practice, the local runner uses manufacturer command-line tools like OpenOCD for ARM chips, esptool for ESP32, or avrdude for traditional Arduino boards. The pipeline script triggers these tools right after unit tests successfully complete.

For the process to run smoothly, the board must enter bootloader mode automatically. On many modern development boards, this is achieved by toggling the DTR and RTS control pins of the USB serial port via the flashing script itself. If your board requires a physical reset button, you can wire a GPIO-controlled transistor to simulate the click mechanically through the runner.

Below is an example of a YAML configuration file for GitHub Actions that executes the build, test, and flashing routine using a local runner configured on your bench:

name: Firmware CI/CD Pipeline

on:
  push:
    branches: [ main ]

jobs:
  build-and-flash:
    runs-on: [self-hosted, linux, workbench]
    steps:
      - name: Checkout source code
        uses: actions/checkout@v4

      - name: Run unit tests
        run: make test

      - name: Compile firmware
        run: make release

      - name: Flash firmware to board
        run: esptool.py --port /dev/ttyUSB0 write_flash 0x0000 build/firmware.bin

This simple file defines a complete workflow running strictly in the physical environment where the board is connected, ensuring the engineering cycle happens end-to-end without manual clicks.

Validating real behavior through serial logs

Flashing firmware to the board is only half the journey; the final, indispensable step of a robust CI/CD pipeline is verifying whether the device actually booted up and is working correctly. Since the microcontroller is physically connected to the local runner's serial port, we can program the pipeline to listen to UART text output right after flashing finishes.

At this stage, a simple Python script or command-line tool reads serial logs for a few seconds, searching for specific strings like 'System initialized successfully' or 'WiFi connected'. If the expected message appears, the pipeline is marked green (success). Otherwise, if the device enters an infinite loop or crashes, the pipeline fails immediately, alerting the team to the problem before code reaches production.

This post-flashing validation closes the hardware automation loop on a high note, combining traditional software engineering best practices with embedded systems rigor. Adopting this mindset drastically reduces time lost to manual debugging and ensures every release delivered to the real world is rock-solid and thoroughly tested.

Final thoughts

Building CI/CD pipelines for embedded firmware using GitHub Actions and local runners radically transforms the hardware development routine. By replacing manual clicks and repetitive flashing with automated test and flash workflows, we gain speed, predictability, and confidence in delivering physical products. Although initial setup requires patience to align serial ports and local dependencies, the return on investment in productivity and quality is immeasurable.

As more teams embrace modern engineering practices, the traditional separation between corporate software development and the embedded systems universe fades away. Connecting the digital world of the cloud with the physical world of the electronics bench is not just a trend, but a one-way street for those aiming to build robust and scalable products in today's market.