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].
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 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.
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:
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:
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 |
É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.
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)
...
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.
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.
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.
Literalmente, "… en caso contrario…" Esta sección es opcional
en la senencia if()
.
Leer un entero desde la entrada estándar, asignándolo a la variable entera “n”.
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
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.
El operador '%' significa "residuo" y permite verificar la paridad rápidamente [14] .
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]
.
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 $
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
$
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:
Implemente para el programa de biblioteca las siguientes funciones (accesibles desde un menú):
-
Ingreso de un nuevo libro
-
Búsqueda de libros por título
-
Búsqueda de libros por autor
-
Eliminación de un libro
-
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)
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".
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 |
---|---|
|
Compilar generando archivo objeto pero no enlazar |
|
Nombre del archivo ( |
|
Enlaza con la librería dinámica |
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.
Opción | Significado |
---|---|
|
Define un macro |
|
Cancela la definición de macro |
|
Especifica ruta para buscar archivos de encabezado |
|
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
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%.)
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.
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] .
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
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.
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.
|
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.
¿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.
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.
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".
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 |
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.)
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 |
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 |
---|---|
|
Reemplazar las ocurrencias de un texto por otro dentro de un string |
|
Eliminar espacios en blanco al inicio y al final de un string |
|
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:
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:
-
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)
-
Como consecuencia de lo anterior, cuando estos ejecutables se cargan en memoria, ésta se llena ineficientemente con código repetido
-
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
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:
/*
* a.c
*/
void fa()
{
}
/*
* b.c: Llama a fa()
*/
extern void fa(void);
void fb()
{
fa();
}
/*
* prueba.c
*/
extern void fb(void);
int main(void)
{
fb();
return 0;
}
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
:
/*
* 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
$ ./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:
-
rutina1_informixe() → libinformixe.so
-
rutina2_oracler() → liboracler.so
-
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
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.
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?
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:
-
sbrk
(ybrk
) no están especificadas en POSIX y su \ comportamiento es muy variado por lo que son poco portables -
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 -
La liberación de memoria con
sbrk
simplemente no es posible \ en algunos sistemas. Evidentementemalloc
tampoco podrá llevar a \ cabo la liberación, pero nos evita tener que considerar este caso por \ separado -
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 -
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 -
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 -
La librería estándar proporciona las rutinas complementarias
calloc
\ yrealloc
que resultarían muy tediosas de implementarse a cada momento \ si se usasbrk
.
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
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?
my_shutdown
para que soliciteuna confirmación antes de "bajar" el sistema. Si tiene tiempo, busque y analice el código fuente del comando halt de Linux.
my_shutdown
paraque 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):
-
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".
-
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.
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] .
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
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.
reloj_block.c
definiendo unnuevo 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:
-
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 sistemafork(2)
. -
Este "shell hijo" tiene como única finalidad el "convertirse" en otro programa (en nuestro caso,
ls
ocp
.) Esto se hace con un grupo de llamadas al sistema conocidas comoexec()
.
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:
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:
-
Agotamiento de memoria
-
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 sá
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:
-
Podrá recibir comandos con sus argumentos separados por espacios, los cuales serán ejecutados como subprocesos
-
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 “\>”. -
Podrá interpretar el amperstand (
&
) al final del comando (separado con un espacio) como ejecución en "background"; en caso contrario será en "foreground" [101]. -
Podrá reconocer el comando interno "exit" para terminar su ejecución.
-
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.
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 |
|
El dispositivo según el caso |
|
La estructura |
|
El ioctl |
|
Ver los resultados |
|
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
llamadod_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:
-
El nombre del archivo de base de datos (con cualquier extensión)
-
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)
-
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. -
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.
-
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:
-
Se añade el header
sys/file.h
paraflock()
y elunistd.h
parasleep()
-
Se abre la base con
GDBM_NOLOCK
-
Se ejecuta
flock(fd,LOCK_EX)
antes de la escritura yflock(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 funcionesaddch()
ymvaddch()
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 comoprintf()
-
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 comofgets()
-
Para imprimir un texto con un formato similar al de
printf()
, se puede usar las rutinasprintw()
ymvprintw()
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 nuestrogetnstr()
.) 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:
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:
Constante | Subsistema |
---|---|
|
Temporizadores |
|
Sonido |
|
Video |
|
CDROM |
|
Joystick |
|
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:
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: