C/Przenośność programów: Różnice pomiędzy wersjami

Usunięta treść Dodana treść
Kj (dyskusja | edycje)
m - porządek bitów
Mina86 (dyskusja | edycje)
Totalna przebudowa rozdziału.
Linia 1:
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. Innymi słowy - kod napisany w języku C jest przenośny ale tylko w wersji źródłowej. Jednak językJę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ż programu są szybsze i mniejsze niż programy napisane w Cjęzykach interpretowanych wyjątkowo(takich, szybkiew iktórych małe.kod Jednakźródłowy nie jest kompilowany do kodu maszynowego tylko ''na bieżąco'' interpretowany przez specjalne narzędzie) jednak istnieje także druga strona medalu. Istnieją- pewne zawiłości sprzętu, które ograniczają przenośność programów. Ten rozdział ma wyjaścić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.
 
== Niezdefiniowane zachowanie i zachowanie zależne od implementacji ==
W trakcie czytania kolejnych rozdziałów można było się natknąć na zwroty takie jak zachowanie niezdefiniowane (ang. ''undefined behaviour'') czy zachowanie zależne od implementacji (ang. ''implementation-defined behaviour''). 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 jakiejś 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 mógł.
 
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 to już nie jest kwestia przenośności kodu, ale po prostu błędu w programie, aczkolwiek nie zawsze. Otóż nic nie stoi na przeszkodzie, aby konkretny kompilator opisał działanie jakiegoś wyrażenia, które według standardu wpada w omawianą klasę zachowań. Tak często bywa z operatorem przesunięcia bitowego działającego dla liczb ujemnych - w takich wypadkach nadal pozostaje jednak kwestia przenośności kodu i jeżeli zależy nam na niej nie możemy korzystać z takich funkcji 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 ==
Rozmiar poszczególnych typów danych (np. char, int czy long) jest różna na różnych platformach, gdyż nie jest definiowany w sztywny sposób, 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<ref>Dokładniejszy opis rozmiarów dostępny jest w rozdziale [[C/Składnia#Typy_danych|Składnia]].</ref>.
 
Z tego powodu, nigdy nie zakładaj, że dany typ będzie miał określony rozmiar. Jeżeli potrzebujesz typu o konkretnym rozmiarze (a dokładnej konkretnej liczbie bitów wartości) możesz skorzystać z pliku nagłówkowego stdint.h 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).
 
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 ==
 
=== Bajty i słowa ===
Wiesz zapewne, że podstawową jednostką danych jest bit., Możektóry onmoże mieć wartość 0 lub 1. OsiemKilka kolejnych bitów stanowi<ref>Standard bajt.wymaga Bajtaby możebyło przechowywaćich wartościco odnajmniej 08 doi 255liczba włączniebitów (jeśliw używamybajcie dodatnichw liczbkonkretnej całkowitych)implementacji lubjest odokreślona -128przez domakro 127CHAR_BIT (jeślizdefiniowane używamyw takżepliku ujemnychnagłówkowym liczb całkowitych)limits.h</ref> Dwastanowi bajtybajt umownie(dla określaskupienia sięuwagi jakoprzyjmijmy, słowo<ref>Rozmiarże słowabajt jestma umowny8 ibitów). różnyCzęsto dlatyp poszczególnychshort architektur,ma powyższywielkość przypadekdwóch dotyczybajtów procesorai i386wówczas ipojawia pokrewnych.</ref>.się Czterypytanie bajtyw tojaki dwasposób słowa, aone osiemzapisane bajtóww topamięci cztery- słowa.czy Wróćmynajpierw terazten dobardziej sednaznaczący sprawy.- Okazuje się'''big-endian''', żeczy słowonajpierw (dwaten bajty)mniej możnaznaczący zapisać na dwa różne sposoby:- '''little-endian'''.
* jeden z nich, zwany big-endian, zakłada, że bajty słowa zapisywane są w pamięci w kolejności: najbardziej znaczący bajt a po nim bajt mniej znaczący.
* drugi - little-endian polega na tym, że najpierw zapisuje się bajt mniej znaczący, a później bajt bardziej znaczący.
Skąd takie nazwy? Otóż pochodzą one z książki pt. "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.
Poniższy przykład dobrze obrazuje oba sposoby przechowywania zawartości zmiennych w pamięci komputera:
* '''porządek big-endian'''<br>
<pre>unsigned short zmienna = 0x1234;</pre>w pamięci komputera zmienna będzie przechowywana tak:<br>
<pre>adres | 0 | 1 |
wartość |0x12|0x34|</pre>
 
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.
* '''porządek little-endian'''<br>
 
<pre>unsigned short zmienna = 0x1234;</pre>w pamięci komputera zmienna będzie wyglądała tak:<br>
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ę.
<pre>adres | 0 | 1 |
 
wartość |0x34|0x12|</pre>
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'')): <tt>unsigned long zmienna = 0x01020304;</tt> w pamięci komputera będzie przechowywana tak:
adres | 0 | 1 | 2 | 3 |
big-endian |0x01|0x02|0x03|0x04|
little-endian |0x04|0x03|0x02|0x01|
 
