Code Submission Evaluation System Login

IOI-leiri 2019


Tasks | Statistics


CSES - GDB-debuggaus

GDB-debuggaus


Sisällys: Demo: Kaatuvan koodin tutkiminen

Aloitetaan seuraavanlaisesta koodista:
#include <bits/stdc++.h>
using namespace std;
int main() {
  while (true) {
    int n = rand()%5;
    vector<int> v(n);
    cout << v[0] << endl;
  }
}
Harjaantunut koodaaja näkee bugin syyn suoraan, mutta monimutkaisemmassa tilanteessa asia ei välttämättä ole ilmiselvä. Käännetään koodi tavallisesti ja katsotaan mitä tapahtuu:
$ g++ -std=c++17 -O2 koodi.cpp -o koodi
$ ./koodi
0
0
0
Segmentation fault (core dumped)
GDB:n (GNU Debugger) avulla voimme selvittää, mitä ohjelma oli tekemässä kaatumishetkellä. Aluksi täytyy muuttaa kääntäjälippuja hieman:

$ g++ -std=c++17 -g -O0 koodi.cpp -o koodi

Uusi -g-lippu lisää kännettyyn binääriin tietoa koodista ja sen rakenteista GDB:n käytettäviksi. Korvasimme myös -O2-optimoinnin -O0-lipulla, eli poistimme kaikki optimoinnit käytöstä. Optimointilipun voi myös jättää kokonaan pois.

Sivuhuomio: Tavallisesti debuggaukseen suositellaan optimointilippua -Og, jonka tarkoituksena on tehdä joitain optimointeja, jotka eivät haittaa debuggausta. Se optimoi kuitenkin välillä pois yksinkertaisesti käytettäviä muuttujia, kuten esimerkkikoodin muuttujan n, joten optimointien poistaminen kokonaan on aluksi selkeämpää. Lisäksi GDB:n kanssa voisi käyttää -ggdb-lippua, joka lisää binääriin joitain erityistietoja, joita muut debuggerit eivät tue. Tavallisessa käytössä -g riittää hyvin ja se on ehkä helpompi muistaa.

Voimme käynnistää GDB:n antamalla binäärin parametrina:
$ gdb koodi
GNU gdb ...
Copyright (C) 2018 Free Software Foundation, Inc.
...turhaa tekstiä...
Reading symbols from koodi...done.
(gdb) 
Olemme nyt GDB:n ns. kehotteessa, jossa voimme antaa sille komentoja. Ohjelman saa käyntiin run-komennolla:

(gdb) run

Käynnistämisen jälkeen ohjelmalle voi antaa syötettä tavallisesti. Ohjelmalle voi myös antaa parametreja tai syötetiedoston tuttuun tapaan:

(gdb) run parametri1 "parametri 2"
(gdb) run < syöte


Esimerkkiohjelmamme kanssa käy näin:
(gdb) run
Starting program: /home/ollpu/koodi 
0
0
0

Program received signal SIGSEGV, Segmentation fault.
main () at koodi.cpp:7
7        cout << v[0] << endl;
(gdb) 
Koodin kaaduttua GDB palaa takaisin kehotteeseen, jossa voimme antaa lisää komentoja. Tässä tapauksessa näemmekin suoraan, millä koodirivillä kaatuminen tapahtui. list-komennolla (lyhenne l) näemme muutaman rivin tämän ympäriltä (vaikka koodi on sama kuin alkuperäisessä tiedostossa). print-komennon (lyhenne p) avulla voimme tarkastella muuttujien arvoja. Itse asiassa, print-komennolle voi antaa minkä tahansa C++-lausekkeen, ja GDB yrittää parhaansa mukaan esittää sen arvon.
(gdb) list
2    using namespace std;
3    int main() {
4      while (true) {
5        int n = rand()%5;
6        vector<int> v(n);
7        cout << v[0] << endl;
8      }
9    }
(gdb) print n
$2 = 0
(gdb) p v
$3 = std::vector of length 0, capacity 0
(gdb) p v[0]
Cannot access memory at address 0x0
(gdb) p n+5
$4 = 5
(gdb) 
Bugi taisikin selvitä: kun listan kooksi sattuu 0, ei ensimmäistä alkiota saa lukea.

Kaatuminen saattaa kuitenkin tapahtua jossain kirjastofunktiossa, jolloin emme näe suoraan mitä meidän koodi tekee väärin. Seuraavalla koodilla:
#include <bits/stdc++.h>
using namespace std;
int main() {
  set<int> x = {1, 2, 3};
  x.erase(x.end());
}
kaatuminen näyttääkin GDB:ssä tältä:
(gdb) run
Starting program: /home/ollpu/koodi 
free(): invalid pointer

Program received signal SIGABRT, Aborted.
__GI_raise (sig=sig@entry=6) at ../sysdeps/unix/sysv/linux/raise.c:51
51    ../sysdeps/unix/sysv/linux/raise.c: Tiedostoa tai hakemistoa ei ole.
(gdb) 
Nyt haluaisimme siis katsoa kutsupinon ulompia tasoja. Voimme listata koko pinon komennolla backtrace (lyhenne bt):

(gdb) backtrace
#0  __GI_raise (sig=sig@entry=6) at ../sysdeps/unix/sysv/linux/raise.c:51
#1  0x00007ffff7483801 in __GI_abort () at abort.c:79
#2  0x00007ffff74cc897 in __libc_message (action=action@entry=do_abort, 
    fmt=fmt@entry=0x7ffff75f9b9a "%s\n") at ../sysdeps/posix/libc_fatal.c:181
#3  0x00007ffff74d390a in malloc_printerr (
    str=str@entry=0x7ffff75f7d88 "free(): invalid pointer") at malloc.c:5350
#4  0x00007ffff74dae1c in _int_free (have_lock=0, p=0x7fffffffdc18, 
    av=0x7ffff782ec40 <main_arena>) at malloc.c:4157
#5  __GI___libc_free (mem=0x7fffffffdc28) at malloc.c:3124
#6  0x000055555555627e in __gnu_cxx::...::deallocate (this=0x7fffffffdc20, __p=0x7fffffffdc28)
    at /usr/include/c++/7/ext/new_allocator.h:125
#7  0x00005555555560bf in std::...::deallocate (__a=..., __p=0x7fffffffdc28, __n=1)
    at /usr/include/c++/7/bits/alloc_traits.h:462
#8  0x0000555555555c8f in std::...::_M_put_node (this=0x7fffffffdc20, __p=0x7fffffffdc28)
---Type <return> to continue, or q <return> to quit---
Listassa näkyy kaikki funktiokutsut, sisimmäinen (uusin) ylimpänä. Pino onkin tässä tapauksessa aika iso (ja sisältää paljon pelottavia kirjastokutsuja), joten poistutaan näkymästä painamalla q<return> ja katsotaan mieluummin listan loppupään kutsuja, esimerkiksi 3 viimeistä komennolla bt -3. Tämä on myös hyödyllistä jos ohjelma kaatui vaikkapa äärettömän rekursion takia.
(gdb) bt -3
#11 0x000055555555539a in std::...::erase[abi:cxx11](std::_Rb_tree_const_iterator<int>) (
    this=0x7fffffffdc20, __position=3) at /usr/include/c++/7/bits/stl_tree.h:1113
#12 0x00005555555550c6 in std::...::erase[abi:cxx11](std::_Rb_tree_const_iterator<int>) (
    this=0x7fffffffdc20, __position=3) at /usr/include/c++/7/bits/stl_set.h:645
#13 0x0000555555554d62 in main () at koodi.cpp:5
(gdb) 
Aivan lopussa, kohdassa #13, näkyy oma funktiomme main. Pääsemme tarkastelemaan ohjelman suoritusta sillä kutsutasolla frame-komennolla (lyhenne f). Haluttu taso ilmaistaan backtrace-listan #numerolla.
(gdb) frame 13
#13 0x0000555555554d62 in main () at koodi.cpp:5
5      x.erase(x.end());
(gdb) 
Näemme rivin omasta koodistamme, jossa kaatuminen tapahtui. Voimme nyt myös tarkastella muuttujia tämän kutstun kontekstista.
(gdb) list
1    #include <bits/stdc++.h>
2    using namespace std;
3    int main() {
4      set<int> x = {1, 2, 3};
5      x.erase(x.end());
6    }
(gdb) print x
$2 = std::set with 3 elements = {[0] = 1, [1] = 3, [2] = 3}
(gdb) 
Kutsupinossa voi vapaasti liikkua ylös ja alas ja tutkia, mitä jokaisella tasolla tapahtuu.

