Arduino. Trucos y secretos.

Tekst
0
Recenzje
Przeczytaj fragment
Oznacz jako przeczytane
Czcionka:Mniejsze АаWiększe Aa

17. Utilizar arrays bidimensionales (matrices)

El lenguaje C define matrices como arrays de arrays. Una matriz de enteros se puede definir con la siguiente línea de texto:

int matriz [filas][columnas];

Las matrices pueden tener también más de dos dimensiones. Para acceder a los elementos se utilizan bucles for anidados.

Al detalle

Para gestionar mapas o conjuntos de datos de dos o más dimensiones, es preciso utilizar matrices. Una matriz es una tabla de datos, organizados en filas y columnas. Algunos lenguajes de programación cuentan con sistemas de gestión de alto nivel de este tipo de datos; en C, a menos que no escribamos nosotros una librería (o que la busquemos), debemos trabajar con arrays de arrays. Una matriz sería un array que contiene otros arrays. Para evitar confusiones, es recomendable que los arrays contenidos tengan todos el mismo tamaño. El lenguaje C permite definir un array multidimensional simplemente añadiendo el nuevo tamaño después de la declaración del array:

int matriz [4][4];

El primer índice especifica el número de filas y el segundo, el número de columnas. El array denominado matriz es una tabla de números con cuatro columnas y cuatro filas. La matriz también se puede inicializar con valores. Se escribe de la siguiente manera:

int matriz[4][4] = {

{1 ,2 ,3 , 4},

{11,12,13,14},

{21,22,23,24},

{31,32,33,34}

};

Es muy importante insertar correctamente los pares de llaves y las comas. Para evitar confusiones, es oportuno aplicar una sangría en varias líneas y añadir, si es necesario, espacios vacíos. Un array dimensional puede tener también más de dos dimensiones. Una matriz tridimensional se puede declara de este modo:

int cubo[3][3][3];

El acceso a los elementos se lleva a cabo mediante índices que siempre empiezan desde 0 y alcanzan una longitud máxima de menos 1. Para acceder al valor que ocupa la segunda fila, tercera columna, escribiremos:

int val = matriz[1][2];

Para trabajar con estructuras multidimensionales, se recomienda utilizar bucles for anidados que recorren filas y columnas. A continuación, puedes ver un sketch de ejemplo que recorre una matriz y muestra su contenido en el Serial Monitor:

int matriz[4][4] = { //[filas][columnas]

{1 , 2, 3, 4},

{11,12,13,14},

{21,22,23,24},

{31,32,33,34}

};

void setup(){

Serial.begin(9600);

for(int r = 0; r < 4; r++){ //fila

for(int c = 0; c < 4; c++){ //columna

int celda = matriz[r][c];

String str = String(celda) + " ";

Serial.print(str);

}//close for j

Serial.println("\n");

}//close for i

}

void loop(){}

Al inicio del sketch podemos ver la definición de una matriz de enteros con dieciséis elementos organizados en cuatro filas y cuatro columnas. La matriz se inicializa con valores numéricos. Todo el código se encuentra en la parte setup() y el loop() no se utiliza. El primer bucle for utiliza un contador denominado r que recorre las filas de la matriz y va de cero a tres. Dentro del cuerpo del primer bucle for, un segundo bucle gestiona el contador para las columnas, denominado c, que va de cero a tres. Dentro del cuerpo del segundo bucle for podemos utilizar las dos variables c y r para recorrer e indicar cada vez las celdas de la matriz que leeremos con:

int celda = matriz[r][c];

El valor leído se compone en una cadena y se muestra en el Serial Monitor. Al final del cuerpo del segundo bucle for encontramos una instrucción:

Serial.println("\n");

que sirve para escribir un «y a parte» (carácter "\n") y separar las filas mostradas por el bucle for interno.

18. Definir una prueba simple

La instrucción para realizar una prueba y evaluar si una determinada expresión es cierta es if. La forma más sencilla que puede tener un bloque de decisión es la siguiente:

if (expresión) {

//código a ejecutar

}

En el caso de que exista otra alternativa, el modo de escribirlo es: «Si la expresión es cierta haces una cosa; si no, haces otra», y la correspondiente sintaxis, que utiliza la instrucción else, es la siguiente:

if (expresión) {

//código a ejecutar

} else {

//código a ejecutar como alternativa

}

Una estructura de decisión puede incluir varias pruebas que confirman si una expresión es cierta o si lo es otra. Estos bloques pueden tener otras ramas creadas con else if y contar (o no) con un bloque final del tipo else para gestionar los casos restantes:

if (expresión1) {

//código para gestionar el caso 1

} else if (expresión2) {

//código para gestionar el caso 2

} else if (expresión3) {

//código para gestionar el caso 3

} else {

//código para los casos restantes

}

Al detalle

Resulta extraño que un programa tenga un flujo continuo y regular de principio a fin: es habitual que se tengan que medir variables o evaluar condiciones para, después, tomar decisiones. Una de las funciones principales y más importantes de un lenguaje de programación es la de poder «tomar decisiones», es decir, «realizar pruebas» evaluando si una determinada expresión es verdadera o falsa. No se admiten situaciones intermedias, porque estamos hablando de lógica «de cálculos» o «booleana». Existen muchos tipos de lógicas, entre las cuales algunas de particulares, más parecidas a nuestra manera de razonar, denominadas lógicas Fuzzi, donde no solo existe lo verdadero o lo falso, sino que también se admiten situaciones intermedias o con matices. Pero Arduino no dispone de ellas.

La forma más simple de prueba es el uso de la instrucción if, seguida del código por ejecutar en el caso de que la expresión que le ha sido proporcionada sea cierta. El código puede estar encerrado entre llaves, o bien escrito «en línea» inmediatamente después de if. Esta sería una sencilla instrucción if que escribe un mensaje en el Serial Monitor:

if (expresión) Serial.println("la expresión es verdadera");

La instrucción if puede «activar» otras operaciones, utilizando para ello un par de llaves:

if (expresión) {

Serial.println("la expresión es verdadera");

}

La expresión por evaluar puede ser cualquier cosa que devuelva un valor verdadero o falso. Por tanto, podría ser una variable de tipo booleano, una condición lógica o matemática a evaluar o bien el resultado de una función o de un comando, como el valor devuelto por una digitalRead(). Una variable booleana se define con:

bool prueba;

y puede ser inicializada o contener solo los tres valores true y false:

bool prueba = false;

prueba = true;

Los términos true y false son, en realidad, nombres de conveniencia o marcadores de posición para dos valores numéricos. El valor true corresponde, de hecho, a 1 y false a 0. Una variable de tipo bool puede ser utilizada directamente por una if:

bool prueba = true;

if (prueba) {

Serial.println("prueba es true");

//otras instrucciones…

}

En C existe el modificador ! que niega, es decir, invierte el valor de una expresión booleana. Para mostrar una cadena en el Serial Monitor, en el caso en que una determinada expresión no sea verdadera, se puede escribir así:

if (!prueba) {

Serial.println("prueba es false");

//otras instrucciones…

}

También se pueden llevar a cabo pruebas evaluando el valor de una determinada expresión. Estas valoraciones también pueden ser de tipo matemático y son muy frecuentes en los sketch de Arduino. Quizás deseas encender un LED solo cuando una variable asume un determinado valor: si el nivel es igual a 100, enciende el LED. La traducción de esta expresión presenta una pequeña dificultad. Cuando se evalúa la semejanza de una expresión en C, se debe utilizar un doble signo de igual, para distinguir una prueba de una asignación de valor a una variable:

if (nivel == 100) {

//instrucción a ejecutar

}

Esta escritura devuelve true solo cuando la variable nivel asume el valor «cien». En programación te encontrarás con varios tipos de condiciones. Por ejemplo, puedes tener que comprobar si el valor contenido en una variable es mayor que un número:

if (temperatura > 30) {

//enciende el ventilador

}

El ventilador se encenderá cuando la temperatura sea superior a treinta y, por tanto, a partir de treinta y uno.

Puedes comprobar cuando una variable es mayor o igual que un número colocando el símbolo igual (=) después del de mayor que (>):

 

