В этом разделе обсудим отладку программ и подробнее остановимся на отладчике gdb- GNU Debugger.
Вообще, отладка - это огромная тема, подробнее этот аспект можно изучить в этой книжке.
Отладочные данные
В реалиях работы с программами мы не всегда даже можем добыть исходники, и отладка будет проводиться над ассемблерным кодом. Для полноценной работы необходимы знания всех внутренностей бинарников и исходников программы, с которой приходилось работать.
О чём необходимо иметь представление для работы, это о том, как в Линуксе вообще выглядит бинарный файл. Основной формат исполняемых бинарных файлов в Linux называется ELF (Executable and Linkable Format). Он состоит из большого количества секций, описывающих связи компонентов программы, хранящихся в ней данных и обо всех именах объектов, присутствовавших в коде. Для ознакомления с компонентами исполняемого файла воспользуемся утилитами nm, которая описывает информацию об именах объектов и их привязкам к памяти, и file, которая даёт информацию о бинарнике в целом.
user@localhost:~/LinuxAppDev/04_Debugging/Lection> ls
ex1.c ex2.c
user@localhost:~/LinuxAppDev/04_Debugging/Lection> cat ex1.c
#include <stdio.h>
int N = 42;
int fun2(int n) {
return n*2+1;
}
int fun1(int c) {
c += 1;
return fun2(c) + fun2(c*2);
}
int main(int argc, char *argv[]) {
int i;
for(i=0; i <10; i++)
printf("%d\n", fun1(N+i));
return 0;
}
user@localhost:~/LinuxAppDev/04_Debugging/Lection> gcc -O0 -g ex1.c -o ex1
user@localhost:~/LinuxAppDev/04_Debugging/Lection> nm ex1
000000000040039c r __abi_tag
000000000040401c B __bss_start
000000000040401c b completed.0
0000000000404008 D __data_start
0000000000404008 W data_start
0000000000401090 t deregister_tm_clones
000000000040107e T _dl_relocate_static_pie
0000000000401100 t __do_global_dtors_aux
0000000000403df0 d __do_global_dtors_aux_fini_array_entry
0000000000404010 D __dso_handle
0000000000403df8 d _DYNAMIC
000000000040401c D _edata
0000000000404020 B _end
00000000004011c4 T _fini
0000000000401130 t frame_dummy
0000000000403de8 d __frame_dummy_init_array_entry
0000000000402110 r __FRAME_END__
0000000000401147 T fun1
0000000000401136 T fun2
0000000000403fe8 d _GLOBAL_OFFSET_TABLE_
w __gmon_start__
0000000000402008 r __GNU_EH_FRAME_HDR
0000000000401000 T _init
0000000000402000 R _IO_stdin_used
U __libc_start_main@GLIBC_2.34
0000000000401177 T main
0000000000404018 D N
U printf@GLIBC_2.2.5
00000000004010c0 t register_tm_clones
0000000000401040 T _start
0000000000404020 D __TMC_END__
user@localhost:~/LinuxAppDev/04_Debugging/Lection> file ex1
ex1: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=116039c01d2ee752dacedb97ef4c5447a7477afb, for GNU/Linux 3.2.0, with debug_info, not stripped
user@localhost:~/LinuxAppDev/04_Debugging/Lection>
Ещё больше нам может рассказать readelf. Даная утилита расшифровывает бинарный файл, выдавая всю информацию о разделах, именах, динамически связанных объектах (в отличие от nm, хранящего связи лишь по статически собранным параметрам).
Как мы можем заметить, бинарный файл устроен сильно сложнее, нежели, например, он описывался бы на ассемблере: дних только заголовков 37 штук!
user@localhost:~/LinuxAppDev/04_Debugging/Lection> readelf -a ex1
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executable file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x401040
Start of program headers: 64 (bytes into file)
Start of section headers: 18424 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 13
Size of section headers: 64 (bytes)
Number of section headers: 38
Section header string table index: 37
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .interp PROGBITS 0000000000400318 00000318
000000000000001c 0000000000000000 A 0 0 1
<...>
[37] .shstrtab STRTAB 0000000000000000 0000467f
0000000000000176 0000000000000000 0 0 1
<...>
Displaying notes found in: .note.ABI-tag
Owner Data size Description
GNU 0x00000010 NT_GNU_ABI_TAG (ABI version tag)
OS: Linux, ABI: 3.2.0
user@localhost:~/LinuxAppDev/04_Debugging/Lection>
Аналогичную информацию о файле может показать утилита objdump.
На основе знаний о составляющих бинарных файлов строятся все алгоритмы отладочных программ. Более того, некоторые отладочные фреймворки на основе этих данных проводят Reverse Engineering - по бинарному файлу восстанавливают исходный код программы
Подготовка к отладке
На самом деле, для отладки программ подготовку провести нужно сильно заранее. Ещё на этапе сборки нужно подготовить данные для корректной, а, главное, удобной разработчику, отладке. Основные этапы подготовки кода к отладке:
- Компиляция программы с нулевой оптимизацией. (флаг
-O0). Это необходимо для избежания несостыковок исходного текста программы с бинарным файлом (н-р, в исходнике есть переменная, которая нигде не используется. При оптимизации переменная будет удалена, а в исходниках её упоминание останется) - Генерация полного
debuginfo(флаг-g). Это необходимо для сбора данных, сгенерированных компилятором для описания исходного кода во время отладки - Доступ к исходникам. Для удобной отладки необходим исходный код программы, на который мы будем ссылаться (при этом он не должен считаться временными файлами для системы, при перенесении исходников в
/tmpничего не заработает). Отлаживать возможно и без исходников, просто это будет ассемблерный код
Основные команды gdb
gdb предоставляет достаточно удобный терминальный интерфейс для отладки программ. Основные команды отладчика:
- Установка точек остановa
break watchpointсрабатывает по изменению регистра или ячейки памятиcatchpointсрабатывает по с++-исключению, либо по системному вызову- запуск программыдля отладки
run - следующий шаг программы (без перехода по функциям)
next - следующий шаг программы (со всеми переходами в функции)
step - продолжение программы до следующей точки останова
continue - продолжение подпрограммы до выхода из неё
finish - просмотр части программы, где сейчас обрабатывает отладчик,
list - просмотр значений переменных
print - просмотр стека связанных фреймов текущего места обработки отладчика
backtrace - установка параметров командной строки
set args display- всё время показывает значение переменнойdump- запись в файл
start - макрос gdb: делает break main + run
Рассмотрим пример отладки программы:
#include <stdio.h>
int N = 42;
int fun2(int n) {
return n*2+1;
}
int fun1(int c) {
c += 1;
return fun2(c) + fun2(c*2);
}
int main(int argc, char *argv[]) {
int i;
for(i=0; i <10; i++)
printf("%d\n", fun1(N+i));
return 0;
}
user@localhost:~/LinuxAppDev/04_Debugging/Lection> gcc -O0 -g ex1.c -o ex1
user@localhost:~/LinuxAppDev/04_Debugging/Lection> gdb ex1
Type "apropos word" to search for commands related to "word"...
Reading symbols from ex1...
(gdb) start
Temporary breakpoint 1 at 0x401186: file ex1.c, line 17.
Starting program: /home/user/LinuxAppDev/04_Debugging/Lection/ex1
Temporary breakpoint 1, main (argc=1, argv=0x7fffffffdb38) at ex1.c:17
17 for(i=0; i <10; i++)
(gdb) step
18 printf("%d\n", fun1(N+i));
(gdb) p argc
$1 = 1
(gdb) p argv
$2 = (char **) 0x7fffffffdb38
(gdb) p *argv
$3 = 0x7fffffffdf95 "/home/user/LinuxAppDev/04_Debugging/Lection/ex1"
(gdb) p argv[2]
$4 = 0x7fffffffdfc8 "SHELL=/bin/bash"
(gdb) p argv[3]
$5 = 0x7fffffffdfd8 "SESSION_MANAGER=local/localhost.localdomain:@/tmp/.ICE-unix/1539,unix/localhost.localdomain:/tmp/.ICE-unix/1539"
(gdb)
Заметим, что в нашей программе откуда-то появились argv[2] и argv[3], хотя мы не подавали никаких параметров. Это особенность передачи параметров: после массива строк параметров ([0] - параметр названия программы, [1] - NULL) идут поля переменных окружения arge, до которых мы таким странным способом дотягиваемся.
Посмотрим, как работает просмотр связанных фреймов:
user@localhost:~/LinuxAppDev/04_Debugging/Lection> gdb ex1
Type "apropos word" to search for commands related to "word"...
Reading symbols from ./a.out...
(gdb) b fun2
Breakpoint 1 at 0x40113d: file ex1.c, line 6.
(gdb) r
Starting program: /home/user/LinuxAppDev/04_Debugging/Lection/a.out
Breakpoint 1, fun2 (n=43) at ex1.c:6
6 return n*2+1;
(gdb) bt
#0 fun2 (n=43) at ex1.c:6
#1 0x0000000000401161 in fun1 (c=43) at ex1.c:11
#2 0x00000000004011a1 in main (argc=1, argv=0x7fffffffdb38) at ex1.c:18
(gdb) n
7 }
(gdb) l
2 int N = 42;
3
4
5 int fun2(int n) {
6 return n*2+1;
7 }
8
9 int fun1(int c) {
10 c += 1;
11 return fun2(c) + fun2(c*2);
(gdb) n
Breakpoint 1, fun2 (n=86) at ex1.c:6
6 return n*2+1;
(gdb) up # подъём по фрейму вверх
#1 0x000000000040116f in fun1 (c=43) at ex1.c:11
11 return fun2(c) + fun2(c*2);
(gdb) down # спуск по фрейму вниз
#0 fun2 (n=86) at ex1.c:6
6 return n*2+1;
(gdb)
Реальный пример отладки программы
Давайте рассмотрим данный пример: здесь создаётся переменная b, вызываются две функции, которые никак не меняют значение этой переменной, после чего b просто выводится
#include <stdio.h>
int tst(long *p) {
long x;
x = (long) p;
return 0;
}
int tst2() {
long int *a;
*a = 42;
return 0;
}
int main(int argc, char *argv[]) {
long b=7;
tst(&b);
tst2();
printf("⇒ %ld\n", b);
return 0;
}
Запустим программу и посмотрим вывод:
user@localhost:~/LinuxAppDev/04_Debugging/Lection> cc -g -O0 ex2.c
user@localhost:~/LinuxAppDev/04_Debugging/Lection> ./a.out
⇒ 42
Значение волшебным образом поменялось, при этом никаких ошибок не выпало. Проверим, были ли какие-то предупреждения при компиляции:
user@localhost:~/LinuxAppDev/04_Debugging/Lection> cc -Wall -g -O0 ex2.c
ex2.c: In function ‘tst’:
ex2.c:4:8: warning: variable ‘x’ set but not used [-Wunused-but-set-variable]
4 | long x;
| ^
ex2.c: In function ‘tst2’:
ex2.c:11:6: warning: ‘a’ is used uninitialized [-Wuninitialized]
11 | *a = 42;
| ~~~^~~~
ex2.c:10:13: note: ‘a’ was declared here
10 | long int *a;
| ^
user@localhost:~/LinuxAppDev/04_Debugging/Lection>
Попробуем отладить программу через gdb
user@localhost:~/LinuxAppDev/04_Debugging/Lection> gdb ./a.out
Type "apropos word" to search for commands related to "word"...
Reading symbols from ./a.out...
(gdb) start
Temporary breakpoint 1 at 0x401172: file ex2.c, line 16.
Starting program: /home/user/LinuxAppDev/04_Debugging/Lection/a.out
Temporary breakpoint 1, main (argc=1, argv=0x7fffffffdb38) at ex2.c:16
16 long b=7;
(gdb) b tst # поставили точки остановы на функции
Breakpoint 2 at 0x40113e: file ex2.c, line 5.
(gdb) b tst2
Breakpoint 3 at 0x401151: file ex2.c, line 11.
(gdb) c
Continuing.
Breakpoint 2, tst (p=0x7fffffffda18) at ex2.c:5
5 x = (long) p;
(gdb) l
1 #include <stdio.h>
2
3 int tst(long *p) {
4 long x;
5 x = (long) p;
6 return 0;
7 }
8
9 int tst2() {
10 long int *a;
(gdb) p/x p # Внутри переменной p - адрес переменной b из main
$0 = 0x7fffffffda18
(gdb) n
6 return 0;
(gdb) bt
#0 tst (p=0x7fffffffda18) at ex2.c:6
#1 0x0000000000401186 in main (argc=1, argv=0x7fffffffdb38) at ex2.c:17
(gdb) x/32x $sp-0x20 Посмотрим стек программы, найдём, где у нас лежит значение переменной p
0x7fffffffd9d0: 0x00000000 0x00000000 0xffffda18 0x00007fff
0x7fffffffd9e0: 0x00000000 0x00000000 0xffffda18 0x00007fff
0x7fffffffd9f0: 0xffffda20 0x00007fff 0x00401186 0x00000000
0x7fffffffda00: 0xffffdb38 0x00007fff 0xf7fe413e 0x00000001
0x7fffffffda10: 0xffffdaf0 0x00007fff 0x00000007 0x00000000
0x7fffffffda20: 0x00000001 0x00000000 0xf7c2a340 0x00007fff
0x7fffffffda30: 0xffffdb20 0x00007fff 0x00401163 0x00000000
0x7fffffffda40: 0x00400040 0x00000001 0xffffdb38 0x00007fff
(gdb) p/x &p
$1 = 0x7fffffffd9d8
(gdb) p/x &x # в переменную x положили значение переменной p - адрес b
$2 = 0x7fffffffd9e8
(gdb) n
7 }
(gdb) l
2
3 int tst(long *p) {
4 long x;
5 x = (long) p;
6 return 0;
7 }
8
9 int tst2() {
10 long int *a;
11 *a = 42;
(gdb) n
main (argc=1, argv=0x7fffffffdb38) at ex2.c:18
18 tst2();
(gdb) s
Breakpoint 3, tst2 () at ex2.c:11
11 *a = 42; # в tst2 неинициазизированную переменную просто перебиваем по её адресу. Но что же там за адрес в переменной? а где она хранится на стеке?
(gdb) p/x &a
$3 = 0x7fffffffd9e8
(gdb) # Это же то самое место на стеке, где лежит адрес b!
(gdb) c
Continuing.
⇒ 42
[Inferior 1 (process 421809) exited normally]
(gdb) q
user@localhost:~/LinuxAppDev/04_Debugging/Lection>
Благодаря отладке мы выяснили, что две “ничего не делающие” функции просто забили стек адресом изначальной переменной b и перебили значение, просто наложив фрейм второй функции на данные, оставшиеся после фрейма первой.
Внешняя отладка
Как мы знаем, некоторые программы взаимодействуют непостредственно со всем экраном вывода, отчего отладка таких программ "on a place" не представляется возможной. Для этого у gdb существует отдельная надстройка для удалённой отладки - gdbserver. Сервер задаёт программу отладки, после чего мы подключаемся к нему и получаем отладочную информацию на клиента, пока на сервере наша программа отрабатывает
Рассмотрим пример такой отладки:
Программа:
#include <stdio.h>
int N = 42;
int fun2(int n) {
return n*2+1;
}
int fun1(int c) {
c += 1;
return fun2(c) + fun2(c*2);
}
int main(int argc, char *argv[]) {
int i;
for(i=0; i <10; i++)
printf("%d\n", fun1(N+i));
return 0;
}
server:
user@localhost:~/LinuxAppDev/04_Debugging/Lection> gdbserver localhost:5000 ex1
Process /home/user/LinuxAppDev/04_Debugging/Lection/ex1 created; pid = 509891
Listening on port 5000 # [0]
Remote debugging from host ::1, port 40422 # [1]
260 # [2]
266
272
278
284
290
296
302
308
314
Child exited with status 0
user@localhost:~/LinuxAppDev/04_Debugging/Lection> # [3]
client:
# [0] сервер уже ожидает какого-то подключения
user@localhost:~/LinuxAppDev/04_Debugging/Lection> gdb
GNU gdb (GDB; openSUSE Tumbleweed) 14.2
Type "apropos word" to search for commands related to "word".
(gdb) target remote localhost:5000 #[1] сервер заметил наше подключение
Remote debugging using localhost:5000
Reading /home/user/LinuxAppDev/04_Debugging/Lection/ex1 from remote target...
Reading /home/user/LinuxAppDev/04_Debugging/Lection/ex1 from remote target...
Reading symbols from target:/home/user/LinuxAppDev/04_Debugging/Lection/ex1...
Reading /lib64/ld-linux-x86-64.so.2 from remote target...
Reading /lib64/ld-linux-x86-64.so.2 from remote target...
Reading symbols from target:/lib64/ld-linux-x86-64.so.2...
Reading /lib64/ld-linux-x86-64.so.2.debug from remote target...
Reading /lib64/.debug/ld-linux-x86-64.so.2.debug from remote target...
Reading /usr/lib/debug//lib64/ld-linux-x86-64.so.2.debug from remote target...
Reading /usr/lib/debug/lib64//ld-linux-x86-64.so.2.debug from remote target...
0x00007ffff7fe2b40 in _start () from target:/lib64/ld-linux-x86-64.so.2
(gdb) b fun2
Breakpoint 1 at 0x40113d: file ex1.c, line 6.
(gdb) continue
Breakpoint 1, fun2 (n=43) at ex1.c:6
6 return n*2+1;
(gdb) continue
Continuing.
Breakpoint 1, fun2 (n=86) at ex1.c:6
6 return n*2+1;
(gdb) continue
Continuing. # [2] функция fun1 отработала, мы напечатали значение
Breakpoint 1, fun2 (n=44) at ex1.c:6
6 return n*2+1;
(gdb) clear fun2
Deleted breakpoint 1
(gdb) continue
Continuing.
[Inferior 1 (process 509891) exited normally] # [3] Программа на сервере завершилась, нам пришло сообщение об этом
(gdb)
Для удобства отладки программ на основе gdb создано множество GUI-приложений (например, плагины к различным IDE). Сам gdb поддерживает представление кода при отладке с помощью флага -tui.
Отдельно упомянем класс UI, интерфейсом для которых выступает браузер. В качестве примера можно рассмотреть gdbgui и GDBFrontend — python-пакеты, которые достаточно установить в отладочное окружение pip install-ом, прав супервользователя при этом не нужно. После запуска нужно зайти браузером на заданный порт отладочной систему, где нас и будет ожидать крошечный веб-сервер, управляющий gdb.
Нередко отладочная система — это удалённая машина в приватной сети или виртуальное окружение,и зайти на этот компьютер браузером не представляется возможным. В этом случае для доступа, скорее всего, используется secure shell — а значит, можно локально запустить, допустим, GDBFrontend и пробросить локальный порт на пользовательский компьютер:
me@homepc:~> firefox http://127.0.0.1:5000 & # Здесь будет gdb-интерфейс
me@homepc:~> ssh user@linuxprac -L5000:localhost:5000 # Заходим в окружение с пробросом 5000 порта
user@linuxprac:~/LinuxAppDev/04_Debugging> pip3 install GDBFrontend
user@linuxprac:~/LinuxAppDev/04_Debugging> ~/.local/bin/gdbfrontend -p 5000
После этого обновляем страницу браузера, загружает файл для отладки и начинаем работать.
gdb-scripting
Для работы с gdb разработан отдельный язык сценариев для упрощения работы при отладке и более точного поиска проблемных точек кода. Как и любые сценарии, сценарии отладчика — это его же команды, собранные в отдельные файлы. Каждый такой файл можно выполнить либо в отладчике (одной командой), либо сразу при старте.
Снова рассмотрим ex1.c:
#include <stdio.h>
int N = 42;
int fun2(int n) {
return n*2+1;
}
int fun1(int c) {
c += 1;
return fun2(c) + fun2(c*2);
}
int main(int argc, char *argv[]) {
int i;
for(i=0; i <10; i++)
printf("%d\n", fun1(N+i));
return 0;
}
Напишем сценарий, который опишет нам стек фреймов для значений локальной переменной n функции fun2, когда она больше 98:
set pagination off
b fun2 if n > 98 # поставили условный брейкпоинт
command 1 # описываем алгоритм для обработки точки остановы
bt
cont
end
run
quit
user@localhost:~/LinuxAppDev/04_Debugging/Lection> ls
a.out ex1.c ex1.gdb ex2.c
user@localhost:~/LinuxAppDev/04_Debugging/Lection> cc -g -O0 ex1.c -o ex1
user@localhost:~/LinuxAppDev/04_Debugging/Lection> gdb ex1
GNU gdb (GDB; openSUSE Tumbleweed) 14.2
Reading symbols from ex1...
(gdb) source ex1.gdb
Breakpoint 1 at 0x40113d: file ex1.c, line 6.
260
266
272
278
284
290
296
Breakpoint 1, fun2 (n=100) at ex1.c:6
6 return n*2+1;
#0 fun2 (n=100) at ex1.c:6
#1 0x000000000040116f in fun1 (c=50) at ex1.c:11
#2 0x00000000004011a1 in main (argc=1, argv=0x7fffffffdb38) at ex1.c:18
302
Breakpoint 1, fun2 (n=102) at ex1.c:6
6 return n*2+1;
#0 fun2 (n=102) at ex1.c:6
#1 0x000000000040116f in fun1 (c=51) at ex1.c:11
#2 0x00000000004011a1 in main (argc=1, argv=0x7fffffffdb38) at ex1.c:18
308
Breakpoint 1, fun2 (n=104) at ex1.c:6
6 return n*2+1;
#0 fun2 (n=104) at ex1.c:6
#1 0x000000000040116f in fun1 (c=52) at ex1.c:11
#2 0x00000000004011a1 in main (argc=1, argv=0x7fffffffdb38) at ex1.c:18
314
[Inferior 1 (process 422497) exited normally]
user@localhost:~/LinuxAppDev/04_Debugging/Lection>
Аналогичные сценарии можно писать на встроенном в gdb Python
Домашнее задание
- Написать простейшую программу-генератор арифметической прогрессии
range.c, принимающую от одного до трёх параметров по аналогии с питоновскимrange()- Без параметров — выводит
help - С одним параметром
N— выводит в столбик последовательность[0, 1, … N-1] - С двумя —
M, N— последовательность[M, M+1, … N-1] - С тремя —
M, N, S— последовательность[M, M+S, M+2S, … N-1]
- Без параметров — выводит
- Написать следующие
gdb-сценарии (формат вывода произвольный):- Запустить
./rangeс параметрами1 12и вывести содержимое переменных, отвечающих на начало, конец, шаг и текущее значение элемента прогрессии, только когда этот элемент кратен 5 - Запустить
./rangeс параметрами-100 100 3и вывести содержимое переменных, отвечающих на начало, конец, шаг и текущее значение элемента прогрессии только для 28, 29, 30, 31, 32, 33, 34, и 35 по счёту элементов прогрессии
- Запустить
- Написать
Makefile, в котором будут- Компиляция бинарника с ключами
-O0 -g - Цель
test: — запуск этихgdb-сценариев и сравнение их вывода с эталоннымgdbлюбит выводить много лишней/динамической информации, например, адреса, которые вполне могут не повторяться при новом запуске.- Чтобы вывод утилиты
rangeне мешал, его надо перенаправлять в/dev/nullпрямо в самой командеrun
- Цель
clean: — удаление всех генератов
- Компиляция бинарника с ключами