Le râteau le plus courant lors de l'utilisation de printf dans des programmes pour microcontrôleurs

De temps en temps dans mes projets, je dois utiliser printf en conjonction avec un port série (UART ou une abstraction sur USB qui imite un port série). Et, comme d'habitude, beaucoup de temps s'écoule entre ses applications et j'arrive à oublier complètement toutes les nuances qui doivent être prises en compte pour qu'il fonctionne normalement dans un grand projet.

Dans cet article, j'ai compilé mes propres nuances principales qui surviennent lors de l'utilisation de printf dans des programmes pour microcontrôleurs, triées par évidence du plus évident au complètement non évident.

Brève introduction


En effet, pour utiliser printf dans des programmes pour microcontrôleurs, il suffit:
  • inclure le fichier d'en-tête dans le code du projet;
  • redéfinir la fonction système _write pour sortir sur le port série;
  • Décrire les talons d'appels système requis par l'éditeur de liens (_fork, _wait et autres);
  • utiliser l'appel printf dans le projet.

En fait, tout n'est pas si simple.

Décrivez tous les talons, pas seulement ceux utilisés.


La présence d'un tas de liens vagues lors de la construction du projet dans un premier temps est surprenante, mais après avoir lu un peu, il devient clair quoi et pourquoi. Dans tous mes projets, je connecte ce sous - module . Ainsi, dans le projet principal, je redéfinis uniquement les méthodes dont j'ai besoin (uniquement _write dans ce cas), et les autres restent inchangées.

Il est important de noter que tous les stubs doivent être des fonctions C. Pas C ++ (ou enveloppé dans un «C» externe). Sinon, la mise en page échouera (n'oubliez pas le changement de nom lors de l'assemblage avec G ++).

En _écriture vient 1 caractère


Malgré le fait que le prototype de la méthode _write ait un argument qui passe la longueur du message affiché, il a une valeur de 1 (en fait, nous le ferons nous-mêmes toujours 1, mais plus sur cela plus tard).
int _write (int file, char *data, int len) { ... } 

Sur Internet, vous pouvez souvent voir une telle implémentation de cette méthode:
Implémentation fréquente de la fonction _write
 int uart_putc( const char ch) { while (USART_GetFlagStatus(USART2, USART_FLAG_TC) == RESET); {} USART_SendData(USART2, (uint8_t) ch); return 0; } int _write_r (struct _reent *r, int file, char * ptr, int len) { r = r; file = file; ptr = ptr; #if 0 int index; /* For example, output string by UART */ for(index=0; index<len; index++) { if (ptr[index] == '\n') { uart_putc('\r'); } uart_putc(ptr[index]); } #endif return len; } 


Une telle implémentation présente les inconvénients suivants:
  • faible productivité;
  • l'insécurité en continu;
  • incapacité à utiliser le port série à d'autres fins;


Faible performance


La lenteur des performances est due à l'envoi d'octets à l'aide des ressources du processeur: vous devez surveiller le registre d'état au lieu d'utiliser le même DMA. Pour résoudre ce problème, vous pouvez préparer le tampon pour l'envoi à l'avance et lors de la réception du caractère de fin de ligne (ou du remplissage du tampon) envoyer. Cette méthode nécessite une mémoire tampon, mais améliore considérablement les performances avec des envois fréquents.
Exemple d'implémentation de _write avec un tampon
 #include "uart.h" #include <errno.h> #include <sys/unistd.h> extern mc::uart uart_1; extern "C" { //      uart. static const uint32_t buf_size = 254; static uint8_t tx_buf[buf_size] = {0}; static uint32_t buf_p = 0; static inline int _add_char (char data) { tx_buf[buf_p++] = data; if (buf_p >= buf_size) { if (uart_1.tx(tx_buf, buf_p, 100) != mc_interfaces::res::ok) { errno = EIO; return -1; } buf_p = 0; } return 0; } // Putty  \r\n    //    . static inline int _add_endl () { if (_add_char('\r') != 0) { return -1; } if (_add_char('\n') != 0) { return -1; } uint32_t len = buf_p; buf_p = 0; if (uart_1.tx(tx_buf, len, 100) != mc_interfaces::res::ok) { errno = EIO; return -1; } return 0; } int _write (int file, char *data, int len) { len = len; //   . if ((file != STDOUT_FILENO) && (file != STDERR_FILENO)) { errno = EBADF; return -1; } //     //   \n. if (*data != '\n') { if (_add_char(*data) != 0) { return -1; } } else { if (_add_endl() != 0) { return -1; } } return 1; } } 

Ici, l'objet uart, uart_1, est responsable de l'envoi direct à l'aide de dma. L'objet utilise des méthodes FreeRTOS pour bloquer l'accès tiers à l'objet au moment de l'envoi de données à partir du tampon (prise et retour de mutex). Ainsi, personne ne peut utiliser l'objet uart lors de l'envoi depuis un autre thread.
Quelques liens:
  • _écrire le code de fonction dans le cadre d'un vrai projet ici
  • l'interface de classe uart est ici
  • implémentation de l'interface de classe uart sous stm32f4 ici et ici
  • instanciation de la classe uart dans le cadre du projet ici


Insécurité en streaming


Cette implémentation reste également non protégée, car personne ne dérange dans le flux FreeRTOS voisin pour commencer à envoyer une autre ligne à printf et broyer ainsi le tampon qui est actuellement envoyé (un mutex à l'intérieur de l'uart protège l'objet contre l'utilisation dans différents flux, mais les données ne leur sont pas transmises ) Dans le cas où il y a un risque que printf d'un autre thread soit appelé, alors il est nécessaire d'implémenter un objet calque qui bloquera complètement l'accès à printf. Dans mon cas particulier, un seul thread interagit avec printf, donc des complications supplémentaires ne feront que réduire les performances (capture et libération constantes de mutex à l'intérieur de la couche).

