torstai 31. heinäkuuta 2008

C ja muisti, osa 1

Tietokoneen prosessorin toiminta voidaan karkeast yksinkertaistaa kahteen osaan.

1. Muistavaruuden käsittely
2. Komentojen suorittaminen.

Kohta yksi voidaan jakaa lukuun ja kirjoitukseen. Komennot taas yleensä manipuloivat luettua dataa, ja kirjoittavat lopputuloksen johonkin muistiosoitteeseen. Muistin lukua käytetään myös luettaessa seuraava suoritettava komento muistista. Huomionarvoista on, että prosessori näkee myös kaikki laitteet, levyt, näytön, verkkokortin jne. vain tiettyinä muistiosoitteina.

Ohjelmointi on prosessorille annettavien komentojen (ja ohjelmaan liittyvän datan) kirjoittamista muistiin, josta prosessori ne lukee ohjelmaa ajettaessa. Mikäli ohjelmointi tulisi tehdä suoraan kirjoittamalla prosessorin käskyt ja data sellaisenaan, olisi "hello word" ohjelman tekokin jo varsin haastavaa, ja vaatisi valtavaa tietämystä koneesta rautatasolla.

Sen vuoksi on kehitetty erilaisia ohjelmointikieliä, joissa yksinkertaisilla käskyillä suoritetaan useampi prosessorikomento halutun toiminnallisuuden saavuttamiseksi. Lisäksi käyttöjärjestelmä ja laiteajurit peittävät suuren osan alla olevan raudan erityispiirteistä, tarjoten omat (enemmän tai vähemmän) standardoidut keinonsa varsinaisiin laitteisiin käsiksipääsyyn.

C-kieli on kuitenkin niin sanottu 'matalan tason kieli', mikä tarkoittaa sitä, että vaikka monia asioita on huomattavasti yksinkertaistettu, niin edelleen ollaan kuitenkin varsin lähellä rautatasoa ja prosessorikäskyjä. Tästä seuraa se, että vaikka voit tuottaa ihan toimivaa ohjelmakoodia ymmärtämättä käyttöjärjestelmää/rautatasoa sen paremmin, niin voidaksesi todella ymmärtää miksi jokin toimii kuten toimii, tai voidaksesi todella luoda omaa koodia nauttien kaikista C:n tarjoamista mahdollisuuksista, sinun on tunnettava alusta jolle ohjelmaa kirjoitat.

Ehkäpä kaikkein eniten ongelmia C:n kirjoituksessa tulee muistinhallinnan kanssa. Tähän liittyy muutamia perusasioita, joita aion läpikäydä tässä blogitekstissäni:



Fyysinen muisti on käyttöjärjestelmän toimesta jaettu pieniin palasiin, ja palaset on numeroitu muistiosoitteiksi. Kuhunkin muistiosoitteeseen mahtuu 8 bittiä tietoa, eli yksi tavu.

C-kielen eri tietotyypeillä on oma pituutensa, tavanomaisimpien tietotyyppien ollessa:
int 32 bittiä, eli 4 tavua.
short int 16 bittiä, eli 2 tavua
char 8 bittiä, eli yksi tavu.
C:ssä voidaan käyttää myös tietotyyppä void, jonka pituutta ei ole määritetty.

Tietotyyppien pituuden selvittämiseksi, C:n standardi määrittä sizeof() lauseen. sizeof(int) palauttaa int tietotyypin koon, sizeof(short int) palauttaa short int:n koon jne.

Huom 1.
sizeof(char *) == sizeof(int *) == sizeof(void *) jne. sizeof( *) palauttaa osoitteen vaatiman tilan, ei sen datan jota osoitteesta alkavaan muistiin on talletettu.
HUOM 2!!! Osoitteen vaatima tila riippuu tietokoneen arkkitehtuurista, tyypillisesti 32 bittisissä koneissa, osoite on 32 bittiä leveä, mutta 64 bittisissä koneissa 64 bittiä leveä. Siis mikäli tahdotte kirjoittaa ohjelmakoodia, joka toimii molemmissa arkkitehtuureissa, älkää muuntako osoitteita int:tyypiksi, sillä 64 bittisessä arkkitehtuurissa osoite ei sovi int tyyppiin, vaan puolet osoitteesta leikkaantuu pois!

