HomeAbout UsContact Us

Zephyr USB Device Stack: Implementing a Custom CDC-ACM Driver

By Jithin Tom
Published in Embedded OS
September 22, 2026
4 min read
Zephyr USB Device Stack: Implementing a Custom CDC-ACM Driver

Table Of Contents

01
Why Build a Custom CDC-ACM Driver?
02
USB Device Stack Overview in Zephyr
03
Step 1: Configure the USB Device Stack
04
Step 2: Define USB Descriptors
05
Step 3: Implement Class Request Handlers
06
Step 4: Manage Data and Notification Endpoints
07
Step 5: Class Configuration Registration and Boot Initialization
08
Testing and Protocol Validation
09
Common Pitfalls and Solutions
10
Summary
11
References
12
Related Reading
13
Frequently Asked Questions

USB device development in Zephyr RTOS frequently starts with the built-in CDC-ACM sample. However, production firmware architectures inevitably demand customizations beyond the generic sample: dedicated endpoint addresses to prevent collisions in composite devices, proprietary vendor-specific control transfers, optimized buffer depths for high-throughput telemetry, or tight coupling with application-layer circular ring buffers.

This guide explores the design and implementation of a bespoke CDC-ACM class driver within Zephyr’s USB device subsystem. We cover the core stack architecture, contiguous linker-section descriptor generation, class-specific control request dispatch, and non-blocking bulk/interrupt endpoint data pipelines.

+-----------------------------------------------------------------+
| Application Layer |
| (e.g., Serial Console, Custom Telemetry, RPC Dispatch) |
+-------------------------------+---------------------------------+
| API Calls (read / write / notify)
v
+-----------------------------------------------------------------+
| Custom CDC-ACM Class Driver Layer |
| - Class Setup Handlers (SET_LINE_CODING, SET_CONTROL_LINE) |
| - Data Endpoint Callbacks (bulk_out_cb, bulk_in_cb, int_in_cb)|
| - Descriptor Table (USBD_CLASS_DESCR_DEFINE / Packed Struct) |
| - Config Registration (USBD_DEFINE_CFG_DATA) |
+-------------------------------+---------------------------------+
| Interface & Endpoint Registration
v
+-----------------------------------------------------------------+
| USB Device Layer (UDL / Core) |
| - Standard Request Dispatch (GET_DESCRIPTOR, SET_ADDRESS) |
| - State Machine (Attached, Powered, Default, Configured) |
| - Linker-Section Descriptor Aggregation |
+-------------------------------+---------------------------------+
| Low-level USB Device Controller API
v
+-----------------------------------------------------------------+
| USB Device Controller Driver (DCD) |
| - Hardware EP FIFOs / Packet Buffers (e.g., STM32 OTG_FS/HS) |
| - USB ISR, Token Handling, Protocol Handshaking |
+-------------------------------+---------------------------------+
| DP / DM Differential Pair
v
[ Physical USB Host / PC ]

Why Build a Custom CDC-ACM Driver?

Zephyr’s built-in CDC-ACM class driver (CONFIG_USB_DEVICE_CLASS_CDC_ACM) works out of the box for standard virtual COM ports backed by the Zephyr UART driver API (zephyr,cdc-acm-uart). However, production engineering frequently encounters requirements that standard middleware cannot accommodate:

  • Non-Standard & Composite Endpoint Mapping: Integrating CDC-ACM alongside USB HID, DFU, or Mass Storage on controllers with constrained endpoint layouts (e.g., STM32 OTG FS with only 4 bidirectional endpoints) requires precise control over endpoint addresses and FIFO allocations.
  • Vendor-Specific Control Requests: Handling custom commands over Endpoint 0 (such as factory calibration, key provisioning, or bootloader invocation) within the same interface.
  • Zero-Copy & High-Throughput Pipelines: Bypassing the intermediate Zephyr UART ring-buffer abstraction to pipe raw USB packets directly into DMA memory or hardware queues.
  • Custom Line State Notifications: Dynamically tracking host DTR/RTS assertions to trigger instant hardware resets or power-state transitions without polling.

