Skip to content

Latest commit

 

History

History
324 lines (242 loc) · 24.8 KB

valgrind.md

File metadata and controls

324 lines (242 loc) · 24.8 KB

Работа с Valgrind

Установка, общая информация

Средство отладки Valgrind входит в состав большинства дистрибутивов Linux. Установка в Ubuntu и Debian:

sudo apt-get install -y valgrind

Valgrind является фреймворком, на основе которого созданы несколько инструментов (tools). Некоторые из них предназначены для поиска ошибок связанных с многопоточносью (Helgrind), другие для оптимизации программ (Cachegrind).

Нас будет в первую очередь интересовать memcheck - инструмент для поиска ошибок, возникающих при работе с памятью. При запуске Valgrind без явного указания инструмента будет запущен именно memcheck.

Подготовка программы для отладки

Отладочная информация

Для того чтобы выполнить программу процессору не требуется знать, как вы назвали ту или иную переменную или функцию. Поэтому по умолчанию компилятор не включает подобную информацию в скомпилированную программу (за исключением имён функций с внешней компоновкой). В то же время эта информация крайне важна при отладке программы: например, вам скорее всего захочется узнать в каком файле и в какой строке кода произошла ошибка. Для этого компилятор может выводить в файл отладочную информацию, т.е. закодированное в специальном формате (он называется DWARF) соответствие между различными сущностями бинарного представления (адреса в памяти, регистры процессора, смещения в кадрах активации) и исходного кода (имена функций и файлов, номера строк и названия переменных).

Компиляция с выводом отладочной информации при помощи GCC

Ключи компилятора GCC, отвечающие за отладочную информацию, начинаются с -g.... Например просто ключ -g добавляет в файл "базовую" отладочную информацию. Для вывода подробной отладочной информации, в т.ч. расширений предназначенных для отладчика GDB используется ключ -ggdb3:

gcc -ggdb3 program.c -o program

Отладочная информация, оптимизации и поведение программы

Важно понимать, что при наличии в программе ошибок (неопределённого поведения) оптимизации компилятора могут изменять наблюдаемое поведение программы. Например, программа может создавать видимость абсолютно корректной работы при оптимизации с уровнем -O1, но "падать" с уровнем -O3. Эта неприятная особенность языков C и С++ является "расплатой" за саму возможность применять некоторые важные оптимизации (т.е. за возможность достичь высокой производительности). Я постараюсь написать об этом отдельный пост.

Наиболее удобно отлаживать программу вообще без оптимизации (т.е. собранной без ключей -O... либо с ключом -O0), но, как уже было сказано, ошибка при этом может исчезнуть.

Важная особенность компиляции с отладочной информацией: вывод отладочной информации не может влиять на поведение программы, т.е. программа скомпилированная с ключом -g и без него выполняет одни и те же инструкции процессора.

Запуск программы под Valgrind-ом

Для запуска программы под Valgrind-ом вы просто указываете список параметров Valgrind-а, затем название вашей программы, и затем параметры, которые вы хотите передать вашей программе. Пример:

valgrind --leak-check=yes ./my-program -o out.txt < in.txt

Данная команда запустит valgrind с параметром --leak-check=yes (поиск утечек памяти), который с свою очередь запустит программу my-program с параметрами -o out.txt, в поток stdin будет перенаправлен файл in.txt.

Подводные камни:

  • Если запустить Valgrind как valgrind ./my-program --leak-check=yes, то параметр --leak-check=yes будет передан программе my-program, а не valgrind

  • Если вы хотите запустить несколько тестов из shell-скрипта, то следует иметь в виду, что Valgrind по-умолчанию не отслеживает ошибки в дочерних процессах: valgrind ./test-my-program.sh будет проверять командную оболочку bash, а не вашу программу. Но это легко исправить:

    valgrind --trace-children=yes ./test-my-program.sh

  • Valgrind значительно замедляет работу программы (на порядок и более), это несколько сужает область его применения

Для поиска утечек памяти следует использовать ключ --leak-check=yes, ошибки доступа к памяти диагностируются по умолчанию (дополнительных параметров не требуется).

После запуска Valgrind будет выводить ошибки в поток stderr. При желании вы можете перенаправить этот поток в файл средствами командной оболочки (2> log.txt) либо при помощи параметра --log-file.

Виды ошибок и интерпретация результата

Общий формат вывода, бэктрейс

Сообщение об ошибке может выглядеть следующим образом:

==72250== Invalid write of size 8
==72250==    at 0x10200988: bitmap_initialize_stat (bitmap.h:333)
==72250==    by 0x10200988: bitmap_obstack_alloc_stat(bitmap_obstack*) (bitmap.c:286)
==72250==    by 0x102A4E03: df_analyze() (df-core.c:1263)
==72250==    by 0x1055D46F: execute_one_pass(opt_pass*) (passes.c:2332)
==72250==    by 0x1055D983: execute_pass_list_1(opt_pass*) (passes.c:2385)
==72250==    by 0x1055D99B: execute_pass_list_1(opt_pass*) (passes.c:2386)
==72250==    by 0x1055DA23: execute_pass_list(function*, opt_pass*) (passes.c:2396)
==72250==    by 0x1027F6CB: cgraph_node::expand() (cgraphunit.c:1983)
==72250==    by 0x10281097: expand_all_functions (cgraphunit.c:2119)
==72250==    by 0x10281097: symbol_table::compile() (cgraphunit.c:2472)
==72250==    by 0x10282D0B: symbol_table::finalize_compilation_unit() (cgraphunit.c:2562)
==72250==    by 0x1063E78F: compile_file() (toplev.c:508)
==72250==    by 0x1011535F: do_compile (toplev.c:1973)
==72250==    by 0x1011535F: toplev::main(int, char**) (toplev.c:2080)
==72250==    by 0x10117337: main (main.c:39)

Рассмотрим по отдельности элементы, из которых оно состоит.

  • В столбце слева мы видим число ==72250==. Это идентификатор процесса (pid). Если вам вдруг потребуется отлаживать сразу несколько взаимодействующих между собой процессов (хотя в нашем курсе такая необходимость вряд ли возникнет), он поможет вам понять, в каком именно из процессов произошла ошибка.
  • Далее, в первой строке указан вид ошибки (возможные виды ошибок будут рассмотрены далее). В данном случае Valgrind указывает нам, что произошла ошибка доступа к памяти: попытка записать 8 байт в область, к которой корректно написанная программа не должна обращаться.
  • Все последующие строки представляют собой бэктрейс.

Бэктрейс (backtrace, называемый также stack trace, иногда call string, последовательность вызовов) - это способ точно указать в какой именно точке программы произошло интересующее нас событие (в нашем случае, ошибка). Проблемное место - файл bitmap.h, строка 333 (строки нумеруются с 1, этому соглашению следуют все инструменты для работы с кодом) в функции bitmap_initialize_stat. Указан также адрес в памяти, 0x10200988 (адрес мог бы понадобиться, если по какой-то причине не удалось установить соответствие с исходным кодом). Информации о том, что ошибка произошла в функции bitmap_initialize_stat могло бы быть вполне достаточно, чтобы устранить ошибку, но если бы на этом месте оказалась функция memcpy, то вряд ли бы такая информация была полезной: в большой программе могут быть сотни и тысячи вызовов многих частых функций. Поэтому Valgrind показывает нам, откуда была вызвана функция bitmap_initialize_stat, а именно, из функции bitmap_obstack_alloc_stat (в файле bitmap.c, строка 286), а та, в свою очередь была вызвана из функции df_analyze и так далее, до функции main которая вызвала метод main класса toplev в файле toplev.c.

Рассмотрим некоторые типичные ошибки, встречающиеся в программах и то, какие диагностические сообщения будет выводить Valgrind.

Чтение неинициализированной памяти

Попробуем скомпилировать и запустить следующую программу:

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

int main(void)
{
    char *data = malloc(5);
    data[0] = 'a';
    data[1] = 'b';
    data[1] = 'c';                                    //  9
    printf("data[2] is equal to 0x%02x\n", data[2]);  // 10
    if (data[2] == 'c')                               // 11
        printf("It is 'c'\n");
    else
        printf("It is not 'c'\n");
    free(data);
    return 0;
}

Как видим, в ней выделяется массив data, в первые 3 элемента которого по замыслу разработчика записываются символы a, b и c соответственно. В строке 9 (номера строк указаны в комментарии справа) допущена опечатка, из-за которой символ c записывается во второй элемент, а не в третий. Если запустить программу под Valgrind-ом, мы увидим следующее:

==13624== Use of uninitialised value of size 8
==13624==    at 0x4E7B0A1: _itoa_word (_itoa.c:180)
==13624==    by 0x4E7F2A9: vfprintf (vfprintf.c:1641)
==13624==    by 0x4E85DE8: printf (printf.c:33)
==13624==    by 0x400627: main (uninit.c:10)

(ещё несколько похожих сообщений)

