In C, suppose we want to create a function that gets the position of a certain device. We usually would have created a Position type definition, and the functions to retrieve this position would look like one of these:
Position* get_position(); // function 1
void get_position(Position* this_position); // function 2
In function 1 the called function would allocate the memory for the Position object and return the address to the caller function. In function 2, the caller would allocate this memory and pass the object address to the function. In C++ you are allowed to return user defined types directly from functions.
struct Position {
float x;
float y;
};
Position get_position() {
//code
}
int foo() {
auto p = get_position();
// code..
}
Generic Programming With Templates
The mind behind Generic Programming is Stepanov. Long story short, C++ takes code reuse to another level with templates:
#include <iostream>
template <class T>
void swap(T& x, T& y)
{
T temp = x;
x = y;
y = temp;
}
int main(void)
{
int a = 6;
int b = 10;
float f = 3.7f;
float g = 4.3f;
double d = 3.14159265358979323846;
double e = 6.00008573217894365218;
swap(a, b);
std::cout << "a is now " << a << "; b is now " << b << std::endl;
swap(f, g);
std::cout << "f is now " << f << "; g is now " << g << std::endl;
swap(d, e);
std::cout << "d is now " << d << "; e is now " << e << std::endl;
return 0;
}
Output:
a is now 10; b is now 6
f is now 4.3; g is now 3.7
d is now 6.00009; e is now 3.14159
Object Life Cycle
In C, the storage duration of an object depends on how you declare them in your code. C++ works with constructors and destructors for the user-defined types, the classes. Classes are like structs that can have functions, loosely speaking.
An object’s constructor is called just after its storage duration begins and the destructor is called just before it ends. They have no return type and the same name as the struct. The destructor has a ~ to the beginning of the class name.
The compiler makes sure the constructor and destructor are invoked automatically for objects with static, local, and thread local storage duration. For objects with dynamic storage duration, you use the keywords new and delete instead of malloc and free.
#include <cstdio>
struct Car {
Car(const float engine_arg) : engine(engine_arg) { // constructor
printf("I am a car with a %.2f engine\n\r", engine);
}
~Car() { //destructor
printf("I was a car with a %.2f engine\n\r", engine);
}
const float engine;
};
void local_car_30(void) {
Car car_local{ 3.0 }; //memory allocated, constructor called
return; //destructor called, memory deallocated
}
int main() {
Car* car1 = new Car{2.0}; //memory allocated, constructor called
local_car_30();
auto car2 = new Car{1.0};
delete car1; //destructor called, memory deallocated
delete car2;
}
The output:
I am a car with a 2.00 engine
I am a car with a 3.00 engine
I was a car with a 3.00 engine
I am a car with a 1.00 engine
I was a car with a 2.00 engine
I was a car with a 1.00 engine
There is a concept in C++ programming called "RAII" which means "resource allocation is initialization", sometimes also called "constructor acquires, destructor releases".
Initializing data in C++ is a mess. For C programmers,you must get the differences from initializing Fully Featured Classes, structs that have data members and methodsfrom Plain-Old Data structures - pure data containers you already know.
Smart Pointers
A raw pointer is a memory address, just that. And you have to take care of the memory management. The idea of smart pointers is to wrap dynamic objects so the compiler will take care of the memory management.
On the list above, suppose I had not used the delete keyword for car1 and car2 – which I have allocated dynamically (with the new keyword). The destructors would never be called and that would mean a memory leak. The use of a smart pointer, in this case the unique_pointer assures memory is cleaned up.
#include <cstdio>
#include <memory>
struct Car {
Car(const float engine_arg) : engine(engine_arg) { // constructor
printf("I am a car with a %.2f engine\n\r", engine);
}
~Car() { //destructor
printf("I was a car with a %.2f engine\n\r", engine);
}
const float engine;
};
void create_cars(void) {
std::unique_ptr<Car> car1{ new Car{2.0} };
auto car2 = new Car{ 1.0 };
Car car3{ 3.0 };
} //no delete for car2. memory leak!
int main() {
create_cars();
}
Output:
I am a car with a 2.00 engine
I am a car with a 1.00 engine
I am a car with a 3.00 engine
I was a car with a 3.00 engine
I was a car with a 2.00 engine
É dito na literatura que o custo do software embarcado tem dominado o projeto de sistemas eletrônicos [1]. Sistemas embarcados, tradicionalmente limitados em memória e processamento, conseguem hoje rodar aplicações mais complexas fruto do avanço no projeto e fabricação de circuitos integrados. Ora, a introdução de hardware mais complexo permite a utilização de recursos de programação também mais complexos. De fato, mais de 90% das inovações na indústria automotiva da última década são decorrência direta do desenvolvimento em software embarcado. A especialização do dispositivo leva à especialização do software e ao conceito de software hardware-dependente:
é especialmente desenvolvido para um bloco de hardware específico: o software é inútil sem aquele hardware
software e hardware juntos implementam uma funcionalidade: isto é, o hardware é inútil sem aquele software
Abaixo segue um diagrama em camadas de um típico sistema embarcado. Conforme subimos as camadas, menos hardware-dependente será o nosso software, de forma que para escrevermos em linguagem interpretada, digamos, eu não preciso tomar conhecimento do microprocessador, e muito menos das tensões de operação dos meus dispositivos. O HAL (camada de abstração de hardware) é onde encontra-se o software mais hardware-dependente, i.e., mudanças no hardware invariavelmente implicarão em mudanças no software desta camada (Figura 1).
Figura 1. Componentes e camadas de um típico sistema embarcado [ECKER]
2. Orientação a objetos para aumentar a coesão
O paradigma de orientação a objetos parte de um conceito muito intuitivo que é particionar um sistema em objetos classificados consoante à sua natureza. Não é necessário muito para perceber como isto aumenta o reuso e a portabilidade. A utilização do paradigma aumenta a coesão dos componentes de nossa arquitetura na medida da nossa habilidade em pensar de maneira orientada a objetos. Ao contrário do domínio de software aplicativo, em software embarcado sobretudo quando os requisitos são bastante específicos e o hardware limitado, a portabilidade e reuso esbarram na hardware-dependência. Se a OO é um bom paradigma para escrever software coeso, muitas vezes nestes domínio este recurso está indisponível. Podemos utilizar o C para escrever em um (meta-)padrão orientado a objetos, se soubermos fazer bom uso de ponteiros.
3. Exemplo de arquitetura orientada a serviços em C
Como exemplo vou descrever uma arquitetura para desacoplar a aplicação do hardware e que se beneficia dos conceitos de OO, entre eles o polimorfismo. O objetivo é escrever software para comunicação serial coeso o suficiente para quando o hardware mudar (ou for definido), somente o código mais fortemente hardware-dependente precise ser adaptado (ou escrito). O conceito de arquitetura orientada a serviços está presente em sistemas operativos baseados em microkernel (dois exemplos extremos: Windows NT e MicroC/OS). A arquitetura proposta está representada na Figura 2. Todo o software representado daqui em diante refere-se somente à camada de Serviços. A camada Board Support Package desacopla (ou diminui o acoplamento) entre o HAL e os Serviços; é a API do HAL para o programador dos Serviços.
Figura 2. Layers em uma arquitetura orientada a serviços [do autor]
É uma boa ideia permitir que a aplicação utilize dois únicos métodos para transferir e receber um número de dados via serial, aplicados sob diferentes componentes de hardware: enviar(interface, x_bytes) e receber(interface, x_bytes). Estes são métodos que assumem uma forma ou outra a depender do objeto. Assim chegamos aos conceitos de interface (Classe abstrata), herança e polimorfismo. Abaixo o diagrama de classes da proposta. A assinatura completa dos métodos foi omitida.
Figura 3. Diagrama de classes da proposta
O padrão de design Proxy/Server permite que o hardware seja configurado de forma genérica abstraindo seus detalhes específicos. As mudanças no BSP são isoladas pela proxy ao programador da aplicação. O servidor por sua vez sustenta-se nos dados de configuração da proxy e na API do HAL para configurar e inicializar os serviços (this_proxy->this_service).
(Errata: na figura 3 os métodos de construção/inicialização e enviar/receber da classe SPI_proxy estão representados como se fossem iguais ao da UART, porém são métodos distintos.)
3.1. Implementação da Classe Abstrata
Uma interface pode ser percebida como um barramento de funções virtuais que aponta para um método ou outro em tempo de execução. Este apontamento segue por um caminho de endereços até chegar ao método para ser executado naquele contexto (etapa do fluxo do programa). Poderíamos também atribuir através de setters os métodos de cada objeto, i.e., ainda em tempo de compilação.
Começaremos por implementar a estrutura de dados que compreende a classe. Esta estrutura implementa 2 métodos que recebem e enviam frames de bytes. Uma classe Serial_Comm, portanto dá cria a um objeto que contém uma tabela de funções genéricas de enviar e receber. Os métodos públicos da interface são somente construtores e desconstrutores. Os métodos de enviar e receber ficam privados, haja vista que o objeto a ser utilizado pelo programador da aplicação é que vai defini-los. A estrutura com as funções virtuaisSerial_Table é declarada mas não definida inicialmente. Mais adiante declaramos que a estrutura é somente leitura (definida em tempo de compilação) contém dois ponteiros para funções, e finalmente as definimos de forma limitada ao escopo. Isto é necessário para que ocorra o alinhamento do endereço desta tabela ao endereço da tabela do objeto que a utiliza em tempo de execução.
Abaixo o cabeçalho do Serial_Comm.
Figura 4. Cabeçalho da classe Serial_Comm
No programa .c da interface, uma estrutura SerialTable é inicializada com endereço de funções dummy. A definição destes métodos com o assert(0) é uma forma de acusar um erro de programação em tempo de execução, se estas funções forem chamadas sem a devida sobrescrita.
Figura 5. Programa da Classe Virtual
3.2. Implementação do serviço UART
As classes de comunicação serial seguirão este padrão de design. O construtor inicialmente define uma estrutura SerialTable. Perceba que esta estrutura só pode ser definida uma vez, mas os elementos de vtbl podem assumir qualquer valor; i.e., se construímos um objeto que herda esta classe podemos estender os parâmetros e definir o endereço dos métodos, que ficarão ligados a uma e somente uma interface de funções virtuais.
Na estrutura de dados de uma classe herdeira a sua superclasse precisa ser declarada primeiramente. De outro modo não poderemos estender os atributos desta.
Na construção da proxy, uma tabela de funções definidas localmente é o endereço destino que contém a definição dos métodos virtuais da interface.
Figura 6. Cabeçalho da classe Uart_proxy
Figura 7. Programa uart_proxy.c
Um ponto chave é o downcast (linhas 51 e 57) feito nos métodos privados Uart_Send_ e Uart_Get_. Um ponteiro para Uart_proxy é inicializado com o endereço de uma interfaceSerial_Comm. O alinhamento da memória permite que acessemos os métodos desta proxy a partir desta interface. Além disso como servidor e proxy estão agregados, os metodos do BSP podem ser chamados na proxy. As funções que começam com “_” (_uart_init, _uart_get …) fazem parte deste BSP, cujo cabeçalho uart_driver_api.h está incluído no programa. No BSP podem-se utilizar as mesmas técnicas, para permitir por exemplo, que a definição do endereço do dispositivo a ser construído se dê também por uma interface abstrata que não muda com as características do dispositivo (mapeado em porta ou memória, plataforma alvo, etc.)
Figura 8. Cabeçalho da classe Uart_Server
Logicamente, a implementação do serviço para SPI segue o mesmo padrão de design.
4. Utilização dos serviços
Para a utilização dos serviços pela camada de aplicação, basta construir uma proxy com parâmetros de inicialização do dispositivo, neste exemplo UART ou SPI, e utilizar os métodos SendFrame e GetFrame que acessam a interface de cada objeto proxy.
Figura 9. Um programa utilizando os Serviços e seus métodos polimórficos
Figura 10. Programa em execução
5. Conclusões
Conceitos do paradigma de programação orientado a objetos podem ser implementados, virtualmente, em qualquer linguagem se partirmos da ideia de que classes são estruturas de dados agregadas a um conjunto de funções que fornecem uma interface externa comum. Tanto estes métodos quanto estes dados podem ser privados ou não. No primeiro caso está o conceito de encapsulamento. A alocação destas estruturas com seus valores e funções, é a instância de um objeto. A instanciação de um objeto de uma classe dentro de outra classe implementa a herança. Uma interface que não implementa métodos pode ser utilizada para conectar-se à interface de uma outra classe, o que chamamos de funções virtuais. As funções virtuais podem então assumir uma forma ou outra a depender do fluxo do programa, o que caracteriza o polimorfismo.
Uma arquitetura orientada à serviço eleva a coesão dos componentes e a consequente reutilização e portabilidade, mitigando os problemas gerados pela natural hardware-dependência do software para sistemas embarcados. A orientação a objetos por sua vez é o paradigma natural para a construção de sistemas orientado a serviços. Este artigo demonstrou uma forma de aplicar estes conceitos em sistemas embarcados quando linguagens OO (comumente neste domínio C++ ou Ada) não estão disponíveis.
O texto desta postagem bem como as figuras não referenciadas são do autor.
As seguintes referências foram consultadas:
[1] Hardware-Dependent Software: Principles and Practices. Ecker, Muller, Dommer. Springer, 2009.
[2] Design Patterns for Embedded Systems in C. Bruce Douglass. Elsevier, 2001.
[3] Object-Oriented Programming in C: Application Note. Quantum Leaps. April, 2019.