¿Por qué mi programa no falla cuando escribo más allá del final de una matriz?

12 minutos de lectura

avatar de usuario
vprajan

¿Por qué el siguiente código funciona sin ningún bloqueo en el tiempo de ejecución?

¡Y también el tamaño depende completamente de la máquina/plataforma/compilador! Incluso puedo dar hasta 200 en una máquina de 64 bits. ¿Cómo se detectaría una falla de segmentación en la función principal en el sistema operativo?

int main(int argc, char* argv[])
{
    int arr[3];
    arr[4] = 99;
}

¿De dónde viene este espacio de búfer? ¿Es esta la pila asignada a un proceso?

  • El desbordamiento de pila ocurre cuando se asigna demasiada memoria de la pila. En este caso, suponiendo sizeof(int)==4, ha asignado unos míseros 12 bytes de la pila. Su código está escribiendo más allá del final de una matriz. Eso no es un desbordamiento de pila. Es comportamiento indefinido.

    – David Hamman

    23 de junio de 2011 a las 11:11


  • Viene del mismo lugar donde obtuviste el resto de tu RAM, probablemente quien te vendió la computadora. arr[3] significa “designar 3 int de espacio disponible para mi uso”, no significa “crear 3 int del espacio fuera del éter”, aunque eso sería una implementación legal si fuera físicamente posible. Está garabateando sobre cualquier memoria/dirección que esté adyacente a arr (bueno, al lado-pero-uno de hecho), que como dice David es UB. Sí, es parte de su pila (los estándares de C y C++ no hablan de pila, pero en la práctica es ahí donde van las variables automáticas).

    –Steve Jessop

    23 de junio de 2011 a las 13:16


  • @vprajan: actualicé su título para reflejar la pregunta, ya que aquí hay una buena respuesta para llamar la atención.

    –Steve Townsend

    23 de junio de 2011 a las 13:16

  • “Error de segmentación” y “Accedí a memoria a la que no tenía intención de acceder” son no equivalente. El primero es un subconjunto de los síntomas de realizar el segundo.

    – Carreras de ligereza en órbita

    23 de junio de 2011 a las 13:27

  • @Steve, gracias por actualizarlo…

    – vprajan

    23 de junio de 2011 a las 13:47

avatar de usuario
Federico Pihl

Algo que escribí hace algún tiempo con fines educativos…

Considere el siguiente programa c:

int q[200];

main(void) {
    int i;
    for(i=0;i<2000;i++) {
        q[i]=i;
    }
}

luego de compilarlo y ejecutarlo, se produce un core dump:

$ gcc -ggdb3 segfault.c
$ ulimit -c unlimited
$ ./a.out
Segmentation fault (core dumped)

ahora usando gdb para realizar un análisis post mortem:

$ gdb -q ./a.out core
Program terminated with signal 11, Segmentation fault.
[New process 7221]
#0  0x080483b4 in main () at s.c:8
8       q[i]=i;
(gdb) p i
$1 = 1008
(gdb)

eh, el programa no fallaba cuando uno escribía fuera de los 200 elementos asignados, sino que fallaba cuando i=1008, ¿por qué?

Introducir páginas.

Uno puede determinar el tamaño de la página de varias maneras en UNIX/Linux, una forma es usar la función del sistema sysconf() así:

#include <stdio.h>
#include <unistd.h> // sysconf(3)

int main(void) {
    printf("The page size for this system is %ld bytes.\n",
            sysconf(_SC_PAGESIZE));

    return 0;
}

que da la salida:

El tamaño de página para este sistema es de 4096 bytes.

o se puede usar la utilidad de línea de comandos getconf de esta manera:

$ getconf PAGESIZE
4096

Post mortem

Resulta que la falla de segmento no ocurre en i=200 sino en i=1008, averigüemos por qué. Inicie gdb para hacer un análisis post mortem:

$gdb -q ./a.out core

