Sebuah studi tentang satu perilaku yang tidak jelas

Artikel ini mengeksplorasi kemungkinan manifestasi dari perilaku tidak terdefinisi yang terjadi di c ++ ketika fungsi non-void diselesaikan tanpa memanggil kembali dengan nilai yang sesuai. Artikel ini lebih ilmiah dan menghibur daripada praktis.

Siapa yang tidak suka bersenang-senang dengan menyapu - kami lewat, kami tidak berhenti.

pengantar


Semua orang tahu bahwa ketika mengembangkan kode c ++, Anda seharusnya tidak mengizinkan perilaku yang tidak terdefinisi.
Namun:

  • perilaku tidak terbatas mungkin tidak tampak cukup berbahaya karena abstraknya konsekuensi yang mungkin terjadi;
  • tidak selalu jelas di mana garisnya.

Mari kita coba untuk menentukan kemungkinan manifestasi dari perilaku tidak terdefinisi yang terjadi dalam satu kasus yang agak sederhana - dalam fungsi non-void, tidak ada pengembalian.

Untuk melakukan ini, pertimbangkan kode yang dihasilkan oleh kompiler paling populer dalam berbagai mode optimisasi.

Penelitian di Linux akan dilakukan menggunakan Compiler Explorer . Penelitian tentang Windows dan macOs X - pada perangkat keras yang tersedia secara langsung untuk saya.

Semua build akan dilakukan untuk x86-x64.

Tidak ada tindakan yang akan diambil untuk meningkatkan atau menekan peringatan / kesalahan kompiler.

Akan ada banyak kode yang dibongkar. Desainnya, sayangnya, sangat beraneka ragam, karena Saya harus menggunakan beberapa alat yang berbeda (well, setidaknya saya berhasil mendapatkan sintaks Intel di mana-mana). Saya akan memberikan komentar yang cukup terperinci tentang kode yang dibongkar, yang, bagaimanapun, tidak menghilangkan kebutuhan akan pengetahuan tentang register prosesor dan prinsip-prinsip stack.

Baca Standar


C ++ 11 draft final n3797, C ++ 14 draft final N3936:
6.6.3 Pernyataan kembali
...
Mengalir dari akhir fungsi setara dengan pengembalian tanpa nilai; ini menghasilkan
perilaku yang tidak terdefinisi dalam fungsi pengembalian nilai.
...

Mencapai akhir fungsi sama dengan mengembalikan tanpa nilai kembali; untuk fungsi yang nilai pengembaliannya disediakan, ini mengarah pada perilaku yang tidak terdefinisi.

C ++ 17 draft n4713
9.6.3 Pernyataan pengembalian
...
Mengalir dari ujung sebuah konstruktor, sebuah destruktor, atau fungsi dengan tipe pengembalian cv void sama dengan pengembalian tanpa operan. Jika tidak, mengalir dari akhir fungsi selain utama (6.8.3.1) menghasilkan perilaku yang tidak ditentukan.
...

Mencapai ujung sebuah konstruktor, destruktor, atau fungsi dengan nilai kembali batal (mungkin dengan kualifikasi const dan volatile) setara dengan kembali tanpa nilai balik. Untuk semua fungsi lain, ini mengarah pada perilaku yang tidak terdefinisi (kecuali untuk fungsi utama).

Apa artinya ini dalam praktik?

Jika tanda tangan fungsi memberikan nilai balik:

  • pelaksanaannya harus diakhiri dengan pernyataan pengembalian dengan instance dari tipe yang sesuai;
  • sebaliknya, perilaku yang tidak jelas;
  • perilaku tidak terdefinisi tidak dimulai dari saat fungsi dipanggil dan bukan dari saat nilai yang dikembalikan digunakan, tetapi dari saat fungsi tidak selesai dengan benar;
  • jika fungsi berisi jalur eksekusi yang benar dan salah - perilaku tidak terdefinisi hanya akan terjadi pada jalur yang salah;
  • perilaku yang tidak jelas dalam pertanyaan tidak mempengaruhi pelaksanaan instruksi yang terkandung di dalam tubuh fungsi.

Ungkapan tentang fungsi utama bukanlah hal baru untuk c ++ 17 - dalam versi Standar sebelumnya, pengecualian serupa dijelaskan di bagian 3.6.1 Fungsi utama.

Contoh 1 - bool


Di c ++ tidak ada tipe dengan status yang lebih sederhana dari pada bool. Mari kita mulai dengannya.

#include <iostream>

bool bad() {};

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

    return 0;
}

MSVC menghasilkan kesalahan kompilasi C4716 untuk contoh seperti itu, jadi kode untuk MSVC harus sedikit rumit dengan menyediakan setidaknya satu jalur eksekusi yang benar:

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

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

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

    return 0;
}

Kompilasi:

PeronPenyusunHasil kompilasi
Linuxx86-x64 Dentang 10.0.0peringatan: fungsi non-void tidak mengembalikan nilai [-Wreturn-type]
Linuxx86-x64 gcc 9.3peringatan: tidak ada pernyataan kembali berfungsi mengembalikan [tipe -Wreturn]
macOs XApple dentang versi 11.0.0peringatan: kontrol mencapai akhir dari fungsi non-void [-Wreturn-type]
WindowsMSVC 2019 16.5.4Contoh aslinya adalah kesalahan C4716, rumit - peringatan C4715: tidak semua jalur kontrol mengembalikan nilai

Hasil Eksekusi:
OptimasiPengembalian programOutput konsol
Linux x86-x64 Dentang 10.0.0
-O0255Tidak ada output
-O1, -O20Tidak ada output
Linux x86-x64 gcc 9.3
-O0089
-O1, -O2, -O30Tidak ada output
macOs X Apple clang versi 11.0.0
-O0, -O1, -O200
Windows MSVC 2019 16.5.4, contoh asli
/ Od, / O1, / O2Tidak membangunTidak membangun
Contoh rumit Windows MSVC 2019 16.5.4
/ Od041
/ O1, / O201

Bahkan dalam contoh paling sederhana ini, empat kompiler telah menunjukkan setidaknya tiga cara untuk menampilkan perilaku yang tidak terdefinisi.

Mari kita cari tahu apa yang dikompilasi oleh kompiler ini di sana.

Linux x86-x64 Dentang 10.0.0, -O0


gambar

Pernyataan terakhir dalam fungsi bad () adalah ud2 .

Deskripsi instruksi dari Manual Pengembang Perangkat Lunak Arsitektur Intel 64 dan 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.

Singkatnya, ini adalah instruksi khusus untuk melempar pengecualian.

Anda harus membungkus panggilan buruk () di coba ... tangkap! Blokir

Tidak peduli seberapa. Ini bukan pengecualian c ++.

Apakah mungkin menangkap ud2 saat runtime?
Pada Windows, __try harus digunakan untuk ini, di Linux dan macOs X, pengendali sinyal SIGILL.

Linux x86-x64 Dentang 10.0.0, -O1, -O2


gambar

Sebagai hasil dari optimasi, kompiler hanya mengambil dan membuang tubuh fungsi () yang buruk dan panggilannya.

Linux x86-x64 gcc 9.3, -O0


gambar

Penjelasan (dalam urutan terbalik, karena dalam kasus ini rantai lebih mudah diurai dari akhir):

5. Operator output dalam aliran untuk bool disebut (baris 14);

4. Alamat std :: cout ditempatkan di register edi - ini adalah argumen pertama dari operator keluaran dalam aliran (baris 13);

3. Isi register eax ditempatkan dalam register esi - ini adalah argumen kedua dari operator keluaran dalam aliran (baris 12);

2. Tiga byte tinggi dari eax diatur ulang ke nol, nilai al tidak berubah (baris 11);

1. Fungsi buruk () disebut (baris 10);

0. Fungsi bad () harus meletakkan nilai balik dalam register al.

Sebaliknya, baris 4 menunjukkan nop (Tanpa Operasi, dummy).

Satu byte sampah dari al register adalah output ke konsol. Program berakhir secara normal.

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


gambar

Kompiler melemparkan semuanya sebagai hasil optimasi.

macOs X Apple clang versi 11.0.0, -O0


Function main ():

gambar

Jalur argumen Boolean dari operator output ke stream (kali ini dalam urutan langsung):

1. Isi dari register al ditempatkan di register edx (baris 8);

2. Semua bit dari register edx adalah nol, kecuali untuk yang terendah (baris 9);

