시그널 처리 심화 (Signal Handling)
Linux 커널의 시그널 메커니즘을 심층적으로 분석합니다. 시그널 자료구조, 전송/전달 경로, signal frame과 sigreturn, 실시간 시그널, signalfd, 스레드 시그널 모델, 커널 내부 사용, 보안 측면, 디버깅 기법까지 포괄적으로 다룹니다.
SIGKILL은 "강제 퇴거 통보"처럼 거부할 수 없고,
SIGTERM은 "점잖은 퇴실 요청"으로 무시하거나 정리 후 나갈 수 있습니다.
시그널 마스크는 "방해 금지 모드"에 해당합니다.
핵심 요약
- 시그널 — 프로세스에 비동기 이벤트를 알리는 소프트웨어 인터럽트입니다.
- SIGKILL(9) / SIGTERM(15) — 프로세스 종료 시그널. SIGKILL은 무시·차단 불가합니다.
- sigaction() — 시그널 핸들러를 등록하는 시스템 콜. 기본 동작을 사용자 정의 함수로 교체합니다.
- 시그널 마스크 —
sigprocmask()로 특정 시그널을 일시적으로 차단(블록)할 수 있습니다. - 실시간 시그널 — SIGRTMIN~SIGRTMAX. 큐잉되고 순서가 보장되는 확장 시그널입니다.
단계별 이해
- 시그널 보내기 —
kill -SIGTERM <pid>로 프로세스에 시그널을 보냅니다.Ctrl+C는 SIGINT, Ctrl+Z는 SIGTSTP을 현재 포그라운드 프로세스에 보냅니다.
- 시그널 받기 — 커널은 시스템 콜에서 복귀할 때 보류(pending) 시그널을 확인하고 전달합니다.
핸들러가 등록되어 있으면 사용자 공간에서 핸들러 함수를 실행합니다.
- 핸들러 작성 —
sigaction()으로 핸들러를 등록합니다. 핸들러 내에서는 async-signal-safe 함수만 사용해야 합니다.signal()보다sigaction()이 이식성과 안정성이 높습니다. - 디버깅 —
strace -e signal ./app으로 시그널 전달을 추적할 수 있습니다./proc/<pid>/status에서 SigPnd, SigBlk, SigCgt 필드로 시그널 상태를 확인합니다.
시그널 개요
시그널(signal)은 프로세스에 비동기 이벤트를 알리는 소프트웨어 인터럽트입니다. 커널, 다른 프로세스, 또는 프로세스 자신이 시그널을 보낼 수 있으며, 수신 프로세스는 기본 동작(종료, 코어 덤프, 중지 등)을 따르거나, 사용자 정의 핸들러를 설치하거나, 시그널을 무시할 수 있습니다.
시그널의 역할
- 프로세스 제어: 종료(SIGTERM/SIGKILL), 중지(SIGSTOP/SIGTSTP), 재개(SIGCONT)
- 예외 통보: 세그폴트(SIGSEGV), 불법 명령(SIGILL), 부동소수점 오류(SIGFPE)
- I/O 이벤트: 파이프 깨짐(SIGPIPE), 비동기 I/O(SIGIO), 긴급 데이터(SIGURG)
- 타이머/알람: 알람(SIGALRM), 가상 타이머(SIGVTALRM), 프로파일링(SIGPROF)
- 사용자 정의: SIGUSR1, SIGUSR2, 실시간 시그널(SIGRTMIN~SIGRTMAX)
표준 시그널 목록
| 번호 | 시그널 | 기본 동작 | 설명 |
|---|---|---|---|
| 1 | SIGHUP | 종료 | 제어 터미널 끊김, 데몬 설정 재로드 관례 |
| 2 | SIGINT | 종료 | Ctrl+C, 포그라운드 프로세스 인터럽트 |
| 3 | SIGQUIT | 코어 덤프 | Ctrl+\, 종료 + 코어 덤프 생성 |
| 6 | SIGABRT | 코어 덤프 | abort() 호출 |
| 9 | SIGKILL | 종료 | 무조건 종료, 핸들러 설치/블록 불가 |
| 11 | SIGSEGV | 코어 덤프 | 잘못된 메모리 접근 (segmentation fault) |
| 13 | SIGPIPE | 종료 | 읽는 쪽이 닫힌 파이프/소켓에 쓰기 |
| 14 | SIGALRM | 종료 | alarm() 타이머 만료 |
| 15 | SIGTERM | 종료 | 정상 종료 요청 (기본 kill 시그널) |
| 17 | SIGCHLD | 무시 | 자식 프로세스 상태 변경 |
| 19 | SIGSTOP | 중지 | 프로세스 중지, 핸들러 설치/블록 불가 |
| 20 | SIGTSTP | 중지 | Ctrl+Z, 터미널 중지 |
| 18 | SIGCONT | 재개 | 중지된 프로세스 재개 |
시그널 번호는 아키텍처마다 다릅니다. 위 표는 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 플래그를 확인하고 시그널을 처리합니다.
전달 경로 흐름
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_SIGINFO | 3인자 핸들러 사용: void handler(int sig, siginfo_t *info, void *ucontext) |
SA_RESTART | 시그널로 인터럽트된 시스템 콜을 자동 재시작 |
SA_NODEFER | 핸들러 실행 중 같은 시그널을 블록하지 않음 (재진입 허용) |
SA_RESETHAND | 핸들러 실행 후 SIG_DFL로 리셋 (일회성) |
SA_ONSTACK | sigaltstack으로 설정한 대체 스택에서 핸들러 실행 |
SA_NOCLDSTOP | 자식이 중지될 때 SIGCHLD를 받지 않음 |
SA_NOCLDWAIT | 자식이 종료 시 좀비 생성 안 함 (자동 reap) |
SA_RESTORER | sa_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_code가 SI_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로 서버 종료 | 클라이언트 연결 끊김 후 write | signal(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);
}