Systemy operacyjne ZSOP. Wykład 4

Michał Goliński

2017-12-02

Wstęp

Pojęcia – przypomnienie

Ostatnio poznaliśmy:

  • zmienne powłoki i środowiskowe
  • kod wyjścia procesu
  • podstawianie w powłoce
  • instrukcje złożone w powłoce
  • mechanizm shebang
  • podstawy obsługi make

Pytania?

Plan na dziś

  • funkcja main w C
  • obsługa kompilatora gcc/g++
  • obsługa IDE – QtCreator
  • używanie zewnętrznych bibliotek
  • funkcje systemowe dla plików
  • funkcje biblioteczne dla plików
  • obsługa błędów
  • przestrzeń adresowa procesu

Dokumentacja

cppreference

Później dowiemy się jak znajdować dokumentację w systemowym podręczniku. Dokumentacja ta jednak, mimo że bardzo wyczerpująco, jest jednak uboga w przykłady.

Nieocenionym źródłem, zwłaszcza na początku programowania w C/C++ jest strona http://en.cppreference.com/w/Main_Page. Zawiera na bieżąco aktualizowaną, wyczerpującą dokumentację oraz przykłady, które można uruchamiać bezpośrednio na stronie, bez włączania kompilatora.

Funkcja main

Punkt startowy programu

Funkcja main jest w języku C punktem startowym wykonywanego programu. Funkcja ta ma w kompilatorze gcc 3 możliwe prototypy.

int main();
int main(int argc, char *argv[]);
int main(int argc, char *argv[], char *envp[]);

argc i argv

Argument argc oznacza w momencie startu programu liczbę argumentów pozycyjnych, a argv jest tablicą wskaźników długości argc, wskazujących na kolejne argumenty pozycyjne, przekazywane jako napisy.

envp

Argument envp jest tablicą wskaźników na zmienne środowiskowe. Każdy wskaźnik wskazuje na napis w rodzaju ZMIENNA=wartość. Koniec tablicy jest sygnalizowany wskaźnikiem zerowym.

Wypisanie argumentów pozycyjnych

#include <stdio.h>
int main(int argc, char *argv[]) {
for(int i = 0; i < argc; ++i) {
printf("%s\n", argv[i]);
}
return 0;
}

Wypisanie zmiennych środowiskowych

#include <iostream>
int main(int argc, char *argv[], char *envp[]) {
for(; *envp; ++envp) {
std::cout << *envp << std::endl;
}
return 0;
}

Narzędzia

GCC

Oryginalnie GCC oznaczało GNU C Compiler. Z biegiem czasu znaczenie zmieniono na GNU Compiler Collection. Dzisiaj GCC udostępnia kompilatory dla następujących języków:

  • C/C++ (gcc/g++)
  • Fortran (gfortran)
  • Ada (gnat)
  • Go (gccgo)
  • Objective-C (cc1obj)

GCC cd.

GCC potrafi generować kod maszynowy dla kilkudziesięciu platform sprzętowych, w tym dla najważniejszych:

  • ARM
  • AVR
  • MIPS
  • PowerPC
  • x86

Clang

Projekt LLVM ma na celu stworzenie narzędzi do stosunkowo łatwego pisania kompilatorów oraz do łatwej integracji tych kompilatorów w innych narzędziach (podświetlanie kodu, podpowiedzi dla programisty). Projekt tworzy też kompilator dla C/C++ o nazwie clang.

Kompilator firmy Intel

Firma Intel sprzedaje swój kompilator (dla pewnych zastosowań udostępnia go za darmo). Kompilator ten kładzie nacisk na optymalizację generowanego kodu maszynowego.

Kompilator firmy Microsoft

Microsoft udostępnia swój kompilator za darmo (ale działa tylko na platformie Windows). Normalnie kompilator jest częścią Visual Studio, ale sam kompilator można ściągnąć jako Visual C++ Build Tools.

Debugger

Debugger to narzędzie pozwalające na wykonanie kodu w kontrolowanych warunkach. W programie działającym pod debuggerem możemy:

  • podglądać wartości zmiennych w trakcie działania progarmu
  • przerywać działanie programu w ściśle określonych miejscach
  • wykonywać program linijka po linijce

Debugger musi być najczęściej zgodny z używanym kompilatorem, dla kompilatorów GCC odpowiednim debuggerem jest gdb.

