Práctica de programación de módulos en Linux

Objetivo de la práctica

El objetivo es que el alumno se familiarice con la programación interna en el sistema operativo Linux, entendiendo como tal aquella orientada al desarrollo de componentes software (denominados módulos de núcleo de Linux) que se incorporarán al propio sistema operativo, ya sea de forma estática, en tiempo de compilación, o de manera dinámica, cuando el sistema operativo ya esté en ejecución.

Aunque los módulos de núcleo de Linux tienen diversos usos (como, por ejemplo, posibilitar la utilización de nuevos formatos de ejecutables), su principal aplicación es el desarrollo de manejadores de dispositivos, que es precisamente el objetivo de esta parte de la asignatura y lo que plantea esta práctica, que sirve además como un punto inicial para acometer el proyecto.

Este documento, además de especificar la funcionalidad del manejador que se pretende desarrollar, incluye la descripción de los fundamentos requeridos para afrontar la práctica de manera que se pueda realizar sin consultar ninguna documentación adicional. Téngase en cuenta que, dada la relación entre esta práctica y el proyecto, parte de la información que aparece en este documento aparece también en el enunciado del proyecto.

En cualquier caso, se propone como referencia este libro, un clásico dentro de este campo, aunque, dada su fecha de publicación, algo obsoleto.

Descripción de la práctica

Los sistemas de la familia UNIX ofrecen una colección de dispositivos de caracteres virtuales que pueden actuar de fuente o sumidero de datos. A continuación, se describen dos de ellos especificando su comportamiento (referencia a su implementación en Linux): A continuación, se muestran dos ejemplos de uso de estos dispositivos:
    # crea un fichero de 4M relleno de ceros
    dd if=/dev/zero of=nuevo bs=4096 count=1024

    # ignoramos la salida y el error generados por make
    make > /dev/null 2>1
La práctica plantea crear un dispositivo de caracteres denominado /dev/seq porque cuando se lee del mismo se obtiene una secuencia de bytes, siendo su comportamiento el siguiente: A continuación, se van a explicar las distintas fases necesarias para afrontar la práctica explicando los conceptos requeridos en cada caso.

Fase preliminar: aspectos básicos sobre el desarrollo de módulos

Esta sección repasa cuál es la estructura básica de un módulo y cómo es el proceso de compilación y carga del módulo.

El primer módulo

El primer ejemplo que trataremos, extraído del libro recomendado, se denomina hello.c, y muestra tambíén cómo se lleva a cabo el paso de parámetros a un módulo:
#include <linux/init.h>
#include <linux/module.h>
#include <linux/moduleparam.h>

MODULE_LICENSE("Dual BSD/GPL");

/*
 * A couple of parameters that can be passed in: how many times we say
 * hello, and to whom.
 */
static char *whom = "world";
static int howmany = 1;
module_param(howmany, int, S_IRUGO);
module_param(whom, charp, S_IRUGO);

static int __init hello_init(void) {
	int i;
	for (i = 0; i < howmany; i++)
		printk(KERN_ALERT "(%d) Hello, %s\n", i, whom);
	return 0;
}


static void __exit hello_exit(void) {
	printk(KERN_ALERT "Goodbye, cruel world\n");
}

module_init(hello_init);
module_exit(hello_exit);

Nótese el uso de las macros module_init y module_exit para definir las funciones que se ejecutarán en la carga y descarga del módulo, respectivamente. Obsérvese, asimismo, el uso de la función printk: el printf del núcleo.

Paso de parámetros a un módulo

Como se puede apreciar en el ejemplo, para crear un parámetro se define una variable global que tenga el tipo adecuado, tal que su nombre corresponderá al del parámetro.
    static int howmany = 1;
A continuación, se debe usar la macro module_param(variable, tipo, permisos) para denotar que la variable definida previamente actuará como un parámetro, especificando su nombre, su tipo y los permisos de acceso a la misma desde fuera del núcleo.
    module_param(howmany, int, S_IRUGO);
En este ejemplo, se le ha otorgado permiso de lectura al usuario que active el módulo (U), a los de su grupo (G) y a los otros usuarios restantes (O).

Nótese que si no se especifica el parámetro en la carga del módulo, la variable contendrá el valor inicial definido en el propio programa.

Asimismo, téngase en cuenta que la macro MODULE_PARM_DESC permite asociar una descripción de texto a la variable. Dicha descripción sirve como documentación del módulo.

Compilación y carga de un módulo

Para compilar este fichero, crearemos, tal como proponen en el libro citado, el siguiente fichero Makefile (NOTA: no olvide incluir los tabuladores):
# If KERNELRELEASE is defined, we've been invoked from the
# kernel build system and can use its language.
ifneq ($(KERNELRELEASE),)
	obj-m := hello.o
# Otherwise we were called directly from the command
# line; invoke the kernel build system.
else
	KERNELDIR ?= /lib/modules/$(shell uname -r)/build
	PWD := $(shell pwd)
default:
	$(MAKE) -C $(KERNELDIR) M=$(PWD) modules
endif

