Работа компилятора в картинках. Часть первая
Теперь мы попробуем заинтересовать вас еще больше, показав примеры, как реальные компиляторы «старых лет» преобразуют привычные действия. Вы можете ничего не понимать в машинном коде, поэтому будем комментировать. С другой стороны, наивно думать, что какие-то там программы, пусть даже и компиляторы, способы использовать сотни видов команд, которые поддерживает процессор – это не так. Практически все из них до сих пор не только создают программы в расчете на Intel 286/386, но даже из того набора берется лишь 5-10% типов команд! Основные из них, конечно:
- Передача данных между ячейками (регистры и память) – MOV
- Сравнить данные – CMP
- Обнуление регистра – XOR. Можно было бы MOV регистр, 0, но XOR регистр, регистр занимает меньше памяти.
- Переходы по условию – JNE
- Безусловные переходы – JMP
- Загрузка в стек – PUSH
- Выгрузка из стека – POP
- Автоинкремент и автодекремент регистра – INC/DEC
- Вызов подпрограммы (ближний или дальний) – CALL
Вот, пожалуй, и все. Сам компилятор может использовать больший набор команд, чем генерирует для программы. Давайте начнем с любимого TURBO Pascal 7.1.
Var J: integer; Begin For j:=1 to 4 do writeln(‘Строка’); End.
Из программы даже неспециалист увидит, что программа 4 раза выведет на экран слово «Строка». Как это будет в коде (2208 байтов), лучше смотреть через программу HIEW. Чтобы получить такой же экран, нужно написать команду hiew <имя_программы.exe>, затем по F4 выбрать режим Decode, нажать F8 и F5 (указатель встанет на начало нашей программы). Хотите верьте, хотите нет, но это ВСЯ программа, которую мы написали, остальное – паскалевские настройки, стандартные библиотеки и т.д.
Если посмотреть внимательно, то можно увидеть вызовы типа call 0005:xxxx – это как раз вызов библиотек. Первое число может менять, но только не xxxx, которое является смещением вызова относительно начала библиотеки. И так во всех программах. Соответственно:
0000 – инициализация программы (туда лучше не смотреть, там все сложно); 02CD – тоже какая-то подготовка (наша программа еще не началась); 0670, 05DD, 0291 – идут часто вместе и обозначают вызов writeln; 0116 – завершение программы.
Наша единственная переменная хранится где-то в секции данных, в ячейке со смещением 52, причем видно, что программа к ней обращается как к слову ([w]ord ptr), то есть это либо тип integer, либо word – другого не дано. Если бы мы указали для j тип byte, то обращение бы шло к половинке ячейки – [b]yte ptr.
Команда mov w, [52], 1 откровенно навевает на мысль, что это начальное заполнение ячейки j. В самом деле, если бы хотели описать все действия в виде алгоритма, то придумали бы следующее:
- Записать в j единицу (mov w, [52], 1)
- Перейти к шагу 4. (jmp 72, т.е. безусловный переход на шаг 4)
- Прибавить к j единицу (счетчик работает). (inc [52])
- Вывести на экран строку. (с адреса от 72 до 8D включительно)
- Сравнить j с 4. (cmp w, [52], 4)
- Если меньше, то переход на шаг 3. (jne 6E, т.е. на шаг 3)
- Завершить программу. (с адреса 95 по 9С)
Обратите внимание, что по адресу 8E указано 16E, а мы написали 6E – это ошибка программы Hiew, которая не сумела определить переход назад. Бывает…
Как видите, программа реализована в точности по алгоритму. С ней можно поиграться, заменив в режиме редактора, к примеру, константу 1 или 4 на другие числа – программа будет выводить строку не 4 раза, а сколько скажете. Это называется «патчинг» – замена алгоритма в готовой программе без изменения исходной. Таким приемом часто пользуются хакеры для снятия защиты. В самом деле, мы можем и не вызывать процедуру печати, забив адресное пространство с 72 до 8D однобайтовой командой nop (ничего не делающей), и тогда writeln не будет вызываться вообще. Или вставим в это место совсем другой код, подходящий по размеру (но только не вирусы, они уже надоели!).
Главное, что вы сейчас должны понять, состоит в следующем:
Компилятор всегда работает по одному принципу, использует готовые конструкции. То есть для for переменная:=значение1 to значение2 всегда будет создаваться конструкция:
1: Присвоить переменной значение1 2: Перейти к шагу 4 3: Увеличить переменную на единицу 4: Что-то сделать (не зря же работает цикл?) 5: Сравнить переменную со значением2 6: Если они не равны, то переход на шаг 3
Весь интерес, конечно, представляет блок в шаге 4, где может быть очень много команд. Но принцип ясен. Кстати, как вы знаете, в Бейсиках эта команда имеет дополнительный параметр STEP x, которая определяет шаг итерации в цикле. Для Паскаля решили остановиться на единице, но можно представить, что значение шага хранилось бы в ячейке 54, и команда по адресу 6E выглядела бы иначе: add w, [52], bx, где в bx переписывалось бы значение из ячейки 54. А что делать, если в этом процессоре НЕЛЬЗЯ передавать данные из ячейки в ячейку напрямую – только через регистр? Зато компилятор бы усложнился, ведь пришлось бы ставить внутренний контроль за итерациями! Подумайте:
FOR J:=1 to 10 STEP 3 – сколько раз выполнится цикл? И кто будет следить за этим? А компилятор должен следить: и за тем, чтобы стек не кончился в результате рекурсий, и чтобы значение не вышло за рамки массива, и чтобы не было деления на ноль… Много чего.
Кстати, указанный выше шаблон почти так же работает для циклов repeat … until, while … do. Интересно, как располагают переменные в массиве? А что будет, если по адресу 96 не обнулить регистр ax? Об этом и другом читайте в следующей части. |