Компилятор домашнего приготовления (ч.2)

Автор: Виталий Белик

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

Краткий экскурс…

В прошлый раз нам удалось поковыряться в кишках Win32 программы. Даже удалось написать простенький «плагиатор» генерирующий экзешник со строкой, которую мы заранее определили. Можно ли остаться на этом этапе написания ядра компилятора и перейти непосредственно к разработке своего языка? Конечно можно – DOS эмуляторы все еще живы, даже если не в виде NTVDM, который, скорее всего, скоро будет исключен из стандартного комплекта Windows, то в виде популярного DosBox, более приспособленного под работу с DOS программами. Одно только маленькое «но», которое врядли подсластит пилюлю работы с DOS – под него нет удобных инструментов отладки (разве что кроме Иды, которая IDA). Популярный Turbo Debugger вполне мог бы подойти для отладки, и собственно это, пожалуй, самое лучшее средство отлаживать DOS программы, которое было на вооружении программистов до появления Windows.

Теперь же, мы, избалованные красивым графическим интерфейсом, смотрим на него совершенно другими глазами. Я уверен, что максимум 1 из 100 пользователей Windows с уверенностью и без тени сомнения может отложить мышку в сторону, и станет все делать в консоли только лишь одной клавиатурой. Отвыкли мы от старого стиля работы с вычислительной техникой. Раньше ведь и мышка–то не везде поддерживалась, а сейчас некоторые ОС могут и не запустится без мышки. Мне удалось застать времена, когда в ходу были DOS 5 на процессорах 80286(386), а 40–мегабайтные жесткие диски приходилось сжимать архиватором типа Stacker. Тогда средства отладки Debug, Soft Ice, Turbo Debugger казались пределом совершенства и удобства (кто помнит – сравнит Turbo Pascal 5 и Turbo Pascal 7 – помните ведь, что в первом мышка то не работала, зато второй так жутко тормозил 286–е, что на него не сразу перешли :) ). Сейчас все эти инструменты ушли на почетную пенсию. И использовать их не то что не выгодно, но даже опасно – операционная система нашего времени может запротестовать, и накрылась наша программа медным тазиком.

Так что и нам нужно будет сделать еще один шаг вперед*. Чтоб писать программы под еще популярную, но уже уступающую свои позиции платформу Win32.

* Комментарий автора.

То что описано в статье всего лишь начало.

Я не хочу читать книги о компиляторах по одной простой причине — я хочу

самостоятельно прийти к тому к чему (возможно) пришли авторы тех книг,

именно поэтому я сделал акцент «Что было бы если бы компилятор писал

студент, которому совершенно не интересны всякие умные авторы умных книг».

Что получится если иметь фантазию, и уметь искать ответы на свои вопросы.

боже упаси мне конкурировать с кем-то, и боже упаси мне идти по чьей-то

протоптанной дорожке  В статье (этой и последующих) я хочу показать что

любой даже тот кто не захочет читать много скучной теории сможет без

проблем добиться желаемого своим оригинальным путем

Я не ссылаюсь и буду стараться избегать ссылаться на кого бы то ни было

из ветеранов этой области. Для них есть Википедия, и прочие порталы.

В конце концов литературы по этому делу много. Главное — это не бояться

пробовать. Подумаешь не по правилам. Подумаешь чего-то не хватает.

Подумаешь непривычно… Ерунда и зависть.

Run on the run

Итак, достаточно лирики. Начнем, пожалуй. С чего начать то? Как обычно со стратегии и планирования. Многие исполняемые программы под Win32 хранятся в файлах, описанные PE заголовком, в котором содержится информация для загрузчика, как, собственно, содержимое файла разместить в памяти, что считать кодом, что считать данными, сколько отвести памяти и прочее. В прошлой статье Оля (Olly Debugger) показала нам этого зверя изнутри. Теперь же наша задача – приготовить ядро нашего компилятора, способное формировать эту информацию.

Поверив Ольке, мы разделим ядро на блоки. Каждый блок (класс) будет отвечать за свою часть формирования файла, а главный класс будет, управляя этими классами получать скомпилированные части экзешника, которые поступят непосредственно в файл, так сказать открытым текстом.

Вспомним, из каких же частей** состоит экзешник win32:

– MZ заголовок – 64 байта минимум

– Какой–то обработчик события, если программа не запущена под Windows (ну кому он нужен, а? предлагаю обойтись без него)

– Непосредственно PE заголовок, имеющий 246 байт в общем случае

– Массив описания секций. Каждая секция размером 32 байта.

** Комментарий автора.

Пока этого нам хватит за глаза. Не будем встрявать в работу с импортом–экспортом.

Потом когда пойдет работа с вводом–выводом, дополнительно разберемся с Win API функциями.

Отсюда вывод: Нам понадобится в общем случае 4–5 классов. Давайте оговорим их.

The Favels

Пойдем по порядку. Помните, «Оля» показывала нам РЕ заголовок? 

Вспомнили? Вот так и пойдем описывать класс, отвечающий за MZ часть заголовка:

Листинг 1

	TAlisaMZHeader=class // Класс описатель MZ заголовка
	  private
	     DOS_PartPag, // Размер всего MZ заголовка до начала PE
     DOS_PageCnt,
	     DOS_ReloCnt,
     DOS_HdrSize,
	     DOS_MinMem,
	     DOS_MaxMem,
	     DOS_ReloSS,
	     DOS_ExeSP,
	     DOS_ChkSum,
	     DOS_ExeIP,
	     DOS_ReloCS,
	     DOS_TablOff,
	     DOS_Overlay:word;
	     OffsetToPE:DWord; // Смещение на начало РЕ заголовка
	  public
	   Constructor Create;
	   Function Compile:String; // Компиляция
	 end;

 

