Tutoriais

Criando uma Biblioteca para Arduino

Eletrogate 16 de março de 2023

Introdução

Neste post, vamos ensinar como criar sua própria biblioteca para Arduino, mostrando todos os detalhes de desenvolvimento, desde o planejamento até a publicação, usando, de exemplo, uma biblioteca para LCDs alfanuméricos. É necessário conhecimento da linguagem C++ e da programação em Arduino. Por isso, recomendo a leitura das apostilas da eletrogate.


Materiais Necessários para o Projeto Criando uma Biblioteca para Arduino

Para essa primeira parte do desenvolvimento, vamos utilizar:

cta_cart

Também vamos utilizar o software 7-Zip, que pode ser baixado aqui.


Cuidados com Dependências

Antes de começarmos o desenvolvimento da nossa biblioteca, temos que entender como a Arduino funciona.

Dependências no Arduino

Como muitos devem saber, existem muitos tipos microcontroladores que funcionam de maneira diferente entre si. Alguns podem ter mais pinos, mais memória, endereços de registradores diferentes e até outras arquiteturas, tornando a programação completamente diferente entre um e outro. Então, como a Arduino consegue padronizar tantas placas? Podemos encontrar a resposta no repositório no github da Arduino, pesquisando por “Core” vamos achar as seguintes resultados:

Como podemos ver, a Arduino tem várias versões das mesmas bibliotecas-base para cada arquitetura diferente, esse “Core” são as bibliotecas obrigatórias para uma arquitetura funcionar na Arduino, e a IDE faz o trabalho de selecionar a correta para cada placa. Isso vai influenciar diretamente na criação de nossa biblioteca, quando queremos criar algo capaz de rodar em todas as placas temos que tomar cuidado com as dependências, por exemplo, se incluímos uma biblioteca como a keyboard.h no projeto, esse projeto não vai rodar em placas diferentes de Leonardo, Esplora, Zero, Due e família MKR como podemos ver no site da Arduino, pois Keyboard.h não faz parte do Core, então não é obrigatório que ela exista para todas as placas, também existem bibliotecas feitas especialmente para uma placa, um exemplo é a esp8266WiFi.h, como o próprio nome diz, é uma biblioteca para se trabalhar com o WiFi no esp8266. Vai depender do objetivo do seu projeto, quais dependências incluir e quais placas ela vai suportar.

Dependências da linguagem

Também é importante considerar a versão da linguagem, cada arquitetura possui um compilador diferente, que suporta funcionalidades diferentes, por exemplo, avr-gcc compilador dos controladores da família AVR, arquitetura da principais placas da Arduino (Uno, Mega, Leonardo, Micro, Nano), não tem suporte a biblioteca padrão do C++, somente a do C, outros compiladores como gcc-arm-none-eabi compilador para ARM cortex-M/ cortex-A suportam, a versão da linguagem também chamado de “ISO” ou “standard” também influencia, por padrão a Arduino usa C++11, então funcionalidades acima dessa versão não funcionam.


Criando a Biblioteca

Neste projeto, vamos criar uma biblioteca para LCDs alfanuméricos compatíveis com o HITACHI HD44780 chamada OmniCrystal, mostrando todos os passos do planejamento até a publicação.

Planejamento

Primeiro, temos que definir os objetivos, funcionamento, dependências e plataformas suportadas. Planejar é uma parte crucial para um bom desenvolvimento, é aqui que vamos definir o caminho, é importante lembrar que bugs ou situações inesperadas podem acontecer a qualquer momento, não é possível prever tudo no planejamento.

Objetivo

Aqui vamos definir o que nossa biblioteca vai fazer, quais problemas ela tenta resolver. No nosso caso, uma biblioteca modular para LCDs alfanuméricos compatíveis com o HITACHI HD44780, feita para resolver o problema da falta de flexibilidade das opções atuais.

Funcionamento

Aqui, vamos definir os detalhes de como nossa biblioteca vai se comportar. A biblioteca vai possuir uma classe principal, que vai receber uma referência de um objeto que herda de uma interface (classe pure virtual). Essa interface possui uma função “send(config, data)”, que recebe dois argumentos, config e data, ambos são números sem sinal de 8 bits. Cada bit desses argumentos representa o estado de um pino no LCD, 1 HIGH, 0 LOW, seguindo o esquemático:

Assim, o usuário pode escolher o meio de comunicação do LCD sem trocar de biblioteca, bastando apenas “encaixar” a interface desejada na classe.

Dependências:

Aqui vamos listar as bibliotecas que vamos usar. (caso seja necessário usar uma). No nosso exemplo, apenas a “Arduino.h”. 

Plataformas:

Aqui, vamos definir as plataformas alvos da nossa biblioteca. Esse exemplo foi feito para rodar em todas as placas suportadas para Arduino.


Desenvolvimento

Durante o desenvolvimento, certos pontos do planejamento podem acabar mudando. Mas, em vez de voltar e replanejar tudo de novo, foque em desenvolver um protótipo funcional, teste, anote bugs e possíveis melhorias e, então, volte, revisando esses pontos. Às vezes, a solução para um problema atual, pode ser o problema futuro. Agora começa a parte prática: escolha um IDE/editor de texto de sua preferência (o IDE é apenas para ter uma ambiente adequado para programar, o código será testado no Arduino IDE), no meu caso, Vscode + PlatformIO  e siga a estrutura a seguir:

Estrutura do projeto:

É recomendado que código do projeto esteja dentro de uma pasta chamado “src”. Não há uma regra de como o esquemas de pastas do seu projeto tem ser estruturado dentro de src, mas existem algumas boas práticas como separação de códigos por pastas, colocando códigos relacionados nas mesmas pastas e nomes expressivos sobre seu conteúdo. (fonte). O Arduino IDE não é recomendada para criar uma biblioteca, pois não suporta criar um projeto com essa estrutura.

Uma biblioteca para Arduino precisa de alguns arquivos adicionais na pasta do projeto. São eles:

  • Uma pasta chamada “examples” os códigos aqui aparecem na aba de exemplos da Arduino IDE;
  • Um arquivo de texto chamado: keywords.txt onde ficam as informações para o realce de sintaxe;
  • Um arquivo chamado: library.properties, onde ficam informações adicionais sobre a biblioteca.

Vamos falar sobre esses 3 arquivos no final da nossa biblioteca. No fim, a estrutura será algo semelhante a isso:

No nosso projeto:

A primeira coisa que temos que fazer é ir em cada arquivo de header (.h) e adicionar um “Include guard” (também é interessante colocar comentários explicando o que essa arquivo faz).

defaltmoules.h

//Modulos Pre-definos para nossa lib
#ifndef DEFULT_MODULES_H
#define DEFULT_MODULES_H

#endif

 

lcdinterface.h

//Interface customizavel para displays LCD alfanumericos
#ifndef LCD_INTERFACE_H
#define LCD_INTERFACE_H

#endif

 

omnicrystal.h

//classe principal
#ifndef OMNICRYSTAL_H
#define OMNICRYSTAL_H

#endif

Include guard é uma definição (geralmente o nome do arquivo em caixa alta) que diz para o compilador se esse header já foi incluído. Isso previne que o arquivo seja incluído duas vezes e cause erros.

Seguindo nosso planejamento, vamos criar o interface:

lcdinterface.h

//Interface customizavel para displays LCD alfanumericos
#ifndef LCD_INTERFACE_H
#define LCD_INTERFACE_H

#include <stdint.h>

struct LCDInterface{
    virtual void send(uint8_t config, uint8_t data) = 0;
};

#endif

esse código pode gerar algumas duvidas como:

  • o que é essa stdint.h?
    • essa é uma das bibliotecas padrão do C, essas bibliotecas existem para praticamente tudo que suporta C, e é uma  das dependências do CoreArduino,  então não tem problema usar.
  • o que ela faz?
    • Ela padroniza números, o tipo “int” não tem um tamanho igual para todas as arquiteturas, então foi criada essa biblioteca para padronizar, ela nos da tipos com  intX_t, uintX_t floatX_t, ufloatX_t…etc, a letra “u” no inicio significa “unsigned”, o que indica que  tipos com “u” no inicio não tem sinal, o X é o tamanho em bits podendo ser 8, 16, 32, 64.
  • Porque Struct e não Class?
    • em C++ struct e class são praticamente a mesma coisa, a única diferença é que na classe a visibilidade de atributos/métodos por padrão é “private” e na struct por padrão é “public”.
  • o que significa “= 0” no final da função?
    • isso indica que a nossa função é “pure virtual”, o que torna nossa classe abstrata, ou seja, ela não pode ser instanciada porque não tem código apenas declarações, é uma classe que apenas pode ser usada em herança.