3. Sebuah pointer ke std :: cout ditempatkan di register rdi - ini adalah argumen pertama dari operator keluaran dalam aliran (baris 10);

4. Isi register edx ditempatkan di register esi - ini adalah argumen kedua ke operator keluaran dalam aliran (baris 11);

5. Pernyataan output disebut dalam aliran untuk bool (baris 13);

Fungsi utama mengharapkan untuk mendapatkan hasil dari fungsi bad () dari register al.

Fungsi bad ():

gambar

1. Nilai dari byte berikutnya dari stack, belum dialokasikan, ditempatkan di register al (baris 4);

2. Semua bit dari register al dikecualikan, kecuali untuk yang paling signifikan (baris 5);

Satu bit sampah dari stack yang tidak terisi adalah output ke konsol. Kebetulan selama tes berjalan ternyata nol.

Program berakhir secara normal.

macOs X Apple clang versi 11.0.0, -O1, -O2


gambar

Argumen boolean dari operator output dalam aliran dibatalkan (baris 5).

Panggilan buruk () dilempar selama optimisasi.

Program selalu menampilkan nol di konsol dan keluar secara normal.

Windows MSVC 2019 16.5.4, Contoh Lanjut, / Od


gambar

Dapat dilihat bahwa fungsi bad () harus memberikan nilai balik dalam register al.

gambar

Nilai yang dikembalikan oleh fungsi buruk () pertama-tama didorong ke stack dan kemudian ke register edx untuk output mengalir.

Satu byte sampah dari register al adalah output ke konsol (jika sedikit lebih tepat, maka byte rendah dari hasil rand ()). Program berakhir secara normal.

Windows MSVC 2019 16.5.4 Contoh Rumit, / O1, / O2


gambar

Kompiler secara paksa menguraikan panggilan buruk (). Fungsi utama:

  • menyalin satu byte dari ebx dari memori yang terletak di [rsp + 30h];
  • jika rand () mengembalikan nol, salin unit dari ecx ke ebx (baris 11);
  • menyalin nilai yang sama ke dl (lebih tepatnya, byte paling signifikan) (baris 13);
  • memanggil fungsi output dalam aliran, yang menampilkan nilai dl (baris 14).

Satu byte sampah dari RAM (dari alamat rsp + 30h) adalah keluaran untuk streaming.

Kesimpulan dari contoh 1


Hasil pertimbangan daftar disassembler ditunjukkan pada tabel:
OptimasiPengembalian programOutput konsolSebab
Linux x86-x64 Dentang 10.0.0
-O0255Tidak ada outputud2
-O1, -O20Tidak ada outputKeluaran konsol dan fungsi panggilan ke fungsi buruk () dilemparkan sebagai hasil optimasi
Linux x86-x64 gcc 9.3
-O0089Satu byte sampah dari register al
-O1, -O2, -O30Tidak ada outputKeluaran konsol dan fungsi panggilan ke fungsi buruk () dilemparkan sebagai hasil optimasi
macOs X Apple clang versi 11.0.0
-O000Satu bit sampah dari RAM
-O1, -O200Panggilan fungsi buruk () diganti dengan nol
Windows MSVC 2019 16.5.4, contoh asli
/ Od, / O1, / O2Tidak membangunTidak membangunTidak membangun
Contoh rumit Windows MSVC 2019 16.5.4
/ Od041Satu byte sampah dari register al
/ O1, / O201Satu byte sampah dari RAM

Ternyata, kompiler tidak menunjukkan 3, tetapi sebanyak 6 varian perilaku tidak terdefinisi - sebelum mempertimbangkan daftar disassembler, kami tidak dapat membedakan beberapa dari mereka.

Contoh 1a - Mengelola Perilaku Tidak Terdefinisi


Mari kita coba sedikit dengan perilaku tidak terdefinisi - memengaruhi nilai yang dikembalikan oleh fungsi buruk ().

Ini hanya dapat dilakukan dengan kompiler yang menghasilkan sampah.
Untuk melakukan ini, telusuri nilai yang diinginkan ke tempat-tempat dari mana penyusun akan mengambilnya.

Linux x86-x64 gcc 9.3, -O0


Fungsi bad bad () tidak mengubah nilai register al, karena kode panggilan mengharuskannya. Jadi, jika kita menempatkan nilai tertentu dalam al sebelum memanggil bad (), maka kami berharap melihat nilai itu sebagai hasil dari mengeksekusi bad ().

