ICLP - Laboratorul 1

Terminologie

(amintiri de la Arhitectura Sistemelor si Sisteme de Operare)

Un sistem de operare este un produs software care se ocupa cu gestionarea si coordonarea activitătilor unui sistem de calcul. El mediaza accesul programelor de aplicatie la resursele masinii.

Un shell este un produs software care asigura interfata cu utilizatorul. El este rulat de catre o consola care poarta numele de terminal pe distributiile Linux. Exista mai multe tipuri de shell in Linux, cel mai folosit fiind bash-ul (Bourne Again Shell) Un prompt in shell are urmatorul format:

username@localhost:~$
  • username reprezinta numele utilizatorului logat in acel moment
  • localhost reprezinta numele statiei de lucru
  • ~ indica directorul curent, corespunzator home-ului utilizatorului. De obicei este /home/username
  • $ marcheaza terminarea promptului si inceperea zonei unde utilizatorul poate introduce comenzi
bogdan@bmacoveiPC:~$

Comenzile sunt cuvinte cheie pe care utilizatorul le introduce in interpretorul de comenzi pentru a configura sistemul sau pentru a obtine anumite rezultate. O comanda poate fi simpla (formata dintr-un singur cuvant) sau compusa (contine argumente suplimentare separate prin spatiu si care sunt marcate, de cele mai multe ori, prin "-"). Exemple:

$ ls					"listeaza continutul directorului curent"
$ ls -l 				"listeaza continutul cu informatii aditionale - dimensiunea, data ultimei modificari etc."
$ man command_name			"ofera informatii despre comanda command_name"
$ command_name --help 			"la fel ca man"
$ cd path/folder_name 			"schimba folderul curent cu cel specificat"
$ cd .. 				"urca in ierarhia de foldere, la parinte"
$ cat file_name 			"afiseaza continutul unui fisier"
$ echo message 				"afiseaza un mesaj la standard output"
$ grep strig_to_find in_file 		"cauta string_to_find in fisierul in_file"
$ rm file 				"sterge fisierul file"
$ mkdir folder_name			"creeaza un folder cu numele folder_name"
				

GCC (GNU Compiler Collection) este o suita de compilatoare disponibila pe majoritatea distributiilor Linux. Este folosit pentru a compila o paleta larga de limbaje de programare precum C, C++, Java, Fortran, Objective-C. Un fisier sursa reprezinta un fisier text in care este scris cod corespunzator unui anumit limbaj de programare. Dintr-un fisier sursa C se obtine, prin procesul de compilare, un fisier executabil. Compilarea se realizeaza in Linux prin comanda gcc.

$ gcc main.c
(sau cu g++ pentru C++) In urma acestor comenzi va aparea un fisier denumit implicit a.out. Acesta este un fisier executabil ce poate sa fie lansat prin comanda:
$ ./a.out
Daca se doreste obtinerea unui executabil cu alt nume se poate folosi optiunea -o.
$ gcc main.c -o my-exec

GCC-ul trece prin mai multe faze de prelucrare a fisierului sursa pana la obtinerea executabilului. Folosind diverse optiuni, putem opri compilarea la una dintre fazele intermediare.
  1. Precompilarea sau preprocesarea
  2. In urma procesului de preprocesare, se realizeaza substitutii in fisierul sursa la intalnirea comenzilor de preprocesare care incep cu caracterul #. Pentru a opri gcc-ul la aceasta faza, introducem comanda:

    $ gcc -E main.c

  3. Compilarea
  4. Compilarea este faza in care, din fisierul preprocesat, se obtine un fisier in limbaj de asamblare. Pentru a opri gcc-ul la aceasta faza, introducem comanda:

    $ gcc -S main.c
  5. Asamblarea
  6. Asamblarea este faza in care codul scris in limbaj de asamblare este tradus in cod masina, reprezentand codificarea binara a instructiunilor programului initial. Fisierul obtinut poarta numele de fisier cod obiect. Pentru a opri gcc-ul la aceasta faza, introducem comanda:

    $ gcc -c main.c
In runtime-ul de C al sistemului de operare, exista functia start ce face apel la functia main, scrisa de utilizator. Aceasta are urmatorul prototip:

int main(int argc, char** argv, char** arge)
			
  • argc este numarul de argumente;
  • argv este un array de siruri de caractere, reprezentand valorile celor argc argumente. argv[argc] = NULL, iar argv[0] este numele programului executabil;
  • arge este un array de siruri de caractere, reprezentand variabilele de mediu si valorile acestora.
