לדלג לתוכן

מצביעי פונקציות ו-callbacks - function pointers

הקדמה

בפרק 3.3 למדנו על פוינטרים - משתנים ששומרים כתובת בזיכרון. ראינו פוינטרים למשתנים, למערכים, ולסטראקטים. אבל אפשר גם להצביע על פונקציות.

פונקציה בC היא בסך הכל קוד מכונה שיושב בזיכרון (ב-text segment, כמו שלמדנו בפרק 5.11). לפונקציה יש כתובת, ואפשר לשמור את הכתובת הזו במשתנה ולקרוא לפונקציה דרכו.

מצביעי פונקציות הם הבסיס ל-callbacks, dispatch tables, פולימורפיזם בC, ועוד. בואו נראה איך.

הבסיס - הגדרת מצביע לפונקציה

נתחיל עם פונקציה רגילה:

int add(int a, int b) {
    return a + b;
}

כדי להגדיר מצביע שיכול להצביע על הפונקציה הזו:

int (*func_ptr)(int, int);

נפרק את הסינטקס:
- int - טיפוס ההחזרה של הפונקציה
- (*func_ptr) - שם המצביע, מוקף בסוגריים כי בלעדיהם זה יתפרש כפונקציה שמחזירה int*
- (int, int) - הפרמטרים של הפונקציה

עכשיו אפשר להצביע ולקרוא:

func_ptr = &add;      // or simply: func_ptr = add;
int result = func_ptr(3, 4);  // result = 7

שם של פונקציה כבר מתנהג כמצביע (בדומה לשם של מערך), אז func_ptr = add ו-func_ptr = &add שקולים.

typedef למצביעי פונקציות

הסינטקס של מצביעי פונקציות מסורבל. typedef עושה את החיים קלים:

typedef int (*operation_t)(int, int);

עכשיו operation_t הוא טיפוס - מצביע לפונקציה שמקבלת שני int ומחזירה int:

operation_t op = add;
int result = op(3, 4);

זה הרבה יותר קריא, במיוחד כשמעבירים מצביעי פונקציות כפרמטרים.

העברת פונקציה כפרמטר - callbacks

אחד השימושים הכי חשובים של מצביעי פונקציות הוא callback - העברת פונקציה כארגומנט לפונקציה אחרת:

void apply(int *arr, int n, int (*func)(int)) {
    for (int i = 0; i < n; i++) {
        arr[i] = func(arr[i]);
    }
}

int double_it(int x) { return x * 2; }
int square(int x) { return x * x; }

int main(void) {
    int arr[] = {1, 2, 3, 4, 5};

    apply(arr, 5, double_it);
    // arr = {2, 4, 6, 8, 10}

    apply(arr, 5, square);
    // arr = {4, 16, 36, 64, 100}

    return 0;
}

הפונקציה apply לא יודעת מה func עושה - היא פשוט קוראת לה על כל איבר. זה מאפשר לנו לכתוב קוד גנרי שעובד עם פונקציות שונות.

דוגמה מוכרת - qsort

הפונקציה qsort מהספרייה הסטנדרטית (שלמדנו בפרק 4.7) משתמשת בדיוק בעיקרון הזה:

void qsort(void *base, size_t nmemb, size_t size,
           int (*compar)(const void *, const void *));

אנחנו מעבירים לה פונקציית השוואה כcallback:

int compare_ints(const void *a, const void *b) {
    return (*(int *)a - *(int *)b);
}

int main(void) {
    int arr[] = {5, 2, 8, 1, 9};
    qsort(arr, 5, sizeof(int), compare_ints);
    // arr = {1, 2, 5, 8, 9}
    return 0;
}

מערכים של מצביעי פונקציות - dispatch tables

אפשר ליצור מערך של מצביעי פונקציות - מה שנקרא dispatch table או jump table:

typedef void (*handler_t)(void);

void handle_add(void)  { printf("ADD\n"); }
void handle_sub(void)  { printf("SUB\n"); }
void handle_mul(void)  { printf("MUL\n"); }
void handle_div(void)  { printf("DIV\n"); }

handler_t handlers[] = {handle_add, handle_sub, handle_mul, handle_div};

void dispatch(int opcode) {
    if (opcode >= 0 && opcode < 4)
        handlers[opcode]();
}

במקום שרשרת if/else או switch, אנחנו פשוט קוראים ל-handlers[opcode](). זה:
- מהיר יותר - קפיצה ישירה במקום השוואות מרובות
- גמיש יותר - קל להוסיף handlers חדשים
- נקי יותר - פחות קוד

איור המראה טבלת ניתוב של מצביעי פונקציות, שבה הערך של opcode משמש אינדקס ישיר לתא בטבלה והקריאה מגיעה לפונקציה המתאימה

ככה זה עובד בקרנל

בפרק 6.2 למדנו על טבלת ה-syscalls. ברמת המימוש, היא בדיוק dispatch table:

// simplified from what's in the kernel
typedef long (*syscall_fn_t)(long, long, long, long, long, long);

syscall_fn_t sys_call_table[] = {
    [0] = sys_read,
    [1] = sys_write,
    [2] = sys_open,
    [3] = sys_close,
    // ...
};

// when a syscall with number n arrives:
long result = sys_call_table[n](arg1, arg2, arg3, arg4, arg5, arg6);

המעבד שומר את מספר הsyscall ב-rax (כמו שראינו בפרק 10.1), והקרנל פשוט קורא ל-sys_call_table[rax]().

תכנות גנרי עם void* - generic programming

בC אין templates או generics כמו בשפות אחרות. במקום זה, משתמשים ב-void* - מצביע שיכול להצביע על כל סוג:

typedef int (*compare_fn)(const void *, const void *);

// a generic function that finds the minimal element in the array
void *find_min(void *arr, int n, int elem_size, compare_fn cmp) {
    void *min = arr;
    for (int i = 1; i < n; i++) {
        void *current = (char *)arr + i * elem_size;
        if (cmp(current, min) < 0)
            min = current;
    }
    return min;
}

עכשיו אפשר להשתמש בה עם כל סוג:

int compare_ints(const void *a, const void *b) {
    return *(int *)a - *(int *)b;
}

int compare_strings(const void *a, const void *b) {
    return strcmp(*(char **)a, *(char **)b);
}

int main(void) {
    int numbers[] = {5, 2, 8, 1, 9};
    int *min_num = find_min(numbers, 5, sizeof(int), compare_ints);
    printf("min number: %d\n", *min_num);

    char *words[] = {"banana", "apple", "cherry"};
    char **min_word = find_min(words, 3, sizeof(char *), compare_strings);
    printf("min word: %s\n", *min_word);

    return 0;
}

שימו לב לcasting: (char *)arr + i * elem_size - מוסיפים i * elem_size בתים לכתובת. אנחנו עושים cast ל-char* כי אריתמטיקה של void לא מוגדרת בC סטנדרטי, ו-char מתקדם בבתים בודדים.

פולימורפיזם בC - struct עם מצביעי פונקציות

אפשר לדמות תכנות מונחה עצמים בC על ידי struct שמכיל מצביעי פונקציות - בדומה ל-virtual table בC++:

#include <stdio.h>

struct animal {
    const char *name;
    void (*speak)(struct animal *self);
    void (*eat)(struct animal *self, const char *food);
};

void dog_speak(struct animal *self) {
    printf("%s says: Woof!\n", self->name);
}

void dog_eat(struct animal *self, const char *food) {
    printf("%s eats %s happily!\n", self->name, food);
}

void cat_speak(struct animal *self) {
    printf("%s says: Meow!\n", self->name);
}

void cat_eat(struct animal *self, const char *food) {
    printf("%s sniffs %s suspiciously...\n", self->name, food);
}

struct animal new_dog(const char *name) {
    return (struct animal){name, dog_speak, dog_eat};
}

struct animal new_cat(const char *name) {
    return (struct animal){name, cat_speak, cat_eat};
}

int main(void) {
    struct animal animals[] = {
        new_dog("Rex"),
        new_cat("Whiskers"),
        new_dog("Buddy"),
        new_cat("Luna"),
    };

    for (int i = 0; i < 4; i++) {
        animals[i].speak(&animals[i]);
        animals[i].eat(&animals[i], "steak");
    }

    return 0;
}

כל "מופע" של animal מכיל מצביעים לפונקציות שמתאימות לסוג שלו. כשקוראים ל-speak, הפונקציה הנכונה נקראת אוטומטית - בדיוק כמו virtual methods.

איור המראה כיצד אותה קריאה לspeak מגיעה לפונקציה אחרת לכל מופע, לפי המצביע ששמור בתוך הstruct

ככה זה עובד בקרנל של לינוקס

בפרק 6.5 למדנו על VFS (Virtual File System). הקרנל משתמש בדיוק בדפוס הזה:

// simplified - the real structure is much bigger
struct file_operations {
    ssize_t (*read)(struct file *, char __user *, size_t, loff_t *);
    ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *);
    int (*open)(struct inode *, struct file *);
    int (*release)(struct inode *, struct file *);
    long (*ioctl)(struct file *, unsigned int, unsigned long);
};

כל מערכת קבצים (ext4, tmpfs, procfs) ממלאת את הstruct הזה עם הפונקציות שלה. כשהקרנל צריך לקרוא מקובץ, הוא פשוט קורא ל-file->f_op->read() - ומגיע לקוד הנכון של מערכת הקבצים הרלוונטית.

גם דרייברי רשת (struct net_device_ops), דרייברי בלוק (struct block_device_operations), ועוד עשרות מבנים בקרנל עובדים בדפוס הזה.

signal handlers הם מצביעי פונקציות

בפרק 5.4 למדנו על signals. כשרושמים handler:

signal(SIGINT, my_handler);

אנחנו בעצם מעבירים מצביע לפונקציה my_handler. הקרנל שומר את המצביע הזה, וכשמגיע סיגנל, הוא קורא לפונקציה דרך המצביע.

גם sigaction עובד ככה:

struct sigaction sa;
sa.sa_handler = my_handler;  // pointer to the function
sigaction(SIGINT, &sa, NULL);

שיקולי אבטחה - function pointer overwriting

מצביעי פונקציות הם גם וקטור תקיפה. אם תוקף מצליח לדרוס מצביע פונקציה בזיכרון, הוא יכול לגרום לתוכנית לקפוץ לקוד זדוני:

struct callback {
    char buffer[64];
    void (*handler)(void);  // function pointer right after the buffer
};

void safe_function(void) {
    printf("safe!\n");
}

int main(void) {
    struct callback cb;
    cb.handler = safe_function;

    // buffer overflow overwrites handler!
    gets(cb.buffer);  // input longer than 64 bytes will overwrite handler

    cb.handler();  // now calls whatever the attacker put there
    return 0;
}

בפרק 7.6 למדנו על טכניקות ניצול כאלו. בפרקטיקה, הגנות כמו ASLR, stack canaries, ו-CFI (Control Flow Integrity) מנסות למנוע ניצול של מצביעי פונקציות.

טעינת פונקציות מספרייה בזמן ריצה - dlopen/dlsym

בפרק 5.10 למדנו על ספריות משותפות (.so). אפשר גם לטעון ספרייה בזמן ריצה ולחפש בה פונקציות:

#include <dlfcn.h>
#include <stdio.h>

int main(void) {
    // loading libm.so (math library)
    void *handle = dlopen("libm.so.6", RTLD_LAZY);
    if (!handle) {
        fprintf(stderr, "dlopen: %s\n", dlerror());
        return 1;
    }

    // looking up the function cos
    double (*my_cos)(double) = dlsym(handle, "cos");
    if (!my_cos) {
        fprintf(stderr, "dlsym: %s\n", dlerror());
        return 1;
    }

    // calling the function!
    printf("cos(0) = %f\n", my_cos(0.0));   // 1.000000
    printf("cos(pi) = %f\n", my_cos(3.14159)); // -1.000000

    dlclose(handle);
    return 0;
}

קומפילציה:

gcc -ldl dlopen_example.c -o dlopen_example

dlsym מחזיר void* שאנחנו עושים לו cast למצביע פונקציה. זה הבסיס למערכות plugin - נשתמש בזה בפרויקט הסיכום (10.7).

סיכום

  • מצביע פונקציה שומר כתובת של פונקציה ומאפשר לקרוא לה בעקיפין
  • typedef הופך את הסינטקס לקריא
  • callbacks - העברת פונקציה כפרמטר (כמו ב-qsort)
  • dispatch tables - מערך של מצביעי פונקציות לניתוב מהיר (כמו טבלת syscalls)
  • תכנות גנרי עם void* - מאפשר לכתוב פונקציות שעובדות עם כל סוג
  • פולימורפיזם עם struct + מצביעי פונקציות - הדפוס שהקרנל של לינוקס משתמש בו בכל מקום
  • dlopen/dlsym - טעינת פונקציות מספרייה בזמן ריצה