9. Funkcje struktury def_blk_fops - 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:
- filp - strukura file pobrana z tablicy deskruptorów
- buf - bufor z danymi do zapisu
- count - ilość danych, którą należy zapisać
- ppos - pozycja, od której należy rozpocząć zapisywanie.
W algorytmie używane są między innymi następujące zmienne pomocnicze (podałem również role, jakie pełnią w
algorytmie):
- ssize_t blocksize - rozmiar bloku urządzenia
- ssize_t blocksize_bits - logarytm dwójkowy z blocksize
- ssize_t buffercount - ilość buforów z tablicy bufferlist, które wymagają synchronizacji
- ssize_t block - numer bloku, od którego rozpocznie się zapisywanie do urządzenia
- loff_t offset - przesunięcie danych względem początku bloku
- ssize_t chars - ilość bajtów do zapisania w kolejnym bloku
- ssize_t written = 0 - ilość bajtów zapisanych do urządzenia
- struct buffer_head * bhlist[NBUF] - bufory używane do odczytu z wyprzedzeniem
- size_t size - rozmiar urządzenia
- kdev_t dev - numer urządzenia, do którego należy zapisac dane
- struct buffer_head * bh - bufor, do którego ma zostać zapisany kolejny blok danych
- struct buffer_head * bufferlist[NBUF] - tablica, w której są pamiętane bufory do synchronizacji.
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:
- ro_bits - tablica bitowa, dla każdego urządzenia przechowuje, czy jest ono
tylko do zapisu
- blksize_size - tablica rozmiarów bloków, dla każdego urządzenia blokowego
przechowuje rozmiar bloku tego urządzenia
- blk_size - tablica rozmiarów urządzeń, dla każdego urządzenia blokowego
przechowuje rozmiar urządzenia
- read_ahead - tablica zawiera sugerowane ilości bloków do odczytu, przy odczycie z wyprzedzeniem.
Używane struktury:
- file - opisana we wstępie. Z punktu widzenia funkcji block_write()
istotne są tylko pola f_dentry, z którego pobierany jest i-węzeł, z którego
pobierany jest numer urządzenia, pole f_reada, które określa czy
dla urządzenia należy stosować czytanie z wyprzedzeniem, pole f_flags,
które określa, czy należy stosować synchronizację
- buffer_head - struktura wykorzystywana przez buforowanie, zupełnie
nieistotna z punktu widzenia funkcji block_write(), w pełni ukryta przez
wywołania getblk(), brelse() i inne.
Typy danych:
- size_t - unsigned int
- off_t - long long
- kdev_t - unsigned short.
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.