시그널 처리 심화 (Signal Handling)

Linux 커널의 시그널 메커니즘을 심층적으로 분석합니다. 시그널 자료구조, 전송/전달 경로, signal frame과 sigreturn, 실시간 시그널, signalfd, 스레드 시그널 모델, 커널 내부 사용, 보안 측면, 디버깅 기법까지 포괄적으로 다룹니다.

전제 조건: 프로세스 관리(task_struct, 프로세스 상태)와 시스템 콜(커널 진입 경로)을 먼저 읽으세요.
일상 비유: 시그널은 전화 알림과 같습니다. 누군가(커널이나 다른 프로세스)가 전화(시그널)를 걸면, 지금 하던 일을 잠시 멈추고 전화를 받습니다(핸들러 실행). SIGKILL은 "강제 퇴거 통보"처럼 거부할 수 없고, SIGTERM은 "점잖은 퇴실 요청"으로 무시하거나 정리 후 나갈 수 있습니다. 시그널 마스크는 "방해 금지 모드"에 해당합니다.

핵심 요약

  • 시그널 — 프로세스에 비동기 이벤트를 알리는 소프트웨어 인터럽트입니다.
  • SIGKILL(9) / SIGTERM(15) — 프로세스 종료 시그널. SIGKILL은 무시·차단 불가합니다.
  • sigaction() — 시그널 핸들러를 등록하는 시스템 콜. 기본 동작을 사용자 정의 함수로 교체합니다.
  • 시그널 마스크sigprocmask()로 특정 시그널을 일시적으로 차단(블록)할 수 있습니다.
  • 실시간 시그널 — SIGRTMIN~SIGRTMAX. 큐잉되고 순서가 보장되는 확장 시그널입니다.

단계별 이해

  1. 시그널 보내기kill -SIGTERM <pid>로 프로세스에 시그널을 보냅니다.

    Ctrl+C는 SIGINT, Ctrl+Z는 SIGTSTP을 현재 포그라운드 프로세스에 보냅니다.

  2. 시그널 받기 — 커널은 시스템 콜에서 복귀할 때 보류(pending) 시그널을 확인하고 전달합니다.

    핸들러가 등록되어 있으면 사용자 공간에서 핸들러 함수를 실행합니다.

  3. 핸들러 작성sigaction()으로 핸들러를 등록합니다. 핸들러 내에서는 async-signal-safe 함수만 사용해야 합니다.

    signal()보다 sigaction()이 이식성과 안정성이 높습니다.

  4. 디버깅strace -e signal ./app으로 시그널 전달을 추적할 수 있습니다.

    /proc/<pid>/status에서 SigPnd, SigBlk, SigCgt 필드로 시그널 상태를 확인합니다.

시그널 개요

시그널(signal)은 프로세스에 비동기 이벤트를 알리는 소프트웨어 인터럽트입니다. 커널, 다른 프로세스, 또는 프로세스 자신이 시그널을 보낼 수 있으며, 수신 프로세스는 기본 동작(종료, 코어 덤프, 중지 등)을 따르거나, 사용자 정의 핸들러를 설치하거나, 시그널을 무시할 수 있습니다.

시그널의 역할

표준 시그널 목록

번호시그널기본 동작설명
1SIGHUP종료제어 터미널 끊김, 데몬 설정 재로드 관례
2SIGINT종료Ctrl+C, 포그라운드 프로세스 인터럽트
3SIGQUIT코어 덤프Ctrl+\, 종료 + 코어 덤프 생성
6SIGABRT코어 덤프abort() 호출
9SIGKILL종료무조건 종료, 핸들러 설치/블록 불가
11SIGSEGV코어 덤프잘못된 메모리 접근 (segmentation fault)
13SIGPIPE종료읽는 쪽이 닫힌 파이프/소켓에 쓰기
14SIGALRM종료alarm() 타이머 만료
15SIGTERM종료정상 종료 요청 (기본 kill 시그널)
17SIGCHLD무시자식 프로세스 상태 변경
19SIGSTOP중지프로세스 중지, 핸들러 설치/블록 불가
20SIGTSTP중지Ctrl+Z, 터미널 중지
18SIGCONT재개중지된 프로세스 재개

시그널 번호는 아키텍처마다 다릅니다. 위 표는 x86/x86_64 기준입니다. MIPS, Alpha 등에서는 번호가 다를 수 있습니다. 커널 소스에서 arch/<arch>/include/uapi/asm/signal.h를 확인하세요.

시그널 자료구조

커널은 시그널을 처리하기 위해 여러 자료구조를 사용합니다. 이들은 task_struct에 연결되어 프로세스/스레드별 시그널 상태를 관리합니다.

sigset_t - 시그널 비트마스크

/* include/linux/signal_types.h */
typedef struct {
    unsigned long sig[_NSIG_WORDS];  /* _NSIG=64, 64비트에서 1개 워드 */
} sigset_t;

/* 시그널 비트마스크 조작 매크로 */
sigaddset(&set, SIGTERM);     /* 시그널 추가 */
sigdelset(&set, SIGTERM);     /* 시그널 제거 */
sigismember(&set, SIGTERM);   /* 포함 여부 확인 */
sigemptyset(&set);            /* 모든 비트 클리어 */
sigfillset(&set);             /* 모든 비트 설정 */
sigandsets(&dst, &a, &b);    /* AND 연산 */
sigorsets(&dst, &a, &b);     /* OR 연산 */
signotset(&set);              /* NOT 연산 (반전) */

struct k_sigaction - 시그널 핸들러 정보

/* include/linux/signal_types.h */
struct k_sigaction {
    struct sigaction sa;
};

/* include/uapi/asm-generic/signal-defs.h */
struct sigaction {
    __sighandler_t  sa_handler;   /* SIG_DFL, SIG_IGN, 또는 핸들러 주소 */
    unsigned long   sa_flags;     /* SA_SIGINFO, SA_RESTART, SA_ONSTACK 등 */
    __sigrestore_t sa_restorer;   /* sigreturn trampoline (VDSO) */
    sigset_t        sa_mask;      /* 핸들러 실행 중 추가 블록할 시그널 */
};

struct sigpending - 대기 중인 시그널

