Universidad de Las Palmas de Gran Canaria

Click here for Picture

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.

1.1. Interfaz e implementación. Parte pública y parte privada

Cada módulo tiene una interfaz, esto es, una definición de lo que realiza y cuál es la forma de invocar a sus servicios. Por ejemplo, la interfaz de un módulo puede venir en forma de un conjunto de subrutinas, cada una especificando cuántos parámetros necesita y de qué tipo son.

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.

1.2. Características ventajosas de la modularidad

Legibilidad. El programa modular es más legible, porque es más sencillo ubicar dónde se realiza tal o cual función y porque las componentes tienen un nombre, como en el caso de los procedimientos y funciones.

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.

1.3. Resumen

En resumen, las ventajas de la programación modular van en la línea de mejorar estos elementos del software:

· Legibilidad

· Reusabilidad

· Fiabilidad

· Tiempo de desarrollo

1.4. Programación modular y ciclo de vida del software

Clásicamente, el desarrollo del software atraviesa cuatro etapas: análisis, diseño, implementación y mantenimiento. En la etapa de análisis se establecen los requerimientos de la aplicación, cómo se va a desarrollar y las líneas maestras del programa: organización de los datos, flujos de datos, etc. La fase de diseño plasma los objetivos descritos en el análisis en forma de módulos funcionales, definiendo las interfaces y las estructuras de datos concretas que se utilizarán. Es en la fase de implementación cuando se codifica el programa en un lenguaje o un sistema concreto.

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.

2.1. Uso de extern

Si una variable o función se declara extern se puede definir más adelante en el mismo módulo (extern no obliga a que el símbolo se encuentre en otro módulo).

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);

2.2. Compilación separada

El programa se compila en UNIX de esta forma:

$ 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.

2.3. Variables y funciones static

Cada módulo fuente tendrá sus propias variables. Pueden surgir problemas si variables en módulos diferentes tienen el mismo nombre. Algunos compiladores lo detectarán y darán un error, pero la mayoría de los compiladores considerará que se trata de la misma variable. La ejecución de ese programa estará condenada al fracaso.

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.

2.4. Ficheros cabeceras

En los ejemplos anteriores, el módulo principal.c ha declarado cuáles son las variables y funciones exportadas por otros módulos. En programas extensos se convierte en un engorro estar escribiendo quizás cientos de nombres de variables y funciones públicas.

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.

2.5. Advertencias

Es importante recalcar que los ficheros cabecera no pertenecen al lenguaje C y son más una costumbre que una regla sintáctica. En un fichero cabecera se pueden escribir cuerpos de funciones, definiciones de variables... cualquier cosa. Es responsabilidad del programador escribir los ficheros cabeceras según las normas aquí descritas.

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.

2.6. Protección contra inclusiones múltiples

Supóngase que un módulo utiliza otros dos módulos llamados ERROR (para imprimir mensajes de error) y FICHEROS (para leer información del disco). Entonces incluirá sus interfaces con

#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.

2.7. Ejemplo

Ilustremos la programación modular en C con un caso más elaborado. Se va a definir un módulo con unas cuantas funciones para gestionar una cola de datos.

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.

3.1. Dependencias

Un fichero depende de otros ficheros para su elaboración. Las dependencias de un fichero son todos los ficheros que de alguna forma participan directa o indirectamente en su elaboración.

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.

3.2. Reglas explícitas

El programa make lee del fichero Makefile un conjunto de reglas de construcción de ficheros. Aunque hay varias formas de expresarlas, una sintaxis muy común es:

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.

3.3. Reglas implícitas

Si continuamente se va a estar trabajando en C, aparecerá una multitud de reglas con esta apariencia:

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.

3.4. Macros y comentarios

En el fichero Makefile pueden insertarse comentarios en forma de líneas que comiencen por el carácter #.

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

3.5. Reglas sin dependencias

Si una regla no tiene dependencias, se aplica siempre. Ejemplo:

mensaje:

echo "Escribo el mensaje"

3.6. Múltiples aplicaciones

Nada impide escribir reglas pertenecientes a varios proyectos de software en el mismo fichero Makefile. Mientras no coincidan los nombres de los fuentes, no hay ningún problema.

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.

3.7. Touch

El programa touch cambia la fecha y hora de última modificación de un fichero al momento actual. Si se ejecuta touch sobre un fichero fuente y luego se invoca a make, se recompilará el ejecutable aunque el fuente no haya cambiado realmente. touch es útil para forzar la recompilación de un programa.

Ejemplo:

touch *.c

3.8. Final

El programa make es una herramienta de gestión de proyectos muy poderosa, con una funcionalidad más rica de la que se ha descrito en estas líneas. Un buen programador de sistemas en UNIX ha de dominar los fundamentos de make.

4. Bibliografía

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.

5.1. Módulos del programa

En la aplicación se trabajará con ficheros consistentes cada uno en una serie registros de tamaño fijo. Las lecturas y escrituras de registros se realizarán por medio de funciones de un módulo llamado bdatos.c. Otro módulo de nombre principal.c utilizará estas funciones, ofreciendo al usuario un menú para que pueda realizar las distintas operaciones posibles.

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.

5.2. Estructuras de datos

Los ficheros de datos contendrán un número variable de registros. Cada registro es una cadena de caracteres. Todos los registros de un mismo fichero tendrán la misma longitud, pero los registros de ficheros diferentes podrán tener longitudes distintas.

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.

Tipo para los registros

Para manejar en memoria principal los registros, se dispondrá de un tipo de datos estructurado, llamado struct Dato, según se define a continuación:

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.

5.3. Descripción del funcionamiento de la aplicación

En este apartado se dará una explicación general del funcionamiento de cada uno de los módulos y cómo se relacionan entre sí. También se darán algunas recomendaciones o indicaciones acerca de la implementación.

En el apartado 5.4 se describen detalladamente las interfaces de todos los módulos.

Módulo disco

En este módulo se implementarán las funciones básicas para l

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.

Módulo cache

La caché de registros se manipulará como una cola en la que se insertan registros de tipo struct Dato, para luego buscarlos por número de registro. Las funciones que gestionan la caché serán estas:

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.

Módulo bdatos

Este módulo será el que integrará los servicios de disco.c y cache.c para ofrecer una interfaz cómoda y potente de trabajo con los ficheros de datos.

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é.

Módulo principal

El módulo principal contendrá la función main, en la que el usuario podrá seleccionar distintas operaciones con ficheros de datos:

· 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é.

5.4. Interfaces de los módulos

En esta sección se describen los prototipos de las funciones públicas de los distintos módulos de la aplicación, junto con su funcionamiento. Estas funciones han de figurar en los ficheros cabeceras de los módulos.

Módulo disco.c

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.

Módulo cache.c

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.

Módulo bdatos.c

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.

5.5. Tratamiento de errores

Los errores generados en una aplicación UNIX suelen dividirse en dos categorías:

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.

5.6. Planificación del trabajo

Esta aplicación es sencilla y se puede construir en menos de diez horas de trabajo, una vez leído este documento. Aunque puede realizarse de forma individual, resulta más productivo hacerlo en grupo con otra persona, para experimentar la separación del trabajo.

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.

- ... -