Chapter 260: USB CDC Implementation on ESP32-S2/3

Chapter Objectives

By the end of this chapter, you will be able to:

  • Manually configure and initialize the USB driver for a Communications Device Class (CDC) device.
  • Create a dedicated FreeRTOS task to manage the TinyUSB stack.
  • Implement application logic to read data sent from a host PC over a virtual serial port.
  • Write data from the ESP32 back to the host PC.
  • Build a simple command-line interface controlled via USB.
  • Understand the workflow for developing applications that use both the UART for debugging and native USB for primary communication.

Introduction

In the previous chapter, we saw how effortlessly we could redirect the system console to the native USB port using a simple menuconfig setting. This is incredibly useful for logging, but it only scratches the surface of what the native USB peripheral can do. The real power is unlocked when we take manual control of the USB stack.

By manually implementing a USB CDC device, you can create a dedicated, high-speed communication channel between your application and a host computer. This is essential for applications that need to receive commands, stream large amounts of sensor data, or present a custom command-line interface (CLI) for configuration and control.

This chapter will guide you through the process of setting up the TinyUSB stack manually, creating a robust two-way serial communication link, and building a practical, interactive application.

Theory

1. What is USB CDC, Really?

The Communications Device Class (CDC) is a composite USB class. This means it’s made up of multiple “interfaces.” For a virtual serial port, two are key:

  1. Communications Class Interface: This interface is used for control purposes—setting the virtual baud rate, parity, and other serial line parameters. Most of the time, TinyUSB and the operating system handle this automatically.
  2. Data Class Interface: This is the workhorse interface. It provides two “bulk” endpoints—one for sending data to the host (IN endpoint) and one for receiving data from the host (OUT endpoint). This is the channel through which our actual application data flows.

When you plug your ESP32 into a PC, and it enumerates as a CDC device, the PC’s operating system recognizes these interfaces and loads a standard driver, creating a virtual COM port that applications like a serial terminal can open and use.

graph TB
    subgraph "Host PC"
        A["PC Application<br>(e.g., PuTTY, Serial Monitor)"]
        B["Operating System<br><b>(Generic CDC Driver)</b>"]
    end

    subgraph "ESP32-S3 Device"
        D["TinyUSB Stack<br><i>(manages USB protocol)</i>"]
        E["ESP32 Application<br><b>(Your Code)</b>"]
    end

    C(USB Cable)

    %% Forward flow
    A --> B
    B --> C
    C --> D
    D --> E

    %% Reverse flow
    E --> D
    D --> C
    C --> B
    B --> A

    %% Add labels to connections
    A -.->|"Writes/Reads Data"| B
    B -.->|"USB Packets"| C
    C -.->|"USB Packets"| D
    D -.->|"API Calls<br>(tud_cdc_n_read/write)"| E

    %% Styling
    classDef pc fill:#DBEAFE,stroke:#2563EB,stroke-width:1px,color:#1E40AF
    classDef esp fill:#D1FAE5,stroke:#059669,stroke-width:2px,color:#065F46
    classDef transport fill:#FEF3C7,stroke:#D97706,stroke-width:1px,color:#92400E

    class A,B pc
    class D,E esp
    class C transport

2. The TinyUSB Application Workflow

When you don’t use the console redirection feature, you must manage the TinyUSB stack yourself. The workflow involves three main components:

  1. Initialization: You must explicitly initialize the TinyUSB stack by calling tusb_init(). This sets up the necessary data structures and prepares the USB peripheral.
  2. The TinyUSB Task: The USB protocol is event-driven and managed by the host. The TinyUSB stack needs to constantly check for and respond to events from the host (e.g., “the host sent you data,” “the host is ready to receive data”). This is handled by a function called tud_task(). It is absolutely critical that this function is called repeatedly and frequently in your application. The standard practice is to dedicate a high-priority FreeRTOS task to this single purpose.
  3. Application Interface: Your main application code interacts with the virtual serial port using the TinyUSB CDC API. This involves checking if data is available, reading it, and writing responses back.
flowchart TD
    A["Start: app_main()"] --> B(Initialize Peripherals<br>e.g., GPIO);
    B --> C{"Call <b>tusb_init()</b><br>Initialize TinyUSB Stack"};
    C --> D{"Create <b>tinyusb_task</b><br>(High Priority)"};
    C --> E{"Create <b>Application Task(s)</b><br>(e.g., cdc_cli_task)"};

    subgraph "FreeRTOS Task Scheduler"
        direction TB
        subgraph "Core 0"
            D_Task["<b>tinyusb_task</b>"]
            D_Task --> D_Loop{ }
            D_Loop -- "Repeats endlessly" --> D_Task_Action["Call <b>tud_task()</b><br><i>Handles USB events</i>"] --> D_Loop
        end
        subgraph "Core 1"
            E_Task["<b>Application Task</b>"]
            E_Task --> E_Action{"Check for input<br><i>tud_cdc_n_available()</i>"}
            E_Action --> E_Decision{Data available?}
            E_Decision -- Yes --> E_Process["Read & Process Data<br><i>tud_cdc_n_read()</i>"]
            E_Process --> E_Write["Write Response<br><i>tud_cdc_n_write()</i>"] --> E_Task
            E_Decision -- No --> E_Task
        end
    end
    
    D_Task_Action -.->|Data for App| E_Action
    E_Write -.->|Data for Host| D_Task_Action

    %% Styling
    classDef primary fill:#EDE9FE,stroke:#5B21B6,stroke-width:2px,color:#5B21B6;
    classDef process fill:#DBEAFE,stroke:#2563EB,stroke-width:1px,color:#1E40AF;
    classDef decision fill:#FEF3C7,stroke:#D97706,stroke-width:1px,color:#92400E;
    classDef task fill:#D1FAE5,stroke:#059669,stroke-width:2px,color:#065F46;

    class A,B,C,D,E primary;
    class D_Task,E_Task,D_Task_Action,E_Action,E_Process,E_Write task;
    class E_Decision decision;

3. Key TinyUSB CDC API Functions

The CDC driver in TinyUSB is designed to support multiple virtual serial ports. Therefore, its functions are often named with _n_ to specify the port number. For a single CDC device, this number will always be 0.

Function Name Description Common Usage
tud_cdc_n_connected(itf) Checks if the host serial terminal is open and connected to the CDC port. Use in a loop to wait before sending an initial prompt, preventing data loss.
tud_cdc_n_available(itf) Returns the number of bytes received from the host and waiting in the buffer. Check if this is > 0 before calling read() to avoid reading empty data.
tud_cdc_n_read(itf, buf, size) Reads available data from the internal USB buffer into your application’s buffer. Call inside an if (tud_cdc_n_available(..)) block to process incoming commands.
tud_cdc_n_write(itf, buf, size) Writes a buffer of data to be sent to the host. Also available as _str() and _char() variants. Used to send log data, command responses, and sensor readings to the PC.
tud_cdc_n_write_flush(itf) Forces any data lingering in the write buffer to be sent to the host immediately. Crucial for interactive CLIs. Call after writing a prompt or response to ensure the user sees it right away.

Analogy: Think of tud_task() as the postal worker for your ESP32’s USB port. The postal worker needs to constantly check the mailbox for incoming mail (host data) and pick up outgoing mail (ESP32 data). If the postal worker stays home (i.e., you don’t call tud_task()), mail piles up, and nothing gets delivered in either direction. Your application logic is like you, the homeowner, who reads the mail and writes replies.

Practical Example: USB Command-Line Interface

Let’s build an application that allows us to control the built-in RGB LED on a common ESP32-S3 development board via a command-line interface over USB. We will send commands like led on and led off from a serial terminal.

Project Goal: Create a manual USB CDC interface to control an LED.

Step 1: Project Setup and Configuration

  1. Create a new ESP-IDF project in VS Code. Set the target to ESP32-S3.
  2. Open menuconfig (ESP-IDF: Launch SDK Configuration Editor).
  3. Go to Component Config —> TinyUSB Stack.
  4. In the Device Descriptor submenu, you can set a custom Product Name like “ESP32-S3 CLI”.
  5. In the CDC-ACM (Serial port) submenu, ensure Number of CDC-ACM ports is set to 1.
  6. Crucially, go back to Component config -> ESP System Settings -> Channel for console output and ensure it is set to Default UART. We want our ESP_LOG messages to go to the UART port for debugging, keeping the CDC port clean for our CLI.
  7. Save and exit menuconfig.

