Debugging

Bitdefender este un lider recunoscut în domeniul securității IT, care oferă soluții superioare de prevenție, detecție și răspuns la incidente de securitate cibernetică. Milioane de sisteme folosite de oameni, companii și instituții guvernamentale sunt protejate de soluțiile companiei, ceea ce face Bitdefender cel mai de încredere expert în combaterea amenințărilor informatice, în protejarea intimității și datelor și în consolidarea rezilienței la atacuri. Ca urmare a investițiilor susținute în cercetare și dezvoltare, laboratoarele Bitdefender descoperă 400 de noi amenințări informatice în fiecare minut și validează zilnic 30 de miliarde de interogări privind amenințările. Compania a inovat constant în domenii precum antimalware, Internetul Lucrurilor, analiză comportamentală și inteligență artificială, iar tehnologiile Bitdefender sunt licențiate către peste 150 dintre cele mai cunoscute branduri de securitate din lume. Fondată în 2001, compania Bitdefender are clienți în 170 de țări și birouri pe toate continentele. Mai multe detalii sunt disponibile pe www.bitdefender.ro.

Resposabili:

  • Cristi Olaru
  • Cristi Pătrașcu
  • Cristi Popa
  • Darius Neațu
  • Liza Babu
  • Radu Nichita

Cuprins

De ce debugging?

De foarte multe ori, codul pe care îl scriem nu funcționează de la început așa cum ne dorim, deoarce conține cel puțin un bug. Un bug poate să fie:

  • un comportament care nu este cel dorit (e.g. nu se obține rezultatul dorit / corect)
  • când programul nu se termină (e.g. ciclează)
  • când primim o eroare fatală neașteptată (e.g. Segmentation fault).

Primul pas este să răspundem la întrebarea “De ce nu merge?” (să găsim bug-ul), lucru care de cele mai multe ori este mai dificil decât rezolvarea propriu-zisă a problemei.

O posibilă modalitate de a descoperi bugurile programului nostru este pur și simplu să ne uităm pe cod. Dacă problema este destul de evidentă, putem avea “noroc” să observăm repede bug-ul.

Debuggingul este procesul prin care găsim și rezolvăm bugurile dintr-un program, skill foarte important pentru orice inginer software. Acesta constă în înțelegerea codului, formularea unor ipoteze (e.g. “Ar putea fi o problemă în funcția foo() ), folosirea mesajelor de log (e.g. printf ), a unor tooluri de debug (e.g. gdb ), etc.

Debugging în practică

Debuggingul este necesar în aproape orice aplicație din Computer Science și în aproape orice context, în diverse forme.

În contextul în care o aplicație ajunge să fie folosită de un număr mare de utilizatori, este foarte posibil să apară și probleme care nu au fost întâmpinate în procesul de dezvoltare și testare. De asemenea, dacă singura informație disponibilă este “Ceva nu a mers cum trebuie la un utilizator”, este foarte greu să se rezolve un bug deoarece nu există context; nu știm în ce condiții s-a reprodus bugul.

Din acest motiv, în industrie, aplicațiile produc și salvează mesaje de logging. Acestea conțin diverse informații despre starea aplicației, (e.g. funcția x s-a încheiat cu succes, operația y a eșuat din cauza erorii z ) care sunt foarte utile pentru a depista și rezolva problemele care au dus la eroare.

De asemenea, în practică se folosesc foarte mult toolurile de debugging (e.g. gdb ), ce permit verificarea anumitor aspecte ale execuției unui program (e.g. pas cu pas ce instrucțiune se execută, ce zone de memorie se accesează, dacă se fac alocări etc).

Logging

Primul tool întâlnit de voi este printf. Adăugarea de mesage de logging în programarele voastre vă poate ajuta foarte multe în procesul de debugging.

În industrie logurile pot ajuta la reproducerea fidelă a contextului în care a apărut problema semnalată.

Consultați tutorialul de logging de pe ocw pentru mai multe detalii.

Tooluri de debugging

După cum a fost menționat anterior, există tooluri de debugging care ne permit să analizăm un program în timp ce rulează, eventual alternându-i flowul de execuție astfel încât să se descopere lucruri noi sau să se (in)valideze ipoteze despre bug.

GDB

Un debugger este un program care permite rularea instrucțiune cu instrucțiune a unui alt program , inspectarea variabilelor și a zonelor de memorie și chiar modificarea acestora în timp real pentru a repara buguri. Acestă abordare se numește analiză dinamică , deoarece un program o dată pornit își schimbă starea internă (e.g. variabilele se modifică în timp).

Cel mai popular debugger este GDB (The GNU Project Debugger). Acesta oferă suport pentru programe scrise în diferite limbaje (e.g. C, C++, D, Rust, Assembly, Go).

Vom prezenta în continuare folosirea acestui tool atât din linia de comandă, dar și din IDE.

Debugging în linia de comandă

Instalare

  • Linux - exemplu Ubuntu
$ sudo apt-get install gdb
  • MAC OS
$ brew install gdb

Comenzi uzuale

  • r[un] - pornește execuția unui program
  • start - pornește execuția unui program și se oprește imediat după intrarea în main
  • s[tep] - trece la următoarea instrucțiune (dacă este un apel de funcție, intră în funcție)
  • n[ext] - trece la următoarea instrucțiune, dar daca este un apel de funcție, execută tot ce este în acea funcție și trece la următoarea instrucțiune din funcția curentă
  • b[reak] - setează un breakpoint (un loc în program unde daca se ajunge, execuția programului se oprește - se așteaptă comenzi de la user în consola gdb)
  • c[ontinue] - continuă execuția programului până la următorul breakpoint sau până la sfârșit
  • p[rint] - afișează conținutul unor variabilele

Notă: Notația r[un] reprezintă faptul că ce este în paranteze drepte este opțional. Astfel, comenzile run și r sunt echivalente.

Exemplu

Fie următorul cod C buggy.c care calculează suma și numărul elementelor pare dintr-un vector:

// buggy.c
 
#include <stdio.h>
#include <stdlib.h>
 
int* sum_even(int n, int *v, int *cnt)
{
    int s = 0;
    for (int i = 0; i < n; i++) {
        if (*v % 2 == 0) {
            s += *v;
            *cnt++;
        }
        v += 4;
    }
 
    return &s;
}
 
int main(void)
{
    int v[] = {2, 5, 4, 6, 1};
    int n = sizeof(v) / sizeof(v[0]);
    int cnt = 0;
    int* s = sum_even(n, v, &cnt);
    printf("Suma: %d\n", *s);
    printf("Numar numere pare: %d\n", cnt);
 
    return 0;
}

Compilăm și rulăm codul:

$ gcc buggy.c -o buggy
$ ./buggy
Segmentation fault (core dumped)

Vedem că obținem o eroare de tipul Segmentation fault. Avem nevoie să vedem de unde provine ea, așa că pornim procesul de debugging. Ca să putem folosi programul gdb pentru debugging, avem nevoie să recompilăm programul cu flagul -g:

Explicație flag -g gdb folosește o tabelă de simboluri , care este o asociere dintre instrucțiunile din binar (programul compilat) și codul sursă. Acest lucru este necesar pentru ca noi să putem înțelege codul sursă.

By default, această tabelă nu este stocată în executabil (deoarece ar ocupa spațiu extra, iar în unele cazuri nu ne putem permite acest lucru). Pentru a genera și stoca această tabelă de simboluri, se folosește flagul -g la compilare.

$ gcc -g buggy.c -o buggy

Interacțiunea cu gdb se face din consola gdb . Pentru a o deschide cu scopul de a face debug pentru ./buggy, rulăm comanda:

$ gdb ./buggy

Avem de-a face cu un Segmentation fault. O posibilă primă abordare este să stabilim la ce instrucțiune se produce. Folosim r pentru a rula programul. Execuția programului se va opri la linia la care se produce Segmentation fault.

Reading symbols from ./buggy...
(gdb) run
 
Program received signal SIGSEGV, Segmentation fault.
0x000055555555524d in main () at buggy.c:26
26      printf("Suma: %d\n", *s);

Alternativa Folosim comanda start pentru a rula programul până se întră în main .

(gdb) start
 
