Fundamentos de las Señales en Sistemas Operativos
Las señales constituyen un mecanismo de interrupción por software diseñado para notificar a un proceso o hilo sobre la ocurrencia de eventos asíncronos. En el ecosistema POSIX, actúan como un medio de comunicación limitado pero crítico entre el kernel y los procesos en espacio de usuario, permitiendo la gestión de excepciones, la finalización controlada y la coordinación de tareas.
Al recibir una señal, un proceso puede adoptar tres comportamientos fundamentales:
- Acción por defecto: El sistema operativo ejecuta una rutina predefinida, que frecuentemente resulta en la terminación del proceso (ej.
SIGTERM) o la generación de un volcado de memoria (ej.SIGSEGV). - Ignorar la señal: El proceso descarta la notificación. Es importante destacar que señales críticas como
SIGKILL(9) ySIGSTOP(19) no pueden ser ignoradas ni capturadas por diseño del kernel. - Captura y manejo personalizado: El proceso registra una función de callback (handler) que el kernel invocará en el contexto del proceso al momento de entregar la señal.
Fase 1: Generación de Señales
Las señales pueden originarse desde múltiples fuentes, tanto a nivel de hardware como de software:
Intervención del Usuario y Comandos
Las combinaciones de teclas en la terminal emuladora generan señales específicas a través del controlador de línea (TTY). Por ejemplo, Ctrl+C envía SIGINT (2) para solicitar la interrupción, y Ctrl+\ envía SIGQUIT (3). Alternativamente, herramientas como el comando kill interactúan con la API del sistema para inyectar señales en procesos arbitrarios.
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
int main(int argc, char *argv[]) {
if (argc != 3) {
fprintf(stderr, "Sintaxis: %s <numero_señal> <pid>\n", argv[0]);
return EXIT_FAILURE;
}
int target_pid = atoi(argv[2]);
int sig_number = atoi(argv[1]);
if (kill(target_pid, sig_number) == -1) {
perror("Fallo al enviar la señal");
return EXIT_FAILURE;
}
printf("Señal %d enviada al proceso %d exitosamente.\n", sig_number, target_pid);
return EXIT_SUCCESS;
}
Condiciones de Software y Temporizadores
Ciertas operaciones lógicas desencadenan señales. Un ejemplo clásico es escribir en una tubería (pipe) cuyo extremo de lectura ha sido cerrado, lo que genera un SIGPIPE (13). Asimismo, la función alarm() configura un temporizador en el kernel que, al expirar, entrega un SIGALRM (14) al proceso invocador.
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
void handle_timeout(int signum) {
printf("\n[Ejecución interrumpida] Temporizador expirado (Señal %d).\n", signum);
exit(EXIT_SUCCESS);
}
int main() {
unsigned int task_counter = 0;
signal(SIGALRM, handle_timeout);
// Programar una señal SIGALRM para dentro de 3 segundos
alarm(3);
while(1) {
printf("Procesando tarea #%u...\n", task_counter++);
sleep(1);
}
return 0;
}
Excepciones de Hardware
Las violaciones a las reglas de la arquitectura del procesador generan señales de fallo. Una división por cero provoca que la ALU de la CPU active un flag de error en el registro de estado (EFLAGS), lo que deriva en un SIGFPE (8). Del mismo modo, intentar acceder a una dirección de memoria no mapeada o sin permisos causa un fallo de página (Page Fault) que la MMU reporta al kernel. El kernel extrae la dirección virtual fallida del registro CR2 y envía un SIGSEGV (11) al proceso infractor.
Cuando un proceso termina anómalamente, el kernel puede generar un archivo core dump. Este archivo es una instantánea de la memoria virtual del proceso en el momento del fallo, invaluable para la depuración post-mortem con herramientas como GDB.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
pid_t child_pid = fork();
if (child_pid == 0) {
// Provocar intencionalmente un fallo de segmentación
int *invalid_ptr = NULL;
*invalid_ptr = 99;
exit(0);
}
int process_status;
waitpid(child_pid, &process_status, 0);
if (WIFSIGNALED(process_status)) {
printf("El proceso hijo terminó debido a la señal: %d\n", WTERMSIG(process_status));
#ifdef WCOREDUMP
if (WCOREDUMP(process_status)) {
printf("Se ha generado un archivo de volcado de núcleo (core dump).\n");
}
#endif
}
return 0;
}
Fase 2: Almacenamiento y Estado en el PCB
El kernel mantiene el estado de las señales para cada proceso dentro de su Bloque de Control de Proceso (PCB) utilizando tres estructuras principales:
- Máscara de Bloqueo (Block Set): Un mapa de bits que define qué señales están temporalmente suspendidas. Una señal bloqueada no se descarta, sino que retrasa su entrega.
- Señales Pendientes (Pending Set): Un mapa de bits que registra las señales que han sido generadas pero aún no han sido entregadas al proceso. Si una señal bloqueada se genera, su bit correspondiente se activa en esta tabla.
- Tabla de Manejadores (Handler Table): Un arreglo de punteros a funciones que asocia cada número de señal con su respectiva rutina de atención (ya sea por defecto, ignorar o una función de usuario).
Es crucial distinguir entre bloqueo e ignorancia. Bloquear una señal previene su entrega hasta que se desbloquee, manteniéndola en estado pendiente. Ignorar una señal implica que, al momento de la entrega, el kernel simplemente la descarta sin ejecutar ninguna acción.
Fase 3: Manipulación y Entrega de Señales
El estándar POSIX define el tipo sigset_t para manipular conjuntos de señales de manera atómica. Las funciones sigemptyset(), sigfillset(), sigaddset() y sigdelset() preparan estas máscaras.
Para alterar la máscara de bloqueo del proceso, se emplea sigprocmask(). El siguiente código demuestra cómo bloquear SIGINT, monitorear su estado en la tabla de pendientes, y posteriormente desbloquearla para permitir su entrega.
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void display_pending_mask() {
sigset_t current_pending;
sigpending(¤t_pending);
printf("Mapa de pendientes (1-31): ");
for (int i = 1; i <= 31; i++) {
printf("%d", sigismember(¤t_pending, i) ? 1 : 0);
}
printf("\n");
}
int main() {
sigset_t block_mask, previous_mask;
sigemptyset(&block_mask);
sigaddset(&block_mask, SIGINT);
// Aplicar la máscara de bloqueo
sigprocmask(SIG_BLOCK, &block_mask, &previous_mask);
printf("SIGINT (Ctrl+C) ha sido bloqueado.\n");
// Monitorear el estado pendiente durante 5 segundos
for (int i = 0; i < 5; i++) {
display_pending_mask();
sleep(1);
}
// Restaurar la máscara original, lo que entregará cualquier señal pendiente
printf("Restaurando máscara de señales...\n");
sigprocmask(SIG_SETMASK, &previous_mask, NULL);
pause(); // Esperar a que la señal entregada finalice el proceso
return 0;
}
La entrega de una señal (y la consiguiente ejecución de su handler) ocurre exclusivamente durante la transición del modo kernel al modo usuario. Cuando un proceso realiza una llamada al sistema o es interrumpido por un evento de hardware (como un clic del teclado o un tick del reloj del planificador), la CPU cambia a modo privilegiado. Antes de devolver el control al espacio de usuario, el kernel evalúa el mapa de bits de señales pendientes contra la máscara de bloqueo. Si existen señales listas para entrega, el kernel altera el stack del proceso para forzar la ejecución de la función handler antes de reanudar el código original.
Captura Robusta con sigaction
Aunque la función signal() es sencilla, su comportamiento varía entre diferentes versiones de UNIX. La API moderna y recomendada es sigaction(), la cual ofrece un control determinista sobre el manejo de señales.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
void robust_handler(int signum, siginfo_t *info, void *context) {
printf("Señal %d capturada de forma segura. PID emisor: %d\n", signum, info->si_pid);
}
int main() {
struct sigaction sa;
sa.sa_sigaction = robust_handler;
sa.sa_flags = SA_SIGINFO; // Habilitar el uso de sa_sigaction en lugar de sa_handler
// Bloquear SIGQUIT temporalmente mientras se ejecuta el handler de SIGINT
sigemptyset(&sa.sa_mask);
sigaddset(&sa.sa_mask, SIGQUIT);
if (sigaction(SIGINT, &sa, NULL) == -1) {
perror("Error configurando sigaction");
return EXIT_FAILURE;
}
while(1) {
printf("Proceso %d en ejecución...\n", getpid());
sleep(2);
}
return 0;
}
Una característica inherente de sigaction es que, por defecto, la señal que está siendo atendida se añade automáticamente a la máscara de bloqueo del proceso durante la ejecución de su propio handler. Esto previene la recursión accidental si la misma señal se genera repetidamente.
Concurrencia y Riesgos en el Manejo de Señales
Reentrancia y Funciones Async-Signal-Safe
Las señales introducen un flujo de ejecución concurrente e impredecible dentro de un mismo proceso. Si un handler interrumpe una función que está modificando una estructura de datos global (como una lista enlazada) y el handler invoca esa misma función, la estructura de datos sufrirá corrupción. Las funciones que no toleran esta interrupción se denominan no reentrantes. Funciones de la biblioteca estándar como malloc(), free() y printf() operan sobre estructuras globales y no son seguras para señales (Async-Signal-Safe).
Optimización del Compilador y Visibilidad
Los controladores de señales suelen modificar variables globales para comunicar eventos al bucle principal del programa. Si estas variables no se declaran adecuadamente, el compilador (bajo niveles de optimización como -O2) puede cargar el valor de la variable en un registro de la CPU y asumir que nunca cambia, creando un bucle infinito.
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
// El calificador volatile previene la optimización agresiva de lectura en caché.
// sig_atomic_t garantiza que la lectura/escritura sea atómica.
volatile sig_atomic_t shutdown_requested = 0;
void graceful_exit(int signum) {
shutdown_requested = 1;
}
int main() {
signal(SIGTERM, graceful_exit);
printf("Servidor iniciado. PID: %d\n", getpid());
while (!shutdown_requested) {
// Lógica principal del servidor
pause(); // Suspender ejecución hasta recibir una señal
}
printf("Cierre limpio completado.\n");
return 0;
}
Gestión de Procesos Hijos (SIGCHLD)
Cuando un proceso hijo cambia de estado (termina o se detiene), el kernel envía un SIGCHLD al proceso padre. Si el padre no captura esta señal para llamar a waitpid(), el hijo permanecerá en la tabla de procesos como un "proceso zombi", consumiendo recursos del sistema.
Un enfoque técnico para evitar zombis sin necesidad de implementar lógica compleja de recolección es configurar la acción por defecto de SIGCHLD para que sea ignorada explícitamente:
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
int main() {
// Indicar al kernel que elimine automáticamente los hijos al terminar
signal(SIGCHLD, SIG_IGN);
pid_t child = fork();
if (child == 0) {
printf("Hijo temporal ejecutándose...\n");
sleep(2);
exit(0);
}
// El padre continúa su ejecución sin preocuparse por llamar a wait()
while(1) {
printf("Proceso padre trabajando...\n");
sleep(3);
}
return 0;
}