web-dev-qa-db-fra.com

Comment un défaut de segmentation fonctionne-t-il sous le capot?

Je n'arrive pas à trouver d'informations à ce sujet à part "le CPU MMU envoie un signal" et "le noyau le dirige vers le programme incriminé, le terminant").

J'ai supposé qu'il envoie probablement le signal au Shell et que le Shell le gère en mettant fin au processus incriminé et en imprimant "Segmentation fault". J'ai donc testé cette hypothèse en écrivant un Shell extrêmement minimal que j'appelle crsh (Shell merdique). Ce shell ne fait rien d'autre que de saisir les entrées de l'utilisateur et de les alimenter à la méthode system().

#include <stdio.h>
#include <stdlib.h>

int main(){
    char cmdbuf[1000];
    while (1){
        printf("Crap Shell> ");
        fgets(cmdbuf, 1000, stdin);
        system(cmdbuf);
    }
}

J'ai donc exécuté ce Shell dans un terminal nu (sans que bash s'exécute en dessous). J'ai ensuite exécuté un programme qui produit une erreur de segmentation. Si mes hypothèses étaient correctes, ce serait a) planter crsh, fermant le xterm, b) ne pas imprimer "Segmentation fault", Ou c) les deux.

braden@system ~/code/crsh/ $ xterm -e ./crsh
Crap Shell> ./segfault
Segmentation fault
Crap Shell> [still running]

Retour à la case départ, je suppose. Je viens de démontrer que ce n'est pas le Shell qui fait ça, mais le système en dessous. Comment le "défaut de segmentation" est-il même imprimé? "Qui" le fait? Le noyau? Autre chose? Comment le signal et tous ses effets secondaires se propagent-ils du matériel à la fin éventuelle du programme?

264
Braden Best

Tous les CPU modernes ont la capacité d'interrompre l'instruction machine en cours d'exécution. Ils enregistrent suffisamment d'état (généralement, mais pas toujours, sur la pile) pour permettre de reprendre l'exécution plus tard, comme si rien ne s'était passé (l'interruption l'instruction sera redémarrée à partir de zéro, généralement). Ensuite, ils commencent à exécuter un gestionnaire d'interruption , qui est juste plus de code machine, mais placé à un emplacement spécial pour que le CPU sache où il se trouve à l'avance. Les gestionnaires d'interruptions font toujours partie du noyau du système d'exploitation: le composant qui s'exécute avec le plus grand privilège et est responsable de la supervision de l'exécution de tous les autres composants .1,2

Les interruptions peuvent être synchrones , ce qui signifie qu'elles sont déclenchées par le CPU lui-même en réponse directe à quelque chose que l'instruction en cours d'exécution a fait, ou asynchrone , ce qui signifie qu'ils se produisent à un moment imprévisible en raison d'un événement externe, comme les données arrivant sur le port réseau. Certaines personnes réservent le terme "interruption" aux interruptions asynchrones et appellent les interruptions synchrones "pièges", "défauts" ou "exceptions" à la place, mais ces mots ont tous une autre signification, donc je vais m'en tenir à "interruption synchrone".

Maintenant, la plupart des systèmes d'exploitation modernes ont une notion de processus . À la base, il s'agit d'un mécanisme par lequel l'ordinateur peut exécuter plusieurs programmes en même temps, mais c'est également un aspect clé de la façon dont les systèmes d'exploitation configurent la protection de la mémoire --- , qui est une caractéristique de la plupart (mais, hélas, toujours pas tous ) processeurs modernes. Il va de pair avec mémoire virtuelle , qui est la possibilité de modifier le mappage entre les adresses de mémoire et les emplacements réels dans la RAM. La protection de la mémoire permet au système d'exploitation de donner à chaque processus son propre bloc de RAM privé, auquel lui seul peut accéder. Il permet également au système d'exploitation (agissant au nom de certains processus) de désigner des régions de RAM en lecture seule, exécutables, partagées entre un groupe de processus coopérants, etc. Il y aura également un morceau de mémoire qui n'est accessible que par le noyau.3

Tant que chaque processus accède à la mémoire uniquement de la manière autorisée par le processeur, la protection de la mémoire est invisible. Lorsqu'un processus enfreint les règles, le CPU génère une interruption synchrone, demandant au noyau de trier les choses. Il arrive régulièrement que le processus n'ait pas vraiment enfreint les règles, seul le noyau doit faire un peu de travail avant de pouvoir continuer le processus. Par exemple, si une page de la mémoire d'un processus doit être "expulsée" du fichier d'échange afin de libérer de l'espace dans RAM pour autre chose, le noyau marquera cette page comme inaccessible. La la prochaine fois que le processus tentera de l'utiliser, le CPU générera une interruption de protection de la mémoire; le noyau récupérera la page du swap, la remettra où elle était, la marquera à nouveau accessible et reprendra l'exécution.

Mais supposons que le processus ait vraiment enfreint les règles. Il a essayé d'accéder à une page qui n'a jamais eu de mappage RAM, ou il a essayé d'exécuter une page qui est marquée comme ne contenant pas de code machine, etc.). La famille de systèmes d'exploitation en général connu sous le nom "Unix", tous utilisent des signaux pour faire face à cette situation.4 Les signaux sont similaires aux interruptions, mais ils sont générés par le noyau et mis en place par les processus, plutôt que d'être générés par le matériel et mis en place par le noyau. Les processus peuvent définir les gestionnaires de signaux dans leur propre code, et indiquer au noyau où ils se trouvent. Ces gestionnaires de signaux s'exécuteront alors, interrompant le flux normal de contrôle, si nécessaire. Les signaux ont tous un numéro et deux noms, dont l'un est un acronyme cryptique et l'autre une phrase légèrement moins cryptique. Le signal généré lorsque le processus a enfreint les règles de protection de la mémoire est (par convention) le numéro 11, et ses noms sont SIGSEGV et "Segmentation fault".5,6

Une différence importante entre les signaux et les interruptions est qu'il existe un comportement par défaut pour chaque signal. Si le système d'exploitation ne parvient pas à définir des gestionnaires pour toutes les interruptions, il s'agit d'un bogue dans le système d'exploitation et l'ordinateur entier se bloque lorsque le processeur tente d'appeler un gestionnaire manquant. Mais les processus n'ont aucune obligation de définir des gestionnaires de signaux pour tous les signaux. Si le noyau génère un signal pour un processus, et que ce signal a été laissé à son comportement par défaut, le noyau ira de l'avant et fera tout ce qui est par défaut et ne dérangera pas le processus. La plupart des comportements par défaut des signaux sont "ne rien faire" ou "terminer ce processus et peut-être aussi produire un vidage de mémoire". SIGSEGV est l'un de ces derniers.

Donc, pour récapituler, nous avons un processus qui a enfreint les règles de protection de la mémoire. Le processeur a suspendu le processus et a généré une interruption synchrone. Le noyau a mis cette interruption en service et a généré un signal SIGSEGV pour le processus. Supposons que le processus n'ait pas configuré un gestionnaire de signal pour SIGSEGV, de sorte que le noyau exécute le comportement par défaut, qui est de terminer le processus. Cela a tous les mêmes effets que les _exit appel système: les fichiers ouverts sont fermés, la mémoire est désallouée, etc.

Jusqu'à présent, rien n'a imprimé de messages qu'un humain peut voir, et le shell (ou, plus généralement, le processus parent du processus qui vient d'être résilié) n'a pas été impliqué du tout. SIGSEGV va au processus qui a enfreint les règles, pas son parent. Cependant, l'étape suivante de la séquence consiste à notifier au processus parent que son enfant a été interrompu. Cela peut se produire de plusieurs manières différentes, dont la plus simple est lorsque le parent attend déjà cette notification, en utilisant l'un des appels système wait (wait, waitpid, wait4, etc). Dans ce cas, le noyau provoquera simplement le retour de cet appel système et fournira au processus parent un numéro de code appelé état de sortie .7 Le statut de sortie informe le parent pourquoi le processus enfant a été interrompu; dans ce cas, il apprendra que l'enfant a été interrompu en raison du comportement par défaut d'un signal SIGSEGV.

Le processus parent peut alors signaler l'événement à un humain en imprimant un message; Les programmes Shell font presque toujours cela. Votre crsh n'inclut pas de code pour ce faire, mais cela arrive quand même, car la routine de la bibliothèque C system exécute un shell complet, /bin/sh, "sous la capuche". crsh est le grand-parent dans ce scénario; la notification du processus parent est envoyée par /bin/sh, qui imprime son message habituel. Alors /bin/sh se ferme, car il n'a plus rien à faire, et l'implémentation de la bibliothèque C de system reçoit cette notification de sortie . Vous pouvez voir cette notification de sortie dans votre code, en inspectant la valeur de retour de system; mais cela ne vous dira pas que le processus de petit-enfant est mort sur une erreur de segmentation, car il a été consommé par le processus Shell intermédiaire.


Notes de bas de page

  1. Certains systèmes d'exploitation n'implémentent pas les pilotes de périphérique dans le cadre du noyau; cependant, tous les gestionnaires d'interruptions doivent toujours faire partie du noyau, tout comme le code qui configure la protection de la mémoire, car le matériel ne permet rien mais le noyau pour faire ces choses.

  2. Il peut y avoir un programme appelé "hyperviseur" ou "gestionnaire de machine virtuelle" qui est encore plus privilégié que le noyau, mais pour les besoins de cette réponse, il peut être considéré comme faisant partie du matériel .

  3. Le noyau est un programme , mais ce n'est pas un processus; cela ressemble plus à une bibliothèque. Tous les processus exécutent de temps en temps des parties du code du noyau, en plus de leur propre code. Il peut y avoir un certain nombre de "threads du noyau" qui seulement exécutent le code du noyau, mais ils ne nous concernent pas ici.

  4. Le seul et unique système d'exploitation auquel vous devrez probablement faire face et qui ne peut pas être considéré comme une implémentation d'Unix est, bien sûr, Windows. Il n'utilise pas de signaux dans cette situation. (En effet, il n'a pas de signaux ; sous Windows le <signal.h> L'interface est complètement truquée par la bibliothèque C.) Elle utilise à la place quelque chose appelé " gestion des exceptions structurées ".

  5. Certaines violations de protection de la mémoire génèrent SIGBUS ("Erreur de bus") au lieu de SIGSEGV. La ligne entre les deux est sous-spécifiée et varie d'un système à l'autre. Si vous avez écrit un programme qui définit un gestionnaire pour SIGSEGV, c'est probablement une bonne idée de définir le même gestionnaire pour SIGBUS.

  6. "Erreur de segmentation" était le nom de l'interruption générée pour les violations de protection de la mémoire par l'un des ordinateurs qui exécutaient le nix d'origine , probablement le PDP-11 . " Segmentation " est un type de protection de la mémoire, mais de nos jours le terme "segmentation fault "fait référence de manière générique à tout type de violation de protection de la mémoire.

  7. Toutes les autres façons dont le processus parent peut être notifié de la fin d'un enfant, finissent par appeler le parent wait et recevoir une sortie statut. C'est juste que quelque chose d'autre se produit en premier.

248
zwol

Le Shell a en effet quelque chose à voir avec ce message, et crsh appelle indirectement un Shell, qui est probablement bash.

J'ai écrit un petit programme C qui sépare toujours les défauts:

#include <stdio.h>

int
main(int ac, char **av)
{
        int *i = NULL;

        *i = 12;

        return 0;
}

Lorsque je l'exécute à partir de mon shell par défaut, zsh, j'obtiens ceci:

4 % ./segv
zsh: 13512 segmentation fault  ./segv

Lorsque je l'exécute à partir de bash, j'obtiens ce que vous avez noté dans votre question:

bediger@flq123:csrc % ./segv
Segmentation fault

J'allais écrire un gestionnaire de signaux dans mon code, puis je me suis rendu compte que l'appel de bibliothèque system() utilisé par crsh est un Shell, /bin/sh Selon man 3 system. Ce /bin/sh Imprime presque certainement un "défaut de segmentation", puisque crsh ne l'est certainement pas.

Si vous réécrivez crsh pour utiliser l'appel système execve() pour exécuter le programme, vous ne verrez pas la chaîne "Erreur de segmentation". Il provient du Shell invoqué par system().

42
Bruce Ediger

Je n'arrive pas à trouver d'informations à ce sujet à part "le CPU MMU envoie un signal" et "le noyau le dirige vers le programme incriminé, le terminant").

Ceci est un peu un résumé tronqué. Le mécanisme de signal Unix est entièrement différent des événements spécifiques au CPU qui démarrent le processus.

En général, lorsqu'une mauvaise adresse est accédée (ou écrite dans une zone en lecture seule, essayez d'exécuter une section non exécutable, etc.), le CPU génère un événement spécifique au CPU (sur les architectures traditionnelles non-VM, c'était appelé violation de segmentation, car chaque "segment" (traditionnellement, le "texte" exécutable en lecture seule, les "données" inscriptibles et de longueur variable, et la pile traditionnellement à l'extrémité opposée de la mémoire) avait une plage d'adresses fixe - sur une architecture moderne, il est plus probable qu'il s'agisse d'une erreur de page [pour la mémoire non mappée] ou d'une violation d'accès [pour les problèmes d'autorisation de lecture, d'écriture et d'exécution], et je vais me concentrer sur cela pour le reste de la réponse).

Maintenant, à ce stade, le noyau peut faire plusieurs choses. Des erreurs de page sont également générées pour la mémoire qui est valide mais non chargée (par exemple permutée, ou dans un fichier mmappé, etc.), et dans ce cas, le noyau mappera la mémoire, puis redémarrera le programme utilisateur à partir de l'instruction qui a provoqué la Erreur. Sinon, il envoie un signal. Cela ne "dirige pas [l'événement d'origine] vers le programme incriminé", car le processus d'installation d'un gestionnaire de signaux est différent et principalement indépendant de l'architecture, par rapport à si le programme devait simuler l'installation d'un gestionnaire d'interruptions.

Si le programme utilisateur a un gestionnaire de signaux installé, cela signifie créer un cadre de pile et définir la position d'exécution du programme utilisateur sur le gestionnaire de signaux. La même chose est faite pour tous les signaux, mais dans le cas d'une violation de segmentation, les choses sont généralement arrangées de sorte que si le gestionnaire de signal retourne, il redémarrera l'instruction qui a causé l'erreur. Le programme utilisateur peut avoir corrigé l'erreur, par ex. en mappant la mémoire à l'adresse incriminée - cela dépend de l'architecture si cela est possible). Le gestionnaire de signal peut également accéder à un emplacement différent dans le programme (généralement via longjmp ou en lançant une exception), pour abandonner toute opération à l'origine du mauvais accès à la mémoire.

Si le programme utilisateur n'a pas de gestionnaire de signal installé, il est simplement arrêté. Sur certaines architectures, si le signal est ignoré, il peut recommencer l'instruction encore et encore, provoquant une boucle infinie.

21
Random832

Un défaut de segmentation est un accès à une adresse mémoire non autorisée (ne faisant pas partie du processus, ou essayant d'écrire des données en lecture seule, ou d'exécuter des données non exécutables, ...). Ceci est détecté par le MMU (unité de gestion de la mémoire, qui fait aujourd'hui partie du CPU), provoquant une interruption. L'interruption est gérée par le noyau, qui envoie un signal SIGSEGFAULT ( voir signal(2) par exemple) au processus incriminé. Le gestionnaire par défaut de ce signal vide le noyau (voir core(5)) et termine le processus.

Le Shell n'a absolument rien à y voir.

18
vonbrand