Руководство по Кросс-Платформенному Системному Программированию для UNIX и Windows: Уровень 1

С помощью этого учебного материала мы научимся писать кросс-платформенный код на Си, используя системные функции популярных ОС (Windows, Linux/Android, macOS и FreeBSD): управление файлами и файловый I/O, консольный I/O, пайпы (неименованные), запуск новых процессов. Мы напишем свои небольшие вспомогательные функции поверх низкоуровневого системного АПИ (API), для того чтобы наш основной код, используя эти функции, мог работать на любой ОС без изменений. Этот учебный материал — начального уровня. Я делю сложные вещи на части, чтобы примеры кода здесь не были слишком заумными для тех, кто только что начал программировать на Си. Мы обсудим различия между системными АПИ и разберёмся, как создать кросс-платформенный программный интерфейс, который скрывает все эти различия от пользователя этого интерфейса.

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

Содержание:

  • Введение

  • Основные проблемы программирования под разные ОС

  • О примерах кода

  • Выделение Памяти

  • Детектор во время компиляции

  • Стандартный I/O

  • Кодировки и конвертация данных

  • Файловый I/O: простая программа файл-эхо

  • Системные ошибки

  • Управление файлами

  • Листинг каталога

  • Неименованные пайпы

  • Запуск других программ

  • Запуск других программ в UNIX

  • Запуск других программ в Windows

  • Запуск других программ и чтение их вывода

  • Получение текущей даты/времени

  • Приостановка выполнения программы

  • Заключение

Введение

Есть одна неудобная вещь в программировании на Си — поддержка нескольких ОС, ведь каждая ОС имеет свой оригинальный системный АПИ. Например, если мы хотим, чтобы наше приложение работало на Linux и Windows, нам нужно будет написать 2 разные программы на Си. Как мы можем решить эту проблему:

  • Переключиться на другой язык (Go, Python, Java и т.д.), который предоставляет нам (почти) полную кросс-платформенную системную библиотеку. Однако, не для всех возможных сценариев это будет правильным решением. Что, если мы хотим написать высокопроизводительный сервер, как nginx? Нам абсолютно необходим Си. Что, если нам нужно строить логику нашей программы вокруг нескольких низкоуровневых сишных библиотек? Да, конечно мы можем сами написать необходимую обвязку этой библиотеки для другого языка, но вместо этого мы можем просто взять и использовать Си. А что, если мы хотим, чтобы наше приложение работало на встроенных системах с ограниченными аппаратными ресурсами (ЦП, память)? Опять же, нам нужен Си.

  • Вставлять препроцессорные ветки #if в наш код, чтобы компилятор использовал отдельную логику для каждой ОС. Основная проблема с этим подходом заключается в том, что такой код выглядит всё таки некрасиво. Когда у всех наших функций по несколько веток #ifdef внутри, такой код становится слишком сложно читать и поддерживать. При этом увеличивается вероятность, что каждая новая правка может сломать что-то где-то там, где мы меньше всего этого ожидаем. Да, иногда препроцессорная ветка — прямо таки палочка-выручалочка, но мы никогда не должны злоупотреблять этой технологией, тут нужно соблюдать баланс.

  • Использовать библиотеку, которая скрывает от нас принципиальные различия между системными АПИ. Другими словами, мы используем библиотеку, которая предоставляет нам простой в использовании кросс-платформенный интерфейс. А пользовательский код, построенный поверх этой библиотеки, просто компилируется и работает на разных ОС. Это и является главной темой данного учебника.

Основные проблемы программирования под разные ОС

Первое, что нам надо обсудить здесь — чем на самом деле различаются системные АПИ в разных ОС, и какие проблемы нам приходится решать при написании кода под разные ОС.

  • Самое главное: Linux, macOS и FreeBSD — всё это UNIX-системы. В большинстве случаев у них похожий системный АПИ (т.е. POSIX), и это значительно сокращает время, необходимое для переноса кода Си между ними. К сожалению, иногда системные функции с одним и тем же именем (напр. sendfile()) имеют разные параметры. Иногда флажки, которые мы передаём функциям, ведут себя иначе (напр. O_NONBLOCK для сокетов). Иногда код, написанный для Linux, не может быть легко перенесён на другую ОС, из-за того что в Linux есть много специфичных системных вызовов, которых просто нет в macOS (напр. sem_timedwait()). Мы должны быть очень аккуратны при прямом использовании системных функций в нашем коде. Держать всегда в голове все эти детали — трудно, поэтому всегда хорошо оставлять комментарии где-нибудь в коде, чтобы мы могли быстро вспомнить эти нюансы по прошествии времени. В итоге, нам нужна тонкая прослойка между кодом нашего приложения и системным АПИ. Кросс-платформенная библиотека — это именно тот программный слой, который будет решать проблемы, что я только что описал. В то же время, скрывая от нас детали реализации для каждой ОС, хорошая библиотека должна также описывать эти различия в своей документации, чтобы мы понимали, как именно она будет работать в конкретной ОС. Иначе мы можем получить код, который на некоторых системах работает плохо или вовсе неправильно.

  • Продолжая упомянутую выше проблему совместимости АПИ, давай предположим, что наше приложение уже использует какую-нибудь Linux-специфичную функцию, но мы хотим, чтобы оно работало ещё и на macOS. Нам нужно решить: 1) должны ли мы написать аналогичную функцию вручную для macOS или 2) должны ли мы переосмыслить наш подход на более высоком уровне. Вариант 1 хороший, но здесь нужно быть осторожным: например, если мы попытаемся реализовать нашу собственную sem_timedwait() для macOS, мы скорее всего будем использовать pthread_cond_timedwait() для эмуляции её логики, но тогда мы должны быть уверены, что всё остальное (включая обработку сигналов UNIX) работает аналогично реализации в Linux. И даже если так, а как насчёт именованных семафоров, будет ли наша функция их поддерживать? И этот код нам самим ещё придётся поддерживать… На мой взгляд, иногда лучше просто переделать логику приложения и использовать какое-то альтернативное решение, если есть возможность.

  • Теперь поговорим о Windows. Windows — это не UNIX, её АПИ полностью отличается почти во всех аспектах, включая (но не ограничиваясь): файлы, сокеты, таймеры, процессы и т.д. И хотя Microsoft через свою Си-рантайм («C runtime») библиотеку предоставляет функции (напр. _open()), которые аналогичны POSIX, их поведение всё равно может не полностью совпадать с тем что в UNIX. Имей в виду, что ты можешь столкнуться с некоторыми неожиданными проблемами, если не прочиташь 100% документации из Microsoft Docs и не поймёшь, как именно такие функции работают внутри. Теоретически _open() должен быть простой тонкой оболочкой для CreateFileW(), но я не буду в этом уверен, пока не увижу код. Однако, зачем вообще пытаться учиться правильно использовать все эти функции-обёртки, когда у нас уже есть очень хорошо расписанная и чёткая документация для всех функций WinAPI низкого уровня (напр. CreateFileW())? Поэтому я в своей работе всегда по возможности стараюсь использовать функции WinAPI напрямую, а не какие-то обёртки вокруг них.

  • В UNIX используется символ / для путей к файлам, а в Windows обычно используется \. Однако большинство функций WinAPI также принимают и / в путях и работают при этом корректно. Поэтому можно сказать, что Windows поддерживает как \, так и / в качестве символа разделителя пути, но просто помни, что / может не сработать в некоторых редких случаях.

  • При компиляции кода для разных платформ возможен конфликт имён. Иногда наш совершенно корректный код не компилируется на другой ОС из-за очень странной ошибки компиляции, которую поначалу довольно сложно понять. Это может произойти например когда мы используем какое-то имя переменной или функции в своём коде, но это имя уже используется в одном из системных заголовочных файлов, которые мы подключаем через #include. Проблема усугубляется, если это имя используется препроцессором — в этом случае компилятор может сойти с ума, и его сообщения об ошибках мало чем помогут. Чтобы предотвратить эту проблему, я рекомендую тебе всегда использовать префикс, уникальный для твоего проекта. Некоторое время назад я начал использовать префикс ff для всех имён в коде моей библиотеки, и с тех пор у меня не было ни одного конфликта в именах. nginx, например, везде использует префикс ngx_, так что это обычная практика для проектов Си. Заметь, что namespace-ы в Си++ не сильно помогают в решении описанной выше проблемы, потому что мы по-прежнему не можем использовать то, что уже зарегистрировано через #define в системном заголовочном файле — всё равно сначала нужно сделать #undef.

    Стоит сказать, что если ты компилируешь свой код для Windows с помощью MinGW, помни, что инклуд файлы MinGW не идентичны файлам, поставляемым в комплекте с Microsoft Visual Studio. Могут быть дополнительные конфликты вокруг глобальных имен — это будет зависеть от того, какие инклуды используются.

  • Ещё одно различие между системными функциями Windows и UNIX заключается в кодировке текста. Когда я хочу открыть файл с именем, содержащим нелатинские символы, мне нужно использовать правильную кодировку текста, иначе система меня не поймёт и либо откроет неправильный файл, либо вернёт ошибку «файл не найден». По умолчанию в системах UNIX обычно используется кодировка UTF-8, а в Windows — UTF-16LE. И уже одно только это отличие мешает нам удобно использовать системные функции напрямую из нашего кода. Если мы попытаемся это сделать, то получим сплошные #ifdef внутри наших функций. Поэтому наша библиотека должна не только обрабатывать имена и параметры системных АПИ-функций, но и автоматически преобразовывать текст в правильную кодировку. Я использую UTF-8 для своих проектов и всем рекомендую делать так же. UTF-16LE неудобен во многих смыслах, включая и тот факт, что он гораздо менее популярен среди текстовых документов, которые ты можешь найти в Интернете. UTF-8 почти всегда лучше и к тому же более популярен.

  • Ещё одно отличие UNIX от Windows — это юзерспейс (userspace) библиотеки, которые мы используем для доступа к системе. В системах UNIX наиболее важная — либ-си (libc). В Linux наиболее широко используемой libc является glibc, но есть и другие реализации (напр. musl libc). libc — это прослойка между нашим кодом и ядром. В этом руководстве все системные функции UNIX, которые мы используем, реализованы внутри libc. Обычно libc передаёт наши запросы в ядро ОС, но иногда и обрабатывает их сама. Без libc нам пришлось бы писать гораздо больше кода для каждой ОС (выполняя системные вызовы самостоятельно), а это было бы очень сложно, отняло бы много времени и всё равно не дало бы нам никаких реальных преимуществ. Поэтому мы остановимся на уровне выше libc и тут разместим наш тонкий кросс-платформенный слой, нам не нужно копать глубже.

    В Windows есть библиотека kernel32.dll, которая предоставляет функции для доступа к системе. kernel32 — это прослойка между юзерспейсом и ядром. Как и в случае с libc для UNIX, без kernel32 нам пришлось бы писать намного больше кода (над ntdll.dll), и как правило у нас нет необходимости этого делать.

Так что в целом при написании кросс-платформенного кода нам приходится учитывать довольно много деталей одновременно. Использование вспомогательных функций или библиотек необходимо, чтобы избежать слишком сложного кода с большим количеством #ifdef. Нам нужно найти хорошую библиотеку или написать свою. Но в любом случае мы должны полностью понимать, что происходит под капотом, и как код нашего приложения взаимодействует с системой, какие системные вызовы мы используем и как. Когда мы двигаемся вперёд по такой методике, мы расширяем свои знания в области разработки ПО, а также пишем в итоге более качественный софт.

О примерах кода

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

  • Мы пишем код в функции main() один раз, и он работает на всех ОС. Это ключевая идея.

  • Код в main() использует функции-обёртки для каждого семейства ОС — именно здесь обрабатывается вся сложность и все различия системных АПИ.

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

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

  • Чтобы собрать файлы примеров в UNIX, просто запусти make. Бинарные файлы будут созданы в том же каталоге. Тебе необходимо установить make и gcc или clang. В Windows необходимо скачать пакет MinGW и установить его, а затем запустить mingw64-make.exe.

  • Если ты захочешь проанализировать полную и настоящую реализацию каждой функции-обёртки, которую мы здесь обсуждаем, ты всегда можешь посмотреть/склонировать мои библиотеки ffbase и ffos, они полностью свободные. Для твоего удобства я помещаю прямую ссылку на них после каждого раздела в этом руководстве.

  • При чтении примеров советую также ознакомиться с официальной документацией по каждой функции. Для систем UNIX есть ман-страницы (man-pages), а для Windows — сайт Microsoft Docs.

Выделение Памяти

Самое главное, что нам нужно при написании программ — уметь выделять память под наши переменные и массивы. Мы можем использовать стэковую память для небольших операционных данных или же динамически выделять большие области памяти, используя хип-память («heap»). libc предоставляет для этого простой интерфейс, и мы разберёмся как его использовать. Но перед этим мы должны понять, чем стэк-память отличается от хип-памяти.

Стэк-память

Стэк-память — это буфер, выделяемый ядром для нашей программы до того момента, как она начнёт выполняться. Сишная программа резервирует («выделяет») область памяти на стэке следующим образом:

	int i; // зарезервировать +4 байта на стэке
	char buffer[100]; // зарезервировать +100 байт на стэке

В процессе компиляции компилятор резервирует некоторое пространство стэка, необходимое для правильной работы функции. Он помещает парочку процессорных инструкций в начало каждой функции. Эти инструкции вычитают необходимое количество байт из указателя на область стэка (stack pointer). Компилятор также добавляет некоторые инструкции, которые восстанавливают указатель стэка в предыдущее состояние, когда наша функция завершает работу — таким образом мы освобождаем область стэка, зарезервированную нашей функцией, чтобы эта же область могла использоваться какой-либо другой функцией после нас. Это также означает, что наша функция не может надёжно возвращать указатели на любой буфер, выделенный на стэке, потому что та же самая область стэка может быть повторно использована/перезаписана после нас.

Предположим, у нас есть такая программа:

void bar()
{
	int b;
	return;
}

void foo()
{
	int f;
	bar();
	return;
}

void main()
{
	int m;
	foo();
}

Для приведённой выше программы вот 5 состояний того, как будет выглядеть наша стэковая память во время выполнения программы (очень упрощённо):

9f6ae9ffd40c31603c3adaec7479efe6.png

  1. Мы внутри main() функции в строке foo();. Компилятор уже зарезервировал область на стэке для нашей переменной m, она показана серым цветом. Зелёная линия — это текущий указатель стэка, который перемещается вниз, когда мы резервируем ещё несколько байт на стэке, и перемещается вверх, когда мы освобождаем эти зарезервированные области. Мы вызываем foo().

  2. Мы находимся внутри функции foo(), и теперь больше места на стэке зарезервировано для нашей переменной f. Все данные, зарезервированные под main(), хранятся в области стэка над нами. Мы вызываем bar().

  3. Внутри bar() для хранения нашей переменной b используется ещё одна область на стэке. При этом, области, зарезервированные всеми родительскими функциями, сохраняются. Мы возвращаемся из функции через return. В этот момент область стэка, зарезервированная для переменной b, сбрасывается и теперь может быть повторно использована другой функцией после нас.

  4. Мы вернулись обратно в foo() и теперь возвращаемся и из неё. То же самое теперь происходит с областью стэка foo() — область, зарезервированная для нашей f, сбрасывается.

  5. Мы вернулись в main(). Теперь всё что у нас осталось на стэке в данный момент — только область для переменной m.

Стэк-память ограничена, и её размер не очень большой (максимум несколько мегабайт). Если ты зарезервируешь очень большое количество байт на стэке, твоя программа может запросто упасть, в момент когда ты попытаешься обратиться к области за пределами стэк-памяти (т.е. области ниже красной линии). И мы не можем добавить больше пространства нашему стэку во время работы нашей программы. Кроме того, небрежное использование стэка для массивов и строк может привести к серьёзным проблемам с безопасностью (переполнение стэка, используемое злоумышленником, может легко привести к выполнению произвольного кода).

Хип-память

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

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

  • Мы можем изменить размер хип-буфера в любое время.

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

3 шага, как использовать динамическую память:

  • Мы просим libc выделить нам немного памяти. libc, в свою очередь, просит ОС зарезервировать область памяти из ОЗУ или свопа (swap).

  • Затем мы можем использовать этот буфер столько времени, сколько нам нужно.

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

Алгоритм libc обычно достаточно умный и не будет пробиваться в ядро ​​каждый раз, когда мы выделяем или освобождаем хип-буферы. Вместо этого он может зарезервировать один большой буфер и разбить его на куски, а затем вернуть эти куски нам по отдельности. Кроме того, когда наша программа освобождает небольшой буфер, это не обязательно означает, что он возвращается обратно в ядро, он вначале остаётся в кэше внутри libc.

Предположим, у нас есть такой код:

#include 

void main()
{
	void *m1 = malloc(1);
	void *m2 = malloc(2);
	void *m3 = malloc(3);
	free(m2);
	free(m1);
	free(m3);
}

Вот как может выглядеть реальная область хип-памяти (очень упрощенно):

b2ea48d2af908c3eabee54818bf93586.png

  1. Когда мы выделяем новый блок (chunk), libc просит ОС выделить для нас область памяти. Затем libc резервирует необходимое количество места и возвращает нам указатель на этот кусок.

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

  3. Когда мы просим освободить буфер, libc просто помечает его как «свободный». Остальные буферы остаются как есть.

  4. После освобождения всех буферов libc может вернуть область памяти обратно ОС (но не обязательно).

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

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

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

Когда Linux резервирует для нас какую-либо область памяти, он не сразу резервирует весь этот объём на физической памяти. Проверь и убедись сам, что объём реальной памяти, потребляемой процессом, который только что выделил буфер размером 4 ГБ, сильно не меняется. Linux предполагает, что хотя наш процесс может запросить большой буфер, в действительности нам может не понадобиться столько места. Пока мы не запишем данные в эту область памяти, блоки физической памяти не будут выделены для нас. Это означает, что несколько процессов, параллельно работающих в системе, могут запрашивать большие блоки памяти, и все их запросы будут удовлетворены, даже если физической памяти недостаточно для хранения всех их данных. Но что тогда произойдёт, если все процессы сразу начнут записывать в свои буферы настоящие данные? Подсистема Out-Of-Memory (OOM, «недостаточно памяти»), работающая внутри ядра, просто убьёт один из них, когда будет достигнут предел физической памяти. А что тогда это означает для нас? Просто помни, что когда мы выделяем большие буферы в Linux, наш процесс иногда может быть принудительно убит, если мы попытаемся заполнить эти буферы данными. Обычно наши приложения должны уважать все другие приложения, работающие в данный момент на системе, и если нам требуется очень большой объём памяти для нашей работы, мы должны быть осторожны, чтобы избежать таких ситуаций OOM, особенно если у юзера есть несохранённая работа.

Использование хип-буфера

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

heap-mem.c

Прокрути вниз до нашей функции main(). Вот строка, где мы выделяем буфер размером 8МБ:

	void *buf = heap_alloc(8*1024*1024);

Мы вызываем нашу собственную функцию heap_alloc() (мы обсудим её реализацию ниже) с одним параметром — количеством байт, которое мы хотим выделить. Результатом является указатель на начало этого буфера. Это означает, что у нас есть область памяти размером 8МБ [buf..buf+8M), доступная для чтения и записи. Обычно этот указатель уже выровнен по крайней мере до 4 или 8 байт (в зависимости от архитектуры процессора). Например, мы можем напрямую разадресовывать указатели short* или int* по этому адресу даже на 32-битном ARM:

	int *array = heap_alloc(8*1024*1024);
	array[0] = 123; // должно нормально работать на ARM

Ещё один важный момент: никто не мешает нам читать или даже записывать какие-то данные за границы буфера. Например, в нашем примере мы действительно можем попытаться записать в этот буфер более 8МБ данных, и скорее всего нам это удастся. Однако в любой момент может произойти авария, потому что мы случайно можем перезаписать данные соседних буферов. После этого может быть повреждена вся область выделенной нам хип-памяти. А если мы попытаемся получить доступ к данным ещё дальше, мы можем перейти ту критическую линию, где начинается неразмеченное пространство памяти (красная линия на схемке). В этом случае процессор пошлёт сигнал на исключение, и наша программа упадёт. Таким образом, это означает, что при работе с буферами в Си мы всегда должны передавать их размер в качестве параметра функции (или внутри struct), чтобы ни одна из наших функций не могла получить доступ к данным за пределами буфера. Если ты пишешь программу, и она периодически случайно падает, то скорее всего, твой код перезаписал где-то буфер на хипе или на стэке. Если это так, ты можешь попробовать скомпилировать своё приложение с параметром -fsanitize=address, после чего программа в случае такого сбоя напечатает нормальное сообщение о том, где ты допустил ошибку. Обычно это помогает.

Следующая строка:

	assert(buf != NULL);

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

Предположим, что мы какое-то время используем наш буфер и делаем какую-то важную работу (хотя здесь в нашем примере на самом деле делать нечего). Затем мы освобождаем буфер, возвращая выделенную область памяти обратно в libc. Если мы не освободим выделенные буферы, ОС автоматически освободит их, когда наш процесс завершится. Из-за этого для простых программ на Си тебе не требуется освобождать все указатели на хип-буферы. Но если ты пишешь серьёзную программу, и использование памяти для твоего приложения будет продолжать расти и расти, пользователь не будет этим доволен. И скорее всего, твоё приложение через какое-то время упадёт из-за OOM. Освобождение выделенных буферов является обязательным для нормального софта. Иногда кажется очень сложным отслеживать каждый указатель на выделенный буфер, но это цена, которую мы платим за 100% контроль над нашим приложением. Благодаря этому, программы на Си могут работать на системах с очень ограниченным объёмом доступной памяти, тогда как программы на других языках не выдерживают таких условий. Я предполагаю, что ты уже знаком с техникой goto end в Си или auto_ptr<> в Си++ для эффективной обработки ситуаций освобождения буфера без каких-либо проблем.

Вот и всё, наш пользовательский код написан! Теперь давай обсудим платформо-зависимый код отдельно для UNIX и Windows. Во-первых, обрати внимание, как я разделил код с помощью веток #ifdef-#else:

#ifdef _WIN32

static inline void func()
{
	...код для Windows...
}

#else // UNIX:

static inline void func()
{
	...код для UNIX...
}

#endif

Я использую один и тот же подход во всех примерах кода здесь. В течение нескольких лет я перепробовал много разных подходов к управлению кросс-платформенным кодом… Теперь моё последнее решение на самом деле самое простое и прямолинейное: я просто использую статические инлайн функции (чтобы они не компилировались внутрь бинарника, если я их не использую) и реализовываю их в одном файле, разделённом на 1 ветку #ifdef верхнего уровня. Я хочу, чтобы каждый пример был единым отдельным файлом без лишних директив #include, и в то же время чтобы код внутри main() был без каких-либо веток препроцессора.

Препроцессорный _WIN32 устанавливается автоматически, когда мы компилируем для Windows — так компилятор узнаёт, какую ветку выбрать, а какую игнорировать.

Функции хип-памяти в Windows

Ладно, теперь прокрути вверх до ветки #ifdef _WIN32.

#include 

Это единый инклуд файл верхнего уровня для системного АПИ в Windows (он, в свою очередь, включает в себя множество других файлов, но нам это уже не важно). Почти все необходимые функции и константы становятся нам доступны после инклуда windows.h. Не самый эффективный способ с точки зрения скорости компиляции (для каждой единицы компиляции препроцессор анализирует десятки инклуд файлов Windows), но способ очень простой и его трудно забыть — это может сэкономить некоторое время программистам при написании кода. Так что, может быть, это в действительности большое преимущество?

Вот функция для выделения буфера в Windows:

void* heap_alloc(size_t size)
{
	return HeapAlloc(GetProcessHeap(), 0, size);
}

HeapAlloc() выделяет область памяти необходимого размера и возвращает указатель на начало буфера. Первый параметр — это дескриптор (т.е. идентификатор) хип-памяти. Обычно мы просто используем GetProcessHeap(), который возвращает дескриптор хип-памяти по умолчанию для нашего процесса. Обрати внимание, что параметр size должен иметь тип size_t, а не int, потому что в 64-битных системах мы можем захотеть выделить огромную область памяти >4ГБ. 32-битного целочисленного типа для этого недостаточно, поэтому size_t.

Вот как мы освобождаем наш буфер:

void heap_free(void *ptr)
{
	HeapFree(GetProcessHeap(), 0, ptr);
}

Указатель, который мы передаём в HeapFree(), должен быть точно таким же, каким его нам изначально вернула функция HeapAlloc(). Не делай никаких арифметических операций с указателями на хип-буфер, ведь, потеряв его, ты не сможешь правильно его потом освободить. Если тебе нужно заинкрементить (увеличить) этот указатель, сделай это с его копией (или сохрани оригинал где-нибудь). Если ты попытаешься освободить неправильный указатель, программа может упасть.

Как видишь, названия наших функций почти такие же, как и у функций Windows. Я везде следую одному и тому же правилу: каждая функция начинается с названия своего контекста (в нашем случае — heap_), затем следует глагол, который определяет, что мы делаем с этим контекстом. В программировании на Си очень удобно полагаться на автоматические подсказки, которые показывают наши редакторы кода, когда мы пишем код. Когда я хочу что-то сделать с хип-памятью, я пишу heap, и мой редактор кода сразу показывает мне все функции, которые начинаются с этого префикса. У Microsoft на самом деле тут такая же логика, и у них тут правильные имена для обеих функций HeapAlloc()/HeapFree(). Но, к сожалению, это всего лишь исключение из правил.

Функции хип-памяти в UNIX

Теперь давай посмотрим, как работать с хип-памятью в UNIX.

#include 

В системах UNIX нет единого инклуд-файла как в Windows. А этот конкретный файл включает в себя лишь объявления для функций динамической памяти, а также некоторых основных типов (size_t).

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

void* heap_alloc(size_t size)
{
	return malloc(size);
}

Функция возвращает NULL при ошибке, но в Linux не всегда полагайся на это поведение, потому что твоё приложение может аварийно завершить работу при записи фактических данных в буфер, возвращаемый malloc().

Освобождение указателя буфера:

void heap_free(void *ptr)
{
	free(ptr);
}

Как и в Windows, попытка освободить неправильный указатель может привести к падению процесса. Зато можно освобождать указатель NULL, это абсолютно безвредно.

Как видишь, имена функций в UNIX сильно отличаются от того что в Windows. Тут не используется кэмэл-кейс (camel-case), имена функций часто очень короткие (иногда слишком короткие), они даже не имеют одного и того же префикса или суффикса. На мой взгляд, мы должны привнести сюда некоторые правила и логику… Я думаю, что мои имена функций, начинающиеся с префикса, лучше и понятнее для меня, а также для тех, кто читает мой код. Поэтому я выбрал эту схему именования для всех своих функций, структур и других объявлений — всё следует одному и тому же правилу.

Аллокация объектов в хип-памяти

Когда мы выделяем массивы простых данных на хипе, нам обычно всё равно, содержат ли они какие-то мусорные данные, потому что мы всегда отдельно храним переменную для индекса/длины массива, которая всегда вначале равна 0 (у массива ещё нет активных элементов). Затем, пока мы заполняем массив, мы равномерно увеличиваем индекс, например так:

	int *arr = heap_alloc(100 * sizeof(int));
	size_t arr_len = 0;
	arr[arr_len++] = 0x1234;

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

struct s {
	void *ptr;
};
...

	struct s *o = heap_alloc(sizeof(struct s));
	...
	// Осторожно, не используй случайно `o->ptr`, так как пока он содержит мусор!
	...
	o->ptr = ...;

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

#ifdef _WIN32

void* heap_zalloc(size_t n, size_t elsize)
{
	return HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, n * elsize);
}

#else

void* heap_zalloc(size_t n, size_t elsize)
{
	return calloc(n, elsize);
}

#endif

1-й параметр — это количество объектов, которые мы хотим выделить, а 2-й параметр — это размер 1 объекта. Мы используем флаг HEAP_ZERO_MEMORY в Windows, при котором ОС занулит содержимое буфера, прежде чем вернуть его нам.

Теперь мы можем использовать нашу функцию для создания объекта и немедленного обнуления его содержимого:

	struct s *o = heap_zalloc(1, sizeof(struct s));

    
            

© Habrahabr.ru