Core was generated by `./a.out'.
Program terminated with signal 11, Segmentation fault.
[New process 4605]
#0  0x080483b4 in main () at seg.c:6
6           q[i]=i;
(gdb) p i
$1 = 1008
(gdb) p &q
$2 = (int (*)[200]) 0x804a040
(gdb) p &q[199]
$3 = (int *) 0x804a35c

q terminó en la dirección 0x804a35c, o más bien, el último byte de q[199] estaba en ese lugar. El tamaño de página es, como vimos anteriormente, 4096 bytes y el tamaño de palabra de 32 bits de la máquina hace que una dirección virtual se divida en un número de página de 20 bits y un desplazamiento de 12 bits.

q[] terminado en número de página virtual:

0x804a = 32842 compensación:

0x35c = 860 por lo que todavía había:

4096 – 864 = quedan 3232 bytes en esa página de memoria en la que q[] fue asignado. Ese espacio puede contener:

3232/4 = 808 enteros, y el código lo trató como si contuviera elementos de q en la posición 200 a 1008.

Todos sabemos que esos elementos no existen y el compilador no se quejó, ni el hw ya que tenemos permisos de escritura en esa página. Sólo cuando i=1008 hizo q[] referirnos a una dirección en una página diferente para la cual no teníamos permiso de escritura, el hw de la memoria virtual detectó esto y activó una falla de segmento.

Un número entero se almacena en 4 bytes, lo que significa que esta página contiene 808 (3236/4) elementos falsos adicionales, lo que significa que aún es perfectamente legal acceder a estos elementos desde q[200]q[201] hasta el elemento 199+808=1007 (q[1007]) sin desencadenar un fallo de segmentación. Al acceder a q[1008] ingresa a una nueva página para la cual los permisos son diferentes.

  • Eso fue absolutamente fascinante, una de las mejores publicaciones que he leído en SO.

    – pg1989

    23 de junio de 2011 a las 13:04

  • Excelente respuesta, excepto por la parte donde dices “todavía es perfectamente legal acceder a estos elementos desde q[200]q[201] todo el camino hasta el elemento” — sucede que para esta implementación del compilador, acceder a estos elementos no causa ningún problema, pero técnicamente acceder a estos elementos es un comportamiento indefinido, y un compilador diferente sería libre de generar resultados muy diferentes. Es decir, es ilegal acceder a estos elementos, pero bajo estas circunstancias puedes salirte con la tuya. Como ir a 75 mph cuando el límite de velocidad es de 65 mph. 🙂

    –Eduardo Loper

    23 de junio de 2011 a las 13:26


  • +1 Aunque estoy de acuerdo con Edward. La “legalidad” está muy estrictamente definida; ¡No doblemos su significado aquí!

    – Carreras de ligereza en órbita

    23 de junio de 2011 a las 13:28

  • ¡excelente publicación! .. Tenga en cuenta que si se hace lo mismo dentro de una función que no sea la función principal, se detecta una falla de segmentación (desbordamiento de búfer) .. !!

    – vprajan

    23 de junio de 2011 a las 13:54

avatar de usuario
NPE

Dado que está escribiendo fuera de los límites de su matriz, el comportamiento de su código no está definido.

Es la naturaleza del comportamiento indefinido que cualquier cosa puede sucederincluida la falta de segfaults (el compilador no tiene la obligación de realizar una verificación de límites).

Está escribiendo en una memoria que no ha asignado pero que está allí y, probablemente, no se está utilizando para nada más. Su código puede comportarse de manera diferente si realiza cambios en partes aparentemente no relacionadas del código, en su sistema operativo, compilador, indicadores de optimización, etc.

En otras palabras, una vez que estás en ese territorio, todas las apuestas están canceladas.

Con respecto a exactamente cuándo / dónde falla un desbordamiento de búfer de variable local depende de algunos factores:

  1. La cantidad de datos en la pila ya en el momento en que se llama a la función que contiene el acceso variable desbordante
  2. La cantidad de datos escritos en la variable/matriz desbordada en total

Recuerda que las pilas crecen hacia abajo. Es decir, la ejecución del proceso comienza con un puntero de pila cerca del fin de la memoria a ser utilizada como pila. Sin embargo, no comienza en la última palabra mapeada, y eso se debe a que el código de inicialización del sistema puede decidir pasar algún tipo de “información de inicio” al proceso en el momento de la creación, y a menudo lo hace en la pila.

Eso es el habitual modo de falla: un bloqueo al regresar de la función que contenía el código de desbordamiento.

Si el total la cantidad de datos escritos en un búfer en la pila es mayor que la cantidad total de espacio de pila utilizado anteriormente (por las personas que llaman / el código de inicialización / otras variables), entonces obtendrá un bloqueo en cualquier acceso a la memoria que se ejecute primero más allá de la parte superior (comienzo) de la pila. La dirección bloqueada estará justo después de un límite de página: SIGSEGV debido al acceso a la memoria más allá de la parte superior de la pila, donde no hay nada asignado.

Si ese total es menor que el tamaño de la parte usada de la pila en este momento, entonces funcionará bien y fallará. luego – de hecho, en plataformas que almacenan direcciones de retorno en la pila (lo cual es cierto para x86/x64), al regresar de su función. Eso es porque la instrucción de la CPU ret en realidad toma una palabra de la pila (la dirección de retorno) y redirige la ejecución allí. Si en lugar de la ubicación del código esperado, esta dirección contiene cualquier basura, se produce una excepción y su programa muere.

Para ilustrar esto: Cuando main() se llama, la pila se ve así (en un programa UNIX x86 de 32 bits):

[ esp          ] <return addr to caller> (which exits/terminates process)
[ esp + 4      ] argc
[ esp + 8      ] argv
[ esp + 12     ] envp <third arg to main() on UNIX - environment variables>
[ ...          ]
[ ...          ] <other things - like actual strings in argv[], envp[]
[ END          ] PAGE_SIZE-aligned stack top - unmapped beyond

Cuando main() comienza, asignará espacio en la pila para varios propósitos, entre otros, para alojar la matriz que se va a desbordar. Esto hará que se vea como:

[ esp          ] <current bottom end of stack>
[ ...          ] <possibly local vars of main()>
[ esp + X      ] arr[0]
[ esp + X + 4  ] arr[1]
[ esp + X + 8  ] arr[2]
[ esp + X + 12 ] <possibly other local vars of main()>
[ ...          ] <possibly other things (saved regs)>

[ old esp      ] <return addr to caller> (which exits/terminates process)
[ old esp + 4  ] argc
[ old esp + 8  ] argv
[ old esp + 12 ] envp <third arg to main() on UNIX - environment variables>
[ ...          ]
[ ...          ] <other things - like actual strings in argv[], envp[]
[ END          ] PAGE_SIZE-aligned stack top - unmapped beyond

Esto significa que puede acceder felizmente mucho más allá arr[2].

Para una muestra de diferentes bloqueos resultantes de desbordamientos de búfer, intente este:

#include <stdlib.h>
#include <stdio.h>

int main(int argc, char **argv)
{
    int i, arr[3];

    for (i = 0; i < atoi(argv[1]); i++)
        arr[i] = i;

    do {
        printf("argv[%d] = %s\n", argc, argv[argc]);
    } while (--argc);

    return 0;
}

y mira como diferente el bloqueo será cuando desborde el búfer por un poco (digamos, 10) bits, en comparación con cuando lo desborde más allá del final de la pila. Pruébelo con diferentes niveles de optimización y diferentes compiladores. Bastante ilustrativo, ya que muestra tanto el mal comportamiento (no siempre imprimirá todo argv[] correctamente), así como bloqueos en varios lugares, tal vez incluso bucles interminables (si, por ejemplo, el compilador coloca i o argc en la pila y el código lo sobrescribe durante el bucle).

Al usar un tipo de matriz, que C++ ha heredado de C, está pidiendo implícitamente no tener una verificación de rango.

Si intentas esto en su lugar

void main(int argc, char* argv[])
{     
    std::vector<int> arr(3);

    arr.at(4) = 99;
} 

usted será obtener una excepción lanzada.

Así que C++ ofrece una interfaz tanto marcada como no marcada. Depende de usted seleccionar el que desea utilizar.

Ese es un comportamiento indefinido: simplemente no observa ningún problema. La razón más probable es que sobrescribe un área de la memoria de la que el comportamiento del programa no depende anteriormente: esa memoria es técnicamente escribible (el tamaño de la pila es de aproximadamente 1 megabyte en la mayoría de los casos) y no ve ninguna indicación de error. No deberías confiar en esto.

avatar de usuario
Kerrek SB

Para responder a su pregunta de por qué “no se detecta”: la mayoría de los compiladores de C no analizan en el momento de la compilación lo que está haciendo con los punteros y con la memoria, por lo que nadie se da cuenta en el momento de la compilación de que ha escrito algo peligroso. En tiempo de ejecución, tampoco hay un entorno administrado y controlado que cuide sus referencias de memoria, por lo que nadie le impide leer la memoria a la que no tiene derecho. La memoria se le asigna en ese punto (porque es solo una parte de la pila no muy lejos de su función), por lo que el sistema operativo tampoco tiene problemas con eso.

Si desea tener control mientras accede a su memoria, necesita un entorno administrado como Java o CLI, donde todo su programa es ejecutado por otro programa de administración que busca esas transgresiones.

avatar de usuario
Saludos y hth. – alf

Su código tiene un comportamiento indefinido. Eso significa que puede hacer cualquier cosa o nada. Dependiendo de su compilador y sistema operativo, etc., podría bloquearse.

Dicho esto, con muchos, si no la mayoría de los compiladores, su código ni siquiera compilará.

Eso es porque tienes void mainmientras que tanto el estándar C como el estándar C++ requieren int main.

Sobre el único compilador que está feliz con void main es de Microsoft, Visual C++.

Eso es un defecto del compiladorpero dado que Microsoft tiene mucha documentación de ejemplo e incluso herramientas de generación de código que generan void main, es probable que nunca lo arreglen. Sin embargo, tenga en cuenta que escribir documentos específicos de Microsoft void main es un carácter más para escribir que el estándar int main. Entonces, ¿por qué no ir con los estándares?

Saludos y salud,

¿Ha sido útil esta solución?

Esta web utiliza cookies propias y de terceros para su correcto funcionamiento y para fines analíticos y para mostrarte publicidad relacionada con sus preferencias en base a un perfil elaborado a partir de tus hábitos de navegación. Al hacer clic en el botón Aceptar, acepta el uso de estas tecnologías y el procesamiento de tus datos para estos propósitos. Configurar y más información
Privacidad