Understanding Zephyr Kconfig: How to Configure Your Build Without Losing Your Mind

Developer configuring Zephyr RTOS build settings on laptop screen with code editor and terminal windows open

On a Raspberry Pi, you want Bluetooth? You run apt install bluez, write some Python, and move on with your life. In Zephyr, you open a file called prj.conf, stare at 30 lines of CONFIG_BT_SOMETHING=y, change one of them, and your build explodes with errors that reference files you’ve never heard of. You paste the error into a search engine, find a forum post from 2021, copy their prj.conf, and it works—but you have no idea why.

That’s cargo-culting, and it’ll hurt you the moment your project diverges even slightly from that forum post. Here’s the thing: Zephyr’s configuration system isn’t actually complicated. It’s just unfamiliar. And the reason it exists is brutally practical. Your microcontroller has 256 KB of RAM, not 4 GB. There’s no room for “install everything and figure it out at runtime.” Every feature included in the binary costs real bytes, so Zephyr builds a custom firmware image tailored to exactly what your project needs. Kconfig is the system that lets you specify what that is.

Think of Zephyr Kconfig as a translation layer. You say “I want Bluetooth.” Kconfig translates that into “include these 47 source files, allocate these buffer sizes, and set these compiler flags.” You don’t need to understand all 47 files. You need to know the right handful of switches.

This article will give you those switches, especially for Bluetooth projects, and the mental model to find new ones on your own.

What Zephyr Kconfig Actually Is (and Why It Feels Like Overkill)

Kconfig originated in the Linux kernel. When you’re managing millions of lines of code that can target thousands of hardware combinations, you need a system to toggle features on and off at compile time. Zephyr adopted Kconfig because it faces the same problem at a smaller scale: a large, modular codebase that must be trimmed to fit on constrained hardware.

Concretely, Kconfig maps human-readable symbols like CONFIG_BT=y to three things: compiler definitions (so #ifdef CONFIG_BT gates work in C), CMake source-file inclusion (so Bluetooth source files actually get compiled), and default values for things like buffer sizes and stack depths.

The Zephyr build configuration merges three layers at build time:

┌─────────────────────────────────────┐
│  Your prj.conf                      │  ← You write this
├─────────────────────────────────────┤
│  Board default configs (defconfig)  │  ← Board maintainers wrote this
├─────────────────────────────────────┤
│  Kconfig defaults (in Zephyr tree)  │  ← Zephyr developers wrote this
└─────────────────────────────────────┘
        ↓  merged at build time  ↓
   Final config (.config / autoconf.h)

The merge order matters: Zephyr defaults load first, then your board’s defconfig overrides some of those, and finally your prj.conf overrides everything else. Highest layer wins.

The practical takeaway: you only need to set things in prj.conf that differ from the defaults. If your board already enables GPIO (most do), you don’t need CONFIG_GPIO=y in your file. This is why sample prj.conf files are often surprisingly short.

Anatomy of prj.conf — It’s Just a Key-Value File

If you’ve worked with .env files or .ini files, the prj.conf in Zephyr will feel familiar. Here’s a minimal example for a BLE peripheral project:

# Enable Bluetooth
CONFIG_BT=y
CONFIG_BT_PERIPHERAL=y
CONFIG_BT_DEVICE_NAME="MyDevice"

# Logging (helpful during development)
CONFIG_LOG=y
CONFIG_LOG_DEFAULT_LEVEL=3

That’s it. Six lines, and you have a Bluetooth peripheral with logging. The syntax rules are few:

  • No spaces around =. CONFIG_BT = y will silently fail.
  • y or n for booleans. Never quote them. CONFIG_BT="y" is wrong; only actual string values get quotes.
  • Integers are bare numbers. CONFIG_LOG_DEFAULT_LEVEL=3 not "3".
  • # starts a comment.

One nuance that trips people up: setting CONFIG_SOMETHING=n is different from simply omitting the line. Omitting it means “use whatever the default is.” Explicitly setting =n forces it off even if a board defconfig or another subsystem would have turned it on. Most of the time, omitting is fine. Use =n when you need to actively fight a default.

Dependencies and the Symbol Hierarchy

Here’s where people hit their first real wall. You add CONFIG_BT_PERIPHERAL=y to your prj.conf but forget CONFIG_BT=y. The build either silently ignores your setting or throws a cryptic error. What happened?

Kconfig symbols can declare that they depend on other symbols. CONFIG_BT_PERIPHERAL depends on CONFIG_BT. If the parent isn’t enabled, the child doesn’t exist as far as the build system is concerned. It’s not a bug; it’s the hierarchy working as designed.

Here’s a simplified dependency tree for a typical BLE peripheral with pairing and a Device Information Service:

CONFIG_BT=y
├── CONFIG_BT_PERIPHERAL=y
│   └── CONFIG_BT_DEVICE_NAME="MyDevice"
├── CONFIG_BT_SMP=y            (security / pairing)
│   └── CONFIG_BT_KEYS_OVERWRITE_OLDEST=y
└── CONFIG_BT_DIS=y            (Device Information Service)
    ├── CONFIG_BT_DIS_MANUF="MyCompany"
    └── CONFIG_BT_DIS_MODEL="v1"

Every child node here requires its parent. You can’t set CONFIG_BT_DIS_MANUF without enabling CONFIG_BT_DIS, which itself requires CONFIG_BT.

There’s a second mechanism called select, where enabling one symbol automatically enables another. This is why some configurations “just work” even when you didn’t explicitly enable all the parents. For example, enabling CONFIG_BT automatically selects certain low-level subsystems. But select isn’t used everywhere, which is why the behavior feels inconsistent. When a symbol you set seems to have no effect, the first thing to check is its depends on chain.

Common Kconfig Patterns for Bluetooth Projects

Rather than memorizing individual symbols, learn the patterns. Each block below follows the same logic: “If you want X, you need Y.”

Basic BLE Peripheral (advertise and accept connections):

CONFIG_BT=y
CONFIG_BT_PERIPHERAL=y
CONFIG_BT_DEVICE_NAME="MySensor"

This is the minimum. Your device will advertise and allow a central to connect.

Adding custom GATT services with more attributes:

CONFIG_BT=y
CONFIG_BT_PERIPHERAL=y
CONFIG_BT_DEVICE_NAME="MySensor"
CONFIG_BT_GATT_DYNAMIC_DB=y
CONFIG_BT_MAX_PAIRED=5

If you’re registering services at runtime or you have many characteristics, you may need CONFIG_BT_GATT_DYNAMIC_DB. For projects with many attributes, you can increase the attribute table size to prevent registration failures.

Bonding and persistent pairing:

CONFIG_BT=y
CONFIG_BT_PERIPHERAL=y
CONFIG_BT_SMP=y
CONFIG_BT_SETTINGS=y
CONFIG_SETTINGS=y
CONFIG_NVS=y
CONFIG_FLASH=y
CONFIG_FLASH_MAP=y

This is the pattern that surprises people most. You just wanted “remember paired devices across reboots,” and suddenly you’re enabling flash storage, NVS (Non-Volatile Storage), and the settings subsystem. Bonding requires persisting encryption keys, which requires a storage backend, which requires flash access. On a desktop, the OS handles this invisibly. On an MCU, you wire it up explicitly.

BLE debugging and logging:

CONFIG_LOG=y
CONFIG_BT_LOG_LEVEL_DBG=y
CONFIG_LOG_BUFFER_SIZE=4096

When Bluetooth connections fail silently, these options give you actual visibility. CONFIG_BT_LOG_LEVEL_DBG enables debug-level log output for the entire Bluetooth subsystem. Increase CONFIG_LOG_BUFFER_SIZE if you see truncated output.

Stack and memory tuning:

CONFIG_BT_RX_STACK_SIZE=2048
CONFIG_SYSTEM_WORKQUEUE_STACK_SIZE=2048
CONFIG_MAIN_STACK_SIZE=2048

On microcontrollers, there’s no virtual memory. Every thread gets a fixed-size stack allocated at compile time. If your BLE callbacks do significant processing, the default stack sizes might be too small, and you’ll see hard faults or stack overflow warnings. These symbols let you bump the allocations. Start with defaults, and increase only when you see stack-related crashes.

How to Explore and Debug Your Zephyr Build Configuration

You don’t have to memorize every CONFIG_ symbol. Zephyr gives you tools to explore interactively.

menuconfig — the interactive browser:

west build -t menuconfig

This opens a terminal UI where you can browse every available Kconfig symbol, organized into categories. Press / to search for a symbol by name. When you select a symbol, the help text shows its description, default value, and, critically, its dependencies. This is the single best tool for answering “why isn’t my symbol working?”

You can also use west build -t guiconfig for a graphical (Qt-based) version if your development environment supports it.

The generated .config — your source of truth:

After a build, check build/zephyr/.config. This is the final merged configuration, the result of Zephyr defaults + board defaults + your prj.conf. Grep it:

grep CONFIG_BT build/zephyr/.config

If your symbol isn’t in there, or it’s set to something you didn’t expect, you know to work backwards through dependencies. If you set CONFIG_BT_SMP=y in prj.conf but it doesn’t appear in .config, a dependency is missing.

autoconf.h — what the C code actually sees:

The file build/zephyr/include/generated/autoconf.h contains the #define statements that gate code inclusion. If you’re reading Zephyr source and wondering whether an #ifdef CONFIG_BT_SMP block is active in your build, check this file.

The debugging workflow is simple: set a symbol in prj.conf, build, grep .config, and if it’s not there, open menuconfig and search for the symbol to read its dependencies.

Building Your Configuration Muscle Memory

Zephyr Kconfig isn’t a mystery. It’s a translation layer between your intent and the compiler. Your prj.conf is a short, readable key-value file where you declare what your firmware needs. The dependency system ensures that enabling a feature pulls in (or requires) whatever that feature needs to function. And when things go wrong, menuconfig and the generated .config file tell you exactly what happened and why.

The most effective way to learn is to start from a working sample. samples/bluetooth/peripheral_hr is a solid starting point, or you can check out Hubble’s Zephyr reference implementation for a real-world example. Modify one symbol at a time. Enable something, rebuild, check .config, flash, test. Break it on purpose, then fix it. Within an afternoon, the CONFIG_ wall stops looking like noise and starts looking like a parts list.

Once you’re comfortable with Kconfig, the next frontier is Zephyr’s devicetree system, the other configuration layer that handles hardware description rather than software features. Together, Kconfig and devicetree are how Zephyr lets you build firmware that fits exactly the hardware and feature set you need, with nothing wasted.


Hubble Network connects Bluetooth devices directly to satellites—no gateways, no extra infrastructure. Learn how it works →