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

Своим постом автор Jenny Jam* пытается заполнить этот пробел. Он рассуждает, когда C — идеальный выбор, а когда лучше обратиться к другим языкам. Описывает, как настроить среду разработки и выбрать инструменты, разобраться в версиях, особенностях сборки и тонкостях работы с библиотеками.

Цель статьи — упорядочить представление о языке C и его экосистеме, и, конечно, дать практические советы, которые пригодятся в реальных проектах.

*Обращаем ваше внимание, что позиция автора может не всегда совпадать с мнением МойОфис


Эта статья — ответ на материал Даниэля Бахлера об изучении F# и статью Хиллела Уэйна о том, как трудно изучать новый язык

Этот документ почти наверняка неполный! Информации очень много, и, возможно, я что-то из нее неправильно понял. Я также не знаком со многими экосистемами, такими как Mac, и поэтому не могу рассуждать о них.

  1. Зачем может потребоваться использование С

  2. В каких случаях лучше воспользоваться другим языком

  3. Где можно выполнить код на С

  4. Объединение с C++ (и Objective-C)

  5. Установка среды разработки

  6. Как писать код: текстовые редакторы и среды разработки (IDE)

  7. Где искать помощь и подсказки: документация

  8. Процесс сборки на языке C

  9. Библиотеки

  10. Системы сборки для языка C

  11. Проверка и форматирование кода

  12. Отладка C

  13. Стандарты и версии

  14. Переносимость, неопределенное поведение и безопасность доступа к памяти

  15. Управление зависимостями

Зачем может потребоваться использование C

Язык C во многом является основополагающим языком для других экосистем. Нередко другие языки программирования, частично или полностью, зависят от компилятора C для загрузки собственного компилятора или среды выполнения. Многие языки используют C как де-факто интерфейс внешних функций, а в некоторых системах утилиты представлены в виде библиотек C, к которым вы обращаетесь. Так что если вы, приступая к какой-то задаче, задумаетесь — а не написать ли мне это на C, вполне возможно, что у вас нет других вариантов.

Вот примеры случаев, когда использование С будет уместным:

  • Если вы хотите добавить расширения для нескольких языков. 

  • Когда вам нужно взаимодействовать с системной или пользовательской библиотекой, которая предоставлена только как API на языке C.

  • Когда те, кто использует ваш код в основном используют C FFI.

  • Когда вы пишете что-то, не требующее мощной среды выполнения. Например, ОС или прошивку на системе с ограниченными ресурсами.

  • Когда обращение к более управляемому языку высокого уровня будет слишком медленным или затратным — возможно, в контексте реализации быстрой/эффективной части проекта.

В каких случаях лучше воспользоваться другим языком

  1. Язык C небезопасен с точки зрения доступа к памяти, плюс вам придется самостоятельно управлять ею.

  2. Когда вы работаете с кодом, который должен обезопасить или обработать ненадежные входные данные.

  3. Когда в приоритете не производительность, а скорость разработки или полезность. Например, это актуально при написании сценариев или в исследовательском программировании.

  4. Когда нужно выполнять сложные операции со строками.

  5. Когда требуется большой набор развитых и хорошо проработанных структур данных и алгоритмов.

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

Где можно выполнить код на C

Язык C почти всегда компилируется (хотя есть и интерпретаторы), поэтому его можно использовать везде, где может выполняться машинный код и есть компилятор. Язык также можно скомпилировать в wasm или javascript с помощью Emscripten — так вы можете выполнять код на C в браузере.

Объединение с C++ (и Objective-C)

Вы наверняка часто слышали, что C и C++ упоминаются в одном ряду или вместе как C/++. Это происходит потому, что, хотя эти языки зачастую совершенно разные, у них близкая история, и C++ включает в себя почти все элементы языка и функции стандартной библиотеки C.

Все основные компиляторы (MSVC, GCC, Clang) воспринимают как C++, так и C (и Objective-C!) — это позволяет использовать код, написанный на C++, из C (или иногда наоборот — это называется Hermetic C++). Несмотря на всё это, у языков разные культуры, нормы и способы выполнения тех или иных действий, и чаще разработчики все же выбирают только С или C++, а не смешивают их.

Установка среды разработки

Набор инструментов, необходимых для создания языка C, обычно называют toolchain. Традиционно он включает в себя компилятор, ассемблер, компоновщик, препроцессор и другие сопутствующие инструменты. Специфика зависит от конкретного toolchain, но в мире *nix они в основном работают по похожей схеме. Обычно вам не нужно беспокоиться об установке той или иной части компилятора, так как все они упакованы вместе.

GCC

GCC (Gnu Compiler Collection) — один из самых распространенных и известных компиляторов, доступных разработчикам. Он бесплатный (вообще, GCC — один из канонических примеров ПО с открытым исходным кодом), и, как правило, может быть установлен с помощью менеджера пакетов вашей системы.

Одно из преимуществ GCC — его возраст: это давно работающий комплексный проект с несколькими бэкендами компилятора и большим количеством дополнительных расширений и утилит. Как правило, если у меня нет особых причин обратиться к другому компилятору, я по умолчанию возьму GCC, хотя Clang не менее достоин и полезен. Но о нем — ниже.

# install on Ubuntu
> sudo apt install gcc

Clang

Ещё один основной toolchain для *nix с открытым исходным кодом. Он был разработан отчасти для того, чтобы унаследовать большинство расширений и настроек компилятора GCC. Это значит, что большинство программ, использующих GCC, могут быть скомпилированы с помощью Clang.

Clang и GCC в основном совместимы по производительности, так что любой из них — отличный выбор, если только вы не ориентируетесь на какой-либо определенный бэкенд или фичу. Также, при создании ПО, полезно попробовать использовать оба, так как каждый из них может найти ошибки, не обнаруженные другим. Одна из особенностей Clang в том, что он (по умолчанию, во всяком случае) является кросс-компилятором со встроенными поддерживаемыми бэкендами, в то время как в случае с GCC вам придется установить сборку компилятора для конкретного бэкенда.

# install on Ubuntu
> sudo apt install clang

MSVC

Это собственный компилятор C и C++ от Microsoft (они относятся к барьеру между языками немного мягче, чем Clang и GCC), и хотя MSVC не является ПО с открытым исходным кодом, он свободно доступен. Чтобы получить его, установите visual studio (обратите внимание, что это не то же, что visual studio code), а версии компилятора до недавнего времени были в комплекте с более широкой средой visual studio, которую можно скачать здесь.

Хотя вам не обязательно использовать интегрированную среду разработки (IDE) Visual Studio, если вы этого не хотите — можете пользоваться автономными инструментами, запускаемыми из командной строки (CLI). Важный момент — это может стать причиной определенных проблем, так как эти инструменты изначально не включаются в среду, а добавляются к ней с помощью специальных ярлыков, которые запускают cmd.exe с каждым из добавленных полезных файлов командной строки. Все подробности есть в этой документации.

Одним из небольших преимуществ CMake является то, что если ваш MSVC установлен в стандартном месте, CMake может определить его местоположение и вызвать инструменты для конфигурирования и сборки.

Mingw

Mingw (и его более современный порт Mingw-64) — это порт GCC для компиляции кода под Windows: либо в виде кросс-компиляции, либо непосредственно в среде Windows. Вообще, именно MSVC — основной способ сборки родного C/++ кода для Windows. Mingw работает и может компилировать много проектов, но у этого toolchain есть недостатки, которых нет у MSVC. Например, использование старой и недокументированной системной библиотеки. Так что если у вас нет веских причин для отказа, я бы рекомендовала использовать MSVC, если вы выполняете компиляцию на родной платформе.

Mingw можно установить в большинстве репозиториев с помощью менеджеров пакетов, а также он загружен здесь.

Как писать код: текстовые редакторы и среды разработки (IDE)

Поскольку языку C уже немало лет, и он используется на многих платформах, у большинства основных текстовых редакторов есть та или иная форма поддержки C/++ — либо встроенная, либо через пользовательские пакеты.

Visual Studio Code

Кроссплатформенный текстовый редактор общего назначения и «облегченная IDE», с огромным репозиторием официальных и поставляемых сообществом плагинов. Этот проект ответственен за создание протокола Language Service Protocol, который был принят в других редакторах. В целом, это удобный и проработанный редактор для многих языков. В нем также есть очень полезный режим удаленного редактирования. Это мой выбор по умолчанию.

Вот инструменты, предоставляемые Microsoft для настройки среды разработки на C/++

Visual Studio

Помимо того что Visual Studio содержит toolchain C/++ и ряд других инструментов, это — полнофункциональная и довольно богатая IDE. У нее есть ряд официально поддерживаемых плагинов для всех основных языков, поддерживаемых Microsoft. Работа по улучшению редактора C для этой среды ведется уже много лет. Обычно я ей не пользуюсь, но это надежная и развитая технология, и если вы разрабатываете код для Windows или в среде Windows, то она отлично для этого подойдет.

CLion

CLion был разработан компанией JetBrains, которая создала ряд редакторов для конкретных языков. CLion — это их инструмент для работы с C/C++. В качестве системы сборки он обычно использует CMake, хотя может импортировать проекты с помощью Makefiles или других инструментов.

Это — платная IDE и она стоит около 100 долларов в год, или меньше, если вы купите полный набор инструментов Intellisense. Мне очень нравится CLion, хотя некоторые опции (например, форматирование по умолчанию) немного неудобны, и мне приходится бороться с ними или редактировать конфигурацию, но это — богатая среда редактирования. К сожалению, экосистема сторонних плагинов в ней гораздо скуднее, чем в других редакторах кода из этого списка.

CLion можно скачать здесь.

Vim

Vim (и его квази-форк Neovim) — это довольно старая и весьма продвинутая среда редактирования на основе командной строки. По умолчанию в ней есть только подсветка синтаксиса для кода, но она обладает огромной базой кода для разработчиков, так что вы можете совершенно точно наполнить ее множеством полезных вещей. В общем, одно из главных преимуществ среды Vim в том, что она, скорее всего, есть на большинстве компьютеров, и вы можете использовать ее, если кодите удаленно по протоколу SSH.

Где искать помощь и подсказки: документация

Справочные страницы

Исторически, источниками информации по функциональности языка C являются справочные страницы (man-pages): база данных документации и клиент в режиме командной строки, который предустановлен почти на всех компьютерах с операционной системой *nix. Как правило, в справочные страницы помещают свою документацию утилиты, системные и другие библиотеки. Обычно она пишется в Roff — простой системе набора текста.

Справочные страницы упорядочены по разделам, которые выступают в качестве пространств имен (подробнее см. на этой man-странице). Как правило, нужные вам блоки находятся в разделе 1 (инструменты командной строки), разделе 2 (системные вызовы и их функции-обертки), разделе 3 (библиотечные функции) и иногда в разделе 7 (обзор или общие темы).

Онлайн-версии руководств размещены в нескольких местах (одно из них — проект ядра linux), но они могут отличаться от версий, установленных на вашем компьютере. Вместо этого я рекомендую очень полезный локальный веб-сервер DWWW. Он преобразует справочные страницы в HTML и индексирует их для поиска.

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

Gnu Info

Gnu Info была разработана как своего рода модернизация man-страниц. Последние были небольшими и автономными, в то время как Gnu Info должна была представлять из себя набор связанных страниц в виде гипертекста.

Gnu Info используется не слишком часто (по моему опыту, даже не так часто, как man-страницы). К ней прибегают во многих проектах Gnu (зачастую в них предоставляется очень скудная man-страница, сообщающая, что вы должны искать информацию в Info), но я думаю, что это в основном пройденный этап. Даже проекты Gnu сейчас все чаще предоставляют документацию в HTML или PDF – обычно я к ним и обращаюсь.

Локально создаваемая документация: Doxygen

Doxygen – это и инструмент, и общий формат для предоставления документации внутри файла C++. Хотя нередко разработчики используют комментарии в стиле doxygen для добавления документации в код, но не используют собственно Doxygen, обращение к нему довольно характерно для старых проектов.

Если у проекта есть документация doxygen, вы можете собрать его локально, и у вас будет актуальная версия. Действующие проекты часто размещают свою документацию в сети, но обычно только для последнего или долгосрочного релиза.

Microsoft Development Network — MSDN

Microsoft предоставляет документы по своим компиляторам, языковым расширениям и библиотекам на MSDN. Эта система, подобно man-страницам для Linux, часто выступает в качестве основной формы документации.

Процесс сборки на языке C

Если вы переходите с других языков, то компиляция в C для вас выглядит несколько необычно: как правило, вы компилируете каждый файл *.c в объектный файл *.o; затем соединяете их вместе в конечный исполняемый файл с помощью компоновщика.

Если у вас есть проект с файлами:

  • foo.h

  • foo.c

  • main.c

  • util.h

то компиляция с традиционным инструментарием в стиле unix будет выглядеть так:

> cc -C foo.c -o foo.o
> cc -C main.c -o foo.o
> ld main.o foo.o -o main

Хотя я и назвал эту модель «необычной», единственная ее странность — включение в текст заголовочных файлов. Всё остальное в принципе похоже на то, что делает большинство компилируемых языков. Традиционная среда разработки C всего лишь делает это более явным образом.

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

В зависимости от вашей системы сборки промежуточные результаты компиляции могут храниться либо в тех же каталогах, что и исходный код (обычно это называется сборкой в дереве), либо в отдельном каталоге сборки (сборка вне дерева). Проекты, основанные на Makefile, в значительной степени склоняются к сборкам в дереве, а основанные на CMake — к сборкам вне дерева.

Включения и пути включения

В языке C есть два способа включения заголовочных файлов:

#include <stdint.h>
#include "somefile.h"

Технически определение этих способов включения зависит от реализации. Но универсально смысл, приписываемый им, заключается в том, что в случае <> поиск включаемого файла происходит по некоторому глобальному набору путей, в то время как стиль включения "" предполагает, что поиск вначале выполняется в локальных каталогах.

В обоих случаях есть набор путей, заранее определенных вашей средой разработки и инструментарием, и вы можете добавить файлы в путь включения в качестве аргумента командной строки: обычно -I /include/path. На это рассчитаны те проекты, в которых заголовочные файлы перенесены в отдельный каталог, хотя обычно эта задача в различных версиях решается системой сборки.

Макросы и препроцессор

Препроцессор C также с технической точки зрения является автономным инструментом, который можно запускать отдельно для файлов, написанных не на языке C. Хотя единственный другой достойный упоминания пример, который я могу вспомнить, — это Fortran. Он все же довольно сильно связан с определением языка, и макросы C должны следовать правилам допустимых идентификаторов в коде C (хотя, поскольку это текстовые макросы, вы можете делать с ними всякие дикости, например, передавать + в качестве аргумента функции).

Если вам когда-нибудь понадобится отладить макрокод, вы можете вызвать препроцессор командой gcc -E code.c -o code_preprocessed.c. В среде *nix MSVC использует отдельные аргументы.

Код ассемблера и программа-ассемблер

Технически, в конвейере преобразования кода C в объектный файл есть второй промежуточный этап, на котором вы компилируете код в формат, традиционно называемый кодом ассемблера. Этот код (в некотором смысле) является текстовым представлением того, как выглядит машинный код для вашей целевой архитектуры, и он преобразуется в машинный код с помощью программы-ассемблера. Обычно этот формат не выводится, если вы специально об этом не попросите На машинах *nix он традиционно имеет расширение файла .s или .S (если вы используете препроцессор), а на Windows — .asm.

Помимо того, что это может пригодится для просмотра текстового вывода конечного кода, вы также можете писать файлы на языке ассемблера и вручную вызывать ассемблер, например, foo.s -o foo.o. Обычно это не нужно, но иногда применяется, если вы хотите получить экстремальную производительность и необходимо вручную настроить вывод кода, или для реализации низкоуровневых системных вызовов или функций libc, которые нельзя сделать на C.

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

Компоновка и компоновщик

Последним этапом традиционной сборки является компоновка (linkage), где вы берете весь промежуточный код в объектных файлах и объединяете их в конечный исполняемый файл или библиотеку. Этот этап включает в себя компоновку секций данных в исполняемом файле, а также проверку правильности компоновки вызовов функций, определенных вне объектного файла.

Существует собственный язык для управления и манипулирования поведением компоновщика —  скрипт компоновщика. Вам вряд ли придется иметь дело с ним дело, если только вы не пишете компилятор. У МакЯнга есть отличное руководство по подробностям взаимодействия со скриптом компоновщика.

Объединение в библиотеки

В среде *nix вы обычно собираете статическую библиотеку, используя ar (первоначально — менеджер архивов), чтобы собрать все файлы в пакет. Общие (совместно используемые) библиотеки собираются с помощью специального флага в компиляторе. В то время как эти средства создают файл для дальнейшего использования инструментарием (а для динамических файлов — загрузчиком), обычно требуется более сложный набор шагов для фактической упаковки кода для распространения его среди других библиотек.

Библиотеки

Если вам нужно использовать функциональные возможности, которые не встроены в язык, вам придется прибегнуть к помощи библиотеки.

Библиотеки — это способ связать воедино функции для выполнения действий, а также для взаимодействия с операционными системами. Например, SDL— это библиотека с функциями, позволяющими рисовать на экране; libjson— это библиотека с функциями для разбора и сериализации данных в формат обмена JSON, и так далее.

Есть две функциональные разновидности библиотек: статические и динамические; и та и другая используют заголовочные файлы для определения своих интерфейсов.

Заголовочные файлы

Технически они не считаются отдельной конструкцией в стандарте C, но де-факто встроены в соглашение об именовании файлов: файлы, предназначенные для #include, имеют расширение .h.

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

Заголовочные файлы также используются для описания интерфейса, который библиотека предоставляет своим пользователям. Обычно заголовочные файлы содержат прототипы функций, реализованных в библиотеке, определения типов, с которыми они работают, а также макросы как для утилиты, так и для описания функциональных возможностей, специфичных для архитектуры. Единственный способ, которым библиотека может предоставить доступ к своим функциям и конструкциям, — это через набор заголовочных файлов.

Статические библиотеки

Технически это более старая из двух концепций. Статическая библиотека — это объединение функций таким образом, чтобы их можно было подключить к двоичному файлу в процессе его создания. Это позволяет встроить функционал в двоичный файл и не зависеть от наличия динамической библиотеки в системе пользователя.

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

В некоторых крупных проектах также используются статические библиотеки для разделения реализации и использования: если вы создаете видеоигру, томожете поместить всю основную логику в библиотеку libgame в функцию startgame(), и получить один исполняемый файл под названием game, который сразу вызывает startgame(), и другой исполняемый файл, который, в отличие от первого, является просмотрщиком моделей или звукового текста.

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

Динамические библиотеки

Динамические библиотеки противоположны статическим: функции, которые вы используете, не загружаются до запуска исполняемого файла, а затем эти функции подтягиваются, так что вы всегда используете ту реализацию, которую предоставляет система, даже если вы замените эти функции на другие.

Также динамические библиотеки часто используются для плагинов: поскольку вы можете вручную загружать библиотеки и связанные с ними значения с помощью Unix dlopen()/dlsym() и Windows LoadLibrary()/GetProcAddress(), вы можете загружать дополнительную логику для выполнения, не вставляя ее внутрь исполняемого файла. В видеоигре это можно сделать для загрузки модов, а в текстовом редакторе – для загрузки плагина с поддержкой нового языка программирования.

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

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

Libc и другие специальные библиотеки

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

Помимо libc, есть еще несколько библиотек, которые находятся в особом месте, где они, как правило, связаны с компилятором (или даже являются частью libc):

  • libm, которая реализует несколько математических функций;

  • libdl, реализующий функции для работы с динамическими библиотеками.

Библиотеки только с заголовками

Эти библиотеки больше распространены в мире C++, но, похоже, они находят применение и в C. Из-за сложности систем сборки и разнообразия подходов разработчики иногда используют возможность объявления функций с ключевым словом `inline`, чтобы включить как прототипы, так и их реализации в заголовочный файл. Это позволяет, после загрузки библиотеки на систему, избежать дополнительной сборки — достаточно просто подключить её с помощью `#include <headeronly.h>`.

Системы сборки для языка C

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

Зачем использовать систему сборки

Вы можете достаточно просто написать сценарий сборки в виде gcc *.c -o foo, но хотя такой способ может сработать для маленьких проектов, он быстро превратится в проблему, если ваш проект очень крупный или у него более сложная компоновка.

Системы сборки — это способ описания действий, которые необходимо предпринять для компиляции набора исходных файлов в конечную форму (или несколько конечных форм). Они позволяют описать это в более краткой, декларативной форме: работа с версией командной строки как таковой может оказаться запутанной, если вам нужно скомпилировать foo_windows.c только под Windows, а нужно скомпилировать foo.c и под Linux, и под MacOS.

Кроме того, системы сборки отслеживают, когда вы в последний раз редактировали файл, и компилируют только те файлы исходного кода, которые были изменены. 

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

Make (GMake, BMake)

Make — это, пожалуй, самая старая система сборки. В то время как POSIX определяет очень ограниченное подмножество правил и утилит, у основных реализаций Make (GNU Make и BSD Make) есть гораздо больший набор возможностей.

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

Пример для нашего небольшого проекта может выглядеть так:

.PHONY: all clean

CFLAGS= -Wall -Wextra

all: foo

foo: main.o foo.o 
    @$(CC) $(CFLAGS) main.o foo.o $(LDFLAGS) -o foo

main.o: main.c foo.h
foo.o: foo.c foo.h

clean:
    @rm -rf *.o foo

Ninja

Ninja похож на Make с большими ограничениями, но он был разработан для систем мета-сборки, таких как описываемый ниже CMake. Он был создан, чтобы обеспечить быстроту выполнения и сборки, и чтобы ничего не нужно было писать вручную.

CMake

CMake позиционирует себя как "система мета-сборки" – вы пишете сценарии сборки проекта на языке сценариев CMake, а затем они компилируются в целевую сборку, подходящую для данной системы (например, для Makefiles, Ninja, Visual Studio Code). Он также выступает в качестве своего рода системы конфигурации, чтобы библиотеки, использующие CMake, могли быть легко собраны и использованы другими проектами, также применяющими его, с минимальным количеством связующего кода.

Также у CMake есть тенденция изменять структуру проектов: в то время как проекты в стиле Make обычно помещают заголовочные файлы в то же расположение, что и файлы реализации, проекты CMake могут помещать их в отдельное место и затем добавлять в путь включения заголовков с помощью команд. Это означает, что у вашей IDE, вероятно, должно быть некое средство рассмотрения команды CMakeLists.txt или ее вывода, чтобы позволить intellisense правильно обрабатывать код.

Обычно я пользуюсь CMake, если ожидаемый проект – большой и со сложной логикой, или если я использую C++. 

У него довольно плохая репутация, но, если вы усвоите хороший способ структурирования, я считаю, что он не так уж и плох — просто многословный. Я предпочитаю использовать большинство рекомендаций по стилю из «Введения в современный CMake».

Система сборки Visual Studio

Visual Studio C имеет собственный формат (который представляет собой специализированный XML-файл) и свой инструмент сборки; но на самом деле предпочтительно редактировать его через открытый интерфейс GUI. Я достаточно часто его использовал, но в нем довольно сложно отображать графические интерфейсы в текстовом виде. Я ничего не имею против этого инструмента: если вы делаете что-то под Windows и используете Visual Studio, вам, вероятно, следует пользоваться либо им, либо CMake.

Проверка и форматирование кода

Полезно, особенно при программировании на языке C, иметь инструменты, которые ищут ошибки и другие проблемы в статическом коде, а также используют отладку во время выполнения. Они могут пригодиться для автоматической CI/CD-проверки запросов на включение изменений или кода, добавляемого в основную ветвь проекта.

Предупреждения компилятора

Первая линия защиты — это предупреждения компилятора. По умолчанию компилятор C позволяет многим потенциальным ошибкам, которые он может обнаружить статически, пройти незамеченными, поэтому считается лучшей практикой включать эти предупреждения. Обычно компиляторы предоставляют флаги как для отдельных предупреждений, так и для их групп, которые можно активировать. В GCC и Clang это обычно выглядит как -Wall или -Wall -Wextra, а в MSVC — как /W4.

Кроме того, IDE может воспользоваться отмеченными ошибками, чтобы показать вам потенциальные проблемы.

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

Предупреждения компилятора как ошибки

В компиляторах также присутствует опция, позволяющая превратить любое обнаруженное предупреждение компилятора в ошибку. В MSVC это /WX, а в GCC и Clang — -Werror. Это, как правило, считается лучшей практикой, но вызывает определенные споры. Некоторые из более продвинутых предупреждений в компиляторах могут действовать скорее как правила стиля или сигнализировать о ситуациях, которые в большинстве случаев не являются ошибками. Это может привести к так называемой «усталости от сигналов тревоги».

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

Если вы разрабатываете код «с нуля», стоит включить эту функцию.

Clang Format

Это хорошо настраиваемый автоматический форматировщик кода в стиле gofmt, но в духе гетерогенной среды языка C. У него есть несколько встроенных предустановок, которые можно изменять. Я никогда не использовал Clang Format, но большинство проектов с открытым исходным кодом, которые я просматривал, имеют конфигурационный файл .clang-format, расположенный где-то на верхнем уровне кодовой базы. Как и многие другие инструменты, его полезнее внедрить в процессе разработки «с нуля», чем добавлять позднее.

Инструменты статического анализа и линтеры

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

Основные из известных мне бесплатных — clang-tidy и cppcheck, оба с открытым исходным кодом и легко доступны.

Отладка C

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

printf()

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

Отладчики (gdb, lldb, windbg)

Если printf() не справляется с задачей, или вы получаете сообщение об аварийном завершении (SEGFAULT), то вам нужно обратиться к отладчикам. Они используют преимущества системных перехватчиков (hooks), позволяя вам выполнять программу построчно и проверять значения переменных и параметров, а также показывать, когда происходят SEGFAULT'ы или приходят другие сигналы.

GDB — один из самых популярных. Он предоставляет как интерфейс командной строки, так и интерфейс удаленного протокола, чтобы вы могли отлаживать код на удаленных машинах.

Windbg — это специфический для Windows отладчик, который имеет два фронтенда и может также использоваться для отладки информации на уровне ядра.

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

Valgrind

Valgrind — это гораздо более тяжеловесный отладчик, эффективно работающий как интерпретатор машинного кода. Вы загружаете в него двоичную программу, и он позволяет обнаруживать ошибки, которые не очевидны до момента выполнения, включая более тонкие проблемы, такие как некорректное использование файловых дескрипторов.

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

Санитайзеры

Они выполняют схожие с Valgrind задачи (поиск ошибок более высокого уровня во время выполнения), но в отличие от последнего не требуют загрузки двоичного файла в интерпретатор; вместо этого все проверки и инструменты времени выполнения устанавливаются в двоичный файл во время компиляции. Это позволяет вам просто запускать двоичный файл как обычно и видеть ошибки только в случае их возникновения.

К сожалению, санитайзеры частично несовместимы друг с другом, поэтому комбинации санитайзеров, которые можно включить одновременно, ограничены. В частности, UBSan и ASan несовместимы друг с другом.

Стандарты и версии

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

Стандарты

Основные версии языка C изложены в документах стандартов ISO. К сожалению, доступ к самим документам по стандартам возможен за существенную плату, поэтому большая часть сообщества полагается на использование финальных черновиков, поскольку они почти наверняка очень близки к окончательному стандарту.

Это не просто гипотезы: и GCC, и Clang используют опцию, определяющую, какой из версий стандарта вы хотите придерживаться. Компании или отдельные проекты часто строго указывают, какой стандарт разрешен, а разные стандарты открывают специфические функциональные возможности.

Разработчики, заинтересованные в переносимости, часто ориентируются на старые версии стандарта, обычно либо C89, либо все чаще C99. Если вы используете аргументы командной строки для выбора конкретного диалекта, вы можете отключить доступ к некоторым функциям, определенным POSIX или вашим локальным окружением.

Предварительная стандартизация C

C существовал как язык по крайней мере за 18 лет до того, как был создан комитет по разработке стандартов C, и за это время почти каждый производитель разработал свой компилятор с собственными возможностями и расширениями синтаксиса. Наиболее близким к стандарту из существующих был учебник, написанный некоторыми из первых разработчиков C, — «Язык программирования C», и сведения о языке, представленные в первом издании, иногда называемом K&R C.

C89 (он же ANSI C)

Проект можно посмотреть здесь.

Это самая ранняя стандартизированная версия C. Часто именно на нее ориентируются люди, желающие сделать свой код пригодным для использования на различных платформах. Основное отличие этой версии от более поздних в том, что все переменные должны быть объявлены в начале блока, а в библиотеку позже было добавлено несколько стандартных типов и функций.

Забавный факт: эталонный компилятор C89 на самом деле был написан на предварительно стандартизированном C++.

C99

Проект можно посмотреть здесь

C99 — это другая основная версия языка C, которую многие рассматривают как в целом безопасный общий знаменатель. Основные синтаксические дополнения, добавленные в него: структуры переменной длины (реализуемые посредством элементов — динамических массивов) и массивы переменной длины (позже сделаны необязательными в C11), типы комплексных чисел, стиль однострочных комментариев C++ //, объявления переменных, разрешенные в середине блока, и добавление нескольких типов для арифметики целых чисел фиксированной длины.

C11

Проект можно посмотреть здесь

В C11 некоторые из новых добавлений C99 (например, массивы переменной длины) стали необязательными. В нем также добавлена ограниченная форма обобщённых типов, обрабатываемых во время компиляции, с помощью директивы _Generic, а также поддержка потоков и соответствующих многопоточных примитивов.

C17

Проект можно посмотреть здесь

C17 был довольно небольшим обновлением стандарта, в котором добавлено несколько вещей и в основном отменены или прояснены позиции проекта C11.

C2X

В текущей версии стандарта, над которой ведется работа, произошли значительные изменения, поэтому, вероятно, этот раздел скоро устареет. В нем изменен ряд технических деталей (несколько старых макросов теперь стали ключевыми словами), введены атрибуты в стиле C++, введена директива препроцессора, используемая для встраивания файлов непосредственно в виде двоичных массивов.

POSIX

POSIX ссылается на набор спецификаций, созданных в 90-х годах и впоследствии обновленных, в попытке найти минимальный набор интерфейсов, инструментов и функциональных возможностей библиотек, которые были бы общими между ними. Библиотеки, определенные в POSIX, — это «оставшиеся», и их краткий список можно найти здесь.

Расширения Glibc и GCC

GCC и основная реализация libc проекта GNU (glibc) предоставляют огромное количество функций расширения и утилит, и программное обеспечение стало опираться на них. Как и в Windows, разработчики glibc сосредоточились на обратной совместимости, поэтому он выступает в качестве некоего стандарта де-факто, как и среда Win32.

Среда Win32

Хотя Windows не определяет никаких стандартов, описывающих ее библиотеки и интерфейсы, Microsoft уделяет большое внимание обратной совместимости. Это означает, что вы можете эффективно полагаться на среду Win32 C как на стабильную среду для создания программ.

Переносимость, неопределенное поведение и безопасность доступа к памяти

Неопределенное поведение — это еще одна из основных странностей языка C, с которой, вероятно, сталкивается каждый разработчик.

Иерархия поведения

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

  • Определенное поведение: код, поведение которого реализация языка C должна обрабатывать, и результат которого, как ожидается, будет одинаковым для всех компиляторов.

  • Поведение, определяемое реализацией: код, который должна обрабатывать допустимая реализация языка C, но результат которого может быть задан самой реализацией.

  • Неопределенное поведение: код, который соответствующая реализация языка C не обязана обрабатывать, и стандарт не предъявляет никаких требований к тому, что может произойти — реализация вообще не ограничена в том, что делать, и может по сути делать что угодно.

Однако, по крайней мере, в кругах пользователей компиляторов, термин «неопределенное поведение» также используется для описания любого кода, который не характеризуется определенным поведением для конкретной реализации. В таком случае, я думаю, лучше обозначить это как «нарушения инвариантов компилятора», которые могут вызвать ошибки компилятора, или привести к тому, что компилятор выдаст код с ошибкой, или к тому, что в большинстве случаев код будет работать, но в нескольких случаях – нет.

Абстрактная машина C

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

Непреднамеренная непереносимость

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

Это не просто гипотеза или что-то, возникающее при работе с непонятными и экзотическими процессорами. Это, например, происходит с людьми, переносящими игру с одной из самых популярных игровых платформ (PC) на другую очень популярную игровую платформу (Nintendo Switch):

Теперь, когда с этим мы разобрались, пришла очередь многопользовательского детерминизма. Одной из главных целей было не вырезать мультиплеер из игры. Более того, я хотел, чтобы игроки на ПК могли играть с игроками на Nintendo Switch. Нам впервые нужно было убедиться, что игра детерминирована между ARM и x86.

Мы подумали, что всё будет в порядке — C++ ведь переносимый язык, правда? Главное — не использовать неопределённое поведение. Однако выяснилось, что мы используем довольно много неопределённого поведения, как в основном коде, так и в библиотеках. Например, при приведении double к целому числу, если значение не помещается в целое число, это считается неопределенным поведением, и полученные значения будут различаться на процессорах ARM и x86.

https://factorio.com/blog/post/fff-370

Не ждите бесплатной переносимости

Возможно, вам не стоит полагаться на неопределенное поведение, которое работает в вашей производственной среде, потому что это может привести к ошибкам в дальнейшем или к тому, что код не сможет компилироваться, если вы обновите версию компилятора. Но стоит задуматься, хотите ли вы взять на себя труд и хлопоты по созданию кода, который может и должен компилироваться даже на самых экзотических платформах, потому что, несмотря на то, что C — переносимый язык, очень легко написать непереносимый код, если не быть начеку и постоянно не тестировать. И иногда правильным выбором в вашем случае будет переход на менее переносимый код, зависящий от конкретной среды.

Управление зависимостями

Или где именно находятся библиотеки?

Мы все в курсе, что язык C —  появился раньше сред, требующих работы с зависимостями и библиотеками.

Менеджер пакетов для всей системы

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

Например, если я хочу использовать библиотеку криптовалют, то я могу установить libopenssl (а в большинстве систем и другие необходимые элементы сборки в libopenssl-dev) через командную строку и начать разработку своего приложения.

> sudo apt install openssl openssl-dev

У этого подхода есть и некоторые приятные преимущества: если вы распространяете свой код в виде исходного кода, вы можете собрать его и связать с установленным в вашей системе, и полагаться на функциональность, встроенную в версию, предоставленную вендором.

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

Git-подмодули и сборка библиотеки как часть шага сборки

Ещё один способ управления зависимостями, который не зависит от платформы или специфического менеджера пакетов — это включение других проектов с помощью утилиты git submodule, описанной здесь. Этот метод особенно полезен, если вы не планируете часто обновлять код и в основном можете придерживаться определенного выпуска библиотеки.

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

Ручной вендоринг

Еще один подход — просто взять исходный код библиотеки, от которой зависит ваша программа, и проверить его в своем коде. Некоторые библиотеки созданы специально именно для этого (например, Lua, SQLite amalgam). Если вы хотите добавить дополнительные возможности или скомпилировать код особым образом, и при этом не планируете часто обновлять версию, то этот метод может быть весьма полезным. Однако многие библиотеки не поддерживают такую интеграцию без дополнительных усилий.

Комментарии (7)


  1. Emelian
    06.01.2025 13:39

    Си, конечно, мощный язык, но С++ гораздо более дружелюбен и удобен. А Visual Studio C++, по факту, очень хорошая среда разработки.

    Я с Си всерьез столкнулся при попытке внедрить код FFPlay.c в своей проект «МедиаТекст» (он не опубликован, но скриншот можно посмотреть в http://scholium.webservis.ru/Pics/MediaText.png ). Но, чтобы добиться этого пришлось сильно напрячься. Вообще, перенос Си-проектов в С++ – удовольствие ещё то!


    1. segment
      06.01.2025 13:39

      Ну, разные языки под разные задачи. ИМХО если требуется что-то низкоуровневое, то я бы предпочел чистый C, а не C++, из-за читаемости и "отлаживаемости". Если требуется что-то более масштабное, гибкое и быстрое, то я бы взял C#. Все эти пляски со стандартами, перегрузками, sfinae и другая шаблонная магия — напрочь отбили желание как-то продолжать его использовать.


      1. Emelian
        06.01.2025 13:39

        Думаю, что лучше сравнивать собственные конкретные проекты. Для работы с пользовательским интерфейсом очень удобен C++ / WTL. Кстати, последний мой проект, в этой связке, это обучающая программа «L'école» ( https://habr.com/ru/articles/848836/ ).


        1. segment
          06.01.2025 13:39

          Не нашел по ссылкам исходники к программе, чтобы как-то посмотреть на удобство работы в коде. Для работы с пользовательским интерфейсом отлично подходит связка C# и AvaloniaUI.


          1. Emelian
            06.01.2025 13:39

            Не нашел по ссылкам исходники к программе, чтобы как-то посмотреть на удобство работы в коде.

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

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

            Для работы с пользовательским интерфейсом отлично подходит связка C# и AvaloniaUI.

            «Лучше один раз потрогать, чем сто раз увидеть» или что-то в этом роде… :) . Конкретно, меня интересуют опенсорсные исходники типа «ТоталКоммандер».


      1. wigneddoom
        06.01.2025 13:39

        ИМХО если требуется что-то низкоуровневое, то я бы предпочел чистый C, а не C++, из-за читаемости и "отлаживаемости".

        Можно всегда на С++ в Си-стайл писать, используя только подмножество языка. Но у Си конечное есть своя ниша - старые/актуальные и новые проекты, типа Linux/DPDK/SPDK/кодеки/либы и т.д. Ну и конечно C ABI.


  1. Uporoty
    06.01.2025 13:39

    Странно, что в выжимке "что обязательно нужно знать" ни слова не сказано про Undefined Behavior.

    А это именно то, что нужно любому разработчику на сях и плюсах знать с первых дней. Потому что доходит до откровенно смешного, я не раз сталкивался, что люди, которые пишут код на C много лет (!), не до конца понимают, что же такое undefined behavior и чем оно может грозить - и продолжают распространять всякие заблуждения, что "это только про ошибки работы с памятью", и "если на моей платформе и моем компиляторе оно работает, то значит так можно, все нормально"...