Gestión de memoria en C. Parte IV. Memoria dinámica.

gestión de memoria dinámica

Este es el cuarto artículo de esta serie y quizás el más abstracto y complicado de entender. En un primer momento hablamos de variables y tipos de datos en un lenguaje fuertemente tipado como es C. Lo aprendido es la base sobre la que se construye todo código programado en C. Son los ladrillos. Y las casas o progrmas se implementan colocando paredes y tejados que serían los arrays y estructuras. Que son colecciones de variables o ladrillos.

Si tomáramos el plano del edificio, cada pared y cada ladrillo está colocado y referenciado. El mapa de un programa es la memoria volátil del equipo donde se ejecuta el programa. Y las medidas métricas son las «casillas» cuadriculadas y numeradas en formato hexadecimal. Las direcciones de memoria que apuntan al primer ladrillo de cada estructura.

Una de las características del lenguaje C, es que, a pesar de ser un lenguaje de alto nivel de abstracción, ofrece la capacidad de trabajar a bajo nivel mediante direcciones de memoria y punteros. Otros lenguajes pueden trabajar con punteros, pero la gestión está más declarada en las librerías. Siendo más restringido y protegido el acceso a la memoria.

Para trabajar con punteros o direcciones de memoria, debemos recordar que las variables son contenedores de valores que se asignan en la memoria en una determinada posición. Esa posición es la dirección de la variable en el mapa de memoria. Cuando declaramos una variable, sea del tipo que sea, el Sistema Operativo asigna una porción de memoria de ese tamaño y lo referencia al identificador que lo declara. Si recuperamos la dirección del identificador añadiendo & delante de este, obtenemos el puntero.

Cuando queremos «anidar» variables de un mismo tipo o de diferentes, declaramos arrays o estructuras. El espacio asignado en este caso será la suma de los tamaños de todos los tipos. El puntero será tal que referencie al primer byte del espacio asignado. Y para acceder a los valores internos nos valdremos de las características del contenedor declarado.

Este modo de trabajar se denomina memoria estática. Ya que la asignación la realiza el OS en una posición determinada con un tamaño y un empaquetamiento definidos en la declaración.

Por contra, definimos memoria dinámica cuando trabajamos con un conjunto asignado de la memoria sin estructura previa.

Que quiere decir esto. Pues que cuando le solicitamos al OS un espacio en memoria y lo hacemos mediante directivas de memoria dinámica, lo que hacemos es solicitar un número determinado de bytes. Ese espacio asignado puede ser recorrido, modificado o redimensionado a voluntad del programador. Siempre que no se exceda del tamaño asignado, lo que produciría errores de tipo «segmentation fault» o lo que suele denominarse «un punterazo».

Para trabajar con memoria dinámica debemos cumplir con tres pasos importantes. Primero solicitar la asignación de memoria. Una vez obtenido el puntero al inicio de la asignación podemos trabajar con ella. Y siempre debemos, al finalizar el trabajo, liberar el espacio asignado para no cometer «memory leaks» que consuman los recursos del sistema.

Asignación o reserva de memoria

En C++ y el trabajo con clases y objetos, podemos solicitar asignación de memoria directamente. Pero en C solo existen dos formas de hacerlo. Mediante las primitivas malloc y calloc. Estas funciones nos permiten solicitar un espacio en memoria de un tamaño determinado y con un empaquetamiento determinado en caso de conocerlo de antemano.

Para hacer una solicitud de memoria dinámica con malloc debemos ser coherentes entre el tamaño solicitado y el tipo de dato que queremos. Los denominados buffer de información que se transmiten en los mensajes de comunicaciones suelen, por ejemplo, construirse de tamaño byte (caracteres sin signo o unsigned char). Pero si se va a manejar un conjunto de valores de tipo entero o float, el espacio debe ser un múltiplo del tamaño para que todos los posibles valores tengan espacio suficiente para almacenarse.

Campos necesarios para trabajar con la primitiva de memoria dinámica malloc.

Esta instrucción reserva un espacio de tamaño dado en la memoria. En el caso de la imagen, se va a tratar como un conjunto de enteros y por eso debemos hacer un cast a ese tipo de dato. El valor de la dirección del primer byte de la memoria asignada se almacenará en el identificador del puntero declarado. Hay que tener en cuenta que tanto el tipo del puntero como el del casteo deben ser iguales y coherentes con el tamaño en bytes de la solicitud.

Una reserva en memoria dinámica realiza, por tanto, en un solo paso, la reserva de memoria de trabajo y su referencia al valor del puntero declarado.

Asignación de memoria estática frente asignación de memoria dinámica en el mapa de memoria

La diferencia entre malloc y calloc es que la primera solo reserva la memoria mientras que la segunda asigna un valor determinado a todo el espacio de memoria. Podríamos decir que calloc además de reservar el espacio lo inicializa en la propia declaración de este.

Reserva y asignación del valor 0 de un espacio de memoria con calloc

La asignación de memoria dinámica mediante malloc es, en cierto sentido, igual a la declaración de un array de tamaño N. Pero al trabajar con ella nos desplazaremos directamente sobre la memoria incrementando la dirección contenida en el puntero como si no conociéramos el tipo, solo el tamaño. Otra ventaja es que el array se declara con un tamaño fijo N que no puede ser modificado. En cambio, trabajando con memoria dinámica, podemos incrementar el tamaño con la primitiva realloc.

Trabajando con memorias dinámicas

La modificación del tamaño asignado a un espacio de memoria dinámica se denominar reasignación de memoria. Esto es debido a que entre la asignación y el cambio de tamaño el OS puede haber ocupado el espacio posterior en el mapa de memoria para el mismo y otro proceso en ejecución. Este espacio por tanto no puede ser «ocupado» y tampoco se puede fragmentar la asignación por restricciones del propio OS. La solución en este caso es asignar otro espacio de trabajo. Por eso la primitiva realloc cambia la construcción con respecto a las primitivas anteriores, necesitando un puntero inicial y devolviendo otro que puede ser el mismo o estar modificado.

Reasignación de memoria por ocupación del espacio posterior.

Una de las ventajas de trabajar con memoria dinámica es que no está sujeta de forma restrictiva al tipo de contenedor. Podemos formatear la información contenida en un espacio asignado entre tipos estáticos o estructuras propias. Incluso podemos declarar un espacio mediante el tipo especial «void», que podríamos definir como «nada», y que nos permite asignar memoria con un identificador no tipado de tamaño byte. De esta forma podremos, en el espacio de trabajo, cambiar los modos de gestión de los punteros.

Declaración de un buffer tipo void y cast a otros tipos de datos.

Otras acciones que se pueden realizar sobre estos espacios de memoria dinámica mediante funciones de las librerías de C son la asignación por bloque, la copia, el desplazamiento de memoria y la comparación binaria.

  • memset: asigna el mismo valor a todos los elementos desde el puntero de inicio hasta la dirección asignada. Este tamaño no tiene que ser el tamaño de la asignación, pero no debe ser mayor.
  • memcpy: copia byte a byte de una dirección de memoria a otra
  • memmove: mueve el contenido de una dirección de memoria a otra
  • memcomp: compara byte a byte dos espacios de memoria.

Con estas instrucciones, y el desplazamiento de direcciones por medio de bucles podemos actuar sobre la memoria de forma más directa que desde las funciones estáticas de los lenguajes. Incluso podemos realizar las mismas funciones de maneras diferentes según convenga. La primitiva calloc es equivalente a la concatenación de las funciones malloc y memset. Pero la primera depende de la gestión de tareas del OS para su aplicación mientras que la concatenación de tareas requiere dobles pasadas con doble tiempo de ejecución. O realloc puede ser equivalente a una nueva asignación seguida de un memmove si estamos seguros de que se va a realizar una reasignación del puntero.

La elección más optimizada de funciones dependerá de las características del compilador y la arquitectura del OS donde se ejecute la aplicación.

Liberación de memoria

El trabajo con memoria dinámica nos permite liberarnos de las restricciones del tipado del lenguaje y jugar con el tamaño de los recursos. Pero esa restricción de tipado de la librería gestiona en segundo plano la asignación y desasignación de memoria. Así como el control del desplazamiento en memoria y el desbordamiento de esta. Por eso, cuando trabajamos con memoria dinámica, debemos tener mucho cuidado al desplazar los puntero y sobre todo, al finalizar el trabajo, debemos liberar la memoria mediante la primitiva free.

Tipos de errores

Como ya hemos comentado, trabajar con puntero, ya sea con memoria estática o dinámica puede causar problemas en tiempo de ejecución por el direccionamiento y la asignación del mapa de memoria. Los errores más comunes se producen al intentar acceder a posiciones de memoria fuera de la asignación inicial. Si tras la memoria reservada se encuentran campos del mismo programa, se producirá una corrupción de memoria al modificar valores sin acceder a los identificadores declarados modificando por tanto los valores esperados. Si la memoria adyacente ha sido asignada a otro proceso, el intento de acceso a esto queda rechazado y se produce un error de segmentación que terminará la ejecución del programa de forma descontrolada.

Error de segmentación al desplazar un puntero a una posición asignada a otro programa

Al trabajar con punteros también debemos tener en cuenta la localidad de las variables. Una memoria reservada se desasignará al terminar la localidad del identificador. Aunque retornemos la dirección de memoria de la variable o estructura, si esta desaparece al cerrarse una función, esta se desasigna y puede causar corrupciones o fallos de segmentación si modificamos esta posteriormente.

Reserva de memoria por un array local en tiempo de ejecución de una función

Error al acceder a esa posición de memoria fuera de la localidad de la variable

Este tipo de errores se puede solicionar mediante el uso de parámetros, escalando la localidad del objeto o utilizando funciones de memoria dinámica para mantener la reserva fuera de la función.

Otro tipo de errores comunes al trabajar con memoria dinámica son los memory leaks o consumo de memoria por no desasignar los punteros que ya no se vayan a utilizar. Si un programa termina sin liberar los recursos asignados, el OS los liberará al cerrar la ejecución. Pero si la no liberación de punteros se prolonga, el programa puede consumir todos los recursos de memoria del sistema obligándolo a cerrarse.

Memoria no desasignada por liberación del puntero.


Referencias:

  • Material de clase preparado para las asignaturas de Programación impartidas en los grados de UFV

Deja un comentario