Skip to content

The Zephyr Shell

Published On:
Apr 19, 2020
Last Updated:
May 21, 2025

Zephyr features a “shell” (one of it’s modules) that it can provide over a serial transport such as a UART, USB or Segger RTT. The shell provides to the user things such as Linux command-line style commands, logging, auto-complete, command history and more. In It is great for implementing a debug interface to control your microcontroller from a terminal application such as NinjaTerm (shameless plug, I developed this app).

Zephyr provides an API that the firmware can use to define the commands available to the user over the shell.

Basic Shell Usage

CONFIG_SHELL=y

This might be all you need to get a basic shell working across the default serial transport (whatever printf() sends to). You should see the shell print a prompt to your terminal, e.g. uart:~$ .

Adding Commands

The API is provided by #include <zephyr/shell/shell.h>.

The macro SHELL_CMD_REGISTER() can be used to register a new root command. Let’s add a basic root command.

static int helloCallback(const struct shell* shell, size_t argc, char** argv)
{
printf("Hello, world!");
return 0;
}
SHELL_CMD_REGISTER(hello, NULL, "Say hello", helloCallback);

Pointer to Specific Shell Instances

You can get a pointer to specific shell instances by using commands such as shell_backend_uart_get_ptr() (declared in zephyr/shell/shell_uart.h) or shell_backend_rtt_get_ptr() (declared in zephyr/shell/shell_rtt.h). For example, if you have a UART backend, you can use:

#include <zephyr/shell/shell_uart.h>
int main() {
struct shell * main_shell;
main_shell = shell_backend_uart_get_ptr();
__ASSERT(main_shell != NULL, "Failed to get shell backend.");
}

shell_set_bypass() can be used to bypass the shell and send raw data to the serial port.

You can pass in NULL to clear an existing bypass, e.g.:

shell_set_bypass(sh, NULL);

The model_shell example project makes use of this bypass feature to provide a direct AT command mode between the shell and the modem.

Shell Login

You can somewhat easily implement a login shell with Zephyr, which protects all the commands behind a simple root-level password that is required to “login”.

Setting CONFIG_SHELL_CMDS_SELECT=y in prj.conf will allow you to select which shell commands are available. Then you can call shell_set_root_cmd() from C code to set a root command. shell_set_root_cmd(NULL) will clear the root command and go back to showing all available commands.

A screenshot of a Zephyr login shell in action.

shell_obscure_set(sh, true) causes all logs to stop. Then calling shell_obscure_set(sh, false) did not restore the logging. After a while a hard fault occurred, likely due to the log messages building up and overflowing some buffer. I managed to fix this by re-enabling the shell logging backend with z_shell_log_backend_enable():

z_shell_log_backend_enable(sh->log_backend, (void *)sh, sh->ctx->log_level);

Another way to fix this is to just stop and start the shell with:

// Fix to restore logging
shell_stop(sh);
shell_start(sh);

These stop and start functions call z_shell_log_backend_disable() and z_shell_log_backend_enable() internally.

Below is the source code to create a login shell. It relies on a PRODUCTION_BUILD macro to determine if the login prompt should be shown or not, as in a development build it can be a pain to have to login each time you want to use the shell.