Jelas, ini dapat dilakukan dengan memanggil fungsi lain yang mengembalikan bool. Tapi itu juga bisa dilakukan dengan menggunakan fungsi yang mengembalikan, misalnya, karakter yang tidak ditandai.

Kode contoh lengkap
#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;
}


Output ke konsol:
1
85
0
240

Windows MSVC 2019 16.5.4, / Od


Dalam contoh untuk MSVC, fungsi bad () mengembalikan byte rendah dari hasil rand ().

Tanpa memodifikasi fungsi bad (), kode eksternal dapat memengaruhi nilai pengembaliannya dengan memodifikasi hasil rand ().

Kode contoh lengkap
#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;
}


Output ke konsol:
1
85
0
240


Windows MSVC 2019 16.5.4, / O1, / O2


Untuk memengaruhi bukan nilai "dikembalikan" oleh fungsi buruk (), cukup membuat satu variabel tumpukan. Agar catatan di dalamnya tidak dibuang selama optimisasi, Anda harus menandainya sebagai volatile.
Kode contoh lengkap
#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;
}


Output ke konsol:
1
85
0
240


macOs X Apple clang versi 11.0.0, -O0


Sebelum memanggil bad (), Anda harus memasukkan nilai tertentu di sel memori itu yang akan menjadi kurang dari bagian atas tumpukan pada saat memanggil bad ().

Kode contoh lengkap
#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 .

Output ke konsol:
0
1
0
1

Tampaknya telah terjadi: adalah mungkin untuk mengubah output dari fungsi bad (), dan hanya bit orde rendah yang diperhitungkan.

Kesimpulan dari contoh 1a


Sebuah contoh memungkinkan untuk memverifikasi interpretasi yang benar dari daftar disassembler.

Contoh 1b - bool rusak


Nah, Anda memikirkannya, "41" akan ditampilkan di konsol bukannya "1" ... Apakah ini berbahaya?

Kami akan memeriksa dua kompiler yang menyediakan seluruh byte sampah.

Windows MSVC 2019 16.5.4, / Od


Kode contoh lengkap
#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;
}


Output ke konsol:
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

Perilaku tidak terdefinisi menyebabkan munculnya variabel Boolean yang memecah setidaknya:
  • operator perbandingan untuk nilai boolean;
  • fungsi hash dari nilai boolean.


Windows MSVC 2019 16.5.4, / O1, / O2


Kode contoh lengkap
#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;
}


Output ke konsol:
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

Bekerja dengan variabel Boolean yang rusak tidak berubah ketika optimisasi dihidupkan.

Linux x86-x64 gcc 9.3, -O0


Kode contoh lengkap
#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;
}


Output ke konsol:
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


Dibandingkan dengan MSVC, gcc juga menambahkan operasi yang salah dari operator yang tidak.

Kesimpulan dari contoh 1b


Gangguan operasi dasar dengan nilai-nilai Boolean dapat memiliki konsekuensi serius untuk logika tingkat tinggi.

Kenapa ini terjadi?

Karena beberapa operasi dengan variabel Boolean diimplementasikan dengan asumsi bahwa true hanyalah sebuah unit.

Kami tidak akan mempertimbangkan masalah ini di disassembler - artikel ini ternyata sangat produktif.

Sekali lagi, kami akan mengklarifikasi tabel dengan perilaku penyusun:
OptimasiPengembalian programOutput konsolSebabKonsekuensi menggunakan hasil yang buruk ()
Linux x86-x64 Dentang 10.0.0
-O0255Tidak ada outputud2
-O1, -O20Tidak ada outputKeluaran konsol dan fungsi panggilan ke fungsi buruk () dilemparkan sebagai hasil optimasi
Linux x86-x64 gcc 9.3
-O0089Satu byte sampah dari register alPelanggaran pekerjaan:
tidak; ==; ! =; <; >; <=; > =; std :: hash.
-O1, -O2, -O30Tidak ada outputKeluaran konsol dan fungsi panggilan ke fungsi buruk () dilemparkan sebagai hasil optimasi
macOs X Apple clang versi 11.0.0
-O000Satu bit sampah dari RAM
-O1, -O200Panggilan fungsi buruk () diganti dengan nol
Windows MSVC 2019 16.5.4, contoh asli
/ Od, / O1, / O2Tidak membangunTidak membangunTidak membangun
Contoh rumit Windows MSVC 2019 16.5.4
/ Od041Satu byte sampah dari register alPelanggaran pekerjaan:
==; ! =; <; >; <=; > =; std :: hash.
/ O1, / O201Satu byte sampah dari RAMPelanggaran pekerjaan:
==; ! =; <; >; <=; > =; std :: hash.

Empat penyusun memberikan 7 manifestasi berbeda dari perilaku yang tidak terdefinisi.

Contoh 2 - struct


Mari kita ambil contoh yang sedikit lebih rumit:

#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;
}

Struktur Uji memerlukan parameter tunggal dari tipe int untuk membangun. Pesan diagnostik adalah output dari konstruktor dan destruktornya. Fungsi buruk (int) memiliki dua jalur eksekusi yang valid, tidak ada yang akan diimplementasikan dalam satu panggilan.

Kali ini - pertama tabel, kemudian analisis disassembler pada poin yang tidak jelas.
OptimasiProgram 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, /O20rnd: 41
8791061810776
Test :: ~ Test ()
Sampah dari lokasi memori yang alamatnya berada di rax

Sekali lagi kita melihat banyak opsi: selain ud2 yang sudah diketahui, setidaknya ada 4 perilaku berbeda.

Penanganan kompiler dengan konstruktor sangat menarik:

  • dalam beberapa kasus, eksekusi berlanjut tanpa memanggil konstruktor - dalam kasus ini, objek berada dalam keadaan acak;
  • dalam kasus lain, panggilan konstruktor tidak disediakan untuk jalur eksekusi, yang agak aneh.

Linux x86-x64 Dentang 10.0.0, -O1, -O2


gambar

Hanya satu perbandingan yang dibuat dalam kode (baris 14), dan hanya ada satu lompatan bersyarat (baris 15). Kompiler mengabaikan perbandingan kedua dan lompatan bersyarat kedua.
Ini mengarah pada kecurigaan bahwa perilaku tak terbatas dimulai lebih awal dari yang ditentukan oleh Standar.

Tetapi memeriksa kondisi kedua jika tidak mengandung efek samping, dan logika kompiler berfungsi sebagai berikut:

  • jika kondisi kedua benar - Anda perlu memanggil Tes konstruktor dengan argumen 142;
  • jika kondisi kedua tidak benar, fungsi akan keluar tanpa mengembalikan nilai, yang berarti perilaku tidak terdefinisi di mana kompiler dapat melakukan apa saja. Termasuk - memanggil konstruktor yang sama dengan argumen yang sama;
  • verifikasi tidak perlu, konstruktor Uji dengan argumen 142 dapat dipanggil tanpa memeriksa kondisinya.

Mari kita lihat apa yang terjadi jika pemeriksaan kedua mengandung kondisi dengan efek samping:

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

Kode lengkap
#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;
}


gambar

Kompiler dengan jujur ​​mereproduksi semua efek samping yang dimaksudkan dengan memanggil rand () (baris 16), dengan demikian menghilangkan keraguan tentang awal yang tidak pantas dari awal perilaku yang tidak terdefinisi.

Windows MSVC 2019 16.5.4, / Od / RTCs


Opsi / RTCs memungkinkan pengecekan error run-time frame stack. Opsi ini hanya tersedia di unit debug. Pertimbangkan kode yang dibongkar pada bagian main ():

gambar

Sebelum memanggil bad (int) (baris 4), argumen disiapkan - nilai variabel rd disalin ke register edx (baris 2), dan alamat efektif dari beberapa variabel lokal yang terletak di alamat dimasukkan ke dalam register rcx rsp + 28j (baris 3).

Agaknya, rsp + 28 adalah alamat variabel sementara yang menyimpan hasil dari panggilan buruk (int).

Asumsi ini dikonfirmasi oleh baris 19 dan 20 - alamat efektif dari variabel yang sama dimuat ke rcx, setelah itu disebut destructor.

Namun, dalam interval baris 4 - 18, variabel ini tidak diakses, meskipun output dari nilai bidang datanya mengalir.

