[Signal] Signal processing {Timing of signal processing; kernel mode and user mode; principle of signal capture; signal processing function: signal, sigaction; reentrant function; volatile keyword; SIGCHLD signal}

1. Timing of signal processing

1.1 When does the operating system perform signal processing?

  • First of all, we need to make it clear that the data structures for managing signals are all in the process PCB, and the process PCB belongs to the kernel data. Therefore, signal detection and processing must be performed in the kernel state (leaved to the operating system).
  • To be precise, it should be when returning from the kernel mode to the user mode, the signal is detected and processed.
  • Why should signal processing be performed before kernel mode returns to user mode? Because tasks in kernel mode are usually more important and have higher priority. Therefore, we must first ensure that the code in the kernel state is executed before signal processing can be performed.

1.2 The concepts of kernel mode and user mode

Kernel mode and user mode are two different execution modes in the operating system, which are used to distinguish the execution environment of the operating system kernel and the execution environment of user programs.

  • Kernel Mode is the privileged mode in which the operating system kernel runs, executing kernel code. In kernel mode, the operating system has full access rights and can execute privileged instructions, access system resources and control hardware devices. Code in kernel mode can perform any operation, including modifying memory, modifying registers, accessing hardware, etc. Code in kernel mode runs in the context of the operating system kernel and has the highest authority and greatest control.

  • User Mode is a non-privileged mode in which user programs run, and user code is executed. In user mode, User programs can only perform restricted operations and cannot directly access system resources and control hardware devices. Code in user mode runs in the context of the user process and is protected and restricted by the operating system. User programs need to request services and resources provided by the operating system through the system call interface, such as file operations, network communications, memory allocation, etc.

1.3 Switching between kernel mode and user mode

The operating system manages and protects system resources and hardware devices by switching processes or threads from user mode to kernel mode, and from kernel mode back to user mode.

  • When a process or thread encounters the following situations, control needs to be transferred to the kernel state, and the operating system kernel will complete the corresponding operations.

    1. When calling a system call function;

    2. When the operating system schedules and switches processes;

    3. When an exception occurs in the process…

  • Once the operation is completed, the operating system will return control to the user program, allowing it to continue executing in user mode.

The switching between kernel mode and user mode is implemented by the operating system’s scheduler and hardware support. Switching to the kernel mode requires saving the context information of the user program and loading the context information of the kernel; switching back to the user mode requires restoring the context information of the user program. The overhead of this kind of switching is relatively large, so the operating system will try to reduce the number of switching between kernel mode and user mode to improve system performance and response speed.

From the perspective of process address space, switching between kernel mode and user mode involves switching address space and changing access rights.

  1. Address space switching: In the x86 architecture, each process has its own virtual address space, including user space and kernel space. User space is used to store the user code and data of the process, while kernel space is used to store the kernel code and data of the operating system. When a process switches from user mode to kernel mode, it needs to switch to the kernel address space in order to access and execute kernel code and data. This usually involves switching the page table and setting the value of the CR3 register to the physical address of the kernel page table to achieve address space switching.

  2. Access permission changes: User mode and kernel mode have different access permissions. In user mode, a process can only access its own user address space and cannot directly access the kernel address space. In kernel mode, the operating system has full access to the entire address space, including user address space and kernel address space. When a process switches from user mode to kernel mode, access permissions change and the process can access and execute kernel code and data.

When switching between kernel mode and user mode, the switch is usually triggered through a system call or exception. When a process executes a system call or an exception occurs, the processor switches from user mode to kernel mode and jumps to the corresponding kernel code for processing. After handling the system call or exception, the processor will switch from the kernel mode back to the user mode and resume the execution of the process.

In short, from the perspective of process address space, switching between kernel mode and user mode involves address space switching and access permission changes. By switching page tables and changing access permissions, the process can access and execute kernel code and data in kernel mode, thereby realizing interaction with the operating system and invoking system services.

2. Principle of signal capture

Schematic diagram

  • The process of signal detection includes: detecting the pending signal set –> detecting the signal mask word –> finding the handler signal processing method table

    • If the signal processing method is default or ignored, the corresponding processing actions (terminating the process, suspending the process, clearing the pending flag) are completed directly in the kernel mode without switching to the user mode.
    • If the signal processing method is custom capture, switch to user mode to execute the signal processing program. After completion, it will fall into the kernel again, clear the pending flag of the corresponding signal, and finally return to user mode to continue executing the user program.
  • Why switch to user mode to execute the signal processing function? Because the signal processing function is provided by the user, if the user code is executed in the kernel mode, since the kernel mode has full access rights, the user code may modify important data of the system, causing damage to system resources and hardware devices.

  • It should be noted that the signal processing function in the Linux system is executed asynchronously (multiple execution flow execution), which will interrupt the currently executing code. Therefore, when writing signal processing functions, you should avoid using non-reentrant functions, global variables and other operations that may cause data races.

shorthand diagram

3. Signal processing function

3.1 signal

There is a detailed introduction in the signal generation chapter, please read the article:

[Signal] Signal generation {Basic concept of signal; common signals; 4 methods of signal generation; core dump core dump; related system calls: signal, kill, raise, abort, alarm}

3.2 sigaction

The sigaction function is a system call function used to obtain and modify signal handlers. It can be used to register a signal processing function, specify the signal processing method and block and unblock the signal during signal processing.

The prototype of the sigaction function is as follows:

int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

parameter:

  1. signum: is the number of the signal to be set
  2. act: If the act pointer is not empty, modify the signal processing action according to act (input type)
  3. oldact: If the oldact pointer is not empty, the original processing action of the signal is sent through oldact (output type)

return value:

  • Success: return 0
  • Failure: Returns -1 and sets errno to indicate the cause of the error.

The sigaction structure is defined as follows:

struct sigaction {<!-- -->
    void (*sa_handler)(int); //**The pointer of the signal processing function, the usage is the same as the handler pointer parameter of the signal function**
    void (*sa_sigaction)(int, siginfo_t *, void *); //Used to process real-time signals
    sigset_t sa_mask; //**Signal mask word**
    int sa_flags; //Flag bit, used to specify the signal processing method
    void (*sa_restorer)(void); //Is a pointer to the recovery function, used to restore the scene of the signal handler
};
  • When a signal processing function is called, the kernel automatically adds the current signal to the signal mask of the process, and automatically restores the original signal mask when the signal processing function returns. , this ensures that when processing a certain signal, if this signal occurs again, it will be blocked until the current handler ends.
  • If when calling the signal processing function, in addition to the current signal being automatically shielded, you also want to automatically shield other signals, use the sa_mask field to record these signals that need additional shielding. When the signal processing function >Automatically restorethe original signal mask when returning.

Through the sigaction function, we can achieve the following functions:

  1. Specify the signal processing method, you can choose default, ignore or custom processing method.
  2. Register a signal processing function. When the specified signal occurs, the corresponding signal processing function is executed.
  3. Blocks the specified signal to avoid being interrupted by other signals while the signal handler function is executing.
  4. Unblocks the specified signal.

test program:

void handler(int signum){<!-- -->
    cout << "Caught a signal: " << signum << endl;
    //Repeatly print the currently blocked pending signal set and return after 10 seconds.
    int cnt = 10;
    while(cnt--)
    {<!-- -->
        sigset_t pset;
        sigpending( & amp;pset);
        ShowPending( & amp;pset);
        sleep(1);
    }
}

void test1()
{<!-- -->
    //Define sigaction structure
    struct sigaction act, oact;
    //Initialize signal mask word
    sigemptyset( & amp;act.sa_mask);
    //During signal processing, shield additional signals [3,7]
    sigaddset( & amp;act.sa_mask, 3);
    sigaddset( & amp;act.sa_mask, 4);
    sigaddset( & amp;act.sa_mask, 5);
    sigaddset( & amp;act.sa_mask, 6);
    sigaddset( & amp;act.sa_mask, 7);
    //sa_flags is set to 0 by default
    act.sa_flags = 0;
    //Set the pointer of the signal processing function
    act.sa_handler = handler;
    
 //Call the sigaction function to capture signal No. 2 and register the signal handler
    sigaction(SIGINT, & amp;act, & amp;oact);
    cout << "catch signal: 2\told action: " << oact.sa_handler << endl;

    while(1);
}

operation result:

  1. Signal No. 2 is successfully captured, and at the same time, signal No. 2 (current signal) is blocked during the execution of the signal handler.
  2. During the execution of signal handler No. 2, the additionally set signals No. [3, 7] are also blocked.
  3. When the signal processing function returns, it automatically restores the original signal mask word and unblocks the [2,7] signal, so the process automatically exits after receiving the signal.

4. Supplementary content

4.1 Reentrant functions

The main function calls the insert function to insert node node1 into a linked list head. The insertion operation is divided into two steps. When the first step is just completed, the process switches to the kernel due to a hardware interrupt. Before returning to the user mode again, it is checked that there is a signal to be processed. , so switch to the sighandler function, sighandler also calls the insert function to insert node node2 into the same linked list head. After completing both steps of the insertion operation, return to the kernel state from sighandler, and return to the user state again to call the insert function from the main function. Continue to execute. The first step was interrupted after the first step. Now continue to complete the second step. The result is that the main function and singhandler insert two nodes into the linked list one after another, but in the end only one node is actually inserted into the linked list.

Like the above example, the insert function is called by different execution flows, and it is possible to enter the function again before the first call returns. This is called reentrancy. The insert function accesses a global linked list and may cause confusion due to reentrancy. Such a function is called a non-reentrant function. On the contrary, if a function can be executed reentrantly without any errors, it is also called a reentrant function. .

For details on reentrant functions, please read on:
[Multi-threading] Thread mutual exclusion {Data competition issues in concurrent execution of multiple execution streams, basic usage of mutex locks, principles of mutex locks; deadlocks; reentrant functions and thread safety}

4.2 volatile keyword

In C language, volatile is a keyword used to declare a variable to be “volatile”.

When a variable is declared volatile, the compiler will ensure that read and write operations on the variable will not be optimized and memory visibility will be maintained. This is because the value of a volatile variable may change unexpectedly, for example due to hardware operations, interrupt handlers, or other threads.

The main scenarios for using the volatile keyword include:

  1. Parallel processing: When multiple threads or processes share a variable, if the variable may be changed by other threads or processes, then the variable should be declared volatile to ensure that each read is read from memory. Instead of using cached values.

  2. Interrupt handling: In an interrupt handler, it is usually necessary to access hardware registers or other external devices. Since the state of these devices may change at any time, the relevant variables must be declared volatile to ensure that each access reads the latest value from memory.

  3. Optimization disabled: The values of some variables may be changed by external factors, but the compiler cannot detect this change, so these variables may be optimized. By declaring these variables volatile, you tell the compiler not to optimize these variables.

test program:

bool flag = false;
// volatile bool flag = false; //volatile modifies flag and disables optimization

void ChangeFlag(int signum){<!-- -->
  (void)signum;
  flag = true;
}

void test3(){<!-- -->
  signal(SIGINT, ChangeFlag);
  while(!flag);
  cout << "End loop, process exits!" << endl;
}

operation result:

The compiler does not perform any optimization, and the process exits after receiving signal No. 2.

Compiling at the highest optimization level, the process did not exit after receiving signal No. 2.

Explanation: During the compilation process, the compiler detected that the variable flag did not change during the entire execution of the main control flow (test3), so it was optimized into a register variable. In other words, each conditional judgment of the while statement directly takes the value in the register, covering the data of the flag in the memory. So even if the process changes the value of flag in memory to 1 after receiving signal No. 2 (interrupt processing), because the register data is initialized to 0 and does not change, the program still keeps looping and will not exit.

Modify the flag with the volatile keyword and compile at the highest optimization level. The process will exit after receiving signal No. 2.

4.3 SIGCHLD signal

The SIGCHLD(17) signal is a signal sent by the operating system to its parent process when a child process terminates or stops. It is a signal that notifies the parent process of a change in the state of the child process. The parent process can handle the state change of the child process by catching the SIGCHLD signal and using the corresponding signal processing function.

The SIGCHLD signal processing methods are as follows:

  1. Ignore signal (default): The default processing method of SIGCHLD signal is to ignore (ign). However, the child process will become a zombie process and wait for the parent process to obtain its exit status.
  2. Ignore the signal (manual): We can also manually choose to ignore the SIGCHLD signal (SIG_IGN) through the signal or sigaction function. In this case, the system will directly recycle the child process resources and release the zombies process. The parent process is no longer required to wait for the child process. This is typically used when the parent process does not care about the exit status of the child process.
  3. Catching signals: The parent process can capture the SIGCHLD signal by registering a SIGCHLD signal handling function. The operating system calls this signal handler when the child process terminates or stops.
  4. Blocking signals: The parent process can choose to block the SIGCHLD signal at certain times to delay the processing of state changes in the child process. This can be achieved by calling the sigprocmask function to set the signal mask.

Test 1: Ignore the SIGCHLD signal (default)

void test1(){<!-- -->
    pid_t id = fork();
    assert(id!=-1);
    if(id == 0)
    {<!-- -->
        printf("[%d]: The child process is running!\
", getpid());
        sleep(3);
        printf("[%d]: Child process exited!\
", getpid());
        exit(0);
    }

    while(true)
    {<!-- -->
        printf("[%d]: The parent process is running!\
", getpid());
        sleep(1);
    }
}

Running result: The child process becomes a zombie process

Test 2: Ignore the SIGCHLD signal (manual)

void test2(){<!-- -->
    signal(SIGCHLD, SIG_IGN); //Manually ignore
    pid_t id = fork();
    assert(id!=-1);
    if(id == 0)
    {<!-- -->
        printf("[%d]: The child process is running!\
", getpid());
        sleep(3);
        printf("[%d]: Child process exited!\
", getpid());
        exit(0);
    }

    while(true)
    {<!-- -->
        printf("[%d]: The parent process is running!\
", getpid());
        sleep(1);
    }
}

Operation result: The system directly recycles the child process resources and releases the zombie process

Test 3: Capture the SIGCHLD signal

void handler(int signum){<!-- -->
    printf("[%d]: Caught a signal: %d\
", getpid(), signum);
    pid_t cpid = 0;
    while((cpid = waitpid(-1, nullptr, WNOHANG)) > 0) //Key points
    {<!-- -->
        printf("[%d]: Child process exited, cpid: %d\
", getpid(), cpid);
    }
}

void test3(){<!-- -->
    signal(SIGCHLD, handler); //custom capture
    pid_t id = fork();
    assert(id!=-1);
    if(id == 0)
    {<!-- -->
        printf("[%d]: The child process is running!\
", getpid());
        sleep(3);
        printf("[%d]: Child process exited!\
", getpid());
        exit(0);
    }

    while(true)
    {<!-- -->
        printf("[%d]: The parent process is running!\
", getpid());
        sleep(1);
    }
}

Running results: The parent process obtains the exit status of the child process, and then the system releases the zombie process.

When the parent process receives the SIGCHLD(17) signal, it only knows that the status of some child processes has changed. What it cannot determine is:

  1. Unsure how many child processes exited
  2. I don’t know which child process exited
  3. Unable to determine whether the child process is terminated or suspended

So when waiting for the child process to exit in the signal handling function:

  1. The while loop waits for multiple processes to exit until the exit status of all exiting processes is collected.
  2. The first parameter of waitpid is passed -1 and waits for any child process to exit.
  3. The third parameter of waitpid is passed WNOHANG to use non-blocking waiting. Otherwise, if no child process exits, the parent process will block and wait.