Electronics Design AU
STM32USB

How Do You Implement a USB CDC (Virtual COM Port) on STM32?

Last updated 20 July 2026 · 10 min read

Direct Answer

STM32Cube USB Device middleware provides a USBD_CDC class driver that presents a virtual COM port (VCP) to the host over USB. STM32CubeMX generates the class binding and the interface file usbd_cdc_if.c, which is where the application-specific logic lives: CDC_Receive_FS() for incoming data, CDC_Transmit_FS() for outgoing data, and the fixed-size APP_RX_DATA_SIZE/APP_TX_DATA_SIZE buffers the class driver reads and writes through. The three implementation points that separate a working VCP from one that drops or corrupts data are copying received bytes out of CDC_Receive_FS() into an application ring buffer before doing anything else, checking CDC_Transmit_FS()'s return value for USBD_BUSY on every call instead of assuming it always succeeds, and giving the USB interrupt an NVIC priority that doesn't starve or get starved by other time-critical peripherals.

Detailed Explanation

A USB virtual COM port (VCP) is one of the most common ways an STM32 product talks to a PC: a firmware update tool, a configuration utility, or just a debug console, all showing up as an ordinary serial port with no custom driver required. STM32Cube's USB Device middleware makes the basic bring-up straightforward through STM32CubeMX, but the generated template leaves the two places where most VCP implementations actually go wrong, RX buffering and the TX busy flag, as an exercise for the application code. This page covers the middleware architecture, the correct RX and TX patterns, and the interrupt-priority consideration that ties USB into the rest of the firmware. It assumes a single-function CDC-ACM device; if you're combining CDC with another USB class on the same device, see How Do You Design USB Composite Device Descriptors for Multiple Interfaces? for the descriptor-level work that adds, and How Do You Implement USB OTG in an Embedded Product? if the product also needs to act as a USB host: both are separate concerns from what's covered here.

STM32Cube USB Device Middleware Architecture

STM32Cube's USB Device middleware splits into two layers that CubeMX generates differently:

  • usbd_cdc.c and usbd_cdc.h, the class driver itself, part of the STM32Cube middleware. It implements the CDC-ACM protocol: endpoint configuration, the standard CDC control requests (SET_LINE_CODING, GET_LINE_CODING, SET_CONTROL_LINE_STATE), and the state machine that moves data between the USB stack and the interface layer below. This file is generated once by CubeMX and is not normally something application code edits.
  • usbd_cdc_if.c (the CDC Interface layer), the file CubeMX generates as a customization point, containing CDC_Init_FS(), CDC_DeInit_FS(), CDC_Control_FS(), CDC_Receive_FS(), and CDC_Transmit_FS(). This is where application-specific RX and TX handling belongs, and it's the only part of the middleware most VCP projects need to modify.

The interface layer works against two fixed-size buffers, UserRxBufferFS and UserTxBufferFS, sized by APP_RX_DATA_SIZE and APP_TX_DATA_SIZE (a modest fixed size in the generated template; the right size for a given product depends on the traffic pattern the application actually generates, and it's worth reviewing rather than leaving at the template's default). CDC_Receive_FS() is called by the class driver with a pointer into UserRxBufferFS already filled with an incoming packet; CDC_Transmit_FS() is called by application code and hands a buffer to the class driver for transmission. Neither buffer is a ring buffer on its own: the ring-buffer behaviour described below is something the application adds on top.

Configuring the USBD_CDC Class Descriptor and Endpoints

CubeMX's USB_DEVICE middleware configuration panel selects the CDC (Virtual Com Port) class for the target USB peripheral (USB_OTG_FS, USB_OTG_HS, or the plain USB peripheral on parts that have it) and generates the standard CDC-ACM descriptor set: a Communication Class interface (used for the AT-command-style control requests) and a Data Class interface (the actual byte stream), tied together correctly since CDC-ACM is one of the cases that genuinely requires the two-interface pairing discussed in the composite-device page linked above. For a single-function CDC device, CubeMX handles this pairing automatically without needing an explicit Interface Association Descriptor, because the device-level class fields alone are sufficient when CDC is the only function present.

MX_USB_DEVICE_Init();
// Generated call chain: USBD_Init() -> USBD_RegisterClass(&hUsbDeviceFS, &USBD_CDC)
//                        -> USBD_CDC_RegisterInterface(&hUsbDeviceFS, &USBD_Interface_fops_FS)
//                        -> USBD_Start(&hUsbDeviceFS)

Beyond selecting the peripheral and confirming the endpoint addresses CubeMX assigns don't collide with any other USB function on the device, there's little to configure by hand at this layer. The real implementation work is in the interface file.

RX Handling: The CDC_Receive_FS Callback and Ring Buffer Pattern

CDC_Receive_FS() fires from USB interrupt context every time the host sends a packet to the device. The CubeMX template's default body typically just re-arms reception and leaves data handling as a placeholder, and that placeholder is exactly where a naive first implementation goes wrong: parsing a command, running a state machine, or writing to flash directly inside this callback blocks the USB interrupt for however long that processing takes, which delays every other USB transaction (including any pending IN transfer) until it returns.

The correct pattern is to treat CDC_Receive_FS() purely as a fast handoff:

static uint8_t appRxRing[APP_RX_RING_SIZE];
static volatile uint16_t ringHead = 0;
static volatile uint16_t ringTail = 0;

static int8_t CDC_Receive_FS(uint8_t *Buf, uint32_t *Len)
{
    for (uint32_t i = 0; i < *Len; i++) {
        uint16_t next = (ringHead + 1) % APP_RX_RING_SIZE;
        if (next != ringTail) {           // drop on overflow rather than corrupt the ring
            appRxRing[ringHead] = Buf[i];
            ringHead = next;
        }
    }
    USBD_CDC_SetRxBuffer(&hUsbDeviceFS, &Buf[0]);
    USBD_CDC_ReceivePacket(&hUsbDeviceFS);   // re-arm reception before returning
    return USBD_OK;
}

The main loop (or an RTOS task) then drains appRxRing at its own pace. Two details matter here:

  • USBD_CDC_ReceivePacket() must be called before the callback returns, every time. This re-arms the endpoint for the next OUT packet from the host; skipping it (or calling it conditionally) is a common way for a VCP to silently stop receiving after the first packet or after an error path is taken.
  • Copy out, don't process in place. Command parsing, checksum validation, or anything involving more than a fast memory copy belongs in the consumer that drains the ring buffer, not in the interrupt-context callback.

TX Handling and the CDC_Transmit_FS Busy-Flag Pitfall

This is the single most common STM32 USB CDC bug: CDC_Transmit_FS() returns USBD_BUSY if a previous IN transfer on the CDC data endpoint hasn't completed, and code that ignores that return value silently loses whatever it just tried to send. The CubeMX template's generated function makes the busy check available but doesn't force the caller to act on it:

uint8_t CDC_Transmit_FS(uint8_t *Buf, uint16_t Len)
{
    uint8_t result = USBD_OK;
    USBD_CDC_HandleTypeDef *hcdc = (USBD_CDC_HandleTypeDef *)hUsbDeviceFS.pClassData;
    if (hcdc->TxState != 0) {
        return USBD_BUSY;   // a previous transfer is still in flight
    }
    USBD_CDC_SetTxBuffer(&hUsbDeviceFS, Buf, Len);
    result = USBD_CDC_TransmitPacket(&hUsbDeviceFS);
    return result;
}

A first implementation that calls this function directly from application code, sends the return value nowhere, and moves on, works fine on the bench with occasional short strings because the previous transfer almost always finishes well before the next call. Under sustained output (streaming measurement data, verbose logging, a control loop pushing telemetry every millisecond), the busy condition stops being rare and starts being routine, and every ignored USBD_BUSY return is data the host never sees.

The reliable pattern uses an application-side TX ring buffer serviced from the main loop or a low-priority task, not a direct call from wherever the data originates:

static uint8_t appTxRing[APP_TX_RING_SIZE];
static volatile uint16_t txHead = 0, txTail = 0;

void AppendToTxRing(const uint8_t *data, uint16_t len) { /* enqueue, same pattern as RX ring */ }

void ServiceCdcTx(void)
{
    if (txHead == txTail) return;                       // nothing queued
    uint16_t chunkLen = /* bytes available up to a contiguous run in the ring */;
    if (CDC_Transmit_FS(&appTxRing[txTail], chunkLen) == USBD_OK) {
        txTail = (txTail + chunkLen) % APP_TX_RING_SIZE;
    }
    // else: still busy, leave the data queued and retry ServiceCdcTx() on the next pass
}

Application code calls AppendToTxRing() whenever it has something to send, and a periodic call to ServiceCdcTx() (from the main loop, a low-priority RTOS task, or a timer callback) drains the ring whenever the endpoint is free. This decouples "the application produced some bytes" from "the USB endpoint happens to be free right now," which is exactly the gap that a direct, unchecked CDC_Transmit_FS() call falls into.

Interrupt Priority for the USB Peripheral

The USB peripheral's interrupt has to coexist with every other interrupt source in the firmware, and getting its NVIC priority wrong is a less obvious but real source of USB reliability problems. Setting the USB interrupt at too low a priority (a high numeric value) means it can be preempted for long stretches by other ISRs, which risks missing the tight timing windows USB transactions expect and can show up as intermittent enumeration failures or dropped packets under interrupt load elsewhere in the system. Setting it too high can starve equally time-sensitive peripherals, like a motor-control PWM update or a communications bus with its own timing requirements. See How Do You Configure STM32 NVIC Interrupt Priorities? for the full priority-grouping model; the practical rule for USB specifically is to give it a priority commensurate with how time-sensitive the rest of the application's interrupt load actually is, rather than leaving it at whatever CubeMX defaults to without reviewing it against the other configured interrupts. If the application also uses FreeRTOS, the USB interrupt priority is subject to the same configMAX_SYSCALL_INTERRUPT_PRIORITY constraint as any other ISR calling FreeRTOS APIs.

