Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Secuencias: Arreglos y Cadenas

Colecciones de datos homogéneos y texto.

Universidad Nacional de Río Negro

Introducción

En C, una secuencia es una colección de elementos de datos del mismo tipo, almacenados en ubicaciones de memoria contiguas. Esta organización en memoria es una de las características que definen el rendimiento de C, ya que permite al hardware acceder a los datos con una gran eficiencia.

La forma más general de una secuencia es el arreglo, que puede contener cualquier tipo de dato, como int, float o estructuras más complejas que veremos más adelante. Aquí, el rol del programador es gestionarlos; definiendo su tamaño y respetando sus límites, ya que el lenguaje no lo hará por nosotros.

Un caso particular y muy importante es la cadena de caracteres. En C, una cadena no es un tipo de dato especial, sino una convención: se trata de un arreglo de caracteres (char) cuyo final se indica con un carácter especial, el carácter nulo (\0). Esta convención es la base sobre la que se construye toda la manipulación de texto en C y también la de algunos problemas.

Desarrollo

Introducción a las Secuencias en C

Arreglos: Secuencias de datos

Definición y Disposición en Memoria

Un arreglo es una colección de tamaño fijo de elementos homogéneos. Al declararlo, el compilador reserva un bloque de memoria continuo y suficientemente grande para albergar todos sus elementos.

int mi_arreglo[4];

Esta declaración reserva espacio para 4 enteros. Si un int ocupa 4 bytes, la disposición en memoria es contigua:

Mapeo de un arreglo en la memoria física RAM. Los elementos se ordenan en
bloques contiguos.

Figure 1:Mapeo de un arreglo en la memoria física RAM. Los elementos se ordenan en bloques contiguos.

Esta contigüidad es lo que permite el acceso indexado (mi_arreglo[2]) de forma casi instantánea.

Declaración e Inicialización

La declaración de un arreglo sigue la sintaxis tipo identificador[cantidad];.

La inicialización define los valores iniciales del arreglo. Es importante distinguir entre arreglos locales (dentro de una función) y los globales o estáticos:

Formas de inicialización explícita:

Inicialización de arreglos en C. Si se omiten elementos, el compilador los
rellena con ceros.

Figure 2:Inicialización de arreglos en C. Si se omiten elementos, el compilador los rellena con ceros.

El Operador sizeof

Para comprender mejor el manejo de memoria de los arreglos, es necesario introducir el operador sizeof.

Este operador es una herramienta de tiempo de compilación que devuelve el tamaño en bytes de un tipo de dato o una variable. Esto es posible por la forma en la que C crea las variables en la memoria, con un único tipo, los “tipos estáticos”. El valor que retorna es de tipo size_t, un tipo de entero sin signo.

1
2
3
4
5
6
int numeros[10];

// sizeof(int) -> Devuelve el tamaño de un entero (4 bytes)
// sizeof(numeros[0]) -> Devuelve el tamaño de los elementos del arreglo (4
bytes)
// sizeof(numeros) -> Devuelve el tamaño total del arreglo (40 bytes)

Esto nos permite calcular la cantidad de elementos de un arreglo de una forma simple, esto es de suma importancia en C, ya que los arreglos no guardan su tamaño.

1
2
3
4
// Funciona incluso si cambiamos 'numeros' a 'long numeros[]'.
size_t cantidad = sizeof(numeros) / sizeof(numeros[0]);
// La cuenta es, el tamaño total del arreglo / el tamaño de un elemento.
// Qué aplicado al arreglo anterior, nos debiera dar 10.

Teniendo en cuenta que los tamaños de los tipos básicos como int o long, pueden cambiar de tamaño entre compiladores.

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>

int main(void) {
    printf("Tamaño de char: %zu bytes\n", sizeof(char));
    printf("Tamaño de int: %zu bytes\n", sizeof(int));
    printf("Tamaño de float: %zu bytes\n", sizeof(float));
    printf("Tamaño de double: %zu bytes\n", sizeof(double));
    printf("Tamaño de long long: %zu bytes\n", sizeof(long long));
    return 0;
}

La salida de este código en una computadora de escritorio actual sería:

Tamaño de char: 1 bytes
Tamaño de int: 4 bytes
Tamaño de float: 4 bytes
Tamaño de double: 8 bytes
Tamaño de long long: 8 bytes

Una característica del lenguaje C es que los tamaños de sus tipos de datos numéricos pueden variar entre diferentes arquitecturas de hardware. El operador sizeof permite escribir código portable que no dependa de un tamaño de tipo de dato fijo, facilitando la adaptación del software a distintas plataformas.

Por ejemplo, históricamente, un int en sistemas de 16-bits ocupa 2 bytes, mientras que en sistemas de 32-bits o 64-bits, comúnmente ocupa 4 bytes.

El uso de sizeof es crucial en la gestión de memoria dinámica, un tema que se abordará más adelante.


Acceso, Modificación y la Identidad del Arreglo

Se accede a los elementos mediante el operador de subíndice [], donde el índice va de 0 a cantidad - 1.

Acceso (lectura)

