Fil-C - это безопасная с точки зрения работы с памятью реализация C и C++, цель которой - позволить коду на C - включая арифметику указателей, объединения и другие возможности, часто приводящие к проблемам в языках с безопасной памятью - выполняться безопасно, без изменений. Ориентация на «фанатичную совместимость» делает Fil-C привлекательным вариантом для внедрения гарантий безопасной памяти в существующие приложения. Несмотря на относительную молодость проекта и единственного активного разработчика, Fil-C уже способен компилировать полностью безопасное с точки зрения памяти пользовательское пространство Linux (основанное на Linux From Scratch), хотя для некоторых более сложных программ требуются модификации. В проект также входят безопасная работа сигналов и конкурентный сборщик мусора.
Fil-C - это форк Clang; он доступен под лицензией Apache v2.0 с исключениями LLVM для рантайма. Изменения из основного компилятора периодически мерджатся в проект; на данный момент Fil-C основан на версии 20.1.8 от июля 2025 г. Проект - личное детище Филипа Пизло, который ранее работал над рантаймами ряда управляемых языков, включая Java и JavaScript. Когда он начинал проект, он даже не был уверен, что это вообще возможно.
Первоначальная реализация была неприемлемо медленной, поскольку требовала вставлять множество различных проверок безопасности. Это дало Fil-C репутацию медленного решения. Однако с тех пор, как первоначальная реализация доказала свою жизнеспособность, Пизло смог оптимизировать множество распространённых случаев, и код, сгенерированный Fil-C, стал всего в несколько раз медленнее кода, созданного Clang, хотя точная замедленность сильно зависит от структуры тестируемой программы.
Надёжное бенчмаркинг-тестирование печально известно своей капризностью, но чтобы хотя бы примерно понять, станет ли такое влияние на производительность проблемой, я собрал Bash версии 5.2.32 с помощью Fil-C и попробовал использовать его как свою командную оболочку. Bash - почти идеальный случай для Fil-C, потому что он проводит больше времени, запуская внешние программы, чем выполняя собственный код, но я всё р��вно ожидал заметной разницы в производительности. Так вот, её не было. Поэтому, по крайней мере для некоторых программ, накладные расходы Fil-C на практике не выглядят проблемой.
Чтобы поддерживать различные проверки безопасности во время выполнения, Fil-C использует другой внутренний двоичный (бинарный) интерфейс приложений (ABI, Application Binary Interface), чем Clang. В результате объекты, скомпилированные с помощью Fil-C, не смогут корректно линковаться с объектами, созданными другими компиляторами. Однако поскольку Fil-C является полной реализацией C и C++ на уровне исходного кода, на практике это просто означает, что всё должно быть перекомпилировано Fil-C. Межъязыковая линковка, например с Rust, в настоящее время проектом не поддерживается.
Возможности
Основная задача в превращении C в безопасный язык - управление указателями. Это особенно сложно из-за того, что, как показал долгий путь к CHERI-совместимости, многие программы ожидают, что указатель будет 32- или 64-битным, в зависимости от архитектуры. Fil-C пробовал несколько разных способов представления указателей с момента начала проекта в 2023 году. Первые указатели в Fil-C были 256-битными, не потокобезопасными и не защищали от использования после освобождения памяти. Текущая реализация, называемая InvisiCaps, позволяет иметь указатели, внешне соответствующие естественному размеру указателя на архитектуре (хотя при этом требуется хранить дополнительную информацию где-то ещё), с полной поддержкой многопоточности и перехватыванием use-after-free ошибок, ценой некоторой накладной нагрузки во время выполнения. Сложность реализации в том, как хранить эти две дополнительные информации так, чтобы это выглядело для программы как обычный 64-битный указатель.
Когда Fil-C выделяет объект в куче, он добавляет два машинных слова метаданных перед началом объекта: верхнюю границу, которая используется для проверки доступов на основе размера объекта, и aux-слово, используемый для хранения дополнительной метаинформации об указателях. Когда программа впервые записывает указатель в объект, рантайм выделяет новое вспомогательное размещение такого же размера, что и объект, в который производится запись, и помещает фактический аппаратный указатель (то есть указатель без capability) в aux-слово объекта. Это вспомогательное размещение используется для хранения capability-информации, связанной с сохранённым указателем (и повторно используется для всех следующих указателей, записываемых в объект). Значение адреса сохраняется в объекте обычным образом, так что любые битовые операции C над значением указателя работают как ожидается.
Такой подход действительно приводит к тому, что структуры, содержащие указатели, в итоге используют вдвое больше памяти, и каждая загрузка указателя включает дополнительное разыменование через aux-слово. На практике, как утверждает документация, накладные расходы этого метода заставляют большинство программ работать примерно в четыре раза медленнее, хотя эта величина сильно зависит от того, насколько активно программа использует указатели. Тем не менее, у автора Fil-C есть идеи нескольких оптимизаций, которые, как он надеется, со временем смогут снизить эти накладные расходы.
Одна сложность этого подхода - атомарный доступ к указателям, то есть использование _Atomic или volatile. К счастью, нет проблемы, которую нельзя решить ещё большей степенью косвенности указателей: когда программа загружает или сохраняет значение указателя атомарно, вместо того чтобы вспомогательное выделение напрямую содержало capability-информацию, оно указывает на третье 128-битное выделение, содержащее capability и значение указателя вместе. Такое выделение может обновляться 128-битными атомарными инструкциями, если платформа их поддерживает, либо через создание новых выделений и атомарное переключение указателей на них.
Поскольку aux-слово используется для хранения значения указателя, Fil-C может применять тегирование указателей, чтобы хранить в нём дополнительную информацию: например, пометку специальных типов объектов, которые должны обрабатываться иначе, таких как функции, потоки и выделения через mmap(). Оно также используется для пометки освобождённых объектов, так что любая попытка обращения приводит к сообщению об ошибке и аварийному завершению.
Управление памятью
Когда объект освобождается, его aux-слово помечает его как освобождённый объект, что позволяет вспомогательной области быть немедленно возвращённой. Однако сам исходный объект нельзя освободить сразу: иначе программа могла бы освободить объект, затем выделить новый объект на том же месте и таким образом скрыть ошибку использования после освобождения (use-after-free).
Вместо этого Fil-C использует сборщик мусора, который освобождает память объекта только после того, как исчезнут все указатели на него. В отличие от других сборщиков мусора для C - таких как сборщик Бёма–Демерса–Вайзера - Fil-C может использовать capability-метаданные для точного отслеживания живых объектов.
Сборщик мусора Fil-C является как параллельным (чем больше ядер, тем быстрее работает сборка), так и конкурентным (сборка выполняется без остановки программы). Технически сборщик требует, чтобы потоки время от времени ненадолго приостанавливались, чтобы сообщить ему, где находятся указатели на стеке, но это делается только в специальных «точках безопасности»; иначе программа может загружать и изменять указатели без уведомления сборщика.
Точки безопасности служат барьером синхронизации: сборщик не может считать объект «мусором», пока каждый поток не прошёл хотя бы одну точку безопасности после завершения маркировки. Эта синхронизация выполняется атомарными инструкциями, поэтому на практике потоки приостанавливаются не более чем на несколько инструкций.
Исключением является реализация fork(), которая использует точки безопасности для временной остановки всех потоков, чтобы предотвратить гонки при порождении процессов. Fil-C вставляет точку безопасности на каждом обратном переходе потока управления — то есть каждый раз, когда код может исполняться в цикле. В обычном случае вставленный код просто загружает регистр-флаг и проверяет, не запрашивает ли сборщик мусора каких-либо действий. Если сборщик делает запрос, поток запускает колбэк для выполнения нужной синхронизации.
Fil-C использует тот же механизм точек безопасности для реализации обработки сигналов. Обработчики сигналов выполняются только тогда, когда прерванный поток достигает точки безопасности. Это в свою очередь позволяет обработчикам сигналов выделять и освобождать память, не нарушая работу сборщика мусора; malloc() Fil-C является безопасным для сигналов.
Memory-safe Линукс
Linux From Scratch (LFS) - это учебное пособие по компиляции собственного полного пользовательского пространства Linux. Оно описывает шаги по компиляции и установке всего основного программного обеспечения, необходимого для типичного пользовательского пространства Linux в окружении chroot(). Пизло успешно прошёл путь LFS с Fil-C, создав безопасную с точки зрения памяти версию, хотя для сборки некоторых фундаментальных компонентов, таких как собственный рантайм Fil-C, библиотека GNU C и ядро, по-прежнему нужен не-Fil-C компилятор. (Хотя рантайм Fil-C использует нормальную копию библиотеки GNU C для выполнения системных вызовов, программы, которые компилирует Fil-C, используют собранную Fil-C версию библиотеки.)
Процесс в основном идентичен LFS вплоть до конца главы 7, потому что все шаги до этого момента заключаются в использовании инструментов перекрёстной сборки для получения рабочего компилятора в окружении chroot(). Единственное отличие - инструменты перекрёстной сборки создаются с другим префиксом установки, чтобы они не конфликтовали с Fil-C. После этого создаётся копия Fil-C, и её можно использовать, чтобы в основном заменить существующий компилятор. Оставшиеся шаги LFS остаются без изменений.
Скрипты для автоматизации процесса включены в Git-репозиторий Fil-C, включая некоторые шаги из Beyond Linux From Scratch, позволяющие получить рабочий графический пользовательский интерфейс и несколько более сложных приложений, таких как Emacs.
В целом Fil-C предлагает удивительно полноценное решение для превращения существующих программ на C в безопасные с точки зрения памяти. Хотя он не устраняет неопределённое поведение, не относящееся к памяти, наиболее опасные и трудно предотвращаемые уязвимости в C-программах обычно связаны с эксплуатацией небезопасных операций с памятью. Разработчикам, которые уже рассматривали Fil-C и отвергли его из-за ранних проблем с производительностью, возможно, стоит взглянуть на него ещё раз - хотя тем, кто рассчитывает на стабильность, возможно, стоит подождать, пока кто-то другой «прыгнет первым», с учётом относительной незрелости проекта. Тем не менее, для существующих приложений, где значительное снижение производительности предпочтительнее эксплуатации уязвимости, Fil-C - отличный выбор.
rsashka
Сборщик мусора для С и С++?