Implementación a bajo nivel de un dispositivo USB sobre Arduino Leonardo. Prueba de concepto 
El protocolo USB es uno de los protocolos de comunicación más utilizado y, a la vez, uno de los más complicados y esotéricos para una gran mayoría de programadores. Lo habitual cuando nos enfrentamos a un desarrollo embebido con USB es tirar de librerías o frameworks ya existentes para el microcontrolador que estemos usando (dentro del ecosistema de microcontroladores AVR de 8 bits una de las mejores librerías es, sin duda, LUFA). Sin embargo, en este caso he optado por una implementación desde cero para, por un lado, aprender más sobre el protocolo y, por otro lado, desmitificar la programación de dispositivos USB.

Breve introducción al protocolo USB

El protocolo USB es un protocolo serie basado en tramas de paquetes y con una arquitectura muy sencilla. En un bus USB hay un único host, cero o más dispositivos y cero o más hubs. En el protocolo USB es siempre el host el que lleva la voz cantante: un dispositivo nunca envía de forma asíncrona datos a su host, es el host el que interroga al dispositivo si tiene datos para él (esta forma de hacer las cosas, a priori enrevesada, facilita luego mucho la implementación del protocolo).

Cada conexión USB entre un host y un dispositivo está formada por varios “endpoints”, un endpoint es un canal de comunicación entre el host y el dispositivo y cada dispositivo puede tener hasta 16 endpoints (o canales) de comunicación con el host. Cada endpoint es unidireccional, esto es, cada endpoint debe ser definido como de entrada o de salida (siempre desde el punto de vista del host, IN es dispositivo->host, y OUT es host->dispositivo, siempre, aunque estemos en el contexto del dispositivo).

Hay cuatro tipos de endpoints: de control (estos son especiales y pueden ser bidireccionales), bulk, interrupt e isócronos:

Control: Endpoints usados para configurar el dispositivo.
Bulk: Endpoints para transferencias “estándar” de datos. No tienen latencia máxima garantizada.
Interrupt: Endpoints destinados a envíos asíncronos. Tienen una latencia máxima fija garantizada.
Isócronos: Endpoints para transferencias con alto ancho de banda, con detección de errores pero sin reintento de envío de paquetes. No se garantiza la entrega de los paquetes.

Cada dispositivo USB tiene una serie de tablas (normalmente alojadas en flash o en ROM) denominadas descriptores, que son transferidas al host en el arranque (mediante un endpoint de control) y que identifican de forma precisa y estándar la funcionalidad y los endpoints disponibles (tipo, dirección, etc.) en el dispositivo.



Cada dispositivo tiene un descriptor de dispositivo, dentro de cada descriptor de dispositivo podrá haber diferentes configuraciones (descriptores de configuración), a su vez dentro de cada configuración habrá descriptores de interfaces y dentro de éstos habrá descriptores de endpoints. Lo más habitual es que cada dispositivo sólo tenga un único descriptor de configuración. Hay un descriptor de endpoint por cada endpoint en uso por parte del dispositivo. No existe descriptor para el endpoint 0 ya que es un endpoint que siempre debe estar disponible y sólo puede ser de control.

Inicialización

La secuencia de inicialización es como sigue:

1. Cuando el host detecta un dispositivo conectado a un puerto USB realiza una secuencia de reset sobre dicho puerto.

2. A continuación, el host envía un paquete SETUP a través del endpoint de control 0 con el comando GET_DESCRIPTOR(device)

3. El dispositivo, como respuesta, devuelve su descriptor de dispositivo.

4. El host solicita a través del endpoint de control 0 los descriptores de cada una de las configuraciones con el comando GET_DESCRIPTOR(configuration). Normalmente cada dispositivo sólo tiene una única configuración.

5. El dispositivo envía como respuesta también a través del endpoint de control 0 los descriptores de configuración (normalmente uno). Cuando un dispositivo envía el descriptor de configuración, envía dentro de él los descriptores de interface y de endpoints asociados a dicha configuración (ver imagen anterior).

6. Cuando el host (el ordenador) ya “sabe quién es” el dispositivo lo “enumera”, esto es, le asigna una dirección en el bus USB y envía al dispositivo el comando SET_ADDRESS(dirección).

7. El dispositivo queda enumerado y pasa a escuchar en la dirección indicada por el host.

8. El host envía al dispositivo el comando SET_CONFIGURATION(configuracion) para decirle al dispositivo que quiere trabajar con una configuración en concreto (como vimos antes, normalmente sólo hay una configuración por cada dispositivo).

A partir de este momento el dispositivo queda listo para usar y para que se empiecen a usar el resto de sus endpoints.

El protocolo es mucho más complejo y es necesario tener en cuenta algunos comandos más (SET_FEATURE, GET_STATUS, etc.) pero a grosso modo así es como funciona el invento.

Recomiendo a todo aquel interesado en el tema, dos lecturas de referencia:
- USB in a Nutshell
- USB Made Simple
Son muy sencillas de leer y mucho más asequibles que el aterrador tocho de 650 páginas de la especificación oficial.