USB Device Stack Overview in Zephyr

Zephyr separates USB functionality into distinct architectural layers:

  1. USB Device Controller Driver (DCD): Hardware-specific driver interacting with USB controller registers (e.g., usb_dc_stm32.c, usb_dc_nrfx.c). It handles physical bus signaling, endpoint interrupts, FIFO read/writes, and bus resets.
  2. USB Device Layer (UDL / Core): Central coordinator (subsys/usb/device/usb_device.c, usb_descriptor.c). It tracks USB bus states (Default, Addressed, Configured), parses standard Chapter 9 requests (GET_DESCRIPTOR, SET_CONFIGURATION), and concatenates class descriptors into a cohesive configuration payload.
  3. Class Driver: Implements USB class specifications (CDC-ACM, HID, Mass Storage). It registers descriptors, handles class-specific control transfers, and manages endpoint FIFOs.
  4. Application Layer: Consumes the class driver interface to stream payload data and handle connection lifecycle events.

CDC-ACM Topology and Descriptors

The USB Communication Device Class (CDC) Abstract Control Model (ACM) requires two distinct interfaces grouped by an Interface Association Descriptor (IAD):

+-------------------------------------------------------------+
| Device Descriptor (Kconfig/Core) |
| bDeviceClass=0xEF, SubClass=0x02, Protocol=0x01 |
| (USB Miscellaneous / IAD) |
+------------------------------+------------------------------+
|
v
+-------------------------------------------------------------+
| Configuration Descriptor Header |
| wTotalLength = Config Hdr + Sum(Classes) |
+------------------------------+------------------------------+
|
v
+-------------------------------------------------------------+
| Interface Association Descriptor (IAD) |
| bFirstInterface=0, bInterfaceCount=2, FunctionClass=CDC |
+------------------------------+------------------------------+
| |
v v
+------------------------------+ +------------------------------+
| Communication Interface (IF0)| | Data Interface (IF1) |
| Class=0x02 (CDC Control) | | Class=0x0A (CDC Data) |
| - Header Functional Descr | | - Bulk IN Endpoint (0x82) |
| - Call Management Descr | | - Bulk OUT Endpoint (0x02) |
| - ACM Functional Descr | | wMaxPacketSize = 64 bytes |
| - Union Functional Descr | +------------------------------+
| - Interrupt IN EP (0x81) |
| wMaxPacketSize = 16 bytes |
+------------------------------+
  • Communication Interface (Interface 0): Uses an Interrupt IN endpoint to push asynchronous serial state notifications (e.g., carrier detect, break, ring) to the host. Contains CDC functional descriptors describing call management and abstract control capabilities.
  • Data Interface (Interface 1): Uses a pair of Bulk endpoints (Bulk IN for device-to-host transmission; Bulk OUT for host-to-device reception) for high-bandwidth raw data transfers.

Step 1: Configure the USB Device Stack

When implementing a custom class driver, enable the core USB stack in prj.conf while explicitly omitting CONFIG_USB_DEVICE_CLASS_CDC_ACM. If CONFIG_USB_DEVICE_CLASS_CDC_ACM is enabled, Zephyr compiles the built-in CDC-ACM driver, resulting in duplicate endpoint bindings and symbol collisions.

# Core USB device stack configuration
CONFIG_USB_DEVICE_STACK=y
CONFIG_USB_DEVICE_VID=0x2FE3
CONFIG_USB_DEVICE_PID=0x0100
CONFIG_USB_DEVICE_MANUFACTURER="embeddedSoft"
CONFIG_USB_DEVICE_PRODUCT="Zephyr Custom CDC-ACM"
CONFIG_USB_DEVICE_SN="0001"
CONFIG_USB_COMPOSITE_DEVICE=y
CONFIG_USB_DEVICE_MAX_POWER=100
# Compiler and memory requirements
CONFIG_MAIN_STACK_SIZE=2048

Zephyr’s USB core uses these Kconfig parameters to generate the standard Device Descriptor and string descriptors automatically.


