Programación de un manejador para un altavoz
Objetivo
El objetivo del trabajo es que el alumno llegue a conocer cómo se desarrolla
un manejador para un dispositivo de caracteres en Linux. Concretamente, por las
razones que se explican más adelante, se ha optado por un
manejador que gestione un altavoz.
Este proyecto va a permitir aprender, al menos
de forma básica, diversos aspectos vinculados con este tipo de desarrollo:
- El ciclo de desarrollo de un módulo del núcleo.
- La incorporación de un manejador al sistema cuando este
está arrancado.
- El acceso al hardware del equipo desde el manejador.
- Los problemas de sincronización que se presentan a la hora de
desarrollar software de estas características. Como ocurre con
cualquier manejador real en Linux, el trabajo asume las peores condiciones
posibles en lo que se refiere a la sincronización: un núcleo
expulsivo (opción Preemptible Kernel en el menú
correspondiente del make config) ejecutando en un multiprocesador.
- Y muchos otros aspectos tales como: la reserva de memoria dentro del
núcleo o la gestión de temporizadores internos.
Este documento, además de explicar las características del manejador
que se pretende desarrollar, incluye información de los diversos
aspectos internos de Linux que se requiere conocer para llevar a cabo
este proyecto. En cualquier caso, se recomienda la consulta del
libro referencia sobre esta temática:
- Linux Device Drivers, Jonathan Corbet, Greg Kroah-Hartman y Alessandro Rubini. Tercera edición, O'Reilly 2005 (PDF | online).
Téngase en cuenta que es un libro un poco antiguo (se ocupa de la
versión 2.6.10 de Linux) y, por tanto, alguna de la información que contiene
ha quedado obsoleta.
Se recomienda también acceder a sitios web que ofrecen la posibilidad
de navegar por el código fuente de las distintas versiones de Linux
ofreciendo un servicio de referencias cruzadas (Linux
Cross Reference) como, por ejemplo, lxr.free-electrons.com o lxr.linux.no.
Plataforma de desarrollo
Se plantean dos posibles plataformas a la hora de afrontar el desarrollo
de este manejador de un altavoz:
- Desarrollo en un computador personal estándar.
- Desarrollo en un equipo Raspberry Pi.
Ambas plataformas incluyen hardware que implementa la funcionalidad de tipo
PWM
requerida para asegurar la correcta temporización de los sonidos
sin necesidad de que el procesador tenga que intervenir en cada
paso de la generación de los mismos.
En caso de que se desarrolle el código para ambas plataformas, se puede
integrar en el mismo manejador usando directivas condicionales del
preprocesador para aquellas partes que sean específicas de cada plataforma.
Desarrollo en un computador personal estándar
En esta plataforma se ha optado por usar el altavoz interno
del equipo como dispositivo para el que se diseñará el manejador,
puesto que tiene el mismo comportamiento en todos los equipos donde está
instalado, esta presente en la mayoría de los equipos, es relativamente
sencillo y, por último, no tiene una importancia crítica en el sistema
(en el sentido de que la manipulación errónea del mismo no pueda comprometer
el buen funcionamiento del sistema).
Desafortunadamente, aunque
durante mucho tiempo el altavoz interno ha sido un componente
que ha estado presente en todos los equipos (entre otras funciones, uno de
sus usos más relevantes es generar sonidos que indiquen qué tipo de problemas
pueden aparecer durante el arranque del equipo),
en los últimos años algunos fabricantes han optado por prescindir del mismo.
NOTA: El alumno debe comprobar si su equipo Linux dispone de altavoz
interno. Para ello, puede instalar el mandato beep, que
interacciona directamente con el altavoz interno, y ejecutarlo,
habiendo previamente cargado el manejador Linux de ese dispositivo
(módulo pcpkr), que, por defecto, no está instalado.
sudo apt-get install beep
sudo modprobe pcspkr
sudo beep
Si la ejecución de ese mandato no produce ningún sonido, su equipo no dispone
de un altavoz interno (al menos, no de uno estándar) y debe notificárselo
al profesor encargado de la práctica para buscar una solución a esta
situación.
Desarrollo en una Raspberry Pi
Aunque en el caso de esta plataforma concebida para el desarrollo
de sistemas empotrados se presenta una gran variedad de posibilidades
a la hora de plantear un proyecto práctico (la imaginación es el límite),
por mantener la compatibilidad con la plataforma PC, se va a desarrollar
también el manejador de un altavoz. El altavoz estará conectado a uno de
los pines de tipo GPIO a los que se les puede asociar la funcionalidad PWM
disponible en esta plataforma, concretamente, al pin 12 de la placa
(identificado en la documentación como GPIO18).
En cualquier caso, para facilitar el desarrollo del proyecto,
aunque no se disponga en principio de un equipo con altavoz interno, todas las
operaciones relacionadas con interaccionar directamente con el altavoz
se ha englobado únicamente en la primera fase del proyecto y el código
correspondiente a las mismas se va a incluir en un solo fichero
(spkr-io.c).
De este fichero, se proporciona una versión inicial tal que la
implementación de esas operaciones sobre el hardware del altavoz
únicamente imprime un mensaje que especifica qué operación
se ha llevado a cabo (por ejemplo, si se ha activado el altavoz o se
ha fijado su frecuencia a un determinado valor).
De esta forma, aunque en un momento dado se use un equipo que no tenga altavoz, se pueden
afrontar las cinco fases restantes comprobando si los mensajes
que van imprimiéndose son correctos.
Descripción general del trabajo práctico a realizar
Se plantea desarrollar un módulo que actúe de manejador
de un dispositivo de caracteres que proporcione
acceso a un altavoz conectado al equipo y que tendrá las siguientes
características:
- El dispositivo se denominará /dev/intspkr. Ese fichero especial se
deberá crear automáticamente al cargarse el módulo.
- El dispositivo podrá usar cualquier major, pero el
minor lo recibirá como parámetro (minor), teniendo
un valor por defecto de 0.
- Para enviar datos al altavoz, el proceso usará la llamada
write. Evidentemente, por la propia esencia del dispositivo,
no habrá una llamada read, aunque sí se implementará
una función ioctl que ofrecerá diversos mandatos (como,
por ejemplo, enmudecer el altavoz).
- Cada 4 bytes que se escriben en el dispositivo definen
un sonido: los dos primeros bytes especifican la frecuencia,
mientras que los dos siguientes determinan la duración de ese
sonido en milisegundos. Completada esa duración, el altavoz se
desactivará, a no ser que haya más sonidos pendientes de generarse,
ya sean parte del mismo write o de dos sucesivos.
Un sonido con un valor de frecuencia de 0 corresponderá a desactivar el
altavoz durante la duración indicada (se trata de un silencio
o pausa en la secuencia de sonidos). Por otro lado,
dado que un sonido con una duración de 0 no tendrá ningún efecto, se
podrá implementar como se considere oportuno siempre que tenga ese
comportamiento nulo.
Nótese que si el manejador recibe menos de
4 bytes, al no tratarse de un sonido completo, guardará esa información
parcial hasta que una escritura posterior la complete. Asimismo,
téngase en cuenta que una única operación de escritura puede
incluir numerosos sonidos (así, por ejemplo, un write de
4KiB incluiría 1024 sonidos), que se ejecutarán como una secuencia.
- Por la propia idiosincrasia del dispositivo, se ofrecerá
acceso exclusivo al mismo: solo se permitirá que lo tenga
abierto un único proceso en modo escritura, retornando
el error -EBUSY si un segundo proceso intenta abrirlo
también en ese modo. Nótese que, sin embargo, sí se permite que
varios procesos puedan abrir el dispositivo en modo lectura,
no para leerlo, que no es posible, sino para poder solicitar
operaciones ioctl sobre el mismo.
- Dado que se pretende que el código desarrollado sea válido para
sistemas multiprocesador con un núcleo expulsivo, se debe realizar
una correcta sincronización tanto entre llamadas concurrentes como
entre una llamada y la rutina asíncrona de tratamiento del temporizador
requerido para implementar la duración de cada sonido.
Nótese que, aunque el dispositivo es de acceso exclusivo, distintos
threads de un proceso (o un proceso y sus descendientes)
pueden realizar llamadas write concurrentes al manejador
usando el mismo descriptor de fichero, que
habrá que secuenciar.
- Para optimizar el funcionamiento del dispositivo, maximizar
el paralelismo entre la aplicación y la operación del dispositivo, y permitir
un mejor secuenciamiento de los sonidos generados por la aplicación
(lo que podría considerarse una especie de streaming),
el manejador se implementará con un esquema de escritura
diferida basado en un modelo productor-consumidor,
utilizando un buffer interno para desacoplar al productor
(la aplicación) y el consumidor (el propio dispositivo). De esta
forma, la operación de escritura copiará los datos al buffer
retornando inmediatemente, excepto si no hay espacio en el mismo,
circunstancia en la que se bloqueará. En el caso de que el tamaño
de los datos a escribir sea mayor que el de la cola, deberá realizar
varias iteraciones de copia en modo sistema, con sus correspondientes bloqueos, hasta completar la llamada.
Por otro lado, cada vez que
se active la rutina de temporización que indica el final de un
sonido, esta comprobará si hay más sonidos almacenados en la cola
y, en caso afirmativo, iniciará la reproducción del siguiente
a la frecuencia especificada activando, asimismo, un temporizador
para controlar la duración del mismo.
- El tamaño del buffer interno se recibirá como parámetro (buffer_size, que será igual al tamaño de una página, PAGE_SIZE,
si no se especifica) y, por razones que se explican más adelante, su tamaño
debería ser una potencia de 2, siendo redondeado por exceso para
cumplir ese requisito en caso de no serlo.
- Para reducir el número de cambios de contexto pero maximizando
el paralelismo entre la aplicación y la reproducción de sonidos,
si hay un proceso bloqueado debido a que el buffer está lleno,
no se le desbloqueará en cuanto se complete el procesado de un sonido
sino que se esperará hasta que haya un hueco suficientemente
grande en el buffer que, o bien, le permita completar
su petición y volver a modo usuario, o bien sea mayor o igual que un cierto umbral mínimo
recibido como parámetro (buffer_threshold, que será
igual a buffer_size, si no se especifica, y, en cualquier
caso, nunca será mayor que el tamaño del buffer).
- Con este modo de operación desacoplado, después de que la aplicación cierre
el fichero (e incluso después de que desaparezca), todavía pueden quedar datos pendientes de procesar en el buffer interno, realizándose el procesado
de los mismos de manera convencional.
Sin embargo, este modo desacoplado presenta un problema a la hora de eliminar el módulo.
Dado que la aplicación puede cerrar el fichero asociado al dispositivo
aunque este siga en funcionamiento, si se intenta eliminar el módulo en
esas circunstancias, el sistema operativo no pondrá ningún impedimento
a esa operación de eliminación del módulo puesto que no hay un fichero abierto
asociado al módulo, lo que causaría una situación catastrófica y un muy
probable pánico en el sistema.
El módulo debería asegurarse
de que la operación de finalización del mismo realiza una desactivación
ordenada del dispositivo, deteniendo el temporizador que pudiera
estar activo y silenciando el altavoz.
- Además de las operaciones write e ioctl,
se ofrece también la llamada fsync, que bloqueará al proceso
hasta que se vacíe el buffer. Esta llamada permite a una
aplicación tener una mayor
sincronía en su interacción con el dispositivo si así lo prefiere.
Así, por ejemplo, una aplicación podría invocar esta llamada
antes del close para asegurarse de que se han procesado
todos los sonidos que ha generado antes de terminar. Otro ejemplo
con todavía mayor sincronía sería una aplicación que llamada a fsync
después de cada write.
- Se proporcionará una operación ioctl para enmudecer
(mute) o desenmudecer el altavoz, según el valor del parámetro
que reciba. Mientras está el altavoz enmudecido, se seguirán
procesando los sonidos de la forma habitual, de manera que cuando se
desenmudezca, se escuchará justo el sonido que esté reproduciéndose
en ese instante (no se oirá nada en el caso de que no se esté procesando
ningún sonido en ese momento o se esté trabajando con un silencio).
- Se proveerá de otra operación ioctl para hacer un reinicio
del dispositivo vaciando la cola de sonidos pendientes de procesar.
Sin embargo, se dejará que concluya el procesado del sonido actual,
en caso de que lo hubiera. Los sonidos que se escriban después de esta
operación se procesarán de la forma convencional.
- Como último aspecto requerido, el código desarrollado debe ser capaz
de funcionar en sistemas Linux desde la versión 3.0 hasta la actual.
Para facilitar el desarrollo de este manejador se plantean una serie
de fases de carácter incremental. Dentro de cada fase, se presentan
tres secciones:
los conceptos teóricos requeridos por la misma (el bagaje teórico
requerido), la funcionalidad a
implementar y las pruebas a realizar sobre el software desarrollado
(no se trata de pruebas exhaustivas, sino un mínimo conjunto
de comprobaciones de la funcionalidad planteada en este enunciado).
Se distinguen las siguientes fases:
- Gestión del hardware del dispositivo.
- Alta, y baja, del dispositivo.
- Operaciones de apertura y cierre.
- Operación de escritura.
- Operación fsync y adaptación a la versión 3.0 de Linux.
- Operaciones ioctl.
Primera fase: Gestión del hardware del dispositivo
Antes de presentar esta fase, hay que resaltar que, aunque ha sido
identificada como la primera en esta presentación, su implementación
puede realizarse en el momento en que se considere oportuno
(incluso al final del proyecto), puesto que se puede desarrollar
el resto de la práctica sin haberla completado (en vez de
sonar el altavoz, aparecerán los mensajes correspondientes).
Las fases restantes de la práctica sí deberían realizarse en
el orden establecido.
Bagaje requerido
Acceso PIO a los registros del dispositivo
Linux proporciona un API interno para acceder a los puertos
de entrada/salida de un dispositivo. Hay diversas funciones
dependiendo del tamaño de la información transferida (8, 16 o 32)
y si se trata de una lectura (in) o una escritura (out):
inb, inw, inl, outb, outw
y outl, respectivamente.
Además, existen versiones de esas mismas funciones con un sufijo
_p (por ejemplo, inb_p), que realizan una muy breve
pausa después de la operación de entrada/salida, que puede ser
necesario en algunas arquitecturas.
Acceso MMIO a los registros del dispositivo
Para poder acceder a los registros del dispositivo cuando estos están
conectados mediante MMIO a partir de una cierta dirección de memoria
física, se requiere asociar un rango de direcciones lógicas
a las direcciones físicas correspondientes a esos registros.
La función ioremap realiza esta función recibiendo
como parámetros el rango de direcciones físicas que se pretende
acceder y devolviendo la dirección lógica a partir de la cual
se puede acceder a ese rango:
void *ioremap(unsigned long phys_addr, unsigned long size);
Aunque la dirección devuelta puede usarse como un puntero convencional
en la mayoría de las plataformas, podría haber algunas en las que no es
posible. Por ello, la documentación de Linux propone como una buena
práctica usar las siguientes funciones para leer y escribir con ese puntero
datos de diversos tamaños:
- unsigned int ioread8(void *addr);
- unsigned int ioread16(void *addr);
- unsigned int ioread32(void *addr);
- void iowrite8(u8 value, void *addr);
- void iowrite16(u16 value, void *addr);
- void iowrite32(u32 value, void *addr);
Una posible ventaja adicional de usar estas funciones en lugar de el acceso
directo mediante el puntero es que facilita la identificación de
qué partes del código manipulan dispositivos de E/S.
Asimismo, su uso en una plataforma basada en un procesador ARM, como
el caso de una Raspberry Pi, incluye
de forma transparente el uso de barreras requerido en este tipo de sistemas.
La sincronización en Linux
Un aspecto fundamental y muy complejo en el desarrollo de un manejador
es el control de todos los problemas de concurrencia que pueden presentarse
durante la ejecución del mismo, y más si este está destinado a ejecutar
en un sistema multiprocesador con un sistema operativo expulsivo, como
ocurre con todo el código de Linux.
De hecho, el grado de complejidad es tal que es prácticamente imposible
verificar de una manera formal la total corrección de un determinado módulo
y que el mismo está libre de condiciones de carrera.
Aunque este aspecto se ha estudiado en la parte teórica de la asignatura,
en esta sección repasaremos brevemente algunos de los conceptos asociados
a este problema, que afectarán al diseño del manejador desde esta primera
fase.
Puede consultar el
capítulo 5 del libro LDD para profundizar en
los aspectos presentados en este apartado.
Simplificando un poco el tema, se pueden distinguir dos tipos de escenarios
potencialmente conflictivos en lo que se refiere a la concurrencia
y dos clases de soluciones. En cuanto a los escenarios conflictivos,
se podrían clasificar como:
- Problemas de concurrencia entre llamadas concurrentes, es decir,
entre eventos síncronos.
- Problemas de concurrencia entre una llamada y una interrupción, ya
sea hardware o software, es decir,
entre un evento síncrono y uno asíncrono.
Con respecto a las posibles soluciones, se podría distinguir entre:
- Soluciones bloqueantes, como, por ejemplo, semáforos y mutexes. Este
tipo de soluciones solo pueden usarse para resolver problemas entre
llamadas concurrentes.
- Soluciones basadas en espera activa, como las distintas
variedades de spinlocks que ofrece cualquier sistema
operativo. Esta clase de soluciones solo se puede aplicar a
situaciones donde la sección crítica es corta y no incluye bloqueos.
Es la única solución posible para resolver los problemas de concurrencia
entre una llamada y una rutina de interrupción. Esta solución también
se puede aplicar al problema de concurrencia entre llamadas siempre
que la sección crítica involucrada sea breve y no incluya ningún
bloqueo.
Linux ofrece una gran variedad de mecanismos de sincronización de ambos
tipos. Dadas las necesidades del proyecto, en esta sección basta con comentar
brevemente uno de cada tipo.
Como mecanismo bloqueante (es decir, adecuado para resolver problemas
de sincronización entre llamadas), se puede usar el mutex,
cuyo modo de operación es el habitual de esta clase de mecanismo.
- El tipo de datos que corresponde a un mutex es: struct mutex.
- La función para iniciarlo es: mutex_init(struct mutex *m).
- Las funciones para obtenerlo son: int mutex_lock(struct mutex *m) e int mutex_lock_interruptible(struct mutex *m). El sufijo interruptible indica que el proceso
esperando por ese mutex puede salir del bloqueo por una señal, y suele
ser la opción usada en la mayoría de las ocasiones. Cuando el proceso
recibe una señal, mutex_lock_interruptible devuelve un valor
negativo y, normalmente, la llamada al sistema debería terminar en ese
momento devolviendo el error -ERESTARTSYS.
- La función para liberarlo es: int mutex_unlock(struct mutex *m).
En cuanto al mecanismo de sincronización basado en espera activa (es decir, adecuado para resolver problemas
de sincronización entre una llamada y una interrupción), se van a presentar
los spinlocks convencionales (Linux ofrece una variedad de
mecanismos de este tipo: spinlocks de lectura/escritura, seqlocks, RCU locks,...).
- El tipo de datos asociado a un spinlock es: spinlock_t.
- La función de iniciación es: spin_lock_init(spinlock_t *lock).
- Las funciones para adquirir un spinlock son:
void spin_lock(spinlock_t *lock), que simplemente solicita
el spinlock,
void spin_lock_irq(spinlock_t *lock), que, además, inhibe
las interrupciones,
void spin_lock_irqsave(spinlock_t *lock, unsigned long flags), que, además, inhibe
las interrupciones pero guardando el estado de las mismas para poder
restaurarlo posteriormente y
void spin_lock_bh(spinlock_t *lock), que, además, inhibe las
interrupciones software.
- Las correspondientes funciones de liberación del spinlock
son:
void spin_unlock(spinlock_t *lock),
void spin_unlock_irq(spinlock_t *lock),
void spin_unlock_irqrestore(spinlock_t *lock, unsigned long flags) y
void spin_unlock_bh(spinlock_t *lock)
Como último punto con respecto a la sincronización es conveniente
resaltar dos aspectos adicionales.
La mejor forma de resolver este problema es evitarlo radicalmente
escribiendo código que no requiera secciones críticas. Así, por ejemplo,
el tipo de datos kfifo de Linux, que implementa una cola
FIFO y que será recomendado para implementar el manejador planteado
en este proyecto, está diseñado de manera que no es necesario ningún
tipo de sincronización siempre que haya solo un productor y un
consumidor.
El spinlock está implementado internamente realizando consultas
y actualizaciones atómicas de variables. Cuando una sección crítica
solo implica la actualización de una variable escalar, en vez
de usar alguno de los mecanismos presentados en esta sección,
puede realizarse un acceso atómico a esa variable. La variable
debe declararse como atomic_t y usarse las operaciones
atómicas que proporciona Linux (atomic_set, atomic_xchg,...).
Aspectos hardware específicos de la plataforma PC
Historia del altavoz interno
El altavoz interno ya estaba presente en el primer PC de IBM (modelo 5150)
como único elemento capaz de producir sonidos en el equipo. El modo
de operación no ha cambiado desde entonces: la salida del temporizador
2 del sistema está conectada al altavoz lo que permite enviar formas
de onda al mismo que generan sonidos con una frecuencia que depende
del contador que se haya cargado en ese temporizador. Nótese que con
esta solución el software no está involucrado directamente en la generación del
sonido (lo cual era un requisito dada la limitada potencia de los
procesadores de entonces), restringiéndose su labor a cargar el temporizador
con un valor que corresponda a la frecuencia deseada y, a continuación,
activar el altavoz, desactivándolo cuando ya no se desee que continúe
el sonido.
Este hardware de sonido tenía una calidad y una versatilidad muy limitadas.
Aún así, con ingenio y aprovechando ciertas propiedades físicas de estos
primeros altavoces, que eran de tipo dinámico (magnet-driven), se idearon técnicas de tipo PWM, que conseguían generar sonidos más complejos, aunque de
calidad limitada, sobre este hardware, requiriendo, eso sí, la dedicación
exclusiva del procesador para llevarlas a cabo. Estas técnicas dejaron de ser
efectivas con la progresiva sustitución de los altavoces dinámicos
por los piezoelécticos, que no tenían esas propiedades físicas.
La aparición de las tarjetas de sonido, con una calidad de audio muy superior,
eclipsó a los altavoces internos que quedaron relegados básicamente a una
importante misión: ser el medio de diagnosticar errores en el arranque
del sistema cuando ni siquiera puede estar accesible la pantalla y menos
todavía la tarjeta de sonido.
Modo de operación del altavoz interno
En esta sección se revisará brevemente el hardware de este dispositivo.
Sin embargo, de forma intencionada, no se entrará en detalle sobre el
mismo porque se considera, desde un punto de vista didáctico, que esa
labor de búsqueda de información sobre un componente hardware y el
estudio detallado de su modo de operación es parte de los conocimientos
prácticos que se pretende que alcance el alumno como parte de este proyecto.
En cualquier caso se sugieren dos referencias de OSDev
que presentan en detalle el hardware involucrado, así como aspectos sobre su
programación (PIT y PC Speaker).
Desde su diseño original, el PC dispone de un circuito denominado
Programmable Internal Timers (PIT; originalmente, chip 8253/8254,
aunque actualmente su funcionalidad está englobada en chips que integran
muchas otras funciones adicionales) que ofrece tres
temporizadores (denominados también canales): el primero (timer 0),
conectado a la línea de interrupción IRQ0 y usado normalmente como
temporizador del sistema operativo, el segundo (timer 1), asociado al refresco de la memoria dinámica,
y el tercero (timer 2), conectado al altavoz interno. Estos
temporizadores pueden actúar como divisores de frecuencia de una entrada de
reloj que oscila, por razones históricas, a 1,193182 MHZ (constante PIT_TICK_RATE en el
código de Linux). Los dos primeros temporizadores tienen directamente
conectada esa señal de entrada, pero para el tercero, que es el que nos
interesa, es necesario conectarla explícitamente, como explicaremos
en breve.
Para configurar uno de estos temporizadores, en primer lugar,
es necesario seleccionar su modo de operación (de un solo disparo,
de onda cuadrada,...) escribiendo en su registro de control (puerto 0x43)
el valor correspondiente (en nuestro caso, hay que seleccionar el timer 2
y especificar un modo de operación 3, denominado square wave generator,
que actúa como un divisor de frecuencia). A continuación, hay que escribir
en su registro de datos (puerto 0x42) el valor del contador del temporizador (el divisor de frecuencia). Al
tratarse de un puerto de 8 bits y dado que el contador puede tener hasta
16 bits, se pueden necesitar dos escrituras (primero la parte baja del
contador y luego la alta) para configurar el contador.
La operación de configuración que acaba de describirse determinará
la forma de onda que alimenta el altavoz (el código correspondiente
deberá incluirse en la función spkr_set_frequency del
fichero spkr-io.c). Sin embargo, no causa que el altavoz
comience a sonar. Como se comentó previamente, para activar
el altavoz (el código correspondiente deberá incluirse en la función
spkr_on del fichero spkr-io.c), es necesario, en
primer lugar, conectar el reloj del sistema a la entrada del temporizador 2,
lo que requiere escribir un 1 en el bit 1 del puerto 0x61.
Sin embargo, eso no es suficiente: la salida del temporizador 2 se
mezcla mediante una función AND con el bit 0 del puerto 0x61, por
lo que habrá que escribir un 1 en dicho bit para que la forma de onda
llegue a la entrada del altavoz. Por tanto, recapitulando, hay que
escribir un 1 en los dos bits de menor peso del puerto 0x61. Téngase
en cuenta que este puerto, denominado puerto B de control del sistema,
está involucrado en la configuración de otros componentes del sistema,
por lo que, a la hora de escribirse los dos primeros bits a 1, hay
que asegurarse de que los bits restantes permanecen inalterados.
Para desactivar el altavoz, es suficiente con escribir un 0 en
cualquiera de esos bits (el código correspondiente deberá incluirse en
la función spkr_off del fichero spkr-io.c).
Soporte del altavoz interno en Linux
Linux incluye dos manejadores para el altavoz interno: los módulos
pcspkr y snd_pcsp. Ambos permiten enviar al altavoz
formas de onda de la frecuencia especificada. El segundo, además, permite
gestionar sonidos más complejos usando las técnicas de PWM comentadas
previamente y solo tiene sentido usarlo en un sistema sin tarjeta
de sonido. Evidentemente, cuando se quiera probar el código
escrito, no debe estar cargado ninguno de estos dos módulos, sino
el desarrollado como parte del proyecto.
En cuanto al API ofrecido a las aplicaciones, ofrece dos tipos
de servicios:
- Basados en ioctls dirigidos a la consola (fichero /dev/console).
Concretamente, proporciona dos operaciones (man console_ioctl):
KDMKTONE, que genera un sonido del periodo (inverso de la frecuencia)
y duración en milisegundos especificados por la parte baja y alta del parámetro de la operación
(obsérvese las similitudes con el esquema propuesto en este proyecto),
y KIOCSOUND, que produce un sonido del periodo especificado que
se mantendrá hasta que se genere otro sonido o se realice esta
misma operación especificando un valor 0 como parámetro.
- Basados en caracteres de escape dirigidos a la consola (fichero /dev/console). Cuando un proceso escribe el carácter ASCII 7 (Control-G,
BELL o \a) en la consola, se produce un sonido
cuya frecuencia y duración es configurable (man console_codes):
la secuencia ESC[10;m] fija en m la frecuencia
del sonido, mientras que la secuencia ESC[11;n] establece como n la duración en milisegundos.
Evidentemente, la breve explicación de estas APIs solo tiene carácter
didáctico, puesto que no se van a usar en este proyecto, aunque
también sirve como comparativa con el API que se propone en este
trabajo. Así, por ejemplo, se puede observar
que estas APIs, a diferencia de la propuesta en este proyecto,
no permiten que la aplicación especifique una secuencia de sonidos
con una sola operación.
Aspectos hardware específicos de la plataforma Raspberry Pi
Toda la documentación técnica sobre los dispositivos en esta plataforma
se encuentra en BCM2835 ARM Peripherals. Sin embargo, en
esta sección se va a intentar proporcionar la información requerida
para implementar la funcionalidad planteada en la práctica.
Los dispositivos periféricos de la Raspberry están accesibles por MMIO
a partir de una cierta dirección física que depende de los modelos:
- En los modelos 1 y Zero en la dirección física 0x20000000 (justo
a continuación de la memoria de 512MiB que disponen).
- En los modelos 2 y 3 en la dirección física 0x3F000000.
Esta plataforma ofrece una colección de pines de tipo GPIO accesibles
mediante MMIO algunos de los cuales tienen funcionalidades añadidas,
tal como la posibilidad de asociar una señal PWM hardware a la
salida de ese pin.
Hay disponibles dos canales PWM que pueden asociarse a distintos
pines. Para el proyecto, vamos a usar el canal 1 de PWM (PWM0) asociado al pin 12
(identificado en la documentación como GPIO18), donde se habrá conectado
un altavoz.
Ambos canales están alimentados por un reloj
dedicado en exclusiva a esa labor que se debe configurar para activar la
funcionalidad PWM.
Como se detalla más adelante, para abordar la funcionalidad requerida
por el proyecto,
hay que interaccionar con tres tipos de dispositivos en la Raspberry:
- Los relojes que gestiona esta plataforma, concretamente con el
reloj asociado al PWM. La dirección base de los registros de configuración
de los distintos relojes corresponde a una dirección física que tiene
un desplazamiento de 0x101000 bytes con respecto a la inicial de la zona de
periféricos de esta plataforma (recuerde que, dependiendo del modelo, es
0x20000000 o 0x3F000000). Por cada reloj, hay dos registros de configuración
(CM_CLKCTL y CM_CLKDIV). En el caso del reloj de PWM, que
es el que nos concierne en este proyecto, sus registros de configuración,
CM_PWMCLKCTL y CM_PWMCLKDIV, aparecen en direcciones
con un desplazamiento de 0xA0 y 0xA4 con respecto a la dirección
inicial de la zona correspondiente a los relojes del sistema (es
decir, dependiendo del modelo, la dirección física de CM_CLKCTL
es 0x201010A0 o 0x3F1010A0 y la de CM_CLKDIV 0x201010A4 o 0x3F1010A4).
- Los pines GPIO de la plataforma, para configurar el pin GPIO18
de manera que quede asociado al canal 1 de PWM (PWM0). La dirección base de los
registros de los pines GPIO corresponde a una dirección física que tiene
un desplazamiento de 0x200000 bytes con respecto a la dirección inicial de la zona de
periféricos de esta plataforma.
- El dispositivo PWM, para configurar el canal 1 de PWM, que será el
usado en el proyecto. La dirección base de los
registros del dispositivo PWM corresponde a una dirección física que tiene
un desplazamiento de 0x20C000 bytes con respecto a la inicial de la zona de
periféricos de esta plataforma.
A continuación, se describen los pasos requeridos para activar una señal
PWM de las características deseadas en esta plataforma. Nótese que,
de forma intencionada, no se especifican todos los detalles de la operación
porque se considera, desde un punto de vista didáctico, que esa
labor de búsqueda de información sobre un componente hardware y el
estudio detallado de su modo de operación es parte de los conocimientos
prácticos que se pretende que alcance el alumno como parte de este proyecto.
- Activación del reloj PWM. La sección 6.3 de BCM2835 ARM Peripherals explica la configuración de los relojes de propósito general asociados
a los pines GPIO. Para el reloj PWM, el modo de operación es el mismo pero
aplicado a sus registros de configuración (CM_PWMCLKCTL y CM_PWMCLKDIV) anteriormente presentados. Dada la funcionalidad requerida por
el proyecto, bastaría con (los valores por defecto de los otros campos
son válidos):
- Escribir en CM_PWMCLKDIV el divisor seleccionado (en la sección
de funcionalidad pedida se explica este aspecto), junto con la PASSWORD
(0x5A) en el byte de más peso del registro.
- Escribir en CM_PWMCLKCTL un valor que seleccione como
fuente del reloj el oscilador y que active el reloj, junto con la PASSWORD
(0x5A) en el byte de más peso del registro.
Nótese que, dado que la configuración del reloj se va a realizar en el
momento de la carga del módulo, no tenemos que preocuparnos de los
avisos que aparecen en el manual que aconsejan manipular el reloj solo
cuando está parado para evitar generar picos extraños en la señal
generada, puesto que en ese momento inicial no está conectado al altavoz.
- Configuración de GPIO18 como PWM. La sección 6 de BCM2835 ARM Peripherals explica la configuración de los pines GPIO. Para el proyecto
se necesita configurar el pin GPIO18 de manera que quede asociado
al canal 1 de PWM (PWM0), que corresponde a la función alternativa 5
(ALT5) vinculada con ese pin, como se aprecia en la tabla que
aparece en la sección 6.2 de ese manual. La configuración de GPIO18
como ALT5, como explica dicho manual, requiere modificar el registro
GPFSEL1 (cuya dirección física tiene un desplazamiento de 4 bytes
con respecto a la zona asociada a la GPIO), que permite configurar
la funcionalidad de los pines de GPIO10 a GPIO19, escribiendo un 010
en los bits 24, 25 y 26 de dicho registro.
Nótese que para no afectar a la configuración de los otros pines es
necesario leer previamente el registro y escribir el nuevo
valor del registro modificando solo esos tres bits.
- Configuración del canal 1 de PWM. La sección 9 de BCM2835 ARM Peripherals explica la configuración de los canales PWM. Para el proyecto,
se va a usar el modo de operación M/S (mark/space), que depende
de dos valores:
- El espacio S (el rango): especifica un número de pulsos del reloj PWM (recuerde que la frecuencia del reloj PWM ya se configuró en un paso
anterior).
- El marcador M (el dato): indica que, del total de los S pulsos especificados, los M primeros tendrán la señal alta mientras que los S-M restantes la tendrán baja (nótese que el duty cycle correspondería a M/S). Obsérvese que para obtener una onda cuadrada, como
se requiere en el proyecto, el valor de M tiene que ser la
mitad del de S.
La configuración del canal 1 en esta modalidad M/S requerirá:
- Especificar el valor del rango en el registro correspondiente
a este canal: RNG1 cuya dirección tiene un desplazamiento
de 0x10 bytes con respecto al inicio de la zona asociada a este
dispositivo.
- Especificar el valor del dato en el registro correspondiente
a este canal: DAT1 cuya dirección tiene un desplazamiento
de 0x14 bytes con respecto al inicio de la zona asociada a este
dispositivo.
- Configurar el canal 1 en la modalidad M/S. Para ello,
se debe modificar el registro de control del PWM (CTRL,
ubicado al inicio de la zona asociada a este dispositivo)
escribiendo un 1 en el bit correspondiente.
Nótese que para no afectar a la configuración del otro canal es
necesario leer previamente el registro y escribir el nuevo
valor del registro modificando solo la parte correspondiente al
primer canal.
- Activar y desactivar el canal 1. Hay que escribir un 1 o un 0,
respectivamente, en el bit de menor peso del registro CTRL,
respetando el contenido previo. Nótese que esta operación se
puede combinar con la previa si se considera oportuno.
Soporte del PWM en Linux
En la versión del núcleo de Linux específica para la Raspberry Pi se incluye el
módulo pwm_bcm2835 que actúa como un manejador PWM para el
canal 1 asociado al pin GPIO18.
Evidentemente, cuando se quiera probar el código
escrito, no debe estar cargado este módulo, sino
el desarrollado como parte del proyecto.
En cuanto al API ofrecido a las aplicaciones, que no se va a usar
en este proyecto, se ofrece a través del
directorio /sys/class/pwm/. Previamente, hay que configurar
el sistema mediante Device Tree Overlays de manera que el
pin GPIO18 quede asociado a PWM0 y el reloj PWM se active. Arrancado
el sistema con esta configuración, escribiendo en ficheros del
directorio /sys/class/pwm/ se accede a esta funcionalidad.
Funcionalidad a desarrollar
En esta primera fase se desarrollará todo el código de acceso al hardware
requerido por el proyecto.
En ella se implementará la funcionalidad específica de la plataforma
para fijar
la frecuencia del altavoz, así como su activación y desactivación
(spkr_set_frequency, spkr_on y spkr_off,
respectivamente, incluidas en el fichero spkr-io.c).
Asimismo, si es necesario incluir algún código específico de la
plataforma
para la configuración inicial del dispositivo o cuando finaliza
el uso del mismo, se incorporará dentro de las funciones
spkr_init y spkr_exit,
respectivamente.
Funcionalidad a desarrollar en la plataforma PC
Se trata de manipular mediante PIO (funciones inb, outb,
etc.) los puertos 0x42 y 0x43 para fijar la frecuencia deseada
en la función spkr_set_frequency y el 0x61 para activar y desactivar la llegada de la señal periódica
al altavoz en las funciones spkr_on y spkr_off,
respectivamente.
Con respecto al cálculo de la frecuencia requerida realizado
dentro de la función spkr_set_frequency, hay que determinar cuál es
el divisor de frecuencia correspondiente teniendo en cuenta
la frecuencia del reloj de entrada al temporizador (constante PIT_TICK_RATE en el código de Linux) y la frecuencia deseada.
En cuanto a las operaciones de activación y desactivación, recuerde que
debe leerse previamente el registro 0x61 para mantener el resto
de los valores que contiene.
En este punto, ya es necesario afrontar el problema de la
sincronización. Cuando se programa el chip 8253/8254 es necesario
realizar dos escrituras al registro de datos para enviar primero
la parte baja del contador y, a continuación, la parte alta.
Por tanto, es necesario crear una sección crítica en el acceso
a ese hardware.
Hay diversas partes del código de Linux que acceden a ese hardware
y usan un spinlock (denominado i8253_lock de tipo
raw spinlock, que es más eficiente pero menos robusto
que un spinlock convencional, y que se incluye en el fichero
linux/i8253.h) para controlar el acceso.
El nuevo código desarrollado deberá, por tanto, usar también
este mecanismo. Dado que este hardware se accede tanto desde
el contexto de una llamada como desde el de una interrupción,
hay que usar las funciones que protegen también contra las
interrupciones: raw_spin_lock_irqsave y
raw_spin_unlock_irqrestore.
Funcionalidad a desarrollar en la plataforma Raspberry Pi
Siguiendo las pautas de configuración para esta plataforma, explicadas
previamente, habría que programar la siguiente funcionalidad:
- sprk_init: habría que realizar en primer lugar las
operaciones ioremap necesarias para tener acceso a las
tres zonas de memoria asociadas a los tres tipos de dispositivos
a configurar:
- Reloj PWM: tal como se explicó previamente, hay que escribir en sus
registros de configuración (CM_PWMCLKDIV y CM_PWMCLKCTL)
los valores adecuados para definir el divisor de frecuencia y
activar el reloj asociándolo al oscilador. Con respecto al cálculo
del divisor de frecuencia, se recomienda un divisor de 16, que,
dado que el oscilador genera una frecuencia de aproximadamente 19,2MHZ, proporciona
una frecuencia resultante de 1,2MHz, que es similar
a la usada en la plataforma PC.
- Configuración de GPIO18 como PWM0: como se detalló anteriormente,
esta operación requiere escribir en la posición correspondiente
del registro GPFSEL1 los tres bits requeridos, pero leyendo
previamente el contenido del registro para respetarlo.
- Configuración del canal 1 de PWM en el modo M/S: tal como
se describió antes, hay que modificar el
registro CTRL para especificar este modo pero sin activar
todavía el canal, respetando el contenido previo. Realmente, no
sería estrictamente necesario realizar esta operación en este momento
pudiéndose incluir en la propia acrivación del canal que se lleva
a cabo en spkr_on.
- spkr_set_frequency: en esta función se escribirá en los
registros RNG1 y DAT1 los valores requeridos
por la frecuencia solicitada. Con respecto al primer valor,
observe que actúa como un divisor de frecuencia, por lo que
basta con comparar la frecuencia configurada en el reloj
PWM (1,2MHz) y la recibida como parámetro. En cuanto al segundo valor,
al requerirse una onda cuadrada, será la mitad que el primero
(lo que resulta en un duty cycle del 50%).
- spkr_on: se modificará el registro CTRL,
respetando su contenido previo, para activar el canal 1.
Nótese que en caso de que no se hubiera especificado el modo M/S
en la iniciación, habría que hacerlo en este momento.
- spkr_off: se modificará el registro CTRL,
respetando su contenido previo, para desactivar el canal 1.
- spkr_exit: se realizarán las operaciones iounmap
requeridas.
Pruebas
Para probar el software desarrollado en esta fase, dado que
todavía no se ha creado el dispositivo como tal, se propone
incluir en la función de inicio del módulo una llamada
para fijar la frecuencia del altavoz, según el valor recibido
en un parámetro entero denominado frecuencia, y, a continuación,
una segunda llamada para
activarlo. Asimismo, en la llamada de fin del módulo, se realizaría
la invocación de la función que desactiva el altavoz.
De esta manera, al cargar el módulo, se debería escuchar un sonido
de la frecuencia correspondiente al parámetro recibido y al
descargarlo debería detenerse la reproducción del mismo.
Segunda fase: Alta y baja del dispositivo
Bagaje requerido
Reserva y liberación de números major y minor
Puede consultar la
sección 2 del capítulo 3 del libro LDD para profundizar en
los aspectos presentados en este apartado.
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, como se comentó previamente,
está compuesto por los valores major y minor asociados,
pudiéndose extraer los mismos del tipo identificador:
dev_t midispo;
.....
int mj, mn;
mj=MAJOR(midispo);
mn=MINOR(midispo);
O viceversa:
int mj, mn;
.....
dev_t midispo;
midispo = MKDEV(mj, mn);
Antes de dar de alta un dispositivo de caracteres, hay que reservar sus números
major y minor asociados.
En caso de que se pretenda que el
número major lo elija el propio sistema, como ocurre en este caso,
se puede usar la función alloc_chrdev_region (definida en
#include <linux/fs.h>)
, que devuelve un
número negativo en caso de error y que tiene los siguientes parámetros:
int alloc_chrdev_region(dev_t *dev, unsigned int firstminor,
unsigned int count, char *name);
- Parámetro solo de salida donde nos devuelve el tipo dev_t
del primer identificador de dispositivo reservado.
- Parámetro de entrada que representa el minor del identificador de dispositivo
que queremos reservar
(el primero de ellos si se pretenden reservar varios).
- Parámetro de entrada que indica cuántos números minor
se quieren reservar.
- Parámetro de entrada de tipo cadena de caracteres con el nombre del
dispositivo.
En caso de que se quiera usar un determinado número major se
utiliza en su lugar la función register_chrdev_region.
La operación complementaria a la reserva es la liberación de los números
major y minor asociados. Tanto si la reserva se ha hecho
con alloc_chrdev_region como si ha sido con register_chrdev_region, la liberación se realiza con la función unregister_chrdev_region,
que recibe el primer identificador de dispositivo a liberar, de tipo
dev_t, así como cuántos se pretenden liberar.
void unregister_chrdev_region(dev_t first, unsigned int count);
Alta y baja de un dispositivo dentro del núcleo
Puede consultar la
sección 4 del capítulo 3 del libro LDD para profundizar en
los aspectos presentados en este apartado.
Por ahora, solo hemos reservado un número de identificador de dispositivo formado por
la pareja major y minor.
A continuación, es necesario "crear un dispositivo" asociado a esos números,
es decir, 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>.
Para iniciar esa estructura de datos (simplemente, dar valor inicial
a sus campos), se puede usar la función cdev_init, que recibe como primer parámetro la dirección de la variable que contiene
la estructura de dispositivo que se pretende iniciar, y como segundo una
estructura de tipo struct file_operations, que especifica las funciones
de servicio del dispositivo (véase
sección 3 del capítulo 3 del libro LDD).
void cdev_init(struct cdev *cdev, struct file_operations *fops);
A continuación, se muestra un ejemplo que especifica solamente
las operaciones
de apertura, cierre y escritura.
static int ejemplo_open(struct inode *inode, struct file *filp) {
.....
static int ejemplo_release(struct inode *inode, struct file *filp) {
.....
static ssize_t ejemplo_write(struct file *filp, const char __user *buf, size_t count, loff_t *f_pos) {
.....
static struct file_operations ejemplo_fops = {
.owner = THIS_MODULE,
.open = ejemplo_open,
.release = ejemplo_release,
.write = ejemplo_write
};
Después de iniciar la estructura que representa al dispositivo, hay que
asociarla con los identificadores de dispositivo reservados previamente.
Para ello, se usa la función cdev_add:
int cdev_add(struct cdev *dev, dev_t num, unsigned int count);
siendo el tercer parámetro igual a 1 en nuestro caso, puesto que
queremos dar de alta un único dispositivo.
Finalmente, la operación de baja del dispositivo se lleva a cabo mediante
la función cdev_del:
void cdev_del(struct cdev *dev);
Nótese que esta operación siempre hay que hacerla antes de liberar el
identificador de dispositivo (unregister_chrdev_region).
Alta de un dispositivo para su uso desde las aplicaciones
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.
Anteriormente a la incorporación del modelo general de dispositivo
en Linux y el sysfs, era necesario que el administrador
invocara los mandatos mknod necesarios para crear los ficheros
especiales requeridos. Con la inclusión de este modelo, el manejador solo debe dar de alta el dispositivo en el sysfs, encargándose
el proceso demonio de usuario udevd de la creación
automática del
fichero especial (en este caso, /dev/intspkr).
El primer paso que se debe llevar a cabo es la creación de una
clase en sysfs para el dispositivo gestionado por
el manejador usando para ello la llamada class_create:
#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 (en este caso, speaker). Después de
ejecutar esta llamada, aparecerá la entrada correspondiente
en sysfs (/sys/class/speaker/).
A la hora de descargar el módulo habrá que hacer la operación
complementaria (class_destroy(struct class * clase)).
Después de crear la clase, hay que dar de alta el
dispositivo de esa clase. Para ello, se usa la función
device_create:
struct device * device_create(struct class *class, struct device *parent, dev_t devt, 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):
- El primer parámetro corresponde al valor retornado por la
llamada que creó previamente la clase.
- El tercero corresponde al dispositivo creado previamente.
- El quinto al nombre que se le quiere dar a la entrada en sysfs.
En nuestro caso, habrá que hacer una llamada a esta función
especificando como nombre de dispositivo intspkr, creándose
la entrada /sys/class/speaker/intspkr.
Nótese que el demonio de usuario udevd
creará automáticamente en /dev la entrada
correspondiente con los números major y minor
seleccionados:
ls -l /dev/intspkr
crw------- 1 root root 251, 0 Oct 9 09:15 /dev/intspkr
A partir de este momento, las llamadas de apertura,
lectura, escritura, cierre, etc. sobre esos ficheros especiales son redirigidas
por el sistema operativo a las funciones de acceso correspondientes exportadas
por el manejador del dispositivo.
La llamada device_destroy(struct class * class, dev_t devt)
da de baja el dispositivo en sysfs.
Funcionalidad a desarrollar
Una vez presentados los conceptos teóricos, se acomete la funcionalidad
planteada en esta fase. En primer lugar, hay que reservar para el dispositivo un número major,
elegido por el propio núcleo, y un número minor, que
corresponde al especificado como
parámetro del módulo con el nombre minor (0 si no se recibe ningún parámetro).
Para ello, se debe incluir dentro de la rutina de iniciación del módulo una llamada a
alloc_chrdev_region para reservar un dispositivo
llamado
spkr, cuyo major lo seleccionará el sistema, mientras que
el minor corresponderá al valor recibido como parámetro.
Asimismo, añada a la rutina de terminación
del módulo la llamada a la función unregister_chrdev_region
para realizar la liberación correspondiente.
Una vez reservado el número de dispositivo,
hay que incluir en la función de carga del módulo
la iniciación (cdev_init) y alta (cdev_add) del
dispositivo. Asimismo, se debe añadir el código de eliminación del
dispositivo (cdev_del) en la rutina de descarga del módulo.
Para dar de alta al dispositivo en sysfs, en la iniciación
del módulo se usarán las funciones class_create
y device_create. En la rutina de descarga del módulo
habrá que invocar a device_destroy y
a class_destroy para dar de baja el dispositivo y la clase
en sysfs.
En cuanto a las funciones exportadas por el módulo, en esta fase,
solo se especificarán
las tres operaciones presentadas previamente (apertura, escritura y
liberación). Además, dichas funciones
solo imprimirán un mensaje con printk mostrando qué función
se está invocando. Estos mensajes se deben mantener en la versión
final de la práctica.
Para evitar que los
programas que usen este dispositivo todavía incompleto se comporten
de manera errónea, las funciones de apertura y liberación deberían
devolver un 0, para señalar que no ha habido error, y la función de
escritura debería
devolver el mismo valor que recibe como tercer parámetro (count),
para indicar que se han procesado los datos a escribir.
Pruebas
Para comprobar el funcionamiento correcto del módulo, puede añadir
un printk que muestre qué major se le ha asignado al
dispositivo y usar el mandato dmesg, que muestra el log
del núcleo, para poder verificarlo (antes de hacer una prueba,
se recomienda vaciar el log, usando dmesg --clear,
para facilitar las verificaciones sobre la información impresa). Asimismo, después de la carga pero antes de la descarga,
puede comprobar en el fichero /proc/devices que se ha creado
una entrada correspondiente al nuevo dispositivo, y que esta desaparece
cuando el módulo es descargado.
Asimismo,
una vez cargado el módulo, debe comprobar que se han creado los ficheros correspondientes en sysfs, así como el fichero especial asociado con el dispositivo:
ls -l /sys/class/speaker/intspkr /dev/intspkr
Pruebe a cargar el módulo, sin especificar el número minor
como parámetro y haciéndolo, y compruebe que el fichero especial
creado tiene el número de minor correctamente asignado.
A continuación, pruebe a ejecutar el siguiente mandato
para comprobar que se activan las funciones de apertura, escritura y cierre:
$ echo X > /dev/intspkr
Tercera fase: Operaciones de apertura y cierre
Bagaje requerido
Parámetros del open
Puede consultar
la sección 3 del capítulo 3 y
la sección 5 del capítulo 3 del libro LDD para profundizar en
los aspectos presentados en este apartado.
La llamada de apertura recibe dos parámetros:
- Un puntero al inodo de fichero (struct inode *).
Desde el punto de vista del diseño de un manejador, el campo más interesante
de esa estructura se denomina i_cdev (struct cdev *i_cdev)
y es un puntero a la estructura cdev que se usó al crear el
dispositivo.
- Un puntero a la estructura file (struct file *)
que representa un descriptor de un fichero abierto. De cara el proyecto,
el único campo relevante es f_mode, que almacena el modo
de apertura del fichero. Para determinar el modo de apertura,
se puede comparar ese campo con las constantes FMODE_READ
y FMODE_WRITE.
Aunque no se requiera para este proyecto, a continuación, por
motivos didácticos, se explica
una estrategia que se utiliza muy frecuentemente en los manejadores
que gestionan varios dispositivos.
Como parece lógico, un manejador que gestiona varios dispositivos
utilizará algún tipo de estructura de datos para guardar la
información de cada uno de ellos. Dentro de esa estructura parece
razonable almacenar en un campo de la misma la estructura cdev
asociada a ese dispositivo. Por ejemplo:
struct info_mydev {
.......
struct cdev mydev_cdev;
.......
};
Dentro del contexto de la llamada de apertura, se requerirá acceder
a la estructura de datos que almacena la información asociada justo
con ese dispositivo. Para lograrlo, se puede acceder al campo
i_cdev del puntero al inodo recibido como parámetro, que
apuntará al campo mydev_cdev de la estructura correspondiente
a ese dispositivo en concreto y subir al comienzo de la
misma con la macro container_of. Por otro lado,
dado que las funciones de lectura y escritura no reciben como
parámetro un puntero al inodo pero sí un puntero
a la estructura file, puede usarse el campo private_data
para dejar disponible esa información a dichas funciones:
static int mydev_open(struct inode *inode, struct file *filp) {
struct info_mydev *info_dev = container_of(inode->i_cdev,
struct info_mydev, mydev_cdev);
filp->private_data = info_dev;
.......
Funcionalidad a desarrollar
Hay que asegurarse de que en cada momento solo está abierto una vez
en modo escritura el fichero. Si se produce una solicitud
de apertura en modo escritura estando ya abierto en ese mismo modo,
se retornará el error -EBUSY.
Nótese que, sin embargo, no habrá ninguna limitación con las
aperturas en modo lectura.
Téngase en cuenta que pueden ejecutarse concurrentemente varias
activaciones de la operación de apertura (ya sea porque se ejecutan
en distinto procesador o, aunque se ejecuten en el mismo, lo hacen
de forma intercalada al tratarse de un núcleo expulsivo). Por tanto,
se deberá asegurar de que no se producen condiciones de carrera.
Pruebas
A continuación se propone una prueba para verificar si
la funcionalidad se ha desarrollado correctamente:
- Se arranca en una ventana un escritor y se le deja parado sin escribir nada por
la pantalla (si es necesario ejecutarlo con sudo, se
usaría el siguiente mandato: sudo sh -c "cat > /dev/intspkr"):
$ cat > /dev/intspkr
- En una segunda ventana se ejecuta otro escritor y debe
producirse un error:
$ cat > /dev/intspkr
bash: /dev/intspkr: Dispositivo o recurso ocupado
- Se lanza un proceso que abre el fichero en modo lectura
para comprobar que no da error:
$ dd if=/dev/intspkr count=0
- En la ventana inicial introducimos un Control-D para
finalizar el proceso escritor y volvemos a lanzarlo para probar
que se ha rehabilitado la posibilidad de abrir el fichero en
modo escritura.
Cuarta fase: Operación de escritura
Esta es la fase central del proyecto en la que se desarrolla
la funcionalidad principal del manejador. En ella, es necesario
gestionar un buffer interno donde se irán almacenando
los sonidos generados por la aplicación en espera de que
se vayan reproduciendo. Para la implementación de ese buffer
que tiene un comportamiento de cola FIFO, se presentan dos
opciones: programar una estructura de datos que implemente esa cola
o, la opción recomendada, usar un tipo de datos interno de Linux,
denominado kfifo, que implementa esa funcionalidad.
Si se opta por la primera alternativa, es necesario conocer como se
reserva memoria dinámica en Linux, mientras que si se selecciona
la segunda, no es estrictamente necesario conocer este aspecto
puesto que esa reserva de memoria se realiza internamente al iniciar
una instancia del tipo kfifo. En cualquier caso, a continuación,
se va a incluir información sobre cómo se solicita y libera memoria
dinámica dentro del núcleo de Linux.
Bagaje requerido
Reserva y liberación de memoria dinámica
Puede consultar la
sección 1 del capítulo 8 del libro LDD para profundizar en
los aspectos presentados en este apartado.
Las funciones para reservar y liberar memoria dinámica son kmalloc
y kfree, cuyas declaraciones son las siguientes:
#include <linux/slab.h>
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 se usa para reservar memoria en el contexto
de una llamada al sistema.
- 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, en ese caso se termina
la función correspondiente retornando el error -ENOMEM.
Acceso al mapa de usuario del proceso
Puede consultar
la sección 7 del capítulo 3 y
la sección 1 del capítulo 6 del libro LDD para profundizar en
los aspectos presentados en este apartado.
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.
Para ello, se proporcionan funciones similares a la rutina estándar
de C memcpy que permiten copiar datos desde el espacio de usuario
al de sistema (copy_from_user) y viceversa (copy_to_user).
Asimismo, se ofrecen macros que permite copiar un único dato en
ambos sentidos (get_user y put_user, respectivamente).
#include <asm/uaccess.h>
unsigned long copy_from_user(void __user *to, const void *from, unsigned long n);
unsigned long copy_to_user(void *to, const void __user *from, unsigned long n);
int put_user(datum,ptr);
int get_user(local,ptr);
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, si se produce un error,
la función correspondiente devuelve el error -EFAULT.
Bloqueo y desbloqueo de procesos
A continuación, se explican algunos conceptos básicos de la gestión
de bloqueos y desbloqueos de procesos en Linux.
- La gestión de bloqueos se basa en el mecanismo de colas de espera
(wait queues). El siguiente ejemplo muestra cómo declarar e iniciar
una cola de espera que, como es frecuente, está incluida dentro de la estructura
que almacena la información del dispositivo:
#include <linux/wait.h>
#include <linux/sched.h>
// declaración de una variable dentro de una estructura
struct info_dispo {
wait_queue_head_t lista_bloq;
................
} info;
// iniciación de la variable (se suele hacer en la carga del módulo)
init_waitqueue_head(&info.lista_bloq);
- Para el bloqueo del proceso,
Linux proporciona varias funciones. En la
práctica se propone usar la macro 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 sea
desbloqueado y se cumpla
la condición.
Nótese que si en el momento
de invocar la función ya se cumple la condición, no se bloqueará.
Tenga en cuenta que la condición no solo se evaluará en el momento
de invocación de la macro, sino todas las veces que sea necesario.
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 realice el tratamiento oportuno.
- Para el desbloqueo de otro proceso,
se propone usar la función
wake_up_interruptible, que desbloquea a todos
los procesos esperando en esa cola de procesos.
Su prototipo es el siguiente:
void wake_up_interruptible(wait_queue_head_t *cola);
Nótese que no es necesario comprobar que hay algún proceso bloqueado
en la cola de espera (es decir, que la cola no está vacía) antes de usar
esta función ya que esta comprobación la realiza la propia función.
KFIFO
Linux proporciona una implementación de una cola FIFO, que permite
almacenar en la misma elementos de un determinado tamaño
(por defecto, cada elemento es de tipo unsigned char, pero puede
especificarse que sea de cualquier tipo). El diseño de
la misma está cuidadosamente realizado de manera que no requiere
ningún tipo de sincronización siempre que solo haya un flujo de ejecución
productor y uno consumidor. Puede consultar el API de kfifo y ejemplos de uso.
A continuación, se comentan brevemente algunas de las funciones que
se pueden requerir a la hora de llevar a cabo el proyecto:
- kfifo_alloc: crea una cola FIFO reservando en memoria
dinámica el buffer requerido. El número de elementos que tendrá
el buffer se especifica en el
segundo parámetro y debe ser una potencia de 2 siendo redondeado por exceso
para cumplirlo (ese requisito permite una gestión más eficiente del buffer). El tercer parámetro debe ser especificado conforme a lo explicado
en la sección que describía cómo reservar memoria dinámica en Linux,
ya que, al fin y al cabo, internamente esta función realiza un kmalloc.
- kfifo_free: libera el buffer y destruye la cola.
- kfifo_is_full, kfifo_is_empty, kfifo_size, kfifo_len y kfifo_avail
retornan, respectivamente, si la cola está llena o vacía, el tamaño de la cola
en número de elementos, el número de elementos almacenados en la misma y el número
que todavía cabrían.
- kfifo_out: extrae datos de la cola FIFO. En el
tercer parámetro se especifican cuántos elementos se quieren extraer
y como valor de retorno devuelve cuántos realmente se han extraído.
- kfifo_from_user: copia datos del mapa de usuario a
la cola FIFO. Esta macro usa internamente la función copy_from_user
presentada en el apartado previo pudiendo, por tanto, devolver el mismo
valor de error.
- kfifo_reset_out: es equivalente a extraer todo el
contenido actual del buffer pero sin copiarlo a ningún sitio.
Gestión de temporizadores
Puede consultar la
sección 4 del capítulo 7 del libro LDD para profundizar en
los aspectos presentados en este apartado.
El API interno de Linux ofrece una colección de funciones que
permiten gestionar temporizadores, es decir, permite solicitar que
cuando transcurra una cierta cantidad de tiempo, se ejecute asíncronamente
la función asociada al temporizador. A la hora de especificar el tiempo
hay que hacerlo usando como unidad el periodo (inverso de la frecuencia)
de la interrupción del reloj del sistema (es decir, el tiempo que transcurre
entre dos interrupciones de reloj). El tiempo actual se almacena en la variable
global jiffies y corresponde al número de interrupciones de reloj
que se han producido desde que arrancó el sistema.
El API de gestión de temporizadores ha cambiado a partir de la
versión del núcleo 4.15, por lo que, a continuación, se revisa brevemente
esta API mostrando los cambios sufridos en la misma.
- struct timer_list: tipo de datos asociado a un temporizador.
Aunque esta estructura tiene más campos, estamos interesados en
los dos siguientes: function,
que corresponde a la función que se invocará cuando se cumpla el
temporizador,
y expires, que determina cuándo se cumplirá el
temporizador especificando qué valor deberá tener la variable jiffies
para que se produzca su activación (así, por ejemplo,
para especificar que el temporizador se debe activar cuando transcurra,
al menos, 1 milisegundo, hay que especificar en este campo la
suma del valor actual de la variable global jiffies más el número de interrupciones
de reloj que corresponden a 1 milisegundo).
En las versiones anteriores a la 4.15, en la estructura struct timer_list hay un campo adicional
denominado data, que permite especificar un valor que la
función asociada al temporizador recibirá como parámetro cuando
sea invocada.
- La iniciación del temporizador es diferente dependiendo de la versión.
- En versiones del núcleo posteriores a la versión 4.15, hay que
usar la función timer_setup, que inicia la estructura asociada
al temporizador, especificando tres parámetros:
- En núcleos anteriores a la versión 4.15, se usa la
función init_timer, que, simplemente, inicia a valores
por degecto la estructura asociada
al temporizador. Después de invocar esta función, pero antes de activar el
temporizador, se deben asignar los valores deseados a los campos
de la estructura que se acaban de explicar.
La función activada cuando se cumple el temporizador, en
vez de una referencia al temporizador, recibe el valor del campo data:
void f(unsigned long d);
- add_timer: activa el temporizador.
- del_timer_sync: desactiva el temporizador y, en el caso
de un multiprocesador, espera a que la función asociada al
temporizador concluya, en el caso de que se estuviera ejecutando
en ese momento.
- jiffies_to_msecs y msecs_to_jiffies: calculan
a cuántos milisegundos corresponde un determinado número de jiffies
(es decir, de interrupciones de reloj) y viceversa.
Es importante resaltar que la rutina del temporizador se ejecuta
de manera asíncrona, en el contexto de una interrupción software.
Por tanto, para protegerse de la misma en un entorno multiprocesador
hay que usar spin_lock_bh para inhibir las interrupciones
software en el fragmento de código conflictivo.
Funcionalidad a desarrollar
Como se comentó previamente, esta fase es el meollo del proyecto
puesto que en ella hay que implementar toda la lógica del modo
de operación desacoplado del manejador.
En cuanto a la operación de escritura, dado que se asume que el
manejador puede ejecutar en un sistema multiprocesador con un
núcleo expulsivo, para evitar condiciones de carrera (recuerde
que la estructura kfifo está diseñada para que funcione
con un único productor y un único consumidor, siendo necesaria
una sincronización explícita en caso contrario), hay que asegurarse de que se secuencia el procesamiento
de las llamadas de escritura (como se verá más adelante, también hay que
secuenciar las llamadas fsync tanto entre sí como con respecto a
las escrituras).
Tal como se ha explicado previamente, el comportamiento de la llamada de
escritura seguirá el rol de productor trabajando de forma desacoplada
con el proceso de consumir los datos (la reproducción de sonidos).
Esta llamada quedará bloqueada solo si se encuentra la cola llena
y terminará en cuanto haya podido copiar en la cola todos los
bytes que se pretenden escribir. Esta llamada, solo en caso de que
en ese momento no se esté reproduciendo ningún sonido
(el dispositivo está parado), programará la reproducción del sonido
de turno, activando el altavoz, a no ser que se trate de un silencio.
Recuerde que si el tamaño de la escritura no es múltiplo de 4,
al no tratarse de un sonido completo, se guardará esa información
parcial hasta que una escritura posterior la complete.
La programación del dispositivo implica fijar la frecuencia
del altavoz al valor especificado y establecer un temporizador
con un valor correspondiente a la duración del sonido.
En caso de tratarse de un silencio no hay que fijar la frecuencia
sino desactivar el altavoz si estuviera activo en ese momento.
Cuando se cumple el temporizador, su rutina de tratamiento
es la que realizará la programación del próximo sonido, siempre que se encuentre
que queda alguno almacenado, desactivando el altavoz en caso contrario.
Además, desbloqueará a un proceso escritor si el hueco en la cola
o bien es suficiente para que el proceso pueda completar
su petición, o bien es mayor o igual que el umbral recibido como parámetro.
Dado el carácter asíncrono de la rutina de temporización,
es necesario establecer los mecanismos de sincronización requeridos
entre dicha rutina y la función de escritura.
Como se comentó previamente, este modo desacoplado conlleva
algunas dificultades en lo que se refiere a la descarga
del módulo (rmmod) puesto que, aunque no haya ningún
fichero abierto asociado al manejador, este puede seguir procesando
los sonidos almacenados en la cola. Hay que asegurarse, por tanto,
en la operación de fin de módulo de que se
realiza una desactivación
ordenada del dispositivo, deteniendo el temporizador que pudiera
estar activo (en un multiprocesador, incluso podría estar
ejecutando en ese momento la rutina del temporizador)
y silenciando el altavoz.
Pruebas
A continuación se propone una serie de pruebas para verificar si
la funcionalidad se ha desarrollado correctamente. Para poder
verificar mejor el comportamiento de la práctica, se recomienda
imprimir un mensaje a la entrada y a la salida de la función de escritura,
al comienzo y al final de la función asociada al temporizador y cada que
vez que se va a programar un sonido especificando la frecuencia
y duración del mismo. Usando el mandato dmesg podrá
visualizar todos esos mensajes. Se proporciona un fichero binario
(songs.bin) con la secuencia de sonidos de varias
canciones, así como una versión en modo texto del mismo (songs.txt)
para facilitar la comprobación del buen comportamiento del código
en las pruebas.
- Esta prueba comprueba si se procesa correctamente
un único sonido (la primera nota de la sintonía de Los Simpsons).
$ dd if=songs.bin of=/dev/intspkr bs=4 count=1
Compruebe con dmesg que la frecuencia y duración son
adecuadas (puede verificarlo comprobando los
valores almacenados en la primera línea del fichero songs.txt),
y que el altavoz se
ha programado correctamente:
spkr set frequency: 1047
spkr ON
spkr OFF
- En esta prueba se genera el mismo sonido pero realizando dos escrituras de
dos bytes. El comportamiento dede ser el mismo que la prueba anterior.
$ dd if=songs.bin of=/dev/intspkr bs=2 count=2
- Esta prueba genera los 8 primeros sonidos del fichero songs.bin
usando 8 escrituras y dejando tanto el tamaño del buffer como
del umbral en sus valores por defecto. La primera escritura debe activar
el primer sonido y las restantes deben devolver el control inmediatamente.
Con dmesg se debe apreciar que, exceptuando el primer sonido,
los demás son programados en el contexto de la rutina de tratamiento
del temporizador.
$ dd if=songs.bin of=/dev/intspkr bs=4 count=8
- La misma prueba que la anterior pero con una única escritura, que
debe completarse inmediatamente.
$ dd if=songs.bin of=/dev/intspkr bs=32 count=1
- Esta prueba intenta comprobar que se tratan adecuadamente las
pausas o silencios que aparecen en una secuencia.
Para ello, se van a generar los 20 primeros sonidos, donde
aparecen dos pausas. Debe comprobarse que el altavoz se desactiva
al tratar esas pausas y que se reactiva al aparecer nuevamente
sonidos convencionales en la secuencia. Se va a probar con
escrituras de 4 bytes y con una única escritura:
$ dd if=songs.bin of=/dev/intspkr bs=4 count=20
$ dd if=songs.bin of=/dev/intspkr bs=80 count=1
-
Esta prueba va a forzar que se llene la cola pero no va a definir
ningún umbral. Para llevarla a cabo, se debe cargar el módulo
especificando un tamaño de buffer de 32. A continuación,
se va ejecutar una prueba que genere 20 sonidos. En primer lugar,
con escrituras de 4 bytes:
dd if=songs.bin of=/dev/intspkr bs=4 count=20
Debe comprobar con dmesg como la llamada de escritura
que encuentra la cola llena se bloquea y que,
cada vez que completa el procesado de un sonido, se desbloquea al
proceso escritor puesto que, al realizar operaciones de 4 bytes,
ya tiene sitio en la cola para completar la llamada y ejecutar
en paralelo con el procesamiento de los sonidos previos.
Finalizada la prueba, repítala usando una única escritura de 80
bytes.
dd if=songs.bin of=/dev/intspkr bs=80 count=1
En este caso, debe comprobar cómo en la operación de escritura
se producen solo dos bloqueos.
- Esta prueba comprueba el funcionamiento del umbral. Para
ello, se repetirá la anterior (programa que genera 20 sonidos)
pero especificando un tamaño
de umbral de 16 bytes. La prueba que realiza escrituras de 4 bytes
no se verá afectada por el cambio pero sí lo estará la que realiza
una única escritura de 80 bytes, que se bloqueará tres veces, en lugar
de 2.
- Aunque se podrían probar múltiples situaciones de error, vamos
a centrarnos solo en una: la dirección del buffer de la
operación write no es válida y, por tanto, esta llamada
debe retornar el error -EFAULT. La prueba consiste
simplemente en ejecutar el programa error proporcionado
como material de apoyo.
./error
- Esta es una prueba de esfuerzo (usa los valores
por defecto de todos los parámetros): se reproduce todo el fichero de canciones usando escrituras de 4 bytes:
$ dd if=songs.bin of=/dev/intspkr bs=4
y, acabada completamente esa prueba (con la cola vacía), se repite
usando escrituras de 4KiB:
$ dd if=songs.bin of=/dev/intspkr bs=4096
- En esta prueba, que también usa los valores por defecto de todos los
parámetros, se va a comprobar que cuando se descarga el módulo justo
después de completarse la aplicación, se hace de forma correcta
deteniendo el procesamiento de sonidos y dejando en silencio el
altavoz:
$ dd if=songs.bin of=/dev/intspkr bs=4096 count=1
$ sleep 1
$ rmmod spkr
Quinta fase: Operación fsync y adaptación a la versión 3.0 de Linux
Bagaje requerido
fsync
La llamada fsync, cuando se usa con un fichero convencional,
se asegura de que todos los datos del fichero que están presentes
en la cache del sistema de ficheros residente en memoria se escriben
en el disco, no completándose hasta que no finalice esa operación
de actualización.
En el caso de dispositivos, se usa habitualmente para asegurarse
de que todos los datos presentes en el buffer del manejador
se han procesado, bloqueando al proceso hasta que eso ocurra.
A continuación, se muestra cómo se incluye en
la estructura struct file_operations la función que
maneja esta llamada al sistema. Nótese que para la funcionalidad
que se plantea en este proyecto, no son significativos los
parámetros que recibe esta llamada.
...........................
static int ejemplo_fsync(struct file *filp, loff_t start, loff_t end, int datasync) {
.....
static struct file_operations ejemplo_fops = {
.owner = THIS_MODULE,
......................
.fsync = ejemplo_fsync
};
El prototipo de esta operación ha ido cambiado en las sucesivas versiones
de Linux. Así, en la versión 3.0, se usaba el siguiente prototipo
(que, a su vez, es diferente del que aparece en la
sección 3 del capítulo 3 del libro LDD):
int spkr_fsync(struct file *filp, int datasync);
Dependencia de la versión del núcleo
Puede consultar la
sección 4.3 del capítulo 2 del libro LDD para profundizar en
los aspectos presentados en este apartado.
El API interno del núcleo va cambiando según va evolucionando Linux.
A veces, se trata de cambios importantes que exigen que haya una versión
distinta de un módulo para dos versiones diferentes del sistema
operativo. Sin embargo, en otras ocasiones, son pequeñas modificaciones
que no afectan a la funcionalidad del módulo y que pueden ser resueltas
por directivas del preprocesador que establecen distintas alternativas
a la hora de compilar el módulo dependiendo del número de versión
del núcleo para el que se compila.
Para la funcionalidad que se requiere en el proyecto, es suficiente
con conocer las dos siguientes macros:
- LINUX_VERSION_CODE: retorna el número de versión del
núcleo para el que se está compilando el módulo (se trata de un entero
formado al concatenar los distintos dígitos que forman parte
del número de una versión del núcleo de Linux).
- KERNEL_VERSION(a,b,c): genera el
entero que representa la versión correspondiente a los valores
pasados como parámetros.
Usando esas dos macros, se pueden establecer regiones de código
de compilación condicional que se adapten a los posibles cambios
en el API del núcleo que pueden existir entre diferentes versiones
del sistema operativo.
Funcionalidad a desarrollar
La implementación de esta fase requiere la incorporación de una nueva
wait queue para permitir que el proceso que realizó la llamada
se quede bloqueado esperando hasta que se complete el procesado
de todos los sonidos almacenados en la cola FIFO. Al igual que ocurre
con la llamada de escritura, mientras el proceso esté bloqueado en
esta función, no se procesarán nuevas llamadas de escritura ni al
propio fsync.
En esta fase, además, nos aseguraremos de que el código desarrollado
también compila para la versión 3.0 de Linux. Esto afecta a tres
aspectos del desarrollo:
Pruebas
Con respecto a la compilación del módulo en un núcleo de la versión 3.0,
si no dispone del entorno adecuado para probarlo, no se preocupe:
se probará una vez entregada la práctica.
En cuanto a la funcionalidad de la operación sync,
se plantea la siguiente prueba (nuevamente, se recomienda
incluir mensajes al entrar y salir se esta función):
- Teniendo el módulo cargado con los valores por defecto,
se va a ejecutar el siguiente mandato que hace una llamada fsync
justo antes del close (nótese el uso del strace
para comprobar que se bloquea la llamada fsync), primero
con 20 escrituras de 4 bytes:
$ strace dd if=songs.bin of=/dev/intspkr bs=4 count=20 conv=fsync
y luego con 1 de 80:
$ strace dd if=songs.bin of=/dev/intspkr bs=80 count=1 conv=fsync
Sexta fase: Operaciones ioctl
Bagaje requerido
ioctl
Puede consultar la
sección 1 del capítulo 6 del libro LDD para profundizar en
los aspectos presentados en este apartado.
Esta llamada es una especie de cajón de sastre a la que
se le puede asignar cualquier funcionalidad del dispositivo
que no sea fácil de plasmar como una lectura o una escritura
(por ejemplo, rebobinar una cinta o expulsar un medio extraíble).
A continuación, se muestra cómo se incluye en
la estructura struct file_operations la función que
maneja esta llamada al sistema.
...........................
static long ejemplo_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) {
.....
static struct file_operations ejemplo_fops = {
.owner = THIS_MODULE,
......................
.unlocked_ioctl = ejemplo_ioctl
};
El segundo parámetro de la llamada permite especificar qué tipo
de operación se quiere solicitar (el número identificador que tiene asignado un
determinado mandato), mientras que el tercero, que generalmente
corresponde a la dirección de memoria de un dato, representa
un valor que puede ser de entrada a la llamada, cuando esta quiere
modificar de alguna forma el estado del dispositivo, o de salida,
en caso de que su objetivo sea obtener información sobre el estado
del dispositivo.
Aunque cada manejador podría asignar sus propios números a sus
mandatos (mandato 1, mandato 2,...), para controlar mejor
los errores, existe un convenio para asignar un número de mandato
único en todo el sistema a cada operación. Para ello, ese
número se compone a partir de la siguiente información:
- Un número único de 8 bits que identifica al manejador (el
número mágico: nmagico).
- El número de la operación (nop) entre las que ofrece el manejador
(1, 2,...).
- Información sobre si la operación accede al dato
asociado al tercer parámetro para modificar de alguna forma
el estado del dispositivo
(escritura), para consultar cierta información de estado
del mismo (lectura) o no lo accede.
- El tamaño (tam) de los datos a transferir (es decir, del dato
accesible a través del tercer parámetro), en caso de que se utilice.
Nótese que se trata del tamaño del dato asociado al puntero no
del propio puntero.
Existen macros para facilitar la creación del número identificador
de un determinado mandato a partir de toda esa información:
- Si se trata de una lectura, sería _IOR(nmagico, nop, tam).
- En caso de una escritura, sería _IOW(nmagico, nop, tam).
- Si no accede al parámetro, correspondería a _IO(nmagico, nop).
Funcionalidad a desarrollar
En esta sección se van a implementar tres operaciones de tipo ioctl:
- SPKR_SET_MUTE_STATE: operación 1 que recibe
como parámetro un puntero a un entero tal que si dicho entero es
distinto de 0 se solicita la desactivación del altavoz (mute on),
mientras que si es igual a 0 corresponde a la reactivación (mute off).
- SPKR_GET_MUTE_STATE: operación 2 que recibe
como parámetro un puntero a un entero donde se retornará
el valor actual del valor fijado por la operación anterior.
- SPKR_RESET: operación 3 que solicita el vaciado de
la cola de sonidos pendientes, no usando ningún parámetro adicional.
A la hora de definir los identificadores únicos de cada operación
se usará como número mágico el carácter '9'.
Dado que se pretende que estas operaciones pueda hacerlas
una aplicación ajena a la que ha solicitado reproducir
sonidos y puesto que solo se permite acceso exclusivo en modo
escritura, las aplicaciones que pretendan usar estas operaciones
deberán abrir el fichero en modo lectura. Además, estas operaciones
deben poder realizarse aunque haya un proceso bloqueado en la
escritura o en un fsync.
Empecemos por la primera operación: la funcionalidad de enmudecer y desenmudecer.
En caso de que se solicite enmudecer y el altavoz
esté activado en ese instante, habrá que desactivarlo inmediatamente.
De manera complementaria, si se solicita desenmudecer el
altavoz y en ese momento debería estar sonando (se está reproduciendo
un sonido y no es un silencio), se procederá a su reactivación.
Recuerde que mientras está el altavoz enmudecido, se seguirán
procesando los sonidos de la forma habitual, de manera que cuando se
desenmudezca, se escuchará justo el sonido que esté reproduciéndose
en ese instante (no se oirá nada en el caso de que no se esté procesando
ningún sonido en ese momento o se esté trabajando con un silencio).
Evidentemente, el cambio del estado de esta opción se mantendrá hasta
que se modifique explícitamente mediante esta misma operación pero
especificando el valor contrario. Deben incluirse los mecanismos de
sincronización requeridos por esta llamada.
En cuanto a la segunda operación, simplemente retorna el estado actual
de esta opción, correspondiendo con el valor especificado en la
última llamada a la operación previa, teniendo en cuenta que el estado
inicial es mute off.
Con respecto a la tercera operación de vaciado de la cola, dado
que kfifo solo permite que haya un único flujo de ejecución
haciendo el rol de consumidor/lector en cada instante, debería
delegarse esta operación para que la realice el mismo flujo
consumidor que extrae los sonidos de la cola (así, se podría implementar de manera que si se ha solicitado
esta operación, cuando se vuelva a la cola para extraer más sonidos,
esta se considerará vacía liberando toda la información almacenada en
la misma). Téngase en cuenta que esta operación también opera en modo
desacoplado en el sentido de que devuelve el control inmediatamente pero
el vaciado no se produce hasta que se completa el procesado del sonido
en curso. Por tanto, los sonidos encolados por llamadas de escritura posteriores
a una operación de vaciado, pero anteriores a que se complete el procesado
del sonido actual también son eliminados. Para conseguir un funcionamiento
más síncrono, se podría hacer que esta operación no se completase hasta
que se lleve a cabo la operación de vaciado, de manera similar al modo
de operación de fsync. Sin embargo, este enunciado no plantea
implementar este modo más acoplado (aunque el mandato que se usará
para las pruebas, denominado reset, consigue ese comportamiento
llamando a fsync justo después de este ioctl).
Para esta llamada, también deben incluirse los mecanismos de
sincronización requeridos por esta llamada.
Pruebas
Antes de comenzar con las pruebas, debe modificar los programas setmute, getmute y reset para incluir en los mismos las
definiciones de las operaciones ioctl correspondientes tal como
se han definido en el módulo. Recuerde que, como se comentó previamente,
el programa reset no solo incluye la llamada ioctl sino
que, a continuación, invoca la llamada fsync, lo que asegura
que al finalizar el programa el vaciado se ha completado.
A continuación, se proponen las pruebas para realizar una verificación
básica de la funcionalidad de esta fase.
- Esta prueba intenta comprobar si se gestiona correctamente el
estado del mute:
$ ./getmute
mute off
$ ./setmute 1
$ ./getmute
mute on
$ ./setmute 0
$ ./getmute
mute off
-
La siguiente prueba va realizando operaciones que enmudecen
y desenmudecen el altavoz en distintas circunstancias (en momentos
donde se están reproduciéndos sonidos y en momentos en los que no).
Para comprobar si la evolución del estado del altavoz es correcta, se recomienda
revisar el log del núcleo con dmesg al final de
cada etapa y el borrado del mismo antes de comenzar la siguiente.
./setmute 1; dd if=songs.bin of=/dev/intspkr bs=48 count=1; sleep 1; ./setmute 0; sleep 1; ./setmute 1; echo etapa1; read v; dd if=songs.bin of=/dev/intspkr bs=48 count=1; echo etapa2; read v; ./setmute 0; dd if=songs.bin of=/dev/intspkr bs=48 count=1
-
Esta prueba se centra en el comportamiento de la operación mute
cuando coincide con la reproducción de pausas.
$ ./setmute 0; dd if=songs.bin of=/dev/intspkr bs=40 count=1 skip=1; sleep 1; ./setmute 0
$ ./setmute 0; dd if=songs.bin of=/dev/intspkr bs=40 count=1 skip=1; sleep 1; ./setmute 1
$ ./setmute 1; dd if=songs.bin of=/dev/intspkr bs=40 count=1 skip=1; sleep 1; ./setmute 0
$ ./setmute 1; dd if=songs.bin of=/dev/intspkr bs=40 count=1 skip=1; sleep 1; ./setmute 1
-
Para probar la operación de vaciado, se propone ejecutar la siguiente
secuencia de mandatos:
dd if=songs.bin of=/dev/intspkr bs=80 count=1; ./reset; dd if=songs.bin of=/dev/intspkr bs=80 count=1 skip=1
Debe comprobarse que de los 20 primeros sonidos, debido al reset,
solo se procesa uno, mientras que la segunda tanda de 20 sonidos
se procesa de forma normal.
Material de apoyo
En este fichero se encuentra el material de apoyo de la práctica.
Al descomprimirlo, se encontrará con un directorio denominado SEU
que incluye a su vez dos subdirectorios:
- kernel: incluye los dos ficheros que contendrán la funcionalidad
de la práctica (spkr-main.c y spkr-io.c), así como
el fichero Makefile correspondiente.
- usuario: incluye los programas de prueba, así como
ficheros de datos con sonidos.
Entrega del proyecto
Dentro del plazo establecido para la entrega del proyecto, el alumno
debe enviar al responsable de la práctica (fperez@fi.upm.es) un correo que especifique como asunto SEU: primera parte del proyecto y adjunte un fichero ZIP que contenga únicamente lo siguiente:
- Fichero spkr-main.c.
- Fichero spkr-io.c.
- Fichero memoria.pdf. Este fichero, además de los datos
personales de los miembros del grupo que han realizado el proyecto,
puede incluir cualquier comentario que quieran realizar acerca del
mismo. En cualquier caso, será obligatorio que la memoria incluya
una sección dedicada a explicar cómo se han solventado los problemas
de concurrencia presentes en este desarrollo.