1

Suppose I have following program:

#include <signal.h>
#include <stddef.h>
#include <stdlib.h>

static void myHandler(int sig){
        abort();
}

int main(void){
        signal(SIGSEGV,myHandler);
        char* ptr=NULL;
        *ptr='a';
        return 0;
}

As you can see, I register a signalhandler and some lines further, I dereference a null pointer ==> SIGSEGV is triggered. But how is it triggered? If I run it using strace (Output stripped):

//Set signal handler (In glibc signal simply wraps a call to sigaction)
rt_sigaction(SIGSEGV, {sa_handler=0x563b125e1060, sa_mask=[SEGV], sa_flags=SA_RESTORER|SA_RESTART, sa_restorer=0x7ffbe4fe0d30}, {sa_handler=SIG_DFL, sa_mask=[], sa_flags=0}, 8) = 0
//SIGSEGV is raised
--- SIGSEGV {si_signo=SIGSEGV, si_code=SEGV_MAPERR, si_addr=NULL} ---
rt_sigprocmask(SIG_UNBLOCK, [ABRT], NULL, 8) = 0
rt_sigprocmask(SIG_BLOCK, ~[RTMIN RT_1], [SEGV], 8) = 0

But something is missing, how does a signal go from the CPU to the program? My understanding:

[Dereferences null pointer] -> [CPU raises an exception] -> [??? (How does it go from the CPU to the kernel?) ] -> [The kernel is notified, and sends the signal to the process] -> [??? (How does the process know, that a signal is raised?)] -> [The matching signal handler is called].

What happens at these two places marked with ????

klutt
  • 30,332
  • 17
  • 55
  • 95
JCWasmx86
  • 3,473
  • 2
  • 11
  • 29
  • 1
    There are exception handler vectors set up by the OS. The cpu invokes the appropriate handler in the kernel. Similarly, processes have a signal handling table from which the kernel invokes the appropriate one. But you know this, since the code installs such a handler :) The `signal(SIGSEGV,myHandler);` tells the kernel to invoke your `myHandler` whenever a `SIGSEGV` happens. – Jester Jul 25 '20 at 17:40
  • 1
    Dereferencing a NULL pointer is not that different from any attempt to dereference an _invalid_ pointer. The OS assigns to your process a specific memory domain (segment). Whenever you try accessing outside it (an the null pointer is for sure outside it) the os detects it and raises the sigsegv exception, that commonly leads to segmentation fault. – Roberto Caboni Jul 25 '20 at 17:42
  • @Jester Thank you, is there a way to add a custom exception handler to the CPU in usermode (With a running OS)? – JCWasmx86 Jul 25 '20 at 17:45
  • In less advanced environments, without an OS managing the nemory segment of your process, the CPU will simply try to access that location. If address 0 happens to be a valid memory location the program will simply write `'a'` there. But since your program wasn't supposet to write there it will likely lead to an inexorable crash (soon or later...). – Roberto Caboni Jul 25 '20 at 17:46
  • 1
    No, you don't get to install global exception handlers since that would allow you to mess with other processes. You get the signals for (most of) the exceptions that concern your process. – Jester Jul 25 '20 at 17:53
  • The OS maintains a vector table to direct control after an exception occurs. – Weather Vane Jul 25 '20 at 17:53
  • I suggest the inspiring [signal man page](https://linux.die.net/man/2/signal) for a better understanding of signals handling. By the way, it suggests avoiding `signal` using [`sigaction`](https://linux.die.net/man/2/sigaction), instead. It's indeed more powerful as it provides a lot of "weapons" for signal handling. – Roberto Caboni Jul 25 '20 at 17:59
  • Talking about a SIGSEGV raised by a process having effects also on other "friend" processes (@Jester) I never did something like it. But if I'm not wrong there are several ways in which this interaction could happen. Any ipc mechanism would do the trick, but an easier way could be writing some info to a file (for example _"I'm dying for the reason XYX, restart me"_) that another polling process could read in order to take its decisions.. – Roberto Caboni Jul 25 '20 at 18:06
  • What happens when the cpu dereferences a null pointer? You get a logically-impossible crash because someone decided to put the microprocessor's ROM at address `0` (and to use a compiler which uses `0` for `NULL`'s bit-pattern), and didn't bother to hook up an error status on writes, so reads go through but writes are silently ignored, and so a `NULL` pointer to a struct ends up causing a logically-impossible crash because the code (and compiler) rather thought that it would read back what it just wrote into the struct. Ah, the wonders of embedded. – TLW Nov 29 '22 at 06:26

2 Answers2

7

A NULL pointer in most (but not all) C implementations is address 0. Normally this address is not in a valid (mapped) page.

Any access to a virtual page that's not mapped by the HW page tables results in a page-fault exception. e.g. on x86, #PF.

This invokes the OS's page-fault exception handler to resolve the situation. On x86-64 for example, the CPU pushes exception-return info on the kernel stack and loads a CS:RIP from the IDT (Interrupt Descriptor Table) entry that corresponds to that exception number. Just like any other exception triggered by user-space, e.g. integer divide by zero (#DE), or a General Protection fault #GP (trying to run a privileged instruction in user-space, or a misaligned SIMD instruction that required alignment, or many other possible things).

The page-fault handler can find out what address user-space tried to access. e.g. on x86, there's a control register (CR2) that holds the linear (virtual) address that caused the fault. The OS can get a copy of that into a general-purpose register with mov rax, cr2.

Other ISAs have other mechanisms for the OS to tell the CPU where its page-fault handler is, and for that handler to find out what address user-space was trying to access. But it's pretty universal for systems with virtual memory to have essentially equivalent mechanisms.


The access is not yet known to be invalid. There are several reasons why an OS might not have bothered to "wire" a process's allocated memory into the hardware page tables. This is what paging is all about: letting the OS correct the situation, like copy-on-write, lazy allocation, or bringing a page back in from swap space.

Page faults come in three categories: (copied from my answer on another question). Wikipedia's page-fault article says similar things.

  • valid (the process logically has the memory mapped, but the OS was lazy or playing tricks like copy-on-write):
    • hard: the page needs to be paged in from disk, either from swap space or from a disk file (e.g. a memory mapped file, like a page of an executable or shared library). Usually the OS will schedule another task while waiting for I/O: this is the key difference between hard (major) and soft (minor).
    • soft: No disk access required, just for example allocating + zeroing a new physical page to back a virtual page that user-space just tried to write. Or copy-on-write of a writeable page that multiple processes had mapped, but where changes by one shouldn't be visible to the other (like mmap(MAP_PRIVATE)). This turns a shared page into a private dirty page.
  • invalid: There wasn't even a logical mapping for that page. A POSIX OS like Linux will deliver SIGSEGV signal to the offending process/thread.

So only after the OS consults its own data structures to see which virtual addresses a process is supposed to own can it be sure that the memory access was invalid.

Deciding whether a page fault is invalid or not is completely up to software. As I wrote on Why page faults are usually handled by the OS, not hardware? - if the HW could figure everything out, it wouldn't need to trap to the OS.

Fun fact: on Linux it's possible to configure the system so virtual address 0 is (or can be) valid. Setting mmap_min_addr = 0 allows processes to mmap there. e.g. WINE needs this for emulating a 16-bit Windows memory layout.

Since that wouldn't change the internal object-representation of a NULL pointer to be other than 0, doing that would mean that NULL dereference would no longer fault. That makes debugging harder, which is why the default for mmap_min_addr is 64k.


On a simpler system without virtual memory, the OS might still be able to configure an MMU to trap on memory access to certain regions of address space. The OS's trap handler doesn't have to check anything, it knows any access that triggered it was invalid. (Unless it's also emulating something for some regions of address space...)


Delivering a signal to user-space

This part is pure software. Delivering SIGSEGV is no different than delivering SIGALRM or SIGTERM sent by another process.

Of course, a user-space process that just returns from a SIGSEGV handler without fixing the problem will make the main thread re-run the same faulting instruction again. (The OS would return to the instruction that raised the page-fault exception.)

This is why the default action for SIGSEGV is to terminate, and why it doesn't make sense to set the behaviour to "ignore".

Peter Cordes
  • 328,167
  • 45
  • 605
  • 847
  • 1
    ... though I see you’ve done an excellent job of succinctly adding in detail! – bazza Jul 26 '20 at 07:55
  • As an aside, one nasty exception here can be when your code doesn't directly use the null pointer, but instead uses a large enough offset of said null pointer such that it happens to point to a valid page (typically due to array indexing, but can be due to e.g. a large struct too, rarely). This is one of the reasons why OSes tend to reserve more than just one page at address 0. (This issue is, of course, more common with smaller address spaces.) This is especially fun when the instruction set allows negative offsets to wrap (and you have something mapped at the end of the address space). – TLW Nov 29 '22 at 06:38
  • 1
    @TLW: Indeed, Linux's default `mmap_min_addr` is 64KiB to guard against small and medium-sized offsets. And Linux *always* reserves the top page of virtual address space; the kernel uses such pointers as error codes, but the kernel does map itself near the top of virtual address space. But user-space code is fully safe from negative offsets wrapping on Linux and most other OSes; the entire high half of virtual address space (or the top 1G on 32-bit systems with a 3:1 split) is kernel only. – Peter Cordes Nov 29 '22 at 06:49
  • @PeterCordes, Hi I have a question about "the process of deliver a signal". As you mentioned the the answer, lets say processA try to dereference a null ptr, so that invoke os's page fault. Hence OS will find the place of processA on kernel stack, and then change the program counter(PC) of processA to the page fault handler. My questions is: now lets say we have processB and processC, processB want to send signal to processC, does processB do the same thing like what kernel do to processA? Will processB change the PC of processC, make the PC of prcoessC point to target signal handler pointer? – Peter Aug 15 '23 at 08:12
  • 1
    @Peter: *then change the program counter(PC) of processA to the page fault handler.* - No, the kernel's page-fault handler runs in kernel mode. It doesn't change the saved user-space-program-counter of processA until / unless the page fault is found to be invalid *and* processA has a signal handler installed for SIGSEGV. If that's the case, the kernel will run set the user-space PC for processA to its **signal** handler after the page fault was determined to be invalid (not copy-on-write or paged out.) Otherwise (no signal handler) processA just dies. – Peter Cordes Aug 15 '23 at 08:51
  • 1
    @Peter: But yes, your `kill(processC_pid, sig)` system call made by processB runs kernel code that interrupts processC if it was running on a different core, and kernel code modifies its task_struct so any core that resumes that task will do so at the signal-handler address, if it has one installed. (Depending on `sigaltstack`, this might also involve changing its user-space stack-pointer, and always updating the bookkeeping so the kernel knows its inside a signal handler.) If not, the default action for the signal applies, either ignore or kill the process. – Peter Cordes Aug 15 '23 at 08:56
3

Typically what happens is that when the CPU’s Memory Management Unit finds that the virtual address the program is trying to access is not in any of the mappings to physical memory, it raises an interrupt. The OS will have set up an Interrupt Service Routine just in case this happens. That routine will do whatever is necessary inside the OS to signal the process with SEGV. In return from the ISR the offending instruction has not been completed.

What happens then depends on whether there’s a handler installed or not for SEGV. The language’s runtime may have installed one that raises it as an exception. Almost always the process is terminated, as it is beyond recovery. Something like valgrind would do something useful with the signal, eg telling you exactly where in the code the program had got to.

Where it gets interesting is when you look at the memory allocation strategies used by C runtime libraries like glibc. A NULL pointer dereference is a bit of an obvious one, but what about accessing beyond the end of an array? Often, calls to malloc() or new will result in the library asking for more memory than has been asked for. The bet is that it can use that memory to satisfy further requests for memory without troubling the OS - which is nice and fast. However, the CPU’s MMU has no idea that that’s happened. So if you do access beyond the end of the array, you’re still accessing memory that the MMU can see is mapped to your process, but in reality you’re beginning to trample where one shouldn’t. Some very defensive OSes don’t do this, specifically so that the MMU does catch out of bounds accesses.

This leads to interesting results. I’ve come across software that builds and runs just fine on Linux which, compiled for FreeBSD, starts throwing SEGVs. GNURadio is one such piece of software (it was a complex flow graph). Which is interesting because it makes heavy use of boost / c++11 smart pointers specifically to help avoid memory misuse. I’ve not yet been able to identify where the fault is to submit a bug report for that one...

bazza
  • 7,580
  • 15
  • 22
  • You assume virtual afddress space and MMU but many popular hardware architectures do not have one (or it has to be enambled by the programmer). For example Cortex -M uCs – 0___________ Jul 25 '20 at 18:43
  • 3
    @P__J__ I think that, given the original question comes tagged “Linux” and mentions SEGV, my assumption is reasonable. – bazza Jul 25 '20 at 20:54
  • You left out the part where the page-fault handler has to figure out of the page fault was valid or not! Linux uses virtual memory with copy-on-write, lazy allocation, and demand-paging to/from disk. – Peter Cordes Jul 26 '20 at 01:26
  • On most ISAs, memory permissions have page granularity. You can't get the HW help you trap array overruns unless you map your array to the end of a page (followed by an unmapped page.) Some JIT VMs actually do this on purpose for large arrays so they can make bounds checking cheap for the non-faulting case, but it would be very expensive to put each small allocation in a separate page. – Peter Cordes Jul 26 '20 at 01:32
  • @PeterCordes Hello, yes I missed out a lot of the detail about what actually goes on inside the OS, but I figured that the OP was more interested in the basics of how the hardware behaved. I didn’t know that about JIT VMs - they’re better than I’d given them credit for! I think I’ve not enough time left on this planet to be able to write a fully comprehensive answer... – bazza Jul 26 '20 at 07:46
  • 1
    That's fine, my answer covers the fact that a page-fault exception isn't always invalid. I considered just mentioning that fact, but I already had more details written for another answer. BTW, re: using virt mem for array bounds checks: [Array bounds checks on 64-bit hardware using hardware memory-protection](https://stackoverflow.com/q/29565312) suggests that Mozilla's JavaScript engine does this. http://dkl.cs.arizona.edu/publications/papers/ics.pdf proposed this for Java but some quick googling didn't find anything about JVMs using it for real. IDK how common it is. – Peter Cordes Jul 26 '20 at 07:55