Step 2: Define USB Descriptors

In Zephyr’s legacy USB stack, descriptors defined by class drivers must be gathered into a linker section so the core stack can assemble the full Configuration Descriptor. Rather than maintaining dynamic arrays of pointers, Zephyr expects class drivers to define a single, contiguous, packed structure containing all class and endpoint descriptors.

The USBD_CLASS_DESCR_DEFINE(primary, 0) macro places this packed structure into the .usb.descriptor_primary.1.0 section. At link time, all registered class structures are concatenated between __usb_descriptor_start and __usb_descriptor_end.

#include <zephyr/kernel.h>
#include <zephyr/init.h>
#include <zephyr/usb/usb_device.h>
#include <zephyr/usb/class/usb_cdc.h>
#include <zephyr/sys/byteorder.h>
/* Complete packed descriptor structure for CDC-ACM */
struct custom_cdc_acm_descriptor {
struct usb_association_descriptor iad;
struct usb_if_descriptor if0;
struct usb_cdc_header_descriptor cdc_header;
struct usb_cdc_cm_descriptor cdc_cm;
struct usb_cdc_acm_descriptor cdc_acm;
struct usb_cdc_union_descriptor cdc_union;
struct usb_ep_descriptor ep_notif;
struct usb_if_descriptor if1;
struct usb_ep_descriptor ep_data_in;
struct usb_ep_descriptor ep_data_out;
} __packed;
/* Register descriptors into the primary descriptor linker section */
USBD_CLASS_DESCR_DEFINE(primary, 0)
static const struct custom_cdc_acm_descriptor custom_cdc_cfg = {
/* Interface Association Descriptor (IAD) */
.iad = {
.bLength = sizeof(struct usb_association_descriptor),
.bDescriptorType = USB_DESC_INTERFACE_ASSOC,
.bFirstInterface = 0,
.bInterfaceCount = 2,
.bFunctionClass = USB_BCC_CDC_CONTROL,
.bFunctionSubClass = ACM_SUBCLASS,
.bFunctionProtocol = 0,
.iFunction = 0,
},
/* Communication Interface (Interface 0) */
.if0 = {
.bLength = sizeof(struct usb_if_descriptor),
.bDescriptorType = USB_DESC_INTERFACE,
.bInterfaceNumber = 0,
.bAlternateSetting = 0,
.bNumEndpoints = 1,
.bInterfaceClass = USB_BCC_CDC_CONTROL,
.bInterfaceSubClass = ACM_SUBCLASS,
.bInterfaceProtocol = 0,
.iInterface = 0,
},
/* CDC Header Functional Descriptor */
.cdc_header = {
.bFunctionLength = sizeof(struct usb_cdc_header_descriptor),
.bDescriptorType = USB_DESC_CS_INTERFACE,
.bDescriptorSubtype = HEADER_FUNC_DESC,
.bcdCDC = sys_cpu_to_le16(USB_SRN_1_20),
},
/* CDC Call Management Functional Descriptor */
.cdc_cm = {
.bFunctionLength = sizeof(struct usb_cdc_call_mgmt_descriptor),
.bDescriptorType = USB_DESC_CS_INTERFACE,
.bDescriptorSubtype = CALL_MANAGEMENT_FUNC_DESC,
.bmCapabilities = 0x00, /* Device does not manage calls */
.bDataInterface = 1,
},
/* CDC ACM Functional Descriptor */
.cdc_acm = {
.bFunctionLength = sizeof(struct usb_cdc_acm_descriptor),
.bDescriptorType = USB_DESC_CS_INTERFACE,
.bDescriptorSubtype = ACM_FUNC_DESC,
.bmCapabilities = 0x02, /* Supports line coding and control state */
},
/* CDC Union Functional Descriptor */
.cdc_union = {
.bFunctionLength = sizeof(struct usb_cdc_union_descriptor),
.bDescriptorType = USB_DESC_CS_INTERFACE,
.bDescriptorSubtype = UNION_FUNC_DESC,
.bControlInterface = 0,
.bSubordinateInterface0 = 1,
},
/* Interrupt IN Notification Endpoint (0x81) */
.ep_notif = {
.bLength = sizeof(struct usb_ep_descriptor),
.bDescriptorType = USB_DESC_ENDPOINT,
.bEndpointAddress = 0x81,
.bmAttributes = USB_DC_EP_INTERRUPT,
.wMaxPacketSize = sys_cpu_to_le16(16),
.bInterval = 10, /* 10 ms polling interval */
},
/* Data Interface (Interface 1) */
.if1 = {
.bLength = sizeof(struct usb_if_descriptor),
.bDescriptorType = USB_DESC_INTERFACE,
.bInterfaceNumber = 1,
.bAlternateSetting = 0,
.bNumEndpoints = 2,
.bInterfaceClass = USB_BCC_CDC_DATA,
.bInterfaceSubClass = 0,
.bInterfaceProtocol = 0,
.iInterface = 0,
},
/* Bulk IN Endpoint (0x82) */
.ep_data_in = {
.bLength = sizeof(struct usb_ep_descriptor),
.bDescriptorType = USB_DESC_ENDPOINT,
.bEndpointAddress = 0x82,
.bmAttributes = USB_DC_EP_BULK,
.wMaxPacketSize = sys_cpu_to_le16(64),
.bInterval = 0,
},
/* Bulk OUT Endpoint (0x02) */
.ep_data_out = {
.bLength = sizeof(struct usb_ep_descriptor),
.bDescriptorType = USB_DESC_ENDPOINT,
.bEndpointAddress = 0x02,
.bmAttributes = USB_DC_EP_BULK,
.wMaxPacketSize = sys_cpu_to_le16(64),
.bInterval = 0,
},
};

[!IMPORTANT] The __packed attribute is mandatory. Without it, compiler structure padding adds alignment bytes between descriptors, corrupting the serial byte stream parsed by the USB host controller and causing immediate enumeration stalls.


Step 3: Implement Class Request Handlers

When the USB host issues class-specific requests to Interface 0, the USB core routes them to the registered class_handler callback.

The CDC-ACM specification mandates handling:

  • SET_LINE_CODING (0x20): Host pushes baud rate, parity, stop bits, and data bits.
  • GET_LINE_CODING (0x21): Host queries current line parameters.
  • SET_CONTROL_LINE_STATE (0x22): Host asserts DTR (Data Terminal Ready) and RTS (Ready to Send) control lines.
/* Driver internal state */
static struct cdc_acm_line_coding line_coding = {
.dwDTERate = sys_cpu_to_le32(115200),
.bCharFormat = USB_CDC_1_STOP_BITS,
.bParityType = USB_CDC_NO_PARITY,
.bDataBits = 8,
};
static uint8_t line_state_dtr;
static uint8_t line_state_rts;
static int cdc_acm_class_handle_req(struct usb_setup_packet *setup,
int32_t *len, uint8_t **data)
{
/* Verify request targets Interface 0 (CDC Control Interface) */
if (setup->wIndex != 0) {
return -EINVAL;
}
switch (setup->bRequest) {
case SET_LINE_CODING:
if (*len < sizeof(struct cdc_acm_line_coding)) {
return -EINVAL;
}
memcpy(&line_coding, *data, sizeof(struct cdc_acm_line_coding));
break;
case GET_LINE_CODING:
*data = (uint8_t *)&line_coding;
*len = sizeof(struct cdc_acm_line_coding);
break;
case SET_CONTROL_LINE_STATE:
line_state_dtr = (setup->wValue & USB_CDC_LINE_CTRL_DTR) ? 1 : 0;
line_state_rts = (setup->wValue & USB_CDC_LINE_CTRL_RTS) ? 1 : 0;
break;
default:
return -ENOTSUP;
}
return 0;
}

Step 4: Manage Data and Notification Endpoints

Endpoint callbacks fire when an IN transfer completes or when OUT data has landed in the controller FIFO.

#define CDC_ACM_BULK_EP_MPS 64
static uint8_t rx_buffer[CDC_ACM_BULK_EP_MPS];
/* Callback triggered when the host sends data to Bulk OUT (0x02) */
static void bulk_out_cb(uint8_t ep, enum usb_dc_ep_cb_status_code ep_status)
{
uint32_t bytes_read = 0;
if (ep_status != USB_DC_EP_DATA_OUT) {
return;
}
/* Read packet from endpoint FIFO */
if (usb_read(ep, rx_buffer, sizeof(rx_buffer), &bytes_read) == 0 && bytes_read > 0) {
/* Forward received bytes to application pipeline */
}
}
/* Callback triggered when device data has been flushed to the host on Bulk IN (0x82) */
static void bulk_in_cb(uint8_t ep, enum usb_dc_ep_cb_status_code ep_status)
{
if (ep_status != USB_DC_EP_DATA_IN) {
return;
}
/* Ready to transmit next chunk or signal TX completion semaphore */
}
/* Callback triggered when serial state notification finishes on Interrupt IN (0x81) */
static void int_in_cb(uint8_t ep, enum usb_dc_ep_cb_status_code ep_status)
{
if (ep_status != USB_DC_EP_DATA_IN) {
return;
}
}
/* Transmit payload data to the host */
int custom_cdc_acm_write(const uint8_t *data, uint32_t len)
{
uint32_t bytes_written = 0;
return usb_write(0x82, data, len, &bytes_written);
}

Step 5: Class Configuration Registration and Boot Initialization

To connect endpoint callbacks, request handlers, and descriptors to Zephyr’s USB core, instantiate USBD_DEFINE_CFG_DATA. Zephyr collects this data at link time and invokes the callbacks during the enumeration cycle:

/* Map endpoints to callbacks */
static struct usb_ep_cfg_data cdc_acm_ep_data[] = {
{
.ep_cb = bulk_out_cb,
.ep_addr = 0x02,
},
{
.ep_cb = bulk_in_cb,
.ep_addr = 0x82,
},
{
.ep_cb = int_in_cb,
.ep_addr = 0x81,
},
};
/* Register class configuration structure with the core USB stack */
USBD_DEFINE_CFG_DATA(custom_cdc_acm_config) = {
.usb_device_description = NULL,
.interface_descriptor = (void *)&custom_cdc_cfg.if0,
.cb_usb_status = NULL,
.interface = {
.class_handler = cdc_acm_class_handle_req,
.custom_handler = NULL,
.vendor_handler = NULL,
},
.num_endpoints = ARRAY_SIZE(cdc_acm_ep_data),
.endpoint = cdc_acm_ep_data,
};
/* Driver initialization routine */
int custom_cdc_acm_init(void)
{
/* Enables controller hardware and attaches to the USB bus */
return usb_enable(NULL);
}

[!NOTE] Zephyr USB Stack Evolution: Zephyr maintains two USB device stack architectures. The implementation detailed above targets the widely deployed legacy USB device stack (CONFIG_USB_DEVICE_STACK=y). In Zephyr v3.4+, the project introduced the next-generation USB device stack (CONFIG_USB_DEVICE_STACK_NEXT=y, using usbd_context and dynamic class instantiation). While the next-generation stack structures class instantiation via devicetree and runtime class contexts, the underlying USB CDC-ACM functional descriptor architecture and SETUP request protocol remain identical.


Testing and Protocol Validation

  1. Host Bus Enumeration: Connect the board to a Linux host machine and monitor kernel logs:

    dmesg -w

    Confirm that the kernel matches the device using the cdc_acm driver:

    usb 1-1: new full-speed USB device number 14 using xhci_hcd
    usb 1-1: New USB device found, idVendor=2fe3, idProduct=0100
    usb 1-1: Product: Zephyr Custom CDC-ACM
    usb 1-1: Manufacturer: embeddedSoft
    cdc_acm 1-1:1.0: ttyACM0: USB ACM device
  2. Descriptor Verification: Use lsusb to confirm the descriptor tree and endpoint attributes:

    lsusb -v -d 2fe3:0100

    Verify that bNumInterfaces equals 2 and both Bulk endpoints report a wMaxPacketSize of 64 bytes.

  3. Serial Terminal Communication: Open the virtual serial terminal at 115200 baud:

    picocom -b 115200 /dev/ttyACM0
  4. Hardware Bus Analysis: For deep diagnostics, connect an external USB hardware protocol analyzer (e.g., Total Phase Beagle USB 480) or inspect packets in Wireshark via usbmon to inspect packet handshakes and SETUP tokens.


Common Pitfalls and Solutions

  • Missing __packed Attribute on Descriptors: If descriptors are declared without __packed, compiler alignment rules pad 1-byte and 2-byte fields (such as bLength and bcdCDC), misaligning the descriptor layout and failing host enumeration.
  • Zero-Length Packet (ZLP) Deadlocks: When sending a payload that is an exact multiple of the bulk endpoint’s wMaxPacketSize (64 bytes), the host controller waits indefinitely for additional data. Transmit an explicit Zero-Length Packet (usb_write(0x82, NULL, 0, NULL)) to terminate the transfer.
  • Neglecting Endpoint Directions: In USB descriptor definitions, IN endpoints must have bit 7 set (e.g., 0x81, 0x82), whereas OUT endpoints must have bit 7 cleared (e.g., 0x02). Confusing these directions leads to endpoint initialization failures within the DCD.
  • SETUP Request Interface Index Validation: Always validate setup->wIndex in class_handler. In composite USB devices, requests for unrelated interfaces (e.g., HID or Mass Storage) must be ignored or forwarded to prevent state corruption.

Summary

Implementing a bespoke CDC-ACM driver in Zephyr RTOS provides complete ownership of endpoint allocations, descriptor layout, and control transfers while leveraging Zephyr’s hardware abstraction layer. By structuring descriptors as packed linker-section definitions, managing request dispatch with custom handlers, and streaming through bulk endpoint callbacks, you can build reliable, high-performance USB communications tailored precisely to your embedded system requirements.


References

  1. Zephyr USB Device Stack Legacy API Documentation
  2. Zephyr Next-Generation USB Device Stack Documentation
  3. Universal Serial Bus Class Definitions for Communication Devices v1.2
  4. STMicroelectronics UM1734: STM32Cube USB Device Library User Manual
  5. Total Phase Beagle USB 480 Protocol Analyzer
  6. Wireshark USB Packet Capture Setup Guide

Frequently Asked Questions

What is the USB CDC-ACM class and why use it?

The USB CDC-ACM (Communication Device Class Abstract Control Model) class emulates a virtual serial communications port over USB, making it ideal for debug consoles, telemetry streams, and device configuration without requiring custom host-side drivers.

How do you configure the USB device stack in Zephyr for a custom CDC-ACM driver?

Enable the core USB device stack in prj.conf, omit the built-in CDC-ACM class driver to prevent endpoint and descriptor collisions, define a packed descriptor structure placed in the primary descriptor linker section via USBD_CLASS_DESCR_DEFINE, and register configuration data using USBD_DEFINE_CFG_DATA.

What are the key steps to handle USB requests and data transfer in the driver?

Implement a class request handler to parse SETUP packets (SET_LINE_CODING, GET_LINE_CODING, SET_CONTROL_LINE_STATE), configure interrupt IN and bulk IN/OUT endpoint callbacks, and invoke usb_read and usb_write for bidirectional communication.

Tags

zephyrusbcdc-acmdevice-driver

Share


Previous Article
Fixing Zephyr BMI160 I2C Timeout on STM32
Jithin Tom

Jithin Tom

A Closer Look at C/C++, RTOS, and Embedded Systems

Related Posts

STM32 Zephyr Kernel Panic Debugging: Causes and Fixes
STM32 Zephyr Kernel Panic Debugging: Causes and Fixes
September 02, 2026
5 min
© 2026, All Rights Reserved.
Powered By Netlyft

Quick Links

Advertise with usAbout UsContact Us

Social Media