Найти в Дзене

С/С++. О жизни до main, или как не умереть до рождения

Многие, увы слишком многие, программисты на вопрос "С чего начинается выполнение программы на C или C++?" издают обиженный вопль. "Дескать что за детские вопросы? Уже в старшей группе детского сада все знают. что точкой входа является функция main !!!" И ответ это в корне неверный. Точнее, очень часто путают понятия точки входа в программу и начала выполнения. И из-за этого периодически попадают в забавные ситуации, в которых даже отладчик не всегда может помочь. Вот и посмотрим. что же у нас в программе происходит до вызова функции main. Надеюсь, что не открою Америку, сказав, что реальное выполнение любой программы начинается с кода загрузчика. Этот код чаще всего писан на Assembler и сильно зависит от операционной системы, компилятора, и массы других параметров. Но в любом случае - его задача провести предварительную инициализацию и настройку программы. И только когда эта работа выполнена, загрузчик из своего кода вызывает функцию main. То есть, до вызова функции main в программе
Куда пропал вызов main?! (Фотография сделана автором, все права сохранены)
Куда пропал вызов main?! (Фотография сделана автором, все права сохранены)

Многие, увы слишком многие, программисты на вопрос "С чего начинается выполнение программы на C или C++?" издают обиженный вопль. "Дескать что за детские вопросы? Уже в старшей группе детского сада все знают. что точкой входа является функция main !!!" И ответ это в корне неверный. Точнее, очень часто путают понятия точки входа в программу и начала выполнения. И из-за этого периодически попадают в забавные ситуации, в которых даже отладчик не всегда может помочь. Вот и посмотрим. что же у нас в программе происходит до вызова функции main.

Надеюсь, что не открою Америку, сказав, что реальное выполнение любой программы начинается с кода загрузчика. Этот код чаще всего писан на Assembler и сильно зависит от операционной системы, компилятора, и массы других параметров. Но в любом случае - его задача провести предварительную инициализацию и настройку программы. И только когда эта работа выполнена, загрузчик из своего кода вызывает функцию main. То есть, до вызова функции main в программе происходит много чего интересного. И если на каком-то предварительном этапе возникнет ошибка, то вызова функции main мы не получим вовсе. И будем долго ломать голову над тем, что же пошло не так... Не вижу в данной статье смысла глубоко лезть в загрузчик. Ограничимся только тем высокоуровневым кодом, который написали мы сами. Есть ли возможность написанным самим программистом кодом убить загрузчик так, чтобы он никогда не смог дойти до вызова функции main? И на этот вопрос существует только один ответ - "Запросто!". Неучет этой возможности и приводит к очень интересным эффектам. В данной статье мы рассмотрим самый распространенный случай - инициализацию глобальных переменных. Занимается этой операцией загрузчик, и естественно, она должна быть выполнена ДО вызова функции main. Если мы заносим в переменные какие-либо константные значения. особых проблем не возникает. Но, при инициализации переменных вполне допустимо и выполнение кода. Естественно, в таком случае написанный нами код будет выполнен ДО вызова main! А любом коде могут быть ошибки. Вот так мы и можем убить программу до ее официального первого вздоха.

Первый, предельно грубый пример:

int g_dVar = 0;
int g_Test = 10 / g_dVar;
int main()
{
std::cout << "Hello World!\n";
}

Как видно, инициализация глабальной переменной g_Test требует выполнения определенного кода. Как видно, в данном случае попытка ее инициализации приведет к делению на 0. И программа благополучно скончается, не дожив до функции main. Данный пример написан на С++, но и для чистого C картина будет аналогичной. В реальной жизни цепочка инициализации бывает значительно сложнее. Соответственно, намного сложнее поймать ошибки такого типа. Чаще всего это имеет место быть не с математическими операциями, как в примере, а в случае некорректной работы с указателями.

Посмотрим на пример номер 2:

int foo(void);
int g_dVar = foo();
int g_Test = 10 / g_dVar;
int foo(void)
{
return 0;
}
int main()
{
std::cout << "Hello World!\n";
}

По первому впечатлению он похож на первый, однако, тут есть одно существенное отличие. Оно состоит в том, что при инициализации глобальной переменной вызывается уже не одна строчка кода, а целая наша собственная функция. Причем, если пример специально упрощен, то в реальной жизни необходимо учитывать следующее:

  1. Функция foo естественно будет заметно сложнее, чем в приведенном примере.
  2. Такая функция может возвращать результат, проверяя значительное количество условий, в том числе - определяемых динамически И вот тут и срабатывает один из стереотипов. Если эта функция реально может быть вызвана из разных мест программы, ее автор может на автомате предполагать, что все условия уже определены. так как функция main уже стартовала. А при инициализации глобальных переменных это условие не выполнено. Отсюда - высокая вероятность некорректной работы такой функции.

Конечно, глобальные переменные есть зло. И в программе на C++ обычно есть возможность полностью избежать их использования. А вот в программе на чистом C обычно такой возможности нет.

В приведенных примерах допущена максимально грубая ошибка. В данном случае возникнет неперхватываемое исключение и при запуске под отладчиком мы это скорее всего увидим (исключение - при написании кода для встраиваемых систем на базе микроконтроллеров).

А теперь давайте попробуем на секунду представить, что вызываемая функция может угодить в бесконечный цикл... Или зависнет на ожидании какого-либо события. Вот тогда-то у нас и станет все совсем интересно. Никаких исключений не возникает, все с виду чинно и благородно. Только вот до функции main мы так никогда и не дойдем. Для многих не слишком опытных программистов это ситуация шоковая, так как ошибка потенциально могла возникнуть и непосредственно в коде загрузчика. Это часто бывает, когда пишется программа для микроконтроллера. Малейшая ошибка в выборе загрузчика для конкретной версии МК может привести к такому же результату.

Ну и в качестве вишенки на торте - никто не требует, чтобы функции foo не создавала дополнительных потоков. А каждый поток, как известно имеет свою функцию, в которой тоже что-то может пойти не так.

В общем - мы можем незаметно для себя вызвать огромный пласт кода, который будет выполняться до входа в функцию main. Теоретически возможно выполнить всю программу, так и не зайдя эту функцию.

Данные примеры показывают, что есть огромное различие между понятиями "точка входа" и "начало выполнения" программы. Точка входа означает всего-навсего адрес функции, которую вызовет загрузчик после завершения всех подготовительных операций. А вот написанный нами код может начать выполняться на значительно более ранних этапах работы загрузчика. В приведенных примерах это этап инициализации глобальных переменных.

В данной статье мы кратко рассмотрели проблемы, возникающие при процедурном подходе. Однако, не стоит думать, что оъектно-ориентированный подход не имеет своих собственных ловушек. В следующей статье мы рассмотрим как умереть молодым не дожив до вызова main в C++.

Жду ваших комментариев