Fonctionner avec des composants externes au microcontrôleur ou à la cible elle-même est la norme en matière de développement de micrologiciels. Il est donc essentiel de savoir comment développer des bibliothèques pour eux. Ces bibliothèques nous permettent d'interagir avec elles et d'échanger des informations ou des commandes. Cependant, il n'est pas rare de constater, dans le code existant ou celui des étudiants (ou pas si étudiants), que ces interactions avec les composants se font directement dans le code de l'application ou, même lorsqu'elles sont placées dans des fichiers séparés, ces interactions sont intrinsèquement lié à la cible.
Regardons un mauvais exemple de développement de bibliothèque pour un capteur de température, d'humidité et de pression Bosch BME280 dans une application pour un STMicroelectronics STM32F401RE. Dans l'exemple, nous voulons initialiser le composant et lire la température toutes les 1 seconde. (Dans l'exemple de code, nous omettrons tout le "bruit" généré par STM32CubeMX/IDE, comme l'initialisation de diverses horloges et périphériques, ou des commentaires comme USER CODE BEGIN ou USER CODE END.)
#include "i2c.h" #include <stdint.h> int main(void) { uint8_t idx = 0U; uint8_t tx_buffer[64] = {0}; uint8_t rx_buffer[64] = {0}; uint16_t dig_temp1 = 0U; int16_t dig_temp2 = 0; int16_t dig_temp3 = 0; MX_I2C1_Init(); tx_buffer[idx++] = 0b10100011; HAL_I2C_Mem_Write(&hi2c1, 0x77U << 1U, 0xF4U, 1U, tx_buffer, 1U, 200U); HAL_I2C_Mem_Read(&hi2c1, 0x77U << 1U, 0x88U, 1U, rx_buffer, 6U, 200U); dig_temp1 = ((uint16_t)rx_buffer[0]) | (((uint16_t)rx_buffer[1]) << 8U); dig_temp2 = (int16_t)(((uint16_t)rx_buffer[2]) | (((uint16_t)rx_buffer[3]) << 8U)); dig_temp3 = (int16_t)(((uint16_t)rx_buffer[4]) | (((uint16_t)rx_buffer[5]) << 8U)); while (1) { float temperature = 0.0f; int32_t adc_temp = 0; int32_t t_fine = 0; float var1 = 0.0f; float var2 = 0.0f; HAL_I2C_Mem_Read(&hi2c1, 0x77U << 1U, 0xFAU, 1U, rx_buffer, 3U, 200U); adc_temp = (int32_t)((((uint32_t)rx_buffer[0]) << 12U) | (((uint32_t)rx_buffer[1]) << 4U) | (((uint32_t)rx_buffer[2]) >> 4U)); var1 = (((float)adc_temp) / 16384.0f - ((float)dig_temp1) / 1024.0f) * ((float)dig_temp2); var2 = ((((float)adc_temp) / 131072.0f - ((float)dig_temp1) / 8192.0f) * (((float)adc_temp) / 131072.0f - ((float)dig_temp1) / 8192.0f)) * ((float)dig_temp3); t_fine = (int32_t)(var1 + var2); temperature = ((float)t_fine) / 5129.0f; // Temperature available for the application. } }
À partir de cet exemple, nous pouvons soulever une série de questions : que se passe-t-il si je dois changer de cible (que ce soit en raison de ruptures de stock, d'une volonté de réduire les coûts ou simplement de travailler sur un autre produit utilisant le même composant) ? Que se passe-t-il si j'ai plusieurs composants du même type dans le système ? Que se passe-t-il si un autre produit utilise le même composant ? Comment tester mon développement si je n'ai pas encore le matériel (situation très courante dans le monde professionnel où les phases de développement firmware et matériel se chevauchent souvent à certains moments du processus) ?
Pour les trois premières questions, la réponse est d'éditer le code, que ce soit pour le changer complètement lors d'un changement de cible, pour dupliquer le code existant pour fonctionner avec un composant supplémentaire du même type, ou pour implémenter le même code pour le autre projet/produit. Dans la dernière question, il n’existe aucun moyen de tester le code sans disposer du matériel nécessaire pour l’exécuter. Cela signifie que ce n'est qu'une fois le matériel terminé que nous pourrons commencer à tester notre code et commencer à corriger les erreurs inhérentes au développement du micrologiciel lui-même, prolongeant ainsi le temps de développement du produit. Cela soulève la question qui donne naissance à ce post : est-il possible de développer des bibliothèques de composants indépendantes de la cible et permettant une réutilisation ? La réponse est oui, et c'est ce que nous verrons dans cet article.
Pour isoler les bibliothèques d'une cible, nous suivrons deux règles : 1) nous implémenterons la bibliothèque dans sa propre unité de compilation, c'est-à-dire son propre fichier, et 2) il n'y aura aucune référence à des en-têtes ou des fonctions spécifiques à la cible. . Nous allons le démontrer en implémentant une bibliothèque simple pour le BME280. Pour commencer, nous allons créer un dossier appelé bme280 au sein de notre projet. Dans le dossier bme280, nous allons créer les fichiers suivants : bme280.c, bme280.h et bme280_interface.h. Pour clarifier, non, je n'ai pas oublié de nommer le fichier bme280_interface.c. Ce fichier ne fera pas partie de la bibliothèque.
Je place généralement les dossiers de la bibliothèque dans Application/lib/.
Le fichier bme280.h déclarera toutes les fonctions disponibles dans notre bibliothèque pour être appelées par notre application. D'autre part, le fichier bme280.c implémentera les définitions de ces fonctions, ainsi que toutes les fonctions auxiliaires et privées que la bibliothèque peut contenir. Alors, que contient le fichier bme280_interface.h ? Eh bien, notre cible, quelle qu'elle soit, devra communiquer avec le composant BME280 d'une manière ou d'une autre. Dans ce cas, le BME280 prend en charge la communication SPI ou I2C. Dans les deux cas, la cible doit être capable de lire et d'écrire des octets dans le composant. Le fichier bme280_interface.h déclarera ces fonctions afin qu'elles puissent être appelées depuis la bibliothèque. La définition de ces fonctions sera la seule partie liée à la cible spécifique, et ce sera la seule chose que nous devrons modifier si nous migrons la bibliothèque vers une autre cible.
Nous commençons par déclarer les fonctions disponibles dans la bibliothèque au sein du fichier bme280.h.
#include "i2c.h" #include <stdint.h> int main(void) { uint8_t idx = 0U; uint8_t tx_buffer[64] = {0}; uint8_t rx_buffer[64] = {0}; uint16_t dig_temp1 = 0U; int16_t dig_temp2 = 0; int16_t dig_temp3 = 0; MX_I2C1_Init(); tx_buffer[idx++] = 0b10100011; HAL_I2C_Mem_Write(&hi2c1, 0x77U << 1U, 0xF4U, 1U, tx_buffer, 1U, 200U); HAL_I2C_Mem_Read(&hi2c1, 0x77U << 1U, 0x88U, 1U, rx_buffer, 6U, 200U); dig_temp1 = ((uint16_t)rx_buffer[0]) | (((uint16_t)rx_buffer[1]) << 8U); dig_temp2 = (int16_t)(((uint16_t)rx_buffer[2]) | (((uint16_t)rx_buffer[3]) << 8U)); dig_temp3 = (int16_t)(((uint16_t)rx_buffer[4]) | (((uint16_t)rx_buffer[5]) << 8U)); while (1) { float temperature = 0.0f; int32_t adc_temp = 0; int32_t t_fine = 0; float var1 = 0.0f; float var2 = 0.0f; HAL_I2C_Mem_Read(&hi2c1, 0x77U << 1U, 0xFAU, 1U, rx_buffer, 3U, 200U); adc_temp = (int32_t)((((uint32_t)rx_buffer[0]) << 12U) | (((uint32_t)rx_buffer[1]) << 4U) | (((uint32_t)rx_buffer[2]) >> 4U)); var1 = (((float)adc_temp) / 16384.0f - ((float)dig_temp1) / 1024.0f) * ((float)dig_temp2); var2 = ((((float)adc_temp) / 131072.0f - ((float)dig_temp1) / 8192.0f) * (((float)adc_temp) / 131072.0f - ((float)dig_temp1) / 8192.0f)) * ((float)dig_temp3); t_fine = (int32_t)(var1 + var2); temperature = ((float)t_fine) / 5129.0f; // Temperature available for the application. } }
La bibliothèque que nous créons sera très simple, et nous implémenterons uniquement une fonction d'initialisation de base et une autre pour obtenir une mesure de température. Maintenant, implémentons les fonctions dans le fichier bme280.c.
Pour éviter de rendre le message trop verbeux, je saute les commentaires qui documenteraient les fonctions. C'est le fichier où iraient ces commentaires. Avec autant d’outils d’IA disponibles aujourd’hui, il n’y a aucune excuse pour ne pas documenter votre code.
Le squelette du fichier bme280.c serait le suivant :
#ifndef BME280_H_ #define BME280_H_ void BME280_init(void); float BME280_get_temperature(void); #endif // BME280_H_
Concentrons-nous sur l’initialisation. Comme mentionné précédemment, le BME280 prend en charge les communications I2C et SPI. Dans les deux cas, nous devons initialiser le périphérique approprié de la cible (I2C ou SPI), puis nous devons pouvoir envoyer et recevoir des octets via eux. En supposant que nous utilisons la communication I2C, dans le STM32F401RE, ce serait :
#include "i2c.h" #include <stdint.h> int main(void) { uint8_t idx = 0U; uint8_t tx_buffer[64] = {0}; uint8_t rx_buffer[64] = {0}; uint16_t dig_temp1 = 0U; int16_t dig_temp2 = 0; int16_t dig_temp3 = 0; MX_I2C1_Init(); tx_buffer[idx++] = 0b10100011; HAL_I2C_Mem_Write(&hi2c1, 0x77U << 1U, 0xF4U, 1U, tx_buffer, 1U, 200U); HAL_I2C_Mem_Read(&hi2c1, 0x77U << 1U, 0x88U, 1U, rx_buffer, 6U, 200U); dig_temp1 = ((uint16_t)rx_buffer[0]) | (((uint16_t)rx_buffer[1]) << 8U); dig_temp2 = (int16_t)(((uint16_t)rx_buffer[2]) | (((uint16_t)rx_buffer[3]) << 8U)); dig_temp3 = (int16_t)(((uint16_t)rx_buffer[4]) | (((uint16_t)rx_buffer[5]) << 8U)); while (1) { float temperature = 0.0f; int32_t adc_temp = 0; int32_t t_fine = 0; float var1 = 0.0f; float var2 = 0.0f; HAL_I2C_Mem_Read(&hi2c1, 0x77U << 1U, 0xFAU, 1U, rx_buffer, 3U, 200U); adc_temp = (int32_t)((((uint32_t)rx_buffer[0]) << 12U) | (((uint32_t)rx_buffer[1]) << 4U) | (((uint32_t)rx_buffer[2]) >> 4U)); var1 = (((float)adc_temp) / 16384.0f - ((float)dig_temp1) / 1024.0f) * ((float)dig_temp2); var2 = ((((float)adc_temp) / 131072.0f - ((float)dig_temp1) / 8192.0f) * (((float)adc_temp) / 131072.0f - ((float)dig_temp1) / 8192.0f)) * ((float)dig_temp3); t_fine = (int32_t)(var1 + var2); temperature = ((float)t_fine) / 5129.0f; // Temperature available for the application. } }
Une fois le périphérique initialisé, il faut initialiser le composant. Ici, il faut utiliser les informations fournies par le fabricant dans sa fiche technique. Voici un bref résumé : nous devons démarrer le canal d'échantillonnage de température (qui est en mode veille par défaut) et lire certaines constantes d'étalonnage stockées dans la ROM du composant, dont nous aurons besoin plus tard pour calculer la température.
Le but de cet article n'est pas d'apprendre à utiliser le BME280, je vais donc sauter les détails de son utilisation, que vous pouvez retrouver dans sa fiche technique.
L'initialisation ressemblerait à ceci :
#ifndef BME280_H_ #define BME280_H_ void BME280_init(void); float BME280_get_temperature(void); #endif // BME280_H_
Détails à commenter. Les valeurs d'étalonnage que nous lisons sont stockées dans des variables appelées dig_temp1, dig_temp2 et dig_temp3. Ces variables sont déclarées comme globales afin qu'elles soient disponibles pour le reste des fonctions de la bibliothèque. Cependant, ils sont déclarés comme statiques afin qu'ils ne soient accessibles qu'au sein de la bibliothèque. Personne en dehors de la bibliothèque n'a besoin d'accéder ou de modifier ces valeurs.
On voit également que la valeur de retour des instructions I2C est vérifiée, et en cas d'échec, l'exécution de la fonction est arrêtée. C'est bien, mais cela peut être amélioré. Ne serait-il pas préférable d'informer l'appelant de la fonction BME280_init que quelque chose s'est mal passé, si tel était le cas ? Pour ce faire, nous définissons l'énumération suivante dans le fichier bme280.h.
J'utilise typedef pour eux. Il y a un débat sur l'utilisation de typedef car ils améliorent la lisibilité du code au prix de masquer les détails. C'est une question de préférence personnelle et de s'assurer que tous les membres de l'équipe de développement sont sur la même longueur d'onde.
void BME280_init(void) { } float BME280_get_temperature(void) { }
Deux remarques : j'ajoute généralement le suffixe _t aux typedefs pour indiquer qu'il s'agit de typedefs, et j'ajoute le préfixe typedef aux valeurs ou aux membres du typedef, dans ce cas BME280_Status_. Ce dernier consiste à éviter les collisions entre les énumérations de différentes bibliothèques. Si tout le monde utilisait OK comme énumération, nous aurions des problèmes.
Nous pouvons maintenant modifier à la fois la déclaration (bme280.h) et la définition (bme280.c) de la fonction BME280_init pour renvoyer un statut. La version finale de notre fonction serait :
void BME280_init(void) { MX_I2C1_Init(); }
#include "i2c.h" #include <stdint.h> #define BME280_TX_BUFFER_SIZE 32U #define BME280_RX_BUFFER_SIZE 32U #define BME280_TIMEOUT 200U #define BME280_ADDRESS 0x77U #define BME280_REG_CTRL_MEAS 0xF4U #define BME280_REG_DIG_T 0x88U static uint16_t dig_temp1 = 0U; static int16_t dig_temp2 = 0; static int16_t dig_temp3 = 0; void BME280_init(void) { uint8_t idx = 0U; uint8_t tx_buffer[BME280_TX_BUFFER_SIZE] = {0}; uint8_t rx_buffer[BME280_RX_BUFFER_SIZE] = {0}; HAL_StatusTypeDef status = HAL_ERROR; MX_I2C1_Init(); tx_buffer[idx++] = 0b10100011; status = HAL_I2C_Mem_Write( &hi2c1, BME280_ADDRESS << 1U, BME280_REG_CTRL_MEAS, 1U, tx_buffer, (uint16_t)idx, BME280_TIMEOUT); if (status != HAL_OK) return; status = HAL_I2C_Mem_Read( &hi2c1, BME280_ADDRESS << 1U, BME280_REG_DIG_T, 1U, rx_buffer, 6U, BME280_TIMEOUT); if (status != HAL_OK) return; dig_temp1 = ((uint16_t)rx_buffer[0]); dig_temp1 = dig_temp1 | (((uint16_t)rx_buffer[1]) << 8U); dig_temp2 = ((int16_t)rx_buffer[2]); dig_temp2 = dig_temp2 | (((int16_t)rx_buffer[3]) << 8U); dig_temp3 = ((int16_t)rx_buffer[4]); dig_temp3 = dig_temp3 | (((int16_t)rx_buffer[5]) << 8U); return; }
Puisque nous utilisons l'énumération de statut, nous devons inclure le fichier bme280.h dans le fichier bme280.c. Nous avons déjà initialisé la bibliothèque. Maintenant, créons la fonction pour récupérer la température. Cela ressemblerait à ceci :
typedef enum { BME280_Status_Ok, BME280_Status_Status_Err, } BME280_Status_t;
Vous l’avez remarqué, n’est-ce pas ? Nous avons modifié la signature de la fonction afin qu'elle renvoie un statut pour indiquer s'il y a eu des problèmes de communication avec le composant ou non, et le résultat est renvoyé via le pointeur passé en paramètre à la fonction. Si vous suivez l'exemple, pensez à modifier la déclaration de fonction dans le fichier bme280.h pour qu'elles correspondent.
BME280_Status_t BME280_init(void);
Super ! A ce stade, dans l'application nous pouvons avoir :
#include "i2c.h" #include <stdint.h> int main(void) { uint8_t idx = 0U; uint8_t tx_buffer[64] = {0}; uint8_t rx_buffer[64] = {0}; uint16_t dig_temp1 = 0U; int16_t dig_temp2 = 0; int16_t dig_temp3 = 0; MX_I2C1_Init(); tx_buffer[idx++] = 0b10100011; HAL_I2C_Mem_Write(&hi2c1, 0x77U << 1U, 0xF4U, 1U, tx_buffer, 1U, 200U); HAL_I2C_Mem_Read(&hi2c1, 0x77U << 1U, 0x88U, 1U, rx_buffer, 6U, 200U); dig_temp1 = ((uint16_t)rx_buffer[0]) | (((uint16_t)rx_buffer[1]) << 8U); dig_temp2 = (int16_t)(((uint16_t)rx_buffer[2]) | (((uint16_t)rx_buffer[3]) << 8U)); dig_temp3 = (int16_t)(((uint16_t)rx_buffer[4]) | (((uint16_t)rx_buffer[5]) << 8U)); while (1) { float temperature = 0.0f; int32_t adc_temp = 0; int32_t t_fine = 0; float var1 = 0.0f; float var2 = 0.0f; HAL_I2C_Mem_Read(&hi2c1, 0x77U << 1U, 0xFAU, 1U, rx_buffer, 3U, 200U); adc_temp = (int32_t)((((uint32_t)rx_buffer[0]) << 12U) | (((uint32_t)rx_buffer[1]) << 4U) | (((uint32_t)rx_buffer[2]) >> 4U)); var1 = (((float)adc_temp) / 16384.0f - ((float)dig_temp1) / 1024.0f) * ((float)dig_temp2); var2 = ((((float)adc_temp) / 131072.0f - ((float)dig_temp1) / 8192.0f) * (((float)adc_temp) / 131072.0f - ((float)dig_temp1) / 8192.0f)) * ((float)dig_temp3); t_fine = (int32_t)(var1 + var2); temperature = ((float)t_fine) / 5129.0f; // Temperature available for the application. } }
Super propre ! C'est lisible. Ignorez l'utilisation de la fonction Error_Handler de STM32CubeMX/IDE. Il n’est généralement pas recommandé de l’utiliser, mais pour l’exemple, cela fonctionne pour nous. Alors, c'est fait ?
Eh bien, non ! Nous avons encapsulé nos interactions avec le composant dans ses propres fichiers. Mais son code appelle toujours des fonctions cibles (fonctions HAL) ! Si on change la cible, il faudra réécrire la bibliothèque ! Indice : nous n'avons encore rien écrit dans le fichier bme280_interface.h. Abordons cela maintenant.
Si l'on regarde le fichier bme280.c, nos interactions avec la cible sont triples : initialiser les périphériques, écrire/envoyer des octets et lire/recevoir des octets. Nous allons donc déclarer ces trois interactions dans le fichier bme280_interface.h.
#ifndef BME280_H_ #define BME280_H_ void BME280_init(void); float BME280_get_temperature(void); #endif // BME280_H_
Si vous remarquez, nous avons également défini un nouveau type pour le statut de l'interface. Désormais, au lieu d'appeler directement les fonctions cibles, nous appellerons ces fonctions depuis le fichier bme280.c.
void BME280_init(void) { } float BME280_get_temperature(void) { }
Et voilà ! Les dépendances cibles ont disparu de la bibliothèque. Nous avons maintenant une bibliothèque qui fonctionne pour STM32, MSP430, PIC32, etc. Dans les trois fichiers de bibliothèque, rien de spécifique à une cible ne doit apparaître. Quelle est la seule chose qui reste ? Eh bien, définir les fonctions de l'interface. C'est la seule partie qui doit être migrée/adaptée pour chaque cible.
Je le fais habituellement dans le dossier Application/bsp/components/.
Nous créons un fichier appelé bme280_implementation.c avec le contenu suivant :
void BME280_init(void) { MX_I2C1_Init(); }
De cette façon, si l'on souhaite utiliser la bibliothèque dans un autre projet ou sur une autre cible, il suffit d'adapter le fichier bme280_implementation.c. Le reste reste exactement le même.
Avec cela, nous avons vu un exemple basique de bibliothèque. Cette implémentation est la plus simple, la plus sûre et la plus courante. Il existe cependant différentes variantes selon les caractéristiques de notre projet. Dans cet exemple, nous avons vu comment effectuer une sélection de l'implémentation au moment de la liaison. Autrement dit, nous avons le fichier bme280_implementation.c, qui fournit les définitions des fonctions d'interface pendant le processus de compilation/liaison. Que se passerait-il si nous voulions avoir deux implémentations ? Un pour la communication I2C et un autre pour la communication SPI. Dans ce cas, nous aurions besoin de spécifier les implémentations au moment de l'exécution à l'aide de pointeurs de fonction.
Un autre aspect est que dans cet exemple, nous supposons qu'il n'y a qu'un seul BME280 dans le système. Que se passerait-il si nous en avions plusieurs ? Devons-nous copier/coller du code et ajouter des préfixes aux fonctions comme BME280_1 et BME280_2 ? Non, ce n’est pas idéal. Ce que nous ferions, c'est utiliser des gestionnaires pour nous permettre d'opérer avec la même bibliothèque sur différentes instances d'un composant.
Ces aspects et comment tester notre bibliothèque avant même d'avoir notre matériel disponible sont un sujet pour un autre article, que nous aborderons dans les prochains articles. Pour l’instant, nous n’avons aucune excuse pour ne pas implémenter correctement les bibliothèques. Cependant, ma première recommandation (et paradoxalement celle que je laisse pour la fin) est avant tout de s’assurer que le constructeur ne fournit pas déjà une bibliothèque officielle pour son composant. C’est le moyen le plus rapide de mettre en place une bibliothèque. Soyez assuré que la bibliothèque fournie par le constructeur suivra probablement une implémentation similaire à celle que nous avons vue aujourd'hui, et notre travail sera d'adapter la partie implémentation de l'interface à notre cible ou produit.
Si ce sujet vous intéresse, vous pouvez retrouver cet article et d'autres liés au développement de systèmes embarqués sur mon blog ! ?
Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!