1. 시그널의 생성
1.1 시그널 개념
우리 주변에는 신호등, 발사 총, 수업 종 등 많은 시그널이 존재합니다.
시그널을 수신하면 세 가지 동작을 할 수 있습니다: 1. 즉시 해당 시그널에 대응한다 2. 자신의 일이나 더 중요한 일이 있을 경우 나중에 처리한다 3. 무시하고 아무것도 하지 않는다
시그널은 프로세스에 전송됩니다(예: kill -9 pid).
프로세스는 프로그래머가 작성한 속성과 로직의 집합입니다: 따라서 프로세스는 시그널을 인식하고 인지하는 기능을 갖습니다.
시그널에 대해 프로세스는 반드시 시그널을 저장할 수 있는 능력을 가져야 하며, 이는 사람이 기억에 저장하는 것과 유사합니다.
이로부터 시그널에 대해 세 가지 반응을 할 수 있음을 알 수 있습니다:
1. 기본 동작 2. 무시 동작 3. 사용자 정의 동작
- 프로세스가 수신한 시그널은 어디에 저장되나요?
아래 그림에서 볼 수 있듯이, 시그널은 1-31번(일반 시그널)과 34-64번(실시간 시그널)로 구성됩니다.

프로세스가 시그널을 수신할 때는 오직 두 가지 가능성만 있습니다: 시그널을 수신했는지 여부이므로 0과 1로 표현할 수 있습니다.
자세히 살펴보면 시그널은 64개이므로 비트맵 형태로 저장할 수 있습니다.
프로세스가 수신한 시그널은 PCB의 변수에 저장되며, 비트를 사용하여 몇 번째 시그널인지 나타냅니다.
struct task_struct {
unsigned int signal; // 비트맵으로 시그널 표현
};
프로세스에 시그널을 보내려면 반드시 시스템 호출을 거쳐야 하므로, kill 명령어 등은 운영체제가 제공하는 인터페이스입니다.
1.2 시그널 생성 방식
-
-
키보드를 통한 전송
프로세스 실행 중
Ctrl+C를 누르면 현재 프로세스가 종료됩니다. 이 단축키는 운영체제가 인식한 후 해당 프로세스에2번 시그널(SIGINT)을 보내 종료시킵니다.
-
-
- 시스템 호출을 통한 시그널 전송
int kill(pid_t pid, int sig);헤더 파일:
#include <sys/types.h> #include <signal.h>기능: 대상 프로세스 pid에 sig 번호 시그널을 전송합니다.
직접 kill 명령어를 구현할 수 있습니다. 예시:
#include <iostream> #include <cstdlib> #include <string> #include <unistd.h> #include <sys/types.h> #include <signal.h> void Usage(const std::string& prog) { std::cout << "\n사용법: " << prog << " pid 시그널" << std::endl; } int main(int argc, char* argv[]) { if (argc != 3) { Usage(argv[0]); exit(EXIT_FAILURE); } int targetPid = std::atoi(argv[1]); // 종료할 프로세스의 PID int sigNum = std::atoi(argv[2]); // 전송할 시그널 번호 int result = kill(targetPid, sigNum); if (result == -1) { perror("kill 실패"); exit(EXIT_FAILURE); } return 0; } -
- 하드웨어에 의한 시그널 생성
(1) 0으로 나누기
코드에서 0으로 나누면 하드웨어 수준에서 계산 오버플로가 발생하여 CPU 예외가 생기고, 프로세스에 예외 시그널이 전송됩니다.
0으로 나누면 운영체제는 Floating point exception을 발생시킵니다.
man 7 signal에서 확인하면: SIGFPE (8번, Core, Floating point exception) 입니다.(2) 잘못된 포인터 접근
유효하지 않은 포인터에 접근하면 프로세스 주소 공간에서 잘못된 포인터 접근 예외가 발생합니다. 프로세스 주소 공간은 페이지 테이블을 통해 물리 메모리에 매핑되며, MMU(메모리 관리 유닛)가 포함됩니다. 예외가 발생하면 프로세스에 시그널이 전송됩니다.
잘못된 포인터나 경계 초과 오류는 Segmentation fault를 발생시킵니다.
man 7 signal: SIGSEGV (11번, Core, Invalid memory reference)이 외에도 여러 예제가 있으므로, 하드웨어도 시그널 생성 원인이 될 수 있습니다.
-
- 소프트웨어에 의한 시그널 생성
(1) 파이프
이전 파이프 학습에서, 파이프의 읽기 끝을 닫았지만 쓰기 끝을 닫지 않으면 시그널이 생성됩니다. 운영체제는 자원 낭비를 허용하지 않으므로, 읽기 끝을 닫고 쓰기 끝을 열어두면 시그널이 생성되어 프로그램이 종료됩니다(대부분의 시그널이 종료를 유발합니다).
(2) 알람
unsigned int alarm(unsigned int seconds);// seconds초 후 프로세스 종료
이 알람은 내부적으로 seconds초 후 프로세스에 14번 시그널을 보내 종료시킵니다.
SIGALRM (14번, Term, Timer signal from alarm(2))이 외에도 여러 예제가 있습니다.
1.3 signal() 함수
signal() 함수:
sighandler_t signal(int signum, sighandler_t handler);
두 번째 인자는 함수 포인터 타입으로, void (*func)(int) 형태입니다.
typedef void (*func)(int); // 함수의 인자는 int 타입
시그널 처리 기능을 설정합니다.
sig로 지정된 시그널 번호에 대한 처리 방법을 지정합니다. func 인자는 프로그램이 시그널을 처리하는 세 가지 방식 중 하나를 지정합니다.
이것이 사용자 정의 동작으로, 프로세스가 signum 시그널을 만났을 때 수행할 작업을 직접 정의합니다.
예시:
void catchSignal(int signal) {
std::cout << "시그널 캐치: " << signal << std::endl;
sleep(1);
}
int main() {
signal(SIGSEGV, catchSignal); // 11번 시그널 처리
int arr[100];
arr[10000000] = 666; // 잘못된 접근
return 0;
}
signal은 단지 선언일 뿐이며, 원래 num번 시그널을 만나면 시스템 지정 동작을 수행해야 합니다.
signal 함수를 호출한 후에는 해당 시그널을 만날 때 사용자 정의 동작(위의 catchSignal 함수)을 수행합니다.
따라서 동작을 변경한 시그널을 만날 때만 사용자 정의 동작이 실행되며, signal을 선언했다고 바로 실행되지 않습니다. 해당 시그널을 만나지 않으면 사용자 정의 동작이 실행되지 않습니다.
1.4 코어 덤프 문제

위 31개의 일반 시그널 중 Core와 Term 타입 모두 프로세스를 종료시키지만, Core는 코어 덤프를 생성합니다.
클라우드 서버에서 프로세스가 Core로 종료되어도 눈에 띄는 현상이 없는데, 이는 클라우드 서버가 Core 파일 크기를 제한했기 때문입니다.

유효 파일 크기를 변경하려면 명령어 ulimit -c 1024를 사용합니다. 1024는 설정할 파일 크기이며, -c는 코어 파일을 의미합니다.
core file 크기를 설정한 후, Core로 종료되는 프로세스를 다시 만나면 프로세스 디렉토리에 core.xxx 파일이 생성됩니다.

gdb에서 core-file core.xxx를 입력하면 다음 내용이 표시됩니다. 디버그 가능한 실행 파일이어야 합니다.

gdb는 프로세스 예외 원인(이 경우 잘못된 포인터 문제)을 표시합니다.
2. 시그널의 저장
2.1 시그널 차단
시그널 저장을 이해하려면 다음 개념을 도입해야 합니다:
- 시그널 처리 과정을 실제로 실행하는 것을 전달(Delivery)이라고 합니다.
- 시그널이 생성되어 전달되기까지의 상태를 보류(Pending)이라고 합니다.
- 시그널은 차단(Block)될 수 있습니다.
- 차단된 시그널은 보류 상태를 유지하다가 차단이 해제되면 전달 동작이 실행됩니다.
따라서 시그널은 차단될 수 있으며, 특정 시그널을 마스킹할 수 있습니다.
PCB에서 시그널은 비트맵 방식으로 저장되며, 이러한 모든 시그널을 시그널 집합이라고 합니다.
PCB에서 시그널 저장은 세 부분으로 구성됩니다:
- 보류 시그널 집합 (Pending)
- 시그널 마스크 집합 (Block)
- 시그널 집합 연산 함수 테이블 (Handler)

- pending: PCB에 도착한 보류 시그널을 나타냅니다.
- block: 현재 프로세스가 차단한 시그널을 나타냅니다.
- handler: 함수 포인터 배열로, 시그널 처리 방식을 저장합니다. 사용자가 시그널 처리 방식을 정의하면 이 함수 테이블의 특정 위치에 사용자 정의 함수 포인터가 교체됩니다.
차단 시그널이 존재하면 해당 시그널은 전달되지 않습니다.
pending: 00000000000000000000000
block: 00000000000000000000010 전달되지 않음
차단 시그널과 보류 시그널이 모두 존재해도 전달되지 않습니다.
pending: 00000000000000000000010
block: 00000000000000000000010 전달되지 않음
보류 시그널이 존재하고 차단되지 않은 경우에만 전달됩니다.
pending: 00000000000000000000010
block: 00000000000000000000000 전달됨
2.2 시그널 포착
2.2.1 사용자 모드와 커널 모드
시그널 포착 과정을 이해하려면 다음을 먼저 이해해야 합니다:
사용자 모드와 커널 모드
운영체제에서는 메모리와 하드웨어 등의 자원을 운영체제가 관리합니다. 운영체제는 누구도 신뢰하지 않으므로, 시스템 자원을 요청할 때 프로세스는 커널 모드로 전환하여 일련의 작업을 수행합니다.
실제로 시스템 호출을 실행하는 주체는 '프로세스'이지만, 신분은 커널입니다. 시스템 호출은 시간이 많이 소요되므로 가능한 적게 사용하는 것이 좋습니다.
상태 전환 과정은 무엇일까요?
-
이전 프로세스 주소 공간에서 사용자는 0-3G의 주소 공간을 가지고 있으며, 프로세스 주소 공간은 페이지 테이블을 통해 물리 메모리에 매핑되어 CPU와 프로세스 간 상호작용을 구현합니다.
-
운영체제는 부팅 시 메모리에 로드되어 전체 컴퓨터를 관리하며, 따라서 운영체제도 메모리에 존재합니다.
-
각 프로세스에 대해 나머지 3-4G 주소 공간은 커널 모드의 프로세스 주소 공간으로, 커널 공간이라고 합니다. 이 전체 프로세스 주소 공간은 물리 메모리의 운영체제 물리 메모리 영역에 매핑됩니다.
-
각 프로세스의 고정된 3-4G는 모두 커널 공간이며 운영체제는 하나뿐이므로, 각 프로세스는 커널 레벨 페이지 테이블을 통해 하나의 운영체제 물리 메모리 위치에 매핑됩니다. 커널 레벨 페이지 테이블은 하나면 충분합니다.
-
프로세스가 시스템 호출을 할 때, 시스템 호출은 트랩 명령어를 발생시켜 CPU의 CR3 레지스터(현재 프로세스 실행 레벨을 기록, 0은 커널 모드, 3은 사용자 모드)를 커널 모드로 표시합니다. 따라서 사용자 모드에서 시스템 호출을 할 때 신분을 전환하고 커널 공간에서 관련 작업을 실행합니다. 실행 후 명령어를 보내 CR3 레지스터를 다시 사용자 모드로 표시합니다.

2.2.2 시그널 포착 과정
시그널 포착 처리는 커널 모드에서 사용자 모드로 돌아갈 때 수행됩니다.
먼저 반드시 커널 모드로 진입해야 하며, 진입 이유는 다양합니다: 인터럽트, 시스템 호출, 프로세스 전환 등.
사용자 모드로 돌아가려 할 때, 커널 모드 진입이 쉽지 않으므로 프로세스가 시그널을 수신했는지 확인합니다. 커널 모드이므로 PCB에 쉽게 접근할 수 있으며, 전달해야 할 시그널이 있는지 검사합니다.
시그널 처리에는 세 가지 방식이 있습니다: 1. 기본 동작 2. 무시 동작 3. 사용자 정의 동작
처리 방식에 따라 다른 과정이 있습니다.
- 기본 동작과 무시 동작:

- 사용자 정의 동작(프로세스를 종료하지 않고 취약점을 자동으로 패치하고 계속 실행하는 경우):

3. 시그널 집합 연산 함수
pending과 block은 모두 시그널 집합으로 구성되며, 비트맵 방식으로 sigset_t 타입입니다.
typedef struct {
unsigned long sig[_NSIG_WORDS];
} sigset_t;
따라서 시그널 집합을 정의하려면 sigset_t block;과 같이 선언합니다.
시그널 집합 관련 함수:
#include <signal.h>
int sigemptyset(sigset_t *set); // 시그널 집합 초기화, 모두 0으로 설정
int sigfillset(sigset_t *set); // 시그널 집합 초기화, 모든 시그널 포함(모두 1)
int sigaddset(sigset_t *set, int signo); // 시그널 집합에 signo 시그널 추가(해당 비트를 1로 설정)
int sigdelset(sigset_t *set, int signo); // 시그널 집합에서 signo 시그널 제거(해당 비트를 0으로 설정)
int sigismember(const sigset_t *set, int signo); // signo 시그널이 집합에 있는지 확인(있으면 1, 없으면 0 반환)
sigprocmask 함수:
sigprocmask 함수를 호출하면 현재 프로세스의 차단 시그널 집합을 읽거나 변경할 수 있습니다.
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
매개변수:
- how: 수행할 작업 유형
1. SIG_BLOCK: 차단할 시그널을 추가합니다. 즉, mask = mask | set
2. SIG_UNBLOCK: 차단 해제할 시그널을 설정합니다. 즉, mask = mask & ~set
3. SIG_SETMASK: 현재 프로세스의 차단 집합을 set이 가리키는 값으로 설정합니다.
- set: 입력 매개변수, how에 따라 함수에 전달할 시그널 집합
- oset: 출력 매개변수, 변경 전의 시그널 집합을 oset이 가리키는 위치에 저장
sigpending 함수: 현재 프로세스의 보류 시그널 집합을 읽어 set 매개변수를 통해 반환합니다.
#include <signal.h>
int sigpending(sigset_t *set);
// 현재 프로세스의 보류 시그널 집합을 읽어 set에 저장. 성공 시 0, 실패 시 -1 반환.
다음은 위 함수들을 사용한 실험 코드입니다:
#include <iostream>
#include <vector>
#include <string>
#include <unistd.h>
#include <signal.h>
using namespace std;
vector<int> blockSignals = {2}; // 차단할 시그널 번호를 배열에 추가
string printPending(sigset_t& pending) {
string result;
int count = 1;
for (int i = 31; i >= 1; i--) {
if (sigismember(&pending, i)) {
result += '1';
} else {
result += '0';
}
if (count++ % 4 == 0) result += ' ';
}
return result;
}
void signalHandler(int signum) {
cout << "시그널 포착: " << signum << endl;
}
int main() {
sigset_t blockSet, oldBlockSet, pendingSet;
sigemptyset(&blockSet);
sigemptyset(&oldBlockSet);
sigemptyset(&pendingSet);
for (const auto& sig : blockSignals) {
sigaddset(&blockSet, sig);
}
sigprocmask(SIG_SETMASK, &blockSet, &oldBlockSet);
int counter = 1;
for (const auto& sig : blockSignals) {
signal(sig, signalHandler);
}
while (true) {
sigpending(&pendingSet);
cout << printPending(pendingSet) << endl;
sleep(1);
if (counter++ == 5) {
sigprocmask(SIG_SETMASK, &oldBlockSet, &blockSet);
}
}
return 0;
}