Skip to main content

AVR USB Made Simple - Enumeration

· 15 min read

In this multi-part series, we will explore USB implementation on AVR microcontrollers, starting with the basics of USB enumeration. USB can be found all around us in our everyday lives. It's in our phones, our computers, our cars, and even our home appliances. It's a ubiquitous standard that has been around for decades and is still going strong. However we often take it for granted as engineers and developers. Silicon vendors have made it so easy to get a device up and running with USB via their libraries and examples that we don't often think about what's going on under the hood.

This series of posts is going attempt to demystify USB and show how to implement it in a bare-metal environment. For these posts I'll be using the atmega32u4 breakout board which has a USB 1.1 (Fullspeed) interface and is breadboard friendly. The simple register based setup of the AVR microcontrollers makes it a great platform to learn the basics of USB with as little abstraction as possible so not to many of the details are hidden from us. It's worth mentioning that there is a very popular library called LUFA that implements a USB stack for AVR microcontrollers. It's a great library and easy to use but it's also a bit of a black box.

You can find the code to follow along on our Github. Just be sure you are looking at the control_basic_enum tag. The repository is based on our avr-cmake-template and built using the avr-gcc toolchain.

USB basics

A great place to learn the basics of USB communication is this USB Made Simple site. It starts out giving a high-level overview of the USB standard and then goes into the details of the protocol. I highly recommend reading through the site at least up to Part 4 to better understand the concepts I'll be talking about in this post.

USB Enumeration

The bare minimum functionality of a USB device is to enumerate with the host which is the process of the host collecting information about the device and it's capabilities. The host does so by initiating Control transfers to the USB endpoint 0, querying information such as the devices product name, manufacturer, serial number and supported interfaces. The host will then use this information to load the appropriate drivers to continue device specific communication.

AVR USB

Our objective in this post is not to implement any host driver functionality but to get our device to enumerate with the host. When we are all done the kernel log output on a Linux machine should look like this when the device is plugged in:

[986045.250483] usb 3-8.2: new full-speed USB device number 23 using xhci_hcd
[986045.401173] usb 3-8.2: New USB device found, idVendor=dead, idProduct=beef, bcdDevice= 0.01
[986045.401185] usb 3-8.2: New USB device strings: Mfr=1, Product=2, SerialNumber=3
[986045.401189] usb 3-8.2: Product: avr usb made simple
[986045.401192] usb 3-8.2: Manufacturer: everydaydev
[986045.401195] usb 3-8.2: SerialNumber: 2023

USB Init

Our first step is to create our USB init function. This will handle setting up the USB peripheral power rails, configuring the PLL for our clock source, and enabling any required interrupts. Note: In this example we are using the external 16MHz crystal on the atmega32u4 breakout board. If you are using a different board or a different clock configuration you will need to adjust the PLL. The main objective is to get a 48MHz clock source for the USB peripheral.

void usb_init(void) {
// Power-On USB pads regulator
UHWCON |= (1 << UVREGE);

// VBUS int enable, VBUS pad enable, USB Controller enable
USBCON |= (1 << USBE) | (1 << OTGPADE) | (1 << FRZCLK);

// Toggle the FRZCLK to get WAKEUP IRQ
USBCON &= ~(1 << FRZCLK);
USBCON |= (1 << FRZCLK);

// Set the PLL input divisor to be 1:2 since we have
// at 16MHz input (we want 8MHz output on the PLL)
PLLCSR |= (1 << PINDIV);

// Default USB postscaler is fine as it generates
// the 48Mhz clock from the 8Mhz PLL input by default

// Start the PLL
PLLCSR |= (1 << PLLE);

// Wait for the PLL to lock
while (!(PLLCSR &(1<<PLOCK)));

// Leave power saving mode
USBCON &= ~(1 << FRZCLK);

// Attach the device by clearing the detach bit
// This is acceptable in the case of a bus-powered device
// otherwise you would initiate this step based on a
// VBUS detection.
UDCON &= ~(1 << DETACH);

// Enable the USB Reset IRQ. This IRQ fires when the
// host sends a USB reset to the device to kickoff
// USB enumeration. Init of the USB will continue in the
// ISRs when the reset signal is received.
UDIEN |= (1 << EORSTE);
}

USB Interrupts

A crucial part of USB communication is the handling of interrupts as all communication will be initiated through them. The AVR USB peripheral has two categories of interrupts: The GEN interrupt vector is where information such as bus resets and VBUS changes are handled and the COM interrupt vector is where all USB communication is handled. This includes Control, Bulk, Interrupt, and Isochronous transfers. Our basic ISRs to achieve USB enumeration look like this:

ISR(USB_GEN_vect) {
// Check if a USB reset sequence was received from the host
if (UDINT & (1<<EORSTI)) {
// Clear the interrupt flag
UDINT &= ~(1<<EORSTI);
// Init our device endpoints
_endpoint_init();
// Enable our Setup RX interrupt. This causes
// an ISR to fire when a Setup packet is received
UEIENX |= (1<<RXSTPE);
}
}

ISR(USB_COM_vect) {
// Check which endpoint caused the interrupt
switch (UEINT) {
case (1<<EPINT0):
// Select EP 0 before checking the interrupt register
UENUM = 0;
// Check if we received a Setup packet
if (UEINTX & (1<<RXSTPI)) {
// Handle the setup packet
_processSetupPacket();
}
break;

default:
break;
}
}

As you can see we are using the GEN vector to detect the End Of Reset interrupt. This occurs when the host resets a device and is wanting the device to begin the enumeration process. When this interrupt fires we then initialize our endpoints - We'll come back to that later. Inside the COM vector you can see we determine which Endpoint caused a communications interrupt (Control transfers always use Endpoint 0) and then we act on what type of communication interrupt was triggered. In our bare example we are only looking for Setup packets. These are the packets that the host sends to the device to query information. We will dive into our Setup packet handler shortly.

Endpoint Initialization

Endpoints are "pipes" that data is sent and received in and must be initialized with a bank size indicating how much data can be written to/from the pipe during any transfer. In our bare enumeration example we will be implementing just the our Control endpoint (EP0) so we can handle Setup packets from the Host. We start by selecting EP0, resetting its FIFO and enabling it in the OUT direction. Once complete, we enable the endpoint interrupt which allow us to detect when a Setup packet is received.

bool _endpoint_init(void) {
// Select Endpoint 0
UENUM = 0x00;
// Reset the endpoint fifo
UERST = 0x7F;
UERST = 0x00;
// Enable the endpoint
UECONX |= (1 << EPEN);
// Configure endpoint as Control with OUT direction
UECFG0X = 0;
// Configure endpoint size
UECFG1X = (1 << EPSIZE1) | (1 << EPSIZE0);
// Allocate the endpoint buffers
UECFG1X |= (1 << ALLOC);
// Enable the endpoint interrupt
UEIENX |= (1 << RXSTPE) | (1 << RXOUTE);
// Check if endpoint configuration is ok
return((UESTA0X & (1 << CFGOK)));
}

Setup Packet Handling

The final piece (and most important) is the handling of the Setup packet. This is where the host will query information about the device, set the bus address, and set the device configuration. The received Setup packet is 8 bytes long and contains information such as the request type, request, value, index, and length. The first step is to read the 8 bytes from the FIFO and then acknowledge the packet by clearing the RXSTPI bit. We then check the request type to determine what type of request was sent. In our example we are only handling the Standard request type. From here you can see we handle GET_STATUS, SET_ADDRESS, and GET_DESCRIPTOR, and SET_CONFIGURATION requests from the Host allowing it to fully configure and enumerate the device in the OS.

