1. Indice

2. Reaizzazione Primitive

Le primitive del nostro sitema devono lavorare su un insieme di strutture dati globali, come descrittori di processo e code dei processi.

Dobbiamo però chiederci cosa succederebbe se, mentre una primitiva sta lavorando su una di queste strutture S, una interruzione (di qualsiasi tipo) causasse un salto ad un’altra primitiva che cerca di accedere alla medesima S?

Pensiamo, per esempio, ad una primitiva che sta cercando di inserire un nuovo des_proc *d1, nella coda pronti (supponiamo in testa).

Per eseguire questa operazione la primitiva esegue due operazioni:

Supponiamo a questo punto che, tra la prima e la seconda scrittura, il processore salti ad un’altra routine di sistema per effetto di una interruzione. Questa nuova routine cerca anch’essa di inserire un altro des_proc *d2 in testa alla coda pronti.

Questa seconda primitiva copierà quindi pronti in d2->puntatore e appenderà d2 in testa a pronti.

Al termine della seconda primitiva si ritornerà quindi alla prima, che proseguirà dal punto in cui si era interrotta. Il problema che sorge è che in questo momento la primitiva appenderà d1 in testa a pronti, cancellando quanto vi aveva scritto la seconda, poiché nella pronti salvata in d1->puntatore non è presente d2.

Perciò l’effetto è che il des_proc* d2 non è più puntato da niente.

Chiaramente non vogliamo che quanto appena descritto possa accadere, ma questo è solo uno degli infiniti problemi che si potrebbero presentare (si pensi, per esempio, ad una salva_stato interrotta da un’altra salva_stato).

Più in generale, quello che abbiamo descritto è un problema di interferenza tra due flussi di esecuzione che lavorano su una stessa struttura dati. La causa di queste interferenze è dovuta alle interruzioni, poiché rende visibili ad altri processi gli stati inconsistenti dovute a espressioni non atomiche.

Infatti, quando si provano ad eseguire più istruzioni che singolarmente possono essere considerate atomiche (poiché i vari stati inconsistenti che comportano vengono risolti al termine della loro esecuzione), a casusa delle interruzioni è possibile per un altro processo inserirsi tra queste espressioni.

In generale, noi vogliamo che ogni struttura dati si trovi in uno stato consistente. (Per esempio, una lista è in uno stato consistente se tutti e soli i suoi elementi sono raggiungibili dalla testa)

In un sistema che non prevede interruzioni, le operazioni che manipolano le strutture dati vengono scritte assumendo che la struttura dati si trovi sempre in uno stato consistente quando l'operazione inizia, e assicurandosi di portarla in un nuovo stato consistente alla fine dell’operazione. Nel mezzo dell’operazione, però, sono ammessi delle transizioni temporanee della struttura dati attraverso stati non consistenti. Ripensiamo all’operazione di inserimento in testa alla coda pronti eseguita dalla prima primitiva:
 Subito dopo la copia di pronti in d1->puntatore, la lista non è in uno stato consistente, in quanto d1 ne fa concettualmente parte, ma non è ancora puntato da pronti.

Questo stato inconsistente non è un problema in un sistema senza interruzioni, in quanto non è osservabile da nessun’altra operazione sulla coda. In presenza di interruzioni, però, lo stato inconsistente diventa improvvisamente visibile da un’altra operazione, che era stata scritta assumendo che ciò non potesse mai accadere, e che dunque non è preparata per affrontare la situazione.

Abbiamo a disposizione principalmente due modi per evitare i malfunzionamenti causati dalle interferenze:

  1. Scrivere tutte le routine in modo da tener conto di tutti i modi in cui queste si possono mescolare, in modo che funzionino in ogni caso. (possibile e anzi desiderabile, ma molto complesso);
  2. Prevenire a priori l’interferenza, eliminando tutte le sorgenti di interruzione durante l’esecuzione delle primitive, o almeno delle loro parti critiche.

Per l’intera durata di tutte primitive del modulo sistema della nostra macchina adotteremo la seconda soluzione, Rilasseremo solo nel modulo io. (Da considerare che alcuni testi d’esame considerano il caso di rilassamento anche nel modulo sistema.)

In particolare per quanto riguarda sistema, tutti i gate saranno del tipo interrupt, con disabilitazione automatica delle interruzioni esterne mascherabili. Durante la scrittura delle primitive, staremo inoltre attenti a non causare eccezioni e a non chiaramare altre primitive tramite int.

Utilizzando questi accorgimenti, le nostre primitive gireranno in un contesto atomico: una volta iniziate saranno portate a compimento, senza che niente le possa interrompere.

Diventeranno in questo modo molto simili alle singole istruzioni di linguaggio macchina, la cui atomiticà è garantita dal processore. Si noti che in molti sistemi reali l’atomicità viene considerata un prezzo troppo alto da pagare e viene rilassata in vari modi, come ad esempio in Linux.

3. Meccanismo di chiamata

A livello Assembler, invocare una primitiva non è come invocare una semplice funzione, proprio perché è necessario passare attraverso un gate con una istruzione INT.

Tuttavia è possibile utilizzare le routine a livello del C++ come una qualunque funzione, per maggior comodità dell’utente.

Ricordando che il gate di una primitiva è così formato:

P = 1               // bit presenza //
L = SISTEMA         // livello di privilegio della routine //
DPL = UTENTE        // livello di privilegio necessario //
ROUTINE = &routine  // indirizzo della routine //
I/T = INTERRUPT     // disabilita le interruzioni esterne //

Le routine avranno quindi tutte lo stesso formato. Nel file sistema.cpp avremo:

//...
extern "C" returnType c_primitiva_i(/*Parametri Formali*/);
//...

Nel file sistema.s avremo invece il corpo della primitiva e la sua “gemella” in assembler:

    .global a_primitiva_i
a_primitiva_i:
    CALL salva_stato
    CALL c_primitiva;
    CALL carica_stato
    IRETQ

; ...

    .global c_primitiva_i
c_primitiva_i:
    pushq %rbp
    movq %rsp, %rbp
    ; ...
    
    ; Usa i parametri attuali in
    ; %rdi, %rsi, etc...
    
    ; ...
    leave
    ret

Per permettere l’invocazione di una primitiva esistono nel nostro modulo sistema delle label globali che chiamano i gate della IDT.

Per poter utilizzare le primitive, esse vengono dichiarate nel file sys.h:

//...
extern "C" returnType primitiva_i(/*parametri formali*/);
//...

Successivamente potranno essere utilizzate all’interno del file utente.cpp:

#include <sys.h>
//...
    primitiva_i(/*parametri attuali*/);
//...

Che verrà tradotta in assembler in qualcosa del tipo:

; Passo i parametri nei registri adeguati
; %rdi, %rsi, ...

CALL primitiva_i

Successivamente l’utente si preoccuperà di inserire in utente.s:

    .global primitiva_i
primitiva_i:
    INT $tipo_i
    RET

4. Scrivere una primitiva

Per scrivere una primitiva dobbiamo eseguire una serie di passaggi, alcuni obbligati altri semplicemente utili.

Il primo passaggio utile (ma non necessario) è creare una nuova costante nel file costanti.h così da potervi riferire per nome e non per valore:

// ...
#define TIPO_I  0x29    /// tipo inutile
// ...

A questo punto il primo passaggi obbligatorio è inserirla nel sys.h:

// ...
extern "C" int inutile(int a, int b);
// ...

Adesso l’utente può dichiararne il corpo e successivamente utilizzarla.

Vediamo quindi come aggiungerla:

Nel file utente.s aggiungiamo le stab:

; ...
    .global inutile
inutile:
    .cfi_startproc      ; per il debugger
    int $TIPO_I
    RET
    .cfi_endproc        ; per il debugger
; ...

Definiamo e descriviamo quindi la funzione:

sistema.s

;...

; Aggiungiamo alla tabella IDT
init_idt:
    ;...
    carica_gate TIPO_I  a_inutile   LIV_UTENTE
    ;...
; ...
.extern c_inutile
a_inutile:
    CALL salva_stato
    CALL c_inutile
    CALL carica_stato
    IRETQ

;...

sistema.cpp

// ...
/**
* Funzione inutile che somma a, b e la priorità del processo che l'ha invocata
* @param a numero intero
* @param b numero intero
* @return la somma tra i parametri e la priorità del processo in esecuzione.
*/
extern "C" void c_inutile(int a, int b){
    // Nella variabile esecuzione si trova l'id del processo che l'ha invocata
    int r = a + b + esecuzione->precedenza;

    // Per restituire r non possiamo fare return, ma scriviamo:
    esecuzione->contesto[I_RAX] = r;
    // Questo sovrascrive il contenuto del registro %rax che avevamo 
    // salvato con la `salva_stato`, così da recuperarlo quando
    // chiameremo la `carica_stato`
}

L’utente a questo punto può utilizzare la nuova primitiva nel file utente.cpp:

#include<all.h>

int main(){
    printf("inutile(2,3) = %d\n", inutile(2,3));
    pause();
    terminate_p();
}

inutile(2, 3) = 1028

4.1. Funzioni di supporto

Le seguenti funzioni sono già definite in sistema.cpp e possono essere utilizzare nel definire nuove primitive: