Wejście-Wyjście

Obsługa urządzeń blokowych i znakowych

Małgorzata Wieczorek

Streszczenie:

Niniejszy dokument omawia mechanizmy obsługi urządzeń blokowych i znakowych w jądrze Linuksa w wersji 2.4. Dokładnie opisuje co dzieje się, gdy użytkownik zgłosi żądanie przesłania lub pobrania danych dla takich urządzeń.

Wstęp

Obsługa urządzeń zewnętrznych w Linuksie dzieli je na dwie kategorie:
  1. urządzenia znakowe
  2. urządzenia blokowe
W grupie blokowych znajdą się te urządzenia, które pozwalają na swobodny dostęp do bloków danych (Random Access). W grupie tej mieszczą się dyski twarde, stacje CD-ROM czy FDD. Pliki obsługujące te urządzenia posiadają wszelkie własności plików regularnych.

W grupie znakowych znajdzie się natomiast szereg urządzeń, które nie pozwalają na swobodny odczyt/zapis. Przykładem może być choćby urządzenie obsługujące kartę dźwiękową (/dev/audio). Odczytując z niego dane dostaniemy to co jest aktualnie podawane na wejście karty dzwiękowej (np. dźwięk zebrany przez mikrofon).

Warto zauważyć, że takie podejście do obsługi urządzeń jest bardzo wygodne. Polecenia, które nie mieszczą się w standarcie obsługi urządzenia (np. odczytanie liczby ścieżek czy głowic twardego dysku, zmiana częstotliwości próbkowania pobieranych czy odczytywanych z karty muzyczej danych, otwieranie kieszeni CD-ROMu) realizowane są przez przesłanie do sterownika urządzeń polecenia nazywanego ioctl.

Tworzone urządzenia nie muszą być związane z fizycznym sprzętem. Program obsługi może zwracać dowolne dane (takimi urządzeniami są np. /dev/null, /dev/zero, w starszych wersjach jądra /dev/sndstat).

Sterownik obsługi urządzenia

Wszelkie urządzenia w Linuksie dostępne są przez tzw. pliki specjalne. Pliki takie obsługują pewien zbiór operacji jakie można wykonać na plikach w systemie plików (np. open, read, write).

Aby utworzyć taki plik należy wydać polecenie: mknod nazwa_pliku c|b major minor

c
urządzenie znakowe
b
urządzenie blokowe
major
numer procedury obsługi urządzenia
minor
numer urządzenia. Pozwala na rozróżnianie różnych urządzeń (np. kolejnych partycji dysku twardego) procedurze obsługi urządzenia.

W systemie operacyjnym Linux, zdefiniowane są na stałe wszystkie możliwe numery procedur urządzeń, które udostępnia jądro. Dzięki temu każde urządzenie może być jednoznacznie zdefiniowane poprzez parę <major, minor> niezależnie od komputera, dystrybucji Linuksa czy wersji jądra.

Dla programisty, który tworzy nowy sterownik obsługi urządzenia rodzi to pewien problem. Jaki numer MAJOR przyporządkować dla swojego sterownika? Twórcy Linuksa zostawili kilka wolnych numerów:

42
powinien być używany tylko i wyłącznie w programach przykładowych (wszelkie tutoriale ucząc jak tworzyć sterowniki urządzeń posługują się tym numerem). Jeśli tworzysz sterownik, który masz zamiar udostępnić NIGDY nie nadawaj mu tego numeru.

60-63, 120-127, 240-254
Numery dostępne dla sterowników, które nie są rozpowszechniane w źródłach jądra. Do użytku wewnętrznego/eksperymentalnego.

0
Podanie tej wartości do funkcji rejestrującej urządzenie automatycznie przydzieli sterownikowi nawyższy dostępny numer MAJOR.

Jeśli chcesz rozpowszechniać swój sterownik, zgłoś się do osoby odpowiedzialnej za przydzielanie tych numerów (przeczytaj plik Documentation/devices.txt).

Sterownik urządzenia blokowego i sterownik urządzenia znakowego, pomimo tego, iż obsługują zupełnie różne urządzenia mogą mieć ten sam numer MAJOR. Wynika to z tego, że owe sterowniki trzymane są w dwóch niezależnych od siebie tabliach (nazywanych dalej rozdzielczymi).

Numery MINOR i MAJOR w dalszej części dokumentu będę nazywać odpowiednio numerami nadrzędnymi i podrzędnymi.

Podprogramy obsługi urządzeń trzymane są w tablicach chrdevs[] i blkdevs[]. W jądrze 2.4 zmieniła się obsługa tablic rozdzielczych dla urządzeń blokowych. Aktualnie wyglądają one tak:

/* Urządzenia znakowe -- plik linux/fs/devices.c */
static struct device_struct chrdevs[MAX_CHRDEV];
static struct device_struct {
  const char *name;
  struct file_operations *fops;
};

/* Urządzenia blokowe -- plik linux/fs/blk_dev.h */
static struct {
  const char *name;
  struct block_device_operations *blkops;
} blkdevs[MAX_BLKDEV];
Struktury file_operations i block_device_operations:
/* Plik linux/include/linux/fs.h */
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);
  int (*mediactl) (kdev_t dev, int op, int optarg);
};      

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 (*writepage) (struct file *, struct page *, int, 
                        size_t, loff_t *, int);
};

Nowością w jądrach 2.4 są operacje readvwritev, które pozwalają czytać/pisać wektor danych (tablicę żądań). Opis tych i pozostałych pól można znaleźć w dokumentach dotyczących tablic rozdzielczych.

Rejestracja sterownika urządzeń w systemie sprowadza się do wypełnienia struktury file_operations lub block_device_operations (zależnie od tego, czy rejestrujemy urządzenie znakowe czy blokowe) i uruchomienia funkcji rejestrującej:

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

int devfs_register_blkdev(unsigned int major, const char * name, 
                          struct block_device_operations *bdops)
int devfs_unregister_blkdev(unsigned int major, const char * name)
W jądrach 2.4 powstaje system pilków podobny do /proc, który w przyszłości ma zastąpić katalog /dev. Urządzenie to nazywa się devfs. Dla urządzenia tego powstał komplet funkcji devfs_*, które na razie po prostu wywołują swoje odpowiedniki ze starszych wersji jądra (tak samo nazwane, ale bez przedrostka devfs_).

Dla urządzeń blokowych należy jeszcze ustawić pola struktury obsługi żądań (struct blk_dev_struct), najlepiej przy pomocy rodziny funkcji blk_queue_init i blk_queue_*, ale o tym powiem w rozdziale dotyczącym obsługi żądań dla urządzeń blokowych.

Obsługa urządzenia znakowego

Rysunek: Schemat wywołań dla urządzeń znakowych (funkcja read())
\includegraphics[width=45mm]{char_flow.eps}

Urządzenia znakowe są bardzo proste w implementacji. Jeszcze raz wymienię najważniejsze własności tego typu urządzeń, różniące je od urządzeń blokowych:

Oto idea obsługi tych urządzeń. Program użytkownika wywołuje funkcję biblioteczną open(), read(), write(), lseek() (w jądrze funkcja ta nazywa się llseek() aby podkreślić, że przesunięcie w pliku jest liczbą 64-bitową), mmap() itp. Dla ustalenia uwagi skupmy się na funkcji read. Dla pozostałych algorytm wygląda analogicznie.

Funkcja biblioteczna zgłasza funkcję systemową sys_read(). Funkcja ta odczytuje inode przypisany do danego deskryptora podanego jako parametr funkcji i odczytuje z niego wskaźnik na funkcję odpowiadającą za polecenie read dla danego urządzenia i wywołuje ją. Funkcja ta to po prostu funkcja ze struktury file_operations przypisanej do sterownika danego urządzenia1. Funkcja ta powinna wypełnić bufor podany przez użytkownika wczytanymi danymi.

Blokujące I/O

Często, gdy użytkownik robi read, a urządzenie nie przysłało jeszcze danych, chcielibyśmy aby czytający proces został wstrzymany, aż do pojawienia sie danych. Aby to zrealizować sterownik urządzenia znakowego sam powinien stworzyć i obsługiwać kolejkę na procesy czekające:

wait_queue_head_t my_queue;
init_waitqueue_head(&my_queue);
/* dla statyczych kolejek wystarczy: */
DECLARE_WAIT_QUEUE_HEAD(my_queue);
Aby dodać do kolejki proces używamy funkcji sleep_on lub interruptible_sleep_on, aby obudzić proces: wake_up, wake_up_interruptible. W dokumentach z rozdziału Procesy można znaleźć dokładny opis tych kolejek i zdefiniowanych na nich operacji.

Urządzenia blokowe

Rysunek: Schemat wywołań dla urządzeń blokowych (funkcja read())
\includegraphics[width=12cm]{block_flow.eps}

Sterownik urządzenia blokowego

Do każdego sterownika urządzenia blokowego przypisana jest kolejka żądań, jakie sterownik powinien obsłużyć:

#include <linux/blkdev.h>
blk_init_queue(request_queue_t *queue, request_fn_proc *request);
blk_cleanup_queue(request_queue_t *queue);

Funkcja request odpowiada za niskopoziomowe procedury czytania i pisania.

Kolejki żądań (request_queue) przechowywane są w tablicy indeksowanej numerami nadrzędnymi sterownika:

struct blk_dev_struct {
  request_queue_t  request_queue;
  queue_proc *queue;
  void *data;
};
struct blk_dev_struct blk_dev[MAX_BLKDEV];
W pliku drivers/block/ll_rw_block.c zadeklarowane są jeszcze takie tablice:

blk_size[][] Indeksowane MAJOR i MINOR. Oznacza rozmiar urządzenia w kilobajtach
blksize_size[][] Rozmiar bloku danych dla każdego z urządzeń. Musi być potęgą dwójki. Gdy NULL - domyślnie 1024 bajty.
hardsect_size[][] Rozmiar sektora w bajtach. Musi być potęgą dwójki, większe lub równe 512. Gdy NULL - domyślnie 512 bajtów.
read_ahead[] Liczba sektorów przy czytaniu z wyprzedzeniem wspólna dla wszystkich urządzeń obsługiwanych przez sterownik.
max_readahead[][] Liczba sektorów przy czytaniu z wyprzedzeniem oddzielna dla każdego z urządzeń.
Sterownik powinien sam zaalokować pamięć dla tablic indeksowanych MINOR.

Analiza żądania czytania

Podobnie jak dla urządzeń znakowych, prześledźmy jak jądro interpretuje żądanie wejścia/wyjścia. Przyjrzyjmy się funkcji czytania.

Funkcja biblioteczna read() wywoła funkcję jądra sys_read() jądra. Funkcja ta na podstawie informacji z i'node z deskryptora otwartego pliku odczyta i wywoła funkcję block_read odczytaną z pola read ze struktury file_operations (pole f_ops i'node). Dla urządzeń blokowych struktura ta inicjowana jest przy tworzeniu i'node na def_blk_fops.

/* fs/block_dev.c */
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,
};
Polecenia zaczynające się od blkdev_*, uruchamiają funkcje ze struktury block_device_operations.

Funkcja block_read() dokładnie opisana jest w innym dokumencie. Tu przedstawię tylko ogólny zarys tego co wykonuje.

Funkcja block_read() dzieli żądany blok danych na serię bloków o wielkości odpowiedniej dla danego urządzenia. Dla każdego z nich uruchamia funkcję getblk(), która sprawdza czy żądany blok jest w pamięci buforowej - jeśli tak, to są one od razu przepisywane do bufora wskazanego przez użytkownika. W przeciwnym generuje żądanie odczytu danego bloku danych - wywoływana jest funkcja ll_rw_blk() dla każdego z takich bloków. Funkcja ta sprawdza poprawność żądania po czym wywołuje funkcję make_request(). Zadaniem tej funkcji jest sformułowanie odpowiednich żądań i zoptymalizowanie ich, np. łączenie dwóch żądań o sąsiednie bloki w jedno o większy blok. Procedura ta od razu umieszcza nowo utworzone żądanie w odpowiednim miejscu kolejki - są one szeregowane przy użyciu algorytmu windowego. Funkcję tą można podmienić, tak aby była odpowiednia dla urządzenia (w jądrze 2.4 podmienia ją np. sterownik RAM-dysku). Służy do tego funkcja (kolejka q powinna być zainicjowana):

void blk_queue_make_request(request_queue_t * q, make_request_fn * mfn)

Dla urządzeń blokowych realizowane jest czytanie z wyprzedzeniem. Oznacza to, że przy odczytywaniu żądanych bloków dyskowych odczytywane jest kilka (tyle ile zapisano w tablicy read_ahead[MAX_BLKDEV]) bloków więcej niż żądano - oczywiście zapisywane są one tylko w pamięci buforowej.

Po wygenerowaniu wszystkich żądań proces jest wstrzymywany aż do odczytania wszystkich bloków.

Procedura strategiczna - request()

Wróćmy jeszcze na chwilę do funkcji request(). Funkcja ta powinna zainicjować nowe żądanie makrem INIT_REQUEST (będzie ono dostępne jako makro CURRENT - wskaźnik na strukturę request). INIT_REQUEST dokona jeszcze standardowego sprawdzenia poprawności żądania i w wypadku błędu lub, gdy nie będzie już nowych żądań do obsłużenia zakończy funkcję request(). Po zakończeniu realizacji żądania funkcja powinna wykonać funkcję end_request z parametrem 1 gdy udało się obsłużyć żądanie lub 0 w wypadku błędu. Następnie funkcja powinna zacząć obsługiwać następne żądanie (wywołując INIT_REQUEST).

W praktyce spotyka się dwie metody pisania funkcji request.

Tryb przepytywania

Pierwsza metoda, tzw. tryb przepytywania, polega na tym, że funkcja request() obsługuje żądania przekazując je jedno po drugim do urządzenia, za każdym razem odpytując urządzenie czy operacja I/O została zakończona.

Schemat takiej funkcji:

void request_1(request_queue_t * q)
{
  unsigned long poczatek_io;
  while (1) {
    INIT_REQUEST;
    poczatek_io = jiffies;
    switch (CURRENT->cmd) {
    case READ:
      /* inicjalizacja operacji czytania */
      break;
    case WRITE:
      /* inicjalizacja operacji pisania */
      break;
    default:
      end_request(0);
      continue;
    };
    while (1) {
      if (odczyt_statusu() & KONIEC_OPERACJI_IO) {
        end_request(1);
        break;
      };
      schedule();
      if (poczatek_io > jiffies - 1000) {
        end_request(0);
        break;
      };
    };
  };
};
schedule() wywoływana jest w celu uniknięcia marnowania czasu procesora.

Taka metoda zastosowana jest np. w sterowniku obsługi kontrolera dysku XT (drivers/block/xd.c). Funkcją realizującą oczekiwanie jest tam funkcja xd_waitport():

static inline u_char xd_waitport (u_short port, u_char flags,
                                  u_char mask, u_long timeout)
{
  u_long expiry = jiffies + timeout;
  int success;

  xdc_busy = 1;
  while ((success = ((inb(port) & mask) != flags)) &&
         time_before(jiffies, expiry)) {
    xd_timer.expires = jiffies;
    cli();
    add_timer(&xd_timer);
    sleep_on(&xdc_wait);
    del_timer(&xd_timer);
    sti();
  }
  xdc_busy = 0;
  return (success);
}

Sterowanie przerwaniami

Drugą metodę stosujemy, gdy urządzenie potrafi zgłosić przerwanie w momencie zakończenia operacji wejścia/wyjścia. Funkcja zleca wtedy operację I/O, prosi kontroler, aby zgłosił przerwanie po zakończeniu transferu danych i kończy działanie (nie wywołując end_request()). Gdy kontroler urządzenia zgłosi przerwanie, procedura obsługi przerwania uruchamia end_request(), pobiera następne żądanie z kolejki i powtarza schemat.

Wyżej wymieniony podział dotyczy również sterowników urządzeń znakowych - oczywiście dla nich implementuje się je w procedurach obsługujących read()write(). Przykładowym urządzeniem sterowanym przerwaniami jest sterownik Microsoft BusMouse (devices/char/msbusmouse.c). Implementuje on standardowy interfejs busmouse, który pozwala na odczytanie informacji o ruchach myszy i naciśniętych przyciskach. Czytanie z urządzenia zwraca 3 bajty opisujące zmiany od ostatniego czytania (lub blokuje proces, gdy nie zdarzył się żaden ruch).

Sterownik msbusmouse rejestruje w systemie procedurę obsługi przerwania dla myszki wywołując funkcję:

  if (request_irq(mouse_irq, ms_mouse_interrupt, 0, "MS Busmouse", NULL))
    return -EBUSY;
przy otwieraniu urządzenia (funkcja open()). Gdy tylko zostanie zgłoszone przerwanie (tak więc zostanie wywołana funkcja ms_mouse_interrupt()), zostanie obudzony proces czekający na kolejce urządzenia (konkretnie busmouse_data[mousedev]->wait), tak aby od razu mógł odczytać informacje o zmianach.

Urządzenia wielokolejkowe

Domyślnie jądro przyporządkowuje jedną kolejkę każdemu sterownikowi urządzeń (jedna na każdy numer nadrzędny). W jądrach 2.4 umożliwiono ustanowienie nie tylko kolejek żądań zależnych od numeru podrzędnego (jak w jądrach 2.2), ale także osobnych funkcji request() obsługujących poszczególne kolejki.

Do wyboru odpowiedniej kolejki służy funkcja queue_proc *queue ze struktury blk_dev_structblk_dev[nr_urządzenia]. Funkcja ma prototyp:

request_queue_t * (queue_proc) (kdev_t dev)
Funkcja przyjmuje obiekt kdev_t (z którego możemy odczytać makrami MINOR(kdev_t)MAJOR(kdev_t) numer podrzędny i nadrzędny urządzenia) a powinna zwrócić wskaźnik na kolejkę żądań do danego urządzenia.

Przy realizowaniu żądań funkcja request() zamiast używać makr INIT_REQUEST, CURRENT i funkcji end_request() powinna sama obsługiwać kolejkę żądań przekazaną jej jako parametr.

Literatura

1
Daniel P. Bovet & Marco Cesati: Linux Kernel, Wydawnictwo ReadMe

2
Alessandro Rubini & Jonathan Corbet: Linux Device Drivers, 2nd Edition, wydawnictwo O'Reilly

3
Źródła systemu Linux, wersja 2.4.7



Footnotes

... urządzenia1
Niektóre sterowniki urządzeń przy otwieraniu urządzeń (tzn. przy funkcji open) podmieniają zawartość tych pól dla otwieranego pliku. Dzięki temu można używać różnych funkcji operujących na urządzeniu (read, write, ...) nawet w zależności od podrzędnego numeru obsługi urządzenia.


Małgorzata Wieczorek