Magaz, The Greek Linux Magazine
Magaz Logo

Επόμενο  Προηγούμενο  Περιεχόμενα

5. Χρήσιμες έως πολύ χρήσιμες πληροφορίες

5.1 The C calling convention

Στο κομμάτι αυτό θα εξετάσουμε τον τρόπο με τον οποίο γίνεται το πέρασμα των παραμέτρων στις συναρτήσεις αλλά και πως υλοποιούνται οι τοπικές μεταβλητές. Ύστατος σκοπός είναι η εξοικείωση με τον υλοποίηση των συναρτήσεων σε χαμηλό επίπεδο ώστε η μελέτη των assembly listings να είναι γρήγορη και οδηγεί σε ξεκάθαρα συμπεράσματα.

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

  • 1. πως ξέρει αυτό το κομμάτι που βρίσκονται οι παράμετροι που περιμένει;
  • 2.που αποθηκεύει τις τοπικές μεταβλητές;

Μια πρώτη προσέγγιση θα ήταν να παίρνει τα δεδομένα του (παραμέτρους και τοπικές μεταβλητές) από συγκεκριμένες απόλυτες διευθύνσεις μνήμης. Όλα ωραία και καλά μέχρι να χρειαστεί να υλοποιήσουμε αναδρομικές συναρτήσεις (συναρτήσεις που καλούν τον εαυτό τους). Για παράδειγμα η f η οποία εκτυπώνει τους αριθμούς από το 1 μέχρι το x:

void f(int x)
{       
    if (x>1)
        f(x-1);
    printf("%d ",x);
}

και έστω πως η συνάρτηση περιμένει την παράμετρο x στη διεύθυνση 100 (τι πρωτότυπους αριθμούς που χρησιμοποιώ!)

Αν έχουμε την κλήση f(2) εμείς περιμένουμε το αποτέλεσμα "1 2". Ας δούμε πιο αναλυτικά τι όντως θα συμβεί: Όταν κληθεί για πρώτη φορά η f όλα είναι όπως πρέπει, η διεύθυνση 100 περιέχει το αρχικό x (το 2). Το 2 είναι μεγαλύτερο από το 1 οπότε καλείται η f(x-1) δηλαδή η f(1). H διεύθυνση 100 περιέχει τώρα τον αριθμό 1. Το τρέχον x δεν είναι μεγαλύτερο του 1 οπότε απλώς εκτελείται η printf και εκτυπώνεται το "1". Η συνάρτηση f(1) επιστρέφει και εκτελείται η printf η οποία εκτυπώνει ... πάλι "1". Η προηγούμενη τιμή του x (το 2) έχει χαθεί :(

Η μαγική λέξη για την αποφυγή τέτοιων προβλημάτων είναι ο σωρός (stack). Κάθε πρόγραμμα διατηρεί το δικό του σωρό, ο οποίος αρχίζει από υψηλές διευθύνσεις και μεγαλώνει προς της χαμηλές. Η σύμβαση της C για την κλήση συναρτήσεων λέει πως οι παράμετροι "σπρώχνονται" στο σωρό από τα δεξιά προς τα αριστερά. Επίσης η συνάρτηση που έκανε την κλήση είναι υπεύθυνη για να καθαρίσει τον σωρό (να τον φέρει στην αρχική κατάσταση). Για παράδειγμα η κλήση της συνάρτησης f(x,y,z) μεταφράζεται σε:

push z
push y
push x
call f       -------> f: ...
                         ...
                         ret
add esp,12     (4bytes * 3 παραμέτρους)        

Οι τοπικές μεταβλητές αποθηκεύονται και αυτές στο σωρό σε χώρο που δεσμεύεται μετά τη διεύθυνση επιστροφής της συνάρτησης. Αυτό γίνεται απλά με την μείωση του stack pointer esp (δείκτη στην "κορυφή" του σωρού) κατά τόσες θέσεις όσες τα bytes που χρειαζόμαστε για τις τοπικές μεταβλητές.

Για να προσπελάσουμε κάποιο δεδομένο χρησιμοποιούμε τον esp και τη σχετική απόσταση του από αυτόν. Για παράδειγμα η τελευταία τοπική μεταβλητή (σε σχέση με τη σειρά που τις έχουμε δηλώσει στον κώδικα) βρίσκεται στη διεύθυνση esp+0.Το πρόβλημα με το παραπάνω σχέδιο είναι ότι κανείς δεν εγγυάται πως ο esp δεν θα αλλάξει τιμή κατά τη διάρκεια της συνάρτησης. Για παράδειγμα αν εκτελεστεί μία εντολή push eax, η τελευταία τοπική μεταβλητή είναι πια στη θέση esp+4 και όχι esp+0. Έτσι ο compiler πρέπει να φροντίσει να ακολουθεί τις αλλαγές και να παράγει σωστά offsets για τις τοπικές μεταβλητές.

Μια πιο βολική προσέγγιση χρησιμοποιεί την έννοια του "πλαισίου". Με κάθε κλήση συνάρτησης δημιουργείται στο σωρό ένα "πλαίσιο" (frame) που περιέχει τα δεδομένα της συγκεκριμένης κλήσης (παραμέτρους, τοπικές μεταβλητές και διεύθυνση επιστροφής) και επίσης τη διεύθυνση του πλαισίου της προηγούμενης συνάρτησης (αυτή που κάλεσε την τρέχουσα). Μέσα σε κάθε πλαίσιο ο τρόπος πρόσβασης στα τοπικά δεδομένα είναι ανεξάρτητος από τις αλλαγές του δείκτη του σωρού. Η δημιουργία του πλαισίου απαιτεί 4 μικρές αλλαγές σε σχέση με τη προηγούμενη προσέγγιση:

  • 1.Ένας καταχωρητής δεσμεύεται καθολικά για να συγκρατεί την τρέχουσα διεύθυνση πλαισίου. Στους x86 αυτός είναι ο ebp.
  • 2.Πριν τη δέσμευση χώρου για τις τοπικές μεταβλητές αποθηκεύεται στο σωρό η διεύθυνση του προηγούμενου πλαισίου.
  • 3.Μετά το (1) η διεύθυνση που περιέχει ο esp (κορυφή του σωρού) γίνεται η διεύθυνση του τρέχοντος πλαισίου και αποθηκεύεται στον ebp.
  • 4.Πριν την επιστροφή της συνάρτησης επανατοποθετείται στον ebp η διεύθυνση του προηγούμενου πλαισίου.
Σχηματικά:

Τώρα σε κάθε συνάρτηση ο κώδικας για την προσπέλαση των τοπικών δεδομένων είναι ο ίδιος. Αν υποθέσουμε πως έχουμε επεξεργαστή και λειτουργικό 32-bit (πχ linux :) ) τότε η πρώτη παράμετρος της συνάρτησης βρίσκεται στη θέση ebp+8, η δεύτερη στην ebp+12 κτλ Ομοίως η πρώτη τοπική μεταβλητή βρίσκεται στη θέση ebp-4, η δεύτερη στη θέση ebp-8.Αυτό ισχύει ακόμα και όταν οι παράμετροι και οι τοπικές μεταβλητές είναι πιο μικροί από 4 bytes (πχ char). O compiler προτιμά να σπαταλήσει λίγη μνήμη για λόγους ομοιογένειας στον παραγόμενο κώδικα αλλά κυρίως για λόγους απόδοσης, αφού με αυτόν τον τρόπο όλα τα δεδομένα είναι ευθυγραμμισμένα (aligned) σε όρια των 4bytes (ανοίξτε κάποιο βιβλίο αρχιτεκτονικής υπολογιστών για να μάθετε γιατί...). Βέβαια, υπάρχουν και προφανείς εξαιρέσεις όταν για παράδειγμα έχουμε "δεδομένα" μεγαλύτερα των 4 bytes όπως structs.

Οι περισσότεροι compilers δίνουν επιλογή πιο από τους δύο τρόπους να χρησιμοποιήσουν. Ο gcc χρησιμοποιεί πλαίσια ως default επιλογή και με το flag "-fomit-frame-pointer" προσπαθεί να το αποφύγει όπου γίνεται. Από το info:

-fomit-frame-pointer     
     Don't keep the frame pointer in a register for functions that
     don't need one. This avoids the instructions to save, set up and
     restore frame pointers; it also makes an extra register available
     in many functions.  *It also makes debugging impossible on some     
     machines.*

Mια συνάρτηση που χρησιμοποιεί frames τυπικά αρχίζει με την ακολουθία:
push ebp
mov ebp,esp         ; Function prologue
sub esp, M

και τελειώνει:
mov esp, ebp
pop ebp             ; Function epilogue
ret

ή

leave           
ret

5.2 System Calls

Τα σύγχρονα λειτουργικά συστήματα που σέβονται τον εαυτό τους, φροντίζουν να ξεχωρίζουν το "χώρο" του πυρήνα (kernel space) από το "χώρο" τον χρηστών (user space). Αυτό γίνεται ώστε να μη μπορεί οποιοσδήποτε τυχαίος χρήστης να πειράξει τον πυρήνα(ή να εκτελεί κώδικα του πυρήνα αυθαίρετα) και να θέσει σε κίνδυνο την ασφάλεια του συστήματος. Ο χωρισμός αυτός υλοποιείται με τη χρήση μηχανισμών που προσφέρει ο εκάστοτε επεξεργαστής (πχ paging, segmentation).

Βέβαια, με κάποιο τρόπο πρέπει οι εφαρμογές να επικοινωνούν με τον πυρήνα για διάφορες εργασίες (πχ Ι/Ο). Η λύσεις είναι τα λεγόμενα call gates (όχι colgates, αυτά είναι οδοντόκρεμες...) και τα software interrupts. Και τα δύο έχουν ως σκοπό να ορίσουν διακριτά σημεία εισόδου στον πυρήνα. Μπορείτε να τα σκεφτείτε ως "gateways" για το τοπικό δίκτυο του πυρήνα.

Στο linux χρησιμοποιούνται τα call gates για εκτελέσιμα που έρχονται από άλλα λειτουργικά πχ solaris και το software interrupt 0x80 για native εφαρμογές. Στο κείμενο αυτό θα ασχοληθούμε μόνο με την δεύτερη τεχνική (για την πρώτη θα έπρεπε πρώτα να αναφερθεί όλος ο μηχανισμός των descriptors στους x86). Το interrupt 0x80 όταν κληθεί μεταφέρει τον έλεγχο στον πυρήνα, μαζί με πληροφορίες για την εργασία που πρέπει να εκτελέσει.

Κατά την κλήση ενός syscall (για την αρχιτεκτονική x86) στον eax υπάρχει ο αριθμός του syscall και στους ebx, ecx, edx, esi, edi, ebp(πυρήνες 2.4 και πάνω) οι μέχρι έξι παράμετροι που δέχεται το συγκεκριμένο syscall. Έτσι μια τυπική κλήση είναι:

mov ebx, 0
mov eax, 1  ; syscall 1: exit
int 0x80    

Φυσικά, επειδή το να γράφουμε τέτοιο κώδικα κάθε φορά που θέλουμε κάτι από τον πυρήνα δεν είναι και πολύ ευχάριστο, η libc έχει φροντίσει να δημιουργήσει τις αντίστοιχες wrapper συναρτήσεις. Έτσι αντί για το παραπάνω εμείς αρκεί να γράφουμε exit(0).

Επόμενο  Προηγούμενο  Περιεχόμενα


Valid HTML 4.01!   Valid CSS!