Обычно main — это функция. Но всегда ли это так?
29К открытий30К показов
Идея написать эту статью пришла мне в голову, когда одного из моих коллег заставили пройти начальный курс по 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?»
29К открытий30К показов