Средство отладки Valgrind входит в состав большинства дистрибутивов Linux. Установка в Ubuntu и Debian:
sudo apt-get install -y valgrind
Valgrind является фреймворком, на основе которого созданы несколько инструментов (tools). Некоторые из них предназначены для поиска ошибок связанных с многопоточносью (Helgrind), другие для оптимизации программ (Cachegrind).
Нас будет в первую очередь интересовать memcheck - инструмент для поиска ошибок, возникающих при работе с памятью. При запуске Valgrind без явного указания инструмента будет запущен именно memcheck.
Для того чтобы выполнить программу процессору не требуется знать, как вы назвали ту или иную переменную или функцию. Поэтому по умолчанию компилятор не включает подобную информацию в скомпилированную программу (за исключением имён функций с внешней компоновкой). В то же время эта информация крайне важна при отладке программы: например, вам скорее всего захочется узнать в каком файле и в какой строке кода произошла ошибка. Для этого компилятор может выводить в файл отладочную информацию, т.е. закодированное в специальном формате (он называется DWARF) соответствие между различными сущностями бинарного представления (адреса в памяти, регистры процессора, смещения в кадрах активации) и исходного кода (имена функций и файлов, номера строк и названия переменных).
Ключи компилятора GCC, отвечающие за отладочную информацию, начинаются с -g...
. Например просто ключ -g
добавляет в файл "базовую" отладочную информацию. Для вывода подробной отладочной информации, в т.ч. расширений предназначенных для отладчика GDB используется ключ -ggdb3
:
gcc -ggdb3 program.c -o program
Важно понимать, что при наличии в программе ошибок (неопределённого поведения) оптимизации компилятора могут изменять наблюдаемое поведение программы. Например, программа может создавать видимость абсолютно корректной работы при оптимизации с уровнем -O1
, но "падать" с уровнем -O3
. Эта неприятная особенность языков C и С++ является "расплатой" за саму возможность применять некоторые важные оптимизации (т.е. за возможность достичь высокой производительности). Я постараюсь написать об этом отдельный пост.
Наиболее удобно отлаживать программу вообще без оптимизации (т.е. собранной без ключей -O...
либо с ключом -O0
), но, как уже было сказано, ошибка при этом может исчезнуть.
Важная особенность компиляции с отладочной информацией: вывод отладочной информации не может влиять на поведение программы, т.е. программа скомпилированная с ключом -g и без него выполняет одни и те же инструкции процессора.
Для запуска программы под 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 же диагностика некоторых ошибок производится бэкэндом, за счёт этого те механизмы (анализ количества итераций цикла), которые используются для оптимизации программы удаётся применить также для диагностики ошибок.