Mihai Budiu -- mihaib+@cs.cmu.edu
http://www.cs.cmu.edu/~mihaib/
18 mai 1999
Societatea contemporană se bazează din ce în ce mai mult pe calculatoare; cu atît mai dramatică este situația curentă a software-ului: mulți specialiști estimează că cea mai importantă criză a tehnologiei informaționale contemporane este robustețea redusă a programelor produse.
Situația este într-adevăr îngrijorătoare: productivitatea medie a unui programator este de circa 3 linii de cod pe zi (cod comentat, depanat și verificat); frecvența medie a erorilor este de una la o mie de linii.
Productivitatea programatorilor nu s-a schimbat în mod substanțial în ultimii douăzeci de ani, dar s-au schimbat enorm sculele pe care le au la dispoziție. Limbajele folosite în ziua de azi sunt mult mai expresive, și ca atare 3 linii de cod pot exprima mult mai mult. Scule sofisticate asistă programatorul în scrierea, verificarea, întreținerea, portarea și depanarea programelor.
În acest text voi face reclamă unui produs de excelentă calitate pentru depanarea programelor. Foarte interesant este faptul că produsul în seamă este în întregime free software, și ca atare este disponibil oricui de pe Internet, pe gratis (am scris un articol întreg despre fenomenul ``free software'' cu mai mult timp în urmă în PC Report; copia articolului este accesibilă din pagina mea de web.)
Programul în sine este ceea ce se numește un ``debugger vizual''; ``vizual'' pentru că permite o interacțiune foarte intuitivă utilizatorului. Calitatea sa rivalizează cu produse de firmă renumite, cum ar fi debugger-ul vizual al lui Microsoft. Pentru cei nerăbdători, programul se numește DDD (Data Display Debugger), și poate fi obținut, în surse sau executabile, cu documentația aferentă, de la Universitatea Tehnică din Braunschweig din Germania: http://www.cs.tu-bs.de/softech/ddd. De fapt vom vedea că DDD este doar un înveliș vizual care poate colabora cu alte debuggere care au interfețe mai simple (linie de comandă).
Trebuie să recunosc că eu însumi sunt un ins destul de conservator, care în general preferă interfață seacă în linie de comandă, stil Unix, unei interfețe vizuale ``desktop'', gen Microsoft Windows. Dar criteriul care ne face să alegem între cele două nu trebuie să fie unul religios, ci unul pragmatic. Productivitatea pe care o am în depanarea codului este mult crescută cu DDD1, așa că programul merită încercat.
Corectitudinea programelor este o noțiune mult mai complicată decît pare la prima vedere; există o sumedenie de definiții posibile, unele implicînd un aparat matematic sofisticat. Trebuie însă spus că, deși există o cantitate enormă de cercetare în ingineria programării (software engineering), în metode de verificare automată a programelor, în generatoare de programe, rezultatele practice sunt cu douăzeci de ani în urma cerințelor proiectelor moderne.
Fără a mă avînta în detalii (în care dealtfel nu sunt expert) pot totuși face cîteva observații generale, sper interesante pentru cititor. Există două clase mari de metode folosite pentru a garanta/analiza corectitudinea programelor. Prima clasă mare privește programele în întregime, și studiind codul sursă poate garanta proprietăți pe care programul le va avea oricînd, indiferent de datele de intrare. Aceste soluții se numesc statice.
A doua clasă studiază programele în execuție cu anumite date de intrare, și garantează oricînd că anumite lucruri nu se vor întîmpla niciodată, pentru că vor fi prevenite în mod explicit. Aceste metode sunt cele dinamice. Debugger-ele fac parte din clasa metodelor dinamice, deci vor avea partea leului în acest text. De aceea să acordăm cîteva cuvinte soluțiilor statice.
Trebuie spus dintru început că cele două clase de soluții (statice și dinamice) sunt fundamental diferite, pentru că proprietățile pe care le pot garanta sunt altele. Există astfel proprietăți care pot fi garantate numai cu soluții statice, proprietăți care pot fi garantate numai cu soluții dinamice, sau cu ambele (sau, din păcate, cu nici una).
Scula statică cea mai comună este compilatorul. Un compilator traduce un program dintr-un limbaj sursă într-un limbaj destinație. Proprietatea pe care trebuie s-o garanteze compilatorul este că ambele programe au același ``înțeles'' (semantică).
Depinzînd de expresivitatea limbajului sursă, compilatoarele pot garanta static anumite proprietăți ale programelor. De exemplu, în limbajele puternic tipizate (strongly typed), anumite erori sunt pur și simplu imposibile. În Java, de pildă, nu poți nicicum aduna un număr cu un caracter; această operație nici nu are sens, și este explicit interzisă prin sistemul de tipuri al limbajului. Din cauza asta, putem fi siguri că programatorul nu va face niciodată o astfel de eroare.
Folosind proprietățile deduse ale programului, compilatoarele moderne nu numai că transformă un program într-altul, ci efectuează și o serie întreagă de optimizări. Analiza statică pe care compilatoarele o efectuează garantează că optimizările făcute sunt corecte, în sensul că nu schimbă semnificația programului (ci doar viteza lui de execuție sau poate mărimea lui, sau alți parametri de interes pentru optimizare).
Există proprietăți care nu se pot garanta static niciodată; astfel de proprietăți se numesc nedecidabile. De exemplu la întrebarea ``va avea variabila X vreodată valoarea 0?'' nu se poate, în general, răspunde în mod static. Firește, pentru unele programe, acest lucru poate fi dovedit, dar nu pentru orice program care manipulează variabila X. Acest lucru poate fi demonstrat matematic. Îmi propun ca într-un articol ulterior, consacrat logicii matematice, să revin asupra acestui fapt. O ramură a informaticii, numită teoria calculabilității, se ocupă cu astfel de fapte.
Mi se pare interesant de menționat și următorul fapt, care este tot o consecință a teoriei calculabilității: am văzut că există mai multe programe diferite care fac același lucru (de exemplu un program și versiunea lui optimizată.) Ei bine, în general este imposibil de determinat care este cel mai mic program care face anumit lucru. Acest enunț se mai numește și Teorema Non-Șomajului pentru cei care scriu compilatoare (full-employment theorem for compiler writers). Asta înseamnă practic că seria de optimizări pe care le pot implementa compilatoarele este nesfîrșită! Formal vorbind, dacă cineva implementează un compilator pe care-l decretează perfect, atunci eu pot construi un alt compilator, care pentru cel puțin un program va genera un rezultat optimizat mai bine!
Pe de altă parte, o proprietate ca ``X este 0'' poate fi detectată cu o soluție dinamică: pur și simplu este suficient să verificăm înainte de fiecare instrucțiune care se execută dacă nu cumva efectul ei va fi să dea această valoare lui X.
În mod dinamic putem garanta o sumedenie de proprietăți care nu pot fi garantate static. De pildă, pentru limbaje de gen Java, care nu permit accesul înafara marginilor unui vector, putem garanta acest lucru folosind teste dinamice: de fiecare dată cînd accesăm un vector, testăm indicele dacă are o valoare între limitele admise.
Analiza dinamică are și ea limitările ei. De pildă, analiza dinamică nu va putea elimina astfel de teste din program; pe cînd un compilator inteligent va observa că într-un cod ca acesta:
int a[10], i; for (i=3; i < 7; i++) a[i] = 0;
variabila i va fi tot timpul între 0 și 9, limitele vectorului, deci va putea elimina testele indicelui din interiorul buclei for. Așa ceva nu putem realiza în mod dinamic.
Este foarte important să înțelegem o altă limitare esențială a metodelor dinamice: ele depind de datele de intrare. Dacă un program verificat dinamic nu face nici un fel de eroare pentru anumite date, nu avem nici o garanție că cu alte date nu se va comporta prost. Prin contrast, în mod static putem verifica adesea proprietăți care sunt valabile pentru orice intrări.
Vom trece în revistă mai jos o serie de alte metode dinamice de garantare a corectitudinii. Deși depanarea nu garantează proprietăți, ci doar permite verificarea lor în cursul execuției, o vom categorisi ca metodă dinamică. Vreau să subliniez că metodele care urmează sunt metode practice, care se bucură de un deosebit succes în activitatea programatorilor, și să vă îndemn să le folosiți în proiectele dumneavoastră.
O metodă relativ primitivă, dar extrem de eficace, este de a face programul însuși să indice progresul, inserînd în cod instrucțiuni de tipărire. Metoda este cîteodată singura posibilă: de exemplu cînd depanați nucleul unui sistem de operare, acesta nu are o noțiune de intrare/ieșire, și nici nu poate fi pus sub controlul unui debugger (adesea poate fi observat cu un debugger, dar nu oprit și modificat).
În programele mele eu pun în anumite zone de cod instrucțiuni de genul:
if (NIVEL_DEPANARE_OPTIMIZARE > 2) fprinf(log, "Am ajuns in acest punct si X=%d\n", X);
Apoi inventez o metodă prin care pot controla nivelul de depanare fără a fi nevoie să recompilez, sau chiar să reopresc programul. De pildă, programul poate avea opțiuni în linia de comandă, care modifică nivelul de depanare. În acest fel pot controla nivelul de depanare independent în diferite părți ale programului; schimbînd nivelul obțin informații de detalii diferite.
În Unix se folosește adesea o metodă interesantă pentru a controla nivelul de depanare a unor programe care nu se opresc niciodată din execuție, cum ar fi demonii care se ocupă de comunicația în Internet: nivelul de depanare poate fi controlat trimițînd anumite semnale acestor procese, cîteodată în conjuncție cu modificarea unor fișiere de configurare care controlează operațiile demonilor.
O tehnică de o utilitate greu de supraestimat este cea a folosirii aserțiunilor. Toate limbajele moderne pun la dispoziție aserțiuni; o aserțiune este o funcție care termină execuția programului dacă primește un argument nul. (Tocmai am măsurat numărul de aserțiuni în codul scris de mine: am în medie o aserțiune la 40 de linii de cod scrise.)
Aserțiunile sunt folosite pentru a verifica dacă anumiți invarianți ai programului sunt adevărați. De exemplu mărimea unui vector care se schimbă dinamic trebuie să fie pozitivă. Putem avea deci în codul care modifică acest vector ceva de genul:
char* resize(char* vector, int marime) { assert(marime > 0); .... }
De îndată ce vom viola această condiție programul se va opri din execuție.
Adesea aserțiunile pe care vrem să le verificăm sunt mai complicate; de pildă vrem să vedem dacă toate elementele dintr-un vector se însumează la 1000. Astfel de verificări sunt foarte costisitoare, și nu ne putem permite să le facem permanent. Dar dacă suspectăm că avem un bug care violează acest invariant, atunci putem proceda astfel (în C cel puțin): creăm o funcție specială care verifică invariantul, pe care o invocăm apoi dintr-o zonă de cod controlată de nivelul de depanare:
if (NIVEL_DEPANARE) assert(suma_vector() == 1000);
Astfel, folosim viteza calculatorului însuși pentru a verifica corectitudinea programului, făcînd teste complicate. Cînd programul este ``complet'' depanat (mai exact cînd credem noi asta), putem scoate aserțiunile și astfel de fragmente din cod foarte simplu; de pildă în C dacă definim macro-ul NDEBUG toate aserțiunile dispar automat din cod.
De fapt tehnologiile dinamice descrise mai sus fac parte dintr-o clasă foarte largă, numită software fault isolation. Tehnologia aceasta este folosită cu mult succes în mai multe produse, începînd cu nuclee ale sistemelor de operare, care permit utilizatorilor să le insereze în nucleu cod, și terminînd cu programe comerciale ca Purify și Insure.
Cele mai sofisticate astfel de scule funcționează chiar fără a avea la dispoziție programul sursă; ele fac ceea ce se numește binary instrumentation: modifică chiar fișierele executabile, inserînd verificări în anumite puncte cheie. De exemplu, foarte popularul program Purify, de la Pure Software, inserează cod special care verifică toate accesele la memorie, și modifică funcțiile de alocare a memoriei din biblioteca standard. Astfel de software prinde foarte multe erori de acces la memorie, cum ar fi accese în zone de memorie nealocate, re-folosirea memoriei de-alocate, citirea unor zone de memorie neinițializate, scurgeri de memorie (adică uitarea de a dealoca memoria alocată: ``memory leaks''), etc. Anumite clase de erori, ca cele cauzate de pointeri eronați, sunt adesea foarte ușor de depanat cu astfel de scule (și foarte greu altfel).
În fine, ajungem la subiectul acestui articol. Debugger-ele sunt programe sofisticate, care permit executarea altor programe într-un mod controlat; ele permit execuția, observarea și modificarea altui program. Debugger-ele folosesc o sumedenie de tehnici, înrudite cu software fault isolation (vom vedea cum), și suport din partea sculelor care generează programele, și a sistemului de operare. Debugger-ele se comportă vis-a-vis de programul depanat asemănător cu niște interpretoare, executînd instrucțiunile una cîte una. Pentru că scrierea unui interpretor (interpretoarele de cod-mașină se numesc ``emulatoare'') este o treabă foarte complicată, debugger-ele folosesc anumite servicii puse la dispoziție de către sistemul de operare.
Sistemul de operare este el însuși o uriașă sculă care face software fault isolation: el permite execuția paralelă a mai multor programe, dar previne interferența lor, claustrînd accesele fiecăruia în propria lui zonă de memorie, cu ajutorul memoriei virtuale, și controlînd interacțiunea dintre procese și resurse prin apelurile de sistem. (Despre sisteme de operare am scris o mulțime de articole în PC Report în trecut).
Vom discuta aici numai despre debugger-ele care permit depanarea unui program în limbajul în care a fost scris (limbajul sursă); debugger-ele care depanează numai limbaj mașină sunt substanțial mai simple, și mai puțin utile.
Figura 1 ilustrează interacțiunile unui debugger; vom detalia pe fiecare în parte în secțiunile următoare.
Cel mai important suport îl primește un debugger de la compilatorul care translatează fișierul din sursă în limbaj mașină. Compilatorul poate fi rugat să depună în fișierul executabil informații despre structura programului. Aceste informații sunt ceea ce se numește ``tabela de simboluri''. Informațiile asociază fiecare instrucțiune din codul mașină rezultat cu linia din codul sursă din care provine, indică adresele tuturor variabilelor din programul sursă, tipurile lor, adresele procedurilor și tipurile lor, etc. Adesea mai mult de 60% din m'arimea unui fi'sier executabil constă doar în informații de acest gen. Pe sistemele Unix există un utilitar numit strip (dezbracă) care șterge astfel de informații; el poate fi folosit pentru a face economie de spațiu pe disc.
Fără informațiile de depanare însă, debugger-ul nu poate face corespondența între codul obiect și fișierele sursă.
Există o sumedenie de standarde de reprezentare a informațiilor de depanare, care sunt menite să facă posibilă depanarea unui program generat de orice compilator cu orice debugger.
Pentru a fi eficace, un debugger folosește și suportul oferit de hardware. Acest suport îi permite să execute programele de depanat cu viteză maximă, intervenind numai atunci cînd este nevoie, în loc de a le urmări execuția pas cu pas, emulînd fiecare instrucțiune separat.
Iată cum funcționează aceste mecanisme: microprocesoarele moderne au niște regiștri speciali, în care se pot scrie felurite adrese. Procesoarele promit că atunci cînd aceste adrese apar în program, hardware-ul generează o excepție, care poate fi tratată apoi de software.
Un breakpoint (punct de întrerupere) este o valoare care este comparată cu adresa curentă a codului din registrul PC (Program Counter): cu alte cuvinte, cînd programul atinge adresa indicată de un breakpoint, se generează automat o întrerupere.
În mod alternativ, un breakpoint poate fi o instrucțiune care generează ea însăși o întrerupere atunci cînd este executată, numită chiar ``instrucțiune breakpoint''. Debugger-ele pot folosi ambele feluri de breapoint-uri.
O variantă de breakpoint-uri sunt cele condiționale: acestea opresc execuția numai dacă o anumită expresie are o anumită valoare, altfel continuă.
Un watchpoint-uri (punct de supraveghere) este o valoare care este comparată cu adresa datelor; dacă un program vrea să citească sau să scrie la o anumită adresă, se generează o întrerupere.
Debugger-ul este un program, iar programul depanat este un altul. Cum de poate debugger-ul să controleze un program independent, care nu a fost scris în acest scop? Sistemele moderne de operare izolează programele unul față de altul; atunci cum de poate debugger-ul să se uite la cele mai intime informații din spațiul de adrese al celuilalt program?
Fără ajutorul sistemului de operare nici n-ar putea.
Tot sistemul de operare pune la dispoziție o interfață specială, prin care un program poate observa comportarea altuia. De asemenea, sistemul de operare asigură respectarea unor reguli de securitate: numai utilizatorul care a pornit un proces are dreptul să-l depaneze; altfel debugger-ele ar putea fi folosite pentru a extrage informații nepermise.
Voi discuta aici interfața oferită de sistemele de tip Unix; deși nu știu ce oferă sistemele de tip Windows, este probabil că au mecanisme înrudite.
Există în Unix în mod tradițional două interfețe pentru depanare oferite de sistemul de operare. Prima este mai veche, și mai puțin elegantă. Ea constă dintr-un singur apel de sistem, numit ptrace.
Ptrace vine de la process trace: urmărește execuția altui proces. Acest apel de sistem de fapt implementează un mic limbaj, prin care debugger-ul conversează cu nucleul sistemului de operare. Iată cum arată ptrace pe Linux:
int ptrace(int operatie, int proces, int adresa, int date);
Prin execuția acestui apel, debugger-ul cere nucleului să efectueze operația indicată asupra procesului descris, la adresa și cu valorile pasate.
Iată exemple de operații: citește un cuvînt (de date, de cod, de stivă), scrie un cuvînt, trimite un semnal, oprește procesul, repornește procesul, etc.
Un debugger pune un breakpoint în celălalt proces astfel: citește o instrucțiune din proces, o memorează local, și o înlocuiește cu o ``instrucțiune breakpoint''. Apoi nucleul este rugat să continue execuția procesului, iar cînd breakpoint-ul este executat, o întrerupere transferă din nou control debugger-ului, care pune la loc instrucțiunea originală și decide apoi ce să facă cu procesul.
Un breakpoint condițional poate fi implementat ca un breakpoint obișnuit, doar că debugger-ul va verifica la fiecare oprire condiția (citind din spațiul celuilalt proces valorile necesare), și va reporni programul doar dacă condiția este falsă.
Interfața aceasta este oarecum incomodă, din mai multe motive.
Din cauza aceasta, în 1984, cercetătorii de la AT&T (unde a fost dezvoltat Unix-ul original) au propus o interfață alternativă, extrem de elegantă, bazată pe un sistem de fișiere virtual.
Sistemul de fișiere proc oferă acces la toate procesele în execuție în același fel în care oferă acces la fișiere. Am mai vorbit în alte articole despre eleganța interfeței Unix pentru accesul la fișiere, și despre sistemul de fișiere proc; aici nu voi face decît să recapitulez faptele esențiale.
În paranteză voi nota că tot cercetătorii de la AT&T au împins ideea accesului la obiecte prin interfața de fișiere și mai departe în proiectul lor de sistem distribuit numit Plan9, care însă din păcate nu a devenit niciodată un produs comercial.
Dacă aveți un sistem Linux la-ndemînă puteți experimenta proc în mod direct, și veți înțelege mai precis ce vreau să povestesc. Mergeți în directorul /proc și uitați-vă. Fiecare proces în execuție are aici un fișier care îi corespunde; procesul cu identificatorul 100 va avea un director cu numele 100.
În acest director se află o mulțime de ``fișiere virtuale'', care descriu informațiile nucleului despre acest proces. Aceste fișiere nu există pe disc; atunci cînd cineva încearcă să citească din aceste fișiere, nucleul de fapt extrage datele din memorie, din anumite structuri de date, și le returnează cititorului.
În acest director există și un fișier virtual care reprezintă întreg spațiul de memorie al acestui proces; citind octetul 5 din acest fișier se citește de fapt octetul de la adresa virtuală 5 din spațiul de adrese al acestui proces.
Utilizînd acest fișier, debugger-ul are imediat o vedere de ansamblu asupra întregului proces, putînd citi și scrie zone întregi cu un singur apel de sistem de citire, respectiv scriere.
Sistemele de operare pun adesea la dispoziție încă o interesantă funcțiune: fișierele-imagine (core). Utilizatorul poate inhiba crearea acestora cu apelul de sistem setrlimit.
Atunci cînd un proces execută o operație ilegală, nucleul îl omoară și simultan crează un fișier numit core, care este foarte asemănător cu fișierul din proc care descrie imaginea de memorie a procesului; acest fișier conține imaginea procesului așa cum arăta el în momentul răposării. Debugger-ul poate folosi acest fișier pentru a studia cauzele morții: poate inspecta variabilele, stiva, etc. în momentul morții, dar nu poate executa programul.
Dacă bibliotecile cu care programul este legat vin cu informații de depanare, debugger-ul poate fi folosit pentru a le depana și pe ele. Debugger-ul captează apelurile prin care procesele își leagă bibliotecile dinamice în momentul execuției, și le citește simbolurile de îndată ce sunt încărcate.
Iată aici recapitulată funcționarea unui debugger, pas cu pas:
În această secțiune voi discuta pe scurt despre unul dintre cele mai răspîndite debuggere, cel al proiectului GNU (despre proiectul GNU puteți citi în articolul meu anterior despre free software, pe care l-am mai menționat). GDB a fost scris inițial de Richard Stallman, celebrul inițiator al Fundației pentru Free Software (FSF), care a scris și editorul de texte Emacs (despre care am avut un alt articol în PC Report) și compilatorul de C numit GCC (Gnu C Compiler).
La data apariției sale, gdb avea tot felul de trăsături inovatoare. Voi menționa pe scurt unele dintre cele mai exotice; gdb poate face toate lucrurile pe care le-am descris mai sus, cînd am vorbit în general despre debuggere.
Gdb este un debugger în linie de comandă, dar există mai multe interfețe vizuale pentru el. O interfață excelentă permite folosirea lui gdb din editorul de texte Emacs; alte interfețe sunt xxgdb, gdbtk, și DDD, care este pretextul acestui articol.
(gdb) print a $1 = 5 (gdb) print a=3 $2 = 3 (gdb) print sin(a) $3 = .14112000805986722210 (gdb) print $1 $4 = 5
Semnele $ de mai sus pot fi folosite pentru a re-evalua expresii complicate. $1 ține locul expresiei a mai sus.
int *array = (int *) malloc (lungime * sizeof (int));
puteți tipări array-ul cu:
(gdp) p *array@lungime
(gdb) print tipareste_arbore(t) 1 |--2 |--3 |--4 | |--5 |--6 $1 = void (gdb)
Rezultatul evaluării funcției tipareste_arbore este nul (void), dar efectul executării ei se vede pe ecran.
(gdb) set $i = 0 (gdb) p vector[$i++].cheie (gdb) ENTER (gdb) ENTER ...
La fiecare ENTER se va re-executa instrucțiunea anterioară, care va incrementa variabila $i, plimbîndu-se prin vector;
(gdb) whatis v type = struct complex (gdb) ptype v type = struct complex { double real; double imag; }
În fine, voi spune cîteva cuvinte despre DDD. DDD nu este un debugger, ci doar o interfață pentru un alt debugger. DDD beneficiază de toată puterea debugger-ului pe care îl folosește, pentru că una din ferestrele sale oferă de fapt chiar interfața în linie de comandă clasică cu debugger-ul.
DDD face practic inutilă învățarea majorității comenzilor gdb, pentru că oferă interfețe foarte intuitive. Figura 3 arată principalele ferestre ale lui DDD.
Iată ce poate face DDD mai bine decît debugger-ele:
Una peste alta, DDD este o sculă extrem de ergonomică, și care se poate dovedi de un ajutor de neprețuit pentru cei care au de depanat programe complexe.
Vă doresc programe cît mai corecte!