Assinatura estática usando o modelo Observer usando C ++ e o microcontrolador Cortex M4


Boa saúde a todos!


Na véspera do Ano Novo, quero continuar falando sobre o uso de C ++ em microcontroladores, desta vez tentarei falar sobre o uso do modelo Observer (mas a seguir chamarei Publisher-Subscriber ou apenas um Assinante, um trocadilho), bem como a implementação de uma assinatura estática para C ++ 17 e as vantagens dessa abordagem em algumas aplicações.


1. Introdução


O Assinante de modelo é um dos modelos mais comuns usados ​​no desenvolvimento de software. Com ele, por exemplo, eles clicam no botão do processamento do Windows Form. E, de fato, em qualquer lugar em que você precise, de alguma forma, responder a alterações nos parâmetros do sistema, sejam alterações nos arquivos ou atualizando o valor medido do sensor, é hora sem pensar use o modelo de assinante.


A vantagem do modelo é que liberamos o conhecimento do Publicador e do Assinante sem estarmos vinculados a objetos específicos. Podemos assinar qualquer pessoa, sem afetar a implementação dos objetos Publisher e Subscriber.


Condições iniciais


Antes de nos familiarizarmos com o modelo, vamos primeiro concordar que queremos desenvolver software confiável no qual:


  • não use alocação de memória dinâmica
  • minimizar o trabalho com ponteiros
  • usamos o maior número possível de constantes para que ninguém possa mudar ninguém, tanto quanto possível
  • mas, ao mesmo tempo, usamos o mínimo de constantes possível localizado na RAM

Agora, vejamos a implementação padrão do modelo de Assinante.


Implementação padrão


Suponha que tenhamos um botão e, quando você clica no botão, precisamos piscar os LEDs, mas quantos deles serão desconhecidos até agora e, de fato, talvez você precise piscar não com LEDs, mas com um holofote no navio para transmitir mensagens em código Morse. É importante que não saibamos quem estará assinando. Infelizmente, eu não tenho holofotes à mão, portanto, todos os exemplos deste artigo, por uma questão de simplicidade e melhor entendimento, são feitos com LEDs.


Portanto, quando você pressiona o botão, é necessário notificar o LED sobre esta impressora. Por sua vez, depois de aprender a pressionar o LED, deve passar para o estado oposto.
A implementação padrão na UML é a seguinte ...



Aqui, a classe ButtonController é responsável por pesquisar o botão e notificar os assinantes sobre o clique. Nesse caso, o Led é o assinante. Essas duas classes são dissociadas por meio das ISubsriber e ISubsriber e nenhuma das classes conhece a outra. Portanto, qualquer objeto herdado da interface ISubscriber pode se inscrever em um evento do ButtonController .


Como a alocação dinâmica de memória é proibida, declarei uma matriz de 3 elementos para assinatura. I.e. máximo pode ser de 3 assinantes. Portanto, em uma primeira aproximação, o método de notificar assinantes da classe ButttonsController pode parecer


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

Todo o sal está no método Notify() da classe Publisher . Nesse método, HandleEvent() a lista de assinantes e chamamos o método HandleEvent() em cada um deles, e isso é legal, porque cada assinante implementa esse método à sua maneira e pode fazer isso lá tudo o que seu coração desejar (na verdade, você precisa ter cuidado; caso contrário, o diabo sabe o que o assinante está fazendo lá, você pode chamar o método dele, por exemplo, de uma interrupção e precisa estar vigilante para impedir que assinantes façam coisas longas e ruins)


No nosso caso, o LED pode fazer qualquer coisa, assim como a mudança de seu estado:


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

Implementação completa de todas as 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 ; } ; 

Como uma assinatura pode parecer no código? E assim:


 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() ; } 

A boa notícia é que podemos assinar qualquer objeto e o tempo de sua criação não importa para nós. Pode ser um objeto global, estático ou local. Por um lado, isso é bom, mas, por outro, por que precisamos assinar o tempo de execução nesse código. De fato, aqui o endereço dos objetos Led1 , Led2 , Led3 é conhecido no estágio de compilação. Então, por que você não pode se inscrever no estágio de compilação e manter uma série de indicadores para os assinantes na ROM?


Além disso, existe o risco de possíveis erros, por exemplo, quantos se perguntaram o que aconteceria ao chamar o método Subsribe() se ele fosse chamado de vários threads? Estamos limitados a apenas 3 assinantes, e o que acontece se assinarmos 4 LEDs?


Na maioria dos casos, precisamos dessa assinatura uma vez na vida durante a inicialização, apenas salvamos indicadores para os assinantes e é isso. O ponteiro manterá o endereço desses assinantes por toda a vida. E o dia é inevitável, quando pode ser arruinado devido a surto de supernova (é claro, se considerarmos um período bastante longo). Mas, em qualquer caso, a probabilidade de falha da RAM é muito maior que a ROM e não é recomendável armazenar dados permanentes na RAM.


Bem, a má notícia é que essa solução arquitetônica ocupa muito espaço na ROM e na RAM. Apenas no caso, escrevemos quantas ROM e RAM essa solução leva:


Módulocódigo rodados rodados rw
main.o4886421

I.e. no total, 552 bytes na ROM e 21 bytes na RAM - digamos que não tanto para pressionar um botão e piscar três LEDs.


Bem, para se proteger de tais problemas e reduzir o consumo de recursos do controlador, vamos considerar a opção com uma assinatura estática.


Assinatura estática


Para tornar a assinatura estática, você pode usar várias abordagens. Vou nomeá-los assim:


  • A tradicional é a mesma abordagem, mas usando o construtor constexpr e definindo a lista de assinantes por meio dela.
  • Não convencional Usando modelos - transfira a lista de assinantes através dos parâmetros do modelo. (aqui, um modelo é uma definição do campo da metaprogramação, não dos padrões de design)

A abordagem tradicional da assinatura estática


Vamos tentar se inscrever na fase de compilação. Para fazer isso, ajustamos um pouco nossa arquitetura:



A imagem não é muito diferente da original, mas existem várias diferenças: o método Subscribe() foi removido e agora a assinatura será realizada diretamente no construtor. O construtor deve aceitar um número variável de argumentos e, para poder assinar estaticamente no estágio de compilação, será constexpr . Uma matriz de assinantes será inicializada e essa inicialização pode ser feita em tempo de compilação:


 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] ; } ; 

Código completo para essa implementação
 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] ; } ; 

Agora a assinatura pode ser feita no momento da compilação:


 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 ; } ; 

Aqui, o objeto buttonController completamente localizado na ROM junto com uma matriz de ponteiros para os assinantes:


main :: buttonController 0x800'1f04 0x10 Dados main.o [1]

Tudo parece não ser nada, exceto que estamos novamente limitados a apenas três assinantes. E a classe do editor deve ter um construtor constexpr e, em geral, ser completamente constante para garantir um ponteiro para os assinantes na ROM; caso contrário, mesmo com endereços de assinantes conhecidos, nosso objeto, juntamente com todo o conteúdo, voltará à RAM.


Das outras desvantagens - como as funções virtuais ainda são usadas, as tabelas de funções virtuais são pouco a pouco nossa ROM. E o recurso é, embora acessível, mas não infinito. Na maioria das aplicações, é possível martelá-lo e levar um microcontrolador maior, mas geralmente acontece que cada byte conta, principalmente quando se trata de produtos fabricados por centenas de milhares, como sensores físicos físicos.


Vamos ver como estão as coisas na memória nesta solução:


Módulocódigo rodados rodados rw
main.o172760 0

E embora o resultado seja "impressionante": o consumo total de RAM é de 0 bytes e a ROM é de 248 bytes, o que equivale à metade da primeira solução, parece que ainda há espaço para melhorias. Desses 248 bytes, aproximadamente 50 ocupam apenas as tabelas de métodos virtuais.


Uma pequena digressão:
Um passo no tamanho de ROM de 256 kB para microcontroladores modernos é a norma (por exemplo, o microcontrolador TI Cortex M4 tem 256 kB de ROM e a próxima versão já é de 512 kB). E não será muito bom quando, devido aos 50 bytes extras, for necessário um controlador com uma ROM de 256 kByte maior e mais caro, portanto, abandonar as funções virtuais pode economizar ... até 50 centavos (a diferença entre o microcontrolador na ROM de 256 e 512 kBytes é de cerca de 50-60 centavos).


Isso parece ridículo para 1 microcontrolador, mas em um lote de 400.000 dispositivos por ano, você pode economizar US $ 200.000. Já não é tão engraçado, mas considerando que tipo de rato. eles podem recompensar a oferta com um diploma e um cartão-presente por 3.000 rublos. Não há absolutamente nenhuma dúvida sobre a correção de recusar funções virtuais e economizar 50 bytes extras em ROM.


Abordagem não convencional


Vamos ver como você pode fazer o mesmo sem funções virtuais e economizar um pouco mais de ROM.


Primeiro, vamos descobrir como poderia ser:


 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 ; } 

Nossa tarefa é desacoplar os dois objetos Publisher ( ButtonController ) e Subscriber ( Led ) um do outro para que eles não se conheçam, mas ao mesmo tempo o ButtonController pode notificar o Led .


Você pode declarar a classe ButtonController alguma maneira.


 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() ; } ... } ; 

Mas você entende, aqui estamos anexados a tipos específicos e teremos que refazer a definição da classe BbuttonController sempre em um novo projeto. E eu gostaria de pegar e usar o ButtonController no novo projeto sem ButtonController .


O C ++ 17 vem para o resgate, onde você não pode especificar o tipo, mas peça ao compilador para deduzir o tipo para você - é exatamente isso que você precisa. Podemos, assim como na abordagem tradicional, liberar o conhecimento do Publicador e do Assinante, enquanto o número de assinantes é praticamente ilimitado.


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

Como a função pass (..) funciona

O método Notify() tem uma chamada para a função pass() ; é usado para expandir os parâmetros do modelo com um número variável de argumentos


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

A implementação da função pass() é simplesmente inimaginável, é apenas uma função que recebe um número variável de argumentos:


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

Como a função HandleEvent() expande em várias chamadas para cada um dos assinantes?


Como a função pass() recebe vários argumentos de qualquer tipo, você pode passar vários argumentos do tipo bool , por exemplo, você pode chamar a função pass(true, true, true) . Nesse caso, é claro, nada vai acontecer, mas não precisamos.


A linha (subscribers.HandleEvent() , true) usa o operador "," (vírgula), que executa os dois operandos (da esquerda para a direita) e retorna o valor do segundo operador, ou seja, aqui os subscribers.HandleEvent() serão executados primeiro e, em seguida, true à função pass() será definido como true .


Bem, "..." é uma entrada padrão para expandir um número variável de argumentos. Para o nosso caso, as ações do compilador podem ser descritas muito esquematicamente da seguinte maneira:


 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) ; 

Em vez de links, você pode usar ponteiros:


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

Adição: Na verdade, graças a vamireh, que apontou que todas essas danças estão com pandeiro pass função pass no C ++ 17 não é necessária. Como o operador "," a vírgula é suportada na expressão fold (que foi introduzida no padrão C ++ 17), o código é ainda mais simplificado:


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

Arquitetonicamente, parece muito simples em geral:



Adicionei outra classe de LCD aqui, mas apenas por exemplo, para mostrar que agora não importa o tipo e o número de assinantes, o principal é que implementaria o método HandleEvent() .


E todo o código em geral também é mais fácil agora:


 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 ; } 

A chamada Notify() no método Run() degenera em uma chamada seqüencial simples


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

E a memória aqui?


Módulocódigo rodados rodados rw
main.o18640 0

ROM total de 190 bytes e 0 bytes de RAM. Agora, o pedido é quase três vezes menor que a versão padrão, enquanto executa exatamente a mesma coisa.


Portanto, se você possui os endereços dos assinantes conhecidos com antecedência no aplicativo e segue as condições definidas no início do artigo


Condições no início do artigo
  • não use alocação de memória dinâmica
  • minimizar o trabalho com ponteiros
  • usamos o maior número possível de constantes para que ninguém possa mudar ninguém, tanto quanto possível
  • mas, ao mesmo tempo, usamos o mínimo de constantes possível localizado na RAM

Com confiança, você pode usar essa implementação do modelo Publisher-Subscriber para reduzir linhas de código e economizar recursos. Lá você olha e pode reivindicar não apenas um cartão-presente, mas também um bônus com base nos resultados do ano.


O exemplo de teste da IAR 8.40.2 está aqui


Tudo com a vinda! E boa sorte no ano novo!

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


All Articles