CH552
Este microcontrolador está basado en un núcleo compatible MCS51 (8051) y, además de los periféricos estándar del 8051, dispone de una interfaz USB full-speed (12 Mbps) que sirve, además. como interfaz para tostar el integrado. Para desarrollar este pequeño proyecto utilizaremos una de las muchas placas disponibles en AliExpress que contienen este microcontrolador. Son placas muy baratas (muchas de menos de 2 ¤) y, para nuestro caso, sólo será necesaria una que incluya, como mínimo:
- El puerto USB (para conectar al ordenador).
- Un botón de bootloader (manteniendo pulsado este botón mientras se conecta el CH551 por USB al ordenador, hace que el micro entre en modo de programación y podamos tostarlo desde el ordenador).
- Un led conectado a cualquiera de sus pines GPIO.
El CH552 incluye dentro ya un reloj a 24 MHz calibrado y los PLLs para llegar a los 48 MHz que son necesarios para el subsistema USB del microcontrolador.
Compilador
Desgraciadamente no hay implementación oficial del backend del GCC (y, por tanto del g++) para la arquitectura 8051 por lo que nos limitaremos a programarlo en C usando el compilador SDCC. El SDCC podemos instalarlo de repositorio o compilarlo de forma sencilla:
$ ./configure --prefix=/opt/sdcc --disable-pic14-port --disable-pic16-port $ make $ make install
De esta forma tendremos en /opt/sdcc un compilador SDCC totalmente funcional. El SDCC soporta de forma nativa la arquitectura 8051 por lo que apenas serán necesarios algunos retoques en la línea de comandos para compilar nuestros programas para CH552.
Particularidades de la arquitectura MCS51
La arquitectura MCS51 (que es como se llama a la arquitectura compartida por todos los microcontroladores 8051 y compatibles, como el CH552) es una arquitectura un tanto peculiar. Se puede resumir en los siguientes puntos:
1. Arquitectura de tipo harvard a nivel funcional (instrucciones y datos en memorias separadas, aunque sólo a nivel funcional, internamente es von newmann).
2. Dos espacios de memoria RAM:
2.1. La IRAM que es siempre de 256 bytes divididos en 128 bytes de datos y 128 bytes de registros de función especial (SFR) para acceder a los periféricos.
2.2. La XRAM que es opcional y puede ser de hasta 64 KBytes. El acceso a la XRAM sólo se puede realizar mediante la instrucción "movx".
Es una arquitectura muy limitada pero que aún se sigue usando en microcontroladores de gama baja como el CH552.
Primera prueba de concepto: un blinker
A todos los efectos, el CH552 es un 8051 y, por tanto comparte con él todos los registros estándar de la familia MCS51, incluidos los registros de configuración de timer. Los registros relacionados con la configuración del reloj y GPIO sí son específicos del CH552. El proceso para hacer parpadear un led se puede resumir en:
* En la rutina de interrupción del timer 0:
- Incluimos la lógica de parpadeo del led.
* En función main():
- Configuramos el PLL del CH552 para que vaya a la frecuencia que necesitamos. Al arrancar, el CH552 va a 6 MHz pero podemos subir la frecuencia hasta 24 MHz como máximo.
- Configuramos el pin GPIO donde está conectado el led como pin de salida.
- Configuramos el timer 0 (puede ser cualquiera, yo elegí el 0).
Habilitamos las interrupciones.
A continuación puede verse el código fuente de lo que sería un sencillo blinker basado en el timer 0 del 8051.
#include <stdint.h> __sfr __at (0xB0) P3; // led at P3.2 (https://github.com/wuxx/nanoCH55x/blob/master/hardware/nanoCH55x-v1.1.pdf) __sfr __at (0x96) P3_MOD_OC; __sfr __at (0x97) P3_DIR_PU; __sfr __at (0xB9) CLOCK_CFG; __sfr __at (0x88) TCON; __sfr __at (0x89) TMOD; __sfr __at (0x8A) TL0; __sfr __at (0x8C) TH0; __sfr __at (0xA8) IE; __sfr __at (0xA1) SAFE_MOD; __sfr __at (0xC9) T2MOD; uint16_t x; void timer0Init() { const uint16_t TIMER_0_INIT_VALUE = 65535 - 2000; T2MOD &= 0xEF; // T0_CLK=0 --> freq = Fsys / 12 = 24 MHz / 2 = 2 MHz TMOD = (TMOD & 0xF0) | 0x01; // mode 1 (16 bit timer), T0_GATE=0, T0_CT=0 TH0 = (uint8_t) (TIMER_0_INIT_VALUE >> 8); TL0 = (uint8_t) (TIMER_0_INIT_VALUE & 0x00FF); TCON = (TCON & 0xCF) | 0x10; // clear timer flag (TF0=0) and start timer (TR0=1) } void timerIsr() __interrupt (1) { // Timer 0 ISR x++; if (x == 1000) { // at 1 KHz, so each 1000 calls we toggle led to toggle it each second P3 ^= 0x04; // switch P3.2 x = 0; } timer0Init(); } int main() { // set Fsys = 24 MHz SAFE_MOD = 0x55; SAFE_MOD = 0xAA; CLOCK_CFG = (CLOCK_CFG & 0xF8) | 0x06; SAFE_MOD = 0; // configure P3.2 as push-pull output pin P3_MOD_OC &= 0xFB; P3_DIR_PU |= 0x04; // configure timer 0 at Fsys / 12 = 2 MHz: T0_CT=0, T0_CLK=0, T0_GATE=0 timer0Init(); // enable timer 0 interrupts IE |= 0x80; // global IE |= 0x02; // timer 0 x = 0; // sleep while (1) ; }
Configuramos el timer 0 en modo 1 (timer de 16 bits sin recarga automática, esto es, hay que recargaro cada vez que queramos esperar un intervalo de tiempo). La función "timer0Init" se encarga de inicializar el timer 0 que, en el caso del CH552 se incrementa con una frecuencia de ${F_{sys} \over 12} = {24 \over 12} = 2\ MHz$. Si queremos que el timer 0 se desborde una vez por segundo, habría que cargar el valor 65535 - 2000000 en el contador, pero eso no es posible, pues se trata de un contador de 16 bits y, por tanto los valores de carga deben estar entre 0 y 65535. En este caso lo que se ha hecho es buscar una frecuencia de desbordamiento de 1 KHz, para lo cual se carga el timer 0 con el valor 65535 - 2000. Como se incrementa a 2 MHz, después de 2000 incrementos el contador se desbordará, con lo que tendremos un tiempo de desbordamiento de 1 / 1000 segundos. Si cada ${1 \over 1000}$ segundos se dispara la interrupción de timer 0 y en esa interrupción, a su vez, cambiamos el valor del bit del led cada 1000 llamadas (la variable x que se usa como contador en el código), conseguiremos que el led cambie de estado una vez por segundo.
# compilamos $ /opt/sdcc/bin/sdcc -mmcs51 -c -o main.rel main.c # enlazamos $ /opt/sdcc/bin/sdcc -mmcs51 --xram-loc 0 --xram-size 1024 --code-loc 0 --code-size 14336 -o main.ihx main.rel # convertimos el fichero hex a binario (objcopy es utilidad estándar en Linux) $ objcopy -Iihex -Obinary main.ihx main.bin # tostamos el binario en el CH552 (previamente insertado mientras pulsamos el botón de bootloader) $ ch55xtool -f main.bin
El parámetro "-mmcs51" sirve para indicar que la arquitectura destino es 8051 y los parámetros adicionales que se pasan en la fase de enlazado sirven para indicar al compilador SDCC el tamaño y la disposición de la memoria en el CH552. "objcopy" es una utilidad estándar en sistemas Linux mientras que "ch55xtool" es una utilidad de Python (se instala con el "pip") encargada de tostar el integrado.
Una vez tostado el CH552 lo podemos desconectar del conector USB y volver a conectarlo (esta vez sin pulsar el botón de bootloader) para comprobar que el led cambia de estado una vez por segundo.
Protocolo USB
En este post, donde se realiza una implementación similar para un microcontrolador AVR (ATMega32u4), se explica de forma resumida el protocolo USB, al igual que en varios documentos disponibles de forma abierta en la web:
https://www.usbmadesimple.co.uk
https://www.beyondlogic.org/usbnutshell
https://www.engineersgarage.com/signal-and-encoding-of-usb-system-part-5-6
https://www.keil.com/pack/doc/mw/USB/html/_u_s_b__endpoints.html
En este caso se han implementado los mensajes más usuales y mínimos del protocolo USB:
- GET_DESCRIPTOR (para dispositivo y configuración)
- SET_ADDRESS
- SET_CONFIGURATION
- GET_STATUS
Para que un dispositivo sea 100% compatible USB debe implementar también otros mensajes adicionales, pero para que un Linux reconozca un dispositivo, es suficiente con que dicho dispositivo responda a los mensajes enumerados.
El objetivo será que el CH552 sea detectado como un dispositivo de caracteres básico (estilo puerto serie) por parte del kernel de Linux, que el kernel cree un dispositivo en /dev/loQueSea y que se pueda encender y apagar el led de la placa CH552 mediante comandos "echo" enviados a dicho dispositivo:
$ echo 0 > /dev/loquesea # apagar el led $ echo 1 > /dev/loquesea # encender el led
Dispositivo USB
Definiremos un dispositivo USB muy básico con un descriptor de dispositivo, otro de configuración y dos endpoints: uno de entrada y otro de salida, ambos de tipo "bulk". El descriptor del dispositivo será el siguiente:
__code const deviceDescriptor myDeviceDescriptor = { 18, // bLength 0x01, // bDescriptorType (device) 0x0110, // bcdUSB (USB 1.1) 0x00, // bDeviceClass 0x00, // bDeviceSubClass 0x00, // bDeviceProtocol 64, // bMaxPacketSize 0xF055, // idVendor 0x0001, // idProduct 0x0000, // bcdDevice 0x00, // iManufacturer 0x00, // iProduct 0x00, // iSerialNumber 0x01 // bNumConfigurations };
Mientras que el descriptor de configuración completo (incluyendo los descriptores de interfaz y endpoints) será el siguiente:
__code const myConfigurationDescriptorType myConfigurationDescriptor = { { // configuration descriptor 9, // bLength 0x02, // bDescriptorType sizeof(configurationDescriptor) + sizeof(interfaceDescriptor) + sizeof(endpointDescriptor) + sizeof(endpointDescriptor), // wTotalLength 1, // bNumInterfaces 1, // bConfigurationValue 0, // iConfiguration 0x80, // bmAttributes 5 // bMaxPower (5 * 2 = 10 mA) }, { // interface descriptor 9, // bLength; 0x04, // bDescriptorType 0, // bInterfaceNumber 0, // bAlternateSetting 2, // bNumEndpoints 0xFF, // bInterfaceClass (vendor defined) 0xFF, // bInterfaceSubClass (vendor defined) 0xFF, // bInterfaceProtocol (vendor defined) 0 // iInterface }, { // endpoint descriptor (ep1 in) 7, // bLength 0x05, // bDescriptorType 0x01, // bEndpointAddress 0x02, // bmAttributes 64, // wMaxPacketSize 10 // bInterval }, { // endpoint descriptor (ep1 out) 7, // bLength 0x05, // bDescriptorType 0x81, // bEndpointAddress 0x02, // bmAttributes 64, // wMaxPacketSize 10 // bInterval } };
Usaremos los primeros 64 bytes de la XRAM para las transferencias del endpoint 0 (el endpoint de control) y los 64 bytes siguientes para las transferencias del endpoint 1, que será bidireccional y definimos todos los endpoints con un tamaño máximo de transferencia de 64 bytes. Nótese que, aunque haya dos descriptores de endpoint en el descriptor de configuración, estos dos descriptores hacen referencia a un mismo endpoint 1, pero en sentidos opuestos. En este caso tenemos dos descriptores y un único endpoint que es bidireccional.
La secuencia de acciones mínima que hacen los sistemas operativos cuando detectan que hay un dispositivo nuevo conectado al bus USB es la siguiente:
1. El host envía a la dirección 0 del bus una petición de GET_DESCRIPTOR(dispositivo) para que el dispositivo responda con su descriptor de dispositivo.
2. Una vez el host recibe el descriptor de dispositivo, comprueba que es válido y, si lo es, le asigna una dirección en el bus enviando una petición SET_ADDRESS al dispositivo para hacerle saber cual será su dirección en el bus en las siguientes peticiones.
3. A continuación el host vuelve a enviar una petición GET_DESCRIPTOR(dispositivo) pero esta vez a la nueva dirección de bus para comprobar que el dispositivo está ahí.
4. El host envía una petición GET_DESCRIPTOR(configuración) por cada configuración que haya (están indicadas en el descriptor del dispositivo y normalmente sólo hay una configuración, como en nuestro caso).
5. El host envía una petición SET_CONFIGURATION para indicar qué configuración se va a usar (por si hay más de una).
6. El host envía una petición GET_STATUS para preguntarle al dispositivo si está operativo.
7. A partir de este instante, el resto de endpoints deberán estar operativos para recibir tokens IN del host (enviar datos al host) o recibir tokens OUT del host (recibir datos del host).
Para configurar inicialmente el módulo USB del CH552:
- Deshabilitamos las interrupciones USB.
- Configuramos los endpoints: dirección de memoria en la XRAM donde se reciben y transmiten los paquetes y configuración del tipo de endpoint.
- Forzamos a que la dirección del dispositivo es la 0.
- Habilitamos la capa física (resistencias pull-up).
- Habilitamos el DMA que se encargará de escribir/leer en la XRAM cada paquetes que llegue/salga.
- Habilitamos las interrupciones: como mínimo para los eventos de reset USB y para los eventos de transferencia USB.
void usbInit() { IE_EX &= ~0x04; // disable usb interrupts USB_CTRL = 0; // device mode // endpoint 0 configuration UEP0_DMA_L = 0x00; // configure ep0 buffer address at XRAM 0x0000 UEP0_DMA_H = 0x00; UEP0_CTRL = 0x02; // ep0 OUT transfer return ACK, IN transfer return NAK // endpoint 1 configuration UEP1_DMA_L = 0x40; // configure ep1 buffer address at XRAM 0x0040 UEP1_DMA_H = 0x00; UEP1_CTRL = 0x00; // ep1 OUT transfer return ACK, OUT transfer return ACK UEP1_T_LEN = 0; // ep1 IN transfers always tx 0 bytes UEP4_1_MOD = 0xC0; // enable ep1 IN (tx) and OUT (rx) // rest of usb device configuration USB_DEV_AD = 0x00; // initial device address = 0 UDEV_CTRL = 0x80; // disable internal DP/DM pull-down USB_CTRL = 0x29; // UC_DEV_PU_EN=1, UC_INT_BUSY=1, UC_DMA_EN=1 (0b00101001) UDEV_CTRL |= 0x01 ; // UD_PORT_EN=1 (0b00000001) USB_INT_FG = 0xFF; // clear interrupt flags USB_INT_EN = 0x03; // enable usb reset and transfer interrupts IE_EX |= 0x04; // enable usb interrupts IE |= 0x80; // enable global interrupts usbStatus = USB_STATUS_WAIT_SETUP; }
A partir de este momento la rutina de interrupción del vector número 8 (el asociado a USB) se disparará cada vez que haya un reset en el bus USB o cada vez que haya una transferencia en cualquiera de los endpoints que hemos configurado:
Si es un evento de reset USB: Se pone la dirección del dispositivo a 0. Se configuran de nuevo los endpoints, por si acaso. Si es un evento de transferencia USB terminada: Si es un token de SETUP y es el endpoint 0: Leemos el paquete de SETUP que estará en el buffer de recepción del endpoint 0. Si es una petición de tipo GET_DESCRIPTOR: Ponemos el descriptor que nos solicita el host en el buffer del endpoint 0. Nos preparamos para recibir un token IN. Si es una petición de tipo SET_ADDRESS: Guardamos en una variable la nueva dirección que tenemos que usar. Preparamos un paquete de respuesta de 0 bytes para el próximo token IN que llegue. Nos preparamos para recibir un token IN. Si es una petición de tipo SET_CONFIGURATION: Preparar próxima transferencia con token IN de 0 bytes a modo de ACK. Si es una petición de tipo GET_STATUS: Preparar próxima transferencia con token IN con 2 bytes a 0 para indicar status ok. Si es un token IN y es el endpoint 0: Si mandé un descriptor: Preparar próxima transferencia de tipo OUT a modo de ack desde el host. Si justo antes llegó un SET_ADDRESS: Ya se puede configurar la dirección USB del dispositivo con la que se almacenó en una variable. Preparar endpoint 0 de nuevo para que llegue otro token SETUP. Si justo antes llegó un SET_CONFIGURATION o un GET_STATUS: Preparar endpoint 0 de nuevo para que llegue otro token SETUP. Si es un token OUT y es el endpoint 0: Preparar endpoint 0 de nuevo para que llegue otro token SETUP. Si es un token IN y es el endpoint 1: Se habrán transferido 0 bytes desde el CH552 al host (PC). Nada que hacer. Si es un token OUT y es el endpoint 1: Encender o apagar el led en función del primer byte del buffer del endpoint 1.
Aquí puede verse el código fuente en C de este pseudocódigo:
void usbIsr() __interrupt (8) { // USB ISR if (USB_INT_FG & 0x01) { // usb reset interrupt UEP0_CTRL = 0x02; // ep0 OUT transfer return ACK expecting DATA0, IN transfer return NAK USB_DEV_AD = 0x00; // initial device address = 0 usbStatus = USB_STATUS_WAIT_SETUP; } else if (USB_INT_FG & 0x02) { // usb transfer interrupt uint8_t token = (USB_INT_ST >> 4) & 0x03; // 0=out, 1=sof, 2=in, 3=setup uint8_t endpoint = (USB_INT_ST & 0x0F); if ((token == 3) && (endpoint == 0) && (USB_RX_LEN == 8)) { // SETUP token #define P ((setupPacket *) ep0Buffer) if ((P->bmRequestType == 0x80) && (P->bRequest == 0x06)) { // GET_DESCRIPTOR uint8_t descriptorType = P->wValue >> 8; uint8_t descriptorIndex = P->wValue & 0x00FF; if (descriptorType == 0x01) { // GET_DESCRIPTOR (device) descriptorPtr = (uint8_t *) &myDeviceDescriptor; descriptorPtrToSend = (P->wLength < sizeof(deviceDescriptor)) ? P->wLength : sizeof(deviceDescriptor); descriptorPtrNextToSend = descriptorPtrToSend; memcpy(ep0Buffer, descriptorPtr, descriptorPtrNextToSend); UEP0_T_LEN = descriptorPtrNextToSend; UEP0_CTRL = 0xC0; // UEP_R_TOG=1, UEP_T_TOG=1, UEP_R_RES=ack, UEP_T_RES=ack usbStatus = USB_STATUS_WAIT_IN_AFTER_GET_DESCRIPTOR; } else if (descriptorType == 0x02) { // GET_DESCRIPTOR (configuration) descriptorPtr = (uint8_t *) &myConfigurationDescriptor; descriptorPtrToSend = (P->wLength < sizeof(myConfigurationDescriptorType)) ? P->wLength : sizeof(myConfigurationDescriptorType); descriptorPtrNextToSend = descriptorPtrToSend; memcpy(ep0Buffer, descriptorPtr, descriptorPtrNextToSend); UEP0_T_LEN = descriptorPtrNextToSend; UEP0_CTRL = 0xC0; // UEP_R_TOG=1, UEP_T_TOG=1, UEP_R_RES=ack, UEP_T_RES=ack usbStatus = USB_STATUS_WAIT_IN_AFTER_GET_DESCRIPTOR; } else { // } } else if ((P->bmRequestType == 0x00) && (P->bRequest == 0x05)) { // SET_ADDRESS addressToUse = (P->wValue & 0x00FF); // response with a DATA1 with 0 length UEP0_T_LEN = 0; UEP0_CTRL = 0x40; // next IN transfer (response) with DATA1 usbStatus = USB_STATUS_WAIT_IN_AFTER_SET_ADDRESS; } else if ((P->bmRequestType == 0x00) && (P->bRequest == 0x09)) { // SET_CONFIGURATION // response with a DATA1 with 0 length UEP0_T_LEN = 0; UEP0_CTRL = 0x40; // next IN transfer (response) with DATA1 usbStatus = USB_STATUS_WAIT_IN_AFTER_SET_CONFIGURATION; } else if (((P->bmRequestType & 0x80) == 0x80) && (P->bRequest == 0x00)) { // GET_STATUS ep0Buffer[0] = ep0Buffer[1] = 0; // always return status ok UEP0_T_LEN = 2; // status word = 2 bytes UEP0_CTRL = 0xC0; // UEP_R_TOG=1, UEP_T_TOG=1, UEP_R_RES=ack, UEP_T_RES=ack usbStatus = USB_STATUS_WAIT_IN_AFTER_GET_STATUS; } } else if ((token == 2) && (endpoint == 0)) { // IN token if (usbStatus == USB_STATUS_WAIT_IN_AFTER_GET_DESCRIPTOR) { descriptorPtrToSend -= descriptorPtrNextToSend; descriptorPtr += descriptorPtrNextToSend; if (descriptorPtrToSend > 0) { descriptorPtrNextToSend = (descriptorPtrToSend > 8) ? 8 : descriptorPtrToSend; memcpy(ep0Buffer, descriptorPtr, descriptorPtrNextToSend); UEP0_T_LEN = descriptorPtrNextToSend; UEP0_CTRL ^= 0x40; // manual toggle UEP_T_TOG } else { UEP0_CTRL = 0x80; // expect OUT transfer with DATA 1 (status stage) usbStatus = USB_STATUS_WAIT_OUT_AFTER_IN_AFTER_GET_DESCRIPTOR; } } else if (usbStatus == USB_STATUS_WAIT_IN_AFTER_SET_ADDRESS) { USB_DEV_AD = (USB_DEV_AD & 0x80) | addressToUse; UEP0_CTRL = 0x02; // ep0 OUT transfer return ACK, IN transfer return NAK usbStatus = USB_STATUS_WAIT_SETUP; } else if (usbStatus == USB_STATUS_WAIT_IN_AFTER_SET_CONFIGURATION) { P3 |= 0x04; // switch on led when device configured UEP0_CTRL = 0x02; // ep0 OUT transfer return ACK, IN transfer return NAK usbStatus = USB_STATUS_WAIT_SETUP; } else if (usbStatus == USB_STATUS_WAIT_IN_AFTER_GET_STATUS) { UEP0_CTRL = 0x02; // ep0 OUT transfer return ACK, IN transfer return NAK usbStatus = USB_STATUS_WAIT_SETUP; } } else if ((token == 0) && (endpoint == 0)) { // OUT token UEP0_CTRL = 0x02; // ep0 OUT transfer return ACK, expect DATA0 (SETUP), IN transfer return NAK usbStatus = USB_STATUS_WAIT_SETUP; } // data in & out at endpoint 1 else if ((token == 2) && (endpoint == 1)) { // IN token: CH552 --> USB host (always tx 0 bytes) UEP1_CTRL ^= 0x40; // manual toggle UEP_T_TOG } else if ((token == 0) && (endpoint == 1)) { // OUT token: USB host --> CH552 (if first byte is '0' then switch off led, else if '1' then switch on len) if (ep1Buffer[0] == '1') P3 |= 0x04; else if (ep1Buffer[0] == '0') P3 &= ~0x04; UEP1_CTRL ^= 0x80; // manual toggle UEP_R_TOG } } USB_INT_FG = 0xFF; }
Nótese que algunos detalles como el cambio entre paquetes de tipo DATA0 y DATA1 no están indicados en el pseudocódigo para facilitar la comprensión.
De esta forma la función main queda muy sencilla:
int main() { // set Fsys = 24 MHz SAFE_MOD = 0x55; SAFE_MOD = 0xAA; CLOCK_CFG = (CLOCK_CFG & 0xF8) | 0x06; SAFE_MOD = 0; // configure P3.2 as push-pull output pin P3_MOD_OC &= 0xFB; P3_DIR_PU |= 0x04; P3 = 0; // turn off led // configure usb device usbInit(); // sleep while (1) ; }
El programa se compila y se tosta exactamente de la misma manera que se vió en la prueba de concepto (no tiene dependencias externas).
Funcionamiento
Una vez que el CH552 está programado lo desconectamos del conector USB que se usó para tostarlo, a continuación podemos irnos a una consola de root y hacer un
$ journalctl -f
Para empezar a trazar todos los eventos del kernel. Si en ese momento enchufamos el CH552 por USB (sin pulsar el botón de bootloader) podremos ver un mensaje de log del kernel parecido a este:
... feb 02 18:57:33 avelino kernel: usb 3-2: new full-speed USB device number 2 using xhci_hcd feb 02 18:57:33 avelino kernel: usb 3-2: New USB device found, idVendor=f055, idProduct=0001, bcdDevice= 0.00 feb 03 18:57:33 avelino kernel: usb 3-2: New USB device strings: Mfr=0, Product=0, SerialNumber=0 ...
Lo que significará que el Kernel ha reconocido correctamente el dispositivo y lo ha enumerado. De hecho podemos hacer:
$ lsusb ... Bus 003 Device 002: ID f055:0001 ...
Y lo veremos junto con el resto de dispositivos USB que tengamos conectados en nuestro ordenador. Si hacemos un "lsusb -v -d f055:0001" nos dará incluso la información de los descriptores de dispositivo y de configuración.
Hasta aquí bien, pero aún no tenemos un driver de dispositivos en el kernel que sea capaz de manejar nuestro nuevo dispositivo inventado y que nos permita encender y apagar el led desde un programa de usuario (haciendo "echo 1 > /dev/loquesea" por ejemplo). En este sentido podemos usar el módulo de kernel "usbserial". Si, cuando cargamos este módulo en el kernel, le pasamos los parámetros "vendor=VENDORID product=PRODUCTID" (en nuestro caso "vendor=0xf055 product=0x0001") haremos que, si se detecta un dispositivo con esos vendor y product el módulo "usbserial" creará un /dev/ttyUSBn por cada par de endpoints IN/OUT que tenga el dispositivo. Por tanto en nuestro caso nos bastará con hacer un:
$ modprobe usbserial vendor=0xf055 product=0x0001
Después de enchufar nuestro nuevo dispositivo. Haciendo esto, el módulo "usbserial" creará un dispositivo en /dev/ttyUSB0 vinculado a los dos enpoints (ep1 IN y ep1 OUT indicados en el descriptor de configuración) de tal manera que escribiendo en /dev/ttyUSB0 se enviarán tokens OUT al endpoint 1 del CH552 para escribir datos en él, mientras que leyendo de /dev/ttyUSB0 se enviarán tokens IN al endpoint 1 del CH552 para leer datos de él. Tras esto ya podremos apagar y encender el led el CH552 con los comandos:
$ echo 1 > /dev/ttyUSB0 # encender $ echo 0 > /dev/ttyUSB0 # apagar
Implementación de un dispositivo de tipo HID
La solución descrita hasta ahora requiere, para su uso, que podamos insertar un driver en el kernel ("usbserial" o similar) que conozca la configuración de nuestro dispositivo o, en su defecto, utilizar "libusb" para que las aplicaciones de usuario puedan enviar y recibir datos del dispositivo. Existe, sin embargo, una alternativa que permitiría el uso del dispositivo desde el espacio de usuario sin necesidad de la instalación de ningún driver: configurar nuestro dispositivo como un dispositivo HID.
HID (Human Interface Device) son una clase de dispositivos dentro del estándar USB utilizados por teclados, ratones, joysticks, touch pads, etc. Un HID USB se diferencia con el dispositivo "plano" que teníamos hasta ahora en los siguientes aspectos:
- El descriptor de configuración indica en el cambo bInterfaceClass el valor 0x03 para indicar que es HID.
- Dentro de la estructura del descriptor de configuración se mete un descriptor HID entre el descriptor de interfaz y el primer descriptor de endpoint.
- Debe incluir al menos un descriptor de endpoint IN de tipo interrupt. En nuestro caso pondremos dos endpoints de tipo interrupt: uno IN y otro OUT (para encender y apagar el led).
- Debemos definir un descriptor "HID report". Dicho descriptor permite definir el formato de los datos que se enviarán y/o recibirán por los endpoints (por ejemplo, los teclados definen un descriptor de HID report donde indican el formato de los bytes que se envían al ordenador con cada pulsación).
- Debemos hacer que el dispositivo responda adecuadamente a la petición GET_DESCRIPTOR(hid report), que es una petición que envía en ordenador, cuando ve que el dispositivo es HID, con el propósito de que el propio dispositivo envíe su descriptor de "HID report". Nótese que una cosa es el descriptor HID (que se incluye dentro del descriptor de configuración) y otra cosa es el descriptor de HID report, que no se incluye en el descriptor de configuración, ya que el ordenador lo solicita mediante la petición GET_DESCRIPTOR(hid report) específica.
La clase de dispositivos HID de USB está muy bien explicada en la documentación oficial, aunque, como el resto de documentos, es algo densa y aquí tratamos de explicarlo de forma sencilla para el caso que nos ocupa. A continuación definimos el nuevo descriptor de configuración para nuestro dispositivo USB HID (se ha resaltado el descriptor HID):
__code const myConfigurationDescriptorType myConfigurationDescriptor = { { // configuration descriptor 9, // bLength 0x02, // bDescriptorType sizeof(configurationDescriptor) + sizeof(interfaceDescriptor) + sizeof(hidDescriptor) + sizeof(endpointDescriptor) + sizeof(endpointDescriptor), // wTotalLength 1, // bNumInterfaces 1, // bConfigurationValue 0, // iConfiguration 0x80, // bmAttributes 5 // bMaxPower (5 * 2 = 10 mA) }, { // interface descriptor 9, // bLength 0x04, // bDescriptorType 0, // bInterfaceNumber 0, // bAlternateSetting 2, // bNumEndpoints 0x03, // bInterfaceClass (HID) 0x00, // bInterfaceSubClass (none) 0x00, // bInterfaceProtocol (none) 0 // iInterface }, { // hid descriptor 9, // bLength 0x21, // bDescriptorType 0x0101, // bcdHID 0, // bCountryCode 1, // bNumDescriptors 0x22, // bReportDescriptorType (report) sizeof(hidReportDescriptor) // wReportDescriptorLength }, { // endpoint descriptor (ep1 in) 7, // bLength 0x05, // bDescriptorType 0x81, // bEndpointAddress 0x03, // bmAttributes (interrupt endpoint) 64, // wMaxPacketSize 10 // bInterval (max. ms between IN tokens) }, { // endpoint descriptor (ep1 out) 7, // bLength 0x05, // bDescriptorType 0x01, // bEndpointAddress 0x03, // bmAttributes (interrupt endpoint) 64, // wMaxPacketSize 10 // bInterval (max. ms between OUT tokens) } };
Como se puede ver se ha cambiado el campo bInterfaceClass para indicar que es un dispositivo HID, se ha incluido el descriptor HID y se han redefinido los endpoints para que sean ambos de tipo interrupt. El campo bReportDescriptorType del descriptor HID indica que hay definido, además, un descriptor de "HID report" con una longitud de wReportDescriptorLength bytes. Este descriptor de "HID report" será solicitado con posterioridad por el ordenador a través de una petición GET_DESCRIPTOR(hid report):
__code const uint8_t hidReportDescriptor[] = { // hid report descriptor for a raw 64 byte rx/tx pipe 0x06, 0xAB, 0xFF, // usage_page 0xFFAB 0x0A, 0x00, 0x02, // usage 0x0200 0xA1, 0x01, // collection 0x01 0x75, 0x08, // report size = 8 bits 0x15, 0x00, // logical minimum = 0 0x26, 0xFF, 0x00, // logical maximum = 255 0x95, 64, // report count = 64 bytes 0x09, 0x01, // usage 0x81, 0x02, // Input (array) 0x95, 64, // report count = 64 bytes 0x09, 0x02, // usage 0x91, 0x02, // Output (array) 0xC0 // end collection };
Los descriptores de HID report son complejos de diseñar y para tal menester se encuentran disponibles aplicaciones de ayuda (por ejemplo, la utilidad oficial de la organización USB Implementers Forum, que se puede ejecutar en Linux con wine) y varios tutoriales (recomiendo este). Para nuestro caso particular sólo necesitamos definir un HID "vendor specific" que permita enviar y recibir paquetes crudos de 64 bytes cada uno.
Una vez que el host (ordenador) detecta que es un dispositivo USB HID y obtiene sus descriptores, empieza a enviar (con la periodicidad en milisegundos indicada en los campos bInterval de los diferentes endpoints de tipo interrupt) tokens IN y OUT para recibir y enviar (respectivamente) datos desde y hasta el dispositivo.
A pesar de que, a priori puede resultar más engorrosa esta nueva "capa" HID sobre la primera aproximación, el definir un dispositivo como HID tienen un gran ventaja: Es posible enviar y recibir paquetes HID (que se envían y reciben por los endpoints interrupt) sin necesidad de ningún driver específico, ni en Windows ni en Linux (por ejemplo, en Linux el kernel crea un dispositivo /dev/hidrawN, cuando un proceso escribe en ese dispositivo, se envían tokens OUT por el endpoint OUT interrupt para escribir los datos, mientras que cuando un proceso intenta leer de ese dispositivo, se envían tokens IN por el endpoint IN interrupt para tratar de leer datos).
Prueba del HID
Cuando conectamos el CH552 configurado como USB HID vemos que el kernel lo ha detectado como un dispositivo USB HID:
... feb 05 13:53:18 avelino kernel: usb 3-2: new full-speed USB device number 18 using xhci_hcd feb 05 13:53:18 avelino kernel: usb 3-2: New USB device found, idVendor=f055, idProduct=0001, bcdDevice= 0.00 feb 05 13:53:18 avelino kernel: usb 3-2: New USB device strings: Mfr=0, Product=0, SerialNumber=0 feb 05 13:53:23 avelino kernel: hid-generic 0003:F055:0001.0008: hiddev0,hidraw3: USB HID v1.01 Device [HID f055:0001] on usb-0000:00:14.0-2/input0 ...
Creándose automáticamente el dispositivo (en este caso el "/dev/hidraw3") que puede ser escrito desde la shell para encender y apagar el led, sin necesidad de ningún driver:
$ echo 1 > /dev/hidraw3 # encender $ echo 0 > /dev/hidraw3 # apagar
Todo el software puede descargarse de la sección soft.
Buen post!!
Lo sentimos. No se permiten nuevos comentarios después de 90 días.