Preprocesor języka C
Poniedziałek, 01 Luty 2010
Preprocesor jest jednym z najbardziej niedocenianych narzędzi
przez programistów. Co ciekawe, każdy programista korzysta z co
najmniej kilku jego podstawowych funkcji. Opisujemy, co potrafi
preprocesor oraz jak wykorzystać jego potęgę w programowaniu.
78 ELEKTRONIKA PRAKTYCZNA 2/2010
notatnik konstruktora
Dodatkowe materiały
na CD i FTP
Proces kompilacji składa się z dwóch
zasadniczych kroków: kompilacji oraz kon-
solidacji. Jednak zanim kod programu trafi
do kompilatora, jest przetwarzany przez pre-
procesor. Preprocesor jest więc narzędziem,
które przetwarza kod źródłowy przed właści-
wym procesem kompilacji. Wszystkie dyrek-
tywy preprocesora zaczynają się znakiem #.
Najbardziej popularnymi są #include oraz
#define.
#include
Instrukcja #include dołącza do kodu
źródłowego kopię pliku podanego za tą in-
strukcją np. #include . Dosłownie
wstawi zawartość pliku stdio.h w miejscu
wywołania.
Przyjęło się używanie plików z roz-
szerzeniem ?.h?, jednak może być to
dowolny plik tekstowy o dowolnym
rozszerzeniu. Aby sprawdzić w swo-
im projekcie, jak wygląda kod programu
przetworzony przez preprocesor, nale-
ży użyć opcji -E kompilatora np. gcc -E
program.c.
Możemy użyć instrukcji #include rów-
nież do innych ciekawych zastosowań. Za-
łóżmy, że mamy plik typu CSV (patrz ram-
ka) i chcemy wkleić zawartość tego pliku
jako tabelę do programu. Pierwszy sposób,
który wybierze większość programistów, to
oczywiście skopiowanie zawartości pliku
i wklejenie go do kodu. Jest to jednak strata
czasu. W projekcie pojawia się kolejna rzecz
o której musimy pamiętać, ponieważ za każ-
dym razem gdy plik CSV zmieni się, musi-
my także zmienić dane w kodzie programu.
Zamiast mozolnie wklejać dane, lepiej wy-
korzystać instrukcję #include i utworzyć
tabelę bezpośrednio z pliku CSV. Można to
zrobić w następujący sposób
int tabela[4][4] = {
#include ?dane.csv?
};
Należy pamiętać, aby sprawdzić czy plik
CSV zawiera końcowe przecinki, ponieważ
dużo programów nie dodaje przecinka na
końcach linii, a to spowoduje błędną inter-
pretację pliku i błąd kompilacji.
#define, #undef
Instrukcja #define służy najczęściej do
zdefiniowania stałej np. #define PI 3.14
Preprocesor języka C
Preprocesor jest jednym z najbardziej niedocenianych narzędzi
przez programistów. Co ciekawe, każdy programista korzysta z co
najmniej kilku jego podstawowych funkcji. Opisujemy, co potrafi
preprocesor oraz jak wykorzystać jego potęgę w programowaniu.
Nie polecam jednak używania #define
w tego typu sytuacjach. Lepiej jest używać
innych wyrażeń języka C, np.
float const PI=3.14;
Definiując liczby całkowite można rów-
nież używać enum np.
enum {N=10};.
Dyrektywą odwrotną do #define jest
#undef. Służy ona do cofnięcia definicji
w miejscu użycia np.
#define STALA 1
printf(?stala=%2?, STALA);
#undef STALA
printf(?stala=%2?, STALA);
W linii numer 4 wystąpi błąd kompilacji,
ponieważ po użyciu dyrektywy #undef, sta-
ła o nazwie STALA nie będzie istniała w pro-
gramie.
Możliwości #define są jednak dużo
większe niż mogłoby się wydawać. Za pomo-
cą tej instrukcji możemy definiować również
makra. Przeanalizujmy najpierw makro, któ-
re dodaje do siebie dwie liczby:
#define ADD(X,Y)((X) + (Y))
int a;
float b;
a = ADD(4, 5);
b = ADD(3.2, 1.1);
Z powyższego kodu zostanie utworzony
przez preprocesor następujący kod dla kom-
pilatora:
int a;
float b;
a = 4 + 5;
b = 3.2 + 1.1;
Można zauważyć, że używanie makr
nie powoduje zbędnego narzutu jaki po-
wodują funkcje. Nie ma instrukcji skoku
do funkcji oraz nie trzeba pamiętać na sto-
sie argumentów funkcji. Kolejną ciekawą
właściwością jest niezależność operacji na
danych. W powyższym przykładzie użyli-
śmy tego samego makra dla danych typu
int oraz float. Stosując funkcje potrzeba-
by napisać dwie implementacje: jedną dla
typu float i drugą dla int. Pewnie zastana-
wiacie się, skoro te makra są takie dobre,
to dlaczego nie są powszechnie stosowane
w programach? Odpowiedź jest bardzo
prosta: nie nadają się do pisania skompli-
kowanych i długich funkcji. Należy także
pamiętać, że kod jest wklejany w miejscu
użycia, a to może powodować znaczne
rozrośnięcie się pliku wynikowego. Makra
trzeba używać z rozwagą, ponieważ mogą
być niebezpieczne w użyciu. Jedną z typo-
wych pułapek jest wywołanie makra z in-
strukcjami arytmetycznymi np.:
#define KWADRAT(x)((x)*(x))
int a;
int x=1;
a = KWADRAT(x++);
Tego typu makro zostanie rozwinięte jako:
a = (x++) * (x++);
Wynik będzie inny niż gdybyśmy do
realizacji tego zadania użyli funkcji. Kolej-
ną wadą jest bardzo trudne wyszukiwanie
błędów w programach. Makra powodu-
ją, że komunikaty kompilatora o błędach
stają się dziwne i niezrozumiałe. Sam
stosuję zasadę, że używam makr tylko
w sytuacjach, gdzie czas wykonania jest
krytyczny. W typowych zastosowaniach,
zamiast używania makr można również
zastosować funkcje typu inline. Będzie to
bardziej eleganckie rozwiązanie. Są jed-
nak przypadki, gdy makra są niezbędne
i nie da się ich zastąpić instrukcjami kom-
pilatora. Będziemy o tym mówić w dalszej
części artykułu.
#if, #ifdef, #ifndef, #elif, #else,
#endif
Preprocesor dostarcza również instruk-
cje warunkowe. Składnia jest bardzo prosta
np.:
#ifdef ALFA
// wykonaj instrukcje jeśli ALFA jest
zdefiniowane
#endif
Dyrektywa #if może sprawdzać wartość
stałej np.:
#if ALFA == 1
// wykonaj instrukcje jeśli ALFA jest
równe 1
#elif ALFA == 2
// wykonaj instrukcje jeśli ALFA jest
równe 2
#else
// wykonaj instrukcje jeśli ALFA jest
różne od 1 i 2
#endif
Instrukcje warunkowe są często stosowa-
ne do zapobiegania dołączaniu kilku kopii
plików nagłówkowych do tego samego pro-
jektu. Przykładowy plik nagłówkowy mógłby
wyglądać następująco:
#ifndef _PLIK_H_
#define _PLIK_H_
// treść właściwa
#endif
Jeśli przez nieuwagę dołączymy ten plik
dwa razy np.:
#include ?plik.h?
#include
#include
W takiej sytuacji nic złego się nie stanie.
Jest to bardzo dobra praktyka i większość
Kody źródłowe
Kody źródłowe prezentowane w artykule są
dostępne na stronie: http://toan.pl
79ELEKTRONIKA PRAKTYCZNA 2/2010
Preprocesor języka C
istniejących bibliotek używa tego zabezpie-
czenia.
Makra predefiniowane
Preprocesor udostępnia nam kilka bar-
dzo użytecznych makr predefiniowanych.
Poniżej umieściłem listę podstawowych, do-
stępnych standardowo:
? __TIME__ ? zwraca godzinę w chwili
kompilacji
? __DATE__ ? zwraca datę w chwili kompi-
lacji
? __LINE__ ? numer linii, w której zostało
użyte
? __FILE__ ? nazwa pliku, w którym zosta-
ło użyte
Makra te są bardzo przydatne przy uru-
chamianiu programu. Mogą się także przy-
dać, gdy chcemy w programie umieścić datę
kompilacji.
Praktyczne wykorzystanie makr
W praktyce programowania często za-
chodzi potrzeba utworzenia zbiorów tzw.
logów działania programu. Osobiście czę-
sto stosuję logi zapisywane w pamięci typu
Flash lub wysyłam je przez RS232. Zapisa-
nie logów w pamięci typu Flash jest moim
zdaniem dobrą praktyką, ponieważ w chwili
awarii mogę sprawdzić, co działo się w pro-
gramie i czy była to wina użytkownika, czy
błędu w programie. Jest to dobre zabezpie-
czenie, gdy ktoś próbuje obarczyć winą
za awarię programistę. Jak wynika z mojej
praktyki, najczęstsze tłumaczenie obsługi
to ?program zwariował?. Zwykle okazuje się
później, że pracownik z nudów ?poklikał?
i narobił zamieszania.
Zastanówmy się, co warto zapisać do lo-
gów? Możemy użyć systemu komunikatów
typu ?wskaźnik ma wartość NULL?. Jest to
jednak dość niewygodne i przy większych
programach można się pogubić w komu-
nikatach. Lepiej jest zastosować notację,
która będzie bliska dla programisty np. za-
pisać w logu nazwę pliku wraz z numerem
wiersza. Przykładowy log mógłby wyglądać
następująco:
main.c:12
main.c:24
i2c.c:11
i2c.c:15
main.c:28
Za numerem linii można umieścić do-
datkowe informacje np. wartość zmiennej na
której operujemy. Tego typu informacje po-
zwolą ustalić faktyczny przebieg programu.
Tworzenie ?na piechotę? tego typu logów nie
ma sensu i lepiej jest użyć w tym celu pre-
procesora. Oto przykładowy program:
#include
#define log() printf(?%s:%d\n?,__
FILE__,__LINE__)
#define log_int(X) printf(?%s:%d
%s=%d\n?,__FILE__,__LINE__,#X,X)
int main()
{
int zmienna=123;
log();
log_int(zmienna);
return 0;
}
Po uruchomieniu otrzymamy:
example.c:9
example.c:10 zmienna=123
W programie są dwa makra: log oraz
log_int. Makro log dodaje do logów tylko na-
zwę pliku i numer linii. Natomiast log_int
dodatkowo umożliwia dodanie do logów
wartości zmiennej typu int oraz wartość tej
zmiennej.
Wyjaśnienia wymaga konstrukcja #X.
Tworzy ona napis z identyfikatora, który
Czym są pliki CSV?
Pliki CSV (Comma Separated Values) są
plikami tekstowymi, w których dane są
oddzielone przecinkami. Przykładowa
zawartość pliku CSV znajduje się poniżej:
1, 2, 3, 4,
5, 6, 7, 8,
9, 10, 11, 12,
13, 14, 15, 16,
Nie ma ograniczeń, co do liczby danych
w wierszu lub liczby wierszy w pliku.
Należy pamiętać o zachowaniu w każdym
wierszu takiej samej liczby danych oraz
unikać pustych wierszy. Pliki CSV można
wygenerować w popularnych arkuszach
kalkulacyjnych np. OpenOffice Calc lub
Microsoft Excel.
R E K L A M A
80 ELEKTRONIKA PRAKTYCZNA 2/2010
notatnik konstruktora
stoi przy symbolu #. Czyli z identyfikatora
zmienna utworzy ?zmienna?. Dzięki temu
oszczędzamy swoje palce i nie musimy uży-
wać dłuższej formy wywołania np. log_in-
t(?zmienna?,zmienna);.
Załóżmy, że chcemy stosować logi tyl-
ko na etapie testów urządzenia. W takim
przypadku możemy wykorzystać preproce-
sor aby dołączał funkcje logujące tylko gdy
zdefiniujemy stałą DEBUG (lub dowolną
inną). Kod programu może wyglądać nastę-
pująco:
#include
#define DEBUG
#ifdef DEBUG
#define log() printf(?%s:%d\n?,__
FILE__,__LINE__)
#define log_int(X) printf(?%s:%d
%s=%d\n?,__FILE__,__LINE__,#X,X)
#else
#define log()
#define log_int(X)
#endif
int main()
{
int zmienna=123;
log();
log_int(zmienna);
return 0;
}
Jest to użyteczna praktyka, ponieważ
usuwając deklaracje stałej DEBUG automa-
tycznie usuwamy wszystkie funkcje logujące
z kodu programu. Proszę sobie wyobrazić,
że w dużym programie możemy mieć nawet
kilkaset miejsc, w których używamy logo-
wania. Zmieniając tylko jedną linię kodu
możemy włączyć lub wyłączyć logowanie
w całym programie. Jest to bardzo wygodne
dla programisty. Zamiast deklarować stałą
w programie możemy również deklarować
makra jako opcje w wywołaniu kompilato-
ra gcc np. gcc ?DDEBUG program.c. Jest to
równoważne używaniu dyrektywy #define
w programie.
Preprocesor bardzo często jest używany
w bibliotekach do tworzenia w jednym pliku
różnych wersji programu np. dla kompilato-
rów różnych producentów, które nie są ze
sobą kompatybilne. Spójrzmy na poniższy
program źródłowy:
#if __GNUC__
__attribute__((__always_inline__))
#endif
static __inline__ int usart_mode_
is_multidrop(volatile avr32_usart_t
*usart)
{
return ((usart->mr >> AVR32_USART_
MR_PAR_OFFSET) & AVR32_USART_MR_PAR_
MULTI) == AVR32_USART_MR_PAR_MULTI;
}
Przykład zaczerpnięto z biblioteki dla
procesora AVR32. Jak widać preprocesor zo-
stał użyty do sprawdzenia czy mamy do czy-
nienia z kompilatorem GNU GCC. Jeśli tak
jest faktycznie, to zostaną dodane atrybuty
wymagane przez ten kompilator dla funkcji
typu inline.
BASIC w języku C
Preprocesor umożliwia stworzenie mini
języka programowania. Oznacza to, że w pre-
procesorze można stworzyć język z zupełnie
inną składnią niż język C. Spójrzmy na po-
niższy listing:
Tab. 1.
int a[3];
void printt() {
int indeks;
for(indeks=0; indeks<=2;
indeks++){
printf(?%d\n?,a[indeks]);
}
}
int main() {
a[0] = 1;
a[1] = 2;
a[2] = 3;
printf(?%s\n?,?Witaj?);
printt();
return 0;
}
LET(a[3])
SUB(printt)
LET(indeks)
LOOP(indeks,0,2)
PRINTI(a[indeks])
ENDLOOP
ENDSUB
BEGIN
a[0] = 1;
a[1] = 2;
a[2] = 3;
PRINT(?Witaj?)
CALL(printt)
END
#include ?basic.h?
// deklaracja tablicy
LET(a[3])
// procedura printt wyswietla
wszystkie elementy tablicy ?a?
SUB(printt)
// deklaracja zmiennej o nazwie
indeks
LET(indeks)
// petla od 0..2
LOOP(indeks,0,2)
PRINTI(a[indeks]);
ENDLOOP
ENDSUB
// start programu
BEGIN
// ustawienie elementow tablicy
a[0] = 1;
a[1] = 2;
a[2] = 3;
// wyswietlenie napisu powitalnego
PRINT(?Witaj?)
// wywolanie procedury printt
CALL(printt)
END
Zastanówmy się czy ten program będzie
poprawnie zinterpretowany przez kompila-
tor języka C. Od razu nasuwa się odpowiedź,
że to nie jest poprawny kod. Jednak wszyst-
ko zależy od tego, co znajduje się w pliku
basic.h. Poniżej znajduje się zawartość tego
pliku:
#include
// blok programu wykonywany na
poczatku
// startu programu
#define BEGIN int main() {
#define END return 0; }
// instrukcje do wyswietlenia danych
na ekranie
#define PRINT(X) printf(?%s\n?,X);
#define PRINTI(X) printf(?%d\n?,X);
// deklaracja zmiennej calkowitej
#define LET(X) int X;
// deklaracja procedury
#define SUB(X) void X() {
#define ENDSUB }
// wywolanie procedury
#define CALL(X) X();
// deklaracja petli
#define LOOP(I,X,Y) for(I=X; I<=Y; I++)
{
#define ENDLOOP }
Jak pamiętamy #define służy do defi-
niowania makr. W tym programie zostało
to wykorzystane do zdefiniowania kawał-
ków kodu. Przeanalizujmy dla przykładu
pierwszą deklarację. BEGIN będzie odpo-
wiednikiem kodu int main() {. Oznacza
to, że gdy napiszemy w swoim programie
słowo BEGIN, to tak jak byśmy napisali ten
kawałek kodu. Pisząc BEGIN informujemy
preprocesor, żeby wziął fragment kodu
i wstawił go w miejscu użycia.
Ponieważ makra mają możliwość prze-
kazywania parametrów, więc możemy
przekazać np. nazwę zmiennej i zmody-
fikować w ten sposób kod programu. Dla
przykładu jeśli wywołamy LET(zmienna)
to zostanie utworzony kod int zmienna;
a nie int X;.
Dla lepszego zrozumienia porównajmy
program przetworzony przez preprocesor
z wersją oryginalną (polecenie gcc ?E li-
sting1.c) ? tab. 1.
Zapewne wielu z was zastanawia się
do czego taka funkcjonalność przydaje
się w praktyce? Jak się okazuje, istnieją
sytuacje, w których się przydaje. W stycz-
niowym numerze EP pojawił się artykuł
na temat maszyny stanów skończonych.
W artykule tym zaprezentowałem biblio-
tekę, która w całości była napisana w pre-
procesorze języka C. Interfejs tej biblioteki
to nic innego jak specyficzny język pro-
gramowania przeznaczony do tworzenia
maszyny stanów. Tego typu rozwiązania
są spotykane szczególnie przy programo-
waniu małych mikroprocesorów, które po-
winny posiadać implementacje statyczną
pewnych funkcji. Dzięki temu wydajność
oraz zużycie pamięci są optymalne przy
zachowaniu odpowiedniego stopnia abs-
trakcji naszego programu.
Podsumowanie
Preprocesor jest doskonałym narzę-
dziem, jednak trzeba go używać z rozwagą
i ostrożnością. Nieumiejętne stosowanie
może doprowadzić do błędów oraz zaciem-
nienia kodu i bardzo trudnej interpretacji
przez innych programistów. Preprocesor
służy do wykonania pewnych zadań, któ-
re nie są możliwe w języku C i właśnie do
tego należy go używać. Pomimo różnych
?pułapek?, które mogą nas spotkać, pole-
cam wszystkim używanie preprocesora,
ponieważ przynosi to wymierne korzyści
oraz skraca czas potrzebny na stworzenie
programu.
Na koniec zachęcam do zapoznanie się
z małą biblioteką dla preprocesora napi-
saną przez firmę Atmel. Znajduje się ona
w AVR32 Software Framework (można po-
brać ze strony http://atmel.com/avr32).
Tomasz Orłowski
tomek@toan.pl
Zobacz więcej w kategorii Notatnik konstruktora