Con este fichero Makefile, bastaría con ejecutar el mandato make para compilar el módulo. La compilación crea, entre otros, un fichero denominado hello.ko que es el código binario del módulo.

El mandato modinfo aplicado a ese fichero devuelve información sobre el módulo, entre la que se incluye sus parámetros, con su descripción en el caso de que se haya incluido esta usando MODULE_PARM_DESC.

    modinfo ./hello.ko
El próximo paso es la carga del módulo para lo que, por razones obvias, se requiere ser super-usuario. En el mandato de carga se especifican los parámetros del módulo:
    sudo insmod ./hello.ko howmany=3 whom=amigo
Para comprobar si el módulo se ha cargado, puede usarse el mandato lsmod, que lista todos los módulos activos en el sistema, y para ver si ha escrito el mensaje de bienvenida puede usar dmesg, que muestra el log del núcleo.

Estando cargado el módulo, se puede acceder a sus parámetros en el sistema de ficheros sys:

    ls -l /sys/module/hello/parameters/
    total 0
    -r--r--r-- 1 root root 4096 oct 17 05:59 howmany
    -r--r--r-- 1 root root 4096 oct 17 05:59 whom

Por último, para descargar el módulo debe usar el siguiente mandato:

    sudo rmmod hello
Tenga en cuenta que si el módulo está formado por varios ficheros, hay que incluir una entrada en el Makefile denominada nombre_del_módulo-y que especifique cuáles son esos ficheros. Así, por ejemplo, suponiendo que se pretende crear un módulo denominado hello a partir de dos ficheros de código fuente denominados hello1.c y hello2.c, habría que especificar:
	obj-m := hello.o
	hello-y := hello1.o hello2.o

Fase 1: creación y destrucción del dispositivo


NOTA: Todo el código de esta fase puede reutilizarlo para el proyecto cambiando únicamente el nombre del dispositivo.
Puede tomar como punto de partida ese módulo básico inicial e ir incorporando la funcionalidad requerida en el mismo.

En esta primera fase, vamos a crear el dispositivo dentro de la función inicial del módulo. La creación de un dispositivo implica los siguientes pasos:

  1. Reserva de los números major y minor asociados al dispositivo: función alloc_chrdev_region.
  2. Creación del dispositivo: funciones cdev_init y cdev_add.
  3. Creación del fichero de dispositivo: funciones class_create y device_create.
Evidentemente, en la función de finalización habrá que liberar todos los recursos reservados.

A continuación, se analiza cada uno de estos pasos.

Reserva de major y minor

Un dispositivo en Linux queda identificado por una pareja de números: el major, que identifica al manejador, y el minor, que identifica al dispositivo concreto entre los que gestiona ese manejador. El tipo dev_t mantiene un identificador de dispositivo dentro del núcleo. Internamente, está compuesto por los valores major y minor asociados a ese dispositivo.

Antes de dar de alta un dispositivo de caracteres, hay que reservar sus números major y minor asociados. Para esta práctica, dejaremos que el número major lo elija el propio sistema y recibiremos como parámetro el valor del minor (el parámetro se denominará minor), usando 0 como valor por defecto.

La función alloc_chrdev_region (definida en #include <linux/fs.h>) realiza esta labor devolviendo un número negativo en caso de error:

int alloc_chrdev_region(dev_t *devID, unsigned int firstminor, unsigned int count, char *name);
  1. Parámetro solo de salida donde nos devuelve el identificador de dispositivo reservado. Tendremos que pasarle una variable para recoger el valor.
  2. Parámetro de entrada que representa el minor del identificador de dispositivo que queremos reservar (el primero de ellos si se pretenden reservar varios). Será recibido como parámetro en nuestro caso.
  3. Parámetro de entrada que indica cuántos números minor se quieren reservar. Solo habrá uno en nuestro caso.
  4. Parámetro de entrada de tipo cadena de caracteres con el nombre del dispositivo. El núcleo asociará este nombre a la reserva y así aparecerá, por ejemplo, en el fichero /proc/devices.
Si esta función, o cualquiera de las usadas en esta fase dentro de la rutina de inicio del módulo, devuelve un error, esa función de inicio debe terminar retornando un -1.

Creación del dispositivo

A continuación, es necesario "crear un dispositivo" asociado a ese identificador. Para ello, se debe de dar de alta dentro del núcleo la estructura de datos interna que representa un dispositivo de caracteres y, dentro de esta estructura, especificar la parte más importante: el conjunto de funciones de acceso (apertura, cierre, lectura, escritura...) que proporciona el dispositivo.

El tipo que representa un dispositivo de caracteres dentro de Linux es struct cdev (no confundir con el tipo dev_t, comentado previamente, que guarda un identificador de dispositivo; nótese, sin embargo, que, como es lógico, dentro del tipo struct cdev hay un campo denominado dev de tipo dev_t que almacena el identificador de ese dispositivo), que está definido en #include <linux/cdev.h>.

Se debe definir una variable de este tipo (o una estructura que la contenga) y usar la función cdev_init para dar valor inicial a sus campos. El primer parámetro será la dirección de esa variable que se pretende iniciar y el segundo una estructura de tipo struct file_operations que especifica las funciones de servicio del dispositivo:

void cdev_init(struct cdev *cdev, struct file_operations *fops);

A continuación, se muestra un ejemplo de uso de la estructura file_operations que especifica solamente las operaciones de apertura, cierre, lectura y escritura, que son las que se usarán en la práctica. En esta primera versión se debería incluir en cada una de ellas simplemente un printk identificándola que sirva para depuración.

static int seq_open(struct inode *inode, struct file *filp) {
    return 0;
}

static int seq_release(struct inode *inode, struct file *filp) {
    return 0;
}

static ssize_t seq_write(struct file *filp, const char __user *buf, size_t count, loff_t *f_pos) {
    return 0;
}

static ssize_t seq_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos) {
    return 0;
}

static struct file_operations seq_fops = {
        .owner =    THIS_MODULE,
        .open =     seq_open,
        .release =  seq_release,
        .write =    seq_write,
        .read =     seq_read
};
Después de iniciar con cdev_init la estructura que representa al dispositivo, hay que asociarla con el identificador de dispositivo reservado previamente. Para ello, se usa la función cdev_add:
int cdev_add(struct cdev *dev, dev_t devID, unsigned int count);
siendo el tercer parámetro igual a 1 en nuestro caso, puesto que queremos dar de alta un único dispositivo.

Creación del fichero de dispositivo

Aunque después de dar de alta un dispositivo dentro del núcleo ya está disponible para dar servicio a través de sus funciones de acceso exportadas, para que las aplicaciones de usuario puedan utilizarlo, es necesario crear un fichero especial de tipo dispositivo de caracteres dentro del sistema de ficheros. En nuestro caso, será el fichero /dev/seq.

El primer paso que se debe llevar a cabo es la creación de una clase para el dispositivo gestionado por el manejador usando para ello la llamada class_create (todo dispositivo tiene que pertenecer a una clase) que retorna el identificador de la clase creada (NULL en caso de error):

#include <linux/device.h>
struct class * class_create (struct module *owner, const char *name);
Como primer parámetro se especificaría THIS_MODULE y en el segundo el nombre que se le quiere dar a esta clase de dispositivos (el que considere oportuno).

Después de crear la clase, hay que dar de alta el dispositivo asociándolo a esa clase. Para ello, se usa la función device_create:

struct device * device_create(struct class *class, struct device *parent, dev_t devID, void *drvdata, const char *fmt, ...)
Para el ejemplo que nos ocupa, solo son relevantes los siguientes parámetros (para los demás se especificará un valor NULL): A partir de este momento, se habrá creado el fichero /dev/seq y las llamadas de apertura, lectura, escritura, cierre, etc. sobre ese fichero especial son redirigidas por el sistema operativo a las funciones de acceso correspondientes exportadas por el manejador del dispositivo.

Destrucción del dispositivo

Como es lógico, en la función de finalización del módulo hay que liberar los recursos reservados asociados al dispositivo, siguiendo el orden inverso a la reserva:

Pruebas

Se recomienda realizar las siguientes pruebas:

Fase 2: operaciones de lectura y escritura

La implementación de esta funcionalidad requiere completar los métodos:

La llamada open

La llamada de apertura recibe dos parámetros: En esta función, se generará el valor aleatorio y se asociará con el campo private_data asignándolo directamente a ese campo usando los castings pertinentes. Una alternativa sería reservar memoria dinámica del núcleo (kmalloc con la opción GFP_KERNEL), guardar en el espacio reservado el valor y hacer que el campo private_data apunte a la zona reservada. Si se usa esta segunda opción, hay que liberar el espacio (kfree) al cerrar el fichero.

Para obtener un valor aleatorio se puede usar la función get_random_bytes(void *buf, int nbytes); (linux/random.h), que devuelve en el primer parámetro el número aleatorio generado cuyo tamaño es especificado en el segundo parámetro.

La llamada write

Simplemente confirmará que se han escrito los bytes solicitados, no realizando ningún procesamiento con los mismos.

La llamada read

Debe recuperar el valor asociado al campo private_data e ir copiándolo al buffer de usuario tantas veces como se ha solicitado (count) incrementándolo en cada iteración, asegurándose, además, de que la próxima lectura de esta sesión continuará la secuencia justo donde la dejó esta llamada.

Para realizar la copia de cada byte se dispone de la macro put_user que copia un dato a la dirección especificada por un puntero que hace referencia a la zona de memoria de usuario:

    #include <asm/uaccess.h>
    int put_user(datum,ptr);
En caso de éxito, esta función devuelve un 0. Si hay un fallo, que se deberá a que la dirección pasada como parámetro es inválida, devuelve el error -EFAULT, que será el valor que debe devolver este método en ese caso.

Pruebas

Se recomienda realizar las siguientes pruebas: Puede ser didáctico usar strace para ver cómo se resuelven las llamadas al dispositivo:
    strace -o traza1 dd if=/dev/seq bs=8 count=4 of=f1
    strace -o traza2 dd if=/dev/seq bs=128 count=3 of=f2