C-strings: el peligro de los terminadores nulos

Un C-string es, esencialmente, un array de tipo char que utiliza un carácter especial, el null terminator ('\0'), para marcar el final de la secuencia de texto. A diferencia de std::string [C++23], que es un contenedor que conoce su propio tamaño, un C-string es solo una dirección de memoria; su longitud se determina recorriendo la memoria hasta encontrar el valor cero.

Esta arquitectura existe por la simplicidad del lenguaje C: permitir que las funciones procesen cadenas de texto sin necesidad de pasar un parámetro adicional con la longitud, ahorrando memoria en sistemas con recursos extremadamente limitados. En C++, los literales de cadena como "Hola" no son punteros por sí mismos, sino arrays de tipo const char[5] (el quinto carácter es el '\0') que, en la mayoría de los contextos, decay (decaen) a un const char*.

Deberías usar C-strings principalmente cuando necesites interactuar con APIs de C (interoperabilidad), en sistemas embebidos donde no puedes permitirte el overhead de la gestión dinámica de std::string, o en algoritmos de muy bajo nivel donde el control absoluto del layout de memoria sea crítico. Sin embargo, si usas C-strings de forma incorrecta, romperás la seguridad de tu programa: si olvidas el terminador nulo, las funciones de la librería estándar seguirán leyendo memoria más allá del array, provocando un out-of-bounds read (lectura fuera de límites), lo que deriva en comportamiento indefinido, fugas de información o fallos de segmentación.

#include <iostream>
#include <cstring> // strlen, strncpy, memmove, strcmp
#include <cstdio>  // snprintf

int main() {
    // 1. Literales y decay: "Hola" es const char[5], decae a const char*
    const char* mensaje = "Hola";

    // 2. Uso seguro de buffers con snprintf
    // Evitamos el peligro de sprintf que no comprueba límites
    char buffer[20];
    std::snprintf(buffer, sizeof(buffer), "Saludo: %s", mensaje);
    std::cout << "Buffer seguro: " << buffer << "\n";

    // 3. La trampa de strncpy
    // Si el tamaño es igual o menor a la longitud, no añade el '\0'
    char truncado[5];
    std::strncpy(truncado, "TextoLargo", 4); 
    // truncado contiene {'T', 'e', 'x', 't'} sin el terminador nulo.
    // Cualquier intento de imprimirlo o usar strlen() disparará UB.
    // La forma correcta de usarlo es asegurar el nulo manualmente:
    truncado[4] = '\0'; 

    // 4. Comparación lexicográfica
    const char* s1 = "apple";
    const char* s2 = "apple";
    if (std::strcmp(s1, s2) == 0) {
        std::cout << "Las cadenas son iguales\n";
    }

    // 5. El problema del solapamiento (overlap): memcpy vs memmove
    // Imagine que queremos desplazar datos dentro de un mismo buffer
    char datos[] = "ABCDE";
    char destino[10];
    std::memcpy(destino, datos, 5); // Copia estándar sin solapamiento

    // Si intentamos desplazar contenido sobre sí mismo:
    // memcpy(datos + 1, datos, 4); // Comportamiento indefinido si hay overlap
    std::memmove(datos + 1, datos, 4); // Seguro: memmove gestiona el solapamiento
    
    std::cout << "Datos tras memmove: " << datos << "\n";

    return 0;
}

Para compilar este código:
g++ -std=c++20 -Wall -Wextra -Wpedantic -o ejemplo ejemplo.cpp

Análisis del código

Fíjate en el comportamiento de std::snprintf: a diferencia del antiguo sprintf, esta función recibe el tamaño del buffer (sizeof(buffer)), garantizando que nunca escriba más bytes de los que el array puede contener. Esto es vital para prevenir el buffer overflow.

En el caso de std::strncpy(truncado, "TextoLargo", 4), estamos cometiendo un error clásico de lógica. Le decimos que copie como máximo 4 caracteres. Como la palabra “TextoLargo” es más larga, la función llena los 4 bytes disponibles, pero la especificación de strncpy dice que si no hay suficientes caracteres, no rellenará con '\0'. Esto deja a truncado en un estado inválido: un array de 5 elementos que no tiene un terminador, lo que significa que la función std::cout seguirá leyendo la memoria de truncado hasta encontrar un cero por puro azar.

Sobre la diferencia entre std::memcpy y std::memmove: cuando realizas operaciones de manipulación de memoria cruda, como desplazar un bloque de datos dentro de un mismo array (overlap), std::memcpy no garantiza la integridad de los datos porque asume que las regiones de origen y destino no se solapan. std::memmove, en cambio, está diseñado para manejar este escenario, ya sea copiando los datos a un área temporal o realizando una copia hacia atrás, asegurando que la operación sea segura.

El uso de std::strcmp es fundamental para la lógica de control, pero recuerda que su resultado no es un booleano, sino un entero (0 si son iguales), debido a su herencia de C para permitir comparaciones de ordenación rápida.

El error frecuente

El error más sutil y peligroso con strncpy ocurre cuando el tamaño n es exactamente igual a la longitud de la cadena de origen (sin contar el \0).

char buffer[4];
std::strncpy(buffer, "abc", 3); 
// buffer es {'a', 'b', 'c'}, pero NO tiene '\0'
std::cout << buffer << std::endl; // Undefined Behavior: lectura fuera de límites

Muchos desarrolladores creen que strncpy es una versión “segura” de strcpy, pero solo lo es en términos de no desbordar el buffer de destino, no en términos de mantener la validez de la cadena. Para usarla con seguridad, siempre debes asegurar el terminador: buffer[n-1] = '\0';. Si buscas seguridad de forma nativa, std::string_view [C++17] o std::string son tus mejores aliados.

29

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio