QUESTION
Does a hardware interrupt immediately become a userspace signal or epoll event?
An interrupt is a change of execution context in which the CPU transfers the current flow to a kernel interrupt handler. An event is a broad term for the fact that state changed; Linux has no single universal object named event. A wait-queue wakeup, poll readiness, an eventfd counter, a completion, and an input event all have subsystem-specific storage formats and consumption rules.
A signal is an asynchronous notification protocol targeting a task or thread group. Signals can be generated without an IRQ by the kill syscall, a page fault, timer expiration, a terminal control character, or child termination. Conversely, a device IRQ may end entirely inside a driver. A common path has an IRQ put data in a ring buffer and wake a wait queue, after which userspace observes it through read or epoll; no signal is involved.
STRUCTURE
Structure diagram
do_send_sig_info()
force_sig_fault()
posix_timer_event()
n_tty_receive_signal_char()
do_notify_parent()
kill_fasync()
A signal is not another name for a hardware IRQ. Signals originate in different contexts such as syscalls, synchronous CPU exceptions, timers, the terminal line discipline, process lifecycle, and fasync, then follow shared enqueue and delivery rules.
CALL PATH
Call path
An interrupt is a cause of kernel entry that executes code, an event is an observable state change, and a signal is a delivery protocol that leaves pending state on a task. They can be connected, but they are not automatically converted one-to-one.
This diagram is not for memorizing function names. Follow which return value, file descriptor, memory mapping, or wait queue is passed to the next stage.
SOURCE COORDINATES
Linux 6.18.37 LTS source locations
Go beyond the glibc function to the files where the syscall implementation meets kernel objects. Each link points to the original file at the same tag.
| File | Function / structure | What to inspect |
|---|---|---|
| kernel/irq/handle.c | handle_irq_event(), handle_irq_event_percpu() | The common path that calls an irqaction handler in hardirq context and passes IRQ_WAKE_THREAD to a threaded handler |
| kernel/softirq.c | irq_exit_rcu(), invoke_softirq(), __do_softirq() | The context boundary that runs pending softirqs after hardirq exit or hands them to ksoftirqd |
| kernel/sched/wait.c | __wake_up_common(), __wake_up_common_lock() | The common wakeup path through which a producer makes tasks on a wait queue runnable |
| fs/eventpoll.c | ep_poll_callback(), ep_send_events() | The path connecting a target file's wait-queue wakeup to an epoll ready list and a userspace events array |
| kernel/signal.c | kill_something_info(), do_send_sig_info(), group_send_sig_info() | The starting point where a kill-family syscall checks PID type and permission before creating a process-directed signal |
| kernel/signal.c | __send_signal_locked(), complete_signal(), get_signal() | The central path where several producers converge on shared pending bitmaps/queues and select a signal eligible for delivery |
| kernel/signal.c | force_sig_fault(), force_sig_info_to_task() | The path that converts a synchronous exception such as a page fault or illegal instruction into a signal carrying si_addr and si_code |
| kernel/time/posix-timers.c | posix_timer_fn(), posix_timer_event() | The starting point where POSIX timer expiration enters the signal queue according to a SIGEV_SIGNAL setting |
| drivers/tty/n_tty.c | n_tty_receive_signal_char(), __isig() | The line-discipline path that converts VINTR, VQUIT, and VSUSP into SIGINT, SIGQUIT, and SIGTSTP for the foreground process group |
| kernel/signal.c | do_notify_parent() | The process-lifecycle path that reports child exit/stop/continue state to the parent through SIGCHLD and wait status |
| fs/fcntl.c | kill_fasync(), kill_fasync_rcu() | The path through which a driver/file using O_ASYNC and fasync registration sends SIGIO or a selected signal |
| fs/signalfd.c | signalfd_read(), signalfd_poll() | The path that consumes already-generated pending signals as fd records and readiness instead of through a handler |
| arch/x86/kernel/signal.c | arch_do_signal_or_restart(), setup_rt_frame() | The path that saves register context and the signal mask in an rt_sigframe immediately before return to userspace and establishes the handler entry point |
COMPLETE PROGRAM
Complete runnable example
The code below is not pseudocode with explanatory lines omitted. It is a minimal example that can be built and run as a file.
cc -std=c17 -Wall -Wextra -O2 -pthread event_sources.c -o event_sources01#define _GNU_SOURCE
02#include <errno.h>
03#include <pthread.h>
04#include <signal.h>
05#include <stdint.h>
06#include <stdio.h>
07#include <sys/epoll.h>
08#include <sys/eventfd.h>
09#include <sys/signalfd.h>
10#include <sys/timerfd.h>
11#include <sys/wait.h>
12#include <time.h>
13#include <unistd.h>
14
15enum source_tag {
16 SOURCE_SIGNAL = 1,
17 SOURCE_TIMER,
18 SOURCE_EVENTFD
19};
20
21enum seen_bit {
22 SEEN_USR1 = 1U << 0,
23 SEEN_CHLD = 1U << 1,
24 SEEN_TIMER = 1U << 2,
25 SEEN_EVENT = 1U << 3
26};
27
28static int add_source(int epfd, int fd, uint32_t tag)
29{
30 struct epoll_event event = {
31 .events = EPOLLIN,
32 .data.u32 = tag
33 };
34 return epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event);
35}
36
37static void *worker(void *argument)
38{
39 int event_fd = *(int *)argument;
40 struct timespec delay = { .tv_sec = 0, .tv_nsec = 150000000 };
41 uint64_t credit = 1;
42
43 nanosleep(&delay, NULL);
44 if (write(event_fd, &credit, sizeof(credit)) != sizeof(credit))
45 return (void *)1;
46 return NULL;
47}
48
49int main(void)
50{
51 sigset_t mask;
52 sigemptyset(&mask);
53 sigaddset(&mask, SIGUSR1);
54 sigaddset(&mask, SIGCHLD);
55 if (pthread_sigmask(SIG_BLOCK, &mask, NULL) != 0)
56 return 1;
57
58 int signal_fd = signalfd(-1, &mask, SFD_CLOEXEC | SFD_NONBLOCK);
59 int timer_fd = timerfd_create(CLOCK_MONOTONIC,
60 TFD_CLOEXEC | TFD_NONBLOCK);
61 int event_fd = eventfd(0, EFD_CLOEXEC | EFD_NONBLOCK);
62 int epfd = epoll_create1(EPOLL_CLOEXEC);
63 if (signal_fd < 0 || timer_fd < 0 || event_fd < 0 || epfd < 0)
64 return 1;
65
66 struct itimerspec timer = {
67 .it_value = { .tv_sec = 0, .tv_nsec = 250000000 }
68 };
69 if (timerfd_settime(timer_fd, 0, &timer, NULL) < 0 ||
70 add_source(epfd, signal_fd, SOURCE_SIGNAL) < 0 ||
71 add_source(epfd, timer_fd, SOURCE_TIMER) < 0 ||
72 add_source(epfd, event_fd, SOURCE_EVENTFD) < 0)
73 return 1;
74
75 pid_t child = fork();
76 if (child < 0)
77 return 1;
78 if (child == 0)
79 _exit(42);
80
81 pthread_t thread;
82 if (pthread_create(&thread, NULL, worker, &event_fd) != 0)
83 return 1;
84 if (kill(getpid(), SIGUSR1) < 0)
85 return 1;
86
87 unsigned int seen = 0;
88 while (seen != (SEEN_USR1 | SEEN_CHLD | SEEN_TIMER | SEEN_EVENT)) {
89 struct epoll_event events[4];
90 int count = epoll_wait(epfd, events, 4, -1);
91 if (count < 0 && errno == EINTR)
92 continue;
93 if (count < 0)
94 return 1;
95
96 for (int i = 0; i < count; ++i) {
97 if (events[i].data.u32 == SOURCE_SIGNAL) {
98 for (;;) {
99 struct signalfd_siginfo info;
100 ssize_t n = read(signal_fd, &info, sizeof(info));
101 if (n == (ssize_t)sizeof(info)) {
102 printf("signal: signo=%u code=%d sender=%u\n",
103 info.ssi_signo, info.ssi_code, info.ssi_pid);
104 if (info.ssi_signo == SIGUSR1)
105 seen |= SEEN_USR1;
106 if (info.ssi_signo == SIGCHLD) {
107 int status;
108 if (waitpid(child, &status, 0) == child)
109 printf("child: exit=%d\n", WEXITSTATUS(status));
110 seen |= SEEN_CHLD;
111 }
112 continue;
113 }
114 if (n < 0 && errno == EAGAIN)
115 break;
116 return 1;
117 }
118 } else if (events[i].data.u32 == SOURCE_TIMER) {
119 uint64_t expirations;
120 if (read(timer_fd, &expirations, sizeof(expirations)) !=
121 sizeof(expirations))
122 return 1;
123 printf("timerfd: expirations=%llu\n",
124 (unsigned long long)expirations);
125 seen |= SEEN_TIMER;
126 } else if (events[i].data.u32 == SOURCE_EVENTFD) {
127 uint64_t credits;
128 if (read(event_fd, &credits, sizeof(credits)) != sizeof(credits))
129 return 1;
130 printf("eventfd: credits=%llu\n",
131 (unsigned long long)credits);
132 seen |= SEEN_EVENT;
133 }
134 }
135 }
136
137 void *worker_result;
138 pthread_join(thread, &worker_result);
139 close(signal_fd);
140 close(timer_fd);
141 close(event_fd);
142 close(epfd);
143 return worker_result != NULL;
144}
CODE NOTES
Code notes
pthread_sigmask(SIG_BLOCKBlocks SIGUSR1 and SIGCHLD before creating the fd. New threads inherit the mask, closing the path where an asynchronous handler runs unexpectedly in any thread and allowing signalfd to consume pending signals.
signalfd(-1, &masksignalfd does not generate new signals. When a signal in its mask enters task or thread-group pending state, it exposes the signal as a readable struct signalfd_siginfo record.
timerfd_create(CLOCK_MONOTONICRepresents expiration as a file containing an 8-byte expiration counter instead of a signal. CLOCK_MONOTONIC is used for elapsed time to avoid wall-clock adjustments.
eventfd(0, EFD_CLOEXECCreates a credit counter with which the worker wakes the main loop. Do not equate one write with one epoll event; inspect the accumulated uint64_t value returned by read.
epoll_ctl(epfd, EPOLL_CTL_ADDRegisters all three fds in one interest list. epoll does not unify the causes of signal, timer, and thread events; it only gathers poll readiness for their files into one array.
write(event_fd, &creditThe worker increments the eventfd counter from userspace process context. This demonstrates that a complete fd event and wakeup can be created without a hardware interrupt.
pid_t child = fork()The child leaves termination status in _exit. The kernel's parent-notification path generates SIGCHLD, and the zombie is released when the parent collects the status with waitpid.
kill(getpid(), SIGUSR1)A purely software signal that begins at the kill syscall. Permission checks can create process-directed pending state without a device IRQ or timer.
epoll_wait(epfd, eventsWhile the main thread is not runnable, it sleeps on the epoll wait queue. A producer's wakeup does not immediately call a userspace callback; it makes the task runnable, and the scheduler decides when it actually executes.
read(signal_fd, &infoA signal fd is a stream of fixed-size records. Because it is nonblocking, drain pending records until EAGAIN after one EPOLLIN.
info.ssi_signo == SIGCHLDA signalfd record is notification, not the reclamation of child resources. After reading SIGCHLD, waitpid or waitid must still collect the child exit status.
read(timer_fd, &expirationsA timerfd read returns the number of expirations accumulated since the previous read. The value can exceed 1 when the event loop is late.
read(event_fd, &creditsA normal eventfd read returns the entire current counter and resets it to 0. EFD_SEMAPHORE applies a different rule that consumes 1 per read.
pthread_join(threadObserving an event and ending the producer thread's lifetime are separate operations. Reclaim thread resources with join, then close the shared fd.
DETAILS
Detailed behavior
First separate interrupts, exceptions, events, and signals
A hardware interrupt is an asynchronous request made by a device independently of the CPU's current instruction flow. A CPU exception is a synchronous event tied to execution of the current instruction; page faults, divide errors, and invalid opcodes are examples. Linux documentation and code may group both broadly under interrupt/exception entry, but their causes and restart rules differ.
Event is a general name for a state change, not an ABI name. An epoll event is a readiness snapshot, an inotify event is a variable-length queued record, eventfd is a uint64 counter, and a completion is a kernel synchronization object. Sharing the word event does not make loss, ordering, and accumulation rules identical.
A signal is a task-notification protocol with a number, disposition, mask, pending state, siginfo, and default action. An interrupt-context routine is not called a signal handler, and a userspace signal handler does not run in hardirq context.
The path from a device IRQ to a userspace read crosses several boundaries
When a device completes a DMA descriptor and raises an interrupt line or MSI, the CPU passes through architecture entry code to find irq_desc and irqaction. The primary handler reads device status, ACKs or masks the cause, and preserves the minimum state required for later processing.
Work that may take longer, such as packet parsing or follow-up after block completion, is deferred to a softirq, NAPI, threaded IRQ, tasklet, workqueue, or similar mechanism. The choice depends on the subsystem and driver's locking, ability to sleep, and latency goals.
Finally, the driver publishes a ring index, counter, or file state and wakes a wait queue, making a blocked task runnable. Data ownership actually moves only after the scheduler selects the task, epoll_wait returns, and userspace calls read. wake_up itself does not invoke a userspace function.
IRQs and events are not one-to-one
One interrupt can process several descriptor completions, and interrupt coalescing combines several packets behind one IRQ. Conversely, a level-triggered interrupt can be observed repeatedly until its cause is removed. Do not interpret IRQ count as I/O-operation count.
poll/epoll readiness is not an event count either. Readable means that read can currently make progress or discover EOF/error. Even if a producer fills a buffer several times, the fd can remain in one ready state; an edge-triggered loop must drain to EAGAIN before it can receive another edge.
APIs with explicit counters, such as eventfd and timerfd, retain accumulated values, but still have counter width, overflow, and read-mode rules. Decide first whether events may be lost, counts are required, or only the latest state matters.
Read a signal in four stages: generation, pending, selection, and delivery
Generation is when a producer chooses the signal number and siginfo and identifies the target. The signal is then recorded in the task or shared-signal pending bitmap/queue. If a standard signal of the same number is already pending, new occurrences can coalesce; realtime signals can queue siginfo records in order.
complete_signal finds a candidate receiving thread and marks signal work through TIF_SIGPENDING and related state. As the target thread prepares to return to userspace, get_signal consults the mask and disposition and selects the signal that will actually be handled.
During handler delivery, architecture code creates an rt_sigframe on the user stack or altstack, records saved registers, the old mask, and siginfo/ucontext, then changes the instruction pointer to the handler. After the handler ends, rt_sigreturn validates the frame and restores context. This is why generation time and handler-instruction execution time differ.
kill, tgkill, and pidfd_send_signal begin with target selection
Depending on the sign of pid, kill(pid, sig) can target one process, the caller's process group, a specified process group, or multiple permitted processes. The kernel resolves the PID in a namespace, checks permission according to credentials and signal type, then enters the group_send_sig_info family.
tgkill(tgid, tid, sig) validates both the thread group and thread ID and creates thread-directed pending state. pthread_kill is a libc interface over this syscall family. A process-directed signal in a multithreaded program can be received by any thread that does not block it, so distinguish the target semantics.
pidfd_send_signal targets the process instance referenced by a pidfd, reducing numeric-PID reuse problems. Whichever API is used, the same mask and disposition rules apply after the common signal-enqueue path.
Page faults and illegal instructions create synchronous signals
When a userspace instruction accesses an invalid address, a CPU page-fault exception enters the kernel. If the kernel resolves it as normal demand paging, COW, or stack growth, execution returns to the same instruction and no signal is generated.
If no VMA exists or permissions do not match and the fault cannot be resolved, architecture fault code calls the force_sig_fault family to record SIGSEGV with SEGV_MAPERR or SEGV_ACCERR and the fault address. Other exception types generate corresponding signals, such as SIGILL for an invalid opcode and SIGFPE for a divide error.
This signal targets the current faulting thread, so its cause differs from a normal kill. If a handler returns without correcting the cause, the same instruction may fault again; the default action can lead to a core dump and process termination.
A timer can be a signal or an fd event, depending on its configured notification method
When a POSIX timer uses SIGEV_SIGNAL, its hrtimer callback updates expiration state and overrun information and sends a notification to the signal queue. If several expirations occur while the signal is pending, interpret the timer overrun value as well.
setitimer and alarm also generate SIGALRM-family signals on expiration, but standard-signal coalescing makes them unsuitable as expiration counters. timerfd increments a file counter and wakes a poll wait queue instead of creating signal-pending state.
Even with the same clock source and hrtimer foundation, choosing a signal or fd userspace ABI changes masks, queues, event-loop integration, and consumption. Equating a timer interrupt directly with SIGALRM skips the timer subsystem and the notification choice in between.
Ctrl-C does not turn directly from a keyboard IRQ into SIGINT
Input bytes from a keyboard or terminal emulator enter the tty driver, where the line discipline applies termios settings. In canonical n_tty, when ISIG is enabled and a byte matches VINTR, __isig sends SIGINT to the foreground process group.
Consequently, writing a control character to the master side of a pseudoterminal can produce the same tty semantics without a physical keyboard IRQ. Conversely, in raw mode or with ISIG disabled, 0x03 can be read as an ordinary input byte.
The SIGINT target is the terminal foreground process group, not necessarily one process visible on the screen. This is why a shell places a pipeline in one process group and transfers foreground ownership with tcsetpgrp.
SIGCHLD is generated by the process lifecycle and serves a different role from wait
When a child enters exit, stop, or continue state, the kernel checks the parent's SIGCHLD disposition and SA_NOCLDSTOP/SA_NOCLDWAIT settings before notification. siginfo can contain the child PID, UID, a code such as CLD_EXITED, and status.
SIGCHLD notifies the parent of a state change; waitpid/waitid performs the operation that collects exit status and resource usage. A zombie can remain if code only reads SIGCHLD in a handler or signalfd and never waits.
When several children finish at nearly the same time, standard SIGCHLD signals can coalesce, so do not perform exactly one wait per signal. Repeat a WNOHANG loop until no more children can be collected.
SIGIO and fasync are an optional path for delivering file events as signals
When an application sets F_SETOWN and O_ASYNC/FASYNC, a file or driver can register subscribers in a fasync_struct list. On a state change, kill_fasync sends the owner SIGIO or the signal selected with F_SETSIG.
Not every fd supports fasync, and readiness, the signal queue, races, and coalescing interact. Counting every occurrence with standard SIGIO for high-rate I/O may be unsuitable because of loss and handler cost.
The device API and workload determine whether to use epoll, io_uring, blocking read, or SIGIO. The hardware IRQ does not directly fire a signal; rather, the file implementation becomes a signal producer when it chooses an fasync policy.
signalfd does not change the cause that generated a signal
Target signals must be blocked before signalfd is created. Otherwise, normal handler/default-action delivery races with fd consumption. Apply a consistent mask policy in every thread of the process.
When a signal becomes pending, signalfd poll reports readable and read obtains one or more signalfd_siginfo records. SIGUSR1 generated by kill and SIGCHLD generated by the lifecycle appear on the same fd, but ssi_code, sender, and status distinguish their causes.
SIGKILL and SIGSTOP cannot be blocked or consumed through signalfd. Also avoid designs that route every synchronous fault signal exclusively to signalfd and repeatedly execute the faulty instruction.
Allowed work and sources of delay differ by context
hardirq context cannot sleep, and its handler must remain short. A softirq likewise cannot use blocking mutexes or userspace access like an ordinary process; prolonged load affects ksoftirqd scheduling and other IRQ latency. Threaded IRQs and workqueues provide sleepable process context but add scheduling delay.
Time from wake_up to epoll_wait returning includes the runnable queue, CPU affinity, priority, preemption, interrupt masking, and softirq backlog. Time from signal pending to handler entry also grows if the target thread does not return from the kernel or blocks the signal.
To locate the bottleneck context, measure IRQ timestamp, state publication, wakeup, scheduler switch, syscall return, and userspace consumption separately. Combining them into one average easily hides upper-tail causes.
OBJECTS
Objects and lifetimes
| Object | Creation and release | Values to inspect |
|---|---|---|
irq_desc / irqaction | Retains handler and chip/domain state between IRQ registration and removal | trigger type, disable depth, action flags, threaded handler |
wait_queue_head | Attached to a subsystem object or file lifetime and manages sleeper/callback registration | wait entry, wake function, lock, exclusive flag |
eventpoll / epitem | Created by epoll_create and EPOLL_CTL_ADD; detached by DEL or fd close | interest mask, ready list, user data |
signal_struct / sighand_struct | A thread group shares pending state and dispositions, which are copied, reset, or released according to fork/exec/exit rules | shared_pending, action, flags |
task_struct.pending | Remains on the task from creation of a thread-directed signal until get_signal consumes it | signal bitmap, sigqueue list, TIF_SIGPENDING |
sigqueue / kernel_siginfo | Allocated when a queued signal needs a record and released on dequeue or target exit | si_code, sender, si_addr, payload, overrun |
rt_sigframe | Created on the user stack at handler delivery and consumed by rt_sigreturn | saved registers, old mask, siginfo, ucontext |
signalfd_ctx | Retains the observation mask from signalfd creation until file close | Mask and pending-signal poll/read results |
FAILURE PATH
Failure conditions and common misconceptions
| Observed symptom | Likely causes | How to verify |
|---|---|---|
| IRQ count differs from packet count | Interrupt coalescing, NAPI budget, or several completions per IRQ | Compare driver statistics, trace_irq_handler, and ring producer/consumer indexes |
| read returns EAGAIN after an epoll event | A race between readiness and consumption, or another thread drained first | Check the nonblocking return value and fd-ownership policy |
| Several signals were sent but only one was received | standard signal pending bit coalescing | Consider realtime signals, an eventfd counter, or an application queue |
| A signal handler executes late | The target mask, a long kernel section, scheduling delay, or selection of another thread | Inspect /proc/PID/task/*/status and sched/irq tracepoints |
| Ctrl-C is not delivered to the process | Foreground PGID mismatch, ISIG off, changed VINTR, or the signal mask | Inspect tcgetpgrp, stty -a, and /proc status |
| A zombie remains after SIGCHLD handling | Only the notification was consumed; waitpid/waitid was not called | ps state, /proc/PID/status, wait syscall trace |
| signalfd and a handler both operate | Some threads do not block the signal | Check SigBlk in every thread and the thread-creation order |
| A sleep warning or lockup occurs in hardirq | The handler uses a blocking API or performs excessive work | Inspect lockdep, the irqsoff tracer, and a handler-duration histogram |
LAB
Verify it yourself
- Run the example and record the arrival order of SIGUSR1, SIGCHLD, eventfd, and timerfd. The order is not guaranteed to be identical each time; verify that all four causes are observed through the same epoll_wait.
- Call kill(getpid(), SIGUSR1) three times in a row and count signalfd records. After observing SIGUSR1 coalescing, switch to SIGRTMIN with a sigqueue payload and compare queue behavior.
- Increase the worker's eventfd writes to three and delay the main read; verify that the returned counter is 3. Distinguish one readiness notification from three occurrences.
- Set the timer interval to 10 ms and make main sleep for 200 ms, then compare accumulated timerfd expirations with POSIX signal-timer overrun reporting.
- In strace -f output, confirm that kill does not appear as a write syscall to signal_fd and that signalfd read returns a separate record. Distinguish the generating syscall from the consuming fd.
- Press Ctrl-C under stty -isig and stty isig and compare a 0x03 input byte with SIGINT generation. Record the foreground process group with tcgetpgrp as well.
- Collect irq_handler_entry/exit, sched_wakeup, and sched_switch with perf trace or ftrace to separate the intervals between IRQ, wakeup, and userspace execution.
- Create one thread that blocks SIGUSR1 and one that does not, then use signalfd/sigwaitinfo to compare the receiving thread for process-directed kill and pthread_kill.
./event_sourcesstrace -f -e trace=epoll_wait,signalfd4,timerfd_create,timerfd_settime,eventfd2,kill,read,write,clone,clone3,wait4 ./event_sourcesPRIMARY REFERENCES