Curso de programación en C para GNU/Linux (I)

A lo largo de este curso repasaremos conceptos de multiprogramación como las definiciones de programa,‭ ‬proceso e hilos,‭ ‬y explicaremos el mecanismo de llamadas al sistema que emplea Linux para poder aceptar las peticiones desde el entorno de usuario. Seguidamente veremos las posibilidades que nos ofrece el Compilador de C de GNU,‭ ‬GCC,‭ ‬y programaremos nuestros primeros ejecutables para GNU/Linux.‭ ‬Después de repasar las llamadas al sistema más comunes,‭ ‬analizaremos las particularidades de UNIX a la hora de manejar directorios,‭ ‬permisos,‭ ‬etc.,‭ ‬y nos adentraremos en la Comunicación Interproceso‭ (‬IPC‭)‬.‭ ‬Finalmente abordaremos de forma introductoria la programación de sockets de red,‭ ‬para dotar de capacidades telemáticas a nuestros programas.

Para seguirlo, solamente hace falta saber un poco de C y tener ganas de “migrar” nuestros conocimientos desde otro sistema a GNU/Linux. No haremos grandes virguerías, por lo que considero que el curso tendrá un nivel “Sencillo”/”Medio”. Espero que os guste 😉

 

Llamadas al sistema

GNU/Linux es un Sistema Operativo multitarea en el que van a convivir un gran número de procesos. Es posible, bien por un fallo de programación o bien por un intento malicioso, que alguno de esos procesos haga cosas que atenten contra la estabilidad de todo el sistema. Por ello, con vistas a proteger esa estabilidad, el núcleo o kernel del sistema funciona en un entorno totalmente diferente al resto de programas. Se definen entonces dos modos de ejecución totalmente separados: el modo kernel y el modo usuario. Cada uno de estos modos de ejecución dispone de memoria y procedimientos diferentes, por lo que un programa de usuario no podrá ser capaz de dañar al núcleo.

Aquí se plantea una duda: si el núcleo del sistema es el único capaz de manipular los recursos físicos del sistema (hardware), y éste se ejecuta en un modo de ejecución totalmente disjunto al del resto de los programas, ¿cómo es posible que un pequeño programa hecho por mí sea capaz de leer y escribir en disco? Bien, la duda es lógica, porque todavía no hemos hablado de las “llamadas o peticiones al sistema” (“syscalls”).

Las syscalls o llamadas al sistema son el mecanismo por el cual los procesos y aplicaciones de usuario acceden a los servicios del núcleo. Son la interfaz que proporciona el núcleo para realizar desde el modo usuario las cosas que son propias del modo kernel (como acceder a disco o utilizar una tarjeta de sonido). La siguiente figura explica de forma gráfica cómo funciona la syscall read():

cursoc01.gif

Mecanismo de petición de servicios al kernel.

El proceso de usuario necesita acceder al disco para leer, para ello utiliza la syscall read() utilizando la interfaz de llamadas al sistema. El núcleo atiende la petición accediendo al hardware y devolviendo el resultado al proceso que inició la petición. Este procedimiento me recuerda al comedor de un restaurante, en él todos los clientes piden al camarero lo que desean, pero nunca entran en la cocina. El camarero, después de pasar por la cocina, traerá el plato que cada cliente haya pedido. Ningún comensal podría estropear la cocina, puesto que no tiene acceso a ella.

Prácticamente todas las funciones que utilicemos desde el espacio de ejecución de usuario necesitarán solicitar una petición al kernel mediante una syscall, esto es, la ejecución de las aplicaciones de usuario se canaliza a través del sistema de peticiones al sistema. Este hecho es importante a la hora de fijar controles y registros en el sistema, ya que si utilizamos nuestras propias versiones de las syscalls para ello, estaremos abarcando todas las aplicaciones y procesos del espacio de ejecución de usuario. Imaginemos un “camarero” malicioso que capturase todas las peticiones de todos los clientes y envenenase todos los platos antes de servirlos… nuestro restaurante debería cuidarse muy bien de qué personal contrata y nosotros deberemos ser cautelosos también a la hora de cargar drivers o módulos en nuestro núcleo.

Programas, procesos, hilos…

Un proceso es una entidad activa que tiene asociada un conjunto de atributos: código, datos, pila, registros e identificador único. Representa la entidad de ejecución utilizada por el Sistema Operativo. Frecuentemente se conocen también con el nombre de tareas (“tasks”).

