Buscar..


Introducción

Mucho tiempo de los desarrolladores de C ++ se dedica a la depuración. El objetivo de este tema es ayudar con esta tarea y proporcionar inspiración para las técnicas. No espere una lista extensa de problemas y soluciones corregidos por las herramientas o un manual sobre las herramientas mencionadas.

Observaciones

Este tema aún no está completo, los ejemplos sobre las siguientes técnicas / herramientas serían útiles:

  • Mencione más herramientas de análisis estático
  • Herramientas de instrumentación binaria (como UBSan, TSan, MSan, ESan ...)
  • Endurecimiento (CFI ...)
  • Fuzzing

Mi programa de C ++ termina con segfault - valgrind

Tengamos un programa básico de falla:

#include <iostream>

void fail() {
    int *p1;
    int *p2(NULL);
    int *p3 = p1;
    if (p3) {
        std::cout << *p3 << std::endl;
    } 
}

int main() { 
    fail();
}

Constrúyalo (agregue -g para incluir información de depuración):

g++ -g -o main main.cpp

Correr:

$ ./main
Segmentation fault (core dumped)
$

Vamos a depurarlo con valgrind:

$ valgrind ./main
==8515== Memcheck, a memory error detector
==8515== Copyright (C) 2002-2015, and GNU GPL'd, by Julian Seward et al.
==8515== Using Valgrind-3.11.0 and LibVEX; rerun with -h for copyright info
==8515== Command: ./main
==8515==
==8515== Conditional jump or move depends on uninitialised value(s)
==8515==    at 0x400813: fail() (main.cpp:7)
==8515==    by 0x40083F: main (main.cpp:13)
==8515==
==8515== Invalid read of size 4
==8515==    at 0x400819: fail() (main.cpp:8)
==8515==    by 0x40083F: main (main.cpp:13)
==8515==  Address 0x0 is not stack'd, malloc'd or (recently) free'd
==8515==
==8515==
==8515== Process terminating with default action of signal 11 (SIGSEGV): dumping core
==8515==  Access not within mapped region at address 0x0
==8515==    at 0x400819: fail() (main.cpp:8)
==8515==    by 0x40083F: main (main.cpp:13)
==8515==  If you believe this happened as a result of a stack
==8515==  overflow in your program's main thread (unlikely but
==8515==  possible), you can try to increase the size of the
==8515==  main thread stack using the --main-stacksize= flag.
==8515==  The main thread stack size used in this run was 8388608.
==8515==
==8515== HEAP SUMMARY:
==8515==     in use at exit: 72,704 bytes in 1 blocks
==8515==   total heap usage: 1 allocs, 0 frees, 72,704 bytes allocated
==8515==
==8515== LEAK SUMMARY:
==8515==    definitely lost: 0 bytes in 0 blocks
==8515==    indirectly lost: 0 bytes in 0 blocks
==8515==      possibly lost: 0 bytes in 0 blocks
==8515==    still reachable: 72,704 bytes in 1 blocks
==8515==         suppressed: 0 bytes in 0 blocks
==8515== Rerun with --leak-check=full to see details of leaked memory
==8515==
==8515== For counts of detected and suppressed errors, rerun with: -v
==8515== Use --track-origins=yes to see where uninitialised values come from
==8515== ERROR SUMMARY: 2 errors from 2 contexts (suppressed: 0 from 0)
$

Primero nos centramos en este bloque:

==8515== Invalid read of size 4
==8515==    at 0x400819: fail() (main.cpp:8)
==8515==    by 0x40083F: main (main.cpp:13)
==8515==  Address 0x0 is not stack'd, malloc'd or (recently) free'd

La primera línea nos dice que segfault es causado por la lectura de 4 bytes. Las líneas segunda y tercera son de pila de llamadas. Significa que la lectura no válida se realiza en la función fail() , línea 8 de main.cpp, a la que llama main, línea 13 de main.cpp.

