
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 Registrationv+-----------------------------------------------------------------+| 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 APIv+-----------------------------------------------------------------+| 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 Pairv[ Physical USB Host / PC ]
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:
Zephyr separates USB functionality into distinct architectural layers:
usb_dc_stm32.c, usb_dc_nrfx.c). It handles physical bus signaling, endpoint interrupts, FIFO read/writes, and bus resets.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.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 |+------------------------------+
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 configurationCONFIG_USB_DEVICE_STACK=yCONFIG_USB_DEVICE_VID=0x2FE3CONFIG_USB_DEVICE_PID=0x0100CONFIG_USB_DEVICE_MANUFACTURER="embeddedSoft"CONFIG_USB_DEVICE_PRODUCT="Zephyr Custom CDC-ACM"CONFIG_USB_DEVICE_SN="0001"CONFIG_USB_COMPOSITE_DEVICE=yCONFIG_USB_DEVICE_MAX_POWER=100# Compiler and memory requirementsCONFIG_MAIN_STACK_SIZE=2048
Zephyr’s USB core uses these Kconfig parameters to generate the standard Device Descriptor and string descriptors automatically.
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
__packedattribute 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.
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;}
Endpoint callbacks fire when an IN transfer completes or when OUT data has landed in the controller FIFO.
#define CDC_ACM_BULK_EP_MPS 64static 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);}
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, usingusbd_contextand 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.
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_hcdusb 1-1: New USB device found, idVendor=2fe3, idProduct=0100usb 1-1: Product: Zephyr Custom CDC-ACMusb 1-1: Manufacturer: embeddedSoftcdc_acm 1-1:1.0: ttyACM0: USB ACM device
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.
Serial Terminal Communication: Open the virtual serial terminal at 115200 baud:
picocom -b 115200 /dev/ttyACM0
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.
__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.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.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->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.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.
Quick Links
Legal Stuff




