Suivez notre compte WeChat "IA Embarquée sur le Bord" pour découvrir les dernières technologies et applications d'IA embarquée
J'ai récemment examiné un code open source sur GitHub où le fichier main.c contenait à la fois des opérations sur les registres GPIO, des calculs de vérification CRC, l'empaquetage de messages MQTT et des décisions logiques métier. J'ai été désorienté - c'est comme si vous mélangiez dans un même plat l'entrée, le plat principal, le dessert et les boissons.
C'est un exemple clair de "ne pas savoir exactement où placer chaque type de code".
Ce problème est très courant. Tout le monde a entendu parler de "couches" d'architecture, mais les frontières entre couches, la définition des interfaces et la gestion des appels inter-couches posent de vrais problèmes en pratique. Aujourd'hui, nous allons éclaircir ce sujet.
Pourquoi une architecture en couches
Une phrase : Lorsque vous modifiez un élément, vous ne devez modifier qu'un seul endroit.
Changement de puce ? Modifiez uniquement la couche HAL. Changement de modèle de capteur ? Modifiez uniquement la couche BSP. Changement de protocole de communication ? Modifiez uniquement la couche Service. Ajustement de la logique métier ? Modifiez uniquement la couche App.
Un changement de puce ne se produit jamais ? Vous n'avez probablement pas encore travaillé dans l'industrie.
Sans architecture en couches, le changement d'un seul capteur pourrait vous obliger à modifier du code du pilote jusqu'à la logique métier, suivi d'une régression complète de tous les tests.
Vue d'ensemble de l'architecture en quatre couches
| Couche | Nom complet | Responsabilités | Contenu typique |
|---|---|---|---|
| HAL | Hardware Abstraction Layer | Masquer les différences entre puces | Lecture/écriture GPIO, transmission SPI/I2C, activation/désactivation des timers, échantillonnage ADC |
| BSP | Board Support Package | Masquer les différences de carte | "Le capteur de température est sur CS2 de SPI1", "La LED s'allume avec un niveau bas sur PA5" |
| Service | Couche de services génériques | Fonctionnalités réutilisables entre projets | Piles de protocoles, journaux, gestion de configuration, OTA, moteur AT |
| App | Couche application | Logique métier spécifique au produit | "Quand la température dépasse 85°C, éteindre le moteur et alerter" |
Principes fondamentaux
La couche supérieure peut appeler la couche inférieure, mais l'inverse est interdit. Si une couche inférieure doit notifier une couche supérieure, utilisez des fonctions de rappel ou des événements.
Les appels inter-couches directs sont interdits. La couche App ne peut pas appeler directement la couche HAL. Si l'application doit lire une GPIO, elle doit utiliser l'interface sémantique fournie par la couche BSP (par exemple bouton_est_appuye()), plutôt qu'un appel direct à lire_gpio(PA3).
Aspect détaillé de chaque couche
Important : Ces noms sont simplement des distinctions, vous pouvez leur donner des noms différents tant que vous pouvez les différencier clairement.
Couche HAL : Se soucier uniquement de "comment contrôler les périphériques de cette puce"
// piloter_spi.h — Ne sait pas quel appareil est connecté
void piloter_spi_initialiser(uint8_t id_spi, uint32_t vitesse);
uint8_t piloter_spi_transférer(uint8_t id_spi, uint8_t donnee);
void piloter_spi_cs_selectionner(uint8_t id_spi, uint8_t broche_cs);
void piloter_spi_cs_deselectionner(uint8_t id_spi, uint8_t broche_cs);
La couche HAL ne sait pas si un capteur de température ou une puce Flash est connecté au SPI. Elle sait seulement "envoyer un octet à SPI1, recevoir un octet de SPI1".
Lors du changement de puce, seule l'implémentation de la couche HAL est réécrite. L'interface reste inchangée, la couche BSP n'est pas affectée.
Couche BSP : Se soucier uniquement de "quels composants se trouvent sur cette carte et comment ils sont connectés"
// carte_capteur_temp.h — Connaît le capteur LM75 sur SPI1 CS2
void carte_capteur_temp_initialiser(void);
float carte_capteur_temp_lire(void); // Retourne en degrés Celsius
L'implémentation de la couche BSP appellera la couche HAL :
// carte_capteur_temp.c
#include "piloter_spi.h"
#define CAPTEUR_SPI 1
#define CAPTEUR_CS BROCHE_B4
void carte_capteur_temp_initialiser(void) {
piloter_spi_initialiser(CAPTEUR_SPI, 1000000);
}
float carte_capteur_temp_lire(void) {
piloter_spi_cs_selectionner(CAPTEUR_SPI, CAPTEUR_CS);
uint8_t msb = piloter_spi_transférer(CAPTEUR_SPI, 0x00);
uint8_t lsb = piloter_spi_transférer(CAPTEUR_SPI, 0x00);
piloter_spi_cs_deselectionner(CAPTEUR_SPI, CAPTEUR_CS);
int16_t brut = (msb << 8) | lsb;
return brut * 0.0625f; // Résolution du LM75
}
Changement de modèle de capteur ? Réécrivez carte_capteur_temp.c, l'interface reste inchangée, la couche Service n'est pas affectée.
Couche Service : Fonctionnalités réutilisables entre projets
// service_surveillance_temp.h — Ne connaît pas le capteur utilisé
typedef struct {
float (*lire_temperature)(void); // Injection de dépendance !
void (*surchauffe_detectee)(float temp);
float seuil;
} configuration_surveillance_temp_t;
void surveillance_temp_initialiser(const configuration_surveillance_temp_t *cfg);
void surveillance_temp_traitement(void);
La couche Service ne sait pas si le capteur est un LM75 ou un DS18B20, ni si l'alerte est un buzzer ou un SMS. Tout cela est injecté via la configuration.
Couche App : Se soucier uniquement de la logique du produit
// app_principale.c
#include "carte_capteur_temp.h"
#include "carte_alarme.h"
#include "service_surveillance_temp.h"
static void gerer_surchauffe(float temp) {
carte_alarme_activer();
service_journaliser("SURCHAUFFE: %.1f", temp);
service_mqtt_publier("alerte/temp", temp);
}
void app_initialiser(void) {
carte_capteur_temp_initialiser();
surveillance_temp_initialiser(&(configuration_surveillance_temp_t){
.lire_temperature = carte_capteur_temp_lire,
.surchauffe_detectee = gerer_surchauffe,
.seuil = 85.0f,
});
}
void app_traitement(void) {
surveillance_temp_traitement();
}
Matrice des relations d'appel inter-couches
| Direction d'appel | HAL | BSP | Service | App |
|---|---|---|---|---|
| HAL appelle | – | Interdit | Interdit | Interdit |
| BSP appelle | Autorisé | – | Interdit | Interdit |
| Service appelle | Interdit | Par injection | Autorisé | Interdit |
| App appelle | Interdit | Autorisé | Autorisé | – |
La couche Service n'appelle pas directement BSP, mais obtient des capacités par injection de dépendance. Ainsi, la couche Service peut être réutilisée entre projets - les BSP diffèrent d'un projet à l'autre, mais la couche Service reste identique.
Structure de répertoires
projet/
hal/
piloter_gpio.h / piloter_gpio_stm32f4.c
piloter_spi.h / piloter_spi_stm32f4.c
piloter_uart.h / piloter_uart_stm32f4.c
bsp/
carte_capteur_temp.h / carte_capteur_temp.c
carte_led.h / carte_led.c
carte_alarme.h / carte_alarme.c
service/
service_surveillance_temp.h / service_surveillance_temp.c
service_journal.h / service_journal.c
service_mqtt.h / service_mqtt.c
app/
app_principale.c
app_configuration.h
main.c ← Ne fait que l'initialisation et la boucle principale
Lors du changement de puce, seul les fichiers .c dans le répertoire hal/ sont remplacés. Changement de carte ? Seul le répertoire bsp/ est modifié. Changement de projet ? Le répertoire service/ est réutilisé tel quel.
Les trois erreurs les plus courantes
Erreur 1 : Couche BSP trop épaisse
Certains écrivent la logique métier dans la couche BSP. Par exemple, carte_capteur_temp_lire() contient une vérification de dépassement de seuil et une alarme - c'est le rôle de la couche App.
Critère : Les fonctions de la couche BSP ne font que "lire/écrire/initialiser", pas "juger/décider/activer".
Erreur 2 : Couche Service dépendant de BSP
La couche Service contient #include "carte_xxx.h" - cela signifie que ce Service ne compilera pas dans un autre projet.
La couche Service ne peut obtenir des capacités externes que via des pointeurs de fonction, pas en incluant directement des fichiers d'en-tête BSP.
Erreur 3 : Couche App contournant BSP pour appeler HAL directement
"Je veux juste lire une GPIO, inutile d'écrire une fonction BSP spécifique" - possible. Mais lorsque votre GPIO passe de PA3 à PB7, vous devrez rechercher tous les endroits dans la couche App où PA3 est utilisé.
Même la manipulation matérielle la plus simple mérite d'être encapsulée dans la couche BSP avec un nom sémantique. bouton_est_appuye() est mille fois meilleur que lire_gpio(PA3), car le premier vous indique "pourquoi on lit cette GPIO".
L'architecture en couches n'est pas une dogme
Enfin, un point souvent négligé : l'architecture en couches est un moyen, pas une fin.
Pour un petit projet de 500 lignes de code, forcer une séparation en quatre couches est plutôt un fardeau. L'important est de maîtriser la pensée en couches - en écrivant chaque ligne de code, demandez-vous : "cette ligne est-elle liée au matériel ? à la conception de la carte ? au produit spécifique ?" La réponse détermine à quelle couche elle appartient.
Même sans établir strictement une structure de quatre répertoires, tant que les relations de dépendance dans votre code sont "vers le haut pour les couches inférieures, sans saut de couche", votre code est déjà plus propre que la plupart des projets embarqués.
L'essence de l'architecture en couches se résume en une phrase : minimiser l'impact de chaque modification. La puce change ? Modifiez HAL. La carte change ? Modifiez BSP. Le besoin change ? Modifiez App. Il n'y a pas de besoins immuables, mais vous pouvez décider du coût de chaque changement.