Obtenir des performances rapides avec un microcontrôleur STM32

11

Je travaille avec le kit de découverte STM32F303VC et je suis légèrement déconcerté par ses performances. Pour vous familiariser avec le système, j'ai écrit un programme très simple simplement pour tester la vitesse de frappe de ce MCU. Le code peut être décomposé comme suit:

  1. L'horloge HSI (8 MHz) est activée;
  2. La PLL est initiée avec le prédécaleur de 16 pour atteindre HSI / 2 * 16 = 64 MHz;
  3. PLL est désigné comme le SYSCLK;
  4. SYSCLK est surveillé sur la broche MCO (PA8), et l'une des broches (PE10) est constamment basculée dans la boucle infinie.

Le code source de ce programme est présenté ci-dessous:

#include "stm32f3xx.h"

int main(void)
{
      // Initialize the HSI:
      RCC->CR |= RCC_CR_HSION;
      while(!(RCC->CR&RCC_CR_HSIRDY));

      // Initialize the LSI:
      // RCC->CSR |= RCC_CSR_LSION;
      // while(!(RCC->CSR & RCC_CSR_LSIRDY));

      // PLL configuration:
      RCC->CFGR &= ~RCC_CFGR_PLLSRC;     // HSI / 2 selected as the PLL input clock.
      RCC->CFGR |= RCC_CFGR_PLLMUL16;   // HSI / 2 * 16 = 64 MHz
      RCC->CR |= RCC_CR_PLLON;          // Enable PLL
      while(!(RCC->CR&RCC_CR_PLLRDY));  // Wait until PLL is ready

      // Flash configuration:
      FLASH->ACR |= FLASH_ACR_PRFTBE;
      FLASH->ACR |= FLASH_ACR_LATENCY_1;

      // Main clock output (MCO):
      RCC->AHBENR |= RCC_AHBENR_GPIOAEN;
      GPIOA->MODER |= GPIO_MODER_MODER8_1;
      GPIOA->OTYPER &= ~GPIO_OTYPER_OT_8;
      GPIOA->PUPDR &= ~GPIO_PUPDR_PUPDR8;
      GPIOA->OSPEEDR |= GPIO_OSPEEDER_OSPEEDR8;
      GPIOA->AFR[0] &= ~GPIO_AFRL_AFRL0;

      // Output on the MCO pin:
      //RCC->CFGR |= RCC_CFGR_MCO_HSI;
      //RCC->CFGR |= RCC_CFGR_MCO_LSI;
      //RCC->CFGR |= RCC_CFGR_MCO_PLL;
      RCC->CFGR |= RCC_CFGR_MCO_SYSCLK;

      // PLL as the system clock
      RCC->CFGR &= ~RCC_CFGR_SW;    // Clear the SW bits
      RCC->CFGR |= RCC_CFGR_SW_PLL; //Select PLL as the system clock
      while ((RCC->CFGR & RCC_CFGR_SWS_PLL) != RCC_CFGR_SWS_PLL); //Wait until PLL is used

      // Bit-bang monitoring:
      RCC->AHBENR |= RCC_AHBENR_GPIOEEN;
      GPIOE->MODER |= GPIO_MODER_MODER10_0;
      GPIOE->OTYPER &= ~GPIO_OTYPER_OT_10;
      GPIOE->PUPDR &= ~GPIO_PUPDR_PUPDR10;
      GPIOE->OSPEEDR |= GPIO_OSPEEDER_OSPEEDR10;

      while(1)
      {
          GPIOE->BSRRL |= GPIO_BSRR_BS_10;
          GPIOE->BRR |= GPIO_BRR_BR_10;

      }
}

Le code a été compilé avec CoIDE V2 avec GNU ARM Embedded Toolchain en utilisant l'optimisation -O1. Les signaux sur les broches PA8 (MCO) et PE10, examinés avec un oscilloscope, ressemblent à ceci: entrez la description de l'image ici

Le SYSCLK semble être configuré correctement, car le MCO (courbe orange) présente une oscillation de près de 64 MHz (compte tenu de la marge d'erreur de l'horloge interne). La partie bizarre pour moi est le comportement sur PE10 (courbe bleue). Dans la boucle infinie while (1), il faut 4 + 4 + 5 = 13 cycles d'horloge pour effectuer une opération élémentaire en 3 étapes (c'est-à-dire bit-set / bit-reset / return). Cela empire encore sur d'autres niveaux d'optimisation (par exemple -O2, -O3, ar -Os): plusieurs cycles d'horloge supplémentaires sont ajoutés à la partie LOW du signal, c'est-à-dire entre les fronts descendant et montant de PE10 (l'activation du LSI semble en quelque sorte pour remédier à cette situation).

Ce comportement est-il attendu de ce MCU? J'imagine qu'une tâche aussi simple que définir et réinitialiser un peu devrait être 2 à 4 fois plus rapide. Existe-t-il un moyen d'accélérer les choses?

KR
la source
Avez-vous essayé de comparer avec un autre MCU?
Marko Buršič
3
Que cherchez-vous à réaliser? Si vous voulez une sortie à oscillation rapide, vous devez utiliser des minuteries. Si vous souhaitez vous connecter à des protocoles série rapides, vous devez utiliser le périphérique matériel correspondant.
Jonas Schäfer
2
Bon début avec le kit !!
Scott Seidman
Vous ne devez pas | = registres BSRR ou BRR car ils sont en écriture uniquement.
P__J__

Réponses:

25

La question ici est vraiment: quel est le code machine que vous générez à partir du programme C, et en quoi diffère-t-il de ce que vous attendez.

Si vous n'aviez pas accès au code d'origine, cela aurait été un exercice de rétro-ingénierie (essentiellement quelque chose commençant par:) radare2 -A arm image.bin; aaa; VV, mais vous avez le code, donc cela rend tout plus facile.

Tout d'abord, compilez-le avec l' -gindicateur ajouté au CFLAGS(même endroit où vous spécifiez également -O1). Ensuite, regardez l'assembly généré:

arm-none-eabi-objdump -S yourprog.elf

Notez bien sûr que le nom du objdumpbinaire ainsi que votre fichier ELF intermédiaire peuvent être différents.

Habituellement, vous pouvez également ignorer la partie où GCC appelle l'assembleur et simplement regarder le fichier d'assemblage. Ajoutez simplement -Sà la ligne de commande GCC - mais cela cassera normalement votre build, donc vous le feriez très probablement en dehors de votre IDE.

J'ai fait l' assemblage d'une version légèrement corrigée de votre code :

arm-none-eabi-gcc 
    -O1 ## your optimization level
    -S  ## stop after generating assembly, i.e. don't run `as`
    -I/path/to/CMSIS/ST/STM32F3xx/ -I/path/to/CMSIS/include
     test.c

et obtenu ce qui suit (extrait, code complet sous le lien ci-dessus):

.L5:
    ldr r2, [r3, #24]
    orr r2, r2, #1024
    str r2, [r3, #24]
    ldr r2, [r3, #40]
    orr r2, r2, #1024
    str r2, [r3, #40]
    b   .L5

Ce qui est une boucle (notez le saut inconditionnel vers .L5 à la fin et l'étiquette .L5 au début).

Ce que nous voyons ici, c'est que nous

  • d'abord ldr(registre de chargement) le registre r2avec la valeur à l'emplacement de mémoire stockée dans r3+ 24 octets. Être trop paresseux pour chercher ça: très probablement l'emplacement de BSRR.
  • Ensuite, ORle r2registre avec la constante 1024 == (1<<10), ce qui correspondrait à la définition du 10e bit dans ce registre, et écrire le résultat à r2lui-même.
  • Puis str(stockez) le résultat dans l'emplacement de mémoire que nous avons lu à la première étape
  • puis répétez la même chose pour un emplacement de mémoire différent, par paresse: l'adresse la plus probable BRR.
  • Enfin b(branche) retour à la première étape.

Nous avons donc 7 instructions, pas trois, pour commencer. Cela ne bse produit qu'une seule fois, et c'est donc très probablement ce qui prend un nombre impair de cycles (nous en avons 13 au total, donc quelque part un nombre de cycles impair doit provenir). Étant donné que tous les nombres impairs inférieurs à 13 sont 1, 3, 5, 7, 9, 11, et nous pouvons exclure tout nombre supérieur à 13-6 (en supposant que le CPU ne peut pas exécuter une instruction en moins d'un cycle), nous savons que le bprend 1, 3, 5 ou 7 cycles CPU.

Étant qui nous sommes, j'ai regardé la documentation d' instructions d'ARM et combien de cycles ils prennent pour le M3:

  • ldr prend 2 cycles (dans la plupart des cas)
  • orr prend 1 cycle
  • str prend 2 cycles
  • bprend 2 à 4 cycles. Nous savons que ce doit être un nombre impair, donc il doit en prendre 3, ici.

Tout cela correspond à votre observation:

13=2(cldr+corr+cstr)+cb=2(2+1+2)+3=25+3

Comme le montre le calcul ci-dessus, il n'y aura guère de moyen de rendre votre boucle plus rapide - les broches de sortie des processeurs ARM sont généralement mappées en mémoire , pas les registres de base du processeur, vous devez donc passer par la routine habituelle de chargement - modification - stockage si vous voulez tout faire avec ça.

Ce que vous pourriez bien sûr faire, ce n'est pas lire ( doit|= implicitement lire) la valeur de la broche à chaque itération de boucle, mais simplement y écrire la valeur d'une variable locale, que vous basculez simplement à chaque itération de boucle.

Notez que je pense que vous connaissez peut-être les micros 8 bits et que vous essayez de lire uniquement les valeurs 8 bits, de les stocker dans des variables locales 8 bits et de les écrire en blocs 8 bits. Non. ARM est une architecture 32 bits, et l'extraction de 8 bits d'un mot 32 bits peut prendre des instructions supplémentaires. Si vous le pouvez, il vous suffit de lire le mot entier de 32 bits, de modifier ce dont vous avez besoin et de le réécrire en entier. Bien sûr, cela dépend de ce que vous écrivez, c'est-à-dire de la disposition et des fonctionnalités de votre GPIO mappé en mémoire. Consultez la fiche technique / le guide de l'utilisateur du STM32F3 pour plus d'informations sur ce qui est stocké dans le 32 bits contenant le bit que vous souhaitez basculer.


Maintenant, j'ai essayé de reproduire votre problème avec la période de « faible » se allongent, mais je ne pouvais tout simplement pas - les regards de boucle exactement la même chose avec -O3comme avec -O1ma version du compilateur. Vous devrez le faire vous-même! Peut-être que vous utilisez une ancienne version de GCC avec un support ARM sous-optimal.

Marcus Müller
la source
4
Le stockage ( =au lieu de |=), comme vous le dites, ne serait-il pas exactement l'accélération que l'OP recherche? La raison pour laquelle les ARM ont les registres BRR et BSRR séparément est de ne pas nécessiter de lecture-modification-écriture. Dans ce cas, les constantes pourraient être stockées dans des registres à l'extérieur de la boucle, de sorte que la boucle intérieure ne serait que de 2 str et d'une branche, donc 2 + 2 +3 = 7 cycles pour tout le cycle?
Timo
Merci. Cela a vraiment éclairci un peu les choses. C'était un peu hâtif de penser que seuls 3 cycles d'horloge seraient nécessaires - 6 à 7 cycles étaient quelque chose que j'espérais réellement. L' -O3erreur semble avoir disparu après le nettoyage et la reconstruction de la solution. Néanmoins, mon code d'assemblage semble contenir une instruction UTXH supplémentaire: .L5: ldrh r3, [r2, #24] uxth r3, r3 orr r3, r3, #1024 strh r3, [r2, #24] @ movhi ldr r3, [r2, #40] orr r3, r3, #1024 str r3, [r2, #40] b .L5
KR
1
uxthest là car GPIO->BSRRLest (incorrectement) défini comme un registre 16 bits dans vos en-têtes. Utilisez une version récente des en-têtes, provenant des bibliothèques STM32CubeF3 , où il n'y a pas de BSRRL et BSRRH, mais un seul BSRRregistre 32 bits . @Marcus a apparemment les en-têtes corrects, donc son code fait des accès complets en 32 bits au lieu de charger un demi-mot et de l'étendre.
berendi - manifestant
Pourquoi le chargement d'un seul octet prendrait-il des instructions supplémentaires? L'architecture ARM a LDRBet STRBqui effectue des lectures / écritures d'octets dans une seule instruction, non?
psmears
1
Le cœur M3 peut prendre en charge la bande de bits (vous ne savez pas si cette implémentation particulière le fait), où une région de 1 Mo d'espace mémoire périphérique est associée à une région de 32 Mo. Chaque bit a une adresse de mot discrète (le bit 0 est utilisé uniquement). Vraisemblablement encore plus lent qu'un simple chargement / stockage.
Sean Houlihane
8

Les registres BSRRet BRRpermettent de définir et de réinitialiser les bits de port individuels:

Registre de réinitialisation / réinitialisation des bits du port GPIO (GPIOx_BSRR)

...

(x = A..H) Bits 15: 0

BSy: Port x définir le bit y (y = 0..15)

Ces bits sont en écriture seule. Une lecture de ces bits renvoie la valeur 0x0000.

0: aucune action sur le bit ODRx correspondant

1: définit le bit ODRx correspondant

Comme vous pouvez le voir, la lecture de ces registres donne toujours 0, donc quel est votre code

GPIOE->BSRRL |= GPIO_BSRR_BS_10;
GPIOE->BRR |= GPIO_BRR_BR_10;

n'est efficace GPIOE->BRR = 0 | GPIO_BRR_BR_10, mais l'optimiseur ne sait pas que, il génère une séquence de LDR, ORR, des STRinstructions au lieu d'un seul magasin.

Vous pouvez éviter l'opération coûteuse de lecture-modification-écriture en écrivant simplement

GPIOE->BSRRL = GPIO_BSRR_BS_10;
GPIOE->BRR = GPIO_BRR_BR_10;

Vous pouvez obtenir une amélioration supplémentaire en alignant la boucle sur une adresse divisible par 8. Essayez de mettre une ou des asm("nop");instructions de mode avant la while(1)boucle.

berendi - pour protester
la source
1

Pour ajouter à ce qui a été dit ici: Certainement avec le Cortex-M, mais à peu près n'importe quel processeur (avec un pipeline, un cache, une prédiction de branche ou d'autres fonctionnalités), il est trivial de prendre même la boucle la plus simple:

top:
   subs r0,#1
   bne top

Exécutez-le autant de millions de fois que vous le souhaitez, mais soyez en mesure de faire varier considérablement les performances de cette boucle, juste ces deux instructions, ajoutez quelques nops au milieu si vous le souhaitez; ça n'a pas d'importance.

Changer l'alignement de la boucle peut faire varier considérablement les performances, en particulier avec une petite boucle comme celle-là, si cela prend deux lignes de récupération au lieu d'une, vous mangez ce coût supplémentaire, sur un microcontrôleur comme celui-ci où le flash est plus lent que le CPU de 2 ou 3, puis en augmentant l'horloge, le rapport devient encore pire 3 ou 4 ou 5 que l'ajout de l'extraction supplémentaire.

Vous n'avez probablement pas de cache, mais si vous l'aviez, cela aide dans certains cas, mais cela fait mal dans d'autres et / ou ne fait aucune différence. La prédiction de branche que vous pouvez ou non avoir ici (probablement pas) ne peut être vue que dans la mesure où elle est conçue dans le tuyau, donc même si vous avez changé la boucle pour créer une branche et que vous aviez une branche inconditionnelle à la fin (plus facile pour un prédicteur de branche de utiliser) tout ce que cela fait est de vous faire économiser autant d'horloges (taille du tuyau d'où il irait normalement jusqu'à la profondeur que le prédicteur peut voir) lors de la prochaine extraction et / ou il ne fait pas de prélecture juste au cas où.

En modifiant l'alignement par rapport aux lignes d'extraction et de cache, vous pouvez déterminer si le prédicteur de branche vous aide ou non, et cela peut être vu dans les performances globales, même si vous ne testez que deux instructions ou les deux avec quelques nops .

C'est un peu trivial de le faire, et une fois que vous comprenez que, puis en prenant du code compilé, ou même un assemblage écrit à la main, vous pouvez voir que ses performances peuvent varier considérablement en raison de ces facteurs, en ajoutant ou en enregistrant quelques-uns à quelques centaines de pour cent, une ligne de code C, un nop mal placé.

Après avoir appris à utiliser le registre BSRR, essayez d'exécuter votre code à partir de la RAM (copier et sauter) au lieu du flash, ce qui devrait vous donner un gain de performances instantané de 2 à 3 fois dans l'exécution sans rien faire d'autre.

old_timer
la source
0

Ce comportement est-il attendu de ce MCU?

C'est un comportement de votre code.

  1. Vous devez écrire dans les registres BRR / BSRR, pas lire-modifier-écrire comme vous le faites maintenant.

  2. Vous subissez également des frais généraux de boucle. Pour des performances optimales, répliquez les opérations BRR / BSRR à plusieurs reprises → copiez-collez-les plusieurs fois dans la boucle afin de passer par de nombreux cycles de définition / réinitialisation avant une surcharge de boucle.

edit: quelques tests rapides sous IAR.

un basculement dans l'écriture dans BRR / BSRR nécessite 6 instructions sous optimisation modérée et 3 instructions sous le niveau d'optimisation le plus élevé; un feuilletage RMW'ng prend 10 instructions / 6 instructions.

boucle aérienne supplémentaire.

dannyf
la source
En passant |=à =une phase de définition / réinitialisation d' un seul bit, 9 cycles d'horloge ( liaison ) sont nécessaires . Le code de montage comprend 3 instructions:.L5 strh r1, [r3, #24] @ movhi str r2, [r3, #40] b .L5
KR
1
Ne déroulez pas les boucles manuellement. Ce n'est pratiquement jamais une bonne idée. Dans ce cas particulier, c'est particulièrement désastreux: cela rend la forme d'onde non périodique. De plus, avoir le même code plusieurs fois en flash n'est pas nécessairement plus rapide. Cela pourrait ne pas s'appliquer ici (cela pourrait!), Mais le déroulement de la boucle est quelque chose que beaucoup de gens pensent utile, que les compilateurs ( gcc -funroll-loops) peuvent très bien faire, et que lorsqu'il est abusé (comme ici) a l'effet inverse de ce que vous voulez.
Marcus Müller
Une boucle infinie ne peut jamais être déroulée efficacement pour maintenir un comportement de synchronisation cohérent.
Marcus Müller
1
@ MarcusMüller: Des boucles infinies peuvent parfois être déroulées de manière utile tout en conservant un timing cohérent s'il y a des points dans certaines répétitions de la boucle où une instruction n'aurait aucun effet visible. Par exemple, si somePortLatchcontrôle un port dont les 4 bits inférieurs sont définis pour la sortie, il peut être possible de dérouler while(1) { SomePortLatch ^= (ctr++); }dans un code qui génère 15 valeurs, puis de revenir en arrière pour démarrer au moment où il aurait sinon généré la même valeur deux fois de suite.
supercat
Supercat, c'est vrai. De plus, des effets tels que le timing de l'interface mémoire, etc. pourraient rendre judicieux un déroulement "partiel". Ma déclaration était trop générale, mais je pense que les conseils de Danny sont encore plus généralisants, et même dangereusement
Marcus Müller