if (temperatura >= 30) {

//enciende el ventilador

}

En este caso, el ventilador se encenderá cuando la temperatura sea igual o mayor que treinta. Un operador muy útil es el módulo (%), que se puede utilizar para obtener el resto de una división. Escrito así podría parecerte que no tiene nada de particular, pero es extremadamente práctico para distinguir números pares e impares, o bien para realizar recuentos periódicos. Así es como se identifica si un número es par o impar:

void setup() {

Serial.begin(9600);

for(int i = 0; i < 10; i++) {

Serial.print(i);

if ((i%2) == 0) {

Serial.println(" = par");

} else {

Serial.println(" = impar");

}

}

}

void loop(){

}

Dentro del bucle for, con i%2 calculamos el resto de la división de i entre 2. El resto de esta división podrá valer 0 o 1 y será 0 si el número es par. De este modo, reconocer si un número es par o impar es inmediato. El operador de módulo se puede utilizar también para crear recuentos que se repiten. Imagina que tienes un contador y quieres que un LED se encienda cada cuatro pasos. Una solución bastante intuitiva, aunque no demasiado elegante, es la de crear un contador secundario que vaya de uno a cuatro y que, después, vuelva a empezar. Cuando el contador es igual a cuatro, el LED se enciende.

int i = 0;

void setup() {

Serial.begin(9600);

}

void loop(){

i++;

Serial.print("i: ");

Serial.println(i);

if (i == 4) {

Serial.println("LED on");

i = 0;

}

}

Para mantener el recuento se necesita un índice principal, externo, creado con una variable i que aumenta en la sección loop() del sketch. Una instrucción if comprueba si el contador es igual a cuatro y, en el caso que lo sea, escribe en el Serial Monitor «LED on». Antes de salir del bloque de código del if, la variable i debe reiniciarse para poder retomar el recuento desde el principio. Con el operador módulo cuatro todo es mucho más sencillo porque no hay que reiniciar el contador cada cuatro pasos. El operador % realiza la división entre i y 4 y devuelve el resto, que será un número que va, periódicamente, del 0 al 3. Mira cómo modificar el sketch:

int i = 0;

void setup() {

Serial.begin(9600);

}

void loop(){

i++;

Serial.print("i: ");

Serial.println(i);

if ((i%4) == 3) {

Serial.println("LED on");

}

}

Ahora i seguirá aumentando, pero cada cuatro pasos aparecerá el texto «LED on».

A menudo tendrás que evaluar varias condiciones y la expresión será más complicada. En C es posible combinar varias expresiones, encerrándolas entre paréntesis y utilizando los operadores lógicos «y» (&&) y «o» (||). Para mostrar un determinado mensaje, si la variable i está comprendida entre tres y cinco deberás escribir:

if ((i >= 3) && (i <= 5)) {

Serial.println("i está en el intervalo");

}

Utiliza los paréntesis para separar las condiciones y poner orden.

Para realizar una operación si una entre varias condiciones es verdadera, haz esto:

int i = 10;

int j = 5;

int k = 6;

if ((i == 9) || (j == 5) || (k == 2)) {

Serial.println("¡OK!");

}

El programa mostrará «¡OK!» si i es igual a nueve, cinco o dos. Obviamente, puedes combinar entre sí distintos operadores lógicos.

Una prueba podría incluir una alternativa: si se verifica una determinada cosa, hago una cosa; si no, hago otra. Este comportamiento se traduce en la estructura if...then y es como si tu programa estuviera en una encrucijada: si ocurre una cosa, voy hacia un lado, y si no, voy hacia el otro.

int i = 10;

if (i == 5) {

Serial.println("i vale 5");

} then {

Serial.println("i no vale 5");

}

Si if...then es comparable a una encrucijada, puedes crear más alternativas con else if:

int i =5;

if (i == 0) {

Serial.println ("A \n");

} else if (i == 5) {

Serial.println ("B \n");

} else {

Serial.println ("C \n");

}

19. Definir una prueba con más de una alternativa

Para controlar el flujo de un programa muy complejo y ramificado, y evitar el uso de muchos if, resulta aconsejable utilizar switch, que evalúa una variable, de tipo int o char, y después elige entre las distintas posibilidades proporcionadas. Un switch es normalmente más rápido que una serie de if. Esta es la sintaxis:

switch(variable) {

case label1:

//código a ejecutar

break;

case label2:

//código a ejecutar

break;

case label3 :

case label4 :

case label5 :

//código a ejecutar para distintos valores unificados

break;

default :

// código a ejecutar en los casos no previstos

}

Al detalle

Un programa complejo puede requerir una larga lista de if para gestionar distintas situaciones. En el caso en que los if se refieran a una única variable, es oportuno utilizar el constructo switch, que es más elegante y más eficaz que una lista de if. switch evalúa una variable que puede ser de tipo int o char y, después, según el valor, salta a una de las secciones marcadas con «etiquetas». Las secciones están delimitadas por la etiqueta y por un break. La etiqueta es, en el caso de variables enteras, un número, mientras que en el caso de variables de tipo char, el símbolo del carácter, encerrado entre comillas simples (por ejemplo, ‘a’). Veamos un ejemplo para evaluar el valor de una variable entera:

int marcha = 1;

switch(marcha) {

case 1 :

Serial.println("Empieza");

break;

case 2 :

Serial.println("has puesto segunda");

break;

case 3 :

case 4 :

case 5 :

Serial.println("¡Brooom!");

break;

default :

Serial.println("¿Qué marcha llevas puesta?");

}

El constructo switch ejecuta una prueba inicial sobre la variable marcha y, después, hace que la ejecución del programa salte hasta la sección adecuada. Cada sección está delimitada por case...break. Como puedes ver, se pueden agrupar varias secciones, manteniendo break (como es el caso de 3, 4 y 5). La última sección del bloque switch es default, que se utilizaría en el caso de situaciones no previstas. Si marcha valiera «-1», terminarías en la sección default. El constructo switch puede parecer completamente igual que un listado de if...then pero, a diferencia de if, es más rápido porque ejecuta una única prueba, mientras que la lista de if requiere tantas pruebas como if presentes.

Si la variable de control de switch fuera de tipo char, no habría ninguna diferencia, solo habría que prestar atención en definir correctamente las etiquetas. Este es un ejemplo que descodifica un comando de tipo char recibido por una hipotética función leeComandoDesdeBluetooth():

char cmd;

cmd = leeComandoDesdeBluetooth();

switch (cmd){

case ΄a΄:

Serial.println("a");

break;

case ΄s΄:

Serial.println("s");

break;

default:

Serial.println("??");

}

20. Definir una función

Para crear una función debes:

•Indicar el tipo de dato que devolverá dicha función.

•Asignar a la función un nombre «unívoco» y significativo.

•Declarar los parámetros que se utilizarán como si fueran variables y encerrarlos entre paréntesis después del nombre.

•Añadir el cuerpo de la función, es decir, las operaciones que deberá llevar a cabo.

•Devolver el cálculo ejecutado mediante la palabra clave return.

A continuación, puedes ver el código de una función genérica que recibe dos parámetros de tipo int, los utiliza para realizar cálculos y, después, devuelve un tipo int:

int función(int a, int b) {

int res = 0;

//hace algo con los parámetros

return res;

}

Una función que no devuelve nada es un procedimiento y se define anteponiéndole la palabra clave void:

void procedimiento() {

//código a ejecutar

}

Un procedimiento también puede recibir parámetros:

void procedimiento(int param) {

//hace algo con param

}

Los parámetros de las funciones normalmente se pasan por valor, lo que significa que los valores de las variables se copiarán en el espacio de memoria de la función. Para permitir que la función modifique directamente las variables pasadas como parámetros, se utiliza el paso por referencia:

void función(int* n) {

*n = *n + 1;

}

Aquí, la llamada a la función se lleva a cabo anteponiendo la & al nombre de la variable:

fiunción(&variable);

De este modo se pasa a la función la dirección de la variable en lugar de su valor.

Al detalle

Los principiantes tienden a escribir todo el código en una secuencia. Este método instintivo funciona, pero produce un código confuso y difícil de mantener. Escribir programas como largas listas de instrucciones es una práctica poco eficaz, que puede funcionar para casos muy sencillos. Sin embargo, en cuanto las cosas se complican, gestionar un programa con esta estructura resulta imposible. Sin duda, tendrás partes que se repiten muchas veces que no podrás gestionar con un simple «copiar y pegar». Imagina que has duplicado varias veces un grupo de líneas que realizan una operación repetitiva: leen la fecha del sistema y la muestran de un modo en particular (año/mes/día). Si al cabo de un tiempo necesitas modificar el modo en que se muestra esta información (año/mes/día), deberás repasar el código de arriba a abajo, modificando todas las secciones donde aparece la fecha. Para evitar este tipo de problemas y organizar el código en partes aisladas y destinadas a llevar a cabo una tarea en concreto, se utilizan las funciones. Una función permite aislar un grupo de instrucciones, atribuirles un nombre conveniente, definir qué necesita para que funcione (los parámetros) y el tipo de resultados que producirá. Una función para sumar dos números tendrá esta forma:

int suma(int a, int b) {

int res = 0;

res = a + b;

return res;

}

Puedes insertar una función en cualquier parte del sketch. Yo, normalmente, prefiero insertarlas justo después de setup() y loop(). Al leer el código de la función anterior, obtenemos informaciones. La primera palabra con que te encuentras es «int», que especifica el tipo de dato devuelto. Inmediatamente después, tenemos el nombre de la función, que debería ser explicativo. Se recomienda utilizar nombres compuestos que expliquen para qué sirve la función: f012() no dice demasiado, pero calcula_fecha()

o leeSensorTemperatura() seguramente sí. Puedes utilizar varias letras separándolas con la inicial en mayúsculas, o con un guion. Las palabras deben estar unidas, no se pueden utilizar espacios. La función suma acepta dos parámetros, a y b, de tipo int. Los parámetros se utilizan para realizar cálculos y devolver un resultado de tipo int con la palabra clave return.

 

En resumen, para crear una función es preciso:

•indicar el tipo de dato que devolverá la función.

•bautizar la función con un nombre «unívoco» y significativo.

•declarar los parámetros que se utilizarán como si fueran variables y encerrarlos entre paréntesis inmediatamente después del nombre.

•añadir el cuerpo de la función, es decir, las operaciones que deberá llevar a cabo.

•devolver el cálculo ejecutado utilizando la palabra clave return.

En el caso de que algunas partes no se utilicen, estas se pueden excluir. Es posible tener una función que no devuelva ningún resultado y que se denomina procedimiento y su nombre va precedido por la palabra clave void, que significa «vacío». Un procedimiento puede tener o no tener parámetros. Dentro de un procedimiento, no encontraremos la palabra return. Este sería un procedimiento que no recibe ningún parámetro:

void errorFatal() {

Serial.println("¡¡¡¡¡Error fatal!!!!!");

}

loop() y setup() también tienen esta forma: son procedimientos que no devuelven nada y no disponen de parámetros. Aquí puedes ver el código de un procedimiento que recibe un único parámetro, una secuencia de caracteres que se mostrarán en el Serial Monitor:

void prt(char msg[]) {

Serial.println(msg);

}

Para llamar una función o un procedimiento, basta con escribir su nombre, proporcionando, si los tiene, los parámetros y recogiendo el resultado obtenido. Este sería un ejemplo de un sketch completo con funciones y procedimientos:

void setup() {

Serial.begin(9600);

errorFatal();

prt("hello world");

int n = suma(10, 20);

Serial.println(n);

}

void loop(){}

int suma(int a, int b) {

int res = 0;

res = a + b;

return res;

}

void prt(char msg[]) {

Serial.println(msg);

}

void errorFatal() {

Serial.println("¡¡¡¡¡Error fatal!!!!!");

}

Hasta ahora hemos utilizado las funciones proporcionando los parámetros, simplemente escribiéndolos entre paréntesis. Normalmente, los parámetros se pasan «por valor», lo que significa que la función recibe una copia de las variables proporcionadas y no modifica las variables originales. Este sistema sirve para evitar cambios en las variables de quien llama y puede evitar muchos problemas en el caso de que haya muchas modificaciones a la vez en una variable. Todas las modificaciones que se realizan sobre la copia de una variable desaparecen y se pierden si no se restituyen con return. Este comportamiento no siempre es el deseado y, en algunos casos, en cambio, sería conveniente poder modificar directamente las variables suministradas para que también se modifiquen en el entorno de quien llama. En C existe esta posibilidad y se denomina paso de parámetros «por referencia» o «por dirección».

Veamos un ejemplo. Crea un sketch y declara las dos funciones:

void fnz1(int n) {

n = n + 1;

}

void fnz2(int* n) {

*n = *n + 1;

}

Seguramente, has visto que Fnz2() es de tipo particular. El parámetro de entrada es de tipo int, pero va seguido de un asterisco *. Cada vez que se utiliza la variable n, delante de su nombre aparece el asterisco. Esta notación es necesaria para poder utilizar el paso de parámetros por referencia. Una variable es un pequeño espacio de memoria dentro de un ordenador. Para poder encontrarla, cada celda debe tener una dirección. Si se conoce la dirección de memoria de la celda resulta inmediato encontrarla dentro de la memoria. ¡Pero ten en cuenta que la dirección es diferente que el valor! La escritura int* sirve para definir un indicador de la celda y es un tipo especial que no contiene un valor, sino la dirección de la celda de memoria de tipo int. Si conoces la dirección, puedes acceder al valor de la celda con una escritura particular, es decir: *n. Normalmente, tratamos solo con los valores de las variables, pero podemos conocer su dirección insertando un símbolo especial: la «et» &. Dada la variable «temperatura», para conocer la dirección de la correspondiente celda de memoria, se utiliza: &temperatura.

Veamos cómo utilizar las dos funciones que acabamos de definir:

int m = 10;

fnz1(m);

Serial.print("m vale: ");

Serial.println(m);

fnz2(&m);

Serial.print("m vale: ");

Serial.println(m);

Utilizamos una variable m, que pasamos a la función fnz1(). Durante la llamada a fnz1() se crea una copia de la variable denominada n. La función utiliza n en su interior y la modifica. La variable «temporal» n, antes de volver al programa principal, vale 11. Cuando la función termina, todas las variables internas son destruidas y las modificaciones se pierden. Por esta razón, cuando el sketch escribe el valor de m, este es aún igual a 10. Con fnz2() las cosas son distintas porque le pasamos la dirección de la variable m (&m) en vez de su valor. Dentro de la función, para trabajar sobre el valor de la variable utilizaremos *n. Sin embargo, cuando la función termine, ¡los cambios permanecerán y la variable m valdrá 11! Si ejecutas el sketch, en el Serial monitor podrás leer:

m vale: 10

m vale: 11

¡Utiliza siempre con atención el paso de variables por dirección y solo si es estrictamente necesario! ¡Recuerda que trabajar directamente con las direcciones de memoria podría ser muy peligroso y producir efectos impredecibles!

Las variables definidas dentro de una función tienen una duración limitada: se crean en el momento en que se invoca la función y, cuando termina su ejecución, se destruyen. Esto significa que no mantienen su valor entre llamada y llamada a menos que se definen fuera de la función (cosa no muy elegante y, sobre todo, arriesgada). El lenguaje de programación C cuenta con el modificador static (que podría confundirse con const), que se utiliza para crear variables permanentes, memorizadas en porciones de memoria especiales. Una variable de este tipo, definida dentro de una función, mantiene su valor entre llamada y llamada porque cuando finaliza su ejecución no se destruye.

Prueba a ejecutar este sketch:

void setup(){

Serial.begin(9600);

}

void loop(){

int n = paso();

Serial.println(n);

delay(500);

}

int paso(){

static int movimientos;

movimientos++;

return movimientos;

}

La función paso utiliza una variable estática denominada movimientos. La variable se crea en la primera llamada de la función y después no se destruye. Al llamar a la función por segunda vez, volverá a encontrarse la variable ya inicializada y valorizada. En el Serial Monitor podrás ver, después, una larga secuencia de números crecientes.