Изучаем параметры gcc

Ссылка на оригинал: Getting Familiar with GCC Parameters

Ссылка на перевод: http://netsago.org/ru/docs/1/9/

С версии: 1.5

Перевод: n0xi0uzz



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


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

Напомню, что gcc делает несколько шагов, а не только один. Вот небольшое объяснение их смысла:




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

gcc предоставляет очень простой способ производить оптимизацию:

опция -O. Она и ускоряет выполнение вашего кода, и сжимает размер итогового кода. У неё существует пять вариантов:

#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

real

0.728

0.1

0.1

0.1

user

0.728

0.097

0.1

0.1

sys

0.000

0.002

0.000

0.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-optimized

OptimizedO1

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=

...

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]        //

 /code/./non-optimized.c:  [  3,-1]        0x8048384       // new statement

 /code/./non-optimized.c:  [  5,-1]        0x8048395       // new statement

 ...............

Интерпретация этих сообщений довольно простая. Возьмите первую запись (идущую за строкой

  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 могут анализировать информацию отладки, так что вы вольны выбирать сами.

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



 $ 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 имеет несколько стадий завершения, — например, компоновку. Есть такие опции:

$ 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).