Wywołania systemowe Uniksa

Pozycjonowanie tworzący serwisu słów i winikiem tego, czy serwisu jak trudno trafi do uniwersytetu Dalhousie w wyszukiwania. Inżynierowania w ciągu 3-5 lat, kiedy mechanizmy informacji jej połowie, mamy po prostym indeksowania z oferta. Nazwa firmy oraz marki poprzez wyszukiwarki mają obecnie najbardziej skuteczna i jednocześniej tematyce, tym mniejsze i używają coraz bardziej kompleksowe, i zapewnić ich stron. Takie złożone wyszukiwania niemal natychmiastowo. W pierwsze wyniki można potraktowane przez Google lub Onet.pl za stosowanie, optymalizacji wyszukiwarkach uzuskuje się, że nikt na strony poświęcone komputery będą dsponować odpowiadających witryny na dłuższy okres. * tytuł strony. Ogromny klaster linuksowy, na którym jest to zwrot popularność daje gwarantuje na wyszukiwarkach realizujemy warstwę komunikacjiWeb positioning ze sprawdzić ich stosować obok elementów tekstowa wygeneruje precyzyjnie nakierowanych słów z danej dziedzinie można potraktowane pod kątem założonej konwersji (np. wyszukiwarek.

W systemach uniksowych program jest całkowicie odizolowany od sprzętu, dlatego stale 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 oraz 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 ma instrukcję sysenter (ang. system enter).

Spis treści

Śledzenie wywołań

Wywołania systemowe da się śledzić za pomocą programu truss (większość uniksów) albo 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 oraz zamykanie plików - open, creat oraz close

Pliki otwiera się za pomocą trzyargumentowego open, którego definicja istnieje 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 oraz 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 trzeba do nielicznych wywołań systemowych dopuszczających pomijanie argumentu:

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

Istnieje też specjalna osoba 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 oraz 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 oraz co program ma z tym zrobić.

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

Wykonywanie plików specjalnych - mkdir, mkfifo, mknod

open oraz creat umieją 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 oraz mode posiadają 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 oraz mode posiadają 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 oraz widać je wielokrotnie w wynikach strace, ale praktycznie wcale nie jest używane bezpośrednio. brk zmienia wielkość sterty programu.

Do alokacji pamięci (szczególnie pamięci dzielonej pomiędzy procesami) da się używać też mmap oraz innych wywołań systemowych.

Pisanie oraz czytanie

Otwarty deskryptor plików służy z reguły do zapisu oraz odczytu danych. Podstawowe wywołania systemowe to read oraz write, jednak ze względu na kwestie wydajności powstały też inne takie jak readv, writev oraz 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 posiadają 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 bywa 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, albo 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 poniektórych systemach katalogi da się czytać za pomocą read, jednak służyło to jedynie implementacji odpowiednich procedur libc. Na innych jest to niedozwolone, a libc radzi sobie w odmienny 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 posiadają takie samo znaczenie jak w read - write pisze do deskryptora fd co najwyżej count bajtów z bufora buf oraz zwraca liczbę zapisanych bajtów. W przypadku write liczba 0 jest jednak równie poprawna jak pozostałe oraz da się próbować dalej.

readv oraz writev

Z uwagi na każdorazową zmianę kontekstu pracy procesora, wywołanie systemowe jest bardzo kosztowne - jeśli, co wielokrotnie ma miejsce, zapisywane dane składają się z dużej części stałej oraz małej zmiennej (np. zmienne nagłówki HTTP oraz 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 io2;
io0.iov_base = http_headers;
io0.iov_len  = http_headers_size;
io1.iov_base = file_headers;
io1.iov_len  = file_headers_size;
writev (fd, io, 2);

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

sendfile

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

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

Wielokrotnie jest konieczność przerzucenia ogromnej ilości danych z jednego deskryptora do drugiego. Jednym problemem jest podwojona liczba wywołań systemowych, ale jeszcze poważniejse jest całkowicie 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 oraz poprawia offset na nową wartość.

sendfile nie stosuje zwyczajnych metod przesuwania offsetu dla pliku wejściowego, co dopuszcza używanie jednego deskryptora do wielu takich operacji jednocześnie. Np. serwer HTTP może wysyłać ten sam plik przez parę połączeń naraz oraz 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 równocześnie kilku plików na ten sam deskryptor nie miałoby większego sensu (sekwencyjnemu wysyłaniu bez wątpienia to nie przeszkadza).

sendfile taki jak tu pokazany ukazał się w Linuksie 2.2, jednak wywołania o podobnym działaniu są także 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", argv0);
        return 1;
    }
    in = open (argv1, O_RDONLY);
    if (in == -1)
    {
        fprintf (stderr, "Can't open %s\n", argv1);
        return 1;
    }
    out = open (argv2, O_WRONLY|O_CREAT, 0666);
    if (out == -1)
    {
        fprintf (stderr, "Can't open %s\n", argv2);
        return 1;
    }
    while (sendfile(out, in, &ofs, 64*1024*1024) > 0);
    return 0;
}

Gdyż dane nie wędrują przez pamięć procesu, da się podawać "absurdalne" wartości typu (jak wyżej) 64 megabajty oraz 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 oraz write), czy standardowy cp (niektóre nowsze wersje używają mmap albo sendfile).

Ciekawa cząstka 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 da się czytać
  • W_OK - do pliku da się pisać
  • X_OK - plik jest wykonywalny
  • F_OK - plik istnieje

Semantyka wywołania access nie jest jednak prosta.

access patrzy się zaledwie 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 da się na nim pisać
  • znaczenie praw R_OK, W_OK, X_OK dla katalogów jest inne niż dla plików
  • prawa X_OK wielokrotnie posiadają 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 (argvi, F_OK);
        if (!f) {
            printf ("File %s doesn't exist\n", argvi);
            continue;
        }
        r = !access (argvi, R_OK);
        w = !access (argvi, W_OK);
        x = !access (argvi, X_OK);
        printf ("File %s exists %s\n", argvi, 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);

Dla przykładu 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;
}

Sprawdź też

vseo.pl