Temporary breakpoint 1, main () at buggy.c:21
21  {

Acum rulăm pas cu pas (comanda n[ext]) până obținem Segmentation fault.

Note: după ce rulăm o comanda, putem să apasăm doar Enter pentru a executa ultima comandă rulată.

(gdb) n
22      int v[] = {2, 5, 4, 6, 1};
(gdb) 
23      int n = sizeof(v) / sizeof(v[0]);
(gdb) 
24      int cnt = 0;
(gdb) 
25      int* s = sum_even(n, v, &cnt);
(gdb) 
26      printf("Suma: %d\n", *s);
(gdb) 
 
Program received signal SIGSEGV, Segmentation fault.
0x000055555555525b in main () at buggy.c:26
26      printf("Suma: %d\n", *s);

Am descoperit că eroarea se produce la linia 26, atunci când printăm *s . În acest moment, variabila s este de interes. În acest punct, încă putem printa variabilele din program în consola gdb.

(gdb) p s
$1 = (int *) 0x0

s este NULL pointer (reamintim că in C, NULL este un pointer de tip void la adresa 0). Un prim indiciu ne este oferit chiar la compilare printr-un warning despre variabila s din funcția sum_even .

buggy.c: In function ‘sum_even’:
buggy.c:17:12: warning: function returns address of local variable [-Wreturn-local-addr]
   17 |     return &s;

Pentru a ieși din gdb , putem rula comanda quit / q sau apăsa Ctrl + D. Vom rula din nou gdb . De data aceasta, vom merge direct la linia 17, unde este `return &s”. Vom face acest lucru setând un breakpoint la această linie.

  (gdb) b 17
  Breakpoint 1 at 0x11d7: file buggy.c, line 17.
 
  (gdb) run
  Breakpoint 1, sum_even (v=0x7fffffffdcf0, cnt=0x7fffffffdca4) at buggy.c:17
  17        return &s;

Acum să printăm valoarea returnată, adică &s .

(gdb) p &s
$2 = (int *) 0x7fffffffdc70

Aici adresa lui s este în regulă. De ce când este returnată adresa este validă, iar în main este 0x0 ? Cum putem rezolva problema?

Spoiler

s este o variabilă locală în funcția sum_even , alocată pe stivă. În momentul în care se iese din funcție, s este scoasă de pe stivă si &s nu mai este o adresă validă. Acest lucru duce la comportament nedefinit (undefined behaviour). În rularea anterioară, a fost întors NULL pointer și de aici a rezultat Segmentation fault

Cod

// buggy_fix1.c
 
#include <stdio.h>
#include <stdlib.h>
 
int sum_even(int n, int *v, int *cnt) 
{
    int s = 0;
    for (int i = 0; i < n; i++) {
        if (*v % 2 == 0) {
            s += *v;
            *cnt++;
        }
        v += 4;
    }
 
    return s;
}
 
int main(void)
{
    int v[] = {2, 5, 4, 6, 1};
    int n = sizeof(v) / sizeof(v[0]);
    int cnt = 0;
    int s = sum_even(n, v, &cnt);
    printf("Suma: %d\n", s);
    printf("Numar numere pare: %d\n", cnt);
 
    return 0;
}

Să compilăm și rulăm noul cod:

$ gcc -g buggy_fix1.c -o buggy
$ ./buggy
Suma: 453961250
Numar numere pare: 0

De această dată am obținut niște rezultate, însă nu pe cele corecte.

Rulam din nou gdb . Bănuim că problema este în funcția sum_even . Pentru a nu merge pas cu pas până în această funcție, vom pune un breakpoint la linia 11, aceea fiind linia unde se modifică s .

Notă: Când folosim breakpoint la linie, execuția se oprește chiar înainte de linia respectivă (linia care ne este afișată încă nu este executată).

b `11`
Breakpoint 1 at 0x1196: file buggy.c, line 11.
(gdb) run
 
Breakpoint 1, sum_even (v=0x7fffffffdca0, cnt=0x7fffffffdc98) at buggy.c:11
11              s += *v;
(gdb) p s
$1 = 0     # initial s este 0; este în regulă (încă nu s-a efectuat adunarea).
(gdb) c
Continuing.
 
Breakpoint 1, sum_even (v=0x7fffffffdcc0, cnt=0x7fffffffdc9c) at buggy.c:11
11              s += *v;
(gdb) p s
$2 = 2     # primul număr din v este 2, este par, deci a fost adunat. Este OK.
(gdb) c
Continuing.
 
Breakpoint 1, sum_even (v=0x7fffffffdcd0, cnt=0x7fffffffdca0) at buggy.c:11
11              s += *v;
(gdb) p s
$3 = 2    # următorul numar din v este 4; este tot par, ar fi trebuit adăugat la sumă. Ceva nu este OK.

Am descoperit “cam pe unde” este problema. Acum că am restrâns zona de căutare, vom rula instrucțiune cu instrucțiune pentru a găsi cauza. Momentan nu mai avem nevoie de breakpoint-ul de la linia 13.

De fiecare dată când punem un breakpoint nou, acestuia i se asociază un număr. Pentru a vedea toate breakpoint-urile existente, putem folosi comanda info b.

(gdb) info b
Num     Type           Disp Enb Address            What
1       breakpoint     keep y   0x0000555555555196 in sum_even at buggy.c:11
    breakpoint already hit 3 times

În acest moment avem un singur breakpoint, cel de la linia 11. Pentru exemplificare, o să mai adăugăm un breakpoint.

(gdb) b 12
Breakpoint 2 at 0x55555555519f: file buggy.c, line 12.
(gdb) info b
Num     Type           Disp Enb Address            What
1       breakpoint     keep y   0x0000555555555196 in sum_even at buggy.c:11
    breakpoint already hit 3 times
2       breakpoint     keep y   0x000055555555519f in sum_even at buggy.c:12

Acum apare și acesta în lista de breakpoint-uri, cu numărul 2. Pentru a șterge un breakpoint, folosim comanda d <numar breakpoint> . Să ștergem breakpoint-ul cu numărul 2 și să adăugăm din nou un breakpoint la linia 14.

(gdb) d 2
(gdb) info b
Num     Type           Disp Enb Address            What
1       breakpoint     keep y   0x0000555555555196 in sum_even at buggy.c:11
    breakpoint already hit 3 times
(gdb) b 12
Breakpoint 3 at 0x55555555519f: file buggy.c, line 12.
(gdb) info b
Num     Type           Disp Enb Address            What
1       breakpoint     keep y   0x0000555555555196 in sum_even at buggy.c:11
    breakpoint already hit 3 times
3       breakpoint     keep y   0x000055555555519f in sum_even at buggy.c:12

Se poate observa că noul breakpoint are acum numărul 3, deși nu există un breakpoint cu numărul 2. Pentru a șterge toate breakpoint-urile, folosim comanda d .

(gdb) d
Delete all breakpoints? (y or n) y
(gdb) info b
No breakpoints or watchpoints.

Anterior am descoperit că este o problemă după ce se adună primul element din vector. Vom rula instrucțiune cu instrucțiune codul din funcția sum_even . Să punem un breakpoint la începutul funcției. Putem fie să punem la linia 8 ( b 8 ), fie sa folosim numele funcției (comanda b sum_even ).

(gdb) b sum_even
Breakpoint 4 at 0x555555555179: file buggy.c, line 8
(gdb) run 
The program being debugged has been started already.
Start it from the beginning? (y or n) y
 
Breakpoint 4, sum_even (v=0x7fffffffdca0, cnt=0x7fffffffdc98) at buggy.c:8
8       int s = 0;

Să trecem pas cu pas prin cod și să afișăm s și v .

Spoiler

(gdb) b sum_even
Breakpoint 1 at 0x117c: file buggy.c, line 8.
(gdb) r
 
Breakpoint 1, sum_even (n=5, v=0x7fffffffdcb0, cnt=0x7fffffffdca4)
    at buggy.c:8
8       int s = 0;
(gdb) n
9       for (int i = 0; i < n; i++) {
(gdb) 
10          if (*v % 2 == 0) {
(gdb) 
11              s += *v;
(gdb) 
12              *cnt++;
(gdb) p s
$1 = 2
(gdb) p v
$2 = (int *) 0x7fffffffdcb0
(gdb) n
14          v += 4;
(gdb) 
9       for (int i = 0; i < n; i++) {
(gdb) 
10          if (*v % 2 == 0) {
(gdb) 
14          v += 4;
(gdb) p v
$3 = (int *) 0x7fffffffdcc0
(gdb) p *v
$4 = 1
(gdb) p s
$5 = 2

Ce observați?

Spoiler La a doua iterație, după operația v += 4 , în loc să ajungem la v[1], ajungem la v[5]. v în loc să crească cu 4, a crescut cu 16 (0x7fffffffdcb0 - 0x7fffffffdca0). Este greșit v += 4 . Operațiile pe pointeri funcționează în concordanță cu tipul pointerului. Dacă incrementăm un int* , valoarea acestuia va creste automat cu 4, pentru a pointa către următorul int . În schimb dacă incrementăm un char* , valoarea acestuia va crește cu 1.

Reminder: p + x , crește adresa indicată de pointerul p cu x * sizeof(T) , unde T este tipul valorilor către care pointează p .

Astfel, corect ar fi v++ în loc de v += 4 .

Să compilăm și rulăm din nou codul după ce corectăm această greșeală.

De data aceasta am obținut suma corectă (12), însă numărul de numere pare este în continuare greșit. În codul nostru, un pointer către variabila cnt este pasat funcției sum_even și astfel valoarea acesteia modificată în sum_even va fi persistentă și în main (side effect).

Singurul loc în care cnt este modificat este la linia 12. Să punem un breakpoint acolo să vedem ce se întâmplă.

Reading symbols from ./buggy...
(gdb) b 12
Breakpoint 1 at 0x11a2: file buggy.c, line 12.
(gdb) run
 
Breakpoint 1, sum_even (n=5, v=0x7fffffffdcb0, cnt=0x7fffffffdca4)
    at buggy.c:12
12              *cnt++;
(gdb) p cnt
$1 = (int *) 0x7fffffffdca4
(gdb) p *cnt
$2 = 0
(gdb) n
14          v++;
(gdb) p cnt
$3 = (int *) 0x7fffffffdca8
(gdb) p *cnt
$4 = 5

Ce observați? Ce se întâmplă atunci când se execută instrucțiunea *cnt++ ?

Spoiler cnt nu este 1, cât ar trebui să fie. De asemenea, adresa către care pointează cnt s-a modificat (a crescut cu 4). ++ se aplică lui cnt , nu lui *cnt . ++ este post-increment, deci instrucțiunea face următoarele:

  • se aplică operatorul de derefențiere pe cnt (*cnt) și se obține primul element din vector (2).
  • rezultatul este stocat in memorie
  • cnt este incrementat
  • rezultatul stocat in memorie este aruncat, deoarece nu se face nimic cu el

Este echivalent cu (ca rezultat, nu ca secventa de operații):

*cnt;
cnt = cnt + 1;

Comportamentul dorit este să încrementăm valoarea de la adresa cnt. De aceea, corect este (*cnt)++.

De ce dupa prima incrementare, *cnt este 5? Este în acest caz 5 o valoare întâmplătoare?

Spoiler 5 este fix valoarea lui n. Deoarece n și cnt sunt variabile locale, ele vor fi alocate pe stivă. Ele sunt declarate consecutiv, de aceea vor avea adrese consecutive pe stivă. Atunci când cnt este incrementat în funcția sum_even (cnt este pointer aici), va pointa fix către adresa luindinmain`.

Putem verifica acest lucru folosind gdb.

(gdb) b 25
Breakpoint 1 at 0x1210: file buggy.c, line 25.
(gdb) r
 
Breakpoint 1, main () at buggy.c:25
25      int s = sum_even(n, v, &cnt);
(gdb) p &n
$1 = (int *) 0x7fffffffdca8  # fix cnt obținut în rularea anterioară
(gdb) p &cnt
$2 = (int *) 0x7fffffffdca4

După ce corectăm și acest bug, compilăm și rulăm, ar trebui să obținem output-ul corect, și anume suma 12 și numărul de numere pare 3.

Debugging în IDE

Instalare

În acest tutorial vom exemplifica cum folosim un debugger integrat în IDE. Vom folosi mediului de debugging din editorul Visual Studio Code care poate fi descărcat de pe https://code.visualstudio.com/download.

Pentru a configura VS Code urmăm câțiva pași:

  • Instalăm C/C++ for Visual Studio Code plugin pentru VSCode
    • CTRL + SHIFT + P: deschiere prompt comenzi VS Code
    • Căutăm și accesăm meniul Extensions: Install Extension
    • Căutăm după C/C++
    • Alegem extensia C/C++ for Visual Studio Code de la Microsoft
    • Selectăm extensia și apăsăm Install
    • Închidem fereastra cu extensia.
  • Ne asigurăm că avem tot proiectul (directorul principal) deschis în VS Code.
    • Presupunem exitența fișierului sursă buggy.c.
  • Se configurează proiectul pentru debugging.
    • Se deschide o sursă C.
    • Se selectează din partea stângă meniul Run and Debug (sau CTRL + SHIFT + D).
    • Se apasă pe Create a lauch.json file pentru a se porni un nou config.
      • Se selectează C++ (GDB / LLDB).
      • Se selectează gcc - Build and debug active file (versiunea default de gcc de pe sistem)
  • Codul este automat compilat și rulat.
  • În cazul codului buggy.c, se obține eroare de tipul Segmentation fault la derefențierea lui s din main().

 IDE configuration

Exemplu

Putem rula un program în modul debugging folosind Run -> Start Debugging sau F5.

După pornirea acestui mod, obersăm că prin selectarea meniului Run and Debug din partea stângă (sau CTRL + SHIFT + D) avem mai multe ferestre afișate:

  • VARIABLES: fereastră unde putem observa valoarea tuturor variabilelor vizibile/folosite de program într-un moment dat al execuției.
  • WATCH: fereastră unde putem vizualiza cum o expresie se modifică (e.g. cnt, 2 * cnt).
  • CALLSTACK: ferastră unde putem vedea succesiunea de apeluri de funcții (e.g. g() a fost apelată din f() , care a fost apelată din main()).

 IDE - 01

În meniul din partea de jos observăm:

  • TERMINAL: terminalul în care rulează excutabilul, citește de la STDIN și afișează la STDOUT.
  • DEBUG CONSOLE: fereastră unde se poate observa ce comanda gdb se rulează în spate.

 IDE - 02

Pentru a pune un breakpoint, se selectează cu mouse-ul linia dorită. Meniul din partea superioară prezintă acțiuni:

  • Continue ⇔ gdb: continue
  • Step Over ⇔ gdb: next
  • Step Into ⇔ gdb: step
  • Step Out - opusul lui step (revenirea dintr-o funcție)
  • Restart ⇔ gdb: run
  • Stop - oprește rularea în modul debugging

 IDE - 03

Concluzie

Am prezentat noțiunile de bază despre cum putem să folosim gdb, atât din linia de comandă, cât și din IDE, pentru a găsi bug-urile din cod în mod eficient, aplicate pe exemplul buggy.c.

Valgrind

Un aspect important din cadrul dezvoltării aplicațiilor software îl reprezintă managementul memoriei, întrucât lucrul incorect cu aceasta (bugurile de memorie) poate duce la numeroase probleme: memory leaks, open files, invalid memory accesses.

Memory leaks / memleaks (leakuri de memorie) sunt acele zone de memorie alocate (e.g. cu mallloc) și pentru care aveam inițial un pointer către adresa de început (e.g. valoarea returnată de malloc a fost validată și stocată într-o variabilă de tip pointer), dar pentru care ulterior am pierdut toate referințele (e.g. nu mai avem acei pointeri - poate erau variabile locale într-o funcție și nu au fost salvați în main) și pe care nu le mai putem elibera (e.g. nu avem ce să pasăm către free).

Aceste blocuri de memorie rămân blocate / neutilizabile până când programul se oprește, moment în care sistemul de operare eliberează toate resursele alocate pentru acel program.

De ce credeți totuși că este un bug grav să ai memory leaks într-un program chiar dacă facem sistemul de operare curat la final?

Spoiler

Leak-urile de memorie reprezintă buguri provocate de probleme de logică (a.k.a. dezvoltatorul a uitat să elibereze o resursă alocată etc).

Dacă după alocarea resursei și pierderea referințelor (deci momentul în care apare leakul de memorie în cod), programul oricum urmează să se termine, atunci memoria se va elibera cu noroc imediat. Dacă programul se modifică pe viitor, norocul se poate schimba.

Cele mai multe aplicații din prezent sunt livrate sub formă de serviciu sau folosesc în spate alte servicii / servere. Putem să ne imaginăm că un astfel de serviciu, precum YouTube, Facebook, Netflix, poate avea în spate procese care ideal rulează luni / ani întregi fără întrerupere. În cazul acesta, sistemul de operare nu poate ajuta pentru bugurile de tip memory leaks apărute în C. Rularea repetată a bucăților de cod dintr-un astfel de proces conduce la alocarea continua de memorie fără a se elibera vreodată. Dacă se ajunge spre capacitatea maximă de RAM a sistemului pe care se rulează, aplicația poate să crape / să fie oprită forțat de un sistem de monitorizare, rezultatul putând fi indisponibilitatea temporară a acelei aplicații, care poate implica costuri financiare enorme.

Analog putem detecta situații în care alte resurse nu sunt eliberate (în principiu au legătură directă sau indirectă tot cu memoria):

  • open files: pentru orice fișier deschis (e.g. cu fopen) biblioteca C alocă resurse în spate (e.g. o structură de tipul FILE, care probabil este alocată cu malloc) trebuie să fie și închis atunci când nu îl mai folosim (e.g. cu fclose); operația de închidere dealocă resurse create la deschiderea fișierului (e.g. folosind free); prin urmare observăm corespondența directă (fopen, fclose)(malloc, free).

Un tool foarte folosit pentru identificarea bugurilor legate de lucrul cu memoria este [valgrind](https://valgrind.org/) (valgrind este de fapt o colecție de tooluri, însă în acest tutorial vom vorbi despre toolul default,memcheck`). Cu ajutorul său putem identifica probleme precum accesarea unei zone de memorie nealocată sau eliberată de pe heap, leak-uri de memorie, fișiere neînchise etc.

Instalare

$ sudo apt-get install valgrind

Să considerăm următorul cod C:

// perfect_squares.c
 
#include <stdlib.h>
#include <stdio.h>
 
int* get_perfect_squares(int n)
{
    int* perfect_squares = (int*) malloc(n);
    if (!perfect_squares) {
        perror("malloc");
        exit(EXIT_FAILURE);
    }
 
    for (int i = 0; i < n; i++) {
        perfect_squares[i] = i * i;
    }
    return perfect_squares;
}
 
int main(void)
{
    int n;
    scanf("%d", &n);
    int* squares = get_perfect_squares(n);
    printf("Perfect squares:\n");
    for (int i = 0; i < n; i++) {
        printf("%d ", squares[i]);
    }
    printf("\n");
 
    return 0;
}

Explicație perror(https://man7.org/linux/man-pages/man3/sys_nerr.3.html):

Spoiler Atunci când o funcție din libc sau un apel de sistem produce o eroare, acestă eroare are un cod asociat (cod de eroare), care este salvat într-o variabilă numită errno (https://man7.org/linux/man-pages/man3/errno.3.html). Cu ajutorul funcției perror putem afișa la stderr textul asociat ultimei erori.

În exemplul de mai sus, în caz că s-a produs o eroare la malloc (if (!perfect_squares)), perror("malloc"); va afișa la stderr mesajul primit ca parametru (malloc), urmat de motivul erorii.

Explicație exit(https://man7.org/linux/man-pages/man3/exit.3.html):

Spoiler Aceasta funcție închide imediat procesul care a apelat-o. Toate fișierele deschise de catre proces sunt închise și memoria alocată este eliberată. Această funcție se folosește în general atunci se produce o eroare în urma căreia nu mai vrem să continuăm execuția programului.

Alternativ, funcția ar putea întoarce NULL, dar nu înainte de a elibera memoria alocată până în acel punct.

La fel ca în cazul gdb , și acum vom compila folosind flag-ul -g .

$ gcc -g perfect_squares.c -o perfect_squares

Programul afișează primele n pătrate perfecte (n citit de la stdin). Să rulăm pentru n = 4.

$ ./perfect_squares
4
Perfect squares:
0 1 4 9

Observăm că rezultatul obținut este corect. Însă acest lucru nu înseamnă neapărat că totul este asa cum ar trebui să fie.

Să rulăm cu valgrind. Mai jos outputul a fost împărțit pe secțiuni pentru a fi mai clar.

Notă: Când rulăm cu valgrind, programul tot se va executa. De aceea, tot îl va citi de la stdin pe n și va afișa la stdout pătratele perfecte.

Informații despre valgrind:

$ valgrind ./perfect_squares
==446151== Memcheck, a memory error detector
==446151== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==446151== Using Valgrind-3.15.0 and LibVEX; rerun with -h for copyright info
==446151== Command: ./perfect_squares
==446151== 
4

Erorile de memorie găsite:

==446151== Invalid write of size 4
==446151==    at 0x109289: get_perfect_squares (perfect_squares.c:15)
==446151==    by 0x1092D9: main (perfect_squares.c:24)
==446151==  Address 0x4a55484 is 0 bytes after a block of size 4 alloc'd
==446151==    at 0x483B7F3: malloc (in /usr/lib/x86_64-linux-gnu/valgrind/vgpreload_memcheck-amd64-linux.so)
==446151==    by 0x109244: get_perfect_squares (perfect_squares.c:8)
==446151==    by 0x1092D9: main (perfect_squares.c:24)
==446151== 
Perfect squares:
==446151== Invalid read of size 4
==446151==    at 0x109307: main (perfect_squares.c:27)
==446151==  Address 0x4a55484 is 0 bytes after a block of size 4 alloc'd
==446151==    at 0x483B7F3: malloc (in /usr/lib/x86_64-linux-gnu/valgrind/vgpreload_memcheck-amd64-linux.so)
==446151==    by 0x109244: get_perfect_squares (perfect_squares.c:8)
==446151==    by 0x1092D9: main (perfect_squares.c:24)
==446151== 
0 1 4 9 

Sumar al memoriei alocate pe heap:

==446151== HEAP SUMMARY:
==446151==     in use at exit: 4 bytes in 1 blocks
==446151==   total heap usage: 3 allocs, 2 frees, 2,052 bytes allocated

Sumar al leak-urilor de memorie:

==446151== LEAK SUMMARY:
==446151==    definitely lost: 4 bytes in 1 blocks
==446151==    indirectly lost: 0 bytes in 0 blocks
==446151==      possibly lost: 0 bytes in 0 blocks
==446151==    still reachable: 0 bytes in 0 blocks
==446151==         suppressed: 0 bytes in 0 blocks
==446151== Rerun with --leak-check=full to see details of leaked memor

Sumar al erorilor:

==592084== For lists of detected and suppressed errors, rerun with: -s
==592084== ERROR SUMMARY: 6 errors from 2 contexts (suppressed: 0 from 0)

Putem observa ca avem erori. Avem un invalid write la linia 15 (at 0x109289: get_perfect_squares (perfect_squares.c:15)), care înseamnă că scriem într-o zonă de memorie invalidă / nealocată. De asemenea, avem și un invalid read la linia 27 (at 0x109307: main (perfect_squares.c:27) ), care înseamnă că încercăm să citim dintr-o zonă de memorie invalidă / nealocată. La acele linii accesăm vectorul perfect_squares.

Invalid write of size 4 reprezintă faptul că încercăm să scriem 4 bytes (un int ). Mesajul Address 0x4a55484 is 0 bytes after a block of size 4 alloc'd ne spune faptul că la adresa unde încercăm să scriem avem 0 bytes alocați și că noi am alocat 4 bytes.

Rulați programul și pentru alte valori ale lui n (e.g. 3, 8, 100). Pentru valori mai mare ale lui n primim Aborted (core dumped).

Care este problema?

Spoiler Am alocat doar 4 octeți. Din pagina de man malloc putem vedea că parametrul size pe care îl primește este numărul de octeți. Astfel, alocarea ar trebui să fie int* perfect_squares = (int*) malloc(n * sizeof(int)). Alternativ, putem folosi sizeof(*perfect_squares) în loc de sizeof(int). În caz că vrem sa schimbăm din int în altceva (e.g. long long), nu trebuie să schimbăm în două locuri.

Rulăm din nou codul modificat și observăm că erorile au fost rezolvate. Însă în secțiunea LEAK SUMMARY observăm că avem leak-uri de memorie. Mai exact, 16 bytes pierduți (definitely lost: 16 bytes in 1 blocks), pentru n = 4.

Ne este sugerată o opțiune foarte utilă atunci când rulăm valgrind și anume --leak-check=full ( Rerun with --leak-check=full to see details of leaked memory ). După cum spune și numele, folosind această opțiune, vom primi un raport mai detaliat al leak-urilor de memorie. Să facem acest lucru.

$ valgrind --leak-check=full ./perfect_squares

Acum raportul conține și locurile unde se produc leak-urile de memorie.

==449199== 16 bytes in 1 blocks are definitely lost in loss record 1 of 1
==449199==    at 0x483B7F3: malloc (in /usr/lib/x86_64-linux-gnu/valgrind/vgpreload_memcheck-amd64-linux.so)
==449199==    by 0x109248: get_perfect_squares (perfect_squares.c:8)
==449199==    by 0x1092DD: main (perfect_squares.c:24)

Observăm că avem o alocare de memorie ce nu are un free asociat. Această alocare este la linia 8. Este nevoie să eliberăm memoria folosită pentru vectorul perfect_squares .

Cod corectat

// perfect_squares.c
 
#include <stdlib.h>
#include <stdio.h>
 
int* get_perfect_squares(int n)
{
    int* perfect_squares = (int*) malloc(n * sizeof(int));
    if (!perfect_squares) {
        perror("malloc");
        exit(EXIT_FAILURE);
    }
 
    for (int i = 0; i < n; i++) {
        perfect_squares[i] = i * i;
    }
    return perfect_squares;
}
 
int main(void)
{
    int n;
    scanf("%d", &n);
    int* squares = get_perfect_squares(n);
    printf("Perfect squares:\n");
    for (int i = 0; i < n; i++) {
        printf("%d ", squares[i]);
    }
    printf("\n");
    free(squares);
 
    return 0;
}


Putem observa că nu există nicio eroare (ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)) și nu este posibil niciun leak de memorie (ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)). Acesta este stadiul în care trebuia să ajungă programul.

==449324== Memcheck, a memory error detector
==449324== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==449324== Using Valgrind-3.15.0 and LibVEX; rerun with -h for copyright info
==449324== Command: ./perfect_squares
==449324== 
10
Perfect squares:
0 1 4 9 16 25 36 49 64 81 
==449324== 
==449324== HEAP SUMMARY:
==449324==     in use at exit: 0 bytes in 0 blocks
==449324==   total heap usage: 3 allocs, 3 frees, 2,088 bytes allocated
==449324== 
==449324== All heap blocks were freed -- no leaks are possible
==449324== 
==449324== For lists of detected and suppressed errors, rerun with: -s
==449324== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)

Notă: Deși în cod avem un singur malloc, observăm că valgrind raportează faptul că avem 3 alocări de memorie pe heap. De unde provin celelalte două?

Spoiler Anumite funcții din biblioteca C pot aloca și ele memorie, însă în general nu este nevoie să ne preocupăm de acest lucru, deoarece aceasta este și eliberată tot de către biblioteca C.

În cazul acesta, cel mai probabil atât scanf, cât și printf au alocat câte un buffer intern pentru stdin și respectiv stdout. Însă nu putem fi siguri că acest lucru este mereu valabil, întrucât implementările funcțiilor din biblioteca C pot să difere.


Exempu - get_multiplication_table

Să mai considerăm încă un exemplu. Avem o funcție care alocă dinamic o matrice în care stocăm tabla înmulțirii. Spre deosebire de exemplul precedent, în acest cod, se apelează free(multiplication_table)

// multiplication_table.c
 
#include <stdlib.h>
#include <stdio.h>
 
int** get_multiplication_table(int n)
{
    int** multiplication_table = (int**)malloc(n * sizeof(int*));
    if (!multiplication_table) {
        perror("malloc");
        exit(EXIT_FAILURE);
    }
    for (int i = 0; i < n; i++) {
        multiplication_table[i] = (int*)malloc(n * sizeof(int));
        if (!multiplication_table[i]) {
            perror("malloc");
            exit(EXIT_FAILURE); 
        }
    }
    for(int i = 0; i < n; i++) {
        for(int j = 0; j < n; j++) {
            multiplication_table[i][j] = i * j;
        }
    }
    return multiplication_table;
}
 
int main(void)
{
    int n, i, j;
    scanf("%d", &n);
    int** multiplication_table = get_multiplication_table(n);
    scanf("%d%d", &i, &j);
    printf("%d * %d = %d\n", i, j, multiplication_table[i][j]);
 
    free(multiplication_table);
 
    return 0;
}

Să compilăm și rulăm următorul cod, ce calculeaza și reține în memorie tabla înmulțirii pentru numerele de la 0 la n-1. Programul citește de la stdin un numărul n și două numere i și j pentru care va afișa i * j. Rezultatul printat pe ecran este cel așteptat. Acum să rulăm și valgrind.

$ valgrind --leak-check=full ./multiplication_table 
==503995== Memcheck, a memory error detector
==503995== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==503995== Using Valgrind-3.15.0 and LibVEX; rerun with -h for copyright info
==503995== Command: ./multiplication_table
==503995== 
10
7 8
7 * 8 = 56
==503995==
==503995== HEAP SUMMARY:
==503995==     in use at exit: 400 bytes in 10 blocks
==503995==   total heap usage: 13 allocs, 3 frees, 2,528 bytes allocated
==503995== 
==503995== 400 bytes in 10 blocks are definitely lost in loss record 1 of 1
==503995==    at 0x483B7F3: malloc (in /usr/lib/x86_64-linux-gnu/valgrind/vgpreload_memcheck-amd64-linux.so)
==503995==    by 0x10927A: get_multiplication_table (multiplication_table.c:14)
==503995==    by 0x10935A: main (multiplication_table.c:32)
==503995== 
==503995== LEAK SUMMARY:
==503995==    definitely lost: 400 bytes in 10 blocks
==503995==    indirectly lost: 0 bytes in 0 blocks
==503995==      possibly lost: 0 bytes in 0 blocks
==503995==    still reachable: 0 bytes in 0 blocks
==503995==         suppressed: 0 bytes in 0 blocks
==503995== 
==503995== For lists of detected and suppressed errors, rerun with: -s
==503995== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0

Avem leak-uri de memorie. De ce se întâmplă acest lucru? Hint: Avem 10 blocuri de memorie care nu au fost eliberate (400 bytes in 10 blocks are definitely lost).

Spoiler

Putem observa că memoria care nu a fost eliberată a fost alocată la linia 16.

Matricea noastră alocată dinamic este un pointer la un vector de pointeri. Astfel, prima dată este alocat vectorul de dimensiune n (linia 8), apoi alocăm n vectori de dimensiune n.

La linia 36 (free(multiplication_table);)), dezalocăm vectorul de pointeri, pierzând astfel referințele către cei n vectori (care nu sunt și nici nu ar mai putea fi eliberati). De aceea trebuie ca mai întâi să dezalocam toti cei n vectori alocați la linia 16 și abia mai apoi vectorul exterior.

for (int i = 0; i < n; i++) {
    free(multiplication_table[i]);
}
free(multiplication_table);

Alternativă cod fără funcția exit:

Spoiler

// multiplication_table.c
 
#include <stdlib.h>
#include <stdio.h>
 
int** get_multiplication_table(int n)
{
    int** multiplication_table = (int**)malloc(n * sizeof(int*));
    if (!multiplication_table) {
        perror("malloc");
        return NULL;
    }
    for (int i = 0; i < n; i++) {
        multiplication_table[i] = (int*)malloc(n * sizeof(int));
        if (!multiplication_table[i]) {
            // eliberare memorie alocată până în acest punct.
            for (int j = 0; j < i; j++){
                free(multiplication_table[j]);
            }
            free(multiplication_table);
            perror("malloc");
            exit(EXIT_FAILURE);
        }
    }
    for(int i = 0; i < n; i++) {
        for(int j = 0; j < n; j++) {
            multiplication_table[i][j] = i * j;
        }
    }
    return multiplication_table;
}
 
int main(void)
{
    int n, i, j;
    scanf("%d", &n);
    int** multiplication_table = get_multiplication_table(n);
    if (!multiplication_table) {
        return EXIT_FAILURE;
    }
    scanf("%d%d", &i, &j);
    printf("%d * %d = %d\n", i, j, multiplication_table[i][j]);
 
    for (int i = 0; i < n; i++) {
        free(multiplication_table[i]);
    }
    free(multiplication_table);
 
    return 0;
}

Concluzie

Am prezentat noțiunile de bază despre cum putem să folosim valgrind pentru a găsi bugurile din cod în mod eficient, aplicate pe câteva exemple.

De obicei rulăm valgrind cu toate flagurile pe care le considerăm utile, pentru a găsi toate problemele deodată: valgrind --leak-check=full --show-leak-kinds=all --show-reachable=no --exit-code=1 ./executabil (--show-reachable=no surprimă posibile warninguri provocate de biblioteci din sistem; în caz că programul are leakuri, procesul părinte - valgrind - iese cu codul specificat).

programare/tutoriale/debugging.txt · Last modified: 2022/01/21 02:03 by radu.nichita
CC Attribution-Share Alike 3.0 Unported
www.chimeric.de Valid CSS Driven by DokuWiki do yourself a favour and use a real browser - get firefox!! Recent changes RSS feed Valid XHTML 1.0