In general, al treiela argument lipseste.
Kernel-ul este cea mai importanta componenta a sistemului de operare. El reprezinta componenta software fundamentala ce manageriaza toate resursele calculatorului. Printre rolurile sale se numara managementul memoriei, lucrul cu serviciile de retea, managementul proceselor, accesul la periferice etc. Kernel-ul reprezinta o interfata care ii este expusa programatorului, care se ocupa de orice interactiune cu dispozitivile I/O, de retea etc.
Un API (Application Programming Interface) reprezinta o interfata prin care se pot construi aplicatii mai complexe pornind de la ceva deja scris. API-ul este strans legat de paradigma limbajului in care ne scriem programele. In limbajele procedurale precum C, avem la dispozitie proceduri pentru I/O, manipulare de stringuri, iar in limbajele orientate pe obiecte precum Java, C#, C++ avem la dispozitie clase pentru astfel de operatii.
La inceputurile sale, sistemul de operare UNIX exista in mai multe versiuni ce functionau pe calculatoare cu arhitecturi diferite, versiuni ce erau departe de a fi complet compatibile. Din acest motiv, a fost nevoie de o standardizare a modului in care utilizatorii interactiuneaza cu sistemul. Acest standard a primit numele POSIX (Portable Operating System Interface). Ultimul X este adaugat deoarece majoritatea variantelor UNIX se termina in X. POSIX documenteaza un API pentru un set de servicii ce ar trebui puse la dispozitie unui program de catre un sistem de operare. Standardul la care se conformeaza sistemele de operare din familia Windows se numeste WinAPI.
Fisierul reprezinta un concept fundamental in sistemele de operare si reprezinta modul prin care sistemul isi organizeaza informatia pe care o foloseste. Fisierul este identificat de utilizator prin nume si cale, dar sistemul il recunoaste dupa numarul sau de i-node. Acest i-node este o structura de date ce este folosita pentru a reprezenta unic un obiect din sistemul de fisiere. Exista mai multe tipuri de fisiere:
  • fisiere de tip regular: orice fisiere ce reprezinta o insiruire simpla de bytes (text, executabile etc.);
  • fisiere de tip director: fisierele care memoreaza legatura dintre numerele de i-node si numele atribuite fisierelor;
  • fisiere de tip pipe: fisiere prin care se face o comunicare unidirectionala si functioneaza pe principiul FIFO;
  • fisiere de tip socket: folosite pentru comunicare in retea;
  • fisiere de tip legatura simbolica: in zona sa de date reprezinta o referinta catre un alt i-node din sistem.
Gestionarea fisierelor se face prin intermediul a trei tabele: tabela de descriptori, tabela de fisiere deschise si tabela de i-node-uri.
  • Tabela de descriptori. Fiecare proces are o tabela de descriptori, constituita dintr-un array de structuri, indexat de la 0 la cel mai mare descriptor posibil. Daca procesul a asociat unui fisier un descriptor i (numar natural), intrarea i din aceasta tabela e completata cu informatii specifice, printre care un pointer la o intrare in tabela de fisiere deschise. De regula, primii trei descriptori sunt asignati automat, dupa cum urmeaza: 0 = intrarea standard, 1 = iesirea standard, 2 = iesirea standard pentru erori. Ei pot fi desemnati si prin urmatoarele constante simbolice, definite in "unistd.h": STDIN_FILENO (=0), STDOUT_FILENO (=1), STDERR_FILENO (=2).
  • Tabela de fisiere deschise este o tabela partaja de toate procesele. Orice intrare din tabela de descriptori, a oricarui proces, memoreaza adresa unei intrari din aceasta tabela. La deschiderea unui fisier de catre un proces, se creeaza o noua intrare in tabela de descriptori a procesului si o noua intrare in tabela de fisiere deschise. Aceasta contine un pointer catre o intrare din tabela de i-node-uri din memorie.
  • Tabela de i-node-uri. La deschiderea unui fisier, in caz ca datele despre i-node-ul corespunzator nu se gasesc deja in aceasta tabela, se creeaza o intrare.