Mirando la línea 8 de main.cpp vemos

std::cout << *p3 << std::endl;

Pero primero revisamos el puntero, entonces, ¿qué pasa? Veamos el otro bloque:

==8515== Conditional jump or move depends on uninitialised value(s)
==8515==    at 0x400813: fail() (main.cpp:7)
==8515==    by 0x40083F: main (main.cpp:13)

Nos dice que hay una variable unificada en la línea 7 y la leemos:

if (p3) {

Lo que nos señala la línea donde comprobamos p3 en lugar de p2. Pero, ¿cómo es posible que p3 no esté inicializado? Lo inicializamos por:

int *p3 = p1;

Valgrind nos aconseja volver a ejecutar con --track-origins=yes , hagámoslo:

valgrind --track-origins=yes ./main

El argumento para valgrind es justo después de valgrind. Si lo ponemos después de nuestro programa, sería pasado a nuestro programa.

La salida es casi la misma, solo hay una diferencia:

==8517== Conditional jump or move depends on uninitialised value(s)
==8517==    at 0x400813: fail() (main.cpp:7)
==8517==    by 0x40083F: main (main.cpp:13)
==8517==  Uninitialised value was created by a stack allocation
==8517==    at 0x4007F6: fail() (main.cpp:3)

Lo que nos dice que el valor no inicializado que usamos en la línea 7 se creó en la línea 3:

int *p1;

Lo que nos guía a nuestro puntero sin inicializar.

Análisis de Segfault con GDB

Vamos a usar el mismo código que el anterior para este ejemplo.

#include <iostream>

void fail() {
    int *p1;
    int *p2(NULL);
    int *p3 = p1;
    if (p3) {
        std::cout << *p2 << std::endl;
    } 
}

int main() { 
    fail();
}

Primero vamos a compilarlo.

g++ -g -o main main.cpp

Vamos a ejecutarlo con gdb

gdb ./main

Ahora estaremos en gdb shell. Escriba ejecutar.

(gdb) run
The program being debugged has been started already.
Start it from the beginning? (y or n) y
Starting program: /home/opencog/code-snippets/stackoverflow/a.out 

Program received signal SIGSEGV, Segmentation fault.
0x0000000000400850 in fail () at debugging_with_gdb.cc:11
11            std::cout << *p2 << std::endl;

Vemos que la falla de segmentación está ocurriendo en la línea 11. Por lo tanto, la única variable que se usa en esta línea es el puntero p2. Permite examinar su contenido escribiendo print.

(gdb) print p2
$1 = (int *) 0x0 

Ahora vemos que p2 se inicializó a 0x0, lo que significa NULL. En esta línea, sabemos que estamos intentando eliminar la referencia de un puntero NULO. Así que vamos y lo arreglamos.

Código limpio

La depuración comienza con la comprensión del código que está intentando depurar.

Código incorrecto:

int main() {
    int value;
    std::vector<int> vectorToSort;
    vectorToSort.push_back(42); vectorToSort.push_back(13);
    for (int i = 52; i; i = i - 1)
    {
    vectorToSort.push_back(i *2);
    }
    /// Optimized for sorting small vectors
    if (vectorToSort.size() == 1);
    else
        {
        if (vectorToSort.size() <= 2)
            std::sort(vectorToSort.begin(), std::end(vectorToSort));
        }
    for (value : vectorToSort) std::cout << value << ' ';
return 0; }

Mejor código:

std::vector<int> createSemiRandomData() {
    std::vector<int> data;
    data.push_back(42);
    data.push_back(13);
    for (int i = 52; i; --i)
        vectorToSort.push_back(i *2);
    return data;
}

/// Optimized for sorting small vectors
void sortVector(std::vector &v) {
    if (vectorToSort.size() == 1)
        return;
    if (vectorToSort.size() > 2)
        return;

    std::sort(vectorToSort.begin(), vectorToSort.end());
}

void printVector(const std::vector<int> &v) {
    for (auto i : v)
        std::cout << i << ' ';
}

int main() {
    auto vectorToSort = createSemiRandomData();
    sortVector(std::ref(vectorToSort));
    printVector(vectorToSort);

    return 0;
 }

Independientemente de los estilos de codificación que prefiera y use, tener un estilo de codificación (y formato) consistente lo ayudará a comprender el código.

Mirando el código anterior, uno puede identificar un par de mejoras para mejorar la legibilidad y la depuración:

El uso de funciones separadas para acciones separadas.

El uso de funciones separadas le permite saltear algunas funciones en el depurador si no está interesado en los detalles. En este caso específico, es posible que no esté interesado en la creación o impresión de los datos y solo desee ingresar en la clasificación.

Otra ventaja es que necesita leer menos código (y memorizarlo) mientras recorre el código. Ahora solo necesita leer 3 líneas de código en main() para poder entenderlo, en lugar de toda la función.

La tercera ventaja es que simplemente tiene menos código que ver, lo que ayuda a un ojo entrenado a detectar este error en segundos.

Usando formateo / construcciones consistentes

El uso de un formato y construcciones consistentes eliminará el desorden del código, lo que hará que sea más fácil concentrarse en el código en lugar de texto. Se han alimentado muchas discusiones sobre el estilo de formato 'correcto'. Independientemente de ese estilo, tener un solo estilo consistente en el código mejorará la familiaridad y hará que sea más fácil enfocarse en el código.

Como el código de formato es una tarea que requiere mucho tiempo, se recomienda usar una herramienta dedicada para esto. La mayoría de los IDE tienen al menos algún tipo de soporte para esto y pueden hacer un formateo más consistente que los humanos.

Es posible que tenga en cuenta que el estilo no se limita a espacios y líneas nuevas, ya que ya no mezclamos el estilo libre y las funciones miembro para comenzar / finalizar el contenedor. ( v.begin() vs std::end(v) ).

Señala la atención a las partes importantes de tu código.

Independientemente del estilo que decida elegir, el código anterior contiene un par de marcadores que podrían darle una pista sobre lo que podría ser importante:

  • Un comentario afirmando optimized , esto indica algunas técnicas de fantasía.
  • Algunos resultados tempranos en sortVector() indican que estamos haciendo algo especial
  • El std::ref() indica que algo está sucediendo con el sortVector()

Conclusión

Tener un código limpio lo ayudará a comprender el código y reducirá el tiempo que necesita para depurarlo. En el segundo ejemplo, un revisor de código podría incluso detectar el error a primera vista, mientras que el error podría estar oculto en los detalles del primero. (PS: El error está en la comparación con 2 )

Análisis estático

El análisis estático es la técnica mediante la cual el código comprueba los patrones vinculados a errores conocidos. Sin embargo, el uso de esta técnica requiere menos tiempo que la revisión de un código, sin embargo, sus verificaciones solo se limitan a las programadas en la herramienta.

Las comprobaciones pueden incluir el punto y coma incorrecto detrás de la sentencia if (var); ( if (var); ) hasta algoritmos de gráficos avanzados que determinan si una variable no está inicializada.

Advertencias del compilador

Habilitar el análisis estático es fácil, la versión más simplista ya está incorporada en su compilador:

Si habilita estas opciones, notará que cada compilador encontrará errores que otros no, y que obtendrá errores en técnicas que podrían ser válidas en un contexto específico. while (staticAtomicBool); podría ser aceptable incluso si while (localBool); no es

Así que, a diferencia de la revisión de código, estás luchando contra una herramienta que entiende tu código, te dice muchos errores útiles y, a veces, no está de acuerdo contigo. En este último caso, es posible que deba suprimir la advertencia localmente.

Como las opciones anteriores habilitan todas las advertencias, pueden habilitar las advertencias que no desea. (¿Por qué debería ser compatible su código con C ++ 98?) Si es así, simplemente puede desactivar esa advertencia específica:

  • clang++ -Wall -Weverything -Werror -Wno-errortoaccept ...
  • g++ -Wall -Weverything -Werror -Wno-errortoaccept ...
  • cl.exe /W4 /WX /wd<no of warning>...

Cuando las advertencias del compilador le ayudan durante el desarrollo, ralentizan bastante la compilación. Es por eso que es posible que no siempre desee habilitarlos de forma predeterminada. O los ejecuta de forma predeterminada o habilita cierta integración continua con los cheques más caros (o todos ellos).

Herramientas externas

Si decide tener alguna integración continua, el uso de otras herramientas no es tan difícil. Una herramienta como clang-tidy tiene una lista de comprobaciones que cubre una amplia gama de problemas, algunos ejemplos:

  • Errores reales
    • Prevención de rebanar
    • Afirma con efectos secundarios.
  • Controles de legibilidad
    • Sangría engañosa
    • Comprobar el nombre del identificador
  • Controles de modernización
    • Utilice make_unique ()
    • Utilizar nullptr
  • Verificaciones de rendimiento
    • Encuentra copias innecesarias
    • Encuentra ineficientes llamadas de algoritmo

Es posible que la lista no sea tan grande, ya que Clang ya tiene muchas advertencias de compilación, sin embargo, te acercará un paso más a una base de código de alta calidad.

Otras herramientas

Existen otras herramientas con fines similares, como:

Conclusión

Existen muchas herramientas de análisis estático para C ++, ambas integradas en el compilador como herramientas externas. Probarlos no toma mucho tiempo para configuraciones fáciles y encontrarán errores que podría pasar por alto en la revisión del código.

Apilamiento seguro (corrupciones de la pila)

Las corrupciones de la pila son errores molestos a la vista. Como la pila está dañada, el depurador a menudo no puede darle un buen seguimiento de la pila de dónde se encuentra y cómo llegó allí.

Aquí es donde entra en juego la pila segura. En lugar de usar una sola pila para tus hilos, usará dos: una pila segura y una pila peligrosa. La pila segura funciona exactamente igual que antes, excepto que algunas partes se mueven a la pila peligrosa.

¿Qué partes de la pila se mueven?

Cada parte que tenga el potencial de dañar la pila se moverá de la pila segura. Tan pronto como una variable en la pila se pasa por referencia o una toma la dirección de esta variable, el compilador decidirá asignarla en la segunda pila en lugar de la segura.

Como resultado, cualquier operación que realice con esos punteros, cualquier modificación que realice en la memoria (basada en esos punteros / referencias) solo puede afectar a la memoria en la segunda pila. Como uno nunca recibe un puntero que está cerca de la pila segura, la pila no puede corromper la pila y el depurador aún puede leer todas las funciones de la pila para dar un buen seguimiento.

¿Para qué se usa realmente?

La pila segura no se inventó para darle una mejor experiencia de depuración, sin embargo, es un efecto secundario agradable para los insectos desagradables. Su propósito original es como parte del Proyecto de integridad de puntero de código (CPI) , en el que intentan evitar la anulación de las direcciones de retorno para evitar la inyección de código. En otras palabras, intentan evitar la ejecución de un código de hackers.

Por este motivo, la función se ha activado en cromo y se ha informado que tiene una sobrecarga de CPU <1%.

¿Cómo habilitarlo?

En este momento, la opción solo está disponible en el compilador -fsanitize=safe-stack , donde se puede pasar -fsanitize=safe-stack al compilador. Se hizo una propuesta para implementar la misma característica en GCC.

Conclusión

La corrupción de la pila puede ser más fácil de depurar cuando la pila segura está habilitada. Debido a una baja sobrecarga de rendimiento, incluso puede activarse de forma predeterminada en la configuración de su compilación.



Modified text is an extract of the original Stack Overflow Documentation
Licenciado bajo CC BY-SA 3.0
No afiliado a Stack Overflow