BPF לקטנטנים, חלק ראשון: BPF מורחב

בהתחלה הייתה טכנולוגיה והיא נקראה BPF. הסתכלנו עליה קודם, מאמר הברית הישנה של סדרה זו. בשנת 2013, באמצעות מאמציהם של אלכסיי סטארבויטוב ודניאל בורקמן, פותחה ונכללה בליבת הלינוקס גרסה משופרת שלו, מותאמת למכונות מודרניות של 64 סיביות. הטכנולוגיה החדשה הזו נקראה בקצרה Internal BPF, ואז שונה שמה ל-Extended BPF, ועכשיו, לאחר מספר שנים, כולם פשוט קוראים לה BPF.

באופן גס, BPF מאפשר לך להריץ קוד שרירותי שסופק על ידי המשתמש במרחב ליבת לינוקס, והארכיטקטורה החדשה התבררה כמוצלחת כל כך שנצטרך עוד תריסר מאמרים כדי לתאר את כל היישומים שלה. (הדבר היחיד שהמפתחים לא עשו טוב, כפי שניתן לראות בקוד הביצועים למטה, היה יצירת לוגו הגון.)

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

תקציר המאמר

מבוא לארכיטקטורת BPF. ראשית, נסקור את ארכיטקטורת ה-BPF ממעוף הציפור ונתאר את המרכיבים העיקריים.

רישומים ומערכת פיקוד של המכונה הוירטואלית BPF. כבר יש לנו מושג על הארכיטקטורה בכללותה, נתאר את המבנה של המכונה הוירטואלית BPF.

מחזור חיים של אובייקטי BPF, מערכת קבצים bpffs. בחלק זה, נסקור מקרוב את מחזור החיים של אובייקטי BPF - תוכניות ומפות.

ניהול אובייקטים באמצעות קריאת מערכת bpf. עם קצת הבנה של המערכת שכבר קיימת, סוף סוף נראה כיצד ליצור ולתפעל אובייקטים ממרחב המשתמש באמצעות קריאת מערכת מיוחדת - bpf(2).

Пишем программы BPF с помощью libbpf. כמובן שניתן לכתוב תוכניות באמצעות קריאת מערכת. אבל זה קשה. לתרחיש מציאותי יותר, מתכנתים גרעיניים פיתחו ספרייה libbpf. ניצור שלד יישום BPF בסיסי שבו נשתמש בדוגמאות הבאות.

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

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

כלי פיתוח. סעיף עזרה כיצד להרכיב את כלי השירות והקרנל הנדרשים לניסויים.

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

מבוא לארכיטקטורת BPF

לפני שנתחיל לשקול את ארכיטקטורת BPF, נתייחס פעם אחרונה (אוי). BPF קלאסי, אשר פותחה כמענה להופעת מכונות RISC ופתר את בעיית סינון מנות יעיל. הארכיטקטורה התבררה כמוצלחת כל כך, שאחרי שנולדה בשנות התשעים המרתקות בברקלי UNIX, היא הועברה לרוב מערכות ההפעלה הקיימות, שרדה לתוך שנות העשרים המטורפות ועדיין מוצאת יישומים חדשים.

ה-BPF החדש פותח כתגובה לנפוצות של מכונות 64 סיביות, שירותי ענן והצורך המוגבר בכלים ליצירת SDN (Sתוכנות תכונה-dנבדק nרשתות). פותח על ידי מהנדסי רשת ליבה כתחליף משופר ל-BPF הקלאסי, ה-BPF החדש, פשוטו כמשמעו שישה חודשים מאוחר יותר, מצא יישומים במשימה הקשה של מעקב אחר מערכות לינוקס, ועכשיו, שש שנים לאחר הופעתו, נצטרך מאמר שלם הבא רק כדי רשום את סוגי התוכניות השונים.

תמונות מצחיקות

בבסיסו, BPF היא מכונה וירטואלית בארגז חול המאפשרת לך להריץ קוד "שרירותי" בחלל הקרנל מבלי לפגוע באבטחה. תוכניות BPF נוצרות בחלל המשתמש, נטענות לתוך הליבה ומחוברות למקור אירועים כלשהו. אירוע יכול להיות למשל משלוח של חבילה לממשק רשת, השקה של פונקציית ליבה כלשהי וכו'. במקרה של חבילה, לתוכנית BPF תהיה גישה לנתונים ולמטה-נתונים של החבילה (לקריאה ואולי גם כתיבה, תלוי בסוג התוכנית); במקרה של הפעלת פונקציית ליבה, הארגומנטים של הפונקציה, כולל מצביעים לזיכרון הליבה וכו'.

בואו נסתכל מקרוב על התהליך הזה. בתור התחלה, בואו נדבר על ההבדל הראשון מה-BPF הקלאסי, שתוכניות עבורן נכתבו ב-Assembler. בגרסה החדשה הורחבה הארכיטקטורה כך שניתן יהיה לכתוב תוכניות בשפות ברמה גבוהה, בעיקר כמובן ב-C. לשם כך פותח backend עבור llvm, המאפשר ליצור bytecode לארכיטקטורת BPF.

BPF לקטנטנים, חלק ראשון: BPF מורחב

ארכיטקטורת BPF תוכננה, בחלקה, לפעול ביעילות על מכונות מודרניות. כדי לגרום לזה לעבוד בפועל, קוד ה-BPF, לאחר שנטען לתוך הליבה, מתורגם לקוד מקורי באמצעות רכיב הנקרא JIT compiler (Jהעליון In Time). לאחר מכן, אם אתם זוכרים, ב-BPF הקלאסי התוכנית הועלתה לתוך הקרנל וצורפה למקור האירוע באופן אטומי - בהקשר של קריאת מערכת אחת. בארכיטקטורה החדשה זה קורה בשני שלבים - ראשית, הקוד נטען לתוך הקרנל באמצעות קריאת מערכת bpf(2)ולאחר מכן, מאוחר יותר, באמצעות מנגנונים אחרים המשתנים בהתאם לסוג התוכנית, התוכנית מתחברת למקור האירוע.

כאן עשויה להיות לקורא שאלה: האם זה אפשרי? כיצד מובטחת בטיחות הביצוע של קוד כזה? בטיחות הביצוע מובטחת לנו בשלב טעינת תוכניות BPF הנקראות verifier (באנגלית שלב זה נקרא verifier ואני אמשיך להשתמש במילה האנגלית):

BPF לקטנטנים, חלק ראשון: BPF מורחב

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

אז מה למדנו עד כה? המשתמש כותב תוכנית ב-C, טוען אותה לתוך הקרנל באמצעות קריאת מערכת bpf(2), שם הוא נבדק על ידי מאמת ומתורגם לקוד בתים מקורי. לאחר מכן אותו משתמש או אחר מחבר את התוכנית למקור האירוע והיא מתחילה לפעול. יש צורך להפריד בין הורדה לחיבור מכמה סיבות. ראשית, הפעלת מאמת יקרה יחסית ועל ידי הורדת אותה תוכנית מספר פעמים אנו מבזבזים זמן מחשב. שנית, איך בדיוק תוכנית מחוברת תלוי בסוג שלה, וממשק "אוניברסלי" אחד שפותח לפני שנה עשוי שלא להתאים לסוגים חדשים של תוכניות. (למרות שכעת, כשהארכיטקטורה הופכת בוגרת יותר, יש רעיון לאחד את הממשק הזה ברמה libbpf.)

הקורא הקשוב עשוי להבחין שעדיין לא סיימנו עם התמונות. ואכן, כל האמור לעיל אינו מסביר מדוע BPF משנה את התמונה מהותית בהשוואה ל-BPF הקלאסי. שני חידושים שמרחיבים משמעותית את היקף הישימות הם היכולת להשתמש בזיכרון משותף ובפונקציות עוזר ליבה. ב-BPF, זיכרון משותף מיושם באמצעות מה שנקרא מפות - מבני נתונים משותפים עם API ספציפי. הם כנראה קיבלו את השם הזה כי הסוג הראשון של המפה שהופיע היה טבלת גיבוב. אחר כך הופיעו מערכים, טבלאות גיבוב מקומיות (לכל מעבד) ומערכים מקומיים, עצי חיפוש, מפות המכילות מצביעים לתוכניות BPF ועוד הרבה יותר. מה שמעניין אותנו כעת הוא שלתוכניות BPF יש כעת את היכולת להתמיד במצב בין שיחות ולשתף אותו עם תוכניות אחרות ועם שטח משתמש.

הגישה למפות מתבצעת מתהליכי המשתמש באמצעות קריאת מערכת bpf(2), ומתוכנות BPF הפועלות בקרנל באמצעות פונקציות עוזר. יתר על כן, עוזרים קיימים לא רק כדי לעבוד עם מפות, אלא גם כדי לגשת ליכולות ליבה אחרות. לדוגמה, תוכניות BPF יכולות להשתמש בפונקציות מסייעות כדי להעביר מנות לממשקים אחרים, ליצור אירועי התאמה, לגשת למבני ליבה וכן הלאה.

BPF לקטנטנים, חלק ראשון: BPF מורחב

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

זה כבר דומה ליכולות שמספקות מודולי הקרנל, שלעומתם ל-BPF יש כמה יתרונות (כמובן, ניתן להשוות רק יישומים דומים, למשל, מעקב אחר מערכת - אי אפשר לכתוב דרייבר שרירותי עם BPF). ניתן לשים לב לסף כניסה נמוך יותר (חלק מהכלי עזר המשתמשים ב-BPF אינם דורשים מהמשתמש כישורי תכנות ליבה, או כישורי תכנות באופן כללי), בטיחות בזמן ריצה (הרם יד בהערות למי שלא שבר את המערכת בעת הכתיבה או בדיקת מודולים), אטומיות - ישנה השבתה בעת טעינת מודולים מחדש, ותת-מערכת ה-BPF מבטיחה שלא תפספסו אירועים (למען ההגינות, זה לא נכון לכל סוגי תוכניות ה-BPF).

הנוכחות של יכולות כאלה הופכת את BPF לכלי אוניברסלי להרחבת הליבה, מה שאושר בפועל: יותר ויותר סוגים חדשים של תוכניות מתווספים ל-BPF, יותר ויותר חברות גדולות משתמשות ב-BPF על שרתי קרב 24×7, יותר ויותר סטארטאפים בונים את העסק שלהם על פתרונות המבוססים על BPF. משתמשים ב-BPF בכל מקום: בהגנה מפני התקפות DDoS, יצירת SDN (לדוגמה, הטמעת רשתות עבור kubernetes), ככלי מעקב המערכת הראשי ואוסף סטטיסטיקות, במערכות זיהוי פריצות ומערכות ארגז חול וכו'.

בואו נסיים את חלק הסקירה של המאמר כאן ונתבונן במכונה הוירטואלית ובמערכת האקולוגית של BPF ביתר פירוט.

סטייה: כלי עזר

על מנת שתוכל להפעיל את הדוגמאות בסעיפים הבאים, ייתכן שתזדקק למספר כלי עזר, לפחות llvm/clang עם תמיכת bpf ו bpftool. בקטע כלי פיתוח אתה יכול לקרוא את ההוראות להרכבת כלי השירות, כמו גם את הקרנל שלך. סעיף זה ממוקם להלן כדי לא להפריע להרמוניה של המצגת שלנו.

רישומי מכונות וירטואליות של BPF ומערכת הוראות

הארכיטקטורה ומערכת הפיקוד של BPF פותחו תוך התחשבות בעובדה שתוכנות ייכתבו בשפת C ולאחר טעינה לקרנל, יתורגמו לקוד מקורי. לכן, מספר האוגרים ומערך הפקודות נבחרו מתוך עין לצומת, במובן המתמטי, של היכולות של מכונות מודרניות. בנוסף, הוטלו הגבלות שונות על תוכניות, למשל עד לאחרונה לא ניתן היה לכתוב לולאות ותתי שגרות, ומספר ההוראות הוגבל ל-4096 (כעת תוכנות מועדפות יכולות לטעון עד מיליון הוראות).

ל-BPF יש אחד עשר אוגרים של 64 סיביות הנגישים למשתמש r0-r10 ומונה תוכניות. הירשם r10 מכיל מצביע מסגרת והוא לקריאה בלבד. לתוכניות יש גישה לערימה של 512 בתים בזמן ריצה וכמות בלתי מוגבלת של זיכרון משותף בצורה של מפות.

תוכניות BPF מורשות להפעיל קבוצה מסוימת של עוזרי ליבה מסוג תוכנית ולאחרונה, פונקציות רגילות. כל פונקציה שנקראת יכולה לקחת עד חמישה ארגומנטים, המועברים ברגיסטרים r1-r5, וערך ההחזרה מועבר אל r0. מובטח כי לאחר החזרה מהפונקציה, תוכן הרגיסטרים r6-r9 לא ישתנה.

לתרגום תוכנית יעיל, נרשם r0-r11 עבור כל הארכיטקטורות הנתמכות ממופה באופן ייחודי לריסטרים אמיתיים, תוך התחשבות בתכונות ה-ABI של הארכיטקטורה הנוכחית. למשל, עבור x86_64 רושמת r1-r5, המשמשים להעברת פרמטרי פונקציה, מוצגים ב- rdi, rsi, rdx, rcx, r8, המשמשים להעברת פרמטרים לפונקציות על x86_64. לדוגמה, הקוד משמאל מתורגם לקוד מימין כך:

1:  (b7) r1 = 1                    mov    $0x1,%rdi
2:  (b7) r2 = 2                    mov    $0x2,%rsi
3:  (b7) r3 = 3                    mov    $0x3,%rdx
4:  (b7) r4 = 4                    mov    $0x4,%rcx
5:  (b7) r5 = 5                    mov    $0x5,%r8
6:  (85) call pc+1                 callq  0x0000000000001ee8

הקופה r0 משמש גם להחזרת התוצאה של הפעלת התוכנית, וברישום r1 התוכנית מועברת מצביע להקשר - תלוי בסוג התוכנית, זה יכול להיות, למשל, מבנה struct xdp_md (עבור XDP) או מבנה struct __sk_buff (עבור תוכניות רשת שונות) או מבנה struct pt_regs (עבור סוגים שונים של תוכניות מעקב) וכו'.

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

נמשיך בתיאור ונדבר על מערכת הפקודה לעבודה עם אובייקטים אלו. את כל (כמעט כולם) להוראות BPF יש גודל קבוע של 64 סיביות. אם תסתכל על הוראה אחת במכונת 64-bit Big Endian תראה

BPF לקטנטנים, חלק ראשון: BPF מורחב

כאן Code - זה הקידוד של ההוראה, Dst/Src הם הקידוד של המקלט והמקור, בהתאמה, Off - הזחה חתומה של 16 סיביות, ו Imm הוא מספר שלם בסימן 32 סיביות המשמש בהוראות מסוימות (בדומה לקבוע cBPF K). הַצפָּנָה Code יש אחד משני סוגים:

BPF לקטנטנים, חלק ראשון: BPF מורחב

שיעורי ההוראה 0, 1, 2, 3 מגדירים פקודות לעבודה עם זיכרון. הֵם נקראים, BPF_LD, BPF_LDX, BPF_ST, BPF_STX, בהתאמה. כיתות 4, 7 (BPF_ALU, BPF_ALU64) מהווים קבוצה של הוראות ALU. כיתות 5, 6 (BPF_JMP, BPF_JMP32) מכילים הוראות קפיצה.

התוכנית הנוספת ללימוד מערכת ההוראות BPF היא כדלקמן: במקום לפרט בקפדנות את כל ההוראות והפרמטרים שלהן, נסתכל על כמה דוגמאות בחלק זה ומהן יתברר כיצד ההוראות פועלות בפועל וכיצד לפרק ידנית כל קובץ בינארי עבור BPF. כדי לגבש את החומר בהמשך המאמר, ניפגש גם עם הנחיות פרטניות בחלקים על Verifier, מהדר JIT, תרגום של BPF קלאסי, כמו גם בעת לימוד מפות, קריאה לפונקציות וכו'.

כאשר אנו מדברים על הוראות בודדות, נתייחס לקבצי הליבה bpf.h и bpf_common.h, המגדירים את הקודים המספריים של הוראות BPF. בעת לימוד ארכיטקטורה בעצמך ו/או ניתוח קבצים בינאריים, ניתן למצוא סמנטיקה במקורות הבאים, ממוינים לפי סדר מורכבות: מפרט eBPF לא רשמי, מדריך BPF ו-XDP, ערכת הוראות, תיעוד/רשתות/filter.txt וכמובן, בקוד המקור של לינוקס - מאמת, JIT, מתורגמן BPF.

דוגמה: פירוק BPF בראש

הבה נסתכל על דוגמה שבה אנו מרכיבים תוכנית readelf-example.c והסתכל על הבינארי המתקבל. נחשוף את התוכן המקורי readelf-example.c להלן, לאחר שנחזיר את ההיגיון שלו מקודים בינאריים:

$ clang -target bpf -c readelf-example.c -o readelf-example.o -O2
$ llvm-readelf -x .text readelf-example.o
Hex dump of section '.text':
0x00000000 b7000000 01000000 15010100 00000000 ................
0x00000010 b7000000 02000000 95000000 00000000 ................

עמודה ראשונה בפלט readelf הוא הזחה והתוכנית שלנו מורכבת לפיכך מארבע פקודות:

Code Dst Src Off  Imm
b7   0   0   0000 01000000
15   0   1   0100 00000000
b7   0   0   0000 02000000
95   0   0   0000 00000000

קודי פקודה שווים b7, 15, b7 и 95. נזכיר ששלושת הביטים הפחות משמעותיים הם מחלקת ההוראה. במקרה שלנו, הסיביות הרביעית של כל ההוראות ריקה, ולכן מחלקות ההוראות הן 7, 5, 7, 5, בהתאמה. Class 7 היא BPF_ALU64, ו-5 הוא BPF_JMP. עבור שתי הכיתות, פורמט ההוראה זהה (ראה למעלה) ונוכל לשכתב את התוכנית שלנו כך (במקביל נשכתב את העמודות הנותרות בצורת אדם):

Op S  Class   Dst Src Off  Imm
b  0  ALU64   0   0   0    1
1  0  JMP     0   1   1    0
b  0  ALU64   0   0   0    2
9  0  JMP     0   0   0    0

מבצע b כיתה ALU64 - האם BPF_MOV. הוא מקצה ערך לאוגר היעד. אם הביט מוגדר s (מקור), אז הערך נלקח מאוגר המקור, ואם, כמו במקרה שלנו, הוא לא מוגדר, אז הערך נלקח מהשדה Imm. אז בהוראות הראשון והשלישי אנחנו מבצעים את הפעולה r0 = Imm. יתר על כן, פעולת JMP class 1 היא BPF_JEQ (קפוץ אם שווה). בענייננו, מאז המעט S הוא אפס, הוא משווה את הערך של אוגר המקור עם השדה Imm. אם הערכים עולים בקנה אחד, המעבר מתרחש ל PC + Offאיפה PC, כרגיל, מכיל את הכתובת של ההוראה הבאה. לבסוף, מבצע JMP Class 9 הוא BPF_EXIT. הוראה זו מפסיקה את התוכנית וחוזרת לקרנל r0. בואו נוסיף עמודה חדשה לטבלה שלנו:

Op    S  Class   Dst Src Off  Imm    Disassm
MOV   0  ALU64   0   0   0    1      r0 = 1
JEQ   0  JMP     0   1   1    0      if (r1 == 0) goto pc+1
MOV   0  ALU64   0   0   0    2      r0 = 2
EXIT  0  JMP     0   0   0    0      exit

נוכל לכתוב זאת מחדש בצורה נוחה יותר:

     r0 = 1
     if (r1 == 0) goto END
     r0 = 2
END:
     exit

אם נזכור מה יש בפנקס r1 התוכנית מועברת מצביע להקשר מהקרנל, וברישום r0 הערך מוחזר לקרנל, אז נוכל לראות שאם המצביע להקשר הוא אפס, אז נחזיר 1, ואחרת - 2. בוא נבדוק שאנחנו צודקים על ידי הסתכלות על המקור:

$ cat readelf-example.c
int foo(void *ctx)
{
        return ctx ? 2 : 1;
}

כן, זו תוכנית חסרת משמעות, אבל היא מתורגמת לארבע הוראות פשוטות בלבד.

דוגמה לחריגה: הוראה של 16 בתים

הזכרנו קודם שחלק מההוראות תופסות יותר מ-64 סיביות. זה חל, למשל, על הוראות lddw (קוד = 0x18 = BPF_LD | BPF_DW | BPF_IMM) - טען מילה כפולה מהשדות לרישום Imm. העובדה היא Imm יש גודל של 32, ומילה כפולה היא 64 סיביות, כך שהטעינה של ערך מיידי של 64 סיביות לתוך אוגר בהוראה אחת של 64 סיביות לא תעבוד. לשם כך, שתי הוראות סמוכות משמשות לאחסון החלק השני של ערך 64 הסיביות בשדה Imm. דוגמא:

$ cat x64.c
long foo(void *ctx)
{
        return 0x11223344aabbccdd;
}
$ clang -target bpf -c x64.c -o x64.o -O2
$ llvm-readelf -x .text x64.o
Hex dump of section '.text':
0x00000000 18000000 ddccbbaa 00000000 44332211 ............D3".
0x00000010 95000000 00000000                   ........

יש רק שתי הוראות בתוכנית בינארית:

Binary                                 Disassm
18000000 ddccbbaa 00000000 44332211    r0 = Imm[0]|Imm[1]
95000000 00000000                      exit

ניפגש שוב עם הנחיות lddw, כשמדברים על רילוקיישן ועבודה עם מפות.

דוגמה: פירוק BPF באמצעות כלים סטנדרטיים

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

$ llvm-objdump -d x64.o

Disassembly of section .text:

0000000000000000 <foo>:
 0: 18 00 00 00 dd cc bb aa 00 00 00 00 44 33 22 11 r0 = 1234605617868164317 ll
 2: 95 00 00 00 00 00 00 00 exit

מחזור חיים של אובייקטי BPF, מערכת קבצים bpffs

(למדתי לראשונה כמה מהפרטים המתוארים בתת-סעיף זה מ הודעה אלכסיי סטארבויטוב נכנס בלוג BPF.)

אובייקטי BPF - תוכניות ומפות - נוצרים ממרחב המשתמש באמצעות פקודות BPF_PROG_LOAD и BPF_MAP_CREATE שיחת מערכת bpf(2), נדבר על איך זה קורה בדיוק בסעיף הבא. זה יוצר מבני נתונים של ליבה ולכל אחד מהם refcount (ספירת הפניות) מוגדרת לאחד, ומתאר קובץ המצביע על האובייקט מוחזר למשתמש. לאחר סגירת הידית refcount האובייקט מצטמצם באחד, וכאשר הוא מגיע לאפס, האובייקט מושמד.

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

BPF לקטנטנים, חלק ראשון: BPF מורחב

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

מה יקרה אם נסגור כעת את טוען האתחול? זה תלוי בסוג מחולל האירועים (הוק). כל ווי הרשת יתקיימו לאחר סיום הטעינה, אלו הם מה שנקרא ווים גלובליים. ולדוגמא, תוכניות מעקב ישוחררו לאחר שהתהליך שיצר אותן יסתיים (ולכן נקראות מקומיות, מ"מקומי לתהליך"). מבחינה טכנית, ל-hooks יש תמיד מתאר קובץ מתאים בחלל המשתמש ולכן נסגרים כשהתהליך סגור, אבל ל-hooks הגלובלי אין. באיור הבא, באמצעות צלבים אדומים, אני מנסה להראות כיצד סיום תוכנית הטעינה משפיע על אורך החיים של אובייקטים במקרה של ווים מקומיים וגלובליים.

BPF לקטנטנים, חלק ראשון: BPF מורחב

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

מצד שני, דמיינו שאתם רוצים להתחבר לנקודת עקיבה בקרנל ולאסוף סטטיסטיקות לאורך שנים רבות. במקרה זה, תרצה להשלים את חלק המשתמש ולחזור לסטטיסטיקה מעת לעת. מערכת הקבצים bpf מספקת הזדמנות זו. זוהי מערכת פסאודו-קבצים בזיכרון בלבד המאפשרת יצירת קבצים המתייחסים לאובייקטי BPF ובכך מגדילים refcount חפצים. לאחר מכן, המטען יכול לצאת, והאובייקטים שהוא יצר יישארו בחיים.

BPF לקטנטנים, חלק ראשון: BPF מורחב

יצירת קבצים ב-bpffs שמתייחסים לאובייקטי BPF נקראת "הצמדה" (כמו בביטוי הבא: "תהליך יכול להצמיד תוכנית או מפה של BPF"). יצירת אובייקטי קבצים עבור אובייקטי BPF הגיונית לא רק להארכת החיים של אובייקטים מקומיים, אלא גם עבור השימושיות של אובייקטים גלובליים - אם נחזור לדוגמה עם תוכנית ההגנה העולמית DDoS, אנחנו רוצים להיות מסוגלים לבוא ולהסתכל בסטטיסטיקה מעת לעת.

מערכת הקבצים BPF מותקנת בדרך כלל /sys/fs/bpf, אך ניתן להרכיב אותו גם באופן מקומי, לדוגמה, כך:

$ mkdir bpf-mountpoint
$ sudo mount -t bpf none bpf-mountpoint

שמות מערכת הקבצים נוצרים באמצעות הפקודה BPF_OBJ_PIN קריאת מערכת BPF. לשם המחשה, בואו ניקח תוכנית, נלמד אותה, נעלה אותה ונצמיד אותה אליה bpffs. התוכנית שלנו לא עושה שום דבר מועיל, אנחנו רק מציגים את הקוד כדי שתוכל לשחזר את הדוגמה:

$ cat test.c
__attribute__((section("xdp"), used))
int test(void *ctx)
{
        return 0;
}

char _license[] __attribute__((section("license"), used)) = "GPL";

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

$ clang -target bpf -c test.c -o test.o
$ mkdir bpf-mountpoint
$ sudo mount -t bpf none bpf-mountpoint

עכשיו בואו נוריד את התוכנית שלנו באמצעות כלי השירות bpftool והסתכל על קריאות המערכת הנלוות bpf(2) (כמה שורות לא רלוונטיות הוסרו מפלט ה-strace):

$ sudo strace -e bpf bpftool prog load ./test.o bpf-mountpoint/test
bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_XDP, prog_name="test", ...}, 120) = 3
bpf(BPF_OBJ_PIN, {pathname="bpf-mountpoint/test", bpf_fd=3}, 120) = 0

כאן טענו את התוכנית באמצעות BPF_PROG_LOAD, קיבל מתאר קובץ מהקרנל 3 ושימוש בפקודה BPF_OBJ_PIN הצמיד את מתאר הקובץ הזה כקובץ "bpf-mountpoint/test". לאחר מכן תוכנית האתחול bpftool סיימו לעבוד, אבל התוכנית שלנו נשארה בליבה, למרות שלא צירפנו אותה לאף ממשק רשת:

$ sudo bpftool prog | tail -3
783: xdp  name test  tag 5c8ba0cf164cb46c  gpl
        loaded_at 2020-05-05T13:27:08+0000  uid 0
        xlated 24B  jited 41B  memlock 4096B

אנו יכולים למחוק את אובייקט הקובץ בדרך כלל unlink(2) ולאחר מכן התוכנית המתאימה תימחק:

$ sudo rm ./bpf-mountpoint/test
$ sudo bpftool prog show id 783
Error: get by id (783): No such file or directory

מחיקת אובייקטים

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

סוגים מסוימים של תוכניות BPF מאפשרים לך להחליף את התוכנית תוך כדי תנועה, כלומר. לספק אטומיות רצף replace = detach old program, attach new program. במקרה זה, כל המופעים הפעילים של הגרסה הישנה של התוכנית יסיימו את עבודתם, ומטפלי אירועים חדשים ייווצרו מהתוכנית החדשה, ו"אטומיות" כאן אומר שאף אירוע אחד לא יתפספס.

צירוף תוכניות למקורות אירועים

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

מניפולציה של אובייקטים באמצעות קריאת מערכת bpf

תוכניות BPF

כל אובייקטי BPF נוצרים ומנוהלים ממרחב המשתמש באמצעות קריאת מערכת bpf, בעל אב הטיפוס הבא:

#include <linux/bpf.h>

int bpf(int cmd, union bpf_attr *attr, unsigned int size);

הנה הצוות cmd הוא אחד מערכי הסוג enum bpf_cmd, attr - מצביע לפרמטרים עבור תוכנית ספציפית ו size - גודל אובייקט לפי המצביע, כלומר. בדרך כלל זה sizeof(*attr). בקרנל 5.8 קריאת המערכת bpf תומך ב-34 פקודות שונות, ו הגדרה union bpf_attr תופסת 200 קווים. אך אל לנו להיבהל מכך, מכיוון שאנו נכיר את הפקודות והפרמטרים במהלך מספר מאמרים.

בואו נתחיל עם הצוות BPF_PROG_LOAD, שיוצר תוכניות BPF - לוקח קבוצה של הוראות BPF וטוען אותה לתוך הקרנל. ברגע הטעינה, המאמת מופעל ולאחר מכן המהדר JIT ולאחר ביצוע מוצלח, מתאר קובץ התוכנית מוחזר למשתמש. ראינו מה קורה איתו בהמשך הסעיף הקודם על מחזור החיים של חפצי BPF.

כעת נכתוב תוכנית מותאמת אישית שתטען תוכנית BPF פשוטה, אבל קודם צריך להחליט איזה סוג של תוכנית אנחנו רוצים לטעון - נצטרך לבחור тип ובמסגרת מסוג זה לכתוב תוכנית שתעבור את מבחן המאמת. עם זאת, כדי לא לסבך את התהליך, הנה פתרון מוכן: ניקח תוכנית כמו BPF_PROG_TYPE_XDP, שיחזיר את הערך XDP_PASS (דלג על כל החבילות). ב-BPF assembler זה נראה מאוד פשוט:

r0 = 2
exit

אחרי שהחלטנו כי אנחנו נעלה, נוכל להגיד לך איך נעשה את זה:

#define _GNU_SOURCE
#include <string.h>
#include <unistd.h>
#include <sys/syscall.h>
#include <linux/bpf.h>

static inline __u64 ptr_to_u64(const void *ptr)
{
        return (__u64) (unsigned long) ptr;
}

int main(void)
{
    struct bpf_insn insns[] = {
        {
            .code = BPF_ALU64 | BPF_MOV | BPF_K,
            .dst_reg = BPF_REG_0,
            .imm = XDP_PASS
        },
        {
            .code = BPF_JMP | BPF_EXIT
        },
    };

    union bpf_attr attr = {
        .prog_type = BPF_PROG_TYPE_XDP,
        .insns     = ptr_to_u64(insns),
        .insn_cnt  = sizeof(insns)/sizeof(insns[0]),
        .license   = ptr_to_u64("GPL"),
    };

    strncpy(attr.prog_name, "woo", sizeof(attr.prog_name));
    syscall(__NR_bpf, BPF_PROG_LOAD, &attr, sizeof(attr));

    for ( ;; )
        pause();
}

אירועים מעניינים בתוכנית מתחילים בהגדרה של מערך insns - תוכנית ה-BPF שלנו בקוד מכונה. במקרה זה, כל הוראה של תוכנית BPF ארוזה במבנה bpf_insn. אלמנט ראשון insns עומד בהוראות r0 = 2, השני - exit.

לָסֶגֶת. הליבה מגדירה פקודות מאקרו נוחות יותר לכתיבת קודי מכונה, ושימוש בקובץ כותרות הליבה tools/include/linux/filter.h יכולנו לכתוב

struct bpf_insn insns[] = {
    BPF_MOV64_IMM(BPF_REG_0, XDP_PASS),
    BPF_EXIT_INSN()
};

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

לאחר הגדרת תוכנית ה-BPF, אנו עוברים לטעינתה לתוך הקרנל. סט הפרמטרים המינימליסטי שלנו attr כולל את סוג התוכנית, הסט ומספר ההוראות, הרישיון הנדרש והשם "woo", שבה אנו משתמשים כדי למצוא את התוכנית שלנו במערכת לאחר ההורדה. התוכנית, כפי שהובטח, נטענת למערכת באמצעות קריאת מערכת bpf.

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

ובכן, אנחנו מוכנים לבדיקה. בואו להרכיב ולהפעיל את התוכנית תחת straceכדי לבדוק שהכל עובד כמו שצריך:

$ clang -g -O2 simple-prog.c -o simple-prog

$ sudo strace ./simple-prog
execve("./simple-prog", ["./simple-prog"], 0x7ffc7b553480 /* 13 vars */) = 0
...
bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_XDP, insn_cnt=2, insns=0x7ffe03c4ed50, license="GPL", log_level=0, log_size=0, log_buf=NULL, kern_version=KERNEL_V
ERSION(0, 0, 0), prog_flags=0, prog_name="woo", prog_ifindex=0, expected_attach_type=BPF_CGROUP_INET_INGRESS}, 72) = 3
pause(

הכל בסדר, bpf(2) החזירו לנו ידית 3 ונכנסנו ללולאה אינסופית עם pause(). בואו ננסה למצוא את התוכנית שלנו במערכת. לשם כך נלך לטרמינל אחר ונשתמש בכלי השירות bpftool:

# bpftool prog | grep -A3 woo
390: xdp  name woo  tag 3b185187f1855c4c  gpl
        loaded_at 2020-08-31T24:66:44+0000  uid 0
        xlated 16B  jited 40B  memlock 4096B
        pids simple-prog(10381)

אנו רואים שיש תוכנית טעונה על המערכת woo שהזיהוי הגלובלי שלו הוא 390 ונמצא כעת בתהליך simple-prog יש מתאר קובץ פתוח המצביע על התוכנית (ואם simple-prog אז יסיים את העבודה woo ייעלם). כצפוי, התוכנית woo לוקח 16 בתים - שתי הוראות - של קודים בינאריים בארכיטקטורת BPF, אבל בצורתו המקורית (x86_64) זה כבר 40 בתים. בואו נסתכל על התוכנית שלנו בצורתה המקורית:

# bpftool prog dump xlated id 390
   0: (b7) r0 = 2
   1: (95) exit

ללא הפתעות. עכשיו בואו נסתכל על הקוד שנוצר על ידי מהדר JIT:

# bpftool prog dump jited id 390
bpf_prog_3b185187f1855c4c_woo:
   0:   nopl   0x0(%rax,%rax,1)
   5:   push   %rbp
   6:   mov    %rsp,%rbp
   9:   sub    $0x0,%rsp
  10:   push   %rbx
  11:   push   %r13
  13:   push   %r14
  15:   push   %r15
  17:   pushq  $0x0
  19:   mov    $0x2,%eax
  1e:   pop    %rbx
  1f:   pop    %r15
  21:   pop    %r14
  23:   pop    %r13
  25:   pop    %rbx
  26:   leaveq
  27:   retq

לא מאוד יעיל עבור exit(2), אבל למען ההגינות, התוכנית שלנו פשוטה מדי, ולתוכניות לא טריוויאליות יש צורך, כמובן, בפרולוג והאפילוג שנוספו על ידי מהדר JIT.

מפות

תוכניות BPF יכולות להשתמש באזורי זיכרון מובנים הנגישים הן לתוכניות BPF אחרות והן לתוכניות בחלל המשתמש. אובייקטים אלו נקראים מפות ובחלק זה נראה כיצד לתפעל אותם באמצעות קריאת מערכת bpf.

נניח מיד שהיכולות של מפות אינן מוגבלות רק לגישה לזיכרון משותף. ישנן מפות למטרות מיוחדות המכילות, למשל, מצביעים לתוכניות BPF או מצביעים לממשקי רשת, מפות לעבודה עם אירועי perf וכו'. לא נדבר עליהם כאן, כדי לא לבלבל את הקורא. מלבד זאת, אנו מתעלמים מבעיות סנכרון, מכיוון שזה לא חשוב לדוגמאות שלנו. רשימה מלאה של סוגי מפות זמינים ניתן למצוא ב <linux/bpf.h>, ובסעיף זה ניקח כדוגמה את הסוג הראשון מבחינה היסטורית, טבלת הגיבוב BPF_MAP_TYPE_HASH.

אם אתה יוצר טבלת גיבוב, למשל, C++, היית אומר unordered_map<int,long> woo, שפירושו ברוסית "אני צריך שולחן woo גודל בלתי מוגבל, שהמפתחות שלהם הם מסוג int, והערכים הם מהסוג long" על מנת ליצור טבלת גיבוב BPF, עלינו לעשות כמעט אותו דבר, פרט לכך שעלינו לציין את הגודל המקסימלי של הטבלה, ובמקום לציין את סוגי המפתחות והערכים, עלינו לציין את הגדלים שלהם בבתים . כדי ליצור מפות השתמש בפקודה BPF_MAP_CREATE שיחת מערכת bpf. בואו נסתכל על תוכנה מינימלית פחות או יותר שיוצרת מפה. לאחר התוכנית הקודמת שטוענת תוכניות BPF, זו אמורה להיראות לך פשוטה:

$ cat simple-map.c
#define _GNU_SOURCE
#include <string.h>
#include <unistd.h>
#include <sys/syscall.h>
#include <linux/bpf.h>

int main(void)
{
    union bpf_attr attr = {
        .map_type = BPF_MAP_TYPE_HASH,
        .key_size = sizeof(int),
        .value_size = sizeof(int),
        .max_entries = 4,
    };
    strncpy(attr.map_name, "woo", sizeof(attr.map_name));
    syscall(__NR_bpf, BPF_MAP_CREATE, &attr, sizeof(attr));

    for ( ;; )
        pause();
}

כאן אנו מגדירים קבוצה של פרמטרים attr, שבו אנו אומרים "אני צריך טבלת גיבוב עם מפתחות וערכי גודל sizeof(int), שבהם אני יכול לשים מקסימום ארבעה אלמנטים." בעת יצירת מפות BPF, אתה יכול לציין פרמטרים אחרים, למשל, באותו אופן כמו בדוגמה עם התוכנית, ציינו את שם האובייקט בתור "woo".

בואו נקמפל ונפעיל את התוכנית:

$ clang -g -O2 simple-map.c -o simple-map
$ sudo strace ./simple-map
execve("./simple-map", ["./simple-map"], 0x7ffd40a27070 /* 14 vars */) = 0
...
bpf(BPF_MAP_CREATE, {map_type=BPF_MAP_TYPE_HASH, key_size=4, value_size=4, max_entries=4, map_name="woo", ...}, 72) = 3
pause(

הנה שיחת המערכת bpf(2) החזיר לנו את מספר המפה המתאר 3 ולאחר מכן התוכנית, כצפוי, ממתינה להנחיות נוספות בשיחת המערכת pause(2).

כעת נשלח את התוכנית שלנו לרקע או נפתח מסוף אחר ונסתכל על האובייקט שלנו באמצעות כלי השירות bpftool (אנו יכולים להבחין במפה שלנו מאחרות לפי שמה):

$ sudo bpftool map
...
114: hash  name woo  flags 0x0
        key 4B  value 4B  max_entries 4  memlock 4096B
...

המספר 114 הוא המזהה העולמי של האובייקט שלנו. כל תוכנית במערכת יכולה להשתמש במזהה זה כדי לפתוח מפה קיימת באמצעות הפקודה BPF_MAP_GET_FD_BY_ID שיחת מערכת bpf.

עכשיו אנחנו יכולים לשחק עם שולחן ה-hash שלנו. בואו נסתכל על תוכנו:

$ sudo bpftool map dump id 114
Found 0 elements

ריק. בואו נשים בו ערך hash[1] = 1:

$ sudo bpftool map update id 114 key 1 0 0 0 value 1 0 0 0

בואו נסתכל שוב בטבלה:

$ sudo bpftool map dump id 114
key: 01 00 00 00  value: 01 00 00 00
Found 1 element

הידד! הצלחנו להוסיף אלמנט אחד. שימו לב שעלינו לעבוד ברמת הבתים כדי לעשות זאת, שכן bptftool לא יודע מה סוג הערכים בטבלת ה-hash. (ניתן להעביר לה את הידע הזה באמצעות BTF, אבל עוד על זה עכשיו.)

איך בדיוק bpftool קורא ומוסיף אלמנטים? בואו נסתכל מתחת למכסה המנוע:

$ sudo strace -e bpf bpftool map dump id 114
bpf(BPF_MAP_GET_FD_BY_ID, {map_id=114, next_id=0, open_flags=0}, 120) = 3
bpf(BPF_MAP_GET_NEXT_KEY, {map_fd=3, key=NULL, next_key=0x55856ab65280}, 120) = 0
bpf(BPF_MAP_LOOKUP_ELEM, {map_fd=3, key=0x55856ab65280, value=0x55856ab652a0}, 120) = 0
key: 01 00 00 00  value: 01 00 00 00
bpf(BPF_MAP_GET_NEXT_KEY, {map_fd=3, key=0x55856ab65280, next_key=0x55856ab65280}, 120) = -1 ENOENT

ראשית פתחנו את המפה לפי המזהה הגלובלי שלה באמצעות הפקודה BPF_MAP_GET_FD_BY_ID и bpf(2) החזיר לנו מתאר 3. שימוש נוסף בפקודה BPF_MAP_GET_NEXT_KEY מצאנו את המפתח הראשון בטבלה על ידי מעבר NULL כמצביע למקש "הקודם". אם יש לנו את המפתח נוכל לעשות BPF_MAP_LOOKUP_ELEMמה שמחזיר ערך למצביע value. השלב הבא הוא שננסה למצוא את האלמנט הבא על ידי העברת מצביע למפתח הנוכחי, אבל הטבלה שלנו מכילה רק אלמנט אחד ואת הפקודה BPF_MAP_GET_NEXT_KEY החזרות ENOENT.

אוקיי, בוא נשנה את הערך על ידי מקש 1, נניח שההיגיון העסקי שלנו דורש רישום hash[1] = 2:

$ sudo strace -e bpf bpftool map update id 114 key 1 0 0 0 value 2 0 0 0
bpf(BPF_MAP_GET_FD_BY_ID, {map_id=114, next_id=0, open_flags=0}, 120) = 3
bpf(BPF_MAP_UPDATE_ELEM, {map_fd=3, key=0x55dcd72be260, value=0x55dcd72be280, flags=BPF_ANY}, 120) = 0

כצפוי, זה מאוד פשוט: הפקודה BPF_MAP_GET_FD_BY_ID פותח את המפה שלנו לפי תעודת זהות, והפקודה BPF_MAP_UPDATE_ELEM מחליף את האלמנט.

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

  • BPF_MAP_LOOKUP_ELEM: מצא ערך לפי מפתח
  • BPF_MAP_UPDATE_ELEM: עדכן/צור ערך
  • BPF_MAP_DELETE_ELEM: הסר מפתח
  • BPF_MAP_GET_NEXT_KEY: מצא את המקש הבא (או הראשון).
  • BPF_MAP_GET_NEXT_ID: מאפשר לך לעבור על כל המפות הקיימות, כך זה עובד bpftool map
  • BPF_MAP_GET_FD_BY_ID: פתח מפה קיימת לפי המזהה הגלובלי שלה
  • BPF_MAP_LOOKUP_AND_DELETE_ELEM: עדכן אטומי את הערך של אובייקט והחזר את הישן
  • BPF_MAP_FREEZE: הפוך את המפה לבלתי ניתנת לשינוי ממרחב המשתמש (לא ניתן לבטל פעולה זו)
  • BPF_MAP_LOOKUP_BATCH, BPF_MAP_LOOKUP_AND_DELETE_BATCH, BPF_MAP_UPDATE_BATCH, BPF_MAP_DELETE_BATCH: פעולות המוניות. לדוגמה, BPF_MAP_LOOKUP_AND_DELETE_BATCH - זוהי הדרך האמינה היחידה לקרוא ולאפס את כל הערכים מהמפה

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

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

$ sudo bpftool map update id 114 key 2 0 0 0 value 1 0 0 0
$ sudo bpftool map update id 114 key 3 0 0 0 value 1 0 0 0
$ sudo bpftool map update id 114 key 4 0 0 0 value 1 0 0 0

בינתיים הכל טוב:

$ sudo bpftool map dump id 114
key: 01 00 00 00  value: 01 00 00 00
key: 02 00 00 00  value: 01 00 00 00
key: 04 00 00 00  value: 01 00 00 00
key: 03 00 00 00  value: 01 00 00 00
Found 4 elements

בוא ננסה להוסיף עוד אחד:

$ sudo bpftool map update id 114 key 5 0 0 0 value 1 0 0 0
Error: update failed: Argument list too long

כצפוי, לא הצלחנו. בואו נסתכל על השגיאה ביתר פירוט:

$ sudo strace -e bpf bpftool map update id 114 key 5 0 0 0 value 1 0 0 0
bpf(BPF_MAP_GET_FD_BY_ID, {map_id=114, next_id=0, open_flags=0}, 120) = 3
bpf(BPF_OBJ_GET_INFO_BY_FD, {info={bpf_fd=3, info_len=80, info=0x7ffe6c626da0}}, 120) = 0
bpf(BPF_MAP_UPDATE_ELEM, {map_fd=3, key=0x56049ded5260, value=0x56049ded5280, flags=BPF_ANY}, 120) = -1 E2BIG (Argument list too long)
Error: update failed: Argument list too long
+++ exited with 255 +++

הכל בסדר: כצפוי, הצוות BPF_MAP_UPDATE_ELEM מנסה ליצור מפתח חדש, חמישי, אך קורס E2BIG.

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

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

כתיבת תוכניות BPF באמצעות libpf

כתיבת תוכניות BPF באמצעות קודי מכונה יכולה להיות מעניינת רק בפעם הראשונה, ואז השובע נכנס. ברגע זה אתה צריך להפנות את תשומת הלב שלך llvm, שיש לו קצה אחורי להפקת קוד עבור ארכיטקטורת BPF, כמו גם ספריה libbpf, המאפשר לך לכתוב את צד המשתמש של יישומי BPF ולטעון את הקוד של תוכניות BPF שנוצרו באמצעות llvm/clang.

למעשה, כפי שנראה במאמר זה ובמאמרים הבאים, libbpf עושה הרבה עבודה בלעדיו (או כלים דומים - iproute2, libbcc, libbpf-goוכו') אי אפשר לחיות. אחד המאפיינים הרוצחים של הפרויקט libbpf הוא BPF CO-RE (Compile Once, Run Everywhere) - פרויקט המאפשר לכתוב תוכניות BPF שניידות מגרעין אחד למשנהו, עם יכולת לרוץ על ממשקי API שונים (לדוגמה, כאשר מבנה הליבה משתנה מגרסה לגרסה). על מנת שתוכל לעבוד עם CO-RE, הליבה שלך חייבת להיות מורכבת עם תמיכת BTF (אנו מתארים כיצד לעשות זאת בסעיף כלי פיתוח. אתה יכול לבדוק אם הליבה שלך בנויה עם BTF או לא פשוט מאוד - על ידי נוכחות הקובץ הבא:

$ ls -lh /sys/kernel/btf/vmlinux
-r--r--r-- 1 root root 2.6M Jul 29 15:30 /sys/kernel/btf/vmlinux

קובץ זה מאחסן מידע על כל סוגי הנתונים המשמשים בקרנל ומשמש בכל הדוגמאות שלנו לשימוש libbpf. נדבר בפירוט על CO-RE במאמר הבא, אבל במאמר הזה - פשוט בנה לעצמך גרעין איתו CONFIG_DEBUG_INFO_BTF.

הספרייה libbpf חי ממש בספרייה tools/lib/bpf הקרנל ופיתוחו מתבצעים באמצעות רשימת התפוצה [email protected]. עם זאת, מאגר נפרד מתוחזק לצרכי יישומים החיים מחוץ לקרנל https://github.com/libbpf/libbpf שבו ספריית הליבה משקפת עבור גישת קריאה פחות או יותר כפי שהיא.

בחלק זה נבחן כיצד ניתן ליצור פרויקט המשתמש libbpf, בואו נכתוב כמה תוכניות בדיקה (חסרות משמעות פחות או יותר) וננתח בפירוט איך הכל עובד. זה יאפשר לנו להסביר ביתר קלות בסעיפים הבאים בדיוק כיצד תוכניות BPF מקיימות אינטראקציה עם מפות, עוזרי ליבה, BTF וכו'.

בדרך כלל פרויקטים באמצעות libbpf הוסף מאגר GitHub כתת-מודול git, נעשה את אותו הדבר:

$ mkdir /tmp/libbpf-example
$ cd /tmp/libbpf-example/
$ git init-db
Initialized empty Git repository in /tmp/libbpf-example/.git/
$ git submodule add https://github.com/libbpf/libbpf.git
Cloning into '/tmp/libbpf-example/libbpf'...
remote: Enumerating objects: 200, done.
remote: Counting objects: 100% (200/200), done.
remote: Compressing objects: 100% (103/103), done.
remote: Total 3354 (delta 101), reused 118 (delta 79), pack-reused 3154
Receiving objects: 100% (3354/3354), 2.05 MiB | 10.22 MiB/s, done.
Resolving deltas: 100% (2176/2176), done.

הולך ל libbpf מאוד פשוט:

$ cd libbpf/src
$ mkdir build
$ OBJDIR=build DESTDIR=root make -s install
$ find root
root
root/usr
root/usr/include
root/usr/include/bpf
root/usr/include/bpf/bpf_tracing.h
root/usr/include/bpf/xsk.h
root/usr/include/bpf/libbpf_common.h
root/usr/include/bpf/bpf_endian.h
root/usr/include/bpf/bpf_helpers.h
root/usr/include/bpf/btf.h
root/usr/include/bpf/bpf_helper_defs.h
root/usr/include/bpf/bpf.h
root/usr/include/bpf/libbpf_util.h
root/usr/include/bpf/libbpf.h
root/usr/include/bpf/bpf_core_read.h
root/usr/lib64
root/usr/lib64/libbpf.so.0.1.0
root/usr/lib64/libbpf.so.0
root/usr/lib64/libbpf.a
root/usr/lib64/libbpf.so
root/usr/lib64/pkgconfig
root/usr/lib64/pkgconfig/libbpf.pc

התוכנית הבאה שלנו בחלק זה היא כדלקמן: נכתוב תוכנית BPF כמו BPF_PROG_TYPE_XDP, זהה לדוגמא הקודמת, אבל ב-C, אנו מקמפלים אותו באמצעות clang, וכתוב תוכנית עוזר שתטען אותה לתוך הקרנל. בסעיפים הבאים נרחיב את היכולות הן של תוכנית ה-BPF והן של תוכנית העוזרים.

דוגמה: יצירת אפליקציה מלאה באמצעות libbpf

מלכתחילה אנו משתמשים בקובץ /sys/kernel/btf/vmlinux, שהוזכר לעיל, וצור את המקבילה שלו בצורה של קובץ כותרת:

$ bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h

קובץ זה יאחסן את כל מבני הנתונים הזמינים בקרנל שלנו, לדוגמה, כך מוגדרת כותרת ה-IPv4 בליבה:

$ grep -A 12 'struct iphdr {' vmlinux.h
struct iphdr {
    __u8 ihl: 4;
    __u8 version: 4;
    __u8 tos;
    __be16 tot_len;
    __be16 id;
    __be16 frag_off;
    __u8 ttl;
    __u8 protocol;
    __sum16 check;
    __be32 saddr;
    __be32 daddr;
};

כעת נכתוב את תוכנית ה-BPF שלנו ב-C:

$ cat xdp-simple.bpf.c
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>

SEC("xdp/simple")
int simple(void *ctx)
{
        return XDP_PASS;
}

char LICENSE[] SEC("license") = "GPL";

למרות שהתוכנית שלנו התבררה כפשוטה מאוד, אנחנו עדיין צריכים לשים לב לפרטים רבים. ראשית, קובץ הכותרת הראשון שאנו כוללים הוא vmlinux.h, שזה עתה יצרנו באמצעות bpftool btf dump - עכשיו אנחנו לא צריכים להתקין את חבילת kernel-headers כדי לגלות איך נראים מבני הקרנל. קובץ הכותרות הבא מגיע אלינו מהספרייה libbpf. עכשיו אנחנו צריכים את זה רק כדי להגדיר את המאקרו SEC, אשר שולח את התו למקטע המתאים של קובץ האובייקט ELF. התוכנית שלנו כלולה במדור xdp/simple, כאשר לפני הלוכסן אנו מגדירים את סוג התוכנית BPF - זו הקונבנציה המשמשת libbpf, בהתבסס על שם המדור הוא יחליף את הסוג הנכון בעת ​​ההפעלה bpf(2). תוכנית BPF עצמה היא C - פשוט מאוד ומורכב משורה אחת return XDP_PASS. לבסוף, סעיף נפרד "license" מכיל את שם הרישיון.

אנו יכולים להרכיב את התוכנית שלנו באמצעות llvm/clang, גרסה >= 10.0.0, או יותר טוב, יותר (ראה סעיף כלי פיתוח):

$ clang --version
clang version 11.0.0 (https://github.com/llvm/llvm-project.git afc287e0abec710398465ee1f86237513f2b5091)
...

$ clang -O2 -g -c -target bpf -I libbpf/src/root/usr/include xdp-simple.bpf.c -o xdp-simple.bpf.o

בין המאפיינים המעניינים: אנו מציינים את ארכיטקטורת היעד -target bpf והנתיב לכותרות libbpf, שהתקנו לאחרונה. כמו כן, אל תשכח -O2, ללא אפשרות זו עשויות להיות לך הפתעות בעתיד. בואו נסתכל על הקוד שלנו, האם הצלחנו לכתוב את התוכנית שרצינו?

$ llvm-objdump --section=xdp/simple --no-show-raw-insn -D xdp-simple.bpf.o

xdp-simple.bpf.o:       file format elf64-bpf

Disassembly of section xdp/simple:

0000000000000000 <simple>:
       0:       r0 = 2
       1:       exit

כן, זה עבד! כעת, יש לנו קובץ בינארי עם התוכנית, ואנחנו רוצים ליצור יישום שיטען אותו לתוך הקרנל. לצורך כך הספרייה libbpf מציע לנו שתי אפשרויות - השתמש ב-API ברמה נמוכה יותר או ב-API ברמה גבוהה יותר. נלך בדרך השנייה, מכיוון שאנו רוצים ללמוד כיצד לכתוב, לטעון ולחבר תוכניות BPF במינימום מאמץ ללימוד הבא שלהן.

ראשית, עלינו ליצור את ה"שלד" של התוכנית שלנו מהבינארי שלה באמצעות אותו כלי עזר bpftool - הסכין השוויצרית של עולם ה-BPF (שניתן לתפוס אותה מילולית, מכיוון שדניאל בורקמן, אחד מיוצרי ומתחזקים של BPF, הוא שוויצרי):

$ bpftool gen skeleton xdp-simple.bpf.o > xdp-simple.skel.h

בקובץ xdp-simple.skel.h מכיל את הקוד הבינארי של התוכנית שלנו ופונקציות לניהול - טעינה, צירוף, מחיקה של האובייקט שלנו. במקרה הפשוט שלנו זה נראה כמו מוגזם, אבל זה עובד גם במקרה שבו קובץ האובייקט מכיל הרבה תוכניות BPF ומפות וכדי לטעון את ה-ELF הענק הזה אנחנו רק צריכים ליצור את השלד ולקרוא לפונקציה אחת או שתיים מהיישום המותאם אישית שאנו כותבים בואו נמשיך עכשיו.

באופן קפדני, תוכנית הטעינה שלנו היא טריוויאלית:

#include <err.h>
#include <unistd.h>
#include "xdp-simple.skel.h"

int main(int argc, char **argv)
{
    struct xdp_simple_bpf *obj;

    obj = xdp_simple_bpf__open_and_load();
    if (!obj)
        err(1, "failed to open and/or load BPF objectn");

    pause();

    xdp_simple_bpf__destroy(obj);
}

כאן struct xdp_simple_bpf מוגדר בקובץ xdp-simple.skel.h ומתאר את קובץ האובייקטים שלנו:

struct xdp_simple_bpf {
    struct bpf_object_skeleton *skeleton;
    struct bpf_object *obj;
    struct {
        struct bpf_program *simple;
    } progs;
    struct {
        struct bpf_link *simple;
    } links;
};

אנו יכולים לראות עקבות של API ברמה נמוכה כאן: המבנה struct bpf_program *simple и struct bpf_link *simple. המבנה הראשון מתאר ספציפית את התוכנית שלנו, הכתובה בסעיף xdp/simple, והשני מתאר כיצד התוכנית מתחברת למקור האירוע.

פונקציה xdp_simple_bpf__open_and_load, פותח אובייקט ELF, מנתח אותו, יוצר את כל המבנים ותתי המבנים (מלבד התוכנית, ELF מכיל גם קטעים אחרים - נתונים, נתונים לקריאה בלבד, מידע באגים, רישיון וכו'), ולאחר מכן טוען אותו לתוך הקרנל באמצעות מערכת שִׂיחָה bpf, אותו נוכל לבדוק על ידי הידור והרצה של התוכנית:

$ clang -O2 -I ./libbpf/src/root/usr/include/ xdp-simple.c -o xdp-simple ./libbpf/src/root/usr/lib64/libbpf.a -lelf -lz

$ sudo strace -e bpf ./xdp-simple
...
bpf(BPF_BTF_LOAD, 0x7ffdb8fd9670, 120)  = 3
bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_XDP, insn_cnt=2, insns=0xdfd580, license="GPL", log_level=0, log_size=0, log_buf=NULL, kern_version=KERNEL_VERSION(5, 8, 0), prog_flags=0, prog_name="simple", prog_ifindex=0, expected_attach_type=0x25 /* BPF_??? */, ...}, 120) = 4

כעת נסתכל על התוכנית שלנו באמצעות bpftool. בוא נמצא את תעודת הזהות שלה:

# bpftool p | grep -A4 simple
463: xdp  name simple  tag 3b185187f1855c4c  gpl
        loaded_at 2020-08-01T01:59:49+0000  uid 0
        xlated 16B  jited 40B  memlock 4096B
        btf_id 185
        pids xdp-simple(16498)

ו-dump (אנו משתמשים בצורה מקוצרת של הפקודה bpftool prog dump xlated):

# bpftool p d x id 463
int simple(void *ctx):
; return XDP_PASS;
   0: (b7) r0 = 2
   1: (95) exit

משהו חדש! התוכנית הדפיסה חלקים מקובץ המקור שלנו C. זה נעשה על ידי הספרייה libbpf, שמצא את מקטע ניפוי הבאגים בבינארי, הידור אותו לאובייקט BTF, טען אותו לקרנל באמצעות BPF_BTF_LOAD, ולאחר מכן ציינו את מתאר הקובץ המתקבל בעת טעינת התוכנית עם הפקודה BPG_PROG_LOAD.

עוזרי ליבה

תוכניות BPF יכולות להפעיל פונקציות "חיצוניות" - עוזרי ליבה. פונקציות עוזרות אלו מאפשרות לתוכניות BPF לגשת למבני ליבה, לנהל מפות, וגם לתקשר עם "העולם האמיתי" - ליצור אירועי התאמה, לשלוט בחומרה (לדוגמה, מנות להפנות מחדש) וכו'.

דוגמה: bpf_get_smp_processor_id

במסגרת פרדיגמת "למידה באמצעות דוגמה", הבה נבחן את אחת מתפקידי העזר, bpf_get_smp_processor_id(), מסוימים בקובץ kernel/bpf/helpers.c. הוא מחזיר את מספר המעבד שבו פועלת תוכנית ה-BPF שקראה לו. אבל אנחנו לא מתעניינים בסמנטיקה שלו כמו בעובדה שהיישום שלו לוקח קו אחד:

BPF_CALL_0(bpf_get_smp_processor_id)
{
    return smp_processor_id();
}

הגדרות פונקציית העזר של BPF דומות להגדרות קריאות מערכת לינוקס. כאן, למשל, מוגדרת פונקציה שאין לה ארגומנטים. (פונקציה שלוקחת, נניח, שלושה ארגומנטים מוגדרת באמצעות המאקרו BPF_CALL_3. המספר המרבי של ארגומנטים הוא חמישה.) עם זאת, זהו רק החלק הראשון של ההגדרה. החלק השני הוא הגדרת מבנה הסוג struct bpf_func_proto, המכיל תיאור של פונקציית העזר שהמאמת מבין:

const struct bpf_func_proto bpf_get_smp_processor_id_proto = {
    .func     = bpf_get_smp_processor_id,
    .gpl_only = false,
    .ret_type = RET_INTEGER,
};

רישום פונקציות עוזר

כדי שתוכניות BPF מסוג מסוים ישתמשו בפונקציה זו, עליהן לרשום אותה, למשל עבור הסוג BPF_PROG_TYPE_XDP פונקציה מוגדרת בקרנל xdp_func_proto, שקובע ממזהה פונקציית העזר האם XDP תומך בפונקציה זו או לא. הפונקציה שלנו היא תומך:

static const struct bpf_func_proto *
xdp_func_proto(enum bpf_func_id func_id, const struct bpf_prog *prog)
{
    switch (func_id) {
    ...
    case BPF_FUNC_get_smp_processor_id:
        return &bpf_get_smp_processor_id_proto;
    ...
    }
}

סוגי תוכניות BPF חדשים "מוגדרים" בקובץ include/linux/bpf_types.h באמצעות מאקרו BPF_PROG_TYPE. מוגדר במרכאות כי זו הגדרה הגיונית, ובמונחי שפת C ההגדרה של קבוצה שלמה של מבני בטון מתרחשת במקומות אחרים. בפרט, בתיק kernel/bpf/verifier.c כל ההגדרות מהקובץ bpf_types.h משמשים ליצירת מערך של מבנים bpf_verifier_ops[]:

static const struct bpf_verifier_ops *const bpf_verifier_ops[] = {
#define BPF_PROG_TYPE(_id, _name, prog_ctx_type, kern_ctx_type) 
    [_id] = & _name ## _verifier_ops,
#include <linux/bpf_types.h>
#undef BPF_PROG_TYPE
};

כלומר, עבור כל סוג של תוכנית BPF, מוגדר מצביע למבנה נתונים מהסוג struct bpf_verifier_ops, אשר מאותחל עם הערך _name ## _verifier_ops, כלומר, xdp_verifier_ops עבור xdp. מִבְנֶה xdp_verifier_ops נקבע על ידי בקובץ net/core/filter.c כדלקמן:

const struct bpf_verifier_ops xdp_verifier_ops = {
    .get_func_proto     = xdp_func_proto,
    .is_valid_access    = xdp_is_valid_access,
    .convert_ctx_access = xdp_convert_ctx_access,
    .gen_prologue       = bpf_noop_prologue,
};

כאן אנו רואים את הפונקציה המוכרת שלנו xdp_func_proto, שיריץ את המאמת בכל פעם שהוא נתקל באתגר כמה פונקציות בתוך תוכנית BPF, ראה verifier.c.

בואו נסתכל כיצד תוכנית BPF היפותטית משתמשת בפונקציה bpf_get_smp_processor_id. לשם כך, אנו משכתבים את התוכנית מהסעיף הקודם שלנו באופן הבא:

#include "vmlinux.h"
#include <bpf/bpf_helpers.h>

SEC("xdp/simple")
int simple(void *ctx)
{
    if (bpf_get_smp_processor_id() != 0)
        return XDP_DROP;
    return XDP_PASS;
}

char LICENSE[] SEC("license") = "GPL";

סמל bpf_get_smp_processor_id נקבע על ידי в <bpf/bpf_helper_defs.h> ספריות libbpf איך

static u32 (*bpf_get_smp_processor_id)(void) = (void *) 8;

זה, bpf_get_smp_processor_id הוא מצביע פונקציה שהערך שלו הוא 8, כאשר 8 הוא הערך BPF_FUNC_get_smp_processor_id типа enum bpf_fun_id, המוגדר עבורנו בקובץ vmlinux.h (קוֹבֶץ bpf_helper_defs.h בקרנל נוצר על ידי סקריפט, כך שמספרי ה"קסם" בסדר). פונקציה זו אינה לוקחת ארגומנטים ומחזירה ערך מסוג __u32. כשאנחנו מריצים את זה בתוכנית שלנו, clang מייצר הוראה BPF_CALL "הסוג הנכון" בואו נרכיב את התוכנית ונסתכל על הסעיף xdp/simple:

$ clang -O2 -g -c -target bpf -I libbpf/src/root/usr/include xdp-simple.bpf.c -o xdp-simple.bpf.o
$ llvm-objdump -D --section=xdp/simple xdp-simple.bpf.o

xdp-simple.bpf.o:       file format elf64-bpf

Disassembly of section xdp/simple:

0000000000000000 <simple>:
       0:       85 00 00 00 08 00 00 00 call 8
       1:       bf 01 00 00 00 00 00 00 r1 = r0
       2:       67 01 00 00 20 00 00 00 r1 <<= 32
       3:       77 01 00 00 20 00 00 00 r1 >>= 32
       4:       b7 00 00 00 02 00 00 00 r0 = 2
       5:       15 01 01 00 00 00 00 00 if r1 == 0 goto +1 <LBB0_2>
       6:       b7 00 00 00 01 00 00 00 r0 = 1

0000000000000038 <LBB0_2>:
       7:       95 00 00 00 00 00 00 00 exit

בשורה הראשונה אנו רואים הוראות call, פרמטר IMM שהוא שווה ל-8, ו SRC_REG - אפס. על פי הסכם ABI המשמש את המאמת, זוהי קריאה לפונקציה עוזרת מספר שמונה. ברגע שהוא מושק, ההיגיון פשוט. החזר ערך מהרישום r0 הועתק ל r1 ובשורות 2,3 הוא מומר לסוג u32 - 32 הסיביות העליונות מנוקות. בשורות 4,5,6,7 נחזיר 2 (XDP_PASS) או 1 (XDP_DROP) תלוי אם פונקציית העזר משורה 0 החזירה ערך אפס או לא אפס.

בואו נבחן את עצמנו: טען את התוכנית ותסתכל על הפלט bpftool prog dump xlated:

$ bpftool gen skeleton xdp-simple.bpf.o > xdp-simple.skel.h
$ clang -O2 -g -I ./libbpf/src/root/usr/include/ -o xdp-simple xdp-simple.c ./libbpf/src/root/usr/lib64/libbpf.a -lelf -lz
$ sudo ./xdp-simple &
[2] 10914

$ sudo bpftool p | grep simple
523: xdp  name simple  tag 44c38a10c657e1b0  gpl
        pids xdp-simple(10915)

$ sudo bpftool p d x id 523
int simple(void *ctx):
; if (bpf_get_smp_processor_id() != 0)
   0: (85) call bpf_get_smp_processor_id#114128
   1: (bf) r1 = r0
   2: (67) r1 <<= 32
   3: (77) r1 >>= 32
   4: (b7) r0 = 2
; }
   5: (15) if r1 == 0x0 goto pc+1
   6: (b7) r0 = 1
   7: (95) exit

אוקי, המאמת מצא את ה-kernel-helper הנכון.

דוגמה: העברת טיעונים ולבסוף הפעלת התוכנית!

לכל פונקציות העזר ברמת הריצה יש אב טיפוס

u64 fn(u64 r1, u64 r2, u64 r3, u64 r4, u64 r5)

פרמטרים לפונקציות עוזרות מועברים ברגיסטרים r1-r5, והערך מוחזר ברישום r0. אין פונקציות שלוקחות יותר מחמישה ארגומנטים, ותמיכה בהן לא צפויה להתווסף בעתיד.

בואו נסתכל על עוזר הקרנל החדש וכיצד BPF מעביר פרמטרים. בואו נכתוב מחדש xdp-simple.bpf.c כדלקמן (שאר השורות לא השתנו):

SEC("xdp/simple")
int simple(void *ctx)
{
    bpf_printk("running on CPU%un", bpf_get_smp_processor_id());
    return XDP_PASS;
}

התוכנית שלנו מדפיסה את מספר המעבד עליו היא פועלת. בואו נקמפל אותו ונסתכל על הקוד:

$ llvm-objdump -D --section=xdp/simple --no-show-raw-insn xdp-simple.bpf.o

0000000000000000 <simple>:
       0:       r1 = 10
       1:       *(u16 *)(r10 - 8) = r1
       2:       r1 = 8441246879787806319 ll
       4:       *(u64 *)(r10 - 16) = r1
       5:       r1 = 2334956330918245746 ll
       7:       *(u64 *)(r10 - 24) = r1
       8:       call 8
       9:       r1 = r10
      10:       r1 += -24
      11:       r2 = 18
      12:       r3 = r0
      13:       call 6
      14:       r0 = 2
      15:       exit

בשורות 0-7 נכתוב את המחרוזת running on CPU%un, ואז בשורה 8 אנו מריצים את המוכר bpf_get_smp_processor_id. בשורות 9-12 אנחנו מכינים את טיעוני העוזר bpf_printk - רושמים r1, r2, r3. למה יש שלושה מהם ולא שניים? כי bpf_printkזהו מעטפת מאקרו סביב העוזר האמיתי bpf_trace_printk, שצריך להעביר את גודל מחרוזת הפורמט.

כעת נוסיף כמה שורות ל xdp-simple.cכך שהתוכנית שלנו מתחברת לממשק lo ובאמת התחיל!

$ cat xdp-simple.c
#include <linux/if_link.h>
#include <err.h>
#include <unistd.h>
#include "xdp-simple.skel.h"

int main(int argc, char **argv)
{
    __u32 flags = XDP_FLAGS_SKB_MODE;
    struct xdp_simple_bpf *obj;

    obj = xdp_simple_bpf__open_and_load();
    if (!obj)
        err(1, "failed to open and/or load BPF objectn");

    bpf_set_link_xdp_fd(1, -1, flags);
    bpf_set_link_xdp_fd(1, bpf_program__fd(obj->progs.simple), flags);

cleanup:
    xdp_simple_bpf__destroy(obj);
}

כאן אנו משתמשים בפונקציה bpf_set_link_xdp_fd, המחבר בין תוכניות BPF מסוג XDP לממשקי רשת. קידדנו קשה את מספר הממשק lo, שהוא תמיד 1. אנו מפעילים את הפונקציה פעמיים כדי לנתק תחילה את התוכנית הישנה אם היא הייתה מצורפת. שימו לב שעכשיו אנחנו לא צריכים אתגר pause או לולאה אינסופית: תוכנית הטעינה שלנו תצא, אבל תוכנית ה-BPF לא תיהרג מכיוון שהיא מחוברת למקור האירוע. לאחר הורדה וחיבור מוצלחים, התוכנית תופעל עבור כל חבילת רשת שתגיע אל lo.

בוא נוריד את התוכנית ונסתכל על הממשק lo:

$ sudo ./xdp-simple
$ sudo bpftool p | grep simple
669: xdp  name simple  tag 4fca62e77ccb43d6  gpl
$ ip l show dev lo
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 xdpgeneric qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    prog/xdp id 669

לתוכנית שהורדנו יש ID 669 ואנו רואים את אותו ID בממשק lo. אנחנו נשלח כמה חבילות ל 127.0.0.1 (בקשה + תשובה):

$ ping -c1 localhost

ועכשיו בואו נסתכל על התוכן של הקובץ הוירטואלי של ניפוי באגים /sys/kernel/debug/tracing/trace_pipe, שבה bpf_printk כותב את הודעותיו:

# cat /sys/kernel/debug/tracing/trace_pipe
ping-13937 [000] d.s1 442015.377014: bpf_trace_printk: running on CPU0
ping-13937 [000] d.s1 442015.377027: bpf_trace_printk: running on CPU0

אותרו שתי חבילות lo ומעובד על CPU0 - תוכנית ה-BPF המלאה הראשונה שלנו עבדה!

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

גישה למפות מתוכניות BPF

דוגמה: שימוש במפה מתוכנית BPF

בסעיפים הקודמים למדנו כיצד ליצור ולהשתמש במפות ממרחב המשתמש, ועכשיו בואו נסתכל על החלק של הקרנל. נתחיל, כרגיל, עם דוגמה. בואו נשכתב את התוכנית שלנו xdp-simple.bpf.c כדלקמן:

#include "vmlinux.h"
#include <bpf/bpf_helpers.h>

struct {
    __uint(type, BPF_MAP_TYPE_ARRAY);
    __uint(max_entries, 8);
    __type(key, u32);
    __type(value, u64);
} woo SEC(".maps");

SEC("xdp/simple")
int simple(void *ctx)
{
    u32 key = bpf_get_smp_processor_id();
    u32 *val;

    val = bpf_map_lookup_elem(&woo, &key);
    if (!val)
        return XDP_ABORTED;

    *val += 1;

    return XDP_PASS;
}

char LICENSE[] SEC("license") = "GPL";

בתחילת התוכנית הוספנו הגדרת מפה woo: זהו מערך בן 8 אלמנטים המאחסן ערכים כמו u64 (ב-C נגדיר מערך כזה כמו u64 woo[8]). בתוכנית "xdp/simple" אנו מקבלים את מספר המעבד הנוכחי למשתנה key ולאחר מכן שימוש בפונקציית העזר bpf_map_lookup_element נקבל מצביע לערך המתאים במערך, אותו אנו מגדילים באחד. מתורגם לרוסית: אנו מחשבים נתונים סטטיסטיים על איזה CPU עיבד מנות נכנסות. בואו ננסה להפעיל את התוכנית:

$ clang -O2 -g -c -target bpf -I libbpf/src/root/usr/include xdp-simple.bpf.c -o xdp-simple.bpf.o
$ bpftool gen skeleton xdp-simple.bpf.o > xdp-simple.skel.h
$ clang -O2 -g -I ./libbpf/src/root/usr/include/ -o xdp-simple xdp-simple.c ./libbpf/src/root/usr/lib64/libbpf.a -lelf -lz
$ sudo ./xdp-simple

בוא נבדוק שהיא התחברה lo ושלח כמה מנות:

$ ip l show dev lo
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 xdpgeneric qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    prog/xdp id 108

$ for s in `seq 234`; do sudo ping -f -c 100 127.0.0.1 >/dev/null 2>&1; done

כעת נסתכל על התוכן של המערך:

$ sudo bpftool map dump name woo
[
    { "key": 0, "value": 0 },
    { "key": 1, "value": 400 },
    { "key": 2, "value": 0 },
    { "key": 3, "value": 0 },
    { "key": 4, "value": 0 },
    { "key": 5, "value": 0 },
    { "key": 6, "value": 0 },
    { "key": 7, "value": 46400 }
]

כמעט כל התהליכים עובדו ב-CPU7. זה לא חשוב לנו, העיקר שהתוכנה עובדת ונבין איך לגשת למפות מתוכנות BPF - באמצעות хелперов bpf_mp_*.

אינדקס מיסטי

אז, אנחנו יכולים לגשת למפה מתוכנית BPF באמצעות שיחות כמו

val = bpf_map_lookup_elem(&woo, &key);

היכן נראית פונקציית העזר

void *bpf_map_lookup_elem(struct bpf_map *map, const void *key)

אבל אנחנו מעבירים מצביע &woo למבנה ללא שם struct { ... }...

אם אנו מסתכלים על מרכיב התוכנית, אנו רואים שהערך &woo אינו מוגדר בפועל (שורה 4):

llvm-objdump -D --section xdp/simple xdp-simple.bpf.o

xdp-simple.bpf.o:       file format elf64-bpf

Disassembly of section xdp/simple:

0000000000000000 <simple>:
       0:       85 00 00 00 08 00 00 00 call 8
       1:       63 0a fc ff 00 00 00 00 *(u32 *)(r10 - 4) = r0
       2:       bf a2 00 00 00 00 00 00 r2 = r10
       3:       07 02 00 00 fc ff ff ff r2 += -4
       4:       18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll
       6:       85 00 00 00 01 00 00 00 call 1
...

והוא כלול ברילוקיישן:

$ llvm-readelf -r xdp-simple.bpf.o | head -4

Relocation section '.relxdp/simple' at offset 0xe18 contains 1 entries:
    Offset             Info             Type               Symbol's Value  Symbol's Name
0000000000000020  0000002700000001 R_BPF_64_64            0000000000000000 woo

אבל אם נסתכל על התוכנית שכבר טעינה, נראה מצביע למפה הנכונה (שורה 4):

$ sudo bpftool prog dump x name simple
int simple(void *ctx):
   0: (85) call bpf_get_smp_processor_id#114128
   1: (63) *(u32 *)(r10 -4) = r0
   2: (bf) r2 = r10
   3: (07) r2 += -4
   4: (18) r1 = map[id:64]
...

לפיכך, אנו יכולים להסיק שבזמן השקת תוכנית הטעינה שלנו, הקישור אל &woo הוחלף במשהו עם ספרייה libbpf. ראשית נסתכל על הפלט strace:

$ sudo strace -e bpf ./xdp-simple
...
bpf(BPF_MAP_CREATE, {map_type=BPF_MAP_TYPE_ARRAY, key_size=4, value_size=8, max_entries=8, map_name="woo", ...}, 120) = 4
bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_XDP, prog_name="simple", ...}, 120) = 5

אנחנו רואים ש libbpf יצר מפה woo ולאחר מכן הורד את התוכנית שלנו simple. בואו נסתכל מקרוב על האופן שבו אנו טוענים את התוכנית:

  • שִׂיחָה xdp_simple_bpf__open_and_load מקובץ xdp-simple.skel.h
  • מה שגורם ל xdp_simple_bpf__load מקובץ xdp-simple.skel.h
  • מה שגורם ל bpf_object__load_skeleton מקובץ libbpf/src/libbpf.c
  • מה שגורם ל bpf_object__load_xattr של libbpf/src/libbpf.c

הפונקציה האחרונה, בין היתר, תקרא bpf_object__create_maps, שיוצר או פותח מפות קיימות, והופך אותן לתיאורי קבצים. (כאן אנו רואים BPF_MAP_CREATE בפלט strace.) לאחר מכן נקראת הפונקציה bpf_object__relocate והיא שמעניינת אותנו, כי אנחנו זוכרים את מה שראינו woo בטבלת רילוקיישן. כשחוקרים אותו, אנו מוצאים את עצמנו בסופו של דבר בפונקציה bpf_program__relocate, איזה עוסק בהעברת מפות:

case RELO_LD64:
    insn[0].src_reg = BPF_PSEUDO_MAP_FD;
    insn[0].imm = obj->maps[relo->map_idx].fd;
    break;

אז אנחנו מקבלים את ההוראות שלנו

18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll

ולהחליף את מאגר המקור בו ב BPF_PSEUDO_MAP_FD, וה-IMM הראשון לתיאור הקובץ של המפה שלנו, ואם הוא שווה, למשל, 0xdeadbeef, אז כתוצאה מכך נקבל את ההוראה

18 11 00 00 ef eb ad de 00 00 00 00 00 00 00 00 r1 = 0 ll

כך מועבר מידע מפה לתוכנית BPF טעונה ספציפית. במקרה זה, ניתן ליצור את המפה באמצעות BPF_MAP_CREATE, ונפתח על ידי מזהה באמצעות BPF_MAP_GET_FD_BY_ID.

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

  • במהלך הקומפילציה, נוצרות רשומות בטבלת ההעברה לקישורים למפות
  • libbpf פותח את ספר האובייקטים של ELF, מוצא את כל המפות בשימוש ויוצר עבורן מתארי קבצים
  • מתארי קבצים נטענים לתוך הליבה כחלק מההוראה LD64

כפי שאתה יכול לתאר לעצמך, יש עוד לבוא ונצטרך להסתכל לתוך הליבה. למרבה המזל, יש לנו מושג - רשמנו את המשמעות BPF_PSEUDO_MAP_FD לתוך פנקס המקורות ונוכל לקבור אותו, שיוביל אותנו לקודש כל הקדושים - kernel/bpf/verifier.c, כאשר פונקציה בעלת שם ייחודי מחליפה מתאר קובץ בכתובת של מבנה סוג struct bpf_map:

static int replace_map_fd_with_map_ptr(struct bpf_verifier_env *env) {
    ...

    f = fdget(insn[0].imm);
    map = __bpf_map_get(f);
    if (insn->src_reg == BPF_PSEUDO_MAP_FD) {
        addr = (unsigned long)map;
    }
    insn[0].imm = (u32)addr;
    insn[1].imm = addr >> 32;

(ניתן למצוא את הקוד המלא по ссылке). אז נוכל להרחיב את האלגוריתם שלנו:

  • בזמן טעינת התוכנית, המאמת בודק את השימוש הנכון במפה וכותב את הכתובת של המבנה המתאים struct bpf_map

בעת הורדת ה-ELF הבינארי באמצעות libbpf יש עוד הרבה דברים שקורים, אבל נדון בזה במאמרים אחרים.

טעינת תוכניות ומפות ללא libbpf

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

כדי להקל על ביצוע ההיגיון, נכתוב מחדש את הדוגמה שלנו למטרות אלו xdp-simple. ניתן למצוא את הקוד המלא והמורחב מעט של התוכנית הנידונה בדוגמה זו Gist.

ההיגיון של היישום שלנו הוא כדלקמן:

  • ליצור מפת סוגים BPF_MAP_TYPE_ARRAY באמצעות הפקודה BPF_MAP_CREATE,
  • צור תוכנית שמשתמשת במפה זו,
  • לחבר את התוכנית לממשק lo,

שמתורגם לאדם כמו

int main(void)
{
    int map_fd, prog_fd;

    map_fd = map_create();
    if (map_fd < 0)
        err(1, "bpf: BPF_MAP_CREATE");

    prog_fd = prog_load(map_fd);
    if (prog_fd < 0)
        err(1, "bpf: BPF_PROG_LOAD");

    xdp_attach(1, prog_fd);
}

כאן map_create יוצר מפה באותו אופן כמו שעשינו בדוגמה הראשונה לגבי קריאת המערכת bpf - "קרנל, בבקשה צור לי מפה חדשה בצורה של מערך של 8 אלמנטים כמו __u64 ותחזיר לי את מתאר הקובץ":

static int map_create()
{
    union bpf_attr attr;

    memset(&attr, 0, sizeof(attr));
    attr.map_type = BPF_MAP_TYPE_ARRAY,
    attr.key_size = sizeof(__u32),
    attr.value_size = sizeof(__u64),
    attr.max_entries = 8,
    strncpy(attr.map_name, "woo", sizeof(attr.map_name));
    return syscall(__NR_bpf, BPF_MAP_CREATE, &attr, sizeof(attr));
}

התוכנית גם קלה לטעינה:

static int prog_load(int map_fd)
{
    union bpf_attr attr;
    struct bpf_insn insns[] = {
        ...
    };

    memset(&attr, 0, sizeof(attr));
    attr.prog_type = BPF_PROG_TYPE_XDP;
    attr.insns     = ptr_to_u64(insns);
    attr.insn_cnt  = sizeof(insns)/sizeof(insns[0]);
    attr.license   = ptr_to_u64("GPL");
    strncpy(attr.prog_name, "woo", sizeof(attr.prog_name));
    return syscall(__NR_bpf, BPF_PROG_LOAD, &attr, sizeof(attr));
}

החלק המסובך prog_load היא ההגדרה של תוכנית ה-BPF שלנו כמערך של מבנים struct bpf_insn insns[]. אבל מכיוון שאנחנו משתמשים בתוכנה שיש לנו ב-C, אנחנו יכולים לרמות קצת:

$ llvm-objdump -D --section xdp/simple xdp-simple.bpf.o

0000000000000000 <simple>:
       0:       85 00 00 00 08 00 00 00 call 8
       1:       63 0a fc ff 00 00 00 00 *(u32 *)(r10 - 4) = r0
       2:       bf a2 00 00 00 00 00 00 r2 = r10
       3:       07 02 00 00 fc ff ff ff r2 += -4
       4:       18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll
       6:       85 00 00 00 01 00 00 00 call 1
       7:       b7 01 00 00 00 00 00 00 r1 = 0
       8:       15 00 04 00 00 00 00 00 if r0 == 0 goto +4 <LBB0_2>
       9:       61 01 00 00 00 00 00 00 r1 = *(u32 *)(r0 + 0)
      10:       07 01 00 00 01 00 00 00 r1 += 1
      11:       63 10 00 00 00 00 00 00 *(u32 *)(r0 + 0) = r1
      12:       b7 01 00 00 02 00 00 00 r1 = 2

0000000000000068 <LBB0_2>:
      13:       bf 10 00 00 00 00 00 00 r0 = r1
      14:       95 00 00 00 00 00 00 00 exit

בסך הכל, אנחנו צריכים לכתוב 14 הוראות בצורה של מבנים כמו struct bpf_insn (עֵצָה: קח את המזבלה מלמעלה, קרא שוב את קטע ההוראות, פתח linux/bpf.h и linux/bpf_common.h ולנסות לקבוע struct bpf_insn insns[] לבד):

struct bpf_insn insns[] = {
    /* 85 00 00 00 08 00 00 00 call 8 */
    {
        .code = BPF_JMP | BPF_CALL,
        .imm = 8,
    },

    /* 63 0a fc ff 00 00 00 00 *(u32 *)(r10 - 4) = r0 */
    {
        .code = BPF_MEM | BPF_STX,
        .off = -4,
        .src_reg = BPF_REG_0,
        .dst_reg = BPF_REG_10,
    },

    /* bf a2 00 00 00 00 00 00 r2 = r10 */
    {
        .code = BPF_ALU64 | BPF_MOV | BPF_X,
        .src_reg = BPF_REG_10,
        .dst_reg = BPF_REG_2,
    },

    /* 07 02 00 00 fc ff ff ff r2 += -4 */
    {
        .code = BPF_ALU64 | BPF_ADD | BPF_K,
        .dst_reg = BPF_REG_2,
        .imm = -4,
    },

    /* 18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll */
    {
        .code = BPF_LD | BPF_DW | BPF_IMM,
        .src_reg = BPF_PSEUDO_MAP_FD,
        .dst_reg = BPF_REG_1,
        .imm = map_fd,
    },
    { }, /* placeholder */

    /* 85 00 00 00 01 00 00 00 call 1 */
    {
        .code = BPF_JMP | BPF_CALL,
        .imm = 1,
    },

    /* b7 01 00 00 00 00 00 00 r1 = 0 */
    {
        .code = BPF_ALU64 | BPF_MOV | BPF_K,
        .dst_reg = BPF_REG_1,
        .imm = 0,
    },

    /* 15 00 04 00 00 00 00 00 if r0 == 0 goto +4 <LBB0_2> */
    {
        .code = BPF_JMP | BPF_JEQ | BPF_K,
        .off = 4,
        .src_reg = BPF_REG_0,
        .imm = 0,
    },

    /* 61 01 00 00 00 00 00 00 r1 = *(u32 *)(r0 + 0) */
    {
        .code = BPF_MEM | BPF_LDX,
        .off = 0,
        .src_reg = BPF_REG_0,
        .dst_reg = BPF_REG_1,
    },

    /* 07 01 00 00 01 00 00 00 r1 += 1 */
    {
        .code = BPF_ALU64 | BPF_ADD | BPF_K,
        .dst_reg = BPF_REG_1,
        .imm = 1,
    },

    /* 63 10 00 00 00 00 00 00 *(u32 *)(r0 + 0) = r1 */
    {
        .code = BPF_MEM | BPF_STX,
        .src_reg = BPF_REG_1,
        .dst_reg = BPF_REG_0,
    },

    /* b7 01 00 00 02 00 00 00 r1 = 2 */
    {
        .code = BPF_ALU64 | BPF_MOV | BPF_K,
        .dst_reg = BPF_REG_1,
        .imm = 2,
    },

    /* <LBB0_2>: bf 10 00 00 00 00 00 00 r0 = r1 */
    {
        .code = BPF_ALU64 | BPF_MOV | BPF_X,
        .src_reg = BPF_REG_1,
        .dst_reg = BPF_REG_0,
    },

    /* 95 00 00 00 00 00 00 00 exit */
    {
        .code = BPF_JMP | BPF_EXIT
    },
};

תרגיל למי שלא כתב זאת בעצמו - מצא map_fd.

נותר עוד חלק אחד לא ידוע בתוכנית שלנו - xdp_attach. למרבה הצער, לא ניתן לחבר תוכניות כמו XDP באמצעות קריאת מערכת bpf. האנשים שיצרו BPF ו-XDP היו מקהילת לינוקס המקוונת, מה שאומר שהם השתמשו בזו המוכרת להם ביותר (אבל לא כדי נוֹרמָלִי אנשים) ממשק לאינטראקציה עם הקרנל: שקעי netlink, ראה גם RFC3549. הדרך הפשוטה ביותר ליישום xdp_attach מעתיק קוד מ libbpf, כלומר, מהקובץ netlink.c, וזה מה שעשינו, לקצר את זה קצת:

ברוכים הבאים לעולם של שקעי Netlink

פתח סוג שקע Netlink NETLINK_ROUTE:

int netlink_open(__u32 *nl_pid)
{
    struct sockaddr_nl sa;
    socklen_t addrlen;
    int one = 1, ret;
    int sock;

    memset(&sa, 0, sizeof(sa));
    sa.nl_family = AF_NETLINK;

    sock = socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE);
    if (sock < 0)
        err(1, "socket");

    if (setsockopt(sock, SOL_NETLINK, NETLINK_EXT_ACK, &one, sizeof(one)) < 0)
        warnx("netlink error reporting not supported");

    if (bind(sock, (struct sockaddr *)&sa, sizeof(sa)) < 0)
        err(1, "bind");

    addrlen = sizeof(sa);
    if (getsockname(sock, (struct sockaddr *)&sa, &addrlen) < 0)
        err(1, "getsockname");

    *nl_pid = sa.nl_pid;
    return sock;
}

קראנו מהשקע הזה:

static int bpf_netlink_recv(int sock, __u32 nl_pid, int seq)
{
    bool multipart = true;
    struct nlmsgerr *errm;
    struct nlmsghdr *nh;
    char buf[4096];
    int len, ret;

    while (multipart) {
        multipart = false;
        len = recv(sock, buf, sizeof(buf), 0);
        if (len < 0)
            err(1, "recv");

        if (len == 0)
            break;

        for (nh = (struct nlmsghdr *)buf; NLMSG_OK(nh, len);
                nh = NLMSG_NEXT(nh, len)) {
            if (nh->nlmsg_pid != nl_pid)
                errx(1, "wrong pid");
            if (nh->nlmsg_seq != seq)
                errx(1, "INVSEQ");
            if (nh->nlmsg_flags & NLM_F_MULTI)
                multipart = true;
            switch (nh->nlmsg_type) {
                case NLMSG_ERROR:
                    errm = (struct nlmsgerr *)NLMSG_DATA(nh);
                    if (!errm->error)
                        continue;
                    ret = errm->error;
                    // libbpf_nla_dump_errormsg(nh); too many code to copy...
                    goto done;
                case NLMSG_DONE:
                    return 0;
                default:
                    break;
            }
        }
    }
    ret = 0;
done:
    return ret;
}

לבסוף, הנה הפונקציה שלנו שפותחת שקע ושולחת אליו הודעה מיוחדת המכילה מתאר קובץ:

static int xdp_attach(int ifindex, int prog_fd)
{
    int sock, seq = 0, ret;
    struct nlattr *nla, *nla_xdp;
    struct {
        struct nlmsghdr  nh;
        struct ifinfomsg ifinfo;
        char             attrbuf[64];
    } req;
    __u32 nl_pid = 0;

    sock = netlink_open(&nl_pid);
    if (sock < 0)
        return sock;

    memset(&req, 0, sizeof(req));
    req.nh.nlmsg_len = NLMSG_LENGTH(sizeof(struct ifinfomsg));
    req.nh.nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK;
    req.nh.nlmsg_type = RTM_SETLINK;
    req.nh.nlmsg_pid = 0;
    req.nh.nlmsg_seq = ++seq;
    req.ifinfo.ifi_family = AF_UNSPEC;
    req.ifinfo.ifi_index = ifindex;

    /* started nested attribute for XDP */
    nla = (struct nlattr *)(((char *)&req)
            + NLMSG_ALIGN(req.nh.nlmsg_len));
    nla->nla_type = NLA_F_NESTED | IFLA_XDP;
    nla->nla_len = NLA_HDRLEN;

    /* add XDP fd */
    nla_xdp = (struct nlattr *)((char *)nla + nla->nla_len);
    nla_xdp->nla_type = IFLA_XDP_FD;
    nla_xdp->nla_len = NLA_HDRLEN + sizeof(int);
    memcpy((char *)nla_xdp + NLA_HDRLEN, &prog_fd, sizeof(prog_fd));
    nla->nla_len += nla_xdp->nla_len;

    /* if user passed in any flags, add those too */
    __u32 flags = XDP_FLAGS_SKB_MODE;
    nla_xdp = (struct nlattr *)((char *)nla + nla->nla_len);
    nla_xdp->nla_type = IFLA_XDP_FLAGS;
    nla_xdp->nla_len = NLA_HDRLEN + sizeof(flags);
    memcpy((char *)nla_xdp + NLA_HDRLEN, &flags, sizeof(flags));
    nla->nla_len += nla_xdp->nla_len;

    req.nh.nlmsg_len += NLA_ALIGN(nla->nla_len);

    if (send(sock, &req, req.nh.nlmsg_len, 0) < 0)
        err(1, "send");
    ret = bpf_netlink_recv(sock, nl_pid, seq);

cleanup:
    close(sock);
    return ret;
}

אז הכל מוכן לבדיקה:

$ cc nolibbpf.c -o nolibbpf
$ sudo strace -e bpf ./nolibbpf
bpf(BPF_MAP_CREATE, {map_type=BPF_MAP_TYPE_ARRAY, map_name="woo", ...}, 72) = 3
bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_XDP, insn_cnt=15, prog_name="woo", ...}, 72) = 4
+++ exited with 0 +++

בוא נראה אם ​​התוכנית שלנו התחברה ל lo:

$ ip l show dev lo
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 xdpgeneric qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    prog/xdp id 160

בוא נשלח פינגים ונסתכל במפה:

$ for s in `seq 234`; do sudo ping -f -c 100 127.0.0.1 >/dev/null 2>&1; done
$ sudo bpftool m dump name woo
key: 00 00 00 00  value: 90 01 00 00 00 00 00 00
key: 01 00 00 00  value: 00 00 00 00 00 00 00 00
key: 02 00 00 00  value: 00 00 00 00 00 00 00 00
key: 03 00 00 00  value: 00 00 00 00 00 00 00 00
key: 04 00 00 00  value: 00 00 00 00 00 00 00 00
key: 05 00 00 00  value: 00 00 00 00 00 00 00 00
key: 06 00 00 00  value: 40 b5 00 00 00 00 00 00
key: 07 00 00 00  value: 00 00 00 00 00 00 00 00
Found 8 elements

היי, הכל עובד. שימו לב, אגב, שהמפה שלנו מוצגת שוב בצורת בתים. זאת בשל העובדה, שלא כמו libbpf לא טענו מידע על סוג (BTF). אבל נדבר על זה יותר בפעם הבאה.

כלי פיתוח

בסעיף זה, נסתכל על ערכת הכלים המינימלית למפתחים של BPF.

באופן כללי, אתה לא צריך שום דבר מיוחד כדי לפתח תוכניות BPF - BPF פועל על כל גרעין הפצה הגון, ותוכנות בנויות באמצעות clang, שניתן לספק מהחבילה. עם זאת, בשל העובדה ש-BPF נמצא בפיתוח, הליבה והכלים משתנים ללא הרף, אם אינך רוצה לכתוב תוכניות BPF בשיטות מיושנות משנת 2019, אז תצטרך לבצע קומפילציה

  • llvm/clang
  • pahole
  • הליבה שלו
  • bpftool

(לעיון, סעיף זה וכל הדוגמאות במאמר הופעלו בדביאן 10.)

llvm/clang

BPF ידידותי עם LLVM ולמרות שלאחרונה ניתן להרכיב תוכניות עבור BPF באמצעות gcc, כל הפיתוח הנוכחי מתבצע עבור LLVM. לכן, קודם כל, נבנה את הגרסה הנוכחית clang מ-git:

$ sudo apt install ninja-build
$ git clone --depth 1 https://github.com/llvm/llvm-project.git
$ mkdir -p llvm-project/llvm/build/install
$ cd llvm-project/llvm/build
$ cmake .. -G "Ninja" -DLLVM_TARGETS_TO_BUILD="BPF;X86" 
                      -DLLVM_ENABLE_PROJECTS="clang" 
                      -DBUILD_SHARED_LIBS=OFF 
                      -DCMAKE_BUILD_TYPE=Release 
                      -DLLVM_BUILD_RUNTIME=OFF
$ time ninja
... много времени спустя
$

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

$ ./bin/llc --version
LLVM (http://llvm.org/):
  LLVM version 11.0.0git
  Optimized build.
  Default target: x86_64-unknown-linux-gnu
  Host CPU: znver1

  Registered Targets:
    bpf    - BPF (host endian)
    bpfeb  - BPF (big endian)
    bpfel  - BPF (little endian)
    x86    - 32-bit X86: Pentium-Pro and above
    x86-64 - 64-bit X86: EM64T and AMD64

(הוראות הרכבה clang נלקח על ידי מ bpf_devel_QA.)

אנחנו לא נתקין את התוכנות שזה עתה בנינו, אלא רק נוסיף אותן PATH, לדוגמה:

export PATH="`pwd`/bin:$PATH"

(ניתן להוסיף את זה .bashrc או לקובץ נפרד. באופן אישי, אני מוסיף דברים כאלה ~/bin/activate-llvm.sh וכשצריך אני עושה את זה . activate-llvm.sh.)

Pahole ו-BTF

שירות pahole משמש בעת בניית הליבה ליצירת מידע ניפוי באגים בפורמט BTF. לא נפרט במאמר זה על הפרטים של טכנולוגיית BTF, מלבד העובדה שהיא נוחה ואנו רוצים להשתמש בה. אז אם אתה מתכוון לבנות את הגרעין שלך, בנה קודם pahole (ללא pahole לא תוכל לבנות את הקרנל עם האפשרות CONFIG_DEBUG_INFO_BTF:

$ git clone https://git.kernel.org/pub/scm/devel/pahole/pahole.git
$ cd pahole/
$ sudo apt install cmake
$ mkdir build
$ cd build/
$ cmake -D__LIB=lib ..
$ make
$ sudo make install
$ which pahole
/usr/local/bin/pahole

גרעינים להתנסות עם BPF

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

כדי לבנות ליבה אתה צריך, ראשית, את הקרנל עצמו, ושנית, קובץ תצורה של ליבה. כדי להתנסות עם BPF נוכל להשתמש ברגיל וניל קרנל או אחד מגרעיני הפיתוח. מבחינה היסטורית, פיתוח BPF מתרחש בתוך קהילת הרשתות של לינוקס, ולכן כל השינויים עוברים במוקדם או במאוחר דרך דיוויד מילר, מתחזק הרשתות של לינוקס. בהתאם לאופי שלהם - עריכות או תכונות חדשות - שינויים ברשת מתחלקים לאחת משתי ליבות - net או net-next. שינויים עבור BPF מחולקים באותו אופן בין bpf и bpf-next, אשר לאחר מכן מתאגדים ל-net ול-net-next, בהתאמה. לפרטים נוספים, ראה bpf_devel_QA и netdev-שאלות נפוצות. אז בחרו גרעין לפי טעמכם וצרכי ​​היציבות של המערכת שעליה אתם בודקים (*-next הגרעינים הם הכי לא יציבים מבין אלו הרשומים).

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

הורד אחד מהגרעינים לעיל:

$ git clone git://git.kernel.org/pub/scm/linux/kernel/git/bpf/bpf-next.git
$ cd bpf-next

בנה תצורת ליבה מינימלית שעובדת:

$ cp /boot/config-`uname -r` .config
$ make localmodconfig

אפשר אפשרויות BPF בקובץ .config לבחירתך (ככל הנראה CONFIG_BPF כבר יופעל מכיוון ש-systemd משתמשת בו). להלן רשימה של אפשרויות מהקרנל המשמש למאמר זה:

CONFIG_CGROUP_BPF=y
CONFIG_BPF=y
CONFIG_BPF_LSM=y
CONFIG_BPF_SYSCALL=y
CONFIG_ARCH_WANT_DEFAULT_BPF_JIT=y
CONFIG_BPF_JIT_ALWAYS_ON=y
CONFIG_BPF_JIT_DEFAULT_ON=y
CONFIG_IPV6_SEG6_BPF=y
# CONFIG_NETFILTER_XT_MATCH_BPF is not set
# CONFIG_BPFILTER is not set
CONFIG_NET_CLS_BPF=y
CONFIG_NET_ACT_BPF=y
CONFIG_BPF_JIT=y
CONFIG_BPF_STREAM_PARSER=y
CONFIG_LWTUNNEL_BPF=y
CONFIG_HAVE_EBPF_JIT=y
CONFIG_BPF_EVENTS=y
CONFIG_BPF_KPROBE_OVERRIDE=y
CONFIG_DEBUG_INFO_BTF=y

לאחר מכן נוכל להרכיב ולהתקין בקלות את המודולים ואת הקרנל (אגב, אתה יכול להרכיב את הקרנל באמצעות הרכיב החדש שהורכב clangעל ידי הוספה CC=clang):

$ make -s -j $(getconf _NPROCESSORS_ONLN)
$ sudo make modules_install
$ sudo make install

ואתחל מחדש עם הקרנל החדש (אני משתמש בשביל זה kexec מהחבילה kexec-tools):

v=5.8.0-rc6+ # если вы пересобираете текущее ядро, то можно делать v=`uname -r`
sudo kexec -l -t bzImage /boot/vmlinuz-$v --initrd=/boot/initrd.img-$v --reuse-cmdline &&
sudo kexec -e

bpftool

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

בזמן כתיבת שורות אלה bpftool מגיע מוכן רק עבור RHEL, פדורה ואובונטו (ראה, למשל, השרשור הזה, שמספר את הסיפור הלא גמור של האריזה bpftool בדביאן). אבל אם כבר בנית את הקרנל שלך, אז בנה bpftool קל כמו פאי:

$ cd ${linux}/tools/bpf/bpftool
# ... пропишите пути к последнему clang, как рассказано выше
$ make -s

Auto-detecting system features:
...                        libbfd: [ on  ]
...        disassembler-four-args: [ on  ]
...                          zlib: [ on  ]
...                        libcap: [ on  ]
...               clang-bpf-co-re: [ on  ]

Auto-detecting system features:
...                        libelf: [ on  ]
...                          zlib: [ on  ]
...                           bpf: [ on  ]

$

(פה ${linux} - זוהי ספריית הליבה שלך.) לאחר ביצוע הפקודות הללו bpftool ייאסף בספרייה ${linux}/tools/bpf/bpftool וניתן להוסיף אותו לנתיב (קודם כל למשתמש root) או פשוט העתק אל /usr/local/sbin.

לאסוף bpftool עדיף להשתמש באחרון clang, הורכב כמתואר לעיל, ובדקו האם הוא מורכב נכון - באמצעות, למשל, הפקודה

$ sudo bpftool feature probe kernel
Scanning system configuration...
bpf() syscall for unprivileged users is enabled
JIT compiler is enabled
JIT compiler hardening is disabled
JIT compiler kallsyms exports are enabled for root
...

אשר יראה אילו תכונות BPF מופעלות בקרנל שלך.

אגב, ניתן להפעיל את הפקודה הקודמת בתור

# bpftool f p k

זה נעשה באנלוגיה לכלי השירות מהחבילה iproute2, שבו אנחנו יכולים, למשל, לומר ip a s eth0 במקום ip addr show dev eth0.

מסקנה

BPF מאפשר לך לנעול פרעוש כדי למדוד ביעילות ולשנות את הפונקציונליות של הליבה. המערכת התבררה כמוצלחת מאוד, כמיטב המסורת של UNIX: מנגנון פשוט המאפשר לך (מחדש) לתכנת את הליבה איפשר למספר עצום של אנשים וארגונים להתנסות. ולמרות שהניסויים, כמו גם הפיתוח של תשתית ה-BPF עצמה, רחוקים מלהסתיים, למערכת כבר יש ABI יציב המאפשר לבנות היגיון עסקי אמין, והכי חשוב, יעיל.

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

מאמר זה, למרות שאינו קצר במיוחד, מהווה רק מבוא לעולם ה-BPF ואינו מתאר תכונות "מתקדמות" וחלקים חשובים בארכיטקטורה. התוכנית קדימה היא בערך כך: המאמר הבא יהיה סקירה כללית של סוגי תוכניות BPF (ישנם 5.8 סוגי תוכניות נתמכים בקרנל 30), ואז סוף סוף נראה כיצד לכתוב יישומי BPF אמיתיים באמצעות תוכניות מעקב ליבה כדוגמה, אז הגיע הזמן לקורס מעמיק יותר על ארכיטקטורת BPF, ואחריו דוגמאות של יישומי רשת ואבטחה BPF.

מאמרים קודמים בסדרה זו

  1. BPF לקטנטנים, חלק אפס: BPF קלאסי

קישורים

  1. מדריך BPF ו-XDP - תיעוד על BPF מ-cilium, או ליתר דיוק מדניאל בורקמן, אחד היוצרים והמתחזקים של BPF. זהו אחד התיאורים הרציניים הראשונים, השונה מהאחרים בכך שדניאל יודע בדיוק על מה הוא כותב ואין שם טעויות. בפרט, מסמך זה מתאר כיצד לעבוד עם תוכניות BPF מסוגי XDP ו-TC באמצעות כלי השירות הידוע. ip מהחבילה iproute2.

  2. תיעוד/רשתות/filter.txt - קובץ מקורי עם תיעוד עבור BPF קלאסי ולאחר מכן מורחב. קריאה טובה אם אתה רוצה להתעמק בשפת הרכבה ובפרטים אדריכליים טכניים.

  3. בלוג על BPF מפייסבוק. הוא מתעדכן לעתים רחוקות, אך כראוי, כפי שכותבים שם אלכסיי סטארובויטוב (מחבר eBPF) ואנדריי נקרייקו - (מתחזק) libbpf).

  4. הסודות של bpftool. שרשור טוויטר משעשע של קוונטין מונה עם דוגמאות וסודות לשימוש ב-bpftool.

  5. צלול לתוך BPF: רשימה של חומר קריאה. רשימה ענקית (ועדיין מתוחזקת) של קישורים לתיעוד BPF מ-Quentin Monnet.

מקור: www.habr.com

הוספת תגובה