Table of Contents

Laboratorul 04 - ISA 3: Instrucțiuni de control și stiva

0. Introducere

În acest laborator vom studia 2 tipuri noi de instrucțiuni din cadrul ISA: instrucțiuni de control și instrucțiuni care lucrează cu stiva.

  1. Instrucțiunile de control sunt importante deoarece determină ordinea în care sunt executate celelalte instrucțiuni din cadrul unui program. Ordinea de execuție este dată de salturi la diferite adrese în memoria ROM unde se află secvențele de program.
  2. Instrucțiunile care lucrează cu stiva sunt importante pentru apelurile de funcții.

În funcție de modul în care se realizează saltul, instrucțiunile de control pot fi împărțite în mai multe categorii:

  • Salturi necondiționate (jump): cele care determină continuarea rulării de la o anumită adresă (instrucțiune)
  • Salturi condiționate (branch/ alegeri): continuarea execuției de la o anumită instrucțiune doar dacă se îndeplinește o anumită condiție
  • Salturi relative: adresa la care se sare este calculată pe baza valorii curente a PC-ului
  • Salturi absolute: adresa la care se sare este cea specificată în instrucțiune, indiferent de valoarea PC-ului
  • Apel de funcție/rutină (call, ret): un salt special la un anumit grup de instrucțiuni “îndepărtat”. După terminarea execuției acelor instrucțiuni, se revine la execuția normală a codului, continuând de la adresa unde s-a facut apelul de funcție, in timp ce apelurile precedente erau “one way”.
  • Oprirea rulării: oprirea necondiționată a execuției (halt) este considerată tot o instrucțiune de control.

În continuare, ne vom ocupa de urmatoarele instructiuni:

  • instrucțiuni de salt necondiționat
    • RJMP
  • instructiuni de salt condiționat
    • BRBS
    • BRBC
  • instructiuni de stiva
    • PUSH
    • POP

1. Program Counter (PC)

Pentru a înțelege efectul salturilor și modul în care acestea funcționează, trebuie mai întâi să înțelegem ce este un program counter (PC) și care este rolul său.

Secvența de cod pe care o are de executat un procesor se află de cele mai multe ori stocată într-o memorie de tip ROM conectată la acesta. Pentru a decodifica și executa corect instrucțiunile din acea memorie, procesorul trebuie să știe tot timpul care este adresa de la care trebuie adusă o instrucțiune în etapa de fetch. Această sarcină este îndeplinită de registrul PC (program counter). Rolul acestui registru este de a reține adresa de la care trebuie adusă următoarea instrucțiune din memorie. Mai este numit și Instruction Pointer deoarece funcționează exact ca un pointer: reține o adresă și ajută procesorul să obțină datele stocate la acea adresă.

În mod normal, de fiecare dată când procesorul se află în stagiul ID al pipeline-ului, după ce instrucțiunea a fost adusă pentru decodificare, program counter-ul va fi incrementat. Totuși, există și alte situații în care valoarea program counter-ului poate fi modificată. De cele mai multe ori, instrucțiunile de salt sunt responsabile pentru modificarea PC-ului în afara stagiului de Instruction Decode.

2. Instrucțiuni de control

2.1 Salturi necondiționate

Salturile necondiționate sunt instrucțiuni ce modifică valoarea Program Counter-ului cu o valoare fixă. Acest tip de salturi nu ține cont de evenimente produse în timpul rulării sau de anumite registre ce țin informații legate de operațiile executate recent de către procesor.

În general, instrucțiunile de salt necondiționate sunt codificate ca instrucțiuni cu un singur operand, operandul fiind o constanta care determină cu cât se modifică Program Counter-ul (peste câte adrese va sări).

Acest tip de salturi este în general folosit atunci când dorim să sărim peste o zonă de memorie deoarece conține implementarea unei rutine pe care nu dorim să o executăm sau dacă dorim să evităm o serie de instrucțiuni ce prezintă un posibil hazard. În limbajele high level, saltul necondiționat este cel mai des reprezentat prin tag-uri GOTO.

2.2 Salturi condiționate. Registrul SREG

