Capturar el estado de 28 pines a 2 millones de muestras por segundo sin que el procesador atienda cada una es el problema concreto que resuelve este recorrido. Lo firma Paul Stoffregen, de PJRC, y lo publicó SparkFun dentro de su serie Paul's Deep Dives, armada a partir de sus respuestas en el foro de PJRC.
La idea básica del acceso directo a memoria es simple. En vez de que la CPU mueva los datos entre un periférico y la memoria una y otra vez, se configura el hardware de DMA para que haga las transferencias. Eso libera al procesador y, más importante para adquisición a alta velocidad, evita ejecutar una rutina de interrupción por cada muestra.
El problema es armar el rompecabezas. No existe un tutorial fácil de DMA para el i.MX RT1062. Los comentarios de DMAChannel.h son lo más parecido a una introducción, y las bibliotecas del Teensy sirven de ejemplo. Pero el DMA sobre GPIO involucra varias partes del chip a la vez, entre ellas GPIO, IOMUX, DMA, DMAMUX, XBAR y casi siempre un temporizador o un reloj externo.
¿Por qué usar DMA acá?
El caso que originó la discusión era muestrear 28 estados de GPIO a 2 MHz. Hacerlo desde una interrupción funciona sorprendentemente bien durante un rato. El código se ve más o menos así.
void myISR()
{
buffer[position] = GPIO6_DR;
position++;
if (position == BUFFER_SIZE) {
position = 0;
// switch buffers
}
}A 2 MHz esa interrupción corre dos millones de veces por segundo. Ahora hay que imaginar que la aplicación además tiene que empaquetar esas muestras en paquetes UDP y mandarlas por Ethernet. El procesador queda atendiendo una interrupción muy frecuente mientras la pila de red hace lo suyo.
Lo que se quiere es pasar de esto
External clock
|
v
CPU ISR
|
v
Read GPIO
|
v
Write RAMa esto.
External clock
|
v
XBAR
|
v
DMAMUX
|
v
DMA
/ \
GPIO RAMLa CPU no participa en cada muestra. Solo necesita enterarse de vez en cuando, por lo general cuando se llenó la mitad del búfer o el búfer completo.
Partir muy lento
El consejo más útil para escribir código de DMA es no empezar a 2 MHz. Hay que empezar ridículamente lento.
Depurar DMA es difícil porque, cuando la configuración está mal, el hardware no suele entregar un mensaje de error útil. Muchas veces el único síntoma es que no pasa nada, que cambia la memoria equivocada, o que todo ocurre mucho más rápido de lo esperado.
Con un disparo muy lento se pueden imprimir posiciones de memoria y mirar cómo avanza la transferencia. Conviene declarar volatile la memoria que el DMA modifica, donde corresponda, para que el compilador no asuma que esa memoria no puede cambiar sola.
Una vez que el camino completo funciona a unos pocos hercios, subir la frecuencia del disparo es fácil. Lo difícil es dejar la cañería bien armada.
La primera trampa, GPIO1 contra GPIO6
Antes de configurar el DMA hay un detalle del GPIO en el i.MX RT1062 que conviene tener claro. Cada puerto GPIO tiene dos formas de acceso.
Los puertos GPIO1 a GPIO4 viven en el bus normal de periféricos. Los puertos GPIO6 a GPIO9 entregan acceso rápido a los pines correspondientes. El Teensy configura los pines para usar esos registros rápidos porque son bastante mejores cuando el procesador ARM manipula el GPIO en forma directa. Por eso se ve tanto código con GPIO6_DR.
El DMA no puede acceder a esos registros rápidos. Es uno de los detalles más frustrantes porque no aparece señalado con claridad en la documentación. Para DMA hay que usar los registros normales, los de GPIO1 a GPIO4.
Los pines individuales se pueden cambiar entre el mapeo rápido y el normal con los registros GPR del IOMUXC. Para un grupo de pines de GPIO1 el código se ve así.
GPIO1_GDIR &= ~(0x03FC0000u);
IOMUXC_GPR_GPR26 &= ~(0x03FC0000u);La primera línea configura esos bits de GPIO como entradas. La segunda saca esos pines del mapeo rápido de GPIO6 para que se puedan leer por GPIO1. Eso es indispensable. Si se apunta el DMA a GPIO6, se puede pasar mucho rato depurando la configuración del DMA cuando el problema real es el bus donde viven los registros.
¿Cómo se describe la transferencia?
Con los pines ruteados a GPIO1, la transferencia es conceptualmente simple. Los 32 bits de un puerto GPIO están representados por un registro mapeado en memoria, y leer ese registro entrega el estado del puerto. En vez de que la CPU ejecute sample = GPIO1_DR;, la instrucción al hardware es otra.
Cada vez que recibas una petición, lee GPIO1_DR y pon el valor de 32 bits resultante en la siguiente posición de este búfer.El controlador de DMA usa un Transfer Control Descriptor, o TCD, para describir una transferencia. El TCD intimida porque el hardware admite una cantidad enorme de posibilidades, pero acá solo se necesita un subconjunto chico. Se quiere el equivalente de una serie de asignaciones.
buffer[0] = GPIO1_DR;
buffer[1] = GPIO1_DR;
buffer[2] = GPIO1_DR;
buffer[3] = GPIO1_DR;
// ...La diferencia es que cada asignación ocurre cuando llega un disparo de hardware. Eso define casi toda la configuración. La dirección de origen se queda siempre igual, con Source = GPIO1_DR y desplazamiento de origen en 0. El destino avanza una palabra de 32 bits después de cada transferencia, o sea Destination = buffer con desplazamiento de 4 bytes. Como los registros GPIO exigen acceso de 32 bits, cada transferencia es de 4 bytes.
Con DMAChannel buena parte de eso se expresa en tres líneas.
DMAChannel dma;
dma.begin();
dma.source(GPIO1_DR);
dma.destinationBuffer(dmaBuffer, sizeof(dmaBuffer));Es un punto de partida bastante más amable que programar a mano cada campo del TCD. DMAChannel además asigna el canal de DMA en forma dinámica. Eso sirve porque las bibliotecas del Teensy que usan DMA emplean el mismo mecanismo, lo que reduce la posibilidad de que dos bibliotecas se peleen el mismo canal.
Bucle menor y bucle mayor
Hay dos términos que aparecen todo el tiempo en el manual de referencia y conviene entender, el bucle menor y el bucle mayor.
En esta captura de GPIO, un bucle menor es una muestra. Ocurre un evento de hardware y se dispara la secuencia.
clock edge
|
v
DMA request
|
v
read GPIO1_DR
|
v
write one uint32_tDespués el puntero de destino avanza cuatro bytes. El bucle mayor es el conjunto de todas esas transferencias individuales que hacen falta para llenar el búfer.
#define DMABUFFER_SIZE 4096
uint32_t dmaBuffer[DMABUFFER_SIZE];Con ese tamaño se configura el DMA para hacer 4.096 transferencias menores. Cuando el bucle mayor termina, el DMA puede generar una interrupción.
dma.interruptAtCompletion();
dma.attachInterrupt(dmaInterrupt);En vez de interrumpir a la CPU en cada muestra, se la interrumpe una vez cada miles de muestras. Ese es el beneficio real.
¿De dónde sale la petición de DMA?
Mover datos de GPIO a RAM no es la parte más difícil. Generar exactamente una petición de DMA por muestra es donde la cosa se pone interesante.
Con un reloj de ADC externo, lo que se busca es que cada flanco de subida o de bajada provoque una transferencia. El problema es que el GPIO no es una de las fuentes normales de petición de DMA. Ahí entra el crossbar del i.MX RT, el XBAR.
El XBAR es un tejido de ruteo programable dentro del chip. Las señales de distintos periféricos y de los pines de entrada y salida se pueden conectar a otros periféricos internos. Para un reloj de muestreo externo, la ruta queda así.
External clock pin
|
v
IOMUX
|
v
XBAR
|
v
DMA request generator
|
v
DMAMUX
|
v
DMASe ve complicado porque son varios periféricos separados, pero cada bloque hace un trabajo bastante simple.
Rutear el reloj externo por XBAR
Supongamos que el pin 4 del Teensy lleva el reloj de muestreo externo. Ese pin se puede rutear a una entrada del XBAR. Primero se configura el mux del pin.
IOMUXC_SW_MUX_CTL_PAD_GPIO_EMC_06 = 3;Después hay que asegurarse de que esa señal del XBAR esté configurada como entrada.
IOMUXC_GPR_GPR6 &=
~(IOMUXC_GPR_GPR6_IOMUXC_XBAR_DIR_SEL_8);Existe además una selección en cadena, porque esta entrada del XBAR puede venir de más de un pad físico.
IOMUXC_XBAR1_IN08_SELECT_INPUT = 0;Luego se conecta esa entrada del XBAR a una de las salidas de petición de DMA.
xbar_connect(
XBARA1_IN_IOMUX_XBAR_INOUT08,
XBARA1_OUT_DMA_CH_MUX_REQ30
);También hay que configurar la salida del XBAR para que genere la petición en el flanco que interesa.
XBARA1_CTRL0 =
XBARA_CTRL_STS0 |
XBARA_CTRL_EDGE0(1) |
XBARA_CTRL_DEN0;Por último se le dice al canal de DMA qué evento de hardware usar.
dma.triggerAtHardwareEvent(DMAMUX_SOURCE_XBAR1_0);Con eso, cada flanco de reloj seleccionado provoca que una muestra de GPIO pase a RAM. Queda un detalle fácil de pasar por alto. El periférico XBAR necesita su reloj habilitado.
CCM_CCGR2 |= CCM_CCGR2_XBAR1(CCM_CCGR_ON);Eso va antes de configurar el XBAR. Si no, se puede escribir código de configuración que se ve perfectamente razonable y pasar mucho rato preguntándose por qué nada funciona.
¿Por qué no disparar desde un temporizador?
Si el reloj de muestreo se genera internamente y no desde afuera, usar un temporizador suena a la solución obvia. Hay una trampa importante.
Un temporizador puede levantar una petición de DMA, pero según cómo estén configurados el temporizador y el DMA, el temporizador puede no recibir el reconocimiento que necesita cuando el DMA atiende esa petición. La petición queda levantada. El DMA entonces ve algo equivalente a una petición permanente.
REQUEST REQUEST REQUEST REQUEST REQUEST...request
|
wait for next timer event
|
requestEl resultado puede ser un canal de DMA que corre sin parar y transfiere el búfer entero tan rápido como el hardware lo permita. Este comportamiento del reconocimiento es una de las piezas más difíciles de entender solo con la documentación de NXP.
Rutear el pulso del temporizador por uno de los generadores de petición del XBAR suele ser una solución bastante más limpia, porque esos generadores reconocen el servicio de DMA en forma automática.
Existe otra salida con dos canales de DMA. Uno hace la transferencia real y el otro hace una operación falsa que reconoce o limpia la condición del temporizador, y el primer canal dispara al segundo. Funciona, pero consume otro canal de DMA y es más complicado.