Incapacité à utiliser le port série à d'autres fins


Étant donné que nous n'envoyons qu'après réception de la chaîne entière (ou que le tampon est plein), au lieu de l'objet uart, vous pouvez appeler la méthode du convertisseur vers une interface de niveau supérieur pour le transfert de paquets ultérieur (par exemple, livraison avec garantie selon le protocole de transmission similaire aux paquets transaction modbus). Cela vous permettra d'utiliser un uart à la fois pour afficher les informations de débogage et, par exemple, pour l'interaction de l'utilisateur avec la console de gestion (si un est disponible sur l'appareil). Il suffira d'écrire un décompresseur côté destinataire.

Par défaut, la sortie flottante ne fonctionne pas


Si vous utilisez newlib-nano, alors par défaut printf (ainsi que tous leurs dérivés comme sprintf / snprintf ... et autres) ne prennent pas en charge la sortie des valeurs flottantes. Ceci est facilement résolu en ajoutant les indicateurs de l'éditeur de liens suivants au projet.
 SET(LD_FLAGS -Wl,-u,vfprintf; -Wl,-u,_printf_float; -Wl,-u,_scanf_float; "_") 

Voir la liste complète des drapeaux ici .

Le programme se fige quelque part dans les entrailles de printf


C'est une autre faille dans les drapeaux de l'éditeur de liens. Pour que le firmware soit configuré avec la version souhaitée de la bibliothèque, vous devez spécifier explicitement les paramètres du processeur.
 SET(HARDWARE_FLAGS -mthumb; -mcpu=cortex-m4; -mfloat-abi=hard; -mfpu=fpv4-sp-d16;) SET(LD_FLAGS ${HARDWARE_FLAGS} "_") 

Voir la liste complète des drapeaux ici .

printf oblige le microcontrôleur à entrer dans une faute grave


Il peut y avoir au moins deux raisons:
  • problèmes de pile;
  • problèmes avec _sbrk;

Problèmes de pile


Ce problème se manifeste vraiment lors de l'utilisation de FreeRTOS ou de tout autre système d'exploitation. Le problème est d'utiliser le tampon. Le premier paragraphe disait que dans _write vient 1 octet chacun. Pour que cela se produise, vous devez interdire l'utilisation de la mise en mémoire tampon dans votre code avant d'utiliser printf pour la première fois.
 setvbuf(stdin, NULL, _IONBF, 0); setvbuf(stdout, NULL, _IONBF, 0); setvbuf(stderr, NULL, _IONBF, 0); 

De la description de la fonction, il s'ensuit que l'une des valeurs suivantes peut être définie de la même manière:
 #define _IOFBF 0 /* setvbuf should set fully buffered */ #define _IOLBF 1 /* setvbuf should set line buffered */ #define _IONBF 2 /* setvbuf should set unbuffered */ 

Cependant, cela peut entraîner un débordement de la pile des tâches (ou des interruptions si vous êtes soudainement une très mauvaise personne qui appelle printf à partir d'interruptions).

D'un point de vue purement technique, il est possible d'organiser les piles très soigneusement pour chaque flux, mais cette approche nécessite une planification minutieuse et il est difficile de détecter les erreurs qu'elle comporte. Une solution beaucoup plus simple consiste à recevoir un octet chacun, à le stocker dans son propre tampon, puis à le produire au format requis, analysé plus tôt.

Problèmes avec _sbrk


Ce problème était pour moi personnellement le plus implicite. Et que savons-nous de _sbrk?
  • Un autre stub qui doit être implémenté pour prendre en charge une partie considérable des bibliothèques standard;
  • requis pour allouer de la mémoire sur le tas;
  • utilisé par toutes sortes de méthodes de bibliothèque comme malloc, gratuit.

Personnellement, dans mes projets dans 95% des cas, j'utilise FreeRTOS avec des méthodes redéfinies new / delete / malloc qui utilisent un tas de FreeRTOS. Ainsi, lorsque j'alloue de la mémoire, je suis sûr que l'allocation se trouve sur le tas FreeRTOS, qui occupe une quantité prédéterminée de mémoire dans la zone bss. Vous pouvez regarder le calque ici . Donc, purement techniquement, il ne devrait pas y avoir de problème. Une fonction ne doit tout simplement pas être appelée. Mais pensons, si elle appelle, alors où tentera-t-elle de se souvenir?

Rappelons la disposition de la RAM du projet "classique" pour microcontrôleurs:
  • .data;
  • .bss;
  • espace vide
  • pile initiale.

Dans les données, nous avons les données initiales des objets globaux (variables, structures et autres champs de projet global). En bss, les champs globaux qui ont une valeur zéro initiale et, soigneusement, un tas de FreeRTOS. C'est juste un tableau en mémoire. avec lesquelles les méthodes du fichier heap_x.c fonctionnent alors. Vient ensuite l'espace vide, après quoi (ou plutôt depuis la fin) se trouve la pile. Parce que FreeRTOS est utilisé dans mon projet, puis cette pile n'est utilisée que jusqu'au démarrage du planificateur. Et, ainsi, son utilisation, dans la plupart des cas, est limitée au collobyte (en fait, généralement une limite de 100 octets).

Mais où, alors, la mémoire est-elle allouée en utilisant _sbrk? Jetez un œil aux variables qu'elle utilise à partir du script de l'éditeur de liens.
 void *__attribute__ ((weak)) _sbrk (int incr) { extern char __heap_start; extern char __heap_end; ... 

Maintenant, nous les trouvons dans le script de l'éditeur de liens (mon script est légèrement différent de celui que st fournit, mais cette partie est à peu près la même là-bas):
 __stack = ORIGIN(SRAM) + LENGTH(SRAM); __main_stack_size = 1024; __main_stack_limit = __stack - __main_stack_size; ...  flash,    ... .bss (NOLOAD) : ALIGN(4) { ... . = ALIGN(4); __bss_end = .; } >SRAM __heap_start = __bss_end; __heap_end = __main_stack_limit; 

Autrement dit, il utilise de la mémoire entre la pile (1 ko de 0x20020000 vers le bas avec 128 ko de RAM) et bss.

Compris. Mais il a eu une redéfinition des méthodes malloc, free, et autres. Utiliser _sbrk après tout n'est pas nécessaire? En fin de compte, un must. De plus, cette méthode n'utilise pas printf, mais la méthode pour définir le mode de mise en mémoire tampon - setvbuf (ou plutôt _malloc_r, qui n'est pas déclarée comme une fonction faible dans la bibliothèque. Contrairement à malloc, qui peut être facilement remplacé).

Comme j'étais sûr que sbrk n'était pas utilisé, j'ai placé un tas de FreeRTOS (section bss) près de la pile (car je savais avec certitude que la pile était utilisée 10 fois moins que nécessaire).

Solutions au problème 3:
  • retrait entre bss et la pile;
  • remplacer _malloc_r pour que _sbrk ne soit pas appelé (séparer une méthode de la bibliothèque);
  • réécrire sbrk via malloc et gratuitement.

J'ai opté pour la première option, car il n'était pas possible de remplacer en toute sécurité le _malloc_r standard (qui se trouve à l'intérieur de libg_nano.a (lib_a-nano-mallocr.o)) (la méthode n'a pas été déclarée comme __attribute__ ((faible)), mais pour exclure une seule fonction de la bi-bibliothèque Je n'ai pas réussi à créer de lien). Je ne voulais vraiment pas réécrire sbrk pour un seul appel.

La solution finale était d'allouer des partitions distinctes en RAM pour la pile initiale et _sbrk. Cela garantit que les sections ne sont pas empilées les unes sur les autres pendant la phase de configuration. A l'intérieur de sbrk, il y a aussi un contrôle pour sortir de la section. J'ai dû faire une petite correction pour que lors de la détection d'une transition à l'étranger, le flux se bloque dans une boucle while (car l'utilisation de sbrk ne se produit qu'au stade initial de l'initialisation et doit être traitée au stade du débogage de l'appareil).
Mem.ld modifié
 MEMORY { FLASH (RX) : ORIGIN = 0x08000000, LENGTH = 1M CCM_SRAM (RW) : ORIGIN = 0x10000000, LENGTH = 64K SRAM (RW) : ORIGIN = 0x20000000, LENGTH = 126K SBRK_HEAP (RW) : ORIGIN = 0x2001F800, LENGTH = 1K MAIN_STACK (RW) : ORIGIN = 0x2001FC00, LENGTH = 1K } 


Modifications apportées à section.ld
 __stack = ORIGIN(MAIN_STACK) + LENGTH(MAIN_STACK); __heap_start = ORIGIN(SBRK_HEAP); __heap_end = ORIGIN(SBRK_HEAP) + LENGTH(SBRK_HEAP); 

Vous pouvez regarder mem.ld et section.ld dans mon projet sandbox dans ce commit .

UPD 07/12/2019: correction de la liste des drapeaux pour travailler printf avec des valeurs flottantes. J'ai corrigé le lien vers les CMakeLists de travail avec des indicateurs de compilation et de mise en page corrigés (il y avait des nuances avec le fait que les indicateurs devraient être répertoriés un par un et via ";", alors que sur une ligne ou sur des lignes différentes, cela n'a pas d'importance).

Source: https://habr.com/ru/post/fr459420/


All Articles