static void  _processSetupPacket(void) {
// Read the 8 bytes from the setup packet. Depending on the type of request
// each value may have a different use/meaning. Reference "The SETUP Packet" section
// of https://www.usbmadesimple.co.uk/ums_4.htm for more information on what each byte
// means for a given request type.
uint8_t bmRequestType = UEDATX;
uint8_t bRequest = UEDATX;
uint8_t wValue_l = UEDATX;
uint8_t wValue_h = UEDATX;
uint8_t wIndex_l = UEDATX;
uint8_t wIndex_h = UEDATX;
uint8_t wLength_l = UEDATX;
uint8_t wLength_h = UEDATX;
uint16_t descriptorLength = 0;

// Ack the received setup package by clearing the RXSTPI bit
UEINTX &= ~(1 << RXSTPI);

if ((bmRequestType & 0x60) == 0) { // Standard request type
switch (bRequest) {
case GET_STATUS:
// Reply with 16 bits for our status. We are self powered, no remote-wakeup
// and we are not halted.
UEDATX = 0;
UEDATX = 0;
// Clear the TXINI bit to initiate the transfer
UEINTX &= ~(1 << TXINI);
// Wait for ZLP from host
while (!(UEINTX & (1 << RXOUTI)));
// Clear the RXOUTI bit to acknowledge the packet
UEINTX &= ~(1 << RXOUTI);
break;

case SET_ADDRESS:
// Section 22.7 of https://ww1.microchip.com/downloads/en/devicedoc/atmel-7766-8-bit-avr-atmega16u4-32u4_datasheet.pdf
// Device stores received address in the UDADDR register.
UDADDR = (wValue_l & 0x7F);
// Device should then respond with a ZLP to acknowledge the request
UEINTX &= ~(1 << TXINI);
// Wait for the bank to become ready again (TIXINI set)
while (!(UEINTX & (1<<TXINI)));
// After sending the ZLP, the device should apply the address by setting the ADDEN bit
UDADDR |= (1 << ADDEN);
break;

case GET_DESCRIPTOR:
switch (wValue_h) {
case DESC_DEVICE:
// Retrieve the Device desc length from the descriptor
// structure. This is the first byte of the descriptor.
descriptorLength = pgm_read_byte(&DeviceDescriptor[0]);
// Send it back to the host
_sendDescriptor((uint8_t*) DeviceDescriptor, descriptorLength);
break;

case DESC_CONFIG:
// The Host will first request the base Config descriptor which is of length
// 9 to determine how many interfaces are available.
// However, once the host determines how many interfaces are available it will then
// do another Config descriptor read with the full length of the descriptor.
descriptorLength = wLength_l + (wLength_h << 8);
// Send the descriptor with the requested length
_sendDescriptor((uint8_t*)ConfigDescriptor, descriptorLength);
break;

case DESC_STRING:
switch (wValue_l) {
case DESC_STRING_LANG:
// Retrieve the Language desc length from the descriptor
// structure. This is the first byte of the descriptor.
descriptorLength = pgm_read_byte(&LanguageDescriptor[0]);
// Send it back to the host
_sendDescriptor((uint8_t*) LanguageDescriptor,descriptorLength);
break;

case DESC_STRING_MANUF:
// Retrieve the Manufacturer string desc length from the descriptor
// structure. This is the first byte of the descriptor.
descriptorLength = pgm_read_byte(&ManufacturerStringDescriptor[0]);
// Send it back to the host
_sendDescriptor((uint8_t*) ManufacturerStringDescriptor,descriptorLength);
break;

case DESC_STRING_PROD:
// Retrieve the Product string desc length from the descriptor
// structure. This is the first byte of the descriptor.
descriptorLength = pgm_read_byte(&ProductStringDescriptor[0]);
// Send it back to the host
_sendDescriptor((uint8_t*) ProductStringDescriptor,descriptorLength);
break;

case DESC_STRING_SERIAL:
// Retrieve the Serial string desc length from the descriptor
// structure. This is the first byte of the descriptor.
descriptorLength = pgm_read_byte(&SerialStringDescriptor[0]);
// Send it back to the host
_sendDescriptor((uint8_t*) SerialStringDescriptor,descriptorLength);
break;

default:
break;
}
break;

default:
break;
}
break;

case SET_CONFIGURATION:
// Select EP 0
UENUM = 0;
// Reply with a ZLP to acknowledge the request
UEINTX &= ~(1 << TXINI);
// Wait for the bank to be ready again
while (!(UEINTX & (1<<TXINI)));
break;

default:
// Invalid request was sent. Reply with a STALl
UECONX |= (1 << STALLRQ);
break;
}
}
else if((bmRequestType & 0x60) == 0x40) { // Vendor specific request type
switch(bRequest) {
default:
// Unsupported vendor specific request. Reply with a STALL
UECONX |= (1 << STALLRQ);
break;
}
}
else { // Invalid request type
// Reply with a STALL
UECONX |= (1 << STALLRQ);
}
}

The last piece is our function for sending descriptors back to the host. This is pretty straight forward but it should be noted that we take care to break up the descriptor into 8 byte chunks. This is because the host will only read 8 bytes at a time.

static void _sendDescriptor(const uint8_t* descriptor, uint16_t length) {
// See section 22.12.2 of https://ww1.microchip.com/downloads/en/devicedoc/atmel-7766-8-bit-avr-atmega16u4-32u4_datasheet.pdf
// for an illustration of the "Control Read" process. Specifically the "DATA" and "STATUS"
// portion of the timing diagram are handled here.

// We are going to chunk the descriptor into 8 byte packets. Looping until we have finished
for(uint16_t i = 1; i <= length; i++) {
if(UEINTX & (1 << RXOUTI)) {
// We received an OUT packet which means
// the HOST wants us to abort.
// Clear the RXOUTI bit to acknowledge the packet
UEINTX &= ~(1 << RXOUTI);
return;
}

// Load the next byte from our descriptor
UEDATX = pgm_read_byte(&descriptor[i-1]);
// If our packet is full
if(((i%8) == 0)) {
// Clear the TXINI bit to initiate the transfer
UEINTX &= ~(1 << TXINI);
// Wait for transmission to complete (TXINI set) or
// the HOST to abort (RXOUTI set)
while (!(UEINTX & ((1 << RXOUTI) | (1<<TXINI))));
}
}
// Go ahead and transmit the remaining data (if there is any) if the HOST
// hasn't asked us to abort
if((!(UEINTX & (1 << RXOUTI)))) {
// Clear the TXINI bit to initiate the transfer
// to send the remaining data we may have queued up
UEINTX &= ~(1 << TXINI);
// Wait for the ACK back from the host (RXOUTI set)
while (!(UEINTX & (1 << RXOUTI)));
}

// Clear the RXOUTI bit to acknowledge the packet
UEINTX &= ~(1 << RXOUTI);
}

