מצביעי פונקציות ו-callbacks - function pointers¶
הקדמה¶
בפרק 3.3 למדנו על פוינטרים - משתנים ששומרים כתובת בזיכרון. ראינו פוינטרים למשתנים, למערכים, ולסטראקטים. אבל אפשר גם להצביע על פונקציות.
פונקציה בC היא בסך הכל קוד מכונה שיושב בזיכרון (ב-text segment, כמו שלמדנו בפרק 5.11). לפונקציה יש כתובת, ואפשר לשמור את הכתובת הזו במשתנה ולקרוא לפונקציה דרכו.
מצביעי פונקציות הם הבסיס ל-callbacks, dispatch tables, פולימורפיזם בC, ועוד. בואו נראה איך.
הבסיס - הגדרת מצביע לפונקציה¶
נתחיל עם פונקציה רגילה:
כדי להגדיר מצביע שיכול להצביע על הפונקציה הזו:
נפרק את הסינטקס:
- int - טיפוס ההחזרה של הפונקציה
- (*func_ptr) - שם המצביע, מוקף בסוגריים כי בלעדיהם זה יתפרש כפונקציה שמחזירה int*
- (int, int) - הפרמטרים של הפונקציה
עכשיו אפשר להצביע ולקרוא:
שם של פונקציה כבר מתנהג כמצביע (בדומה לשם של מערך), אז
func_ptr = addו-func_ptr = &addשקולים.
typedef למצביעי פונקציות¶
הסינטקס של מצביעי פונקציות מסורבל. typedef עושה את החיים קלים:
עכשיו operation_t הוא טיפוס - מצביע לפונקציה שמקבלת שני int ומחזירה int:
זה הרבה יותר קריא, במיוחד כשמעבירים מצביעי פונקציות כפרמטרים.
העברת פונקציה כפרמטר - 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) משתמשת בדיוק בעיקרון הזה:
אנחנו מעבירים לה פונקציית השוואה כ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:
אנחנו בעצם מעבירים מצביע לפונקציה my_handler. הקרנל שומר את המצביע הזה, וכשמגיע סיגנל, הוא קורא לפונקציה דרך המצביע.
גם sigaction עובד ככה:
שיקולי אבטחה - 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;
}
קומפילציה:
dlsym מחזיר void* שאנחנו עושים לו cast למצביע פונקציה. זה הבסיס למערכות plugin - נשתמש בזה בפרויקט הסיכום (10.6).
סיכום¶
- מצביע פונקציה שומר כתובת של פונקציה ומאפשר לקרוא לה בעקיפין
typedefהופך את הסינטקס לקריא- callbacks - העברת פונקציה כפרמטר (כמו ב-qsort)
- dispatch tables - מערך של מצביעי פונקציות לניתוב מהיר (כמו טבלת syscalls)
- תכנות גנרי עם void* - מאפשר לכתוב פונקציות שעובדות עם כל סוג
- פולימורפיזם עם struct + מצביעי פונקציות - הדפוס שהקרנל של לינוקס משתמש בו בכל מקום
- dlopen/dlsym - טעינת פונקציות מספרייה בזמן ריצה