Operatii asupra fisierelor
  • Deschiderea unui fisier
  • int open(char* file_name, int mode, mode_t permissions)
    Aceasta metoda intoarce descriptorul corespunzator intrarii din tabel pentru fisierul cu numele file_name, deschis cu modul mode si cu masca de drepturi permissions.
    In caz de succes, se intoarce cel mai mic descriptor liber, altfel se intoarce -1 si se seteaza errno.
    Headere necesare: sys/types.h, sys/stat.h, fcntl.h
  • Inchiderea unui fisier
  • int close(int descriptor)
    Inchide descriptorul de fisier deschis in prealabil. Functia intoarce 0 in caz de succes, respectiv -1 in caz de esec si seteaza errno.
    Headere necesare: unistd.h
  • Citirea din fisier
  • ssize_t read(int descriptor, void* destination, size_t bytes)
    Citeste din fisierul indicat de descriptor un numar de bytes octeti si ii pune in destination.
    Aceasta destinatie trebuie sa fie alocata in prealabil. In caz de succes, intoarce numarul de octeti cititi, care poate fi mai mic decat bytes.
    In caz de esec, intoarce -1 si seteaza errno. Headere necesare: unistd.h
  • Scrierea in fisier
  • ssize_t write(int descriptor, void* source, size_t bytes)
    Scrie in fisierul indicat de descriptor un numar de bytes octeti din source.
    In caz de succes, intoarce numarul de octeti scrisi, iar in caz de esec intoarce -1 si seteaza errno.
    Headere necesare: unistd.h

Procese


Intuitia despre procese


Un proces este o instanta a unui program. Acesta este modul prin care sistemul de operare abstractizeaza executia unui program.
Dintr-un program se pot crea mai multe procese care sunt, insa, independente logic.

Diferenta dintre un program si un proces


Un program este o entitate pasiva ce descrie modul in care ar trebui sa se execute un proces. Acesta se afla salvat pe disc sub forma unui fisier numit executabil (ELF - executable and linking format, PE - portable executable).
Fisierele executabile sunt, din punctul de vedere al sistemului de operare, fisiere de tip regular.
Un proces este o entitate activa ce reprezinta imaginea in memoria principala (RAM) a unui program. Pentru orice cerere a utilizatorului, sistemul de operare laneaza un proces pentru a o satisface. De gestiunea proceselor se ocupa kernel-ul sistemului de operare. Pentru a putea realiza acest lucru, acesta are nevoie sa tina intern o structura pentru fiecare proces existent, numita PCB - Process Control Block.

Process Control Block


pointer process state
process number
process counter

registers

memory limits
list of open files
...

Unele dintre cele mai importante informatii continute de un PCB sunt:
PID-ul (process identification) reprezinta identificatorul unic al unui proces din sistem. Aceasta este cea mai importanta caracteristica a unui proces si este folosita in majoritatea API-urilor POSIX de lucru cu procese. Pentru a obtine PID-ul curent, se utilizeaza functia
pid_t getpid()
iar pentru a intoarce PID-ul parintelui, se foloseste functia
pid_t getppid()
(ambele functii sunt in unistd.h)
Pentru a crea un proces nou, se utilizeaza apelul sistem
pid_t fork()
Apelul este declarat in unistd.h. Executarea acestui apel realizeaza un nou proces, copie fidela a procesului ce a efectuat apelul. Sistemul ii va aloca un nou PID si o parte dintre datele si resursele procesului apelant. Apelul returneaza PID-ul fiului in contextul procesului tata, respectiv 0 in contextul procesului fiu. Daca apelul esueaza, atunci intoarce -1.

Procesele sunt organizate sub forma ierarhica pe principiul tata-fiu si sunt abstractizate in sistemul virtual de fisiere din /proc, unde pentru fiecare PID exista un director aferent. Primul proces lansat de kernel, dupa ce a fost incarcat in memorie de catre bootloader este init. Acesta are PID-ul 1 si este lansat dupa imaginea din /sbin/init. Procesul init este tatal majoritatea proceselor de sistem, ce asigura buna functionare a calculatorului. Un proces poate avea oricati fii, dar doar un singur tata. Pentru a afisa ierarhia de procese ce sunt active la un moment dat in sistem se foloseste comanda:
$ pstree
O alta caracteristica importanta a unui proces este id-ul proprietarului. Acesta poate sa fie diferit de proprietarul fisierului executabil din care s-a creat procesul, pentru ca id-ul este al utilizatorului care a lansat procesul.
Pe parcursul executiei unui proces, el se poate afla in doua moduri: user mode (cand executa instructiuni de utilizator, executa algoritmi, rezolva probleme, lucruri uzuale) sau kernel mode (atunci cand executa instructiuni privilegiate precum scrierea pe disk, citirea de pe un socket de retea, afisare pe ecran). Un proces intra in kernel mode atunci cand executa un apel de sistem. Intrucat, din punctul de vedere al programatorului, un apel sistem seamana cu un apel normal de functie C, putem imparti functiile din C in doua categorii: functii de biblioteca (functii uzuale, precum cele de manipulare de stringuri, precum strlen(), strcat() etc) si functii de apel de sistem.
Pentru a afla informatii despre procesele ce ruleaza in sistem se foloseste comanda:
$ ps
Lansata fara niciun argument, aceasta va afisa procesele ce a ufost lansate de la terminalul curent. Pentru a afisa informatii despre toate proceslee din sistem, se utilizeaza cu argumentele
$ ps -e 
$ ps -p pid1, pid2, ..., pidn
$ ps -C cmd1, cmd2, ..., cmdn 
$ ps -u user1, user2, ..., usern
Comanda
$ top
afiseaza, in mod dinamic, informatii complete despre procesele ce ruleaza in sistem.

