← Programación en C para Sistemas Operativos Bloque 2 · Tipos, operadores y control · Tema 5 de 42

Tema 5

Tipos de datos y su tamaño

Para la memoria, todo son bytes. Lo que convierte cuatro bytes en el número −1082130432, en el 3212836864 o en el −1,0, es el tipo con que el programa los mira. En C, a diferencia de Bash o de Python, el programador elige el tipo de cada dato, y con él cuántos bytes ocupa, qué valores puede tener y qué pasa cuando se sale de ellos. En este tema recorremos los tipos básicos del lenguaje, medimos su tamaño con sizeof, vemos cómo se guardan los números con y sin signo, en qué orden quedan los bytes en la memoria, y por qué los programas de sistema usan tipos como uint32_t, size_t o pid_t en lugar de un simple int.

1. Bytes y tipos

La memoria de una computadora es una larga fila de bytes, cada uno con su dirección, y cada byte tiene 8 bits: puede guardar un número entre 0 y 255. Nada en la memoria dice qué significan esos bytes. Este programa toma cuatro bytes fijos y los copia, sin cambiarlos, en tres variables de tipos distintos:

mismosbytes.c
#include <stdio.h>
#include <string.h>

int main(void)
{
    unsigned char bytes[4] = {0x00, 0x00, 0x80, 0xbf};
    int entero;
    unsigned int sin_signo;
    float real;

    memcpy(&entero, bytes, 4);
    memcpy(&sin_signo, bytes, 4);
    memcpy(&real, bytes, 4);

    printf("como int:          %d\n", entero);
    printf("como unsigned int: %u\n", sin_signo);
    printf("como float:        %f\n", real);
    return 0;
}
terminal · los mismos cuatro bytes, tres valores
$ gcc -std=c17 -Wall -Wextra -o mismosbytes mismosbytes.c && ./mismosbytes
como int:          -1082130432
como unsigned int: 3212836864
como float:        -1.000000
$ printf '\x00\x00\x80\xbf' | od -A d -t x1 -t d4 -t u4 -t f4
0000000  00  00  80  bf
            -1082130432
             3212836864
             -1.0000000
0000004

memcpy, de string.h, copia bytes de un lugar a otro sin interpretarlos. Los tres resultados son correctos, y los tres son el mismo dato: un entero con signo muy negativo, un entero sin signo de más de tres mil millones, o el número real −1. La segunda orden lo confirma sin escribir C: od tiene opciones para leer los mismos bytes como cada uno de esos tipos. Esa es la idea central del tema:

El tipo de un dato determina cuántos bytes ocupa en la memoria y cómo se interpretan esos bytes: qué valores representan y qué operaciones se pueden hacer con ellos. En C, cada variable tiene un tipo fijo, que el programador declara y el compilador conoce al compilar.

En Bash, todo era texto. En Python, el intérprete elige la representación y la cambia cuando hace falta: un entero crece sin límite. En C, el tipo es una decisión del programador, y tiene consecuencias. Un sistema operativo necesita esa precisión: una cabecera de un paquete de red, una entrada de una tabla de archivos o un registro de un dispositivo tienen un formato exacto, byte por byte, y el programa tiene que poder describirlo.

2. Los tipos enteros

C tiene cinco tipos enteros básicos, de menor a mayor, y cada uno en versión con signo y sin signo:

TipoMínimo que garantiza el estándarEn Linux x86-64Uso habitual
char8 bits1 byteCaracteres y bytes sueltos.
short16 bits2 bytesRaro hoy; formatos de archivo y de red.
int16 bits4 bytesEl entero de uso general: contadores, índices pequeños, códigos de retorno.
long32 bits8 bytesValores grandes; en Linux, del tamaño de una dirección.
long long64 bits8 bytesCuando hacen falta al menos 64 bits en cualquier plataforma.

Lo que el estándar promete es poco: un tamaño mínimo para cada tipo, y que cada uno sea al menos tan grande como el anterior. Un int de 16 bits es perfectamente legal, y existió en las computadoras de los años ochenta; hoy sigue existiendo en microcontroladores pequeños. Que en Linux un int tenga 32 bits es una decisión de la plataforma, no del lenguaje. Esto tiene una consecuencia directa: un programa que supone que int tiene 4 bytes es correcto en Linux, pero no es portable a cualquier sistema.

