Podsystem Wejścia/Wyjścia w systemie Linux 2.4.7
Urządzenia znakowe i blokowe. Funkcja block_read(), block_write().
9. Funkcje struktury def_blk_fops - funkcja block_write()
< Poprzednia strona Spis treści

9. Funkcje struktury def_blk_fops - funkcja block_write()


9.1 Funkcja block_write()

Idea działania algorytmu jest bardzo prosta. Polega na wyznaczeniu kolejnych bloków, które musimy zapisać i kolejno na pobieraniu bufora dla kolejnego bloku. Jeśli bufor jest ważny, to poprostu zapisujemy do niego dane, w przeciwnym wypadku, jeśli chcemy go całkowicie zapisać, to poprostu wpisujemy dane do nowego bufora, a jeśli częściowo, to musimy najpierw brakującą część ściągnąc z urządzenia. Realizacja tej koncepcji również nie jest skomplikowana.

Funkcja przyjmuje cztery parametry:
struct file * filp, const char * buf, size_t count, loff_t *ppos.
Krótki komentarz:

W algorytmie używane są między innymi następujące zmienne pomocnicze (podałem również role, jakie pełnią w algorytmie): Na początku działania algorytmu, na podstawie struktury file (zmienna filp) ustawiana jest wartość zmiennej dev na numer urządzenia, do którego algorytm zapisze dane. W tym czasie wykorzystywana jest (praktycznie niepotrzebna) zmienna inode. Następnie korzystamy z prostej funkcji is_read_only(), która za pomocą tablic ro_bits stwierdza, czy urządzenie jest przeznaczone tylko do odczytu. Jeśli nie mozna do niego pisać, to niestety zwracany jest błąd.

dev = inode->i_rdev;
if ( is_read_only( inode->i_rdev ))
  return -EPERM;


Oczywiście zamiast używać:
if ( is_read_only( inode->i_rdev ))
można użyć:
if ( is_read_only( dev )) , co wydaje mi się, jest lepszym rozwiązaniem.

Kolejnym krokiem algorytmu jest ustalenie wielkości bloku dla urządzenia. W tym celu odczytywane są odpowiednie pola z tablicy blksize_size, o ile są one określone. W tym bowiem przypadku przyjmuje się, że wielkość bloku jest równa BLOCK_SIZE, który jest zdefiniowany w pliku include/linux/fs.h i może mieć wartość np. 2^10.

blocksize = BLOCK_SIZE;
if (blksize_size[MAJOR(dev)] && blksize_size[MAJOR(dev)][MINOR(dev)])
  blocksize = blksize_size[MAJOR(dev)][MINOR(dev)];


Następnie do blocksize_bits obliczny jest logarytm z wartości blocksize. Przypuszczalnie celem obliczania logarytmu była optymalizacja, ale zysk jeśli jest, to jest znikomy ponieważ zyskiwane są dwie operacje dzielenia kosztem liczenia logarytmu i dwóch przesunięć w prawo. Mając blocksize i blocksize_bits obliczana jest początkowa pozycja w urządzeniu wyrażona w blokach i przesunieciu względem początku bloku.

block = *ppos >> blocksize_bits;
offset = *ppos & (blocksize-1);


W tym momencie zakładamy, że wielkość bloku jest potęgą dwójki. Oczywiście można zastosować następujące rozwiązanie.
block = *ppos / blocksize;
offset = *ppos & (blocksize-1);


Kolejnym krokiem jest pobranie informacji o wielkości urządzenia. W tym celu korzysta się z tablicy blk_size, która zawiera wielkości kolejnych urządzeń blokowych. Jeśli nie jest określona wielkość, przyjmuje się, że rozmiar wynosi INT_MAX. Rozmiar jest wyliczany w blokach. Znajduje się tu ograniczenie na wielkość urządzenia, które nie może przkraczać INT_MAX bloków.

if (blk_size[MAJOR(dev)])
  size = ((loff_t) blk_size[MAJOR(dev)][MINOR(dev)] << BLOCK_SIZE_BITS) >> blocksize_bits;
else
  size = INT_MAX;


Po tych przygotowaniach zaczyna się teraz główna pętla zapisu danych. Wewnątrz pętli modyfikowana jest zmienna count licząca ilość danych, którą należy jeszcze zapisać do pliku. Pętla się kończy jeśli zapisane zostaną wszystkie dane, czyli count==0.

while (count>0) {

Na początku sprawdzane jest czy w ogóle jest miejsce żeby cokolwiek zapisać, to znaczy czy czasem nie skończyło się urządzenie.

if (block >= size) {
  retval = -ENOSPC;
  goto cleanup;
}


Oczywiście o wiele bardziej strukuralne jest rozwiązanie, które wymaga jednego dodatkowego porównania.

while (count>0 && block<size) {

Następnie wyliczane jest ilość bajtów do zapisania w kolejnym bloku.

chars = blocksize - offset;
if (chars > count)
  chars=count;


W tym momencie funkcja ma wszystkie informacje, aby rozpocząć zapis bloku. W zmiennej block znajduje się numer kolejnego bloku, w zmiennej chars jest ilość bajtów do zapisania. Oczywiście dev i blocksize zawierają numer urządzenia i wielkość bloku. W tym momencie funkcja pobiera bufor dla bloku block i zapamiętuje go pod zmienną bh. W tym momencie w tej zmiennej znajduje się bufor, który w systemie buforowania odpowiada blokowi, który trzeba zapisać. Oczywiście jeśli wystąpił bład zapis zostaje przerwany.

bh = getblk(dev, block, blocksize);
if (!bh) {
  retval = -EIO;
  goto cleanup;
}


Teraz należy sprawdzić, czy bufor jest ważny, ponieważ jeśli jest, to nie jest konieczne wykonywanie żadnych dodatkowych czynności. Idealną sytuacją jest by każdy sprowadzony bufor był wazny. Oczywiście jest to praktycznie niemożliwe ze względu na małą ilość buforów w porównaniu z ilością bloków.

if (!buffer_uptodate(bh)) {

Następnie sprawdzamy, czy dane zapisane do bufora wypełnią go w całości. W tym bowiem wypadku nie jest potrzebny ważny bufor, ponieważ i tak całe dane z niego zostaną nadpisane. Konieczne jest jednak zaczekanie na zakończenie innych operacji na tym buforze.

if (chars == blocksize)
  wait_on_buffer(bh);


Pozostał teraz najbardziej niewdzięczny przypadek, w którym nie ma ważnego bufora dla bloku i co więcej istnieje potrzeba zapisu części bloku co wiąże się z koniecznością wczytania pozostałej części bloku. W celu poprawienia wydajności stosuje się odczyt z wyprzedzeniem, o ile jest dostępny dla urządzenia. Jeśli jest zdefiniowany odczyt z wyprzedzeniem, to korzystjąc z tablicy read_ahead określana jest ilość bloków jaka powinna być odczytana. Oczywiście nie może ona przekraczać ilości miejsca w tablicy bhlist, pamiętającej bloki w odczycie z wyprzedzeniem z równie oczywistych powodów można czytać tylko takie bloki, które nie są poza urządzeniem.

bhlist[0] = bh;
if (!filp->f_reada || !read_ahead[MAJOR(dev)]) {
  blocks = 1;
} else {
  blocks = read_ahead[MAJOR(dev)] / (blocksize >> 9) / 2;
  if (block + blocks > size) blocks = size - block;
  if (blocks > NBUF) blocks=NBUF;
  if (!blocks) blocks = 1;


Teraz dla każdego bloku pobieramy dla niego odpowiedni bufor. Może się zdarzyć, że w czasie przydziału buforów wystąpi bład i wtedy należy zwolnić wszystkie bufor, które do tej pory udało się pobrać.

for(i=1; i<blocks; i++) {
  bhlist[i] = getblk (dev, block+i, blocksize);
  if (!bhlist[i]) {
    while(i >= 0) brelse(bhlist[i--]);
    retval = -EIO;
    goto cleanup;
  }
}


Po sukcesie w tej części funkcji można przystąpić do odczytu danych. Po odczycie napewno potrzebny jest tylko i wyłącznie pierwszy bufor, dlatego każdy z pozostałych zaznaczamy jako nieużywany, natomiast na pierwszym czekamy na zakończenie operacji na nim.

ll_rw_block(READ, blocks, bhlist);
for(i=1; i<blocks; i++) brelse(bhlist[i]);
wait_on_buffer(bh);
if (!buffer_uptodate(bh)) {
  brelse(bh);
  retval = -EIO;
  goto cleanup;
}


W tym momencie w zmiennej bh pamiętany jest bufor dla aktualnie zapisywanego bloku. Jedyna rzecz jaka pozostała, to po prostu skopiować dane do bufora i zaktualizować zmienne. Po pierwsze zwiększny jest numer kolejnego bloku do zapisu. Odpowiednio aktualizowane są offset i ppos, które nadal oznaczają przesunięcie względem początku bloku i pozycję kolejnych danych do zapisania. Liczone są także bajty zapisane i odliczane bajty jeszcze do zapisania.

block++;
offset = 0;
*ppos += chars;
written += chars;
count -= chars;


Kolejną instrukcją jest kopiowanie danych z buf do bufora bh od odpowiedniej pozycji.

copy_from_user(offset + bh->b_data,buf,chars);

Następnie wykonywana jest instrucja, której wynik nie jest nigdzie wykorzystywany.

p += chars;

Dalej ustawiana jest nowa pozycja danych wejściowych.

buf += chars;

Oczywiście, aby system buforowania zauważył, że stan bufora się zmienił, trzeba go o tym poinformować. W tym celu bufor jest ustawiany jako ważny i zabrudzony. W tym momencie zostało zrobione wszystko co było konieczne i w tym momencie można poinformować mechanizm buforowania, że zakończone zostało korzystanie z bufora. Jednak dzieje się tak tylko w przypadku, gdy nie jest ustawiona synchronizacja zapisu. Jest ona sprawdzana w strukturze file i zostaje ustawiona np. przy otwieraniu urządzenia z flagą O_SYNC. Jeśli więc nie ma synchronizacji następuje proste zaznaczenie, że zakończono korzystanie z bufora. Jeśli jest synchronizacja, to bufor jest zapisywany w tablicy bufferlist buforów do synchronizacji i zwiększana jest zmienna buffercount.

if (filp->f_flags & O_SYNC) {
  bufferlist[buffercount++] = bh;
  /* tu można wstawić czyszczenie buforów */
}
else
  brelse(bh);


Teraz należy sprawdzić, czy nie została przepełniona tablica buforów do synchronizacji. Łatwo widać, że bufor może trafic do tablicy tylko gdy jest ustawiona flaga synchronizacji. Tak więc można uniknąć tego sprawdzania w przypadku urządzenia bez synchronizacji przenosząc poniższy kod do bloku po sprawdzeniu flagi. Warto zwrócić uwagę, że wartość filp->f_flags & O_SYNC nie zmienia się w czasie wykonywania funcji. Może więc zostać policzona tylko raz i zapamiętana. Jeśli teraz nastąpiło przepełnienie, to należy opróżnić tablicę. Oczywiście można w ogóle nie korzystać z tablicy bufferlist, ale obniży to wydajność systemu.

if (buffercount == NBUF){

Teraz wymuszone zostaje zlecenie zapisu buforów, a następnie po zakończeniu innych operacji na każdym z nich bufory są zaznaczane jako nieużywane. Oczywiście po tej operacji tablica jest pusta.

ll_rw_block(WRITE, buffercount, bufferlist);
for(i=0; i<buffercount; i++){
  wait_on_buffer(bufferlist[i]);
  if (!buffer_uptodate(bufferlist[i]))
    write_error=1;
  brelse(bufferlist[i]);
}
buffercount=0;


Na zakończenie każdego obrotu pętli wywoływana jest funkcja, której zadaniem jest w razie potrzeby obudzenie demona bdflush, który zajmuje się fizycznym zapisem buforów do urządzenia. Warto jednak zwrócić uwagę, że zarówno w starszych jądrach (2.2.19) jak i w nowszych (2.4.16) nie jest ona w tym miejscu używana.

balance_dirty(dev);

Zauważmy, że o kolejnych obrotach pętli wiele mozna powiedzieć. Tak więc żądanie odczytu może pojawić się jeszcze maksymalnie raz, przy zapisie ostatniego niepełnego bloku. W kolejnych obrotach offset będzie zawsze równy 0. Można się zastanowić nad optymalizacją polegającą na jawnym rozdzieleniu pętli na trzy części. Pierwsza zapisuje jawnie pierwszy blok, trzecia ostatni blok, a środkowa złożona z pętli pozostałe bloki. Zalety tego rozwiązania są takie, że pętla nie musi roważać szczególnych przypadków niepełnych bloków. Zatem jest istnieje koniczność sprawdzania mniejszej ilości warunków. Przypuszczalnie wadami byłby nieczytelny i dłuższy kod, a także być może obniżenie wydajności w przypadku zapisu małej ilości bloków, co może być ważną cechą algorytmu. Po opuszczeniu pętli konieczna jest jeszcze synchronizacja pozostałych buforów z tablicy bufferlist. Schemat działania jest dokładnie taki sam jak w przypadku czyszczenia z wnętrza pętli.

if ( buffercount ){
  ll_rw_block(WRITE, buffercount, bufferlist);
  for(i=0; i<buffercount; i++){
    wait_on_buffer(bufferlist[i]);
    if (!buffer_uptodate(bufferlist[i]))
      write_error=1;
    brelse(bufferlist[i]);
  }
}


Jedyna rzecz jaka jeszcze pozostaje, to stwierdzenie czy nie było żadnych błedów i zwrócenie ilości zapisanych bajtów ze zmiennej written.

Używane tablice danych:

Używane struktury: Typy danych:

9.1 Uwagi końcowe

Czytelnik może być zawiedziony, że tak właściwie w funkcjach zapisu i odczytu danych tak właściwie nie ma żadnego odczytywania ani zapisywania danych. Jeśli właśnie tak uważasz, to polecam lekturę katalogu drivers. W kwestii użytych algorytmów, to moim zdaniem są one rzeczywiście pomysłowe. Szczególny nacisk został w tych algorytmach położony na optymalizację wywołań funkcji ll_rw_block(). Dzięki temu możliwa jest optymalizacja dostępu do urządzenia blokowego, ponieważ urządzenie działa lepiej jeśli otrzymuje mało poleceń odczytu dużych porcji danych. W tym znaczeniu szczególnie dopracowany jest algorytm odczytu. Niestety z całą pewnością muszę stwierdzić, że obie funkcję, jak i inne fragmenty kodu źródłowego nie są napisane strukturalnie. Szczgólnie chodzi mi o użycie instrukcji goto. Także nie widzę uzasadnienia dla użycia kilku instrukcji break. Można oczywiście argumentować maksymalną optymalizacją kodu źródłowego. Jednak jeśli chodzi o maksymalną optymalizację, to moim zdaniem możnaby się zastanowić nad kilkoma rozwiązaniami mogącymi poprawić wydajność. Główną optymalizacją, którą proponuję, jest rozdzielenie w obu funkcjach sytuacji nietypowych od typowych. Mam tu na myśli podzielenie operacji na trzy fazy. Pierwsza i trzecia to odczyt/zapis pierwszego i ostatniego bloku, ponieważ mogą być niepełne. Druga polega na odczycie pozostałych bloków. Takie rozwiązanie ma tę zaletę, że faza druga posiada o wiele prostszą strukturę, ponieważ wszystkie bloki są takie same, więc nie trzeba sprawdzać żadanych warunków dotyczących rozmiaru bloków. Druga poprawka opiera się na spostrzeżeniu, że bardzo uważnie sprawdzane są warunki poprawności w kwesti bloków, do których wykonywane są odwołania. Z całą pewnością funkcja generuje poprawne żądania, więc nie jest konieczne ponowne sprawdzanie ich poprawności na przykład w funkcji ll_rw_block(). Proponuję więc wprowadzenie funkcji, która wykonuje pracę funkcji ll_rw_block(), ale wykonuje tylko niezbędne testy poprawności danych.


Autor: Łukasz Kamiński