& Reference Operator
Descripción
Los punteros son uno de los temas más complicados para los principiantes en el aprendizaje de C, y es posible escribir la gran mayoría de los programas de Arduino sin tener que encontrarse con los punteros. Sin embargo, para la manipulación de ciertas estructuras de datos, el uso de punteros puede simplificar el código, y el conocimiento de la manipulación de punteros es útil para tener en la propia caja de herramientas.
Bien usados, los punteros son excepcionalmente útiles para resolver cierto tipo de problemas, pero aprender a manejarlos puede provocar serios dolores de cabeza, especialmente cuando tratas de depurar un programa que se niega a funcionar como debe.
Como funcionan las variables
Los punteros son uno de los temas más complicados para los principiantes en el aprendizaje de C, y es posible escribir la gran mayoría de los programas de Arduino sin tener que encontrarse con los punteros. Sin embargo, para la manipulación de ciertas estructuras de datos, el uso de punteros puede simplificar el código, y el conocimiento de la manipulación de punteros es útil para tener en la propia caja de herramientas.
Bien usados, los punteros son excepcionalmente útiles para resolver cierto tipo de problemas, pero aprender a manejarlos puede provocar serios dolores de cabeza, especialmente cuando tratas de depurar un programa que se niega a funcionar como debe.
Como paso previo repasaremos como funcionan las variables
En primer lugar debemos entender que la memoria del Arduino (como cualquier memoria) está numerada en posiciones. Cada posición de memoria tiene una dirección única, que debe ser especificada cuando queremos leer o escribir su valor.
Si miramos el tipo de memoria de los distintos Arduinos, vemos que, por ejemplo, el Uno dispone de 32 K de memoria Flash y de 2 K de RAM para almacenar los programas.
Cuando definimos una variable, el compilador le asigna una posición en la memoria RAM. Si la variables es del tipo char o byte asigna un byte de memoria, si es del tipo int le asigna dos posiciones de memoria y si es un tipo long le asigna 4 posiciones de memoria..
Si declaramos la variable:
int numero
le decimos al compilador que vamos a utilizar una variable que se llama numero.
Si esta variable la definimos como:
numero = 10
le decimos al compilador que la variable anteriormente definida tiene un valor de 10.
Declarar y definir una variable son dos operaciones distintas. Mediante la declaración asignamos un nombre a la variable y mediante la definición asignamos un valor a dicha variable. Así que manejamos dos conceptos diferentes, por lo que el compilador, ante esta situación, crea un espacio de memoria donde escribe el nombre de la variable y por otro lado asigna físicamente una o más direcciones de memoria para contener el valor de esta variable.
Así, si el compilador le asigna a la viaaible numero la posición de memoria 2050 y graba en ella el valor que le hemos asignado de 10, tendremos:
NOMBRE | DIRECCIÓN DE MEMORIA | CONTENIDO |
---|---|---|
Numero | 2050 | 10 |
C++ nos exige que declaremos las variables antes de usarlas para reservar el espacio dependiendo del tipo y cuando les asignemos valores escribe los escribe en la posición indicada.
Esto nos da una idea de lo que puede suceder si escribimos un valor long en una dirección de memoria que corresponde a un int. Como el tipo long ocupa 4 bytes, cuando intentemos meterlos en una dirección a la que se ha asignado 2 bytes, va a ocupar el contenido de las siguientes posiciones de memoria, que pueden estar usadas por otros satos. Prueba esto:
Esto es un error que no es detectado por el compilador, sencillamente lo ignora y nos dice que la variable numero tiene asignado de valor de -31072, que es lo que resulta de los dos últimos bytes de 100000 en binario, con signo. Además, acabamos de escribir en posiciones de memoria contiguas, que pueden corresponder a otra variable o peor aún, a un puntero a una función.
En el caso de que el valor de L fuera inferior a lo que cabe en un int con signo, es decir, inferior a 215 (un int son 15 bits de datos más uno de signo) no nos daríamos cuenta del problema, pero cuando supere ese valor nos devuelve valores absurdos, muy difíciles de depurar.
En el caso de que afecte a la dirección de una función el desastre es mucho mayor, porque en algún momento el programa intentará ejecutar dicha función con un salto a una dirección que es sencillamente basura y el Arduino se colgará.
Los punteros en C++
Una vez comprendida la diferencia entre la dirección y el contenido de una variable estamos ya preparados para entender los punteros (Pointers en inglés). Un puntero es, simplemente, un tipo de datos que contiene la dirección física de algo en el mapa de memoria.
Cuando declaramos un puntero se crea una variable de tipo pointer, y cuando le asignamos el valor, lo que hacemos es apuntarlo a la dirección física de memoria, donde se encuentra algo concreto, sea un int, un long o cualquier cosa que el compilador entienda.
Como en Arduino UNO el mapa de memoria es de menos de 64 k, los punteros que especifican una dirección de memoria se codifican con 16 bits o 2 bytes (216 = 65.536 > 32.768). En los PCs que disponen de Gigas de memoria, los punteros deben necesariamente ser mayores para poder indicar cualquier posición de memoria.
Una curiosidad de los punteros, es que o bien tienen una dirección de 16 bits en Arduino, o contienen basura, pero no hay más opciones.
Porque aunque pueden apuntar a tipos de diferente longitud, la memoria en la que empiezan se sigue definiendo con 16 bits (Aunque el tipo indica cuantos bytes hay que leer para conseguir el dato completo).
Naturalmente si tenemos una variable como numero en Arduino, podemos conseguir la dirección en la que esta almacenada, con el operador ‘&’, sin más que hacer &numero:
Lamentablemente, esto sí que generará un aviso por parte del compilador diciendo que el tema es ambiguo y tenemos que hacer un cast de tipo de la siguiente manera:
Que en mi caso me responde diciendo 2290 pero en el vuestro puede ser otro.
Un cast consiste en forzar la conversión de un tipo en otro, y se efectúa precediendo a la variable que queremos forzar por el tipo que deseamos entre paréntesis.
En realidad un puntero es sencillamente otro tipo de datos que contiene una dirección de memoria y cuando entiendes esto, comprendes que puedes definir punteros, por si mismos, para usarlos de diferentes maneras.
Para declarar un puntero usamos el operador ‘*’, basta con declararlo precedido de un *:
Que significa, crea un puntero a un int llamado p_data.
Aquí es donde la cosa se empieza a complicar. Aunque el puntero a un int es una dirección, lo mismo que un puntero a un long, es imprescindible indicarle al compilador a qué vamos a apuntar, para que sepa cuantos bytes tiene que leer o escribir cuando se lo pidamos.
Si leemos un long donde hay un int, leeremos basura. Si escribimos un long donde hay un int corrompemos el valor de otras posibles variables y estamos en el caso que definimos antes con las variables.
A los nombres de los punteros, se les aplican las mismas reglas que a los nombres de variables o funciones, pero conviene dejar claro que es un puntero para que quien lo lea no se despiste y malinterprete el programa.
Así es muy frecuente que al nombre de los punteros se les empiece por algo como “p_” o “ptr”, que siempre ayuda tener las cosas claras (y porque los errores con los punteros suelen ser desastrosos).
Nos surge entonces una pregunta ¿Cómo asigno la dirección de una variable, por ejemplo, a un puntero que he creado?. Pues de nuevo, muy fácil:
Como &numero nos da la dirección física donde se almacena numero, basta sencillamente con asignarla a ptrNumero. El operador “&” precediendo a una variable devuelve su dirección de memoria.
Ahora ptrNumero apunta a la dirección donde está almacenada la variable numero. ¿Podría usar esta información para modificar el valor almacenado allí?. Por supuesto:
Veréis que la respuesta en la consola al imprimir numero es 7. Hemos usado un puntero para modificar el contenido de la celda a la que apunta usando el operador *.
Podemos asignar un valor a la posición a la que apunta un puntero, basta con referirse a él con el * por delante. Y si queremos leer el contenido de la posición de memoria a la que apunta un puntero usamos el mismo truco:
El resultado será 5.
Vamos a recapitular las ideas básicas:
- Un puntero es una variable que apunta a una dirección concreta de nuestro mapa de memoria.
- Para conocer la dirección concreta de donde algo está almacenado, basta con preceder el nombre de ese algo con el operador “&” y esa es su dirección, que podemos asignar a un puntero previamente definido (del mismo tipo).
- Usamos el operador “*” precediendo al nombre del puntero, para indicar que queremos leer o escribir en la dirección a la que apunta, y no, cambiar el valor del puntero.
Mucho cuidado con lo siguiente. La instrucción
*ptrNumero = 7;
Tiene todo el sentido del mundo, pues guarda un 7 en la dirección al que el valor de ptrNumero apunta. Pero en cambio
ptrNumero = 7;
Es absurda, porque acabamos de apuntar a la dirección de memoria número 7. Si escribimos algo en una posición de memoria cuyo uso desconocemos podemos corromper una parte del programa.
Salvo que sepamos con certeza que apuntando a la dirección vamos a modificar algo que hay en la dirección 7 del mapa de memoria.
¿Porque usar punteros?
La razón más importante para usar punteros es que los argumentos que pasamos a las funciones se pasan por valor, es decir que una función no puede cambiar el valor de la variable que le pasamos. Prueba esto:
El resultado es esto:
Como la variable k del programa principal y la k de la función doble son de ámbito diferente, no pueden influirse la una a la otra.
Cuando llamamos a la función doble(k), lo que el compilador hace es copiar el valor de k y pasárselo por valor a la función, pero no le dice a la función la dirección de la variable k. De ese modo aislamos el ámbito de las dos variables. Nada de lo que se haga en doble influirá en el valor del k de la función principal.
A veces puede interesarnos que una función modifique el valor de la variable. Podríamos definir una variable global y con eso podríamos forzar a usar la misma variable para que modifique su valor. El problema es que a medida que los programas crecen, el número de variables globales tienden al infinito, y seguirlas puede complicarse mucho.
Otra solución limpia y elegante es pasar a una función la dirección de la variable y ahora la función sí que puede modificar el valor de esta, Prueba esto:
El resultado es:
Al pasarle la variable por referencia, la función doble() sí que ha podido modificar el contenido de la variable, y en cada ciclo la función doble.
Podemos pasar a una función tantos parámetros como quisiéramos, pero solo puede devolvernos un valor.
Pero con los punteros podemos pasar tantas variables como queramos por referencia a una función de modo que no necesitamos que nos devuelva múltiples valores, ya que la función puede cambiar múltiples variables.
Los punteros en las Matrices (Arrays)
Vamos a tratar el uso de punteros en las matrices (Arrays). Probemos este programa:
El resultado es este:
Si no usas Serial.Flush, probablemente no veras el mensaje completo. La razón es que lo que envías por el puerto serie, se transmite en bloques y no carácter a carácter.
Por eso, si quieres garantizar que todo se ha enviado usa flush() antes de salir con exit(0).
Hemos utilizado p como una matriz de char y usado un loop para recorrerlo e imprimirlo. Nada nuevo en esto. Pero hagamos un pequeño cambio en el programa:
Hemos cambiado la línea:
Serial.print( h[i] );
Por esta otra:
Serial.print( *(h + i)) );
Y el resultado es… exactamente lo mismo. ¿Por qué?
Pues porque una matriz es una colección de datos, almacenada en posiciones consecutivas de memoria (y sabemos el tamaño de cada dato porque lo hemos declarado como char o int o cualquier otro tipo), y lo que el compilador hace cuando usamos el nombre de la matriz con un índice como h[i] es apuntar a la dirección de memoria donde empieza la matriz y sumarle el índice multiplicado por la longitud en bytes, de los datos almacenados, en este caso 1 porque hemos declarado un char).
En realidad es otra forma de decir lo mismo. Como esto:
Serial.print(*( h+ i * sizeof(char)));
Cuando usamos h+i, el compilador entiende que sumemos i al puntero que indica el principio de la matriz, y por eso, al usar el operador *, busca el contenido almacenado en h+i, que es exactamente lo mismo que la forma anterior.
Así que si usas una matriz, sin índice, lo que en realidad estás haciendo es pasar un puntero al comienzo en memoria de la matriz, o sea, su dirección de inicio.
¿Y qué te parece esto?:
Pues es lo mismo, pero en ese estilo típicamente críptico que caracteriza algunos de los aspectos más oscuros de C++. Declaramos sobre la marcha un puntero que apunta a h con:
char *ptr = h ;
Utilizamos el bucle para contar simplemente, pero *ptr significa el contenido al que ptr apunta, o sea h, y después de imprimirlo, incrementamos ptr, con lo que apunta al siguiente char de la matriz
Esta última forma es elegante, eficaz y conciso. Pero a cambio también es difícil de leer, más difícil de comprender ya que será alabado por los iniciados pero siempre hay que pensar en aquellos que van a leer nuestros programas.
※ ARDUINO BUY RECOMMENDATION
Arduino UNO R3 | |
Arduino Starter Kit |