Wywołania systemowe Uniksa

W systemach uniksowych program jest całkowicie odizolowany od sprzętu, dlatego zawsze musi się odwoływać do odpowiednich funkcji jądra.

Z punktu widzenia programu odwołania te są ukryte w bibliotece libc – program nie wie, czy dana funkcja dostarczana jest bezpośrednio przez jądro, czy też implementuje ją libc korzystając z innych mechanizmów jądra (np. w GNU/Linuksie fork zaimplementowany jest za pomocą clone).

Na x86 i innych systemach o podobnej architekturze libc (lub też czasem program bezpośrednio) komunikuje się z jądrem za pośrednictwem przerwań systemowych. W Linuksie funkcje systemowe są dostępne przez przerwanie 0x80, argumenty są przekazywane w rejestrach w następującej kolejności: eax, ebx, ecx, edx, edi, esi, ebp. Numer funkcji systemowej jest przekazywany w eax, natomiast pozostałe argumenty zależą od rodzaju funkcji (nie wszystkie muszą być wykorzystane). Status operacji zwracany jest w rejestrze eax. Gdy operacja wykona się bezbłędnie, jego wartość jest równa 0, w przeciwnym razie jest to (ujemna) stała z pliku asm/errno.h[1]. Pozostałe rejestry nie są zmieniane.

W przypadku innych procesorów wywołania systemowe są wykonywane przez specjalizowane instrukcje procesora – np. Pentium 4 posiada instrukcję sysenter (ang. system enter).

Śledzenie wywołań

Wywołania systemowe można śledzić za pomocą programu truss (większość uniksów) lub strace (Linux).

Oto przykład działania strace dla trywialnego programu true:

execve("/bin/true", ["true"], [/* 35 vars */]) = 0
uname({sys="Linux", node="myhost", ...}) = 0
brk(0)                                  = 0x804a308
access("/etc/ld.so.nohwcap", F_OK)      = -1 ENOENT (No such file or directory)
open("/etc/ld.so.preload", O_RDONLY)    = -1 ENOENT (No such file or directory)
open("/home/taw/local/lib/libc.so.6", O_RDONLY) = -1 ENOENT (No such file or directory)
stat64("/home/taw/local/lib", {st_mode=S_IFDIR|0755, st_size=72, ...}) = 0
open("/etc/ld.so.cache", O_RDONLY)      = 3
fstat64(3, {st_mode=S_IFREG|0644, st_size=85050, ...}) = 0
old_mmap(NULL, 85050, PROT_READ, MAP_PRIVATE, 3, 0) = 0x40012000
close(3)                                = 0
open("/lib/libc.so.6", O_RDONLY)        = 3
read(3, "\\177ELF\\1\\1\\1\\0\\0\\0\\0\\0\\0\\0\\0\\0\\3\\0\\3\\0\\1\\0\\0\\0@Z\\1\\000"..., 1024) = 1024
fstat64(3, {st_mode=S_IFREG|0755, st_size=1109900, ...}) = 0
old_mmap(NULL, 1122692, PROT_READ|PROT_EXEC, MAP_PRIVATE, 3, 0) = 0x40027000
mprotect(0x40130000, 37252, PROT_NONE)  = 0
old_mmap(0x40130000, 20480, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED, 3, 0x109000) = 0x40130000
old_mmap(0x40135000, 16772, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x40135000
close(3)                                = 0
old_mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x4013a000
munmap(0x40012000, 85050)               = 0
brk(0)                                  = 0x804a308
brk(0x804b308)                          = 0x804b308
brk(0x804c000)                          = 0x804c000
_exit(0)

Uwaga: szczegóły dotyczą jądra Linux 2.4, ale różnice nie są aż tak duże.

Otwieranie i zamykanie plików – open, create i close

Pliki otwiera się za pomocą trzyargumentowego open, którego definicja znajduje się w fcntl.h[2]:

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

Pierwszy argument pathname oznacza ścieżkę do pliku.

Drugi flags – opcje otwarcia, z czego ważniejsze to:

  • O_RDONLY – otwórz tylko do odczytu
  • O_WRONLY – otwórz tylko do zapisu
  • O_RDWR – otwórz do zapisu i odczytu
  • O_CREAT – utwórz plik jeśli nie istnieje
  • O_EXCL – używane razem z O_CREAT – zwróć błąd jeśli plik już istnieje
  • O_TRUNC – jeśli w pliku są już jakieś dane, skasuj je
  • O_APPEND – plik jest otwierany w trybie dopisywania do końca
  • O_NONBLOCK – otwórz plik w trybie nieblokującym

Opcjonalny trzeci – uprawnienia dla nowo utworzonych plików. open należy do nielicznych wywołań systemowych dopuszczających pomijanie argumentu:

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

Istnieje też specjalna postać open:

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

równoważna open(pathname, O_CREAT|O_WRONLY|O_TRUNC, mode)

W przypadku powodzenia open i creat zwracają numer otwartego deskryptora pliku. W przypadku błędu zwracają -1 a errno jest ustawiana na kod błędu.

int close(int fd);

zamyka otwarty deskryptor pliku. W dawnych czasach close nie zwracało kodu błędu, więc nikt go nie sprawdzał. Współcześnie zwraca kod błędu, co z punktu widzenia architektury systemu jest kompletnym nieporozumieniem – nikt tak naprawdę nie zdefiniował co konkretnie ma znaczyć błąd przy zamykaniu pliku i co program ma z tym zrobić.

Kernel może zwracać błędy EBADF (deskryptor jest zły), EINTR i dość ogólny EIO (błąd wejścia/wyjścia).

Tworzenie plików specjalnych – mkdir, mkfifo, mknod

open i creat potrafią tworzyć tylko zwykłe pliki. Do tworzenia innych plików stworzono osobne wywołania systemowe.

Katalogi tworzy się za pomocą:

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

gdzie pathname i mode mają znaczenie podobne jak w creat.

Pliki urządzeń tworzy się za pomocą:

int mknod(const char *pathname, mode_t mode, dev_t dev);

gdzie pathname i mode mają to samo znaczenie a dev to informacje o typie urządzenia.

Zakończenie pracy – _exit

void _exit(int status);

służy do zakończenia pracy programu. status zostanie zwrócony jako kod wyjścia.

Zarządzanie pamięcią – brk

int brk(void *end_data_segment);

Wywołanie brk jest bardzo ważne i widać je często w wynikach strace, ale praktycznie nigdy nie jest używane bezpośrednio. brk zmienia wielkość sterty programu.

Do alokacji pamięci (szczególnie pamięci dzielonej między procesami) można używać też mmap i innych wywołań systemowych.

Pisanie i czytanie

Otwarty deskryptor plików służy głównie do zapisu i odczytu danych. Podstawowe wywołania systemowe to read i write, jednak ze względu na kwestie wydajności powstały też inne takie jak readv, writev i sendfile.

Innym mechanizmem jest mmap.

read

read jest zdefiniowany w unistd.h[] jako:

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

Pierwszym argumentem jest otwarty deskryptor piku, drugim bufor, do którego mają się dostać zapisywane dane, trzecim zaś liczba danych, którą co najwyżej chcemy odczytać. read zwraca liczbę bajtów, która była w rzeczywistości odczytana.

Liczba ta może być mniejsza od żądanej z wielu przyczyn – np. jeśli akurat w danej chwili ilość danych dostępnych na połączeniu sieciowym jest mniejsza od żądanej, lub też jeśli zanim odczytano wszystkie dane nastąpiło przerwanie.

I o ile wartości od 1 do count są poprawne, wartość 0 może oznaczać tylko jedno – koniec pliku. Jeśli przerwanie nastąpiło przed odczytaniem danych, kernel zwraca kod błędu (errno) EINTR, jeśli zaś nie było aktualnie żadnych danych, a połączenie było otwarte w trybie nieblokującym – EAGAIN.

Inne możliwe błędy to:

  • EBADF – błędny deskryptor
  • EINVAL – deskryptor nie do odczytu (np. otwarty jako tylko do zapisu)
  • EIO – błąd wejścia wyjścia
  • EISDIR – deskryptor wskazuje na katalog. Na niektórych systemach katalogi można czytać za pomocą read, jednak służyło to wyłącznie implementacji odpowiednich procedur libc. Na innych jest to niedozwolone, a libc radzi sobie w inny sposób.
  • EFAULT – błędny adres bufora, poza przestrzenią adresową procesu

write

write jest zdefiniowany w unistd.h jako:

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

Argumenty mają takie samo znaczenie jak w readwrite pisze do deskryptora fd co najwyżej count bajtów z bufora buf i zwraca liczbę zapisanych bajtów. W przypadku write liczba 0 jest jednak równie poprawna jak pozostałe i można próbować dalej.

readv i writev

Ze względu na każdorazową zmianę kontekstu pracy procesora, wywołanie systemowe jest bardzo kosztowne - jeśli, co często ma miejsce, zapisywane dane składają się z dużej części stałej i małej zmiennej (np. zmienne nagłówki HTTP i plik zawarty w cache'u serwera), możliwe są dwie nieoptymalne strategie:

  1. wywołać write kilkakrotnie (przynajmniej dwa razy)
  2. przepiąć dane tak, żeby były ciągłe w pamięci, po czym wywołać write tylko jeden raz

Nic nie stoi jednak na przeszkodzie, żeby kernel sam zajął się tą operacją – służą temu zdefiniowane w sys/uio.h wywołania:

int readv(int filedes, const struct iovec *vector, size_t count);
int writev(int filedes, const struct iovec *vector, size_t count);

Pierwszy argument to tradycyjnie otwarty deskryptor pliku, drugi to wskaźnik na tablicę wektorów, trzeci zaś to ilość elementów tej tablicy. Element ma postać:

struct iovec {
    void *iov_base;
    size_t iov_len;
};

gdzie iov_base to adres a iov_len rozmiar bufora.

Procedura naszego serwera miałaby wówczas postać:

struct iovec io[2];
io[0].iov_base = http_headers;
io[0].iov_len  = http_headers_size;
io[1].iov_base = file_headers;
io[1].iov_len  = file_headers_size;
writev (fd, io, 2);

readv i writev pojawiły się po raz pierwszy w systemie 4.2BSD. readv nie jest aż tak istotne jak writev.

sendfile

Kolejny często występujący problem wydajności przedstawia następujący fragment kodu:

bytes_read = read (fd1, buf, buf_size);
write (fd2, buf, bytes_read);

Często występuje konieczność przerzucenia ogromnej ilości danych z jednego deskryptora do drugiego. Jednym problemem jest podwojona liczba wywołań systemowych, ale jeszcze poważniejse jest zupełnie bezużyteczne kopiowanie danych. Przeciwdziałać temu ma zdefiniowany w nagłówku sys/sendfile.h:

ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

out_fd to deskryptor wyjściowy, in_fd – wejściowy, offset to wskaźnik na zmienną przechowującą offset w pliku wejściowym, od którego ma zacząć dane wywołanie, a count – ilość danych do przetransferowania. Wywołanie zwraca ilość rzeczywiście zapisanych danych i poprawia offset na nową wartość.

sendfile nie stosuje zwyczajnych metod przesuwania offsetu dla pliku wejściowego, co umożliwia używanie jednego deskryptora do wielu takich operacji jednocześnie. Np. serwer HTTP może wysyłać ten sam plik przez kilka połączeń naraz i dzięki temu rozwiązaniu nie musi wielokrotnie duplikować deskryptora, a po ich rozłączeniu wielokrotnie go zamykać.

Offset pliku wyjściowego jest poprawiany normalnie – wysyłanie jednocześnie kilku plików na ten sam deskryptor nie miałoby większego sensu (sekwencyjnemu wysyłaniu oczywiście to nie przeszkadza).

sendfile taki jak tu pokazany pojawił się w Linuksie 2.2, jednak wywołania o podobnym działaniu istnieją również w innych systemach.

Przykład działania:

#include <sys/sendfile.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>

int main(int argc, char **argv)
{
    off_t ofs=0;
    int in, out;

    if (argc < 3)
    {
        fprintf (stderr, "Usage: %s <infile> <outfile>\n", argv[0]);
        return 1;
    }
    in = open (argv[1], O_RDONLY);
    if (in == -1)
    {
        fprintf (stderr, "Can't open %s\n", argv[1]);
        return 1;
    }
    out = open (argv[2], O_WRONLY|O_CREAT, 0666);
    if (out == -1)
    {
        fprintf (stderr, "Can't open %s\n", argv[2]);
        return 1;
    }
    while (sendfile(out, in, &ofs, 64*1024*1024) > 0);
    return 0;
}

Ponieważ dane nie wędrują przez pamięć procesu, można podawać „absurdalne” wartości typu (jak wyżej) 64 megabajty i kernel dobrze sobie z nimi radzi. Powyższy program kopiuje plik 32219641-bajtowy (linux-2.4.19.tar.gz) prawie dwukrotnie szybciej niż cat (który robi to 4-kilobajtowymi odwołaniami read i write), czy standardowy cp (niektóre nowsze wersje używają mmap lub sendfile).

Ciekawa część wyników strace to:

open("/home/username/linux-2.4.19.tar.gz", O_RDONLY) = 3
open("/home2/username/linux-kopia", O_WRONLY|O_CREAT, 0666) = 5
sendfile(5, 3, [0], 67108864)           = 32219641
sendfile(5, 3, [32219641], 67108864)    = 0
_exit(0)                                = ?

Sprawdzanie uprawnień

Zdefiniowane w unistd.h wywołanie:

int access(const char *pathname, int mode);

służy do sprawdzenia praw do pliku pathname.

Tryb to maska złożona z:

  • R_OK – plik można czytać
  • W_OK – do pliku można pisać
  • X_OK – plik jest wykonywalny
  • F_OK – plik istnieje

Semantyka wywołania access nie jest jednak prosta.

access patrzy się jedynie na uprawnienia, nie na rzeczywiste możliwości, tak więc:

  • jeśli system jest zamontowany read only, access pokaże W_OK zależnie od uprawnień, choć nie można na nim pisać
  • znaczenie praw R_OK, W_OK, X_OK dla katalogów jest inne niż dla plików
  • prawa X_OK często mają pliki które nie nadają się do wykonywania, np. w systemach plików ISO 9660 czy FAT.
  • itd.

access zwraca tylko prawa przysługujące uprawnieniom real, nie zaś effective – tak więc ma pewne zastosowanie w programach używających praw setuid czy też setgid. Naiwne stosowanie – sprawdzenie za pomocą wywołania access, po czym otwarcie pliku za pomocą open – stwarza jednak lukę czasową, w trakcie której plik może zostać podmieniony.

Przykład działania:

#include <unistd.h>
#include <stdio.h>

char *str[] = {
    "but isn't readable, writable or executable",
    "and is executable but is not readable or writable",
    "and is writable but is not readable or executable",
    "and is writable and executable but not readable",
    "and is readable but is not writable or executable",
    "and is readable and executable but not writable",
    "and is readable and writable but not executable",
    "and is readable, writable and executable"
};

int main(int argc, char *argv[])
{
    int i=1;
    int f,r,w,x;

    for (;i<argc;++i)
    {
        f = !access (argv[i], F_OK);
        if (!f) {
            printf ("File %s doesn't exist\n", argv[i]);
            continue;
        }
        r = !access (argv[i], R_OK);
        w = !access (argv[i], W_OK);
        x = !access (argv[i], X_OK);
        printf ("File %s exists %s\n", argv[i], str[(r<<2)+(w<<1)+x]);
    }
    return 0;
}

Przechwytywanie sygnałów

Kontroler sygnałów instaluje się za pomocą zdefiniowanej w signal.h funkcji:

typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);

Na przykład jeśli nie chcemy pozwolić na Control-C (SIGINT, 2) w trakcie wpisywania danych, możemy przechwycić sygnał:

#include <stdio.h>
#include <signal.h>

void catchsig (int arg)
{
    printf ("We've just got signal %i\n", arg);
}

int main ()
{
    float f;
    signal (SIGINT, catchsig);
    scanf ("%f\n", &f);
    return 0;
}

Zobacz też

  • madvise