IDE

Wszystkie znane mi kompilatory są narzędziami działającymi w linii poleceń, ale programiści z reguły używają narzędzi graficznych zapewniających podświetlanie składni i błędów, podpowiedzi i integrujących debugger. Narzędzia te tzw. zintegrowane środowiska programistyczne (IDE – Integrated Development Environment).

IDE cd.

Najbardziej znane IDE dla C++ to:

  • Visual Studio (tylko Windows, dodatkowo obsługa języków .NET)
  • Eclipse (platforma do tworzenia IDE, dostępne również C/C++)
  • NetBeans (platforma do tworzenia IDE, dostępne również C/C++)
  • XCode (tylko MacOS)
  • QtCreator (osobiście polecam)
  • CLion
  • Code::Blocks

Fazy kompilacji programu

Preprocesor

Przed właściwą kompilacją wywoływany jest tzw. preprocesor – procesor makr rozumiejący tylko test. Instrukcje preprocesora (dyrektywy) poprzedzone są znakiem #. Najważniejsze to:

  • #include – powoduje wstawienie zawartości żądanego pliku
  • #define – definiuje makra, które potem są rozwijane
  • #if...#endif – pozwala włączać/wyłączać pewne fragmenty kodu w zależności od warunków

Translacja kodu

Główna faza kompilacji – kod w języku C jest parsowany i zamieniany na kod asemblera dla odpowiedniej platformy. Na tym etapie odbywa się także optymalizacja powstającego kodu.

Asemblacja

Kod asemblera jest tłumaczony na kod maszynowy przez asembler. Wynik zapisywany jest w tzw. pliku obiektowym.

Konsolidacja (łączenie)

Konsolidator łączy różne pliki obiektowe w jeden plik wykonywalny lub bibliotekę.

Obsługa kompilatora w linii poleceń

Wprowadzenie

Kompilatory C/C++ są bardzo skomplikowanymi programami, więc mają tysiące opcji wpływających na proces kompilacji. Poznamy tylko te podstawowe.

Pliki wejściowe

GCC (gcc, g++) rozpoznaje typ pliku po rozszerzeniu:

  • .c – plik źródłowy C
  • .C, .cpp, .cc, .cxx – plik źródłowy C++
  • .i, .ii – pliki źródłowe dla których pomijane jest wywołanie preprocesora
  • .s – kod asemblera

Najprostsze użycie

Aby skompilować program do pliku wykonywalnego, czasem wystarczy po prostu wykonać:

$ gcc plik1.c plik2.c ...

Do kompilatora przekazujemy po prostu wszystkie pliki źródłowe składające się na program. Program zostanie domyślnie zapisany w pliku o nazwie a.out.

Przerywanie procesu kompilacji

Proces kompilacji możemy przerwać:

  • -E – przerwij po wywołaniu preprocesora, efekt pracy wysyłany domyślnie na standardowe wyjście
  • -S – przerwij po translacji kodu do asemblera, kod zapisywany do pliku z rozszerzeniem .s
  • -c – przerwij po asemblacji, kod maszynowy zapisywany do pliku z rozszerzeniem .o

Tworzone pliki

Opcja -o pozwala zapisać wynik pracy do innego pliku niż domyślny.

Opcja -save-temps zapisuje wszystkie pliki pośrednie (przetworzony przez preprocesor, kod asemblera i kod obiektowy do pliku).

Standard języka

Opcja -std pozwala zmienić standard (wersję) języka, w którym zapisano pliki źródłowe:

  • -std=c90, -std=c99, -std=c11
  • -std=gnu11
  • -std=c++03, -std=c++11, -std=c++14
  • -std=gnu++14

Architektura komputera

Rodzina architektura docelowego komputera jest ustalona na etapie kompilacji kompilatora. Można jednak w drobnym zakresie wpływać na generowany kodu:

  • -march – pozwala wybrać generację konieczną do uruchomienia programu, np. i686, haswell, athlon64, znver1
  • -mtune – pozwala wybrać architekturę pod którą będzie optymalizowany kod

Optymalizacja

