autor: Wojtek Hury <wh159141@students.mimuw.edu.pl>

Podsystem wejścia-wyjścia

Obsługa urządzeń blokowych i znakowych

1. Wstęp

Dokument ten powstał w ramach projektu na zajęciach Systemy Operacyjne w semestrze zimowym roku akademickiego 2001/2002. Celem projektu jest przygotowanie materiałów do ćwiczeń z przedmiotu Budowa systemu operacyjnego Linux (wersja 2.4.7).
Niniejsze opracowanie opisuje obsługę urządzeń blokowych i znakowych. Chcę zwrócić uwagę na to, że wszystkie opisywane przeze mnie struktury danych i algorytmy dotyczą jądra Linuxa w wersji 2.4.7 i mogą być nieaktualne dla późniejszych jego wersji. Moim zamiarem było opisanie sposobu, w jaki Linux udostępnia urządzenia, bez wchodzenia w szczegóły działania algorytmów opisanych w innych częściach projektu.
 

2. Urządzenia w systemie Linux

W systemie Linux urządzenia podzielone są na trzy grupy: znakowe, blokowe i sieciowe. My zajmiemy się urządzeniami blokowymi i znakowymi. Oto charakterystyczne cechy urządzeń blokowych:
  • stała wielkość (dla jednego urządzenia) przesyłanych danych (porcję danych o tej ustalonej wielkości nazywamy blokiem) - zwykle wynosi ona 512 lub 1024 bajty;
  • swobodny dostępem do danych - urządzenie blokowe oferuje dostęp do dowolnego bloku danych, bez względu na jego adres na tym urządzeniu;
  • buforowanie danych - przy przesyłaniu danych do/z urządzenia dane są buforowane, co przyspiesza ich wymianę pomiędzy pamięcią i urządzeniem a także zmniejsza liczbę rzeczywistych odwołań do urządzenia;
  • i znakowych: Urządzeniami blokowymi są na przykład twarde dyski i cd-romy. Wśród urządzeń znakowych można wymienić klawiaturę, myszkę i terminale.

    Identyfikacja urządzeń

    Urządzenia identyfikowane są przez swój typ (blokowe/znakowe) oraz parę liczb naturalnych MAJOR MINOR. Numer MAJOR (nazywany numerem głównym) identyfikuje miejsce w tablicy rozdzielczej - Linux ma dwie tablice: jedną dla urządzeń znakowych i jedną dla blokowych. W tych tablicach  przechowywane są wskaźniki do struktur z podprogramami obsługi urządzeń (więcej informacji o tablicach rozdzielczych można znaleźć w rozdziale im poświęconym). Liczba MINOR (nazywana numerem podrzędnym) służy do rozpoznania urządzenia przez podprogram jego obsługi (jeden podprogram obsługi może  sterować działaniem wielu urządzeń). Liczba MAJOR określająca urządzenie może należeć do zakresu 1..254 - z tym, że wiele numerów jest zajętych przez istniejące sterowniki. 255 jest zarezerwowane dla przyszłych zastosowań, kiedy numeracja urządzeń zostanie rozszerzona. Numer MINOR może należeć do zakresu 0..255.
    Numery urządzenia w wielu miejscach przechowywane są w obiektach (oba naraz) typu kdev_t zdefiniowanego jako

    typedef unsigned short kdev_t;

    Aby wydobyć numer podrzędny lub główny z kdev_t należy użyć makr MAJOR() i MINOR().

    Pliki specjalne

    Dostęp do urządzeń następuje w Linuxie poprzez pliki specjalne, na których programy z przestrzeni użytkownika wykonują standardowe operacje takie jak open(), read() czy write(). W wyniku wywołania tych standardowych operacji na pliku specjalnym system wykonuje odpowiednie funkcje dostarczone przez sterownik urządzenia, któremu odpowiada dany plik. Więcej informacji o plikach specjalnych można znaleźć w rozdziale poświęconym obsłudze plików specjalnych.

    Sterowniki

    Funkcje obsługi urządzenia dostarczane są przez sterowniki. Tak naprawdę można by mówić, że to sterowniki są znakowe lub blokowe, gdyż dane urządzenie może mieć dwa pliki specjalne jemu odpowiadające z dwoma sterownikami - jednym znakowym a drugim blokowym. Sterowniki są częścią jądra systemu. Pliki specjalne nie muszą być skojarzone z fizycznym urządzeniem - utworzenie sterownika takiego "urządzenia" jest sposobem na napisanie nowej funkcji systemowej (jak powiedzieliśmy, sterownik jest częścią jądra). Do takich dziwnych "urządzeń" należą /dev/null pochłaniające wszystko do niego wysłane i /dev/zero generujące zadaną liczbę zer.
     

    3. Sterownik urządzenia

    Jak już wspomniałem, użytkownik widzi urządzenia jako pliki i ma do nich dostęp poprzez standardowe funkcje obsługi plików. Jeśli następuje wywołanie funkcji bibliotecznej, jądro po wykryciu, że plik odnosi się do urządzenia blokowego lub znakowego, wywołuje odpowiednią funkcję dostarczoną przez sterownik. Poniżej przedstawiam mechanizm informowania systemu przez sterownik jakich ma użyć funkcji.

    3.1. Sterownik urządzenia znakowego

    Funkcje dla urządzenia znakowego przechowywane są w strukturze

    struct file_operations {
             struct module *owner;
             loff_t (*llseek) (struct file *, loff_t, int);
             ssize_t (*read) (struct file *, char *, size_t, loff_t *);
             ssize_t (*write) (struct file *, const char *, size_t, loff_t *);
             int (*readdir) (struct file *, void *, filldir_t);
             unsigned int (*poll) (struct file *, struct poll_table_struct *);
             int (*ioctl) (struct inode *, struct file *, unsigned int, unsigned long);
             int (*mmap) (struct file *, struct vm_area_struct *);
             int (*open) (struct inode *, struct file *);
             int (*flush) (struct file *);
             int (*release) (struct inode *, struct file *);
             int (*fsync) (struct file *, struct dentry *, int datasync);
             int (*fasync) (int, struct file *, int);
             int (*lock) (struct file *, int, struct file_lock *);
             ssize_t (*readv) (struct file *, const struct iovec *, unsigned long, loff_t *);
             ssize_t (*writev) (struct file *, const struct iovec *, unsigned long, loff_t *);
             ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *, int);
             unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned long, unsigned long, unsigned long);
    };

    Tablica rozdzielcza urządzeń znakowych jest zdefiniowana następująco:

    struct device_struct {
            const char * name;
            struct file_operations * fops;
    } chrdevs[MAX_CHRDEV];

    Po przygotowaniu funkcji dla urządzenia sterownik powinien zadeklarować strukturę file_operations zawierającą wskaźniki do tych funkcji. Tę strukturę można skojarzyć z urządzeniem za pomocą funkcji rejestrującej

    int register_chrdev(unsigned int major, const char * name, struct file_operations *fops);

    Funkcja register_chrdev() wpisuje do tablicy rozdzielczej chrdevs pod indeksem major wskaźnik fops i nazwę urządzenia name. Jeśli major jest równe 0, funkcja szuka wolnego miejsca w tablicy chrdevs. Urządzenie można wyrejestrować za pomocą funkcji

    int unregister_chrdev(unsigned int major, const char * name)

    Z każdym plikiem specjalnym odpowiadającym urządzeniu znakowemu skojarzona jest domyślna funkcja otwierania pliku:

    int chrdev_open(struct inode * inode, struct file * file)

    wywołanie której powoduje wpisanie do file->f_op wskaźnika do struktury file_operations znalezionego w tablicy rozdzielczej chrdevs pod indeksem odpowiadającym numerowi głównemu urządzenia (numer skojarzonego z plikiem urządzenia jest przechowywany w i-węźle inode - więcej informacji o tym można znaleźć w opracowaniu dotyczącym obsługi plików specjalnych). Następnie, jeśli w strukturze file_operations sterownik dostarczył funkcję open, chrdev_open ją wywoła.
    Po tej zmianie struktury file skojarzonej z tym plikiem, każde wywołanie na nim funkcji bibliotecznej spowoduje wywołanie odowiedniej funkcji sterownika.
    Uwaga: sposobem na skojarzenie różnych funkcji z różnymi urządzeniami o tym samym numerze głównym jest zmienianie struktur file->f_op w zależności od numeru podrzędnego urządzenia - takiej zmiany dokonuje się w dostarczanej przez sterownik funkcji open().
    Oto schemat działania systemu przy wywołaniu  open() na pliku skojarzonym z urządzeniem znakowym:

    3.1. Sterownik urządzenia blokowego

    Funkcje odpowiadające standardowym operacjom na pliku przechowywane są dla urządzenia blokowego w strukturze

    struct block_device_operations {
             int (*open) (struct inode *, struct file *);
             int (*release) (struct inode *, struct file *);
             int (*ioctl) (struct inode *, struct file *, unsigned, unsigned long);
             int (*check_media_change) (kdev_t);
             int (*revalidate) (kdev_t);
    };

    oraz w strukturze file_operations.
    Jest to nowość - we wcześniejszych wersjach (jądro 2.2.*) funkcje dla urządzeń blokowych były przechowywane tak jak w przypadku urządzeń znakowych - tylko w strukturze file_operations.

    Tablica rozdzielcza urządzeń znakowych jest zdefiniowana następująco:

    struct block_device {
            struct list_head        bd_hash;
            atomic_t                bd_count;
            dev_t                   bd_dev;  /* not a kdev_t - it's a search key */
            atomic_t                bd_openers;
            const struct block_device_operations *bd_op;
            struct semaphore        bd_sem; /* open/close mutex */
    };
    static struct {
            const char *name;
            struct block_device_operations *blkops;
    } blkdevs[MAX_BLKDEV];

    Po przygotowaniu funkcji dla urządzenia blokowego sterownik powinien zadeklarować strukturę block_device_operations zawierającą wskaźniki do tych funkcji. Strukturę tę można skojarzyć z urządzeniem za pomocą funkcji rejestrującej

    int register_blkdev(unsigned int major, const char * name, struct block_device_operations *bdops);

    Funkcja działa analogicznie do register_chrdev() - ustawia odpowiednie wartości w tablicy blkdevs.
    Urządzenie blokowe możemy wyrejestrować przy pomocy funkcji

    int unregister_chrdev(unsigned int major, const char * name)

    Z każdym plikiem specjalnym odpowiadającym urządzeniu blokowemu skojarzona jest domyślna struktura file_operations:

    struct file_operations def_blk_fops = {
             open:           blkdev_open,
             release:        blkdev_close,
             llseek:         block_llseek,
             read:           block_read,
             write:          block_write,
             fsync:          block_fsync,
             ioctl:          blkdev_ioctl,
    };

    blkdev_open identyfikuje urządzenie, z którym związany jest otwierany plik specjalny, na podstawie pola i_bdev (wskaźnik do strukturyw block_device) i-węzła skojarzonego z otwieranym plikiem. Następnie, jeśli wskaźnik inode->i_bdev->bd_op do struktury block_device_operations nie jest zainicjalizowany, przypisuje mu wartość wskaźnika z miejsca tablicy rozdzielczej odpowiadającego danemu urządzeniu. Następnie, jeśli inode->i_bdev->bd_op->open jest określony, wywołuje tę funkcję.
    Oto schemat działania systemu przy wywołaniu  open() na pliku skojarzonym z urządzeniem blokowym:

    Funkcje block_read() i block_write() struktury def_blk_fops są standardowymi funkcjami zapisu i odczytu dla urządzeń blokowych i zostały opisane w odrębnym dokumencie.
    Funkcja block_fsync() synchronizuje dane pomiędzy urządzeniem a pamięcią.
    Funkcja block_llseek() zadeklarowana jako
    static loff_t block_llseek(struct file *file, loff_t offset, int origin)
    ustawia odpowiednio wskaźnik file->filepos.
    Funkcja blkdev_close() wywołuje block_fsync() oraz inode->i_bdev->bd_op->release().
    Funkcja blkdev_ioctl() wywołuje inode->i_bdev->bd_op->ioctl().

    Inne struktury danych, o których piszący sterownik powinien wiedzieć to:

    struct blk_dev_struct {
            request_queue_t  request_queue;
            queue_proc *queue;
            void *data;
    };
    Dla każdego urządzenie blokowego system przechowuje kolejkę kierowanych do niego żądań. Struktury blk_dev_struct przechowują taką kolejkę, a także wskaźnik do funkcji wybierającej odpowiednią kolejkę. Struktury te są przechowywane w tablicy indeksowanej numerami głównymi:

    struct blk_dev_struct blk_dev[MAX_BLKDEV];

    Jeśli sterownik zamierza korzystać tylko z jednej kolejki żądań, powinien zainicjować pole request_queue. Jeśli tych kolejek będzie więcej, powinien ustawić pole queue, które ma wskazywać funkcję zwracającą wskaźnik do kolejki w zależności od numerów głównego i podrzędnego. Typ queue_proc zdefiniowany jest jako

    typedef request_queue_t * (queue_proc) (kdev_t dev);

    W request_queue_t znajdują się struktury danych służące do przechowywania żądań a także wskaźnik do funkcji odpowiedzialnej za niskopoziomowe procedury realizacji żądań (nazywaną procedurą strategii). Typ tej funkcji jest zdefiniowany jako

    typedef void (request_fn_proc) (request_queue_t *q);

    i sterownik powinien taką funkcję dostarczyć.
    Zainicjować request_queue, przydzielając jej odpowiednią funkcję request_fn_proc, można przy pomocy funkcji blk_init_queue():

    blk_init_queue(request_queue_t *queue, request_fn_proc *request);

    Każdemu wywołaniu blk_init_queue() powinno odpowiadać wywołanie

    void blk_cleanup_queue(request_queue_t *);

    int * blk_size[MAX_BLKDEV];
    Tablica tablic indeksowana MAJOR i MINOR - przechowywane są w niej rozmiary urządzeń w kilobajtach.

    int * blksize_size[MAX_BLKDEV];
    Tablica tablic indeksowana MAJOR i MINOR - przechowywane są w niej rozmiary bloków danych dla urządzeń.

    int * hardsect_size[MAX_BLKDEV];
    Tablica tablic indeksowana MAJOR i MINOR - przechowywane są w niej rozmiary sektorów w urządzeniu.

    int read_ahead[MAX_BLKDEV];
    Tablica indeksowana MAJOR - liczba sektorów czytanych z wyprzedzeniem dla każdego sterownika.

    int * max_readahead[MAX_BLKDEV];
    Tablica tablic indeksowana MAJOR i MINOR - liczba sektorów czytanych z wyprzedzeniem dla każdego urządzenia.

    int * max_sectors[MAX_BLKDEV];
    Tablica tablic indeksowana MAJOR i MINOR - maksymalna iczba sektorów w jednym żądaniu.

    Uwaga: Sterownik powinien sam zaalokować pamięć dla tablic indeksowanych MINOR.
     

    4. Procedura strategii

    Wspomniałem już wyżej o procedurze strategii - funkcji, którą będę tutaj nazywał do_request(). Procedura strategii jest odpowiedzialna za niskopoziomową realizację żądań przychodzących do urządzenia. Jeśli żądanie uda się zrealizować wywołuje funkcję end_request() z parametrem równym 1, w przeciwnym wypadku równym 0. Kiedy przekaże żądanie urządzeniu, może posłużyć się dwoma mechanizmami w celu jego ukończenia: Poniżej przedstawię dwie hipotetyczne funkcje do_request() - pierwszą z aktywnym czekaniem, drugą korzystającą z mechanizmu przerwań.
    Makra INIT_REQUEST oraz CURRENT zdefiniowane są w pliku include/linux/blk.h. CURRENT wskazuje na żądanie w kolejce żądań, które ma być właśnie spełnienone. INIT_REQUEST wykonuje pewne sprawdzające czynności niezbędne przed rozpoczęciem realizacji żądania.

    static void do_foo_request(...) {
        while(... /*kolejka żądań nie jest pusta*) {
            INIT_REQUEST;
            if (CURRENT->cmd == WRITE) { /*piszemy*/
                if (foo_write(CURRENT->sector, CURRENT->buffer, CURRENT->nr_sectors << 9)) {
                    /* pisanie się powiodło  - foo_write musiało przesłać dane
                     * do urządzenia i odpytywać je, aż dostało odpowiedź
                     * o realizacji żądania */
                    end_request(1);
                } else {
                    end_request(0);
                }
            }
            if (CURRENT->cmd == READ) { /*czytamy*/
                if (foo_read(CURRENT->sector, CURRENT->buffer, CURRENT->nr_sectors << 9)) {
                    /* czytanie się powiodło  - foo_write musiało przesłać dane
                     * do urządzenia i odpytywać je, aż dostało odpowiedź
                     * o realizacji żądania */
                    end_request(1);
                } else {
                    end_request(0);
                }
            }
        }
    }

    Natomiast w przypadku wykorzystania mechanizmu przerwań, kod do_request() mógłby wyglądać tak:

    static int foo_busy; /* inicjowane na zero */

    static void do_foo_request(...) {

        if (foo_busy) /* inne żądanie jest właśnie przetwarzane */
            return;
        foo_busy = 1;
        foo_initialize_io();
    }

    static void foo_initialize_io(void) {

        if (CURRENT->cmd == READ) {
            SET_INTR(foo_read_intr);  //ustawienie obsługi przerwania
        } else {
            SET_INTR(foo_write_intr); //ustawienie obsługi przerwania
        }
        ...
        /*  wysłanie polecenia do urządzenia
            jeśli czytanie, to tylko żądanie czytania,
            jeśli pisanie trzeba przygotować dane do
            zapisania i też wysłać polecenie */
    }

    static void foo_read_intr(void) {
        int error=0;

        ...
        /* przeczytanie z urządzenia (wiemy, że dane są gotowe)
         i umieszczenie w CURRENT->buffer;
         ustawienie error=1 jeśli były jakieś błędy */
        end_request(error?0:1);
        if (!CURRENT)     /* jeśli nie ma nowych żądań */
            foo_busy = 0;
        INIT_REQUEST;
        /* INIT_REQUEST spowoduje zakończenie foo_read_intr, jeśli nie ma nowych żądań */

        /* nowe żądanie */
        foo_initialize_io();
    }

    static void foo_write_intr(void) {
        int error=0;

        ...
        /* dane zostały zapisane. error=1 jeśli nastąpił błąd */
        end_request(error?0:1);
        if (!CURRENT)    /* jeśli nie ma nowych żądań */
            foo_busy = 0;
        /* INIT_REQUEST spowoduje zakończenie foo_write_intr, jeśli nie ma nowych żądań */
        INIT_REQUEST;
        /* Przerobimy nowe żądanie */
        foo_initialize_io();
    }