LoginCmd.c
#include <zephyr/kernel.h>
#include <zephyr/shell/shell.h>
#include <zephyr/shell/shell_uart.h>
#include <zephyr/init.h>
/**
* The password for the shell login.
*/
#define SHELL_PASSWORD "zephyr"
static int LoginInit(void)
{
// Only enable login in production builds, otherwise skip past the login prompt
#if PRODUCTION_BUILD == 1
printk("Please login with password to continue.\n");
if (!CONFIG_SHELL_CMD_ROOT[0]) {
shell_set_root_cmd("login");
}
struct shell const * sh = shell_backend_uart_get_ptr();
// Obscure the input so that the password is not visible
shell_obscure_set(sh, true);
// Disable logging to the console so that we don't see it at the login prompt
z_shell_log_backend_disable(sh->log_backend);
#else
printk("Development build. Login is not required.\n");
#endif
return 0;
}
static int CheckPassword(char *passwd)
{
return strcmp(passwd, SHELL_PASSWORD);
}
static int CmdLogin(const struct shell* sh, size_t argc, char** argv)
{
static uint32_t attempts;
if (CheckPassword(argv[1]) != 0)
{
shell_error(sh, "Incorrect password!");
attempts++;
if (attempts > 3)
{
k_sleep(K_SECONDS(attempts));
}
return -EINVAL;
}
// Clear history so password not visible there
z_shell_history_purge(sh->history);
shell_obscure_set(sh, false);
shell_set_root_cmd(NULL);
shell_prompt_change(sh, "uart:~$ ");
z_shell_log_backend_enable(sh->log_backend, (void *)sh, sh->ctx->log_level);
shell_print(sh, "Logged in successfully! Hit tab for help. Use the command \"logout\" to log back out.\n");
attempts = 0;
return 0;
}
static int CmdLogout(const struct shell* sh, size_t argc, char** argv)
{
shell_set_root_cmd("login");
shell_obscure_set(sh, true);
shell_prompt_change(sh, "login: ");
// Disable logging to the console so that we don't see it at the login prompt
z_shell_log_backend_disable(sh->log_backend);
shell_print(sh, "\n");
return 0;
}
SHELL_CMD_ARG_REGISTER(login, NULL, "<password>", CmdLogin, 2, 0);
SHELL_CMD_REGISTER(logout, NULL, "Log out.", CmdLogout);
SYS_INIT(LoginInit, APPLICATION, CONFIG_APPLICATION_INIT_PRIORITY);

shell_prompt_change(sh, "login: ") is used to change the shell prompt to login: so that it’s obvious to the user that they need to login before doing anything else.

An example of shell login can be found here.

Using the Shell Over USB CDC ACM

Add the following to your prj.conf file to enable the USB device driver:

prj.conf
CONFIG_USB_DEVICE_STACK=y
CONFIG_USB_DEVICE_PRODUCT="My Product Console"
CONFIG_USB_DEVICE_VID=0xDEAD
CONFIG_USB_DEVICE_PID=0xBEEF
CONFIG_USB_DEVICE_INITIALIZE_AT_BOOT=n

The CONFIG_USB_DEVICE_PRODUCT shows up in Windows as the “Bus reported device description” under the device properties in the Device Manager.

A screenshot of a USB serial CDC device in Windows showing the bus reported device description.

Add the following under /chosen in your board’s DTS file:

my_board.dts
/ {
chosen {
zephyr,console = &cdc_acm_uart0;
zephyr,shell-uart = &cdc_acm_uart0;
};
}

Then add the following additional override for &zephyr_udc0 at the bottom of your board’s DTS file:

my_board.dts
&zephyr_udc0 {
cdc_acm_uart0: cdc_acm_uart0 {
compatible = "zephyr,cdc-acm-uart";
};
}

Because we set CONFIG_USB_DEVICE_INITIALIZE_AT_BOOT=n, we need to initialize the USB device manually. This can be done in the main() function. We’ll also then want to send some data to test the USB CDC is working. We’ll use the LOG_INF() macro to send data to the shell.

main.c
#include <zephyr/logging/log.h>
#include <zephyr/usb/usb_device.h>
LOG_MODULE_REGISTER(main, LOG_LEVEL_INF);
void preSerialFatalError(void) {
while (1) {
gpio_pin_toggle_dt(&l_userLedGpioSpec);
k_msleep(250);
}
}
int main(void)
{
int usbEnableRc = usb_enable(NULL);
if (usbEnableRc != 0) {
preSerialFatalError();
}
while (1)
{
LOG_INF("Hello, world!");
k_sleep(K_SECONDS(1));
}
return 0;
}

The official example waits for the DTR line to be asserted before sending data. In traditional serial communication, the DTR (data terminal ready) line is driven by Data Terminal Equipment (DTE) such as a computer to data communication equipment (DCE) such as a modem. The DTE uses it to signal it is ready to send data.1

Footnotes

  1. Wikipedia (2025, Mar 10). Data Terminal Ready [wiki]. Retrieved 2025-07-30, from https://en.wikipedia.org/wiki/Data_Terminal_Ready.