Implementación en el ATmega32u4

Este microcontrolador, incluido en el Arduino Leonardo, puede ser configurado como dispositivo USB. Debido a que los tiempos USB son extremadamente cortos, es necesario realizar una implementación orientada a interrupciones. Como prueba de concepto he decidido implementar un puerto serie virtual (al estilo de los cables conversores USB a RS232) basado en dos endpoints de tipo bulk, uno de entrada y otro de salida.

El pseudocódigo para implementar el dispositivo USB quedaría así:
interrupción de reset de bus USB
configurar endpoints 0 como control, 1 como bulk in y 2 como bulk out
inicializar buffers
habilitar interrupción de endpoints
fin interrupción

interrupción de endpoints
si ha llegado un paquete de setup por el endpoint 0 entonces
si es de tipo GET_DESCRIPTOR entonces
devolver el descriptor correspondiente
en otro caso, si es de tipo SET_ADDRESS entonces
configurar el módulo USB del chip para usar la dirección indicada
en otro caso, si es de tipo GET_STATUS entonces
devolver 0 (todo ok)
en otro caso, si es de tipo GET_CONFIGURATION entonces
devolver un 1 (la configuración activa es siempre la 1)
en otro caso, si es de tipo SET_CONFIGURATION entonces
no hacer nada (sólo hay una configuración y es la que siempre está activa)
fin si
en otro caso, si ha llegado un token bulk out por el endpoint 2 entonces
transferir bytes del buffer del endpoint 2 al buffer de usuario
en otro caso, si ha llegado un token bulk in por el endpoint 1 entonces
transferir bytes del buffer de usuario al buffer del endpoint 1
fin si
fin interrupción
Los descriptores se encuentran definidos en USBDescriptor.H:
class USBSerialDescriptorContainer {
    public:
        USBDeviceDescriptor deviceDescriptor;
        USBConfigurationDescriptor configurationDescriptor;
        USBInterfaceDescriptor interfaceDescriptor;
        USBEndpointDescriptor inEndpointDescriptor;
        USBEndpointDescriptor outEndpointDescriptor;
} __attribute__ ((packed));

const USBSerialDescriptorContainer MyUSBSerialDescriptorContainer = {
    {  // device descriptor
        0x12,    // descriptor size
        0x01,    // descriptor type (device)
        0x0100,  // usb protocol version
        0x00,
        0x00,
        0x00,
        0x40,    // bMaxPacketSize0 = 64  (for endpoint 0)
        0xA4F6,  // idVendor
        0x5678,  // idProduct
        0x0100,  // product version
        0x00,
        0x00,
        0x00,
        0x01     // one available configuration
    },
    {  // configuration descriptor
        0x09,    // descriptor size
        0x02,    // descriptor type (configuration)
        0x0020,  // total size of this descriptor and rest of descriptors inside this configuration 9 + 9 + 7 + 7 = 32 bytes
        0x01,    // num interfaces = 1
        0x01,    // this configuration number = 1
        0x00,
        0x80,    // not self powered
        0x04     // max power in units of 5 mA (4 * 5 = 20 mA)
    },
    {  // interface descriptor
        0x09,    // descriptor size
        0x04,    // descriptor type (interface)
        0x00,    // interface number (zero based)
        0x00,
        0x02,    // num endpoints = 2
        0xFF,    // class = vendor specific
        0x00,    // subclass
        0x00,
        0x00
    },
    {  // in endpoint descriptor
        0x07,    // descriptor size
        0x05,    // descriptor type (endpoint)
        0x81,    // in endpoint 1
        0x02,    // bulk endpoint
        0x0040,  // max packet size = 64
        0x0A     // 10ms for interval polling
    },
    {  // out endpoint descriptor
        0x07,    // descriptor size
        0x05,    // descriptor type (endpoint)
        0x02,    // out endpoint 2
        0x02,    // bulk endpoint
        0x0040,  // max packet size = 64
        0x0A     // 10ms for interval polling
    }
};

Para hacer la prueba de concepto lo he implementado todo, por ahora, en una única clase “USB” a modo de utility class (con atributos y métodos estáticos):
ISR(USB_GEN_vect) {
    USB::__general_isr();
}

void USB::__general_isr() {
    if (UDINT & (1 << EORSTI))
        USB::configureEndpoint0();
    UDINT = 0;
}

ISR(USB_COM_vect) {
    USB::__endpoint_isr();
}

void USB::sendDataToEndpoint0() {
    while (USB::toSendSize > 0) {
        // wait for host ready for in packet
        while (!(UEINTX & ((1 << TXINI) | (1 << RXOUTI))))
            ;
        if (UEINTX & (1 << RXOUTI))
            break;
        // send in packet
        uint16_t n = (USB::toSendSize >= 64) ? 64 : USB::toSendSize;
        USB::writeFromBuffer(n, USB::toSend);
        USB::toSend += n;
        USB::toSendSize -= n;
        UEINTX = ~(1 << TXINI);
    }
}

