Neopixels sur SMT32F4 : pilotage avec un timer et DMA

Dans l’article précédent concernant les Neopixels, je vous ai montré ma solution « quick’n’dirty » pour piloter un anneau de 16 Neopixels. Cette implémentation utilise une boucle vide dont le nombre d’itérations permettra de générer des timings + ou – précis. Cette solution, qui a le mérite d’être simple et fonctionnelle, n’est pas du tout précise, optimisée et évolutive. En effet, même si la boucle de Delay() ne fait rien, elle occupe le processeur pour ne rien faire, justement. De plus, si la fréquence du processeur devait changer dans le future, tous les timings deviendraient invalides.

C’est pour cela que j’ai décidé de réimplémenter le contrôle de mes Neopixels en utilisant un timer du micro-contrôlleur. Ce timer va générer en hardware (sans intervention du programme, à part lors de l’initialisation) des timings très précis. De plus, l’utilisation du DMA va permettre de générer toute une trame de données vers les LEDs en consommant très peu de ressources du processeur.

Qu’est-ce qu’un timer?

D’une manière générale, un timer, c’est un compteur ou décompteur. Il est configuré par le programme pour incrémenter ou décrémenter un registre. La vitesse à laquelle le timer incrémente ou décrémente le registre dépend de son horloge. Par exemple, un timer dont l’horloge tourne à 1Khz va incrémenter son registre 1000 fois par seconde.

Le STM32F4 dispose d’une multitude de timers pouvant être configurés pour effecuter divers actions : compter/décompter, mesurer la fréquence d’un signal externe, générer des signaux sur certaines pins du processeur,…

Dans notre cas, nous allons utiliser un timer pour générer un signal PWM (Pulse Width Modulation). En effet, le signal que nous devons fournir aux Neopixels est un signal dont la période est fixe, mais dont le temps passer à un niveau haut ou bas varie. C’est justement cela qui permet de coder un 0 ou un 1.

Et un DMA?

DMA signifie Direct Memory Access. Il s’agit d’un mécanisme qui permet d’effectuer des transfert de mémoire entre différentes mémoires, ou entre la mémoire et les périphériques… sans que le programme ne doive intervenir pour gérer le transfert. Immaginons par exemple que vous souhaitiez envoyer un gros paquet de données sur un USART (ligne série). Sans l’aide du DMA, le programme va devoir gérer l’envoi de tous les bytes, byte à byte. Il va donc passer beaucoup de temps à gérer le transfert, et aura donc moins de temps pour faire fonctionner le reste de l’application. En utilisant un transfert DMA, il est possible de configurer le transfert en indiquant au contrôlleur DMA qu’il va devoir transferer un certains nombre de bytes provenant de la mémoire RAM vers le périphérique USART que vous aurez choisi. Une fois que le transfert sera lancé, le programme ne devra plus intervenir pour gérer le transfert, tout va se faire automatiquement. C’est presque magique!

PWM + DMA = Neopixel?

Oui, il est possible de combiner ces deux techniques pour piloter les Neopixels. Voici comment cela va se passer:

  1. Le programme va préparer un buffer de données. Ce buffer contient la configuration de chacunes des LEDs (leur couleur, rouge, vert et bleu);
  2. Le DMA va transférer ce buffer vers le timer;
  3. Le timer va générer le signal avec les bons timings sur une pin du processeur
  4. Une fois que le transfert DMA est terminé (et donc que tous les bytes du buffers auront été envoyés), le contrôleur DMA va générer une interruption pour que le programme puisse éventuellement mettre à jours le buffer de données pour le prochain envoi.

Assez parlé, passons au code!

J’ai donc créé une fonction ws2812_initTimer(), qui initialise la sortie du processeur sur laquelle le signal va être généré, le timer, le DMA est l’interruption de fin de transfert:

void ws2812_initTimer(uint32_t* buffer)
{
    TIM_TimeBaseInitTypeDef tim;
    NVIC_InitTypeDef   NVIC_InitStructure;
    GPIO_InitTypeDef  GPIO_InitStructure;
    TIM_OCInitTypeDef TIM_OCInitStruct;
    DMA_InitTypeDef DMA_InitStructure;
	
    /* Start peripheral clocks */
    RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE);
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_SYSCFG, ENABLE);
    RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOA , ENABLE); 
	
    /* Configure PA6 as alternate function for TIM3 */
    GPIO_PinAFConfig(GPIOA, GPIO_PinSource6, GPIO_AF_TIM3);
	
    /* Init PA6 */
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF;
    GPIO_InitStructure.GPIO_OType = GPIO_OType_PP;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_100MHz;
    GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_NOPULL;
    GPIO_Init(GPIOA, &GPIO_InitStructure);
		
    /* TIM3 base init */	
    tim.TIM_ClockDivision = TIM_CKD_DIV1;
    tim.TIM_CounterMode = TIM_CounterMode_Up;
    tim.TIM_Period = 105;
    tim.TIM_RepetitionCounter = 0;
    tim.TIM_Prescaler = 0;
    TIM_TimeBaseInit(TIM3, &tim);
	
    /* TIM3 OC1 init */
    TIM_OCInitStruct.TIM_OCIdleState = TIM_OCIdleState_Reset;
    TIM_OCInitStruct.TIM_OCMode = TIM_OCMode_PWM1;
    TIM_OCInitStruct.TIM_OCNIdleState = TIM_OCNIdleState_Reset;
    TIM_OCInitStruct.TIM_OCIdleState = TIM_OCIdleState_Reset;
    TIM_OCInitStruct.TIM_OCNPolarity = TIM_OCNPolarity_High;
    TIM_OCInitStruct.TIM_OCPolarity = TIM_OCPolarity_High;
    TIM_OCInitStruct.TIM_OutputNState = TIM_OutputNState_Disable;
    TIM_OCInitStruct.TIM_OutputState = TIM_OutputState_Enable;
    TIM_OCInitStruct.TIM_Pulse = 10;
    TIM_OC1Init(TIM3, &TIM_OCInitStruct);
    TIM_OC1PreloadConfig(TIM3, TIM_OCPreload_Enable); 
	
    /* Init DMA for TIM3 */
    TIM_DMAConfig(TIM3, TIM_DMABase_CCR1, TIM_DMABurstLength_1Transfer);
    RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_DMA1, ENABLE);
	
    DMA_DeInit(DMA1_Stream4);

    DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&TIM3->CCR1; // Physical address of Timer 3 CCR1
    DMA_InitStructure.DMA_Memory0BaseAddr = (uint32_t)buffer;		  // Memory address where the DMA will read the data
    DMA_InitStructure.DMA_DIR = DMA_DIR_MemoryToPeripheral;			  // Transfert from RAM memory to peripheral
    DMA_InitStructure.DMA_BufferSize = 42;                            // Number of Half-words (16 bits) to be transfered
    DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable;  // Do no increment peripheral address
    DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable;			  // Automatically increment buffer (RAM) index
    DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; // TIM3 is a 16 bits counter -> transfert 16bits at a time
    DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord;
    DMA_InitStructure.DMA_Mode = DMA_Mode_Normal;					   
    DMA_InitStructure.DMA_Priority = DMA_Priority_High;
    DMA_InitStructure.DMA_Channel  = DMA_Channel_5; 
    DMA_InitStructure.DMA_FIFOMode = DMA_FIFOMode_Disable;
    DMA_InitStructure.DMA_FIFOThreshold = DMA_FIFOThreshold_1QuarterFull;
    DMA_InitStructure.DMA_MemoryBurst = DMA_MemoryBurst_Single;
    DMA_InitStructure.DMA_PeripheralBurst = DMA_PeripheralBurst_Single;
    DMA_InitStructure.DMA_Priority = DMA_Priority_Medium;
	
    /* Enable IRQ for DMA */
    NVIC_InitStructure.NVIC_IRQChannel = DMA1_Stream4_IRQn;             
    NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0x0F; 
    NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0x0F;
    NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
    NVIC_Init(&NVIC_InitStructure);
		
    DMA_ITConfig(DMA1_Stream4, DMA_IT_TC, ENABLE);
	
    /* Start the DMA */
    DMA_ClearFlag(DMA1_Stream4, DMA_FLAG_FEIF4|DMA_FLAG_DMEIF4|DMA_FLAG_TEIF4|DMA_FLAG_HTIF4|DMA_FLAG_TCIF4);
    DMA_Cmd(DMA1_Stream4, DISABLE);
    while (DMA1_Stream4->CR & DMA_SxCR_EN);
    DMA_Init(DMA1_Stream4, &DMA_InitStructure);
    TIM_DMACmd(TIM3, TIM_DMA_CC1, ENABLE);	
}

Cette fonction est la plus compliquée, car c’est elle qui se charge de tout initialiser. Analysons-la étape par étape.

J’ai décidé d’utiliser le Timer 3 (TIM3) car la sortie PA6 peut être utilisée avec ce timer comme « Output Compare ». Cela signifie que le timer est capable de prendre la main sur cette sortie pour générer des signaux à une fréquence déterminée.

Je commence donc par démarrer les clocks nécessaires, puis je configure PA6 en ‘Alternate Function’ pour le TIM3.

Vient ensuite l’initialisation du TIM3. Il s’agit ici de configurer le timer pour générer un signal PWM à la bonne fréquence. Pour un Neopixel, 1bit doit durer 1250ns. En effet:

  • Pour écrire un « 1 » : 800ns au niveau logique 1 et 450ns au niveau logique 0
  • pour écrire un « 0 » : 400ns au niveau logique 1 et 850ns au niveau logique 0

Dans les deux cas, l’addition des deux timings est de 1250ns. La période du timer doit donc être de 1.25µs.

Dans ma configuration à 168Mhz, TIM3 est cadencé à 84Mhz. Un tick du timer dure donc (1/84000000) 11.9ns. Il faut donc 105 * 11.9 pour arriver à 1250ns. C’est pour cela que TIM_PERIOD est initialisé à 105.

J’initialise ensuite le premier canal « Output Compare ». C’est ce canal qui va prendre la main sur la sortie du processeur pour générer le signal vers le Neopixel. C’est la valeur de TIM_OCInitStruct.TIM_Pulse qui va déterminer le nombre de cycles du timer durant lequel le signal sur PA6 sera à 1 par rapport au nombre de cycles qu’il passera à 0. Par contre, sa valeur a ici très peu d’importance, vu que c’est précisément cette valeur qui va être transférée via le DMA.

C’est justement le DMA qui est initialisé ensuite. Les paramètres suivants sont importants:

  • DMA_PeripheralBaseAddr : adresse du registre du périphérique que l’on souhaite écrire via le DMA
  • DMA_Memory0BaseAddr : adresse en RAM d’où les données vont être lues
  • DMA_DIR : direction du transfert : RAM -> timer
  • DMA_BufferSize : taille du transfert

Nous avons aussi besoin d’une interruption à la fin du transfert DMA. En effet, un transfert permet d’envoyer une trame de données pour l’ensemble des 16 LEDs de l’anneau. Une fois que ce transfert est terminé, nous pourrons décider de le relancer (ou non). Cela se fera dans le gestionnaire de l’interruption.

Une fois tout cela initialisé, nous pouvons démarrer le contrôleur DMA.

Voilà, l’initialisation est terminée. Comme vous pouvez le constater, cela n’est pas très simple, ni à comprendre, ni à expliquer. Pour en arriver à ce résultat, j’ai du lire, lire et encore lire le « Reference manual » du processeur pour trouver les infos dont j’avais besoin (alternate functions, clocks, réglages spécifiques pour mon application,…).

Si vous avez des questions concernant cette initialisation, n’hésitez pas à les poser dans les commentaires!

Cette fonction va donc démarrer un premier transfert. Assurez vous donc que le buffer dont l’adresse est donnée en paramètre à ws2812_initTimer() contienne des valeurs cohérentes (les 3 bytes de couleurs pour chacunes des LEDs). Une fois que ce premier transfert sera terminé, une interruption de fin de transfert va être générée. Voici son handler:

void DMA1_Stream4_IRQHandler(void)
{
    if(DMA_GetITStatus(DMA1_Stream4, DMA_IT_TCIF4) != RESET)
    {
        TIM_Cmd(TIM3, DISABLE); 
        DMA_Cmd(DMA1_Stream4, DISABLE);
        DMA_ClearFlag(DMA1_Stream4, DMA_FLAG_TCIF4); 	
        DMA_ClearITPendingBit(DMA1_Stream4, DMA_IT_TCIF4);
        ws2812_send(16);
	}
}

En gros, on arrête le timer et le DMA avant de re-générer un nouveau transfert avec la fonction ws2812_send():

void ws2812_send(uint16_t len)
{
    uint16_t buffersize;
    buffersize = (len*24)+42;

    DMA_SetCurrDataCounter(DMA1_Stream4, buffersize); 
    DMA_Cmd(DMA1_Stream4, ENABLE); 
    TIM_Cmd(TIM3, ENABLE); 
}

Remarquez la valeur attribuée à la variable buffersize :

  • len est le nombre de LEDs à piloter, 16 dans notre cas
  • 24 est le nombre de bytes qui vont être transférés en DMA vers le timer. En effet, la couleur de chaque LED est codée sur 3 bytes (24 bits). Il va donc falloir générer 24 périodes du timer pour chaque LED.
  • Les 42 derniers bytes seront toujours à 0. Ils permettent d’assurer que le signal restera à l’état bas pendant 52.5µs entre chaque trame. Cela est requis par les Neopixels.

Pour vous aider à comprendre comment les choses vont se dérouler, voici une représentation temporelle de ce morceau de programme:

Le contenu du buffer à envoyer est le même que celui que nous avons envoyé lors de l’article précédent:

L’intensité de chaque couleur est codée sur 1 byte. Il faut donc 3 bytes par LEDs.

Nous voici donc arrivés à la fin de ce deuxième article sur les Neopixels. Je peux comprendre que cet article puisse sembler très compliqué, voire complètement incompréhensible pour certains d’entres vous. Il est en effet très compliqué d’expliquer simplement le fonctionnement bas niveau du processeur sans répéter le contenu du manuel du processeur. Je vous invite donc à poster un commentaire si certains points nécessitent + d’explications, ou si vous avez une questions, aussi bêtes soient-elles 🙂

Pour la suite, j’envisage de coder quelques séquences de couleurs pour animer mes Neopixels, et pourquoi pas aussi permettre à une application de contrôler les Neopixels via une communication série, par exemple! A bientôt pour la suite!

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *