Cadenas en C
El terminador nulo como estructura de datos, el coste algorítmico de strlen y el patrón cuadrático que engendra, y las razones estructurales por las que las cadenas de C llevan cincuenta años produciendo vulnerabilidades.
C no tiene un tipo cadena. Tiene una convención: una secuencia de char que termina cuando aparece un byte cero. Esa decisión, tomada para ahorrar un par de bytes por objeto en máquinas con kilobytes de memoria, definió la ergonomía y la seguridad del software durante medio siglo. Todo lo demás se deduce de ella: que averiguar la longitud sea una operación lineal, que ninguna función pueda comprobar si cabe lo que va a escribir, que un solo byte perdido convierta una cadena en un recorrido sin final por la memoria del proceso. Entender esta convención a fondo es entender por qué las cadenas de C no son un tipo, sino un contrato que nadie vigila.
- Comprender el terminador nulo como estructura de datos y sus invariantes.
- Analizar el coste de
strleny detectar el patrón cuadrático que induce. - Distinguir dónde vive cada cadena: literal, array automático o memoria dinámica.
- Enumerar los modos de fallo estructurales y reconocerlos en código real.
El terminador nulo como estructura de datos
Una cadena en C es un array de char con una invariante añadida: en algún punto de él hay un byte de valor cero, y ese byte marca el final. La longitud no se guarda en ninguna parte; se descubre recorriendo.
char saludo[] = "Hola";
/* en memoria: 'H' 'o' 'l' 'a' '\0' -> 5 bytes, longitud 4 */
sizeof saludo; /* 5: el array completo, con el terminador */
strlen(saludo); /* 4: caracteres hasta el cero, sin contarlo */
Esa diferencia de uno entre capacidad y longitud es el error clásico. Un buffer de n bytes almacena como mucho n - 1 caracteres útiles, porque el terminador ocupa sitio. Cada vez que reserves espacio para texto, el + 1 debe estar escrito y justificado.
Un array de char es un objeto del lenguaje. Un buffer es memoria con capacidad conocida. Una cadena es un buffer que además cumple la invariante del terminador. Las tres cosas se confunden en la conversación diaria, pero el bug siempre nace en la frontera: memoria que alguien trata como cadena sin que nadie haya garantizado que contiene un cero. Cuando revises código, pregúntate por cada puntero de texto quién estableció esa invariante y en qué línea.
El coste de strlen
strlen no puede hacer nada inteligente: recorre byte a byte hasta encontrar el cero. Es Θ de n, con n la longitud de la cadena, y no existe implementación que mejore esa cota, por muy vectorizada que esté.
size_t mi_strlen(const char *s) {
const char *p = s;
while (*p) p++; /* recorre hasta el terminador */
return (size_t)(p - s);
}
Las implementaciones reales de la libc leen palabras completas de 8 o 16 bytes y detectan el cero con trucos de manipulación de bits o con instrucciones SIMD, pero eso solo reduce la constante: sigue tocando toda la cadena. Y aquí aparece el patrón patológico:
/* Cuadratico: strlen se reevalua en cada iteracion */
for (size_t i = 0; i < strlen(s); i++)
procesar(s[i]);
/* Lineal: la longitud se calcula una sola vez */
size_t n = strlen(s);
for (size_t i = 0; i < n; i++)
procesar(s[i]);
Joel Spolsky bautizó el primer caso como el algoritmo del pintor Schlemiel: el pintor que vuelve al bote de pintura tras cada brochazo, y cada vez camina más. La versión más costosa del mismo error es concatenar en bucle con strcat, que recorre el destino entero para localizar su final antes de cada añadido: construir una cadena de un megabyte a base de trozos pequeños puede pasar de milisegundos a minutos.
flowchart TD A[Cadena de C] --> B[La longitud no se guarda] B --> C[Hay que recorrer hasta el byte cero] C --> D[strlen cuesta lineal en la longitud] D --> E[strlen dentro de la condicion del bucle] E --> F[Coste cuadratico] D --> G[strlen una vez fuera del bucle] G --> H[Coste lineal] C --> I[Si falta el terminador se lee memoria ajena] style H fill:#a6e3a1,color:#11111b style I fill:#f38ba8,color:#11111b
Dónde vive cada cadena
Tres orígenes con propiedades radicalmente distintas, y confundirlos es la segunda familia de bugs más común:
Literal de cadena
const char *p = "hola"; apunta a un objeto estático de duración total del programa, casi siempre en una sección de solo lectura. Modificarlo es comportamiento indefinido. En C23 sigue teniendo tipo array de char no const, pero escribir en él es un fallo.
Array automático
char s[] = "hola"; copia el literal en la pila. Es modificable, su capacidad la fija el inicializador y muere al salir del ámbito. Devolver su dirección desde una función es un puntero colgante.
Memoria dinámica
char *s = malloc(n + 1); da capacidad elegida en ejecución y duración controlada por ti. Tú estableces el terminador y tú liberas. Es la única de las tres que puede crecer.
Subcadena prestada
Un puntero al interior de otra cadena. No es una cadena independiente: comparte terminador, comparte vida útil y se invalida cuando el original se libera o se reasigna.
const char *malo(void) {
char local[] = "temporal";
return local; /* puntero colgante: el array muere aqui */
}
const char *bueno(void) {
return "temporal"; /* literal estatico: sobrevive al retorno */
}
strlen cuenta bytes, no caracteres. Con UTF-8, la cadena "año" mide cuatro bytes y tres caracteres, y con emoji o escritura devanagari la distancia entre ambas magnitudes crece. Peor aún: partir por un número de bytes puede cortar una secuencia multibyte por la mitad y producir texto inválido. C23 añadió char8_t y los literales u8 para dar tipo a lo que antes era un char * con una convención encima, pero el lenguaje sigue sin ofrecer iteración por puntos de código: eso es trabajo tuyo o de una biblioteca. Nunca escribas lógica de recorte, alineación o truncado de texto asumiendo que un byte es un carácter.
Los modos de fallo estructurales
Casi todos los fallos con cadenas se reducen a cuatro violaciones de invariante, y conviene saberlos nombrar:
char d[8];
strcpy(d, "una cadena mucho mas larga"); /* 1. Escritura fuera de limites */
char sin_cero[4] = {'a','b','c','d'};
strlen(sin_cero); /* 2. Lectura fuera de limites */
char *p = malloc(8);
strncpy(p, origen, 8); /* 3. Truncado sin terminador */
printf(p); /* 4. Cadena de formato ajena */
El primero corrompe la pila o el montón y es la base histórica de la ejecución de código arbitraria. El segundo lee hasta encontrar un cero por casualidad y puede filtrar memoria contigua, como hizo Heartbleed. El tercero es el regalo envenenado de strncpy, que no termina el destino si el origen llena el buffer exacto. El cuarto convierte datos en instrucciones para printf y permite leer y hasta escribir memoria con %n.
Hay un quinto modo, más sutil, que aparece en cuanto el texto viene de fuera: el cero incrustado. Si un atacante mete un byte nulo en mitad de una entrada, todo el código que trate esa entrada como cadena verá una versión truncada, mientras que el código que la trate como bloque de bytes con longitud verá el original completo. Esa discrepancia entre dos capas que validan cosas distintas ha producido bypasses de validación de rutas y de certificados durante años.
/* Para datos que pueden contener ceros, la cadena no es la abstraccion correcta */
void *memchr(const void *s, int c, size_t n); /* busca acotado por longitud */
int memcmp(const void *a, const void *b, size_t n);
La regla práctica: si el dato viene de la red, de un fichero o del usuario, trátalo como buffer con longitud hasta que hayas validado explícitamente que no contiene ceros y que puede ascender a cadena.
Merece la pena detenerse en la magnitud histórica de esta decisión de diseño. A comienzos de los setenta había dos formas conocidas de representar texto: guardar la longitud junto a los bytes, como hacían PL/I y luego Pascal, o marcar el final con un centinela. La primera cuesta una o varias palabras de cabecera por cadena y limita la longitud al rango del contador; la segunda cuesta un solo byte y no limita nada. En un PDP-11 con memoria contada en kilobytes, la elección parecía obvia, y C se quedó con el centinela. El resultado es que en C la longitud de una cadena no es un dato, es el resultado de un cómputo, y de ahí se derivan en cascada todas las consecuencias que has visto: que saber cuánto mide algo cueste recorrerlo entero, que las funciones no puedan comprobar si el destino tiene sitio porque tampoco conocen su capacidad, que un byte perdido convierta una lectura acotada en un paseo por el espacio de direcciones del proceso, y que el compilador sea incapaz de ayudarte porque char * no distingue entre un carácter suelto, un buffer de mil bytes y una cadena bien formada. Cincuenta años después, los estudios de Microsoft y del proyecto Chromium coinciden en que alrededor del setenta por ciento de sus vulnerabilidades críticas son de seguridad de memoria, y las cadenas encabezan la lista. La lección no es que C sea un mal lenguaje ni que sus diseñadores se equivocaran con la información que tenían: es que un byte ahorrado en el layout se paga durante décadas en la superficie de ataque, y que toda decisión de representación de datos es, en el fondo, una decisión de seguridad tomada por adelantado. Cuando diseñes tus propias estructuras, elige explícitamente qué invariantes vivirán en el dato y cuáles en la disciplina, sabiendo que las segundas se violarán tarde o temprano.
- Declara
char s[] = "Hola"e imprimesizeof systrlen(s). Explica la diferencia de uno y dónde está exactamente ese byte. - Escribe tu propia
strlencon un bucle de punteros y compárala en tiempo con la de la libc sobre una cadena de un millón de bytes. - Mide el coste de concatenar diez mil trozos con
strcaten bucle frente a mantener un puntero al final. Grafica los tiempos y confirma la curva cuadrática. - Crea un array de
charsin terminador y pásalo astrlenbajo AddressSanitizer. Anota el informe exacto que emite. - Toma un literal de cadena por
char *e intenta modificar su primer carácter. Explica en qué sección del ejecutable vive y verifícalo conobjdumporeadelf.