Prefacio

Las presentes notas fueron confeccionadas en su mayor parte el año 2003, pero nunca se culminaron. Se presentan aquí puesto que el contenido puede ser útil a los interesados.

El texto ha sido convertido a asciidoc en Julio de 2023, pero requiere una revisión detallada (la conversión se ha hecho en forma semi-automática a partir de un formato propietario denominado QDK.)

Asimismo, está pendiente una revisión de diversas afirmaciones que a la fecha no son válidas como suele ocurrir con el paso de los años. Espero hacer esto gradualmente en la medida que el tiempo lo permita.

Del mismo modo, ciertas secciones están desactualizadas. Por ejemplo, las versiones 1.x de SDL actualmente son obsoletas, ergo los ejemplos correspondientes serán difíciles de compilar.

Finalmente, es necesario actualizar las referencias. El ejemplo más relevante en mi opinión es la aparición en 2010 de "The Linux Programming Interface" [1]; en esa misma línea, en 2013 se lanzó la tercera edición del clásico APUE [2].

Sobre estas notas…​

Este texto surgió por la necesidad de ordenar algunas ideas y tener un nuevo pretexto para revisar mucha documentación interesante. Mi pretención es proporcionar una visión general de algunas de las muchas facilidades que tiene a su disposición un programador de C en ambientes Linux y Unix, aunque también puede ser de valor para quien proviene de entornos Windows.

Téngase en cuenta que no pretendo propocionar información de tipo "referencial" sobre ningún tema en particular. Dada la diversidad de éstos, se requeriría muchos libros adicionales. En ese sentido, el lector que desea programar "en serio" deberá obligatoriamente consultar las guías, manuales y referencias correspondientes.

Los listados del texto algunas veces son programas completos; sin embargo, siempre se intenta reutilizar sus subrutinas constituyentes, las cuales ya no se repiten en los listados posteriores. De otro lado, todos los programas de ejemplo que presento han sido probados usando los sistemas Linux RedHat 8.0 [3] y Linux Debian 3.1.

Lima, Febrero de 2006

INTRODUCCION AL AMBIENTE DE PROGRAMACION

1. Revisión breve del lenguaje C

1.1. Introducción

Uno de los lenguajes más importantes e imprescindibles [4] en Linux/Unix es el C. C permite lograr la máxima interacción con el sistema operativo y con el hardware (después del assembler) por lo que se emplea típicamente para la elaboración de controladores de dispositivos (device drivers) y rutinas críticas en programas de propósito general. Con esto no queremos dar a entender que C es el mejor lenguaje para toda situación [5] .

Este texto no pretende enseñar el lenguaje C, y por el contrario, asumiremos que el lector ya lo utiliza con cierta fluidez. Nuestro objetivo central será describir algunas de las muchas maneras en que se puede utilizar en el sistema operativo Linux. Es importante resaltar que casi todo este material puede aplicarse con muy pocas modificaciones a las diferentes variantes de Unix.

Si bien no explicaremos el lenguaje C, es conveniente hacer una recapitulación rápida a fin de que el lector tenga una idea de la base que requiere para proseguir. Asumo que Ud. sabe (o tiene la idea de) cómo compilar programas en C, aunque quizá no en Linux. En el capítulo 2 se trata con detalle el tema de la compilación en Linux/Unix.

1.2. Conceptos Básicos

C es un lenguaje con una larga tradición. Como cualquier lenguaje, ha sufrido cierta evolución a través de los años, y sigue evolucionando aunque a un ritmo más "ordenado".

Los primeros "dialectos" usados en los años 70 y principios de los 80 se conocen como "K&R C" (por Brian Kernigham y Dennis Ritchie, sus creadores) y -aunque suene a falta de respeto- es preferible evitarlos en la medida de lo posible. Su uso puede ser inevitable en un sistemas muy antiguos que no lograron actualizarse a los nuevos tiempos.

A mediados de los 80 se aprecian intentos serios por lograr la estandarización del lenguaje cuyo resultado fue el dialecto "oficial" conocido como "ANSI C". ANSI es la institución de estándares de USA, y su primer estándar oficial para C fue el "X3.159-1989", que data de 1989. En general, los estándares (norteamericanos) de ANSI se han subsumido en la actualidad en los estándares internacionales ISO. Así el mencionado "ANSI C" fue ratificado como el estándar "ISO/IEC 9899:1990". El lenguaje C descrito en estos estándares se conoce informalmente como C89 o C90.

En 1995 se publicó un nuevo estándar (ISO/IEC 9899:1994) que involucraba algunos cambios poco significativos en el lenguaje, centrándose por el contrario en la librería estándar. A esto se le denomina C94 o C95. Finalmente, en 1999 se aparece el estándar ISO/IEC 9899:1999 (conocido como C99) que presenta un número considerable de novedades. Sin embargo, son pocos los compiladores que en la actualidad soportan completamente este estándar [6] .

El lenguaje vs la librería estándar

El lenguaje C es bastante reducido. Consiste de algunas palabras clave (keywords), operadores e identificadores que permiten efectuar ciertas tareas de modo muy eficiente. Sin embargo, muchos de los constituyentes de los programas comunes requieren efectuar tareas para las que el lenguaje no está preparado. Por ejemplo, "trazar una línea de color verde en una ventana" es algo para lo cual no existe ninguna palabra clave.

Esto se ha diseñado así a propósito. La idea es que todas aquellas tareas que van más allá del procesamiento de información "simple" (caracteres, números, conjuntos), deben implementarse mediante llamadas "externas" al lenguaje. En otras palabras, alguien escribirá "rutinas" adicionales que permiten al programador de C poder efectuar todas las tareas que requiere. Estas rutinas adicionales se suelen denominar "librerías" [7] . El programador utiliza las librerías sin requerir conocer cómo trabajan internamente, necesitando tan solo aplicar la documentación referente a cómo se invocan (lo que se conoce como "interfaz de programación" o API.) [8]

Existen muchas librerías para diversos propósitos; por ejemplo, librerías matemáticas para efectuar cálculo matricial, librerías multimedia para procesar archivos de audio y video, librerías para procesar documentos XML, etc.

En particular, es imprescindible conocer el papel de la librería estándar de C, la cual contiene una gran cantidad de rutinas que permiten llevar a cabo tareas que son requeridas en casi todos los programas. Por ejemplo, el lenguaje C no tiene ninguna palabra clave para mostrar o solicitar información al usuario vía la pantalla y el teclado. Sin embargo, la "librería estándar de C" contiene las rutinas necesarias para llevar a cabo estas acciones (por ejemplo, las famosas printf() y fgets(), respectivamente.)

En virtualmente todas las implementaciones de lenguaje C para computadores personales y superiores está garantizada la presencia de la "librería estándar" [9].

Existen casos especiales donde no es conveniente soportar la totalidad de la librería estándar (por ejemplo, dispositivos personales portátiles.)] , lo que conlleva a que todos los cursos y libros que enseñan lenguaje C dediquen gran parte del contenido a describir las rutinas de dicha librería, por lo que la gente muchas veces la considere (incorrectamente) como parte del lenguaje.

Como es de esperarse, esto no ocurre con otras librerías. Esto quiere decir que si el programador está interesado, por ejemplo, en procesar archivos en formato JPEG, deberá buscar (y quizá, comprar) una librería gráfica que le ayude (o tal vez, con mucha más dificultad, escribir su propia librería.) Una vez instalada la librería, deberá ser capaz de indicar al compilador que se desea "enlazar" el programa con dicha librería.

Un programa de prueba

El programa que mostramos a continuación permite indagar acerca de la paridad de un número entero introducido desde la línea de comando o desde el teclado. Sólo en este listado (por ser el primero) numeraremos las líneas:

     1	#include <stdio.h>
     2	#include <stdlib.h>

     3	void par(int);

     4	int main(int argc,char **argv)
     5	{
     6	int n;

     7	if(argc==2)
     8		par(atoi(argv[1]));
     9	else
    10		{
    11		scanf("%d",&n);
    12		par(n);
    13		}
    14	return 0;
    15	}

    16	void par(int x)
    17	{
    18	if(x%2)
    19		printf("El numero es impar\n");
    20	else
    21		printf("El numero es par\n");
    22	}

A continuación los pasos a seguir para la compilación y ejecución en ambiente Linux/Unix. Esto puede variar dependiendo del compilador del que se dispone.

$ cc -o paridad paridad.c
$ ./paridad
3
El numero es impar
$ ./paridad
2
El numero es par
$ ./paridad 7
El numero es impar
$ ./paridad 8
El numero es par

Analicemos las líneas relevantes del programa:

Líneas 1 y 2

Estas directivas #include insertan código C proveniente de otro archivo. Típicamente (como en nuestro caso) se emplean para declarar ciertas funciones de librerías que se van a emplear. Para esto, el estándar de C requiere que se proporcione ciertos archivos auxiliares "de encabezado" (headers) como los que hemos empleado [10] . En particular, stdio.h proporciona las declaraciones de las funciones scanf() [11] y printf(), mientras que stdlib.h proporciona la de atoi(). La siguiente tabla presenta algunos archivos de encabezado:

Table 1. Algunos archivos de encabezado estándar
Archivo Finalidad Algunas rutinas

stdio.h

Entrada/Salida

printf, scanf, getchar, putchar, fgets, fwrite, etc

string.h

Manejo de strings

strcpy, strcat, strcmp, etc

stdlib.h

Conversiones, asignación dinámica de memoria, números aleatorios, entorno, etc

atoi, atof, calloc, free, rand, srand, putenv, exit, system, etc

math.h

Operaciones matemáticas

log, exp, sin, cos, etc

time.h

Control del tiempo y manipulación de fechas

time, ctime, localtime, etc

Línea 3

Ésta corresponde a una declaración de función (prototipo.) Toda función que va a ser usada en un programa, debería [12] siempre declararse tal como lo hemos hecho [13] . De no hacerlo, el compilador genera una declaración por omisión que puede no ser correcta y tendremos errores difíciles de rastrear.

Línea 4

Todo programa se inicia en la función main la cual retorna un entero al sistema operativo (valor de retorno) y puede aceptar un conjunto de argumentos provenientes del mismo. Es también válido descartar los argumentos con una definición tal como:

int main(void)
...
Línea 6

Declaramos y definimos un entero "n". Los enteros (como el resto de tipos básicos de C) no tienen un rango pre-establecido y dependen de la implementación. En computadores con microprocesadores de 16 bits, los enteros int suelen estar en el rango [-2 ^ 15 ; 2 ^ 15 - 1] aunque son posibles otros valores. En computadores con microprocesadores de 32 bits, por el contrario, el rango típico es [-2 ^ 31 ; 2 ^ 31 - 1]. Esto también puede variar dependiendo del compilador empleado.

En aplicaciones que requieren un rango determinado para sus operaciones, es frecuente hacer uso de typedef para crear un nuevo tipo:

typedef int ENTERO;

Nuestro programa podría emplear ahora el tipo ENTERO para todos sus cálculos. Pero si portamos el programa a un computador cuyos enteros int son demasiado pequeños, entonces podríamos fácilmente cambiar la sentencia anterior por algo como:

typedef long ENTERO;

Así nuestro tipo ENTERO tiene ahora un rango mayor.

Esta solución de todas maneras es inconveniente, desde que nos hemos visto obligados a modificar el código y recompilarlo. El estándar C99 proporciona un nuevo conjunto de enteros con rangos definidos; por ejemplo, enteros de 8, 16, 32 y 64 bits representados en los tipos int8_t, int16_t, int32_t e int64_t respectivamente.

Línea 7

main recibe el número de argumentos y un array con los argumentos en las variables argc y argv. El primer argumento (argv[0]) es el propio nombre del programa que se ha ejecutado (./paridad) y los otros (argv[1], argv[2]…​) corresponden al resto de la línea de comandos. Nuestro programa usará argv[1] sólo si éste se proporciona, para lo cual debe consultar el primer argumento (argc.) Nótese que los nombres argc y argv son sólo convencionales.

Línea 8

La invocación a la función par() con el argumento argv[1] convertido a entero, cuando el test de la línea 7 haya sido satisfactorio.

Línea 9

Literalmente, "…​ en caso contrario…​" Esta sección es opcional en la senencia if().

Línea 11

Leer un entero desde la entrada estándar, asignándolo a la variable entera “n”.

Línea 14

Este programa siempre retorna cero al sistema operativo. Convencionalmente, el cero significa "éxito" y cualquier otro valor significa "error". El shell Linux/Unix puede usar una construcción como esta, gracias al valor de retorno:

$ ./paridad 7 && echo Programa exitoso || echo Programa fallido
Línea 16

La definición de la función par() que a diferencia de su prototipo ahora sí tiene el nombre del argumento (x.) En los prototipos esto no es necesario.

Línea 18

El operador '%' significa "residuo" y permite verificar la paridad rápidamente [14] .

Línea 19

printf() permite imprimir "con formato". En nuestro caso, el formato es un texto sencillo y realmente no se aprovecha. Podríamos haber empleado exitosamente la función puts().

Si Ud. no comprendió este programa…​ búsque un buen libro de C y retorne aquí cuando lo haya completado.

1.3. Punteros

Gran parte del poder del lenguaje C se deriva de la posibilidad de hacer uso de los "punteros"…​ y viceversa, gran parte de los problemas del lenguaje C se derivan de la posibilidad de hacer uso de los "punteros".

El siguiente programa solicita al usuario un número entero “n”. Luego construye una matriz de n x n enteros y los rellena con valores aleatorios de -10 a +10. Luego imprime la matriz e invoca a la función "suma()" que imprime la suma de todos los elementos.

/*
 * matrix.c: crea una matriz dinamicamente
 * con valores aleatorios y suma los elementos
 */
#include <stdio.h>
#include <stdlib.h>

void suma(int **m,int q);

int main(void)
{
int n,z,j;
int **matriz;

printf("n? ");
scanf("%d",&n);

matriz=calloc(n,sizeof(int*));
for(z=0;z<n;z++)
	{
	matriz[z]=calloc(n,sizeof(int));
	for(j=0;j<n;j++) matriz[z][j]=rand()%21-10;
	}

for(z=0;z<n;z++)
	{
	for(j=0;j<n;j++) printf("%3d",matriz[z][j]);
	printf("\n");
	}
suma(matriz,n);
return 0;
}

void suma(int **m,int n)
{
int z,j,s=0;

for(z=0;z<n;z++)
	for(j=0;j<n;j++) s+=m[z][j];
printf("Suma=%d\n",s);
}

A continuación un ejemplo de ejecución:

$ ./matrix
n? 10
 -9 -6 -1  9 -2  0  0 -1  5  0
 -8  9 10 -6 10 -3 -7  5  6  6
  7  4  2 -1 -8 -5 -5  3 -9  9
 -5-10 -7  2  7 -1 -9 -3  6  6
  5  8  2  4 10  0 10 -8 -8  5
  7  9 -3 -2 -2 -1  1  1 -9  0
 -1 -4  1  2  7 -5  9  8  0  4
  2 -7 -1 -7  7 -1  1  4 -1  4
 -3 -7  2  4  1  8-10 -8 -2 -9
  2  8 -5-10 -3 -9 -6 -4  8  4
Suma=12

Obsérvese que hemos hecho uso extensivo de la sintaxis de acceso a elementos de arrays (x[][]), pero en ninguna parte del programa se han definido variables de este tipo. Esto es sólo una conveniencia pues como se sabe:

X[n]      es igual que     *(X+n)
X[n][m]   es igual que     *(*(X+n)+m)

Supongo que habrá observado (o que ya sabía) que los valores de la matriz son siempre los mismos (ejecución tras ejecución) para un mismo “n”. Esto es así debido a que los números generados son pseudoaleatorios y por tanto, no verdaderos aleatorios. A fin de lograr una mejor "sensación" de aleatoriedad, se suele hacer uso de:

#include <time.h>
...
srand(time(NULL));

srand() inicializa la semilla de números aleatorios. Obviamente, se debería invocar antes de generar la matriz [15] .

La rutina calloc() sirve para reservar un bloque de memoria (inicializándolo a cero.) La hemos usado para generar un conjunto de n "punteros a int", y luego cada uno de éstos se ha hecho apuntar a nuevas áreas (también asignadas con calloc()) usadas para almacenar "conjuntos de ‘n’ valores int".

No hemos verificado -por abreviar- que calloc() NO haya retornado NULL, lo que hubiera significado (típicamente) que el sistema ya no tiene la memoria suficiente o que el usuario está limitado en su consumo [16] .

Punteros y Estructuras

Iniciaremos la elaboración de un programa que almacene los títulos de los libros de una biblioteca en la memoria del computador y que finalmente los grabará en disco. La siguiente estructura nos puede será útil:

struct LIBRO
	{
	char titulo[40];
	char autor[30];
	int edicion;
	int a_publicacion;
	};

Y la biblioteca entera puede ser un array de estas estructuras:

struct LIBRO bib[800];
int n_bib=0;

Por comodidad se suele definir el tipo “LIBRO” como la estructura en sí:

typedef struct
	{
	char titulo[40];
	char autor[30];
	int edicion;
	int a_publicacion;
	} LIBRO;

La biblioteca será ahora:

LIBRO bib[800];
int n_bib=0;

Si nuestra biblioteca sólo tiene 10 libros, entonces se está desperdiciando 790 estructuras en la valiosa memoria RAM. A fin de no desperdiciar tanto, podríamos sólo definir un puntero hacia un área de memoria que se incrementará conforme se requiera:

LIBRO *bib;
int n_bib=0;

A continuación un programa para llenar la biblioteca en memoria y guardarla en un archivo del disco:

/*
 * bib1.c: Lee un conjunto de libros y los graba
 * en disco
 */
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

typedef struct
        {
	char titulo[40];
	char autor[30];
	int edicion;
	int a_publicacion;
	} LIBRO;

LIBRO *bib=NULL;
int n_bib=0;
char tmp_line[80];

void getline(char *t)
{
printf("%s",t);
if(fgets(tmp_line,80,stdin)==NULL)
	{
	tmp_line[0]=0;
	return;
	}
tmp_line[strlen(tmp_line)-1]=0;
}

int main()
{
FILE *fp;

for(;;)
	{
	getline("Titulo? ([Enter] para terminar) ? ");
	if(tmp_line[0]==0) break;
	bib=realloc(bib,(n_bib+1)*sizeof(LIBRO));
	strcpy(bib[n_bib].titulo,tmp_line);

	getline("Autor? ");
	strcpy(bib[n_bib].autor,tmp_line);

	getline("Edicion? ");
	bib[n_bib].edicion=atoi(tmp_line);

	getline("Anho publicacion? ");
	bib[n_bib].a_publicacion=atoi(tmp_line);

	n_bib++;
	}
fp=fopen("libros.dat","w");
fwrite(bib,sizeof(LIBRO),n_bib,fp);
fclose(fp);
return 0;
}

Ahora lo vemos en ejecución. Hemos hecho además un volcado del contenido del archivo de disco [17] , el cual muestra una gran cantidad de espacio desperdiciado por los arrays componentes de la estructura.

$ ./bib
Titulo? ([Enter] para terminar) ? La muerte en Lima
Autor? Suicida anonimo
Edicion? 5
Anho publicacion? 2004
Titulo? ([Enter] para terminar) ? La vida en Azpitia
Autor? Danfy
Edicion? 6
Anho publicacion? 1997
Titulo? ([Enter] para terminar) ?
$ od -c libros.dat
0000000   L   a       m   u   e   r   t   e       e   n       L   i   m
0000020   a  \0  \0  \0  \0  \0  \0  \0  \0  \0  \0  \0  \0  \0  \0  \0
0000040  \0  \0  \0  \0  \0  \0  \0  \0   S   u   i   c   i   d   a
0000060   a   n   o   n   i   m   o  \0  \0  \0  \0  \0  \0  \0  \0  \0
0000100  \0  \0  \0  \0  \0  \0  \0  \0 005  \0  \0  \0 324  \a  \0  \0
0000120   L   a       v   i   d   a       e   n       A   z   p   i   t
0000140   i   a  \0  \0  \0  \0  \0  \0  \0  \0  \0  \0  \0  \0  \0  \0
0000160  \0  \0  \0  \0  \0  \0  \0  \0   D   a   n   f   y  \0  \0  \0
0000200  \0  \0  \0  \0  \0  \0  \0  \0  \0  \0  \0  \0  \0  \0  \0  \0
0000220  \0  \0  \0  \0  \0  \0  \0  \0 006  \0  \0  \0 315  \a  \0  \0
0000240
$
Lista enlazada

Una vez que hemos leído todos los datos de los libros desde el teclado, ¿cómo haríamos para añadir o eliminar un registro intermedio (digamos, el registro "k-esimo") de nuestra biblioteca?

Pensemos sólo en eliminar un libro. Primeramente habría que desplazar los libros un registro, cubriendo el "k-esimo":

	for(z=k;z<n_bib-1;z++)
		memcpy(&bib[z+1],&bib[z],sizeof(LIBRO))

o en forma más compacta:

	memcpy(bib+k,bib+k+1,(n_bib-k-1)*sizeof(LIBRO))

Y a fin de liberar la memoria anteriormente asignada al registro, tendríamos que reasignar el área con “realloc()” a un tamaño igual a n_bib-1:

	bib=realloc(bib,(n_bib-1)*sizeof(LIBRO));

Todo este desplazamiento es muy ineficiente [18] (pues por un sólo registro podríamos tener que desplazar físicamente a todos los otros) y en ciertas aplicaciones resulta inaceptable por el tiempo que puede conllevar. Una solución muy común a este tipo de problema consiste en utilizar las clásicas "listas enlazadas". Esto demanda que redefinamos la estructura del siguiente modo:

typedef struct LIBRO {
	char titulo[40];
	...
	struct LIBRO * siguiente;
	} LIBRO;

Nótese que requerimos especificar el tipo de la estructura (struct LIBRO) para que el puntero “*siguiente” haga referencia a la misma, y luego hacemos que el tipo struct LIBRO sea equivalente a LIBRO gracias al typedef. Una forma exactamente igual a lo anterior sería:

struct LIBRO {
	char titulo[40];
	...
	struct LIBRO * siguiente;
	};
typedef struct LIBRO LIBRO;

El programa que viene ahora es similar al anterior, con la diferencia que tras introducir los libros se solicita al usuario el índice (empezando de uno) de uno de los libros a ser eliminado de la lista enlazada:

/*
 * bib2.c: Leer un conjunto de libros, guardarlos en una
 * lista enlazada, y eliminar un elemento intermedio
 */
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

typedef struct LIBRO
        {
	char titulo[40];
	char autor[30];
	int edicion;
	int a_publicacion;
	struct LIBRO * siguiente;
	} LIBRO;

LIBRO *primero=NULL,*ultimo;
char tmp_line[80];

void getline(char *t)
{
printf("%s",t);
if(fgets(tmp_line,80,stdin)==NULL)
	{
	tmp_line[0]=0;
	return;
	}
tmp_line[strlen(tmp_line)-1]=0;
}

int main()
{
LIBRO *k,*k_anterior;
int z,eliminable;

for(;;)
	{
	getline("Titulo? ([Enter] para terminar) ? ");
	if(tmp_line[0]==0) break;
	if(primero==NULL)
		{
		primero=calloc(1,sizeof(LIBRO));
		ultimo=primero;
		}
	else
		{
		ultimo->siguiente=calloc(1,sizeof(LIBRO));
		ultimo=ultimo->siguiente;
		}
	ultimo->siguiente=NULL;
	
	strcpy(ultimo->titulo,tmp_line);
	getline("Autor? "); strcpy(ultimo->autor,tmp_line);
	getline("Edicion? "); ultimo->edicion=atoi(tmp_line);
	getline("Anho public.? "); ultimo->a_publicacion=atoi(tmp_line);
	}

getline("\nQue libro eliminara? ");
eliminable=atoi(tmp_line);
if(eliminable==1)
	abort(); /* Falta corregir esto */

k=primero; z=0;
while(k)
	{
	z++;
	if(z==eliminable)
		{
		k_anterior->siguiente=k->siguiente;
		free(k);
		break;
		}
	k_anterior=k;
	k=k->siguiente;
	}
printf("Nuevo listado de libros:\n");
k=primero;
while(k)
	{
	printf("%s\n",k->titulo);
	k=k->siguiente;
	}

return 0;
}

Como se ve, se mantiene un puntero llamado “primero” al primer elemento de la lista enlazada, así como el puntero “ultimo” al último. El puntero al último elemento sirve para añadir nuevos elementos a la lista con facilidad:

ultimo->siguiente=calloc(1,sizeof(LIBRO));

Por otro lado, obsérvese que la inicialización del primer elemento es un caso especial pues allí no tiene sentido lo anterior (dado que "primero" y "ultimo" están en NULL.)

La eliminación del elemento “eliminable” consiste en una "reconexión" de la lista, y la liberación aislada del elemento. Nótese que cuando se escoge el primer elemento el programa aborta (abort(3)). Ud. deberá (como ejercicio) completar el código necesario para eliminar el primer elemento pues es un caso especial en el que no hay un “k_anteiror” apuntando a algo.

$ ./bib2
Titulo? ([Enter] para terminar) ? Libro 1
Autor? xxx
Edicion? 1
Anho public.? 2000
Titulo? ([Enter] para terminar) ? Libro 2
Autor? xxx
Edicion? 2
Anho publicacion? 2001
Titulo? ([Enter] para terminar) ? Libro 3
Autor? xxx
Edicion? 2002
Anho public.? 2002
Titulo? ([Enter] para terminar) ? Libro 4
Autor? jj benites
Edicion? 29
Anho public.? 2070
Titulo? ([Enter] para terminar) ?

Que libro eliminara? 3
Nuevo listado de libros:
Libro 1
Libro 2
Libro 4
$
Graficando funciones

El siguiente programa es tremendamente sencillo, pero nos permite ilustrar los punteros a funciones. Lo implementaremos en varios archivos.

La rutina más importante se guarda en el archivo graficador.c:

#include <stdio.h>

#define MAX_X 60
#define MAX_Y 20

void graficador(double x1, double y1, double x2, double y2,
		double (*f)(double))
{
int x,y,valor[MAX_X];

for(x=0;x<MAX_X;x++)
	valor[x]=MAX_Y*((*f)( x1+x*(x2-x1)/MAX_X )-y1)/(y2-y1);

putchar('+');
for(x=0;x<MAX_X;x++) putchar('-');
putchar('+');
putchar('\n');

for(y=MAX_Y;y>=0;y--)
	{
	putchar('|');
	for(x=0;x<MAX_X;x++)
		{
		if(valor[x]==y)
			putchar('*');
		else
			putchar(' ');
		}
	putchar('|');
	putchar('\n');
	}

putchar('+');
for(x=0;x<MAX_X;x++) putchar('-');
putchar('+');
putchar('\n');
}

Esta rutina grafica en la pantalla [19] la región (x1,y1)-(x2,y2) y obtiene los valores de una función real que se le pasa como argumento. Estos valores se convierten al rango de enteros [0,MAX_Y] para ser dibujados en la pantalla con facilidad.

La función main, junto con un par de ejemplos que aparecen en la electrónica básica [20] , se muestran a continuación (archivo prueba.c):

#include "graficador.h"
#include <math.h>

double rect_completa(double x)
{
return fabs(sin(x));
}

double rect_media(double x)
{
if(sin(x)<0)
	return 0;
return sin(x);
}

int main(void)
{
graficador(0,-1.4,2*3.1416,1.4,sin);
graficador(0,-2,2*3.1416,2,tan);
graficador(0,-0.8,5*3.1416,1.8,rect_media);
graficador(0,-0.8,5*3.1416,1.8,rect_completa);
return 0;
}

Por comodidad, hemos creado el archivo de encabezado graficador.h cuya única finalidad es declarar la rutina graficador():

void graficador(double , double , double , double ,
		double (*)(double));

Compile todo esto con:

$ gcc -o prueba prueba.c graficador.c
$ ./prueba

El resultado es el siguiente:

Sinusoide, Tangente, Rectificadores de Onda

Ejercicios

Implemente para el programa de biblioteca las siguientes funciones (accesibles desde un menú):

  1. Ingreso de un nuevo libro

  2. Búsqueda de libros por título

  3. Búsqueda de libros por autor

  4. Eliminación de un libro

  5. Guardar la base de datos

El programa cargará la base de datos desde disco tan pronto como se inicia.

2. Usando el compilador Gcc y Make

Casi la totalidad de instalaciones Linux emplean el compilador "GCC" (Gnu Compiler Collection) para compilar programas en C [21] . GCC ha sido portado a diversas arquitecturas y está disponible prácticamente en todas las variantes Unix. Sin embargo, cada Unix suele proporcionar su propio compilador, aunque en ciertos casos es un producto "opcional" (es decir, hay que comprarlo.)

La compilación de programas C en Linux se lleva a cabo mediante el comando gcc [22] . En los sistemas Unix frecuentemente se usa el comando cc, o algunas variantes como "c89" que deben estar documentadas en los manuales correspondientes; en muchos de los ejemplos que se muestran a continuación, "gcc" puede ser reemplazado por "cc" sin mayor dificultad.

2.1. Usando el comando Gcc (o cc)

Compilar programas de un sólo archivo fuente

El siguiente listado presenta un programa completo (prog1.c)

/*
 * prog1.c: Saludo trivial
 */
#include <stdio.h>

int main(void)
{
printf("Hola mundo\n");
return 0;
}

La compilación de un programa contenido totalmente en un único archivo (como el ejemplo de arriba) es muy simple. Usamos comando gcc (o cc), especificando el nombre del ejecutable resultante con la opción -o, y el archivo fuente:

$ gcc -o prog1 prog1.c
$ ./prog1
Hola mundo

Como Ud. debe saber bien, la compilación de un programa corresponde a la traducción del código fuente (C en nuestro caso) a código máquina (específico del microprocesador.) Este código máquina se guarda (si se desea) en "archivos objeto" (también llamados de "código objeto" o simplemente "objetos") con extensión ".o". Nótese que en ejemplo anterior no se generó ningún archivo objeto [23] , cosa que sí haremos a continuación:

$ rm prog1
$ gcc -c prog1.c
$ ls
prog1.c  prog1.o

El siguiente paso es el llamado "enlace" (link), el cual genera el ejecutable final a partir de uno o más "archivos objeto". Para el ejemplo anterior, ejecutaríamos lo siguiente:

$ gcc -o prog1 prog1.o
$ ls
prog1  prog1.c  prog1.o

Como se ve, en estos dos pasos hemos generado tanto el "archivo objeto" como el "ejecutable".

Enlazando la librería matemática

En muchos sistemas las funciones matemáticas/científicas no se enlazan automáticamente. Por ejemplo, considere el programa (prog2.c.)

/*
 * prog2.c: Calculo matematico
 */
#include <stdio.h>
#include <math.h>

#define PI 3.1416

int main(void)
{
printf("Seno de PI/4 es %lf\n",sin(PI/4));

return 0;
}

A pesar de que no tiene error alguno, su compilación falla:

$ gcc -o prog2 prog2.c
/tmp/ccUAjaIU.o: In function `main':
/tmp/ccUAjaIU.o(.text+0x21): undefined reference to `sin'
collect2: ld devolvió el estado de salida 1
$

Obsérvese que la compilación propiamente dicha no es la que falla, sino la etapa de "enlace". Esto se aprecia en la última línea donde se menciona el programa “ld” (link editor, el enlazador [24] .)

La penúltima línea del mensaje indica que hay una "referencia indefinida a sin ". Es decir, no se puede ubicar el código de la función sin(). La solución es muy sencilla. Basta con añadir el enlace de la librería matemática:

$ gcc -o prog2 prog2.c -lm
$ ./prog2
Seno de PI/4 es 0.707108

La explicación en detalle de la opción -lXXX se proporciona en el capítulo 3. Por ahora sólo recuerde que en ciertos programas deberá añadir -lm al comando de compilación.

2.2. Opciones del compilador

He de empezar advirtiendo que cada compilador tiene sus propias opciones aunque hay cierto acuerdo en las más sencillas. Por ejemplo, en las secciones anteriores hemos apreciado el uso de las siguientes opciones: .Algunas opciones "estándar"

Opcion Significado

-c

Compilar generando archivo objeto pero no enlazar

-o outfile

Nombre del archivo (outfile) generado como resultado

-lXXX

Enlaza con la librería dinámica libXXX.so

En esta sección veremos más opciones de compilación; sin embargo, mi referencia es el compilador GNU C (gcc.) Si Ud. emplea un compilador distinto tendrá que contrastar este texto con los manuales respectivos.

Table 2. Opciones de precompilación
Opción Significado

-D NOMBRE[=VALOR]

Define un macro

-U NOMBRE

Cancela la definición de macro

-I RUTA

Especifica ruta para buscar archivos de encabezado

-E

Sólo invoca al precompilador sin compilar

Para el siguiente programa:

/*
 * test_precompilador.c
 */
#include <stdio.h>

#ifdef NUMEROS_GRANDES
typedef long long ENTERO;
#define FORMATO "%lld"
#else
typedef int ENTERO;
#define FORMATO "%d"
#endif

#define expande(t) tostr(t)
#define tostr(s) #s

#ifdef TITULO
char *tit= expande(TITULO) "\n";
#else
char *tit="Prueba de preprocesador\n";
#endif

int main(void)
{
ENTERO a;
printf("%s",tit);

printf("Escriba un numero? ");scanf(FORMATO,&a);
printf("Su mitad es " FORMATO "\n",a/2);
return 0;
}

Obtenemos la siguiente salida dependiendo de las macros establecidas:

$ gcc -o test_precompilador test_precompilador.c
$ ./test_precompilador
Prueba de preprocesador
Escriba un numero? 30000
Su mitad es 15000
$ ./test_precompilador
Prueba de preprocesador
Escriba un numero? 40000000000
Su mitad es 1073741823

El último resultado claramente es erroneo, lo que se debe a que el valor introducido no es soportado en el tipo de dato entero empleado (por omisión, int.) Si recompilamos con los flags adecuados, forzaremos a emplear un tipo de dato más grande (aquí, long long) que soportará el valor. Adicionalmente hemos modificado "título" con que se inicia el programa [25] .

$ gcc -DTITULO="Manzana Verde" -DNUMEROS_GRANDES \
        -o test_precompilador test_precompilador.c
$ ./test_precompilador
Manzana Verde
Escriba un numero? 40000000000
Su mitad es 20000000000
Optimización

La opción “-O” ("O" mayúscula, posiblemente acompañada de un número) suele usarse para solicitar al compilador que optimice el programa a fin de lograr mejor performance [26] .

El siguiente programa de prueba permite hacer un extenso cálculo de enteros. Requiere que le proporcionemos en la línea de comandos un valor numérico inicial totalmente arbitrario [27] y un segundo argumento que corresponde al número de iteraciones a calcular.

/*
 * test_optimizacion.c: Opciones de optimizacion
 */
#include <stdio.h>
#include <stdlib.h>

long s1,s2,s3,s4;

void x1(void) { s1=(10*s4+s3+31)%79+3; }
void x2(void) { s2=(7*s4+s3*8+9*s4+19)%25+4; }
void x3(void) { s3=(s1*3+11*s2+13*s4)%191+5; }
void x4(void) { s4=(s1*9+s2*5+s3*17+37)%421+6; }

int main(int argc,char **argv)
{
long z,n;

s1=atol(argv[1]); s2=17; s3=90; s4=13;
n=atol(argv[2]);

for(z=0;z<n;z++)
	{
	x1(); x2(); x3(); x4();
	}
printf("%ld %ld %ld %ld\n",s1,s2,s3,s4);
return 0;
}

Haciendo pruebas en mi sistema (un Athlon XP 2000), 25'000,000 resulta ser un valor que proporciona resultados del orden de pocos segundos [28] :

$ gcc  -o test_optmizacion test_optmizacion.c
$ time ./test_optmizacion 9 25000000
20 9 107 403

real    0m5.419s
user    0m5.330s
sys     0m0.002s
$ gcc -O  -o test_optmizacion test_optmizacion.c
$ time ./test_optmizacion 9 25000000
20 9 107 403

real    0m5.046s
user    0m4.939s
sys     0m0.004s
$ gcc -O2  -o test_optmizacion test_optmizacion.c
$ time ./test_optmizacion 9 25000000
20 9 107 403

real    0m4.914s
user    0m4.898s
sys     0m0.004s
$ gcc -O3  -o test_optmizacion test_optmizacion.c
$ time ./test_optmizacion 9 25000000
20 9 107 403

real    0m4.074s
user    0m4.061s
sys     0m0.000s

Como se aprecia, la reducción de tiempo (real) para la optmización "simple" (-O) fue de aproximadamente 6.9%, para la optimización de nivel 2 (-O2) resulta aproximadamente 10% y para la de nivel 3 (-O3) alcanza un 26%. Estos niveles de mejora son tremendamente variables dependiendo del contenido del programa (hay programas que con todas las opciones no mejoran ni 1%.)

Arquitectura y tipo de CPU

Los compiladores de C típicamente generan código ejecutable por un conjunto muy amplio de procesadores (de una misma familia) a fin de que el programa pueda ser fácilmente distribuido entre computadores similares. Sin embargo, esto obliga generalmente a desaprovechar la potencia de los computadores más avanzados (de dicha familia.) El compilador gcc dispone de la opción -march para especificar la "arquitectura" del procesador que ejecutará el programa (otros compiladores deben proporcionar opciones análogas.)

En el caso del computador que estoy usando en estos momentos, (un Athlon XP) la opción correspondiente es -march=athlon-xp. Los resultados son espectaculares:

$ gcc  -o test_optmizacion test_optmizacion.c
$ time ./test_optmizacion 9 25000000
20 9 107 403

real    0m5.419s
user    0m5.330s
sys     0m0.002s
$ gcc -march=athlon-xp -o test_optmizacion test_optmizacion.c
$ time ./test_optmizacion 9 25000000
20 9 107 403

real    0m2.846s
user    0m2.832s
sys     0m0.002s
$ gcc -O3 -march=athlon-xp -o test_optmizacion test_optmizacion.c
$ time ./test_optmizacion 9 25000000
20 9 107 403

real    0m1.479s
user    0m1.467s
sys     0m0.000s

Ahora el tiempo de ejecución se ha reducido en 47% por sólo aprovechar la arquitectura del CPU, y en 73% si además se aprovecha la optimización de nivel 3.

Evidentemente, el precio a pagar es alto: el ejecutable puede no ser utilizable en computadores que emplean la familia de microprocesadores Intel, o incluso modelos anteriores de AMD.

Alertas

El compilador normalmente es capaz de alertar acerca de código fuente válido pero "riesgoso", lo que muchas veces significa que hemos cometido un error involuntario.

Por ejemplo, considere el siguiente programa "sintácticamente" correcto:

/*
 * /* Comentario anidado?
 */

#include <stdio.h>

main()
{
float f;
int t;
printf("Un numero como %d\n",f);
}

Si lo compilamos con gcc (al menos con las opciones preinstaladas en mi sistema) no obtengo niguna alerta:

$ gcc -o test_alertas test_alertas.c

A continuación pediremos al compilador sea más quisquilloso con nuestro programa. Las opciones que se muestran a continuación son específicas de gcc y Ud. deberá consultar la documentación respectiva si emplea otro compilador. Para empezar, solicitemos al compilador que nos alerte acerca de posibles "comentarios anidados" como el que tiene nuestro programa:

gcc -Wcomment -o test_alertas test_alertas.c
test_alertas.c:2:4: aviso: "/*" dentro de un comentario

Más útiles son los avisos con respecto a incoherencias en los formatos de printf() y similares. En nuestro caso el formato "%d" no podrá procesar un "float":

$ gcc -Wformat -o test_alertas test_alertas.c
test_alertas.c: En la función `main':
test_alertas.c:11: aviso: formato int, argumento double (argumento 2)

Otro aspecto cuestionable es que no hemos especificado el valor de retorno de main() (el cual es int por omisión) y pudo ser un descuido nuestro. Hagamos que el compilador nos alerte en estos casos:

$ gcc -Wimplicit-int -o test_alertas test_alertas.c
test_alertas.c:8: aviso: el tipo de devolución por omisión es `int'

De acuerdo a lo anterior, la función main() debe retornar un entero; sin embargo, en ningún momento hemos retornado nada. Esto también puede ser alertado por el compilador:

$ gcc -Wreturn-type -o test_alertas test_alertas.c
test_alertas.c:8: aviso: el tipo de devolución por omisión es `int'
test_alertas.c: En la función `main':
test_alertas.c:12: aviso: el control alcanza el final de una función
que no es void

La variable 't' no se usa:

gcc -Wunused-variable -o test_alertas test_alertas.c
test_alertas.c: En la función `main':
test_alertas.c:10: aviso: variable `t' sin uso

Bien, suficiente diversión. Hay muchas otras alertas que se podrían configurar de modo similar, sin embargo la opción -Wall nos puede ahorrar todo el trabajo:

$ gcc -Wall -o test_alertas test_alertas.c
test_alertas.c:2:4: aviso: "/*" dentro de un comentario
test_alertas.c:8: aviso: el tipo de devolución por omisión es `int'
test_alertas.c: En la función `main':
test_alertas.c:11: aviso: formato int, argumento double (argumento 2)
test_alertas.c:10: aviso: variable `t' sin uso
test_alertas.c:12: aviso: el control alcanza el final de una función
que no es void

Esta opción implica la máxima rigurosidad en alertas, y la recomiendo siempre que sea posible. Trate de "pulir" sus programas hasta que no aparezca ninguna [29] .

Estándares

Considere el siguiente programa que emplea enteros extra-largos (long long int.) Estos enteros no están soportados en el estándar C89 (ver capítulo 1) pero sí son parte del C99:

/*
 * test_estandar.c: C89 vs C99
 */
#include <stdio.h>

int main(void)
{
long long int x=6LL;

printf("Superentero: %lld\n",x);

return 0;
}

Gcc normalmente utiliza el estándar C89 así como extensiones específicas de GNU. Si deseamos especificar un cumplimiento estrícto del estándar "c89" (sin extensiones) debemos usar la opción -std [30] junto con la opción -pedantic. En nuestro programa, éstas producen el siguiente resultado:

$ gcc -Wall -pedantic -std=c89 -o test_estandar test_estandar.c
test_estandar.c: En la función `main':
test_estandar.c:5: aviso: ISO C89 no da soporte a `long long'
test_estandar.c:5: aviso: ISO C89 prohibe constantes enteras long long
test_estandar.c:7: aviso: ISO C89 no tiene soporte para el modificador
de longitud ll `printf'

El mismo programa, sujeto al estándar C99 no tiene estos inconvenientes:

$ gcc -Wall -pedantic -std=c99 -o test_estandar test_estandar.c
$

2.3. Automatización de la compilación con Make

Programas de varios archivos de código fuente

En los programas grandes es frecuente separar el código en diversos archivos para que todo sea más manejable. El siguiente archivo (modulo1.c) efectua un cálculo matemático a partir de dos números enteros:

/*
 * modulo1.c
 */
int mcd(int a,int b)
{
int r;
while(b)
	{
	r=a%b;
	a=b;
	b=r;
	}
return a;
}

Espero que el lector haya descubierto que esta función simplemente calcula el máximo común divisor con el algoritmo de Euclides. Ahora viene la segunda parte (modulo2.c):

/*
 * modulo2.c
 */
#include <stdio.h>

int mcd(int,int);

int main(void)
{
int x=510, y=340;

printf("MCD de %d y %d es %d\n",x,y,mcd(x,y));
return 0;
}

Método 1

Para compilar todo en un solo paso (sin generar "archivos objeto"), podríamos hacer lo siguiente:

$ gcc -o mcd modulo1.c modulo2.c

Esto compila ambos archivos fuente y genera un único ejecutable (que he llamado mcd.)

Método 2

La otra manera de compilar esto (y que es más importante para lo que veremos) es hacerlo todo paso a paso:

$ gcc -c modulo1.c
$ gcc -c modulo2.c
$ gcc -o mcd modulo1.o modulo2.o

La diferencia es más trascendente de lo que puede parecer en principio. Obsérvese que las dos primeras operaciones son "compilaciones" y la última sólo es un "enlace". ¿Qué ocurriría si hacemos una modificación en - por ejemplo - modulo1.c? Si usamos el primer método, el compilador estaría recompilando inutilmente a modulo2.c. El segundo método permite evitar esto:

$ gcc -c modulo1.c
$ gcc -o mcd modulo1.o modulo2.o

Por tanto es más eficiente que el primero.

Esta eficiencia es importantísima en proyectos grandes; por ejemplo, con 100 o más archivos de código fuente. La automatización de todas estas cuestiones se las podemos dejar a make.

Make es una herramienta diseñada para compilar proyectos relativamente grandes y con "dependencias" complicadas.

Dependencias y el Makefile

Un concepto fundamental en “make” [31] son las relaciones de dependencia. En el ejemplo anterior observamos la siguiente secuencia de operaciones:

   modulo1.c  --> modulo1.o ---+
                                +--> mcd
   modulo2.c  --> modulo2.o ---+

Si - por ejemplo - modulo1.c es modificado por el programador, entonces es evidente que debemos generar un nuevo archivo objeto modulo1.o para dar lugar a un nuevo ejecutable mcd. En otras palabras, mcd depende de modulo1.o y de modulo2.o, mientras que modulo1.o depende de modulo1.c, y por último modulo2.o depende de modulo2.c.

Nótese que todo archivo (excepto los de código fuente) debe ser construido a partir de sus "dependencias". Todo esto lo especificaremos en un archivo llamado convencionalmente "Makefile" que es usado por el comando “make” para describir lo que tiene que hacer. Para nuestro ejemplo sería algo así:

# Primera version de Makefile

mcd: modulo1.o modulo2.o
	gcc -o mcd modulo1.o modulo2.o

modulo1.o: modulo1.c
	gcc -c modulo1.c

modulo2.o: modulo2.c
	gcc -c modulo2.c

Por lo que podemos apreciar existen tres "reglas", que son las que indican qué archivos dependen de cuales, y cómo generarlos cuando hace falta. En la primera regla, el "target" u "objetivo" a construir es el ejecutable “mcd”. Éste se debe reconstruir cuando está desactualizado con respecto a sus dependencias modulo1.o o modulo2.o. Luego de esta especificación de dependencias se debe colocar los comandos a ejecutarse para satisfacer tales dependencias. Como sabemos, gcc permite generar el ejecutable a partir de los archivos objeto.

Análogamente, tanto modulo1.o como modulo2.o tienen dependencias respecto a los archivos fuente modulo1.c y modulo2.c respectivamente, cosa que se especifica mediante dos reglas adicionales.

Note
Estos comandos especificados en cada regla deben iniciarse siempre con un tabulador al principio de la línea. De no ser así, make asume que se trata de una nueva regla. No se puede usar espacios, necesariamente es un caracter de tabulación.
Invocación de make

El "Makefile" anteriormente presentado puede guardarse en cualquier archivo de texto, aunque por comodidad se suele utilizar el nombre Makefile o makefile.

Pruebe a compilar el programa a partir de los módulos. Empiece borrando el ejecutable y los módulos objeto para ver el proceso completo:

$ rm -f mcd modulo1.o modulo2.o
$ ls
Makefile  modulo1.c  modulo2.c

Ahora invoque a make. A continuación muestro lo que obtuve en mi computador [32] :

$ make
gcc -c modulo1.c
gcc -c modulo2.c
gcc -o mcd modulo1.o modulo2.o

Si volvemos a invocar a make Ud. apreciará que éste se niega a hacer algo debido a que todo está ahora actualizado.

Supogamos ahora que modificamos a modulo1.c. Podríamos hacer un cambio editando el archivo, pero para engañar a make simplemente basta con que actualicemos su fecha de modificación (a fin de que sea más reciente que los archivos que dependen de éste.) El comando touch puede actualizar esa fecha:

$ make
make: `mcd' está actualizado.
$ touch modulo1.c
$ make
gcc -c modulo1.c
gcc -o mcd modulo1.o modulo2.o

Observe con atención que make sólo ha compilado lo necesario (no ha recompilado modulo2.c.) Cómo logra hacerlo es lo que veremos a continuación.

Procesamiento de dependencias

¿En qué casos se considera que un archivo "target" está "desactualizado" con respecto a sus dependencias? Antes de responder a esto, make hace un análisis de cada una de las "dependencias" y verifica si éstas son "target’s" en otras reglas. Todas estas reglas deben ser resueltas antes de proseguir. En nuestro caso, las dependencias modulo1.o y modulo2.o son los "target" de la segunda y tercera regla respectivamente. Analicemos la segunda regla (la tercera es análoga.)

La segunda regla (con target modulo1.o) presenta como dependencia única a modulo1.c. Como ya no hay ninguna otra regla que tenga a modulo1.c como target, entonces Make analiza las fechas de modificación del target modulo1.o y su dependencia modulo1.c. Si modulo1.c es más reciente que modulo1.o, entonces significa que el programador ha hecho cambios en el código fuente; por lo tanto, make ejecuta el comando indicado en la regla, el cual regenera a modulo1.o. Esto trae como consecuencia que éste último ahora sea más reciente que modulo1.c.

Tras analizar a reglas segunda y tercera, make retorna a analizar la primera. Ahora verificará las fechas de modificación para conocer si las dependencias (modulo1.o y modulo2.o) son más recientes que el target mcd. De ser así se ejecutará los comandos asociados a dicha regla; es decir, invocará a gcc para volver a hacer el enlace de los archivos objeto generando un nuevo ejecutable.

Es importante advertir que en el momento en que make regresa a la primera regla, las fechas de modificación de los archivos involucrados pueden haber cambiado (debido a los comandos de las otras reglas.) De este modo, un simple cambio en un archivo de código fuente se propaga adecuadamente al archivo objeto correspondiente y da lugar a un nuevo ejecutable.

También debemos anotar que la primera regla normalmente es la que inicia la cadena recurrente de dependencias. Si Ud. hubiera empezado el archivo con la siguiente regla:

modulo1.o: modulo1.c
	gcc -c modulo1.c
...

Entonces make sólo se preocuparía de actualizar el archivo objeto "modulo1.o" cuando sea necesario (cuando cambie "modulo1.c") pero no hará absolutamente nada más debido a que "modulo1.c" no es target en ninguna otra regla.

A riesgo de sonar repetitivo…​ recuerde una vez más que los comandos asociados a las reglas deben iniciarse con un tabulador como primer caracter de la línea.

Reglas implícitas

El Makefile que presentamos arriba se puede abreviar considerablemente. Por ejemplo, la siguiente versión será totalmente equivalente y más poderosa:

# Segunda version de Makefile

mcd: modulo1.o modulo2.o
	gcc -o mcd modulo1.o modulo2.o  # usar tabulador

clean:
	rm -f *.o mcd  # usar tabulador

Empecemos por el final. Hemos añadido una regla con target "clean", la cual no participa normalmente del proceso de compilación (clean no aparece como dependencia de nada.) Simplemente nos sirve para "limpiar" el directorio del programa de todos los archivos generados (objetos y el ejecutable) a fin de forzar una compilación total.

Como mencioné, esto no ocurrirá a no ser que lo solicitemos explícitamente con el siguiente comando:

$ make clean

Si bien no es obligatorio tener una regla con target "clean", muchos proyectos la tienen, y mucha gente espera encontrarla.

Dejando de lado el target "clean", y volviendo a lo anterior, ¿qué ocurrió con las otras dos reglas que estaban en la versión anterior del Makefile? La respuesta es que son redundantes.

Make normalmente viene preconfigurado con lo que se conoce como "reglas implícitas", que simplemente son acciones a tomarse con ciertas relaciones de dependencia cuando no se proporciona una acción explícitamente.

Como vemos, en la primera regla la acción para construir el ejecutable está completamente determinada de manera explícita. Como sabemos, make además buscará otras reglas donde modulo[12].o sean "targets". Al no haber tales reglas, entonces intentará buscar una regla implícita que sea adecuada.

Una regla implícita "adecuada" (que no necesitamos añadir al Makefile pues ya está pre-configurada) tendría aproximadamente el siguiente aspecto:

%.o:%.c
	gcc -c $<

Esta regla es aplicada por make a los archivos modulo[12].c para generar los target’s modulo[12].o [33] . De este modo, esta nueva versión del Makefile es completamente equivalente a la anterior.

Recuerde que ésto último no requiere añadirse al Makefile; sólo se presenta para que la explicación sea más completa.

Variables

Continuando con nuestra reducción del Makefile, presentamos una tercera versión:

# Tercera version de Makefile

OBJETOS= modulo1.o modulo2.o

mcd: $(OBJETOS)
	gcc -o $@ $(OBJETOS)

clean:
	rm -f *.o mcd

Como se aprecia, las variables se pueden definir con la notación NOMBRE=VALOR. Éstas se expanden con la notación $(NOMBRE) y pueden proporcionan un gran poder de abstracción en Makefiles complicados.

Algunas variables tienen un significado particular dependiendo de su contexto. Por ejemplo, la variable "@" (que se expande con "$@") significa el "target" de una regla y su valor (en nuestro caso, reemplaza a “mcd”.) A estas variables se les denomina a veces "automáticas".

Table 3. Algunas variables automáticas en GNU Make
Variable Significado

@

El target de la regla

<

El nombre de la primera dependencia

?

El nombre de todas las dependencias más nuevas que el target

^

El nombre de todas las dependencias sin repeticiones

+

El nombre de todas las dependencias, posiblemente repetidas

Variables en reglas implícitas

Hace un momento presenté una probable regla implícita para construir un archivo objeto (*.o) a partir de un archivo fuente C (*.c). A continuación presento una versión más acorde con la realidad:

%.o:%.c
	$(CC) -c $(CFLAGS) $<

Esto quiere decir que es posible usar un compilador distinto a gcc con facilidad (al menos desde el punto de vista de make) simplemente definiendo la variable CC al inicio del Makefile. Asimismo, la variable CFLAGS permite introducir algunas opciones al proceso de compilación (ver más adelante.)

Table 4. Algunas variables de GNU Make que invocan programas con opciones
Nombre del programa Opciones Significado Valor por omisión

AR

ARFLAGS

Programa de "archivamiento" (capítulo 3)

ar

AS

ASFLAGS

Programa ensamblador

as

CC

CFLAGS

Compilador de C

cc

CXX

CXXFLAGS

Compilador de C++

g++

FC

FFLAGS

Compilador fortran

f77

LEX

LFLAGS

Analizador lexicográfico (capítulo 20)

lex

PC

PFLAGS

Compilador de pascal

pc

YACC

YFLAGS

Analizador sintáctico (capítulo 20)

yacc

RM

Programa que elimina archivos

rm -f

Comentarios finales sobre Make

Make es un programa tremendamente sofisticado. Aquí sólo hemos presentado los aspectos más elementales, aunque ya estamos en capacidad de compilar proyectos pequeños. En Linux la versión de Make distribuida es GNU Make, la cual presenta numerosas extensiones y algunas incopatibilidades con respecto a las versiones "oficiales" de los Unix’es. Para más información sobre GNU Make, consúltese la documentación "info" (info make.)

3. Crear y Utilizar Librerías

Las librerías son rutinas (en general, cualquier código) usado para proporcionar funcionalidad a los programas. Por lo general, las librerías contienen una colección de funciones que llevan a cabo un conjunto de tareas orientadas a cierto propósito. Por ejemplo, la librería "gdbm" proporciona un conjunto de rutinas para interactuar con bases de archivos planos (capítulo 10), la librería "zlib" proporciona un conjunto de rutinas para comprimir y descomprimir cualquier conjunto de datos, la librería SDL permite programar aplicaciones multimedia/juegos (capítulo 12), etc.

En este capítulo mostraremos cómo crear y utilizar librerías, lo cual requiere que el lector conozca el material del capítulo 2.

3.1. Un ejemplo: mejoras para el procesamiento de strings

A fin de ilustrar el proceso completo de creación de una librería, utilizaremos (inventaremos) un ejemplo práctico (stringplus.) En realidad, no es muy relevante qué es lo que hace esta librería, sino la forma como está siendo construida.

3.1.1. Diseño

Aunque la librería estándar ya proporciona un conjunto de funciones para procesamiento de cadenas de caracteres (strcpy, strcat, strchr, etc.) es recurrente la necesidad de diversas funciones adicionales tales como:

Función Descripción

replace

Reemplazar las ocurrencias de un texto por otro dentro de un string

trim

Eliminar espacios en blanco al inicio y al final de un string

substr

Proporcionar una sección de una cadena en un nuevo string

A esta pequeña librería le denominaremos stringplus.

3.1.2. Implementación

A fin de hacer la explicación más interesante y general, implementaremos cada función en un archivo aparte:

Reemplazo de textos

Dada una cadena de texto, buscar todas las ocurrencias de cierto patrón (otra cadena) y reemplazarlo por otra cadena de texto. La cadena original no se altera, y los reemplazos dan lugar a una nueva, asignada dinámicamente (se debe eliminar posteriormente con free(3).) Nótese que esta implementación asume que los strings a crearse no pasan de 1024 bytes. Esto es un fallo que Ud. debería corregir a modo de ejercicio:

/*
 * replace.c: remplazar en cadena todas las ocurrencias
 * de "tal" por "cual", retornando un nuevo string
 */
#include "stringplus.h"

char *replace(const char *cadena,const char *tal,const char *cual)
{
if(cadena==NULL || tal==NULL || cual==NULL || strlen(tal)==0)
	return NULL;

/* Implementacion poco inteligente: reservamos 1K 
 * y no verificamos si no alcanza */
char *res=(char *)calloc(1024,sizeof(char));
const char *p1=cadena;
const char *p=cadena;

for(;;)
	{
	p=strstr(p,tal);
	while(*p1 && p1!=p)
		{
		int j=strlen(res);
		res[j]=*p1;
		res[j+1]=0;
		p1++;
		}
	if(p1==p)
		{
		strcat(res,cual);
		p1+=strlen(tal);
		p+=strlen(tal);
		}
	if(!*p1)
		break;
	}
return res;
}

3.1.3. Sub-Cadenas de Texto

La función substring recibe como argumento una cadena (que no se altera), un entero no negativo que indica la posición a partir de la cual se extraerá una nueva cadena, y otro entero (no negativo) que indica cuántos caracteres se extraerán como máximo. Si los enteros proporcionados son negativos, se retorna NULL. Si los enteros intentan obtener caracteres más allá de los que contiene la cadena, se retorna un string vacío.

/*
 * substr.c: Retornar un nuevo string a partir
 * de una seccion de un string inicial
 */
#include "stringplus.h"

char *substr(const char *cadena,int n,int m)
{
if(cadena==NULL || n<0 || m<0)
	return NULL;
if(n>=strlen(cadena))
	return strdup("");
if(n+m <= strlen(cadena))
	{
	char *p=(char*)malloc(m+1);
	p[m]=0;
	strncpy(p,cadena+n,m);
	return p;
	}
return strdup(cadena+n);
}

3.1.4. Recorte de blanks

Es frecuente la necesidad de eliminar espacios en blanco (blanks) en cadenas de texto. En general, consideramos "blanks" a los espacios, tabuladores y saltos de línea. La función trim retorna un nuevo string recortado a partir de una cadena pasada como argumento (que no se modifica.)

/*
 * trim.c: eliminar espacios en blanco
 * al inicio y al final de un string
 */
#include "stringplus.h"

char *trim(const char *cadena)
{
if(cadena==NULL)
	return NULL;
if(*cadena == 0)
	return strdup("");
char *p=strdup(cadena);
char *ptr=p;
while(*p)
	{
	if(*p !=' ' && *p!='\t' && *p!='\n')
		break;
	p++;
	}
char *t=p+strlen(p)-1;
while(*t)
	{
	if(t==p)
		break;
	if(*t !=' ' && *t!='\t' && *t!='\n')
		break;
	t--;
	}
*(t+1)=0;
char *tmp=strdup(p);
free(ptr);
return tmp;
}

3.1.5. Header de la librería

Es típico que las librerías tengan uno o más headers. Para nuestro caso bastará con uno:

/*
 * stringplus.h: Header de libreria de strings
 */
#ifndef STRINGPLUS_H
#define STRINGPLUS_H
#include <stdio.h>
#include <string.h>

char *replace(const char *cadena,const char *tal,const char *cual);
char *substr(const char *cadena,int n,int m);
char *trim(const char *cadena);

#endif

3.2. Prueba

Ahora crearemos un pequeño programa para probar todo el código anterior (prueba.c):

#include "stringplus.h"

int main(int argc,char **argv)
{
char *to_replace="Erre con erre guitarra";
char *from_replace=replace(to_replace,"rr","abc");
if(from_replace==NULL)
	fprintf(stderr,"Error en replace\n"),exit(1);
printf("to_replace:[%s]\n",to_replace);
printf("from_replace(rr,abc):[%s]\n\n",from_replace);
free(from_replace);

char *to_substr="La tierra es guera";
char *from_substr=substr(to_substr,3,6);
if(from_substr==NULL)
	fprintf(stderr,"Error en substr\n"),exit(1);
printf("to_substr:[%s]\n",to_substr);
printf("from_substr(3,6):[%s]\n\n",from_substr);
free(from_substr);


char *to_trim="   There goes the Challenger    ";
char *from_trim=trim(to_trim);
if(from_trim==NULL)
	fprintf(stderr,"Error en trim\n"),exit(1);
printf("to_trim:[%s]\n",to_trim);
printf("from_trim:[%s]\n\n",from_trim);
free(from_trim);


return 0;
}

3.2.1. Compilación y ejecución

El siguiente Makefile permite compilar todos nuestros módulos, generando un ejecutable prueba:

OBJECTS = prueba.o replace.o trim.o substr.o

prueba: $(OBJECTS)
	gcc -o $@ $(OBJECTS)

clean:
	rm -f prueba *.o

La salida es la siguiente:

$ ./prueba
to_replace:[Erre con erre guitarra]
from_replace(rr,abc):[Eabce con eabce guitaabca]

to_substr:[La tierra es guera]
from_substr(3,6):[tierra]

to_trim:[   There goes the Challenger    ]
from_trim:[There goes the Challenger]

3.2.2. Recapitulación

Revisemos en lo que hemos conseguido hasta este momento. Gracias al código de los archivos:

  • replace.c

  • substr.c

  • trim.c

  • stringplus.h

El programa prueba.c ha podido ejecutarse haciendo uso de nuestras nuevas funciones extendidas de strings, lo cual era el objetivo de todo esto. Eso significa que los cuatro archivos listados arriba constituyen para todo propósito lo que se conoce conceptualmente por "librería".

De otro lado, otro programador que hace uso de nuestra librería no necesita estrictamente tener acceso al código fuente de la misma. Por tal motivo nuestra librería podría constar sólamente de los archivos compilados:

  • replace.o

  • substr.o

  • trim.o

  • stringplus.h

Nótese que el archivo cabecera stringplus.h no tiene equivalente compilado por lo que se debe proporcionar. El distribuír sólo código compilado es una práctica comercial común, pero que lamentablemente limita las posibilidades de introducir mejoras y correcciones [34] .

Ahora bien, desde el punto de vista del sistema operativo Unix/Linux todavía no disponemos de una "librería" como tal, puesto que éstas se suelen distribuír en un solo archivo de formato especial (y no como un conjunto de archivos objeto *.o) como veremos a continuación.

3.3. Construcción de archivos de librería

3.3.1. Librerías estáticas

Las librerías estáticas consisten simplemente en empaquetar los archivos de código objeto *.o en un solo gran archivo (con extensión *.a) usando el clásico comando ar [35] . Este archivo se puede enlazar con el compilador como si fuese cualquier otro archivo objeto.

Dados los archivos del ejemplo anterior, el siguiente Makefile genera el archivo de librería estática "stringplus.a", y lo enlaza con el código de prueba (prueba.c):

# Crear un archivo de libreria estatica
# 
OBJECTS = replace.o trim.o substr.o

prueba: prueba.c stringplus.a
	gcc -o $@ prueba.c stringplus.a

stringplus.a: $(OBJECTS)
	ar rcs $@ $(OBJECTS)

clean:
	rm -f prueba *.o stringplus.a

La ejecución de make se muestra a continuación [36] :

$ make
cc    -c -o replace.o replace.c
cc    -c -o trim.o trim.c
cc    -c -o substr.o substr.c
ar rcs stringplus.a replace.o trim.o substr.o
gcc -o prueba prueba.c stringplus.a

Con esto, la distribución de la librería se limita a los archivos:

  • stringplus.h

  • stringplus.a

Así como una buena explicación de cómo se usa.

A esta clase de librería se le conoce como "librería estática". Los ejecutables enlazados con librerías estáticas pueden ser llevados a otros computadores sin necesidad de llevar también la librería, pues ésta ya está contenida en aquellos.

3.3.2. Librerías compartidas (shared libraries)

El esquema de enlace con librerías estáticas funciona bien, pero tiene algunas características que pueden ser muy indeseables:

  1. Si dos o más programas se enlazan a la librería estática, cada ejecutable tendrá añadido el mismo código repetido (desperdicio de espacio de disco)

  2. Como consecuencia de lo anterior, cuando estos ejecutables se cargan en memoria, ésta se llena ineficientemente con código repetido

  3. Si descubrimos un problema en nuestra librería y logramos corregirlo, entonces debemos volver a enlazar todos los programas que utilizaban la versión defectuosa. En la práctica, esto significa generar y reinstalar todos los ejecutables.

Las librerías compartidas (conocidas como shared libraries o dynamic libraries) resuelven todos estos problemas de un modo bastante completo, a costa de cierta complejidad añadida.

Lamentablemente el procedimiento necesario para hacer esto no es totalmente estandarizado entre Unix’es, y lo que haremos aquí (Linux/gcc) puede requerir adaptaciones para otros ambientes.

El siguiente Makefile permite obtener la librería "libstringplus.so" y enlazarla con "prueba.c":

# Crear un archivo de libreria dinamica
# 
OBJECTS = replace.o trim.o substr.o
CFLAGS = -fpic

prueba: prueba.c libstringplus.so
	gcc -o $@ prueba.c libstringplus.so
#	gcc -L. -o $@ prueba.c -lstringplus

libstringplus.so: $(OBJECTS)
	gcc -shared -o $@ $(OBJECTS)

clean:
	rm -f prueba *.o libstringplus.so

Convencionalmente, las librerías dinámicas tienen un nombre de archivo de la forma libXXX.so.v.m.r [37] , donde la extensión "*.so" indica que se trata de un "shared object"; "v", "m" y "r" son enteros positivos: "v" corresponde a la "versión" de la librería, "m" es el "número menor", y "r" el "release". Diferentes valores para el número "v" significan diversas versiones incompatibles de la librería [38] ; por el contrario, distintos valores de "m" corresponden a versiones compatibles de la misma [39] , y finalmente "r" (que es opcional) complementa a "m" permitiendo indicarle mayor granularidad. En conjunto, estos números permiten la coexistencia de distintas versiones de una librería.

Por simplicidad, en lo que sigue no emplearemos los números "v", "m" y "r", aunque cumplen una función fundamental para el correcto manejo de "dependencias" de las aplicaciones, por lo que se recomienda leer el "Programming Library HOWTO" http://www.dwheeler.com/program-library .

Usando gcc, la opción -shared genera librerías compartidas mientras que la opción -fpic [40] genera código máquina con "direcciones independientes de la posición", lo cual es indicado para todos los módulos constituyentes de las librerías compartidas (mas no es necesario para los programas que las invocan. [41] )

Ahora bien, esta "shared library" por definición NO está contenida en el archivo ejecutable con quien está enlazada. Por ejemplo, nuestro ejecutable (prueba) sólo contiene una referencia a “libstringplus.so” [42] , la que deberá ser cargada a memoria recién en el momento de la ejecución del programa.

Esto significa que si queremos llevar nuestro ejecutable “prueba” a otro computador, también tendremos que llevar por separado “libstringplus.so” [43] .

Para apreciar a qué shared library’s hace referencia nuestro ejecutable prueba, podemos usar el comando ldd:

$ ldd prueba
libstringplus.so => not found
libc.so.6 => /lib/libc.so.6 (0x40029000)
/lib/ld-linux.so.2 => /lib/ld-linux.so.2 (0x40000000)
$

Como se aprecia, nuestra librería está referenciada aunque no está siendo encontrada (ver mensaje "not found".) Esto se debe a que las shared library’s tienen que colocarse en ciertos directorios especiales en el sistema previamente a su uso.

Si tratamos de ejecutar el programa, éste fallará:

$ ./prueba
./prueba: error while loading shared libraries: libstringplus.so:
cannot open shared object file: No such file or directory

Una manera temporal de solucionar esto consiste en especificar la variable de entorno “LD_LIBRARY_PATH” con el directorio en el que se encuentra “libstringplus.so”. En nuestro caso, es el directorio actual ("."):

$ LD_LIBRARY_PATH=. ./prueba
to_replace:[Erre con erre guitarra]
from_replace(rr,abc):[Eabce con eabce guitaabca]

to_substr:[La tierra es guera]
from_substr(3,6):[tierra]

to_trim:[   There goes the Challenger    ]
from_trim:[There goes the Challenger]

3.3.3. Enlace en tiempo de ejecución

La solución de largo plazo consiste en "instalar" nuestra librería en el sistema. Para esto podemos hacer lo siguiente:

$ su
Password:
# cp libstringplus.so /usr/lib
# exit
$ ldd prueba
libstringplus.so => /usr/lib/libstringplus.so (0x40029000)
libc.so.6 => /lib/libc.so.6 (0x4002b000)
/lib/ld-linux.so.2 => /lib/ld-linux.so.2 (0x40000000)
$ ./prueba
to_replace:[Erre con erre guitarra]
...

Parece que todo se resuelve mágicamente llevando la librería al directorio /usr/lib. Lo que ocurre es que éste directorio (al igual que /lib y otros más) es uno de los lugares preconfigurados donde el "enlazador de tiempo de ejecución" busca las shared libraries. Por ejemplo, en el sistema (Linux) que estoy utilizando, las rutas son [44] :

$ cat /etc/ld.so.conf
/usr/kerberos/lib
/usr/X11R6/lib
/usr/lib/qt-3.0.5/lib
/usr/lib/sane
/usr/lib/mysql
/usr/lib/qt2/lib
/usr/lib/wine

A éstas se les añade automáticamente /lib y /usr/lib. Esta información se puede hallar en el man page de “ld.so”.

Para resumir esta sección, cuando distribuimos (hacia usuarios finales) un programa enlazado dinámicamente con la librería "stringplus" (como por ejemplo, prueba), entonces también debemos proporcionarle la librería libstringplus.so, o al menos indicarle que ésta es requisito para que el programa funcione.

Por otro lado, cuando querramos distribuir nuestra librería a otro desarrollador, proporcionaremos los archivos [45] :

  • stringplus.h

  • libstringplus.so

(y la explicación correspondiente.)

3.3.4. La opción "-l" del compilador

Si se observa con atención el último Makefile, observará que para la compilación de prueba (tras haber generado la shared library) hemos indicado el enlace a la libstringplus.so simplemente especificando el nombre completo de tal archivo:

	gcc -o $@ prueba.c libstringplus.so

Esto funciona bien puesto que la librería está en nuestro directorio actual.

Sin embargo, muchas veces enlazamos nuestros programas con librerías que no hemos construido nosotros y que están instaladas en diversos directorios del sistema. En ese caso, el comando hipotético podría ser algo como:

	gcc -o $@ prueba.c /usr/lib/libstringplus.so

Sin embargo, el directorio donde se ubican las librerías es variable, y sería engorroso tener que buscarlas para cada caso.

A tal efecto, la opción "-l" del compilador [46] permite que especifiquemos tan solo el nombre de la librería (con -lXXX) y automáticamente se buscará en todos los directorios adecuados el archivo “libXXX.so”. Nótese que para que esto funcione, es necesario que el archivo tenga un nombre con este formato (iniciado con "lib" y terminado en ".so".)

Lamentablemente, esto crea un nuevo inconveniente: el enlazador ld no considera al directorio actual (.) como posible lugar de búsqueda. A fin de añadirlo a la lista de directorios, empleamos la opción "-L" del compilador [47] . Finalmente, el nuevo comando para generar el ejecutable (en el Makefile) es:

	gcc -L. -o $@ prueba.c -lstringplus

3.4. Ejecución dinámica con dlopen

La interfaz dlopen proporciona un mecanismo para ejecutar código compilado en demanda, lo que proporciona un grado de flexibilidad extremo. Proporcionaremos un nuevo ejemplo para hacer la respectiva demostración [48] .

3.4.1. Ejemplo: Factoriales Reimplementados

A continuación tres implementaciones que nos permiten obtener el factorial de un número entero (retornando un entero largo.)

3.4.2. Con loop simple

/*
 * Calculo de un factorial con un loop repetido
 */

long factorial(int n)
{
long f=1;

do
	f*=n;
while(--n);
return f;
}

3.4.3. Con recursividad

/*
 * Calculo de un factorial usando recursividad
 */

long factorial(int n)
{
if(n>1) return n*factorial(n-1);
else return 1L;
}

3.4.4. Con fórmula aproximada

/*
 * Calculo de un factorial usando formula
 * de Stirling
 */

#include <math.h>

#define PI 3.14159265358979323846

long factorial(int n)
{
double f;

return exp(-n)*pow(n,n+0.5)*sqrt(2.0*PI)*
	(1.0+1.0/(12.0*n)+1.0/(288.0*n*n));
}

Aunque no era necesario, me parece más didáctico haber creado las tres implementaciones mediante una función que tiene el mismo nombre (factorial.) Como se sabe, sería imposible crear un programa que acceda a estas tres funciones a la vez (¿cómo las podría distinguir?.)

Pero con dlopen (y compañía) sí podemos. El siguiente programa hace uso de éstas implementaciones de factorial dependiendo de lo que el usuario escoge:

#include <stdio.h>
#include <dlfcn.h>
#include <errno.h>

//long factorial(int);

main(int argc,char **argv)
{
int n,m;
void *obj;
long (*fact)(int);

printf("Escriba el numero > ");
scanf("%d",&n);
printf("Que metodo usamos? (1-loop 2-recursividad 3-Stirling) > ");
scanf("%d",&m);

switch(m)
	{
	case 1: obj=dlopen("./fact1.so",RTLD_NOW); break;
	case 2: obj=dlopen("./fact2.so",RTLD_NOW); break;
	case 3: obj=dlopen("./fact3.so",RTLD_NOW); break;
	default:
	fprintf(stderr,"Metodo invalido!\n");exit(1);
	}
if(obj==NULL)
	{ fprintf(stderr,"dlopen: %s\n",dlerror()); exit(1); }

fact=dlsym(obj,"factorial");
if(fact==NULL)
	{ fprintf(stderr,"dlsym: %s\n",dlerror()); exit(1); }

printf("%ld\n",(*fact)(n));
}

Nótese que nuestro programa accede a shared libraries (.so) aunque -al menos en Linux- también puede acceder a archivos objeto (.o). A tal efecto, el siguiente Makefile permite compilar todo:

LIBS = fact1.so fact2.so fact3.so
CFLAGS = -fpic

all: $(LIBS) prueba

%.so:%.o
	gcc -shared -o $@ $<

prueba: prueba.c
	gcc -o prueba prueba.c -ldl

clean:
	rm -f *.o *.so prueba

Obsérvese que "prueba" no se enlaza con ninguna de las implementaciones de factoriales, pero sí debe hacerlo con la librería "libdl.so" (opción -ldl) que proporciona estas capacidades. Note también la inclusión del header "dlfcn.h".

A continuación una muestra de su ejecución:

$ ./prueba
Escriba el numero > 5
Que metodo usamos? (1-loop 2-recursividad 3-Stirling) > 1
120
$ ./prueba
Escriba el numero > 5
Que metodo usamos? (1-loop 2-recursividad 3-Stirling) > 2
120
$ ./prueba
Escriba el numero > 5
Que metodo usamos? (1-loop 2-recursividad 3-Stirling) > 3
dlopen: ./fact3.so: undefined symbol: sqrt

Casi listo, pero en el caso de “fact3” se requería enlazar con la librería matemática (que define la función sqrt().) Esto es exclusivo de fact3, por lo que lo enlazamos en el momento de su creación:

LIBS = fact1.so fact2.so fact3.so
CFLAGS = -fpic

all: $(LIBS) prueba

%.so:%.o
	gcc -shared -o $@ $<

fact3.so: fact3.o
	gcc -shared -o $@ $< -lm

prueba: prueba.c
	gcc -o prueba prueba.c -ldl

clean:
	rm -f *.o *.so prueba

Como se ve, hemos añadido una regla explícita para el caso de fact3.so que incluye a -lm [49]. Esta vez, la ejecución no falla.

3.4.5. Handlers y símbolos

Como se aprecia, nuestro programa central accede a las "shared libraries" mediante la función dlopen(). Ésta retorna un puntero tipo (void *) que sirve para hacer referencia al contenido de la misma (o NULL en caso de error.) Obsérve que requiere especificar la ruta completa (en este caso con “./”) pues en caso contrario buscará la librería sólo en los lugares preconfigurados del sistema (ver discusión sobre esto más arriba en la sección de creación de shared libraries [50]. ) Pruebe a usar "fact1.so" en lugar de "./fact1.so" para que lo compruebe.

Teniendose el "handler" a la shared library, es posible obtener un puntero a cualquier objeto de la misma (una función o una variable.) Para esto se emplea la función dlsym() que recibe como argumento el nombre de este objeto (lo que en las librerías se conoce como un símbolo.) Estos punteros deben ser del tipo adecuado al objeto al que se está referenciando (en nuestro caso, un puntero a función que recibe "int" y retorna "long".)

Cuando el programa ya no requiere acceder a estos objetos, el "handler" obtenido con dlopen() debe cerrarse con dlclose() (nuestro programa de prueba finaliza de inmediato, por lo que no vale la pena la molestia.)

3.5. Problemas Frecuentes

Referencias no resueltas/circulares

A continuación proporcionamos los archivos constituyentes de dos librerías triviales, y un programa que hace uso de éstas. Esto nos permitirá mostrar un error que aparece con relativa frecuencia, generado por referencias no resueltas:

Código fuente de la librería "a.a"
/*
 * a.c
 */

void fa()
{
}
Código fuente de la librería "b.a"
/*
 * b.c: Llama a fa()
 */
extern void fa(void);

void fb()
{
fa();
}
Código fuente de programa de prueba
/*
 * prueba.c
 */
extern void fb(void);

int main(void)
{
fb();
return 0;
}
Compilación y explicación

A continuación el Makefile originalmente diseñado para compilar esto:

all: prueba

prueba: prueba.c a.a b.a
	gcc -o $@ prueba.c a.a b.a
a.a: a.o
	ar rcs $@ a.o
b.a: b.o
	ar rcs $@ b.o
clean:
	rm -f a.o b.o a.a b.a prueba

Y la salida cuando se utiliza:

cc    -c -o a.o a.c
ar rcs a.a a.o
cc    -c -o b.o b.c
ar rcs b.a b.o
gcc -o prueba prueba.c a.a b.a
b.a(b.o)(.text+0x7): In function `fb':
: undefined reference to `fa'
collect2: ld returned 1 exit status
make: *** [prueba] Error 1

Para comprender la falla, debemos entender cómo opera el enlazador sobre los archivos que se le proporcionan. Si vemos los comandos ejecutados, apreciaremos que la última invocación al compilador gcc compila el archivo “prueba.c” (lo que genera el módulo objeto prueba.o) e invoca al enlazador con los siguientes argumentos (en el orden indicado):

  • prueba.o

  • a.a

  • b.a

El enlazador analiza el primer archivo. En tanto prueba.o sólo hace referencia a la función fb(), el enlazador toma nota de que ésta debe estar definida en algún módulo subsiguiente. Es decir, hasta este momento lo único que "hace falta" es la implementación de fb().

Luego se procesa a.a. En tanto esta librería no contiene fb(), el enlazador asume que su contenido no se necesita, y se ignora completamente. Esto parece un descuido, pero es la manera normal en que se comporta el enlazador [51] .

Finalmente se procesa b.a. Dado que contiene a fb(), su código se toma en consideración para el proceso. Sin embargo, fb() hace referencia a una cierta función fa(). El enlazador toma nota de esto, y asume que fa() está implementada en un módulo todavía no analizado. Como b.a es el último módulo, el enlazador no puede resolver la referencia pendiente fa() y falla.

Una manera rápida (y correcta) de resolver esto, consiste simplemente en intercambiar el orden en que se proporcionan los archivos al enlazador:

gcc -o $@ prueba.c b.a a.a

Sin embargo, existen casos más sofisticados (con más archivos participantes) en los cuales no es suficiente con jugar con el orden de los mismos. En tales casos se puede hablar de referencias circulares. Para resolver esto, a veces basta con repetir los archivos a enlazar (por ejemplo, gcc -o $@ prueba.c a.a b.a a.a) o cambiar el comportamiento del enlazador para que "regrese" a buscar referencias no resueltas en los módulos especificados con anterioridad. Para obtener este comportamiento, se puede utilizar la siguiente (extraña) sintaxis [52] :

gcc -o $@ prueba.c -Wl,--start-group -Wl,a.a -Wl,b.a -Wl,--end-group

La opción -Wl, de gcc, permite pasar opciones directamente al enlazador ld. En el enlazador, las opciones --start-group y --end-group permiten encerrar un conjunto de librerías sobre las cuales ld tendrá el comportamiento señalado arriba.

3.5.1. Forzar exportación de símbolos (dlopen)

En el ejemplo que mostramos a continuación el programa principal (prueba.c) invoca mediante dlopen() a la librería “checksum” que contiene la función del mismo nombre. Esta función requiere tener acceso al string llamado “compartido”, el cual está definido en el programa prueba.c:

Archivos de ejemplo:
/*
 * prueba.c: Invocar a checksum()
 */
#include <dlfcn.h>
#include <stdio.h>
#include <errno.h>
#include <stdlib.h>

char *compartido="Habia una vez, un andinista";

int main(int argc,char **argv)
{
void *obj;
void (*cksum)(void);

obj=dlopen("./checksum.so",RTLD_NOW);
if(obj==NULL)
	{ fprintf(stderr,"dlopen: %s\n",dlerror()); exit(1); }

cksum=dlsym(obj,"checksum");
if(cksum==NULL)
	{ fprintf(stderr,"dlsym: %s\n",dlerror()); exit(1); }

(*cksum)();
return 0;
}
/*
 * checksum.c: Hallar checksum a string "compartido"
 */
#include <stdio.h>
#include <string.h>

extern char *compartido;

void checksum(void)
{
int z;
unsigned short int s=0;
for(z=0;z<strlen(compartido);z++)
	s+=(unsigned char)compartido[z];
printf("Checksum=%hd\n",s);
}
# 
# Makefile para mostrar fallo de export-dynamic
# 
LIBS = checksum.so
CFLAGS = -fpic -Wall

all: $(LIBS) prueba

%.so:%.o
	gcc -shared -o $@ $<

prueba: prueba.c
	gcc -Wall -o prueba prueba.c -ldl

clean:
	rm -f *.o *.so prueba
Ejecución y explicación
$ ./prueba
dlopen: ./checksum.so: undefined symbol: compartido

El programa falla cuando se intenta cargar la librería compartida debido a que no se ha podido resolver el símbolo "compartido". Los símbolos accesibles dinámicamente tienen que estar registrados en la llamada "tabla de símbolos dinámicos", la cual normalmente sólo incluye aquellos que son referenciados por otro módulo en el momento del enlace.

En el caso de prueba.c, el enlazador no tiene forma de saber que “compartido” está referenciado por checksum.c (pues se enlazan separadamente) y por tanto no se incluye en la tabla.

La forma más simple de evitar este problema es forzar la exportación de todos los símbolos hacia la tabla mencionada, para lo cual bastará con añadir la opción --export-dynamic del enlazador; por tanto, desde el compilador, usaremos -Wl,--export-dynamic [53] :

$ gcc -Wall -Wl,--export-dynamic -o prueba prueba.c -ldl
$ ./prueba
Checksum=2488

El caso de “fact3.so” es interesante y nos permite ilustrar el segundo argumento de dlopen (actualmente RTLD_NOW.) “fact3.so” es un caso especial debido a que hace referencia a "símbolos externos", es decir, rutinas o funciones que están contenidas fuera de ella. Por ejemplo, se invoca a las rutinas “exp()” y “sqrt()” que están implementadas en la librería matemática estándar (libm.so [54] .)

Como vimos, “fact3.so” tiene a libm.so como una dependencia:

	fact3.so -> libm.so

Compilemos nuevamente a “prueba” pero sin la librería matemática libm.so:

$ gcc -o prueba prueba.c -ldl
$ ./prueba
Escriba el numero > 5
Que metodo usamos? (1-loop 2-recursividad 3-Stirling) > 1
120
$ ./prueba
Escriba el numero > 3
Que metodo usamos? (1-loop 2-recursividad 3-Stirling) > 3
dlopen: ./fact3.so: undefined symbol: sqrt

Como se aprecia, en el tercer caso la ejecución de dlopen falla debido a que no puede resolverse (al menos) el símbolo “sqrt()” que es usado por la fórmula de Stirling. Nótese que todavía no se ha invocado a dlsym() y menos a la función factorial().

Ahora cambiaremos el modo de trabajo de dlopen. Modifique esta línea:

case 3: obj=dlopen("./fact3.so",RTLD_NOW); break;

Por esta:

case 3: obj=dlopen("./fact3.so",RTLD_LAZY); break;

Volvemos a compilar sin libm.so y probamos:

$ gcc -o prueba prueba.c -ldl
$ ./prueba
Escriba el numero > 4
Que metodo usamos? (1-loop 2-recursividad 3-Stirling) > 3
./prueba: relocation error: ./fact3.so: undefined symbol: exp

Como se aprecia, ni dlopen ni dlsym fallaron, y el programa cayó (aparatosamente) durante su intento de invocar a la rutina “exp()”. En otras palabras, la resolución de los símbolos se ha postergado hasta el momento de su uso.

Recapitulando, el segundo argumento para dlopen() puede ser RTLD_NOW o RTLD_LAZY. En el primer caso, todos los símbolos externos referenciados por la shared library deben ser resueltos tan pronto se carga la misma (de lo contrario la carga falla); en el segundo estos son resueltos conforme se utilizan.

¿Qué método debemos usar? El primer método parece ser el más seguro pues el programador podría tomar una acción correctiva cuando dlopen() falla desde el inicio. Sin embargo, el segundo método también tiene sus ventajas. Imaginemos por un momento una shared library que tiene la capacidad de interactuar con tres tipos distintos de bases de datos: Informixe, Oracler y Sybases. Esta librería implementa las siguientes funciones, las cuales tienen sus correspondientes dependencias:

  1. rutina1_informixe() → libinformixe.so

  2. rutina2_oracler() → liboracler.so

  3. rutina2_sybases() → libsybases.so

Lo más probable es que el usuario sólo tenga instalada una sola de las bases de datos, por lo que sólo dispone en su sistema de, por ejemplo, libsybases.so.

Si un programa utilizara dicha librería con el método RTLD_NOW, entonces forzaría al usuario a tener instaladas las 3 shared libraries que actúan como dependencias. Esto es absurdo y casi imposible en la práctica.

3.6. Ejercicios

1 La librería de textos

Añada a nuestra librería de textos algunos las siguientes mejoras: .. En replace() elimine el tamaño prefijado de 1K para el string resultado .. En substr(), permita argumentos numéricos negativos tal como ocurre en \ diversos lenguajes como PHP, Pyhton, Java .. Añada una función split() que permita "partir" una cadena de texto \ en un array de varias cadenas constituyentes mediante un delimitador \ pasado como argumento

3.6.1. Números complejos

El programa que dibuja un fractal de Mandelbrot del capítulo 12 hace uso de un conjunto de rutinas que implementan operaciones de números complejos. Pruebe a adaptar y completar este código separándolo en una shared library. Adapte el programa de dibujo del fractal para que la emplee.

Invocar a la librería matemática

Mediante dlopen acceda a la librería matemática libm.so [55] . Solicite al usuario el nombre de una función y un valor numérico real. Estas funciones deben corresponder a aquellas definidas en la librería matemática y serán invocadas con el valor introducido. Por ejemplo:

Nombre de funcion? sin
Argumento? 0.1
Resultado: .09983341664682815230
Nombre de funcion? klimb
Error: 'klimb' no se encuentra!
Nombre de funcion?
Símbolos dinámicos del programa

En el ejemplo de cálculo de factoriales con dlopen(), añada una nueva implementación al archivo prueba.c:

long factorial(int x)
{
return exp(lgamma(x+1.0));
}

Asegúrese de incluir el header math.h. Añada una opción adicional (método '4') para invocar a esta implementación. A fin de acceder a ésta, utilice dlopen() pero especificando NULL en el argumento que especifica la librería compartida.

¿Se requiere de --export-dynamic? Posiblemente el método '2' (recursividad) empiece a arrojar valores erróneos. Encuentre la explicación.

INTERFAZ CON EL SISTEMA OPERATIVO

4. Llamadas al Sistema vs Librería Estándar

Las llamadas al sistema se consideran la interfaz de más bajo nivel para acceder al sistema operativo Unix/Linux, sólo por encima de la programación del mismo kernel. Muchas de éstas son accesibles indirectamente mediante la librería estándar. En este capítulo intentaremos explicar la relación entre ambos métodos mediante ejemplos ilustrativos.

4.1. Conceptos

Como se sabe, el sistema operativo Linux (y todos los Unix’es) consisten, entre otros componentes, de un programa principal denominado "núcleo" o "kernel" el cual administra la ejecución de los procesos así como los recursos de hardware. Cuando un proceso en ejecución desea hacer uso de los recursos del computador (por ejemplo, reservar un área de memoria), debe hacer una solicitud al kernel. Éste es quien finalmente proporciona (o deniega) el recurso al proceso.

Estas "solicitudes" se conocen como llamadas al sistema (system calls), las cuales, desde el punto de vista del programador, lucen como una interfaz de programación (API) para interactuar con los recursos de hardware y otros procesos. Se puede considerar una librería, pero cuya implementación subyace en el kernel, lo que la hace muy especial.

Un caso interesante corresponde a la interacción con los archivos de disco. Según veníamos diciendo, el acceso a los recursos del computador (como los discos) se debe hacer mediante solicitudes al kernel (llamadas al sistema.) Si leemos la documentación adecuada, encontraremos que la llamada al sistema que permite escribir información en un archivo se llama write(2). Sin embargo, la mayoría de programadores de C aprenden que una forma típica de hacer lo mismo consiste en invocar a la rutina fprintf de la librería estándar. Surge la pregunta, ¿Por qué hay dos métodos para lograr lo mismo? Es decir, ¿Para qué disponemos de la librería estándar si el kernel ya nos proporciona una llamada al sistema?

Podemos responder de las siguientes maneras: . La librería estándar puede permitirnos una ejecución más eficiente \ mediante la asignación de buffers de lectura/escritura . La librería estándar presenta más flexibilidad al \ programador que las llamadas al sistema. Es decir, es más fácil \ interactuar con las rutinas de I/O de la libería estándar que con \ las correspondientes llamadas al sistema [56]. . En un sistema operativo distinto, las llamadas al sistema pueden \ ser completamente distintas (pues el kernel será distinto), pero \ si sólo usamos la librería estándar (que, aunque suena \ repetitivo, está estandarizada) entonces nuestro programa podrá \ seguir siendo válido en aquel otro sistema operativo.

A estas alturas el lector debe estarse preguntando: Si hay tantas ventajas con la librería estándar, ¿para qué complicarnos con las llamadas al sistema?

Ocurre que la librería estándar, en tanto tiene que ser "implementable" en muchos sistemas operativos, tiene que hacer un compromiso y recoger lo más común, frecuente y/o representativo de éstos. En otras palabras, termina implementando el "mínimo común denominador" de todos ellos, por lo que se abandona la funcionalidad que precisamente distingue a cada sistema operativo. A veces esta funcionalidad distintiva es justamente lo que necesitamos.

En otras palabras, las llamadas al sistema permiten potencialmente hacer cosas que con la librería estándar no se puede.

El lector podría pensar que usar las llamadas al sistema significa destruir la portabilidad. Esto no es necesariamente cierto; más bien, se llega a un compromiso distinto: si bien la "librería estándar" es casi universalmente portable, las "llamadas al sistema" estandarizadas son muy portables entre diversas variantes de Linux y Unix [57] . Sin embargo, subyace el hecho de que para muchos programas las llamadas al sistema no son imprescindibles; en tales casos NO se deben utilizar a fin de no perder ni un ápice de portabilidad footnote:[ Las "llamadas al sistema" mencionadas (como read(), getuid(), etc) al menos en Linux no son realmente "llamadas al sistema" sino subrutinas pertenecientes al gran conjunto de librerías "GNU libc". El kernel proporciona una interfaz única (implementada con una interrupción) que recibe un código numérico que identifica la funcionalidad que se desea (por ejemplo, la funcionalidad de read(), getuid(), etc.) y los parámetros necesarios.

En algunos sistemas (por ejemplo RedHat 8.0, Debian 3.1), estos "códigos de llamada al sistema" se pueden ver en el archivo /usr/include/asm/unistd.h. Tenga en cuenta que esto no es portable. También vea el manual de syscall(2) y syscalls(2) si tiene curiosidad. ] .

4.1.1. Manejo de errores

Convencionalmente, la mayoría de llamadas al sistema retornan "-1" en caso de error; sin embargo, esto no nos indica nada acerca de la naturaleza del error. Para obtener dicha información se puede consultar la variable global errno (asumiendo que se incluye el header <errno.h>, la cual corresponde a un número entero positivo que tiene significados pre-establecidos de diversos errores. Dicha variable es empleada como mecanismo para detallar errores tanto de por las llamadas al sistema como por la librería estándar.

La función perror(3) es parte de la librería estándar, y tiene como objetivo imprimir una descripción acerca del valor actual de errno. Otra forma de obtener esta información (sin necesidad de programar) consiste en navegar dentro de algunos archivos header tales como /usr/include/errno.h o /usr/include/asm/errno.h [58] .

4.1.2. Extensiones a la librería estándar

La librería estándar de lenguaje C (especificada en los estándares ANSI/ISO) está diseñada para ser independiente del sistema operativo. Sin embargo, existen muchos casos en los que es conveniente disponer de rutinas que, sin ser llamadas al sistema, desempeñen tareas que tienen relación directa con el sistema operativo en uso.

Con este fin, diversas versiones de Unix han estandarizado un conjunto de extensiones a la "librería estándar". Estas extensiones comprenden, por ejemplo, rutinas para leer archivos de configuración, navegar en directorios, encriptar passwords, etc. Dependiendo del sistema operativo en uso, algunas de estas rutinas se documentan como llamadas al sistema y viceversa.

4.2. Ejemplos Ilustrativos

En lo que sigue de este capítulo presentaremos algunos ejemplos en los que un mismo programa se implementa tanto con llamadas al sistema como mediante la librería estándar. En los capítulos subsiguientes se describen programas que dependen exclusivamente de llamadas al sistema.

4.2.1. Asignación Dinámica de Memoria

Los sistemas Unix proporcionan la llamada al sistema sbrk(2) [59] , encargada de extender o recortar el área de datos disponible para que un proceso manipule información; en otras palabras, permite reservar más memoria para el proceso, o liberarla. A continuación se presenta un ejemplo sencillo (probado en Linux) en donde el segmento de datos se expande en 20,000 bytes, los cuales son modificados y luego leídos:

/*
 * sbrk_block: reserva de bloque con sbrk
 */
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>

#define BS 20000
int main()
{
char *ptr;
ptr=sbrk(BS);
if(ptr==(char*)-1) exit(1);
int z;
for(z=0;z<BS;z++)
	ptr[z]=z%128;
long t=0;
for(z=0;z<BS;z++)
	t+=ptr[z];
printf("Total=%ld\n",t);
return 0;
}

Su equivalente mediante la librería estándar es muy similar:

/*
 * malloc_block: Reserva de bloque con malloc
 */
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>

#define BS 20000
int main()
{
char *ptr;
ptr=malloc(BS);
if(ptr==NULL) exit(1);
int z;
for(z=0;z<BS;z++)
	ptr[z]=z%128;
long t=0;
for(z=0;z<BS;z++)
	t+=ptr[z];
printf("Total=%ld\n",t);
return 0;
}

Hasta aquí todo parece indicar que ambos métodos son similares y se podrían emplear indistintamente, pero esto NO es cierto. El lector NUNCA debería emplear sbrk en sus programas (si dispone de malloc) debido a:

  1. sbrk (y brk) no están especificadas en POSIX y su \ comportamiento es muy variado por lo que son poco portables

  2. La liberación de memoria con sbrk requiere reducir el segmento \ de datos pasándole un valor negativo; esto podría eliminar la \ información que se encuentra "al final" del segmento

  3. La liberación de memoria con sbrk simplemente no es posible \ en algunos sistemas. Evidentemente malloc tampoco podrá llevar a \ cabo la liberación, pero nos evita tener que considerar este caso por \ separado

  4. sbrk en ciertos sistemas no agranda el segmento de datos de \ manera contínua, sino dejando "huecos" que deben administrarse \ cuidadosamente. Malloc nos evita tener que controlar esto

  5. Para múltiples asignaciones de bloques pequeños, es más eficiente hacer \ una única invocación a sbrk, y administrar manualmente las posiciones \ de los pequeños bloques. Malloc se encarga de esto automáticamente

  6. En ciertos sistemas existen alternativas mejores que sbrk (para \ ciertas circunstancias.) Malloc se encarga de elegir la mejor estrategia \ para realizar la asignación de manera óptima

  7. La librería estándar proporciona las rutinas complementarias calloc \ y realloc que resultarían muy tediosas de implementarse a cada momento \ si se usa sbrk.

Como se aprecia, para este caso está muy claro que los programas siempre deberán preferir la librería estándar en lugar de las llamadas al sistema.

4.2.2. Borrar archivos y directorios

Los programas que presentamos a continuación eliminan el archivo o directorio especificado como primer argumento en la línea de comando. En el caso de las llamadas al sistema, se debe emplear unlink(2) o rmdir(2) respectivamente, mientras que la librería estándar posee la rutina remove(3) para ambos casos.

Con llamadas al sistema:

/*
 * unlink_rmdir: borra archivo o directorio en
 * primer argumento
 */
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>

int main(int argc,char **argv)
{
struct stat s;
if(argc!=2)
	return 1;
if(stat(argv[1],&s)==-1)
	return 2;
if(S_ISDIR(s.st_mode))
	{
	if(rmdir(argv[1])==-1)
		return 3;
	return 0;
	}
if(unlink(argv[1])==-1)
	return 4;
return 0;
}

Nótese el uso de la llamada al sistema stat(2) que se emplea para obtener información acerca de un archivo. A fin de discernir si estamos ante un archivo regular o un directorio, se puede emplear el macro S_ISDIR() sobre el miembro st_mode de la estructura de tipo struct stat [60] . El manual de stat(2) proporciona la explicación de los miembros de dicha estructura.

Con librería estándar el listado (remove.c) es más corto:

/*
 * remove: borra archivo o directorio en
 * primer argumento
 */
#include <stdio.h>

int main(int argc,char **argv)
{
if(argc!=2)
	return 1;
if(remove(argv[1])==-1)
	return 2;
return 0;
}

En conclusión, para este caso la librería estándar resulta mucho más cómoda, aunque las llamadas al sistema permiten acceder a más información que puede ser útil para casos más sofisticados.

4.2.3. I/O a Disco

El siguiente programa (file_write) escribe un millón de bytes hacia un archivo mediante la llamada al sistema write(2):

/*
 * file_write: Escribir un millon de bytes
 * con write(2)
 */
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int main()
{
int fd=open("test.dat",O_WRONLY|O_CREAT,0777);
if(fd==-1)
	return 1;
int z;
char x;
for(z=0;z<1000000;z++)
	{
	x=z%127;
	write(fd,&x,1);
	}
close(fd);
return 0;
}

La versión que hace uso de la librería estándar es muy similar:

/*
 * file_fputc: Escribir un millon de bytes
 * con fputc(3)
 */
#include <stdio.h>

int main()
{
FILE *fp=fopen("test.dat","w");
if(fp==0)
	return 1;
int z;

for(z=0;z<1000000;z++)
	fputc(z%127,fp);
fclose(fp);
return 0;
}

Sin embargo, los tiempos logrados por la librería estándar corresponden a la 40-ava parte de la implementación con llamadas al sistema [61] :

$ time ./file_write

real    0m4.409s
user    0m0.130s
sys     0m4.250s
$ time ./file_fputc

real    0m0.107s
user    0m0.080s
sys     0m0.010s

La librería estándar a final de cuentas tiene que invocar a las llamadas al sistema que escriben en disco (como write); sin embargo, aquella emplea un buffer intermedio que es llevado al disco (mediante write) sólo cuando se llena (o cuando se cierra el archivo.) Esto reduce el número de invocaciones a write, y permite ilustrar el hecho de que la sola invocación a una llamada al sistema es -en términos de tiempo- mucho más costosa que la invocación de rutinas de cualquier librería.

En el capítulo 8 se trata las llamadas al sistema para I/O.

4.2.4. Temporizadores

A continuación se presenta un programa que imprime asteriscos a intervalos de un segundo. El retardo se programa mediante la rutina sleep(3) que es parte del estándar POSIX y se encuentra en prácticamente cualquier sistema operativo. sleep tiene una granularidad de 1 segundo, lo que significa que no es posible especificar retardos de una décima o una milésima de segundo.

/*
 * star_sleeep: Imprimir estrellas cada segundo
 */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main()
{
int z,k;

for(;;)
	{
	k=rand()%80;
	for(z=0;z<k;z++)
		putchar(' '); /* Un espacio */
	printf("*\n");
	sleep(1);
	}
return 0;
}

Para mostrar cómo se pueden hacer estos retardos más granulares, el listado 2 presenta el mismo programa pero usando la rutina usleep(3) que es parte del estándar del Unix BSD, y que también se encuentra en Linux. Esta rutina tiene una granularidad de una millonésima de segundo [62] .

/*
 * star_usleep: Imprimir estrellas cada decima
 */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main()
{
int z,k;

for(;;)
	{
	k=rand()%80;
	for(z=0;z<k;z++)
		putchar(' '); /* Un espacio */
	printf("*\n");
	usleep(100000); /* Cien mil */
	}
return 0;
}

Lamentablemente, la función usleep no es parte de los estándares actuales de Unix y se considera obsoleta. Normalmente se implementa en términos de la llamada al sistema nanosleep(2) o select(2).

El mismo programa, pero con la llamada al sistema nanosleep(2) (star_nanosleep.c):

/*
 * star_nanosleep: Imprimir estrellas cada decima
 */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <time.h>

void retardo(void)
{
struct timespec ts;
ts.tv_sec = 0; 
ts.tv_nsec = 100000000;  /* 0.1 segundos */
nanosleep (&ts,NULL);
}

int main()
{
int z,k;

for(;;)
	{
	k=rand()%80;
	for(z=0;z<k;z++)
		putchar(' '); /* Un espacio */
	printf("*\n");
	retardo();
	}
return 0;
}

Finalmente, la versión con select(2) (star_select.c) [63] :

/*
 * star_select: Imprimir estrellas cada decima
 */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/select.h>

void retardo(void)
{
struct timeval tv;
tv.tv_sec = 0;
tv.tv_usec = 100000;  /* 0.1 segundos */
select (0, NULL, NULL, NULL, &tv);
}

int main()
{
int z,k;

for(;;)
	{
	k=rand()%80;
	for(z=0;z<k;z++)
		putchar(' '); /* Un espacio */
	printf("*\n");
	retardo();
	}
return 0;
}

En este caso parece ser que las llamadas al sistema (como nanosleep) llevan la ventaja.

4.2.5. Lista de Grupos

El programa que se presenta a continuación muestra cómo las llamadas al sistema y las rutinas de la librería estándar (o de POSIX) se complementan. Se trata de obtener la lista de los grupos a que pertenece el usuario que lanzó el proceso en ejecución.

Empezamos obteniendo el UID del proceso, lo cual es inmediato gracias a la llamada al sistema getuid(2).

Luego, dado el UID es sencillo buscar la entrada correspondiente en el archivo /etc/passwd y obtener el nombre del usuario así como el GID del grupo principal. Con estos valores se recorre el archivo /etc/group y se puede conocer tanto el nombre que corresponde al GID hallado, así como los grupos suplementarios pues contienen en su lista de "usuarios" los nombres de sus miembros [64] :

/*
 * Encontrar los grupos a los que pertenezco
 */
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <pwd.h>
#include <grp.h>

int main()
{
/* Encontrar el UID */
uid_t u=getuid();
/* Encontrar entrada en passwd */
struct passwd *p=getpwuid(u);
/* Recorremos /etc/group */

struct group *g;
while((g=getgrent()))
	{
	/* Estamos en el grupo principal? */
	if(g->gr_gid==p->pw_gid)
		printf("G. Prin.: %s\n",g->gr_name);
	/* Estamos en un grupo suplementario? */
	char **ptr=g->gr_mem;
	while(*ptr)
		{
		if(strcmp(*ptr,p->pw_name)==0)
			printf("G. Sup.: %s\n",g->gr_name);
		ptr++;
		}
	}
return 0;
}

Estos recorridos en los archivos señalados se podrían hacer "manualmente" mediante funciones de I/O; sin embargo, POSIX y otros estándares de Unix proporcionan rutinas auxiliares para recorrerlos de un modo más sencillo. De éstas, hemos empleado getpwuid(3) y getgrent(3).

4.2.6. Shutdown Inmediato

El programa del listado 3 permite hacer shutdown inmediato [65] a un sistema Linux. Este programa debe ser ejecutado por el administrador para tener éxito [66] . Nótese que hemos empleado tres llamadas al sistema: getuid, sync y reboot. Consulte ahora mismo el manual de estas llamadas al sistema con el comando man. Posiblemente deba especificar la sección correspondiente (con man 2 …​) Las dos primeras son totalmente portables entre sistemas Linux y Unix, mientras que la tercera está disponible en Linux mas no necesariamente en otros Unix [67] .

La ejecución del programa se inicia averiguando el UID (User ID) de sí mismo (para ver quién lo ha ejecutado.) En caso de ser distinto de cero - lo que significa que el ejecutor NO es root [68] imprime un mensaje y finaliza sin hacer nada (con exit.) En caso contrario, hace un "flush" de los buffers de memoria a disco (a fin de reducir las pérdidas de información) con la llamada al sistema sync y finalmente detiene el computador con reboot.

/*
 * my_shutdown: Bajar el sistema de inmediato
 */
#include <unistd.h>
#include <sys/types.h>
#include <sys/reboot.h>
#include <stdio.h>
#include <stdlib.h>

int main()
{
int user;

user=getuid();
if(user!=0)
        {
        fprintf(stderr,"Usted no es el administrador!\n");
        exit(1);
        }

sync();
sleep(3);
reboot(RB_POWER_OFF);
return 1;
}

Con la librería estándar no hay un equivalente directo. Podemos emplear indirectamente al comando /sbin/halt que se emplea normalmente para detener el sistema, pero el comportamiento no es idéntico. Podría decirce que la librería estándar ya llegó a su límite.

/*
 * via_halt: Lanzar el comando halt
 */
#include <unistd.h>
#include <sys/types.h>
#include <stdio.h>
#include <stdlib.h>

int main()
{
int user;

user=getuid();
if(user!=0)
        {
        fprintf(stderr,"Usted no es el administrador!\n");
        exit(1);
        }

system("/sbin/halt");
return 1;
}

4.3. Ejercicios

1 El buffer óptimo

A En el programa file_write, la grabación se ha realizado byte a byte, mientras que en file_fputc, tal como se explicó, la librería estándar astútamente usa un buffer a fin de hacer la grabación en bloques más grandes. Se pide modificar file_write para hacer la grabación usando un buffer de tamaño configurable. Además, se deberá probar cuánto toma realizar la misma grabación (un millón de bytes) con distintos tamaños de buffer. En general, si el buffer es más grande, el tiempo se reduce. Sin embargo, esta reducción no es ilimitada, puesto que a partir de cierto tamaño, el agrandamiento del buffer tiene un efecto despreciable. Analice y encuentre un tamaño "óptimo" de buffer.

B ¿Este tamaño óptimo es igual en otros sistemas? ¿en distintos tipos de sistema de archivo?

2 Modifique el programa my_shutdown para que solicite

una confirmación antes de "bajar" el sistema. Si tiene tiempo, busque y analice el código fuente del comando halt de Linux.

3 Investigue cómo se podría adaptar el programa my_shutdown para

que pueda operar en otros sistemas Unix (¿y en sistemas Windows?).

5. Programación con señales

Las señales proporcionan un mecanismo de notificación entre procesos y el sistema operativo cuyo uso es frecuente en el desarrollo de aplicaciones de mediana y alta complejidad. En este capítulo expondremos los mecanismos necesarios para escribir programas usando señales mediante ejemplos ilustrativos.

5.1. Conceptos

Las señales se pueden pensar como "avisos" enviados a los procesos Unix o Linux, los cuales están identificados por ciertas constantes numéricas. Por ejemplo, al interrumpir la ejecución de un proceso con el teclado (frecuentemente con la combinación CTRL-C [69] ), el proceso en cuestión recibe la señal "SIGINT", que tiene por valor 2. Otro caso familiar ocurre al ejecutar el comando kill inmediatamente seguido del Process ID (PID), usado para terminar un proceso. En este caso, el proceso recibe la señal SIGTERM (que tiene valor 15 [70] .) En la tabla que se presenta más abajo se indican algunas señales de uso frecuente.

¿Qué ocurre cuando un proceso recibe una señal? Generalmente se presentan dos casos (aunque hay excepciones):

  1. Si el proceso se ha "preparado" para "capturar" esa señal, entonces el proceso es "notificado". Normalmente esto hace que el proceso ejecute cierta función predeterminada (llamada signal handler), aunque como un caso particular, la acción puede ser simplemente "ignorar la señal".

  2. Si el proceso no se ha "preparado" para capturar esa señal, entonces el proceso recibe cierto tratamiento preestablecido (default action.) En la mayoría de los casos esto corresponde a la finalización inmediata del proceso [71]. Entre las excepciones más destacables se encuentra la señal SIGKILL (número 9) la cual no puede ser "capturada" por ningún proceso, lo cual significa que inexorablemente termina o "mata" al proceso. Es por esto que muchos usuarios emplean el comando kill -9 pid para terminar incondicionalmente un proceso.

Como es de esperarse, un usuario (o sus procesos) no pueden enviar señales a procesos de otro usuario, mientras que el administrador no tiene esta limitación.

Table 5. Algunas señales
Señal Valor Acción Normal Explicación

SIGINT

2

Terminar Proceso

Emitida normalmente al presionar CTRL-C

SIGQUIT

3

Terminar Proceso

Emitida normalmente al presionar CTRL-Backslash

SIGFPE

8

Terminar Proceso

Excepción de operación de punto flotante

SIGKILL

9

Terminar Proceso

Terminación incondicional. No "capturable"

SIGSEGV

11

Terminar Proceso

Intento de acceso inválido a memoria

SIGALRM

14

Terminar Proceso

Usada frecuentemente como temporizador

SIGTERM

15

Terminar Proceso

Usada para terminar procesos

SIGCHLD

17

Ignorada

Un proceso hijo ha terminado

SIGUSR1

22

Terminar Proceso

Para uso del programador

La información de los procesos en ejecución puede ser hallada rápidamente mediante el comando:

ps -ef

o mediante:

ps ax

Esto permite identificar rápidamente el PID de los mismos, especialmente con ayuda de grep:

$ ps ax|grep temporizador
 1315 pts/1    S+     0:00 ./temporizador

Así, para terminar el proceso mostrado arriba usaríamos:

$ kill 1315

Esto en realidad envía la señal SIGTERM. Para enviar otra señal (como por ejemplo, SIGQUIT):

$ kill -SIGQUIT 1315

Recomiendo consultar cualquier libro de Unix o Linux para más detalles sobre las señales y el comando kill. A partir de ahora el enfoque se dirigirá hacia la programación.

5.2. Interfaz de programación

Lamentablemente la interfaz de programación ha tenido diversas modificaciones e incompatibilidades históricas según los "sabores" de Unix. En términos muy generales se reconoce una antigua implementación (años 70 y parte de los 80) de señales "inseguras" [72] , propensas a "race conditions" y otros problemas difíciles de identificar. La llamada al sistema signal(2) se asocia a este período. A partir de las recomendaciones POSIX.1, la programación de señales se hizo más robusta, extendiéndose notablemente con POSIX 1b. Aunque "signal" se mantiene por compatibilidad en prácticamente todos los sistemas Unix (y Linux), se recomienda evitar su uso en aplicaciones complejas que requieren ser portables.

5.3. Método obsoleto: signal

Al margen de ser poco recomendada, signal(2) es la interfaz más sencilla en lo que refiere a programar señales y la emplearé aquí con fines didácticos y porque el lector de seguro se la encontrará en muchas aplicaciones existentes.

A modo de ejemplo, considérese el siguiente programa (temporal.c) que crea un archivo (que asumiremos debe ser sólo temporal), duerme durante algunos segundos, borra el archivo temporal y termina. Esto no tiene nada de especial; sin embargo, supóngase ahora que el usuario decide terminar el proceso prematuramente presionando CONTROL-C o ejecutando el clásico comando “kill PID”; esto casi de seguro ocurrirá durante la "pausa" de sleep(). Así, el proceso será terminado, pero el archivo "temporal" permanecerá.

/* temporal.c */

#include <unistd.h>
#include <stdio.h>

int main()
{
FILE *fp;

fp=fopen("/tmp/temporal","w");
fprintf(fp,"La Hormiga Roja\n");
fclose(fp);

sleep(100);

unlink("/tmp/temporal");
return 0;
}

Como se sabe, no es correcto que el sistema se llene de archivos temporales sin utilidad, por lo que intentaremos corregir el programa mediante la captura de señales.

El listado que se presenta a continuación (tempo_signal.c) presenta una solución trivial usando signal(2). Allí se ha declarado y definido una función llamada “borra_y_sale()” (nuestro signal handler) que sencillamente termina el programa pero asegurándose de eliminar el archivo temporal. Nótese que esta función no debe retornar nada (tipo void) pero sí debe aceptar un parámetro numérico entero.

/* temp_signal.c */

#include <unistd.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>

void borra_y_sale(int);

int main()
{
FILE *fp;

signal(SIGINT,borra_y_sale);
signal(SIGTERM,borra_y_sale);

fp=fopen("/tmp/temporal","w");
fprintf(fp,"La Hormiga Roja\n");
fclose(fp);

sleep(100);

unlink("/tmp/temporal");
return 0;
}

void borra_y_sale(int s)
{
unlink("/tmp/temporal");
exit(1);
}

Las señales más frecuentes (pero no las únicas) que se emplean para terminar un proceso, son SIGINT (como dijimos, CTRL+C) y SIGTERM (con kill pid.) Por esto las capturaremos con "signal". La ocurrencia de estas señales ocasionará que se ejecute el código de la función “borra_y_sale”, con lo que conseguiremos el efecto deseado.

En el código correspondiente a borra_y_sale, la variable "s" puede ser utilizada para discernir cuál fue la señal recibida. De este modo podríamos tomar acciones distintas de ser necesario. Otra manera de proceder corresponde a crear distintos signal handlers, de modo tal que cada señal desencadene acciones distintas.

Un caso particular que se presenta con frecuencia corresponde a "ignorar" las señales. Esto se puede hacer en tempo_signal.c simplemente definiendo el signal handler como una función vacía (que no hace nada.) Sin embargo, una forma más simple (con algunas diferencias sutiles) consiste en utilizar el identificador SIG_IGN. En efecto, en lugar de escribir:

signal(SIGINT,funcion_vacia);

Escribiríamos:

signal(SIGINT,SIG_IGN);

Entonces esta señal sería completamente ignorada y no se requeriría del signal handler [73] .

5.4. Método recomendado: sigaction

La llamada al sistema sigaction(2) corresponde al sucesor estandarizado de signal. Tiene mayor flexibilidad y un mejor comportamiento en diversas situaciones (que no expondremos aquí [74] .) Lamentablemente, la flexibilidad suele estar acompañada de una mayor complejidad, y ésta no es la excepción.

Sigaction requiere de tres argumentos, a saber, el número de la señal, y hasta dos punteros a estructuras de tipo sigaction (creadas por el usuario.) A continuación se presenta una nueva versión del programa, donde hemos reemplazado signal por sigaction. Obsérvese que aquí el tercer argumento pasado a sigaction es NULL.

/* tempo_sigaction.c */
#include <unistd.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>

void borra_y_sale(int);

int main()
{
FILE *fp;
struct sigaction s;

s.sa_handler=borra_y_sale;
sigemptyset(&s.sa_mask);
s.sa_flags=0;
sigaction(SIGINT,&s,NULL);
sigaction(SIGTERM,&s,NULL);

fp=fopen("/tmp/temporal","w");
fprintf(fp,"La Hormiga Roja\n");
fclose(fp);
sleep(100);
unlink("/tmp/temporal");
return 0;
}

void borra_y_sale(int s)
{
unlink("/tmp/temporal");
exit(1);
}

La estructura sigaction tiene (al menos) los siguientes miembros:

  • sa_handler: El signal handler asociado a la señal. Similar al caso de signal.

  • sa_mask: Un "signal set" (conjunto de señales) correspondiente a las señales que serán bloqueadas durante la ejecución del signal handler (se explica más adelante.) Por ahora baste indicar que debemos inicializarlo a "cero" mediante la rutina auxiliar sigemptyset(3) y un puntero al mismo.

  • sa_flags: Permite modificar el comportamiento de las señales en algunos aspectos definidos por el vendedor. Estas modificaciones son interesantes pero lamentablemente no muy portables.

5.4.1. Secciones Críticas

El siguiente listado (reloj.c) simula un cronómetro mediante las variables "h" y "m" (hora y minuto.) Tal como en un cronómetro normal, los valores de "h" están en el rango de 0 a 23 (nunca alcanza 24), mientras que "m" está en el rango de 0 a 59 (nunca alcanza 60.)

Los valores actuales de (h,m) se obtienen lanzando externamente la señal SIGUSR1 al proceso (mediante el comando kill -SIGUSR1 pid.)

/*
 * Simulacion de reloj
 */
#include <signal.h>
#include <stdio.h>

void imprime_hora(int);

int h=0,m=0;

int main()
{
struct sigaction s;

s.sa_handler=imprime_hora;
sigemptyset(&s.sa_mask);
s.sa_flags=0;
sigaction(SIGUSR1,&s,NULL);

for(;;)
	{
	m++;
	if(m==60)
		{
		m=0;
		h++;
		if(h==24)
			h=0;
		}
	}
return 0;
}

void imprime_hora(int s)
{
fprintf(stderr,"Hora: %02d:%02d\n",h,m);
}

Si Ud. analiza el programa paso a paso, notará que en ciertos momentos los valores de las variables "h" y "m" sí alcanzan los valores extremos 24 y 60 respectivamente, los cuales se emplean para resetearlas a cero. Lamentablemente, esto significa que la señal podría llegar en un "mal momento" y generar una valor incorrecto (por ejemplo, 06:60 o 24:00), lo cual efectivamente ocurre si se hace un buen número de pruebas [75] .

El problema radica en que existe una sección crítica en el programa (el incremento de los contadores, su análisis y posible reseteo) durante la cual no es correcto que la señal sea capturada. Lo deseable es que durante esta sección crítica, si se generase una señal, ésta sea mantenida "en suspenso" hasta que se salga de aquella (obviamente, con un valor coherente de "h" y "m".) A esto se le conoce como el "bloqueo" de señales, y se implementa mediante la llamada al sistema sigprocmask(2):

/*
 * Simulacion de reloj con bloqueo de senhal
 */
#include <signal.h>
#include <stdio.h>

void imprime_hora(int);

int h=0,m=0;

int main()
{
struct sigaction s;

s.sa_handler=imprime_hora;
sigemptyset(&s.sa_mask);
s.sa_flags=0;
sigaction(SIGUSR1,&s,NULL);

sigset_t usr1_mask;
sigemptyset(&usr1_mask);
sigaddset(&usr1_mask,SIGUSR1);

for(;;)
	{
	sigprocmask(SIG_BLOCK,&usr1_mask,NULL);
	m++;
	if(m==60)
		{
		m=0;
		h++;
		if(h==24)
			h=0;
		}
	sigprocmask(SIG_UNBLOCK,&usr1_mask,NULL);
	}
return 0;
}

void imprime_hora(int s)
{
fprintf(stderr,"Hora: %02d:%02d\n",h,m);
}

Con respecto al listado anterior, la única diferencia consiste en el uso de sigprocmask al inicio y al final de la "sección crítica", lo cual significa que con cada iteración del loop principal, la señal bloqueada (SIGUSR1) tiene la oportunidad de ser procesada (ejecutar el signal handler.)

Todo proceso tiene una "máscara de bloqueo" que no es otra cosa que la lista de señales bloqueadas. En el ejemplo anterior, hemos creado el "conjunto de señales" usr1_mask (de tipo sigset_t) que contiene únicamente la señal SIGUSR1 (añadida mediante sigaddset().) La primera invocación a sigprocmask bloquea las señales especificadas en el conjunto pasado como argumento (por eso se especifica la constante SIG_BLOCK) mientras que la segunda "desbloquea" las señales del mismo conjunto (constante SIG_UNBLOCK.)

Haga las pruebas necesarias y verifique que esto resuelve los inconvenientes señalados para la versión anterior [76] .

Señal de Alarma

La llamada al sistema alarm(2) permite programar un temporizador que enviará al proceso la señal SIGALRM al cumplirse el tiempo especificado. Esto normalmente significa que se deberá capturar dicha señal a fin de hacer algo útil en ese precíso momento.

El listado que se muestra a continuación es una ligera variación con respecto al anterior. Ahora el programa no sólo genera los valores de "h" y "m" al llegar SIGUSR1 (a voluntad del usuario), sino también en cada segundo de manera automática. Con este fin hemos hecho dos invocaciones a sigaction(), una para cada señal.

La alarma se programa antes de entrar al loop principal con un argumento igual a 1 segundo. Esto significa que al transcurrir este tiempo se generará la señal SIGALRM por primera vez, y se ejecutará el handler imprime_hora(). Esta función analiza la variable "s" (que puede contener SIGALRM o SIGUSR1) y para primer caso reinicializa el temporizador invocando nuevamente a alarm() [77] .

Otra modificación consiste en guardar los valores de "h" y "m" en un archivo (no enviarlos al error estándar como antes.) Supondremos que un requisito adicional del programa radica en que al finalizar, debe dejar el archivo hora.txt con la última "hora" registrada. Si se analiza con cuidado, se observará que aquí puede presentarse un problema si el programa es terminado justo en el momento en que el archivo se ha abierto (pues se borra toda información del mismo), pero antes de terminarse la nueva grabación. Esto es altamente improbable (pues el lapso considerado es muy breve) pero no es imposible. Dicho en otras palabras, tenemos una nueva (pequeña) sección crítica.

Puesto que el programa no tiene forma de terminar por sí mismo, es de esperarse que sea terminado externamente mediante alguna señal (como SIGTERM.) Esto quiere decir que durante nuestra nueva sección crítica, las señales deben ser bloqueadas.

Podría pensarse nuevamente en sigprocmask, pero hay una forma más sencilla. Como se recordará, la estructura sigaction que se pasa a sigaction() contiene un miembro (sa_mask) correspondiente a un "conjunto de señales" que precísamente son bloqueadas durante la ejecución del signal handler. Para este caso hemos "llenado" totalmente (con todas las señales existentes) este conjunto empleando la rutina sigfillset() a diferencia de lo que hacíamos anteriormente (sigemptyset()) que lo mantenía "vacío" (es decir, no bloqueaba señal alguna.)

/*
 * Simulacion de reloj con alarm
 */
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

void imprime_hora(int);

int h=0,m=0;

int main()
{
struct sigaction s;

s.sa_handler=imprime_hora;
sigfillset(&s.sa_mask);
s.sa_flags=0;
sigaction(SIGUSR1,&s,NULL);
sigaction(SIGALRM,&s,NULL);

sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask,SIGUSR1);
sigaddset(&mask,SIGALRM);

alarm(1);

for(;;)
	{
	sigprocmask(SIG_BLOCK,&mask,NULL);
	m++;
	if(m==60)
		{
		m=0;
		h++;
		if(h==24)
			h=0;
		}
	sigprocmask(SIG_UNBLOCK,&mask,NULL);
	}
return 0;
}

void imprime_hora(int s)
{
FILE *fp=fopen("hora.txt","w");
if(fp==NULL)
	exit(1);
fprintf(fp,"Hora: %02d:%02d\n",h,m);
fclose(fp);
if(s==SIGALRM)
	alarm(1);
}

No huelga recordar que SIGKILL [78] es una excepción a la regla al no poder capturarse o bloquearse, lo que significa que todos estos programas pueden fallar si se emplea. Esta es la razón por la que su uso se recomienda sólo como último recurso (el famoso kill -9.)

5.5. Envío de señales

La llamada al sistema kill(2) es empleada para enviar señales desde un proceso a otro (o hacia sí mismo.) Como se indicó más arriba, esto sólo funciona entre procesos que tienen el mismo dueño, o cuando se trata del administrador.

Esta llamada al sistema requiere especificar el PID del proceso destinatario así como la señal a enviarse. El programa que se lista a continuación consiste en un loop infinito al inicio del cual, el proceso se envía la señal SIGUSR1 a sí mismo (obtiene su PID mediante la llamada al sistema getpid(2)); esto desencadenará la ejecución alternada de los handlers “dia()” y “noche()”, los cuales reprograman el comportamiento de dicha señal.

Luego de esto, el programa invoca a la función “la_pausa()”, que como indica su nombre, hace una breve pausa. Sin embargo, añadiremos el siguiente requisito: durante este lapso (que asumiremos es una especie de "sección crítica"), la señal SIGUSR1 deberá desactivar su comportamiento normal (no intercambiará día-noche); por el contrario, ésta sólo imprimirá un mensaje alertando que no está "operativa". Esto lo debe verificar el lector lanzando la señal manualmente con el comando kill mientras el proceso está en ejecución.

/* killbill.c */
#include <sys/types.h>
#include <signal.h>
#include <stdio.h>
#include <unistd.h>

void dia(int);
void noche(int);
void la_pausa(void);
void h_pausa(int);

int main()
{
struct sigaction s;
s.sa_handler=dia; sigemptyset(&s.sa_mask); s.sa_flags=0;
sigaction(SIGUSR1,&s,NULL);
for(;;)
	{
	kill(getpid(),SIGUSR1);
	la_pausa();
	}
return 0;
}

void la_pausa(void)
{
struct sigaction s_c,s_estado;
s_c.sa_handler=h_pausa;
sigemptyset(&s_c.sa_mask);
s_c.sa_flags=0;
sigaction(SIGUSR1,&s_c,&s_estado);
/* Se inicia la pausa */
sleep(1);
/* Termina la pausa */
sigaction(SIGUSR1,&s_estado,NULL);
}

void dia(int sig)
{
struct sigaction s;
s.sa_handler=noche; sigemptyset(&s.sa_mask); s.sa_flags=0;
sigaction(SIGUSR1,&s,NULL);
printf("Dia\n");
}

void noche(int sig)
{
struct sigaction s;
s.sa_handler=dia; sigemptyset(&s.sa_mask); s.sa_flags=0;
sigaction(SIGUSR1,&s,NULL);
printf("Noche\n");
}

void h_pausa(int sig)
{
printf("Estamos en la pausa! no interrumpir!\n");
}

La función la_pausa() permite ilustrar el uso del tercer argumento de sigaction; si éste no es NULL, entonces se asume que se trata de una estructura (de tipo struct sigaction) en la que se almacena la configuración actual de la señal. Ésta es utilizada al final de la_pausa() para restablecer aquella a su valor original, sin necesidad de conocer si estamos "de día" o "de noche".

En los capítulos 6 y 7 veremos otras características de sigaction y de kill [79] .

5.6. Ejercicios

1 El siguiente listado presenta una implementación alternativa de la

función sleep(3) que se emplea para que un programa "duerma" un determinado número de segundos. Esta implementación se hace mediante las llamadas al sistema alarm(2) - antes explicada, así como de la llamada al sistema pause(2). Ésta última simplemente detiene la ejecución del programa hasta que arriba alguna señal (en nuestro caso, SIGALRM.) [80]

/*
 * sleep.c: Implementacion de sleep con alarm
 */
#include <unistd.h>
#include <signal.h>

void my_sleep(int tiempo);

int main()
{
	my_sleep(5);
	return 0;
}

static void dumb_handler(int s)
{
return;
}

void my_sleep(int tiempo)
{
struct sigaction s;

/* s.sa_handler=SIG_IGN; */
s.sa_handler=dumb_handler;
sigemptyset(&s.sa_mask);
s.sa_flags=0;
sigaction(SIGALRM,&s,NULL);

alarm(tiempo);
pause();
return;
}

Esta implementación presenta varios problemas cuya resolución se sugieren al lector:

A) Se ha empleado un handler "tonto" denominado "dumb_handler", el cual aparentemente no realiza ninguna función. Se podría pensar que nos podemos ahorrar este handler programando la señal para que sea "ignorada" con la siguiente línea:

s.sa_handler=SIG_IGN;

Sin embargo, esto no funcionará. Pruebe esto y lea el manual de pause(2). Intente comprender este comportamiento.

B) Si antes de invocar a "my_sleep" ya se hubiera programado una alarma, ésta se perdería pues "my_sleep" la "reprograma" . Más correcto sería que "my_sleep" respete la alarma inicial antes programada y retorne al cumplirse ésta (descartando el argumento inicial "tiempo".) Para esto, Ud. deberá averiguar primero si ya hay una alarma programada y cuántos segundos le faltan para cumplirse. Esto se puede hacer invocando a la llamada al sistema alarm con argumento cero, lo que retorna cuántos segundos faltan para que llege el evento, o cero si no hay evento pendiente. Lamentablemente esto tiene el efecto secundario de cancelar la alarma que está pendiente, por lo que posiblemente se deba volver a invocar por segunda vez.

C) Si SIGALRM ya hubiese tenido un signal handler previo a la invocación de "my_sleep", éste se perdería. Modifique "my_sleep" para que restaure el signal handler original.

2 Modifique el programa reloj_block.c definiendo un

nuevo conjunto de señales “orig_mask” que será empleado del siguiente modo:

	sigprocmask(SIG_BLOCK, &usr1_mask, &orig_mask);
	... Seccion critica ...
	sigprocmask(SIG_SETMASK, &orig_mask, NULL);

Revise las páginas de manual de sigprocmask para comprender por qué esto es equivalente a la versión original.

6. Nacimiento y Muerte de Procesos

La creación y finalización de procesos "hijos" es parte fundamental de muchas aplicaciones en Linux/Unix. En este capítulo haremos una primera revisión a este extenso tema, centrado en las llamadas al sistema fork(2) y waitpid(2).

6.1. Algunos Conceptos

Normalmente todo proceso Linux/Unix puede crear otro proceso a partir de sí mismo. Este proceso generado se suele denoninar "proceso hijo" (child process) mientras que el generador se convierte en el "proceso padre" (parent process.) El proceso hijo es una réplica exacta del proceso padre, aunque suele tener un "comportamiento" distinto.

En muchas ocasiones el "hijo" interactúa con el "padre" colaborando para desempeñar una tarea de modo cooperativo, aunque en otras, el "hijo" puede desempeñar funciones totalmente ajenas al progenitor. Un ejemplo muy didáctico lo constituye la ejecución de un comando en el shell (intérprete de comandos), tal como ls o cp. Esto se implementa necesariamente en dos etapas:

  1. El shell (por ejemplo bash, sh, ksh, etc.) genera un proceso hijo que es una copia exacta de sí mismo (es decir, otro shell.) Esto se hace mediante la llamada al sistema fork(2).

  2. Este "shell hijo" tiene como única finalidad el "convertirse" en otro programa (en nuestro caso, ls o cp.) Esto se hace con un grupo de llamadas al sistema conocidas como exec().

Aquí nos interesa sólo la primera parte de este proceso, a saber, la creación del proceso "hijo" a partir del "padre" mediante fork(2). En el capítulo 7 desarrollaremos exec().

6.2. Demostración de Fork

Note
No podemos dejar de mencionar el popular artículo "A fork() in the road" discutido en https://news.ycombinator.com/item?id=29709802 .

El listado que presentamos a más abajo (fork_trivial.c) es una ilustración básica de la creación de un proceso hijo mediante fork(2). Esta llamada al sistema, al ser invocada, crea instantáneamente el proceso "hijo" con las mismas características que el padre, y ejecutándose en el mismo punto del programa [81] . La única diferencia (y lo que permite distinguirlos) radica en el valor de retorno de fork:

Table 6. Valores retornados por fork

0

Es el proceso hijo

> 0

Es el proceso padre que es informado acerca del pid de su proceso hijo recién creado

-1

Es el proceso padre y no pudo crear hijo

Algunas causas por las que fork podría fallar corresponden a:

  1. Agotamiento de memoria

  2. Alcance del límite asignado al usuario para creación de procesos

/* fork_trivial.c */
#include <sys/types.h>
#include <sys/unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main()
{
int p;

printf("Lanzo fork...\n");
p=fork();
if(p==-1)
	{
	printf("Fork fallo.\n");
	exit(1);
	}
if(p==0)
	printf("Soy el proceso hijo. Mi PID es: %d\n",getpid());
else
	printf("Soy el proceso padre. El PID de mi hijo es: %d\n",p);

return 0;
}

Nótese que se ha empleado la llamada al sistema getpid(2) que proporciona el PID del proceso en ejecución a fin de imprimir información de interés.

En el siguiente ejemplo (fork_descendencia.c) se muestra una forma de crear un proceso "hijo", luego un "nieto", un "bisnieto" y un "tataranieto" [82] . Cada uno de ellos tiene un tiempo de vida de aproximadamente 10 segundos. Obsérvese también que se ha hecho uso de la llamada al sistema getppid(2) que proporciona el PID del padre del proceso. Contraste la ejecución con el comando ps(1) [83] .

Si se elimina el sleep(10) la salida puede ser diferente. Sucede que si los procesos "padre" mueren antes que los "hijos", estos últimos son "adoptados" por el proceso especial llamado init (cuyo PID=1), y eso es lo que podría retornar getppid(2) [84] .

/*
 * fork_descendencia.c:
 * Crear hijo, nieto, bisnieto, tataranieto
 */
#include <sys/types.h>
#include <sys/unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main()
{
int p,z;

printf("Se inicia el padre con PID %d\n",getpid());

for(z=1;z<=4;z++)
	{
	p=fork();
	if(p==0)
		printf("Soy la generacion %d. Mi PID es: %d. Mi PPID es %d\n",z,getpid(),getppid());
	else
		{
		sleep(10);
		exit(0);
		}
	}
return 0;
}

Finalmente, el siguiente listado (fork_hermanos.c) muestra cómo crear un conjunto de procesos "hijos" del mismo padre; es decir, un grupo de "hermanos". De igual modo, el lector debe observar el efecto que puede tener la eliminación del sleep(10) en cuanto al valor retornado por getppid().

/*
 * fork_hermanos:
 * Crear cinco procesos "hermanos"
 */
#include <sys/types.h>
#include <sys/unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main()
{
int p,z;

printf("Se inicia el padre con PID %d\n",getpid());

for(z=1;z<=5;z++)
	{
	p=fork();
	if(p==0)
		{
		printf("Soy el hijo %d. Mi PID es \
%d. Mi PPID es %d\n",z,getpid(),getppid());
		exit(0);
		}
	}
sleep(10);
return 0;
}

6.3. Paralelizar una aplicación

Con frecuencia las aplicaciones crean procesos hijos con el fin de realizar tareas disímiles (pero complementarias.) En cambio aquí - a fin de hacer la exposición más sencilla - se presenta un programa que realiza un gran cálculo matemático valiéndose de tres procesos hijos auxiliares cuyo comportamiento es casi idéntico.

En los libros de cálculo se demuestra fácilmente que la constante matemática PI se puede hallar mediante la suma infinita:

PI/4 = 1 - 1/3 + 1/5 - 1/7 + 1/9 - 1/11 ...

Nosotros sumaremos hasta el término "1/n", intentando dividir el proceso en tres sumas parciales. Tal como indica la fórmula, al final multiplicaremos por cuatro:

4*{
1        - 1/3      + 1/5      - 1/7      .. - 1/(K-1)
+
1/(K+1)  - 1/(K+3)  + 1/(K+5)  - 1/(K+7)  .. - 1/(2K-1)
+
1/(2K+1) - 1/(2K+3) + 1/(2K+5) - 1/(2K+7) .. - 1/(3K-1)
}

Asimismo, compararemos el resultado obtenido con una aproximación más "exacta" de PI recogida de los libros a fin de aquilatar el error.

Admito que este esquema de tres sumas parciales es un tanto "caprichoso". Sin embargo, además de servirnos para ilustrar fork(), también podría ser muy beneficioso en un computador que tenga tres unidades de procesamiento (CPU’s) pues el sistema operativo normalmente intenta balancear entre estos a las aplicaciones. En nuestro caso, significaría que el programa obtiene sus resultados de forma paralela (y por tanto, más rápida) aprovechando el hardware disponible. A esto se le conoce como "paralelizar" la aplicación [85] .

#include <sys/types.h>
#include <sys/wait.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <math.h>

#define FILE_TMP "pi.tmp"
void sumador(double);

double N;
int fd;

int main()
{
double nterm;
printf("Ultimo termino? ");
scanf("%lf",&nterm);
N=ceil(nterm/12);

unlink(FILE_TMP);
fd=open(FILE_TMP,O_WRONLY|O_CREAT,0777);
if(fd==-1)
	{
	perror("open fallo");
	exit(1);
	}
if(fork()==0)
	sumador(0.0);
if(fork()==0)
	sumador(4.0*N);
if(fork()==0)
	sumador(8.0*N);
while(wait(NULL)!=-1)
	;
close(fd);
double p=0;
FILE *fp;
char str[256];
fp=fopen(FILE_TMP,"r");
while(fgets(str,256,fp))
	p+=atof(str);
fclose(fp);
double delta=fabs(3.1415926535897932384626433832795-p);
printf("PI ~ %.15f D=%.1E\n",p,delta);
return 0;
}

void sumador(double inicio)
{
double contador;
double p=0;
double fin=4*N+inicio;
char str[256];
for(contador=1.0+inicio;contador<fin;contador+=4.0)
	{
	p=p+1.0/contador;
	p=p-1.0/(contador+2.0);
	/* printf("+1/%d-1/%d\n",
	(int)contador,(int)contador+2); */
	}
sprintf(str,"%.15f\n",4*p);
write(fd,str,strlen(str));
exit(0);
}

Este programa requiere algunos comentarios adicionales. En primer lugar, se ha empleado la estructura de "procesos hermanos" de fork_hermanos.c para crear tres subprocesos (hijos.)

Cada proceso hijo hace un cálculo (independientemente) del resto. Sin embargo, al final, el proceso padre debe recoger estos resultados parciales y sacar un valor total. Esto requiere algún mecanismo de "comunicación entre procesos", para lo cual Linux/Unix proporciona diversas facilidades que se discuten en otros capítulos. A fin de mantener sencillo el ejemplo, se ha utilizado un archivo temporal de disco llamado "pi.tmp" donde cada proceso escribirá una línea con el subtotal que ha calculado. El padre, al terminar todos los hijos, lee los valores calculados desde ese archivo y los totaliza. Nótese con especial cuidado que el archivo es abierto por el padre (en modo escritura) una sola vez; todos los hijos automáticamente heredan el "file handler" (en general, todos los archivos abiertos) por lo que todos los hijos también pueden grabar en el mismo. Asimismo, cada hijo añade una linea al archivo en vez de sobreescribir las existentes. Esto ocurre porque el "puntero" de grabación se comparte entre procesos heredados [86] .

6.4. Esperar el final de los hijos

Otro aspecto muy importante del programa anterior radica en cómo puede determinar el padre que todos sus hijos han terminado. Para esto hemos empleado la llamada al sistema wait(2), que en su forma más simple (como en nuestro programa) simplemente "espera" a que algún hijo termine. En nuestro programa, la hemos invocado repetidamente hasta que nos retornó "-1", que corresponde - entre otros - al caso en que ya no hay hijos cuya muerte se debe "esperar" [87] . También se pudo haber invocado exactamente tres veces, a fin de asegurarnos de que los tres procesos hijos hayan terminado.

wait(2) es la función más simple - y menos potente - para esperar procesos hijos. El listado que se muestra a continuación (espera.c) es una demostración de la función waitpid(2). A diferencia de wait(), waitpid() permite esperar por un hijo en particular (conociendo el PID), así como recibir notificaciones acerca de procesos hijos que son "detenidos" (pero no terminados.) Si se desea esto último, se debe añadir la constante WUNTRACED en el tercer argumento.

Al igual que en wait(2), se puede suministrar un "puntero a entero" a fin de recabar información concerniente a la forma en que terminó el proceso [88] .

/*
 * espera.c
 * Demostracion de waitpid
 */
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <stdio.h>
#include <stdlib.h>

void menu_hijo(void);

int main()
{
int pid_child,status;

pid_child=fork();
if(pid_child>0)
	{
	for(;;)
		{
		waitpid(pid_child,&status,WUNTRACED);
		printf("\nSoy el proceso padre\n");
		if(WIFEXITED(status))
			{
			printf("El hijo termino normalmente.\n");
			printf("Valor de retorno: %d\n",
					WEXITSTATUS(status));
			break;
			}
		if(WIFSIGNALED(status))
			{
			printf("El hijo termino por senhal.\n");
			printf("La senhal fue: %d\n",
					WTERMSIG(status));
			break;
			}
		if(WIFSTOPPED(status))
			{
			printf("El hijo ha sido detenido.\n");
			printf("La senhal fue: %d\n",
					WSTOPSIG(status));
			printf("Se le continuara esperando...\n");
			}
		}
	}
else
	menu_hijo();

return 0;
}

void menu_hijo(void)
{
int i,r;

for(;;)
	{
	printf("Soy el proceso hijo. Escriba un numero de senhal\n"
	"o cero para terminar: ");
	scanf("%d",&i);
	if(i==0)
		{
		printf("Escriba el valor de retorno: ");
		scanf("%d",&r);
		exit(r);
		}
	kill(getpid(),i);
	}
}

El programa crea un proceso hijo, el cual solicita al usuario un valor numérico que será usado como señal a aplicarse a sí mismo. Aquí se puede apreciar el uso de la llamada al sistema kill(2) que permite enviar una señal a cualquier proceso, incluido a sí mismo [89] .

Si el usuario introduce un numero cero, el proceso hijo terminará con exit(3). Como se aprecia en el listado, la variable entera status es usada para averiguar la manera en que terminó el proceso hijo. Esta variable requiere de las macros WIFEXITED, WIFSIGNALED y WIFSTOPPED para indagar, respectivamente, si el proceso terminó en forma normal (por ejemplo con exit(3)), por una señal, o ha sido detenido. Las macros complementarias WEXITSTATUS, WTERMSIG y WSTOPSIG permiten obtener información más detallada.

A continuación mostramos una sesión de ejecución de este programa en un sistema Linux.

$ ./espera
Soy el proceso hijo. Escriba un numero de senhal
o cero para terminar: 2

Soy el proceso padre
El hijo termino por senhal.
La senhal fue: 2

$ ./espera
Soy el proceso hijo. Escriba un numero de senhal
o cero para terminar: 0
Escriba el valor de retorno: 17

Soy el proceso padre
El hijo termino normalmente.
Valor de retorno: 17

$ ./espera
Soy el proceso hijo. Escriba un numero de senhal
o cero para terminar: 19

Soy el proceso padre
El hijo ha sido detenido.
La senhal fue: 19
Se le continuara esperando...

***

Soy el proceso hijo. Escriba un numero de senhal
o cero para terminar:

Los dos primeros casos son sencillos. En el tercer caso, la señal 19 (SIGSTOP) detiene el proceso (no lo termina.) Esto es reportado correctamente por el proceso padre, el cual nuevamente invoca a waitpid (se indica con asteriscos.) A fin de que el proceso hijo continue, se debe abrir otra sesión y ejecutar un comando tal como kill -SIGCONT pid, donde "pid" es el PID del proceso hijo. Para averiguar el PID del proceso hijo detenido, se puede usar el comando ps, visualizando el campo de STATUS con el identificador "T":

$ ps ax | grep espera
  PID TTY      STAT   TIME COMMAND
...
15071 pts/1    S      0:00 ./espera
15072 pts/1    T      0:00 ./espera
15092 pts/2    S      0:00 grep espera
...

En este ejemplo, es claro que el PID del hijo es 15072.

6.5. Procesos zombies

La mayoría de los procesos tiene una duración finita. Hemos visto anteriormente que cuando un proceso padre muere, el proceso hijo es "adoptado" por el proceso de PID=1 (llamado init.) Sin embargo, una situación muy interesante ocurre cuando el hijo muere antes que el "padre". En este caso, no obstante que el proceso hijo ya no está en ejecución, el sistema guarda la "información de estado" acerca de éste, a la espera de que el padre en algún momento la reclame.

Si el proceso padre no hace nada con respecto a su "hijo" muerto, el sistema reportará en la tabla de procesos un "zombie" (proceso que no puede morir) por tiempo indefinido (hasta que el padre reclame la "información de estado" o muera.)

Las llamadas wait y waitpid se usan por los procesos padre para "esperar" a la muerte de sus procesos hijos, y de paso obtener la "información de estado" que evita la formación de zombies. Es una mala práctica permitir la existencia de zombies por no tomar las precauciones del caso.

Por ejemplo, los listados fork_descendencia.c y fork_hermanos.c pueden generar zombies durante el tiempo en que el padre "duerme" con sleep(), debido a que no los ha "esperado" como sí ocurre en los ejemplos posteriores. El comando ps deberá ser capaz de mostrar estos "zombies" más o menos del modo siguiente:

 2473 pts/1    Z      0:00 [fork_descend <defunct>]
 2474 pts/1    Z      0:00 [fork_descend <defunct>]
 2475 pts/1    Z      0:00 [fork_descend <defunct>]
 2476 pts/1    Z      0:00 [fork_descend <defunct>]
 2477 pts/1    Z      0:00 [fork_descend <defunct>]

En ambos casos, los difuntos desaparecen cuando el padre termina (pues, como se dijo, los hijos huérfanos son adoptados y "esperados" por init, lo que les permite "morir en paz" [90] .)

6.6. Grupos de procesos

Los procesos hijos de un mismo antecesor mantienen un rasgo que los identifica como pertenecientes a la misma "familia". Este es el "process group id" (PGID.) El siguiente listado (fork_grupo.c) presenta un programa que crea una jerarquía de cuatro procesos: padre, hijo, nieto y bisnieto. Sin embargo, a diferencia de todos los programas anteriores, el nieto y el bisnieto tienen un PGID distinto al de sus progenitores, el cual ha ocurrido gracias a la llamada al sistema setpgrp(2) [91] .

Hemos definido además un "signal handler" para la señal SIGINT en el padre, lo cual se heredará a toda la descendencia. Cuando los cuatro procesos están en ejecución (en este caso, durmiendo) se requiere que el usuario presione la tecla de interrupción (CTRL+C) a fin de detenerlos. Como observará el lector, esto sólo afecta al padre y al hijo pues están en el "grupo de procesos en foreground" [92] .

/* fork_grupo.c */
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>

void handler(int s)
{
printf("Proceso %d recibio senhal %d\n",getpid(),s);
}

int main()
{
int z;

signal(SIGINT,handler);

z=fork();
if(z>0)
	{
	printf("Proceso padre pid=%d pgid=%d\n",getpid(),getpgrp());
	sleep(60);
	exit(0);
	}
/* El hijo */
z=fork();
if(z>0)
	{
	printf("Proceso hijo pid=%d pgid=%d\n",getpid(),getpgrp());
	sleep(60);
	exit(0);
	}

setpgrp();

/* El nieto */
z=fork();
if(z>0)
	{
	printf("Proceso nieto pid=%d pgid=%d\n",getpid(),getpgrp());
	sleep(60);
	exit(0);
	}

/* El bisnieto */
printf("Proceso bisnieto pid=%d pgid=%d\n",getpid(),getpgrp());
sleep(60);
return 0;
}

6.7. Demonios

El concepto informal de demonio se refiere a un proceso que no interactúa con el usuario mediante terminal alguno. Aunque no existe una defición precisa (y por tanto, no existe una manera única de programarlo), existe un conjunto de recomendaciones establecidas que pasaremos a explicar. El siguiente programa implementa un demonio que se limita a escribir aleatoriamente algunas frases absurdas en el archivo /tmp/infierno.txt:

/* demonio.c */
#include <unistd.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <limits.h>

void inicializa(void);
void trabaja(void);
void cierra_archivos(void);

int main(void)
{
inicializa();
trabaja();
return 0;
}

void inicializa(void)
{
int p;
if((p=fork())==-1) {
	fprintf(stderr,"fork error (%d)\n",errno);
	exit(1);
	}
if(p!=0) exit(0);
setsid();
chdir("/");
umask(022);
cierra_archivos();
}

void cierra_archivos(void)
{
int openmax,z;
openmax=sysconf(_SC_OPEN_MAX);
if(openmax==-1) openmax=20;
for(z=0;z<openmax;z++)
	close(z);
}

static char *frase[]={
	"A quien madruga, le duelen los parpados",
	"La paciencia es la madre de todos los males",
	"Hay que hacer un MBA",
	"Este programa es absurdo"};
void trabaja(void)
{
FILE *fp;
int l=sizeof(frase)/sizeof(char*);

for(;;) {
	fp=fopen("/tmp/infierno.txt","w");
	fprintf(fp,"%s\n",frase[rand()%l]);
	fclose(fp);
	sleep(5);
	}
}

La función main() invoca a la inicialización del demonio (que es lo que nos interesa) y luego a una rutina que hace el "trabajo real" (trabaja().) Aquí sólo explicaremos la inicialización del demonio [93] .

Como se aprecia, el programa se inicia haciendo una copia de sí mismo con fork(2), y descartando al padre (que termina con exit(3).) Esto tiene doble propósito; en primer lugar, el shell que ejecuta al programa recibe la notificación de que éste ha terminado; de este modo, el shell puede proseguir con otras tareas. En segundo motivo es mucho más sutil, pero en términos informales la idea es "desconectar" al demonio de todos los procesos asociados al terminal (especialmente del shell), lo que equivale a "desasociarlo" al terminal de control en uso. Esto evita, por ejemplo, que el shell pueda colocar (incorrectamente) al demonio en "foreground", o que le envíe señales en caso de desconexión, etc. Todo esto se logra mediante la invocación a setsid(2), la cual crea una nueva "sesión" que no está asociada a ningún terminal; sin embargo, para que setsid(2) funcione es necesario que el proceso que la invoca no sea un "proceso líder de grupo [94] ", lo que se puede conseguir fácilmente con fork(2).

Es recomendable además cambiar el directorio actual del proceso a la raíz (/) o a un directorio específico de la aplicación (chdir(2).) Por ejemplo, si el proceso tiene un fallo irrecuperable los archivos core se generan en el directorio actual del proceso. En este punto también se aconseja invocar a umask(2) con un argumento conveniente a fin de evitar que los archivos que se crean tengan permisos independientes de la máscara prefijada del usuario heredada del shell.

El último paso consiste en cerrar los descriptores abiertos heredados del shell pues ya no se utilizarán. Lamentablemente no hay una forma portable de conocer qué descriptores han sido abiertos por lo que se procede a intentar cerrar todos los descriptores admisibles. Sin embargo, tampoco hay una manera infalible de conocer el máximo número de descriptores admisibles. El estándar POSIX.1 proporciona la rutina sysconf(3) para obtener el valor de diversos límites como el mencionado; a tal fin se debe invocar con el argumento _SC_OPEN_MAX. Si ésta retorna -1, utilizamos un valor arbitrario de 20 [95] .

6.8. Ejercicios

1 En el listado fork_descendencia.c, si se añade una llamada sleep(5) justo antes de cerrar el loop for, provocaremos que los descendientes tomen más tiempo en crearse. Esto ocasionará que sus padres mueran primero. Así podrá observar claramente la adopción de procesos huérfanos por parte de init:

		sleep(10);
		exit(0);
		}
	sleep(5);
	}

Tras comprobar esto elimine el sleep(10) (dejando el sleep(5)) y modifique el programa para que los padres "esperen" correctamente a los hijos sin que estos sean adoptados por init.

2 Modifique el programa de cálculo de PI para que el número de subprocesos sea un argumento solicitado al usuario.

3 Investigue la llamada al sistema wait4(2) y úsela en el programa del espera.c para averiguar el consumo de cpu del proceso hijo al terminar éste.

4 Cuando un proceso invoca a setpgrp(2), y ésta tiene éxito, su nuevo PGID coincide con el PID. Como recordará, en el programa fork_grupo.c el "nieto" y el "bisnieto" comparten el mismo PGID. El hijo tiene acceso al PID del nieto, y por tanto al PGID del nieto y del bisnieto. Modifique el programa para que el "hijo", pasados 30 segundos, termine al nieto y al bisnieto usando el PGID de éstos. Para esto, deberá leer el manual de kill(2) donde se indica que el uso de un PID negativo equivale a enviar la señal a todos los procesos con PGID igual al valor absoluto de tal "pid".

7. Las funciones exec y el mini-shell

Cuando un proceso requiere iniciar la ejecución de otro programa, utiliza un conjunto de llamadas al sistema conocidas como "funciones exec". Un ejemplo típico lo constituyen los shell’s de los sistemas Linux/Unix, los cuales suelen ejecutar diversas aplicaciones dependiendo de lo que el usuario solicite en la línea de comando.

A tal efecto, en este capítulo desarrollaremos paulatinamente un pequeño programa que intenta emular las funciones fundamentales de un shell; este programa requiere la aplicación de conceptos que se presentan (en orden de importancia) en los capítulos 6, 5 y 8.

7.1. Conceptos Básicos

Linux/Unix permiten la creación de nuevos procesos mediante la llamada al sistema fork(2). Sin embargo, estos procesos son prácticamente idénticos en cuanto a que ejecutan exactamente el mismo programa (el mismo código.) A fin de lanzar un nuevo programa (es decir, cargar y ejecutar un archivo de disco) utilizaremos las "funciones exec" que reemplazarán el código del proceso en ejecución por el del nuevo programa. Téngase en cuenta que esto no crea un nuevo proceso (el PID no varía tras el exec), simplemente reemplaza al que está en ejecución e invocó a exec.

En lo que sigue, intentaremos construir un "shell básico" que ejecutará comandos dependiendo de lo que el usuario le proporcione como entrada.

7.2. Un shell prototipo

A fin de mostrar el uso de las llamadas al sistema "exec" [96] , a continuación se presenta un prototipo para un "shell trivial" que permite ejecutar tres comandos dependiendo de lo que el usuario seleccione mediante un menú. El texto que le sigue muestra una breve sesión con este programa.

/*
 * shell_trivial.c: Ejecutar uno de tres 
 * comandos elegidos por el usuario.
 */
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>

int main()
{
int opcion,i;
char *cmd;

for(;;)
	{
	printf("Seleccione: 1-date 2-ls 3-cal 0=finalizar ? ");
	scanf("%d",&opcion);
	switch(opcion)
		{
		case 0:
			return 0;
		case 1:
			cmd="date"; break;
		case 2:
			cmd="ls"; break;
		case 3:
			cmd="cal"; break;
		default:
			fprintf(stderr,"Error: comando no comprendido\n");
		}
	i=fork();
	if(i==0)
		{
		execlp(cmd,cmd,NULL);
		perror("Exec fallo");
		return 1;
		}
	else
		waitpid(i,NULL,0);
	}
}

Ejemplo de sesión con este programa:

$ ./shell_trivial
Seleccione: 1-date 2-ls 3-cal 0=finalizar ? 1
dom ago  3 12:21:52 PET 2003
Seleccione: 1-date 2-ls 3-cal 0=finalizar ? 2
exec.sxw  ikaro  out  prog1  prog1.c  t5.txt  t8.txt  tx1.txt  xprog6  xprog6.c
Seleccione: 1-date 2-ls 3-cal 0=finalizar ? 3
   agosto de 2003
do lu ma mi ju vi 
                1  2
 3  4  5  6  7  8  9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
Seleccione: 1-date 2-ls 3-cal 0=finalizar ? 0
$

En el programa, el usuario selecciona un valor numérico lo que provoca que se asigne una determinada cadena de texto al puntero "cmd". Luego se crea un proceso hijo, y el hijo inmediatamente invoca a “execlp()” para ejecutar un nuevo programa (que viene en "cmd".) El padre, por su parte, sólo espera a que este programa termine para volver a presentar el menú al usuario. Esto es a grandes rasgos lo que hace un shell.

¿Qué hubiera ocurrido si sólo se hubiese ejecutado el exec, sin el fork? - en ese caso, el "comando" se hubiera ejecutado, pero nunca volveríamos al menú inicial pues este programa ya no estaría en ejecución.

Finalmente, debemos observar con cuidado la sintaxis usada en el execlp. Esta función requiere como primer parámetro el nombre de un programa ejecutable, en nuestro caso, el puntero "cmd". El resto de parámetros corresponden a la lista de argumentos "argv[ ]" a ser pasados al comando, empezando por "argv[0]", que convencionalmente corresponde al nombre del programa (por eso hemos empleado nuevamente a "cmd".) La lista de argumentos se debe terminar con un puntero NULL [97] .

Por ejemplo, si se hubiera deseado ejecutar el comando "ls -l", la función execlp correspondiente sería:

execlp("ls","ls","-l",NULL);

Por otro lado, surge la pregunta natural: ¿cómo encuentra el sistema la ruta donde está el programa "ls"? La respuesta es la famosa variable de entorno [98] "PATH", que contiene la lista de directorios con "ejecutables" donde se busca los programas [99] . Existen otras formas de "exec" que no consultan esta variable de entorno y asumen la presencia de un "pathname" [100] .

Las funciones exec no deben retornar nunca. En nuestro programa, si exec retorna se ejecuta la rutina "perror" y el proceso hijo termina. Típicamente esto ocurre cuando el sistema no puede hallar el programa o no se tiene permiso de ejecución.

7.3. Un shell más inteligente

El ejemplo anterior ilustró el punto central de las funciones exec. Ahora intentaremos elaborar un "shell" totalmente nuevo con las siguientes características:

  1. Podrá recibir comandos con sus argumentos separados por espacios, los cuales serán ejecutados como subprocesos

  2. Interpretará la sintaxis > archivo como redirección de la salida estándar del comando hacia el archivo especificado. Para facilitar el "parsing" obligaremos a introducir espacios antes y después del “\>”.

  3. Podrá interpretar el amperstand (&) al final del comando (separado con un espacio) como ejecución en "background"; en caso contrario será en "foreground" [101].

  4. Podrá reconocer el comando interno "exit" para terminar su ejecución.

  5. Desactivará la señal SIGQUIT durante la introducción de comandos; la señal SIGINT servirá para cancelar el comando introducido hasta el momento. Estas señales se reactivarán para los comandos ejecutados en foreground, manteniéndose anuladas para procesos en background.

Mostraremos y explicaremos el programa por partes. Asumiremos que el programa está constituido por un solo archivo (para facilitar la exposición.) El lector puede fácilmente adaptar el programa para organizarlo en varios archivos.

7.3.1. Inicialización y loop principal

El listado 1 tiene como función princial reprogramar las señales SIGINT, SIGQUIT y SIGCHLD, así como recibir el comando del usuario.

/*
 * Listado 1
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/wait.h>
#include <signal.h>
#include <errno.h>

#define LINE_WIDTH 256
#define N_ARGS 30

int read_a_line(void);
int parse(void);
int process(void);
void cleanup(void);
void intr_handler(int);
void child_handler(int);
void desactivar_senhales(void);
void redireccion(void);

char LINE[LINE_WIDTH];
char DST_FILE[LINE_WIDTH];
char *COMMAND[N_ARGS];
char flag_cancel;
char flag_bg;
char flag_redirect;

int main(int argc,char **argv)
{
struct sigaction sa;

sa.sa_handler=intr_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags=0;
sigaction(SIGINT,&sa,NULL);

sa.sa_handler=SIG_IGN;
sigemptyset(&sa.sa_mask);
sa.sa_flags=0;
sigaction(SIGQUIT,&sa,NULL);

sa.sa_handler=child_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags=SA_RESTART;
sigaction(SIGCHLD,&sa,NULL);

while(1)
	{
	if(read_a_line()==-1)
		continue;
	if(parse()==-1)
		continue;
	if(process()==-1)
		return 0;
	cleanup();
	}
}

Las funciones cuyos prototipos se muestran en el listado 1 se irán explicando poco a poco. El programa asigna un handler a SIGINT (intr_handler()), ignora SIGQUIT, y asigna un handler a SIGCHLD (child_handler().) El handler de SIGINT no debe hacer nada, excepto interrumpir la lectura del texto (ver función read_a_line().) Como se sabe, para esto es necesario que el handler exista (aunque esté vacío) puesto que si la señal simplemente se ignora, entonces la lectura no se interrumpiría [102] . La señal SIGCHLD será enviada por el sistema a nuestro proceso cada vez que uno de sus hijos muera. Esto nos ayudará a evitar los "procesos zombies" [103] . Esto tiene el efecto indeseable de interrumpir la lectura (tal como en el caso de SIGINT) por lo que hemos utilizado el "flag" SA_RESTART [104] , que reinicia la lectura en forma instantánea al terminar la ejecución del handler, con lo que evitamos su interrupción [105] .

Finalmente, las funciones read_a_line() y parse() retornan -1 en caso de error (por lo que se debe volver a leer inmediatamente), mientras que process() retorna -1 cuando se da el comando de salida “exit”, por lo que termina el programa. En caso contrario, se invoca a la rutina cleanup() que limpia la memoria asignada dinámicamente por parse().

7.3.2. Signal handlers y lectura de comandos

/*
 * Listado 2
 */
void intr_handler(int s)
{
}

void child_handler(int s)
{
int r;

do
	r=waitpid(-1,NULL,WNOHANG);
while(r!=0 && r!=-1);
}

int read_a_line(void)
{
int z;

write(STDOUT_FILENO,"$$$ ",4);
z=read(STDIN_FILENO,LINE,LINE_WIDTH-1);

if(z==-1 && errno==EINTR)
	{
	write(STDOUT_FILENO,"\n",1);
	return -1;
	}
if(z==0)
	{
	LINE[0]='\0';
	return 0;
	}
LINE[z-1]='\0';
return 0;
}

El listado 4 presenta los handlers y la función read_a_line(). El handler de SIGCHLD presenta el uso de waitpid(2) con la opción WNOHANG, el cual hace que la función retorne inmediatamente así los procesos hijos no hayan terminado todavía. Como "PID" se ha especificado "-1", lo que significa "esperar a cualquier proceso hijo". De acuerdo a la documentación, waitpid retornará "-1" si ya no hay más hijos por esperar, o "0" si ningún hijo ha terminado todavía [106] . Por este motivo, la invocamos repetidamente (tratando de "esperar" a todos los hijos muertos en hasta el momento), hasta que retorne cualquiera de estos valores. Esto se hace así debido a que podría tenerse varios procesos en background que mueren en forma simultánea.

La lectura del comando se hace mediante read(2). Esta llamada al sistema puede retornar antes de tiempo por una interrupción de señal (en nuestro caso, SIGINT), lo cual se comprueba verificando que el valor de retorno sea "-1" y que “errno” contenga "EINTR" [107] .

Verificamos también si se ha retornado cero caracteres, lo que significa que el usuario presionó sólo [CTRL]+[D] (End Of File), y en caso contario, se asume que se presionó [Enter] para finalizar la línea y éste "fin de línea" es eliminado.

7.3.3. Parsing

/*
 * Listado 3
 */
int parse(void)
{
char *n=LINE;
int z=0;
char *err_msg1="Se requiere archivo de destino\n";
char *err_msg2="Demasiados argumentos\n";

flag_bg=0;
flag_redirect=0;

n=strtok(n," ");
if(n==NULL)
	{
	COMMAND[0]=NULL;
	return 0;
	}
do
	{
	if(strcmp(n,"&")==0)
		{
		COMMAND[z]=NULL;
		flag_bg=1;
		break;
		}
	if(strcmp(n,">")==0)
		{
		COMMAND[z]=NULL;
		n=strtok(NULL," ");
		if(n==NULL)
			{
			cleanup();
			write(STDERR_FILENO,err_msg1,strlen(err_msg1));
			return -1;
			}
		strcpy(DST_FILE,n);
		flag_redirect=1;
		continue;
		}
	if(z==N_ARGS)
		{
		COMMAND[z-1]=NULL;
		cleanup();
		write(STDERR_FILENO,err_msg2,strlen(err_msg2));
		return -1;
		}
	COMMAND[z]=strdup(n);
	z++;
	}
	while((n=strtok(NULL," ")));

COMMAND[z]=NULL;

return 0;
}

El listado 3 sólo realiza el "parsing" de la línea ingresada. Los "tokens" o palabras separadas por espacios se guardan consecutivamente en el array de punteros "COMMAND[ ]". En caso de errores, se retorna -1 liberando la memoria usada con cleanup(). Nótese que los punteros se asignan mediante la rutina strdup(3), por lo que deberán liberarse con free(3). En caso de redirección (\>) o de ejecución en background (\&), se activan las variables de estado “flag_redirect” y “flag_bg”.

7.3.4. Ejecutar los comandos

/*
 * Listado 4
 */
int process(void)
{
int p;

if(COMMAND[0]==NULL)
	return 0;

if(strcmp(COMMAND[0],"exit")==0)
	return -1;

p=fork();

if(p==0)
	{
	if(flag_redirect)
		redireccion();
	if(flag_bg==1)
		desactivar_senhales();
	execvp(COMMAND[0],COMMAND);
	exit(errno);
	}
else
	if(flag_bg==0)
		waitpid(p,NULL,0);
return 0;
}

La función process() utiliza el array "COMMAND[ ]" y ejecuta el programa especificado mediante fork y exec. Antes de esto se verifica si el comando (primer token) es “exit” o si simplemente no hay comando (no se hace nada si el usuario simplemente presiona [ENTER].) De haberse solicitado redirección, se invoca a la función redireccion(). Si se ha solicitado ejecución en background, se desactivan las señales en la función desactivar_senhales(). Nótese que para el caso foreground las señales no se necesitan reactivar. Esto se debe a que "exec" resetea los signal handlers a su valor por omisión si es que éstos están asociados a un signal handler.

La función exec usada en esta ocasión es “execvp”, la cual requiere el nombre del programa como primer parámetro, y un array de punteros para especificar los argumentos como segundo parámetro. Este array de punteros debe terminar en NULL, cosa que ya se ha hecho en parse(). Como sabemos, exec no debe retornar (salvo error.) En ese caso, el proceso hijo no tiene nada más que hacer salvo retornarlo con exit() a fin de que el padre lo analice (cosa que no hacemos aquí.)

Finalmente, si el proceso se lanza en foreground, debemos esperar a que éste termine. Para esto usamos waitpid(2) en su forma más simple (sin el flag WNOHANG.)

7.3.5. Limpieza de memoria

/* listado 5 */
void cleanup(void)
{
char **p=COMMAND;

while(*p)
	free(*p++);
}

La función cleanup() del listado 5 libera la memoria asignada con strdup() en el array COMMAND. De no haberse implementado, el programa continuaría trabajando bien, pero silenciosamente crecería su uso de memoria de modo ilimitado [108] .

7.3.6. Desactivación de señales

/*
 * Listado 6
 */
void desactivar_senhales(void)
{
struct sigaction sa;

sa.sa_handler=SIG_IGN;
sigemptyset(&sa.sa_mask);
sa.sa_flags=0;

sigaction(SIGINT,&sa,NULL);
}

El listado 6 presenta la función “desactivar_senhales()” usada cuando se lanza un proceso en background. Como se aprecia, sólo hemos desactivado SIGINT debido a que estaba asignada al signal handler intr_handler(). SIGQUIT no ha requerido de este tratamiento pues ya estaba ignorada desde el inicio.

SIGCHLD no ha requerido modificación pues al estar asignada al handler child_handler(), es reseteada automáticamente por exec a su "default".

7.3.7. Redireccion de salida estándar

/*
 * Listado 8
 */
void redireccion(void)
{
int fd;
int e;
char *msg_err="Error abriendo el archivo de redireccion\n";

close(STDOUT_FILENO);
fd=open(DST_FILE,O_WRONLY|O_CREAT,0777);
if(fd!=STDOUT_FILENO)
	{
	e=errno;
	write(STDERR_FILENO,msg_err,strlen(msg_err));
	exit(e);
	}
}

El listado 7 presnta el código usado para implementar la redirección. Se ha cerrado el descriptor STDOUT_FILENO (o sea, el número 1) y se ha invocado a open(2) inmediatamente. La llamada al sistema open(2) siempre retorna el descriptor más pequeño que no está abierto. En este caso, sólo podía ser el 1 (puesto que el cero, STDIN_FILENO, ya estaba abierto para leer del terminal.) Esto significa que open() está reabriendo la salida estándar hacia el archivo “DST_FILE”, lo cual será empleado por el programa ejecutado con exec. En caso de error debemos escribir en el "error estándar" (STDERR_FILENO) pues ya no hay salida estándar.

Como se indicó al inicio, todos estos listados conforman un solo gran archivo. Concaténelos y compile el archivo total:

$ cat listado[1234567].c > minishell.c
$ gcc -o minishell minishell.c

Ahora presentamos una sesión (recortada) con nuestro mini-shell:

$ ./minishell
$$$ ls /etc
CORBA            gnome            nethack
Crack            gnome-vfs-2.0    network
Muttrc           gnome-vfs-mime   nsswitch.conf
Muttrc.user-es   gpm.conf         oaf
X11              grep-dctrl.rc    openoffice
...
$$$ ls / > prueba
$$$ cat prueba
bin
boot
...
usr
var
win1
$$$ sleep 10 &
$$$ pwd
/root/prog_unix/cap5
$$$ ps
  PID TTY          TIME CMD
 1086 pts/1    00:00:01 bash
13586 pts/1    00:00:00 prog3
13591 pts/1    00:00:00 sleep
13593 pts/1    00:00:00 ps
$$$ ps
  PID TTY          TIME CMD
 1086 pts/1    00:00:01 bash
13586 pts/1    00:00:00 prog3
13594 pts/1    00:00:00 ps
$$$ exit
$

7.4. Ejercicios

1 Nuestro shell no reporta errores en la ejecución de los procesos hijo. Ni siquiera nos avisa cuando no puede ejecutar el programa solicitado. Para esto el proceso padre deberá averiguar las condiciones en que sus hijos terminan mediante las facilidades proporcionadas en waitpid(2) [109] . Inspírese en el comportamiento de los shell’s de Linux/Unix e intente implementar esto.

2 Cada vez que se ejecute un proceso en background, regístre el comando y el PID en una tabla a fin de que esta pueda ser consultada. Implemente el comando interno “jobs” que lista esta tabla. Verifique que la terminación de los procesos en background limpien paulatinamente esta tabla a fin de mantenerla actualizada. Compare ésto con el comando jobs del shell bash o ksh.

3 Si se elimina el flag SA_RESTART en el handling de SIGCHLD, además del problema con la "interrupción de la lectura" que se describió antes, surgirá otro problema un tanto más sutil. Considérese el caso en que se ejecuta el comando “sleep 20 \&” (en background) e inmediatamente se lanza un “sleep 60” (en foreground.) El primer sleep terminará tras 20 segundos y se lanzará la señal SIGCHLD al "shell", lo que invocará al signal handler (child_handler()), pero tendrá el efecto indeseado de interrumpir el waitpid al proceso en foreground. Compruébese esto en la práctica.

Elimine el flag SA_RESTART (a fin de hacer el programa más portable) y solucione los problemas mencionados. El siguiente código puede resolver el último de éstos inconvenientes.

	if(flag_bg==0)
		{
		while(waitpid(p,NULL,0)==-1 && errno==EINTR)
			;
		}

La "interrupción de la lectura" se puede solucionar activando una variable auxiliar en el hanlder de SIGCHLD que puede usarse para forzar una relectura inmediata. A continuación se muestra parte del código de read_a_line() donde se hace uso de la variable "flag_hijo_murio".


sigo_leyendo:
flag_hijo_murio=0;
z=read(STDIN_FILENO,LINE,LINE_WIDTH-1);

/* Interrupcion por SIGINT o SIGCHLD */
if(z==-1 && errno==EINTR)
        {
        if(flag_hijo_murio)
                goto sigo_leyendo;
        write(STDOUT_FILENO,"\n",1);
        return -1;
        }

Verificar que esto resuelve los inconvenientes.

8. Entrada/Salida a bajo nivel

Cualquier programa que pretenda acceder o conservar información contenida en dispositivos de almacenamiento requiere efectuar I/O (entrada/salida) desde y hacia los archivos correspondientes. Aquí mostraremos cómo se lleva a cabo este proceso en los sistemas Linux/Unix mediante llamadas al sistema.

8.1. Conceptos Básicos

Linux/Unix considera cualquier información de dispositivos de almacenamiento como contenida en "archivos", los cuales deben "abrirse" para efectuar el acceso correspondiente. La "apertura" (open) del archivo, se efectúa haciendo referencia al "nombre del archivo", y si tal "apertura" es exitosa, el sistema asigna un número entero a la nueva conexión programa-archivo. Este número se conoce como "file descriptor" o "descriptor de archivo". La llamada al sistema que permite abrir archivos es open(2).

A partir de ese momento, todas las operaciones que hagan relación a tal conexión se harán con referencia al "file descriptor", y no al nombre del archivo.

Probablemente el lector ya conoce la forma típica de interactuar con los archivos del sistema, a saber, mediante la "librería estándar" (recuérdese, por ejemplo, las funciones fopen, fgets, fprintf, etc.) Esta librería estándar, a fin de cuentas, invoca a las llamadas al sistema que veremos.

8.2. Escribiendo en disco

/*
 * write_trivial: Crear un archivo de prueba con
 * llamadas al sistema
 */
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <errno.h>

int main()
{
int fd;

fd=open("/tmp/trivial.txt",O_WRONLY|O_CREAT,0666);
if(fd==-1)
	perror("open fallo");

write(fd,"xyz\n",4);

close(fd);
return 0;
}

El descriptor obtenido con open(2) será empleado únicamente para escribir información en el archivo, por lo que se usa la constante de "tipo de acceso" O_WRONLY.

Table 7. Especificaciones de tipo de acceso
Constante Significado

O_WRONLY

Sólo lectura

O_RDONLY

Sólo escritura

O_RDWR

Lectura y escritura

Además, si el archivo especificado no existe, deseamos que sea creado, para lo cual se añade el modificador O_CREAT. La presencia de este flag obliga a especificar el modo o "permiso" de creación del archivo como tercer argumento de open(2). Hemos usado el modo 0777 [110] , lo cual constituye los "permisos iniciales" que se reducirán con la "máscara de permisos del usuario" (umask.) [111] Si el archivo ya existiera, este tercer argumento es obviado [112] .

Al igual que la mayoría de llamadas al sistema, si open(2) falla, retorna "-1" y se puede consultar errno para más detalles.

Luego, se escribe información en el archivo mediante write(2), la cual requiere especificar el descriptor, un puntero a la información a escribir, y cuántos bytes se escribirán. Finalmente el archivo se cierra con close(2).

8.3. Los buffers son "volátiles"

El programa write_kill.c muestra un programa que crea dos archivos y graba cierta información en ellos, usando llamadas al sistema y la librería estándar respectivamente. Antes de cerrarse los archivos (con close() y fclose() respectivamente) el proceso muere con SIGKILL [113] . El texto subsiguiente muestra los resultados del programa.

/*
 * write_kill: Crear archivos de prueba con
 * llamadas al sistema y libreria estandar
 */
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <errno.h>
#include <signal.h>

int main()
{
int fd;
FILE *fp;

fd=open("/tmp/wk1",O_WRONLY|O_CREAT,0666);
write(fd,"Mitsubishi\n",11);

fp=fopen("/tmp/wk2","w");
fputs("Mitsubishi\n",fp);

raise(SIGKILL);

return 0;
}

Algunos resultados:

$ ./write_kill
Terminado (killed)
$ cat /tmp/wk1
Mitsubishi
$ cat /tmp/wk2
$

Lo que se puede apreciar es que la llamada al sistema write(2) sí cumplió su cometido, mientras que fputs(3) no lo hizo. Esto último ocurrió porque fputs(3), siendo parte de la librería estándar, intenta escribir en sus buffers hasta llenarlos, antes de llevar a cabo la grabación real (con write(2).) En este caso la señal dió muerte al proceso y la información del buffer se perdió [114] .

Write(2), en cambio, no emplea este tipo de buffer, y logra escribir en el archivo. Sin embargo, esto no significa necesariamente que la información recién grabada resida físicamente en el disco duro, pues la información escrita por write(2) normalmente también va a un buffer administrado por el kernel (buffer caché), que no está afecto a la terminación del proceso como el buffer de la librería estándar. Pero si el sistema sufriera una "interrupción eléctica" apenas terminado el programa, la información "grabada" por write(2) (que reside en el buffer caché) también se podría perder.

El sistema operativo efectúa la verdadera grabación del "buffer caché" cada cierto tiempo en forma automática y transparente. De acuerdo con esto, si la interrupción eléctica ocurriera "algún tiempo" después de la ejecución del programa, los datos sí estarían físicamente en el disco y no se perderían. Con el fin de evitar que esto ocurra, existen maneras de forzar escrituras "reales" en el disco con cada write() [115] .

8.4. I/O síncrona

La llamada al sistema open(2) permite especificar una serie de "flags" que modifican el comportamiento de las operaciones de I/O. Uno de ellos es el flag “O_SYNC” que genera "escrituras reales" en el disco. El uso de esta opción, por tanto, hará más lentas las escrituras.

/*
 * write_sync.c
 * write en modo sincrono
 */
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <errno.h>

#define SIZ 10000

int main()
{
long z;
int fd;

fd=open("/tmp/write_sync.txt",O_WRONLY|O_CREAT|O_SYNC,0666);
if(fd==-1)
	perror("open fallo");

for(z=0;z<SIZ;z++)
	write(fd,"x",1);

close(fd);
return 0;
}

En mi sistema el tiempo requerido para el proceso fue de 8.5 segundos aproximadamente; en cambio, al eliminar la opción O_SYNC, este tiempo se redujo a 0.13 segundos. Es evidente que la versión "con buffer caché" es mucho más veloz.

8.5. Los descriptores estándar

De modo convencional, el shell y casi todas las aplicaciones Linux/Unix reservan los archivos cuyos descriptores son "0", "1" y "2" para la "entrada estándar", "salida estándar" y "error estándar", respectivamente. Normalmente, estos tres archivos corresponden al "terminal del control" donde el usuario ejecuta los procesos, y así, leer el descriptor "0" equivale a leer desde el teclado; escribir en los descriptores "1" o "2", equivalen a escribir en la pantalla. Aunque es bastante seguro usar estos números, el estándar POSIX.1 recomienda usar en su lugar las constantes STDIN_FILENO, STDOUT_FILENO, y STDERR_FILENO, respectivamente. El siguiente ejemplo (descriptores.c) muestra un programa trivial de interacción con el usuario que hace uso de estos descriptores "estándar". Nótese que estos no requieren ser abiertos con open() pues el shell ya los ha abierto por nosotros. Este ejemplo hace uso de la llamada al sistema read(2) que lee información de un archivo especificado por su descriptor [116]). ] .

/*
 * descripores.c: Descriptores estandar
 */
#include <unistd.h>
#include <string.h>

int main()
{
char *quest="Escriba algo: ";
char L[256];
char R[512]="Ud. escribio: ";
int i;

write(STDOUT_FILENO,quest,strlen(quest));
i=read(STDIN_FILENO,L,256);
L[i]='\0';

strcat(R,L);
write(STDOUT_FILENO,R,strlen(R));

return 0;
}

En el texto siguiente se aprecia claramente cómo se comporta este programa en su interacción con el terminal y cuando la salida estándar es redirigida a un archivo llamado “ikaro”:

$ ./descriptores
Escriba algo: Jojolete
Ud. escribio: Jojolete
$ ./descriptores > ikaro
Ninfas de la tierra
$ cat ikaro
Escriba algo: Ud. escribio: Ninfas de la tierra

8.6. Alterando archivos abiertos

La llamada al sistema open() permite especificar un conjunto de opciones relacionadas a la apertura del archivo. A modo de ejemplo, la opción O_APPEND especifica que toda escritura con write() siempre añadirá los datos al final del archivo:

fd=open("nombre archivo",O_WRONLY|O_CREAT|O_APPEND,0666);

En ciertos casos se desea añadir (o eliminar) estas opciones luego de que el archivo ha sido abierto (después del open()), lo cual se puede conseguir mediante la llamada al sistema fcntl(2).

El programa append.c abre un mismo archivo en dos oportunidades. Luego, usando uno de los descriptores de archivo (fd1), escribe un texto en aquél. En este momento el segundo descriptor (fd2) todavía "apunta" al inicio del archivo, por lo que si se emplea para escribir, destruiría lo que se hizo vía fd1.

Aquí entra en acción el flag O_APPEND: obsérvese que fcntl(2) es usada en dos ocasiones, la primera para obtener la información actual acerca de las "opciones" del archivo abierto (F_GETFL), lo que proporciona un valor entero status al que se le aplica el flag O_APPEND con una operación "or". Luego, este nuevo estado se "graba" con F_SETFL [117] .

/*
 * append.c: Demostracion de fcntl con el flag
 * O_APPEND
 */
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <errno.h>

int main()
{
int fd1,fd2,status;

fd1=open("/tmp/append.txt",O_WRONLY|O_CREAT,0666);
fd2=open("/tmp/append.txt",O_WRONLY|O_CREAT,0666);

write(fd1,"xyz\n",4);

/* Setear modo append para fd2 */
status=fcntl(fd2,F_GETFL,0);
status|=O_APPEND;
fcntl(fd2,F_SETFL,status);

write(fd2,"abc\n",4);

close(fd1); close(fd2);
return 0;
}

El resultado del programa:

$ cat /tmp/append.txt
xyz
abc

Fcntl(2) permite modificar otros flags del archivo abierto, pero no los discutiremos aquí.

8.7. Archivos especiales

Como se sabe, el sistema Linux/Unix suele interactuar con los dispositivos de hardware mediante "archivos especiales". Por ejemplo, un disco duro puede ser accesado como /dev/hda, un diskette como /dev/fd0, etc. La mayoría de los dispositivos de hardware transmiten o reciben cierta información, para lo cual el programador normalmente empleará las conocidas llamadas al sistema read(2) y write(2) para leer y escribir, desde y hacia el dispositivo, respectivamente.

Sin embargo, en muchos casos no basta con leer y escribir puesto que los dispositios de hardware suelen ser capaces de realizar otras operaciones. Un ejemplo típico se presenta en el siguiente programa (expulsion.c), el cual solicita la expulsión de un CDROM a la unidad correspondiente. Antes de ejecutarlo, asegúrese de que un CDROM esté dentro de la unidad, y que no esté en uso ni montado como filesystem [118] .

/*
 * expulsion.c: expulsion de CDROM
 */
#include <sys/ioctl.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>

/* Especifica de linux */
#include <linux/cdrom.h>

/* Modificar como convenga */
#define DEVICE "/dev/cdrom"

int main()
{
int fd,i;
fd=open(DEVICE,O_RDONLY);
if(fd==-1)
	{
	perror("No se pudo abrir " DEVICE);
	return 1;
	}
i=ioctl(fd,CDROMEJECT);
if(i==-1)
	{
	perror("No se pudo aplicar ioctl");
	return 1;
	}
return 0;
}

La llamada al sistema ioctl(2) sirve para dar cabida a cualquier operación "no convencional" que va más allá de la lectura/escritura de información. Su primer argumento es el decriptor del archivo abierto (al dispositivo de hardware) mientras que el segundo corresponde a un "comando" aplicado sobre el dispositivo. Dependiendo del "comando", puede requerirse argumentos adicionales en el mismo ioctl. Lamentablemente muchos de estos comandos no están estandarizados y son incompatibles entre diversos sistemas Unix, o Linux’es con kernels muy desfasados. Este ejemplo usa el "comando" CDROMEJECT definido en el header <linux/cdrom.h> [119] . Esto, evidentemente, sólo es aplicable en Linux, debiendo el lector buscar métodos análogos para otros Unix’es.

8.8. Ejercicios

1 Open y permisos

A Investigue la documentación de open(2). Esta llamada al sistema permite crear un nuevo archivo con determinados "permisos" gracias a su tercer argumento. Modifique el write_trivial.c a fin de que el archivo sea creado con permiso "600".

B Es poco probable que sólo con open(2) Ud. pueda generar un archivo con un permiso tan permisivo como 0777. Hay dos maneras de conseguirlo, a saber, 1) modificando previamente al open(2) la "mascara de creación" con la llamada al sistema umask(2), y 2) modificando posteriormente al open(2) los permisos de archivo con la llamada al sistema chmod(2). Investigue estas llamadas al sistema e intente implementar ambos métodos en write_trivial.c.

C A fin de crear un archivo nuevo se ha empleado open(2) con el flag O_CREAT. Investigue la llamada al sistema creat(2) y úsela en write_trivial.c para reemplazar a open.

2 Añadido y eliminación de flags con fcntl(2)

A Modifique write_sync.c a fin de añadir la opción O_APPEND como parte de los flags de open(2). Esto provocará que la información grabada se añada al final del archivo “/tmp/write_sync.txt”. Verifíquelo observando los tamaños del archivo antes y luego de la ejecución.

B Luego vuelva a modificar este mismo programa para mediante fcntl(2) después del open(2), se "desactive" el flag O_APPEND. Verifíquelo observando los tamaños del archivo resultante, antes y luego de la ejecución.

C Investigue la llamada al sistema lseek(2) y úsela para lograr un efecto similar al flag O_APPEND de la parte "A" de este ejercicio.

D De modo análogo a la parte "B" de este ejercicio, intente eliminar el flag O_SYNC. En las versiones actuales de Linux esto no funciona, siendo una limitación. Verifíquelo tomando tiempos a la ejecución. Pruébelo en otros sistemas Unix.

4 Geometría del disco duro

Adapte el programa expulsion.c para leer la geometría de un disco duro (como /dev/hda o lo que convenga según el caso.) Para esto, deberá usar el ioctl: HDIO_GETGEO, el cual requiere como argumento adicional un puntero a una estructura de tipo “hd_geometry”, lo cual está definido en el archivo header <linux/hdreg.h>. Las modificaciones a hacer son:

Archivo header

#include <linux/hdreg.h>

El dispositivo según el caso

#define DEVICE "/dev/hda"

La estructura

struct hd_geometry geom;

El ioctl

i=ioctl(fd,HDIO_GETGEO,&geom);

Ver los resultados

printf("Heads: %u Sectors: %u Cyls: %u\\n", geom.heads, geom.sectors, geom.cylinders);

Compare el resultado de este programa con la salida de fdisk(8):

[root@edithpiaf]# fdisk -l /dev/hda

Disco /dev/hda: 255 cabezas, 63 sectores, 7297 cilindros
Unidades = cilindros de 16065 * 512 bytes
...
[root@edithpiaf]# ./leer_geometria
Heads: 255 Sectors: 63 Cyls: 7297

9. Directorios y Subdirectorios

Los directorios son archivos especiales usados para contener el nombre de otros archivos. Mediante unas pocas funciones sencillas de utilizar, es posible lograr su manipulación.

En este capítulo desarrollaremos dos programas principales, a saber, un "clone" del popular comando DIR del sistema DOS, y un prototipo de "file-manager" (administrador de archivos), ambos con la finalidad de ilustrar la programación con directorios. Este último ejemplo requiere conceptos de la librería curses (capítulo {curses_num}.)

9.1. DIR ala DOS

El siguiente listado presenta una imitación del comando DIR del sistema DOS (al que denominamos xdir.) Esto demuestra el uso típico de las rutinas opendir(), readdir() y _closedir() [120] .

Cuando se abre un directorio con la rutina opendir(), ésta devuelve un puntero a una variable de tipo DIR (definida en dirent.h) que representa un "handler" al directorio abierto.

Una vez abierto el directorio, se procede a leer su contenido. Para esto usamos la función readdir() que recibe el handler obtenido con opendir() y devuelve un puntero a una estructura de tipo dirent (también definida en dirent.h.) En realidad es muy sencillo de usar no obstante la mezcla de los dos punteros.

/*
 * xdir.c: Lee el directorio actual
 */
#include <sys/types.h>
#include <dirent.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>

int main()
{
DIR *dh;
struct dirent *de;

dh=opendir(".");
if(dh==NULL)
	fprintf(stderr,"No pude abrir directorio actual (%d)\n",errno),exit(1);
while((de=readdir(dh)))
	printf("%s\n",de->d_name);
closedir(dh);
return 0;
}
  • opendir requiere como argumento el nombre del directorio a abrirse

  • Tras la lectura del directorio, éste se debe cerrar con closedir() a fin de no desperdiciar recursos

  • El miembro de la estructura dirent llamado d_name contiene el nombre del archivo actual. En mi sistema, por ejemplo, es de tipo char[256]

9.1.1. La estructura dirent

Con esta estructura se puede obtener información adicional al nombre de cada archivo. En mi sistema, la orden man 3 readdir muestra:

struct dirent {
	long            d_ino;        /* num inodo*/
	off_t           d_off;        /* despl. al siguiente dirent */
	unsigned short  d_reclen;     /* long. de este registro */
	unsigned char   d_type;       /* tipo de fichero */
	char            d_name[256];  /* nombre del fichero */
};

9.2. Dirclone: xdir mejorado

Ahora, basandonos en xdir.c, haremos algunos añadidos. Trataremos de generar un comportamiento un poco más parecido al del original DOS, soportando algunas opciones y argumentos. El programa resultante (dirclone.c) constará de un solo archivo, pero para la explicación lo dividiremos en varios listados más pequeños. El lector deberá simplemente concatenarlos:

cat listado[12345].c > dirclone.c

La primera parte (listado1.c)recogerá las opciones y los argumentos. Leerá los directorios solicitados y almacenará los contenidos:

/*
 * listado1.c (dirclone)
 */
#include <sys/types.h>
#include <sys/stat.h>
#include <dirent.h>
#include <unistd.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
#include <time.h>

int getdir(int n);
int impresion(int n);
int check_line(int x);
int compara(char **,char**);
void parse_opciones(void);
void sort_dir(void);
char *mayusculas(char *x);

char *directorio[100]; int ndirectorio=0;
char **archivo; int narchivo;
char *opcion[5]; int nopcion=0;

char opt_wide=0, opt_pause=0, opt_sort=0, opt_may=1;

int main(int argc,char **argv)
{
int n=1;

for(;n<argc;n++)
	{
	if(argv[n][0]=='/')
		{
		if(nopcion==5)
			fprintf(stderr,"Demasiadas opciones\n"),exit(1);
		else
			opcion[nopcion++]=strdup(argv[n]);
		}
	else
		{
		if(nopcion==100)
			fprintf(stderr,"Muchos directorios\n"),exit(1);
		else
			directorio[ndirectorio++]=strdup(argv[n]);
		}
	}
parse_opciones();
if(ndirectorio==0)
	directorio[ndirectorio++]=strdup(".");
		
for(n=0;n<ndirectorio;n++)
	{
	printf("\nDirectorio de %s:\n",directorio[n]);
	getdir(n);
	if(opt_sort)
		sort_dir();
	impresion(n);
	}
return 0;
}

Los arreglos de punteros “directorio” y “archivo” contienen respectivamente, la lista de directorios seleccionados por el usuario, y la lista de archivo en el directorio que se está procesando en un momento dado.

Este programa admite las opciones:

  • /W Listado horizontal breve

  • /P Pausa para visualizar por páginas

  • /L No convertir a mayúsculas

  • /O Ordenar en orden alfabético por nombre

Las rutas se deben especificar con backslash (tal como en DOS.) Así por ejemplo, es válido ejecutar:

$ dirclone \\etc\\sysconfig  /W

Nótese que se requiere doble backslash debido al significado especial que tiene este símbolo en el shell.

Finalmente, nuestro dirclone.c aceptará más de un directorio como argumento (cosa que no era posible en el antiguo DIR.)

9.2.1. Procesamiento de opciones

/*
 * listado2.c (dirclone)
 */
void parse_opciones(void)
{
int z,j;

for(z=0;z<nopcion;z++)
	{
	for(j=0;j<strlen(opcion[z]);j++)
		switch(tolower(opcion[z][j]))
			{
			case 'w': opt_wide=1; break;
			case 'p': opt_pause=1; break;
			case 'o': opt_sort=1; break;
			case 'l': opt_may=0; break;
			/* Otras? */
			}
	}
}

Este código es sencillo. Dependiendo de las opciones del usuario se activarán las variables auxiliares opt_*. Nótese que las opciones pueden fundirse (/W /O = /W/O = /WO) y pueden usarse en mayúsculas o minúsculas.

9.2.2. Cargar el directorio

/*
 * listado3.c (dirclone)
 */
int getdir(int nd)
{
int z;
DIR *dh;
struct dirent *de;
char *x=directorio[nd];

for(z=0;z<strlen(x);z++)
	if(x[z]=='\\')
		x[z]='/';
dh=opendir(x);
if(dh==NULL)
	{
	perror("DIRCLONE");
	return -1;
	}
archivo=NULL;
narchivo=0;
while((de=readdir(dh)))
	{
	archivo=realloc(archivo,(narchivo+1)*sizeof(char*));
	archivo[narchivo]=strdup(de->d_name);
	narchivo++;
	}
closedir(dh);
return 0;
}

Aquí leemos las entradas del directorio "nd" (indice al array directorio.) Cada vez que se lee un directorio la variable archivo se inicializa a NULL para que realloc pueda funcionar adecuadamente. El realloc hace crecer al área apuntada por archivo conforme se lee el directorio. Cada entrada se genera haciendo una copia con strdup.

9.2.3. Listado breve

La función de impresión del directorio es algo extensa por lo que la explicaremos en dos partes. La primera corresponde a la impresión abreviada horizontal (opción /W) y la segunda a la impresión lineal normal.

/*
 * listado4.c (dirclone)
 */

#define MORE_LINE l++;if(check_line(l))l=0
int impresion(int nd)
{
int z,na=0,nnd=0;
char path[1024];
struct stat s;
unsigned long total=0;
static int l=0;

MORE_LINE; MORE_LINE;
if(opt_wide)
	{
	int k=0;
	for(z=0;z<narchivo;z++)
		{
		sprintf(path,"%s/%s",directorio[nd],archivo[z]);
		stat(path,&s);
		if(S_ISDIR(s.st_mode))
			{
			char *p=malloc(3+strlen(archivo[z]));
			sprintf(p,"[%.13s]",archivo[z]);
			free(archivo[z]); archivo[z]=p;
			nnd++;
			}
		else
			{
			na++;
			total+=s.st_size; MORE_LINE;
			}
		printf("%-15.15s ",mayusculas(archivo[z]));
		if(k++==4)
			{ k=0; putchar('\n'); MORE_LINE; }
		}
	if(k)
		{ putchar('\n'); MORE_LINE; }
	}
else

Nótese que usamos la llamada al sistema stat(2) para obtener información adicional del archivo, como saber si se trata de un subdirectorio o su tamaño. Para esto, stat recibe un puntero a una estructura tipo stat (asignada por el usuario) que hemos llamado "s". En mi sistema, cuando consulto man 2 stat obtengo información sobre esta estructura:

struct stat
  {
      dev_t         st_dev;      /* dispositivo */
      ino_t         st_ino;      /* inodo */
      mode_t        st_mode;     /* protección */
      nlink_t       st_nlink;    /* número de enlaces físicos */
      uid_t         st_uid;      /* ID del usuario propietario */
      gid_t         st_gid;      /* ID del grupo propietario */
      dev_t         st_rdev;     /* tipo dispositivo (si es
                                    dispositivo inodo) */
      off_t         st_size;     /* tamaño total, en bytes */
      unsigned long st_blksize;  /* tamaño de bloque para el
                                    sistema de ficheros de E/S */
      unsigned long st_blocks;   /* número de bloques asignados */
      time_t        st_atime;    /* hora último acceso */
      time_t        st_mtime;    /* hora última modificación */
      time_t        st_ctime;    /* hora último cambio */
  };

Este manual detalla también el uso de la macro S_ISDIR que permite saber si el archivo es un directorio.

Note también que hemos truncado los nombres de archivo a 15 caracteres (y los [directorios] a 13) mediante la "precisión" del especificador de formato %s de printf, sólo para efectos de visualización.

9.2.4. Listado largo

Aquí también truncamos los nombres de archivo, en este caso, a 20 caracteres.

	{
	
	for(z=0;z<narchivo;z++)
		{
		time_t t;
		struct tm *dt;

		sprintf(path,"%s/%s",directorio[nd],archivo[z]);
		stat(path,&s); t=s.st_mtime;
		dt=localtime(&t);
		if(S_ISDIR(s.st_mode))
			{
			printf("%-20.20s <DIR>      "
			"%.2d/%.2d/%.2d %.2d:%.2d\n",
			mayusculas(archivo[z]),
			dt->tm_mday,dt->tm_mon+1,dt->tm_year-100,
			dt->tm_hour,dt->tm_min);
			MORE_LINE;
			nnd++;
			}
		else
			{
			printf("%-20.20s   %8ld "
			"%.2d/%.2d/%.2d %.2d:%.2d\n",
			mayusculas(archivo[z]),s.st_size,
			dt->tm_mday,dt->tm_mon+1,dt->tm_year-100,
			dt->tm_hour,dt->tm_min);
			total+=s.st_size; MORE_LINE;
			na++;
			}
		}
	};
printf("      %d archivos     %ld bytes\n",na,total);
MORE_LINE;
printf("      %d directorios\n",nnd);
MORE_LINE;
return 0;
}

Obsérvese al final la conversión a la hora en formato dd/mm/yy hh:mm usando la estructura tm y la rutina localtime.

En estos listados se aprecia el uso de nuestra macro llamado MORE_LINE usada para verificar, cada vez que se imprime una línea, si necesitamos hacer una pausa (opción /P.) Nótese que este análisis lo realiza la función check_line (ver abajo) gracias a la variable estática "l" que guarda el número de líneas impresas.

9.2.5. Verificación de líneas impresas

La función check_line es autoexplicativa.

/*
 * listado6.c (dirclone)
 */
int check_line(int x)
{
if(opt_pause==0)
	return 0;
if(x<24)
	return 0;
printf("Presione Enter para continuar . . .");
getchar();
return 1;
}

9.2.6. Ordenación

Hemos empleado la función qsort que requiere de una función auxiliar para hacer las comparaciones (función compara.)

/*
 * listado7.c (dirclone)
 */

int compara(char **x,char**y)
{
return strcmp(*x,*y);
}

void sort_dir(void)
{
qsort(archivo,narchivo,sizeof(char*),
  (int (*)(const void *,const void *))compara);
}

9.2.7. Mayúsculas o minúsculas

Este código evita la conversión a mayúsculas si la opción /L está activada; en caso contrario, convierte cada caracter a mayúsculas con toupper().

/*
 * listado8.c (dirclone)
 */

char *mayusculas(char *x)
{
static char M[1024];
int z;

if(!opt_may)
	return x;
for(z=0;z<strlen(x);z++)
	M[z]=toupper(x[z]);
M[z]='\0';
return M;
}

Esto completa nuestro dirclone.c. Vea los ejercicios para más ideas.

9.3. Prototipo de File-Manager

Este programa es más ambicioso, aunque no muy complejo. Intenta crear la base para un "administrador de archivos" usando la pantalla completa usando Curses/Ncurses. Nuestra versión sólo sirve para navegar entre directorios, pero es relativamente sencillo adaptarla para hacer otras cosas más útiles.

Al igual que en el caso anterior, el programa consta de un solo archivo, cuyas partes se mostrarán consecutivamente.

El primer listado corresponde a la inicialización. Se obtiene el directorio inicial (el actual o el primer argumento de línea de comando) y se inicia el loop sin final de la función run() que es la que hace el trabajo real.

/*
 * listado1.c (mini file manager)
 */
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <dirent.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ncurses.h>
#include <pwd.h>
#include <grp.h>

int run(void);
char *gr(int g);
char *us(int u);

char ruta[1024];

int main(int argc,char **argv)
{
struct stat s;

if(argc>1)
	strcpy(ruta,argv[1]);
else
	strcpy(ruta,".");
if(stat(ruta,&s)==0 && S_ISDIR(s.st_mode))
	{
	int e;
	
	initscr();
	keypad(stdscr,TRUE);
	for(;;)
		if((e=run()))
			break;
	endwin();
	if(e==-1)
		{ perror("filemanager"); exit(1); }
	return 0;
	}
else
	fprintf(stderr,"Especifique una ruta valida.\n"),exit(1);
}

9.3.1. Almacenamiento del directorio

La función run efectúa la apertura y almacenamiento dinámico del contenido del directorio (en el puntero archivo) y luego interactúa con el usuario. Aquí mostramos sólo la primera parte.

Obsérvese que hemos cambiado el directorio actual del proceso (chdir) y luego hallamos la ruta absoluta usando la ruitina getcwd. Esto nos evita tener que analizar rutas relativas y casos especiales (como el "..".)

/*
 * listado2.c (mini file manager)
 */
int run(void)
{
DIR *dh;
struct dirent *de;
int narchivo=0,pos=0,offset=0,z,k;
char **archivo=NULL;
struct stat s;

if(chdir(ruta)==-1)
	return -1;
if(getcwd(ruta,1024)==NULL)
	return -1;

dh=opendir(ruta);
if(dh==NULL)
	return -1;
while((de=readdir(dh)))
	{
	archivo=realloc(archivo,(narchivo+1)*sizeof(char*));
	archivo[narchivo]=strdup(de->d_name);
	narchivo++;
	}
closedir(dh);

9.3.2. Interacción con el usuario

Esta interacción se da en un loop infinito que muestra el directorio (o parte de éste), recepciona los comandos del usuario por el teclado, y los procesa.

Dejamos el procesamiento para el siguiente listado.

/*
 * listado3.c (mini file manager)
 */

for(;;)
	{
	static char FDIR[257];
	
	clear();
	printw("DIRECTORIO:%s\n\n",ruta);
	for(z=0;z<21;z++)
		{
		if(offset+z>=narchivo)
			break;
		if(offset+z==pos) attron(A_REVERSE);
		stat(archivo[offset+z],&s);
		if(S_ISDIR(s.st_mode))
			{
			attron(A_BOLD);
			sprintf(FDIR,"%s/",archivo[offset+z]);
			printw(" %-30.30s",FDIR); attroff(A_BOLD);
			}
		else
			printw(" %-30.30s",archivo[offset+z]);
		if(offset+z==pos) attroff(A_REVERSE);
		printw("   %4o  %-8s  %-8s\n",
		s.st_mode&0777,us(s.st_uid),gr(s.st_gid));
		}	
	move(pos-offset+2,31);
	k=getch();

Nótese que hacemos uso de las características de video invertido y video brillante del terminal para el archivo actual y para los directorios, respectivamente. Además, la variable FDIR se usa para guardar el nombre de los directorios con un slash añadido (/) al final. Por otro lado, mostramos los permisos en formato octal mientras que los usuarios y grupos propietarios se obtienen en funciones aparte (us y gr.)

9.3.3. Respondiendo al usuario

Esto termina el contenido de la función run():

/*
 * listado4.c (mini file manager)
 */

	switch(k)
		{
		case KEY_DOWN:
			if(pos<narchivo-1) pos++;
			if(pos-offset>20) offset++;
			break;
		case KEY_UP:
			if(pos>0) pos--;
			if(offset>pos) offset--;
			break;
		case '\n':
			stat(archivo[pos],&s);
			if(S_ISDIR(s.st_mode))
				{
				sprintf(ruta,"%s/%s",ruta,archivo[pos]);
				for(z=0;z<narchivo;z++)
					free(archivo[z]);
				free(archivo);
				return 0;
				}
			break;
		case 033:
			return 1;
		}
	}
return 0;
}

El único caso interesante se da cuando el usuario presiona [ENTER] sobre un directorio. Esto crea un nuevo PATH al cual se reingresa con chdir, para lo cual se retorna a main con valor cero. Antes de esto se limpia la memoria asociada a archivo.

La tecla [ESCAPE] (código 033) permite terminar la aplicación.

9.3.4. Obtener nombres de usuarios y grupos

Las funciones us y gr ilustran el uso de las rutinas getpwuid y getgrgid que requieren de punteros a estructuras especiales definidas en pwd.h y grp.h.

/*
 * listado5.c (mini file manager)
 */
char *us(int u)
{
struct passwd *usp;
usp=getpwuid(u);
return usp->pw_name;
}

char *gr(int g)
{
struct group *grp;
grp=getgrgid(g);
return grp->gr_name;
}

9.4. Ejercicios

1 Correcciones a DIRCLONE

A) DIRCLONE tiene una falla de memoria que debe ser corregida: cada vez que se procesa un directorio, se asigna memoria para el puntero índice archivo y para cada uno de los strings (nombres de archivo) asociados a los elementos de éste. Cuando se especifica más de un directorio a listarse, esta memoria se reasigna para los nuevos elementos, pero nunca se borra la anteriormente utilizada.

B) Cuando DIRCLONE recibe un archivo como argumento (y no un directorio), inocentemente intenta listarlo provocando un error. Observe el comportamiento del comando DIR del DOS para este caso e impleméntelo.

C) Cuando DIRCLONE recibe una opción inválida, no emite ningún mensaje. Analice el comportamiento del DIR original en este caso. Si desea, puede implementar la opción de ayuda /?.

D) El original DIR en el listado largo, separa el nombre de la extensión en columnas separadas. Impleméntelo.

2 Listado recursivo

Extienda DIRCLONE para que acepte una nueva opción "/S" que permita listar recursivamente todos los subdirectorios contenidos a partir del especificado.

3 Ordenamiento mejorado

Si revisa la ayuda del DIR original, notará que es posible ordenar el listado de diversos modos (opciones /ON, /OE, /OS, etc.)

4 File manager útil

El mini file manager sólo permite navegar por los directorios del sistema. Implemente los siguientes comandos extendiendo el switch…​case…​ que procesa las teclas recibidas:

A) La tecla [ENTER] puede servir para ejecutar un archivo si éste es ejecutable (consultar el manual de stat(2) para más información.) En caso contrario se puede invocar al editor "vi" para editarlo.

B) La tecla [F5] puede usarse para "refrescar" el listado que se está visualizando (por ejemplo, si se sospecha que algunos archivos han sido creados/borrados/alterados.)

C) La tecla [DELETE] puede usarse para borrar un archivo. El programa debería pedir confirmación.

5 Extras para el file manager

A) Imprima una barra de ayuda donde se describan los comandos disponibles.

B) Añada la fecha de modificación al listado para cada archivo. Puede usarse un formato compacto como en el caso de DIRCLONE.

C) Genere una caja que encierre toda la aplicación a fin de darle mejor aspecto.

D) En el ejercicio anterior, parte "A", en lugar de editar un archivo con "vi" por el hecho de no ser ejecutable, conviene saber si realmente es de texto (caso contrario "vi" no ayudará mucho.) Una forma rápida de hacer esto es invocando a las utilidades file y grep y usar el valor de retorno. Por ejemplo:

file /etc/passwd | grep text > /dev/null
echo $?
0
file /bin/cp | grep text > /dev/null
echo $?
1

La línea de comandos "file %s | grep text > /dev/null" se puede usar como entrada para la rutina system(), que la ejecutará con ayuda del shell. Consulte el manual de esta rutina para indagar por el valor de retorno.

6 Ordenar archivos por fechas [121]

El listado obtenido mediante ls permite ordenar los archivos de uno o más directorios por fechas de modificación (opción -t.) Sin embargo, cuando se desea buscar los últimos archivos modificados en una estructura de directorios esto no sirve. Incluso con la opción -R (listdo recursivo) el ordenamiento sólo se refiere a los archivos dentro de cada directorio.

Se pide crear un programa que reciba como argumentos el nombre de uno o más directorios, y que ordene por fecha a todos los archivos contenidos en éstos (incluso en sus subdirectorios.) Se puede usar qsort() para efectuar el ordenamiento. El programa arrojará un listado ordenado con las fechas de modificación de los archivo, y sus respectivos nombres.

6 Construya un programa utilitario llamado "weirdfiles", el cual analiza los archivos con nombres extraños de un directorio y permite al usuario renombrarlos interactivamente. Los nombres extraños son aquellos que contienen caracteres no imprimibles, comodines del shell, espacios y un guión (dash) como primer caracter. Añada una opción (-a) para que el renombramiento sea automático (no interactivo.)

$ ./weirdfiles -a .
Se renombro 20 archivos con nombres problematicos.
$ ./weirdfiles directx
Archivo Klima\0x98kito*5 tiene nombre problematico.
Nuevo nombre? Klimaquito_5
$

10. Bases de datos en archivos indexados

10.1. Introducción

Es frecuente la necesidad de archivar información de manera eficiente, cómoda y segura. Los sistemas de bases de datos intentan satisfacer estos requerimientos de distintas formas y bajo diversas estrategias. Por ejemplo, los llamados DBMS (database management systems) suelen ser complejos programas (muchas veces con servidores dedicados) que proporcionan un increible espectro de funciones para usuarios y aplicaciones.

Sin embargo, también es común que los programas requieran almacenar un conjunto de datos sin depender de un sistema auxiliar tipo DBMS. Por ejemplo, un pequeño programa que guarde el catálogo de discos de mi abuelo no debería requerir la adquisición, configuración y mantenimiento de un (gigante) sistema DBMS. En esos casos los programadores pueden recurrir a la creación de archivos "planos" cuyo formato específico es totalmente arbitrario.

Sin embargo, cuando el volumen de información crece, esto deja de ser eficiente y seguro.

A tal efecto existen librerías especializadas que ayudan a los programadores a crear pequeñas bases de datos (que se suelen denominar de "archivos indexados") que son un punto intermedio entre los dos escenarios que hemos descrito.

Estas bases de datos no requieren de la instalación de programas adicionales (como es el caso de un sistema DBMS) y reducen los problemas mencionados para los archivos "arbitrarios".

Note
Una estrategia adicional consiste en utilizar una base de datos "embedded", la cual combina gran parte de la funcionalidad de un DBMS con la simplicidad de los archivos indexados. El caso más conocido en la actualidad es SQLite https://www.sqlite.org/index.html .

Es digno de resaltar que la "velocidad" del almacenamiento "de archivo indexado" es en muchos casos superior a la que se alcanza con un sistema DBMS. Por otro lado, emular esta performance programáticamente es francamente una pérdida de tiempo, y una puerta abierta a la introducción de bugs.

10.1.1. dbm/ndbm

En los sistemas Unix comerciales existe una librería estándar para crear bases de datos de "archivo indexados" llamada dbm o ndbm. Esta librería es relativamente antigua y si bien existen opciones superiores, todavía es posible encontrar programas que la emplean.

10.1.2. GNU dbm (Gdbm)

Esta librería es un reemplazo bastante similar a dbm/ndbm, pero "abierto". Está distribuida bajo la licencia GPL (versión 2.)

Posee funciones de compatibilidad que permiten operar sobre archivos dbm/ndbm.

10.1.3. Berkeley DB

Esta librería posee un diseño más sofisticado, así como API’s para lenguaje C, C++ y Java. Se distribuye mediante una licencia bastante liberal por Sleepycat Software. Tiene un modo de compatibilidad con dbm aunque no puede usar archivos ya creados con esa librería.

En lo que sigue usaremos Gdbm, pero los conceptos son similares para los otros casos.

Note
En Ubuntu 22.04 hemos empleado el comando sudo apt-get install libgdbm6 libgdbm-dev para instalar la librería Gdbm.

10.2. Un diccionario electrónico

El programa que presentamos a continuación simplemente almacena la definición de las palabras que se le proporcionan.

/*
 * Listado 1: Diccionario electronico basico
 */
#include <gdbm.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

void consulta(void); void nuevo(void);

GDBM_FILE f;
char L[80];

int main(int argc,char **argv)
{
f=gdbm_open("diccionario.data",0,GDBM_WRCREAT,0600,NULL);
if(f==NULL) fprintf(stderr,"Fallo gdbm_open\n"),exit(1);

while(1)
	{
	printf("1- consulta, 2- ingresar nuevo, 3- salir > ");
	fgets(L,80,stdin);
	if(L[0]=='1') consulta();
	if(L[0]=='2') nuevo();
	if(L[0]=='3') break;
	}
gdbm_close(f);
return 0;
}

void consulta(void)
{
datum palabra,descripcion;

printf("Palabra a consultar? ");
fgets(L,80,stdin); L[strlen(L)-1]=0;
palabra.dptr=strdup(L);
palabra.dsize=strlen(L);
descripcion=gdbm_fetch(f,palabra);
if(descripcion.dptr==NULL)
	printf("No se encontró esa palabra\n");
else
	{
	strncpy(L,descripcion.dptr,descripcion.dsize);
	L[descripcion.dsize]=0;
	printf("Descripcion: %s\n",L);
	free(descripcion.dptr);
	}
}

void nuevo(void)
{
datum palabra,descripcion;

printf("Palabra a ingresar? ");
fgets(L,80,stdin); L[strlen(L)-1]=0;
palabra.dsize=strlen(L); palabra.dptr=strdup(L);
printf("Definicion? ");
fgets(L,80,stdin); L[strlen(L)-1]=0;
descripcion.dsize=strlen(L); descripcion.dptr=strdup(L);
if(gdbm_store(f,palabra,descripcion,GDBM_REPLACE)!=0)
	printf("Error en gdbm_store\n");
free(palabra.dptr);
free(descripcion.dptr);
}

La compilación requiere la inclusión de la librería Gdbm (-lgdbm), por lo que mi Makefile luce así:

CFLAGS:=-Wall
LDFLAGS:=-lgdbm

all: diccionario

clean:
	rm -f *.o diccionario

Un ejemplo de uso:

$ ./diccionario
1- consulta, 2- ingresar nuevo, 3- salir > 2
Palabra a ingresar? jirafa
Definicion? animal de cuello largo
1- consulta, 2- ingresar nuevo, 3- salir > 2
Palabra a ingresar? tucan
Definicion? animal politico de colores
1- consulta, 2- ingresar nuevo, 3- salir > 1
Palabra a consultar? jirafa
Descripcion: animal de cuello largo
1- consulta, 2- ingresar nuevo, 3- salir > 3
$

El programa es muy fácil de seguir. Algunos aspectos interesantes:

10.2.1. Conexión a la base de datos

La "conexión" a la base de datos se hace con gdbm_open(). Esta requiere:

  1. El nombre del archivo de base de datos (con cualquier extensión)

  2. un valor numérico relacionado al buffer interno de la librería (en nuestro caso, "cero" hace que la librería asigne automáticamente uno conveniente)

  3. Un identificador que señala el tipo de operaciones que haremos con la base de datos: GDBM_WRCREAT permite crear la base de datos (si no existe), leer y escribir [122]; GDBM_READER sólo permite lecturas. Lamentablemente no es posible que dos procesos actúen como escritores en la misma base de datos a la vez.

  4. El modo (permisos) con que se crea el archivo de base de datos, si éste todavía no existe. En nuestro caso hemos dado lectura y escritura sólo al propietario.

  5. Un puntero a una función que se ejecutará en caso de un problema grave (fatal.) Si se usa NULL, Gdbm la proporciona.

El valor retornado por esta rutina es de tipo GDBM_FILE, y deberá ser usado a lo largo del programa para las operaciones con la base de datos.

10.2.2. Los datos en Gdbm

Todo lo que se graba en la base de datos está indexado por una "clave". Por ejemplo, en un diccionario, todo el contenido está indexado por las palabras definidas; en una avenida, los solares están indexados por un número asociado a las viviendas; en un supermercado, todos los productos suelen tener un código numérico (especialmente si usan lectoras de códigos de barras.)

La idea principal de todo esto es que si se quiere acceder a un dato, hay que hacerlo mediante la clave.

En GDBM, tanto la clave como los datos referenciados por ella, pueden ser cualquier tipo de información y pueden tener cualquier tamaño.

El tipo de datos datum es una estructura que se usa para almacenar tanto las claves como los datos referenciados. datum es una esctructura con dos miembros:

  • dptr, un puntero a char, que referencia al dato

  • dsize, un entero que señala el tamaño del dato almacenado en dptr

En nuestro programa, estamos interesados en almacenar sólo textos (sin el caracter nulo del fin de cadena). Nuestra clave es una palabra leída en el array “L”, así que la "cargamos" en un datum llamado "palabra" así:

palabra.dptr=strdup(L);
palabra.dsize=strlen(L);

Nótese que esto sólo sirve para hacer la búsqueda, y luego nosotros debemos liberar la memoria asignada con strdup() manualmente (con free()) o el programa nunca parará de elevar su consumo de memoria.

Como se aprecia en la funcion consulta(), la rutina gdbm_fetch() se usa para recibir un datum que contiene la respuesta (que he llamado llamado "descripcion".)

El miembro "dptr" de "descripción" contiene la información buscada, o NULL en caso de que no exista aquella. Por eso la copiamos (con strncpy()) a un array que podamos manipular (nuestro array auxiliar “L”) asegurándonos de terminarlo en NUL para poder imprimirlo sin sorpresas.

La información "dptr" también debe ser liberada manualmente por nosotros (la librería GDBM la asigna en memoria con malloc() pero nunca la libera.)

La escritura es sencilla si se ha comprendido lo anterior. Notar el último parámetro de gdbm_store() que corresponde al flag GDBM_REPLACE, que pudo ser también GDBM_INSERT. En el primer caso, si se trata de escribir con una clave ya existente, la librería simplemente sobreescribe el dato (sin avisar) mientras que en el segundo no hace nada y retorna 1.

En caso de error, esta rutina retorna -1.

10.3. Locks, cerrojos o candados

Un aspecto crítico en las bases de datos está referido a la consistencia de los datos que observan los lectores y escritores de la misma. Imaginemos el siguiente escenario para una compañía de reserva aerea:

  • El asiento 60-A del vuelo 900 Lima-Quincemil de hoy es el único que queda disponible. Esta situación se puede apreciar en todos los terminales de venta de la empresa aerea

  • Pedro K. paga el costo del pasaje y el operador solicita en el computador que el asiento 60-A quede asignado a este buen señor

  • Al mismo tiempo, Pablo J., que está enterado de la misma información en otro punto de venta, paga el costo del pasaje y el operador realiza la misma operación

  • El sistema central recibe la ambas ordenes, casi simultáneamente, y puesto que el asiento 60-A está libre, simultáneamente escribe el registro correspondiente (quizá con una mezcla confusa de los datos de Pedro K. y Pablo J.)

  • Pedro y Pablo se enfrentan a la tripulación de la nave

Si pensamos un poco, el problema radica en el paso cuatro, en el sentido de que es posible que dos procesos escriban información en el mismo lugar (el mismo registro de la base de datos correspondiente al asiento 60-A.) Debe haber un mecanismo que impida esto, y de hecho los "cerrojos" o "candados" (locks) tienen esta función. Con los cerrojos, sólo un proceso puede escribir (o leer) uno o más registros a la vez.

En el ejemplo anterior, los procesos antes de escribir deben solicitar un cerrojo sobre la base de datos. Aunque las reservas han llegado casi al mismo tiempo, el sistema operativo siempre asignará secuencialmente los cerrojos a los procesos a fin de que nunca haya simultaneidad.

Siguiendo con el ejemplo, supondremos que el sistema operativo asigna el primer cerrojo a la solicitud de Pedro K. (que llegó un milisegundo antes que la de Pablo J.) Entonces, asignado el cerrojo, el sistema debera volver a verificar que el registro está libre, y en este caso, ecribirá la información. Acto seguido, liberará el cerrojo.

Por otro lado, la solicitud de Pablo J. no recibe el cerrojo (pues ya fue asignado a Pedro K.) y el sistema procede a indicarle que lamentablemente el asiento acaba de ser vendido [123] .

10.3.1. Locks en Gdbm

Gdbm no presenta muchas ventajas en cuanto a los cerrojos. Normalmente, Gdbm sólo permite que un proceso que accede a la base datos escriba en ella (es decir, que abra la base de datos con GDBM_WRCREAT):

Gdbm crea un cerrojo sobre todo el archivo cuando éste se abre con GDBM_WRCREAT. Un único "cerrojo de escritor".

Por ejemplo, nuestro diccionario no puede ser ejecutado por más de un usuario a la vez sobre la misma base de datos. Pruébelo Ud. mismo usando dos terminales.

Una solución a esto corresponde a hacer una versión del programa distinta, apta sólo para "lectores" del diccionario. En ese caso, basta con cambiar el flag GDBM_WRCREAT por GDBM_READER en la apertura de la base de datos:

f=gdbm_open("diccionario.data",0,GDBM_READER,0600,NULL);

Los procesos que abren la base de datos con GDBM_READER no pueden usar las funciones que modifican la base de datos (éstas simplemente no hacen nada.)

Otra forma algo mejor, consiste en preguntar a los usuarios si desean actuar como escritores (además de lectores.) Según su respuesta, se reemplazará el flag GDBM_WRCREAT por GDBM_READER en la apertura. Quizá podría pedirse un password a los escritores. Creo que esto no es difícil de implementar y se deja como ejercicio.

10.3.2. Locks manuales

Supongamos que de todos modos deseamos que todos los usuarios escriban en el diccionario. Si bien Gdbm no nos ayuda en esto, al menos nos da algunas salidas.

Una forma forzada pero ilustrativa de lograr esto, consiste en abrir la base de datos en modo escritura (gdbm_open()) sólo en el momento de hacer una modificación, y cerrar la base de datos inmediatamente. Como estas operaciones suelen ser veloces, es baja la probabilidad de que dos procesos pretendan escribir en simultáneo en la base de datos (una "colisión"), con el consiguiente error. Pero si obtenemos un error, podríamos simplemente esperar unos momentos (hacer un retardo), y volver a intentar abrir la base de datos hasta obtener el lock de escritor.

El problema de esto es que si tenemos muchos usuarios escritores, y una base de datos grande (más lenta), la probabilidad de "colisión" aumenta. Para evitar esto, nos veríamos obligados a reducir el "retardo" a fin de que los procesos intenten con más frecuencia obtener el lock de escritor; pero esto puede conllevar a una sobrecarga innecesaria y perjudicial en la performance sistema. Con todo, puede ser una solución de baja escalabilidad pero muy interesante, por lo que también se deja como ejercicio.

Veamos ahora otra solución más satisfactoria. Aquí haremos una sola apertura a la base de datos con el flag GDBM_WRCREAT para poder tener acceso a las funciones de escritura, pero además especificaremos el flag GDBM_NOLOCK, que evita que Gdbm ponga el cerrojo de escritor a la base de datos.

Si bien nuestro programa de diccionario no se beneficia mucho con los cerrojos, de todos modos trataremos de hacer las escrituras con una "reserva" previa. Es decir, ahora nosotros debemos poner nuestros propios cerrojos.

Ahora, antes de hacer cada escritura podríamos poner un cerrojo en modo "bloqueo" para:

  • Reservar la base de datos a fin de que sólo el proceso puede escribir

  • Si otro proceso ha hecho la reserva, nuestro proceso espera (se bloquea) a que se libere el otro cerrojo

La ventaja aquí es que el sistema operativo automáticamente detiene el proceso que quiere escribir sólo por el tiempo necesario.

Esto constrasta con el comportamiento de gdbm_open() que en caso de colisión no se bloquea sino que retorna inmediatamente con un código de error [124].

La siguiente versión del programa permite la escritura "segura" y simultánea por varios usuarios en el diccionario. Nótese que son muy pocas las modificaciones con respecto a la versión anterior:

  1. Se añade el header sys/file.h para flock() y el unistd.h para sleep()

  2. Se abre la base con GDBM_NOLOCK

  3. Se ejecuta flock(fd,LOCK_EX) antes de la escritura y flock(fd,LOCK_UN) después de.

/*
 * Listado 2: Diccionario con multiples escritores
 */
#include <gdbm.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/file.h>

void consulta(void); void nuevo(void);

GDBM_FILE f;
char L[80];

int main(int argc,char **argv)
{
f=gdbm_open("diccionario.data",0,GDBM_WRCREAT|GDBM_NOLOCK,0600,NULL);

... EL RESTO DE main() NO CAMBIA
}

void consulta(void)
{
... consulta() NO CAMBIA EN LO ABSOLUTO
}

void nuevo(void)
{
datum palabra,descripcion;
int fd;

printf("Palabra a ingresar? ");
fgets(L,80,stdin); L[strlen(L)-1]=0;
palabra.dsize=strlen(L); palabra.dptr=strdup(L);
printf("Definicion? ");
fgets(L,80,stdin); L[strlen(L)-1]=0;
descripcion.dsize=strlen(L); descripcion.dptr=strdup(L);
fd=gdbm_fdesc(f);
printf("A punto de aplicar cerrojo...\n");
flock(fd,LOCK_EX);
if(gdbm_store(f,palabra,descripcion,GDBM.REPLACE)!=0)
	printf("Error en gdbm_store\n");
printf("Hecho! ahora duermo 10s...\n"); sleep(10);
flock(fd,LOCK_UN);
free(palabra.dptr);
free(descripcion.dptr);
}

Adicionalmente se ha añadido un retardo de 10 segundos para que el archivo permanezca más tiempo bloqueado. Esto sólo se ha hecho por fines didácticos: el usuario deberá ahora tratar de iniciar el programa en dos terminales distintos y debe intentar registrar una nueva palabra casi al mismo tiempo. Si observa con cuidado los mensajes, notará que en uno de los casos el programa hace la escritura y duerme los 10 segundos antes de liberar el cerrojo, mientras que el otro tiene que esperar a que todo esto termine para recién aplicar su cerrojo, escribir, y volver a esperar otros 10 segundos.

Sólo cuando haya apreciado todo esto, elimine los mensajes innecesarios y el retardo.

10.4. Búsquedas en la base de datos

Gdbm no presenta facilidades para efectuar búsquedas. Lo único que podemos obtener es un listado con todo el contenido de la base de datos (sin ningún orden en particular) con el cual implementaremos esta funcionalidad.

Este listado total lo obtenemos con las rutinas gdbm_firstkey() y gdbm_nextkey() con un loop como lo que sigue:

datum key,nextkey,data;
	...
	key=gdbm_firstkey(f);
	while (key.dptr)
		{
		data=gdbm_fetch(f,key);

		// Hacer algo con (key,data)

		free(data.dptr);
		nextkey=gdbm_nextkey(f,key);
		free(key.dptr);
		key=nextkey;
		}

En realidad, las rutinas mencionadas permiten obtener un listado total de todas las claves de la base de datos; he añadido el datum "data" que recoge la información referenciada por estas claves.

Como se aprecia, el datum retornado por las funciones mencionadas puede apuntar a NULL para indicar que no hay registros que leer; además, en estas funciones Gdbm usa malloc() para la información retornada en *.dptr, por lo que se debe liberar manualmente con free().

Ahora trataremos de aprovechar esto para hacer una búsqueda en el diccionario. El usuario escribirá un texto y el programa buscará las entradas en don de ese texto esté contenido ya sea en la palabra-clave o en la definición. En realidad es muy fácil:

1- consulta, 2- nuevo, 3- busqueda 4- salir > 3
Texto a buscar? rafa
jirafa: animal de cuello largo
1- consulta, 2- nuevo, 3- busqueda 4- salir >

El programa:

/*
 * Listado 3: Diccionario con busquedas
 */
#include <gdbm.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/file.h>

void consulta(void); void nuevo(void); void busqueda(void);

GDBM_FILE f;
char L[80];

int main(int argc,char **argv)
{
f=gdbm_open("diccionario.data",0,GDBM_WRCREAT|GDBM_NOLOCK,0600,NULL);
if(f==NULL) fprintf(stderr,"Fallo gdbm_open\n"),exit(1);
while(1)
	{
	printf("1- consulta, 2- nuevo, 3- busqueda 4- salir > ");
	fgets(L,80,stdin);
	if(L[0]=='1') consulta();
	if(L[0]=='2') nuevo();
	if(L[0]=='3') busqueda();
	if(L[0]=='4') break;
	}
gdbm_close(f);
return 0;
}

void consulta(void)
{ ... NO CAMBIA ... }

void nuevo(void)
{ ... NO CAMBIA ... }

void busqueda(void)
{
datum key,nextkey,data;
char t_palabra[80],t_descripcion[80];

printf("Texto a buscar? ");
fgets(L,80,stdin); L[strlen(L)-1]=0;

key=gdbm_firstkey(f);
while (key.dptr)
	{
	data=gdbm_fetch(f,key);

	strncpy(t_palabra,key.dptr,key.dsize);
	t_palabra[key.dsize]=0;
	strncpy(t_descripcion,data.dptr,data.dsize);
	t_descripcion[data.dsize]=0;
	if(strstr(t_palabra,L)!=NULL ||
		strstr(t_descripcion,L)!=NULL)
		printf("%s: %s\n",t_palabra,t_descripcion);

	free(data.dptr);
	nextkey=gdbm_nextkey(f,key);
	free(key.dptr);
	key=nextkey;
	}
}

Los datos recogidos en (key,data) son llevados a los strings t_palabra y t_descripcion a fin de que sean terminados en NUL; y como ya debe saber el lector, buscar si un texto está dentro de otro es bastante sencillo con la conocida rutina strstr() que no explicaremos aquí.

10.5. Ejercicios

1 Implemente el diccionario de modo tal que todos los usuarios puedan escribir, pero sin hacer uso de flock(2). Para esto, el programa abrirá la base de datos en modo GDBM_READER (para procesar las lecturas), y justo en el momento de la escritura, la reabrirá en modo GDBM_WRCREAT (no se necesita cerrar la apertura inicial.) En caso de fallar esta apertura, el programa debe reintentar abrirla tras un "prudente" retardo. Una vez culminada la escritura, debe cerrarla para que otros procesos tengan la misma oportunidad.

Intente simular que la escritura toma mucho tiempo (digamos, 10s) añadiendo un sleep(10) justo antes de cerrar la base de datos, de modo tal que se pueda apreciar el efecto de bloqueo-reintento usando dos terminales en los que el programa intenta escribir al mismo tiempo.

2 En lugar de emplear el descriptor de la base de datos, implemente el bloqueo con un archivo auxiliar. Para esto, simplemente cree un archivo (no interesa el contenido) llamado por ejemplo, diccionario.lock, y abra un descriptor al mismo con open(2). Si todos los programas crean el cerrojo hacia el mismo archivo, entonces el efecto es el mismo.

Esta técnica es muy usada para sincronizar procesos que interactuan, independientemente de que se trabaje con bases de datos.

3 Mejore el diccionario añadiendo un campo adicional a la descripción del texto: haga que en cada escritura se guarde además la fecha y hora en que se realiza. Una forma muy simple de hacer esto es definir un delimitador que separe la descripción de cada palabra de su fecha y hora de registro. Por ejemplo, en lugar de guardar:

clave dato

jirafa

animal de gran altura

Se puede guardar algo como:

clave dato

jirafa

animal de gran altura%01/01/04 12:33

Ud. debe utilizar un caracter poco empleado (como el signo de porcentaje) para este fin. Nótese que al nivel de Gdbm no habría absolutamente ningún cambio que realizar.

4 Adapte el programa diccionario para que el computador genere N > 1000 palabras aleatorias con sus correspodientes definiciones (usando las letras aleatoriamente) y las almacene con Gdbm. Con esto obtendremos una base de datos relativamente grande.

Note
De preferencia N debe ser un argumento de línea de comandos a fin de permitir parametrizar valores más grandes que permitan "exigir" al computador.

Paralelamente, este programa deberá escribir un archivo de texto con las palabras y las definiciones, todas delimitadas por el fin de línea. Este archivo tiene la ventaja de ser fácilmente legible y editable por las personas:

ajsfdluwe
nvkl lkwluc ocnwiquoiw
jnsalkdas
kjdf wduiut hcajsd qwuiqwehqwu

Luego haga un pequeño programa que simplemente busque una palabra existente en el diccionario, y tómele tiempo (T_DBM_1.)

Y luego, que busque una palabra NO existente en el diccionario; tómele tiempo (T_DBM_2.)

Luego haga otro pequeño programa (que no tiene nada que ver con Gdbm) que busque secuencialmente alguna palabra existente en el archivo de texto, y tómele tiempo (T_TXT_1.)

Y por último, que busque una palabra NO existente en el archivo de texto (T_TXT_2.)

Compare todos los tiempos para diferentes valores de N.

PROGRAMACION INTERACTIVA

11. Programando el terminal con Curses

Cuando se utilizan terminales o consolas en modo texto (o emuladores de terminal) y se necesita acceder a todas las zonas de la pantalla, es frecuente el empleo de la librería "curses" o su clone "ncurses", disponibles en los sistemas Unix y Linux.

11.1. Algunas Nociones

La interacción con los terminales en modo texto suele hacerse línea a línea de modo secuencial. Sin embargo, es frecuente que las aplicaciones requieran utilizar áreas arbitrarias de la pantalla para lograr un efecto visual más apropiado. A tal efecto, los terminales reconocen e interpretan ciertas combinaciones de caracteres especiales como "comandos". En otras palabras, estos caracteres en lugar de imprimirse en la pantalla, ordenan al terminal que adquiera cierto comportamiento inusual, tal como cambiar el color del texto, hacer saltar el cursor, etc.

Estas combinaciones de caracteres especiales se conocen como "secuencias de escape", y varían con cada tipo de terminal utilizado, por lo que no es recomendable su utilización en forma directa. Precísamente, la librería "Curses" se desarrolló con el fin de abstraer esta complejidad y proporcionar al programador una interface estandarizada para enviar comandos a cualquier tipo de terminal sin preocuparse por las secuencias de escape.

La librería "Curses" no es de libre distribución, por lo que Linux no la proporciona. En su lugar se provee la librería "Ncurses" [125] , la cual emula la funcionalidad de "Curses" y la supera en algunos aspectos. Aquí usaremos la palabra "Curses" para designar ambas.

En lo que sigue presentaremos algunos ejemplos típicos; luego elaboraremos un "juego interactivo", y terminaremos con la implementación de una rutina genérica de menú.

11.2. Iniciando Curses

El listado "basico.c" presenta un programa bastante sencillo que solicita un texto al usuario y luego lo imprime en otra posición de la pantalla.

/*
 * basico.c: Programa basico
 */
#include <ncurses.h>
#include <unistd.h>

char buf[81];

int main()
{
    initscr();
    mvaddstr(5, 10, "Escribe tu nombre: ");
    getnstr(buf, 80);
    mvprintw(10, 4, "Tu nombre es: %s...", buf);
    refresh();
    sleep(3);
    endwin();
    return 0;
}

A continuación una serie de consideraciones relevantes:

  • Los programas que usan Curses deben incluir el header <curses.h>. En el caso de Linux, si usamos Ncurses, incluiremos <ncurses.h> aunque suele proporcionarse un enlace simbólico llamado <curses.h> con lo que resulta indistinto

  • La primera rutina Curses a utilizarse deberá ser initscr(), la cual inicializa Curses y borra la pantalla

  • Para imprimir texto hemos usado la rutina mvaddstr(). Esta rutina permite posicionar el cursor para la impresión. El primer argumento es la posición vertical (desde el margen superior) y el segundo la posición horizontal (desde el margen izquierdo.) Ambos se inician en cero

  • Una rutina relacionada es addstr(), que sirve para imprimir texto en la posición actual del cursor. Análogamente, existen las funciones addch() y mvaddch() para imprimir un caracter a la vez. Para imprimir sólo debemos usar rutinas Curses y no debemos combinarlas con rutinas de la librería estándar como printf()

  • Para leer una línea de texto hemos usado la rutina getnstr(). No se debe combinar con otras rutinas de lectura de la librería estándar como fgets()

  • Para imprimir un texto con un formato similar al de printf(), se puede usar las rutinas printw() y mvprintw() como demuestra el ejemplo

  • Normalmente, nada de lo que se imprime aparece en la pantalla hasta que se ejecuta la rutina refresh() o hasta que se invoca a cualquier rutina Curses de lectura de texto (como nuestro getnstr().) El lector deberá probar la ejecución del programa sin la invocación a "refresh" para notar la diferencia

  • Todos los programas antes de terminar deberían ejecutar la rutina endwin() que desactiva la configuración Curses y reestablece el terminal para el shell

Para compilar un programa Curses, se deberá especificar el enlace a esta librería. Por ejemplo:

cc -o prog1 prog1.c -lcurses

En sistemas que no usan "curses" sino "ncurses", se deberá usar “-lncurses”.

Un requisito fundamental para ejecutar correctamente una aplicación que usa Curses es que la variable de entorno “TERM” esté correctamente configurada. Esto debería ser consultado al administrador del sistema.

11.3. Documentación Curses

En la mayoría de sistemas Unix/Linux, la documentación de curses se encuentra en los "man pages" correspondientes. Por ejemplo, en Linux el comando “man ncurses” proporciona un listado de otros "man pages" para ciertas funciones. A fin de dar una idea de esto, mostramos parte de este manual:

curses Routine Name     Manual Page Name
--------------------------------------------
...
addch                   addch(3NCURSES)
addchnstr               addchstr(3NCURSES)
addchstr                addchstr(3NCURSES)
addnstr                 addstr(3NCURSES)
addnwstr                addwstr(3NCURSES)
addstr                  addstr(3NCURSES)
addwstr                 addwstr(3NCURSES)
assume_default_colors   default_colors(3NCURSES)*
attr_get                attr(3NCURSES)
...

Supóngase que se desea información sobre la rutina "addch"; entonces la documentación se consigue en ciertos sistemas simplemente mediante “man addch” y en otros mediante el comando: “man curs_addch” (agregando el prefijo “curs”.)

Un concepto recurrente en la documentación corresponde a las "ventanas". "Curses" permite definir zonas rectangulares (partes de la pantalla) y darles un tratamiento independiente (por ejemplo, cada ventana tendrá diferentes ejes de coordenadas.) La pantalla entera se considera también una ventana especial y existe una variable global de Curses para hacer referencia a ella: “stdscr”. En nuestros ejemplos no crearemos ventanas aunque sí emplearemos “stdscr”.

Algunas implementaciones de Curses proporcionan librerías adicionales para crear "paneles", "menús" y "formularios". Los paneles son similares a las ventanas con la diferencia que pueden superponerse. Los menús proporcionan facilidades para selección de opciones, y los formularios permiten introducir diversos tipos de información de un modo semiautomático. En mi opinión, la interfaz de programación de los paneles, menús y formularios es muy poco intuitiva e impráctica. No será explicada aquí [126] .

11.4. Caja sin eco

Normalmente cuando se solicita información al usuario vía teclado, es conveniente que el usuario pueda observar lo que está tipeando. Para esto, el terminal debe tener el "eco" activado, lo que significa que a cada tecla presionada se le hará "eco"; es decir, en la pantalla aparecerá el caracter correspondiente a la tecla presionada (este es el caso normal cuando el usuario interactúa con el shell.)

Sin embargo, en algunos casos esto no es deseable (por ejemplo, cuando se introduce una contraseña.) El programa que mostramos a continuación (caja_sin_eco.c) dibuja una cajita [127] y espera a que el usuario presione una tecla. Sin embargo, esta tecla no aparecerá en el lugar del cursor debido a que el eco se desactivó con la rutina noecho(). En otro lugar de la cajita se mostrará qué tecla fue la que se presionó. Pruebe el mismo programa eliminando la invocación a "noecho" y observe la diferencia.

/*
 * caja_sin_eco.c
 */
#include <ncurses.h>
#include <unistd.h>

int main()
{
    int z, j, c;
    initscr();
    noecho();
    cbreak();

    for (j = 3; j <= 20; j++) {
	mvaddch(2, j, '-');
	mvaddch(10, j, '-');
    }
    for (z = 3; z <= 9; z++) {
	mvaddch(z, 2, '|');
	mvaddch(z, 21, '|');
    }
    mvaddch(2, 2, '+');
    mvaddch(2, 21, '+');
    mvaddch(10, 2, '+');
    mvaddch(10, 21, '+');

    mvaddstr(4, 5, "??? ");
    c = getch();
    mvprintw(6, 5, "Tecla: %c", c);
    refresh();
    sleep(2);
    endwin();
    return 0;
}

La rutina cbreak() instruye al terminal para que cada tecla presionada por el usuario esté inmediatamente disponible en el programa. Lo opuesto (modo nocbreak) significa que el usuario deberá terminar una línea de texto con [Enter] para que el programa recién empiece a recibir las teclas que se presionaron [128] .

11.4.1. Caja Mejorada

El listado anterior presentó una caja construida en base a caracteres estándar del teclado. Ciertos terminales son capaces de mucho más. En el listado siguiente (caja_mejorada.c) se presenta un programa que dibuja la misma caja pero usando caracteres especiales proporcionados por Curses. Nótese que la apariencia final puede veriar dependiendo del tipo de terminal [129] .

/*
 * caja_mejorada.c
 */
#include <ncurses.h>
#include <unistd.h>

int main()
{
    int z, j, c;
    initscr();
    noecho();

    for (j = 3; j <= 20; j++) {
	mvaddch(2, j, ACS_HLINE);
	mvaddch(10, j, ACS_HLINE);
    }
    for (z = 3; z <= 9; z++) {
	mvaddch(z, 2, ACS_VLINE);
	mvaddch(z, 21, ACS_VLINE);
    }
    mvaddch(2, 2, ACS_ULCORNER);
    mvaddch(2, 21, ACS_URCORNER);
    mvaddch(10, 2, ACS_LLCORNER);
    mvaddch(10, 21, ACS_LRCORNER);

    mvaddstr(4, 5, "??? ");
    c = getch();
    mvprintw(6, 5, "Tecla: %c", c);
    refresh();
    sleep(2);
    endwin();
    return 0;
}

Los identificadores de estos caracteres especiales (como ACS_VLINE) se pueden hallar en el manual de addch().

11.5. Animación: Carrera de obstáculos

A continuación desarrollaremos un sencillo juego consistente en esquivar obstáculos que aparecerán en la pantalla. Supondremos que conducimos un auto (al cual controlaremos horizontalmente) y el objetivo serásobrevivir el mayor tiempo posible sin colisionar con los obstáculos.

Los obstáculos aparecerán a lo largo de la pantalla y "descenderán" hacia el auto a velocidad creciente. Esto significa que el contenido de toda la pantalla (excepto el auto) deberá desplazarse hacia abajo, cosa que se conoce como "scroll". Si bien Curses proporciona una rutina para hacer scroll, nosotros no la usaremos a fin de tener un control más completo de la pantalla, lo que nos obliga a escribir nuestra propia rutina de scroll.

Por otro lado, intentaremos que la pantalla sea "pintada" en momentos estratégicos (cuando todos los objetos están en su lugar) por lo que usaremos una "pantalla buffer" en la que se harán todas las modificaciones correspondientes al juego, y luego este buffer será volcado de una sola vez al terminal vía Curses.

Finalmente, el programa no asumirá dimensiones fijas de la pantalla, sino que las averiguará al iniciarse. Esto permitirá jugar en terminales gráficos tipo Xterm redimensionados por el usuario a cualquier tamaño.

El programa ha sido escrito en un solo archivo fuente, sin embargo, lo presentaré por partes para facilitar la explicación. El lector deberá unir estas piezas en un mismo archivo para compilarlo.

/*
 * carrera.c: Demostracion de ncurses
 */
#include <ncurses.h>
#include <stdlib.h>
#include <unistd.h>
#include <time.h>

void nuevo_obstaculo(void);
void scroll_down(void);
void clear_screen(void);
void draw_screen(void);
void set(int y, int x, chtype c);
void pinta_carrito(void);
void borra_carrito(void);
void carrera(void);
void pset(int y, int x, chtype c);

// Altura de zona oculta para crear obstaculos
#define GAP 5

chtype **pantalla;
int max_y, max_x;
int car_y, car_x;
int crash = 0;

Nuestra "pantalla buffer" será "GAP" lineas más alta que la pantalla real del terminal. Por lo tanto, sólo una parte de la "pantalla buffer" se visualizará mientras que otra parte permanecerá oculta. Esto se ha hecho así a fin que los obstáculos grandes (de varias líneas de altura) puedan ser dibujados en la "zona oculta" y el scroll los haga aparecer gradualmente en la "zona visible" con lo que conseguiremos un efecto más natural.

La memoria para "pantalla buffer" se asignará dinámicamente, por lo que hemos definido la variable "pantalla" como un puntero a puntero al tipo "chtype", que es el tipo de los caracteres en curses (vease el manual de addch().) Finalmente, max_y, max_x, car_y, car_x corresponden respectivamente a las dimensiones de la pantalla del terminal y a la posición del carrito. "crash" es un flag usado para alertar cuando el carrito colisiona.

int main()
{
    int z;
    time_t inicio, final;

    srand(time(NULL));
    initscr();
    cbreak();
    nodelay(stdscr, TRUE);
    keypad(stdscr, TRUE);
    noecho();

/* Encontrar dimensiones de pantalla */
    getmaxyx(stdscr, max_y, max_x);

/* Nuestra pantalla sera de max_y+GAP */
    max_y += GAP;
    pantalla = calloc(max_y, sizeof(chtype *));
    for (z = 0; z < max_y; z++)
	pantalla[z] = calloc(max_x, sizeof(chtype));

// Pantalla limpia y carrito al medio
    clear_screen();
    car_x = max_x / 2;
    car_y = max_y - 4;
// La carrera empieza
    time(&inicio);
    carrera();
// Fin de la carrera
    draw_screen();
    refresh();
    time(&final);
    sleep(1);
    endwin();
// Score:
    printf("Duracion de la carrera: %ld seg\n", (long) (final - inicio));
    return 0;
}

Como se sabe, srand() se usa para inicializar la generación de números aleatorios, cosa que no detallaremos aquí.

La rutina nodelay() configura el terminal para que la lectura de teclas no se "bloquee", es decir, para que cuando se lea una tecla con getch() el programa continue inmediatamente si el usuario no presiona nada [130] . Como se vio en programas anteriores, getch normalmente detiene la ejecución del programa hasta que el usuario responde (ejecución "bloqueada".) Nótese que nodelay() requiere como argumentos la "ventana" a configurar y el flag "TRUE" o "FALSE". Como deseamos configurar la pantalla total, usaremos la variable especial stdscr. Asimismo, deseamos controlar el carrito con las teclas de movimiento de cursor. Estas "teclas especiales" requieren que "Curses" se configure mediante "keypad" a fin de que sean reconocidas [131] . Los argumentos son análogos a nodelay(). Las rutinas cbreak() y noecho() se explicaron anteriormente [132] .

Para dimensionar nuestra "pantalla buffer" requerimos conocer el tamaño de la "pantalla real", lo que se consigue con la macro “getmaxyx” de Curses. Nótese que el alto de la pantalla se incrementa en "GAP", y que mediante “calloc()” se le asigna un espacio de (max_x*[GAP+max_y]) caracteres de tipo “chtype”.

void carrera(void)
{
    int c;
    long delay = 30000;
    int aux = 0;

    for (;;) {
	nuevo_obstaculo();
	if (aux == 0)
	    scroll_down();
	if (crash)
	    return;
	aux = (aux + 1) % 8;
	draw_screen();
	refresh();
	c = getch();
	borra_carrito();
	switch (c) {
	case KEY_LEFT:
	    if (car_x > 2)
		car_x--;
	    break;
	case KEY_RIGHT:
	    if (car_x < max_x - 3)
		car_x++;
	    break;
	case 'q':
	    return;
	}
	pinta_carrito();
	if (crash)
	    return;
	usleep(delay);
	delay -= 10;
	if (delay < 10000)
	    delay = 10000;
    }
}

A fin de sincronizar la velocidad de procesamiento del teclado con el scroll, se ha configurado el scroll de pantalla cada 8 lecturas getch(), lo que se consigue con la variable "aux" que va de cero a (8-1=7) [133] . Estas lecturas getch() tienen además un retardo de "delay" microsegundos (sino el juego es demasiado veloz.) Nótese que la función scroll_down() retorna "verdadero" en caso de choque del carrito, y el juego termina.

En cada iteración se invoca a la rutina “nuevo_obstaculo()”, la cual posiblemente creará un nuevo obstáculo en la "zona oculta".

La tecla presionada por el usuario (si la hay) se analiza y compara con "KEY_LEFT" y "KEY_RIGHT" (teclas de movimiento de cursor.) Asimismo, la tecla "q" finaliza el juego.

Durante el procesamiento de estas teclas, el carrito es borrado puesto que puede reaparecer en una nueva posición. Nótese que esto no borra el carrito de la "pantalla real", sino sólo de la "pantalla buffer" por lo que no observaremos molestos "parpadeos" del mismo.

A fin de incrementar la dificultad conforme avanza el juego, el "delay" se va reduciendo en cada iteración. Finalmente, cada iteración redibuja la pantalla real (nuestra rutina draw_screen()) y la refresca con la rutina Curses refresh().

void clear_screen(void)
{
    int z, j;

    for (z = 0; z < max_y; z++)
	for (j = 0; j < max_x; j++)
	    pantalla[z][j] = ' ';
}

void draw_screen(void)
{
    int z, j;

    for (z = GAP; z < max_y - 1; z++)
	for (j = 0; j < max_x; j++)
	    mvaddch(z - GAP, j, pantalla[z][j]);
}

void scroll_down(void)
{
    int z, j;

    borra_carrito();
    for (z = max_y - 1; z >= 1; z--)
	for (j = 0; j < max_x; j++)
	    pantalla[z][j] = pantalla[z - 1][j];
    for (j = 0; j < max_x; j++)
	pantalla[0][j] = ' ';
    pinta_carrito();
}

Estas rutinas son bastante autoexplicativas. La rutina de "scroll" merece un comentario. El scroll es precedido por el borrado del carrito (pues éste no debe descender) y tras el "scroll", el carrito se repinta mediante “pinta_carrito()”. Esta última rutina setea "crash" en "1" en caso de detectar colisiones.

void nuevo_obstaculo(void)
{
    int x = rand() % max_x;

    if (rand() % 18 == 0)
	switch (rand() % 10) {
	case 0:
	case 1:
	case 2:
	case 3:
	case 4:
	case 5:
	case 6:
	case 7:
	    //   *
	    pset(0, x, '*');
	    break;
	case 8:
	    //    + 
	    //   OOO
	    pset(0, x, '+');
	    pset(1, x, 'O');
	    pset(1, x - 1, 'O');
	    pset(1, x + 1, 'O');
	    break;
	case 9:
	    //  .   .
	    pset(0, x + 2, '.');
	    pset(0, x - 2, '.');
	    break;
	}
}

Esta rutina dibuja un obstáculo si la función “rand()” retorna un múltiplo de 18 [134] . En otras palabras, en promedio, una de cada 18 veces se generará un obstáculo. En caso de generarse, el obstáculo se colocará en la "zona oculta" de la "pantalla buffer" en la posición aleatoria "x".

Como se observa, se toma un número aleatorio de 0 a 9, y en la mayoría de casos se genera un "bache simple" (un solitario asterisco) en la pantalla (casos 0-7.) En el caso 8 se genera una "iglesia" y en el caso 9 un "tunel" compuesto por dos puntitos. Evidentemente no se ha hecho un gran aprovechamiento de la "zona oculta" con estos obstáculos tan pequeños.

Por seguridad, no hemos modificado directamente la variable "pantalla", sino que hemos preferido usar una función auxiliar “pset()” que verifica que las coordenadas estén dentro del rango aceptable. Esto permite crear obstáculos entrecortados en los bordes de la pantalla sin temor a hacer caer el programa (por ejemplo, con un acceso a pantalla[0][-1].)

void borra_carrito(void)
{
    pset(car_y, car_x, ' ');
    pset(car_y + 1, car_x, ' ');
    pset(car_y + 1, car_x + 1, ' ');
    pset(car_y + 1, car_x - 1, ' ');
    pset(car_y + 2, car_x, ' ');
    pset(car_y + 2, car_x + 1, ' ');
    pset(car_y + 2, car_x - 1, ' ');
}

void pinta_carrito(void)
{
    set(car_y, car_x, '^');
    set(car_y + 1, car_x, '#');
    set(car_y + 1, car_x + 1, 'H');
    set(car_y + 1, car_x - 1, 'H');
    set(car_y + 2, car_x, '#');
    set(car_y + 2, car_x + 1, 'H');
    set(car_y + 2, car_x - 1, 'H');
}

Estas rutinas son autoexplicativas. En el caso de “pinta_carrito()”, la rutina “set()” es similar a “pset()” con la diferencia de que activa la variable "crash" en caso de detectar colisiones. El carrito luce así:

      ^
     H#H
     H#H

Por último, las rutinas que faltaban:

void pset(int y, int x, chtype c)
{
    if (x >= 0 && x < max_x && y >= 0 && y < max_y)
	pantalla[y][x] = c;
}

void set(int y, int x, chtype c)
{
    if (x < 0 || x >= max_x || y < 0 || y >= max_y)
	return;
    if (pantalla[y][x] != ' ') {
	pset(y, x, '*');
	crash = 1;
    } else
	pantalla[y][x] = c;
}

11.6. Menú Personalizado

A continuación crearemos una función para implementar un menú usando Curses. Este menú utilizará las teclas de cursor para seleccionar una opción. La "opción activa" se visualiza con vídeo invertido. Empecemos por el programa que hace uso de esta función:

/*
 * pruebamenu.c: Usando x_menu()
 */
#include <ncurses.h>
#include <stdio.h>

int x_menu(char *, ...);

int main()
{
    int z;

    initscr();

    z = x_menu("Elija lo que mas le agrade",
	       "Auto de lujo", "Casa de campo",
	       "Viaje a Paris", "Pareja con dinero", NULL);

    printw("Ud. eligio la opcion %d. Presione Enter...", z);
    getch();
    endwin();
    return 0;
}

Como se ve, la función del menú (x_menu()) requiere un título seguido por la lista de alternativas terminada en NULL. Se retorna la alternativa seleccionada o -1 en caso de error. Obsérvese que main() debe inicializar el sistema Curses con “initscr()”.

/*
 * x_menu.c: Menu curses
 */
#include <ncurses.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdarg.h>

int x_menu(char *t, ...)
{
    va_list ap;
    char *p;
    int n = 0, z, x = 0, flag = 0;
    char *opts[100];

    keypad(stdscr, TRUE);
    va_start(ap, t);
    for (;;) {
	p = va_arg(ap, char *);
	if (p == NULL)
	    break;
	opts[n] = strdup(p);
	n++;
    }
    va_end(ap);
    if (n == 0)
	return -1;

    for (;;) {
	clear();
	printw("%s\n\n", t);
	for (z = 0; z < n; z++) {
	    if (z == x)
		attron(A_REVERSE);
	    printw("%s", opts[z]);
	    if (z == x)
		attroff(A_REVERSE);
	    printw("\n");
	}
	switch (getch()) {
	case KEY_DOWN:
	    if (x < n - 1)
		x++;
	    break;
	case KEY_UP:
	    if (x > 0)
		x--;
	    break;
	case ' ':
	case '\n':
	    flag = 1;
	    break;
	}
	if (flag == 1)
	    break;
    }
    for (z = 0; z < n; z++)
	free(opts[z]);
    clear();
    return x;
}

Como se aprecia, la función x_menu (de tipo "variadic") almacena todos los argumentos en el array de punteros "opts" en forma dinámica. Luego ingresa a un loop donde se imprimen las opciones, cuidando de que la opción activa ("x") sea impresa en vídeo invertido (con attron()/attroff() y el argumento A_REVERSE.) Véase el manual de estas funciones para más información. Finalmente, la memoria asignada con strdup() se libera con free() y se retorna la opción seleccionada.

Nótese que x_menu() borra toda la pantalla con clear(). Esto puede no ser deseable. Véase el ejercicio 5.

11.7. Ejercicios

1 Modifique el juego para que los obstáculos sean más interesantes (intente usar todo el "GAP") Amplíe los tipos de obstáculos.

2 Modifique el juego para que haga uso de colores. Los colores se deben inicializar con la rutina start_color. Luego se deben definir las "parejas de colores" mediante init_pair(). Finalmente, la parejas de colores (COLOR_PAIR) pueden ser usadas al imprimir.

La figura muestra un screenshot de una versión del juego que responde a las preguntas anteriores:

cf2r

3 El programa genera obstáculos a un ritmo constante independientemente del tamaño de la pantalla. Esto hace que en una pantalla pequeña sea muy difícil de jugar (gran densidad de obstáculos) y lo contrario en una pantalla grande. Pruebe esto con un terminal gráfico tipo Xterm. Modifique el programa para que la "densidad de obstáculos" se genere en función del ancho de la pantalla (max_x). [135]

4 Modifique la función x_menu() para que genere una caja alrededor de las opciones. Esto requiere encontrar la opción de mayor longitud para dimensionar la caja.

5 Modifique la función x_menu() para que acepte dos argumentos correspondientes a las coordenadas donde se inicia el menú en la pantalla. Calculado el tamaño de la "caja", bórrese sólo esta área rectangular evitando borrar la pantalla entera con clear().

12. Programación Gráfica con SDL

SDL es una librería que proporciona acceso de "bajo nivel" para video, audio, teclado, mouse, joystick y temporizadores de manera portable para múltiples plataformas. Entre otras cosas, esta librería se ha utilizado exitosamente para portar diversos juegos comerciales de Wind*ws a Linux, aunque el camino inverso también es posible [136] .

En esta revisión sólo veremos aspectos básicos de programación gráfica 2D. Consúlte la documentación para más información acerca de la programación acelerada 3D y en particular el enlace con las librerías OpenGL. Para más imformación y software adicional, visite el https://www.libsdl.org Sitio oficial de SDL.

12.1. Inicializar Subsistemas SDL

SDL comprende diversos ámbitos a los que denomina "subsistemas". Un programa puede utilizar uno o más subsistemas, los cuales deben ser inicializados antes de emplearse:

Table 8. Subsistemas SDL
Constante Subsistema

SDL_INIT_TIMER

Temporizadores

SDL_INIT_AUDIO

Sonido

SDL_INIT_VIDEO

Video

SDL_INIT_CDROM

CDROM

SDL_INIT_JOYSTICK

Joystick

SDL_INIT_EVERYTHING

Todo lo anterior

Para inicializar los subsistemas, se debe llamar a la función SDL_Init() con las constantes indicadas en la tabla haciendo una operación OR binaria. Por ejemplo, si nuestro programa usará Video, Sonido y Joystick de SDL, escribiremos:

if(SDL_Init(SDL_INIT_VIDEO|SDL_INIT_AUDIO|SDL_INIT_JOYSTICK)==-1)
	fprintf(stderr,"Error inicializando.\n"),exit(1);

Como se aprecia, esta función retorna -1 en caso de error.

Nota Redundante: Es imprescindible inicializar los subsistemas SDL antes de hacer uso de ellos.

Tan pronto SDL_Init() retorna con éxito, es conveniente definir una "función de salida" con atexit(3) de modo tal que invoque a SDL_Quit(). Ésta última restaurará el modo original de la pantalla y del teclado a fin de no perder el control en el caso de una aplicación fallida:

atexit(SDL_Quit);

12.2. Inicializar el Video

Una vez inicializado el subsistema de video SDL con SDL_Init(), es necesario configurar el modo de video deseado. Entre los aspectos involucrados tenemos principalmente la resolución y el número máximo de colores simultáneos (pixel depth.) En este texto se utilizará una resolución de 800 x 600 pixels y una profundidad de 16 bits por pixel (16 bpp), lo que da un total de 2 ^ 16=65536 colores simultáneos como máximo. Por supuesto, es posible emplear 8 bpp, 24 bpp y 32 bpp y muchas otras resoluciones.

SDL_Surface *pantalla;

if(SDL_Init(SDL_INIT_VIDEO)==-1)
	exit(1);
pantalla=SDL_SetVideoMode(800,600,16,SDL_SWSURFACE);

La constante SDL_SWSURFACE solicita a SDL la creación una "superficie" por software (normalmente asociada a una "ventana" del ambiente GUI.) Esta superficie es accesible mediante el puntero de tipo SDL_Surface llamado "pantalla".

También podríamos haber escrito:

SDL_SWSURFACE|SDL_FULLSCREEN

Para forzar a que nuestra superficie ocupe toda la pantalla y no sólo una ventana.

SDL_SetVideoMode() retorna NULL en caso de error.

12.3. Algunas rutinas auxiliares

Para los siguientes programas emplearemos las siguientes rutinas auxiliares (archivo aux.c):

/*
 * Rutinas auxiliares para SDL
 */
#include "sdl_aux.h"
#include <stdlib.h>
#include <stdio.h>

Uint32 getpixel(SDL_Surface * s, int x, int y)
{
    Uint32 r;
    Uint8 *p = (Uint8 *) s->pixels + y * s->pitch + x * 2;
    r = *(Uint16 *) p;
    return r;
}

void putpixel(SDL_Surface * s, int x, int y, Uint32 pixel)
{
    Uint8 *p = (Uint8 *) s->pixels + y * s->pitch + x * 2;
    *(Uint16 *) p = pixel;
}

SDL_Surface *inicializa(void)
{
    SDL_Surface *s;
    if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_TIMER) < 0)
	fprintf(stderr, "SDL_Init() error\n"), exit(1);
    atexit(SDL_Quit);
    s = SDL_SetVideoMode(800, 600, 16, SDL_SWSURFACE);
/* Para habilitar pantalla completa:
s=SDL_SetVideoMode(800,600,16,SDL_SWSURFACE|SDL_FULLSCREEN); */
    if (s == NULL) {
	fprintf(stderr, "Error en SDL_SetVideoMode\n");
	exit(1);
    }
    return s;
}

/* Los colores referenciales para el espectro */
static SDL_Color ref_color[10] = {
    { 255, 0, 0, -1 },
    { 255, 255 / 3, 0, -1 },
    { 255, 2 * 255 / 3, 0, -1 },
    { 255, 255, 0, -1 },
    { 255 / 2, 255, 0, -1 },
    { 0, 255, 255, -1 },
    { 0, 2 * 255 / 3, 255, -1 },
    { 0, 0, 255, -1 },
    { 2 * 255 / 3, 0, 255, -1 },
    { 255, 0, 255, -1 }
};

Uint32 getColorSpectrum(SDL_Surface * s, double c)
{
    if (c < 0.0 || c > 1.0)
	c = 0.0;
    double particion = 1.0 / 9;
    int i_part = c * 8.999999;
    double inicio = particion * i_part;
    double fin = particion * (i_part + 1);
    double factor = (c - inicio) / (fin - inicio);
    SDL_Color rpta;
    rpta.r =
	ref_color[i_part].r + (ref_color[i_part + 1].r -
			       ref_color[i_part].r) * factor;
    rpta.g =
	ref_color[i_part].g + (ref_color[i_part + 1].g -
			       ref_color[i_part].g) * factor;
    rpta.b =
	ref_color[i_part].b + (ref_color[i_part + 1].b -
			       ref_color[i_part].b) * factor;
    return SDL_MapRGB(s->format, rpta.r, rpta.g, rpta.b);
}

En primer lugar, hemos separado la inicialización del video SDL en la función inicializa(), la cual será invocada al inicio de nuestros ejemplos. Asimismo, inicializaremos el subsistema de temporización pues nuestros programas lo requerirán (invocarán a SDL_Delay() para hacer pausas.)

Sorprendentemente, SDL no proporciona funciones para modificar pixels directamente en las superficies, pero permite hacer cambios directamente en la memoria de las mismas. Conociendo la representación de los pixels se puede hacer los cambios necesarios, tal como se muestra en las funciones getpixel() y putpixel() [137] . Téngase en cuenta que estas rutinas sólo pueden operar con una profundidad de 16 bpp. Consúltese la documentación para los equivalentes en otras profundidades de color, o para funciones más generales que operen en cualquier profundidad.

Hemos agregado una rutina auxiliar que permite obtener los colores del espectro (en la representación adecuada a la superficie gráfica en uso) a partir de un valor real en el rango [0,1]. La usaremos en lo sucesivo para dar más "vida" a algunos gráficos.

Como se ve, los colores se representan con un entero de 32 bits (tipo Uint32); sin embargo, la codificación de bits de los mismos depende de diversos factores [138] . Es por esto que los "colores" del programa se deben "calcular" primero con la rutina SDL_MapRGB(), la cual requiere información acerca del formato de la superficie donde se va a pintar (el miembro →format.)

12.4. Trazos simples y Colores

El siguiente programa traza un rectángulo con el conjunto de colores del espectro:

/* grafica_espectro: Grafica muchos colores en un rectangulo */

#include "sdl_aux.h"

int main()
{
    SDL_Surface *screen;
    Uint32 blanco;
    int z, j;

    screen = inicializa();
    blanco = SDL_MapRGB(screen->format, 0xff, 0xff, 0xff);

    SDL_FillRect(screen, NULL, blanco);
    if (SDL_MUSTLOCK(screen))
	SDL_LockSurface(screen);
    for (z = 100; z < 500; z++)
	for (j = 0; j < 200; j++)
	    putpixel(screen, 100 + j, z,
		     getColorSpectrum(screen, (z - 100) / 400.0));
    if (SDL_MUSTLOCK(screen))
	SDL_UnlockSurface(screen);
    SDL_UpdateRect(screen, 0, 0, 0, 0);
    SDL_Delay(5000);		// 5 segundos para admirar la pantalla
    return 0;
}

El color "blanco" se ha utilizado para hacer un "llenado" en toda la superficie con la función SDL_FillRect(). El segundo argumento (NULL) significa "relleno total", sin embargo, se podría rellenar sólo una región.

Cuando se accede directamente a la "memoria de pixels" de una superficie (como lo hacen getpixel() y putpixel()) posiblemente sea necesario "bloquear" dicha superficie a fin de evitar ciertas des-sincronizaciones con otras operaciones de SDL. Por este motivo se emplea (en forma condicional) las rutinas SDL_LockSurface() y SDL_UnlockSurface() durante el acceso a la memoria de pixels, previa consulta mediante la macro SDL_MUSTLOCK() [139] . Mientras una superficie está "bloqueada" no se debe hacer llamadas al sistema ni a otras de SDL; sólo debe acceder a los pixels.

Finalmente, tras modificar la pantalla, debemos asegurarnos de que estas modificaciones surtan efecto, pues SDL no necesariamente hace los cambios en el momento que se lo solicitan a fin de hacer optimizaciones; [140] a esto se le denomina "actualización". Con este fin se emplea la rutina SDL_UpdateRect(), la cual permite especificar (en sus últimos cuatro parámetros) qué región de la pantalla se requiere actualizar. En nuestro caso, todos los parámetros en "cero" corresponden a un "atajo" que significa actualizar la pantalla completa.

12.4.1. Compilación de programas SDL

Para obtener las opciones de compilación adecuadas, SDL proporciona el comando auxiliar sdl-config. Las opciones "de compilación" se obtienen con sdl-config --cflags, mientras que las opciones "de enlace" se obtienen con sdl-config --libs. Nuestro programa podría por lo tanto compilarse mediante:

cc $(sdl-config --cflags) -o grafica_espectro
	grafica_espectro.c sdl_aux.c $(sdl-config --libs)

Como siempre, es mejor emplear un Makefile como el que se muestra al final del capítulo.

12.5. Gráficos y Matemáticas

Las fórmulas matemáticas se emplean con frecuencia para producir diversas clases de gráficos. Entre los más sorprendentes destacan los famosos fractales, que tienen la característica de generar figuras con mucha "variación y contenido" a partir de ecuaciones tremendamente simples.

12.5.1. Fractales de Mandelbrot

El programa listado a continuación produjo el siguiente resultado:

Un fractal de Mandelbrot

Este programa se inicia con la creación de una pequeña e incompleta librería de rutinas para operar con números complejos (pues así lo requieren estos fractales.) A fin de cuentas, lo único que deseamos es aplicar reiterativamente la fórmula:

Z = Z^2+c

donde Z es un punto cualquiera del plano complejo y c es una constante (también compleja.) Si la fórmula anterior es aplicada reiterativamente (en nuestro caso, 50 iteraciones) y siempre se genera un número relativamente pequeño [141] , entonces el punto que representa el Z inicial, es dibujado; caso contrario, se descarta.

Como se observa, el valor “c” que usa el programa (rutina operacion()) es:

c = -0.53 + 0.53 i

Lo cual se puede modificar para obtener otra clase de figuras.

El programa grafica el área delimitada por las coordenadas del plano complejo (x1=0, x2=1.7, y1=-1, y2=1), pero el lector podrá variar estos argumentos con mucha facilidad (al inicio de la función main().)

Asimismo, es muy fácil definir la granuralidad de los puntos del gráfico con la variable "n" (en nuestro programa, 1000.) Con valores menores, el programa prueba menos puntos del área propuesta, pero se ejecuta más aprisa. Pruebe la ejecución con valores distintos de "n".

/*
 * mandelbrot.c: Fractales de Mandelbrot
 */
#include "sdl_aux.h"
#include <math.h>

typedef struct {
    double x, y;
} COMPLEJO;

COMPLEJO suma(COMPLEJO a, COMPLEJO b)
{
    COMPLEJO r;
    r.x = a.x + b.x;
    r.y = a.y + b.y;
    return r;
}

COMPLEJO multiplica(COMPLEJO a, COMPLEJO b)
{
    COMPLEJO r;
    r.x = a.x * b.x - a.y * b.y;
    r.y = a.x * b.y + a.y * b.x;
    return r;
}

double norma(COMPLEJO a)
{
    return sqrt(a.x * a.x);
}

COMPLEJO operacion(COMPLEJO a)
{
    COMPLEJO c = { -0.53, 0.53 };
    return suma(multiplica(a, a), c);
}

int pintable(double x, double y)
{
    COMPLEJO a = { x, y };
    double norma_maxima = norma(a) * 50;

    int z;
    for (z = 0; z < 50; z++) {
	a = operacion(a);
	if (norma(a) > norma_maxima)
	    return 0;
    }
    return 1;
}

int main(int argc, char *argv[])
{
/* Modificar estas variables para nuevas figuras */
    double x1 = 0, x2 = 1.7, y1 = -1, y2 = 1;
    int n = 1000;

/* Variables auxiliares */
    double delta = (x2 - x1) / n;
    int m = (y2 - y1) / delta;
    double x, y;
    int z, j, px, py;

/* SDL */
    SDL_Surface *s;
    Uint32 negro, blanco;

    s = inicializa();
    negro = SDL_MapRGB(s->format, 0x0, 0x0, 0x0);
    blanco = SDL_MapRGB(s->format, 0xff, 0xff, 0xff);
    SDL_FillRect(s, NULL, blanco);

    for (z = 0; z < m; z++) {
	if (SDL_MUSTLOCK(s))
	    SDL_LockSurface(s);
	for (j = 0; j < n; j++) {
	    x = x1 + j * delta;
	    y = y1 + z * delta;
	    if (pintable(x, y)) {
		px = 800 * j / n;
		py = 600 * z / m;
		putpixel(s, px, py, negro);
	    }
	}
	if (SDL_MUSTLOCK(s))
	    SDL_UnlockSurface(s);
	if (z % 10 == 0)
	    SDL_UpdateRect(s, 0, 0, 0, 0);
    }
    SDL_UpdateRect(s, 0, 0, 0, 0);
    SDL_Delay(5000);		/* 5 segundos para admirar pantalla */
    return 0;
}

12.5.2. Gráfica de Funciones Arbitrarias

El siguiente programa permite graficar una función de dos variables (la cual se puede cambiar con facilidad al inicio), generándose un efecto tridimensional: