Datei wird geladen, bitte warten...
Zitiervorschau
Spis treści Przedmowa Wstęp O autorze, o książce Podziękowania Zgłaszanie błędów i errata Część I. Kilka codziennych czynności Rozdział 1. Konsola i interpreter poleceń 1.1. Wykorzystanie interpretera 1.2. Przekierowania 1.3. Przykładowe polecenia wykorzystujące przekierowania 1.4. Bieżący katalog roboczy 1.5. Zmienne środowiskowe 1.6. Skrypt startowy 1.7. Konsola okiem programisty Ćwiczenia Bibliografia Rozdział 2. Czytanie nieznanego języka 2.1. Podobieństwa i różnice 2.2. Studium przypadku 2.3. Uwagi na koniec Ćwiczenia Bibliografia Część II. Podstawy Rozdział 3. Podstawy architektury komputerów 3.1. Własny (wirtualny) komputer 3.2. Rejestry procesora 3.3. Kod maszynowy 3.4. Zestaw instrukcji 3.5. Pamięć operacyjna
3.6. Komunikacja z urządzeniami 3.7. Przerwania 3.8. Konsola znakowa 3.9. Programowalny timer 3.10. Przykładowy „kompilator” 3.11. Emulator Ćwiczenia Bibliografia Rozdział 4. Typy liczb naturalnych i całkowitych 4.1. Zapis binarny i heksadecymalny 4.2. Typy liczb naturalnych 4.3. Liczby całkowite 4.4. Little i Big Endian 4.5. Przepełnienie zmiennych naturalnych i całkowitych 4.6. Przycięcie wyniku 4.7. Saturacja 4.8. Zasygnalizowane przepełnienie 4.9. Niezdefiniowane zachowanie 4.10. Awans zmiennej 4.11. Duże liczby 4.12. Obsługa przepełnienia w praktyce Ćwiczenia Bibliografia Rozdział 5. Typy pseudorzeczywiste 5.1. Wstęp do liczb zmiennoprzecinkowych 5.2. Ułamki binarne 5.3. IEEE 754 i zmienne binarne 5.4. Kodowanie IEEE 754 Double Precision 5.5. Wartości specjalne i zdenormalizowane 5.6. Istotne wartości zmiennoprzecinkowe 5.7. Porównanie liczb zmiennoprzecinkowych 5.8. Dziesiętne typy zmiennoprzecinkowe 5.9. Typy stałoprzecinkowe Ćwiczenia Bibliografia
Rozdział 6. Znaki i łańcuchy znaków 6.1. ASCII i strony kodowe 6.2. Unicode 6.3. Łańcuchy znaków 6.4. Konwersja kodowań Ćwiczenia Bibliografia Część III. Wykonywanie programu Bibliografia Rozdział 7. Procesy 7.1. Procesy w systemie operacyjnym GNU/Linux 7.2. Procesy w systemie operacyjnym Windows 7.3. Programowe tworzenie nowego procesu 7.4. Plik wykonywalny a nowy proces 7.5. API debuggera 7.6. Dziedziczenie po procesie rodzicu 7.7. Inne operacje na zewnętrznych procesach Ćwiczenia Bibliografia Rozdział 8. Wątki 8.1. Tworzenie nowych wątków 8.2. Typy wątków i ich przełączanie 8.3. Kontekst wątku 8.4. Zmienne lokalne dla wątku 8.5. Pula wątków Bibliografia Rozdział 9. Synchronizacja 9.1. Blokujące atomowe bariery 9.2. Spinlocki – wirujące blokady 9.3. Muteksy i sekcje krytyczne 9.4. Zdarzenia i zmienne warunkowe 9.5. Problemy w synchronizacji Ćwiczenia Bibliografia
Część IV. Pliki i formaty danych Bibliografia Rozdział 10. System plików 10.1. Podstawowe operacje na systemie plików 10.2. Prawa dostępu 10.3. Operacje na plikach i danych 10.4. Ciekawe mechanizmy systemu plików Ćwiczenia Bibliografia Rozdział 11. Pliki binarne i tekstowe 11.1. Pliki tekstowe 11.2. Pliki binarne 11.3. Wstęp do serializacji 11.4. Formaty plików Ćwiczenia Bibliografia Rozdział 12. Format BMP i wstęp do bitmap 12.1. Grafika rastrowa 12.2. Canvas, surface, image, ... 12.3. Przegląd popularnych formatów pikseli 12.4. Wyświetlenie bitmapy 12.5. Ogólna struktura pliku BMP 12.6. Nagłówek BITMAPFILEHEADER 12.7. Nagłówek BITMAPINFOHEADER 12.8. Słowo o implementacji 12.9. Implementacja 24-bitowego BI_RGB 12.10. Paleta kolorów 12.11. Kompresja RLE w wydaniu BMP 12.12. Implementacja RLE8 12.13. Podsumowanie Ćwiczenia Bibliografia Rozdział 13. Format PNG 13.1. Struktura PNG 13.2. Bloki IHDR oraz IEND
13.3. Blok IDAT, kompresja i filtry adaptacyjne 13.4. Prosty dekoder Ćwiczenia Bibliografia Część V. Komunikacja Bibliografia Rozdział 14. Komunikacja międzyprocesowa 14.1. Potoki 14.2. Nazwane potoki 14.3. Gniazda domeny UNIX i socketpair 14.4. Pamięć współdzielona 14.5. Wiadomości w WinAPI Ćwiczenia Bibliografia Rozdział 15. Komunikacja sieciowa 15.1. Wstęp do sieci TCP/IP 15.2. Gniazda TCP oraz DNS 15.3. Nasłuchujące gniazda TCP oraz HTTP 15.4. Gniazda UDP i peer-to-peer Ćwiczenia Bibliografia Programowanie dla zabawy Ćwiczenia Bibliografia Zakończenie Przypisy
Przedmowa Można śmiało powiedzieć, że na komputerach używanych w latach 70. i 80. ubiegłego wieku każdy dostępny fragment pamięci oraz cykl procesora był na wagę złota. W czasach, gdy masa megabajtu przestrzeni dyskowej była wyrażana w kilogramach, a programiści dysponowali niezwykle znikomą mocą obliczeniową, wyciskano siódme poty ze wszystkich instrukcji procesora, składających się na program, a o każdym bajcie było wiadomo, skąd się wziął i jaką rolę dokładnie odgrywa. Fakt ten był szczególnie widoczny na przykładzie starych gier wideo, które pomimo niewyobrażalnych dziś ograniczeń potrafiły zaskoczyć dopracowaniem oraz grywalnością na maszynach pokroju konsoli Atari 2600. Choć operowała ona na zaledwie 128 bajtach pamięci operacyjnej, to słynne produkcje, takie jak River Raid, Keystone Kapers czy Pitfall! na długo pozostały we wspomnieniach ówczesnych graczy. Z perspektywy lat trudno nie docenić kunsztu tych majstersztyków oraz umiejętności ich autorów. Na skutek błyskawicznego rozwoju branży sprzętowej oraz powstawania coraz wygodniejszych systemów operacyjnych i środowisk programistycznych, szybszych i efektywniejszych kompilatorów czy w końcu samych języków programowania, lepiej skrojonych pod konkretne zastosowania i niezależnych od docelowej platformy, programowanie z biegiem czasu stało się w pewnym sensie łatwiejsze. Asembler został zamieniony na C, ten na C++, by następnie ustąpić w wielu przypadkach interpretowanym językom wysokiego poziomu, takim jak PHP, Java czy Python. Z drugiej strony postępujące oddalenie programisty od krzemu wykonującego tworzony kod utrudnia dostrzeżenie i zrozumienie pełnego obrazu tego, co dzieje się „pod maską” programu i całego środowiska uruchomieniowego. Na przykład wykonanie prostego skryptu „Hello, World!” napisanego w języku Python na systemie Windows składa się – w dużym uproszczeniu – z uruchomienia interpretera, przetłumaczenia kodu wysokiego poziomu na tzw. kod bajtowy (bytecode), obsłużenia każdej instrukcji tego kodu, przejścia do trybu jądra poprzez odpowiednie wywołanie systemowe, rasteryzacji tekstu do bitmapy i przesłania jej do sterownika graficznego, a w końcu na ekran. Ilu współczesnych programistów mogłoby opisać szczegółowo przebieg większości wymienionych etapów wykonania? Prawdopodobnie tylko
nieznaczny procent – i choć nie jest to oczywiście problem sam w sobie, przykład ten ilustruje, jak długa jest lista zależności naszych aplikacji od elementów środowiska wykonawczego, z których istnienia możemy nawet nie zdawać sobie sprawy. Mimo że zrozumienie wewnętrznych mechanizmów związanych z oprogramowaniem z całą pewnością przydaje się w pracy programisty, muszę przyznać, że główną motywacją wielu badań, które wspólnie z autorem tej książki przeprowadziliśmy podczas naszej 11-letniej znajomości, była zwyczajna ciekawość i rozrywka. Zagłębianie się w coraz to niższe partie środowiska wykonania aplikacji i skryptów, systemów operacyjnych czy w końcu samego procesora z czasem stało się swego rodzaju uzależnieniem pozwalającym na zaspokojenie głodu wiedzy. Szczególnie miło wspominam wczesne lata tego zauroczenia, gdy nierzadko w aktywnej jeszcze sieci IRC wystarczyło, by ktoś rzucił hasło: „Zróbmy konkurs na najmniejszy kompilator ezoterycznego języka Brainfuck!”, i już grupa ludzi rozmyślała przez tydzień nad ekstremalnymi optymalizacjami w asemblerze. Wkrótce potem zamiłowanie do niskopoziomowych aspektów informatyki i inżynierii wstecznej w naturalny sposób skierowała Gynvaela i mnie na tor bezpieczeństwa oprogramowania. Dalsze prace pozwoliły na odebranie prestiżowych nagród Pwnie Award (w tym wspólnej w 2013 r. w kategorii „Najbardziej Innowacyjne Badania Naukowe”), a stworzenie polskiej drużyny Dragon Sector na konkurowanie z najlepszymi zespołami Security CTF na świecie oraz zajęcie pierwszego miejsca w globalnym rankingu CTFtime.org w roku 2014. Książka, którą trzymasz w ręku, jest wynikiem pasji, dociekliwości i wyjątkowego zacięcia dydaktycznego autora, które dotychczas ujawniało się w rozmaitych formach – od licznych, długich na kilka stron ekranowych postów na forach powiązanych z programowaniem i bezpieczeństwem, poprzez inicjatywę IRCowych wykładów (wyklady.net), posty na prywatnym blogu, wystąpienia na konferencjach i spotkaniach branżowych, aż po techniczne podcasty publikowane w serwisie YouTube. Dzieło to odbieram osobiście jako swoiste ukoronowanie edukacyjnej działalności autora. O prawdziwej wyjątkowości książki świadczy jednak przede wszystkim fakt, że podchodzi do tematyki inaczej niż podobne opracowania tego typu. Nie znajdziemy tutaj opisu składni lub wstępu do żadnego języka programowania, metodyk tworzenia oprogramowania czy wzorców architektonicznych – tematy te zostały już
gruntownie opisane w innych publikacjach, a ich znalezienie nie stanowi problemu. W zamian autor, opierając się na swoim ponad 20-letnim doświadczeniu, skupił się na równie istotnych kwestiach, które bywają z reguły pomijane w innych źródłach, co skutkowało tym, że początkujący programiści byli zmuszeni dochodzić do nich sami na podstawie prób i błędów, tracąc przy tym energię i nabierając niekoniecznie prawidłowych nawyków lub przekonań. Większość opisanych tutaj mechanizmów, technik i zachowań ma charakter ogólny i posiada zastosowanie niezależnie od użytego języka programowania, dotykając problemu tworzenia poprawnych i przemyślanych programów, a nie wyłącznie kompilującego się kodu. Część I porusza zagadnienia związane z codziennymi czynnościami programistycznymi, ze szczególnym uwzględnieniem konsoli i okna poleceń. Aspekt ten jest ważny głównie dla niedoświadczonych koderów, stawiających swoje pierwsze kroki w środowiskach konsolowych, choć często ignorowany lub traktowany po macoszemu. Część II opisuje najbardziej fundamentalne koncepty, kierujące współczesnym programowaniem, takie jak podstawy architektury komputerów, binarne kodowanie i operacje na typach liczb naturalnych, całkowitych i pseudorzeczywistych czy reprezentacja znaków i ich ciągów. Rozdziały te są jednymi z moich ulubionych, gdyż bardzo wyraźnie akcentują, jak pewne niskopoziomowe elementy środowiska wykonania przenikają i wpływają na kod wysokiego poziomu – za przykład mogą posłużyć niedokładne (wbrew intuicji) typy zmiennoprzecinkowe w językach takich jak Java czy Python, czy niekompatybilność reprezentacji ciągów tekstowych używanych wewnętrznie i przekazywanych do funkcji biblioteki standardowej, umożliwiająca atak typu poison null byte w starszych wersjach PHP. Części III, IV i V omawiają najważniejsze komponenty środowiska uruchomieniowego aplikacji, wprowadzając Czytelnika w świat procesów i wątków (jak również związanych z nimi problemów), interakcji z systemem plików oraz samymi plikami, a także komunikacji pomiędzy programami. Wszystkie rozdziały są okraszone olbrzymią dawką technicznych smaczków, studiów przypadku i przykładowych listingów kodu, tworząc lekturę wypełnioną po brzegi treścią. Z pełnym przekonaniem polecam ten tytuł każdemu początkującemu i średnio zaawansowanemu programiście, a przede wszystkim tym, którzy zamiast pobieżnych wyjaśnień dostępnych w wielu opracowaniach (np. „zmienna typu short może przechowywać wartości z zakresu od –32768 do 32767”) wolą poznać pełną historię. Uważam, że jest to pozycja obowiązkowa na półce każdego
zainteresowanego tą dziedziną informatyki, tuż obok książki do nauki konkretnego języka programowania oraz podręcznika do algorytmów i struktur danych; idealnie wypełni ona lukę dotyczącą podstaw środowiska wykonawczego oraz kluczowych mechanizmów używanych w oprogramowaniu w praktycznych zastosowaniach. Życzę Ci, Czytelniku, dobrej zabawy na kolejnych kartach tego tomu, gdyż to właśnie zabawa i fascynacja tematem pomagają z łatwością Zrozumieć programowanie. Mateusz Jurczyk Senior Software Engineer, Google Kraków, wrzesień 2015 r.
Wstęp O autorze, o książce Kiedy miałem siedem lat, zostałem dumnym posiadaczem swojego pierwszego, własnego komputera Atari 800XL wraz z magnetofonem oraz dwoma dżojstikami; do zestawu był również dołączony zestaw kaset z przeróżnymi grami, takimi jak River Ride, Bruce Lee czy Archon. Wczytanie gry trwało zazwyczaj od pięciu do dziesięciu minut, a więc całą wieczność dla niecierpliwego dziecka. Na szczęście jedna z gier nie wymagała kasety i była dostępna od razu po włączeniu komputera – nazywała się Atari BASIC i była grą w programowanie. Co więcej, była do niej dołączona instrukcja w formie zdobytej cudem kserokopii książki, która dla osoby rozpoznającej litery, ale niekoniecznie umiejącej złożyć je w wyrazy, stanowiła zagadkę, tak samo jak BASIC. Niemniej jednak umiejętność czytania nie jest potrzebna, by przepisywać kolejne znaki z przykładowych listingów, co też z upodobaniem czyniłem – najpierw z kserówek, a potem z kolejnych numerów czasopisma „Bajtek”, po które co miesiąc udawałem się wraz z którymś z rodziców do pobliskiego kiosku. Na samym przepisywaniu listingów się nie kończyło – jeszcze większą radość przynosiło mi nanoszenie rozmaitych zmian we wprowadzonych programach i obserwowanie ich skutków. W efekcie okazało się, że parametry rozkazu REM były bez znaczenia (z czym wiązała się inna świetna wiadomość – nie trzeba było ich przepisywać!), parametry komendy PRINT były wypisywane na ekran, po instrukcji GOSUB musiało znaleźć się RETURN, a GRAPHICS na spółkę z PLOT i DRAWTO pozwalały rysować kolorowe linie. Z każdym przepisanym listingiem poznawałem coraz więcej wzorców, a z każdą wprowadzoną zmianą coraz lepiej rozumiałem to, co właściwie działo się w programie. Bardzo szybko zacząłem również pisać własne, proste programiki, które z biegiem czasu stawały się coraz dłuższe i bardziej złożone. Wkrótce potem w domowym salonie stanął komputer kompatybilny z IBM PC, wyposażony w procesor 80286, 1 MB RAM, stację dyskietek 5,25" i kartę graficzną Hercules
oferującą jedynie monochromatyczne tryby graficzne i tekstowe. Wraz z nim nadszedł czas nauki nowego wariantu znanego mi już języka – stworzonego przez Microsoft GW-BASIC. Wkrótce potem komputer został doposażony w dysk twardy o pojemności 50 MB, co umożliwiło zainstalowanie pełnego systemu MS-DOS 5 wraz z interpreterem języka QBasic, a później także Windows 3.1 i potężnym IDE języka Visual Basic 1.0. Czas leciał, leciwy 80286 został wymieniony na wielokrotnie szybszy 80486, a z języka Visual Basic przeniosłem się na Turbo Pascal. W kolejnych latach pojawiły się procesory z rodziny Pentium, pierwsze akceleratory 3D do kart graficznych (wtedy jeszcze jako osobne urządzenia), a także ogólnodostępny Internet za sprawą numeru 0–20 21 22. Wraz z nim uzyskałem dostęp do sieci IRC, grup dyskusyjnych i hobbystycznie prowadzonych stron internetowych poświęconych różnym językom programowania. Tymczasem trafiłem do liceum, komputer przeszedł kolejną metamorfozę i został wyposażony w procesor Intel Celeron taktowany z częstotliwością 333 MHz oraz kartę graficzną z wbudowaną akceleracją 3D, a ja za namową znajomego porzuciłem Turbo Pascal na rzecz C++ i OpenGL; moje zainteresowanie wzbudził też temat inżynierii wstecznej. Potem nadeszły studia na Politechnice Wrocławskiej, kolejne języki programowania w niemałej liczbie i coraz bardziej skomplikowane projekty. Na drugim roku rozpocząłem pierwszą pracę jako programista i reverse engineer dla jednej z polskich firm antywirusowych, coraz bardziej interesując się również zagadnieniami związanymi z bezpieczeństwem komputerowym. Wkrótce przeszedłem do hiszpańskiej firmy Hispasec, skończyłem studia inżynierskie, by ostatecznie trafić do firmy Google, w której pracuję do dzisiaj. Spoglądając w przeszłość, mogę powiedzieć, że moja fascynacja wszystkim, co wiąże się z komputerami, a w szczególności programowaniem, nigdy nie minęła, mimo że zaczęła się ponad ćwierć wieku temu – jednym z jej efektów było napisanie książki, którą właśnie, Czytelniku, trzymasz w ręku. Podczas jej tworzenia starałem się uchwycić preferowane przeze mnie podejście do programowania, które jest zabarwione ciekawością, chęcią zrozumienia tego, jak każdy z używanych mechanizmów działa od środka, a także zamiłowaniem do niskopoziomowych zakątków informatyki. Co za tym idzie, książkę pisałem z myślą o pasjonatach, dla których programowanie jest jednocześnie wyzwaniem i świetną zabawą, bez względu na to, czy programują jedynie dla przyjemności, czy też pracują w zawodzie.
Dobierając zawartość książki, starałem się przede wszystkim wybrać tematy, o które często pytają początkujący i średnio zaawansowani programiści – stąd m.in. obecność rozdziałów o wątkach, gniazdach sieciowych czy plikach binarnych. Do spisu treści trafiły również tematy moim zdaniem istotne dla każdego programisty, takie jak niskopoziomowe kodowanie danych, synchronizacja procesów wielowątkowych czy choćby korzystanie z wiersza poleceń. Chciałem jednak również wskazać, że programowanie nie kończy się na aplikacjach narzędziowych lub „usługowych”, stąd obecność ostatniego rozdziału książki zatytułowanego „Programowanie dla zabawy”. Jednocześnie zdecydowałem się pominąć pewne, skądinąd bardzo istotne, zagadnienia, takie jak algorytmy, wzorce projektowe czy szczegółowe opisy konkretnych języków programowania – są one opisane w wielu innych, bardziej wyspecjalizowanych publikacjach. Przykłady w książce zostały napisane w różnych językach programowania – są to przede wszystkim Python, C, C++ oraz Java. Ta nietypowa różnorodność brała się z chęci podkreślenia, że w większości środowisk występują te same podstawowe mechanizmy, których używa się w bardzo podobny sposób niezależnie od wybranego języka programowania, a różnice często sprowadzają się wyłącznie do nazw bibliotek, funkcji, klas itp. Jednocześnie z uwagi na specyfikę poszczególnych języków oraz ich umowną klasyfikację jako nisko- bądź wysokopoziomowe istnieją zadania łatwiejsze i trudniejsze do wykonania w każdym z nich – na przykład korzystanie z zaawansowanych funkcji oferowanych przez system operacyjny jest łatwiejsze w C i C++, ale już przetwarzanie danych tekstowych jest zdecydowanie prostsze w językach pokroju Python. Ostatecznie nie ma języka idealnego – warto więc, by programista znał ich kilka i zgodnie z zasadą use the right tool for the right job oraz własnymi preferencjami wybierał język adekwatny do danego zadania. Tak jak nie istnieje idealny język programowania, tak nie ma również idealnego systemu operacyjnego, stąd tematy poruszane w książce omawiam w kontekście dwóch popularnych rodzin systemów: Microsoft Windows oraz GNU/Linux (w szczególności Ubuntu). Kontynuując temat przykładowych programów, starałem się również, by listingi miały wewnętrznie spójny styl, ale jednak zróżnicowany pomiędzy sobą – jednym z celów książki było zaprezentowanie fragmentów istniejącego ekosystemu programistycznego, w którym różni programiści stosują odmienne
style tworzenia kodu. Warto więc jak najwcześniej nabrać pewnej elastyczności w tej kwestii – choć każdy projekt powinien konsekwentnie kierować się zasadami konkretnego stylu, jego wybór jest często kwestią osobistych preferencji programisty. Nie w każdym przykładowym kodzie zawarłem pełne sprawdzanie błędów – wynikało to z chęci zwiększenia czytelności listingów i redukcji ich długości, która w przeciwnym razie mogłaby być przytłaczająca dla bardziej początkujących Czytelników. W przypadku rozwijania kodu produkcyjnego (tj. o wysokiej jakości) pełne sprawdzanie błędów jest w zasadzie obowiązkowe, choć oczywiście nie każdy tworzony kod musi przystawać do takich standardów. W trakcie pracy nad pomocniczymi narzędziami jednorazowego użytku czy rozwiązaniami zadań podczas konkursów trwających kilka lub kilkadziesiąt godzin obranie drogi „na skróty” może nierzadko zaoszczędzić sporo czasu i okazać się ostatecznie strategią najkorzystniejszą. W książce oprócz przykładowych listingów kodu występuje również znaczna liczba ramek oznaczonych jako [VERBOSE] oraz [BEYOND]. Celem tych pierwszych jest dokładniejsze przedyskutowanie zagadnień, które pojawiają się w tekście, i są skierowane przede wszystkim do bardziej początkujących Czytelników. Zawartość ramek [BEYOND] natomiast znacznie rozwija tematykę i często wykracza poza średni poziom skomplikowania danego rozdziału – jestem przekonany, że nawet zaawansowani programiści mogą w nich znaleźć coś nowego i ciekawego. Chciałbym również zachęcić do odwiedzenia oficjalnego serwisu „Zrozumieć Programowanie”, w którym można znaleźć przykładowe kody źródłowe, erratę, jak i niewielkie forum stworzone z myślą o dyskusji na tematy poruszane w książce, w tym również o ćwiczeniach i flagach. Znajduje się on pod adresem: http://gynvael.coldwind.pl/book/
Podziękowania W powstaniu i nadaniu ostatecznego kształtu tej książce swój udział miało wiele osób, którym chciałbym w tym miejscu podziękować. Mojej żonie Arashi Coldwind za wsparcie okazywane podczas całego czasu trwania tego, bądź co
bądź, niemałego projektu, jakim jest napisanie książki. Autorowi przedmowy, a jednocześnie redaktorowi merytorycznemu i mojemu dobremu przyjacielowi Mateuszowi Jurczykowi, który na edycję i korektę tej książki poświęcił ogromną ilość własnego czasu. Tomaszowi Łopuszańskiemu, który wraz ze mną przesiadywał do późnych godzin nocnych, przeglądając i szlifując każdy kolejny nadesłany rozdział. Łukaszowi Łopuszańskiemu, który przekonał mnie do napisania tej książki i doprowadził do jej wydania. Sebastianowi Rosikowi za genialny projekt okładki. Recenzentom merytorycznym, którzy wychwycili znaczą liczbę niedociągnięć obecnych w pierwotnej wersji, a byli to: Paweł „KrzaQ” Zakrzewski, Robert „Jagger” Święcki, Mariusz Zaborski, Unavowed, Michał Leszczyński, Michał Melewski, Karol Kuczmarski, Adam „pi3” Zabrocki, Sergiusz „q3k” Bazański, Tomasz ‚KeiDii’ Bukowski. Testerom, którzy wskazali dodatkowe błędy i nieścisłości, w składzie: Łukasz „Stiltskin” Głowacki, Nism0, Łukasz „Lord Darkstorm”, Trzeciakiewicz, Alan Cesarski. A także Ange Albertiniemu, Mikołajowi Koprasowi, Mattowi Moore’owi, Ferminowi Sernie oraz Michałowi Zalewskiemu. Mam nadzieję, Drogi Czytelniku, że książka ta będzie dla Ciebie ciekawą i inspirującą lekturą. Gynvael Coldwind Zurych, wrzesień 2015 r.
Zgłaszanie błędów i errata W idealnym wszechświecie napisałbym książkę bez błędów, w której nie byłoby miejsca na literówki czy brakujące przecinki. Niestety, książka ta pochodzi z naszego wszechświata, więc wbrew wszelkim staraniom błędy na pewno się pojawią. Z tego względu chciałbym zachęcić Czytelników do zaglądania od czasu do czasu na stronę z erratą, którą będę aktualizował przy okazji każdego znalezionego lub zgłoszonego błędu merytorycznego. Erratę można znaleźć pod adresem:
http://gynvael.coldwind.pl/book/errata Chciałbym również zachęcić Czytelników do zgłaszania wszelkiego rodzaju błędów, zarówno merytorycznych, jak i językowych – wszystkie zauważone niedociągnięcia będą eliminowane w kolejnych wydaniach książki. Informacje na temat wykrytych błędów proszę zgłaszać pod poniższym adresem: http://gynvael.coldwind.pl/book/bugbounty Dodatkowo, naśladując niejako Donalda Knutha[1], postaram się wysłać pamiątkową pocztówkę każdemu Czytelnikowi, który jako pierwszy zgłosi dany błąd merytoryczny. Szczegóły tego swoistego bug bounty można również znaleźć na wspomnianej stronie internetowej.
Część I
Kilka codziennych czynności Ryzykując otarcie się o banał, napiszę, że w skład umiejętności niezbędnych programiście wchodzą: Umiejętność projektowania architektury programów, funkcji, klas, protokołów itp. Znajomość algorytmów, struktur danych i wzorców projektowych. Znajomość przynajmniej jednego języka programowania i sprawność w posługiwaniu się nim. Umiejętność, którą osobiście określam mianem „tłumaczenia myśli na kod”. Jak się często okazuje, umiejętności te są konieczne, ale nie zawsze wystarczające, aby biegle tworzyć i analizować oprogramowanie. Wynika to z dwóch faktów: programy ani nie działają w próżni, ani nie są tworzone w jednolity sposób przez niewielką grupę osób. Oznacza to, że programista musi zapoznać się również z ekosystemem, w którym są tworzone i wykonywane jego programy, oraz pogodzić się z myślą, że inni programiści tworzą kod w odmiennych językach bądź na podstawie innych zasad (lub ich braku). W niniejszym rozdziale chciałbym wskazać i opisać kilka codziennych czynności, które „przytrafiają się” programistom. Dodam, że niektóre z nich mogą być konieczne do zrozumienia i prawidłowej interpretacji pewnych fragmentów książki; w szczególności chciałbym zachęcić do zapoznania się z rozdziałami: „Konsola i interpreter poleceń” – to właśnie konsolę wykorzystuję w zdecydowanej większości przykładów w niniejszej książce, oraz „Czytanie nieznanego języka”, ponieważ przykładowe listingi zostały sporządzone w różnych językach, a często również w odmiennych stylach tworzenia kodu[2].
Rozdział 1
Konsola i interpreter poleceń Pomimo upływu lat konsola nadal jest jednym z podstawowych narzędzi wykorzystywanych przez zaawansowanych programistów i administratorów systemów – w praktyce konsola i interpreter poleceń to często niezwykle wygodne i przydatne narzędzia, a w niektórych wypadkach stanowią wręcz jedyną możliwość skorzystania z pewnych programów. Zarówno konsola, jak i interpreter to de facto standard w przypadku większości unixowych systemów operacyjnych (rys. 1).
Rysunek 1. ConEmu-Maximus[3], DOSBox[4] z uruchomioną aplikacją w trybie tekstowym oraz GNOME Terminal[5]
Na samym początku warto wyjaśnić pewną nieścisłość związaną z używanymi terminami, a mianowicie: czym jest konsola, a czym interpreter poleceń? Konsola (nazywana również emulatorem terminalu lub potocznie – terminalem) jest pewnego rodzaju środowiskiem wykonania, z którego mogą (ale nie muszą) korzystać aplikacje – należy od razu zaznaczyć, że w danym momencie z jednej konsoli może korzystać wiele aplikacji. Elementem centralnym jest bufor tekstowy o określonej (ale niekoniecznie stałej) wielkości, który jest w całości lub częściowo wyświetlany w oknie konsoli i który może być pośrednio lub bezpośrednio modyfikowany przez wszystkie aplikacje z niej korzystające. Dodatkowo w danym momencie jedna aplikacja jest tzw. aplikacją pierwszoplanową (foreground) – wejście z klawiatury, a czasem również myszki, jest przekazywane właśnie do niej. Domyślnie dane wypisywane przez aplikację na standardowe wyjście (stdout) oraz standardowe wyjście błędów (stderr) są również odbierane przez konsolę, która wyświetla tekst, biorąc pod uwagę wszystkie znaki specjalne i sekwencje kontrolne oraz pozycję kursora. Analogicznie wejście z klawiatury jest przesyłane przez konsolę na standardowe wejście (stdin) aplikacji pierwszoplanowej[6]. Z punktu widzenia użytkownika konsola jest po prostu tekstowym interfejsem wykorzystywanym przez niektóre programy. Jedną z podstawowych aplikacji korzystających z konsoli jest właśnie interpreter poleceń (command processor lub command-line interpreter), nazywany czasem powłoką systemową (shell)[7], który zwyczajowo udostępnia użytkownikowi zestaw poleceń i funkcji do: Nawigacji w systemie (polecenia typu cd, dir lub ls, pwd itp.). Konfigurowania środowiska uruchomieniowego, w tym zmiennych środowiskowych (set, export, path, ulimit itp.). Uruchamiania innych programów z podanymi argumentami i ewentualnymi przekierowaniami strumieni stdin/stdout/stderr. Tworzenia skryptów automatyzujących powyższe czynności. Przykładowe interpretery poleceń to m.in.: cmd.exe (Windows); PowerShell (Windows); Bash (GNU/Linux itp.);
Z shell (GNU/Linux itp.). W dalszych podrozdziałach opiszę główne koncepty (przekierowania, argumenty, zmienne środowiskowe, katalog roboczy) wykorzystywane przy pracy z interpreterami poleceń, przy czym skupię się na dwóch z nich: cmd.exe (Windows 7) oraz Bash (Ubuntu 14.04.2 LTS). Ponieważ będzie to opis skrócony, zachęcam czytelników do bliższego przyjrzenia się wybranemu przez siebie interpreterowi we własnym zakresie. Pominę również opis większości poleceń (zarówno wbudowanych, jak i dostępnych w systemie operacyjnym) do operacji na plikach i katalogach – zostały one opisane w wielu innych, łatwo dostępnych publikacjach, np.: Bash: – http://www.tldp.org/LDP/GNU-Linux-Tools-Summary/html/c2690.htm – http://www.tldp.org/LDP/GNU-Linux-Tools-Summary/html/x3289.htm cmd.exe:
– http://en.wikibooks.org/wiki/Guide_to_Windows_Commands/File_and_D –
http://en.wikibooks.org/wiki/Guide_to_Windows_Commands/File_Comm
1.1. Wykorzystanie interpretera Poniżej zaprezentowalem kilka przykładów użycia interpretera wraz z krótkim, wysokopoziomowym opisem ich działania. Większość użytych mechanizmów jest wyjaśniona w dalszej części tego rozdziału. (Ubuntu) reset && make test (Windows) cls && make test Wyczyszczenie okna konsoli oraz rekompilacja projektu i jego uruchomienie (przy wykorzystaniu skryptu Makefile). W przypadku dystrybucji Ubuntu osobiście preferuję stosowanie komendy (programu) reset zamiast clear, ponieważ w emulatorze konsoli, z którego korzystam (GNOME Terminal), reset czyści cały bufor tekstowy konsoli, natomiast clear jedynie przewija ekran niżej tak, by wydawało się, że został on wyczyszczony – jest to istotne, gdy kompilacja
lub uruchomienie projektu powoduje wypisanie dużej ilości tekstu, a dla nas wygodne byłoby szybkie przewinięcie ekranu na początek danych. Alternatywnie można by skorzystać np. z polecenia make test 2>&1 | less. (Ubuntu) python gen_input.py | LD_PRELOAD=`pwd`/debug.so ./app Uruchomienie skryptu gen_input.py, który generuje na standardowe wyjście pewne dane wejściowe dla aplikacji app – są one przekazywane na jej standardowe wejście. Dodatkowo niektóre importowane przez aplikację funkcje zostaną podmienione na funkcje o tej samej nazwie, znajdujące się w dynamicznej bibliotece debug.so, która umieszczona jest w obecnym katalogu roboczym. (Ubuntu)
for
i
data/$i.data; done (Windows) for /l
in %i
{1..1000}; in
do
(1,1,1000)
./a.out do
$i;
cp
out.data
@(a.exe
%i
&&
copy
out.data data\%i.data) Uruchomienie aplikacji a.out / a.exe tysiąc razy z kolejnymi liczbami (od 1 do 1000) w pierwszym parametrze. Za każdym razem wygenerowany przez aplikację plik out.data jest kopiowany do katalogu data i nadawana jest mu nowa nazwa w formacie .data. (Ubuntu) watch pep8 test.py Co dwie sekundy (domyślne opóźnienie programu watch) okno konsoli jest czyszczone, a następnie uruchomiony zostaje walidator stylu PEP 8 języka Python. Jest to wygodne rozwiązanie, jeśli w tym czasie w osobnym oknie poprawiamy skrypt test.py i chcemy od razu widzieć, czy zmiany odniosły zamierzony skutek (tj. czy ostrzeżenie o nieprawidłowym stylu zniknęło). Kilka innych przykładów pojawia się również w innych miejscach w tym rozdziale.
1.2. Przekierowania Jedną z najważniejszych cech interpreterów jest możliwość przekierowania standardowego wyjścia (oznaczanego najczęściej deskryptorem 1), wyjścia błędów (2) i wejścia (0) procesów oraz łączenia ich między procesami. Najłatwiej jest to wytłumaczyć na przykładach (które są poprawne zarówno dla Bash, jak
i cmd.exe; prawdopodobnie zadziałają również w przypadku większości innych współczesnych interpreterów): program > xyz Wszystkie dane wypisywane na standardowe wyjście przez uruchomiony program trafią do pliku xyz (jeśli plik ten istnieje, zostanie nadpisany). Jest to idealne rozwiązanie, gdy danych jest dużo, a my chcemy je na spokojnie przejrzeć lub użyć jako danych wyjściowych w późniejszym terminie. Alternatywnie można by napisać program 1>xyz, choć nie jest to konieczne, ponieważ standardowe wyjście przekierowania w tę stronę.
(1)
jest
domyślnym
argumentem
dla
program 2> errors Podobnie jak wyżej, z tą różnicą, że do pliku errors trafią dane wypisywane na standardowe wyjście błędów. program >> xyz Analogicznie jak w powyższych przypadkach, przy standardowego wyjścia) zostaną dopisane na koniec pliku xyz.
czym
dane
(ze
program < input Program otrzyma dane z pliku input na standardowe wejście (tj. czytanie ze standardowego wejścia będzie równoznaczne z czytaniem z pliku input). To przekierowanie idealnie nadaje się do powtarzanych wielokrotnie testów aplikacji odczytujących dane ze standardowego wejścia – dzięki temu nie trzeba ich za każdym razem wprowadzać ręcznie. program 2>&1 Przekierowanie standardowego wyjścia błędów na standardowe wyjścia; przydatne rozwiązanie, jeśli zachodzi potrzeba przefiltrowania standardowego wyjście błędów (patrz dalej). program >xyz 2>&1 Przekierowanie obu standardowych wyjść do pliku xyz. Mogłoby się wydawać, że warianty program 2>&1 >xyz lub program >xyz 2>xyz również zadziałają, jednak tak niestety nie jest (patrz również ramka „Jak rozwiązywane są przekierowania [VERBOSE]” oraz ramka „Dwa przekierowania do jednego pliku [BEYOND]”).
program | inny_program Standardowe wyjście pierwszego programu zostanie przekierowane na standardowe wejście drugiego programu. Drugi program najczęściej albo filtruje otrzymane dane (np. wyszukując tylko podanych wzorców), albo poddaje je dalszej obróbce. Jak rozwiązywane są przekierowania [VERBOSE] Wspomniałem już, że przy przekierowaniu obu standardowych wyjść (stdout, stderr) do jednego pliku należy użyć polecenia program >xyz 2>&1 oraz że podanie przekierowań w odwrotnej kolejności nie zadziała zgodnie z oczekiwaniami – wynika to ze sposobu ich realizacji. W dużym uproszczeniu: o stosowanym zapisie w stylu lewa_strona>prawa_strona, myśleć
jako
o
standardowej
lewa_strona&1 >xyz: 1. 2>&1 (a więc fd2=fd1) – deskryptor 2 ma od teraz wskazywać na ten sam potok/plik itp., do którego odnosi się deskryptor 1. W tym momencie deskryptor 1 wskazuje na odziedziczone po interpreterze stdout, zatem
deskryptor 2 od tego momentu będzie również odnosić się do standardowego wyjścia interpretera. 2. >xyz (a więc fd1=file("xyz", WRITE)) – deskryptor 1 od teraz ma wskazywać na nowo otwarty (do zapisu) plik xyz. Deskryptor 2 nie jest w żaden sposób uaktualniany ani zmieniany, zatem cały czas odnosi się do standardowego wyjścia interpretera. Podsumowując, w momencie faktycznego uruchomienia programu (przekierowania odbywają się bezpośrednio przed jego uruchomieniem), stdout nowego procesu (deskryptor 1) jest przekierowane do pliku xyz, a stderr (deskryptor 2) wskazuje na stdout procesu rodzica (interpretera). Nic natomiast nie zmieniło się w przypadku stdin (deskryptor 0). Rozważmy jeszcze jeden przypadek. Załóżmy, że chcemy zamienić stdout i stderr miejscami, tak aby stdout uruchamianego procesu wskazywało na stderr interpretera, oraz analogicznie, aby stderr uruchamianego procesu wskazywało na stdout interpretera. W tym celu należy stworzyć dodatkowy tymczasowy deskryptor (podobnie jak przy zamianie miejscami wartości zmiennych) i z niego skorzystać. Polecenie, którego użyjemy, wygląda następująco: program 3>&1 1>&2 2>&3. Rozważmy, co się dzieje w kolejnych krokach przekierowań: 1. 3>&1 (a więc fd3=fd1) – ma zostać utworzony nowy deskryptor – 3 (fd3), który ma wskazywać na to samo, co deskryptor 1, czyli na odziedziczone po interpreterze stdout. 2. 1>&2 (a więc fd1=fd2) – od teraz deskryptor 1 ma wskazywać na to samo, co deskryptor 2, czyli na odziedziczone po interpreterze stderr. 3. 2>&3 (a więc fd2=fd3) – od teraz deskryptor 2 ma wskazywać na to samo, co deskryptor 3, czyli na odziedziczony po interpreterze stdout. Ostatecznie w momencie uruchomienia programu stdout nowego procesu będzie wskazywać na stderr procesu interpretera, a stderr nowego procesu na stdout interpretera. Aby przetestować opisany mechanizm w praktyce, warto stworzyć pomocniczy program, np. w języku Python (2.7): import sys print>>sys.stderr, "STDERR" print "STDOUT"
Przetestowanie podmiany będzie wymagało od nas upewnienia się, że stdout i stderr interpretera wskazują na różne urządzenia. Możemy np. wywołać interpreter z przekierowanymi stdout/stderr do oddzielnych plików oraz nakazać mu uruchomić pomocniczy program ze wspomnianymi przekierowaniami: Ubuntu: $ bash -c -- "python p1.py 3>&1 1>&2 2>&3" >log_stdout 2>log_stderr $ cat log_stdout STDERR $ cat log_stderr STDOUT Windows: > cmd /c "python p.py 3>&1 1>&2 2>&3" >log_stdout 2>log_stderr > type log_stdout STDERR > type log_stderr STDOUT Więcej informacji na temat przekierowań można znaleźć w oficjalnym podręczniku Basha [1] oraz na MSDN [2].
Dwa przekierowania do jednego pliku [BEYOND] Wspomniałem również o drugim intuicyjnym, lecz błędnym sposobie przekierowania obu standardowych wyjść do jednego pliku: program >xyz 2>xyz. Dlaczego wariant ten jest niepoprawny? Na systemach z rodziny Windows odpowiedź otrzymujemy natychmiast: > program >xyz 2>xyz The process cannot access the file because it is being used by another process. Interpreter zgłosił, że plik xyz jest już otwarty przez inny proces, nie można go więc użyć do przekierowania. „Innym procesem” w tym przypadku jest sam interpreter, który przy realizacji wyrażenia >xyz sam otworzył wskazany plik, ustawiając flagę FILE_SHARE_READ, która zezwala na ponowne otwarcie tego
pliku jedynie do odczytu. Przy realizacji 2>xyz interpreter próbuje ponownie otworzyć plik xyz do zapisu, co skutkuje błędem ze względu na wspomnianą flagę. W przypadku interpretera Bash polecenie wykona się, lecz w pliku xyz znajdziemy jedynie strzępy informacji. Wynika to z faktu, że obie próby otwarcia pliku xyz powiodły się. Są one jednak przypisane do oddzielnych deskryptorów, co łatwo sprawdzić, korzystając z programu strace: > strace -f bash -c -- "program >xyz 2>xyz" 2>&1 | grep -A 3 'open("xyz' [pid [pid [pid
7716] open("xyz", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 3 7716] dup2(3, 1) = 1 7716] close(3) = 0
[pid [pid [pid [pid 0
7716] 7716] 7716] 7716]
open("xyz", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 3 dup2(3, 2) = 2 close(3) = 0 execve("/program", ["program"], [/* 22 vars */]) =
Ponieważ każdy deskryptor ma swój własny, niezależny kursor zapisu (patrz również część IV książki), który najpierw jest ustawiony na początek pliku, to przy każdej operacji zapisu mogą zostać nadpisane dane, które zostały zapisane, korzystając z drugiego deskryptora. Można to przetestować, używając pomocniczego skryptu (Python 2.7): import sys sys.stdout.write("aaaaaa") sys.stdout.flush() sys.stderr.write("AAA") Zaprezentowany program (w kontekście omawianego przekierowania) zapisuje do pliku ciąg "aaaaaa", korzystając z deskryptora stdout – jego kursor zapisu jest zatem przesunięty o 6 bajtów do przodu, dzięki czemu następny zapis korzystający z tego deskryptora wyemituje dane za już istniejącymi. Dwie linie dalej zapisanie ciągu "AAA" używa innego deskryptora, wskazującego na ten sam plik – stderr. Kursor zapisu tego deskryptora cały czas wskazuje na początek pliku, a więc tam właśnie trafią dane. W efekcie powinniśmy
otrzymać ciąg "AAAaaa", co możemy sprawdzić, uruchamiając skrypt z przekierowaniami: $ python test.py >xyz 2>xyz $ cat xyz AAAaaa Podsumowując, opisana metoda przekierowania nie pozwala na skuteczne zapisanie wszystkich danych wypisywanych na stdout i stderr.
1.3. Przykładowe polecenia wykorzystujące przekierowania Poniżej zaprezentowałem kilka typowych przekierowań wraz z ich wyjaśnieniem:
przykładów
wykorzystania
(Ubuntu) program | less (Windows) program | more Wyświetl dane ze standardowego wyjścia programu po jednej stronie ekranowej naraz. (Ubuntu) ps aux | grep "python" (Windows) tasklist | find "python" Przefiltruj standardowe wyjście poleceń ps aux i tasklist w poszukiwaniu słowa „python”. W praktyce wypisane zostaną przede wszystkim informacje o procesach Pythona. (Ubuntu) strace -e trace=file program 2>&1 | grep passwd Przefiltruj logi generowane przez strace w poszukiwaniu słowa „passwd”. W praktyce wypisane zostaną przede wszystkim bezpośrednie interakcje procesu z plikiem passwd. (Ubuntu) cat /etc/passwd | wc -l Wypisz, ile linii ma plik /etc/passwd. Pod Windowsem analogiczny efekt można uzyskać jedynie niebezpośrednio (np. korzystając z polecenia find
/c
/v
"ciąg", aby wypisać liczbę linii niezawierających danego ciągu, który należy
dobrać tak, by nigdy nie wystąpił w pliku), ale w praktyce lepiej jest zainstalować np. pakiet GnuWin32 [3] i skorzystać z wc -l. (Ubuntu) program | grep -i "warning" | sort > warning.log (Windows) program | find /i "warning" | sort > warning.log Uruchom program, przefiltruj jego wejście pod kątem linii zawierających słowo „warning” (wielkość liter nie ma znaczenia), a następnie posortuj linie alfabetycznie i zapisz je do pliku warning.log.
1.4. Bieżący katalog roboczy W skład środowiska, w którym działa aplikacja, wchodzi tzw. katalog roboczy (nazywany czasem w skrócie cwd lub rzadziej – wd), czyli po prostu jeden z katalogów w systemie plików, w którym dana aplikacja została uruchomiona lub który dana aplikacja wybrała po uruchomieniu. Katalog roboczy jest wykorzystywany przez system operacyjny jako domyślny katalog w przypadku operacji na systemie plików korzystających ze ścieżek względnych (tj. relatywnych względem katalogu roboczego). Należy dodać, że operując na ścieżkach (dotyczy to zarówno interpretera, większości innych aplikacji, jak i większości poleceń w językach programowania), można użyć dodatkowo dwóch pseudokatalogów: . (kropka) – obecny katalog; .. (dwie kropki) – nadrzędny katalog. Ponadto niektóre interpretery poleceń udostępniają pseudokatalog ~ (tylda) oznaczający katalog domowy użytkownika (zazwyczaj /home/ w przypadku systemów unixowych). Interpreter poleceń posiada zazwyczaj szereg poleceń do zmiany oraz wyświetlenia katalogu roboczego (tab. 1), np.: Tabela 1. Przykładowe polecenia operujące na katalogu roboczym
Bash
cmd.exe
Opis
pwd
cd
Wyś wietlenie obecneg o katalog u roboczeg o.
cd xyz
cd xyz
Przejś cie do katalog u xyz znajdująceg o s ię w obecnym katalog u roboczym.
cd ..
cd ..
Przejś cie do nadrzędneg o katalog u.
lub
cd..
Wylis towanie zawartoś ci katalog u.
ls
dir /w
ls la
dir
cd /
cd \
Przejś cie do g łówneg o katalog u dys ku lub s ys temu plików.
C: D:
Zmiana obecneg o dys ku na podany [4].
lub
lub
itp. Uruchomiona aplikacja domyślnie dziedziczy katalog roboczy po swoim rodzicu (np. po interpreterze poleceń), dzięki czemu wszelkie operacje na systemie plików zazwyczaj przeprowadza się, nawigując po katalogach (tj. zmieniając katalog roboczy interpretera) i posługując się ścieżkami względnymi.
1.5. Zmienne środowiskowe Zmienne środowiskowe (environment variables) są niewielkimi, tekstowymi wartościami, w których najczęściej przechowuje się pewne ustawienia, z których korzystają wywoływane aplikacje lub niektóre funkcje biblioteczne. Przykładowe zmienne środowiskowe możemy zobaczyć poniżej: $ env SHELL=/bin/bash VTE_VERSION=3409 TERM=xterm PATH=/usr/bin:/bin:/usr/sbin:/usr/local/sbin PWD=/tmp
LANG=en_US.UTF-8 HOME=/home/gynvael LANGUAGE=en_US:en [...] > set ALLUSERSPROFILE=C:\ProgramData APPDATA=C:\Users\gynvael\AppData\Roaming BXSHARE=d:\bin\bochs-2.6 COMMANDER_DRIVE=C: COMMANDER_EXE=C:\totalcmd\TOTALCMD64.EXE COMMANDER_INI=C:\Users\gynvael\wincmd.ini COMMANDER_PATH=C:\totalcmd CommonProgramFiles=C:\Program Files\Common Files [...] Z reguły w systemie istnieje możliwość zdefiniowania domyślnego zestawu zmiennych środowiskowych[8], natomiast w praktyce każdy proces posiada ich kopię, którą otrzymuje od procesu-rodzica i którą może swobodnie modyfikować – w takim wypadku procesy potomne domyślnie dziedziczą właśnie tę zmodyfikowaną kopię. Proces-rodzic może również zdefiniować dla uruchamianego programu całkowicie odrębny zestaw zmiennych środowiskowych, niezależny od otrzymanych bądź domyślnych zmiennych. Należy jednak zaznaczyć, że zmienne są kopiowane w momencie uruchomienia procesu potomnego – oznacza to, że proces-rodzic nie będzie mógł modyfikować zmiennych środowiskowych procesu potomnego po jego uruchomieniu, ponieważ ten operował będzie na własnej kopii. Analogicznie proces potomny nie może zmienić wartości danej zmiennej środowiskowej w bloku procesu-rodzica. Z tego względu zmienne środowiskowe nie mogą służyć za metodę komunikacji między dwoma procesami[9], a jedynie do przekazania pewnych parametrów w momencie uruchomienia procesu. Podobnie jak w przypadku cwd, interpretery poleceń posiadają zestaw rozkazów do modyfikacji zmiennych środowiskowych interpretera, które zostaną odziedziczone przez uruchamiane aplikacje. Dodatkowo interpretery często korzystają ze zmiennych środowiskowych jako ze zmiennych
wewnętrznych w skryptach. W takim wypadku niektóre interpretery (np. Bash) umożliwiają określenie, które zmienne powinny być przekazywane procesom potomnym, a które powinny być dostępne jedynie z poziomu interpretera lub jego skryptów. Tabela 2 zawiera środowiskowych.
przykładowe
polecenia
operujące
na
zmiennych
Tabela 2. Przykładowe polecenia operujące na zmiennych środowiskowych
Bash
cmd.exe
Opis
export a=xyz
set a=xyz
Us tawienie zmiennej ś rodowis kowej a na wartoś ć xyz.
lub
export a a=xyz
W przypadku Bas h s amo export a powoduje us tawienie „ bitu dziedziczenia” dla dotychczas lokalnej zmiennej a.
a=xyz
Us tawienie zmiennej a, która nie zos tanie przekazana proces om potomnym, na wartoś ć xyz.
a=xyz ./program
Uruchomienie prog ramu prog ram oraz przekazanie mu zmiennej ś rodowis kowej a. Po zakończeniu prog ramu zmienna ta nie jes t widoczna w ś rodowis ku.
unset a
set a=
Us unięcie zmiennej ś rodowis kowej a.
export
set
Wypis anie ws zys tkich zmiennych ś rodowis kowych (które otrzyma proces potomny).
lub
env
lub
printenv
lub
declare xp declare p
Wypis anie ws zys tkich zmiennych ś rodowis kowych i lokalnych.
Jeśli chodzi o same zmienne środowiskowe i ich znaczenie, to kilka przykładów znajdziemy poniżej: PATH – określa, w jakich katalogach mają być m.in. poszukiwane pliki wykonywalne do uruchomienia (w przypadku podania jedynie nazwy programu z pominięciem ścieżki). Zmienna ta jest wykorzystywana przez wiele aplikacji oraz funkcji API systemu. W systemach z rodziny Windows separatorem, w przypadku podania większej liczby katalogów, jest średnik, natomiast w systemach unixowych standardowo jest to dwukropek. Zmienna ta jest o tyle istotna z punktu widzenia programowania, że warto zawrzeć w niej wszelkie katalogi z kompilatorami i interpreterami, z jakich się korzysta[10]. PROMPT (cmd.exe), PS1 i PS2 (Bash) – definiują tzw. znak zachęty (command prompt) w interpreterze poleceń. USERPROFILE (Windows), HOME (GNU/Linux) – określa ścieżkę katalogu domowego użytkownika. COMSPEC (Windows) – zmienna wykorzystywana przez funkcję system ze standardowej biblioteki C pod systemem Windows (msvcrt.dll) do określenia, który interpreter poleceń powinien zostać wywołany. LD_PRELOAD (GNU/Linux) – określa dynamiczne biblioteki, które powinny zostać załadowane przed wszystkimi innymi bibliotekami, wymaganymi przez uruchamiany program (oznacza to, że funkcje wyeksportowane w tych bibliotekach zostaną użyte w miejsce funkcji o tej samej nazwie znajdujących się w załadowanych później bibliotekach). LD_LIBRARY_PATH (GNU/Linux) – określa ścieżki, w których mogą znajdować się wymagane przez program biblioteki dynamiczne. Zachęcam czytelników do wylistowania zmiennych środowiskowych w swoim systemie i próby określenia znaczenia każdej z nich (ćwiczenie [CON:what's-thiswhat's-that]). Do zmiennych środowiskowych można odwoływać się również z linii poleceń w interpreterze (oraz skryptach interpretera). Obrazują to następujące przykłady: Bash:
$ test="Some example text." $ echo "{$test}" Some example text. $ echo "SomeChar${test}AnotherChar" SomeCharSome example text.AnotherChar $ cmd.exe: > set test=Some example text. > echo %test% Some example text. > echo SomeChar%test%AnotherChar SomeCharSome example text.AnotherChar Delayed expansion w cmd.exe [BEYOND] W przypadku interpretera cmd.exe istnieje jeszcze jeden mechanizm odwoływania się do zmiennych środowiskowych – tzw. „opóźnione rozwinięcie” (delayed expansion), oznaczane znakami wykrzykników. Korzysta się z niego w zasadzie tylko w przypadku bardziej skomplikowanych skryptów, używających bloków kodu (oznaczanych nawiasami okrągłymi). O „opóźnionym rozwinięciu” możemy myśleć jako o standardowych odwołaniach do zmiennych znanych z różnych języków programowania – zaskoczeniem wydaje się, że to właśnie „normalne” (tj. oznaczone znakami procentów) rozwinięcie wymaga pewnego wyjaśnienia. W przypadku skryptów .bat (zwanych czasem skryptami batch lub skryptami wsadowymi) standardowe rozwinięcie działa podobnie do preprocesora z języków C/C++, czyli podmiana zmiennej na jej wartość następuje na samym początku przetwarzania danego bloku (lub linii) skryptu. Oznacza to, że jeśli w danym bloku nastąpi zmiana wartości zmiennej, to zmiana ta nie zostanie odzwierciedlona przy kolejnym użyciu zmiennej w tym samym bloku (w przeciwieństwie do zmiennych rozwijanych z opóźnieniem, których wartość zostaje podstawiona dopiero w momencie faktycznego użycia). Stwierdzenie to najlepiej ilustruje poniższy przykład, wykorzystujący obydwa typy odwołania do zmiennej test, której wartość jest modyfikowana wewnątrz bloku kodu:
@echo off setlocal enabledelayedexpansion set test=Some example text. ( echo %test%, !test! set test=Switching text to this. echo %test%, !test! ) Wynik działania skryptu jest następujący: Some example text., Some example text. Some example text., Switching text to this. Wykomentowując instrukcję @echo
off, można zaobserwować,
jak
wyglądają kolejne uruchamiane linie i bloki skryptu (interpreter je wypisze bezpośrednio przed wykonaniem) po fazie rozwinięcia zmiennych oznaczonych procentami: > > > >
rem @echo off setlocal enabledelayedexpansion set test=Some example text. (
echo Some example text., !test! set test=Switching text to this. echo Some example text., !test! ) Some example text., Some example text. Some example text., Switching text to this. W powyższym listingu możemy zaobserwować, że bezpośrednio przed wykonaniem ostatniego bloku kodu rozwinięcie %test% zostało już wykonane. Co ciekawe, istnienie dwóch różnych mechanizmów rozwijania zmiennych pozwala na tworzenie całkiem ciekawych skryptów, które mogą np. emulować obiektowość [5][6].
1.6. Skrypt startowy Co oczywiste, modyfikowanie zmiennych środowiskowych (np. PATH) przy każdym uruchomieniu interpretera jest zdecydowanie uciążliwe. Zamiast tego można albo zmodyfikować zmienne domyślne w systemie, albo skorzystać ze skryptu startowego, który jest uruchamiany przy starcie interpretera. W przypadku cmd.exe pod systemami z rodziny Windows skrypt startowy można stworzyć w dowolnym miejscu i podać jego ścieżkę w rejestrze systemowym: \Software\Microsoft\Command Processor\AutoRun (REG_SZ) Wartość można dodać zarówno dla wszystkich użytkowników (HKEY_LOCAL_MACHINE), jak i tylko dla siebie (HKEY_CURRENT_USER). Osobiście zazwyczaj umieszczam skrypt w C:\Users\gynvael\cmdstart.bat i ma on treść zależną głównie od zainstalowanych interpreterów i kompilatorów, np.: @echo off color a rem Additional command-line binaries set PATH=%PATH%;d:\commands;d:\bin\virtualbox;d:\bin\reskit2003; rem MinGW GCC set PATH=%PATH%;d:\bin\gcc\bin rem Python set PATH=%PATH%;d:\bin\Python27_64;d:\bin\pypy-2.2.1-win32 rem PHP set PATH=%PATH%;d:\bin\php rem Bochs set BXSHARE=d:\bin\bochs-2.6 set PATH=%PATH%;d:\bin\bochs-2.6 rem Sysinternals
set PATH=%PATH%;d:\bin\SysinternalsSuite rem Go set GOROOT=d:\bin\go set PATH=%PATH%;d:\bin\go\bin prompt gynvael:haven-windows$g W przypadku Bash i systemów unixowych skrypt startowy nosi nazwę .bashrc i znajduje się w katalogu domowym użytkownika (w praktyce Bash uruchamia plik .bash_profile, który jednak zwyczajowo wywołuje .bashrc). Na przykład moje zmiany w nim zazwyczaj ograniczają się do modyfikacji znaku zachęty oraz dopisania jednego lub kilku aliasów: [...] PATH=$PATH:~/commands:/usr/sbin:/usr/local/sbin PS1='\[\033[0;31m\]`date +%T` \[\033[1;34m\]\u\[\033[m\]:\ [\033[1;30m\]\h\[\033[0;32m\]> \[\033[1;32m\] alias c='g++ -Wall -Wextra' Warto zapoznać się bliżej z tworzeniem bardziej złożonych skryptów – w praktyce nieraz mogą one okazać się bardzo przydatne (omówienie konkretnych przypadków wykracza jednak poza zakres książki).
1.7. Konsola okiem programisty Z punktu widzenia programowania najbardziej podstawową operacją, którą powinien znać każdy programista, jest interakcja ze standardowym wyjściem oraz wejściem. Dla przypomnienia tabela 3 zawiera przykłady operacji na standardowym wyjściu i wejściu w kilku językach. Tabela 3. Wypisanie wiadomości na stdout i stderr oraz pobranie linii tekstu z stdin
Język
Przykładowy fragment kodu
Interakcja z Python
stdout
print "Example text"
lub
sys.stdout.write("Example text\n")
stderr
sys.stderr.write("Example text\n")
lub
print>>sys.stderr, "Example text"
lub
# 2.7
print("Example text", file=sys.stderr) 3
stdin
line = raw_input()
lub
# 2.7
line = sys.stdin.readline()
C / C++
stdout
puts("Example text")
lub
fputs("Example text", stdout);
stderr
fputs("Example text", stderr);
lub
fwrite("Example text", 1, 12, stderr);
stdin
fgets(buffer, buffer_size, stdin);
lub
gets_s(buffer, buffer_size);[11]
lub
scanf("%15[^\n]", buffer); // dla char buffer[16]
C++
stdout
std::cout echo Example text | a.exe
Zdarza się, iż podczas tworzenia aplikacji docelowo okienkowych korzysta się z wypisywania pomocniczych informacji (tzw. debug messages) na standardowe wyjście. Przed publikacją finalnego produktu następuje jedynie przestawienie subsystemu na okienkowy (tak by konsola nie była alokowana dla tej aplikacji), ale korzystając z przekierowań, można nadal dostać się do wypisywanych informacji. Drugie w kolejce są zazwyczaj argumenty programu – co prawda aplikacje okienkowe również ich używają, niemniej jednak są one zdecydowanie najczęściej spotykane w przypadku programów konsolowych. Dostęp do argumentów odbywa się przeważnie na jeden z dwóch sposobów: gdy główna funkcja programu otrzymuje tablicę argumentów w jednym z parametrów lub gdy dostęp do tej tablicy zachodzi przy użyciu standardowej biblioteki albo funkcji systemowych. Tabela 4 wskazuje, gdzie można znaleźć tablicę argumentów w kilku przykładowych językach. Tabela 4. Dostęp do argumentów programu
Język
Metoda
Python
std. biblioteka
C / C++
main
C / C++ (WinAPI)
API systemowe
Java
main
Przykładowy kod import sys print sys.argv #include int main(int argc, char **argv) { for (int i = 0; i < argc; i++) { printf("%i: %s\n", i, argv[i]); } return 0; } #include #include int main(void) { puts(GetCommandLine()); // Patrz również: CommandLineToArgvW return 0; } class args { public static void main(String[] args) { for(int i = 0; i < args.length; i++) { System.out.println(i + ": " + args[i]); } } }
Sytuacja ze zmiennymi środowiskowymi wygląda podobnie jak z argumentami, choć dostęp do nich zazwyczaj odbywa się poprzez funkcję ze standardowej biblioteki w danym języku – pewnym wyjątkiem są języki C i C++, w których funkcja main w niektórych implementacjach może zostać zadeklarowana z trzema argumentami i otrzymać zmienne środowiskowe w trzecim z nich. Tabela 5 zawiera przykłady odwołań do zmiennych środowiskowych w kilku językach: Tabela 5. Dostęp do zmiennych środowiskowych
Język Python
Metoda std. biblioteka
C / C++
main
C / C++
std. biblioteka
Java
std.biblioteka
Przykładowy kod print os.environ['PATH']
lub
print os.getenv('PATH') #include int main(int argc, char **argv, char **envp) { for (int i = 0; envp[i] != NULL; i++) { printf("%i: %s\n", i, envp[i]); } return 0; } #include #include int main(void) { puts(getenv("PATH")); return 0; }
class env { public static void main(String[] args) { System.out.println(System.getenv("PATH")); // Patrz również: os.getenv() } }
Warto wspomnieć jeszcze o kilku innych metodach dostępu do zmiennych środowiskowych zależnych od systemu operacyjnego: W niektórych systemach opartych na jądrze Linux początkowe zmienne środowiskowe danego procesu znajdują się również w pseudopliku /proc//environ. W języku C w przypadku pewnych systemów unixowych istnieje zmienna globalna char **environ lub char **__environ, która posiada tablicę zmiennych środowiskowych analogiczną do envp.
WinAPI posiada dodatkowe funkcje do operowania na zmiennych środowiskowych – np. GetEnvironmentVariable oraz CreateEnvironmentBlock. Dla wszystkich wymienionych funkcji pobierających zmienne środowiskowe, rozpoczynających się od get* lub Get*, istnieją również odpowiedniki modyfikujące blok zmiennych środowiskowych danego procesu, których nazwy zazwyczaj brzmią identycznie, ale zaczynają się od czasowników: set, put lub unset. Oprócz tego, jak wspomniałem wcześniej, można zdefiniować zupełnie nowy blok zmiennych środowiskowych dla procesu potomnego podczas jego tworzenia – umożliwiają to poszczególne funkcje uruchamiające programy, np. execve czy CreateProcess. Ponadto systemy operacyjne, jak i same konsole, często udostępniają możliwość dodatkowego konfigurowania terminalu, sposobu wyświetlania danych czy odbioru innych zdarzeń od użytkownika (np. pozycji myszki). Tabela 6 zawiera krótką wskazówkę, gdzie szukać informacji o API konsolowym.
Tabela 6. Przykładowe funkcje i biblioteki służące do bezpośredniej interakcji z konsolą
System lub Język
Funkcje lub biblioteka
Python (GNU/Linux)
Moduły termios oraz tty.
C/C++ (GNU/Linux)
Ps eudoplik /dev/tty, a także nag łówki termios.h oraz sys/ioctl.h.
C/C++ i (WinAPI) Java
Funkcje z „ Cons ole” w nazwie, np. SetConsoleMode, FillConsoleOutputAttribute, WriteConsoleOutput itp. Klas a java.io.Console.
Kończąc ten rozdział, chciałbym jeszcze raz gorąco zachęcić czytelników do zapoznania się z możliwościami konsoli i wybranym interpreterem poleceń.
Ćwiczenia [CON:start-script] Stwórz lub zmodyfikuj skrypt startowy interpretera poleceń, z którego korzystasz. [CON:what's-this-what's-that] Uruchom interpreter i wyświetl zmienne środowiskowe, a następnie postaraj się określić znaczenie każdej z nich, tj. przez jaki program lub funkcję jest dana zmienna wykorzystywana.
Bibliografia [1]
Redirections,
Bash
Reference
Manual,
http://coldwind.pl/s/c1r1
http://www.gnu.org/software/bash/manual/bash.html#Redirections [2]
Using command redirection operators, Microsoft Developer Network, https://technet.microsoft.com/en-us/library/bb490982.aspx [3] GnuWin32, http://gnuwin32.sourceforge.net/ [4] Chen R., Why does each drive have its own current directory?, 2010, http://blogs.msdn.com/b/oldnewthing/archive/2010/10/11/10073890.aspx [5]
Coldwind G., Skrypty .BAT i programowanie obiektowe, 2009, http://gynvael.coldwind.pl/?id=123 [6] Coldwind G., OpenGL w skryptach .BAT, 2009, http://gynvael.coldwind.pl/?id=127
Rozdział 2.
Czytanie nieznanego języka Pracując przy niewielkich projektach, często korzystamy tylko z jednego języka programowania, a więc jeśli zachodzi potrzeba zrozumienia fragmentów jego kodu, mamy do czynienia jedynie z językiem, który i tak już znamy. Taka sytuacja ma miejsce, kiedy musimy zapoznać się z kodem stworzonym przez inne osoby pracujące przy naszym projekcie, z kodem jednego z używanych komponentów lub ze swoim własnym, który stworzyliśmy dawno temu i nie do końca pamiętamy, jak funkcjonował. Analogicznie sprawa ma się w przypadku czytania fragmentów innych projektów napisanych w znanym nam języku, choć w tym wypadku odmienne reguły formatowania oraz nieznane komponenty mogą wyglądać nieco egzotycznie i spowolnić analizę. Sprawa wygląda zupełnie inaczej w przypadku eksploracji większych projektów – w takiej sytuacji bardzo szybko dociera się do innych, niekoniecznie znanych nam (lub znanych w niewielkim stopniu) języków oraz technologii. Często identycznie jest w przypadku posiłkowania się źródłami środowiska wykonania (run-time/execution environment) danego języka oraz jego standardowych i dodatkowych bibliotek. Jedynie w nielicznych przypadkach szczegółowy opis interesującego nas fragmentu znajdziemy w oficjalnej dokumentacji projektu[12], częściej jednak będziemy musieli sięgać do źródeł. Jak się okazuje, czytanie i stosunkowo prawidłowa interpretacja kodu w nieznanym języku jest jak najbardziej możliwa – a z im większą ich liczbą miało się do czynienia, tym jest to łatwiejsze. Wynika to z kilku prostych czynników: Języki programowania z danej grupy paradygmatycznej[13] są często bardzo podobne składniowo, gramatycznie i w sposobie użycia. Nazwy funkcji, klas, metod, struktur, a także słowa kluczowe, dyrektywy itd. w zdecydowanej większości przypadków składają się z połączenia angielskich słów lub ich skrótów. Już samo rozwinięcie skrótów i przetłumaczenie wyrazów na język polski niesie ze sobą znaczną porcję informacji.
Program to nie tylko sam kod, ale również komentarze oraz dane (w szczególności komunikaty o błędach), które często są bardzo wymowne. Oczywiście od wszystkich wymienionych czynników istnieją także wyjątki, natomiast w większości wypadków już samo wykorzystanie wymienionych informacji i wskazówek wystarcza do zrozumienia wybranego fragmentu. W razie gdyby to jednak zawiodło, pozostaje nam konsultacja z ogólnodostępną dokumentacją danego języka, choćby w formie tablic informatycznych (potocznie zwanych cheatsheetami).
2.1. Podobieństwa i różnice W językach wywodzących się z paradygmatów programowania proceduralnego czy obiektowego (na takich bowiem skupiam się w tej książce) wiele wyrażeń ma albo identyczny, albo przynajmniej zbliżony zapis. To samo dotyczy dostępności różnych konstrukcji składniowych, których lwia część występuje w większości języków z tych rodzin. Na przykład w C, C++, Objective-C, Java, PHP, JavaScript, Perl oraz w innych językach początek i koniec bloku kodu oznacza się znakami { oraz }. Wyjątkiem od tej reguły jest np. Python, w którym o przynależności do danego bloku decyduje głębokość wcięć. Inną konwencję przyjmuje również Pascal, Delphi oraz ADA, które w tym celu używają słów kluczowych begin oraz end. Kolejnym przykładem może być zapis wywołania funkcji – praktycznie w każdym języku z tej grupy jest to po prostu nazwa funkcji, po której następuje lista rozdzielonych przecinkami parametrów umieszczonych w okrągłych nawiasach. Niektóre języki (np. Ruby czy D) dopuszczają w pewnych przypadkach pominięcie nawiasów. Można ich również nie stosować np. w językach Python (2:7) oraz PHP w przypadku dyrektywy[14] print (występującej w obu tych językach), a także w niektórych zastosowaniach sizeof oraz typeof w językach C oraz C++. Wyrażenia arytmetyczno-logiczne i przypisanie wartości do zmiennej również są w zasadzie identyczne w większości języków (wyjątkiem jest np. Pascal, używający digrafu := do oznaczenia przypisania). Różnice w tej kwestii dotyczą głównie dostępności i sposobu oznaczenia niektórych operacji. Na przykład
wyrażenie a = (2 + b) * 3 będzie zapisane tak samo w większości języków, ale już znak dzielenia może oznaczać różne działania. Dokładniej: wynikiem operacji 1 / 2 w większości języków będzie 0, ponieważ literały 1 oraz 2 zostaną zinterpretowane jako stałe typu całkowitego, a więc i wynikiem dzielenia będzie liczba całkowita. Wyjątkiem jest Pascal oraz Python 3, w których znak / oznacza konkretnie dzielenie na liczbach rzeczywistych[15] (a ściślej zmiennoprzecinkowych), a także JavaScript, w którym nie istnieje oddzielny typ dla liczb całkowitych[16] – w tych przypadkach wynikiem będzie 0.5. Idąc dalej: w kilku językach występuje operator potęgowania, którego nie znajdziemy w innych – operację tę w języku Python oznacza się digrafem **, a w VB.NET znakiem ^[17]. Innym przykładem może być dość charakterystyczny operator ===, występujący w tej postaci m.in. w językach PHP, ActionScript oraz JavaScript, który oznacza równość co do wartości oraz typu[18]. Oczywiście, im bardziej zagłębimy się w dany język, tym więcej charakterystycznych wyrażeń dla niego znajdziemy. Przykładem może być dość nietypowe użycie operatora >
>>
0 LOAD_FAST 3 LOAD_CONST
0 (a) 1 (5)
6 COMPARE_OP 9 POP_JUMP_IF_FALSE 12 LOAD_FAST
2 (==) 32 1 (b)
15 LOAD_CONST 18 COMPARE_OP 21 POP_JUMP_IF_FALSE
2 (10) 2 (==) 32
24 27 28 29
LOAD_CONST PRINT_ITEM PRINT_NEWLINE JUMP_FORWARD
3 ('Kod.')
32 35 36 37 40
LOAD_CONST PRINT_ITEM PRINT_NEWLINE LOAD_CONST RETURN_VALUE
4 ('Inny kod.')
5 (to 37)
0 (None)
Analizując powyższy fragment (a w zasadzie jedynie tłumacząc kod z języka angielskiego na polski), można zaobserwować, że mamy do czynienia z dwoma skokami warunkowymi, które wykonają się w momencie niespełnienia jednego z warunków. Zgodnie z naszymi przewidywaniami operator logicznej koniunkcji w tym wypadku okazał się nie być „prawdziwym” operatorem, lecz zrealizowanym za pomocą skoków warunkowych. Podobny eksperyment wykonajmy jeszcze dla języka C: #include void func(int a, int b) { if (a == 5 && b == 10) { puts("Kod."); } else { puts("Inny kod."); } } Powyższy kod został skompilowany kompilatorem z rodziny MinGW-w64 GCC 4.6.2 do pliku obiektowego poleceniem gcc
-c
test.c, a następnie
zdisasemblowany za pomocą programu IDA Pro. Funkcja func została przedstawiona w postaci grafu na rysunku 3. Analizując sam graf, można zaobserwować dwa rozgałęzienia związane ze skokami warunkowymi: pierwsze w okolicy porównania (instrukcja cmp) ze stałą 5, a drugie przy porównaniu ze stałą 10. Nasze założenie okazało się ponownie prawdziwe.
Rysunek 3. Funkcja func jako graf przedstawiony w programie IDA Pro
Tabela 3. Instrukcje porównania oraz skoki warunkowe
Opkod (hex)
Mnemonik oraz parametry
Opis
20
VCMP rdst, rsrc
compare – porównaj Porównuje wartoś ci rejes trów rdst z rsrc i zapis uje wynik porównania do rejes tru FR (technicznie V CMP wykonuje odejmowanie bez zapis ywania wyniku i us tawia flag ę ZF, jeś li wynik jes t równy zero, oraz CF, jeś li wynik jes t mniejs zy od zera; w obu przypadkach, jeś li warunek nie zos tał s pełniony, dana flag a jes t zerowa). W parze ze s kokiem warunkowym jes t to odpowiednik wys okopoziomowej kons trukcji: if (rdst warunek rsrc) goto cel
Zarówno faktyczny warunek, jak i cel zależą od użyteg o s koku warunkoweg o. Przykłady porównania wartoś ci w rejes trach R1 i R2: VCMP R1, R2
Kod mas zynowy: 20 01 02
Patrz również przykłady zamies zczone przy opis ie s koków warunkowych w dals zej częś ci tabeli. 21
VJZ imm16 VJE imm16
jump if z ero – s kocz, jeś li zero jump if equal – s kocz, jeś li równe S prawdza, czy flag a ZF jes t us tawiona – jeś li tak, rejes tr PC jes t zwięks zany o imm16 (modulo 216). W innym przypadku s kok nie jes t wykonany i ins trukcja nie przynos i żadnych efektów. O ile parametrem w zapis ie mnemonicznym jes t adres docelowy, o tyle na poziomie kodu mas zynoweg o imm16 mus i być zapis any jako różnica pomiędzy adres em docelowym a adres em ins trukcji bezpoś rednio po s koku warunkowym. Przeliczeń pomiędzy parametrem relatywneg o s koku a adres em docelowym wykonuje s ię za pomocą poniżs zych dwóch wzorów: adres_docelowy = (adres_instrukcji_skoku + 3 + imm16) mod 216 imm16 = (adres_docelowy (adres_instrukcji_skoku + 3)) mod 216
Przykład s koku pod adres 0x30, jeś li wartoś ci w rejes trach R1 i R2 s ą równe (zakładam, że adres ins trukcji V JZ to 0x13): VCMP R1, R2 VJZ 0x30
Kod mas zynowy: 20 01 02 21 1A 00
22
VJNZ imm16 VJNE imm16
jump if not z ero – s kocz, jeś li nie zero jump if not equal – s kocz, jeś li nie równe S prawdza, czy flag a ZF jes t wyzerowana – jeś li tak, wykonuje relatywny s kok do ws kazaneg o miejs ca (wg s chematu opis aneg o przy ins trukcji V JZ).
23
VJC imm16 VJB imm16
jump if carry – s kocz, jeś li nas tąpiło przenies ienie jump if below – s kocz, jeś li mniejs ze S prawdza, czy flag a CF jes t us tawiona – jeś li tak, wykonuje relatywny s kok do ws kazaneg o miejs ca (wg s chematu opis aneg o przy ins trukcji V JZ).
24
VJNC imm16 VJAE imm16
jump if not carry – s kocz, jeś li nie nas tąpiło przenies ienie jump if above or equal – s kocz, jeś li więks ze lub równe S prawdza, czy flag a CF jes t wyzerowana – jeś li tak, wykonuje relatywny s kok do ws kazaneg o miejs ca (wg s chematu opis aneg o przy ins trukcji V JZ).
25
VJBE imm16
jump if below or equal – s kocz, jeś li mniejs ze lub równe S prawdza, czy us tawiona jes t flag a CF lub ZF (lub obie) – jeś li tak, wykonuje relatywny s kok do ws kazaneg o miejs ca (wg s chematu opis aneg o przy ins trukcji V JZ).
26
VJA imm16
jump if above – s kocz, jeś li więks ze
S prawdza, czy us tawiona jes t flag a CF i czy jednocześ nie flag a ZF jes t wyzerowana – jeś li tak, wykonuje relatywny s kok do ws kazaneg o miejs ca (wg s chematu opis aneg o przy ins trukcji V JZ). Idąc dalej, kolejną grupą będą instrukcje operujące na stosie (patrz tab. 4). O ile w przypadku wirtualnych maszyn stosowych stos jest zazwyczaj dedykowaną, oddzielną strukturą danych, o tyle w przypadku większości prawdziwych (niewirtualnych) architektur stos jest po prostu fragmentem pamięci operacyjnej, który nie różni się niczym od innych regionów. Tak jest w przypadku architektur x86[31] czy ARM i tak też będzie w przypadku naszej architektury. Zasada działania takiego stosu jest bardzo prosta: wszystkie elementy są tej samej wielkości, zazwyczaj powiązanej bezpośrednio z bitowością procesora. W naszym przypadku procesor jest 32-bitowy, więc każdy element na stosie będzie zajmował tyleż bitów (4 bajty). Od strony naszego CPU jedynym wyznacznikiem tego, gdzie stos faktycznie się znajduje, jest rejestr SP, w którym przechowywany jest adres wierzchołka stosu. Na rejestrze SP operują m.in. instrukcje VPUSH oraz VPOP, które, kolejno, umieszczają element na stosie i ustawiają SP, by na niego wskazywał, oraz zdejmują element ze stosu i ustawiają SP, by wskazywał na poprzedni element (patrz również rys. 4). Inną kwestią, którą musimy rozważyć, jest pytanie: w którą stronę stos rośnie? Czy po umieszczeniu nowego elementu na stosie rejestr SP powinien być zwiększany o 4, czy też zmniejszany o 4? Odpowiedzią, która się nasuwa, jest „zwiększany o 4”, ale w praktyce, szczególnie w przypadku ograniczonej ilości pamięci, lepszym rozwiązaniem jest zmniejszanie wskaźnika stosu. W takim wypadku możemy powiedzieć, że stos ma swój początek (dno) na końcu pamięci, a więc będzie rósł w kierunku mniejszych adresów (początku pamięci). Dzięki temu trochę upraszczamy problem tego, gdzie umieścić stos w pamięci, tak by z jednej strony miał dużo miejsca, ale z drugiej zostawił go możliwie jak najwięcej dla reszty programu (np. implementacji sterty), a także minimalizujemy fragmentację pamięci[32].
Rysunek 4. Przykładowa zmiana rejestru SP podczas operowania na stosie Tabela 4. Instrukcje operujące na stosie
Opkod (hex)
Mnemonik oraz parametry
Opis
30
VPUSH rsrc
push – odłóż (na s tos ie) Zmniejs za adres w rejes trze S P o 4, a nas tępnie kopiuje 32 bity wartoś ci z rejes tru rsrc pod ws kazany przez S P adres pamięci. Przykład umies zczenia 8 bajtów zerowych na s tos ie: VXOR R1, R1 VPUSH R1 VPUSH R1
Kod mas zynowy: 17 01 01
30 01 30 01
31
VPOP rdst
pop – zdejmij (ze s tos u) Wczytuje wartoś ć z pamięci operacyjnej s pod adres u, na który ws kazuje S P, do rejes tru rdst, a nas tępnie zwięks za adres w rejes trze S P o 4. Przykład ś ciąg nięcia wartoś ci ze s tos u do rejes tru R5: VPOP R5
Kod mas zynowy: 31 05
Przedostatnią grupą instrukcji są skoki bezwarunkowe. Choć mogłem opisać je wraz ze skokami warunkowymi, z uwagi na instrukcje z rodziny VCALL chciałem uczynić to dopiero po omówieniu stosu. Instrukcje VCALL oraz VCALLR służą do wywoływania funkcji – różnica pomiędzy nimi a zwykłymi skokami bezwarunkowymi (VJMP, VJMPR) polega na tym, że przy wywołaniu funkcji musi zostać zapamiętany adres powrotu. Istnieje kilka metod, by to osiągnąć, natomiast w naszym przypadku (podobnie jak ma to miejsce w architekturze x86) po prostu odłożymy wartość z rejestru PC na stos – czyli o instrukcji VCALL możemy myśleć jako o sekwencji pseudoinstrukcji VPUSH PC+3 oraz VJMP adres. Dzięki temu, gdy będziemy chcieli wrócić do miejsca wywołania, będziemy mogli pobrać adres ze stosu i do niego skoczyć. W tym celu na liście umieścimy instrukcję VRET, która w zasadzie będzie odpowiednikiem sekwencji instrukcji VPOP rtmp oraz VJMPR rtmp. Tabela 5. Skoki bezwarunkowe
Opkod (hex)
Mnemonik oraz parametry
Opis
40
VJMP imm16
jump – s kocz
Wykonuje relatywny s kok do ws kazaneg o miejs ca (wg s chematu opis aneg o przy ins trukcji V JZ). Odpowiednik goto z języków wyżs zeg o poziomu. 41
VJMPR rsrc
jump to address from register – s kocz do adres u z rejes tru Wykonuje bezwzg lędny s kok do ws kazaneg o w rejes trze adres u. Technicznie kopiuje wartoś ć z rejes tru rsrc (modulo 216) do rejes tru PC. Przykład s koku pod adres 0x1234: VSET R1, 0x1234 VJMPR R1
Kod mas zynowy: 01 01 34 12 00 00 41 01
42
VCALL imm16
call – wywołaj Zapis uje adres kolejnej ins trukcji (PC+3) na s tos ie, a nas tępnie wykonuje relatywny s kok do ws kazanej lokalizacji (wg s chematu opis aneg o przy ins trukcji VJZ).
43
VCALLR rdst
call an address from register – wywołaj funkcję s pod adres u z rejes tru Zapis uje adres kolejnej ins trukcji (PC+2) na s tos ie, nas tępnie wykonuje bezwzg lędny s kok do ws kazaneg o adres u (patrz również VJMPR).
44
VRET
return – powróć Pobiera ze s tos u adres i do nieg o s kacze. Odpowiednik ps eudoins trukcji VPOP PC lub s ekwencji VPOP rtmp i VJMPR rtmp.
Przykład wywołania funkcji i powrotu (zakładam, że adres ins trukcji VCALL to 0x10, a adres etykiety func to 0x40): VCALL func
...
func: VSET R1, 0x1234 VRET
Kod mas zynowy: 0x10: 42 2D 00 ... 0x40: 01 01 34 12 00 00 44
Ostatnia grupa instrukcji jest odmienna od przedstawionych i służy do komunikacji z zewnętrznymi urządzeniami oraz do sterowania zachowaniem procesora w wyjątkowych sytuacjach. Obie te kwestie są omówione dokładniej w dalszej części niniejszego rozdziału. Tabela 6. Dodatkowe instrukcje sterujące
Opkod (hex)
Mnemonik oraz parametry
Opis
F0
VCRL imm16, rsrc
control register load – wczytaj rejes tr s terowania Kopiuje wartoś ć rejes tru rsrc do s pecjalneg o rejes tru s terująceg o o numerze imm 16. W przypadku, g dy rejes tr s pecjalny nie is tnieje, zos tanie wyg enerowany wyjątek 2 (INT_GENERAL_ERROR). Przykład us tawienia rejes tru s pecjalneg o 0x110 na wartoś ć 1: VSET R0, 1 VCRL 0x110, R0
Kod mas zynowy: 01 00 01 00 00 00 F0 00 10 01
F1
F2
VCRS imm16, rdst
control register store – zachowaj rejes tr s terowania
VOUTB imm8, rsrc
output byte – zapis z bajt na wyjś cie
Kopiuje wartoś ć ze s pecjalneg o rejes tru s terująceg o o numerze imm 16 do rejes tru doceloweg o rdst. W przypadku, g dy rejes tr s pecjalny nie is tnieje, zos tanie wyg enerowany wyjątek 2 (INT_GENERAL_ERROR).
Wys yła dolny bajt z rejes tru rsrc do urządzenia (portu) ws kazaneg o przez imm 8. Przykład wys łania litery "A" (kod 0x41) na kons olę: VSET R0, 0x41 VOUTB 0x20, R0
Kod mas zynowy: 01 00 41 00 00 00 F2 00 20
F3
VINB imm8, rdst
input byte – wczytaj bajt z wejś cia Odbiera dos tępny bajt od ws kazaneg o przez imm 8 urządzenia (portu) i zapis uje g o do rejes tru rdst. W zależnoś ci od urządzenia praca proces ora może zos tać ws trzymana aż do pojawienia s ię bajtu danych. Przykład odebrania bajtu z kons oli: VINB 0x20, R0
Kod mas zynowy: F3 00 20
F4
VIRET
interrupt return – powróć z obs ług i przerwania
Przywraca s tan zachowanych na s tos ie rejes trów, w tym rejes tru PC, tym s amym wracając do s tanu i miejs ca wykonania, w którym nas tąpiło przerwanie. FF
VOFF
power off – wyłącz mas zynę Przerywa działanie mas zyny wirtualnej.
Mając gotowy zestaw instrukcji, możemy omówić kolejne elementy systemu, a następnie przejść do implementacji samej maszyny wirtualnej.
3.5. Pamięć operacyjna O pamięci operacyjnej w naszym wypadku można myśleć jako o tablicy bajtów (tj. wartości naturalnych z zakresu 0 do 255 włącznie) o rozmiarze 65536 elementów (indeksowanych od 0 do 65535). Indeksy tej tablicy są jednocześnie adresami w pamięci maszyny wirtualnej, co ułatwi implementację. Zanim przejdziemy do następnego punktu, warto zwrócić uwagę na różnicę między pamięcią operacyjną a pamięcią fizyczną: pamięć operacyjna to pamięć, do której procesor może się bezpośrednio odwołać. W przypadku naszej maszyny mamy tylko jeden rodzaj pamięci – pamięć RAM o wielkości 64 kB (co czyni z niego naszą pamięć operacyjną), niemniej jednak prawdziwe komputery często posiadają kilka rodzajów pamięci, które mogą być bezpośrednio dostępne dla procesora. Idealnym przykładem jest VRAM (Video Random Access Memory, czyli pamięć karty graficznej), której fragment lub całość jest zazwyczaj podmapowana w przestrzeni adresowej procesora. Na przykład w starszych procesorach z rodziny x86 w momencie startu systemu operacyjnego pod adresami 0xB8000– 0xBFFFF (włącznie) procesor „widział” pamięć karty graficznej, a konkretniej tzw. bufor tekstowy (a co za tym idzie, pamięć RAM pod tymi adresami była niedostępna). Za inny przykład może posłużyć pamięć podręczna procesora (cache), a także pamięć karty sieciowej itp.
Należy dodać, że we współczesnych systemach operacyjnych procesy użytkownika nie operują bezpośrednio na pamięci fizycznej – zamiast tego stosowane są jedna lub dwie warstwy translacji adresów (stronicowanie oraz ewentualnie segmenty), dzięki którym możliwa jest separacja przestrzeni adresowej różnych procesów. W rezultacie do pamięci fizycznej dostęp ma jedynie jądro systemu oraz sterowniki. Pamięć innych urządzeń w pamięci fizycznej [VERBOSE] Niektóre współczesne systemy operacyjne umożliwiają łatwe sprawdzenie, które zakresy adresów fizycznych są używane przez jakie urządzenia. W systemach opartych na jądrze Linux często istnieje pseudoplik /proc/iomem, który zawiera listę adresów fizycznych oraz odpowiadające im urządzenia. $ cat /proc/iomem 00000000-0009ebff 0009ec00-0009ffff 000c0000-000c7fff 000cf800-000d07ff
: : : :
System RAM reserved Video ROM Adapter ROM
000e3000-000fffff : reserved 000f0000-000fffff : System ROM 00100000-bddaffff : System RAM 01000000-01303df4 01303df5-0150118f 015a0000-016d7583 bddb0000-bddbe3ff :
: Kernel code : Kernel data : Kernel bss ACPI Tables
bddbe400-bddeffff bddf0000-bddfffff bde00000-bfffffff c0000000-c0000fff [...]
ACPI Non-volatile Storage reserved RAM buffer Intel Flush Page
: : : :
W przypadku systemów z rodziny Windows analogiczny spis można znaleźć w Menedżerze urządzeń (Device manager) – można się do niego dostać na wiele sposobów, np. naciskając Win+Pause i wybierając opcję „Menedżer urządzeń” lub z linii poleceń poleceniem mmc devmgmt.msc. W samym Menedżerze należy wybrać z menu Widok → Zasoby według typów (View → Resource by type).
Potem wystarczy rozwinąć sekcję Pamięć (Memory), by zobaczyć listę (patrz rys. 5).
Rysunek 5. Spis mapowań w pamięci fizycznej (Windows 7)
Wracając do naszej maszyny wirtualnej, w jej przypadku jest znacznie prościej, gdyż RAM będzie jednocześnie pamięcią operacyjną i żaden z jego fragmentów nie będzie podlegał specjalnym zasadom.
3.6. Komunikacja z urządzeniami We współczesnej informatyce przyjęło się stosować trzy podstawowe metody komunikacji z urządzeniami, z czego my zaimplementujemy dwie:
Porty – procesor udostępnia zestaw instrukcji do zapisu oraz odczytu danych (zazwyczaj pojedynczych bajtów lub słów maszynowych) z portu o danym numerze. Każde urządzenie podłączone do procesora ma przypisany jeden lub kilka portów. W przypadku prostych mikrokontrolerów wysłanie bajtu na port jest równoznaczne z ustawieniem odpowiednich stanów (wysokich oraz niskich) na ośmiu konkretnych pinach procesora (jeden bit na pin). Bardziej złożone procesory oferują z reguły proste buforowanie danych, a więc operacje na portach przypominają raczej operacje na parze potoków lub gniazd aniżeli ustawienie stanu pinów. Mapowanie pamięci – o czym już pisałem – fragment pamięci danego urządzenia jest bezpośrednio widoczny dla procesora. Co ciekawe, nie muszą to być prawdziwe komórki pamięci – równie dobrze mogą to być rejestry sterujące urządzenia lub mechanizmy podobne do portów (czyli „absorbujące” dane lub je „generujące”). Przerwania sprzętowe – o ile interakcja w obu powyższych przypadkach jest inicjowana przez procesor na żądanie programisty[33], o tyle w tym przypadku jest na odwrót – urządzenie generuje tzw. przerwanie, co powoduje przerwanie normalnej pracy przez procesor i przejście do procedury obsługi przerwania, dostarczonej z reguły przez programistę systemu operacyjnego. Niejako obok tych opcji istnieje jeszcze jeden mechanizm wymiany danych, uzupełniający powyższe – tzw. DMA (Direct Memory Access, bezpośredni dostęp [urządzenia] do pamięci). DMA jest odpowiedzią na problem wydajności podczas wymiany znacznych ilości danych (rzędu megabajtów) pomiędzy procesorem a urządzeniem. Jeśli procesor miałby wczytywać dane z portu bajt po bajcie[34] (np. w celu przepisania ich do pamięci RAM), to wczytanie 1 GB danych zajęłoby co najmniej miliard cykli (przy utopijnie optymistycznym założeniu, że jeden odczyt z portu i zapis do RAM zajmowałby jeden cykl). Oczywiście znaczyłoby to, że przez ten czas system operacyjny przeznaczałby sporą część mocy obliczeniowej procesora na przepisywanie danych, co zdecydowanie negatywnie wpłynęłoby na czas trwania operacji jak i responsywność reszty systemu. Alternatywą do PIO jest właśnie DMA. W takim scenariuszu system operacyjny, w pewnym uproszczeniu, zleca odczytanie konkretnych danych i zapisanie pod określony adres pamięci RAM, po czym wraca do innych zajęć.
W tym czasie dysk twardy wczytuje dane, które dzięki zastosowaniu DMA są kopiowane bezpośrednio do pamięci RAM. Po zakończeniu (lub w przypadku wystąpienia błędu) kontroler DMA generuje przerwanie, dzięki czemu system operacyjny dostaje informację, że dane są już dostępne. Wracając do głównego wątku, w przypadku naszej maszyny wirtualnej zaimplementujemy jedynie komunikację przy użyciu portów oraz przerwań sprzętowych. Od strony wirtualnego procesora obsługa portów wymaga przynajmniej pary instrukcji do odczytu oraz zapisu danych; w naszym przypadku są to VINB oraz VOUTB (patrz tab. 6), a także „podpięcia” procedur obsługujących porty od strony urządzenia podczas implementacji maszyny wirtualnej. Porty na x86 [BEYOND] Podobnie jak w przypadku mapowanej pamięci urządzeń, systemy operacyjne pozwalają łatwo sprawdzić, jakie zakresy portów są używane przez konkretne urządzenia. W przypadku GNU/Linux informacje te znajdują się w pseudopliku /proc/ioports: > cat /proc/ioports 0000-001f : dma1 0020-0021 : pic1 0040-0043 : timer0 0050-0053 : timer1 0060-0060 0064-0064 0070-0071 0080-008f 00a0-00a1 00c0-00df 00f0-00ff 0250-0253 0256-025f
: : : : : : : : :
keyboard keyboard rtc0 dma page reg pic2 dma2 fpu pnp 00:0b pnp 00:0b
03c0-03df : vga+ 0400-041f : pnp 00:07
W przypadku systemów z rodziny Windows analogiczny spis znajduje się w Menedżerze urządzeń, w widoku Zasoby według typów (View → Resource by type). Tam wystarczy rozwinąć sekcję Wejście/Wyjście (Input/Output), by zobaczyć pełną listę (patrz rys. 4). Opisy funkcji danych portów można znaleźć w dokumentacjach konkretnych urządzeń (choć te niekoniecznie muszą być łatwo dostępne). Historycznie dobrym źródłem informacji na ten temat jest lista „Ralf Brown's Interrupt List”, na której, wbrew temu, co sugeruje nazwa, znajdują się również informacje o portach [6].
Rysunek 4. Wykaz portów mapowanych przez urządzenia (Windows 7)
3.7. Przerwania
Jeśli chodzi o obsługę przerwań, to w momencie wystąpienia przerwania procesor musi wiedzieć, pod jakim adresem znajduje się (w zależności od przyjętej architektury) procedura obsługi wszystkich przerwań lub zestaw procedur obsługujących pojedyncze przerwania. Wymaga to podjęcia kilku decyzji odnośnie do działania procesora: Czy chcemy, by istniała tylko jedna procedura obsługi przerwań, która dostaje w wybranym przez nas rejestrze identyfikator przerwania, czy może preferujemy, by każde przerwanie miało własną, dedykowaną procedurę obsługującą? W przypadku wybrania drugiej opcji, czy chcemy, by lista procedur obsługujących przerwania znajdowała się w pamięci, czy może w dodatkowych, specjalnych rejestrach procesora? Jeśli lista procedur ma znajdować się w pamięci, to czy adres listy powinien być stały, czy raczej chcemy pozwolić programiście na wybranie go i zapisanie w dodatkowym, specjalnym rejestrze procesora? Na przykład w przypadku architektury x86 w trybie rzeczywistym każde przerwanie posiada własną procedurę obsługi. Lista adresów tych procedur (zwana IVT, Interrupt Vector Table) jest zlokalizowana w pamięci operacyjnej pod adresem 0. W trybie chronionym sytuacja jest podobna, z tą różnicą, że programista może wybrać miejsce umieszczenia listy procedur, której struktura jest zresztą nieco bardziej złożona (mowa tu o IDT, Interrupt Descriptor Table) – w tym przypadku adres IDT jest umieszczany w specjalnym rejestrze IDTR za pomocą instrukcji LIDT (Load Interrupt Descriptor Table Register). My zdecydujemy się na trochę odmienne podejście – o ile każde przerwanie nadal będzie miało własną procedurę obsługi, o tyle adresy poszczególnych procedur trafią do specjalnych rejestrów sterujących o numerach od 0x100 do 0x100+liczba przerwań –1. W tabeli 6 zadeklarowaliśmy dwie instrukcje do obsługi rejestrów specjalnych: VCRS oraz VCRL. Warto również umieścić w procesorze flagę, która pozwala na dezaktywację, oraz późniejszą aktywację, obsługi przerwań. W naszym przypadku niech będzie to flaga na pozycji 0 w rejestrze specjalnym 0x110. Jeśli w chwili, gdy obsługa przerwań jest wyłączona, dojdzie do wygenerowania kolejnego przerwania maskowalnego, procedura obsługi tego przerwania powinna zostać aktywowana,
gdy tylko nastąpi ponowna aktywacja obsługi przerwań. Jednocześnie urządzenia nie powinny generować kolejnych przerwań, zanim poprzednie z nich nie zostanie obsłużone. Jeśli chodzi o same przerwania, to interesują nas dwie grupy: Przerwania związane z błędami – te przerwania są niemaskowalne, tzn. nie można dezaktywować ich obsługi. – 0 – przerwanie generowane przez procesor w momencie próby dostępu do nieistniejącego adresu pamięci (jak pamiętamy, dostępne jest jedynie 64 kB RAM, ale sama przestrzeń adresowa ma 32 bity szerokości). – 1 – przerwanie generowane przez procesor w momencie próby wykonania dzielenia przez 0. – 2 – przerwanie generowane przez procesor w momencie wystąpienia ogólnego błędu (np. próby wykorzystania nieistniejącego portu lub rejestru specjalnego). Przerwania związane ze sprzętem – te przerwania są maskowalne, a więc można wyłączyć ich obsługę. – 8 – przerwanie PIT, generowane przez zewnętrzne urządzenie – minutnik. – 9 – przerwanie konsoli znakowej, generowane przez zewnętrzne urządzenie – konsolę. Ostatnia rzecz, o której powinniśmy zdecydować w kontekście przerwań, to kwestia tego, jak dokładnie powinien zachować się procesor w momencie wystąpienia przerwania. Trzeba wziąć pod uwagę, że po zakończeniu obsługi przerwania powinien być możliwy powrót do kodu, który procesor wykonywał przed przerwaniem. Powinniśmy również uwzględnić to, że jedno przerwanie może wystąpić, podczas gdy inne jest już obsługiwane. Biorąc pod uwagę te spostrzeżenia, możemy określić następujące zachowanie procesora w momencie przerwania: 1. Wartości rejestrów R0-R15 oraz rejestru flagowego są zapisywane na stosie. 2. Obsługa przerwań jest wyłączana. 3. Rejestr PC jest ustawiany na adres procedury obsługi wyjątku, która wczytana jest ze specjalnego rejestru sterowania o numerze 0x100+numer przerwania.
3.8. Konsola znakowa Pierwszym z dwóch urządzeń obsługiwanych przez nasz procesor jest najprostsza możliwa konsola znakowa, która ograniczy się w naszym przypadku do operacji odczytu danych ze standardowego wejścia (STDIN) oraz zapisu na standardowe wyjście (STDOUT). Dodatkowo udostępnimy pewne asynchroniczne (tj. nieblokujące procesora na dłuższy czas/działające w tle) funkcje, które pozwolą na odpytanie konsoli o dostępność danych oraz poproszenie konsoli o powiadomienie (wygenerowanie przerwania), gdy dane staną się dostępne. Porty udostępnione przez konsolę znakową zostały opisane w tabeli 7. Tabela 7. Porty konsoli znakowej
Port
Kierunek
0x20
IN
0x20
OUT
0x21
IN
Opis Odczyt pojedynczeg o bajtu z S TDIN; w razie braku danych do pobrania operacja jes t blokująca. Zapis bajtu na S TDOUT. Zwraca jedną z nas tępujących wartoś ci: 1, jeś li s ą dos tępne dane na S TDIN. 0, jeś li na S TDIN nie ma danych, a odczyt z portu 0x20 byłby operacją blokującą.
0x22
IN/OUT
Rejes tr s terujący, pos iadający nas tępujące flag i: bit 0: us tawienie powoduje aktywację g enerowania przerwania 9, g dy pojawią s ię nowe dane dos tępne do odczytu; wyg enerowanie przerwania 9 powoduje wyzerowanie teg o bitu.
3.9. Programowalny timer Drugim i ostatnim urządzeniem jest PIT, czyli w naszym przypadku odpowiednik „minutnika kuchennego” lub klepsydry, z tą różnicą, że będzie on operował na milisekundach. Jego działanie będzie proste. Na początku programista ustawia (poprzez port 0x71), po ilu milisekundach ma zostać wygenerowane przerwanie, po czym aktywuje timer. Po upływie określonej (lub większej, z uwagi na niedokładność naszej implementacji lub czynniki zewnętrzne, jak np. wyłączenie obsługi przerwań) liczby milisekund zostanie wygenerowane przerwanie 8. Aby poprawić precyzję pomiaru, programista może odczytać dokładną liczbę milisekund, która do tej pory minęła (port 0x71) (patrz tab. 8). Tabela 8. Porty timera
Port
Kierunek
0x70
IN/OUT
Opis Rejes tr s terujący, pos iada nas tępujące flag i: bit 0: aktywacja timera; wyg enerowaniu przerwania bit res etowany.
0x71
OUT
po jes t
Us tawienie liczby milis ekund przed wyg enerowaniem przerwania. Wewnętrzny rejes tr przechowujący tę wartoś ć ma 16 bitów. Każde wywołanie powoduje przes unięcie wartoś ci w rejes trze w lewo o 8 bitów oraz dodanie us tawianej wartoś ci do wyniku. nowa_wartość = ((stara_wartość d", "3ff0000000000000".decode("hex")) (1.0,) Uważni czytelnicy zapewne zwrócili uwagę na dopisek „BE”, znajdujący się przy bajtach analizowanej liczby, lub opcję ">" funkcji unpack w przykładowym kodzie – niespodziewanie zakodowane liczby zmiennoprzecinkowe również mogą podlegać podziałowi bajtów zgodnie ze schematami Big oraz Little Endian, omówionymi w poprzednim rozdziale. Na przykład na architekturze x86
używany jest zapis Little Endian, a więc gdybyśmy przedstawiony przykład chcieli powtórzyć, kopiując sekwencję bajtów bezpośrednio do zmiennej typu double, należałoby zapisać ją w odwrotnej kolejności bajtów, co ilustruje przykład w natywnym języku C[72]: #include #include int main(void) { unsigned char number[] = { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF0, 0x3F }; double x; memcpy(&x, number, 8); printf("%f\n", x); return 0; } Kompilacja i uruchomienie prezentują się następująco (środowisko MinGW GCC 4.6.2, architektura x86, system Windows 7): > gcc -Wall -Wextra copy_double.c > a 1.000000 Możemy
również
stworzyć
prosty
skrypt,
który
wypisze
wartości
poszczególnych pól danej liczby rzeczywistej zakodowanej w omawianym typie o podwójnej precyzji (Python 2.7)[73], analogicznie jak przedstawiłem to we wcześniejszym przykładzie: #!/usr/bin/python # -*- coding: utf-8 -*from struct import pack, unpack import sys import math if __name__ == "__main__": for x in sys.argv[1:]: x = float(x)
# Zakoduj wartość do postaci bajtów, a następnie zinterpretuj jako # typ uint64. x_bytes = pack(">d", x) x_uint64, = unpack(">Q", x_bytes) # Przepisz odpowiednie pola do nowych zmiennych. # Dodatkowa uwaga: ((1 >> long(float(x)) == x True Pozostaje jeszcze pytanie, czy
faktycznie wypełniliśmy
wszystkie bity
mantysy; możemy to sprawdzić, korzystając z metody hex typu float: >>> float(x).hex() '0x1.fffffffffffffp+1023' Część ułamkowa przedstawionej liczby składa się z trzynastu cyfr f, gdzie (jak pamiętamy z poprzedniego podrozdziału) każda cyfra f odpowiada czterem zapalonym bitom (1111bin) – otrzymujemy zatem 13 * 4, czyli 52 zapalone bity, a więc mantysa jest wypełniona dokładnie. Wracając do samej wielkości, najczęściej zapisuje się ją w notacji naukowej, tj.: >>> float(x) 1.7976931348623157e+308
Jeśli chodzi o najmniejszą możliwą wartość reprezentowalną przez omawiany typ (nie mylić z wartością najbliższą zeru – do tej przejdziemy za chwilę), to jest ona po prostu liczbą przeciwną do tej przedstawionej powyżej, ponieważ zmienia się jedynie bit znaku. Ostateczny zakres omawianego typu wynosi zatem: [-1,7976931348623157e+308, 1,7976931348623157e+308] Kolejnymi ciekawymi wartościami są wspomniane już wartości najbliższe zeru od strony dodatniej oraz ujemnej. W przypadku Double Precision warto podzielić je na dwie dodatkowe grupy: znormalizowane oraz zdenormalizowane. Zacznijmy od wyliczenia najbliższych zeru wartości znormalizowanych od strony dodatniej. Aby to zrobić, wystarczy stworzyć zmienną o najmniejszym możliwym wykładniku (-1022) ze wszystkimi jawnymi bitami mantysy zgaszonymi oraz znakiem bitu ustawionym na zero: (-1)0 * 1,0000000000000000000000000000000000000000000000000000bin * 2-1022 = 2-1022 Do obliczenia dokładnej[76] wartości tego wyrażenia można skorzystać np. z konwersji typu float na typ tekstowy w Pythonie:
>>> "%.1100f" % (2**(-1022)) '0.000000000000000000000000000000000000000000000000000000000000000 Ponownie korzystając z metody hex, możemy potwierdzić, że faktycznie wszystkie bity mantysy, oprócz bitu całkowitego, są wygaszone, a wykładnik przyjął wartość minimalną: >>> x = 2**-1022 >>> x.hex() '0x1.0000000000000p-1022' Ostatecznie wyliczona wartość w notacji naukowej wygląda następująco: >>> x 2.2250738585072014e-308 Biorąc pod uwagę ten wynik oraz ponownie korzystając z faktu, iż wartość najbliższa zeru od strony liczb ujemnych jest liczbą przeciwną do powyższej, otrzymujemy dwie wartości, które są najbliższymi zeru znormalizowanymi wartościami typu Double Precision:
-2,2250738585072014e-308 oraz 2,2250738585072014e-308 Jeśli chodzi o najbliższe zeru wartości zdenormalizowane, to, jak pamiętamy z podrozdziału o wartościach specjalnych i liczbach zdenormalizowanych, przyjmuje się, że niejawny bit całkowity jest ustawiony na zero oraz że wykładnik jest równy minimalnemu (-1022). Dodatkowym warunkiem jest, by mantysa nie była w pełni wygaszona – w przeciwnym razie otrzymalibyśmy specjalną wartość zero lub zero ujemne. Z tego względu najbliższą zeru dodatnią wartość zdenormalizowaną otrzymamy, zapalając jedynie najmniej znaczący bit mantysy: (-1)0 * 0,0000000000000000000000000000000000000000000000000001bin * 2-1022 Biorąc pod uwagę to, iż mantysa w tym wypadku jest również potęgą dwójki, możemy uprościć powyższe do następującego zapisu: 2-52 * 2-1022 = 2-1074 Wykonując otrzymujemy:
kolejne
kroki
analogicznie
do
poprzedniego
przypadku,
>>> x = 2**-1074
>>> "%.1100f" % x '0.000000000000000000000000000000000000000000000000000000000000000 >>> x.hex() '0x0.0000000000001p-1022' >>> x 5e-324 W przedstawionym przypadku Python 2.7 zaokrąglił liczbę w notacji naukowej do 5e-324, ale widząc dokładny wynik kilka linii wyżej, wiemy, że bliższą aproksymacją w notacji naukowej będzie np. 4,9406564584124654417e-324. Biorąc pod uwagę tę wartość oraz fakt, że najbliższa zeru zdenormalizowana wartość ujemna jest przeciwną wartością do wyliczonej, otrzymujemy parę najbliższych zeru wartości zdenormalizowanych: -4,9406564584124654417e-324 oraz 4,9406564584124654417e-324 Wszystkie wartości pomiędzy najbliższą zeru dodatnią wartością zdenormalizowaną a najbliższą zeru dodatnią wartością znormalizowaną stanowią przedział (lewostronnie domknięty) liczb zdenormalizowanych dodatnich:
[4,9406564584124654417e-324, 2,2250738585072014e-308) Analogicznie jest w przypadku zdenormalizowanych liczb ujemnych: (-2,2250738585072014e-308, -4,9406564584124654417e-324] W literaturze często podaje się jeszcze jedną wartość: liczbę dokładnych cyfr dziesiętnych. Można ją obliczyć, biorąc pod uwagę zaokrąglony w dół logarytm o podstawie 10 z 2wielkość mantysy : log10253
=
53 * log102
=
15,9545
= 15
Opierając się na powyższych wyliczeniach, możemy powiedzieć, że wynik danej (pojedynczej) operacji na zmiennych typu Double Precision będzie dokładny do 15 najbardziej znaczących cyfr dziesiętnych. Należy jednak pamiętać, że dotyczy to jedynie pojedynczej operacji na dwóch zmiennych tego samego typu. Jeżeli w grę wchodzi jeszcze konwersja np. między zapisem dziesiętnym a binarnym (tj. konwersja decymalnej liczby zapisanej tekstowo na typ Double Precision), końcowy wynik może być inny niż się spodziewamy, właśnie z uwagi na błąd wprowadzony podczas konwersji. Podsumowując niniejszą sekcję, tabela 3 zawiera wymienione wartości dla typów Single, Double oraz Extended Precision. Tabela 3. Istotne wartości popularnych binarnych typów zmiennoprzecinkowych zgodnych ze standardem IEEE 754
Single Precision
Double Precision
Extended Precision
Wielkoś ć zmiennej
32 bity
64 bity
80 bitów
Wielkoś ć pola znaku
1
1
1
Wielkoś ć wykładnika
8
11
15
„ Obciążenie” wykładnika (s tała K)
127
1023
16383
Zakres wykładnika
[-126, 127]
[-1022, 1023]
[-16382, 16383]
Wielkoś ć mantys y (w tym liczba niejawnych bitów)
24 (1)
53 (1)
64 (0)
Liczba dokładnie reprezentowanych cyfr dzies iętnych
7
15
19
Maks ymalna wartoś ć
3,402e+38
1,797e+308
1,189e+4932
Minimalna wartoś ć
-3,402e+38
-1,797e+308
-1,189e+4932
Najbliżs ze zeru wartoś ci znormalizowane
-1,175e-38 1,175e-38
-2,225e-308 2,225e-308
-3,362e-4932 3,362e-4932
Najbliżs ze zeru wartoś ci zdenormalizowane
-1,401e-45 1,401e-45
-4,940e-324 4,940e-324
-3,645e-4951 3,645e-4951
5.7. Porównanie liczb zmiennoprzecinkowych Powtarzającym się motywem w niniejszym rozdziale jest niedokładność liczb zmiennoprzecinkowych dla pewnych wartości. Wspomniałem m.in. o błędzie wynikającym z konwersji ułamka dziesiętnego na binarny, a także o błędzie wynikającym z przekroczenia precyzji. Z tego względu należy mieć na uwadze, że jeśli operuje się na liczbach zmiennoprzecinkowych, końcowy wynik może być niedokładny. Na szczęście w przypadku wielu zastosowań nie stanowi to dużego problemu. Programistów uczula się przede wszystkim na kwestię operacji porównywania dwóch liczb zmiennoprzecinkowych, zazwyczaj jako antyprzykład podając poniższą pętlę nieskończoną (Python 2.7): #!/usr/bin/python x = 0.0 while x != 1.0: print x, x += 0.1
Na pierwszy rzut oka mogłoby się wydawać, że pętla zakończy działanie po 10 iteracjach, jednak, jak pamiętamy z poprzednich sekcji, liczba dziesiętna 0.1 nie jest dokładnie reprezentowana przez Pythonowy typ float. Co za tym idzie wraz z każdą operacją dodania błąd będzie wzrastać, co ostatecznie prowadzi do pętli nieskończonej: > infloop.py 0.0 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1.0 1.1 1.2 1.3 1.4 1.5 1.6 1.7 1.8 1.9 2.0 2.1 2.2 2.3 2.4 2.5 2.6 [...] Dodatkowo bardzo złudne są same liczby wypisywane na ekran, korzystając z domyślnego formatowania – z uwagi na zaokrąglenia zastosowane przy konwersji (na reprezentację tekstową) mogłoby się wydawać, że wynik jest nadal dokładny. Wrażenie to znika, jeśli wypiszemy więcej cyfr danej zmiennej: #!/usr/bin/python x = 0.0 while x != 1.0: print x, "%.50f" % x, x.hex() x += 0.1 > infloop2.py 0.0 0.00000000000000000000000000000000000000000000000000 0x0.0p+0 0.1 0.10000000000000000555111512312578270211815834045410 0x1.999999999999ap-4 0.2 0.20000000000000001110223024625156540423631668090820 0x1.999999999999ap-3 0.3 0.30000000000000004440892098500626161694526672363281 0x1.3333333333334p-2 0.4 0.40000000000000002220446049250313080847263336181641 0x1.999999999999ap-2 0.5 0.50000000000000000000000000000000000000000000000000 0x1.0000000000000p-1 0.6 0.59999999999999997779553950749686919152736663818359 0x1.3333333333333p-1
0.7 0.69999999999999995559107901499373838305473327636719 0x1.6666666666666p-1 0.8 0.79999999999999993338661852249060757458209991455078 0x1.9999999999999p-1 0.9 0.89999999999999991118215802998747676610946655273438 0x1.cccccccccccccp-1 1.0 0.99999999999999988897769753748434595763683319091797 0x1.fffffffffffffp-1 1.1 1.09999999999999986677323704498121514916419982910156 0x1.1999999999999p+0 1.2 1.19999999999999995559107901499373838305473327636719 0x1.3333333333333p+0 1.3 1.30000000000000004440892098500626161694526672363281 0x1.4cccccccccccdp+0 „Przypadkowe” równości [VERBOSE] Co ciekawe, z uwagi na stosowane zaokrąglenia oraz na fakt, że rzeczywiste obliczenia na architekturze x86 są wykonywane z większą precyzją (domyślnie 80-bitową), niektóre wartości uzyskane przez sumowanie liczby 0,1 są równe spodziewanej wartości dziesiętnej przekodowanej na typ zmiennoprzecinkowy (pomimo błędu wynikającego z konwersji). Można się o tym przekonać, korzystając np. z poniższego przykładu: #!/usr/bin/python k = [ 0.0, 0.1, 0.2, 0.3, 0.4 ,0.5, 0.6, 0.7, 0.8, 0.9, 1.0 ] x = 0.0 dx = 0.1 for ki in k: print ki, x, x += dx 0.0 0.1 0.2 0.3 0.4 0.5
0.0 0.1 0.2 0.3 0.4 0.5
True True True False True True
x == ki, "\t %.20f %.20f" % (ki, x) 0.00000000000000000000 0.00000000000000000000 0.10000000000000000555 0.10000000000000000555 0.20000000000000001110 0.20000000000000001110 0.29999999999999998890 0.30000000000000004441 0.40000000000000002220 0.40000000000000002220 0.50000000000000000000 0.50000000000000000000
0.6 0.6 True
0.59999999999999997780 0.59999999999999997780
0.7 0.7 True 0.8 0.8 False
0.69999999999999995559 0.69999999999999995559 0.80000000000000004441 0.79999999999999993339
0.9 0.9 False 1.0 1.0 False
0.90000000000000002220 0.89999999999999991118 1.00000000000000000000 0.99999999999999988898
Z tego względu zaleca się stosować alternatywne rozwiązania i zrezygnować z korzystania z operacji równości w przypadku liczb zmiennoprzecinkowych. W widocznym powyżej przypadku można by zastosować: Porównania z wybraną dokładnością (stała oznaczająca dokładność jest najczęściej nazywana epsilonem i w zapisie matematycznym reprezentowana znakiem ε). Porównania przy wykorzystaniu znaku mniejszości, tj. x < 1,0. Pierwszy z wariantów realizuje się przez sprawdzenie, czy odległość pomiędzy dwoma porównywanymi wartościami (obliczana jako bezwzględna[77] wartość z różnicy między obydwiema wartościami) jest mniejsza (ewentualnie mniejsza lub równa) niż wybrane ε. W omawianym przykładzie moglibyśmy zapisać to w następujący sposób: #!/usr/bin/python x = 0.0 epsilon = 0.00000001 while not (abs(x - 1.0) < epsilon): print x, x += 0.1 Dobór ε zależy od faktycznego przedziału, na którym operujemy, oraz od specyfiki samych operacji. W omawianym przypadku wybrałem wartość 0,00000001 z uwagi na to, że błąd wynikający z sumowania na pewno nie będzie większy niż ta wartość. Jak bowiem wspomniałem wcześniej, Double Precision wykonuje operacje precyzyjnie do 15 znaków dziesiętnych, a błąd konwersji objawia się głównie na najmniej znaczących bitach mantysy. Co więcej, de facto operuje na jednej cyfrze po przecinku w ułamku dziesiętnym, więc jakikolwiek epsilon bliższy zeru niż 0,1 byłby odpowiedni[78].
Ponieważ dobór ε zależy od przedziału, warto czasem wyznaczać go automatycznie, bazując na liczbie, z którą porównujemy, np. wyliczając błąd względny [13][14][24], korzystając ze wzoru ((a - b) / a) i porównując go z wybraną wartością epsilon. W tym przypadku należy wziąć pod uwagę to, że a może być równe zero i odpowiednio obsłużyć ten wariant, co jest zilustrowane na następującym przykładzie: #!/usr/bin/python def fcmp(a, b): if a == b: return 0.0 if abs(a) > abs(b): return abs((a - b) / a) return abs((a - b) / b) x = 0.0 while not (fcmp(x, 1.0) < 0.0001): x += 0.1 print x, fcmp(x, 1.0), fcmp(1.0, x) Od danego przypadku zależy, którą metodę lepiej zastosować, natomiast w praktyce z operacji równości dwóch zmiennych zmiennoprzecinkowych korzysta się bardzo rzadko.
5.8. Dziesiętne typy zmiennoprzecinkowe Oprócz omówionych do tej pory i najczęściej używanych binarnych typów pseudorzeczywistych standard IEEE 754-2008 zdefiniował również decymalne liczby zmiennoprzecinkowe, w których, jak sama nazwa wskazuje, operuje się na cyfrach dziesiętnych, a nie binarnych. Należy jednak zaznaczyć, że wszystkie dostępne obecnie implementacje tych typów są emulacjami programowymi, tj. współczesne procesory nie posiadają ich natywnego wsparcia, przez co są one odpowiednio wolniejsze od opisanych wcześniej typów binarnych.
Standard definiuje następujące dziesiętne typy zmiennoprzecinkowe: decimal32 (32 bity, 7 cyfr dziesiętnych); decimal64 (64 bity, 16 cyfr dziesiętnych); decimal128 (128 bitów, 34 cyfry dziesiętne). Oraz jeden typ szablonowy: decimal{k} (k bitów, 9 * k/32 - 2 cyfr dziesiętnych). Dodatkowo różne języki oraz biblioteki posiadają własne implementacje (niekoniecznie oparte na IEEE 754) liczb decymalnych o skończonej bądź nieograniczonej precyzji. Tabela 4 zawiera spis przykładowych typów w kilku językach. Tabela 4. Przykładowe typy decymalne w wybranych językach programowania
Język
T yp
Komentarz
C++
std::decimal::decimal32 std::decimal::decimal64 std::decimal::decimal128
Implementacja typów decymalnych o tych s amych nazwach wg s tandardu IEEE 754-2008. Formalnie typy te s tanowią częś ć propozycji rozs zerzeń języka: N2489 [15] oraz N3407 [16]. Ich obs ług a zos tała dodana m.in. w GCC.
C
_Decimal32 _Decimal64 _Decimal128
Jak wyżej – implementacja typów decymalnych wg s tandardu IEEE 754-2008. Typy te s tanowią częś ć propozycji rozs zerzenia języka – N1312 [18] – zaimplementowaneg o m.in. w GCC [17].
Java
java.math.BigDecimal
Typ o dowolnej precyzji, niezg odny z IEEE 754. Liczby mają pos tać liczba całkowita * 10-wykładnik .
Python
decimal.Decimal
Typ o okreś lonej, ale dowolnej precyzji, niezg odny z IEEE 754.
Dla przykładu wykonajmy wariację na temat zaprezentowanego wcześniej kodu testującego równość skonwertowanej wartości ułamka dziesiętnego z wyliczoną wartością, która powinna być mu równa. W tym celu skorzystamy z języka C++ oraz rozszerzenia GCC dodającego zmiennoprzecinkowe typy decymalne. Aby ich użyć, należy dodać atrybut mode(DD) do zmiennej w momencie jej deklaracji (DD oznacza Double Precision Decimal): // g++ -std=c++11 decimal64_eq.cc -Wall -Wextra // Tested on g++ (Ubuntu 4.8.2-19ubuntu1) 4.8.2 #include #include typedef float _Decimal64 __attribute__((mode(DD))); // Alternatively class std::decimal::decimal64 from could be // used here. int main() { std::vector k{ 0.0dd, 0.1dd, 0.2dd, 0.3dd, 0.4dd, 0.5dd, 0.6dd, 0.7dd, 0.8dd, 0.9dd, 1.0dd }; _Decimal64 x = 0.0dd; _Decimal64 dx = 0.1dd; for (const auto& ki : k) { // Specifier %Df should work with _Decimal64 type, but not all printf // implementations support it. // printf("%Df %Df %s \t%.20Df %.20Df\n",
//
ki, x, x == ki ? "True" : "False", ki, x);
printf("%f %f %s\n", (float)ki, (float)x, x == ki ? "True" : "False"); x += dx; } return 0; } Wynik: 0.000000 0.000000 True 0.100000 0.200000 0.300000 0.400000
0.100000 0.200000 0.300000 0.400000
True True True True
0.500000 0.600000 0.700000 0.800000
0.500000 0.600000 0.700000 0.800000
True True True True
0.900000 0.900000 True 1.000000 1.000000 True Drugim przykładem niech będzie skorzystanie z dowolnej precyzji typu Decimal w języku Python i wyliczenie dokładnej wartości 2-1074 w celu zweryfikowania, czy wyświetlona wcześniej wartość jest poprawna: >>> from decimal import *
>>> getcontext().prec = 1100 >>> x = Decimal(2)**-1022 >>> print x 2.2250738585072013830902327173324040642192159804623318305533274168 044348139181958542831590125110205640673397310358110051524341615534 088560123853777188211307779935320023304796101474425836360719215650 425037342083752508066506166581589487204911799685916396485006359087 183048747997808877537499494515804516050509153998565824708186451135 358049921159810857660519924333521143523901487956996095912888916029
415110634663133936634775865130293717620473256317814856643508721228
376420448468114076139114770628016898532441100241614474216185671661 401542850847167529019031613227788967297073731233340869889831750678
469260927739779728586596549410913690954061364675687023986783152906 84617210924625396728515625E-308 Porównując cyfry wyniku, można się przekonać, że użyty wcześniej format "%.1100f" % (2**(-1022)) faktycznie wypisał poprawny wynik. Zmienne dziesiętne są przydatne wszędzie tam, gdzie bardziej istotny jest precyzyjny (w danym zakresie) wynik niż szybkość obliczeń (np. w operacjach finansowych).
5.9. Typy stałoprzecinkowe Mniej popularne na współczesnych komputerach, ale nie mniej ciekawe i nadal stosowane w przypadku mikrokontrolerów, są tzw. typy stałoprzecinkowe (fixed point), w których zakłada się pewną stałą liczbę bitów (lub cyfr w przypadku typów decymalnych) całkowitych oraz ułamkowych, a co za tym idzie – stały wykładnik. Niewątpliwą zaletą tego rodzaju typów jest prostota ich programowej implementacji, która w zdecydowanej części bezpośrednio stosuje istniejące operacje arytmetyczne na typach całkowitych, okraszone – w niektórych miejscach – przesunięciami bitowymi (patrz np. [19][20]). W ramach przykładu zaprezentuję programową implementację (w języku C) kilku bardzo podstawowych operacji na wymyślonym stałoprzecinkowym typie myfixed_t, opartym na uint16_t (rys. 3). Całkowita wielkość zmiennej tego typu będzie wynosiła 16 bitów, przy czym przyjmiemy założenie, że 8 górnych bitów będzie stanowiło część całkowitą, a 8 dolnych część ułamkową (w rozumieniu normalnego ułamka binarnego, z którego korzystamy w całym niniejszym rozdziale). Dla uproszczenia przyjmę również założenie, że typ ten będzie reprezentował wartości nieujemne, a więc bit znaku nie będzie obecny.
Rysunek 3. Liczba 4.75 zakodowana w przykładowym typie myfixed_t Jeśli chodzi o wspierane operacje, to skupię się na czterech arytmetycznych (dodawanie, odejmowanie, mnożenie oraz dzielenie) oraz konwersji w obie strony pomiędzy typem myfixed_t oraz float, której użyję głównie do zainicjowania zmiennych typu myfixed_t oraz wypisania ich wartości. Czytając poniższy opis implementacji operacji, należy cały czas pamiętać, że bazowym typem jest uint16_t. Konwersja myfixed_t→float: Załóżmy na chwilę, że część ułamkowa nas nie interesuje (np. jest wyzerowana), i pomyślmy, jakie operacje w tym wypadku powinniśmy wykonać, aby otrzymać zmienną typu float zawierającą część całkowitą. W takiej sytuacji wystarczy oczywiście przed konwersją zmiennej wykonać przesunięcie bitowe o 8 bitów w prawo, dzięki czemu część całkowita trafi na dolne bity bazowego typu uint16_t, co pozwoli na skorzystanie z istniejących w języku C konwersji uint16_t na typ float. Jak pisałem wcześniej, przesunięcie o N bitów w prawo jest równoważne matematycznej operacji podzielenia liczby przez 2N, a więc zamiast przesuwać możemy równie dobrze podzielić przez 256. W ten sposób dotarliśmy do rozwiązania, które doprowadzi również do konwersji części ułamkowej – wystarczy zacząć od skonwertowania bazowej zmiennej typu uint16_t na typ float, a następnie podzielić wynik przez 256 (czyli de facto zmniejszyć wykładnik o 8). Konwersja float→myfixed_t: Odwrotna konwersja wymaga jedynie odwrócenia opisanej wcześniej operacji, tj. zamiast dzielenia przez 256, wystarczy pomnożyć przez 256, a następnie dokonać konwersji float na bazowy typ uint16_t. Dodawanie: Na poziomie bitów dodanie dwóch liczb zapisanych w myfixed_t jest tożsame z dodaniem dwóch liczb zapisanych w bazowym typie uint16_t (tj. możemy
użyć zwykłej operacji dodawania i wynik będzie prawidłowy). Odejmowanie: Podobnie jak dodawnie. Mnożenie: Podobnie jak dodawanie, przy czym wynikiem mnożenia dwóch liczb 16bitowych jest liczba 32-bitowa, w której górne 16 bitów przypada na część całkowitą, a dolne 16 na część ułamkową (wynika to ze specyfiki samej operacji mnożenia). W takim wypadku wystarczy zadbać o to, by wynik trafił do zmiennej 32-bitowej, a następnie przesunąć go o 8 bitów w prawo i obciąć do 16 bitów, co ponownie da nam prawidłową liczbę typu myfixed_t. Dzielenie: Sytuacja odwrotna do mnożenia. Najprościej jest wziąć dzielną, zrzutować ją na zmienną 32-bitową i przesunąć o 8 bitów w lewo, a następnie wykonać operację dzielenia. Otrzymany wynik będzie prawidłową liczbą typu myfixed_t. Porównania (): Podobnie jak w przypadku dodawania i odejmowania, możemy polegać na operacjach na bazowym typie uint16_t, gdyż wynik porównania będzie identyczny bez względu na to, czy bity zostaną zinterpretowane jako uint16_t czy myfixed_t. Przykładowa implementacja wraz z krótkim programem testującym została przedstawiona poniżej: #include #include // Unsigned fixed point type with 8 bit integer and 8 bit fraction. typedef uint16_t myfixed_t;
inline myfixed_t float_to_myfixed(float f) { return (uint16_t)(f * 256.0f); } inline float myfixed_to_float(myfixed_t m) { return (float)m / 256.0f; } inline myfixed_t myfixed_add(myfixed_t a, myfixed_t b) { return a + b; } inline myfixed_t myfixed_sub(myfixed_t a, myfixed_t b) { return a - b; } inline myfixed_t myfixed_mul(myfixed_t a, myfixed_t b) { return ((uint32_t)a * (uint32_t)b) >> 8; } inline myfixed_t myfixed_div(myfixed_t a, myfixed_t b) { return ((uint32_t)a gcc -Wall -Wextra simple_fixed.c > a initial=12.00 result=5.39 result=2.89 result=6.43 result=7.43 Porównując otrzymane wyniki z tymi dokładnymi, umieszczonymi w komentarzach, od razu można dostrzec pewien błąd, wynikający z ograniczonej precyzji oraz zaokrąglania wyniku (w kierunku ujemnej nieskończoności), niemniej jednak same operacje działają prawidłowo i dają poprawny (z dokładnością do niewielkiego błędu) wynik. Obecnie bardzo niewiele języków programowania oferuje wbudowane typy stałoprzecinkowe – wyjątek stanowi np. Ada, dysponująca zarówno binarnymi, jak i decymalnymi wariantami zmiennych stałoprzecinkowych. Dodatkowo kompilatory z rodziny GCC oferują typy stałoprzecinkowe [21] (oznaczone dopiskiem _Fract lub _Accum) zgodne z jednym z zaproponowanych rozszerzeń języka C [22], ale są one dostępne jedynie na architekturach z rodziny MIPS, ARM oraz AVR.
Ćwiczenia [FPU:bin-fract] Używając dowolnej metody, stwórz tablicę ujemnych potęg dwójki od 2-1 do 210. Następnie korzystając z tablicy, skonwertuj poniższe ułamki binarne na odpowiadające im liczby dziesiętne: 0,10000000 0,01000000 0,11000000 0,10110110 [FPU:dec-fract] Korzystając z metody opisanej w podrozdziale „Ułamki binarne”, skonwertuj poniższe liczby decymalne na odpowiadające im ułamki binarne (z dokładnością do 8 bitów po przecinku): 0,125 0,3 2,17 12,835 [FPU:real-deal] Korzystając z dokumentacji typu Single Precision, zakoduj w nim dziesiętną liczbę 137,9972 (tj. wylicz bit znaku, mantysę oraz wykładnik i zapisz je w postaci bitowej), a następnie sprowadź do postaci bajtów przy użyciu notacji Little Endian. Następnie napisz program, który skonwertuje/zinterpretuje wyliczone bajty jako Single Precision i wypisze uzyskaną wartość, korzystając z dowolnej metody (np. Python i struct.unpack lub C i memcpy) [FPU:inf-nan-0] Wymień jak najwięcej różnych operacji, które w wyniku dadzą Inf, NaN lub ujemne zero. [FPU:fixed-overflow] W kontekście przykładowego typu myfixed_t, omówionego w podrozdziale „Typy stałoprzecinkowe”, odpowiedz na następujące pytania:
Jaka jest największa i najmniejsza liczba, którą można wyrazić w myfixed_t? Jaką wartość ma niezerowa liczba najbliższa zeru? Co się stanie, gdy dojdzie do przepełnienia zmiennej tego typu? [FPU:fixed-16-16] W dowolnym języku programowania zaimplementuj stałoprzecinkowy typ myfixed1616_t (16 bitów całkowitych, 16 ułamkowych). Implementacja powinna obejmować dodawanie, odejmowanie, mnożenie, dzielenie, podstawowe operacje porównania oraz konwersje z/na typ Double Precision. Dodatkowo postaraj się, aby typ myfixed1616_t był typem ze znakiem. [FPU:fixed-sin] Używając
dowolnego
typu
stałoprzecinkowego
(np.
myfixed1616_t
z poprzedniego ćwiczenia), zaimplementuj wyliczenie funkcji sinus, korzystając ze wzoru Taylora [23] dla pierwszych czterech elementów, tj.:
Przetestuj napisany kod dla różnych wartości x (podawanej w radianach). Jaki jest relatywny błąd (patrz podrozdział zmiennoprzecinkowych”) dla przykładowych wartości?
„Porównanie
liczb
Bibliografia [1] uM-FPU64 Floating Point Coprocessor, Micromega Corporation, http://micromegacorp.com/products.html [2] IEEE Standard for Binary Floating-Point Arithmetic (IEEE Std 754-1985), 1985, Institute of Electrical and Electronics Engineers.
http://coldwind.pl/s/c2r5
[3] IEEE Standard for Binary Floating-Point Arithmetic (IEEE Std 754-2008), 2008, Institute of Electrical and Electronics Engineers. [4] Dr Vickery Ch., IEEE-754 References, http://babbage.cs.qc.edu/courses/cs341/IEEE754references.html [5] Quadruple-precision floating-point format – Hardware support, Wikipedia, https://en.wikipedia.org/wiki/Quadruple-precision_floatingpoint_format#Hardware_support [6] IEEE floating point, Wikipedia, https://en.wikipedia.org/wiki/IEEE_floating_point [7] EXT_packed_float, NVIDIA Corporation, https://www.opengl.org/registry/specs/EXT/packed_float.txt Small Float Formats, OpenGL https://www.opengl.org/wiki/Small_Float_Formats#Half_floats [9] _control87, _controlfp, __control87_2, Microsoft Developer [8]
Wiki, Network,
https://msdn.microsoft.com/en-gb/library/e9b52ceh.aspx [10] Barreto R., Controlling FPU rounding modes with Python, 2009, https://rafaelbarreto.wordpress.com/2009/03/30/controlling-fpu-rounding-modeswith-python/ [11] Intel, Microprocessor and Peripheral Handbook, 1989. [12] Extended precision, https://en.wikipedia.org/wiki/Extended_precision [13] Błąd względny, https://pl.wikipedia.org/wiki/B%C5%82%C4%85d_wzgl%C4%99dny [14] Dawson B., Comparing floating point numbers, Cygnus http://www.cygnus-
Wikipedia, Wikipedia, Software,
software.com/papers/comparingfloats/Comparing%20floating%20point%20numbe [15] Extension for the programming language C++ to support decimal floatingpoint arithmetic (ISO/IEC JTC1 SC22 WG21 N2849), ISO, 2009, http://www.openstd.org/jtc1/sc22/wg21/docs/papers/2009/n2849.pdf [16] Proposal to Add Decimal Floating Point Support to C++ (ISO/IEC JTC1 SC22 WG21 N3407), ISO, 2012, http://www.openstd.org/jtc1/sc22/wg21/docs/papers/2012/n3407.html [17] GCC Manual – Decimal Floating Types, https://gcc.gnu.org/onlinedocs/gcc/Decimal-Float.html [18] Extension for the programming language C to support decimal floating-point arithmetic (ISO/IEC JTC1 SC22 WG14 N1312), ISO, 2008, http://www.open-
std.org/JTC1/SC22/WG14/www/docs/n1312.pdf [19] Night Stalker, How to use Fixed Point (16.16) Math (Part 1 of 2), 1995, http://mirror.bagelwood.com/textfiles/programming/fix1faq.txt [20] Night Stalker, How to use Fixed Point (16.16) Math (Part 2 of 2), 1995, http://mirror.bagelwood.com/textfiles/programming/fix2faq.txt [21] GCC Manual – Fixed-Point Types, https://gcc.gnu.org/onlinedocs/gcc4.9.0/gcc/Fixed-Point.html#Fixed-Point [22] Programming languages – C – Extensions to support embedded processors (ISO/IEC JTC1 SC22 WG14 N1169), ISO, 2006, http://www.open-
std.org/jtc1/sc22/wg14/www/docs/n1169.pdf [23] Wzór Taylora, Wikipedia, https://pl.wikipedia.org/wiki/Wz%C3%B3r_Taylora#Funkcje_trygonometryczne_i_c [24] Dawson B., Comparing Floating Point Numbers, 2012 Edition, 2012, https://randomascii.wordpress.com/2012/02/25/comparing-floating-pointnumbers-2012-edition/ [25] Technical information about Locomotive BASIC – Floating Point data definition, 2013, http://cpctech.cpc-live.com/docs/bastech.html
Rozdział 6.
Znaki i łańcuchy znaków W dwóch poprzednich rozdziałach omówiłem typy liczbowe oraz stosowane metody ich kodowania. Niejako oddzielną grupą są typy tekstowe – w tym pojedyncze znaki oraz całe ich ciągi, potocznie zwane „stringami”. Właśnie im poświęcony jest niniejszy rozdział, w którym wyjaśniona zostanie problematyka związana z kodowaniem, reprezentacją, a także zarządzaniem ciągiem znaków. Zaczynając od pojedynczego znaku, z niskopoziomowego punktu widzenia jest on liczbą, a konkretniej indeksem, którego ostatecznym celem jest wskazanie określonego glifu (graficznej reprezentacji znaku) w czcionce użytej do jego narysowania (rys. 1).
Rysunek 1. Uproszczony schemat prezentujący powiązanie pomiędzy kodem znaku a czcionką
Aby dana liczba reprezentowała ten sam symbol niezależnie od wybranej czcionki, wprowadzono dla nich ujednolicone kody – w przeszłości najbardziej popularnym z nich było rozszerzone ASCII, obecnie zaś standardem jest Unicode. Traktują o nich dwa pierwsze podrozdziały.
6.1. ASCII i strony kodowe ASCII (American Standard Code for Information Interchange) jest 7-bitową tablicą kodową, która mapuje liczby z zakresu 0-127 na podstawowe znaki używane w języku angielskim, a także zawiera dodatkowe „znaki kontrolne”, które nie są reprezentowane przez glify w czcionce, ale używane do oznaczenia przejścia do nowej linii, tabulacji i innych specjalnych funkcji związanych z formatowaniem lub przesyłaniem tekstu. Podstawowy zestaw ASCII jest rozszerzany (w zakresie 128-255) o wybraną stronę kodową udostępniającą dodatkowe znaki – zazwyczaj są to znaki diakrytyczne (np. „ą”, „ć” itd.) stosowane w językach w danym regionie oraz znaki imitujące elementy graficzne (np. ramki). Domyślna strona kodowa zależy od ustawień systemu, niemniej jednak można ją programowo zmienić w kontekście danego terminala. ASCII wraz z wybraną stroną kodową tworzą 8-bitowe rozszerzone ASCII (extended ASCII), którego elementy z reguły koduje się 8-bitowymi liczbami naturalnymi. Poniżej znajduje się kilka prostych programów wypisujących tzw. drukowalne (tj. niespecjalne) znaki ASCII w oknie konsoli. Pierwszy z nich (Python 2.7) realizuje to w najprostszy możliwy sposób: #!/usr/bin/python for ch in xrange(32, 127): print chr(ch), > ascii.py ! " # $ % & ' ( ) * + , - . / 0 1 2 3 4 5 6 7 8 9 : ; < = > ? @ A B C D E F G H I J K L M N O P Q R S T U V W X Y Z [ \ ] ^ _ ` a b c d e f g h i j k l m n o p q r s t u v w x y z { | } ~ W drugim przykładzie (również Python 2.7) starałem się sformatować wyjście w bardziej przyjazny sposób oraz dodać wypisanie trzech najbardziej istotnych
znaków kontrolnych[79] (tabulacji, znaku powrotu karetki oraz znaku przejścia do nowej linii): #!/usr/bin/python print " |", for i in xrange(0, 0x10): print "+%x" % i, print "\n----+-" + ("-" * 0x10 * 3) for j in xrange(0, 0x80, 0x10): print " %.2x |" % j, for i in xrange(0, 0x10): ch = j + i if ch >= 0x20 and ch < 0x7f: print " %c" % ch, elif ch == ord("\n"): print "\\n", elif ch == ord("\t"): print "\\t", elif ch == ord("\r"): print "\\r", else: print "
",
print Uruchomienie i wynik prezentują się następująco: > ascii_pretty.py | +0 +1 +2 +3 +4 +5 +6 +7 +8 +9 +a +b +c +d +e +f ----+------------------------------------------------00 | \t \n \r 10 20 30 40
| | | |
0 @
! 1 A
" 2 B
# 3 C
$ 4 D
% 5 E
& 6 F
' 7 G
( 8 H
) 9 I
* : J
+ ; K
, < L
= M
. > N
/ ? O
50 |
P
Q
R
S
T
U
V
W
X
Y
Z
[
\
]
^
_
60 | 70 |
` p
a q
b r
c s
d t
e u
f v
g w
h x
i y
j z
k {
l |
m }
n ~
o
Trzeci z przykładowych kodów, tym razem w języku C, jest przystosowany do systemu Windows i wypisuje rozszerzone ASCII łącznie z wybraną dodatkową stroną kodową: // gcc extascii.c -o extascii.exe #include #include void print_pretty_ext_ascii(void) { int i, j, ch; printf("
| ");
for(i = 0; i < 0x10; i++) { printf("+%x ", i); } puts("\n----+------------------------------------------------");
for (j = 0; j < 0x100; j += 0x10) { printf(" %.2x | ", j); for (i = 0; i < 0x10; i++) { ch = j + i; if (ch >= 0x20 && ch != 0x7f) { printf(" %c ", ch); } else if (ch == '\n') { printf("\\n "); } else if (ch == '\t') { printf("\\t "); } else if (ch == '\r') { printf("\\r ");
} else { printf("
");
} } putchar('\n'); } } int main(int argc, char **argv) { if (argc >= 2) { int codepage = atoi(argv[1]); SetConsoleOutputCP((UINT)codepage); } print_pretty_ext_ascii(); return 0; } Rysunek 2 przedstawia wyniki działania powyższego kodu dla trzech przykładowych stron kodowych zawierających polskie znaki specjalne[80]. Warto zwrócić uwagę na obecność znaków używanych w przeszłości do rysowania tabel w przypadku strony kodowej CP 852 (patrz również ćwiczenie [STR:ascii-lines]), a także na różnorodność kodów odpowiadających polskim znakom; na przykład litera „ą” w przypadku CP 852 jest reprezentowana przez wartość 0xA5, w Windows-1250 przez 0xB9, natomiast w ISO-8859-2 przez liczbę 0xB1.
Rysunek 2. Wynik działania przykładowego programu (Windows 7, czcionka Consolas, terminal ConEmu-Maximus5) dla trzech różnych stron kodowych
(kolejno: CP 852, Windows-1250 oraz ISO-8859-2) Różnice w kodowaniu poszczególnych znaków wymuszają konieczność używania tego samego kodowania w każdej części rozwijanego programu i środowiska, w którym jest tworzony i operuje, lub, jeśli nie jest to możliwe, zadbania o odpowiednią konwersję pomiędzy różnymi kodowaniami – co nie zawsze jest możliwe, a zwłaszcza w przypadku znacznych rozbieżności pomiędzy kodowaniem źródłowym i docelowym. Różnice w kodowaniu [VERBOSE] Na przełomie lat dziewięćdziesiątych ubiegłego wieku i początku wieku XXI różnice w kodowaniu były najbardziej widoczne na polskich stronach WWW tworzonych pod systemami z rodziny Windows, gdzie domyślnym stosowanym przez wiele edytorów kodowaniem był wspomniany wcześniej Windows-1250. Jeśli dana strona nie miała jawnie zadeklarowanego kodowania, to przeglądarki internetowe (szczególnie te wywodzące się z systemów unixowych, gdzie dominowało kodowanie ISO-8859-2) próbowały zinterpretować wszystkie rozszerzone znaki właśnie jako ISO-8859-2. Przykład rezultatu takiego zachowania znajduje się poniżej (HTML):
Najkrótszy pangram: Zażółć gęsią jaźń.
Efektem interpretacji powyższego tekstu jako zakodowanego ISO-8859-2 jest następujący ciąg: Najkrótszy pangram: Za¿ó³æ gêsi¹ jaŸl. Błąd ten bardzo łatwo naprawić przez zadeklarowanie użytego kodowania w źródle strony internetowej. Można to zrobić albo w samym pliku HTML (korzystając z tagu lub , jak pokazano poniżej), albo w nagłówku HTTP w polu Content-Type:
Najkrótszy pangram: Zażółć gęsią jaźń.
Po zadeklarowaniu z oczekiwaniami napis:
kodowania
przeglądarka
wyświetli
zgodny
Najkrótszy pangram: Zażółć gęsią jaźń. Dodam, że przeglądarki są również wyposażone w mechanizm detekcji kodowania, który stara się wydedukować kodowanie, bazując na występujących na stronie znakach. Mechanizm ten jest jednak zawodny, zdecydowanie lepszą praktyką jest zatem jawne deklarowanie użytego kodowania. Analogiczny problem występuje w zasadzie we wszystkich miejscach w systemie, gdzie może dojść do różnic w kodowaniu (np. podczas rozmów za pośrednictwem protokołu IRC). Na szczęście współcześnie większość aplikacji zaadoptowała standard Unicode, a konkretniej kodowanie UTF-8 (do różnych typów kodowań Unicode wrócę w dalszej części tego rozdziału), co znacznie poprawiło współpracę między aplikacjami. Jeśli chodzi o tworzony kod źródłowy, to z uwagi na różnice w kodowaniu pomiędzy odmiennymi edytorami przyjęło się unikać znaków spoza standardowego zbioru ASCII w kodzie źródłowym produkcyjnej a wszelkie wiadomości tekstowe przechowywać w osobnych tekstowych o ściśle określonym kodowaniu. Takie podejście tłumaczenie aplikacji na wiele języków oraz oferowanie jego użytkownikowi[81].
jakości, plikach ułatwia wyboru
Nie tylko ASCII i Unicode [BEYOND] Oprócz stosowanego obecnie Unicode oraz starszego ASCII w przeszłości używano również innych kodowań, a z częścią z nich można spotkać się nawet w dzisiejszych czasach. Na przykład:
W latach 50. i 60. ubiegłego wieku platformy serwerowe firmy IBM używały 8-bitowego kodowania EBCDIC (Extended Binary Coded Decimal Interchange Code). Popularne w latach 80. ubiegłego wieku komputery Commodore 64 używały kodowania PETSCII, które składało się z dwóch tabel znaków: shifted zawierającej standardowe znaki specjalne, a także cyfry, wielkie i małe litery, jak również garść znaków pseudograficznych, oraz unshifted, w której nie występowały małe litery (ich miejsce zajęły wielkie litery), ale pojawiło się jeszcze więcej znaków pseudograficznych. W krajach korzystających ze znaków logograficznych (np. w Chinach lub w Japonii) siedmio- lub ośmiobitowe kodowania nie wystarczały do zakodowania choćby podstawowego zestawu znaków. W takim przypadku z pomocą przychodziły dwubajtowe kodowania, jak Big5, lub kodowania o zmiennej wielkości (jeden lub dwa bajty), takie jak GBK lub Shift JIS. Bardzo dobry spis popularnych w przeszłości kodowań można znaleźć na Wikipedii [1]. Zanim przejdę do omawiania używanego powszechnie Unicode, chciałbym wspomnieć o jeszcze jednym, bardzo istotnym detalu: kody znaków nie są tożsame z kodami klawiszy, tj. korzystając z API do odczytu stanu klawiszy lub reagującego na zdarzenia związane z naciśnięciami klawiszy, należy spodziewać się kodów niekoniecznie kompatybilnych z ASCII czy Unicode[82]. Jest to związane m.in. z tym, że ani ASCII, ani Unicode nie zawierają znaków specjalnych odpowiadających klawiszom typu F2, Caps Lock czy Print Screen, które można znaleźć na współczesnych klawiaturach, jak i również z tym, że klawiatury nie posiadają oddzielnych klawiszy dla małych i wielkich liter – ich wielkość jest jedynie wypadkową naciśniętego klawisza, zapamiętanego stanu Caps Lock oraz stanu wciśnięcia klawisza Shift. Niemniej jednak niektóre API dbają o to, by mapowanie klawiszy na znaki było przynajmniej zbliżone do faktycznych kodów znaków, choć różne API robią to w odmienny sposób. Na przykład w WinAPI klawisze 0-9 oraz A-Z mają identyczne kody jak odpowiadające im znaki ASCII [3]; podobnie jest w przypadku międzyplatformowej biblioteki SDL, w której klawisze 0-9 oraz A-Z są mapowane na kody odpowiadające znakom ASCII 0-9 oraz a-z [4].
Uwaga ta nie dotyczy oczywiście standardowego wejścia (stdin) w aplikacjach konsolowych uruchomionych bez przekierowania, ponieważ dane, które na nie trafiają, są już odpowiednio przetworzone. Jednym z zadań konsoli jest odpowiednia translacja zdarzeń związanych z klawiaturą na odpowiednie, normalne lub specjalne, znaki w wybranym kodowaniu.
6.2. Unicode Współcześnie najczęściej używanym standardem kodowania znaków jest Unicode, który określa zarówno kody samych znaków w postaci liczb naturalnych, jak i kilka różnych możliwości ich zapisania na poziomie pojedynczych bajtów. Unicode definiuje kilkanaście grup znaków (zwanych „płaszczyznami”, plane), z których każda składa się dokładnie z 65536 kodów znaków, choć nie wszystkie muszą zostać wykorzystane. Dodatkowo grupy dzielą się na płaszczyznę podstawową – BMP (Basic Multilingual Plane) – zawierającą znaki o kodach[83] od U+0000 do U+FFFF oraz szesnaście kolejnych, które stanowią suplement do płaszczyzny podstawowej. W sumie daje to ponad milion możliwych kodów, ale tylko około jedna czwarta z nich została do tej pory przydzielona [5][6]. Oprócz większości znaków ze znacznej liczby języków w Unicode można znaleźć również: Ponad 40 grup cyfr spotykanych w różnych częściach świata; co ciekawe, implementacje funkcji do konwersji tekstu na typy liczbowe wchodzące w skład standardowych bibliotek języków Python, Java oraz C (tylko w przypadku implementacji pod systemem Windows) wspierają dużą część z nich [7]. Elementy pseudograficzne umożliwiające tworzenie obramowań i tabel (U+2500–U+257F). Symbole znane z klasycznych gier: szachów, warcabów, domino, shogi, mahjonga czy kart (w tym same karty). Przeróżne strzałki w bardzo dużej ilości. Symbole matematyczne. Emotikony (U+1F600–U+1F64F). Bardzo liczne piktogramy przedstawiające np. telefon (U+1F57F), kota (U+1F408), myśliwiec (U+1F6E6) czy jednorożca (U+1F984) – patrz również rysunek 3.
Rysunek 3. Przykładowe piktogramy, które można znaleźć w Unicode (czcionka Symbola [8]) Chciałbym zachęcić czytelników do poświęcenia kilku minut na przejrzenie dostępnych znaków na stronie http://www.unicode.org/charts/ (lub alternatywnie [9]). Warto zwrócić uwagę, że znaki na pozycjach od 0x00 do 0xFF są identyczne jak w przypadku kodowania ASCII/ISO-8859-1, co daje kompatybilność wsteczną ze wskazanym kodowaniem[84]. Ponieważ zakres stosowanych w Unicode znaków rozciąga się aż do wartości U+10FFFF, nie jest bezpośrednio możliwe zakodowanie każdego znaku za pomocą jednego bajtu. Należy więc skorzystać z kodowania wielobajtowego, jednego z trzech definiowanych przez standard Unicode: UTF-32 (odmiana Little lub Big Endian)[85] – kodowanie o stałej długości (fixed-length), w którym indeks każdego znaku jest zawsze kodowany jako 32-bitowa liczba naturalna. UTF-16 (LE lub BE) – kodowanie o zmiennej długości (variable-length), w którym indeks każdego znaku jest zakodowany w jednej lub dwóch 16bitowych jednostkach kodowania (code unit). UTF-8[86] – kodowanie o zmiennej długości, w którym indeks każdego znaku jest kodowany przy użyciu od jednego do sześciu bajtów. Pierwsze 128 znaków Unicode zakodowanych w UTF-8 jest w pełni kompatybilne z ASCII. W przeszłości niektóre popularne aplikacje obsługiwały również dwa inne kodowania:
UCS-2 (LE lub BE) – kodowanie o stałej długości, w którym indeks znaku jest kodowany jako 16-bitowa liczba naturalna. Korzystając z UCS-2, można zakodować jedynie znaki o indeksach od U+0 do U+FFFF, a więc podstawową płaszczyznę Unicode (BMP). UTF-7 – „binarnie bezpieczne” kodowanie[87], w którym większość znaków z przedziału 0-0x7F jest kodowana bezpośrednio jako 8-bitowa liczba naturalna, a pozostałe znaki za pomocą prefiksu „+” oraz terminatora „-” (lub dowolnego innego znaku spoza alfabetu zmodyfikowanego Base64 [12]), pomiędzy którymi umieszczane są znaki zapisane przy użyciu UTF-16 BE i zakodowane dodatkowo wariantem Base64 [13]. Ponadto, w ramach ciekawostki, można wymienić kilka kodowań, które nie zyskały powszechnej akceptacji lub powstały jedynie w formie konceptu [14]: UTF-1 – kodowanie zbliżone do UTF-8, bazujące na systemie o podstawie K=190 [15]. UTF-5 oraz UTF-6 – powstałe jako propozycje kodowania nazw domen internetowych zawierających niełacińskie znaki (ostatecznie wybrano w tym celu kodowanie Punycode [16]). UTF-9 oraz UTF-18 – powstałe z myślą o maszynach, na których bajt miał 9 bitów. Specyfikacje zostały opublikowane 1 kwietnia. SCSU (Standard Compression Scheme for Unicode) oraz BOCU-1 (Binary Ordered Compression for Unicode) – kodowania kompresujące zaprojektowane w celu kodowania tekstu przy użyciu jak najmniejszej liczby bajtów. UTF-EBCDIC – bazujące na UTF-8 kodowanie częściowo kompatybilne z EBC-DIC (w przeciwieństwie do UTF-8, które jest częściowo kompatybilne z ASCII). W dalszej części podrozdziału omówię cztery najbardziej rozpowszechnione kodowania Unicode: dwa o stałej wielkości znaku – UTF-32 i UCS-2 oraz dwa o zmiennej – UTF-16 i UTF-8. Zanim jednak przejdę do samych kodowań, warto poświęcić chwilę na omówienie różnicy pomiędzy obiema grupami kodowań. ASCII, podobnie jak UCS-2 i UTF-32 są bezstanowymi[88] kodowaniami o stałej wielkości znaku, co oznacza, że każdy znak jest zapisany przy użyciu dokładnie 1,
2 lub 4 bajtów. Główną zaletą takiego podejścia jest jego prostota – każdy znak jest po prostu zakodowaną binarnie liczbą naturalną, co w przypadku niektórych języków programowania przekłada się bezpośrednio na odpowiedni, podstawowy typ zmiennej. Stała wielkość znaku pozwala również w trywialny sposób odwołać się do litery na dowolnej pozycji w buforze bajtów – wystarczy pomnożyć pozycję przez wielkość zakodowanego znaku, by otrzymać jego przesunięcie w buforze. Również operacje obcięcia (truncation) ciągu do określonej liczby znaków oraz połączenia (zwanego również konkatenacją, concatenation) dwóch ciągów tekstowych są proste i szybkie w implementacji. Wadą zbyt małych wielkości zakodowanych znaków jest jednak brak możliwości reprezentacji ich większej liczby – jest tak zarówno w przypadku ASCII, o czym pisałem już wcześniej, jak i UCS-2. W UTF-32 problem ten nie występuje, jednak w przypadku znaków zapisywanych przy użyciu jednego znaczącego bajtu pozostałe trzy są marnowane, co w wielu przypadkach może czynić ten zapis nieoptymalnym. Podobny problem, choć w mniejszej skali, występuje w przypadku UCS-2. UTF-8 i UTF-16 są semibezstanowymi kodowaniami o zmiennej wielkości, w których liczba bajtów potrzebnych do zakodowania znaku zależy od jego kodu – im wyższy kod, tym więcej bajtów. Co za tym idzie, kody te są dość kompaktowe i w przypadku dużej liczby znaków całkowita wielkość ciągu jest zazwyczaj zdecydowanie krótsza w porównaniu z tym samym ciągiem zapisanym w UCS-2 (o ile konwersja byłaby w ogóle możliwa) lub UTF-32. Z uwagi na bezstanowość pomiędzy znakami konkatenacja jest równie prosta jak w przypadku znaków o stałej wielkości. Z uwagi na budowę UTF-8 oraz UTF-16 (które zostaną szczegółowo omówione w dalszej części rozdziału) bardzo łatwo jest również ustalić w danym miejscu ciągu bajtów, czy jest to środek znaku, czy jego początek. Dzięki temu operacja obcięcia ciągu do określonej liczby bajtów jest również stosunkowo prosta, choć wymaga upewnienia się, że ostatni znak jest zakodowany w całości i nie został obcięty w połowie. Operacja znalezienia litery na danej pozycji jest jednak zdecydowanie trudniejsza, ponieważ wymaga przetworzenia ciągu od początku aż do oczekiwanego znaku – w końcu każdy wcześniejszy znak mógł być zapisany za pomocą (w przypadku UTF-8) zarówno 1, jak i 2, 3, 4, 5 lub 6 bajtów, co można stwierdzić dopiero po ich analizie. Ten sam problem dotyczy operacji przycięcia tekstu po określonej ilości znaków.
Mając na uwadze powyższe rozważania, możemy przejść do omówienia samych kodowań. Kodowanie UTF-32: Jest to najprostszy wariant kodowania – znak jest zakodowany jako 32-bitowa liczba naturalna, której wartość wpada w zakres od 0 do 0x10FFFF. UTF-32 występuje zarówno w wersji Little, jak i Big Endian. Przykład ciągu „UTF-32 BE/LE” zapisanego w UTF-32 oraz odpowiadające mu kody Unicode zostały przedstawione poniżej: Unicode: U+55 U+54 U+46 U+2d U+33 U+32 U+20 U+42 U+45 U+2f U+4c U+45
UTF-32 (Little Endian): 55 00 00 00 32 00 00 00 20 00 00 00
54 00 00 00
46 00 00 00
2d 00 00 00
33 00 00 00
42 00 00 00
45 00 00 00
2f 00 00 00
4c 00 00 00
00 00 00 54
00 00 00 46
00 00 00 2d
00 00 00 33
00 00 00 42
00 00 00 45
00 00 00 2f
00 00 00 4c
45 00 00 00 UTF-32 (Big Endian): 00 00 00 00
00 00 00 00
00 00 00 00
55 32 20 45
Kodowanie UCS-2: Kodowanie identyczne z UTF-32, przy czym ograniczone do 16-bitów i wartości, które można w nich zapisać (dla przypomnienia, jest to przedział od 0 do 0xFFFF włącznie). Również występuje w wersji Little oraz Big Endian. Przykład ciągu „UCS-2 BE/LE” zapisanego w UCS-2 oraz odpowiadające mu kody Unicode przedstawiają się następująco: Unicode: U+55 U+43 U+53 U+2d U+32 U+20 U+42 U+45 U+2f U+4c U+45 UCS-2 (Little Endian):
55 00
43 00
4c 00
45 00
53 00
2d 00
32 00
20 00
42 00
45 00
2f 00
00 2d
00 32
00 20
00 42
00 45
00 2f
UCS-2 (Big Endian): 00 55 00 4c
00 43 00 45
00 53
Kodowanie UTF-16: Kodowanie UTF-16 jest częściowo kompatybilne z UCS-2, tj. wszystkie znaki o kodach z przedziałów od U+0000 do U+D7FF oraz od U+E000 do U+FFFF są zapisywane identycznie w obu kodowaniach. Jeśli chodzi o wartości U+10000 i wyższe, to są one kodowane za pomocą dwóch bajtów, w dwóch etapach: Po pierwsze, od kodu znaku odejmowana jest wartość 0x10000, dzięki czemu do zakodowania pozostają jedynie wartości 0x0–0xFFFFF (wymaga to 20 bitów). Powstała liczba jest rozbijana na dwie grupy po 10 bitów, z czego: – Grupa zawierająca najbardziej znaczące bity jest łączona z maską bitową 0xD800 i zapisywana binarnie jako pierwsza 16-bitowa liczba. – Grupa zawierająca najmniej znaczące bity jest łączona z maską bitową 0xDC00 i zapisywana binarnie jako druga 16-bitowa liczba. Pozostaje jednak pytanie, co z wartościami z przedziału od U+D800 do U+DFFF. W przypadku UTF-16 są one ignorowane, tj. nie istnieje możliwość ich zakodowania. Niemniej jednak nie stanowi to problemu, ponieważ Unicode celowo definiuje ten przedział jako nieużywany – a więc UTF-16 umożliwia zapisanie wszystkich znaków z płaszczyzny podstawowej za pomocą jednej 16bitowej liczby. Dekodowanie wartości UTF-16 na znak przebiega w odwrotny sposób: Jeśli analizowana 16-bitowa liczba jest z przedziału od 0x0000 do 0xD7FF lub od 0xE000 do 0xFFFF, to jest to kod znaku. W przeciwnym wypadku (liczba jest z przedziału od 0xD800 do 0xDBFF): – Wyekstraktuj dolne 10 bitów liczby. – Odczytaj kolejną 16-bitową liczbę i potwierdź, że jest ona z przedziału od 0xDC00 do 0xDFFF. – Po raz kolejny wyekstraktuj dolne 10 bitów liczby.
– Połącz oba zestawy bitów w jedną liczbę (bity z pierwszej liczby powinny trafić na pozycje 19–10, a bity z drugiej na pozycje 9–0). – Do otrzymanej w ten sposób liczby dodaj wartość 0x10000. Wynik jest kodem znaku. Podany schemat można przetestować, np. korzystając z języka Python 2.7, który oferuje zarówno mechanizm zapisu Unicode'owych stałych tekstowych w kodzie, jak i zakodowania ich jako UTF-16: #!/usr/bin/python # -*- coding: utf-8 -*import struct import sys def manual_decode_utf16le(s): unicodes = [] code = None i = 0 while i < len(s): # Odczytaj 16-bitową liczbę naturalną (LE). codepoint = struct.unpack(" assoc .exe .exe=exefile > ftype exefile exefile="%1" %* Co
więcej,
funkcje
z
rodziny
ShellExecute
umożliwiają
również
uruchomienie domyślnej aplikacji obsługującej dany protokół. Na przykład w
przypadku
parametru
http://gynvael.coldwind.pl/
uruchomiona domyślna przeglądarka systemowa[109].
zostałaby
Podsumowując: ostatecznie każda platforma dysponuje przynajmniej dwiema różnymi funkcjami do tworzenia nowych procesów. Proste funkcje są synchroniczne i czekają, aż proces zakończy działanie – co za tym idzie nie umożliwiają pełnej interakcji poza przekazaniem mu argumentów wejściowych (argumenty linii poleceń, zmienne środowiskowe, standardowe wejście) oraz odebrania danych wyjściowych (standardowe wyjście, wyjście błędów; kod wyjścia – exit code). Umożliwiają to natomiast funkcje asynchroniczne, które pozwalają na dostęp do procesu w formie odpowiedniego uchwytu oraz ewentualnie zestawu uchwytów do stdin, stdout oraz stderr. Tabela 1 zawiera spis przykładowych funkcji do tworzenia procesów związanych z przykładowymi językami oraz systemowymi API. Tabela 1. Przykładowe funkcje tworzące procesy
Platforma
Funkcja
Komentarz
Java
Runtime.exec
Funkcja as ynchroniczna.
ProcessBuilder
Umożliwia m.in. przekierowanie s tandardoweg o wejś cia, wyjś cia i wyjś cia błędów. Jes t to funkcja as ynchroniczna.
subprocess.call subprocess.check_call subprocess.check_output
Funkcje s ynchroniczne.
os.system
Wrapper na funkcję system z języka C.
system
Korzys tając z interpretera poleceń, wykonuje dane polecenie. W przypadku s ys temów z rodziny GNU/Linux interpreterem jes t prog ram /bin/sh, natomias t w przypadku s ys temów z rodziny
Python
C/C++
Windows interpreter jes t ws kazany przez zmienną ś rodowis kową ComSpec. Jes t to funkcja s ynchroniczna. PHP
WinAPI
GNU/Linux, C/C++
system, exec, passthru
Ws zys tkie wymienione funkcje s ą implementowane przez tę s amą funkcję, która wewnętrznie korzys ta z popen (POS IX), a tym s amym z interpretera poleceń.
popen
Wrapper na funkcję popen (POS IX).
CreateProcess
Funkcja as ynchroniczna.
CreateProcessAsUser
Podobnie jak CreateProcess, ale umożliwia s tworzenie proces u, któreg o właś cicielem będzie inny użytkownik.
ShellExecute, ShellExecuteEx
As ynchroniczna funkcja, która dodatkowo s prawdza w rejes trze, jak należy uruchomić dany rodzaj pliku. Jes t to is totne w przypadku plików innych niż wykonywalne, kiedy to uruchamiany jes t odpowiedni prog ram obs ług ujący dany typ plików.
execve, execl, execlp, execle, execv, execvp, execvpe
Przeks ztałca obecny proces w nowy na pods tawie ws kazaneg o pliku wykonywalneg o,
arg umentów i zmiennych ś rodowis kowych. Niektóre elementy s tareg o proces u (np. częś ć uchwytów) mog ą być dziedziczone. popen
As ynchroniczna funkcja podobna do system w języku C. Zwraca potok połączony z wybranym s tandardowym wyjś ciem lub wejś ciem proces u, umożliwiając interakcję z uruchomionym proces em.
7.4. Plik wykonywalny a nowy proces Dokładny przebieg powstawania nowego procesu (a raczej tworzenia go przez system operacyjny) jest dosyć skomplikowany i zdecydowanie wykracza poza zakres niniejszej książki. Niemniej jednak warto znać przynajmniej uproszczony przebieg tej operacji. Proces tworzony jest na podstawie parametrów zadanych przez wskazany plik wykonywalny. W przypadku systemów z rodziny Windows współcześnie używanym typem jest PE (Portable Executable), choć 32-bitowe wersje systemów w ramach podsystemu NTVDM (NT Virtual DOS Machine) nadal obsługują archaiczne pliki wykonywalne, używane w systemie MS-DOS oraz Windows 3.x: MZ (tj. DOS MZ Executable), NE (New Executable) i beznagłówkowe pliki COM. Systemy z rodziny GNU/Linux korzystają natomiast przede wszystkim z plików ELF (Executable and Linkable Format), choć również mogą obsługiwać archaiczne pliki a.out [6][110]. Co więcej, jądro Linux posiada dwa mechanizmy wprowadzające bardzo dużą elastyczność, jeśli chodzi o tworzenie procesów na podstawie innego rodzaju plików: #! (czytane najczęściej z shebang; opcja BINFMT_SCRIPT w konfiguracji jądra) – jeśli wskazany do wykonania plik zaczyna się od znaków #!,
kernel odczytuje pierwszą linię pliku i uznaje, że po znakach #! znajduje się pełna ścieżka interpretera (oraz jego argumenty), który ma zostać wykonany ze ścieżką do przetwarzanego pliku w parametrze (stąd na początku skryptów Bash, Python itp. pojawiają się linie zawierające nagłówki o treści #!/bin/bash, #!/usr/bin/python itd.). MISC (BINFMT_MISC) – mechanizm bardzo zbliżony do powyższego, z tą różnicą, że faktycznie uruchomiony interpreter zależy od rozszerzenia lub wartości magicznej pliku (patrz rozdział „Format BMP i wstęp do bitmap”). Dokumentacja jądra Linux jako przykłady podaje binarne pliki Java (Java class, JAR) [7], skompilowane pliki CPython (pliki pyc), a także pliki PE obsługiwane przez Wine [8][9]. Listę obsługiwanych formatów plików w danym systemie można znaleźć w pseudokatalogu /proc/sys/fs/binfmt_misc/[111]. W każdym razie, niezależnie od użytego mechanizmu, ostatecznie proces i tak oparty jest na pliku wykonywalnym ELF (na potrzeby niniejszej książki pominę archaiczne formaty), bez względu na to, czy będzie to faktyczny, wskazany plik wykonywalny czy np. interpreter języka Python lub maszyna wirtualna Javy. Tylko statyczne ELF-y [BEYOND] W rzeczywistości jądro Linux obsługuje wyłącznie statycznie zlinkowane pliki wykonywalne (tj. takie, które nie deklarują potrzeby załadowania żadnych bibliotek dynamicznych). Jeśli dany plik ELF korzysta z dynamicznych bibliotek, ma on dodatkowo ustawione pole INTERP w nagłówkach programowych ELF (Program Headers), zawierające ścieżkę do interpretera, którego należy użyć jako plik wykonywalny w miejsce otwieranego pliku. Mechanizm ten jest zbliżony do wspomnianego znacznika #!, ale stawia dodatkowe wymaganie – interpreter musi być statycznie zlinkowanym plikiem wykonywalnym. Zwyczajowo do zadań takiego interpretera należą m.in. spełnienie żądań programu dotyczących bibliotek dynamicznych, a także ewentualne naniesienie poprawek związanych z relokacjami. Wartość pola INTERP można odczytać np. za pomocą narzędzia readelf: $ readelf -l /bin/bash Elf file type is EXEC (Executable file) Entry point 0x42020b
There are 9 program headers, starting at offset 64 Program Headers: Type PhysAddr
Offset
VirtAddr
FileSiz
MemSiz
Flags
Align PHDR 0x0000000000000040 0x0000000000400040 0x0000000000400040 0x00000000000001f8 0x00000000000001f8 8 INTERP 0x0000000000000238 0x0000000000400238 0x0000000000400238 0x000000000000001c 0x000000000000001c 1
R E
R
[Requesting program interpreter: /lib64/ld-linux-x8664.so.2] ... W systemach z rodziny GNU/Linux interpreter jest dostarczany przez standardową bibliotekę języka C (GNU C Library) i w przypadku architektury x86 można go znaleźć w dwóch lokacjach: /lib/ld-linux.so.2 (x86-32); /lib64/ld-linux-x86-64.so.2 (x86-64). Interpreterem można posłużyć się również bezpośrednio (patrz również [10]), np.: $ /lib64/ld-linux-x86-64.so.2 /bin/date Wed May 15 13:32:46 CEST 2015 Jako ciekawostkę dodam, że polecenie ldd służące do listowania bibliotek dynamicznych zadeklarowanych do zaimportowania przez plik ELF w praktyce jest skryptem Bash, który wywołuje jeden z wymienionych interpreterów z parametrem --list.
O ile na poziomie bitowym formaty ELF oraz PE zdecydowanie się różnią, to implementują one w większości te same koncepty i zawierają analogiczne informacje (zachęcam do zapoznania się z ich graficzną reprezentacją [11]). W szczególności wymienić należy[112]: Podstawowe informacje o obrazie (image) – w tym rozumieniu słowo „obraz” jest używane w kontekście konkretnej reprezentacji pliku wykonywalnego w pamięci, tj. ułożenia jego sekcji w pamięci. Do tych podstawowych informacji zalicza się przede wszystkim: – Docelowy adres obrazu (base address) w przestrzeni adresowej procesu. – Wielkość obrazu (tj. ile pamięci należy zarezerwować na potrzeby pliku wykonywalnego). – Punkt wejścia (Entry Point, EP) – adres pierwszej instrukcji pierwszego wątku w programie. Sekcje kodu, danych, danych tylko do odczytu itp. Każda sekcja reprezentuje logiczny fragment pliku wykonywalnego i zawiera, oprócz samych danych, również informacje o źródłowym (tj. w pliku) i docelowym (tj. w pamięci) położeniu i wielkości sekcji, a także o minimalnych wymaganych prawach dostępu do tego obszaru pamięci. Listę „importów” (Import Table), czyli wymaganych bibliotek dynamicznych oraz symboli (funkcji, zmiennych globalnych itp.) do zaimportowania z tych bibliotek. Biblioteki dynamiczne (które również są plikami ELF lub PE) posiadają analogiczną listę zawierającą pary symboli i odpowiadające im adresy w obrazie biblioteki. Analizowanie plików PE i ELF [VERBOSE] Istnieje wiele narzędzi listujących pliki PE oraz ELF w przystępny dla człowieka sposób. Lista przykładowych znajduje się poniżej: PE: PEview [12] – wyposażony w proste GUI program wyświetlający podstawowe struktury 32-bitowych plików PE;
objdump (wchodzący w skład pakietu MinGW lub MinGW-w64) – znany z systemów GNU/Linux konsolowy program wyświetlający zarówno informacje o podstawowych strukturach pliku wykonywalnego, jak i jego zdisasemblowany kod maszynowy; dumpbin (wchodzący w skład pakietu Visual Studio) – program zbliżony w działaniu do objdump. ELF: readelf – konsolowy program wyświetlający struktury formatu ELF; objdump – jw.
Uruchomienie nowego procesu – w dużym uproszczeniu – zawsze zaczyna się od zaalokowania i zainicjowania odpowiednich struktur opisujących proces w jądrze systemu oraz utworzenia nowej tablicy opisującej przestrzeń pamięci procesu. Na początku do tej tablicy trafia jedynie kilka „systemowych” stron pamięci[113] (np. vDSO [13] pod systemami z rodziny GNU/Linux oraz SharedUserData [14], a także wspomniany wcześniej PEB pod systemami z rodziny Windows). Następnie odczytywany jest plik wykonywalny (pod systemami z rodziny GNU/Linux to, który plik wykonywalny jest przetwarzany, może ulec zmianie, jeśli wymaga interpretera), system wybiera adres bazowy, pod jaki obraz zostanie wczytany, po czym następuje alokacja pamięci dla obrazu oraz ewentualne wczytanie danych do pamięci. Współczesne obrazy rzadko są wczytywane pod wskazane adresy zasugerowane w nagłówkach pliku – wynika to przede wszystkim z użycia mechanizmu ASLR (Address-Space Layout Randomization) [114]. Zamiast tego pliki wykonywalne oraz biblioteki dynamiczne są tworzone w sposób ułatwiający ich relokację – albo przez stworzenie kodu, który korzysta z adresacji względnej wobec wskaźnika instrukcji, albo dodanie do pliku informacji o miejscach, w których należy poprawić adresy po przesunięciu obrazu w inne miejsce (mowa o tzw. tablicy relokacji). W tym momencie może zostać utworzony pierwszy wątek, co wiąże się m.in. z alokacją jego stosu (oraz wspomnianej wcześniej struktury TEB w przypadku systemów z rodziny Windows). W przypadku statycznych plików ELF wątek rozpoczyna wykonanie programu od adresu wejścia zdefiniowanego w obrazie
pliku wykonywalnego. Jeśli statycznym ELF-em jest loader dynamicznych plików ELF (patrz ramka „Tylko statyczne ELF-y [BEYOND]”), kontynuuje on przygotowywanie środowiska procesu, zaczynając od załadowania właściwego pliku wykonywalnego do pamięci. W przypadku systemów z rodziny Windows sytuacja wygląda podobnie – do pamięci procesu trafia biblioteka ntdll.dll[115], w której znajduje się kod realizujący dalszą część procedury przygotowującej proces do wykonania zadanego programu. W obu przypadkach kolejnym krokiem jest wczytanie do pamięci (w identyczny sposób jak plik wykonywalny) wymaganych bibliotek dynamicznych, a także kolejnych (patrz również ramka „Drzewo zaznaczyć, że niezależnie od tego, zależności, jest ona wczytywana
bibliotek wymaganych przez te już wczytane zależności [VERBOSE]”) – należy przy tym ile razy dana biblioteka występuje w drzewie do pamięci jedynie raz. W dalszej kolejności
następuje rozwiązanie importów (import resolution), tj. adresy symboli wyliczone na podstawie tablicy eksportów bibliotek dynamicznych i informacji o adresie danej biblioteki w pamięci są umieszczane w tablicach importów wymagających tych bibliotek i samego pliku wykonywalnego. Drzewo zależności [VERBOSE] Ponieważ każdy plik PE i ELF, niezależnie od tego, czy jest plikiem wykonywalnym, czy dynamiczną biblioteką, może wymagać importu kolejnych bibliotek, tworzy się z tego de facto drzewo zależności (dependency tree). Aby podejrzeć drzewo zależności pod systemami GNU/Linux, można skorzystać z programu lddtree: $ lddtree /bin/bash bash => /bin/bash (interpreter => /lib64/ld-linux-x86-64.so.2) libtinfo.so.5 => /lib/i386-linux-gnu/libtinfo.so.5 libdl.so.2 => /lib/i386-linux-gnu/libdl.so.2 ld-linux.so.2 => /lib/i386-linux-gnu/ld-linux.so.2 libc.so.6 => /lib/i386-linux-gnu/libc.so.6 Pod systemami z rodziny Windows można skorzystać z aplikacji Dependency Walker (patrz rys. 4), która w przeszłości była częścią pakietu Visual Studio, ale obecnie wymaga osobnego pobrania [15].
Innymi terminami, o których warto wspomnieć w tym kontekście, są „DLL hell” [16] oraz bardziej ogólnie „dependency hell” [17], które są humorystycznymi określeniami na zamieszanie, które powstaje, gdy w systemie (a konkretniej w ścieżkach, w których wyszukiwane są biblioteki dynamiczne) znajduje się więcej niż jedna wersja danej biblioteki.
Rysunek 4. Dependency Walker Ostatnim krokiem jest uruchomienie po kolei wszystkich funkcji inicjalizujących zadeklarowanych w bibliotekach dynamicznych (przykładem może być funkcja DllMain w plikach DLL), jak i w samym pliku wykonywalnym [18]. Dalej następuje skok do punktu wejścia pliku wykonywalnego, w efekcie czego program zaczyna faktyczne wykonanie. Jeśli tym programem jest maszyna wirtualna dla danego języka, kontynuowane jest przygotowanie środowiska do wykonania procesu, na co zazwyczaj składa się dalsza inicjalizacja, wczytanie
podstawowych bibliotek używanych przez program w danym języku oraz wczytanie głównego pliku programu, jego ewentualna kompilacja i ostatecznie rozpoczęcie wykonania.
7.5. API debuggera Interakcje między procesami można generalnie podzielić na kilka grup: Komunikację (Inter-process Communication, w skrócię IPC), o której piszę w części V niniejszej książki. Debugowanie oraz podobne operacje niskopoziomowe. Dziedziczenie po procesie rodzica. Inne operacje na obiekcie procesu. W pozostałej części tego rozdziału opisuję trzy ostatnie mechanizmy z podanej listy – zacznę od API debuggera. Niezależnie od języka programowania klasyczny interfejs debuggera jest oparty na modelu synchronicznych zdarzeń, tj. debugger jest informowany o wszystkich zdefiniowanych zdarzeniach w obrębie debugowanego procesu i jego celem jest ich przetworzenie i ewentualnie podjęcie decyzji na ich podstawie. W praktyce debuggery składają się przede wszystkim z pętli debugującej, której zadaniem jest: Odebranie
informacji
o
zdarzeniu
(każde
zdarzenie
powoduje
wstrzymanie procesu debugowanego). Przetworzenie odebranej informacji. Wznowienie procesu, z którym może wiązać się zakomunikowanie systemowi decyzji, czy o danym zdarzeniu powinien zostać poinformowany proces debugowany (w szczególności mowa tu o wyjątkach). Jeśli chodzi o zestaw zdarzeń, o których jest informowany debugger, to różnią się one w zależności od konkretnej technologii (języka, systemu, procesora itp.). Na przykład interfejs debuggera w systemach z rodziny Windows informuje o następujących zdarzeniach[116]:
Utworzenie nowego procesu. Utworzenie nowego wątku. Wystąpienie wyjątku. Zakończenie procesu. Zakończenie wątku. Wczytanie dynamicznej biblioteki[117]. Wygenerowanie wiadomości tekstowej dla debuggera. Wystąpienie wewnętrznego błędu interfejsu debuggera. Usunięcie dynamicznej biblioteki z pamięci. Oprócz umożliwienia otrzymywania zdarzeń API debuggera udostępnia również mechanizmy do niskopoziomowego operowania na procesie i wątkach. Do standardowych operacji można zaliczyć: Odczyt oraz zapis pamięci debugowanego procesu. Dostęp do mapy pamięci. Modyfikację atrybutów (uprawnień dostępu) stron pamięci. Podgląd oraz modyfikację kontekstu (tj. zestawu rejestrów procesora) wątków. WinAPI i dostęp do pamięci innego procesu [BEYOND] W podrozdziale 1.1 podałem przykład dostępu do pamięci innego procesu w systemach z rodziny GNU/Linux, korzystając z mechanizmu /proc/[pid]/mem. Jak również wspomniałem, systemy z rodziny Windows nie dysponują podobnym mechanizmem, więc wykonanie takiej czynności jedynie za pomocą narzędzi opartych na systemie plików jest niemożliwe. Niemniej jednak w skład zestawu funkcji API debuggera pod systemem Windows jak najbardziej wchodzą funkcje do operacji na pamięci innego procesu. Są to: ReadProcessMemory; WriteProcessMemory. Obie funkcje operują na uchwycie procesu i wymagają jedynie uprawnień do operacji na pamięci procesu (konkretniej: PROCESS_VM_READ w przypadku ReadProcessMemory oraz PROCESS_VM_WRITE i PROCESS_VM_OPERATION
w przypadku WriteProcessMemory), z czego wynika, że dany proces nie musi być debugowany. Stosując wymienione funkcje, możemy stworzyć proste narzędzie korzystające z linii poleceń, które zadziała podobnie jak użyty wcześniej program dd z parametrem /proc/[pid]/mem, który odczyta wskazany fragment pamięci i wypisze jego treść na standardowe wyjście (C++): #include #include #include #include void usage() { puts("usage: readmem \n" "e.g. : readmem 1234 0x12345678 32\n" " Reads 32 bytes from address 0x12345678 of process 1234.\n"); } int main(int argc, char **argv) { if (argc != 4) { usage(); return 1; } unsigned int pid; uint64_t address; unsigned int length; if (sscanf(argv[1], "%u", &pid) != 1 || sscanf(argv[2], "%I64i", &address) != 1 || sscanf(argv[3], "%i", &length) != 1) { puts("error: incorrect argument"); usage(); return 1; }
// Otwórz proces z uprawnieniami do odczytu pamięci. HANDLE h = OpenProcess(PROCESS_VM_READ, FALSE, pid); if (h == NULL) { printf("error: could not open remote process (code: %u)\n", static_cast(GetLastError())); return 1; } // Odczytaj i wypisz podany fragment pamięci. BYTE *data = new(std::nothrow) BYTE[length]; if (data == NULL) { printf("error: failed to allocate %u bytes of memory\n", length); CloseHandle(h); return 1; } BOOL result = ReadProcessMemory( h, reinterpret_cast(address), data, length, NULL); if (!result) { puts("error: failed to read memory"); delete [] data; CloseHandle(h); return 1; } fwrite(data, 1, length, stdout); putchar('\n'); delete [] data; CloseHandle(h); return 0; }
Do przetestowania przedstawionego narzędzia użyjemy ponownie programu print_and_wait (patrz źródło w podrozdziale 1.1), który bez problemu można skompilować również pod systemy z rodziny Windows. Kompilacja oraz jego uruchomienie przebiegają następująco: > gcc print_and_wait.c -o print_and_wait.exe > print_and_wait Address: 00403064 W osobnej konsoli zaprezentowane narzędzie:
możemy
skompilować
> g++ -Wall -Wextra readmem.cc -o readmem.exe > tasklist | find "print_and_wait" print_and_wait.exe 13776 Console 900 K > readmem 13776 0x00403064 24
oraz
przetestować
1
1
This is a sample string. Wypisany został poprawny string, co potwierdza, że narzędzie działa zgodnie z naszymi intencjami. Należy dodać, iż powyższy kod zadziała w pełni poprawnie również w przypadku 64-bitowych procesów, ale tylko w przypadku, jeśli samo narzędzie będzie skompilowane do 64-bitowego pliku wynikowego. Wynika to ze specyfiki funkcji ReadProcessMemory, która wartość adresu w zewnętrznym procesie przyjmuje za pośrednictwem typu LPCVOID (czyli po prostu const void*), który w przypadku 32-bitowych programów ma jedynie 32 bity, a więc nie jest w stanie wyrazić 64-bitowego adresu. Oznacza to jednocześnie, że 32-bitowe procesy korzystające z funkcji ReadProcessMemory mają dostęp jedynie do dolnych czterech gigabajtów przestrzeni adresowej 64bitowego procesu. Jako ciekawostkę dodam, iż opisany problem można by obejść, np. korzystając z biblioteki wow64ext autorstwa ReWolfa [19], niemniej jednak temat przeskakiwania pomiędzy trybami procesora wykracza poza tematykę niniejszej książki.
Wszystkie typowe dla debuggerów funkcje można zaimplementować na podstawie wymienionych zdarzeń i operacji. Dwa przykłady znajdują się poniżej: Punkt wstrzymania na konkretnej instrukcji: debugger zamienia w pamięci debugowanego procesu wskazaną instrukcję na inną, która spowoduje rzucenie wyjątku[118]. Gdy wykonanie dotrze do tego miejsca, debugger otrzymuje informację o wystąpieniu wyjątku. W momencie wznowienia wykonania instrukcja jest podmieniana na oryginalną. Alternatywnie niektóre architektury umożliwiają zażądanie od procesora wygenerowania wyjątku w przypadku wykonania instrukcji na konkretnym adresie (niezależnie od znajdującej się pod nim instrukcji)[119]. Breakpoint przy dostępie do zmiennej: debugger zmienia prawa do strony pamięci, w której znajduje się zmienna, na „brak dostępu”. Jeśli debugowany proces odwoła się do dowolnej zmiennej znajdującej się w tej stronie pamięci, zostanie wygenerowany wyjątek, o którym zostaje poinformowany debugger. Następnie debugger sprawdza, czy adres wyjątku zgadza się z adresem zmiennej – jeśli nie, lub w przypadku wznowienia działania, prawa do strony pamięci są przywracane, po czym dostęp do pamięci jest ewentualnie ponownie usuwany po wykonaniu kolejnej instrukcji. Z tworzeniem debuggerów wiąże się jeszcze kilka innych problemów, np. tzw. symbolizacja, czyli zamiana adresu na odpowiadającą mu nazwę zmiennej, funkcji itp. – wymaga to wygenerowania przez kompilator tablicy symboli dla debuggera, z której ten będzie mógł korzystać, oraz dodatkowej biblioteki obsługującej dany format symboli. Listowanie procesów wraz z linią poleceń [BEYOND] W podrozdziale „Procesy w systemie operacyjnym – Windows” w tym rozdziale przedstawiłem bardzo prosty program listujący procesy w systemach Windows. Jednocześnie stwierdziłem, że wypisanie linii poleceń, z jaką został uruchomiony proces, jest bardziej skomplikowane niż w przypadku GNU/Linux, gdzie jest ona dostępna w pseudopliku cmdline w katalogu /proc/[pid]/ danego procesu. W praktyce mając dostęp do pamięci danego procesu oraz znając adres znajdującej się w niej struktury Process Environment
Block [20], można pobrać linię poleceń bezpośrednio z przestrzeni adresowej danego procesu [21][22]. Poniżej znajduje się przykładowy kod (kompatybilny z MinGW-w64 GCC), który realizuje to zadanie – zachęcam do jego przestudiowania: #include #include #include #include #include // https://msdn.microsoft.com/enus/library/windows/desktop/ms684280.aspx extern "C" LONG WINAPI NtQueryInformationProcess( HANDLE ProcessHandle, DWORD ProcessInformationClass, PVOID ProcessInformation, ULONG ProcessInformationLength, PULONG ReturnLength ); // Źródło: // https://msdn.microsoft.com/enus/library/windows/desktop/ms684280.aspx typedef struct _PROCESS_BASIC_INFORMATION { PVOID Reserved1; PVOID PebBaseAddress; PVOID Reserved2[2]; ULONG_PTR UniqueProcessId; PVOID Reserved3; } PROCESS_BASIC_INFORMATION; // https://msdn.microsoft.com/enus/library/windows/desktop/aa813741.aspx typedef struct _RTL_USER_PROCESS_PARAMETERS { BYTE Reserved1[16];
PVOID
Reserved2[10];
UNICODE_STRING ImagePathName; UNICODE_STRING CommandLine; } RTL_USER_PROCESS_PARAMETERS, *PRTL_USER_PROCESS_PARAMETERS; // https://msdn.microsoft.com/enus/library/windows/desktop/aa813706.aspx typedef struct _PEB { BYTE Reserved1[2]; BYTE BeingDebugged; BYTE Reserved2[1]; PVOID Reserved3[2]; PVOID Ldr; PRTL_USER_PROCESS_PARAMETERS ProcessParameters; BYTE Reserved4[104]; PVOID Reserved5[52]; PVOID PostProcessInitRoutine; BYTE Reserved6[128]; PVOID Reserved7[1]; ULONG SessionId; } PEB, *PPEB; void fetch_cmdline(DWORD pid, char *buffer, size_t max_length) { // Jeśli bufor jest zbyt mały, nie trzeba nic robić. if (buffer == NULL || max_length == 0) { return; } buffer[0] = '\0'; // Otwórz zdalny proces, tak aby można pobrać o nim informacje // oraz czytać jego pamięć. HANDLE h = OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION,
FALSE, pid); if (h == NULL) { return; } // Pobierz podstawowe informacje o procesie. PROCESS_BASIC_INFORMATION pqi; LONG status = NtQueryInformationProcess(h, 0 /* ProcessBasicInformation */, &pqi, sizeof(pqi), NULL); if (status < 0) { CloseHandle(h); return; } // Adres PEB równy NULL oznacza, iż prawdopodobnie mamy do // czynienia z 64-bitowym procesem, którego PEB znajduje się // poza zasięgiem 32-bitowej aplikacji (co również oznacza, że // nasza aplikacja jest 32-bitowa).W takim wypadku funkcja // ReadProcessMemory i tak nie byłaby w stanie odczytać // odpowiedniego fragmentu pamięci. if (pqi.PebBaseAddress == NULL) { CloseHandle(h); return; } // Pobierz zawartość struktury Process Environment Block zdalnego procesu. PEB peb; SIZE_T read_bytes; BOOL result = ReadProcessMemory(h, pqi.PebBaseAddress, &peb, sizeof(peb), &read_bytes); if (!result || read_bytes != sizeof(peb)) {
ze
CloseHandle(h); return; } // Pobierz zawartość struktury RTL_USER_PROCESS_PARAMETERS zdalnego procesu. RTL_USER_PROCESS_PARAMETERS upp; result = ReadProcessMemory(h, peb.ProcessParameters, &upp, sizeof(upp), &read_bytes); if (!result || read_bytes != sizeof(upp)) { CloseHandle(h); return; } // Zaalokuj odpowiedni bufor w celu pobrania linii poleceń. size_t length = upp.CommandLine.Length + 2; WCHAR *command_line_utf16 = new WCHAR[length]; memset(command_line_utf16, 0, length * sizeof(WCHAR)); // Pobierz linię poleceń do zaalokowanego bufora. result = ReadProcessMemory(h, upp.CommandLine.Buffer, command_line_utf16, length - 2, &read_bytes); if (!result || read_bytes != length - 2) { CloseHandle(h); return; } // Skonwertuj UTF-16 na rozszerzone ASCII, którego używamy. memset(buffer, 0, max_length); WideCharToMultiByte(CP_ACP, 0, command_line_utf16, -1, buffer, max_length - 1, NULL, NULL); CloseHandle(h); }
int main() { // Pobierz procesy obecne w systemie. HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); if (snapshot == INVALID_HANDLE_VALUE) { fprintf(stderr, "CreateToolhelp32Snapshot failed.\n"); return 1; } puts("
PID Thread Count Executable\n" "----------------------------------");
PROCESSENTRY32 entry; entry.dwSize = sizeof(PROCESSENTRY32); // Dla każdego procesu na liście wypisz informacje o nim. BOOL result = Process32First(snapshot, &entry); while (result) { char cmdline[4096]; fetch_cmdline(entry.th32ProcessID, cmdline, sizeof(cmdline)); printf("%6u %12u %-20s %s\n", static_cast(entry.th32ProcessID), static_cast(entry.cntThreads), entry.szExeFile, cmdline); result = Process32Next(snapshot, &entry); } CloseHandle(snapshot); return 0; } Celowo wskazałem na konkretne środowisko kompilacji, co wynika z użycia funkcji z tzw. Native API (niskopoziomowe API w systemach Windows,
w większości przekładające się bezpośrednio na wywołania systemowe jądra systemu) – NtQueryInformationProcess – która nie jest zdefiniowana w plikach nagłówkowych ani standardowych bibliotekach przystosowanych do linkowania podczas procesu kompilacji. Z tego względu należy odwołać się bezpośrednio do dynamicznej biblioteki ntdll.dll, w której ta funkcja się znajduje. Można to zrobić na dwa sposoby: pierwszy z nich zakłada skorzystanie z funkcji GetModuleHandle oraz GetProcAddress do uzyskania adresu funkcji w pamięci (jak wspominałem, ntdll.dll jest obecne w pamięci każdego procesu). Drugi sposób polega na skorzystaniu z możliwości linkera wchodzącego w skład MinGW GCC (lub MinGW-w64 GCC), który pozwala na bezpośrednie linkowanie o bibliotekę dynamiczną[120]. Biorąc to wszystko pod uwagę, kompilacja wygląda dość nietypowo: > g++ -Wall -Wextra cmdline.cc c:\windows\System32\ntdll.dll o cmdline.exe Po uruchomieniu otrzymujemy spis procesów wraz z argumentami, z jakimi zostały wywołane, o ile nasz użytkownik ma dostęp do ich pamięci: > cmdline ... 10188 3 gvim.exe C:\bin\gvim\vim73\gvim.exe c:\book\Procesy\cmdline.cc ... Więcej informacji o API dla debuggerów można znaleźć w oficjalnej dokumentacji danej platformy [23][24][25].
7.6. Dziedziczenie po procesie rodzicu W I części książki, pisząc o konsoli, wspominałem, iż proces potomny domyślnie dziedziczy po procesie rodzicu zmienne środowiskowe oraz katalog roboczy (choć proces rodzic może je również ustawić osobno dla procesu dziecka) – jak się okazuje, nie są to jedyne dziedziczone elementy środowiska. Tabela 2 zawiera listę przykładowych elementów, które dziecko dziedziczy po rodzicu lub które rodzic może osobno ustawić dla dziecka.
Tabela 2. Przykładowe elementy dziedziczone przez proces potomny [26][27]
Cecha
Domyślnie dziedziczone
Uwagi
Uchwyty i des kryptory
Zależy od s ys temu i rodzaju uchwytu
(Windows ) Dziedziczone s ą jedynie wybrane uchwyty z g rupy obiektów jądra (pliki, potoki itp.) i tylko jeś li dziedziczenie uchwytów zos tało wybrane w funkcji tworzącej nowy proces . (GNU/Linux) Niektóre rodzaje des kryptorów (w s zczeg ólnoś ci des kryptory plików oraz potoków) s ą domyś lnie dziedziczone, chyba że jawnie zos tała wybrana opcja zaniechania dziedziczenia. Inne rodzaje des kryptorów nie s ą dziedziczone (np. otwarte s trumienie katalog ów).
Zmienne ś rodowis kowe
Tak
W niektórych wypadkach (np. execve) wymag ane jes t ws kazanie konkretnej tablicy zmiennych ś rodowis kowych.
Katalog roboczy
Tak
Niektóre funkcje umożliwiają podanie inneg o katalog u roboczeg o dla dziecka.
Proces dziecko proces u debug owaneg o jes t debug owany
Zależy od s ys temu
(GNU/Linux) Domyś lnie nie. (Windows ) Domyś lnie tak.
Og raniczenia pamięci, czas u proces ora itp.
Tak
W pewnych, bardzo og raniczonych, przypadkach proces rodzic może s tworzyć proces dziecko bez niektórych og raniczeń.
Kons ola
Tak
Przynależnoś ć do daneg o proces ora[121]
Tak
7.7. Inne operacje na zewnętrznych procesach W podrozdziale 1.3, przy okazji tworzenia procesów, zaprezentowałem jedną z najprostszych operacji na uchwycie innego procesu, czyli oczekiwanie, aż proces się zakończy. Tabela 3 zawiera inne standardowe operacje wykonywane przy użyciu uchwytu do procesu lub jego identyfikatora, których do tej pory jeszcze nie opisywałem: Tabela 3. Inne przykładowe operacje na zewnętrznych procesach
Operacja
Przykładowa funkcja
Uwagi
Zabicie proces u
TerminateProcess (WinAPI) kill (GNU/Linux) Popen.kill (Python)
W przypadku s ys temów z rodziny GNU/Linux do wykonania operacji wys tarczy identyfikator proces u[122].
Pobranie dodatkowych informacji o proces ie
NtQueryInformationProcess (NTAPI) GetProcessAffinityMask (WinAPI) GetProcessTimes (WinAPI) QueryProcessCycleTime (WinAPI)
W przypadku s ys temów z rodziny GNU/Linux patrz ps eudokatalog /proc. WinAPI pos iada liczne funkcje o nazwach rozpoczynających s ię od „ GetProces s ” i „ QueryProces s ” , które zapewniają podobne informacje.
itd.
Patrz podrozdział 1.3.
Oczekiwanie na zakończenie proces u
WaitForSingleObject (WinAPI) waitpid (GNU/Linux) Popen.wait (Python)
Zmiana uprawnień związanych z dos tępem do proces u
SetSecurityInfo (WinAPI)
Domyś lnie użytkownicy (z wyjątkiem adminis tratorów) nie mają uprawnień do interakcji z proces ami innych użytkowników. Is tnieje jednak możliwoś ć zezwolenia wybranemu użytkownikowi lub g rupie na otwarcie proces u.
Inne
VirtualProtextEx (WinAPI) CreateRemoteThread (WinAPI)
Wymienione funkcje umożliwiają kolejno alokację pamięci oraz utworzenie wątku w kontekś cie inneg o proces u.
Chciałbym raz jeszcze podkreślić, że pliki wykonywalne, procesy i operacje na nich stanowią bardzo szeroki temat i tu opisałem jedynie ich podstawy, które są niezbędne do zrozumienia wątków opisanych w kolejnym rozdziale.
Ćwiczenia [PROC:verbose-list] Dla wybranego systemu operacyjnego stwórz program, który wypisze jak najwięcej informacji o wszystkich procesach uruchomionych w systemie. [PROC:debugger-ish]
Dla wybranego systemu operacyjnego stwórz niewielki debugger, który będzie monitorował wskazany proces i wypisywał informacje o pojawiających się zdarzeniach.
Bibliografia [1]
proc(5), Linux http://linux.die.net/man/5/proc [2] Process Explorer, https://technet.microsoft.com/en-
man
page, Microsoft,
http://coldwind.pl/s/c3r7
us/sysinternals/bb896653.aspx [3] VMMap, Microsoft, https://technet.microsoft.com/enus/library/dd535533.aspx [4] The Open Group Base Specifications Issue 7 – execute a file, IEEE, The Open Group, 2013, http://pubs.opengroup.org/onlinepubs/9699919799/functions/exec.html [5] Singleton (wzorzec projektowy), Wikipedia, https://pl.wikipedia.org/wiki/Singleton_(wzorzec_projektowy) [6] A.out, Wikipedia, https://en.wikipedia.org/wiki/A.out [7] Java(tm) Binary Kernel Support https://www.kernel.org/doc/Documentation/java.txt
for
Linux
v1.03,
[8] Wine, https://www.winehq.org/ [9] Kernel Support for miscellaneous (your favourite) Binary Formats v1.1, https://www.kernel.org/doc/Documentation/binfmt_misc.txt [10] Coldwind G., Data, data, data! (slajd 46, Case-study: mixer (RuCTF Quals 2014)), 2014, http://gynvael.coldwind.pl/?id=531 [11] Albertini A., Posters (PE101, etc), http://pics.corkami.com [12] PEview, http://wjradburn.com/software/ [13] VDSO, Linux Programmer's Manual, http://man7.org/linux/manpages/man7/vdso.7.html [14] skape, SharedUserData SystemCall Hook, 2005, http://uninformed.org/index.cgi? v=3&a=4&p=22 [15] Dependency Walker, Microsoft, http://www.dependencywalker.com/ [16] DLL Hell, Wikipedia, https://en.wikipedia.org/wiki/DLL_Hell
[17] Dependency hell, Wikipedia, https://en.wikipedia.org/wiki/Dependency_hell [18] Carrera E., A PE trick, the Thread Local Storage, 2007, http://blog.dkbza.org/2007/03/pe-trick-thread-local-storage.html [19] ReWolf, WOW64Ext Library, https://github.com/rwfpl/rewolf-wow64ext [20] ReWolf, Evolution of Process Environment Block (PEB), 2013, http://blog.rewolf.pl/blog/?p=573 [21] Moore S.A., Get Process Info with NtQueryInformationProcess, http://www.codeproject.com/Articles/19685/Get-Process-Info-withNtQueryInformationProcess [22] Liu W.J., HOWTO: Get the command line of a process, 2009, http://wj32.org/wp/2009/01/24/howto-get-the-command-line-of-processes/ [23] Debugging and Error Handling, Microsoft Developer Network, https://msdn.microsoft.com/en-us/library/windows/desktop/ee663265.aspx [24] ptrace(2), Linux man page, http://linux.die.net/man/2/ptrace [25] Java™ Platform Debugger Architecture (JPDA), Oracle, http://docs.oracle.com/javase/7/docs/technotes/guides/jpda/ [26] Inheritance, Microsoft Developer Network, https://msdn.microsoft.com/enus/library/windows/desktop/ms683463.aspx [27] execve(2), Linux man page, http://linux.die.net/man/2/execve [28] CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection'), Common Weakness Enumeration, The MITRE Corporation, https://cwe.mitre.org/data/definitions/78.html [29] Jurczyk M., „Jak napisać własny debugger w systemie Windows”, „Programista” 21-24.
Rozdział 8.
Wątki W poprzednim rozdziale wspomniałem, że w praktyce to właśnie wątki są jednostką wykonującą kod (execution unit). Z punktu widzenia programisty możliwość skorzystania z wielu wątków pozwala przede wszystkim na uproszczenie konstrukcji bardziej złożonych programów, a także zwiększenie ich wydajności i responsywności. Dodatkowo, w przypadku czasochłonnych obliczeń wymagających znacznej mocy procesora i możliwych do podzielenia na niezależne części, wątki umożliwiają zrównoleglenie obliczeń, a co za tym idzie skrócenie czasu ich trwania. Jest jednak druga strona medalu – możliwość jednoczesnego wykonywania kodu korzystającego z tych samych obiektów i struktur wprowadza szereg komplikacji i wymaga podjęcia dodatkowych kroków (względem programów jednowątkowych) w celu zapewnienia poprawnie zsynchronizowanego dostępu do używanych danych. Zaniedbanie tego aspektu może prowadzić do błędnych wyników obliczeń, zakleszczeń wątków (deadlock) paraliżujących działanie programu czy – w skrajnych wypadkach – błędów narażających bezpieczeństwo systemu użytkownika[123]; stąd też cały następny rozdział poświęcę właśnie na wytłumaczenie problemów i mechanizmów synchronizacji.
8.1. Tworzenie nowych wątków Rozdział o wątkach zaczniemy od ich tworzenia. W zależności od podejścia zastosowanego w danym języku programowania utworzenie wątku sprowadza się do jednej z dwóch operacji: Wywołania funkcji typu „stwórz wątek”, przyjmującej jako paramter odwołanie do funkcji, którą nowy wątek ma wykonać. Stworzenia nowej klasy dziedziczącej po klasie typu „wątek”, zaimplementowanie w niej głównej metody wątku, a następnie
stworzenia obiektu danej klasy i wywołania metody typu „uruchom wątek”. Tabela 1 zawiera spis rozwiązań oferowanych przez przykładowe języki bądź API systemowe, a w dalszej części podrozdziału znajduje się kilka przykładów tworzenia wątków za pomocą wybranych popularnych języków lub API systemów operacyjnych. Tabela 1. Przykładowe funkcje i klasy tworzące nowe wątki
Język, biblioteka lub system
Nazwa
T yp
Uwagi
WinAPI
CreateThread
Funkcja
C++
std::thread
Klas a
Dos tępna począws zy od s tandardu C++11. Pomimo że std::thread jes t klas ą, w praktyce jej użycie przypomina wywołanie funkcji. Konkretniej, tworząc nowy obiekt typu std::thread, w kons truktorze podaje s ię funkcję, którą ma uruchomić nowy wątek (oraz ewentualne parametry do niej).
C
thrd_create
Funkcja
Dos tępna począws zy od s tandardu C11.
POS IX Threads (lub pthreads )
pthread_create
Funkcja
Popularna biblioteka implementująca wątki wg s pecyfikacji POS IX, używana m.in. pod s ys temami z rodziny GNU/Linux. Dos tępna jes t
również pod inne s ys temy, np. Windows . Linux
clone
Funkcja
Python 2.7
threading.Thread
Klas a
Java
Thread
Klas a
Nis kopoziomowe wywołanie s ys temowe umożliwiające m.in. s tworzenie noweg o wątku.
Klas y Thread można użyć na dwa s pos oby: Jako klas y bazowej dla klas y wątku. Do s tworzenia wątku oparteg o na is tniejącym już obiekcie implementującym interfejs Runnable.
PHP
Thread
Klas a
Dos tępna w ramach rozs zerzenia pthreads, będąceg o wrapperem na ws pomnianą wcześ niej bibliotekę pthreads .
C i WinAPI Na widocznym poniżej kodzie zademonstrowano stworzenie nowego wątku w języku C, korzystając z WinAPI. W nowym wątku zostanie uruchomiona funkcja RunMeInANewThread: #include #include
DWORD WINAPI RunMeInANewThread(LPVOID data) { printf("I was run in a new thread with %p as data.\n", data); return 0xC0DE; } int main(void) { // Stworzenie nowego wątku. HANDLE h = CreateThread( NULL, 0, RunMeInANewThread, (LPVOID)0x12345678, 0, NULL); if (h == NULL) { fprintf(stderr, "Creating the second thread failed.\n"); return 1; } // Oczekiwanie na zakończenie nowego wątku. DWORD retval; WaitForSingleObject(h, INFINITE); GetExitCodeThread(h, &retval); printf("Second thread returned: %x\n", (unsigned int)retval); CloseHandle(h); return 0; } Kompilacja oraz uruchomienie: > g++ -Wall -Wextra newthread.c > a I was run in a new thread with 12345678 as data. Second thread returned: c0de Analizując powyższy kod, można zauważyć, iż prototyp funkcji uruchamianej w nowym wątku musi być następujący [1]:
DWORD WINAPI ThreadProc(LPVOID lpParameter); Jak już kilkukrotnie wspominałem, typ LPVOID to alias na void* używany w WinAPI. Bardziej tajemnicze może być jednak samo słowo kluczowe WINAPI, pojawiające się pomiędzy nazwą funkcji a typem zwracanym – w przypadku x8632 jest ono zdefiniowane jako __stdcall. Słowa kluczowe __stdcall, __cdecl, __fastcall
itp.
określają
tzw.
konwencję wywołania (calling convention) [2], czyli niskopoziomowy sposób przekazania argumentów do wywoływanej funkcji. Na przykład __stdcall wymaga, by argumenty funkcji były umieszczone na stosie, a wywołana funkcja jest odpowiedzialna za ich usunięcie ze stosu. W praktyce to, jak zdefiniowane są konkretne konwencje wywołania, jest istotne przede wszystkim podczas tworzenia kodu w języku asembler (oraz analizy kodu na poziomie asemblera); programując wysokopoziomowo, należy jedynie uważać, by zadeklarować odpowiednią konwencję w przypadkach, gdy jest ona inna od domyślnej. W przypadku języka C domyślną konwencją najczęściej jest __cdecl. W razie pomieszania konwencji wywołania kompilator powinien zgłosić błąd (lub ostrzeżenie), choć niestety w niektórych wersjach kompilatorów komunikat błędu nie jest zbyt klarowny – rozważmy np. następujący kod w przypadku kompilatora MinGW GCC 4.6.2: void func(void (*funcptr)(void)) { } void __stdcall stdfunc(void) { } int main(void) { // Funkcja func oczekuje wskaźnika na funkcję typu __cdecl, jednak // w parametrze otrzymuje funkcję typu __stdcall. func(stdfunc); return 0; } Po kompilacji otrzymujemy następujący, niezbyt precyzyjny komunikat błędu: > gcc badcall.c badcall.c: In function 'main':
badcall.c:5:3: warning: passing argument 1 of 'func' from incompatible pointer type [enabled by default] badcall.c:1:6: note: expected 'void (*)(void)' but argument is of type 'void (*)(void)' Na szczęście błąd został poprawiony w nowszych wersjach MinGW GCC, np. wersja 4.8.2 wyświetla następujący komunikat: [...] badcall.c:1:6: note: expected ‘void (*)(void)’ but argument is of type ‘void (__attribute__((__stdcall__)) *)(void)’ [...] C i pthreads Utworzenie nowego wątku, korzystając z API zgodnego ze specyfikacją POSIX [3][4], jest w zasadzie identyczne (z dokładnością do typów i nazw funkcji) jak w przypadku WinAPI. Zachęcam do porównania obu przypadków we własnym zakresie: #include #include void *run_me_in_a_new_thread(void *data) { printf("I was run in a new thread with %p as data.\n", data); return (void *)0xC0DE; } int main(void) { // Stworzenie nowego wątku. pthread_t th; int result = pthread_create( &th, NULL, run_me_in_a_new_thread, (void*)0x12345678); if (result != 0) { fprintf(stderr, "error: creating second thread failed.\n"); return 1; }
// Oczekiwanie na zakończenie nowego wątku. void *retval = NULL; pthread_join(th, &retval); printf("Second thread returned: %p\n", retval); return 0; } Kompilacja oraz uruchomienie: $ gcc -Wall -Wextra newpthread.c -lpthread $ ./a.out I was run in a new thread with 0x12345678 as data. Second thread returned: 0xc0de Python (2.7 oraz 3) W języku Python (2.7 oraz 3) korzysta się z metody tworzenia nowych wątków opartej na dziedziczeniu klasy bazowej – klasa dziedzicząca powinna zdefiniować metodę run, która zostanie wywołana wraz z utworzeniem nowego wątku. Przykładowy kod znajduje się poniżej: #!/usr/bin/python # -*- coding: utf-8 -*import threading class MyThread(threading.Thread): def __init__(self, data): super(MyThread, self).__init__() self.__data = data self.__retval = None def run(self): print("I was run in a new thread with %x as data." % self.__data) self.__retval = 0xC0DE
def get_retval(self): return self.__retval # Stworzenie nowego wątku. th = MyThread(0x12345678) th.start() # Oczekiwanie na zakończenie nowego wątku. th.join() print("Second thread returned: %x" % th.get_retval()) Uruchomienie oraz przebieg wykonania: > newpythread.py I was run in a new thread with 12345678 as data. Second thread returned: c0de Należy zaznaczyć, że wzorcowa implementacja języka Python – CPython – posiada tzw. Global Interpreter Lock [5], czyli mechanizm synchronizujący, który w praktyce sprawia, iż tylko jeden wątek może wykonywać kod bajtowy języka Python w tym samym czasie. O ile nie przeszkadza to w przypadku wątków zajmujących się przede wszystkim interakcją z otoczeniem (I/O bound), o tyle ma to wysoce negatywny wpływ na wątki obliczeniowe (CPU bound). Aby temu zaradzić, stosuje się podejście polegające na wykorzystaniu wielu oddzielnych procesów – w języku Python istnieje nawet dedykowany moduł – multiprocessing – ułatwiający ich użycie. Java Stworzenie nowego wątku w języku Java jest w zasadzie identyczne jak w języku Python. Zachęcam do porównania przykładowych kodów w obu językach: public class NewJavaThread extends Thread { private int data; private int retval; public void run() {
System.out.println("I was run in a new thread with " + Integer.toHexString(data) + " as data."); retval = 0xC0DE; } public int getReturnValue() { return retval; } public static void main(String args[]) { // Stworzenie nowego wątku. NewJavaThread th = new NewJavaThread(); th.data = 0x12345678; th.start(); // Oczekiwanie na zakończenie nowego wątku. while (true) { try { th.join(); break; } catch (InterruptedException e) { // Oczekiwanie zostało przerwane. W tym przypadku nie powinno // się to nigdy zdarzyć (drugi wątek nie robi nic, co mogłoby // spowodować przerwanie pierwszego wątku), niemniej // kompilatory Java mogą wymagać, by to przerwanie zostało // złapane. } } System.out.println("Second thread returned: " + Integer.toHexString(th.getReturnValue()));
} } Kompilacja oraz uruchomienie wyglądają następująco: > javac NewJavaThread.java > java NewJavaThread I was run in a new thread with 12345678 as data. Second thread returned: c0de Dodatkowo zaprezentowałem w przykładach, jak przekazać danemu wątkowi dane wejściowe oraz jak odebrać wyjściową wartość po zakończeniu wątku. W każdym przykładzie była to tylko jedna wartość na wejściu i wyjściu, ale w praktyce jest to wystarczające do przekazania wskaźnika bądź referencji do struktury zawierającej większą ilość danych. Mechanizm ten można również wykorzystać w języku C++ do powiązania wątku z obiektem, tak jak ma to miejsce w przypadku języków Python oraz Java. Jest to zaprezentowane na poniższym przykładzie (C++ i WinAPI): #include #include #include class MyThread { public: MyThread(int data) : data_(data) { } // Główna metoda nowego wątku. void ThreadProc() { printf("Proof that I have access to data: %x\n", data_); } static DWORD WINAPI ThreadProcWrapper(LPVOID obj) { MyThread *my_thread = static_cast(obj); my_thread->ThreadProc(); return 0;
} private: int data_; }; int main() { // Stworzenie nowego wątku. std::unique_ptr second_thread(new MyThread(0xCAFE)); HANDLE h = CreateThread(NULL, 0, MyThread::ThreadProcWrapper, second_thread.get(), 0, NULL); if (h == NULL) { fprintf(stderr, "Creating the second thread failed.\n"); return 1; } // Oczekiwanie na zakończenie nowego wątku. WaitForSingleObject(h, INFINITE); CloseHandle(h); return 0; }
8.2. Typy wątków i ich przełączanie Jak wspomniałem we wstępie do tej części książki, we współczesnych systemach operacyjnych zazwyczaj współistnieje obok siebie kilkaset wątków, które oczekują na wykonanie swojego kodu. O tym, któremu wątkowi zostanie przydzielony który logiczny procesor, decyduje planista systemowy (scheduler). Jest on również odpowiedzialny za przełączanie pomiędzy wątkami (context switching), co sprowadza się do następujących czynności[124]: 1. Wstrzymania pracy wątku wywłaszczanego.
2. Zapisania jego kontekstu. 3. Wybrania kolejnego, gotowego do pracy wątku. 4. Wczytania jego kontekstu. 5. Wznowienia jego wykonania. Przełączanie wątków odbywa się wiele razy na sekundę (dokładna liczba zależy od konkretnego systemu, ilości uruchomionych aplikacji itp.) – można to sprawdzić, korzystając z kilku przykładowych narzędzi: (Windows) Monitor wydajności (perfmon) [6] i licznik System/Przełączanie kontekstu/s (patrz rys. 1). (Windows) Process Explorer (o którym wspominałem m.in. w poprzednim rozdziale) oraz kolumny Context Switches i Context Switches Delta z grupy Process Performance (patrz rys. 2). (GNU/Linux) vmstat oraz kolumna cs (patrz rys. 3). (GNU/Linux) pidstat uruchomiony z parametrem -w oraz kolumny cswch/s (context switches per second) oraz nvcswch/s (non-voluntary context switches per second).
Rysunek 1. Przykładowy wykres wygenerowany przez Monitor wydajności dla licznika System/Przełączanie kontekstu/s
Rysunek 2. Przykładowe statystyki ilości przełączeń wątków na proces w Process Explorer
Rysunek 3. Przykładowe statystyki pokazywane przez program vmstat Przełączenie wątku następuje w jednej z dwóch sytuacji: Wątek wykorzysta cały przydzielony kwant czasu procesora (time quantum lub time slice) – jest to tzw. przymusowe wywłaszczenie (nonvoluntary preemption). Wątek dobrowolnie rezygnuje z reszty przydzielonego czasu, np. z powodu oczekiwania na zewnętrzne dane – jest to nazywane dobrowolnym wywłaszczeniem (voluntary preemption). Z tego względu z punktu widzenia programowania wątki dzieli się na dwie logiczne[125] grupy: CPU bound – wątek ograniczony przez procesor, czyli taki, który w większości przypadków wykorzystuje cały przydzielony mu czas procesora. Jest to typowe dla wątków wykonujących czasochłonne obliczenia.
I/O bound – wątek ograniczony przez wyjście/wejście, czyli taki, który w większości przypadków nie wykorzystuje przydzielonego mu kwantu czasu z uwagi na oczekiwanie na zakończenie blokującej (synchronicznej) operacji związanej z interakcją z użytkownikiem, zewnętrznym procesem lub (zazwyczaj pośrednio) urządzeniem wejścia/wyjścia. W tę grupę wpadają również wątki dobrowolnie przechodzące w stan uśpienia w efekcie wywołaniu funkcji typu sleep (GNU/Linux), Sleep (WinAPI), time.sleep (Python) lub Thread.sleep (Java)[126]. Zdecydowana większość wątków w systemie należy do drugiej grupy. Jeśli wszystkie wątki w systemie są tego typu, oprogramowanie prezentujące statystyki dotyczące użycia procesora raportuje wartości w okolicach 5%. Pojawienie się choćby jednego wątku typu CPU bound w systemie natychmiast zwiększa zużycie do 100% jednego logicznego procesora (co w przypadku istnienia wielu logicznych procesorów przekłada się na odpowiednio mniejsze obciążenie całego systemu)[127]. Jeśli liczba wątków typu CPU bound dorówna ilości logicznych procesorów, system często staje się mniej responsywny, ale nie powoduje to jego zawieszenia. Podczas tworzenia oprogramowania dobrze postrzegane jest dbanie o to, by wątek nie zajmował całej dostępnej mocy procesora, chyba że jest to konieczne lub wynika ze specyfiki danej aplikacji. Oznacza to również, iż nie stanowi problemu istnienie nawet kilkudziesięciu wątków w ramach danej aplikacji, pod warunkiem, że są one I/O bound. Poniżej znajduje się lista przykładowych operacji, które często powodują przejście wątku w stan oczekiwania na a w konsekwencji dobrowolne wywłaszczenie:
dane
lub
dalsze
instrukcje,
Synchroniczny odczyt i zapis z/do pliku, potoku, gniazda sieciowego itp. Synchroniczne oczekiwanie na nowe zdarzenie związane z kanałem komunikacyjnym. Oczekiwanie na muteks, zdarzenie i semafor. Oczekiwanie na zakończenie innego procesu lub wątku. Oczekiwanie na interakcję ze strony użytkownika. Przykładem kodu CPU bound jest tzw. aktywna pętla[128], w najprostszym przypadku jest pustą nieskończoną pętlą (C++):
która
int main() { while (true) { } // Ewentualnie "for(;;);" return 0; } Dodanie do tej pętli instrukcji oczekującej lub usypiającej wątek na określony czas zmieni wątek w I/O bound, który nie spowoduje wzrostu obciążenia systemu ani spadku jego responsywności: #if defined(__unix__) # include # define SLEEP(x) usleep(x * 1000) #elif defined(WIN32) || defined(_WIN32) # include # define SLEEP(x) Sleep(x) #endif int main() { while (true) { SLEEP(10); } return 0; } Rysunek 4 zawiera cztery zrzuty ekranu prezentujące obciążenie systemu działającego pod kontrolą ośmiu logicznych procesorów, w czterech przypadkach (od góry): Normalnego obciążenia systemu[129]. Uruchomionego jednowątkowego programu z aktywną pętlą. Uruchomionego jednowątkowego programu z aktywną pętlą, przy czym powstały proces został przypisany do pierwszego logicznego procesora. Uruchomionego jednowątkowego programu z pętlą z poleceniem uśpienia wątku.
W drugim przypadku doskonale widać, iż wątek jest przełączany pomiędzy czterema różnymi logicznymi procesorami, choć najczęściej jest wykonywany przez siódmy z kolei. W trzecim przypadku wątek został przypisany pierwszemu logicznemu procesorowi, co spowodowało wzrost jego użycia do 100%, podczas gdy użycie innych aktywnych procesorów pozostało na poziomie normalnego obciążenia systemu.
Rysunek 4. Zużycie procesora zaprezentowane przez Menedżer Zadań systemu Windows 7 dla czterech przypadków
8.3. Kontekst wątku Kontekstem wątku najczęściej nazywany jest przede wszystkim pełen zestaw rejestrów procesora istotnych z punktu widzenia wykonania kodu w trybie użytkownika (w formie „uśpionej” jest to kopia wartości rejestrów). W zależności
od systemu jeden lub kilka rejestrów zawiera informacje o lokacji pozostałych elementów, które są niekiedy zaliczane do kontekstu (z przykładami dla systemu Windows na platformie x86-64): Informacje specyficzne dla wątku. W przypadku systemu Windows są one zawarte we wspomnianej wcześniej strukturze Thread Environment Block (TEB), na którą „wskazuje”[130] rejestr segmentowy GS. Stos wątku. Na wierzchołek stosu wskazuje rejestr RSP, a ponadto dokładna informacja o wielkości stosu danego wątku zawarta jest w jego strukturze TEB. Zmienne lokalne dla wątku (thread-local variables). TEB posiada miejsce na 64 zmienne lokalne wątku, choć w praktyce często są to wskaźniki na dodatkową pamięć zaalokowaną na ich potrzeby. API systemowe często udostępnia zestaw funkcji pozwalających na wgląd w kontekst danego wątku – jest to przydatne przede wszystkim podczas tworzenia debuggerów. Dokładna struktura kontekstu wątku zależy od danej architektury procesora (tj. zestawu rejestrów, jaki jest przez nią zdefiniowany), należy więc mieć na uwadze, iż kod korzystający z kontekstu będzie z definicji nieprzenośny i będzie wymagał dodatkowej pracy podczas portowania[131] aplikacji. W przypadku WinAPI dokładną strukturę kontekstu można znaleźć w pliku WinNT.h pod nazwą CONTEXT, a w przypadku systemów z rodziny GNU/Linux w /usr/include/sys/user.h pod nazwą user_regs_struct. Kontekst wątku z poziomu WinAPI i C++ [BEYOND] Poniżej przedstawiam przykładowy kod, tworzący dodatkowy wątek, w którym wykonywana będzie pewna nieskończona pętla (napisana w języku asembler x86-32). Konkretniej pętla ta będzie oczekiwać, aż wartość jednego z rejestrów procesora – EAX – będzie różna od liczby 0x12345678, na którą to wartość rejestr EAX zostanie ustawiony przed pętlą. Ponieważ jedyną operacją wewnątrz pętli jest samo sprawdzenie tego warunku, w obrębie normalnego działania wątku pętla ta nigdy nie powinna się zakończyć. W naszym przypadku będzie jednak inaczej – korzystając z bazowego wątku, pobierzemy kontekst drugiego z nich, zmienimy w nim wartość EAX na inną i poczekamy na jego zakończenie, co będzie miało miejsce, jedynie gdy wartość EAX faktycznie uległa zmianie.
W przypadku współczesnych wersji systemu Windows w celu swobodnego operowania na kontekście wątku wymagane jest jego wstrzymanie – w przedstawionym poniżej przykładzie odpowiedzialna jest za to funkcja API SuspendThread, której towarzyszy odwrotna funkcja wznawiająca wątek – ResumeThread: #include #include #define UNUSED(a) (void)a HANDLE ev_thread_ready; DWORD WINAPI InfiniteLoop(LPVOID unused) { UNUSED(unused); puts("[2] Second thread ready! Entering infinite loop."); // Dajmy znać pierwszemu wątkowi, że ten wątek wystartował. SetEvent(ev_thread_ready); // Nieskończona pętla. #ifdef __GNUC__ __asm( ".intel_syntax noprefix\n" "mov eax, 0x12345678\n" "1:\n" " cmp eax, 0x12345678\n" " je 1b\n" ".att_syntax\n"); #endif // Powyższy kod odpowiada następującemu pseudo-kodowi: // eax = 0x12345678; // while(eax == 0x12345678) { } // Wersja dla Microsoft Visual C++. #ifdef _MSC_VER __asm {
mov eax, 0x12345678 infloop: cmp eax, 0x12345678 je infloop } #endif puts("[2] The infinite loop has ended!"); return 0; } int main(void) { ev_thread_ready = CreateEvent(NULL, FALSE, FALSE, NULL); // Wyłącz buforowanie stdout - w tym programie zależy nam na jak // najszybszym przekazaniu danych na standardowy strumień // wyjścia konsoli. setvbuf(stdout, NULL, _IONBF, 0); puts("[1] Creating a new thread."); HANDLE h = CreateThread(NULL, 0, InfiniteLoop, NULL, 0, NULL); // Poczekajmy, aż drugi wątek będzie gotowy (da nam znać // za pomocą zdarzenia ev_thread_ready); WaitForSingleObject(ev_thread_ready, INFINITE); CloseHandle(ev_thread_ready); puts("[1] Second thread said it's ready. Suspending it..."); bool retry = true; do { SuspendThread(h); CONTEXT ctx;
ctx.ContextFlags = CONTEXT_FULL;
// Pobierz cały
kontekst. // Wątek jeszcze nie jest wstrzymany [7], więc funkcja // GetThreadContext może za pierwszym razem się nie powieść. while (!GetThreadContext(h, &ctx)) { Sleep(0); } // Jeśli w rejestrze EAX nie ma wartości 0x12345678, // wstrzymaliśmy wątek zbyt wcześnie. W przeciwnym wypadku // można przystąpić do zmiany wartości EAX. if (ctx.Eax == 0x12345678) { ctx.Eax = 0xDEADC0DE; // Dowolna inna wartość. SetThreadContext(h, &ctx); retry = false; puts("[1] Changed EAX to 0xDEADC0DE!"); } else { puts("[1] Suspended thread too early. Retrying."); } ResumeThread(h); } while(retry); // Poczekajmy, aż drugi wątek skończy działanie. WaitForSingleObject(h, INFINITE); CloseHandle(h); puts("[1] The end."); return 0; } Kompilacja i uruchomienie powyższego kodu przebiegają następująco: > cl /W4 context.cc
Microsoft (R) 32-bit C/C++ Optimizing Compiler Version 16.00.30319.01 for 80x86 Copyright (C) Microsoft Corporation.
All rights reserved.
context.cc Microsoft (R) Incremental Linker Version 10.00.30319.01 Copyright (C) Microsoft Corporation. All rights reserved. /out:context.exe context.obj > context.exe [1] [2] [1] [1]
Creating a new thread. Second thread ready! Entering infinite loop. Second thread said it's ready. Suspending it... Changed EAX to 0xDEADC0DE!
[2] The infinite loop has ended! [1] The end. > g++ -Wall -Wextra context.cc > a [1] Creating a new thread. [2] Second thread ready! Entering infinite loop. [1] [1] [2] [1]
Second thread said it's ready. Suspending it... Changed EAX to 0xDEADC0DE! The infinite loop has ended! The end.
Na marginesie dodam, że znając specyfikę wygenerowanego przez kompilator niskopoziomowego kodu, można by pokusić się o usunięcie wstawki asemblera i skorzystać jedynie z języka C++ w celu napisania pętli porównującej – w końcu ostatecznie kompilator tłumaczy kod C++ na asembler, a więc pętla porównująca napisana w C++ również de facto korzystałaby z rejestrów procesora. Byłoby to jednak bardzo ryzykowne, gdyż wiele czynników ma wpływ na to, jaki konkretnie rejestr zostanie wybrany oraz czy porównanie faktycznie będzie miało miejsce[132].
Inną kwestią, na którą chciałbym zwrócić uwagę, są użyte dyrektywy preprocesora (#ifdef __GNUC__ i #ifdef _MSC_VER). Przyjęło się, że każdy kompilator C lub C++ definiuje w przestrzeni nazw preprocesora pewne makra, których obecność mówi, iż to właśnie ten kompilator jest użyty do kompilacji. Z tego względu można sprawdzić ich obecność i włączyć fragment kodu w skład kodu źródłowego jedynie w wypadku, gdy mamy do czynienia z konkretnym kompilatorem (w przypadku powyższego kodu zdefiniowałem oddzielną wstawkę asemblera dla MinGW GCC oraz Microsoft Visual C++). W Internecie można znaleźć zagregowane listy makr definiowanych przez różne kompilatory [8][9].
8.4. Zmienne lokalne dla wątku Zmienne lokalne dla wątku są ciekawym konstruktem z pogranicza zmiennych lokalnych oraz globalnych. Z założenia identyfikator zmiennej lokalnej dla wątku jest widoczny w globalnej przestrzeni nazw (lub jest przekazany w inny sposób do wszystkich, bądź wybranych, wątków), ale w praktyce prowadzi on do różnych instancji zmiennej, w zależności od wątku, który skorzystał z danego identyfikatora. Mówiąc inaczej, każdy wątek dysponuje własnym, właściwym sobie egzemplarzem zmiennej lokalnej wątku; skorzystanie z identyfikatora zmiennej lokalnej dla wątku przekierowuje wątek zawsze na jego własną instancję danej zmiennej. Przykład użycia zmiennej lokalnej dla wątku jest zaprezentowany poniżej (C++11): #include #include thread_local int thread_var; // C11, C++11 // Starsze wersje GCC nie obsługujące w pełni standardów C11 i C++11 posiadały inne // słowo kluczowe oznaczające zmienne lokalne dla wątku, dostępne // w ramach rozszerzenia języka:
//
__thread int thread_var;
// Analogicznie było w przypadku Microsoft Visual C++, który udostępniał // następujący mechanizm oznaczenia zmiennych lokalnych wątku: // __declspec(thread) int thread_var; void print_thread_var() { printf("[2] Initial thread_var value: %u\n", thread_var); thread_var = 8765; printf("[2] Changed thread_var value: %u\n", thread_var); } int main(void) { thread_var = 1234; printf("[1] Initial thread_var value: %u\n", thread_var); // Uruchomienie nowego wątku, tym razem korzystając z klasy // std::thread z C++11. std::thread second_thread(print_thread_var); second_thread.join(); printf("[1] Final thread_var value: %u\n", thread_var); return 0; } Kompilacja w przypadku systemów GNU/Linux i kompilatora GCC wymaga podania dodatkowego parametru -pthread, co nie jest konieczne w przypadku nowych wersji MinGW GCC. Sam proces kompilacji i uruchomienia przebiega następująco: $ g++ -std=c++11 threadlocal.cc -pthread $ ./a.out [1] Initial thread_var value: 1234 [2] Initial thread_var value: 0 [2] Changed thread_var value: 8765 [1] Final thread_var value: 1234
Zgodnie z tym, co pisałem wcześniej, oba wątki operują na różnych zmiennych, pomimo korzystania z tego samego identyfikatora obecnego w globalnej przestrzeni nazw. Mechanizm zmiennych lokalnych dla wątków jest oferowany przez większość współczesnych języków programowania. Tabela 2 zawiera listę przykładowych słów kluczowych lub funkcji, które udostępniają zmienne lokalne dla wątku. Tabela 2. Przykładowe mechanizmy udostępniające zmienne lokalne dla wątku
Język, biblioteka lub system
Słowo kluczowe, funkcja lub klasa
Uwagi
WinAPI
TlsAlloc TlsGetValue TlsSetValue TlsFree
Zmienne lokalne dla wątku przechowywane w s trukturze TEB. Ich liczba jes t og raniczona do 64 wartoś ci o wielkoś ci ws kaźnika na danej platformie. Mechanizm ten jes t zazwyczaj wewnętrznie wykorzys tywany przez implementacje innych języków (przy czym najczęś ciej w TEB umies zczają one jedynie adres wewnętrznej s truktury opis ującej zmienne lokalne dla wątku).
POS IX Threads
pthread_key_create pthread_getspecific pthread_setspecific pthread_key_delete
Mechanizm bardzo zbliżony do powyżs zeg o.
C++11
thread_local
S łowo kluczowe okreś lające zmienną jako zmienną lokalną dla wątku o s tatycznym czas ie is tnienia (tj. is tniejącą przez cały czas działania prog ramu).
C11
thread_local _Thread_local
Jak wyżej.
C, C++ (Micros oft V is ual C++)
__declspec(thread)
Atrybut zmiennej działający identycznie jak powyżs ze s łowa kluczowe. Oferowany przez kompilator Micros oft V is ual C++.
C, C++ (GCC)
__thread
S łowo kluczowe działające jak opis ane wyżej. Oferowane przez kompilatory z rodziny GCC.
Python (2.7)
threading.local
Klas a, której obiekty s ą dla wątku lokalnymi s łownikami.
Java
ThreadLocal
Klas a s zablonowa, która pozwala tworzyć obiekty lokalne dla wątku.
8.5. Pula wątków W przypadku większych aplikacji, szczególnie tych obsługujących zewnętrzne zdarzenia, które mogą pojawić się w nieznanej liczbie równocześnie (przykładem może być serwer HTTP), korzysta się z tzw. puli wątków (thread pool), czyli zestawu stworzonych wcześniej wątków, które przebywają w stanie uśpienia i są budzone do obsługi konkretnego zdarzenia. Podejście to jest szybsze niż tworzenie za każdym razem nowego wątku, ponieważ każdorazowo oszczędzany jest czas potrzebny na alokację struktur z nimi związanych oraz ich inicjalizację. Jednocześnie pule zapewniają ograniczenie obciążenia i liczby stworzonych wątków w przypadku bardzo dużej liczby zdarzeń, gdyż zapewniają, że nigdy nie powstanie więcej wątków do ich obsługi, niż wynosi wielkość puli. W takim przypadku zdarzenia i związane z nimi zadania będą kolejkowane i obsługiwane w miarę zwalniających się wątków z puli. Współczesne języki programowania lub standardowe biblioteki często oferują pule wątków, dzięki czemu nie jest konieczna ich ręczna implementacja. Poniżej znajduje się przykład użycia puli w języku Java, w którym tworzona jest pula składająca się z czterech wątków, która następnie otrzymuje do wykonania 80 zadań (polegających na „nicnierobieniu” przez określoną ilość czasu).
Jednocześnie główny wątek co 50 milisekund odpytuje zadania o to, czy zostały wykonane, i wypisuje aktualny stan na standardowe wyjście. Sam kod wygląda następująco: import java.util.concurrent.Executors; import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; class PoolTest { public static void main(String args[]) { // Stworzenie nowej puli wątków. ExecutorService pool = Executors.newFixedThreadPool(4); // Zlecenie 80 zadań puli. Tylko 4 zadania naraz będą aktywne. // Metoda submit zwraca obiekt implementujący interfejs Future, // który można odpytać, czy dane zadanie zostało już wykonane. // Alternatywnie można by użyć prostszej metody execute. Future[] tasks = new Future[80]; for (int i = 0; i < 80; i++) { tasks[i] = pool.submit(new MyThread(i)); } // Zamknij przyjmowanie nowych zadań do puli i usuń pulę po // zakończeniu wszystkich zadań. pool.shutdown(); // Odpytaj po kolei każde zadanie, aż wszystkie z nich // się nie zakończą. while (!pool.isTerminated()) { // Sprawdź i wyświetl stan wszystkich zadań. String s = ""; for (int i = 0; i < 80; i++) {
s += tasks[i].isDone() ? "D" : "."; } System.out.println(s); // Poczekaj 50 ms przed kolejną iteracją pętli. try { Thread.sleep(50); } catch (InterruptedException e) { // W tym programie żaden wątek nie przerywa innego, // więc ten blok nigdy się nie wykona. } } System.out.println("All done!"); } } class MyThread implements Runnable{ private int data; MyThread(int data) { this.data = data; } public void run() { try { // Poczekaj pewien czas przed zakończeniem zadania. Thread.sleep(25 * (this.data % 7)); } catch (InterruptedException e) { // W tym programie żaden wątek nie przerywa innego, więc to się // nigdy nie wykona. } } }
Przykład kompilacji oraz wykonania znajduje się poniżej – widać na nim, jak wątki z puli po kolei realizują kolejne zadania:
> javac PoolTest.java > java PoolTest D.................................................................
DDD............................................................... DDDDD..DD.........................................................
DDDDDD.DDD........................................................ DDDDDDDDDDD...D................................................... DDDDDDDDDDDD..DD..................................................
DDDDDDDDDDDDD.DDD................................................. DDDDDDDDDDDDDDDDDD...DD........................................... DDDDDDDDDDDDDDDDDDDD.DDD.......................................... DDDDDDDDDDDDDDDDDDDD.DDDD......................................... DDDDDDDDDDDDDDDDDDDDDDDDDD..DD....................................
DDDDDDDDDDDDDDDDDDDDDDDDDDD.DDD................................... DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD...D.............................. DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD..DD.............................
DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD.DDDD........................... DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD...DD...................... DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD.DDD..................... DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD...D................
DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD..DD............... DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD.DDD.............. DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD...D.........
DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD.DD........ DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD.DDDD...... DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD..DD. DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD.DDD DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD
DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD
All done! Podział wątków na włókna [BEYOND] Włókna (fibers) są rzadziej stosowanym mechanizmem niż wątki, niemniej jednak istotnym m.in. w przypadku aplikacji serwerowych, od których wymagana jest duża wydajność i o których zakłada się, że będą starać się w pełni wykorzystywać dostępną moc obliczeniową procesora. Można by pokusić się wręcz o stwierdzenie, że przez umiejętne wykorzystanie operacji asynchronicznych włókna umożliwiają zmianę wątków I/O bound w wątki CPU bound, zwiększając jednocześnie ich responsywność na zdarzenia z zewnątrz. Innym zastosowaniem włókien są wszelkiego rodzaju generatory z bardzo rozbudowanym kontekstem danej operacji – w takim wypadku z punktu widzenia programisty wygodniejsze może być użycie włókien do zachowania kontekstu między generowaniem kolejnych danych. W praktyce włókno jest konstrukcyjnie zbliżone do wątku w tym sensie, iż posiada własny kontekst oraz stos i jest uznawane za oddzielną jednostkę wykonującą. Niemniej jednak, w przeciwieństwie do wątków, które są przełączane przez planistę systemowego, włókna same (jawnie) decydują zarówno o momencie przełączenia wykonania na inne włókno, jak i o wyborze, na które włókno ma nastąpić przełączenie. Zarówno wykonanie danego włókna, jak i przełączenia pomiędzy włóknami realizowane są w czasie pracy danego wątku, a więc de facto wątek cały czas korzysta z przydzielonego czasu procesora do wykonywania oddzielnych, wybranych przez programistę zadań i nie rezygnuje przedwcześnie z przydzielonego mu kwantu czasu procesora (patrz również rys. 5). Co więcej, przełączenie kontekstu pomiędzy dwoma włóknami jest zazwyczaj dużo szybsze niż przełączenie pomiędzy dwoma wątkami.
Rysunek 5. Wątek z czterema włóknami
Mechanizm włókien jest oferowany m.in. przez WinAPI (funkcje: CreateFiber, SwitchToFiber, ConvertThreadToFiber) [10]. Zbliżonymi do nich mechanizmami są tzw. współprogramy (coroutine) oferowane m.in. przez języki Python 3 oraz C++ w ramach biblioteki Boost [11][12]. Po lekturze tego rozdziału czytelnik powinien mieć pewne rozeznanie, jeśli chodzi o zarządzanie wątkami. Niemniej jednak tworzenie kodu wielowątkowego wymaga jego synchronizacji, która jest dokładnie omówiona w następnym rozdziale.
Bibliografia [1] ThreadProc callback function, Microsoft Developer Network, https://msdn.microsoft.com/enus/library/windows/desktop/ms686736.aspx [2] Fog A., Calling conventions for different C++ compilers and
operating
systems,
http://coldwind.pl/s/c3r8
2014,
http://www.agner.org/optimize/calling_conventions.pdf [3] Draft Standard for Information Technology – Portable Operating System Interface (POSIX), IEEE, The Open Group, 2007, http://www.openstd.org/jtc1/sc22/open/n4217.pdf [4] The Open Group Base Specifications Issue 7, IEEE Std 1003.1™, 2013 Edition, IEEE, The Open Group, http://pubs.opengroup.org/onlinepubs/9699919799/ [5] GlobalInterpreterLock, The Python Wiki, https://wiki.python.org/moin/GlobalInterpreterLock [6] Monitor wydajności systemu Windows – omówienie, Microsoft TechNet, https://technet.microsoft.com/pl-pl/library/cc749154.aspx [7] von Löwis M., What does SuspendThread really do?, 2009, http://www.dcl.hpi.unipotsdam.de/research/WRK/2009/01/what-does-suspendthread-really-do/ [8] Pre-defined C/C++ Compiler Macros, http://sourceforge.net/p/predef/wiki/Home/ [9]
Pre-defined C/C++ Compiler Macros http://sourceforge.net/p/predef/wiki/Compilers/ [10] Coldwind G., Włókna w nici, czyli fibers http://gynvael.coldwind.pl/?id=22
– vs
Compilers, threads,
2008,
[11] D'Souza A., Coroutines in C++, 2013, http://aldrin.co/coroutine-basics.html [12] Schäling B., Chapter 51. Boost.Coroutine, http://theboostcpplibraries.com/boost.coroutine
Rozdział 9.
Synchronizacja W poprzednim rozdziale zasygnalizowałem, że problem synchronizacji dostępu do zasobów występuje już w przypadku aplikacji jednowątkowych działających na systemach wieloprocesowych, czyli takich, w których wiele programów jest uruchomionych jednocześnie. Typowym przykładem jest utworzenie pliku, ale tylko w przypadku, gdy ten nie istnieje – rozważmy zaprezentowany poniżej fragment kodu w języku Python: fname = "/writable/file/path/example_file" if not os.path.isfile(fname): f = open(fname, "w") Kod sprowadza się do dwóch kroków: 1. Sprawdzenia, czy plik istnieje. 2. Utworzenia pliku, jeśli nie istnieje. Jako że mamy do czynienia z systemem wieloprocesorowym, w tle mogą działać równocześnie inne aplikacje. Nic nie stoi na przeszkodzie, by jedna z nich utworzyła plik w tej samej lokacji co przedstawiony kod. To z kolei może doprowadzić do sytuacji, w której rozważany plik jest tworzony przez inny proces w momencie, gdy wykonanie wspomnianego kodu znajduje się pomiędzy pierwszym a drugim krokiem – a więc system operacyjny już oznajmił, że plik nie istnieje, ale sam plik nie został jeszcze przez nas stworzony. Ogólna klasa błędów tego typu nazywana jest „sytuacją wyścigu” (race condition), a konkretny błąd, w którym dopuszcza się do sytuacji wyścigu pomiędzy sprawdzeniem a wykorzystaniem danej informacji, jest klasyfikowany jako TOCTTOU (time of check to time of use). W niektórych wypadkach błędy typu race condition mogą skutkować konsekwencjami związanymi z bezpieczeństwem użytkownika lub systemu – dotyczy to w szczególności sytuacji, w których dwa procesy o różnych uprawnieniach mogą wejść w interakcję z tym samym zasobem. Trzymając się
nadal systemu plików, typowym przykładem jest tworzenie plików tymczasowych w nieprawidłowy sposób [1] – rozważmy działanie pierwszej fazy przykładowego dwufazowego instalatora aplikacji: 1. Stwórz katalog tymczasowy w katalogu C:\Windows\Temp. 2. Zmień uprawnienia do katalogu, tak by żaden inny użytkownik nie miał do niego dostępu. 3. Wypakuj pliki drugiej części instalatora do stworzonego i zabezpieczonego katalogu. 4. Uruchom plik wykonywalny drugiej fazy instalacji z katalogu tymczasowego. Podobnie jak w poprzedniej sytuacji, przez krótką chwilę[133] pomiędzy pierwszym a drugim krokiem inny użytkownik ma możliwość utworzenia pliku w tymczasowym katalogu[134] – wynika to z domyślnych uprawnień: > mkdir c:\windows\temp\phase2 > icacls c:\windows\temp\phase2 c:\windows\temp\phase2 BUILTIN\Users:(I)(CI)(S,WD,AD,X) BUILTIN\Administrators:(I)(OI)(CI)(F) NT AUTHORITY\SYSTEM:(I)(OI)(CI)(F) mypc\myuser:(I)(F) CREATOR OWNER:(I)(OI)(CI)(IO)(F) mypc\myuser:(I)(OI)(CI)(IO)(F) W
powyższym
listingu
mówi
o
tym
linia
BUILTIN\Users:(I)(CI)
(S,WD,AD,X)– dowolny użytkownik może tworzyć pliki (WD – write data/add file) oraz podkatalogi (AD – append data/add subdirectory). Stwarza to pewne możliwości dla złośliwego użytkownika (atakującego), w zależności od dokładnego działania pierwszej i drugiej fazy instalatora, które pozwolą na wykonanie dostarczonego przez siebie kodu, np.: Zakładając, że pierwsza faza nieprawidłowo obsługuje sytuację istnienia jednego z rozpakowywanych plików (tj. proces instalacji jest kontynowany, nawet jeśli plik docelowy już istniał), atakujący może utworzyć plik o nazwie identycznej z nazwą pliku wykonywalnego drugiej fazy. W tym wypadku przechodząc do drugiej fazy, instalator uruchomi de facto kod atakującego.
Zakładając, że plik wykonywalny drugiej fazy (lub jedna z używanych przez niego, rozpakowanych bibliotek dynamicznych) importuje bibliotekę DLL obecną domyślnie w systemie, która jednak nie znajduje się na liście KnownDlls[135] (np. version.dll lub wsock32.dll), atakujący może stworzyć plik DLL o takiej samej nazwie w katalogu tymczasowym. W takiej sytuacji w momencie uruchomienia drugiej fazy instalatora w miejsce biblioteki systemowej zostanie wczytana biblioteka dynamiczna obecna w katalogu aplikacji, co spowoduje wykonanie kodu dostarczonego przez atakującego[136].
Tych problemów można by uniknąć, korzystając z oferowanych przez system operacyjny możliwości połączenia pierwszego i drugiego kroku (w obu przypadkach) w jeden, dzięki czemu uzyskałyby one wymaganą atomowość, tj. z punktu widzenia innych procesów byłyby to pojedyncze operacje, przez co sytuacja wyścigu nie miałaby szansy zaistnieć. Omawiane problemy dotyczą wyłącznie przypadków interakcji procesu ze współdzielonymi komponentami oferowanymi przez system operacyjny. W przypadku wątków w obrębie jednego procesu (jak również w przypadku procesów współdzielących pamięć) do sytuacji wyścigu może dochodzić również podczas korzystania z tych samych zmiennych, w tym obiektów, tablic itp. Na przykład, jeśli dwa wątki w tym samym czasie będą modyfikować „niesynchronizowany” obiekt słownika, może to spowodować błędy w jego wewnętrznym stanie, co z kolei może prowadzić do niepoprawnych wyników (np. braku przed chwilą dodanego wpisu) lub wręcz zakończenia aplikacji z błędem. Zachowanie to można przetestować w praktyce, np. za pomocą poniższego kodu (C++11): #include #include #include #include std::unordered_map g_dict; const char *g_keys[] = {
"k1", "k2", "k3", "k4", "k5", "k6" }; DWORD WINAPI Purger(LPVOID data) { unsigned int poor_mans_random = 647; for (;;) { g_dict.erase(g_keys[poor_mans_random % 6]); poor_mans_random = (poor_mans_random * 4967 + 1777) % 1283; } return 0; } DWORD WINAPI Adder(LPVOID data) { unsigned int poor_mans_random = 499; for (;;) { g_dict[g_keys[poor_mans_random % 6]] = poor_mans_random; poor_mans_random = (poor_mans_random * 4967 + 1777) % 1283; } return 0; } int main(void) { CreateThread(NULL, 0, Purger, NULL, 0, NULL); CreateThread(NULL, 0, Adder, NULL, 0, NULL); unsigned int result = 0; for (;;) { result += g_dict["k1"]; } return (int)result;
} Zgodnie z przewidywaniami uruchomienie powyższego programu skutkuje zamknięciem aplikacji z błędem, a konkretniej z kilkoma różnymi błędami: Kompilacja i uruchomienie 1: > g++ -std=gnu++0x stress_map.cc -o stress_map > gdb -q stress_map.exe Reading symbols from c:\code\stress_map.exe...done. (gdb) r Starting program: c:\code\stress_map.exe [New Thread 10520.0x2de8] [New Thread 10520.0x32cc] [New Thread 10520.0x31a0] Program received signal SIGSEGV, Segmentation fault. [Switching to Thread 10732.0x2bb0] 0x6fc618c8 in libstdc++-6!_ZNKSs4sizeEv () from d:\bin\gcc\bin\ libstdc++-6.dll (gdb) set disassembly-flavor intel (gdb) x/1i $eip => 0x6fc618c8 : mov eax,DWORD PTR [eax] (gdb) i r eax eax
0xfeeefeee
-17891602
Wartość 0xfeeefeee, znajdująca się w jednym z rejestrów, jest używana przez implementację sterty obecną w systemie Windows do oznaczania pamięci, która została zwolniona [2]. Uruchomienie 2: > gdb -q stress_map.exe Reading symbols from c:\code\stress_map.exe...done. (gdb) r Starting program: c:\code\stress_map.exe [New Thread 11744.0x31b0]
[New Thread 11744.0xcf0] [New Thread 11744.0x3550] warning: HEAP[a.exe]: warning: Free Heap block 00522018 modified at 00522094 after it was freed Program received signal SIGTRAP, Trace/breakpoint trap. 0x778f082d in ntdll!TpWaitForAlpcCompletion () from C:\Windows\ system32\ntdll.dll Uruchomienie 3: > gdb -q stress_map.exe Reading symbols from c:\code\stress_map.exe...done. (gdb) r Starting program: c:\code\stress_map.exe [New Thread 9420.0x2cfc] [New Thread 9420.0x33b0] [New Thread 9420.0x3118] Program received signal SIGSEGV, Segmentation fault. 0x75bee619 in strnlen () from C:\Windows\syswow64\msvcrt.dll (gdb) set disassembly-flavor intel (gdb) x/1i $eip => 0x75bee619 : (gdb) i r esi esi 0xfeeefeee
movzx
edx,BYTE PTR [esi]
-17891602
I tak dalej – w praktyce błąd może wystąpić w dowolnym miejscu w kodzie, który operuje na zmiennych, na które wpływają wątki modyfikujące współdzielony obiekt. Aby rozwiązać ten problem, stosuje się zestaw gotowych narzędzi – od relatywnie niskopoziomowych zmiennych i operacji atomowych, poprzez tzw. muteksy, semafory, zdarzenia i sekcje krytyczne, aż do gotowych synchronizowanych kontenerów – ich opis stanowi większą część niniejszego rozdziału. Na pierwszy rzut oka można by rozwiązywać kwestie związane z synchronizacją przez użycie np. zestawu globalnych flag i innych „zwyczajnych”
zmiennych, w praktyce jednak wiąże się z tym wiele ukrytych problemów, a co za tym idzie – jest bardzo trudne w poprawnej realizacji i z tego względu odradzane. Problemy manualnej synchronizacji [VERBOSE] Problemy związane z manualną synchronizacją wątków wynikają przede wszystkim z faktu, że tworząc kod w „klasyczny” sposób, programista nie ma pełnej kontroli nad kolejnością jego wykonania, co jest kluczowe dla poprawnej synchronizacji. Jest tak z dwóch powodów. Pierwszy z nich jest związany z językiem programowania – nie każdy język określa kolejność, w jakiej będą wykonywane zapisane przez programistę operacje. Rozważmy przykład w języku C: a = b() + c() * d(); Z matematycznego punktu widzenia w powyższym wyrażeniu kolejność jest oczywista: 1. Mnożenie wyników wykonania funkcji c i d. 2. Zsumowanie wyniku mnożenia z wartością zwróconą przez funkcję b. 3. Zapisanie wyniku do zmiennej a. Z programistycznego punktu widzenia mamy do czynienia jeszcze z trzema „dodatkowymi” operacjami: wywołanie funkcji b; wywołanie funkcji c; wywołanie funkcji d. Jak wspominałem, język C nie definiuje ściśle kolejności wykonania operacji, a więc funkcje te mogą zostać wykonane w dowolnej kolejności. Problem ten dotyczy oczywiście nie tylko wywołań funkcji, ale również pobierania wartości zmiennych z pamięci podczas wykonywania obliczeń, a nawet kolejności samych obliczeń w przypadku istnienia równoważnych wyrażeń[137]. Drugim powodem jest architektura dzisiejszych procesorów, która umożliwia wykonanie instrukcji oraz operacji na pamięci poza kolejnością (out-of-order execution) [3]. Rozważmy pseudokod wykonywany przez dwa wątki, z których jeden czeka, aż zostanie ustawiona globalna zmienna (flaga)
mówiąca o dostępności danych (data_ready), a następnie pobiera gotowe dane (data), a drugi przygotowuje dane i ustawia wspomnianą flagę.
W ątek 1
W ątek 2
; Poczekaj na dane. spinlock:
; Uzupełnij dane. store [data], R1
W ątek 1 load R1, [data_ready] cmp R1, 0 jump_eq spinlock
W ątek 2 ; Zasygnalizuj gotowość danych. store [data_ready], 1
; Dane są gotowe. load R2, [data]
Na pierwszy rzut oka wszystko wygląda prawidłowo – pierwszy wątek zawsze czeka z pobraniem danych, aż flaga data_ready będzie podniesiona, a drugi wątek podnosi tę flagę, dopiero gdy dane są gotowe do pobrania. W praktyce jednak niektóre współczesne procesory mogą zmienić kolejność wykonania zapisów do pamięci w przypadku drugiego wątku, przez co flaga data_ready zostanie podniesiona, zanim dane faktycznie trafią do pamięci – w takim wypadku pierwszy wątek mógłby pobrać nieprawidłowe, nieaktualne dane. Ostatecznie wszystkie problemy można rozwiązać, np. pisząc wrażliwy kod w formie gwarantującej określoną kolejność działań, korzystając z barier pamięci, a także dbając o to, by w odpowiednich miejscach stosować blokujące operacje atomowe. Niemniej jednak wygodniej i bezpieczniej zdać się na gotowe i dobrze przetestowane mechanizmy synchronizujące.
9.1. Blokujące atomowe bariery
Patrząc na zagadnienie od strony niskopoziomowej, większość mechanizmów synchronizacji zbudowana jest na trzech mechanizmach udostępnianych przez procesor, a mianowicie: Bariery pamięci. Operacje atomowe. Operacje wzajemnie blokujące (interlocked). Bariery pamięci (memory barrier lub memory fence) są niskopoziomowymi instrukcjami, które w pewnym uproszczeniu wstrzymują wykonanie kolejnych instrukcji na procesorze, dopóki wszystkie wcześniej występujące w kodzie instrukcje operujące na pamięci nie zostaną w pełni wykonane – dzięki temu można uzyskać gwarancję kolejności odczytu i/lub zapisu do pamięci, gdy jest to istotne (patrz ramka „Problemy manualnej synchronizacji [VERBOSE]”). Na przykład na architekturze x86 korzysta się z dedykowanych instrukcji lfence, sfence oraz mfence, choć dowolna blokująca (lub serializująca[138]) instrukcja również jest barierą. Operacje nazywane atomowymi są operacjami, których całkowity efekt obserwowalny przez inny wątek jest widoczny od razu w całości. Oznacza to gwarancję, że nie istnieje możliwość, by inny wątek pobrał tylko część wyniku takiej operacji. Przykładem istotnych operacji atomowych na współczesnych procesorach z rodziny x86 są wszelkie zapisy do pamięci pod adresy wyrównane naturalnie, przez co rozumiem wyrównanie do: Jednego bajtu w przypadku zapisu jednego bajtu. Dwóch bajtów w przypadku zapisu dwóch bajtów. Czterech bajtów w przypadku zapisu czterech bajtów. I tak dalej. Bazując na tym opisie, możemy powiedzieć, iż instrukcja architektury x86 w formie mov [0x1234], eax jest operacją atomową – rejestr eax jest 32-bitowy, a zatem do pamięci zapisywane są cztery bajty; adres 0x1234 jest podzielny przez 4 bez reszty, więc zapis następuje pod naturalnie wyrównany adres. Z drugiej strony instrukcja mov [0x1235], eax nie daje gwarancji atomowości, więc procesor może rozbić zapis na dwie operacje.
Inny przykład atomowości (lub jej braku) dotyczy obsługi 64-bitowych zmiennych, np. typu uint64_t znanego z języków C i C++. Na 64-bitowej architekturze x86-64 (formalnie AMD64) 64-bitowe zmienne mieszczą się w pojedynczych rejestrach procesora, więc ich zapis do pamięci sprowadza się do wykonania jednej instrukcji, np. mov [0x1230], rax, co czyni tę operację atomową. Z drugiej strony w 32-bitowym trybie procesora (x86-32) do przechowania 64-bitowej wartości potrzebne są aż dwa rejestry, więc zapisuje się ją do pamięci również dwiema instrukcjami, np. mov [0x1230], eax oraz mov [0x1234], edx – wyraźnie widać tutaj brak atomowości operacji. W rezultacie inny wątek odczytujący tę zmienną mógłby otrzymać nieprawidłową wartość, składającą się z 32 bitów starej wartości zmiennej oraz 32 bitów nowej. Ostatnią kategorią mechanizmów synchronizacji są operacje na pamięci typu interlocked – w ich przypadku podległa architektura gwarantuje, że podczas wykonania danej operacji żaden inny logiczny procesor obecny w systemie nie będzie miał możliwości interakcji z danym fragmentem pamięci, na którym dokonywana jest operacja (w uproszczeniu każdy logiczny procesor, który podejmie próbę wejścia w interakcję z danym fragmentem pamięci, zostanie wstrzymany do momentu zakończenia operacji blokującej – faktyczna implementacja zależy od konkretnej architektury). Operacje typu interlocked gwarantują więc atomowość (relatywnie) bardziej złożonych operacji niż proste przypisanie. Przykładem operacji blokującej na architekturze x86 jest instrukcja lock add [addr], reg[139], która do wskazanej zmiennej w pamięci dodaje wartość z określonego rejestru. W przypadku rezygnacji z prefiksu lock instrukcja ta przestaje być atomowa i sprowadza się do dwóch oddzielnych poleceń odczytu i zapisu do pamięci. Dodam, że niektóre instrukcje, a w szczególności xchg (exchange – zamiana miejscami dwóch wartości), który jest podstawą wielu mechanizmów synchronizujących, są domyślnie blokujące i nie wymagają jawnie podanego prefiksu lock. Do zastosowań xchg wrócę jeszcze w następnym podrozdziale. W praktyce tworząc oprogramowanie w popularnych językach programowania, rzadko bezpośrednio sięga się do opisanych mechanizmów – jednym z niewielu przykładów użycia operacji atomowych w językach wysokiego poziomu są proste generatory unikatowych (w kontekście procesu) identyfikatorów. Poniżej znajduje się przykład (w języku C) programu testującego dwa takie generatory – jeden z nich (InterlockedGetUniqueID) korzysta
z blokującej operacji atomowej (która jest jednocześnie barierą, choć w tym wypadku jest to nieistotne) oferowanej przez WinAPI – InterlockedIncrement, a
druga
(BadGetUniqueID)
posługuje
się
standardowym,
nieatomowym
prefiksowym operatorem inkrementacji: #include #include #include #include typedef int (*uid_func_t)(void); #define THREAD_COUNT 8 #define IDS_PER_THREAD (4 * 1000 * 1000) int InterlockedGetUniqueID(void) { static LONG next_uid; return (int)InterlockedIncrement(&next_uid); } int BadGetUniqueID(void) { static volatile LONG next_uid; return ++next_uid; } static volatile LONG g_poor_mans_lock; DWORD WINAPI BruteTest(LPVOID param) { int i; clock_t start_time; uid_func_t get_unique_id_ptr = (uid_func_t)param; // Próba zsynchronizowania wątków, by wystartowały naraz, co // zwiększy liczbę obserwowalnych kolizji. W praktyce kilka wątków // może być wywłaszczonych w tym momencie, niemniej istnieje istotne
// prawdopodobieństwo, że przynajmniej dwa wątki uruchomią się // w tym samym czasie. InterlockedIncrement(&g_poor_mans_lock); while (g_poor_mans_lock != THREAD_COUNT); // Pobieranie ID. start_time = clock(); for (i = 0; i < IDS_PER_THREAD; i++) { get_unique_id_ptr(); } // Powrót – zwracanym „kodem wyjściowym” jest czas wykonania. return (DWORD)(clock() - start_time); } void RunTest(uid_func_t f) { int i; HANDLE h[THREAD_COUNT]; clock_t total_time; // Uruchomienie THREAD_COUNT wątków. g_poor_mans_lock = 0; for (i = 0; i < THREAD_COUNT; i++) { h[i] = CreateThread(NULL, 0, BruteTest, (LPVOID)f, 0, NULL); } WaitForMultipleObjects(THREAD_COUNT, h, TRUE, INFINITE); // Wyliczenie czasu i zamknięcie uchwytów. total_time = 0; for (i = 0; i < THREAD_COUNT; i++) { DWORD exit_time = 0; GetExitCodeThread(h[i], &exit_time); total_time += (clock_t)exit_time; CloseHandle(h[i]);
} printf("Next ID
: %i\n", f());
printf("Should be: %i\n", THREAD_COUNT * IDS_PER_THREAD + 1); printf("CPU time : %f sec\n", (double)total_time / CLOCKS_PER_SEC); } int main(void) { puts("*** Non-atomic UID:"); RunTest(BadGetUniqueID); puts("\n*** Interlocked UID:"); RunTest(InterlockedGetUniqueID); return 0; } Uruchomienie nie pozostawia złudzeń co do potrzeby synchronizacji w przedstawionym wypadku – wartość wyliczona za pomocą operacji nieatomowych dalece odbiega od oczekiwanej (test został wykonany na komputerze wyposażonym w procesor Intel Core i7-2600, oferującym 4 rdzenie i 8 logicznych procesorów): > gcc uid.c -Wall -Wextra -O3 -o uid.exe > uid.exe *** Non-atomic UID: Next ID : 7425691 Should be: 32000001 CPU time : 0.700000 sec *** Interlocked UID: Next ID : 32000001 Should be: 32000001 CPU time : 5.547000 sec
Wyraźnie zauważalna jest jednak również różnica w całkowitym czasie wykonania – operacje blokujące oraz bariery są zdecydowanie wolniejsze niż ich niesynchronizowane odpowiedniki. Co za tym idzie w pewnych zastosowaniach nie korzysta się z operacji atomowych, decydując się na zwiększoną szybkość kosztem wprowadzonego błędu. Im mniej „kolizji” przy dostępie do zmiennej, tym oczywiście mniejszy błąd – w omawianym przykładzie celowo starałem się doprowadzić do jak największej liczby kolizji, ale w typowym działaniu zwykłych aplikacji byłoby ich statystycznie mniej. Przykładem może być liczenie ogólnych statystyk mówiących o liczbie wywołań danej funkcji czy modułu programu – bardzo dokładne informacje są w takich wypadkach zazwyczaj niepotrzebne, można więc zrezygnować z synchronizacji, dzięki czemu narzut związany z prowadzeniem statystyk może być niezauważalnie mały. Tabela 1 zawiera przykładowe nazwy operacji (lub typów) atomowych, udostępnianych przez różne platformy. Dodatkowo tabela 2 zawiera przykłady barier. Tabela 1. Przykłady funkcji, klas i typów atomowych
Język, biblioteka lub system
Funkcje, klasy lub typy
Java
java.util.concurent.atomic
C (rozs zerzenie GCC)
__sync_fetch_and_add __sync_fetch_and_sub __sync_fetch_and_or ...
C (od C11)
_Atomic (typ) atomic_compare_exchange_strong atomic_compare_exchange_weak atomic_fetch_add atomic_fetch_add_explicit ...
C++
std::atomic
Uwagi
Dos tępne w ramach pliku nag łówkoweg o stdatomic.h, tylko w wypadku, g dy makro __STDC_NO_ATOMICS__ nie jes t zdefiniowane.
(od C++11)
std::atomic_flag
S zablon std::atomic może wewnętrznie korzys tać z dodatkowych blokad (np. muteks ów) w niektórych przypadkach. Klas a std::atomic_flag jes t natomias t zaws ze implementowana bez zewnętrznych blokad (tj. korzys ta z nis kopoziomowych atomowych operacji blokujących).
boos t (C++)
Boost.Atomic
Implementacja std::atomic z C++11 dla platform, które g o dos tarczają.
WinAPI
InterlockedIncrement InterlockedExchange InterlockedAnd ...
Tabela 2. Przykłady funkcji ustanawiających bariery
Język, biblioteka lub system
Funkcje lub klasy
C (rozs zerzenie GCC)
__sync_synchronize __sync_lock_test_and_set __sync_lock_release
C, C++ (rozs zerzenie V is ual C++)
_ReadBarrier _WriteBarrier _ReadWriteBarrier
C i C++
atomic_thread_fence
(od C11 i C++11) WinAPI
MemoryBarrier
9.2. Spinlocki – wirujące blokady Najprostszym niskopoziomowym mechanizmem synchronizującym jest tzw. spinlock (dosłownie: wirująca blokada), którego fragmenty mogą być wykorzystane do budowy innych, bardziej złożonych i częściej używanych w praktyce mechanizmów, jak muteksy czy zdarzenia. W najprostszym wydaniu spinlock jest aktywną pętlą (spinning), która oczekuje, aż dana blokada (lock) zostanie zniesiona – w praktyce sprowadza się to do ciągłego, atomowego sprawdzania, czy dana zmienna „synchronizująca” (lub „obserwowana”) zmieniła wartość. Przykładu spinlocka tego typu zastosowałem w przykładzie w poprzednim podrozdziale: ... static volatile LONG g_poor_mans_lock; ... // Próba zsynchronizowania wątków, by wystartowały naraz, co // zwiększy ilość obserwowalnych kolizji. W praktyce kilka wątków // może być wywłaszczonych w tym momencie, niemniej jednak istnieje // istotne prawdopodobieństwo, że przynajmniej dwa wątki uruchomią // się w tym samym czasie. InterlockedIncrement(&g_poor_mans_lock); while (g_poor_mans_lock != THREAD_COUNT); ... W powyższym przypadku wątki testowały zmienną g_poor_mans_lock i blokowały kod do momentu, aż ta nie osiągnęła wartości równej THREAD_COUNT. Należy zwrócić uwagę na kilka szczegółów:
Zmiana wartości zmiennej, na której oparta jest blokada, powinna być atomowa (stąd użycie InterlockedIncrement do zaznaczenia, iż kolejny wątek jest gotowy). Jeśli spinlock jest realizowany w języku, który pozwala na agresywne optymalizacje[140], to zmienna, na której oparta jest blokada, powinna być z nich wykluczona. W przypadku języków C i C++ odpowiedzialne jest za to słowo kluczowe volatile, które informuje kompilator, iż wartość danej zmiennej może ulec zmianie w nieprzewidziany sposób w dowolnym momencie wykonania. Dopóki warunek blokady nie zostanie spełniony, aktywna pętla tego typu zużywa 100% przydzielonego czasu procesora. Tworząc przedstawiony kod, brałem pod uwagę to, iż zastosowany spinlock miał na celu jedynie zsynchronizowanie rozpoczęcia pewnych operacji. Jeśli spinlock byłby użyty jako zdarzenie informujące o dostępności nowych danych w innej zmiennej, powinien zawierać on również barierę – o problemach wynikających z braku barier w przypadku spinlocków wspominałem już w ramce „Problemy manualnej synchronizacji [VERBOSE]”.
Rysunek 1a. Spinlock – schemat zajęcia z aktywnym oczekiwaniem
O ile w pierwszym z przykładowych kodów spinlock był oparty na sprawdzeniu konkretnego warunku zależnego od liczby wątków, o tyle najczęściej zmienna synchronizująca ogranicza się do dwóch możliwych wartości: Blokada jest zajęta (zablokowana) – kod sprawdzający spinlock powinien poczekać na jej zwolnienie. Blokada w stanie zwolnionym – kod sprawdzający spinlock może kontynuować działanie. Faktyczne liczbowe wartości powyższych stałych są praktycznie dowolne, przy czym najczęściej stosuje się liczby 0 (blokada zwolniona) oraz 1 (blokada zajęta). Spinlock w wersji bardziej złożonej może działać jak prosty muteks (patrz kolejny podrozdział), tj. w momencie zwolnienia blokady może ponownie oznaczyć ją jako zajętą. Przykład zasady zajęcia blokady przez spinlock tego typu znajduje się na rysunku 1a. Jak pisałem wcześniej, spinlock jako aktywna pętla zużywa cały przydzielony kwant czasu procesora podczas oczekiwania na zwolnienie blokady – podejście to jednak ma uzasadnienie jedynie w kilku przypadkach, w których obserwowana (synchronizująca) wartość ma zostać zmieniona przez: Inny wątek, który jest aktywnie wykonywany na innym logicznym procesorze, tj. nie jest wywłaszczony. Niskopoziomowy kod wykonany w ramach przerwania (które jednocześnie nie spowoduje wywłaszczenia wątku). Inny komponent sprzętowy w ramach DMA (Direct Memory Access). Inny komponent sprzętowy w ramach MMIO (Memory-mapped I/O), jeśli zmienna jest umieszczona np. w pamięci karty graficznej lub sieciowej. W zasadzie wszystkie z wymienionych przypadków pochodzą z domeny programowania niskopoziomowego[141] – w programowaniu wysokopoziomowym praktycznie nie korzysta się z aktywnych blokad w typie spinlocków, doskonale natomiast sprawdzają się wysokopoziomowe mechanizmy wspomagane przez system operacyjny, o których piszę w kolejnych podrozdziałach. Dodam, że pewnym połączeniem obu światów są spinlocki (a w zasadzie proste muteksy), które w pętli oczekującej na zwolnienie blokady zakładają możliwość krótkotrwałego uśpienia wątku (patrz rys. 1b).
Rysunek 1b. Spinlock – schemat zajęcia blokady z usypianiem wątku (na podstawie libpthread [4])
Relatywnie niewielka tabela 3 zawiera przykłady gotowych do użycia implementacji spinlocków. Tabela 3. Przykłady gotowych implementacji spinlocków
Język, biblioteka lub system
Funkcje lub klasy
POS IX Threads (lub pthreads )
pthread_spin_init pthread_spin_lock pthread_spin_trylock pthread_spin_unlock pthread_spin_destroy
C#
SpinLock SpinWait
9.3. Muteksy i sekcje krytyczne Muteks (mutex, mutual exclusion, ew. lock[142]) jest najczęściej stosowanym mechanizmem synchronizacji, który w klasycznej formie służy do umożliwienia korzystania z chronionego zasobu tylko jednemu wątkowi naraz. Muteks może znajdować się w jednym z dwóch stanów: Zajęty – wątek zajął muteks na własność i korzysta z chronionego zasobu; taki wątek powinien zwolnić muteks, gdy tylko zakończy operacje na chronionym zasobie. Wolny (ew. dostępny) – żaden wątek nie korzysta z chronionego zasobu. Typowe użycie muteksu przebiega w następujący sposób: 1. Muteks zostaje zajęty (operacja acquire lub lock). a. Jeśli muteks był wolny, zostaje od razu zajęty i wykonanie przechodzi dalej. b. Jeśli muteks był zajęty – sytuacja taka nazywana jest czasem „sporem” (contention) – wątek oczekuje, aż zostanie zwolniony, po czym stara się go zająć i w przypadku powodzenia przechodzi dalej. 2. Wątek wykonuje operacje na chronionym zasobie[143]. Wszystkie inne wątki albo wykonują inny kod, albo oczekują, aż muteks zostanie zwolniony. 3. Muteks zostaje zwolniony (operacja release lub unlock).
W przeciwieństwie do prostych spinlocków operacja zajęcia muteksu, w przypadku wystąpienia sporu, z zasady poddaje wątek podczas oczekiwania na zwolnienie muteksu. Co więcej, system operacyjny może wspierać muteksy na poziomie planisty systemowego – w takim wypadku wątek pozostaje w stanie uśpienia, dopóki muteks nie zostanie zwolniony przez inny wątek. Zastosowanie takiego podejścia umożliwia oszczędzenie czasu procesora, który inaczej musiałby zostać zużyty na niepotrzebne wznowienie oczekującego wątku i wykonanie jednego obrotu pętli testującej blokadę (która oczywiście nadal byłaby zajęta). Oznacza to jednak również, że zajęcie muteksu w przypadku zaistnienia sporu wprowadza dodatkowe opóźnienie (w stosunku do prostego spinlocka) związane z działaniem planisty, choć w praktyce rzadko kiedy stanowi to problem. Przykład użycia muteksu w języku C++ (C++11), będący de facto poprawioną wersją przykładu z początku rozdziału, wygląda następująco: #include #include #include #include #include std::unordered_map g_dict; std::mutex g_dict_mutex; const char *g_keys[] = { "k1", "k2", "k3", "k4", "k5", "k6" }; void Purger() { unsigned int poor_mans_random = 647; for (;;) { g_dict_mutex.lock(); // Zajęcie muteksu na czas operacji. g_dict.erase(g_keys[poor_mans_random % 6]); g_dict_mutex.unlock();
poor_mans_random = (poor_mans_random * 4967 + 1777) % 1283; } } void Adder() { unsigned int poor_mans_random = 499; for (;;) { g_dict_mutex.lock(); // Zajęcie muteksu na czas operacji. g_dict[g_keys[poor_mans_random % 6]] = poor_mans_random; g_dict_mutex.unlock(); poor_mans_random = (poor_mans_random * 4967 + 1777) % 1283; } } int main(void) { std::thread purger(Purger); std::thread adder(Adder); unsigned int result = 0; for (;;) { // Zajęcie muteksu, korzystając z RAII (zostanie automatycznie // zwolniony na końcu bloku kodu). std::lock_guard lock(g_dict_mutex); result += g_dict["k1"]; } return (int)result; } Kompilacja oraz uruchomienie programu przebiegają następująco: $ g++ -Wall -Wextra -std=c++11 ./stress_map_fix1.cc o stress_map_fix1 $ ./stress_map_fix1
(program działa bezbłędnie) O ile wersja bez muteksów powodowała błędy w wewnętrznym stanie mapy g_dict, o tyle wersja z muteksem synchronizującym dostęp do tego obiektu nie sprawia żadnych problemów. W kodzie źródłowym samego przykładu warto zwrócić uwagę na kilka kwestii: Czysto umowne jest to, że muteks g_dict_mutex synchronizuje dostęp do obiektu g_dict, gdyż do programisty należało upewnienie się, że muteks ten jest zajmowany w każdym przypadku. Co ciekawe, niektóre kompilatory oferują możliwość umieszczenia w kodzie adnotacji informującej, iż dana zmienna może być użyta jedynie w przypadku, gdy dany muteks jest zajęty – przykładem może być clang (LLVM) i zaproponowany atrybut GUARDED_BY [5], np.: std::mutex g_dict_mutex; std::unordered_map
g_dict
W tym przypadku, jeśli kompilator w czasie kompilacji wykryłby dostęp do g_dict bez wcześniejszego zajęcia blokady, zostałoby wygenerowane ostrzeżenie. Ponieważ zajęcie muteksu powoduje blokadę wszystkich innych wątków, które mogą na niego oczekiwać (w tym wypadku mowa o dwóch), starałem się podczas tworzenia kodu, by muteks był zajęty minimalną ilość czasu[144]. W obu nowych wątkach użyłem klasycznego podejścia do zajmowania i zwalniania muteksu, natomiast w głównym (pierwszym) wątku zastosowałem klasę std::lock_guard, która oferuje mechanizm RAII (Resource Acquisition Is Initialization). Celem RAII jest zwolnienie programisty z „ręcznego” uwalniania zasobu (np. zwolnienia muteksu, zamknięcia uchwytu itp.) – w tym celu tworzony jest lokalny obiekt, którego konstruktor nabywa zasób (w tym wypadku: zajmuje muteks), a destruktor go zwalnia. Zalety RAII są najlepiej widoczne w sytuacjach, gdy wiele ścieżek w sekcji krytycznej powoduje jej opuszczenie – typowym powodem jest przedwczesne opuszczenie funkcji z błędem.
RAII i implementacja lock_guard [VERBOSE] W praktyce implementacja klasy lock_guard jest trywialna i ogranicza się do wywołania odpowiednich metod na obiekcie w konstruktorze i destruktorze. Poniżej znajduje się fragment pliku libstdc++-v3/include/std/mutex z GCC 4.9.3, który definiuje omawiany szablon: /// @brief
Scoped lock idiom. // Acquire the mutex here with a constructor call, then release // with the destructor call in accordance with RAII style. template class lock_guard { public: typedef _Mutex mutex_type; explicit lock_guard(mutex_type& __m) : _M_device(__m) { _M_device.lock(); } lock_guard(mutex_type& __m, adopt_lock_t) : _M_device(__m) { } // calling thread owns mutex ~lock_guard() { _M_device.unlock(); } lock_guard(const lock_guard&) = delete; lock_guard& operator=(const lock_guard&) = delete; private: mutex_type& };
_M_device;
Warto zaznaczyć, że obiektu powyższej klasy można użyć jedynie w sytuacji, gdy obszar sekcji krytycznej idealnie nakłada się z obszarem życia obiektu.
Jeśli tak nie jest, w niektórych wypadkach zaleca się stworzenie dodatkowego, „sztucznego” bloku kodu wyłącznie na potrzeby sekcji krytycznej, np.: void MyServlet::MyHandler(const std::string& id) { // Pobierz ID zasobu na podstawie zewnętrznego ID. id_to_res_mutex_.lock(); if (id_to_res_.find(id) == id_to_res_.end()) { // Brak ID w mapie. id_to_res_mutex_.unlock(); return; } std::string resource_id = id_to_res_[id]; id_to_res_mutex_.unlock(); // Bardzo wolne przetwarzanie. ProcessResource(resource_id); // Zasób został w pełni przetworzony, można go usunąć z mapy. id_to_res_mutex_.lock(); id_to_res_.erase(id); id_to_res_mutex_.unlock(); } można zapisać jako: void MyServlet::MyHandler(const std::string& id) { // Pobierz ID zasobu na podstawie zewnętrznego ID. std::string resource_id; { std::lock_guard> lock(id_to_res_mutex_); if (id_to_res_.find(id) == id_to_res_.end()) { return; } resource_id = id_to_res_[id]; }
// Bardzo wolne przetwarzanie. ProcessResource(resource_id); // Zasób został w pełni przetworzony, można go usunąć z mapy. { std::lock_guard> lock(id_to_res_mutex_); id_to_res_.erase(id); } } Bardzo podobny mechanizm RAII jest wbudowany w język Java pod postacią bloku synchronized, który potrafi skorzystać z dowolnego obiektu jak z muteksu[145]. W ramach przykładu przetłumaczyłem wcześniejszy przykład z C++ na Java, wykorzystując m.in. anonimowe klasy do zdefiniowania nowych wątków. Dodam, że również w Java poniższy kod bez muteksów prowadzi do sytuacji wyścigu, w której dwa wątki modyfikują wewnętrzny stan struktury naraz, niezależnie od siebie – w efekcie prowadzi to do rzucenia poniższego wyjątku: Exception in thread "Thread-1" java.lang.NullPointerException at java.util.TreeMap.fixAfterInsertion(TreeMap.java:2131) at java.util.TreeMap.put(TreeMap.java:574) at StressMapFixed$2.run(StressMapFixed.java:28) Poprawny kod zawierający odpowiednie mechanizmy synchronizacji znajduje się poniżej: import java.util.*; public class StressMapFixed { public static TreeMap dict; public static final String[] KEYS = { "k1", "k2", "k3", "k4", "k5", "k6" };
public static void main(String args[]) { dict = new TreeMap(); // Korzystając z możliwości stworzenia klasy dziedziczącej „w miejscu”, // definiuję klasę-wątek Purger oraz Adder. Thread purger = new Thread() { public void run() { int poor_mans_random = 647; for (;;) { synchronized(dict) { // Zajęcie muteksu. dict.remove(KEYS[poor_mans_random % 6]); } // Zwolnienie muteksu. poor_mans_random = (poor_mans_random * 4967 + 1777) % 1283; } } }; Thread adder = new Thread() { public void run() { int poor_mans_random = 499; for (;;) { synchronized(dict) { // Zajęcie muteksu. dict.put(KEYS[poor_mans_random % 6], poor_mans_random); } // Zwolnienie muteksu. poor_mans_random = (poor_mans_random * 4967 + 1777) % 1283; } } }; // Uruchomienie obu wątków. purger.start();
adder.start(); int result = 0; for (;;) { synchronized(dict) { // Zajęcie muteksu. Integer value = dict.get("k1"); if (value != null) { result += value; } }
// Zwolnienie muteksu.
} } } Kompilacja i uruchomienie: > javac StressMapFixed.java > java StressMapFixed (program działa bezbłędnie) Rezygnacja z próby zajęcia blokady [VERBOSE] W niektórych wypadkach lepszym rozwiązaniem od potencjalnie długiego czekania na zajęcie blokady jest podejście oportunistyczne, które polega na zajęciu blokady, tylko jeśli jest ona od razu dostępna. Niektóre API udostępniają odpowiednie funkcje do realizacji tego zadania – przykładem może być rodzina *_trylock z biblioteki pthreads: int ret = pthread_mutex_trylock(&my_mutex); if (ret == EBUSY) { // Blokada jest zajęta. Lepiej wrócić i porobić coś innego. return false; } else if (ret != 0) { // Wystąpił inny błąd. ... obsługa błędu ... return false; } // Udało się od razu zająć blokadę.
... pthread_mutex_unlock(&my_mutex); return true; Inne podejście zakłada zdefiniowanie maksymalnego czasu oczekiwania na dany muteks, tj. jeśli blokady nie da się zająć przez określony czas, wątek jest budzony, by zwrócić błąd analogiczny do powyższego. Mechanizm tego typu dostarczany jest przez międzyprocesowe muteksy w WinAPI. Co ciekawe, w ich wypadku do zajęcia blokady stosuje się te same funkcje, których używaliśmy do oczekiwania na zakończenie procesu czy wątku w poprzednich rozdziałach, np.: // Zajmij międzyprocesowy muteks (utworzony/otwarty za pomocą funkcji // CreateMutex lub OpenMutex) lub przerwij próbę po 50 ms. DWORD ret = WaitForSingleObject(g_my_mutex, 50); if (ret == WAIT_TIMEOUT) { // Nie udało się zająć muteksu w ciągu 50 ms. return false; } else if (ret != 0) { // Wystąpił błąd innego rodzaju. ... obsługa błędu ... return false; } // Udało się zająć blokadę w ciągu 50 ms. ... ReleaseMutex(g_my_mutex); Należy dodać, iż niektóre rodzaje blokad mogą wybudzać oczekujące w „klasyczny” sposób wątki, nawet jeśli nie uda się zająć blokady (przykładem może być powyższy międzyprocesowy muteks – oczekujący wątek zostanie wybudzony, jeśli proces, który miał zajętą blokadę, zostanie zakończony) – warto więc upewnić się w dokumentacji danego API, czy można założyć, że funkcja zajmująca blokadę wróci tylko w momencie zajęcia blokady, czy może należy sprawdzać, czy faktycznie udało się daną blokadę zająć.
Jak wspomniałem wcześniej, problemy z synchronizacją wynikają przede wszystkim z modyfikowania obiektu, w trakcie gdy korzystają z niego inne wątki. Można pójść o krok dalej i wyciągnąć wniosek, że nie stanowi problemu sytuacja, w której wiele wątków korzysta naraz z obiektu, pod warunkiem że żaden z nich go nie modyfikuje. Z tego względu stosuje się pewien wariant muteksów nazywany blokadą typu czytelnicy-pisarz (readers-writer lock, ew. rwlock), która umożliwia zajęcie blokady w jednym z dwóch trybów: Czytelnika – w takim wypadku dowolny inny wątek może również wejść do sekcji krytycznej w trybie czytelnika. Co za tym idzie wątek zajmujący blokadę w trybie czytelnika wejdzie bez oczekiwania do sekcji krytycznej, jeśli ta jest już zajęta w tym trybie. Pisarza – blokada w tym trybie działa jak klasyczny muteks, czyli żaden inny wątek nie może wejść do sekcji krytycznej. Jednocześnie przed zajęciem blokady pisarz czeka, aż wszyscy czytelnicy wyjdą z sekcji krytycznej. W czasie oczekiwania żaden inny wątek nie może zająć blokady w trybie czytelnika (w przeciwnym wypadku mogłoby dojść do sytuacji zagłodzenia wątku pisarza – patrz podrozdział „Problemy w synchronizacji”). Użycie muteksów tego typu poprawia wydajność w przypadkach, gdy wiele wątków chce uzyskać informacje ze struktury danych, która jest modyfikowana stosunkowo rzadko. Poza omówionym istnieją również inne warianty blokad zbliżonych do muteksów: Semafory – umożliwiają wejście do sekcji krytycznej określonej liczbie wątków naraz. Nadmiarowe wątki czekają, aż jeden lub więcej wątków nie opuści sekcji. Blokady z priorytetyzacją – w przypadku sytuacji sporu kolejność wejścia do danej sekcji krytycznej zależy od priorytetu związanego z danym fragmentem kodu lub danym wątkiem (w klasycznym muteksie porządek jest nieokreślony lub stosowany jest algorytm FCFS[146]). Blokady umożliwiające ponowne wejście (reentrant mutex) – w klasycznych blokadach wątek, który zajął blokadę i wykonuje kod z sekcji krytycznej, nie może ponownie przejść przez zajęcie tej samej
blokady (ponieważ technicznie dana blokada jest już zajęta). Muteksy typu reentrant pozwalają na przejście przez blokadę wątkowi, który zajął (ale jeszcze nie zwolnił) daną blokadę. Tabela 4 zawiera przykładowe nazwy klas, typów, funkcji i konstruktów implementujących muteksy (i podobne blokady) w kilku popularnych językach programowania, bibliotekach i API systemowych. Tabela 4. Przykładowe implementacje muteksów (i podobnych blokad) na kilku popularnych platformach
Język, biblioteka lub API systemowe
Nazwy (typów, funkcji)
T yp blokady
WinAPI
CRITICAL_SECTION InitializeCriticalSection EnterCriticalSection LeaveCriticalSection DeleteCriticalSection
Muteks
CreateMutex OpenMutex WaitForSingleObject WaitForMultipleObjects
Muteks
pthread_mutex_t pthread_mutex_init pthread_mutex_destroy pthread_mutex_lock pthread_mutex_trylock pthread_mutex_timedlock pthread_mutex_unlock
Muteks
pthread_rwlock_t pthread_rwlock_init pthread_rwlock_destroy pthread_rwlock_rdlock pthread_rwlock_wrlock
Czytelnicy-pis arz
std::mutex
Muteks
POS IX Threads (pthreads )
C++
Uwagi
Muteks , k być użyty s ynchron międzypr
(C++11) Java
Python 2
synchronized
Muteks
java.util.concurrent.Semaphore
S emafor
threading.Lock
Muteks
threading.RLock
Muteks
Muteks kl reentrant
threading.Semaphore threading.BoundedSemaphore
S emafor
BoundedSe sprawdza dodatkowo metoda nie zosta wywołana nadmiarow razy w st do acquir
Bezpiecznie jest
Obiekty synchronizowane [VERBOSE] założyć, że operowanie na obiektach udostępnianych
w ramach bibliotek (w tym bibliotek standardowych) nie jest w żaden sposób synchronizowane – a więc obowiązek synchronizacji spada na programistę. Na pierwszy rzut oka może się to wydawać uciążliwe, jednak należy zwrócić uwagę na fakt, iż obiekty współdzielone przez wiele wątków są relatywnie rzadko używane w stosunku do ich lokalnych odpowiedników. Co za tym idzie czas procesora, który niepotrzebnie byłby zużyty na sprawdzenie blokad, jest w praktyce oszczędzony (choć niekoniecznie jest to istotna oszczędność). Niemniej jednak niektóre języki oraz biblioteki oferują również zestaw klas, których obiekty są w pełni synchronizowane – przykładem może być Collections.synchronizedMap z Java lub Queue.Queue z Python 2.7, np.: #!/usr/bin/python # -*- coding: utf-8 -*from threading import Thread import Queue
import sys done = False class MyProducer(Thread): def __init__(self, q): super(MyProducer, self).__init__() self.q = q def run(self): global done # Umieść 10 zadań w kolejce. for i in xrange(10): self.q.put(i) # Poczekaj, aż wszystkie zostaną wybrane i przetworzone. self.q.join() done = True # Poniżej używam stdout.write zamiast print, ponieważ print # nie jest operacją atomową - znak nowej linii jest wypisywany # w oddzielnej operacji, przez co w środowisku wielowątkowym # dochodzi do sytuacji wyścigu, gdzie kilka wiadomości może # trafić do tej samej linii, co zmniejsza czytelność. W przypadku # użycia sys.stdout.write dla krótkiej wiadomości istnieje # znaczne prawdopodobieństwo, że cała wiadomość zostanie # przepisana do buforu standardowego wyjścia w jednej operacji. sys.stdout.write("[P] Done.\n")
class MyConsumer(Thread): def __init__(self, q, tid): super(MyConsumer, self).__init__() self.q = q self.tid = tid def run(self): global done while not done: try: work = self.q.get(timeout = 0.01) sys.stdout.write("[%i] %u\n" % (self.tid, work)) self.q.task_done() except Queue.Empty: pass
# Brak pracy w kolejce.
sys.stdout.write("[%i] Exiting.\n" % self.tid) def main(): # Uruchom wątki. q = Queue.Queue() threads = [ MyProducer(q) ] threads.extend([ MyConsumer(q, i) for i in xrange(8) ]) for th in threads: th.start() # Poczekaj, aż wątki zakończą działanie. for th in threads: th.join() if __name__ == "__main__": main() Uruchomienie i wykonanie przebiegają następująco: $python syncqueue.py [0] 0
[2] 2 [1] 1 [0] 3 [5] [3] [2] [4]
8 4 5 6
[1] [0] [P] [6]
7 9 Done. Exiting.
[5] [2] [1] [3]
Exiting. Exiting. Exiting. Exiting.
[7] Exiting. [4] Exiting. [0] Exiting. Używając obiektów wewnętrznie synchronizowanych, należy pamiętać, że o ile w ich przypadku gwarantowana jest obserwowalna atomowość pojedynczych operacji, to wykonanie całej grupy operacji nie jest atomowe. Na przykład, jeśli wątek doda dwa elementy, jeden po drugim, do obiektu klasy Queue.Queue, to w praktyce w kolejce oba elementy mogą zostać rozdzielone jednym lub kilkoma obiektami dodanymi przez inne wątki. Jeśli istotne jest, by wymusić, aby oba elementy znalazły się w kolejce obok siebie, należałoby skorzystać z klasycznej metody z dodatkowym muteksem, który musiałby być używany przy każdym dostępie do wspomnianego obiektu Queue.Queue. Oznacza to, że oferowane przez biblioteki synchronizowane obiekty są przydatne przede wszystkich w przypadkach, w których atomowość grup operacji jest nieistotna.
9.4. Zdarzenia i zmienne warunkowe
Na początku tego rozdziału zaprezentowałem kod, w którym wątki były blokowane przez spinlock, który oczekiwał, aż pewna zmienna osiągnie określoną wartość. Spinlocki są, jak wspomniałem, niskopoziomowymi mechanizmami – ich wysokopoziomowym odpowiednikiem (w tym konkretnym zastosowaniu) są blokady oczekujące na sygnał, które przyjmują formę prostych sygnalizujących zdarzeń (event)[147] oraz zmiennych warunkowych (conditional variable). Sygnalizujące zdarzenie jest synchronizującym obiektem, który może znajdować się jednocześnie w jednym z dwóch stanów: niezasygnalizowanym (blokującym) lub zasygnalizowanym (nieblokującym). Idąc dalej, na obiekcie tym wątek może wykonać jedną z dwóch operacji: Poczekać na zasygnalizowanie – w takim wypadku wątek zostanie zablokowany aż do momentu zmiany stanu na zasygnalizowany. Zmienić stan na zasygnalizowany, wszystkich czekających wątków.
co
spowoduje
wznowienie
Dodatkowo, jeśli dany obiekt zdarzenia ma zostać użyty ponownie, można go zresetować do wyjściowego stanu niezasygnalizowanego – niektóre implementacje udostępniają samoresetujące zdarzenia, których stan zmienia się na niezasygnalizowany w momencie, gdy jeden (lub określona liczba wątków) odbierze informację o tym, że zdarzenie ma stan zasygnalizowany (tj. przejdzie przez blokadę). Sygnalizujące zdarzenie a muteks [VERBOSE] Łatwo zauważyć, że muteksy tak samo jak zdarzenia również w praktyce oczekują na sygnał od innego wątku (zwolnienie blokady), który umożliwi wątkowi kontynuowanie działania. Istnieje jednak kilka istotnych różnic: Zdarzenia nie tworzą sekcji krytycznej (tj. nie „zwalnia się” zdarzenia). Wszystkie wątki oczekujące na zdarzenie zostaną odblokowane, gdy tylko zdarzenie zostanie zasygnalizowane. W przypadku muteksów zostałby odblokowany tylko jeden wątek. Co ciekawe, w kodzie nieprodukcyjnym można stworzyć proste zdarzenie działające tylko dla dwóch wątków za pomocą prostych muteksów. Realizuje
się to następująco: Pseudokod wątku oczekującego – konsumenta: // Ustawienie stanu muteksu na „zajęty”. Z logicznego punktu // widzenia nie jest tworzona żadna sekcja krytyczna. poor_mans_event_mutex.lock(); // Próba ponownego zajęcia, która doprowadzi do blokady. poor_mans_event_mutex.lock(); ... kod wykonywany po zasygnalizowaniu zdarzenia ... Wątek sygnalizujący – producent (pseudokod): ... przygotowanie danych dla konsumenta ... // „Obudzenie” wątku konsumenta, czyli zasygnalizowanie zdarzenia. // W tym wypadku sprowadza się to do zdjęcia blokady. poor_mans_event_mutex.unlock(); Powyższy kod jest oczywiście słabej jakości i nie powinien nigdy zostać użyty w kodzie produkcyjnym, niemniej jednak może okazać się przydatny, gdy trzeba bardzo szybko stworzyć mechanizm sygnalizującego zdarzenia dla jednego wątku w kodzie pisanym na szybko (np. podczas udziału w ograniczonym czasowo konkursie – patrz również rozdział „Programowanie dla zabawy”)[148]. Co więcej, jak wspomniałem wcześniej, zadziała on tylko w przypadku prostych muteksów. Na przykład w implementacji, która monitoruje, który wątek jest właścicielem blokady (tj. znajduje się w sekcji krytycznej), zaprezentowany kod spowoduje wyjątek (lub inaczej objawiający się błąd) w dwóch miejscach: Przy próbie ponownego zajęcia muteksu, który nie jest typu reentrant, przez pierwszy wątek – zostanie wykryte potencjalne zakleszczenie. Przy próbie odblokowania muteksu przez drugi wątek, który nie jest właścicielem blokady.
Ostatecznie więc kod ten ma jedynie charakter ciekawostki, a w praktyce zalecane jest korzystanie z prawdziwych sygnalizujących zdarzeń oraz zmiennych warunkowych. Zdarzeń używa się m.in. do powiadamiania wątków przetwarzających dane (konsumentów) o tym, że nowa porcja danych została dla nich przygotowana – dzięki temu wątek tego typu nie musi aktywnie sprawdzać, czy dane są dostępne. Zdarzenia są również wykorzystywane w celu zasygnalizowania zakończenia pracy i wezwania wątku do zakończenia. Przykład zawierający zdarzenia obu typów znajduje się poniżej (C++, WinAPI): #include #include #include std::list g_work; CRITICAL_SECTION g_work_mutex;
// Dostęp do listy musi być // synchronizowany niezależnie
od // użycia zdarzeń, szczególnie jeśli // jest ona modyfikowana w pętli. HANDLE g_work_ready_ev; wybudzania
// Zdarzenie, którego użyjemy do // wątku przetwarzającego przygotowane
dane. HANDLE g_work_completed_ev;
// Zdarzenie, którego użyjemy do // poinformowania wątku
o zakończeniu // pracy. DWORD WINAPI Producer(LPVOID) {
for (int i = 0; i < 16; i++) { // Przygotuj nowe dane. W prawdziwym programie byłoby tu dużo // więcej obliczeń. Sleep(150); // Przygotowywanie danych trwa długo. :) int new_data = i; // Dodaj nowy pakiet danych na koniec listy, synchronizując // dostęp do struktury za pomocą sekcji krytycznej. EnterCriticalSection(&g_work_mutex); g_work.push_back(new_data); LeaveCriticalSection(&g_work_mutex); // Poinformuj drugi wątek, że dane są gotowe do przetwarzania. SetEvent(g_work_ready_ev); } SetEvent(g_work_completed_ev); return 0; } DWORD WINAPI Consumer(LPVOID) { HANDLE events[] = { g_work_ready_ev, g_work_completed_ev }; for (;;) { // Poczekaj na jedno ze zdarzeń. // Zmienna ret zawiera indeks zdarzenia, które zostało zasygnalizowane. // Jeśli oba zdarzenia zostały zasygnalizowane, ret zawiera mniejszy // indeks. printf("** Zzz...!\n"); DWORD ret = WaitForMultipleObjects(2, events, FALSE, INFINITE);
if (ret >= sizeof(events)) { // Wystąpił błąd lub jedno ze zdarzeń zostało porzucone. // Aplikacja prawdopodobnie zostanie zakończona z błędem, więc // można zakończyć wątek. // Nie powinno się to nigdy zdarzyć w przypadku tego przykładu. return 1; } // Czy wątek został obudzony przez g_work_ready_ev? if (ret == 0) { printf("-- Signaled: Data ready!\n"); // Przetwórz dane. for (;;) { bool done = false; int data; // Sprawdź, czy dane są dostępne. EnterCriticalSection(&g_work_mutex); if (g_work.size() > 0) { // Pobierz dane. data = g_work.front(); g_work.pop_front(); } else { // Brak danych. done = true; } LeaveCriticalSection(&g_work_mutex); if (done) { break; }
// Przetwarzanie danych. printf("Working on data: %i\n", data); Sleep(100); // Symulacja ciężkiej pracy. printf("Done working on: %i\n", data); } continue; } // Czy wątek został obudzony przez g_work_completed_ev? if (ret == 1) { // Zdarzenie g_work_completed_ev zostało zasygnalizowane. // Koniec pracy. printf("-- Signaled: Work complete!\n"); break; } } return 0; } int main() { // Stwórz zdarzenie, które będzie automatycznie resetowane // w przypadku, gdy przynajmniej jeden wątek zostanie zwolniony // po zasygnalizowaniu. g_work_ready_ev = CreateEvent(NULL, FALSE, FALSE, NULL); // Poniższe zdarzenie nie będzie samoresetujące. g_work_completed_ev = CreateEvent(NULL, TRUE, FALSE, NULL); InitializeCriticalSection(&g_work_mutex); HANDLE h[2] = { CreateThread(NULL, 0, Producer, NULL, 0, NULL), CreateThread(NULL, 0, Consumer, NULL, 0, NULL) };
WaitForMultipleObjects(2, h, TRUE, INFINITE); CloseHandle(h[0]); CloseHandle(h[1]); CloseHandle(g_work_ready_ev); CloseHandle(g_work_completed_ev); DeleteCriticalSection(&g_work_mutex); return 0; } Kompilacja oraz uruchomienie przebiegają następująco: > g++ event.cc -Wall -Wextra > a ** Zzz...! -- Signaled: Data ready! Working on data: 0 Done working on: 0 ** Zzz...! -- Signaled: Data ready! Working on data: 1 Done working on: 1 ** Zzz...! ... ** Zzz...! -- Signaled: Data ready! Working on data: 15 Done working on: 15 ** Zzz...! -- Signaled: Work complete! Należy zwrócić uwagę, że w powyższym przykładzie istnieje niegroźna (odpowiednio obsłużona) sytuacja wyścigu, w której wątek-konsument zostanie wybudzony mimo braku danych do obsłużenia. Dochodzi do niej w przypadku, gdy wątek-producent zdąży przygotować kolejny pakiet danych w czasie, gdy
wątek-konsument jest aktywny i na bieżąco obsługuje dane (a więc odebrał poprzedni sygnał i obiekt zdarzenia został zresetowany) – sytuację tego typu przedstawia rysunek 2. Obsłużenie tego zdarzenia sprowadza się do sprawdzenia, czy dane faktycznie są dostępne.
Rysunek 2. Przykład sytuacji wyścigu, która prowadzi do wybudzenia wątkukonsumenta przy braku dostępności danych Opisana sytuacja ma miejsce m.in. dlatego, że dawka informacji przekazywana wraz z zasygnalizowaniem zdarzenia ogranicza się de facto do
jednego bitu[149] stanu obiektu (zasygnalizowany/niezasygnalizowany) – wątekkonsument otrzymuje informację o dostępności danych, ale już nie o ich ilości, i z tego względu na poziomie implementacji zdarzenie jest oddzielne od samych danych i ich dostępności. Co więcej, mimo wybudzenia wątku nie jest jasne, czy dostęp do danych faktycznie się powiedzie – istnieje prawdopodobieństwo, że wątek-producent będzie uaktualniał listę pakietów do przetworzenia, przez co wątek-konsument nie będzie w stanie zająć muteksu synchronizującego dostęp do danych i zostanie ponownie uśpiony. Obsługę tych problemów można uprościć, korzystając ze zmiennych warunkowych. W najprostszym wydaniu zmienne warunkowe samoresetujących zdarzeń, z dwiema istotnymi różnicami:
są
zbliżone
do
Same w sobie nie mają stanu zasygnalizowane/niezasygnalizowane – zamiast tego przechowują listę oczekujących wątków, z których co najmniej jeden jest wybudzany po zasygnalizowaniu[150]; po wybudzeniu wątek zostaje usunięty z listy wątków oczekujących na sygnał od danej zmiennej warunkowej. Jeśli nie było wątków oczekujących, powiadomienie przepada. W przeciwieństwie do zdarzeń zmienne warunkowe pozwalają często dokładnie określić, ile wątków ma zostać wybudzonych – pozwala to np. na skorelowanie liczby wybudzonych wątków z ilością otrzymanych danych. Są powiązane z muteksem, który z założenia synchronizuje dostęp do danych powiązanych ze zdarzeniem na poziomie logiki aplikacji (choć jego głównym celem jest synchronizowanie dostępu do listy oczekujących wątków). W typowej implementacji wątek-konsument musi zająć muteks przed przejściem w stan oczekiwania – na czas oczekiwania muteks jest zwalniany i zostaje automatycznie zajęty w momencie wznowienia. Analogicznie niektóre implementacje wymagają, by wątek-producent podczas sygnalizowania również był właścicielem muteksu. Ostatecznie na zmiennych warunkowych wątki wykonują jedną z dwóch operacji: (Konsument) Dodanie wątku na koniec listy oczekujących wątków.
(Producent) Wybudzenie jednego, określonej liczby lub wszystkich oczekujących wątków. Podobnie jak miało to miejsce w przypadku zdarzeń, w niektórych implementacjach wątek może zostać wybudzony w sytuacjach innych niż po zasygnalizowaniu (spurious wakeup) – wtedy należy sprawdzić powód wybudzenia i ewentualnie wrócić do oczekiwania. Przykład zastosowania tego mechanizmu synchronizacji znajduje się poniżej (Python 2.7): #!/usr/bin/python # -*- coding: utf-8 -*from threading import Condition, Lock, Thread from time import sleep class WorkContainer: def __init__(self): self.work = [] self.mutex = Lock() # Stworzenie zmiennej warunkowej (z włączonymi wewnętrznymi # komunikatami, co ma wartosć edukacyjną i jest oferowane przez # klasę). self.cond = Condition(lock = self.mutex, verbose = True) self.work_complete = False class ProducerThread(Thread): def __init__(self, work): super(ProducerThread, self).__init__() self.work = work def run(self): # Wygeneruj nowe dane. for i in xrange(16):
sleep(0.15)
# Przygotowanie danych trwa długo. :)
new_data = i # Dodaj nowy pakiet danych do listy i wybudź jeden wątek. self.work.mutex.acquire() # Tożsame z self.cond.acquire(). self.work.work.append(new_data) self.work.cond.notify(1) self.work.mutex.release() # Koniec pracy. with self.work.mutex: # Zajęcie muteksu w stylu RAII. self.work.work_complete = True self.work.cond.notifyAll() class ConsumerThread(Thread): def __init__(self, work): super(ConsumerThread, self).__init__() self.work = work def run(self): self.work.mutex.acquire() while True: # Sprawdź, czy jest do wykonania jakakolwiek praca lub czy # powinniśmy zakończyć działanie. data = None if self.work.work: data = self.work.work.pop(0) elif self.work.work_complete: self.work.mutex.release() print "Work complete!" break # W przypadku braku danych przejdź do oczekiwania na dane.
if data is None: self.work.cond.wait() continue # Przetworzenie danych - muteks jest niepotrzebny. Jeśli # w międzyczasie nowe dane zostaną dodane, to obecny wątek nie # otrzyma powiadomienia (ponieważ nie jest w stanie oczekiwania), # dlatego istotne jest, by w kolejnej iteracji pętli ponownie # sprawdzić, czy dane są dostępne. self.work.mutex.release() print "Working on data: %i" % data sleep(0.1) # Ciężka praca. Dużo ciężkiej pracy. print "Done working on: %i" % data # Zajmij ponownie muteks. self.work.mutex.acquire() def main(): # Uruchom wątki. work = WorkContainer() pro_th = ProducerThread(work) con_th = ConsumerThread(work) pro_th.start() con_th.start() print "Threads started" # Poczekaj na ich zakończenie. pro_th.join() con_th.join() if __name__ == "__main__":
main() Wykonanie powyższego programu przebiega następująco: $python cond.py Threads started Thread-1: .notify(): notifying 1 waiter Thread-2: .wait(): got it Working on data: 0 Done working on: 0 Thread-1: .notify(): notifying 1 waiter Thread-2: .wait(): got it Working on data: 1 Done working on: 1 … Working on data: 14 Done working on: 14 Thread-1: .notify(): notifying 1 waiter Thread-1: .notify(): no waiters Thread-2: .wait(): got it Working on data: 15 Done working on: 15 Work complete! Niektóre implementacje zmiennych warunkowych pozwalają zdefiniować faktyczny warunek, który ma zostać sprawdzony przed wybudzeniem wątku[151] – najczęściej przyjmuje on formę prostej funkcji (np. anonimowej), którą przekazuje się do funkcji oczekującej, np. (C++11): #include
#include #include #include // Prosty kontener na pracę, w założeniu dzielony między wątkami. class WorkContainer { public: WorkContainer() : some_number_(0) { } std::condition_variable& cond() { return cond_; } std::mutex& mutex() { return m_; } int value() { return some_number_; } void set_value(int some_number) { some_number_ = some_number; } private: int some_number_; std::condition_variable cond_; std::mutex m_; // Synchronizuje dostęp do powyższych obiektów. }; void Producer(WorkContainer *work) { std::chrono::milliseconds sleep_time(15); for (int i = 0; i < 10; i++) { std::this_thread::sleep_for(sleep_time);
printf("Setting value: %i\n", i); work->mutex().lock(); work->set_value(i); work->mutex().unlock(); // Powiadom oczekujący wątek. work->cond().notify_one(); miał zajęty muteks
// C++11 nie wymaga, aby wątek // w momencie wywołania
notify_*. } } void Consumer(WorkContainer *work) { // Obudź się tylko dla wartości 8. std::unique_lock lock(work->mutex()); work->cond().wait(lock, [&work](){ // Anonimowa funkcja (lambda), która używa zmiennej work od // rodzica i sprawdza warunek, zwracając true lub false. return work->value() == 8; }); printf("Woke up at value: %i\n", work->value()); } int main() { // Stwórz kontener na wszystkie obiekty związane z pracą i uruchom // wątek konsumenta oraz producenta (w tej kolejności). WorkContainer work; std::thread con(Consumer, &work); std::thread pro(Producer, &work); // Poczekaj, aż oba wątki zakończą pracę. pro.join();
con.join(); return 0; } Kompilacja i uruchomienie: $ g++ -std=c++11 condpred.cc -pthread $ ./a.out Setting Setting Setting Setting
value: value: value: value:
0 1 2 3
Setting Setting Setting Setting
value: value: value: value:
4 5 6 7
Setting value: 8 Woke up at value: 8 Setting value: 9 Podsumowując ten podrozdział, tabela 5 zawiera przykładowe zdarzenia (funkcje, klasy) oferowane przez kilka popularnych języków programowania, bibliotek czy systemowych API. Tabela 5. Przykładowe implementacje zdarzeń i zmiennych warunkowych
Język, biblioteka lub API systemowe
Nazwy (typów, funkcji)
Mech
WinAPI
CreateEvent OpenEvent SetEvent ResetEvent PulseEvent WaitForSingleObject WaitForMultipleObjects
Zdar
InitializeConditionVariable
SleepConditionVariableCS SleepConditionVariableSRW WakeAllConditionVariable WakeConditionVariable
Zmie waru
POS IX Threads (pthreads )
pthread_cond_init pthread_cond_destroy pthread_cond_signal pthread_cond_broadcast pthread_cond_wait pthread_cond_timedwait
Zmie waru
C++ (C++11)
Std::conditional_variable
Zmie waru
Java
AbstractQueuedLongSynchronizer.ConditionObject AbstractQueuedSynchronizer.ConditionObject
Zmie waru
Python 2
Threading.Condition
Zmie waru
9.5. Problemy w synchronizacji Z opisanymi w niniejszym rozdziale mechanizmami może wiązać się kilka komplikacji, które wynikają z niedopatrzenia programisty lub ze specyfiki ich implementacji. Zaczynając od pierwszej przyczyny, problemem, na który łatwo się natknąć, jest tzw. zakleszczenie (deadlock), czyli sytuacja, w której jeden lub więcej wątków oczekuje na blokadę, która nigdy nie zostanie zwolniona. W przypadku muteksów lub podobnych mechanizmów tego typu błąd wynika zazwyczaj z jednego z dwóch powodów: Jeden z wątków aplikacji opuścił sekcję krytyczną bez zwolnienia blokady (programista zapomniał o jej zwolnieniu w jednej ze ścieżek wykonania), przez co blokada pozostaje zajęta.
Dwa lub więcej wątków podjęło próbę zajęcia dwóch lub więcej blokad w różnej kolejności, np. wątek 1 podjął próbę zajęcia najpierw blokady A, a następnie B, podczas gdy wątek 2 podjął próbę zajęcia tych samych blokad w odwrotnej kolejności: najpierw B, a następnie A – może wtedy dojść do sytuacji, w której każdemu wątkowi udało się zająć pierwszą blokadę, a więc zajęcie drugiej w kolejności stało się niemożliwe, przez co doszło do zakleszczenia. W celu uniknięcia lub szybkiego wykrycia tych błędów z pomocą przychodzą nam mechanizmy, takie jak RAII, std::lock[152], adnotacje (GUARDED_BY itp.), a ostatecznie również przykładanie uwagi do tego, by blokady były zajmowane zawsze w tej samej kolejności. W przypadku zmiennych warunkowych dodatkowym problemem może być przegapienie powiadomienia – rozwiązanie tego problemu wymaga odpowiedniej konstrukcji kodu (co zostało zaprezentowane w poszczególnych przykładach w tym rozdziale), w szczególności jeśli powiadomienia są odbierane w pętli. Innym zmartwieniem jest tzw. zagłodzenie, będące cechą implementacji danego mechanizmu synchronizującego (co oznacza również, iż część implementacji jest na niego odporna). Do zagłodzenia dochodzi w przypadku, gdy dany wątek oczekujący na zajęcie blokady (np. muteksu) pomimo częstej zmiany jej stanu nie potrafi z powodzeniem jej zająć, ponieważ za każdym razem zostaje ona przydzielona innemu wątkowi. Sytuacja ta dotyczy w szczególności mechanizmów, w których można nadać oczekującym wątkom konkretne priorytety – w takim przypadku duża liczba wątków wysokiego priorytetu zainteresowanych blokadą może doprowadzić do zagłodzenia wątków o niższym priorytecie[153]. Do pewnego stopnia tymczasowe „głodzenie” wątków może wystąpić również w przypadku, gdy wszystkie wątki mają identyczny priorytet, ale dany mechanizm nie korzysta z żadnej metody szeregowania wątków oczekujących na zajęcie blokady (a więc blokadę za każdym razem dostaje przypadkowy wątek). W takim wypadku zagłodzenie jest zazwyczaj krótkotrwałe, ale w dłuższym okresie okazuje się, że niektóre wątki mogły uzyskać blokadę znacząco więcej razy niż inne (przykład znajduje się w ramce „Sprawiedliwość spinlocków i wyścig do tysiąca [BEYOND]” zaprezentowanej poniżej).
Sprawiedliwość spinlocków i wyścig do tysiąca [BEYOND] Proste spinlocki przedstawione na początku tego rozdziału są prawdopodobnie niesprawiedliwe, ale przy obecnej liczbie logicznych procesorów nie powinny doprowadzać do zagłodzenia. Wykazanie niesprawiedliwości spinlocków może jednak stanowić pewne wyzwanie, związane przede wszystkim z działalnością planisty systemowego, który (jak wspominałem wcześniej) uniemożliwia dokładne zsynchronizowanie wszystkich wskazanych wątków. Idealnym rozwiązaniem tego problemu jest stworzenie testów w trybie jądra (np. w formie sterownika) – umożliwia to czasowe wyłączenie planisty systemowego oraz ewentualnie przerwań sprzętowych. Niemniej jednak tworzenie i testowanie sterowników wiąże się z innymi problemami, które wykraczają poza tematykę niniejszej książki, więc zdecydowałem się na inne rozwiązanie i podjąłem próbę przetestowania sprawiedliwości spinlocków w trybie użytkownika. Prezentowany program (bazujący na przykładzie z podrozdziału „Blokujące atomowe bariery”) uruchamia THREAD_COUNT wątków (domyślnie 8), próbuje je zsynchronizować, a następnie w każdym z nich w pętli zwiększa prosty licznik, oddzielny dla każdego wątku. Dostęp do wszystkich liczników jest synchronizowany za pomocą jednego, wspólnego, aktywnego spinlocka (typu „muteksowego”), a więc tylko jeden wątek naraz może zwiększyć swój licznik. Gdy jeden z wątków zwiększy swój licznik do wartości granicznej MAX_COUNTER (domyślnie 1000), wątki kończą działanie. Jednocześnie mierzone są (w cyklach procesora na wątek): Maksymalny i minimalny czas oczekiwania na zajęcie spinlocka. Moment, w którym spinlock został zajęty w celu zwiększenia licznika do następnej wartości. Całkowity czas działania pętli. Po zakończeniu wszystkich wątków wypisywane są informacje o tych, które choć raz zwiększyły licznik. Zakładam, że jeśli licznik nie został zwiększony ani razu, to wątek był w stanie wywłaszczonym przez cały czas trwania eksperymentu. Do pliku tekstowego zapisywane są również informacje o momencie zajęcia spinlocków. Implementacja prezentuje się następująco: #include
#include #include #include #define THREAD_COUNT 8 #define MAX_COUNTER 1000 static static static static
volatile LONG g_spinlock; int g_counter[THREAD_COUNT]; uint64_t g_max_wait[THREAD_COUNT]; uint64_t g_min_wait[THREAD_COUNT];
static static static static
uint64_t uint32_t volatile volatile
g_total[THREAD_COUNT]; g_round_time[THREAD_COUNT][MAX_COUNTER]; int g_finish; LONG g_poor_mans_lock;
inline void my_spinlock_lock(LONG volatile *lock) { while(InterlockedExchange(lock, 1) == 1); } inline void my_spinlock_unlock(LONG volatile *lock) { InterlockedExchange(lock, 0); } inline uint64_t rdtsc() { uint64_t timestamp; // Używam instrukcji RDTSCP zamiast RDTSC, ponieważ ta pierwsza // jest instrukcją serializującą, tj. czeka, aż wszystkie // poprzednie instrukcje faktycznie się wykonają - RDTSC mogłoby // zostać wykonane poza kolejnością, co skutowałoby otrzymaniem // niepoprawnego wyniku. asm volatile ("rdtscp"
: "=A" (timestamp)
// EDX:EAX mają zostać zapisane do
:
// zmiennej timestamp // Brak argumentów wejściowych.
: "ecx");
// Zawartość rejestru ECX jest // nadpisywana (jeśli kompilator
uzna // wartość w ECX za istotną). return timestamp; } DWORD WINAPI BruteTest(LPVOID param) { uint64_t t_start, t_end, t_final; uint64_t t_total_start, t_total_end; int thread_id = (int)param; g_min_wait[thread_id] = ~0ULL; // Próba zsynchronizowania wątków, by wystartowały jednocześnie // (jak w poprzednim przykładzie). InterlockedIncrement(&g_poor_mans_lock); while (g_poor_mans_lock != THREAD_COUNT); t_total_start = rdtsc(); while (!g_finish) { // Zajmij spinlock i zmierz czas operacji w cyklach procesora. t_start = rdtsc(); my_spinlock_lock(&g_spinlock); t_end = rdtsc(); // Zanotuj czas trwania operacji. t_final = t_end - t_start; g_round_time[thread_id][g_counter[thread_id]] = t_end t_total_start;
if (t_final > g_max_wait[thread_id]) { g_max_wait[thread_id] = t_final; } if (t_final < g_min_wait[thread_id]) { g_min_wait[thread_id] = t_final; } if (++g_counter[thread_id] == MAX_COUNTER) { g_finish = 1; } my_spinlock_unlock(&g_spinlock); } t_total_end = rdtsc(); // Zanotuj całkowity czas trwania pętli. g_total[thread_id] = t_total_end - t_total_start; return 0; } int main(void) { int i, j; int non_zero_threads = 0; HANDLE h[THREAD_COUNT]; // Uruchomienie THREAD_COUNT wątków. g_poor_mans_lock = 0; for (i = 0; i < THREAD_COUNT; i++) { h[i] = CreateThread(NULL, 0, BruteTest, (LPVOID)i, 0, NULL); } WaitForMultipleObjects(THREAD_COUNT, h, TRUE, INFINITE); // Zamknij uchwyty i wypisz podstawowe informacje.
for (i = 0; i < THREAD_COUNT; i++) { CloseHandle(h[i]); if (g_counter[i] > 0) { printf("Counter %2i: %10i [%10I64u -- %10I64u] %I64u\n", i, g_counter[i], g_min_wait[i], g_max_wait[i], g_total[i]); non_zero_threads++; } } printf("Total: %i threads\n", non_zero_threads); FILE *f = fopen("starv.txt", "w"); uint64_t tsc_sum[THREAD_COUNT] = { 0 }; for (i = 0; i < MAX_COUNTER; i++) { fprintf(f, "%i\t", i); for (j = 0; j < THREAD_COUNT; j++) { if (g_round_time[j][i] == 0) { g_round_time[j][i] = tsc_sum[j]; } tsc_sum[j] = g_round_time[j][i]; fprintf(f, "%u\t", g_round_time[j][i]); } fprintf(f, "\n"); } fclose(f); return 0; } Kompilacja przebiega w sposób standardowy: > gcc -Wall -Wextra starv.c -O3 -o starv
Jeśli chodzi o uruchomienie i testy, to należy przede wszystkim odrzucić wszystkie przypadki, w których czas wykonania pętli dowolnego z wątków znacznie odbiega od pozostałych. Na przykład poniższy test powinien zostać odrzucony: > starv Counter Counter
1: 2:
392 [ 539 [
102 -281 --
204160] 42967]
1697634 2346949
Counter Counter Counter
5: 6: 7:
1000 [ 744 [ 217 [
181 -130 -257 --
57543] 238877] 778089]
2346393 2452835 2347499
Total: 5 threads Jak można zaobserwować, czas wykonania wątku 1 zdecydowanie odbiega od czasu przedstawionego przez inne wątki; również wątek 6 wykonywał się ponad 100 tys. cykli dłużej. Prawdopodobnie w pierwszym przypadku wątek został wywłaszczony w czasie kroku synchronizacji startu pętli, ale został wznowiony niedługo po starcie. Drugi przypadek (wątek 6) jest trudniejszy do wytłumaczenia, zwłaszcza że wątek ten zdążył doliczyć do ¾ limitu, a różnica w czasie jest zbyt mała, by podejrzewać pełne wywłaszczenie – prawdopodobnie doszło do obsłużenia przerwania sprzętowego przez dany rdzeń. W końcu po niedużej ilości uruchomień powinniśmy otrzymać akceptowalne wyniki – przykład znajduje się dalej (wynik pochodzi z komputera wyposażonego w procesor Intel Core i7-2600, oferującego cztery rdzenie z Hyper-Threading Technology, które są widziane przez system operacyjny jako osiem logicznych procesorów): > starv Counter Counter Counter Counter
1: 4: 5: 6:
Counter 7: Total: 5 threads
953 482 1000 895
[ [ [ [
353 [
142 257 133 139
-----
54484] 65697] 62149] 130119]
3003300 3004034 3002499 3004431
353 --
120327]
3003687
Korzystając z poniższego skryptu[154] oraz stworzonego przez program pliku starv.txt, można wyrysować wykres wzrostu wartości licznika w czasie (patrz rys. 3): set terminal svg enhanced size 1600,800 set output "starv.svg" set encoding utf8 set xlabel 'Czas (cykle procesora)' set ylabel 'Iteracja pętli' set nokey set xrange [0:3e+06] plot "starv.txt" "starv.txt" "starv.txt" "starv.txt"
using using using using
2:1 3:1 4:1 5:1
with with with with
lines, lines, lines, lines,
\ \ \ \
"starv.txt" using 6:1 with lines, \ "starv.txt" using 7:1 with lines, \ "starv.txt" using 8:1 with lines, \ "starv.txt" using 9:1 with lines Jak można zauważyć, trzy najszybsze wątki mają zbliżone wyniki i poza chwilowymi wybiciami cały czas stosunkowo sprawiedliwie wygrywają wyścig o zajęcie spinlocka (co zresztą widać po ostatecznych wynikach: 1000, 953, 895). Ewentualne zagłodzenia zdarzają się, ale są krótkotrwałe. Dwa najwolniejsze wątki zdecydowanie jednak odstają i ostatecznie razem uzbierały mniej zwycięstw niż którykolwiek z pozostałych. Taka sytuacja miała miejsce, ponieważ system, na którym przeprowadzany był test, posiada tylko 4 rdzenie, które są wyposażone w Hyper-Threading Technology – oznacza to, iż dwa wątki były wykonywane na tym samym rdzeniu i prowadziły wewnętrzną walkę między sobą o dostęp do tych samych modułów procesora[155], co w rezultacie spowolniło ich działanie i zmniejszyło szanse obu na zajęcie spinlocka. W praktyce identyfikator przydzielonego procesora można sprawdzić, korzystając z funkcji GetCurrentProcessorNumber z WinAPI. Przykładowe uruchomienie programu po dodaniu logowania numeru logicznego procesora daje następujące wyniki:
Counter
1:
949 [
145 --
13736]
2756756
CPU: 2 Counter
4:
364 [
402 --
34831]
2756451
5:
1000 [
139 --
20001]
2754934
775 [
139 --
22180]
2756091
354 [
423 --
38648]
2755862
CPU: 1 Counter CPU: 4 Counter
6: CPU: 6 Counter 7: CPU: 0 Total: 5 threads
Rysunek 3. Liczba wygranych wyścigów o spinlock pokazana w czasie dla 5 wątków
Jak widzimy, w tym przypadku oba najwolniejsze wątki (4 oraz 7) zostały faktycznie uruchomione na tym samym rdzeniu, który udostępniał logiczne procesory 0 oraz 1. Gdyby zmienić THREAD_COUNT na 4, wyniki byłyby bardziej zbliżone, co można zobaczyć w przypadku poniższego testu: Counter CPU: 0 Counter CPU: 2 Counter CPU: 6 Counter CPU: 4
0:
1000 [
206 --
55760]
3824995
1:
970 [
186 --
34786]
3825851
2:
791 [
232 --
31053]
3826904
3:
926 [
146 --
32373]
3826476
Total: 4 threads W praktyce można więc uznać, że w przypadku tego testu uruchomionego na czterech wątkach na tym konkretnym procesorze spinlocki zachowują się relatywnie sprawiedliwie i nie dochodzi do zagłodzenia żadnego z wątków. Temat synchronizacji w aplikacjach wielowątkowych i wieloprocesowych nie powinien być demonizowany – wymaga on od programisty pewnego doświadczenia z nim związanego, a więc im szybciej zainteresuje się on tworzeniem aplikacji wielowątkowych, tym lepiej.
Ćwiczenia [SYNC:make-all-mistakes] Stwórz kilka przykładowych programów, w których wykażesz, że: Dostęp do współdzielonych obiektów powinien być synchronizowany między wątkami. Jeśli dwa wątki starają się zająć dwa muteksy w odwrotnej kolejności, to może dojść do zakleszczenia. Pominięcie zwolnienia blokady w jednej ze ścieżek może doprowadzić do zakleszczenia.
Przegapienie sygnału od zmiennej warunkowej może doprowadzić do zakleszczenia. Niektóre, pozornie proste, operacje nie są atomowe. [SYNC:make-all-mistakes-bonus-round] Stwórz przykładowy program, którym wykażesz, dlaczego wymagane jest użycie barier na procesorze danego typu, przez zademonstrowanie istnienia błędów wprowadzonych przez niskopoziomową zmianę kolejności operacji na pamięci. Podpowiem, że pierwszym krokiem będzie sprawdzenie, w jakim przypadku bariery są konieczne na procesorze konkretnego rodzaju.
Bibliografia [1]
CWE-377: Insecure Corporation,
Temporary File,
The MITRE 2014,
https://cwe.mitre.org/data/definitions/377.html [2] Sawicki A., Magic Numbers in Visual C++, 2009,
http://coldwind.pl/s/c3r9
http://www.asawicki.info/news_1292_magic_numbers_in_visual_c.html [3] Memory ordering, Wikipedia, https://en.wikipedia.org/wiki/Memory_ordering [4] GNU Hurd/ POSIX Threading Library, Free Software Foundation, http://www.gnu.org/software/hurd/libpthread.html Thread Safety Analysis, The http://clang.llvm.org/docs/ThreadSafetyAnalysis.html [6] Piponi D., Optimising pointer subtraction with [5]
Clang 2-adic
integers,
Team, 2010,
http://blog.sigfpe.com/2010/05/optimising-pointer-subtraction-with-2.html [7] Intel® 64 and IA-32 Architectures Software Developer’s Manual: Volume 2A: Instruction Set Reference, A-M, Intel Corporation, 2011.
Część IV
Pliki i formaty danych W przeszłości w informatyce słowa „plik” używano dosłownie, tj. w odniesieniu do pliku papierowych kart perforowanych, stosowanych we wczesnych latach rozwoju tej branży zarówno do przechowywania samego programu, jak i wykorzystywanych przez niego danych. Wraz z rozwojem informatyki słowa tego zaczęto używać również w kontekście „wirtualnych” plików kart, czyli danych przeniesionych z kart dziurkowanych do pamięci komputera lub na dysk twardy, określany wówczas mianem „dysku plikowego” (disk file). Z czasem karty perforowane odeszły w niepamięć i pozostało samo – używane do dzisiaj – słowo „plik” oznaczające zbiór danych o określonej (zazwyczaj w bajtach) długości. Pliki zaczęto łączyć w zestawy w ramach bardzo prostych systemów plików, a te z kolei z biegiem lat zyskały hierarchiczną strukturę zwaną całościowo strukturą plików i katalogów. W dzisiejszych czasach systemy plików są bardzo rozbudowanymi tworami, szczególnie z punktu widzenia programu działającego w kontekście systemu operacyjnego, i oprócz przechowywania samego wykazu plików i katalogów zajmują się również wieloma powiązanymi mechanizmami, takimi jak obsługa sieci wirtualnych powiązań, zwanych linkami, prawa i śledzenie dostępu czy też mapowanie plików i katalogów na konkretne fizyczne bądź wirtualne urządzenia. Problematyce systemu plików oraz samym plikom poświęcony jest pierwszy rozdział tej części książki. Pierwotnie struktura danych zawartych w plikach była indywidualną kwestią każdego programu i często znana jedynie programiście lub grupie programistów pracujących nad konkretnymi obliczeniami. Aby przekazanie pliku z danymi innemu zespołowi miało sens, musiała zostać również załączona informacja o obowiązującym formacie danych, czyli o tym, jak interpretować kolejne wczytywane wartości i czego one dotyczą. Z upływem lat niektóre formaty danych zyskały większą popularność i stały się de facto standardami (niekoniecznie wyłącznymi) sposobu kodowania pewnego rodzaju informacji w pliku.
Współcześnie istnieje ogromna liczba ustandaryzowanych (formalnie bądź nieformalnie) formatów plików – większość z nich stosuje jednak pewne schematy, których znajomość i zrozumienie znacznie ułatwia ewentualną z nimi pracę bądź tworzenie nowych, uszytych na miarę formatów dla własnych aplikacji. Na ich opisie, a także na odczycie danych z plików, ich analizie i sprowadzeniu do formy, na której potrafi operować procesor (co całościowo jest określane angielskim terminem parsing), skupiłem się w pozostałych rozdziałach tej części książki.
Bibliografia [1]
Caldwind G., Zrozumieć programowanie – Programowanie dla zabawy – Ćwiczenia, PWN, 2015, http://gynvael.caldwind.pl/vexillum
http://coldwind.pl/s/c4
Rozdział 10.
System plików Z niskopoziomowego punktu widzenia nośniki danych, z których korzystamy (dyski twarde, pendrive'y, karty pamięci itp.), są ciągłą „tablicą” bajtów[156]. Z założenia nie istnieją w nich koncepty plików, katalogów, partycji, praw dostępu itd. – wszystkim tym zajmuje się jądro systemu operacyjnego, a konkretniej sterowniki odpowiedzialne za interakcję ze sprzętem, obsługę woluminów oraz kodowanie i dekodowanie systemu plików (patrz również rys. 1).
Rysunek 1. Uproszczone nisko- i wysokopoziomowe spojrzenie na pliki i katalogi
Nie licząc drobnych wyjątków, programiści z reguły operują na konceptach pliku i katalogu udostępnianych przez system operacyjny – oznacza to, iż w praktyce nie wiadomo, na którym fizycznym nośniku w danym komputerze plik się znajduje[157], które konkretnie sektory zajmuje ani jaka jest wielkość samych sektorów. Nie wiemy również, czy plik jest (na poziomie systemu plików[158]) zaszyfrowany lub skompresowany – wszystkie te cechy są całkowicie transparentne i w zasadzie nieistotne w większości przypadków. Z tych względów w rozdziale tym skupię się na operacjach wysokopoziomowych na systemie plików oraz samych plikach, choć w kilku wypadkach wspomnę również o położonych niżej mechanizmach, z których programista może również skorzystać[159]. Zanim przejdziemy do omówienia pierwszego zagadnienia, dodam jeszcze, iż w niniejszym rozdziale używając terminu „plik”, często będę miał na myśli dowolny rodzaj wpisu w systemie plików, w tym: zwykły plik, katalog, pseudopliki (których zawartość jest dynamicznie obsługiwana przez określony sterownik lub samo jądro systemu), pseudokatalog, link symboliczny itp.
10.1. Podstawowe operacje na systemie plików Do operacji na systemie plików zaliczam głównie te, które wchodzą w interakcję (czy to odczytu, czy modyfikacji) ze strukturą katalogów, np.: Utworzenie, usunięcie lub przeniesienie pliku. Pobranie listy wpisów w danym katalogu. Odczyt lub modyfikacja uprawnień. Odczyt lub modyfikacja innych metadanych. Ponieważ specyfika większości z wymienionych operacji jest bardzo zależna od samego systemu operacyjnego, to języki programowania najczęściej oferują je w bardzo ograniczonym zakresie, który jest oparty na wspólnych cechach historycznie obecnych w systemach plików. Dostęp do API udostępniających operacje specyficzne dla danego systemu operacyjnego, bądź systemu plików, wymaga często korzystania z dodatkowych bibliotek lub wręcz bezpośredniej interakcji ze sterownikami (więcej o tym w podrozdziale „Ciekawe mechanizmy systemów plików”).
Jeśli chodzi o najpopularniejsze z punktu widzenia interfejsu użytkownika operacje, tj. zarządzanie katalogami i plikami, wyglądają one zazwyczaj równie prosto i sprowadzają się do wywołania określonej funkcji z odpowiednimi parametrami – tabela 1 zawiera przykładowy zaprezentowanych w kilku przykładowych językach.
spis
funkcji
tego
typu,
Tabela 1. Przykładowe funkcje realizujące podstawowe operacje na strukturze plików i katalogów
Operacja
C/C++ (std.)
C/C++ (API)
Python 2.7
S kopiowanie pliku
brak[160]
WinAPI:
shutil.copy shutil.copy2
Przenies ienie/ zmiana nazwy pliku
rename[162]
Us unięcie pliku
remove
CopyFile SHFileOperation
WinAPI:
os.rename
MoveFile MoveFileWithProgress MoveFileEx SHFileOperation
GNU/Linux:
os.remove
unlink
WinAPI: DeleteFile SHFileOperation
Utworzenie katalog u
brak
GNU/Linux: mkdir
os.mkdir os.makedirs
WinAPI: mkdir _mkdir CreateDirectory CreateDirectoryEx
S kopiowanie katalog u (rekurs ywnie)
brak
WinAPI: SHFileOperation
shutil.copytree
Us unięcie katalog u (pus teg o)
brak
GNU/Linux: rmdir
os.rmdir os.removedirs
WinAPI: RemoveDirectory SHFileOperation
Us unięcie katalog u (rekurs ywnie)
brak
Przenies ienie/ zmiana nazwy katalog u
brak
WinAPI:
shutil.rmtree
SHFileOperation
GNU/Linux:
os.rename
rename[163] WinAPI: MoveFile MoveFileWithProgress MoveFileEx SHFileOperation
Jeśli chodzi o pobranie listy plików i podkatalogów w katalogu, to problem jest w zasadzie tożsamy z problemem odczytu wszystkich (a więc de facto nieznanej ilości) rekordów z bazy danych[164]. Jest to zazwyczaj realizowane na jeden z kilku sposobów: Wysokopoziomowe API mogą zwrócić gotowy kontener z informacjami o katalogu. Niskopoziomowe API dostarczają najczęściej uchwyt do katalogu, którego używa się do pobierania kolejnych informacji o wpisach. Często API pozwalają również filtrować wyniki, tj. wyświetlać wpisy, których nazwy pasują do podanej maski – tak jest np. w przypadku WinAPI czy funkcji glob, dostępnej w kilku językach. Poniżej znajdują się dwa przykłady prezentujące zarówno niskopoziomowe (C i WinAPI), jak i wysokopoziomowe (Python 2.7) podejścia do listowania plików. Systemy z rodziny Windows i C:
#include #include void list_files(const char *pattern); int main(int argc, char **argv) { int i; if (argc == 1) { list_files(".\\*"); } else { for (i = 1; i < argc; i++) { list_files(argv[i]); } } return 0; } void list_files(const char *pattern) { HANDLE h; WIN32_FIND_DATA entry; printf("--- Listing files for: %s\n", pattern); // 32-bitowe procesy na 64-bitowych systemach z rodziny Windows // widzą częściowo zwirtualizowany system plików - dotyczy to // w szczególności katalogu C:\Windows\system32, który w przypadku // 32-bitowych procesów przekierowuje do C:\Windows\sysWOW64. // Aby tymczasowo wyłączyć wirtualizację, można posłużyć się funkcją: // PVOID old; // Wow64DisableWow64FsRedirection(&old); // Po wylistowaniu plików można włączyć ponownie wirtualizację:
//
Wow64RevertWow64FsRedirection(old);
h = FindFirstFile(pattern, &entry); if (h == INVALID_HANDLE_VALUE) { DWORD last_error = GetLastError(); if (last_error == ERROR_FILE_NOT_FOUND) { puts("(no files found)"); return; } printf("(error: %u)\n", (unsigned int)last_error); return; } do { // Ustal wypisany typ pliku. const char *type = "FILE"; if ((entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)) { type = "DIR "; } if ((entry.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT)) { type = "LINK"; } // Wypisz typ i nazwę pliku. // (w strukturze WIN32_FIND_DATA jest również dużo innych ciekawych // informacji) printf("[%s] %s\n", type, entry.cFileName); } while (FindNextFile(h, &entry)); FindClose(h); }
Kompilacja oraz przykładowe wykonanie (patrz również ramka „Argumenty main i rozwinięcie symboli wieloznacznych [VERBOSE]”): > gcc myls.c -Wall -Wextra -o myls > myls c:\Windows\system32\drivers\etc\* --- Listing files for: c:\Windows\system32\drivers\etc\* [DIR ] . [DIR ] .. [FILE] hosts [FILE] lmhosts.sam [FILE] networks [FILE] protocol [FILE] services Argumenty main i rozwinięcie symboli wieloznacznych [VERBOSE] W przypadku systemów unixowych przyjęło się, iż interpreter poleceń odpowiedzialny jest m.in. za rozwinięcie symboli wieloznacznych (wildcard expansion). Rozwinięcie polega na próbie zamiany argumentów, w których występują określone symbole (w szczególności asterisk oraz znak zapytania), na jeden lub więcej argumentów będących listą plików i katalogów spełniających zadany warunek (przy czym nie dotyczy to argumentów zawartych w cudzysłowach – te są pomijane podczas rozwijania). Można to zaobserwować, np. wykorzystując poniższy, bardzo prosty program (język C), który wypisuje wszystkie argumenty w kolejnych liniach: #include int main(int argc, char **argv) { while(argc-->0) puts(*argv++); return 0; } Kompilacja oraz testy przebiegają następująco: $ gcc args.c -o args-ubuntu-gcc -Wall -Wextra $ ./args-ubuntu-gcc /etc/pass* ./args-ubuntu-gcc /etc/passwd
/etc/passwd/etc/passwd.org $ ./args-ubuntu-gcc '/etc/pass*' ./args-ubuntu-gcc /etc/pass* $ ./args-ubuntu-gcc "/etc/pass*" ./args-ubuntu-gcc /etc/pass* W przypadku systemów z rodziny Windows domyślne interpretery poleceń nie oferują rozwinięcia, niemniej jednak taka opcja jest oferowana przez niektóre kompilatory języków C i C++. W szczególności niektóre wersje MinGW GCC domyślnie dokonują rozwinięcia: > gcc args.c -o args-win32-mingw > args-win32-mingw c:\windows\system32\drivers\etc\* args-win32-mingw c:\windows\system32\drivers\etc\hosts c:\windows\system32\drivers\etc\lmhosts.sam c:\windows\system32\drivers\etc\networks c:\windows\system32\drivers\etc\protocol c:\windows\system32\drivers\etc\services Opcja ta oferowana jest również przez Microsoft C/C++ Optimizing Compiler (będący częścią Visual Studio), przy czym wymaga dodatkowego załączenia pliku obiektowego setargv.obj, który jest dostępny w domyślnej instalacji pakietu: > cl args.c /Feargs-win32-msvc-wce /link setargv.obj ... > args-win32-msvc-wce c:\windows\system32\drivers\etc\* args-win32-msvc-wce c:\windows\system32\drivers\etc\hosts c:\windows\system32\drivers\etc\lmhosts.sam c:\windows\system32\drivers\etc\networks c:\windows\system32\drivers\etc\protocol c:\windows\system32\drivers\etc\services
Dodam, że aby wyłączyć domyślne rozwinięcie symboli wieloznacznych w przypadku MinGW, należy stworzyć zmienną globalną o nazwie _CRT_glob (typu int) i ustawić ją na wartość 0 [4], np.: #include int _CRT_glob = 0; int main(int argc, char **argv) { while(argc-->0) puts(*argv++); return 0; } Po ponownej kompilacji testowy program zachowa się w następujący sposób: > gcc args.c -o args-win32-mingw-nowce > args-win32-mingw-nowce c:\windows\system32\drivers\etc\* args-win32-mingw-nowce c:\windows\system32\drivers\etc\* Dodanie
zmiennej
_CRT_glob
może
być
konieczne
w
przypadku
przykładowego programu listującego pliki w WinAPI, jeśli ten ma zostać skompilowany za pomocą wersji MinGW GCC z domyślnie włączonym rozwinięciem symboli wieloznacznych. Analogiczny kod stworzony w języku Python wygląda następująco: Python 2.7 i/lub 3.4: #!/usr/bin/python # -*- coding: utf-8 -*from glob import glob import os import stat import sys def list_files(pattern): print("--- Listing files for: %s" % pattern)
# Alternatywnie można by użyć os.listdir. for entry in glob(pattern): mode = os.lstat(entry).st_mode t = "FILE" if stat.S_ISDIR(mode): t = "DIR " if stat.S_ISLNK(mode): # W przypadku linków w systemie Windows powyższy warunek mimo # wszystko nie będzie spełniony. t = "LINK" print("[%s] %s" % (t, os.path.basename(entry))) def main(): dirs = [] if len(sys.argv) == 1: dirs.append("./*") else: dirs.extend(sys.argv[1:]) for d in dirs: list_files(d) if __name__ == "__main__": main() Wykonanie skryptu daje podobny (choć nie identyczny) rezultat do wcześniejszego programu stworzonego w języku C. Wspomniana różnica dotyczy systemu Windows i linków symbolicznych, które są obsługiwane przez domyślny system plików – NTFS, ale nie są poprawnie rozpoznawane przez funkcję os.lstat, obecną we wzorcowej implementacji języka. Różnicę ilustruje następujący eksperyment: > myls.exe "c:\Documents and Settings" --- Listing files for: c:\Documents and Settings
[LINK] Documents and Settings > myls.py "c:\Documents and Settings" --- Listing files for: c:\Documents and Settings [DIR ] Documents and Settings Jednocześnie w przypadku systemów opartych na jądrze Linux powyższy program zachowuje się prawidłowo: $ python myls.py /proc/self --- Listing files for: /proc/self [LINK] self Warto pamiętać, iż tego typu różnice zdarzają się w przypadku różnych implementacji danego języka, a nawet tej samej implementacji działającej na odmiennych systemach – jest to istotne w przypadku portowania kodu na inną platformę. CPython 2.7.10, Windows i linki symboliczne [BEYOND] Obecna w standardowej bibliotece języka Python funkcja os.lstat (oraz zbliżona do niej w działaniu funkcja os.stat), służąca do pobierania podstawowych informacji o pliku, jest de facto wrapperem na funkcję o tej samej nazwie występującej w standardzie POSIX, która jest dostępna m.in. pod systemami z rodziny GNU/Linux [5]: int lstat(const char *path, struct stat *buf); Ponieważ funkcja lstat nie występuje w systemach z rodziny Windows, konieczna jest emulacja jej zachowania w implementacji języka Python, co sprowadza się do wywołania funkcji GetFileAttributesEx z WinAPI oraz przetłumaczenia zwróconej przez nią struktury WIN32_FILE_ATTRIBUTE_DATA (użytej m.in. przez nas we wcześniejszym przykładzie) na strukturę w formacie używanym przez lstat. W skład tej operacji wchodzi również przetłumaczenia atrybutów pliku na odpowiednie wartości używane w polu st_mode docelowej struktury – implementacja tej czynności znajduje się w pliku Modules/posixmodule.c w kodzie źródłowym CPython i wygląda następująco (Python 2.7.10):
static int attributes_to_mode(DWORD attr) { int m = 0; if (attr & FILE_ATTRIBUTE_DIRECTORY) m |= _S_IFDIR | 0111; /* IFEXEC for user,group,other */ else m |= _S_IFREG; if (attr & FILE_ATTRIBUTE_READONLY) m |= 0444; else m |= 0666; return m; } Jak można zaobserwować, zaprezentowany kod nie zawiera sprawdzenia, czy wskazany plik jest linkiem symbolicznym, tj. brakuje w nim następującego warunku, który powinien rozpoczynać powyższy kod: if (attr & FILE_ATTRIBUTE_REPARSE_POINT) m |= _S_IFLNK; /* 0xa000 */ else if (attr & FILE_ATTRIBUTE_DIRECTORY) ... Warto również wskazać uproszczone podejście do ustawienia wartości mówiących o prawach do pliku, co wynika przede wszystkim ze znacznej niekompatybilności w podejściu do uprawnień w systemach unixowych oraz systemach z rodziny Windows. Oprócz samego listowania katalogów na systemach korzystających z mechanizmu dysków logicznych (oczywistym przykładem jest system Windows[165]) przydatne jest również wylistowanie wszystkich dysków. Przykład w języku C oraz WinAPI wygląda następująco:
#include
#include static const char *DRIVE_TYPES_AS_TEXT[] = { "(unknown)", // DRIVE_UNKNOWN "(root path invalid)", // DRIVE_NO_ROOT_DIR "removable", // DRIVE_REMOVABLE "fixed (hard disk / flash drive)", // DRIVE_FIXED "remote (network)", // DRIVE_REMOTE "CD/DVD/BR-ROM (or similar)", // DRIVE_CDROM "RAM disk" // DRIVE_RAMDISK }; int main(void) { DWORD present_drives = GetLogicalDrives(); int i; for (i = 0; i < 32; i++) { if ((present_drives & (1 drives A:\ removable B:\ C:\
fixed (hard disk / flash drive) fixed (hard disk / flash drive)
D:\ E:\ F:\
fixed (hard disk / flash drive) fixed (hard disk / flash drive) CD/DVD/BR-ROM (or similar)
I:\ J:\ L:\ S:\
fixed (hard disk / flash drive) removable remote (network) fixed (hard disk / flash drive)
Logiczne i fizyczne dyski w systemie Windows [VERBOSE] W przypadku systemów z rodziny DOS jeszcze na początku lat 80. ubiegłego wieku każdy dysk logiczny odpowiadał jednemu dyskowi fizycznemu[166], tj. widząc w systemie dyski A: oraz B:, można było powiedzieć, iż mamy do czynienia z komputerem, który ma dwie stacje dyskietek (ewentualny dysk twardy miałby oznaczenie „C:”). W roku 1983 miał premierę system PC-DOS 2.0, który wprowadził wsparcie dla partycjonowania dysków twardych w postaci tzw. Master Boot Record – 512-bajtowej struktury znajdującej się na samym początku dysku, która m.in. zawierała listę partycji. Każda partycja była widziana przez system jako oddzielny (logiczny) dysk, a więc zasada „jedna litera – jedno fizyczne urządzenie” została zerwana. W następnych latach zostały wprowadzone kolejne mechanizmy, które na dobre ją pogrzebały – wymienić można m.in.: JOIN oraz SUBST – dwa dostępne w MS-DOS polecenia umożliwiające stworzenie pewnego rodzaju symbolicznego dowiązania pomiędzy określoną
ścieżką
a
logicznym
dyskiem.
Konkretniej
JOIN
umożliwiał dostęp do wskazanego dysku logicznego poprzez określony katalog na innym (lub tym samym) dysku logicznym[167], a SUBST pozwalał stworzyć nowy dysk logiczny, który był jedynie
symbolicznym dowiązaniem do wskazanego katalogu na innym, istniejącym już dysku logicznym[168]. NTFS Mount Point – mechanizm analogiczny do JOIN, umożliwiający podmontowanie określonego woluminu pod już istniejący katalog na partycji NTFS, z tą różnicą, iż tak podmontowany wolumin nie musi posiadać odpowiadającego mu dysku logicznego (tj. nie musi mieć przypisanej litery dysku). Dyski sieciowe. RAID (Redundant Array of Independent Disks) – zestaw, implementowanych zarówno sprzętowo, jak i programowo, transparentnych mechanizmów umożliwiających korzystanie z wielu fizycznych dysków twardych jak z jednego, co pozwala na zwiększenie pojemności wynikowego „wirtualnego” dysku (przez rozbicie danych na dwa lub więcej fizycznych nośników), zwiększenie bezpieczeństwa danych (przez zapisanie tych samych danych na wielu nośnikach) lub zwiększenie szybkości operacji wejścia/wyjścia (co jest możliwe dzięki zrównolegleniu operacji na kilku różnych fizycznych urządzeniach). Windows oferuje programową implementację RAID (oraz zbliżonych mechanizmów) w postaci dynamicznych woluminów, a w szczególności: woluminów łączonych (Spanned Volume), rozłożonych (Stripped Volume – RAID-0), dublowanych (Mirrored Volume) oraz RAID-5[169]. Warto więc pamiętać, iż liczba i oznaczenia logicznych dysków nie mają obecnie przełożenia na faktyczną ilość partycji czy fizycznych urządzeń.
10.2. Prawa dostępu Współczesne systemy operacyjne wraz z systemami plików oferują kontrolę dostępu do wybranych zasobów (plików, katalogów, pseudoplików itp.), dzięki czemu możliwe jest zdefiniowanie, którzy konkretnie użytkownicy i które grupy użytkowników mogą operować na danych w zasobie oraz w jakim zakresie mogą to robić. Z tego względu każdy plik i katalog w systemie plików ma jasno określonego właściciela oraz prawa dostępu (kontrolowane przez właściciela
pliku lub administratora systemu), które są porównywane z uprawnieniami użytkownika, kiedy ten chce wykonać określoną operację. Na przykład właściciel pliku może odmówić dostępu do pliku innym użytkownikom; jeśli jakikolwiek inny użytkownik spróbuje wyświetlić dane zawarte w pliku, dostanie stosowną informację o odmowie dostępu, np.: Ubuntu 14.04.3: gynvael@ubuntu$ touch sample-file gynvael@ubuntu$ chmod 0700 sample-file gynvael@ubuntu$ mv sample-file /tmp gynvael@ubuntu$ ls -la /tmp/sample-file -rwx------ 1 gynvael gynvael 0 Aug 16 02:16 /tmp/sample-file anotheruser@ubuntu$ cat /tmp/sample-file cat: /tmp/sample-file: Permission denied Windows 7: gynvael:windows> echo.>c:\Windows\Temp\sample-file gynvael:windows> cacls c:\windows\temp\sample-file c:\windows\temp\sample-file BUILTIN\Administrators:(ID)F NT AUTHORITY\SYSTEM:(ID)F windows\gynvael:(ID)F anotheruser:windows>type c:\windows\temp\sample-file Access is denied. Sam mechanizm stojący za kontrolą praw dostępu, jak i sam format, w jakim są one określane, są zależne od systemu operacyjnego i ich szczegółowy opis wykracza poza zakres niniejszej książki. W praktyce używane są obecnie dwa mechanizmy: Znany z systemów unixowych mechanizm ogólnych praw dostępu, określający możliwość zapisu, odczytu oraz wykonania dla właściciela pliku, grupy oraz pozostałych użytkowników (traktowanych jako jedna grupa).
Używany w systemach z rodziny Windows, ale również opcjonalnie dostępny w niektórych systemach unixowych mechanizm listy praw dostępu (Access Control List, ACL), który pozwala na określenie konkretnych praw dostępu użytkowników lub grup.
do
danego
pliku
dla
wskazanych
Zaczynając od tego pierwszego przypadku, każdy plik oraz katalog ma jasno określonego właściciela oraz grupę, a same prawa dostępu do pliku sprowadzają się do 9-bitowej liczby (a w zasadzie 12-bitowej, o czym za chwilę), podzielonej na 3bitowe zestawy określające uprawnienia, kolejno (od najbardziej znaczących bitów): właściciela pliku, grupy, do której należy plik, oraz wszystkich innych użytkowników, tj. tych, którzy nie są ani właścicielami pliku, ani nie należą do grupy, której własnością jest plik (patrz również rys. 2). Każdy zestaw bitów określa: Bit 2 (Read): prawo do odczytu pliku lub wylistowania zawartości katalogu. Bit 1 (Write): prawo do modyfikacji pliku lub, w przypadku katalogu, utworzenia w nim nowego pliku lub katalogu, a także zmiany nazwy lub usunięcia istniejących plików (niezależnie od tego, kto jest ich właścicielem[170]). Bit 0 (eXecute): prawdo do uruchomienia pliku lub prawo do wejścia do katalogu (i zarazem ewentualne prawo do wejścia do katalogów podrzędnych, jeśli te nie zabronią tego we własnym zakresie). Oprócz tych trzech zestawów, dających razem 9 bitów, wykorzystywane są również trzy dodatkowe bity: Bit 11 (Set user ID, SUID): pliki wykonywalne z ustawionym bitem SUID są uruchamiane z prawami właściciela pliku, tj. nowy proces operuje z efektywnymi uprawnieniami użytkownika, którego własnością był plik w momencie uruchomienia (przykładem mogą być polecenia /bin/su oraz /bin/mount działające z uprawnieniami użytkownika root) [171]. Bit 10 (Set group ID, SGID): analogicznie jak w przypadku SUID, pliki wykonywalne z ustawionym bitem SGID są wykonywane z uprawnieniami grupy, która jest właścicielem pliku w momencie
uruchomienia (przykładem mogą być polecenia /usr/bin/screen lub /usr/bin/crontab); w przypadku katalogów każdy utworzony plik lub podkatalog automatycznie zostanie własnością grupy będącej właścicielem katalogu z bitem SGID (zmieniona zostanie jedynie grupa – użytkownik będący właścicielem pliku pozostaje bez zmian). Bit 9 (resTricted deletion flag / sTicky bit): jeśli bit ten jest zapalony w przypadku katalogu, użytkownicy w katalogu nie mogą zmienić nazwy ani usunąć plików lub podkatalogów, których nie są właścicielami (typowym przykładem są katalogi /tmp oraz /dev/shm). Dla wygody prawa plików są najczęściej prezentowane w formie ósemkowej (w którym każda cyfra odpowiada dokładnie trzem bitom)[172] lub tekstowej. Na przykład liczba 04755 może zostać symbolicznie zapisana jako rwsr-xr-x (patrz również rys. 2).
Rysunek 2. Notacja ósemkowa, bitowa oraz tekstowa (symboliczna) praw dostępu do plików (GNU/Linux) Grupy i użytkownicy w GNU/Linux [VERBOSE] W przypadku systemów z rodziny GNU/Linux grupy oraz użytkownicy są zdefiniowani w dwóch plikach, kolejno /etc/group oraz /etc/passwd[173]. Przykładowe wpisy dotyczące danego użytkownika mogą wyglądać następująco: $ cat /etc/passwd | grep gynvael gynvael:x:1000:1000:Gynvael Coldwind,,,:/home/gynvael:/bin/bash W tym przypadku ID użytkownika gynvael jest równe 1000. Takie samo jest również ID głównej grupy użytkownika (domyślnie wszystkie pliki tworzone przez użytkownika są równocześnie własnością jego głównej grupy): $ cat /etc/group | grep gynvael adm:x:4:gynvael,syslog cdrom:x:24:gynvael www-data:x:33:gynvael gynvael:x:1000: admin:x:112:gynvael Z powyższego listingu można się dowiedzieć, iż główna grupa użytkownika (GID 1000) nazywa się gynvael, tj. tak samo jak użytkownik – jest to domyślne ustawienie w przypadku tworzenia nowego użytkownika na systemie Ubuntu. Ponadto możemy wyciągnąć wniosek, iż użytkownik należy do grup adm (GID 4), cdrom (GID 24), www-data (GID 33) oraz admin (GID 112). Analogiczne informacje można uzyskać, wykonując polecenie id, opcjonalnie z nazwą użytkownika w parametrze, np.: $ id uid=1000(gynvael) gid=1000(gynvael) groups=1000(gynvael),4(adm), 24(cdrom),33(www-data),112(admin) Opis przeznaczenia każdej grupy oraz dokładnego formatu obu wymienionych plików można znaleźć w dokumentacji systemowej (opis
formatów jest dostępny m.in. w dokumentacji systemu, tj. man 5 passwd oraz man 5 group). Standardowe API dostępne w systemach z rodziny GNU/Linux oraz językach, które oferują biblioteki specyficzne dla tych systemów, umożliwiają operowanie na prawach do pliku, a w szczególności ich odczyt oraz modyfikację. Dalej znajduje się przykładowy program w języku Python 3 (kompatybilny z 2.7), który prezentuje użycie wspomnianej wcześniej funkcji stat do wyświetlenia praw dostępu ustawionych na określonych plikach lub katalogach:
#!/usr/bin/python # -*- coding: utf-8 -*import os import stat import sys import pwd import grp def uid_to_username(uid): try: return pwd.getpwuid(uid).pw_name except KeyError as e: return "???" def gid_to_groupname(gid): try: return grp.getgrgid(gid).gr_name except KeyError as e: return "???" def mod_to_string(mod): perms = "" perms += "-r"[bool(mod & 4)] perms += "-w"[bool(mod & 2)]
perms += "-x"[bool(mod & 1)] return perms def special_to_string(mod): spec = [] if mod & 0o4000: spec.append("SUID") if mod & 0o2000: spec.append("SGID") if mod & 0o1000: spec.append("sticky") return ' '.join(spec) def main(): for fname in sys.argv[1:]: print("%s %s" % ("-" * 40, fname)) try: s = os.stat(fname) user_name = uid_to_username(s.st_uid) group_name = gid_to_groupname(s.st_gid) perms_owner = mod_to_string(s.st_mode >> 6) perms_group = mod_to_string(s.st_mode >> 3) perms_others = mod_to_string(s.st_mode) special = special_to_string(s.st_mode) print("%-14s: %s" % ("Owner", user_name)) print("%-14s: print("%-14s: print("%-14s: print("%-14s:
%s" %s" %s" %s"
% % % %
("Group", group_name)) ("Perms (owner)", perms_owner)) ("Perms (group)", perms_group)) ("Perms (others)", perms_others))
print("%-14s: %s" % ("Special", special)) except OSError as e: print("error accessing file: %s" % e.strerror) if __name__ == "__main__": main() Przykładowe wykonanie przebiega następująco: $ python3 show_mod.py /etc/passwd /tmp /bin/mount
---------------------------------------- /etc/passwd Owner Group
: root : root
Perms (owner) : rwPerms (group) : r-Perms (others): r-Special : ---------------------------------------- /tmp Owner : root Group : root Perms (owner) : rwx Perms (group) : rwx Perms (others): rwx Special : sticky ---------------------------------------- /bin/mount Owner : Group : Perms (owner) : Perms (group) :
root root rwx r-x
Perms (others): r-x Special : SUID Dalej znajduje się drugi przykład, tym razem w języku C, który prezentuje, jak bezpiecznie stworzyć plik tymczasowy o określonych prawach dostępu, a następnie umożliwić dostęp do niego użytkownikom z grupy www-data. W tym celu użyta zostanie niskopoziomowa funkcja open, która, w przeciwieństwie do dostępnej w języku C funkcji fopen (oraz analogicznych funkcji lub klas i metod obecnych w C++), pozwala na bardzo dokładne określenie zachowania w przypadku, gdy tworzony plik już istnieje, a także umożliwia zdefiniowanie pierwotnych praw dostępu do nowo utworzonego pliku. Mówiąc ściślej, w tym wypadku plik zostanie utworzony w publicznie dostępnym katalogu /tmp (a więc potencjalnie każdy mógłby mieć do niego dostęp), jednak uprawnienia do jego odczytu i zapisu zostaną ograniczone jedynie do jego właściciela (co zapewniają prawa dostępu ustawione na 0600). Co więcej, w razie gdyby plik o takiej nazwie już istniał[174], funkcja open zakończy się z błędem, co gwarantuje zastosowanie
flag O_CREAT oraz O_EXCL, które razem powodują, iż otwarcie pliku powiedzie się tylko w przypadku, gdy faktycznie zostanie utworzony nowy plik. Kod źródłowy opisanego programu prezentuje się następująco: #include #include #include #include #include #include
int main(void) { int fd = open("/tmp/knownname", O_CREAT | // Utwórz nowy plik, jeśli nie istnieje. O_EXCL | // Upewnij się, że na pewno zostanie // utworzony nowy plik (tj. plik nie istniał // wcześniej). O_WRONLY, // Otwarcie tylko do zapisu. 0600 // S_IRUSR | S_IWUSR - czyli rw------); if (fd == -1) { perror("Failed to create file"); return 1; } // W tym momencie tylko obecny użytkownik posiada // dostęp do nowego pliku. // ... // Załóżmy, że po wykonaniu stosownych operacji na pliku chcemy dać
// użytkownikom z grupy www-data dostęp tylko do odczytu do pliku. struct group *www_data = getgrnam("www-data"); if (www_data == NULL) { perror("Failed to get ID of group www-data"); close(fd); return 2; } // Uwaga: aby móc zmienić grupę pliku, użytkownik musi być // członkiem docelowej grupy. if (fchown(fd, -1, www_data->gr_gid) == -1) { perror("Failed to change group"); close(fd); return 3; } // Nowe uprawnienie: S_IRGRP, co daje rw-r-----. if (fchmod(fd, 0640) == -1) { perror("Failed to change permissions"); close(fd); return 4; } close(fd); return 0; } Kompilacja oraz dwukrotne uruchomienie (w celu przetestowania, czy za drugim razem program prawidłowo zakończy się z błędem) przebiegają następująco: $ gcc newfile.c -Wall -Wextra -o newfile $ ./newfile $ ls -la /tmp/knownname -rw-r----- 1 gynvael www-data 0 Aug 16 01:47 /tmp/knownname
$ ./newfile Failed to create file: File exists Powyższy system praw jest wystarczający w większości scenariuszy, niemniej jednak ostatecznie jest on mało elastyczny i dokładne rozdanie praw kilku użytkownikom może wymagać utworzenia dodatkowych grup w systemie (co wymaga już uprawnień administratora, które niekoniecznie muszą być dostępne). Jednym z mechanizmów adresujących wymienione utrudnienia są tzw. listy kontroli dostępu (Access Control List, ACL), w przypadku których każdy obiekt w systemie plików posiada rozszerzalny spis praw dostępu (w tym praw odmawiających dostępu) dla każdego wskazanego użytkownika lub grupy użytkowników. Współczesne systemy
z
rodziny
GNU/Linux
często
domyślnie oferują
możliwość korzystania z list kontroli dostępu[175] (w formie tzw. POSIX ACL), a także zestaw dodatkowych poleceń do operacji na nich. Przykład wykorzystania mechanizmu ACL (w szczególności poleceń setfacl i getfacl) pod systemem Ubuntu 14.04.3 znajduje się poniżej: root# echo Example. > testfile root# ls -la testfile -rw------- 1 root root 9 Aug 17 19:32 testfile root# sudo -u anotheruser cat /var/testfile cat: /var/testfile: Permission denied root# setfacl -m u:anotheruser:r testfile root# ls -la testfile -rw-r-----+ 1 root root 9 Aug 17 19:32 testfile root# getfacl testfile # file: testfile # owner: root # group: root user::rwuser:anotheruser:r-group::--mask::r-other::--root# sudo -u anotheruser cat /var/testfile
Example. W przedstawionym listingu warto zwrócić uwagę na kilka kwestii: ACL oraz ogólne prawa dostępu w przypadku systemów unixowych są używane razem. Z założenia w przypadku danej operacji zawsze sprawdzane jest uprawnienie lepiej opisujące użytkownika (np. wpis nadający lub odbierający określone prawo konkretnemu użytkownikowi ma pierwszeństwo względem wpisu definiującego uprawnienia grupy, w której użytkownik się znajduje, oraz względem ogólnego others). Szczegóły uprawnienia wynikające z listy kontroli dostępu są dodatkowo maskowane (w rozumieniu maski bitowej) przez wartość znajdującą się w polu bitowym mask (w przypadku powyżej ustawione na r--) [8]. Polecenie ls wyświetla znak plusa na końcu podstawowych praw, jeśli plik ma dodatkowo zdefiniowaną listę kontroli dostępu. API pozwalające korzystać z mechanizmu ACL dostępne jest m.in. z poziomu języka C (sys/acl.h), a także w ramach modułu posix1e w języku Python. Kod źródłowy testowego programu w tym języku wygląda następująco: #!/usr/bin/python # -*- coding: utf-8 -*import posix1e import pwd import grp TAGS_WITH_QUALIFIER = [ posix1e.ACL_USER, posix1e.ACL_GROUP ] def print_acl_info(fn): acl = posix1e.ACL(file=fn) for entry in acl: if entry.tag_type in TAGS_WITH_QUALIFIER: # Wpis dla konkretnego użytkownika lub grupy. user_name = "???" try:
# Mając ID użytkownika lub grupy, znajdź odpowiadającą nazwę # w /etc/passwd lub /etc/group. if entry.tag_type == posix1e.ACL_USER: user_name = pwd.getpwuid(entry.qualifier).pw_name else: user_name = grp.getgrgid(entry.qualifier).gr_name except KeyError as e: # Brak wpisu. pass print("%-40s: %s (%s)" % (entry, entry.permset, user_name)) else: # Wpis ogólny. print("%-40s: %s" % (entry, entry.permset)) def _remove_dup(acl, entry): # Znajdź i usuń wszystkie duplikaty, tj.: # - W przypadku ogólnych wpisów (user/group/other) usuń wskazany # ogólny wpis, tj. taki, w którym zgadza się tag_type. # - W przypadku konkretnych wpisów (user obj/group obj) usuń # wskazany konkretny wpis, tj. taki, w którym zgadza się zarówno # tag_type, jak i qualifier. for e in acl: if e.tag_type == entry.tag_type: if e.tag_type in TAGS_WITH_QUALIFIER and e.qualifier == entry.qualifier: acl.delete_entry(e) else: acl.delete_entry(e) def _reset_acl(fn, s, append): file_acl = posix1e.ACL(file=fn)
acl = posix1e.ACL(text=s) for entry in acl: _remove_dup(file_acl, entry) if append: file_acl.append(entry) file_acl.calc_mask()
# Przelicz maskę.
if not file_acl.valid(): print("invalid ACL provided: %s" % acl) return False # Zaaplikuj przeliczone ACL. file_acl.applyto(fn) return True def remove_acl(fn, s): return _reset_acl(fn, s, False) def add_acl(fn, s): return _reset_acl(fn, s, True) # Kilka przykładowych testów powyższych funkcji. test_file = "/home/gynvael/testfile" # Plik musi istnieć. test_user = "www-data" print("---- Original ACL:") print_acl_info(test_file) print("---- Adding user %s to ACL with RWX:" % test_user) add_acl(test_file, "u:%s:rwx" % test_user) print_acl_info(test_file) print("---- Changin %s's access to R-X:" % test_user)
add_acl(test_file, "u:%s:r-x" % test_user) print_acl_info(test_file) print("---- Removing %s from ACLs:" % test_user) remove_acl(test_file, "u:%s:-" % test_user) print_acl_info(test_file) Samo wykonanie przebiega w następujący sposób: $ python acls.py ---- Original ACL: ACL entry for the owner
: rwACL entry for the group : rwACL entry for the mask : rwACL entry for the others : r----- Adding user www-data to ACL with RWX: ACL ACL ACL ACL
entry entry entry entry
for for for for
the owner user with uid 33 the group the mask
: : : :
rwrwx (www-data) rwrwx
ACL entry for the others ---- Changin www-data's access to R-X: ACL entry for the owner
: r--
ACL ACL ACL ACL
user with uid 33 the group the mask the others
: : : :
r-x (www-data) rwrwx r--
www-data from ACLs: the owner the group the mask the others
: : : :
rwrwrwr--
entry entry entry entry
for for for for
---- Removing ACL entry for ACL entry for ACL entry for ACL entry for
: rw-
Zdecydowanie bardziej rozbudowany mechanizm list kontroli dostępu jest używany w systemach z rodziny Windows, w których każdy plik[176] posiada dwie listy kontroli dostępu:
DACL (Discretionary Access Control List), określająca prawa dostępu do pliku. SACL (System Access Control List), określająca, które próby dostępu powinny być logowane przez system[177]. DACL, w porównaniu z POSIX ACL, definiuje zdecydowanie więcej praw do plików i katalogów – są one przedstawione w tabeli 2 (w formie pełnej [9][10][11]) oraz tabeli 3 (w formie uproszczonej). Tabela 2. Prawa do plików i katalogów (w kolejności oferowanej przez narzędzie icacls
Skrót
Pełna nazwa prawa
Działanie w przypadku pliku
Działanie w przypadku katalogu
DE
Delete
Prawo do us unięcia pliku lub katalog u.
RC
Read Control
Prawo do odczytu DACL pliku lub katalog u (ale już nie S ACL).
W DAC
Write DAC
Prawo do modyfikacji DACL pliku lub katalog u.
WO
Write Owner
Prawo do zmiany właś ciciela pliku lub katalog u.
S
Synchronize
Prawo do użycia uchwytu do pliku lub katalog u z funkcją do s ynchronizacji (np. WaitForSingleObject)[178].
RD
Read Data
Prawo do odczytu danych z pliku.
Prawo do wylis towania zawartoś ci katalog u.
WD
Write Data
Prawo do zapis u do pliku.
Prawo utworzenia pliku w katalog u.
AD
Append Data
–
Prawo utworzenia podkatalog u w katalog u.
REA
Read Extended Attributes
Prawo do odczytu rozs zerzonych atrybutów.
W EA
Write Extended Attributes
Prawo do zapis u rozs zerzonych atrybutów.
X
Execute
Prawo do wykonania.
Prawo do wejś cia do katalog u.
DC
Delete Child
Prawo do us unięcia pliku (tożs ame z DE).
Prawo us unięcia katalog u, łącznie ze ws zys tkimi podkatalog ami i plikami, które zawiera.
RA
Read Attributes
Prawo do odczytu pods tawowych atrybutów.
WA
Write Attributes
Prawo do zapis u pods tawowych atrybutów.
Tabela 3. Uproszczone prawa do plików i katalogów, udostępniane m.in. przez niektóre podstawowe narzędzia (w tym cacls, icacls oraz Explorer)
Skrót
Pełna nazwa prawa
Odpowiadające prawa w formie pełnej
N
No access
(brak uprawnień)
F
Full access
M, W DAC, W O
M
Modify access
RX, W, D
RX
Read and execute access
R, X
R
Read-only access
RA, RD, REA, RC, S
W
Write-only access
AD, W A, W D, W EA, RC, S
D
Delete access
DE
Przykład wykorzystania narzędzi konsolowych do utworzenia współdzielonego katalogu oraz pliku, do którego dostęp (tylko do odczytu) otrzyma inny użytkownik, wygląda następująco: gynvael> cd e:\ gynvael> mkdir shared gynvael> icacls shared shared windows\gynvael:(I)(OI)(CI)(F) ... gynvael> icacls shared /grant anotheruser:(RX) processed file: shared ... gynvael> echo Test file.>e:\shared\testfile.txt gynvael> icacls e:\shared\testfile.txt e:\shared\testfile.txt windows\gynvael:(I)(F) ... anotheruser> e: anotheruser> cd \shared anotheruser> rem W tym momencie użytkownik nie będzie miał jeszcze dostępu. anotheruser> type testfile.txt Access is denied. gynvael> icacls e:\shared\testfile.txt /grant anotheruser:(R) processed file: e:\shared\testfile.txt ... gynvael> icacls e:\shared\testfile.txt
e:\shared\testfile.txt windows\anotheruser:(R) windows\gynvael:(I)(F) ... anotheruser> type testfile.txt Test file. Alternatywnie
możliwe
jest
skorzystanie
z
mechanizmu
dziedziczenia
uprawnień (którego dokładny opis wykracza poza zakres tematyczny książki[179]), dzięki któremu każdy kolejny plik i katalog tworzony w katalogu e:\shared byłby od razu dostępny dla użytkownika anotheruser: gynvael> icacls e:\shared /remove:g anotheruser processed file: e:\shared ... gynvael> icacls e:\shared /grant anotheruser:(CI)(RX) anotheruser:(OI)(R) processed file: e:\shared ... gynvael> icacls e:\shared e:\shared windows\anotheruser:(OI)(R) windows\anotheruser:(CI)(RX) windows\gynvael:(I)(OI)(CI)(F) ... gynvael> echo Another test file.>e:\shared\anothertestfile.txt gynvael> mkdir e:\shared\testdir gynvael> icacls e:\shared\anothertestfile.txt e:\shared\anothertestfile.txt windows\anotheruser:(I)(R) windows\gynvael:(I)(F) ... gynvael:> icacls e:\shared\testdir e:\shared\testdir windows\anotheruser:(I)(OI)(IO)(R) windows\anotheruser:(I)(CI)(RX) windows\gynvael:(I)(OI)(CI)(F) ...
Z punktu widzenia programowania operowanie na Windowsowej implementacji ACL jest analogiczne do operowania na POSIX ACL, z dokładnością do stopnia skomplikowania. W praktyce definiowane prawa zapisuje się w SDDL (Security Descriptor Definition Language) [14], który niestety nie jest specjalnie czytelny i którego dokładną analizę pozostawiam zainteresowanym czytelnikom. W niniejszej książce ograniczę się do prostego przykładu stworzenia pliku, do którego dostęp tylko-do-odczytu otrzyma jeszcze jeden użytkownik (C++, WinAPI): #include #include #include #include #include int main() { const std::string file_path("e:\\shared\\SharedFile.txt"); // Katalog e:\\shared posiada następujące wpisy na liście kontroli dostępu: // windows\gynvael:(I)(OI)(CI)(F) // windows\anotheruser:(RX) // Domyślnie utworzenie nowego pliku w tym katalogu nie pozwala // użytkownikowi anotheruser na jakikolwiek dostęp do pliku. // Podobnie jak na GNU/Linux, należy przetłumaczyć nazwę użytkownika // na jego identyfikator (SID). BYTE sid_bytes[SECURITY_MAX_SID_SIZE]; DWORD sid_size = sizeof(sid_bytes); CHAR domain_name[256]; DWORD domain_name_size = sizeof(domain_name); SID_NAME_USE sid_type; BOOL ret = LookupAccountName( NULL, "anotheruser",
sid_bytes, &sid_size, domain_name, &domain_name_size, &sid_type); if (!ret) { DWORD last_error = GetLastError(); std::cerr /proc/sys/kernel/yama/protected_sticky_symlinks
Dla bezpieczeństwa nie zaleca się wykonywania tego ćwiczenia na serwerze współdzielonym przez dwóch lub więcej użytkowników.
Bibliografia [1]
Server Message Block, Wikipedia, https://en.wikipedia.org/wiki/Server_Message_Block [2] Network File System, Wikipedia, https://en.wikipedia.org/wiki/Network_File_System [3] iSCSI, Wikipedia, https://en.wikipedia.org/wiki/ISCSI
http://coldwind.pl/s/c4r10
[4] Khan M., Re: command line globbing, Cygwin project mail archive, https://www.cygwin.com/ml/cygwin/1999-11/msg00052.html [5] lstat(2), Linux man page, http://linux.die.net/man/2/lstat [6] FilePermissionsACLs, Ubuntu documentation, [7]
https://help.ubuntu.com/community/FilePermissionsACLs Zieja K., Enable Support for ACL in Debian / Ubuntu, http://www.projectenvision.com/blog/4/Enable-Support-for-ACL-in-DebianUbuntu
[8] Gruenbacher A., POSIX Access Control Lists on Linux, SuSE Linux AG, http://www.vanemery.com/Linux/ACL/POSIX_ACL_on_Linux.html [9] Icacls, Microsoft Developer Network, https://technet.microsoft.com/enus/library/cc753525.aspx [10] Standard Access Rights, Microsoft https://msdn.microsoft.com/enus/library/windows/desktop/aa379607(v=vs.85).aspx
Developer
[11] File Security and Access Rights, Microsoft Developer https://msdn.microsoft.com/enus/library/windows/desktop/aa379607(v=vs.85).aspx [12] Security Auditing, Microsoft Developer https://technet.microsoft.com/en-us/library/cc771395(v=ws.10).aspx
Network,
Network,
Network,
[13] Asynchronous I/O and The Asynchronous Disk I/O Explorer, FlounderCraft Ltd., http://www.flounder.com/asynchexplorer.htm [14] Security Descriptor Definition Language, Microsoft Developer Network, https://msdn.microsoft.com/en-
us/library/windows/desktop/aa379567(v=vs.85).aspx [15] Compression – LZNT1, http://forensicswiki.org/wiki/Compression#LZNT1
ForensicsWiki,
[16] Changes in EFS, Microsoft Developer Network, https://technet.microsoft.com/en-us/library/dd630631(WS.10).aspx [17] Peter i in., Copy a file in a sane, safe and efficient way, stackoverflow, http://stackoverflow.com/questions/10195343/copy-a-file-in-a-sane-safe-andefficient-way [18] Drive letter assignment, Wikipedia, https://en.wikipedia.org/wiki/Drive_letter_assignment [19] Directories and the Set-User-ID and Set-Group-ID Bits, GNU Coreutils, https://www.gnu.org/software/coreutils/manual/html_node/Directory-Setuidand-Setgid.html
Rozdział 11.
Pliki binarne i tekstowe W poprzednim rozdziale omówiliśmy temat zarządzania plikami na poziomie systemu plików, a także podstawowe, niskopoziomowe operacje na samych plikach, takie jak otwieranie, tworzenie, odczyt, zapis, kontrola kursorów itp. W tym rozdziale pójdziemy o krok dalej – w stronę wyższego poziomu abstrakcji – i skupimy się na głównej funkcji plików, którą jest trwałe przechowanie istotnych dla programu danych.
11.1. Pliki tekstowe W programowaniu, a po części również ogólnie w informatyce, obowiązuje mniej lub bardziej formalne rozróżnienie na pliki binarne oraz tekstowe. Koncepty te są bardzo intuicyjne i sprowadzają się do następujących, nieformalnych definicji: W plikach binarnych dane przechowywane są w sposób zakodowany, bardzo często w sposób identyczny jak ma to miejsce w pamięci operacyjnej programu, dzięki czemu zapis i odczyt danych może być bardzo szybki – często wystarczy jedynie wczytać bajty do pamięci i można od razu na nich operować. Z drugiej strony, zawartość pliku jest dla człowieka nieczytelna (patrz rys. 1), a więc wykonywanie ręcznych modyfikacji jest utrudnione i wymaga odpowiedniego podejścia i narzędzi. Pliki tekstowe są przede wszystkim czytelne dla człowieka – można je otworzyć dowolnym edytorem tekstowym i bez problemu na nich operować[195]. Ta sama cecha sprawia, że dane w tej postaci są zasadniczo niezrozumiałe dla procesora, przez co przed wykonaniem faktycznych, programowych operacji na zapisanych za ich pomocą danych konieczne jest wykonanie szeregu analiz i konwersji.
O ile na poziomie zapisu na fizycznym medium pliki binarne i tekstowe niczym się nie różnią, o tyle pewne różnice dotyczą dwóch innych aspektów: W przypadku niektórych systemów operacyjnych (np. z rodziny Windows) podczas otwierania pliku za pomocą pewnych standardowych funkcji (np. ze standardowej biblioteki języka C i C++) należy zadeklarować, czy plik ma zostać otwarty jako tekstowy (wywołanie fopen(..., "r")), czy binarny (fopen(..., "rb")). Tryb otwarcia ma wpływ na zachowanie kilku funkcji operujących na danym pliku. Istnieją odrębne zestawy funkcji do odczytu i zapisu danych charakterystycznych dla obu typów tych plików. W przypadku plików binarnych funkcje te są zorientowane na pojedyncze bajty, podczas gdy w przypadku plików tekstowych operują one na konceptach „znaków” oraz „linii”.
Rysunek 1. Czytelność plików binarnych Pierwsza ze wskazanych różnic, dotycząca obecnie jedynie systemu Windows, ma związek przede wszystkim ze znacznikiem końca linii[196]. Znacznik ten jest istotny w momencie wyświetlania tekstu na konsoli (lub innym, analogicznym interfejsie wyjściowym), gdzie jego celem jest poinformowanie implementacji konsoli o konieczności przeniesienia kursora do nowej linii. W przypadku współczesnych emulatorów terminali (zarówno pod systemami unixowymi, jak i systemami z rodziny Windows) za znacznik końca linii uznawany jest przede wszystkim znak LF (Line Feed) o kodzie 0x0A, reprezentowany w językach
programowania za pomocą sekwencji ucieczki \n. Cofając się w czasie do pierwszych terminali, korzystających jeszcze z drukarek zamiast ekranów, okazuje się, że przejście do następnej linii było wówczas realizowane za pomocą dwóch znaków sterujących: CR (Carriage Return, powrót karetki, czyli znak \r o kodzie 0x0D), który powodował powrót głowicy drukarki na początek linii. Wspomnianego już LF, który powodował przesunięcie papieru o jedną linię w górę[197]. W połowie lat 60. ubiegłego wieku za sprawą systemu operacyjnego Multics doszło do rozwidlenia, czego powodem było wprowadzenie sterownika pośredniczącego pomiędzy programem a terminalem, którego zadanie polegało na tłumaczeniu pojedynczego znaku końca linii (LF) na konkretną sekwencję, wymaganą przez podłączony terminal. Ostatecznie systemy wzorujące się na Multics za znacznik końca linii uznawały LF, podczas gdy systemy wierne archaicznym terminalom pozostały przy sekwencji CR LF. Wracając do czasów współczesnych, jak można się domyślić, systemy unixowe (w tym te z rodziny GNU/Linux) wywodzą się z rozgałęzienia Multics – stąd ich preferencja względem sekwencji LF. Z drugiej strony MS-DOS oraz pierwsze systemy z rodziny Windows podążały ścieżką oryginalnych terminali, a więc sam znak LF wystarczał na nich jedynie do przesunięcia kursora w pionie (patrz rys. 2). Warto dodać, że dzisiejszym terminalom pod systemem Windows wystarcza już sam znak LF, niemniej jednak to nadal CR LF jest oficjalnie uznawane za koniec linii i takie zachowanie jest również wymagane przez ogromną liczbę programów (np. edytorów tekstowych, kontrolek w interfejsie graficznym itp.).
Rysunek 2. Zachowanie sekwencji LF oraz CR LF pod systemem MS-DOS Z punktu widzenia programowania, a w szczególności języków wywodzących się ze środowisk unixowych (C, C++ itp.), znakiem końca linii jest jednak sam znacznik LF. Oznacza to potencjalnie, że korzystając z identycznych portów tych języków na systemach z rodziny DOS oraz Windows, programista musiałby obsługiwać dodatkowo pojawiający się znak CR na końcu każdej linii. Aby zapobiec tej sytuacji i zwiększyć kompatybilność pomiędzy platformami, stosuje się właśnie tryb tekstowy podczas operacji na plikach. W praktyce wszystkie funkcje odczytujące dane z pliku otwartego w trybie tekstowym dokonują automatycznej konwersji sekwencji CR LF na pojedynczy znak LF. Co więcej, wszystkie funkcje zapisujące dane do pliku w tym trybie dokonują konwersji znaku LF na sekwencję CR LF, dzięki czemu programista, tworząc kod, nie musi zajmować się różnicami wynikającymi z używanej na danym systemie konwencji. Należy jednak zaznaczyć, że tego typu konwersja w przypadku plików binarnych jest absolutnie niepożądana i prowadzi do uszkodzeń danych podczas odczytu i zapisu. Konieczne jest więc zachowanie
szczególnej ostrożności podczas otwierania plików, korzystając z API, które wspiera oba tryby pracy z ich zawartością. Druga różnica pomiędzy trybem binarnym a tekstowym dotyczy znaku o kodzie 0x1A; w trybie tekstowym jest to tzw. miękki koniec pliku (soft EOF). Niektóre API, a w szczególności używana pod systemem Windows implementacja standardowej biblioteki języka C (z której korzysta m.in. wzorcowa implementacja języka Python na tym systemie), uznają pierwsze wystąpienie znaku 0x1A za koniec pliku i odmawiają odczytu dalszych danych po natrafieniu na ten znak, jednocześnie przenosząc kursor odczytu na faktyczny koniec pliku. Przykład wskazanego zachowania znajduje się poniżej (język Python): >>> >>> >>> >>> >>>
f = open("testfile", "wb") # Tryb binarny f.write("aaa" + "\x1a" + "bbb") f.close() f = open("testfile", "r") # Tryb tekstowy f.read()
'aaa' >>> f.tell() 7L >>> f.read() '' Co ciekawe, przenosząc „manualnie” kursor odczytu na pozycję bezpośrednio po znaku 0x1A, otrzymujemy dostęp do „ukrytych” danych: >>> f.seek(4) >>> f.read() 'bbb' Samo oznaczenie trybu otwarcia pliku zależy od konkretnego interfejsu oraz języka. Tabela 1 zawiera przykładowy wykaz oznaczeń, istotnych oczywiście jedynie w systemie Windows. Należy dodać, że w większości przypadków domyślnym trybem operacji jest tryb tekstowy, co jest niestety częstą przyczyną błędów, szczególnie w programach przenoszonych z systemów unixowych. Tabela 1. Oznaczenia binarnego i tekstowego trybu otwarcia pliku w różnych językach programowania
Język, biblioteka, API
T ryb binarny
T ryb tekstowy
Uwagi
C
Litera „ b” , np.:
Domyś lnie lub litera „ t” , np.: fopen ("test", "w")
Domyś lny tryb otwarcia można zmienić, korzys tając ze zmiennej g lobalnej _fmode [1].
fopen("test", "wb")
C / POS IX
_O_BINARY
Domyś lnie _O_TEXT
C++
std::*fstream:: binary
Domyś lnie
Python
(jak w przypadku języka C)
Java
–
O ile pliki zaws ze s ą otwierane w trybie binarnym, o tyle s ekwencja uznawana za znak końca linii przez API do obs ług i danych teks towych zależy od platformy, na której jes t uruchomiona dana aplikacja. Domyś lne zachowanie można zmienić, modyfikując włas noś ć line.separator (np. za pomocą metody System.setProperties).
Przechodząc do samych operacji na plikach tekstowych, są one zbliżone lub identyczne do operacji na strumieniach standardowego wyjścia oraz wejścia. Konkretniej najczęściej wykorzystywaną funkcjonalnością jest pobranie linii tekstu[198]; ewentualnie, jeśli dane API udostępnia taką możliwość, korzysta się
również z możliwości wczytania fragmentu tekstu i jego natychmiastowej konwersji na wskazany typ (np. wczytanie ciągu "1234" i zamiana na zmienną typu całkowitego o wartości 1234). Dostępność funkcji wczytująco-konwertujących jest na szczęście niekonieczna, ponieważ po wczytaniu danej linii tekstu do zmiennej typu string można skorzystać z funkcji operujących na łańcuchach do uzyskania tego samego efektu. Dokładne omówienie tematyki przetwarzania skomplikowanych formatów plików wykracza poza zakres książki, niemniej jednak w ramach przykładu[199] zademonstruję prosty parser plików konfiguracyjnych w formacie INI [2]. Przykładowy plik w tym formacie może wyglądać następująco: test.ini: ; Przykładowy plik konfiguracyjny w formacie INI. max_enemy_count=20 [Hero] start_hp=100 start_mp=100 start_inv=Item.Dagger,Item.Cap [Item.Dagger] dmg=2 max_durability=15 value=4 [Item.Cap] ar=1 max_durability=9 value=2 Z logicznego punktu widzenia powyższy plik konfiguracyjny jest podzielony na sekcje, których początek – i jednocześnie nazwa – jest oznaczony za pomocą znaków [ oraz ]. W sekcjach znajdują się nazwane pola oraz ich wartości (np. value=4). Oprócz tego występują również komentarze, zaznaczone znakiem ; znajdującym się na początku linii, oraz linie puste. Z punktu widzenia języka programowania najwygodniej jest opisane dane sprowadzić do postaci struktury
słownikowej nazwanych sekcji, gdzie każda sekcja byłaby słownikiem nazwanych pól. Dzięki takiemu podejściu dostęp do wskazanego pola może być realizowany w możliwie najprostszy sposób, np. dostęp do pola dmg z sekcji Item.Dagger wymagałby trywialnego zapisu my_ini["Item.Dagger"]["dmg"]. W celu sprowadzenia zawartości pliku do tej formy możemy np. obsługiwać linię po linii, gdzie dla każdej z nich nasz program próbowałby wykryć jej typ (komentarz, nowa sekcja, pole z wartością, pusta linia), przetworzyć ją oraz zapisać w odpowiednie miejsce powstającego słownika. Mogłoby to przebiegać w następujący sposób: Utwórz nowy słownik dla całości konfiguracji. Utwórz nowy słownik dla domyślnej sekcji i dodaj go do słownika. Nowo utworzoną sekcję uznaj za sekcję „aktywną”. Następnie, dla każdej wczytanej linii: – Jeśli linia zaczyna się od znaku ;, zignoruj ją. – Jeśli linia zaczyna się od znaku [ oraz kończy znakiem ], stwórz nowy słownik, dodaj go do słownika konfiguracji pod nazwą odczytaną spomiędzy nawiasów kwadratowych i tak uzyskaną nową sekcję uznaj za „aktywną”. – Jeśli linia nie zawiera znaku =, zignoruj ją (alternatywnie: jeśli linia nie jest pusta, zgłoś błąd). – Podziel linię na dwie części względem pierwszego znaku =. Dodaj drugą część do słownika aktywnej sekcji pod nazwą zgodną z pierwszą z uzyskanych części. Zwróć powstały słownik dla ogólnej konfiguracji. Warto zaznaczyć, że formaty tekstowe często zakładają pewną dowolność w kontekście wykorzystania białych znaków (spacji, tabulatorów itp.) i z tego względu przyjąć założenie, że pomiędzy częściami składowymi konfiguracji (tj. wskazanymi wyżej znakami specjalnymi, nazwami sekcji, nazwami pól oraz ich wartościami), a także tokenami i początkiem lub końcem linii może wystąpić dowolna liczba białych znaków. W takim przypadku dobrym rozwiązaniem będzie więc użycie funkcji usuwającej sekwencje białych znaków z początku oraz końca uzyskiwanych tokenów – wiele języków programowania posiada do tego
specjalne funkcje, zazwyczaj o nazwach zawierających angielskie słowa strip lub trim. Przykładowy kod realizujący opisany algorytm w języku Python wygląda następująco: #!/usr/bin/python # -*- coding: utf-8 -*import pprint import sys
def parse_ini(fname): ini = {} section = {} ini["__global__"] = section with open(fname) as f: for ln in f: # Odczyt linia po linii. # Komentarz. if ln.startswith(';'): continue # Usuń białe znaki z końca/początku linii. ln = ln.strip() if ln.startswith('[') and ln.endswith(']'): section_name = ln[1:-1].strip()
# Wydobądź nazwę
sekcji. section = {} ini[section_name] = section continue # Ignorowanie pustych i niepoprawnych linii. if '=' not in ln: continue
# Rozbij linię na dwie części względem znaku = i usuń białe znaki # z początku i końca obu uzyskanych części. name, val = [x.strip() for x in ln.split("=", 1)] section[name] = val return ini
if __name__ == "__main__": pp = pprint.PrettyPrinter() pp.pprint(parse_ini("test.ini")) Uruchomienie i wykonanie skryptu przebiegają następująco: > python parseini.py {'Hero': {'start_hp': '100', 'start_inv': 'Item.Dagger,Item.Cap', 'start_mp': '100'}, 'Item.Cap': {'ar': '1', 'max_durability': '9', 'value': '2'}, 'Item.Dagger': {'dmg': '2', 'max_durability': '15', 'value': '4'}, '__global__': {'max_enemy_count': '20'}} Jeśli chodzi o tworzenie plików tekstowych, a raczej tekstowy zapis danych do otwartych plików, to sprowadza się on do czynności identycznych jak wypisywanie danych na standardowe wyjście i nie wymaga dodatkowego komentarza (zachęcam natomiast początkujących czytelników do wykonania ćwiczenia „FILE:ini-writer”).
11.2. Pliki binarne Dane w plikach binarnych są, podobnie jak miało to miejsce w przypadku pamięci operacyjnej, sprowadzone do postaci serii pojedynczych bajtów. W tym celu stosuje się m.in. kodowania omówione w drugiej części książki, ale nie tylko – w przeciwieństwie do sytuacji z pamięcią operacyjną wybór stosowanych
kodowań nie jest ograniczony do tych, z których dany procesor potrafi natywnie korzystać. Oznacza to, że operując na istniejących formatach, można zetknąć się z bardzo różnorodnymi i ciekawymi kodowaniami, które nie są widoczne w pamięci operacyjnej – przykładem może być kodowanie LEB128 opisane w ramce o tej samej nazwie. LEB128 [VERBOSE] LEB128 (Little Endian Base 128) jest prostym kodowaniem liczb naturalnych i całkowitych, spotykanym w niektórych formatach plików, np. w formacie symboli debuggera DWARF, a także w przypadku plików wykonywalnych DEX używanych w systemie Android. Tym, co odróżnia LEB128 od innych kodowań opisanych w rozdziale „Typy liczb naturalnych i całkowitych” , jest brak stałej szerokości zakodowanej wartości, co daje możliwość zapisu liczby o dowolnej wielkości. Schemat kodowania jest bardzo prosty (patrz również rys. 3) – wejściowa wartość jest sprowadzana do najkrótszej możliwej serii bitów o długości podzielnej przez siedem, za pomocą których można daną wartość wyrazić. W przypadku liczb naturalnych stosowany jest w tym celu zwykły system binarny, natomiast dla liczb całkowitych stosuje się kodowanie U2. W następnym kroku grupy 7 bitów, zaczynając od najmniej znaczących, są zapisywane jako kolejne bajty. Najstarszy bit w każdym bajcie służy do oznaczenia faktu, że w kolejnym bajcie znajduje się dalsza część wartości, a więc wszystkie zapisane bajty oprócz ostatniego będą miały zapalony najstarszy bit.
Rysunek 3. Schemat kodowania LEB128 Przebieg dekodowania jest następujących kroków: 1. Wyzeruj zmienną docelową.
jeszcze
prostszy
i
sprowadza
się
do
2. Wczytaj bajt. 3. Wydziel dolne 7 bitów. Dodaj je, odpowiednio przesunięte, do zmiennej docelowej. 4. Jeśli najstarszy bit wczytanego bajtu był zapalony, przejdź do punktu 2. 5. (tylko w przypadku liczb całkowitych) Dopełnij zmienną szóstym bitem (licząc od zera) ostatniego bajtu. Powyższy schemat charakteryzuje się zaletami i wadami identycznymi jak kodowanie znaków UTF-8 opisane w rozdziale „Znaki i łańcuchy znaków”, tzn. jest bardzo kompaktowe dla niewielkich wartości, ale większe z nich zostaną zapisane przy użyciu dłuższego ciągu bajtów, niż miałoby to miejsce
w przypadku klasycznego kodowania o stałej wielkości. Dodatkową zaletą jest wspomniana możliwość zakodowania wartości o dowolnym rozmiarze, choć z drugiej strony (w przeciwieństwie do UTF-8) poznanie długości zakodowanej reprezentacji liczby ma złożoność liniową i wymaga analizy wszystkich bajtów liczby, podczas gdy w przypadku UTF-8 wystarczy jedynie analiza pierwszego bajtu. Na wyższym poziomie abstrakcji informacje w pliku są najczęściej zapisywane w postaci struktur, nazywanych tutaj „nagłówkami”, lub w formie tablic prostych typów (nazywanych po prostu „danymi”) – dokładny opis takiego podejścia jest przedstawiony w rozdziale „Format BMP i wstęp do bitmap”. W bardziej złożonych, ale i bardziej elastycznych formatach struktury i dane mogą być łączone w tzw. bloki (chunk), o czym z kolei piszę w rozdziale „Format PNG”. O ile w znacznej liczbie formatów nagłówki, dane czy całe bloki są zapisywane jeden po drugim (w sposób ciągły bądź strumieniowy), o tyle niektóre formaty stosują strukturę bardziej przypominającą graf, w której dane i nagłówki są porozrzucane po całym pliku i wskazują na siebie za pomocą przesunięć[200] (patrz również rys. 4). Należy pamiętać, że struktura pliku, a także samo kodowanie poszczególnych pól są czysto umowne i jako takie nie są „fizycznie” zaznaczone w pliku, tj. posiadając jedynie jeden losowy bajt z pliku, nie jesteśmy w stanie określić ani do jakiego nagłówka lub pola z danymi on należy, ani tym bardziej, jak go zinterpretować.
Rysunek 4. Trzy przykładowe podejścia do projektowania struktury formatu plików W ostatnim przykładzie na rysunku 4 warto zwrócić uwagę na „puste” miejsce w pliku. Ponieważ plik jest ciągłym strumieniem bajtów, słowo „pusty” w tym kontekście oznacza bajty, których wartość nie jest w żaden sposób istotna ani wykorzystywana przez program przetwarzający dany format. Obecność „pustego” miejsca najczęściej wynika z jednej z następujących przyczyn: Stanowią one dopełnienie (padding), które stosuje się, gdy zapisana całkowita wielkość danego elementu (nagłówka, danych) musi spełniać określone założenia (np. być wielokrotnością liczby 4), ale faktyczny rozmiar elementu tego założenia nie spełnia. W takim wypadku stosuje się bajty dopełniające aż do otrzymania odpowiedniej wielkości.
Stanowią one wyrównanie (alignment), które stosuje się, gdy przesunięcie danego elementu w pliku musi spełniać określone, podobne do poprzedniego przypadku założenie (np. być wielokrotnością liczby 512). W takiej sytuacji przestrzeń pomiędzy poprzednim elementem a najbliższym przesunięciem spełniającym dane kryterium jest wypełniona bajtami o nieistotnej wartości. Terminy „wyrównanie” i „dopełnienie” mają bardzo zbliżone znaczenie i zdarza się, że używa się ich zamiennie (np. „wielkość wyrównana do wielokrotności czwórki” lub „bajty dopełniające do przesunięcia podzielnego przez 512”). Są one pozostałością po danych usuniętych z pliku na poziomie logicznym[201]. O ile w przypadku małych plików cała zawartość jest najczęściej przepisywana (a więc „puste” miejsce nie występuje), o tyle dla bardzo dużych plików bardziej korzystnym podejściem może być oznaczenie danych jako usuniętych, podobnie jak robi się to w przypadku dysków twardych i usuwanych plików. Zarówno dopełnienie, jak i wyrównanie mają na celu najczęściej optymalizację odczytu bądź użycia danych pod kątem szybkości, co wiąże się z niskopoziomowymi mechanizmami stosowanymi przez: Dyski twarde – jak wspominałem w poprzednim rozdziale, dyski twarde są podzielone na sektory. Umieszczenie danych na początku sektora sprawia, że dane nie przekraczają granicy większej liczby sektorów niż jest to konieczne, a co za tym idzie – ich odczyt jest szybszy. Procesor – użycie zmiennych, które znajdują się w pamięci operacyjnej na adresach naturalnych (tj. podzielnych bez reszty przez wielkość zmiennej), jest statystycznie szybsze niż w sytuacji, gdy adresy zmiennych nie są odpowiednio wyrównane – ma to związek z budową pamięci podręcznej procesora oraz stronicowaniem (opis tych mechanizmów wykracza jednak poza zakres książki). Co więcej, niektóre architektury wymagają, by adresy zmiennych były wyrównane naturalnie. Jeśli dane w pliku, pola nagłówków, jak i same nagłówki są zapisane z odpowiednimi wyrównaniami, to po wczytaniu
ich całościowo do pamięci można od razu operować na nich z optymalną szybkością. Z punktu widzenia
programowania
na
wysokim
poziomie abstrakcji
działanie na plikach binarnych sprowadza się do trzech prostych operacji: odczytu określonej liczby bajtów, zapisu określonej liczby bajtów oraz zarządzania kursorem pliku, o których wspominałem już w poprzednim rozdziale. Do tematu odczytu i zapisu można jednak podejść na kilka sposobów. Pierwszym z nich jest ograniczenie liczby operacji na samym pliku do absolutnego minimum i wczytanie (lub zapisanie) zawartości całego pliku za pomocą jednego wywołania. Pozwala to na komfort operowania na danych w pamięci, a co za tym idzie swobodny i bardzo szybki dostęp do dowolnego obszaru danych podczas ich przetwarzania bądź kodowania. W przypadku odczytu podejście to ma jednak dwie wady: po pierwsze, działa tylko dla plików mniejszych niż ilość dostępnej pamięci lub rozmiar przestrzeni adresowej, a po drugie, tworząc kod tego typu, łatwo o błędy związane z odwołaniami poza bufor (szczególnie w przypadku operowaniu na polach zawierających przesunięcia), co może prowadzić do niespodziewanych wyjątków lub, w przypadku języków niższego poziomu, wręcz problemów z bezpieczeństwem. W sytuacji tworzenia całego pliku w pamięci do zapisu, w zależności od języka, konieczne może być wyliczenie dokładnej wielkości docelowego pliku w celu zaalokowania bufora odpowiedniego rozmiaru, co w przypadku niektórych, bardziej złożonych formatów (korzystających z kodowań zmiennej długości czy kompresji) może być zdecydowanie nietrywialne – na szczęście nie jest to konieczne w przypadku języków wysokiego poziomu. W ogólności podejście tego typu doskonale sprawdza się dla programów pomocniczych operujących na relatywnie niewielkich plikach.
Rysunek 5. Schemat przykładowego formatu plików Aby zademonstrować opisane podejście, posłużę się przykładem bardzo prostego formatu plików (patrz również rys. 5), rozpoczynającego się od dwubajtowego nagłówka zawierającego jedno pole z liczbą (zakodowaną metodą Little Endian) zestawów danych. Od razu po nagłówku zapisane będą same zestawy danych, z których każdy będzie się zaczynał od jednobajtowego nagłówka zestawu mówiącego o liczbie wartości w zestawie oraz samych wartości, z których każda będzie 32-bitową liczbą naturalną (zakodowaną metodą Little Endian). Poniżej znajduje się przykładowy kod stworzony w języku Python (2.7, 3), który konstruuje w pamięci zawartość pliku w opisanej strukturze. Samym kodowaniem liczb do postaci binarnej zajmuje się funkcja struct.pack:
#!/usr/bin/python # -*- coding: utf-8 -*# Wysokopoziomowe tworzenie całego pliku w pamięci. from struct import pack # Stwórz plik postaci: # N # # # #
0: 1: 1 2: 2 2 ...
# N-1: N-1 N-1 ... N-1 N = 5 data = [] # Dopisz do danych 16-bitową liczbę zestawów. # Znak formatujący < oznacza Little Endian, natomiast H 16bitową liczbę naturalną. data.append(pack("I", self.get_block(4))[0] def get_uint16(self): return unpack(">H", self.get_block(2))[0]
def get_uint8(self): return ord(self.get_block(1)) # Wyjątek rzucany w przypadku błędu w dekodowaniu. class PNGError(Exception): pass class PNGReader: def __init__(self, fname): self.fname = fname self.png = None self.bitmap_data = [] self.header = { } self.bitmap = { "header": self.header, "rgb": [ ] } def _verify_magic(self): PNG_HEADER = "\x89PNG\r\n\x1a\n" return PNG_HEADER == self.png.get_block(len(PNG_HEADER)) def _read_chunk(self): # Wczytanie pojedynczego bloku. chunk = { } chunk["length"] chunk["type"] chunk["data"] chunk["crc32"]
= = = =
self.png.get_uint32() self.png.get_block(4) self.png.get_block(chunk["length"]) self.png.get_uint32()
# Wyliczenie CRC32 z data i type. crc = crc32(chunk["type"]) crc = crc32(chunk["data"], crc) & 0xffffffff
if chunk["crc32"] != crc: raise PNGError("chunk %s CRC32 incorrect" % chunk["type"]) return chunk def _process_IHDR(self, chunk): # Wczytaj pola nagłówka. data = StreamReader(chunk["data"]) self.header["width"] = data.get_uint32() self.header["height"] = data.get_uint32() self.header["bpp"] = data.get_uint8() self.header["color"] self.header["compression"] self.header["filter"] self.header["interlace"]
= = = =
data.get_uint8() data.get_uint8() data.get_uint8() data.get_uint8()
# Ten dekoder obsługuje tylko PNG typu 24-bpp bez przeplotu. if self.header["bpp"] != 8: raise PNGError("unsupported bpp (%u)" % self.header["bpp"]) if self.header["color"] != 2: raise PNGError("unsupported color type (%u)" % self.header ["color"]) if self.header["compression"] != 0: raise PNGError("unsupported compression type (%u)" % self.header["compression"]) if self.header["filter"] != 0: raise PNGError("unsupported filter type (%u)" % self.header ["filter"]) if self.header["interlace"] != 0: raise PNGError("unsupported interlace type (%u)" %
self.header["interlace"]) return def _process_IDAT(self, chunk): # Dodaj dane do bitmap_data w celu późniejszej dekompresji. self.bitmap_data.append(chunk["data"]) # Implementacja filtrów wg dokumentacji PNG. def _paeth(self, a, b, c): p = a + b - c pa pb pc if
= abs(p - a) = abs(p - b) = abs(p - c) pa out = out[PIPE_READ_END]; pipes->err = err[PIPE_READ_END]; return child; } int main(int argc, char **argv, char **envp) { UNUSED(argc); UNUSED(argv); process_standard_io pstdio; char *process_path = "/usr/bin/find"; char *process_argv[] = { process_path, "/etc", "-name", "passwd", NULL }; pid_t child = spawn_process(&pstdio, process_path, process_argv, envp); // Poczekaj, aż proces potomny zakończy działanie. Wszystkie dane // wypisane na standardowe wyjście i wyjście błędów nadal będą // znajdowały się w buforach jądra przynależnych do odpowiednich potoków. waitpid(child, NULL, 0); // Wypisz dane ze standardowych wyjść. Korzystam w tym celu m.in. // z funkcji splice, która kopiuje dane bezpośrednio między dwoma
// potokami, bez potrzeby manualnego wczytania ich do pamięci // procesu ze źródłowego potoku i zapisu do docelowego potoku. // Ponieważ splice korzysta z niskopoziomowych deskryptorów // (a nie obiektu FILE), używam funkcji fileno w celu otrzymania // niskopoziomowego deskryptora standardowego wyjścia obecnego // procesu. Alternatywnie mógłbym użyć stałej 1, ponieważ standardowe // wyjście w tym procesie na pewno będzie na tym deskryptorze. puts("Child's STDOUT:"); fflush(stdout); splice(pstdio.out, NULL, fileno(stdout), NULL, 1024, 0); puts("\nChild's STDERR:"); fflush(stdout); splice(pstdio.err, NULL, fileno(stdout), NULL, 1024, 0); putchar('\n'); // Zamknij potoki. close(pstdio.in); close(pstdio.out); close(pstdio.err); return 0; } Kompilacja oraz uruchomienie przebiegają następująco: $ gcc -Wall -Wextra pipe.c -o pipe $ ./pipe Child's STDOUT: /etc/passwd /etc/pam.d/passwd /etc/cron.daily/passwd Child's STDERR: /usr/bin/find: `/etc/cups/ssl': Permission denied
/usr/bin/find: `/etc/ppp/peers': Permission denied /usr/bin/find: `/etc/polkit-1/localauthority': Permission denied /usr/bin/find: `/etc/ssl/private': Permission denied /usr/bin/find: `/etc/chatscripts': Permission denied W przypadku systemu GNU/Linux bufor potoku w jądrze może osiągnąć maksymalny rozmiar 64 KB. Oznacza to, że zaprezentowane w poprzednim przykładzie podejście, w którym proces-rodzic czeka na zakończenie procesu dziecka przed przystąpieniem do odbioru danych, będzie działało jedynie w przypadku stosunkowo niewielkiej liczby przekazywanych bajtów. Podejście to nie wspiera również żadnej złożonej interakcji między procesami. Chcąc zaradzić tym problemom, nie można niestety po prostu przenieść odczytu danych przed wywołanie funkcji waitpid, która oczekuje na zakończenie procesu potomnego. Wynika to ze specyfiki programu, który posiada dwa różne potoki, w których mogą (ale nie muszą) pojawić się dane. W niekorzystnym przypadku operacja read mogłaby zablokować się podczas próby odczytu danych z niewykorzystywanego potoku, podczas gdy w drugim potoku bufor zdążyłby się maksymalnie zapełnić, co doprowadziłoby do opisanej już w rozdziale „Synchronizacja” sytuacji zakleszczenia (proces-rodzic byłby zablokowany w oczekiwaniu na dane z jednego potoku, podczas gdy proces potomny na operacji wysyłania danych do drugiego z nich). Istnieją trzy rozwiązania wspomnianego problemu, a o dwóch z nich wspomniałem już w IV części książki: Użycie nieblokujących (asynchronicznych) potoków i operacji, jeśli takie są dostępne w danej implementacji. Użycie osobnych wątków do odbioru danych z obu potoków. Skorzystanie z funkcji monitorujących dostępność danych wielu potoków jednocześnie. W tym rozdziale wybiorę ostatnie z możliwych rozwiązań, co sprowadza się do użycia funkcji select lub poll, które w działaniu przypominają funkcję WaitForMultipleObjects w systemie Windows (więcej o działaniu tej funkcji w III części książki). Konkretniej funkcje te otrzymują zestaw deskryptorów i blokują wykonanie do momentu, aż jeden ze wskazanych deskryptorów (w tym wypadku – potoków) nie zasygnalizuje dostępności danych (lub innego zdarzenia,
np. zamknięcia jednej strony potoku). Możliwe jest również podanie limitu czasu, po którym funkcja ma wznowić wykonanie wątku pomimo braku dostępności danych – jest to o tyle istotne w tym przypadku, że oprócz oczekiwania na dane z potoków musimy również monitorować stan procesu potomnego. W tym celu należy przeplatać wywołania funkcji select wywołaniami funkcji waitpid z opcją WNOHANG, która pozwala sprawdzić stan procesu bez blokowania wątku. Ostatecznie pętla odbierająco-monitorująca powinna zakończyć działanie, gdy proces potomny zostanie zamknięty i bufory obu potoków będą opróżnione. Zmiany w kodzie realizujące to zadanie zostały przedstawione poniżej: #include ... int main(int argc, char **argv, char **envp) { UNUSED(argc); UNUSED(argv); process_standard_io pstdio; char *process_path = "/usr/bin/find"; char *process_argv[] = { process_path, "/etc", "-name", "passwd", NULL }; pid_t child = spawn_process(&pstdio, process_path, process_argv, envp); int child_exited = 0; działanie. int no_more_out = 0; i zamknięty. int no_more_err = 0; i zamknięty.
// Flaga: proces potomny zakończył // Flaga: potok pstdio.out wyczyszczony // Flaga: potok pstdio.err wyczyszczony
while (!(child_exited && no_more_out && no_more_err)) {
// Sprawdź, czy proces potomny zakończył działanie. if (!child_exited && waitpid(child, NULL, WNOHANG) == child) { puts("Child exited."); child_exited = 1; // Niezależnie od stanu procesu nadal mogą być dostępne do odebrania dane. } // Sprawdź, czy w jednym z potoków są gotowe dane. W tym celu // użyj funkcji select, która monitoruje określone deskryptory // w oczekiwaniu na dane, ew. wraca wcześniej w przypadku ich // braku (w tym przypadku po 25 milisekundach) struct timeval timeout; timeout.tv_sec = 0; timeout.tv_usec = 25 * 1000;
// 25 milisekund.
// Utwórz strukturę fd_set i zaznacz informację o tym, które // deskryptory nas interesują. fd_set read_fds; FD_ZERO(&read_fds); FD_SET(pstdio.out, &read_fds); FD_SET(pstdio.err, &read_fds); // Funkcja select przyjmuje w drugim argumencie maksymalną // wartość deskryptora plus jeden (sic!). Sprawdź, który // z naszych deskryptorów ma większą wartość liczbową. int max_fds = pstdio.out > pstdio.err ? pstdio.out : pstdio.err; // Sprawdź dostępność danych.
if (select(max_fds + 1, &read_fds, NULL, NULL, &timeout) == 0) { continue; } // Prawdopodobnie dane są dostępne do odczytu, choć jest również // możliwe, że jeden lub oba potoki zostały zamknięte, gdyż proces // potomny kończy działanie. Odbierz dostępne dane, jeśli jakieś // są. W obu przypadkach operacje w tym momencie będą nieblokujące // z powodu obecności danych lub zamkniętego z drugiej strony potoku. char buffer[1024]; int ret; if (FD_ISSET(pstdio.out, &read_fds)) { ret = read(pstdio.out, buffer, sizeof(buffer)); if (ret != 0) { puts("Child's STDOUT:"); fwrite(buffer, sizeof(char), ret, stdout); putchar('\n'); } else { no_more_out = 1; } } if (FD_ISSET(pstdio.err, &read_fds)) { ret = read(pstdio.err, buffer, sizeof(buffer)); if (ret != 0) { puts("Child's STDERR:"); fwrite(buffer, 1, ret, stdout); putchar('\n');
} else { no_more_err = 1; } } } puts("End of data."); // Zamknij potoki. close(pstdio.in); close(pstdio.out); close(pstdio.err); return 0; } Przebieg kompilacji i wykonania zmodyfikowanej wersji wygląda następująco: $ gcc -Wall -Wextra pipe_select.c -o pipe_select $ ./pipe_select Child's STDERR: /usr/bin/find: `/etc/cups/ssl': Permission denied Child's STDERR: /usr/bin/find: `/etc/ppp/peers': Permission denied Child's STDERR: /usr/bin/find: Child's STDERR: `/etc/polkit-1/localauthority': Permission denied Child's STDERR: Child's STDOUT: /etc/passwd /etc/pam.d/passwd Child's STDERR:
/usr/bin/find: `/etc/ssl/private': Permission denied Child's STDOUT: /etc/cron.daily/passwd Child's STDERR: /usr/bin/find: `/etc/chatscripts': Permission denied Child exited. End of data. Należy zaznaczyć, że zaprezentowany wynik wykonania jest jednym z wielu możliwych. Jak pisałem we wstępie do tej części książki, w przypadku strumieniujących mechanizmów komunikacji nie można czynić żadnych założeń co do ilości danych odbieranych jednocześnie (tj. za pomocą jednego wywołania funkcji read). Oznacza to, że w kolejnych wykonaniach może pojawić się więcej lub mniej oddzielnych bloków „Child's STDOUT” i „Child's STDERR” niż w powyższym, przykładowym listingu. Przeplot bloków standardowego wyjścia i standardowego wyjścia błędów może również przyjąć inną formę (choć kolejność w obrębie danego potoku będzie oczywiście identyczna). Wsparcie dla potoków występuje w większości języków programowania i systemów operacyjnych. Tabela 1 zawiera przykładowe funkcje, typy i klasy z kilku języków programowania i systemowych API, służące do tworzenia i operacji na anonimowych potokach. Tabela 1. Przykładowe API do operacji na potokach
Język, API
Funkcje, klasy, typy
Uwagi
GNU/Linux, C
pipe, pipe2 close read, write select, pselect, poll, ppoll dup2 fcntl (F_SETPIPE_SZ, F_GETPIPE_SZ)
Za pomocą wymienionych arg umentów fcntl można, w og raniczonym zakres ie, kontrolować wielkoś ć bufora potoku.
Dobrym źródłem informacji o potokach jes t man 7 pipe. WinAPI, C
CreatePipe, CreateFile CloseHandle ReadFile, WriteFile PeekNamedPipe DuplicateHandle SetNamedPipeHandleState
S ys tem Windows implementuje anonimowe potoki za pomocą nazwanych potoków z unikatową nazwą.
Python
os.pipe os.close os.read, os.write os.dump2 select.select, select.poll, select.epoll
W przypadku przekierowań s tandardowych s trumieni język Python oferuje również g otowe rozwiązania w module subprocess.
Java
java.io.PipedInputStream java.io.PipedOutputStream java.nio.channels.Pipe
Potoki w języku Java nie s ą kompatybilne z s ys temowymi. Podobnie jak w języku Python, Java oferuje g otowe rozwiązania w przypadku przekierowania s tandardowych s trumieni, np. w ramach klas y ProcessBuilder.
14.2. Nazwane potoki Potoki nazwane i anonimowe działają w ten sam sposób w kontekście samego przesyłania danych, a różnice pomiędzy nimi dotyczą przede wszystkim otoczki. Jak sama nazwa wskazuje, nazwane potoki posiadają adres (nazwę), dzięki któremu inne procesy mogą otworzyć wybraną końcówkę potoku we własnym zakresie – a więc nie zachodzi potrzeba przekazywania deskryptorów/uchwytów
pomiędzy procesami, jak to miało miejsce w przypadku potoków opisanych w poprzednim rozdziale. Co za tym idzie funkcja tworząca potok, która w poprzednim rozdziale zwracała deskryptory obu końcówek, w przypadku nazwanych potoków została zastąpiona funkcją tworzącą potok o określonej nazwie oraz zestawem funkcji umożliwiających otwarcie określonej końcówki. Jeśli chodzi o sam adres nazwanego potoku, to jego natura zależy od danej platformy. W przypadku systemu Windows adresem jest ścieżka w systemie plików prowadząca do pseudopliku reprezentującego potok, który znajduje się w katalogu \\.\pipe\ obsługiwanym przez NPFS (Named Pipe File System). Ponieważ NPFS jest dostępny jedynie za pośrednictwem ścieżek UNC (Universal Naming Convention), narzędzia przystosowane do operowania na ścieżkach w klasycznym, DOS-owym stylu nie są w stanie operować na nazwanych potokach[252]. Co za tym idzie pomimo obecności nazwanych potoków w systemie plików, chcąc wylistować wszystkie z nich obecne w systemie, nie wystarczy posłużyć się podstawowymi poleceniami dostępnymi w konsoli; zamiast tego trzeba użyć dedykowanego narzędzia, np. pipelist z pakietu SysInternals [1]: > pipelist PipeList v1.01 by Mark Russinovich http://www.sysinternals.com Pipe Name Instances ------------InitShutdown lsass protected_storage ntsvcs scerpc plugplay Winsock2\CatalogChangeListener-358-0 ...
Instances
Max
---------
--------
3 4 3 3 3
-1 -1 -1 -1 -1
3 1
-1 1
Inspekcja listy praw dostępu do potoków sprawia więcej problemów. Jedną z opcji jest użycie programu Process Hacker (bardzo zbliżonego w wyglądzie i sposobie użycia do Process Explorer, o którym wspominałem już w tej książce), który pozwala na wyszukanie procesu z otwartym uchwytem do potoku oraz na wyświetlenie praw dostępu do docelowego obiektu, bazując na uchwycie – patrz rysunek 2.
Rysunek 2. Process Hacker i prawa dostępu \Device\NamedPipe\epmapper (\\.\pipe\epmapper)
do
nazwanego
potoku
W przypadku systemów z rodziny GNU/Linux jest podobnie, z tym że pseudoplik reprezentujący dany potok może zostać umieszczony w dowolnym miejscu w systemie plików. Co za tym idzie, aby odnaleźć wszystkie nazwane potoki obecne w systemie, można skorzystać dokładnie z tych samych metod i narzędzi, z których skorzystalibyśmy w przypadku szukania plików. Przykładem może być program find z parametrem -type p (Debian GNU/Linux 7): $ find / -type p -exec ls -la {} \; 2>/dev/null
prw------- 1 gynvael gynvael 0 Aug 23 22:37 /run/screen/Sgynvael/14976.im prw------- 1 root root 0 Aug 23 14:48 /run/initctl prw-r----- 1 root adm 0 Aug 24 01:45 /dev/xconsole prw------- 1 root root 0 Aug 23 14:48 /var/run/initctl Pozostając przy systemach z rodziny GNU/Linux, utworzenie nazwanego potoku osiąga się najczęściej za pomocą funkcji mkfifo, która powoduje powstanie pseudopliku potoku w określonym miejscu w systemie plików, o ile użytkownik posiada odpowiednie uprawnienia. Funkcja ta pozwala również zdefiniować bazowe prawa dostępu do potoku w identycznej postaci jak ma to miejsce w przypadku zwykłych plików. Po utworzeniu potoku żadna z końcówek nie jest automatycznie otwierana – należy to zrobić manualnie, korzystając ze standardowych funkcji do operacji na plikach. Należy zaznaczyć, że operacja otwarcia końcówki potoku może być operacją blokującą – wątek wznowi działania, dopiero gdy druga końcówka potoku zostanie otwarta przez inny proces lub inny wątek tego samego programu. Przykład (Python 2.7 i 3) prostego programu tworzącego potok z przysłowiami znajduje się poniżej: #!/usr/bin/python # -*- coding: utf-8 -*import os import time # Lista przysłów i powiedzeń ludowych zasłyszanych w Internecie. proverbs = [ "Using floats in a banking app is INFINITE fun.", "threads.synchronize your Remember to ", "C and C++ are great for parsing complex formats [a hacker on IRC]", ( # Phil Karlton + anonymous "There are only two hard things in Computer Science: cache ", "invalidation, naming things and off by one errors." ) ]
# Stwórz nazwany potok. try: os.mkfifo("/tmp/proverb", 0o644) except OSError as e: # Jeśli błąd jest inny niż „plik już istnieje” (17), podaj wyjątek dalej. if e.errno != 17: raise # Zajmij końcówkę zapisu i oczekuj na połączenia na końcówce odczytu. print ("Pipe /tmp/proverb created. Waiting for clients.") print ("Press Ctrl+C to exit.") proverb = 0 try: while True: fdw = open("/tmp/proverb", "w") print ("Client connected! Sending a proverb.") fdw.write(proverbs[proverb % len(proverbs)] + "\n") fdw.close() proverb += 1 time.sleep(0.1) # Daj chwilę czasu klientowi na rozłączenie się. except KeyboardInterrupt: pass # Usuń nazwany potok z systemu plików. print ("\nCleaning up!") os.unlink("/tmp/proverb") Uruchomienie i przebieg wykonania wyglądają następująco: $ python3 proverb_server.py Pipe /tmp/proverb created. Waiting for clients. Press Ctrl+C to exit.
Client connected! Sending a proverb. Client connected! Sending a proverb. Client connected! Sending a proverb. Aby przetestować program obsługujący potok, należy w trakcie jego działania skorzystać np. z polecenia cat do odczytania danych z potoku (każde uruchomienie
cat
jest
jednoznaczne
z
otwarciem
końcówki
odczytu
i odczytaniem danych): $ ls -la /tmp/proverb prw-r--r-- 1 gynvael gynvael 0 Aug 24 01:37 /tmp/proverb $ cat /tmp/proverb Using floats in a banking app is INFINITE fun. $ cat /tmp/proverb threads.synchronize your Remember to $ cat /tmp/proverb C and C++ are great for parsing complex formats [a hacker on IRC] W przypadku systemu Windows nazwane potoki posiadają trzy dodatkowe, dość unikatowe cechy: Dostęp do potoków za pośrednictwem sieci, który jest możliwy dzięki SMB (Server Message Block) – mechanizmowi odpowiedzialnemu m.in. za sieciowe udostępnianie plików i drukarek. Dostęp do potoku jest chroniony przez listę praw dostępu (identyczną jak w przypadku plików), co oznacza, że od zdalnego klienta może być wymagane wykonanie uwierzytelnienia w systemie, w którym znajduje się potok, by otrzymać do niego dostęp. Dokładny opis tego tematu wykracza jednak poza zakres niniejszej książki. Tryb dupleksowy, w którym obie strony mogą zapisywać dane do potoku oraz z niego czytać, a więc obie końcówki są dwukierunkowe (w rozumieniu half-duplex). Tryb pakietowy, w którym potok operuje na strumieniu wiadomości zamiast na strumieniu bajtów, jak ma to miejsce w trybie podstawowym. Za „wiadomość” uznawany jest pakiet danych wysłany przez jedno wywołanie funkcji WriteFile – jądro gwarantuje, że
odbiorca za pomocą jednego wywołania funkcji ReadFile odbierze dokładnie jedną wiadomość (chyba że docelowy bufor będzie zbyt mały – w takim wypadku odbiorca zostanie o tym poinformowany). Aby zaprezentować nazwane potoki w systemie Windows działające dodatkowo w trybach pakietowym i dupleksowym, posłużę się prostą aplikacją serwerową (język C), która sumuje, mnoży bądź dzieli zestaw liczb dostarczonych przez klienta. Protokół aplikacji[253] będzie bardzo prosty i ograniczy się do dwóch pakietów: calc_request – żądanie wykonania obliczeń w formie wybranej operacji oraz zestawu liczb o niestałej długości; calc_response – pobranie rezultatu wyliczeń lub informacji o błędzie. Dokładny format pakietów został przedstawiony w tabeli 2. Z uwagi na obecność jednego rodzaju pakietu wysyłanego w danym kierunku nie jest wymagane zawarcie identyfikatora rodzaju pakietu w protokole, co ma zazwyczaj miejsce w praktycznych, bardziej złożonych zastosowaniach. Tabela 2. Protokół komunikacji przykładowej aplikacji
Nazwa pakietu
Kierunek
Przesunięcie
W ielkość
T yp i nazwa pola (C)
Uw
calc_request
klient→ s erwer
0
4
enum { OP_SUM = 0, OP_MUL = 1, OP_DIV = 2 } op;
Typ
4
4 * ?
unsigned int data[];
Lic elem tab być wyw
zw pak calc_response
s erwer→ klient
0
4
enum { RES_ERROR = 0, RES_OK = 1 } res;
4
4
unsigned int value;
Info op ope
Wy (tyl ww res RES
Oba pakiety zostały zdefiniowane we wspólnym dla serwera i klienta pliku nagłówkowym calc.h przedstawionym poniżej: #pragma once #define CALCSERVER_PIPE "\\\\.\\pipe\\calcserver" // Ponieważ dane będą przesyłane w obrębie jednej architektury, // dopuszczalne jest posłużenie się bezpośrednio strukturami, bez // zastosowania serializacji. // Uwaga: najlepiej jest wyzerować instancje struktur przed ich // wysłaniem w celu upewnienia się, że żadne niezainicjalizowane dane // nie zaplątały się pomiędzy pola (tj. w dopełnienia między polami) // – pozwoli to uniknąć problemów z bezpieczeństwem. W tym wypadku // pola będą prawdopodobnie ściśle dopasowane, niemniej jednak // w przyszłości struktura potencjalnie może ulec zmianie. typedef struct {
enum { OP_SUM, // Suma liczb. OP_MUL, // Iloczyn liczb. OP_DIV // Pierwsza liczba podzielona przez pozostałe. } op; unsigned int data[]; // Wielkość tablicy zależy od rozmiaru dynamicznej alokacji. } calc_request; // Maksymalna liczba wartości w danych. #define CALC_REQUEST_MAX_DATA 48 typedef struct { enum { RES_ERROR, RES_OK } res; unsigned int value; } calc_response; Część serwerowa działa w nieskończonej pętli, podczas której: Tworzony jest nazwany potok \\.\pipe\calcserver, jedna z końcówek zostaje zajęta i następuje oczekiwanie, aż inny proces zajmie drugą. Następuje odebranie wiadomości, które sprowadza się do trzech operacji: – Oczytu do bufora o zerowej długości – ma to na celu jedynie zablokowanie wątku aż do momentu, gdy dane będą dostępne (tj. gdy klient wyśle pakiet calc_request). – Ustalenia długości danych za pomocą funkcji PeekNamedPipe i alokacji odpowiednio dużego bufora. – Faktycznego odebrania pakietu calc_request do przygotowanego bufora. Przeprowadzane są obliczenia. Wynik jest odsyłany do klienta, a potok zamykany.
Kod części serwerowej wygląda następująco: #include #include #include #include
#include "calc.h" // Ponowna deklaracja dwóch funkcji, w razie gdyby MinGW Platform SDK // w danej wersji ich nie posiadał. // https://msdn.microsoft.com/enus/library/windows/desktop/aa365440.aspx BOOL WINAPI GetNamedPipeClientProcessId( HANDLE Pipe, PULONG ClientProcessId ); // https://msdn.microsoft.com/enus/library/windows/desktop/aa365442.aspx BOOL WINAPI GetNamedPipeClientSessionId( HANDLE Pipe, PULONG ClientSessionId ); HANDLE WaitForNewClient(void) { // Stwórz nowy potok (dwukierunkowy dostęp, tryb pakietowy). HANDLE h = CreateNamedPipe( CALCSERVER_PIPE, PIPE_ACCESS_DUPLEX, PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT, PIPE_UNLIMITED_INSTANCES, sizeof(calc_response),
sizeof(calc_request) + sizeof(unsigned int) * CALC_REQUEST_MAX_DATA, 0, NULL); if (h == INVALID_HANDLE_VALUE) { printf("error: failed to create pipe (%u)\n", (unsigned int) GetLastError()); return INVALID_HANDLE_VALUE; } // Poczekaj na połączenie z drugiej strony potoku. BOOL result = ConnectNamedPipe(h, NULL); if (result == TRUE) { return h; } // W powyższym przypadku ma miejsce sytuacja wyścigu, tj. klient // może się podłączyć do potoku pomiędzy wywołaniem funkcji // CreateNamedPipe a ConnectNamedPipe. W takim wypadku GetLastError // zwróci wartość ERROR_PIPE_CONNECTED. if (GetLastError() == ERROR_PIPE_CONNECTED) { return h; } printf("error: failed to connect pipe (%u)\n", (unsigned int) GetLastError()); CloseHandle(h); return INVALID_HANDLE_VALUE; } calc_request *GetCalcRequest(HANDLE h, size_t *item_count) { // Zablokuj wykonanie aż do otrzymania danych.
DWORD bytes_read; BOOL result = ReadFile(h, NULL, 0, &bytes_read, NULL); if (!result) { printf("error: failed on initial read (%u)\n", (unsigned int)GetLastError()); return NULL; } // Sprawdź wielkość dostępnych danych. DWORD bytes_avail; result = PeekNamedPipe(h, NULL, 0, NULL, &bytes_avail, NULL); // Przykładowa detekcja błędów związanych z pakietem. if (bytes_avail < sizeof(calc_request)) { printf("protocol error (packet too small)\n"); return NULL; } if ((bytes_avail - sizeof(calc_request)) % sizeof(unsigned int) != 0) { printf("protocol error (data misaligned)\n"); return NULL; } // Zaalokuj pamięć na strukturę, a następnie odbierz wiadomość. calc_request *creq = malloc(bytes_avail); ReadFile(h, creq, bytes_avail, &bytes_read, NULL); if (creq->op != OP_SUM && creq->op != OP_MUL && creq->op != OP_DIV) { printf("protocol error (invalid operation)\n"); free(creq); return NULL; }
*item_count = (bytes_avail - sizeof(calc_request)) / sizeof (unsigned int); return creq; } int Calc(unsigned int *result, calc_request *cr, size_t count) { switch (cr->op) { case OP_SUM: *result = 0; for (size_t i = 0; i < count; i++) { *result += cr>data[i]; } break; case OP_MUL: *result = 1; for (size_t i = 0; i < count; i++) { *result *= cr>data[i]; } break; case OP_DIV: if (count == 0) { return -1; } *result = cr->data[0]; for (size_t i = 1; i < count; i++) { if (cr->data[i] == 0) { return -1; } *result /= cr->data[i]; } break; default: return -1;
} return 0; } int SendCalcResponse(HANDLE h, calc_response *cresp) { DWORD bytes_sent; if (!WriteFile(h, cresp, sizeof(*cresp), &bytes_sent, NULL)) { printf("error: failed to send data over pipe (%u)\n", (unsigned int)GetLastError()); return -1; } if (bytes_sent != sizeof(*cresp)) { printf("error: failed to send all of the data over pipe (%u, %u)\n", (unsigned int)bytes_sent, (unsigned int)GetLastError()); return -1; } return 0; } int main(void) { // Jednowątkowy serwer pseudo-kalkulatora, obsługujący jednego klienta naraz. for (;;) { // Stwórz potok i poczekaj na połączenie. HANDLE h = WaitForNewClient(); if (h == INVALID_HANDLE_VALUE) { continue; } // Wypisz informacje o kliencie. ULONG pid, sid;
GetNamedPipeClientProcessId(h, &pid); GetNamedPipeClientSessionId(h, &sid); printf("info: client connected! (pid=%u, sid=%u)\n", (unsigned int)pid, (unsigned int)sid); // Odbierz żądanie. size_t item_count = 0; calc_request *creq = GetCalcRequest(h, &item_count); if (creq == NULL) { CloseHandle(h); continue; } // Wylicz wynik. unsigned int value = 0; calc_response cresp; memset(&cresp, 0, sizeof(cresp)); if (Calc(&value, creq, item_count) == 0) { cresp.res = RES_OK; cresp.value = value; } else { cresp.res = RES_ERROR; } printf("info: result for the client is %u\n", value); free(creq); // Odeślij wynik i rozłącz klienta. SendCalcResponse(h, &cresp); DisconnectNamedPipe(h); CloseHandle(h); } return 0; }
Centralną część aplikacji klienckiej stanowi funkcja DoMath, której celem jest przesłanie zadanej operacji wraz z danymi do serwera oraz odebranie wyniku. Operacje te można by wykonać, korzystając z funkcji CreateFile, WriteFile, ReadFile oraz CloseHandle, natomiast możliwe jest również użycie funkcji CallNamedPipe, która wykonuje dokładnie te cztery operacje. Ostatecznie program-klient przeprowadza krótką serię testów różnych operacji, wypisując otrzymane wyniki na standardowe wyjście. Kod tej części przedstawia się następująco: #include #include #include #include #include #include "calc.h" // Dla przykładu skorzystamy z funkcji wariadycznej, tj. funkcji, // która przyjmuje dowolną liczbę nienazwanych parametrów po // parametrach nazwanych. int DoMath(unsigned int *calc_result, int op, size_t numdata, ...) { // Zaalokuj pamięć na pakiet z żądaniem. size_t creq_size = sizeof(calc_request) + numdata * sizeof(unsigned int); calc_request *creq = malloc(creq_size); memset(creq, 0, creq_size); creq->op = op; // Skopiuj argumenty operacji. va_list vl; va_start(vl, numdata); for (size_t i = 0; i < numdata; i++) {
creq->data[i] = va_arg(vl, unsigned int); } // Wyślij wiadomość do serwera z prośbą o wykonanie operacji. calc_response cresp; DWORD cresp_recv_size = 0; BOOL result = CallNamedPipe( CALCSERVER_PIPE, creq, creq_size, &cresp, sizeof(cresp), &cresp_recv_size, NMPWAIT_USE_DEFAULT_WAIT); free(creq); // Przeanalizuj wynik. if (result == TRUE && cresp_recv_size == sizeof(cresp)) { if (cresp.res == RES_ERROR) { return -1; } *calc_result = cresp.value; return 0; } printf("protocol error (%u)\n", (unsigned int)GetLastError()); return -1; } int main(void) { unsigned int result; // Przetestuj kalkulator dla operacji sumowania. if (DoMath(&result, OP_SUM, 27, 11, 22, 33, 44, 55, 66, 77, 88, 99,
111, 222, 333, 444, 555, 666, 777, 888, 999, 1111, 2222, 3333, 4444, 5555, 6666, 7777, 8888, 9999) == 0) { printf("result: %u (should be 55485)\n", result); } else { puts("failed"); } // Przetestuj kalkulator dla operacji mnożenia. if (DoMath(&result, OP_MUL, 29, 70, 108, 97, 103, 123, 77, 89, 45, 80, 73, 80, 69, 45, 73, 83, 45, 67, 65, 76, 76, 69, 68, 45, 72, 69, 78, 82, 89, 125) == 0) { printf("result: %u (should be 1270874112)\n", result); } else { puts("failed"); } // Przetestuj kalkulator dla operacji dzielenia. if (DoMath(&result, OP_DIV, 21, 1048576, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2) == 0) { printf("result: %u (shoule be 1)\n", result); } else { puts("failed"); } return 0; } Kompilacja obu części przebiega następująco: > gcc calc_client.c -Wall -Wextra -o calc_client.exe > gcc calc_server.c -Wall -Wextra -o calc_server c:\windows\ system32\kernel32.dll
Manualne linkowanie z dynamiczną biblioteką kernel32.dll jest wymagane jedynie w przypadku, gdy używana wersja Platform SDK (zazwyczaj dostarczanego z kompilatorem) nie zawiera symboli zastępczych dla funkcji GetNamedPipeClientProcessId oraz GetNamedPipeClientSessionId. Podobnie jak we wcześniejszych przypadkach opisanych w tym rozdziale, przetestowanie zestawu aplikacji wymaga otwarcia dwóch konsol i uruchomienia na jednej z nich aplikacji serwerowej, a na drugiej klienta. Przykładowy przebieg może wyglądać następująco: > calc_server.exe info: client connected! (pid=10728, sid=1) info: result for the client is 55485 info: client connected! (pid=10728, sid=1) info: result for the client is 1270874112 info: client connected! (pid=10728, sid=1) info: result for the client is 1 > calc_client.exe result: 55485 (should be 55485) result: 1270874112 (should be 1270874112) result: 1 (shoule be 1)
14.3. Gniazda domeny UNIX i socketpair Komunikacja za pomocą omówionych w poprzednim rozdziale potoków jest jednokierunkowa (simplex) lub, w najlepszym wypadku, dwukierunkowa, korzystająca z jednego bufora (half-duplex) – jedna strona pisze do wspólnego bufora komunikacyjnego, a druga z niego czyta. Sprawdza się to dobrze w niektórych sytuacjach, ale w innych wygodniejsza byłaby jednoczesna komunikacja w obie strony (full-duplex). W przypadku systemów z rodziny GNU/Linux mechanizmem implementującym tego typu lokalną komunikację są gniazda domeny UNIX (UNIX domain sockets), zwane również gniazdami komunikacji międzyprocesowej (IPC sockets). Podobnie jak potoki, istnieją one w dwóch formach:
Anonimowej, w której gniazdo wraz z równoważnymi dwoma deskryptorami jest tworzone za pomocą funkcji socketpair. Nazwanej, w której wykorzystuje się cały zestaw funkcji związanych z gniazdami. Bez względu na formę gniazdo domeny UNIX może działać w jednym z trzech trybów[254]: • SOCK_STREAM – tryb strumieniowy. • SOCK_SEQPACKET – tryb strumieniowy z zaznaczeniem granicy pakietów. • SOCK_DGRAM – bezpołączeniowy tryb pakietowy, bez gwarancji zachowania kolejności pakietów. Należy w tym miejscu zaznaczyć, że zarówno gniazda domeny UNIX, jak i gniazda sieciowe wykorzystują dokładnie ten sam zestaw funkcji. Co więcej, tryby te dostępne są w obu formach komunikacji; w szczególności omówione w następnym rozdziale protokoły TCP oraz UDP implementują tryby SOCK_STREAM oraz SOCK_DGRAM (SOCK_SEQPACKET jest dostępny w przypadku użycia znacznie mniej popularnego protokołu SCTP – Stream Control Transmission Protocol). Ostatecznie różnica w wyborze, czy gniazdo powinno być gniazdem komunikacji międzyprocesowej, czy sieciowej, zależy od wyboru tzw. domeny komunikacji, którego dokonuje się podczas tworzenia gniazda. Współcześnie używane są trzy domeny: •
AF_UNIX
(Address
Family
–
UNIX)
–
komunikacja
lokalna
(międzyprocesowa). • AF_INET (Address Family – Internet Protocol) – komunikacja sieciowa, korzystająca głównie z protokołów IPv4, TCP oraz UDP. • AF_INET6 (Address Family – Internet Protocol, version 6) – jw., przy czym zamiast IPv4 stosowany jest protokół IPv6. Jak wspomniałem wcześniej, anonimowe gniazda tworzy się za pomocą funkcji socketpair, która przyjmuje rodzaj gniazda jako argument i zwraca dwa deskryptory połączonych ze sobą gniazd. Analogicznie jak w przypadku nienazwanych potoków, jeden z deskryptorów należy przekazać docelowemu procesowi, np. za pomocą mechanizmu dziedziczenia. Po wykonaniu tego zabiegu można rozpocząć komunikację za pomocą funkcji send oraz recv.
Poniżej znajduje się przykład demonstrujący użycie anonimowych gniazd domeny UNIX w trybie strumieniowym. Dodatkowo gniazda zostały ustawione w tryb asynchroniczny, przez co monitorowanie deskryptorów odbywa się w sposób aktywny. Alternatywnym podejściem byłoby wykorzystanie wątków lub funkcji select/poll. Patrząc na przykład od strony wysokopoziomowej, główny proces tworzy (spawn_worker) zestaw dziesięciu procesów potomnych (child_main), którym zostają zadane do wykonania pewne obliczenia. W momencie ich zakończenie wynik jest przesyłany, właśnie korzystając z gniazd domeny UNIX, do procesu rodzica. Ten, gdy uzbiera wymaganą liczbę wyników (w tym wypadku 5), informuje procesy potomne o zakończeniu obliczeń i kończy działanie. Implementacja w języku C prezentuje się następująco: #include #include #include #include #include #include #include #include #include #define OP_EXIT 0xFF #define WORKER_COUNT 10 #define UNUSED(a) ((void)a) int child_main(int s, unsigned int n, unsigned int mod) { // Wylicz N-ty wyraz ciągu Fibonacciego modulo mod i odeślij // odpowiedź. Co jakiś czas sprawdzaj, czy rodzic nie przysłał // bajtu 0xFF, który oznacza sygnał do zakończenia. Zakończ // również, jeśli połączenie zostanie przerwane. const int MSG_CHECK_INTERVAL = 10000; printf("child(%u, %u): ready; starting the math\n", n, mod); fflush(stdout);
unsigned int check_counter = MSG_CHECK_INTERVAL; unsigned int result = 1; unsigned int i; for (i = 1; i < n; i++) { // Wykonaj obliczenia. result = (result + i) % mod; // Ew. sprawdź, czy są nowe dane. if (--check_counter == 0) { check_counter = MSG_CHECK_INTERVAL;
// Reset.
// Odbierz bajt danych, jeśli jest dostępny. unsigned char data; ssize_t ret = recv(s, &data, 1, 0); if (ret == 0) { // Połączenie zostało zamknięte, można zakończyć proces. printf("child(%u, %u): connection closed, exiting\n", n, mod); return 1; } if (ret == 1) { if (data == OP_EXIT) { printf("child(%u, %u): received FF packet, exiting\n", n, mod); return 1; } else { printf("child(%u, %u): received %.2X packet, ignoring\n", n, mod, data); fflush(stdout); } }
// Sprawdź, czy wystąpił nieoczekiwany błąd. Błędy, które // są normalnymi zdarzeniami, to: // EAGAIN i EWOULDBLOCK - brak danych if (ret == -1 && !(errno == EAGAIN || errno == EWOULDBLOCK)) { // Wystąpił błąd związany z gniazdem. printf("child(%u, %u): connection failed, exiting\n", n, mod); return 1; } // Brak danych. } } printf("child(%u, %u): done!\n", n, mod); fflush(stdout); // Sprawdź, czy połączenie jest nadal aktywne, a jeśli tak, wyślij dane. unsigned char data; ssize_t ret = recv(s, &data, 1, 0); if (ret == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) { // Funkcja send może nie wysłać wszystkich danych od razu, // natomiast zwróci informację o tym, ile bajtów udało się wysłać. // W praktyce dla 4 bajtów podział wiadomości nie powinien się // nigdy zdarzyć. ssize_t to_send = sizeof(result); unsigned char *presult = (unsigned char*)&result; while (to_send > 0) { ret = send(s, presult, to_send, 0); if (ret == -1) { if (errno == EAGAIN || errno == EWOULDBLOCK) {
// Wywołanie spowodowałoby zablokowanie wątku (gniazdo jest // w trybie asynchronicznym, więc jest to uznane za // niekrytyczny błąd). continue; } // Połączenie zostało zerwane lub wystąpił inny błąd. return 1; } to_send -= ret; presult += ret; } return 0; } printf("child(%u, %u): result sent failed\n", n, mod); return 1; } int spawn_worker(unsigned int n, unsigned int mod) { // Stwórz asynchroniczne gniazdo strumieniujące typu Unix Domain Socket. int sv[2]; int ret = socketpair(AF_UNIX, SOCK_STREAM | SOCK_NONBLOCK, 0, sv); if (ret == -1) { return -1; } // Utwórz proces potomny. pid_t child = fork(); if (child == 0) {
// Kod procesu-dziecka. close(sv[0]); (proces
// Zamknij zduplikowane gniazdo rodzica // potomny z niego nie korzysta).
// Przejdź do kodu procesu potomnego. ret = child_main(sv[1], n, mod); close(sv[1]); // W normalnej sytuacji odpowiednia procedura zarejestrowana przy // pomocy atexit wywołałaby fflush dla stdout, natomiast poniżej // korzystamy z_exit, która pomija wywołanie wszelkich procedur // zarejestrowanych przy pomocy atexit, należy więc fflush wywołać // manualnie. fflush(stdout); _exit(ret); } // Kontynuacja procesu rodzica. // Zamknij gniazdo dziecka (proces potomny posiada jego kopię). close(sv[1]); return sv[0]; } int main(void) { // Stwórz 10 procesów potomnych. Poczekaj na wyniki od połowy // z nich i zakończ pracę. struct child_into_st { int s;
unsigned char data[sizeof(unsigned int)]; ssize_t data_received; } workers[WORKER_COUNT]; int results = 0; int i; for (i = 0; i < WORKER_COUNT; i++) { workers[i].data_received = 0; workers[i].s = -1; } // Stwórz procesy. for (i = 0; i < WORKER_COUNT; i++) { int s = spawn_worker(1000000 * (1 + rand() % 100), 2 + rand() % 12345); // Celowo nie wywołuję funkcji srand dla powtarzalności wyników. if (s == -1) { printf("main: failed to create child %i; aborting\n", i); fflush(stdout); goto err; } workers[i].s = s; } // Aktywnie czekaj, aż pojawi się przynajmniej 5 wyników. while (results < 5) { for (i = 0; i < WORKER_COUNT; i++) { if (workers[i].s == -1) { continue; } // Spróbuj odebrać dane.
ssize_t ret = recv(workers[i].s, workers[i].data + workers[i].data_received, sizeof(workers[i].data) - workers[i]. data_received, 0); // Zinterpretuj wynik działania funkcji recv. int close_socket = 0; if (ret == 0) { printf("main: huh, child %i died\n", i); fflush(stdout); close_socket = 1; } else if (ret == -1) { if (errno != EAGAIN && errno != EWOULDBLOCK) { printf("main: child %i connection error\n", i); fflush(stdout); close_socket = 1; } // W innym wypadku po prostu nie ma danych do odebrania. } else if (ret > 0) { // Udało się odebrać dane. Czy są już 4 bajty? workers[i].data_received += ret; if (workers[i].data_received == sizeof(workers[i].data)) { // Tak, został odebrany cały wynik. unsigned int res; memcpy(&res, workers[i].data, sizeof(unsigned int)); results++; printf("main: got result from %i: %u (we have %i/5 results now)\n", i, res, results); fflush(stdout); close_socket = 1; }
} // Zamknij gniazdo, jeśli to jest wymagane. if (close_socket) { close(workers[i].s); workers[i].s = -1; } } } printf("main: we have met the required number of results; finishing\n"); fflush(stdout); // Poinformuj procesy o zakończeniu i wyjdź. for (i = 0; i < WORKER_COUNT; i++) { if (workers[i].s != -1) { unsigned char byte = OP_EXIT; send(workers[i].s, &byte, 1, 0); close(workers[i].s); } } return 0; err: // Pozamykaj gniazda. Procesy potomne same wyjdą po wykryciu ich zamknięcia. for (i = 0; i < WORKER_COUNT; i++) { if (workers[i].s != -1) { close(workers[i].s); } } return 1; }
Kompilacja oraz przykładowe uruchomienie przebiegają następująco: $ gcc -Wall -Wextra socketpair.c -o socketpair $ ./socketpair child(16000000, 7499): ready; starting the math child(87000000, 5910): ready; starting the math child(36000000, 3625): ready; starting the math child(22000000, 6251): ready; starting the math child(27000000, 10240): ready; starting the math child(27000000, 8717): ready; starting the math child(37000000, 7134): ready; starting the math child(60000000, 4372): ready; starting the math child(28000000, 11839): ready; starting the math child(93000000, 11403): ready; starting the math child(16000000, 7499): done! main: got result from 1: 6459 (we have 1/5 results now) child(22000000, 6251): done! main: got result from 4: 2220 (we have 2/5 results now) child(28000000, 11839): done! main: got result from 5: 8095 (we have 3/5 results now) child(27000000, 8717): done! child(27000000, 10240): done! main: got result from 7: 6561 (we have 4/5 results now) main: got result from 8: 7982 (we have 5/5 results now) main: we have met the required number of results; finishing child(36000000, 3625): received FF packet, exiting child(93000000, child(87000000, child(60000000, child(37000000,
11403): received FF packet, exiting 5910): received FF packet, exiting 4372): received FF packet, exiting 7134): received FF packet, exiting
Jeśli chodzi o nazwane gniazda domeny UNIX, to ich użycie jest identyczne jak w przypadku gniazd sieciowych – jedyną różnicą jest format adresu, który w przypadku gniazd domeny UNIX ma charakter ścieżki do pliku. Przekazywanie deskryptorów [BEYOND]
Gniazda domeny UNIX, oprócz danych, pozwalają również na wykonywanie dwóch dodatkowych czynności: Przekazywanie deskryptorów (np. plików, potoków itp.) – pozwala to np. na udostępnienie na żądanie wybranych plików procesowi działającemu z niższymi uprawnieniami. Przekazanie i odebranie deskryptorów jest realizowane za pomocą funkcji sendmsg oraz recvmsg. Uzyskanie
autorytatywnej informacji o zalogowanym użytkowniku, który jest drugą stroną komunikacji. Konkretniej, korzystając z gniazd domeny UNIX oraz funkcji getsockopt z parametrem SO_PEERCRED, można odpytać jądro systemu o numer użytkownika, numer grupy oraz identyfikator procesu, który pierwotnie nawiązał połączenie z gniazdem (należy zaznaczyć, iż deskryptor mógł zostać przekazany innemu procesowi; w takim przypadku proces ten może skorzystać z SCM_CREDENTIALS, aby przesłać aktualne informacje, które zostaną uwierzytelnione przez jądro systemu). Dokładny opis tych czynności wykracza jednak poza zakres książki. Dodatkowe informacje można znaleźć np. w [2] i [3].
14.4. Pamięć współdzielona We wszystkich opisanych do tej pory przypadkach jądro systemu operacyjnego uczestniczyło zarówno w nawiązaniu połączenia, jak i w samej wymianie danych. Zdecydowaną zaletą tego podejścia jest zapewniona przez jądro obsługa zarówno buforowania, jak i synchronizacji dostępu do wykorzystywanych wewnętrznie buforów. Wadami natomiast są dodatkowe wywołania systemowe związane z przesyłem danych, jak i samo nieco nadmiarowe kopiowanie danych z pamięci procesu źródłowego do pamięci kernela, a następnie z pamięci kernela do pamięci procesu docelowego. Alternatywną metodą przekazywania danych pomiędzy procesami jest pamięć współdzielona, która nie posiada ani wskazanych wad, ani zalet omówionych wcześniej metod.
Na wysokim poziomie abstrakcji można myśleć o pamięci współdzielonej jako o buforze, który jest jednocześnie dostępny w dwóch lub większej liczbie procesów. W praktyce jądro dokonuje alokacji jednej lub większej liczby fizycznych stron pamięci, a następnie – na jawne żądanie – podmapowuje te strony w przestrzeni adresowej procesu (patrz również rys. 3).
Rysunek 3. Współdzielone strony pamięci podmapowane w dwóch różnych procesach Najczęściej obiekty pamięci współdzielone są zasobami nazwanymi[255]. W przypadku GNU/Linux nazwa jest ścieżką do pliku znajdującego się w katalogu /dev/shm, obsługiwanym przez dedykowany wirtualny system plików. W przypadku Windows (w którym pamięć współdzieloną nazywa się „sekcją”) nazwa obiektu jest rezerwowana w przestrzeni nazw obiektów (Object Name Space), z której korzystają np. międzyprocesowe muteksy czy zdarzenia, o których wspominałem w rozdziale „Synchronizacja”. W przestrzeni tej obiekty mogą być lokalne (prefiks „Local\”) – dostępne jedynie w sesji, w której działa proces; lub globalne (prefiks „Global\”) – dostępne w dowolnej sesji[256]. W przypadku obu systemów obiekty sesji podlegają takiej samej ochronie jak pliki[257], a więc m.in. posiadają własne listy dostępów (ACL). Wylistowanie wszystkich obiektów pamięci współdzielonej w przypadku systemów z rodziny GNU/Linux jest trywialne z uwagi na ponowne wykorzystanie
systemu plików i sprowadza się do wydania polecenia ls -la /dev/shm/. W przypadku systemu Windows należy skorzystać z dodatkowego narzędzia, np. AccessChk z pakietu Sysinternals [5], które listuje nazwane sekcje. Przykładowy sposób użycia: accesschk -t section -o \Sessions\1\BaseNamedObjects\ – wyświetlenie wszystkich lokalnych sekcji; accesschk -o -t section \BaseNamedObjects\ – wyświetlenie wszystkich globalnych sekcji; accesschk -os -t section – wyświetlenie wszystkich sekcji, łącznie z tymi niedostępnymi z poziomu podstawowych funkcji WinAPI. Poniżej znajdują się dwa przykłady: utworzenia nowej sekcji i skopiowania do niej danych oraz podmapowania istniejącej sekcji i zapisania z niej danych do pliku. Oba zostały stworzone w języku C z wykorzystaniem WinAPI. Kod programu tworzącego sekcję prezentuje się następująco: #include #include #include #include int main(void) { // Stwórz nową sekcję (pamięć dzieloną) i podmapuj ją w przestrzeni procesu. HANDLE h = CreateFileMapping( INVALID_HANDLE_VALUE, NULL, PAGE_READWRITE, 0, 4096, "Local\\ HelloWorld"); void *p = MapViewOfFile(h, FILE_MAP_WRITE, 0, 0, 0); // Skopiuj wiadomość na początek podmapowanej sekcji. const char *message = "Hello World!\x1A"; memcpy(p, message, strlen(message)); // Poczekaj na zakończenie programu.
puts("Press ENTER to leave..."); getchar(); UnmapViewOfFile(p); CloseHandle(h); return 0; } Kod drugiego programu, którego można użyć do zrzucenia danych z dowolnej sekcji do pliku (pod warunkiem posiadania odpowiednich uprawnień), znajduje się poniżej: #include #include #include #include
int main(int argc, char **argv) { if (argc != 2) { puts("usage: dumpsection "); puts("note : for \\Sessions\\N\\BaseNamedObjects\\ " "objects use Local\\ prefix"); puts(" for \\BaseNamedObjects\\ objects use " "Global\\ prefix"); return 1; } // Podmapuj sekcję do przestrzeni pamięci procesu. HANDLE h = OpenFileMapping(FILE_MAP_READ, FALSE, argv[1]); if (h == NULL) { printf("error: failed to open mapping (%u)\n", (unsigned int)GetLastError()); return 1; } void *p = MapViewOfFile(h, FILE_MAP_READ, 0, 0, 0);
if (p == NULL) { printf("error: failed to map view (%u)\n", (unsigned int)GetLastError()); CloseHandle(h); return 1; } // Pobierz rozmiar sekcji. MEMORY_BASIC_INFORMATION mbi; VirtualQuery(p, &mbi, sizeof(mbi)); // Stwórz nazwę pliku. char fname[256 + 8] = {0}; strncpy(fname, argv[1], 255); for (char *ch = fname; *ch != '\0'; ch++) { if (*ch == '\\') { *ch = '_'; } } strcat(fname, ".bin"); // Zapisz zawartość sekcji do pliku. FILE *f = fopen(fname, "wb"); if (f == NULL) { perror("error: failed to open output file"); UnmapViewOfFile(p); CloseHandle(h); return 1; } fwrite(p, 1, mbi.RegionSize, f); fclose(f); UnmapViewOfFile(p); CloseHandle(h);
return 0; } Korzystając z dwóch terminali, można skompilować oraz przetestować działanie obu programów: Terminal 1: > gcc -std=c99 -Wall -Wextra hellosharedmemory.c -o hello > hello Press ENTER to leave... Terminal 2: > accesschk -t section o \Sessions\1\BaseNamedObjects\HelloWorld Accesschk v5.10 - Reports effective permissions for securable objects Copyright (C) 2006-2012 Mark Russinovich Sysinternals - www.sysinternals.com \Sessions\1\BaseNamedObjects\HelloWorld Type: Section RW NT AUTHORITY\SYSTEM RW haven\gynvael RW haven\S-1-5-5-0-1745682-gynvael > gcc -std=c99 -Wall -Wextra dumpsection.c -o dumpsection > dumpsection Local\HelloWorld > type Local_HelloWorld.bin Hello World! Jak można zaobserwować, oba zadziałały prawidłowo. Użycie pamięci współdzielonej w systemach GNU/Linux jest analogiczne – różnią się jedynie wykorzystywane funkcje (patrz tab. 3). Tabela 3. Spis funkcji do operacji na obiektach pamięci współdzielonej
Czynność
W inAPI
GNU/Linux
Uwagi
Utworzenie noweg o obiektu pamięci ws półdzielonej
CreateFileMapping
shm_open
Technicznie shm_ope jes t niewielkim wrapperem na funkc open, który tworzy lu otwiera plik w katalo /dev/shm.
CreateFileMapping powinno być wywoła z INVALID_HANDLE_V w pierwszym parametrze.
Us tawienie rozmiaru reg ionu pamięci ws półdzielonej
-
ftruncate
Otwarcie obiektu
OpenFileMapping
shm_open
Podmapowanie pamięci ws półdzielonej do przes trzeni adres owej proces u
MapViewOfFile
mmap
Us unięcie mapowania
UnmapViewOfFile
munmap
Zamknięcie uchwytu do obiektu
CloseHandle
close
Us unięcie obiektu
-
shm_unlink
W przypadku WinAP wielkoś ć jes t deklarowana przy tworzeniu obiektu.
W przypadku WinAP obiekt jes t us uwany w momencie
zamknięcia os tatnie uchwytu do nieg o. Na zakończenie tego podrozdziału dodam, że wymiana danych poprzez pamięć współdzieloną wymaga odpowiedniej synchronizacji przy wykorzystaniu międzyprocesowych mechanizmów do tego przeznaczonych (muteksów, zdarzeń itd.). Ponieważ ostatecznie jest to mechanizm niskopoziomowy, wszystkie problemy synchronizacyjne, które opisałem w rozdziale „Synchronizacja” (związane np. z atomowością operacji, wymogiem użycia barier itp.), dotyczą również pamięci współdzielonej. Dodatkowym problemem jest również fakt, że pamięć współdzielona może zostać podmapowana w różnych procesach pod różne adresy. Oznacza to, że bezpośrednie użycie wskaźników w sekcji współdzielonej jest niemożliwe, ponieważ będą one poprawne jedynie w odniesieniu do procesu, który zapisze je do podmapowanej pamięci w swojej przestrzeni adresowej. Pewnym rozwiązaniem jest użycie przesunięć względem początku pamięci współdzielonej, choć w tym wypadku należy dokładnie sprawdzać, czy dane przesunięcie nie sięga poza podmapowany obszar, w szczególności jeśli druga strona komunikacji nie jest zaufana.
14.5. Wiadomości w WinAPI Nieco odmiennym, ale często wykorzystywanym mechanizmem komunikacji w przypadku graficznych aplikacji w systemie Windows są wiadomości, z których korzysta się zarówno do komunikacji międzyprocesowej, jak i wewnątrzprocesowej (pomiędzy komponentami graficznymi, oknami itp.). W przeciwieństwie do innych opisanych w tym rozdziale mechanizmów, wiadomości pozwalają na przesłanie jedynie liczbowego identyfikatora typu wiadomości oraz dwóch wskaźników nazywanych wParam oraz lParam. W przypadku komunikacji wewnątrzprocesowej ograniczenie to nie stanowi problemu, ponieważ wskaźniki mogą wskazywać na lokalną strukturę z dowolną ilością danych. Z kolei w przypadku komunikacji międzyprocesowej jest się w zasadzie ograniczonym do przesłania dwóch liczb o wielkości wskaźników.
Wiadomości tego typu stosowane są przede wszystkim do przesyłania komunikatów o zdarzeniach interfejsu użytkownika pomiędzy systemem a oknami aplikacji[258] (a raczej procedurami je obsługującymi), a także pomiędzy główną logiką danej aplikacji a jej oknami. Na przykład okno otrzymuje serię wiadomości, jeśli znajdzie się w jego obrębie kursor myszy, użytkownik naciśnie jeden z przycisków lub system zdecyduje, że fragment okna musi zostać odmalowany. Okno może również otrzymać informację od logiki aplikacji, np. o potrzebie zmiany tekstu do wyświetlenia na inny, zablokowaniu przycisku lub zmianie pozycji danej kontrolki. Niemniej jednak wiadomości mogą być również używane w celu przesłania dowolnych innych informacji pomiędzy stronami, które niekoniecznie muszą dotyczyć interfejsu użytkownika. Dodatkowo wiadomości można wysłać również do wszystkich okien w systemie (broadcast), co czyni z tego idealny mechanizm notyfikacji o zdarzeniach systemowych (np. istotnej zmianie konfiguracji). Jeśli chodzi o identyfikator wiadomości, to może on być dowolną, wybraną przez programistę liczbą naturalną. Istnieje jednak umowny podział identyfikatorów na trzy grupy, który ma na celu m.in. zapobiegnięcie kolizji identyfikatorów pomiędzy aplikacjami, komponentami czy bibliotekami. Podział ten wygląda następująco [7]: 0–0x03FF (formalnie 0–WM_USER-1): identyfikatory zarezerwowane na użytek systemu. Większość wiadomości zdefiniowanych w WinAPI, a związanych z UI oraz zdarzeniami w systemie, wpada w ten przedział. 0x0400–0x7FFF
(formalnie
WM_USER–0x7FFF):
identyfikatory
przeznaczone do użytku wewnętrznego danej klasy okna (np. kontrolki). Wiadomości o tych kodach nie powinny być przesyłane pomiędzy oknami różnych (niekompatybilnych) klas, gdyż mogą znaczyć zupełnie różne rzeczy. 0x8000–0xBFFF (formalnie WM_APP–0xBFFF): identyfikatory przeznaczone do wewnętrznego użytku przez aplikację, w tym do komunikacji międzyprocesowej aplikacji, o których wiadomo, że są ze sobą kompatybilne i nie wykorzystują danych identyfikatorów w inny sposób (w szczególności żadna z bibliotek używanych przez aplikację
nie korzysta z tych identyfikatorów w nieprzewidziany przez twórcę sposób). 0xC000–0xFFFF: identyfikatory przydzielane przez system podczas rejestrowania tekstowego typu wiadomości za pomocą funkcji RegisterWindowMessage (patrz równieź tablice atomów [8]). System gwarantuje, że wszystkie zarejestrowane atomy otrzymają niepowtarzalne identyfikatory z tej puli (choć liczbowa wartość samych identyfikatorów jest nieokreślona). Powyżej 0xFFFF: pula zarezerwowana. Technicznie wiadomości są przesyłane przede wszystkim pomiędzy wątkami. Nawet jeśli adresatem wiadomości jest konkretne okno wskazane za pomocą jego wartości HWND (Handle: Window – uchwyt okna), to pierwotnie wiadomość i tak otrzyma wątek, który stworzył wskazane okno. Każdy wątek zainteresowany odbiorem wiadomości (co jest w zasadzie wymogiem dla wątków obsługujących interfejs użytkownika) posiada charakterystyczną pętlę, która odbiera wiadomość (funkcja GetMessage), ewentualnie wstępnie filtruje wiadomości i obsługuje te, których celem nie jest konkretne okno, a w końcu wywołuje procedurę wskazanego w wiadomości okna, przekazując jej wiadomość (funkcja DispatchMessage). W przypadku aplikacji wysoce interaktywnych (np. gier) w miejsce blokującej funkcji GetMessage stosuje się asynchroniczne wywołanie PeekMessage. Wysyłanie wiadomości odbywa się na jeden z dwóch sposobów: Asynchronicznie (funkcje z rodziny PostMessage) – wiadomość jest umieszczana w kolejce wiadomości wątku docelowego i jest faktycznie odbierana, dopiero gdy wątek wywoła funkcję GetMessage lub PeekMessage. Synchronicznie (funkcje z rodziny SendMessage) – wiadomość jest natychmiast dostarczana do procedury obsługującej wskazane okno[259]. Procedura obsługująca okno może zwrócić liczbę (typu LRESULT), która zostanie przesłana nadawcy wiadomości jako wartość zwrócona SendMessage lub w inny sposób w przypadku innych funkcji z tej rodziny.
Sposób wysłania wiadomości zależy m.in. od jej specyfiki. Na przykład pobranie nazwy okna (wiadomość WM_GETTEXT) wymaga de facto skopiowania danych do wskazanego bufora – zazwyczaj w takich przypadkach bufor jest lokalny, a informacja potrzebna jest natychmiast, więc korzysta się z funkcji SendMessage. Z drugiej strony, informacja o rozpoczęciu procedury zamykania procesu (WM_QUIT) powinna przejść przez kolejkę wiadomości w danym wątku (GetMessage oraz PeekMessage zwracają FALSE, gdy ją odbiorą, co ma na celu przerwanie pętli odbierającej wiadomości), jest zatem zawsze wysyłana za pomocą funkcji PostMessage. Zaprezentowany dalej przykład demonstruje użycie wiadomości do komunikacji międzyprocesowej. Konkretniej każde uruchomienie aplikacji powoduje rozesłanie „wiadomości powitalnej” do wszystkich okien w systemie. Wiadomość ta jest obsługiwana przez inne instancje tej samej aplikacji, które o powitaniu informują w konsoli. Analogicznie, jeśli któraś z instancji zostanie zamknięta, rozsyła ona „wiadomość pożegnalną” do wszystkich innych instancji, a technicznie – do wszystkich okien w systemie. Przykładowy kod przedstawia się następująco (C++, WinAPI): #include #include static const char kWindowClass[] = "MsgTestWindowClass"; static const char kUniqueMessageClass[] = "MsgTestMessageClass"; enum { kMsgTestHi, kMsgTestBye }; static DWORD thread_main;
// Identyfikator głównego wątku.
BOOL WINAPI CleanExitHandler(DWORD) { puts("info: received shutdown signal"); // Wyślij wiadomość o zakończaniu aplikacji do głównego wątku.
// Zazwyczaj robi się to za pomocą funkcji PostQuitMessage, jednak // funkcja obsługująca sygnały CTRL+C oraz podobne działa w innym // wątku, więc nie można posłużyć się funkcją PostQuitMessage, // która wysyła wiadomość WM_QUIT tylko do obecnego wątku. PostThreadMessage(thread_main, WM_QUIT, 0, 0); return TRUE; } int main() { // Pobranie adresu obrazu pliku wykonywalnego aplikacji, który jest // często używany w „okienkowej” części WinAPI. HINSTANCE h_instance = static_cast (GetModuleHandle(NULL)); // Rejestracja bardzo prostej klasy okna. WNDCLASSEX wc; memset(&wc, 0, sizeof(wc)); wc.cbSize = sizeof(WNDCLASSEX); wc.lpfnWndProc = DefWindowProc; wc.hInstance wc.lpszClassName
= h_instance; = kWindowClass;
if (!RegisterClassEx(&wc))
{
printf("error: failed to register window class (%u)\n", static_cast(GetLastError())); return 1; } // Stworzenie okna w systemie. Samo okno nie zostanie wyświetlone // (w tym celu należałoby wywołać funkcje ShowWindow i UpdateWindow),
// ale nie przeszkadza to w odbieraniu wiadomości rozgłoszeniowych. HWND hwnd = CreateWindowEx( 0, kWindowClass, NULL, 0, 0, 0, 0, 0, NULL, NULL, h_instance, NULL); if (hwnd == NULL) { printf("error: failed to create a window (%u)\n", static_cast(GetLastError())); return 1; } // Rejestracja unikalnego numeru rodzaju wiadomości w systemie. // Wszystkie inne procesy, które zarejestrują tę samą nazwę // wiadomości, otrzymają dokładnie ten sam identyfikator. UINT msgtest_msg_class = RegisterWindowMessage(kUniqueMessageClass); printf("info: unique message ID is %u\n", msgtest_msg_class); // Wyślij wiadomość powitalną do innych procesów. printf("info: my process ID is %u\n", static_cast(GetCurrentProcessId())); PostMessage(HWND_BROADCAST, msgtest_msg_class, static_cast(GetCurrentProcessId()), static_cast(kMsgTestHi)); // Zarejestruj handler sygnału CTRL+C, który da znać pętli // z wiadomościami, że należy zakończyć proces w kontrolowany sposób. thread_main = GetCurrentThreadId(); SetConsoleCtrlHandler(CleanExitHandler, TRUE); // Odbieraj wiadomości aż do otrzymania informacji o zakończaniu
// procesu. Technicznie GetMessage zwróci FALSE po otrzymaniu // wiadomości WM_QUIT. MSG msg; while(GetMessage(&msg, NULL, 0, 0)) { if (msg.message == msgtest_msg_class) { int m = static_cast(msg.lParam); printf("info: process %u says '%s'\n", static_cast(msg.wParam), m == kMsgTestHi ? "Hi!" : m == kMsgTestBye ? "Bye!" : "???"); } else { DispatchMessage(&msg); } } PostMessage(HWND_BROADCAST, msgtest_msg_class, static_cast(GetCurrentProcessId()), static_cast(kMsgTestBye)); DestroyWindow(hwnd); UnregisterClass(kWindowClass, h_instance); puts("info: bye!"); return 0; } Kompilacja oraz uruchomienie zostały pokazane poniżej. Listing pochodzi z pierwszego włączenia programu, podczas gdy w tle, na innych konsolach, zostały uruchomione kolejne instancje aplikacji: > g++ msgtest.cc -Wall -Wextra -o msgtest > msgtest info: unique message ID is 49990 info: my process ID is 10544 info: process 10544 says 'Hi!' info: process 11112 says 'Hi!' info: process 11112 says 'Bye!' info: process 12020 says 'Hi!'
info: process 5012 says 'Hi!' info: process 5012 says 'Bye!' info: process 12020 says 'Bye!' Użytkownik naciska CTRL+C info: received shutdown signal info: bye! Generalnie głównym zastosowaniem wiadomości jest komunikacja pomiędzy oknami interfejsu graficznego, niemniej jednak temat ten wykracza poza zakres niniejszej książki. W kolejnym rozdziale omówię mechanizmy komunikacji sieciowej, które, jak wskazywałem we wstępie do tego rozdziału, można również z powodzeniem wykorzystać do komunikacji międzyprocesowej.
Ćwiczenia [IPC:paranoid] W niniejszym rozdziale wskazałem metody listowania wszystkich aktywnych nazwanych potoków, gniazd IPC oraz obiektów pamięci współdzielonej. Sporządź ich listę dla swojego systemu oraz wyjaśnij, jaka aplikacja (i w jakim celu) korzysta z danego medium komunikacji.
Bibliografia [1]
PipeList, Microsoft TechNet, https://technet.microsoft.com/enus/sysinternals/dd581625.aspx [2] Stover T., Demystifying Unix Domain Sockets, 2006, http://www.thomasstover.com/uds.html [3] unix(7), Linux man page, http://linux.die.net/man/7/unix
http://coldwind.pl/s/c5r14
[4] CC Hameed, Sessions, Desktops and Windows Stations, Microsoft TechNet, 2007, http://blogs.technet.com/b/askperf/archive/2007/07/24/sessions-desktops-andwindows-stations.aspx
[5]
AccessChk, Microsoft TechNet, https://technet.microsoft.com/enus/sysinternals/bb664922.aspx [6] Forshaw J., Did the “Man With No Name” Feel Insecure?, Google Project Zero, http://googleprojectzero.blogspot.ch/2014/10/did-man-with-no-name-feelinsecure.html [7] WM_USER, Microsoft Developer Network, https://msdn.microsoft.com/enus/library/windows/desktop/ms644931(v=vs.85).aspx [8] About Atom Tables, Microsoft Developer Network, https://msdn.microsoft.com/en[9]
us/library/windows/desktop/ms649053(v=vs.85).aspx Side-channel attack, Wikipedia, https://en.wikipedia.org/wiki/Sidechannel_attack
Rozdział 15.
Komunikacja sieciowa 15.1. Wstęp do sieci TCP/IP Zanim przejdziemy do omawiania kwestii gniazd (sockets) i programowej komunikacji za pośrednictwem sieci lokalnych oraz Internetu, warto poświęcić chwilę na omówienie podstaw sieci w ogólności. Zaznaczę, iż ten podrozdział ma charakter bardzo skrótowy oraz uproszczony – tematyka współczesnych sieci jest bowiem niezwykle rozległa i zdecydowanie wykracza poza zakres niniejszej książki. We wstępie do części „Komunikacja” wspomniałem o modelu OSI oraz o tym, że programiści operują zazwyczaj na wyższych warstwach modelu – w szczególności piątej (warstwa sesji), szóstej (warstwa prezentacji) oraz siódmej (warstwa aplikacji). Niemniej jednak, aby poprawnie tworzyć oprogramowanie sieciowe, warto na krótką chwilę zajrzeć do warstwy czwartej (warstwa transportowa), w której znajdują się protokoły TCP i UDP, oraz trzeciej (warstwa sieciowa), w której znajdziemy protokół IP[260]. Operując w ramach warstwy trzeciej (tj. korzystając z dobrodziejstw warstwy drugiej i pierwszej), można już bezproblemowo przesyłać pakiety danych wewnątrz bezpośredniej sieci lokalnej bez względu na to, czy jest to faktyczna sieć lokalna, czy np. połączenie zestawione pomiędzy dwoma modemami. Mówiąc w pewnym uproszczeniu, przesłanie danych na tej warstwie sprowadza się do przekazania pakietu danych, okraszonego ewentualnym adresem fizycznym odbiorcy, określonemu interfejsowi sieciowemu (np. konkretnej karcie sieciowej lub modemowi). Sam interfejs, łącznie z mechanizmami działającymi na warstwach łącza danych oraz fizycznej, jest odpowiedzialny za przesył danych, korzystając z dostępnego medium (np. miedzianego kabla, światłowodu, fal radiowych lub podczerwieni). Należy zwrócić uwagę, że na tym poziomie nie istnieją „połączenia”, „sesje” i tym podobne koncepty. Mamy do czynienia jedynie z pakietem danych, który przez określony okres jest przesyłany przez dostępne
medium bezpośrednio połączone z danym interfejsem. Co więcej, docelowy odbiorca również musi być podłączony do tego samego medium – jedynymi pośrednikami mogą być urządzenia warstwy pierwszej i drugiej przekazujące pakiety dalej, takie jak przełącznik sieciowy (switch) działający w drugiej warstwie czy wtórnik (repeater) działający w warstwie pierwszej.
Rysunek 1. Przykładowa topologia sieci składającej się z czterech komputerów i przełącznika sieciowego
Na przykład w przypadku topologii sieci przedstawionej na rysunku 1 pakiet wysłany przez PC 1 poprzez interfejs eth0 będzie widziany jedynie przez interfejs eth0 komputera PC 2. W przypadku wysłania pakietu przez PC 1, korzystając z interfejsu eth1, pakiet ten będzie mógł być odebrany przez PC 3 oraz PC 4, ponieważ przełącznik sieciowy poda otrzymany pakiet dalej, do interfejsów sieciowych obu tych komputerów[261]. Jednocześnie, korzystając jedynie z dobrodziejstw warstwy drugiej i pierwszej, komputer PC 2 nie będzie w stanie skomunikować się z komputerami PC 3 i PC 4, ponieważ te nie należą do tej samej bezpośredniej sieci lokalnej[262]. W ten sposób dochodzimy do protokołu IPv4 (Internet Protocol version 4), działającego w obrębie warstwy trzeciej. Protokół ten wprowadza m.in. następujące mechanizmy: Hierarchiczne, 32-bitowe adresy IPv4 (nazywane w skrócie adresami IP), prezentowane zwyczajowo w postaci decymalnie zapisanych 8bitowych wartości oddzielonych kropkami, np. 192.168.2.12. Routing (przekazywanie pakietów) pomiędzy bezpośrednimi sieciami lokalnymi, a co za tym idzie również (pośrednio) pomiędzy bardzo odległymi sieciami. Dokładny opis tematyki adresacji i routingu wykracza poza zakres niniejszej pozycji, dlatego poprzestanę na opisie uproszczonym.
Rysunek 2. Przykładowa maska, adresy IP i adresy podsieci W ramach protokołu IPv4 każda sieć (a w zasadzie podsieć) posiada swój własny adres IP oraz tzw. maskę podsieci, będącą w praktyce maską bitową, analogiczną do tych omawianych w rozdziale „Format BMP”, o wielkości 32 bitów[263]. Dodatkowo każde urządzenie w danej sieci posiada unikatowy adres IP, który spełnia następującą własność (patrz również rys. 2): adres_IP AND maska_podsieci == adres_IP_podsieci Co za tym idzie adresem IP podsieci jest zawsze najniższy możliwy adres IP spełniający przedstawioną zależność[264]. Interfejsy sieciowe [VERBOSE] W celu wylistowania dostępnych w systemie interfejsów sieciowych można posłużyć się konsolą i następującymi poleceniami: GNU/Linux: $ /sbin/ifconfig eth0 Link encap:Ethernet
HWaddr 08:00:27:46:e5:e5
inet addr:192.168.2.198
Bcast:192.168.2.255
Mask:255.255.255.0 inet6 addr: fe80::a00:27ff:fe46:e5e5/64 Scope:Link UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1 RX packets:903281 errors:0 dropped:15 overruns:0 frame:0 TX packets:110669 errors:0 dropped:0 overruns:0 carrier:0 collisions:0 txqueuelen:1000 RX bytes:1006830978 (1.0 GB)
TX bytes:7496555 (7.4
MB) lo
Link encap:Local Loopback inet addr:127.0.0.1 Mask:255.0.0.0 inet6 addr: ::1/128 Scope:Host UP LOOPBACK RUNNING MTU:65536 Metric:1 RX packets:133 errors:0 dropped:0 overruns:0 frame:0 TX packets:133 errors:0 dropped:0 overruns:0
carrier:0 collisions:0 txqueuelen:0 RX bytes:9505 (9.5 KB) TX bytes:9505 (9.5 KB) Windows: > ipconfig /all ... Ethernet adapter Local Area Connection: Connection-specific DNS Suffix Description . . . . . . . . . . Controller Physical Address. . . . . . . . DHCP Enabled. . . . . . . . . . Autoconfiguration Enabled . . . Link-local IPv6 Address . . . . f525%13(Preferred)
. : . : Realtek PCIe GBE Family . . . .
: : : :
50-E5-49-3D-A2-14 No Yes fe80::8cf1:df55:d18e:
IPv4 Address. . . . . . . . . . . : 192.168.0.199(Preferred) Subnet Mask . . . . . . . . . . . : 255.255.255.0 IPv4 Address. . . . . . . . . . . : 192.168.2.199(Preferred) Subnet Mask . . . . . . . . . . . : 255.255.255.0 Default Gateway . . . . . . . . . : 192.168.2.1 DHCPv6 IAID . . . . . . . . . . . : 357623113 DHCPv6 Client DUID. . . . . . . . : 00-01-00-01-16-F8-C154- 50-67-F1-37-AB-4F DNS Servers . . . . . . . . . . . : 8.8.8.8 8.8.4.4 NetBIOS over Tcpip. . . . . . . . : Enabled W obu przypadkach w listingach można znaleźć takie informacje, jak: Adres fizyczny interfejsu (HWaddr, Physical Address) używany w warstwie drugiej. Adres lub adresy IPv4 oraz ewentualnie IPv6 interfejsu (inet/inet6 addr, IPv4/Link-local IPv6 Address) używane w warstwie trzeciej. Maskę lub maski podsieci IPv4 (mask, Subnet mask) używane również w warstwie trzeciej. Dodatkowe informacje zależne od systemu. Dodam, że na każdą kartę sieciową przypada przynajmniej jeden interfejs. Ponadto część interfejsów może być wirtualna – typowym przykładem jest tzw. loopback (pętla zwrotna), czyli pseudointerfejs sieciowy używany do komunikacji TCP/IP w obrębie jednego komputera. Innymi przykładami mogą być pseudointerfejsy sieci VPN (Virtual Private Network[265]) lub programowe interfejsy połączone z wirtualnymi maszynami. Drugim ze wspomnianych mechanizmów jest routing, czyli możliwość przekazywania pakietów pomiędzy sieciami. Ma on miejsce w momencie, gdy urządzenie otrzymuje pakiet przeznaczony dla odbiorcy znajdującego się w innej podsieci i stara się przesłać pakiet, korzystając z interfejsu: Bezpośrednio podłączonego do podsieci, w której znajduje się odbiorca.
Podłączonego do podsieci, przez którą ostatecznie można dotrzeć do podsieci docelowej. Domyślnego, czyli takiego, na który przesyłane są wszystkie pakiety, których docelowy adres IP nie należy do jednej ze znanych podsieci.
Rysunek 3. Przykładowa topologia połączonych ze sobą w jedną sieć
trzech
podsieci
lokalnych
pośrednio
W przypadku sieci przedstawionej na rysunku 3, przy założeniu włączonego i odpowiednio skonfigurowanego routingu na komputerach PC 1 oraz PC 2, możliwe jest przesłanie pakietu IP pomiędzy komputerem PC 4 a PC 5 pomimo faktu, że nie znajdują się one w tej samej sieci lokalnej. Tablica tras [VERBOSE] Bez względu na to, czy przekazywanie zewnętrznych pakietów (tzw. forwarding) jest włączone, również lokalny system musi odpowiednio kierować pakiety IP generowane w ramach działających na nim aplikacji do odpowiednich interfejsów. Z tego względu system operacyjny posiada jasno zdefiniowaną tablicę tras (routing table), która jest przez niego wykorzystywana podczas nadawania pakietów. Aby wyświetlić tablicę routingu, można posłużyć się następującymi poleceniami: GNU/Linux: $ /sbin/route Kernel IP routing table Destination Gateway Ref Use Iface default 0 eth0 192.168.2.0 0 eth0 192.168.56.0 0 eth1
Genmask
Flags Metric
192.168.2.1
0.0.0.0
UG
0
0
*
255.255.255.0
U
0
0
*
255.255.255.0
U
0
0
Windows:
> route print ================================================================= Interface List 13...50 e5 49 3d e0 a0 ......Realtek PCIe GBE Family Controller 23...08 00 27 00 48 76 ......VirtualBox Host-Only Ethernet Adapter 1...........................Software Loopback Interface 1
================================================================= IPv4 Route Table
================================================================= Active Routes: Network Destination Netmask Gateway Interface Metric 0.0.0.0 192.168.0.199 266 127.0.0.0 127.0.0.1 306
0.0.0.0
192.168.2.1
255.0.0.0
On-link
255.255.255.255
On-link
255.255.255.255
On-link
192.168.0.0 255.255.255.0 192.168.0.199 266 192.168.0.199 255.255.255.255 192.168.0.199 266
On-link
192.168.0.255 255.255.255.255 192.168.0.199 266 192.168.2.0 255.255.255.0
On-link
127.0.0.1 127.0.0.1 306 127.255.255.255 127.0.0.1 306
192.168.0.199 266 192.168.2.199 255.255.255.255 192.168.0.199 266 192.168.2.255 255.255.255.255 192.168.0.199 266 192.168.56.0 255.255.255.0 192.168.56.1 276 192.168.56.1 255.255.255.255 192.168.56.1 276 192.168.56.255 255.255.255.255 192.168.56.1 276 224.0.0.0 240.0.0.0 127.0.0.1 306
On-link
On-link On-link On-link On-link On-link On-link On-link
224.0.0.0 192.168.56.1 276 224.0.0.0
240.0.0.0
On-link
240.0.0.0
On-link
192.168.0.199 266 255.255.255.255 255.255.255.255 127.0.0.1 306 255.255.255.255 255.255.255.255
On-link
On-link 192.168.56.1 276 255.255.255.255 255.255.255.255 On-link 192.168.0.199 266 =================================================================
Persistent Routes: Network Address Netmask Gateway Address Metric 0.0.0.0 0.0.0.0 192.168.2.1 Default ================================================================= W obu przypadkach warto zwrócić uwagę na zdefiniowaną domyślną trasę (wpisy oznaczone jako default) dla pakietów IP, których adres docelowy nie należy do żadnej z innych znanych podsieci. Na podobnej zasadzie działa sieć Internet – w ogólności jest to zestaw ogromnej liczby połączonych wzajemnie sieci, które dzięki mechanizmowi routingu pozwalają na komunikację bardzo odległych (topologicznie) węzłów znajdujących się w różnych sieciach. Korzystając z domyślnie dostępnych narzędzi, bardzo łatwo jest sprawdzić, jaką trasę musi przebyć pakiet, by dotrzeć do określonego adresu docelowego. Wystarczy w tym celu skorzystać z poleceń tracert (Windows) lub traceroute (GNU/Linux)[266], np.: $ traceroute -n -q1 cern.ch traceroute to cern.ch (188.184.9.234), 30 hops max, 60 byte packets 1 2 3 4
31.133.0.1 0.169 ms 91.216.191.253 0.160 ms 212.162.10.113 72.189 ms 194.25.210.45 15.589 ms
5
217.239.43.29
6 7
193.159.166.222 45.225 ms 192.65.184.38 50.776 ms
46.300 ms
8 * ... IPv4 [BEYOND] Bardzo wiele otwartych protokołów sieciowych, w tym IPv4, jest zdefiniowanych w formie tzw. RFC (Request for Comments[267]). Podstawowa specyfikacja IPv4 jest zawarta w RFC 791 [1] opublikowanym w roku 1981, choć należy dodać, że w późniejszym czasie ukazywały się kolejne rewizje, rozszerzające pierwotną specyfikację. Chciałbym zachęcić czytelników do zapoznania się zarówno ze wskazanym wcześniej, jak i innymi wybranymi RFC. Dodam, że dokumenty tego rodzaju są pisane specyficznym, nieco prawniczym językiem, do którego warto się przyzwyczaić – są one bowiem niezastąpione w czasie tworzenia aplikacji sieciowych wykorzystujących otwarte protokoły. RFC 791 definiuje m.in. nagłówek IPv4, który towarzyszy każdemu pakietowi przesyłanemu przez Internet, a obecnie również przez sieci lokalne (choć należy dodać, że w obu przypadkach coraz bardziej popularny staje się IPv6 – nowa wersja protokołu internetowego). Nagłówek ten przedstawia się następująco (źródło[268]: RFC 791):
Example Internet Datagram Header W nagłówku tym warto zwrócić uwagę m.in. na następujące pola: Source Address – adres IP nadawcy, dzięki któremu odbiorca wie, kto nadał pakiet i kto potencjalnie oczekuje na odpowiedź (w ramach protokołu nabudowującego na IPv4). Destination Address – adres IP odbiorcy. Time to Live – pozostały czas życia pakietu; każdy router na trasie pakietu powinien zmniejszyć wartość zapisaną w tym polu co najmniej o jeden – ma to na celu zapobieganie powstawaniu pakietów, które w nieskończoność krążyłyby po sieci. Header Checksum – o ile IPv4 dba o integralność własnego nagłówka za pomocą sumy kontrolnej zapisanej w tym polu, o tyle integralność samych danych spoczywa na barkach protokołów wyższych warstw. Protocol – informacja o naturze transportowanych danych. Na przykład protokół TCP jest reprezentowany przez wartość 6, natomiast UDP – 17 [9].
Opuszczając warstwę trzecią i kierując się w górę, docieramy do warstwy transportowej (czwartej), w której znajdują się dwa istotne protokoły: UDP (User Datagram Protocol) oraz TCP (Transmission Control Protocol). Porównując je ze sobą, można powiedzieć, że UDP jest bardzo prostym i szybkim protokołem, umożliwiającym przesyłanie paczek danych o określonej długości, natomiast TCP stara się zagwarantować stabilność i integralność przesyłanych strumieniowo danych, często kosztem konieczności ponowienia wysłania pakietów. Bardziej szczegółowe porównanie znajduje się w tabeli 1. Tabela 1. Porównanie protokołów UDP i TCP
Cecha
UDP
T CP
S tanowoś ć
Bezs tanowy.
Dane s ą przes yłane w ramach zes tawionych i ś ciś le kontrolowanych połączeń.
Tryb przes yłania danych
Pakietowy (dane s ą przes yłane w tzw. datag ramach).
S trumieniowy.
Kontrola integ ralnoś ci danych
16-bitowa s uma kontrolna.
16-bitowa s uma kontrolna.
Reakcja na wykrycie us zkodzonych danych
Bezpowrotne odrzucenie pakietu.
Retrans mis ja poprawnej wers ji us zkodzonych danych.
Potwierdzenie odbioru
Brak.
Odbiorca potwierdza odbiór danych. W razie braku wymag aneg o potwierdzenia dane s ą wys yłane ponownie.
Detekcja zduplikowanych pakietów
Brak.
Nadmiarowe, powielone pakiety s ą odrzucane.
Kolejnoś ć odebranych danych
Nieokreś lona.
Zg odna z kolejnoś cią wys łania – implementacja TCP zajmuje s ię ewentualnym przes ortowaniem otrzymanych s eg mentów danych.
Wymog i co do rozpoczęcia komunikacji
Brak.
Obie s trony mus zą potwierdzić chęć komunikacji.
Wymog i co do zakończenia komunikacji
Brak.
Komunikacja jes t kończona na wyraźne życzenie jednej ze s tron lub po okreś lonym czas ie braku aktywnoś ci którejkolwiek ze s tron.
Wspólnym konceptem dla obu protokołów jest port – 16-bitowy identyfikator (liczba), używany przede wszystkim przez system operacyjny w celu powiązania konkretnego procesu działającego w systemie z napływającymi datagramami lub połączeniami sieciowymi. Dzięki temu system jest w stanie przekazać przychodzące dane (lub informacje o stanie połączenia) odpowiedniej aplikacji. Co za tym idzie nagłówek każdego pakietu TCP oraz UDP zawiera informacje o porcie docelowym oraz źródłowym (zwrotnym)[269]. TCP [BEYOND] Podobnie jak IPv4, protokół TCP został zdefiniowany w ramach RFC, a konkretniej w RFC 793 [10] opublikowanym w roku 1981. Podobnie jak w przypadku informacji o IPv4, zaznaczę, że dokładny opis protokołu, a także maszyny stanów TCP, wykracza poza zakres tej książki, niemniej jednak warto przynajmniej spojrzeć na budowę nagłówka pakietu TCP (źródło: RFC 793):
TCP Header Format Note that one tick mark represents one bit position. W nagłówku tym warto zwrócić szczególną uwagę na kilka pól: Destination Port oraz Source Port to wspomniane wcześniej porty – docelowy oraz źródłowy. Flagi, a w szczególności ACK i SYN – wykorzystywane m.in. podczas nawiązywania połączenia, o czym wspominam w dalszej części tego podrozdziału. Checksum, o którym wspominałem już kilkukrotnie – przechowuje ono 16-bitową sumę kontrolną transportowanych danych oraz samych nagłówków, a ponadto umożliwia wykrycie niektórych uszkodzeń danych. Sequence Number oraz Acknowledgment Number – służą m.in. do zapewnienia kolejności odbioru danych zgodnej z kolejnością ich wysłania oraz do kontroli, czy wszystkie kolejne pakiety z powodzeniem dotarły do odbiorcy.
Patrząc na porty od drugiej strony, uruchomiona aplikacja – chcąc prowadzić komunikację – musi utworzyć tzw. gniazdo (socket), które tworzy powiązanie pomiędzy aplikacją a: (w przypadku UDP) Konkretnym portem lokalnym – wszystkie pakiety przychodzące, które zostaną odebrane i mają podany określony port lokalny w polu portu docelowego, zostaną przekazane aplikacji. (w przypadku TCP) Konkretnym portem lokalnym w przypadku gniazd nasłuchujących (listening) lub, w przypadku gniazda reprezentującego nawiązane połączenie, zestawem złożonym z lokalnego portu, lokalnego adresu IP używanego interfejsu, zdalnego portu (i ewentualnie źródłowego portu nadawcy) oraz zdalnego adresu IP. Ilustracja zaprezentowanego opisu znajduje się na rysunku 4.
Rysunek 4. Gniazda, porty i połączenia Aplikacje mają dużą swobodę, jeśli chodzi o wybór wykorzystywanych do komunikacji portów[270], jednak przyjęło się, że pewne popularne serwisy wykorzystują określone porty, np.: 22 TCP – SSH (Secure SHell). 53 UDP i TCP – DNS (Domain Name System). 80 TCP – HTTP. 443 TCP – HTTPS (HTTP przesyłane przez SSL). 6667 TCP – IRC. Co więcej, organizacja ICANN (Internet Corporation for Assigned Names and Numbers) odpowiedzialna m.in. za przydzielanie adresów IP w sieci Internet, prowadzi rejestr numerów portów używanych przez konkretne, zarejestrowane protokoły [11]. Rejestr ten ma charakter czysto umowny i nie wprowadza (oraz nie narzuca) żadnych technicznych ograniczeń wykorzystania konkretnych portów do innych celów[271]. W tabeli 1 wspomniałem, że w przypadku TCP wymiana danych odbywa się w obrębie zestawionych i ściśle kontrolowanych połączeń. W celu nawiązania połączenia jedna ze stron tworzy gniazdo nasłuchujące i powiązuje je (bind) z określonym portem TCP oraz wybranym, lokalnym adresem IP wskazanego interfejsu sieciowego. Alternatywnie gniazdo może być powiązane z określonym portem TCP na wszystkich dostępnych interfejsach sieciowych – w takim wypadku za lokalny adres IP uznaje się 0.0.0.0. Gdy gniazdo nasłuchujące jest gotowe, druga strona komunikacji może zainicjować połączenie (connect). Z niskopoziomowego punktu widzenia „połączenie” jest czysto umownym konceptem i aby je nawiązać, należy wymienić kilka pakietów TCP w ramach tzw. three-way handshake[272]. W uproszczeniu proces ten sprowadza się do przesłania następujących trzech[273] pakietów pomiędzy stroną nawiązującą połączenie (klientem) a właścicielem gniazda nasłuchującego (serwerem): 1. Klient wysyła do serwera pakiet TCP z ustawioną flagą SYN. 2. Serwer wysyła do klienta pakiet TCP z ustawionymi flagami SYN oraz ACK. 3. Klient wysyła do serwera pakiet TCP z ustawioną flagą ACK.
Po przeprowadzaniu opisanej wymiany uznaje się, że połączenie jest nawiązane, z czym wiąże się m.in. stworzenie po stronie serwera drugiego gniazda, reprezentującego nawiązane połączenie, które może zostać użyte do wymiany danych lub zamknięcia połączenia. Samo użyte wcześniej gniazdo nasłuchujące nadal pozostaje aktywne i może zostać wykorzystane do odbierania (accept) kolejnych połączeń. Protokół UDP jest znacznie prostszy i nie wymaga zestawiania połączeń. Z tego względu każde gniazdo korzystające z UDP jest jednocześnie gniazdem nasłuchującym (tj. wszystkie pakiety wysłane na określony port docelowy trafią do tego gniazda) i może zostać użyte do wysłania pakietu do dowolnego adresata, określonego konkretnym adresem IP oraz portem docelowym. UDP [BEYOND] W porównaniu z TCP oraz IPv4 protokół UDP jest trywialny i jego bazowa specyfikacja – RFC 768 [3] – zawiera się w trzech stronach (w porównaniu z 85 stronami TCP oraz 45 stronami IPv4). Ma to m.in. związek z jego bezstanowością, ale również z brakiem gwarancji niezawodności w dostarczaniu danych czy też z chęcią stworzenia jak najmniejszego pakietu, tak by narzut ze strony nagłówka był jak najmniejszy (co w przeszłości było bardziej istotne niż obecnie). Sam nagłówek UDP prezentuje się następująco (źródło: RFC 768):
User Datagram Header Format Nagłówek ten zawiera jedynie cztery pola: Source Port, Destination Port i Checksum, których funkcja jest identyczna jak w przypadku TCP (patrz ramka „TCP [BEYOND]”), oraz pole Length, które mówi o długości danych przesyłanych w pakiecie (w bajtach). Z uwagi na szerokość pola jeden pakiet może pomieścić maksymalnie 65535 bajtów danych[274].
Wyświetlenie aktywnych połączeń [VERBOSE] Zarówno systemy z rodziny GNU/Linux, jak i z rodziny Windows dysponują domyślnym narzędziem netstat, które wyświetla aktywne gniazda w systemie. Przykład jego użycia znajduje się poniżej: GNU/Linux: $ netstat -atun Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address
Foreign Address
State tcp LISTEN tcp LISTEN tcp LISTEN tcp LISTEN tcp
0
0 0.0.0.0:22
0.0.0.0:*
0
0 127.0.0.1:25
0.0.0.0:*
0
0 192.168.56.198:445
0.0.0.0:*
0
0 127.0.0.1:33321
0.0.0.0:*
0 192.168.56.198:139
0.0.0.0:*
0 192.168.56.198:51930
192.168.56.1:6000
0 192.168.56.198:38235
192.168.56.1:33321
0 192.168.56.198:51926
192.168.56.1:6000
0 0.0.0.0:43793 0 0.0.0.0:137
0.0.0.0:* 0.0.0.0:*
0 LISTEN tcp 0 ESTABLISHED tcp 0 ESTABLISHED tcp 0 ESTABLISHED ... udp udp ...
0 0
Windows: > netstat -an Active Connections Proto Local Address TCP 0.0.0.0:21 LISTENING TCP 0.0.0.0:135 LISTENING ... TCP 127.0.0.1:49194 ESTABLISHED
Foreign Address 0.0.0.0:0 0.0.0.0:0
127.0.0.1:14147
State
127.0.0.1:49228
0.0.0.0:0
LISTENING TCP 127.0.0.1:50911
TCP
0.0.0.0:0
LISTENING TCP 192.168.0.199:139 LISTENING TCP 192.168.0.199:55974 TIME_WAIT ... UDP 127.0.0.1:59915 UDP 192.168.0.199:137 UDP ...
192.168.0.199:138
0.0.0.0:0 192.168.0.77:139
*:* *:* *:*
W zależności od użytych opcji oraz posiadanych uprawnień netstat potrafi również wyświetlić aplikacje, które są właścicielami wypisanych gniazd (opcja -p w przypadku systemów z rodziny GNU/Linux oraz -b w przypadku Windows). Na zakończenie tego podrozdziału warto dodać, że istnieje wiele narzędzi, które mogą ułatwić programiście tworzenie aplikacji sieciowych. Jednym z nich jest Wireshark (patrz rys. 5) – sniffer, który pozwala na dokładną inspekcję ruchu sieciowego na poziomie pojedynczych pakietów warstwy drugiej, z wbudowanymi mechanizmami ułatwiającymi analizę protokołów z wyższych warstw. Co więcej, Wireshark „rozumie” wiele popularnych protokołów i potrafi wyświetlić przesyłane dane w przejrzystej i łatwej dla zrozumienia dla człowieka formie. Narzędzie to jest w zasadzie obowiązkowe, jeśli tworzymy aplikacje sieciowe, ponieważ znacznie ułatwia proces ich debugowania.
Rysunek 5. Program Wireshark i pakiet DNS Dwa kolejne narzędzia, które warto wymienić, to socat (SOcket CAT) oraz netcat[275] (często skracany do „nc”). Są to niewielkie konsolowe aplikacje pozwalające tworzyć i nawiązywać połączenia, co również bywa niezwykle przydatne podczas debugowania. Czasami, jeśli nie udaje się nawiązać połączeń sieciowych (szczególnie w kierunku przychodzącym) lub przesyłać datagramów, przyczynę problemu można znaleźć w konfiguracji zapory, której głównym celem jest ograniczenie zewnętrznego dostępu do wrażliwych aplikacji sieciowych. Warto również zapoznać się z działaniem zapory sieciowej wbudowanej w system – iptables w systemach GNU/Linux oraz z Zaporą systemu Windows (Windows Firewall), którą można zarządzać zarówno z konsoli (polecenie netsh advfirewall), jak i w trybie graficznym („Zapora systemu Windows z zabezpieczeniami zaawansowanymi” w menu Start). O wprowadzaniu zmian do konfiguracji zapory wspomnę jeszcze przy okazji omawiania tematu gniazd nasłuchujących.
15.2. Gniazda TCP oraz DNS Jak już kilkukrotnie wspomniałem, w praktyce programiści nie operują bezpośrednio na protokołach IPv4, TCP czy UDP – zamiast tego korzysta się z gniazd udostępnionych przez system operacyjny, który ukrywa całą mechanikę związaną z protokołami niższych warstw. W praktyce w przypadku TCP i IPv4 korzysta się z gniazd typu AF_INET (Address Family: Internet) w połączeniu z SOCK_STREAM (strumieniowe przesyłanie danych, w praktyce wymuszające użycie protokołu TCP). W przypadku TCP jedna strona połączenia jest zawsze serwerem, a druga klientem – ta pierwsza tworzy gniazdo nasłuchujące, a druga nawiązuje z nim połączenie, czego efektem jest utworzenie po stronie serwera kolejnego gniazda reprezentującego nawiązane połączenie. Ze względu na ten podział operowanie na gnieździe serwerowym oraz klienckim różni się w nieznacznym stopniu, co zostało zilustrowane poniżej (w pseudokodzie naśladującym typowe dla gniazd API)[276]:
Serwer
Klient
Komentarz
s = socket()
c = socket()
Obie s trony tworzą g niazdo.
bind(s, "0.0.0.0", 1234)
S erwer wiąże g niazdo ze ws kazanym lokalnym adres em IPv4 oraz portem TCP. Opcjonalnie również klient może s tworzyć takie dowiązanie, co pozwala na wybranie konkretneg o, źródłoweg o portu TCP; jeś li teg o nie zrobi, źródłowy port TCP zos tanie wybrany przez s ys tem operacyjny.
listen(s)
S erwer przeks ztałca g niazdo w g niazdo nas łuchujące.
c = accept(s)
S erwer czeka na połączenie przychodzące – jeś li zos tanie poprawnie nawiązane,
s erwer otrzyma g niazdo noweg o połączenia. ...
connect(c, "1.2.3.4", 1234)
Klient nawiązuje połączenie z s erwerem.
(accept zwraca nowe gniazdo)
Wątek s erwera zos taje wznowiony, a accept zwraca g niazdo noweg o połączenia.
close(s)
Opcjonalnie s erwer zamyka od razu g niazdo nas łuchujące. Alternatywnie móg łby kontynuować oczekiwanie na kolejne połączenia (a wymianę danych w ramach noweg o połączenia wykonywać w os obnym wątku).
data = recv(c) send(c, data)
Obie s trony wymieniają informacje. Domyś lnie recv i send s ą operacjami blokującymi (recv do czas u pojawienia s ię danych, send do czas u nadania ws zys tkich lub częś ci danych).
shutdown(c)
Jedna ze s tron kończy połączenie.
close(c)
close(c)
Obie s trony zamykają niepotrzebne już g niazda.
Gniazda i Windows [VERBOSE] Na poziomie API systemowego gniazda na systemach z rodziny GNU/Linux oraz Windows (udostępnione w ramach biblioteki Winsock lub Windows Socket 2) są bardzo zbliżone – różnice są kosmetyczne i zazwyczaj można je ukryć prostymi rzutowaniami lub zmianą nazwy funkcji. Na przykład funkcja send w systemie GNU/Linux przyjmuje dowolny rodzaj wskaźnika na bufor, natomiast w przypadku WinAPI wymagany jest wskaźnik typu const char *. Niewielka różnica występuje również w funkcji close, która w przypadku
WinAPI nosi nazwę closesocket. Po stronie systemu Windows istnieją jednak dwie dodatkowe różnice, które warto wskazać: Użycie gniazd wymaga zlinkowania aplikacji z jedną z dodatkowych bibliotek – wsock32 lub ws2_32[277]. Przed pierwszym użyciem gniazda należy przeprowadzić inicjalizację biblioteki. Ostatni punkt sprowadza się do krótkiej wstawki, którą najlepiej umieścić w pobliżu początku programu: WSADATA wsdat; memset(&wsdat, 0, sizeof(wsdat)); if (WSAStartup(MAKEWORD(2, 2), &wsdat)) { // Wystąpił błąd. // Np.: return false; } Pierwszym parametrem funkcji WSAStartup jest wersja biblioteki Winsock wymagana przez aplikację – obecną wersją niezmiennie od roku 1996 jest 2.2. Wypełniana przez funkcję struktura WSADATA zawiera kilka informacji o bibliotece, które nie są kluczowe dla działania programu. Dodam, że istnieją biblioteki międzyplatformowe, które ukrywają omówione różnice przed programistą i pozwalają skupić się na samym kodzie sieciowym – przykładem może być bardzo prosta biblioteka NetSock [6]. W zależności od konkretnego API funkcja nawiązująca połączenie może wymagać podania adresu IP (np. 31.133.0.26) albo umożliwić również podanie adresu w formie domeny (np. gynvael.coldwind.pl). W drugim przypadku wewnętrzna implementacja gniazd będzie musiała przetłumaczyć domenę na konkretny adres IPv4, co współcześnie realizuje się na jeden z następujących sposobów: Tłumaczenie domeny na adres IP jest pobierane z pliku /etc/hosts lub C:\Windows\System32\drivers\etc\hosts. Adres IP domeny znajduje się w pamięci podręcznej[278].
W ostateczności system wysyła prośbę o przetłumaczenie adresu do wybranego serwera DNS (Domain Name System)[279]. W uproszczeniu o systemie DNS można myśleć jako o hierarchicznej, rozproszonej bazie danych. Hierarchia jest związana z budową nazw domen, które przypominają zapisaną od tyłu ścieżkę katalogów. Na przykład rozważając domenę gynvael.coldwind.pl, ostatni człon – „pl” – jest tzw. domeną najwyższego poziomu (Top Level Domain, TLD). W obrębie tej domeny jest zdefiniowana subdomena „coldwind”, z kolei w obrębie której jest zdefiniowana kolejna subdomena – „gynvael”. Co za tym idzie, jeśli serwer DNS, z którym komunikuje się nasz system, nie posiada adresu danej domeny w pamięci podręcznej ani nie jest serwerem autorytatywnym w sprawie tej domeny[280], musi ustalić serwer DNS odpowiedzialny za daną domenę. Sprowadza się to do rekursywnego odpytywania serwerów DNS, zaczynając od głównych (Root Servers) [7][281], poprzez serwery główne danej domeny najwyższego poziomu, aż do dotarcia do serwera DNS, który będzie w stanie odpowiedzieć na zapytania dotyczące poszukiwanej domeny. W tym miejscu warto zaznaczyć, że serwery DNS przechowują zbiór różnych informacji nie tylko o adresie IP, na jaki tłumaczy się dana domena. Typów wpisów jest kilka[282], a wybrane z nich określa się następującymi oznaczeniami: A (1, host Address) – adres IP, na jaki tłumaczy się dana domena. NS (2, authoritative Name Server) – adres serwera DNS, który może posiadać informacje o danej domenie (lub przynajmniej o pewnej jej części). MX (15, Mail eXchange) – adres serwera e-mail obsługującego daną domenę. TXT (16, TeXT strings) – dowolne dane tekstowe powiązane z domeną. W typowym scenariuszu, aby przetłumaczyć adres domeny na adres IP, należy odpytać serwer DNS o rekord A. W celu przeprowadzenia pełnego, rekursywnego rozwiązania nazwy domeny należałoby odpytywać kolejne serwery (rozpoczynając od głównych) o rekord NS aż do odnalezienia serwera odpowiedzialnego za daną domenę – wtedy należałoby go odpytać o rekord A w celu ostatecznego ustalenia adresu IP. Obie te czynności można przetestować,
korzystając z konsolowych narzędzi dostępnych zarówno w systemach z rodziny GNU/Linux (host), jak i Windows (nslookup), np.: $ host gynvael.coldwind.pl 8.8.8.8 ... gynvael.coldwind.pl has address 31.133.0.26 lub manualnie przechodząc przez kolejne szczeble hierarchii: > nslookup -type=NS . ... (root) nameserver = a.root-servers.net (root) nameserver = b.root-servers.net ... > nslookup -type=NS www.gynvael.coldwind.pl a.root-servers.net ... pl nameserver = a-dns.pl pl nameserver = b-dns.pl ... > nslookup -type=NS www.gynvael.coldwind.pl a-dns.pl ... coldwind.pl coldwind.pl
nameserver = dns1.domeny.tv nameserver = dns2.domeny.tv
> nslookup -type=A www.gynvael.coldwind.pl dns1.domeny.tv ... Name: www.gynvael.coldwind.pl Address:
31.133.0.26
Wszystkie opisane czynności są wykonywane w sposób niewidoczny dla programisty przez moduł zajmujący się rozwiązywaniem nazw domen (resolver) w środowisku wykonania programu lub samym systemie operacyjnym oraz końcowy serwer DNS, z którym moduł się ewentualnie komunikuje. Niemniej jednak pozostańmy jeszcze chwilę przy temacie DNS, ponieważ podstawowa wersja protokołu jest na tyle prosta, że może posłużyć za dobry przykład użycia gniazd TCP. W kwestii wyjaśnienia dodam, że odpytywanie serwerów DNS odbywa się zazwyczaj przy użyciu UDP, niemniej jednak protokół ten jest
dostępny również w wersji TCP, co zostało uwzględnione w specyfikacji (bazowa wersja DNS jest opisana w RFC 1035 [12]). Sam protokół DNS opiera się na jednym typie pakietu[283] (patrz rys. 6), używanym zarówno w przypadku zapytania, jak i odpowiedzi, który składa się z 12-bajtowego nagłówka oraz czterech sekcji zawierających „tablice” z: zapytaniami; odpowiedziami na zapytania; informacjami o innych (autorytatywnych) serwerach DNS, które mogą znać odpowiedzi na zapytania; ewentualnie dodatkowymi informacjami.
Rysunek 6. Ogólna struktura pakietu DNS
W przypadku zapytań używana jest struktura o nazwie question, natomiast w innych przypadkach wykorzystywana jest struktura RR (Resource Record). Każda z tych struktur składa się z krótkiego nagłówka, po którym następują dane zmiennej długości – w przypadku struktury question jest to nazwa domeny, o którą serwer jest odpytywany (bądź został odpytany w przypadku odpowiedzi), natomiast w przypadku RR są to dane odpowiedzi, których format zależy od konkretnego typu rekordu. Zacznijmy od nagłówka DNS, który prezentuje się następująco (źródło: RFC 1035):
Poszczególne pola, których rozmiar jest wyrażony w bitach, mają następujące znaczenie: ID – 16-bitowy identyfikator zapytania; klient wysyłający zapytanie może ustawić identyfikator na dowolną wartość (najlepiej unikatową względem danego systemu, choć w przypadku pojedynczego zapytania
korzystającego z TCP nie ma to znaczenia), a serwer skopiuje ją do odpowiedzi. QR (Query) – flaga określająca, czy pakiet jest zapytaniem (0), czy odpowiedzią (1). OPCODE – rodzaj zapytania; zazwyczaj 0 (Query), choć zdefiniowane są również inne wartości [13]. AA (Authorative Answer) – flaga używana w odpowiedzi przez serwer w celu zadeklarowania, że w stosunku do danej domeny odpowiedź jest autorytatywna (tj. serwer jest odpowiedzialny za daną domenę). TC (TrunCation) – flaga używana w odpowiedzi przez serwer w celu zadeklarowania, że odpowiedź była zbyt długa, by zmieścić się w jednym pakiecie, i z tego względu została obcięta. RD (Recursion Desired) – flaga używana w zapytaniu w celu poproszenia serwera o ustalenie adresu domeny rekursywnie, gdyby jej adres nie był mu znany bezpośrednio. RA (Recursion Available) – informacja dla odpytującego mówiąca o tym, czy dany serwer obsługuje przetwarzanie rekursywne. Z – pole zarezerwowane, które musi być wyzerowane we wszystkich pakietach. RCODE – kod błędu w odpowiedzi; może przyjąć następujące wartości: – 0 (No Error) – brak błędu. – 1 (Format Error) – błąd składni zapytania. – 2 (Server Failure) – wewnętrzny błąd serwera. – 3 (Name Error) – błąd nazwy; domena nie istnieje wg serwera autorytatywnego. – 4 (Not Implemented) – serwer nie obsługuje zapytań danego typu. – 5 (Refused) – odmowa wykonania operacji. – W wersji bazowej protokołu pozostałe wartości zostały zarezerwowane na poczet przyszłych wersji protokołu. Kolejne kody zostały zdefiniowane w ramach RFC 2136, RFC 6672, RFC 2845 itd. Pełen spis można znaleźć na stronie IANA dotyczącej protokołu DNS [14]. QDCOUNT – liczba zapytań (tj. liczba struktur question, które wystąpią po nagłówku). ANCOUNT – liczba rekordów RR z odpowiedziami na zapytania.
NSCOUNT
–
liczba
rekordów
RR
z
informacjami
o
serwerach
autorytatywnych. ARCOUNT – liczba rekordów RR z dodatkowymi informacjami. Zanim przejdziemy do omawiania struktury question oraz RR, warto wyjaśnić, jak zapisywana jest nazwa domeny w różnych wewnętrznych polach tych struktur. Jest to o tyle ciekawe, że stosowana jest pewna bardzo prosta kompresja. Przede wszystkim nazwa domeny jest dzielona na poszczególne człony (zwane etykietami – labels) względem znaku kropki. Każdy z członów jest następnie zapisywany w formie długość+dane, gdzie długość jest 6-bitową liczbą zapisaną w 8-bitowym polu, którego dwa górne bity muszą być wyzerowane (z jednym wyjątkiem, o którym dalej). Człony są następnie zapisywane jeden po drugim, a po nich dopisywany jest człon pusty, tj. taki, którego długość wynosi 0 (patrz rys. 7).
Rysunek 7. Zapis nazwy domeny w pakiecie DNS, krok pierwszy Od tej reguły istnieje jeden wyjątek, oznaczany przez zapalenie dwóch górnych bitów w polu długości (tj. bitów 6 oraz 7). W takim przypadku 6 bitów długości oraz kolejne 8 bitów następnego bajtu traktuje się jako 14-bitowy wskaźnik na miejsce w pakiecie (liczone od początku nagłówka), gdzie znajduje się pozostała część nazwy domeny. Pozwala to na skorzystanie z użytej już wcześniej w pakiecie domeny lub jej fragmentu bez konieczności ponownego powtarzania pełnej nazwy
(patrz rys. 8). Kompresja ta jest opcjonalna i można użyć jej jedynie w przypadku końcowych członów w danej nazwie domeny.
Rysunek 8. Zapis nazwy domeny w pakiecie DNS, krok drugi – kompresja Przechodząc do struktury question, ma ona następujący format (źródło: RFC 1035):
Opis znaczeń poszczególnych pól znajduje się poniżej: QNAME – nazwa domeny, zapisana we wspomniany wcześniej sposób. QTYPE – rodzaj rekordu, który nas interesuje, np. A (1), NS (2), MX (15) czy TXT (16). QCLASS – rodzaj sieci, której zapytanie dotyczy; w praktyce stosuje się wartość 1 oznaczającą IN (Internet).
A zatem zapytanie o adres IP (A) domeny gynvael.coldwind.pl w sieci Internet będzie wyglądało następująco (heksadecymalnie + w postaci drukowalnej, jeśli ma sens): 07 "gynvael" 08 "coldwind" 02 "pl" 00 01 01 Sama odpowiedź zostanie dostarczona w postaci struktury RR (jednej lub większej ich liczby, zgodnie z deklaracją serwera w nagłówku pakietu[284]). Struktura ta ma następującą postać (źródło: RFC 1035):
Opis znaczeń poszczególnych pól znajduje się poniżej:
NAME – nazwa domeny, której dotyczą informacje. TYPE – rodzaj rekordu. CLASS – rodzaj sieci; podobnie jak w przypadku zapytania, zazwyczaj będzie to IN (1). TTL (Time To Live) – czas ważności odpowiedzi, tj. przez ile sekund odpowiedź pozostaje ważna. RDLENGTH (Record Data Length) – długość załączonego rekordu. RDATA (Record Data) – faktyczne dane rekordu, którego interpretacja zależy od pola TYPE (oraz ewentualnie CLASS). Jeśli chodzi o same rekordy, to wspomniane RFC definiuje strukturę wszystkich bazowych rekordów. W naszym przypadku przyjmiemy założenie, że interesują nas dwa podstawowe: rekord A zawierający adres IP, na który tłumaczy się dana domena, oraz rekord MX zawierający adres (w formie domeny) serwera pocztowego, który obsługuje e-mail dla danej domeny. Zaczynając od rekordu A, jego budowa jest trywialna i składa się tylko z jednego pola – 32-bitowego adresu IPv4. Rekord MX składa się natomiast z dwóch pól: 16-bitowej wartości PREFERENCE, która w przypadku wystąpienia kilku serwerów obsługujących pocztę wskazuje, które z nich są preferowane (podobnie jak w przypadku różnego rodzaju priorytetów, im mniejsza wartość, tym bardziej preferowany jest dany serwer). EXCHANGE – adres, a ściślej mówiąc nazwa domeny (zapisana w sposób przedstawiony wcześniej) serwera pocztowego. Podsumowując, aby wykonać zapytanie DNS za pomocą TCP, należy: Skonwertować nazwę domeny do zapisu używanego przez DNS. Stworzyć pakiet składający się z: – Nagłówka DNS. – Struktury question zawierającej zapytanie. Połączyć się z serwerem. Wysłać długość pakietu. Wysłać sam pakiet. Odebrać długość pakietu.
Odebrać cały pakiet. Zakończyć połączenie (nie będzie już potrzebne). Przeanalizować nagłówek. Odczytać wszystkie odpowiedzi, w tym: – Skonwertować (rozkompresować) nazwy domen do postaci tekstowej „z kropkami”. – Zdekodować rekordy w odpowiedziach zgodnie z ich typem. Kroki te zostały zaimplementowane w przykładowym kodzie w języku Python przedstawionym dalej. Ze względu na to, że jest on relatywnie długi, to zanim do niego przejdziemy, chciałbym wskazać kilka funkcji i wyjaśnić ich cel (w kolejności: od funkcji wysokopoziomowych do niskopoziomowych): dns_query – główna funkcja sterująca opisanym powyżej procesem; znajduje się w niej m.in. tworzenie połączenia, a także wysokopoziomowe wysyłanie i odbieranie danych; dns_query_make_packet – funkcja tworząca binarny pakiet z zapytaniem dla zadanych parametrów; funkcja zwraca gotowy do wysłania nagłówek oraz strukturę question; dns_response_parse_packet – funkcja sterująca przetwarzaniem odpowiedzi;
zwraca
słownik
(dict)
z
gotowymi
do
użycia
odpowiedziami; dns_response_parse_header – funkcja przetwarzająca nagłówek odpowiedzi; dns_class_to_str – prosta funkcja konwertująca liczbową wartość pola CLASS na reprezentację tekstową (np. 1 na "IN"); dns_type_to_str – jw. dla pola TYPE; dns_ttl_to_str
–
prosta
funkcja
zamieniająca
liczbę
sekund
w tekstowy opis interwału czytelny dla człowieka (np. wartość 300 zostanie skonwertowana na „5 minutes”); dns_data_to_str – bardziej skomplikowana funkcja konwertująca rekordy obsługiwanych typów do ich tekstowej reprezentacji lub do reprezentacji heksadecymalnej oraz tekstowej w przypadku nieznanego rodzaju rekordu;
dns_decode_domain – prosta funkcja dekodująca i dekompresująca nazwę domeny; dns_tcp_send_packet – funkcja wysyłająca długość pakietu oraz sam pakiet; dns_tcp_recv_packet – funkcja odbierająca długość pakietu oraz sam pakiet; recv_all – funkcja odbierająca dokładnie określoną liczbę bajtów. Należy dodać, że poniżej zaprezentowana implementacja jest bardzo uproszczona i zawiera obsługę jedynie najprostszego wariantu DNS – m.in. całkowicie ignoruje informacje dodatkowe oraz dane na temat serwerów autorytatywnych w odpowiedzi. Sam kod prezentuje się następująco: #!/usr/bin/python # -*- coding: utf-8 -*import os import socket import sys from datetime import timedelta from struct import pack, unpack def dns_query(query, domain, dnserver): # Stwórz gniazdo korzystające z TCP (AF_INET, SOCK_STREAM). s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # Połącz się ze wskazanym serwerem na porcie 53. try: s.connect((dnserver, 53)) # DNS działa na porcie 53. except socket.error as e: sys.stderr.write("error: failed to connect to server (%s)\n" % e.strerror) return None # Wyślij zapytanie. dns_query_packet = dns_query_make_packet(query, domain)
dns_tcp_send_packet(s, dns_query_packet) # Odbierz odpowiedź. dns_response_packet = dns_tcp_recv_packet(s) dns_reply = dns_response_parse_packet(dns_response_packet) # Zamknij połączenie i gniazdo. s.shutdown(socket.SHUT_RDWR) s.close() return dns_reply def dns_query_make_packet(query_type, domain): # Stworzenie nagłówka. query_id = 1234 # W przypadku pojedynczej aplikacji i TCP pole to # może mieć dowolną wartość (jest w zasadzie # nieużywane). # W podstawowym użyciu większość pól z 16-bitowego słowa sterującego # może być wyzerowana. cw_rcode = 0 cw_z = 0 cw_ra = 0 cw_rd = 1 # Flaga „rozwiąż zapytanie rekursywnie”. cw_tc = 0 cw_aa = 0 cw_opcode = 0 # Standardowe zapytanie (QUERY). cw_qr = 0 # Flaga „zapytanie”. # Złożenie pojedynczych pól do 16-bitowego słowa. control_word = ( cw_rcode | (cw_z nslookup -type=MX coldwind.pl Server: google-public-dns-a.google.com
Address:
8.8.8.8
Non-authoritative answer: coldwind.pl MX preference = 0, mail exchanger = gynvael.coldwind.pl Jak można zaobserwować, w tym przypadku wszystko się zgadza. Zanim przejdziemy do tematu gniazd nasłuchujących, chciałbym jeszcze zwrócić uwagę na procedurę poprawnego zamknięcia połączenia. O ile w przypadku komunikacji międzyprocesowej oraz bezpołączeniowej wystarczające jest zamknięcie deskryptora gniazda lub zniszczenie innego obiektu reprezentującego połączenie, o tyle w przypadku TCP przed zamknięciem deskryptora gniazda należy zamknąć samo połączenie, do czego służy funkcja shutdown. Zostało to uwzględnione w przykładowym kodzie w następującym fragmencie: # Zamknij połączenie i gniazdo. s.shutdown(socket.SHUT_RDWR) s.close() Warto wskazać, że połączenie można zamknąć również tylko w jednym kierunku (np. opcja socket.SHUT_WR w języku Python), co pozwala na odebranie pozostałych danych, ale też informuje system oraz drugą stronę, że aplikacja nie będzie już żadnych danych wysyłała.
15.3. Nasłuchujące gniazda TCP oraz HTTP Jak wspomniałem już kilkukrotnie w tym rozdziale, gniazda TCP występują w dwóch formach: nawiązującej połączenia oraz odbierającej połączenia przychodzące. W tym drugim wypadku zamiast funkcji connect, która nawiązuje połączenie z adresatem, używa się funkcji: bind – do powiązania gniazda z lokalnym portem oraz interfejsem sieciowym (lub wszystkimi interfejsami); listen – do przekształcenia gniazda w gniazdo nasłuchujące;
accept – do odbierania kolejnych przychodzących połączeń, z których każde powoduje utworzenie nowego gniazda. Na poziomie konstrukcji aplikacji serwerowych (czyli takich, które z definicji obsługują więcej niż jedno połączenie) obsługa wielu połączeń może odbyć się na jeden z trzech sposobów: Wielowątkowość – do obsługi każdego kolejnego połączenia zostaje utworzony nowy, dedykowany wątek (ewentualnie może zostać użyty istniejący wątek z puli). Korzystając z tego podejścia, które idealnie sprawdza się w protokołach typu żądanie-odpowiedź (requestresponse), wątek może oczekiwać na dane bez blokowania głównego wątku. Tryb asynchroniczny – wszystkie wykorzystywane gniazda są ustawiane w tryb nieblokujący (łącznie z gniazdem nasłuchującym), a następnie aktywnie odpytywane przez główny wątek o dostępność nowych danych lub połączeń – tego typu podejście zaprezentowałem już w poprzednim rozdziale przy okazji omawiania gniazd domeny UNIX. Oczekiwanie na wiele deskryptorów – przy wykorzystaniu funkcji poll lub select, w sposób identyczny z zaprezentowanym przy okazji omawiania potoków w poprzednim rozdziale. Niezależnie od wybranej architektury po nawiązaniu połączenia komunikacja poprzez nowo otrzymane gniazdo odbywa się w sposób identyczny jak w przypadku połączeń wychodzących. W ramach przykładu wykorzystania gniazd nasłuchujących przedstawię prosty czat webowy oparty na zintegrowanym, wielowątkowym serwerze HTTP stworzonym w języku Python, przy wykorzystaniu niskopoziomowych gniazd[285]. Z punktu widzenia architektury przykładowa aplikacja składa się z trzech części (w kolejności: od części niskopoziomowych w górę warstw abstrakcji): Wielowątkowego serwera HTTP. Serwerowej części czatu, opartej na modelu żądanie-odpowiedź. Interfejsu użytkownika realizowanego po stronie przeglądarki internetowej.
HTTP (HyperText Transfer Protocol) jest tekstowym protokołem typu żądanieodpowiedź rezydującym w warstwie siódmej modelu OSI, używanym przez WWW (World Wide Web), czyli serwisy i strony internetowe. Protokół ten pierwotnie był bardzo prosty, szczególnie w pierwszej udokumentowanej wersji z roku 1991 (HTTP/0.9 [15]), i sprowadzał się do: Jednolinijkowego żądania przesłania zasobu w postaci: GET identyfikator-zasobu Beznagłówkowej odpowiedzi z treścią zasobu. Przez lata protokół był sukcesywnie rozwijany, zarówno w ramach podstawowej specyfikacji, jak i wielu rozszerzeń protokołu. Najpopularniejszą obecnie wersją jest HTTP/1.1 (RFC 2616 [16]), choć niewiele serwerów i aplikacji klienckich w pełni obsługuje pełną dostępną specyfikację wraz z rozszerzeniami (jest to na szczęście niekonieczne)[286]. W wersji 1.1 protokół nadal korzysta z modelu żądanie-odpowiedź (choć opcjonalnie za pośrednictwem jednego połączenia można wysłać wiele żądań, co nie było możliwe w wersji 0.9 czy 1.0), natomiast wprowadzono kilka znacznych zmian co do formatu obu pakietów. Zaczynając od żądania, ma ono obecnie następującą formę: METODA identyfikator-zasobu HTTP/wersja Nagłówek-1: dane nagłówka Nagłówek-2: dane nagłówka ... Nagłówek-3: dane nagłówka Opcjonalne, dodatkowe dane przesyłane do serwera (np. dane formularzy, zawartość przesyłanego pliku lub inne informacje) Wszystkie zaprezentowane linie muszą być oddzielone znakiem końca linii w stylu DOS/Windows, czyli dwubajtową sekwencją „\r\n” (0D 0A). Należy również zwrócić uwagę, że po nagłówkach musi znaleźć się jedna pusta linia, która oznacza koniec nagłówków (lub koniec żądania w przypadku większości metod). Jeśli chodzi o same metody, nazywane również „czasownikami”, to najczęściej używa się jednej z dwóch poniższych (lista jest niewyczerpująca[287]):
GET – jak w przypadku HTTP/0.9, standardowe żądanie przesłania zasobu. POST – przesłanie dodatkowych danych do serwera, które są załączone po nagłówkach; również (podobnie jak w przypadku GET) żądanie przesłania wskazanego zasobu. Wymagane jest, by serwery HTTP obsługiwały obie wspomniane metody. Idąc dalej, identyfikator zasobu teoretycznie może mieć dowolną postać, ale nie może zawierać spacji. W praktyce stosuje się jednak format URL (Uniform Resource Locator) [18] i dwa jego fragmenty: ścieżkę (path) oraz tzw. ciąg zapytania (query string). Ostatecznie identyfikator przyjmuje następującą postać (znaną m.in. z paska adresu przeglądarki): /ścieżka/do/zasobu?parametr=wartość&kolejnyparametr=wartość W przypadku powyższej formy należy wskazać kilka aspektów: Zarówno ścieżka (/ścieżka/do/zasobu), jak i ciąg zapytania (? parametr=wartość&kolejnyparametr&wartość) są opcjonalne i nie muszą wystąpić. Niemniej jednak żądany identyfikator nie może być pusty, więc jako najkrótszy poprawny identyfikator stosuje się / (choć technicznie również można by użyć znaku ?). Ścieżka do zasobu ma charakter hierarchiczny, ale w praktyce jest to czysto umowne i ma znaczenie jedynie w przypadku, gdy serwer WWW bezpośrednio mapuje identyfikator na ścieżki do plików na dysku[288]. Ciąg zapytania nie jest uznawany za identyfikator zasobu – zawiera on dodatkowe parametry, które procedura obsługująca zasób powinna otrzymać. Dodatkowe parametry mogą być również przekazane za pomocą metody POST po nagłówkach HTTP. Spacja, znak %, +, a także znaki / oraz ? w przypadku ścieżki i znaki =, & w przypadku ciągu zapytania są znakami specjalnymi, a więc nie mogą zostać użyte w członach sekcji, nazwach parametrów ani ich wartościach. To samo dotyczy znaków niedrukowalnych, kontrolnych oraz rozszerzonych (względem kodowania ASCII). Aby przekazać te znaki, stosuje się tzw. kodowanie URL (URL encoding), które działa w następujący sposób: – Wszystkie znaki dozwolone nie są kodowane.
– Wszystkie pozostałe znaki (bajty) są kodowane jako zapisany heksadecymalnie kod znaku poprzedzony znakiem procentu, np. znak & zostanie zapisany jako %26. – Alternatywnym, skróconym kodowaniem spacji jest znak +. – Niektóre serwery obsługują również niestandardowe kodowanie znaków UNICODE w postaci 4-cyfrowego zapisanego heksadecymalnie kodu znaku poprzedzonego sekwencją %u. Przechodząc do nagłówków, standard HTTP/1.1 definiuje około pięćdziesiąt różnych nagłówków, do których dochodzą kolejne zdefiniowane w ramach rozszerzeń. Chociaż wiele z nich jest istotnych, to ostatecznie żądanie wymaga tylko jednego – Host – wskazującego domenę, której dotyczy żądanie[289]. Opis pozostałych z nich wykracza poza zakres książki; w kontekście przykładowej aplikacji żadne inne nagłówki nie będą istotne. Podsumowując, przykładowy, poprawny następująco:
nagłówek
HTTP
wygląda
GET /ext/466c6167.php HTTP/1.1 Host: gynvael.coldwind.pl Nagłówek ten można przetestować, korzystając np. ze wspomnianego wcześniej narzędzia netcat: > nc -v gynvael.coldwind.pl 80 ... gynvael.coldwind.pl [31.133.0.26] 80 (http) open GET /ext/466c6167.php HTTP/1.1 Host: gynvael.coldwind.pl HTTP/1.1 200 OK Date: Sun, 30 Aug 2015 13:32:47 GMT Server: Apache/2.4.7 (Ubuntu) Last-Modified: Sun, 30 Aug 2015 13:31:47 GMT ETag: "1c-51e8756b03199" Accept-Ranges: bytes Content-Length: 28 Content-Type: text/html
Hell-o HTTP World! ... Ogólny format odpowiedzi HTTP jest zbliżony do zapytania i ma następującą formę (którą można zobaczyć również na powyższym listingu): HTTP/1.1 kodbłędu Kod Błędu W Formie Tekstowej Nagłówek1: dane nagłówka Nagłówek2: dane nagłówka ... Nagłówek3: dane nagłówka Opcjonalnie treść zasobu (nie występuje w przypadku metod HEAD i OPTIONS). Pełen spis kodów błędów można znaleźć w oficjalnej specyfikacji formatu. Przykładowa aplikacja wykorzysta trzy z nich: 200 OK – wszystko się powiodło, brak błędu; 400 Bad Request – zapytanie było nieprawidłowe; 404 Not Found – nie znaleziono wskazanego zasobu. Większość nagłówków odpowiedzi jest opcjonalna – w przypadku naszej aplikacji użyjemy trzech z nich (w tym dwóch zalecanych): Content-Length – długość przesyłanych danych zasobu w bajtach. Content-Type – rodzaj zasobu (np. HTML, JavaScript, JPEG itp.) zapisany w formie tzw. typu MIME (Multipurpose Internet Mail Extensions type, współcześnie nazywanego również Internet media type); dla typów tekstowych zalecane jest podanie po średniku również kodowania, np. „;charset=utf-8”. W przykładowej aplikacji wykorzystamy następujące typy MIME (pełen wykaz zarejestrowanych typów można znaleźć na stronie IANA [19]): – text/html – strona WWW w formacie HTML; – text/css – arkusz styli CSS; – application/javascript – skrypt w języku JavaScript;
– application/json – zserializowane dane w postaci JSON. Server – nazwa oprogramowania lub typ serwera HTTP. Podobnie jak w przypadku żądania, w odpowiedzi dane są oddzielone od nagłówków pojedynczą pustą linią. Poprawne zrozumienie formatu odpowiedzi można również przetestować, korzystając z narzędzia netcat. W tym celu należy zacząć od stworzenia pliku zawierającego odpowiedź, upewnić się, że znaki końca linii są kodowane w konwencji DOS/Windows, a następnie stworzyć za pomocą netcat gniazdo nasłuchujące i przekierować plik na strumień wejścia programu (GNU/Linux): $ cat > re.http HTTP/1.1 200 OK Content-Type: text/html;charset=utf-8 It seems to work! Hell-o HTTP browser! (naciśnięcie ctrl+d) $ nc -v -l -p 8888 < re.http Listening on [0.0.0.0] (family 0, port 8888) Connection from [127.0.0.1] port 8888 [tcp/*] accepted (family 2, sport 41930) GET / HTTP/1.1 Host: 127.0.0.1:8888 User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:40.0) Gecko/20100101 Firefox/40.0 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 Accept-Language: en-US,en;q=0.5 Accept-Encoding: gzip, deflate Connection: keep-alive Zrzut ekranu z przeglądarki internetowej, która została użyta po drugiej stronie komunikacji, znajduje się na rysunku 9.
Rysunek 9. Druga strona komunikacji – przeglądarka internetowa Mozilla Firefox Zachęcam czytelników do zapoznania się z tematyką protokołu HTTP w szerszym zakresie, natomiast informacje, które przedstawiłem do tej pory, powinny wystarczyć do stworzenia wspomnianej aplikacji webowej, w tym serwera HTTP. Konstrukcja samego serwera, którego implementacja w przykładzie jest rozbita pomiędzy klasę ClientThread a funkcję main, wygląda następująco: Główna funkcja (main) tworzy gniazdo nasłuchujące na porcie 8888, po czym w pętli odbiera połączenia.
Po odebraniu połączenia tworzony jest nowy wątek (ClientThread), który zostaje właścicielem nowego gniazda z połączeniem. Nowy wątek odbiera dane aż do wystąpienia sekwencji "\r\n\r\n" (czyli końca nagłówków), a następnie parsuje odebrane dane. Jeśli zadeklarowaną przez klienta w żądaniu metodą jest POST, wątek doczytuje dane w ilości wskazanej w nagłówku Content-Length. Dysponując
przetworzonym żądaniem, wątek przekazuje je do właściwej aplikacji, co w tym przypadku jest realizowane przez wywołanie metody handle_http_request w głównym obiekcie klasy SimpleChatWWW. Z punktu widzenia serwera HTTP celem wywołanej metody jest zwrócenie obiektu (w tym wypadku słownika) z kodem błędu, nagłówkami oraz danymi do przekazania klientowi. Na podstawie otrzymanych danych generowana jest odpowiedź HTTP, która jest następnie odsyłana do klienta, a połączenie zamykane. Cała logika związana z działaniem aplikacji czatu jest zawarta w klasie SimpleChatWWW, której obiekt jest tworzony przez główny wątek na początku wykonania i używany przez cały czas trwania programu. W pewnym uproszczeniu klasa ta jest nieco skomplikowanym kontenerem na wiadomości (w formie IP + tekst), który spełnia trzy funkcje: Dodaje nowe wiadomości do listy. Zwraca nowe wiadomości z listy (tj. wszystkie wiadomości począwszy od wskazanej). Przekazuje do wysłania statyczne pliki aplikacji (HTML, JavaScript, CSS). Wymienione funkcje w praktyce sprowadzają się do obsługi sześciu różnych metodzie
żądań HTTP (przekazanych wspomnianej wcześniej handle_http_request), które realizują następujące czynności:
GET /index.html – przekazanie do przesłania zawartości statycznego pliku httpchat_index.html (warto zwrócić uwagę, że nasz serwer nie mapuje bezpośrednio adresów URL na pliki; zamiast tego selektywnie wysyła wybrane pliki w odpowiedzi na konkretne żądania).
GET / – jw.; przyjęło się, że zasób / jest alternatywną nazwą dla /index.html, /index.htm lub podobnych. GET
/style.css – przekazanie do przesłania statycznego pliku
httpchat_style.css. GET /main.js –
przekazanie do
przesłania
statycznego
pliku
httpchat_main.js. POST /chat – dodanie przesłanej wiadomości do listy wiadomości. Wiadomość powinna być zserializowana do formatu JSON i składać się ze słownika z jednym wpisem – "text" – którego wartością powinien być ciąg tekstowy z wiadomością. POST /messages – przekazanie do przesłania wszystkich wiadomości nowszych od wskazanej. Klient musi przesłać – w formacie JSON – słownik z kluczem "last_message_id", który zawiera identyfikator (numer) najnowszej znanej mu wiadomości. Na poziomie implementacji obsługa wymienionych żądań znajduje się w metodach z prefiksem __handle, np. __handle_POST_messages. Co więcej, obsługa przygotowania do przesłania statycznego pliku znajduje się w funkcji __send_file, która dodatkowo zajmuje się pamięcią podręczną, w ramach której przechowywana jest zawartość uprzednio wczytanych plików (jednocześnie pliki są przeładowywane, jeśli ich zawartość, a konkretniej data ostatniej modyfikacji, ulegnie zmianie). Cała część serwerowa (server-side) aplikacji znajduje się w pliku httpchat.py zaprezentowanym poniżej i na kolejnych stronach: httpchat.py: #!/usr/bin/python # -*- coding: utf-8 -*# Uwaga: ponieważ w Python 3 metoda socket.recv zwraca typ bytes, # a nie str, a większość kodu wyższego poziomu operuje na stringach # (które nie są w pełni kompatybilne z klasą bytes, w szczególności # jeśli chodzi o porównania lub użycie jako klucz w słowniku),
# w kilku miejscach program sprawdza, z którą wersją interpretera ma # do czynienia, i wybiera odpowiednią wersję kodu do wykonania. import import import import
json os socket sys
from threading import Event, Lock, Thread DEBUG = False # Zmiana na True powoduje wyświetlenie dodatkowych komunikatów. # Implementacja logiki strony WWW. class SimpleChatWWW(): def __init__(self, the_end): self.the_end = the_end self.files = "." # Na potrzeby przykładu pliki mogą być w katalogu # roboczym. self.file_cache = {} self.file_cache_lock = Lock() self.messages = [] self.messages_offset = 0 self.messages_lock = Lock() self.messages_limit = 1000 przechowywanych wiadomości.
# Maksymalna liczba
# Mapowanie adresów WWW na metody obsługujące. self.handlers = { ('GET', '/'): self.__handle_GET_index, ('GET', '/index.html'): self.__handle_GET_index, ('GET', '/style.css'): self.__handle_GET_style, ('GET', '/main.js'): self.__handle_GET_javascript,
('POST', '/chat'):
self.__handle_POST_chat,
('POST', '/messages'): }
self.__handle_POST_messages,
def handle_http_request(self, req): req_query = (req['method'], req['query']) if req_query not in self.handlers: return { 'status': (404, 'Not Found') } return self.handlers[req_query](req) def __handle_GET_index(self, req): return self.__send_file('httpchat_index.html') def __handle_GET_style(self, req): return self.__send_file('httpchat_style.css') def __handle_GET_javascript(self, req): return self.__send_file('httpchat_main.js') def __handle_POST_chat(self, req): # Odczytaj potrzebne pola z otrzymanego obiektu JSON. Bezpiecznie # jest nie czynić żadnych założeń co do zawartości i typu # przesyłanych danych. try: obj = json.loads(req['data']) except ValueError: return { 'status': (400, 'Bad Request') } if type(obj) is not dict or 'text' not in obj: return { 'status': (400, 'Bad Request') } text = obj['text'] if type(text) is not str and type(text) is not unicode: return { 'status': (400, 'Bad Request') }
sender_ip = req['client_ip'] # Dodaj wiadomość do listy. Jeśli lista jest dłuższa niż limit, # usuń jedną wiadomość z przodu i zwiększ offset. with self.messages_lock: if len(self.messages) > self.messages_limit: self.messages.pop(0) self.messages_offset += 1 self.messages.append((sender_ip, text)) sys.stdout.write("[
INFO ] %s\n" % (sender_ip, text))
return { 'status': (200, 'OK') } def __handle_POST_messages(self, req): # Odczytaj potrzebne pola z otrzymanego obiektu JSON. Bezpiecznie # jest nie czynić żadnych założeń co do zawartości i typu # przesyłanych danych. try: obj = json.loads(req['data']) except ValueError: return { 'status': (400, 'Bad Request') } if type(obj) is not dict or 'last_message_id' not in obj: return { 'status': (400, 'Bad Request') } last_message_id = obj['last_message_id'] if type(last_message_id) is not int: return { 'status': (400, 'Bad Request') } # Skopiuj wszystkie wiadomości, poczynając od last_message_id.
with self.messages_lock: last_message_id -= self.messages_offset if last_message_id < 0: last_message_id = 0 messages = self.messages[last_message_id:] new_last_message_id = self.messages_offset + len(self.messages) # Wygeneruj odpowiedź. data = json.dumps({ "last_message_id": new_last_message_id, "messages": messages }) return { 'status': (200, 'OK'), 'headers': [ ('Content-Type', 'application/json;charset=utf-8'), ], 'data': data } # Stworzenie odpowiedzi zawierającej zawartość pliku obecnego na # dysku. W praktyce poniższa metoda dodatkowo stara się przechowywać # pliki w pamięci podręcznej i odczytywać je, tylko jeśli nie zostały # załadowane już wcześniej lub jeśli plik zmienił się w międzyczasie. def __send_file(self, fname): # Ustal typ pliku na podstawie jego rozszerzenia. ext = os.path.splitext(fname)[1] mime_type = { '.html': 'text/html;charset=utf-8',
'.js': 'application/javascript;charset=utf-8', '.css': 'text/css;charset=utf-8', }.get(ext.lower(), 'application/octet-stream') # Sprawdź, kiedy plik został ostatnio zmodyfikowany. try: mtime = os.stat(fname).st_mtime except: # Niestety, CPython na Windows rzuca klasę wyjątków, która nie # jest zadeklarowana pod GNU/Linux. Najprościej jest złapać # wszystkie wyjątki, choć jest to zdecydowanie nieeleganckie # rozwiązanie. # Plik prawdopodobnie nie istnieje lub nie ma do niego dostępu. return { 'status': (404, 'Not Found') } # Sprawdź, czy plik znajduje się w pamięci podręcznej. with self.file_cache_lock: if fname in self.file_cache and self.file_cache[fname][0] == mtime: return { 'status': (200, 'OK'), 'headers': [ ('Content-Type', mime_type), ], 'data': self.file_cache[fname][1] } # W ostateczności wczytaj plik. try: with open(fname, 'rb') as f: data = f.read()
mtime = os.fstat(f.fileno()).st_mtime
# Uaktualnij
mtime. except IOError as e: # Nie udało się odczytać pliku. if DEBUG: sys.stdout.write("[WARNING] File %s not found, but requested.\n" % fname) return { 'status': (404, 'Not Found') } # Dodaj zawartość pliku do pamięci podręcznej (chyba że inny # wątek zrobił to w międzyczasie). with self.file_cache_lock: if fname not in self.file_cache or self.file_cache[fname] [0] < mtime: self.file_cache[fname] = (mtime, data) # Odeślij odpowiedź z zawartością pliku. return { 'status': (200, 'OK'), 'headers': [ ('Content-Type', mime_type), ], 'data': data } # Bardzo prosta implementacja wielowątkowego serwera HTTP. class ClientThread(Thread): def __init__(self, website, sock, sock_addr): super(ClientThread, self).__init__() self.s = sock self.s_addr = sock_addr self.website = website def __recv_http_request(self): # Bardzo uproszczone przetwarzanie zapytania HTTP, którego
# głównym celem jest wydobycie: # - metody # - żądanej ścieżki # - kolejnych parametrów w formie słownika # - dodatkowych danych (w przypadku POST) # Odbierz dane aż do zakończenia nagłówka. data = recv_until(self.s, '\r\n\r\n') if not data: return None # Podziel zapytanie na linie. lines = data.split('\r\n') # Przeanalizuj zapytanie (pierwsza linia). query_tokens = lines.pop(0).split(' ') if len(query_tokens) != 3: return None method, query, version = query_tokens # Wczytaj parametry. headers = {} for line in lines: tokens = line.split(':', 1) if len(tokens) != 2: continue # Wielkość liter nagłówka nie ma znaczenia, więc warto ją # znormalizować, np. zamieniając wszystkie litery na małe. header_name = tokens[0].strip().lower() header_value = tokens[1].strip() headers[header_name] = header_value # W przypadku metody POST pobierz dodatkowe dane.
# Uwaga: przykładowa implementacja w żaden sposób nie ogranicza # ilości przesyłanych danych. if method == 'POST': try: data_length = int(headers['content-length']) data = recv_all(self.s, data_length) except KeyError as e: # Brak wpisu Content-Length w nagłówkach. data = recv_remaining(self.s) except ValueError as e: return None else: data = None # Umieść wszystkie istotne dane w słowniku i zwróć go. request = { "method": method, "query": query, "headers": headers, "data": data, "client_ip": self.s_addr[0], "client_port": self.s_addr[1] } return request def __send_http_response(self, response): # Skonstruuj odpowiedź HTTP. lines = [] lines.append('HTTP/1.1 %u %s' % response['status']) # Ustaw podstawowe pola. lines.append('Server: example') if 'data' in response:
lines.append('Content-Length: %u' % len(response['data'])) else: lines.append('Content-Length: 0') # Przepisz nagłówki. if 'headers' in response: for header in response['headers']: lines.append('%s: %s' % header) lines.append('') # Przepisz dane. if 'data' in response: lines.append(response['data']) # Skonwertuj odpowiedź na bajty i wyślij. if sys.version_info.major == 3: converted_lines = [] for line in lines: if type(line) is bytes: converted_lines.append(line) else: converted_lines.append(bytes(line, 'utf-8')) lines = converted_lines self.s.sendall(b'\r\n'.join(lines)) def __handle_client(self): request = self.__recv_http_request() if not request: if DEBUG: sys.stdout.write("[WARNING] Client %s:%i doesn't make any sense. " "Disconnecting.\n" % self.s_addr) return
if DEBUG: sys.stdout.write("[
INFO ] Client %s:%i requested %s\n" %
( self.s_addr[0], self.s_addr[1], request['query'])) response = self.website.handle_http_request(request) self.__send_http_response(response) def run(self): self.s.settimeout(5) niż 5 sekund. try:
# Operacje nie powinny zajmować dłużej
self.__handle_client() except socket.timeout as e: if DEBUG: sys.stdout.write("[WARNING] Client %s:%i timed out. " "Disconnecting.\n" % self.s_addr) self.s.shutdown(socket.SHUT_RDWR) self.s.close() # Niezbyt szybka, ale wygodna funkcja odbierająca dane aż do napotkania # konkretnego ciągu znaków (który również jest zwracany). def recv_until(sock, txt): txt = list(txt) if sys.version_info.major == 3: txt = [bytes(ch, 'ascii') for ch in txt] full_data = [] last_n_bytes = [None] * len(txt) # Dopóki ostatnie N bajtów nie będzie równe poszukiwanym, odczytuj dane. while last_n_bytes != txt: next_byte = sock.recv(1) if not next_byte:
return ''
# Połączenie zostało zerwane.
full_data.append(next_byte) last_n_bytes.pop(0) last_n_bytes.append(next_byte) full_data = b''.join(full_data) if sys.version_info.major == 3: return str(full_data, 'utf-8') return full_data # Pomocnicza funkcja odbierająca dokładnie określoną liczbę bajtów. def recv_all(sock, n): data = [] while len(data) < n: data_latest = sock.recv(n - len(data)) if not data_latest: return None data.append(data_latest) data = b''.join(data) if sys.version_info.major == 3: return str(data, 'utf-8') return data # Pomocnicza funkcja odbierająca dane z gniazda aż do rozłączenia. def recv_remaining(sock): data = [] while True: data_latest = sock.recv(4096) if not data_latest: data = b''.join(data) if sys.version_info.major == 3:
return str(data, 'utf-8') return data data.append(data_latest) def main(): the_end = Event() website = SimpleChatWWW(the_end) # Stwórz gniazdo. s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # W przypadku GNU/Linux należy wskazać, iż ten sam adres lokalny # powinien być możliwy do wykorzystania od razu po zamknięciu # gniazda. W innym wypadku adres będzie w stanie „kwarantanny” # (TIME_WAIT) przez 60 sekund i w tym czasie ponowna próba # powiązania z nim gniazda zakończy się błędem. s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # Nasłuchuj na porcie 8888 na wszystkich interfejsach. s.bind(('0.0.0.0', 8888)) s.listen(32) # Liczba w parametrze mówi o maksymalnej długości kolejki # oczekujących połączeń. W tym wypadku połączenia będą # odbierane na bieżąco, więc kolejka może być niewielka. # Typowe wartości to 128 (GNU/Linux) lub „kilkaset” # (Windows). # Ustaw timeout na gnieździe, tak by blokujące operacje wykonywane # na nim były przerywane co sekundę. Dzięki temu kod będzie mógł
# sprawdzić, czy serwer został wezwany do zakończenia pracy. s.settimeout(1) while not the_end.is_set(): # Odbierz połączenie. try: c, c_addr = s.accept() c.setblocking(1)
# W niektórych implementacjach języka
Python # gniazdo zwrócone przez metodę accept na # gnieździe nasłuchującym z ustawionym limitem # czasu jest domyślnie asychroniczne, co jest # niepożądanym zachowaniem. if DEBUG: sys.stdout.write("[ INFO ] New connection: %s:%i\n" % c_addr) except socket.timeout as e: continue # Wróć na początek pętli i sprawdź warunek końca. # Nowe połączenie. Stwórz nowy wątek do jego obsługi (alternatywnie # można by w tym miejscu skorzystać z puli wątków – threadpool). ct = ClientThread(website, c, c_addr) ct.start() if __name__ == "__main__": main() W tym momencie można już testować zaprezentowany serwer, korzystając np. z niezastąpionego konsolowego narzędzia cURL, jak pokazano poniżej:
Terminal 1 (serwer): $ python httpchat.py [ INFO ] asdf [ INFO ] Hello World! Terminal 2 (curl): $ curl -v -d '{"text":"Hello World!"}' http://192.168.2.199:8888/chat ... * upload completely sent off: 23 out of 23 bytes < * < < *
HTTP/1.1 200 OK Server example is not blacklisted Server: example Content-Length: 0 Connection #0 to host 192.168.2.199 left intact
> curl -v -d '{"last_message_id":-1}' http://192.168.2.199:8888/messages ... * < *
netsh advfirewall firewall show rule name=all verbose | grep -i -B 15 -A 5 "d:\\tmp\\python.exe" Rule Name: python.exe ----------------------------------------------------------------Description: python.exe Enabled: Yes Direction: In ... LocalIP: Any RemoteIP: Protocol:
Any UDP
LocalPort:
Any
RemotePort: ...
Any
Program: InterfaceTypes: ... Action:
D:\tmp\python.exe Any Block
... W kolejnym kroku można albo zmienić dane reguły, albo usunąć je całkowicie i dodać nowe (lub ponownie uruchomić aplikację, tak by wyświetlił się monit). W ramach przykładu przyjmę założenie, że preferencją jest usunięcie istniejących reguł i stworzenie nowych, ściśle dopasowanych do przykładowego czatu: > netsh advfirewall firewall delete rule name=all program=d:\tmp\ python.exe Deleted 2 rule(s). Ok. > netsh advfirewall firewall add rule name=Python8888 dir=in action=allow program=d:\tmp\python.exe "description=Python / WebChat test" enable=yes profile=any localport=8888 remoteport=any protocol=tcp security=notrequired Ok. O ile w środowisku produkcyjnym ściśle dopasowana konfiguracja dla interpretera jest pożądana, o tyle w przypadku komputerów używanych do programowania zazwyczaj luźniejsze reguły sprawdzają się lepiej, ponieważ nie wymagają od programisty ciągłego dodawania nowych reguł dla każdego, nawet testowego skryptu. Można więc pomyśleć o dodaniu reguł zezwalających na dowolne wykorzystanie portów przez dany interpreter: > netsh advfirewall firewall add rule "name=Python All Allowed" dir=in action=allow program=d:\tmp\python.exe "description=Python Dev Env" enable=yes profile=any security=notrequired Ok.
> netsh advfirewall firewall add rule "name=Python All Allowed" dir=out action=allow program=d:\tmp\python.exe "description=Python Dev Env" enable=yes profile=any security=notrequired Ok. Mimo że opis zasad zarządzania zaporami sieciowymi dalece wykracza poza zakres tej książki, chciałbym zachęcić czytelników do zapoznania się z nimi, gdyż może to pozwolić w przyszłości na zaoszczędzenie czasu spędzonego na niepotrzebnym debugowaniu w pełni sprawnej aplikacji zablokowanej przez zaporę.
15.4. Gniazda UDP i peer-to-peer Ostatnim z wymienionych mechanizmów komunikacji sieciowej są gniazda wykorzystujące protokół UDP (AF_INET, SOCK_DGRAM). O samym protokole UDP, problemach z nim związanych, ale również jego zaletach pisałem już we wstępie do tego rozdziału. Wspomniałem m.in., że jest to protokół, który nie wymaga nawiązywania połączenia, a dane można przesyłać od razu, dysponując jedynie adresem docelowym oraz numerem portu, na którym druga strona spodziewa się datagramów (pakietów). Co za tym idzie obie strony komunikacji są równorzędne. Cykl życia gniazd UDP oraz funkcje używane do operowania na nich[294] przedstawiają się następująco:
Dowolna strona komunikacji
Komentarz
s = socket(AF_INET, SOCK_DGRAM)
S tworzenie g niazda.
bind(s, "0.0.0.0", 1234)
Powiązanie g niazda z lokalnym portem UDP oraz interfejs em.
sendto(s, ...) recvfrom(s, ...)
Wys łanie datag ramu na ws kazany adres lub odebranie pakietu wraz z adres em źródłowym.
close(s)
Zamknięcie g niazda.
Poniżej znajduje się przykład użycia zaprezentowanych powyżej funkcji i protokołu UDP, który równocześnie demonstruje bardzo prostą sieć typu peer-topeer, w której poszczególne węzły sieci przekazują otrzymane wiadomości do swoich sąsiadów. Dzięki takiej strukturze porozumiewać się mogą również węzły, które nie są ze sobą bezpośrednio połączone. Dokładną analizę działania sieci P2P pozostawię czytelnikowi – wszystkie mechanizmy w nim użyte powinny być na tym etapie znane. Dodam, że w celu uproszczenia poniższy kod nie zawiera pełnego sprawdzania błędów – w szczególności została pominięta weryfikacja poprawności w otrzymywanych, zserializowanych danych (walidacja typów zaprezentowana m.in. w poprzednim przykładzie).
typów została
Sam kod w języku Python wygląda następująco: #!/usr/bin/python # -*- coding: utf-8 -*import hashlib import json import os import socket import sys import time from struct import pack, unpack from threading import Event, Lock, Thread # Domyślny port - można go zmienić, podając inny w argumencie skryptu. CHAT_PORT = 59999
PY3 = False if sys.version_info.major == 3: PY3 = True # Wątek odbierający wiadomości. class Receiver(Thread): def __init__(self, s, the_end, p2pchat): super(Receiver, self).__init__() self.s = s self.the_end = the_end self.p2pchat = p2pchat def run(self): while not self.the_end.is_set(): try: # Odbierz pakiet o maksymalnej możliwej wielkość pakietu UDP/IPv4. packet, addr = self.s.recvfrom(0xffff) if PY3: packet = str(packet, 'utf-8') packet = json.loads(packet) t = packet["type"] except socket.timeout as e: continue except ValueError as e: # Przypadek, gdy dane nie są prawidłowo sformatowanym JSONem. continue except KeyError as e: # Przypadek, gdy packet nie ma zdefiniowanego klucza "type". continue except TypeError as e: # Przypadek, gdy packet nie jest słownikiem. continue addr = "%s:%u" % addr
self.p2pchat.handle_incoming(t, packet, addr) self.s.close() class P2PChat(): def __init__(self): self.nickname = '' self.s = None self.the_end = Event() self.nearby_users = set() self.known_messages = set() self.id_counter = 0 self.unique_tag = os.urandom(16) def main(self): # Alternatywnie w języku Python 3 można by napisać: # print("Enter your nickname: ", end="", flush=True) sys.stdout.write("Enter your nickname: ") sys.stdout.flush() nickname = sys.stdin.readline() if not nickname: return self.nickname = nickname.strip() # Przetwórz początkowe IP innych użytkowników. port = CHAT_PORT if len(sys.argv) == 2: port = int(sys.argv[1]) print("Creating UDP socket at port %u.\n" "To change the port, restart the app like this: udpchat.py \n" % port) # Stwórz gniazdo UDP na wybranym porcie i wszystkich interfejsach.
self.s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self.s.settimeout(0.2) self.s.bind(("0.0.0.0", port)) # Uruchom wątek odbierający dane. th = Receiver(self.s, self.the_end, self) th.start() print("To start, please add another user's address, e.g.:\n" " /add 1.2.3.4\n" " /add 1.2.3.4:59999\n" " /add gynvael.coldwind.pl:45454\n" "Or wait for a message from someone else.\n") # Przejdź do głównej pętli. try: while not self.the_end.is_set(): sys.stdout.write("? ") sys.stdout.flush() # Odczytaj linię od użytkownika. ln = sys.stdin.readline() if not ln: self.the_end.set() continue ln = ln.strip() if not ln: continue if ln[0] == '/': # Polecenie. cmd = [l for l in ln.split(' ') if len(l) > 0] self.handle_cmd(cmd[0], cmd[1:]) else:
# Wiadomość. self.send_message(ln) except KeyboardInterrupt as e: self.the_end.set() # Receiver powinien zamknąć gniazdo, gdy zakończy działanie. print("Bye!") def handle_incoming(self, t, packet, addr): # Pakiet z informacją o nowym sąsiadującym węźle w sieci P2P. if t == "HELLO": print("# %s/%s connected" % (addr, packet["name"])) self.add_nearby_user(addr) return # Pakiet z wiadomością tekstową. if t == "MESSAGE": # Jeśli nadawca był do tej pory nieznany, dodaj go do zbioru # sąsiadujących węzłów. self.add_nearby_user(addr) # Sprawdź, czy tej wiadomości nie otrzymaliśmy od innego węzła # z sieci. if packet["id"] in self.known_messages: return self.known_messages.add(packet["id"]) # Dopisz nadawcę wiadomości do listy węzłów, przez które # przeszła wiadomość. packet["peers"].append(addr) # Wyświetl wiadomość i jej trasę.
print("\n[sent by: %s]" % ' --> '.join(packet["peers"])) print(" %s" % (packet["name"], packet["text"])) # Wyślij wiadomość do sąsiadujących węzłów. self.send_packet(packet, None, addr) def handle_cmd(self, cmd, args): # W przypadku komendy /quit zakończ program. if cmd == "/quit": self.the_end.set() return # W przypadku manualnego dodawania węzłów upewnij się, że są # poprawnie zapisane, przetłumacz domenę (DNS) na adres IP # i dopisz do zbioru sąsiadujących węzłów. if cmd == "/add": for p in args: port = CHAT_PORT addr = p try: if ':' in p: addr, port = p.split(':', 1) port = int(port) addr = socket.gethostbyname(addr) except ValueError as e: print("# address %s invalid (format)" % p) continue except socket.gaierror as e: print("# host %s not found" % addr) continue addr = "%s:%u" % (addr, port) self.add_nearby_user(addr) return # Nieznane polecenie.
print("# unknown command %s" % cmd) def add_nearby_user(self, addr): # Sprawdź, czy węzeł nie jest już znany. if addr in self.nearby_users: return # Dodaj węzeł i wyślij mu wiadomość powitalną. self.nearby_users.add(addr) self.send_packet({ "type": "HELLO", "name": self.nickname }, addr) def send_message(self, msg): # Wylicz unikatowy identyfikator wiadomości. hbase = "%s\0%s\0%u\0" % (self.nickname, msg, self.id_counter) self.id_counter += 1 if PY3: hbase = bytes(hbase, 'utf-8') h = hashlib.md5(hbase + self.unique_tag).hexdigest() # Wyślij pakiet z wiadomością do wszystkich znanych węzłów. self.send_packet({ "type": "MESSAGE", "name": self.nickname, "text": msg, "id": h, "peers": [] }) def send_packet(self, packet, target=None, excluded=set()): # Zserializuj pakiet. packet = json.dumps(packet)
if PY3: packet = bytes(packet, 'utf-8') # Jeśli nie ma podanego żadnego docelowego węzła, należy wysłać # wiadomość do wszystkich węzłów oprócz tych ze zbioru excluded. if not target: target = list(self.nearby_users) else: target = [target] for t in target: if t in excluded: continue # Zakładam, że w tym momencie wszystkie adresy są poprawnie # sformatowane. addr, port = t.split(":") port = int(port) # Faktyczne wysłanie pakietu. self.s.sendto(packet, (addr, port)) def main(): p2p = P2PChat() p2p.main() if __name__ == "__main__": main() Przykład działania zaprezentowanego czatu w sieci składającej się z pięciu peerów, wraz ze zrzutami ekranów konsoli oraz schematem połączeń pomiędzy nimi, znajduje się na rysunku 13.
Podsumowując ten rozdział, należy dodać, że sprawne tworzenie aplikacji sieciowych, z uwagi na specyfikę tego obszaru informatyki, wymaga pewnej wprawy. Wynika to m.in. z faktu, że podobnie jak w przypadku synchronizacji wątków, komunikacja jest bardzo zależna od otaczającego ją ekosystemu, począwszy od bezpośredniego systemu operacyjnego i jego stosu TCP/IP, a skończywszy na zasadach panujących w sieciach lokalnych, dużych sieciach wewnętrznych czy sieci Internet.
Ćwiczenia [NET:all-the-protocols] We wstępie do tej części książki wspomniałem o siedmiu warstwach modelu OSI. Dla każdej warstwy podaj co najmniej trzy przykłady protokołów przynależących do danej warstwy, których nie opisywałem w tej książce. Do czego jest wykorzystywany każdy z nich? [NET:paranoid] W niniejszym rozdziale wskazałem metody listowania wszystkich aktywnych gniazd TCP oraz UDP. Sporządź ich listę dla swojego systemu oraz wyjaśnij, jaka aplikacja (i w jakim celu) korzysta z danego medium komunikacji. [NET:udp-dns] W podrozdziale „Gniazda TCP” została przedstawiona implementacja protokołu DNS w języku Python. Zmodyfikuj kod, tak by korzystał z protokołu UDP (zwróć uwagę na różnice w protokole DNS w przypadku użycia TCP oraz UDP). [NET:ftp-server] Korzystając z dowolnego języka programowania i dokumentacji protokołu, stwórz własny serwer FTP.
Rysunek 13. Przykład działania czatu peer-to-peer w niewielkiej sieci złożonej z 5 komputerów
Bibliografia
[1] RFC 791 – Internet Protocol, DARPA Internet Program, 1981, https://tools.ietf.org/html/rfc791 [2] Request for Comments, Wikipedia, https://en.wikipedia.org/wiki/Request_for_Comments http://coldwind.pl/s/c5r15 [3] Postel J., RFC 768 – User Datagram Protocol, 1980, https://tools.ietf.org/html/rfc768 [4] Beardsley T., Qian J., The TCP Split Handshake: Practical Effects on Modern Network Equipment, 2010, https://nmap.org/misc/split-handshake.pdf [5] TCP hole punching, Wikipedia, https://en.wikipedia.org/wiki/TCP_hole_punching [6] Coldwind G., Tools/NetSock, http://gynvael.coldwind.pl/?id=182 [7] Root Servers, IANA, https://www.iana.org/domains/root/servers [8] Domain Name System (DNS) Parameters – Resource Record (RR) TYPEs, IANA, http://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml#dnsparameters-4 Protocol Numbers, IANA, http://www.iana.org/assignments/protocolnumbers/protocol-numbers.xhtml [10] RFC 793 – Transmission Control Protocol, DARPA Internet Program, 1981, [9]
https://tools.ietf.org/html/rfc793 [11] Service Name and Transport Protocol Port Number Registry, IANA, http://www.iana.org/assignments/service-names-port-numbers/service-namesport-numbers.xhtml [12] Mockapetris P., RFC 1987 - Domain Names – Implementation and Specification, https://tools.ietf.org/html/rfc1035 [13] Domain Name System (DNS) Parameters – DNS OpCodes, IANA, http://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml#dnsparameters-5 [14] Domain Name System (DNS) Parameters – DNS RCODEs, IANA, http://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml#dnsparameters-6 [15] The Original HTTP as defined in 1991, http://www.w3.org/Protocols/HTTP/AsImplemented.html [16] RFC 2616 – Hypertext Transfer Protocol – HTTP/1.1, 1999, https://tools.ietf.org/html/rfc2616 [17] HTTP/2, Wikipedia, https://en.wikipedia.org/wiki/HTTP/2
[18] Uniform Resource Locator, Wikipedia, https://en.wikipedia.org/wiki/Uniform_resource_locator [19] Media Types, IANA, http://www.iana.org/assignments/media-types/mediatypes.xhtml
Programowanie dla zabawy Z mojego punktu widzenia programowanie jest świetną zabawą samą w sobie. Co istotne, nie każdy program musi poznanej funkcji w danym języku podchodzi się, ot tak, po prostu, dla Programowanie jako dziedzina
być aplikacją produkcyjną lub testem nowo czy bibliotece – czasami do programowania zabawy. informatyki istnieje już wiele lat i z biegiem
czasu zaczęły powstawać różnego rodzaju zabawy, wyzwania czy turnieje z nim związane. W niniejszym rozdziale postaram się przedstawić te, z którymi osobiście się spotkałem. Równocześnie chciałbym zachęcić czytelników do spróbowania swoich sił w jednej (lub wielu) z nich, zarówno we własnym zakresie, jak i w większej grupie znajomych. Code golf Code golf to nic innego jak ćwiczenie polegające na napisaniu najkrótszego kodu lub stworzeniu najmniejszego pliku wykonywalnego implementującego rozwiązanie danego problemu. Innymi słowy, im mniej bajtów, tym lepiej. Przykładem może być następujące zadanie, które w przeszłości podsunął mi mój brat: Napisz możliwie najkrótszy program w języku Python 2.7 (korzystając wyłącznie z biblioteki standardowej), wypisujący numery tych linii z pliku text.txt, które zawierają wyraz podany w argumencie. Standardowe rozwiązanie tego problemu mogłoby wyglądać następująco: import sys with open('text.txt') as f: for idx, line in enumerate(f): if sys.argv[1] in line: print idx Zaprezentowany kod zajmuje 121 bajtów. Krótszym rozwiązaniem będzie np. następujący skrypt (96 bajtów): import sys;k=0
for x in open('text.txt').read().split(sys.argv[1]) [:-1]:k+=x.count('\n');print k lub następujący kod, który w działaniu standardowego rozwiązania (80 bajtów):
jest
bardziej
zbliżony
do
import sys for k,x in enumerate(open('text.txt')): if sys.argv[1] in x:print k Próba rozwiązania tego typu problemu pozwala programiście zapoznać się w bardzo szczegółowy sposób z dokładną składnią języka, a także lepiej poznać podstawowe biblioteki oferowane przez języki programowania[295]. W niektórych wariantach tej zabawy zamiast długości kodu liczona jest wielkość pliku wykonywalnego. W takich wypadkach najczęściej taki plik tworzy się manualnie, tj. definiując poszczególne nagłówki ręcznie za pomocą dyrektyw asemblera. Alternatywnie tworzy się 16-bitowy kod w DOSowym „formacie” COM, który z założenia nie ma żadnych nagłówków, tj. znajduje się w nim jedynie sam kod maszynowy. Jeśli chodzi o standardowe, używane współcześnie formaty plików wykonywalnych (PE, ELF, Mach-O), to zostało udowodnione, iż możliwe jest stworzenie poprawnie działających plików tego typu, liczących około kilkuset bajtów (choć bardzo wiele zależy od wersji systemowego loadera, który zajmuje się ładowaniem i uruchamianiem programów). Poniżej znajduje się krótka lista najmniejszych uzyskanych do tej pory rozmiarów poszczególnych rodzajów plików wykonywalnych wraz z linkami do artykułów mówiących o tym, jak dany wynik osiągnięto: PE EXE x86-32: 97 bajtów (Windows XP), 252 bajtów (Windows 7) [1] [2]. PE EXE x86-64: 268 bajtów (Windows 7) [2]. ELF x86-32: 45 bajtów (GNU/Linux) [3]. Mach-O x86-32: 164 bajty [4]. Z własnego doświadczenia najlepiej wspominam zabawę polegającą na stworzeniu jak najmniejszego pod względem rozmiaru pliku wykonywalnego kompilatora ezoterycznego języka programowania Brainfuck. Program miał za zadanie wczytać „skrypt” ze standardowego wejścia, a wynikowy kod
maszynowy przekazać na standardowe wyjście. Zarówno sam kompilator, jak i plik wynikowy miały być poprawnymi plikami COM, a więc całość była napisana w 16-bitowym asemblerze. Ostatecznie moja wersja (w zabawie brało udział więcej osób) zajmowała 125 bajtów. Gdy przez przypadek natknąłem się na nią ponownie w 2013 roku i postanowiłem opublikować na swoim blogu [5], Peter Ferrie podjął wyzwanie i stworzył znacznie bardziej imponującą wersję kompilatora, zajmującą jedynie 100 bajtów [6]. Quine Innym ciekawym ćwiczeniem jest tworzenie tzw. quines, czyli programów wypisujących swój własny kod. O ile na pierwszy rzut oka problem ten wydaje się trywialny, dość szybko okazuje się, że nie do końca taki jest. Najprościej byłoby wyjść od następującego rozwiązania (pseudokod): print '' Niestety taka konstrukcja prowadzi do nieskończonej rekursji, do której dołącza potrzeba stosowania sekwencji ucieczki do wyrażenia kolejnych cudzysłowów oraz pojawiających się później samych znaków sygnalizujących sekwencje ucieczki: print 'print \'print \\\'print \\\\\\\'print \\\\\\\\\\\\\\\'... Do problemu należy więc podejść inaczej, jednak zamiast przedstawić tu rozwiązanie, chciałbym zachęcić czytelnika do zmierzenia się z tym zadaniem we własnym zakresie. Przykładowe rozwiązanie quine dla Python oraz wielu innych języków można znaleźć na stronie The Quine Page [7], ponadto jedno z możliwych rozwiązań dla języka C opublikowałem na swoim blogu [8]. W serwisie Wikipedia [9] zebrano dodatkowe warianty quine, o których również warto wspomnieć: Uroboros Quine – program w danym języku programowania ma za zadanie wypisać kod źródłowy w innym języku programowania. Ten, po kompilacji (jeśli ta jest wymagana) oraz uruchomieniu, powinien wypisać kod źródłowy w kolejnym języku. I tak aż do ostatniego programu, który musi wypisać kod źródłowy pierwszego programu z serii. Multiquines – zestaw różnych programów w odmiennych językach programowania, z których każdy musi umożliwiać wygenerowanie
kodu źródłowego dowolnego innego programu z rozważanego zestawu. Radiation-hardened Quine – program, którego celem jest wypisanie swojego kodu źródłowego, nawet w przypadku, gdy dowolny ze znaków kodu źródłowego zostałby usunięty. Na koniec dodam, że jedną z ciekawszych wariacji na temat quine jest plik ZIP, którego rozpakowanie powoduje wygenerowanie pliku identycznego z wejściowym archiwum – świetnym artykułem na ten temat jest „Zip Files All The Way Down” autorstwa Russa Coxa [10]. Kod poliglotyczny Kolejnym ciekawym ćwiczeniem jest poliglot (polyglot) – mowa tu o plikach zawierających kod źródłowy, który jest poprawny jednocześnie względem składni dwóch lub więcej języków programowania i którego wynik wykonania jest taki sam, bez względu na to, jako który język się go zinterpretuje. Najprostszym przykładem poliglotu jest najzwyklejsze „Hello World”, takie jak: print("Hello World"); Zaprezentowany kod jest poprawny zarówno w języku Ruby (1.9.1), jak i w języku Perl (5.18.2) i w obu wypadkach wypisze „Hello World” na standardowe wyjście. Ponadto kod ten jest poprawny również w języku Python (zarówno w wersji 2, jak i 3), przy czym w tym przypadku na końcu wypisanego tekstu znajdzie się dodatkowo znak nowej linii. Zdarza się, iż programy tego typu są bardziej ambitne niż „Hello World”, ale w praktyce stworzenie nawet prostego „Hello World”, działającego w kilku, kilkunastu językach, nie jest trywialnym zadaniem. Spis przykładowych programów poliglotycznych można znaleźć np. na Wikipedii [19]. Binarny plik poliglotyczny Pewną wariacją na temat programów poliglotycznych są binarne pliki poliglotyczne (binary polyglot), czyli takie, które mogą być interpretowane jako więcej niż jeden format. Świetnym przykładem jest CorkaMIX [11] autorstwa Ange Albertini – jest to plik, który jednocześnie jest poprawnym plikiem wykonywalnym pod systemem Windows (PE), dokumentem PDF, archiwum JAR (wzorcowy format aplikacji w języku Java – technicznie plik ZIP zawierający pliki z kodem bajtowym oraz dodatkowe dane), a także dokumentem HTML zawierającym JavaScript. Warto dodać, że „poprawność” w tym wypadku
niekoniecznie oznacza pełną zgodność ze specyfikacją danego formatu; istotna jest przede wszystkim kompatybilność z konkretną implementacją[296] danego formatu. Co ciekawe, możliwość stworzenia plików poliglotycznych z konkretnych formatów ma w niektórych przypadkach pewne konsekwencje związane z bezpieczeństwem. Typowym i najprostszym przykładem jest tzw. GIFAR [12] – plik będący jednocześnie poprawnym plikiem GIF oraz apletem Java (JAR), którego działanie i skutki zostały przedstawione poniżej. Ze względu na fakt, że niezaufane aplety Java (jak i np. pliki HTML) mogą zawierać kod, który potrafi wykraść poufne dane (np. ciasteczka czy treść witryny widocznej dla zalogowanego użytkownika) dotyczące strony, w kontekście której są uruchamiane, to zasadniczo serwisy internetowe starały się nie dopuścić[297], by użytkownik mógł sprawić, że kontrolowany przez niego plik tego rodzaju będzie serwowany z wrażliwej domeny. Na przykład serwis pozwalający na umieszczanie własnych zdjęć (tj. plików graficznych) może sprawdzać, czy dany plik jest faktycznie w pełni poprawnym plikiem graficznym – i odrzucać wszystkie pozostałe. Aplety Java (pliki JAR) nie są z reguły poprawnymi plikami graficznymi, więc zostałyby normalnie odrzucone, a przynajmniej tak mogłoby się wydawać – w 2008 roku Billy Rios oraz Nate McFeters skonstruowali plik, określając go mianem „GIFAR” (właściwie był to plik GIF z doklejonym na końcu plikiem ZIP/JAR, na które zostały naniesione drobne poprawki), który przechodził przez tego typu walidacje, a następnie – gdy już znalazł się na serwerze i był serwowany w kontekście wrażliwej domeny – mógł zostać uruchomiony po stronie użytkownika jako aplet Java i wykraść niektóre wrażliwe dane. Dodam, że połączenie pliku GIF oraz ZIP jest możliwe dzięki temu, iż plik GIF posiada nagłówek i jest parsowany od początku pliku, w przeciwieństwie do formatu ZIP, którego parsowanie zaczyna się od końca pliku. Ta ostatnia możliwość jest używana m.in. w przypadku SFX ZIP (SelF eXtracting ZIP) – samorozpakowujących się archiwów ZIP, które technicznie są plikiem wykonywalnym zawierającym ekstraktor z dołączonymi na końcu strukturami ZIP. Z tego względu w przypadku plików SFX wystarczy zmienić rozszerzenie na .zip, aby otrzymać w pełni poprawne archiwum. Pliki schizofreniczne
Wspomniałem wcześniej, że implementacja parsera danego formatu nie zawsze jest zgodna z jego specyfikacją. Należy dodać, że implementacja implementacji nierówna, przez co w niektórych przypadkach istnieje możliwość stworzenia pliku jednego, konkretnego typu, który w jednym programie zostanie zinterpretowany w sposób różny niż w innym [13]. Przykładem może być format BMP, który opisywałem w jednym z wcześniejszych rozdziałów. Jak wspominałem, w pierwszym nagłówku o nazwie BITMAPFILEHEADER znajduje się pole bfOffBits, które mówi o pozycji danych (czyli właściwego obrazu/bitmapy) w pliku. W zdecydowanej większości przypadków dane znajdują się od razu po nagłówkach (oraz po opcjonalnej palecie kolorów), a więc pole bfOffBits zawiera po prostu przesunięcie pierwszego bajtu po tych nagłówkach. Biorąc pod uwagę ten fakt, autorzy niektórych implementacji założyli, że pole bfOffBits można zignorować, ponieważ dane i tak będą w spodziewanym miejscu, co wcale nie musi odpowiadać rzeczywistości. Bazując na zaprezentowanym powyżej błędzie w założeniach, można stworzyć plik BMP, który będzie zawierał dwa różne obrazy – jeden wyświetlany przez programy respektujące pole bfOffBits, a drugi przez te ignorujące to pole. Należy jednak zaznaczyć, że oba obrazy muszą być tej samej wielkości i, w przypadku jej obecności, korzystać z tej samej palety kolorów – wynika to z tego, że nagłówki zawierające te informacje są wspólne dla obu właściwych obrazów.
Rysunek 1. Struktura schizofrenicznego pliku BMP
Struktura takiego pliku przedstawiona jest na rysunku 1, natomiast rysunek 2 prezentuje zrzut ekranu z programów IrfanView (który respektuje pole bfOffBits) oraz Universal Viewer (który ignoruje pole bfOffBits). Dodam, iż trik ten opisałem już w artykule „Format BMP okiem hakera” [14], choć oryginalnie zamiast Universal Viewer wskazałem Lister, wchodzący w skład aplikacji Total Commander, niemniej jednak na przestrzeni lat implementacja w Listerze się zmieniła i zaczęła respektować wspomniane wcześniej pole.
Rysunek 2. Schizofreniczny plik output.bmp[298] wyświetlony w programach IrfanView oraz Universal Viewer Konkursy algorytmiczne O ile w niniejszej książce nie piszę zbyt wiele o algorytmice w rozumieniu klasycznym, o tyle jest to zdecydowanie istotna i ciekawa część informatyki. Z tego też powodu powstał cały szereg serwisów, na których można zmierzyć się z zadaniami algorytmicznymi o różnych stopniach trudności (takich jak SPOJ, Top Coder itp.), a swoje umiejętności algorytmiczne przetestować podczas różnego rodzajukonkursów, zarówno indywidualnych, jak i zespołowych. Przykładami mogą być: Olimpiada Informatyczna, Potyczki Algorytmiczne, Deadline24, Marathon24 czy Akademickie Mistrzostwa Świata w Programowaniu Zespołowym. Gamedev Compo Wywodzący się z demosceny (o której za chwilę) termin „compo” pochodzi od angielskiego słowa „competition”, oznaczającego konkurs lub turniej. W tym
przypadku gamdev compo oznacza mniej lub bardziej formalny konkurs, zazwyczaj ograniczony z uwagi na czas trwania oraz liczebność drużyny, polegający na stworzeniu (najczęściej od podstaw) gry na określony temat. Tematy takich konkursów, nierzadko poddawane głosowaniu, są z reguły bardzo ogólne, jak „gra zawierająca czołg” lub „gra typu Tower Defene”, i podlegają znacznej dowolności w interpretacji. Przykładem compo może być odbywający się przy okazji siedleckiej konferencji Inżynierii Gier Komputerowych siedmiogodzinny, drużynowy turniej (gamedev compo) lub odbywające się dużo częściej mniej formalne konkursy organizowane przez serwis Warsztat.GD, który swego czasu prowadził również nieformalny ranking agregujący ich wyniki. Udział w ograniczonym czasowo compo wymaga specyficznego podejścia, w tym odłożenia na bok metodologii używanych podczas tworzenia kodu produkcyjnego, tj. dobrych praktyk programowania, testów jednostkowych (również Test Driven Developement), idealnie dobranych, uszytych na miarę algorytmów itp. – zamiast tego zaleca się tworzenie kodu w sposób, który zajmuje najmniej czasu (będąc nadal względnie działającym i debugowalnym), nawet jeśli wymaga to rezygnacji z jego czytelności[299]. Podobnie sam koncept gry musi być odpowiednio dobrany – nie każdy świetny pomysł można poprawnie zaimplementować w czasie kilku lub kilkudziesięciu godzin.
Rysunek 3. Przykładowe zrzuty ekranu z gier stworzonych podczas IGK compo[300]
Naśladowanie grafiki Innym rodzajem zabawy, z którym miałem okazję się spotkać, było proceduralne naśladowanie grafiki. Punktem wyjściowym był prosty obraz (wygenerowany lub ewentualnie narysowany), a zadaniem uczestników było stworzenie programu, który wygeneruje obraz jak najbardziej zbliżony do wzorcowego – im bardziej skomplikowany obraz, tym więcej pracy wymagało jego odtworzenie. Dodam, że wplecenie w kod wzorcowego obrazu w postaci bitmapy było z oczywistych powodów niedozwolone. Przykładowy, wzorcowy obraz wraz z wynikiem działania programu zgłoszonego z uczestników znajduje się na rysunku 4.
przez jednego
Rysunek 4. Wzorcowa grafika z ProcGFX Compo 3 oraz grafika wygenerowana przez zgłoszenie Mateusza Jurczyka Demoscena Można powiedzieć, że demoscena jest miejscem, w którym programowanie łączy się ze sztuką. Członkowie demosceny tworzą m.in. tzw. dema oraz intra – programy, które zawsze kojarzyły mi się z krótkimi filmami animowanymi. Mówiąc ściślej, ich jedynym zadaniem jest zaprezentowanie ciekawych
audiowizualnych scen, składających się przede wszystkim z pomysłowych efektów graficznych. W tym momencie gorąco zachęcam do przerwania lektury niniejszej książki i do zapoznania się z kilkoma demami lub intrami, szczególnie jeśli czytelnik nigdy wcześniej się z nimi nie zetknął – można je znaleźć np. na stronie http://pouet.net/ w sekcji „Prods” lub w postaci nagrania na serwisach typu YouTube. Dodam jeszcze, że intra i dema tworzone są na bardzo różne platformy, z których wyciskane są ostatnie poty – warto więc zwrócić uwagę na opis każdego z dem, by móc w pełni docenić daną produkcję. Intra zazwyczaj są ograniczone co do rozmiaru pliku wykonywalnego produkcji (w skrajnych przypadkach „produkcja” ogranicza się do jednej sceny). Najpopularniejsze formaty to: Intro 64K (ograniczenie do 64 kB), 4K, 1K oraz 256b; oprócz tego zdarzają się intra z innymi ograniczeniami co do wielkości, od 32b do 256K. W przeciwieństwie do intr, dema nie podlegają tego typu ograniczeniom, więc są to zazwyczaj znacznie większe produkcje. Oprócz dem i intr istnieje jeszcze jedna kategoria prac występujących na demoscenie – są to „grafiki generowane proceduralnie” (procedural graphics), czyli de facto programy, które w trybie offline (czyli nie w czasie rzeczywistym) generują pojedyncze obrazy, korzystając jedynie z dobrodziejstw oferowanych przez język programowania oraz matematykę, której zazwyczaj nie brakuje w takich przypadkach. Intra, dema i grafiki generowane proceduralnie są często publikowane na rozmaitych zlotach demosceny (demoparty), z reguły w ramach compo. Oprócz dem i intr istnieje również wiele innych form sztuki obecnej na demoscenie, choć niekoniecznie już związanych stricte z programowaniem (np. grafika, muzyka, animacja). Niektóre grupy demoscenowe (oraz rzadziej inne) publikują od czasu do czasu tzw. ziny (zwane również diskmagami, szczególnie za granicą), czyli magazyny o demoscenie, grafice, muzyce i programowaniu, których oprawa sama zawiera zazwyczaj elementy typowe dla demosceny. Przykładem może być świetny, choć wychodzący dość rzadko magazyn „Hugi” [15]. CTF/Wargames/HackMe Innym rodzajem zabawy z programowaniem, zdecydowanie bardziej zorientowanym w kierunku bezpieczeństwa komputerowego, są zawody Capture The Flag, a także serwisy z zadaniami podobnego typu, potocznie nazywane Hackme lub Wargames. Co ciekawe, w obu przypadkach oprócz samego
bezpieczeństwa pojawiają się również nietrywialne ilości kodu pomocniczego, który trzeba w tym czasie napisać w celu rozwiązania zadania; sama znajomość języków programowania, bibliotek, API itp. przydaje się również podczas analizy wstecznej aplikacji. Najczęściej wyróżnia się następujące kategorie zadań (ze względu na tematykę): Web – bezpieczeństwo aplikacji internetowych. Zazwyczaj otrzymujemy od organizatorów adres serwisu WWW i naszym zadaniem jest jego analiza na zasadzie black box (tj. bez dostępu do kodu źródłowego, choć w niektórych wypadkach kod ten można zdobyć po drodze) lub white box (mając do dyspozycji kod źródłowy) oraz odnalezienie jednego lub większej ilości błędów, które wspólnie umożliwią przejęcie konta administratora serwisu lub wykonanie dowolnego dostarczonego przez nas kodu po stronie serwera. RE – reverse-engineering, czyli inżynieria wsteczna. Zazwyczaj otrzymujemy program, który sprawdza, czy podane przez użytkownika hasło do zadania jest poprawne[301]. Celem zadania jest zrozumienie, jak program działa, w jaki sposób weryfikowane jest hasło oraz wywnioskowanie lub wyliczenie poprawnego hasła. Najczęściej sam program otrzymujemy w postaci pliku wykonywalnego ELF lub PE, choć zdarzają się również bardziej egzotyczne przypadki, jak skompilowane aplikacje w językach Java lub Python na stare konsole, takie jak GameBoy lub SNES, czy archaiczne, 8-bitowe komputery. pwn[302] – tzw. „niskopoziomowa eksploitacja”. Celem zadania jest z reguły analiza serwisu sieciowego, którego kopię posiadamy w postaci pliku wykonywalnego, najczęściej napisanego w C lub C++, a następnie odnalezienie błędów umożliwiających przejęcie kontroli nad przebiegiem wykonania oraz ich wykorzystanie w celu wykonania własnego lub wskazanego przez siebie kodu, który umożliwi nam pełny, zdalny dostęp do serwera. Kategoria ta wymaga zarówno umiejętności związanych z inżynierią wsteczną, dobrych umiejętności programistycznych, jak i doskonałego zrozumienia architektury komputera i systemów operacyjnych. crypto – kryptografia. Celem zadania jest najczęściej rozszyfrowanie otrzymanej zaszyfrowanej wiadomości, nie znając klucza, a często nawet użytego algorytmu. W niektórych wypadkach otrzymujemy
również kod implementacji danego algorytmu szyfrującego, który został użyty do zaszyfrowania wiadomości – wówczas celem jest znalezienie błędu w jego implementacji. Rzadziej zadania w tej kategorii polegają na przewidzeniu serii liczb pseudolosowych lub np. uzyskaniu tzw. kolizji na funkcjach haszujących, czyli znalezieniu dwóch zestawów różnych danych wejściowych, które po zastosowaniu funkcji haszującej skutkują wyliczeniem tej samej sumy kontrolnej. stegano – steganografia. Celem zadania jest odnalezienie ukrytej wiadomości w otrzymanym pliku, najczęściej graficznym lub dźwiękowym. forensics – informatyka śledcza. Najczęściej udostępniony jest obraz dysku twardego komputera, zrzut pamięci RAM lub zrzut ruchu sieciowego, a także informacja o tym, gdzie szukać flagi (np. „flaga znajduje się w usuniętym pliku secret.bmp” albo „flaga znajduje się w zaszyfrowanej rozmowie prowadzonej przez komunikator Pidgin”). networking – zadanie polegające na interakcji z podanym serwerem za pomocą mniej znanych lub mniej standardowych protokołów sieciowych. Może polegać np. na wykonaniu poprawnego port knockingu[303], przeprowadzeniu wymiany danych z aplikacją komunikującą się poprzez SCTP (Stream Control Transmission Protocol) itp. shellcoding – zadanie polegające na stworzeniu kodu maszynowego, który wykonuje określoną funkcję (np. otwiera plik flag zawierający hasło do zadania oraz wypisuje jego zawartość na standardowe wyjście), a dodatkowo w swojej formie spełnia określone założenia, np. „każdy bajt kodu maszynowego musi być znakiem drukowalnym” lub „kod maszynowy zinterpretowany jako zestaw 32-bitowych liczb musi być ciągiem malejącym” itp. ppc – zadanie typowo programistyczne, zazwyczaj polegające na stworzeniu programu, który zdalnie (na serwerze) rozwiąże określone przez twórców zadanie, np. odnajdzie wyjście z labiryntu lub za każdym razem trafi do tarczy przy zadanych parametrach fizycznych. hardware – elektronika oraz analiza firmware'u. Zwyczajowo otrzymujemy zdjęcie lub schemat układu bądź w przypadku zawodów offlinesam układ lub dostęp do takowego. Zazwyczaj oczekuje on na
wprowadzanie hasła (za pomocą przełączników, klawiaturki czy połączenia szeregowego), a naszym celem jest analiza układu – zarówno od strony elektroniki, jak i oprogramowania – oraz odzyskanie hasła. W skrajnych przypadkach sam układ może być emulowany, np. przez zaimplementowanie go w popularnej grze Minecraft [16]. inne (np. trivia lub recon). Oczywiście wszystkie zadania (w szczególności serwisy oraz same serwery) są przygotowane specjalnie na potrzeby zawodów, gdzie istnieje przyzwolenie na testowanie i przełamywanie ich zabezpieczeń, a także dostęp do danych wchodzących w skład zadania. Rozwiązanie przykładowego zadania na CTF [VERBOSE] Jak już wspomniałem, pomimo nacisku na bezpieczeństwo komputerowe w większości kategorii turniejów CTF bardzo często konieczne jest napisanie znacznych ilości kodu pomocniczego podczas ich rozwiązywania. Na przykład na początku 2015 roku odbył się PlaidCTF, czyli rozgrywane przez Internet zawody organizowane przez jeden z najlepszych zespołów CTF-owych na świecie – Plaid Parliament of Pwning, powiązany z Carnegie Mellon University. Jedno z zadań, nazywające się po prostu „tp” (z kategorii pwn), polegało m.in. na „wydostaniu się” z restrykcyjnego sandboxa[304], w którym działała atakowana aplikacja. Całość odbywała się pod kontrolą 64-bitowego systemu Ubuntu, prawdopodobnie w wersji 14.04.2 LTS, a sam sandbox był oparty na mechanizmie seccomp-bpf i zezwalał jedynie na dostęp do kilku wybranych wywołań systemowych (takich jak mmap, mremap, munmap i mprotect). Był jednak pewien wyjątek – w przypadku wywołania systemowego wykonanego z jednego konkretnego miejsca w pamięci sandbox zezwalał na wykonanie dowolnego wywołania systemowego. Jedyny problem polegał na tym, że miejsce to było wybierane losowo przy każdym połączeniu z aplikacją, a szansa na jego odgadnięcie wynosiła ok. 0,0000000033%[305] – konkretniej losowana była jedna strona pamięci (o wielkości 0x1000 bajtów), która musiała się znaleźć między adresami 0x10000000 a 0x700000000000. Dodam, że w momencie rozwiązywania tej zagadki mogliśmy już wykonać dowolny kod po stronie serwera.
Ostatecznie wybrana przez nas metoda polegała na zaalokowaniu (za pomocą wywołania mmap) jednej strony pamięci pod adresem z początku przedziału (0x10000000), a następnie rozszerzeniu alokacji (za pomocą mremap) do jak największych rozmiarów. Całość opierała się na założeniu, że kernel nie pozwoli na rozszerzenie alokacji, jeśli ta miałaby pokryć istniejący już region pamięci. Zamiast rozszerzać alokację liniowo (tj. za każdym razem o stałą liczbę stron), użyliśmy tzw. bisekcji (binary search), czyli podejścia polegającego na stopniowym zawężaniu przedziału możliwych wartości (w tym wypadku wartością była wielkość alokacji po rozszerzeniu). Algorytm ten ma złożoność O(log2n) i w naszym przypadku wyglądał następująco (patrz również rys. 5): Załóż, że: 1. L to lewa strona przedziału (początkowo: 0) a. R to prawa strona przedziału (początkowo: 0x700000000000 – 0x10000000). b. Jeśli (R - L) jest mniejsze lub równe 0x1000 (jednej stronie pamięci), przerwij wykonanie (wynik jest w R). 4. Niech testowana wartość A będzie równa średniej arytmetycznej z L i R (czyli L + (R - L) / 2, a więc (L + R) / 2) w zaokrągleniu do pełnych stron. 5. Wykonaj próbę zmiany rozmiaru alokacji do wielkości A. 6. Jeśli próba się powiodła (czyli alokacja na pewno nie pokryła żadnej już istniejącej), niech nową wartością L będzie A. 7. Jeśli próba się nie powiodła (czyli nowy region przykryłby poszukiwany obszar w pamięci), niech nową wartością R będzie A. 8. Idź do kroku 2.
Rysunek 5. Ilustracja procesu bisekcji dla poszukiwania strony w pamięci Przykładowa implementacja powyższego algorytmu w języku C, którą posłużyliśmy się podczas zawodów do przetestowania tego pomysłu, wygląda następująco: #define _GNU_SOURCE #include #include #include int main(void) { unsigned long long kStart = 0x10000000ULL; unsigned long long kEnd = 0x700000000000ULL;
// Allocate a page at an "unknown" address to look for it. mmap((void *)0x45551341a000, 0x1000, PROT_EXEC | PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_PRIVATE | MAP_FIXED, -1, 0); // Look where the data was allocated. mmap((void *)kStart, 0x1000, PROT_NONE, MAP_ANONYMOUS | MAP_PRIVATE | MAP_FIXED | MAP_NORESERVE, -1, 0); unsigned long long sz = kEnd - kStart; unsigned long long left = 0; unsigned long long right = sz; unsigned long long old_size = 0x1000; while (1) { unsigned long long attempt = (left + (right - left) / 2) & ~0xfffULL; if (right - left 1 [0x10000000] [37fff8000000] 380008000000 - 540004000000 --> 0 [0xffffffffffffffff] [37fff8000000] 380008000000 - 460006000000 --> 0 [0xffffffffffffffff] [37fff8000000] [3efff7000000] [427ff6800000] [443ff6400000]
380008000000 3f0007000000 428006800000 444006400000
-
3f0007000000 428006800000 444006400000 452006200000
--> --> --> -->
1 1 1 1
[0x10000000] [0x10000000] [0x10000000] [0x10000000]
[451ff6200000] 452006200000 - 459006100000 --> 0 [0xffffffffffffffff] [451ff6200000] 452006200000 - 455806180000 --> 0 [0xffffffffffffffff] [451ff6200000] 452006200000 [453bf61c0000] 453c061c0000 [4549f61a0000] 454a061a0000 [4550f6190000] 455106190000 [455476188000] 455486188000
-
453c061c0000 454a061a0000 455106190000 455486188000 455646184000
--> --> --> --> -->
1 1 1 1 0
[0x10000000] [0x10000000] [0x10000000] [0x10000000]
[0xffffffffffffffff] [455476188000] 455486188000 - 455566186000 --> 0 [0xffffffffffffffff] [455476188000] 455486188000 - 4554f6187000 --> 1 [0x10000000]
[4554e6187000] 4554f6187000 - 45552e186000 --> 0 [0xffffffffffffffff] [4554e6187000] 4554f6187000 - 455512186000 --> 1 [0x10000000] [455502186000] 455512186000 - 455520186000 --> 0 [0xffffffffffffffff] [455502186000] 455512186000 - 455519186000 --> 0 [0xffffffffffffffff] [455502186000] 455512186000 - 455515986000 --> 0 [0xffffffffffffffff] [455502186000] 455512186000 - 455513d86000 --> 0 [0xffffffffffffffff] [455502186000] 455512186000 - 455512f86000 --> 1 [0x10000000] [455502f86000] 455512f86000 - 455513686000 --> 0 [0xffffffffffffffff] [455502f86000] 455512f86000 - 455513306000 --> 1 [0x10000000] [455503306000] 455513306000 - 4555134c6000 --> 0 [0xffffffffffffffff] [455503306000] 455513306000 - 4555133e6000 --> 1 [0x10000000] [4555033e6000] 4555133e6000 - 455513456000 --> 0 [0xffffffffffffffff] [4555033e6000] 4555133e6000 - 45551341e000 --> 0 [0xffffffffffffffff] [4555033e6000] [455503402000] [455503410000] [455503417000]
4555133e6000 455513402000 455513410000 455513417000
-
455513402000 455513410000 455513417000 45551341a000
--> --> --> -->
1 1 1 1
[0x10000000] [0x10000000] [0x10000000] [0x10000000]
[45550341a000] 45551341a000 - 45551341c000 --> 0 [0xffffffffffffffff] [45550341a000] 45551341a000 - 45551341b000 --> 0 [0xffffffffffffffff] the end probably at: 45551341a000 Zaglądając ponownie do kodu, możemy stwierdzić, że poszukiwana strona faktycznie znajduje się pod adresem 0x45551341a000, a więc korzystając z omówionej metody, udało się ją zlokalizować. W ramach ciekawostki
wspomnę
jeszcze,
że
podczas
zawodów
musieliśmy
przetłumaczyć
zaprezentowany kod na asembler (x86-64) i dopisać część korzystającą z odzyskanej możliwości wywoływania dowolnych wywołań systemowych w celu zdobycia flagi. Inne Oprócz wymienionych konkursów, zabaw czy innych rozrywek programistycznych istnieje oczywiście również szereg innych podobnych aktywności, np.: IOCCC (The International Obfuscated C Code Contest) – coroczny konkurs na stworzenie programu w języku C, którego kod jest poprawny, ale całkowicie nieczytelny. Underhanded C Contest – kolejny coroczny konkurs, podczas którego celem jest stworzenie pozornie niewinnego kodu źródłowego, który jednak zawiera jakąś ukrytą, niecną funkcjonalność. Jednolinijkowe programy muzyczne – krótkie programy napisane zazwyczaj w języku C, których celem jest proceduralne wygenerowanie melodii (patrz np. [17]). Constrained programming – ogólna nazwa na tworzenie programów z narzuconymi, często przesadnymi ograniczeniami (np. [18]). Z pewnością nie wszystkie podobne aktywności udało mi się tu przedstawić – zachęcam więc czytelników, by we własnym zakresie postarali się odkryć inne programistyczne inicjatywy, prowadzone dla zabawy, a także spróbować swoich sił w różnego rodzaju wyzwaniach tego typu.
samemu
Ćwiczenia [FUN:capture-the-flag] Znajdź wszystkie flagi ukryte w niniejszej książce. Do odzyskania niektórych z nich będzie wymagane wykazanie się podstawowymi umiejętnościami programistycznymi lub okołoprogramistycznymi. Flagę rozpoznasz po prefiksie „Flag{” oraz sufiksie „}”.
Zdobyte flagi możesz http://gynvael.coldwind.pl/book/flag.
wprowadzić
pod
adresem:
Bibliografia [1]
Sotirov A., Tiny http://www.phreedom.org/research/tinype/ [2] Albertini A., PE – minimal https://code.google.com/p/corkami/wiki/PE? show=content#minimal_sizes
PE, sizes,
http://coldwind.pl/s/fun
[3] Raiter B., A Whirlwind Tutorial on Creating Really Teensy ELF Executables for Linux, http://www.muppetlabs.com/~breadbox/software/tiny/teensy.html [4] Seriot N., Hello Mach-O, 2012-2013, http://seriot.ch/hello_macho.php [5]
Coldwind G., Brainfuck compiler http://gynvael.coldwind.pl/n/bf_125_bytes [6] Ferrie P., BRAINFCK COMPILER http://pferrie.host22.com/misc/brainfck.htm
in IN
125 100
bytes, BYTES!,
[7] Thompson G. P. II, The Quine Page, http://www.nyx.net/~gthompso/quine.htm [8] Coldwind G., C Quine, http://gynvael.coldwind.pl/n/c_quine [9] Quine (computing), Wikipedia, https://en.wikipedia.org/wiki/Quine_(computing) [10] Cox R., Zip Files All The Way Down, 2010, http://research.swtch.com/zip [11] Albertini A., CorkaMIX, http://mix.corkami.com/ [12] Rios B., SUB Fixes GIFARs, http://xs-sniper.com/blog/2008/12/17/sun-fixes-gifars/ [13] Albertini A., Coldwind G., Schizophrenic files, 2014, http://gynvael.coldwind.pl/? id=539 [14] Coldwind G., Format BMP okiem hakera, hakin9 2/2008 [15] Hugi - Demoscene Diskmag, http://www.hugi.scene.org/ [16] Bazański S., PHDays CTF 4 Qualifications – Escape from Minecraft, Dragon Sector, 2014, http://blog.dragonsector.pl/2014/02/ph4quals-escape-fromminecraft.html [17] Viznut, Some deep analysis of one-line music programs, http://countercomplex.blogspot.ch/2011/10/some-deep-analysis-of-one-linemusic.html
2011,
[18] Ormandy T., Fun with Constrained Programming, 2012, http://blog.cmpxchg8b.com/2012/09/fun-with-constrained-programming.html [19] Polyglot (computing), Wikipedia, https://en.wikipedia.org/wiki/Polyglot_(computing) [20] Vexillium, http://vexillium.org/?gd [21] Zaborski M., Rosik S., Coldwind G., IGK Compo 2013 – o naszej grze, http://oshogbo.vexillium.org/news/17/
Zakończenie I tak oto nasza wędrówka dobiegła końca. Sześćset stron to oczywiście zbyt mało, by móc poruszyć wszystkie istotne i interesujące zagadnienia związane z programowaniem, niemniej jednak mam nadzieję, że na kartach tej książki udało Ci się, Czytelniku, znaleźć coś dla siebie. Pozostaje mi jedynie zachęcić do kontynuowania przygody z programowaniem i odkrywania kolejnych fascynujących mechanizmów, formatów, protokołów, języków itp. Ostatecznie pozwoli Ci to tworzyć kod oparty nie tylko na znajomości składni języka i powierzchniowego działania funkcji bibliotecznych, lecz także na ugruntowanej wiedzy o podległych zależnościach, ukrytych w tle procesach czy niskopoziomowych mechanizmach. GG, HF GL Gynvael Coldwind Zurych, 2015 r.
[1] Autor znakomitej serii książek pt. „Sztuka programowania” (org. The Art of Computer Programming), a także twórca TeX – języka oraz oprogramowania do składu tekstu, które stworzył właśnie na potrzeby swoich publikacji. Znany również z nagradzania osób, które znalazły błędy w jego książkach, pamiątkowymi czekami wystawionymi na jednego „heksadecymalnego dolara” (tj. 256 centów) lub odpowiednio mniejszymi wartościami w przypadku drobniejszych potknięć. [2] Czego celem, o czym wspomniałem już we wstępie do książki, jest wprowadzenie początkujących czytelników do pracy z kodem tworzonym przez różnych programistów. [3] Nakładka na emulator konsoli pod kontrolą systemu z rodziny Windows. [4] Emulator systemu komputerowego pracującego pod kontrolą systemu DOS. [5] Domyślny emulator konsoli w niektórych wersjach systemu Ubuntu. [6]
W praktyce wszystkie procesy posiadające dostęp do dyskryptora standardowego wejścia – w szczególności procesy potomne – mogą odbierać wprowadzane dane, choć kolejność dostępu do danych może być
niedeterministyczna. [7] Termin „shell” w odniesieniu do interpretera poleceń jest współcześnie rzadziej używany niż w czasach, gdy systemy operacyjne nie miały trybu graficznego i były uruchamiane w trybie tekstowym, w którym jedyną powłoką systemową był właśnie interpreter poleceń. Obecnie „shell” pasuje bardziej do graficznych interfejsów, takich jak Windows Explorer czy Ubuntu Unity, jednak jest on nadal używany w obu znaczeniach na systemach unixowych. [8] Na przykład w przypadku systemu Windows 7 ustawienia te można znaleźć w Panelu Sterowania, a konkretniej nawigując po System → Zaawansowane ustawienia systemowe → Zaawansowane → Zmienne środowiskowe. W przypadku Ubuntu 14.04.2 LTS domyślne zmienne są zdefiniowane w pliku /etc/environment. [9] Pod systemem Windows od powyższych zasad istnieje pewien wyjątek. Mianowicie dowolny proces w systemie, który posiada odpowiednie uprawnienia, może zmodyfikować domyślne zmienne środowiskowe,
a
następnie
rozesłać
wiadomość
WM_SETTINGCHANGE
z
parametrem
„Environment”, która informuje wszystkie zainteresowane procesy, że uległy one zmianie. W przypadku odebrania takiego zdarzenia zainteresowane procesy mogą (ale nie muszą) odświeżyć swój blok zmiennych środowiskowych. [10] Warto dodać, że systemy unixowe najczęściej nie mają w PATH pojedynczej kropki, czyli obecnego katalogu roboczego. Stąd, aby uruchomić plik znajdujący się w obecnym katalogu, należy obok jego nazwy podać również ścieżkę, np. ./a.out. Niektórzy preferują dopisanie kropki do PATH. [11] W przeszłości istniała jeszcze funkcja gets, która na szczęście została usunięta w nowych standardach języka C. Funkcja ta przyjmowała jedynie adres bufora (ale już nie jego wielkość) i w przypadku zbyt dużej ilości danych wejściowych pamięć znajdująca się bezpośrednio za buforem była nadpisywana, co często prowadziło do błędów związanych z bezpieczeństwem (chodzi o tzw. buffer overflow). Dokładnie ten sam problem może wystąpić w związku z operatorem >>(char*) w przypadku obiektu std::cin w C++. [12] Niestety praktyka pokazuje, że dokumentacja często jest niekompletna, nieaktualna, pomija interesujące nas szczegóły lub po prostu w ogóle nie istnieje. [13] Przykładowym paradygmatem w kontekście programowania jest np. programowanie obiektowe lub funkcyjne [1]. [14] Warto dodać, że w języku Python 3 print jest funkcją (a nie dyrektywą). [15] Dzielenie całkowite w Pascalu można uzyskać, używając słowa kluczowego DIV, a w Python 3 za pomocą operatora // (podwójny znak dzielenia). Ten sam digraf jest używany jako początek komentarza w niektórych innych językach. [16] Jak wspominam w części II książki, jedynym liczbowym typem w języku JavaScript jest zmiennoprzecinkowy typ Number, będący odpowiednikiem double w językach C, C++ czy Java, czy float w PHP oraz Python. [17] Użycie ^, czyli karety (popularnie zwanej „daszkiem”) do oznaczenia potęgowania może dziwić, szczególnie że w większości języków znakiem tym oznacza się bitowy XOR. Ma to podłoże historyczne – w Dartmouth BASIC (czyli pierwszym, oryginalnym języku z tej rodziny) potęgowanie było reprezentowane za pomocą znaku ↑ (strzałka w górę) [2]. Podobnie było w kolejnych wersjach języka BASIC, ale z czasem znak ↑ zniknął z klawiatur, a pojawił się dobrze nam znany „daszek” (kareta), który jednak wizualnie
podobny jest do poprzednika – został więc zaadoptowany w językach z rodziny BASIC do oznaczenia operacji potęgowania. [18] W językach o stosunkowo słabym typowaniu możliwe jest porównanie zmiennych o bardzo różnych typach, operacja równości co do wartości jest więc zazwyczaj niewystarczająca. [19] Wyrażenie to można zapisać również za pomocą tradycyjnych wywołań funkcji i metod, tj.: std::cout.operator> 4.3.hex() '0x1.1333333333333p+2' [74] W przypadku języka Python 2.7 wyjątkiem jest operacja NaN/0, która zawsze powoduje rzucenie wyjątku. Inne języki zazwyczaj zwracają w tym wypadku wynik NaN. [75] Jeśli nie udałoby się zakodować precyzyjnie tej wartości w zmiennej typu float, to przy konwersji z float na long otrzymalibyśmy inną liczbę. Wyjątkiem jest przypadek, w którym wyliczona wartość przepełniłaby zakres zmiennej float (tj. jeśli podana przeze mnie wartość byłaby większa niż faktyczny zakres omawianego typu) – w takim wypadku otrzymalibyśmy nieskończoność, co przy konwersji na typ long spowodowałoby rzucenie wyjątku OverflowError. [76] Kwestię dokładności tej wartości potwierdzimy jeszcze w podrozdziale dotyczącym typów decymalnych. [77] Języki o silniejszym typowaniu niż Python dostarczają zazwyczaj zestaw funkcji do obliczania wartości bezwzględnej. Warto sprawdzić, czy we własnym kodzie zastosowaliśmy odpowiednią funkcję. Na przykład funkcja abs w języku C operuje na typie int, a dla typu double należy użyć funkcji fabs. [78] Ponieważ jakikolwiek epsilon w omawianym przedziale jest odpowiedni, to równie dobrze zamiast 0,00000001 mogłem wybrać 0,09, 0,00012937, 0,0000000000564 albo 0,000000000000001 – każda z tych wartości sprawdziłaby się w tym przypadku.
[79] Pozostałe znaki kontrolne mają znaczenie głównie historyczne. Ich pełen spis można znaleźć np. na Wikipedii [26]. [80] Historycznie pod systemami MS-DOS inną popularną stroną kodową zawierającą polskie znaki diakrytyczne była Mazovia [27]. [81] W kontekście aplikacji wspierających wiele wersji językowych warto zainteresować się biblioteką gettext [2]. [82] Na przykład współczesne klawiatury po naciśnięciu klawisza „A” wysyłają do procesora kod 0x1C, a po jego puszczeniu kod 0xF0, po którym następuje ponownie kod 0x1C. Dla porównania znakom „A” oraz „a”, zarówno w ASCII, jak i w Unicode, odpowiadają kody 0x41 oraz 0x61. [83] Zwyczajowo kody Unicode są zapisywane w postaci U+kod_heksadecymalnie. [84] Należy zaznaczyć, iż kompatybilność ta dotyczy jedynie kodów znaków. Na poziomie samych sekwencji binarnych znaki Unicode są zapisywane jednak inaczej i ewentualna kompatybilnosć dotyczy jedynie zakresu od 0x00 do 0x7F w przypadku kodowania UTF-8 (o czym piszę więcej w dalszej części rozdziału). [85] Znane również pod nazwą „UCS-4”. Historycznie UTF-32 i UCS-4 różniły się nieznacznie w definicji – UTF-32 ma zdefiniowany zakres od 0 do 0x10FFFF, natomiast UCS-4 był traktowany jako 31-bitowa liczba naturalna o pełnym zakresie wynikającym z szerokości pola użytego do zakodowania wartości. Współcześnie UCS-4 został przedefiniowany na zakres UTF-32, w wyniku czego obie nazwy są używane jako synonimy. [86] W przeszłości używano również dwóch innych nazw: FSS-UTF (File System Safe UTF) [10] oraz UTF-2 [11]. [87] Kodowaniem „binarnie bezpiecznym” (binary safe) nazywane są kodowania, których wynikową formę można bez problemów przesłać przez medium, które pozwala jedynie na wykorzystanie znaków drukowalnych z podstawowego zestawu ASCII. [88] W tym kontekście bezstanowość oznacza, że do zdekodowania N-tego znaku nie jest potrzebna żadna wiedza o znakach poprzedzających, a więc podczas kodowania i dekodowania nie jest wyliczany i przechowywany żaden stan pomiędzy znakami; są one od siebie całkowicie niezależne. [89] Funkcje WinAPI występują często w dwóch wersjach – A (np. CreateProcessA) oraz W (np. CreateProcessW). Wersja A (od słowa „ANSI”) przyjmuje ciągi tekstowe zakodowane w ASCII z wykorzystaniem domyślnej strony kodowej, natomiast wersja W (wide-char) przyjmuje ciągi zakodowane
za pomocą UTF-16 Little Endian (a we wcześniejszych wersjach systemów Windows UCS-2 Little Endian). Warianty A najczęściej dokonują jedynie konwersji odpowiednich parametrów na kodowanie UTF-16 i wywołują odpowiadające im funkcje w wersji W. [90] Współczesne implementacje dekoderów UTF-8 odrzucają nieprawidłowo zakodowane znaki. W przeszłości jednak liczne błędy bezpieczeństwa w aplikacjach internetowych były spowodowane akceptowaniem przez dekodery niepoprawnych form znaków. [91] Z technicznego punktu widzenia nie ma żadnego powodu, by w przypadku UTF-8 używać znacznika BOM, ponieważ kodowanie to nie korzysta z wielobajtowych liczb (a jedynie z wielu jednobajtowych). Niemniej jednak znacznik ten jest czasem wykorzystywany do zadeklarowania kodowania pliku. [92] W tym kontekście terminem „terminator” jest określany znak, który oznacza koniec ciągu. W przeszłości używano również innych znaków do oznaczania końca tekstu, np. niektóre API systemu MS-DOS oczekiwały znaku $ na końcu ciągów tekstowych. [93] W wielu przypadkach z dbałością o dokładne kontrolowanie wielkości bufora w praktyce bywa różnie, co może prowadzić do klasycznego błędu bezpieczeństwa, zwanego przepełnieniem bufora (buffer overflow), który w skrajnych wypadkach umożliwia atakującemu wykonanie kodu maszynowego dostarczonego lub wybranego przez siebie, przejmując tym samym kontrolę nad podatną aplikacją lub całym systemem komputerowym. [94] Jedynym miejscem wymagającym synchronizacji jest licznik referencji, używany m.in. do wskazania, kiedy obiekt przestaje być używany i może zostać zwolniony. Liczniki referencji są jednak zazwyczaj prostymi zmiennymi, na których wykonuje się wyłącznie atomowe operacje (o których więcej piszę w rozdziale „Synchronizacja”), a więc nie wymagają one zastosowania muteksów lub innych skomplikowanych mechanizmów. [95] Wadą takiego rozwiązania jest znaczny narzut pamięciowy w pewnych przypadkach. Na przykład, jeśli ciąg bazowy jest bardzo długi, a na podstawie jego podciągu zostanie stworzony kolejny obiekt, to nawet jeśli oryginalny ciąg przestanie być używany, to w praktyce obecność obiektu wykorzystującego jego część uniemożliwia zwolnienie odpowiadającej mu pamięci. Prowadzi to
do nadmiernego zużycia pamięci, którego przyczyna może być trudna do ustalenia. [96] Jeśli wewnętrznie wykorzystane jest kodowanie o zmiennej długości, operacja podmiany znaku może być jednak bardziej kosztowna, gdyż może wymagać przesunięcia fragmentu ciągu znajdującego się za podmienianym znakiem. W przypadku zmiany znaku kodowanego za pomocą mniejszej liczby bajtów na znak kodowany większą liczbą bajtów nieunikniona może być również realokacja pamięci. [97] Jeśli zostanie zaalokowane na przykład o 50 bajtów więcej, niż wynosi wielkość struktury (wyłączając ostatnie pole tablicy), to przyjmuje się, że w tej konkretnej instancji struktury tablica ma wielkość 50 bajtów. Na poziomie pamięci operacyjnej sprowadza się to do nadpisania przez „przepełniające” elementy tablicy nadmiarowej pamięci uzyskanej dzięki alokacji, o której wiadomo, że nic innego z niej nie korzysta. Wzorzec ten od czasów standardu C99 nie wymaga podania wielkości tablicy (np. char ob_size[]) – taki element struktury formalnie nazywany jest „elastycznym polem struktury” (flexible array member). [98] Popularną metodą stosowaną przez implementacje stringów alokujących nadmiarowe bajty jest ograniczenie liczby realokacji przez alokowanie dwukrotności obecnej lub wymaganej wielkości bufora. Alternatywnie, jeśli programista zna końcową wielkość ciągu lub przynajmniej może oszacować rozsądne ograniczenie z góry, może podpowiedzieć implementacji, ile miejsca będzie potrzebne – w przypadku klasy std::string w C++ służy do tego metoda
reserve,
a
java.lang.StringBuilder
w
przypadku oraz
języka
Java
java.lang.StringBuffer
i
klas metoda
ensureCapacity. [99] Doktorat z bezpieczeństwa niskopoziomowego również jest mile widziany podczas korzystania z tego typu. [100] Wyjątkiem jest sytuacja, w której „stary” obiekt zostanie zwolniony, a chwilę później to samo miejsce w pamięci zostanie przydzielone dla „nowego” obiektu. [101] Przynajmniej na poziomie przestrzeni użytkownika (userland lub user mode) – jądro, sterowniki oraz inne aplikacje niskopoziomowe rządzą się własnymi prawami. [102] Zarówno /proc/[pid]/exe, jak i pliki z katalogu /proc/[pid]/fd/ są przezentowane przez system plików jako linki symboliczne. W praktyce jednak
zachowują się bardziej jak linki twarde, tj. umożliwiają dostęp do danych docelowego pliku nawet po jego usunięciu (co nie jest możliwe w przypadku „zwykłych” linków symbolicznych). [103] W przykładowym programie korzystam jedynie z kilku pól struktury PROCESSENTRY32; zachęcam jednak do zapoznania się z jej pełną definicją. [104] Opis stworzenia zrzutu pamięci znajduje się w ramce „Zrzut pamięci procesu (Minidump, core) [VERBOSE]”, patrz rozdział „Format BMP i wstęp do bitmap”. [105] Alternatywnie można z Menu Start wybrać wpis „Visual Studio Command Prompt” lub analogiczny, który spowoduje uruchomienie konsoli i automatyczny start odpowiednich skryptów (wadą tej metody jest ustawienie katalogu roboczego na katalog Visual Studio). [106] Dodam, że wzorzec projektowy, w którym zakłada się, że w danym środowisku może istnieć jednocześnie tylko jeden obiekt danej klasy, nazywany jest singletonem [5]. Jak w przypadku każdego wzorca, warto zapoznać się z jego zaletami i wadami przed zastosowaniem w kodzie produkcyjnym. [107] Z punktu widzenia bezpieczeństwa należy bardzo uważać, przekazując ciągi tekstowe kontrolowane przez użytkownika do funkcji tego typu – istnieje zaskakująco wiele możliwości uruchomienia dodatkowego, nieprzewidzianego przez programistę procesu, co w skrajnych wypadkach może doprowadzić do przejęcia konta użytkownika w systemie przez atakującego [28]. [108] Klucze HKCR/, HKCU/Software/Classes/ HKCU/Software/Microsoft/Windows/CurrentVersion/Explorer/FileExts/.
oraz
[109] Domyślny interpreter poleceń na systemach z rodziny Windows udostępnia polecenie start, które wywołuje wewnętrznie funkcję ShellExecuteEx – można więc posłużyć się nim, by przetestować zachowanie się tej funkcji z różnymi parametrami. Analogiczną funkcjonalność na systemach z rodziny GNU/Linux udostępniają programy gnome-open, gvfs-open, xdq-open, exo-open itp. (jest to zależne od konkretnego środowiska). [110] Właśnie stąd wzięła się nazwa domyślnego wyjściowego pliku wykonywalnego generowanego przez kompilator GCC. [111] Nietrudno zauważyć analogię pomiędzy mechanizmem BINFMT_MISC (szczególnie w przypadku rozszerzeń pliku) a sposobem działania funkcji ShellExecute w WinAPI. Patrząc od strony wysokopoziomowej, jedyną różnicą jest miejsce implementacji danej funkcji – ShellExecute jest częścią
WinAPI, natomiast MISC jest implementowane bezpośrednio przez jądro systemu. [112] Pliki wykonywalne to bardzo złożony i obszerny temat. W niniejszej książce ograniczę się jedynie do uproszczonych, podstawowych konceptów. [113] Na części współczesnych platform, w tym x86, pamięć dzieli się na strony, które zazwyczaj mają po 4 KB (choć technicznie mogą mieć inny rozmiar, pod warunkiem że jest on wspierany przez procesor). Ma to bezpośredni związek z mechanizmem stronicowania (paging), którego opis jednak wykracza poza zakres niniejszej książki. [114] Jeden ze stosowanych współcześnie mechanizmów mitygacji zapobiegających wykorzystaniu podatności bezpieczeństwa. Jego działanie opiera się na założeniu, iż atakującemu dużo trudniej jest doprowadzić do wykonania własnego lub wskazanego przez siebie kodu, jeśli adresy poszczególnych elementów w pamięci są mu nieznane. Technika ASLR polega na dobieraniu adresów poszczególnych regionów pamięci procesu w jak najbardziej losowy i nieprzewidywalny sposób. [115] Z uwagi na swoją fundamentalną rolę podczas uruchamiania i wykonywania programu biblioteka ntdll.dll jest zawsze obecna w przestrzeni procesu, niezależnie od tego, czy została pośrednio lub bezpośrednio zaimportowana przez proces wykonywalny. W pamięci procesu należy również spodziewać się biblioteki kernel32.dll oraz – w przypadku systemów Windows 7 i nowszych – kernelbase.dll. [116] Interfejs debuggera w systemach z rodziny Windows został w wyczerpujący sposób opisany w języku polskim w serii artykułów „Jak napisać własny debugger w systemie Windows” autorstwa Mateusza Jurczyka, opublikowanej na łamach magazynu „Programista” (numery 21-24) [29]. [117] Tylko w przypadku użycia systemowego API do wczytania biblioteki. W przypadku gdy biblioteka zostałaby wczytana manualnie (tj. program emulowałby działanie loadera), zdarzenie oczywiście nie zostałoby wygenerowane. [118] W przypadku x86 najczęściej jest to instrukcja int3 tłumaczona na bajt 0xCC, która powoduje wygenerowanie przerwania nr 3, czyli Breakpoint Exception (#BP). Podobny, choć bardziej wysokopoziomowy mechanizm można spotkać w implementacji JavaScript w niektórych przeglądarkach, np. Google Chrome –
istnieje w nich wyrażenie debugger, które w momencie wykonania powoduje przejście do debuggera JavaScript. [119] Jest to sposób używany najczęściej w przypadku interfejsu debuggera w maszynach wirtualnych, w których sprawdzenie adresu wykonywanej instrukcji jest stosunkowo bezproblemowe. Niemniej jednak np. architektura x86 również dysponuje podobnym mechanizmem w postaci czterech rejestrów debuggera (DR). [120] Celowo użyłem przyimka „o” zamiast „z”, ponieważ biblioteka dynamiczna nie jest konsolidowana z plikiem wynikowym, jak ma to miejsce w przypadku linkowania z „normalnymi” bibliotekami. Zamiast tego dodawane są jedynie odpowiednie wpisy do tablicy importów pliku wynikowego. [121] Tak zwany affinity mask, czyli maska bitowa, która informuje system, z których logicznych procesorów może korzystać dany wątek lub proces. Domyślnie zarówno proces, jak i wątki mogą korzystać ze wszystkich procesorów, co w praktyce oznacza, że dany wątek po każdym wywłaszczeniu i wznowieniu wykonania prawdopodobnie będzie wykonywany przez inny logiczny procesor. Przynależność można ustawić za pomocą polecenia taskset (GNU/Linux), start z parametrem /affinity (Windows CMD) lub za pomocą odpowiednich funkcji systemowego API, np. sched_setaffinity (GNU/Linux) lub SetThreadAffinityMask (Windows). [122] Technicznie zostało to rozwiązane za pomocą systemu sygnałów. W przypadku niektórych sygnałów (np. SIGKILL) dany proces jest od razu zabijany. W innych przypadkach proces ma okazję obsłużyć sygnał bez zakończenia działania (np. SIGHUP jest czasem używany do zasygnalizowania procesowi, by przeładował pliki konfiguracyjne). Niektóre błędy na poziomie procesora (np. dzielenie przez zero) są tłumaczone przez jądro Linux na odpowiadające im sygnały (np. SIGFPE). Do wysyłania sygnałów do procesów służy funkcja kill (sic!) lub polecenie powłoki o tej samej nazwie. [123] Należy zaznaczyć, że część błędów tego typu dotyczy również aplikacji jednowątkowych. O szczegółach piszę w dalszej części rozdziału. [124] W znacznym uproszczeniu. Dokładny opis przełączania wątków i sposoby działania planisty systemowego wykracza poza zakres niniejszej książki. [125] Jest to tylko umowny podział – z punktu widzenia programowania nie trzeba sygnalizować, jakiego typu wątek chcemy stworzyć.
[126] Typową programistyczną metodą rezygnacji z reszty przydzielonego kwantu czasu jest wywołanie tego rodzaju funkcji z argumentem 0, np. Sleep(0) – operacja ta jest nazywana poddaniem wątku (yield). W niektórych językach istnieje dedykowana metoda poddająca wątek – przykładem może być Java i metoda Thread.yield lub WinAPI i funkcja SwitchToThread. Poddanie wątku można interpretować również jako polecenie do planisty systemowego: „wywłaszcz wątek teraz, ale wróć do niego jak najszybciej”. [127] Stuprocentowe zużycie procesora jest pokazywane również w przypadku poddania wątku za pomocą Sleep(0) lub analogicznej metody, niemniej jednak nawet w przypadku dużej ilości tego typu wątków system nadal pozostanie responsywny z punktu widzenia użytkownika. [128] Wzorzec, w którym w ramach aktywnej pętli realizowane jest oczekiwanie na dane, nazywany jest aktywnym oczekiwaniem (busy wait lub spinning). [129] Zarejestrowane podczas pisania niniejszej książki, a więc system był w aktywnym użyciu. [130] Konkretniej struktura TEB mieści się na początku pamięci opisanej przez segment, którego selektor znajduje się w rejestrze segmentowym GS. Inaczej mówiąc, TEB można odnaleźć pod adresem GS:0. [131] Portowaniem nazywany jest proces przerabiania programu w sposób umożliwiający mu działanie na innej platformie. [132] Na przykład moduł optymalizatora mógłby uznać sprawdzenie wartości lokalnej zmiennej za nieistotne (w końcu i tak nie ulega ona zmianie w pętli, więc teoretycznie warunek nie ma nigdy szansy na spełnienie) i całkowicie usunąć je z kodu wynikowego programu. [133] Nazywany „oknem czasu” (time window) lub „oknem możliwości” (window of opportunity). [134] Pod warunkiem że zna jego nazwę – współcześnie katalog C:\Windows\Temp wymaga uprawnień administratora, by wylistować jego zawartość. [135] W rejestrze systemu Windows pod ścieżką HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\KnownDLLs znajduje się lista bibliotek dynamicznych, które w razie ładowania biblioteki dynamicznej o wskazanej nazwie powinny być wyszukiwane najpierw w katalogu systemu (C:\Windows\System32). Biblioteki DLL nieznajdujące się na tej liście są wyszukiwane najpierw w katalogu aplikacji.
[136] Technika ta nazywana jest DLL spoofing lub DLL hijacking. [137] Standardy języków C i C++ pozwalają kompilatorom przekształcać matematyczne wyrażenia wedle uznania, pod warunkiem że ostateczny wynik wyrażenia nie ulegnie zmianie. Przykładem może być przekształcenie wyrażenia (a + b) + c w formę a + (b + c) lub przekształcenie wyrażenia a / 7 w (((a * -1840700269LL) >> 32) + a) >> 2 (sic!) dla zmiennych a, b i c będących (dodatnimi w drugim przypadku) 32-bitowymi zmiennymi typu int [6]. [138] Instrukcją serializującą nazywana jest dowolna instrukcja, która powoduje wstrzymanie wykonania aż do momentu pełnego zakończenia wszystkich wcześniejszych instrukcji. Przykładem takich instrukcji na x86 są cpuid oraz rdtscp (read time-stamp counter and processor ID). [139] Prefiks lock oznacza instrukcję blokującą. W przypadku platformy x86 można stosować go jedynie z niektórymi instrukcjami, takimi jak: add, adc, and, btc, btr, bts, cmpxchg, cmpxch8b, dec, inc, neg, not, or, sbb, sub, xor, xadd oraz xchg [7]. [140] W szczególności moduł optymalizatora mógłby uznać, że nie warto ciągle pobierać wartości zmiennej z pamięci – lepiej pobrać ją raz przed pętlą, a potem operować na rejestrze (optymalizatory niektórych języków mogą, ale nie muszą, ignorować istnienie innych wątków). Co więcej, bazując na fakcie, że pętla jest pusta (a więc zmienna nigdy nie zostanie w niej zmodyfikowana), optymalizator mógłby użyć równoważnego przy obranych założeniach zapisu: if (g_poor_mans_lock != THREAD_COUNT) { while(1); } [141] Wydawać by się mogło, że pierwszy przypadek jest istotny również w domenie wysokopoziomowej, należy jednak zwrócić uwagę, iż w praktyce w oprogramowaniu tworzonym na tym poziomie nie zachodzi potrzeba synchronizowania wykonania wątków z dokładnością do kilku cyklów procesora. Co więcej, w trybie użytkownika na większości systemów operacyjnych taka synchronizacja jest niemożliwa – jest to związane z niewidocznym dla programisty działaniem planisty systemowego, który w dowolnym momencie może wywłaszczyć jeden lub kilka oczekujących wątków, o czym wspomniałem również w komentarzu w przykładowym kodzie przy okazji zastosowanego spinlocka.
[142] Niektóre źródła podają, że blokada (lock) ma zasięg ograniczony do danego procesu, podczas gdy muteksy mogą być używane również do synchronizowania wielu procesów. W praktyce zależy to od konkretnej implementacji i często muteks i lock są używane jako synonimy. [143] Kod wykonywany pomiędzy zajęciem muteksu (lub innego, podobnego mechanizmu synchronizacji) a jego zwolnieniem jest nazywany sekcją krytyczną. Ponieważ w danym programie różne części kodu mogą korzystać z zasobu, do którego dostęp jest synchronizowany, można powiedzieć, że istnieje wiele powiązanych ze sobą sekcji krytycznych. Inną interpretacją takiej sytuacji jest założenie istnienia jednej, sfragmentowanej, sekcji krytycznej. [144] Należy jednak dodać, że rozważany przykład jest na tyle prosty, iż dodatkowe włączenie przeliczania wartości pseudolosowej do sekcji krytycznej nie spowodowałoby zauważalnej zmiany. [145] Innymi słowy, każdy obiekt ma niejawnie dowiązany muteks, z którego korzysta synchronized. [146] First Come First Served – w przypadku sporu muteksy zajmują blokadę w kolejności podejścia do jej zajęcia. [147] Celowo użyłem przymiotnika „sygnalizujące”, aby odróżnić zdarzenia w kontekście synchronizacji od zdarzeń w kontekście komunikacji. [148] Z drugiej strony wzorzec ten został użyty w implementacji zmiennych warunkowych we wzorcowym interpreterze języka Python. [149] Ewentualnie można wliczyć również czas zasygnalizowania, ale tylko jeśli obiekt będzie w stanie niezasygnalizowanym w momencie próby przejścia blokady przez wątek. Czas ten będzie miał ograniczoną dokładność ze względu na niedeterministyczne działanie planisty systemowego. [150] W przypadku zmiennych warunkowych zamiast „sygnalizacji” stosuje się termin „powiadomienie” (notify). [151] Z punktu widzenia systemu operacyjnego wątek jest wybudzany i zajmuje muteks, identycznie jak w przypadku braku zdefiniowanego warunku, przy czym w sytuacji, gdy zdefiniowany warunek nie jest spełniony, następuje ponowne zwolnienie muteksu i uśpienie wątku. Ostatecznie jest to więc jedynie drobne uproszczenie dla programisty, a nie zmiana na poziomie architektury mechanizmu synchronizującego. [152] Klasa szablonowa w C++, która zajmuje wiele muteksów i jednocześnie wykrywa zakleszczenia.
[153] Jednym z mechanizmów stosowanych, by temu zapobiec, jest zwiększanie priorytetu wątku wraz z upływem czasu (aging) – dzięki temu każdy wątek będzie w stanie zająć blokadę w skończonym czasie. [154] Wykres na podstawie przedstawionego skryptu został wygenerowany programem gnuplot. [155] Implementacja wątków nie była w ogóle dostosowana do współpracy z Hyper-Threading Technology. [156] Najczęściej podzieloną na sektory o wielkości 4096 lub 512 bajtów. [157] A także, czy plik faktycznie znajduje się na fizycznym nośniku w danym komputerze – nie ma przeszkód, by był to dysk sieciowy oparty na SMB [1], NFS [2] czy iSCSI [3]. [158] Niektóre systemy plików (np. NTFS) oferują szyfrowanie lub kompresję wybranych plików; więcej w podrozdziale „Ciekawe mechanizmy systemu plików”. [159] Należy zaznaczyć, iż nie każdy język czy środowisko udostępnia programiście możliwość operowania na systemie plików – np. JavaScript oraz aplety Java wykonywane w kontekście przeglądarki internetowej dysponują bardzo ograniczonymi możliwościami interakcji z dyskiem i plikami. [160] Ostatecznie kopiowanie sprowadza się do otwarcia obu plików, wczytania danych źródłowych (np. w częściach) i zapisania ich do pliku wynikowego, np. [17]. [161] Konkretniej java.nio.file.Files.copy. Analogicznie w dalszej części tabeli. [162] W systemach z rodziny GNU/Linux wymaga, by pliki źródłowe i docelowa ścieżka wskazywały na tę samą partycję. [163] Standard C definiuje rename jako funkcję, która służy do zmiany nazwy pliku. Zmiana nazwy katalogu lub przeniesienie pliku bądź katalogu wykracza więc poza ramy standardu (w praktyce rename w rozszerzonej formie jest zdefiniowane w specyfikacji POSIX) – stąd umieszczenie rename w tej, a nie innej kolumnie. [164] Która ponadto może zostać zmodyfikowana w trakcie odczytu, tj. do katalogu może zostać dodany nowy plik lub podkatalog. [165] Dla uściślenia: przykładem oznaczenia dysku logicznego jest np. C: (czyli „dysk C”).
[166] Sam koncept oznaczania dysków literami pojawił się w systemie CP/CMS w późnych latach 60. ubiegłego wieku i został zaadoptowany m.in. w CP/M, MSDOS, a ostatecznie również w systemach z rodziny Windows czy OS/2 [18]. [167] Mechanizm ten był bardzo zbliżony do typowego dla systemów unixowych montowania dysków i partycji pod wskazanymi ścieżkami. [168] Polecenie SUBST nadal istnieje i funkcjonuje poprawnie we współczesnych systemach z rodziny Windows. [169] Ich dostępność zależy od wersji systemu. [170] W przypadku danego katalogu prawo to może zostać ograniczone do tworzenia nowych plików (ale już nie do usuwania lub zmiany nazwy istniejących, których dany użytkownik nie jest właścicielem) – patrz bit 9 (restricted deletion flag). [171] W przypadku plików niewykonywalnych oraz katalogów bit SUID jest ignorowany, choć na systemach unixowych innych niż GNU/Linux może być inaczej [19]. [172] Jest to jedno z bardzo niewielu miejsc w informatyce, w którym system ósemkowy jest nadal wykorzystywany. [173] Historycznie plik ten zawierał również hasła użytkowników (najczęściej w formie wyniku funkcji skrótu z hasła i tzw. soli). Obecnie hasła umieszczone są w pliku /etc/shadow, do którego dostęp ma jedynie root. [174] Na przykład inny, złośliwy, użytkownik mógłby podstawić plik o takiej samej nazwie i nadać wszystkim prawa do zapisu (przez co on sam mógłby również kontrolować jego zawartość). Ewentualnie podstawiony mógłby zostać link symboliczny, który prowadziłby do innego istotnego pliku w systemie. W przypadku zapisu plików do katalogów publicznych (jakim jest /tmp) niezadbanie o ochronę przed wymienionymi scenariuszami może mieć negatywne konsekwencje związane z bezpieczeństwem systemu i w efekcie doprowadzić do eskalacji uprawnień złośliwego użytkownika. [175] W przypadku gdy nie są dostępne domyślne, może wystąpić konieczność instalacji odpowiedniej paczki (np. acl w przypadku Ubuntu), podmontowanie partycji z odpowiednimi parametrami lub rekompilacja jądra z włączonymi opcjami CONFIG_*_POSIX_ACL [6][7]. [176] W przypadku systemu Windows dotyczy to nie tylko plików i katalogów, ale i innych obiektów jądra, np. obiektu procesu, wątku, muteksu itp.
[177] Tematyka logowania dostępu do obiektów systemu i reagowania na zdarzenia z tym związane wykracza poza ramy niniejszej książki. Więcej informacji na ten temat można znaleźć np. w oficjalnej dokumentacji systemu [12]. [178] Użycie funkcji z grupy WaitFor* z uchwytem do pliku lub katalogu ma sens w przypadku operacji asynchronicznych (o których piszę w dalszej części rozdziału) – jest to jednak odradzane [13], jak i niepotrzebne z uwagi na dostępność innych mechanizmów. [179] Na marginesie zaznaczę jedynie, iż prawa oznaczone OI (Object Inherit) są dziedziczone przez pliki, a CI (Container Inherit) przez podkatalogi. [180] W pewnym uproszczeniu można o mechanizmie blokowania dostępu do pliku myśleć jako o wbudowanym muteksie, który ma synchronizować dostęp do, bądź co bądź, dzielonego zasobu, którym może być plik. W zależności od systemu operacyjnego tego rodzaju blokady mogą być typu „miękkiego” (tj. mogą opcjonalnie zostać zignorowane) lub „twardego” (ich respektowanie jest wymuszane przez jądro systemu). [181] W niektórych wypadkach występują dwa oddzielne kursory do operacji odczytu oraz zapisu. [182] W zależności od konkretnego API operacja truncate może wymagać jedynie nazwy pliku (a nie jego uchwytu). [183] Podobnie jak w przypadku truncate, dane API może nie korzystać z uchwytu pliku – w takiej sytuacji wielkość pliku można określić, przesuwając kursor na jego koniec i odczytując jego pozycję, choć działa to jedynie w wypadku plików otwartych w trybie binarnym (do czego wrócimy w następnym rozdziale). [184] W pewnym, bardzo dużym uproszczeniu można o nim myśleć jak o „głowicy” na dysku twardym, choć w praktyce jest to koncept przede wszystkim programowy. [185] Powrót kursora na początek pliku jest oczywiście równoznaczny z jego przesunięciem na offset 0. [186] API zazwyczaj posiadają funkcję, w której nazwie znajduje się właśnie to słowo. Patrz również tabela 4. [187] Co istotne, jeśli proces zostanie zabity przez system (np. z powodu krytycznego, nieobsłużonego wyjątku), bufor może (ale nie musi) być opróżniony (w przypadku niskopoziomowych błędów raczej nie będzie). Co więcej, w przypadku wyjścia z procesu, korzystając z niskopoziomowych
funkcji (np. wywołania systemowego sys_exit), procedury opróżnienia otwartych buforów również nie zostaną wywołane, a więc dane nie zostaną przesłane do jądra systemu. [188] Lub odłączenia urządzenia bez skorzystania z opcji „bezpiecznego usuwania sprzętu”. [189] Przy każdym przykładzie starałem się podać przynajmniej słowa kluczowe (nazwy funkcji lub poleceń konsolowych), które wskażą czytelnikowi, w którym kierunku szukać informacji na dany temat. Co więcej, w przypadku poleceń często można skorzystać ze wspomnianych wcześniej narzędzi strace oraz Process Monitor, aby sprawdzić, jakie funkcje operujące na systemie plików są wywoływane (i z jakimi parametrami). [190] Konkretniej mogą je tworzyć użytkownicy posiadający uprawnienie SeCreateSymbolicLinkPrivilege. [191] Historycznie mechanizm ten był nawet tak implementowany. Obecnie informacja o ścieżce docelowej jest przechowywana w tablicy plików, a nie w danych pseudopliku. [192] Co więcej, poprawnie jest myśleć o każdym „normalnym” pliku jako o twardym linku do danych na dysku. [193] Niektóre źródła wskazują również, iż odczyt mniejszej ilości danych z wolnego dysku oraz ich dekompresja są całościowo szybsze niż wczytanie tych samych danych w wersji nieskompresowanej, zajmującej więcej miejsca na dysku. Jednocześnie powoduje to oczywiście większe zużycie procesora przez dany wątek. [194] Chodzi o funkcje ZwSetEaFile oraz ZwQueryEaFile obecne w pliku ntdll.dll. [195] Z dokładnością do kodowania znaków, o czym wspominałem w rozdziale „Znaki i łańcuchy znaków”. [196] Nazywanym czasem w skrócie EOL lub EOLN (end of line). [197] Kolejność CR LF wynikała z tego, że powrót głowicy na początek linii był powolny (tj. wolniejszy nawet niż ówczesny przesył informacji z komputera do terminalu) – opłacało się więc wysłać najpierw polecenie powrotu głowicy, by ta zaczęła jak najszybciej swoją wędrówkę, a dopiero w drugiej kolejności polecenie przejścia do nowej linii, które było znacznie szybszą operacją. Z uwagi na powolność przesuwu głowicy w niektórych wypadkach mimo wszystko wymagane było odczekanie kolejnej chwili, zanim głowica dotarła na początek linii, tj. przesłanie od razu kolejnych znaków do wydruku nie dałoby
spodziewanego efektu. Przerwa ta była realizowana m.in. za pomocą wysłania kilku pustych (NUL) znaków, których jedynym celem było zajęcie medium przesyłowego na krótką chwilę, tak by kolejne dane trafiły do drukarki, dopiero gdy ta będzie gotowa [7]. [198] Warto zwrócić uwagę na fakt, że niskopoziomowe API udostępniane przez system operacyjny potrafią jedynie pobrać konkretną liczbę bajtów, a więc wyszukiwanie sekwencji EOLN jest realizowane w ramach implementacji środowiska wykonania lub wykorzystywanej biblioteki (patrz również ćwiczenie „FILE:bin-can-text”). [199] Inny przykład parsowania tekstu można znaleźć w rozdziale „Komunikacja sieciowa” we fragmencie dotyczącym implementacji serwera HTTP. [200] Przesunięcie to pozycja (podawana najczęściej w bajtach) pierwszego bajtu danego elementu względem punktu odniesienia, którym najczęściej jest początek pliku. [201] Bajty mogą nadal zawierać nienaruszone dane, ale odwołania do nich z innych struktur w pliku mogły zostać usunięte (nadpisane, wyzerowane), przez co w praktyce dane te nigdy nie zostaną przetworzone przez program. [202] Z punktu widzenia bezpieczeństwa jest to niestety duża wada modułu Pickle, gdyż deserializacja niezaufanego ciągu może prowadzić bezpośrednio do wykonania kodu w języku Python dostarczonego przez atakującego [4]. Podobne problemy z bezpieczeństwem dotyczą analogicznych mechanizmów w języku PHP [5]. [203] Niektóre menedżery plików (np. Total Commander) posiadają możliwość wyświetlenia zawartości pliku w formacie zbliżonym do prezentowanego przez hexedytory. [204] O innych podejściach wspominam w ramce „Red Green Blue [VERBOSE]” w podrozdziale „Przegląd popularnych formatów pikseli”. [205] W przypadku plików graficznych termin „raw” ma jeszcze jedno znaczenie, związane z fotografią – a konkretniej z aparatami cyfrowymi. Mianowicie zdecydowana większość aparatów cyfrowych ma możliwość zapisu zdjęć również (obok JPEG, który jest niejako standardem) w tzw. raw camera format, co oznacza, że dane zdjęcie jest zapisane bezstratnie (tj. bez stratnej kompresji oraz zawierające wszystkie dane oraz metadane, które aparat może zapisać) w formacie specyficznym dla danego producenta (np. CR2 lub NEF). A więc plik
graficzny w formacie „raw” będzie dla fotografa i programisty oznaczać dwie zupełnie różne rzeczy. [206] Czytelników zainteresowanych tematem automatycznego wykrywania parametrów surowej bitmapy zachęcam do lektury artykułów [21] oraz [22]. [207] Czytelników zainteresowanych tematem automatycznego wykrywania parametrów surowej bitmapy zachęcam do lektury artykułów [21] oraz [22]. [208] Alternatywnie można o tym myśleć jako o zestawie kroków, które należy wykonać, aby otrzymać wynikowy obraz, np. „narysuj koło o takim rozmiarze na tej pozycji, następnie narysuj gwiazdę o takim rozmiarze na takiej pozycji” itd. [209] Jako ciekawostkę dodam, że format BMP jest używany jako tego typu struktura w przypadku niektórych funkcji WinAPI i GDI (Graphics Device Interface), przy czym w takim wypadku stosuje się nazwę DIB (Device Independent Bitmap). Przykładową funkcją operującą na DIB-ach może być StretchDIBits. [210] Celowo tak dobrałem szerokości bitmap, by ilość pikseli dzieliła się przez szerokość bez reszty. W przeciwnym przypadku zabrakłoby danych, by wypełnić całkowicie ostatni wiersz bitmapy. [211] Piksele pochodzą z monitora LCD DELL P2411H. Fotografia została wykonana aparatem Nikon D800E z obiektywem AF-S VR Micro-Nikkor 105mm f/2.8G IF-ED. [212] Co ciekawe, „K” pochodzi podobno od słowa „Key”, a nie „blacK” [23]. [213] Oczywiście nic nie stoi na przeszkodzie, żeby użyć 32-bitowego formatu jako „4 bajty per piksel”, a nie „jeden uint32_t per piksel” – w takiej sytuacji podejście jest identyczne jak w przypadku 24-bitowego formatu. [214] Szczególnie jeśli biblioteka wykorzystuje tzw. double buffering, czyli dwa framebuffery dla danego okna, z których jeden jest wykorzystywany do odmalowywania okna, a na drugi w tym czasie nanosi się zmiany. [215] O ile pliki JPEG, a w zasadzie ich najczęściej używany wariant – JFIF (JPEG File Interchange Format), nie posiadają specjalnie wydzielonego magic'a, o tyle wymaga się, by plik rozpoczynał się od tzw. markera SOI (Start Of Image), składającego się z dwóch bajtów – FF D8. A zatem nawet mimo braku formalnego identyfikatora formatu można rozpoznać plik JPEG właśnie po tych dwóch bajtach (choć oczywiście może się zdarzyć, że pliki w innych formatach również będę miały bajty FF D8 na początku). Niektóre programy
do obsługi plików graficznych (np. IrfanView) dopuszczają pliki JPEG zaczynające się od innych markerów (np. FF FE, czyli marker komentarza). [216] ZIP jest jednym z niewielu formatów, w których „nagłówek”, od którego zaczyna się parsing, znajduje się na końcu pliku. [217] W przypadku użycia BI_BITFIELDS format BMP pozwala na dodatkowe zdefiniowanie masek bitowych dla poszczególnych barw. Poza tym niewiele różni się od BI_RGB dla bpp 24 i 32. [218] Na pierwszy rzut oka mogłoby się wydawać, że opcje BI_JPEG i BI_PNG pozwalają na użycie kompresji z tychże formatów w plikach BMP. Niestety oficjalna dokumentacja mówi, że opcje te służą jedynie umożliwieniu przekazania plików graficznych typu JPEG i PNG drukarkom podczas definiowania dokumentu do wydrukowania, korzystając z niektórych funkcji WinAPI [16]. [219] jw. [220] W przypadku języków wysokopoziomowych typu Java czy Python nie robi to różnicy (w najgorszym wypadku zostanie rzucony wyjątek, który nie zostanie obsłużony), natomiast w językach takich jak C lub C++ zaalokowanie zbyt małego obszaru pamięci, a następnie próba zapisu do otrzymanego bufora zbyt dużej ilości informacji mogą skończyć się zaburzeniami stabilności i/lub bezpieczeństwa aplikacji (patrz buffer overflow [24]). [221] Początkowo chciałem napisać, że „maski i paleta kolorów są opcjonalne”, ale nie byłoby to poprawne stwierdzenie. Cała sprawa rozbija się o to, że w przypadku odpowiednich opcji kodowania maski i/lub paleta musi się obowiązkowo pojawić, a w przypadku pozostałych opcji kodowania maski i/lub paleta powinny być nieobecne. Słowo „opcjonalne” wskazywałoby raczej na dowolność w pojawieniu się tych części (podobnie jest w przypadku opcjonalnych argumentów w niektórych językach programowania), co oczywiście nie byłoby prawdą. [222] Należy zaznaczyć, że zdarzają się lepsze i gorsze parsery (szczególnie z punktu widzenia bezpieczeństwa), ale zazwyczaj są one dobrze przetestowane na typowych, prawidłowych plikach w danym formacie. [223] RAII (Resource Acquisition Is Initialization) – wzorzec projektowy, w którym utworzenie (przeważnie lokalnego) obiektu jest jednoznaczne z zajęciem pewnego zasobu (najczęściej otwarciem pliku lub zajęciem muteksu); zasób jest zwalniany w momencie zniszczenia obiektu (w przypadku lokalnego obiektu –
gdy obiekt ten wyjdzie poza blok kodu, w którym istnieje). W opisywanym przypadku dzięki użyciu mechanizmu obsługi plików zgodnego z RAII nie trzeba by pamiętać o zamknięciu uchwytu do pliku w każdej gałęzi kodu wychodzącej z funkcji – zamknięciem pliku zająłby się destruktor obiektu. [224] W przypadku niektórych platform i kart/układów graficznych paletę kolorów można było zmieniać również w trakcie wyświetlania klatki na monitorze CRT, np. na końcu wiersza (określanego w tym przypadku terminem „scanline”) – w takim wypadku kolejny wiersz był wyświetlany z wykorzystaniem innej palety kolorów. Dzięki temu możliwe było wyświetlenie na ekranie większej liczby kolorów, niż korzystając jedynie ze stałej (w danej klatce) palety. Oczywiście proceder zmiany koloru należało powtarzać co klatkę. Trik ten był szczególnie popularny na platformach z lat 80. ubiegłego wieku; patrz również [20]. [225] Na przykład format plików wykonywalnych PE korzysta przede wszystkim z mechanizmu „wskaźników”. [226] Identyczne podejście stosuje się w przypadku komunikacji opartej na pakietach/wiadomościach korzystających z protokołów strumieniujących do samego transportu danych – przykładem może być WebSocket oparty o TCP. [227] Format PNG został stworzony w 1996 r., kiedy natknięcie się na 7-bitowe medium było wciąż prawdopodobne. Obecnie nie jest to możliwe. [228] Zależy to również od konkretnego języka, a także użytego kodowania oraz – w przypadku rozszerzonego ASCII – strony kodowej. [229] Jako ciekawostkę dodam, że naprawienie tak uszkodzonego pliku było jednym z zadań na zawodach Security Capture The Flag – Plaid CTF 2015 (PNG Uncorrupt) [1]. [230] Konkretniej PNG korzysta z najpopularniejszego wariantu CRC32 używającego wielomianu x 32+x 26+x 23+x 22+x 16+x 12+x 11+x 10+x 8+x 7+x 5+x 4+x 2+x+1 [2]. [231] Na kolor (nie „na piksel”), a więc w celu otrzymania liczby na piksel, należy przemnożyć wartość wejściową przez liczbę kolorów wyrażających piksel. [232] Autorstwa Johna Walkera, z modyfikacjami Wesleya J. Landakera. Program jest często dostępny w systemach z rodziny GNU/Linux. [233] Autorką zdjęcia jest Arashi Coldwind.
[234] Co zaskakujące, w systemie Windows nazwane potoki służą również do komunikacji pomiędzy różnymi komputerami, pośrednio korzystając z protokołu SMB. [235] Alternatywnym określeniem opóźnienia, stosowanym przede wszystkim w kontekście aplikacji czasu rzeczywistego (np. komunikatorów internetowych czy gier sieciowych), jest „lag”. Według istotnej części środowiska graczy to właśnie lag, a nie brak umiejętności, jest najczęstszym powodem przegranych w grach sieciowych. :) [236] Konkretniej wykorzystuje się protokoły TCP (Transmission Control Protocol), UDP (User Datagram Protocol), IPv4 (Internet Protocol version 4) oraz IPv6 leżące na niższych warstwach, ale z punktu widzenia programisty są udostępnione w postaci wysokopoziomowych gniazd, i w praktyce nie ma się do czynienia z samymi protokołami. [237] Wynika to poniekąd z konstrukcji dostępnych mechanizmów komunikacji, z których większość wymaga, by jedna strona była „tą główną”, z którą pozostałe strony się łączą. W praktyce w niczym to nie przeszkadza, ponieważ bezpośrednio po nawiązaniu połączenia strony mogą np. przejść do prostego modelu peer-to-peer, w którym obie strony są równorzędne. [238] Angielskie wyrażenie „peer” w kontekście sieci tłumaczy się jako „równorzędną stronę”; poza tym kontekstem oznacza osobę o równej randze. [239] Ponownie: wyjątkiem są nazwane potoki w systemach z rodziny Windows, które można przestawić w tryb pakietowy. [240] Dotyczy sieci opartej na IPv4. W sieci IPv6 jest to możliwe z uwagi na 32bitowe pole mówiące o wielkości pakietu w nagłówku IPv6. [241] W praktyce stosuje się dużo mniejsze pakiety, zazwyczaj całościowo (tj. łącznie z nagłówkiem IPv4) nieprzekraczające 1500 bajtów, co wynika z maksymalnej wielkości ramki (Maximum Transmission Unit, MTU) przesyłanej w większości współczesnych sieci. Wielkość stosowanego MTU można sprawdzić za pomocą poleceń ifconfig | grep MTU (GNU/Linux) oraz netsh interface ipv4 show subinterfaces (Windows) – zazwyczaj jest ona wyraźnie większa w przypadku pseudointerfejsów lokalnych. [242] Choć w zależności od wielkości „paczek” danych równie dobrze może zostać odebrany tylko fragment pierwszej, lub cała pierwsza paczka, i niewielki kawałek drugiej, a także dowolny inny wariant.
[243] Kod przetwarzający dane, które są dostępne w całości, jest z reguły znacznie prostszy od implementacji, która z jednej strony stara się parsować dane, a z drugiej musi dokładnie kontrolować, ile z nich jest dostępnych i w razie ich braku poprosić o kolejną porcję. [244] Z tego też powodu w niektórych przypadkach stosuje się odwrotną „emulację”, tj. korzystanie z protokołu opartego na pakietach do przesyłania danych strumieniowo, np. używając w tym celu odpowiednio małych pakietów o stałej wielkości. [245] Dane z odrzuconego pakietu nie zostaną ponownie wysłane i nigdy nie dotrą do odbiorcy – w przypadku UDP należy się liczyć z taką możliwością. [246] TCP/IP to skrótowa nazwa tzw. zestawu protokołów internetowych (Internet Protocol Suite), w którego skład wchodzą m.in. TCP, UDP, ICMP, IP, ale też HTTP czy np. Ethernet [6]. [247] Przywołując przykład anegdotyczny, zdarzyło mi się [4], że po ściągnięciu pliku o wielkości 300 MB ze strony WWW (HTTP/TCP), korzystając z Internetu mobilnego z bardzo słabym sygnałem (przez co pobieranie trwało około godziny), plik okazał się być uszkodzony. Po analizie porównawczej z tym samym plikiem ściągniętym na zdalnym serwerze o stabilnym łączu (co dla porównania trwało około 5 sekund) okazało się, że pobrany plik różni się od pierwowzoru w ponad 400 różnych miejscach, a więc ponad 400 różnych pakietów TCP uległo zmianie w taki sposób, że ostateczna suma kontrolna zgadzała się z wartością pola Checksum. Aby doszło do takich uszkodzeń, liczba ponawianych retransmisji pojedynczych pakietów TCP musiała być ogromna. [248] W przypadku TLS wykrycie nieprawidłowych danych zazwyczaj jest błędem krytycznym, który powoduje zamknięcie połączenia. TLS nie zapewnia retransmisji danych. [249] Również administrator systemu może to robić, korzystając z zewnętrznej lub wbudowanej w system zapory sieciowej (firewall). [250] Nazywane czasem końcówkami „zapisu” i „odczytu” lub końcówką „wyjściową” i „wejściową”. [251] W systemach z rodziny Windows nie jest to prawdą – wrócę do tej kwestii przy okazji omawiania mechanizmu nazwanych potoków. [252] Z programistycznego punktu widzenia nie stanowi to żadnego problemu, co można szybko sprawdzić, wpisując glob.glob(r"\\.\pipe\*")
w interpreterze języka Python. [253] Większość komunikacji sieciowej oraz międzyprocesowej opiera się na dokładnie zdefiniowanych protokołach, opartych często na systemie binarnych pakietów (przypominających bloki z plików binarnych). Podejście to będzie widoczne również w pozostałej części tego oraz następnego rozdziału. [254] Dostępność trybów zależy od systemu operacyjnego i wersji jądra. [255] Choć w przypadku systemu Windows możliwe jest również stworzenie anonimowych obiektów pamięci współdzielonej. [256] W uproszczeniu termin „sesja” w przypadku systemów z rodziny Windows odnosi się do pojedynczej sesji zalogowanego użytkownika [4]. Począwszy od Windows Vista, w systemie domyślnie istnieją dwie sesje: użytkownika (korzystającego z komputera lokalnie lub połączonego zdalnie) oraz serwisów – separacja ta ma związek z bezpieczeństwem, a komunikacja pomiędzy sesjami jest bardzo ograniczona. [257] Wyjątkiem są nienazwane obiekty sekcji pod systemem Windows [6]. [258] „Okna” w przypadku WinAPI oznaczają zarówno faktyczne okna aplikacji w rozumieniu interfejsu użytkownika, jak i wszystkie kontrolki, z których dane okno jest zbudowane. Niejako na marginesie dodam, że pełnoprawne okna mogą również być ukryte, tj. niewidoczne dla użytkownika, co ma znaczenie w kontekście przykładowego kodu. [259] W praktyce uruchomieniem procedury obsługującej okno zajmują się funkcje GetMessage oraz PeekMessage, które od razu przekazują do niej wiadomość, tym samym nie kopiując jej do wskazanej przez programistę przy wywołaniu instancji struktury MSG. [260] Osoby zainteresowane tematem bezpieczeństwa lub samymi sieciami zachęcam również do zainteresowania się we własnym zakresie przynajmniej warstwą drugą, w której znajdują się m.in. protokoły z rodziny Ethernet. [261] W praktyce urządzenia typu switch starają się zapamiętać, które fizyczne połączenia prowadzą do jakich adresów fizycznych, i przesyłać pakiety jedynie do interfejsów, do których może być podłączony potencjalny odbiorca. Tym samym, jeśli switch nie rozpozna adresata otrzymanego pakietu, pakiet zostanie wysłany na wszystkie interfejsy z wyłączeniem interfejsu nadawcy. Profesjonalne modele przełączników sieciowych pozwalają również na stałe przypisać konkretne interfejsy przełącznika do określonych adresów
fizycznych, dzięki czemu potencjalnie poufne pakiety nie są rozsyłane po całej sieci lokalnej. [262] W praktyce możliwe jest stworzenie tzw. programowego mostu warstwy drugiej (bridge), w ramach którego system operacyjny komputera PC 1 będzie przesyłał pakiety warstwy drugiej otrzymane z jednego interfejsu na drugi interfejs, ale na potrzeby dyskusji załóżmy, iż taki most nie został skonfigurowany. [263] Maska sieciowa składa się z wybranej wielkości ciągłego bloku zapalonych wiodących bitów, po których następuje ciągły blok dopełniających zer. Poprawną maską jest zatem 0xFFFF0000 czy 0x80000000, ale nie 0x01010101. [264] W praktyce oznacza to, że adres IP sieci oraz maska będą miały wyzerowane dokładnie te same bity. [265] VPN to wirtualne, często zewnętrznie szyfrowane, sieci lokalne korzystające z Internetu do faktycznego transportu danych. [266] Jeszcze więcej radości sprawiają programy i serwisy wizualizujące trasę pakietu na mapie świata (przykładem może być Open Visual Traceroute). [267] Oryginalnie RFC były niewielkimi, technicznymi publikacjami, których głównym celem było zebranie opinii na przedstawiony temat. Z czasem RFC stały się de facto specyfikacjami protokołów sieciowych i niektórych innych mechanizmów stosowanych w informatyce [2]. [268] Należy dodać, że użycie ramek skopiowanych bezpośrednio z RFC nie jest przypadkowe i ma na celu łagodne oswojenie czytelnika ze specyfiką stylu tego typu dokumentów. [269] Należy jednak wskazać, że porty UDP oraz TCP są zupełnie rozłącznymi grupami portów, które obowiązują jedynie w obrębie danego protokołu (a więc np. porty 80 UDP i 80 TCP nie są ze sobą w żaden sposób powiązane). [270] Pewnym wyjątkiem są porty z przedziału 0–1023, do których użycia w niektórych systemach operacyjnych wymagane są uprawnienia administratora. [271] Tym samym nie można jednoznacznie poprowadzić implikacji „jeśli dany komputer nasłuchuje na porcie 80 TCP, to działa na nim serwer HTTP” (choć w praktyce z bardzo dużym prawdopodobieństwem tak właśnie jest). [272] Tłumaczone na „trójstopniowe uzgadnianie” lub bardziej kolokwialnie „trzykrotny uścisk dłoni”.
[273] W ramach ciekawostki dodam, że w niektórych wypadkach możliwe jest użycie również czterech (four-way handshake) lub pięciu pakietów (five-way handshake) [4]. Alternatywnie obie strony mogą próbować nawiązać połączenie równocześnie, bez użycia gniazda nasłuchującego [5]. [274] Pakiet może oczywiście również nie zawierać żadnych danych i nadal być w pełni poprawnym pakietem UDP. [275] Istnieje wiele implementacji i portów programu netcat, jednak sposób ich użycia jest bardzo podobny. [276] Jak wspomniałem w poprzednim rozdziale, dokładnie ten sam schemat ma również zastosowanie w przypadku nazwanych gniazd domeny UNIX (choć na poziomie implementacji jest kilka drobnych różnic, np. adresem w gniazdach domeny UNIX jest nazwa pliku, a nie adres IP i numer portu). [277] W przypadku kompilatora MinGW GCC sprowadza się to do dodania flagi lws2_32 podczas kompilacji. Jeśli korzystamy natomiast z Microsoft Visual C++, można w kodzie dodać linię #pragma comment (lib, "Ws2_32.lib"). [278] Domyślnie dotyczy to jedynie systemów z rodziny Windows. Aby wyświetlić na nich zawartość pamięci podręcznej DNS, można posłużyć się poleceniem ipconfig /displaydns. [279] Adres używanego przez system serwera DNS można sprawdzić za pomocą polecenia ipconfig /all (w przypadku Windows – pola DNS Servers) lub odczytując plik /etc/resolv.conf (w przypadku rodziny GNU/Linux – pola nameserver). [280] Czyli takim, na którym właściciel domeny umieścił jej konfigurację, co oznacza, iż serwer ten jest głównym źródłem informacji o danej domenie. [281] Na przykład a.root-server.net, b.root-server.net itd. [282] Pełen spis typów, łącznie ze współczesnymi rozszerzeniami, można znaleźć na stronie IANA [8]. [283] Zgodnie z tym, o czym wspomniałem we wstępie do tej części książki, w przypadku TCP warstwa pakietów musi być oczywiście emulowana. W przypadku DNS odbywa się to przez poprzedzenie samego pakietu DNS 16bitową długością całkowitą pakietu (zakodowaną metodą Big Endian). [284] Co więcej, na jedno zapytanie o domenę serwer może udzielić wielu odpowiedzi (np. wskazać kilka różnych adresów IP), a także wskazać adresy domen serwerów autorytatywnych, jak również przekazać ich adresy IP w rekordach dodatkowych informacji.
[285] Python, jak i inne współczesne języki programowania, dysponuje zestawem bibliotek umożliwiających użycie gotowych serwerów HTTP (np. klasa BaseHTTPServer w Python 2.7 czy http.server w Python 3). Niemniej w celu wyjaśnienia, jak tego typu serwery działają od wewnątrz, przykład nie wykorzystuje gotowych bibliotek. [286] Warto dodać, że w maju 2015, po niespełna 20 latach od powstania HTTP/1.1, została opublikowana specyfikacja nowej wersji protokołu – HTTP/2.0. Po trzech miesiącach od publikacji około 1% serwisów internetowych wspierało nową wersję protokołu [17]. [287] Pozostałe spotykane metody to np. HEAD, OPTIONS, TRACE, CONNECT, PUT, DELETE, PATCH. [288] Podejście to jest nadal popularne, choć stosowane jedynie w przypadku niewielkich stron i serwisów WWW. W szczególności jest to typowe podejście wykorzystywane przez język PHP, choć zachowanie to można zmienić, korzystając z dodatkowych modułów do wykorzystywanego serwera WWW (np. mod_rewrite w przypadku serwera Apache pozwala na dowolne przemapowanie identyfikatorów zasobu, w tym skierowanie wszystkich do jednego skryptu PHP, który obsłuży je wedle własnej, wewnętrznej logiki). [289] Jak wiemy z poprzedniego podrozdziału, nie ma problemu, by wiele domen wskazywało na jeden adres IP, co jest zresztą bardzo popularnym scenariuszem. W takim wypadku serwer, dysponując jedynie gniazdem i adresem IP drugiej strony komunikacji, nie byłby w stanie stwierdzić, którą domeną (stroną WWW) jest zainteresowany klient – stąd potrzeba jawnego podania domeny w dodatkowym polu nagłówka. [290] Popularnie metoda ta nazywana jest AJAX (Asynchronous JavaScript and XML), choć obecnie format XML jest wykorzystywany rzadziej niż dużo prostszy JSON (metoda serializacji przesyłanych danych jest oczywiście dowolna i zależy jedynie od wyboru programisty). [291] Choć może to wymagać ustawienia odpowiednich przekierowań na routerze (z uwagi na NAT) lub zmian w konfiguracji zapory sieciowej (patrz ramka „Reguły zapory sieciowej [VERBOSE]”). [292] Dostępność narzędzi do konfiguracji. [293] Wirtualne sieci wewnętrzne stworzone pomiędzy maszyną wirtualną a komputerem-gospodarzem są czasem uznawane przez system Windows za
sieci publiczne, a więc w przypadku testów trzeba pamiętać, by zezwolić na dostęp do portów danej aplikacji również w profilu publicznym. [294] Jak również na datagramowych gniazdach domeny UNIX (AF_UNIX, SOCK_DRGAM), o których wspomniałem w poprzednim rozdziale. [295] Ostatecznie może istnieć jakiś „dziwny”, niespotykany zapis lub funkcja, które pozwolą na ucięcie kolejnego bajtu! [296] Implementacje często są bardziej liberalne niż oficjalna specyfikacja, tj. mogą akceptować pliki, które nie do końca spełniają jej założenia. W niektórych wypadkach oczywiście może nastąpić odwrotna sytuacja, w której dana implementacja nie radzi sobie z poprawnym (względem oficjalnej specyfikacji) plikiem. [297] Współcześnie, m.in. właśnie z uwagi na binarne pliki poliglotyczne, zaleca się korzystanie z osobnej domeny do serwowania plików dostarczonych przez użytkowników. [298] Autorką wykorzystanych zdjęć jest Arashi Coldwind. [299] Oczywiście nie można również przesadzić w drugą stronę – czas poświęcony na debugowanie źle napisanego kodu jest również czasem straconym. [300] Po lewej: „Jet Escape” stworzone w składzie: Mateusz Jurczyk, Sebastian Rosik i Gynvael Coldwind podczas IGK 2012 [20], po prawej: „Xeno Invasion” autorstwa Mariusza Zaborskiego, Sebastiana Rosika i Gynvaela Coldwinda, IGK 2013 [21]. [301] Taki program zazwyczaj nazywany jest crackme (z ang. „połam mnie”), choć czasami nazwa jest bardziej precyzyjna i wskazuje na konkretny cel, np. keygenme (z ang. „napisz generator poprawnych kluczy/haseł do mnie”), unpackme (z ang. „rozpakuj mnie” – dotyczy plików wykonywalnych, które są w jakiś sposób zaszyfrowane w stanie spoczynku) czy po prostu reverseme (z ang. w bardzo luźnym tłumaczeniu „przeanalizuj mnie”). [302] Słowo to pochodzi od angielskiego zwrotu „to own something” (literówka w słowie „own” na „pwn” jest celowa), które w slangu środowiska związanego z bezpieczeństwem komputerowym oznacza „przejąć nad czymś kontrolę” lub „włamać się do czegoś”. [303] Port knocking („pukanie do portów”) to prosta technika automatycznego dopisywania danego adresu IP do listy adresów, z których można się łączyć z danym serwisem. Konkretniej pukanie do portów przypomina wprowadzanie kodu składającego się z cyfr (a raczej liczb, gdzie następny
numer portu jest kolejną liczbą w kodzie), w którym korzysta się z protokołów TCP lub UDP i wysyła pakiety na ściśle określone porty w precyzyjnie określonej kolejności. Jeśli „kod” (czyli numery portów i kolejność ich wprowadzania) się zgadza, host docelowy dodaje źródłowy adres IP do listy dostępowej danego serwisu (na stałe lub na pewien określony czas). [304] Sandbox to ogólna nazwa mechanizmów ograniczających dostęp danej aplikacji do środowiska wykonywalnego (dysku, innych programów itp.). Zazwyczaj celem jego zastosowania jest wprowadzenie większych ograniczeń dla procesu niż te, które wynikają z praw dostępu kontrolowanych przez system operacyjny. [305] Dla porównania prawdopodobieństwo trafienia szóstki w Lotto wynosi 0,0000072%.