Norbert Langner

Norbert Langner

My homepage @ PUT

(PSiW) Obsługa plików

We wstępie napisaliśmy już program, który otwierał wskazany plik. Czas przyjrzeć się dokładniej podstawowym operacjom na plikach w Uniksie. Poznamy też pojęcie deskryptora do plików oraz wskaźnika pozycji pliku.

Jądro systemu Unix udostępnia dwie podstawowe operacje na plikach odczyt oraz zapis, realizowane za pomocą funkcji, odpowiednio read oraz write. Pliki nie mają narzuconej żadnej struktury, jest to, z punktu widzenia systemu, tablica bajtów. Zatem, wszystkie operacje wykonywane są z dokładnością do jednego bajta.

Warto zaznaczyć, że funkcje, z których będziemy korzystać, możemy nazwać niskopoziomowymi. Liczba i złożoność operacji jest dość ograniczona i ściśle związana z systemami zgodnymi z POSIX. Popularne platformy programistyczne udostępniają bardziej złożone i prostsze w użyciu funkcje, nie mniej, pod spodem zawsze (pod Unix/Linux) znajdzie się wywołanie omawianych funkcji systemowych.

Deskryptor

Program działający w ramach systemu operacyjnego może wykonywać różne operacje na plikach. Co istotne, wszystkie akcje są wykonywane przy wykorzystaniu API systemu operacyjnego, który dba o ich poprawny (z punktu widzenia działania OS) przebieg. Dlatego, zanim program dokona operacji na pliku, pierw musi go otworzyć. System operacyjny, jeśli nic nie stoi na przeszkodzie, rezerwuje plik (zasób) dla programu. Żeby uprościć inne operacje, system zwraca programowi rodzaj referencji do otwartego pliku. W systemach Uniksowych nazywamy ją deskryptorem i jest to, po prostu, liczba całkowita.

Deskryptor jest wartością, którą przekazujemy do innych funkcji wykonujących operacje wejścia/wyjścia. System operacyjny na jej podstawie wie, na którym pliku wykonać zadaną czynność. Co ciekawe, deskryptory nie ograniczają się tylko do plików na dysku: mogą to być potoki (poznamy je później), ale także gniazda sieciowe.

Specyficznym rodzajem deskryptora jest konsola systemowa, która reprezentowana jest przez system operacyjny w postaci trzech pseudoplików. W domyślnym przypadku, każdy uruchamiany program dostaje trzy deskryptory do użycia. Są to, w kolejności:

  • Standardowe wejście (nr 0) - stdin.
  • Standardowe wyjście (nr 1) - stdout.
  • Standardowe wyjście komunikatów o błędach (nr 2) - stderr. To wyjście nie jest buforowane, czyli wszystko, co tam trafi ląduje natychmiast na konsolę.

Domyślnie wyjścia 1 i 2 trafiają na terminal, w którym został uruchomiony program. Co ciekawe, deskryptory te zachowują się bardzo podobnie do plików i możemy na nich bezpośrednio wykonywać operacje odczytu lub zapisu za pomocą tych samych funkcji, które zostaną opisane poniżej. Należy tylko pamiętać, że na deskryptorze 0 nie zadziała operacja write, a na deskryptorach 1 i 2 read.

Otwieranie pliku

Poniższe funkcje są zadeklarowane w pliku nałówkowym fcntl.h, dołączamy go do naszego programu dyrektywą #include <fcntl.h> (patrz przykładowe programy).

Otwieranie plików realizujemy za pomocą funkcji open, jej nagłówek jest następujący:

int open(const char *pathname, int flags);

W parametrze pathname podajemy ścieżkę do pliku (względną lub bezwzględną), który chcemy otworzyć. Nazwa flags sugeruje natomiast, że mamy do czynienia z tzw. polem bitowym, czyli liczbą, której poszczególne bity są interpretowane, wpływając na działanie funkcji. Na szczęście, dostępne są dla nas gotowe do użycia stałe, zawierające gotowe, właściwe parametry. Najważniejsze to:

  • O_RDONLY - otwarcie pliku tylko do odczytu,
  • O_WRONLY - otwarcie pliku tylko do zapisu,
  • O_RDWR - otwarcie pliku do odczytu i zapisu.

Nie są to, jedyne wartości, które można użyć. W tym momencie odsyłam do dokumentacji, a dokładnie man 2 open, gdzie dokładnie opisano możliwości funkcji open (a także creat i openat).

Funkcja open zwraca nam wartość typu int będąca opisanym wcześniej deskryptorem. Przypomnijmy, że wartość -1 oznacza błędne wykonanie funkcji. Szczegóły dotyczące napotkanego błędu możemy odczytać, np. funkcją perror. Dla dalszego użycia deskryptora musimy zapisać go sobie do jakiejś zmiennej.

int fd = open("sciezka/do/pliku.txt", O_RDONLY);
if (fd == -1) {
  std::perror("Błąd podczas próby otwarcia pliku");
  exit(1);
}

Tworzenie nowego pliku

Jeśli chcemy utworzyć nowy plik na dysku, możemy skorzystać w funkcji creat, jej nagłówek prezentuje się tak:

int creat(const char *pathname, mode_t mode);

Pełny opis znajdiemy w man 2 open - jest to funkcja bliźniacza do open, dlatego została opisana w ramach jednego dokumentu. Parametr pathname działa identycznie, jak w funkcji open. Jako mode podajemy zaś docelowe uprawnienia, jakie chcemy przyznać tworzonemu pliku. Podajemy tutaj wartość ósemkową, co w językach C/C++ realizujemy poprzez dodanie zera przed właśicwą liczbą, np. 0666 dla uprawnień odczyt-zapis dla wszystkich. Należy pamiętać, że są to uprawnienia maksymalne, ograniczone maską umask przydzieloną procesowi. W systemie Ubuntu domyślną maską jest 002, co oznacza, że stworzony plik ostatecznie będzie miał uprawnienia 664.

Funkcja zwraca deskryptor lub -1, jeśli tworzenie pliku nie powiedzie się.

Działanie funkcji creat jest identyczne w działaniu, jak wywołanie funkcji open z flagami O_CREAT|O_WRONLY|O_TRUNC.

Zamykanie pliku

Kiedy kończymy pracę z plikiem, warto go zamknąć. Oprócz odblokowania pliku dla innych programów, oszczędzamy zasoby systemowe i zapobiegamy przypadkowym zapisom lub odczytom. W przypadku naszych, małych programów, wpływ jest pomijalny, jednak zawsze warto podążać za dobrymi praktykami. W API POSIX zamykanie realizujemy funkcją close, której synopsis jest następujący:

#include <unistd.h>
int close(int fd);

Warto zauważyć, że funkcja close znajduje się w pliku nagłówkowym unistd.h, który musimy dołączyć do naszego programu. Oprócz tego, jedynym parametrem jest deskryptor, który chcemy zamknąć. Funkcja zwraca nam wartość 0, jeśli operacja się powiedzie lub -1 w przypadku błędu. Dokładny opis znajduje się w man 2 close.

Odczyt danych z pliku

Operacje odczytu i zapisu są zadeklarowane w pliku nagłówkowym unistd.h.

Mając otwarty plik i zapisany w jakiejś zmiennej deskryptor. Czas odczytać dane. Tutaj przypomnijmy, że w podstawowym API operacje na danych są wykonywane z dokładnością do jednego bajta. Typ, algorytm odczytu i interpretacji (serializacji/deserializacji) danych są w pełni zadaniem programisty. Najprostszym do odczytu i interpretacji są proste pliki tekstowe (podstawowy znak ASCII zapisywany jest w pamięci jako jeden bajt) i na tym typie danych się skupimy.

Żeby odczytać dane z pliku używamy funkcję read. Jej nagłówek przedstawia się następująco:

ssize_t read(int fd, void *buf, size_t count);

Na pierwszy rzut oka, widzimy tutaj dwa niezwykłe typy ssize_t oraz size_t. Należą one do typów systemowych i ich właściwy rozmiar zależy od platformy systemowej. Dla typu ssize_t dokumentacja wskazuje, że powinien on przyjmować wartości licznika bajtów oraz -1 dla zapisania informacji o błędzie. Natomiast, size_t powinien być w stanie zapisać nieujemną liczbę bajtów. Dla platformy x86 możemy uprościć ssize_t jako int, a size_t jako unsigned int - pamiętajmy, że takie uproszczenie ogranicza przenaszalność kodu.

Opis wszystkich systemowych typów danych znajdziemy w man 7 system_data_types.

Jako pierwszy parametr wywołania fd podajemy deskryptor otrzymany z funkcji open. Do buf musimy podać wskaźnik na tablicę, do której chcemy wczytać dane z pliku, a w count podajemy rozmiar tablicy lub ilość bajtów, którą chcemy odczytać. Pamiętajmy, że tutaj my jesteśmy odpowiedzialni za to, żeby nie przekroczyć rozmiaru bufora. Zwróćmy uwagę na to, że buf jest typu void *, co oznacza, że funkcja traktuje dane z pliku jako dowolny typ. Funkcja zwraca nam liczbę odczytanych bajtów z pliku (może być mniejsza lub równa rozmiarowi bufora) lub -1 w przypadku napotkania błędu. Przykładowe wywołanie funkcji:

// otwarcie pliku ...
char *buffer[512];
int read_bytes = read(fd, buffer, 512);
if (read_bytes == -1) {
  perror("Błąd podczas odczytu z pliku");
  exit(1);
}

Zapis danych do pliku

Analogiczną operacją do odczytu jest zapis, który realizujemy funkcją write. Jej nagłówek jest prawie identyczny do funkcji read (poza nazwą), ale dla porządku go przytoczymy:

ssize_t write(int fd, const void *buf, size_t count);

Warto tutaj zwrócić uwagę na to, że buf został oznaczony jako const void *, czyli funkcja nie edytuje bufora. Bardzo istotnym parametrem jest count, ponieważ to, jaką wartość tutaj podamy, dokładnie określa ilość danych, która trafi do pliku. Ponownie, to my odpowiadamy za poprawne wartości. Przykładowy zapis do pliku przedstawia poniższy przykład:

// otwarcie pliku ...
const char *message = "Halo!";
int written_bytes = write(fd, message, 5);
if (written_bytes == -1) {
  perror("Błąd podczas zapisu do pliku");
  exit(1);
}

Przykład - Kopiowanie pliku

W repozytorium, w folderze 02_files/file_copy znajduje się przykładowa aplikacja, która działa bardzo podobnie do podstawowego działania polecenia `cp`.

Poruszanie się po pliku

Operacje odczytu i zapisu wykonywane są sekwencyjnie. Każde wywołanie funkcji powoduje odczytanie lub zapisanie w dalszej części pliku. System operacyjny, dla każdego otwartego pliku, zapisuje sobie bieżącą pozycję dla tych operacji. Czasami chcielibyśmy przesunąć się do określonej części pliku, aby pobrać tylko potrzebne dane. Do tego celu służy funkcja lseek, oto jej nagłówek:

off_t lseek(int fd, off_t offset, int whence);

Pierwszy parametr przyjmuje deskryptor otwartego pliku, parametr offset wskazuje o ile bajtów, względem punktu odniesienia, przenieść wskaźnik pozycji w pliku. Punkt odniesienia definiuje parametr whence, który przyjmuje 3 możliwe wartości:

  • SEEK_SET przesuwa wskaźnik o `offset` względem początku pliku
  • SEEK_CUR przesuwa wskaźnik o `offset` względem aktualnej pozycji
  • SEEK_END przesuwa wskaźnik o `offset` względem końca pliku

Należy zauważyć, że offset przyjmuje wartości całkowite, gdzie ujemne oznaczają cofanie się. Dodatkowo, możliwe jest przesunięcie wskaźnika poza wielkość pliku. Dla odczytu spowoduje to, że odczyt zakończy się odczytaniem 0 bajtów (ale sama operacja się powiedzie). Natomiast, w przypadku zapisu, nowe dane trafią we wskazane miejsce, a wcześniejsze bajty zostaną wypełnione bajtami o wartości 0 (ASCII \0). Dokładny opis jest w dokumentacji man 2 lseek.

Funkcja zwraca aktualną pozycję pliku względem jego początku lub -1, jeśli wykonanie się nie powiedzie.

Przykład - rozmiar pliku

Przesuwanie pliku można kreatywnie wykorzystać, aby określić rozmiar otwartego pliku. Przedstawia to przykładowy kod w repozytorium, który znajduje się w folderze 02_files/file_size.

Zadania

  1. Napisz program tworzący kopię pierwszej połowy podanego pliku.
  2. Napisz program konwertujący małe litery na duże, w podanym jako argument wywołania, pliku. Do zamiany wielkości pojedynczego znaku możesz skorzystać z funkcji toupper (ctype.h).
  3. Napisz program, który sklei zawartość kolejnych plików i zapisze do osobnego pliku, zgodnie z wywołaniem ./sklejacz plik1 plik2 plik3 plik_sklejony.
  4. Napisz program do tworzenia kopii pliku z odwróceniem kolejności plików/bajtów.
  5. Napisz program wypisujący na ekran histogram występowania wszystkich liter alfabetu łacińskiego w podanym pliku. Zignoruj znaki białe oraz interpunkcyjne. Wielkość liter może, lecz nie musi być respektowana.
  6. * Napisz program wyświetlający rozdzielczość pliku BMP podanego jako argument wywołania.