Авторские статьи Уменьшение размера Си программы на примере Visual Studio

Discussion in 'Статьи' started by slesh, 19 Apr 2011.

  1. slesh

    slesh Elder - Старейшина

    Joined:
    5 Mar 2007
    Messages:
    2,702
    Likes Received:
    1,224
    Reputations:
    455
    Уменьшение размера Си программы на примере Visual Studio​

    Intro:
    Многие слышали, что программы, написанные на Си, могут иметь самый минимальный размер (сравнимый с размером только ASM программ). Но в большинстве случаев сами получали простейшие программы размером по 50-70 килобайт, и удивлялись этому и не могли понять, в чем истинная причина этого. В данной статье будет рассказано, как можно уменьшить размер программы до минимума и не нарваться на подводные камни.
    Всё что будет расписано в частности будет касаться Си программ (именно Си, а не С++) и среды разработки Visual Studio 2008 (для других версий данной IDE это всё будет действовать с небольшими изменениями) VS у нас русская, по этому не пугайтесь если не найдете нужных пунктов у себя. При указании параметра конфигурации, в скобках будет указан ключ для использования в командной строке при компиляции/линковки, который можно использовать, если не используется файл проекта.
    Выбран Си только потому, что в нем не используется дополнительного кода связанного с созданием и уничтожение классов, по причине отсутствия их :)

    Причины ожирения программы:
    Одна из самых главных причин это CRT (C Runtime Library) которая докидывает довольно много кода в исполняемый файл, даже когда нам это не нужно. Отказаться от CRT не так то и просто как кажется. Если с самого начала написания программы это не планировалось, то могут возникнуть довольно серьезные проблемы.
    Среди остальных причин можно отметить:
    1. Особенности создания исполняемого файла
    2. Особенности настройки оптимизации
    3. Особенности самого кода программы

    Избавление от статической линковки CRT:
    Когда программа настолько на использование CRT что отказаться от неё нельзя, то довольно удобным шагом будет изменение линковки со статической на динамическую.
    Для этого в свойствах проекта в параметре Свойства конфигурации -> C/C++ -> Создание кода -> Библиотека времени выполнения задать значение Многопоточная DLL (/MD)
    После выполнения данного действия, почти весь код CRT будет подгружаться из DLL. Но тут нас ждем подводный камень: VS 2008 в качестве DLL использует MSVCR90.dll, которая в свою очередь по дефолту не установлена в Windows XP, так что ваша программа будет работать только в системах где установлена данная библиотека или где установлена VS 20008 и выше. А таскать её с собой довольно сложно из-за большого веса.
    В 90% случаях данная проблема решается использованием более старой версии CRT. Для этого следует выполнить следующие пункты:
    Взять из VC++ 6 версии библиотеку MSVCRT.LIB
    Данную библиотеку кинуть в папку с программой (или в папку с библиотеками) под именем MSVCRTOLD.LIB
    В свойствах проекта задать Свойства конфигурации -> Компоновщик -> Ввод -> Игнорировать все стандартные библиотеки - Да (/NODEFAULTLIB).
    Добавить в параметры линковки (Свойства конфигурации -> Компоновщик -> Командная строка -> Дополнительные параметры) библиотеку MSVCRTOLD.LIB
    После такого рода манипуляции программа будет использовать CRT из msvcrt.dll, которая по дефолту есть в каждой Windows, начиная с древних времен.
    Такой подход помогает во многих случаях, за исключением тех, где используется новые функции, которых не было в старых версиях CRT. Так что будьте внимательны. Ну и конечно же придется еще чуть по играться с параметрами компиляции.

    Полное избавление от CRT:
    Для полного счастья нам всё же потребуется полный отказ от использования функций CRT. Как уже многие догадались, придется использовать только “скудный” WinAPI. Но как показывает практика, WinAPI “скудный” по функционалу, только для тех, кто его не знает или не умеет пользоваться MSDN. Большинство функций CRT реализованы в системных библиотеках или же их собственная реализация не создаст трудностей. Вся настройка предполагается для Release сборки.

    Шаг 1: Настройка компиляции
    В Windows существует 2 общепринятых формата строк. Это Ansi Char (1 байт) и Wide Char (2 байта). Поэтому для обработки каждого из них существует 2 вида WinAPI функций (оканчивающихся на A или W). К примеру: CreateFileA и CreateFileW, первая принимает строковые параметры в виде Ansi строк, а вторая в виде Wide строк. Не будет разводить холивар по поводу правильности использования и скорости работы каждой из функции, у нас стоит задача уменьшить вес программы, поэтому будет использовать только Ansi строки т.к. они в 2 раза меньше занимают места. По дефолту VS 2008 пытается использовать Wide строки. Для того чтобы отучить её от этого следует для параметра Свойства конфигурации -> Общие -> Набор Знаков задать значение “Не задано”. Это позволит нам безболезненно использовать различные встроенные макросы для Ansi функций. Да и вообще для меньшей путаницы лучше указывать сразу какой тип строк мы используем в функции. Т.е. вместо CreateFile писать CreateFileA.
    Важным моментам в уменьшении размера является оптимизация при компиляции. Для этого следует в разделе: “Свойства конфигурации -> С/С++ -> Оптимизация”, задать следующие параметры:
    1. Оптимизация - Наименьший размер (/O1)
    2. Развертывать подставляемые функции - По умолчанию
    3. Включить подставляемые функции – Нет
    4. Предпочитать размер или краткость кода - Предпочитать краткость кода (/Os)
    5. Оптимизация всей программы - Включить создание кода во время компоновки (/GL) – т.е. оптимизация будет выполнена на уровне всех модулей программы, а не каждого модуля в отдельности.
    Далее нам следует отказаться от всякого левого кода, который автоматически вставляется при компиляции. Для этого следует в разделе: “Свойства конфигурации -> С/С++ -> Создание кода”, задать следующие параметры:
    1. Включить объединение строк - Да (/GF) – заставляет компилятор объединять одинаковые строки в одну, если они не изменяются.
    2. Включить С++ исключения – Нет – нам они не нужны потому что пишем на Си.
    3. Библиотека времени выполнения - Многопоточная (/MT) – хотя по факту нам вообще это не важно, т.к. мы не будем использовать CRT
    4. Проверка переполнения буфера - Нет (/GS-) – Знать о том, что мы переполнили буфер, нам как-то не обязательно потому, что мы пишем правильный код, в котором переполнений не должно быть :)

    Убираем все левые, предварительно скомпилированные заголовки: “Свойства конфигурации -> С/С++ -> Предварительно скомпилированные заголовки -> Создавать или использовать предварительно скомпилированные заголовки ” - Не использовать предварительно скомпилированные заголовки
    Ну и конечно же зададим что у нас программа на Си : “Свойства конфигурации -> С/С++ -> Дополнительно -> Компилировать как” - Компилировать как C код (/TC)

    Шаг 2: Настройка компоновки.
    Правильно скомпилировать код, это одно, а правильно собраться по воедино – это уже другое. Приступим к действиям в разделе “Свойства конфигурации -> Компоновщик”:
    1. Ввод ->Игнорировать все стандартные библиотеки - Да (/NODEFAULTLIB) – Это нам позволит избавиться сразу от CRT
    2. Файл манифеста ->Создавать Манифест – Нет – Манифест нам как-то не нужен, т.к. нам особо не на чего претендовать. Если конечно нам нужно автоматическое вывод окна UAC или красивые элементы окна, то можно и оставить его, но прежде убрать всё лишнее
    3. Отладка -> Создавать отладочную информацию – Нет – Отладочная информация нам не нужна, т.к. отладкой мы будет заниматься в Debug сборке.
    4. Дополнительно -> Точка входа - тут прописываем имя функции для точки входа, допустим EntryPoint у нас будет. Т.к. мы отказались от CRT (которая ставила свою точку входа), то мы должны задать её сами.
    5. Дополнительно -> Внесение случайности в базовый адрес - Отключить внесение случайности в образ (/DYNAMICBASE:NO) – нам как то по пофигу на это, по этому пусть будет всегда один и тот же. Потом проще будет)
    6. Дополнительно -> Фиксированный базовый адрес - Образ должен быть загружен по фиксированному адресу (/FIXED) – отключает создание релоков (которые нужны для DLL) а т.к. мы получаем EXE файл, который будет всегда загружаться в одно и то же место, то нам релоки не нужны.

    Шаг 3: Настройка кода
    Так как мы установили свою точку входа, то необходимо объявить её. Первоначальный файл у нас будет выглядеть следующим образом:
    Code:
    #include <windows.h>
    // точка входа
    void EntryPoint(void)
    {
    Необходимые действия
    ExitProcess(0);
    }
    ExitProcess для того, чтобы после всех действий наша программа действительно завершила работу, даже если стек был поврежден.

    Шаг 4: Замена функций CRT
    Как я уже выше упоминал, то частенько большинство функций CRT уже реализованы, в системных библиотеках, с тем или иным изменением. Рассмотрим самые популярные:
    Работа со строками:
    1. strcpy – lstrcpyA
    2. strcat – lstrcatA
    3. strlen – lstrlenA
    4. strcmp – lstrcmpA
    5. strstr – StrStrA / StrStrIA
    6. strchr – StrChrA / StrChrIA
    7. stricmp – StrCmpIA/ StrCmpNA / StrCmpNIA
    8. itoa – StrToIntA
    9. sprintf – wsprintfA / wnsprintfA

    Работа с памятью:
    1. malloc – HeapAlloc / VirtualAlloc зависит от размера выделяемой памяти и её предназначении
    2. free – HeapFree / VirtualFree

    Работа с файлами:
    1. fopen – CreateFileA
    2. fclose – CloseHandle
    3. fwrite – WriteFile
    4. fread – ReadFile
    5. fgets – придется реализовывать самому парсинг считанных данных на строки

    Работа с потоками:
    1. _beginthread – CreateThread
    2. _endthread – ExitThread

    Консольный ввод/вывод
    1. printf – wvsprintf + WriteConsole
    2. scanf – ReadConsole + парсинг строк

    Аргументы командной строки
    1. argc + argv – CommandLineToArgvW + GetCommandLineW

    Для большинства функций реализации можно найти в исходниках CRT, которые присутствует в Visual Studio Professional в папке: %папка устновки VS% \VC\crt\src\
    Из исходников CRT можно взять только нужные функции и скинуть их в отдельный файл исходника. Среди часто используемых это: memset, memcpy, atoi. Также большая часть функций может быть найдена в ntdll.dll, для использования которых придется позаимствовать из WDK/DDK файл ntdll.lib

    Шаг 5: Изменение параметров секций
    Даёт довольно хорошую оптимизацию по размеру (до килобайта). Дело в том, что по умолчанию Си компилятор создаёт следующие секции:
    1. . text – секция кода
    2. .rdata – секция импорта
    3. .data – секция данных
    А также секции экспорта, ресурсов, релоков и прочие.
    Каждая секция перед записью в файл выравнивается по размеру на 512 байт (размер секции всегда кратен будет 512 байтам). Т.е. если мы имеет данных (переменные глобальные) на 4 байта, то всё равно секция данных будет занимать 512 байт минимум. Т.е. если имеем 3 секции, то максимум мы сможем потерять до 1533 (511 * 3) байт из-за выравнения размера. Чтобы такое не случалось можно прибегнуть объединению нескольких секций в одну. Практика показала, что практически всегда объединению поддаются секции кода, импорта и данных.
    Для объединения секций необходимо в главном файле исходников прописать код:
    Code:
    #pragma comment(linker, "/MERGE:.data=.text")
    #pragma comment(linker, "/MERGE:.rdata=.text")
    Тем самым мы объединим 3 секции в одну с именем .text
    Но тут есть один подводный камень: Секция кода имеет права – RE (чтение и выполнение), секция данных RW (чтение и запись), а секция импорта – R (только чтение). И после того как мы объединили всё в одну секцию, то она должна иметь общие права для всех секций которые в неё вошли, иначе можно столкнуться с ошибками при включенном DEP и прочих защитах. Для установки прав необходимо после объединения секций прописать команду :
    Code:
    #pragma comment(linker, "/SECTION:.text,EWR")
    Т.е. дать секции .text права на чтение, запись и выполнение.
    ВАЖНО: Большинство антивирусов довольно плохо относятся к файлам у которых есть секция с правами на запись и выполнения, по этому они могут считать данные файлы упакованными или просто сказать что подозрительный файл или вообще что это вирус. Так что сильно не пугайтесь :)

    Шаг 6: Уменьшение размера Dos заголовка.
    Dos заголовок находится в самом начале программы и имеет в нем небольшую DOS программу которая выводит сообщения типа: This program cannot be run in DOS mode. Что является защитой от запуска Windows программ под DOS’ом
    Времене DOS прошли, по этому нам данная подпрограмма не нужна и от этой строки смысла нам не будет. По этому можно использовать альтернативный Dos заголовок с меньшим размером. Для этого в параметрах компоновщика требутеся укзаать опцию: /stub:stub.bin
    stub.bin – это файл с альтернативным заголовком.
    К примеру, можно использовать этот (предварительно переведя его из HEX в двоичные данные):
    Code:
    4D5A00000100000002000000FFFF0000
    40000000000000004000000000000000
    B44CCD2100000000000000000000000
    000000000000000000000000000000000
    
    Файл stub.bin должен располагаться в папке с исходным кодом.

    Шаг 7: Изменение кода
    Очень часто большой объем кода получается из-за:
    1. Использования большого числа глобальных переменных. Поэтому пытается отказаться от глобальных переменных (особенно инициализированных), особенно буферов типа:
      char buf[1024] = {0};
    2. Такой код увеличит размер файла на 1024 байт. Так что всю инициализацию следует производить более осторожно. Если обычные числовые переменные особого размера не вносят, то буферы дают большой вклад в размер. Если требуется глобальный буфер, то проще выделить под него память в начале программы.
    3. Использование большого числа локальных переменных, описанных как статические (static). Та же самая проблема, что и с инициализированными глобальными переменными.
    4. Повторение участков кода. Допустим если есть участок кода состоящий из 3-4 функций и используется он в программе много раз, то более рациональным былобы использовать его в виде отдельной функции.
    5. Использования одинаковых (неизменяемых) строк в виде констант (префикс const или использовать запись через #define) это позволит компилятору безболезненно объединить их в одну.
    6. Сворачивать большие участки одинакового кода в цикли. Т.е. не писать их последовательно.

    Шаг 8: Оптимизация ресурсов
    В небольших программах большой объем данных занимают именно ресурсы. Если от диалогов особо отказаться не получится, то следующие участки поддаются хорошей оптимизации:
    Иконки – весят много, особенно если они многоцветные и много размерные. Для уменьшения размера следует по возможности использовать системные иконки (из shell32.dll), их там очень много, так что найти нужные не составит труда. Если же приходится иметь иконки свои, то лучше сделать 2 вида их 16*16 и 32*32 точки. Этого будет достаточно, ну и по возможности делать их 16 цветными.
    Информация о версии – является с одной стороны и нужной, а с другой стороны и абсолютно бесполезной. Если у нас не крутая программа, а обычная утилитка, то лучше вообще отказаться от информации о версии, а все необходимые данные отображать в рабочем окне программы.

    Шаг 9: А как же быть с исключениями?
    После отказа от CRT, при добавлении в программу конструкций __try / __except, программа отказываются работать по причине отсутствия парочки функций. Их можно реализовать самому, или позаимствовать из CRT. Но всё равно лучше не использовать данные конструкции, а сразу хорошо отладить код. Если нужно ловить все ошибки, то ставить глобальный обработчик всех ошибок через SetUnhandledExceptionFilter

    Шаг 10: Убираем всякие побочные артефакты

    Вробе бы всё у нас хорошо, и проверку переполнения буфера мы отключили, но с того не ссего при компоновке у нас могут начать сыпаться ошибки вида, что не найден внешний символ __chkstk. На деле этого говорит о то, что проверка всётаки включилась автоматически. Такое бывает в том, случае когда под локальные переменные функции отводится слишком большое количество памяти в стеке. Это магическое значение равно 4 килобайтам. Важно понимать, что это значение является общим для всей вложенности функций начиная от точки входа в программу или начала потока. Пример:
    Code:
     void TestProc_2()
     {
    	char buf_2[1024*3];
    	MessageBoxA(0, buf_2, buf_2, MB_OK);
     }
    
     void TestProc_1()
     {
    	char buf_1[1024*3];
    	TestProc_2();
    	MessageBoxA(0, buf_1, buf_1, MB_OK);
     }
    
    При вызове TestProc_1 в стеке зарезервируется 3 килобайта под буфер (buf_1), затем из TestProc_1 вызовется TestProc_2, которая тоже зарезервирует 3 килобайта, в сумме мы получим 6 килобайта, что больше 4 килобайт, это и вызовет автоматическую проверку стека.
    Чтобы небыло таких неприятностей есть 2 выхода:
    1) Не использовать большое кол-во локальных переменных. Вернее врякого рода буферов. И при необходимости просто выделять память.
    2) В параметрах компиляции задать инимальный проверяемый размер стека, который будет больше того, который мы используем. Подобрать можно эксперементальным путём, или анализом вызываемых функций. Для задания этого минимального значения следует прописать в параметрах компиляции: /GsNNNN где NNNN – значение в батах (в нашем случае 8192 хватит нам)

    Final
    Вот такими вот не сложными манипуляциями можно кардинально уменьшить размер исполняемого файла.
    Для наглядности в аттаче добавлен пример такой программы (с исходниками): Программа отображается для выбранного DLL/EXE файла все иконки содержащиеся в ресурсах и имеющие размер 32*32 пикселя. Размер программы без сжатия вышел равным 2560 байт
     

    Attached Files:

    foxovsky, marynli, Fepsis and 9 others like this.
  2. shadowrun

    shadowrun Banned

    Joined:
    29 Aug 2010
    Messages:
    842
    Likes Received:
    170
    Reputations:
    84
    Очень содержательная статья. Почерпнул для себя много нового. Спасибо
     
  3. Bo0oM

    Bo0oM Member

    Joined:
    26 Dec 2009
    Messages:
    2
    Likes Received:
    35
    Reputations:
    21
    Отличная статья! Доступно, понятно, интересно! Так держать!
     
  4. Osstudio

    Osstudio Banned

    Joined:
    17 Apr 2011
    Messages:
    638
    Likes Received:
    160
    Reputations:
    81
    Зачёт) Не знал..
     
  5. spb

    spb New Member

    Joined:
    13 Apr 2009
    Messages:
    0
    Likes Received:
    0
    Reputations:
    0
    познавательно, спасибо.
    а есть такое по c++, Pascal, Delphi?
     
  6. Flisk

    Flisk Member

    Joined:
    4 Aug 2010
    Messages:
    147
    Likes Received:
    8
    Reputations:
    -2
    По дельфи что-то было на васме (поищите статьи мс-рэма).
     
  7. lomerok

    lomerok Elder - Старейшина

    Joined:
    23 Apr 2008
    Messages:
    141
    Likes Received:
    8
    Reputations:
    0
    как раз то что нужно. думал буду щас рыть на васме и не найду прийдется пудрить кому то мозги чтоб помогли .. а тут все на коленке. люблю содержательные материалы чтоб от и до..сори если офтоп . .просто большой респект автору.. чес слово благодарен
     
  8. roman921

    roman921 Member

    Joined:
    24 May 2015
    Messages:
    316
    Likes Received:
    22
    Reputations:
    0
    А какую функцию winapi можно взять взамен memchr для поиска по памяти байтов заданных в поиске ?
     
  9. binarymaster

    binarymaster Elder - Старейшина

    Joined:
    11 Dec 2010
    Messages:
    4,717
    Likes Received:
    10,195
    Reputations:
    126
    Такие низкоуровневые функции обычно есть в различных рантайм библиотеках, в WinAPI вряд ли найдётся подобное. Проще самим написать функцию поиска, а для проверки читаемости памяти по указателю использовать IsBadReadPtr.
     
  10. roman921

    roman921 Member

    Joined:
    24 May 2015
    Messages:
    316
    Likes Received:
    22
    Reputations:
    0
    Сделал поиск через ReadProcessMemory.
     
  11. binarymaster

    binarymaster Elder - Старейшина

    Joined:
    11 Dec 2010
    Messages:
    4,717
    Likes Received:
    10,195
    Reputations:
    126
    Эта API функция просто считывает память в буфер. Конкретно поиск, т.е. нахождение смещения определённой последовательности байт - я имел ввиду именно это, в виде функции.