Comment une erreur de segmentation fonctionne-t-elle sous le capot?

266

Il semble que je ne trouve aucune information à ce sujet en dehors de "la MMU de la CPU envoie un signal" et "le noyau le dirige vers le programme incriminé, en le mettant fin".

J'ai supposé qu'il envoie probablement le signal au shell et que celui-ci 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 (crap shell). Ce shell ne fait rien sauf prendre les entrées utilisateur et les alimenter à la system()méthode.

#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é cette coquille dans un terminal nu (sans bashcourir en dessous). Ensuite, j'ai procédé à l'exécution d'un programme qui produit une erreur de segmentation. Si mes hypothèses étaient correctes, cela provoquerait a) un crash crsh, la fermeture du xterm, b) pas d'impression "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 cela, mais le système situé en dessous. Comment "Erreur 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 du programme?

Braden Best
la source
43
crshest une excellente idée pour ce genre d’expérimentation. Merci de nous avoir tous informés de cette idée et de son idée.
Bruce Ediger
30
Quand j'ai vu pour la première fois crsh, j'ai pensé qu'il serait prononcé "crash". Je ne suis pas sûr que ce soit un nom tout aussi approprié.
Jpmc26
56
C'est une belle expérience ... mais vous devez savoir ce qui se system()passe sous le capot. Il s'avère que cela system()va engendrer un processus shell! Ainsi, votre processus shell génère un autre processus shell et ce processus (probablement /bin/shou quelque chose du genre) est celui qui exécute le programme. La manière /bin/shou bashfonctionne est en utilisant fork()et exec()(ou une autre fonction dans la execve()famille).
Dietrich Epp
4
@BradenBest: Exactement. Lisez la page de manuel man 2 wait, elle inclura les macros WIFSIGNALED()et WTERMSIG().
Dietrich Epp
4
@DietrichEpp Tout comme vous l'avez dit! J'ai essayé d'ajouter un chèque pour l' (WIFSIGNALED(status) && WTERMSIG(status) == 11)avoir imprimer quelque chose de maladroit ( "YOU DUN GOOFED AND TRIGGERED A SEGFAULT"). Quand j'ai exécuté le segfaultprogramme de l'intérieur crsh, il imprimait exactement cela. En attendant, les commandes qui se terminent normalement ne produisent pas le message d'erreur.
Braden Best

Réponses:

248

Tous les processeurs modernes ont la capacité d' interrompre l'instruction machine en cours d'exécution. Ils sauvegardent suffisamment d’état (généralement, mais pas toujours, sur la pile) pour permettre la reprise ultérieure de l’exécution, comme si rien ne s’était passé (l’instruction interrompue sera redémarrée à partir de zéro, en général). Ensuite, ils commencent à exécuter un gestionnaire d’interruptions , qui est juste plus de code machine, mais placé à un emplacement spécial afin que la CPU sache où elle se trouve à l’avance. Les gestionnaires d'interruption 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 , c'est-à-dire qu'elles sont déclenchées par la CPU elle-même en réponse directe à l'action de l'instruction en cours d'exécution, ou asynchrones , ce qui signifie qu'elles se produisent à un moment imprévisible en raison d'un événement externe, tel que les données arrivant sur le réseau. Port. Certaines personnes réservent le terme "interruption" pour les interruptions asynchrones et appellent des interruptions synchrones "traps", "faults" ou "exceptions", mais ces mots ont tous une autre signification, alors je vais m'en tenir à "interruption synchrone".

Aujourd'hui, la plupart des systèmes d'exploitation modernes ont une notion de processus . Il s’agit essentiellement d’un mécanisme selon lequel l’ordinateur peut exécuter plus d’un programme à la fois, mais c’est aussi un aspect essentiel de la façon dont les systèmes d’exploitation configurent la protection de la mémoire , caractéristique de la plupart des logiciels (mais, hélas, toujours pas tous ) les processeurs modernes. Cela va avec la mémoire virtuelle, qui permet 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 il est le seul à pouvoir accéder. Il permet également au système d’exploitation (agissant pour le compte de certains processus) de désigner des régions de RAM comme étant en lecture seule, exécutables, partagées par un groupe de processus coopérants, etc. Il y aura également un bloc de mémoire uniquement accessible par le serveur. noyau. 3

Tant que chaque processus n'accède à la mémoire que de la manière que le processeur est configuré pour autoriser, la protection de la mémoire est invisible. Lorsqu'un processus enfreint les règles, le processeur génère une interruption synchrone, demandant au noyau de résoudre le problème. Il arrive régulièrement que le processus n'enfreigne pas vraiment les règles, seul le noyau doit effectuer certains travaux avant que le processus puisse continuer. Par exemple, si une page de la mémoire d'un processus doit être "expulsée" dans le fichier d'échange afin de libérer de l'espace dans la RAM pour autre chose, le noyau indiquera que cette page est inaccessible. La prochaine fois que le processus essaiera de l'utiliser, la CPU générera une interruption de protection de la mémoire. le noyau récupérera la page de swap, la remettra à sa place, la marquera de nouveau comme accessible et reprendra son exécution.

Mais supposons que le processus ait réellement enfreint les règles. Il a essayé d'accéder à une page sur laquelle aucune RAM n'a été mappée, ou a essayé d'exécuter une page marquée comme ne contenant pas de code machine, ou autre. La famille de systèmes d'exploitation généralement connue sous le nom "Unix" utilise tous des signaux pour faire face à cette situation. 4 Les signaux ressemblent aux interruptions, mais ils sont générés par le noyau et mis en champs par des processus, plutôt que par le matériel et par le noyau. Les processus peuvent définir des gestionnaires de signauxdans leur propre code, et dire au noyau où ils se trouvent. Ces gestionnaires de signaux s’exécuteront ensuite, interrompant le flux de contrôle normal, si nécessaire. Les signaux ont tous un numéro et deux noms, l'un étant un acronyme cryptique et l'autre une phrase légèrement moins cryptique. Le signal généré lorsque le processus enfreint les règles de protection de la mémoire est le numéro 11 (par convention), ainsi que ses noms SIGSEGVet "Défaut de segmentation". 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. Tout l'ordinateur se bloque lorsque le processeur tente d'appeler un gestionnaire manquant. Cependant, les processus ne sont nullement tenus de définir des gestionnaires de signaux pour tous les signaux. Si le noyau génère un signal pour un processus et que celui-ci a conservé son comportement par défaut, le noyau s'exécutera comme il se doit, quelle que soit la valeur par défaut, sans déranger le processus. Les comportements par défaut de la plupart des signaux sont "ne rien faire" ou "mettre fin à ce processus et peut-être aussi produire un vidage de la base". SIGSEGVest l'un de ces derniers.

Donc, pour récapituler, nous avons un processus qui a brisé les règles de protection de la mémoire. La CPU a suspendu le processus et généré une interruption synchrone. Le noyau a mis en place cette interruption et généré un SIGSEGVsignal pour le processus. Supposons que le processus n'ait pas configuré de gestionnaire de signal SIGSEGV, le noyau applique donc le comportement par défaut, qui consiste à mettre fin au processus. Cela a tous les mêmes effets que l' _exitappel système: les fichiers ouverts sont fermés, la mémoire est désallouée, etc.

Jusque-là, rien n'a encore imprimé de message visible par un humain, et le shell (ou plus généralement, le processus parent du processus qui vient d'être terminé) n'a pas été impliqué du tout. SIGSEGVva au processus qui a enfreint les règles, pas son parent. L' étape suivante de la séquence consiste toutefois à informer le processus parent que son enfant a été arrêté. Cela peut se produire de plusieurs façons différentes, dont la plus simple est quand le parent attend déjà cette notification, en utilisant l' un des waitappels système ( wait, waitpid, wait4, etc.). Dans ce cas, le noyau ne fera que renvoyer cet appel système et fournira au processus parent un numéro de code appelé statut de sortie.. 7 Le statut de sortie indique au parent pourquoi le processus enfant a été arrêté. dans ce cas, il apprendra que l'enfant a été arrêté en raison du comportement par défaut d'un SIGSEGVsignal.

Le processus parent peut ensuite signaler l'événement à un humain en imprimant un message. les programmes shell le font presque toujours. Votre crshcode n'inclut pas cela, mais cela arrive quand même, parce que la routine de la bibliothèque C systemexécute un shell complet /bin/sh, "sous le capot". crshest le grand - parent dans ce scénario; la notification de processus parent est remplie par /bin/sh, ce qui affiche son message habituel. Ensuite, /bin/shelle quitte elle-même, car elle n'a plus rien à faire, et l'implémentation de la bibliothèque C de systemreçoit cette notification de sortie. Vous pouvez voir cette notification de sortie dans votre code en inspectant la valeur de retour desystem; mais cela ne vous dira pas que le processus de petit-enfant est mort sur un segfault, car il a été consommé par le processus de 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 noyau; Cependant, tous les gestionnaires d'interruptions doivent toujours faire partie du noyau, de même que le code qui configure la protection de la mémoire, car le matériel ne permet rien d'autre que le noyau de faire cela.

  2. Il peut exister un programme appelé "hyperviseur" ou "gestionnaire de machine virtuelle" encore plus privilégié que le noyau, mais aux fins 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 des parties du code du noyau, de temps en temps, en plus de leur propre code. Il peut y avoir un certain nombre de "threads du noyau" qui n'exécutent que le code du noyau, mais ils ne nous concernent pas ici.

  4. Le seul et unique système d'exploitation que vous aurez probablement à traiter et qui ne peut pas être considéré comme une implémentation d'Unix est bien entendu Windows. Il n'utilise pas de signaux dans cette situation. ( En effet, il n'a pas avoir des signaux, sous Windows l' <signal.h>interface est complètement truqué par la bibliothèque C.) Il utilise ce qu'on appelle « gestion structurée des exceptions » au lieu.

  5. Certaines violations de la 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 définissant un gestionnaire SIGSEGV, c’est probablement une bonne idée de définir le même gestionnaire SIGBUS.

  6. "Erreur de segmentation" est 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 système Unix d'origine , probablement le PDP-11 . La " segmentation " est un type de protection de la mémoire, mais de nos jours le terme " erreur de segmentation " désigne de manière générique toute violation de la protection de la mémoire.

  7. Tous les autres moyens par lesquels le processus parent peut être averti qu'un enfant s'est terminé, aboutissent avec l'appel du parent waitet la réception d'un statut de sortie. C'est juste que quelque chose d'autre se passe en premier.

zwol
la source
@zvol: ad 2) Je ne pense pas qu'il soit correct de dire que le processeur connaît tout des processus. Vous devriez dire qu'il appelle un gestionnaire d'interruption, qui transfère le contrôle.
user323094
9
@ user323094 Les processeurs multicœurs modernes en savent assez sur les processus; assez pour que, dans cette situation, ils ne puissent suspendre que le thread d'exécution qui a déclenché le défaut de protection de la mémoire. De plus, j'essayais de ne pas entrer dans les détails de bas niveau. Du point de vue du programmeur d’espace utilisateur, la chose la plus importante à comprendre à l’étape 2 est que c’est le matériel qui détecte les violations de la protection de la mémoire; il en va de même pour la répartition précise du travail entre le matériel, le micrologiciel et le système d’exploitation lorsqu’il s’agit d’identifier le "processus incriminé".
dimanche
Une autre subtilité susceptible de semer la confusion chez un lecteur naïf est "Le noyau envoie un signal SIGSEGV au processus incriminé." qui utilise le jargon habituel, mais signifie en réalité que le noyau se dit lui-même de traiter le signal foo on process bar (c’est-à-dire que le code utilisateur n’est pas impliqué sauf s’il existe un gestionnaire de signal installé, une question résolue par le noyau). Je préfère parfois "déclencher un signal SIGSEGV sur le processus" pour cette raison.
dmckee
2
La différence significative entre SIGBUS (erreur de bus) et SIGSEGV (erreur de segmentation) est la suivante: SIGSEGV se produit lorsque la CPU sait que vous ne devez pas accéder à une adresse (et ne fait donc aucune demande de bus de mémoire externe). SIGBUS se produit lorsque la CPU ne découvre le problème d’adressage qu’après avoir placé votre demande sur son bus d’adresses externe. Par exemple, demander une adresse physique à laquelle rien sur le bus ne répond, ou demander de lire des données sur une limite mal alignée (ce qui nécessiterait deux requêtes physiques pour obtenir une au lieu d'une)
Stuart Caie le
2
@StuartCaie Vous décrivez le comportement des interruptions ; En effet, de nombreux processeurs font la distinction que vous décrivez (bien que certains ne le fassent pas, et la ligne entre les deux varie). Les signaux SIGSEGV et SIGBUS ne sont toutefois pas mappés de manière fiable sur ces deux conditions de niveau CPU. La seule condition dans laquelle POSIX requiert SIGBUS plutôt que SIGSEGV est lorsque vous insérez mmapun fichier dans une région de mémoire plus grande que le fichier, puis accédez à des "pages entières" au-delà de la fin du fichier. (POSIX est par ailleurs assez vague quand SIGSEGV / SIGBUS / SIGILL / etc arriver.)
Zwol
42

Le shell a effectivement quelque chose à voir avec ce message et crshappelle indirectement un shell, ce qui est probablement le cas bash.

J'ai écrit un petit programme en C qui fait toujours la distinction entre les fautes:

#include <stdio.h>

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

        *i = 12;

        return 0;
}

Quand je le lance à partir de mon shell par défaut zsh, je reçois ceci:

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

Quand je le lance à partir de bash, je comprends ce que vous avez noté dans votre question:

bediger@flq123:csrc % ./segv
Segmentation fault

J'allais écrire un gestionnaire de signal dans mon code, puis je me suis rendu compte que l' system()appel de bibliothèque utilisé par crshexec est un shell, /bin/shselon man 3 system. C’est /bin/shpresque certainement l’impression «faute de segmentation», ce crshn’est certainement pas le cas.

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

Bruce Ediger
la source
5
Je discutais de cela avec Dietrich Epp. J'ai piraté ensemble une version de crsh qui utilise execvpet refait le test pour constater que, même si le shell ne plante toujours pas (ce qui signifie que SIGSEGV n'est jamais envoyé au shell), il n'imprime pas "Erreur de segmentation". Rien n'est imprimé du tout. Cela semble indiquer que le shell détecte le moment où ses processus enfants sont tués et est responsable de l’impression "Défaut de segmentation" (ou une de ses variantes).
Braden Best
2
@BradenBest - J'ai fait la même chose, mon code est plus complexe que votre code. Je n'ai aucun message du tout, et ma coquille encore plus affreuse n'imprime rien. J'ai utilisé waitpid()sur chaque fork / exec, et il renvoie une valeur différente pour les processus qui ont une erreur de segmentation, par rapport aux processus qui se terminent avec le statut 0.
Bruce Ediger
21

Il semble que je ne trouve aucune information à ce sujet en dehors de "la MMU de la CPU envoie un signal" et "le noyau le dirige vers le programme incriminé, en le mettant fin".

C'est un peu un résumé tronqué. Le mécanisme de signal Unix est entièrement différent des événements spécifiques à la CPU qui lancent le processus.

En général, quand une adresse incorrecte est accédée (ou écrite dans une zone en lecture seule, une tentative d'exécution d'une section non exécutable, etc.), la CPU génère un événement spécifique à la CPU (sur les architectures traditionnelles non-VM). 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 située à 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 de lecture, d'écriture et d'exécution, et je me concentrerai sur cela pour le reste de la réponse).

Maintenant, à ce stade, le noyau peut faire plusieurs choses. Des défauts de page sont également générés pour la mémoire valide mais non chargée (par exemple, permutée, ou dans un fichier mmapped, etc.). Dans ce cas, le noyau mappera la mémoire, puis redémarrera le programme utilisateur à partir de l'instruction Erreur. Sinon, cela envoie un signal. Cela ne signifie pas "directement [l'événement d'origine] vers le programme incriminé", car le processus d'installation d'un gestionnaire de signaux est différent et indépendant de l'architecture, plutôt que si le programme devait simuler l'installation d'un gestionnaire d'interruptions.

Si un programme de traitement de signal est installé dans le programme utilisateur, cela signifie que vous créez un cadre de pile et que vous définissez la position d'exécution du programme utilisateur sur le programme de traitement de signal. 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 signaux le renvoie, il relancera l’instruction qui a causé l’erreur. Le programme utilisateur peut avoir corrigé l'erreur, par exemple en mappant la mémoire sur l'adresse incriminée (cela dépend de l'architecture si cela est possible). Le gestionnaire de signaux peut également accéder à un emplacement différent du programme (généralement via longjmp ou en lançant une exception), pour abandonner l'opération, quelle qu'elle soit, à l'origine du mauvais accès à la mémoire.

Si aucun programme de traitement de signal n'est installé dans le programme utilisateur, il est simplement terminé. Sur certaines architectures, si le signal est ignoré, il peut redémarrer l'instruction encore et encore, provoquant une boucle infinie.

Au hasard832
la source
+1, seule réponse qui ajoute quelque chose à celui accepté. Belle description de l'histoire de la "segmentation". Fait amusant: x86 a toujours des limites de segment en mode protégé 32 bits (avec ou sans pagination (mémoire virtuelle) activée), de sorte que les instructions pouvant accéder à la mémoire peuvent générer #PF(fault-code)(défaut de page) ou #GP(0)("Si une adresse effective d'opérande de mémoire est en dehors du CS, Limite de segment DS, ES, FS ou GS. "). Le mode 64 bits supprime les contrôles de limite de segment, car les systèmes d’exploitation utilisent uniquement la pagination et un modèle de mémoire à plat pour l’espace utilisateur.
Peter Cordes
En fait, je pense que la plupart des systèmes d’exploitation sous x86 utilisent une pagination segmentée: un groupe de gros segments dans un espace adresse plat et paginé. C’est ainsi que vous protégez et mappez la mémoire du noyau dans chaque espace adresse: les anneaux (niveaux de protection) sont liés à des segments, pas à des pages
Lorenzo Dematté
Aussi, sur NT (mais j'aimerais savoir si sur la plupart des Unix, c'est pareil!), Une "erreur de segmentation" peut arriver assez souvent: il y a un segment protégé de 64k au début de l'espace utilisateur, donc le déréférencement d'un pointeur NULL soulève une (bon?) faute de segmentation
Lorenzo Dematté le
1
@ LorenzoDematté Oui, tous ou presque tous les Unix modernes laisseront un bloc d'adresses non mappées en permanence au début de l'espace d'adressage afin d'attraper les déréférences NULL. Il peut être assez volumineux - sur les systèmes 64 bits, en fait, il peut atteindre quatre gigaoctets , de sorte que la troncature accidentelle des pointeurs sur 32 bits sera rapidement interceptée. Cependant, la segmentation au sens strict du x86 est à peine utilisée; il existe un segment plat pour l’espace utilisateur et un pour le noyau, et peut-être quelques-unes des astuces spéciales consistant à utiliser certains systèmes FS et GS.
dimanche
1
@ LorenzoDematté NT utilise des exceptions plutôt que des signaux; dans ce cas, STATUS_ACCESS_VIOLATION.
Random832
18

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

La coquille n'a absolument aucune main dans ceci.

vonbrand
la source
3
Donc, votre bibliothèque C, comme glibc sur un bureau, définit la chaîne?
Drewbenn
7
Il est également intéressant de noter que SIGSEGV peut être manipulé / ignoré. Il est donc possible d'écrire un programme qui ne soit pas arrêté par celui-ci. La machine virtuelle Java est un exemple remarquable qui utilise SIGSEGV en interne à des fins différentes, comme mentionné ici: stackoverflow.com/questions/3731784/...
Nowak Karol
2
De même, sous Windows, .NET ne se contente pas d'ajouter des vérifications de pointeur nul dans la plupart des cas - il détecte uniquement les violations d'accès (équivalentes aux segfaults).
Immibis