Getting Started with Raspberry Pi Pico and TinyUSB.
In the previous article I taught you how to setup the development environment for Raspberry Pi Pico and built our first Pi Pico Project. If you haven’t read it yet, I suggest you read it first because this article assumes you know the basics of setting up and flashing the programs onto the Pico Board.
With this article, I introduce TinyUSB to you and build a small USB CDC device with it so that you can understand how to use TinyUSB for your Pico Projects.
What is TinyUSB.
TinyUSB is an open-source cross platform USB Host/Device stack for embedded systems. With TinyUSB we can build Thread-safe, memory-safe, portable and comprehensive USB devices.

To understand how TinyUSB works, let’s quickly look at the above architecture of the TinyUSB stack. It has the application layer on the top which hides all USB protocol complexities from the programmers and allows them to write their application logic abstractly. In this example, as we will be making a simple CDC device, the application logic is to check if the host has sent any data and send some data back to the PC. With TinyUSB we can abstractly write this communication logic in the application layer without dealing with the great complexity of the USB protocol.
Next the stack has the Class API layer which has both host class and device class. The class API layer consists of high level APIs that match USB classes. A USB class is a standardized type of USB device defined by the USB Implementers’ Forum (USB-IF) which tells the operating system what kin of device you are and how to talk to you.
TinyUSB allows us to build comprehensive devices including classes like CDC (Communication Device Class), HID (Human Interface Device), MSC (Mass Storage Class), MIDI (Musical Instrument) and Audio(Microphones/speakers)
Next, we have the USB Drivers that handle packet transfers and this is what actually makes the Pico looks like the device we want (a CDC device in this guide) to the computer.
Next we have the OS Abstraction Layer (OSAL) which is a thin compatibility layer that makes TinyUSB run on different environments like RTOSes like FreeRTOS or bare-metal (No OS). Finally we have the Hardware Driver Layer where TinyUSB talks to the chip’s (RP2040) USB hardware.
Even though TinyUSB allows us to build with both host and device modes, I have limited this guide only to the device mode as Raspberry Pi Pico does not support USB host.
Why TinyUSB + Pi Pico.
The Pi Pico has the chip RP2040 which has a built-in USB device controller and together with TinyUSB we can build many interesting things like a USB serial device, a MIDI keyboard, a HID keyboard or a mouse, a USB device and many more with almost no low-level work.
Also, Raspberry Pi officially chose TinyUSB for the SDK and we get working configuration, a lot of example projects and seamless CMake integration.
TinyUSB and Pico is extremely lightweight and efficient as both were designed for small MCUs. So we can build USB enabled projects having plenty of CPU and RAM left for the actual project taking the advantage of RP2040’s fast dual cores and lots of SRAM.
There is a strong community support for both RP2040 and TinyUSB as RP2040 is extremely inexpensive and popular and TinyUSB is widely used across Adafruit, Arduino, ESP32-S3, STM32 and even funded by Adafruit.
Also, this combination helps you understand descriptors, endpoints and classes without dealing with raw USB registers and that’s a big advantage for beginner while still being powerful for advanced projects.
Making the first TinyUSB project.
As I mentioned above, in this guide I will show you how to make a USB CDC device with TinyUSB. First, we have to start a new project. As I have covered the basics in my first guide, I will not explain every step here and if you think something is missing, please check the first article here: https://medium.com/@chamodh/setup-build-and-flash-your-first-raspberry-pi-pico-program-with-c-c-730373c01eca
When you create the new project, give it a name you like and select the board type as Pico and create the new project.

After creating the project you should see the file tree similar to the following. (Note that the name of the main directory and the usb_cdc_device will be the name you chose when you created the project).

For every TinyUSB project, we need another two files inside our project directory. Namely, tusb_config.hand usb_descriptors.c TinyUSB expects them in the exact names.
So let’s create them first.
Now your file tree should be similar to the following.

Next, we have to add the usb_descriptors.c file to the CMakeLists.txt so that the compiler knows, it should look for this file and compile it too. To do this, we have to change the following line in the generated CMakeLists.txtfile.

Change that line into following, by adding usb_descriptors.cnext to the usb_cdc_device.c

Next, we have to tell the compiler that we need to use the TinyUSB library for our project. To do this we have to change the target_link_libraries section of the CMakeLists.txt

We have to add tinyusb_device and tinyusb_board after pico_stdlib

Also we have to enable stdio over USB and disable stdio over UART for TinyUSB to work. You can do that by changing the following line in the CMakeLists.txt

usb_descriptors.c
The usb_descriptors.c file plays a major role in every TinyUSB project. TinyUSB uses it to learn what type of device we made and tell that back to the host device so that it can identify and communicate with it.
In usb_descriptors.c we can enter specific details of the device we make (such as Manufacturer’s name, id, device type, device name, device serial number device version and many more) so that we can make any custom USB device we need.
However, writing the usb_descriptors.c file is a tedious task for the beginners as lack of proper documentations and various macros TinyUSB uses. To prevent this guide being unnecessarily long, I don’t discuss how we can write it from scratch but I am sharing the fully commented, minimal and beginner friendly usb_descriptors.c file I wrote for this project. You can copy and paste it into your usb_descriptors.c file as this is your TinyUSB project and I wish to bring you a full explanation on how that is written in a next article of this series.
Here is the code I wrote in the usb_descriptors.c file.
#include <tusb.h>
#include <bsp/board_api.h>
#define PROTOTYPE_VID 0xF1D0 //official VID for prototyping
#define PROTOTYPE_PID 0x4000 // we use a fixed product id for our product
tusb_desc_device_t const device_desc = {
.bLength = sizeof(tusb_desc_device_t), //length of this descriptor (in bytes)
.bDescriptorType = TUSB_DESC_DEVICE,
.bcdUSB = 0x0200, //usb specification version number in BCD. here it is USB 2.0
.bDeviceClass = TUSB_CLASS_MISC, //CDC Class falls under MISC class
.bDeviceSubClass = MISC_SUBCLASS_COMMON, //CDC uses common subclass
.bDeviceProtocol = MISC_PROTOCOL_IAD, //this is a protocol code (assigned by the USB-IF)
.bMaxPacketSize0 = CFG_TUD_ENDPOINT0_SIZE,
.idVendor = PROTOTYPE_VID, //we use the prototype VID as the vendor id
.idProduct = PROTOTYPE_PID, //the fixed product ID we set before
.bcdDevice = 0x0100, //device release number in BCD
.iManufacturer = 0x1, //index of the Manufaturer field in the string_dec_arr
.iProduct = 0x02, //index of the product field in the string desc_arr
.iSerialNumber = 0x03, //index of the serial number string
.bNumConfigurations = 0x01
};
//to store the device descriptors that come as strings, we use a pointer to
char const *string_desc_arr[] = {
(const char []) {0x09, 0x04}, // 0 : supported language
"Chamodh Nethsara", // 1 : Manufacturer of the product
"CDC example", // 2 : Product
"10000000" // 3 : serial number of the product
};
//USB hosts identify interfaces by numbers (0, 1, 2) and the CDC device we make need two interfaces
// one for communication the other for the data interface
//we use this enum structure to tell the TinyUSB's Macro to use them
enum {
ITF_NUM_CDC = 0,
ITF_NUM_CDC_DATA,
ITF_NUM_TOTAL
};
//CDC needs three endpoints
//Endpoint : 0x81 (IN) Interript
//Endpoint : 0x02 (OUT) Data Out
//Endpoint : 0x82 (IN) Data IN
#define EPNUM_CDC_NOTIF 0x81
#define EPNUM_CDC_OUT 0x02
#define EPNUM_CDC_IN 0x82
#define CONFIG_TOTAL_LEN (TUD_CONFIG_DESC_LEN + TUD_CDC_DESC_LEN)
uint8_t config_desc[] = {
TUD_CONFIG_DESCRIPTOR(
1,ITF_NUM_TOTAL, 0, CONFIG_TOTAL_LEN, 0x80,100
),
TUD_CDC_DESCRIPTOR(
ITF_NUM_CDC,
0,
EPNUM_CDC_NOTIF,
8,
EPNUM_CDC_OUT,
EPNUM_CDC_IN,
64
),
};
//callback functions
uint8_t const *tud_descriptor_device_cb(void) {
return (uint8_t const *)&device_desc;
};
uint8_t const *tud_descriptor_configuration_cb(uint8_t index) {
(void) index; //we avoid the unused variable error while keeping the function's signature intact
return config_desc;
}
// buffer to hold the string descriptor during the request | plus 1 for the null terminator
static uint16_t _desc_str[32 + 1];
uint16_t const *tud_descriptor_string_cb(uint8_t index, uint16_t langid) {
(void) langid;
if (index == 0) {
memcpy(&_desc_str[1], string_desc_arr[0], 2);
_desc_str[0] = (TUSB_DESC_STRING << 8) | (2 + 2);
return _desc_str;
}
const char *str = string_desc_arr[index];
size_t len = strlen(str);
for (size_t i = 0; i < len; i++) {
_desc_str[1 + i] = str[i];
}
_desc_str[0] = (TUSB_DESC_STRING << 8) | (2 + len * 2);
return _desc_str;
}
tusb_config.h
The tusb_config.hfile is the other cruicial file that every TinyUSB project needs. It tells TinyUSB what features your project needs. It tells you whether you are building a Device or a Host, (a device in our example), which USB classes you use, what endpoint size to use and how big your buffers should be. Unlike the descriptors file, this is easier to understand once you know what the TinyUSB macros needs you to define.
Here is the commented tusb_config.hfile I wrote for this project.
#define CFG_TUD_ENABLED (1) //enables tinyusb device mode
#define CFG_TUD_CDC (1) //tells we use the CDC class for our project
// Legacy RHPORT configuration
// Tells TinyUSB that RHPORT0 is active and should run in:
// - DEVICE mode (not HOST mode)
// - FULL SPEED (12 Mbps) USB operation.
#define CFG_TUSB_RHPORT0_MODE \
(OPT_MODE_DEVICE | OPT_MODE_FULL_SPEED)
#ifndef BOARD_TUD_RHPORT
#define BOARD_TUD_RHPORT (0)
#endif
// end legacy RHPORT
#define CFG_TUD_CDC_RX_BUFSIZE (64) //sets the receiving buffer size to 64 bytes
#define CFG_TUD_CDC_TX_BUFSIZE (64) // sets the sending buffer size to 64 bytes
#define CFG_TUD_CDC_EP_BUFSIZE (64) // endpoint buffer size for CDC endpoints.
// max packet size for endpoint 0 (control endpoint).
#ifndef CFG_TUD_ENDPOINT0_SIZE
#define CFG_TUD_ENDPOINT0_SIZE (64)
#endif
Making a simple adder which can communicate through CDC
Now it’s time to write the actual application logic. As this is the first TinyUSB project you do, I will show you how to make a simple adder to which you can send your operands and get the sum of the operands.
For this, we write our logic in the usb_cdc_device.c file.
#include <stdlib.h>
#include <bsp/board_api.h>
#include <tusb.h>
#include <pico/stdio.h>
#include <pico/stdlib.h>
//turn the built_in_led on and off
void set_built_in_led(bool led_on) {
gpio_put(PICO_DEFAULT_LED_PIN, led_on);
}
//variables that hold the operands
int operand1 = 0;
int operand2 = 0;
//this flag is used to determine which operand the user sends
//when we get the first input, we set this flag to false
bool waiting_for_first_op = true;
// Invoked when CDC interface received data from host
void tud_cdc_rx_cb(uint8_t itf)
{
(void) itf;
char buff[64];
uint32_t count = tud_cdc_read(buff, sizeof(buff)); //put the received message into the buffer (char array) and store the length as count
buff[count] = '\0'; // Null-terminate the string so atoi() works correctly
int value = atoi(buff); //conver the charater buffer to integer
if(waiting_for_first_op){
operand1 = value; //if the program was waiting for the first operand, set the input integer as the value of the first operand
waiting_for_first_op = false; //set the flaf to false
set_built_in_led(true); //turns on the built in led to indicate that the Pico is waiting for the second input
tud_cdc_write_str("first number received! send the second number");//ask user to send the second number
tud_cdc_write_flush(); //flush the buffer to ensure the message is sent fully to the host
}
else {
//get the second operand and calculate the sum
operand2 = value;
int sum = operand1 + operand2;
char out[64]; //a character buffer to hold our output
snprintf(out, sizeof(out), "sum = %d\n", sum); //this is a safe method to write a string buffer that avoids buffer overflow
tud_cdc_write_str(out);
tud_cdc_write_flush();
set_built_in_led(false);
waiting_for_first_op = true;
}
}
int main(void)
{
//initialize the pico
board_init();
//intitialize the tinyUSB stack
tusb_init();
//intialize Pico's default led pin so that we can use it
gpio_init(PICO_DEFAULT_LED_PIN);
//set that pin as an output
gpio_set_dir(PICO_DEFAULT_LED_PIN, GPIO_OUT);
while (true) {
tud_task();
}
return 0;
}
Code explanation.
void tud_cdc_rx_cb(uint8_t itf)
This function is automatically called by TinyUSB whenever the host sends data to the Pico through the virtual COM port.
char buff[64];
uint32_t count = tud_cdc_read(buff, sizeof(buff));
buff[count] = '\0';
This is for reading the incoming USB data.
tud_cdc_read()copies the received characters intobuffcountstores how many bytes were received
char out[64];
snprintf(out, sizeof(out), "sum = %d\n", sum);
tud_cdc_write_str(out);
tud_cdc_write_flush();
This code is responsible for sending the result back to the host
snprintfsafetly creates a text message.tud_cdc_write_str()sends it to the terminal- The LED is turned off because the calculation is done
while (true) {
tud_task();
}
This is the heartbeat of the USB stack. As TinyUSB requires polling the tud_task() function processes all USB activity like reading/writing CDC data, handling enumeration, responding to SETUP packets, detecting line state changes and running class drivers.
Now you can compile this project and flash it onto the board. (I have described this process in the previous guide)
And I tested it using the serial monitor and here is the result

It is working and you have successfully built your first TinyUSB project. However, on Windows, it’s bit difficult to see the custom descriptors we made for our device as Windows try to ignore the descriptors of CDC devices. But I checked on both Ubuntu (Linux) and Android and it correctly identifies the custom USB descriptors I wrote in the usb_descriptors.c file.

You can install any serial terminal application on your OS (macOS, Linux, Windows, Android, iOS) and communicate with this device seamlessly. Here is how it works on Android.


Conclusion
In this guide I introduced the TinyUSB stack and showed how we can use it with Raspberry Pi Pico by building a simple CDC adder. However, the true power of TinyUSB comes when we create USB devices of other classes like HID and MIDI. I have planned to bring such examples in future articles of this series too. So stay tuned by following me on Medium.
You can find the full project code base on my GitHub: https://github.com/chamodhk/usb-cdc-adder
This article was also published on Medium.
Thank you so much for reading! If you learned something new, share this with your friends : ) Happy building.