Skip to content

Latest commit

 

History

History
363 lines (281 loc) · 23.9 KB

compiler.md

File metadata and controls

363 lines (281 loc) · 23.9 KB

Работа с компилятором

Компилятор GCC предоставляет множество опций командной строки. Полезно будет ознакомиться с основными из них. Список всех опций и их описание можно прочитать на сайте GCC.

Заметим что Clang во многом совместим с GCC и описанные здесь основные опции работают и с Clang (если явно не сказано обратное). У Clang также имеется подробная документация.  

 

 

Функции драйвера компилятора

Программа gcc (а также g++) --- это так называемый драйвер компилятора, т.е. обёртка, служащая для запуска других программ --- компилятора в узком смысле (программы, "переводящей" другие программы с языка высокого уровня на язык ассемблера), ассемблера и компоновщика. По умолчанию g++ запускает все три программы (компилятор, ассемблер и компоновщик) последовательно. Таким образом, исполнение

g++ test.cpp

создаст сразу исполняемый файл с программой, исходный текст которой взят из файла test.cpp. Имя выходного файла по умолчанию --- a.out. Для того чтобы задать произвольное имя выходного файла используется ключ -o:

g++ test.cpp -o test

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

g++ -o test test.cpp

Для того чтобы получить файл с объектным кодом (т.е. пропустить программу последовательно через компилятор и ассемблер) служит ключ -c:

g++ -c test.cpp -o test.o

Если же требуется получить файл на языке ассемблера, следует использовать ключ -S:

g++ -S test.cpp -o test.s

Скомпоновать несколько объектных файлов в один исполняемый можно следующим образом:

g++ file1.o file2.o -o myprogram

При компоновке важно использовать правильный драйвер: g++ для программ, написанных на языке C++ и gcc для программ на языке C. От выбора драйвера зависит набор стандартных библиотек, который будет по умолчанию подключен к программе.

В операционной системе OS X за файлами gcc и g++ скрывается компилятор Clang, существенная часть которого разработана самой компанией Apple. До того как Clang был создан, в OS X использовался GCC. Чтобы не ломать сборку проектов, в которых явно указано имя файла gcc или g++, инженеры Apple решили оставить файлы с такими именами.

Выбор языка и стандарта

Язык программы (C либо C++) определяется драйвером исходя из расширения файла и не зависит от того, какой драйвер (gcc или g++ используется для компиляции). При необходимости язык можно задать явно (с помощью опции -x), но лучше, разумеется, пользоваться общепринятыми расширениями файлов: .c для программ на языке C и .cpp (или .cc) для языка C++.

Компилятор GCC поддерживает несколько стандартов языков C и C++ различающихся набором доступных возможностей языка. Версия стандарта задаётся при помощи ключа -std=, например:

g++ -c test.cpp -std=c++14

задаёт стандарт C++14. GCC поддерживает некоторые расширения языков C и C++ (их список весьма внушительный). Чтобы разрешить их использование, используются обозначение стандарта в котором c заменяется на gnuc++ на gnu++). Например:

gcc -c test.c -std=gnu11

запускает компиляцию с использованием стандарта C11 с включенными расширениями.

Пути к заголовочным файлам и макроопределния

Если вы используете в программе директиву препроцессора вида #include <header.hpp>, компилятор должен каким-то образом найти в файловой системе заголовочный файл header.hpp. По умолчанию, GCC ищет файлы в системных путях, заданных ещё в процессе сборки самого компилятора, таких как /usr/include. Часто требуется добавить к ним дополнительные пути к исползуемым библиотекам. Для этого служит опция командной строки -I. Например:

g++ -c file.cpp -Ithird-party/include -Iinclude

добавляет к списку путей директории third-party/include и include (которые должны находиться в текущей директории).

Напомним, что при использовании директивы #include с кавычками, т.е. #include "header.hpp" для поиска также используется директория, в которой находится компилируемый файл.

В некоторых случаях поведением программы удобно урпавлять с помощью макросов. Например, библиотека glibc определяет макрос assert, поведение которого изменяется в зависимости от того, определён ли макрос NDEBUG. А именно, если NDEBUG определён, то assert никак не влияет на поведение программы. Если же он не определён, то assert проверяет, истинно ли значение, переданное ему в качестве аргумента, и если оно ложно, программа аварийно завершается. Такое поведение полезно при отладке, но в дистрибутиве программы, передаваемой пользователю, лишние проверки нежелательны, т.к. отрицательно сказываются на производительности программы. Поэтому при компиляции выпускаемой версии программы определяют макрос NDEBUG:

g++ -c program.cpp -DNDEBUG

Опция -D опредяет макрос NDEBUG так, как если бы в первой строке программы была директива

#define NDEBUG

С помощью опции -D можно также задать значение определяемого макроса:

g++ -c program.cpp -DVERSION="1.1"

эквивалентно добавлению строки

#define VERSION "1.1"

в начало компилируемого файла.

Опции, влияющие на программы с неопределённым поведением

Отличительной чертой языков С и C++ является т.н. "неопределённое поведение": стандарты этих языков подразумевают, что программист позаботится о том, чтобы в программе отсутствовали некоторые виды поведения, например, обращение к неинициализированным переменным. Компилятор, в свою очередь, может полагаться на это, поскольку стандарт не предъявляет к компилятору никаких требований относительно того, какой именно код должен сгенерировать компилятор при наличии в программе неопределённого поведения. Такое соглашение между авторами стандарта, авторами компилятора и программистами позволяет во многих случаях генерировать код с максимальным быстродействием. В то же время, обеспечить отсутствие в программе некоторых видов неопределённого поведения трудно, а задача проверки кода на отстуствие неопределённого поведения алгоритмически неразрешима. Поэтому авторы компилятора GCC добавили несколько опций, которые позволяют "доопределить" стандарты языков C и C++, придав некоторым случаям неопределённого поведения вполне конкретную семантику.

В частности, опция -fwrapv (от "wrap oVerflow") определяет, что арифметические операции над целыми числами со знаком должны выполняться с циклическим переполнением. К примеру, согласно стандарту C++, в следующем примере

int x;
// ...
if (x + 1 > x) {
    // ...
}

переполнение при вычислении x + 1 является неопределённым поведением, поэтому компилятор может предположить, что этого никогда не произойдёт, а условие x + 1 > x всегда истинно и удалить код, отвечающий за проверку этого условия. Однако, если задать опцию -fwrapv компилятор будет основываться на том, что переполнение циклическое, поэтому x + 1 может быть отрицательным (например, при 32-битном int, 0x7FFFFFFF + 1 равно 0x80000000 или -2147483648) и не будет удалять проверку.

Ещё одно проблематичное для многих правила языков C и C++ --- это правило о строгом соответствии псевдонимов (strict aliasing) и связанная с ним оптимизация --- анализ псевдонимов, основанный на типах (type based alias analysis). Согласно стандартам языков C и C++, через указатель на тип T допускается получать доступ только к значениям типа T. Исключение составляют указатели на символьный тип (char, signed char и unsigned char), т.к. через них можно считывать значения других типов. Например, следующий код не вызывает неопределённого поведения:

int x = 5;
const char* p = (char *)&x;
return *p;

а вот этот пример --- вызывает:

double x = 1.0;
return *(int *)(&x);

За счёт данного правила компилятор может в следующем фрагменте кода

int foo(int* x, double* y)
{
    y[0] = x[0];
    return x[0];
}

считать, что присваивание y[0] = x[0] не меняет значения x[0] (поскольку указатели x и y различаются) и выполнить одну операцию чтения из памяти вместо двух.

Ключ -fno-strict-aliasing отключает данный вид анализа, т.е. заставляет компилятор предполагать, что любые два указателя могут быть псевдонимами друг друга, если обратное не следует, например, из правил адресной арифметики.

Предупреждения

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

Рассмотрим такой пример:

if (x = 0) {
    std::cout << "x is zero!";
}

Ошибка здесь заключается в том, что вместо оператора сравнения == был использован оператор присваивания =. Условие в if всегда ложно и поэтому строка "x is zero!" никогда не будет выведена. Несмотря на явную ошибку, этот код абсолютно корректен с точки зрения стандарта C++. Если мы попробуем скомпилировать его с включенными предупреждениями

g++ -с -Wall test.cpp

то получим сообщение от компилятора:

test.cc: In function ‘int main()’:
test.cc:6:11: warning: suggest parentheses around assignment used as truth value [-Wparentheses]
  if (x = 0) {
           ^

GCC сообщает нам, что если мы действительно не имели в виду сравнение, а хотим использовать результат присваивания в качестве булева значения, то следует заключить его в скобки.

Большинство предупреждений имеют отдельный ключ командандной строки, который отвечает только за данное предупреждение. Также существуют ключи, активирующие сразу целую группу предупреждений.

Так, предупреждения, которые с высокой вероятностью вызваны ошибкой в программе активируются опцией -Wall. Предупреждения, активируемые опцией -Wextra, могут в некоторых случаях вызывать ложно-положительные срабатывания, но в большинстве случаях также полезны. Рекомендуется использовать как -Wall, так и -Wextra в своих проектах.

Наконец, предупреждения, не включенные ни в одну из этих групп, могут быть полезны в зависимости от стандартов кодирования, применямых в конкретном проекте. Например, разработчики проекта могут договориться о том, чтобы всегда помечать перекрываемые виртуальные методы с помощью ключевого слова override. В этом случае полезно будет задействовать опцию -Wsuggest-override (данная опция присутствует только в GCC начиная с версии 5), чтобы компилятор выдывал предупреждение о виртуальных методах, в объявлении которых ключевое слово override отсутствует.

Оптимизации компилятора

Оптимизатор присутствует в большинстве современных компиляторов С и C++, таких как GCC, Clang и MSVC. Функция оптимизатора заключается в том, чтобы преобразовать исходную программу в эквивалентную ей семантически, но работающую быстрее. Оптимизатор GCC организован в виде конвеера, состоящего из т.н. проходов. Каждый проход выполняет некоторый вид преобразований программы, хранящейся в памяти в виде промежуточного представления. Это представление последовательно подаётся на вход каждого из проходов (этапов конвеера). Как и предпреждения, оптимизации объединены в группы, активируемые различными ключами командной строки. Так, ключ -O1 (либо -O) включает наиболее простые и быстро выполняемые оптимизации, -O2 выполняет также и более "дорогие" оптимизации. Наконец, в -O3 входят сложные оптимизации, порой имеющие квадратичное (по количеству кода в отдельной функции программы) время работы. Ключ -Os настраивает оптимизатор так, чтобы генерировать код меньшего размера (иногда в ущерб быстродействию). Такой режим полезен при компиляции программ, предназначенных для встраиваемых систем (например, микроконтроллеров).

Чтобы получить общее представление о том, как работает оптимизатор, рассмотрим в качестве примера один из проходов оптимизатора --- встраивание функций (inlining). Встраивание выполняет подстановку тела функции в место её вызова. Например, такой код:

int add2(int x)
{
    return x + x;
}

int add3(int x)
{
    return x + add2(x);
}

Можно преобразовать в эквивалентный:

int add2(int x)
{
    return x + x;
}

int add3(int x)
{
    return x + x + x;
}

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

int add3(int x)
{
    int __add2_x = x;
    int __add2_result = __add2_x + __add2_x;
    return x + __add2_result;
}

От лишних операций копирования помогут избавиться последующие этапы конвеера: проход распространения констант и копий (constant and copy propagation), а также проход устранения мёртвого кода (dead code elimination). Как видно из этого примера, выполнение одной оптимизации может предоставить возможность выполнить другие, поэтому конвеер оптимизатор устроен достаточно сложно: некоторые проходы выполняются несколько раз, а их порядок тщательно выверен.

Оптимизация позволяет значительно увеличить производительность программы: зачастую оптимизированный код выполняется в 1,5-3 раза быстрее неоптимизированного. Разумеется оптимизации не бесплатны: компиляция программы с включённой оптимизацией выполняется медленнее. Ещё один недостаток оптимизированного кода --- меньшее удобство в отладке (например, в отладчике может быть недоступен просмотр значений некоторых переменных). Частично этот недостаток устраняется опцией -Og (доступна только в GCC): она выключает те проходы оптимизатора, которые отрицательно сказываются на качестве отладочной информации. Производительность кода с этой опцией примерно соответствует производительности -O1.

Отладочная информация

Отладочная информация содержит соответствия между элементами бинарного кода (адреса, регистры процессора) и исходного кода (номера строк, имена функций и переменных) программы. Запись отладочной информации в генерируемые компилятором файлы включается с помощью ключа -g. Ключ -ggdb3 включает вывод дополнительной информаций, специфичной для отладчика GDB. Подробнее об отладке (и отладочной информации) можно прочитать в статье Отладка программ с помощью GDB.