Para leer el valor de un elemento, se utiliza el operador de subíndice [] con el índice del elemento deseado. Es importante recordar que los índices en C comienzan en cero. Una expresión de acceso como miArreglo[i] es un “r-value”, ya que representa un valor que puede ser leído.

Por ejemplo int valor = mi_arreglo[3];. Aquí, mi_arreglo[3] nos permite acceder al valor de la cuarta posición.

1
2
3
4
5
6
7
8
9
10
11
int calificaciones[5] = {10, 8, 9, 7, 10};

// Obtener el valor del primer elemento (índice 0)
int primera = calificaciones[0];

// Obtener el valor del cuarto elemento (índice 3)
int cuarta = calificaciones[3];

printf("La primera calificación es: %d\n", primera);
printf("La cuarta calificación es: %d\n", cuarta);
printf("Acceso directo al segundo elemento: %d\n", calificaciones[1]);

Salida:

La primera calificación es: 10
La cuarta calificación es: 7
Acceso directo al segundo elemento: 8
Modificación (escritura)

Para escribir un nuevo valor en un elemento, la expresión de subíndice mi_arreglo[i] se coloca en el lado izquierdo de una operación de asignación. En este contexto, la expresión es un “l-value”, ya que representa una ubicación de memoria modificable.

Por ejemplo, mi_arreglo[3] = 100;. Acá, mi_arreglo[3] nos permite modificar el cuarto valor de la secuencia.

1
2
3
4
5
6
7
int edades[4] = {20, 25, 22, 28};
// 1. Mostrar el valor original del tercer elemento (índice 2)
printf("La edad original en el índice 2 es: %d\n", edades[2]);
// 2. Modificar el valor en el índice 2. 'edades[2]' es un l-value.
edades[2] = 23;
// 3. Mostrar el valor modificado. 'edades[2]' se evalúa como un r-value.
printf("La nueva edad en el índice 2 es: %d\n", edades[2]);

Salida:

La edad original en el índice 2 es: 22
La nueva edad en el índice 2 es: 23
Identidad

El identificador de un arreglo, como mi_arreglo, es especial. No es una variable que contiene el arreglo, sino que está permanentemente asociado con la dirección de memoria del primer elemento de esa secuencia. Por esta razón, no se puede reasignar para que se refiera a otra secuencia. La operación arr1 = arr2; es ilegal. Para copiar los valores, se debe recorrer y copiar cada elemento, uno por uno.

El identificador de un arreglo es una constante que representa la dirección de inicio del bloque de memoria asignado. Por esta razón, el nombre de un arreglo es un l-value no modificable.

1
2
3
4
5
int arr1[5] = {1, 2, 3, 4, 5};
int arr2[5] = {10, 20, 30, 40, 50};

// La siguiente línea es ilegal y causará un error de compilación.
arr1 = arr2; // Error: expression is not assignable.

Recorrido de Arreglos y Comportamiento Indefinido

La estructura de control ideal para iterar sobre un arreglo es el lazo for. El uso de size_t para el índice del lazo es la forma correcta de hacerlo, tal como lo indica la regla de estilo Regla 0x3010h: Las variables que representan tamaños o índices de arreglos deben ser de tipo size_t.

1
2
3
4
5
6
int numeros[] = {10, 20, 30, 40, 50};
size_t cantidad = sizeof(numeros) / sizeof(numeros[0]);

for (size_t i = 0; i < cantidad; i++) {
    printf("Elemento %zu: %d\n", i, numeros[i]);
}

Arreglos de Longitud Variable (ALV/VLA)

Desde el estándar C99, C permite declarar arreglos cuyo tamaño se determina en tiempo de ejecución. Estos se conocen como ALV o VLA (en Inglés).

1
2
3
4
5
6
7
8
int cantidad = 0;
printf("Ingrese el tamaño del arreglo:\n");
scanf("%d", &cantidad);

int arreglo[cantidad]; // Declaración de un VLA

printf("El tamaño del arreglo en bytes es: %zu\n", sizeof(arreglo));
// El resultado será sizeof(int) * cantidad

Estos tienen limitaciones importantes. Por ejemplo, un ALV no puede ser inicializado en su declaración. Intentarlo producirá un error de compilación:

error: variable-sized object may not be initialized

Las implicaciones y el uso correcto de la memoria dinámica, que es la alternativa recomendada a los ALV, se abordarán en Memoria Dinámica.

De todas formas y como se imaginarán, hay una regla de estilo Regla 0x5001h: Los arreglos estáticos deben ser creados con un tamaño fijo en tiempo de compilación.

El Mecanismo de Paso a Funciones: Decaimiento de Arreglos

En C, todo pasaje de parámetros se realiza estrictamente por valor (copia física en el Stack Frame). Sin embargo, los arreglos poseen un comportamiento físico particular al ser transmitidos a una función: el compilador no realiza una copia de todos los elementos del arreglo en el registro de activación de la función receptora.

En su lugar, el nombre del arreglo decae implícitamente a un puntero que almacena la dirección de memoria de su primer elemento (es decir, arr se evalúa como &arr[0]). Lo que recibe la función en su Stack Frame es una copia por valor de ese puntero (dirección física).

Como consecuencia, cualquier lectura o modificación que la función realice sobre los elementos utilizando el operador de indexación ([]) afectará de forma directa e inmediata a los datos originales en la memoria del programa, logrando una simulación de pasaje por referencia mediante indirección.

Decaimiento físico de un arreglo a puntero al ser pasado a una función. El
parámetro ptr en el Stack Frame de la función mostrar recibe una copia del
valor de la dirección física del inicio del arreglo 0x7FFEE100.

Figure 3:Decaimiento físico de un arreglo a puntero al ser pasado a una función. El parámetro ptr en el Stack Frame de la función mostrar recibe una copia del valor de la dirección física del inicio del arreglo 0x7FFEE100.

Funciones Puras y con Efectos Secundarios

Al trabajar con secuencias, la distinción entre funciones puras y aquellas con efectos secundarios (ver Funciones Puras y con Efectos Secundarios) adquiere una relevancia crítica debido al mecanismo de pasaje de parámetros en C. Como los arreglos se transmiten mediante su dirección de memoria (paso por referencia simulado), las funciones pueden modificar su contenido directamente en el invocador.

Funciones puras sobre arreglos

Una función que opera sobre arreglos es pura si se limita a leer sus elementos sin alterar el contenido original. Para indicar explícitamente esta intención y garantizar la portabilidad y seguridad, se debe usar el calificador const en el parámetro del arreglo (ver regla de estilo Regla 0x3007h: Los argumentos de tipo puntero deben ser const siempre que la función no los modifique).

Ejemplo de función pura:

1
2
3
4
5
6
7
8
9
10
11
12
int maximo(const int valores[], size_t cantidad) 
{
    int max = valores[0];
    for (size_t i = 1; i < cantidad; i++) 
    {
        if (valores[i] > max) 
        {
            max = valores[i];
        }
    }
    return max;
}

Esta función sólo lee el contenido del arreglo y devuelve un resultado. No altera el contenido original.

Funciones con efectos secundarios en arreglos

Una función con efectos secundarios modifica el contenido del arreglo original. En este caso, el parámetro de arreglo no debe llevar el calificador const.

Ejemplo de función con efectos secundarios:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void ordenar(int v[], size_t cantidad) 
{
    for (size_t i = 0; i < cantidad - 1; i++) 
    {
        for (size_t j = 0; j < cantidad - i - 1; j++) 
        {
            if (v[j] > v[j + 1]) 
            {
                int temp = v[j];
                v[j] = v[j + 1];
                v[j + 1] = temp;
            }
        }
    }
}

Esta función cambia el contenido del arreglo original; su ejecución modifica el estado de la secuencia en el invocador.

¿Por qué distinguirlas?
Buenas prácticas
Estrategia mixta

En algunos casos, puede ser útil definir una función pura que calcule un resultado y otra función con efectos que lo aplique.

Por ejemplo:

1
2
3
4
5
6
int encontrar_maximo(const int v[], int cantidad);
void imprimir_maximo(const int v[], int cantidad) 
{
    int m = encontrar_maximo(v, cantidad);
    printf("El máximo es %d\n", m);
}

Esta separación permite testear encontrar_maximo independientemente de la función que interactúa con el usuario.

Esto explica dos situaciones clave:

Efectos secundarios en arreglos

Al recibir la “dirección de la casa”, los cambios en el arreglo que pasamos a la función, se reflejan en el arreglo original, efectivamente dando un resultado por fuera del retorno explícito de la función.

Esta situación se puede dar cuando utilizamos al arreglo como una variable más.

1
2
3
4
5
6
7
8
int maximo(int arreglo[], size_t size) {
    for (size_t i = 1; i < size; i++) {
        if (arreglo[i] > arreglo[0]) {
            arreglo[0] = arreglo[i];
        }
    }
    return arreglo[0];
}

Contraejemplo con efectos secundarios destructivos

El problema principal del código es el efecto secundario destructivo. La función no solo calcula el valor máximo, sino que también modifica de manera irreversible el arreglo que se le pasa como argumento.

Este tipo de comportamiento puede llevar a errores sutiles y difíciles de depurar, especialmente en programas grandes donde el arreglo original podría ser necesario para operaciones posteriores. Por ejemplo, si el valor original en la posición 0 fuera crucial para otro cálculo, esa información se perdería permanentemente. Para evitar modificaciones no deseadas, es una buena práctica usar el calificador const en los parámetros de arreglo que no deben ser alterados, adhiriendo a la regla de estilo Regla 0x3007h: Los argumentos de tipo puntero deben ser const siempre que la función no los modifique.

Resolver este problema solo requiere agregar una variable para reemplazar arreglo[0], pero es un buen contraejemplo de un uso negativo de los efectos secundarios de los arreglos.

Si la función (o procedimiento) modifica el arreglo, esto debe estar claramente expresado en la documentación.

Aunque parezca algo negativo, la utilización de efectos secundarios en arreglos es sumamente importante y se utiliza en código que, por ejemplo, ordene los valores que contiene.

Pérdida de sizeof

