Gestiunea memoriei
Subsistemul de gestiune a memoriei din cadrul unui sistem de operare este folosit de toate celelalte subsisteme: scheduling, I/O, filesystem, gestiunea proceselor, networking. Memoria este o resursă importantă și sunt necesari algoritmi eficienți de utilizare și gestiune a acesteia. Rolul subsistemului de gestiune a memoriei este de a ține evidența zonelor de memorie fizică ocupate sau libere, de a oferi proceselor sau celorlalte subsisteme acces la memorie și de a mapa paginile de memorie virtuală ale unui proces (pages) peste paginile fizice (frames).
Nucleul sistemului de operare oferă un set de interfețe (apeluri de sistem) care permit alocarea/dezalocarea de memorie, maparea unor regiuni de memorie virtuală peste fișiere, partajarea zonelor de memorie. Din păcate, nivelul limitat de înelegere a acestor interfețe și a acțiunilor ce se petrec în spate conduc la o serie de probleme foarte des întâlnite în aplicatiile software: memory leak-uri, accese invalide, suprascrieri, buffer overflow, corupere de zone de memorie. Este, în consecintă, fundamentală cunoașterea contextului în care acționează subsistemul de gestiune a memoriei și înelegerea interfeței pusă la dispoziție de sistemul de operare programatorului.
Spațiul de adrese al unui proces
Spațiul de adrese al unui proces, sau, mai bine spus, spațiul virtual de adresă al unui proces reprezintă zona de memorie virtuală utilizabilă de un proces. Fiecare proces are un spațiu de adresă propriu. Chiar în situațiile în care două procese partajează o zonă de memorie, spațiul virtual este distinct, dar se mapează peste aceeași zonă de memorie fizică.
În figura alăturată este prezentat un spațiu de adresă tipic pentru un proces. În sistemele de operare moderne, în spațiul virtual al fiecărui proces se mapează memoria nucleului, aceasta poate fi mapată fie la începutul fie la sfârsitul spațiului de adresă. (Note). În continuare ne vom referi numai la spațiul de adresă din user-space pentru un proces. Cele 4 zone importante din spațiul de adresă al unui proces sunt zona de date, zona de cod, stiva și heap-ul. După cum se observă și din figură, stiva și heap-ul sunt zonele care pot creşte. De fapt, aceste două zone sunt dinamice şi au sens doar în contextul unui proces. De partea cealaltă, informaţiile din zona de date şi din zona de cod sunt descrise în executabil.
Zona de cod
Zona/segmentul de cod (denumit şi 'text segment') reprezintă instrucţiunile programului. Registrul de tip 'instruction pointer' va referi adrese din zona de cod. Se citeşte instrucţiunea indicată, se decodifică şi se interpretează, după care se incrementează contorul programului şi se trece la următoarea instrucţiune. Zona de cod este, de obicei, o zonă read-only.
Zone de date
Zonele de date conţin variabilele globale definite într-un program şi variabilele de tipul read-only. În funcţie de tipul de date există mai multe sub-tipuri de zone de date.
.data
Zona .data conţine variabilele globale iniţializate la valori nenule ale unui program. De exemplu:
.bss
Zona .bss conţine variabilele globale neiniţializate sau iniţializate la zero ale unui program. De exemplu:
În general acestea nu vor fi prealocate în executabil ci în momentul creării precesului. Alocare zonei .bss se face peste pagini fizice zero (zeroed frames).
.rodata
Zona .rodata conţine informaţie care poate fi doar citită, nu şi modificată. Aici sunt stocate constantele:
Stiva
Stiva este o regiune dinamică în cadrul unui proces. Stiva este folosită pentru a reţine "stack frame-urile" (link) în cazul apelurilor de funcţii şi pentru a stoca variabilele locale. Pe marea majoritate a arhitecturilor moderne stiva creşte în jos şi heap-ul creşte în sus. Stiva este gestionată automat de compilator. La fiecare revenire din funcţie stiva este golită. În figura de mai jos este prezentată o vedere conceptuală asupra stivei in momentul apelului unei funcţii.
Heap-ul
Heap-ul este zona de memorie dedicată alocării dinamice a memoriei. Heap-ul este folosit pentru7 alocarea de regiuni de memorie a căror dimensiune se află doar la runtime. La fel ca şi stiva, heap-ul este o regiune dinamică şi care îi modifică dimensiunea. Spre deosebire de stivă, însă, heap-ul nu este gestionat de compilator. Este de datoria programatorului să ştie câtă memorie trebuie să aloce şi să reţină cât a alocat şi când trebuie să dezaloce. Problemele frecvente în majoritatea programelor țin de pierderea referinţelor la zonele alocate (memory leaks) sau referirea de zone nealocate sau insuficient alocate (accese invalide). La limbaje precum Java, Lisp, etc. unde nu există "pointer freedom", eliberarea spaţiului alocat se face automat prin intermediul unui garbage collector (link). Pe aceste sisteme se previne problema pierderii referinţelor, dar încă rămâne activă problema referirii zonelor nealocate.
Alocarea memoriei
Alocarea memoriei este realizată static de compilator sau dinamic, în timpul execuţiei. Alocarea statică este realizată în segmentele de date pentru variabilele locale sau pentru literali. În timpul execuţiei, variabilele se alocă pe stivă sau în heap. Alocarea pe stivă se realizează automat de compilator pentru variabilele locale unei funcții (mai puțin variabilele locale prefixate de identificatorul static).
Alocarea dinamică se realizează în heap. Alocarea dinamică are loc atunci când nu se ştie în momentul compilării câtă memorie va fi necesară pentru o variabilă, o structură, un vector. Dacă se ştie din momentul compilării cât spaţiu va ocupa o varibilă, se recomandă alocarea ei statică, pentru a preveni erorile frecvente apărute în contextul alocării dinamice. Pentru a fragmenta cât mai puţin spațiul de adrese al procesului, ca urmare a alocărilor şi dezalocărilor unor zone de dimensiuni variate, alocatorul de memorie va organiza segmentul de date alocate dinamic sub formă de heap, de unde şi numele segmentului.
Alocarea memoriei în Linux
În Linux alocarea memoriei pentru procesele utilizator se realizează prin intermediul funcţiilor de bibliotecă malloc, calloc şi realloc iar dezalocarea ei prin intermediul funcţiei free. Aceste funcţii reprezintă apeluri de bibliotecă şi rezolvă cererile de alocare şi dezalocare de memorie pe cât posibil în user space. Aşadar, se ţin nişte tabele care specifică zonele de memorie alocate în heap. Dacă există zone libere pe heap, un apel malloc care cere o zonă de memorie care poate fi încadrată într-o zonă liberă din heap va fi satisfăcut imediat marcând în tabel zona respectivă ca fiind alocată şi întorcând programului apelant un pointer spre ea. Dacă în schimb se cere o zonă care nu încape în nicio zonă liberă din heap, malloc va încerca extinderea heap-ului prin apelul de sistem brk sau mmap.
Întotdeauna eliberaţi (free) memoria alocată. Memoria alocată de proces este eliberată automat la terminarea procesului însă în cazul unui proces server, de exemplu, care rulează foarte mult timp şi nu eliberează memoria alocată acesta va ajunge să ocupe toată memoria disponibilă în sistem cauzând astfel consecințe nefaste. Atenţie să nu eliberaţi de două ori aceeaşi zonă de memorie întrucât acest lucru va avea drept urmare coruperea tabelelor ţinute de malloc ceea ce va duce din nou la consecinţe nefaste. Întrucât funcția free se întoarce imediat dacă primeşte ca parametru un pointer NULL, este recomandat ca după un apel free, pointer-ul să fie resetat la NULL. Câteva exemple de alocare a memoriei sunt prezentate în continuare:
Apelul realloc este folosit pentru modificarea spatiului de memorie alocat dupa un apel malloc:
Apelul calloc este folosit pentru alocarea de zone de memorie al căror conţinut este nul (plin de valori de zero). Spre deosebire de malloc, apelul va primi două argumente: numărul de elemente şi dimensiunea unui element.
Mai multe informaţii găsiţi în manualul bibliotecii standard C şi în pagina de manual man malloc.
Probleme de lucru cu memoria
Lucrul cu heap-ul este una dintre cauzele principale ale apariţiilor problemelor de programare. Lucrul cu pointerii, necesitatea folosirii unor apeluri de sistem/bibliotecă pentru alocare/dezalocare, pot conduce la o serie de probleme care afectează (de multe ori fatal) funcţionarea unui program. Problemele cele mai des întâlnite în lucrul cu memoria sunt:
accesul invalid la memorie
leak-urile de memorie
Accesul invalid la memorie presupune accesarea unor zone care nu au fost alocate sau au fost eliberate. Leak-urile de memorie sunt situaiile în care se pierde referinţa la o zonă alocată anterior. Acea zonă va rămâne ocupată până la încheierea procesului. Ambele probleme şi utilitarele care pot fi folosite pentru combaterea acestora vor fi prezentate în continuare.
Acces invalid
De obicei, accesarea unei zone de memorie invalide rezultă într-o eroare de pagină (page fault) şi terminarea procesului (în Unix înseamnă trimiterea semnalului SIGSEGV - afişarea mesajului 'Segmentation fault'). Totuşi, dacă eroarea apare la o adresă invalidă dar într-o pagină validă, hardwareul şi sistemul de operare nu vor putea sesiza acţiunea ca fiind invalidă. Acest lucru se datorează faptului că alocarea memoriei se face la nivel de pagină. Pot exista situaţii în care să fie folosită doar jumătate din pagină. Deşi cealaltă jumătate conţine adrese invalide, sistemul de operare nu va putea detecta accesele invalide la acea zonă. Asemenea accese pot duce la coruperea heap-ului şi la pierderea consistenţei memoriei alocate. După cum se va vedea în continuare, există utilitare care ajută la detectarea acestor situaţii.
Un tip special de acces invalid este buffer overflow. Acest tip de atac presupune referirea unor regiuni valide din spaţiul de adresă al unui proces prin intermediul unei variabile care nu ar trebui să poată referenţia aceste adrese. De obicei, un atac de tip buffer overflow rezultă în rularea de cod nesigur. Protecţia la accese de tip buffer overflow se realizează prin verificarea limitelor unui buffer/vector fie la compilare, fie la rulare.
GDB - Detectarea zonei de acces invalid de tip page fault
Pe lângă facilităţi de bază precum urmărirea unei variabile sau configurarea de puncte de oprire (breakpoints), GDB pune la dispoziţia utilizatorilor şi comenzi avansate, utile în anumite cazuri. Comanda disassamble poate fi folosită pentru a afișa codul maşină generat de compilator. Comanda info reg afişează conţinutul registrelor. Aceste comenzi sunt folosite rar, atunci când utilizatorul încearcă să depaneze codul generat de compilator, sau când are părţi din program scrise direct în asamblare. O comandă foarte utilă atunci când se depanează programe complexe este backtrace. Această comandă afişează toate apelurile de funcţii în curs de execuţie. Exemplu: fibonacci_test.c
Pentru exemplul de mai sus, vom demonstra utilitatea comenzii backtrace:
Se observă că la afişarea apelurilor de funcţii se afişează şi parametrii cu care a fost apelată funcţia. Acest lucru este posibil datorită faptului că atât variabilele locale cât şi parametrii acesteia sunt păstraţi pe stivă până la ieşirea din funcţie.
Fiecare funcţie are alocată pe stivă un frame, în care sunt plasate variabilele locale funcţiei, parametrii pasaţi funcţiei şi adresa de revenire din functie. În momentul în care o funcţie este apelată, se creează un nou frame prin alocarea de spaţiu pe stivă de către funcţia apelată. Astfel, dacă avem apeluri de funcţii imbricate, atunci stiva va conţine toate frame-urile tuturor funcţiilor apelate imbricat. GDB dă posibilitatea utilizatorului să examineze frame-urile prezente în stivă. Astfel, utilizatorul poate alege oricare din frame-urile prezente folosind comanda frame. După cum s-a observat, exemplul anterior are un bug ce se manifestă atunci când numărul introdus de la tastatură depăşeşte dimensiunea buffer-ului alocat (static). Acest tip de eroare poartă denumirea de buffer overflow şi este extrem de gravă. Cele mai multe atacuri de la distanţă pe un sistem sunt cauzate de acest tip de erori. Din păcate, acest tip de eroare nu este uşor de detectat, pentru că în procesul de buffer overrun se pot suprascrie alte variabile, ceea ce duce la detectarea erorii nu imediat când s-a făcut suprascrierea, ci mai târziu, când se va folosi variabila afectat.
Din analiza de mai sus se observă că funcţia fibonacci a fost apelată cu valoarea 0. Cum funcţia nu testează ca parametrul să fie valid, se va apela recursiv de un număr suficient de ori pentru a cauza umplerea stivei programului. Se pune problema cum s-a apelat funcţia cu valoarea 0, când trebuia apelată cu valoarea 10.
Se observă că problema este cauzată de faptul că variabila baza a fost alterată. Pentru a determina când sa întâmplat acest lucru, se poate folosi comanda watch. Această comandă primește ca parametru o expresie şi va opri execuţia programului de fiecare dată când valoarea expresiei se schimbă. (gdb) quit
Din analiza de mai sus se observă că valoarea variabilei este modificată în funcţia _IO_vfscanf, care la rândul ei este apelată de către functia scanf. Dacă se analizează apoi parametrii pasaţi funcției scanf se observă imediat cauza erorii. Pentru mai multe informaţii despre GDB consultaţi manualul online (alternativ pagina info - info gdb) sau folosiţi comanda help din cadrul GDB. mcheck - verificarea consistenţei heap-ului glibc permite verificarea consistenţei heap-ului prin intermediul apelului mcheck definit în mcheck.h. Apelul mcheck forţează malloc să execute diverse verificări de consistenţă precum scrierea peste un bloc alocat cu malloc. Alternativ, se poate folosi opţiunea -lmcheck la legarea programului fără a afecta sursa acestuia. Varianta cea mai simplă este folosirea variabilei de mediu MALLOC_CHECK_. Dacă un program va fi lansat în execuţie cu variabila MALLOC_CHECK_ configurată, atunci vor fi afişate mesaje de eroare (eventual programul va fi terminat forţat - aborted). Mai jos se găseşte un exemplu de cod cu probleme în alocarea şi folosirea heap-ului: Exemplu: mcheck_test.c
Mai jos programul este compilat şi rulat. Mai întâi este rulat fără opţiuni de mcheck, după care se defineşte variabila de mediu MALLOC_CHECK_ la rularea programului. Se observă că deşi se depăşeşte spaţiul alocat pentru vectorul v1 şi se referă vectorul după eliberarea spaţiului, o rulare simplă nu rezultă în afişarea nici unei erori. Totuşi, dacă definim variabila de mediu MALLOC_CHECK_, se detectează cele două erori. De observat că o eroare este detectată doar în momentul unui nou apel de memorie interceptat de mcheck.
mcheck nu este o soluţie completă şi nu detectează toate erorile ce pot apărea în lucrul cu memoria. Detectează, totuşi, un număr important de erori şi reprezintă o facilitate importantă a glibc. O descriere completă găsiți în pagina asociată din manualul glibc.
Leak-uri de memorie
Un leak de memorie apare în două situaţii:
un program omite să elibereze o zonă de memorie
un program pierde referinţa la o zonă de memorie dealocată şi, drept consecință, nu o poate elibera
Memory leak-urile au ca efect reducerea cantităţii de memorie existentă în sistem. Se poate ajunge, în situaţiile extreme, la consumarea întregii memorii a sistemului şi la imposibilitatea de funcţionare a diverselor aplicaţii ale acestuia. Ca şi în cazul problemei accesului invalid la memorie, utilitarul Valgrind este foarte util în detectarea leak-urilor de memorie ale unui program.
mtrace
Un utilitar care poate fi folosit la depanarea erorilor de lucru cu memoria este mtrace. Acest utilitar ajută la identificarea leak-urilor de memorie ale unui program. Utilitarul mtrace se folosete cu apelurile mtrace şi muntrace implementate în biblioteca standard C:
Utilitarul mtrace introduce handlere pentru apelurile de biblioteca de lucru cu memoria (malloc, realloc, free). Apelurile mtrace şi muntrace activează, respectiv dezactivează monitorizarea apelurilor de bibliotecă de lucru cu memoria. Jurnalizarea operaţiilor efectuate se realizează în fişierul definit de variabile de mediu MALLOC_TRACE. După ce apelurile au fost înregistrate în fişierul specificat, utilizatorul poate să folosească utilitarul mtrace pentru analiza acestora. În exemplul de mai jos este prezentată o situaţie în care se alocă memorie fără a fi eliberată: Exemplu: mtrace_test.c
În secvenţa de comenzi de mai jos se compilează fişierul de mai sus, se stabileşte fişierul de jurnalizare şi se rulează comanda mtrace pentru a detecta problemele din codul de mai sus.
Mai multe informaţii despre detectarea problemelor de alocare folosind mtrace gasiti în pagina asociată din manualul glibc.
Dublă dezalocare
Denumirea "dublă dezalocare" oferă o bună intuiţie asupra cauzei: eliberarea de două ori a aceluiaşi spaţiu de memorie. Dubla dezalocare poate avea efecte negative deoarece afectează structurile interne folosite pentru a gestiona memoria ocupată. În ultimele versiuni ale bibliotecii standard C se detectează automat cazurile de dublă dezalocare. Fie exemplul de mai jos: Exemplu: dfree.c
Rularea executabilului obţinut din programul de mai sus duce la afişarea unui mesaj specific al glibc de eliberare dublă a unei regiuni de memorie şi terminarea programului:
Situaţii de dezalocare sunt, de asemenea, detectate de Valgrind.
Valgrind
Valgrind reprezintă o suită de utilitare folosite pentru operaţii de debugging şi profiling. Cel mai popular este Memcheck, un utilitar care permite detectarea de erori de lucru cu memoria (accese invalide, memory leak-uri etc.). Alte utilitare din suita Valgrind sunt Cachegrind, Callgrind utile pentru profiling sau Helgrind, util pentru depanarea programelor multithreaded. În continuare ne vom referi doar la utilitarul Memcheck de detectare a erorilor de lucru cu memoria. Mai precis, acest utilitar detectează următoarele tipuri de erori:
folosirea de memorie neiniţializată
citirea/scrierea din/în memorie după ce regiunea respectivă a fost eliberată
citirea/scrierea dincolo de sfârşitul zonei alocate
citirea/scrierea pe stivă în zone necorespunzătoare
memory leak-uri
folosirea necorespunzătore de apeluri malloc/new şi free/delete
Valgrind nu necesită adaptarea codului unui program ci folosete direct executabilul (binarul) asociat unui program. La o rulare obişnuită Valgrind va primi argumentul --tool pentru a preciza utilitarul folosit şi programul care va fi verificat de erori de lucru cu memoria. În exemplul de rulare de mai jos se foloseşte programul prezentat la seciunea mcheck:
S-a folosit utilitarul memcheck pentru obţinerea informaţiilor de acces la memorie. Se recomandă folosirea opţiunii -g la compilarea programului pentru a prezenta în executabil informaţii de depanare. În rularea de mai sus, Valgrind a identificat două erori: una apare la linia 17 de cod şi este corelată cu linia 10 (malloc), iar cealaltă apare la linia 22 şi este corelată cu linia 19 (free):
Exemplul următor reprezintă un program cu o gamă variată de erori de alocare a memoriei: Exemplu: valgrind_test.c
În continuare, se prezintă comportamentul executabilului obţinut la o rulare obişnuită şi la o rulare sub Valgrind:
Se poate observa că la o rulare obişnuită programul nu generează nici un fel de eroare. Totuşi, la rularea Valgrind apar erori în 3 contexte:
la apelul strcat (linia 10) şirul nu a fost iniţializat
se scrie în memorie după free (linia 20: p[1] = 'a')
underrun (linia 28) În plus există leak-uri de memorie datorită noului apel malloc care asociază o nouă valoare lui p (linia 24).
Valgrind este un utilitar de bază în depanarea programelor. Este facil de folosit (nu este intrusiv, nu necesită modificarea surselor) şi permite detectarea unui număr important de erori de programare apărute ca urmare a gestiunii defectuoase a memoriei. Informaţii complete despre modul de utilizare a Valgrind şi a utilitarelor asociate se găsesc în paginile de documentaţie Valgrind: http://valgrind.org/docs/manual/index.html
Alte utilitare pentru depanarea problemelor de lucru cu memoria
Utilitarele prezentate mai sus nu sunt singurele folosite pentru detectarea problemelor apărute în lucrul cu memoria. Alte utilitare sunt:
http://en.wikipedia.org/wiki/Category:Memory_management_software
dmalloc
mpatrol
DUMA
Electric Fence
Resurse utile
Linux System Programming - Chapter 8 - Memory Management
Windows System Programming - Chapter 5 - Memory Management (Win32 and Win64 Memory Management Architecture, Heaps, Managing Heap Memory
Linux Application Programming - Chapter 7 - Memory Debugging Tools
Windows Memory Management
Virtual Memory Allocation and Paging
GDB manual
Valgrind Home
Using Valgrind to Find Memory Leaks
The Memory Management Reference
IBM trial download: Rational Purify 7.0
Using Purify
Memory Management Software
Smashing the Stack for Fun and Profit