void USB::__endpoint_isr() {
    if (USB::status == USB::STATUS_IDLE) {
        UENUM = 0;
        uint8_t aux = UEINTX;
        if (aux & (1 << RXSTPI)) {
            // setup packet received
            USB::readOnBuffer(8, USB::buffer);
            USB::setupPacketReceived = (USBSetupPacket *) USB::buffer;
            UEINTX = ~((1 << RXSTPI) | (1 << RXOUTI) | (1 << TXINI));
            if (USB::setupPacketReceived->bRequest == USBSetupPacket::REQUEST_GET_DESCRIPTOR) {
                uint8_t descriptorType = USB::setupPacketReceived->wValue >> 8;
                USB::toSendSize = 0;
                if (descriptorType == 1) {
                    // get the device descriptor
                    USB::toSend = (uint8_t *) &MyUSBSerialDescriptorContainer.deviceDescriptor;
                    USB::toSendSize = sizeof(USBDeviceDescriptor);
                    USB::toSendSize = (USB::toSendSize > USB::setupPacketReceived->wLength) ? USB::setupPacketReceived->wLength : USB::toSendSize;
                }
                else if (descriptorType == 2) {
                    // get the configuration descriptor
                    USB::toSend = (uint8_t *) &MyUSBSerialDescriptorContainer.configurationDescriptor;
                    USB::toSendSize = sizeof(USBSerialDescriptorContainer) - sizeof(USBDeviceDescriptor);
                    USB::toSendSize = (USB::toSendSize > USB::setupPacketReceived->wLength) ? USB::setupPacketReceived->wLength : USB::toSendSize;
                }
                USB::sendDataToEndpoint0();
            }
            else if (USB::setupPacketReceived->bRequest == USBSetupPacket::REQUEST_SET_ADDRESS) {
                UEINTX = ~(1 << TXINI);
                USB::address = USB::setupPacketReceived->wValue;
                UDADDR = USB::address;
                while (!(UEINTX & (1 << TXINI)))
                    ;
                UDADDR |= (1 << ADDEN);
                UEINTX = ~(1 << TXINI);
            }
            else if (USB::setupPacketReceived->bRequest == USBSetupPacket::REQUEST_GET_STATUS) {
                USB::buffer[0] = 0;
                USB::buffer[1] = 0;
                USB::toSend = (uint8_t *) USB::buffer;
                USB::toSendSize = 2;
                USB::sendDataToEndpoint0();
            }
            else if (USB::setupPacketReceived->bRequest == USBSetupPacket::REQUEST_GET_CONFIGURATION) {
                USB::buffer[0] = 1;
                USB::toSend = (uint8_t *) USB::buffer;
                USB::toSendSize = 1;
                USB::sendDataToEndpoint0();
            }
            else if (USB::setupPacketReceived->bRequest == USBSetupPacket::REQUEST_SET_CONFIGURATION) {
                UEINTX = ~(1 << TXINI);
            }
        }
        // check for bulk out transfer
        UENUM = 2;
        if (UEINTX & (1 << RXOUTI)) {
            UEINTX &= ~(1 << RXOUTI);
            uint16_t numBytesReceived = (((uint16_t) UEBCHX) << 8) | ((uint16_t) UEBCLX);
            numBytesReceived = (numBytesReceived > 64) ? 64 : numBytesReceived;
            USB::readOnBuffer(numBytesReceived, USB::rxBuffer);
            USB::rxBufferFull = true;
            UEINTX &= ~(1 << FIFOCON);
        }
        // check for bulk in transfer
        UENUM = 1;
        if (UEINTX & (1 << TXINI)) {
            UEINTX &= ~(1 << TXINI);
            // TODO: write data to buffer
            UEINTX &= ~(1 << FIFOCON);
        }
    }
    else if (USB::status == USB::STATUS_HALT) {
        UENUM = 0;
        UEINTX = ~((1 << RXSTPI) | (1 << RXOUTI) | (1 << TXINI));
        UECONX = (1 << STALLRQ) | (1 << EPEN);   // stall
    }
}

Una vez conectado el Arduino Leonardo y enumerado el dispositivo podemos, desde un ordenador con Linux, reconocer el nuevo dispositivo como interface serie:

1. Instalamos el driver usbserial:
modprobe usbserial vendor=0xa4f6 product=0x5678
Esto instala el driver genérico usbserial y lo asocia a nuestro dispositivo creando el dispositivo /dev/ttyUSB0 en el sistema de archivos.

2. Emitimos una cadena de caracteres de ejemplo para hacer la prueba:
echo -en "Prueba desde Linux\n\0" > /dev/ttyUSB0
3. Voilà:





El código es compatible también con el microcontrolador ATmega16u4 y es muy fácil de adaptar al resto de microcontroladores USB de AVR. Puede descargarse de la sección soft.

Administrator (Avelino Herrera Morales) 
Gracias Carlos. Atendí a la sugerencia que me hiciste y enumeré de forma más ordenada el proceso de inicialización :-)

Carlos Moisés 
La introducción al protocolo USB está genial.

Comentarios 
Lo sentimos. No se permiten nuevos comentarios después de 90 días.