4. Muistinhallinta
Tässä luvussa tutustumme tarkemmin siihen, miten muistia varataan C-ohjelman suorituksen aikana. Kolme tavallista tapaa muistin varaamiseen ovat:
-
Automaattinen muistinvaraus: Muistia varataan funktion paikallisille muuttujille muistialueelta nimeltä pino. Muisti varataan ja vapautetaan automaattisesti funktion suorituksen aikana.
-
Staattinen muistinvaraus: Muistia varataan globaaleille ja staattisille muuttujille koko ohjelman suorituksen ajaksi. Muisti varataan ohjelman käynnistyessä ja vapautetaan ohjelman päättyessä.
-
Dynaaminen muistinvaraus: Muistia varataan C:n standardikirjaston funktioilla (kuten
malloc) muistialueelta nimeltä keko. Ohjelmoija huolehtii itse muistin varaamisesta ja vapauttamisesta.
Automaattinen muistinvaraus
Automaattinen muistinvaraus on C-kielen tavallinen tapa varata muistia paikallisille muuttujille eli funktion sisällä määritellyille muuttujille.
Muistia varataan muistialueelta nimeltä pino (stack), jonka toiminta muistuttaa samannimistä tietorakennetta. Ohjelmoijan kannalta hyvä mielikuva asiasta on, että kun funktion suoritus alkaa, funktion muuttujille varataan muistia pinosta. Sitten kun funktion suoritus päättyy, muuttujille varattu muisti vapautetaan.
Automaattinen muistinvaraus on tarkoitettu väliaikaiseen melko pienen muistimäärän varaamiseen funktioissa. Pinomuistin käyttäminen on tehokasta, mutta siihen liittyy myös joitakin rajoituksia, joihin tutustumme pian.
Pinomuistin käyttäminen
Tarkastellaan esimerkkinä seuraavaa koodia:
void test_1(void) {
int a = 1;
test_2();
test_3();
}
void test_2(void) {
int b = 2;
}
void test_3(void) {
int c = 3;
int d = 4;
test_2();
}
Kun funktiota test_1 kutsutaan, ohjelman suoritus voisi edetä seuraavasti:
- Funktio
test_1varaa muistia muuttujallea. - Funktio
test_2varaa muistia muuttujalleb. - Funktio
test_2vapauttaa muuttujallebvaratun muistin. - Funktio
test_3varaa muistia muuttujillecjad. - Funktio
test_2varaa muistia muuttujalleb. - Funktio
test_2vapauttaa muuttujallebvaratun muistin. - Funktio
test_3vapauttaa muuttujillecjadvaratun muistin. - Funktio
test_1vapauttaa muuttujalleavaratun muistin.
Pinomuisti on toteutettu yleensä niin, että pino kasvaa oikealta vasemmalle. Yllä olevassa tilanteessa muuttujille voisi varata muistia seuraavaan tapaan:
Tämä esimerkki on yksinkertaistus pinomuistin käyttämisestä ohjelman suorituksen aikana. Todellisuudessa tilanne on monimutkaisempi, koska pinomuistia käytetään muuhunkin kuin funktion muuttujia varten ja funktio saattaa myös säilyttää muuttujia rekistereissä pinomuistin sijasta. Tutustumme asiaan tarkemmin konekielisen ohjelmoinnin yhteydessä.
Varatun muistin käyttöaika
Funktiossa varattu muisti on käytettävissä vain funktion suorituksen aikana, koska muisti vapautuu automaattisesti funktion suorituksen päättyessä. Esimerkiksi seuraava koodi ei ole toimiva:
int* create_array(int n) {
int array[n];
for (int i = 0; i < n; i++) {
array[i] = i;
}
return array; // VIRHE
}
int main(void) {
int n = 10;
int *range = create_array(n);
for (int i = 0; i < n; i++) {
printf("%d ", range[i]);
}
}
Funktiossa create_array on ideana luoda n-kokoinen taulukko ja palauttaa osoitin taulukkoon funktion paluuarvona.
Tässä on kuitenkin ongelmana, että taulukolle array varattu muistialue vapautuu automaattisesti funktion päättyessä. Tämän takia on virhe funktiossa create_array palauttaa osoitin taulukon muistialueeseen, koska varattu muisti ei ole enää käytettävissä funktion suorituksen päätyttyä.
Yllä olevan koodin voi kääntää, mutta gcc antaa seuraavan varoituksen:
test.c: In function ‘create_array’:
test.c:9:12: warning: function returns address of local variable [-Wreturn-local-addr]
9 | return array;
| ^~~~~
Tällaisen koodin suorittaminen johtaa määrittelemättömään toimintaan, esimerkiksi ”Segmentation fault” -virheeseen.
Pinomuistin rajallisuus
Automaattisen muistinvarauksen rajoituksena on myös, että saatavilla oleva pinomuistin määrä on usein melko pieni. Esimerkiksi Linux-ympäristössä on tavallista, että ohjelman käytössä oleva pinomuistin määrä on oletuksena vain 8 megatavua.
Seuraava koodi tuo esille pinomuistin rajallisuuden:
int main(void) {
int n = 10000000;
int array[n];
}
Koodi koettaa varata funktion sisällä taulukon, jossa on 10 miljoonaa int-lukua. Kun jokainen int-luku vie tilaa 4 tavua, koko taulukko vie tilaa noin 40 megatavua muistia. Tämä on selvästi enemmän kuin tavallinen käytettävissä olevan pinomuistin määrä Linux-ympäristössä.
Yritys suorittaa yllä oleva koodi johtaa tyypillisesti siihen, että taulukkoa ei pystytä varaamaan ja ohjelman suoritus päättyy kaatumiseen.
Linuxissa komennolla ulimit voi tarkastaa ohjelman käytettävissä olevan pinomuistin määrän sekä myös muuttaa sitä.
Seuraava komento tarkastaa pinomuistin määrän:
$ ulimit -s
8192
Tässä tuloksena on 8192 kilotavua eli 8 megatavua, mikä on pinomuistin tavallinen määrä Linux-ympäristössä.
Seuraava komento suurentaa pinomuistin määrää:
$ ulimit -s 102400
Komennon suorituksen jälkeen pinomuistia on käytettävissä 102400 kilotavua eli 100 megatavua, jolloin pinoon mahtuu selvästi enemmän tietoa.
Kuitenkaan pinomuistin määrän suurentaminen ei ole yleensä ottaen hyvä ratkaisu. Pinomuisti on tarkoitettu melko pienen tietomäärän väliaikaiseen tallentamiseen, eikä ohjelman toimintaa ole järkevää rakentaa sen varaan, että pinomuistin määrää on kasvatettu.
Pinomuisti funktiokutsuissa
Pinomuistia voidaan käyttää funktion muuttujien tilanvarauksen lisäksi myös funktiokutsuihin liittyvään tiedonvälitykseen. Tähän sisältyy esimerkiksi funktioiden parametrien ja paluuarvojen välittäminen muistissa.
Käytännössä tämä asia saattaa tulla vastaan silloin, kun funktiota kutsutaan rekursiivisesti. Tällöin ongelmaksi saattaa tulla, että sisäkkäiset funktiokutsut kuluttavat liikaa pinomuistia.
Esimerkiksi seuraava rekursiivinen funktio sum laskee positiivisten lukujen summan lukuun n asti:
long sum(int n) {
if (n == 0) return 0;
return sum(n - 1) + n;
}
Kun tätä funktiota kutsutaan, pinoon tallennetaan tyypillisesti tietoa sisäkkäisistä funktiokutsuista. Tämän takia funktion kutsuminen suurella n:n arvolla (esimerkiksi miljoona) voi johtaa pinomuistin loppumiseen ja ohjelman kaatumiseen.
C-ohjelmoijan näkökulmasta on vaikeaa saada tietoa, miten ohjelma tarkalleen käyttää pinomuistia funktion tiedonvälitykseen. Tutustumme asiaan syvällisemmin myöhemmin kurssilla konekielisen ohjelmoinnin yhteydessä.
Staattinen muistinvaraus
Staattisessa muistinvarauksessa tietty muistialue varataan ohjelman käyttöön koko ohjelman suorituksen ajaksi. Näin tehdään silloin, kun koodissa määritellään globaali muuttuja tai funktion sisällä määritellään staattinen muuttuja.
Globaali muuttuja
Globaali muuttuja on funktioiden ulkopuolella määritelty muuttuja, joka on näkyvissä kaikissa funktioissa. Esimerkiksi seuraavassa koodissa on globaali muuttuja counter, jota käytetään funktiossa test:
int counter = 0;
void test(void) {
counter++;
printf("%d\n", counter);
}
int main(void) {
test();
test();
test();
}
Koodin tulostus on seuraava:
1
2
3
Globaali muuttuja tulee määritellä koodissa, ennen kuin siihen voi viitata funktiossa. Tässä on sama logiikka kuin vaatimuksessa, että funktio tulee esitellä koodissa, ennen kuin sitä voi kutsua toisesta funktiosta.
Staattinen muuttuja
Kun muuttujan määrittelyssä funktion sisällä on avainsana static, muuttujasta tulee staattinen, jolloin sen arvo säilyy pysyvästi muistissa funktion eri kutsukertojen välillä. Seuraava koodi esittelee asiaa:
void test(void) {
static int counter = 0;
counter++;
printf("%d\n", counter);
}
int main(void) {
test();
test();
test();
}
Koodin tulostus on seuraava:
1
2
3
Tässä funktion test muuttuja counter on staattinen, minkä takia sen arvo kasvaa yhdellä jokaisella funktion suorituskerralla.
Avainsanan static lisäksi C-kielessä on myös avainsana auto, joka ilmaisee, että muuttuja ei ole staattinen vaan sille varataan automaattisesti muistia funktiossa. Tämä on oletustapa muuttujan määrittelyssä, eli seuraavat määrittelyt tarkoittavat samaa funktion sisällä:
int counter = 0;
auto int counter = 0;
Koska muuttujalle varataan oletuksena muistia automaattisesti, avainsana auto on käytännössä melko tarpeeton.
Dynaaminen muistinvaraus
Dynaamisessa muistinvarauksessa muistia varataan muistialueelta nimeltä keko (heap) kutsumalla C:n standardikirjaston funktioita. Tämä antaa ohjelmoijalle sekä enemmän vapautta että vastuuta muistin käyttämiseen liittyen.
Dynaamisessa muistinvarauksessa ohjelmoija päättää itse, milloin muistia varataan ja vapautetaan. Tämän ansiosta varattu muistialue ei ole sidoksissa tietyn muuttujan näkyvyysalueeseen, vaan esimerkiksi funktiossa varattua muistia voidaan käyttää funktion päättymisen jälkeen.
Kekomuistin määrässä ei ole pinomuistin kaltaisia rajoituksia, minkä ansiosta ohjelma voi varata dynaamisesti suurenkin määrän muistia ilman ongelmia.
Dynaaminen muistinvaraus on kuitenkin monimutkaisempaa sekä muistinhallinnan että ohjelmoijan kannalta. Jos varattua muistialuetta ei vapauteta, sitä ei voida käyttää myöhemmin uudestaan toiseen tarkoitukseen.
Kekomuistin käyttäminen
C:n standardikirjaston otsikkotiedosto stdlib.h sisältää funktioita dynaamiseen muistinvaraukseen. Tavallisin muistia varaava funktio on malloc, jolle annetaan varattavan muistin määrä. Funktio varaa muistin keosta ja palauttaa osoittimen varatun muistialueen alkuun.
Esimerkiksi seuraava koodi varaa muistia n alkion int-taulukolle:
int n = 100;
int *data = malloc(n * sizeof(int));
Funktiolle annetaan parametrina varattavan muistin määrä tavuina. Tässä tarvittava muistin määrä saadaan kaavalla n * sizeof(int), koska taulukkoon tulee n alkiota ja jokainen alkio vie tilaa sizeof(int) tavua.
Kun funktiota malloc kutsutaan, se joko varaa muistin ja palauttaa osoittimen varattuun muistialueeseen tai palauttaa NULL-osoittimen merkkinä siitä, että muistin varaaminen ei onnistunut. Jälkimmäinen voi tapahtua esimerkiksi silloin, kun saatavilla ei ole riittävästi vapaata muistia.
Ohjelmoijan vastuulla on käsitellä tilanne, jossa muistin varaaminen ei onnistunut. Tämän voi havaita tarkastamalla, palauttiko funktio NULL-osoittimen:
if (data == NULL) {
// varaus ei onnistunut
}
Jos muistin varaaminen onnistui, ohjelma voi käyttää varattua muistialuetta haluamallaan tavalla saamansa osoittimen kautta. Varattua muistialuetta ei ole alustettu, vaan sen alkusisältö on määrittämätön. Muistialuetta voi käyttää käytännössä samalla tavalla kuin taulukkoa, esimerkiksi seuraavasti:
for (int i = 0; i < n; i++) {
data[i] = i;
}
Ohjelmoijan vastuulla on myös vapauttaa varattu muisti, kun sitä ei enää tarvita. Muisti voidaan vapauttaa kutsumalla standardikirjaston funktiota free, jolle annetaan parametriksi osoitin varatulle muistialueelle:
free(data);
Muistin vapauttaminen on tärkeää, koska jos muistia ei vapauteta, ohjelmassa on muistivuoto (memory leak) eli ohjelma kuluttaa muistia mutta ei vapauta sitä. Tämä voi johtaa siihen, ettei muistia ole saatavilla riittävästi ohjelmalle tai muille samaan aikaan käyttöjärjestelmässä käynnissä oleville ohjelmille.
Ohjelman suorituksen päätyttyä kaikki ohjelman dynaamisesti varaama muisti kuitenkin vapautetaan automaattisesti. Ei ole siis mahdollista, että ohjelman tekemä muistinvaraus jatkuisi vielä ohjelman suorituksen jälkeen.
Muistinvaraus funktiossa
Seuraavassa on esimerkki funktiosta, joka varaa muistia dynaamisesti ja palauttaa osoittimen varattuun muistialueeseen. Koska muistialue varataan dynaamisesti, sitä voidaan käyttää funktion päättymisen jälkeen.
int* create_array(int n) {
int *array = malloc(n * sizeof(int));
if (array == NULL) return NULL;
for (int i = 0; i < n; i++) {
array[i] = i;
}
return array;
}
int main(void) {
int n = 10;
int *range = create_array(n);
if (range) {
for (int i = 0; i < n; i++) {
printf("%d ", range[i]);
}
}
free(range);
}
Koodin tulostus on seuraava:
0 1 2 3 4 5 6 7 8 9
Koodi varautuu kahdessa kohdassa siihen, ettei muistinvaraus onnistunut. Jos malloc-kutsun jälkeen osoitin array on NULL-osoitin eli muistinvaraus ei onnistunut, funktio create_array palauttaa NULL-osoittimen. Pääfunktiossa taulukon sisältö puolestaan tulostetaan vain, jos osoitin ei ole NULL-osoitin.
Koska funktio create_array varaa muistia mutta ei vapauta sitä, funktiota kutsuneen koodin vastuulla on hoitaa muistin vapauttaminen. Tämä tehdään pääfunktion viimeisellä rivillä kutsumalla funktiota free.
Huomaa, että funktiota free voidaan käyttää myös NULL-osoittimelle. Tämän ansiosta funktiota free voidaan kutsua turvallisesti pääfunktion lopussa riippumatta siitä, onnistuiko muistin varaaminen vai ei.
Kekomuistin hallinnointi
Muistin dynaaminen varaaminen keosta on selvästi hankalampaa kuin automaattinen muistinvaraus pinosta. Kun ohjelma käyttää pinomuistia, sen riittää varata ja vapauttaa muistia pinon päältä. Sen sijaan kekomuistia käytettäessä funktioiden malloc ja free tulee käsitellä seuraavanlaisia pyyntöjä:
- Varaa annettu määrä muistia ja palauta osoitin muistialueen alkuun.
- Vapauta muistialue annetun osoittimen perusteella.
Tässä on haasteena toisaalta, kuinka löytää sopivan kokoinen muistialue varattavaksi, ja toisaalta, kuinka toteuttaa muistinhallinta tehokkaasti.
Esimerkiksi Linuxissa muistin varaaminen dynaamisesti on monimutkainen prosessi, jossa muistia voidaan varata usealla eri tavalla riippuen varattavan muistin määrästä sekä eri tavoilla saatavilla olevasta muistista.
Muistialueet
Käyttöjärjestelmä antaa ohjelman käyttöön virtuaalisia muistialueita sen mukaan, miten ohjelma varaa muistia.
64-bittisessä Linuxissa muistinhallinta on yleensä toteutettu niin, että ohjelmien virtuaaliset muistiosoitteet ovat välillä 0x000000000000 ja 0x7fffffffffff. Pinolle varattu muistialue on tämän välin lopussa ja muut muistialueet sijaitsevat aiemmin välillä.
Voimme saada käsitystä asiasta tutkimalla seuraavan ohjelman toimintaa:
#include <stdio.h>
#include <stdlib.h>
int g1, g2, g3;
char *s = "aybabtu";
void f(void) {
printf("%s", s);
}
int main(void) {
int l1, l2, l3;
int *d1 = malloc(sizeof(int));
int *d2 = malloc(sizeof(int));
int *d3 = malloc(sizeof(int));
printf("global: %p %p %p\n", &g1, &g2, &g3);
printf("local: %p %p %p\n", &l1, &l2, &l3);
printf("dynamic: %p %p %p\n", d1, d2, d3);
printf("string: %p\n", s);
printf("function: %p\n", f);
while (1);
}
Ohjelma näyttää eri tavoin varatun muistin osoitteita sekä funktion osoitteen muistissa. Ohjelman tulostus voi olla esimerkiksi seuraava:
global: 0x5fe7d5c1a01c 0x5fe7d5c1a020 0x5fe7d5c1a024
local: 0x7ffc96279644 0x7ffc96279648 0x7ffc9627964c
dynamic: 0x5fe80be312a0 0x5fe80be312c0 0x5fe80be312e0
string: 0x5fe7d5c18004
function: 0x5fe7d5c17169
Tästä näkee, että paikallisilla pinosta varatuilla muuttujilla osoitteet ovat tosiaan lähellä muistiavaruuden ylärajaa 0x7fffffffffff ja muissa varauksissa osoitteet ovat puolestaan lähellä kohtaa 0x5fe7d5c16000.
Muistikartta
Kun ohjelma on käynnissä, voimme tutkia sille kuuluvia muistialueita tarkemmin seuraavasti. Komento pidof hakee binääritiedoston test prosessi-id:n ja komento pmap näyttää tähän prosessiin liittyvän muistikartan.
Ohjelman lopussa on ikuinen silmukka sen takia, että ohjelma jää taustalle pyörimään ja voimme rauhassa tutkia sen muistinkäyttöä.
Tässä tapauksessa saamme seuraavan raportin:
$ pidof test
23748
$ pmap -x 23748
23748: ./test
Address Kbytes RSS Dirty Mode Mapping
00005fe7d5c16000 4 4 4 r---- test
00005fe7d5c17000 4 4 4 r-x-- test
00005fe7d5c18000 4 4 4 r---- test
00005fe7d5c19000 4 4 4 r---- test
00005fe7d5c1a000 4 4 4 rw--- test
00005fe80be31000 132 4 4 rw--- [ anon ]
00007f9a98200000 160 160 0 r---- libc.so.6
00007f9a98228000 1568 920 0 r-x-- libc.so.6
00007f9a983b0000 316 64 0 r---- libc.so.6
00007f9a983ff000 16 16 16 r---- libc.so.6
00007f9a98403000 8 8 8 rw--- libc.so.6
00007f9a98405000 52 16 16 rw--- [ anon ]
00007f9a985e5000 12 8 8 rw--- [ anon ]
00007f9a98629000 8 4 4 rw--- [ anon ]
00007f9a9862b000 16 0 0 r---- [ anon ]
00007f9a9862f000 8 0 0 r---- [ anon ]
00007f9a98631000 8 4 0 r-x-- [ anon ]
00007f9a98633000 4 4 0 r---- ld-linux-x86-64.so.2
00007f9a98634000 172 172 0 r-x-- ld-linux-x86-64.so.2
00007f9a9865f000 40 40 0 r---- ld-linux-x86-64.so.2
00007f9a98669000 8 8 8 r---- ld-linux-x86-64.so.2
00007f9a9866b000 8 8 8 rw--- ld-linux-x86-64.so.2
00007ffc9625a000 132 12 12 rw--- [ stack ]
ffffffffff600000 4 0 0 --x-- [ anon ]
---------------- ------- ------- -------
total kB 2692 1468 104
Tämä sopii hyvin yhteen ohjelman näyttämien muistiosoitteiden kanssa. Voimme havaita, että muistiosoitteet osuvat seuraaville alueille:
| Alkuosoite | Koko | Oikeudet | Kuvaus |
|---|---|---|---|
00005fe7d5c1a000 |
4 kt | read, write | Globaalit muuttujat |
00007ffc9625a000 |
132 kt | read, write | Paikalliset muuttujat (pino) |
00005fe80be31000 |
132 kt | read, write | Dynaaminen muistinvaraus |
00005fe7d5c18000 |
4 kt | read | Merkkijonoliteraali |
00005fe7d5c17000 |
4 kt | read, execute | Funktio |
Tästä näkee myös, että erilaisiin muistialueisiin liittyy erilaisia oikeuksia:
-
Muuttujat ovat muistialueilla, joita ohjelma voi sekä lukea että muuttaa.
-
Merkkijonoliteraali on muistialueella, jota ohjelma voi vain lukea. Tällä tavalla käyttöjärjestelmä estää merkkijonoliteraalin muuttamisen.
-
Funktio on muistialueella, jonka sisältöä prosessori voi suorittaa koodina mutta ei voi muuttaa.
Muistikartassa näkyy myös muita varattuja muistialueita, esimerkiksi osa muistista on varattu C:n standardikirjastoa (libc) varten.
Satunnaiset muistiosoitteet
Jos ohjelman käynnistää uudestaan, muistiosoitteet sijaitsevat edelleen suunnilleen samoissa kohdissa muistiavaruutta mutta ne ovat muuttuneet:
global: 0x5b96b760101c 0x5b96b7601020 0x5b96b7601024
local: 0x7ffdbd2fb164 0x7ffdbd2fb168 0x7ffdbd2fb16c
dynamic: 0x5b96c59332a0 0x5b96c59332c0 0x5b96c59332e0
string: 0x5b96b75ff004
function: 0x5b96b75fe169
Modernissa Linuxissa on yleensä käytössä ASLR-tekniikka (Address Space Layout Randomization), mikä tarkoittaa, että ohjelman muistiosoitteissa on satunnaisuutta ja ne vaihtuvat ohjelman jokaisella suorituskerralla. Tämä parantaa ohjelman turvallisuutta, koska hyökkääjän on vaikeampaa saada selville ohjelman muistiosoitteita.
Virheiden etsiminen
Muistinkäsittelyyn liittyvät virheet ovat hyvin yleisiä C-ohjelmoinnissa. Virheitä voi olla vaikeaa havaita ja paikantaa, koska virhe ei välttämättä tule esille selkeällä tavalla ohjelmaa suorittaessa. Sopivat työkalut auttavat kuitenkin paljon muistinkäsittelyn virheiden etsimisessä.
AddressSanitizer
AddressSanitizer on työkalu, joka havaitsee kattavasti muistinkäsittelyyn liittyviä bugeja ohjelman suorituksen aikana.
Työkalun saa käyttöön gcc-kääntäjässä seuraavasti:
$ gcc test.c -o test -fsanitize=address -g
Tässä lippu -fsanitize=address lisää käännettyyn ohjelmaan AddressSanitizer-valvonnan ja lippu -g lisää debug-tietoa, jonka avulla voidaan näyttää tarkemmin tietoa virheen syystä.
Tarkastellaan esimerkkinä seuraavaa ohjelmaa:
#include <stdlib.h>
int main(void) {
int n = 100;
int *data = malloc(sizeof(int) * n);
data[n] = 42;
}
Tässä ohjelmassa on kaksi virhettä: ohjelma kirjoittaa tietoa varatun muistialueen ulkopuolelle, eikä vapauta varaamaansa muistialuetta.
Kun ohjelma suoritetaan AddressSanitizer-valvonnassa, ohjelman suoritus keskeytyy seuraavaan raporttiin:
=================================================================
ERROR: AddressSanitizer: heap-buffer-overflow on address 0x5140000001d0 at pc 0x5ae1acabb21d bp 0x7ffd605991a0 sp 0x7ffd60599190
WRITE of size 4 at 0x5140000001d0 thread T0
#0 0x5ae1acabb21c in main /home/ahslaaks/tmp/test.c:6
#1 0x73868142a1c9 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
#2 0x73868142a28a in __libc_start_main_impl ../csu/libc-start.c:360
#3 0x5ae1acabb0e4 in _start (/home/ahslaaks/tmp/test+0x10e4)
0x5140000001d0 is located 0 bytes after 400-byte region [0x514000000040,0x5140000001d0)
allocated by thread T0 here:
#0 0x7386818fd9c7 in malloc ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:69
#1 0x5ae1acabb1cc in main /home/ahslaaks/tmp/test.c:5
#2 0x73868142a1c9 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
#3 0x73868142a28a in __libc_start_main_impl ../csu/libc-start.c:360
#4 0x5ae1acabb0e4 in _start (/home/ahslaaks/tmp/test+0x10e4)
...
Tämä raportti kertoo seuraavia asioita:
- Ohjelman suoritus keskeytyi virheeseen ”heap-buffer-overflow” eli kekomuistin käsittelyyn liittyvään ylivuotoon.
- Koodin rivillä 6 ohjelma kirjoitti 4 tavua tietoa varatun muistialueen ulkopuolelle.
- Koodin rivillä 5 varattiin 400-tavuinen muistialue ja ohjelma kirjoitti tietoa heti tämän muistialueen jälkeiseen muistipaikkaan.
AddressSanitizer löytää tehokkaasti muistivirheitä, joten eikö oikeastaan olisi hyvä idea kääntää ohjelma aina lipulla -fsanitize=address?
Tämä on hyvä idea ohjelman kehityksen aikana, mutta ei lopullisessa ohjelmassa, koska muistinkäytön valvonta hidastaa ohjelman suoritusta sekä kasvattaa ohjelman muistinkulutusta.
Valgrind
Valgrind on toinen työkalu muistinkäytön virheiden havaitsemiseen. Valgrind suorittaa ohjelman, tarkkailee sen muistinkäyttöä ja tuottaa sitten raportin havaitsemistaan ongelmista.
Myös Valgrindia käyttäessä tutkittava ohjelma kannattaa kääntää -g-lipulla, jotta käännökseen lisätään mukaan debug-tiedot:
$ gcc -g test.c -o test
Tämän jälkeen ohjelman voi käynnistää näin Valgrindin kautta:
$ valgrind ./test
Tarkastellaan taas seuraavaa ohjelmaa, joka kirjoittaa muistiin väärään kohtaan eikä vapauta varaamaansa muistialuetta:
#include <stdlib.h>
int main(void) {
int n = 100;
int *data = malloc(sizeof(int) * n);
data[n] = 42;
}
Valgrind antaa ohjelmasta seuraavan raportin:
Invalid write of size 4
at 0x109185: main (test.c:6)
Address 0x4ab01d0 is 0 bytes after a block of size 400 alloc'd
at 0x4846828: malloc (in /usr/libexec/valgrind/vgpreload_memcheck-amd64-linux.so)
by 0x10916C: main (test.c:5)
HEAP SUMMARY:
in use at exit: 400 bytes in 1 blocks
total heap usage: 1 allocs, 0 frees, 400 bytes allocated
LEAK SUMMARY:
definitely lost: 400 bytes in 1 blocks
indirectly lost: 0 bytes in 0 blocks
possibly lost: 0 bytes in 0 blocks
still reachable: 0 bytes in 0 blocks
suppressed: 0 bytes in 0 blocks
Tämä raportti kertoo seuraavia asioita:
- Koodin rivillä 6 ohjelma kirjoitti 4 tavua tietoa varatun muistialueen ulkopuolelle.
- Koodin rivillä 5 varattiin 400-tavuinen muistialue ja ohjelma kirjoitti tietoa heti tämän muistialueen jälkeiseen muistipaikkaan.
- Ohjelmassa on muistivuoto, koska ohjelma varasi keosta 400 tavua muistia mutta ei koskaan vapauttanut tätä muistia.