Содержание
Автор: Эли Дэ Брувэр (Elie De Brauwer)
В шестой части этой серии я показал вам несложные способы исследования приложений. В этой статье я представлю инструмент, который позволит вам копнуть глубже, сделать post-mortem анализ и изучить внутреннюю работу приложения. strace/ltrace/valgrind – действительно отличные инструменты, но они покажут вам только часть того, что происходит на самом деле; strace например, показывает только системные вызовы, а valgrind – что происходит с выделением/освобождением памяти. Инструмент, обсуждаемый здесь, называется gdb (The GNU debugger), и для него не существует пределов – если есть что-то, относящееся к приложению, которое вы хотите исследовать, GDB – это то, что вам нужно. На обычной Ubuntu-системе gdb может быть установлен командой:
sudo apt-get install gdb
Все IDE в Linux-системе с возможностью отладки обычно имеют текстовый режим с gdb в качестве основы. Здесь я сфокусируюсь на использовании gdb из командной строки, но знайте, что, когда дело доходит до комплексной отладки, иногда полезно иметь графическое представление. Одной из старейших графических надстроек над gdb является ddd (Data Display Debugger), вы можете установить её, набрав:
sudo apt-get install ddd
Выше представлен скриншот ddd в действии. Он состоит из трёх больших панелей. Вверху находится панель данных, где вы можете вывести переменные и посмотреть их содержимое. В середине вы видите панель с исходным кодом – здесь можно установить точки остановки. Внизу находится панель взаимодействия с gdb. Здесь можно набрать любую команду gdb или нажать на соответствующие кнопки.
Пример для этой статьи называется ifstat. В Ubuntu уже существует приложение ifstat, наш пример ведёт себя так же, но он проще. Приложение представлено в Листинге 1. Цель приложения – печатать каждые 2 секунды скорость передачи данных заданного сетевого устройства. В основе приложения – цикл while (Строки 29-49), в котором читается /proc/dev/net и печатается входящая и исходящая скорость потока заданного сетевого устройства в килобайтах в секунду и в пакетах в секунду. Функция main сама по себе довольно проста (Строки 51-60). Здесь мы проверяем, задан ли один параметр командной строки. Этот параметр станет интерфейсом, за которым мы хотим наблюдать. Если параметры отсутствуют, или их передано слишком много, ечатается сообщение с инструкциями пользователю, как использовать приложение. Пока ничего нового для нас, все новые штуки в функции parseDevFile() (Строки 5-28) будут кратко обсуждены ниже. Эта функция открывает /proc/dev/net и производит парсинг его содержимого; счётчики, которые представляют для насинтерес, будут сохранены в указателях bRx, pRx, bTx и pTx, которые передаются при вызове этой функции. Принимая указатели, мы можем изменить их значения внутри функции. Функция вернет 0 в случае успеха или -1, если произошёл сбой при открытии файла.
Листинг 1: ifstat.c
01. #include <stdio.h> 02. #include <stdlib.h> 03. #include <string.h> 04. #include <unistd.h> 05. typedef unsigned long long ull 06. int parseDevFile(const char * iface, ull *bRx, ull *pRx, 07. ull *bTx, ull *pTx) 08. { 09. FILE * fp = NULL; 10. char * line = NULL; 11. unsigned int len = 0; 12. fp = fopen("/proc/net/dev", "r"); 13. if(fp==NULL) 14. { 15. return -1; 16. } 17. while(getline(&line,&len,fp)!= -1) 18. { 19. if(strstr(line,iface)!=NULL) 20. { 21. sscanf(strstr(line,":")+1,"%llu%llu%*u%*u%*u%*u%*u%*u%llu%llu", 22. bRx, pRx, bTx, pTx); 23. } 24. } 25. fclose(fp); 26. free(line); 27. return 0; 28. } 29. void dumpInterfaceUsage(const char * iface) 30. { 31. ull ifaceBRxOld=0, ifaceBTxOld=0, ifacePRxOld=0, ifacePTxOld=0; 32. ull ifaceBRxNew=0, ifaceBTxNew=0, ifacePRxNew=0, ifacePTxNew=0; 33. const int SLEEP_TIME = 2; 34. 35. if(parseDevFile(iface,&ifaceBRxOld,&ifacePRxOld,&ifaceBTxOld,&ifacePTx Old)==-1) return; 36. sleep(SLEEP_TIME); 37. while(1) 38. { 39. if(parseDevFile(iface,&ifaceBRxNew,&ifacePRxNew,&ifaceBTxNew,&ifac ePTxNew)==-1) return; 40. printf("%s In: %8.2f kbyte/s %5llu P/s Out: %8.2f kbyte/s %5llu P/s\n", iface, 41. (ifaceBRxNew-ifaceBRxOld)/(SLEEP_TIME * 1024.0), 42. (ifacePRxNew-ifacePRxOld)/SLEEP_TIME, 43. (ifaceBTxNew-ifaceBTxOld)/(SLEEP_TIME * 1024.0), 44. (ifacePTxNew-ifacePTxOld)/SLEEP_TIME); 45. ifaceBRxOld=ifaceBRxNew; ifaceBTxOld=ifaceBTxNew; 46. ifacePRxOld=ifacePRxNew; ifacePTxOld=ifacePTxNew; 47. sleep(SLEEP_TIME); 48. } 49. } 50. 51. int main(int argc, char **argv) 52. { 53. if(argc != 2) 54. { 55. printf("Использование: %s имяинтерфейса\n", argv[0]); 56. exit(1); 57. } 58. dumpInterfaceUsage(argv[1]); 59. return 0; 60. }
В нашем примере первое, что мы делаем, – открываем файл – в Строке 9 находится объявление файлового указателя. Строка 12 содержит вызов fopen() (man fopen для подробностей), первый аргумент – это файл, который мы хотим открыть, второй аргумент говорит, как мы хотим открыть файл. В нашем случае «r» значит, что мы хотим открыть файл для чтения. Как только мы закончили чтение файла, мы закрываем его, используя fclose() в Строке 25.
C-стиль I/O
Давайте обсудим C-стиль I/O: вызовы fopen(), fclose(), fread(), fwrite() являются частью стандарта C, и они должны быть доступны на каждой платформе. Вызовы open(), close(), read(), write(), тем не менее, являются частью стандарта POSIX, и являются в действительности внутренними системными вызовами. Один из обычных инструментов для чтения файла – это fread(). Однако, если вы заглянете в справочное руководство, то откроете для себя, что нужно настроить буфер. А именно, определить размер элемента и количество элементов для чтения, а это не очень удобно в нашем случае. Вот почему мы используем getline(); эта функция принимает указатель на указатель как первый аргумент и указатель на целое число как второй аргумент. Внутри эта функция всегда будет читать полную строку и копировать данные в переданный буфер, если в нём достаточно места, или она выделит новый буфер, если места недостаточно (прочтите man getline для подробностей). Нам лишь нужно не забыть освободить указатель, выделенный для нас getline() (Строка 26).
Строки 19-24 делают парсинг строки, прочитанной из файла. Строка 19 проверяет, есть ли внутри прочитанной строки имя интерфейса (что означает, что мы прочитали достаточно строк). Если мы имеем нужную строку, то используем sscanf() для конвертации значений в строке в переменную типа unsigned long long, используемую в нашем приложении. Заметьте, что «*» внутри строки формата означает, что нам неинтересно это значение.
Теперь, скомпилировав и запустив приложение, мы получим следующий вывод во время испытания активности моего беспроводного соединения.
edb@lapedb:~/fullcircle/c-7$ gcc -ggdb -o ifstat ifstat.c edb@lapedb:~/fullcircle/c-7$ ./ifstat wlan0 wlan0 In: 1.36 kbyte/s 16 P/s Out: 1.50 kbyte/s 16 P/s wlan0 In: 103.25 kbyte/s 84 P/s Out: 4.61 kbyte/s 54 P/s wlan0 In: 1.29 kbyte/s 15 P/s Out: 1.50 kbyte/s 16 P/s
Ошибки
К сожалению, это статья об отладке, и несмотря на то, что этот пример работает как надо, он далёк от совершенства. Заметьте, что я компилировал пример с передачей компилятору флага -ggdb, это значит, что отладочная информация встроена внутрь моего исполняемого файла, и это позволит отладчику получить более точную информацию.
Когда я пытаюсь запустить приложение, случайно передав ему «b» в качестве имени интерфейса, оно ведёт себя следующим образом:
edb@lapedb:~/fullcircle/c-7$ ./ifstat b Segmentation fault
Итак, что произошло здесь: видимо, наше приложение попыталось получить доступ к памяти, не принадлежащей ему, а ядру это не понравилось, и оно отправило нам сигнал SIGSEGV. В результате наше приложение завершилось. Есть два варианта того, как мы могли бы поступить в этой ситуации; мы могли бы перезапустить приложение в нашем отладчике и произвести отладку в живую. Или мы могли бы получить core-файл и сделать анализ причин произошедшего. Когда вы встречаетесь с такой ситуацией с любым пакетом вашего дистрибутива, и отправляете отчёт об ошибке, люди часто просят у вас core-файл. Полезно знать, как создать эти core-файлы, так что это мы и сделаем в первую очередь.
edb@lapedb:~/fullcircle/c-7$ ulimit -c unlimited
edb@lapedb:~/fullcircle/c-7$ ./ifstat b Segmentation fault (core dumped)
edb@lapedb:~/fullcircle/c-7$ ls -hal core -rw------- 1 edb edb 280K 2009-03-07 13:33 core
С помощью ulimit можно установить ограничения определённых ресурсов, в частности, размер core-файлов. По умолчанию это значение равно 0. Если мы изменим его на unlimited, приложение сможет создавать core-файлы (core-файл является дампом рабочей памяти приложения). Теперь давайте взглянем на него, используя gdb:
edb@lapedb:~/fullcircle/c-7$ gdb ifstat core GNU gdb 6.8-debian Copyright (C) 2008 Free Software Foundation, Inc. License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html> This is free software: you are free to change and redistribute it. There is NO WARRANTY, to the extent permitted by law. Type "show copying" and "show warranty" for details. This GDB was configured as "i486-linux-gnu"... warning: Can't read pathname for load map: Input/output error. Reading symbols from /lib/tls/i686/cmov/libc.so.6...done. Loaded symbols for /lib/tls/i686/cmov/libc.so.6 Reading symbols from /lib/ld-linux.so.2...done. ПРИМЕЧАНИЕ: и ещё несколько ошибок libc.so.6.
Итак, что мы видим? Мы запустили gdb и передали ему в качестве аргументов наш бинарный файл и core-файл. gdb сообщил нам, что приложение было завершено из-за нарушения сегментации. Мы ввели where, и gdb вывел бэктрейс – список всех функций, которые вызывались; мы видим, что мы начали с main, затем вошли в dumpInterfaceUsage, затем в parseDevFile, которая вызвала sscanf. Обычно мы возлагаем надежду (в данном случае оправданную) на то, что проблема находится внутри кода, который мы только что написали, а не в какой-либо библиотеке, которую мы используем. Итак, наша догадка заключается в том, что мы сделали что-то не так при вызове sscanf(). Так, для уверенности, я «попросил» gdb вывести строчную переменную, после чего можно убедиться, что мы застряли на строке, содержащей «b» (которую мы передали как название сетевого устройства), но функция strstr(), которая ищет символ «:», вернула NULL, потому что в заголовке символ «:» отсутствует. Таким образом, sscanf() попытался считать строку, начиная с адреса 1 в памяти.
Чтобы добиться такого же эффекта в живой сессии, запустите gdb и укажите исполняемый файл в качестве аргумента. В консоли gdb наберите run и далее аргументы для запуска. Произойдёт то же самое:
edb@lapedb:~/fullcircle/c-7$ gdb ifstat (gdb) run b Starting program: /home/edb/fullcircle/c-7/ifstat b Program received signal SIGSEGV, Segmentation fault. 0xb7fd26c7 in rawmemchr () from /lib/tls/i686/cmov/libc.so.6
Но здесь мы не используем core-файл. Ниже приведён вывод живой сессии:
edb@lapedb:~/fullcircle/c-7$ gdb ifstat (gdb) break parseDevFile Breakpoint 1 at 0x80485da: file ifstat.c, line 11. (gdb) run bla Starting program: /home/edb/fullcircle/c-7/ifstat bla Breakpoint 1, parseDevFile (iface=0xbf96175d "bla", bRx=0xbf961290, pRx=0xbf961280, bTx=0xbf961288, pTx=0xbf961278) at ifstat.c:11 11 FILE * fp = NULL; (gdb) step 12 char * line = NULL; (gdb) step 13 unsigned int len = 0; (gdb) step 15 fp = fopen("/proc/net/dev", "r"); (gdb) step 16 if(fp==NULL) (gdb) print fp $1 = (FILE *) 0x9e20008 (gdb) step 21 while(getline(&line,&len,fp)!= -1) (gdb) display line 1: line = 0x0 (gdb) step 23 if(strstr(line,iface)!=NULL) ПРИМЕЧАНИЕ: и ещё несколько ошибок 'line = 0x9e20170'.
Когда мы запускаем приложение с параметром 'bla', мы видим, что все значения скорости равны нулю. И мы решаем разобраться, в чём дело. Если что-то идет не так, мы подозреваем, что проблема находится в parseDevFile, и c помощью команды break parseDevFile указываем отладчику установить точку останова в том месте, где эта функция вызывается. Это означает, что приложение будет запускаться и работать как обычно, но будет остановлено и отобразит оболочку отладчика gdb, как только встретится точка останова. После установки точки останова мы запускаем приложение и получаем сообщение отладчика, когда программа достигнет точки останова. Мы решаем пошагово пройти выполнение функции, используя команды пошаговой отладки (это соответствует выполнению одной строчки кода). После вызова fopen() мы проверяем, действителен и правилен ли указатель на файл. Похоже, что да. Мы решаем вызвать команду display (чтобы выражение каждый раз выводилось на экран) для указателя line, который содержит нашу строку (экранный вывод немного обрезан из соображений форматирования). Но мы видим, что цикл while выполняется без вызова sscanf. Итак, мы можем сделать вывод, что устройство «bla» не существует. Когда мы вызываем cont для продолжения выполнения, мы видим, что в следующий раз программа, обнаруживая точку останова, возвращает нас в оболочку отладчика gdb.
Выводы
В этой статье я ввёл понятие C-стиль I/O и использовал getline(), а также сделал обзор gdb с высоты птичьего полёта. Из-за ограниченного места мне удалось лишь поверхностно рассмотреть gdb. Но я надеюсь, что этого достаточно, чтобы читатель понял: gdb позволяет исследовать, как выполняется приложение, как оно использует систему. Я настоятельно рекомендую всем, кто работает с приложениями на C, уделить время на изучение gdb, поскольку он окажется крайне полезным инструментом, когда речь зайдёт об устранении неполадок приложений. Когда дело дойдет до упражнений из этой статьи, исправьте приложение! Убедитесь, что программа выдаёт предупреждение, когда интерфейс не найден, и сделайте распознавание интерфейса более рациональным.