Обычно main — это функция. Но всегда ли это так?

Идея написать эту статью пришла мне в голову, когда одного из моих коллег заставили пройти начальный курс по CS в моем университете. Мы с ним искали способ написать корректную программу так, чтобы она проходила тесты, но ни один из экзаменующих не мог понять, как и почему она работает. Так я начал вспоминать разные трюки в C, которые видел когда-то в старом коде, и один из них был весьма занимательным. Идея этого трюка пришла мне в голову из-за названия блога «main is usually a function». Я подумал тогда: «А в каких случаях main может не быть функцией?» Давайте это выясним!

Если вы хотите скачать исходный код к этой статье, я выложил его здесь. Обратите внимание, что я писал его под 64-битный Linux, и вам, возможно, придется поправить его под свой платформу.

Я, как и, полагаю, многие разработчики, ищу ответ на вопрос следующим образом. Шаг 1: поиск в Google. Шаг 2: перехожу по каждой релевантной ссылке на первой странице. Если проблема не решена, я изменяю запрос и повторяю все заново. В этот раз мне повезло, и ответ нашелся при первом же поиске на Stackoverflow. В 1984 году короткая программа выиграла IOCCC. main в ней была объявлена так: short main[] = {...}, и это каким-то образом работало! К сожалению, она была написана под абсолютно другую архитектуру и компилятор, и я не смог её собрать, чтобы посмотреть, что она делает, но, судя по тому, что это был просто набор чисел, я мог предположить, что в массиве были байты скомпилированного кода, который просто помещался в память на место main.

Приняв версию, что код этой программы — скомпилированная функция main, представленная в виде массива, давайте посмотрим, сможем ли мы написав маленькую программу и повторить этот трюк.

Отлично! Это сработало! Почти… Итак, наша следующая цель — напечатать что-нибудь на экран. Насколько я помнил тогда ассемблер, в скомпилированном коде есть секция команд и секция данных. При этом в секции команд содержится исполняемый, но не изменяемый код, а в секции данных лежат изменяемые данные, которые нельзя выполнить. В нашем случае, мы можем только заполнить код функции main, поэтому все, что мы положим в секцию данных, будет нам недоступно. Нам нужно найти способ положить строку «Hello world!» в функцию main и сослаться на нее.

Я стал думать, что можно написать за как можно меньшее количество строк. Поскольку я знал, что собираю программу под 64-битный Linux, я мог вызвать системную команду write, которая выведет что-нибудь на экран. Сейчас я, конечно, понимаю, что мог тогда и не использовать ассемблер, но, в то же время, я рад, что получил такой опыт. Начинать со встроенного ассемблера в GCC было непросто, но когда я более-менее привык, дела пошли быстрее.

Сначала было очень трудно. Оказалось, что все, что я мог узнать об ассемблере через поисковик, это старый Intel-овский синтаксис, причем для 32-битной архитектуры. Мне же надо было скомпилировать код под 64-битную систему без каких-либо специальных флагов компилятора. Это значит никаких флагов и опций компилятора, никаких дополнительных шагов линковщика и встроенный в GCC синтаксис AT&T. Бо́льшую часть времени я потратил на поиск информации об ассемблере для 64-битных систем! Возможно, плохо искал. Здесь я пользовался по большей части методом проб и ошибок. Я всего лишь хотел вывести строку «Hello world!» на экран с помощью встроенного ассемблера, почему это так сложно? Для тех, кто хочет узнать, как это сделать, рекомендую взглянуть на эти сайты: Linux syscall list, Intro to Inline Asm, Differences between Intel and AT&T Syntax.

В конце концов, у меня начал получатся более-менее внятный asm-код, который даже работал. Вспомним, моя цель в том, чтобы написать такую main, которая представляет из себя массив с asm-командами, выводящими на экран «Hello World».

Ура! Оно работает! Давайте посмотрим на скомпилированный код в 16-ричном виде, он должен совпадать один в один с ассемблерным листингом, который мы написали. Комментарии справа поясняют, что происходит.

Это на самом деле похоже на работающую main. Теперь давайте сделаем дамп её содержимого в виде строки 16-ричных символов и посмотрим работает ли она. Для этого также можно использовать gdb. Наверняка есть и какой-то более удобный способ, если вы его знаете, можете скинуть в комментарии к оригинальной статье. Когда мы ранее дизассемблировали main, её длина была 49 байт, т.е. мы можем использовать команду dump для сохранения в файл в указанном далее виде.

Теперь у нас есть дамп, и мы можем сконвертировать его в обычные 10-ричные числа. Самый простой способ для этого, который я знаю — это использовать python. В python 2.6 и 2.7 можно просто использовать вот такую команду, чтобы получить подходящий в нашем случае массив целых чисел.

Я полагаю, если бы мои знания bash и unix были на более высоком уровне, я бы нашёл способ сделать это несколько проще, но гугл в ответ на что-нибудь вроде «hex dump of compiled function» выдаёт несколько вопросов про то, как напечатать 16-ричный дамп на разных языках. Тем не менее, у нас теперь есть массив разделённых запятыми чисел, представляющих нашу функцию. Попробуем записать его в новый файл и проверим, сработает ли трюк. Вот что получилось.

Segmentation fault! Что же я сделал не так? Настало время снова запустить gdb и попробовать посмотреть, в чём ошибка. Т.к. main теперь уже не функция, мы не можем просто использовать break main, чтобы поставить точку останова. Вместо этого можно сделать break _start, и мы получим останов на функции, которая передаёт управление на точку входа в libc (которая, в свою очередь, вызывает main) и сможем увидеть адрес, который передаётся в __libc_start_main.

Я провел эксперимент, и понял, что в %rdi помещается адрес main, однако в этот раз что-то было не так. Ну конечно! Компилятор поместил main в секцию данных! Как я уже отмечал, в секции данных хранятся изменяемые данные, но их нельзя выполнить, а в секции с кодом хранятся выполнимые инструкции, которые нельзя изменить. Код пытался выполнить инструкции из секции данных, что и вызывало segfault. И как мне было объяснить компилятору, что моя «main» должна располагаться в разделе с кодом? Поиск ничего не дал, и я был уверен, что это конец. Пора было сдаваться и заканчивать приключение.

Однако эта проблема не давала мне уснуть всю ночь. Я продолжил искать пока не нашел очевидное и простое решение на Stackoverflow, но, к сожалению, потерял на него ссылку. Все, что надо было сделать — объявить main как const. Я поменял объявление на const char main[] = { и он расположился в правильной секции, и я заново попробовал его скомпилировать.

А! Что он делает сейчас?! Время снова запускать gdb и понять, что происходит

Глядя на код, мы видим, что адрес main (в ASM обозначается _start) в инструкциях на моей машине выглядит так: mov $0x4005a0,%rdi. Мы можем использовать это, чтобы поставить точку останова на main, выполняя break *0x4005a0 и затем продолжая выполнения с помощью c.

Я вырезал часть кода, которая была не важна. Если вы не заметили причину ошибки, то она была в том, что адрес для вывода (0x400510) не совпадал с адресом хранения строки «Hello world!\n» (0x4005c3)! На самом деле он до сих пор указывает на вычисленный адрес в исходном исполняемом файле и не использует относительную адресацию для вывода. Это значит, что нам надо изменить ассемблерный код, чтобы загрузить адрес строки, относительный к текущему адресу. В данных условиях это довольно сложно выполнить в 32-битном коде, но к счастью мы используем 64-битный asm, так что можно использовать инструкцию lea для упрощения задачи.

Измененный код прокомментирован, так что вы можете посмотреть его. Компилируем код и проверяем, что он работает.

А сейчас мы можем снова использовать способы, описанные выше, чтобы извлечь hex-значения в виде целочисленного массива. Но в то же время, я хочу сделать это более скрытно и запутанно, используя все 4 байта, которые дают мне int’ы. Этого можно добиться, выводя информацию в gdb как int вместо выгрузки hex в файл и после копируя его в программу.

Я выбрал число 13, так как размер main равен 49 байтам и результат деления 49 на 4 округляется до 13 для обеспечения надежности. Так как мы выходим из функции раньше, это ничего не меняет. Теперь, все, что нам необходимо сделать, — скопировать и вставить этот код в наш compiled_array_main.c и запустить его.

Всё это время мы игнорируем предупреждение о том, что main не является функцией.

Подозреваю, что все, что сделает комиссия, когда мой коллега покажет такой код на экзамене, — отругает за плохой стиль.

Перевод статьи «Main is usually a function. So then when is it not?»