Un programa representa una entidad pasiva. Cuando un programa es reconocido por el Sistema Operativo y tiene asignado recursos, se convierte en proceso. Es decir, la ejecución de código implica la existencia de un entorno concreto.

Generalmente un proceso:

  • Es la unidad de asignación de recursos: el Sistema Operativo va asignando los recursos del sistema a cada proceso.
  • Es una unidad de despacho: un proceso es una entidad activa que puede ser ejecutada, por lo que el Sistema Operativo conmuta entre los diferentes procesos listos para ser ejecutados o despachados.

Sin embargo, en algunos Sistemas Operativos estas dos unidades se separan, entendiéndose la segunda como un hilo o thread. Los hilos no generan un nuevo proceso sino que producen flujos de ejecución disjuntos dentro del mismo proceso. Así pues, un hilo o “proceso ligero” (“lightweight process, LWP”) comparte los recursos del proceso, así como la sección de datos y de código del proceso con el resto de hilos. Esto hace que la creación de hilos y el cambio de ejecución entre hilos sea menos costoso que el cambio de contexto entre procesos, aumentando el rendimiento global del sistema.

Un Sistema Operativo multiusuario y multiprogramado (multitarea) pretende crear la ilusión a sus usuarios de que se dispone del sistema al completo. La capacidad de un procesador de cambiar de tarea o contexto es infinitamente más rápida que la que pueda tener una persona normal, por lo que habitualmente el sistema cumple este objetivo. Es algo parecido a lo que pasa en un restaurante de comida rápida: por muy rápido que seas comiendo, normalmente la velocidad de servir comida es mucho mayor. Si un camarero fuese atendiéndote cada 5 minutos, podrías tener la sensación de que eres el cliente más importante del local, pero en realidad lo que está haciendo es compartir sus servicios (recursos) entre todos los clientes de forma rápida (“time-sharing”).

Estructuras de datos

Si queremos implementar la ejecución de varias tareas al mismo tiempo, los cambios de contexto entre tareas y todo lo concerniente a la multiprogramación, es necesario disponer de un modelo de procesos y las estructuras de datos relacionadas para ello. Un modelo de procesos típico consta de los siguientes elementos:

  • PCB (Process Control Block): un bloque o estructura de datos que contiene la información necesaria de cada proceso. Permite almacenar el contexto de cada uno de los procesos con el objeto de ser reanudado posteriormente. Suele ser un conjunto de identificadores de proceso, tablas de manejo de memoria, estado de los registros del procesador, apuntadores de pila, etc.
  • Tabla de Procesos: la tabla que contiene todos los PCBs o bloques de control de proceso. Se actualiza a medida que se van creando y eliminando procesos o se producen transiciones entre los estados de los mismos.
  • Estados y Transiciones de los Procesos: los procesos se ordenan en función de su información de Planificación, es decir, en función de su estado. Así pues, habrá procesos bloqueados en espera de un recurso, listos para la ejecución, en ejecución, terminando, etc.
  • Vector de Interrupciones: contiene un conjunto de apuntadores a rutinas que se encargarán de atender cada una de las interrupciones que puedan producirse en el sistema.

En Linux esto está implementado a través de una estructura de datos denominada task_struct. Es el PCB de Linux, en ella se almacena toda la información relacionada con un proceso: identificadores de proceso, tablas de manejo de memoria, estado de los registros del procesador, apuntadores de pila, etc.

La Tabla de Procesos no es más que un array de task_struct, en la versión 2.4.x de Linux desaparece el array task como tal y se definen arrays para buscar procesos en función de su identificativo de proceso (PID) como pidash:

extern struct task_struct *pidhash[PIDHASH_SZ];

PIDHASH_SZ determina el número de tareas capaces de ser gestionadas por esa tabla (definida en “/usr/src/linux/include/linux/sched.h”). Por defecto PIDHASH_SZ vale 512 (#define PIDHASH_SZ (4096 >> 2)), es decir, es posible gestionar 512 tareas concurrentemente desde un único proceso inicial o “init”. Podremos tener tantos procesos “init” o iniciales como CPUs tenga nuestro sistema:

extern struct task_struct *init_tasks[NR_CPUS];

NR_CPUS determina el número de procesadores disponibles en el sistema (definida en “/usr/src/linux/include/linux/sched.h”). Por defecto NR_CPUS vale.1, pero si se habilita el soporte para multiprocesador, SMP, este número puede crecer (hasta 32 en la versión del kernel 2.4.19, por ejemplo).

Estados de los procesos en Linux

Como ya hemos comentado, los procesos van pasando por una serie de estados discretos desde que son creados hasta que terminan o mueren. Los diferentes estados sirven para saber cómo se encuentra un proceso en cuanto a su ejecución, con el objeto de llevar un mejor control y aumentar el rendimiento del sistema. No tendría sentido, por ejemplo, cederle tiempo de procesador a un proceso que sabemos que sabemos a ciencia cierta que está a la espera de un recurso todavía no liberado.

En Linux el estado de cada proceso se almacena dentro de un campo de la estructura task_struct. Dicho campo, “state”, irá variando en función del estado de ejecución en el que se encuentre el proceso, pudiendo tomar los siguientes valores:

  • TASK_RUNNING (0): Indica que el proceso en cuestión se está ejecutando o listo para ejecutarse. En este segundo caso, el proceso dispone de todos los recursos necesarios excepto el procesador.
  • TASK_INTERRUPTIBLE (1): el proceso está suspendido a la espera de alguna señal para pasar a listo para ejecutarse. Generalmente se debe a que el proceso está esperando a que otro proceso del sistema le preste algún servicio solicitado.
  • TASK_UNINTERRUPTIBLE (2): el proceso está bloqueado esperando a que se le conceda algún recurso hardware que ha solicitado (cuando una señal no es capaz de “despertarlo”).
  • TASK_ZOMBIE (4): el proceso ha finalizado pero aún no se ha eliminado todo rastro del mismo del sistema. Esto es habitualmente causado porque el proceso padre todavía lo espera con una wait().
  • TASK_STOPPED (8): el proceso ha sido detenido por una señal o bien mediante el uso de ptrace() para ser trazado.

En función del estado de la tarea o proceso, estará en una u otra cola de procesos:

  • Cola de Ejecución o runqueue: procesos en estado TASK_RUNNING.
  • Colas de Espera o wait queues: procesos en estado TASK_INTERRUPTIBLE ó TASK_ININTERRUPTIBLE.

Los procesos en estado TASK_ZOMBIE ó TASK_STOPPED no necesitan colas para ser gestionados.

Identificativos de proceso

Todos los procesos del sistema tienen un identificativo único que se conoce como Identificativo de Proceso o PID. El PID de cada proceso es como su DNI (Documento Nacional de Identidad), todo el mundo tiene el suyo y cada número identifica a un sujeto en concreto. Si queremos ver los PIDs de los procesos que están actualmente presentes en nuestro sistema, podemos hacerlo mediante el uso del comando “ps”, que nos informa del estado de los procesos:

txipi@neon:~$ ps xa
  PID TTY      STAT   TIME COMMAND
    1 ?        S      0:05 init[2]
    2 ?        SW     0:00 [keventd]
    3 ?        SWN    0:03 [ksoftirqd_CPU0]
    4 ?        SW     0:12 [kswapd]
    5 ?        SW     0:00 [bdflush]
    6 ?        SW     0:03 [kupdated]
   75 ?        SW     0:12 [kjournald]
  158 ?        S      1:51 /sbin/syslogd
  160 ?        S      0:00 /sbin/klogd
  175 ?        S      0:00 /usr/sbin/inetd
  313 ?        S      0:00 /usr/sbin/sshd
  319 ?        S      0:00 /usr/sbin/atd
  322 ?        S      0:04 /usr/sbin/cron
  330 tty1     S      0:00 /sbin/getty 38400 tty1
  331 tty2     S      0:00 /sbin/getty 38400 tty2
  332 tty3     S      0:00 /sbin/getty 38400 tty3
  333 tty4     S      0:00 /sbin/getty 38400 tty4
  334 tty5     S      0:00 /sbin/getty 38400 tty5
  335 tty6     S      0:00 /sbin/getty 38400 tty6
22985 ?        S      0:00 /usr/sbin/sshd
22987 ?        S      0:00 /usr/sbin/sshd
22988 pts/0    S      0:00 -bash
23292 pts/0    R      0:00 ps xa

En la primera columna vemos cómo cada uno de los procesos, incluido el propio “ps xa” tienen un identificativo único o PID. Además de esto, es posible saber quién ha sido el proceso padre u originario de cada proceso, consultando su PPID, es decir, el Parent Process ID. De esta manera es bastante sencillo hacernos una idea de cuál ha sido el árbol de creación de los procesos, que podemos obtener con el comando “pstree”:

txipi@neon:~$ pstree
init-+-atd
     |-cron
     |-6*[getty]
     |-inetd
     |-keventd
     |-kjournald
     |-klogd
     |-sshd---sshd---sshd---bash---pstree
     `-syslogd

Como vemos, el comando “pstree” es el proceso hijo de un intérprete de comandos (bash) que a su vez es hijo de una sesión de SSH (Secure Shell). Otro dato de interés al ejecutar este comando se da en el hecho de que el proceso init es el proceso padre de todos los demás procesos. Esto ocurre siempre: primero se crea el proceso init, y todos los procesos siguientes se crean a partir de él.

Además de estos dos identificativos existen lo que se conocen como “credenciales del proceso”, que informan acerca del usuario y grupo que lo ha lanzado. Esto se utiliza para decidir si un determinado proceso puede acceder a un recurso del sistema, es decir, si sus credenciales son suficientes para los permisos del recurso. Existen varios identificativos utilizados como credenciales, todos ellos almacenados en la estructura task_struct:

/* process credentials */

uid_t uid,euid,suid,fsuid;

gid_t gid,egid,sgid,fsgid;

Su significado es el siguiente:

Identificativos reales uid Identificativo de usuario real asociado al proceso gid Identificativo de grupo real asociado al proceso
Identificativos efectivos euid Identificativo de usuario efectivo asociado al proceso egid Identificativo de grupo efectivo asociado al proceso
Identificativos guardados suid Identificativo de usuario guardado asociado al proceso sgid Identificativo de grupo guardado asociado al proceso
Identificativos de acceso a ficheros fsuid Identificativo de usuario asociado al proceso para los controles de acceso a ficheros fsgid Identificativo de grupo asociado al proceso para los controles de acceso a ficheros

Tabla: Credenciales de un proceso y sus significados.

Planificación

El planificador o scheduler en Linux se basa en las prioridades estáticas y dinámicas de cada una de las tareas. A la combinación de ambas prioridades se la conoce como “bondad de una tarea” (“task’s goodness”), y determina el orden de ejecución de los procesos o tareas: cuando el planificador está en funcionamiento se analiza cada una de las tareas de la Cola de Ejecución y se calcula la “bondad” de cada una de las tareas. La tarea con mayor “bondad” será la próxima que se ejecute.

Cuando hay tareas extremadamente importantes en ejecución, el planificador se llama en intervalos relativamente amplios de tiempo, pudiéndose llegar a periodos de 0,4 segundos sin ser llamado. Esto puede mejorar el rendimiento global del sistema evitando innecesarios cambios de contexto, sin embargo es posible que afecte a su interatividad, aumentando lo que se conoce como “latencias de planificación” (“scheduling latencies”).

El planificador de Linux utiliza un contador que genera una interrupción cada 10 milisegundos. Cada vez que se produce dicha interrupción el planificador decrementa la prioridad dinámica de la tarea en ejecución. Una vez que este contador ha llegado a cero, se realiza una llamada a la función schedule(), que se encarga de la planificación de las tareas. Por lo tanto, una tarea con una prioridad por defecto de 20 podrá funcionar durante 0,2 segundos (200 milisegundos) antes de que otra tarea en el sistema tenga la posibilidad de ejecutarse. Por lo tanto, tal y como comentábamos, una tarea con máxima prioridad (40) podrá ejecutarse durante 0,4 segundos sin ser detenida por un evento de planificación.

El núcleo del sistema se apoya en las estructuras task_struct para planificar la ejecución de las tareas, ya que, además de los campos comentados, esta estructura contiene mucha información desde el punto de vista de la planificación:

  • volatile long state: nos informa del estado de la tarea, indicando si la tarea es ejecutable o si es interrumpible (puede recibir señales) o no.
  • long counter: representa la parte dinámica de la “bondad” de una tarea. Inicialmente se fija al valor de la prioridad estática de la tarea.
  • long priority: representa la parte estática de la “bondad” de la tarea.
  • long need_resched: se analiza antes de volver a la tarea en curso después de haber llamado a una syscall, con el objeto de comprobar si es necesario volver a llamar a schedule() para planificar * de nuevo la ejecución de las tareas.
  • unsigned long policy: inidica la política de planificación empleada: FIFO, ROUND ROBIN, etc.
  • unsigned rt_priority: se utiliza para determinar la “bondad” de una tarea al utilizar tiempo real por software.
  • struct mm_struct *mm: apunta a la información de gestión d memoria de la tarea. Algunas tareas comparten memoria por lo que pueden compartir una única estructura mm_struct.

18 pensamientos en “Curso de programación en C para GNU/Linux (I)

  1. nando

    Interesante el tema, lanzanos un poquito más de carnaza plis. 🙂

    Una duda:
    ¿Por qué está la política del planificador (policy) en cada task_struct?
    ¿No es repetir un dato que influye a todos los procesos?

    Agur,
    Nando.

    Responder
  2. txipi

    Muy buena pregunta Nando 😉

    La policy es importante cuando se trata de procesos en tiempo real. Cuando los procesos son "normales", se usa SCHED_OTHER y se atiende a la política del scheduler del sistema. Cuando son en tiempo real, puede utilizarse SCHED_FIFO o SCHED_RR para hacer una FIFO o un ROUND ROBIN con esos procesos que necesitan ser en tiempo real (Soft Real Time, claro, supongo que si eliges mal la política de tus proceso de tiempo real, los requisitos de tiempo tendrán que ser menos exigentes).

    A ver si esta noche subo otro cachito (es un poco rollete maquetarlo todo para que esté en formato blog O:-D).

    Responder
  3. Cybrid

    Yo tengo una preguntilla, ¿Podrías explicar un poco más exactamente que significa cada identificador de usuario/grupo?. ¿Cuál es la diferencia entre usuario efectivo y real?. Gracias Txipi, y excelente blog 😀

    Responder
  4. txipi

    @etox: de nada, un placer que os gusten 😉

    @Cybrid: la diferencia entre el uid y el euid se ve muy bien en los ejecutables que tienen el bit de SUID activado, como el binario "passwd" para cambiar las contraseñas, por ejemplo. Cuando ejecutas passwd, tu usuario real sigue siendo el de antes, cybrid, por ejemplo; mientras que tu usuario efectivo es root en ese momento (para que puedas editar el fichero /etc/shadow que es donde estan las contraseñas del sistema). Sin embargo, hay ejecutables a los que no les vale que tenga el bit de SUID activado para considerarte root, porque miran tanto euid como uid. Un ejemplo de este tipo de ejecutables desconfiados es /bin/bash. Prueba a copiarte un /bin/bash a otro lado, activar el bit de SUID y tratar de ejecutarlo con otro usuario que no sea root: no te abrirá una shell de root, porque, aunque efectivamente seas root gracias al bit de SUID, realmente sabe que no eres root. Con ksh, por ejemplo, sí que cuela 🙂

    Responder
  5. pablo

    recien puedo leer el documento y la verdad me interesa muchisimo aprendar mas sobre el tema, debido a que quiere empezar a programar aplicaciones para el A1200, por lo que voy a seguir paso a paso todos los documentos que suban, pero lo primero es lo primero, actualmente trabajo sobre la plataforma windows, como tengo que hacer para empezar a probar todo lo que han escrito? se que es una pregunta para nada concreta, pero estoy perdidisimo.

    desde ya muchas gracias.

    Responder
  6. roberto

    ¿Se puede descargar el curso en PDF?. No siempre dispongo de conexión. POr otra parte me manejo mejor sobre papel.

    Gracias de todas formas.

    Responder
  7. Pingback: Curso de programación en C para GNU/Linux (II) « txipi:blog

  8. Pingback: Curso de programación en C para GNU/Linux (final II) « txipi:blog

  9. Pingback: Programando sobre Linux con C « Lo de Miguel

  10. Miguel

    Muy bueno el material, Txipi. Te felicito y te agradezco porque estoy cursando una materia que trata estos temas.

    Muy clara su exposición, amigo.

    Saludos.
    Miguel

    Responder
  11. jose

    Simplemente genial.

    Buscaba información sobre las diferencias entre visual c++ y gcc, por suerte para mi me encontré este tesoro que me ha tenido un buen rato entusiasmado.

    Tienes a un nuevo asiduo a tu blog, un saludo.

    Responder

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *