Manejador de dispositivo

Se pretende que el alumno llegue a conocer cómo se desarrolla un manejador para un dispositivo de caracteres en Linux. Este tipo de desarrollo permite aprender, al menos de forma básica, aspectos tales como la reserva de memoria dentro del núcleo, la manera de sincronizar procesos o el tratamiento de señales.

En cuanto al manejador que se pretende desarrollar, dadas las dificultades técnicas y logísticas de trabajar con un dispositivo hardware, se plantea crear un manejador para un dispositivo software (NOTA: en los sistemas UNIX existen varios dispositivos puramente software como, por ejemplo, los dispositivos /dev/zero y /dev/null). Concretamente, se pretende crear un dispositivo que ofrezca un medio de comunicación entre procesos, con características diferentes a los ya existentes en Linux (como los pipes, FIFO, sockets o colas de mensajes). A continuación, se especifican las características de este nuevo mecanismo de comunicación:

Para facilitar el desarrollo de este manejador se plantean una serie de fases de carácter incremental.

Primera fase: crear y registrar el dispositivo

Se desarrollará un módulo que al cargarse registre el manejador y que al descargarse lo dé de baja. Aunque hay distintas funciones que permiten realizar estas dos operaciones de alta y baja del manejador, se propone optar por el estilo antiguo por ser más simple aunque menos potente. Para ello, se usan las funciones register_chrdev y unregister_chrdev, respectivamente (la interfaz más moderna, que es más compleja, usa las funciones register_chrdev_region y unregister_chrdev_region).

El Capítulo 4 de The Linux Kernel Module Programming Guide explica cómo se usan estas funciones, así como en qué consiste el tipo file_operations, en el cual el manejador especifica sus funciones de servicio (en el caso de la práctica, las correspondientes a open, release, read y write). Se recomienda saltarse la Sección 4.1.4 de ese capítulo y olvidarse del uso de las funciones try_module_get o module_put comentadas en el mismo, ya que sólo pueden ser necesarias cuando un módulo usa los servicios de otro módulo.

En esta primera fase, las funciones del manejador sólo mostrarán un mensaje con printk.

Téngase en cuenta que para usar el dispositivo hay que crearlo, es decir, hay que crear un fichero de tipo dispositivo de caracteres. Para ello, se usa el mandato mknod, siendo root, de la siguiente forma:

    mknod /tmp/canal c major minor
    chmod 666 /tmp/canal # para permitir acceso a cualquier usuario

A continuación se intenta explicar el significado de los valores denominados major y minor.

El valor major identifica al manejador del dispositivo. Este valor se corresponde con el número especificado por el manejador en la llamada register_chrdev. En caso de usar un valor de 0 en esa llamada, que es la recomendación que se propone en este enunciado, el valor de major lo elegirá el sistema operativo en tiempo de ejecución y lo podremos averiguar consultando el fichero /proc/devices (nótese que en este caso el dispositivo habrá que crearlo después de cargar el módulo ya que hasta entonces no se conocerá el número asignado).

En cuanto al valor minor, identifica a una de las unidades que gestiona ese manejador. Si se usa la interfaz de registro más antigua (register_chrdev), como se propone en esta práctica, al registrar un manejador se dan de alta automáticamente 256 valores minor para ese manejador (los que van desde 0 a 255), es decir, 256 posibles unidades. Para aclararlo, pongamos un ejemplo.

Supóngase que el manejador tiene asignado un valor major igual a 253 y que se crean los tres siguientes ficheros:

    mknod /tmp/canal c 253 0
    mknod /tmp/canal1 c 253 1
    mknod /tmp/canal2 c 253 2
Si un proceso abre cualquiera de estos ficheros, se activará la función correspondiente del manejador, que recibirá como parámetro el valor minor asociado al fichero que se ha abierto. Concretamente, para obtener el valor minor, hay que aplicar la función iminor al primer parámetro recibido por la función de apertura, que se corresponde con el inodo del fichero y es de tipo struct inode.

Dado que en la práctica sólo se desea tener una única unidad con un minor igual a 0, la llamada de apertura devolverá un error si se intenta acceder usando un minor distinto de 0. Concretamente, se retornará el error ENODEV, que indica que el dispositivo no existe (dado que la convención es que en caso de error un manejador devuelve un valor negativo, se devolverá un valor -ENODEV).

Segunda fase: control de apertura

En esta segunda fase, sólo nos ocuparemos de las llamadas de apertura y cierre dejando intactas las de lectura y escritura.

Básicamente, se trata de asegurar el protocolo planteado:

En caso de que no se cumplan las condiciones descritas, la llamada de apertura terminará con un error: -EBUSY en el caso de que exista previamente una apertura que entra en conflicto con la solicitud, y -ECONNREFUSED si una solicitud de apertura de escritura no encuentra una sesión de lectura activa.

Se recomienda declarar una estructura de datos para almacenar de forma conjunta toda la información sobre el estado del dispositivo.

Para comprobar si se trata de una apertura de escritura o de lectura, se puede acceder al segundo parámetro de la función de apertura del manejador, que es de tipo struct file, y consultar su campo f_mode comparándolo con las constantes FMODE_READ y FMODE_WRITE para determinar el tipo de apertura.

Pruebas

A continuación se proponen algunas pruebas para verificar si la funcionalidad se ha desarrollado correctamente (téngase en cuenta que, al no estar implementadas la lectura ni la escritura, las pruebas resultan un poco artificiosas):

  1. Si se ejecuta un solo escritor debe producirse un error de conexión rechazada:
        $ cat > /tmp/canal
        bash: /tmp/canal: Conexión rehusada
    
  2. Si se ejecuta un lector y llega otro lector debe producirse un error en el segundo:
        # se puede lanzar en background o en una ventana diferente
        $ sleep 20 < /tmp/canal & # primer lector OK
        $ cat /tmp/canal # segundo lector error
        cat: /tmp/canal: Dispositivo o recurso ocupado
    
  3. Si se ejecuta un lector, luego un escritor y, a continuación, otro escritor, debe de producirse error en este último:
        # se puede lanzar en background o en una ventana diferente
        $ sleep 20 < /tmp/canal & # primer lector OK
        # se puede lanzar en background o en una ventana diferente
        $ cat > /tmp/canal &  # primer escritor OK
        $ cat > /tmp/canal # segundo escritor error
        bash: /tmp/canal: Dispositivo o recurso ocupado
    
  4. Si, habiendo un lector y un escritor, el lector realiza un cierre prematuro manteniéndose el escritor, la ejecución de un nuevo lector producirá un error:
        # se puede lanzar en background o en una ventana diferente
        $ sleep 10 < /tmp/canal & # primer lector OK
        # se puede lanzar en background o en una ventana diferente
        $ cat > /tmp/canal &  # primer escritor OK
        # esperar hasta que termine el sleep del primer lector y lanzar...
        $ cat /tmp/canal # nuevo lector error
        cat: /tmp/canal: Dispositivo o recurso ocupado
    

Tercera fase: lectura y escritura

A continuación, se describe el comportamiento que deben de tener las llamadas de lectura y escritura teniendo en cuenta que deben de seguir un modelo síncrono basado en datagramas:

Con respecto a la gestión del buffer interno que sirve como almacenamiento temporal del mensaje en curso, se reservará de forma dinámica con kmalloc, usándose kfree para liberar la memoria del mismo cuando sea pertinente.

Otro aspecto a tener en cuenta a la hora de desarrollar el manejador es la gestión de las señales que se pueden producir mientras un proceso está usando las funciones del manejador. Más adelante se comentará este aspecto con mayor profundidad.

A continuación, se revisan algunos aspectos que deben de conocerse para afrontar esta fase.

Reserva y liberación de memoria dinámica

Las funciones para reservar y liberar memoria dinámica son kmalloc y kfree, cuyas declaraciones son las siguientes:

    void *kmalloc(size_t size, int flags);
    void kfree(const void *);

Sus prototipos son similares a las funciones correspondientes de la biblioteca de C. La única diferencia está en el parámetro flags, que controla el comportamiento de la reserva. Los tres valores más usados para este parámetro son:

GFP_KERNEL
Reserva espacio para uso del sistema operativo pero asumiendo que esta llamada puede bloquear al proceso invocante si es necesario. Es el que usaremos en la práctica.
GFP_ATOMIC
Reserva espacio para uso del sistema operativo pero asegurando que esta llamada nunca se bloquea. Es el método que se usa para reservar memoria en el ámbito de una rutina de interrupción.
GFP_USER
Reserva espacio para páginas de usuario. Se usa en la rutina de fallo de página para asignar un marco al proceso.
En caso de error, la llamada kmalloc devuelve NULL. Habitualmente, y así se debe hacer en la práctica, en ese caso se termina la función correspondiente retornando el error -ENOMEM.
Acceso al mapa de usuario del proceso
Habitualmente, un manejador necesita acceder al mapa de memoria de usuario para leer información del mismo, en el caso de una escritura, y para escribir información en él, si se trata de una lectura. Los prototipos para estas funciones son, respectivamente, los dos siguientes:
    #include <asm/uaccess.h>
    unsigned long copy_from_user(void *to, const void *from, unsigned long n);
    unsigned long copy_to_user(void *to, const void *from, unsigned long n);
En caso de éxito, estas funciones devuelven un 0. Si hay un fallo, que se deberá a que en el rango de direcciones del buffer de usuario hay una o más direcciones inválidas, devuelve un valor distinto de 0 que representa el número de bytes que no se han podido copiar.

Normalmente, y así se hará en la práctica, si se produce un error, la función correspondiente devuelve el error -EFAULT.

Bloqueo y desbloqueo de un proceso

La mayoría de los manejadores requieren bloquear en algunas circunstancias al proceso que invoca alguna de las funciones del manejador, de manera que se quede a la espera de la ocurrencia de un determinado evento. Antes de explicar qué funciones proporciona Linux para realizar estas operaciones de bloqueo y desbloqueo, se plantea un ejemplo hipotético que puede ilustrar el uso típico de esta funciones.

Supongamos un hipotético manejador de un terminal en el que la función de lectura bloqueará al proceso que la invoca en el caso de que no haya ningún carácter en el buffer asociado al terminal. El desbloqueo se producirá cuando llegue una interrupción del terminal. A continuación, se especifica un pseudo-código para estas funciones:

    struct info_term {
        char buffer[MAX];
	lista_proc bloq_term;
        ................
    } info;

    int leer_caracter() {
        while (vacio(info.buffer))
            bloquear(&info.bloq_term);
        consumir_caracter(info.buffer);
    }

    void rut_int_terminal() {
	insertar_caracter(info.buffer);
        if (!vacia(&info.bloq_term))
            desbloquear(&info.bloq_term);
    }

Una vez planteado el ejemplo que usaremos en esta sección, se presenta de forma muy resumida los aspectos básicos de la gestión de bloqueos y desbloqueos en Linux.

En primer lugar, se muestra cuál es el tipo de datos en el que se basa la construcción de listas de procesos bloqueados. Se trata de las colas de espera (wait queues), que se declaran e inician como se muestra a continuación.

    #include <linux/wait.h>
    
    // declaración e iniciación de una variable aislada
    DECLARE_WAIT_QUEUE_HEAD(cola);

    // declaración de una variable dentro de una estructura
    struct info_term {
        wait_queue_head_t bloq_term;
        ................
    } info;
    // iniciación de la variable
    init_waitqueue_head(&info.bloq_term);
Linux proporciona una gran variedad de funciones para bloquear y desbloquear procesos con diversas especializaciones, lo que proporciona un mecanismo muy flexible y eficiente. En esta presentación sólo revisaremos una pequeña parte de esta funciones.

Con respecto al desbloqueo, Linux proporciona una familia de funciones para realizar esta operación (en la versión actual hay 7 funciones de desbloqueo, reconocibles ya que sus nombres comienzan por el prefijo wake_up). De todas formas, la mayoría de los manejadores utilizan la función wake_up_interruptible, que desbloquea a todos los procesos esperando en esa cola de procesos (realmente, para ser precisos, habría que hacer dos matizaciones que no hay que tener en cuenta de cara a la práctica: por un lado, sólo despierta a aquellos procesos que se hayan bloqueado de forma interrumpible, que suele ser lo habitual; por otro lado, si hay algún proceso que haya hecho un bloqueo exclusivo, sólo despierta a ese proceso, aunque tampoco suele usarse este tipo de bloqueo). Su prototipo es el siguiente:

    void wake_up_interruptible(wait_queue_head_t *cola);
A continuación, se muestra cómo se utilizaría esta función en el ejemplo planteado (nótese que no es necesario comprobar que la cola no está vacía antes de usarla ya que esta comprobación la realiza la propia función).
    void rut_int_terminal() {
	insertar_caracter(info.buffer);
        wake_up_interruptible(&info.bloq_term);
    }
En el caso del bloqueo, existe una interfaz antigua basada en las funciones sleep_on e interruptible_sleep_on. Esta última función es la que más se usaba ya que ponía al proceso en estado de bloqueo interrumpible, que es más recomendable (un proceso en un bloqueo no interrumpible no puede ser despertado por una señal). Su declaración es la siguiente:
    void interruptible_sleep_on(wait_queue_head_t *cola);
