Debugger-miniharkka

ITKA203 Käyttöjärjestelmät -kurssin Demo 7a keväällä 2014. "Debugger-miniharkka"

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

Tilanne: Tämä on aivan tehtävissä ja palautustehtävän muotokin on
sopivastai määritelty. "Interaktiivisen osion" teksti ei ole päivitetty nykyisen koodiversion mukaiseksi, mutta malliharkan skriptattu ajo on OK.

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 yleistä kykyäsi ymmärtää ohjelmia.

Historiallisia huomautuksia: Aliohjelma-aktivaation toimintaa käsittelevä harjoitustyö on ollut mukana käyttöjärjestelmäkurssilla IT-tiedekunnan perustamisesta alkaen, ja vuosia sitäkin ennen. Tarkoitus on, että tämä harjoitus jossain määrin vastaisi aiempien kurssikertojen perinteistä harjoitustyötä.

Käytämme vuonna 2014 relevantteja työkaluja eli x86-64 -arkkitehtuurin mukaisissa Red Hat Enterprise Linux -virtuaalipalvelimissamme toimivaa, jo tutuksi tullutta, kääntäjää gcc sekä uutena tuttavuutena debuggeria gdb. Molempia on nähty käytännön esimerkeissä kurssin luennoilla. Toki tällainen harjoitus voitaisiin tehdä yksinkertaisella "lasten tietokonesimulaattorilla" tai jonkin muinoisen (ja sitä kautta nykyisiä yksinkertaisemman) prosessorin emulaattorilla, mutta koen että nykyisen tuotantovälineistön käyttö tarjoaa jonkinlaisen "oikotien onneen", vaikkakin mahdollisesti "vaikeuksien kautta voittoon". Yhteisen palvelinkoneiston käyttö mahdollistaa yhtenäisen ohjeistuksen laitteistoon ja ohjelmistoon, mikä ei mikroluokkia ja kotikoneita käytettäessä välttämättä olisi järin helppoa.

Kesällä 2011 tehtiin vastaava harjoitus. Silloin ajattelin, että tämä tuottaisi vain vähän lisätyötä, jos on taustalla aiemmat demot ja luennot käytynä / materiaalit ymmärrettynä. Jälkikäteen oli silloin todettava, että joillakin opiskelijoilla vaati vielä kovastikin työtä päästä tästä kärryille. Lisäksi jotkut sivuaineopiskelijat kokivat aihepiirin tarpeettomaksi omaan osaamiskenttäänsä nähden. Keväällä 2014 näitä ilmiötä on koetettu korjata seuraavin tavoin:

  • Harjoitus on vapaaehtoinen, jotta kellekään ei tulisi "pakkopullan" tuntua. Tentissäkään ei tulla enää kysymään perinteistä kysymystä "selvitä aliohjelmakutsun toiminta konekielen tasolla". Tekemistä kuitenkin motivoidaan jopa kahdella bonuspisteellä, ja ainakin tietotekniikan pääaineopiskelijan kannattaisi ajatella tätä oman osaamispohjan kannalta hyvinkin välttämättömänä jumppatuokiona.
  • Harjoituksen muotoa on yritetty säätää yksinkertaisemmaksi edelliseen versioon nähden.
  • Komentokehotteesta, C-kielestä ja tekstipohjaisesta tiedonkäsittelystä on tehty pohjaharjoituksia jonkin verran aiempaa enemmän.

Vielä lisämotivaatioksi: Mainitsen tässä erityisesti kaksi IT-tiedekunnan syksyllä 2014 starttaavaa maisterikoulutusta, joissa tämän harjoituksen käsittelemistä asioista on suoria yhtymäkohtia:

Pieni ohjattu läpikulku

Ensin käydään malliharkka.c -ohjelman kautta läpi harjoitustyön työkalut ja idea. Koodi ja muu välineistö on saatavilla kurssin nettisivulta:

http://users.jyu.fi/~nieminen/kj14/demo7a.zip

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

Ensinnäkin ohjelmaa on syytä silmäillä lähdekoodin tasolla, kääntää ja ajaa sitä ja todeta, että se toimii odotusten mukaisesti:

gcc malliharkka.c
./a.out

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 (eli iso ä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 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ä. Tässä vaiheessa lienee käynyt selväksi, että tekstikomennoilla toimiva käyttöliittymä tukee luonnostaan komentojen täsmällistä tallentamista, toistamista ja skriptiksi asettelemista.

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.

Interaktiivinen ajo

Käynnistä debuggeri uudelleen:

gdb malliharkka

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ä 8086-käskyjä vai myöhempiä laajennoksia). Lyhimmät käskyt (kuten push BP) ovat pisimpään mukana olleita.
  • Sitten on käsky AT&T-syntaksilla eli mnemonic ja operandit.
  • Esimerkkejä käskyistä ja osoitusmuodoista on kurssimateriaalissa. Prosessorimanuaalit ovat useampituhatsivuisia. Niistä joitakin satoja on käskyjen toiminnan kuvailua. Suurin osa ohjelmien tarvitsemista rakenteista hoituvat kuitenkin muutamilla peruskäskyillä. Valtaosa nykyprosessorin käskyistä liittyy erityistehtäviin kuten liukulukulaskentaan. Myös prosessoriytimien keskinäinen synkronointi edellyttää runsaasti selvitystä nykyprosessorien manuaaleissa.

Koska ohjelmaa ei vielä ole käynnistetty, pitää gdb:lle kertoa eksplisiittisesti, mistä aliohjelmasta disassembly halutaan tehdä (toki näin voi aina tehdä muutenkin; oletuksena disassembly näytetään siitä aliohjelmasta, jonka sisällä suorituskohta milloinkin on). 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.

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

[TODO: Tämän luvun teksti on OK 2014, mutta esimerkit on vielä vuoden 2011 ajosta, joten rivinumerot luultavasti eri kuin nyt.]

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          |                 |
+----------------------------------------------+-----------------+

Ohjelman käynnistymisestä on jo aikaa, ja standardikirjasto on ehtinyt suorittaa alustustoimenpiteitä. Periaatteessa on täysin mahdollista, että jo tässä vaiheessa tulisi kellokeskeytys ja prosessi joutuisi ready-jonoon odottelemaan seuraavaa vuoroaan. Ja nythän se on joka tapauksessa pysäytettynä debuggerin toimesta. Pelit on seis, mutta se ei ohjelman kannalta vaikuta. Kun se seuraavan kerran jatkaa suorittamista, tilanne ei ole suoritettavan prosessin kannalta mitenkään erilainen.

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 (varsinainen toimenpide).
  • RIP:n arvo päivittyy seuraavan käskyn osoitteksi. Tässä tapauksessa lukuarvoksi 0x40060e.

Tai siltä ainakin näyttää. (Varsinaisesti pelissä on L1- ja L2 -välimuistit, muistinhallintayksikkö MMU ja sen TLB-puskurit; seuraava käskykin voi olla jo pitkään ollut suorituksessa tai suoritettuna johtuen liukuhihnoista ja ennakoivasta suorituksesta. Tai sitten suoritus on tehty emulaattorilla, simulaattorilla, virtuaalikoneella tai ainoastaan ohjelmaansa debuggaavan kehittäjän tai harjoitustyötä tekevän opiskelijan pään sisäisessä mielikuvamaailmassa. Millään näillä teknisillä suunnittelun ja optimoinnin urotöillä ei ole vaikutusta siihen, miten ohjelman suoritus näyttäytyy prosessoriarkkitehtuurirajapinnassa eli konekielikoodina.)

Totea, että näin käy, niinkuin ounasteltiin, eli komenna yhden käskyn suorittaminen ("step instruction"):

stepi

Tulostus vahvistaa, että ainakin RIP päivittyi juuri niin kuin pitikin; 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/alimmainen
     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.

[TODO: (Varmista...) Tämän pätkän pitäisi olla jossain määrin tarpeeton 2014, kun tutkittavaan aliohjelmaan on lisätty turha_kutsu(): 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. 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, siis muistiosoitteeseen, 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ä, vastaako tilanne ennakko-oletuksia vielä breakpointin kohdalla... jos vastaa, niin mistä sitten oikein tulee lopulta se väärä muistiosoite, "null pointer" tai muu ongelma.)

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...).

Ohjattu läpikävely on nyt tehty. Mennään itse asiaan.

Skriptattu ajo

Tekstimuotoisia komentoja vastaanottavat ohjelmat ovat siitä kivoja, että niitä voi skriptata, eli komentoja voi tallentaa tiedostoksi, jotka luetaan ja suoritetaan. Niin myös gdb -debuggerille voidaan tehdä, tosin ei kovin monipuolisella tai helpolla tavalla. Koodipaketissa on mukana gdb:n ymmärtämä komentotiedosto gdbkomennot_malli, joka ajaa debuggeria automaattisesti, tarkoituksena tallentaa eräästä ohjelman osasta "puolikattava" logitieto aivan konekäsky kerrallaan. Toinen, melkein samanlainen komentotiedosto gdbkomennot_harkka tuottaa varsinaisessa harjoitustyössä tutkittavan login.

Kokeillaan kuitenkin ensin vielä malliharkan kanssa. Käännä ohjelma seuraavasti:

gcc -g -O0 -DHILJAA -o malliharkka malliharkka.c

Optiot ovat edelleen -g debug-tiedoille ja -O0 optimointien estämiselle, -o malliharkka tulostiedoston nimelle. Lisäksi on optio -DHILJAA, jotta C-koodista jää pois tulostukset. Ne on kirjoitettu rivien #ifndef HILJAA ja #endif väliin. Optio -DHILJAA määrittelee ("D" niinkuin "define", ehkä...) kyseisen makron, ja esikääntäjä yksinkertaisesti poistaa tulostuskutsut ennen kääntämistä. Emme ole enää kiinnostuneita tulosteista, vaan konekielisestä laskennasta. Tulostusten poisto yksinkertaistaa osaltaan tutkittavaa assembler-koodia.

Nyt ajetaan debuggeria komentojonotilassa, ja tuloste ohjataan talteen logitiedostoon:

gdb -batch -x gdbkomennot_malli malliharkka > logi_malli.txt

Optiolla -batch kerrotaan gdb:lle, että sen ei pidä odottaa tai kysellä mitään interaktiota käyttäjältä, vaan kaikki tarvittava on komentotiedostossa. Optio -x gdbkomennot_malli pyytää gdb:tä ottamaan vastaan tiedostoon gdbkomennot_malli kirjoitetut toimintaohjeet. Loppu lienee selvää. Toki saat mielellään olla hiukan kiinnostunut siitä, mitä gdbkomennot_malli sisältää.. Voin kertoa, että sen laatiminen tapahtui taas perinteisen hataralla pohjalla netistä ohjeita ja manuaaleja etsiskelleen. Komentotiedoston sisältö on kuitenkin sivuseikka... logitiedostoon ilmaantuvien tulosteiden pitäisi olla suomen kielellä kohtalaisen selvästi annotoituja.

Tutki tulostetta. Harjoitustyö perustuu samanlaisen login tutkimiseen eri ohjelmasta (miniharkka.c). Esimerkiksi näin saat tulostettua ihan vaan "jäljen" eli kiinnostavassa pätkässä suoritettujen konekäskyjen osoitteet ja suoritusjärjestyksen:

grep "=>" logi_malli.txt

Varo vaaraa jälleen: Huomaa lainausmerkit grepin ensimmäisessä argumentissa "=>" sillä jos niitä ei olisi, niin shell kuvittelisi että haluat väkäsellä ohjata tavaraa tiedoston logi_malli.txt päälle, ja uusiksihan se silloin menisi, kun tiedosto tyhjenisi...

Seuraavalla komennolla voisi tarkkailla, minkälaiset konekäskyt ovat muuttaneet lippurekisterin bittejä:

grep -E "FLAGS|=>" logi_malli.txt

Palataan alkuperäiseen aiheeseen, eli pinokehyksen käyttöön aliohjelma-aktivaation toteutuksessa ja siihen, kuinka parametrit välitetään aliohjelmalle ja paluuarvo sieltä takaisin kutsuvaan ohjelmaan...

Skriptatun ajon tulosteen tutkimista

Malliharjoitustyö; "ohjattu login tutkiminen".

Edellä tehty logi on normaali tekstitiedosto, joten sitä voi tutkia millä tahansa tekstieditorilla (joka osaa tulkita ASCII-koodin 0x0a eli "line feed" rivinvaihdoksi).

Höpsöhkönä esimerkkinä tulosteesta voisi poimia tilanteita vaikkapa grepillä (vaikkei tässä välttämättä järkeä olekaan, mutta jos nyt tilanne mahdollistaa, niin miksipä ei...). Koodipaketissa on mukana shell-skripti, jolla voi näyttää logista tietyn käskyn suorituksen ja askeltaa eteen- ja taaksepäin:

./selaa_tulostetta.sh logi_malli.txt

Tulosteen voi ottaa näkyviin myös vaikka nettiselaimeen, jossa voi kelata askel kerrallaan taakse ja eteenpäin selaimen "Find next" ja "Find previous" -toiminnoilla.

Varsinaisessa logissa on tilannekuva jokaisen konekielikäskyn suorituksen jälkeen siitä asti kun pääohjelma alkoi, siihen asti kun se loppuu. Välissä tapahtuu luonnollisesti se yksi aliohjelmakutsu. Tilannekuva on seuraavanlaisessa formaatissa:

--- Logitettu jälki tähän asti: 31 suoritettua konekäskyä
--- Muutaman olennaisimman rekisterin tilanne:
RIP (käsky) 0x000000000040066b  RSP (huippu)  0x00007fffffffde30  RBP (kanta) 0x00007fffffffde60
RAX (yleis) 0x0000000000000005  RBX (yleis)   0x0000000000000000  FLAGS (liput) 1000000110
--- Pinomuistia:
                   0x7fffffffdec0:  0x0000000000000000
                   0x7fffffffdeb8:  0x0000000000000006
                   0x7fffffffdeb0:  0x00007fffffffdfa0
                   0x7fffffffdea8:  0x0000000000000008
                   0x7fffffffdea0:  0x0000000000000002
                   0x7fffffffde98:  0xfffffffffffffff0
                   0x7fffffffde90:  0x0000000000000003
                   0x7fffffffde88:  0x0000000000000002
                   0x7fffffffde80:  0xffffffffffffffff
                   0x7fffffffde78:  0x0000000100000000
                   0x7fffffffde70:  0x00007fffffffdfa8
                   0x7fffffffde68:  0x0000000000400748
kehyksen kanta --> 0x7fffffffde60:  0x00007fffffffdec0
                   0x7fffffffde58:  0x0000000000000005
                   0x7fffffffde50:  0x0000000000000000
                   0x7fffffffde48:  0x0000000000000000
                   0x7fffffffde40:  0x00000000f63d4e2e
                   0x7fffffffde38:  0x00007fffffffde80
  pinon huippu --> 0x7fffffffde30:  0x0000000000000006
--- Seuraavaksi "noudettava ja suoritettava" käsky:
=> 0x40066b <kaanna_taulukko+229>:  jg     0x4005c1 <kaanna_taulukko+59>
--- Suoritetaan 32. käsky nyt...
68      if (taulu[ivasen] == taulu[ioikea]){
--- Pling. suoritettu

Alussa on tieto siitä, montako käskyä on tähän mennessä logitettu. Sitten on pinoon liittyvien rekisterien sisältö. Sitten on varsinaisen virtuaalimuistin sisältöä muodossa osoite_heksana: sisältö_heksana. Kantaosoite ja pinon huipun osoite on annotoitu muistitulosteeseen. Näissä esimerkkiohjelmissa kaikki käsiteltävät luvut, muistiosoitteet mukaanlukien, ovat tarkoituksella saman mittaisia (64 bittiä), joten osoitteiden tulostaminen 8 tavupaikan välein antaa ymmärrettävän tulosteen.

Tässä esimerkissä aliohjelman suoritus on hyvässä vauhdissa ja sillä on jo luotuna oma alue pinon päällä. Taulukon sisältö ei ole käytettävissä muuten kuin kehyksen sisälle viedyn muistiosoitteen (0x00007fffffffde80) kautta. Tässä siis perusesimerkki "olion tilan muuttamisesta viitteen kautta". Lopussa logiin on vielä laitettu seuraavana suoritettavan käskyn disassembly. Esimerkin tapauksessa tehdään ilmeisesti ehdollinen hyppykäsky jg ("jump if greater"), joka liittyy silmukan loppuehdon tarkistamiseen koodirivillä for(ioikea=koko-1; ioikea>ivasen; ioikea--){ - ikävä kyllä en ehtinyt selvittää, saisiko tulosteessa oikaistua rivien tulostusta. Debuggeri nimittäin tulostaa käskyn suorituksen yhteydessä (stepi) automaattisesti seuraavan koodirivin, jolle suoritus siirtyy. Tämä on interaktiivisessa käytössä luonnollista ja mukavata, mutta tällaisen login tekemisessä olisi kiva, jos tuossa olisi vielä lukenut se koodirivi, johon nimenomaan näytetty käsky liittyi.

Tarkoitus on logia selaamalla selvittää, miten paikallisia muuttujia, parametreja ja yksittäistä paluuarvoa konkreettisesti pidetään muistissa missäkin vaiheessa suoritusta, ja kuinka tämä hoituu kahta osoiterekisteriä käyttäen. Tarkoitus on tutkia, pohtia ja ymmärtää sekä kysyä apuja, mikäli tarvitsee.

Ohjat omiin käsiin: varsinainen opinnäyttö

Koodipaketissa on mukana toinenkin C-koodi nimeltään miniharkka.c - tutustu siihen, kunnes ymmärrät sen ikään kuin se olisi itse tekemäsi. Eli lue, käännä, aja jne... Seuraavaksi tehdään käännökset ja logi gdb:llä samalla periaatteella kuin edellä:

Idea:

gcc -g -O0 -DHILJAA -o miniharkka miniharkka.c
gdb -batch -x gdbkomennot_harkka miniharkka > harkka_logi.txt

Ajo on nyt tallessa tekstitiedostossa harkka_logi.txt. Ota syntynyt tekstitiedosto auki lempparitekstieditoriisi tai muuhun, millä sitä mielestäsi on hyvä tutkia.

Vapaaehtoinen palautustehtävä (2p)

Vastauksesi on täsmälleen yksi tekstitiedosto, nimeltään vaikkapa miniharkka.txt, johon tulee vastauksia kysymyksiin ja valikoituja otteita edellä tehdystä logista harkka_logi.txt kommentoituna suomen kielellä.

Yleisiä kysymyksiä

Vastaa seuraaviin kysymyksiin login perusteella:

  • Montako konekäskyä ohjelman suorittaminen vaati pääohjelman alusta loppuun?

  • Montako konekäskyä ohjelman suorituksesta oli tutkitun aliohjelman sisällä?

  • Montako aktivaatiota kutsupinossa oli enimmillään?

  • Mitkä olivat aktivaatioiden pinokehysten kantaosoitteet sillä hetkellä?

  • Missä osoitteissa sijaitsivat aktivaatioiden paluuosoitteet?

  • Missä aliohjelman summaa_ja_nollaa_valilta paluuarvo sijaitsi siinä vaiheessa, kun aliohjelman viimeinen käsky ret oli juuri suoritettu?

  • Tulosteita käyttäen hahmota, missä kohtaa muistia (tai prosessoria) mikäkin asia sijaitsi ajon aikana. Kirjoita seuraavan muotoiset rivit, joissa kerrot jokaisen kiinnostavassa aliohjelmassa summaa_ja_nollaa_valilta olevan muuttujan sekä asian muistiosoitteet juuri kyseisen aliohjelma-aktivaation aikana.

    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):

    Muuttuja indA oli muistiosoitteessa 0x287562858
    Muuttuja indB oli muistiosoitteessa ...
    Muuttuja i    oli 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
    

Disassemblyn kommentointi

  • Login alussa on disassemblyt pääohjelmasta main sekä aliohjelmasta summaa_ja_nollaa_valilta. Kopioi nämä vastaustiedostoosi ja 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 konekielisen käskyrivin 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...