USB Serial Console Output in a ZephyrRTOS application

"The CDC ACM class is used as backend for different subsystems in Zephyr. However, its configuration may not be easy for the inexperienced user. Below is a description of the different use cases and some pitfalls." - Zephyr's USB device stack guide.

Over the last few months, I've been getting up to speed on ZephyrRTOS, a small, real-time operating system for resource-constrained devices, like microcontrollers.

I started looking into Zephyr because a customer of Blues expressed interest in using Zephyr with the Swan MCU we make, so I decided to see what it would take to add support. Since the Swan is based on the STM32L4R5, support was fairly straightforward and Swan will officially support Zephyr in the upcoming v3.2.0 release.

When I say "fairly straightforward," I mean that it was easy enough once I figured out Zephyr's unique approach to architecture, device family, and device support. Inspired by the Linux ecosystem, Zephyr makes heavy use of Kconfig and devicetree for its configuration semantics. A little bit of a learning curve for this non-Linux pro, but I got there.

So what does all of this have to do with adding USB Serial console output to a Zephyr application?

Because Zephyr is configuration-driven and focused on being small, every feature is opt-in. Very little comes along "for free" in a simple application. If you want access to GPIOs, you have to configure your app for it. Same with accessing a UART, I2C, SPI, etc. For most features, this configuration isn't hard, but it does mean the learning-curve of Zephyr is higher than other ecosystems which tend to pack in features by default for the sake of an easier onboarding and rapid development experience.

One of the features I had a bit of a time getting working was printing to a USB serial console from a Zephyr application. Perhaps I have been spoiled by the Arduino world, but as I was working to put together samples for using Zephyr with the Notecard, I found myself using the old pattern of using print statements to provide output of my running application.

Serial.println() is how we debug right? right?!?

And while Zephyr has a ton samples including some on using the USB subsystem, I had to mix and match a few samples in order to get something working. The project source is here if you want to get right to it and skip over my step-by-step below.

First, Configuration

Before I am able to use any USB UART features in my application, I had to create a couple of configuration files. The first is an app.overlay. Overlay files are an app-specific extension of a devicetree configuration, which is a hierarchical data structure that defines the hardware available on a given board. When defining support for a board, devicetree is the method used for specifying GPIOs available, buses, peripherals, and the like. Overlay files are an extension of a board's devicetree for enabling a hardware feature that's disabled by default, or configuring an app-specific sensor.

In my case, the feature I want to enable is the Communication Device Class for USB UART using the Abstract Control Module, aka CDC ACM UART. Acronym salad FTW.

/ {
    chosen {
        zephyr,console = &cdc_acm_uart0;
    };
};

&zephyr_udc0 {
    cdc_acm_uart0: cdc_acm_uart0 {
    	compatible = "zephyr,cdc-acm-uart";
        label = "CDC_ACM_0";
    };
};

With my app.overlay in place, the next thing I need to create is a Kconfig file and a prj.conf both of which are part of the Kconfig system. Kconfig is a configuration system that allows the Zephyr kernel and sub-systems to be configured at build time, so my application binary only includes the features I need. According to the Zephyr docs, "The goal is to support configuration without having to change any source code." My Kconfig was pretty basic, just a few lines to set the USB PID for my application.

config USB_DEVICE_PID
    default USB_PID_CONSOLE_SAMPLE

source "Kconfig.zephyr"

Next, in my prj.conf file, I added a number of configurations to enable USB, specify my device Product and VID, and enable the UART console over USB Serial.

CONFIG_USB_DEVICE_STACK=y
CONFIG_USB_DEVICE_PRODUCT="Your Product Name"
CONFIG_USB_DEVICE_VID=4440

CONFIG_SERIAL=y
CONFIG_CONSOLE=y
CONFIG_UART_CONSOLE=y
CONFIG_UART_LINE_CTRL=y

Thankfully, that's all the config I needed.

Second, Code

With the configuration complete, it's time to write some C code. All of the snippets below can be found in the main.c of the sample linked above.

First, I need some includes, from the always-present zephyr.h to printk for writing to the console, to the essential USB and UART includes.

#include <zephyr.h>
#include <sys/printk.h>
#include <sys/util.h>
#include <string.h>
#include <usb/usb_device.h>
#include <drivers/uart.h>

Next, I added a build assertion to make sure that I have an overlay in place and I don't accidentally build or flash a binary without USB Serial support. If Zephyr's west build tool doesn't find an overlay with zephyr,cdc_acm_uart defined, the build will fail.

/*
 * Ensure that an overlay for USB serial has been defined.
 */
BUILD_ASSERT(DT_NODE_HAS_COMPAT(DT_CHOSEN(zephyr_console), 			zephyr_cdc_acm_uart), "Console device is not ACM CDC UART device");

Next, I initialized a USB device object from the zephyr,console object I defined in the chosen section of my app.overlay.


const struct device *usb_dev = DEVICE_DT_GET(DT_CHOSEN(zephyr_console));
uint32_t dtr = 0;

if (usb_enable(NULL)) {
    return;
}

From here, I have two options for connecting to the USB Serial device. The first is to sleep for a few seconds to allow the developer to connect to a terminal and connect.

// Sleep to wait for a terminal connection.
k_sleep(K_MSEC(2500));
uart_line_ctrl_get(usb_dev, UART_LINE_CTRL_DTR, &dtr);

The other is to wait for the developer to connect to a terminal connect right away.

while (!dtr) {
    uart_line_ctrl_get(usb_dev, UART_LINE_CTRL_DTR, &dtr);
    k_sleep(K_MSEC(100));
}

These approaches may seem the same, but the second won't continue execution of your main application until you connect a terminal. An important distinction if you forget you've done this, run your device without connecting a terminal and wonder why nothing is happening.

Finally, it's time to print to USB Serial. Here, in my forever loop, I'm just printing a simple message and sleeping for two seconds.

while (1) {
    printk("Hello from USB Serial...\n");
    k_sleep(K_MSEC(2000));
}

And that's it. After I've done a west build and west flash I can connect to my device using a command like minicom --device /dev/ttyACM0 for your specific serial device and I'll see output every two seconds...

Hello from USB Serial...
Hello from USB Serial...
Hello from USB Serial...

It's a bit of work to enable a feature that one might take fro granted in another ecosystem, but given that Zephyr is optimized for creating compact, production-quality embedded applications, I understand and agree with the approach.

Brandon Satrom

Brandon Satrom