Su uso en el ejemplo sería el siguiente:
    int leer_caracter() {
        while (vacio(info.buffer))
            interruptible_sleep_on(&info.bloq_term);
        consumir_caracter(info.buffer);
    }
Dado que el uso de esta función era propenso a las condiciones de carrera en el intervalo que transcurre entre la consulta de la condición y la invocación de la misma, se ha desarrollado una nueva colección de funciones que evitan este problema. Entre ellas está la función wait_event_interruptible cuyo prototipo es el siguiente:
    int wait_event_interruptible(wait_queue_head_t cola, int condicion);
Esta función causa que el proceso se bloquee en la cola hasta que se cumpla la condición. A todos los efectos, el modo de operación de esta función se puede interpretar como un bucle que consulta repetidamente la condición bloqueando al proceso hasta que ésta se cumpla. Nótese que si en el momento de invocar la función ya se cumple la condición, no se bloqueará.

Utilizando esta función, el ejemplo quedaría de la siguiente forma:

    int leer_caracter() {
	wait_event_interruptible(info.bloq_term, !vacio(info.buffer));
        consumir_caracter(info.buffer);
    }
La función wait_event_interruptible devuelve un valor distinto de 0 si el bloqueo ha quedado cancelado debido a que el proceso en espera ha recibido una señal. Si es así, el tratamiento habitual es terminar la llamada devolviendo el valor -ERESTARTSYS, que indica al resto del sistema operativo que realize el tratamiento oportuno. Esta misma estrategia con respecto al tratamiento de señales será la que se utilizará en el desarrollo del manejador planteado en este enunciado. El ejemplo quedaría definitivamente de la siguiente manera:
    int leer_caracter() {
	if (wait_event_interruptible(info.bloq_term, !vacio(info.buffer)))
		return -ERESTARTSYS;
        consumir_caracter(info.buffer);
    }
Pruebas

A continuación se proponen algunas pruebas para verificar si la funcionalidad se ha desarrollado correctamente:

  1. Prueba de un lector y un escritor que lee líneas del teclado, en ambos casos usando cat. Se pueden realizar varias ejecuciones tecleando distinto número de líneas de entrada en el escritor (incluso ninguna línea). Recuerde que para indicar el final de la entrada de datos se usa el Control-D.
        # se puede lanzar en background o en una ventana diferente
        $ cat /tmp/canal &
        $ cat > /tmp/canal
    
  2. Prueba de un lector y un escritor que lee líneas de un fichero, en ambos casos usando cat.
        # se puede lanzar en background o en una ventana diferente
        $ cat /tmp/canal &
        $ cat > /tmp/canal < /etc/passwd
    
  3. Prueba de un lector y un escritor tal que el tamaño de las lecturas es menor que el de las escrituras, en ambos casos usando dd. Nótese que debe de perderse la mitad de la información del fichero de contraseñas.
        # se puede lanzar en background o en una ventana diferente
        $ dd bs=1 < /tmp/canal &
        $ dd bs=2 > /tmp/canal < /etc/passwd
    
  4. Para esta prueba se usará el siguiente programa, que denominaremos lector.c:
    #include <stdio.h>
    #include <unistd.h>
    
    int main() {
    	int tam;
    	char buf[4096];
    	while ((tam=read(0, buf, 4096))>0) {
    		write(1, buf, tam);
    		sleep(5);
    	}
    	return 0;
    }
    
    Se intenta comprobar el comportamiento síncrono del mecanismo, observando cómo una escritura quedará bloqueda hasta que se produzca la lectura. Para la prueba se ejecutará el mandato strace, muy usado por los programadores de código de sistema, que muestra las llamadas al sistema que va ejecutando un proceso.
        # se deben lanzar en ventanas diferentes
        $ strace ./lector < /tmp/canal
        $ strace cat > /tmp/canal
    
  5. Se va a comprobar si se produce la señal SIGPIPE en el escritor cuando hay un cierre prematuro en el lector.
        # se puede lanzar en background o en una ventana diferente
        $ head -3 /tmp/canal &
        $ cat > /tmp/canal
    
  6. Esta prueba es similar a la anterior pero el escritor, en vez de leer de la entrada estándar, lo hace de un fichero.
        # se puede lanzar en background o en una ventana diferente
        $ head -3 /tmp/canal &
        $ dd bs=1 > /tmp/canal < /etc/passwd