Cada tipo se puede escribir de varias formas equivalentes. short es short int, long es long int, unsigned solo es unsigned int, y signed es redundante salvo con char, como veremos. En el curso usamos las formas cortas.

3. El operador sizeof

sizeof da el tamaño en bytes de un tipo o de una expresión. Este programa mide los tipos básicos en nuestra plataforma:

tamanos.c
#include <stdio.h>

int main(void)
{
    printf("char        %zu\n", sizeof(char));
    printf("short       %zu\n", sizeof(short));
    printf("int         %zu\n", sizeof(int));
    printf("long        %zu\n", sizeof(long));
    printf("long long   %zu\n", sizeof(long long));
    printf("float       %zu\n", sizeof(float));
    printf("double      %zu\n", sizeof(double));
    printf("long double %zu\n", sizeof(long double));
    printf("void *      %zu\n", sizeof(void *));
    printf("size_t      %zu\n", sizeof(size_t));
    return 0;
}
terminal · Linux en x86-64
$ gcc -std=c17 -Wall -Wextra -o tamanos tamanos.c && ./tamanos
char        1
short       2
int         4
long        8
long long   8
float       4
double      8
long double 16
void *      8
size_t      8
El tamaño de los tipos en Linux x86-64, en bytes: char ocupa 1, short 2, int 4, long 8 y un puntero 8 byte 0 1 2 3 4 5 6 7 char 1 byte short 2 bytes int 4 bytes long 8 void * 8
Cada casilla es un byte. En Linux de 64 bits, un long y una dirección de memoria ocupan lo mismo: 8 bytes.

Algunos detalles de sizeof que conviene conocer desde ahora:

terminal · noeval.c: sizeof no ejecuta su expresión
$ cat noeval.c
#include <stdio.h>
int main(void)
{
    int x = 5;
    size_t t = sizeof(x++);
    printf("t = %zu, x = %d\n", t, x);
    return 0;
}
$ gcc -std=c17 -Wall -Wextra -o noeval noeval.c && ./noeval
t = 4, x = 5

4. 32 y 64 bits: los modelos de datos

Cada plataforma decide el tamaño de int, long y los punteros, y esa combinación se llama modelo de datos. Los nombres indican qué tipos tienen 32 o 64 bits: LP64 significa que Long y Punteros tienen 64.

Modelointlonglong longpunteroDónde
ILP324484Linux y Windows de 32 bits, muchos sistemas embebidos.
LP644888Linux, macOS y los demás Unix de 64 bits.
LLP644488Windows de 64 bits.

La diferencia que más problemas causa es la de long: un programa que guarda una dirección o un tamaño de archivo en un long funciona en Linux de 64 bits y falla en Windows, donde long sigue teniendo 32. Y en todos los modelos, int tiene 32 bits, aunque la máquina sea de 64: ampliarlo habría desperdiciado memoria en millones de programas que no lo necesitaban. La solución a esta incertidumbre son los tipos de stdint.h, de la sección 10.

Con el paquete gcc-multilib instalado, la opción -m32 compila para el modelo ILP32 en la misma máquina, y tamanos.c muestra entonces 4 bytes para long y para los punteros. Es una forma barata de probar si un programa depende de los tamaños de su plataforma.

5. Con y sin signo

Un tipo sin signo (unsigned) usa todos sus bits para la magnitud: con 8 bits, va de 0 a 255. Un tipo con signo tiene que representar también los negativos, y todas las computadoras actuales lo hacen de la misma forma, el complemento a dos, que C23 convirtió en obligatoria. La regla es simple: el bit más alto vale lo mismo que en el número sin signo, pero negativo. En 8 bits, ese bit vale −128 en lugar de 128. Este programa muestra los bits de varios valores de un byte, leídos de las dos formas:

signo.c
#include <stdio.h>

/* muestra los 8 bits de un byte, del más alto al más bajo */
static void bits(unsigned char b)
{
    for (int i = 7; i >= 0; i--)
        putchar((b >> i) & 1 ? '1' : '0');
}

int main(void)
{
    signed char valores[] = {0, 1, 2, 127, -128, -2, -1};

    for (int i = 0; i < 7; i++) {
        signed char v = valores[i];
        unsigned char u = (unsigned char) v;

        bits(u);
        printf("   con signo: %4d   sin signo: %3d\n", v, u);
    }
    return 0;
}
terminal · un byte, leído con y sin signo
$ ./signo
00000000   con signo:    0   sin signo:   0
00000001   con signo:    1   sin signo:   1
00000010   con signo:    2   sin signo:   2
01111111   con signo:  127   sin signo: 127
10000000   con signo: -128   sin signo: 128
11111110   con signo:   -2   sin signo: 254
11111111   con signo:   -1   sin signo: 255

La función bits usa operadores que veremos en el tema 8; lo que importa ahora es la tabla. Mientras el bit más alto está apagado, las dos lecturas coinciden. Cuando se enciende, la lectura con signo resta 256: 11111111 es 255 sin signo y −1 con signo. Por eso −1 tiene «todos los bits encendidos», como vimos con return -1 en el tema 3, que llegaba a la shell como 255. Y por eso el rango con signo es asimétrico: de −128 a 127, porque el cero ocupa uno de los valores no negativos.

La regla vale para cualquier tamaño: con n bits, un tipo sin signo va de 0 a 2n − 1, y uno con signo de −2n−1 a 2n−1 − 1. La otra gran diferencia entre ambos es qué pasa al salirse del rango:

terminal · wrap.c: 255 + 1 en un unsigned char, y 0 − 1 en un unsigned int
$ ./wrap
0 4294967295

Los tipos sin signo dan la vuelta, y está garantizado: la aritmética sin signo es aritmética módulo 2n. Restar 1 a 0 da el mayor valor posible. En los tipos con signo, en cambio, el desbordamiento es comportamiento indefinido, como vimos con INT_MAX + 1 en el tema 1. Mezclar los dos tipos en una misma expresión tiene además una trampa, que el tema 7 explica en detalle:

terminal · comp.c: compara int i = -1 con unsigned int u = 1
$ gcc -std=c17 -Wall -Wextra -o comp comp.c
comp.c: In function ‘main’:
comp.c:6:11: warning: comparison of integer expressions of different signedness: ‘int’ and ‘unsigned int’ [-Wsign-compare]
    6 |     if (i < u)
      |           ^
$ ./comp
-1 >= 1 ?!

Al comparar un int con un unsigned int, C convierte el primero a sin signo, y −1 se convierte en 4294967295, que es mayor que 1. El compilador lo advirtió gracias a -Wextra. La regla práctica: usa tipos sin signo para lo que no puede ser negativo por naturaleza, como tamaños, bytes o máscaras de bits, y no mezcles tipos con y sin signo en comparaciones.

6. Los límites

Como los tamaños dependen de la plataforma, el encabezado limits.h define constantes con el rango de cada tipo. Un programa que necesita saber el mayor int no debería escribir 2147483647, sino INT_MAX:

terminal · limites.c: los valores de limits.h en Linux x86-64
$ ./limites
CHAR_BIT   = 8
SCHAR_MIN  = -128
SCHAR_MAX  = 127
UCHAR_MAX  = 255
CHAR_MIN   = -128
SHRT_MIN   = -32768
SHRT_MAX   = 32767
USHRT_MAX  = 65535
INT_MIN    = -2147483648
INT_MAX    = 2147483647
UINT_MAX   = 4294967295
LONG_MIN   = -9223372036854775808
LONG_MAX   = 9223372036854775807
ULONG_MAX  = 18446744073709551615

Los nombres siguen un patrón: el prefijo del tipo (SCHAR, UCHAR, SHRT, INT, LONG, LLONG, con U delante para los sin signo) y el sufijo _MIN o _MAX. Los tipos sin signo no tienen _MIN, porque es siempre 0. CHAR_BIT es la cantidad de bits de un byte: 8 en cualquier sistema que vayas a usar, aunque el estándar admite más.

Las constantes no salen de la nada. El limits.h de glibc las toma del compilador, que las conoce como macros predefinidas, las que listamos con gcc -dM -E en el tema 4:

terminal · de dónde sale INT_MAX
$ grep -n 'define INT_MAX' /usr/lib/gcc/x86_64-linux-gnu/15/include/limits.h
120:#define INT_MAX __INT_MAX__
$ gcc -dM -E - < /dev/null | grep '__INT_MAX__'
#define __INT_MAX__ 0x7fffffff

0x7fffffff es un 0 seguido de 31 unos: el mayor número con signo de 32 bits. Leer los límites en hexadecimal ayuda a ver su forma: INT_MIN es 0x80000000, el bit más alto solo, y UINT_MAX es 0xffffffff, todos los bits encendidos.

7. El tipo char

char es un entero de un byte, y su nombre engaña: no guarda «caracteres» en un sentido especial, sino números, que por convención se interpretan como códigos de caracteres. 'A' es simplemente otra forma de escribir 65, el código ASCII de la letra A:

caracter.c
#include <stdio.h>

int main(void)
{
    char letra = 'A';
    char alto = (char) 0xe9;          /* el byte 233 */

    printf("'A' vale %d, y sizeof 'A' es %zu\n", letra, sizeof 'A');
    printf("letra + 1 = %d, que es '%c'\n", letra + 1, letra + 1);
    printf("el byte 0xe9 guardado en un char vale %d\n", alto);
    if (alto < 0)
        printf("en esta plataforma, char tiene signo\n");
    else
        printf("en esta plataforma, char no tiene signo\n");
    return 0;
}
terminal · el mismo programa, con el char por omisión y con -funsigned-char
$ gcc -std=c17 -Wall -Wextra -o caracter caracter.c && ./caracter
'A' vale 65, y sizeof 'A' es 4
letra + 1 = 66, que es 'B'
el byte 0xe9 guardado en un char vale -23
en esta plataforma, char tiene signo
$ gcc -std=c17 -Wall -Wextra -funsigned-char -o caracter caracter.c && ./caracter
caracter.c: In function ‘main’:
caracter.c:11:14: warning: comparison is always false due to limited range of data type [-Wtype-limits]
   11 |     if (alto < 0)
      |              ^
'A' vale 65, y sizeof 'A' es 4
letra + 1 = 66, que es 'B'
el byte 0xe9 guardado en un char vale 233
en esta plataforma, char no tiene signo

Hay tres sorpresas en esa salida:

Por eso C tiene en realidad tres tipos de un byte: char, signed char y unsigned char. La convención es usar char para texto y unsigned char para bytes crudos, como los de un archivo binario o un paquete de red, donde un valor de 0 a 255 es lo natural. Con eso, las letras con tilde del UTF-8, cuyos bytes están por encima de 127, como vimos con od en el tema 3, no se vuelven negativas al procesarlas byte por byte.

8. El tipo de las constantes

Los números escritos en el código también tienen tipo. Por omisión, un entero es un int; si no cabe en int, el compilador elige el primer tipo más grande en el que quepa. Los sufijos fuerzan otro tipo:

terminal · lit.c: sizeof de 100, 100L, 3000000000, 100u y 1.5f
$ ./lit
4 8 8 4 4
ConstanteTipoComentario
100intEl tipo por omisión.
100LlongSufijo L; LL para long long.
3000000000longNo cabe en un int, y el compilador lo agranda solo.
100uunsigned intSufijo U; se combina: 100UL.
0x1F, 017, 0b101intHexadecimal, octal (¡un cero adelante!), y binario desde C23.
1.5, 1.5fdouble, floatCon punto decimal es double; el sufijo f lo hace float.
'A'intUna constante de carácter.

El octal merece una advertencia: un cero adelante cambia la base. 017 no es diecisiete, sino quince. Es la notación que usa chmod para los permisos, como 0644, y la usaremos así en el curso de archivos; fuera de ese caso, nunca escribas un número con ceros a la izquierda.

9. El orden de los bytes

Un int ocupa cuatro bytes consecutivos, pero ¿en qué orden se guardan? El estándar no lo dice, y los procesadores no se ponen de acuerdo. Este programa guarda el número 0x12345678, cuyos bytes son fáciles de reconocer, y los muestra en el orden en que quedaron en la memoria:

orden.c
#include <stdio.h>
#include <string.h>
#include <stdint.h>

int main(void)
{
    uint32_t numero = 0x12345678;
    unsigned char bytes[4];

    memcpy(bytes, &numero, sizeof numero);
    printf("numero = 0x%08x\n", numero);
    for (int i = 0; i < 4; i++)
        printf("byte %d (dirección más %d): 0x%02x\n", i, i, bytes[i]);
    return 0;
}
terminal
$ ./orden
numero = 0x12345678
byte 0 (dirección más 0): 0x78
byte 1 (dirección más 1): 0x56
byte 2 (dirección más 2): 0x34
byte 3 (dirección más 3): 0x12
$ lscpu | grep -i 'byte order'
Byte Order:                           Little Endian
El número 0x12345678 en la memoria: en little-endian, las direcciones crecientes guardan 78, 56, 34 y 12; en big-endian, 12, 34, 56 y 78 dirección +0 +1 +2 +3 little-endian x86-64, casi todo ARM 78 56 34 12 big-endian el orden de la red 12 34 56 78
El mismo número, en los dos órdenes. En verde, el byte menos significativo, 0x78.

En x86-64 el byte menos significativo, 0x78, queda primero, en la dirección más baja: es el orden little-endian, «el extremo pequeño primero». Otros procesadores usan el orden inverso, big-endian, que es el que parece natural al leer el número de izquierda a derecha. Explica también lo que vimos en mismosbytes.c: los bytes 00 00 80 bf forman el número 0xbf800000, leído al revés.

Mientras un programa solo use sus datos en la memoria, el orden no se nota. Importa cuando los bytes salen de la máquina: al escribir un archivo binario que otra computadora leerá, o al enviar datos por la red. Los protocolos de internet usan big-endian, el llamado orden de red, y POSIX ofrece funciones para convertir, como htonl («host to network long»), que aparecerán en el curso de redes.

10. Los tipos exactos de stdint.h

Cuando el tamaño importa, «un int, que en Linux tiene 32 bits» no alcanza. C99 agregó stdint.h, con tipos cuyo nombre dice exactamente su tamaño:

TipoSignificaPara qué
int8_t, int16_t, int32_t, int64_tExactamente 8, 16, 32 o 64 bits, con signo.Formatos binarios, protocolos, registros de hardware.
uint8_t … uint64_tLo mismo, sin signo.Bytes, máscaras, direcciones IP, sumas de verificación.
int_least16_tEl tipo más pequeño de al menos 16 bits.Ahorrar memoria en arreglos grandes, con portabilidad total.
int_fast16_tEl tipo más rápido de al menos 16 bits.Contadores en bucles críticos.
intptr_t, uintptr_tUn entero capaz de guardar una dirección.Hacer cuentas con direcciones, en el bloque 4.
intmax_t, uintmax_tEl entero más grande disponible.Imprimir tipos del sistema de tamaño desconocido.

No son tipos nuevos, sino alias de los básicos, creados con typedef, que veremos en el tema 26. Cada plataforma los define según sus tamaños:

terminal · int32_t e int64_t, en glibc para x86-64
$ grep -n 'typedef.*int32_t;\|typedef.*int64_t;' /usr/include/x86_64-linux-gnu/bits/stdint-intn.h
26:typedef __int32_t int32_t;
27:typedef __int64_t int64_t;
$ grep -n '__int32_t;\|__int64_t;' /usr/include/x86_64-linux-gnu/bits/types.h | head -n 2
41:typedef signed int __int32_t;
44:typedef signed long int __int64_t;

En esta plataforma, int32_t es un int e int64_t es un long; en Windows, int64_t sería un long long. El programa escribe int64_t y funciona igual en los dos. Imprimirlos con printf tiene una dificultad: el formato correcto depende de qué tipo básico hay detrás, %ld o %lld. inttypes.h resuelve el problema con macros que se pegan a la cadena de formato:

exactos.c
#include <stdio.h>
#include <stdint.h>
#include <inttypes.h>

int main(void)
{
    int8_t   pequeno = INT8_MAX;
    uint16_t puerto  = 8080;
    int64_t  grande  = INT64_MAX;
    uint64_t bytes   = UINT64_C(5000000000);

    printf("int8_t   %zu byte,  máximo %" PRId8 "\n", sizeof pequeno, pequeno);
    printf("uint16_t %zu bytes, puerto %" PRIu16 "\n", sizeof puerto, puerto);
    printf("int64_t  %zu bytes, máximo %" PRId64 "\n", sizeof grande, grande);
    printf("uint64_t %zu bytes, valor  %" PRIu64 "\n", sizeof bytes, bytes);
    printf("int_fast16_t ocupa %zu bytes\n", sizeof(int_fast16_t));
    printf("intptr_t     ocupa %zu bytes\n", sizeof(intptr_t));
    return 0;
}
terminal
$ ./exactos
int8_t   1 byte,  máximo 127
uint16_t 2 bytes, puerto 8080
int64_t  8 bytes, máximo 9223372036854775807
uint64_t 8 bytes, valor  5000000000
int_fast16_t ocupa 8 bytes
intptr_t     ocupa 8 bytes

