Prácticas de
Sistemas Operativos
Programación modular en lenguaje
C
Escuela Universitaria de Informática
E
sta práctica pretende acercar al alumno a la programación modular en lenguaje C, para que se adiestre o descubra esta metodología de programación y se familiarice con elementos típicos de la programación de sistemas. La primera parte de este documento introduce al lector a las ventajas de la programación modular en general y muestra cómo desarrollar esta técnica dentro del lenguaje C. Se explica también la herramienta make del entorno UNIX. La segunda parte es un ejercicio que se propone al alumno, para que desarrolle en grupo una pequeña aplicación modular de la que se da el diseño ya preparado.
Contenido
1.
Principios de la programación modular
Supongamos que hay escrita una aplicación de síntesis de
imágenes en 3D que incluye un entorno de ventanas propio. El programa
ocupa cien mil líneas de código y consiste en un solo bloque
begin...end, sin ninguna subrutina ni nada parecido.
Es obvio que ese programa sería difícilmente legible, por más que el programador se haya preocupado de incluir comentarios. Si el programa falla, localizar dónde está el error puede ser una labor colosal. Si el programa lo ha hecho una sola persona, habrá tardado un tiempo enorme. Por otra parte, ¿lo entenderá alguien aparte de ella? Si el programa ha sido hecho por varias personas, ¿cómo han podido repartirse el trabajo sin problemas? ¿Cómo han podido probar cada uno de los algoritmos que han implementado por separado? Si hay que añadir una nueva función al programa, o adaptarlo a una nueva tarjeta de vídeo, puede se difícil delimitar las zonas de código que hay que modificar y, peor aún, las modificaciones afectarán de forma imprevisible a otras zonas del programa.
Este sería un ejemplo extremo que pone de manifiesto los problemas que tiene no estructurar el código. La programación modular impone una disciplina a la construcción de programas, que tiene como pilar la descomposición del programa en módulos: porciones de código que cumplen funciones diferenciadas.
Cada lenguaje de programación modular ofrece una manera de realizar esta descomposición: subrutinas, bibliotecas, tipos de datos estructurados, objetos, procesos, etc.
El programa modular deja de ser un simple reguero de líneas de código y se convierte en un conjunto de módulos que actúan como componentes diferenciados, como elementos que se combinan y organizan para dar como resultado la aplicación final. Se está dando una estructura al programa: tiene unas partes diferenciadas y fácilmente identificables.
La programación modular proporciona muchísimas ventajas en el diseño, desarrollo y mantenimiento de las aplicaciones. Con un ejemplo de la vida real, consideremos el proceso de fabricación de un automóvil. El hecho de que un automóvil esté construido a partir de componentes simples (volante, ruedas, carrocería, etc.), supone una gran cantidad de ventajas técnicas en su fabricación. Se puede colocar un cuentarrevoluciones digital sin necesidad de rediseñar el resto del vehículo. Si se lanza un nuevo modelo, se pueden reutilizar los mismos diseños de volantes o asientos de modelos anteriores. La descomposición en piezas separadas permite que las distintas clases de componentes se elaboren simultáneamente en secciones diferentes, disminuyendo el tiempo de fabricación total.
La interfaz se distingue de la implementación, que será la escritura del código que desempeña las funciones especificadas en la interfaz. En la implementación se pueden construir subrutinas de apoyo a las ofrecidas en la interfaz e incluso invocar a subrutinas de otros módulos.
Habitualmente la interfaz de un módulo es pública, es decir, visible para el resto del programa y por tanto utilizable, mientras que la implementación es privada, o sea que no es visible y por tanto ningún otro módulo puede alterarla o saber de sus funciones internas, variables, etc.
La aplicación estará formada por módulos interdependientes, en una relación cliente-servidor.
Fiabilidad. La implementación de cada módulo es privada, no visible. Así pues un módulo no puede afectar a los datos locales de otro, con lo que se evitan los "efectos colaterales" de la escritura de mal código, o de la alteración incontrolada de variables en fragmentos dispersos del código.
Descomposición del trabajo. En la fase de diseño se decide la estructura del programa: cuántos módulos tendrá, que función realizará cada uno y cuáles son sus interfaces con el resto del código. Una vez realizada esta descomposición, resulta sencillo asignar la implementación de los módulos a personas distintas que elaboren los fragmentos del programa por separado, resultando en un menor tiempo de desarrollo y un mejor aprovechamiento de los recursos humanos.
Prototipado. Cada módulo es una caja negra. Tiene una interfaz pública y una implementación que es privada. Los módulos sólo ven las interfaces de los otros módulos. Por ello se puede escribir un prototipo de la aplicación construyendo módulos parcialmente implementados, o con implementaciones poco eficientes, pero que remeden el comportamiento de la aplicación final. A esto se llama un prototipo de la aplicación. Los desarrolladores de software pueden probar sus módulos en el prototipo sin esperar a que el resto de los programadores termine su trabajo.
Reutilización de código. Muchos módulos pueden ser útiles en futuras aplicaciones. Si se escribe una biblioteca de funciones para gestionar una base de datos para una aplicación de venta de viviendas, no costará mucho incorporarla en una nueva aplicación de gestión de una agencia matrimonial. Esto disminuye el tiempo medio de desarrollo y aumenta el valor añadido del software.
Modificación de código. Supongamos que un programa escrito para MS-DOS reúne todas las operaciones con ficheros en un módulo. Entonces, si el programa se transporta de MS-DOS a MacOS, las alteraciones en el programa relacionadas con las diferencias en la gestión de ficheros de ambos sistemas se habrán de efectuar sólo en ese módulo de ficheros.
Además, si el programa está bien diseñado y construido, las alteraciones en un módulo no afectarán de forma imprevisible al resto del programa.
Sustitución de código. Un caso parecido consiste en reescribir un módulo si es poco eficiente o si tiene que responder a nuevas necesidades. Basta respetar la interfaz del módulo para que la nueva implementación se adapte a la aplicación.
Evaluación y depuración de código. Con el programa separado en módulos, se puede efectuar un análisis de su ejecución para determinar qué módulos se utilizan más, con el fin de invertir tiempo en optimizarlos. También se simplifica la tarea de localizar los errores en el código.
· Legibilidad
· Reusabilidad
· Fiabilidad
· Tiempo de desarrollo
Así pues, la modularización de una aplicación ha de realizarse antes de escribir la primera línea de código. Previamente tienen que estar definidos cuáles son los módulos de la aplicación, qué realizan y cuáles son sus interfaces. Lo mismo ocurre con las estructuras de datos que se utilizarán en la aplicación.
Se puede decir que la única desventaja de la programación modular
es que exige más trabajo en la fase del diseño. Pero las
contrapartidas son enormes. Además, lanzarse a escribir código
directamente es una aventura que casi siempre termina mal.
2.
Lenguaje C y programación modular
El lenguaje C es más un ensamblador estructurado que un lenguaje de
programación modular. Ofrece muy poco en cuanto a fiabilidad,
legibilidad, o cualquiera de las ventajas que hallamos en la
programación modular... si se imparte en las titulaciones
informáticas es porque todo el mundo lo utiliza en programación
de sistemas, especialmente en los ubicuos entornos de Microsoft y los sistemas
UNIX.
Así y todo, este primitivo lenguaje ofrece algunas construcciones para desarrollar el modelo de programación modular. Estas son:
· Funciones
· Ficheros de compilación separada
El lector ya ha de conocer cómo crear funciones en lenguaje C. En este documento se explicará cómo construir una aplicación descompuesta en módulos de compilación separada, cada uno con su interfaz y su implementación.
Un buen programa en C deberá estar estructurado en funciones. A su vez, las funciones que desempeñan tareas relacionadas han de agruparse en módulos. El lenguaje C no da soporte a módulos, pero hay una manera de construirlos: los ficheros de compilación separada.
Un programa en C puede consistir en varios ficheros fuentes. Cada uno contendrá un módulo, que incluirá sus propias funciones, variables y tipos de datos. Algunos de estos componentes serán públicos (utilizables por otros módulos) y otros privados.
A continuación se lista un programa compuesto de dos ficheros fuentes. Uno es el módulo principal y otro es un pequeño módulo que gestiona una variable contadora.
principal.c
#include <stdio.h>
/* variable contadora definida en otro módulo */
extern int contador;
/* Función para incrementar el contador */
extern void avanza_contador(void);
/* Punto de entrada */
main()
{
int i;
for ( i=0; i< 100; i++ )
{
avanza_contador();
printf ( "El contador vale %d\n", contador );
}
}
contador.c
int contador = 0;
void avanza_contador (void)
{
contador++;
}
En el módulo contador.c se define una variable contadora y una función para incrementar la variable. El módulo principal.c utiliza la variable y la función, las cuales declara como externas, utilizando el modificador extern. La palabra extern en una declaración significa que el símbolo definido existe, pero está definido en otro lugar.
La palabra extern se puede omitir si se trata de una función. En el ejemplo anterior, la declaración de función externa podría haber sido
void avanza_contador(void);
$ cc -o contador principal.c contador.c
La compilación ocurre (al menos) en dos fases. Primero, cada módulo .c genera un fichero objeto de extensión .o que contiene el código y almacenamiento producidos por la compilación de ese módulo. A continuación, esos ficheros objetos se enlazan (link) para dar lugar al fichero ejecutable.
Esos pasos quedan de manifiesto con estas órdenes de compilación, que dan el mismo resultado:
$ cc -c principal.c
$ cc -c contador.c
$ cc -o contador principal.o contador.o
En las dos primeras líneas se generan, respectivamente, los ficheros objetos principal.o y contador.o. La tercera orden toma los objetos y los enlaza, dando como resultado el ejecutable contador.
Esta compilación en dos fases (compilación y enlace) tiene una ventaja: si el programa está descompuesto en varios módulos y se modifica uno de ellos, sólo es necesario recompilar el módulo fuente alterado y luego enlazar todos los objetos. El enlace suele tardar mucho menos que la compilación, así que se puede ahorrar mucho tiempo con la compilación separada.
Si distintos módulos escriben una función llamada imprime() para resolver tareas internas al módulo, al enlazarlos se descubrirá que existen varias funciones con el mismo nombre y se abortará la compilación.
Una solución a todos estos casos sería cambiar los nombres de las funciones y variables que coincidan, lo cual es incómodo y va en contra de la separación del trabajo, ya que en tal caso cada programador tendría que estar al tanto del código que estén escribiendo sus otros compañeros.
En cualquier caso, persiste el inconveniente de que todas las variables y funciones de un módulo son visibles en los restantes, algo que atenta contra la fiabilidad (un módulo puede alterar una variable interna de otro módulo).
En el fondo, estos problemas se derivan de la falta de distinción entre la parte pública y la parte privada de un módulo. El lenguaje C permite definir variables y funciones privadas mediante la palabra reservada static.
Al declarar una variable global como static, es visible sólo en el módulo en que se declaró. Además, si se declaran variables static con el mismo nombre en distintos módulos, son variables diferentes, no se afectan las unas a las otras.
De igual forma, se puede definir una función como static, que será utilizable sólo en el módulo en que se define.
El siguiente ejemplo muestra un módulo que gestiona una variable por medio de varias funciones públicas.
cuenta.c
/* Funciones públicas */
int ingresa_en_cuenta ( double cantidad );
int reintegra_de_cuenta ( double cantidad );
double saldo_en_cuenta (void);
/* Implementación (parte privada) */
static double saldo = 0.0; /* La variable */
static int cantidad_positiva (double cantidad); /* Comprueba si es >0 */
static void numeros_rojos (void); /* Gestión de saldo deudor */
/* Ingresa una cantidad en la cuenta */
int ingresa_en_cuenta ( double cantidad )
{
if ( ! cantidad_positiva(cantidad) )
return -1;
saldo += cantidad;
return 0;
}
/* Extrae una cantidad de la cuenta */
int reintegra_de_cuenta ( double cantidad )
{
if ( ! cantidad_positiva(cantidad) )
return -1;
saldo -= cantidad;
if ( saldo<0 )
numeros_rojos();
return 0;
}
/* Devuelve el saldo */
double saldo_en_cuenta (void)
{
return saldo;
}
/* Implementación de cantidad_positiva */
int cantidad_positiva ( double cantidad )
{ return cantidad > 0; }
/* Implementación de numeros_rojos */
void numeros_rojos (void)
{
...
}
principal.c
#include <stdio.h>
/* Funciones públicas de cuenta.c */
int ingresa_en_cuenta ( double cantidad );
int reintegra_de_cuenta ( double cantidad );
double saldo_en_cuenta (void);
main()
{
ingresa_en_cuenta (3000.0);
reintegra_de_cuenta (1000.0);
printf ( "El saldo es %.2lf\n", saldo_en_cuenta() );
}
Obsérvese que las funciones locales en cuenta.c sólo se han declarado static una vez; basta con que la primera vez que se nombre la función se declare como static.
Las funciones públicas de cuenta.c se han declarado en principal.c sin la palabra extern, ya que se puede omitir.
En el módulo principal.c se podría haber definido una variable llamada saldo de cualquier tipo, o una función llamada numeros_rojos() sin entrar en conflicto con las del otro módulo.
Para evitar este problema, los programadores de C se emplean los llamados ficheros cabeceras (header files).
Por cada módulo existirá, aparte del fichero fuente con el código y los datos, un fichero cabecera que contendrá toda la parte pública del módulo, es decir:
· Constantes y macros públicas (#defines, etc.)
· Tipos de datos públicos (estructuras, typedefs, etc.)
· Prototipos de las funciones públicas
· Declaraciones extern de las variables públicas
Por supuesto, toda esta información aparecerá profusamente comentada, dado que un fichero cabecera es el informe de lo que está disponible en el módulo. Los comentarios deberían contener al menos:
· El nombre del módulo
· Función que desempeña
· Para cada función, qué es lo que realiza, qué posibles valores devuelve, etc.
Por convenio, los ficheros cabeceras tienen extensión .h.
El fichero cabecera lo utilizarán los otros módulos usuarios de sus funciones, incluyéndolo como parte de su código. Esto se hace escribiendo una línea de la forma
#include "módulo.h"
Lo que hace #include es lisa y llanamente incrustar el texto del fichero módulo.h en el fuente que se está compilando, con el mismo efecto que si se hubiera escrito el texto de módulo.h en el fichero que hace el #include.
Es decir, el fichero cabecera ahorra tiempo de tecleo, entre otras cosas.
Siguiendo este método, al ejemplo de cuenta.c se añadiría un fichero cuenta.h con este contenido:
/* CUENTA.H
Gestión de una cuenta bancaria
*/
int ingresa_en_cuenta ( double cantidad );
int reintegra_de_cuenta ( double cantidad );
double saldo_en_cuenta (void);
El módulo principal, en lugar de los prototipos de funciones, tendrá la línea de inclusión de la cabecera:
#include "cuenta.h"
#include <stdio.h>
main()
{
...
}
Lo habitual es que todo módulo .c incluya su propia cabecera, pues es donde están declarados todos los tipos, variables y funciones que él mismo utiliza.
El uso de ficheros cabeceras no es obligatorio. Siempre se pueden escribir directamente las interfaces de los módulos que se utilizan, aunque se considera una mala costumbre.
#include "ERROR.h"
#include "FICHEROS.h"
Si el módulo FICHEROS también utiliza el módulo ERROR, tendrá un #include de la cabecera ERROR.h, que por tanto se incluirá dos veces en el módulo principal. Cuando menos, esto es una pérdida de tiempo, pero también puede dar lugar a errores de compilación. Si en ERROR.h se define una estructura, aparecerá doblemente declarada en el módulo principal y se abortará la compilación.
Un fichero cabecera puede protegerse contra esta doble o múltiple inclusión utilizando las directivas de compilación condicional del preprocesador. La estructura general se muestra en este ejemplo:
#ifndef __ERROR_H
#define __ERROR_H
... interfaz del módulo ...
#endif
La primera vez que se incluya el fichero ERROR.h, se preguntará si la macro __ERROR_H está definida; como no es así, se #define el símbolo y se compila el resto del código hasta #endif (o sea, toda la interfaz del módulo). Las siguientes veces que se haga un #include de este fichero dentro de la misma compilación, el símbolo __ERROR_H ya estará definido, con lo que se ignorará lo escrito hasta #endif.
Se suelen utilizar símbolos de la forma __módulo__H, para evitar colisiones con otras macros definidas por el programador.
Fichero cabecera cola.h
#ifndef __COLA_H
#define __COLA_H
/*****************************************************************/
/* cola.h */
/* */
/* Manejo de una cola de datos con búsqueda por clave */
/* */
/* struct Dato */
/* tipo para los elementos de la cola */
/* */
/* Funciones */
/* */
/* void limpia_cola(void) */
/* deja la cola vacía - la inicializa */
/* */
/* int cola_vacia(void) */
/* devuelve 1 si la cola está vacía; */
/* 0 si la cola no está vacía */
/* */
/* void inserta_dato (struct Dato* dato) */
/* inserta el dato en la cola */
/* */
/* int busca_dato (int clave,struct Dato* dato) */
/* busca en la cola un elemento cuya clave coincida con */
/* el primer arg. y lo copia en el segundo arg. */
/* Si no existe ningún elemento con esa clave, */
/* la función devuelve un 0; si existe, devuelve un 1. */
/* */
/* struct Dato* NUEVODATO (void) */
/* reserva un área de memoria para un nuevo Dato */
/* */
/*****************************************************************/
/* Tipo para los datos almacenados en la cola */
struct Dato
{
char contenido [80]; /* contenido del dato */
int clave; /* clave de búsqueda */
};
/* Funciones públicas */
void limpia_cola (void);
int cola_vacia (void);
void inserta_dato ( struct Dato* dato );
void busca_dato ( int clave, struct Dato* dato );
#define NUEVODATO(p) ( (p)=malloc( sizeof(struct Dato) ) )
#endif /* __COLA_H */
Fichero de implementación cola.c
#include "cola.h"
... implementación de las funciones ...
Casi todo el fichero cabecera está ocupado por comentarios; hay que insistir en que los programas han de ser lo más legibles que se pueda.
Nótese también que se está documentando una función llamada NUEVODATO() que realmente es una macro. Los usuarios de este módulo no tienen por qué saber si una función realmente es tal función o está implementada como macro.
El tipo de datos struct Dato se define en la interfaz para que otros
módulos clientes de cola puedan crear variables de ese
tipo y utilizar correctamente las funciones.
3.
El programa make
Al descomponer una aplicación en varios módulos fuentes, la
compilación se vuelve más elaborada. Supongamos un programa
compuesto de cuatro módulos: pantalla.c,
ficheros.c, bdatos.c y principal.c,
cada uno con su respectivo fichero cabecera .h. La forma
más simple de compilarlos es
$ cc -o programa pantalla.c ficheros.c bdatos.c principal.c
Cuantos más módulos tenga la aplicación, más larga será la orden de compilación y por tanto más incómoda. Se puede escribir un escrito de shell con el que sólo haga falta teclear compila para generar el ejecutable. Pero con ello no se evita que se compilen absolutamente todos los módulos del programa, aunque no se hayan modificado.
En nuestro ejemplo, si sólo se hubiera retocado principal.c para imprimir un mensaje de presentación, no haría falta recompilar todos los módulos. Bastaría con haber escrito:
$ cc -o programa pantalla.c ficheros.o bdatos.o principal.o
es decir, aprovechar los ficheros objetos ya generados por los módulos ficheros.c, bdatos.c y principal.c, que no han cambiado y por tanto no hay que recompilar.
El programa make está concebido para manejar proyectos de software consistentes en múltiples ficheros dependientes entre sí, compilando automáticamente sólo los módulos que se hayan modificado. La información necesaria se escribe en un fichero de nombre Makefile, aunque también obedece a ciertas reglas implícitas de construcción de programas.
Las dependencias directas del fichero ejecutable programa son: principal.o, ficheros.o, bdatos.o y pantalla.o. A su vez, cada fichero .o tiene sus propias dependencias: el fichero principal.o depende de principal.c, pantalla.h, ficheros.h y bdatos.h.
objetivo: fich1 fich2 ...
orden1
orden2
objetivo es el fichero que se desea crear. Tras los dos puntos se escriben las dependencias del mismo (fich1, fich2, ...) En las líneas inferiores se escriben las órdenes que hay que ejecutar para construir el objetivo (muchas veces basta una sola). Estas órdenes de construcción van sangradas con un tabulador. No valen los espacios.
Para construir un objetivo, se entra la orden make objetivo. Se lee el fichero Makefile del directorio de trabajo y se busca la regla para construir objetivo. Una vez localizada, el programa make aplica más o menos este algoritmo:
1. Se aplican las reglas de cada dependencia, si existen
2. Se ejecutan las órdenes de construcción en caso de que:
a) no existe el objetivo
b) la fecha de última modificación de alguna de las dependencias es posterior a la del objetivo
Con un ejemplo de Makefile:
programa: principal.c pantalla.c
cc -o programa principal.c pantalla.c
Si se ejecuta
$ make programa
se ejecutará la orden de compilación cuando sea necesario. Por ejemplo, si se había cambiado el módulo pantalla.c, se procederá a compilar. De todas formas, este ejemplo siempre requiere compilar todos los ficheros fuentes.
Este otro ejemplo de fichero Makefile es más eficiente:
programa: principal.o pantalla.o
cc -o programa principal.o pantalla.o
principal.o: principal.c pantalla.h
cc -c principal.c
pantalla.o: pantalla.c pantalla.h
cc -c pantalla.c
En este fichero se expresan tres reglas, para construir programa, principal.o y pantalla.o, respectivamente. El fichero cabecera pantalla.h se supone que lo incluyen los .c, y por ello se trata de una dependencia para los ficheros objetos principal.o y pantalla.o.
Supóngase que, habiendo compilado el programa hace un par de días, se modifica el fichero pantalla.c. Si se entra la orden
$ make programa
se dispara la regla para construir pantalla.o, puesto que es una de las dependencias de programa y a su vez una de sus dependencias ha cambiado (pantalla.c). Luego de aplicar esta regla, se ejecuta la orden de compilación para construir programa. Por tanto, se habrán ejecutado estas dos órdenes:
cc -c pantalla.c
cc -o programa principal.o pantalla.o
Como puede verse, la aplicación de una regla puede disparar la aplicación de otras en cascada.
Con este mecanismo, en un Makefile puede definirse la organización de una aplicación escrita en C (o en cualquier otro lenguaje) y generar los ficheros ejecutables con una simple llamada a make, el cual sólo compilará lo necesario en cada momento.
modulo.o: modulo.c modulo.h otro.h otro2.h
cc -c modulo.c
El programa make posee la suficiente inteligencia para saber que si modulo.o depende, entre otros, de modulo.c, ha de ejecutar la orden cc -c modulo.c. Por ello el ejemplo anterior se podría haber escrito como sigue:
programa: principal.o pantalla.o
cc -o programa principal.o pantalla.o
principal.o: principal.c pantalla.h
pantalla.o: pantalla.c pantalla.h
Este conocimiento de make viene dado por reglas implícitas que describen cómo constuir ficheros en C, FORTRAN, lex y yacc, etc. Las reglas implícitas se aplican sólo cuando se omiten las órdenes de construcción.
En muchas ocasiones un fichero prog.o sólo depende de un fichero prog.c, es decir, con el mismo nombre, salvo la extensión. En estos casos ni siquiera hace falta escribir la regla correspondiente al módulo prog.o en el Makefile.
Más aún, si un fichero ejecutable programa sólo depende de un fuente programa.c, basta con escribir
make programa
para compilarlo, sin necesidad de fichero Makefile.
Las macros se utilizan en make para abreviar textos que se utilizan en varias partes del Makefile. Una macro se declara en cualquier punto del Makefile de esta forma:
nombre = texto
La macro se puede usar con la expresión $(nombre). Se advierte que se distinguen mayúsculas de minúsculas.
make incorpora algunas macros predefinidas, como $(CC) para el compilador de C.
El ejemplo original podría escribirse finalmente de esta forma:
#
# Makefile para compilar un programa simple
#
OBJ=principal.o pantalla.o
programa: $(OBJ)
$(CC) -o programa $(OBJ)
principal.o: principal.c pantalla.h
pantalla.o: pantalla.c pantalla.h
mensaje:
echo "Escribo el mensaje"
Si se van a generar varios ejecutables, es costumbre incluir una regla como esta:
todo: programa1 programa2 programa3 ...
Así, si se ejecuta make todo, se construirán programa1, programa2 y programa3.
Ejemplo:
touch *.c
EL LENGUAJE DE PROGRAMACIóN C
B. Kernighan
McGraw-Hill, 1985
PRACTICAL C PROGRAMMING
Steve Oualline
O'Reilly & Associates, Inc. 1993
MANAGING PROJECTS WITH MAKE
Andrew Oram & Steve Talbott
O'Reilly & Associates, Inc. 1991
5.
Actividad práctica
A continuación se propone un ejercicio práctico donde el alumno
aplicará los fundamentos y herramientas de programación modular
descritas en este texto. Se ha de desarrollar un programa simple que manipule
registros de datos almacenados en disco, para lo cual se habrá de
construir una aplicación en C con cuatro módulos .c
y sus correspondientes cabeceras .h. Además, se
elaborará un fichero Makefile para el mantenimiento del
programa.
Las siguientes páginas describen con detalle el diseño de la aplicación. El alumno sólo tendrá que implementar los servicios descritos. Aunque el alumno no haya de diseñar en esta práctica, es muy conveniente que perciba cómo se ha estructurado la aplicación, para que en futuras actividades sea capaz de hacerlo por su cuenta y además elaborar la documentación de su software, de lo cual estas páginas son un ejemplo.
Se mantendrá una caché de registros en memoria principal, de manera que cuando se solicite trabajar con un registro, primero se compruebe que se encuentra en la caché, para no acceder al disco en ese caso.
Un módulo llamado cache.c gestionará el conjunto de registros de la caché. Las lecturas y escrituras en disco serán servidas por el módulo disco.c. El módulo bdatos.c empleará las funciones públicas de cache.c y disco.c para cumplir con su misión.
Salvo principal.c, todos los módulos tendrán su fichero cabecera donde se declararán sus interfaces respectivas. Con todo ello, la aplicación completa estará compuesta de estos ficheros:
disco.c, disco.h Acceso a registros en disco en bajo nivel cache.c, cache.h Gestión de la caché de registros bdatos.c, bdatos.h Funciones de manejo de registros dato.h Tipo de datos común (ver más adelante) principal.c Programa principal (main) Makefile Fichero de mantenimiento para make
La estructura de la aplicación puede representarse con este diagrama, en el que los módulos aparecen como cuadros. Las flechas indican las relaciones de clientela que existen entre los módulos: por ejemplo, que bdatos.c utiliza los servicios de disco.c.
Los registros se numeran de 1 (uno) en adelante.
Un fichero de datos tendrá la siguiente estructura:
· Cabecera al inicio del fichero que describirá las
características internas del fichero. Constará de estos dos
campos enteros:
- Número de registros en el fichero
-
Tamaño en bytes de cada registro
· Zona de registros, constituida por un número de registros de tamaño fijo, cumpliendo lo especificado en la cabecera.
struct Dato
{
int nreg; /* Número de registro */
char* registro; /* Puntero a la cadena de caracteres */
};
Este tipo de datos será usado en bdatos.c y en cache.c. Por ello ha de crearse un fichero cabecera, llamado dato.h, en el que se declare esta estructura.
En el apartado 5.4 se describen detalladamente las interfaces de todos los módulos.
int crea_fich ( const char* nombre, int lreg, int nreg );
int activa_fich ( const char* nombre, struct Cabecera* cabec );
int accede_fich ( int fich, int nreg, char* registro, int clase_acceso );
int desactiva_fich ( int fich );
crea_fich construirá un fichero de datos con el número y tipo de registros que se le solicite.
Los ficheros de datos han de abrirse con activa_fich antes de usarse. Devuelve un descriptor de fichero imprescindible para operar con él. Cuando se deja de trabajar con un fichero, se llama a desactiva_fich.
Se lee o escribe información con accede_fich, a la que se le pasa como parámetro un descriptor de fichero previamente abierto.
activa_fich leerá la cabecera del fichero, con el fin de conocer cuántos registros hay y de qué tamaño son.
Para trabajar con el disco, se utilizarán las funciones de bajo nivel del UNIX: creat, open, close, read, write y lseek. En los manuales del laboratorio hay información sobre estos servicios.
int inicia_cache ( int maxreg );
int ingresa_cache ( const struct Dato* registro );
int busca_cache ( int nreg, struct Dato* registro );
int cache_llena (void);
int extrae_cache ( struct Dato* registro );
La caché tendrá un tamaño predeterminado, que se fijará con una llamada a inicia_cache. Inicialmente, la caché está vacía. Puede ingresarse un registro nuevo con ingresa_cache, con lo que la caché irá llenándose. Para buscar un registro se utiliza busca_cache, que busca el registro nreg en la caché y lo deposita en registro si lo encuentra.
La caché puede llenarse de registros. En ese caso, la función ingresa_cache dará un error. Para detectar si la caché aún tiene espacios se utiliza la función cache_llena. Si la caché está llena, pero de todas formas se quiere ingresar un registro, hay que eliminar alguno de los registros que ya están presentes, de lo que se encarga extrae_cache. Esta función devuelve algún registro de la caché y lo elimina de ella.
Hay que resolver qué registro de los presentes en la caché se selecciona como víctima para su desalojo de la caché cuando se invoca extrae_cache. Eso queda a criterio del alumno; basta con adoptar una política FIFO.
La estructura interna de la caché también queda a discreción del alumno, siempre que cumpla con el funcionamiento deseado. Algunas posibilidades son: vector dinámico, lista encadenada o tabla hash.
int crea_bdatos ( const char* fichero, int lreg, int nreg );
int abre_bdatos ( const char* fichero );
int lee_registro ( int nreg, struct Dato* registro );
int escribe_registro ( int nreg, struct Dato* registro );
int cierra_bdatos (void);
El fichero de datos se manipulará abriéndolo primero con abre_bdatos; luego se podrán leer y escribir registros con lee_registro y escribe_registro. Finalmente, habrá que cerrar el fichero con cierra_bdatos.
La función crea_bdatos simplemente invoca a crea_fich y tiene su mismo significado.
Sólo puede haber un fichero de datos abierto en cada momento. Obsérvese que se guarda de forma transparente al usuario el descriptor de fichero que se abre.
El acceso a un registro, tanto en lee_registro como en escribe_registro, se hará como sigue: primero se busca el registro en la caché. Si se encuentra, se trabaja con el dato en memoria. De lo contrario, se accede al disco con accede_fich y se trae el registro, el cual se ingresa en la caché.
La caché puede llenarse y ello implica la invocación a extrae_cache. Como el elemento extraído puede haber sido modificado, habrá que guardarlo en el disco.
La llamada a cierra_bdatos no sólo cierra el fichero actual con desactiva_fich, sino que también salva en disco todos los elementos de la caché mediante repetidas llamadas a extrae_cache. De lo contrario se podría perder la información escrita en la caché.
· Crear un fichero nuevo y abrirlo
· Abrir un fichero existente
· Leer un registro determinado del fichero actual
· Escribir un registro determinado del fichero actual
· Cerrar el fichero actual
Todas estas funciones se realizarán a través del módulo bdatos.c. No se admite que el módulo principal utilice directamente servicios de cache.c, de disco.c, ni que se trabaje directamente con las funciones de UNIX para ficheros.
Cuando se lea un registro, se imprimirán por pantalla los caracteres leídos. En el caso de la escritura, se pedirá al usuario que entre un texto, que será el que se escriba en el registro deseado.
El programa deberá informar adecuadamente de cualquier error que se produzca. Por ejemplo, intentar abrir un fichero no existente, leer un registro no existente, carecer de memoria suficiente, etc.
Pueden añadirse más operaciones, con el fin de evaluar la tasa de aciertos en la caché.
Nombre
struct Cabecera - estructura para la cabecera del fichero
Sintaxis
struct Cabecera
{
int nreg; /* Número de registros */
int lreg; /* Longitud en bytes de cada registro */
};
Nombre
crea_fich - crea y pone a punto un fichero de datos
Sintaxis
int crea_fich ( const char* nombre, int lreg, int nreg );
Descripción
Crea un fichero de datos con la estructura descrita en la página 22. nombre es un puntero a la cadena que contiene la ruta del fichero. lreg es la longitud en bytes de los registros del fichero. nreg es el número de registros del fichero.
La función devuelve un 0 si se realiza con éxito y -1 si se produce un error.
Recomendaciones
Se sugiere que se dé a cada registro un valor característico con objeto de que posteriormente se pueda comprobar la implementación correcta de las funciones de acceso.
Esta función habrá de invocar a la función creat del UNIX.
Nombre
activa_fich - Activa (abre) un fichero
Sintaxis
int activa_fich ( const char* nombre, struct Cabecera *cabec );
Descripción
Abre un fichero y a continuación lee la cabecera de éste para poder acceder a sus registros posteriormente, por tanto se debe usar antes de acceder a un fichero.
nomb puntero a la cadena que contiene la ruta del fichero para abrir
cabec puntero a una estructura de tipo Cabecera en donde la función depositará las características del fichero abierto (tamaño de los registros y número de ellos).
Si la función se ejecuta con éxito entonces retorna el descriptor del fichero abierto (valor entero positivo). Si se produce un error devuelve un -1.
Recomendaciones
Esta función tendrá que invocar a la función open del UNIX.
Nombre
accede_fich - lee o escribe registros en un fichero
Sintaxis
int accede_fich ( int fd, int nreg, char *bufer, int clase )
Descripción
Accede a un registro de un fichero abierto previamente por activa_fich.
fd descriptor válido de fichero devuelto por activa_fich.
reg número de registro a acceder.
bufer puntero al buffer desde donde o a donde se realizará la transferencia.
clase indicador del tipo de acceso (LECTURA o ESCRITURA).
Si la función devuelve un entero positivo, se interpretará como el número de bytes del registro transferidos. Si el retorno es -1, entonces es que se ha producido un error y no se ha transferico ningún byte.
Nombre
desactiva_fich - Cierra un fichero
Sintaxis
int desactiva_fich ( int fd );
Descripción
fd es el descriptor válido del fichero para cerrar
Retorna 0 si se ha ejecutado con éxito y -1 si un error ha ocurrido.
Nombre
inicia_cache - pone a punto la caché
Sintaxis
int inicia_cache ( int maxreg );
Descripción
Habilita las estructuras necesarias para albergar maxreg registros. maxreg debería ser mayor que cero.
Devuelve un 0 si todo ha ido bien y un -1 si hay error. Puede darse error por memoria insuficiente o valor incorrecto de maxreg.
Nombre
ingresa_cache - ingresa un registro en la caché
Sintaxis
int ingresa_cache ( const struct Dato* registro );
Descripción
Ingresa un registro en la caché. registro es un puntero al dato que se va a ingresar.
Devuelve un 0 si todo ha ido bien y un -1 si registro es un puntero nulo o si la caché está llena.
Nombre
busca_cache - busca un registro dentro de la caché
Sintaxis
int busca_cache ( int nreg, struct Dato* registro );
Descripción
Busca el registro número nreg dentro de la caché. registro es un puntero a la zona de memoria donde se copiará el elemento.
Devuelve un cero si han ido bien las cosas; -1 en otro caso.
Nombre
cache_llena - devuelve el estado de la caché
Sintaxis
int cache_llena (void);
Descripción
Devuelve un 0 si la caché tiene huecos disponibles, y un valor no nulo si está llena por completo.
Nombre
extrae_cache - elimina un elemento de la caché
Sintaxis
int extrae_cache ( struct Dato* registro );
Descripción
Elimina un elemento de la caché y lo deposita en registro. Devuelve un 0 si la ejecución ha sido satisfactoria y un -1 si ha habido problemas.
Nombre
crea_bdatos - crea un fichero de datos
Sintaxis
int crea_bdatos ( const char* nombre, int lreg, int nreg );
Descripción
Crea un fichero de datos con la estructura descrita en la página 22. Su funcionamiento es idéntico a crea_fich, descrita en el módulo disco.c
Recomendaciones
Ha de implementarse como una simple llamada a crea_fich
Nombre
abre_bdatos - abre un fichero de datos con caché
Sintaxis
int abre_bdatos ( const char* nombre );
Descripción
Abre un fichero de datos cuya ruta viene dada en nombre. La caché se vacía y pone a punto con espacio para un 20% del número de elementos del fichero.
Devuelve 0 si ha tenido éxito y -1 en caso de error. Algunas condiciones de error son:
· Ya existía un fichero de datos abierto
· El fichero de datos no existe
· Error con la caché
Nombre
lee_registro - lee un registro de un fichero de datos
Sintaxis
int lee_registro ( int nreg, struct Dato* registro );
Descripción
Lee el registro número nreg del fichero de datos previamente abierto. Lo deposita en la zona de memoria apuntada por registro.
Si el registro se encuentra en la caché, se accede a ésta; si no, se lee el dato directamente del fichero y se guarda en la caché. Si hubo que desalojar algún elemento de la caché, éste se escribe en el fichero.
La función devuelve un cero si ha ido bien y un -1 en caso contrario. Algunas condiciones de error son: fichero de datos no abierto, error en la caché, número de registro inválido, etc.
Nombre
escribe_registro - escribe un registro en un fichero de datos
Sintaxis
int escribe_registro ( int nreg, struct Dato* registro );
Descripción
Escribe el registro número nreg del fichero de datos previamente abierto. Los datos para escribir se encuentran en la zona de memoria apuntada por registro.
El registro siempre se escribe en la caché. Si hay que desalojar algún elemento de la caché, éste se escribe en el fichero.
La función devuelve un cero si ha ido bien y un -1 en caso contrario. Algunas condiciones de error son: fichero de datos no abierto, error en la caché, número de registro inválido, etc.
Nombre
cierra_bdatos - cierra el fichero de datos actual
Sintaxis
int cierra_bdatos (void);
Descripción
Cierra el fichero de datos actualmente abierto. Los datos presentes en la caché son escritos en el fichero antes de cerrarlo.
Devuelve 0 si todo ha ido bien; -1 en caso contrario.
Recomendaciones
Los datos de la caché se irán leyendo con extrae_cache.
Errores producidos en las funciones del sistema. Son los que se dan en todas aquellas funciones del sistema que han sido invocadas durante la ejecución de la aplicación. UNIX dispone para el tratamiento de estos errores de un fichero cabecera llamado errno.h, en el que están definidos todos los errores que pueden ocurrir en cualquier función del sistema.
La variable externa errno, que debe ser declarada en el programa, contiene el código del error que se acaba de producir.
La función perror() imprime por pantalla un mensaje de error acorde con el contenido de errno, habitualmente en inglés.
Errores específicos de la aplicación. Son los que se producen cuando se viola alguna restricción que ha sido impuesta implícita o explícitamente en las especificaciones del programa a desarrollar. En la aplicación propuesta, un ejemplo de este tipo de errores ocurriría cuando se pretende acceder a un registro fuera del rango del fichero actual.
El tratamiento de este tipo de errores debe contemplarse en cualquier aplicación y se debe hacer de forma que cuando se produzcan se puedan detectar inmediatamente y además conocer sus orígenes. Una manera de llevar a cabo este tratamiento es desarrollar una función genérica de tratamiento de errores y el uso de un fichero cabecera en donde se definan los códigos de errores.
Queda como opción el desarrollar un módulo error.h y error.c donde se tramiten los errores.
Cada miembro del grupo de prácticas desarrollará dos de los cuatro módulos, con la restricción de que una misma persona no podrá desarrollar disco.c y cache.c a la vez. El principal motivo es que son los módulos que ocuparán más tiempo o dificultad.
El trabajo se hará simultáneamente. Como durante la elaboración de los módulos no se podrá contar con el par de módulos del compañero, habrá que verificar el funcionamiento del código escrito usando prototipos "tontos" de los otros módulos.
Una vez que se disponga de los cuatro módulos, se integrarán en la aplicación definitiva. Se elaborará un informe explicativo del trabajo realizado y se comunicará al profesor encargado de tutorizar la práctica para su evaluación.
- ... -