Abonnement statique à l'aide du modèle Observer à l'aide de C ++ et du microcontrôleur Cortex M4


Bonne santé à tous!


À la veille de la nouvelle année, je veux continuer à parler de l'utilisation de C ++ sur les microcontrôleurs, cette fois, je vais essayer de parler de l'utilisation du modèle Observer (mais ci-après je l'appellerai Publisher-Subscriber ou juste un abonné, un jeu de mots), ainsi que la mise en œuvre d'un abonnement statique à C ++ 17 et les avantages de cette approche dans certaines applications.


Présentation


Template Subscriber est l'un des modèles les plus courants utilisés dans le développement de logiciels. Avec lui, par exemple, ils font le traitement des clics sur les boutons dans Windows Form. Quoi qu'il en soit, à n'importe quel endroit où vous devez en quelque sorte répondre aux changements des paramètres du système, que ce soit des changements dans les fichiers ou la mise à jour de la valeur mesurée du capteur, il est temps sans réfléchir utilisez le modèle Abonné.


L'avantage du modèle est que nous libérons la connaissance de l'éditeur et de l'abonné sans être lié à des objets spécifiques. Nous pouvons signer n'importe qui à n'importe qui, sans affecter la mise en œuvre des objets Publisher et Subscriber.


Conditions initiales


Avant de nous familiariser avec le modèle, convenons d'abord que nous voulons développer un logiciel fiable dans lequel:


  • n'utilisez pas d'allocation de mémoire dynamique
  • minimiser le travail avec des pointeurs
  • nous utilisons autant de constantes que possible pour que personne ne change autant que possible
  • mais en même temps, nous utilisons aussi peu de constantes que possible situées dans la RAM

Voyons maintenant l'implémentation standard du modèle Subscriber.


Implémentation standard


Supposons que nous ayons un bouton, et lorsque vous cliquez sur le bouton, nous devons clignoter les LED, mais combien d'entre elles seront inconnues jusqu'à présent, et en effet, vous devrez peut-être clignoter non pas avec des LED, mais avec un projecteur sur le navire pour transmettre des messages en code Morse. Il est important que nous ne sachions pas qui sera abonné. Malheureusement, je n'ai pas de projecteur sous la main, donc tous les exemples de l'article dans un souci de simplicité et une meilleure compréhension sont faits avec des LED.


Ainsi, lorsque vous appuyez sur le bouton, vous devez informer la LED de cette pression. À son tour, après avoir appris à appuyer sur la LED devrait passer à l'état opposé.
L'implémentation standard en UML est la suivante ...



Ici, la classe ButtonController est chargée d'interroger le bouton et d'informer les abonnés du clic, et Led dans ce cas est l'abonné. Ces deux classes sont découplées via les ISubsriber IPublisher et ISubsriber et aucune des classes ne connaît l'autre. Ainsi, tout objet héritant de l'interface ISubscriber peut s'abonner à un événement de ButtonController .


Étant donné que l'allocation dynamique de mémoire est interdite, j'ai déclaré un tableau de 3 éléments pour l'abonnement. C'est-à-dire maximum peut être de 3 abonnés. Ainsi, dans une première approximation, la méthode de notification des abonnés de la classe ButttonsController peut sembler


 struct ButtonController : IPublisher { void Run() { for(;;) { if (UserButton::IsPressed()) { Notify() ; } } } void Notify() const override { //          HandleEvent() for(auto it: pSubscribers) { if (it != nullptr) { it->HandleEvent() ; } } } } ; 

Tout le sel se trouve dans la méthode Notify() de la classe Publisher . Dans cette méthode, nous passons en revue la liste des abonnés et appelons HandleEvent() sur chacun d'entre eux, et c'est cool, car chaque abonné implémente cette méthode à sa manière et peut y faire tout tout ce que votre cœur désire (en fait, vous devez être prudent, sinon le diable sait ce que l'abonné y fait, vous pouvez appeler sa méthode, par exemple, à partir d'une interruption et vous devez être vigilant pour empêcher les abonnés de faire des choses longues et mauvaises)


Dans notre cas, la LED est autorisée à faire n'importe quoi, elle fait donc la commutation de son état:


 template <typename Port, std::uint32_t pinNum> struct Led: ISubscriber { static void Toggle() { Port::ODR::Toggle(1 << pinNum); } void HandleEvent() override { //  ,    ,  Toggle() ; } }; 

Implémentation complète de toutes les classes
 template<typename Port, std::size_t pinNum> struct Button { static bool IsPressed() { bool result = false; if ((Port::IDR::Read() & (1 << pinNum)) == 0) //   { while ((Port::IDR::Read() & (1 << pinNum)) == 0) //     { }; result = true; } return result; } } ; //     GPIOC.13 using UserButton = Button<GPIOC, 13> ; struct ISubscriber { virtual void HandleEvent() = 0; } ; struct IPublisher { virtual void Notify() const = 0; virtual void Subscribe(ISubscriber* subscriber) = 0; } ; template <typename Port, std::uint32_t pinNum> struct Led: ISubscriber { static void Toggle() { Port::ODR::Toggle(1 << pinNum); } void HandleEvent() override { Toggle() ; } }; struct ButtonController : IPublisher { void Run() { for(; ;) { if (UserButton::IsPressed()) { Notify() ; } } } void Notify() const override { for(auto it: pSubscribers) { if (it != nullptr) { it->HandleEvent() ; } } } void Subscribe(ISubscriber* subscriber) override { if (index < pSubscribers.size()) { pSubscribers[index] = subscriber ; index ++ ; } //   3   ...   } private: std::array<ISubscriber*, 3> pSubscribers ; std::size_t index = 0U ; } ; 

Comment un abonnement peut-il apparaître dans le code? Et donc:


 int main() { //  Led1    5  GPIOC static Led<GPIOC,5> Led1 ; //  Led2    8  GPIOC static Led<GPIOC,8> Led2 ; //  Led3    9  GPIOC static Led<GPIOC,9> Led3 ; ButtonController buttonController ; //  3  buttonController.Subscribe(&Led1) ; buttonController.Subscribe(&Led2) ; buttonController.Subscribe(&Led3) ; //       buttonController.Run() ; } 

La bonne nouvelle est que nous pouvons signer n'importe quel objet et le moment de sa création n'a pas d'importance pour nous. Il peut s'agir d'un objet global, statique ou local. D'une part, c'est bien, mais d'autre part, pourquoi devons-nous nous abonner à l'exécution dans ce code. En effet, en effet, ici l'adresse des objets Led1 , Led2 , Led3 est connue au stade de la compilation. Alors, pourquoi ne pouvez-vous pas vous abonner au stade de la compilation et conserver un ensemble de pointeurs vers les abonnés en ROM?


De plus, il existe un risque d'erreurs potentielles, par exemple, combien se sont demandé ce qui se passerait lors de l'appel de la méthode Subsribe() si elle était appelée à partir de plusieurs threads? Nous sommes limités à seulement 3 abonnés, et que se passe-t-il si nous signons 4 LED?


Dans la plupart des cas, nous avons besoin de cet abonnement une fois dans la vie lors de l'initialisation, nous enregistrons simplement des pointeurs vers les abonnés et c'est tout. Le pointeur conservera à vie l'adresse de ces abonnés. Et le jour est inévitable quand il peut être ruiné en raison de l'épidémie de supernova (bien sûr, si l'on considère une période de temps assez longue). Mais dans tous les cas, la probabilité de défaillance de la RAM est beaucoup plus élevée que la ROM et il n'est pas recommandé de stocker des données permanentes dans la RAM.


Eh bien, la mauvaise nouvelle est qu'une telle solution architecturale prend beaucoup d'espace dans la ROM et la RAM. Au cas où, nous écrivons combien de ROM et de RAM cette solution prend:


Modulecode roro datadonnées rw
main.o4886421

C'est-à-dire au total 552 octets en ROM et 21 octets en RAM - disons pas tellement pour appuyer sur un bouton et faire clignoter trois LED.


Eh bien, afin de vous protéger de tels problèmes et de réduire la consommation de ressources du contrôleur, considérons l'option avec un abonnement statique.


Abonnement statique


Afin de rendre l'abonnement statique, vous pouvez utiliser plusieurs approches. Je vais les nommer comme ceci:


  • La méthode traditionnelle est la même approche, mais en utilisant le constructeur constexpr et en définissant la liste des abonnés à travers elle.
  • Non conventionnel Utilisation de modèles - transférez la liste des abonnés via les paramètres du modèle. (ici, un modèle est une définition du domaine de la métaprogrammation, pas des modèles de conception)

L'approche traditionnelle de l'abonnement statique


Essayons de nous abonner au stade de la compilation. Pour ce faire, nous peaufinons un peu notre architecture:



L'image n'est pas très différente de l'original, mais il existe plusieurs différences: la méthode Subscribe() a été supprimée et maintenant l'abonnement sera effectué directement dans le constructeur. Le constructeur doit accepter un nombre variable d'arguments, et pour pouvoir signer statiquement au stade de la compilation, ce sera constexpr . Un tableau d'abonnés y sera initialisé et cette initialisation peut se faire au moment de la compilation:


 struct ButtonController : IPublisher { template<typename... Args> constexpr ButtonController(Args const*... args): pSubscribers() { std::initializer_list<ISubscriber const*> result = {args...} ; std::size_t index = 0U; for(auto it: result) { if (index < size) { pSubscribers[index] = const_cast<ISubscriber*>(it); } index ++ ; } } private: static constexpr std::size_t size = 3U; ISubscriber* pSubscribers[size] ; } ; 

Code complet pour une telle implémentation
 struct ISubscriber { virtual void HandleEvent() const = 0; } ; struct IPublisher { virtual void Notify() const = 0; } ; template<typename Port, std::size_t pinNum> struct Button { static bool IsPressed() { bool result = false; if ((Port::IDR::Read() & (1 << pinNum)) == 0) //   { while ((Port::IDR::Read() & (1 << pinNum)) == 0) //     { }; result = true; } return result; } } ; template <typename Port, std::uint32_t pinNum> struct Led: ISubscriber { constexpr Led() { } static void Toggle() { Port::ODR::Toggle(1<<pinNum); } void HandleEvent() const override { Toggle() ; } }; //     GPIOC.13 using UserButton = Button<GPIOC, 13> ; struct ButtonController : IPublisher { template<typename... Args> constexpr ButtonController(Args const*... args): pSubscribers() { std::initializer_list<ISubscriber const*> result = {args...} ; std::size_t index = 0U; for(auto it: result) { if (index < size) { pSubscribers[index] = const_cast<ISubscriber*>(it); } index ++ ; } } void Run() const { for(; ;) { if (UserButton::IsPressed()) { Notify() ; } } } void Notify() const override { for(auto it: pSubscribers) { if (it != nullptr) { it->HandleEvent() ; } } } private: static constexpr std::size_t size = 3U; ISubscriber* pSubscribers[size] ; } ; 

Désormais, l'abonnement peut être effectué au moment de la compilation:


 int main() { //  Led1    5  GPIOC static constexpr Led<GPIOC,5> Led1 ; //  Led2    8  GPIOC static constexpr Led<GPIOC,8> Led2 ; //  Led3    9  GPIOC static constexpr Led<GPIOC,9> Led3 ; static constexpr ButtonController buttonController(&Led1, &Led2, &Led3) ; buttonController.Run() ; return 0 ; } ; 

Ici, l'objet buttonController complètement situé dans la ROM avec un tableau de pointeurs vers les abonnés:


main :: buttonController 0x800'1f04 0x10 Données main.o [1]

Tout ne semble rien, sauf que nous sommes à nouveau limités à seulement 3 abonnés. Et la classe d'éditeur doit avoir un constructeur constexpr et en général être complètement constante afin de garantir un pointeur vers les abonnés dans la ROM, sinon, même avec des adresses d'abonnés connues, notre objet, avec tout le contenu, ira à nouveau en RAM.


Parmi les autres inconvénients - puisque les fonctions virtuelles sont toujours utilisées, les tables de fonctions virtuelles peu à peu notre ROM. Et la ressource est, bien que abordable, mais pas infinie. Dans la plupart des applications, il est possible de marteler dessus et de prendre un microcontrôleur plus grand, mais il arrive souvent que chaque octet compte, en particulier lorsqu'il s'agit de produits fabriqués par centaines de milliers, tels que des capteurs physiques physiques.


Voyons comment les choses se passent avec la mémoire dans cette solution:


Modulecode roro datadonnées rw
main.o172760

Et bien que le résultat soit «étonnant»: la consommation totale de RAM est de 0 octet, et la ROM est de 248 octets, ce qui est deux fois moins que dans la première solution, il estime qu'il existe encore un potentiel d'amélioration. Sur ces 248 octets, environ 50 occupent simplement les tables de méthodes virtuelles.


Une petite digression:
Une étape dans la taille de la ROM de 256 ko pour les microcontrôleurs modernes est la norme (par exemple, le microcontrôleur TI Cortex M4 a 256 ko de ROM, et la prochaine version est déjà 512 ko). Et ce ne sera pas très bien quand, en raison de 50 octets supplémentaires, nous devons prendre un contrôleur avec une ROM de 256 Ko plus grande et plus chère, par conséquent, l'abandon des fonctions virtuelles peut économiser ... jusqu'à 50 cents (la différence entre le microcontrôleur dans la ROM de 256 et 512 Ko est d'environ 50-60 cents).


Cela semble ridicule pour 1 microcontrôleur, mais sur un lot de 400 000 appareils par an, vous pouvez économiser 200 000 $. Déjà pas si drôle, mais vu quel genre de rat. ils peuvent récompenser l'offre avec un diplôme et une carte-cadeau pour 3000 roubles, il n'y a absolument aucun doute sur la justesse de refuser les fonctions virtuelles et d'économiser 50 octets supplémentaires en ROM.


Approche non conventionnelle


Voyons comment vous pouvez faire de même sans fonctions virtuelles et économisez encore plus de ROM.


Voyons d'abord comment cela pourrait être:


 int main() { //  Led1    5  GPIOC static Led<GPIOC,5> Led1 ; //  Led2    8  GPIOC static Led<GPIOC,8> Led2 ; //  Led3    9  GPIOC static Led<GPIOC,9> Led3 ; //   ButtonController<Led1, Led2, Led3> buttonController ; buttonController.Run() ; return 0 ; } 

Notre tâche consiste à découpler les deux objets Publisher ( ButtonController ) et Subscriber ( Led ) l'un de l'autre afin qu'ils ne se connaissent pas, mais en même temps ButtonController pourrait avertir Led .


Vous pouvez déclarer la classe ButtonController une manière ou d'une autre.


 template <Led<GPIOC,5>& subscriber1, Led<GPIOC,8>& subscriber2, Led<GPIOC,9>& subscriber3> struct ButtonController { void Run() const { for(; ;) { if (UserButton::IsPressed()) { Notify() ; } } } void Notify() const { subscriber1.HandleEvent() ; subscriber2.HandleEvent() ; subscriber3.HandleEvent() ; } ... } ; 

Mais vous comprenez, ici nous sommes attachés à des types spécifiques et nous devrons refaire la définition de la classe BbuttonController chaque fois dans un nouveau projet. Et je voudrais simplement prendre et utiliser ButtonController dans le nouveau projet sans ButtonController .


C ++ 17 vient à la rescousse, où vous ne pouvez pas spécifier le type, mais demandez au compilateur de déduire le type pour vous - c'est exactement ce dont vous avez besoin. On peut, tout comme dans l'approche traditionnelle, libérer la connaissance de l'Editeur et de l'Abonné, alors que le nombre d'abonnés est pratiquement illimité.


 template <auto& ... subscribers> struct ButtonController { void Run() const { for(; ;) { if (UserButton::IsPressed()) { Notify() ; } } } void Notify() const { pass((subscribers.HandleEvent() , true)...) ; } ... } ; 

Fonctionnement de la fonction pass (..)

La méthode Notify() a un appel à la fonction pass() ; elle est utilisée pour développer les paramètres du modèle avec un nombre variable d'arguments


  void Notify() const { pass((subscribers.HandleEvent() , true)...) ; } 

L'implémentation de la fonction pass() est tout simplement inimaginable, c'est juste une fonction qui prend un nombre variable d'arguments:


 template<typename... Args> void pass(Args...) const { } } ; 

Comment la fonction HandleEvent() développe-t-elle en plusieurs appels pour chacun des abonnés?


Puisque la fonction pass() accepte plusieurs arguments de n'importe quel type, vous pouvez lui passer plusieurs arguments de type bool , par exemple, vous pouvez appeler la fonction pass(true, true, true) . Dans ce cas, bien sûr, rien ne se passera, mais nous n'en avons pas besoin.


La ligne (subscribers.HandleEvent() , true) utilise l'opérateur "," (virgule), qui exécute les deux opérandes (de gauche à droite) et renvoie la valeur du deuxième opérateur, c'est-à-dire ici subscribers.HandleEvent() sera exécuté en premier, puis true à la fonction pass() sera défini sur true .


Eh bien, "..." est une entrée standard pour développer un nombre variable d'arguments. Pour notre cas, les actions du compilateur peuvent être décrites très schématiquement comme suit:


 pass((subscribers.HandleEvent() , true)...) ; -> pass((Led1.HandleEvent() , true), (Led2.HandleEvent() , true), (Led3.HandleEvent() , true)) ; -> Led1.HandleEvent() ; -> pass(true, (Led2.HandleEvent() , true), (Led3.HandleEvent() , true)) ; -> Led2.HandleEvent() ; -> pass(true, true, (Led3.HandleEvent() , true)) ; -> Led3.HandleEvent() ; -> pass(true, true, true) ; 

Au lieu de liens, vous pouvez utiliser des pointeurs:


 template <auto* ... subscribers> struct ButtonController { ... } ; 

Addition: En fait, merci à vamireh qui a souligné que toutes ces danses sont avec tambourin pass fonction pass en C ++ 17 n'est pas nécessaire. Étant donné que l'opérateur "," la virgule est prise en charge dans l'expression fold (qui a été introduite dans la norme C ++ 17), le code est encore simplifié:


 template <auto& ... subscribers> struct ButtonController { void Run() const { for(; ;) { if (UserButton::IsPressed()) { Notify() ; } } } void Notify() const { ((subscribers.HandleEvent()), ...) ; } } ; 

Sur le plan architectural, cela semble très simple en général:



J'ai ajouté une autre classe LCD ici, mais purement par exemple, pour montrer que maintenant peu importe le type et le nombre d'abonnés, l'essentiel est qu'il implémente la méthode HandleEvent() .


Et tout le code en général est également plus facile maintenant:


 template<typename Port, std::size_t pinNum> struct Button { static bool IsPressed() { bool result = false; if ((Port::IDR::Read() & (1 << pinNum)) == 0) //   { while ((Port::IDR::Read() & (1 << pinNum)) == 0) //     { }; result = true; } return result; } } ; //     GPIOC.13 using UserButton = Button<GPIOC, 13> ; template <typename Port, std::uint32_t pinNum> struct Led { static void Toggle() { Port::ODR::Toggle(1<<pinNum); } void HandleEvent() const { Toggle() ; } }; template <auto& ... subscribers> struct ButtonController { void Run() const { for(; ;) { if (UserButton::IsPressed()) { Notify() ; } } } void Notify() const { ((subscribers.HandleEvent()), ...) ; } } ; int main() { //  Led1    5  GPIOC static constexpr Led<GPIOC,5> Led1 ; //  Led2    8  GPIOC static constexpr Led<GPIOC,8> Led2 ; //  Led3    9  GPIOC static constexpr Led<GPIOC,9> Led3 ; static constexpr ButtonController<Led1, Led2, Led3> buttonController ; buttonController.Run() ; return 0 ; } 

L'appel Notify() de la méthode Run() dégénère en un simple appel séquentiel


 Led1.HandleEvent() ; Led2.HandleEvent() ; Led3.HandleEvent() ; 

Et la mémoire ici?


Modulecode roro datadonnées rw
main.o18640

ROM total 190 octets et 0 octets de RAM. Maintenant la commande, elle est presque 3 fois plus petite que la version standard, alors qu'elle effectue exactement la même chose.


Ainsi, si vous avez les adresses des abonnés connues à l'avance dans la candidature et que vous suivez les conditions définies au début de l'article


Conditions au début de l'article
  • n'utilisez pas d'allocation de mémoire dynamique
  • minimiser le travail avec des pointeurs
  • nous utilisons autant de constantes que possible pour que personne ne change autant que possible
  • mais en même temps, nous utilisons aussi peu de constantes que possible situées dans la RAM

En toute confiance, vous pouvez utiliser une telle implémentation du modèle Publisher-Subscriber pour réduire les lignes de code et économiser des ressources, et là vous regardez et vous pouvez réclamer non seulement une carte-cadeau, mais aussi un bonus basé sur les résultats de l'année.


L' exemple de test sous IAR 8.40.2 se trouve ici


Tout à venir! Et bonne chance pour la nouvelle année!

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


All Articles