Design Considerations

  • Size both ring buffers for the actual burst pattern, not the average throughput. A device that mostly sends short status lines but occasionally dumps a large log or data capture needs a TX ring sized for the burst, not for typical traffic, or it will simply drop the tail of every large transfer once the ring fills.
  • Decide what happens on ring-buffer overflow before it happens in the field. Dropping the newest bytes (as in the RX example above) is simple and predictable; overwriting the oldest unread bytes is a different trade-off that suits some telemetry use cases better. Pick one deliberately rather than letting whichever behaviour falls out of the implementation decide it.
  • Treat HAL_Delay() inside any USB callback path the same way it's treated everywhere else on STM32: something to avoid in a context that other time-sensitive code depends on. A blocking delay inside CDC_Receive_FS() or a TX service routine has the same effect as processing data inline; it stalls the USB interrupt for its full duration.
  • A CDC-ACM connection with no application listening on the host side is still a valid, connected USB device. CDC_Transmit_FS() reports success once the STM32 has handed the endpoint its data, not once a host application has actually read it; a slow or absent host-side reader can still let the TX ring fill up if nothing else pushes back on the producer. Zeus Design builds STM32 firmware with fault-tolerant USB CDC handling for products that rely on a virtual COM port for field diagnostics or firmware updates.

Common Mistakes

  • Ignoring the return value of CDC_Transmit_FS(). This is the pitfall the page title promises and the most common bug in STM32 CDC implementations: a USBD_BUSY return means the bytes were not sent, and treating the call as fire-and-forget silently drops data under any load heavier than occasional short strings.
  • Processing data inside CDC_Receive_FS() instead of copying it out. Parsing commands, writing to flash, or running any non-trivial logic directly in the RX callback blocks the USB interrupt and delays every other pending USB transaction, including outgoing data.
  • Forgetting to call USBD_CDC_ReceivePacket() to re-arm reception after every CDC_Receive_FS() invocation, including on error paths. Skipping this call, even conditionally, stops the device from accepting further data from the host.
  • Undersizing APP_RX_DATA_SIZE, APP_TX_DATA_SIZE, or the application-level ring buffers for real traffic bursts. The CubeMX defaults are a reasonable starting point, not a guarantee that fits every application's actual data pattern.
  • Leaving the USB interrupt priority at an unreviewed default in a firmware design with other genuinely time-critical interrupts. This can produce intermittent, hard-to-reproduce USB reliability issues that look unrelated to USB at first, because the actual cause is priority contention elsewhere in the interrupt table.

Frequently Asked Questions

Why does my STM32 virtual COM port drop data under load?
The near-universal cause is ignoring the return value of CDC_Transmit_FS(). It returns USBD_BUSY whenever a previous IN transfer on the CDC data endpoint hasn't finished yet, and if the calling code treats that return value as success, the bytes it tried to send are simply gone. Under light, human-typed serial traffic this rarely shows up, because there's enough time between calls for the endpoint to clear. Under sustained higher-throughput traffic (streaming sensor data, log output, a fast control loop), the busy condition becomes routine rather than rare, and unhandled busy returns turn into a steady trickle of missing bytes on the host side. The fix is to always check the return value and either queue the data in an application ring buffer for a retry, or hold back the caller until the endpoint frees up.
Can I use USB CDC alongside another USB class on the same STM32 device?
Yes, CDC-ACM combined with HID, mass storage, or a vendor-specific interface is a common pattern, but it turns the device into a composite device, which needs an Interface Association Descriptor and careful interface/endpoint numbering across the whole descriptor set, not just within the CDC class. That descriptor-level work is MCU-agnostic and covered in full in How Do You Design USB Composite Device Descriptors for Multiple Interfaces? This page assumes a single-function CDC-ACM device; if you're adding a second class, read that page alongside this one before touching the descriptor configuration in CubeMX.
Do I need to use DMA for STM32 USB CDC transfers?
No, not for a typical CDC-ACM virtual COM port. USB Full Speed CDC traffic (up to 12 Mbit/s, and in practice far less for a serial-style byte stream) is comfortably handled by the USB peripheral's own packet FIFOs without DMA; STM32Cube's USB Device middleware moves data between the class driver's buffers and the endpoint FIFOs on its own. DMA becomes relevant mainly on USB High Speed peripherals (OTG_HS) moving sustained bulk throughput, which is a different design problem to a VCP used for logging or command/response traffic. Adding DMA to a CDC implementation that doesn't need the throughput adds complexity without a corresponding benefit.

References

Related Questions

Related Forum Discussions