Series Context: This is Part 6 of the 17-part USB Development Mastery series. Part 5 covered the TinyUSB stack architecture and porting. Now we implement our first real USB device class: CDC ACM — the class behind every USB-to-serial converter you have ever plugged in.
1
USB Fundamentals
USB system architecture, transfer types, host/device model, protocol stack
2
Electrical & Hardware Layer
D+/D- signalling, pull-ups, connectors, USB-C, STM32 USB peripherals
3
Protocol & Enumeration
Enumeration sequence, USB packets, descriptors, endpoint concepts
4
USB Device Classes
HID, CDC, MSC, MIDI, Audio, composite devices, vendor class
5
TinyUSB Deep Dive
Stack architecture, execution model, STM32 integration, descriptor callbacks
6
CDC Virtual COM Port
CDC class, bulk transfers, printf over USB, baud rate handling
You Are Here
7
HID Devices
HID descriptors, report format, keyboard/mouse/gamepad implementation
8
USB Mass Storage
MSC class, SCSI commands, FATFS integration, RAM disk
9
Composite Devices
Multiple classes, IAD descriptor, CDC+HID, CDC+MSC
10
Debugging USB
Wireshark capture, protocol analyser, enumeration debugging, common failures
11
RTOS + USB Integration
FreeRTOS + TinyUSB, task priorities, thread-safe communication
12
Advanced USB Topics
Host mode, OTG, isochronous, USB audio, USB video
13
Performance & Optimisation
DMA, zero-copy buffers, throughput maximisation, latency tuning
14
Custom USB Class Drivers
Vendor class, writing descriptors, OS driver interaction
15
Bare-Metal USB
Direct register programming, writing USB stack from scratch, PHY timing
16
Security in USB
BadUSB attacks, device authentication, secure firmware, USB firewall
17
USB Hardware Design
PCB layout, differential pairs, impedance matching, EMI, USB-C PD
CDC Class Architecture
The Communications Device Class (CDC) is one of the oldest USB device classes, defined in USB CDC specification 1.2. Within CDC, the Abstract Control Model (ACM) subclass is the one that emulates a traditional RS-232 serial port. When a host sees a CDC ACM device, it loads a built-in driver (no INF file, no manual installation) and presents the device as a COM port or tty device.
The Two-Interface Pairing
CDC ACM requires two separate USB interfaces that always appear together:
- Communications Interface — carries the control path. Uses bInterfaceClass=0x02 (CDC Communications), bInterfaceSubClass=0x02 (ACM), bInterfaceProtocol=0x00 (No specific protocol). This interface owns the mandatory EP0 control pipe and an optional Interrupt IN notification endpoint (used to report serial state changes like DCD, DSR).
- Data Interface — carries the actual data payload. Uses bInterfaceClass=0x0A (CDC Data), bInterfaceSubClass=0x00, bInterfaceProtocol=0x00. This interface owns one Bulk IN and one Bulk OUT endpoint for bidirectional data flow.
This separation dates back to the original CDC spec's intent to support telephone modems: the control interface would manage line state and AT commands while the data interface would carry the voice/data payload. For a simple serial port, the notification endpoint is optional (most implementations omit it) and the data interface does all the heavy lifting.
IAD Requirement for Composite Devices
When your device is a composite (e.g., CDC + HID), the host needs to know that these two interfaces form a single logical function. That is the job of the Interface Association Descriptor (IAD). The IAD must appear immediately before the Communications Interface in the configuration descriptor. Key fields: bFunctionClass=0x02 (CDC), bFunctionSubClass=0x02 (ACM), bFunctionProtocol=0x00, bFirstInterface=first interface number, bInterfaceCount=2.
CDC Functional Descriptors
Inside the Communications Interface, before the endpoint descriptor, you must include a series of class-specific (functional) descriptors that describe the CDC capabilities:
/* ─── CDC Functional Descriptors ────────────────────────────────────────── */
/* 1. Header Functional Descriptor (required — marks start of CDC func descs) */
typedef struct {
uint8_t bLength; /* 5 */
uint8_t bDescriptorType; /* 0x24 = CS_INTERFACE */
uint8_t bDescriptorSubtype;/* 0x00 = Header */
uint16_t bcdCDC; /* 0x0120 = CDC spec 1.2 */
} __attribute__((packed)) cdc_header_desc_t;
/* 2. ACM Functional Descriptor — capabilities bitmap */
typedef struct {
uint8_t bLength; /* 4 */
uint8_t bDescriptorType; /* 0x24 = CS_INTERFACE */
uint8_t bDescriptorSubtype; /* 0x02 = ACM */
uint8_t bmCapabilities;
/* bit 0: device supports SET/GET_COMM_FEATURE (we set 0 — not supported)
bit 1: device supports SET/GET/CLEAR_LINE_CODING, SET_CONTROL_LINE_STATE,
SERIAL_STATE notification — set this bit to 1
bit 2: device supports SEND_BREAK
bit 3: device supports network connection notification */
} __attribute__((packed)) cdc_acm_desc_t;
/* 3. Union Functional Descriptor — binds control and data interfaces together */
typedef struct {
uint8_t bLength; /* 5 */
uint8_t bDescriptorType; /* 0x24 = CS_INTERFACE */
uint8_t bDescriptorSubtype; /* 0x06 = Union */
uint8_t bMasterInterface; /* Communications interface number */
uint8_t bSlaveInterface0; /* Data interface number */
} __attribute__((packed)) cdc_union_desc_t;
/* 4. Call Management Functional Descriptor (mandatory for ACM) */
typedef struct {
uint8_t bLength; /* 5 */
uint8_t bDescriptorType; /* 0x24 = CS_INTERFACE */
uint8_t bDescriptorSubtype; /* 0x01 = Call Management */
uint8_t bmCapabilities;
/* bit 0: device handles call management itself (0 = host does it)
bit 1: device can send/receive call management data over data class
interface (set to 0 for a simple CDC ACM device) */
uint8_t bDataInterface; /* Data interface number */
} __attribute__((packed)) cdc_call_mgmt_desc_t;
Understanding these four functional descriptors is important because they tell the host exactly what control requests your firmware is prepared to handle. The bmCapabilities field in the ACM descriptor must have bit 1 set if you intend to handle SET_LINE_CODING and SET_CONTROL_LINE_STATE — which is almost always the case.
| Descriptor | bDescriptorSubtype | Role |
| Header | 0x00 | Marks start of CDC functional descriptor block; declares CDC spec version |
| Call Management | 0x01 | States whether device or host handles call management; references data interface |
| ACM | 0x02 | Declares supported control requests (line coding, line state, break) |
| Union | 0x06 | Associates communications interface with its companion data interface |
TinyUSB CDC Configuration
Enabling CDC in TinyUSB requires exactly one configuration macro and a correctly built descriptor table. Let us walk through every piece in order.
tusb_config.h Settings
/* tusb_config.h — minimum CDC-only configuration */
#ifndef _TUSB_CONFIG_H_
#define _TUSB_CONFIG_H_
/* ─── Board / MCU ─────────────────────────────────────────────────────────── */
#define CFG_TUSB_MCU OPT_MCU_STM32F4 /* or OPT_MCU_RP2040, etc. */
#define CFG_TUSB_OS OPT_OS_NONE /* bare-metal cooperative */
#define CFG_TUSB_MEM_SECTION __attribute__((section(".usb_buf")))
#define CFG_TUSB_MEM_ALIGN __attribute__((aligned(4)))
/* ─── Debug ──────────────────────────────────────────────────────────────── */
#define CFG_TUSB_DEBUG 0 /* 0=off, 1=error, 2=warning, 3=all */
/* ─── Device stack ───────────────────────────────────────────────────────── */
#define CFG_TUD_ENDPOINT0_SIZE 64
/* ─── CDC class ──────────────────────────────────────────────────────────── */
#define CFG_TUD_CDC 1 /* number of CDC ports */
/* RX buffer: bytes the stack buffers while waiting for tud_cdc_read().
Keep as a power of 2; 512 is comfortable for most applications.
If you are streaming large data, increase to 1024 or 2048. */
#define CFG_TUD_CDC_RX_BUFSIZE 512
/* TX buffer: bytes of outgoing data the stack can hold before blocking.
Must be >= the largest single write you ever attempt in one call.
Increase if you are using printf() with large format strings. */
#define CFG_TUD_CDC_TX_BUFSIZE 512
/* EP size: 64 for Full-Speed, 512 for High-Speed */
#define CFG_TUD_CDC_EP_BUFSIZE 64
#endif /* _TUSB_CONFIG_H_ */
Complete Descriptor Arrays
/* usb_descriptors.c — CDC-only device, no composite, no IAD needed */
#include "tusb.h"
/* ─── Device Descriptor ──────────────────────────────────────────────────── */
tusb_desc_device_t const desc_device = {
.bLength = sizeof(tusb_desc_device_t),
.bDescriptorType = TUSB_DESC_DEVICE,
.bcdUSB = 0x0200, /* USB 2.0 */
/* For a pure CDC-only device (no composite), set class codes here.
If composite (CDC+HID), set 0xEF/0x02/0x01 for IAD-based multi-function. */
.bDeviceClass = TUSB_CLASS_CDC, /* 0x02 */
.bDeviceSubClass = 0x00,
.bDeviceProtocol = 0x00,
.bMaxPacketSize0 = CFG_TUD_ENDPOINT0_SIZE,
.idVendor = 0xCafe, /* Replace with your VID */
.idProduct = 0x4001, /* Replace with your PID */
.bcdDevice = 0x0100, /* Device version 1.0 */
.iManufacturer = 0x01,
.iProduct = 0x02,
.iSerialNumber = 0x03,
.bNumConfigurations = 0x01
};
uint8_t const * tud_descriptor_device_cb(void) {
return (uint8_t const *)&desc_device;
}
/* ─── Configuration Descriptor ───────────────────────────────────────────── */
/* Total length: config(9) + IAD(8) + comm_iface(9) + header(5) +
call_mgmt(5) + acm(4) + union(5) + notif_ep(7) +
data_iface(9) + bulk_out(7) + bulk_in(7) = 75 bytes */
#define CONFIG_TOTAL_LEN TUD_CONFIG_DESC_LEN + TUD_CDC_DESC_LEN
/* Endpoint numbers — choose numbers that don't conflict with other classes */
#define EPNUM_CDC_NOTIF 0x81 /* EP1 IN — notification (interrupt) */
#define EPNUM_CDC_OUT 0x02 /* EP2 OUT — data bulk out */
#define EPNUM_CDC_IN 0x82 /* EP2 IN — data bulk in */
uint8_t const desc_fs_configuration[] = {
/* Configuration Descriptor */
TUD_CONFIG_DESCRIPTOR(
1, /* bConfigurationValue */
2, /* bNumInterfaces */
0, /* iConfiguration string index */
CONFIG_TOTAL_LEN,
TUSB_DESC_CONFIG_ATT_REMOTE_WAKEUP,
100 /* bMaxPower: 100 * 2mA = 200mA */
),
/* CDC Descriptor block — macro expands to IAD + both interfaces + endpoints */
TUD_CDC_DESCRIPTOR(
0, /* Communications interface number */
4, /* string index for CDC interface */
EPNUM_CDC_NOTIF, /* Notification EP (interrupt IN) */
8, /* Notification EP max packet size */
EPNUM_CDC_OUT, /* Data bulk OUT EP */
EPNUM_CDC_IN, /* Data bulk IN EP */
CFG_TUD_CDC_EP_BUFSIZE /* Bulk EP max packet size (64 for FS) */
)
};
uint8_t const * tud_descriptor_configuration_cb(uint8_t index) {
(void)index;
return desc_fs_configuration;
}
/* ─── String Descriptors ─────────────────────────────────────────────────── */
char const * string_desc_arr[] = {
(const char[]){ 0x09, 0x04 }, /* 0: Language ID — English (0x0409) */
"Wasil Zafar", /* 1: Manufacturer */
"CDC Serial Demo", /* 2: Product */
"CDC000001", /* 3: Serial Number */
"TinyUSB CDC ACM" /* 4: CDC Interface string */
};
static uint16_t _desc_str[32];
uint16_t const * tud_descriptor_string_cb(uint8_t index, uint16_t langid) {
(void)langid;
uint8_t chr_count;
if (index == 0) {
memcpy(&_desc_str[1], string_desc_arr[0], 2);
chr_count = 1;
} else {
if (index >= sizeof(string_desc_arr) / sizeof(string_desc_arr[0])) return NULL;
const char *str = string_desc_arr[index];
chr_count = (uint8_t)strlen(str);
if (chr_count > 31) chr_count = 31;
for (uint8_t i = 0; i < chr_count; i++) {
_desc_str[1 + i] = str[i]; /* Convert ASCII to UTF-16LE */
}
}
_desc_str[0] = (uint16_t)((TUSB_DESC_STRING << 8) | (2 * chr_count + 2));
return _desc_str;
}
Sending Data via CDC
TinyUSB's CDC TX API is a two-step process: write to the internal buffer, then flush. Understanding why the flush step exists is essential to avoiding a common bug where data sits in a buffer and never reaches the host.
Core TX API
| Function | Returns | Purpose |
tud_cdc_write(buf, bufsize) | uint32_t bytes_written | Copy data into the TX FIFO. May write fewer bytes than requested if the FIFO is full. |
tud_cdc_write_flush() | uint32_t bytes_flushed | Trigger actual USB transfer. You must call this after writing, or data stays buffered indefinitely. |
tud_cdc_write_available() | uint32_t bytes_free | Check how many bytes can currently be written without blocking. |
tud_cdc_write_clear() | void | Discard all pending TX data (useful on error recovery). |
Printf Retargeting via _write() Syscall
The most popular use of CDC is to retarget printf() from the standard output to USB. With GCC and newlib, you override the _write() syscall:
/* syscalls.c — retarget printf to TinyUSB CDC */
#include "tusb.h"
#include <string.h>
/* Called by newlib's printf/puts/fwrite when writing to stdout (fd=1) or
stderr (fd=2). We route both to CDC. */
int _write(int fd, char *ptr, int len) {
(void)fd;
if (!tud_cdc_connected()) {
/* Host port not open — drop the data to avoid blocking */
return len;
}
int written = 0;
while (written < len) {
/* Write as many bytes as the FIFO can accept right now */
uint32_t available = tud_cdc_write_available();
if (available == 0) {
/* FIFO is full — flush what we have and wait for space */
tud_cdc_write_flush();
/* Run the TinyUSB task so the transfer can complete */
tud_task();
continue;
}
uint32_t to_write = (uint32_t)(len - written);
if (to_write > available) to_write = available;
written += (int)tud_cdc_write(ptr + written, to_write);
}
tud_cdc_write_flush();
return len;
}
/* ─── Usage in main.c ──────────────────────────────────────────────────── */
int main(void) {
board_init();
tusb_init();
while (1) {
tud_task(); /* must be called regularly */
/* Wait until host opens the port (DTR goes high) */
if (tud_cdc_connected()) {
static uint32_t counter = 0;
static uint32_t last_ms = 0;
uint32_t now = board_millis();
if (now - last_ms >= 1000) {
last_ms = now;
printf("Uptime: %lu seconds\r\n", counter++);
}
}
}
}
Non-Blocking Write Pattern
/* Non-blocking CDC write — returns bytes actually sent */
uint32_t cdc_write_nonblocking(const uint8_t *data, uint32_t len) {
if (!tud_cdc_connected()) return 0;
uint32_t written = tud_cdc_write(data, len);
tud_cdc_write_flush();
return written;
}
/* Sending a formatted status line */
void send_status_message(const char *module, int code, const char *msg) {
char buf[128];
int n = snprintf(buf, sizeof(buf), "[%s] code=%d msg=%s\r\n", module, code, msg);
if (n > 0 && tud_cdc_connected()) {
tud_cdc_write(buf, (uint32_t)n);
tud_cdc_write_flush();
}
}
Critical Pitfall: Never call tud_cdc_write_flush() from within an interrupt handler. TinyUSB's buffers are managed in the task context. Calling flush from an ISR can corrupt the internal state. Always write data in the main task loop or in a thread-safe wrapper.
Receiving Data via CDC
TinyUSB delivers incoming CDC data through a receive FIFO. You can either poll the FIFO in your main loop or use the tud_cdc_rx_cb() callback for event-driven processing.
RX API
| Function | Returns | Purpose |
tud_cdc_available() | uint32_t | Number of bytes waiting in the RX FIFO |
tud_cdc_read(buf, bufsize) | uint32_t bytes_read | Remove bytes from the RX FIFO into your buffer |
tud_cdc_read_char() | int32_t (char or -1) | Read exactly one byte; returns -1 if FIFO empty |
tud_cdc_read_flush() | void | Discard all bytes currently in the RX FIFO |
tud_cdc_peek(ui8_p) | bool | Read the next byte without consuming it |
Callback-Driven Reception
/* tud_cdc_rx_cb() is a weak function declared in TinyUSB — override it
in your application code. It is called from within tud_task() when
new bytes arrive from the host. */
void tud_cdc_rx_cb(uint8_t itf) {
(void)itf; /* interface number — 0 for first CDC port */
/* Read all available bytes in one shot */
uint8_t buf[CFG_TUD_CDC_RX_BUFSIZE];
uint32_t count = tud_cdc_read(buf, sizeof(buf));
if (count == 0) return;
/* Route to your processing function */
cli_process_input(buf, count);
}
Complete Command Line Parser
A common pattern is to accumulate characters until a newline arrives, then parse the complete command. The following implementation handles backspace, echoes characters back to the terminal, and dispatches commands:
/* cli.c — USB CDC command line interface */
#include "tusb.h"
#include <string.h>
#include <stdio.h>
#define CLI_MAX_LINE 128
#define CLI_MAX_ARGS 8
static char s_line_buf[CLI_MAX_LINE];
static int s_line_len = 0;
static bool s_dtr_was_set = false;
/* Forward declarations */
static void cli_execute(char *line);
static void cdc_print(const char *str);
/* Called from tud_cdc_rx_cb() or polled from main loop */
void cli_process_input(const uint8_t *data, uint32_t len) {
for (uint32_t i = 0; i < len; i++) {
char c = (char)data[i];
if (c == '\r' || c == '\n') {
/* End of line — echo newline, null-terminate, execute */
cdc_print("\r\n");
s_line_buf[s_line_len] = '\0';
if (s_line_len > 0) {
cli_execute(s_line_buf);
}
s_line_len = 0;
cdc_print("$ "); /* prompt */
} else if (c == 0x7F || c == '\b') {
/* Backspace */
if (s_line_len > 0) {
s_line_len--;
cdc_print("\b \b"); /* erase character on terminal */
}
} else if (c >= 0x20 && c < 0x7F) {
/* Printable ASCII — echo and buffer */
if (s_line_len < CLI_MAX_LINE - 1) {
char echo[2] = {c, 0};
cdc_print(echo);
s_line_buf[s_line_len++] = c;
}
}
}
}
/* Simple tokenizer and command dispatcher */
static void cli_execute(char *line) {
/* Tokenise: split on spaces */
char *argv[CLI_MAX_ARGS];
int argc = 0;
char *tok = strtok(line, " \t");
while (tok && argc < CLI_MAX_ARGS) {
argv[argc++] = tok;
tok = strtok(NULL, " \t");
}
if (argc == 0) return;
if (strcmp(argv[0], "help") == 0) {
cdc_print("Commands: help, status, reset, led <on|off>, echo <text>\r\n");
} else if (strcmp(argv[0], "status") == 0) {
char buf[64];
snprintf(buf, sizeof(buf), "Uptime: %lu ms\r\n", board_millis());
cdc_print(buf);
} else if (strcmp(argv[0], "reset") == 0) {
cdc_print("Resetting...\r\n");
tud_cdc_write_flush();
board_reset();
} else if (strcmp(argv[0], "led") == 0 && argc == 2) {
if (strcmp(argv[1], "on") == 0) board_led_write(true);
else if (strcmp(argv[1], "off") == 0) board_led_write(false);
else cdc_print("Usage: led <on|off>\r\n");
} else if (strcmp(argv[0], "echo") == 0 && argc >= 2) {
cdc_print(argv[1]);
cdc_print("\r\n");
} else {
cdc_print("Unknown command. Type 'help'.\r\n");
}
}
static void cdc_print(const char *str) {
tud_cdc_write(str, strlen(str));
tud_cdc_write_flush();
}
/* tud_cdc_line_state_cb — detect when host opens the port */
void tud_cdc_line_state_cb(uint8_t itf, bool dtr, bool rts) {
(void)itf; (void)rts;
if (dtr && !s_dtr_was_set) {
/* Host just opened the port — send welcome banner */
cdc_print("\r\n=== USB CDC CLI ready ===\r\n$ ");
}
s_dtr_was_set = dtr;
}
Line Coding & Control Signals
CDC ACM carries two important control mechanisms that mirror classical RS-232: line coding (baud rate, data bits, stop bits, parity) and control line state (DTR, RTS). Understanding these is critical both for implementing a UART bridge and for simply knowing when a host application has opened the port.
Line Coding Callback
/* Called when the host sends SET_LINE_CODING (usually when you change baud
rate in a terminal program or call serial.open() in Python). */
void tud_cdc_line_coding_cb(uint8_t itf, cdc_line_coding_t const *p_line_coding) {
(void)itf;
uint32_t baud = p_line_coding->bit_rate;
uint8_t stop = p_line_coding->stop_bits; /* 0=1, 1=1.5, 2=2 stop bits */
uint8_t parity = p_line_coding->parity; /* 0=None,1=Odd,2=Even,3=Mark,4=Space */
uint8_t databits = p_line_coding->data_bits; /* 5, 6, 7, 8, or 16 */
/* For a pure USB device: baud rate doesn't affect USB transfer speed.
USB always sends at 12Mbps (Full-Speed) or 480Mbps (High-Speed).
However, for a CDC-to-UART bridge, you must reconfigure the UART. */
/* Log the change (for debugging) */
char buf[80];
snprintf(buf, sizeof(buf),
"Line coding: baud=%lu stop=%u parity=%u data=%u\r\n",
baud, stop, parity, databits);
/* Note: do NOT call tud_cdc_write() here — you are inside a callback.
Store the values and process them in the main task loop instead. */
(void)buf; /* suppress unused warning in this example */
}
/* cdc_line_coding_t fields summary */
/*
typedef struct TU_ATTR_PACKED {
uint32_t bit_rate; // 9600, 115200, 921600, etc. (irrelevant for USB speed)
uint8_t stop_bits; // 0=1 stop bit, 1=1.5 stop bits, 2=2 stop bits
uint8_t parity; // 0=None, 1=Odd, 2=Even, 3=Mark, 4=Space
uint8_t data_bits; // 5, 6, 7, 8, or 16
} cdc_line_coding_t;
*/
Control Line State Callback
/* DTR (Data Terminal Ready): set to true when host application opens the port.
This is the most reliable way to detect that someone is actually listening.
RTS (Request To Send): set by host to indicate it is ready to receive data.
Both bits are in the bmRequestType of SET_CONTROL_LINE_STATE.
TinyUSB parses this and calls this weak callback for you. */
static bool s_host_port_open = false;
void tud_cdc_line_state_cb(uint8_t itf, bool dtr, bool rts) {
(void)itf;
(void)rts; /* For most applications, only DTR matters */
if (dtr) {
/* Host opened the port — safe to start sending data */
s_host_port_open = true;
} else {
/* Host closed the port — stop sending to avoid buffer overflow */
s_host_port_open = false;
/* Optionally flush/discard any pending TX data */
tud_cdc_write_clear();
}
}
bool cdc_host_connected(void) {
return tud_cdc_connected() && s_host_port_open;
}
Why Baud Rate Doesn't Affect USB Speed
When you set 9600 baud in PuTTY connected to a USB CDC device, PuTTY sends a SET_LINE_CODING request over EP0 with bit_rate=9600. The USB data endpoint, however, still transfers at Full-Speed (12Mbps) — the baud rate field is purely informational for the device. It matters only if you are building a CDC-to-UART bridge and need to reconfigure the downstream UART's clock divider to match what the terminal application expects.
CDC ↔ UART Bridge Full Implementation
The most common real-world application of CDC is bridging USB to a hardware UART — replacing a dedicated FTDI/CP2102 chip with firmware running on your main MCU. The key design challenges are: handling different data rates, managing backpressure in both directions, and using DMA on the UART side to avoid CPU load.
/* cdc_uart_bridge.c — Bidirectional USB CDC ↔ UART bridge with DMA RX
Target: STM32F4 with HAL, TinyUSB, USART2
Uses HAL_UARTEx_ReceiveToIdle_DMA() for efficient UART RX */
#include "tusb.h"
#include "stm32f4xx_hal.h"
#include <string.h>
extern UART_HandleTypeDef huart2;
extern DMA_HandleTypeDef hdma_usart2_rx;
/* ─── Double-buffer for UART DMA RX ──────────────────────────────────────── */
#define UART_RX_DMA_BUF_SIZE 256
static uint8_t uart_rx_buf_a[UART_RX_DMA_BUF_SIZE];
static uint8_t uart_rx_buf_b[UART_RX_DMA_BUF_SIZE];
static uint8_t *uart_rx_active = uart_rx_buf_a;
static uint8_t *uart_rx_pending = NULL;
static uint32_t uart_rx_pending_len = 0;
/* ─── Circular buffer for CDC RX (data from USB waiting for UART TX) ──────── */
#define CDC_TO_UART_BUF_SIZE 512
static uint8_t cdc_to_uart_buf[CDC_TO_UART_BUF_SIZE];
static uint32_t ctu_head = 0; /* write index */
static uint32_t ctu_tail = 0; /* read index */
static bool s_bridge_active = false; /* true when DTR is asserted */
/* ─── Circular buffer helpers ────────────────────────────────────────────── */
static inline uint32_t ctu_count(void) {
return (ctu_head - ctu_tail + CDC_TO_UART_BUF_SIZE) % CDC_TO_UART_BUF_SIZE;
}
static inline uint32_t ctu_free(void) {
return CDC_TO_UART_BUF_SIZE - 1 - ctu_count();
}
static bool ctu_push(const uint8_t *data, uint32_t len) {
if (len > ctu_free()) return false; /* overflow — drop packet */
for (uint32_t i = 0; i < len; i++) {
cdc_to_uart_buf[ctu_head] = data[i];
ctu_head = (ctu_head + 1) % CDC_TO_UART_BUF_SIZE;
}
return true;
}
static uint32_t ctu_pop(uint8_t *dest, uint32_t max_len) {
uint32_t count = ctu_count();
if (count > max_len) count = max_len;
for (uint32_t i = 0; i < count; i++) {
dest[i] = cdc_to_uart_buf[ctu_tail];
ctu_tail = (ctu_tail + 1) % CDC_TO_UART_BUF_SIZE;
}
return count;
}
/* ─── Bridge initialisation ──────────────────────────────────────────────── */
void bridge_init(void) {
/* Start first DMA RX — will fill uart_rx_buf_a */
HAL_UARTEx_ReceiveToIdle_DMA(&huart2, uart_rx_active, UART_RX_DMA_BUF_SIZE);
/* Disable the DMA half-transfer interrupt — we use idle-line only */
__HAL_DMA_DISABLE_IT(&hdma_usart2_rx, DMA_IT_HT);
}
/* ─── UART RX complete (called from HAL_UARTEx_RxEventCallback) ──────────── */
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) {
if (huart->Instance != USART2) return;
/* The DMA has captured 'Size' bytes into the active buffer */
uart_rx_pending = uart_rx_active;
uart_rx_pending_len = Size;
/* Swap buffers so DMA writes into the other one while we process */
if (uart_rx_active == uart_rx_buf_a) {
uart_rx_active = uart_rx_buf_b;
} else {
uart_rx_active = uart_rx_buf_a;
}
/* Restart DMA on the new buffer immediately to avoid dropping bytes */
HAL_UARTEx_ReceiveToIdle_DMA(&huart2, uart_rx_active, UART_RX_DMA_BUF_SIZE);
__HAL_DMA_DISABLE_IT(&hdma_usart2_rx, DMA_IT_HT);
}
/* ─── CDC RX callback — data arrived from USB host ───────────────────────── */
void tud_cdc_rx_cb(uint8_t itf) {
(void)itf;
if (!s_bridge_active) {
tud_cdc_read_flush(); /* discard if no UART connection established */
return;
}
uint8_t buf[64];
uint32_t count = tud_cdc_read(buf, sizeof(buf));
if (count > 0) {
/* Buffer for UART TX — main task drains this to HAL_UART_Transmit */
ctu_push(buf, count);
}
}
/* ─── Line state callback — DTR controls bridge activation ──────────────── */
void tud_cdc_line_state_cb(uint8_t itf, bool dtr, bool rts) {
(void)itf; (void)rts;
s_bridge_active = dtr;
if (!dtr) {
/* Host closed port — flush both directions */
ctu_head = ctu_tail = 0;
tud_cdc_write_clear();
}
}
/* ─── Line coding callback — reconfigure UART to match host baud rate ────── */
void tud_cdc_line_coding_cb(uint8_t itf, cdc_line_coding_t const *p) {
(void)itf;
/* Map CDC parity to UART_PARITY_* values */
static const uint32_t parity_map[] = {
UART_PARITY_NONE, /* 0: None */
UART_PARITY_ODD, /* 1: Odd */
UART_PARITY_EVEN, /* 2: Even */
UART_PARITY_NONE, /* 3: Mark — not supported, fall back to none */
UART_PARITY_NONE /* 4: Space — not supported, fall back to none */
};
huart2.Init.BaudRate = p->bit_rate;
huart2.Init.WordLength = (p->data_bits == 9) ? UART_WORDLENGTH_9B : UART_WORDLENGTH_8B;
huart2.Init.StopBits = (p->stop_bits == 2) ? UART_STOPBITS_2 : UART_STOPBITS_1;
huart2.Init.Parity = (p->parity < 5) ? parity_map[p->parity] : UART_PARITY_NONE;
HAL_UART_DeInit(&huart2);
HAL_UART_Init(&huart2);
/* Restart DMA RX after re-init */
HAL_UARTEx_ReceiveToIdle_DMA(&huart2, uart_rx_active, UART_RX_DMA_BUF_SIZE);
__HAL_DMA_DISABLE_IT(&hdma_usart2_rx, DMA_IT_HT);
}
/* ─── Main bridge task — call from your main loop ────────────────────────── */
void bridge_task(void) {
/* 1. Forward pending UART RX data to CDC TX */
if (uart_rx_pending_len > 0 && tud_cdc_connected()) {
uint32_t written = tud_cdc_write(uart_rx_pending, uart_rx_pending_len);
tud_cdc_write_flush();
/* If we couldn't write all bytes, some are lost here — add a
secondary ring buffer for production-grade implementations */
(void)written;
uart_rx_pending_len = 0;
uart_rx_pending = NULL;
}
/* 2. Forward CDC RX data (from circular buffer) to UART TX */
if (ctu_count() > 0) {
uint8_t tx_buf[64];
uint32_t n = ctu_pop(tx_buf, sizeof(tx_buf));
if (n > 0) {
HAL_UART_Transmit(&huart2, tx_buf, (uint16_t)n, 10);
}
}
}
Windows / Linux / Mac Host Behavior
Understanding how each OS interacts with your CDC device saves hours of debugging mysterious connection issues.
Windows
Windows 10 and later include the usbser.sys class driver for CDC ACM — no INF file required. The device appears as COMx (e.g., COM3, COM7) in Device Manager under "Ports (COM & LPT)". The port number is assigned at first connection and remembered by the USB hardware ID.
A notorious Windows quirk is the latency timer: Windows polls the CDC device for new data at a 1ms interval. This is usually fine, but if you are sending very small packets rapidly, you may see "bursty" delivery rather than smooth streaming. You cannot change this timer from the device side.
Linux
Linux loads the cdc_acm kernel module automatically and creates /dev/ttyACM0 (or ACM1, ACM2, etc.). Non-root users cannot access this device by default. Add a udev rule to grant access:
# /etc/udev/rules.d/99-usb-serial.rules
# Replace idVendor and idProduct with your values (use lsusb to find them)
SUBSYSTEM=="tty", ATTRS{idVendor}=="cafe", ATTRS{idProduct}=="4001", \
MODE="0666", GROUP="dialout", SYMLINK+="cdc_demo"
# Reload udev rules (no reboot needed)
sudo udevadm control --reload-rules && sudo udevadm trigger
macOS
macOS creates /dev/tty.usbmodemXXXX and a companion /dev/cu.usbmodemXXXX. Use the cu.* device for outgoing connections (it does not wait for carrier detect). macOS includes IOUSBHostHIDDevice and a CDC driver that loads automatically.
Python pyserial Example
pip install pyserial
/* Python code — saved as cdc_test.py */
/*
import serial
import time
# Open the CDC port — baud rate value is sent to device as SET_LINE_CODING
# but does NOT affect USB transfer speed
port = serial.Serial(
port='/dev/ttyACM0', # or 'COM7' on Windows, '/dev/cu.usbmodem...' on Mac
baudrate=115200, # irrelevant for USB speed, but required by pyserial
timeout=1.0
)
print(f"Opened: {port.name}")
# Send a command and read back the response
port.write(b"status\r\n")
time.sleep(0.05)
response = port.read_all()
print("Response:", response.decode('utf-8', errors='replace'))
# Continuous read loop
port.write(b"help\r\n")
while True:
line = port.readline()
if line:
print(line.decode('utf-8', errors='replace'), end='')
else:
break
port.close()
*/
Flow Control
USB CDC has no built-in hardware flow control signal like RS-232's CTS/RTS — those signals exist in the CDC spec as notifications, but most implementations ignore them. Instead, flow control in CDC relies on the stack's buffering behavior.
What Happens When the Host Stops Reading
If your firmware writes data faster than the host reads it, the following chain of events occurs:
- The TinyUSB TX FIFO fills up (CFG_TUD_CDC_TX_BUFSIZE bytes)
tud_cdc_write() begins returning fewer bytes than requested (partial writes)
- Eventually
tud_cdc_write() returns 0 — no data was accepted
- If you loop retrying, your application task starves the USB task loop
Overflow Detection and Recovery
/* Overflow-safe CDC write with overflow counter */
static uint32_t s_overflow_count = 0;
bool cdc_write_safe(const uint8_t *data, uint32_t len) {
if (!tud_cdc_connected()) return false;
uint32_t available = tud_cdc_write_available();
if (available < len) {
/* Not enough space — either partial write or drop */
if (available == 0) {
s_overflow_count++;
return false; /* Drop this packet — caller handles retry */
}
/* Partial write: accept what fits, drop the rest */
len = available;
}
uint32_t written = tud_cdc_write(data, len);
tud_cdc_write_flush();
return (written == len);
}
/* XON/XOFF software flow control — embed control characters in data stream */
#define XON_CHAR 0x11 /* Ctrl+Q */
#define XOFF_CHAR 0x13 /* Ctrl+S */
static bool s_xoff_active = false;
void cdc_rx_check_flow(const uint8_t *data, uint32_t len) {
for (uint32_t i = 0; i < len; i++) {
if (data[i] == XOFF_CHAR) {
s_xoff_active = true; /* host says: pause sending */
} else if (data[i] == XON_CHAR) {
s_xoff_active = false; /* host says: resume sending */
}
}
}
bool cdc_tx_allowed(void) {
return !s_xoff_active && tud_cdc_connected();
}
Two-Port CDC (Multi-CDC)
TinyUSB supports multiple CDC ports by increasing CFG_TUD_CDC to 2 (or more). Each port appears as a separate COM/ttyACM device on the host. A common use case is one port for application data and a second port for debug logs — the developer can open only the debug port without disturbing the data connection.
Configuration for Two Ports
/* tusb_config.h additions for two CDC ports */
#define CFG_TUD_CDC 2 /* Two independent CDC ACM ports */
/* Each port has its own buffers */
#define CFG_TUD_CDC_RX_BUFSIZE 512
#define CFG_TUD_CDC_TX_BUFSIZE 512
Descriptor for Two CDC Ports
/* Two IADs, two Communications interfaces, two Data interfaces = 4 interfaces total */
#define CONFIG_TOTAL_LEN (TUD_CONFIG_DESC_LEN + TUD_CDC_DESC_LEN + TUD_CDC_DESC_LEN)
#define EPNUM_CDC0_NOTIF 0x81
#define EPNUM_CDC0_OUT 0x02
#define EPNUM_CDC0_IN 0x82
#define EPNUM_CDC1_NOTIF 0x83
#define EPNUM_CDC1_OUT 0x04
#define EPNUM_CDC1_IN 0x84
uint8_t const desc_fs_configuration[] = {
TUD_CONFIG_DESCRIPTOR(1, 4, 0, CONFIG_TOTAL_LEN,
TUSB_DESC_CONFIG_ATT_REMOTE_WAKEUP, 100),
/* CDC Port 0: interfaces 0 and 1 */
TUD_CDC_DESCRIPTOR(0, 4, EPNUM_CDC0_NOTIF, 8,
EPNUM_CDC0_OUT, EPNUM_CDC0_IN, 64),
/* CDC Port 1: interfaces 2 and 3 */
TUD_CDC_DESCRIPTOR(2, 5, EPNUM_CDC1_NOTIF, 8,
EPNUM_CDC1_OUT, EPNUM_CDC1_IN, 64)
};
Port-Specific API
/* Use tud_cdc_n_*() variants to target a specific port by index */
/* Port 0: application data */
void app_send_data(const uint8_t *buf, uint32_t len) {
if (tud_cdc_n_connected(0)) {
tud_cdc_n_write(0, buf, len);
tud_cdc_n_write_flush(0);
}
}
/* Port 1: debug log — lower priority, drop if buffer full */
void debug_log(const char *msg) {
if (tud_cdc_n_connected(1)) {
uint32_t avail = tud_cdc_n_write_available(1);
uint32_t len = strlen(msg);
if (avail >= len) {
tud_cdc_n_write(1, msg, len);
tud_cdc_n_write_flush(1);
}
}
}
/* RX callbacks use the interface number to distinguish ports */
void tud_cdc_rx_cb(uint8_t itf) {
uint8_t buf[64];
uint32_t count = tud_cdc_n_read(itf, buf, sizeof(buf));
if (itf == 0) {
app_process_rx(buf, count);
} else if (itf == 1) {
debug_process_command(buf, count);
}
}
Practical Exercises
Theory becomes skill only through implementation. Work through these exercises in order — each one builds on the previous.
Beginner: USB CDC Echo Device
Implement a CDC device that echoes every received byte back to the sender. Requirements: echo must be byte-for-byte identical; the device must handle partial writes gracefully; verify with both a terminal emulator and Python's pyserial.
/* echo_main.c — simplest possible CDC application */
void tud_cdc_rx_cb(uint8_t itf) {
(void)itf;
uint8_t buf[64];
uint32_t count = tud_cdc_read(buf, sizeof(buf));
if (count > 0 && tud_cdc_connected()) {
/* Echo: write may be partial if TX FIFO is full */
uint32_t sent = 0;
while (sent < count) {
sent += tud_cdc_write(buf + sent, count - sent);
tud_task(); /* allow stack to drain if FIFO was full */
}
tud_cdc_write_flush();
}
}
Intermediate: USB-to-UART Bridge with DMA
Using the bridge implementation from Section 6 as a starting point, add these features: (a) forward line coding changes to the downstream UART using HAL_UART_Init(); (b) add an LED that blinks when data flows in either direction; (c) implement a 100ms timeout that resets the bridge state if the host disconnects and reconnects without toggling DTR.
Advanced: CDC Command Line Interface with Flash Config Storage
Build a full USB CDC CLI that supports at least these 5 commands:
help — list all commands with brief descriptions
status — print firmware version, uptime, and free heap
config get <key> — read a named value from internal flash
config set <key> <value> — write a named value to flash (persist across resets)
reset — perform a software reset after flushing CDC TX
Use STM32 EEPROM emulation or RP2040 flash storage for persistence. The config storage should survive power cycling. Bonus: implement command history (up-arrow key, sending ESC[A sequence).
Generate Your CDC Design Document
Fill in the details of your CDC Virtual COM Port project below and export a complete design document in your preferred format. The document includes your configuration parameters, buffer sizing rationale, and design notes.
Conclusion & Next Steps
CDC ACM is the right choice any time you need a simple, driver-free serial connection between your embedded device and a host computer. Here is what we covered in this article:
- Architecture: CDC requires two interfaces (Communications + Data) held together by an IAD in composite devices. Four functional descriptors declare ACM capabilities to the host.
- TinyUSB setup:
CFG_TUD_CDC 1 enables the class; TUD_CDC_DESCRIPTOR macro builds the descriptor block; buffer sizes are tuned via CFG_TUD_CDC_RX/TX_BUFSIZE.
- Sending data: Always pair
tud_cdc_write() with tud_cdc_write_flush(). Retarget printf() via _write() override for zero-effort debug output.
- Receiving data: Use
tud_cdc_rx_cb() for event-driven processing. Accumulate characters until \n for command-line interfaces.
- Line coding: DTR is your reliable "port is open" signal. Baud rate changes only matter for UART bridges — USB speed is always fixed at Full-Speed.
- UART bridge: Use DMA with idle-line interrupt on the UART side, double buffering, and a circular buffer to handle CDC backpressure without dropping data.
- Host behavior: Windows shows COMx, Linux shows /dev/ttyACM0, macOS shows /dev/cu.usbmodem*. Add udev rules on Linux for non-root access.
Next Article: Part 7 — USB HID Devices. We dive into the HID class: authoring report descriptors byte by byte, implementing keyboards, mice, gamepads, and custom sensor HID devices, and understanding boot protocol vs report protocol.
Other Articles in This Series
Part 5: TinyUSB Deep Dive
Architecture, configuration macros, initialization sequence, callback model, and a complete porting checklist for new MCU targets.
Read Article
Part 7: HID Devices
Report descriptor authoring, keyboard and mouse implementation, custom sensor HID, and boot protocol vs report protocol.
Read Article
Part 9: Composite Devices
Combining CDC + HID + MSC in one device, IAD descriptor assembly, and interface association rules.
Read Article