לדלג לתוכן

מצביעי פונקציות ו-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;      // או פשוט: 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 חדשים
- נקי יותר - פחות קוד

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

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

// מפושט ממה שיש בקרנל
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,
    // ...
};

// כשמגיע syscall עם מספר n:
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 *);

// פונקציה גנרית שמוצאת את האיבר המינימלי במערך
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.

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

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

// מפושט - המבנה האמיתי גדול הרבה יותר
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;  // מצביע לפונקציה
sigaction(SIGINT, &sa, NULL);

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

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

struct callback {
    char buffer[64];
    void (*handler)(void);  // מצביע פונקציה ישר אחרי הbuffer
};

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

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

    // buffer overflow דורס את handler!
    gets(cb.buffer);  // קלט ארוך מ-64 בתים ידרוס את handler

    cb.handler();  // עכשיו קורא למה שהתוקף שם שם
    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) {
    // טוענים את libm.so (ספריית מתמטיקה)
    void *handle = dlopen("libm.so.6", RTLD_LAZY);
    if (!handle) {
        fprintf(stderr, "dlopen: %s\n", dlerror());
        return 1;
    }

    // מחפשים את הפונקציה cos
    double (*my_cos)(double) = dlsym(handle, "cos");
    if (!my_cos) {
        fprintf(stderr, "dlsym: %s\n", dlerror());
        return 1;
    }

    // קוראים לפונקציה!
    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.6).


סיכום

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