View on GitHub

Methodics of GNU/Linux application software development

Practical materials for learning application software development on Linux

В этом разделе обсудим отладку программ и подробнее остановимся на отладчике 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 - по бинарному файлу восстанавливают исходный код программы

Подготовка к отладке

На самом деле, для отладки программ подготовку провести нужно сильно заранее. Ещё на этапе сборки нужно подготовить данные для корректной, а, главное, удобной разработчику, отладке. Основные этапы подготовки кода к отладке:

  1. Компиляция программы с нулевой оптимизацией. (флаг -O0). Это необходимо для избежания несостыковок исходного текста программы с бинарным файлом (н-р, в исходнике есть переменная, которая нигде не используется. При оптимизации переменная будет удалена, а в исходниках её упоминание останется)
  2. Генерация полного debuginfo (флаг -g). Это необходимо для сбора данных, сгенерированных компилятором для описания исходного кода во время отладки
  3. Доступ к исходникам. Для удобной отладки необходим исходный код программы, на который мы будем ссылаться (при этом он не должен считаться временными файлами для системы, при перенесении исходников в /tmp ничего не заработает). Отлаживать возможно и без исходников, просто это будет ассемблерный код

Основные команды gdb

gdb предоставляет достаточно удобный терминальный интерфейс для отладки программ. Основные команды отладчика:

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

Домашнее задание

  1. Написать простейшую программу-генератор арифметической прогрессии 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]
  2. Написать следующие gdb-сценарии (формат вывода произвольный):
    • Запустить ./range с параметрами 1 12 и вывести содержимое переменных, отвечающих на начало, конец, шаг и текущее значение элемента прогрессии, только когда этот элемент кратен 5
    • Запустить ./range с параметрами -100 100 3 и вывести содержимое переменных, отвечающих на начало, конец, шаг и текущее значение элемента прогрессии только для 28, 29, 30, 31, 32, 33, 34, и 35 по счёту элементов прогрессии
  3. Написать Makefile, в котором будут
    • Компиляция бинарника с ключами -O0 -g
    • Цель test: — запуск этих gdb-сценариев и сравнение их вывода с эталонным
      • gdb любит выводить много лишней/динамической информации, например, адреса, которые вполне могут не повторяться при новом запуске.
      • Чтобы вывод утилиты range не мешал, его надо перенаправлять в /dev/null прямо в самой команде run
    • Цель clean: — удаление всех генератов