/* include/linux/signal_types.h */
struct sigpending {
    struct list_head list;   /* sigqueue 연결 리스트 */
    sigset_t signal;          /* 대기 중인 시그널 비트마스크 */
};

/* 개별 시그널 큐 항목 */
struct sigqueue {
    struct list_head   list;
    int                flags;
    struct siginfo     info;    /* 시그널 상세 정보 */
    struct ucounts     *ucounts;
};

sighand_struct - 핸들러 테이블

/* include/linux/sched/signal.h */
struct sighand_struct {
    refcount_t          count;          /* 참조 카운트 */
    struct k_sigaction  action[_NSIG];  /* 64개 시그널 핸들러 */
    spinlock_t          siglock;        /* 시그널 처리 동기화 */
    wait_queue_head_t   signalfd_wqh;   /* signalfd 대기 큐 */
};

/* sighand_struct는 같은 스레드 그룹의 모든 스레드가 공유합니다.
 * clone(CLONE_SIGHAND)로 생성된 스레드들이 동일한 sighand를 참조합니다.
 * action[] 배열 인덱스는 시그널 번호 - 1 입니다 (0-based). */

signal_struct - 프로세스 그룹 시그널 정보

/* include/linux/sched/signal.h (주요 필드만) */
struct signal_struct {
    refcount_t          sigcnt;
    atomic_t            live;            /* 살아있는 스레드 수 */

    struct sigpending   shared_pending;  /* 프로세스 단위 대기 시그널 */

    int                 group_exit_code;
    int                 group_stop_count;
    unsigned int        flags;           /* SIGNAL_GROUP_EXIT 등 */

    struct rlimit       rlim[RLIM_NLIMITS]; /* RLIMIT_SIGPENDING 포함 */
    struct task_struct  *group_exec_task;
};

/* task_struct 내 시그널 관련 필드 */
struct task_struct {
    /* ... */
    struct signal_struct   *signal;     /* 스레드 그룹 공유 */
    struct sighand_struct  *sighand;    /* 핸들러 테이블 (공유) */
    struct sigpending      pending;     /* 스레드 개별 대기 시그널 */
    sigset_t               blocked;     /* 블록된 시그널 마스크 */
    sigset_t               real_blocked;
    sigset_t               saved_sigmask;
    unsigned long          sas_ss_sp;   /* 대체 시그널 스택 포인터 */
    size_t                 sas_ss_size; /* 대체 시그널 스택 크기 */
    /* ... */
};
💡

두 가지 pending 큐: task_struct.pending은 특정 스레드에게 보낸 시그널(tkill/tgkill)을, signal_struct.shared_pending은 프로세스 전체에 보낸 시그널(kill)을 저장합니다. 시그널 전달 시 두 큐를 모두 확인합니다.

시그널 전송

시그널 전송은 사용자 공간의 시스템 콜에서 시작하여 커널 내부 함수 체인을 통해 대상 프로세스의 pending 큐에 시그널을 추가하는 과정입니다.

전송 시스템 콜

/* 유저 공간 시스템 콜 인터페이스 */

/* 프로세스(스레드 그룹) 단위 시그널 전송 */
kill(pid_t pid, int sig);
/* pid > 0 : 해당 PID 프로세스
 * pid == 0: 같은 프로세스 그룹의 모든 프로세스
 * pid == -1: 시그널 전송 가능한 모든 프로세스
 * pid < -1 : 프로세스 그룹 ID == |pid|인 모든 프로세스 */

/* 특정 스레드에 시그널 전송 (Linux 전용) */
tkill(pid_t tid, int sig);               /* 구형, race 조건 가능 */
tgkill(pid_t tgid, pid_t tid, int sig);  /* 권장: tgid로 검증 */

/* 시그널 + 데이터 전송 (RT 시그널용) */
sigqueue(pid_t pid, int sig, const union sigval value);

/* 파일 디스크립터 소유자에게 시그널 전송 */
fcntl(fd, F_SETOWN, pid);   /* 소유자 설정 */
fcntl(fd, F_SETSIG, sig);   /* SIGIO 대신 사용할 시그널 */

커널 내부 전송 경로

/* kernel/signal.c - 시그널 전송의 핵심 경로 */

/* kill() 시스템 콜 → 커널 진입점 */
SYSCALL_DEFINE2(kill, pid_t, pid, int, sig)
{
    struct kernel_siginfo info;
    prepare_kill_siginfo(sig, &info);
    return kill_something_info(sig, &info, pid);
}

/* kill_something_info → kill_pid_info → group_send_sig_info */
int group_send_sig_info(int sig, struct kernel_siginfo *info,
                        struct task_struct *p, enum pid_type type)
{
    int ret;
    rcu_read_lock();
    ret = check_kill_permission(sig, info, p);  /* 권한 검사 */
    if (!ret)
        ret = do_send_sig_info(sig, info, p, type);
    rcu_read_unlock();
    return ret;
}

/* do_send_sig_info → send_signal_locked */
static int send_signal_locked(int sig, struct kernel_siginfo *info,
                               struct task_struct *t, enum pid_type type)
{
    struct sigpending *pending;
    struct sigqueue *q;

    /* 프로세스 단위(PIDTYPE_TGID) → shared_pending
     * 스레드 단위(PIDTYPE_PID)  → task.pending */
    pending = (type != PIDTYPE_PID) ?
              &t->signal->shared_pending : &t->pending;

    /* 이미 같은 비-RT 시그널이 pending이면 무시 (중복 방지) */
    if (legacy_queue(pending, sig))
        goto ret;

    /* sigqueue 할당 및 큐에 추가 */
    q = __sigqueue_alloc(sig, t, GFP_ATOMIC, override_rlimit);
    if (q) {
        list_add_tail(&q->list, &pending->list);
        copy_siginfo(&q->info, info);
    }
    sigaddset(&pending->signal, sig);

    /* 대상 스레드 깨우기 */
    complete_signal(sig, t, type);
    return 0;
}

complete_signal - 대상 스레드 선택

/* kernel/signal.c */
static void complete_signal(int sig, struct task_struct *p,
                             enum pid_type type)
{
    struct task_struct *t, *signal_target;

    /* 프로세스 단위 시그널: 시그널을 블록하지 않는 스레드를 찾음 */
    signal_target = p;
    if (wants_signal(sig, p))
        goto found;

    /* 메인 스레드가 블록하면 다른 스레드를 순회 */
    t = p;
    while ((t = next_thread(t)) != p) {
        if (wants_signal(sig, t)) {
            signal_target = t;
            goto found;
        }
    }
    return;  /* 모든 스레드가 블록 → 시그널은 pending으로 남음 */

found:
    /* TIF_SIGPENDING 설정 → 다음 커널→유저 전환 시 시그널 처리 */
    signal_wake_up(signal_target, sig == SIGKILL);
}

/* wants_signal(): 스레드가 시그널을 받을 수 있는지 확인 */
static inline bool wants_signal(int sig, struct task_struct *p)
{
    if (sigismember(&p->blocked, sig))
        return false;      /* 블록된 시그널 */
    if (p->flags & PF_EXITING)
        return false;      /* 종료 중 */
    if (sig == SIGKILL)
        return true;       /* SIGKILL은 항상 전달 */
    if (task_is_stopped_or_traced(p))
        return false;      /* 중지/추적 중 */
    return task_curr(p) || !task_sigpending(p);
}

legacy_queue() 함수는 비-RT 시그널(1~31)에 대해 이미 같은 시그널이 pending이면 새 시그널을 무시합니다. 따라서 표준 시그널은 큐잉되지 않으며, 여러 번 보내도 한 번만 전달됩니다. RT 시그널(32~64)은 이 제한이 없어 큐잉됩니다.

시그널 전달 (Delivery)

시그널 전달은 pending 큐에 있는 시그널을 실제로 처리하는 단계입니다. 커널에서 유저 공간으로 복귀하기 직전(exit_to_user_mode_loop)에 TIF_SIGPENDING 플래그를 확인하고 시그널을 처리합니다.

전달 경로 흐름

시그널 전달 (Delivery) 흐름 syscall/IRQ 리턴 TIF_SIGPENDING 확인 get_signal() dequeue 핸들러? SIG_DFL 기본 동작 종료/코어덤프/중지 핸들러 handle_signal() setup_rt_frame() 유저 스택에 프레임 구성 유저 핸들러
시그널이 커널에서 유저 공간 핸들러로 전달되는 과정

get_signal() - 시그널 디큐

/* kernel/signal.c */
bool get_signal(struct ksignal *ksig)
{
    struct task_struct *tsk = current;
    struct sighand_struct *sighand = tsk->sighand;
    int signr;

    for (;;) {
        /* 그룹 중지/종료 처리 */
        if (unlikely(tsk->jobctl & JOBCTL_STOP_PENDING))
            do_jobctl_trap();

        /* pending 큐에서 다음 시그널 가져오기 */
        signr = dequeue_signal(tsk, &tsk->blocked, &ksig->info);
        if (!signr)
            break;  /* pending 시그널 없음 */

        /* 핸들러 확인 */
        struct k_sigaction *ka = &sighand->action[signr - 1];

        if (ka->sa.sa_handler == SIG_IGN)
            continue;  /* 무시 */

        if (ka->sa.sa_handler != SIG_DFL) {
            /* 사용자 핸들러 → handle_signal()로 전달 */
            ksig->ka = *ka;
            if (ka->sa.sa_flags & SA_ONESHOT)
                ka->sa.sa_handler = SIG_DFL;
            return true;
        }

        /* SIG_DFL: 기본 동작 수행 */
        if (sig_kernel_ignore(signr))
            continue;
        if (sig_kernel_stop(signr))
            do_signal_stop(signr);
        if (sig_kernel_coredump(signr))
            do_coredump(&ksig->info);
        do_group_exit(signr);  /* 종료 */
    }
    return false;
}

/* dequeue_signal: 두 pending 큐를 모두 확인 */
int dequeue_signal(struct task_struct *tsk, sigset_t *mask,
                    struct kernel_siginfo *info)
{
    int signr;
    /* 스레드 개별 pending 먼저 확인 */
    signr = __dequeue_signal(&tsk->pending, mask, info);
    if (!signr)
        /* 프로세스 공유 pending 확인 */
        signr = __dequeue_signal(&tsk->signal->shared_pending, mask, info);
    return signr;
}

handle_signal() - 유저 핸들러 설정

/* arch/x86/kernel/signal.c */
static void handle_signal(struct ksignal *ksig, struct pt_regs *regs)
{
    /* 1. 유저 스택에 signal frame 구성 */
    bool failed = setup_rt_frame(ksig, regs);

    if (failed) {
        force_sigsegv(ksig->sig);
        return;
    }

    /* 2. 시그널 블록 마스크 갱신 */
    signal_setup_done(failed, ksig, stepping());
    /*
     * signal_setup_done()이 수행하는 작업:
     * - sa_mask에 지정된 시그널을 blocked에 추가
     * - SA_NODEFER가 없으면 현재 시그널도 blocked에 추가
     * → 핸들러 실행 중 같은 시그널의 재진입 방지
     */
}

Signal Frame

시그널 핸들러를 호출하려면 커널이 유저 스택에 signal frame을 구성해야 합니다. 이 프레임에는 핸들러 리턴 후 원래 실행을 복구하기 위한 모든 정보가 들어있습니다.

rt_sigframe 구조 (x86_64)

/* arch/x86/include/asm/sigframe.h */
struct rt_sigframe {
    char __user         *pretcode;    /* sigreturn trampoline 주소 */
    struct ucontext     uc;           /* 실행 컨텍스트 */
    struct siginfo      info;         /* SA_SIGINFO일 때 siginfo */
};

struct ucontext {
    unsigned long       uc_flags;
    struct ucontext     *uc_link;
    stack_t             uc_stack;     /* 대체 시그널 스택 정보 */
    struct sigcontext   uc_mcontext;  /* 레지스터 상태 */
    sigset_t            uc_sigmask;   /* 블록 마스크 */
};

/* sigcontext: 유저 공간 레지스터 저장 */
struct sigcontext {
    __u64 r8, r9, r10, r11, r12, r13, r14, r15;
    __u64 rdi, rsi, rbp, rbx, rdx, rcx, rax;
    __u64 trapno, err;
    __u64 rip;               /* 복귀 주소 (인터럽트된 지점) */
    __u64 cs, eflags, rsp, ss;
    struct _fpstate *fpstate; /* FPU/SSE/AVX 상태 */
};

