Lekcja 03- Urządzenia znakowe

Wstęp

W tej lekcji utworzymy pierwszy sterownik wirtualnego urządzenia znakowego. Czemu wirtualnego? Wirtualnego ponieważ ten sterownik nie będzie obsługiwać żadnego fizycznego urządzenia, stworzy on natomiast odpowiedni plik w katalogu /dev do którego będzie można pisać i czytać. Można to porównać do urządzenia typu /dev/zero, /dev/null czy /dev/random.

Czym jest urządzenie znakowe?

Urządzenie znakowe(ang. character device, chardev) to urządzenie z którego możemy czytać znaki lub zapisywać znaki. Jakie znaki? Normalne, odczytujemy z/zapisujemy do niego po prostu ciąg bajtów, który może być interpretowany np. jako tekst jak to ma miejsce np. w przypadku UARTa.

Jak obsługujemy takie urządzenia? Zgodnie z filozofią wszystko jest plikiem to takie urządzenia obsługujemy tak samo jak zwykłe pliki czyli używamy POSIXowych funkcji takich jak open, close, read, write czy lseek. W zależności od potrzeb różne funkcje mogą być zaimplementowane, nie jest wymagana implementacja wszystkich funkcji jeśli dana opcja nie jest potrzebna, np. może być urządzenie do którego zapisujemy jakieś dane(czyli implementujemy funkcje write), ale z jakiś powodów nie chcemy aby była możliwość odczytu z niego(wtedy nie implementujemy funkcji read).

Major i Minor numbers

Numer major i minor to numery służące odpowiednio do identyfikacji sterownika i urządzenia w systemie. Każde urządzenie posiadające ten sam numer major używa tego samego sterownika, numer minor jest używany przez sterownik do określenia z którym urządzeniem ma się komunikować.

W modułach do przechowywania tych numerów używa się zmiennej dev_t. Na chwilę pisania tego kursu pierwsze 12 bitów służy do przechowywania numeru major, a pozostałe 20 jest zarezerwowane dla minora. Niech też Cię nie kusi aby robić jakieś operacje bitowe aby odczytać te numery, istnieją do tego odpowiednie makra. Dla swojej wygody i łatwiejszej przenoszalności używaj ich.

Numery te mogą być nadawane zarówno statycznie jak i dynamicznie. Statyczne nadawanie tych numerów grozi tym, że będziemy próbowali użyć numeru, który jest już w użyciu co spowoduje błąd. W tym przykładzie zostanie jedynie zademonstrowane dynamiczne przydzielenie numerów major i minor.

Szkielet sterownika

Skoro już wiemy co będziemy kodować to zakodujmy to. Na początek zaprezentuje szkielet takiego modułu, funkcje będą puste. Omówieniem istotnych elementów kodu oraz implementacją poszczególnych funkcji zajmiemy się w odpowiednich sekcjach. Szkielet takiego sterownika prezentuje się następująco:

#include <linux/module.h>
#include <linux/fs.h>
#include <linux/uaccess.h>
#include <linux/init.h>
#include <linux/slab.h>
#include <linux/cdev.h>

#define MOD_NAME "mychardev"
#define KBUF_SIZE (size_t)(10 * PAGE_SIZE)

static char *kbuf;
static dev_t first;
static unsigned int count = 1;
static struct cdev *my_cdev;
static struct class *c;

static int my_open(struct inode *i, struct file *f)
{
}

static int my_release(struct inode *i, struct file *f)
{
}

static ssize_t my_read(struct file *f, char __user *buf, size_t lbuf, loff_t *ppos)
{
}

static ssize_t my_write(struct file *f, const char __user *buf, size_t lbuf, loff_t *ppos)
{
}

static const struct file_operations my_fops = {
	.owner = THIS_MODULE,
	.read = my_read,
	.write = my_write,
	.open = my_open,
	.release = my_release
};

static int __init my_init(void)
{
}

static void __exit my_exit(void)
{
}

module_init(my_init);
module_exit(my_exit);

MODULE_LICENSE("GPL");

Kod nie wygląda jakoś strasznie, zaledwie pięć zmiennych globalnych, sześć funkcji i jakaś struktura.

Struktura file_operations

Zaczniemy trochę od środka kodu bo od struktury file_operations. Struktura ta przechowuje funkcje, które będą wywoływane podczas odpowiedniego wywołania systemowego. Niemalże wszystkie pola mają takie same nazwy jak w standardzie POSIX oprócz funkcji close. W strukturze file_operations pole release odpowiada funkcji close. Struktura ta ma znacznie więcej pól, nie będą one nam póki co potrzebne więc aby zachować prostotę przykładu ograniczmy się do tych czterech podstawowych wywołań: open, release(close), read oraz write.

Funkcja init

Tę funkcję już poznałeś w poprzedniej lekcji, w tym przypadku będzie ona bardziej rozbudowana ponieważ będzie robić teraz nieco więcej niż tylko logowanie, że moduł został załadowany. Tutaj dokona ona inicjalizacji naszego urządzenia.

Na początek zobaczymy jak wygląda cała funkcja, a następnie zajmiemy się jej poszczególnymi fragmentami:

static int __init my_init(void)
{
	if(alloc_chrdev_region(&first, 0, count, MOD_NAME) < 0) {
		pr_err("%s: Failed to allocate character device region\n", MOD_NAME);
		goto err1;
	}

	if(!(my_cdev = cdev_alloc())) {
		pr_err("%s: cdev_aloc() failed\n", MOD_NAME);
		goto err2;
	}

	kbuf = kmalloc(KBUF_SIZE, GFP_KERNEL);
	if(kbuf == NULL) {
		pr_err("%s: Cannot allocate memory\n", MOD_NAME);
		goto err3;
	}
	cdev_init(my_cdev, &my_fops);
	if(cdev_add(my_cdev, first, count) < 0) {
		pr_err("%s: Cannot add character device\n", MOD_NAME);
		goto err4;
	}

	c = class_create(THIS_MODULE, "my_cls");
	device_create(c, NULL, first, "%s", "mycdrv");

	pr_info("%s: Loading succeeded, major=%i, minor=%i\n", MOD_NAME, MAJOR(first), MINOR(first));
	return 0;

err4:
	kfree(kbuf);
err3:
	cdev_del(my_cdev);
err2:
	unregister_chrdev_region(first, count);
err1:
	return -1;
}

Nie jest ona jakoś wielka, ale zacznijmy od początku.

Zaczyna się ona od wywołania funkcji alloc_chrdev_region(), służy ona do przydzielenia wolnych numerów major i minor. Numery te są zapisywane do pierwszego parametru, w naszym przypadku jest to zmienna first. Drugi parametr określa od jakiego numery minor mają zostać przydzielone, my chcemy zacząć od 0. Trzeci parametr określa ile numerów minor ma zostać przydzielonych(ile urządzeń będzie obsługiwanych przez ten sterownik). Ostatni parametr to nazwa sterownika lub urządzenia powiązanego z przydzielonymi numerami. Jeśli operacja nie powiedzie się zostanie zalogowana odpowiednia informacja i zostanie wykonany skok do odpowiedniego miejsca, który zwróci błąd.

Następnie wywołujemy cdev_alloc(). Funkcja ta alokuje w pamięci strukturę cdev, która to służy do reprezentacji naszego urządzenia w kodzie. Jeśli alokacja się nie powiedzie logujemy odpowiednią informację i skaczemy do odpowiedniego miejsca w kodzie, które cofnie poprzednie operacje i zwróci błąd.

Następnie alokujemy pamięć za pomocą funkcji kmalloc(). kmalloc() to taki kernelowy malloc(). Funkcja ta pobiera dwa parametry- ilość bajtów do zaalokowania oraz flagę, która określa sposób alokacji pamięci. W naszym przykładzie alokujemy standardową pamięć z kernelowej przestrzeni adresowej oraz dopuszczamy możliwość uśpienia funkcji. Podobnie jak w przypadku malloca jest zwracany wskaźnik na voida. Jeśli alokacja nie powiedzie się to zostanie zwrócony NULL, w takim przypadku logujemy co trzeba i skaczemy do odpowiedniego miejsca.

Po alokacji pamięci wykonujemy inicjalizacje urządzenia i przekazujemy mu naszą strukturę file_operations. Dzięki tej funkcji nasze urządzenie będzie mogło zostać zarejestrowane w systemie. Zgodnie z dokumentacją funkcja ta nie może się nie powieść dlatego nie sprawdzamy czy wystąpił błąd.

Zainicjowaliśmy urządzenie więc teraz możemy je dodać do systemu za pomocą funkcji cdev_add(). Oprócz struktury reprezentującej urządzenie przekazujemy również wcześniej uzyskany numery major, minor oraz zakres numerów minor. Oczywiście jeśli funkcja się nie powiedzie logujemy odpowiednią informację oraz skaczemy do odpowiedniego miejsca w kodzie.

Dodaliśmy urządzenie w systemie, ale nie został utworzony żaden plik w systemie plik z nim powiązany. Aby uniknąć dodawania tego plik ręcznie za pomocą komendy mknod my stworzymy odpowiedni plik w kodzie.

Najpierw musimy utworzyć klasę do którego będzie należeć nasze urządzenie używamy do tego funkcję class_create(). Jeśli funkcja się nie powiedzie to zapewne wiecie co się stanie.

Jak mamy utworzoną klasę urządzeń to możemy teraz utworzyć urządzenie, służy do tego funkcja device_create(), utworzy ona nam urządzenie o nazwie mycdrv w katalogu /dev.

Na koniec logujemy, że wszystko się udało i zwracamy wartość 0 czyli kod informujący system, że wszystko się powiodło.

Poniżej return 0 znajduje się obsługa błędów do której skakaliśmy.

Oj, nie uważałeś na wykładach, czemu używasz goto?

goto, zapomniane słowo kluczowe języka C, a jak ktoś już je zna to je demonizuje je tak samo mocno jak programiści Javy demonizują singletony. A są to zwykłe narzędzia, które mają swoje zastosowanie. Ale wracając do rzeczy, niechęć do słowa goto prawdopodobnie w dużej mierze wywołuje artykuł z 1968 „Goto considered harmful” autorstwa Dijkstry. Zwróćmy jednak uwagę na kontekst historyczny, Dijkstra pisał ten artykuł w czasach gdy wynalazkiem były takie funkcjonalności języka jak wyrażenia warunkowe i pętle, a po lasach biegały jeszcze ostatnie dinozaury. Wtedy rzeczywiście programiści mogli nagminnie używać goto. Natomiast dzisiaj ludzie niepotrzebnie gadają takie rzeczy, śmiem twierdzić, że nie rozumieją nawet co mówią, są oni pewnego rodzaju fanatykami programistycznymi tak samo jak te osoby co narzekają na singletony. Obecnie goto ma jedno bardzo dobre zastosowanie, obsługa błędów podczas inicjalizacji sprzętu, sterowników itd. Poza Linuksem takie podejście jest stosowane również w Androidzie i przypuszczam, że w innych systemach również. Gdybyśmy nie użyli goto w funkcji init jej kod by się wydłużył i wyglądał by tak:

static int __init my_init(void)
{
	if(alloc_chrdev_region(&first, 0, count, MOD_NAME) < 0) {
		pr_err("%s: Failed to allocate character device region\n", MOD_NAME);
		return -1;
	}

	if(!(my_cdev = cdev_alloc())) {
		pr_err("%s: cdev_aloc() failed\n", MOD_NAME);
		unregister_chrdev_region(first, count);
		return -1;
	}

	kbuf = kmalloc(KBUF_SIZE, GFP_KERNEL);
	if(kbuf == NULL) {
		pr_err("%s: Cannot allocate memory\n", MOD_NAME);
		cdev_del(my_cdev);
		unregister_chrdev_region(first, count);
		return -1;
	}
	cdev_init(my_cdev, &my_fops);
	if(cdev_add(my_cdev, first, count) < 0) {
		pr_err("%s: Cannot add character device\n", MOD_NAME);
		kfree(kbuf);
		cdev_del(my_cdev);
		unregister_chrdev_region(first, count);
		return -1;
	}

	c = class_create(THIS_MODULE, "my_cls");
	if(c == NULL) {
		pr_err("%s: Cannot create class\n", MOD_NAME);
		kfree(kbuf);
		cdev_del(my_cdev);
		unregister_chrdev_region(first, count);
		return -1;
	}
	if(device_create(c, NULL, first, "%s", "mycdrv") == NULL) {
		pr_err("%s: Cannot create device\n", MOD_NAME);
		class_destroy(c);
		kfree(kbuf);
		cdev_del(my_cdev);
		unregister_chrdev_region(first, count);
		return -1;
	}

	pr_info("%s: Loading succeeded, major=%i, minor=%i\n", MOD_NAME, MAJOR(first), MINOR(first));
	return 0;
}

Jak widać każdy kolejny warunek sprawdzający czy operacja się powiodła puchnie, a tak to mamy wszystko elegancko ogarnięte na końcu funkcji.

Tak więc młody adepcie kernela, nie bój się goto!

Funkcja exit

Funkcja exit() jest dosyć prosta, robi ona to samo co funkcja init tylko, że na odwrót, wygląda ona następująco:

static void __exit my_exit(void)
{
	device_destroy(c, first);
	class_destroy(c);
	kfree(kbuf);
	cdev_del(my_cdev);
	unregister_chrdev_region(first, count);
	pr_info("%s: Unloading succeeded\n", MOD_NAME);
}

Jak widać wykonuje ona operacje odwrotne niż init np. jeśli coś był rejestrowane to teraz jest odrejestrowywane. Dodatkowo zwróć uwagę, że wszystko odbywa się jakby na odwrót np. w funkcji init najpierw rejestrowaliśmy numery major i minor, w funkcji exit zwracamy je systemowi na końcu.

Funkcja open

Funkcja open() czyli funkcja służąca do otwarcia pliku powiązanego z naszym urządzeniem. W naszym przypadku nie będzie ona robiła nic specjalnego, będzie ona jedynie zliczać ilość otwarć pliku i będzie tę informacje logować do systemu. Jej wykonanie zawsze się powiedzie. Jej implementacja wygląda następując:

static int my_open(struct inode *i, struct file *f)
{
	static int counter = 0;
	counter++;
	pr_info("%s: Opened, counter: %i\n", MOD_NAME, counter);
	return 0;
}

Zwróć uwagę na jej parametry. Struktura inode służy do określenia pliku w pamięci, wskazuje ona gdzie on fizycznie się znajduje i czym on faktycznie jest.

Natomiast struktura file nie opisuje gdzie ten plik jest(choć jednym z jej pól jest wskaźnik do struktury inode), służy ona do przechowywania stanu obecnych operacji na pliku takich jak np. aktualna pozycja w pliku.

Funkcja release

Funkcja release() czyli funkcja odpowiedzialna za zamknięcie pliku powiązanego z naszym urządzeniem, odpowiednik POSIXowej funkcji close(). W naszym przypadku również nie robi ona nic nadzwyczajnego, loguje ona informacje o zamknięciu pliku i zwraca 0. Jej implementacja wygląda następująco:

static int my_release(struct inode *i, struct file *f)
{
	pr_info("%s: Closed\n", MOD_NAME);
	return 0;
}

Funkcja read

Funkcja read() czy funkcja służąca do odczytu danych z naszego urządzenia. W naszym przypadku dane będą odczytywane bufora kbuf, który zaalokowaliśmy w funkcji init. Funkcja ta wygląda następująco:

static ssize_t my_read(struct file *f, char __user *buf, size_t lbuf, loff_t *ppos)
{
	int nbytes, maxbytes, bytes_to_read;
	maxbytes = KBUF_SIZE - *ppos;
	bytes_to_read = maxbytes > lbuf ? lbuf : maxbytes;
	nbytes = bytes_to_read - copy_to_user(buf, kbuf + *ppos, bytes_to_read);
	*ppos += nbytes;
	return nbytes;
}

Najpierw sprawdzamy ile bajtów możemy odczytać z naszego bufora. Gdy już wiemy ile maksymalnie bajtów może zostać odczytanych sprawdzamy czy wartość ta jest większa niż ilość bajtów do odczytu zażądana przez użytkownika(parametr lbuf), jeśli jest większa to będziemy odczytywać tyle bajtów ile chce użytkownik.

Gdy już wiemy ile bajtów należy odczytać to wywołujemy funkcje copy_to_user(), służy ona do skopiowania danych z kernel space’a do user space’a(przypominam, że te obie przestrzenie są oddzielone od siebie w pamięci). Funkcja ta zwraca ilość bajtów, które mogły się nie skopiować, idealnie powinna ona zwrócić wartość 0 co oznacza, że wszystkie bajty zostały skopiowane. Zwróconą wartość zwracamy od zmiennej bytes_to_read aby wiedzieć ile bajtów zostało rzeczywiście odczytanych.

Wskaźnik pozycji przesuwamy o ilość odczytanych bajtów. Poprawna zmiana pozycji w pliku jest bardzo istotna, jeśli byśmy tego nie zrobili to przy kolejnych odczytach byśmy ciągle czytali dane z początku pliku, może to się objawiać np. wpadaniem programów takich jak cat w nieskończoną pętle.

Zgodnie z konwencją funkcja kończy się zwróceniem ilości odczytanych bajtów, zwrócenie wartości 0 oznacza dotarcie do końca pliku.

Funkcja write

Funkcja write() czyli funkcja służąca do zapisu danych w naszym buforze. Jej implementacja jest właściwie identyczna jak funkcji read() z tą drobną różnicą, że zamiast funkcji copy_to_user wywołujemy funkcję copy_from_user czyli funkcję kopiującą dane z user space’a do kernel space’a. Reszta kodu jest identyczna, implementacja wygląda następująco:

static ssize_t my_write(struct file *f, const char __user *buf, size_t lbuf, loff_t *ppos)
{
	int nbytes, maxbytes, bytes_to_write;
	maxbytes = KBUF_SIZE - *ppos;
	bytes_to_write = maxbytes > lbuf ? lbuf : maxbytes;
	nbytes = bytes_to_write - copy_from_user(kbuf + *ppos, buf, bytes_to_write);
	*ppos += nbytes;
	return nbytes;
}

Kompilacja

Makefile poza nazwą pliku nie różni się niczym od Makefile’a z poprzedniej lekcji:

obj-m += 02_chardev.o
all:
	make -C /ścieżka/do/zbudowanego/kernela M=$(shell pwd) modules
clean:
	make -C /ścieżka/do/zbudowanego/kernela M=$(PWD) clean

A budujemy wszystko za pomocą komendy make:

# BBB
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf-
# RPi4
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- KERNEL=kernel8

Testowanie modułu

Uruchom swoją płytkę, prześlij moduł na nią i go załaduj:

sudo insmod 02_chardev.ko

Jeśli operacja się powiodła to możesz zapisywać do i odczytywać dane ze swojego własnego urządzenia:

echo „Eluwina mój sterowniku” > /dev/mycdrv
cat /dev/mycdrv

Komenda cat powinna wyświetlić napis, który zapisałeś komendą echo.

Ten wpis został opublikowany w kategorii Kurs pisania sterowników. Dodaj zakładkę do bezpośredniego odnośnika.

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *