Laborator 3 - Kernel API

Obiectivele laboratorului

  • familiarizarea cu API-ul de bază pentru nucleul Linux
  • descrierea mecanismelor de alocare a memoriei
  • descrierea mecanismelor de locking

Cuvinte cheie

  • contexte de execuție
  • printk
  • kmalloc / kfree
  • list_head
  • spinlock_t
  • struct semaphore
  • atomic_t

Materiale ajutătoare

Noțiuni generale

În cadrul laboratorului curent se prezintă un set de concepte și funcții de bază necesare programării kernel. Este important de reținut faptul că programarea kernel diferă extrem de mult față de programarea în user space. Kernel-ul este o entitate de sine stătătoare, care nu poate folosi bibliotecile din user-space (nici chiar libc în Linux sau kernel32.dll în Windows). Drept urmare, funcțiile uzuale utilizate în user-space (printf, malloc, free, open, read, write, memcpy, strcpy etc.) nu mai pot fi folosite. În concluzie, programarea kernel se bazează pe un API total nou și independent, ce nu are legătură cu API-ul din user-space, fie că ne referim la POSIX, Win32 sau ANSI C (funcțiile standard de bibliotecă pentru limbajul C).

Accesarea memoriei

O diferență importantă în programarea kernel este modul de accesare și alocare a memoriei. Din cauza faptului că programarea kernel se face la un nivel foarte aproape de mașina fizică, există reguli importante în ceea ce privește gestiunea memoriei. În primul rând, se lucrează cu mai multe tipuri de memorie:

  • memorie fizică
  • memorie virtuală din spațiul de adresare kernel
  • memorie virtuală din spațiul de adresare al unui proces
  • memorie rezidentă – știm sigur că paginile accesate sunt prezente în memoria fizică

Memoria virtuală din spațiul de adresare al unui proces nu poate fi considerată rezidentă din cauza mecanismelor de memorie virtuală implementate de sistemul de operare: paginile pot fi în swap, sau pur și simplu pot să nu fie prezente în memoria fizică drept rezultat al mecanismului de demand paging. Memoria din spațiul de adresare kernel poate fi rezidentă sau nu. Atât segmentele de date și cod ale unui modul, cât și stiva kernel a unui proces sunt rezidente (în Windows, dacă se dorește, și acestea se pot swapa). Memoria dinamică poate fi sau nu rezidentă, în funcție de modul în care se alocă.

Atunci când se lucrează cu memorie rezidentă lucrurile sunt simple: memoria se poate accesa oricând. Dacă se lucrează însă cu memorie nerezidentă, atunci aceasta se poate accesa doar din anumite contexte. Memoria nerezidentă se poate accesa doar din context proces. Accesarea memoriei nerezidente din context întrerupere are rezultate impredictibile și, din această cauză, atunci când sistemul de operare detectează un astfel de acces, va lua măsuri drastice: blocarea sau resetarea sistemului, pentru a preveni coruperi grave.

Memoria virtuală a unui proces nu se poate accesa direct din kernel. În general este descurajată total accesarea spațiului de adresă al unui process, dar există situații în care un device driver trebuie să o facă. Cazul tipic este cel în care device driver-ul trebuie să acceseze un buffer din user-space. În acest caz, device driverul trebuie să folosească funcții speciale și nu să acceseze direct bufferul. Acest lucru este necesar pentru a preveni accesarea unor zone invalide de memorie.

O altă diferență față de programarea din userspace, relativ la lucrul cu memoria, este datorată stivei, stivă a cărei dimensiune este fixă și limitată. În nucleul Linux 2.6.x se folosește implicit o stivă de 4K, iar în Windows se folosește o stivă de 12K. Din această cauză, trebuie evitate alocarea unor structuri de mari dimensiuni pe stivă sau folosirea apelurilor recursive.

Contexte de execuție

Relativ la modul de execuție în kernel, distingem două contexte: context proces și context întrerupere. Ne aflăm în context proces atunci când rulăm cod ca urmare a unui apel de sistem sau când rulăm în contextul unui kernel thread. Atunci când rulăm în rutina de tratare a unei întreruperi sau a unei acțiuni amânabile, rulăm în context întrerupere.

