Enteros con precisión: tamaños reales frente a garantizados
Los rangos que el estándar garantiza frente a las anchuras reales del ABI, los tipos exactos de stdint.h, el _BitInt de C23 y las dos aritméticas del entero: con signo y sin signo.
El estándar de C nunca te dijo cuánto mide un int: solo cuánto mide como mínimo. Toda la portabilidad real de un programa de sistemas vive en esa grieta entre lo garantizado y lo que hace tu máquina. C23 la estrecha —fija el complemento a dos, añade _BitInt y estandariza la aritmética comprobada— pero no la cierra: elegir el tipo entero sigue siendo una decisión de ingeniería, no un detalle de estilo.
- Separar los rangos garantizados por el estándar de las anchuras reales del ABI.
- Leer los modelos de datos y entender por qué
longno es portable. - Elegir con criterio entre los tipos exactos, mínimos y rápidos de
stdint.h. - Dominar el desbordamiento con signo y sin signo, y comprobarlo con
stdckdint.h.
Lo garantizado frente a lo real
El estándar define los tipos enteros por rango mínimo, nunca por anchura. Las cotas de limits.h son deliberadamente modestas: signed char cubre al menos de -127 a 127, short e int al menos de -32767 a 32767, long llega al menos a 2147483647 y long long a 9223372036854775807.
Esos límites negativos asimétricos no son un descuido: hasta C17 el estándar admitía complemento a uno y signo-magnitud, representaciones con dos ceros distintos. C23 las elimina: los enteros con signo son complemento a dos, sin excepciones. Lo que durante décadas fue un supuesto universal pero informal es ahora una garantía del lenguaje, y de ahí se derivan varias limpiezas que verás más abajo.
Lo que sigue sin estar en el lenguaje es la anchura. La fija el modelo de datos del ABI de tu plataforma:
ILP32
int, long y puntero de 32 bits. El mundo embebido de 32 bits y los binarios x86 heredados. Aquí long y puntero coinciden por casualidad histórica, no por diseño.
LP64
int de 32, long y puntero de 64. Linux, macOS, los BSD y prácticamente todo Unix moderno de 64 bits.
LLP64
int y long de 32, long long y puntero de 64. Windows de 64 bits. Es el modelo que rompe el reflejo de creer que long mide lo mismo que un puntero.
#include <limits.h>
#include <stdint.h>
#include <stdio.h>
int main(void) {
printf("CHAR_BIT = %d\n", CHAR_BIT); // 8 en todo lo que usarás
printf("int %zu B, max %d\n", sizeof(int), INT_MAX);
printf("long %zu B, max %ld\n", sizeof(long), LONG_MAX);
printf("void* %zu B\n", sizeof(void *));
}
long es el tipo menos portable del lenguaje: 32 bits en Windows de 64 bits, 64 en Linux, 32 en embebido. Un campo declarado long en un formato de fichero o en una estructura de red cambia de tamaño al cambiar de compilador, y el bug aparece meses después como corrupción silenciosa. Regla: long solo para interoperar con APIs que ya lo usan; para todo lo demás, un tipo de anchura declarada.
Exactos, mínimos y rápidos
stdint.h no ofrece una familia de tipos, sino tres, y confundirlas es un error frecuente:
#include <stdint.h>
int32_t exacto; // exactamente 32 bits, complemento a dos, sin relleno
int_least32_t minimo; // el más pequeño que tenga AL MENOS 32 bits
int_fast32_t rapido; // el más rápido que tenga al menos 32 bits
intmax_t maximo; // el entero con signo más ancho de la plataforma
intptr_t comopunt; // entero capaz de guardar un void* de ida y vuelta
Los exactos (intN_t, uintN_t) son opcionales: el estándar solo los exige si la plataforma tiene un tipo con esa anchura exacta y sin bits de relleno. En cualquier máquina que uses existen, y son los que necesitas para formatos binarios, registros de hardware y protocolos de red, donde la representación es parte del contrato.
Los mínimos (int_leastN_t) están garantizados siempre y son la elección portable cuando solo te importa el rango. Los rápidos (int_fastN_t) sacrifican memoria por velocidad y pueden ser sorprendentemente anchos: en LP64, int_fast16_t suele ser de 64 bits. No los uses en estructuras cuya disposición importe.
No existe un especificador de printf para int32_t, porque su tipo subyacente varía. inttypes.h resuelve el problema con macros de cadena que se concatenan: printf("%" PRId32 "\n", x) y printf("%" PRIu64 "\n", y). Para las constantes hay pareja: UINT64_C(1) << 40 produce un literal del tipo correcto sin depender de sufijos escritos a mano.
_BitInt(N): anchura al bit
C23 añade un tipo entero cuya anchura eliges tú, bit a bit: _BitInt(N) con signo y unsigned _BitInt(N) sin él. La anchura máxima está en BITINT_MAXWIDTH de limits.h, y los literales llevan sufijo wb o uwb.
#include <limits.h>
#include <stdint.h>
typedef unsigned _BitInt(24) rgb_t; // 24 bits justos, ni uno más
typedef _BitInt(12) adc_t; // muestra de un conversor de 12 bits
rgb_t color = 0xC0FFEEuwb;
adc_t muestra = -2048wb; // el mínimo exacto de 12 bits con signo
La propiedad que lo hace interesante no es el ahorro de memoria, sino la semántica: _BitInt no participa en las promociones enteras. Un unsigned _BitInt(12) no se convierte en int antes de operar; la aritmética ocurre a 12 bits y el resultado envuelve a 12 bits. Al mezclar dos _BitInt de anchuras distintas gana el más ancho, y la conversión implícita desde tipos ordinarios está restringida para que no se cuelen truncamientos accidentales.
Eso lo convierte en la herramienta correcta para modelar campos de hardware, formatos comprimidos y aritmética de precisión no estándar, donde antes tocaba emular con máscaras y desplazamientos escritos a mano. El precio: el soporte de biblioteca es escaso —no hay especificadores de printf para él— y la generación de código para anchuras raras puede ser costosa.
flowchart TD A[Necesito un entero] --> B[La representacion es parte de un contrato externo] B -->|si, formato binario o registro| C[Tipo exacto de stdint como uint32_t] B -->|no, solo me importa el rango| D[Necesito una anchura no potencia de dos] D -->|si| E[BitInt de N bits de C23] D -->|no| F[int_leastN_t si prima la portabilidad] F --> G[int_fastN_t si prima la velocidad] A --> H[Es un tamano o un indice de memoria] H -->|si| I[size_t o ptrdiff_t] style C fill:#a6e3a1,color:#11111b style E fill:#f9e2af,color:#11111b style I fill:#89b4fa,color:#11111b
Dos aritméticas bajo el mismo signo de suma
El operador + esconde dos semánticas incompatibles según el tipo de sus operandos.
Sin signo, la aritmética es modular: el resultado se reduce módulo dos elevado a la anchura. Está perfectamente definida, y por eso es la base de los hashes, los CRC y los contadores circulares. Con signo, el desbordamiento es comportamiento indefinido, y el compilador lo explota para optimizar: puede asumir que x + 1 siempre es mayor que x y eliminar la comprobación entera que escribiste para detectarlo.
unsigned u = UINT_MAX;
u + 1u; // 0, definido y garantizado
int s = INT_MAX;
s + 1; // comportamiento indefinido, no "un número negativo"
if (s + 1 < s) { } // el compilador puede borrar esta rama por completo
La forma correcta de detectar el desbordamiento es no provocarlo. C23 estandariza en stdckdint.h lo que antes eran extensiones del compilador:
#include <stdckdint.h>
int32_t total;
if (ckd_add(&total, a, b)) // devuelve true si hubo desbordamiento
return -1; // ...y deja en total el resultado envuelto
if (ckd_mul(&bytes, n, tam)) // el patrón canónico antes de un malloc
return -1;
Cuando escribes uint32_t no estás pidiendo espacio: estás firmando un contrato de que ese valor nunca será negativo, nunca pasará de 4294967295 y ocupará exactamente cuatro bytes con esa representación. Cada línea del contrato es verificable —por el compilador con -Wconversion, por el sanitizador con -fsanitize=integer, por el lector— y cada una excluye una clase de fallos. El error de principiante no es elegir mal el tipo, es creer que la elección solo afecta a la memoria. La elección determina si el desbordamiento envuelve o destruye tu programa, si la comparación con un int es correcta o absurda, y si el compilador puede razonar sobre tu código o tiene que asumir lo peor. C23 refuerza esa lectura: al fijar el complemento a dos elimina un supuesto tácito, con _BitInt te deja declarar exactamente la anchura que el dominio del problema exige, y con stdckdint.h convierte la comprobación de desbordamiento en una operación de primera clase en vez de un truco. Programar en C con soltura es escribir esos contratos a conciencia y dejar que la cadena de herramientas los verifique por ti.
- Imprime
CHAR_BIT,sizeofdeint,long,long longyvoid*, e identifica tu modelo de datos. - Recorre un contador
uint8_tdesde 250 hasta que envuelva y comprueba que da exactamente 0. - Compila con
-fsanitize=signed-integer-overflowunINT_MAX + 1y observa el diagnóstico en ejecución. - Sustituye una multiplicación previa a un
mallocporckd_muly provoca el desbordamiento a propósito. - Declara un
unsigned _BitInt(12), imprimesizeofy comprueba que la aritmética envuelve a 12 bits, no a 32.