دراسة سلوك غامض واحد

تستكشف المقالة المظاهر المحتملة للسلوك غير المحدد الذي يحدث في c ++ عند اكتمال دالة غير خالية دون استدعاء الإرجاع بقيمة مناسبة. المقالة علمية ومسلية أكثر منها عملية.

من لا يحب المرح في القفز على أشعل النار - نحن نمر ، لا نتوقف.

المقدمة


يعلم الجميع أنه عند تطوير كود c ++ ، يجب ألا تسمح بسلوك غير محدد.
ومع ذلك:

  • قد لا يبدو السلوك غير المحدود خطيرًا بما فيه الكفاية بسبب تجريد العواقب المحتملة ؛
  • ليس من الواضح دائمًا أين هو الخط.

دعونا نحاول تحديد المظاهر المحتملة للسلوك غير المحدد الذي يحدث في حالة واحدة بسيطة نوعًا ما - في وظيفة غير خالية ، لا عودة.

للقيام بذلك ، ضع في اعتبارك الرمز الذي تم إنشاؤه من قبل المترجمين الأكثر شيوعًا في أوضاع التحسين المختلفة.

سيتم إجراء البحث في Linux باستخدام مستكشف المترجم . البحث على Windows و macOs X - على الأجهزة المتاحة لي مباشرة.

سيتم تنفيذ كافة الإصدارات لـ x86-x64.

لن يتم اتخاذ أي تدابير لتعزيز أو قمع تحذيرات / أخطاء المترجم.

سيكون هناك الكثير من التعليمات البرمجية المفككة. تصميمه ، للأسف ، هو متنوع ، لأنه يجب أن أستخدم العديد من الأدوات المختلفة (حسنًا ، على الأقل تمكنت من الحصول على بنية Intel في كل مكان). سأقدم تعليقات تفصيلية بشكل معتدل حول التعليمات البرمجية المفككة ، والتي ، مع ذلك ، لا تلغي الحاجة إلى معرفة سجلات المعالج ومبادئ المكدس.

قراءة قياسي


المسودة النهائية لـ C ++ 11 n3797 ، المسودة النهائية لـ C ++ 14 N3936:
6.6.3 بيان الإرجاع
...
انسياب نهاية الدالة يعادل الإرجاع بدون قيمة ؛ وينتج عن ذلك
سلوك غير محدد في دالة إرجاع القيمة.
...

يعادل الوصول إلى نهاية دالة العائد بدون قيمة إرجاع ؛ بالنسبة للدالة التي يتم توفير قيمتها المرتجعة ، يؤدي هذا إلى سلوك غير محدد.

مشروع C ++ 17 n4713
9.6.3 بيان الإرجاع
...
انسياب نهاية المنشئ أو المدمر أو دالة بنوع إرجاع cv void يعادل الإرجاع بدون معامل. وبخلاف ذلك ، فإن التدفق خارج نهاية دالة غير الرئيسية (6.8.3.1) ينتج عنه سلوك غير محدد.
...

إن الوصول إلى نهاية المنشئ أو المدمر أو الوظيفة بقيمة إرجاع فارغة (ربما باستخدام مؤهلات ثابتة ومتقلبة) يعادل الإرجاع بدون قيمة إرجاع. بالنسبة لجميع الوظائف الأخرى ، يؤدي هذا إلى سلوك غير محدد (باستثناء الوظيفة الرئيسية).

ماذا يعني هذا في الممارسة العملية؟

إذا كان توقيع الوظيفة يوفر قيمة إرجاع:

  • يجب أن ينتهي تنفيذه ببيان إرجاع بمثيل من النوع المناسب ؛
  • خلاف ذلك ، السلوك الغامض.
  • لا يبدأ السلوك غير المحدد من لحظة استدعاء الوظيفة وليس من لحظة استخدام القيمة المرتجعة ، ولكن من لحظة عدم اكتمال الوظيفة بشكل صحيح ؛
  • إذا كانت الوظيفة تحتوي على مسارات تنفيذ صحيحة وغير صحيحة - سيحدث سلوك غير محدد فقط على المسارات غير الصحيحة ؛
  • لا يؤثر السلوك غير المحدد في تنفيذ التعليمات الواردة في نص الوظيفة.

العبارة حول الوظيفة الرئيسية ليست جديدة على c ++ 17 - في الإصدارات السابقة من المعيار ، تم وصف استثناء مماثل في القسم 3.6.1 الوظيفة الرئيسية.

المثال 1 - منطقي


في c ++ ، لا يوجد نوع مع حالة أبسط من bool. لنبدأ معه.

#include <iostream>

bool bad() {};

int main()
{
    std::cout << bad();

    return 0;
}

يولد MSVC خطأ ترجمة C4716 لمثل هذا المثال ، لذا يجب أن يكون رمز MSVC معقدًا قليلاً من خلال توفير مسار تنفيذ واحد صحيح على الأقل:

#include <iostream>
#include <stdlib.h>

bool bad()
{
    if (rand() == 0) {
        return true;
    }
}

int main()
{
    std::cout << bad();

    return 0;
}

التحويل البرمجي:

منصةالمترجمنتيجة التجميع
لينكسالإصدار x86-x64 Clang 10.0.0تحذير: لا ترجع الدالة غير الفارغة قيمة [-Wreturn-type]
لينكسx86-x64 الخليج 9.3تحذير: لا يوجد بيان إرجاع في دالة إرجاع غير فارغ [-Wreturn-type]
macOs Xنسخة Apple clang 11.0.0تحذير: يصل التحكم إلى نهاية الوظيفة غير الفارغة [-Wreturn-type]
شبابيكMSVC 2019 16.5.4المثال الأصلي هو الخطأ C4716 ، معقد - تحذير C4715: لا ترجع كل مسارات التحكم قيمة

نتائج التنفيذ:
الاقويعودة البرنامجإخراج وحدة التحكم
Linux x86-x64 Clang 10.0.0
-O0255لا يوجد إخراج
-O1 ، -O20لا يوجد إخراج
Linux x86-x64 gcc 9.3
-O0089
-O1 ، -O2 ، -O30لا يوجد إخراج
macOs X Apple clang الإصدار 11.0.0
-O0 ، -O1 ، -O200
Windows MSVC 2019 16.5.4 ، المثال الأصلي
/ Od ، / O1 ، / O2لا يوجد بناءلا يوجد بناء
Windows MSVC 2019 16.5.4 مثال معقد
/ Od041
/ O1 ، / O201

حتى في هذا المثال الأبسط ، أظهر أربعة مترجمين ثلاث طرق على الأقل لعرض سلوك غير محدد.

دعونا نكتشف ما جمعه هؤلاء المترجمون هناك.

Linux x86-x64 Clang 10.0.0، -O0


صورة

العبارة الأخيرة في دالة bad () هي ud2 .

وصف الإرشادات من دليل مطوري برامج Intel 64 و IA-32 :
UD2—Undefined Instruction
Generates an invalid opcode exception. This instruction is provided for software testing to explicitly generate an invalid opcode exception. The opcode for this instruction is reserved for this purpose.
Other than raising the invalid opcode exception, this instruction has no effect on processor state or memory.

Even though it is the execution of the UD2 instruction that causes the invalid opcode exception, the instruction pointer saved by delivery of the exception references the UD2 instruction (and not the following instruction).

This instruction’s operation is the same in non-64-bit modes and 64-bit mode.

باختصار ، هذه تعليمات خاصة لرمي استثناء.

تحتاج إلى لف المكالمة () السيئة في محاولة ... التقاط!

لا يهم كيف. هذا ليس استثناء c ++.

هل من الممكن التقاط ud2 في وقت التشغيل؟
في نظام التشغيل Windows ، يجب استخدام __try لهذا ؛ في نظام Linux و macOs X ، معالج إشارة SIGILL.

Linux x86-x64 Clang 10.0.0 و -O1 و -O2


صورة

نتيجة للتحسين ، قام المترجم بكل بساطة بالتخلص من كل من وظيفة () السيئة واستدعائها.

Linux x86-x64 gcc 9.3، -O0


صورة

التفسيرات (بترتيب عكسي ، لأنه في هذه الحالة ، من السهل تحليل السلسلة من النهاية):

5. يسمى عامل الإخراج في دفق منطقية (السطر 14) ؛

4. العنوان std :: cout موضوع في سجل edi - هذه هي الوسيطة الأولى لعامل الإخراج في الدفق (السطر 13) ؛

3. يتم وضع محتويات سجل eax في سجل esi - هذه هي الوسيطة الثانية لعامل الإخراج في الدفق (السطر 12) ؛

2. يتم إعادة تعيين وحدات البايت المرتفعة من eax إلى الصفر ، ولا تتغير قيمة al (السطر 11) ؛

1. تسمى الدالة () السيئة (السطر 10) ؛

0. يجب أن تضع الدالة bad () القيمة المرتجعة في السجل.

بدلاً من ذلك ، يظهر السطر 4 بلا (لا عملية ، وهمية).

يتم إخراج بايت واحد من القمامة من التسجيل al إلى وحدة التحكم. ينتهي البرنامج بشكل طبيعي.

Linux x86-x64 gcc 9.3 و -O1 و -O2 و -O3


صورة

ألقى المترجم كل شيء نتيجة التحسين.

macOs X Apple clang الإصدار 11.0.0 ، -O0


الوظيفة main ():

صورة

مسار الوسيطة المنطقية لعامل الإخراج إلى الدفق (هذه المرة بالترتيب المباشر):

1. يتم وضع محتويات السجل في سجل edx (السطر 8) ؛

2. جميع وحدات البت في سجل edx مُصفَّرة ، باستثناء أقلها (السطر 9) ؛

3. يتم وضع مؤشر لـ std :: cout في سجل rdi - وهذه هي الوسيطة الأولى لعامل الإخراج في الدفق (السطر 10) ؛

4. يتم وضع محتويات سجل edx في سجل esi - هذه هي الوسيطة الثانية لعامل الإخراج في الدفق (السطر 11) ؛

5. يتم استدعاء بيان الإخراج في التدفق للبول (السطر 13) ؛

تتوقع الوظيفة الرئيسية الحصول على نتيجة الوظيفة السيئة () من التسجيل al.

الوظيفة السيئة ():

صورة

1. توضع قيمة البايت التالي من المكدس ، غير المخصصة بعد ، في التسجيل al (السطر 4) ؛

2. يتم استثناء جميع أجزاء السجل ، باستثناء الأقل أهمية (السطر 5) ؛

يتم إخراج بت واحد من القمامة من المكدس غير المخصص إلى وحدة التحكم. حدث ذلك أنه خلال التشغيل التجريبي تبين أنه صفر.

ينتهي البرنامج بشكل طبيعي.

macOs X Apple clang الإصدار 11.0.0 ، -O1 ، -O2


صورة

تم إلغاء الوسيطة المنطقية لعامل الإخراج في الدفق (السطر 5).

تم طرح المكالمة () السيئة أثناء التحسين.

يعرض البرنامج دائمًا صفرًا في وحدة التحكم ويخرج بشكل طبيعي.

Windows MSVC 2019 16.5.4 ، مثال متقدم ، / Od


صورة

يمكن ملاحظة أن الدالة bad () يجب أن توفر قيمة إرجاع في التسجيل al.

صورة

يتم أولاً دفع القيمة التي يتم إرجاعها بواسطة الدالة bad () إلى المكدس ثم إلى سجل edx لتدفق الإخراج.

يتم إخراج بايت واحد من القمامة من التسجيل al إلى وحدة التحكم (إذا كان أكثر دقة ، ثم بايت منخفض نتيجة راند ()). ينتهي البرنامج بشكل طبيعي.

Windows MSVC 2019 16.5.4 مثال معقد ، / O1 ، / O2


صورة

قام المحول البرمجي بتضمين المكالمة () السيئة. الوظيفة الأساسية:

  • نسخ بايت واحد من ebx من الذاكرة الموجودة عند [rsp + 30h] ؛
  • إذا عادت rand () صفرًا ، انسخ الوحدة من ecx إلى ebx (السطر 11) ؛
  • ينسخ نفس القيمة إلى dl (بتعبير أدق ، البايت الأقل دلالة) (السطر 13) ؛
  • استدعاء دالة الإخراج في الدفق ، والتي تنتج قيمة dl (السطر 14).

يتم إخراج بايت واحد من القمامة من ذاكرة الوصول العشوائي (من العنوان rsp + 30h) للتدفق.

خاتمة المثال 1


تظهر نتائج النظر في قوائم تفكيك في الجدول:
الاقويعودة البرنامجإخراج وحدة التحكمسبب
Linux x86-x64 Clang 10.0.0
-O0255لا يوجد إخراجud2
-O1 ، -O20لا يوجد إخراجتم طرح إخراج وحدة التحكم والاستدعاء للدالة السيئة () نتيجة للتحسين
Linux x86-x64 gcc 9.3
-O0089بايت واحد من القمامة من سجل آل
-O1 ، -O2 ، -O30لا يوجد إخراجتم طرح إخراج وحدة التحكم والاستدعاء للدالة السيئة () نتيجة للتحسين
macOs X Apple clang الإصدار 11.0.0
-O000قطعة واحدة من القمامة من ذاكرة الوصول العشوائي
-O1 ، -O200تم استبدال استدعاء الدالة () بالصفر
Windows MSVC 2019 16.5.4 ، المثال الأصلي
/ Od ، / O1 ، / O2لا يوجد بناءلا يوجد بناءلا يوجد بناء
Windows MSVC 2019 16.5.4 مثال معقد
/ Od041بايت واحد من القمامة من سجل آل
/ O1 ، / O201بايت واحد من القمامة من ذاكرة الوصول العشوائي

كما اتضح ، لم يظهر المترجمون 3 ، ولكن ما يصل إلى 6 متغيرات من السلوك غير المحدد - قبل النظر في قوائم التفكيك ، لم نتمكن من تمييز بعضها.

المثال 1 أ - إدارة السلوك غير المحدد


دعنا نحاول التوجيه قليلاً بسلوك غير محدد - يؤثر على القيمة التي ترجعها الدالة bad ().

يمكن القيام بذلك فقط مع المترجمات التي تنتج القمامة.
للقيام بذلك ، قم بإزالة القيم المطلوبة في الأماكن التي سينتقل منها المترجمون.

Linux x86-x64 gcc 9.3، -O0


