How to Set Up CI/CD for Embedded Projects

Every firmware engineer has a “that build” story. The one where a release candidate compiled fine on the lead developer’s machine, got flashed to 500 units, and bricked half of them because someone had a different toolchain version. Or the subtler version: a pull request merged three weeks ago silently broke the state machine for a rarely-tested mode, and nobody noticed until a customer filed a bug.
Here’s what’s maddening: web teams solved these problems a decade ago with CI/CD pipelines. But every CI/CD tutorial assumes you’re deploying a Node.js app or spinning up a Docker container that serves HTTP. None of them explain how to get arm-none-eabi-gcc into a GitHub Actions runner, or what “testing” even means when your binary targets a Cortex-M4 with no display and no network stack.
This guide bridges that gap. By the end, you’ll have a clear path to: (1) a GitHub Actions workflow that cross-compiles your firmware on every push, (2) automated unit tests that catch regression bugs before they hit hardware, and (3) an understanding of how emulated target testing fits into the picture. No hardware-in-the-loop rigs required. No DevOps jargon without translation.
We won’t cover HIL testing setups or OTA deployment workflows; those are separate, more advanced topics. Let’s start with what delivers value this week.
The CI/CD Loop, Translated for Firmware Engineers
Strip away the web-dev framing and CI/CD is a five-stage loop: Trigger → Environment → Build → Test → Report. Every stage maps directly to embedded work, but with different constraints than a web tutorial would assume.
Trigger: A Git push to main, a pull request opened, or a nightly schedule. Identical to any CI system. No embedded-specific complexity here.
Environment: This is where embedded CI diverges. A web project installs dependencies with npm install. You need an ARM GCC toolchain, or the Zephyr SDK, or possibly a licensed compiler like IAR, inside a container or VM that the CI runner can spin up. This single step is what makes every generic tutorial fall apart for embedded teams. The solution: Docker containers with pinned toolchain versions.
Build: Cross-compilation producing a .elf, .bin, or .hex. Not a runnable server binary. The CI system doesn’t need to “deploy” it. It just needs to prove it compiles and archive the artifact.
Test: Host-based unit tests compiled with native gcc, then optionally emulated target tests under QEMU or Renode. Not “open a browser and check the homepage.”
Report: A green or red check on your pull request. Optionally, binary size tracking so you can spot when someone’s change balloons your firmware by 12 KB.
The core principle: whether you use Keil, IAR, GCC, or Zephyr’s west, the pipeline shape is identical. The only variable is how you set up the environment. Reproducible build environments, specifically Docker containers with pinned toolchain versions, are the key to making embedded CI/CD reliable. When everyone’s build runs in the same container, “it works on my machine” stops being a valid excuse.
Step 1 — Automate Your Firmware Build
Prerequisite: Your build must be invocable from a single command. If your workflow is “open Keil, click Build, pray,” you need to extract that into a CLI-driven system first: make all, cmake --build build/, or west build. IDE-based builds can’t be triggered by a CI runner. This step alone is worth doing even if you never set up CI, because it forces you to document your build process as code rather than tribal knowledge.
Here’s a GitHub Actions workflow that cross-compiles firmware using ARM GCC in a Docker container:
# .github/workflows/build.yml
name: Firmware Build
on: [push, pull_request] # Trigger on every push and PR
jobs:
build:
runs-on: ubuntu-latest
container:
image: ghcr.io/your-org/firmware-ci:arm-gcc-13.2 # Pinned toolchain
steps:
- uses: actions/checkout@v4
- name: Build firmware
run: |
cmake -B build -DCMAKE_TOOLCHAIN_FILE=cmake/arm-gcc.cmake
cmake --build build --target all
- name: Report binary size
run: arm-none-eabi-size build/firmware.elf
- name: Archive artifacts
uses: actions/upload-artifact@v4
with:
name: firmware-bin
path: build/firmware.binThat’s it. Seventeen lines of meaningful YAML and your firmware compiles on every push with zero human intervention.
For Zephyr projects, the on-ramp is even smoother. The Zephyr project publishes an official Docker image (ghcr.io/zephyrproject-rtos/ci) with the full Zephyr SDK pre-installed. Replace the build step with west build -b your_board app/ and you’re running.
The first time a teammate opens a pull request and sees a green checkmark confirming the build passed, before any human reviewed the code, something clicks. You’ve just eliminated an entire class of integration failures, and it cost you 15 minutes of setup.
Quick win: That arm-none-eabi-size output can be parsed and posted as a PR comment using a small script or a third-party action. Every code review then includes visibility into flash and RAM impact. Teams consistently report that this alone changes how developers think about resource usage.
Step 2 — Add Host-Based Unit Tests for Firmware Testing
Automated builds catch compilation errors. But the bugs that really hurt, logic errors in your state machine, off-by-one errors in your protocol parser, edge cases in your CRC implementation, sail right through a successful build.
The highest-leverage next step: compile portions of your firmware with a native host compiler (gcc or clang on x86) and run unit tests on the CI runner. No target hardware needed.
What to test this way: state machines, data parsers, protocol encoders/decoders, ring buffer implementations, algorithm logic, configuration validators. These are pure logic. They take inputs and produce outputs with no hardware dependency.
What NOT to test this way: HAL calls, interrupt handlers, DMA configurations, GPIO toggling. These need hardware or emulation.
This approach only works if your codebase separates hardware abstraction from application logic. If your state machine directly calls HAL_GPIO_WritePin(), you can’t compile it on x86. The fix: define a clean interface boundary (a HAL abstraction layer) that you can stub during testing. This is good architecture regardless of CI/CD.
For a test framework, start with Unity if you’re writing C. It’s lightweight, widely used in embedded, and has zero dependencies. Zephyr projects can use the built-in Ztest framework, which integrates with west.
Here’s a minimal example, a function and its test:
// src/crc8.c
uint8_t crc8(const uint8_t *data, size_t len) {
uint8_t crc = 0x00;
for (size_t i = 0; i < len; i++) {
crc ^= data[i];
for (int j = 0; j < 8; j++)
crc = (crc & 0x80) ? (crc << 1) ^ 0x07 : (crc << 1);
}
return crc;
}
// test/test_crc8.c
void test_crc8_known_vector(void) {
uint8_t data[] = {0x31, 0x32, 0x33};
TEST_ASSERT_EQUAL_HEX8(0xF4, crc8(data, 3));
}Extend the GitHub Actions workflow with a parallel test job:
unit-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build and run tests
run: |
cd test
make all # Compiles with host gcc, links Unity, runs tests
./test_runnerThis job runs concurrently with the firmware build. It adds maybe 30 seconds to your pipeline and catches the majority of logic regressions. The cost-to-value ratio is absurd. This is the single highest-leverage investment in embedded firmware testing.
Step 3 — Emulated Target Testing Closes the Gap
Host-based unit tests validate logic. But they don’t exercise your actual firmware binary. They don’t catch linker script errors, memory layout issues, RTOS scheduling bugs, or startup code failures. Your firmware might pass every unit test and still crash on boot because a memory region overlaps.
Emulated target testing fills this gap. You compile the full firmware binary for an emulated board and run it under QEMU or Renode inside CI.
| QEMU | Renode | |
|---|---|---|
| Best for | Cortex-M/A CPU-level emulation | Full SoC emulation with peripherals |
| Zephyr integration | First-class (qemu_cortex_m3, etc.) | First-class (many supported boards) |
| Peripheral support | Basic (UART, timers) | Extensive (SPI, I2C, ADC, and more) |
| Setup complexity | Low | Medium |
For Zephyr projects, this is remarkably easy. Build targeting an emulated board and run:
emulated-test:
runs-on: ubuntu-latest
container:
image: ghcr.io/zephyrproject-rtos/ci:latest
steps:
- uses: actions/checkout@v4
- name: Build and run on QEMU
run: |
west build -b qemu_cortex_m3 app/ -- -DCONFIG_TEST=y
west build -t run # Launches QEMU, captures serial outputThe test firmware writes pass/fail results over the emulated UART (semihosting), and the CI job parses the output to determine success or failure.
Be honest about limitations: emulation fidelity varies. Timing-sensitive code behaves differently. Not all peripherals are modeled. Emulated testing complements, but does not replace, testing on real hardware. It does, however, catch an entire class of bugs (memory faults, stack overflows, RTOS misconfigurations) that host unit tests miss entirely, and it runs in seconds with no lab equipment.
Practical Gotchas That Will Save You Hours
Pin your toolchain versions. Using latest tags in your Docker images means your build can break on a Tuesday morning because the toolchain updated overnight. Use explicit version tags: arm-gcc-13.2, not arm-gcc-latest.
Cache aggressively. Zephyr module fetches (west update) and toolchain downloads are slow. Use GitHub Actions’ actions/cache to persist these across runs, or better yet, bake them into a pre-built Docker image. A typical Zephyr build goes from 8 minutes to under 2 minutes with proper caching.
Parallelize jobs. Build, host unit test, and emulated test jobs have no dependencies on each other. Run them concurrently to keep your pipeline fast.
Start small. A build-only pipeline merged this week is worth more than a perfect five-stage pipeline planned for next quarter. You can always add test jobs later.
Treat CI config as code. Review .yml changes in pull requests with the same rigor as firmware changes. A broken pipeline affects the entire team.
Licensed compilers (IAR, Keil): If you’re using a commercial toolchain, you’ll need to handle license secrets in CI. This is doable (GitHub encrypted secrets, license servers) but adds complexity. The examples in this guide use open toolchains for this reason.
From Automated Builds to Hardware-in-the-Loop
The three steps above, build automation, host unit testing, and emulated target testing, cover the vast majority of what a CI/CD pipeline can catch without physical hardware. For most teams, this is the right place to invest for the next 6–12 months.
The natural next tier is hardware-in-the-loop (HIL) testing: real dev boards connected to dedicated CI runners, exercising actual peripherals, electrical interfaces, and real-time timing. This requires infrastructure investment (lab space, dedicated machines, custom test fixtures) and is a topic that deserves its own guide.
Beyond that, CI/CD pipelines eventually feed into OTA deployment workflows, connecting your build process all the way to production firmware delivery.
But here’s what matters right now: these three steps are achievable this week with zero budget and zero infrastructure beyond a GitHub account.
Commit Your First build.yml Today
The progression is simple: automate the build, add unit tests, layer in emulation. Each step is independently valuable and incrementally builds on the last.
CI/CD isn’t a luxury borrowed from web development. It’s arguably more critical for embedded, where every debugging session means connecting a probe, flashing a board, reproducing the state, and staring at register dumps. Every bug your pipeline catches is a bug you don’t have to debug with a JTAG adapter and an oscilloscope.
Start today. Create .github/workflows/build.yml, paste the template from Step 1, adapt it to your toolchain, and push. When that first green checkmark appears on your pull request, you’ll understand why every team that adopts embedded CI/CD says the same thing: “We should have done this years ago.”
Hubble Network enables over-the-air firmware updates to deployed devices worldwide—no gateways, no infrastructure buildout. See how it works →