Możemy wpływać na poziom optymalizacji tworzonego kodu. Generalnie im wyższy stopień optymalizacji tym kompilacja trwa dłużej a kod asemblera jest mniej czytelny, za to program wykonuje się szybciej (w teorii przynajmniej). Opcje dotyczące optymalizacji:

  • -O0 – brak optymalizacji
  • -O1, -O2, -O3 – coraz wyższa optymalizacja
  • -Os – twórz jak najbardziej kompaktowy kod
  • -Ofast – twórz jak najszybszy kod
  • -Og – włącz tylko te optymalizacje, które nie wpływają na debugowanie

Ostrzeżenia

Kompilator może też generować ostrzeżenia. Pełna lista dostępnych ostrzeżeń jest w dokumentacji. Najważniejsze opcje:

  • -Wall – włącz wszystkie ostrzeżenia
  • -Werror – traktuj ostrzeżenia jako błędy
  • -Wpendantic – ostrzega przed konstrukcjami mogącymi wpływać na przenośność kodu między kompilatorami

Informacje debugowania

Opcja -g powoduje dołączenie informacji przydatnych debuggerowi które pozwalają np. wykonywać program linijka po linijce.

Definiowanie makr

Opcja -D pozwala definiować makra preprocesora na etapie kompilacji. Pozwala to w łatwy sposób np. przełączać się między wersją produkcyjną i prototypową kodu.

$ gcc -D DEBUG program.c
$ gcc program.c

Opcje konsolidatora

Najważniejsze opcje przekazywane konsolidatorowi:

  • -lbiblioteka – dołącz bibliotekę, bez tego dostaniemy błąd o braku definicji funkcji
  • -L – dołącz podany katalog do ścieżki przeszukiwań bibliotek z opcji -l
  • -shared – nie twórz pliku wykonywalnego tylko bibliotekę dynamiczną
  • -nostdlib – nie łącz z biblioteką standardową

Inne opcje

  • -I – dołącz podany katalog do ścieżki przeszukiwań dla preprocesora
  • -masm – pozwala wybrać dialekt asemblera na platformie x86 (intel lub att)
  • -fverbose-asm – generuje kod asemblera z komentarzami da programisty

Przykład

Skompilujemy następujący kod i obejrzymy tworzone pliki pośrednie z różnymi opcjami.

int main() {
int i = 10;
while (i >= 0) {
--i;
}
return 0;
}

Wejście i wyjście

printf

Do wypisywania na standardowe wyjście służy funkcja biblioteczna printf, zadeklarowana w pliku stdio.h. Działa następująco:

#include <stdio.h>
int main() {
printf("%s\n", "napis");
printf("%d --> %d\n", 1, 4);
printf("%f\n", 1.5);
return 0;
}

Dokumentację funkcji printf można znaleźć w manualu:

$ man 3 printf

scanf

Do wczytywania ze standardowego wejścia służy funkcja scanf:

#include <stdio.h>
int main() {
int i;
double d;
char s[1000];
scanf("%s", s);
scanf("%d", &i);
scanf("%lf\n", &d);
return 0;
}

Złożony przykład

Mnożenie liczb

Biblioteka GMP zawiera typy i algorytmy pozwalająca na działania na dowolnie dużych liczbach (ograniczeniem jest tylko dostępna pamięć).

Stworzymy program, który wypisze na wyjściu liczb całkowitych przekazanych przez parametry pozycyjne, bez ograniczeń na liczbę cyfr:

$ multiply 2 3
6

Dla prostoty pominiemy sprawdzanie błędów. W prawdziwym programie należałoby o to zadbać.

Kod programu

// lecture04gmp.c
#include <gmp.h>
int main(int argc, char *argv[]) {
mpz_t x, y;
mpz_init(x);
mpz_init(y);
mpz_set_str(x, argv[1], 10);
mpz_set_str(y, argv[2], 10);
mpz_mul(x, x, y); // x = x*y
gmp_printf("%Zd\n", x);
mpz_clear(x);
mpz_clear(y);
return 0;
}

Kompilacja

Aby skompilować program musimy wywołać:

$ gcc -c lecture04gmp.c
$ gcc -lgmp -o multiply lecture04gmp.o

Lub w jednym kroku:

$ gcc -lgmp -o multiply lecture04gmp.c

Plik Makefile

Ostatnio poznaliśmy narzędzie make do zarządzania kompilacją. Odpowiednik plik Makefile dla naszego projektu mógłby wyglądać tak:

all: multiply
multiply: lecture04gmp.o
gcc -lgmp -o multiply lecture04gmp.o
lecture04gmp.o: lecture04gmp.c
gcc -c lecture04gmp.c
test: all
./multiply 111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111 3
clean:
rm -f multiply lecture04gmp.o

Kompilacja z plikiem Makefile

Aby skompilować program wystarczy wpisać:

$ make

Aby przetestować:

$ make test

Aby usunąć efekty kompilacji:

$ make clean

To samo w IDE

Teraz zobaczymy ten sam program w IDE – QtCreator

Będziemy potrzebowali pliku CMakeLists.txt:

cmake_minimum_required(VERSION 3.2)
project(gmp-demo)
add_executable(${PROJECT_NAME} "main.c")
find_path(GMP_INCLUDE_DIR NAMES gmp.h )
find_library(GMP_LIBRARIES NAMES gmp libgmp)
target_include_directories(${PROJECT_NAME} PRIVATE ${GMP_INCLUDE_DIR})
target_link_libraries(${PROJECT_NAME} ${GMP_LIBRARIES})

To samo w C++

Ten sam program w C++, dzięki udostępnianym przez C++ możliwościom jest znacznie krótszy:

#include <iostream>
#include <gmpxx.h>
int main(int argc, char *argv[]) {
mpz_class a(argv[1]), b(argv[2]);
std::cout << a*b << std::endl;
return 0;
}

Kompilacja:

$ g++ -lgmp -lgmpxx -o multiply lecture04gmp.cpp

Obsługa plików – funkcje systemowe

Funkcja open

Do otwarcia pliku służy funkcja systemowa open:

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);

Funkcja open cd.

Argument pathname to ścieżka do pliku (względna lub nie). Argument flags to logiczna kombinacja opcji otwarcia pliku. Jedna z poniższych jest obowiązkowa:

  • O_RDONLY – plik otwierany tylko do odczytu
  • O_WRONLY – plik otwierany tylko do zapisu
  • O_RDWR – plik otwierany tylko do odczytu i zapisu

Funkcja open cd.

Inne to:

  • O_APPEND – dopisuj do pliku
  • O_CREAT – plik zostanie utworzony jeśli nie istnieje, prawa do nowego pliku są w argumencie mode
  • O_TRUNC – Jeżeli otwieramy w trybie dopuszczającym zapis (np. O_WRONLY), skasuj poprzednią zawartość pliku

Deskryptor pliku

Funkcja open zwraca liczbę. Jeśli jest ona równa -1, to znaczy że doszło do jakiegoś błędu. Jeśli jest ona dodatnia, to jest to tzw. deskryptor pliku – małą liczba dzięki której dalej w programie możemy odwoływać się do otwartego pliku. Z reguły mamy pewne ograniczenie na liczbę równocześnie otwartych deskryptorów. Ten sam plik może być otwarty kilka razy, z różnymi deskryptorami.

Deskryptor pliku cd.

Świeżo uruchomiony program ma otwarte trzy pliki. Odpowiadające im deskryptory to:

  • deskryptor 0 –standardowe wejście
  • deskryptor 1 –standardowe wyjście
  • deskryptor 2 –standardowe wyjście błędów

Funkcja close

Do zamykania niepotrzebnych już deskryptorów używamy funkcji close

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

Co prawda zamykanie deskryptorów nie jest niezbędne na końcu działania programu (pliki zostaną zamknięte automatycznie), dobrym nawykiem jest to robić.

Funkcja read

Do czytania z pliku używamy funkcji read:

#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);

Funkcja ta odczytuje maksymalnie count bajtów z otwartego pliku odpowiadającego deskryptorowi fd do bufora na który wskazuje buf. Funkcja zwraca liczbę bajtów, które udało się odczytać. Zwrócenie wartości 0 sygnalizuje koniec pliku, wartość -1 oznacza błąd.

Funkcja read cd.

Jeżeli plik można przeszukiwać, dane są odczytywane począwszy od obecnej pozycji kursowa oraz odczytanie pewnej liczby bajtów przesuwa o tyle samo kursor pliku do przodu. Tym samym kolejne wywołania pozwalają odczytać cały plik.

Funkcję read można wywołać na dowolnym deskryptorze, w szczególności można za jej pomocą odbierać dane z otwartych połączeń sieciowych.

Funkcja write

Do zapisu do pliku używamy funkcji write:

#include <unistd.h>
ssize_t write(int fd, void *buf, size_t count);

Funkcja ta zapisuje maksymalnie count bajtów do otwartego pliku odpowiadającego deskryptorowi fd z bufora na który wskazuje buf. Funkcja zwraca liczbę bajtów, które udało się zapisać. Zwrócenie wartości 0 sygnalizuje, że żadnych danych nie udało się zapisać, wartość -1 oznacza błąd.

Funkcja write cd.

Jeżeli plik można przeszukiwać, dane zapisywane są w obecnej pozycji kursora oraz zapisanie pewnej liczby bajtów przesuwa o tyle samo kursor pliku do przodu.

Funkcję write można wywołać na dowolnym deskryptorze, w szczególności można za jej pomocą wysyłać dane przez otwarte połączenia sieciowe.

Funkcja lseek

Niektóre pliki można przeszukiwać, to znaczy ma sens dla nich pojęcie miejsca w pliku. System z każdym takim otwartym plikiem stowarzysza tzw. kursor, który wskazuje na obecne miejsce w pliku. Pozycję kursora można zmieniać z użyciem funkcji lseek:

#include <sys/types.h>
#include <unistd.h>
off_t lseek(int fd, off_t offset, int whence);

Funkcja lseek cd.

Funkcja przesuwa kursor w pliku stowarzyszonym z deskryptorem fd. Jeśli flaga whence jest równa:

  • SEEK_SET – kursor przesuwany jest na pozycję offset bajtów od początku pliku
  • SEEK_CUR – kursor przesuwany jest o offset bajtów w stosunku do obecnej pozycji
  • SEEK_END – kursor przesuwany jest o offset bajtów w stosunku do końca pliku

Funkcja zwraca nową pozycję kursora (od początku pliku) lub -1 w przypadku błędu.

Funkcja lseek cd.

Kursor może być ustawiony poza końcem pliku, wtedy przy próbie zapisu zostanie stworzona dziura, czyli miejsce wypełnione zerami. Niektóre systemy plików potrafią zapisać dziurę bez zajmowania miejsca na dysku.

Dokumentacja

Dokumentacja funkcji systemowych znajduje się w drugiej sekcji podręcznika. Aby wyświetlić dokumentację dot. funkcji systemowej open, robimy:

$ man 2 open

Pominięcie 2 może wyświetlić inną stronę podręcznika.

Przykład – kopiowanie

Napiszemy prosty odpowiednik programu cp. Potrafi tylko skopiować plik na cel, nadpisując go jeśli trzeba i nie sprawdza błędów. Za to przed kopiowaniem wyświetla długość pliku i na końcu wyświetla liczbę bajtów, które udało się skopiować.

Przykład – kopiowanie cd.

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main(int argc, char* argv[]) {
int source = open(argv[1], O_RDONLY);
int dest = open(argv[2], O_WRONLY | O_CREAT | O_TRUNC, S_IRWXU);
int BUF_SIZE = 5;
off_t end = lseek(source, 0, SEEK_END);
printf("Plik źródłowy wydaje się mieć %lld bajtów.\n", (long long) end);
lseek(source, 0, SEEK_SET);
char buf[BUF_SIZE];
ssize_t s;
ssize_t total = 0;
while (s = read(source, buf, BUF_SIZE)) {
write(dest, buf, s);
total += s;
}
printf("Kopiowanie zakończone.\n");
printf("Skopiowano %zd bajtów.\n", total);
close(source);
close(dest);
return 0;
}

Obsługa błędów

Problem

Wiele funkcji potrzebuje możliwość zgłaszania błędów. Niestety nie zawsze jest możliwość by w wartości zwracanej zawrzeć jego przyczynę, są nawet przypadki, gdzie wartość zwracana nigdy nie sygnalizuje błędu. W takich przypadkach biblioteka standardowa używa mechanizmu errno.

errno.h

W pliku nagłówkowym errno.h zadeklarowana jest zewnętrzna zmienna liczbowa o nazwie errno. Wartość tę programista przed wywołaniem funkcji ustawia na 0. W przypadku wystąpienia błędu funkcja biblioteczna zmienia tę wartość na coś różnego od zera. Dokumentacja każdej funkcji wypisuje możliwe błędy i ich znaczenie. Wartości oznaczane są symbolami, których należy używać. Polecenie errno -l w powłoce wyświetli listę wszystkich możliwych błędów.

Przykład użycia

#include <errno.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main(int argc, char *argv[]) {
errno = 0;
int fd = open(argv[1], O_RDONLY);
if (fd == -1) {
perror("Niepowodzenie w funkcji open");
return 1;
}
close(fd);
return 0;
}

Obsługa plików – funkcje biblioteczne

Dlaczego funkcje biblioteczne?

Funkcje read i write udostępniane przez jądro są niewygodne w użyciu, bo potrafią tylko zapisać i odczytać ciąg bajtów, w przeciwieństwie np. do funkcji printf i scanf.

Funkcje biblioteczne są nadbudowane na poznanych funkcjach systemowych.

Typ FILE

Funkcje biblioteczne do obsługi plików nie działają na deskryptorach plików (nie bezpośrednio). Deskryptory są „opakowane” w strukturę FILE (tworząc tzw. strumień). Jako programiści nie musimy wiedzieć jakie elementy zawiera ta struktura, jedynie przechowujemy wskaźniki na nią i przekazujemy je do funkcji.

Bibliotek standardowa na starcie ma trzy otwarte strumienie: stdin, stdout i stderr.

Otwieranie pliku

Aby otworzyć strumień mając nazwę pliku używamy funkcji fopen. Aby „opakować” otwarty deskryptor pliku w strumień, używamy funkcji fdopen:

#include <stdio.h>
FILE *fopen(const char *pathname, const char *mode);
FILE *fdopen(int fd, const char *mode);

Obie funkcje zwracają wskaźnik do otwartego strumienia.

Zamykanie pliku

Aby zamknąć strumień i opróżnić powiązane z nim bufory musimy wywołać fclose, tym razem nawet przed zakończeniem programu. Bez wywołania tej funkcji dane mogą nigdy nie trafić do pliku. To się często zdarza gdy program kończy się nieprawidłowo.

#include <stdio.h>
int fclose(FILE *stream);

Opróżnienie bufora

Dane są czasem buforowane w celu podniesienia wydajności. Aby nakazać opróżnienie bufora stowarzyszonego ze strumieniem używamy fflush:

#include <stdio.h>
int fflush(FILE *stream);

Zapis i odczyt z formatowaniem

Funkcja fprintf wykonuje to samo co printf, z tym, że zapisuje wyjście do przekazanego strumienia. Analogiczne fscanf działa jak scanf.

#include <stdio.h>
int fprintf(FILE *stream, const char *format, ...);
int fscanf(FILE *stream, const char *format, ...);

Zapis i odczyt bez formatowania

Funkcje fread i fwrite są odpowiednikiem read i write z tą drobną różnicą, że potrafią działać w jednostkach większych od bajtu:

#include <stdio.h>
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);

Funkcje te odczytują i zapisują dane do przekazanego strumienia. Odczytane/zapisane będzie co najwyżej nmemb obiektów, każdy po size bajtów. Obiekty są odczytywane i zapisywane pod wskaźnikiem ptr.

Zapis i odczyt bez formatowania cd.

Funkcje zwracają liczbę obiektów, które udało się odczytać/zapisać.

Zapis i odczyt po linii i po znaku

Biblioteka stdio udostępnia też funkcje:

#include <stdio.h>
int fputc(int c, FILE *stream);
int fputs(const char *s, FILE *stream);
int fgetc(FILE *stream);
char *fgets(char *s, int size, FILE *stream);

Służą one do czytania i pisania po jednym bajcie lub po jednej linii.

Kodowania wielobajtowe

Czasem potrzebujemy odwoływać się do pojedynczych znaków w kodowaniach w których znaki zajmują wiele bajtów. Takim kodowaniem jest np. UTF-8, używane dzisiaj powszechnie w internecie.

Biblioteka stdio bardzo słabo radzi sobie z obsługą takich kodowań na poziomie pojedynczych znaków (nie ma problemu, gdy chcemy przesłać bez bliższego przyglądania się cały napis). Teoretycznie dostępny jest typ wchar_t i kilka funkcji operujących na tym typie, ale do obsługi unikodu w C/C++ prawdopodobnie lepiej zaprząc zewnętrzne biblioteki, np. ICU.

int main() {
printf("%s\n", "żółć");
return 0;
}

Wykrywanie błędów strumienia

Gdy odczyt lub zapis ze strumienia napotka błąd lub strumień zakończy się, funkcja fread zwraca wartość EOF (inne funkcje zachowują się podobnie). Aby rozróżnić błąd od końca pliku możemy wywołać funkcje:

#include <stdio.h>
int feof(FILE *stream);
int ferror(FILE *stream);

Funkcje zwracają 0 lub 1 w zależności od tego czy strumień napotkał błąd lub napotkał koniec pliku. Aby móc dalej odczytywać ze strumienia, który napotkał błąd należy użyć funkcji clearerr.

Dokumentacja

Dokumentację funkcji bibliotecznych znajdziemy w podręczniku w sekcji 3:

$ man 3 printf

Jeśli nie podamy 3 możemy trafić na podręcznik innego polecenia.

Przykład – kopiowanie

#include <stdio.h>
int main(int argc, char* argv[]) {
FILE *source = fopen(argv[1], "r");
FILE *dest = fopen(argv[2], "w");
int BUFSIZE = 5;
fseek(source, 0, SEEK_END);
printf("Plik źródłowy wydaje się mieć %ld bajtów.\n", ftell(source));
fseek(source, 0, SEEK_SET);
char buf[BUFSIZE];
size_t s;
size_t total = 0;
while (s = fread(buf, sizeof(char), BUFSIZE, source)) {
fwrite(buf, sizeof(char), s, dest);
total += s;
}
printf("Kopiowanie zakończone.\n");
printf("Skopiowano %zd bajtów.\n", total);
fclose(source);
fclose(dest);
return 0;
}

Język C++

IOStreams

W języku C++ do obsługi wejścia i wyjścia służy biblioteka IOStreams. Nie będziemy tutaj bliżej omawiać jej działania. Powiedzmy tylko, że bazowy typy (odpowiednik FILE) to std::ifstream dla plików wejściowych i std::ofstream dla plików wyjściowych a zamiast funkcji printf i scanf używamy operatorów << i >>. Biblioteka zapewnia obiekty cin, cout i cerr odpowiadające strumieniom standardowym.

Przykład – kopiowanie

#include <iostream>
#include <fstream>
using std::cout;
using std::ifstream;
using std::ofstream;
int main(int argc, char* argv[]) {
ifstream source(argv[1], ifstream::binary | ifstream::ate);
ofstream dest(argv[2], ofstream::binary);
std::ios::sync_with_stdio(false);
cout << "Plik źródłowy wydaje się mieć " << source.tellg() << " bajtów.\n";
source.seekg(0);
dest << source.rdbuf();
cout << "Kopiowanie zakończone.\n";
source.close();
dest.close();
return 0;
}

Ładowanie programu

shebang a pliki binarne

Kiedy system zostaje poproszony o załadowanie programu, najpierw sprawdza czy plik programu wykonywalnego nie zaczyna się od #!. Jeżeli tak, to ładowany jest program interpretera, który na wejście dostaje podany plik skryptowy. Interpreter sam może nie być prawdziwym plikiem wykonywalnym, ale system wykona tylko kilka kroków w poszukiwaniu prawdziwego pliku wykonywalnego:

$ echo '#!/bin/ls' > /tmp/t0
$ echo '#!/tmp/t0' > /tmp/t1
$ echo '#!/tmp/t1' > /tmp/t2
$ chmod +x /tmp/t*
$ /tmp/t2

shebang a pliki binarne cd.

Kiedy system znajdzie właściwy plik binarny do akcji wkracza konsolidator dynamiczny ld.

ld

Zadaniem konsolidatora (podobnie jak konsolidatora w czasie kompilacji) jest upewnić się, że każda funkcja, która może zostać wywołana ma definicję. W tym celu ld przegląda plik wykonywalny i ładuje do pamięci odpowiednie biblioteki. Po załadowaniu wszystkich bibliotek upewnia się, że kod programu odwołuje się do odpowiednich adresów w bibliotekach i zaczyna wykonywanie programu.

ld cd.

Jeżeli dwa programy odwołują się do tej samej biblioteki, to jest duża szansa, że biblioteka zostanie załadowana tylko raz i jej kod będzie dzielony między używające jej programu. Dzięki temu oszczędzamy pamięć kosztem szybkości wykonania.

Przestrzeń adresowa procesu

Pamięć działającego procesu razem z załadowanymi bibliotekami możemy obejrzeć w pliku maps w katalogu /proc/<nr_procesu>.

Kolejne linijki to adresy w przestrzeni adresowej procesu, prawa do obszaru pamięci (dane do odczytu, zapisu, wykonywalne, prywatne/dzielone), przesunięcie w odpowiednim pliku, a na końcu nazwę pliku z którego załadowano ten kawałek pamięci.

Funkcja mmap

System pozwala mapować pliki do obszaru pamięci procesu. Znaczy to, że przy próbie odczytu z jakiegoś regionu pamięci procesu dane będą w istocie czytane z pliku, na odwrót – przy zmianie tych danych dane trafią do pliku na dysku. To jest inny mechanizm dostępu do danych w pliku niż read/write – czasem znacznie szybszy. Poza tym znacznie bardziej przydaje się, gdy chcemy dużo skakać po pliku, bo nie trzeba wielokrotnie wywoływać lseek.

Funkcja mmap cd.

Funkcja mmap udostępnia coś w rodzaju „okienka”, przez które można oglądać w pamięci i zmieniać plik. Odpowiednie dane do pamięci nie są od razu wczytywane, dopiero wtedy gdy rzeczywiście będą potrzebne.

Funkcja mmap cd.

#include <sys/mman.h>
void *mmap(void *addr, size_t len, int prot, int flags, int fildes, off_t off);

Funkcja mapuje fragment długości len bajtów od miejsca off w pliku o deskryptorze fildes. prot to prawa zapisu/odczytu/wykonywania a flags decyduje o tym, czy mapowanie jest współdzielone z innymi procesami czy prywatne. Zwracana wartość jest wskaźnikiem na początek zmapowanego obszaru pamięci i może zależeć od przekazanej wskazówki addr. Mapowanie usuwamy funkcją munmap.

Dokumentacja

Funkcja mmap może bardzo różnić się między systemami operacyjnymi. Polecenie man 3p mmap wyświetli dokumentację dla największego wspólnego mianownika: tego co definiuje standard POSIX. Aby zobaczyć co można zrobić pod Linuksem, trzeba przeczytać man 2 mmap.

Przykład

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>
#include <stdint.h>
uint64_t hash(uint64_t x) {
x = (x ^ (x >> 30)) * UINT64_C(0xbf58476d1ce4e5b9);
x = (x ^ (x >> 27)) * UINT64_C(0x94d049bb133111eb);
x = x ^ (x >> 31);
return x;
}
int main(int argc, char *argv[]) {
int fd = open("/tmp/mmap.bin", O_RDWR | O_CREAT | O_TRUNC, S_IRWXU);
int size = 1024*1024;
size_t len = sizeof(uint64_t)*size;
lseek(fd, len - 1, SEEK_SET);
write(fd, "", 1);
uint64_t *data = (uint64_t *) mmap(0, len, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
data[size-1] = hash(size-1);
for (int i = size - 2; i >= 0; --i) {
data[i] = hash(data[i+1]);
}
munmap(data, len);
close(fd);
return 0;
}

Alokacja pamięci

Funkcja mmap może także mapować nieistniejące, anonimowe pliki. Efektem jest zwiększanie dostępnej przestrzeni adresowej procesu.

Alokacja pamięci cd.

Kiedy w programie potrzebujemy pamięci, normalnym sposobem jej uzyskania jest wywołanie funkcji bibliotecznej malloc (w C++ wywołujemy operator new). Funkcja malloc alokuje pamięć z systemu używając mmap lub innej funkcji – sbrk, a potem przydziela i zwalnia mniejsze kawałki pamięci zgodnie z życzeniami programisty bez wywoływania funkcji systemowych (co przyspiesza działanie, podobnie jak bufory odczytu i zapisu).

Śledzenie funkcji systemowych

strace

Program strace pozwala śledzić wywołania wszystkich funkcji systemowych, ich argumenty i zwracane wartości. Aby go użyć po prostu przed poleceniem uruchamiającym program wpisujemy strace.

$ strace true