Надеюсь, не забыли***, что мы пишем на Delphi? Хоть я и перечислил сюда все поля, а можно было этого не делать. Но, если мы все не перечислим, то впоследствии можем сильно запутаться. Смотрите (см. листинг 1), из всего этого нам важны два поля: DOS_PartPag и OffsetToPE:DWord. В принципе, это будут константы, т.е. я имею ввиду, что мы забьем в эти поля значения при создании экземпляра класса.

Функция Compile будет компилировать эти поля в поток байт. Давайте заранее оговоримся – в качестве хранилища компиляций будут переменные типа String. Поскольку в Делфи этот тип весьма развит (с ним еще с паскаля было очень удобно обрабатывать массивы байтов), пускай, и понимаются они в виде символов. Так что в качестве потока будет String во всей Алисе.

*** Комментарий автора.

Пока не забыл, хочу напомнить, что наш компилятор должен компилировать

значения этих полей не так как мы определим их, а в обратном порядке. Т.е. нам

придется вносить в хранилище компиляции их с конца.

Поэтому приготовим пару функций (см. листинг 2), которые помогут нам переворачивать байты числа, превращая их в строку:

Листинг 2

	// Функция переворачивающая 2–х байтовое слово
	Function WordToStr(w:word):String;
	 begin
	  Result:=chr(lo(w))+chr(hi(w));
	 end;
	// Функция переворачивающая 4–х байтное значение
	 Function DWordToStr(w:dword):String;
	 var l,h:word; s:string[4];
	 begin
	   asm
	    mov eax,[w]
	    mov [l],ax
	    shr eax,16
	    mov [h],ax
	   end;
	   Result:=WordToStr(l)+WordToStr(h);
	 end;

 

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

Листинг 3

	constructor TAlisaMZHeader.Create;
	var i:integer;
	begin
	 DOS_PartPag :=$80;
	 DOS_PageCnt :=$1;
	 DOS_ReloCnt :=$0;
	 DOS_HdrSize :=$4;
	 DOS_MinMem :=$10;
	 DOS_MaxMem :=$FFFF;
	 DOS_ReloSS :=$0;
	 DOS_ExeSP :=$140;
	 DOS_ChkSum :=$0;
	 DOS_ExeIP :=$0;
	 DOS_ReloCS :=$0;
	 DOS_TablOff :=$40;
	 DOS_Overlay :=$0;
	 OffsetToPE:=$40;
	end;

 

Это наш конструктор. Здесь мы, как и оговаривали, заведем константно значения в поля. Опять таки, не обязательно было так досконально их описывать, но, дабы не запутаться, все же стоило это сделать. Следующим будет реализация метода компиляции заголовка (см. листинг 4):

Листинг 4

	function TAlisaMZHeader.Compile: String;
	var i:integer;
	begin
	 Result:='MZ';
	 Result:=Result+WordToStr(DOS_PartPag);
 Result:=Result+WordToStr(DOS_PageCnt);
	 Result:=Result+WordToStr(DOS_ReloCnt);
	 Result:=Result+WordToStr(DOS_HdrSize);
	 Result:=Result+WordToStr(DOS_MinMem);
	 Result:=Result+WordToStr(DOS_MaxMem);
	 Result:=Result+WordToStr(DOS_ReloSS);
	 Result:=Result+WordToStr(DOS_ExeSP);
	 Result:=Result+WordToStr(DOS_ChkSum);
	 Result:=Result+WordToStr(DOS_ExeIP);
	 Result:=Result+WordToStr(DOS_ReloCS);
	 Result:=Result+WordToStr(DOS_TablOff);
	 Result:=Result+WordToStr(DOS_Overlay);
	 for i:=1 to 32 do Result:=Result+#0;
	 Result:=Result+WordToStr(OffsetToPE);
	 for i:=length(Result) to OffsetToPE–1 do Result:=Result+#0;
	end;

 

Тут, я думаю, тоже все понятно: каждое поле по порядку мы приводим к строке, и выкатываем как поток байт в результат метода. Здесь разве что непонятным моментом могут послужить циклы.

for i:=1 to 32 do Result:=Result+#0; дополняет MZ заголовок 32–я байтами до поля описания смещения на РЕ, а for i:=length(Result) to OffsetToPE–1 do Result:=Result+#0; призван заполнить пустоту в том месте, где у порядочных компиляторов должен быть обработчик события запуска под другую операционку. Мы договорились, что его не будет, так что в принципе второй цикл можно было не писать, но он не помешает. Соответственно обратите внимание – я определил OffsetToPE так, чтоб смещение заголовка РЕ было непосредственно под MZ.

Так–с. С MZ разобрались. Перейдем к следующему – PE заголовку. Здесь ситуация чуть сложнее. Микрософт постарался и понавыдумывал кучу параметров, в которых легко запутаться, особенно при расчете их значений, так что описание класса будет побольше (см. листинг 5):