Seperti yang kita lihat dari daftar MSVC sebelumnya, argumen untuk operator keluaran aliran harus diharapkan dalam register rdx. Register rdx mendapatkan hasil dereferencing alamat yang terletak di rax (baris 9).

Dengan demikian, kode panggilan mengharapkan dari yang buruk (int):

  • mengisi variabel yang alamatnya dilewatkan melalui register rcx (di sini kita melihat RVO beraksi);
  • mengembalikan alamat variabel ini melalui rax register.

Mari kita beralih ke daftar bad (int):

gambar

  • di eax, nilai 0xCCCCCCCC dimasukkan, yang kami lihat di pesan pelanggaran akses (baris 9) (perhatikan bahwa itu hanya 4 byte, sedangkan dalam pesan AccessViolation alamat terdiri dari 8 byte);
  • perintah rep stos disebut, menjalankan 0xC siklus penulisan isi eax ke memori mulai dari alamat rdi (baris 10). Ini adalah 48 byte - persis seperti yang dialokasikan pada tumpukan di baris 6;
  • pada jalur eksekusi yang benar, nilai dari rsp + 40h dimasukkan dalam rax (baris 23, 36);
  • nilai register rcx (melalui mana main () melewati alamat tujuan) didorong ke stack di rsp + 8 (baris 4);
  • rdi didorong ke stack, yang mengurangi rsp sebesar 8 (baris 5);
  • 30h byte dialokasikan pada stack dengan mengurangi rsp (baris 6).

Jadi rsp + 8 di baris 4 dan rsp + 40h di sisa kode adalah nilai yang sama.
Kode ini agak membingungkan tidak menggunakan rbp.

Ada dua kecelakaan dalam pesan Pelanggaran Akses:

  • nol di bagian atas alamat - mungkin ada sampah;
  • alamatnya ternyata tidak sengaja salah.

Rupanya, opsi / RTC mengaktifkan stack overwriting dengan nilai-nilai non-nol tertentu, dan pesan Pelanggaran Akses hanyalah efek samping acak.

Mari kita lihat bagaimana kode dengan opsi / RTC diaktifkan berbeda dari kode tanpa itu.

gambar

Kode untuk bagian main () hanya berbeda di alamat variabel lokal pada stack.

gambar

(untuk kejelasan, saya menempatkan dua versi fungsi (int) buruk di sebelahnya - dengan / RTC dan tanpa)
Tanpa / RTC, instruksi rep stos menghilang dan menyiapkan argumen untuknya di awal fungsi.

Contoh 2a


Sekali lagi, cobalah untuk mengontrol perilaku yang tidak terbatas. Kali ini hanya untuk satu kompiler.

Windows MSVC 2019 16.5.4, / Od / RTCs


Dengan opsi / RTCs, kompiler menyisipkan di awal kode fungsi buruk (int) yang mengisi bagian bawah rax dengan nilai tetap, yang dapat menyebabkan pelanggaran akses.

Untuk mengubah perilaku ini, cukup isi rax dengan beberapa alamat yang valid.
Ini dapat dicapai dengan modifikasi yang sangat sederhana: tambahkan output sesuatu ke std :: cout ke badan (int) yang buruk.

Kode contoh lengkap
#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
Test :: ~ Test ()

operator << mengembalikan tautan untuk streaming, yang diimplementasikan sebagai menempatkan alamat std :: cout di rax. Alamatnya benar, bisa ditinjau ulang. Pelanggaran akses dicegah.

Kesimpulan


Dengan menggunakan contoh paling sederhana, kami dapat:

  • kumpulkan sekitar 10 manifestasi berbeda dari perilaku yang tidak terbatas;
  • pelajari secara detail bagaimana opsi-opsi ini akan dieksekusi.

Semua kompiler menunjukkan kepatuhan yang ketat terhadap Standar - tidak ada contoh apakah perilaku tak terbatas dimulai sebelumnya. Tetapi Anda tidak dapat menolak fantasi untuk mengkompilasi pengembang.

Seringkali, manifestasinya tergantung pada nuansa halus: perlu menambahkan atau menghapus satu baris kode yang tampaknya tidak relevan - dan perilaku program berubah secara signifikan.

Jelas, lebih mudah untuk tidak menulis kode seperti itu daripada menyelesaikan teka-teki nanti.

All Articles