Miniharkka

ITKA203 Käyttöjärjestelmät -kurssin Demo 4 kesällä 2011. "Miniharjoitustyö"

Paavo Nieminen, paavo.j.nieminen@jyu.fi

Contents

Tämän harjoituksen tavoitteet

Päätavoite:

  • Näet esimerkin kautta, kuinka konekielinen ohjelma saa prosessorin suorittamaan aliohjelma-aktivaation. Sisäistät suorituspinon toimintaperiaatteen ja roolin prosessin (tai säikeen) suorituksessa.

Toissijainen tavoite:

  • samalla näet hieman yleiskuvaa konekielisestä ohjelmoinnista.
  • samalla syvennyt viitteen (tai muistiosoittimen) rooliin ohjelmoinnissa, mikä parantaa kykyäsi ohjelmoida.

Ideat:

  • Tämän pitäisi käsittääkseni tuottaa vain vähän lisätyötä, jos on taustalla demo1, demo2 ja luennot käytynä / materiaalit ymmärrettynä tähän saakka. Ohjausta tarjotaan demotilaisuuksissa, sähköpostilistalla ja sopimuksen mukaan henkilökohtaisesti.
  • Tarkoitus on, että tämä jossain määrin vastaisi aiempien kurssikertojen harjoitustyötä, paitsi että käytetään 2000-luvun työkaluja, ja C-ohjelmointiosuus ei vaadi ohjelmoinnillista taituruutta (ohjelma annetaan valmiina).

Käytämme jo tutuksi tullutta kääntäjää gcc ja uutena työkaluna tulee debuggeri gdb.

Ensimmäinen läpikulku

Ensin käydään ensin läpi malliharkka.c -ohjelman kautta kaikki harjoitustyön vaiheet. Koodi ja sen pohjalta tehty vastausrunko on saatavilla kurssin nettisivulta:

http://www.cc.jyu.fi/~nieminen/kj11kesa/demo4/demo4.zip

Käytä tutuksi tullutta pääteyhteyttä jalavaan, ja käytä neljättä demoa varten tekemääsi hakemistoa.

Alustava esimerkki: ohjelma x86-64 -assembleriksi GNU-kääntäjällä (gcc)

Kokeile huvikseen seuraavaa:

gcc -E malliharkka.c | less

Em. komennolla ajoit vain C-käännöksen ensimmäisen vaiheen, jossa ns. esikääntäjä lisäilee omia rivejään lähdekoodiin ja käsittelee mm. #include -rivit. (Tämä oli vain erillinen käytännön demonstraatio siitä, mikä on C-kielen esikääntäjä ja mitä se tekee. Näet, että varsinaista C-kieltä kertyy tässä tapauksessa moninkertainen määrä itse kirjoitettuun nähden.)

Nyt itse asiaan. Anna seuraava komento:

gcc -O0 -S malliharkka.c

Optio -O0 (eli iso oo ja nolla) tarkoitti, että kääntäjä ei optimoisi koodia. Koodin optimointi oletettavasti tekisi käännöksestä vaikeamman ymmärtää, kun tuotettu konekieli koettaisi oikoa mutkia suoriksi nopeamman suoritettavuuden ja/tai lyhyemmän binääritiedoston toivossa. Optio -S tarkoitti, että ei käännetä valmiiksi konekieleksi asti, vaan tuotetaan vastaava assembler-koodi. Nyt pitäisi olla syntynyt tiedosto malliharkka.s. Sen pitäisi alkaa jotakuinkin näin:

.


        .file   "malliharkka.c"
        .text
.globl kaanna_taulukko
        .type   kaanna_taulukko, @function
kaanna_taulukko:
.LFB2:
        pushq   %rbp
.LCFI0:
        movq    %rsp, %rbp
.LCFI1:
        movq    %rdi, -40(%rbp)
        movq    %rsi, -48(%rbp)

...

Pisteellä alkavat tekstit ovat assemblerin ohjauskomentoja. Esim. .file "malliharkka.c" kertoo, että tämä on syntynyt tuon nimisestä C-koodista kääntämällä. .text kertoo, että seuraavat asiat pitää sijoittaa koodialueelle (jota sanotaan jostain syystä tekstialueeksi Unix-maailmassa...) .globl kaanna_taulukko tarkoittanee että meillä on globaali symboli nimeltä kaanna_taulukko ja .type kaanna_taulukko, @function kertonee että kyseinen symboli tarkoittaa funktiota eli aliohjelmaa.

Kaksoispisteeseen päättyvät rivit ovat symbolisia nimiä muistiosoitteille. Eli sekä kaanna_taulukko että .LFB2 ovat nimiä sille muistipaikalle, jossa sijaitsee käskyn pushq %rbp konekielikoodin ensimmäinen tavu. Assembler-ohjelmoijan ei tarvitse tietää muistipaikan numeroarvoa; itse asiassa voi olla, että kukaan ei tiedä lopullisia osoitteita ennen kuin käyttöjärjestelmä lataa konekielisen ohjelman kovalevyltä muistiin suoritusta varten.

Tutustu lyhyesti assembler-käännökseen, pyri alustavasti ymmärtämään, miten se toimii. Luentomateriaalissa on x86-64 -assemblerin esimerkkikäskyjä. Sieltä pitäisi tutunnäköisiä komentoja löytyä. Kohta katsotaan asiaa vielä tarkemmin.

Tutustu GNU-debuggeriin (gdb)

Käännä ensin ohjelma komennolla:

gcc -g -O0 -o malliharkka malliharkka.c

Argumentti -g tuottaa debug-tiedot; niitä tarvitaan kohta. Jälleen -O0 kieltää optimoimasta käännöstä ja -o malliharkka määrää nimen käännöksen tulostiedostolle. Pitäisi syntyä ajettava konekielinen tiedosto malliharkka.

Käynnistä komentorividebuggeri seuraavasti:

gdb malliharkka

Mitäs nyt? Tilanne näyttää seuraavalta ja vaatii hieman selitystä:

[nieminen@jalava harkka]$ gdb malliharkka
GNU gdb (GDB) Red Hat Enterprise Linux (7.0.1-32.el5_6.2)
Copyright (C) 2009 Free Software Foundation, Inc.
...
(gdb)   [ja vilkkuva kursori ...]

Käynnistit debuggerin. Ohjelmointi 1:ltä saattaa olla tuttu asia jokin (esim. Eclipse IDE:n) graafinen debuggeri, jolla voi opiskella ohjelman toimintaa, ja jota voi käyttää virheenetsinnässä (siitä niiden nimi "de-" "bugger"). Nyt käytettävä gdb on samanlainen, mutta komentorivikäyttöinen. Tällekin on olemassa graafisia julkisivuja eli front-endejä, mutta interaktiivisten komentoriviohjelmien käyttö on yksi tämän kesäkurssin teemoja, joten noudatamme sitä loppuun asti. Tarkoitushan on käydä läpi ohjelmistojen hierarkioita ja askeltaa vähän aikaa näillä matalammilla ja vähemmän graafisilla tasoilla, joille ensinnäkin saattaa joskus syystä tai toisesta pakosti joutua ja joiden olemassaolo on joka tapauksessa hyvä ymmärtää. Joskus voi olla ihan hyvä idea eriyttää graafinen käyttöliittymä ja taustalla oleva koneisto löyhästi vaikkapa tekstimuotoisen kommunikointiprotokollan avulla. Debuggeri odottaa nyt siis tekstimuotoisia komentoja, joiden avulla voit tutkia argumenttina annetun ohjelman toimintaa. Komenna debuggeria:

run

Debuggeri käynnisti malliharjoitustyön käännetyn ohjelman, antoi sen mennä niin pitkälle kuin se ilman virheitä etenee, tässä tapauksessa onnistuneeseen loppuun saakka. Tulostui toivon mukaan seuraavaa:

Starting program: /autohome/home3/363/nieminen/kj07kesa/harkka/malliharkka
-1 2 3 -16 2 8
8 2 -16 3 2 -1
Samoja lukuja oli 1 kpl.

Program exited normally.

Jos ohjelma olisi kaatunut, debuggerin avulla voisi alkaa selvittelemään, miksi niin kävi... Debuggeri on siis arvokas ohjelmantekijän työkalu...

Tekstipohjainen ohjelma on erilainen kuin graafinen ohjelma, mutta se voi olla käyttäjäystävällinen omalla tavallaan. Esim. gdb:n käyttäjäystävällisyys toteutuu mm. seuraavin tavoin:

  • Komennoista saa ohjeita komentamalla help. Eli komentoja ei tarvitse muistaa, vaan ne saa esille aina ohjelman käytön yhteydessä.
  • Komennoissa on automaattitäydennys tabulaattorinäppäimellä, ja niille on lyhyitä "alias"-nimiä, jotka on nopea kirjoittaa (ks. helpit).
  • Komentohistoria on käytettävissä nuolinäppäimillä.
  • Edellisen komennon voi toistaa painamalla pelkkää enteriä.

Debuggerin käyttöliittymä on paljolti samanlainen kuin tehokkaan interaktiivisen shellin -- tai minkä tahansa hyvin tehdyn tekstipohjaisen ohjelman. Mieleen tulee itselle esimerkiksi laskentaohjelmisto Matlabin "Command Window", joka on samalla tavoin kätevä.

Käväistään vähän aikaa pois debuggerista; siitä pääsee pois komennolla:

quit

Olet nyt käyttänyt komentorividebuggeria kerran. Seuraavaksi tutustutaan debuggeriin vielä tarkemmin.

Aja ohjelmaa GNU-debuggerilla (gdb) käsky kerrallaan ja tallenna ajo

Nyt käynnistetään gdb uudelleen sillä tavoin, että koko tuleva tekstimuotoinen keskustelusi ohjelman kanssa tallentuu tekstitiedostoon. Itse harjoitustyössä toteutat samat vaiheet toiselle, valmiiksi annetulle C-ohjelmalle, ja saat näin tekstitiedoston, johon lisäät sitten myöhemmin vähän "oppimista ilmaisevaa tekstiä" muutamaan tärkeimpään kohtaan. Kokeillaan malliharkan kanssa, kuinka tämä tapahtuu. Eli käynnistä debuggeri seuraavalla tavoin:

gdb malliharkka | tee malliharkka_ajo.txt

Ohjelma tee lukee syötteensä, kaiuttaa sen suoraan ulostulovirtaan, ja tallentaa sen myös argumenttina annettuun tiedostoon eli tässä tapauksessa syntyy malliharkka_ajo.txt, johon tallentuvat kaikki gdb-sessiossasi kirjoitettu ja tulostunut teksti. Gdb:ssä olisi mahdollisuus dumpata tulosteet tiedostoon ilman mitään apuohjelmaa, mutta käytetään tee:tä nyt, koska halutaan talteen koko dialogi komentoineen eikä pelkkiä tulosteita.

Komenna gdb:tä seuraavasti:

display /3i $rip
display /t $eflags
display /x $rbp
display /x $rsp
display /x $rip

Voit copy-pastata suoraan tästä harkkaohjeesta. Komennot voi halutessaan lukea tiedostosta gdb:n käynnistyksen yhteydessä optiolla -x aloituskomentoja.esim. Ylläolevien komentojen merkitys on, että tästedes gdb näyttää aina automaattisesti seuraavat tiedot (viimeksi komennettu ensimmäisenä):

/x $rip käskyosoiterekisterin arvo heksalukuna ("x = hex"), seuraavaksi suoritettava konekielikomento tullaan noutamaan (fetch) tästä muistiosoitteesta
/x $rsp pino-osoittimen arvo heksalukuna
/x $rbp pinokehyksen kantaosoittimen arvo heksana
/t $eflags lippurekisterin lippubitit ("t = base two l. bitit")
/3i $rip kolme konekielikäskyä alkaen muistipaikasta, johon RIP osoittaa ("3i = three instructions")

Jos tuli kirjoitus- tai kopiointivirhe, voit komentaa undisplay, mikä poistaa aiemmat näyttövalinnat, ja sen jälkeen voi antaa uudelleen ylläolevat (tai muut sopivat) display -komennot. Ne jäävät voimaan seuraavaan undisplayhin asti.

Kokonaiskuva ohjelman osista: Disassemblyt debuggerissa

Debuggeri on nyt ladannut pyydetyn ohjelmatiedoston, ja se on valmis avaamaan konepellin oikein kunnolla. Katsotaan ensin yleiskuvaa ohjelmasta debuggerin disassembly-ominaisuudella. Komenna seuraavasti:

disassemble /m main

Pitäisi näkyä pääohjelman assembler-koodi alkaen jotenkin seuraavasti:

Dump of assembler code for function main:
68      int main(int argc, char *argv[]){
0x00000000004005f7 <main+0>:    push   %rbp
0x00000000004005f8 <main+1>:    mov    %rsp,%rbp
0x00000000004005fb <main+4>:    sub    $0x50,%rsp
0x00000000004005ff <main+8>:    mov    %edi,-0x44(%rbp)
0x0000000000400602 <main+11>:   mov    %rsi,-0x50(%rbp)

69        long long int testitaulu[] = {-1, 2, 3, -16, 2, 8};
0x0000000000400606 <main+15>:   movq   $0xffffffffffffffff,-0x40(%rbp)
...

Nyt, kun ohjelma on käännetty debug-tietojen kanssa, osaa gdb liittää assembler-tulosteeseen C-koodirivit (rivinumero ja kyseisellä rivillä oleva koodi). Tämä on havainnollista, koska nähdään suoraan, mikä koodin osa kääntyy minkäkinlaiseksi konekieleksi. Esim. ohjelman alussa on aliohjelman esittelyn jälkeen pinokehyksen alustava koodin osuus. Nähtävästi rekistereissä siirretyt parametrit (mainin tapauksessa siis ohjelman käynnistyksessä saamat argumentit) siirretään rekistereistä pinokehykseen ikään kuin ne olisivat paikallisia muuttujia.

Selitys asioille, jotka debuggeri näyttää:

  • Vasemmassa reunassa on 64-bittinen heksaluku. Tämä on virtuaalimuistiosoite, josta käskyn konekielinen bittijono alkaa.
  • Seuraavaksi on suhteellinen osoite aliohjelman alusta lähtien. Siis esim. <main+4> tarkoittaa että kyseinen käsky alkaa muistipaikasta, joka saadaan lisäämällä main() -aliohjelman ensimmäisen käskyn osoitteeseen 4. Huomataan, että x86-64:ssä konekielikäskyjen bittijonot voivat olla eri mittaisia (riippuen mm. siitä, onko mukana vakioarvoja, ja siitä ovatko käskyt alkuperäisiä 086-käskyjä vai myöhempiä laajennoksia).
  • Sitten on käsky AT&T-syntaksilla eli mnemonic ja operandit.
  • Esimerkkejä käskyistä ja osoitusmuodoista on kurssimateriaalissa.

Koska ohjelmaa ei vielä ole käynnistetty, pitää gdb:lle kertoa eksplisiittisesti, mistä aliohjelmasta disassembly halutaan tehdä (toki näin voi aina tehdä). Katso vastaavat myös muista aliohjelmista, eli komenna:

disassemble /m tulosta_taulukko

ja:

disassemble /m kanna_taulukko

Jos koko listaus ei mahdu yhteen ruutuun, gdb sivuttaa sen; katso loppuun asti painamalla tarvittaessa enter. Pääteikkunan selaus taaksepäin on mahdollista myöhemmin, ja kaikki tuloksethan tallentuvat jatkuvasti myös tekstitiedostoon, jota voit tarkastella myöhemmin.

Mitä seuraavaksi tulee tapahtumaan

Jotta et putoaisi kartalta, otetaan tähän kohtaan yleiskuva siitä, mitä seuraavaksi tapahtuu:

  • Debugger-ohjelma gdb tulee "hallitsemaan" tarkasteltavana olevaa konekielistä ohjelmaa pyytämilläsi tavoin.
  • Debugger antaa suorittaa ohjelmaa pala kerrallaan (opit kohta määrittelemään erilaisia tapoja suorittaa palanen: konekielikäsky kerrallaan, nykyinen aliohjelma loppuun, tietylle C-koodiriville saakka tahi määriteltyyn pysähdyspisteeseen eli breakpointtiin saakka).
  • Kun debugger on pysäyttänyt ohjelman, se antaa tutkia pysäytettyä prosessia "jäädytetyssä" tilanteessa, tärkeimpinä rekisterien arvot ja muistin sisältö.

Seuraavan läpikävelyn idea on, että muutama gdb:n komento tulee tutuksi, ja että voit soveltaa niitä jatkossa. Komennoista saa paljon lisätietoa komentamalla help sekä tietysti Internetistä.

Ohjelman ajo askel askeleelta

Ohjelma pitää tietysti ensin käynnistää, jotta sitä päästään debuggaamaan. Nyt ei ajeta sitä kokonaan läpi run:illa vaan aloitetaan käsky kerrallaan suorittaminen. Komenna:

start

Tutki debuggerin tulostetta, joka tuli välittömästi. Pitäisi olla jotakuinkin seuraavaa:

Temporary breakpoint 1 at 0x400606: file malliharkka.c, line 69.
Starting program: /autohome/home3/363/nieminen/files/kjesim/malliharkka/malliharkka
warning: no loadable sections found in added symbol-file system-supplied DSO at 0x2aaaaaaab000

Temporary breakpoint 1, main (argc=1, argv=0x7fffffffe208) at malliharkka.c:69
69        long long int testitaulu[] = {-1, 2, 3, -16, 2, 8};
5: /x $rip = 0x400606
4: /x $rsp = 0x7fffffffe0d0
3: /x $rbp = 0x7fffffffe120
2: /t $eflags = 1000000010
1: x/3i $rip
0x400606 <main+15>:     movq   $0xffffffffffffffff,-0x40(%rbp)
0x40060e <main+23>:     movq   $0x2,-0x38(%rbp)
0x400616 <main+31>:     movq   $0x3,-0x30(%rbp)

Suoritus pysähtyi nyt main() -aliohjelman alussa ennen sitä konekielikäskyä, joka on syntynyt sovellusohjelman ensimmäisen suoritettavan C-kielisen rivin kääntämisestä (siis rivin numero 69, jossa on taulukon esittely ja alustus). Pinokehyksen alustus, joka aiemmassa disassemblyssä nähtiin, on nyt siis jo suoritettu. Koska käännöksessä oli mukana debug-tiedon tallennus ja lähdekoodi on saatavilla, debuggeri kykenee näyttämään C-kielisen koodirivin, jota ollaan suorittamassa. Sitten näkyvät edellisissä display-komennoissa pyydetyt asiat.

Pysähdy fiilistelemään: Olet nyt sillä äärilaidalla, johon sovellusohjelmoija syvimmillään pääsee tietokonetta käyttämään. Ohjelma on käännetty tietylle prosessorille (ja tietylle käyttöjärjestelmälle). Näet ohjelman pysäytettynä debuggerissa, ja ymmärrät (toivottavasti) tarkoin, mistä on kyse: Seuraavassa hetkessä prosessori noutaisi käskyn. Se olisi virtuaalimuistiosoitteesta 0x400606, jonka prosessori muuntaisi fyysiseksi osoitteeksi käyttäen 48-bittisen virtuaaliosoitteen osia osoitteenmuodostuksessa:

+----------------------------------+-----------+-----------------+
|Hierarkkinen indeksointi          | Sivun     | Muistipaikan    |
|sivutaulun hakemiseen             | indeksi   | indeksi sivulla |
|                                  | taulussa  |                 |
|                                  |           |                 |
|000000000   000000000   000000010 | 000000000 | 011000000110    |
|                                  |           |                 |
+----------------------------------+-----------+-----------------+
|       Korvautuu osoitteenmuodostuksessa      | pysyy samana    |
|          sivutaulun tietojen mukaan          |                 |
+----------------------------------------------+-----------------+

Saattaisi tulla sivuviittausvirhe, ja pahimmassa tapauksessa kovalevy olisi herätettävä virransäästötilasta swappaamista varten... sitä ei tiedä, missä vaiheessa käsky lopulta suoritettaisiin. Lopulta se kuitenkin tulisi noudetuksi prosessorin suoritettavaksi. Käsky, joka tuosta kohtaa muistia löytyy, on malliharjoitustyön osalta seuraavanlainen:

0x400606 <main+15>:     movq   $0xffffffffffffffff,-0x40(%rbp)

Tämän käskyn suoritus tulee siirtämään lukuarvon -1 (kaikki bitit ykkösiä, heksaesitys 0xffffffffffffffff) virtuaalimuistiosoitteeseen, joka saadaan vähentämällä RBP:n arvosta 64 (lisätään etumerkillinen heksaluku -0x40). Kyseinen muistiosoite, ja siis siirron kohde, tulee olemaan:

RBP-rekisterin arvo
|
|              plus
|              |
|              | Käskyn binäärikoodiin sisällytetty vakiosiirros
|              | |
|              | |                     64-bittiseen rekisteriin
|              | |                     jää tässä ynnäyksessä
|              | |                     tulokseksi tämä luku
|              | |                     |
0x7fffffffe120 + -0x40              == 0x7fffffffe0e0

Eli bitteinä:

+----------------------------------+-----------+-----------------+
|Hierarkkinen indeksointi          | Sivun     | Muistipaikan    |
|sivutaulun hakemiseen             | indeksi   | indeksi sivulla |
|                                  | taulussa  |                 |
|                                  |           |                 |
|011111111   111111111   111111111 | 111111110 | 000011100000    |
|                                  |           |                 |
+----------------------------------+-----------+-----------------+
|       Korvautuu osoitteenmuodostuksessa      | pysyy samana    |
+----------------------------------------------+-----------------+

Käskyn suorituksessa prosessori tekee kohdeoperandin osoitteenmuodostuksen fyysiseksi osoitteeksi; väylä siirtää luvun keskusmuistiin (tai siltä ainakin näyttää; varsinaisesti pelissä kylläkin on L1- ja L2 -välimuistit), ja RIP:n arvo päivittyy seuraavan käskyn osoitteksi. Tässä tapauksessa lukuarvoksi 0x40060e. Totea, että näin käy, eli komenna yhden käskyn suorittaminen ("step instruction"):

stepi

Tulostus vahvistaa, että näin juuri kävi; seuraava tilanne on debuggerin mukaan tällainen:

5: /x $rip = 0x40060e
4: /x $rsp = 0x7fffffffe0d0
3: /x $rbp = 0x7fffffffe120
2: /t $eflags = 1000000010
1: x/3i $rip
0x40060e <main+23>:     movq   $0x2,-0x38(%rbp)
0x400616 <main+31>:     movq   $0x3,-0x30(%rbp)
0x40061e <main+39>:     movq   $0xfffffffffffffff0,-0x28(%rbp)

Suoritetaan kohta ohjelmaa käsky kerrallaan eteenpäin. Jos nyt tekisit disassemblyn, se näyttäisi suorituksessa meneillään olevan aliohjelman koodin. Voit halutessasi kokeilla:

disassemble /m

Tutkitaanpa muistia kantaosoitteen RBP ympäriltä, esim:

x /32xg $rbp-128

Muistutetaan itseämme vielä rekisterien arvoista tässä vaiheessa ohjelman suoritusta:

display

Tuloste oli seuraavanlainen (huomioita lisätty jälkeenpäin):

*1*: Pinon huippu on osoitteessa $rsp
*2*: Äskeinen sijoitus muutti osoitteen -64($rbp) sisällöksi -1:n

*3*: Main-aliohjelman pinokehyksen kanta on $rbp. Tässä
     osoitteessa on yleensä aiemman pinokehyksen kantaosoite; se
     on nyt 0x00, eli mainia kutsuneella koodilla ilmeisesti ei
     ole pinokehystä ihan samassa mielessä kuin normaalilla
     aliohjelmalla... main on siis ohjelman "uloin kehys".

*4*: Tässä on paluuosoite, eli main-aliohjelmaa kutsuneen
     call-käskyn jälkeisen käskyn osoite. Tämä on dynaamisesti
     ladatun C-kirjaston (tiedostosta /lib64/libc.so.6) koodia,
     joka jatkuu siis osoitteessa 0x0000003bbec1d994 siten että
     siellä kutsutaan käyttöjärjestelmän exit()-palvelua antaen
     parametriksi sovellusohjelman mainista palauttama
     kokonaisluku


(gdb)  x /32xg $rbp-128
0x7fffffffe0a0: 0x00000000000000bf      0x00000000005657f0
0x7fffffffe0b0: 0x00007fffffffe0f6      0x00007fffffffe0f7
0x7fffffffe0c0: 0x0000000000000000      0x0000003bbea1bbc0
0x7fffffffe0d0: 0x00007fffffffe208 *1*  0x00000001004003c3
0x7fffffffe0e0: 0xffffffffffffffff *2*  0x00000000004006d7
0x7fffffffe0f0: 0x0000000000000000      0x0000003bbea1bbc0
0x7fffffffe100: 0x00000000004006a0      0x0000000000000000
0x7fffffffe110: 0x00007fffffffe200      0x0000000000000000
0x7fffffffe120: 0x0000000000000000 *3*  0x0000003bbec1d994 *4*
0x7fffffffe130: 0x0000000000400410      0x00007fffffffe208
0x7fffffffe140: 0x0000000100000000      0x00000000004005f7
0x7fffffffe150: 0x0000003bbea1bbc0      0x01cc81a23c8695ae
0x7fffffffe160: 0x0000000000000000      0x00007fffffffe200
0x7fffffffe170: 0x0000000000000000      0x0000000000000000
0x7fffffffe180: 0x01ccfe5dc379749e      0x01cc819982474ce1
0x7fffffffe190: 0x0000000000000000      0x0000000000000000
(gdb) display
5: /x $rip = 0x40060e
4: /x $rsp = 0x7fffffffe0d0
3: /x $rbp = 0x7fffffffe120
2: /t $eflags = 1000000010
1: x/3i $rip
0x40060e <main+23>:     movq   $0x2,-0x38(%rbp)
0x400616 <main+31>:     movq   $0x3,-0x30(%rbp)
0x40061e <main+39>:     movq   $0xfffffffffffffff0,-0x28(%rbp)

Muistin tulostus tehdään myöhemminkin, jotta huomataan, miten pinomuisti muuttuu.

Sitten ajetaan ohjelmaa konekäsky kerrallaan:

stepi

Huomioi, miten rekisterit muuttuvat aina käskyn jälkeen. Edellisen komennon saat toistettua painamalla pelkästään enteriä. Jossain vaiheessa tulee vastaan malliharjoitustyön ensimmäinen aliohjelmakutsu:

0x400646 <main+79>:     callq  0x4005a4 <tulosta_taulukko>

Mennään tähän aliohjelmaan, eli suoritetaan vielä kerran:

stepi

Katso, että suoritus on tosiaan tullut aliohjelmaan tulosta_taulukko eli gdb näyttää jotakin seuraavanlaista:

55      void tulosta_taulukko(long long int taulu[], long long int koko){

Eli suoritus päätyi C-koodiriville, josta alkaa aliohjelmalohko. Mutta tämä tulostusaliohjelma ei nyt ole se, mikä meitä kiinnostaa, vaan kiinnostaa se varsinainen algoritmitoteutus, johon suoritus päätyy vasta myöhemmin. Tämä on debuggauksessa tyypillinen tilanne: kiinnostava asia tulee vasta myöhemmin. Annetaan nyt koko aliohjelman mennä loppuun, eli komenna:

finish

Tällä tavoin voi gdb:llä suorittaa loppuun aliohjelman. (Tämä vastaa "step out" -komentoa joissakin debuggereissa).

Malliharjoitustyössä on seuraavaksi vuorossa kiinnostuksemme kohde, eli varsinaisen taulukonkääntöalgoritmin yksi suorituskerta. Debuggeri kertoo, millä rivillä suoritus on menossa:

74        samoja = kaanna_taulukko(testitaulu, koko);
...

Komenna esim. gdb:tä taas tulostamaan muistin sisältöä RBP:n ympäriltä ja muistuta itseäsi rekisterien arvoista (eli tehdään sama kuin aiemmassa kohdassa):

x /32xg $rbp-128
display

Huomannet muistin sisällöstä, missä kohtaa siellä sijaitsee taulukon luvut, missä kohtaa taulukon koko. Aja nyt käsky kerrallaan ja tarkkaile, mitä ohjelma tekee... miten ja mitä kautta parametrit siirretään (rekisterit RDI ja SDI), ja miten suoritus siirretään aliohjelman koodiin.

stepi

Askella ohjelmaa tarkkaavaisesti, kunnes seuraavaksi tulisi vuoroon call-käsky, jolla ohjelma siirtyy aliohjelmaan kaanna_taulukko. Ja sitten vielä se call-käsky. Pysyithän kärryillä siinä, miten rekisterit muuttuivat. Vuorossa pitäisi olla ensimmäinen käsky aliohjelmasta kaanna_taulukko.

Jos haluat, voit tietysti missä tahansa vaiheessa tehdä meneillään olevasta aliohjelmasta disassemblyn:

disassemble

tai koodirivien kanssa:

disassemble /m

Joka tapauksessa tutki, mitä kertoo komento:

info registers

Se näyttää prosessin koko kontekstin, eli käyttäjän näkemät rekisterit. Huomataan esimerkiksi, että rekisterissä RSI on lukuarvo kuusi ja rekisterissä RDI on muistiosoite johonkin kohtaan muistia. Katsotaan, mitä siellä kohtaa muistia on:

x /12xg $rdi

Siellähän on testiohjelman taulukon lukuarvot. Taulukko on siis välitetty aliohjelmalle muistiosoitteena rekisterissä RDI. Ihan niinkuin ABI-dokumentaation mukaan pitikin. Varsinaiset taulukon luvut sijaitsevat pinomuistissa kutsuvan ohjelman pinokehyksen alueella. Mainin alussahan oli sijoituskäskyjä, jotka laittoivat nuo luvut pinomuistiin. C-ohjelmassa taulukko alustettiin esittelyn yhteydessä, mutta konekielikäännöksessä se nähtävästi tehdään erillisillä peräkkäin suoritettavilla operaatioilla.

Nyt ilmeisesti ohjelmalla on tarkoitus luoda juuri kutsutulle aliohjelmalle oma kehys. Katso käsky käskyltä, miten se tapahtuu:

stepi

Katso tulosteesta, miten RSP ja RBP toimivat. Aliohjelman alussa myös siirretään parametrit rekistereistä uuden pinokehyksen sisään, ikään kuin ne olisivat lokaaleja muuttujia. Jatkossa niitä käytetään pinomuistin kautta noista osoitteista eli -0x28(%rbp) ja -0x30(%rbp).

Tutki vielä tässäkin kohtaa muistin sisältö seuraavasti:

x /32xg $rbp-128
display

Löydätkö pinomuistista edellisen pinokehyksen kantaosoitteen? Entä osoitteen käskyyn, johon aliohjelmasta tulee palata? Pitäisi olla asiat niissä muistipaikoissa, joissa pinokehysmallin mukaan olettaisitkin. Jos aliohjelma ei kutsu mitään muita aliohjelmia, pinon huippua ei luultavasti ole päivitetty lokaalien muuttujien mukaan, koska uutta aktivaatiota ei tarvitse luoda. Tämä on siis "lehtialiohjelma", jonka lokaalit muuttujat mahtuvat kutsuvan ohjelman kehyksen "red zoneen" (ks. kurssimateriaali). Siksi suorituksen ajan RBP==RSP. Sen sijaan main() -aliohjelman suorituksesta nähdään normaali tilanne, jossa SP:n täytyy olla valmiina uuden kehyksen luontiin. 128 tavun "Red zone" on siis x86-64:n ABIssa sovittu asia, joka on x86-64:n GNU-C-kääntäjässä näin; muissa toteutuksissa voi olla sovittu ihan millä tavoin vaan!

Käy sitten aktivaatiota läpi eteenpäin käsky käskyltä. Silmukan ja ehtolauseiden toteutustavasta ei olla kauhean kiinnostuneita, mutta suoritetaan sitä kuitenkin askel askeleelta läpi ja katsotaan päällisin puolin, millaisista käskyistä se muodostuu:

stepi

Ja toista tätä. Huomaa, miten debugger näyttää aina C-kielisen koodirivin, jota ollaan suorittamassa, ja voit käydä läpi komento komennolta sen, millaiseksi konekielikäskyjen ryppääksi kukin lähdekoodirivi on käännetty. Sijoituksista näet mm. missä muistiosoitteissa (suhteessa kantaosoiterekisteriin) säilytetään mitäkin muuttujia. Siitä näkee myös, miten for-silmukka toteutuu siten että yhdestä lähdekoodirivistä muodostaan käännöksessä käskyjä ennen ja jälkeen silmukkalohkon sisällön: Silmukka alkaa itseasiassa hyppykäskyllä sen loppuun, jossa testataan lopetusehto. Jos ehto ei vielä päde, hypätään silmukkalohkon koodin alkuun. Näin voidaan assembler-koodista havaita, että for-silmukkaa ei suoriteta kertaakaan, jos sen ehto ei ole alussa tosi. Ohjelmoinnista ehkä tiesit tämän sopimuksen. Nyt sille on myös konkreettinen näkökulma ja toteutus prosessorikomentoina. Mielestäni tämän pitäisi olla valaisevaa... Ehdolliset hypyt toimivat EFLAGS-rekisterin bittien mukaan. Observoi, että EFLAGSin bitit muuttuvat aika usein käskyjen seurauksena.

Huomaa, että osoitteiden muodostaminen kokonaisluvulla indeksoituun taulukkoon koostuu monesta vaiheesta: Indeksimuuttuja otetaan ensin pinomuistista rekisteriin, kerrotaan rekisterissä sitten kahdeksalla (bittien siirto kolmella pykälällä vasemmalle, shl) koska indeksoidaan kahdeksan tavun mittaisia alkioita, ja osoitteethan puolestaan osoittavat yhden tavun mittaisia peräkkäisiä muistilokeroita. Sitten lisätään tulokseen ensimmäisen alkion muistiosoite, joka sijaitsee pinomuistissa. Lopulta laskemisen jälkeen rekisterissä on sen muistipaikan numero, jota voidaan käyttää halutun alkion osoittamisessa. [Koodin optimointi luultavasti (toivottavasti) aiheuttaisi tehokkaamman (ja luultavasti sekavamman) koodin muodostumisen. Parhaimmillaan optimointi ehkä kopioisi lyhyen aliohjelman koodin suoraan kutsuvaan ohjelmaan, siis ilman aliohjelmakutsua, ja lyhyt tunnetun mittainen silmukka saatettaisiin tehdä tuottamalla silmukan sisällön koodi peräkkäin ilman varsinaisia ehtoja ja hyppykäskyjä, peräkkäiset indeksit ehkä käytäisiin läpi vain lisäämällä edelliseen indeksiin sopiva luku (tässä 8)... ym...]

Okei, koko algoritmia ei tarvitse askeltaa läpi... Kun mielestäsi kykenet hyvin uskomaan sen asian, että C-koodi on kääntynyt käsky kerrallaan useaksi konekielikäskyksi, joita prosessori suorittaa yksi kerrallaan, ja että muuttujat ilmenevät tiettyinä muistipaikkoina suorituspinossa, voit siirtyä tämän aliohjelman loppuun. Tee se esim. seuraavasti. Aseta breakpoint return-lauseeseen, eli komenna:

break 51

(tai mikä nyt omassa lähdekoodissasi onkaan aliohjelman return-lauseen rivinumero). Sitten komenna:

continue

Nyt ohjelman suoritus jatkui breakpointtiin asti, joka on siis se kohta, johon on sijoitettu ensimmäinen breakpoint-rivillä sijaitsevan return-lauseen suoritukseen kuuluva konekielikäsky. (Tästedes suoritus pysähtyisi aina tuohon kohtaan, kunnes breakpoint otettaisiin pois päältä. Breakpointit helpottavat debuggausta; sellainen laitetaan paikkaan, joka on juuri ennen sitä kohtaa, jossa ohjelma esimerkiksi tuntuu kaatuvan. Sitten tarkkaillaan käsky käskyltä, miten koodi esim. sen kaatumisen saa aikaan: mitä on missäkin rekisterissä, mistä tulee se väärä muistiosoite, "null pointer" tai muu.)

Nyt taas ole hyvin tarkkana: Mitä tekee leaveq ja retq. Mitä on pinon päällä milläkin hetkellä, miten kontrolli palaa kutsuneeseen ohjelmaan. Mitä kutsuva ohjelma vielä tekee ennen kuin koko aliohjelmaa kutsuva C-koodirivi on suoritettu loppuun.

Muita aliohjelmia ei tarvitse enää askeltaa. Katsotaan kuriositeettina, mitä tapahtuu C-ohjelman lopuksi. Suorita ohjelmaa riville, jossa on malliharjoitustyön loppu eli main-ohjelman return:

until 78

Aja tästä eteenpäin käsky käskyltä kunnes kontrolli päätyy jonnekin aivan muualle kuin itse ohjelmoituun koodiin. Viimeinen retq -komento vie johonkin tällaiseen paikkaan:

0x0000003bbec1d994 in __libc_start_main () from /lib64/libc.so.6

Eli prosessin virtuaalimuistiin on tuohon kohtaan käynnistyksen yhteydessä dynaamisesti linkitetty kaikkien C-ohjelmien yhteinen apukirjasto libc, jonka tehtävä on mm. tehdä alustuksia ja lopetteluja käyttöjärjestelmäkutsujen avulla, ja siinä välissä kutsua sovellusohjelmoijan kirjoittamaa tietynlaista main -nimistä aliohjelmaa. Näin siis C-ohjelma päätyy sen sovittuun aloituspisteeseen, ja näin se jatkaa vielä sovitun päätepisteen jälkeenkin libc:ssä toteutetuilla lopputoimilla. Nyt tiedät senkin; se on jo aika paljon enemmän kuin Ohjelmointi 1:llä kerrottiin ohjelman suorituksesta... ja vasta alkua siitä, mistä todellisessa maailmassa olisi kyse, jos asia vietäisiin "oikeasti tekniseksi" (mitä edelleenkään mielestäni tällä kurssilla ei oikeasti tehty missään vaiheessa...).

Sitten sovellat edellä nähtyä vielä toiseen ohjelmaan nimeltä miniharkka.c tuottaen tee -apuohjelmalla tekstitiedoston sekä teet tiedoston sekaan alla mainitut opinnäytöt, niin miniharkka on palautettavissa tuttuun tapaan sähköpostilla.

Tuota lopullinen opinnäyttö

Oletamme, että olet ajanut konekielelle käännetyn C-ohjelman miniharkka.c gdb:llä samoin kuin edellä tehtiin malliharjoitustyölle. Ajo on tallessa tekstitiedostossa. Saat toki soveltaa gdb:n käskyjä ja suoritusjärjestystä vapaasti, kunhan pystyt tuottamaan alla mainitun ohjeen mukaisen vastauksen.

Ota uudelleen käsittelyyn se, mitä gdb-sessiossa tapahtui. Eli ota syntynyt tekstitiedosto auki lempparitekstieditoriisi ja kirjoita sen sekaan seuraavat huomiot:

  • Otit jossain vaiheessa disassemblyn pääohjelmasta main sekä aliohjelmasta summaa_ja_nollaa_valilta. Osoita tallentuneiden disassembly-rivien perään kirjoitetulla tekstillä, että olet ymmärtänyt miten aliohjelmaa kutsutaan ja miten sieltä palataan. Eli kohdenna vastaus aliohjelman disassemblyn alkuun ja loppuun sekä main-aliohjelman disassemblyssä niiden käskyjen kohdalle, jotka kävit kutsun osalta läpi konekäsky kerrallaan. Jokaisen aktivaatioon liittyvän koodirivin perään (muistele siis, mitkä rivit liittyivät aktivaatioon, so. parametrien välitys, RIP:n päivitys, paluuarvon välitys, paluuarvon sijoittaminen paikalliseen muuttujaan, tilanvaraukset pinosta) kirjoita seuraavan muotoinen kommentti:

    0x000000000040066d <main+92>:   callq  0x4004f8 <kaanna_taulukko>
    HARKKA: Yllä olevan käskyn rooli aliohjelmakutsun toteutuksessa
            on ... [tai vastaava aloitus, jolla aloitat selityksen,
            mikä on käskyn merkitys kokonaisuuden kannalta]
    

    Aliohjelman disassemblyssä tee samoin jokaisen aktivaatioon liittyvän koodirivin perään, siis aliohjelman alussa ja lopussa tiettyyn määrään rivejä. Muita kuin aliohjelmamekanismiin liittyviä rivejä ei tarvitse kommentoida mitenkään! Esimerkki:

    0x00000000004004f9 <kaanna_taulukko+1>: mov    %rsp,%rbp
    HARKKA: Yllä olevan käskyn rooli aliohjelmakutsun toteutuksessa
            on ... [tai vastaava aloitus, jolla aloitat selityksen
            mikä on käskyn merkitys kokonaisuuden kannalta]
    0x00000000004004fc <kaanna_taulukko+4>: mov    %rdi,-0x40(%rbp)
    HARKKA: ...[Tämän rivin merkitys ei ole niin kriittinen,
            mutta saa nämäkin toki huvikseen kommentoida;
            liittyväthän nämä pinokehyksen käyttöön... ]
    
    ... sitten kun tulee algoritmin toteutuksellisten rivien
    käännös, ei tarvitse kommentoida ollenkaan...
    

    Tarkastan harjoitustyöt (pistokokeina, tuskin kaikkia kovin tarkoin) seulomalla vastauksesta automaattisesti rivit, joilla lukee HARKKA: ja pari riviä siitä ympäriltä (grep-apuohjelmalla, luonnollisesti:)); silmäilen ne läpi, ja tekstien pitäisi osoittaa ymmärrystä. Pitkiä niiden ei tarvitse olla, mutta ymmärrystä ei vielä osoita pelkästään sanoa "käsky siirtää RSP:n RBP:hen" tai muu suomennos käskystä "mov %rsp,%rbp"; jokaiseen käskyyn on joku syy/merkitys joka pitää ymmärtää ja siis osata selittää kurssilla nimetyillä käsitteillä (suorituskohta, kontrolli, kutsu, pinokehys, kantaosoite, pino-osoitin, parametri ... mitä näitä nyt sitten olikaan)!

  • Otit myös aliohjelmastasi disassemblyn, ja yhden kutsun osalta tulostit pinomuistin sisällön ennen ja jälkeen pinokehyksen luonnin. Tulosteita käyttäen hahmota, missä kohtaa muistia (tai prosessoria) mikäkin asia sijaitsee. Kirjoita seuraavan muotoiset rivit, joissa kerrot jokaisen kiinnostavassa aliohjelmassa summaa_ja_nollaa_valilta olevan muuttujan sekä asian muistiosoitteet juuri kyseisen aliohjelma-aktivaation aikana. Eli sijoita rivit vaikkapa siihen kohtaan tekstitiedostoasi, missä gdb suorittaa jo ensimmäistä aliohjelman C-koodiriviä, ts. aktivaatio on kunnolla aloitettu. Käytä osoitteille heksalukuja kuten gdb. Käytä nyt absoluuttisia virtuaalimuistiosoitteita, älä suhteellisia rekistereihin nähden (eli joudut ihan vähän laskemaan heksaluvuilla, tai tekemään tulkinnan muistissa olevien lukuarvojen perusteella):

    HARKKA: Muuttuja indA on muistiosoitteessa 0x287562858
    HARKKA: Muuttuja indB on muistiosoitteessa ...
    HARKKA: Muuttuja i    on muistiosoitteessa ...
    ...
    
    [Käsittele kaikki C-lähdekoodissa olevat muuttujat, niin
    lokaalit muuttujat kuin parametritkin.]
    
    Taulukkoparametrin osalta selvennä seuraavin tavoin:
    
    HARKKA: Aliohjelman taulukkoparametrin ``taulu`` sisältämät
            numerot sijaitsevat muistiosoitteissa ALKUOSOITE -
            LOPPUOSOITE
    
  • Samassa kohtaa aliohjelman aktivaation suoritusta jatka kertomalla, mitä heksalukuja seuraavat muistiosoitteet olivat siinä vaiheessa kun gdb-ajosi tapahtui:

    HARKKA: Tämän aktivaation pinokehyksen kantaosoite on ...
    HARKKA: Edellisen aktivaation kehyksen kantaosoite on ...
    HARKKA: Edellisen aktivaation kehyksen kantaosoite on tallennettu
            muistiosoitteeseen ...
    HARKKA: Pinon huipun osoite tällä hetkellä on ...
    
  • Pääohjelmassa aktivaation jälkeen kerro seuraavaa:

    HARKKA: Aliohjelmastani on juuri palattu; sen paluuarvo
            sijaitsee nyt ... [missä]
    

Siinäpä tärkeimmät. Nämä asiat tulee ymmärtää konekieliohjelmoinnista ja aliohjelman suoritusperiaatteesta Käyttöjärjestelmät -kurssin puitteissa. Yllämainitut vastaukset lisättynä gdb-tulosteeseen riittävät mielestäni hyvin asian osoittamiseksi.

Harjoitustyön palautus

Harjoitustyössä tuotetaan edellä kerrottujen ohjeiden mukainen tekstitiedosto, jonka sisältö tulee lähettää Jalavasta mail-ohjelmalla samalla tavalla kuin demot; sähköpostin otsikon tulee olla täsmälleen "mun harkka"

Jos työ on valmis ja palautettu ennen kesän ensimmäistä tenttipäivää, annan siitä +1 pistettä bonusta ensimmäiseen tenttiyritykseen. Pientä motivointia pittää aina yrittää!