PRId64 es una macro que vale "ld" en Linux de 64 bits, y la cadena "máximo %" PRId64 "\n" se convierte, por la unión de cadenas literales del tema 3, en "máximo %ld\n". Es incómodo de escribir, pero correcto en cualquier plataforma. Los límites siguen el mismo patrón que en limits.h: INT8_MAX, UINT64_MAX; y las macros como UINT64_C dan a una constante el tipo correcto. Fíjate también en int_fast16_t: en x86-64 ocupa 8 bytes, porque glibc considera que las operaciones de 64 bits son las más rápidas en ese procesador.

11. Los tipos del sistema

Las funciones de la biblioteca y las llamadas al sistema usan sus propios nombres de tipos, que también son alias. Aparecen en el SYNOPSIS de casi toda página de las secciones 2 y 3 del manual, y conviene conocerlos antes de llegar a ellos:

sistema.c
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <time.h>

int main(void)
{
    size_t  largo = strlen("sistema operativo");
    ssize_t escritos = write(1, "hola\n", 5);
    pid_t   yo = getpid();
    time_t  ahora = time(NULL);

    printf("size_t  %zu bytes: strlen devolvió %zu\n", sizeof largo, largo);
    printf("ssize_t %zu bytes: write devolvió %zd\n", sizeof escritos, escritos);
    printf("pid_t   %zu bytes: mi pid es %jd\n", sizeof yo, (intmax_t) yo);
    printf("off_t   %zu bytes\n", sizeof(off_t));
    printf("uid_t   %zu bytes\n", sizeof(uid_t));
    printf("time_t  %zu bytes: ahora es %jd\n", sizeof ahora, (intmax_t) ahora);
    return 0;
}
terminal
$ gcc -std=c17 -Wall -Wextra -o sistema sistema.c && ./sistema
hola
size_t  8 bytes: strlen devolvió 17
ssize_t 8 bytes: write devolvió 5
pid_t   4 bytes: mi pid es 560
off_t   8 bytes
uid_t   4 bytes
time_t  8 bytes: ahora es 1792101965
TipoRepresentaLo usanFormato
size_tUn tamaño o una cantidad de bytes; sin signo.sizeof, strlen, malloc, read%zu
ssize_tUn tamaño, o −1 para indicar error; con signo. Es de POSIX.read, write%zd
pid_tUn identificador de proceso.getpid, fork, kill%jd con (intmax_t)
uid_t, gid_tIdentificadores de usuario y de grupo.getuid, chown%ju con (uintmax_t)
off_tUna posición dentro de un archivo.lseek, stat%jd con (intmax_t)
time_tUn instante, en segundos desde el 1 de enero de 1970.time, stat%jd con (intmax_t)

La forma de write resume por qué existen dos tipos para tamaños: le pasamos cuántos bytes escribir como size_t, que no puede ser negativo, y nos devuelve cuántos escribió como ssize_t, que puede ser −1 si falla, la convención del tema 2. Para los tipos cuyo tamaño POSIX no fija, como pid_t, no existe un formato propio; lo portable es convertirlos al mayor entero, intmax_t, e imprimirlo con %jd.

Usar estos nombres en lugar de int o long no es un capricho. Le dice al lector qué representa el valor, y deja que cada sistema elija el tamaño adecuado. time_t es el ejemplo clásico: en los sistemas de 32 bits era un entero de 32 bits con signo, que se desborda el 19 de enero de 2038, el llamado «problema del año 2038». Los programas que usaban time_t se arreglan recompilando con un time_t de 64 bits; los que guardaron la hora en un int, no.

12. Punto flotante y bool

Para números con decimales, C tiene float (4 bytes), double (8 bytes) y long double (16 bytes en x86-64, aunque usa solo 10). Se guardan en punto flotante, una especie de notación científica en binario, según el estándar IEEE 754, y eso tiene consecuencias que sorprenden a todo programador alguna vez:

flotante.c
#include <stdio.h>
#include <float.h>

int main(void)
{
    double suma = 0.1 + 0.2;
    float grande = 16777217;          /* 2^24 + 1 */

    printf("0.1 + 0.2        = %.17f\n", suma);
    printf("¿es igual a 0.3? %s\n", suma == 0.3 ? "sí" : "no");
    printf("16777217 en float = %.1f\n", grande);
    printf("float:  %d dígitos, épsilon %g\n", FLT_DIG, FLT_EPSILON);
    printf("double: %d dígitos, épsilon %g\n", DBL_DIG, DBL_EPSILON);
    printf("1.0 / 0.0 = %f\n", 1.0 / 0.0);
    return 0;
}
terminal
$ ./flotante
0.1 + 0.2        = 0.30000000000000004
¿es igual a 0.3? no
16777217 en float = 16777216.0
float:  6 dígitos, épsilon 1.19209e-07
double: 15 dígitos, épsilon 2.22045e-16
1.0 / 0.0 = inf

0,1 no tiene representación exacta en binario, igual que 1/3 no la tiene en decimal, y la suma acumula el error: nunca se comparan números de punto flotante con ==. Un float tiene unos 7 dígitos significativos, y ya no distingue 16777217 de 16777216. Y la división por cero no detiene el programa, sino que da infinito. En la programación de sistemas el punto flotante se usa poco: tamaños, direcciones, contadores y tiempos se guardan en enteros. Cuando haga falta, usa double, no float.

Para los valores de verdad, C99 agregó el tipo _Bool, que solo guarda 0 o 1, y el encabezado stdbool.h, que define los nombres cómodos bool, true y false. En C23 los tres son palabras clave y el encabezado ya no hace falta. Un bool ocupa un byte, y cualquier valor distinto de cero que se le asigne se convierte en 1: bool b = 42; guarda 1. Por tradición, las funciones de C, incluidas las del sistema, siguen usando int para las condiciones, con 0 como falso y cualquier otro valor como verdadero.

13. Cada tipo con su formato

printf no sabe qué tipos recibe: el formato se lo dice, y si el formato no coincide con el argumento, el resultado es comportamiento indefinido. -Wall detecta casi todos los desacuerdos, como vimos con aviso.c en el tema 2. Esta tabla reúne los formatos de los tipos de este tema; el tema 10 explica las opciones de ancho y precisión:

TipoFormatoTipoFormato
int%dunsigned int%u, %x
long%ldunsigned long%lu, %lx
long long%lldunsigned long long%llu
char como letra%csigned char como número%hhd
size_t%zussize_t%zd
intmax_t%jduintmax_t%ju
int64_t"%" PRId64uint32_t"%" PRIu32
double%f, %guna dirección%p

14. Elegir el tipo correcto

Con tantas opciones, unas pocas reglas cubren casi todos los casos que aparecerán en el curso:

Para guardar…UsaPor qué
Un número entero cualquiera, de rango moderadointEs el tipo natural del procesador, y el que esperan la mayoría de las funciones.
Un tamaño, una longitud, un índice de un arreglosize_tEs lo que devuelven sizeof y strlen, y cubre cualquier tamaño posible en memoria.
Un resultado de read o writessize_tPuede ser −1.
Un campo de un formato binario o de reduint8_t, uint16_t, uint32_t…El tamaño es parte del formato, y no puede depender de la plataforma.
Bytes crudosunsigned char o uint8_tValores de 0 a 255, sin sorpresas de signo.
TextocharEs lo que esperan todas las funciones de cadenas.
Un identificador o un tiempo del sistemapid_t, uid_t, time_t, off_tEl sistema decide su tamaño, y el nombre documenta el significado.
Un valor de verdadbool o intbool es más claro; int, más tradicional.
Un número con decimalesdoubleMás precisión que float, sin costo apreciable.

15. Resumen

16. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. En mismosbytes.c, ¿cómo pueden los mismos cuatro bytes valer −1082130432, 3212836864 y −1.0?

Porque la memoria solo guarda bytes, y es el tipo el que dice cómo interpretarlos. Leídos como entero con signo, el bit más alto encendido los hace negativos; como entero sin signo, todos los bits suman; y como float, siguen el formato IEEE 754, en el que esos bytes representan −1.

2. ¿Es correcto un compilador en el que int ocupa 2 bytes? ¿Qué garantiza el estándar?

Sí. El estándar solo garantiza que int tenga al menos 16 bits, y que cada tipo entero sea al menos tan grande como el anterior: char ≤ short ≤ int ≤ long ≤ long long. Que en Linux tenga 32 bits es una decisión de la plataforma.

3. ¿Por qué sizeof(x++) no incrementa x?

Porque sizeof se resuelve al compilar, a partir del tipo de la expresión, sin ejecutarla. El compilador solo necesita saber que x++ es un int para reemplazar todo por la constante 4.

4. ¿Qué diferencia hay entre los modelos LP64 y LLP64, y qué problema puede causar?

En LP64 (Linux, macOS), long y los punteros tienen 64 bits; en LLP64 (Windows de 64 bits), los punteros tienen 64 pero long sigue teniendo 32. Un programa que guarda una dirección o un tamaño grande en un long funciona en Linux y pierde información en Windows.

5. ¿Qué valor tiene el byte 11111111 con signo y sin signo, y por qué?

Sin signo, 255: la suma de todos los bits. Con signo, en complemento a dos, −1: el bit más alto vale −128 en lugar de 128, y −128 + 127 = −1.

6. ¿Por qué -1 < 1u es falso?

Porque al comparar un int con un unsigned int, C convierte el int a sin signo, y −1 se convierte en el mayor valor sin signo, 4294967295, que es mayor que 1. -Wextra lo advierte con -Wsign-compare.

7. ¿Por qué se recomienda unsigned char para procesar bytes crudos, y no char?

Porque el signo de char depende de la plataforma: en x86-64 tiene signo, y los bytes de 128 a 255 se leen como negativos, lo que rompe comparaciones y cuentas; en ARM no lo tiene. unsigned char da siempre valores de 0 a 255.

8. ¿Cuánto vale sizeof 'A' en C, y por qué?

4 en Linux, porque en C una constante de carácter como 'A' tiene tipo int, no char. Es solo otra forma de escribir el número 65.

9. En x86-64, ¿en qué orden quedan en la memoria los bytes de 0x12345678, y cuándo importa?

En orden little-endian: 78 56 34 12, el byte menos significativo en la dirección más baja. Importa cuando los bytes salen de la máquina, al escribir archivos binarios o enviar datos por la red, donde el orden estándar es big-endian.

10. ¿Por qué existe PRId64, en lugar de imprimir un int64_t con %ld?

Porque int64_t es un alias de distintos tipos básicos según la plataforma: long en Linux de 64 bits, long long en Windows o en sistemas de 32 bits, y cada uno necesita un formato distinto. PRId64 es una macro que se expande al formato correcto en cada plataforma.

11. ¿Por qué write recibe un size_t pero devuelve un ssize_t?

La cantidad de bytes a escribir no puede ser negativa, y size_t es sin signo. El resultado, en cambio, tiene que poder indicar un error con −1, y para eso hace falta un tipo con signo del mismo tamaño: ssize_t.

12. ¿Por qué 0.1 + 0.2 == 0.3 es falso?

Porque 0,1, 0,2 y 0,3 no tienen representación exacta en binario, y se guardan aproximados. La suma de las aproximaciones de 0,1 y 0,2 da 0,30000000000000004, que no es exactamente la aproximación de 0,3. Los números de punto flotante no se comparan con ==.

Para practicar

  1. Agrega a tamanos.c los tipos unsigned short, _Bool, int_least8_t, int_fast32_t, ssize_t, pid_t y off_t. ¿Alguno te sorprende?
  2. Modifica signo.c para que muestre los 16 bits de un short, con los valores 0, 1, −1, 32767, −32768 y 1000.
  3. Escribe un programa que sume 1 a UCHAR_MAX, USHRT_MAX y UINT_MAX, guardando cada resultado en una variable del mismo tipo, y muestra los resultados. Luego haz lo mismo con INT_MAX y compila con -fsanitize=undefined.
  4. Cambia orden.c para que use un uint64_t con el valor 0x0102030405060708. Predice el orden de los 8 bytes antes de ejecutarlo.
  5. Crea un archivo de 4 bytes con printf '\x01\x00\x00\x00' > uno.bin y averigua con od -t d4 uno.bin qué número entero representa en tu máquina. Luego crea otro con los bytes en orden inverso, '\x00\x00\x00\x01': ¿qué número es ahora, y por qué?
  6. Imprime getuid(), getppid() y el tamaño de gid_t con los formatos portables de la sección 11. Busca en man 2 getuid el encabezado que necesitas.
  7. Escribe un bucle que sume 0,1 diez veces en un float y en un double, e imprime los resultados con %.10f. ¿Cuál se aleja más de 1?