Table of Contents

Laborator 02: Toolchain

În acest laborator, vom trece prin fiecare nivel de procesare a unui limbaj de nivel înalt și prin toolchain-ul pe care îl vom folosi de acum încolo.

C basics: GOTOs

Un concept mai puțin abordat în tutoriale de C este instrucțiunea goto. Prin instrucțiunea goto, un program poate sări în puncte intermediare în cadrul unei funcții. Aceste puncte intermediare se numesc label-uri (etichete).

Un exemplu de cod:

#include <stdio.h>
 
int main()
{
    int i, j, k;
    /* some code */
do_some_work:
    /* some other code */
    work();
    if (any_work())
        goto do_some_work;
    /* some code */
    return 0;
}

Programul execută un job prin work(). În caz că mai sunt alte joburi neterminate, programul sare la labelul do_some_work.
do_some_work este punctul din program în care începe procesarea unui nou job. Acesta e marcat printr-un nume urmat de :. Pentru a sări la acest punct din program se folosește instrucțiunea goto urmată de numele etichetei declarate. Prin diferite combinații de if-uri si goto-uri se pot echivala alte instrucțiuni din C, cum ar fi else, for si while.
Codul dat exemplu mai sus ar putea fi un candidat care să înlocuiască o instrucțiune do { /* ... */ } while ();:

#include <stdio.h>
 
int main()
{
    int i, j, k;
    /* some code */
    do {
        /* some other code */
        work();
    } while (any_work());
    /* some code */
    return 0;
}

The "WHYs" of goto

Această instrucțiune nu doar că adesea lipsește din tutorialele de C, dar se fac recomandări împotriva abordării ei deoarece de cele mai multe ori duce la cod ofuscat (greu de înțeles, întreținut și depanat).
Există totuși mici cazuri în care este folosit. În codul kernel-ului de Linux, instrucțiunile de goto sunt folosite ca o formă de try-catch din limbaje de nivel mai înalt (precum C++, Java, C#, etc.).
Exemplu:

int process_data_from_mouse_device(...)
{
    int err;
    int x, y;
 
    /* >>try<< instructions */
    err = init_communication_with_mouse();
    if (err)
        goto error;
 
    err = get_x_coord_from_mouse(&x);
    if (err)
        goto error;
 
    err = get_y_coord_from_mouse(&y);
    if (err)
        goto error;
 
    err = announce_upper_layers_of_mouse_movement(x, y);
    if (err)
        goto error;
 
    err = close_communication_with_mouse();
    if (err)
        goto error;
 
    return 0;
 
    /* >>catch<< instructions' exceptions */
error:
    print_message("Failed to get data from mouse device. Error = %d", err);
    return err;
}

Acest cod încearcă să proceseze datele venite de la un mouse și sa le paseze altor părți superioare din kernel care le-ar putea folosi. În caz că apare vreo eroare, se afișează un mesaj de eroare și se termină procesarea datelor. Codul pare corect, dar nu prea este. Nu este corect pentru că în caz că apare o eroare în mijlocul funcției, comunicația cu mouse-ul este lăsată deschisă.
O variantă mai corectă ar fi următoarea:

int process_data_from_mouse_device(...)
{
    int err;
    int x, y;
 
    /* >>try<< instructions */
    err = init_communication_with_mouse();
    if (err)
        goto error;
 
    err = get_x_coord_from_mouse(&x);
    if (err)
        goto error_close_connection;
 
    err = get_y_coord_from_mouse(&y);
    if (err)
        goto error_close_connection;
 
    err = announce_upper_layers_of_mouse_movement(x, y);
    if (err)
        goto error_close_connection;
 
    err = close_communication_with_mouse();
    if (err)
        goto error;
 
    return 0;
 
    /* >>catch<< instructions' exceptions */
error_close_connection:
    close_communication_with_mouse();
error:
    print_message("Failed to get data from mouse device. Error = %d", err);
    return err;
}

În varianta îmbunătățită, dacă apare o eroare, se face și o parte de curățenie: conexiunea cu mouse-ul va fi închisă, și apoi codul va continua cu tratarea generală a oricărei erori din program (afișarea unui mesaj de eroare).

Totuși acest curs/laborator de ce abordează un astfel de subiect?
Când vom ajunge în limbajul de asamblare se va putea observa ca o bună parte din instrucțiunile unui limbaj de nivel înalt, chiar și precum C, sunt inexistente, iar o bună parte din workflow seamănă cu un program format din goto-uri.

În orice alt caz, această formă de programare ar trebui evitată pe cât posibil.

De la C la program în rulare

Un compilator trece prin următoarele etape:

În etapa de compilare codul este tradus din cod de nivel înalt în limbaj de asamblare. Limbajul de asamblare este o formă human-readable a ce ajunge procesorul să execute efectiv. Dacă programele scrise în limbaje de nivel înalt ajung să fie portate ușor pentru procesoare diferite (arm, powerpc, x86, etc.), cele scrise în limbaj de asamblare sunt implementări specifice unei anumite arhitecturi. Limbaje de nivel înalt reprezintă o formă mai abstractă de rezolvare a unei probleme, din punctul de vedere al unui procesor, motiv pentru care și acestea trebuie traduse în limbaj de asamblare în cele din urmă, pentru a se putea ajunge la un binar care poate fi rulat. Mai multe detalii în laboratorul următor.

În imaginea de mai jos sunt reprezentate etape prin care trece un program din momentul în care este scris până când este rulat ca un proces. Putem observa între acestea etapele descrise mai sus: preprocesare, compilare, asamblare, link-editare.

 Etapele procesului de compilare: de la program la proces

Majoritatea compilatoarelor oferă opțiunea de a genera și un fișier cu programul scris în limbaj de asamblare.

În cazul compilatorului gcc este de ajuns să adăugați flag-ul -S și vă va genera un fișier *.s cu codul aferent.

În arhiva de aici aveți un exemplu de trecere a unui program foarte simplu hello.c prin cele patru faze. Îl puteți testa pe un sistem Unix/Linux și pe un sistem Windows cu suport de MinGW. Pe sistemele din laborator există suport MinGW și îl veți putea testa folosind comanda make la fel ca mai jos și apoi diversele fișiere:

$ make
cc  -E -o hello.i hello.c
cc -Wall -S -o hello.s hello.i
cc  -c -o hello.o hello.s
cc  -o hello hello.o

$ ls
Makefile  hello  hello.c  hello.i  hello.o  hello.s

$ ./hello
Hello, World!

$ tail -10 hello.i



# 5 "hello.c"
int main(void)
{
 puts("Hello, World!");

 return 0;
}

$ cat hello.s
	.file	"hello.c"
	.section	.rodata
.LC0:
	.string	"Hello, World!"
	.text
	.globl	main
	.type	main, @function
main:
.LFB0:
	.cfi_startproc
	pushq	%rbp
	.cfi_def_cfa_offset 16
	.cfi_offset 6, -16
	movq	%rsp, %rbp
	.cfi_def_cfa_register 6
	movl	$.LC0, %edi
	call	puts
	movl	$0, %eax
	popq	%rbp
	.cfi_def_cfa 7, 8
	ret
	.cfi_endproc
.LFE0:
	.size	main, .-main
	.ident	"GCC: (Debian 5.2.1-17) 5.2.1 20150911"
	.section	.note.GNU-stack,"",@progbits

$ file hello.o
hello.o: ELF 64-bit LSB relocatable, x86-64, [...]

$ file hello
hello: ELF 64-bit LSB executable, x86-64, [...]

$ objdump -d hello.o

hello.o:     file format elf64-x86-64


Disassembly of section .text:

0000000000000000 <main>:
   0:	55                   	push   %rbp
   1:	48 89 e5             	mov    %rsp,%rbp
   4:	bf 00 00 00 00       	mov    $0x0,%edi
   9:	e8 00 00 00 00       	callq  e <main+0xe>
   e:	b8 00 00 00 00       	mov    $0x0,%eax
  13:	5d                   	pop    %rbp
  14:	c3                   	retq 

Toolchain

În cadrul laboratorului vom folosi asamblorul NASM. Acesta este foarte folosit în tutoriale și cărți legate de scrierea programelor în limbaje de asamblare.
Ca IDE vom folosi SASM. Pe Windows dispune de un installer de tipul next-next-next. Vine cu tool-urile aferente(inclusiv NASM) și este preconfigurat corespunzător.
Ambele programe menționate sunt open-source și valabile pe toate platformele cunoscute (Windows, Linux și MacOS).

Exerciții:

1. [20p] Online C Compiling

Un tool interesant pentru a observa cum se traduce codul C în limbaj de asamblare este Compiler Explorer.

  1. Intrați pe Compiler Explorer.
  2. Încărcați programul “sum over array” din exemple (accesibile folosind intrările Source: Examples și Name: sum over array).
  3. Asigurați-vă că x86 gcc 4.8.2 este selectat la Compiler:.
  4. Selectați Intel syntax. Acesta este un format mai ușor de digerat și mai asemănător formatului acceptat de NASM.
  5. Folosiți opțiunea -m32 (la Compiler options) pentru a afișa cod în limbaj de asamblare pe 32 de biți (față de 64 de biți în mod implicit).
  6. Pentru a putea să vă dați seama ce cod C s-a tradus într-un anumit set de instrucțiuni de asamblare selectați Colourise.
  7. În continuare codul este destul de greoi. Pentru a putea fi mai human-readable adăugați opțiunea -O2 la opțiunile de compilare (Compiler options).
  8. Se poate observa existența simbolurilor .L3: și .L4:. Acestea reprezintă puncte fixe în program, label-uri, destul de asemănătoare cu ceea ce se găsește și în C.
  9. Treceți, pe rând, prin compilatoarele corespunzătoare următoarelor arhitecturi: ARM, ARM64, AVR, PowerPC. Se poate observa cum codul generat diferă de la o arhitectură la alta.
  10. Mai încercați și următoarele compilatoare: clang și icc. După cum se poate observa, deși este același cod C și aceeași arhitectură, codul generat diferă. Acest lucru se întâmplă pentru că fiecare compilator poate avea o strategie de optimizare și generare de cod diferită.

clang este un compilator open-source de C\C++. Adesea este folosit în IDE-uri datorită mesajelor de eroare de compilare foarte sugestive pe care le produce.

icc este compilatorul de C\C++ al celor de la compania Intel.


Scrieți în zona Code editor următoarea secvență de cod:

int simple_fn(void)
{
    int a = 1;
    a++;
    return a;
}

Observați codul în limbaj de asamblare atunci când opțiunile de compilare (Compiler options) sunt -m32, respectiv atunci când opțiunile de compilare sunt -m32 -O2. Observați ce efect au opțiunile de optimizare asupra codului în limbaj de asamblare generat.

2.[20p] Microsoft Visual Studio: from C to assembly

La exercițiul anterior am abordat compilatoarele gcc, clang și icc pentru a observa cam ce cod de asamblare produc. În acest exercițiu vom trece în revistă și compilatorul celor de la Microsoft (valabil pe Microsoft Windows, evident).

În primă fază deschideți Microsoft Visual Studio și creați un nou proiect gol.

Pentru a crea un proiect gol, folosiți File → New project, apoi selectați Win32 Console Application și selectați Empty Project.

Pentru a adăuga un fișier nou în cadrul proiectului mergeți cu mouse-ul în zona Solution Explorer (din partea dreapta sus a ecranului) și apoi folosiți click dreapta pe intrarea Source files, apoi folosiți Add și apoi, după caz, New Item sau Existing Item. Adăugați un fișier nou în cadrul proiectului, numit main.cpp și adăugați următorul cod în fișier:

#define DATA_LEN 100 
 
int testFunction(int* input, int length)
{
	int sum = 0;
 
	for (int i = 0; i < length; ++i) {
		sum += input[i];
	}
 
	return sum;
}
 
int main()
{
	int random_data[DATA_LEN];
 
	testFunction(random_data, DATA_LEN);
 
	return 0;
}

Mergeți în proprietățile proiectului, la secțiunea indicată în următoarea imagine: Microsoft Visual Studio: Project Properties

Asigurați-vă că la câmpul Assembler Output este selectat Assembly With Source Code (/FAs) și dați OK pentru ca proprietățile să ia efect.
Dați Clean Project și apoi Build Project. În acest moment vi s-a generat un fișier *.asm, dar care nu va apărea în Visual Studio.

Pentru a vizualiza fișierul în cauză, dați click dreapta pe Solution nume_proiect și selectați Open Folder in File Explorer. În directoare nume_proiect\Debug veți găsi fișierul Source.asm corespunzător programului scris în C. După cum puteți observa, acesta conține codul de asamblare intercalat cu comentarii în care este prezentat codul original.

Există două directoare cu numele Debug: unul aferent soluției și altul aferent proiectului. Alegeți directorul Debug aferent proiectului.

3. [20p] SASM: walkthrough

3.1 Proiect nou

Pentru a crea un proiect nou intrați la File → New.

3.2 Ferestre

Legendă:

  1. editorul
  2. fereastră în care se pot transmite date programului
  3. fereastră în care se afișează outputul programului
  4. logging pentru build, clean, run

3.3 Butoane

  1. Build Build - construiește executabilul
  2. Run Run - rulare normală a programului
  3. Stop Stop - oprește execuția programului
  4. Debug Debug - rularea programului cu suport de debugging
  5. Step into Step into - dacă instrucțiunea curentă apelează o funcție, se va arăta și execuția din interiorul ei
  6. Step over Step over - dacă instrucțiunea curentă apelează o funcție, debugger-ul va executa funcția ca și cum ar fi o singură instrucțiune
  7. Continue Continue - continuă execuția până la următorul breakpoint sau până se termină programul

3.4 Modul debugging

În modul debugging (programul rulat cu Debug), vă mai apare o ferestră cu resursele procesorului (starea registrelor sale):

Opțional, puteți activa și o fereastră prin care puteți monitoriza valorile unor variabile în timpul sesiunii de depanare:

Activarea acestei ferestre se face din meniu astfel:

Pentru a introduce un breakpoint dați click în dreapta linenumber-ului corespunzător liniei pe care vreți să o depanați. Când va fi setat un breakpoint pentru o linie, în stanga ei va apărea și un cerculeț roșu care să desemneze acest lucru: Insert breakpoint
O alternativă ar fi, având cursorul pe linia în cauză, să apăsați tasta F8 sau să dați comanda din meniu:
Breakpoint menu entry

4. [40p] C: GOTOs

Când scrieți cod cu etichete (label-uri) țineți cont de următoarele recomandări de indentare:

  • Nu indentați etichetele (label-urile). “Lipiți-le” de marginea din stânga a ecranului de editare.
  • O etichetă este singură pe linie. Nu există cod după etichetă.
  • Nu țineți cont de indentare în indetarea codului. Codul trebuie indendat în același mod și cu etichete și fără etichete.
  • Puneți o linie liberă înaintea linie care conține o etichetă.

Pentru algoritmii de mai jos scrieți cod în C fără a folosi:

Adică va trebui să folosiți if și multe instrucțiuni goto.

[20p] Implementați maximul dintr-un vector folosind cod C și constrângerile de mai sus.

[20p] Implementați căutare binară folosind cod C și constrângerile de mai sus.

Reiterăm ideea că scenariile de utilizare ale instrucțiunii goto sunt limitate. Exercițiile acestea au valoare didactică pentru a vă acomoda cu instrucțiuni de salt (jump) pe care le vom folosi în dezvoltarea în limbaj de asamblare.

Pentru scrierea de programe puteți folosi orice editor indicat pe Desktop sau IDE-urile Code::Blocks sau Visual Studio.

Pentru compilarea/rularea codului puteți folosi IDE-urile Code::Blocks sau Visual Studio sau puteți folosi linia de comandă ca mai jos.

Pentru a compila un fișier cod sursă C/C++ în linia de comandă folosind Visual Studio, urmați pașii:

  1. Deschideți butonul de start, selectați All apps , apoi mergeți la litera V, selectați directorul Visual Studio 2015 și alegeți opțiunea Visual Studio x86 Native Command Prompt.
  2. Accesați directorul în care aveți codul sursă.
  3. Folosiți comanda
    cl <nume-fisier>.cpp

    unde <nume-fisier> este numele fișierului.

  4. Rulați executabilul obținut folosind comanda
    .\nume-fisier

    unde <nume-fisier> este numele fișierului.

5. [20p] Bonus

Implementați cu aceleași constrângeri ca la exercițiul anterior și algoritmii:

  1. bubble sort [10p]
  2. căutarea unui substring într-un string [10p]