Zbirnik, zbirni jezik, zbiranje

Kaj se bomo v tem poglavju naučili

Spoznali bomo, kaj je pravzaprav zbirni jezik. Glede na to, da se zbirni jeziki med seboj razlikujejo, bomo na konceptualnem nivoju spoznali, kako z njim deklariramo ukaze in podatke ter katere korake moramo narediti, da pridemo do izvršljivega programa.  Spoznali bomo tudi, kaj potrebujemo za odkrivanje in odpravljanje napak v programih, pisanih v zbirnem jeziku.

Uvod

Kaj je zbirni jezik?

To je najnižji nivo programiranja, ki poenostavi izredno nepregledno in zamudno programiranje na nivoju strojnega jezika. Kot bomo spoznali kasneje, uporabljamo primitivne ukaze s kraticami (mnemoniki), ki nas v angleškem jeziku spominjajo na to, kar želimo od računalnika. Med ukazi v zbirnem jeziku (assembly language) in strojnem jeziku velja običajno kar razmerje 1:1

Primer:   

mov ax,bx

pomeni, da vsebino regitra AX prepišemo v register BX. In za to zadošča en ukaz na strojnem nivoju.

Zakaj naj bi programirali v zbirnem jeziku?

Dobri programerji lahko na ta način napišejo zelo kompaktne in hitre programe. Kar počnemo v nekem višjem programskem jeziku, lahko zanesljivo zakodiramo tudi v zbirnem jeziku. Kljub temu se danes programiranje v zbirnem jeziku redko uporablja. Celo operacijski sistemi, ki morajo biti sprogramirani res učinkovito, so danes programirani v jezikih, kot so C, C++ in podobni.  Lahko pa v take programe (na primer v jeziku C) vključimo dele optimizirane kode, zapisane v zbirnem jeziku (»inline« kodiranje). Žal pa programiranje v zbirnem jeziku pomeni tudi to, da tak program ni prenosljiv na računalnike z drugo zgradbo CPE. 

Pa začnimo...

Pripraviti želimo program (v binarni kodi), ki naj bi tekel na našem računalniku. Takemu računalniku recimo ciljni računalnik.

Najprej s primernim urejevalnikom napisemo program v zbirnem jeziku. To seveda lahko naredimo na katerikoli računalniku (To ni nujno isti računalnik, na katerem želimo poganjati naš program).

Podobno, kot smo si pomagali z urejevalnikom za pisanje samega programa, si sedaj pomagajmo z zbirnikom (assembler). Zbirnik je program, ki bere program v zbirnem jeziku in tvori program v binarni kodi.

Ta program mora v resnici še preiti nekaj faz (povezovanje s sistemskimi in drugimi podprogrami, nalaganje v pomnilnik), vendar to ni pomembno za razumevanje samega zbiranja.

Med samim potekom dobimo kar nekaj uporabnih izpisov, ki nam pomagajo pri ugotavljanju napak v našem programu. Po prehodu čez zbirnik  je to datoteka z izpisom programa (listing file), ki tipično vsebuje naslednje:

         Izvorno kodo programa
         Koristne naslove (kje je kaj)
         Prevedeno (objektno) kodo (strojni jezik)
         Imena segmentov (to je posebnost Intelove tehnologije)
         Simbole (imena spremenljivk, constant in procedur)

Primer take datoteke z listanjem bomo spoznali kasneje.

Po prehodu čez povezovalnik, ki doda »zunanje« rutine, dobimo še datoteko z izpisi  naslovov (Map file). V primeru Intelove tehnologije tako dobimo izpisane podatke o posameznih segmentih:

         Začetni naslov segmenta
         Končni naslov segmenta
         Velikost segmenta
         Tip segmenta

Če binarni program pogledamo bolj podrobno, zasledimo (vsaj po eno) področje s podatki, programsko kodo ter sklad. Področij s kodo in s podatki je lahko tudi več in se med seboj lahko prepletajo (kar pa je, vsaj za programerje-začetnike,nevarno).

S kakšnimi mnemoniki lahko povemo zbirniku, da želimo rezervirati prostor za podatke?

S kakšnimi instrukcijami povemo zbirniku, kakšna naj bo programska koda?

Zbirnik razporeja (alocira prostor za) podatke in kodo sekvenčno, tako kot bere vrstice v našem programu. Ne pozabimo, da je zbirnik navaden program in ima kot tak svoje spremenljivke. Ena od teh je lokacijski števec. Ta se med zbiranjem povečuje za toliko besed, kolikor jih je potrebnih za posamezen podatek ali kodiran ukaz.

Preprost program

Delovanje računalnika lahko ponazorimo s pisanjem programa za izračun  A+B-C. Ta izraz razstavimo na naslednje operacije: A prepišemo v akumulatorski register, vsebini akumulatorja prištejemo  B, nato odštejemo C  in končno ustavimo računalnik ter preverimo vsebino akumulatorja. Kako bi izgledal tak program v zbirnem jeziku za Intel x86?

Primer programa v zbirnem jeziku:

TITLE Add and Subtract         

; This program adds and subtracts 32-bit integers.

INCLUDE unility.inc

.code

Main  PROC

            moveax,10000h                     ; EAX = 10000h

            add eax,40000h                     ; EAX = 50000h

            sub eax,20000h                      ; EAX = 30000h

            call DumpRegs                       ; display registers

            exit

main  ENDP

           END main

Opazimo več vrstic. Prej smo sicer rekli, da ena vrstica v zbirnem jeziku ustreza enemu strojnemu ukazu, pa to očitno ni čisto res. Najprej bi opozorili, da program vsebuje tudi komentarje. Ti so v našem primeru zaradi nazornosti pobarvani zeleno. Opazimo, da se vsak komentar začne s podpičjem in nadaljuje do konca vrstice. Zbirnik seveda komentarje ignorira.

Sicer pa posamezne vrstice pomenijo neko navodilo (direktivo ) zbirniku ali pa res zapis želenega programskega ukaza (ekvivalent strojnemu ukazu).  Zaradi večje preglednosti so navodila v našem primeru pobarvana modro, ukazi pa rdeče.

Navodila (directive)

         Z njimi povemo zbirniku, kaj naj naredi
         Njim ne ustreza noben strojni ukaz samega računalnika
         Z njimi deklariramo procedure, podatke in model pomnenja (*).
         Različni zbirniki uporabljajo različne, čeprav podobne direktive

Ukazi (instrukcije)

         Jih zbirnik preslika v strojno kodo
         Med izvajanjem programa jih CPE spoznava in izvaja
         Stavek, ki opisuje ukaz, tipično vsebuje oznako (labelo), mnemonic, operande in komentar

* Pripomba: Model pomnenja je posebnost Intelove tehnologije. Zbirniku lahko napovemo, da bo naš program kratek ali dolg in bo temu primerno izbiral tip strojnih ukazov.

Če pogledamo ukaze našega primera, zlahka ugotovimo, da naj bi program nekaj prenesel (mov), pa temu nekaj prištel (add) in odštel (sub). Malo manj nam je razumljiva vrstica  call DumpRegs, pa o tem več kasneje.

Zaradi večje preglednosti lahko te vrstice pišemo tudi s tabulatorjem. Prva kolona je oznaka (label ) vrstice. Druga kolona vsebuje  operacijo. Tretja kolona vsebuje morebitni  operand, četrta kolona vsebuje  komentar.

Vsaka vrstica (ki ni le komentar) vsebuje besedico, ki pove zbirniku, kaj naj na danem mestu naredi. Tem besedam, ki so v bistvu okrajšave angleških napotkov, pravimo mnemoniki.

Opazimo, da imajo nekateri stavki oznake (v načem primeru »main«). Zbirnik loči oznake od mnemonikov po tem, da se (vsaj v našem primeru) oznake obvezno začnejo v prvi koloni, mnemoniki pa morajo imeti pred seboj vsaj en presledek.

Za mnemoniki je lahko eden ali več argumentov, odvisno od vrste mnemonika. Če jih je več, so (običajno) ločeni z vejicami.

Zbirnik bi naš program v zbirnem jeziku prebral. Navodila (direktive) povedo zbirniku, kakšno ime ima naš program (vrstica TITLE), Povedo mu, da bo moral vključiti in uporabljati nekatere, vnaprej pripravljene podprograme (vrstica include  unility.inc), ter da program vsebuje  neko proceduro oziroma kodo (vrstici .code in PROC Main).

Zbirnik tvori strojno (binarno) sliko programa. Omenili smo že, da pripravi tudi izpis (takoimenovano listanje) programa, ki nam pride prav pri iskanju morebitnih napak. Poglejmo si listanje za naš primer:

Primer izpisa programa („listanje”  programa)

00000000                                          .code

00000000                               main PROC

00000000  B8  00010000                  mov eax,10000h

00000005  05  00040000                   add eax,40000h

0000000A  2D 00020000                   sub eax,20000h

0000000F  E8  00000000E                call DumpRegs

exit

00000014  6A 00                               * push +000000000h

00000016  E8 00000000E                 * call ExitProcess

0000001B                                          main ENDP

            END main

V bistvu  je ta izpis sestavljen iz treh delov:

Čas je, da razložimo pomen vrstice »call DumpRegs».  To namreč ni preprost strojni ukaz pač pa klic podprograma s tem imenom.

Če se morda komu zdi samo programiranje v zbirnem jeziku preprosto, bi  se zagotovo ustavil, ko bi moral preko računalnika narediti kakšen lep izpis, kot smo to navajeni v višjih programskih jezikih. Zasilno si lahko pomagamo tako, da nam nekdo naredi podprogram za izpis vsebine registrov računalnika in ugotavljamo, ali je po določenih računalniških korakih njihova vsebina takšna, kot jo pričakujemo. Da pa lahko tak,  vnaprej pripravljen podprogram kličemo, moramo zbirniku povedati, da naj ga vključi (zato je v izvornem programu tudi navodilo »include utility.inc«

In kakšen izpis bi dobili v našem primeru:

Primer izhoda (izpisa registrov)

Kako v zbirnem jeziku deklariramo podatke?

V prejšnjem primeru smo imeli podatke »integrirane« v sam program. Če pogledamo prejšnji primer listanja programa, bomo v strojni kodi kaj hitro odkrili podatke 1000, 4000 in 2000 (v šestnajstiškem formatu). 

V splošnem pa so podatki v drugem delu pomnilnika kot sama programska koda. Spomnimo se naslednje slike:

Za vsak podatek moramo povedati, kje je, kakšnega tipa je in kakšno vrednost ima. To naredimo s primernimi navodili zbirniku. 

Za primer zbirnika za Intel, bi to lahko zgledalo tako:

Podatki1         DB      10,4,10H

Podatki2         DW     100,?,-5

Podatki3         DD      3*20,0FFFDH

Vidimo, da imajo te vrstice podobno obliko, kot ostale vrstice zbirnega jezika. Zaradi večje preglednosti smo mnemonike pobarvali rdeče. Operande pa modro. Pomen mnemonikov je naslednji:

         DB (define byte)                   Vsak operand zaseda en bajt

         DW (define word)                 Vsak operand zasede eno besedo (dva bajta)

         DD (define double word)      Vsak operand zasede 2 besedi (4 bajte)

Vsaki vrstici lahko po potrebi dodamo oznako (labelo), ki omogoča naslavljanje tako rezerviranih pomnilniških lokacij.

Operandi so lahko številske konstante, podane v desetiškem ali šestnajstiškem sistemu (slednje imajo na koncu črko H). Lahko pa tudi kar rezerviramo pomnilniško lokacijo in ne podamo njene vsebine (za to uporabimo namesto številske vrednosti znak ?). Poleg tega lahko povemo, da potrebujemo večkratno dolžino (uporabno za rezervacijo polj).

In kako izgleda rezervirani pomnilnik za naš primer:

Opazimo tri področja, ki so velika 3 bajte, 6 bajtov oziroma 8 bajtov.

Poenostavimo si programiranje

Program v zbirnem jeziku bo imel pravilno zgradbo, če uporabimo predlogo oziroma šablono, kot jo kaže spodnja slika:

TITLE Program Template          

; Program Description:

; Author:

; Creation Date:

; Revisions:

; Date:              Modified by:

INCLUDE utility.inc

.data

            ; (tu vstavimo spremenljivke in konstante)

.code

main PROC

            ; (tu vstavimo programsko kodo, torej ukaze)

            exit

main ENDP

            ; (tu podamo kodo za morebitne podprograme)

END main

Pravilno napisan program mora vsebovati tudi podatke o avtorju, o verziji programa ter seveda sam opis programa.

Vhodno izhodne operacije

Niso tako enostavne, kot to velja za programiranje v višjih jezikih.  Programiranje v zbirnem jeziku pač ni temu namenjeno. Če že moramo predvideti tudi vpis podatkov in izpis rezultatov, mora nekdo sprogramirati primerne podprograme, ki jih po potrebi kličemo. Nekaj podobnega smo zasledili tudi v našem prejšnjem primeru.

Preverjanje in popravljanje programa

Če nam program ne da pravega rezultata, ga moramo razhroščiti (debug). Pomagamo si tako, da njegovo izvajanje prožimo korak za korakom. V tem načinu lahko po vsakem koraku preverimo stanje računalnikovih registrov. V ta namen oporabjamo takoimenovane razhroščevalnike (debuggers).

Normalno med izvajanjem programa ne vidimo njegove izvorne kode. Razhroščevalnik je programsko orodje,  ki omogoča, da naš program teče v »razhroščevalnem  režimu« (debugging mode). To pomeni, da lahko v vsakem trenutku izvajanja programa vidimo njegovo izvorno kodo, ga prekinemo, pogledamo vrednosti spremenljivk in te tudi spreminjamo ter nadaljujemo izvajanje programa.

Poznamo dve vrsti razhroščevalnikov: konzolne in vizualne (grafične) razhroščevalnike. Bolj prijazni so grafični (vizualni), kjer vidimo izvorno kodo v enem oknu, v drugem pa vrednosti spremenljivk, oziroma v primeru zbirnega jezika vrednosti pomnilniških lokacij in delovnih registrov.

Vsak razhroščevalnik mora omogočati:

         Prikaz vrednosti v registrih in pomnilniku

         Vnašanje vrednosti v registre in pomnilnik

         Izvajanje zaporedja programskih ukazov

         Prekinitev programa, kjerkoli to želimo.

Ena od pomembnih lastnosti razhroščevalnikov je sledenje programa. To pomeni:

         Koračno izvajanje programa ukaz za ukazom in sprotno sledenje vsebin registrov in pomnilniških lokacij.

         Nastavljanje prekinitvenih točk (breakpoints). Razhroščevalniku povemo, pri katerem programskem ukazu naj se zaustavi. To pride v poštev predvsem pri programih, ki deloma potekajo v zankah in bi bilo koračno sledenje prezamudno.

         Nastavljanje  pogojev za zaustavljanje (watchpoint). Zahtevamo, da se program zaustavi, ko pride na primer do spremembe vsebine nekega registra ali kakšne druge opazovane vrednosti.

Primer bolj preprostega  razhroščevalnika je OllyDbg, ki je tudi prosto dobavljiv. Spodnja slika predstavlja tipično zaslonsko sliko tega orodja:

Primer profesionalnega, grafično usmerjenega razhroščevalnika je PEBrowse Professional Interactive Windows Debugger, ki ga uporabljamo za sistemsko programiranje. Primer ekranske slike tega orodja kaže spodnja slika

Poleg prikaza programske kode v simbolični in šestnajstiški obliki vidimo tudi trenutno stanje registrov računalnika.