Intro
As embedded systems complexity grows, firmware development needs more and more tools and build systems to handle it. One common reason to switch from bare metal to a more complex RTOS is when adding more communications, such as bluetooth, to an embedded system. Nordic is a very popular creator of bluetooth chips, and they support their hardware with their nordic nRF connect SDK, a collection of tools and the Zephyr RTOS all packaged up and included in custom VSCode extensions. Nordic supports devs working on their hardware with online courses, such as their nRF Connect SDK Fundamentals course, that guides you in installing all the necessary tools to build and flash a dev kit from Nordic.
However, a lot of the intro level courses want to get to developing a project, and completely gloss over how things are built and flashed. They have you install a collection of mystery tools, then give you magic strings to use. The moment something doesn’t go according to the tutorial, you’re stuck searching the internet for forum posts. Developing a basic model of how the code is actually built and flashed before diving into the projects themselves gives a strong foundation for using and troubleshooting projects. As a bonus, it also prevents the common “mistrust of complexity” problem when trying to shift bare metal engineers to more complex systems.
This post will go through the tools used to build a Nordic blinky project from the Fundamentals course. It will then parse out the logs from the build system and connect them to the tools we talked about.

What will we need to install?
The Nordic SDK contains tools to build your application for whichever supported hardware you’re using, plus the source code to build an RTOS-based project. Nordic uses Zephyr as the RTOS of choice, so it also includes the Zephyr SDK. The Zephyr SDK contains everything needed to build Zephyr projects, as well as any libraries that are needed by either the Zephyr RTOS itself, or the application written by the developer.
Review tooling from the bottom-up
The above summary has a lot of layers and components, so lets start at someplace a bare metal engineer recognizes, and work up the layers.
The Compiler and Linker
The classic ARM GCC is used for compiling and linking the source code into an ELF or hex file that you will flash onto a device. The actual binaries will be part of the Zephyr SDK, but the versioning is managed by the Nordic SDK and VSCode extensions. Here’s an example filepath to the gcc that I used to compile code for the nRF5340 dev kit:
/opt/nordic/ncs/toolchains/580e4ef81c/opt/zephyr-sdk/arm-zephyr-eabi/bin/arm-zephyr-eabi-gcc
Which shows the binary is inside the nordic toolchain folder and the Zephyr SDK folder.
Ninja
If you’re familiar with Make, Ninja is a replacement for it developed by Evan Martin back in 2012. Like Make, Ninja requires a script (called a build.ninja for Ninja) that, when run, will go through all .c/.h files to be compiled/linked and build the final hex. This will include the source files written by you, the header file for the given hardware, and the header files and source code for the RTOS functionality needed by the project. How are those last two items found or created? Well they are generated ultimately by…
CMake
CMake is used to add a level of abstraction to the build process, since CMake can generate multiple different build scripts from the same initial CMake files, as well as being OS-agnostic. Cmake supports Ninja, Make, and other build systems like GreenHills and Xcode. However, Zephyr only supports using Ninja or Make, and the Nordic SDK defaults to generating Ninja scripts. So when run, CMake will generate Ninja scripts that use a particular compiler/assembler/linker needed for your hardware.
Since CMake is being used for a Zephyr project, it also will be used to call Python scripts that generate the header files for the given hardware, and the header files and source code for the planned RTOS functionality. Once generated, these are used by the generated Ninja script when compiling the project together. Two Zephyr configuration files, the DeviceTree and Kconfig files (explained more below), are used by the Python scripts to generate the header/source files for the particular hardware/dev kit you want to flash.
To give more visibility into what CMake is doing, it can even generate a list of the actual commands in a compile_commands.json file. This is set by default in the Nordic SDK, so when building projects, you can verify CMake is creating the commands you think it should.
For more info: CMAKE_EXPORT_COMPILE_COMMANDS
Zephyr West build
The Zephyr west tool is a “meta-tool” that has a lot of functionality in it, but the main focus for this post is the west build function, which will run CMake and build the code. If no build folder exists, west build will run CMake to generate the folder and all configuration files needed. If there already exists a build folder, it will incrementally recompile with CMake.
Zephyr Configurations – An Aside
There’s two configuration files listed in the above build process that you won’t be familiar with if you haven’t used Zephyr or worked on the Linux kernel: DeviceTree and KConfig
The DeviceTree is a file with specific syntax used to describe the hardware (GPIO, buttons, the SoC, etc.). The build system uses it to generate the header file representing the hardware that more bare metal firmware engineers would be familiar with. It also configures the default boot-time configuration of the hardware. It can have a base configuration file provided by Nordic for Nordic chips, with “overlays” that modify the described hardware, to support custom hardware.
The Kconfig is borrowed from the Linux kernel, originally. This configuration file controls which software functionality from the kernel will be compiled in the final elf/hex file. An example would be software support for the watchdog, or a floating point unit. Like the DeviceTree, the Kconfig can also have a base configuration with modifications done per project.
Zephyr has more info on them both, and if you’re confused about the differences between the two: DeviceTree vs Kconfig
Installing the tools
Now that we know what we’re supposed to be using, let’s get a blinky project running on your dev kit, following the Nordic Developer Academy fundamentals course. The first thing the course does is have you install extensions.
The Extensions
Below is the list of extensions Nordic currently recommends to install for VSCode, which can be installed all at once with their nRF Connect for VS Code Extension Pack. The descriptions below are partly pulled from their overview of what’s in the extension:
- nRF Connect for VS Code
- This has an interface to the build system and nRF Connect SDK. It also provides an interface to manage nRF Connect SDK versions and toolchains, and sets an active SDK/toolchain version.
- The nRF Connect SDK installs these: Zephyr SDK, cmake, dtc, git, gperf, ninja, python3 and west.
- The Zephyr SDK includes the toolchains for multiple supported architectures.
- nRF DeviceTree
- Provides DeviceTree language support and the DeviceTree Visual Editor.
- It’s a nice GUI for editing DeviceTree files, but is not strictly necessary for building and compiling.
- nRF Kconfig
- Provides Kconfig language support.
- Note: This extension does not appear to work on MacOS Sequoia 15.1
- Provides a builtin serial and RTT terminal to VSCode.
- Mainly useful if you do not already have a favorite serial terminal.
- Adds language support for C/C++, including features such as IntelliSense.
- If you already use VSCode for C/C++ coding, this is likely already installed.
- CMake language support
- Same as the C/C++ extension above, may already be installed
- Linker map files support
Once the above extensions are installed, the course walks you through downloading a particular toolchain and Nordic SDK version, creating an application based on the blinky sample, and configuring the hardware you want to build to, so the correct DeviceTree and Kconfig files are used. Finally, it has you actually build the application, and use the VSCode extension to flash your hardware. In my case, I was using the nRF5340 dev kit, which had a built in J-Link to support flashing the chip, so flashing the hardware didn’t require me to dig out a programmer from my bins.
J-Link issues on MacOS
When I followed the steps from dev academy, I successfully got blinky flashed to my dev kit, but started getting endless errors from finder about how the J-Link was not ejected properly.
Successful flash!
Angry JLink message:

There are two different ways to solve this.
First solution
I first did some hunting online and came across this forum post that implied the issue was coming from the J-Link firmware on the dev kit being too old. With this and other vague hints online, I decided to update the J-Link programmer on the dev kit.
I downloaded the J-Link config program from SEGGER. Once installed, I opened up the J-Link config app v8.10f and powered on the dev kit. From there, I could see the J-Link programmer on my dev kit listed as connected via USB.
Right clicking on the appeared J-Link and then “update firmware”, I got the J-Link firmware that was compiled oct 8 2024.
Once this finished updating, the error went away!
Now, to then flash using VScode and the updated J-Link programmer, you must reset the device, otherwise you’ll get the following error:
[error] [ JLink] - Communication timed out: Requested 4 bytes, received -1 bytes !
[error] [ Client] - Encountered error -105: Command select_coprocessor executed for 128 milliseconds with result -105
ERROR: Failed when selecting coprocessor APPLICATION
[error] [ Worker] - An unknown error.
ERROR: The JLinkARM DLL timed out while communicating to the J-Link probe.
ERROR: If the error condition persists, run the same command again with
ERROR: argument -- log, contact Nordic Semiconductor and provide the generated
ERROR: log.log file to them.
NOTE: For additional output, try running again with logging enabled (--log).
NOTE: Any generated log error messages will be displayed.
FATAL ERROR: command exited with status 35:
Second Solution
Talking with coworkers, I learned Zephyr actually suggests a different solution: disabling the Mass Storage device.
Removing the Mass Storage device functionality frees up a USB Endpoint on the J-link programmer that supports 512 bytes, which prevents data corruption or drops if you use the USB CDC ACM Serial Port with packets larger than 64 bytes.
If you already installed the J-Link Software and Documentation pack from the first solution, you can just go to your terminal, enter JLinkExe, and then at the prompt do MSDDisable to disable the Mass Storage device functionality.
Example Logs from Building and Flashing
Since we’ve now built and flashed the blinky application, we can review the logs to see how the tools we learned about are actually called. Here are some example logs generated when building and flashing the nRF5340 devkit.
Note: If, instead of using the VSCode extension, you run these commands on the command line, you can include -vvv flag after west to get extra debug information
Build Configuration Logs
Here’s the start of the log example when generating the initial build configuration for a project named fund_less5_exer1. This was triggered by adding a build config for an nRF5340 dev kit in VSCode and unchecking the option to both build the configurations and build the application.
Executing task: nRF Connect: Generate config nrf5340dk_nrf5340_cpuapp_ns for fund_less5_exer1
Building fund_less5_exer1
west build --build-dir /Users/yourprofile/nordic/nordic-zephyr/fund_less5_exer1/build /Users/yourprofile/nordic/nordic-zephyr/fund_less5_exer1 --pristine --board nrf5340dk_nrf5340_cpuapp_ns --cmake-only -- -DNCS_TOOLCHAIN_VERSION=NONE
The west build command above includes the needed paths, which board, and doing the configuration only.
-- west build: generating a build system
Loading Zephyr default modules (Zephyr base).
-- Application: /Users/yourprofile/nordic/nordic-zephyr/fund_less5_exer1
-- CMake version: 3.21.0
-- Found Python3: /opt/nordic/ncs/toolchains/580e4ef81c/opt/python@3.9/bin/python3.9 (found suitable version "3.9.6", minimum required is "3.8") found components: Interpreter
-- Cache files will be written to: /Users/yourprofile/Library/Caches/zephyr
-- Zephyr version: 3.5.99 (/opt/nordic/ncs/v2.6.1/zephyr)
-- Found west (found suitable version "1.2.0", minimum required is "0.14.0")
-- Board: nrf5340dk_nrf5340_cpuapp_ns
-- Found host-tools: zephyr 0.16.5 (/opt/nordic/ncs/toolchains/580e4ef81c/opt/zephyr-sdk)
-- Found toolchain: zephyr 0.16.5 (/opt/nordic/ncs/toolchains/580e4ef81c/opt/zephyr-sdk)
-- Found Dtc: /opt/nordic/ncs/toolchains/580e4ef81c/bin/dtc (found suitable version "1.6.1", minimum required is "1.4.6")
Here the logs show the west build is collecting the tools needed: CMake, python for the Python scripts, and Zephyr SDK and a related toolchain.
-- Found BOARD.dts: /opt/nordic/ncs/v2.6.1/zephyr/boards/arm/nrf5340dk_nrf5340/nrf5340dk_nrf5340_cpuapp_ns.dts
-- Generated zephyr.dts: /Users/yourprofile/nordic/nordic-zephyr/fund_less5_exer1/build/zephyr/zephyr.dts
-- Generated devicetree_generated.h: /Users/yourprofile/nordic/nordic-zephyr/fund_less5_exer1/build/zephyr/include/generated/devicetree_generated.h
-- Including generated dts.cmake file: /Users/yourprofile/nordic/nordic-zephyr/fund_less5_exer1/build/zephyr/dts.cmake
This section of the log gets the default DeviceTree for the given hardware processes it, and generates a .h file for the DeviceTree
Parsing /opt/nordic/ncs/v2.6.1/zephyr/Kconfig
Loaded configuration '/opt/nordic/ncs/v2.6.1/zephyr/boards/arm/nrf5340dk_nrf5340/nrf5340dk_nrf5340_cpuapp_ns_defconfig'
Merged configuration '/Users/yourprofile/nordic/nordic-zephyr/fund_less5_exer1/prj.conf'
Configuration saved to '/Users/yourprofile/nordic/nordic-zephyr/fund_less5_exer1/build/zephyr/.config'
Kconfig header saved to '/Users/yourprofile/nordic/nordic-zephyr/fund_less5_exer1/build/zephyr/include/generated/autoconf.h'
Here it’s finding the default Kconfig, adding any custom configs I added in the application project, and generating a header file for it.
-- Found GnuLd: /opt/nordic/ncs/toolchains/580e4ef81c/opt/zephyr-sdk/arm-zephyr-eabi/bin/../lib/gcc/arm-zephyr-eabi/12.2.0/../../../../arm-zephyr-eabi/bin/ld.bfd (found version "2.38")
-- The C compiler identification is GNU 12.2.0
-- The CXX compiler identification is GNU 12.2.0
-- The ASM compiler identification is GNU
-- Found assembler: /opt/nordic/ncs/toolchains/580e4ef81c/opt/zephyr-sdk/arm-zephyr-eabi/bin/arm-zephyr-eabi-gcc
-- Using ccache: /opt/nordic/ncs/toolchains/580e4ef81c/bin/ccache
Dropping partition 'nonsecure_storage' since it is empty.
-- Configuring done
-- Generating done
-- Build files have been written to: /Users/yourprofile/nordic/nordic-zephyr/fund_less5_exer1/build
And lastly for the initial configuration, here’s the log of the chosen toolchain the code will be built with. Remember, this first log list is the build configuration process, it hasn’t actually generated the final files to flash to the hardware. That’s done in this next section. At this point, you can look in the /build folder and see the generated build configuration files.
Building Logs
Here’s the logs to actually build the hex files. The default logging levels feel pretty high-level, but again, you can use the -vvv flag on the command line to get more log output, if desired.
Building fund_less5_exer1
west build --build-dir /Users/yourprofile/nordic/nordic-zephyr/fund_less5_exer1/build /Users/yourprofile/nordic/nordic-zephyr/fund_less5_exer1
Here’s the main west call.
[1/219] Preparing syscall dependency handling
[4/219] Generating include/generated/version.h
-- Zephyr version: 3.5.99 (/opt/nordic/ncs/v2.6.1/zephyr), build: v3.5.99-ncs1-1
Zephyr determines used syscalls, and shows which version it’s using
[9/219] Generating ../../tfm/CMakeCache.txt
CMake Warning at cmake/version.cmake:22 (message):
Actual TF-M version is not available from Git repository. Settled to
v2.0.0
Trusted Firmware M is outside the scope of this post. Because I’m building to a cortex-m33 chip on my nRF5340, it has support for a Secure Processing Environment (SPE). Trusted Firmware M implements the SPE for Armv8-M and Armv8.1-M architecture, so it’s automatically included and built as needed.
Call Stack (most recent call first):
CMakeLists.txt:22 (include)
-- Found Git: /opt/nordic/ncs/toolchains/580e4ef81c/bin/git (found version "2.37.3")
-- The C compiler identification is GNU 12.2.0
-- The CXX compiler identification is GNU 12.2.0
-- The ASM compiler identification is GNU
-- Found assembler: /opt/nordic/ncs/toolchains/580e4ef81c/opt/zephyr-sdk/arm-zephyr-eabi/bin/arm-zephyr-eabi-gcc
-- Found Python3: /opt/nordic/ncs/toolchains/580e4ef81c/opt/python@3.9/bin/python3.9 (found version "3.9.6") found components: Interpreter
CMake Deprecation Warning at /opt/nordic/ncs/v2.6.1/zephyr/cmake/modules/FindDeprecated.cmake:121 (message):
'PYTHON_PREFER' variable is deprecated. Please use Python3_EXECUTABLE
instead.
Call Stack (most recent call first):
/opt/nordic/ncs/v2.6.1/zephyr/cmake/modules/python.cmake:16 (find_package)
/opt/nordic/ncs/v2.6.1/zephyr/cmake/modules/user_cache.cmake:30 (include)
/opt/nordic/ncs/v2.6.1/zephyr/cmake/modules/extensions.cmake:5 (include)
/opt/nordic/ncs/v2.6.1/nrf/subsys/nrf_security/tfm/CMakeLists.txt:38 (include)
-- Found Python3: /opt/nordic/ncs/toolchains/580e4ef81c/opt/python@3.9/bin/python3.9 (found suitable version "3.9.6", minimum required is "3.8") found components: Interpreter
-- Cache files will be written to: /Users/yourprofile/Library/Caches/zephyr
-- Configuring done
-- Generating done
The logs above give the versioning info again
CMake Warning:
Manually-specified variables were not used by the project:
CRYPTO_RNG_MODULE_ENABLED
MBEDTLS_PSA_CRYPTO_USER_CONFIG_FILE
PYTHON_PREFER
-- Build files have been written to: /Users/yourprofile/nordic/nordic-zephyr/fund_less5_exer1/build/tfm
[163/167] Linking C executable bin/tfm_s.axf
Memory region Used Size Region Size %age Used
FLASH: 32104 B 32 KB 97.97%
RAM: 10416 B 32 KB 31.79%
[17/219] Performing install step for 'tfm'
-- Install configuration: "MinSizeRel"
----- Installing platform NS -----
[217/219] Linking C executable zephyr/zephyr.elf
Memory region Used Size Region Size %age Used
FLASH: 25456 B 992 KB 2.51%
RAM: 4896 B 416 KB 1.15%
IDT_LIST: 0 GB 32 KB 0.00%
[219/219] Generating zephyr/merged.hex
And lastly, here it shows the merged.hex file that was generated (as well as the tfm generation, which again, is outside the scope of this post).
Flashing Logs
We have the merged.hex file generated from the build above, so let’s flash the dev kit and see if that’s the file used! Here’s the logs when hitting the “Flash” button in VSCode.
Building fund_less5_exer1
west build --build-dir /Users/yourprofile/nordic/nordic-zephyr/fund_less5_exer1/build /Users/yourprofile/nordic/nordic-zephyr/fund_less5_exer1
[0/16] Performing build step for 'tfm'
ninja: no work to do.
[2/3] Performing install step for 'tfm'
-- Install configuration: "MinSizeRel"
----- Installing platform NS -----
[3/3] Completed 'tfm'
The west build command is called again, but notice it can tell the project was just recently built, and doesn’t try to do anything.
* Executing task: nRF Connect: Flash: fund_less5_exer1/build (active)
Flashing build to 1050029006
west flash -d /Users/yourprofile/nordic/nordic-zephyr/fund_less5_exer1/build --skip-rebuild --dev-id 1050029006
-- west flash: using runner nrfjprog
-- runners.nrfjprog: reset after flashing requested
Here’s the actual flash command! West is used for flashing as well, like mentioned earlier, it’s a “meta-tool” that has a lot of functionality, and flashing is one of them. The west command also has the dev-id, which is the SN of the J-Link programmer on my devkit.
-- runners.nrfjprog: Flashing file:
/Users/yourprofile/nordic/nordic-zephyr/fund_less5_exer1/build/zephyr/merged.hex
[ #################### ] 1.895s | Erase file - Done erasing
[ #################### ] 0.433s | Program file - Done programming
[ #################### ] 0.335s | Verify file - Done verifying
Applying pin reset.
-- runners.nrfjprog: Board with serial number 1050029006 flashed successfully.
And there’s the merged.hex file getting flashed to the device, followed by toggling the reset pin.
Success!
Next Steps
This post focused solely on the build system at a high level, connecting the dots from the compiler up to build a better mental model of the Nordic Connect SDK ecosystem. The next steps would be working through the nRF dev academy courses to continue to get more in-depth with the actual Zephyr and Nordic application code. Or continue on your own, using the Nordic Connect SDK documentation and Zephyr API info. I’ve included links in the references section below to help move forward. I hope this gives you more context and more confidence to work with Nordic hardware.
Do Embedded Firmware Right with Dojo Five on Your Team
If you’d like to work with a knowledgeable team of engineers just like me on your next embedded project, you can book a call with us to get the conversation started.
References
- Nordic Connect SDK documentation: https://docs.nordicsemi.com/bundle/ncs-latest/page/nrf/index.html
- nRF Connect SDK class from Nordic: https://academy.nordicsemi.com/courses/nrf-connect-sdk-fundamentals/
- Zephyr API calls to use in code: https://docs.nordicsemi.com/bundle/zephyr-apis-latest/page/index.html
- Nordic product spec for nRF5340: https://docs.nordicsemi.com/bundle/ps_nrf5340/page/keyfeatures_html5.html
- For more in depth info on the build system in general: https://docs.nordicsemi.com/bundle/ncs-latest/page/zephyr/build/cmake/index.html