Un alt tip de salturi sunt cele condiționate. Acestea au același efect, acela de a modifica valoarea Program Counter-ului, însă nu îl vor modifica de fiecare dată. Atunci când se execută o instrucțiune de salt condiționat, se verifică anumite criterii, în funcție de tipul instrucțiunii. Dacă acele criterii sunt îndeplinite, se va realiza saltul. Altfel, Program Counter-ul continuă să fie incrementat în mod normal, în stagiul ID. Salturile condiționale sunt cel mai ușor asemănate cu instrucțiunile de tip if din limbajele de programare de nivel înalt.

Deoarece aceste instrucțiuni verifică îndeplinirea unor condiții pentru a executa salturi, este nevoie de un mod de a reține starea parametrilor verificați. De cele mai multe ori, acești parametri reprezintă evenimente ce au avut loc în urma executării unor operații de către UAL:

  • rezultatul a fost egal sau nu cu zero
  • a avut loc un overflow
  • rezultatul este negativ sau pozitiv etc.

Pentru a reține toate aceste evenimente se folosesc flag-uri grupate în ceea ce se numește Processor Status Register sau SREG (este oarecum echivalentul registrului flags din arhitectura x86).

Printre flag-urile des folosite din SREG se află:

  • Z (Zero) - indică dacă rezultatul unei operații aritmetice este zero
  • C (Carry) - indică faptul că s-a realizat un transport la nivelul bitului cel mai semnificativ in cazul unei operații aritmetice. Altfel spus, a avut loc o depășire în aritmetica modulo N considerată. În procesorul nostru pe 8 biți, 255 + 1, deși ar trebui să aibă rezultatul 256, de fapt acesta este 0 din cauza aritmeticii modulo 256 (28). Pentru a diferenția dintre un 0 apărut real și unul cauzat de o depășire, se utilizează acest semnal de carry.
  • V (Overflow) - arată că, în cazul unei operații aritmetice cu semn, complementul față de doi al rezultatului nu ar încăpea în numărul de biți folosiți pentru a reprezenta operanzii. Cu alte cuvinte, se poate întampla ca adunând două numere pozitive, să obținem unul negativ (127signed + 1signed = -128signed), dar și adunând două numere negative să obținem unul pozitiv (-128signed + (-1)signed = +127signed). Evident, rezultatul nu este corect în aceste situații, și semnalarea se face prin flag-ul de overflow.
  • N (Negative) - semnul rezultatului unei operații aritmetice (este pur și simplu valoarea bitului de semn al rezultatului)
  • S (Sign) - este un flag unic AVR, calculat după formula S = N xor V, ce reprezintă “care ar fi trebuit să fie semnul corect al rezultatului”. Cu alte cuvinte, dacă N == 1, dar și V == 1, înseamnă că rezultatul este negativ, dar din cauza unei depășiri. S este setat în acest caz pe 0, semnalând că semnul “corect” al operației ar fi trebuit să fie pozitiv.

Scrierea biților cu valorile corespunzătoare din SREG revine UAL-ului. La execuția unei operații, se calculeaza și valorile fiecărui flag ce poate fi afectat de acel tip de operație. Practic, ca și la arhitectura x86, putem considera că SREG este un registru global, a cărui valoare este setată de ultima instrucțiune aritmetico-logică executată de procesor.

Pentru a vedea specificațiile Atmel asupra SREG, consultați datasheet-ul ISA-ului. Modul în care acesta este modificat de fiecare instrucțiune este de asemenea descris în pagina corespunzatoare acesteia.

2.3 Instructiuni de salt in AVR

Există două tipuri de salturi, în funcție de adresa destinație:

  • la adrese absolute, în care adresa destinație a saltului este conținută în valoarea imediată din instrucțiune
  • salturi autorelative, în care adresa destinație este codificată ca fiind diferența dintre Program Counter-ul curent (adresa instrucțiunii de salt) și adresa destinației. Avantajul este că, pentru salturi apropiate, se salveaza biți pentru codificarea adresei, dar dezavantajul este că nu se poate sări prea departe.

