Portabilidad: estándar, POSIX y extensiones
Los tres círculos de la disponibilidad en C, las macros de test de características que deciden qué declara cada cabecera, y la disciplina de escribir código que compila donde dijiste que compilaría.
Portable no significa que funcione en tu máquina y en la del compañero. Significa que sabes exactamente qué contrato asumes, que ese contrato está escrito en el código y no en tu cabeza, y que algo automático lo comprueba. La mayoría del código que se llama portable solo ha sido probado en un sitio; la diferencia entre eso y la portabilidad real son unas pocas macros y una forma distinta de razonar.
- Delimitar qué garantiza ISO C, qué añade POSIX y qué es extensión de una implementación.
- Usar correctamente las macros de test de características y entender por qué su posición importa.
- Separar comportamiento indefinido, no especificado y definido por la implementación.
- Estructurar un proyecto para que las dependencias de plataforma sean explícitas y verificables.
Tres círculos concéntricos
Cuando escribes #include <stdio.h> no obtienes “lo que hay en la cabecera”: obtienes el subconjunto que corresponde al contrato que declaraste. Hay tres círculos.
El interior es ISO C, el estándar del lenguaje, hoy C23. Define el lenguaje, unas treinta cabeceras y nada más. No sabe qué es un archivo del sistema, un proceso, un socket ni un permiso. fopen existe; open no. Es el único círculo que se cumple en un microcontrolador, en un kernel o en una implementación freestanding.
El intermedio es POSIX, que estandariza el sistema operativo: descriptores de archivo, procesos, señales, hilos, sockets, permisos, terminales. Lo cumplen Linux, los BSD y macOS con matices; Windows nativo no. Ahí viven open, fork, pthread_create, dup2 y getopt.
El exterior son las extensiones de cada implementación: lo que glibc o el propio compilador añaden por su cuenta y nadie más garantiza. asprintf, memmem, las funciones anidadas o los atributos de GCC.
flowchart TD A[Extensiones de la implementacion] --> B[POSIX y XSI] B --> C[ISO C 23] C --> D[Freestanding: solo lenguaje y cabeceras minimas] style C fill:#a6e3a1,color:#11111b style A fill:#f38ba8,color:#11111b
La frontera se mueve. C23 absorbió de POSIX funciones que llevaban décadas siendo extensiones: strdup, strndup, memccpy, gmtime_r y localtime_r son hoy ISO C. Saber en qué círculo vive cada función que usas es la mitad del trabajo de portabilidad.
Las macros de test de características
El mecanismo que selecciona el círculo son las macros de test de características. No son opcionales ni decorativas: las cabeceras de glibc incluyen features.h, leen esas macros y declaran u ocultan prototipos en consecuencia.
_POSIX_C_SOURCE
Con el valor 200809L habilita POSIX.1-2008. Es la elección por defecto para código de sistemas serio.
_XOPEN_SOURCE
Con 700 habilita la Single UNIX Specification versión 4, que incluye POSIX más las extensiones XSI.
_GNU_SOURCE
Lo habilita todo, incluidas las extensiones GNU. Cómodo y peligroso: oculta que dejaste de ser portable.
_FILE_OFFSET_BITS
Con 64 fuerza desplazamientos de archivo de 64 bits en sistemas de 32 bits. Su compañera _TIME_BITS evita el desbordamiento de 2038.
Dos reglas que se incumplen a diario. La primera: la definición debe preceder a toda inclusión, sin excepción. Si una cabecera entra antes, sus guardas de inclusión impiden que reevalúe nada y la macro no tendrá efecto, sin un solo aviso.
#define _POSIX_C_SOURCE 200809L // primero, antes que cualquier #include
#include <stdio.h>
#include <unistd.h>
La segunda: la bandera del compilador también decide. Con -std=c23 pides ISO estricto y gcc deja de predefinir el conjunto por defecto, así que funciones POSIX que compilaban ayer desaparecen. Con -std=gnu23 el compilador predefine _DEFAULT_SOURCE y todo vuelve a estar visible. Ese es el motivo exacto de errores como una declaración implícita de fileno o de popen en un proyecto que solo cambió de bandera.
gcc -std=c23 -Wall prog.c # ISO estricto: sin POSIX salvo que lo pidas
gcc -std=gnu23 -Wall prog.c # con extensiones y POSIX visibles por defecto
La recomendación profesional es la incómoda: usa -std=c23 y declara explícitamente el nivel de POSIX que necesitas. Cuesta una línea y convierte una dependencia invisible en un requisito escrito.
Lo que el estándar no promete
Portabilidad no es solo disponibilidad de funciones; es también no depender de lo que el estándar dejó abierto. Hay tres categorías y confundirlas es caro.
Definido por la implementación: el estándar exige una elección y que se documente. Si char es con signo o sin signo, cuántos bits tiene un int, cómo se representa un puntero convertido a entero. Depender de ello es legítimo si lo declaras.
No especificado: hay varias opciones válidas y nadie tiene que decirte cuál. El orden de evaluación de los argumentos de una llamada, o el orden en que se disponen ciertos objetos en memoria.
Indefinido: no hay contrato de ninguna clase y el compilador puede asumir que nunca ocurre. Desbordar un entero con signo, leer memoria liberada, violar el aliasing estricto. Aquí no hay portabilidad posible porque ni siquiera hay programa.
El repertorio de trampas es conocido y se evita con disciplina. El tamaño de los enteros cambia entre modelos: en Linux de 64 bits long mide 8 bytes, en Windows de 64 bits mide 4. La solución es stdint.h con tipos de anchura fija y inttypes.h para imprimirlos.
#include <inttypes.h>
#include <limits.h>
#include <stdint.h>
#include <stdio.h>
static_assert(CHAR_BIT == 8, "esta implementacion asume octetos");
int main(void) {
uint64_t n = 1;
printf("%" PRIu64 "\n", n); // nunca %lu: no es portable
size_t s = sizeof n;
printf("%zu\n", s); // %zu es el especificador correcto
}
Nótese que %zu para size_t y las macros PRIu64 no son manías estilísticas: pasar el especificador equivocado a una función variádica es comportamiento indefinido, no un aviso cosmético. C23 mejora el terreno al exigir representación en complemento a dos, lo que elimina de golpe varias ambigüedades históricas sobre enteros con signo.
Las dos trampas restantes son de representación en memoria. El orden de bytes no está especificado, así que volcar una estructura a un archivo o a un socket y leerla en otra máquina produce números distintos; la solución no es detectar la arquitectura, sino serializar byte a byte con desplazamientos, que es correcto en cualquier orden. Y el alineamiento: leer un entero de cuatro bytes desde una dirección no alineada es indefinido aunque en x86 funcione, y en otras arquitecturas provoca una excepción o una lectura silenciosamente lenta. La forma portable de reinterpretar bytes es memcpy, que el compilador reduce a una instrucción cuando puede.
uint32_t leer_be32(const unsigned char *p) {
return (uint32_t)p[0] << 24 | (uint32_t)p[1] << 16
| (uint32_t)p[2] << 8 | (uint32_t)p[3];
}
Compilar con -Wall -Wextra -Wpedantic y tratar como errores los avisos de formato y de conversión es el detector de portabilidad más barato que existe. -Wpedantic en particular delata el uso de extensiones que creías estándar, y -Wconversion saca a la luz las truncaciones que solo se manifiestan al cambiar de modelo de datos.
Escribir código que de verdad viaja
La estrategia que funciona no es sembrar el código de condicionales de plataforma, sino aislar. Define una interfaz propia en términos de tu dominio, y que cada plataforma tenga su implementación en un archivo distinto, elegido por el sistema de compilación. Así el noventa por ciento de tu código no sabe en qué sistema corre y el diez por ciento restante está concentrado, aislado y es fácil de auditar.
Cuando necesites condicionales, detecta la característica, no el sistema. Preguntar por el sistema operativo es frágil porque la relación entre sistema y funcionalidad cambia; preguntar por la función es exacto. C23 aporta una herramienta directa para esto:
#if defined(__has_include)
# if __has_include(<sys/random.h>)
# include <sys/random.h>
# define TENGO_GETRANDOM 1
# endif
#endif
Para lo que no se resuelve con cabeceras están las comprobaciones del sistema de compilación, que ejecutan una compilación de prueba y definen una macro según el resultado. En Meson eso es cc.has_function y cc.has_header; el equivalente clásico es el configure de Autotools. La ventaja es que la respuesta la da el compilador de destino, no tu memoria.
El resto es higiene verificable: compila con gcc y con clang, porque cada uno detecta cosas distintas; activa -Wall -Wextra -Wpedantic para que el compilador te avise cuando uses una extensión sin darte cuenta; ejecuta las pruebas en más de una libc y en más de una arquitectura, si es posible una de orden de bytes distinto; y escribe con static_assert las suposiciones que no puedas evitar, para que fallen al compilar y no en producción.
El error de fondo es tratar la portabilidad como una propiedad difusa que el código tiene más o menos. No lo es: es una afirmación concreta, del tipo “esto compila y funciona en cualquier sistema POSIX.1-2008 con enteros en complemento a dos y CHAR_BIT igual a ocho”. Formulada así, todo cambia. Una afirmación se puede escribir en el código, y eso es exactamente lo que hacen _POSIX_C_SOURCE y static_assert: no son burocracia, son el enunciado de tu contrato hecho ejecutable. Una afirmación se puede comprobar, y por eso importa compilar con dos compiladores, dos libc y dos arquitecturas: cada entorno adicional es un intento de refutación, y el que nunca refutas no te dice nada. Y una afirmación se puede acotar: decir “solo Linux con glibc sobre x86-64” es una respuesta perfectamente profesional si está escrita y comprobada, mientras que “es portable” sin especificar a qué es una afirmación vacía. Aquí se cierra el nivel entero: la libc no es el lenguaje, glibc no es POSIX, POSIX no es ISO C, y tu programa no depende de “C” sino de un conjunto preciso de supuestos que puedes enumerar. Enumerarlos es lo que separa a quien espera que su código funcione en otra parte de quien sabe si lo hará.
- Coge un programa tuyo que use POSIX, compílalo con
-std=c23sin macros y anota cada declaración implícita; arréglalo con_POSIX_C_SOURCEen lugar de volver a-std=gnu23. - Mueve la definición de la macro después del primer
#includey comprueba que deja de tener efecto sin ningún aviso. - Sustituye todos los
%luy%daplicados asize_to a enteros de anchura fija por%zuy las macros deinttypes.h, y compila con-Wformat. - Añade comprobaciones con
static_assertsobreCHAR_BITy el tamaño de tus estructuras, y verifica que fallan al compilar si cambias el empaquetado.