Por qué las cadenas en C son diferentes
Quien llega a C desde Python, JavaScript, Java o C# suele llevarse una sorpresa al intentar trabajar con texto. En esos lenguajes existe un tipo string con su longitud, sus métodos y su gestión de memoria automática. En C no hay nada de eso. No existe un tipo de dato para las cadenas: un texto es, sencillamente, una secuencia de caracteres almacenada en memoria que termina con un carácter especial.
Esta sencillez es coherente con la filosofía de C, un lenguaje cercano a la máquina que deja en manos del programador el control de la memoria. Pero también es la fuente de algunos de los errores más frecuentes y peligrosos de la historia del software, como los desbordamientos de búfer que durante décadas han permitido ejecutar código malicioso en sistemas de todo el mundo.
En esta guía veremos cómo se representan las cadenas en C, cómo declararlas e inicializarlas, cómo modificarlas, qué funciones ofrece la biblioteca estándar en string.h, cuál es la diferencia entre arrays y punteros, y qué prácticas seguir para escribir código seguro. Los ejemplos siguen el estándar C23, la revisión vigente del lenguaje, publicada oficialmente como ISO/IEC 9899:2024, y compilan también con versiones anteriores.
Los caracteres: el tipo char
Antes de hablar de cadenas hay que hablar de caracteres. C tiene un conjunto reducido de tipos básicos: int, short, long, long long, float, double, long double, bool y char. Este último es el único pensado para representar caracteres, y ocupa exactamente un byte.
Un literal de carácter se escribe entre comillas simples:
char inicial = 'M';
char espacio = ' ';
char digito = '7';
En realidad, un char es un número entero pequeño. El carácter 'M' se almacena como su código, 77 en ASCII, y se puede operar con él como con cualquier número:
#include <stdio.h>
int main(void) {
char letra = 'M';
printf("%c %d %c\n", letra, letra, letra + 1);
return 0;
}
M 77 N
Las comillas simples solo admiten un carácter. Para varios caracteres se usan comillas dobles, y eso ya no es un char, sino una cadena. Intentar mezclarlos produce un aviso del compilador:
char ciudad = "Lima";
warning: initialization of 'char' from 'char *' makes integer from pointer without a cast [-Wint-conversion]
Arrays: la base de las cadenas
Un array es una colección de elementos del mismo tipo almacenados de forma contigua en memoria. Se declara indicando el tipo, el nombre y el número de elementos entre corchetes:
int temperaturas[3];
temperaturas[0] = 18;
temperaturas[1] = 21;
temperaturas[2] = 19;
Los índices empiezan en 0, de modo que un array de tres elementos tiene las posiciones 0, 1 y 2. C no comprueba los límites: acceder a temperaturas[3] compila sin errores, pero lee o escribe memoria que no pertenece al array, con resultados impredecibles.
Una cadena en C es un array de char. Pero hay un detalle fundamental que la distingue de un array de caracteres cualquiera.
El carácter nulo de terminación
Un array no guarda su propia longitud. Cuando una función como printf recibe un texto, solo recibe la dirección de su primer carácter. ¿Cómo sabe dónde termina? Gracias al carácter nulo de terminación, que se escribe '\0' y tiene el valor numérico 0.
Toda cadena en C debe terminar en ese carácter. Las funciones que trabajan con texto recorren la memoria carácter a carácter hasta encontrarlo. La cadena "Lima" ocupa en memoria cinco bytes:
Índice: 0 1 2 3 4
Valor: 'L' 'i' 'm' 'a' '\0'
Es importante no confundir '\0', cuyo valor es 0, con el carácter '0', el dígito cero, cuyo valor en ASCII es 48.
Esto tiene una consecuencia práctica constante: un array para almacenar una cadena de n caracteres necesita n + 1 posiciones. El carácter nulo ocupa memoria, pero no cuenta como parte de la longitud del texto.
Declarar e inicializar cadenas
Hay varias formas de crear una cadena. La más explícita es asignar los caracteres uno a uno, incluido el terminador:
#include <stdio.h>
int main(void) {
char producto[7] = {'C', 'a', 'f', 'e', ' ', '5', '\0'};
printf("%s|\n", producto);
return 0;
}
Cafe 5|
El especificador %s de printf imprime la cadena hasta encontrar el carácter nulo. Si lo olvidáramos, printf seguiría leyendo la memoria que hay a continuación hasta toparse por casualidad con un cero, mostrando basura o provocando un fallo.
La forma habitual, mucho más cómoda, es usar un literal de cadena entre comillas dobles. El compilador añade el terminador automáticamente:
char pais[6] = "Chile";
Incluso puedes omitir el tamaño, y el compilador lo calculará a partir del literal, incluyendo el carácter nulo:
char ciudad[] = "Lima"; // array de 5 elementos
También puedes reservar más espacio del necesario, algo útil si después vas a guardar un texto más largo en el mismo array. Las posiciones sobrantes se rellenan con ceros:
char destino[32] = "Quito";
Hay un caso límite que conviene conocer. Si declaras un array exactamente del tamaño del texto, sin espacio para el terminador, C lo permite pero el resultado no es una cadena válida:
char codigo[4] = "ABCD"; // válido en C, pero sin '\0'
Usar codigo con printf o strlen sería un error. Evítalo dejando siempre que el compilador calcule el tamaño o reservando una posición extra.
strlen frente a sizeof
Dos operaciones se confunden con frecuencia al empezar, y entender strlen frente a sizeof evita muchos errores. sizeof devuelve el tamaño en bytes de un objeto, calculado en tiempo de compilación. strlen, de la biblioteca string.h, cuenta los caracteres hasta el terminador, sin incluirlo:
#include <stdio.h>
#include <string.h>
int main(void) {
char ciudad[] = "Lima";
char destino[32] = "Quito";
printf("%zu %zu\n", sizeof ciudad, strlen(ciudad));
printf("%zu %zu\n", sizeof destino, strlen(destino));
return 0;
}
5 4
32 5
sizeof mide la capacidad del array; strlen, la longitud del texto que contiene. Además, strlen recorre la cadena entera para contarla, de modo que llamarla repetidamente dentro de un bucle sobre una cadena larga es ineficiente. Si necesitas la longitud varias veces, guárdala en una variable.
Modificar el contenido de una cadena
Una de las limitaciones que más sorprende es que, una vez declarado, un array no se puede asignar con el operador =:
char ciudad[10] = "Lima";
ciudad = "Cusco";
error: assignment to expression with array type
Los arrays no son valores que se puedan copiar como un bloque. Para cambiar el contenido hay dos opciones. La primera es modificar los caracteres uno a uno, recordando colocar el terminador:
ciudad[0] = 'C';
ciudad[1] = 'u';
ciudad[2] = 's';
ciudad[3] = 'c';
ciudad[4] = 'o';
ciudad[5] = '\0';
La segunda, la práctica, es usar las funciones de copia de string.h. La clásica es strcpy, que copia una cadena en otra, terminador incluido:
#include <string.h>
char ciudad[10] = "Lima";
strcpy(ciudad, "Cusco");
Pero strcpy tiene un problema grave: no sabe cuánto espacio hay en el destino. Si la cadena de origen es más larga, escribirá más allá del final del array. Lo veremos con detalle en la sección de seguridad, porque ese es el origen de muchas vulnerabilidades.
Cadenas y punteros
En C hay una relación estrecha entre arrays y punteros. Cuando usas el nombre de un array en la mayoría de expresiones, se convierte en un puntero a su primer elemento. Por eso es habitual ver cadenas declaradas como punteros a char:
const char *saludo = "Hola";
Aunque parezca equivalente a char saludo[] = "Hola", hay diferencias importantes.
Con el array, el compilador reserva memoria propia para la variable y copia en ella el contenido del literal. Puedes modificar sus caracteres libremente.
Con el puntero, la variable solo guarda la dirección de un literal que el compilador coloca en una zona de memoria de solo lectura. Los literales de cadena son inmutables, e intentar modificarlos es un comportamiento indefinido que, en la mayoría de sistemas, termina el programa:
char *capital = "Lima";
capital[0] = 'X';
Segmentation fault (core dumped)
Por eso, cuando apuntes a un literal, declara el puntero como const char *. El compilador detectará cualquier intento de modificación.
Otra diferencia es lo que devuelve sizeof: con el array, el tamaño del array; con el puntero, el tamaño del propio puntero, que en sistemas de 64 bits es 8, independientemente de la longitud del texto:
const char *lit = "Quito";
printf("%zu %zu\n", sizeof lit, strlen(lit));
8 5
Cuando pasas una cadena a una función, siempre llega como puntero. Por eso la función no puede saber el tamaño del array original con sizeof, y las funciones seguras reciben ese tamaño como parámetro adicional.
Funciones esenciales de string.h
La biblioteca estándar ofrece en string.h las funciones básicas para trabajar con cadenas. Estas son las más usadas.
Para comparar dos cadenas no se usa ==, que compararía direcciones de memoria, sino strcmp. Devuelve 0 si son iguales, un valor negativo si la primera es menor en orden lexicográfico y uno positivo si es mayor:
if (strcmp(usuario, "admin") == 0) {
puts("Acceso de administrador");
}
printf("%d %d %d\n",
strcmp("abc", "abd") < 0,
strcmp("Lima", "Lima"),
strcmp("b", "a") > 0);
1 0 1
Para concatenar, strcat añade una cadena al final de otra, y strncat limita el número de caracteres añadidos:
char nombre[20] = "Ana";
strncat(nombre, " Ruiz", sizeof nombre - strlen(nombre) - 1);
puts(nombre);
Ana Ruiz
Para buscar, strchr encuentra la primera aparición de un carácter y strstr la de una subcadena. Ambas devuelven un puntero a la posición encontrada, o NULL si no la hay:
const char *correo = "[email protected]";
const char *arroba = strchr(correo, '@');
if (arroba) {
printf("Dominio: %s\n", arroba + 1);
}
Dominio: dominio.example
Para dividir una cadena en partes, strtok separa por delimitadores. Tiene la particularidad de modificar la cadena original, sustituyendo los separadores por terminadores, y de mantener estado interno entre llamadas:
char colores[] = "rojo,verde,azul";
for (char *t = strtok(colores, ","); t != NULL; t = strtok(NULL, ",")) {
printf("[%s]", t);
}
puts("");
[rojo][verde][azul]
Por su estado interno, strtok no es segura en programas con varios hilos. En sistemas POSIX existe strtok_r, que recibe el estado como parámetro.
C23 incorporó al estándar dos funciones que llevaban años disponibles en POSIX: strdup, que crea una copia de una cadena en memoria dinámica, y strndup, que limita los caracteres copiados. La memoria devuelta debe liberarse con free:
#include <stdlib.h>
#include <string.h>
char *copia = strdup("Caracas");
puts(copia);
free(copia);
Para transformar caracteres, ctype.h ofrece funciones como toupper, tolower, isdigit o isspace. Conviene convertir el carácter a unsigned char antes de pasarlo, porque con valores negativos el comportamiento no está definido:
#include <ctype.h>
char frase[] = "hola mundo";
for (size_t i = 0; frase[i] != '\0'; i++) {
frase[i] = (char)toupper((unsigned char)frase[i]);
}
puts(frase);
HOLA MUNDO
Formatear y convertir cadenas
printf y sus variantes no solo imprimen: también permiten construir cadenas con formato. snprintf escribe el resultado en un array respetando su tamaño máximo, y es la forma recomendada de componer texto:
char etiqueta[32];
int pedido = 1042;
double total = 58.4;
snprintf(etiqueta, sizeof etiqueta, "Pedido %d: %.2f EUR", pedido, total);
puts(etiqueta);
Pedido 1042: 58.40 EUR
Los especificadores de formato permiten alinear y recortar texto, algo útil para generar tablas en la terminal:
printf("%-10s|%5s|%.3s\n", "Cusco", "Ica", "Arequipa");
Cusco | Ica|Are
Para convertir texto en números, evita atoi y atof, que no informan de errores: atoi("42abc") devuelve 42 sin avisar de que sobraban caracteres, y atoi("abc") devuelve 0, indistinguible de un cero real. Usa strtol, strtoul o strtod, que indican dónde terminó la conversión:
char *fin;
long valor = strtol("42abc", &fin, 10);
printf("%ld '%s'\n", valor, fin);
42 'abc'
Comprobando que fin apunta al terminador y que errno no indica desbordamiento, sabrás si la conversión fue completa y correcta.
Leer cadenas desde la entrada
Leer texto del usuario es otro punto delicado. La función gets era tan peligrosa, porque no limitaba la longitud leída, que fue eliminada del lenguaje en C11. Tampoco es recomendable scanf("%s", ...) sin límite de anchura.
La forma segura es fgets, que recibe el tamaño del búfer y nunca escribe más allá:
#include <stdio.h>
#include <string.h>
int main(void) {
char nombre[50];
printf("¿Cómo te llamas? ");
if (fgets(nombre, sizeof nombre, stdin) != NULL) {
nombre[strcspn(nombre, "\n")] = '\0'; // quitar el salto de línea
printf("Hola, %s\n", nombre);
}
return 0;
}
fgets conserva el salto de línea final si cabe en el búfer, por eso es habitual eliminarlo con strcspn, que devuelve la posición del primer carácter de un conjunto.
Memoria dinámica para cadenas
Cuando no se conoce de antemano el tamaño de un texto, se reserva memoria en tiempo de ejecución con malloc. La regla de oro es reservar la longitud más uno, para el terminador:
#include <stdlib.h>
#include <string.h>
char *duplicar(const char *origen) {
size_t largo = strlen(origen);
char *copia = malloc(largo + 1);
if (copia == NULL) {
return NULL;
}
memcpy(copia, origen, largo + 1);
return copia;
}
Olvidar ese + 1 es uno de los errores más habituales y produce desbordamientos de un byte, difíciles de detectar porque a menudo el programa parece funcionar. Y toda memoria reservada con malloc debe liberarse con free cuando ya no se necesite, para evitar fugas.
Seguridad: cómo evitar el desbordamiento de búfer
Un desbordamiento de búfer ocurre cuando se escriben más datos de los que caben en un array. Los bytes sobrantes sobrescriben la memoria adyacente, que puede contener otras variables, direcciones de retorno de funciones o estructuras de control. En el mejor de los casos, el programa falla; en el peor, un atacante puede aprovecharlo para ejecutar su propio código.
Las funciones clásicas strcpy, strcat, sprintf y gets son la causa más frecuente, porque escriben sin conocer el tamaño del destino:
char usuario[8];
strcpy(usuario, "administrador_principal"); // escribe 24 bytes en 8
Las alternativas seguras reciben el tamaño del destino. snprintf es la más recomendable para copiar o componer cadenas, porque siempre añade el terminador y devuelve la longitud que habría tenido el resultado completo, lo que permite detectar truncamientos:
char codigo[10];
int necesarios = snprintf(codigo, sizeof codigo, "Pedido-%d", 123456);
if (necesarios >= (int)sizeof codigo) {
fprintf(stderr, "Aviso: texto truncado (%d caracteres necesarios)\n", necesarios);
}
printf("%s\n", codigo);
Aviso: texto truncado (13 caracteres necesarios)
Pedido-12
Ojo con strncpy: aunque parece la versión segura de strcpy, no añade el terminador si el origen ocupa todo el espacio, y rellena con ceros el resto del destino. Si la usas, coloca el terminador manualmente en la última posición.
Además de elegir bien las funciones, los compiladores modernos ayudan a detectar problemas. Compila siempre con avisos activados y, durante el desarrollo, con los sanitizadores:
gcc -std=c2x -Wall -Wextra -Wformat=2 -g -fsanitize=address,undefined programa.c -o programa
El AddressSanitizer detecta en tiempo de ejecución los accesos fuera de límites y muestra un informe con la línea exacta del error. La opción -std=c2x habilita las características de C23 en compiladores que aún no reconocen el nombre definitivo -std=c23.
GCC incluso detecta algunos truncamientos en tiempo de compilación:
warning: '%d' directive output truncated writing 6 bytes into a region of size 3 [-Wformat-truncation=]
Arrays de cadenas
Es habitual necesitar una lista de textos, como los días de la semana o las opciones de un menú. Hay dos formas de declararla, con implicaciones distintas en memoria.
Un array de punteros a literales es la opción más compacta cuando los textos no van a cambiar. Cada elemento ocupa solo lo que mide su puntero, y los literales se guardan en memoria de solo lectura:
const char *dias[] = {"lunes", "martes", "miércoles", "jueves", "viernes"};
size_t total = sizeof dias / sizeof dias[0];
for (size_t i = 0; i < total; i++) {
printf("%zu: %s\n", i + 1, dias[i]);
}
Un array bidimensional de char, en cambio, reserva para cada texto el mismo número de bytes, el de la cadena más larga más el terminador. Desperdicia algo de espacio, pero cada fila es modificable:
char ciudades[3][12] = {"Lima", "Bogotá", "Montevideo"};
strcpy(ciudades[0], "Cusco");
La expresión sizeof dias / sizeof dias[0] es la forma idiomática de calcular el número de elementos de un array en C, y solo funciona en el ámbito donde se declaró el array, no dentro de una función que lo recibe como puntero.
Errores comunes con cadenas en C
El primero es olvidar el espacio para el terminador, al declarar arrays o al reservar memoria con malloc.
El segundo es comparar cadenas con ==. Esa comparación verifica si dos punteros apuntan a la misma dirección, no si el texto es igual. Usa strcmp.
El tercero es modificar un literal de cadena a través de un puntero. Declara los punteros a literales como const char *.
El cuarto es devolver desde una función un puntero a un array local, que deja de existir cuando la función termina:
char *mal_saludo(void) {
char texto[] = "Hola";
return texto; // el array se destruye al salir de la función
}
Las alternativas son devolver memoria reservada con malloc, recibir el búfer de destino como parámetro o usar un literal constante.
El quinto es usar sizeof sobre un parámetro de función esperando obtener el tamaño del array, cuando en realidad devuelve el tamaño del puntero.
El sexto es confundir caracteres y bytes con texto en UTF-8. En UTF-8, letras como la á o la ñ ocupan dos bytes, de modo que strlen("Bogotá") devuelve 7, no 6. Las funciones de string.h trabajan con bytes, no con caracteres visibles. Para manipular texto internacional con precisión se necesitan bibliotecas específicas, como ICU.
Conclusiones
C no tiene un tipo de dato para las cadenas: un texto es un array de char terminado en el carácter nulo '\0'. Esa convención es la base de todo lo demás. Cada array debe tener espacio para los caracteres más el terminador, strlen cuenta los caracteres sin incluirlo y sizeof mide la capacidad del array.
Las cadenas se pueden inicializar carácter a carácter o, de forma más práctica, con un literal entre comillas dobles. Una vez declarado, un array no se puede reasignar con =, sino que su contenido se modifica carácter a carácter o con funciones de copia. Los punteros a literales deben declararse como const char *, porque los literales son de solo lectura.
La biblioteca string.h proporciona las operaciones esenciales: comparar con strcmp, buscar con strchr y strstr, dividir con strtok y, desde C23, duplicar con strdup. Para componer y copiar texto de forma segura, snprintf es la opción preferente; para leer entradas, fgets; y para convertir texto en números, strtol y sus variantes.
Trabajar con cadenas en C exige disciplina: reservar siempre el byte del terminador, respetar los tamaños de los búferes, liberar la memoria dinámica y compilar con avisos y sanitizadores. Con esos hábitos, se aprovecha el control y la eficiencia que ofrece el lenguaje sin caer en los errores que lo han hecho famoso.
