תקציר:

פוסט זה ילמד אתכם מהי ניתוח סטטי (static analysis) בשפות C ו-C++‎ ומדוע הוא חשוב. תלמדו כיצד להשתמש בניתוח סטטי כחלק מתהליך הפיתוח שלכם וטיפים כיצד להפוך אותו לאוטומטי.

מבוא לניתוח סטטי

“ארבעת שלבי המיומנות” (four stages of competence) הוא מודל למידה ידוע המתאר את השלבים שעובר לומד בעת רכישת מיומנות. פוסט זה מיועד לכם אם מעולם לא שמעתם על ניתוח קוד סטטי. עד סוף הפוסט הזה, אם תחליטו ללמוד עוד על ניתוח קוד סטטי, עברתם בהצלחה מהמצב של “אני לא יודע שאני לא יודע” אל המצב של “אני יודע שאני לא יודע”. 😁

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

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

כלל ה-”90/10” הוא פרדיגמה ידועה שחלה על תחומים רבים במדעי המחשב. לדוגמה, 90 אחוז מזמן הריצה של תוכנית מתבזבז ב-10 אחוז מהקוד. בהשלכת כלל זה על האזהרות שמשליך המהדר, נוכל לטעון ש-90 אחוז מהמתכנתים מתקנים רק 10 אחוז מאזהרות המהדר. “אלה רק אזהרות,” אני שומע אתכם אומרים!

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

אסון Ariane 5

מה יכול להיות גרוע יותר מאיבוד טיל 30 שניות לתוך השיגור שלו, ופיצוץ של 370 מיליון דולר מכספי משלם המסים? הכול בגלל שהמהדר ניסה לדחוס ערך של 64 ביט לכתובת של 16 ביט.

0123456789101112131415

מרחב כתובות של 16 ביט

0123456789101112131415
63

מרחב כתובות של 64 ביט

ערך של 64 ביט יכול להיות מספר גדול מאוד (עד 2^64 -1, ליתר דיוק). זה אינו יכול להיכנס למרחב כתובות של 16 ביט, אבל המהדר יצר הוראות לעשות בדיוק את זה! תוכלו לקרוא עוד על האסון כאן.

מנת יתר של קרינה ב-Therac-25

Therac-25 הוא הסיפור על איך מכונה שנועדה להציל את חיי החולים הפכה בסופו של דבר להורגת שלהם – הכול בגלל באגים בתוכנה שלה. מצבי תחרות (race conditions) וגלישות מספר שלם (integer overflows) הביאו לתקלה במכונה, ובכך לשליחת מנות גבוהות של קרינה.

מצבי תחרות מתרחשים כאשר מספר תהליכונים (threads) שמריצים בלוק קוד אינם מסונכרנים כראוי. לדוגמה:

Thread 1Thread local storageVariableThread 2Thread local storage
Read Value00Read Value0
Increment Value10Increment Value1
Write Value11Write Value1

מרוצי נתונים (data races) הניבו תוצאה שגויה בגלל הריצה המקבילית של תהליכונים שאינם מסונכרנים. באגים כאלה אינם דטרמיניסטיים וקשים למדי למעקב.

גלישות מספר שלם מתרחשות כאשר פעולה מניבה ערכים מחוץ לטווח שניתן לייצג במספר נתון של ספרות. נניח שמספר שלם לא מסומן (unsigned) של 16 ביט מחזיק את ערכו המרבי של 65535 כך:

1111111111111111

מרחב כתובות של 16 ביט

אם ננסה להוסיף 1 למספר זה, מתרחשת גלישת מספר שלם מכיוון ש-65536 אינו ניתן לייצוג במספר שלם של 16 ביט. גלישה זו גורמת להתנהגות לא מוגדרת (undefined behavior) עם מספרים שלמים מסומנים ויכולה לגרום לדליפת נתונים למרחב זיכרון אחר (עם מספר שלם לא מסומן, לעומת זאת, התנהגות הגלישה מוגדרת וצפויה ולא תגרום לבעיה).

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

Heartbleed – פגיעות ב-OpenSSL

הבאג Heartbleed

זהו אחד מאותם באגים נדירים שיש לו דף אינטרנט משלו! OpenSSL היא ספריית הצפנה המשמשת לאבטחת מידע. היא קוד פתוח ונתמכת היטב בכל מערכות ההפעלה המודרניות. בדיקת גבולות (bounds check) חסרה בטיפול בהרחבת ה-TLS heartbeat ניתנת לניצול כדי לחשוף מידע רגיש. אף שהמהדר אינו תופס זאת, השמטת בדיקת גבולות ניתנת לתפיסה על ידי כלי לניתוח קוד סטטי. פגיעות תוכנה קריטית יכולה אף להפוך לנשק. המפורסם ביותר מבין מקרים כאלה הוא זה של Stuxnet שבו תוכנה לאוטומציה תעשייתית הותקפה באופן ספציפי כדי לספק תולעת (worm) ששיבשה את הבקרים הלוגיים מתוכנתים (PLC), ופעלה כנשק.

הערת אגב: מהי בדיקת גבולות, ולמה היא חשובה?

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

420100713532
Index 0Index 1Index 2Index 3Index 4Index 5Index 6Index 7

גבולות המערך הם (0, 7), שניהם כוללים. שפות מסוימות כמו C# או Java בודקות אם הגישה למערך נמצאת בתוך הגבול הזה. שפות כמו C ו-C++‎ מאפשרות למתכנת להתמודד עם הגישה למערך, ואין שום בדיקת גבולות שמתווספת על ידי המהדר. זה מוביל לבאגים עדינים שניתן לתפוס במהלך ניתוח קוד סטטי.

כיצד לנהל את איכות הקוד?

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

  • ביקורות קוד (Code reviews) – הנוהג של פיקוח על קוד לפני שהוא נכנס לסביבת הייצור הוא דרך מצוינת לעצור באגים. יעילות גישה זו תלויה במידה רבה ביכולת של המבקר.
  • בדיקות תוכנה (Software testing) – יותר מ-50% מהזמן בפרויקטי תוכנה מושקע בבדיקות. בדיקות יכולות לתפוס באגים, והרצות בדיקה אוטומטיות מבטיחות שהבאגים לא חוזרים (regression). אבל בדיקות יקרות וגם יכולות להוביל לתחושת ביטחון מזויפת.
  • ניתוח קוד סטטי (Static code analysis) – בניגוד לביקורות קוד שזקוקות למבקרים אנושיים, ניתוח קוד סטטי משתמש בכלים כדי לבדוק תוכניות. בדיקה זו יכולה אף להשתלב בבנייה הלילית (nightly builds) כדי ליצור דוחות בנייה יומיים. חיסרון יהיה העלות הכרוכה בכלי.

מהו ניתוח קוד סטטי?

במהלך ההידור, קוד המקור מומר לייצוגי ביניים כמו עץ תחביר מופשט (Abstract Syntax Tree, AST) וגרף בקרת זרימה (Control Flow Graph, CFG). מהדרים משתמשים בייצוגי ביניים אלה כדי להריץ אלגוריתמים של ניתוח זרימת נתונים (data flow analysis, DFA) לצורך אופטימיזציות של קוד. במהלך שלב אופטימיזציית הקוד, ניתן לקבוע את המשתנים הלא בשימוש ואת הקוד הלא בשימוש (dead code). המטרה העיקרית של מהדר היא להמיר ייצוגי ביניים כאלה לקוד הרצה. לעומת זאת, המטרה העיקרית של כלי לניתוח קוד סטטי היא להשתמש בייצוגי הביניים כדי למצוא בעיות בקוד.

מה ניתוח קוד סטטי יכול לעשות עבורכם?

ניתוח קוד סטטי יכול

  • לזהות קוד שסוטה מתקן קידוד (למשל, MISRA C)
  • לזהות קוד שעלול להוביל לדליפות משאבים או זיכרון
  • לזהות קוד שעלול להוביל ל-null pointer dereferencing
  • לזהות בעיות מקביליות בקוד המובילות למצבי תחרות
  • לזהות שימוש שגוי בממשקי API
  • לזהות תנאים שתמיד מסתכמים ל-true או ל-false
  • לזהות בעיות קדימות אופרטורים (operator precedence)
  • ועוד…

(עיינו בנספח לפרטים נוספים על חלק מהבעיות הללו.)

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

לכן הדרך האידיאלית להריץ ניתוח קוד סטטי היא לשלב אותו עם ניהול בקרת המקור (source control management) ועם מערך הבנייה הלילית שלו. כלי חינמי בקוד פתוח, CppCheck, מאפשר לכם לנתח בצורה חלקה קוד C ו-C++‎ במחשב שלכם. תוכלו אפילו למצוא GitHub actions חינמיים לשימוש (🤑) שמאפשרים לכם להריץ את CppCheck בחופשיות על המאגר שלכם המתארח ב-GitHub. אף ש-CppCheck חינמי, יש לו חלק נכבד של מגבלות והוא אינו מספק מערכת מקיפה כל כך של כללים ובדיקות כמו כלי ניתוח סטטי מסחריים. לכן, אני ממליץ להשתמש ב-CppCheck אם אינכם משתמשים בכלום כרגע ויש לכם תקציב מצומצם. עם זאת, אם אתם עובדים בתעשייה (כגון רכב, מכשור רפואי, תעופה וכו’) שצריכה לעמוד בתקני בטיחות מסוימים, אז אני ממליץ לכם בחום להשתמש בכלי מסחרי כגון C++Test של Parasoft או QA-MISRA של QA Systems. הכלים המסחריים דורשים מומחיות מסוימת כדי להגדיר אותם כחלק מאוטומציית הבדיקות שלכם, וזה משהו ש-Novodes תשמח לעזור לכם בו.

נספח – המשך טכני לפסקה על מה ניתוח קוד סטטי יכול לעשות עבורכם

  1. AST: עץ תחביר מופשט (Abstract Syntax Tree) הוא מבנה נתונים המתקבל כתוצאה מ-lexing ו-parsing של תוכנית. למידע נוסף, ראו https://en.wikipedia.org/wiki/Abstract_syntax_tree
  2. CFG: גרף בקרת זרימה (Control Flow Graph). גרף שבו בלוקים בסיסיים של תוכנית מהווים את הצמתים וזרימת הבקרה מתארת את הקשתות. מבנה נתונים זה מתקבל כתוצאה ממעבר אופטימיזציה במהדר. רוב הכלים לניתוח קוד סטטי דורשים זאת כתנאי מקדים. למידע נוסף, ראו https://en.wikipedia.org/wiki/Control-flow_graph
  3. DFA: ניתוח זרימת נתונים (Data flow analysis). ניתוח זרימת נתונים מקים משוואות נסיגה (recurrence equations), שהפתרונות שלהן יכולים להכריע אם ניתן לבצע אופטימיזציה מסוימת (למשל, Liveness analysis, Code hoisting, Copy propagation, ו-Common sub-expression elimination). למידע נוסף, ראו https://en.wikipedia.org/wiki/Data-flow_analysis
  4. שימוש שגוי ב-API: לכל API – בין אם בקשת שירות אינטרנט, קריאה לספריית צד שלישי, או אפילו הקריאה לפונקציית ספרייה סטנדרטית – יש חוזה שיש לעקוב אחריו. קחו לדוגמה את פונקציית ה-C הסטנדרטית strtok:

char * strtok ( char * str, const char * delimiters );

החוזה אומר: בקריאה ראשונה*, הפונקציה מצפה למחרוזת C כארגומנט עבור str, שהתו הראשון שלה משמש כמיקום ההתחלתי לסריקת tokens.* בקריאות עוקבות*, הפונקציה מצפה ל-null pointer ומשתמשת במיקום שמיד לאחר סוף ה-token האחרון כמיקום ההתחלתי החדש לסריקה.*

ללא הבנה ועמידה בחוזה זה אנו מובטחים שיהיו לנו באגים.

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

if (isUser = AuthenticateUser(username, password) == FAIL) {

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

תיקון זה משתמש בסוגריים, ובכך כופה את קדימות האופרטורים הנכונה:

if ((isUser = AuthenticateUser(username, password)) == FAIL) {