Laborator 5 - Întreruperi

Obiectivele laboratorului

  • înțelegerea modului de comunicare cu dispozitivele periferice
  • deprinderea de cunoștințe de implementare a rutinelor de tratare a întreruperilor
  • înțelegerea particularităților în sincronizarea cu rutinele de tratarea a întreruperilor

Cuvinte cheie

  • IRQ
  • port I/O
  • adresă I/O
  • adresă de bază
  • UART
  • request_region / release_region
  • inb / outb

Materiale ajutătoare

Noțiuni generale

Comunicația cu hardware-ul

Un dispozitiv periferic este controlat prin scrierea și citirea registrelor sale. De cele mai multe ori, un dispozitiv are mai multe registre, care pot fi accesate la adrese consecutive, fie în spațiul de adrese din memorie, fie în spațiul de adrese I/O. Fiecare dispozitiv conectat la magistrala I/O are un set de adrese I/O, numite porturi I/O. Porturile I/O pot fi mapate la adrese din memoria fizică, astfel încât procesorul va putea realiza comunicarea cu dispozitivul prin instrucțiuni care operează direct cu memoria. Din motive de simplitate, vom folosi în mod direct porturile I/O (fără mapare la adrese din memoria fizică) pentru comunicarea cu dispozitivele fizice.

Porturile I/O ale fiecărui dispozitiv sunt structurate într-un set de registre specializate, pentru a oferi o interfață uniformă pentru programare. Astfel, majoritatea dispozitivelor vor avea următoarele tipuri de registre:

  • registre de control, care primesc comenzi pentru dispozitiv
  • registre de stare, care conțin informații despre starea internă a dispozitivului
  • registre de intrare, din care se preiau datele de la dispozitiv
  • registre de ieșire, în care se scriu datele pentru a le transmite către dispozitiv

În majoritatea cazurilor, porturile fizice sunt diferențiate după numărul de biți: pot fi porturi pe 8, 16 sau 32 de biți.

Spre exemplu, portul paralel are 8 porturi de I/O pe 8 biți, începând de la adresa de bază 0x378. Registrul de date se găsește la adresa de bază (0x378), registrul de status la adresa de bază + 1 (0x379), iar cel de control la adresa de bază + 2 (0x37a). Registrul de date este atât registru de intrare cât și de ieșire.

Tratarea întreruperilor

Deși există echipamente care pot fi controlate în întregime folosind porturi I/O sau zone de memorie speciale, există și situații în care acest lucru este insuficient. Principala problemă care mai trebuie tratată e faptul că anumite evenimente au loc la intervale de timp nedeterminate și este ineficient ca procesorul (CPU) să interogheze repetat echipamentele despre starea lor (polling). Modalitatea de rezolvare a acestei probleme o reprezintă IRQ (Interrupt ReQuest), notificări hardware prin care procesorul este anunțat de apariția unui anumit eveniment extern.

Pentru ca IRQ-urile să fie utile, trebuie să existe secvențe de cod care să le trateze. Deoarece, în multe situații, numărul de întreruperi disponibile este limitat, un device driver trebuie să aibă un comportament cât mai “civilizat” cu întreruperile: adică întreruperile trebuie să fie cerute înainte de a fi utilizate și eliberate atunci când nu mai este nevoie de ele. În plus, în anumite situații, device driver-ele trebuie să folosească în comun o întrerupere sau să se sincronizeze cu întreruperile. Despre toate acestea vom discuta în continuare.

Locking

Atunci când trebuie să accesăm resurse partajate între o rutină de tratare a întreruperii (A) și cod ce rulează în context proces sau într-o rutină de tratare a unei acțiuni amânabile (B), trebuie să folosim un mod special de sincronizare. În (A) trebuie să folosim o primitivă de tip spinlock, iar în (B) trebuie să dezactivăm întreruperile ȘI să folosim o primitivă de tip spinlock. Dezactivarea întreruperilor nu este suficientă pentru că rutina de întrerupere poate rula pe un alt procesor decât cel pe care rulează (B).

Folosirea doar a unui spinlock poate duce la deadlock. Exemplul clasic de deadlock în acest caz este:

  1. rulăm în context proces, pe procesorul X, și achiziționăm lock-ul
  2. înainte de a elibera lock-ul, se generează o întrerupere pe procesorul X
  3. rutina de tratare a întreruperii va încerca să achiziționeze și ea lock-ul și va intra într-o buclă infinită

Comunicarea cu hardware-ul în Linux

În Linux, conceptul de porturi I/O este implementat pe toate platformele.

Alocarea porturilor I/O

Înainte de a putea lucra cu porturile I/O, trebuie să ne asigurăm că avem acces exclusiv la ele. Pentru a obține porturile dorite, se folosește funcția request_region:

#include <linux/ioport.h>
 
struct resource *request_region(unsigned long first, unsigned long n, const char *name);

Parametrul first specifică adresa de bază pentru dispozitiv, iar n numărul de porturi dorite. Adresa de bază este începutul zonei contigue de adrese (sau de porturi I/O) asociate dispozitivului și trebuie să fie unică pentru fiecare dispozitiv. Parametrul name reprezintă numele dispozitivului.

Pentru eliberarea porturilor rezervate, se folosește funcția release_region:

void release_region(unsigned long start, unsigned long n);

Spre exemplu, portul serial COM1 are adresa de bază 0x3F8 și deține 8 porturi. Secvența de cod pentru alocarea porturilor asociate acestuia este următoarea:

#include <linux/ioport.h>
#define MY_BASEPORT 0x3F8
#define MY_NR_PORTS 8
 
if (! request_region(MY_BASEPORT, MY_NR_PORTS, "com1")) {
    /* handle error */
    return -ENODEV;
}

iar cea pentru eliberare:

release_region(MY_BASEPORT, MY_NR_PORTS);

De cele mai multe ori, alocarea porturilor se realizează la inițializarea driver-ului, în funcția init_module, iar eliberarea acestora la deinițializare, în funcția cleanup_module.

Toate porturile alocate apar în /proc/ioports:

$ cat /proc/ioports
0000-001f : dma1
0020-0021 : pic1
0040-005f : timer
0060-006f : keyboard
0070-0077 : rtc
0080-008f : dma page reg
00a0-00a1 : pic2
00c0-00df : dma2
00f0-00ff : fpu
0170-0177 : ide1
01f0-01f7 : ide0
0376-0376 : ide1
0378-037a : parport0
037b-037f : parport0
03c0-03df : vga+
03f6-03f6 : ide0
03f8-03ff : serial
...

Operații de scriere și citire a porturilor I/O

După ce un driver a obținut intervalul de porturi I/O dorite, trebuie să realizeze operații de citire sau scriere pe aceste porturi. Întrucât porturile fizice sunt diferențiate după numărul de biți (8, 16 sau 32 de biți), există diferite funcții de acces a porturilor în funcție de dimensiunea lor. În asm/io.h sunt definite următoarele funcții de acces a porturilor:

  • unsigned inb(int port), citește porturi de dimensiune un octet (8 biți)
  • void outb(unsigned char byte, int port), scrie porturi de dimensiune un octet (8 biți)
  • unsigned inw(int port), citește porturi de dimensiune doi octeți (16 biți)
  • void outw(unsigned short word, int port), scrie porturi de dimensiune doi octeți (16 biți)
  • unsigned inl(int port), citește porturi de dimensiune patru octeți (32 biți)
  • void outl(unsigned long word, int port), scrie porturi de dimensiune patru octeți (32 biți)

Argumentul port specifică adresa portului de unde se citește sau se scrie, iar tipul său este dependent de platformă (poate fi unsigned long sau unsigned short).

Anumite platforme pot avea probleme atunci când procesorul încearcă să transfere date prea rapid către și de la dispozitiv. Soluția este inserarea unei întârzieri după fiecare instrucțiune de I/O, în cazul în care urmează o altă instrucțiune de același tip. În cazul în care dispozitivul pierde date, se pot folosi funcții care introduc această întârziere; numele acestora este similar cu cele descrise mai sus, cu deosebirea că se termină în _p: inb_p, outb_p etc.

Spre exemplu, următoarea secvență scrie un octet pe portul serial COM1 și apoi îl citește:

#include <asm/io.h>
#define MY_BASEPORT 0x3F8
 
unsigned char value = 0xFF;
outb(value, MY_BASEPORT);
value = inb(MY_BASEPORT);

Operații I/O în user space

Operații I/O în user space

Deși funcțiile descrise mai sus sunt definite pentru device drivere, ele pot fi folosite și din user space, prin includerea header-ului <sys/io.h>. Pentru a putea fi folosite, vor trebui apelate mai întâi funcțiile ioperm sau iopl pentru obținerea permisiunii de a realiza operații cu porturile. Funcția ioperm obține permisiunea pentru porturi individuale, în timp ce iopl pentru întregul spațiu de adrese I/O. Pentru a putea folosi aceste funcții utilizatorul trebuie să fie root.

Următoarea secvență folosită în user space obține permisiunea pentru primele 3 porturi ale portului serial, și apoi le eliberează:

#include <sys/io.h>
#define MY_BASEPORT 0x3F8
 
if (ioperm(MY_BASEPORT, 3, 1)) {
    /* handle error */
}
if (ioperm(MY_BASEPORT, 3, 0)) {
    /* handle error */
}

Al treilea parametru al funcției ioperm este 1 pentru obținerea permisiunii și 0 pentru eliberare.

Întreruperi în Linux

Obținerea unei întreruperi

La fel ca și în cazul celorlalte resurse, un driver trebuie să obțină accesul la o linie de întreruperi înainte de a o putea utiliza și să o elibereze la finalul execuției.

În Linux, cererea de obținere și respectiv eliberare a unei întreruperi se face cu ajutorul funcțiilor request_irq și free_irq:

#include <linux/interrupt.h>
 
typedef irqreturn_t (*irq_handler_t)(int, void *);
 
int request_irq(unsigned int irq_no, irq_handler_t handler,
                unsigned long flags, const char *dev_name, void *dev_id);
 
void free_irq(unsigned int irq_no, void *dev_id);

Se observă că pentru obținerea unei întreruperi dezvoltatorul apelează funcția request_irq. În cadrul funcției request_irq trebuie să specifice numărul întreruperii (irq_no), un handler ce va fi chemat în momentul generării întreruperii (handler), flag-uri ce vor instrui kernelul despre comportamentul dorit (flags), numele dispozitivului ce folosește această întrerupere (dev_name), și un pointer ce poate fi configurat de către utilizator la orice valoare, și care nu are semnificație globală (dev_id). De cele mai multe ori, dev_id va fi configurat la pointerul către datele private ale dispozitivului. În schimb, la eliberarea întreruperii, folosind funcția free_irq dezvoltatorul trebuie să transmită aceeași valoare a pointerului (dev_id), împreună cu numărul întreruperii (irq_no). Numele dispozitivului (dev_name) este folosit pentru afișarea de statistici în /proc/interrupts.

Valoarea pe care o întoarce request_irq este 0 în cazul în care înregistrarea s-a efectuat cu succes sau un cod de eroare negativ care indică motivul eșecului. O valoare uzuală este -EBUSY care este întoarsă atunci când întreruperea este ocupată deja de un alt echipament.

Există situații în care deși un dispozitiv folosește întreruperi nu putem citi regiștrii dispozitivului într-un mod non-blocant (de exemplu un senzor conectat la un bus I2C sau SPI al cărui driver nu garantează că operațiile de read / write pe bus sunt non-blocante). În această situație în întrerupere trebuie să planificăm o acțiune amânabilă ce rulează în context proces (work queue, kernel thread) pentru a putea accesa regiștrii dispozitivului. Pentru că o astfel de situație este relativ comună, kernel-ul pune la dispoziție funcția request_threaded_irq pentru întregistra rutine de tratare a întreruperilor ce rulează în două faze: o fază în context proces și o fază în context întrerupere:

#include <linux/interrupt.h>
 
int request_threaded_irq(unsigned int irq, irq_handler_t handler,
                         irq_handler_t thread_fn,
                         unsigned long flags, const char *name, void *dev);

handler este funcția ce rulează în context întrerupere și va implementa operațiile critice și care pot fi executate din context întrerupere, în timp ce funcția thread_fn rulează în context proces și va implementa restul operațiilor.

Flag-urile ce pot fi transmise la obținerea unei întreruperi sunt:

  • IRQF_SHARED anunță kernelul că întreruperea poate fi partajată cu alte dispozitive. Dacă acest flag nu este setat, atunci, dacă există deja un handler asociat cu întreruperea cerută, cererea de obținere a unei întreruperi va eșua. O întrerupere partajată este tratată prin execuția tuturor rutinelor înregistrate. Această abordare duce la o situație interesantă: cum își poate da seama un device driver dacă rutina de tratare a întreruperii a fost activată de o întrerupere generată de dispozitivul pe care îl gestionează? Toate dispozitivele care oferă suport pentru întreruperi au asociate un registru de stare, care poate fi interogat în rutina de tratare pentru a afla dacă întreruperea a fost sau nu generată de dispozitiv (în cazul portului serial, acest registru de stare este IIR - Interrupt Information Register). La cererea unei întreruperi partajate cu request_irq, argumentul dev_id trebuie să fie unic în kernel; poate fi setat la un pointer către datele private ale modulului, dar nu poate fi NULL.
  • IRQF_ONESHOT întreruperea va fi reactivată după rularea rutinei de tratare din context proces; fără acest flag, întreruperea va fi reactivată dupa rularea rutinei de tratare din context întrerupere

Obținerea întreruperii se poate realiza fie la inițializarea driver-ului, în funcția init_module, fie atunci când dispozitivul este deschis prima dată, în funcția open.

Următorul exemplu realizează obținerea întreruperii pentru portul serial COM1:

#include <linux/interrupt.h>
 
#define MY_BASEPORT 0x3F8
#define MY_IRQ 4
 
static my_init(void)
{
	[...]
	struct my_device_data *my_data;
	int err;
 
	err = request_irq(MY_IRQ, my_handler, IRQF_SHARED,
			       "com1", my_data);
	if (err < 0) {
		/* handle error*/
		return err;
	}
 
	[...]
}

După cum se poate observa, IRQ-ul pentru portul serial COM1 este 4, care este folosit în mod partajat (IRQF_SHARED).

La cererea unei întreruperi partajate (IRQF_SHARED) cu request_irq, argumentul dev_id nu poate fi NULL.

Pentru eliberarea întreruperii asociate portului serial se va executa următoarea secvență:

free_irq(MY_IRQ, my_data);

In funcția de inițializare, init_module, sau în funcția de deschidere a dispozitivului, open, trebuie activate întreruperile pentru a putea fi generate de către dispozitiv. Această operație este dependentă de dispozitivul folosit, dar de cele mai multe ori presupune setarea unui bit din registrul de control.

Pentru portul serial trebuie realizate două operații pentru activarea întreruperilor:

  1. se activează toate întreruperile prin setarea bitului 3 (Aux Output 2) în registrul MCR - Modem Control Register
  2. se activează întreruperea dorită (RDAI - Receive Data Available Interrupt, THREI - Transmit Holding Register Empty Interrupt) prin setarea bitului corespunzător în registrul IER - Interrupt Enable Register.

În exemplul de mai jos se activează întreruperea RDAI pentru portul COM1:

#include <asm/io.h>
#define MY_BASEPORT 0x3F8
 
outb(0x08, MY_BASEPORT+4);
outb(0x01, MY_BASEPORT+1);

Implementarea rutinei de tratare a întreruperii

Să examinăm acum signatura funcției de tratare a întreruperii:

irqreturn_t (*handler)(int irq_no, void *dev_id);

Funcția primește ca parametri numărul întreruperii pe care rutina o tratează și pointer-ul trimis la cererea de obținere a întreruperii. Rutina de tratare a întreruperii trebuie să întoarcă o valoare cu tipul irqreturn_t. Pentru versiunea curentă de kernel, există trei valori valide: IRQ_NONE, IRQ_HANDLED și IRQ_WAKE_THREAD. Device driverul trebuie să întoarcă IRQ_NONE dacă observă că întreruperea nu a fost generată de dispozitivul pe care îl comandă. În caz contrar, device driverul trebuie să întoarcă IRQ_HANDLED dacă întreruperea poate fi tratată direct din context înterupere sau IRQ_WAKE_THREAD pentru a planifica rularea funcției de tratare din context proces.

Un handler de întrerupere va avea următoarea structură:

irqreturn_t my_handler(int irq_no, void *dev_id)
{
	struct my_device_data *my_data = (struct my_device_data *) dev_id;
 
	/* if interrupt is not for this device (shared interrupts) */
	/* return IRQ_NONE;*/
	/* clear interrupt-pending bit */
	/* read from device or write to device*/
 
	return IRQ_HANDLED;
}

De obicei, primul lucru executat în rutina de tratare a întreruperii este să se determine dacă întreruperea a fost generată de dispozitivul comandat de driver. Pentru aceasta, de obicei se citesc informații din registrul de control al dispozitivului, care indică daca acesta a generat întreruperea. Al doilea lucru constă în resetarea bitului interrupt-pending pe dispozitivul fizic; cele mai multe dispozitive nu vor mai genera întreruperi până când acest bit nu a fost resetat. Acest pas depinde doar de dispozitiv, și poate și lipsi (cum e cazul portului paralel, care nu are un astfel de bit). Portul serial are un astfel de bit: bitul 0 din registrul IIR.

Locking

Deoarece rutinele de tratare pentru întreruperi se execută în context întrerupere, acțiunile care se pot efectua sunt limitate: nu se poate accesa memoria din user space, nu se pot apela funcții blocante, nu se poate face sincronizare doar cu spinlock-uri deoarece acest lucru ar duce la deadlock în cazul în care spinlock-ul este deja obținut de către un proces care a fost întrerupt.

Totuși, există cazuri în care device driverele trebuie să se sincronizeze cu întreruperile. În aceste situații este necesară dezactivarea întreruperilor și folosirea spinlock-urilor. Există două modalitați de dezactivarea a întreruperilor: dezactivarea tututor întreruperilor, la nivel de procesor, sau dezactivarea la nivel de întrerupere. Dezactivarea la nivel de procesor este mai rapidă și de asemenea previne deadlock-urile mai complexe și de accea este preferată. În acest scop, există funcții de locking care dezactivează, respectiv reactivează întreruperile: spin_lock_irqsave, spin_unlock_irqrestore, spin_lock_irq și spin_unlock_irq.

#include <linux/spinlock.h>
 
void spin_lock_irqsave(spinlock_t *lock, unsigned long flags);
void spin_unlock_irqrestore(spinlock_t *lock, unsigned long flags);
 
void spin_lock_irq(spinlock_t *lock);
void spin_unlock_irq(spinlock_t *lock);

Funcția spin_lock_irqsave dezactivează întreruperile pentru procesorul local înainte de a obține spinlock-ul; starea anterioara a întreruperilor este salvată în flags. În cazul în care sunteți siguri că întreruperile pe procesorul curent nu au fost deja dezactivate de altcineva (deci sunteți siguri că trebuie să activați întreruperile când eliberați spinlock-ul), puteți folosi funcția spin_lock_irq.

Pentru spinlock-urile de tip read / write există funcții similare:

Dacă dorim să ne sincronizăm la nivel de întrerupere (nerecomandat pentru că este dezactivarea unei întreruperi este mai lentă, nu se pot dezactiva întreruperile partajate, pot apărea deadlock-uri în situații în care lucrăm cu mai multe întreruperi) o putem face cu ajutorul funcțiilor disable_irq, disable_irq_nosync și enable_irq. Dezactivarea are loc la nivelul tuturor procesoarelor din sistem și apelurile pot să fie imbricate: dacă se apelează de două ori disable_irq vor fi necesare tot atâtea apeluri enable_irq pentru activarea ei. Diferența dintre disable_irq și disable_irq_nosync e că prima din ele va aștepta terminarea handler-elor aflate în execuție. Din această cauză disable_irq_nosync este în general mai rapidă.

Spre exemplu, următoarea secvență dezactivează și apoi activează întreruperea pentru portul serial COM1:

#define MY_IRQ 4
 
disable_irq(MY_IRQ);
enable_irq(MY_IRQ);

Este posibilă și dezactivarea tuturor întreruperilor pentru procesorul curent. Dezactivarea tuturor întreruperilor de către device drivere pentru sincronizare este neadecvată. Funcțiile care dezactivează / reactivează întreruperile pe procesorul local sunt local_irq_disable și local_irq_enable. Nu puteți folosi aceste funcții în mod singular pentru sincronizare din cauza problemelor ce apar în sisteme multiprocesor.

Utilizare spinlock-uri

Pentru a folosi o resursă partajată atât în context proces cât și în rutina de tratare a întreruperii, se vor folosi funcțiile descrise mai sus astfel:

static spinlock_t lock;
 
/* IRQ handling routine: interrupt context */
irqreturn_t so2_kbd_interrupt_handle(int irq_no, void *dev_id)
{
	[...]
	spin_lock(&lock);
	/* critical region - access shared resource */
	[...]
	spin_unlock(&lock);
	[...]
}
 
/* Process context: disable interrupts when locking */
static void my_access(void)
{
	unsigned long flags;
 
	spin_lock_irqsave(&lock, flags);
	/* critical region - access shared resource */
	spin_unlock_irqrestore(&lock, flags);
 
	[...]
}
 
void my_init(void)
{
	[...]
	spin_lock_init(&lock);
	[...]
}

Funcția my_access de mai sus rulează în context proces. Atunci când facem sincronizare dezactivăm întreruperile și folosim spinlock-ul lock, adică funcțiile spin_lock_irqsave și spin_unlock_irqrestore.

În rutina de tratare a întreruperii, folosim funcțiile spin_lock și spin_unlock pentru acces la resursa partajată.

Atunci când folosim argumentul flags în cazul funcțiilor spin_lock_irqsave și spin_unlock_irqrestore, acesta este trimis prin valoare. Funcția spin_lock_irqsave modifică valoarea flag-ului, dar această funcție este de fapt un macro și poate folosi flag-ul trimis prin valoare.

Statistici despre întreruperi

Informații și statistici despre întreruperile din sistem pot fi găsite în /proc/interrupts sau /proc/stat. În fișierul /proc/interrupts apar numai întreruperile din sistem care au câte un handler asociat:

 # cat /proc/interrupts
                CPU0
  0:         7514294       IO-APIC-edge   timer
  1:            4528       IO-APIC-edge   i8042
  6:               2       IO-APIC-edge   floppy
  8:               1       IO-APIC-edge   rtc
  9:               0       IO-APIC-level  acpi
 12:            2301       IO-APIC-edge   i8042
 15:              41       IO-APIC-edge   ide1
 16:            3230       IO-APIC-level  ioc0
 17:            1016       IO-APIC-level  vmxnet ether
NMI:               0
LOC:         7229438
ERR:               0
MIS:               0

În prima coloană se specifică numărul IRQ-ului asociat întreruperii; în coloana următoare se afișează numărul de întreruperi care au fost generate pentru fiecare procesor din sistem; ultimele două coloane oferă informații despre controller-ul de întreruperi și numele dispozitivului care a înregistrat un handler pentru acea întrerupere.

Fișierul /proc/stat oferă informații despre activitatea sistemului, inclusiv numărul de întreruperi generate de la ultima boot-are a sistemului:

# cat /proc/stat | grep intr
intr 7765626 7754228 4620 0 0 0 0 2 0 1 0 0 0 2377 0 0 41 3259 1098 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0

Fiecare linie din fișierul /proc/stat începe cu un text care specifică semnificația informațiilor de pe linie; pentru informații despre întreruperi, acest text este intr. Primul număr de pe linie reprezintă numărul total de întreruperi, iar celelalte numere reprezintă numărul de întreruperi pentru fiecare IRQ, începând de la 0. Valoarea contorului reprezintă numărul de întreruperi pentru toate procesoarele din sistem.

Resurse utile

Port serial

Port paralel

Controller tastatură

Linux

so2/laboratoare/lab05.txt · Last modified: 2017/03/21 18:04 by dan_ioan.bolohan
CC Attribution-Share Alike 3.0 Unported
www.chimeric.de Valid CSS Driven by DokuWiki do yourself a favour and use a real browser - get firefox!! Recent changes RSS feed Valid XHTML 1.0