10. Konekieliohjelmointi
Konekielellä ohjelmointi on varsin erilaista kuin korkeamman tason ohjelmointikielen käyttäminen. Haasteena on, että konekielinen ohjelma tulee toteuttaa käyttäen yksinkertaisia komentoja eikä käytettävissä ole korkean tason ohjausrakenteita, kuten ehtolauseita ja silmukoita.
Tämän luvun tavoitteena on oppia konekielisen ohjelmoinnin perustekniikat, joiden avulla pystyy periaatteessa toteuttamaan minkä tahansa ohjelman konekielellä. Seuraavassa luvussa perehdymme vielä tarkemmin siihen, miten aliohjelmat toteutetaan konekielessä.
Tiedon käsittely
Konekielisessä ohjelmoinnissa tietoa voi säilyttää prosessorin rekistereissä sekä tietokoneen muistissa. Rekistereissä olevaa tietoa on huomattavasti nopeampaa käsitellä kuin muistissa olevaa tietoa, mutta rekistereissä voi säilyttää kerrallaan vain vähän tietoa.
Tässä luvussa käsittelemme muistia staattisen muistinvarauksen kautta, jolloin koodissa määritellään tarvittavat muistialueet, jotka varataan kiinteästi ohjelman suorituksen ajaksi. Seuraavassa luvussa tutustumme pinomuistin käyttämiseen konekielen näkökulmasta.
Rekisterit
Tavalliset tiedon käsittelemiseen käytettävät 64-bittiset rekisterit ovat:
- Perusrekisterit
rax,rbx,rcx,rdx,rsijardi - Lisärekisterit
r8,r9,r10,r11,r12,r13,r14jar15
Rekisterien nimissä näkyy x86-konekielen historia, mutta nykypäivän näkökulmasta rekistereitä voi käyttää useimmissa tilanteissa vapaasti tiedon käsittelemiseen. Usein tapana on käyttää ensisijaisesti perusrekistereitä ja ottaa tarvittaessa käyttöön lisärekistereitä, jos tarvitaan enemmän rekistereitä.
Perusrekisterit jakautuvat osiin seuraavasti:
| 64-bittinen rekisteri | 32-bittinen alaosa | 16-bittinen alaosa | 8-bittinen yläosa | 8-bittinen alaosa |
|---|---|---|---|---|
rax |
eax |
ax |
ah |
al |
rbx |
ebx |
bx |
bh |
bl |
rcx |
ecx |
cx |
ch |
cl |
rdx |
edx |
dx |
dh |
dl |
rsi |
esi |
si |
– | sil |
rdi |
edi |
di |
– | dil |
Rekistereissä rax, rbx, rcx ja rdx 16-bittinen alaosa jakautuu 8-bittiseen yläosaan ja alaosaan. Rekistereissä rsi ja rdi ei ole tapaa viitata 8-bittiseen yläosaan.
Lisärekisterit jakautuvat osiin seuraavasti:
| 64-bittinen rekisteri | 32-bittinen alaosa | 16-bittinen alaosa | 8-bittinen alaosa |
|---|---|---|---|
r8 |
r8d |
r8w |
r8b |
r9 |
r9d |
r9w |
r9b |
r10 |
r10d |
r10w |
r10b |
r11 |
r11d |
r11w |
r11b |
r12 |
r12d |
r12w |
r12b |
r13 |
r13d |
r13w |
r13b |
r14 |
r14d |
r14w |
r14b |
r15 |
r15d |
r15w |
r15b |
Muistinvaraus
Tavalliset konekielisen ohjelmoinnin tietoyksiköt ja muistinvaraukseen liittyvät avainsanat ovat:
| Tietoyksikkö | Koko | Muistinvaraus |
|---|---|---|
byte |
1 tavu (8 bittiä) | db / resb |
word |
2 tavua (16 bittiä) | dw / resw |
dword |
4 tavua (32 bittiä) | dd / resd |
qword |
8 tavua (64 bittiä) | dq / resq |
Koodin osio .data varaa staattisesti muistia tiedolle, jolla on tietty alkusisältö. Esimerkiksi seuraava koodi varaa kolme muistialuetta:
section .data
name db "Uolevi"
size dq 100
table dd 3, 1, 4, 1, 5, 9, 2, 6
Muistialueet sisältävät seuraavaa tietoa:
name: merkkijono, jonka alkusisältö on"Uolevi"size: 64-bittinen luku, jonka alkusisältö on100table: 8 alkion taulukko, joka muodostuu 32-bittisistä luvuista
Koodissa muistialueisiin voidaan viitata niiden nimien kautta. Esimerkiksi name vastaa muistiosoitetta, josta alkaa merkkijonon "Uolevi" tilanvaraus.
C-koodissa vastaavat muistialueet voisi varata suunnilleen seuraavasti:
char name[] = "Uolevi";
long size = 100;
int table[] = {3, 1, 4, 1, 5, 9, 2, 6};
Erona on kuitenkin, että konekielessä name, size ja table ovat vain osoitteita muistialueiden alkuun eikä niihin liity tietoa tyypeistä tai taulukoiden koosta. Tietoyksiköiden koot (kuten db ja dd) määrittävät varattavan muistialueen koon, mutta ne eivät vaikuta muuten muistin käyttämiseen.
Huomaa, että C:n merkkijonon "Uolevi" lopussa on nollatavu, jota ei ole vastaavan merkkijonon lopussa konekielen tilanvarauksessa.
Koodin osio .bss varaa vastaavalla tavalla muistia tiedolle, jonka alkusisältönä on nollaa. Esimerkiksi seuraavasti:
section .bss
buffer resb 128
Tässä buffer on nollaa sisältävä muistialue, jonka koko on 128 tavua.
Osioiden .data ja .bss erona on, että osion .data alkusisältö tallennetaan ohjelman binääritiedostoon, kun taas osion .bss alkusisältöä ei tallenneta. Tämän takia osion .bss käyttäminen pienentää binääritiedoston kokoa. Esimerkiksi jos 128 tavun muistialue buffer määriteltäisiin osiossa .data, binääritiedostossa olisi 128 tavua nollia tämän muistialueen alkusisältönä.
Tiedon kopiointi
Komento mov kopioi tietoa rekisterien ja muistin välillä. Komentoa voidaan käyttää useilla eri tavoilla:
-
Kopiointi kahden yhtä suuren rekisterin välillä: Esimerkiksi
mov rax, rbxkopioi rekisterinrbxsisällön rekisteriinrax(64-bittiset rekisterit) jamov ecx, eaxkopioi rekisterineaxsisällön rekisteriinecx(32-bittiset rekisterit). -
Vakioarvon sijoitus rekisteriin: Esimerkiksi
mov rax, 42sijoittaa rekisteriinraxluvun 42. Koska kohteena oleva rekisteri on 64-bittinen, luku 42 sijoitetaan siihen 64-bittisenä lukuna. -
Kopiointi rekisterin ja muistin välillä: Esimerkiksi
mov [num], raxkopioi tietoa rekisteristäraxmuistiin osoitteeseennumja komentomov rax, [num]kopioi tietoa muistista osoitteestanumrekisteriinrax. Kopioitavan tiedon määrä on 64 bittiä rekisterinraxkoon perusteella. -
Vakioarvon sijoitus muistiin: Esimerkiksi
mov qword [num], 42sijoittaa 64-bittisen luvun 42 muistiin osoitteeseennum. Tässä tulee ilmoittaa erikseen muistiin sijoitettavan luvun koko (qword= 64-bittinen luku), koska komennossa ei ole rekisteriä, josta voisi päätellä kopioitavan tiedon määrän.
Huomaa, että tietoyksikön koko voidaan antaa selkeyden vuoksi myös silloin, kun sen voisi päätellä muutenkin. Esimerkiksi komento mov qword [num], rax tarkoittaa samaa kuin lyhyempi komento mov [num], rax.
Rajoituksena mov-komennossa on, että tietoa ei voida kopioida kahden muistiosoitteen välillä, vaan korkeintaan yksi operandi voi olla muistiosoite. Esimerkiksi komento mov qword [num1], qword [num2] ei ole mahdollinen, vaan kopiointi tulee tehdä rekisterin kautta esimerkiksi seuraavasti:
mov rax, [num1]
mov [num2], rax
Useissa x86-konekielen komennoissa on rajoituksia liittyen siihen, miten komennoilla voi käsitellä muistia tai mitä rekistereitä komentojen yhteydessä voi käyttää. Esimerkiksi ei ole sallittua, että komennon molemmat operandit ovat muistiosoitteita, vaan tulee käyttää apuna rekistereitä.
Tällaiset rajoitukset konekielissä johtuvat siitä, että mitä monipuolisempia komentoja konekielessä on, sitä monimutkaisemmaksi tulee prosessorin toteutus sekä komentojen tavutason esitystapa. Konekielen suunnittelussa tavoitteena on toisaalta tarjota ohjelmoinnissa hyödyllisiä komentoja mutta toisaalta pitää konekieli riittävän yksinkertaisena.
Komentoa mov ei voi käyttää tiedon kopiointin kahden eri kokoisen rekisterin välillä. Esimerkiksi komento mov eax, dx ei ole mahdollinen, koska rekisteri eax on 32-bittinen ja rekisteri dx on 16-bittinen. Kuitenkin tässä voidaan käyttää komentoja movzx ja movsx, jotka kopioivat pienemmän rekisterin sisällön suurempaan.
Komento movzx on tarkoitettu etumerkittömän luvun kopiointiin, ja se nollaa kohderekisterin ylemmän puoliskon. Komento movsx on puolestaan tarkoitettu etumerkillisen luvun kopiointiin, ja se asettaa kohderekisterin ylemmän puoliskon luvun etumerkin mukaisesti.
Esimerkiksi kun rekisterin dx sisältönä on 10011001 10101111, komennot toimivat seuraavasti:
-
Komento
movzx eax, dxtulkitsee rekisterindxsisällön etumerkittömänä lukuna ja asettaa rekisterineaxsisällöksi00000000 00000000 10011001 10101111. -
Komento
movsx eax, dxtulkitsee rekisterindxsisällön etumerkillisenä lukuna ja asettaa rekisterineaxsisällöksi11111111 11111111 10011001 10101111.
Komentoja movzx ja movsx voi käyttää, kun pienempi rekisteri on 8- tai 16-bittinen ja suurempi rekisteri on 16-, 32- tai 64-bittinen. Siis kaikki muut yhdistelmät ovat sallittuja paitsi kopiointi 32-bittisestä rekisteristä 64-bittiseen rekisteriin.
Komento movsxd kopioi etumerkillisen luvun 32-bittisestä rekisteristä 64-bittiseen rekisteriin. Tälle komennolle ei ole etumerkitöntä vastinetta, mutta kopiointi 32-bittiseen rekisteriin tavallisella mov-komennolla nollaa vastaavan 64-bittisen rekisterin ylemmän puoliskon.
Laskuoperaatiot
Konekielen tavalliset laskukomennot ovat:
| Komento | Kuvaus | Esimerkki | Selitys |
|---|---|---|---|
inc |
Kasvatus yhdellä | inc rax |
Kasvattaa rekisterin rax arvoa yhdellä. |
dec |
Vähennys yhdellä | dec rax |
Vähentää rekisterin rax arvoa yhdellä. |
add |
Yhteenlasku | add rax, rbx |
Lisää rekisterin rax arvoon rekisterin rbx arvon. |
sub |
Vähennyslasku | sub rax, rbx |
Vähentää rekisterin rax arvosta rekisterin rbx arvon. |
mul / imul |
Kertolasku | mul rbx / imul rbx |
Kertoo rekisterin rax arvon rekisterin rbx arvolla ja sijoittaa tuloksen rekistereihin rdx:rax. |
div / idiv |
Jakolasku | div rbx / idiv rbx |
Jakaa rekisterien rdx:rax arvon rekisterin rbx arvolla ja sijoittaa tuloksen rekisteriin rax ja jakojäännöksen rekisteriin rdx. |
Konekielessä ei voi käyttää laskulausekkeita kuten korkeamman tason ohjelmoinnissa, vaan laskut tulee esittää sarjana yksittäisiä operaatioita. Esimerkiksi laskutoimitus (42 + 1337) * 5 voidaan toteuttaa seuraavasti:
mov rax, 42
mov rbx, 1337
add rax, rbx
mov rbx, 5
mul rbx
Tämä koodi muodostuu seuraavista vaiheista:
- Komento
mov rax, 42sijoittaa rekisteriinraxluvun 42. - Komento
mov rbx, 1337sijoittaa rekisteriinrbxluvun 1337. - Komento
add rax, rbxlaskee yhteen rekisterienraxjarbxluvut ja sijoittaa tuloksen rekisteriinrax. - Komento
mov rbx, 5sijoittaa rekisteriinrbxluvun 5. - Komento
mul rbxkertoo rekisterienraxjarbxluvut ja sijoittaa tuloksen rekistereihinrdx:rax.
Kahden n-bittisen luvun kertolaskun tulos vie tilaa 2n bittiä, minkä takia tulos esitetään kahdessa rekisterissä. Esimerkiksi tässä kerrotaan 64-bittiset luvut, jolloin tuloksena oleva 128-bittinen luku esitetään kahden 64-bittisen rekisterin yhdistelmänä. Merkintä rdx:rax tarkoittaa, että rekisteri rdx sisältää tuloksen ylemmän puoliskon ja rekisteri rax sisältää tuloksen alemman puoliskon.
Tässä kerrottavat luvut ovat pieniä ja kertolaskun tulos mahtuu kokonaan alempaan 64-bittiseen rekisteriin. Rekisterin rax sisällöksi tulee kertolaskun tulos 6895 ja rekisterin rdx sisällöksi tulee 0. Komento mul varautuu siis tallentamaan tuloksen kahteen rekisteriin, mutta tulos voi mahtua myös kokonaan alempaan rekisteriin, jolloin ylempi rekisteri nollautuu.
Komennoissa mul ja div käytettävät kiinteät rekisterit määräytyvät seuraavasti komennon operandin koon mukaan:
- Jos operandi on 8-bittinen, käytetään rekisterejä
aljaah. - Jos operandi on 16-bittinen, käytetään rekisterejä
axjadx. - Jos operandi on 32-bittinen, käytetään rekisterejä
eaxjaedx. - Jos operandi on 64-bittinen, käytetään rekisterejä
raxjardx.
Komennoissa mul ja div näkyy x86-konekielen historia: komennot käyttävät kiinteitä rekisterejä, koska prosessorit oli aikoinaan helpompaa toteuttaa tällaisilla rajoituksilla laskutoimituksissa.
Kerto- ja jakolasku ovat bittitasolla melko monimutkaisia, eikä niitä ole monissa vanhemmissa konekielissä. Esimerkiksi 1980-luvun kotitietokone Commodore 64 sekä ensimmäinen Nintendo-pelikonsoli (NES) perustuvat 6502-konekieleen, jossa ei ole kerto- ja jakolaskua.
Jos konekielessä ei ole kerto- ja jakolaskua, ne pystyy kuitenkin toteuttamaan tarvittaessa yhteen- ja vähennyslaskun sekä bittioperaatioiden avulla. Useissa tapauksissa kerto- ja jakolaskut on melko helppoa korvata muilla komennoilla, kuten tulemme huomaamaan myös tällä kurssilla.
Laskuissa tulee tietyissä tilanteissa ottaa huomioon lukujen etumerkillisyys. Yhteen- ja vähennyslaskussa tämä ei vaikuta asiaan, koska nämä operaatiot suoritetaan bittitasolla samalla tavalla riippumatta etumerkillisyydestä. Niinpä komentoja add ja sub voi käyttää sekä etumerkittömien että etumerkillisten lukujen kanssa.
Sen sijaan kerto- ja jakolaskussa tuloksen bittiesitykseen vaikuttaa etumerkillisyys, minkä takia saatavilla on erilliset komennot eri tilanteita varten. Komennot mul ja div käsittelevät etumerkittömiä lukuja, kun taas komennot imul ja idiv käsittelevät etumerkillisiä lukuja.
Konekielen tasolla muistin sisältöön ei liity tietoa, minkä tyyppistä tietoa muistissa on tietyssä kohdassa. Tämän takia esimerkiksi komento mul ei voi ”tunnistaa” luvun tyyppiä ja suorittaa kertolaskua eri tavalla etumerkittömälle ja etumerkilliselle luvulle, vaan tarvitaan erilliset komennot mul ja imul.
Komentoa imul voidaan itse asiassa käyttää usealla eri tavalla:
-
Yhden operandin syntaksi:
imul rbxkertoo rekisterienraxjarbxarvot ja tulos esitetään rekistereissärdx:rax. Tämä syntaksi vastaa perinteistä komennossamulkäytettävää syntaksia. -
Kahden operandin syntaksi:
imul rax, rbxkertoo rekisterienraxjarbxarvot ja sijoittaa tulokseen rekisteriinrax. Tämä syntaksi vastaa komentojenaddjasubkäyttämää syntaksia. -
Kolmen operandin syntaksi:
imul rax, rbx, 42kertoo rekisterinrbxarvon 42:lla ja sijoittaa tuloksen rekisteriinrax. Tässä komennon kolmas operandi on vakioarvo.
Huomaa, että useamman operandin syntaksi on mahdollinen vain komennossa imul, eikä sitä voi käyttää komennoissa mul, div tai idiv.
Komennon imul useamman operandin syntaksissa kertolaskun tulosta ei esitetä kahden rekisterin yhdistelmänä vaan vain yhdessä rekisterissä. Jos tulos ei mahdu kokonaan yhteen rekisteriin, tuloksesta jää talteen vain alempi puolisko ja tuloksen ylempi puolisko katoaa.
Useamman operandin imul vastaa sitä, miten kertolasku on yleensä toteutettu korkeamman tason ohjelmointikielissä. Esimerkiksi seuraavat koodit toimivat samalla tavalla:
int a, b;
a = 123;
b = 456;
a = a * b; // 56088
a = 123456789;
b = 987654321;
a = a * b; // -67153019
mov eax, 123
mov ebx, 456
imul eax, ebx ; 56088
mov eax, 123456789
mov ebx, 987654321
imul eax, ebx ; -67153019
Molemmissa koodeissa sekä kerrottavat luvut että kertolaskun tulos ovat 32-bittisiä. Ensimmäisen kertolaskun tulos 56088 mahtuu 32-bittiseen muuttujaan ja rekisteriin, kun taas toisen kertolaskun tulos näkyy muodossa –67153019, koska 32 bittiä ei riitä sen esittämiseen.
Vaikka komento imul on varsinaisesti tarkoitettu etumerkillisten lukujen kertolaskuun, komennon useamman operandin syntaksia voi käyttää myös etumerkittömien lukujen kertolaskussa. Syynä tähän on, että lukujen etumerkillisyys ei bittitasolla vaikuta kertolaskun tuloksen jälkipuoliskoon. Tämän takia ei ole merkittävä puute, ettei komennolla mul ole vastaavaa useamman operandin syntaksia.
Jakolaskussa tulee ottaa huomioon, että jaettava luku esitetään aina kahden rekisterin yhdistelmänä. Tämän takia ylempi rekisteri tulee alustaa oikealla tavalla silloinkin, kun jaettava luku mahtuu kokonaan alempaan rekisteriin. Esimerkiksi kun rax sisältää jaettavan luvun, myös rdx tulee alustaa ennen jakolaskua.
Etumerkittömässä jakolaskussa riittää nollata ylempi rekisteri. Esimerkiksi seuraava koodi jakaa rekisterin rax arvon rekisterin rbx arvolla:
mov rdx, 0
div rbx
Etumerkillisessä jakolaskussa etumerkki vaikuttaa ylemmän rekisterin alustukseen. Tässä tapauksessa voidaan käyttää komentoa cqo, joka laajentaa rekisterissä rax olevan etumerkillisen luvun oikealla tavalla rekistereihin rdx:rax:
cqo
idiv rbx
Komento cqo on osa laajempaa komentoperhettä, jotka laajentavat etumerkillisen luvun suurempaan rekisteriin:
- Komento
cbwtekee laajennuksenal→ax - Komento
cwdtekee laajennuksenax→dx:ax - Komento
cwdetekee laajennuksenax→eax - Komento
cdqtekee laajennukseneax→edx:eax - Komento
cdqetekee laajennukseneax→rax - Komento
cqotekee laajennuksenrax→rdx:rax
Huomaa, että komennot käsittelevät aina kiinteitä rekistereitä eikä niissä esiintyviä rekistereitä ole mahdollista muuttaa.
Bittioperaatiot
Komennot and, or, xor ja not suorittavat vastaavan bittioperaation. Esimerkiksi komento and rax, rbx sijoittaa rekisteriin rax and-operaation tuloksen rekisterien rax ja rbx arvoille.
Klassinen trikki konekielisessä ohjelmoinnissa on nollata rekisterin arvo xor-operaatiolla. Esimerkiksi komento xor rax, rax nollaa rekisterin rax arvon, koska kahden saman luvun xor-operaation tuloksena on aina nolla.
Trikin hyötynä on, että xor-komento voi olla tehokkaampi ja viedä vähemmän tilaa tavutasolla kuin vastaava mov-komento.
Esimerkiksi komennon mov eax, 0 (vastaa mov rax, 0) tavuesitys on b8 00 00 00 00, kun taas komennon xor rax, rax tavuesitys on 48 31 c0, eli tässä tapauksessa xor-komento säästää tilaa kaksi tavua.
Etumerkillinen luku voidaan muuttaa käänteiseksi komennoilla not ja inc, mutta tätä varten on myös erillinen komento neg, joka on käytännössä parempi tapa tehdä muutos. Seuraavat koodit toimivat samalla tavalla:
not rax
inc rax
neg rax
Komennot shl ja shr suorittavat bittisiirron vasemmalle ja oikealle. Näitä komentoja voidaan hyödyntää toteuttamaan helpommin tietynlaiset kerto- ja jakolaskut. Esimerkiksi komento shl rax, 1 kertoo rekisterin rax arvon kahdella ja komento shr rbx, 2 jakaa rekisterin rbx arvon neljällä.
Tarkemmin ottaen komento shr soveltuu jakolaskuun vain luvun ollessa etumerkitön, koska se lisää luvun alkuun nollabittejä. Saatavilla on kuitenkin toinen komento sar (aritmeettinen bittisiirto), joka lisää luvun alkuun etumerkin mukaista bittiä ja toimii oikein luvun ollessa etumerkillinen.
Kertolaskun tapauksessa luvun etumerkillisyydellä ei ole vaikutusta ja komento shl soveltuu sekä etumerkittömän että etumerkillisen luvun kertolaskuun. Tästäkin komennosta on olemassa aritmeettinen versio sal, mutta komennot shl ja sal toimivat aina samalla tavalla.
Komennot rol ja ror pyörittävät luvun bittejä vasemmalle ja oikealle. Komento rol (vasen pyöritys) siirtää bittejä luvun alusta luvun lopuun, ja vastaavasti komento ror (oikea pyöritys) siirää bittejä luvun lopusta luvun alkuun. Esimerkiksi komento rol rax, 1 pyörittää rekisterin rax bittejä askeleen vasemmalle.
Huomaa, että C-kielen standardissa ei ole operaatioita bittipyörityksiin, vaikka useissa konekielissä on komennot niitä varten.
Bittisiirroissa ja bittipyörityksissä on erityistä, että jos toinen operandi (askelten määrä) annetaan rekisterissä, tässä tilanteessa ainoa sallittu rekisteri on 8-bittinen rekisteri cl. Esimerkiksi komennot shl rax, cl ja ror rax, cl tekevät bittisiirron ja bittipyörityksen rekisterin cl askelten määrän perusteella.
Muistin osoittaminen
Katsotaan vielä tarkemmin erilaisia tapoja osoittaa muistia. Aloitetaan seuraavasta koodista:
section .data
number dq 42
section .text
mov rax, number
mov rbx, [number]
Tämä koodi sijoittaa rekisteriin rax muistiosoitteen number ja rekisteriin rbx muistiosoitteessa number olevan 64-bittisen luvun. C-kielen näkökulmasta voitaisiin ajatella, että number on osoitin muistissa olevaan muuttujaan ja [number] puolestaan tarkoittaa samaa kuin C-kielessä syntaksi *number.
Tarkastellaan sitten seuraavaa koodia:
section .data
table dd 3, 1, 4, 1, 5, 9, 2, 6
section .text
mov rbx, table
mov rcx, 2
mov eax, [rbx + 4 * rcx]
Tämä koodi sijoittaa rekisteriin rbx taulukon table muistiosoitteen ja rekisteriin rcx haettavan alkion indeksin. Sitten koodi hakee rekisteriin eax taulukon table alkion osoitteesta rbx + 4 * rcx. Tässä taulukon alkiot ovat 32-bittisiä (tietoyksikkö dd) eli muistiosoituksen kerroin 4 vastaa alkion kokoa tavuina.
Yleisemmin x86-konekielen komennoissa 64-bittinen muistiosoite voidaan ilmaista kaavalla base + (scale * index) + offset, jonka osat ovat:
baseon 64-bittinen rekisteri (”taulukon alkuosoite”)scaleon 1, 2, 4 tai 8 (”alkion koko tavuina”)indexon 64-bittinen rekisteri (”alkion indeksi”)offseton kiinteä lukuarvo (”siirto”)
Esimerkiksi komennossa mov eax, [rbx + 4 * rcx] taulukon alkuosoite on rbx, alkion koko on 4 tavua, alkion indeksi on rcx ja siirto puuttuu (eli se on 0).
Myös komento mov rbx, [number] vastaa yllä olevaa kaavaa, kun muistiosoite number esitetään siirtona ja kaikki muut osat puuttuvat.
Komento lea (load effective address) sijoittaa rekisteriin tietyn kaavan tuottaman muistiosoitteen. Esimerkiksi koodi
lea rax, [rbx + 4 * rcx]
sijoittaa rekisteriin rax muistiosoitteen, joka saadaan kaavalla rbx + 4 * rcx. Toisin kuin komento mov, komento lea ei käsittele muistissa olevaa tietoa vaan vain laskee, missä osoitteessa tieto sijaitsee.
Vaikka komento lea on tarkoitettu muistiosoitteen laskemiseen, mikään ei estä käyttämästä sitä myös muuhun laskemiseen. Esimerkiksi äskeinen koodi laskee rekisteriin rax lausekkeen rbx + 4 * rcx arvon, vaikka tämä ei olisi mikään todellinen ohjelman käytettävissä oleva muistiosoite. Tämän ansiosta seuraavat koodit tuottavat saman tuloksen:
lea rax, [rbx + 4 * rcx]
mov rax, rcx
shl rax, 2
add rax, rbx
Komentoa lea pystyy siis käyttämään joissakin tilanteissa lyhentämään ja tehostamaan koodia. Tässä on oleellista, että lea vain laskee muistiosoitteen eikä yritä käsitellä muistia kyseisessä osoitteessa.
Seuraavat komennot toimivat suunnilleen samalla tavalla:
mov rax, numberlea rax, [number]
Kumpikin komento sijoittaa rekisteriin rax muistiosoitteen number. Kuitenkin erona on, että mov-komento sallii 64-bittisen muistiosoitteen, kun taas lea-komennossa muistiosoite (eli vakiona annettu ”siirto” kaavassa, joka laskee muistiosoitteen) voi olla vain 32-bittinen.
Komento mov rax, [number] toimii myös muistiosoitteen ollessa 64-bittinen, mutta tämä on rekisteriin rax liittyvä poikkeus eikä komento toimi muilla rekistereillä (kuten rbx tai rsi). Tämä on muistutus siitä, että x86-konekielessä on melkein loputtomasti poikkeuksia ja yllätyksiä.
Hyppykomento
Konekielisessä ohjelmoinnissa haarautuminen ja silmukat toteutetaan hyppykomennon avulla, joka siirtää koodin suoritukseen toiseen kohtaan koodia. Konekielessä on useita erilaisia hyppykomentoja, joissa on erilaisia ehtoja sille, missä tilanteessa hyppy tapahtuu.
Yksinkertaisin hyppykomento on jmp, joka suorittaa hypyn ilman ehtoa. Komentoa voidaan käyttää esimerkiksi seuraavasti:
mov rax, 0
jmp skip
inc rax
skip:
mov rbx, rax
Yllä olevassa koodissa komento jmp skip hyppää nimiöön skip, minkä takia komentoa inc rax ei suoriteta.
Konekielen komento jmp vastaa siis C-kielen komentoa goto. C-kielessä komento goto on melko harvinainen, koska ohjausrakenteet voidaan yleensä toteuttaa paremmin muilla tavoilla. Konekielessä kuitenkin hyppykomentoja käytetään jatkuvasti, koska muita tapoja ei ole.
Ehdollinen hyppy
Ehdollinen hyppy voidaan toteuttaa kahden komennon yhdistelmänä. Ensin käytetään komentoa cmp, joka vertailee kahta arvoa toisiinsa. Tämän jälkeen käytetään ehdollista hyppykomentoa, joka päättää vertailun tuloksen perusteella, tapahtuuko hyppy vai jatkuuko koodin suoritus eteenpäin.
Ehdollisen hypyn voi toteuttaa esimerkiksi näin:
cmp rax, 0
je is_zero
Tässä komento cmp rax, 0 vertaa toisiinsa rekisterin rax arvoa ja lukua 0. Hyppykomento je (jump if equal) suorittaa hypyn nimiöön is_zero, jos vertailun tuloksena on, että arvot ovat yhtä suuret.
Hyppykomentojen avulla voidaan suorittaa vastaavat vertailut kuin korkeamman tason ohjelmointikielissä. Kun on suoritettu ensin komento muotoa cmp X, Y, voidaan käyttää seuraavia hyppykomentoja:
| Komento | Hypyn ehto |
|---|---|
je |
X == Y |
jne |
X != Y |
jg / ja |
X > Y |
jge / jae |
X >= Y |
jl / jb |
X < Y |
jle / jbe |
X <= Y |
Kun taulukon rivillä on kaksi komentoa, ensimmäistä komentoa käytetään etumerkillisten lukujen kanssa ja toista komentoa etumerkittömien lukujen kanssa. Esimerkiksi komentoa jg käytetään etumerkillisten lukujen kanssa ja komentoa ja käytetään etumerkittömien lukujen kanssa.
Kun verrataan lukujen järjestystä, vertailu tulee tehdä eri tavalla etumerkillisille ja etumerkittömille luvuille, minkä takia konekielessä käytetään eri komentoja riippuen lukujen bittiesitysten tulkinnasta.
Esimerkiksi jos verrataan tavuja X = 00000000 ja Y = 10101010, lukujen etumerkillisyys vaikuttaa näin:
- Jos luvut ovat etumerkillisiä,
X= 0 jaY= –86, jotenX > Y - Jos luvut ovat etumerkittömiä,
X= 0 jaY= 170, jotenX < Y
Yllä olevat komennot voidaan antaa myös käänteisessä muodossa: esimerkiksi komento jg voidaan antaa myös muodossa jnle ja komento jge voidaan antaa myös muodossa jnl. Nämä ovat assemblerin tarjoamia vaihtoehtoisia nimiä, jotka viittaavat tavutasolla samaan konekieliseen komentoon.
Silmukka
Silmukka voidaan toteuttaa yhdistämällä ehdollinen hyppykomento ja rekisteri, jonka arvoa muutetaan silmukassa. Esimerkiksi seuraavasti:
mov rax, 0
mov rcx, 1
loop_start:
add rax, rcx
inc rcx
cmp rcx, 100
jle loop_start
Tämä silmukka laskee summan 1 + 2 + … + 100 rekisteriin rax käyttäen apuna rekisteriä rcx. Silmukka toimii seuraavasti:
- Ennen silmukkaa rekisteriin
rcxlaitetaan aloitusarvo 1. - Nimiö
loop_startilmaisee silmukan aloituskohdan. - Silmukan sisällä komento
addlisää rekisterinrcxarvon rekisterinraxarvoon ja komentoinckasvattaa rekisterinrcxarvoa yhdellä. - Silmukan lopussa komento
jlesiirtää suorituksen silmukan alkuun, jos rekisterinrcxarvo on enintään 100. Muuten silmukka päättyy ja suoritus siirtyy eteenpäin koodissa.
Käytännössä silmukan voi toteuttaa monella vähän erilaisella tavalla riippuen siitä, missä vaiheessa koodi muuttaa ja vertailee rekisteriä sekä millä komennolla tai komennoilla hypyt on toteutettu.
Seuraavassa on toisenlaista silmukkaa käyttävä esimerkkiohjelma, joka tulostaa komentoriville merkit a–z merkki kerrallaan.
letters.asm
global _start
section .data
letter db 'a'
section .text
_start:
mov rax, 1 ; write
mov rdi, 1 ; stdout
mov rdx, 1 ; length
mov rsi, letter
loop_start:
syscall ; print letter
cmp byte [letter], 'z' ; last letter reached?
je finish
inc byte [letter] ; move to next letter
jmp loop_start
finish:
mov byte [letter], 10
syscall ; print newline
mov rax, 60 ; exit
mov rdi, 0
syscall
Ohjelman tulostus on seuraava:
$ ./letters
abcdefghijklmnopqrstuvwxyz
Ohjelma muodostuu seuraavista osista:
Rekisterien valmistelu:
mov rax, 1 ; write
mov rdi, 1 ; stdout
mov rdx, 1 ; length
mov rsi, letter
Tämä koodi valmistelee rekisterit tekstiä tulostavaa systeemikutsua varten. Rekisteri rsi sisältää tekstin osoitteen muistissa ja rekisteri rdx kertoo tulostettavien merkkien määrän. Näitä rekisterien arvoja käytetään ohjelmassa useissa systeemikutsuissa, jotka tulostavat yhden merkin kerrallaan.
Tulostussilmukka:
loop_start:
syscall ; print letter
cmp byte [letter], 'z' ; last letter reached?
je finish
inc byte [letter] ; move to next letter
jmp loop_start
Tämä koodi toteuttaa silmukan seuraavasti:
syscallsuorittaa systeemikutsun, joka tulostaa yhden merkin.cmp byte [letter], 'z'vertailee tulostettua merkkiä ja merkkiäz.je finishhyppää nimiöönfinish, jos merkit ovat samat.inc byte [letter]kasvattaa merkkiä yhdellä eli siirtyy seuraavaan merkkiin.jmp loop_starthyppää silmukan alkuun.
Rivinvaihto ja ohjelman lopetus:
finish:
mov byte [letter], 10
syscall ; print newline
mov rax, 60 ; exit
mov rdi, 0
syscall
Tämä koodi tulostaa rivinvaihdon systeemikutsulla ja suorittaa sitten toisen systeemikutsun, joka päättää ohjelman.
Hyppykomennon toiminta
Miten hyppykomento tietää, minkä tuloksen komennon cmp tekemä vertailu on tuottanut? Tämä tieto välittyy prosessorin lippurekisterissä (rflags), jossa olevat bitit antavat tietoa ohjelman komentojen tuloksista.
Kun koodissa on komento cmp X, Y, prosessori suorittaa laskun X - Y ja päivittää lippurekisteriä tämän laskun tuloksen perusteella. Sitten hyppykomento päättelee lippurekisterin sisällöstä, tuleeko ehdollista hyppyä tehdä.
Esimerkiksi hyppykomento je (jump if equal) suorittaa hypyn siinä tapauksessa, että lippurekisterin nollalippu (zero flag) on asetettu. Nollalippu on asetettu, jos viimeksi tehdyn laskun tuloksena oli 0. Komennon cmp jälkeen tämä tarkoittaa sitä, että vertailtavat arvot X ja Y ovat yhtä suuria.
Seuraavassa koodissa komennon cmp tulos ei välity komennolle je, koska komentojen välissä on toinen komento inc:
cmp rax, 0
inc rax
je is_zero
Komento inc rax päivittää lippurekisteriä, minkä takia komennon cmp rax, 0 tuottama tulos ei ole enää saatavilla. Yllä oleva koodi suorittaa hypyn, jos komennon inc rax jälkeen rekisterin rax arvona on 0.
Koska komennon cmp lisäksi muutkin komennot päivittävät lippurekisteriä, silmukan voi joissain tilanteissa toteuttaa lyhyemmin ilman komentoa cmp. Esimerkiksi seuraava silmukka käy läpi luvut 10:stä 1:een:
mov rcx, 10
loop_start:
dec rcx
jnz loop_start
Tässä komento dec rcx vähentää rekisterin arvoa ja samalla päivittää lippurekisteriä. Komento jnz suorittaa hypyn, jos nollalippua ei ole asetettu eli komennon dec rcx tuloksena rcx ei ole 0. Toisin sanoen tässä silmukassa hyppy tapahtuu, jos rekisterin rcx sisältönä on suurempi luku kuin 0.
Komento jnz (jump if not zero) on tavutasolla sama kuin komento jne (jump if not equal). Nämä komennot toimivat samalla tavalla, koska ne suorittavat hypyn, jos nollalippua ei ole asetettu.
Yllä olevassa koodissa on luontevampaa käyttää komentoa jnz, koska ajatuksena on tehdä hyppy, kun edellisen komennon tuloksena ei ole 0. Komento jne olisi tässä hankalammin ajateltava, koska koodi ei vertaa yhtäsuuruutta.
Yllä olevaa silmukkaa voi tiivistää entisestään komennolla loop:
mov rcx, 10
loop_start:
loop loop_start
Komento loop vastaa komentojen dec ja jnz yhdistelmää ja olettaa, että silmukassa on käytössä rekisteri rcx. Komentoa loop ei kuitenkaan käytetä yleensä, koska se on erillisiä komentoja hitaampi. Komento on myös rajoittunut alaspäin menevään silmukkaan, jossa on käytössä rekisteri rcx.
Nollalipun lisäksi usein käytettyjä lippurekisterin lippuja ovat:
- Merkkilippu (sign flag) ilmaisee, onko laskun tulos negatiivinen.
- Muistilippu (carry flag) ilmaisee ylivuodon laskun tuloksessa, kun käsitellään etumerkittömiä lukuja.
- Ylivuotolippu (overflow flag) ilmaisee ylivuodon laskun tuloksessa, kun käsitellään etumerkillisiä lukuja.
Esimerkiksi seuraavassa koodissa komento jc (jump if carry) suorittaa hypyn, jos laskussa tapahtuu etumerkitön ylivuoto:
add rax, rcx
jc overflow
Itse asiassa komento jc on toinen nimi komennolle jb, joka suorittaa hypyn tapauksessa X < Y etumerkittömillä luvuilla. Tässä muistilipun arvo kertoo, tuleeko hyppy tehdä, koska komennon cmp X, Y suorittamassa laskussa X - Y tapahtuu ylivuoto tarkalleen ehdon X < Y pätiessä.
Systeemikutsut
Linuxissa ja muissa moderneissa käyttöjärjestelmissä ohjelmat eivät saa käsitellä tietokoneen laitteistoa vapaasti vaan ne toimivat käyttöjärjestelmän alaisuudessa. Esimerkiksi kun ohjelma haluaa lukea tietoa näppäimistöltä tai tulostaa tietoa näytölle, sen täytyy pyytää tähän lupa käyttöjärjestelmältä.
Linuxissa 64-bittinen ohjelma viestii käyttöjärjestelmän kanssa systeemikutsujen kautta. Kun ohjelma haluaa tehdä jotain käyttöjärjestelmän valvomaa, se suorittaa systeemikutsun komennolla syscall. Systeemikutsun numero annetaan rekisterissä rax ja parametrit annetaan rekistereissä järjestyksessä rdi, rsi, rdx, r10, r8, r9. Systeemikutsun jälkeen kutsun paluuarvo tulee rekisteriin rax.
Kaikki systeemikutsut on listattu Linuxin lähdekoodissa ja verkossa on useita sivustoja, jotka kertovat tarkemmin systeemikutsujen käyttämisestä.
Esimerkkejä systeemikutsuista
Systeemikutsu 0 (read):
Systeemikutsu 0 lukee tietoa annetusta tiedostosta. Kutsun parametrit ovat tiedostotunnus (rdi), osoitin muistialueeseen (rsi) sekä luettavan tiedon enimmäismäärä tavuina (rdx). Kutsun paluuarvo on luettujen tavujen määrä tai negatiivinen arvo virhetilanteessa.
Esimerkiksi seuraava koodi lukee käyttäjältä enintään 16 tavua tietoa puskuriin buffer. Tiedostotunnus 0 tarkoittaa standardisyötettä (stdin).
mov rax, 0 ; read
mov rdi, 0 ; stdin
mov rsi, buffer
mov rdx, 16
syscall
Systeemikutsu 1 (write):
Systeemikutsu 1 kirjoittaa tietoa annettuun tiedostoon. Kutsun parametrit ovat tiedostotunnus (rdi), osoitin muistialueeseen (rsi) sekä kirjoitettavan tiedon määrä tavuina (rdx). Kutsun paluuarvo on kirjoitettujen tavujen määrä tai negatiivinen arvo virhetilanteessa.
Esimerkiksi seuraava koodi tulostaa käyttäjälle 16 tavua tietoa puskurista buffer. Tiedostotunnus 1 viittaa standarditulostukseen (stdout).
mov rax, 1 ; write
mov rdi, 1 ; stdout
mov rsi, buffer
mov rdx, 16
syscall
Systeemikutsu 60 (exit):
Systeemikutsu 60 sulkee ohjelman. Kutsun ainoa parametri on ohjelman paluuarvo ( rdi). Esimerkiksi seuraava koodi sulkee ohjelman paluuarvolla 0.
mov rax, 60 ; exit
mov rdi, 0
syscall
Systeemikutsujen seuraaminen
Linuxissa komento strace näyttää ohjelman tekemät systeemikutsut. Esimerkiksi luvun 9 esimerkkiohjelman suoritus tuottaa seuraavan raportin:
$ strace ./test
execve("./test", ["./test"], 0x7ffeac6f79e0 /* 19 vars */) = 0
write(1, "All your base are belong to us\n", 31All your base are belong to us
) = 31
exit(0) = ?
+++ exited with 0 +++
Tässä systeemikutsu execve käynnistää ohjelman ja systeemikutsut write ja exit ovat varsinaiset ohjelman tekemät systeemikutsut.
Systeemikutsu vs. keskeytys
Komento syscall on moderni tapa suorittaa systeemikutsu 64-bittisessä Linuxissa. Perinteinen 32-bittisessä Linuxissa käytettävä tapa on suorittaa sen sijaan komento int 0x80, joka aiheuttaa keskeytyksen (interrupt). Tämä keskeyttää ohjelman ja siirtää suorituksen käyttöjärjestelmälle systeemikutsua varten.
Esimerkiksi seuraava 32-bittinen koodi suorittaa systeemikutsun write:
mov eax, 4 ; write
mov ebx, 1 ; stdout
mov ecx, buffer
mov edx, 128
int 0x80
Koodissa on seuraavat erot verrattuna 64-bittiseen koodiin:
- Systeemikutsun parametrit välitetään 32-bittisissä rekistereissä eikä 64-bittisissä rekistereissä.
- Systeemikutsun
writenumero on 4 eikä 1. Systeemikutsut on numeroitu eri tavalla 32-bittisessä ja 64-bittisessä Linuxissa. - Komennon
syscallsijasta käytetään komentoaint 0x80.
Tässä 0x80 on keskeytyksen numero, joka tarkoittaa systeemikutsun suorittamista. Yleisemmin keskeytykset liittyvät tietokoneen matalan tason toimintaan (esimerkiksi muistin ja laitteiston käsittely), eikä käyttöjärjestelmän alaisuudessa toimiva ohjelma saa kutsua tällaisia keskeytyksiä.
Konekielinen ohjelmointi oli ennen varsin erilaista, koska ohjelma pystyi käsittelemään suoraan tietokoneen muistia ja laitteistoa ilman käyttöjärjestelmän valvontaa.
Seuraava MS-DOS-ohjelma antaa näytteen vanhan ajan ohjelmoinnista. Ohjelma piirtää punaisen pikselin näytön keskelle kutsumalla keskeytyksiä ja käsittelemällä suoraan näyttömuistin sisältöä.
org 100h
section .text
start:
mov ax, 0x0013 ; 320x200 resolution, 256-color palette
int 0x10 ; set graphics mode
mov ax, 0xa000 ; video memory segment
mov es, ax ; configure segment register
mov di, 32160 ; pixel location: screen center
mov al, 40 ; pixel color: red
mov [es:di], al ; draw pixel
mov ah, 0x00
int 0x16 ; wait for keyboard input
mov ax, 0x0003
int 0x10 ; restore text mode
mov ax, 0x4c00
int 0x21 ; quit program
Koodi toimii seuraavasti:
- Koodi asettaa grafiikkatilan kutsumalla keskeytystä
0x10(näytön resoluutio 320x200 pikseliä, käytettävissä 256 väriä). - Koodi piirtää punaisen pikselin muuttamalla näyttömuistin sisältöä. Segmentissä
0xa000oleva muistialue (320 · 200 = 64000 tavua) vastaa näytön sisältöä: jokainen tavu ilmaisee tietyn pikselin värin. - Koodi odottaa näppäimen painallusta (keskeytys
0x16), asettaa sitten takaisin tekstitilan (keskeytys0x10) ja sulkee ohjelman (keskeytys0x21).
Kertoman laskeminen
Tehdään seuraavaksi hieman monimutkaisempi konekielinen ohjelma, joka kysyy käyttäjältä luvun ja ilmoittaa sitten tämän luvun kertoman. Tavoitteena on, että ohjelmaa voi käyttää seuraavasti:
$ ./factorial
5
120
$ ./factorial
20
2432902008176640000
Ohjelman toteuttamisessa konekielellä on yllättävä vaikeus: Linuxin systeemikutsut käsittelevät lukuja merkkimuodossa, mutta laskutoimituksissa lukujen tulee olla binäärimuodossa. Esimerkiksi luku 120 muodostuu merkkimuodossa tavuista 31 32 30 (merkit '1', '2' ja '0') mutta 64-bittisenä binäärilukuna se muodostuu tavuista 78 00 00 00 00 00 00 00.
Itse asiassa kertoman laskemisen voi toteuttaa konekielellä varsin lyhyesti, mutta lukujen esitystavan muunnokset vaativat paljon enemmän koodia. Seuraavassa koodissa on kaksi aliohjelmaa muunnoksia varten:
read_numberlukee luvun käyttäjältä rekisteriinrax.print_numbertulostaa rekisterissäraxolevan luvun käyttäjälle.
Koodi kutsuu aliohjelmia komennolla call ja käyttää komentoa ret aliohjelmista takaisin palaamiseen. Tutustumme materiaalin seuraavassa luvussa tarkemmin siihen, miten nämä komennot toimivat.
factorial.asm
global _start
section .bss
buffer resb 20
section .text
_start:
call read_number
mov rcx, rax ; set loop counter
mov rax, 1 ; initialize result
loop_start:
cmp rcx, 1 ; check if result ready
jle ready
mul rcx ; multiply current result by counter
dec rcx ; decrease counter
jmp loop_start
ready:
call print_number
mov rax, 60 ; exit
mov rdi, 0
syscall
read_number:
mov rax, 0 ; read
mov rdi, 0 ; stdin
mov rsi, buffer ; input buffer
mov rdx, 19 ; max input length
syscall
mov byte [buffer+19], 10 ; add newline at end
mov rax, 0 ; initialize result
mov rbx, 10 ; decimal base
mov rcx, buffer ; pointer to first digit
read_digit:
cmp byte [rcx], 10 ; check for end of input
je read_ready
mul rbx ; multiply result by 10
mov dl, [rcx] ; load current digit
sub dl, '0' ; convert ASCII to numeric value
add rax, rdx ; add digit to result
inc rcx ; move to next digit
jmp read_digit
read_ready:
ret
print_number:
lea rcx, [buffer+19] ; pointer to end of buffer
mov byte [rcx], 10 ; add newline at end
mov rbx, 10 ; decimal base
print_digit:
mov rdx, 0
div rbx ; extract last digit
dec rcx ; move buffer pointer
add dl, '0' ; convert numeric value to ASCII
mov [rcx], dl ; store digit in buffer
cmp rax, 0 ; check if conversion ready
jne print_digit
mov rax, 1 ; write
mov rdi, 1 ; stdout
mov rsi, rcx ; buffer position
lea rdx, [buffer+20]
sub rdx, rcx ; number length
syscall
ret
Pääohjelma
Seuraava koodi kutsuu aliohjelmia ja laskee kertoman:
_start:
call read_number
mov rcx, rax ; set loop counter
mov rax, 1 ; initialize result
loop_start:
cmp rcx, 1 ; check if result ready
jle ready
mul rcx ; multiply current result by counter
dec rcx ; decrease counter
jmp loop_start
ready:
call print_number
Koodi kutsuu ensin aliohjelmaa read_number, joka lukee luvun käyttäjältä. Aliohjelman päätteeksi käyttäjältä luettu luku on rekisterissä rax.
Tämän jälkeen koodi laskee luvun kertoman silmukan avulla. Koodissa on silmukka, jossa rekisteri rcx käy läpi kerrottavat luvut.
Lopuksi koodi kutsuu aliohjelmaa print_number, joka tulostaa luvun käyttäjälle. Aliohjelma olettaa, että tulostettava luku annetaan rekisterissä rax.
Luvun lukeminen
Aliohjelma read_number lukee luvun käyttäjältä:
read_number:
mov rax, 0 ; read
mov rdi, 0 ; stdin
mov rsi, buffer ; input buffer
mov rdx, 19 ; max input length
syscall
mov byte [buffer+19], 10 ; add newline at end
mov rax, 0 ; initialize result
mov rbx, 10 ; decimal base
mov rcx, buffer ; pointer to first digit
read_digit:
cmp byte [rcx], 10 ; check for end of input
je read_ready
mul rbx ; multiply result by 10
mov dl, [rcx] ; load current digit
sub dl, '0' ; convert ASCII to numeric value
add rax, rdx ; add digit to result
inc rcx ; move to next digit
jmp read_digit
read_ready:
ret
Koodi käyttää puskuria buffer, johon luetaan aluksi käyttäjän syöte systeemikutsulla. Tämän jälkeen koodi käy läpi puskurissa olevat numerot vasemmalta oikealle rekisterin rcx avulla ja muodostaa luvun rekisteriin rax. Kun puskurissa tulee vastaan rivinvaihto, tämä päättää luvun lukemisen.
Koodin ideana on kertoa kunkin numeron kohdalla rekisterissä rax oleva luku 10:llä ja lisätä siihen käsiteltävä numero. Esimerkiksi kun käyttäjä antaa luvun 123, logiikka on seuraava:
- Rekisterissä
raxon ensin luku 0. - Numeron 1 käsittelyssä luvusta tulee 0 · 10 + 1 = 1.
- Numeron 2 käsittelyssä luvusta tulee 1 · 10 + 2 = 12.
- Numeron 3 käsittelyssä luvusta tulee 12 · 10 + 3 = 123.
Koska numerot tulevat käyttäjältä merkkeinä, koodissa on komento sub dl, '0' joka muuttaa merkkikoodin vastaavaksi lukuarvoksi.
Luvun tulostaminen
Aliohjelma print_number tulostaa luvun käyttäjälle:
print_number:
lea rcx, [buffer+19] ; pointer to end of buffer
mov byte [rcx], 10 ; add newline at end
mov rbx, 10 ; decimal base
print_digit:
mov rdx, 0
div rbx ; extract last digit
dec rcx ; move buffer pointer
add dl, '0' ; convert numeric value to ASCII
mov [rcx], dl ; store digit in buffer
cmp rax, 0 ; check if conversion ready
jne print_digit
mov rax, 1 ; write
mov rdi, 1 ; stdout
mov rsi, rcx ; buffer position
lea rdx, [buffer+20]
sub rdx, rcx ; number length
syscall
ret
Tämän koodin toiminta on käänteinen verrattuna luvun lukemiseen. Koodi muodostaa puskuriin buffer rekisterissä rax olevaa lukua vastaavan merkkijonon. Koodi käyttää silmukkaa, joka etenee oikealta vasemmalle rekisterin rcx avulla.
Koodi jakaa joka vaiheessa rekisterin rax arvon 10:llä ja siirtää jakojäännöksen tulostettavaan lukuun. Näin jatketaan, kunnes rekisterin rax arvoksi on tullut nolla. Esimerkiksi koodi tulostaa luvun 123 seuraavasti:
- Tulostettava luku on alussa tyhjä.
- Koodi laskee jakolaskun 123 / 10: tulos 12 ja jakojäännös 3. Numero 3 menee tulostettavan luvun alkuun.
- Koodi laskee jakolaskun 12 / 10: tulos 1 ja jakojäännös 2. Numero 2 menee tulostettavan luvun alkuun.
- Koodi laskee jakolaskun 1 / 10: tulos 0 ja jakojäännös 1. Numero 1 menee tulostettavan luvun alkuun.
Koodi muuttaa luvun jakojäännöksen merkiksi komennolla add dl, '0', joka on myös käänteinen luvun lukemiseen verrattuna.
C-funktioiden kutsuminen
C-kielessä luvun lukeminen ja tulostaminen on helppoa käyttäen standardikirjaston funktioita scanf ja printf. Nämä funktiot hoitavat muunnokset luvun merkkimuodon ja binäärimuodon välillä, eikä ohjelmoijan tarvitse huolehtia asiaa.
Voimme käyttää funktioita scanf ja printf myös konekielisessä ohjelmassa, kunhan ohjelman käännöksessä linkitetään mukaan C:n standardikirjasto. Seuraava koodi toteuttaa kertoman laskemisen näiden funktioiden avulla:
factorial.asm
global main
extern scanf
extern printf
extern exit
section .data
scanf_format db "%ld", 0
printf_format db "%ld", 10, 0
number dq 0
section .text
main:
push rbp
lea rdi, [scanf_format] ; parameter 1: format string
lea rsi, [number] ; parameter 2: pointer to store result
mov rax, 0 ; no vector parameters
call scanf
mov rcx, [number] ; set loop counter
mov rax, 1 ; initialize result
loop_start:
cmp rcx, 1 ; check if result ready
jle ready
mul rcx ; multiply current result by counter
dec rcx ; decrease counter
jmp loop_start
ready:
lea rdi, [printf_format] ; parameter 1: format string
mov rsi, rax ; parameter 2: number to print
mov rax, 0 ; no vector parameters
call printf
mov rdi, 0
call exit
pop rbp
ret
Ohjelma kutsuu ulkoisia funktioita scanf, printf ja exit, jotka kuuluvat C-kielen standardikirjastoon. Koodi on myös rakennettu C-ohjelman pääfunktion muotoon niin, että koodin suoritus alkaa nimiöstä main.
Seuraavat komennot kääntävät ja linkittävät ohjelman:
$ nasm -f elf64 factorial.asm -o factorial.o
$ gcc -no-pie factorial.o -o factorial
Ensimmäinen komento kääntää ohjelman NASM-kääntäjällä samalla tavalla kuin aiemminkin. Toinen komento linkittää koodin gcc:llä, jolloin siihen tulee mukaan C-kielen standardikirjasto.
Käännöslippu -no-pie tarkoittaa, että koodi on toteutettu perinteisellä tavalla eikä se ole PIE-yhteensopiva (Position Independent Executable). PIE on ohjelman turvallisuutta parantava tekniikka, jossa käyttöjärjestelmä lataa ohjelman muistiin satunnaiseen osoitteeseen kiinteän osoitteen sijasta.
PIE vaatii esimerkiksi, että osoitteet .data-osion muistialueille ilmoitetaan suhteessa ohjelmarekisterin rip arvoon. Tämän voi toteuttaa käytännössä rel-avainsanan avulla seuraavasti:
lea rsi, [rel number]
Tämä koodi tuottaa lea-komennon, joka ei sisällä kiinteää osoitetta number vaan siirtymän rekisterin rip ja osoitteen number välillä.
Toteutamme tällä kurssilla konekieliset esimerkkiohjelmat perinteisellä tavalla, koska se on helpompaa konekieliohjelmoinnin opettelussa.