web-dev-qa-db-fra.com

Débogage de la corruption de mémoire

Tout d'abord, je réalise que ce n'est pas une question de style Q&A parfaite avec une réponse absolue, mais je ne peux penser à aucun libellé pour le faire fonctionner mieux. Je ne pense pas qu'il existe une solution absolue à cela et c'est l'une des raisons pour lesquelles je le poste ici au lieu de Stack Overflow.

Au cours du dernier mois, j'ai réécrit un morceau de code de serveur (mmorpg) assez ancien pour être plus moderne et plus facile à étendre/modifier. J'ai commencé avec la partie réseau et j'ai implémenté une bibliothèque tierce (libevent) pour gérer les choses pour moi. Avec tous les changements de refactorisation et de code, j'ai introduit une corruption de mémoire quelque part et j'ai eu du mal à savoir où cela se produit.

Je n'arrive pas à le reproduire de manière fiable sur mon environnement de développement/test, même lorsque j'implémente des bots primitifs pour simuler une charge, je ne reçois plus de plantages (j'ai résolu un problème de libevent qui a causé certaines choses)

J'ai essayé jusqu'à présent:

Valgrinding the hell out of it - Aucun invalide n'écrit jusqu'à ce que la chose se bloque (ce qui peut prendre 1+ jour de production .. ou juste une heure) ce qui me déroute vraiment, à un moment donné, il accèderait à une mémoire invalide et n'écraserait pas les choses par chance? (Existe-t-il un moyen de "répartir" la plage d'adresses?)

Outils d'analyse de code, à savoir couverture et cppcheck. Bien qu'ils aient signalé certains cas de ... méchanceté et Edge dans le code, il n'y avait rien de grave.

Enregistrer le processus jusqu'à ce qu'il se bloque avec gdb (via undodb), puis revenir en arrière. Cela/sonne/comme cela devrait être faisable, mais je finis par planter gdb en utilisant la fonction de remplissage automatique ou je me retrouve dans une structure de libevent interne où je me perds car il y a trop de branches possibles (une corruption en provoque une autre, etc.) sur). Je suppose que ce serait bien si je pouvais voir à quoi appartient un pointeur à l'origine/où il a été alloué, cela éliminerait la plupart des problèmes de branchement. Je ne peux pas exécuter valgrind avec undodb cependant, et je l'enregistrement gdb normal est anormalement lent (si cela fonctionne même en combinaison avec valgrind).

Revue de code! Par moi-même (à fond) et en demandant à quelques amis de regarder mon code, bien que je doute qu'il soit suffisamment approfondi. Je pensais peut-être embaucher un développeur pour faire un examen/débogage du code avec moi, mais je ne peux pas me permettre de mettre trop d'argent et je ne saurais pas où chercher quelqu'un qui serait prêt à travailler pour peu- pas d'argent s'il ne trouve pas le problème ou si quelqu'un est qualifié du tout.

Je dois également noter: j'obtiens généralement des backtraces cohérentes. Il y a quelques endroits où le crash se produit, principalement lié à la classe de socket qui est corrompue d'une manière ou d'une autre. Que ce soit un pointeur invalide pointant vers quelque chose qui n'est pas un socket ou la classe de socket elle-même devenant écrasée (partiellement?) Par du charabia. Bien que je soupçonne qu'il y plante le plus car c'est l'une des parties les plus utilisées, c'est donc la première mémoire corrompue qui est utilisée.

Dans l'ensemble, ce problème m'a occupé pendant près de 2 mois (allumé et éteint, plus un projet de loisir) et me frustre vraiment au point où je deviens grincheux IRL et que je pense à abandonner. Je ne peux pas penser à quoi d'autre je suis censé faire pour trouver le problème.

Y a-t-il des techniques utiles que j'ai manquées? Comment gérez-vous cela? (Cela ne peut pas être si courant car il n'y a pas beaucoup d'informations à ce sujet .. ou je suis vraiment vraiment aveugle?)

Éditer:

Quelques spécifications au cas où cela compte:

Utiliser c ++ (11) via gcc 4.7 (version fournie par debian wheezy)

La base de code est d'environ 150 000 lignes

Modifier en réponse au message de david.pfx: (désolé pour la réponse lente)

Conservez-vous des enregistrements minutieux des accidents, pour rechercher des modèles?

Oui, j'ai encore des décharges des récents accidents qui traînent

Les quelques endroits sont-ils vraiment similaires? De quelle manière?

Eh bien, dans la version la plus récente (ils semblent changer chaque fois que j'ajoute/supprime du code ou modifie des structures connexes), il serait toujours pris dans une méthode de minuterie d'élément. Fondamentalement, un élément a une heure spécifique après laquelle il expire et il envoie des informations mises à jour au client. Le pointeur de socket invalide serait dans la classe Player (toujours valide pour autant que je sache), principalement liée à cela. Je subis également des charges de plantage dans la phase de nettoyage, après l'arrêt normal où il détruit toutes les classes statiques qui n'ont pas été explicitement détruites (__run_exit_handlers Dans la trace). Impliquant principalement std::map D'une classe, devinant que ce n'est que la première chose qui arrive.

À quoi ressemblent les données corrompues? Zéros? Ascii? Motifs?

Je n'ai pas encore trouvé de motifs, cela me semble quelque peu aléatoire. C'est difficile à dire car je ne sais pas où la corruption a commencé.

Est-ce lié au tas?

Il est entièrement lié au tas (j'ai activé le garde de pile de gcc et cela n'a rien détecté).

La corruption se produit-elle après une free()?

Vous devrez élaborer un peu là-dessus. Voulez-vous dire avoir des pointeurs d'objets déjà libres qui traînent? Je mets chaque référence à null une fois que l'objet est détruit, donc à moins que je manque quelque chose quelque part, non. Cela devrait apparaître dans Valgrind, mais ce n'est pas le cas.

Y a-t-il quelque chose de distinct dans le trafic réseau (taille du tampon, cycle de récupération)?

Le trafic réseau est constitué de données brutes. Donc, les tableaux char, (u) intX_t ou les structures emballées (pour supprimer le remplissage) pour les choses plus complexes, chaque paquet a un en-tête composé d'un identifiant et de la taille du paquet elle-même qui est validée par rapport à la taille attendue. Ils font environ 10 à 60 octets, le plus gros (paquet de démarrage interne, tiré une fois au démarrage) ayant une taille de quelques Mo.

Beaucoup, beaucoup d'affirmations de production. Crash précoce et prévisible avant que les dégâts ne se propagent.

J'ai eu une fois un crash lié à la corruption de std::map, Chaque entité a une carte de sa "vue", chaque entité qui peut le voir et vice versa est dedans. J'ai ajouté un tampon de 200 octets devant et après, l'ai rempli avec 0x33 et l'ai vérifié avant chaque accès. La corruption a disparu comme par magie, j'ai dû déplacer quelque chose qui l'a fait corrompre autre chose.

Journalisation stratégique, pour que vous sachiez exactement ce qui se passait juste avant. Ajoutez à la journalisation à mesure que vous vous rapprochez d'une réponse.

Cela fonctionne .. dans une large mesure.

En désespoir de cause, pouvez-vous enregistrer l'état et redémarrer automatiquement? Je peux penser à quelques logiciels de production qui font cela.

Je fais un peu ça. Le logiciel se compose d'un processus principal de "cache" et de certains autres processus ouvriers qui accèdent tous au cache pour obtenir et enregistrer des éléments. Donc, par crash, je ne perds pas beaucoup de progrès, cela déconnecte toujours tous les utilisateurs et ainsi de suite, ce n'est certainement pas une solution.

Concurrence: threading, conditions de concurrence, etc.

Il y a un thread mysql pour faire des requêtes "asynchrones", tout cela reste intact et ne partage les informations avec la classe de base de données que via des fonctions avec tous les verrous.

Les interruptions

Il y a une minuterie d'interruption pour l'empêcher de se verrouiller qui s'arrête simplement si elle n'a pas terminé un cycle pendant 30 secondes, ce code devrait cependant être sûr:

if (!tics) {
    abort();
} else
    tics = 0;

tics est volatile int tics = 0; qui augmente chaque fois qu'un cycle est terminé. Ancien code aussi.

événements/rappels/exceptions: état de corruption ou pile imprévisible

Beaucoup de rappels sont utilisés (E/S réseau asynchrone, temporisateurs), mais ils ne devraient rien faire de mal.

Données inhabituelles: données d'entrée/calendrier/état inhabituels

J'ai eu quelques cas Edge liés à cela. La déconnexion d'un socket pendant que les paquets sont encore en cours de traitement a permis d'accéder à un nullptr et autres, mais ceux-ci ont été faciles à repérer jusqu'à présent puisque chaque référence est nettoyée juste après avoir dit à la classe elle-même que c'est fait. (La destruction elle-même est gérée par une boucle supprimant tous les objets détruits à chaque cycle)

Dépendance à l'égard d'un processus externe asynchrone.

Envie d'élaborer? C'est un peu le cas, le processus de cache mentionné ci-dessus. La seule chose que je pouvais imaginer du haut de ma tête serait de ne pas terminer assez rapidement et d'utiliser des données poubelles, mais ce n'est pas le cas car cela utilise également le réseau. Même modèle de paquet.

23
Robin

C'est un problème difficile, mais je pense qu'il y a beaucoup plus d'indices à trouver dans les accidents que vous avez déjà vus.

  • Conservez-vous des enregistrements minutieux des accidents, pour rechercher des modèles?
  • Les quelques endroits sont-ils vraiment similaires? De quelle manière?
  • À quoi ressemblent les données corrompues? Zéros? Ascii? Motifs?
  • Y a-t-il des multi-threads impliqués? Serait-ce une condition de concurrence?
  • Est-ce lié au tas? La corruption se produit-elle après un free ()?
  • Est-ce lié à la pile? La pile est-elle corrompue?
  • Une référence pendante est-elle possible? Une valeur de données qui a mystérieusement changé?
  • Y a-t-il quelque chose de distinct dans le trafic réseau (taille du tampon, cycle de récupération)?

Des choses que nous avons utilisées dans des situations similaires.

  • Beaucoup, beaucoup d'affirmations de production. Crash précoce et prévisible avant que les dégâts ne se propagent.
  • Beaucoup, beaucoup de gardes. Les éléments de données supplémentaires avant et après les variables locales, les objets et les mallocs () sont définis sur une valeur, puis vérifiés fréquemment.
  • Journalisation stratégique, pour que vous sachiez exactement ce qui se passait juste avant. Ajoutez à la journalisation à mesure que vous vous rapprochez d'une réponse.

En désespoir de cause, pouvez-vous enregistrer l'état et redémarrer automatiquement? Je peux penser à quelques logiciels de production qui font cela.

N'hésitez pas à ajouter des détails si nous pouvons vous aider.


Puis-je simplement ajouter que des bogues sérieusement indéterminés comme celui-ci ne sont pas si communs et qu'il n'y a pas beaucoup de choses qui peuvent (généralement) les provoquer. Ils comprennent:

  • Concurrence: threading, conditions de concurrence, etc.
  • Interruptions/événements/rappels/exceptions: état de corruption ou pile imprévisible
  • Données inhabituelles: données d'entrée/calendrier/état inhabituels
  • Dépendance à l'égard d'un processus externe asynchrone.

Ce sont les parties du code sur lesquelles se concentrer.

21
david.pfx

Utilisez une version de débogage de malloc/free. Enveloppez-les et écrivez les vôtres si nécessaire. Beaucoup d'amusement!

La version que j'utilise ajoute des octets de garde avant et après chaque allocation, et maintient une liste "allouée" qui vérifie gratuitement les morceaux libérés. Cela intercepte la plupart des dépassements de tampon et des erreurs multiples ou voyantes "libres".

L'une des sources de corruption les plus insidieuses continue d'utiliser un morceau après sa libération. Free devrait remplir la mémoire libérée avec un modèle connu (traditionnellement, 0xDEADBEEF). Cela aide si les structures allouées incluent un élément "nombre magique" et incluent généreusement les vérifications du nombre magique approprié avant d'utiliser une structure.

6
ddyer

Tout ce qui a été dit dans les autres réponses est très pertinent. Une chose importante en partie mentionnée par ddyer est que l'emballage malloc/free a des avantages. Il en mentionne quelques-uns mais j'aimerais ajouter un outil de débogage très important à cela: vous pouvez connecter chaque malloc/free dans un fichier externe avec quelques lignes de pile d'appels (ou la pile complète d'appels si vous le souhaitez). Si vous faites attention, vous pouvez facilement le faire assez rapidement et l'utiliser en production s'il le faut.

D'après ce que vous décrivez, ma supposition personnelle est que vous pourriez garder une référence à un pointeur quelque part pour libérer de la mémoire et finir par libérer un pointeur qui ne vous appartient plus ou y écrire. Si vous pouvez déduire une plage de tailles à surveiller avec la technique ci-dessus, vous devriez être en mesure de réduire considérablement la journalisation. Sinon, une fois que vous avez trouvé la mémoire corrompue, vous pouvez déterminer le modèle malloc/free qui l'a conduit assez facilement à partir des journaux.

Une note importante est que, comme vous le mentionnez, la modification de la disposition de la mémoire peut masquer le problème. Il est donc très important que votre journalisation ne fasse aucune allocation (si vous le pouvez!) Ou aussi peu que possible. Cela aidera à la reproductibilité si elle est liée à la mémoire. Il sera également utile s'il est aussi rapide que possible si le problème est lié au multithread.

Il est également important que vous trappiez les allocations des bibliothèques tierces afin de pouvoir également les enregistrer correctement. On ne sait jamais d'où cela pourrait provenir.

Comme dernière alternative, vous pouvez également créer un allocateur personnalisé dans lequel vous allouez au moins 2 pages pour chaque allocation et les démapper lorsque vous libérez (aligner l'allocation sur une limite de page, allouer une page avant et la marquer comme non accessible ou aligner le allouer à la fin d'une page et allouer une page après et marquer n'est pas accessible). Assurez-vous de ne pas réutiliser ces adresses de mémoire virtuelle pour de nouvelles allocations pendant au moins un certain temps. Cela implique que vous devrez gérer vous-même votre mémoire virtuelle (réservez-la et utilisez-la comme vous le souhaitez). Notez que cela dégradera vos performances et pourrait finir par utiliser des quantités importantes de mémoire virtuelle en fonction du nombre d'allocations que vous l'alimentez. Pour atténuer cela, cela vous aidera si vous pouvez exécuter en 64 bits et/ou réduire la plage d'allocations qui en ont besoin (en fonction de la taille). Valgrind pourrait très bien déjà le faire, mais il pourrait être trop lent pour vous de résoudre le problème. Faire cela uniquement pour quelques tailles ou objets (si vous savez lesquels, vous pouvez utiliser l'allocateur spécial uniquement pour ces objets) garantira un impact minimal sur les performances.

3
Nicholas Frechette

Pour paraphraser ce que vous dites dans votre question, il n'est pas possible de vous donner une réponse définitive. Le mieux que nous puissions faire est de suggérer des choses à rechercher et des outils et des techniques.

Certaines suggestions sembleront naïves, d'autres peuvent sembler plus applicables, mais j'espère que l'une déclenchera une réflexion que vous pourrez suivre. Je dois dire que le réponse de david.pfx a de bons conseils et suggestions.

Des symptômes

  • pour moi, cela ressemble à un dépassement de tampon.

  • un problème connexe utilise des données de socket non validées comme indice ou clé, etc.

  • est-il possible que vous utilisiez une variable globale quelque part, ou que vous ayez une variable globale et locale du même nom, ou que les données d'un joueur interfèrent d'une manière ou d'une autre?

Comme pour de nombreux bogues, vous faites probablement une hypothèse invalide quelque part. Ou peut-être plus d'un. Les erreurs d'interaction multiples sont difficiles à détecter.

  • Chaque variable a-t-elle une description? Et pouvez-vous définir une affirmation de validité?
    Si vous ne les ajoutez pas, parcourez le code pour voir que chaque variable semble être utilisée correctement. Ajoutez cette affirmation partout où cela a du sens.

  • La suggestion d'ajouter une assertion de lots est bonne: le premier endroit pour les mettre est sur chaque point d'entrée de fonction. Validez les arguments et tout état global pertinent.

  • J'utilise beaucoup de journalisation pour déboguer des codes à long terme/asynchrones/en temps réel.
    Encore une fois, insérez une écriture de journal à chaque appel de fonction.
    Si les fichiers journaux deviennent trop volumineux, les fonctions de journalisation peuvent encapsuler/changer de fichier/etc.
    C'est très utile si les messages de journal sont en retrait avec la profondeur d'appel de la fonction.
    Le fichier journal peut montrer comment un bogue se propage. Utile quand un morceau de code fait quelque chose de pas tout à fait correct qui agit comme une bombe à action retardée.

Beaucoup de gens ont leur propre code de journalisation. J'ai un ancien système de journaux de macros C quelque part, et peut-être une version C++ ...

3
andy256

Essayez de définir un point de surveillance sur l'adresse mémoire à laquelle il se bloque. GDB s'arrêtera à l'instruction qui a provoqué la mémoire invalide. Ensuite, avec trace arrière, vous pouvez voir votre code à l'origine de la corruption. Ce n'est peut-être pas la source de la corruption mais la répétition du point de surveillance sur chaque corruption peut conduire à la source du problème.

Soit dit en passant, étant donné que la question est étiquetée C++, envisagez d'utiliser des pointeurs partagés qui prennent soin de la propriété en conservant un nombre de références et supprimez la mémoire en toute sécurité une fois que le pointeur est hors de portée. Mais utilisez-les avec prudence car ils peuvent provoquer un blocage dans une utilisation rare de la dépendance circulaire.

0
Mohammad Azim