
Bonjour, Habr! Dans cet article, je veux parler du travail en virgule flottante pour les processeurs avec une architecture ARM. Je pense que cet article sera utile principalement pour ceux qui portent leur système d'exploitation sur l'architecture ARM et en même temps, ils ont besoin d'un support pour le matériel à virgule flottante (ce que nous avons fait pour
Embox , qui utilisait auparavant une implémentation logicielle d'opérations à virgule flottante).
Commençons donc.
Drapeaux du compilateur
Pour prendre en charge la virgule flottante, vous devez transmettre les indicateurs corrects au compilateur. Une recherche rapide
de nous mène à l'idée que deux options sont particulièrement importantes: -mfloat-abi et -mfpu. L'option -mfloat-abi définit l'
ABI pour les opérations en virgule flottante et peut avoir l'une des trois valeurs: «soft», «softfp» et «hard». L'option 'soft', comme son nom l'indique, indique au compilateur d'utiliser les appels de fonction intégrés pour programmer le point flottant (cette option a été utilisée auparavant). Les deux «softfp» et «hard» restants seront considérés un peu plus tard, après avoir considéré l'option -mfpu.
-Mppu flag et version VFP
L'option -mfpu, telle qu'écrite dans la
documentation en ligne de gcc , vous permet de spécifier le type de matériel et peut prendre les options suivantes:
'auto', 'vfpv2', 'vfpv3', 'vfpv3-fp16', 'vfpv3-d16', 'vfpv3-d16-fp16', 'vfpv3xd', 'vfpv3xd-fp16', 'neon-vfpv3', 'neon -fp16 ',' vfpv4 ',' vfpv4-d16 ',' fpv4-sp-d16 ',' neon-vfpv4 ',' fpv5-d16 ',' fpv5-sp-d16 ',' fp-armv8 ',' neon -fp-armv8 'et' crypto-neon-fp-armv8 '. Et «néon» est identique à «néon-vfpv3», et «vfp» est «vfpv2».
Mon compilateur (arm-none-eabi-gcc (15: 5.4.1 + svn241155-1) 5.4.1 20160919) produit une liste légèrement différente, mais cela ne change pas l'essence de la question. Dans tous les cas, nous devons comprendre comment tel ou tel indicateur affecte le compilateur, et bien sûr, quel indicateur doit être utilisé quand.
J'ai commencé à comprendre la plate-forme basée sur le processeur imx6, mais nous allons la reporter un peu de temps, car le coprocesseur néon a des fonctionnalités dont je parlerai plus tard, et nous commencerons par un cas plus simple - de la plate-forme
intégrateur / cp ,
Je n'ai pas la carte elle-même, donc le débogage a été fait sur l'émulateur qemu. Dans qemu, la plate-forme Interator / cp est basée sur le processeur
ARM926EJ-S , qui Ă son tour prend en charge le coprocesseur
VFP9-S . Ce coprocesseur est conforme à la version 2 de l'architecture à virgule flottante (VFPv2). En conséquence, vous devez définir -mfpu = vfpv2, mais cette option ne figurait pas dans la liste des options de mon compilateur. Sur
Internet, j'ai rencontré une option de compilation avec les drapeaux -mcpu = arm926ej-s -mfpu = vfpv3-d16, je l'ai installée et tout a été compilé pour moi. Lorsque j'ai commencé, j'ai reçu une
exception d'instruction non définie , qui était prévisible, car le coprocesseur était
éteint .
Afin de permettre au coprocesseur de fonctionner, vous devez définir le bit EN [30] dans le registre
FPEXC . Cela se fait Ă l'aide de la commande VMSR.
asm volatile ("VMSR FPEXC, %0" : : "r" (1 << 30);
En fait, la commande VMSR est traitée par le coprocesseur et lève une exception si le coprocesseur n'est pas activé, mais l'accès à ce registre
ne le provoque pas . Certes, contrairement aux autres, l'accès à ce registre n'est possible qu'en
mode privilégié .
Après que le coprocesseur a été autorisé à fonctionner, nos tests de fonctions mathématiques ont commencé à passer. Mais lorsque j'ai activé l'optimisation (-O2), l'exception d'instruction non définie mentionnée précédemment a été déclenchée. Et il est apparu sur l'instruction
vmov qui a été appelée dans le code plus tôt, mais qui a été exécutée avec succès (sans exception). Enfin, j'ai trouvé à la fin de la page l'expression «Les instructions qui copient les constantes immédiates sont disponibles dans VFPv3» (c'est-à -dire que les opérations avec des constantes sont prises en charge à partir de VFPv3). Et j'ai décidé de vérifier quelle version est publiée dans mon émulateur. La version est enregistrée dans le registre
FPSID . De la documentation, il s'ensuit que la valeur du registre doit être 0x41011090. Cela correspond à 1 dans le domaine de l'architecture [19..16] c'est-à -dire VFPv2. En fait, après avoir fait une impression au démarrage, j'ai obtenu ceci
unit: initializing embox.arch.arm.fpu.vfp9_s: VPF info: Hardware FP support Implementer = 0x41 (ARM) Subarch: VFPv2 Part number = 0x10 Variant = 0x09 Revision = 0x00
Après avoir lu attentivement que 'vfp' est alias 'vfpv2', j'ai mis le bon drapeau, cela a fonctionné. Revenant à la
page où j'ai vu la combinaison de drapeaux -mcpu = arm926ej-s -mfpu = vfpv3-d16, je note que je n'ai pas fait assez attention, car -mfloat-abi = soft apparaît dans la liste des drapeaux. Autrement dit, il n'y a pas de support matériel dans ce cas. Plus précisément, -mfpu n'a d'importance que si une valeur autre que «soft» est définie sur -mfloat-abi.
Assembleur
Il est temps de parler d'assembleur. Après tout, je devais prendre en charge l'exécution, et, par exemple, le compilateur, bien sûr, ne connaît pas le changement de contexte.
Registres
Commençons par la description des registres. VFP vous permet d'effectuer des opérations avec des nombres à virgule flottante 32 bits (s0..s31) et 64 bits (d0..d15) .La correspondance entre ces registres est illustrée dans l'image ci-dessous.

Q0-Q15 sont des registres 128 bits d'anciennes versions pour travailler avec SIMD, plus Ă leur sujet plus tard.
Système de commande
Bien sûr, le plus souvent, le travail avec les registres VFP doit être confié au compilateur, mais au moins vous devez écrire le changement de contexte manuellement. Si vous avez déjà une compréhension approximative de la syntaxe des instructions d'assembleur pour travailler avec des registres à usage général, le traitement des nouvelles instructions ne devrait pas être difficile. Le plus souvent, le préfixe «v» est simplement ajouté.
vmov d0, r0, r1 /* r0 r1, .. d0 64 , r0-1 32 */ vmov r0, r1, d0 vadd d0, d1, d2 vldr d0, r0 vstm r0!, {d0-d15} vldm r0!, {d0-d15}
Et ainsi de suite. Une liste complète des commandes se trouve sur
le site Web d'ARM .
Et bien sûr, n'oubliez pas la version VFP pour qu'il n'y ait pas de situations comme celle décrite ci-dessus.
Drapeau -mfloat-abi 'softfp' et 'hard'
Retour Ă -mfloat-abi. Si vous lisez la
documentation , nous verrons:
'softfp' permet la génération de code à l'aide d'instructions matérielles à virgule flottante, mais utilise toujours les conventions d'appel soft-float. 'hard' permet la génération d'instructions à virgule flottante et utilise des conventions d'appel spécifiques aux FPU.
Autrement dit, nous parlons de passer des arguments à une fonction. Mais au moins, il n’était pas très clair pour moi quelle est la différence entre les conventions d’appel «flottant» et «spécifiques aux FPU». En supposant que le boîtier rigide utilise des registres à virgule flottante et le boîtier softfp utilise des registres entiers, j'ai trouvé une confirmation sur le
wiki Debian . Et bien que ce soit pour les coprocesseurs NEON, mais cela n'a pas d'importance. Un autre point intéressant est qu'avec l'option softfp, le compilateur peut, mais n'est pas obligé d'utiliser le support matériel:
"Le compilateur peut faire des choix intelligents quant au moment et s'il génère des instructions FPU émulées ou réelles en fonction du type de FPU choisi (-mfpu =)"
Pour plus de clarté, j'ai décidé d'expérimenter et j'ai été très surpris, car avec l'optimisation -O0 désactivée, la différence était très faible et ne s'appliquait pas aux endroits où la virgule flottante était réellement utilisée. En supposant que le compilateur pousse simplement tout sur la pile, plutôt que d'utiliser des registres, j'ai activé l'optimisation -O2 et j'ai de nouveau été surpris, car avec l'optimisation, le compilateur a commencé à utiliser des registres matériels à virgule flottante pour les options matérielles et sotffp, et la différence est, comme et dans le cas de -O0, c'était très insignifiant. En conséquence, pour moi, j'ai expliqué cela par le fait que le compilateur résout le
problème associé au fait que si vous copiez des données entre des registres à virgule flottante et des entiers, les performances chuteront considérablement. Et le compilateur, lors de l'optimisation, commence à utiliser toutes les ressources à sa disposition.
Lorsqu'on m'a demandé quel drapeau utiliser 'softfp' ou 'hard', j'ai répondu moi-même comme suit: partout où il y a déjà des parties compilées avec le drapeau 'softfp', vous devez utiliser 'hard'. S'il y en a, vous devez utiliser 'softfp'.
Changement de contexte
Étant donné qu'Embox prend en charge le multitâche préemptif, pour fonctionner correctement lors de l'exécution, une implémentation du changement de contexte était naturellement nécessaire. Pour ce faire, vous devez enregistrer les registres du coprocesseur. Il y a quelques nuances. Premièrement: il s'est avéré que
les commandes d'opération de pile pour les virgules flottantes (vstm / vldm) ne prennent pas en charge tous les modes . Deuxièmement: ces opérations ne prennent pas en charge le travail avec plus de seize registres 64 bits. Si vous devez charger / enregistrer plus de registres à la fois, vous devez utiliser deux instructions.
Je vais donner une autre petite optimisation. En fait, la sauvegarde et la restauration de 256 octets de registres VFP à chaque fois ne sont pas du tout nécessaires (les registres à usage général n'occupent que 64 octets, donc la différence est significative). Une optimisation évidente n'effectuera ces opérations que si le processus utilise en principe ces registres.
Comme je l'ai déjà mentionné, lorsque le coprocesseur VFP est éteint, une tentative d'exécution de l'instruction correspondante entraînera une exception «Instruction non définie». Dans le gestionnaire de cette exception, vous devez vérifier la cause de l'exception et s'il s'agit d'utiliser un coprocesseur VPF, le processus est marqué comme utilisant le coprocesseur VFP.
En conséquence, la sauvegarde / restauration du contexte déjà écrite a été complétée par des macros
#define ARM_FPU_CONTEXT_SAVE_INC(tmp, stack) \ vmrs tmp, FPEXC ; \ stmia stack!, {tmp}; \ ands tmp, tmp, #1<<30; \ beq fpu_out_save_inc; \ vstmia stack!, {d0-d15}; \ fpu_out_save_inc: #define ARM_FPU_CONTEXT_LOAD_INC(tmp, stack) \ ldmia stack!, {tmp}; \ vmsr FPEXC, tmp; \ ands tmp, tmp, #1<<30; \ beq fpu_out_load_inc; \ vldmia stack!, {d0-d15}; \ fpu_out_load_inc:
Pour vérifier l'exactitude de l'opération de changement de contexte dans des conditions à virgule flottante, nous avons écrit un test dans lequel nous multiplions dans un thread dans une boucle et divisons dans un autre, puis comparons les résultats.
EMBOX_TEST_SUITE("FPU context consistency test. Must be compiled with -02"); #define TICK_COUNT 10 static float res_out[2][TICK_COUNT]; static void *fpu_context_thr1_hnd(void *arg) { float res = 1.0f; int i; for (i = 0; i < TICK_COUNT; ) { res_out[0][i] = res; if (i == 0 || res_out[1][i - 1] > 0) { i++; } if (res > 0.000001f) { res /= 1.01f; } sleep(0); } return NULL; } static void *fpu_context_thr2_hnd(void *arg) { float res = 1.0f; int i = 0; for (i = 0; i < TICK_COUNT; ) { res_out[1][i] = res; if (res_out[0][i] != 0) { i++; } if (res < 1000000.f) { res *= 1.01f; } sleep(0); } return NULL; } TEST_CASE("Test FPU context consistency") { pthread_t threads[2]; pthread_t tid = 0; int status; status = pthread_create(&threads[0], NULL, fpu_context_thr1_hnd, &tid); if (status != 0) { test_assert(0); } status = pthread_create(&threads[1], NULL, fpu_context_thr2_hnd, &tid); if (status != 0) { test_assert(0); } pthread_join(threads[0], (void**)&status); pthread_join(threads[1], (void**)&status); test_assert(res_out[0][0] != 0 && res_out[1][0] != 0); for (int i = 1; i < TICK_COUNT; i++) { test_assert(res_out[0][i] < res_out[0][i - 1]); test_assert(res_out[1][i] > res_out[1][i - 1]); } }
Le test a réussi avec succès lorsque l'optimisation a été désactivée, nous avons donc indiqué dans la description du test qu'il devait être compilé avec l'optimisation, EMBOX_TEST_SUITE ("Test de cohérence du contexte FPU. Doit être compilé avec -02"); même si nous savons que les tests ne doivent pas reposer
Coprocesseur NEON et SIMD
Il est temps de dire pourquoi j'ai reporté l'histoire sur imx6. Le fait est qu'il est basé sur le noyau Cortex-A9 et contient le coprocesseur NEON le plus avancé (https://developer.arm.com/technologies/neon). NEON n'est pas seulement VFPv3, mais c'est aussi un coprocesseur SIMD. VFP et NEON utilisent les mêmes registres. VFP utilise des registres 32 bits et 64 bits pour le fonctionnement, et NEON utilise des registres 64 bits et 128 bits, ces derniers étant simplement désignés Q0-Q16. En plus des valeurs entières et des nombres à virgule flottante, NEON est également capable de travailler avec un anneau polynomial de 16 ou 8e degré modulo 2.
Le mode vfp pour NEON n'est presque pas différent du coprocesseur vfp9-s démonté. Bien sûr, il est préférable de spécifier les options vfpv3 ou vfpv3-d32 pour -mfpu pour une meilleure optimisation, car il dispose de 32 registres 64 bits. Et pour activer le coprocesseur, vous devez donner accès aux coprocesseurs c10 et c11. cela se fait à l'aide de commandes
asm volatile ("mrc p15, 0, %0, c1, c0, 2" : "=r" (val) :); val |= 0xf << 20; asm volatile ("mcr p15, 0, %0, c1, c0, 2" : : "r" (val));
mais il n'y a pas d'autres différences fondamentales.
Une autre chose si vous spécifiez -mfpu = neon, dans ce cas, le compilateur peut utiliser des instructions SIMD.
Utilisation de SIMD en C
Pour << enregistrer >> les valeurs manuellement par registre, vous pouvez inclure "arm_neon.h" et utiliser les types de données correspondants:
float32x4_t pour quatre flottants 32 bits dans un registre, uint8x8_t pour huit entiers 8 bits et ainsi de suite. Pour accéder à une seule valeur, nous l'appelons tableau, addition, multiplication, affectation, etc. comme pour les variables ordinaires, par exemple:
uint32x4_t a = {1, 2, 3, 4}, b = {5, 6, 7, 8}; uint32x4_t c = a * b; printf(“Result=[%d, %d, %d, %d]\n”, c[0], c[1], c[2], c[3]);
Bien sûr, l'utilisation de la vectorisation automatique est plus facile. Pour la vectorisation automatique, ajoutez l'indicateur -ftree-vectorize à GCC.
void simd_test() { int a[LEN], b[LEN], c[LEN]; for (int i = 0; i < LEN; i++) { a[i] = i; b[i] = LEN - i; } for (int i = 0; i < LEN; i++) { c[i] = a[i] + b[i]; } for (int i = 0; i < LEN; i++) { printf("c[i] = %d\n", c[i]); } }
La boucle d'addition génère le code suivant:
600059a0: f4610adf vld1.64 , [r1 :64] 600059a4: e2833010 add r3, r3, #16 600059a8: e28d0a03 add r0, sp, #12288 ; 0x3000 600059ac: e2811010 add r1, r1, #16 600059b0: f4622adf vld1.64 , [r2 :64] 600059b4: e2822010 add r2, r2, #16 600059b8: f26008e2 vadd.i32 q8, q8, q9 600059bc: ed430b04 vstr d16, [r3, #-16] 600059c0: ed431b02 vstr d17, [r3, #-8] 600059c4: e1530000 cmp r3, r0 600059c8: 1afffff4 bne 600059a0 <foo+0x58> 600059cc: e28d5dbf add r5, sp, #12224 ; 0x2fc0 600059d0: e2444004 sub r4, r4, #4 600059d4: e285503c add r5, r5, #60 ; 0x3c
Après avoir effectué des tests de code parallélisé, nous avons constaté qu'un simple ajout dans une boucle, à condition que les variables soient indépendantes, donne une accélération jusqu'à 7 fois. De plus, nous avons décidé de voir dans quelle mesure la parallélisation affecte les tâches réelles, avons pris MESA3d avec son émulation logicielle et mesuré le nombre de fps avec différents drapeaux, nous avons obtenu un gain de 2 images par seconde (15 contre 13), c'est-à -dire que l'accélération est d'environ 15-20% .
Je vais
donner un autre
exemple d'accélération en utilisant des commandes NEON , pas les nôtres, mais de ARM.
La copie de la mémoire est jusqu'à 50% plus rapide que la normale. De vrais exemples sont là dans l'assembleur.
Cycle de copie normal:
WordCopy LDR r3, [r1], #4 STR r3, [r0], #4 SUBS r2, r2, #4 BGE WordCopy
boucle avec des commandes et registres néon:
NEONCopyPLD PLD [r1, #0xC0] VLDM r1!,{d0-d7} VSTM r0!,{d0-d7} SUBS r2,r2,#0x40 BGE NEONCopyPLD
Il est clair que la copie sur 64 octets est plus rapide que 4 octets, et une telle copie donnera une augmentation de 10%, mais les 40% restants semblent donner le travail du coprocesseur.
Cortex-m
Travailler avec des FPU dans Cortex-M n'est pas très différent de celui décrit ci-dessus. Par exemple, voici à quoi ressemble la macro ci-dessus pour enregistrer le contexte fpu-shny
#define ARM_FPU_CONTEXT_SAVE_INC(tmp, stack) \ ldr tmp, =CPACR; \ ldr tmp, [tmp]; \ tst tmp, #0xF00000; \ beq fpu_out_save_inc; \ vstmia stack!, {s0-s31}; fpu_out_save_inc:
De plus, la commande vstmia utilise uniquement les registres s0-s31 et les registres de contrôle sont accessibles différemment. Par conséquent, je n'entrerai pas dans trop de détails, je ne vous expliquerai que les diff. Nous avons donc pris en charge la découverte de STM32F7 avec
cortex-m7 pour cela, respectivement, nous devons définir l'indicateur -mfpu = fpv5-sp-d16. Veuillez noter que dans les versions mobiles, vous devez regarder de plus près la version du coprocesseur, car le même cortex-m peut avoir des options différentes. Donc, si votre option n'est pas en double précision, mais en simple, alors il peut ne pas y avoir de registres D0-D16, comme nous l'avons dans
stm32f4discovery , c'est pourquoi la variante avec les registres S0-S31 est utilisée. Pour ce contrôleur, nous utilisons -mfpu = fpv4-sp-d16.
La principale différence est l'accès aux registres de contrôle du contrôleur, ils sont situés directement dans l'espace d'adressage du noyau principal, et pour différents types ils sont différents
cortex-m4 pour
cortex-m7 .
Conclusion
Sur ce, je terminerai ma courte histoire sur la virgule flottante pour ARM. Je note que les microcontrôleurs modernes sont très puissants et conviennent non seulement pour le contrôle, mais aussi pour le traitement de signaux ou de divers types d'informations multimédias. Afin d'utiliser efficacement tout ce pouvoir, vous devez comprendre comment il fonctionne. J'espère que cet article a aidé à comprendre cela un peu mieux.