Skip to content

16. Στατική και δυναμική ανάλυση κώδικα

Σύνοψη Στατική ανάλυση κώδικα, το λογισμικό Cppcheck, παραδείγματα με το Cppcheck, δυναμική ανάλυση κώδικα, το λογισμικό Valgrind, παραδείγματα με το Valgrind και το εργαλείο memcheck, άλλα εργαλεία του Valgrind όπως τα callgrind και massif.

Προαπαιτούμενη γνώση Τύποι δεδομένων, είσοδος/έξοδος, δομές επιλογής και επανάληψης, συναρτήσεις, πίνακες, δείκτες, αλφαριθμητικά.

16.1 Εισαγωγή

Η στατική ανάλυση κώδικα και η δυναμική ανάλυση κώδικα είναι σημαντικά στάδια κατά την ανάπτυξη λογισμικού. Η στατική ανάλυση εστιάζει στον έγκυρο εντοπισμό πιθανών προβληματικών καταστάσεων και στη βελτίωση της ποιότητας του κώδικα. Από την άλλη μεριά, η δυναμική ανάλυση παρακολουθεί την εκτέλεση του προγράμματος και βοηθά στην απόκτηση διαίσθησης για τη συμπεριφορά του κώδικα όταν εκτελείται.

16.2 Στατική ανάλυση κώδικα

Η στατική ανάλυση εξετάζει τον κώδικα χωρίς να τον εκτελεί. Ο κύριος σκοπός της είναι η ανίχνευση σφαλμάτων, προβληματικών καταστάσεων, θεμάτων ποιότητας κώδικα και θεμάτων στυλ νωρίς στην ανάπτυξη του κώδικα. Βοηθά στη βελτίωση της αξιοπιστίας και διευκολύνει τη συντήρηση του κώδικα. Ειδικότερα, η στατική ανάλυση κώδικα εστιάζει στους ακόλουθους τομείς:

  1. Ανίχνευση σφαλμάτων: Εντοπίζει συνηθισμένα προγραμματιστικά λάθη, όπως αποαναφορές NULL δεικτών, υπερχειλίσεις προσωρινών χώρων αποθήκευσης (buffer overflows), διαρροές μνήμης (memory leakages) και άλλα.

  2. Ποιότητα κώδικα: Επιβάλλει στάνταρντ κώδικα, οδηγίες στυλ και βέλτιστες πρακτικές. Εντοπίζει προβλήματα όπως μεταβλητές και συναρτήσεις που δεν χρησιμοποιούνται, ασυνέπειες μορφοποίησης κώδικα, προβληματική ανάθεση ονομάτων σε μεταβλητές και συναρτήσεις, και άλλα.

  3. Βελτιστοποίηση απόδοσης: Εντοπίζει σημεία κώδικα που δημιουργούν προβλήματα ταχύτητας εκτέλεσης.

Μερικά από τα πλέον διαδεδομένα λογισμικά στατικής ανάλυσης κώδικα στη C είναι τα:

  • Cppcheck: Το Cppcheck είναι ένα ευρέως χρησιμοποιούμενο εργαλείο στατικής ανάλυσης κώδικα για τη C και τη C++. Εντοπίζει διάφορους τύπους σφαλμάτων, όπως αποαναφορές NULL δεικτών, μεταβλητές ή συναρτήσεις που δεν χρησιμοποιούνται και άλλα. Το Cppcheck μπορεί να χρησιμοποιηθεί από τη γραμμή εντολών, να κληθεί μέσα από IDEs ή από build συστήματα. Επιπλέον, υπάρχει και το cppcheck-gui που επιτρέπει τη χρήση του Cppcheck μέσω ενός γραφικού περιβάλλοντος.

  • Clang-Tidy: Το Clang-Tidy είναι ένα εργαλείο στατικής ανάλυσης κώδικα που αποτελεί τμήμα της υποδομής του μεταγλωττιστή Clang. Προσφέρει ένα εύρος ελέγχων για C και C++ κώδικα.

  • Splint: Το Splint είναι ένα εργαλείο στατικής ανάλυσης, ανοικτού κώδικα, που έχει αναπτυχθεί συγκεκριμένα για την C. Εστιάζει στην ανίχνευση πιθανών ευπαθειών ασφάλειας, όπως υπερχειλίσεις προσωρινών χώρων αποθήκευσης και κλήσεις συστήματος χωρίς ελέγχους. Το Splint εμφανίζει αναλυτικές προειδοποιήσεις (warnings) για προβληματικές καταστάσεις και μπορεί να ρυθμιστεί έτσι ώστε να επιβάλει συγκεκριμένα στάνταρντ κώδικα στη συγγραφή των προγραμμάτων.

  • Coverity: Το Coverity είναι ένα εμπορικό εργαλείο στατικής ανάλυσης που υποστηρίζει τη C και τη C++. Εντοπίζει ελαττώματα στον κώδικα και παρέχει συμβουλές για τη βελτίωση της ποιότητάς του. Παρέχει ένα εκτεταμένο σύνολο ελέγχων που περιλαμβάνει ελέγχους ευπαθειών ασφάλειας, ελέγχους διαρροών πόρων και άλλους ελέγχους.

  • PVS-Studio: Το PVS-Studio είναι εμπορικό λογισμικό στατικής ανάλυσης κώδικα για τη C και τη C++. Ανιχνεύει ένα ευρύ φάσμα προβληματικών καταστάσεων όπως αποαναφορές NULL δεικτών, μη αρχικοποιημένες μεταβλητές, μη-αποδοτικό κώδικα και άλλα. Εμφανίζει αναλυτικές αναφορές για τα προβλήματα που εντοπίζει και μπορεί να ενσωματωθεί σε IDEs έτσι ώστε να διευκολύνει τον προγραμματιστή.

Στη συνέχεια θα περιγραφούν ορισμένες βασικές δυνατότητες του Cppcheck. Ωστόσο, συχνά είναι αποτελεσματικότερο να χρησιμοποιείται ένας συνδυασμός εργαλείων στατικής ανάλυσης έναντι μόνο ενός έτσι ώστε να επιτυγχάνεται πληρέστερη ανάλυση και βελτίωση της ποιότητας του κώδικα.

16.2.1 Cppcheck

Το Cppcheck δεν εντοπίζει συντακτικά λάθη, αλλά προσπαθεί να βρει λάθη που δεν βρίσκει ο μεταγλωττιστής, δίνοντας έμφαση στην αποφυγή ανάδειξης καταστάσεων που δεν είναι στην πραγματικότητα προβληματικές (αποφυγή ψευδώς θετικών λαθών). Στο εγχειρίδιο του Cppcheck 1 αναφέρεται ότι υπάρχουν πολλά λάθη που δεν ανιχνεύει και ότι ο προσεκτικός έλεγχος (testing) και η παρακολούθηση εκτέλεσης του κώδικα με ταυτόχρονη καταμέτρηση κρίσιμων μεγεθών (instrumentation) μπορεί να εντοπίσει περισσότερα λάθη από το Cppcheck.
Η εγκατάσταση του Cppcheck είναι εύκολη και περιγράφεται στη σελίδα του λογισμικού https://cppcheck.sourceforge.io/. Το Cppcheck μπορεί να ελέγξει ένα αρχείο πηγαίου κώδικα ή όλα τα αρχεία πηγαίου κώδικα σε έναν κατάλογο, αποκλείοντας από τον έλεγχο, εφόσον απαιτείται, κάποια αρχεία. Στη συνέχεια θα παρουσιαστούν 6 περιπτώσεις χρήσης του Cppcheck.

1. Πρόσβαση εκτός των ορίων πίνακα Ένα πρόβλημα που είναι σε θέση να εντοπίσει το Cppcheck είναι η πρόσβαση εκτός των ορίων πινάκων. Στο παράδειγμα του κώδικα 16.1, ενώ ο πίνακας a είναι δέκα θέσεων, στην τελευταία επανάληψη του βρόχου επανάληψης γίνεται πρόσβαση στην ενδέκατη θέση.

Κώδικας 16.1: ch16_p1.c - πρόσβαση εκτός ορίων πίνακα.
1
2
3
4
5
6
7
int main(void) {
  int a[10];
  for (int i = 0; i <= 10; i++) {
    a[i] = i;
  }
  return 0;
}

Ενώ η μεταγλώττιση του κώδικα ολοκληρώνεται χωρίς προβλήματα, το Cppcheck εντοπίζει το πρόβλημα και εμφανίζει το ακόλουθο αρκετά κατανοητό μήνυμα:

$ cppcheck ch16_p1.c
Checking ch16_p1.c ...
ch16_p1.c:4:6: error: Array 'a[10]' accessed at index 10, which is out of bounds.
    ↪ [arrayIndexOutOfBounds]
    a[i] = i;
     ^
ch16_p1.c:3:21: note: Assuming that condition 'i<=10' is not redundant
    for (int i = 0; i <= 10; i++) {
                      ^
ch16_p1.c:4:6: note: Array index out of bounds
    a[i] = i;
     ^

2. Χρήση μη αρχικοποιημένων μεταβλητών Η C επιτρέπει να δηλωθεί μια μεταβλητή, αλλά να μην αρχικοποιηθεί. Σε αυτήν την περίπτωση, αν χρησιμοποιηθεί η μεταβλητή, τότε η τιμή της θα είναι απροσδιόριστη και μπορεί να προκληθούν προβλήματα στην ορθή εκτέλεση του κώδικα. Στο ακόλουθο παράδειγμα (κώδικας 16.2) χρησιμοποιείται η μη αρχικοποιημένη μεταβλητή b:

Κώδικας 16.2: ch16_p2.c - χρήση μη αρχικοποιημένης μεταβλητής.
1
2
3
4
5
6
7
8
#include <stdio.h>

int main(void) {
  int a = 42, b;
  a += b;
  printf("a=%d\n", a);
  return 0;
}

Το Cppcheck εντοπίζει το πρόβλημα και εμφανίζει το μήνυμα “Uninitialized variable”. Αξίζει να σημειωθεί ότι στο συγκεκριμένο παράδειγμα αντίστοιχο μήνυμα θα εμφάνιζε και ο μεταγλωττιστής gcc αν είχε χρησιμοποιηθεί ο διακόπτης ‐Wall κατά τη μεταγλώττιση.

$ cppcheck ch16_p2.c
Checking ch16_p2.c ...
ch16_p2.c:5:8: error: Uninitialized variable: b [uninitvar]
    a += b;
         ^

3. Μείωση εμβέλειας μεταβλητών και εντοπισμός μεταβλητών που δεν χρησιμοποιούνται Ιδανικά, κάθε μεταβλητή θα πρέπει να έχει τη μικρότερη δυνατή εμβέλεια. Το Cppcheck μπορεί να εντοπίσει περιπτώσεις που αυτό δεν τηρείται. Επίσης, μπορεί να προειδοποιήσει για ύπαρξη μεταβλητών που δεν χρησιμοποιούνται. Τα δύο αυτά προβλήματα υπάρχουν στον κώδικα 16.3, όπου η μεταβλητή a μπορεί να έχει μικρότερη εμβέλεια, εφόσον δηλωθεί μέσα στο μπλοκ της if, και η μεταβλητή b μπορεί να αφαιρεθεί από τον κώδικα καθώς δεν χρησιμοποιείται.

Κώδικας 16.3: ch16_p3.c - μεταβλητή με μεγαλύτερη από την απαιτούμενη εμβέλεια και μεταβλητή που δεν χρησιμοποιείται.
#include <stdio.h>

int main(void) {
  int a = 42;
  if (1) {
    int b = 73;
    a += 1;
    printf("%d\n", a);
  }
}

Η εκτέλεση του Cppcheck με τον διακόπτη ‐‐enable=style θα δώσει τα ακόλουθα αποτελέσματα:

$ cppcheck ‐‐enable=style ch16_p3.c
Checking ch16_p3.c ...
ch16_p3.c:4:7: style: The scope of the variable 'a' can be reduced. [variableScope]
  int a = 42;
      ^
ch16_p3.c:6:11: style: Variable 'b' is assigned a value that is never used.
    ↪ [unreadVariable]
    int b = 73;
          ^

4. Εντοπισμός συναρτήσεων που δεν χρησιμοποιούνται Μια συνάρτηση μπορεί να υπάρχει στον κώδικα αλλά να μην καλείται, όπως συμβαίνει με τη συνάρτηση bar() στον κώδικα 16.4.

Κώδικας 16.4: ch16_p4.c - εντοπισμός συνάρτησης που δεν καλείται.
1
2
3
4
5
6
7
#include <stdio.h>

int foo(void) { return 42; }

int bar(void) { return 73; }

int main(void) { printf("%d\n", foo()); }

Το Cppcheck εφόσον κληθεί με τον διακόπτη ‐‐enable=unusedFunction εντοπίζει τη συνάρτηση που δεν καλείται και εμφανίζει το ακόλουθο μήνυμα:

$ cppcheck ‐‐enable=unusedFunction ch16_p4.c
Checking ch16_p4.c ...
ch16_p4.c:5:0: style: The function 'bar' is never used. [unusedFunction]
int bar(void) { return 73; }
^

5. Διαρροή μνήμης Διαρροή μνήμης (memory leakage) προκύπτει όταν δεσμεύεται δυναμικά μια ποσότητα μνήμης που δεν αποδεσμεύεται ποτέ. Τυπικά, για κάθε κλήση της malloc()calloc()) θα πρέπει να υπάρχει μια κλήση της free() που να αποδεσμεύει τη μνήμη. Στον κώδικα 16.5, η συνάρτηση foo() δεσμεύει μνήμη για n αριθμούς double κάθε φορά που καλείται, αλλά η μνήμη αυτή δεν αποδεσμεύεται με συνέπεια να προκύπτει διαρροή μνήμης.

Κώδικας 16.5: ch16_p5.c - διαρροή μνήμης.
#include <stdio.h>
#include <stdlib.h>

void foo(int n) {
  double *a = malloc(sizeof(double) * n);
  printf("Memory allocated of %lu bytes\n", sizeof(double) * n);
}

int main(void) {
  foo(10);
  foo(100);
}

Το Cppcheck εντοπίζει το πρόβλημα και εμφανίζει το ακόλουθο μήνυμα:

$ cppcheck ch16_p5.c
Checking ch16_p5.c ...
ch16_p5.c:7:1: error: Memory leak: a [memleak]
}
^

6. Αποαναφορά NULL δείκτη Αν ένας δείκτης έχει αρχικοποιηθεί με την τιμή NULL, αυτό υποδηλώνει ότι δεν έχει λάβει ακόμα τιμή που θα επέτρεπε την ορθή αποαναφορά του. Οπότε, αν επιχειρηθεί αποαναφορά τότε κατά την εκτέλεση το πρόγραμμα θα καταρρεύσει προκαλώντας segmentation fault. Στον κώδικα 16.6, ο δείκτης p αρχικοποιείται στην τιμή NULL και μετά γίνεται εσφαλμένα αποαναφορά του, προκειμένου να ανατεθεί τιμή στη θέση μνήμης που δείχνει.

Κώδικας 16.6: ch16_p6.c - αποαναφορά NULL δείκτη.
1
2
3
4
5
#include <stdlib.h>
int main(void) {
  int *p = NULL;
  *p = 42;
}

Το Cppcheck εντοπίζει το πρόβλημα και εμφανίζει το μήνυμα που ακολουθεί:

$ cppcheck ch16_p6.c
Checking ch16_p6.c ...
ch16_p6.c:4:4: error: Null pointer dereference: p [nullPointer]
    *p = 42;
     ^
ch16_p6.c:3:12: note: Assignment 'p=NULL', assigned value is 0
    int *p = NULL;
             ^
ch16_p6.c:4:4: note: Null pointer dereference
    *p = 42;
     ^

16.3 Δυναμική ανάλυση κώδικα

Η δυναμική ανάλυση κώδικα είναι μια τεχνική ελέγχου λογισμικού, που εμπεριέχει την παρατήρηση και ανάλυση της συμπεριφοράς ενός προγράμματος κατά την εκτέλεσή του. Η δυναμική ανάλυση λειτουργεί συμπληρωματικά με τη στατική ανάλυση, καθώς μπορεί να αποκαλύψει λάθη κατά την εκτέλεση όπως διαρροές μνήμης, αποαναφορές NULL δεικτών, υπερχειλίσεις χώρων προσωρινής αποθήκευσης, σε σενάρια που η στατική ανάλυση δεν είναι σε θέση να εντοπίσει. Επιπλέον, με τη δυναμική ανάλυση κώδικα αποκτάται καλύτερη κατανόηση σχετικά με τη συμπεριφορά του προγράμματος κάτω από διαφορετικές εισόδους και περιβάλλοντα εκτέλεσης.
Υπάρχουν διάφορα εργαλεία δυναμικής ανάλυσης κώδικα για τη C. Τα γνωστότερα από αυτά είναι τα ακόλουθα:

  • Valgrind: Το Valgrind είναι ένα δημοφιλές εργαλείο δυναμικής ανάλυσης που βοηθά στην ανίχνευση διαρροών μνήμης, υπερχειλίσεων και άλλων προβλημάτων σχετικών με τη μνήμη, ενώ έχει και πολλές άλλες δυνατότητες. Επιτρέπει την παρακολούθηση της εκτέλεσης και τη μέτρηση διαφόρων μεγεθών (instrumentation) του προγράμματος με διάφορα εργαλεία όπως τα memcheck, callgrind, massif και cachegrind. Στη συνέχεια θα παρουσιαστούν μερικά παραδείγματα με το Valgrind. Μια ιδιαιτερότητα του Valgrind είναι ότι μπορεί να λειτουργήσει μόνο σε Linux και όχι σε Windows ή MacOS.

  • GNU Debugger (GDB): Το GDB είναι ένας ισχυρό λογισμικό που επιτρέπει την ανάλυση και την αποσφαλμάτωση του κώδικα κατά τον χρόνο εκτέλεσης. Βοηθά στην κατανόηση της συμπεριφοράς του κώδικα και στην αναγνώριση προβληματικών καταστάσεων. Στο Κεφάλαιο 15 έγινε περιγραφή βασικών δυνατοτήτων του GDB.

  • Address Sanitizer (ASan): Το ASan είναι ένα εργαλείο που περιέχεται στους μεταγλωττιστές GCC και Clang. Εντοπίζει σφάλματα μνήμης όπως υπερχειλίσεις, απόπειρες χρήσης δυναμικής μνήμης μετά την απελευθέρωσή της και άλλα. Λειτουργεί δημιουργώντας μηχανισμούς μέτρησης μεγεθών κατά τη μεταγλώττιση που χρησιμοποιούνται στον χρόνο εκτέλεσης για τον εντοπισμό σφαλμάτων.

  • American Fuzzy Lop (AFL): Το AFL είναι ένα λογισμικό τύπου fuzzer που πραγματοποιεί δυναμική ανάλυση δημιουργώντας και μεταλλάσσοντας διάφορες εισόδους προκειμένου να αποκαλύψει δύσκολες στον εντοπισμό ευπάθειες προγραμμάτων. Χρησιμοποιείται για ελέγχους ασφάλειας και μπορεί να εντοπίσει σφάλματα που προκαλούν καταρρεύσεις, αλλοίωση μνήμης (memory corruption) και άλλα.

16.3.1 Valgrind

Το Valgrind είναι μια συλλογή εργαλείων αποσφαλμάτωσης (debugging) και εκτίμησης απόδοσης (profiling) κώδικα που στοχεύουν στη δημιουργία ορθών και αποδοτικών προγραμμάτων. Το δημοφιλέστερο από τα εργαλεία του Valgrind είναι το memcheck που εντοπίζει κοινά σφάλματα χειρισμού μνήμης στη C που μπορούν να οδηγήσουν σε μη επιθυμητή συμπεριφορά (τερματισμό προγράμματος, μη αναμενόμενα αποτελέσματα). Το memcheck είναι το προκαθορισμένο εργαλείο του valgrind και ενεργοποιείται αυτόματα αν δεν ενεργοποιηθεί κάποιο άλλο εργαλείο με τον διακόπτη ‐‐tool. Στη συνέχεια ακολουθεί μια παραλλαγή του κώδικα 16.5 που παρουσίαζε διαρροή μνήμης και που το Cppcheck ήταν σε θέση να εντοπίσει. Στον νέο κώδικα (κώδικας 16.7), το Cppcheck δεν εντοπίζει πλέον τη διαρροή μνήμης, ενώ το Valgrind, όπως θα δούμε, την εντοπίζει.

Κώδικας 16.7: ch16_p7.c - διαρροή μνήμης που ανιχνεύεται από το Valgrind, αλλά όχι από το Cppcheck.
#include <stdio.h>
#include <stdlib.h>

void foo(int n) {
  double *a = malloc(sizeof(double) * n);
  printf("Memory allocated for %lu bytes\n", sizeof(double) * n);
  for (int i = 0; i < n; i++) {
    a[i] = 0;
  }
}

int main(void) {
  foo(10);
  foo(100);
}

Η εκτέλεση του Valgrind χρησιμοποιεί το εκτελέσιμο πρόγραμμα που δημιουργεί ο μεταγλωττιστής και συνίσταται η μεταγλώττιση να γίνεται με τον διακόπτη ‐g (συμπερίληψη πληροφοριών εκσφαλμάτωσης στο εκτελέσιμο για καλύτερα μηνύματα) και να μη χρησιμοποιείται ελτιστοποίηση κώδικα υψηλότερη από ‐Ο1. Τα μηνύματα που επιστρέφει το Valgrind δείχνουν ότι δεσμεύεται πρώτα μνήμη 80 bytes και μετά μνήμη 800 bytes, που μετά δεν απελευθερώνονται. Το σχετικό μήνυμα αναφέρει ότι 880 bytes είναι οριστικά χαμένα (definitely lost). Η τιμή 22 που εμφανίζεται στην αρχή κάθε σειράς της εξόδου είναι ένας αναγνωριστικός αριθμός διεργασίας που λαμβάνει το πρόγραμμα κατά την εκτέλεσή του και θα είναι διαφορετικός για κάθε νέα εκτέλεση. Η εκτέλεση του Valgrind γίνεται όπως στη συνέχεια (πρώτα μεταγλώττιση, μετά κλήση του Valgrind με όρισμα το εκτελέσιμο αρχείο):

$ gcc ‐o ch16_p7 ‐g ch16_p7.c
$ valgrind ./ch16_p7
==22== Memcheck , a memory error detector
==22== Copyright (C) 2002‐2022, and GNU GPL'd, by Julian Seward et al.
==22== Using Valgrind ‐3.21.0 and LibVEX; rerun with ‐h for copyright info
==22== Command: ./ch16_p7
==22==
Memory allocated for 80 bytes
Memory allocated for 800 bytes
==22==
==22== HEAP SUMMARY:
==22==      in use at exit: 880 bytes in 2 blocks
==22==   total heap usage: 2 allocs, 0 frees, 880 bytes allocated
==22==
==22== LEAK SUMMARY:
==22==    definitely lost: 880 bytes in 2 blocks
==22==    indirectly lost: 0 bytes in 0 blocks
==22==      possibly lost: 0 bytes in 0 blocks
==22==    still reachable: 0 bytes in 0 blocks
==22==          suppressed: 0 bytes in 0 blocks
==22== Rerun with ‐‐leak‐check=full to see details of leaked memory
==22==
==22== For lists of detected and suppressed errors, rerun with: ‐s
==22== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)
Αν χρησιμοποιηθεί ο διακόπτης ‐‐leak‐check=full κατά την κλήση του Valgrind, όπως προτρέπει και το μήνυμα στην 4η γραμμή από το τέλος της προηγούμενης εξόδου, τότε θα εμφανιστούν επιπλέον μηνύματα με τις γραμμές κώδικα που προκαλούν τη διαρροή μνήμης. Το σχετικό απόσπασμα από την έξοδο του Valgrind φαίνεται στη συνέχεια.

$ valgrind ‐‐leak‐check=full ./ch16_p7
...
==25== 80 bytes in 1 blocks are definitely lost in loss record 1 of 2
==25==      at 0x48D943C: malloc (in
    ↪ /usr/libexec/valgrind/vgpreload_memcheck ‐arm64‐linux.so)
==25==      by 0x1087CB: foo (ch16_p7.c:5)
==25==      by 0x10883F: main (ch16_p7.c:13)
==25==
==25== 800 bytes in 1 blocks are definitely lost in loss record 2 of 2
==25==      at 0x48D943C: malloc (in
    ↪ /usr/libexec/valgrind/vgpreload_memcheck ‐arm64‐linux.so)
==25==      by 0x1087CB: foo (ch16_p7.c:5)
==25==      by 0x108847: main (ch16_p7.c:14)
...

Το Valgrind παρακολουθεί την εκτέλεση του προγράμματος, συνάγει συμπεράσματα για πιθανά προβλήματα και εμφανίζει σχετικά μηνύματα. Ακολουθεί ένα ακόμα παράδειγμα (κώδικας 16.8) όπου δυναμικά δεσμεύεται και αποδεσμεύεται ένας πίνακας, αλλά η απόφαση για τις εντολές που θα εκτελεστούν στη συνέχεια εξαρτάται από την τιμή ενός στοιχείου του πίνακα ( A[5] ) που δεν έχει αρχικοποιηθεί. Το Valgrind μπορεί και σε αυτήν την περίπτωση να εντοπίσει το λάθος.

Κώδικας 16.8: ch16_p8.c - επιλογή διαδρομής εκτέλεσης στο πρόγραμμα βάσει μη αρχικοποιημένης τιμής.
#include <stdio.h>
#include <stdlib.h>

int main(void) {
  int *a = malloc(sizeof(int) * 10);
  if (a[5] > 0) {
    printf("A\n");
  } else {
    printf("B\n");
  }
  free(a);
}

Το μήνυμα που εμφανίζει το Valgrind είναι “Conditional jump or move depends on uninitialised value”, μαζί με τη γραμμή κώδικα που εντοπίζεται το πρόβλημα. Αυτό απλά σημαίνει ότι το Valgrind εντόπισε πως στη γραμμή 6 του κώδικα η επιλογή για την εκτέλεση του ενός μπλοκ κώδικα ή του άλλου, εξαρτάται από μια τιμή που δεν έχει αρχικοποιηθεί.

$ gcc ‐g ‐o ch16_p8 ch16_p8.c
$ valgrind ./ch16_p8
==32== Memcheck , a memory error detector
==32== Copyright (C) 2002‐2022, and GNU GPL'd, by Julian Seward et al.
==32== Using Valgrind ‐3.21.0 and LibVEX; rerun with ‐h for copyright info
==32== Command: ./ch16_p8
==32==
==32== Conditional jump or move depends on uninitialised value(s)
==32==      at 0x108828: main (ch16_p8.c:6)
==32==
B
==32==
==32== HEAP SUMMARY:
==32==      in use at exit: 0 bytes in 0 blocks
==32==  total heap usage: 1 allocs, 1 frees, 40 bytes allocated
==32==
==32== All heap blocks were freed ‐‐ no leaks are possible
==32==
==32== Use ‐‐track‐origins=yes to see where uninitialised values come from
==32== For lists of detected and suppressed errors, rerun with: ‐s
==32== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0)

Περισσότερες πληροφορίες για το Valgrind και το εργαλείο memcheck μπορούν να βρεθούν στην ιστοσελίδα του λογισμικού (https://valgrind.org/), στο εγχειρίδιο χρήσης του 2, στα άρθα 3 και 4 και σε πολλές άλλες πηγές στο διαδίκτυο, καθώς το Valgrind είναι ιδιαίτερα δημοφιλές, ενώ αναπτύσσεται ενεργά για πάνω από 20 έτη.

16.3.1.1 Ανάλυση χρόνου εκτέλεσης με το Valgrind

Το Valgrind μπορεί να χρησιμοποιηθεί για να αναλύσει τον χρόνο εκτέλεσης κάθε συνάρτησης μετρώντας το πλήθος των εντολών μηχανής που εκτελούνται. Η διαδικασία γίνεται σε δύο βήματα, πρώτα εκτελείται το Valgrind και δημιουργείται ένα αρχείο με όνομα callgrind.out.pid, όπου pid είναι το αναγνωριστικό της διεργασίας που εκτελέστηκε. Στη συνέχεια καλείται το εργαλείο callgrind_annotate, με όρισμα αυτό το αρχείο. Ακολουθεί ένα παράδειγμα (κώδικας 16.9) όπου επιχειρείται η ταξινόμηση πινάκων με τον αλγόριθμο ταξινόμησης με εισαγωγή. Οι πίνακες (3 πίνακες με 100000, 10000 και 1000 θέσεις, αντίστοιχα) δημιουργούνται δυναμικά, γεμίζουν με τυχαίες τιμές και στη συνέχεια ταξινομούνται με τη συνάρτηση insertion_sort() (ταξινόμηση με εισαγωγή).

Κώδικας 16.9: ch16_p9.c - ταξινόμηση πινάκων τυχαίων τιμών, καταμέτρηση εντολών μηχανής που εκτελούνται με το Valgrind.
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

// ταξινόμηση με εισαγωγή
void insertion_sort(int arr[], int n) {
  int i, key, j;
  for (i = 1; i < n; i++) {
    key = arr[i];
    j = i - 1;
    while (j >= 0 && arr[j] > key) {
      arr[j + 1] = arr[j];
      j = j - 1;
    }
    arr[j + 1] = key;
  }
}

// εκτύπωση των δύο πρώτων και των δύο τελευταίων στοιχείων του πίνακα
void print_array(int arr[], int n) {
  assert(n >= 4); // ο πίνακας πρέπει να έχει τουλάχιστον 4 στοιχεία
  printf("%d %d ... %d %d\n", arr[0], arr[1], arr[n - 2], arr[n - 1]);
}

int main(void) {
  srand(time(NULL));
  int N[] = {10000, 1000, 100};
  for (int k = 0; k < 3; k++) {
    int *a = malloc(sizeof(int) * N[k]);
    for (int i = 0; i < N[k]; i++) {
      a[i] = rand() % 1000;
    }
    insertion_sort(a, N[k]);
    print_array(a, N[k]);
    free(a);
  }
}

Το πρώτο βήμα της διαδικασίας δημιουργεί το αρχείο callgrind.out.75 σε αυτήν την εκτέλεση. Οι εντολές και η έξοδος του Valgrind φαίνονται στη συνέχεια.

$ gcc ‐g ch16_p9.c ‐o ch16_p9
$ valgrind ‐‐tool=callgrind ./ch16_p9
==75== Callgrind , a call‐graph generating cache profiler
==75== Copyright (C) 2002‐2017, and GNU GPL'd, by Josef Weidendorfer et al.
==75== Using Valgrind ‐3.21.0 and LibVEX; rerun with ‐h for copyright info
==75== Command: ./ch16_p9
==75==
==75== For interactive control , run 'callgrind_control ‐h'.
4 17 ... 999972 999983
100 118 ... 999804 999913
273 791 ... 998243 999911
==75==
==75== Events       : Ir
==75== Collected    : 63186286821
==75==
==75== I    refs:       63,186,286,821

Το δεύτερο βήμα της διαδικασίας χρησιμοποιεί το αρχείο που παράχθηκε και εμφανίζει πλήθη εντολών μηχανής που εκτελέστηκαν προκειμένου να επιτευχθεί η λειτουργικότητα των επιμέρους εντολών του προγράμματος. Λόγω του μεγάλου μεγέθους της εξόδου παρουσιάζονται στη συνέχεια μόνο επιλεγμένες γραμμές της εξόδου. Οι αριθμοί που εμφανίζονται στην αρχή των γραμμών είναι το πλήθος των εντολών μηχανής που εκτελέστηκαν για να ολοκληρωθεί το τμήμα κώδικα που ακολουθεί στην ίδια γραμμή.

$ callgrind_annotate callgrind.out.75
‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐
Profile data file 'callgrind.out.75' (creator: callgrind ‐3.21.0)
...
63,181,458,721 (99.99%) ch16_p9.c:insertion_sort '2 [ch16_p9]
...
63,185,010,995 (100.0%) events annotated

Περισσότερες πληροφορίες για τη χρήση του εργαλείου callgrind μπορούν να εντοπιστούν στο άρθρο 5. Αξίζει να σημειωθεί ότι υπάρχουν εξειδικευμένα λογισμικά παρακολούθησης προγραμμάτων κατά την εκτέλεσή τους, με σκοπό να εντοπιστούν ευκαιρίες βελτίωσης απόδοσής και όχι για να εντοπιστούν σφάλματα ή ευπάθειες διαφόρων τύπων. Η διαδικασία αυτή λέγεται profiling. Δημοφιλή τέτοια λογισμικά είναι το gprof, το gprofng και το perf.

16.3.1.2 Ανάλυση χρήσης μνήμης με το Valgrind

Ένα άλλο χρήσιμο εργαλείο του Valgrind είναι το massif, που είναι ένας heap profiler, δηλαδή καταγράφει πληροφορίες για τη μνήμη που δεσμεύεται και απελευθερώνεται στον σωρό (heap), κατά την εκτέλεση του προγράμματος.

$ valgrind ‐‐tool=massif ./ch16_p9
==73== Massif, a heap profiler
==73== Copyright (C) 2003‐2017, and GNU GPL'd, by Nicholas Nethercote
==73== Using Valgrind ‐3.21.0 and LibVEX; rerun with ‐h for copyright info
==73== Command: ./ch16_p9
==73==
40 40 ... 999984 999992
144 411 ... 999967 999995
1642 1784 ... 997991 999016
==73==

Η ανωτέρω εντολή δημιουργεί ένα αρχείο με όνομα massif.out.pid, όπου pid είναι το αναγνωριστικό της διεργασίας που δημιουργήθηκε. Η ερμηνεία των δεδομένων που περιέχει το αρχείο γίνεται με το πρόγραμμα ms_print όπως φαίνεται στη συνέχεια:

$ ms_print massif.out.73
...
Number of snapshots: 8
    Detailed snapshots: [2 (peak)]

‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐---
n            time(i)         total(B)    useful‐heap(B)  extra‐heap(B)  stacks(B)
‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐---
0                 0                0                 0              0          0
1            55,966          400,008           400,000              8          0
2    62,355,587,563          400,008           400,000              8          0
100.00% (400,000B) (heap allocation functions) malloc/new/new[], ‐‐alloc‐fns, etc.
‐>100.00% (400,000B) 0x108B2B: main (ch16_p9.c:25)

‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐---
n           time(i)         total(B)    useful‐heap(B)  extra‐heap(B)   stacks(B)
‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐---
3    62,355,587,563               0                 0              0           0
4    62,355,587,611          40,008            40,000              8           0
5    62,989,504,205               0                 0              0           0
6    62,989,504,253           4,008             4,000              8           0
7    62,995,892,447               0                 0              0           0
...

Οι δυναμικές δεσμεύσεις και αποδεσμεύσεις μνήμης από τον κώδικα 16.9 για 100000, 10000 και 1000 ακεραίους, φαίνονται στην παραπάνω έξοδο να προκαλούν δέσμευση μνήμης 400000, 40000 και 4000 bytes αντίστοιχα. Περισσότερες πληροφορίες για τη χρήση του εργαλείου massif μπορούν να εντοπιστούν στο άρθρο 6.

16.4 Ασκήσεις

Άσκηση 1
Γράψτε ένα πρόγραμμα, με όνομα ch16_e1.c, που να διαβάζει ένα κείμενο ως είσοδο από τον χρήστη, χωρίς να ελέγχει το μέγεθος της εισόδου, προκαλώντας πιθανά υπερχείλιση στον πίνακα που θα αποθηκεύει την είσοδο. Χρησιμοποιήστε το Cppcheck για την ανίχνευση της ευπάθειας και προτείνετε έναν τρόπο έτσι ώστε να επιλύεται το πρόβλημα. Χρησιμοποιήστε την ακόλουθη εντολή έτσι ώστε να εμφανίζονται μηνύματα τύπου invalidscanf.

$ cppcheck ‐‐enable=all ch16_e1.c ‐‐suppress=missingIncludeSystem

Άσκηση 2
Εξετάστε το ακόλουθο πρόγραμμα (κώδικας 16.10) με το Cppcheck. Διορθώστε το πρόβλημα που εντοπίζει το Cppcheck.

Κώδικας 16.10: ch16_e2.c - κώδικας με πρόβλημα που μπορεί να εντοπιστεί με το Cppcheck.
#include <stdio.h>
#include <stdlib.h>

int main(void) {
  char *p = malloc(1);
  *p = 'a';
  free(p);
  char c = *p;
  printf("%c\n", c);
}

Άσκηση 3
Eξετάστε το ακόλουθο πρόγραμμα (κώδικας 16.11) πρώτα με το Cppcheck και μετά με το Valgrind. Διαπιστώστε ότι το Cppcheck δεν εμφανίζει κάποιο μήνυμα, ενώ το Valgrind εντοπίζει το πρόβλημα: “Invalid read of size 1”. Ποιο είναι το πρόβλημα στον κώδικα;

Κώδικας 16.11: ch16_e3.c - κώδικας με πρόβλημα που ανιχνεύεται από το Valgrind.
#include <stdio.h>
#include <stdlib.h>

int main(void) {
  char *p = malloc(1);
  *p = 'a';
  char c = *(p + 1);
  printf("%c\n", c);
  free(p);
}

Άσκηση 4
Εξετάστε το ακόλουθο πρόγραμμα (κώδικας 16.12) πρώτα με το Cppcheck και μετά με το Valgrind. Διαπιστώστε ότι το Cppcheck εμφανίζει μήνυμα memleak, ενώ το Valgrind δεν εντοπίζει κάποιο πρόβλημα. Γιατί πιστεύετε ότι συμβαίνει αυτό;

Κώδικας 16.12: ch16_e4.c - κώδικας για τον οποίο το Cppcheck εντοπίζει πρόβλημα διαρροής μνήμης.
#include <stdlib.h>

int main(void) {
  int *p = malloc(sizeof(int));
  *p = 42;
  (*p)++;
  if (*p == 42) {
    p = NULL;
  }
  free(p);
}

  1. Cppcheck 2.11 manual. https://cppcheck.sourceforge.io/manual.pdf. [Online; accessed 2023-July-12]. 

  2. Valgrind manual. https://valgrind.org/docs/manual/manual.html. [Online; accessed 2023-July-12]. 

  3. Paul Floyd. “Valgrind Part 1 - Introduction”. Στο: Overload 20.108 (April 2012), σσ. 14–15. 

  4. Paul Floyd. “Valgrind Part 2 - Basic Memcheck”. Στο: Overload 20.109 ( June 2012), σσ. 24–29. 

  5. Paul Floyd. “Valgrind Part 4 - Cachegrind and Callgrind”. Στο: Overload 20.111 (October 2012), σσ. 4–7. 

  6. Paul Floyd. “Valgrind Part 5 - Massif ”. Στο: Overload 20.112 (December 2012), σσ. 20–24.