Starile procesului


Un proces, pe toate durata executiei sale, se poate afla intr-una dintre urmatoarele stari. Starea curenta este tinuta in PCB. Starile sunt:
In timp ce este in RUNNING, un proces poate executa instructiuni de utilizator, si atunci spunem ca este in user space, sau instructiuni de sistem privilegiate, si atunci spunem ca este in kernel space. Un proces poate sa fie rulat in doua moduri, in foreground (acapareaza terminalul curent, poate sa citeasca input de la utilizator, iar prompt-ul shell-ului ne revine doar atunci cand procesul s-a terminat), sau in background (atunci cand prompt-ul de la shell ne revine imediat, procesul isi continua executia, insa nu mai poate primi input de la standard input).

Memoria virtuala


Sistemele de operare moderne o facilitate foarte importanta care se numeste multitasking. Aceasta facilitate inseamna ca sistemul poate tine in memoria principala mai multe programe ce pot sa fie executate pe procesor intr-un mod echitabil prin ceea ce se numeste context switching.
Aceasta abordare aduce, insa, o problema: spatiul de adresare a doua procese poate sa se suprapuna si sa cauzeze o alterare a integritatii datelor. Astfel, s-a introdus un mecanism ce se numeste memorie virtuala. Prin acest mecanism, spatiul de adresare a unui proces este virtualizat - adresele sale sunt, de fapt, adrese virtuale ce sunt mapate la adrese fizice de catre o componenta din procesor ce poarta numele MMU - Memory Management Unit.
Memoria virtuala a unui proces se imparte in mai multe segmente. Acestea sunt zona de text (contine instructiunile cod masina ale procesului), zona de date initializate (contine variabilele globale si statice initializate ce sunt citite din executabil la momentul incarcarii programului), zona de date neinitializate (contine variabile globale si statice neinitializate, care sunt definite doar la nivel de simbol si sunt initializate cu valori aleatoare la runtime), stack-ul (zona de memorie a carei dimensiuni este definita dinamic la runtime, unde sunt retinute variabilele locale si argumentele functiilor) si heap-ul (zona de memorie unde se pot aloca variabile dinamic).

Terminarea proceselor. Procese zombie. Sincronizarea tata-fiu


Un proces se poate termina anormal (atunci cand apar exceptii - efectueaza o impartire la 0, dereferentiaza un pointer NULL etc), sau normal (atunci cand executa un apel de sistem _exit()).

Procese zombie si sincronizarea tata-fiu. De cele mai multe ori, procesele tata si fiu nu se termina in acelasi timp, intre ele existand un comportament asincron. Se disting trei scenarii, proceslee se termina in acelasi timp, procesul tata se termina inaintea procesului fiu (caz in care procesul fiu devine orfan si este adoptat de un proces a carui imagine a fost comanda /sbin/init), respectiv procesul fiu se termina inaintea procesului tata (caz in care procesul fiu trece in starea ZOMBIE, asteptand ca tatal sa ia act de terminarea sa. Daca, totusi, tatal nu ia act de terminarea sa, atunci noul tata, init, va lua act de terminarea sa si zombie-ul va iesi din tabela de procese a sistemului).

Procesul tata poate executa urmatorul apel pentru a lua la cunostinta terminarea proceselor fiu:
#include < sys/types.h >
#include < sys/wait.h >
pid_t wait(int* status)
Daca nu exista niciun proces fiu, atunci apelul va returna -1 si va seta errno. Daca exista procese fiu, atunci apelul va bloca procesul curent pana cand unul dintre acestea se va termina si va intoarce PID-ul primului proces returnat si va stoca starea sa in adresa indicata de pointerul transmis ca parametru. Avem si varianta de a astepta dupa un proces cu un anumit pid:
pid_t waitpid(pid_t pid, int* status, int options)