Pentru AVR, salturile condiționate (branch-urile) sunt întotdeauna autorelative, iar cele necondiționate (jump-urile) vin în două variante: unele relative (RJMP pe 16 biți), și altele absolute (JMP pe 32 de biți, pe care este dificil să o implementăm pe procesorul nostru cu instrucțiuni de 16 biți). În cazul instrucțiunii JMP, adresa destinație este o valoare imediată pe 22 de biți, permițând saltul la orice adresă în spațiul de 4M (de fapt, 8 megabytes, ținând cont că fiecare instrucțiune are 16 biți). Pe de alta parte, RJMP cu cei 12 biți de valoare imediată, poate executa un salt la “doar” +/- 2K instrucțiuni relativ la cea curentă. Încă și mai ciudat, există și CALL/RCALL, prin care se poate specifica adresa unei funcții atât ca valoare absolută, cât și relativă. Considerentele sunt aceleași: economisirea memoriei de instrucțiuni (32 de biți pentru CALL vs 16 pentru RCALL), dar și simplificarea logicii de Instruction Fetch și Instruction Decode.

Din alt considerent, există jump-uri și branch-uri la adrese:

  • imediate, conținute în instrucțiune
  • indirecte, conținute într-un registru, care trebuie citit în prealabil.

În acest laborator vom implementa doar salturi la adrese imediate.

3. Stiva - PUSH, POP

Această pereche de instrucțiuni este folosită pentru a pune și a extrage date de pe stivă.

  • PUSH pune la poziția curentă de pe stivă octetul aflat în registrul Rr și apoi decrementează stack pointer-ul cu 1 (stack[sp- -] = Rr).
  • POP incrementează stack pointer-ul cu 1 și apoi pune în Rd valoarea de la poziția curentă din stiva (Rd = stack[++sp]).

Așa cum este implementată stiva în AVR, index-ul cel mai mare este la baza stivei, iar cel mai mic în vârful acesteia.

  • Inițial, stack pointer-ul se află la baza stivei (valoarea cea mai mare - în schelet: define STACK_START 8'hBF).
  • Atunci când se pune un octet pe stivă, stack pointer-ul este mutat către vârf, adică se decrementează.
  • Dacă realizăm operația inversa, stack pointer-ul se mută către bază, deci este incrementat.

  • Stack pointer-ul se va află tot timpul pe poziția liberă cea mai apropiată de baza stivei.
  • În acest laborator stiva va fi mapată peste spațiul de adresă 0x40 - 0xBF.

TL;DR

1. PC

Registrul PC sau program counter (PC) reține adresa de la care trebuie adusă urmatoarea instrucțiune din memorie. Mai este numit și Instruction Pointer (IP) deoarece funcționeaza exact ca un pointer: reține o adresă și ajuta procesorul să obțină datele stocate la acea adresă.

2. Control flow

În funcție de modul în care se realizează saltul, instrucțiunile de control pot fi împărțite în mai multe categorii:

  • Salturi necondiționate (jump): cele care determină continuarea rulării de la o anumita adresă (instrucțiune)
  • Salturi condiționate (branch/alegeri): continuarea execuției de la o anumită instrucțiune doar dacă se îndeplinește o anumită condiție
  • Salturi relative: adresa la care se sare este calculată pe baza valorii curente a PC-ului
  • Salturi absolute: adresa la care se sare este cea specificată în instrucțiune, indiferent de valoarea PC-ului
  • Apel de funcție/rutină (call, ret)
  • Oprirea rulării (halt)

3. Stack

SP sau stack pointer-ul se va afla tot timpul pe poziția liberă cea mai apropiată de baza stivei.

PUSH, POP - această pereche de instrucțiuni este folosită pentru a pune și a extrage date de pe stivă.

  • PUSH pune la poziția curentă de pe stivă octetul aflat în registrul Rr și apoi decrementează stack pointer-ul cu 1 (stack[sp- -] = Rr).
  • POP incrementează stack pointer-ul cu 1 și apoi pune în Rd valoarea de la poziția curentă din stiva (Rd = stack[++sp]).

În acest laborator stiva va fi mapată peste spațiul de adresă 0x40 - 0xBF. Inițial, stack pointer-ul se află la baza stivei (valoarea cea mai mare - în schelet: define STACK_START 8'hBF).

Exerciții

În laboratorul de astăzi vom implementa câteva dintre instrucțiunile de jump și branching menționate. Pentru fiecare instrucțiune va trebui să aduceți modificări următoarelor fișiere:

  • decode_unit.v - aici se implementează logica pentru decodificarea instrucțiunilor
  • control_unit.v - controller-ul este cel care se ocupă de modificarea program counter-ului. Pentru instrucțiunile de branch și jump, considerăm că această modificare se face în starea de EXECUTE
  • ​alu.v​ - implementarea logicii de calcul a flag-urilor verificate în cadrul operațiilor de branch.
  • signal_generation_unit.v - Analizați cum sunt setate semnalele CONTROL_STACK_POSTDEC și CONTROL_STACK_PREINC.

Task 1 (1p). Generați codul mașină corespunzator secvenței de cod de mai jos și copiați-l in memoria ROM. Simulați codul și folosind semnalele relevante explicați ce face codul dat.

    ldi r16, 5
    ldi r17, 15
    push r16
    push r17
    main_loop:
        mov r30, r16
        sub r30, r17
        brbs 1, done
        brbs 2, label2
    label1:
        sub r16, r17 
        rjmp main_loop
    label2:
        sub r17, r16
        rjmp main_loop
    done:
        push r16
        pop r20
        pop r21
        pop r22

  • Pentru verificarea corectitudinii exercițiilor consultați log-urile simulării. Semnalul nu va fi complet verde, însă checker-ul va arăta mesajul OK peste tot dacă exercițiile au fost rezolvate corect.

  • Folositi datasheet-ul pentru a identifica opcode-ul instructiunilor si bitii corespunzatori operanzilor.
  • ATENȚIE! Pentru a beneficia de mesajele de debug puse în checker va trebui să lăsați ROM.v neschimbat! (P.S. Puteți adăuga la sfârșit, începând cu adresa 16.)
  • Implementați doar instrucțiunile menționate în enunțurile din acest laborator. Instrucțiunile din laboratoarele anterioare sunt deja implementate, acesta fiind motivul pentru care checkerul arată deja OK în dreptul acestora.

Task 2 (2p). Implementați instrucțiunea RJMP - salt necondiționat. Această operație are un singur operand: numărul de instrucțiuni peste care trebuie să sară program counter-ul.

  • Pentru testare implementați și BRBS și BRBC. Apoi folosiți checker-ul și verificați-le pe toate.

Task 3 (1.5p). Implementați instrucțiunea BRBS (BRanch if Bit in SREG is Set). BRBS este un exemplu de instrucțiune de control cu 2 operanzi. Al doilea operand este bit-ul din SREG corespunzător flag-ului pe care dorim să îl verificăm. Această instrucțiune va face un salt doar dacă bit-ul selectat este 1.

  • Pentru testare implementați și BRBC. Apoi folosiți checker-ul și verificați-le pe toate.

  • Dacă alegeți să folosiți BRMI pentru codul de testare, NU trebuie să implementați această instrucțiune.
  • BRMI este un caz special de BRBS (BRMI – Branch if Minus). Căutați în datasheet diferența.
  • avrasm.jar va interpreta BRMI ca pe un BRBS cu anumiți biți setați corect. Va genera o instrucțiune BRBS.

Task 4 (1.5p). Implementați instrucțiunea BRBC (BRanch if Bit in SREG is Cleared). BRBC este un alt exemplu de instrucțiune de control cu 2 operanzi. Operandul suplimentar este bit-ul din SREG corespunzător flag-ului pe care dorim să îl verificăm. Această instrucțiune va face un salt doar dacă bit-ul selectat este 0.

  • Pentru testare folosiți exercițiul 5.

  • Dacă alegeți să folosiți BRPL pentru codul de testare, NU trebuie să implementați această instrucțiune.
  • BRPL este un caz special de BRBC (BRPL – Branch if Plus). Căutați în datasheet diferența.
  • avrasm.jar va interpreta BRPL ca pe un BRBS cu anumiți biți setați corect. Va genera o instrucțiune brbc.

Task 5 (2 x 2p). Implementați instrucțiunile PUSH și POP.

  • Considerăm că stiva începe la STACK_START (0xBF - definit în schelet) și crește spre adresa 0.
  • Dacă ați implementat corect PUSH, atunci checker-ul o să vă dea OK pentru instrucțiunile PC == 2 și PC == 3.
  • Dacă ați implementat corect POP, atunci checker-ul o să vă dea OK aproape peste tot. Analizați codul din rom.v și găsiți o explicație pentru acest comportament.

Resurse

Schelet de cod

[0] AVR Instruction Set

[1] ATTiny 20 Datasheet

[2] Wikipedia: Atmel AVR instruction set

[3] Tool generare cod masina AVR
Pentru a folosi tool-ul de generare al codului masina AVR, pentru windows descărcați Java pentru cmd.