Листинг 5

	TAlisaPEHeader=class
	  private
	    FHeader: String;
	    //******************************************
	       Machine:word; // Семейство процессоров
	       NumberOfSections:word; // Кол–во секций. оно считается
	       TimeDateStamp:dword; // Время компиляции.
	       PointerToSymbolTable:dword;
	       NumberOfSymbols:dword;
	       SizeOfOptionalHeader:word; // Размер опционального заголовка
	       Characteristics:word; // Тип загружаемого файла
	      MagicNumber:word;
	       MajorLinkerVersion:byte;
	       MinorLinkerVersion:byte;
	       SizeOfCode:dword;
	       SizeOfInitializedData:dword;
	       SizeOfUninitializedData:dword;
	       AddressOfEntryPoint:dword; // Адрес точки начала кода
	       BaseOfCode:dword;
	       BaseOfData:dword;
	      ImageBase:dword; // Базовый адрес
	       SectionAlignment:dword; // Размер секций в памяти
	       FileAlignment:dword; // Размер секций в файле.
	       MajorOSVersion:word;
	       MinorOSVersion:word;
	       MajorImageVersion:word;
	       MinorImageVersion:word;
	       MajorSubsystemVersion:word;
	       MinorSubsystemVersion:word;
	       Reserved1:dword;
	       { Размер всего образа, размещаемого в памяти
	         Получается что это размер PE+кол–во секций*Размер секций в памяти.
	         Учитывая что PE тоже выравнивается до размера секций в памяти.
	         он считается такой же равноправной секцией, только секцией
	         для описания всех остальных секций, такой себе Interface
	       }
	       SizeOfImage:dword;
	       SizeOfHeaders:dword; //Общий размер всех заголовков
	      {Контрольная сумма. Вообще ходят неоднозначные слухи о ее надобности.
	Некоторые HEX редакторы в ней нуждаются, но для запуска скомпилированной
	программы ее никто не проверяет. Мы не будем ее рассчитывать, но в принципе
	на будущее необходимо знать, что это за поле.}
	CheckSum:dword;
	Subsystem:word; // Тип приложения, консолька, оконка, или ДЛЛ
	DLLCharacteristics:word;
	SizeOfStackReserve:dword; // память требуемая для стека
	// объем памяти отводимой в стеке немедленно после загрузки
	SizeOfStackCommit:dword;
	SizeOfHeapReserve:dword; // максимальный возможный размер кучи
	SizeOfHeapCommit:dword;
	LoaderFlags:dword;
	NumberOfRvaAndSizes:dword;
	Export_Table_address:dword;
	Export_Table_size:dword;
	Import_Table_address,
	Import_Table_size,
	Resource_Table_address,
	Resource_Table_size,
	Exception_Table_address,
	Exception_Table_size,
	Certificate_File_pointer,
	Certificate_Table_size,
	Relocation_Table_address,
	Relocation_Table_size,
	Debug_Data_address,
	Debug_Data_size,
	Architecture_Data_address,
	Architecture_Data_size,
	Global_Ptr_address,
	Must_be_0,
	TLS_Table_address,
	TLS_Table_size,
	Load_Config_Table_address,
	Load_Config_Table_size,
	Bound_Import_Table_address,
	Bound_Import_Table_size,
	Import_Address_Table_address,
	Import_Address_Table_size,
	Delay_Import_Descriptor_address,
	Delay_Import_Descriptor_size,
	COM_Runtime_Header_address,
	Import_Address_Table_size2,
	Reserved2,
	Reserved3:dword;
	//******************************************
	public
	Function Header:String;
	Constructor Create;
	end;

 

Из всей этой «лесопосадки» нам понадобятся только несколько полей (я к ним в листинге 5 коментарии приписал). Все остальные, поверив Оле, проинициализируем константными значениями. Здесь же в классе присутствует функция Header, которая сформирует выходную строку, содержащую скомпилированный РЕ заголовок. Посмотрим на листинг 6, в котором будет описана инициализация этих полей:

Листинг 6

	constructor TAlisaPEHeader.Create;
	begin
	//******************************************
	      Machine :=$014C;// IMAGE_FILE_MACHINE_I386
	       //NumberOfSections :=$2;
	       TimeDateStamp :=$0;
	       PointerToSymbolTable :=$0;
	       NumberOfSymbols :=$0;
	       SizeOfOptionalHeader :=$E0;
	       Characteristics :=$010E;
	      MagicNumber :=$010B;
	       MajorLinkerVersion :=$0;
	       MinorLinkerVersion :=$0;
	       SizeOfCode :=$0;
	       SizeOfInitializedData :=$0;
	       SizeOfUninitializedData :=$0;
	       //AddressOfEntryPoint :=$0;
	       BaseOfCode :=$0;
	       BaseOfData :=$0;
	      ImageBase :=$400000;
	       //SectionAlignment :=$0;
	       //FileAlignment :=$200
	       MajorOSVersion :=$1;
	       MinorOSVersion :=$0;
	       MajorImageVersion :=$0;
	       MinorImageVersion :=$0;
	       MajorSubsystemVersion :=$3;
	       MinorSubsystemVersion :=$A;
	       Reserved1:=0;
	       //SizeOfImage :=$3000;
	       SizeOfHeaders :=$200;
	       //CheckSum :=$ 7FAB
	       Subsystem :=$3;// IMAGE_SUBSYSTEM_WINDOWS_CUI
	       DLLCharacteristics :=$0;
	       SizeOfStackReserve :=$1000;
	       SizeOfStackCommit :=$1000;
	       SizeOfHeapReserve :=$10000;
	       SizeOfHeapCommit :=$0;
	       LoaderFlags :=$0;
	       NumberOfRvaAndSizes :=$10;
		        Export_Table_address:=0;
	        Export_Table_size:=0;
	        Import_Table_address:=0;
	        Import_Table_size:=0;
	        Resource_Table_address:=0;
	        Resource_Table_size:=0;
	        Exception_Table_address:=0;
	        Exception_Table_size:=0;
	        Certificate_File_pointer:=0;
	        Certificate_Table_size:=0;
	        Relocation_Table_address:=0;
	        Relocation_Table_size:=0;
	        Debug_Data_address:=0;
	        Debug_Data_size:=0;
	        Architecture_Data_address:=0;
	        Architecture_Data_size:=0;
	        Global_Ptr_address:=0;
	        Must_be_0:=0;
	        TLS_Table_address:=0;
	        TLS_Table_size:=0;
	        Load_Config_Table_address:=0;
	        Load_Config_Table_size:=0;
	        Bound_Import_Table_address:=0;
	        Bound_Import_Table_size:=0;
	        Import_Address_Table_address:=0;
	        Import_Address_Table_size:=0;
	        Delay_Import_Descriptor_address:=0;
	        Delay_Import_Descriptor_size:=0;
	        COM_Runtime_Header_address:=0;
	        Import_Address_Table_size:=0;
	       Reserved2:=0;
	       Reserved3:=0;
	 //******************************************
	end;

 

Обратите внимание, я закомментировал те поля, значения которых придется рассчитывать. И не забудем описать реализацию компиляции этого класса в выходную строку (см. листинг 7):

Листинг 7

	function TAlisaPEHeader.Header: String;
	begin
	 Result:='PE'#0#0;
	 Result:=Result+ WordToStr(Machine);
	 Result:=Result+ WordToStr(NumberOfSections);
	 Result:=Result+ DWordToStr(TimeDateStamp);
	 Result:=Result+ DWordToStr(PointerToSymbolTable);
	 Result:=Result+ DWordToStr(NumberOfSymbols);
	 Result:=Result+ WordToStr(SizeOfOptionalHeader);
	 Result:=Result+ WordToStr(Characteristics);
	 Result:=Result+ WordToStr(MagicNumber);
	 Result:=Result+ chr(MajorLinkerVersion);
	 Result:=Result+ chr(MinorLinkerVersion);
	 Result:=Result+ DWordToStr(SizeOfCode);
	 Result:=Result+ DWordToStr(SizeOfInitializedData);
	 Result:=Result+ DWordToStr(SizeOfUninitializedData);
	 Result:=Result+ DWordToStr(AddressOfEntryPoint);
	 Result:=Result+ DWordToStr(BaseOfCode);
	 Result:=Result+ DWordToStr(BaseOfData);
	 Result:=Result+DWordToStr(ImageBase);
	 Result:=Result+DWordToStr(SectionAlignment);
	 Result:=Result+DWordToStr(FileAlignment);
	 Result:=Result+ WordToStr(MajorOSVersion);
	 Result:=Result+ WordToStr(MinorOSVersion);
	 Result:=Result+ WordToStr(MajorImageVersion);
	 Result:=Result+ WordToStr(MinorImageVersion);
	 Result:=Result+ WordToStr(MajorSubsystemVersion);
	 Result:=Result+ WordToStr(MinorSubsystemVersion);
	 Result:=Result+DWordToStr(Reserved1);
	 Result:=Result+DWordToStr(SizeOfImage);
	 Result:=Result+DWordToStr(SizeOfHeaders);
	 Result:=Result+DWordToStr(CheckSum);
	 Result:=Result+ WordToStr(Subsystem);
	 Result:=Result+ WordToStr(DLLCharacteristics);
	 Result:=Result+DWordToStr(SizeOfStackReserve);
	 Result:=Result+DWordToStr(SizeOfStackCommit);
	 Result:=Result+DWordToStr(SizeOfHeapReserve);
	 Result:=Result+DWordToStr(SizeOfHeapCommit);
	 Result:=Result+DWordToStr(LoaderFlags);
	 Result:=Result+DWordToStr(NumberOfRvaAndSizes);
	 Result:=Result+DWordToStr(Export_Table_address);
	 Result:=Result+DWordToStr(Export_Table_size);
	 Result:=Result+DWordToStr(Import_Table_address);
	 Result:=Result+DWordToStr(Import_Table_size);
	 Result:=Result+DWordToStr(Resource_Table_address);
	 Result:=Result+DWordToStr(Resource_Table_size);
	 Result:=Result+DWordToStr(Exception_Table_address);
	 Result:=Result+DWordToStr(Exception_Table_size);
	 Result:=Result+DWordToStr(Certificate_File_pointer);
	 Result:=Result+DWordToStr(Certificate_Table_size);
	 Result:=Result+DWordToStr(Relocation_Table_address);
	 Result:=Result+DWordToStr(Relocation_Table_size);
	 Result:=Result+DWordToStr(Debug_Data_address);
	 Result:=Result+DWordToStr(Debug_Data_size);
	 Result:=Result+DWordToStr(Architecture_Data_address);
	 Result:=Result+DWordToStr(Architecture_Data_size);
	 Result:=Result+DWordToStr(Global_Ptr_address);
	 Result:=Result+DWordToStr(Must_be_0);
	 Result:=Result+DWordToStr(TLS_Table_address);
	 Result:=Result+DWordToStr(TLS_Table_size);
	 Result:=Result+DWordToStr(Load_Config_Table_address);
	 Result:=Result+DWordToStr(Load_Config_Table_size);
	 Result:=Result+DWordToStr(Bound_Import_Table_address);
	 Result:=Result+DWordToStr(Bound_Import_Table_size);
	 Result:=Result+DWordToStr(Import_Address_Table_address);
	 Result:=Result+DWordToStr(Import_Address_Table_size);
	 Result:=Result+DWordToStr(Delay_Import_Descriptor_address);
	 Result:=Result+DWordToStr(Delay_Import_Descriptor_size);
	 Result:=Result+DWordToStr(COM_Runtime_Header_address);
	 Result:=Result+DWordToStr(Import_Address_Table_size2);
	 Result:=Result+DWordToStr(Reserved2);
	 Result:=Result+DWordToStr(Reserved3);
	end;

 

Здоровый код, не так ли? Мне лично неприятно иметь дело с такой щепетильностью. Будь программизм Микрософта продуманнее, они могли бы все эти поля сделать одного размера, тогда и нам было бы попроще – работа с массивом однородных элементов все таки не требует большой писанины кода. Ну, да ладно. Скажу по секрету, это описание будет единственным огромным, далее рутинного кода будет не так много.

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

Листинг 8

	TAlisaSection=class // Класс, описывающий секции
	  private
	    Name:string[8]; // (0h) Имя секции
	{ (8h) Размер выделяемой под секцию памяти. Фактически это размер данных, которые компилируются.
	Если речь идет о коде, то это размер чистого кода в байтах без выравнивания до размера секции}
	VirtualSize,
	// (0Ch) Адреc, с которого начнется секция относительно базы
	VirtualAddress,
	SizeOfRawData, // (10h) Размер секции в файле.
	// (14h) Смешение в файле, с которого начинается секция
	PointerToRawData,
	PointerToRelocations,
	PointerToLineNumbers:dword;
	NumberOfRelocations,
	NumberOfLineNumbers:word;
	Characteristics:dword; // Доступы секции
	public
	_Data:String; // Это поле для чистых данных или кода
	// Функция будет возвращать скомпилированное описание заголовка секции
	Function Header:String;
	end;

 

Здесь, как видим, все попроще. Тоже описываются все поля, чтоб не запутаться, но только некоторые для нас будут важны – те которым сопутствуют комментарии. Напомню их:

– Поле имени. Важно только для программиста, чтобы удобно было различать и группировать содержимое программы. Фактически нам понадобится только две–три секции. Главная секция кода (для точности она будет идти всегда первая) и одна секция данных. Вполне возможно, что потом понадобится выделить еще одну секцию для описания функций, но время покажет, если функции небольшие, их вполне можно будет оставить вместе с главным кодом, не выделяя лишней секции (вообще можно натолкать все в одну секцию, но выглядеть это будет некрасиво. Неудобно для анализирования).

– VirtualSize – размер выделяемой под секцию памяти. Получается, что это размер чистого кода, столько байт сколько накомпилированно в опкодах или, если речь идет о секции данных, то столько инициализированных байт, сколько мы впихнем. Если, предположим, программа будет состоять из двух операторов Mov Eax, некое значение, то это поле будет содержать цифру 10. Потому что размер этой операции 5 байт (учитывая, что оперируем переменной DWord) * 2 ибо их два. А поскольку у нас все содержимое секции будет хранится в виде строки, высчитать это поле труда не составит – функцию Length в Делфи еще никто не отменял.

– VirtualAddress. Адрес с которого будет начинаться секция. Первая наша секция будет начинаться с 1000h – это будет секция кода, следующие секции будут соответственно 2000h, 3000h и т.д. Это не займет много места как показывает практика, для двух секций (код и данные) хватит 2 кбайт.

**** Комментарий автора.

Кстати, хотите прикол? Я до сегодняшнего дня не сталкивался с программами менее мегабайта,

редко писал на Ассемблере, и еще реже обращал внимание на файлы, которые потом компилировались.

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

Помните, в предыдущей статье мы компилировали в Ассемблере программу–пустышку, чтоб принести ее в жертву

во имя Ее Величества Науки? Так вот давайте посмотрим на свойства, которые нам покажет Windows на рисунке 1:

Ничего не смущает? Откуда там аж 4 кбайта если мы заказывали всего две секции + заголовок, Это что за особенности NTFS? Кстати, пожалуй, нужно взять на заметку, что секцию можно описать таким образом, что ее реализация отсутствует в файле. Это так называемая секция неинициализированных данных. Вот смотрите, на рисунке 3 очень характерно показано в шаблоне для MASM из пакета MASM Builder:

Видите секцию «.data?»? Тело этой секции в файл не компилируется, но ее описание в РЕ заголовке присутствует, говоря загрузчику «Эй, приятель, зарезервируй мне в памяти секцию. Не ищи ее в файле, она нужна будет только для работы, и не содержит начальных значений». Загрузчик послушно кивает (чем он там кивать умеет?) и резервирует в памяти местечко для пикничка на обочине, не выискивая в файле, чем бы эту полянку наполнить. И даже в этом случае компилятор инициализирует VirtualSize для неинициализированных секций. Ну компилятору ассемблера хорошо, он заранее знает какого размера будут переменные, даже не инициализированные. Мы условимся следующим образом: Пусть компилятор наш сам определяет инициализированная ли секция или нет. Если в ней все ячейки будут пустыми, то не резервировать в файле для нее место. Поскольку реализация секции так же будет представлять строку, проверка будет проста: если строка пуста, то и не формировать тело секции в файл, но раз уж мы ее заказали – в памяти загрузчик должен «чрез себя прекувыркнутся», но зарезервировать место. Потом, эксперементируя, можно даже будет вообще обойтись одной единственной секцией – кода, куда можно впихнуть и данные, но тогда придется менять поле доступа (characteritisc) для этой секции. Опс… что–то я отвлекся. Продолжим…

– SizeOfRawData. Поле говорящее, сколько байтов в файле займет секция (при неинициализированной секции это значение 0).

– PointerToRawData. Поле, указывающее смещение, покоторому находится начало секции. Фактически оно считается по формуле fileAlignment*(Номер секции+1). +1 потому что РЕ заголовок мы тоже считаем секцией. Для неинициализированной секции имеет значение 0.

– Characteristics. Это поле доступов. Указывает, что можно делать с этой секцией, только читать, писать и читать или только выполнять. Значения мы подсмотрим в Оле, и установим их стандартно. Пока что нам хватит только два значения: Чтения и выполнения (для секции кода – $60000020) и чтения и записи (для секции данных – $C0000040).

Здесь же опишем поле _Data, которое будет хранить собственно тело секции. Для секции кода здесь будут опкоды команд, для секций данных – данные. Компилировать описание секции будет функция «Header». Такс… ничего не забыли? Вроде нет. В таком случае перейдем к реализации методов этого класса. А, собственно, у нас только одна функция, ее и реализуем (см. листинг 9):

Листинг 9

	function TAlisaSection.Header: String;
	begin Result:='';
	// Если тело ее пусто будем считать что это неинициализированная
	// секция, и не нуждается в компиляции в файл.
	    if _Data='' then begin
	     SizeOfRawData:=0;
	     PointerToRawData:=0;
	     VirtualSize:=2;
	    end;
	    VirtualSize:=length(_Data);
	    if VirtualSize=0 then VirtualSize:=1;
	// В остальном действия такие же – переворачиваем значение и в строку
	    Result:=Result+Name;
	    Result:=Result+DWordToStr(VirtualSize);
	    Result:=Result+DWordToStr(VirtualAddress);
	    Result:=Result+DWordToStr(SizeOfRawData);
	    Result:=Result+DWordToStr(PointerToRawData);
	    Result:=Result+DWordToStr(PointerToRelocations);
	    Result:=Result+DWordToStr(PointerToLineNumbers);
	    Result:=Result+ WordToStr(NumberOfRelocations);
	    Result:=Result+ WordToStr(NumberOfLineNumbers);
	    Result:=Result+DWordToStr(Characteristics);
	end;

 

Видите, все просто. Банально поле за полем выдаем в строку–поток компилированных данных, по–порядку, не забыв попереворачивать положение байт в поле.

Master of puppets

У нас есть заготовленные детали механизма. Простые шестеренки, неплохо бы собрать из них редуктор. Именно этим и займемся – напишем главный класс. Который будет компилировать, используя выше описанные классы–шестеренки, и формировать выходной файл.

Определимся, что туда должно входить:

  1. Поля классов для РЕ,MZ и массив секций
  2. Функции компиляции описаний всех этих секций, и тел секций.
  3. Не забудем метод создания секций.
  4. Попутно нам пригодятся функции расчета размера скомпилированного заголовка, расчета выравнивания секции в файле. Хоть мы и не будем писать огромные программы, но неплохо бы подумать на перспективу, о расчете размера выравниваний согласно содержимому секции. А вдруг данных в секции больше, чем 512 байт (это минимальное значение для инициализированной секции или РЕ заголовка), или банально – количество секции, например, 5 а их описание раздует заголовок в размер поболее 512 (372+40*5 = 572. Здесь 372 – минимальный размер РЕ заголовка, 40 – размер описания секций, 5 – их количество). Значит, придется резервировать для секций уже не 200h (512 в десятичной).

Определились? Приступим. Смотрим описание класса в листинге 10:

Листинг 10

	TAlisaCompiler=class
	 Private
	// Наши компиляторы заголовков
	  PE:TAlisaPEHeader;
	  MZ:TAlisaMZHeader;
	  Sections:TObjectList;
	// Это переменная–коллектор скомпилированного файла
	  _Data:String;
	// Функции, компилирующие описания заголовков, и из реализации
	   Function CompilePE:string;
	   Function CompileSectionsHeader(i:integer):string;
	//Вспомогательные функции
	// Функция вычисления, сколько нужно для стандартного РЕ заголовка
	// без обработчика запуска программы не из–под Windows
	   Function LengthHeader:Integer;
	// Функция расчета выравнивания в файле, согласно максимальному размеру
	// тел секций
	Function CalcFileAlignment:Integer;
	// Функция, проставляющая для каждой секции рассчитанное выравнивание
	// секций в файле
	procedure FixAlignSectionInFile;
	public
	// По умолчанию мы обязательно будем иметь как минимум одну секцию –
	// секцию кода. Она же будет в списке первой, и начинаться будет по
	// смещению 1000h
	_Code:TAlisaSection;
	// Функция, создающая новую секцию.
	Function NewSection(AName:string;Type_:Dword):TAlisaSection;
	// функция общей компиляции в поле _Data. Его содержимое и будет
	// поступать в файл как есть.
	Function Compile:String;
	Constructor Create;
	Destructor Free;
	end;

 

Ок. Начнем по порядку реализовывать каждое звено цепочки. Первым пойдет конструктор (листинг 11). Здесь мы проинициализируем основные поля заголовка и создадим секцию кода:

Листинг 11

	constructor TAlisaCompiler.Create;
	begin
	  MZ:=TAlisaMZHeader.Create;
	  pe:=TAlisaPEHeader.Create;
	 //******************************************
	    pe.AddressOfEntryPoint:=$1000;
	    pe.ImageBase:=$400000;
	    pe.SectionAlignment:=$1000;
	    { TODO –oUser –cConsole Main : С этим параметром аккуратнее }
	    pe.FileAlignment:=$200;
	    pe.SizeOfHeaders:=pe.FileAlignment;
	    pe.Subsystem:=3;
	 //******************************************
	  Sections:=TObjectList.Create;
	  _Code:=NewSection('Код',codeSec);
	end;

 

Это стандартные настройки для основных параметров нашего компилятора, позаимствованные из компиляции FASM. Их описания я давал выше. Конструкторы MZ и РЕ мы уже описали и реализовали выше, поэтому перейдем к реализации метода создания секции (см. листинг 12):

Листинг 12

	function TAlisaCompiler.NewSection;
	begin
	// создадим секцию и внесем ее в список секций
	 Result:=TAlisaSection.Create;
	 Sections.Add(Result);
	// Назовем ее и обязательно дополним до 8 байт
	 Result.Name:=AName;
	 while length(Result.Name)<8 do Result.Name:=Result.Name+#0;
	// Определим параметры секции. РЕ заголовок хоть и
	// считается секцией, но в список их не заносится. Здесь находятся
	// только настоящие секции
	Result.Characteristics:=Type_;
	Result.VirtualAddress:=PE.SectionAlignment*Sections.Count;
	Result.VirtualSize:=1;
	Result.SizeOfRawData:=PE.FileAlignment;
	end;

 

Теперь посмотрим***** реализацию метода компиляции, чтоб располагать последовательностью вызовов методов (см. листинг 13):

Листинг 13

	function TAlisaCompiler.Compile: String;
	var i,csp:integer; HeaderSum,CheckSum:dword;s,e:string;
	begin
	// Посчитаем количество секций
	 PE.NumberOfSections:=Sections.Count;
	// и выясним общий размер заголовка с описаниями секций
	 pe.SizeOfImage:=(PE.NumberOfSections+1)*pe.SectionAlignment;
	// Проведем расчет выравнивания секций в файле
	 pe.FileAlignment:=CalcFileAlignment;
	 // Поправим на основе расчета поля секций
	 FixAlignSectionInFile;
	 // Скомпилируем заголовки
	_Data:=CompilePE;
	// Теперь присоединим тела секций
	for i:=0 to Sections.Count–1 do
	_Data:=_Data+CompileSection(i);
	// Теперь нужно посчитать контрольную сумму
	//CheckSumMappedFile(@s[1],length(s),@HeaderSum,@CheckSum);
	Result:=_Data;
	end;

 

***** Комментарий автора.

Обратите внимание, я привел реализацию расчета контрольной суммы, на всякий случай.

Вдруг потребуется. Но на данные момент функция расчета контрольной суммы закоментированна.

Как я уже говорил, Windows на нее плюёт с высокой колокольни. Для работы этого метода нужно

реализовать шесть функции: LengthHeader, CalcFileAlignment, FixAlignSectionInFile, CompilePE,

CompileSectionsHeader и CompileSection. Они не все вызываются в нем, но мы и вложения перечислим.

Сделаем это ниже. В листингах 14–19.

Листинг 14

	function TAlisaCompiler.LengthHeader: Integer;
	begin
	// Это длина MZ+PE без описания секций
	 Result:=MZ.OffsetToPE+248;
	//Это общая чистая длина PE с секциями
	Result:=Result+Sections.Count*40;
	end;

 

Эта функция расчета длины заголовка в байтах. Помните, мы условились, что не будем использовать обработчик события запуска под не Windows систему, поэтому можем смело говорить, что OffsetToPE является крайней точкой MZ заголовка, за которым, не теряя ни одного байта, начнется РЕ заголовок. Кто-то скажет: «А зачем нужно было избавляться от этого обработчика, коль все равно выровнять до FileAlignment придется? В чем тут потери?». Верно, можно было и не отказываться от него, но во–первых лень писать еще и код этого обработчика, а во вторых при анализе так быстрее можно найти части заголовка, давайте сравним вид Оли с обработчиком и без него, начиная с точки смещения на РЕ часть:

Видите? На рисунках 4 и 3 разница очевидна. На втором мы сразу добрались до РЕ, а на первом его еще не видать, хотя на размер РЕ экзешника это не повлияло.

Ладно, идем далее. Листинг 15 – это расчет выравнивания тел секций в файле. Листинг 16 – это проставление полей в секциях согласно расчету.

Листинг 15

	function TAlisaCompiler.CalcFileAlignment: Integer;
	var ls,i,l:integer;
	begin
	// Получаем размер РЕ
	 l:=LengthHeader;
	//Если этот размер не делится нацело на 512, значит, его лучше выровнять
	//считая, что появилась еще какая–то секция
	if (l mod $200)<>0 then l:=(l div $200+1)*$200;
	// И соответственно точно так же проанализировать тела секций.
	// Вдруг, какая из них больше минималки, тогда ровнять будем по ней
	for i:=0 to Sections.count–1 do begin
	ls:=length(TAlisaSection(Sections[i])._Data);
	if ls>l then
	if (ls mod $200)<>0 then l:=(ls div $200+1)*$200;
	end;
	Result:=l;
	end;

 

Листинг 16:

	procedure TAlisaCompiler.FixAlignSectionInFile;
	var i:integer;
	begin
	for i:=0 to Sections.Count–1 do
	if TAlisaSection(Sections[i]).SizeOfRawData<>0 then
	TAlisaSection(Sections[i]).PointerToRawData:=pe.FileAlignment*(i+1)
	else
	TAlisaSection(Sections[i]).PointerToRawData:=0;
	end;

 

Следующий метод призван компилировать РЕ заголовок (все поля должны быть рассчитаны корректно до вызова этой функции):

Листинг 17

	function TAlisaCompiler.CompilePE: string;
	var i:integer;
	begin
	// Компилируем РЕ заголовок
	 Result:= MZ.Compile+PE.Header;
	// Компилируем описания секций
	 for i:=0 to Sections.Count–1 do begin
	  Result:=Result+TAlisaSection3(Sections[i]).Header;
	 end;
	// выравниваем скомпилированное по минимальному размеру
	// тела секции в файле.
	 while length(result)<pe.FileAlignment do Result:=Result+#0;
	end;

 

Здесь всплыла функция Header (Метод класса TAlisaPEHeader), описанная в листинге 7. Идем далее. Компиляция тел секций в листинге 18:

Листинг 18

function TAlisaCompiler.CompileSection(i: integer): String;
	begin
	Result:='';
	if (i>=0)and(i<Sections.Count) then
	with TAlisaSection(Sections[i]) do begin
	Result:=_Data;
	if Result<>'' then
	while length(result)<pe.FileAlignment do Result:=Result+#0;
	end;
	end;

Здесь тела секций выравниваются до FileAlignment, дополняясь нулями. Фух… Кажись все. Вот оно – ядро компилятора, формирующее интерьер файла – экзешника. Далее содержимое поля _Data пойдет непосредственно в файл. А что? Попробуем? Поместим эти классы в отдельный модуль, назовем <Alisa.pas>. На всякий случай напомню, что описание классов вешается в раздел interface, а реализация в раздел implementation. Так же в модуле понадобятся использование модулей types и contnrs. Я не описал деструктор TAlisaCompiler.Free, Но он не так важен. В нем просто нужно освобождать задействованные в классе объекты, это вы сами уж напишите.

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

Видите опкод? А100204000 в 16–тиричном виде. Вот так мы его и опишем в строку (обратите внимание, Оля показывает нам, как нужно правильно написать опкод в уже перевернутом виде – сначала младшие потом старшие байты), не забыв про retn (C3h) в листинге 19. Кстати, кто–то спросит «Откуда мы знаем адрес переменной? Не будем же мы заставлять в коде указывать адрес?». Нет, конечно, но проверить то нам наше творчество как–то надо, а мы помним, что у нас секция данных идет после кода, и размер секций в памяти 1000h, плюс база 400000h, вот и получается, что 400000h+размер секции кода дадут 402000h. А поскольку у нас только одна переменная (для проверки хватит) она лежит в самом начале секции данных – вот вам и адрес.

Листинг 19

	program Project1;
	{$APPTYPE CONSOLE}
	uses
	  SysUtils,
	  Unit1 in 'Unit1.pas';
	var a:TAlisaCompiler;d:TAlisaSection;
	 f:file of byte;s:string;
	begin
	 // Зарядим компилятор
	 a:=TAlisaCompiler.Create;
	 // Создадим секцию данных
	  d:=a.NewSection('Данные',dataSec);
	 // Положим в секцию данных число 5
	  d._Data:=#5;
	 // положим в секцию кода опкод команды, помещающей в регистр EAX число
	 // из секции данных и выход из программы
	  a._Code._Data:=#$A1#$00#$20#$40#$00#$C3;
	 // Скомпилируем
	  s:=a.Compile;
	  { TODO –oUser –cConsole Main : Insert code here }
	 // Сохраним в файл
	  AssignFile(f,'file.exe'); rewrite(f);
	  BlockWrite(f,s[1],length(s));
	  CloseFile(f);
	 a:=nil;
	end.

 

Набрали? Сохранили проект? Жмем <F9>? И пытаемся открыть полученный file.exe в отладчике или дизассемблере (Оля рулит, так что покажу в ней). Посмотрим, что получилось (см. рисунок 7):

  Нажмите <F8>. Оля выполнит команду, поместив в EAX число из памяти.

Close to seven

О! А вот и дождик пошел. Посвежело, исчезла изнуряющая жара. Это хорошо. И у нас все получилось удачно. Согласитесь, приятно собственноручно создавать что–то. Чувствуешь себя на порядок выше. Жаль, что многие начинающие хакеры этого не понимают, Хоть и хорошо знают систему, но кидают все силы на то чтобы ее поломать, но «ломать – не строить». Есть, конечно, люди, взламывающие не ради развлечения, а чтоб выяснить слабые места, но таких гораздо меньше.

Теперь то можно достать из холодильника квас, и откинувшись на спинку кресла, подумать о том, как дальше все эти наработки использовать. И воплотить все свои замыслы в код. Но это уже совсем другая история…

Вы можете ответить или разместить запись на вашем сайте.

Ответить

Powered by Procoder