setup_rt_frame 동작

/* arch/x86/kernel/signal.c (간략화) */
static int setup_rt_frame(struct ksignal *ksig,
                           struct pt_regs *regs)
{
    struct rt_sigframe __user *frame;

    /* 1. 유저 스택에 프레임 공간 확보 */
    frame = get_sigframe(ksig, regs, sizeof(*frame));
    /* SA_ONSTACK이면 대체 스택(sigaltstack) 사용 */

    /* 2. siginfo 복사 (SA_SIGINFO인 경우) */
    copy_siginfo_to_user(&frame->info, &ksig->info);

    /* 3. ucontext 저장: 레지스터, 시그널 마스크, FPU 상태 */
    setup_sigcontext(&frame->uc.uc_mcontext, regs);
    __put_user(current->blocked, &frame->uc.uc_sigmask);
    save_fpu_state(&frame->uc.uc_mcontext);

    /* 4. pretcode에 VDSO sigreturn trampoline 주소 설정 */
    __put_user(vdso_sigreturn, &frame->pretcode);

    /* 5. pt_regs 수정: 핸들러가 실행되도록 설정 */
    regs->sp  = (unsigned long)frame;    /* 스택 → 프레임 */
    regs->ip  = (unsigned long)ksig->ka.sa.sa_handler; /* RIP → 핸들러 */
    regs->di  = ksig->sig;               /* 1번째 인자: 시그널 번호 */
    regs->si  = (unsigned long)&frame->info;  /* 2번째: siginfo* */
    regs->dx  = (unsigned long)&frame->uc;    /* 3번째: ucontext* */

    return 0;
}

스택 프레임 구성 후 유저 복귀: 커널은 pt_regs의 RIP를 시그널 핸들러 주소로, RSP를 signal frame으로 변경합니다. 유저 공간으로 복귀하면 마치 핸들러가 호출된 것처럼 실행이 이어집니다. 핸들러가 리턴하면 스택의 pretcode (VDSO sigreturn trampoline)로 점프하여 rt_sigreturn 시스템 콜을 호출합니다.

sigreturn 시스템 콜

/* arch/x86/kernel/signal.c */
SYSCALL_DEFINE0(rt_sigreturn)
{
    struct pt_regs *regs = current_pt_regs();
    struct rt_sigframe __user *frame;
    sigset_t set;

    frame = (struct rt_sigframe __user *)(regs->sp - sizeof(long));

    /* 1. signal frame에서 블록 마스크 복원 */
    __get_user(set, &frame->uc.uc_sigmask);
    set_current_blocked(&set);

    /* 2. 저장된 레지스터 복원 (RIP, RSP 포함) */
    restore_sigcontext(regs, &frame->uc.uc_mcontext);

    /* 3. FPU 상태 복원 */
    restore_fpu_state(&frame->uc.uc_mcontext);

    /* 원래 인터럽트된 지점으로 복귀 */
    return regs->ax;
}

VDSO sigreturn trampoline

/* arch/x86/entry/vdso/vdso32/sigreturn.S (32-bit 예시) */
/* VDSO에 매핑된 sigreturn 트램펄린 코드 */

/*
 * 핸들러가 return하면 스택에서 pretcode 주소를 pop하여 여기로 점프합니다.
 * 이 코드는 rt_sigreturn 시스템 콜을 호출하여 원래 실행을 복원합니다.
 *
 * __vdso_rt_sigreturn:
 *     mov    $__NR_rt_sigreturn, %eax
 *     syscall                          (또는 int $0x80)
 *
 * VDSO를 사용하는 이유:
 * 1. 유저 스택에 실행 코드를 넣으면 NX(No-Execute) 보호와 충돌
 * 2. VDSO는 커널이 유저 주소 공간에 매핑한 읽기+실행 페이지
 * 3. 모든 프로세스가 공유하므로 메모리 효율적
 * 4. ASLR 적용으로 주소가 프로세스마다 다름
 */

SROP (Sigreturn-Oriented Programming) 공격: 공격자가 가짜 signal frame을 스택에 구성하고 sigreturn을 호출하면 임의의 레지스터 값을 설정할 수 있습니다. 이에 대한 대응으로 Linux는 signal frame에 cookie/canary 검증을 추가하고 있으며, x86에서는 shadow stack (CET)이 추가 보호를 제공합니다.

시그널 마스킹

프로세스는 sigprocmask()를 사용하여 특정 시그널의 전달을 일시적으로 차단(블록)할 수 있습니다. 블록된 시그널은 사라지지 않고 pending 상태로 유지되다가 블록 해제 시 전달됩니다.

sigprocmask 시스템 콜

/* 유저 공간 인터페이스 */
#include <signal.h>

sigset_t mask, oldmask;

sigemptyset(&mask);
sigaddset(&mask, SIGINT);
sigaddset(&mask, SIGTERM);

/* SIG_BLOCK: 기존 블록 마스크에 추가 */
sigprocmask(SIG_BLOCK, &mask, &oldmask);

/* 크리티컬 섹션: SIGINT, SIGTERM이 전달되지 않음 */
do_critical_work();

/* SIG_SETMASK: 이전 마스크로 복원 */
sigprocmask(SIG_SETMASK, &oldmask, NULL);
/* 이 시점에서 pending이었던 SIGINT/SIGTERM이 전달됨 */

/* SIG_UNBLOCK: 특정 시그널만 블록 해제 */
sigprocmask(SIG_UNBLOCK, &mask, NULL);

커널 내부 마스킹 처리

/* kernel/signal.c */
SYSCALL_DEFINE4(rt_sigprocmask, int, how, sigset_t __user *, nset,
                sigset_t __user *, oset, size_t, sigsetsize)
{
    sigset_t old_set, new_set;

    if (oset)
        old_set = current->blocked;

    if (nset) {
        copy_from_user(&new_set, nset, sizeof(sigset_t));
        /* SIGKILL, SIGSTOP은 절대 블록 불가 */
        sigdelsetmask(&new_set, sigmask(SIGKILL) | sigmask(SIGSTOP));

        switch (how) {
        case SIG_BLOCK:
            sigorsets(&new_set, ¤t->blocked, &new_set);
            break;
        case SIG_UNBLOCK:
            sigandnsets(&new_set, ¤t->blocked, &new_set);
            break;
        case SIG_SETMASK:
            break;
        }
        set_current_blocked(&new_set);
        /* → recalc_sigpending() 호출: TIF_SIGPENDING 재계산 */
    }
}

SA_FLAGS 플래그

플래그설명
SA_SIGINFO3인자 핸들러 사용: void handler(int sig, siginfo_t *info, void *ucontext)
SA_RESTART시그널로 인터럽트된 시스템 콜을 자동 재시작
SA_NODEFER핸들러 실행 중 같은 시그널을 블록하지 않음 (재진입 허용)
SA_RESETHAND핸들러 실행 후 SIG_DFL로 리셋 (일회성)
SA_ONSTACKsigaltstack으로 설정한 대체 스택에서 핸들러 실행
SA_NOCLDSTOP자식이 중지될 때 SIGCHLD를 받지 않음
SA_NOCLDWAIT자식이 종료 시 좀비 생성 안 함 (자동 reap)
SA_RESTORERsa_restorer 필드가 유효함 (libc가 VDSO 주소 설정)
💡

SA_RESTART와 시스템 콜: SA_RESTART가 설정되면 read(), write(), waitpid() 등 느린(slow) 시스템 콜이 시그널에 의해 중단된 후 자동으로 재시작됩니다. 그렇지 않으면 -EINTR을 반환하며, 유저 프로그램이 직접 재시도 루프를 구현해야 합니다.

실시간 시그널 (RT Signals)

POSIX 실시간 시그널(SIGRTMIN~SIGRTMAX, 일반적으로 32~64)은 표준 시그널의 한계를 극복합니다.

표준 시그널 vs RT 시그널

특성표준 시그널 (1~31)RT 시그널 (32~64)
큐잉안 됨 (하나만 pending)됨 (여러 개 큐잉)
전달 순서보장 안 됨시그널 번호 순 (낮은 번호 먼저)
데이터 전달불가sigqueue()로 sigval 전달 가능
의미사전 정의 (SIGTERM 등)애플리케이션 정의
개수 제한없음 (비트마스크)RLIMIT_SIGPENDING 제한

RT 시그널 전송

#include <signal.h>

/* sigqueue()를 이용한 데이터 전달 */
union sigval value;
value.sival_int = 42;
value.sival_ptr = my_data;

sigqueue(target_pid, SIGRTMIN + 3, value);

/* 핸들러에서 데이터 수신 (SA_SIGINFO 필수) */
void rt_handler(int sig, siginfo_t *info, void *ucontext)
{
    int data = info->si_value.sival_int;  /* 42 */
    pid_t sender = info->si_pid;          /* 보낸 프로세스 PID */
    uid_t uid = info->si_uid;              /* 보낸 프로세스 UID */
    printf("RT signal %d from PID %d, data=%d\n",
           sig, sender, data);
}

/* 핸들러 등록 */
struct sigaction sa;
sa.sa_sigaction = rt_handler;
sa.sa_flags = SA_SIGINFO;
sigemptyset(&sa.sa_mask);
sigaction(SIGRTMIN + 3, &sa, NULL);

커널 내부 RT 시그널 처리

/* kernel/signal.c - legacy_queue()에서 RT 시그널 구분 */
static inline bool legacy_queue(struct sigpending *signals, int sig)
{
    /* RT 시그널(sig >= 32)은 항상 큐잉 → false 반환 */
    return (sig < SIGRTMIN) && sigismember(&signals->signal, sig);
}

/* __dequeue_signal에서 RT 시그널의 우선순위 처리 */
/*
 * next_signal()은 sigset_t에서 가장 낮은 번호의 설정된 비트를 반환합니다.
 * 따라서 RT 시그널 중 SIGRTMIN이 SIGRTMAX보다 먼저 전달됩니다.
 * 같은 번호의 RT 시그널이 여러 개 큐잉된 경우 FIFO 순서로 전달됩니다.
 */

/* RLIMIT_SIGPENDING: 큐잉 가능한 시그널 수 제한 */
/* 프로세스당 최대 sigqueue 수를 제한하여 DoS 방지 */
/* ulimit -i 로 확인/설정, 기본값은 보통 약 128000 */

RLIMIT_SIGPENDING 초과 시: sigqueue 할당이 실패하면 시그널은 여전히 전달되지만 siginfo 데이터는 손실될 수 있습니다. si_codeSI_USER로 설정되어 수신 측에서 데이터 유무를 구분할 수 없게 됩니다.

signalfd

signalfd는 시그널을 파일 디스크립터를 통해 수신하는 메커니즘입니다. epoll, select, poll과 통합하여 이벤트 루프 기반 프로그래밍에서 시그널을 통합 처리할 수 있습니다.

signalfd 사용법

#include <sys/signalfd.h>
#include <sys/epoll.h>
#include <signal.h>

/* 1. 대상 시그널을 블록 (핸들러 전달 방지) */
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGINT);
sigaddset(&mask, SIGTERM);
sigaddset(&mask, SIGCHLD);
sigprocmask(SIG_BLOCK, &mask, NULL);

/* 2. signalfd 생성 */
int sfd = signalfd(-1, &mask, SFD_NONBLOCK | SFD_CLOEXEC);

/* 3. epoll에 등록 */
int epfd = epoll_create1(EPOLL_CLOEXEC);
struct epoll_event ev = { .events = EPOLLIN, .data.fd = sfd };
epoll_ctl(epfd, EPOLL_CTL_ADD, sfd, &ev);

/* 4. 이벤트 루프에서 시그널 읽기 */
struct signalfd_siginfo fdsi;
while (1) {
    int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
    for (int i = 0; i < nfds; i++) {
        if (events[i].data.fd == sfd) {
            ssize_t s = read(sfd, &fdsi, sizeof(fdsi));
            if (s == sizeof(fdsi)) {
                printf("Signal %d from PID %d\n",
                       fdsi.ssi_signo, fdsi.ssi_pid);
                if (fdsi.ssi_signo == SIGTERM)
                    graceful_shutdown();
            }
        }
    }
}

signalfd 커널 구현

/* fs/signalfd.c */

/* signalfd_poll(): epoll이 호출하는 poll 콜백 */
static __poll_t signalfd_poll(struct file *file,
                              struct poll_table_struct *wait)
{
    struct signalfd_ctx *ctx = file->private_data;
    __poll_t events = 0;

    /* sighand->signalfd_wqh에 등록 → 시그널 전달 시 깨어남 */
    poll_wait(file, ¤t->sighand->signalfd_wqh, wait);

    /* pending 시그널 중 관심 있는 것이 있으면 POLLIN */
    if (next_signal(¤t->pending, &ctx->sigmask) ||
        next_signal(¤t->signal->shared_pending, &ctx->sigmask))
        events |= EPOLLIN;

    return events;
}

/* signalfd_read(): read() 시 호출 */
/* dequeue_signal()을 호출하여 pending 큐에서 시그널을 꺼내고,
 * signalfd_siginfo 구조체로 변환하여 유저 버퍼에 복사합니다.
 * 시그널이 없고 O_NONBLOCK이 아니면 대기합니다. */
💡

signalfd의 장점: (1) 시그널 핸들러의 비동기성 문제 회피 - 핸들러 내에서 async-signal-safe 함수만 호출해야 하는 제약이 없음. (2) epoll/select로 I/O 이벤트와 시그널을 통합 대기. (3) 멀티스레드 환경에서 특정 스레드가 시그널을 처리하도록 구성 용이.

시그널과 스레드

POSIX 스레드(pthread) 모델에서 시그널 처리는 복잡합니다. 시그널은 프로세스 수준과 스레드 수준의 두 가지 관점을 가집니다.

프로세스 vs 스레드 시그널

/*
 * 리눅스 스레드 시그널 모델:
 *
 * [프로세스 (thread group)]
 *  ├── signal_struct (공유)
 *  │   └── shared_pending  ← kill()로 보낸 시그널
 *  │
 *  ├── sighand_struct (공유)
 *  │   └── action[64]      ← 핸들러 테이블 (전 스레드 공유)
 *  │
 *  ├── Thread 1 (task_struct)
 *  │   ├── pending          ← tgkill()로 보낸 시그널
 *  │   └── blocked          ← 개별 블록 마스크
 *  │
 *  ├── Thread 2 (task_struct)
 *  │   ├── pending
 *  │   └── blocked
 *  │
 *  └── Thread 3 (task_struct)
 *      ├── pending
 *      └── blocked
 *
 * 공유되는 것: 핸들러(sigaction), 프로세스 시그널 큐
 * 개별적인 것: blocked 마스크, 스레드별 pending 큐
 */

/* 프로세스 단위 시그널 (shared_pending에 큐잉) */
kill(pid, SIGTERM);         /* 블록하지 않는 임의의 스레드가 처리 */

/* 스레드 단위 시그널 (특정 스레드의 pending에 큐잉) */
pthread_kill(thread, SIGUSR1);  /* 정확히 해당 스레드만 처리 */
tgkill(tgid, tid, SIGUSR1);    /* 커널 인터페이스 */

/* 동기적 시그널은 항상 원인 스레드에 전달 */
/* SIGSEGV, SIGFPE, SIGILL, SIGBUS, SIGTRAP */

멀티스레드 시그널 처리 패턴

/* 권장 패턴: 전용 시그널 처리 스레드 */

static void *signal_thread(void *arg)
{
    sigset_t *mask = arg;
    int sig;

    for (;;) {
        /* sigwait: 동기적으로 시그널 대기 */
        sigwait(mask, &sig);
        switch (sig) {
        case SIGTERM:
            initiate_shutdown();
            return NULL;
        case SIGHUP:
            reload_config();
            break;
        case SIGCHLD:
            reap_children();
            break;
        }
    }
}

int main(void)
{
    sigset_t mask;
    pthread_t sig_thread;

    /* 메인 스레드에서 모든 시그널 블록 */
    sigfillset(&mask);
    pthread_sigmask(SIG_BLOCK, &mask, NULL);
    /* 이후 생성되는 모든 스레드도 이 마스크를 상속 */

    /* 시그널 전용 스레드 생성 */
    sigemptyset(&mask);
    sigaddset(&mask, SIGTERM);
    sigaddset(&mask, SIGHUP);
    sigaddset(&mask, SIGCHLD);
    pthread_create(&sig_thread, NULL, signal_thread, &mask);

    /* 워커 스레드들 생성 (시그널 블록 상태) */
    create_worker_threads();

    pthread_join(sig_thread, NULL);
    return 0;
}

주의: signal()/sigaction()으로 설정한 핸들러는 프로세스 전체에 적용됩니다. 멀티스레드 프로그램에서 시그널 핸들러를 사용하면 어느 스레드에서 핸들러가 실행될지 예측하기 어렵습니다. 전용 스레드 + sigwait() 또는 signalfd 패턴을 권장합니다.

커널 내부 시그널 사용

커널 자체도 여러 서브시스템에서 시그널을 활용합니다. 유저 공간 프로세스에 중요한 이벤트를 알리거나 강제 종료하는 데 사용됩니다.

SIGKILL/SIGSTOP 특수 처리

/* SIGKILL과 SIGSTOP은 커널이 특별히 처리합니다:
 *
 * 1. 핸들러 설치 불가: sigaction()에서 SIG_DFL 강제
 * 2. 블록 불가: sigprocmask()에서 자동 제거
 * 3. 무시 불가: SIG_IGN 설정이 거부됨
 */

/* kernel/signal.c - do_sigaction() */
int do_sigaction(int sig, struct k_sigaction *act,
                  struct k_sigaction *oact)
{
    /* SIGKILL, SIGSTOP에 대한 핸들러 변경 거부 */
    if (sig == SIGKILL || sig == SIGSTOP)
        return -EINVAL;
    /* ... */
}

/* SIGKILL 전달 시: 프로세스의 모든 스레드에 전파 */
static void complete_signal(int sig, struct task_struct *p, ...)
{
    if (sig_fatal(p, sig)) {
        /* SIGKILL: 모든 스레드에 TIF_SIGPENDING 설정 */
        /* signal->flags |= SIGNAL_GROUP_EXIT */
        signal_wake_up(t, 1);  /* 1 = SIGKILL, 대기 중이면 강제 깨움 */
    }
}

OOM Killer와 시그널

/* mm/oom_kill.c */
static void oom_kill_process(struct oom_control *oc,
                             const char *message)
{
    struct task_struct *victim = oc->chosen;

    /* TIF_MEMDIE 설정: 메모리 할당 우선권 부여 */
    mark_oom_victim(victim);

    /* SIGKILL 전송: 프로세스 종료하여 메모리 회수 */
    do_send_sig_info(SIGKILL, SEND_SIG_PRIV, victim, PIDTYPE_TGID);

    /* 자식 프로세스에도 SIGKILL 전송 가능 */
    pr_err("Killed process %d (%s) total-vm:%lukB\n",
           task_pid_nr(victim), victim->comm,
           victim->mm->total_vm << (PAGE_SHIFT - 10));
}

코어 덤프 생성

/* fs/coredump.c */
void do_coredump(const struct kernel_siginfo *siginfo)
{
    /* 코어 덤프를 유발하는 시그널:
     * SIGQUIT, SIGILL, SIGTRAP, SIGABRT, SIGFPE,
     * SIGSEGV, SIGBUS, SIGSYS, SIGXCPU, SIGXFSZ */

    /* 1. 스레드 그룹의 모든 스레드 중지 */
    zap_threads(tsk, mm, core_state, exit_code);

    /* 2. 코어 파일 생성 (core_pattern에 따라) */
    /* /proc/sys/kernel/core_pattern 설정:
     *   "core"                    → 현재 디렉토리에 core 파일
     *   "/var/cores/core.%e.%p"   → 프로그램명.PID
     *   "|/usr/bin/coredumpctl"   → 파이프로 외부 프로그램에 전달
     */

    /* 3. 바이너리 포맷 핸들러의 core_dump() 호출 */
    /* ELF: elf_core_dump() → PT_NOTE + 레지스터 + 메모리 매핑 */
}
💡

코어 덤프 제어: ulimit -c unlimited로 코어 파일 크기 제한 해제, /proc/sys/kernel/core_pattern으로 파일 경로 패턴 설정, prctl(PR_SET_DUMPABLE, 1)로 setuid 프로세스 코어 덤프 허용. systemd 환경에서는 coredumpctl을 통해 관리합니다.

시그널 보안

시그널 전송에는 권한 검사가 필수입니다. 무분별한 시그널 전송은 DoS 공격이나 권한 상승에 악용될 수 있기 때문입니다.

권한 검사

/* kernel/signal.c */
static int check_kill_permission(int sig,
    struct kernel_siginfo *info, struct task_struct *t)
{
    struct pid *sid;
    int error;

    /* 시그널 번호 유효성 검사 */
    if (!valid_signal(sig))
        return -EINVAL;

    /* 커널 내부 시그널은 항상 허용 */
    if (!si_fromuser(info))
        return 0;

    error = audit_signal_info(sig, t);  /* 감사 로그 */
    if (error)
        return error;

    /* POSIX 권한 검사:
     * - 같은 UID (real 또는 saved set-user-ID)
     * - CAP_KILL capability
     * - 같은 세션의 SIGCONT */
    if (!same_thread_group(current, t) &&
        !kill_ok_by_cred(t)) {
        /* LSM (SELinux/AppArmor) 검사 */
        error = security_task_kill(t, info, sig, NULL);
        if (error)
            return error;
    }
    return 0;
}

PID 네임스페이스와 시그널

/*
 * PID 네임스페이스 경계에서의 시그널 전송 규칙:
 *
 * 1. 자식 네임스페이스 → 부모 네임스페이스:
 *    - 부모 NS의 프로세스 PID를 알 수 없으므로 불가
 *    - 네임스페이스 init(PID 1)에 시그널은 가능하나
 *      핸들러가 설치된 시그널만 전달됨
 *
 * 2. 부모 네임스페이스 → 자식 네임스페이스:
 *    - 정상적으로 전달됨
 *    - 자식 NS의 init 프로세스에 SIGKILL 가능 (NS 전체 종료)
 *
 * 3. 네임스페이스 init (PID 1) 보호:
 *    - 자기 NS 내에서: 핸들러 없는 시그널은 무시 (SIGKILL/SIGSTOP 포함)
 *    - 부모 NS에서: SIGKILL/SIGSTOP은 전달됨
 */

/* kernel/signal.c - sig_task_ignored() */
static bool sig_task_ignored(struct task_struct *t, int sig,
                              bool force)
{
    /* 네임스페이스 init 프로세스 보호 */
    if (is_global_init(t) || is_child_reaper(task_pid(t)))
        if (!force && sig_handler_ignored(handler, sig))
            return true;
    return false;
}

seccomp과 시그널

/* seccomp 필터는 시스템 콜을 검사하여 시그널을 발생시킬 수 있습니다 */

/* seccomp 동작 중 시그널 관련 */
/* SECCOMP_RET_KILL_THREAD → SIGSYS 전달 (스레드 종료) */
/* SECCOMP_RET_KILL_PROCESS → SIGSYS 전달 (프로세스 종료) */
/* SECCOMP_RET_TRAP → SIGSYS 전달 (핸들러에서 처리 가능) */

/* seccomp과 시그널 시스템 콜 필터링 예시 */
/* kill(), tgkill(), rt_sigaction() 등을 필터링하여
 * 컨테이너 내 프로세스가 보낼 수 있는 시그널을 제한 가능 */

/* SIGSYS 핸들러에서 seccomp 위반 정보 확인 */
void sigsys_handler(int sig, siginfo_t *info, void *ucontext)
{
    /* info->si_syscall: 거부된 시스템 콜 번호 */
    /* info->si_arch: 아키텍처 (AUDIT_ARCH_X86_64 등) */
    fprintf(stderr, "Blocked syscall %d\n", info->si_syscall);
}

시그널 기반 공격 벡터: (1) SROP: 앞서 설명한 sigreturn 악용. (2) 시그널 race condition: 시그널 핸들러 내에서 non-reentrant 함수 호출로 인한 취약점. (3) TOCTOU: 시그널 핸들러가 공유 상태를 수정하는 동안 메인 코드에서 같은 상태를 읽는 race. async-signal-safe 함수만 핸들러 내에서 호출하는 것이 근본적 대응입니다.

디버깅

시그널 관련 문제는 비동기적 특성 때문에 재현과 진단이 어려울 수 있습니다. 다음 도구와 기법을 활용하세요.

strace를 이용한 시그널 추적

# 시그널 관련 시스템 콜만 추적
strace -e trace=signal -p <PID>

# 출력 예시:
# rt_sigaction(SIGTERM, {sa_handler=0x4011a0, sa_mask=[], sa_flags=SA_RESTORER|SA_SIGINFO, sa_restorer=0x7f...}, ...) = 0
# rt_sigprocmask(SIG_BLOCK, [INT], [], 8) = 0
# --- SIGTERM {si_signo=SIGTERM, si_code=SI_USER, si_pid=1234, si_uid=1000} ---
# rt_sigreturn({mask=[]}) = 0

# 시그널 전달 과정 상세 추적
strace -e trace=signal -e signal=all -f -p <PID>

# -f: fork된 자식도 추적
# -e signal=all: 모든 시그널 표시
# 특정 시그널만: -e signal=SIGTERM,SIGCHLD

proc 파일시스템으로 시그널 상태 확인

# /proc/<pid>/status에서 시그널 정보 확인
cat /proc/1234/status | grep -i sig

# SigQ:   1/128206     ← 현재 큐잉된 시그널 수 / 최대 제한
# SigPnd: 0000000000000000  ← 스레드별 pending 시그널 (비트마스크)
# ShdPnd: 0000000000004000  ← 프로세스 shared pending (비트 14 = SIGALRM)
# SigBlk: 0000000000010000  ← 블록된 시그널 (비트 16 = SIGSTKFLT)
# SigIgn: 0000000000000004  ← 무시된 시그널 (비트 2 = SIGQUIT)
# SigCgt: 0000000180004002  ← 핸들러 설치된 시그널

# 비트마스크 해석: 비트 N은 시그널 N을 의미
# 예: 0x4000 = 비트 14 = SIGALRM

# 대체 시그널 스택 정보
cat /proc/1234/status | grep SigAlt
# (sigaltstack이 설정된 경우 표시)

# 모든 스레드의 시그널 상태 확인
ls /proc/1234/task/
# 각 TID 디렉토리의 status를 확인
for tid in /proc/1234/task/*; do
    echo "=== $(basename $tid) ==="
    grep Sig "$tid/status"
done

ftrace로 시그널 추적

# 시그널 생성(generate) 이벤트 활성화
echo 1 > /sys/kernel/debug/tracing/events/signal/signal_generate/enable

# 시그널 전달(deliver) 이벤트 활성화
echo 1 > /sys/kernel/debug/tracing/events/signal/signal_deliver/enable

# 추적 결과 확인
cat /sys/kernel/debug/tracing/trace

# 출력 예시:
# bash-1234  [002] signal_generate: sig=15 errno=0 code=0 comm=myapp pid=5678
# myapp-5678 [001] signal_deliver:  sig=15 errno=0 code=0 sa_handler=4011a0 sa_flags=14000004

# 특정 프로세스만 필터링
echo 'common_pid == 5678' > /sys/kernel/debug/tracing/events/signal/signal_generate/filter
echo 'common_pid == 5678' > /sys/kernel/debug/tracing/events/signal/signal_deliver/filter

# 추적 비활성화
echo 0 > /sys/kernel/debug/tracing/events/signal/enable

시그널 관련 일반적인 문제와 해결

증상원인해결
kill이 안 먹힘시그널이 블록됨 (SigBlk 확인)SIGKILL 사용 (블록 불가)
좀비 프로세스부모가 SIGCHLD를 처리 안 함SA_NOCLDWAIT 또는 waitpid()
SIGPIPE로 서버 종료클라이언트 연결 끊김 후 writesignal(SIGPIPE, SIG_IGN)
EINTR 오류 반복시스템 콜이 시그널로 중단SA_RESTART 또는 재시도 루프
핸들러 내 데드락핸들러에서 non-reentrant 함수async-signal-safe 함수만 사용
SIGSEGV 무한 루프SIGSEGV 핸들러가 원인을 해결 못함핸들러에서 _exit() 또는 longjmp
멀티스레드 시그널 누락잘못된 스레드가 시그널 수신전용 시그널 스레드 패턴 사용
💡

async-signal-safe 함수 목록: POSIX에서 시그널 핸들러 내에서 안전하게 호출할 수 있는 함수는 제한되어 있습니다. write(), _exit(), signal(), sigaction() 등은 안전하지만, printf(), malloc(), pthread_mutex_lock() 등은 안전하지 않습니다. 전체 목록은 man 7 signal-safety를 참조하세요.

sigaltstack을 이용한 스택 오버플로 디버깅

/* 대체 시그널 스택 설정 - 스택 오버플로 시 SIGSEGV를 처리 */
#include <signal.h>
#include <stdlib.h>

void setup_alt_stack(void)
{
    stack_t ss;

    /* MINSIGSTKSZ: 최소 시그널 스택 크기 (보통 2KB) */
    /* SIGSTKSZ: 권장 시그널 스택 크기 (보통 8KB) */
    ss.ss_sp = malloc(SIGSTKSZ);
    ss.ss_size = SIGSTKSZ;
    ss.ss_flags = 0;
    sigaltstack(&ss, NULL);

    /* SIGSEGV 핸들러를 대체 스택에서 실행 */
    struct sigaction sa;
    sa.sa_sigaction = segv_handler;
    sa.sa_flags = SA_SIGINFO | SA_ONSTACK;  /* SA_ONSTACK 필수 */
    sigemptyset(&sa.sa_mask);
    sigaction(SIGSEGV, &sa, NULL);
}

void segv_handler(int sig, siginfo_t *info, void *ucontext)
{
    /* info->si_addr: 폴트가 발생한 주소 */
    /* 스택 오버플로: si_addr이 스택 근처 */
    /* 일반 SEGV: si_addr이 접근한 잘못된 주소 */

    /* 에러 메시지 출력 (write는 async-signal-safe) */
    const char msg[] = "Stack overflow detected!\n";
    write(STDERR_FILENO, msg, sizeof(msg) - 1);
    _exit(1);
}