كن حكيماً، وقم بالتنقية: حافظ على خلو كود C++ الخاص بك من الأخطاء
على الرغم من كل الخسائر التي تسببت فيها هذه الجائحة، إلا أنها جعلتنا على الأقل أكثر وعيًا بأهمية النظافة الشخصية.
لقد أصبحنا نرش المطهرات في الأماكن وعلى الأسطح وأيدينا بمعدل أكبر بكثير، فلماذا لا نقوم بتطهير كودنا البرمجي بينما نحن في ذلك؟ ففي النهاية، البرمجيات هي التي تدير العالم، والأخطاء البرمجية التي تتسبب في خلل في أداء البرامج يمكن أن تؤدي إلى أضرار جسيمة — تمامًا مثل نظيراتها من الفيروسات.
إذا كنت تبرمج بلغة C و C++، فأنت تعرف هذا الأمر جيدًا. فمن السهل تخصيص جزء من الذاكرة ثم نسيان تحريره لاحقًا، أو الكتابة عن غير قصد خارج حدود مخزن الذاكرة المؤقت. ويصعب للغاية اكتشاف هذه المشكلات دون استخدام الأدوات المناسبة، وغالبًا ما تتسبب في حدوث أعطال متفرقة ومفاجئة.
يمكن أن يساعدك استخدام أدوات التنقية أثناء كتابة البرنامج واختباره على اكتشاف عدد كبير من المشكلات في شفرة المصدر في مرحلة مبكرة، بما في ذلك تسربات الذاكرة، وتجاوز سعة المخزن المؤقت، والسلوك غير المحدد.
سنلقي اليوم نظرة على ثلاثة أنواع من أدوات التنقية في Clang، وكيفية استخدامها، والأخطاء التي يمكنها مساعدتنا في القضاء عليها في مهدها.
تنظيف مساحة العناوين باستخدام AddressSanitizer (ASan)
يُستخدم AddressSanitizer (يُعرف اختصارًا بـ ASan) للكشف عن حالات «الاستخدام بعد التحرير» و«التحرير المزدوج»، وتجاوزات سعة المخزن المؤقت (المكدس، والهيوب، والمخزن المؤقت العام) ونقص سعته، بالإضافة إلى أخطاء الذاكرة الأخرى.
It consists of both a compiler instrumentation module and a run-time library that inserts red zones around each set of bytes allocated with the malloc function. It also poisons the freed bytes and keeps track of the call stack for each malloc/free pair.
هذا هو شكل الكود الخاص بنا بدون ASan:
وإليكم ما تفعله ASan به من أجل الكشف عن الأخطاء المتعلقة بالعناوين:
Using ASan is as simple as adding the -fsanitize=address as both compiler and linker flag. You can also set a large number of run-time options via the ASAN_OPTIONS environment variable (here is a full list of ASan flags you can use).
لكي تدرك حقًّا مدى فائدة هذه الأداة، إليك اختبار صغير. حاول العثور على خطأ في مقتطف الشفرة التالي:
char const * src{ "Hello world!" };
auto const dst{ std::make_unique< char[] >( std::strlen( src ) ) };
std::strcpy( dst.get(), src );
std::puts( dst.get() );
الأمر صعب، أليس كذلك؟ والآن، تخيل أن هذا الخطأ كان جزءًا من قاعدة كود أكبر بكثير. سيستغرقنا الأمر وقتًا طويلاً لتصحيحه يدويًّا، في حين أنه عند تشغيل ASan، يمكننا تحديد تجاوز سعة المخزن المؤقت المخفي في غضون ثوانٍ (انظر العرض التوضيحي):
==1==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x60200000001c at pc 0x00000044b754 bp 0x7ffc9ea586d0 sp 0x7ffc9ea57e80
WRITE of size 13 at 0x60200000001c thread T0
#0 0x44b753 (/app/output.s+0x44b753)
كما سيقوم ASan بتنبيهك إذا استمر برنامجك في استخدام مؤشر بعد تحريره (وهو ثغرة أمنية شائعة تُعرف بـ«خطأ الاستخدام بعد التحرير»). وفيما يلي مثال على برنامج يحتوي على هذا الخطأ:
constexpr std::size_t bodyOffset{ 96u };
char * message{ new char[ 1024 ] };
// fill in the message
char * bodyBegin{ message + bodyOffset };
delete [] message;
// many lines of code ...
std::puts( bodyBegin ); // heap-use-after-free bug
وإليك التقرير الذي تحصل عليه من ASan بعد اختبار الكود:
==1==ERROR: AddressSanitizer: heap-use-after-free on address 0x6190000000e0 at pc 0x000000470a69 bp 0x7ffe047e6b90 sp 0x7ffe047e6340
READ of size 929 at 0x6190000000e0 thread T0
رائع جدًّا، أليس كذلك؟
الآن، ربما تتساءل: كم يكلف هذا؟ حسنًا، لا شيء يأتي مجانًا في عالمنا اليوم، وللأسف، ينطبق الأمر نفسه على ASan.
ومع ذلك، إذا أخذت في الاعتبار مقدار الوقت الذي يمكن أن توفره لك هذه الأداة، فإنها ليست باهظة الثمن على الإطلاق. ويبلغ متوسط الزيادة في الاستهلاك حوالي الضعف، سواء من حيث الأداء أو من حيث استخدام الذاكرة.
وبالحديث عن الأداء، فإن Valgrind، المنافس الرئيسي لـ ASan، سيؤدي إلى إبطاء برنامجك بمعدل يتراوح بين 10 و100 أضعاف. وهذا يمثل تحسناً ملحوظاً من وجهة نظرنا.
والآن بعد أن قمنا بتنظيف مساحة العناوين لدينا، دعونا نواصل عملية تطهير الذاكرة.
الكشف عن عمليات قراءة الذاكرة غير المُهيأة باستخدام MemorySanitizer (MSan)
قد تظن أن AddressSanitizer يغطي جميع الأخطاء المتعلقة بالذاكرة، لكن الأمر ليس كذلك. فهو لا يعالج عمليات قراءة الذاكرة غير المُهيأة، وهنا يأتي دور أداة تنظيف أخرى تُسمى MemorySanitizer (يُشار إليها باختصار بـ MSan).
تتيح لك MSan نسخ الذاكرة غير المُهيأة وإجراء عمليات منطقية وحسابية بسيطة عليها، ولكن عندما تحاول استخدامها في عبارة اتخاذ قرار، ستظهر لك علامة تحذير حمراء. لفهم ذلك بشكل أفضل، ألقِ نظرة على مقتطف الشفرة التالي (عرض توضيحي):
int *src{ new int[ 5 ] };
int dst[ 5 ];
std::memcpy( dst, src, 5 ); // copying uninitialized memory, OK
if ( src[ 0 ] ) // MSan warning: use-of-uninitialized-value
{
// more code ...
}
كما ترون، سيقوم MSan بتنبيهنا إلى أي قيمة غير مُهيأة قيد الاستخدام. أما تسرب الذاكرة الذي ربما لاحظتموه، فلن يتم الإبلاغ عنه لأن هذا الأمر يندرج ضمن نطاق عمل ASan — ومن المهم ألا نخلط بين الأمرين.
To get to the origin of this value, use -fsanitize-memory-track-origins flag along with the -fsanitize=memory.
Now, let’s talk a bit about how MSan actually works. It implements a bit to bit shadow mapping, as shown in the figure below, where 1 means “poisoned” or uninitialized bit. This allows for very efficient computation of the shadow memory address. Given the application memory address ProductAddr, computed ShaddowAddr is ProductAddr & ShadowMask, where ShadowMask is a platform-specific constant.
كلما ترتب على الوصول إلى إحدى البتات الملوثة أي تأثير جانبي (مثلًا في عمليات التفرع)، سيصدر تحذير. وتؤدي هذه البتة الإضافية إلى زيادة في الحمل على وحدة المعالجة المركزية بمقدار 2.5 ضعف، وعلى الذاكرة بمقدار ضعفين. وستكون هذه الأعباء الإضافية أعلى قليلاً إذا تم تتبع مصادر الذاكرة أيضًا، وبالتحديد 5 أضعاف على وحدة المعالجة المركزية و3 أضعاف على الذاكرة.
الكشف عن السلوك غير المُعرَّف باستخدام UndefinedBehaviorSanitizer (UBSan)
وأخيرًا وليس آخرًا، UndefinedBehaviorSanitizer (يُعرف اختصارًا بـ UBSan).
UBSan will catch signed integer overflow, use of null pointers, division by zero and other undefined behavior as you’re executing your program. Apart from -fsanitize=undefined compiler flag, which checks for all kinds of bugs, there are many additional flags that can be helpful in finding more specific bugs, including:
-fsanitize=bounds
-fsanitize=vptr
-fsanitize=enum
-fsanitize=signed-integer-overflow
-fsanitize=null
-fsanitize=unsigned-integer-overflow
-fsanitize=return
-fsanitize=integer-divide-by-zero
-fsanitize=unreachable
-fsanitize=alignment
كما ترى، يتعامل برنامج UBSan بشكل جيد جدًّا مع الأخطاء البسيطة مثل تلك الموضحة في المثال التالي (عرض توضيحي):
int main()
{
int m = std::numeric_limits< int >::max();
return m + 1;
}
وإليكم التقرير الذي تلقيناه:
runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
سيُظهر UBSan قدراته على أفضل وجه في عمليات التطوير اليومية وكذلك في قواعد البرمجة الضخمة. أما فيما يتعلق بالأداء، فهناك زيادة في الحمل على وحدة المعالجة المركزية (CPU) تبلغ حوالي 1.25 ضعف، ولا يوجد أي تأثير على استخدام الذاكرة على الإطلاق (لا يؤثر UBSan على تخطيط مساحة العناوين).
الجوانب السلبية التي يجب أخذها في الاعتبار
والآن بعد أن رأينا كيف يمكن أن تساعدنا أدوات التنقية في كتابة كود أفضل، لا يزال يتعين علينا أن نذكر بعض الجوانب السلبية لاستخدامها.
أولاً، على عكس Valgrind الذي يجري جميع الفحوصات دون الحاجة إلى إعادة ترجمة الكود المصدري، تتطلب أدوات التنقية إعادة ترجمة الكود الخاص بك. وقد يستغرق ذلك بعض الوقت، خاصةً إذا كنت تعمل على قاعدة كود كبيرة.
العيب الثاني (والذي يمكن القول إنه الأهم)، هو حقيقة أن ASan وMSan لا يمكنهما العمل معًا. وهذا يعني أنك ستحتاج إلى إجراء عدة عمليات تشغيل لاختبار برنامجك، وهو ما قد يستغرق، مرة أخرى، وقتًا طويلاً. ربما يكون هذا هو السبب في أن أدوات التنقية لا تزال لا تحظى بشعبية كبيرة بين مجتمع مطوري C++.
على الرغم من عيوبها، فإننا نوصي بشدة باستخدام أدوات التنقية. فهي تكتشف المشكلات التي قد تبدو آمنة بالنسبة للأدوات المنافسة، كما أنها لا تعطل سير عملك بسبب التباطؤ الشديد.
لذا، فمن المنطقي تمامًا دمجها مع عمليات التطوير «السريعة» الخاصة بك، مثل التكامل المستمر أو مسارات طلبات السحب.
ومع ذلك، دعونا لا نستبعد Valgrind بعد، لأنه لا يزال بإمكانه أن يكون مفيدًا جدًّا لنا. سنتحدث عن مزاياه في إحدى المقالات القادمة.
هذا كل ما لدينا في الوقت الحالي — تابعونا لمزيد من المنشورات الشيقة القادمة، وتذكروا أن تطوير برامج موثوقة يبدأ بكتابة كود نظيف.