¿Cómo escribo mi propia biblioteca Arduino?
En esta lección, aprenderemos a escribir nuestras propias bibliotecas para Arduino y analizaremos algunas preguntas típicas sobre la interacción del código en la biblioteca y el código en el boceto (en el archivo principal del programa). Esta es la tercera lección relacionada con las bibliotecas: asegúrese de leer y dominar la lección sobre objetos y clases del bloque de programación, y la lección sobre el uso de bibliotecas del bloque de lecciones básicas, así como la lección sobre la creación de funciones. En este tutorial usaremos todos nuestros conocimientos previos, por lo que te recomiendo que te ocupes de todo lo que no tienes claro.
Es muy conveniente escribir bibliotecas en el editor de texto Notepad ++ ( sitio oficial ), el llamado cuaderno del programador. Este cuaderno reconoce y resalta la sintaxis, es capaz de autocompletar texto y búsqueda avanzada, y mucho más. Recomiendo encarecidamente trabajar en él si no sabe cómo usar Microsoft Visual Studio u otros entornos de desarrollo serios.
Tratar con archivos.
Una biblioteca es principalmente un archivo de texto con código que podemos conectar a nuestro boceto y usar los comandos disponibles allí. Una biblioteca puede tener varios archivos o incluso carpetas con archivos, pero siempre se incluye solo uno: el archivo de encabezado principal con la extensión .h , que a su vez extrae el resto de los archivos necesarios.
En general, la biblioteca tiene la siguiente estructura (el nombre de la biblioteca es testLib ):
- testLib – carpeta de la biblioteca
- ejemplos – carpeta con ejemplos
- testLib.h – archivo de encabezado
- testLib.cpp – archivo de implementación
- keywords.txt – mapa de resaltado de sintaxis
A veces los archivos .h y .cpp se pueden encontrar en la carpeta src. Todos los archivos y carpetas, excepto el encabezado .h, son opcionales y pueden estar ausentes, es decir, una biblioteca solo puede constar de un archivo de encabezado.
De esta forma, la biblioteca está en la carpeta con todas las demás bibliotecas y se puede incluir en el boceto usando el comando #include. En general, hay dos lugares donde el programa buscará la biblioteca (es decir, el archivo de la biblioteca):
- Carpeta de bocetos
- Carpeta de la biblioteca
En consecuencia, el comando de inclusión tiene dos opciones para encontrar un archivo, depende si nombre está encerrado entre <> o «» :
- #include <file.h> : buscará un archivo en la carpeta de la biblioteca
- #include “file.h” – intentará encontrar un archivo en la carpeta con el boceto, si no lo encuentra, irá a la carpeta de bibliotecas
Núcleo de la biblioteca
Llenemos nuestro archivo testLib.h, nuestra biblioteca de prueba, con el código mínimo para trabajar:
#ifndef testLib_h #define testLib_h #include <Arduino.h> // código de biblioteca #endif
La construcción de las directivas del preprocesador prohíbe volver a vincular la biblioteca y generalmente es opcional, pero es mejor no ser vago y escribir así. El archivo de biblioteca testLib.h se encuentra en la carpeta testLib en la carpeta con todas las demás bibliotecas. También incluimos el archivo principal Arduino.h para usar las funciones de arduino en nuestro código. Si no hay ninguno, no tiene que conectarlo. También incluimos testLib.h en nuestro boceto de prueba, como en la captura de pantalla del último capítulo.
Puede encontrar la construcción ifndef-define de C # en casi todas las bibliotecas. En las versiones actuales del IDE, puede hacer esto:
#pragma once // conecta Arduino.h // código de biblioteca
La directiva #pragma once le dice al compilador que este archivo necesita ser incluido solo una vez, esta es solo una pequeña alternativa a # ifndef-define. Lo usaremos más.
Escribir una clase.
Usemos lo aprendido en la lección objetos y las clases e insertemos la versión final de la clase en testLib.h:
testlib.h
#pragma once #include <Arduino.h> // descripción de la clase class Color { // clase Color public: Color(byte color = 5, byte bright = 30); void setColor(byte color); void setBright(byte bright); byte getColor(); byte getBright(); private: byte _color; // variable color byte _bright; // variable brillo }; // implementación de métodos Color::Color(byte color = 5, byte bright = 30) { _color = color; // recuerda _bright = bright; } void Color::setColor(byte color) {_color = color;} void Color::setBright(byte bright) {_bright = bright;} byte Color::getColor() {return _color;} byte Color::getBright() {return _bright;}
testsketch.h
#include <testLib.h> Color myColor ( 10 ) ; // crea un objeto myColor con _color (obtenemos 10, 30) Color myColor2 ( 10, 20 ) ; // ¡establece color y brillo! (obtenemos 10, 20) Color myColor3; void setup () { } void loop () { }
En realidad, así es como colocamos nuestra clase en un archivo separado, lo conectamos al programa principal y usamos el código: acabamos de crear varios objetos. Comprobemos si funciona: enviamos los métodos de retorno al puerto:
testsketch.h
#include <testLib.h> Color myColor ( 10 ) ; // crea un objeto myColor con _color (obtenemos 10, 30) Color myColor2 ( 10, 20 ) ; // ¡establece color y brillo! (obtenemos 10, 20) Color myColor3; void setup() { Serial.begin(9600); Serial.println(myColor.getColor()); // 10 Serial.println(myColor2.getBright()); // 20 Serial.println(myColor3.getColor()); // 5 } void loop() { }
El código genera los valores de la clase, los genera correctamente. De hecho, ¡aquí hemos escrito nuestra propia biblioteca! A continuación, puede separar la descripción y la implementación creando un archivo testLib.cpp:
testLib.h
#pragma once #include <Arduino.h> // descripción de la clase class Color { // clase Color public: Color(byte color = 5, byte bright = 30); void setColor(byte color); void setBright(byte bright); byte getColor(); byte getBright(); private: byte _color; // variable de color byte _bright; // variable brillo } ;
testLib.cpp
#include <testLib.h> // debemos incluir el encabezado // implementación de métodos Color::Color(byte color = 5, byte bright = 30) { // conmstructor _color = color; // recuerda _bright = bright; } void Color::setColor(byte color) {_color = color;} void Color::setBright(byte bright) {_bright = bright;} byte Color::getColor() {return _color;} byte Color::getBright() {return _bright;}
testSketch
#include <testLib.h> Color myColor ( 10 ) ; // crea un objeto myColor Color myColor2 ( 10, 20 ) ; // ¡establece color y brillo! (obtenemos 10, 20) Color myColor3; // sin inicialización void setup() { Serial.begin(9600); Serial.println(myColor.getColor()); // 10 Serial.println(myColor2.getBright()); // 20 Serial.println(myColor3.getColor()); // 5 } void loop() { }
Un punto importante: si la biblioteca tiene un archivo name.cpp de biblioteca, entonces la implementación de métodos y funciones debe estar ahí. No puede especificar la implementación en el archivo name.h de la biblioteca, habrá un error.
Si la biblioteca consta solo del archivo de encabezado name.h de la biblioteca, entonces la implementación se puede escribir en ella.
Y ahora tenemos (con los ejemplos) una biblioteca completa, dividida en archivos. Puede complementarlo con el archivo keywords.txt para que nuestros métodos estén resaltados en el código.
Keywords.txt
keywords.txt es un archivo que contiene un «mapa» de resaltado de sintaxis, es decir, de qué color resaltar qué palabras. La sintaxis para construir este archivo es muy simple: los nombres de las funciones / métodos se enumeran en una nueva línea y el tipo de palabra clave está separado por tabulaciones (presionando la tecla TAB).
- KEYWORD1 : naranja en negrita, resaltado para tipos de datos y nombres de clases
- KEYWORD2 – naranja, para métodos y funciones
- LITERAL1 – azul, para constantes
Así es como se verá keywords.txt en nuestra biblioteca:
# comentario testLib KEYWORD1 Color KEYWORD1 setColor KEYWORD2 setBright KEYWORD2 getColor KEYWORD2 getBright KEYWORD2
Puedes dejar comentarios, aquí comienzan con un hash #. No tenemos constantes, así que no usé LITERAL1. Veamos cómo se ve el código con el resaltado de nuestros comandos de la biblioteca. Un punto importante: para que los cambios surtan efecto, debe cerrar todas las ventanas del IDE de Arduino y volver a abrir el boceto.
Ejemplos de implementación.
Hemos analizado la estructura de la creación de una biblioteca, veamos algunas opciones privadas con ejemplos. Haré ejemplos con clases, no con funciones, porque la mecánica de trabajar con una clase, con una biblioteca, es mucho más complicada, y aquí estamos aprendiendo a escribir bibliotecas.
En todos los ejemplos, he creado la biblioteca de prueba testLib.h y la pruebo en testSketch.
Biblioteca sin clase
La biblioteca no necesita tener una clase, puede ser solo un conjunto de funciones:
testLib.h
#pragma once #include <Arduino.h> void printLol() { Serial.println("lol"); }
tesSketch
#include <testLib.h> void setup() { Serial.begin(9600); printLol(); // imprime lol } void loop() { }
Variaciones obvias: tiene más sentido escribir la declaración por separado de la implementación de la función. O incluso poner la implementación en un archivo .cpp.
testLib.h
#pragma once #include <Arduino.h> // declaracion void printLol () ;
testLib.cpp
#include <testLib.h> // / debemos incluir el encabezado // implementacion void printLol() { Serial.println("lol"); }
tesSketch
#include <testLib.h> void setup() { Serial.begin(9600); printLol(); // muestra lol } void loop() { }
Vamos a envolverlo en el espacio de nombres
Este ejemplo se refiere al ejemplo anterior: en la “biblioteca” que creamos funciones, los nombres de estas funciones pueden coincidir con otras funciones en el esquema, lo que generará problemas. En lugar de escribir una clase, las funciones se pueden incluir en un namespace. Mira este ejemplo, creo que todo se aclarará:
testLib.h
#pragma once #include <Arduino.h> // espacio de nombres myFunc namespace myFunc { void printLol () ; } ; // implementación void myFunc :: printLol () { serial.println ( "lol" ) ; }
El uso del espacio de nombres le permite separar funciones con los mismos nombres de diferentes documentos, llamar a una función desde un espacio de nombres se ve exactamente igual que una clase: nombre de namespace :: nombre de función.
tesSketch
#include <testLib.h> void setup () { serial.beguin( 9600 ) ; // saca kek de la función printLol de este modulo printLol () ; // saldrá lol de la función de biblioteca myFunc :: printLol () ; } void printLol () { serial.println ( "kek" ) ; } void loop () { }
Pasar y enviar un valor a una clase
Considere este ejemplo: debe pasar un cierto valor a la clase, procesarlo y devolver el resultado al boceto. Como ejemplo, devolvamos un número multiplicado por 10. O mejor aún, considere una situación más compleja: necesita tomar un valor en una clase, escribirlo en una variable privada y obtenerlo usando un método separado:
testLib.h
#pragma once #include <Arduino.h> class testClass { public : void setValue ( int val ) ; int getValue () ; private : int _value = 0; } ;
testLib.cpp
#include <testLib.h> // debemos incluir el encabezado void testClass :: setValue ( int val ) { // toma el valor externo y escribe en nuestro _value _value = val; } int testClass :: getValue () { return _value; // devuelve la variable de la clase }
tesSketch
#include <testLib.h> testClass testObject; void setup() { Serial.begin(9600); testObject.setValue(666); Serial.println(testObject.getValue()); // imprime 666 } void loop() { }
Modificar una variable de una clase
Considere esta situación: necesitamos cambiar el valor de una variable en el boceto usando un método / función de biblioteca. Aquí hay dos opciones: asignar directamente o usar un puntero. Consideremos ambas opciones en un ejemplo:
testLib.h
#pragma once #include <Arduino.h> class testClass { public : int multTo5 (int value) ; void multTo7 ( int * value ) ; private : } ;
testLib.cpp
int testClass::multTo5(int value) { // devuelve el valor multiplicado por 5 return value * 5; } void testClass::multTo7(int* value) { // multiplicar la variable por 7 *value = *value * 7; }
tesSketch
#include <testLib.h> testClass testObject; void setup() { int a = 10; a = testObject.multTo5(a); // a == 50 testObject.multTo7(&a); // a == 350 } void loop() { }
En la primera versión, pasamos el valor de la variable, dentro del método lo multiplicamos por 5 y lo devolvemos, y podemos igualar la misma variable en el boceto al nuevo valor. En el caso de un puntero, todo funciona de manera más interesante: pasamos la dirección de la variable al método, multiplicamos esta variable por 7 dentro de la clase, y listo. En términos generales, en este ejemplo, *value es un muñeco vudú para la variable a: lo que haremos con * valor dentro del método: se reflejará inmediatamente en a.
Este tema se puede desarrollar con esta opción: podemos almacenar la dirección de una variable en la clase, y la clase siempre tendrá acceso directo al valor de la variable, ¡no será necesario pasarlo cada vez!
testLib.h
#pragma once #include <Arduino.h> class testClass { public : void takeControl ( int* value) ; void multTo6 () ; private : int * _value; // almacena la dirección } ;
testLib.cpp
#include <testLib.h> // debemos incluir el encabezado void testClass :: takeControl ( int * value ) { _value = value; } void testClass :: multTo6 () { * _value = * _value * 6; }
tesSketch
#include <testLib.h> testClass testObject; int a = 10; void setup () { // pasó la dirección a testObject.takeControl ( & a ) ; // ahora a == 10 testObject.multTo6 () ; // aquí a se convierte en 60 a = 5; testObject.multTo6 () ; // aquí a se convierte en 30 testObject.multTo6 () ; // aquí a se convierte en 180 } void loop () { }
De esta forma, la clase y sus métodos pueden tener un control completo sobre la variable en el programa principal.
Pasando una matriz a una clase
Intentemos pasar la matriz a la clase para que los métodos de la clase puedan, por ejemplo, sumar los elementos de la matriz y devolverla.
testLib.h
#pragma once #include <Arduino.h> class testClass { public: long getSum(int *array, byte length); private: };
testLib.cpp
#include <testLib.h> //debemos incluir el encabezado long testClass::getSum(int *array, byte length) { long sum = 0; //calcula la longitud de la matriz length = length / sizeof(int); for (byte i = 0; i < length; i++) { sum += array[i]; } return sum; }
tesSketch
#include <testLib.h> testClass testObject; void setup() { //hacer matriz int myArray[] = {5, 33, 58, 251, 91}; // pasar la matriz y su tamaño (en bytes) long arraySum = testObject.getSum((int*)myArray, sizeof(myArray)); // arraySum == 438 } void loop() { }
Creo que el mecanismo principal está claro, aquí dejo otro ejemplo de cómo transferir una estructura.
Pasando una estructura por puntero:
// pasar la estructura por puntero struct foo_param_t { float *u; int n; float b; float c; } void foo(foo_param_t *p) { for (int i=0; i<p->n; i++) { float x = i*M_PI; p->u[i] = 1.0+p->b*x+p->c*x*x; } } void bar() { const int N = 10; float a[N]; foo_param_t p = {(float*)&a, N, 1.23, -4.56}; foo(&p); }
Pasando una estructura por referencia:
// pasar la estructura por referencia struct foo_param_t { float *u; int n; float c; float b; } void foo(foo_param_t& p) { for (int i=0; i<p.n; i++) { float x = i*M_PI; p.u[i] = 1.0+p.b*x+p.c*x*x; } } void bar() { const int N = 10; float a[N]; foo_param_t p = {(float*)&a, N, 1.23, -4.56}; foo(p); }
Pasar una función a una clase
Creo que recuerdas cómo funcionan cosas como attachInterrupt: especificamos el nombre de una función que se puede llamar desde otra función. Esto se hace mediante un puntero de función. Veamos un ejemplo simple sin una clase:
testLib.h
#pragma once #include <Arduino.h> // la función adjunta se almacena aquí void (*atatchedF)(); // conecta la función void attachFunction(void (*function)()) { atatchedF = *function; } // llamar a la función adjunta void callFunction() { (*atatchedF)(); }
tesSketch
#include <testLib.h> void setup() { Serial.begin(9600); // conectó la función printKek attachFunction(printKek); // llamó a la función conectada callFunction(); // llamar a printKek } void printKek() { Serial.println("kek"); } void loop() { }
Ahora pongámoslo todo en la clase y almacenemos la dirección de la función adjunta dentro de la clase.
testLib.h
#pragma once #include <Arduino.h> class testClass { public: void attachFunction(void (*function)()); void callFunction(); private: void (*atatchedF)(); };
testLib.cpp
#include <testLib.h> // debemos incluir el encabezado void testClass::attachFunction(void (*function)()) { atatchedF = *function; } void testClass::callFunction() { (*atatchedF)(); }
tesSketch
#include <testLib.h> testClass testObj; void setup() { Serial.begin(9600); // conectó la función printKek testObj.attachFunction(printKek); //llamó a la función conectada testObj.callFunction(); // llama printKek } void printKek() { Serial.println("kek"); } void loop() { }
Creación automática de objetos
Crear una clase también significa crear un objeto, pero a veces se escribe una biblioteca para un solo objeto (por ejemplo, una biblioteca para trabajar con una interfaz) y crear un objeto en un boceto parece un código adicional. Pero, si abre cualquier ejemplo usando la biblioteca Wire.h, no encontrará una creación de objeto Wire allí, ¡pero se usa! Por ejemplo:
#include <Wire.h> void setup() { Wire.begin(); } // ...........
Estamos usando un objeto Wire , ¡pero no lo creamos! A veces puede ser adecuado, le mostraremos cómo hacerlo: solo necesita agregar una línea al archivo de encabezado:
extern ombre_clase nombre_objeto;
Y en .cpp, si hay uno, agregue:
nombre_clase nombre_objeto = nombre_clase () ;
Así, el objeto se creará dentro de la biblioteca y podremos usarlo desde el boceto. Tomemos el primer ejemplo de la lección, del capítulo «Pasar y enviar un valor a una clase», y eliminemos la creación de objetos innecesarios:
testLib.h
#pragma once #include <Arduino.h> class testClass { public: long get_x10(int value); private: }; extern testClass testObject;
testLib.cpp
#include <testLib.h> // incluir encabezado long testClass::get_x10(int value) { return value*10; } testClass testObject = testClass();
tesSketch
#include <testLib.h> // ¡no creas un objeto! void setup() { Serial.begin(9600); Serial.println(testObject.get_x10(450)); // imprime 4500 } void loop() { }
Establecer el tamaño de una matriz al crear un objeto
Debe recordar de la lección sobre matrices que se debe conocer el tamaño de la matriz antes de iniciar la ejecución del programa. Pero, ¿qué pasa si en una clase necesitamos una matriz con la capacidad de establecer su tamaño? Si hay un objeto en el programa, o para todos los objetos, el tamaño de la matriz será el mismo, entonces obviamente puede hacerlo así:
#define ARRAY_LEN 20 lass myClass { public: byte vals[ARRAY_LEN]; private: } ; myClass obj1; // obj1.vals tendrá 20 celdas aquí myClass obj2; // obj2.vals tendrá 20 celdas aquí myClass obj3; // obj3.vals tendrá 20 celdas aquí
Si queremos poder establecer el tamaño de la matriz para cada objeto, existen opciones:
Vía plantilla:
Al crear objetos, <parámetro> aparecerá en consecuencia:
template < int ARRAY_LEN > class myClass { public: byte vals[ARRAY_LEN]; private: }; myClass obj1; // obj1.vals tendrá 20 celdas aquí myClass obj2; // obj2.vals tendrá 20 celdas aquí myClass obj3; // obj3.vals tendrá 20 celdas aquí
También puede escribir el tamaño de la matriz en una variable para usar en más código:
template < int ARRAY_LEN > class myClass { public: byte vals[ARRAY_LEN]; byte arrSize = ARRAY_LEN; private: }; myClass<30> obj1; // obj1.vals tiene 30 celdas // obj1.arrSize es 30
Cuando un objeto se crea globalmente, dicha matriz se almacenará en el área de variables globales y el compilador podrá calcular su tamaño.
A través de new y puntero:
Puede asignar una «matriz» de forma dinámica y almacenarla como un puntero. Como valor «constante», un truco de C ++ llamado lista de inicialización (dos puntos después myClass ( int x ) ):
class myClass { public: int* arr; myClass(int x) : arr(new int[x]) { // constructor } private: }; myClass obj(5); void setup() { Serial.begin(9600); obj.arr[0] = 1; obj.arr[1] = 2; obj.arr[2] = 3; obj.arr[3] = 4; obj.arr[4] = 5; for (byte i = 0; i < 5; i++) { Serial.println(obj.arr[i]); // imprimirá 1 2 3 4 5 } } void loop() { }
Incluso con la creación global de un objeto, dicha matriz se almacenará en la memoria dinámica y el compilador no podrá calcular su tamaño.
Haciendo constantes:
Probablemente vio a menudo en bibliotecas pasar una constante a una función, no tiene que ir muy lejos: digitalWrite (13, HIGH ); ¿Dónde está HIGH? ¿Qué es? Si abre Arduino.h, encontrará HIGH allí, esta es una constante, defina:
#define HIGH 0x1
Y en keywords.txt aparece como LITERAL1, lo que le da color azul. Hagamos una biblioteca que genere texto según la constante especificada:
testLib.h
#pragma once #include <Arduino.h> // constantes #define KEK 0 #define LOL 1 #define AZAZA 2 #define HELLO 3 class testClass { public: void printer(byte value); private: };ct;
testLib.cpp
#include <testLib.h> //debemos incluir el encabezado void testClass::printer(byte value) { switch (value) { case 0: Serial.println("kek"); break; case 1: Serial.println("lol"); break; case 2: Serial.println("azaza"); break; case 3: Serial.println("hello"); break; } }
tesSketch
#include <testLib.h> testClass testObject; void setup() { Serial.begin(9600); testObject.printer(KEK); // imprime kek testObject.printer(LOL); // imprime lol testObject.printer(AZAZA); // imprime azaza testObject.printer(HELLO); // imprime hello } void loop() { }
Así es como puede pasar una palabra en lugar de un valor, y será más conveniente trabajar con dicha biblioteca. Tenga en cuenta que usamos constantes (define), esto no es muy correcto: si en otro documento conectado a continuación o en el propio boceto, nuestra definición coincide con el nombre de otra variable, función u otra definición, entonces el programa no funcionará correctamente. define se aplica a otros documentos, incluido el programa principal (Sketch).
¿Qué hacer? Puede nombrar sus constantes de manera tan única que nadie interfiera con ellas, por ejemplo, agregue un prefijo con el nombre de la biblioteca: MYLIB_ CONSTANT.
También puede reemplazar la definición con una enumeración, entonces su biblioteca no afectará a otras y al documento principal, pero otras bibliotecas y definiciones externas también pueden ingresar a su biblioteca.
Interferencia de compilación:
A continuación, considere esta situación: sabemos cómo utilizar las directivas del preprocesador y queremos influir en el proceso de compilación de la biblioteca sin tocar nada en el archivo de la biblioteca. ¿Es posible? Sí, es posible. Un punto importante: este truco solo funciona en el archivo de encabezado de la biblioteca, es decir, es muy probable que el archivo de implementación .cpp deba abandonarse.
Si realiza una definición antes de conectar el archivo de la biblioteca, esta definición será «visible» desde el archivo de encabezado de la biblioteca y se puede utilizar para declaraciones de compilación condicional.
Un punto importante: al crear una biblioteca, no se recomienda escribir código ejecutable en el archivo de encabezado fuera de la clase, porque esto dará lugar a errores al conectar la biblioteca en diferentes archivos. Para utilizar la «magia de las definiciones», debe formatear correctamente la implementación en el archivo de encabezado, vea un ejemplo:
Así es como puede hacerlo:
// lib.h class testClass { public: int func() {return 10;} private: };
Pero esto de abajo es imposible:
// lib.h class testClass { public: int func(); private: }; int testClass::func() { return 10; }
Bueno, aquí hay un ejemplo de cómo funciona una definición que «encaja» en la biblioteca:
testLib.h
#pragma once #include <Arduino.h> void printResult() { // si SEND_NUDES está definido #ifdef SEND_NUDES Serial.begin(9600); Serial.println("nudes"); #endif }
tesSketch
// definir SEND_NUDES // ANTES de conectar la biblioteca #define SEND_NUDES #include <testLib.h> void setup() { // imprime "nudes" si SEND_NUDES está definido printResult(); } void loop() { }
¿Por qué es necesario? La compilación condicional le permite controlar la compilación de su código, es decir, codificar qué partes del código se compilarán y cuáles no. Para obtener más detalles sobre los peligros y las complejidades de trabajar con define, incluida la creación de bibliotecas, lea la lección anterior sobre las directivas de preprocesador.