Kaatuminen ei välttämättä tapahdu heti esimerkiksi virheellisen muistin käytön jälkeen vaan vasta myöhemmin, jolloin kaatumistilanteen tutkiminen voi olla harhaanjohtavaa. Tässä auttavat kääntäjäliput -fsanitize=address ja -fsanitize=undefined. Ensimmäisen kanssa kaikki muistinkäytöt tarkistetaan, ja ohjelman suoritus keskeytetään heti ja diagnostiikkatietoja näytetään, jos havaitaan virhe. Jos virheen haluaa saada kiinni GDB:ssä, täytyy antaa asetus ASan-kirjastolle (Address Sanitizer) ympäristömuuttujan avulla. Ympäristömuuttujan voi asettaa GDB:ssä näin:
set environment ASAN_OPTIONS=abort_on_error=1

Joissain ympäristöissä ASan antaa seuraavanlaisia virheviestejä
$ ./koodi
==7678==ASan runtime does not come first in initial library list; you should either link runtime to your application or manually preload it with LD_PRELOAD.
Näin ei pitäisi käydä, mutta joka tapauksessa ongelmaan auttaa käännöslipun -static-libasan lisääminen.


Näillä eväillä kaatumistilanteiden tutkiminen helpottuu huomattavasti, mutta koodin suorituksen voi keskeyttää muussakin tilanteessa.


Omat keskeytykset ja eteneminen

Jos ohjelma on jumissa tai odottaa esimerkiksi syötettä, GDB-kehotteeseen pääsee aina takaisin painamalla Ctrl-C (ohjelmaa ei tapeta).

Välillä on käytännöllistä keskeyttää koodin suoritus täsmälleen tietyssä kohdassa. Tämä onnistuu asettamalla breakpointteja kehotteesta etukäteen. Breakpointin voi asettaa koodirivin, tässä 14, alkuun seuraavasti:

(gdb) break 14
(gdb) break koodi.cpp:14


Jos ohjelmaan liittyy monta tiedostoa, voi joutua määrittämään tiedostonimen erikseen, kuten yllä.
Vastaavasti breakpointin voi asettaa funktion alkuun antamalla funktion nimen.

Breakpointille voi antaa myös ehdon, joka tarkistetaan ennen pysähtymistä. Kuten print-komennon kanssa, ehto voi olla mikä tahansa C++-lauseke.
#include <bits/stdc++.h>
using namespace std;
int main() {
  for (int i = 0; i < 10; ++i) {
    cout << i << endl;
  }
}
(gdb) break 5 if i == 3

Nyt suoritus keskeytetään vasta kun iteraatio on kohdassa 3.

On pidettävä mielessä, että breakpointtiin pysähdyttäessä valittu rivi ei ole vielä suoritettu. Erityisesti jos rivillä on muuttujamäärittely, niin muuttujan tarkastelu print-komennolla on sallittua (kuten kaikkien näkyvyysalueella olevien muuttujien), mutta sen arvoa ei ole vielä asetettu.

Aktiivisten breakpointtien listan näkee:
(gdb) info break
Num     Type           Disp Enb Address            What
1       breakpoint     keep y   0x000055555555489f in main() at koodi.cpp:5
        stop only if i == 3
        breakpoint already hit 1 time
(gdb) 
Yksittäisen breakpointin, tai kaikki, voi poistaa delete breakpoints -komennon (lyhenne del br) avulla. GDB:ssä komentoja lyhentäessä kelpaa mikä tahansa yksikäsitteinen alku, eli br, break, breakpoint, breakpoints käyvät kaikki.

# poista breakpoint 1
(gdb) delete breakpoint 1
# poista breakpoint 2
(gdb) del br 2
# poista kaikki breakpointit
(gdb) del break


Breakpointtiin pysähdyttäessä ohjelma on vielä täysin ehjässä tilassa, eli sen suoritus voi jatkua. Komennolla continue ohjelma jatkaa suoritusta tavallisesti, kunnes mahdollisesti jokin breakpoint kohdataan taas. step ja next sen sijaan etenevät ohjelmassa yhden lähdekoodirivin verran, mutta next ei mene uusien funktiokutsujen sisälle. Lisää komentoja on tämän ohjeen lopussa.

Toinen kätevä tapa halutussa kohdassa keskeyttämiselle on signaalin tuottaminen suoraan koodissa.
#include <csignal> // protip: käytä <bits/stdc++.h>
...
// vakio SIGINT == 2
raise(SIGINT);
Suoritus keskeytyy kun tämä kohta koodia ajetaan.

Sivuhuomio: Sama signaali (SIGINT) annetaan ohjelmalle kun painaa Ctrl-C, ja GDB kaappaa sen. Muitakin signaalityyppejä voisi käyttää, ks. info signal.


Komentoja (työn alla)
Yleisiä Tarkastelu Suorituksen jatkaminen Resursseja