* Dereference 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:

void.setup() { serial.begin (9600) } void.loop() { int numero; long L = 100000; numero = L; serial.println (numero); }

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:

int numero = 100; Serial.println (&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:

Serial.println ( (long)&numero);

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:

int numero 5 int *ptrNumero; ptrNumero = №

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:

int *ptrNumero; ptrNumero = № *ptrNumero = 7 ; Serial.println( numero);

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:

int *ptrNumero; ptrNumero = № Serial.println( *ptrNumero);

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:

void setup() { Serial.begin(9600); } void loop() { int k = 10; Serial.print("Desde loop k vale: "); Serial.println(k); dobla(k); Serial.println("..................."); delay(500); } void dobla(int k) { k = k * 2 ; Serial.print("Desde la función k vale: "); Serial.println(k); }

El resultado es esto:

COM6
Send
Desde loop k vale: 10 Desde la función k vale: 20 ................... Desde loop k vale: 10 Desde la función k vale: 20 ................... Desde loop k vale: 10 Desde la función k vale: 20
Autoscroll Show timestamp
Clear output
9600 baud  
Newline  

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:

int k = 10; void setup() { Serial.begin(9600); } void loop() { Serial.print("Desde loop k vale: "); Serial.println (k); doble( &k ); // Pasamos la direccion de k y no su valor Serial.println("..................."); delay(500); } void doble(int *k) {// Avisamos a la funcion de que recibira un puntero *k = *k * 2 ; Serial.print("Desde la funcion k vale: "); Serial.println (*k); }

El resultado es:

COM6
Send
Desde loop k vale: 10 Desde la funcion k vale: 20 ................... Desde loop k vale: 20 Desde la funcion k vale: 40 ................... Desde loop k vale: 40 Desde la funcion k vale: 80 ................... Desde loop k vale: 80 Desde la funcion k vale: 160 ................... Desde loop k vale: 160 Desde la funcion k vale: 320 ................... Desde loop k vale: 320
Autoscroll Show timestamp
Clear output
9600 baud  
Newline  

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:

char h[] = { 'P', 'r', 'u', 'e', 'b', 'a', '\n'} ; void setup() { Serial.begin(9600); } void loop() { for (int i = 0 ; i < 6 ; i++) Serial.print( h[i] ); Serial.flush(); exit(0); }

El resultado es este:

COM6
Send
Prueba
Autoscroll Show timestamp
Clear output
9600 baud  
Newline  

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:

char h[] = { 'P', 'r', 'u', 'e', 'b', 'a', '\n'} ; void setup() { Serial.begin(9600); } void loop() { for (int i = 0 ; i < 6 ; i++) Serial.print( *(h + i) ); Serial.flush(); exit(0); }

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?:

char h[] = { 'P', 'r', 'u', 'e', 'b', 'a', '\n'} ; void setup() { Serial.begin(9600); } void loop() { char *ptr = h ; for (int i = 0 ; i < 6 ; i++) Serial.print(*ptr++); Serial.flush(); exit(0); }

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
Please note: These are Amazon affiliate links. If you buy the components through these links, We will get a commission at no extra cost to you. We appreciate it.

※ OUR MESSAGES