Unele dintre apelurile din API-ul kernel pot duce la blocarea procesului curent. Exemple comune sunt folosirea unui semafor sau așteptarea unei condiții. În acest caz, procesul este trecut în starea WAITING și alt proces este rulat. O situație interesantă apare în momentul în care o funcție ce poate duce la suspendarea procesului curent este chemată din context întrerupere. În acest caz, nu există un proces curent, și din această cauză rezultatele sunt impredictibile. De câte ori sistemul de operare detectează această condiție va genera o condiție de eroare care va duce la oprirea sistemului de operare.

Locking

Una dintre cele mai importante caracteristici ale programării în kernel este paralelismul. Atât Linux, cât și Windows suportă sisteme SMP, cu mai multe procesoare, dar și preemptivitate în kernel. Acest lucru face programarea kernel mai dificilă, deoarece accesul la variabilele globale trebuie sincronizat, fie cu primitive de spinlock, fie cu primitive blocante. Deși este recomandat să se folosească primitive blocante, acestea nu pot fi folosite în context întrerupere, așa că singura soluție de locking în context întrerupere sunt spinlock-urile.

Spinlock-urile sunt folosite pentru realizarea excluderii mutuale. Atunci când nu pot obține accesul la regiunea critică nu suspendă procesul curent, ci folosesc mecanismul de busy-waiting (așteaptă într-un ciclu while eliberarea lock-ului). Codul care se execută în regiunea critică protejată de un spinlock nu are voie să suspende procesul curent (trebuie să respecte condițiile execuției în context întrerupere). Mai mult, nu se va ceda procesorul decât pentru servirea întreruperilor. Datorită mecanismului folosit, este important ca un spinlock să fie deținut cât mai puțin timp posibil.

Preemptivitate

Atât Linux, cât și Windows folosesc nuclee preemptive. Nu trebuie confundată noțiunea de multitasking preemptiv cu noțiunea de kernel preemptiv. Noțiunea de multitasking preemptiv se referă la faptul că sistemul de operare întrerupe rularea unui proces în mod forțat, atunci când acestuia i-a expirat cuanta de timp și rulează în user-space, pentru a rula alt proces. Un kernel este preemptiv dacă un proces ce rulează în kernel-mode (ca urmare a unui apel de sistem) poate fi întrerupt pentru a rula un alt proces.

Datorită preemptivității, atunci când partajăm resurse între două porțiuni de cod ce pot rula din contexte proces diferite, trebuie să ne protejăm cu primitive de sincronizare, chiar și în cazul uni-procesor.

Linux Kernel API

Convenție indicare erori

Pentru programarea în nucleul Linux, convenția folosită la apelul funcțiilor pentru a indica succes este identică cu cea din programarea UNIX: 0 pentru success, sau o valoare diferită de 0 pentru insucces. Pentru insucces se returnează valori negative, așa cum este prezentat în exemplul de mai jos:

if (alloc_memory() != 0)
    return -ENOMEM;
 
if (user_parameter_valid() != 0)
    return -EINVAL;

Lista exhaustivă a erorilor și o sumară explicație găsiți în include/asm-generic/errno-base.h și include/asm-generic/ernno.h.

Șiruri de caractere

În Linux, programatorului de kernel i se pun la dispoziție funcțiile uzuale de lucru pe șiruri: strcpy, strncpy, strlcpy, strcat, strncat, strlcat, strcmp, strncmp, strnicmp, strchr, strnchr, strrchr, strstr, strlen, memset, memcpy, memmove, memscan, memcmp, memchr. Aceste funcții sunt declarate în headerul include/linux/string.h și sunt implementate în kernel în fișierul lib/string.c.

printk

Echivalentul printf în kernel este printk, definit în include/linux/printk.h. Sintaxa printk seamănă foarte mult cu cea a printf. Primul parametru al printk decide categoria de mesaje în care se încadrează mesajul curent:

#define KERN_EMERG   "<0>"  /* system is unusable */
#define KERN_ALERT   "<1>"  /* action must be taken immediately */
#define KERN_CRIT    "<2>"  /* critical conditions */
#define KERN_ERR     "<3>"  /* error conditions */
#define KERN_WARNING "<4>"  /* warning conditions */
#define KERN_NOTICE  "<5>"  /* normal but significant condition */
#define KERN_INFO    "<6>"  /* informational */
#define KERN_DEBUG   "<7>"  /* debug-level messages */

Astfel, un mesaj în kernel de tip warning ar fi trimis cu:

printk(KERN_WARNING "my_module input string %s\n", buff);

În cazul în care nivelul de logging lipsește din apelul printk, se realizează logging cu nivelul implicit de la momentul apelului. Un lucru ce trebuie reținut este că mesajele trimise cu printk sunt vizibile doar pe consolă 1) și doar dacă nivelul lor depășește nivelul implicit setat pe consolă 2).

Pentru a reduce dimensiunea linilor atunci când se folosește printk, se recomandă folosirea următoarelor funcții ajutătoare, în loc de a folosi direct apelul printk:

pr_emerg(fmt, ...); /* echivalent cu printk(KERN_EMERG pr_fmt(fmt), ...); */
pr_alert(fmt, ...); /* echivalent cu printk(KERN_ALERT pr_fmt(fmt), ...); */
pr_crit(fmt, ...); /* echivalent cu printk(KERN_CRIT pr_fmt(fmt), ...); */
pr_err(fmt, ...); /* echivalent cu printk(KERN_ERR pr_fmt(fmt), ...); */
pr_warning(fmt, ...); /* echivalent cu printk(KERN_WARNING pr_fmt(fmt), ...); */
pr_warn(fmt, ...); /* echivalent cu cu printk(KERN_WARNING pr_fmt(fmt), ...); */
pr_notice(fmt, ...); /* echivalent cu printk(KERN_NOTICE pr_fmt(fmt), ...); */
pr_info(fmt, ...); /* echivalent cu printk(KERN_INFO pr_fmt(fmt), ...); */

Un caz special este pr_debug care apeleaza funcția printk doar atunci când macroul DEBUG este definit sau dacă se folosește dynamic debugging.

Alocare memorie

În Linux se poate aloca doar memorie rezidentă, cu ajutorul apelului kmalloc. Un apel tipic kmalloc este prezentat în continuare:

#include <linux/slab.h>
 
string = kmalloc (string_len + 1, GFP_KERNEL);
if (!string) {
    //report error: -ENOMEM;
}

După cum se observă, primul parametru indică dimensiunea în octeți a zonei de alocat. Funcția întoarce un pointer către o zonă de memorie ce poate fi folosită direct în kernel, sau NULL dacă nu s-a putut aloca memorie. Cel de-al doilea parametru specifică modul în care se dorește să se facă alocarea, iar cele mai folosite valori sunt:

  • GFP_KERNEL - folosirea acestei valori poate duce la suspendarea procesului curent; astfel, nu poate fi folosită în context întrerupere;
  • GFP_ATOMIC - atunci când se folosește această valoare se garantează ca funcția kmalloc nu suspendă procesul curent; poate fi folosită oricând.

Complementara funcției kmalloc este kfree, funcție ce primește ca argument o zonă alocată de kmalloc. Această funcție nu suspendă procesul curent și, în consecință, poate fi apelată din orice context.

Liste

Pentru că listele înlănțuite sunt deseori folosite, Linux kernel API pune la dispoziție o modalitate unitară de definire și folosire a listelor. Aceasta implică folosirea unui element de tipul struct list_head în cadrul structurii pe care vrem să o considerăm nod al unei liste. Structura list_head este definită în include/linux/list.h alături de toate celelalte funcții ce lucrează pe liste. Codul următor arată definiția structurii list_head și folosirea unui element din acest tip într-o altă structură bine cunoscută din kernelul Linux:

struct list_head {
    struct list_head *next, *prev;
};
 
struct task_struct {
    ...
    struct list_head children;
    ...
};

Rutinele uzuale pentru lucrul cu liste sunt următoarele:

  • LIST_HEAD(name) este folosit pentru a declara santinela unei liste
  • INIT_LIST_HEAD(struct list_head *list) se folosește pentru a inițializa santinela unei liste, atunci când alocarea se face în mod dinamic, prin setarea valorii câmpurilor next și prev la list.
  • list_add(struct list_head *new, struct list_head *head) adaugă elementul new după elementul head.
  • list_del(struct list_head *entry) șterge elementul aflat la adresa entry din lista din care face parte.
  • list_entry(ptr, type, member) întoarce stuctura de tip type care conține elementul ptr din listă cu numele member în cadrul structurii.
  • list_for_each(pos, head) iterează o listă, folosind pos drept cursor.
  • list_for_each_safe(pos, n, head) iterează o listă, folosind pos drept cursor și n cursor temporar. Acest macro este folosit în cazul în care se dorește ștergerea unui element din listă.

Următorul cod arată modul de folosire a acestor rutine:

#include <linux/slab.h>
#include <linux/list.h>
 
struct pid_list {
    pid_t pid;
    struct list_head list;
};
 
LIST_HEAD(my_list);
 
static int add_pid(pid_t pid)
{
    struct pid_list *ple = kmalloc(sizeof *ple, GFP_KERNEL);
 
    if (!ple)
        return -ENOMEM;
 
    ple->pid = pid;
    list_add(&ple->list, &my_list);
 
    return 0;
}
 
static int del_pid(pid_t pid)
{
    struct list_head *i, *tmp;
    struct pid_list *ple;
 
    list_for_each_safe(i, tmp, &my_list) {
        ple = list_entry(i, struct pid_list, list);
        if (ple->pid == pid) {
            list_del(i);
            kfree(ple);
            return 0;
        }
    }
 
    return -EINVAL;
}
 
static void destroy_list(void)
{
    struct list_head *i, *n;
    struct pid_list *ple;
 
    list_for_each_safe(i, n, &my_list) {
        ple = list_entry(i, struct pid_list, list);
        list_del(i);
        kfree(ple);
    }
}

Evoluția listei poate fi văzută în următoarea figură:

Structura listelor kernel

Se observă comportamentul de tip stivă introdus de macro-ul list_add, precum și folosirea unei santinele.

Din exemplul de mai sus se observă că modalitatea de definire și folosire a unei liste (dublu înlănțuite) este generică și, în același timp, nu introduce un overhead suplimentar. Structura list_head este folosită pentru a menține legăturile între elementele listei. Se observă, de asemenea, că iterarea prin listă se face tot cu ajutorul acestei structuri, iar obținerea elementelor din listă se face cu ajutorul list_entry. Această idee de implementare și folosire a unei liste nu este nouă, ea fiind descrisă în The Art of Computer Programming de Donald Knuth în anii '80.

Mai multe funcții și macrodefiniții de lucru cu liste kernel sunt prezentate și explicate în headerul include/linux/list.h.

Locking

Spinlock-uri

spinlock_t (definit in linux/spinlock.h) este tipul de bază ce implementează conceptul de spinlock în Linux. El descrie un spinlock, iar operațiile asociate cu un spinlock sunt spin_lock_init, spin_lock, spin_unlock. Un exemplu de utilizare este prezentat mai jos:

#include <linux/spinlock.h>
 
DEFINE_SPINLOCK(lock1);
spinlock_t lock2;
 
spin_lock_init(&lock2);
 
spin_lock(&lock1);
/* critical region */
spin_unlock(&lock1);
 
spin_lock(&lock2);
/* critical region */
spin_unlock(&lock2);

În Linux se pot folosi spinlock-uri de tip read/write, utile în probleme de genul cititori-scriitor. Aceste tipuri de lockuri sunt identificate de rwlock_t, iar funcțiile cu care se poate opera asupra unui spinlock de tip read/write sunt rwlock_init, read_lock, write_lock. Un exemplu de utilizare:

#include <linux/spinlock.h>
 
DEFINE_RWLOCK(lock);
 
struct pid_list {
    pid_t pid;
    struct list_head list;
}; 
 
int have_pid(struct list_head *lh, int pid)
{
    struct list_head *i;
    void *elem;
 
    read_lock(&lock);
    list_for_each(i, lh) {
        struct pid_list *pl = list_entry(i, struct pid_list, list);
        if (pl->pid == pid) {
            read_unlock(&lock);
            return 1;
        }
    }
    read_unlock(&lock);
 
    return 0;
}
 
void add_pid(struct list_head *lh, struct pid_list *pl)
{
    write_lock(&lock);
    list_add(&pl->list, lh);
    write_unlock(&lock);
}

Mutex

Un mutex este reprezentat de o variabilă de tipul struct mutex (definit în linux/mutex.h). Funcțiile și macro-urile pentru lucrul cu mutex sunt prezentate în continuare:

#include <linux/mutex.h>
 
/* functii pentru initializarea mutexului */
void mutex_init(struct mutex *mutex);
DEFINE_MUTEX(name);
 
/* functii pentru achiziționarea mutexului */
void mutex_lock(struct mutex *mutex);
 
/* functie pentru eliberarea semaforului */
void mutex_unlock(struct mutex *mutex);

Operațiile sunt similare cu operațiile clasice ale mutexului din userspace sau cu operațiile spinlock-ului: mutex-ul se achiziționează înainte de intrarea în zona critică și se eliberează la ieșirea din zona critică. Spre deosebire de spin-lock-uri, aceste operații se pot folosi doar în context proces.

