Сбой редко приходит с объяснением. Программа молча выдаёт не то, что от неё ждут, съедает память за час работы или завершается на ровном месте, а журналы хранят тишину. Между текстом программы и ядром лежит слой библиотечных функций: malloc, strlen, fopen, memcpy, и почти вся реальная работа проходит через него. Утилита ltrace показывает, какие библиотечные функции вызывает программа, с какими аргументами, в каком порядке и что они возвращают, без правок в коде и без специальных сборок. Ниже разобраны порядок вызовов, контроль аргументов, утечки памяти, сравнение сборок и подключение к работающему процессу.
Граница между ядром и библиотекой в инструментах наблюдения
За программой можно наблюдать на трёх этажах. Утилита strace стоит на границе ядра и показывает системные вызовы: открытие файлов, чтение и запись дескрипторов, работу с памятью. Отладчик работает внутри программы: точки останова, значения переменных, стек вызовов. ltrace занимает средний этаж и показывает обращения к функциям библиотек, прежде всего системной libc.
Разница видна на чтении файла. strace сообщит, что программа обратилась к read с дескриптором три и получила девятнадцать байт. ltrace сообщит, что была вызвана функция fread с запросом на тридцать два байта, а внутри неё уже прошли системные read. Имена библиотечных функций говорят о намерении программы, а не о механике ядра.
Ключ -S выводит системные вызовы вместе с библиотечными, и картина собирается целиком. Фрагмент прогона программы, читающей файл:
openat@SYS(AT_FDCWD, "/tmp/data.txt", 0, 00) = 3
file_app->fread(0x7ffde2a12450, 1, 32, 0x6359177ad010 <unfinished ...>
read@SYS(3, "tick data line one\n", 4096) = 19
<... fread resumed> ) = 19
+++ exited (status 0) +++
Библиотечный вызов fread раскрывается на системные read, и обе картины идут одним потоком. Суффикс @SYS отделяет системные вызовы, пометки unfinished и resumed показывают вложенность.
Таблица связывания и точки останова в механике ltrace
Ключевое слово механики - динамическая компоновка. Вызовы из основной программы в библиотеку идут не напрямую, а через таблицу связывания процедур, коротко PLT: она даёт загрузчику возможность подставлять реальные адреса функций в момент первого обращения. ltrace ставит точку останова на слот PLT каждого отслеживаемого вызова: процесс доходит до слота, останавливается, утилита читает регистры и восстанавливает исходную команду, после чего программа продолжает путь в библиотеку. Ловушка возврата даёт значение, которое функция вернула вызывающему коду.
Чтобы печатать аргументы по-человечески, утилите нужны сигнатуры функций. Описания хранятся в файлах прототипов: системный /etc/ltrace.conf, файлы в /usr/share/ltrace и пользовательские каталоги настройки. Для системной библиотеки описания готовы, поэтому strlen печатается как строка, а malloc как число байт. Если описания нет, в трассу уходят необработанные значения регистров, для собственных функций это нормально.
Слот PLT покрывает вызовы между модулями: из основной программы в библиотеку и между библиотеками. Ключ -e работает по этим слотам, а ключ -x берёт имена из таблицы символов и видит вызовы внутри модуля, включая локальные функции программы. Вызов собственной функции duplicate из main невидим при фильтре -e и попадает в трассу при -x:
duplicate(0x5c16bf65b004, 0x7fff515278a8, 0x7fff515278b8, 0x5c16bf65cda0) = 0x5c16c3de3010
Аргументы нечитаемы без прототипа, зато видны порядок и результат. Есть тонкость на стороне библиотек: сборка с опцией -Bsymbolic отправляет внутренние вызовы мимо PLT, и они исчезают из трассы. Системная libc собрана без такого сокрытия, поэтому вызовы вроде memcpy внутри printf видны.
Чтение трассы с аргументами, результатами и вложенностью вызовов
Базовая строка трассы состоит из четырёх частей: вызывающий модуль, имя функции, аргументы, результат. Прогон маленькой программы без фильтров читается легко:
strlen("sample") = 6
malloc(7) = 0x60acefaea010
memcpy(0x60acefaea010, "sample\0", 7) = 0x60acefaea010
printf("%s\n", "sample") = 7
free(0x60acefaea010) = <void>
Первая строка говорит: программа спросила у библиотеки длину строки sample и получила шесть. Четвёртая: печать вернула семь символов. Функции без результата показываются как <void>. Уже этот уровень отвечает, какие функции и в каком порядке вызывает программа, без правки кода.
Здесь же живёт сценарий контроля аргументов. Трасса показывает фактические значения в момент вызова, и расхождение с ожиданием видно раньше ошибки: если в malloc уходит семь байт вместо семидесяти, строка malloc(7) сообщает об этом сама. Когда библиотека возвращает отказ, аргументы объясняют причину быстрее, чем числовой код ошибки.
Когда внутри одного отслеживаемого вызова происходит другой, ltrace печатает внешний вызов с пометкой unfinished, показывает вложенные, а после возврата продолжает прерванную строку словом resumed. Ключ -n с числом добавляет отступ по уровню вложенности:
app_fixed->printf("%s\n", "sample" <unfinished ...>
libc.so.6->memcpy(0x5664b7dd400b, "%&'()*+,-./0123456789:;<=>?@ABCD"..., 140736853430080) = 0x5664b7dd400b
<... printf resumed> ) = 7
Строка memcpy с отступом - внутренняя работа printf. Без -n те же события идут сплошным потоком, и связи приходится угадывать.
Надстройки просты. Ключ -T добавляет время вызова в угловых скобках, по нему видно, что первый malloc стоил 0.001369 секунды, дальше дешевле. Ключ -s ограничивает длину печатаемых строк, по умолчанию тридцать два символа, лишнее сворачивается в многоточие: длинный путь вроде /usr/lib/x86_64-linux-gnu/libc.so.6 в трассе обрезан, лимит поднимается до -s 256.
Фильтры по именам функций для наблюдения без лишнего шума
Трасса без фильтра даже у крошечной программы содержит сотни строк: загрузка библиотек, инициализация libc, служебные вызовы. Первый рабочий ключ - -e, он оставляет только нужные имена:
ltrace -e 'malloc+free' ./app_fixed
Плюс объединяет имена, запись читается как "показывать malloc и free". Для программы с циклом из трёх проходов трасса превращается в ровные пары выделения и освобождения.
Имена в фильтрах понимают шаблоны и регулярные выражения, минус исключает совпадения: запись -e
Ловушка: запятая разделителем не служит. Запись -e 'malloc,free' молча даёт пустой поток, и легко решить, что программа ничего не вызывает. Пустая трасса почти всегда означает опечатку в фильтре, а не тихую программу.
Для отбора по библиотеке существует ключ -l, например -l 'libc.so*' покажет только вызовы в системную библиотеку. Ключ -L отключает библиотечный слой, в паре с -S остаётся поток системных вызовов. Для C++ есть ключ -C, он раскрывает низкоуровневые имена в читаемые.
Сводные счётчики и поиск утечек памяти по парам malloc и free
Самый частый сценарий для собственного кода - память. Утечка почти невидима: объём процесса медленно растёт, а перезапуск откладывает проблему, а не решает. ltrace подходит к задаче прямым счётом: каждый выделенный блок обязан быть освобождён, и если вызовы malloc и free не сходятся в пары, появляется числовая зацепка. Ключ -c сворачивает трассу в таблицу счётчиков. Сводка для программы, которая трижды выделяет память и ни разу не освобождает:
% time seconds usecs/call calls function
------ ----------- ----------- --------- --------------------
79.11 0.000428 142 3 malloc
20.89 0.000113 113 1 exit_group
------ ----------- ----------- --------- --------------------
100.00 0.000541 4 total
Колонки просты: доля времени, секунды, микросекунды на вызов, число вызовов, имя. Три malloc и ни одного free, баланс не сходится. Для исправленной сборки таблица честная: три malloc, три free. Строка exit_group в сводке всегда: это системный вызов завершения, утилита перехватывает его ради итогов.
Порядок поиска утечки сводится к короткому списку действий:
- собрать программу без встраивания стандартных функций, для gcc подходят флаги -O0 и -fno-builtin;
- запустить трассировку с фильтром -e 'malloc+free' и вывести поток в файл;
- снять счётчики ключом -c и сравнить число вызовов malloc и free за одинаковое число проходов;
- при перевесе выделений найти в трассе указатель, который выделяется повторно и не освобождается;
- по порядку окружающих вызовов определить место утечки в коде, закрыть его и повторить замер.
Та же механика сравнивает сборки. Компилятор с оптимизацией встраивает короткие функции прямо в код, и они исчезают из трассы. Сборка без оптимизации показывает все вызовы:
app_fixed->strlen("sample") = 6
app_fixed->malloc(7) = 0x6375721d3010
app_fixed->memcpy(0x6375721d3010, "sample\0", 7) = 0x6375721d3010
В сборке с оптимизацией строки strlen и memcpy исчезают: функции встроены в код программы и через библиотеку не проходят, остаются пары malloc и free. Чтобы видеть вызовы стандартных функций, программу собирают без встраивания. Обратный вывод тоже верен: два прогона на разных сборках показывают не то, что написано в коде, а то, что реально выполняется. Так ловят расхождение между тестовой машиной и сервером.
Подключение к работающему процессу и запись трассы в файл
Перезапустить проблемную программу получается не всегда: сбой живёт в службе с состоянием, которую нельзя останавливать. Для таких случаев существует ключ -p, утилита подключается к работающему процессу по номеру и печатает вызовы без перезапуска:
ltrace -p 4242 -e 'malloc+free'
Через мгновение в консоли появляется живой поток:
exe->malloc(128) = 0x56c224b3c010
exe->free(0x56c224b3c010) = <void>
Имя вызывающего модуля в этом режиме подписывается обобщённо. Остановить наблюдение можно сочетанием Ctrl+C: утилита отключится от процесса, и тот продолжит работу. Права на подключение регулирует настройка ядра ptrace_scope: без root свободно подключаться получается в основном к собственным потомкам.
Тяжёлые прогоны удобнее вести с ключом -o: поток трассы уходит в файл, обычный вывод программы остаётся в консоли, события не смешиваются:
ltrace -o /var/tmp/trace.log -e 'malloc+free' ./app_fixed
Файл удобен: поток можно перепроверить дважды, длинный прогон не теряет начало в буфере терминала, разбор можно доверить grep и wc. Ключ -f подключает к потомкам после fork или clone, строки получают номера процессов.
Ограничения динамической видимости и связка со strace и отладчиком
У ltrace есть честные границы. Утилита видит только вызовы, проходящие через таблицу связывания, плюс то, что разрешает ключ -x по таблице символов. Всё, что компилятор встроил в код, исчезает полностью: вызова нет ни на одном уровне наблюдения. Имена живут в таблице символов, и урезанная сборка без имён оставит ключ -x слепым.
Второе ограничение - скорость. Каждый перехваченный вызов означает остановку процесса, чтение регистров, восстановление команды и ловлю возврата. Плотный цикл превращается в долгое ожидание: тестовый цикл выделения и освобождения памяти без трассировки проходил за тысячные доли секунды, под наблюдением с фильтром занял восемь секунд. На горячих циклах цена огромна, поэтому фильтр -e нужен не только ради читаемости, но и ради времени прогона.
Третье ограничение - аргументы без прототипов: собственные функции печатаются сырыми значениями, и для глубокой проверки придётся либо описывать прототипы в конфигурации утилиты, либо уходить в отладчик.
Связка инструментов закрывает всю высоту программы. strace отвечает за системный слой: файлы, сеть, права, дескрипторы. ltrace - за библиотечный: память, форматирование, стандартные функции. Отладчик - за внутреннюю логику: переменные, условия, стек. Рабочий порядок обычно такой: счётчики ltrace -c и strace -c показывают, где программа тратит время, узкий фильтр -e раскрывает конкретные вызовы, и только вопрос про значения переменных уходит в отладчик.
По сути ltrace держится на одной честной идее: программа разговаривает с библиотеками, и этот разговор можно слушать целиком. Утилита не заменит отладчик, зато за минуту ответит, какие функции вызывает код, с какими аргументами и в каком порядке. Когда журнал молчит, а отладочная сборка недоступна, такой ответ часто оказывается первым конкретным фактом. Именно за это утилиту держат рядом со strace и отладчиком: три взгляда с разной высоты складываются в полную картину.