Descriptors

USB Descriptors are a crucial part of USB enumeration and they are the mechanism by which the host learns about the device and it's capabilities. The descriptors are a data structure that is sent to the host when requested via a GET_DESCRIPTOR request. The most common descriptors are Device, Configuration, Interface, Endpoint, and String descriptors. The host will request these descriptors, learning a bit more about the device each time, and use that information to inform it's next decision which might be to request more information or begin loading a driver for the device. Our descriptors are pretty basic at the moment since we don't actually implement any functionality on the device. Our basic descriptors and commented explanations are below.

// USB descriptors (example, replace with your own)
const uint8_t PROGMEM DeviceDescriptor[] = {
0x12, // bLength
0x01, // bDescriptorType (Device == 1)
0x01, 0x01, // bcdUSB (USB 1.1 for Full Speed)
0x00, // bDeviceClass (0 for composite device)
0x00, // bDeviceSubClass
0x00, // bDeviceProtocol
0x08, // bMaxPacketSize0 (8 bytes)
0xad, 0xde, // idVendor (0xdead)
0xef, 0xbe, // idProduct (0xbeef)
0x01, 0x00, // bcdDevice (Device version)
0x01, // iManufacturer (Index of manufacturer string descriptor)
0x02, // iProduct (Index of product string descriptor)
0x03, // iSerialNumber (Index of serial number string descriptor)
0x01 // bNumConfigurations (Number of configurations)
};

const uint8_t PROGMEM ConfigDescriptor[] = {
0x09, // bLength
0x02, // bDescriptorType (Configuration == 2)
0x12, 0x00, // wTotalLength (Total length of configuration descriptor and sub-descriptors)
0x01, // bNumInterfaces (Number of interfaces in this configuration)
0x01, // bConfigurationValue (Configuration value, must be 1)
0x00, // iConfiguration (Index of string descriptor for this configuration)
0x80, // bmAttributes (Bus-powered, no remote wakeup)
0xFA, // bMaxPower (Maximum power consumption, 500mA)
0x09, // bLength = 0x09, length of descriptor in bytes
0x04, // bDescriptorType = 0x04, (Interface == 4)
0x00, // bInterfaceNumber = 0;
0x00, // bAlternateSetting = 0;
0x00, // bNumEndpoints = USB_Endpoints;
0xFF, // bInterfaceClass = 0xFF, classcode: custome (0xFF)
0xFF, // bInterfaceSubClass = 0xFF, subclasscode: custome (0xFF)
0xFF, // bInterfaceProtocol = 0xFF, protocoll code: custome (0xFF)
0x00 // iInterface = 0, Index for string descriptor interface
};

const uint8_t PROGMEM LanguageDescriptor[] = {
0x04, // bLength - Length of sting language descriptor including this byte
0x03, // bDescriptorType - (String == 3)
0x09,0x04 // wLANGID[x] - (0x0409 = English USA)
};

const uint8_t PROGMEM ManufacturerStringDescriptor[] = {
24, // bLength - Length of string descriptor (including this byte)
0x03, // bDescriptorType - (String == 3)
'e',0x00, // bString - Unicode Encoded String (16 Bit)
'v',0x00,
'e',0x00,
'r',0x00,
'y',0x00,
'd',0x00,
'a',0x00,
'y',0x00,
'd',0x00,
'e',0x00,
'v',0x00
};

const uint8_t PROGMEM ProductStringDescriptor[] = {
40, // bLength - Length of string descriptor (including this byte)
0x03, // bDescriptorType - (String == 3)
'a',0x00, // bString - Unicode Encoded String (16 Bit)
'v',0x00,
'r',0x00,
' ',0x00,
'u',0x00,
's',0x00,
'b',0x00,
' ',0x00,
'm',0x00,
'a',0x00,
'd',0x00,
'e',0x00,
' ',0x00,
's',0x00,
'i',0x00,
'm',0x00,
'p',0x00,
'l',0x00,
'e',0x00,
};

const uint8_t PROGMEM SerialStringDescriptor[] = {
0x0A, // bLength - Length of string descriptor (including this byte)
0x03, // bDescriptorType - (String == 3)
'2',0x00, // bString - Unicode Encoded String (16 Bit)
'0',0x00,
'2',0x00,
'3',0x00
};

Conclusion

That's it! We now have a device that will enumerate with the host and provide basic information about itself. In the next post we will add a bit more functionality to our device by adding handling for "Vendor specific" control transfers allowing us to read/write small amounts of data from/to the device. We will also demonstrate a couple of different ways to write data to a USB device via Control transfers from a Host OS using C, NodeJS, and Python.