СтатьиПрограммирование

Изучаем параметры gcc.
n0xi0uzz
24 марта 2008 13:40



Теги: programming, gcc, nix

Перевод статьи «Getting Familiar with GCC Parameters», автор — Mulyadi Santosa


gcc (GNU C Compiler) — набор утилит для компиляции, ассемблирования и компоновки. Их целью является создание готового к запуску исполняемого файла в формате, понимаемом вашей ОС. Для Linux, этим форматом является ELF (Executable and Linking Format) на x86 (32- и 64-битных). Но знаете ли вы, что могут сделать для вас некоторые параметры gcc? Если вы ищете способы оптимизации получаемого бинарного файла, подготовки сессии отладки или просто наблюдать за действиями, предпринимаемыми gcc для превращения вашего исходного кода в исполняемый файл, то знакомство с этими параметрами обязательно. Так что, читайте.

Напомню, что gcc делает несколько шагов, а не только один. Вот небольшое объяснение их смысла:
Препроцессирование: Создание кода, более не содержащего директив. Вещи вроде «#if» не могут быть поняты компилятором, поэтому должны быть переведены в реальный код. Также на этой стадии разворачиваются макросы, делая итоговый код больше, чем оригинальный. [1]
Компиляция: Берется обработанный код, проводятся лексический и синтаксический анализы, и генерируется ассемблерный код. В течение этой фазы, gcc выдает сообщения об ошибках или предупреждениях в случае, если анализатор при парсинге вашего кода находит там какие-либо ошибки. Если запрашивается оптимизация, gcc продолжит анализировать ваш код в поисках улучшений и манипулировать с ними дальнейшем. Эта работа происходит в многопроходном стиле, что показывает то, что иногда требуется больше одного прохода по коду для оптимизации. [2]
Ассемблирование: Принимаются ассемблерные мнемоники и производятся объектные коды, содержащие коды команд. Часто недопонимают то, что на стадии компиляции не производятся коды команд, это делается на стадии ассемблирования. В результате получаются один или более объектных файла, содержащие коды команд, которые являются действительно машинозависимыми. [3]
Компоновка: Трансформирует объектные файлы в итоговые исполняемые. Одних только кодов операции недостаточно для того, чтобы операционная система распознала и выполнила их. Они должны быть встроены в более полную форму. Эта форма, известная как бинарный формат, указывает, как ОС загружает бинарный файл, компонует перемещение и делает другую необходимую работу. ELF является форматом по умолчанию для Linux на x86. [4]

Параметры gcc описаны здесь, прямо и косвенно затрагивая все четыре стадии, поэтому для ясности, эта статья построена следующим образом:
— Параметры, относящиеся к оптимизации
— Параметры, относящиеся к вызову функций
— Параметры, относящиеся к отладке
— Параметры, относящиеся к препроцессированию

Прежде всего, давайте ознакомимся с вспомогательными инструментами, которые помогут нам проникать в итоговый код:
— Коллекция утилит ELF, которая включает в себя такие программы, как objdump и readelf. Они парсят для нас информацию о ELF.
Oprofile, один из стандартных путей подсчета производительности аппаратного обеспечения. Нам нужна эта утилита для просмотра нескольких аспектов производительности кода.
— time, простой способ узнать общее время работы программы.

Следующие инструкции могут быть применены в gcc версий 3.x и 4.x, так что они достаточно общие. Начнем копать?


Параметры, относящиеся к оптимизации кода

.

gcc предоставляет очень простой способ производить оптимизацию: опция -O. Она и ускоряет выполнение вашего кода, и сжимает размер итогового кода. У неё существует пять вариантов:
— от -O0 (O ноль) до -O3. "0" означает отсутствие оптимизации, а "3" — наивысший уровень оптимизации. "1" и "2" находятся между этими краями. Если просто используете -O без указания номера, это будет означать -O1.
— -Os говорит gcc оптимизировать размер. В общем-то, это похоже на -O2, но пропускает несколько шагов, которые могут увеличить размер.

Какое ускорение в действительности можно от них получить? Что ж, предположим, у нас есть такой код:
#include 
int main(int argc, char *argv[])
{
   int i,j,k;
   unsigned long acc=0; 
   for(i=0;i<10000;i++)
        for(j=0;j<5000;j++)
                for(k=0;k<4;k++)
                        acc+=k;
   printf("acc = %lu\n",acc);
   return 0;
}

С помощью gcc, создадутся четыре разных бинарных файла, используя каждый из -O вариантов (кроме -Os). Утилита time запишет их время исполнения, таким образом:
$ time ./non-optimized






Без оптимизации-O1-O2-O3
real0.7280.10.10.1
user0.7280.0970.10.1
sys0.0000.0020.0000.000

Для упрощения, будем использовать следующие обозначения:
Non-optimized обозначает исполняемый файл, скомпилированный с опцией -O0.
OptimizedO1 обозначает исполняемый файл, скомпилированный с опцией -O1.
OptimizedO2 обозначает исполняемый файл, скомпилированный с опцией -O2.
OptimizedO3 обозначает исполняемый файл, скомпилированный с опцией -O3.
Как вы могли заметить, время выполнения программы, скомпилированной с -O1 в семь раз меньше, чем время выполнения программы, при компиляции которой не использовалась оптимизация. Обратите внимание, что нет большой разницы между -O1, -O2 и -O3, — на самом деле, они почти одинаковы. Так в чем же магия -O1?

После беглого изучения исходного кода, вы должны отметить, что такой код конечен для оптимизации. Прежде всего, давайте посмотрим на короткое сравнение дизассемблированных версий non-optimized и optimizedO1:
$ objdump -D non-optimized
$ objdump -D optimizedO1

(Примечание: вы можете получить другие результаты, поэтому используйте эти как основные)



Non-optimizedOptimizedO1
mov 0xfffffff4(%ebp)
add %eax,0xfffffff8(%ebp)
addl $0x1,0xfffffff4(%ebp)
cmpl $0x3,0xfffffff4(%ebp)
add $0x6,%edx
add $0x1,%eax
cmp $0x1388,%eax

Приведенные примеры реализуют самый вложенный цикл (for (k=0;k<4;k++)). Обратите внимание на различие: неоптимизированный код напрямую загружает и хранит из адреса памяти, в то время как optimized01 использует регистры ЦПУ в качестве сумматора и счетчик цикла. Как вам, возможно, известно, доступ к регистрам может быть получен в сотни или тысячи раз быстрее, чем к ячейкам ОЗУ.

Не удовлетворяясь простым использованием регистров ЦПУ, gcc использует другой трюк оптимизации. Давайте снова посмотрим дизассемблированный код optimizedO1 и обратим внимание на функцию main():
......
   08048390 
: ... 80483a1: b9 00 00 00 00 mov $0x0,%ecx 80483a6: eb 1f jmp 80483c7 80483a8: 81 c1 30 75 00 00 add $0x7530,%ecx

0x7530 это 30000 в десятичной форме, поэтому мы можем быстро угадать цикл. Этот код представляет собой самый вложенный и самый внешний циклы (for(j=0;j<5000;j++) ... for(k=0;k<4;k++)), так как они являются буквальным запросом на 30000 проходов. Примите во внимание, что вам нужно всего лишь три прохода внутри. Когда k=0, acc остается прежним, поэтому первый проход можно проигнорировать.
   80483ae:       81 f9 00 a3 e1 11       cmp    $0x11e1a300,%ecx
   80483b4:       74 1a                   je     80483d0 
   80483b6:       eb 0f                   jmp    80483c7 

Хмм, теперь это соответствует 300 000 000 (10 000*5 000*6). Представлены все три цикла. После достижения этого числа проходов, мы переходим прямо к printf() для вывода суммы (адреса 0x80483d0 - 0x80483db).
   80483b8:       83 c2 06                add    $0x6,%edx
   80483bb:       83 c0 01                add    $0x1,%eax
   80483be:       3d 88 13 00 00          cmp    $0x1388,%eax
   80483c3:       74 e3                   je     80483a8 
   80483c5:       eb f1                   jmp    80483b8 

Шесть добавляется в сумматор при каждой итерации. В итоге, %edx будет содержать всю сумму после выполнения всех трех циклов. Третья и четвертая строки показывают нам, что после выполнения 5000 раз, должен быть переход к адресу 0x80483a8 (как указано ранее).

Мы можем заключить, что gcc создает здесь упрощение. Вместо прохода три раза в самый вложенный цикл, он просто добавляет шесть для каждого среднего цикла. Это звучит просто, но это заставляет вашу программу сделать только 100 000 000 проходов вместо 300 000 000. Это упрощение, называемое разворачиванием цикла, одно из тех задач, которые делают -O1/2/3. Конечно же, вы и сами можете это сделать, но иногда неплохо знать, что gcc может определить такие вещи и оптимизировать их.

С опциями -O2 и -O3 gcc тоже пытается произвести оптимизацию. Обычно она достигается посредством переупорядочивания [5] и трансформацией кода. Целью этой процедуры является устранить столько ошибочных ветвей, сколько возможно, что повышает качество использования конвейера. Например, мы можем сравнить, как non-optimized и optimizedO2 выполняет самый внешний цикл.
 80483d4:       83 45 ec 01             addl   $0x1,0xffffffec(%ebp)
 80483d8:       81 7d ec 0f 27 00 00    cmpl   $0x270f,0xffffffec(%ebp)
 80483df:       7e c4                   jle    80483a5 

Бинарный файл non-optimized использует jle для выполнения перехода. Математически это означает, что вероятность выбора ветви 50%. С другой стороны, версия optimizedO2 использует следующее:
80483b4:       81 c1 30 75 00 00       add    $0x7530,%ecx
80483ba:       81 f9 00 a3 e1 11       cmp    $0x11e1a300,%ecx
80483c0:       75 e1                   jne    80483a3 

Теперь, вместо jle используется jne. При условии, что любое целое может быть сопоставлено в предыдущем cmp, нетрудно сделать вывод, что это увеличит шанс выбора ветви почти до 100%. Это небольшое, но полезное указание процессору для определения того, какой код должен быть выполнен. Хотя, для большинства современных процессоров, этот вид трансформации не является ужасно необходимым, так как предсказатель переходов достаточно умен для того, чтобы сделать это сам.

Для доказательства того, как сильно это изменение может помощь, к нам на помощь придет OProfile. Oprofile выполнен для записи числа изолированных ветвей и изолированных ошибочных ветвей. Изолированные здесь обозначает «выполненные внутри конвейера данных ЦПУ»
$ opcontrol --event=RETIRED_BRANCHES_MISPREDICTED:1000 --event=RETIRED_BRANCHES:1000;

Мы запустим non-optimized и optimizedO2 пять раз каждый. Затем мы возьмем максимум и минимум примеров. Мы посчитаем степень ошибки, используя эту формулу (выведена отсюда).
Степень ошибки = изолированные ошибочные ветви / изолированные ветви
Теперь вычислим степень ошибки для каждого бинарного файла. Для non-optimized получилось 0,5117%, в то время как optimizedO2 получил 0,4323% — в нашем случае, выгода очень мала. Фактическая выгода может различаться для реальных случаев, так как gcc сам по себе не может много сделать без внешних указаний. Пожалуйста, прочтите о __builtin_expect() в документации по gcc для подробной информации.


Параметры, относящиеся к вызову функций



По существу, gcc предоставляет вам несколько путей управления тем, как вызывается функция. Сначала давайте рассмотрим встраивание. С помощью встраивания, вы сокращаете стоимость вызова функции, так как тело функции подставлено прямо в вызывающую функцию. Пожалуйста, учтите, что это не по умолчанию, а только когда вы используете -O3 или, по крайней мере, -finline-functions.

Как полученный бинарный файл выглядит после того, как gcc сделает встраивание? Рассмотрим следующий листинг:
#include 

inline test(int a, int b, int c)
{
        int d;
        d=a*b*c;
        printf("%d * %d * %d is %d\n",a,b,c,d);
}

static inline test2(int a, int b, int c)
{
         int d;
         d=a+b+c;
         printf("%d + %d + %d is %d\n",a,b,c,d);
}

int main(int argc, char *argv[])
{
        test(1,2,3);
        test2(4,5,6);
}

Скомпилируем этот код со следующим параметром:
$ gcc -S -O3 -o  

-S указывает gcc остановиться сразу после стадии компиляции (мы расскажем о ней позже в этой статье). Результат будет следующим:
....
test:
        pushl   %ebp
        movl    %esp, %ebp
        pushl   %ebx
....
main:
        leal    4(%esp), %ecx
        andl    $-16, %esp
        pushl   -4(%ecx)
...
        movl    $6, 16(%esp)
        movl    $3, 12(%esp)
        movl    $2, 8(%esp)
        movl    $1, 4(%esp)
        movl    $.LC0, (%esp)
        call    printf
...
        movl    $15, 16(%esp)
        movl    $6, 12(%esp)
        movl    $5, 8(%esp)
        movl    $4, 4(%esp)
        movl    $.LC1, (%esp)
        call    printf
...

И test(), и test() действительно встроены, но вы также можете видеть test(), который остался вне main(). Вот где играет роль ключевое слово static. Написав, что функция — static, вы сообщаете gcc, что эта функция не будет вызываться из какого-либо внешнего объектного файла, поэтому нет нужды порождать коды. Таким образом, это экономит размер, и если вы можете сделать функцию статичной, сделайте это где только возможно. С другой стороны, будьте благоразумны при решении, какая функция должна быть встраиваемой. Увеличение размера для небольшого увеличения скорости не всегда оправдано.

С помощью некоторой эвристики, gcc решает, должна быть функция встраиваемой, или нет. Одним из таких доводов является размер функции в терминах псевдо-инструкций. По умолчанию, лимитом является 600. Вы можете поменять этот лимит, используя -finline-limit. Проэксперементируйте для нахождения лучших лимитов встраивания для вашего конкретного случая. Также возможно переделать эвристику так, чтобы gcc всегда встраивал функцию. Просто объявите вашу функцию так:
__attribute__((always_inline)) static inline test(int a, int b, int c)

Теперь перейдем к передаче параметров. На архитектуре x86, параметры помещаются в стек и позже достаются из стека для дальнейшей обработки. Но gcc дает вам возможность изменить это поведение и использовать вместо этого регистры. Функции, у которых меньше трех параметров могут использовать эту возможность указанием -mregparm=, где n — число регистров, которое вы хотите использовать. Если мы применим этот параметр (n=3) к предыдущему коду, убрав атрибут inline и не используя оптимизацию, мы получим:
...
test:
        pushl   %ebp
        movl    %esp, %ebp
        subl    $56, %esp
        movl    %eax, -20(%ebp)
        movl    %edx, -24(%ebp)
        movl    %ecx, -28(%ebp)
...
main:
...
        movl    $3, %ecx
        movl    $2, %edx
        movl    $1, %eax
        call    test

Вместо стека, используются EAX, EDX и ECX для хранения первого, второго и третьего параметров. Поскольку доступ к регистру происходит быстрее, чем к ОЗУ, это будет одним из способов уменьшить время работы. Хотя вы должны обратить внимание на следующие вещи:
— Вы ДОЛЖНЫ компилировать весь ваш код с таким же числом -mregparm регистров. Иначе у вас будут проблемы с вызовом функций из другого объектного файла, если они будут принимать разные соглашения.
— Используя -mregparm, вы разрушаете совместимый с Intel x86 бинарный интерфейс приложений (ABI). Поэтому, вы должны учитывать это, если вы распространяете свое ПО только в бинарной форме.

Возможно, вы заметили эту последовательность в начале каждой функции:
push   %ebp
mov    %esp,%ebp
sub    $0x28,%esp

Эта последовательность, также известная как пролог функции, написана чтобы установить указатель фрейма (EBP). Это приносит пользу, помогая отладчику делать трассировку стека. Следующая структура поможет вам понять это [6]:
[ebp-01] Последний байт последней локальной переменной
[ebp+00] Старое значение ebp
[ebp+04] Возвращает адрес
[ebp+08] Первый аргумент
Можем мы пренебречь этим? Да, с помощью -fomit-frame-pointer, пролог будет укорочен, так что функция начнется просто с выделения стека (если есть локальные переменные):
sub    $0x28,%esp

Если функция вызывается очень часто, вырезание пролога спасет несколько тактов ЦПУ. Но будьте осторожны: делая это, вы также усложняете отладчику задачу по изучению стека. Например, давайте добавим test(7,7,7) в конец test2() и перекомпилируем с параметром -fomit-frame-pointer и без оптимизации. Теперь запустите gdb для исследования бинарного файла:
$ gdb inline
(gdb) break test
(gdb) r
Breakpoint 1, 0x08048384 in test ()
(gdb) cont
Breakpoint 1, 0x08048384 in test ()
(gdb) bt
#0  0x08048384 in test ()
#1  0x08048424 in test2 ()
#2  0x00000007 in ?? ()
#3  0x00000007 in ?? ()
#4  0x00000007 in ?? ()
#5  0x00000006 in ?? ()
#6  0x0000000f in ?? ()
#7  0x00000000 in ?? ()

При втором вызове test, программа остановилась, и gdb вывел трассировку стека. В нормальной ситуации, main() должна идти в фрейме №2, но мы видим только знаки вопроса. Запомните, что я сказал про расположение стека: отсутствие указателя фрейма мешает gdb находить расположение сохраненного возвращаемого адреса в фрейме №2.


Опции, относящиеся к отладке.



Каждый иногда нуждается в отладке его или её кода. Когда это время приходит, обычно вы запускаете gdb, ставите точки останова там и тут, анализируете бэктрейсы, и так далее, чтобы выявить расположение нарушающего работу кода. А что получаете на самом деле? Если вы не используете опции отладки, вы просто получаете адрес, указывающий на регистр EIP.

Вот в чем проблема, в действительности вы не хотите адрес. Вы хотите, чтобы gdb или другой отладчик просто показал требуемые строки. Но gdb не может этого сделать без определенного вида помощи. Эта помощь, называемая отладкой с приписываемыми форматами записи (Debugging With Attributed Record Formats — DWARF), помогает вам выполнять отладку на уровне исходного кода.

Как это сделать? Используйте -g при компиляции в объектный код, то есть:
  gcc -o -g test test.c

Что такого добавляет gcc, что отладчик может сопоставлять адрес с исходным кодом? Вам нужна dwarfdump [7] чтобы узнать это. Это утилита находится внутри тарболла libdwarf или в RPM, так что вы не найдете её в виде отдельного пакета. Скомпилируйте её сами или просто установите из репозитория вашего дистрибутива; оба варианта должны сработать. В этой части статьи я использую версию 20060614 RPM.

Используя readelf, вы можете отметить, что в неотлаженной версии первого листинга существует 28 разделов:
 $ readelf -S ./non-optimized

Но в отлаженной версий есть 36 разделов. Новые разделы:
* debug_arranges
* debug_pubnames
* debug_info
* debug_abbrev
* debug_line
* debug_frame
* debug_str
* debug_loc
Вам не нужно копаться во всех этих разделах; для быстрого изучения, будет достаточно рассмотреть .debug_line. Команда, которая вам нужна:
$ /path/to/dwarfdump -l 
Вот пример того, что вы получите:
 .debug_line: line number info for a single cu
   Source lines (from CU-DIE at .debug_info offset 11):
         [row,column]        //
Интерпретация этих сообщений довольно простая. Возьмите первую запись (идущую за строкой ) в качестве примера:
  line number 3 in file non-optimized.c is located in address 0x8048384.

gdb дает ту же информацию:
 $ gdb non-optimized-debugging
   (gdb) l *0x8048384
   0x8048384 is in main (./non-optimized.c:3).

readelf также предоставляет похожую информацию, используя --debug-info:
 $ readelf --debug-dump=line 
   Line Number Statements:
   Extended opcode 2: set Address to 0x8048384
   Special opcode 7: advance Address by 0 to 0x8048384 and Line by 2 to 3
   Advance PC by constant 17 to 0x8048395
   Special opcode 7: advance Address by 0 to 0x8048395 and Line by 2 to 5
   ....
И readelf, и dwarfdump могут анализировать информацию отладки, так что вы вольны выбирать сами.

Что вы должны понимать, так это то, что исходный код сам по себе не встроен в объектный файл. На самом деле, отладчик должен проверять отдельный файл исходного кода. Запись в колонке помогает определять, откуда загружать файл исходного кода. Учтите, что она содержит полный путь, что значит невозможность его нахождения gdb в случае перемещения или переименования.

gcc сам имеет возможность давать много информации об отладке. Кроме DWARF, существуют:
* Stabs: -gstabs производит собственный формат stabs, в то время, как -gstabs+ включает в себя специфичные расширения GNU.
* Common Object File Format (COFF): Создается с помощью -gcoff.
* XCOFF: Создается с помощью -gxcoff. Если вы предпочитаете включать расширения GNU, используйте -gxcoff+.
* Virtual Memory System (VMS): Создается с помощью -gvms.

Каждый из этих форматов описаны в ссылках ([8], [9] и [10]), но для x86-совместимой архитектуры, без проблем, вам лучше использовать формат DWARF. Последней спецификацией DWARF является DWARF третьей версии, и gdb может создавать её с помощью -gdwarf-2. Это может ввести в заблуждение новых пользователей, так как вы можете подумать, что такая опция создаст информацию DWARF 2. На самом деле, DWARF 2 объединен с некоторыми возможностями DWARF 3. Не каждый отладчик поддерживает третью версию, поэтому используйте её с осторожностью.

Хотя не всегда все идет так гладко. Когда вы комбинируете опции -O и -g, для информации о строке необходимо установить связь с фактическим кодом в упомянутом сдвиге адреса. Пример может это пояснить. Возьмите файл (я снова использую первый листинг) и скомпилируйте его:
 $ gcc -O2 -ggdb -o debug-optimized listing-one.c
   $ readelf --debug-dump=line debug-optimized
   ..
   Special opcode 107: advance Address by 7 to 0x80483aa and Line by 4 to 11
   ...

Но что говорит gdb?
 $ gdb debug-optimized
   (gdb) l *0x80483aa
   0x80483aa is in main (./non-optimized.c:11).
   ...
   11              printf("acc = %lu\n",acc);
   ...
   (gdb) disassemble main
   ...
   0x080483aa :   add    $0x6,%edx
   0x080483ad :   cmp    $0x1388,%eax
   ...

Здесь вы видите полное расхождение. Изучая одну информацию об отладке, вы будете ожидать, что указанный адрес содержит что-то вроде инструкции CALL. Но в действительности, вы получите инструкции ADD и CMP, что больше похоже на конструкцию цикла. Это побочный эффект действий оптимизации — в этом случае меняется порядок инструкций. Так что возьмите себе за правило не смешивать опции -g (или её варианты) c -O.


Опции, управляющие стадиями компиляции.



В целях изучения, иногда вы хотите узнать, как ваш исходный код трансформируется в исполняемый. К счастью, gcc предоставляет вам опции для остановки на любой стадии обработки. Вспомните, что gcc имеет несколько стадий завершения, — например, компоновку. Есть такие опции:
* -c останавливает на стадии ассемблирования, но пропускает компоновку. Результатом является объектный код.
* -E останавливает на стадии препроцессинга. Все директивы препроцессора развернуты, так что вы видите только чистый код.
* -S останавливает после компиляции. Она оставляет вас с ассемблерным кодом.

-c наиболее часто используется, когда у вас есть несколько исходных файлов и вы хотите скомбинировать их для получения итогового исполняемого файла. Так что, вместо такого:
$ gcc -o final-binary test1.c test2.c

будет лучше разделить их так:
$ gcc -c -o test1.o test1.c
$ gcc -c -o test2.o test2.c

и затем:
$ gcc -o final-binary ./test1.o ./test1.o

Возможно, вы заметили, что такая же последовательность используется, если вы собираете программу, используя Makefile. Преимущество использования -c ясно: вам нужно перекомпилировать только измененные исходные файлы. Только фаза, на которой переделывается компоновка всех объектных файлов, и это очень экономит время, особенно в больших проектах. Очевидным примером этого является ядро Linux.

-E будет полезна, если вы хотите посмотреть, как ваш код в действительности выглядит после разворачивания макросов, определений и т.п. Возьмите следующий листинг в качестве примера:
#include 

#define A 2
#define B 4
#define calculate(a,b) a*a + b*b

void plain_dummy()
{
    printf("Just a dummy\n");
}

static inline justtest()
{
    printf("Hi!\n");
}

int main(int argc, char *argv[])
{
#ifdef TEST
    justtest();
#endif
    printf("%d\n", calculate(A,B));
    return 0;
}

Скомпилируем его следующим образом:
$ gcc -E -o listing2.e listing2.c

Учтите, что мы не указываем параметров -D, что означает, что TEST не определен. Так и что мы имеем в препроцессорном файле?
void plain_dummy()
{
    printf("Just a dummy\n");
}

static inline justtest()
{
    printf("Hi!\n");
}

int main(int argc, char *argv[])
{
    printf("%d\n", 2*2 + 4*4);
    return 0;
}

А где вызов justtest() внутри main()? Нигде. TEST не определен — вот почему код исключен. Вы также можете видеть, что макрос calculate уже развернут в умножение и сложение констант. В конечной исполняемой форме, эти числа будут заменены результатами операций. Как вы видите, -E довольно удобна для проверки корректности директив.

Обратите внимание, что plain_dummy() все ещё здесь, не смотря на то, что она ни разу не вызывается. Это не удивительно, так как ещё не была произведена компиляция, вот почему не произошло исключение «мертвого» кода на этой стадии. stdio.h также развернут, но не показан в листинге выше.

Я нашел интересное приложение использования -E в качестве утилиты создания HTML. Вкратце, она помогает вам перенимать обычные действия в программировании, такие как модуляризация кода и макросы в мир HTML — то, что не может быть сделано на чистом HTML.

-S дает вам код на ассемблере, больше похожий на то, что вы видите с помощью objdump -d/-D. Хотя с помощью -S вы продолжите видеть директивы и имена символов, который делают код проще к изучению. Например, вызов printf("%d\n", 20) может быть трансформирован в:
.section        .rodata.str1.1,"aMS",@progbits,1
.LC0:
        .string "%d\n"
...
        movl    $20, 4(%esp)
        movl    $.LC0, (%esp)
        call    printf

Вы можете видеть, что format string %d помещена в область данных, доступную только для чтения (.rodata). Также, вы можете удостовериться, что аргументы помещаются в стек справа налево, со строкой форматирования на верху стека.


Заключение.



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


Благодарности.



Я хочу поблагодарить сообщества на irc каналах OFTC (#kernelnewbies и #gcc) и #osdev (Freenode) за их полезные идеи.


Ссылки


1. Статья в Википедии о Препроцессоре;
2. Статья в Википедии о Компиляторе;
3. Статья в Википедии об Ассемблере;
4. Статья в Википедии о Компоновщике.
5. Пример переупорядочивания кода (англ.);
6. Frame pointer omission (FPO) optimization and consequences when debugging, Часть 1 и Часть 2 (англ.);
7. Описание DWARF (англ.);
8. Описание stabs (англ.);
9. Описание COFF (англ.);
10. Описание XCOFF (варианта COFF) (англ.);
11. Использование препроцессора C в качестве утилиты создания HTML (англ.);
12. Документация по gcc (англ.);
13. AMD Athlon Processor x86 Code Optimization Guide (англ., pdf).


Теги: programming, gcc, nix

Статьи с такими же тегами:

Введение в Lua
Делаем веб-сервер легче вместе с thttpd.