Компилятор: Путь от исходника до исполняемого файла. Обзор и практика на gcc
• В постах про AST, CFG и компоновщик мы разбирали отдельные части этого пути, сегодня соберём их в одну картину и пройдём её руками.
• Компилятором обычно называют всю цепочку, но на деле это несколько отдельных программ, а
gcc лишь запускает их по очереди: cc1 (препроцессор и компилятор), as (ассемблер) и collect2 (обёртка над компоновщиком ld).• Проверим это на простой программе
hello.c, команды ниже для Linux с gcc (на Windows подойдёт WSL):#include <stdio.h>
#define N 3
int main(void) {
int a = N * 2;
printf("%d\n", a);
return 0;
}
• Чтобы увидеть, кого вызывает
gcc, запустите gcc -v hello.c и найдите в выводе строки с cc1, as и collect2. Теперь пройдём цепочку по шагам.• Шаг 1. Препроцессор:
gcc -E hello.c -o hello.i. Он раскрывает #include и #define: содержимое stdio.h подставляется в файл (получаются сотни строк), а в конце видно int a = 3 * 2;, то есть макрос N уже заменён на 3.• Шаг 2. Компиляция в ассемблер:
gcc -S -masm=intel hello.c -o hello.s. Внутри компилятор строит AST, проверяет типы, переводит код в промежуточное представление (IR), оптимизирует его (в том числе на основе CFG) и генерирует ассемблер. Вот что получилось (служебные директивы убраны, у вас вывод может слегка отличаться):main:
push rbp
mov rbp, rsp
sub rsp, 16
mov DWORD PTR -4[rbp], 6
...
call printf@PLT
...
ret
• Обратите внимание на
6: выражение 3 * 2 компилятор посчитал сам, до запуска программы.• Шаг 3. Ассемблер:
gcc -c hello.c -o hello.o. Получился объектный файл с символами, релокациями и секциями из цикла про компоновщик. Команда nm hello.o покажет символы:0000000000000000 T main
U printf
•
main определён в этом файле (T), а printf нет (U), его адреса компилятор не знает. Как же тогда собран вызов? Посмотрим objdump -dr -M intel hello.o:27: e8 00 00 00 00 call 2c <main+0x2c>
28: R_X86_64_PLT32 printf-0x4
• Нули после
e8 (код call) это заглушка, а строка под ней релокация: "подставь сюда смещение до printf с поправкой -4".• Шаг 4. Компоновщик:
gcc hello.o -o hello, затем ./hello выведет 6. Если снова вызвать objdump -d -M intel hello, на месте нулей окажется реальное смещение: call 1050 <printf@plt> (адрес у вас может отличаться). Компоновщик выполнил релокацию. Сам printf лежит в libc, поэтому nm hello покажет его всё ещё неопределённым, а адрес найдёт динамический компоновщик при запуске.• Теперь оптимизации. Возьмём такой код (
sq.c):int square(int x) { return x * x; }
int main(void) {
return square(5);
}• Сравните вывод
gcc -S -masm=intel -O0 sq.c и -O2. На -O0 main честно вызывает square, а на -O2 остаётся только это:main:
mov eax, 25
ret
• Компилятор встроил
square в main (inlining) и посчитал 5 * 5 заранее (constant folding). Поэтому дизассемблированный оптимизированный код так сильно отличается от исходника.• Промежуточное представление тоже можно увидеть:
gcc -c -fdump-tree-gimple hello.c сохранит IR GCC в файл вида hello.c.006t.gimple, а clang -S -emit-llvm hello.c покажет LLVM IR.• Вот небольшое задание, если хотите потренероваться: возьмите любую свою функцию с циклом и сравните её ассемблер на -O0 и -O2. Проще всего это делать в Compiler Explorer. Удачи!
#Компилятор
• Поддержать автора монеткой: @v_meshke
