
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 {
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 {
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)
Como uma assinatura pode parecer no código? E assim:
int main() {
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:
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)
Agora a assinatura pode ser feita no momento da compilação:
int main() {
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:
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() {
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 (..) funcionaO 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)
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?
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!