Dentro de la función, sizeof(arreglo) no funciona como se espera. La función solo conoce la dirección en memoria del arreglo, no el tamaño total de la secuencia original.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
 * Esta funcion, ¡no cumple con su objetivo!
 * para cualquier arreglo que le pasemos;
 * ¡vamos a obtener el mismo valor!
 */
size_t obtener_tamanio(int arreglo[]) {
    printf("Tamaño del arreglo: %zu\n", sizeof(arreglo));
    // El arreglo es siempre de tamaño 8 (la dirección)
    printf("Tamaño de un valor: %zu\n", sizeof(arreglo[0]));
    // El valor apuntado va a ser siempre `int` con 4 bytes.
    return sizeof(arreglo) / sizeof(arreglo[0]);
}

int main(void) {
    int arreglo1[] = {10, 20, 30, 40, 50};
    int arreglo2[20];
    size_t uno = obtener_tamanio(arreglo1);
    size_t dos = obtener_tamanio(arreglo2);
    printf("Tamaño de arreglo1: %zu\n", uno); // obtenemos 2
    printf("Tamaño de arreglo2: %zu\n", dos); // obtenemos 2
    return 0;
}

Esta situación se explica porque, teniendo en cuenta que sizeof se resuelve en tiempo de compilación, el operador no puede saber con qué arreglo vamos a llamar a la función.

Por lo tanto, para que una función pueda trabajar sobre cualquier arreglo, se debe pasar el tamaño de forma explícita, como un argumento separado. La firma correcta de la función debe incluir el tamaño del arreglo como parámetro. El uso de size_t para el tamaño (Regla 0x3010h: Las variables que representan tamaños o índices de arreglos deben ser de tipo size_t) y el hecho de pasar el tamaño explícitamente (Regla 0x300Ch: Verificá siempre los límites de los arreglos antes de acceder a sus elementos) son cruciales para la seguridad y portabilidad.

1
2
3
4
5
6
7
8
9
10
11
/**
 * Imprime los elementos de un arreglo de enteros en la salida estándar.
 *
 * @param arreglo de números a mostrar.
 * @param size    la cantidad de valores en el arreglo.
 *
 * @post
 * - Los elementos del arreglo `arreglo` se han impreso.
 * - El `arreglo` no será modificado.
  */
void imprimir_arreglo(int arreglo[], size_t size);

Retorno de Secuencias desde Funciones

Una función no puede retornar un arreglo local. Las variables de un arreglo local se alojan en el registro de activación (stack frame) de la función en la pila. Al ejecutarse la instrucción de retorno, el registro de activación de la función se desapila y destruye de forma física en memoria lógica, quedando ese espacio disponible para ser sobrescrito por cualquier llamada subsiguiente en el programa. Intentar acceder a la dirección de memoria de un objeto local que ya ha sido liberado del stack constituye una desreferenciación de puntero colgante y provoca comportamiento indefinido o fallas de segmentación.


Cadenas: Secuencias de Caracteres

Una cadena es un arreglo de char que sigue una regla: el último carácter de interés está seguido por un carácter nulo (\0). Este terminador es crucial, ya que las funciones de biblioteca como strlen o printf con %s dependen de él para saber dónde termina el texto.

El comportamiento general de una cadena es el mismo que el de un arreglo.

Por ejemplo, la siguiente cadena:

char cadena[7] = "Hola";
Representación en memoria de una cadena con terminador nulo. La cadena “Hola”
ocupa 5 bytes (4 caracteres + ‘\0’), pero el arreglo tiene capacidad para 7. Los
bytes no inicializados contienen basura. El terminador ‘\0’ marca el fin lógico
de la cadena y es esencial para las funciones de string.h.

Figure 4:Representación en memoria de una cadena con terminador nulo. La cadena “Hola” ocupa 5 bytes (4 caracteres + ‘\0’), pero el arreglo tiene capacidad para 7. Los bytes no inicializados contienen basura. El terminador ‘\0’ marca el fin lógico de la cadena y es esencial para las funciones de string.h.

En donde los ? quizás sean cero (\0), pero como no está inicializado, no podemos estar seguros del valor que tendrá, a todos los efectos prácticos, es basura.

También, si aplicamos el cálculo de tamaño usando sizeof que vimos antes, vamos a obtener el tamaño en bytes de la cadena.

size_t espacio_reservado = sizeof(mi_cadena) / sizeof(mi_cadena[0]);

Esto coincide con el largo del arreglo, ya que en el estándar del lenguaje C, el operador sizeof(char) es siempre igual a 1 por definición. El byte en C es, precisamente, el tamaño de almacenamiento físico de un char. Esto es independiente de la estrategia de codificación de caracteres empleada por la plataforma (como ASCII o UTF-8), garantizando la portabilidad de este cálculo.

Por lo tanto, el espacio_reservado tendría 7, y no el largo de la cadena que es 4.

Inicialización de una cadena

Al declarar una cadena usando la sintaxis de arreglo, estás creando una copia local y mutable del texto basada en el literal de cadena; el texto entre comillas dobles ("), sumando un carácter más para el terminador \0.

char mi_cadena[] = "Texto inicial";

Es sumamente importante destacar que los literales, que no están asignados a una variable arreglo (char []), no pueden ser modificados. Esto significa que nuestros programas no funcionaran si pasamos como argumento un literal a una función que modifica dicha cadena.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
void ordena_caracteres(char cadena[]) {
    size_t n = strlen(cadena);
    for (size_t i = 0; i < n - 1; i++) {
        for (size_t j = 0; j < n - i - 1; j++) {
            if (cadena[j] > cadena[j + 1]) {
                char temp = cadena[j];
                cadena[j] = cadena[j + 1];
                cadena[j + 1] = temp;
            }
        }
    }
}

int main(void) {
    char mi_cadena[] = "ejemplo de cadena desordenada";

    printf("Cadena original: \"%s\"\n", mi_cadena);

    // Llama a la función para ordenar la cadena.
    ordena_caracteres(mi_cadena);

    printf("Cadena ordenada: \"%s\"\n", mi_cadena);

    // Además, ¿en dónde modificamos la cadena?
    ordena_caracteres("ejemplo de cadena desordenada");

    return 0;
}

Modificando cadenas

Y la salida sería:

:linenos:
:emphasize-lines: 3
Cadena original: "ejemplo de cadena desordenada"
Cadena ordenada: "   aaaacdddddeeeeeejlmnnooprs"
Segmentation fault (core dumped)

Ese Segmentation fault (core dumped) resulta de intentar modificar algo que no debía ser modificado, por lo que es importante utilizar una variable de cadena intermedia para evitar este tipo de errores.

Más adelante veremos detalles adicionales sobre los literales de cadena y qué situaciones nos podemos encontrar si no los utilizamos con cuidado.

Largo de cadenas

Para obtener el largo de una cadena, podemos usar strlen, definido en <string.h>.

Esta función está definida de la siguiente forma:

size_t strlen(const char str[]);

Y se encarga de recorrer la cadena hasta encontrarse un carácter nulo (\0)

Más información sobre strlen


Lectura Segura de Cadenas

El uso de scanf("%s", buffer) y gets() constituye una de las mayores vulnerabilidades de seguridad en lenguaje C. La alternativa segura y exigida es fgets, de acuerdo con la regla de estilo Regla 0x5006h: Preferí fgets sobre gets y scanf para leer cadenas.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
#include <string.h>

#define BUFFER_SIZE 100

void leer_entrada(void) {
    char buffer[BUFFER_SIZE];
    
    printf("Ingrese su nombre: ");
    // fgets garantiza que no se lean más bytes que el tamaño máximo del búfer
    if (fgets(buffer, BUFFER_SIZE, stdin) != NULL) {
        // Remover el '\n' si fue capturado
        size_t len = strlen(buffer);
        if (len > 0 && buffer[len - 1] == '\n') {
            buffer[len - 1] = '\0';
        }
        printf("Nombre ingresado: %s\n", buffer);
    }
}

Biblioteca Estándar <string.h>: Un Vistazo Rápido

Las funciones de biblioteca para manipular cadenas más importantes:

Más información sobre string.h y otras funciones disponibles.


Cadenas Seguras: El Dilema del NUL

El modelo de cadenas de C —terminadas en '\0' sin información de longitud— es una fuente histórica de vulnerabilidades. La Regla Regla 0x5004h: Todas las operaciones con cadenas deben ser seguras exige usar operaciones seguras. Esta sección fundamenta por qué.

Las cadenas de caracteres en el lenguaje C son una de sus características más fundamentales y, a la vez, una de sus mayores fuentes de vulnerabilidades de seguridad. Entender su diseño histórico es clave para comprender por qué son inseguras y por qué surgieron las “cadenas seguras”.

Cadenas Tradicionales en C: El Legado del NUL

Historia y Diseño

Cuando Dennis Ritchie y Ken Thompson diseñaron el lenguaje C en la década de 1970, lo hicieron pensando en la eficiencia y el hardware de la época (como la PDP-11). Los recursos eran extremadamente limitados. Para representar una secuencia de caracteres, adoptaron una convención simple y eficaz:

Este estilo se conoce como cadena terminada en nulo (o null-terminated string). Por ejemplo, la cadena “HOLA” se almacena en memoria como:

Hola\0

Esta simplicidad era genial para los sistemas de esa era. No se necesitaba almacenar metadatos adicionales como la longitud; la función que leía la cadena simplemente avanzaba hasta encontrar el \0.

La Raíz de la Inseguridad: Ausencia de Información de Tamaño

La principal debilidad de este diseño es que la propia cadena no sabe cuál es su longitud ni la capacidad del buffer que la contiene. Para obtener su longitud, una función como strlen() debe recorrerla carácter por carácter desde el inicio hasta encontrar el \0, una operación de complejidad O(n)O(n).

El verdadero peligro surge con funciones de manipulación como strcpy() (copiar), strcat() (concatenar) o gets() (leer desde la entrada estándar). Estas funciones escriben datos en una memoria destino asumiendo ciegamente que hay espacio suficiente.

El Ataque Clásico: Buffer Overflow (Desbordamiento de Búfer)

Imagina este código:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
#include <string.h>

void vulnerable() {
    char buffer_pequeno[10]; // Buffer con capacidad para 9 caracteres + \0
    char entrada_maliciosa[] = "AAAAAAAAAAAAAAAAAAAA"; // 20 caracteres

    //strcpy no sabe que buffer_pequeno solo tiene 10 bytes.
    //Copiará los 20 caracteres de la entrada, más el \0.
    strcpy(buffer_pequeno, entrada_maliciosa);

    printf("Contenido del buffer: %s\n", buffer_pequeno);
}

Al ejecutar strcpy(), se escriben 21 bytes (20 ‘A’ más el \0) en un espacio de solo 10 bytes. Los 11 bytes extra se escriben fuera de los límites del buffer_pequeno, sobrescribiendo otras áreas de la memoria en el stack.

Esto puede causar desde un simple fallo del programa hasta las peores vulnerabilidades de seguridad, como la ejecución de código arbitrario, si un atacante diseña la entrada_maliciosa para sobrescribir la dirección de retorno de la función con la dirección de su propio código malicioso.

Cadenas Seguras: La Solución del Tamaño Explícito

Para solucionar estos problemas, surgieron las “cadenas seguras”. El concepto fundamental es simple pero poderoso: la estructura de datos de la cadena debe contener información sobre su tamaño.

Una cadena segura generalmente almacena dos piezas clave de metadatos junto a los datos de los caracteres:

  1. Longitud (length): El número actual de caracteres en la cadena.

  2. Capacidad (capacity): El tamaño total del bloque de memoria asignado para la cadena, indicado como un size_t.

Con esta información, cualquier operación de escritura puede verificar primero si longitud < capacidad.

Ventajas Clave de las Cadenas Seguras

Implementaciones y Alternativas

Existen varias formas de implementar cadenas seguras en C y otros lenguajes.

1. Anexo K de C11 (Funciones _s)

El estándar C11 introdujo un anexo opcional (Anexo K) con versiones “seguras” de las funciones de cadena tradicionales. Estas funciones, como strcpy_s(), strcat_s() y snprintf_s(), generalmente toman un argumento adicional: el tamaño del búfer de destino.

1
2
3
4
5
6
7
8
9
10
11
12
// Ejemplo con strcpy_s
char buffer[10];
const char* origen = "Texto largo";

// strcpy_s verifica que el tamaño del buffer (10)
// es suficiente para copiar el origen.
// En este caso, devolvería un error en lugar de desbordar.
errno_t resultado = strcpy_s(buffer, sizeof(buffer), origen);

if (resultado != 0) {
    // Manejar el error de copia
}

Problema: Este anexo es opcional y su adopción ha sido muy limitada. Compiladores populares como GCC y Clang no lo implementan por defecto, y la comunidad lo ha criticado por su diseño y usabilidad. Microsoft sí lo implementa en su compilador MSVC.

2. Bibliotecas de Terceros (La Solución Práctica en C)

La forma más común y recomendada de usar cadenas seguras en C es a través de bibliotecas de alta calidad.

1
2
3
4
5
6
7
8
9
10
// Ejemplo (conceptual) de uso de SDS
#include "sds.h"

sds mi_cadena = sdsnew("Hola "); // Crea una nueva cadena SDS
mi_cadena = sdscat(mi_cadena, "Mundo!"); // Concatena de forma segura

printf("%s\n", mi_cadena); // Imprime "Hola Mundo!"
printf("Longitud: %zu\n", sdslen(mi_cadena)); // Obtiene la longitud en O(1)

sdsfree(mi_cadena); // Libera la memoria

Tabla Comparativa

CaracterísticaCadenas NUL-TerminadasAnexo K de C11 (_s)Bibliotecas (SDS) / C++ std::string
SeguridadMuy Baja (propensa a overflows)Mejorada (requiere disciplina del programador)Alta (seguridad por diseño)
Obtener LongitudO(n) - LentoO(n) - LentoO(1) - Instantáneo
Gestión de MemoriaManual y propensa a erroresManual (el programador provee el buffer)Automática y gestionada por la biblioteca/objeto
Facilidad de UsoSimple para tareas básicas, compleja para seguridadVerbosa y propensa a errores de usoMuy alta, interfaz de alto nivel
Datos BinariosNo (el \0 actúa como terminador)No (la longitud determina el final)

Conclusión

Aunque las cadenas tradicionales de C son un pilar histórico de la programación de sistemas, su diseño es inherentemente inseguro para el desarrollo de software moderno. Ignorar la información sobre el tamaño del búfer fue una decisión comprensible en su momento, pero ha sido la causa de innumerables vulnerabilidades.

Conexión con el Siguiente Tema

Los arreglos que estudiamos tienen una limitación crítica: tamaño fijo determinado en compilación. Si declaramos int arr[100], consumimos memoria para 100 enteros incluso si solo usamos 10. Si necesitamos 101, el programa no compila. Esta rigidez es problemática para software real que debe adaptarse a cantidades variables de datos.

Además, cuando pasamos arreglos a funciones, vimos que en realidad estamos pasando la dirección del primer elemento. ¿Qué significa “dirección”? ¿Cómo manipulamos estas direcciones directamente?

El próximo tema introduce conceptos que profundizan en cómo se organiza y manipula la memoria:

Los punteros son el concepto más poderoso y peligroso de C. Dominando punteros y arreglos simultáneamente, se comprende la esencia del lenguaje: control directo sobre la memoria con la sintaxis mínima necesaria.

Pregunta puente: Cuando escribimos int arr[5], ¿dónde exactamente en la memoria se almacenan estos 5 enteros? ¿Cómo accede la CPU a arr[3]? La respuesta requiere entender direcciones de memoria, lo que nos lleva naturalmente a los punteros.

Textos Fundamentales

Algoritmos sobre Arreglos

Cadenas y Procesamiento de Texto

Recursos en Línea

Herramientas

Ejercicios y Práctica

Ejercicios de Autoevaluación

Inicialización y Memoria

Solution to Exercise 1

La expresión correcta es:

size_t cantidad = sizeof(temperaturas) / sizeof(temperaturas[0]);

Este cálculo es portable porque:

  • sizeof(temperaturas) devuelve el espacio en bytes reservado para todo el arreglo (ej: 50×8=40050 \times 8 = 400 bytes en plataformas de 64 bits).

  • sizeof(temperaturas[0]) devuelve el tamaño de un único elemento del arreglo, equivalente a sizeof(double) (8 bytes). El cociente 400/8400 / 8 es siempre 50, sin importar cuántos bytes asigne la arquitectura física al tipo double.

Solution to Exercise 2

En C, cuando se inicializa de forma parcial un arreglo:

  • Los elementos correspondientes a los inicializadores explícitos toman los valores indicados: datos[0] vale 1, datos[1] vale 2, y datos[2] vale 3.

  • El estándar del lenguaje garantiza que todos los elementos restantes que no fueron declarados explícitamente (datos[3] a datos[9]) se inicializan automáticamente a 0. No contienen datos “basura”, a diferencia de una declaración sin inicializador como int datos[10];.

Solution to Exercise 3

La inicialización estática {0} requiere que el compilador conozca el tamaño y el diseño del arreglo en tiempo de compilación para generar las instrucciones de asignación de memoria correspondientes en el segmento de datos. Dado que el tamaño de un ALV/VLA se determina recién en tiempo de ejecución (según el valor de la variable n), el compilador no puede generar el bloque de inicialización de antemano. Para inicializar un VLA, debés declararlo y luego rellenar sus celdas de forma procedimental (por ejemplo, mediante un lazo for).

Acceso e Iteración

Solution to Exercise 4

Al ser una función pura, se utiliza el calificador const para prometer que el arreglo original no será alterado.

1
2
3
4
5
6
7
8
9
10
11
12
#include <stddef.h>

int buscar_minimo(const int arreglo[], size_t size) {
    // #PRE: size > 0
    int minimo = arreglo[0];
    for (size_t i = 1; i < size; i++) {
        if (arreglo[i] < minimo) {
            minimo = arreglo[i];
        }
    }
    return minimo;
}
Solution to Exercise 5

Al ser una función con efectos secundarios, modificamos el arreglo original de forma directa en su memoria física:

1
2
3
4
5
6
7
#include <stddef.h>

void escalar_arreglo(int arreglo[], size_t size, int factor) {
    for (size_t i = 0; i < size; i++) {
        arreglo[i] = arreglo[i] * factor; // Efecto secundario
    }
}

El paso por referencia se simula porque el compilador no copia los elementos a la función; en su lugar, le pasa la dirección del primer elemento del arreglo. La indexación arreglo[i] opera sobre la misma dirección física de la memoria del invocador.

Solution to Exercise 6

Esto ocurre debido al fenómeno de decaimiento de arreglo a puntero (array decay). Cuando un arreglo se pasa como parámetro a una función, la firma int arr[] es convertida implícitamente por el compilador en un puntero al primer elemento (int *arr). Dado que el operador sizeof evalúa el tipo de datos de su operando en tiempo de compilación, calcula el tamaño de la variable parámetro arr (que es de tipo puntero, ocupando 8 bytes en sistemas de 64 bits), habiéndose perdido la información sobre la capacidad del bloque de memoria del arreglo original.

Cadenas y Caracteres

Solution to Exercise 7

La diferencia radica en la región de memoria física donde se almacena el contenido:

  • char *p = "Hola": Crea un puntero p que apunta directamente a la dirección del literal de cadena "Hola", el cual reside en una sección de la memoria del programa de solo lectura (como la sección .rodata). Intentar modificar esa región física viola los permisos de hardware, provocando un fallo de segmentación (Segmentation Fault).

  • char s[] = "Hola": Declara un arreglo local en el stack y copia los caracteres del literal "Hola" (incluido el \0) a dicho arreglo. Modificar s[0] es totalmente válido porque estamos escribiendo sobre memoria local del stack asignada al programa.

Solution to Exercise 8

La función strlen recorre de forma secuencial la memoria byte a byte desde la dirección base recibida, incrementando un contador de longitud, hasta encontrar el primer carácter que sea igual a \0. Si el arreglo carece de \0, strlen continuará leyendo más allá de los límites del arreglo en posiciones de memoria consecutivas (acceso fuera de límites). Esto provocará que retorne un largo erróneo de forma impredecible o que el programa aborte con un error de violación de acceso (Segmentation Fault) si intenta leer una página de memoria no asignada.

Solution to Exercise 9
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>
#include <string.h>

int main(void) {
    char mensaje[100] = "Hola Mundo";
    
    size_t capacidad = sizeof(mensaje);
    size_t longitud = strlen(mensaje);

    // Se utiliza %zu para variables de tipo size_t
    printf("Capacidad física: %zu bytes\n", capacidad);
    printf("Longitud lógica: %zu caracteres\n", longitud);
    
    return 0;
}

La salida será:

Capacidad física: 100 bytes
Longitud lógica: 10 caracteres
  • sizeof(mensaje) retorna la capacidad del arreglo físico (100 bytes).

  • strlen(mensaje) retorna la cantidad de caracteres antes del \0 (10).

Cadenas Seguras

Solution to Exercise 10
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>
#include <string.h>

int main(void) {
    char buffer[80];
    printf("Ingresá un texto: ");
    
    if (fgets(buffer, sizeof(buffer), stdin) != NULL) {
        size_t len = strlen(buffer);
        // Si el último carácter es un salto de línea, lo reemplazamos por el terminador nulo
        if (len > 0 && buffer[len - 1] == '\n') {
            buffer[len - 1] = '\0';
        }
        printf("Leído de forma segura: \"%s\"\n", buffer);
    }
    return 0;
}
Solution to Exercise 11
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <ctype.h>
#include <stddef.h>

int comparar_ignorar_caso(const char s1[], const char s2[]) {
    size_t i = 0;
    while (s1[i] != '\0' && s2[i] != '\0') {
        char c1 = tolower((unsigned char)s1[i]);
        char c2 = tolower((unsigned char)s2[i]);
        
        if (c1 != c2) {
            return c1 - c2;
        }
        i++;
    }
    return tolower((unsigned char)s1[i]) - tolower((unsigned char)s2[i]);
}
Solution to Exercise 12

La función strcpy copia los bytes de la cadena src al buffer dest de forma incondicional hasta encontrar el \0 en src. No tiene conocimiento del tamaño físico asignado al buffer dest. Si la longitud de src excede la capacidad de dest, strcpy continuará escribiendo sobrepasando los límites del arreglo. Esto sobrescribe variables adyacentes en el stack, incluyendo punteros y la dirección de retorno de la función actual. Un atacante puede explotar esto para secuestrar el flujo de control ejecutando código malicioso (vulnerabilidad de stack buffer overflow). Para evitarlo, deben emplearse funciones con control de tamaño explícito o comprobar los límites de antemano.

Glosario

size_t
Según el estándar ISO C de 1999 (C99), size_t es un tipo de dato entero sin signo de al menos 16 bits (en las secciones 7.17 y 7.18.3). Este se utiliza para representar el tamaño de un objeto. Las funciones de la biblioteca que toman o devuelven tamaños esperan que estos sean de este tipo. Además, el operador sizeof, que es evaluado en tiempo de compilación, debe dar como resultado un valor que debe ser al menos, compatible. Para utilizarlo, es necesario importar stddef.h o stdlib.h. Esto también implica que un size_t es un tipo garantizado para contener cualquier índice de un arreglo. Para mas información, ver: CPPReference - size_t
Literal de cadena
Es el texto que se escribe directamente en el código, encerrado entre comillas dobles, como “Texto inicial”. Piensa en él como una plantilla de texto original y constante que el programa crea al compilarse. Este literal se almacena en una parte de la memoria del programa que se considera fija y no debe alterarse. Cuando creas un arreglo como char mi_cadena[] = "Texto inicial";, lo que realmente sucede es que el contenido de este literal se copia al arreglo, permitiendo su modificación.

Síntesis y Resumen

Este apunte introduce las secuencias (arreglos) como la primera estructura de datos para manejar colecciones homogéneas, y las cadenas como caso especial para texto.

Referencias y Lecturas Complementarias

References
  1. Kernighan, B. W., & Ritchie, D. M. (2014). C Programming Language, 2nd Edition.
  2. King, K. N. (2008). C Programming: A Modern Approach (2nd ed.). W. W. Norton & Company.
  3. Weiss, M. A. (2014). Data Structures and Algorithm Analysis in C (2nd ed.). Pearson.
  4. Cormen, T. H., Leiserson, C. E., Rivest, R. L., & Stein, C. (2009). Introduction to Algorithms (3rd ed.). MIT Press.
  5. Sedgewick, R., & Wayne, K. (2011). Algorithms (4th ed.). Addison-Wesley. https://algs4.cs.princeton.edu/
  6. Seacord, R. C. (2013). Secure Coding in C and C++ (2nd ed.). Addison-Wesley.
  7. Gustedt, J. (2019). Modern C. Manning Publications. https://modernc.gforge.inria.fr/
  8. Sommers, J. (2025). jsommers/cbook. https://github.com/jsommers/cbook (Original work published 2017)