Tämän asian sisäistäminen ja ymmärtäminen on eräs C-ohjelmoinnin kulmakiviä. Vaikka muuttujatyyppiä käytettäessä, kääntäjä osaa itse hoitaa ohjelmakoodin sellaiseksi, että prosessori tietää montako tavua milloinkin on luettava, niin joskus on tarpeen 'kadottaa' tietotyyppi ja palauttaa data myöhemmin takaisin samaksi tyypiksi. Esittelen nyt muutaman tilanteen missä tällaista kikkailua voi joutua harrastamaan.

Joissain tilanteissa voidaan haluta luoda esim. funktio, jolle voidaan antaa erilaista dataa argumenttina, tilanteesta riippuen. Hyvänä esimerkkinä vaikka pthread kirjasto ja threadin luonti. (Thread eli säie on itsenäinen suorituspolku prosessin sisällä. Eli threadien avulla voidaan haarauttaa ohjelman suoritus suorittamaan useampaa koodia näennäisesti yhtäaikaa.)

Threadi luodaan kutsumalla pthread_create() funktiota, jolle annetaan parametrina sen funktion osoite, josta uuden säikeen suorituksen tahdotaan jatkuvan. Lisäksi annetaan uuden säikeen alkamisfunktion parametrit. pthread_create funktio on toteutettu siten, että se hyväksyy starttifunktioksi vain funktion, jonka argumentti on tyyppiä void *. Eli 'tyypittömän' datablockin osoite. Syy tällaisen argumentin käyttöön on yksinkertainen. pthread kirjaston tekijät, eivät voi tietää mihin tarkoitukseen kirjaston käyttäjät tahtovat uuden säikeen luoda. Täten on mahdotonta myöskään arvata, millaisia ja minkä tyyppisiä argumentteja starttifunktiolle voidaan antaa.

Otetaanpa esimerkki. Kuvitellaan, että olemme tekemässä webbiserveriä, jossa käytämme socketteja ihka ensimmäisen blogipostini esimerkin tapaisesti. Kohdassa jossa serverimme hyväksyy kuuntelevaan sockettiin tulevan yhteyden, serverin pitää päästä jatkamaan kuuntelua mahdollisimman nopeasti, jotta muut palvelimemme sisällöstä kiinnostuneet pääsevät katselemaan tarjoamaamme laatupornoa. Kuitenkin ensimmäisen clientin pyynnöt on käsiteltävä.

ratkaisemme ongelman käyttämällä threadeja. Kun hyväksymme uuden yhteyden, luomme uuden threadin joka aloittaa suorituksensa kasittele_clientin_pyynnot() funktiosta. Samalla annamme alkuperäisen kuuntelijasockettimme palata accept funktioon odottelemaan uusia yhteyspyyntöjä.

Mitä tietoja kasittele_client_request() funktiomme nyt tarvitsee? Ainakin socketin, jonka kautta clientin kanssa keskustellaan. Lisäksi clientin osoitetiedot on hyvä saada, jotta saadaan ne logitiedostoon talteen. Luultavasti täysiverisessä serverissä tarvittaisiin muutakin dataa, mutta tämän kohdan toteutuskin voisi olla hieman toisenlainen :)

Mutta kuinka nyt saamme socketin ja clientin tiedot kasittele_clientin_pyynnot() funktiolle? Argumentti jonka annamme on siis muistipaikan osoite. Helpoin mieleentuleva tieto on, sijoittaa socket ja osoitetiedot peräkkäisiin paikkoihin muistissa, ja antaa kasittele_clientin_pyynnot() funktiolle sen muistipaikan alkuosoite, josta datamme alkaa.

Esimerkki 1: Sekalaisen datan sijoittaminen peräkkäisiin muistipaikkoihin.

int sock;
struct sockaddr clientin_osoite;

void *socketti_ja_osoite; //muisti paikka josta data alkaa

.
.
.

/*
Allocoidaan, eli varataan muistia sockettia ja osoitetta
varten, niiden tarvitsema määrä. Malloc() palauttaa
paluuarvonaan osoitteen,josta varattu tila alkaa:
*/
socketti_ja_osoite=malloc(sizeof(int)+sizeof(struct sockaddr));
/*
Tarkistetaan että muistn varaus onnistui, varaaminen voi
epäonnistua mm. jos muistia ei ole vapaana
*/
if(NULL==socketti_ja_osoite)
{
//virhekäsittely, muistin varaaminen epäonnistui!
}
/*
Nyt kun muisti on varattu käytettäväksi, täytyy data saada
sijoitettua muistiin. Talletetaan ensin socketin numero
muistipaikan alkuun. Tehdään tämä memcpy eli muistin
kopiointi funktiolla, joka kopioi 1. argumentin osoittamaan
muistipaikkaan dataa, 2. argumentin osoittamasta
muistipaikasta,
3. argumentin kertoman tavumäärän. huom, & merkillä saadaan
minkä tahansa muuttujan muistipaikan alkuosoite.
*/
memcpy
(
socketti_ja_osoite, //1. arg, eli mihin kopioidaan.
(void *)&sock, //2. arg, eli mistä kopioidaan.
sizeof(int) //3. arg, eli montako tavua kopioidaan.
);

/*
Seuraavaksi meidän tulisi saada kopioitua clientin
osoitetiedot sockettitiedon perään. Eli koska int tyypin
tieto vaatii muistia 4 tavua, on osoite johon clientin
tiedot tahdomme socketti_ja_osoite muistipaikka + 4 tavua.
HUOMAA: Kun muistipaikkaan lisätään luku, lisää kääntäjä
itse muistipaikan osoitteeseen niin monta pykälää, kuin on
tarpeen että
LUVUN OSOITTAMA MÄÄRÄ OSOITTEESEEN YHDISTETTYÄ TIETOTYYPPIÄ
VAATII.
Tämän vuoksi
VOID * TYYPPISEEN OSOITTEESEE EI VOI LISÄTÄ MITÄÄN!
Siksi joudumme muuntamaan (castaamaan) socketti_ja_osoite
muistipaikan toisen tyyppiseksi.

Selvennykseksi: Jos meillä on:
void *foo=0x0,
niin
(char *)foo+4 on 4
(int *)foo+4 on 4*4 eli 16.

Siksi epäselvyyksien välttämiseksi minä itse muunnan aina
osoitteeseen lisätessäni/siitä vähentäessäni osoitteen
(char *) tyyppiseksi ja lisään sizeof( lisättävä tyyppi )
koon. Mikäli
tahdon lisätä useamman kuin yhden kappaleen jotain tietoa,
kerron sizeof() lausekkeen lukumäärällä.
VARO KUITENKIN TÄTÄ, mikäli kappalemäärä ei ole tiedossa,
sillä suurella kappalemäärällä voi sizeof()*kappalemäärä
kasvaa suuremmaksi kuin 32 bitin kenttään sopii, ja
sizeof*kappalemäärä voi 'pyörähtää ympäri' aikaansaaden
hyvin pienen luvun. Erityisen ikävää tämä on muistia
varattaessa jolloin varausfunktio ei näe virhettä, ja
luulee muistin varaamisen sujuneen ongelmitta. Varatun
muistin koko ei kuitenkaan nyt ole haluttu, ja ohjelma
luultavasti kaatuu myöhemmin, ja jossain aivan muussa
kohdassa kuin muistinvarauksessa jossa todellinen vika on.

Oho. Eksyin sivuraiteille, mutta mennäänpä nyt resinalla
takaisin pääradalle.
*/
memcpy
(
(void *)((char *)socketti_ja_osoite)+sizeof(int),
(void *)&clientin_osoite,
sizeof(struct sockaddr)
};
/*
Tai vaihtoehtoisesti:
memcpy
(
(void *)((char *)socketti_ja_osoite)+4,
(void *)&clientin_osoite,
sizeof(struct sockaddr)
);
*/
/*
Tai vaihtoehtoisesti:
memcpy
(
(void *)((int *)socketti_ja_osoite)[1],
(void *)&clientin_osoite,
sizeof(struct sockaddr)
);

*/
/*
Tai vaihtoehtoisesti:
memcpy
(
(void *)((int *)socketti_ja_osoite)+1,
(void *)&clientin_osoite,
sizeof(struct sockaddr)
);

*/

/*
Ja voila, data on nyt socketti_ja_osoite osoittaman
muistipaikan perässä, valmiina pthread_create funktiolle
annettavaksi.
*/


Esimerkki 2: Sekalaisen datan saaminen peräkkäisistä muistipaikoista.

Nyt rakennamme kasittele_clientin_pyynnot() funktioomme tarvittavat kikkulat, jotta saamme socketin ja clientin tiedot kaivettua argumentin osoittamasta muistialueesta. Vaikeutena on se, että koska tiedot annettiin funktiota kutsuttaessa void * tyyppisenä, ei funktion sisällä ole kääntäjän toimesta mitään tyyppitietoatietoa, eikä näinollen myöskään tietoa siitä, miten pitkä muistialue on kyseessä, onko se varattu käyttöä varten, tai miten monta tietorakennetta se sisältää. Ennen kuin tietoa voidaan käyttää, on tämä kaikki kerrottava kääntäjälle tavalla tai toisella.

Tähän on ainakin kaksi mahdollista lähestymistapaa. Joko luoda oikean tyyppiset muuttujat joihin tieto kaivetaan argumentista, tai laskea aina oikea muistipaikan osoite ja kertoa kääntäjälle datan tyyppi sen jälkeen.

Ensimmäinen tapa on mielestäni selkeämpi, helpompi käyttää ja koodia tutkivan työkaverin selkeämpi käsittää.


void * kasittele_clientin_pyynnot( void *sockettiJaOsoite)
{
/*
Luodaan siis muuttujat joihin socketti ja osoite
talletetaan
*/
int sock;
struct sockaddr clientin_tiedot;

/*
Minulla on tapana asettaa pointterit jotka eivät enää ole
käytössä NULL:iksi, eli osoittamaan osoitetta 0. Tämä
usein auttaa virheiden kiinnisaamisessa. Tarkistetaan
siis onko argumenttina annettu osoite validi:
*/
if(sockettiJaOsoite==NULL)
{
/*
Virhekäsittely, socketti ja clientin osoitetieto ei
ole oikein annettu.
*/
}
/*
socketti on helppo, sillä sen arvo on heti muistin
osoittmassa paikassa, ja perustyypin ollessa kyseessä
voimme olla varmoja että sijoitusoperaattori on
käytössä. Eli valehtelemme siis silmät kirkkaina
argumentin tietotyypin olevan int *, eli osoite jossa
on int tyypin tieto talletettuna. Sitten kerromme
kääntäjälle haluavamme datan tästä osoitteesta, ja
tallennamme sen int tyypin muuttujaan.

Huomaa, kun osoitemuuttuja luodaan, se tapahtuu:
int *muuttujannimi;
Näin se myös esitellään funktion prototyypissä. Mutta
mikäli myöhemmin sijoitamme tähden osoitemuuttujan
nimen eteen, se tarkoittaa että haluamme datan joka on
osoitemuuttujan kertomassa muistipaikassa.

Eli
int *foo; //luo osoitinmuuttujan int tyyppiseen dataan
int bar;
*foo=1; //tallettaa luvun 1, foo:n osoitepaikkaan.
/*
tallettaa bar muuttujaan osoitteessa foo olevan DATAN.
Ei foon osoitetta.
*/
bar=*foo;
sock=*((int *)sockettiJaOsoite);
/*
(int *)sockettiJaOsoite => väitetään
socettiJaOsoite kertoman muistipaikan sisältävän
int tyyppisen datan.
*((int *)sockettiJaOsoite) => Kerrotaan olevamme
kiinnostuneita datasta, ei osoitteesta.
*/
/*
clientin tiedot saadaan ehkä helpoiten käyttämällä
jälleen & merkkiä saadaksemme clientin_tiedot muuttujan
muistipaikan osoitteen, ja kopioimalla sinne
sizeof(struct sockaddr) verran dataa sockettiJaOsoite+4
muistipaikasta alkaen.
*/
memcpy
(
(void *)&clientin_tiedot,
(void *)(char *)sockettiJaOsoite+sizeof(int),
sizeof(struct sockaddr)
);
}

Jaha, mutta koska ilta alkaa taas hiipimään nurkkiin, taidan lopettaa tältä päivää ja jättä loput mehevät muistijutut ensi kertaan :)

Ei kommentteja: