C/Przenośność programów

Z Wikibooks, biblioteki wolnych podręczników.
< C

Jak dowiedziałeś się z poprzednich rozdziałów tego podręcznika, język C umożliwia tworzenie programów, które mogą być uruchamiane na różnych platformach sprzętowych pod warunkiem ich powtórnej kompilacji. Język C należy do grupy języków wysokiego poziomu, które tłumaczone są do poziomu kodu maszynowego (tzn. kod źródłowy jest kompilowany). Z jednej strony jest to korzystne posunięcie, gdyż programy są szybsze i mniejsze niż programy napisane w językach interpretowanych (takich, w których kod źródłowy nie jest kompilowany do kodu maszynowego, tylko na bieżąco interpretowany przez tzw. interpreter). Jednak istnieje także druga strona medalu - pewne zawiłości sprzętu, które ograniczają przenośność programów. Ten rozdział ma wyjaśnić Ci mechanizmy działania sprzętu w taki sposób, abyś bez problemu mógł tworzyć poprawne i całkowicie przenośne programy.[1]

Niezdefiniowane zachowanie i zachowanie zależne od implementacji[edytuj]

W trakcie czytania kolejnych rozdziałów można było się natknąć na zwroty takie jak zachowanie niezdefiniowane (ang. undefined behavior) czy zachowanie zależne od implementacji (ang. implementation-defined behavior). Cóż one tak właściwie oznaczają?

Zacznijmy od tego drugiego. Autorzy standardu języka C czuli, że wymuszanie jakiegoś konkretnego działania danego wyrażenia byłoby zbytnim obciążeniem dla osób piszących kompilatory, gdyż dany wymóg mógłby być bardzo trudny do zrealizowania na konkretnej architekturze. Dla przykładu, gdyby standard wymagał, że typ unsigned char ma dokładnie 8 bitów to napisanie kompilatora dla architektury, na której bajt ma 9 bitów byłoby cokolwiek kłopotliwe, a z pewnością wynikowy program działałby o wiele wolniej niżby to było możliwe.

Z tego właśnie powodu, niektóre aspekty języka nie są określone bezpośrednio w standardzie i są pozostawione do decyzji zespołu (osoby) piszącego konkretną implementację. W ten sposób, nie ma żadnych przeciwwskazań (ze strony standardu), aby na architekturze, gdzie bajty mają 9 bitów, typ char również miał tyle bitów. Dokonany wybór musi być jednak opisany w dokumentacji kompilatora, tak żeby osoba pisząca program w C mogła sprawdzić jak dana konstrukcja zadziała.

Należy zatem pamiętać, że poleganie na jakimś konkretnym działaniu programu w przypadkach zachowania zależnego od implementacji drastycznie zmniejsza przenośność kodu źródłowego.

Zachowania niezdefiniowane są o wiele groźniejsze, gdyż zaistnienie takowego może spowodować dowolny efekt, który nie musi być nigdzie udokumentowany. Przykładem może tutaj być próba odwołania się do wartości wskazywanej przez wskaźnik o wartości NULL.

Jeżeli gdzieś w naszym programie zaistnieje sytuacja niezdefiniowanego zachowania, to nie jest już to kwestia przenośności kodu, ale po prostu błędu w kodzie, chyba że świadomie korzystamy z rozszerzenia naszego kompilatora. Rozważmy odwoływanie się do wartości wskazywanej przez wskaźnik o wartości NULL. Ponieważ według standardu operacja taka ma niezdefiniowany skutek to w szczególności może wywołać jakąś z góry określoną funkcję - kompilator może coś takiego zrealizować sprawdzając wartość wskaźnika przed każdą dereferencją, w ten sposób niezdefiniowane zachowanie dla konkretnego kompilatora stanie się jak najbardziej zdefiniowane.

Sytuacją wziętą z życia są operatory przesunięć bitowych, gdy działają na liczbach ze znakiem. Konkretnie przesuwanie w lewo liczb jest dla wielu przypadków niezdefiniowane. Bardzo często jednak, w dokumentacji kompilatora działanie przesunięć bitowych jest dokładnie opisane. Jest to o tyle interesujący fakt, iż wielu programistów nie zdaje sobie z niego sprawy i nieświadomie korzysta z rozszerzeń kompilatora.

Istnieje jeszcze trzecia klasa zachowań. Zachowania nieokreślone (ang. unspecified behaviour). Są to sytuacje, gdy standard określa kilka możliwych sposobów w jaki dane wyrażenie może działać i pozostawia kompilatorowi decyzję co z tym dalej zrobić. Coś takiego nie musi być nigdzie opisane w dokumentacji i znowu poleganie na konkretnym zachowaniu jest błędem. Klasycznym przykładem może być kolejność obliczania argumentów wywołania funkcji.

Rozmiar zmiennych[edytuj]

Rozmiar poszczególnych typów danych (np. int, short czy long) jest różna na różnych platformach, gdyż nie jest definiowany w sztywny sposób (poza typem char, który zawsze zajmuje 1 bajt), jak np. "long int zawsze powinien mieć 64 bity" (takie określenie wiązałoby się z wyżej opisanymi trudnościami), lecz w na zasadzie zależności typu "long powinien być nie krótszy niż int", "short nie powinien być dłuższy od int". Pierwsza standaryzacja języka C zakładała, że typ int będzie miał taki rozmiar, jak domyślna długość liczb całkowitych na danym komputerze, natomiast modyfikatory short oraz long zmieniały długość tego typu tylko wtedy, gdy dana maszyna obsługiwała typy o mniejszej lub większej długości[2].

Z tego powodu, nigdy nie zakładaj, że dany typ będzie miał określony rozmiar. Jeżeli potrzebujesz typu całkowitego o konkretnym rozmiarze (a dokładnej konkretnej liczbie bitów wartości) możesz skorzystać z pliku nagłówkowego :

i używać na przykład:

  • int8_t
  • int16_t
  • int32_t
  • int64_t

zamiast int


stdint.h[edytuj]

wprowadzonego do języka przez standard ISO C z 1999 roku. Definiuje on typy int8_t, int16_t, int32_t, int64_t, uint8_t, uint16_t, uint32_t i uint64_t (o ile w danej architekturze występują typy o konkretnej liczbie bitów).


własny plik nagłówkowy[edytuj]

Jednak możemy posiadać implementację, która nie posiada tego pliku nagłówkowego. W takiej sytuacji nie pozostaje nam nic innego jak tworzyć własny plik nagłówkowy, w którym za pomocą słówka typedef sami zdefiniujemy potrzebne nam typy. Np.:

 typedef unsigned char      u8;
 typedef   signed char      s8;
 typedef unsigned short     u16;
 typedef   signed short     s16;
 typedef unsigned long      u32;
 typedef   signed long      s32;
 typedef unsigned long long u64;
 typedef   signed long long s64;

Aczkolwiek należy pamiętać, że taki plik będzie trzeba pisać od nowa dla każdej architektury na jakiej chcemy kompilować nasz program.

Porządek bajtów i bitów[edytuj]

Bajty i słowa[edytuj]

Wiesz zapewne, że podstawową jednostką danych jest bit, który może mieć wartość 0 lub 1. Kilka kolejnych bitów[3] stanowi bajt (dla skupienia uwagi, przyjmijmy, że bajt składa się z 8 bitów). Często typ short ma wielkość dwóch bajtów i wówczas pojawia się pytanie w jaki sposób są one zapisane w pamięci - czy najpierw ten bardziej znaczący - big-endian, czy najpierw ten mniej znaczący - little-endian.

Skąd takie nazwy? Otóż pochodzą one z książki Podróże Guliwera, w której liliputy kłóciły się o stronę, od której należy rozbijać jajko na twardo. Jedni uważali, że trzeba je rozbijać od grubszego końca (big-endian) a drudzy, że od cieńszego (little-endian). Nazwy te są o tyle trafne, że w wypadku procesorów wybór kolejności bajtów jest sprawą czysto polityczną, która jest technicznie neutralna.

Sprawa się jeszcze bardziej komplikuje w przypadku typów, które składają się np. z 4 bajtów. Wówczas są aż 24 (4 silnia) sposoby zapisania kolejnych fragmentów takiego typu. W praktyce zapewne spotkasz się jedynie z kolejnościami big-endian lub little-endian, co nie zmienia faktu, że inne możliwości także istnieją i przy pisaniu programów, które mają być przenośne należy to brać pod uwagę.

Poniższy przykład dobrze obrazuje oba sposoby przechowywania zawartości zmiennych w pamięci komputera (przyjmujemy CHAR_BIT == 8 oraz sizeof(long) == 4, bez bitów wypełnienia (ang. padding bits)): unsigned long zmienna = 0x00010203; w pamięci komputera będzie przechowywana tak:

adres         | 0  | 1  | 2  | 3  |
big-endian    |0x00|0x01|0x02|0x03|
little-endian |0x03|0x02|0x01|0x00|

Konwersja z jednego porządku do innego[edytuj]

Czasami zdarza się, że napisany przez nas program musi się komunikować z innym programem (może też przez nas napisanym), który działa na komputerze o (potencjalnie) innym porządku bajtów. Często najprościej jest przesyłać liczby jako tekst, gdyż jest on niezależny od innych czynników, jednak taki format zajmuje więcej miejsca, a nie zawsze możemy sobie pozwolić na taką rozrzutność.

Przykładem może być komunikacja sieciowa, w której przyjęło się, że dane przesyłane są w porządku big-endian. Aby móc łatwo operować na takich danych, w standardzie POSIX zdefiniowano następujące funkcje (w zasadzie zazwyczaj są to makra):

 #include <arpa/inet.h>
 uint32_t htonl(uint32_t);
 uint16_t htons(uint16_t);
 uint32_t ntohl(uint32_t);
 uint16_t ntohs(uint16_t);

Pierwsze dwie konwertują liczbę z reprezentacji lokalnej na reprezentację big-endian (host to network), natomiast kolejne dwie dokonują konwersji w drugą stronę (network to host).

Można również skorzystać z pliku nagłówkowego endian.h, w którym definiowane są makra pozwalające określić porządek bajtów:

 #include <endian.h>
 #include <stdio.h>
 
 int main(void) {
 printf("Porządek ");
 #if __BYTE_ORDER == __BIG_ENDIAN
   printf("big-endian");
 #elif __BYTE_ORDER == __LITTLE_ENDIAN
   printf("little-endian");
 #elif defined __PDP_ENDIAN && __BYTE_ORDER == __PDP_ENDIAN
   printf("PDP");
 #endif
 printf(" (%d)\n", __BYTE_ORDER);
   return 0;
 }

Na podstawie makra __BYTE_ORDER można skonstruować funkcję, która będzie konwertować liczby pomiędzy różnymi porządkami:

 #include <endian.h>
 #include <stdio.h>
 #include <stdint.h>
 
 uint32_t convert_order32(uint32_t val, unsigned from, unsigned to) {
   if (from==to) {
     return val;
   } else {
     uint32_t ret = 0;
     unsigned char tmp[5] = { 0, 0, 0, 0, 0 };
     unsigned char *ptr = (unsigned char*)&val;
     unsigned div = 1000;
     do tmp[from / div % 10] = *ptr++; while ((div /= 10));
     ptr = (unsigned char*)&ret;
     div = 1000;
     do *ptr++ = tmp[to / div % 10]; while ((div /= 10));
     return ret;
   }
 }
 
 #define LE_TO_H(val)  convert_order32((val), 1234, __BYTE_ORDER)
 #define H_TO_LE(val)  convert_order32((val), __BYTE_ORDER, 1234)
 #define BE_TO_H(val)  convert_order32((val), 4321, __BYTE_ORDER)
 #define H_TO_BE(val)  convert_order32((val), __BYTE_ORDER, 4321)
 #define PDP_TO_H(val) convert_order32((val), 3412, __BYTE_ORDER)
 #define H_TO_PDP(val) convert_order32((val), __BYTE_ORDER, 3412)
 #define LE_TO_BE(val) convert_order32((val), 1234, 4321)
 #define BE_TO_LE(val) convert_order32((val), 4321, 1234)
 #define PDP_TO_BE(val) convert_order32((val), 3421, 4321)
 #define PDP_TO_LE(val) convert_order32((val), 3421, 1234)
 #define BE_TO_PDP(val) convert_order32((val), 4321, 3421)
 #define LE_TO_PDP(val) convert_order32((val), 1234, 3421)
 
 int main (void)
 {
   printf("%08x\n", LE_TO_H(0x01020304));
   printf("%08x\n", H_TO_LE(0x01020304));
   printf("%08x\n", BE_TO_H(0x01020304));
   printf("%08x\n", H_TO_BE(0x01020304));
   printf("%08x\n", PDP_TO_H(0x01020304));
   printf("%08x\n", H_TO_PDP(0x01020304));
   return 0;
 }

Ciągle jednak polegamy na niestandardowym pliku nagłówkowym endian.h. Można go wyeliminować sprawdzając porządek bajtów w czasie wykonywania programu:

 #include <stdio.h>
 #include <stdint.h>
 
 int main(void) {
   uint32_t val = 0x04030201;
   unsigned char *v = (unsigned char*)&val;
   int byte_order = *v * 1000 + *(v + 1) * 100 + *(v + 2) * 10 + *(v + 3);
   printf("Porządek ");
   if (byte_order == 4321)
     printf("big-endian");
   else if (byte_order == 1234)
     printf("little-endian");
   else if (byte_order == 3412)
     printf("PDP");
   printf(" (%d)\n", byte_order);
   return 0;
 }

Powyższe przykłady opisują jedynie część problemów jakie mogą wynikać z próby przenoszenia binarnych danych pomiędzy wieloma platformami. Wszystkie co więcej zakładają, że bajt ma 8 bitów, co wcale nie musi być prawdą dla konkretnej architektury, na którą piszemy aplikację. Co więcej liczby mogą posiadać w swojej reprezentacje bity wypełnienia (ang. padding bits), które nie biorą udziały w przechowywaniu wartości liczby. Te wszystkie różnice mogą dodatkowo skomplikować kod. Toteż należy być świadomym, iż przenosząc dane binarnie musimy uważać na różne reprezentacje liczb.

Biblioteczne problemy[edytuj]

Dostępność bibliotek[edytuj]

Pisząc programy nieraz będziemy musieli korzystać z różnych bibliotek. Problem polega na tym, że nie zawsze będą one dostępne na komputerze, na którym inny użytkownik naszego programu będzie próbował go kompilować. Dlatego też ważne jest, abyśmy korzystali z łatwo dostępnych bibliotek, które dostępne są na wiele różnych systemów i platform sprzętowych. Zapamiętaj: Twój program jest na tyle przenośny na ile przenośne są biblioteki z których korzysta!

Przykład :

Odmiany bibliotek[edytuj]

Pod Windows funkcje atan2, floor i fabs są w tej samej bibliotece, co standardowe funkcje C.

Pod Uniksami są w osobnej bibliotece matematycznej libm w wersji:

  • statycznej (zwykle /usr/lib/libm.a) i pliku nagłówkowym math.h (zwykle /usr/include/math.h)[4]
  • ladowanej dynamicznie ( /usr/lib/libm.so )

Aby korzystać z tych funkcji potrzebujemy:

  • dodać : #include <math.h>
  • przy kompilacji dołączyć bibliotekę libm : gcc main.c -lm

Opcja -lm używa libm.so albo libm.a w zależności od tego, które są znalezione, i w zależności od obecności opcji -static. [5][6]


wieloplatformowe[edytuj]

Kompilacja warunkowa[edytuj]

Przy zwiększaniu przenośności kodu może pomóc preprocessor. Przyjmijmy np., że chcemy korzystać ze słówka kluczowego inline wprowadzonego w standardzie C99, ale równocześnie chcemy, aby nasz program był rozumiany przez kompilatory ANSI C. Wówczas możemy skorzystać z następującego kodu:

 #ifndef __inline__
 # if __STDC_VERSION__ >= 199901L
 #  define __inline__ inline
 # else
 #  define __inline__
 # endif
 #endif

a w kodzie programu zamiast słówka inline stosować __inline__. Co więcej, kompilator GCC rozumie słówka kluczowe tak tworzone i w jego przypadku warto nie redefiniować ich wartości:

 #ifndef __GNUC__
 # ifndef __inline__
 #  if __STDC_VERSION__ >= 199901L
 #   define __inline__ inline
 #  else
 #   define __inline__
 #  endif
 # endif
 #endif

Korzystając z kompilacji warunkowej można także korzystać z różnego kodu zależnie od (np.) systemu operacyjnego. Przykładowo, przed kompilacją na konkretnej platformie tworzymy odpowiedni plik config.h, który następnie dołączamy do wszystkich plików źródłowych, w których podejmujemy decyzje na podstawie zdefiniowanych makr. Dla przykładu, plik config.h:

 #ifndef CONFIG_H
 #define CONFIG_H
 
 /* Uncomment if using Windows */
 /* #define USE_WINDOWS */
 
 /* Uncomment if using Linux */
 /* #define USE_LINUX */
 
 #error You must edit config.h file
 #error Edit it and remove those error lines
 
 #endif

Jakiś plik źródłowy:

 #include "config.h"
 
 /* ... */
 
 #ifdef USE_WINDOWS
   rob_cos_wersja_dla_windows(void);
 #else
   rob_cos_wersja_dla_linux(void);
 #endif

Istnieją różne narzędzia, które pozwalają na automatyczne tworzenie takich plików config.h, dzięki czemu użytkownik przed skompilowaniem programu nie musi się trudzić i edytować ich ręcznie, a jedynie uruchomić odpowiednie polecenie. Przykładem jest zestaw autoconf i automake.


Przypisy

  1. Writing portable code by HP AllianceOne Partner Program
  2. Dokładniejszy opis rozmiarów dostępny jest w rozdziale Składnia.
  3. Standard wymaga aby było ich co najmniej 8 i liczba bitów w bajcie w konkretnej implementacji jest określona przez makro CHAR_BIT zdefiniowane w pliku nagłówkowym limits.h
  4. An Introduction to GCC - for the GNU compilers gcc and g++. 2.7 Linking with external libraries
  5. man ld
  6. [c,+gcc+:++atan2,+floor+fabs#4916d793e62da10d | Dyskusja na grupie pl.comp.os.linux.programowanie na temat c, gc : atan2, floor fabs]