How to Use Zephyr Shell for Runtime Debugging on Live Devices

Developer typing Zephyr shell commands on laptop connected to embedded device for live debugging

You just spent four minutes waiting for a build, flash, and reboot cycle, only to discover that a single I2C register read returns 0xFF instead of the expected WHO_AM_I value. You add a printk, rebuild, reflash, reboot, check the output, realize you need to see a second register, and do it all again. That’s twenty minutes gone to answer a question that should take ten seconds.

The frustrating part: Zephyr already ships with a tool that lets you scan an I2C bus, read arbitrary registers, toggle GPIOs, inspect thread states, and tune log levels, all at runtime, all without reflashing. It’s the Zephyr Shell subsystem, and most firmware engineers either don’t know it exists or underestimate how much it can do.

This guide walks you through enabling the shell, using its built-in commands, writing custom ones, and building debugging workflows that collapse your iteration time from minutes to seconds. You already know your way around west build, Kconfig, and devicetree, so let’s skip the basics and get to the useful parts.

More Than printk: How the Shell Actually Works

The Zephyr Shell isn’t a logging hack. It’s a bidirectional, interactive command framework with tab completion, command history, and a modular architecture. Think of it as a Linux-style CLI running on your MCU.

+------------------+
|  Your Commands   |    <-- Custom shell modules
+------------------+
| Built-in Modules |    <-- kernel, device, gpio, i2c, sensor...
+------------------+
|   Shell Core     |    <-- Parsing, tab-complete, history
+------------------+
|  Transport Layer |    <-- UART / RTT / USB / Telnet
+------------------+
|    Hardware       |
+------------------+

The shell runs in its own dedicated thread (configurable priority and stack size). Transport backends handle the physical connection: UART, SEGGER RTT, USB CDC-ACM, or even Telnet for networked devices. On top of that, command modules register themselves at compile time, so the shell automatically discovers every module you’ve enabled.

This means you can type help on a live device and see exactly what’s available, no documentation lookup required.

Enabling the Shell in Your Project

Add the following to your prj.conf (or better, to a dedicated shell.conf overlay):

# prj.conf - Shell configuration
CONFIG_SHELL=y
CONFIG_SHELL_BACKEND_SERIAL=y
CONFIG_SHELL_PROMPT_UART="mydevice:~$ "
CONFIG_SHELL_CMD_BUFF_SIZE=256
CONFIG_SHELL_PRINTF_BUFF_SIZE=30
CONFIG_SHELL_HISTORY=y
CONFIG_SHELL_HISTORY_BUFFER=512
CONFIG_SHELL_TAB=y
CONFIG_SHELL_TAB_AUTOCOMPL=y

That’s it for the baseline. Build and flash normally with west build -b <your_board> && west flash. No code changes needed.

Choosing a Transport Backend

BackendKconfig OptionBest ForRequires
UARTCONFIG_SHELL_BACKEND_SERIAL=yMost boards, simplest setupSerial adapter or onboard USB-UART
RTTCONFIG_SHELL_BACKEND_RTT=yJ-Link users, no free UARTSEGGER J-Link + RTT Viewer
USB CDC-ACMCONFIG_SHELL_BACKEND_SERIAL=y + USB CDC configUSB-native MCUs, no UART pinsUSB cable + CONFIG_USB_DEVICE_STACK=y + CDC-ACM config

UART is the default and the simplest starting point. If your board’s debug probe exposes a serial port (most nRF DKs, Nucleo boards), you’re already set.

Devicetree and Connecting

The shell uses the zephyr,shell-uart chosen node. On most boards this is preconfigured. If you need a different UART, override it in your board’s overlay:

/ {
    chosen {
        zephyr,shell-uart = &uart1;
    };
};

Connect with your preferred terminal tool: minicom -D /dev/ttyACM0 -b 115200, screen /dev/ttyACM0 115200, PuTTY on Windows, or SEGGER RTT Viewer for the RTT backend. You should see the shell prompt immediately after device boot.

Built-in Shell Commands: The Full Tour

This is where the shell earns its keep. Zephyr ships with dozens of command modules that cover the most common debugging scenarios. You enable them via Kconfig, and each one adds a set of subcommands to the shell.

Navigating the Shell

Type help to list all registered top-level commands. Every command supports help as a subcommand (kernel help). Tab completion works on commands, subcommands, and even device names. Arrow keys navigate command history.

The kernel Module

Available by default when the shell is enabled.

  • kernel threads lists every thread: address, name, priority, state, and stack usage. This is your first stop when diagnosing hangs or scheduling issues.
  • kernel stacks shows stack high-water marks for every thread. Requires CONFIG_INIT_STACKS=y and CONFIG_THREAD_STACK_INFO=y. Essential for right-sizing stack allocations.
  • kernel uptime returns system uptime in milliseconds.
  • kernel reboot cold triggers a software reset from the shell. No physical button press needed.

Here’s what a real session looks like:

mydevice:~$ kernel threads
 0x20001a00 shell_uart     (real-time)  : PENDING
    stack size 2048, unused 1124, usage 924 (45%)
 0x20002200 main           (cooperative): RUNNING
    stack size 4096, unused 2580, usage 1516 (37%)
 0x20003100 sysworkq       (cooperative): PENDING
    stack size 1024, unused 788, usage 236 (23%)
 0x20003e00 idle           (lowest)     : READY
    stack size 256, unused 168, usage 88 (34%)
mydevice:~$ device list
devices:
- clock-controller (READY)
- gpio@50000000    (READY)
- uart@40002000    (READY)
- i2c@40003000     (READY)
- spi@40004000     (READY)

The device Module

device list enumerates every device driver and its initialization status (READY or not). During board bring-up, this is the fastest way to confirm that a driver initialized successfully. If a device shows as not ready, you know the problem is in driver init, not in your application code.

The gpio Module

Enable with CONFIG_GPIO_SHELL=y.

  • gpio conf gpio@50000000 13 out configures pin 13 as output.
  • gpio set gpio@50000000 13 1 drives pin 13 high.
  • gpio get gpio@50000000 7 reads pin 7’s current state.
  • gpio toggle gpio@50000000 13 toggles an LED without writing a single line of code.

This is invaluable for verifying board routing, checking level shifters, or confirming a pin isn’t shorted, all before you write driver code.

The i2c Module

Enable with CONFIG_I2C_SHELL=y.

  • i2c scan i2c@40003000 scans the bus and prints every address that ACKs. During peripheral bring-up, this immediately tells you whether your sensor is electrically present.
  • i2c read i2c@40003000 0x68 0x75 1 reads 1 byte from register 0x75 at address 0x68. For an MPU-6050, this returns the WHO_AM_I register. No code required.
  • i2c write i2c@40003000 0x68 0x6B 0x00 writes to a register. Useful for manually waking a device or testing a configuration sequence.

The sensor Module

Enable with CONFIG_SENSOR_SHELL=y.

sensor get bme280@76 fetches and prints all channels from a sensor device. This validates the entire driver stack, from bus communication to channel conversion, in one command.

The log Module

This one changes how you debug. With CONFIG_LOG=y and CONFIG_SHELL_LOG_BACKEND=y:

  • log enable dbg my_driver switches a specific log module to debug level at runtime. No rebuild. No reflash.
  • log disable my_driver silences a noisy module while you focus on something else.
  • log status shows current log levels for all modules.

Other Notable Modules

  • flash reads, writes, and erases flash regions. Enable with CONFIG_FLASH_SHELL=y.
  • net shows interface status, IP configuration, and supports ping. Essential for networked devices. Requires CONFIG_NET_SHELL=y.
  • date gets/sets system time if RTC is configured.

Creating Custom Shell Commands

Built-in commands cover peripherals and kernel state. Custom commands expose your application’s state: connection status, error counters, state machine transitions, test triggers. Every debugging-conscious project should have them.

Static Command Registration

Here’s a complete, copy-pasteable example. This creates a myapp command with status and trigger subcommands:

#include <zephyr/shell/shell.h>
#include <stdlib.h>

/* Replace these with your actual application functions */
extern const char *app_state_to_str(int state);
extern int get_app_state(void);
extern int get_error_count(void);
extern void simulate_event(int event_id);

static int cmd_status(const struct shell *sh, size_t argc, char **argv)
{
    shell_print(sh, "App state:   %s", app_state_to_str(get_app_state()));
    shell_print(sh, "Uptime:      %u ms", k_uptime_get_32());
    shell_print(sh, "Error count: %d", get_error_count());
    return 0;
}

static int cmd_test_trigger(const struct shell *sh, size_t argc, char **argv)
{
    int event_id = atoi(argv[1]);
    shell_print(sh, "Triggering test event %d...", event_id);
    simulate_event(event_id);
    return 0;
}

SHELL_STATIC_SUBCMD_SET_CREATE(myapp_cmds,
    SHELL_CMD(status, NULL, "Print application status", cmd_status),
    SHELL_CMD_ARG(trigger, NULL, "Trigger test event <id>",
                  cmd_test_trigger, 2, 0),
    SHELL_SUBCMD_SET_END
);

SHELL_CMD_REGISTER(myapp, &myapp_cmds, "Application debug commands", NULL);

Key conventions to follow:

  • Use shell_print(), not printk. It routes output to the correct shell backend and handles concurrency.
  • SHELL_CMD_ARG specifies mandatory and optional argument counts. The 2 above means two total arguments (the command name itself counts as one), and 0 optional. The shell validates this automatically.
  • Return 0 on success, non-zero on error.

Dynamic Commands

If you need commands that enumerate runtime data (say, listing active BLE connections by name) use SHELL_DYNAMIC_CMD_CREATE. This lets you generate subcommand lists at runtime so tab completion works against live data.

Stripping Debug Commands from Production

Wrap your debug shell files in a Kconfig guard:

#ifdef CONFIG_MYAPP_DEBUG_SHELL
/* all shell command registrations here */
#endif

Add CONFIG_MYAPP_DEBUG_SHELL=y only in your debug overlay. Production builds won’t include the commands, or their string literals, in the binary.

Four Debugging Workflows That Replace Reflashing

Workflow 1: Diagnosing a Thread Hang

Your device stops responding. Instead of adding printk breadcrumbs and reflashing, connect to the shell and run kernel threads. Look for a thread stuck in PENDING state with unexpectedly high stack usage, or a high-priority thread that’s RUNNING and starving others. You’ve identified the culprit in seconds.

Workflow 2: Peripheral Bring-Up

New board revision arrives. Run device list to confirm all drivers initialized. Run i2c scan i2c@40003000 to verify the accelerometer responds at the expected address. Run i2c read i2c@40003000 0x6B 0x00 1 to check its WHO_AM_I register. If everything checks out, run sensor get to validate the full driver path. You’ve validated the entire peripheral stack without writing application code.

Workflow 3: Runtime Log Tuning

A bug appears only under specific conditions. Instead of rebuilding with debug logging (which might change timing and mask the bug), run log enable dbg my_driver to switch that one module to debug level. Observe the output, then log disable my_driver when you’re done. Zero rebuild overhead, zero timing disruption.

Workflow 4: GPIO Validation

You’re unsure whether a pin is routed correctly on the PCB. From the shell, configure it as output and toggle it while probing with a multimeter or logic analyzer. Then configure it as input and verify you can read the expected level. This takes thirty seconds instead of writing and flashing a test program.

Performance, Footprint, and Production Safety

The shell subsystem costs roughly 4–8 KB of ROM depending on which modules you enable. RAM usage is dominated by the shell thread stack (CONFIG_SHELL_STACK_SIZE, default 2048 bytes) and history buffer.

For production firmware, you have three options:

  1. Disable entirely. Remove CONFIG_SHELL=y. Zero overhead.
  2. Whitelist commands. Use CONFIG_SHELL_CMDS_SELECT=y to include only specific, safe commands.
  3. Separate build configuration. Maintain a debug.conf overlay that enables the shell and a release.conf that doesn’t.

Be clear-eyed about security: the shell gives whoever connects full device access, including reading memory, toggling GPIOs, and rebooting. Never ship production firmware with an unprotected shell on an externally accessible interface.

Making the Shell Part of Your Standard Workflow

Here’s the action plan: create a shell.conf overlay in your project repo today. Enable the shell, the kernel module, and whichever peripheral modules match your hardware. Add a basic custom command that prints your application’s state. Commit it so every engineer on the team can build with -DEXTRA_CONF_FILE=shell.conf and get an interactive debug console on any device.

Once you’ve worked this way, typing a command and getting an answer in under a second instead of waiting for build-flash-boot, you won’t go back to the reflash-reboot-repeat cycle. The shell turns firmware debugging from a batch process into a conversation with your device. Start that conversation today.


Hubble Network connects your Bluetooth devices directly from satellite — so you can debug smarter in the lab, then deploy everywhere without range limitations. Learn more →