Step 2: Write the Application Code

Replace the contents of main/main.c with the following code. Note the comments explaining each part.

C
#include <stdio.h>
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "driver/gpio.h"
#include "tusb.h"
#include "tusb_cdc_acm.h"

// Define the GPIO for the onboard LED (this is for the ESP32-S3-DevKitC-1 v1.0)
// Check your board's schematic for the correct GPIO number.
#define BLINK_GPIO 48 

static const char *TAG = "USB_CLI";

// Task to run the TinyUSB stack
void tinyusb_task(void *pvParameters)
{
    while (1) {
        // This is the main processing task for TinyUSB
        tud_task();
        // Give other tasks a chance to run
        vTaskDelay(pdMS_TO_TICKS(1)); 
    }
}

// Task to handle commands received over CDC
void cdc_cli_task(void *pvParameters)
{
    char command_buffer[64];
    int command_len = 0;

    // Wait until the CDC port is connected
    while (!tud_cdc_n_connected(0)) {
        vTaskDelay(pdMS_TO_TICKS(100));
    }
    ESP_LOGI(TAG, "CDC port connected");
    tud_cdc_n_write_str(0, "ESP32-S3 CLI Ready\r\n> ");
    tud_cdc_n_write_flush(0);


    while (1) {
        // Check if there is data to be read
        if (tud_cdc_n_available(0)) {
            uint8_t buf[64];
            uint32_t count = tud_cdc_n_read(0, buf, sizeof(buf));

            for(uint32_t i=0; i<count; i++) {
                // Echo the character back to the terminal
                tud_cdc_n_write_char(0, buf[i]);

                if (buf[i] == '\r' || buf[i] == '\n') {
                    // Terminate the command string
                    command_buffer[command_len] = '\0';
                    tud_cdc_n_write_str(0, "\r\n"); // New line for the response

                    // Process the command
                    if (strcmp(command_buffer, "led on") == 0) {
                        gpio_set_level(BLINK_GPIO, 1);
                        tud_cdc_n_write_str(0, "OK: LED is ON\r\n");
                    } else if (strcmp(command_buffer, "led off") == 0) {
                        gpio_set_level(BLINK_GPIO, 0);
                        tud_cdc_n_write_str(0, "OK: LED is OFF\r\n");
                    } else {
                        tud_cdc_n_write_str(0, "ERROR: Unknown command\r\n");
                    }

                    // Reset for the next command
                    tud_cdc_n_write_str(0, "> ");
                    tud_cdc_n_write_flush(0);
                    command_len = 0;
                } else if (command_len < sizeof(command_buffer) - 1) {
                    // Add character to buffer
                    command_buffer[command_len++] = buf[i];
                }
            }
             tud_cdc_n_write_flush(0);
        }
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}


void app_main(void)
{
    ESP_LOGI(TAG, "Initializing USB CLI Application");

    // Configure the GPIO for the LED
    gpio_reset_pin(BLINK_GPIO);
    gpio_set_direction(BLINK_GPIO, GPIO_MODE_OUTPUT);

    // Initialize the TinyUSB stack
    ESP_LOGI(TAG, "USB initialization");
    tusb_init();
    
    // Create a task to run the TinyUSB driver
    xTaskCreate(tinyusb_task, "tinyusb_task", 4096, NULL, 5, NULL);

    // Create a task to handle our CLI
    xTaskCreate(cdc_cli_task, "cdc_cli_task", 4096, NULL, 4, NULL);
}

Step 3: Build, Flash, and Interact

  1. Connect UART Port: Connect your ESP32-S3 board to your PC using the UART port.
  2. Flash: Build and flash the code using the standard VS Code command.
  3. Monitor UART: Open the ESP-IDF monitor on the UART COM port. You should see the ESP_LOGI messages: “Initializing USB CLI Application” and “USB initialization”.
  4. Connect Native Port: Now, connect a second USB cable to the Native USB port. Your PC should detect a new serial device.
  5. Monitor CDC Port: Open a second serial terminal (e.g., PuTTY, Tera Term, or the VS Code ESP-IDF: Monitor Device (Select Port to Monitor) command on the new COM port).
  6. Interact: Press Enter in the CDC terminal. You should see the ESP32-S3 CLI Ready prompt. Now you can type led on or led off and see the LED respond, with “OK” messages printed back to your terminal.

Variant Notes

  • ESP32-S2/S3 Only: This functionality is exclusive to the S2 and S3 variants. Attempting to compile this code for other targets will result in build errors as the TinyUSB component will not be available for them.
  • Performance on ESP32-S3: The dual-core architecture of the S3 is a significant advantage here. You can pin the tinyusb_task to one core and the cdc_cli_task (and other application logic) to the other. This ensures that even if your application is busy, the USB stack remains highly responsive, preventing dropouts.
  • Resource Usage on ESP32-S2: On the single-core S2, you must be more careful. If your application task performs a long, blocking operation, it can starve the tinyusb_task, potentially causing the USB device to become unresponsive to the host. Keep application tasks short and use delays to yield processing time.

Common Mistakes & Troubleshooting Tips

Mistake / Issue Symptom(s) Troubleshooting / Solution
Forgetting tud_task() The device doesn’t show up on the PC at all, or it appears as an “Unknown Device” and is unresponsive. Create a Task: Ensure you have a dedicated FreeRTOS task that does nothing but call tud_task() in a continuous loop. This task must have a high priority.
No Response in Terminal You can send commands to the ESP32, but you never see the “OK” response or the next prompt. Flush the Buffer: The data is likely sitting in the USB buffer. Call tud_cdc_n_write_flush(0) immediately after writing your response to force it to be sent to the PC.
Mixing up Ports You type led on into the UART monitor and nothing happens, or you look for ESP_LOG messages in the CDC terminal and see nothing. Be Methodical: Use two separate terminal windows. Label one “DEBUG (UART)” and the other “CLI (Native USB)”. Always double-check which window you are typing in.
Blocking the USB Task On an ESP32-S2, the USB connection works for a while but then becomes unresponsive, especially when the application is busy. Don’t Block: Ensure your application tasks do not perform long, blocking operations. Use vTaskDelay() frequently to yield control so the high-priority tinyusb_task can run.

Exercises

  1. ADC Data Logger: Write an application that reads a value from an ADC channel every second. Format this value into a CSV (Comma Separated Value) string like "timestamp,adc_value\n" (e.g., "12345,2048\n") and write it to the USB CDC port. You can then copy this output and paste it into a spreadsheet to graph the data.
  2. RGB LED Controller: Extend the example to control the RGB components of the onboard LED. Implement commands like rgb <r> <g> <b> (e.g., rgb 255 0 128). This will require more advanced string parsing using sscanf or strtok.
  3. USB-to-UART Bridge: Turn your ESP32-S3 into a USB-to-UART converter. Initialize UART1 on two spare GPIOs. Write a task that reads from USB CDC and writes to UART1. Write a second task that reads from UART1 and writes to USB CDC. You can then use your S3 board to communicate with other serial devices.

Summary

  • Manual USB CDC implementation gives you full control over a high-speed serial data channel to a host PC.
  • The TinyUSB stack, integrated into ESP-IDF, provides the necessary APIs.
  • The tud_task() function is the heart of the USB stack and must be called continuously in a dedicated FreeRTOS task.
  • You can read and write data using the tud_cdc_n_read() and tud_cdc_n_write() functions.
  • A common and robust design pattern is to use the UART port for debug logs and the native USB CDC port for the primary application I/O.
  • This feature is only available on the ESP32-S2 and ESP32-S3.

Further Reading

  • TinyUSB Device CDC Documentation: The official documentation for the TinyUSB CDC class provides details on all available functions.
  • ESP-IDF USB Examples: ESP-IDF includes a dedicated example for a USB CDC-ACM device.
    • Look in examples/peripherals/usb/device/tusb_cdc in your ESP-IDF installation directory.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top