Лекция 3. Отладка и трассировка
В прошлый раз мы разговаривали о том, как устроено сборочное окружение и как оно формируется, и в числе прочего поговорили про инструменты автоматического набора зависимостей. Разница между и прошлым и позапрошлым разом в том, что в прошлый раз говорили про большие проекты не в одну сотню файлов, когда отыскивать вручную зависимости совсем не хочется. Маленькие и красивые примеры использования подобных вещей найти невозможно. Итак, начав с середины, идём дальше. Ситуация, когда вы сами уже не очень знаете, что у вас в коде творится (например, потому что писали его не вы одни).
Довольно часто бывает так, что программа работает и делает чёрт знает что. Особенно часто это бывает с программами на C, в силу исторических его особенностей. Особенность C — любая синтаксически верная программа скомпилируется и будет выполняться. Никого не волнует, если вы два раза делаете переполнение, делаете чёрте что с указателями, заводите память и не отдаете её. Предполагается, что что вы написали, то и хотели. Например, хотел человек сложить ссылку на int с float’ом — наверное у него были какие-то соображения на этот счёт. Это, а также то, что некоторые люди забывают, что делает их программа на 200-й строчке, приводит к необходимости пошаговой отладки.
Пошаговая отладка
Что это означает для операционной системы — что нормальное выполнение программы заменяется ненормальным и она выполняется так, что в какие-то моменты управление передается программе-отладчику, и она нам что-то показывает. Вот так спроста из бинарного кода это сделать нельзя. Есть два варианта. Первый — аппаратная поддержка. Если нет непосредственно режима работы процессора, при котором после каждого такта дёргается прерывание, на котором висит отладчик, то хотя бы специальной программе можно сказать, что надо вставить это прерывание. Есть ещё совсем аппаратная отладка, когда вместо отладчика используется специальная железка. Например, у ARM есть JTAG-интерфейс. Другой способ — запускать из виртуальной машины, эмулируя выполнение, а из виртуальной машины можно всё. Однако широкой такой практики лектору не встретилось. Проблема с эмуляторами состоит в том, что в эмуляторах медленно и неправдиво реализовано железо.
Поговорим про полуаппаратную поддержку, которая состоит в том, чтобы насильственно вываливаться по прерыванию. Что нам может захотеться при использовании такого пошагового выполнения?
Мы договорились, что пошаговое выполнение у нас будет именно исходного кода на исходном языке. При этом мы стояли перед пропастью и сделали огромный шаг вперед. Одна строчка у нас превращается в кусок бинарного кода. Понятно, что для предоставления такой информации нам понадобятся сами исходники и дополнительная информация, которая будет толковать о соответствии бинарника и исходника. Debuginfo. По умолчанию оно не включено, потому что большое и нужно только для отладки. Что нам ещё нужно помимо исходного текста и debuginfo? Хорошо бы иметь ещё исходные тексты и debuginfo используемых библиотек. При таком раскладе мы можем вообразить себе пошаговое выполнение.
Кто главный враг такого подхода?
Параллелизм это враг номер два. Враг номер один — оптимизация. Хорошая оптимизация может превратить соответствие вашего кода ассемблеру в хорошо перемолотую тыкву. Попробуйте пройти в отладчике программы, скопилированной с -o3. Вы увидите, какие интересные там номера строчек, и не увидите многих своих переменных, вместо них будет optimized out.
На примере gdb давайте придумаем, что мы ещё хотим, кроме пошагового выполнения?
Брекпойнты. Выполняем подряд, но где-то останавливаемся. Точка останова — это возможность выполнять инструкции до какого-то момента и в этот момент остановиться. Командочка b. В gdb есть длинная форма команды и краткие алиасы.
b 25 b main
Остановиться на 25-й строке, остановиться в начале функции.
Условные точки останова — останавливаемся, когда значение по такому-то адресу равно тому-то или когда осуществляется доступ к какой-то ячейке памяти. Более общий вариант — когда некоторое выражение изменило своё значение. Это называется watchpoint. Останов, если выражение поменяло своё значение.
Чего ещё мы можем захотеть? Всего на свете, но не будем забывать, что мы используем не вариант с эмуляцией, а с родным процессором. В C++ ешё можно отследить исключения. Это называется catchpoint.
В каком-то далёком году в Линуксе научились хватать непосредственно системный вызов.
К этому списку добавляются команды по выполнению
- run — запуск
- continue — возобновить выполнение
- next — следующая строчка вашей программы, если была функция, выполнится вся функция
- step — зайти внутрь функции
- final — при заходе в функцию на стек добавляется кадр. final — выполняться до тех пор, пока не начнётся возврат из функции.
- stepi — ходить по машинным инструкциям
- advance — выполнять до такого-то номера строки
Один из самых интересных и показательных инструментов, который можно применить если программа вылетела с ошибкой, это бэктрейс. Бэктрейс — это список стековых кадров со всеми вызванными функциями и их параметрами. Это очень информативно. Если происходит memory corruption, то сразу видно, в какой функции испортился указатель, хотя разыменоваться с плохими последствиями он может дальше. Для этого есть командочка
- backtrace
Забыли главное, командочка list — показать исходник вокруг того места, где остановились.
После backtrace можно сказать list и понять, что творилось, когда программа упала. С помощью командочек up и down можно потом найти кадр, в котором все сломалось.
Если надо посмотреть не код, а память, есть print — печатает на экран результат вычисления некоторого выражения. display — добавит выражение в список, выдающийся каждый раз, когда выполняется останов.
Можно напечатать 16-ричный дамп памяти командочкой dump. Некоторые любят это делать, ощущают себя хакерами.
Команда info — информация о брекпойнтах.
Всего в gdb около 800 команд.
Вот такой вот инструмент, пользоваться которым достаточно легко. Собираете программу с ключом -g
gcc -g a.c gdb a.out b main run
Как передать аргументы? Сказать run аргументы, или запустить gdb.
Что можно сказать в довершение про gdb? Людей обычно пугает его нарочитая командлайновость. Лектор встречал разработчиков, которые написали много-много кода не пользуясь gdb. Есть врапперы.
- ключ --ui,
- cdb
- ужасный ddd, написанный на Tk. Лектор им пользовался, но перестал.
- интегрированные среды типа KDevelop
Самые простые вещи, связанные с разделяемыми библиотеками. Раньше библиотеки были статические, подключались к программе полностью или секциями, в конце концов решили, что это неправильно и перешли на позднее связывание. Библиотека одна, загружена в память, а программы к ней ходят.
Так вот, разделяемая библиотека, загружается on-demand. Есть почти что два способа её загрузить
- системный вызов dlopen() — открываем файл, смотрим заголовки, смотрим таблицу символов и начинаем вызывать. Это зло. Используется, только если ваша ОС совсем не загружает вам разделяемые библиотеки. Или когда у вас в ней символы, которые могут пересекаться с другими — например, когда у вас система плагинов. Отлаживать это очень сложно, потому что с точки зрения трассировщика это просто открытие файла.
- ld.so — заставить системный компоновщик заниматься этим всем при старте.
Будем рассматривать второй вариант. Первый лектор не любит, потому что отлаживать с ним и пакеты собирать — это кхххм.
objdump — утилита со всеми пирогами. Она может показать кучу всего разного. Например, -T покажет какие имена в данной библиотеке экспортируются наружу и что из неё может вызываться. Ну и куча других вещей там есть.
Примерно тоже самое, но уже на уровне работы с заголовком библиотеки
- nm
objdum и nm используются, чтобы посмотреть как устроено пространство имен и что есть в вашей библиотеке.
На самом деле, чтобы разобраться с зависимостями, из objdump и nm надо было бы городить пирог. Это неправильный способ. Правильный состоит в том, что в заголовке бинарника уже сказано, с какими библиотеками он слинкован, и посмотреть этот список можно командой
- ldd
На самом деле это такой специальный способ запуска, при котором вместо компоновки с библиотекой выводится сообщение, что с ней неплохо было бы скомпоноваться. Некоторые программы от обычного пользователя под ldd не запустятся. Тем не менее, это первый инструмент, который используется, если какой-то программе не хватает какой-то библиотеки.
Итак, есть способ определить, с какими библиотеками слинковался бинарник, путём его специфического запуска. Причём этот процесс управляемый, вы можете определить LD_LIBRARY_PATH — путь, по которому лежат дополнительные библиотеки.
Можете совершить более тонкое издевательство над бинарником и подгрузить ему в память заранее вообще любую библиотеку LD_PRELOAD=/home/.../libhack.so ./a.out — сначала загрузить библиотеку, а уже потом принимать решение о подгрузке других библиотек. LD_PRELOAD осуществляет перегрузку нужных вам символов. На этой технологии основана куча веселых хаков. Например, под Линуксом нету 30-дневных триальников, потому что есть faketime, которая перегружает системную time и делает машину времени. Это странные вещи, есть и другие способы применения. fakeroot — запущенная программа думает, что запущена от рута. Тут правда более сложная система, помимо простой перегрузки операций ведется журнал операций. Если не пытаться читать данные из свежесозданных под ним устройств, то всё даже будет работать! Или это называется fakechroot?
- ltrace — программа вызывает какую-то функцию, ltrace перехватывает и пишет диагностику — откуда что вызвано, а потом вызывает.
Ещё больше можно выудить из главной Линукс-библиотеки — libc. libc строго регламентирована, поэтому можно выудить дополнительные диагностические данные.
- strace — трассировка системных вызовов. Все системные вызовы — это очень много, поэтому у strace есть механизмы фильтрации. Например, показать только открытия файлов, или показать только системные вызовы, связанные с работой с файлами.
strace -efile strace -f strace -o out strace -y
strace -y — чудесный ключик, который позволяет посмотреть не номер дескриптора, а имя файла.
У нас осталось пять минут на valgrind. Это подсистема трассировки и отладки, которая использует альтернативный подход — частичную или полную эмуляцию. Запускается виртуальная машина, заточенная под аккуратный учёт того, что происходит с памятью в вашей программе. Например, она может отследить, что на выделенный кусок памяти никто уже не ссылается. Кроме того, там есть работа связанная с оптимизацией — попадание/непопадание в кеш. Примерно такой же механизм для стека функций, который нужен для профилирования. Это обширный инструмент. В дз будет входить протыкивание примера на валгринде.
Для решения задачи непосредственного слежения за памятью есть ещё несколько инструментов, основанных на LD_PRELOAD
- duma (бывший efence)
- gperftools, бывшие google perfomance tools
- gmalloc
Имя им легион.
Домашнее задание:
- Найти 10 самых используемых библиотек
- протыкать пошаговую инструкцию в gdb
- задание на gdb сексема