Mihai Budiu
ianuarie 1996
Acest articol face parte dintr-o serie de articole care își propune să discute limbajul C standard (ANSI C) supunînd atenției felurite aspecte mai puțin înțelese / folosite / explicate. Fiecare articol este complet, în sensul că o cunoaștere a limbajului C ar trebui să fie suficientă pentru înțelegerea lui, și nu se bazează fundamental pe altele din aceeași serie. Datorită însă faptului că în realitate articolele nu prezintă tehnici de programare, ci efectiv limbajul și semantica (semnificația) sa (deci o construcție unitară), referiri incrucișate între articole apar, atunci cînd aspecte clarificate cu o altă ocazie intervin în discurs.
Vom folosi pe alocuri termenul obiect pentru a denota anumite construcții ale unui limbaj cum ar fi variabile, constante, funcții, metode, obiecte (în sensul limbajelor orientate pe obiecte). A nu se confunda această noțiune cu cea de obiect din limbajele orientate pe obiecte, pe care nu o folosim niciodată explicit în acest articol, și al cărei sens îl socotim mai restrîns.
Încă de la apariția sa la începutul anilor '70 limbajul C s-a impus ca lider de necontestat pentru programarea de sistem. Aceasta din urmă cuprinde o clasă largă de programe care interacționează foarte strîns cu calculatorul și a căror performanță o afectează pe a tuturor celorlalte. Un exemplu tipic de astfel de program este sistemul de operare. (El este singurul program care -- de exemplu -- interacționează cu discul. Toate celelalte programe cer serviciile lui pentru acest scop, prin funcțiile de lucru cu fișiere. De aceea el ``interacționează strîns cu calculatorul''.)
C are mai multe calități care îl fac atît de apreciat de către programatorii de sistem (și nu numai). Una dintre ele, de care ne vom ocupa în parte în acest articol, este suportul pe care îl oferă pentru dezvoltarea de programe mari, eventual lucrate în echipă. Vom vedea că acest suport este oferit prin niște mecanisme extrem de simple, dar a căror existență are adesea un impact foarte important asupra limbajului însuși.
Să vedem acum ce condiții trebuie să fie îndeplinite de un limbaj pentru ne permite scrierea unor programe mari. Să numim aceste condiții premize, și să urmărim apoi întruparea lor în cazul C-ului.
Pentru ca un program să poată fi scris de mai mulți programatori trebuie ca el să poată fi descompus în părți independente -- cît mai mult -- una de alta, care să poată fi concepute, compilate, testate eventual separat.
O categorie de astfel de părți sunt bibliotecile. Unele operații foarte utile sunt totdeauna aceleași (de exemplu funcțiile matematice (sinus, cosinus, exponențiale)) și pot fi scrise odată pentru totdeauna de un programator, fiind apoi folosite de toți utilizatorii. Aceste funcții sunt livrate adesea de firme sub forma unor biblioteci de funcții.
Chiar și cînd un singur programator este implicat, împărțirea unui program în părți puțin dependente una de alta este de foarte mare ajutor pentru scrierea, dar mai ales depanarea rezultatului. Studii experimentale au arătat că dificultatea scrierii unui program crește exponențial cu lungimea lui. Asta înseamnă ca a scrie un program de 2000 de linii este (mult) mai greu decît a scrie 2 de cîte 1000.
Este deci foarte util să poți împărți un program în mai multe texte, fiecare -- probabil -- într-un fișier separat. Această împărțire se poate face în foarte multe feluri; de preferință toate funcțiile / variabilele / procedurile etc. care fac ceva înrudit trebuie sa fie puse în același fișier. Numele unui astfel de fișier este modul. Putem acum enunța premiza întîi necesară unui limbaj pentru scrierea de programe mari:
PREMIZA 1 : textul unui program poate fi împărțit în mai multe module.
Pentru a putea testa corectitudinea -- măcar sintactică -- a fiecărui modul, el trebuie să se poată compila separat de celelalte. Reamintim că în cursul compilării toate erorile sintactice sunt depistate (iar cîteodată și posibile erori conceptuale -- de exemplu variabile neinițializate). Compilarea separată permite corectarea fiecărui modul independent. Avem deci:
PREMIZA 2 : fiecare modul se poate compila separat.
Pe de altă parte, modulele făcînd parte dintr-un tot, este de așteptat ca unele obiecte (funcții, proceduri, variabile, etc.) dintr-un modul să se folosească cumva de altele, dintr-un alt modul. Pentru că modulele se pot compila separat, pentru a face posibilă verificarea corectitudinii sintactice a fiecăruia în parte, cumva insușirile obiectelor dintr-un modul care sunt accesibile și din altele trebuie descrise modulelor utilizator.
Să numim un obiect (variabilă, funcție, etc.) al unui modul care poate fi folosit de alte module exportat de modulul căruia îi aparține. El este, prin simetrie, importat de modulele care îl folosesc.
Premiza care urmează este necesară pentru întreținerea ușoară a informațiilor care trebuie să fie folosite din mai multe locuri:
PREMIZA 3 : Declarațiile obiectelor exportate de fiecare modul trebuie să fie centralizate (strînse într-un singur loc). (Declarația este o instrucțiune care descrie unele din însușirile unui obiect; ea va fi tratată mai pe larg mai jos, în contextul limbajului C).
Avem apoi:
PREMIZA 4 : Trebuie să existe o metodă prin care declarațiile unui modul exportator sunt preluate de modulele importatoare.
Pentru a încheia ne mai trebuie o premiză. Cineva trebuie să combine rezultatele compilărilor diferitelor module separate într-un singur program care se poate executa :
PREMIZA 5 : Trebuie să putem pune împreună mai multe module compilate (separat) pentru a obține un executabil.
În principiu pentru a obține programul am putea avea o nouă compilare a tuturor modulelor împreună. Însă din moment ce fiecare modul este probabil deja compilat, ar fi timp cîștigat dacă am putea folosi acest lucru.
Să observăm că remarcile din această secțiune sunt practic independente de limbajul considerat. Ele arată care trebuie să fie calitățile unui limbaj pentru a ușura scrierea unor programe multimodul.
Compilarea unui program scris în C presupune prezența mai multor module. Cazul existenței unuia singur este unul particular. (În realitate, după cum vom vedea, chiar programele care par formate dintr-un singur modul sunt adesea formate din mai multe: cele scrise de programator plus biblioteci!)
Limbajul C face o distincție foarte precisă între declarații și definiții. O declarație este o construcție care anunță existența unui obiect precum și anumite trăsături ale sale. Definiția unui obiect construiește efectiv obiectul (îi alocă un spațiu). Orice definiție este simultan și o declarație, dar nu și invers.
Limbajul C permite exportarea variabilelor și funcțiilor. C mai permite și folosirea în comun a macrodefinițiilor (numite pe scurt macrouri) și numelor de tipuri (introduse cu typedef) de către mai multe module. Macrourile și tipurile nu dau naștere nici unui obiect care să existe în timpul execuției programului, cum este de exemplu o variabilă. (Cu alte cuvinte macro-urile și tipurile au numai declarații și niciodată definiții).
Pentru a ilustra terminologia să considerăm un program foarte simplu format din două fișiere:
/*********************** * fisierul : header.h * ***********************/ #define YES 1 #define NO 0 #define max(a,b) ((a) > (b) ? (a) : (b)) typedef int NUMAR; NUMAR abs(NUMAR); /* valoarea absoluta */ extern NUMAR m; /********************* * fisierul : main.c * *********************/ #include "header.h" #include <stdio.h> NUMAR m; NUMAR abs(NUMAR x) { if (x < (NUMAR) 0) return -x; else return x; } static NUMAR n; int main(void) { int y,z; /* sarim ceva din program */ if (max (y,z) < m) /* mai sarim ceva */ }
O calitate importantă a obiectelor în C este vizibilitatea. Vizibilitatea unui obiect este totalitatea liniilor din program în care el poate fi referit.
Obiectele C care sunt declarate înafara oricărei funcții se numesc externe, iar celelalte se numesc locale. În fișierul main.c din exemplul nostru sunt externe variabilele m și n, precum și funcțiile main și abs.
Obiectele externe au vizibilitate începînd de la declarația lor și pînă la sfîrșitul fișierului cu excepția blocurilor care declară un alt obiect cu același nume. Deci m este vizibil în funcțiile abs și main, dar n numai în main.
Obiectele declarate în interiorul unui bloc (delimitat de { }) se numesc locale. y și z sunt locale lui main. x este local lui abs. Un obiect local este vizibil începînd de la locul declarației și pînă la sfîrșitul blocului în care este declarat (cu excepția blocurilor interioare care declară un obiect cu același nume).
Cum arată definiția unei variabile / funcții în C presupunem că se știe. Declarația unei variabile se face prefixînd definiției ei cuvîntul extern. Declarația unei funcții se face ne-scriind blocul care formează corpul ei. Astfel în fișierul header.h avem declarații (și nu definiții) ale variabilei m și funcției abs.
În C implicit toate obiectele externe dintr-un fișier pot fi exportate. Dacă un obiect extern este folosit numai de modulul în care este definit (adică nu este exportat) atunci definiția sa se poate prefixa cu cuvîntul static. Astfel în fișierul main.c m este exportat iar n nu este! Obiectele care pot fi exportate se numesc cu legare externă (external linkage). Asta pentru că un alt modul le poate folosi fără a le defini, legîndu-se la cel în care sunt definite. Pentru modulul main.c m este cu legare externă.
Se obișnuiește a se strînge toate declarațiile obiectelor exportate de un modul (sau un grup de module) într-un fișier numit header (sau antet în română). Aceste fișiere nu conțin niciodată definiții. Numele lor se termină prin convenție cu .h. Un astfel de fișier este în exemplul nostru header.h.
Compilarea este formată din trei faze independente, care sunt realizate de obicei de trei programe distincte, care se execută unul după altul sau în paralel.
Exemplele pe care le prezentăm sunt pentru două tipuri de compilatoare : de sub UNIX (practic orice UNIX, testate pentru Linux) sau MsDos (testate cu BorlandC 2.0).
module c si module module program headere preprocesate obiect executabil | ---------------- ------------------ --------------- ^ \->| preprocesare | -> | compilare | -->| linkeditare |---/ | | | propriu-zisa | /->| (legare) | ---------------- ------------------ | --------------- biblioteci
Prin anumite opțiuni putem ruga compilatorul să parcurgă numai unele dintre aceste faze. Cînd compilăm mai multe module, noi dăm o singură comandă, dar această comandă invocă cele trei faze pentru noi. Să presupunem că avem un program format din modulul main.c de mai sus și modulul error.c. Sub UNIX putem crea rezultatul cu:
cc main.c error.c
Cu BorlandC o facem cu comanda
bcc main.c error.c
(Chiar cînd compilarea este lansată din mediul integrat BorlandC prin tasta F9, sau prin Compile/Build, în realitate același lucru se întîmplă : mediul integrat invocă pentru noi compilatorul cu o astfel de linie, luînd din project numele fișierelor de compilat !).
Iată evoluția fișierelor pentru MsDos BorlandC
După cum vedeți premiza 1 (mai multe module) este îndeplinită -- un program se poate asambla din mai multe fișiere sursă.
Să studiem acum fiecare fază a compilării separat, identificînd și celelalte premize:
Preprocesarea este o fază deosebit de interesantă, căreia intenționăm să-i acordăm un articol special cîndva. Acum o vom privi din punctul de vedere care ne interesează.
Preprocesorul este de obicei un program separat, care se numește cpp (C PreProcessor). Numele său vine de la faptul că el realizează o procesare înainte (pre) de compilarea propriu-zisă.
Preprocesorul este un program care prelucrează texte. El primește un text iar rezultatul lui este tot un text. Comparați-l din acest punct de vedere cu celelalte două faze.
În principiu preprocesorul parcurge toate textele care-i sunt specificate de sus în jos, lucrînd pe linii, căutînd comenzi pentru el (numite directive), pe care le execută. Comenzile pentru preprocesor încep cu un semn diez (#) în prima coloană. Preprocesorul C are trei mari grupe de comenzi:
#include #define / #undef #if / #else / #endif / #ifndef
Cea mai interesantă pentru noi acum este #include,
prezentă și în fișierul main.c. Această directivă este
urmată de un nume de fîsîer scris între semnele < > sau
" "
. În main.c avem
#include <stdio.h> #include "header.h"
Prima formă caută fișierul specificat (stdio.h) într-o
serie de directoare care depind de implementarea compilatorului. De
obicei în UNIX este vorba de /usr/lib, iar la BorlandC există
o opțiune ``Directories'' unde se specifică acest(e) locuri. A doua
formă caută fișierul specificat (header.h) întîi relativ la
același loc în care se găsește main.c (ex. în directorul
curent), iar apoi, dacă a eșuat în căutare,
în aceleași locuri ca prima formă a lui include (cu < >
).
După cum arată și numele fișierelor (faptul că se termină cu .h), de obicei (dar nu obligatoriu) acestea sunt fișiere header, care conțin declarațiile unor obiecte exportate de alte module, importate de modulul curent.
Cînd preprocesorul execută directiva #include
el găsește headerul indicat (sau dacă nu, oprește
compilarea raportînd o eroare) și substituie linia
#include
cu textul headerului. După aceea continuă
preprocesarea din același punct, prelucrînd deci textul tocmai
introdus (care poate include alte headere la rîndul lui).
Iată deci cum premiza a 4-a (preluarea într-un modul a fișierelor cu declarații) este îndeplinită. De asemenea, fișierele header sunt o consecința a premizei a 3-a (centralizarea declarațiilor).
Puteți vedea rezultatul preprocesării rulînd comandă cpp pe fișierele C. Aceasta realizează numai prima fază a compilării din cele mai sus indicate. (-P- indică suprimarea informațiilor despre numele fișierelor incluse. Rezultatul obținut este fișierul main.i; prin convenție extensia .i este pentru fișiere preprocesate.)
Borland:
cpp -P- main.c
sau UNIX
cc -E main.c > main.i
sau UNIX (dacă aveți cpp în directorul /lib) :
/lib/cpp main.c > main.i
Datorită includerii, fișierele preprocesate conțin deja declarațiile obiectelor importate. Apoi ele sunt compilate separat (premiza 2). Rezultatul acestei compilări este ceea ce se numește modul obiect (object file). Borlandc generează fișiere cu extensia .obj, compilatoarele din UNIX cu extensia .o. Se obține cîte unul pentru fiecare modul C inițial.
Ce este un modul (fișier) obiect? Este un amestec de program compilat (cod mașină) și variate informații. Tot textul C al modulului a fost compilat în cod mașină. Însă referințele la simboluri externe din alte module nu au putut fi satisfăcute, pentru că aceste module sunt compilate separat. De aceea modulul obiect conține o listă a simbolurilor nesatisfăcute, indicînd și locurile unde sunt folosite. În plus mai conține și o listă a simbolurilor exportate de modul.
De asemenea un modul obiect are o parte cu informații numite de relocare. Acestea sunt necesare pentru că toate salturile din codul compilat sar la niște adrese care se vor schimba probabil atunci cînd modulul obiect va fi pus cap la cap cu altele pentru a forma executabilul.
Adesea modulul obiect mai conține și alte informații care sunt necesare pentru depanarea programului rezultat. Aceste informații arată căror fișiere sursă și căror linii (C) le corespund feluritele instrucțiuni din codul mașină.
Cîteodată faza de compilare propriu-zisă este formată din două părți independente și făcute de programe separate. O primă fază generează un program în limbaj de asamblare, iar a doua fază asamblează programul pentru a obține un fișier obiect.
module programe in module preprocesate asamblare obiect ---| |------------| |------------| |---- |->| compilare |->| asamblare |->| | | | | | | ---| |------------| |------------| |----- \_________________________/ compilare propriu-zisa
Puteți opri compilarea la module obiect invocînd compilatorul cu opțiunea -c (compile only); iată cum puteți compila cele două surse la module obiect :
UNIX :
cc -c error.c main.c
BorlandC
bcc -c error.c main.c
Fișierul obiect nu arată prea interesant -- pentru cititorul uman -- dar puteți încerca să opriți compilarea la limbaj de asamblare:
UNIX
cc -S error.c main.c
BorlandC
bcc -S error.c main.c
Rezultatele sunt fișiere cu extensia .s în UNIX sî .asm în DOS. Citiți-le! Aceste fișier pot fi asamblate pentru a obține cod obiect prin
UNIX
as -c error.c main.c
BorlandC
tasm error.c main.c
După cum am văzut fiecare modul sursă este compilat separat într-un modul obiect. Modulele obiect au o mulțime de referințe nesatisfăcute (la obiectele importate). Legarea modulelor (implicînd completarea referințelor obiectelor globale, relocarea codului) este făcută de ultima fază a compilării, numită linkeditare. (Link = legătură în engleză). Ea este făcută de programul ld (în UNIX) sau tlink (BorlandC). Argumentele ei sunt module obiect.
Interesant este că aceste module obiect pot fi obținute prin compilarea unor programe scrise în limbaje diferite; este frecventă combinarea programelor scrise în limbaj de asamblare cu a celor în C: toate sunt compilate la module obiect, după care sunt legate împreună. Lucrurile nu sunt foarte simple, pentru că trebuie respectate anumite convenții (de exemplu compilatorul C se poate aștepta ca anumiți regiștri să nu fie schimbați de nici o funcție).
Puteți încerca să legați modulele obiect obținute mai sus astfel, deși n-o să meargă!
UNIX :
ld main.o error.o
BorlandC :
tlink main.obj error.obj
Iată de ce: în realitate cînd linkerul este invocat de către compilatorul de C (deci nu separat, cum am făcut noi mai sus), mai primește ca argumente, pe lîngă modulele obiect generate prin fazele anterioare ale compilării și cel puțin o bibliotecă. Este vorba de biblioteca standard C, numită în UNIX libc.a, iar în DOS cs.lib (în locul lui s poate fi o altă literă care depinde de modelul de memorie pe care l-ați folosit). Erorile obținute prin comanda de mai sus arată care sunt simbolurile externe importate din bibliotecă și care au lipsit la legare.
Pentru a lega modulele trebuie specificate și bibliotecile. Sintaxa la BorlandC pentru linker e puțin mai complicată :
BorlandC :
tlink c0s main error, main, ,cs
pentru că se specifică separate de virgule fișierele .obj (incluzînd c0s, un fișier standard), numele rezultatului, fișierele map (informații generate de compilator), apoi bibliotecile, și apoi resursele (aici map și resursele lipsesc).
Sub UNIX lucrurile pot fi foarte complicate, pentru că bibliotecile se pot afla în multe locuri. Numele bibliotecilor este dat linkerului este precedat de opțiunea -l; linkerul unui nume specificat îi prefixează lib, sufixîndu-i .a. Astfel -lc leagă biblioteca libc.a. O linie minimă de comandă pentru linker ar fi (dar în funcție de sistem mai trebuie specificate și alte opțiuni, ca -L care indică locul bibliotecilor) :
ld /usr/lib/crt0.o main.o error.o -L/usr/lib -lc
Fișierele c0s (Borland), respectiv crt0.o (UNIX) care apar mai sus sunt niște module obiect care conțin codul unei funcții cu care se începe execuția programului, care va cheama funcția main() și care după executarea acesteia cheamă funcția exit(), pentru a termina civilizat programul.
O bibliotecă (cs.lib respectiv libc.a sunt
biblioteci) este de fapt o colecție de module obiect care au fost
compilate mai demult din sursele lor și au fost strînse laolaltă
într-un fișier arhivă. În libc.a (cs.lib) se află codul
funcțiilor standard pe care programul dumneavoastră le poate folosi.
De exemplu printf, exit, fopen, scanf. Prin convenție
declarațiile acestor funcții sunt grupate în fișiere header puse
într-un anumit loc în sistemul de fișiere. Acestea sunt headerele
incluse prin directiva #include <nume_header.h>
! (De aceste
headere este nevoie pentru că ele conțin declarațille funcțiilor
exportate de bibliotecă).
Și dumneavoastră puteți crea biblioteci folosind programul ar (în UNIX) sau tlib (în DOS). Aceste programe manipulează arhive (biblioteci) în multiple feluri. Bibliotecile pot fi explorate (pentru a vedea ce conțin) cu tdump (în DOS) sau cu nm, ranlib, objdump, etc. sub UNIX. O discuție acestor utilitare este însă în afara acestui articol.
Rezultatul obținut de la linker este în fine un program executabil sub sistemul de operare pentru care lucrează linkerul. Un astfel de fișier are însă o structură destul de complicată, folosită de sistemul de operare pentru două scopuri: el conține informații despre structura însăși a fișierului executabil (cît de lung e codul, cît de lungi sunt datele, sistemul de operare pentru care merge, dacă folosește biblioteci încărcate dinamic, eventuale date de relocare, etc.) și informații de depanare, care sunt folosite cînd programul este depanat (de ex. cu td = Turbo Debugger în DOS sau cu dbx în UNIX). Aceste informații sunt preluate din fișierele obiect, dacă existau, și în principiu indică din ce surse C provin instrucțiunile din executabilul final. Aceste informații pot fi scoase din program, dacă nu mai este nevoie ca el să fie depanat, cu tdstrip (DOS) sau strip (UNIX), pentru a face un executabil mai mic.
Două cuvinte despre linkeditarea dinamică. Aceasta este o tehnologie relativ nouă, prezentă în sistemul de operare Windows sub forma fișierelor-biblioteci cu extensia .dll (Dinamic Linking Libraries). Sub UNIX aceste biblioteci se numesc fie dll-uri fie Shared Libraries (biblioteci comune; numele lor conține particula so; exemplu: lib.so.4.1). Ideea este destul de simplă. Există o clasă mare de funcții care tind să fie prezente în majoritatea programelor. Un exemplu tipic este printf: cam toate programele au ceva de scris. Atunci în loc să se lege (linkeze) codul acestor funcții în fiecare program, funcțiile sunt ``încărcate'' în memorie o singură dată de sistemul de operare, și apoi legate de toate programele care au nevoie de ele. De aici cele două denumiri ale acestor biblioteci: dinamice pentru că legarea nu mai e o fază a compilării, ci chiar a executării programului (legarea se face cînd programul este pornit) și comune (shared) pentru că pot aparține mai multor programe diferite simultan!
Limbajul C pune la dispoziție niște mecanisme excelente pentru scrierea de programe mari. Aceste mecanisme permit
Cunoașterea fazelor compilării este esențială pentru scrierea unor programe corecte în C. Dacă un obiect este folosit fără a fi declarat, se obțin erori, sau codul generat este incorect! (Vom reveni asupra acestui aspect într-un articol despre prototipuri de funcții). Asta poate da naștere la multe bug-uri misterioase. Este clar că un modul nu poate ști nimic despre obiectele globale (adică cu legare externă) definite în alte module pînă în clipa legării. Ori pînă la legare anumite informații se pierd datorită compilării (de ex. anumite informații de tip). Linker-ul ar putea să nu depisteze anumite erori (de exemplu folosirea unui același obiect cu tipuri diferite în module diferite).
Lecția se poate rezuma într-o propoziție : nu folosiți niciodată în C funcții sau variabile nedeclarate! Puteți folosi sau nu pentru declarații headere.
Acest articol (și toate din această serie) folosesc o terminologie standard, foarte precisă. Iată semnificațiile cuvintelor cheie din acest text (și numele lor englezesc) :