Вопросы на повторение:
- Что такое компилятор?
- Зачем писать новые компиляторы?
- Можно ли будет любую программу этого курса скомпилировать на Apple M1, например?
- Какие есть этапы компиляции?
В это курсе мы будем использовать компилятор gcc
. В простейшем случае, чтобы скомпилировать программу из одного файла, можно просто написать gcc file.c
. Называться по-умолчанию выходной бинарный файл будет a.out
. Переопределить это имя можно с помощью опции -o
.
Давайте рассмотрим этапы компиляции на примере простой программы aplusb.c
.
- Препроцессинг: разворачиваются
include
иdefine
, удаляем комментарии. Выполним только эту стадию:gcc -E aplusb.c -o aplusb_preprocessed.c
Что за комментарии препроцессор оставляет в начале? Хорошо написано здесь: https://stackoverflow.com/questions/49109904/how-to-interpret-prefixed-lines-in-c-preprocessor-output
- Компиляция: код на C превращается в ASM
На самом деле ASM не обозначает какой-то один язык. Это семейство низкоуровневых платформоспецифичных языков. На курсе мы с вами будем разбирать ARM и x86 диалекты, но есть и множество других для менее известных архитектур.
gcc -S aplusb_preprocessed.c -o aplusb_asm.S
- Ассемблирование: код на ассемблере преобразуется в машинный код.
gcc -c aplusb_asm.S -o aplusb_object.o
В целом, ассемблер и так сильно к нему приближен. С помощью утилиты objdump
можно удобно рассматривать объектные файлы. В частности, флаг -d
позволяет посмотреть на ассемблерный код.
- Компоновка/Линковка: объектные файлы собираются в один бинарный (на этом этапе разрешаются зависимости, например
glibc
)
gcc aplusb_object.o -o aplusb_executable.out
Удобно выполнять сборку одно командой gcc
в случае, если надо собрать проекты из пары файлов. Для больших проектов нужен некий инструмент автоматизации, системы сборки. Одна из простейших из них -- make
. Makefile
состоит из целей, которые берут некоторые зависимости и применяют к ним команды сборки.
Основной формат (в качетсве отступа используется именно табуляция)
target: prerequisite1 ... prerequisite_n
command_1
...
command_n
В качестве примера можно посмотреть на Makefile
в этой папке.
Make не хранит своё состояние. Поэтому пересборка цели не выполняется лишь в одном случае: имя цели является файлом, и время его модификации больше времени модификации всех зависимостей цели.
Мы обсудили простейший сценарий. На самом деле, Make поддерживает циклы, функции и так далее. Подробнее про это можно почитать на https://makefiletutorial.com/. Ещё подробнее в официальной документации: https://www.gnu.org/software/make/manual/make.html
Возможно, вы уже дебажили с помощью логирования. Но иногда удобнее воспользоваться отладчиком. Опция -g
позволяет снабдить файл дополнительной информацией для отладки.
gcc -g aplusb.c -o aplusb_debug.out
Запустим бинарный файл под отладчиком gdb
gdb ./aplusb_debug.out
Поставим точку останова: b 7
Запустим исполнение: run
.
С помощью layout src
можно посмотреть на исходный код.
Дальше с помощью ni
можно переключаться на следующую инструкцию (если нужно зайти в функции, используем si
).
С помощью print
можно вывести значения переменной.
Чтобы выйти из отладки, можно использовать команду q
.
Шпаргалка по GDB https://darkdust.net/files/GDB%20Cheat%20Sheet.pdf
Попробуем под gdb
запустить падающую программу.
gcc -g bad.c -o bad.out
Чтобы сразу выполнить программу, можно передать команды gdb
в аргумент ex
.
gdb -ex=r ./bad.out
Видим строчку, в которой произошёл Segfault.
С помощью команды bt
можно вывести stacktrace.
Дальшё с помощью frame 0
можно переключиться на верхний фрейм.
info args
покажет аргументы, с которыми была вызвана функция.
info locals
выведет локальные переменные.
В прошлых двух мы тоже вышли за границы массива, но почему-то не упали. Узнаете, почему, когда сходите на лекцию про виртуальную память процесса.
В жизни часто возникает необходимость проанализировать на падение программы, в которой были отладочные символы, но запущена она была не под дебаггером. Для этого используются специальные coredump
файлы, которые сохраняют состояние процесса на момент падения.
Попросим складывать файлы в текущую папку:
sudo sysctl kernel.core_pattern="./coredump"
Разрешим создавать coredump
файлы большого размера и выполним программу.
ulimit -c unlimited; ./bad.out
Теперь можно проанализировать coredump
.
gdb ./bad.out ./coredump
Ну а дальше можно повторить все манипуляции, которые мы делали ранее.
Обычно проблемы целесообразно ловить на как можно более ранних этапах. В прошлом пункте мы разобрались, как можно проанализировать упавшую программу, в этом разбёремся, как частично отлавливать их на этапе компиляции. Adress Sanitizer позволяет отлавливать выход за границы массива, утечки памяти и некоторые другие ошибки. Подробнее можно почитать по ссылке.
gcc -fsanitize=address bad.c -o bad.out
Можно заметить, что размер бинарного файла увеличился, потому что сгенерировались некоторые дополнительные проверки. Исполнение из-за них также замедлится примерно в 2 раза, но это допустимо для отладочной сборки Зато теперь, если мы запустим программу, при падении увидим сильно более понятное сообщение об ошибке.
На лекции рассказали про то, что ядро предоставляет некоторый API системных вызовов. Они используются, например, для взаимодействия с железом, создания процессов и межпроцессорной коммуникации. С помощью утилиты strace
можно посмотреть список системных вызовов, которые вызывает ваша программа.
strace ./bad.out
В начале мы выделяем память, потом загружаем в неё динамические библиотеки, после этого и записываем данные с помощью системных вызова write
, и, наконец, завершаем программу с помощью системного вызова exit_group
.