Algo que pocas veces tenemos en cuenta es la alineación de las variables en la memoria RAM. Muchas veces, ni nos va, ni nos viene, aunque en ciertas ocasiones suele causar calentamientos de cabeza.
Tiene que ver con la forma que tiene la CPU para dialogar con la RAM y la arquitectura de éstas. Partimos del hecho de que pedir un dato a la memoria es algo lento, sí se hace muchos millones de veces por segundo, pero mientras viene o no viene el dato, la CPU simplemente espera. Ahora, tenemos que tener en cuenta:
- El diálogo CPU<–>Memoria es a través de palabras y la palabra, en un sistema de 32bit es de 32bit (4bytes), en uno de 64bit serán 8bytes, no en todos los procesadores es así, pero casi.
- Cada byte en memoria tiene una dirección, para que la CPU y la memoria puedan ubicar el dato.
- Por otra parte, las palabras en memoria tienen una posición que coincide con que su dirección sea divisible por la longitud de la palabra.
- Sólo podemos acceder a una palabra en un mismo instante.
Por tanto, en mi PC de 32bit, cuando quiero un int de 32bit, normalmente me lo traigo de direcciones que sean divisibles por 4, por lo tanto, sólo hago una lectura a la memoria; por ejemplo, mi dato ocupa desde la dirección 0x0030 a la 0x0033 (4bytes), me lo traigo de una sola tirada. De otro modo, si mi dato ocupara desde la direccion 0x0032 a la 0x0035 (4bytes también), tendría que hacer dos peticiones, la palabra 0x0030–0x0033 y la palabra 0x0034-0x0036, y luego coger los dos últimos bytes de una y los dos últimos bytes de la otra; cosa que por experimentar está bien, pero no es óptimo, perdemos mucho tiempo, tardamos el doble. Si nos queremos traer una variable de 8bytes, tendremos que hacerlo de dos tandas, y por diseño, es más fácil traérsela de una dirección de memoria divisible por 8
También tenemos que tener en cuenta que perdemos memoria, es decir, tendremos bytes en memoria que no usaremos para nada (y a veces no nos sobra precisamente), pero sólo podemos aguantarnos… u optimizar más nuestros programas (siempre que compense). Por ejemplo cuando declaramos dos variables, un short (2bytes) y un int(4 bytes), la primera de ellas (el short) se situará en memoria en la dirección 0x0030 y la segunda (el int) en la 0x0034, dejando 2 bytes dentro de la memoria inutilizados. Aunque los compiladores modernos, a la hora de declarar variables intentan hacerlo de la forma más inteligente posible, para aprovechar los huecos al máximo, aunque no declaremos las variables por orden, a la hora de situarlas en memoria, se suelen colocar bien:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | #include <stdio.h> int main() { char c,c2,c3,c4,c5,c6; int i; short k; long long i2; long long ai2; short k2; printf("%lX [c]\t\t(%lu bytes)\n", (long unsigned)&c, sizeof(c)); printf("%lX [c2]\t\t(%lu bytes)\n", (long unsigned)&c2, sizeof(c2)); printf("%lX [c3]\t\t(%lu bytes)\n", (long unsigned)&c3, sizeof(c3)); printf("%lX [c4]\t\t(%lu bytes)\n", (long unsigned)&c4, sizeof(c4)); printf("%lX [c5]\t\t(%lu bytes)\n", (long unsigned)&c5, sizeof(c5)); printf("%lX [c6]\t\t(%lu bytes)\n", (long unsigned)&c6, sizeof(c6)); printf("%lX [i]\t\t(%lu bytes)\n", (long unsigned)&i, sizeof(i)); printf("%lX [i2]\t\t(%lu bytes)\n", (long unsigned)&i2, sizeof(i2)); printf("%lX [k]\t\t(%lu bytes)\n", (long unsigned)&k, sizeof(k)); printf("%lX [ai2]\t\t(%lu bytes)\n", (long unsigned)&ai2, sizeof(ai2)); printf("%lX [k2]\t\t(%lu bytes)\n", (long unsigned)&k2, sizeof(k2)); return 0; } |
Nota: represento variables de tipo long unsigned para representar bien los valores en sistemas de 64bit.
El resultado es algo así:
BFAF2BCF [c] (1 bytes) BFAF2BCE [c2] (1 bytes) BFAF2BCD [c3] (1 bytes) BFAF2BCC [c4] (1 bytes) BFAF2BCB [c5] (1 bytes) BFAF2BCA [c6] (1 bytes) BFAF2BC4 [i] (4 bytes) BFAF2BB8 [i2] (8 bytes) BFAF2BC2 [k] (2 bytes) BFAF2BB0 [ai2] (8 bytes) BFAF2BAE [k2] (2 bytes)
Esto resultará lo siguiente:
BFAF2BA8 | BFAF2BAB | BFAF2BAC | k2 | k2 | BFAF2BAF | BFAF2BB0 | ai2 | ai2 | ai2 | ai2 | BFAF2BB3 | BFAF2BB4 | ai2 | ai2 | ai2 | ai2 | BFAF2BB7 | BFAF2BB8 | i2 | i2 | i2 | i2 | BFAF2BB7 | BFAF2BBC | i2 | i2 | i2 | i2 | BFAF2BBF | BFAF2BC0 | k | k | BFAF2BC3 | BFAF2BC4 | i | i | i | i | BFAF2BC7 | BFAF2BC8 | c6 | c5 | BFAF2BC7 | BFAF2BCC | c4 | c3 | c2 | c1 | BFAF2BCF |
El caso es que las variables siempre encajan.
Pero el tema es que cuando creamos un struct en C, lógicamente las variables también tienen que encajar, y además tienen que estar en el mismo orden en que las ponemos, el compilador, esta vez no tiene la libertad de antes de reubicar información.
Vemos el siguiente ejemplo:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | #include <stdio.h> typedef struct { short s1; /* 2 */ int i1; /* 4 */ long long l1; /* 8 */ int i2; /* 4 */ long long l2; /* 8 */ short s2; /* 2 */ char c1; /* 1 */ short s3; /* 2 */ } mi_struct; int main() { mi_struct e1; printf("%lX [e1]\t\t(%lu bytes)\n", (long unsigned)&e1, sizeof(e1)); printf("%lX [e1.s1]\t\t(%lu bytes)\n", (long unsigned)&e1.s1, sizeof(e1.s1)); printf("%lX [e1.i1]\t\t(%lu bytes)\n", (long unsigned)&e1.i1, sizeof(e1.i1)); printf("%lX [e1.l1]\t\t(%lu bytes)\n", (long unsigned)&e1.l1, sizeof(e1.l1)); printf("%lX [e1.i2]\t\t(%lu bytes)\n", (long unsigned)&e1.i2, sizeof(e1.i2)); printf("%lX [e1.l2]\t\t(%lu bytes)\n", (long unsigned)&e1.l2, sizeof(e1.l2)); printf("%lX [e1.s2]\t\t(%lu bytes)\n", (long unsigned)&e1.s2, sizeof(e1.s2)); printf("%lX [e1.c1]\t\t(%lu bytes)\n", (long unsigned)&e1.c1, sizeof(e1.c1)); printf("%lX [e1.s3]\t\t(%lu bytes)\n", (long unsigned)&e1.s3, sizeof(e1.s3)); return 0; } |
Y en este ejemplo sí que podemos ver gran diferencia entre 32bit y 64bit.
Tabla de contenidos
32bit
BFC5E510 [e1] (36 bytes) BFC5E510 [e1.s1] (2 bytes) BFC5E514 [e1.i1] (4 bytes) BFC5E518 [e1.l1] (8 bytes) BFC5E520 [e1.i2] (4 bytes) BFC5E524 [e1.l2] (8 bytes) BFC5E52C [e1.s2] (2 bytes) BFC5E52E [e1.c1] (1 bytes) BFC5E530 [e1.s3] (2 bytes)
BFC5E510 | e1.s1 | e1.s1 | ? | ? | BFC5E513 | BFC5E514 | e1.i1 | e1.i1 | e1.i1 | e1.i1 | BFC5E517 | BFC5E518 | e1.l1 | e1.l1 | e1.l1 | e1.l1 | BFC5E51B | BFC5E51C | e1.l1 | e1.l1 | e1.l1 | e1.l1 | BFC5E51F |
BFC5E520 | e1.i2 | e1.i2 | e1.i2 | e1.i2 | BFC5E523 | BFC5E524 | e1.l2 | e1.l2 | e1.l2 | e1.l2 | BFC5E527 | BFC5E528 | e1.l2 | e1.l2 | e1.l2 | e1.l2 | BFC5E52B | BFC5E52C | e1.s2 | e1.s2 | e1.c1 | ? | BFC5E52F |
BFC5E530 | e1.s3 | e1.s3 | ? | ? | BFC5E533 |
Ya de primeras vemos que la estructura ocupa 36bytes y la suma de sus partes (2 + 4 + 8 + 4 + 8 + 2 + 1 + 2 = 31 bytes), por lo que vemos que la estructura ocupa más.
Si vemos el mapa de memoria (la tabla de arriba), vemos cinco interrogaciones, es decir, 5 huecos donde no trabajamos, y sólo servirán para poder alinear los demás datos.
Vemos que las primeras interrogaciones están en BFC5E512 y es que lo próximo que tenemos que colocar es un entero, por ello nos desplazamos hasta BFC5E514. El siguiente byte problemático es justo después de un char, y lo siguiente será al final, porque se nos quedan algunos bytes «colgados» y no vamos a poder aprovechar.
64bit
7FFFB3652EF0 [e1] (40 bytes) 7FFFB3652EF0 [e1.s1] (2 bytes) 7FFFB3652EF4 [e1.i1] (4 bytes) 7FFFB3652EF8 [e1.l1] (8 bytes) 7FFFB3652F00 [e1.i2] (4 bytes) 7FFFB3652F08 [e1.l2] (8 bytes) 7FFFB3652F10 [e1.s2] (2 bytes) 7FFFB3652F12 [e1.c1] (1 bytes) 7FFFB3652F14 [e1.s3] (2 bytes)
En la tabla acortaré las direcciones de memoria:
B3652EF0 | e1.s1 | e1.s1 | ? | ? | e1.i1 | e1.i1 | e1.i1 | e1.i1 | B3652EF7 |
B3652EF8 | e1.l1 | e1.l1 | e1.l1 | e1.l1 | e1.l1 | e1.l1 | e1.l1 | e1.l1 | B3652EFF |
B3652F00 | e1.i2 | e1.i2 | e1.i1 | e1.i2 | ? | ? | ? | ? | B3652F07 |
B3652F08 | e1.l2 | e1.l2 | e1.l2 | e1.l2 | e1.l2 | e1.l2 | e1.l2 | e1.l2 | B3652F0F |
B3652F10 | e1.s2 | e1.s2 | e1.c1 | ? | e1.s3 | e1.s3 | ? | ? | B3652F17 |
De primeras vemos que en 64bit la estructura ocupa 40bytes en lugar de 31bytes… pero esta vez, vemos cómo a la hora de colocar un long int (8bytes) en B3652F04, se lo lleva a B3652F08, así será capaz de leerlo en una lectura a memoria (de la palabra entera de 8bytes) en lugar de 2 lecturas (de 4bytes cada una en dos palabras diferentes).
¿Cuándo debemos tener esto en cuenta?
Esto tampoco tiene mucha importancia si sólo vamos a almacenar una pequeña información en un registro, pero, si vamos a almacenar un array de muchos elementos de un registro, o una lista enlazada, podremos observar que si en el registro tenemos 9bytes que sólo sirven para hacer bulto, es decir, para conseguir alinear la información, en un millón de elementos, estaremos perdiendo cerca de 9Mb, nos puede ayudar a optimizar un poco la información que almacenamos.
Por otra parte, si lo que estamos haciendo es implementar una lectura/escritura de información en un formato determinado y, por ejemplo, tenemos:
- 3 bytes de identificación
- 4 bytes de control
- 4 bytes tamaño
- resto de archivo de información
No podremos crear un registro del tipo:
1 2 3 4 5 6 | struct { char identif[3]; int control; int size; } TFormato; |
Ya que desde el último carácter hasta el entero de control habrá 1byte de diferencia y el formato no se respetará correctamente, por lo que seguramente haya algún fallo.
Esto tampoco quiere decir que no se pueda leer una variable que no esté alineada, siempre podemos hacerlo jugando con los bytes y trabajando un poco con el código, aunque las arquitecturas modernas lo permiten directamente con instrucciones especiales, eso sí, no se recomienda mucho su uso, ya que es más lento hacer dos peticiones a memoria, y luego trabajar con el resultado de las dos para unirlo; por otra parte, si trabajamos con un procesador que no disponga de estas instrucciones, tendremos que programar cómo hacemos las peticiones y la fusión de la información, aunque de eso se encargue el compilador, debemos tenerlo en cuenta si queremos que nuestro programa sea rápido.
Un pequeño ejemplo de un acceso no alineado
Si queremos provocar un acceso no alineado en C, podemos hacer lo siguiente: crear un array de char más o menos grande, y un puntero a un entero que apunte a una dirección dentro del array de char (con que apunte a la dirección base+1 vale para que no esté alineado.
1 2 3 4 5 6 7 8 9 10 11 12 13 | #include <stdio.h> int main() { unsigned char datos[6]; int *punt; printf("%lX (%lu)\n", (unsigned long)&datos, sizeof(datos)); punt=(int*)&datos[1]; printf("%lX (%lu)\n", (unsigned long)punt, sizeof(*punt)); *punt=123456789; printf("%d %d %d %d => %d %X <= %X %X %X %X\n", datos[1], datos[2], datos[3], datos[4], *punt, *punt, datos[4], datos[3], datos[2], datos[1]); } |
La salida es algo así (en 64bits, aunque sólo se nota en el tamaño de las direcciones de memoria):
7FFFC98BE8B0 (6) 7FFFC98BE8B1 (4) 21 205 91 7 => 123456789 75BCD15 <= 7 5B CD 15
Primero mostramos la dirección de memoria base del array y el tamaño del mismo, luego la dirección de memoria base de la variable entera con la que trabajaremos y su tamaño (muy importante el sizeof(*punt), sin el * nos daría el tamaño del puntero, y estamos en 64bit, es decir, sería de 8bytes.
Y tras ello, asignaremos al entero el valor 123456789, podremos ver el valor en decimal de cada uno de los bytes que componen el entero, el valor del entero, y luego el valor del mismo en hexadecimal acompañado de los valores en hexadecimal de cada uno de los bytes de antes.
Esto está diseñado para verse en un sistema little endian, en un sistema big endian, la última línea de salida sería:
7 91 205 21 => 123456789 75BCD15 <= 15 CD 5B 7
Foto: wwarby (Flickr)
Pingback: BlogESfera.com /
Está muy interesante, no pensaba que ocurriera esto con lenguajes de alto nivel como C ( suponía que da alguna forma lo «allanaban» )
Y está allanado, aquí sobre todo lo cuento desde el punto de vista de la optimización y la lectura de formatos de archivo. Es un hecho que tenemos que tener muy en cuenta en algunos ensambladores, y normalmente cuando programamos en cualquier lenguaje no nos afecta, pero si queremos cuidar nuestro código y optimizarlo es algo que deberíamos tener en cuenta.
Pingback: Poesía binaria » Leyendo archivos de imagen en formato BMP en C /