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);
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.
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("...................");
}
void dobla(int k)
{ k = k*2 ;
Serial.print("Desde la función k vale: ");
Serial.println (k);
}
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:
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("...................");
}
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);
}
{
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("...................");
}
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:
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)
{ Serial.begin(9600);}
void loop()
{
char h[] = { 'P','r','u','e',''b,'a','\n'} ;
for (int i=0 ; i < 6 ; i++)
Serial.print( h[i] );
Serial.flush();
exit(0);
}
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:
void loop()
{
char h[] = { 'P','r','u','e','b','a','\n'} ;
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?:
void setup()
{ Serial.begin(9600);}
void loop()
{
char h[] = { 'P','r','u','e','b','a','\n'} ;
char *ptr = h ;
for (int i=0 ; i < 9 ; i++)
Serial.print(*ptr++);
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.
Saludos Manuel, excelente la explicación, apenas para nosostros los "Pobres Mortales".
ResponderEliminarVení, por favor cambiá la figura segunda del Monitor Serie, pues es la misma anterior, y de primerazo confunde!!!
Mao.