لا تقوم الدالة bad () الفارغة بتعديل قيمة التسجيل al ، حيث يتطلبها رمز الاتصال. وبالتالي ، إذا وضعنا قيمة معينة في al قبل استدعاء bad () ، فإننا نتوقع أن نرى هذه القيمة كنتيجة لتنفيذ bad ().

من الواضح أنه يمكن القيام بذلك عن طريق استدعاء أي دالة أخرى تقوم بإرجاع bool. ولكن يمكن القيام بذلك أيضًا باستخدام دالة تقوم بإرجاع ، على سبيل المثال ، حرف غير محرف.

رمز المثال الكامل
#include <iostream>

bool bad() {}

bool goodTrue()
{
    return rand();
}

bool goodFalse()
{
    return !goodTrue();
}

unsigned char goodChar(unsigned char ch)
{
    return ch;
}

int main()
{
    goodTrue();
    std::cout << bad() << std::endl;

    goodChar(85);
    std::cout << bad() << std::endl;

    goodFalse();
    std::cout << bad() << std::endl;

    goodChar(240);
    std::cout << bad() << std::endl;

    return 0;
}


الإخراج إلى وحدة التحكم:
1
85
0
240

Windows MSVC 2019 16.5.4 ، / Od


في المثال الخاص بـ MSVC ، تُرجع الدالة bad () البايت المنخفض لنتيجة rand ().

بدون تعديل وظيفة bad () ، يمكن أن تؤثر الشفرة الخارجية على قيمتها المرتجعة عن طريق تعديل نتيجة rand ().

رمز المثال الكامل
#include <iostream>
#include <stdlib.h>

void control(unsigned char value)
{
    uint32_t count = 0;
    srand(0);
    while ((rand() & 0xff) != value) {
        ++count;
    }

    srand(0);
    for (uint32_t i = 0; i < count; ++i) {
        rand();
    }
}

bool bad()
{
    if (rand() == 0) {
        return true;
    }
}

int main()
{
    control(1);
    std::cout << bad() << std::endl;

    control(85);
    std::cout << bad() << std::endl;

    control(0);
    std::cout << bad() << std::endl;

    control(240);
    std::cout << bad() << std::endl;

    return 0;
}


الإخراج إلى وحدة التحكم:
1
85
0
240


Windows MSVC 2019 16.5.4 ، / O1 ، / O2


للتأثير على القيمة "المرتجعة" بواسطة الدالة () السيئة ، يكفي إنشاء متغير مكدس واحد. حتى لا يتم التخلص من السجل الموجود فيه أثناء التحسين ، يجب عليك وضع علامة على أنه متقلب.
رمز المثال الكامل
#include <iostream>
#include <stdlib.h>

bool bad()
{
  if (rand() == 0) {
    return true;
  }
}

int main()
{
  volatile unsigned char ch = 1;
  std::cout << bad() << std::endl;

  ch = 85;
  std::cout << bad() << std::endl;

  ch = 0;
  std::cout << bad() << std::endl;

  ch = 240;
  std::cout << bad() << std::endl;

  return 0;
}


الإخراج إلى وحدة التحكم:
1
85
0
240


macOs X Apple clang الإصدار 11.0.0 ، -O0


قبل استدعاء bad () ، يجب إدخال قيمة معينة في خلية الذاكرة تلك ستكون أقل من أعلى المكدس في وقت استدعاء bad ().

رمز المثال الكامل
#include <iostream>

bool bad() {}

void putToStack(uint8_t value)
{
    uint8_t memory[1]{value};
}

int main()
{
    putToStack(20);
    std::cout << bad() << std::endl;

    putToStack(55);
    std::cout << bad() << std::endl;

    putToStack(0xfe);
    std::cout << bad() << std::endl;

    putToStack(11);
    std::cout << bad() << std::endl;

    return 0;
}

-O0, memory. , .

memory , — , , .

, .. , — putToStack .

الإخراج إلى وحدة التحكم:
0
1
0
1

يبدو أنه حدث: من الممكن تغيير ناتج وظيفة () السيئة ، ولا يتم أخذ سوى البت منخفض الترتيب في الاعتبار.

خاتمة المثال 1 أ


مثال جعل من الممكن التحقق من التفسير الصحيح لقوائم المجمع.

المثال 1 ب - منطقية مكسورة


حسنًا ، تفكر في ذلك ، سيتم عرض "41" في وحدة التحكم بدلاً من "1" ... هل هذا خطير؟

سوف نتحقق من مترجمين يوفران بايتًا كاملاً من القمامة.

Windows MSVC 2019 16.5.4 ، / Od


رمز المثال الكامل
#include <iostream>
#include <stdlib.h>
#include <set>
#include <unordered_set>

bool bad()
{
    if (rand() == 0) {
        return true;
    }
}

int main()
{
    bool badBool1 = bad();
    bool badBool2 = bad();

    std::cout << "badBool1: " << badBool1 << std::endl;
    std::cout << "badBool2: " << badBool2 << std::endl;

    if (badBool1) {
      std::cout << "if (badBool1): true" << std::endl;
    } else {
      std::cout << "if (badBool1): false" << std::endl;
    }
    if (!badBool1) {
      std::cout << "if (!badBool1): true" << std::endl;
    } else {
      std::cout << "if (!badBool1): false" << std::endl;
    }

    std::cout << "(badBool1 == true || badBool1 == false || badBool1 == badBool2): "
              << std::boolalpha << (badBool1 == true || badBool1 == false || badBool1 == badBool2)
              << std::endl;
    std::cout << "std::set<bool>{badBool1, badBool2, true, false}.size(): "
              << std::set<bool>{badBool1, badBool2, true, false}.size()
              << std::endl;
    std::cout << "std::unordered_set<bool>{badBool1, badBool2, true, false}.size(): "
              << std::unordered_set<bool>{badBool1, badBool2, true, false}.size()
              << std::endl;

    return 0;
}


الإخراج إلى وحدة التحكم:
badBool1: 41
badBool2: 35
if (badBool1): true
if (! badBool1): false
(badBool1 == true || badBool1 == false || badBool1 == badBool2): false
std :: set <bool> {badBool1، badBool2 ، true، false} .size (): 4
std :: unordered_set <bool> {badBool1، badBool2، true، false} .size (): 4

أدى السلوك غير المحدد إلى ظهور متغير منطقي يكسر على الأقل:
  • عوامل المقارنة للقيم المنطقية ؛
  • دالة التجزئة لقيمة منطقية.


Windows MSVC 2019 16.5.4 ، / O1 ، / O2


رمز المثال الكامل
#include <iostream>
#include <stdlib.h>
#include <set>
#include <unordered_set>

bool bad()
{
  if (rand() == 0) {
    return true;
  }
}

int main()
{
  volatile unsigned char ch = 213;
  bool badBool1 = bad();
  ch = 137;
  bool badBool2 = bad();

  std::cout << "badBool1: " << badBool1 << std::endl;
  std::cout << "badBool2: " << badBool2 << std::endl;

  if (badBool1) {
    std::cout << "if (badBool1): true" << std::endl;
  }
  else {
    std::cout << "if (badBool1): false" << std::endl;
  }
  if (!badBool1) {
    std::cout << "if (!badBool1): true" << std::endl;
  }
  else {
    std::cout << "if (!badBool1): false" << std::endl;
  }

  std::cout << "(badBool1 == true || badBool1 == false || badBool1 == badBool2): "
    << std::boolalpha << (badBool1 == true || badBool1 == false || badBool1 == badBool2)
    << std::endl;
  std::cout << "std::set<bool>{badBool1, badBool2, true, false}.size(): "
    << std::set<bool>{badBool1, badBool2, true, false}.size()
    << std::endl;
  std::cout << "std::unordered_set<bool>{badBool1, badBool2, true, false}.size(): "
    << std::unordered_set<bool>{badBool1, badBool2, true, false}.size()
    << std::endl;

  return 0;
}


الإخراج إلى وحدة التحكم:
badBool1: 213
badBool2: 137
if (badBool1): true
if (! badBool1): false
(badBool1 == true || badBool1 == false || badBool1 == badBool2): false
std :: set <bool> {badBool1، badBool2 ، true، false} .size (): 4
std :: unordered_set <bool> {badBool1، badBool2، true، false} .size (): 4

لم يتغير العمل مع متغير منطقي تالف عند تشغيل التحسين.

Linux x86-x64 gcc 9.3، -O0


رمز المثال الكامل
#include <iostream>
#include <stdlib.h>
#include <set>
#include <unordered_set>

bool bad()
{
}

unsigned char goodChar(unsigned char ch)
{
  return ch;
}

int main()
{
  goodChar(213);
  bool badBool1 = bad();

  goodChar(137);
  bool badBool2 = bad();

  std::cout << "badBool1: " << badBool1 << std::endl;
  std::cout << "badBool2: " << badBool2 << std::endl;

  if (badBool1) {
    std::cout << "if (badBool1): true" << std::endl;
  }
  else {
    std::cout << "if (badBool1): false" << std::endl;
  }
  if (!badBool1) {
    std::cout << "if (!badBool1): true" << std::endl;
  }
  else {
    std::cout << "if (!badBool1): false" << std::endl;
  }

  std::cout << "(badBool1 == true || badBool1 == false || badBool1 == badBool2): "
    << std::boolalpha << (badBool1 == true || badBool1 == false || badBool1 == badBool2)
    << std::endl;
  std::cout << "std::set<bool>{badBool1, badBool2, true, false}.size(): "
    << std::set<bool>{badBool1, badBool2, true, false}.size()
    << std::endl;
  std::cout << "std::unordered_set<bool>{badBool1, badBool2, true, false}.size(): "
    << std::unordered_set<bool>{badBool1, badBool2, true, false}.size()
    << std::endl;

  return 0;
}


الإخراج إلى وحدة التحكم:
badBool1: 213
badBool2: 137
if (badBool1): true
if (! badBool1): true
(badBool1 == true || badBool1 == false || badBool1 == badBool2): false
std :: set <bool> {badBool1، badBool2 ، true، false} .size (): 4
std :: unordered_set <bool> {badBool1، badBool2، true، false} .size (): 4


وبالمقارنة مع MSVC ، أضاف gcc أيضًا التشغيل غير الصحيح للعامل not.

خاتمة المثال 1 ب


يمكن أن يكون لتعطيل العمليات الأساسية بقيم منطقية عواقب وخيمة على المنطق رفيع المستوى.

لماذا حصل هذا؟

لأن بعض العمليات باستخدام متغيرات منطقية يتم تنفيذها على افتراض أن true هي وحدة فقط.

لن نعتبر هذه المشكلة في أداة التفكيك - اتضح أن المقالة ضخمة.

مرة أخرى سنوضح الجدول بسلوك المترجمين:
الاقويعودة البرنامجإخراج وحدة التحكمسببعواقب استخدام نتيجة سيئة ()
Linux x86-x64 Clang 10.0.0
-O0255لا يوجد إخراجud2
-O1 ، -O20لا يوجد إخراجتم طرح إخراج وحدة التحكم والاستدعاء للدالة السيئة () نتيجة للتحسين
Linux x86-x64 gcc 9.3
-O0089بايت واحد من القمامة من سجل آلانتهاك العمل:
لا ؛ == ؛ ! = ؛ > ؛ <= ؛ > = ؛ std :: التجزئة.
-O1 ، -O2 ، -O30لا يوجد إخراجتم طرح إخراج وحدة التحكم والاستدعاء للدالة السيئة () نتيجة للتحسين
macOs X Apple clang الإصدار 11.0.0
-O000قطعة واحدة من القمامة من ذاكرة الوصول العشوائي
-O1 ، -O200تم استبدال استدعاء الدالة () بالصفر
Windows MSVC 2019 16.5.4 ، المثال الأصلي
/ Od ، / O1 ، / O2لا يوجد بناءلا يوجد بناءلا يوجد بناء
Windows MSVC 2019 16.5.4 مثال معقد
/ Od041بايت واحد من القمامة من سجل آلانتهاك العمل:
== ؛ ! = ؛ > ؛ <= ؛ > = ؛ std :: التجزئة.
/ O1 ، / O201بايت واحد من القمامة من ذاكرة الوصول العشوائيانتهاك العمل:
== ؛ ! = ؛ > ؛ <= ؛ > = ؛ std :: التجزئة.

قدم أربعة مترجمين 7 مظاهر مختلفة للسلوك غير المحدد.

المثال 2 - البنية


لنأخذ مثالاً أكثر تعقيدًا:

#include <iostream>
#include <stdlib.h>

struct Test
{
    Test(uint64_t v)
        : value(v)
    {
        std::cout << "Test::Test(" << v << ")" << std::endl;
    }
    ~Test()
    {
        std::cout << "Test::~Test()" << std::endl;
    }

    uint64_t value;
};

Test bad(int v)
{
    if (v == 0) {
        return {42};
    } else if (v == 1) {
        return {142};
    }
}

int main()
{
    const auto rnd = rand();
    std::cout << "rnd: " << rnd << std::endl;

    std::cout << bad(rnd).value << std::endl;

    return 0;
}

تتطلب بنية الاختبار معلمة واحدة من النوع int لإنشاء. يتم إخراج الرسائل التشخيصية من مُنشئها ومدمّرها. تحتوي الوظيفة (int) السيئة على مسارين للتنفيذ صالحين ، ولن يتم تنفيذ أي منهما في مكالمة واحدة.

هذه المرة - أولاً الجدول ، ثم تحليل المفكك على النقاط الغامضة.
الاقويProgram returnConsole output
Linux x86-x64 Clang 10.0.0
-O0255rnd: 1804289383ud2
-O1, -O20rnd: 1804289383
Test::Test(142)
142
Test::~Test()
if (v == 1) . else if else.
Linux x86-x64 gcc 9.3
-O00rnd: 1804289383
4198608
Test::~Test()
nop .
value .
-O1, -O2, -O30rnd: 1804289383
Test::Test(142)
142
Test::~Test()
if (v == 1) . else if else.
macOs X Apple clang version 11.0.0
-O0The program has unexpectedly finished.rnd: 16807ud2
-O1, -O20rnd: 16807
Test::Test(142)
142
Test::~Test()
if (v == 1) . else if else.
Windows MSVC 2019 16.5.4
/Od /RTCsAccess violation reading location 0x00000000CCCCCCCCrnd: 41MSVC stack frame run-time error checking
/Od, /O1, /O20
rnd : 41 8791061810776
اختبار :: ~ اختبار ()
قمامة من موقع ذاكرة عنوانه في rax

مرة أخرى نرى العديد من الخيارات: بالإضافة إلى ud2 المعروف بالفعل ، هناك على الأقل 4 سلوكيات مختلفة.

التعامل مع المترجم مع منشئ مثير للاهتمام للغاية:

  • في بعض الحالات ، استمر التنفيذ دون استدعاء المنشئ - في هذه الحالة ، كان الكائن في حالة عشوائية ؛
  • في حالات أخرى ، لم يتم توفير استدعاء منشئ على مسار التنفيذ ، وهو أمر غريب نوعًا ما.

Linux x86-x64 Clang 10.0.0 و -O1 و -O2


صورة

يتم إجراء مقارنة واحدة فقط في الرمز (السطر 14) ، وهناك قفزة مشروطة واحدة فقط (السطر 15). تجاهل المترجم المقارنة الثانية والقفزة الشرطية الثانية.
هذا يؤدي إلى الشك في أن السلوك غير المحدود بدأ في وقت أبكر مما ينص عليه المعيار.

لكن التحقق من حالة الثانية إذا لم يحتوي على أي آثار جانبية ، وعمل منطق المترجم على النحو التالي:

  • إذا كان الشرط الثاني صحيحًا - تحتاج إلى استدعاء اختبار المُنشئ بالحجة 142 ؛
  • إذا لم يكن الشرط الثاني صحيحًا ، فسيتم إنهاء الوظيفة دون إرجاع قيمة ، مما يعني سلوكًا غير محدد يمكن للمترجم من خلاله فعل أي شيء. بما في ذلك - استدعاء نفس المنشئ بنفس الوسيطة ؛
  • التحقق غير ضروري ؛ يمكن استدعاء مُنشئ الاختبار مع الوسيطة 142 دون التحقق من الحالة.

دعنا نرى ما يحدث إذا كان الفحص الثاني يحتوي على حالة ذات آثار جانبية:

Test bad(int v)
{
    if (v == 0) {
        return {42};
    } else if (v == rand()) {
        return {142};
    }
}

كود كامل
#include <iostream>
#include <stdlib.h>

struct Test
{
    Test(uint64_t v)
        : value(v)
    {
        std::cout << "Test::Test(" << v << ")" << std::endl;
    }
    ~Test()
    {
        std::cout << "Test::~Test()" << std::endl;
    }

    uint64_t value;
};

Test bad(int v)
{
    if (v == 0) {
        return {42};
    } else if (v == rand()) {
        return {142};
    }
}

int main()
{
    const auto rnd = rand();
    std::cout << "rnd: " << rnd << std::endl;

    std::cout << bad(rnd).value << std::endl;

    return 0;
}


صورة

قام المترجم بصدق بإعادة إنتاج جميع الآثار الجانبية المقصودة عن طريق استدعاء rand () (السطر 16) ، وبالتالي تبديد الشكوك حول البداية المبكرة غير المناسبة للسلوك غير المحدد.

Windows MSVC 2019 16.5.4 ، / Od / RTCs


يمكّن الخيار / RTCs تدقيق أخطاء وقت تشغيل إطار المكدس. هذا الخيار متاح فقط في تجميع التصحيح. ضع في اعتبارك الشفرة المفككة للجزء الرئيسي ():

صورة

قبل استدعاء bad (int) (السطر 4) ، يتم إعداد الوسيطات - يتم نسخ قيمة المتغير rnd إلى سجل edx (السطر 2) ، ويتم تحميل العنوان الفعال لبعض المتغيرات المحلية الموجودة في العنوان في سجل rcx rsp + 28h (السطر 3).

من المفترض أن rsp + 28 هو عنوان متغير مؤقت يخزن نتيجة استدعاء (int) سيئة.

يتم تأكيد هذا الافتراض من خلال السطور 19 و 20 - يتم تحميل العنوان الفعال للمتغير نفسه في rcx ، وبعد ذلك يتم استدعاء المدمر.

ومع ذلك ، في فاصل السطور 4-18 ، لا يتم الوصول إلى هذا المتغير ، على الرغم من إخراج قيمة حقل البيانات الخاص به للتدفق.

كما رأينا من قوائم MSVC السابقة ، يجب توقع حجة عامل إخراج الدفق في سجل rdx. يحصل سجل rdx على نتيجة لإلغاء الإشارة إلى العنوان الموجود في rax (السطر 9).

وبالتالي ، فإن رمز الاتصال يتوقع من (int) السيئ:

  • ملء متغير يتم تمرير عنوانه من خلال تسجيل rcx (هنا نرى RVO في العمل) ؛
  • إعادة عنوان هذا المتغير من خلال سجل rax.

دعنا ننتقل إلى قائمة سيئة (int):

صورة

  • في eax ، يتم إدخال القيمة 0xCCCCCCCC ، التي رأيناها في رسالة انتهاك الوصول (السطر 9) (لاحظ أنها 4 بايت فقط ، بينما في رسالة AccessViolation يتكون العنوان من 8 بايت) ؛
  • يتم استدعاء الأمر rep stos ، تنفيذ دورات 0xC لكتابة محتويات eax إلى الذاكرة بدءًا من rdi العنوان (السطر 10). هذه هي 48 بايت - بالضبط كما تم تخصيصها على المكدس في السطر 6 ؛
  • في مسارات التنفيذ الصحيحة ، يتم إدخال القيمة من rsp + 40h في rax (السطور 23 ، 36) ؛
  • قيمة سجل rcx (التي يمر من خلالها main () بعنوان الوجهة) يتم دفعها إلى المكدس عند rsp + 8 (السطر 4) ؛
  • يتم دفع rdi إلى المكدس ، مما يقلل من rsp بمقدار 8 (السطر 5) ؛
  • يتم تخصيص 30 ساعة بايت على المكدس بتقليل rsp (السطر 6).

لذا فإن rsp + 8 في السطر 4 و rsp + 40h في بقية الكود هي نفس القيمة.
الرمز مربكًا إلى حد ما لا يستخدم RBP.

هناك حادثان في رسالة انتهاك الوصول:

  • الأصفار في الجزء العلوي من العنوان - يمكن أن يكون هناك أي قمامة ؛
  • تحول العنوان عن طريق الخطأ إلى أنه غير صحيح.

على ما يبدو ، مكّن الخيار / RTCs الكتابة فوق المكدس بقيم معينة غير صفرية ، وكانت رسالة انتهاك الوصول مجرد تأثير جانبي عشوائي.

دعنا نرى كيف يختلف الكود مع خيار / RTCs قيد التشغيل عن الكود بدونه.

صورة

يختلف كود أقسام main () فقط في عناوين المتغيرات المحلية على المكدس.

صورة

(للتوضيح ، قمت بوضع نسختين من الوظيفة (int) السيئة بجوارها - مع / RTCs وبدونها)
بدون / RTCs ، اختفت تعليمات rep stos وإعداد الحجج لها في بداية الوظيفة.

مثال 2 أ


مرة أخرى ، حاول التحكم في السلوك غير المحدود. هذه المرة لمترجم واحد فقط.

Windows MSVC 2019 16.5.4 ، / Od / RTCs


مع خيار / RTCs ، يقوم المحول البرمجي بإدراج الكود في بداية الوظيفة السيئة (int) التي تملأ النصف السفلي من rax بقيمة ثابتة ، مما قد يؤدي إلى انتهاك الوصول.

لتغيير هذا السلوك ، ما عليك سوى ملء rax ببعض العناوين الصالحة.
يمكن تحقيق ذلك من خلال تعديل بسيط للغاية: إضافة ناتج شيء ما إلى std :: cout إلى الجسم (int) السيئ.

رمز المثال الكامل
#include <iostream>
#include <stdlib.h>

struct Test
{
    Test(uint64_t v)
        : value(v)
    {
        std::cout << "Test::Test(" << v << ")" << std::endl;
    }
    ~Test()
    {
        std::cout << "Test::~Test()" << std::endl;
    }

    uint64_t value;
};

Test bad(int v)
{
  std::cout << "rnd: " << v << std::endl;
  
  if (v == 0) {
        return {42};
    } else if (v == 1) {
        return {142};
    }
}

int main()
{
    const auto rnd = rand();

    std::cout << bad(rnd).value << std::endl;

    return 0;
}



rnd : 41 8791039331928
اختبار :: ~ اختبار ()

عامل التشغيل << يقوم بإرجاع ارتباط إلى دفق ، والذي يتم تنفيذه على أنه وضع العنوان std :: cout في rax. العنوان صحيح ، ويمكن إلغاء الإشارة إليه. تم منع انتهاك الوصول.

استنتاج


باستخدام أبسط الأمثلة ، تمكنا من:

  • جمع حوالي 10 مظاهر مختلفة للسلوك إلى أجل غير مسمى ؛
  • تعلم بالتفصيل كيف سيتم تنفيذ هذه الخيارات.

أظهر جميع المترجمين التزامًا صارمًا بالمعيار - في أي مثال لم يبدأ السلوك غير المحدد في وقت سابق. ولكن لا يمكنك رفض الخيال لمطوري الترجمة.

في كثير من الأحيان ، يعتمد المظهر على الفروق الدقيقة: من الجدير إضافة أو إزالة سطر واحد من التعليمات البرمجية يبدو غير ذي صلة - ويتغير سلوك البرنامج بشكل كبير.

من الواضح أنه من الأسهل عدم كتابة مثل هذا الرمز من حل الألغاز لاحقًا.

All Articles