Си без #include MineOzelot, Картона
Может, вообще стоит начинать изучать Си без препроцессора. Из-за него много проблем с пониманием…
— Неизвестный, 2019 г.

Напишу пару постов с потоком мыслей из головы о том, как работает сборка программы на Си. Как могу и что хочу. Спрашивайте вопросы и советуйте советы и Кот может сказать, что ему не нравится, потому что этот пост он сможет увидеть.

Код в этих постах будет написан для компилятора gcc, с совместимостью с компилятором clang. То есть, не с обязательной совместимостью с компилятором msvc, да. Этот пост вообще не очень совместим с Windows, а ориентирован на Linux и юникс-подобные системы. Версия языка как минимум C99. И лучше иметь опыт в программировании и обращении с юниксовым терминалом перед чтением этого поста.

Все должно быть просто, потому что Си — это просто.
C++ — это другая история. Большая часть описанного ниже неверно для C++.

Кат.


Представим, что вы разобрались с тем, как установить на ваш компьютер компилятор Си. И даже запустили один раз. Вот так:
$ gcc --version
gcc (GCC) 9.2.1 20190827 (Red Hat 9.2.1-1)
Copyright © 2019 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.


Если вы хотите использовать clang, то замените «gcc» на «clang» вот так:
$ clang --version
clang version 9.0.1 (Fedora 9.0.1-2.fc31)
Target: x86_64-unknown-linux-gnu
Thread model: posix
InstalledDir: /usr/bin


Начнем, как всегда, с простой, всей знакомой программы, которая выводит в терминал строку «Hello, World!».
extern int printf(const char *, ...); //Объявление функции printf, которая печатает строку в терминал

int main(void) { //Определение функции main, с которой начинается исполнение программы
  printf("Hello, World!\n"); //Печатаем строку, используя функцию printf

  return 0; //main возвращает 0, так как программа успешно выполнена
}

Создайте в текстовом редакторе файл hello.c и запишите в него этот код. Затем соберите программу, используя:
$ gcc hello.c -o hello

И, чтобы запустить получившийся исполняемый файл:
$ ./hello

Программа выведет на экран «Hello, World!» и завершится.

Разберемся, что здесь происходит.
Файл с кодом на Си — это наши объяснения компилятору, что должен будет делать исполняемый файл, который мы хотим от него получить. Функция в Си — это некоторая последовательность операций, имеющая свое имя. Одни функции могут вызывать другие функции по их имени. При этом вызывающая функция может передать вызываемой функции какие-то данные — аргументы, так и вызываемая функция может передать вызывающей функции что-то обратно — возвращаемое значение. Типы аргументов, принимаемых функцией, определяются параметрами (гугли «фактические и формальные параметры»). В коде нашей программы есть две функции. «main» — это функция, которая начинает выполняться, когда мы запускаем исполняемый файл. main возвращает целое число (int) — 0, если программа завершается успешно, или 1, если произошла ошибка. Это просто договоренность. (void) в параметрах main означает, что эта функция не принимает какие-либо аргументы. И если их пытаются передать, то это ошибка. Вторая функция — printf, которую мы объявляем.

Следует понять разницу между понятиями «объявление» и «определение». Когда мы рассказываем компилятору о том, что какой-то объект в принципе существует — это объявление. Когда мы рассказываем компилятору о том, что этот объект делает и представляет из себя — это определение. Определение включает в себя объявление.

Во время сборки, компилятор читает данный ему файл построчно и запоминает все объявления, которые встретил. Когда он натыкается на вызов какой-либо функции, он ищет ее в списке объявлений. В отличие от компиляторов некоторых других языков, компилятор Си обрабатывает только один исходный файл за раз, считая каждый отдельной единицей трансляции.

extern int printf(const char *, ...); //Объявление функции printf, которая печатает строку в терминал

Здесь мы говорим компилятору, что функция printf существует. Просто ее определение находится не в этом файле, а в другой единице трансляции. В точности, в стандартной библиотеке Си, которая будет присоединена к нашей программе на поздних этапах сборки. Компилятор верит нам, и просто ведет себя так, будто функция есть.
В этой строчке мы также щедро рассказываем компилятору о функции printf, давая ему даже больше информации, чем ему нужно на самом деле. Если мы изменим ее так:
extern int printf(); //Объявление функции printf, которая печатает строку в терминал

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

Сборку программы на Си можно разбить на три этапа:
1. Препроцессор просматривает входные файлы и выполняет свои директивы. Препроцессор — это просто инструмент работы с текстом. Его так же можно использовать на любой текст, не только на код. Хоть книгу с ним пиши. Директивы препроцессора начинаются с # в начале строки. С их помощью можно включать один файл в другой или вырезать куски кода под условия окружения. Результатом работы препроцессора является модифицированный текст входного файла.
2. Компилятор берет модифицированный препроцессором текст и производит трансляцию, для каждого входного файла независимо. Компилятор никогда не смотрит в соседние файлы в поисках функций или переменных. Оставьте это Java. Результатом работы компилятора являются объектные файлы — файлы-полуфабрикаты. В них уже находится оптимизированный транслированный код. Но так же в них находятся подсказки для следующего этапа сборки — команды вроде «вставь ту штуку сюда» и «найди вот эту функцию и прицепи ее здесь».
3. Последний этап — компоновка. Компоновщик собирает множество объектных файлов и библиотек в исполняемый файл. Как конструктор. Если в объектном файле написано, что надо найти функцию «printf» и прицепить ее к вызову в этом объектном файле, то компоновщик ищет эту функцию среди всех данных ему кусочков и соединяет их. Если компоновщик не сможет найти какую-либо запрошенную функцию, то он скажет об этом программисту своим знаменитым «undefined reference» и завершит сборку с ошибкой.

Именно на этапе компоновки происходит поиск и присоединение к программе внешних зависимостей. Если сборка происходит через команды gcc или clang, то компоновщик по умолчанию подключает к программе стандартную библиотеку Си. Можно попробовать собрать программу, общаясь с компоновщиком вручную, в юниксах его, обычно, зовут ld. Но, на самом деле, без стандартной библиотеки Си никто не будет искать даже функцию main, поскольку вызов на main находится в библиотеках C Runtime.

Если в программе используются внешние зависимости, то никакой компилятор их искать не будет. Даже если вы напишете #include — вы просто добавите еще порцию неопределенных ссылок. Чтобы позволить программисту подключать свои библиотеки, команды gcc и clang предоставляют флаги, через которые программист может общаться с компоновщиком — флаги -l, -L и -Wl.

В чем же тогда смысл #include.
1. Когда у вас собирается множество объявлений, связанных друг с другом одной идеей, вы хотите иметь способ получить доступ к этим объявлениям и не писать миллион строк, объясняя компилятору, какие штуки вы хотите использовать. Вы пишете специальный файл, который называют «заголовочным» — .h. В этих файлах собирают можество объявлений, чтобы оформить их в один пакет. Поэтому #include имеет оформляющую интерфейсы функцию.
2. Когда ваша программа состоит из множества исходных файлов, и вы не хотите повторять одну и ту же информацию в них несколько раз. Тогда вы пишете .h или .inc файлы, содержащие информацию, которая используется в проекте множество раз. #include может включать файлы с любым расширением. То есть, #include имеет «сушащую» функцию.
3. Иногда вы хотите абстрагировать от ребят, которые будут использовать вашу библиотеку, некоторые особенности интерфейса. Так происходит, например, со стандартной библиотекой, где объявления, макросы и константы могут различаться между платформами, но главное, чтобы стандарт работал. #include имеет абстрагирующую функцию.

Пойду спать.

10 комментариев

avatar
keyboard_arrow_up
keyboard_arrow_down
Push.
avatar
keyboard_arrow_up
keyboard_arrow_down
  • Ori
  • Бабушка-айтишник
Если в программе используются внешние зависимости, то никакой компилятор их искать не будет. Даже если вы напишете #include — вы просто добавите еще порцию неопределенных ссылок. Чтобы позволить программисту подключать свои библиотеки, команды gcc и clang предоставляют флаги, через которые программист может общаться с компоновщиком — флаги -l, -L и -Wl.
А что из себя представляет «своя библиотека»? Это… скомпилированные объектники должны быть, я так понимаю, чтобы компоновщик мог их подключать к объектникам программы? В каком виде лежит стандартная библиотека, куча файлов .o где-то в папках у компилятора?
avatar
keyboard_arrow_up
keyboard_arrow_down
Да, библиотеки — это наборы кусочков, с которыми работает компоновщик. При этом библиотеки могут быть «статическими» или «динамическими». Статические библиотеки представляют из себя множество объектных файлов, которые, обычно, предоставляются в виде архива. В Windows это называется .lib, в юниксах принято .a. Юниксовские статические библиотеки являются просто ar-архивами с кучей объектных файлов.
Начиная с простого, некоторые библиотеки в системе лежат в виде нескольких объектных файлов — когда нужна особая настройка. Например, библиотека C Runtime (crt), которая выполняет инициализацию и деинициализацию стандартной библиотеки Си и вызывает функцию main, представлена в системе в виде нескольких файлов: crt1.o, crti.o, crtn.o, Scrt1.o. crt1.o и Scrt1.o нужны только, когда собираются исполняемые файлы, при чем Scrt1.o используется, когда генеруется PIE (position-independent executable).
Чтобы собирать объектные файлы в один архив, в юниксах используют программу ar. Раньше она, возможно, использовалась и просто для упаковки файлов, но сейчас для этого используют tar, а ar только для упаковки статических библиотек.
Если мы хотим просто скомпилировать исходный файл и получить объектный файл, без дальнейшей компоновки, в gcc для этого есть флаг -c, который постоянно используется при сборке многофайловых программ:
$ gcc -c hello.c -o hello.o
$ file hello.o
hello.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

Дальше, если мы хотим скомпоновать этот файл в исполняемый, делаем:
$ gcc hello.o -o hello
$ ./hello
Hello, World!

Также мы можем сделать из этого объектного файла статическую библиотеку, использовав ar:
$ ar rcs libhello.a hello.o
$ ar t libhello.a 
hello.o
$ file libhello.a 
libhello.a: current ar archive

`ar t` отображает список файлов в архиве.
Теперь так же просто соберем из этого исполняемый файл:
$ gcc libhello.a -o hello
$ ./hello 
Hello, World!

Так работают статические библиотеки.

Динамические библиотеки у нас называются .so — shared object, в Windows — .dll. Эти штуки подключаются к исполняемому файлу не во время сборки, а во время запуска. То есть, когда мы просим систему запустить файл, она передает этот файл динамическому компоновщику, которого зовут ld-linux.so в Linux. Динамический компоновщик размещает исполняемый файл в оперативную память, ищет требуемые этим файлом динамические библиотеки в файловой системе, и тоже загружает их в память, чтобы исполняемый файл мог их использовать. Динамические библиотеки, в отличие от статических, не являются архивами объектных файлов. Это такие же скомпанованные бинарники, как и исполняемый файл, только без точки входа. Можно сделать из hello.o динамическую библиотеку, но в архитектуре x86-64 потребуется перекомпилировать этот файл с флагом -fPIC (position-independent code):
$ gcc -fPIC -c hello.c -o hello.o
$ gcc -shared hello.o -o libhello.so

Теперь функция main из нашего кода становится довольно бесполезной, поскольку никто ее вызывать не будет. Хотя, если у использующего ее исполняемого файла не будет определено своей main, то при его запуске вызовется main библиотеки.
Чтобы собрать исполняемый файл с зависимостью от динамической библиотеки:
$ gcc main.c -L. -lhello -o main


Вот так можно компоновать исполняемый файл, обращаясь напрямую к ld:
$ ld -dynamic-linker /usr/lib64/ld-linux-x86-64.so.2 -o hello /usr/lib64/crt1.o /usr/lib64/crti.o /usr/lib64/libc.so ./hello.o /usr/lib64/crtn.o
$ ./hello
Hello, World!
avatar
keyboard_arrow_up
keyboard_arrow_down
  • Ori
  • Бабушка-айтишник
Так.
Если мы пишем «без include», то есть сами объявляем только те функции, которые нам нужны, а не всё подряд из stdio или math, то размер нашего исполняемого файла уменьшится, но незначительно? То есть… когда функция подключается из статической библиотеки, то она просто копируется в наш исполняемый файл. Но так как мы не используем все функции, перечисленные в заголовочном файле, то они ведь не скопируются в исполняемый. В исполняемом будут только их какие-то объявления пустые?

А когда мы используем динамическую библиотеку, то в наш исполняемый файл не копируется ничего. Сама динамическая библиотека становится исполняемым файлом и в ней исполняется нужная нам функция. Как-то так?
avatar
keyboard_arrow_up
keyboard_arrow_down
Если мы пишем «без include», то есть сами объявляем только те функции, которые нам нужны, а не всё подряд из stdio или math, то размер нашего исполняемого файла уменьшится, но незначительно?
Если объявление не используется, компилятор не будет писать в объектном файле, что функцию нужно найти. Потому что ее не нужно искать, если она не нужна. Но заголовочные файлы могут содержать определения, которые могут создавать лишние объекты в получаемом объектном файле.

То есть… когда функция подключается из статической библиотеки, то она просто копируется в наш исполняемый файл.
На самом деле, ld не оперирует отдельными функциями. ld работает с кусками кода побольше — секциями. Если компилятор не разделил несколько функций в отдельные секции, а записал все в одну, то ld не сможет их разделить и вставит в файл секцию целиком. Да и даже если у каждой функции будет своя секция, ld ленивый, и не будет делать то, о чем его не просят.
gcc можно попросить разделять все функции на раздельные секции флагом -ffunction-sections. А ld можно попросить убирать неиспользуемые секции флагом --gc-sections.

А когда мы используем динамическую библиотеку, то в наш исполняемый файл не копируется ничего. Сама динамическая библиотека становится исполняемым файлом и в ней исполняется нужная нам функция. Как-то так?
Код динамической библиотеки загружается в память вместе с кодом исполняемого файла. Когда программа вызывает функцию из библиотеки, она передает управление коду библиотеки в памяти. В исполняемый файл динамическая библиотека не копируется, в исполняемый файл прописывается зависимость от библиотеки.
avatar
keyboard_arrow_up
keyboard_arrow_down
  • Ori
  • Бабушка-айтишник
То есть «без include» совсем немного уменьшает исполняемый файл и делать так не нужно.

М, недавно смотрел немножко про ассемблер, видел там секции. То есть из статической библиотеки скорее всего вставится целая секция и не факт, что там все будет нужное.

Так, а динамическую я значит правильно понял. В наш исполняемый файл не добавляется логики из библиотеки. Вызывается библиотека и выполняется.
avatar
keyboard_arrow_up
keyboard_arrow_down
Исполняемый файл — это как образ диска. В нем записано то, как программа должна располагаться в оперативной памяти, и то, какая информация должна находиться в оперативной памяти. Когда ld-linux.so получает исполняемый файл, он записывает в память требуемые динамические библиотеки и секции исполняемого файла, при этом подменяя ссылки на функции динамической библиотеки на адреса этих функций в памяти.
Когда ты говоришь «Вызывается библиотека» это звучит для меня, будто открывается терминал и вводится «библиотека, запустить это функцию, вот аргументы».
avatar
keyboard_arrow_up
keyboard_arrow_down
  • Ori
  • Бабушка-айтишник
То есть после запуска исполняемого файла, которому нужна dll, в памяти он окажется таким образом, как будто в нем была dll. Хотя мы его прочитали из одного места, а dll из другого.
avatar
keyboard_arrow_up
keyboard_arrow_down
О, какой пост. *_*
avatar
keyboard_arrow_up
keyboard_arrow_down
Довольно скудный. Мои навыки в рассказывании плохи.
Только зарегистрированные и авторизованные пользователи могут оставлять комментарии.