data[2] is equal to 0x00
==13624== Conditional jump or move depends on uninitialised value(s)
==13624==    at 0x400635: main (uninit.c:11)
==13624== 
It is not 'c'

Первое сообщение означает, что в функции _itoa_word было использовано неинициализированное значение длиной в 8 байт. Почему именно 8, а не 1? Это определяется тем, что именно Valgrind считает использованием. Дело в том, что оптимизирующий компилятор может генерировать код, в котом будут присутствовать операции копирования неинициализированных значений, которых в исходной программе не было. Чтобы избежать ложноположительных срабатываний Valgrind не считает копирование использованием. При передаче data[2] в виде параметра в функцию printf значение приводится к типу int, затем где-то в самой функции printf преобразуется в 8-байтное значение, и только здесь Valgrind обнаруживает его использование.

Второе сообщение явно указывает строку 11 в нашей программе: "условный переход или пересылка зависит от неинициализированного значения". В программе присутствует сравнение, результат которого зависит от data[2] (а там, как мы знаем, "мусор").

Выход за границу массива

Теперь рассмотрим другой пример:

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

char *my_strdup(const char *src)
{
    size_t len = strlen(src);
    char *dest = malloc(len);              //  8
    memcpy(dest, src, len);
    dest[len] = '\0';                      // 10
    return dest;
}

int main(void)
{
    const char hello[] = "Hello, world!";
    char *copy = my_strdup(hello);
    printf("Result: '%s'\n", copy);        // 18
    free(copy);
    return 0;
}

Здесь заново реализована библиотечная функция strdup, которая выделяет память и копирует в неё строку. В ней намеренно допущена ошибка: выделяется на 1 байт меньше, чем необходимо. Valgrind выдаёт следующие сообщения:

==2467== Invalid write of size 1
==2467==    at 0x400694: my_strdup (oob_write.c:10)
==2467==    by 0x4006CB: main (oob_write.c:17)
==2467==  Address 0x51de04d is 0 bytes after a block of size 13 alloc'd
==2467==    at 0x4C28C20: malloc (vg_replace_malloc.c:296)
==2467==    by 0x40066D: my_strdup (oob_write.c:8)
==2467==    by 0x4006CB: main (oob_write.c:17)
==2467==
==2467== Invalid read of size 1
==2467==    at 0x4E7FE2C: vfprintf (vfprintf.c:1642)
==2467==    by 0x4E85DE8: printf (printf.c:33)
==2467==    by 0x4006E5: main (oob_write.c:18)
==2467==  Address 0x51de04d is 0 bytes after a block of size 13 alloc'd
==2467==    at 0x4C28C20: malloc (vg_replace_malloc.c:296)
==2467==    by 0x40066D: my_strdup (oob_write.c:8)
==2467==    by 0x4006CB: main (oob_write.c:17)
==2467==
Result: 'Hello, world!'

Первое сообщение говорит там о том, что в строке 10 произведена некорректная операция записи 1 байта: байт по адресу 0x51de04d находится непосредственно за (0 bytes after) блоком памяти размером 13 байт, выделенном в строке 8.

Во втором сообщении сказано, что внутри функции vfprintf произошло чтение из того же адреса памяти.

Использование памяти после освобождения

Изменим функцию main в предыдущем примере следующим образом:

int main(void)
{
    const char hello[] = "Hello, world!";
    char *copy = my_strdup(hello);
    free(copy);                              // 18
    printf("Result: '%s'\n", copy);          // 19
    free(copy);                              // 20
    return 0;
}

Сообщение об ошибке тоже изменилось:

==2723== Invalid read of size 1
==2723==    at 0x4E7FE2C: vfprintf (vfprintf.c:1642)
==2723==    by 0x4E85DE8: printf (printf.c:33)
==2723==    by 0x4006F1: main (use_after_free.c:19)
==2723==  Address 0x51de040 is 0 bytes inside a block of size 13 free'd
==2723==    at 0x4C29E90: free (vg_replace_malloc.c:473)
==2723==    by 0x4006DB: main (use_after_free.c:18)

Из него видно, что в функции vfprintf, в которую мы попали, вызвав printf в строке 19, произошло чтение из ранее освобождённой области памяти: адрес 0x51de040 находится непосредственно в начале (0 bytes inside) блока длиной 13 байт, освобождённого в строке 18.

Ещё одна ошибка:

==2723== Invalid free() / delete / delete[] / realloc()
==2723==    at 0x4C29E90: free (vg_replace_malloc.c:473)
==2723==    by 0x4006FD: main (use_after_free.c:20)
==2723==  Address 0x51de040 is 0 bytes inside a block of size 13 free'd
==2723==    at 0x4C29E90: free (vg_replace_malloc.c:473)
==2723==    by 0x4006DB: main (use_after_free.c:18)

В строке 20 мы пытаемся освободить память, которая уже была освобождена ранее. Кстати, эту ошибку часто способен обнаружить аллокатор памяти в библиотеке glibc. Если мы запустим программу без Valgrind, то увидим следующее:

Result: ''
*** Error in `./a.out': double free or corruption (fasttop): 0x0000000000d00010 ***
Aborted

Повторный вызов free приводит к аварийному завершению программы (Valgrind же подменяет функцию free на собственную реализацию и этого не происходит). Как видим, сообщение менее информативно: неясно где именно произошла ошибка. Об этом можно было бы узнать, запустив программу под отладчиком GDB, но в отличие от Valgrind, он не помог бы отследить, когда память была освобождена в первый раз.

Утечки памяти

Теперь изменим функцию main в нашем примере следующим образом:

int main(void)
{
    const char hello[] = "Hello, world!";
    char *copy = my_strdup(hello);           // 17
    printf("Result: '%s'\n", copy);
    return 0;
}

Если запустить программу под Valgrind-ом с ключом --leak-check=yes, то Valgrind сообщит нам о том, какие именно блоки памяти не были освобождены:

==2977== HEAP SUMMARY:
==2977==     in use at exit: 13 bytes in 1 blocks
==2977==   total heap usage: 1 allocs, 0 frees, 13 bytes allocated
==2977== 
==2977== 13 bytes in 1 blocks are definitely lost in loss record 1 of 1
==2977==    at 0x4C28C20: malloc (vg_replace_malloc.c:296)
==2977==    by 0x40061D: my_strdup (leak.c:8)
==2977==    by 0x40067B: main (leak.c:17)

Ограничения

Несмотря на то что Valgrind крайне мощный и полезный инструмент, он всё же не способен справиться с некоторыми типами ошибок при работе с памятью. А именно, Valgrind не обнаруживает ошибки доступа к переменным со статическим и автоматическим временем жизни (иначе говоря, памяти выделенной в сегменте данных либо на стеке). Обнаруживаются только ошибки, возникающие при работе с динамической памятью (кучей). Например, рассмотрим следующую программу:

#include <stdio.h>

char dest[2];

int main(void)
{
    char src[2];
    src[0] = 'a';
    src[1] = 'b';
    for (int i = 0; i <= 2; i++)
        dest[i] = src[i];
    printf("%s\n", dest);          // 12
    return 0;
}

Valgrind обнаруживает в ней одну ошибку:

==3379== Conditional jump or move depends on uninitialised value(s)
==3379==    at 0x4C2C1B8: strlen (vg_replace_strmem.c:412)
==3379==    by 0x4EA09FB: puts (ioputs.c:36)
==3379==    by 0x40043C: main (static.c:12)

Хотя на самом деле в ней присутствует выход за границы массивов src и dest.

Лирическое отступление. Обнаружение выхода за границы массива на этапе компиляции

Ошибки, имеющися в приведённом примере может обнаружить оптимизирующий компилятор. Скомпилируем программу с оптимизацией:

gcc -g -O2 -std=c99 -Wall -Wextra static.c

GCC выдаёт следующие предупреждения:

static.c: In function ‘main’:
static.c:11:22: warning: iteration 2u invokes undefined behavior [-Waggressive-loop-optimizations]
         dest[i] = src[i];
                      ^
static.c:10:5: note: containing loop
     for (int i = 0; i <= 2; i++)
     ^
static.c:11:22: warning: array subscript is above array bounds [-Warray-bounds]
         dest[i] = src[i];
                      ^
static.c:11:13: warning: array subscript is above array bounds [-Warray-bounds]
         dest[i] = src[i];
             ^

Clang (вплоть до последней на сегодняшний день версии 3.7) в этой ситуации уступает GCC. Он не способен обнаружить проблем в этой программе. Это связано с принципиально различающимися подходами GCC и Clang к диагностике: Clang (это т.н. фронтэнд компилятора) анализирует программу, переводит её в промежуточное представление и одновременно пытается диагностировать ошибки. Далее промежуточное представление оптимизируется бэкэндом (LLVM), на этом этапе диагностика ошибок не производится. В GCC же диагностика некоторых ошибок производится бэкэндом, за счёт этого те механизмы (анализ количества итераций цикла), которые используются для оптимизации программы удаётся применить также для диагностики ошибок.