=== Konwersja z jednego porządku do innego ===
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ść.
Czasami zdarza się, że programy muszą być przystosowane do przetwarzania danych w określonym porządku, np. big-endian (porządek ten jest obowiązującym w komunikacji sieciowej). W takim przypadku pisząc program, który działa na architekturze o porządku little-endian musimy zadbać o to, aby dane wysyłane były we właściwej kolejności bajtów. Aby stwierdzić, jaki porządek bitów posiada nasza architektura dołączamy plik endian.h:
 
#include <endian.h>
Przykładem może być komunikacja sieciowa, w której przyjeło się, że dane przesyłane są w porządku big-endian. Aby móc łatwo operować na takich danych standard POSIX definiuje następujące funkcje (w zasadzie zazwyczaj są to makra):
Zdefiniowane jest tam makro __BYTE_ORDER. Dzięki niemu możemy dowiedzieć się, jaki porządek bajtów jest stosowany na naszym komputerze. Oto program, sprawdzający tenże porządek:
#include <arpa/inet.h>
uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);
 
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 () {
{
#if __BYTE_ORDER == __BIG_ENDIAN
printf ("Porządek big-endian (4321)\n");
#elif __BYTE_ORDER == __LITTLE_ENDIAN
printf("Porządek little-endian (1234)\n");
#elif defined __PDP_ENDIAN && __BYTE_ORDER == __PDP_ENDIAN
printf("Porządek PDP (3412)\n");
#else
printf ("PorządekInny little-endianporządek (%d)\n", __BYTE_ORDER);
#endif
return 0;
}
 
Na podstawie makra __BYTE_ORDER można skonstruować funkcję, która będzie konwertować liczby pomiędzy porządkiem różnymi porządkami:
Jest jeszcze jeden sposób, aby dowiedzieć się, w jaki sposób nasz procesor układa dane w pamięci. Jest to bardzo prosty test, polegający na tym, że tworzymy sobie jakąś zmienną, zajmującą w pamięci więcej niż 1 bajt. W zmiennej umieszczamy wartość 1. Jeśli zostanie ona umieszczona w pierwszym bajcie zmiennej, to mamy do czynienia z porządkiem little-endian, w przeciwnym zaś wypadku - big endian. Oto prosty kod:
 
#include <endian.h>
int i = 1;
char *wsk = &i;
if (*wsk == 1) printf ("Porządek little-endian\n");
else printf ("Porządek big-endian\n");
 
Wróćmy do tematu konwersji. Do konwertowania zmiennych pomiędzy różnymi porządkami bitów używa się makr htons(16-bitowe zmienne)/htonl(32-bitowe zmienne) oraz ntohl i ntohs. Makro hton przetwarza lokalny porządek bitów na big-endian, a ntoh z big-endian na lokalny.
 
=== Porządek bajtów a pola bitowe ===
Z rozdziału [[C/Typy złożone|typy złożone]] dowiedziałeś się czym są i jak działają pola bitowe, a w rozdziale [[C/Zaawansowane operacje matematyczne|zaawansowane operacje matematyczne]] zaznajomiłeś się ze sposobem przechowywania liczb zmiennoprzecinkowych w pamięci komputera. Przeanalizujemy je teraz pod kątem kolejności bitów. Najmniejszym typem, przechowującym liczby rzeczywiste jest typ '''float'''. Jest on zbudowany tak<ref>Według normy IEEE 754.</ref>:
S| EXP | MAN
x|xxxxxxxx|xxxxxxxxxxxxxxxxxxxxxxx
1| 2-9 | 10-32
* S - oznacza znak i zajmuje 1 bit
* EXP - wykładnik, zajmujący 8 bitów
* MAN - mantysa, zajmująca 23 bity
W sumie typ float zajmuje 32 bity, czyli 4 bajty. Ponieważ wszystkie elementy tego typu są liczbami całkowitymi, to komuś może przyjść ochota rozpisać tenże typ na elementy składowe. Do tego może posłużyć następujący przykład:
#include <stdio.h>
#include <stdlibstdint.h>
uint32_t convert_order32(uint32_t val, unsigned from, unsigned to) {
union {
if (from==to) {
float r;
return val;
struct {
} else {
unsigned int sign:1,
uint32_t ret = exp:8,0;
unsigned char tmp[5] = { 0, 0, 0, 0, 0 man:23};
unsigned char *ptr = (unsigned char*)&val;
} arc;
unsigned div = 1000;
} abc;
do tmp[from / div % 10] = *ptr++; while ((div /= 10));
ptr = (unsigned char*)&ret;
int main (int argc, char** argv)
div = 1000;
{
do *ptr++ = tmp[to / div % 10]; while ((div /= 10));
abc.r = 0;
return ret;
printf ("%d %d %d\n", abc.arc.sign, abc.arc.exp, abc.arc.man);
return 0;}
}
Powyższy przykład może wydawać się całkowicie poprawny, lecz na architekturach o porządku little-endian będzie on generował niestety niepoprawne wyniki. Dlaczego? Otóż odwrócenie kolejności bajtów oznacza, że bit znaku nie znajdzie się na pierwszym miejscu, lecz na ostatnim. Dlatego kolejność bitów typu będzie dokładnie odwrócona. Dlatego też musimy przestawić kolejność pól bitowych.
#include <stdio.h>
#include <endian.h>
#define LE_TO_H(val) convert_order32((val), 1234, __BYTE_ORDER)
union {
#define H_TO_LE(val) convert_order32((val), __BYTE_ORDER, 1234)
float r;
#define BE_TO_H(val) convert_order32((val), 4321, __BYTE_ORDER)
#if __BYTE_ORDER == __LITTLE_ENDIAN
#define H_TO_BE(val) convert_order32((val), __BYTE_ORDER, 4321)
struct {
#define PDP_TO_H(val) convert_order32((val), 3412, __BYTE_ORDER)
unsigned int man:23,
#define H_TO_PDP(val) convert_order32((val), __BYTE_ORDER, 3412)
exp:8,
sign:1;
} arc;
#else
struct {
unsigned int sign:1,
exp:8,
man:23;
} arc;
#endif
} abc;
int main (int argc, char** argv)
{
printf("%08x\n", LE_TO_H(0x01020304));
abc.r = 0;
printf ("%d %d %d08x\n", abc.arc.sign, abc.arc.exp, abc.arc.manH_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:
Dopiero teraz nasz program będzie generował poprawne wyniki na wszystkich architekturach.
 
#include <stdio.h>
{{infobox|Mimo faktu, że język C jest językiem wysokiego poziomu, pisząc w nim programy musimy bardzo uważać na tego typu sprzętowe ewenementy. Problemy te są obecne, ponieważ kod języka C tłumaczony jest bezpośrednio do kodu maszynowego.}}
#include <stdint.h>
 
== Rozmiar zmiennych ==
int main() {
Nie tylko kolejność bajtów jest ważna. Rozmiar poszczególnych typów danych (np. char, int czy long) jest różna na różnych platformach. Nigdy w swoich programach nie zakładaj, że dany typ będzie miał stały, określony rozmiar! Aby dowiedzieć się, ile bajtów ma poszczególny typ używaj słowa kluczowego '''sizeof'''. Rozmiar poszczególnych typów nie jest definiowany w sztywny sposób, jak np. "long int zawsze powinien mieć 64 bity", lecz w na zasadzie nieprecyzyjnych zależności, jak np. "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.
uint32_t val = 0x04030201;
 
unsigned char *v = (unsigned char *)&val;
Słowo '''typedef''' daje możliwości, które mogą zostać wykorzystane przy tworzeniu bardzo przenośnych programów (np. jądra systemu operacyjnego). Wiemy, że na platformie 32-bitowej typ long ma zwykle długość 32 bitów. Jednak na platformach 64-bitowych przyjęło się, że typ long ma rozmiar 64 bitów. Czasami wymagana jest dokładna znajomość długości zmiennej. Dlatego też, dla każdej z architektury z osobna możemy zadeklarować typy pochodne, o ustalonej długości:
int byte_order = v[0] * 1000 + v[1] * 100 + v[2] * 10 + v[3];
 
/* dla procesorów 32-bitowych */
if (byte_order == 4321) {
typedef unsigned char u8;
printf("Porządek big-endian (4321)\n");
typedef signed char s8;
} else if (byte_order == 1234) {
typedef unsigned short u16;
printf("Porządek little-endian (1234)\n");
typedef signed short s16;
} else if (byte_order == 3412) {
typedef unsigned long u32;
printf("Porządek PDP (3412)\n");
typedef signed long s32;
} else {
typedef unsigned long long u64;
printf("Inny porządek (%d)\n", byte_order);
typedef signed long long s64;
}
 
return 0;
Natomiast dla procesorów 64-bitowych:
}
 
typedef unsigned char u8;
typedef signed char s8;
typedef unsigned short u16;
typedef signed short s16;
typedef unsigned int u32;
typedef signed int s32;
typedef unsigned long u64;
typedef signed long s64;
 
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.
Dzięki temu możemy używać typu o postaci np. u32 (nieujemna liczba całkowita, z zakresu od 0 do 2^32-1), nie przejmując się różnymi architekturami, na których nasz kod ma działać.
 
== Biblioteczne problemy ==
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!
 
== Kompilator kompilatorowi nierówny ==
Staraj się nie korzystać z rozszerzeń kompilatorów, które nie są zgodne z oficjalnie zatwierdzonymi specyfikacjami. Czasami jest tak, że jeden kompilator ma podobne rozszerzenia, jak inny, jednak zupełnie inaczej się z nich korzysta. Musisz o tym pamiętać, gdyż twój program niekoniecznie musi być kompilowany tym samym kompilatorem. Dlatego też nie korzystaj z roszerzeń poszczególnych kompilatorów o ile nie jest to konieczne.
<noinclude>
{{Przypisy}}