Variabile atomice

De multe ori, este nevoie doar de sincronizarea accesului la o variabilă simplă, de exemplu un contor. Pentru aceasta se poate folosi o variabilă de tip atomic_t (definit în include/linux/atomic.h) care ține o valoare întreagă. Mai jos sunt prezentate unele operații care pot fi efectuate asupra unei variabile atomic_t:

#include <asm/atomic.h>
 
void atomic_set(atomic_t *v, int i);
int atomic_read(atomic_t *v);
void atomic_add(int i, atomic_t *v);
void atomic_sub(int i, atomic_t *v);
void atomic_inc(atomic_t *v);
void atomic_dec(atomic_t *v);
int atomic_inc_and_test(atomic_t *v);
int atomic_dec_and_test(atomic_t *v);
int atomic_cmpxchg(atomic_t *v, int old, int new);

Utilizarea variabilelor atomice

Un mod frecvent de utilizare a variabilelor atomice este pentru a menține starea unei acțiuni (de exemplu un flag). Putem folosi astfel o variabilă atomică pentru a marca acțiuni exclusive. De exemplu, considerăm că o variabilă atomică poate avea valorile LOCKED și UNLOCKED și, dacă LOCKED atunci o funcție anume să se întoarcă cu un mesaj -EBUSY. Modul de folosire este indicat schematic în codul de mai jos:

#define LOCKED		0
#define UNLOCKED	1
 
static atomic_t flag;
 
static int my_acquire(void)
{
	int initial_flag;
 
	/*
	 * Check if flag is UNLOCKED; if not, lock it and do it atomically.
	 *
	 * This is the atomic equivalent of
	 * 	if (flag == UNLOCKED)
	 * 		flag = LOCKED;
	 * 	else
	 * 		return -EBUSY;
	 */
	initial_flag = atomic_cmpxchg(&flag, UNLOCKED, LOCKED);
	if (initial_flag == LOCKED) {
		printk(KERN_ALERT "Already locked.\n");
		return -EBUSY;
	}
 
	/* Do your thing after getting the lock. */
	[...]
}
 
static void my_release(void)
{
	/* Release flag; mark it as unlocked. */
	atomic_set(&flag, UNLOCKED);
}
 
void my_init(void)
{
	[...]
	/* Atomic variable is initially unlocked. */
	atomic_set(&flag, UNLOCKED);
 
	[...]
}

Codul de mai sus este echivalentul folosirii unei operații de tipul trylock (precum pthread_mutex_trylock).


Putem, de asemenea, folosi o variabilă pentru a reține dimensiunea unui buffer și pentru actualizări atomice ale acesteia. De exemplu codul de mai jos:

static unsigned char buffer[MAX_SIZE];
static atomic_t size;
 
static void add_to_buffer(unsigned char value)
{
	buffer[atomic_read(&size)] = value;
	atomic_inc(&size);
}
 
static unsigned char remove_from_buffer(void)
{
	unsigned char value;
 
	value = buffer[atomic_read(&size)];
	atomic_dec(&size);
 
	return value
}
 
static void reset_buffer(void)
{
	atomic_set(&size, 0);
}
 
void my_init(void)
{
	[...]
	/* Initilized buffer and size. */
	atomic_set(&size, 0);
	memset(buffer, 0, sizeof(buffer));
 
	[...]
}

Operatii atomice pe biți

Kernelul pune la dispoziție un set de funcții (în asm/bitops.h) care modifică sau testează biți în mod atomic.

#include <asm/bitops.h>
 
void set_bit(int nr, void *addr);
void clear_bit(int nr, void *addr);
void change_bit(int nr, void *addr);
int test_and_set_bit(int nr, void *addr);
int test_and_clear_bit(int nr, void *addr);
int test_and_change_bit(int nr, void *addr);

addr reprezintă adresa zonei de memorie ai cărei biți se modifică sau testează, iar nr reprezintă bitul asupra căruia se efectuează operația.

Resurse utile

1) În Linux consola este terminalul virtual curent; din această cauză, atunci când folosiți X Windows, aceste mesaje nu or sa apară în emulatorul de terminal xterm. Le puteți afișa, însă, folosind comanda dmesg sau accesând fișierul de logging /var/log/syslog.
2) Pentru mai multe detalii despre configurări pentru logging consultați Laboratorul 2.
so2/laboratoare/lab03.txt · Last modified: 2017/03/06 12:04 by razvan.deaconescu
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