Continuando, vamos criar nossa classe principal:

omnicrystal.h

//classe principal
#ifndef OMNICRYSTAL_H
#define OMNICRYSTAL_H

#include <Arduino.h>
#include <stdint.h>
#include <interface/defultmodules.h>
#include <interface/lcdinterface.h>

enum BusType {Bus4Bits, Bus8Bits};

class Omnicrystal{
    private:
        LCDInterface &_bridge;
        const BusType _bus; // tipo de comunicação 4 ou 8 bits
        const uint8_t _line; // quantidade de linhas no display
        const uint8_t _col; //quantidade de colunas por linhas

    public:
        Omnicrystal(LCDInterface &bridge, const BusType bus, uint8_t line, uint8_t col) : _bridge{bridge}, _bus{bus},
            _line{line}, _col{col}{}
};

#endif

 

Primeiro, incluímos as dependências. Depois, criamos uma enum para definir o tipo de comunicação (bus). Por que enums e não #defin ?  tipos Enum permite criar uma lista limitada de opções em que é feito uma associação de nomes com valores constantes, evitando erros e melhorando a legibilidade do código. Exemplo:

//imagine uma função que recebe um numero inteiro
//onde cada numero realiza uma tarefa

#define TAREFA_1 0
#define TAREFA_2 1
#define TAREFA_3 2

int Fun_int(int tarefa);

//o compilador apenas vê os valores como inteiro
//ele não sabe que só existem tarefas de 0 a 3

Fun_int(TAREFA_1);
Fun_int(TAREFA_3);
Fun_int(99); //não gera erros

//agora com enums a historia é diferente
//definindo um tipo enumerado, você garante que as opções de entrada são sempre validas

enum Tarefa {
    TAREFA1,
    TAREFA2,
    TAREFA3,
};

int Fun_enum(Tarefa tarefa);

Fun_enum(TAREFA1);
Fun_enum(TAREFA3);
Fun_enum(99); //invalido 99 não faz parte da Enum Tarefas

Vamos ver bastante uso de Enums nesse projeto.

Continuando, o que significa “: _bridge{bridge}, _bus{bus}....“? Isso é uma lista de inicialização que permite inicializar valores no construtor da classe. Umas das vantagens é inicializar valores constantes dentro de uma classe, como nos vemos no código. Mas, por que ele precisa ter um constante na classe? Valores constantes ajudam o seu compilador a entender o código. Se você tem certeza que um valor nunca muda, como o tipo de comunicação com o display ou o tamanho dele, é sempre bom declarar como constantes. (os valores devem ser inicializados na mesma ordem que forem declarados no classe, como nos declaramos: _bridge, _bus, _line, _col, temos que inicializar seguindo essa ordem)

A próxima etapa é criar as funções para enviar dados para o LCD. Para isso, temos que olhar o datasheet. Lá é onde vamos encontrar informações muito uteis. Primeiro, temos que criar uma função para iniciar o display. Olhando no datasheet, temos esse diagrama.

(https://www.futurlec.com/LED/LCD16X2BLa.shtml pag: 45-46)

Aqui, já temos todas as informações necessárias para começar. Como podemos ver, os modos para LCD 4 e 8 Bits são iguais no seu inicio, a diferença começando após a inicialização do display. Também temos que notar que existe esses pinos RS e RW. RS é o Register Select, que diz a nosso LCD se a informação que ele vai receber é um texto(HIGH/1) ou um comando(LOW/0). O R/W é o pino que diz se nos vamos ler ou escrever uma informação no LCD. Como não temos função para ler do LCD, vamos manter ele sempre no LOW, que significa apenas escrita. É com isso que nos temos que trabalhar agora.

Primeiro declaramos a funções no header da classe principal:

omnicrystal.h

//classe principal
#ifndef OMNICRYSTAL_H
#define OMNICRYSTAL_H

#include <Arduino.h>
#include <stdint.h>
#include <interface/defultmodules.h>
#include <interface/lcdinterface.h>

enum BusType {Bus4Bits, Bus8Bits};

class Omnicrystal{
    private:
        LCDInterface &_bridge; //interface de comunicação
        const BusType _bus; // tipo de comunicação 4 ou 8 bits
        const uint8_t _line; // quantidade de linhas no display
        const uint8_t _col; //quantidade de colunas por linhas
        void send4Bits(uint8_t data, uint8_t RS_state);
        void send8Bits(uint8_t data, uint8_t RS_state);
        void send(uint8_t data, uint8_t RS_state);

    public:
        Omnicrystal(LCDInterface &bridge, const BusType bus, uint8_t line, uint8_t col) : _bridge{bridge}, _bus{bus},
            _line{line}, _col{col}{}
        Omnicrystal& begin();
        Omnicrystal& write(const char *text);
};

#endif

Adicionamos uma função que envia 8 bits e outra que envia 4 bits para os modos de comunicação, uma função “send”, que vai selecionar automaticamente a função correta baseada no “_bus”, uma função para iniciar o LCD e uma para escrever informações nele. Agora, temos que criar essas funções no “source” (.cpp) da nossa classe principal. Mas, antes, precisamos de informações adicionais, como vimos após iniciar o LCD temos que enviar algum comandos. Então, vamos voltar ao datasheet:

(https://www.futurlec.com/LED/LCD16X2BLa.shtml Pag:24)

(https://www.futurlec.com/LED/LCD16X2BLa.shtml Pag:49)

Com todas as informações sobre funções e tempo, podemos criar as funções:

omnicrystal.cpp

#include <omnicrystal.h>

void Omnicrystal::send8Bits(uint8_t data, uint8_t RS_state){
    _bridge.send(RS_state, data);
    _bridge.send(RS_state | 0b00000100, data);
    delayMicroseconds(1);
    _bridge.send(RS_state, data);
}

//divide 8 bits em dois pacotes de 4bits
//(Ex: tranforma 0b01101001 em 0b01100000 e 0b10010000)
void Omnicrystal::send4Bits(uint8_t data, uint8_t RS_state){
    uint8_t high_nibble = data & 0xF0;
    uint8_t low_nibble = data << 4;
    send8Bits(high_nibble, RS_state);
    delayMicroseconds(1);
    send8Bits(low_nibble, RS_state);
}

void Omnicrystal::send(uint8_t data, uint8_t RS_state){
    if(_bus == Bus8Bits){
        send8Bits(data, RS_state);
    }else{
        send4Bits(data, RS_state);
    }

    if(RS_state == 1){
        delayMicroseconds(2);
    }
    else{
        delayMicroseconds(40);
    }
}

Omnicrystal& Omnicrystal::begin(){
    delay(50);//aguarda o LCD iniciar
    send8Bits(0x30, 0);
    delayMicroseconds(4100);
    send8Bits(0x30, 0);
    delayMicroseconds(100);
    send8Bits(0x30, 0);
    delayMicroseconds(100);

    //configura o modo de comunicação corretamente
    if(_bus == Bus4Bits){
        send8Bits(0x20, 0);
        send(0x28, 0);
    }else{
        send(0x38, 0);
    }
    send(0x01, 0); //limpa o LCD
    delay(2);
    send(0x02,0); //Reinicia variaveis internas
    delay(2);
    send(0x06,0); //liga modo incremental do display
    send(0x0F,0); //LCD on, cursor on, cursor piscando
    return *this;
}

//lê cada letra de uma string e escreve no LCD
Omnicrystal& Omnicrystal::write(const char *text){
    uint16_t i = 0;
    while(text[i] != '\0'){
        send((uint8_t)text[i], 1);
        i++;
    }
    return *this;
}

Iniciamos o display de acordo com o datasheet. Mas por que o datasheet diz para enviar 0x30(0b0011_0000) três vezes? Se olhar na tabela de comandos, podemos ver que 0x30 é o comando que define a quantidade de bits na comunicação em 8bits (0b0010_0000 + 0b0001_0000). Então, significa que ele força a comunicação em 8 bits. Como 4 e 8 bits usam os mesmos pinos para configurar a comunicação, forçar em 8 bits garante que ele vai funcionar nos dois. É por isso que o fluxograma de inicialização é igual no inicio. Agora, falta apenas uma coisa para terminamos nosso primeiro protótipo: criar uma interface. Então, vamos no arquivo defultmodules.h e criar uma. Vamos começar com a comunicação paralela, a padrão do LCD.

defultmodules.h

//Modulos Pre-definos para nossa lib
#ifndef DEFULT_MODULES_H
#define DEFULT_MODULES_H

#include <stdint.h>
#include "lcdinterface.h"
#include "Arduino.h"

class LCDParallel : public LCDInterface{
    private:
        const uint8_t RS_pin;
        const uint8_t EN_pin;
        const uint8_t com_pins[8];
        const uint8_t pins_offset;

        //inicia os pinos usados no LCD
        void init_pins(){
            pinMode(EN_pin, OUTPUT);
            pinMode(RS_pin, OUTPUT);
            for(size_t i = pins_offset; i < 8; i++){
                pinMode(com_pins[i], OUTPUT);
            }
        }
    public:
        LCDParallel(uint8_t RS, uint8_t EN, uint8_t D4,uint8_t D5,uint8_t D6, uint8_t D7):
        RS_pin{RS},
        EN_pin{EN},
        com_pins{255, 255, 255, 255, D4, D5, D6, D7},
        pins_offset{4}
        {
            init_pins();
        }
        LCDParallel(uint8_t RS, uint8_t EN, uint8_t D0,uint8_t D1,uint8_t D2, uint8_t D3,
                uint8_t D4, uint8_t D5, uint8_t D6, uint8_t D7):
        RS_pin{RS},
        EN_pin{EN},
        com_pins{D0, D1, D2, D3, D4, D5, D6, D7},
        pins_offset{0}
        {
            init_pins();
        }
        void send(uint8_t config, uint8_t data){
            digitalWrite(RS_pin, config & 0x01);
            for(size_t i = pins_offset; i < 8; i++){
                digitalWrite(com_pins[i], data & (1 << i));
            }
            digitalWrite(EN_pin, config & 0x04);
}
};

#endif

Primeiro, devemos herdar LCDIntrerface de forma “public” (a função “send” também precisa ficar na área “public” da classe). Após isso, definimos os pinos como constantes, pois eles não mudam durante a execução. pin_offset diz se estamos usando 4 ou 8 pinos. Se usarmos 4, ele ignora os pinos D0-D3. Se usamos 8, ele usa todos os pinos de D0-D7. Depois, criamos uma função para iniciar os pinos e um construtor. Caso o usuário use apenas 4 pinos, nosso construtor completa nosso array de pinos com 255. Esse numero não tem um significado especial, não representa uma porta, é só um valor para preencher espaço. Caso escolha 8 pinos, ele completa todo array com os pinos e, depois, chama nossa função para iniciar os pinos. A função send faz exatamente o que definimos no esquema de funcionamento durante o planejamento: verifica os bits na posição i de “data”. Se for 1, ele coloca a porta da posição i em HIGH, se for 0 coloca a porta da posição i em LOW.

Primeiro Teste

Agora, é hora de testar nosso primeiro protótipo. Vá até a pasta do projeto clique com o botão direito e, utilizando 7-Zip, selecione adicionar para “nome.zip”

Você pode baixar o protótipo desse projeto em https://github.com/RecursiveError/omnicrystal/releases/download/v0.1.0-alpha/omnicrystal.zip

Agora, basta abrir o Arduino IDE, ir na opção Sketch->Include Libary -> Add .ZIP Libary

navegar até a pasta que esta o arquivo .zip e abrir.

Esquema:

Código:

#include <Arduino.h>
#include <omnicrystal.h>

LCDParallel lcdInter(6,7,8,9,10,11);
Omnicrystal lcd(lcdInter, Bus4Bits, 2, 16);

void setup() { 
  lcd.begin();
}

void loop() {
  lcd.write("teste 1 ");
  delay(5000);
}

Resultado:

O protótipo funciona. A próxima etapa é testar, adicionar funcionalidades comuns para LCDs, criar novos módulos e publicar oficialmente. Deixe nos comentários funcionalidades que vocês gostariam de ver e duvidas. Obrigado por ler até aqui e até a próxima!


Sobre o Autor


Guilherme Schultz
LinkedIn

Apaixonado por tecnologia, Autodidata em eletrônica e desenvolvimento embarcado.


Eletrogate

16 de março de 2023

A Eletrogate é uma loja virtual de componentes eletrônicos do Brasil e possui diversos produtos relacionados à Arduino, Automação, Robótica e Eletrônica em geral.

Conheça a Metodologia Eletrogate e Lecione um Curso de Robótica nas Escolas da sua Região!

Eletrogate Robô

Cadastre-se e fique por
dentro de novidades!