Я пишу компоновщик (linker). Не совсем обычный. Он ориентирован не на создание исполняемых файлов, а на облегчение быстрой итерации программы без необходимости в перекомпоновке (re-link) и повторном открытии после внесения изменений. Это «горячая загрузка» кода с детализацией. Подробности — к старту курса по разработке на С++.
Как я узнал из статьи Андреаса Фредериксона Hot Runtime Linking (archive.org), желанной мне динамичности в компилируемой среде перед выполнением можно достичь благодаря управлению компоновкой и загрузкой. Я пытался сделать горячую загрузку в Cakelisp, но она вышла неустойчивой и ограниченной. Работа на этапе компоновки/загрузки не только подходила лучше, но и означала возможность горячей загрузки не только кода, написанного на Cakelisp.
Цель этого компоновщика — внести в компилируемые языки динамическую среду. Я называю его «компоновщиком-загрузчиком», ведь его цель не в создании исполняемого файла, как у большинства компоновщиков, а в компоновке, загрузке и исполнении кода, и ещё возможности делать всё это при постоянно выполняемом процессе.
Полагаю, поддержка работы программы и внесение в неё изменений поменяют ваше отношение к ней. Чрезвычайно короткое время итераций побуждает к экспериментам. Сэкономленное время накапливается во время разработки, а принятие изменений с меньшим трением сделает продукт лучше.
Как жил мир без этого компоновщика?
Горячая загрузка на языках семейства C обычно выполняется через загрузку динамическую. В Windows динамическая загрузка выполняется через LoadLibrary
в файлах .dll
, а в GNU/Linux — через libdl
, которые загружают файлы .so
(shared object). Но у такого подхода немало ограничений:
- Требуется другая структура проекта, а перезагружаемые части должны будут входить в динамические библиотеки. Всё это усложнит сборку и, возможно, файловую систему.
- Динамические библиотечные переменные не сохранятся при загрузке1.
- Управление памятью между DLL может вызывать проблемы на Windows. Может понадобиться рефакторинг кода, который не был заранее рассортирован по библиотекам DLL.
- В разных операционных системах динамическая загрузка построена по-разному. В Windows всему коду потребуется громоздкая аннотация
__declspec(dllexport)
. И, если вы привыкли «стелсить» в духе Unix, она обернётся для вас болью.
Ещё одна альтернатива — компиляция на лету (JIT-компиляция). Но к системам JIT у меня есть несколько вопросов:
- Обычно для работы «джитированным» кодом нужны ограниченные интерфейсы. То есть в проекте поменять можно далеко не всякий код, например его «скриптовая» часть этой операции не поддастся. При этом трудно провести черту между кодом, который можно и нельзя подвергать динамической загрузке. Та же проблема возникает со встроенными динамическими скриптовыми языками, такими как Lua и Python. А сколько кода для вашего приложения может быть написано как раз на них?
- JIT-компиляция нуждается в генерации машинного кода. Обычно это сложный процесс, и он требует большого объёма работ по отладке. На практике приходится возлагать его на сторонние JIT-библиотеки, которые, как правило, являются большими зависимостями.2.
Что будет уметь делать мой компоновщик?
В данный момент я задумал построить интерфейс так:
- Компилируйте ваш объект как хотите, с получением любой массы объектных файлов или их архивов (и, в конечном счёте, динамических библиотек)3.
- Вместо использования
ld
,link.exe
и других возможностей GNU для компоновки ваших объектов в исполняемый файл, вызовитеlinker-loader [ваш список объектов]
... - Компоновщик-загрузчик (
linker-loader
) выполнит одну из следующих задач:- При первом вызове он скомпонует и загрузит объекты напрямую, а затем выполнит точку входа (
_start
,main
или то, что вы выберете). Это означает, что ваша программа начнёт выполняться без компиляции в отдельный исполняемый файл. - При последующих вызовах, если программа ещё выполняется4, компоновщик будет лишь перекомпоновывать (re-link) и загружать объектные файлы, которые были изменены. При новом вызове
linker-loader
«увидит», что программа всё ещё выполняется, и сообщит существующему процессуlinker-loader
о необходимости перезагрузки изменённых объектов. Уже одно это решение должно сэкономить ваше время, потому что вам больше не потребуется перекомпоновывать всю программу каждый раз, когда вы вносите изменения. -
linker-loader
сделает всё возможное, чтобы полностью сохранить состояние при перезагрузке без необходимости в какой-либо специальной разметки с вашей стороны. Если вы внесёте изменения, которые он не сможет устранить, компоновщик-загрузчик должен будет спросить вас, что делать дальше. Например, если размер данных изменился и вы не против удаления старых данных, он сможет это сделать.
- При первом вызове он скомпонует и загрузит объекты напрямую, а затем выполнит точку входа (
Такой интерфейс не должен требовать большой предварительной работы для использования с существующим проектом. Однако автомодификация проекта и анализ собственных наработок потребуют дополнительной работы.
Похожие проекты и источники вдохновения
Заняться этим проектом меня побудили очень многие идеи:
- Из Hot Runtime Linking (archive.org) Андреаса Фредериксона я вынес саму идею создания компоновщика-загрузчика.
- Game Oriented Assembly Lisp (GOAL) от Naughty Dog был компилируемым языком с динамической средой. Он создан в 2001 году для Playstation 2. Если Энди Гэвин смог построить такой функционал уже тогда, почему бы не сделать это сейчас?
- Malleable Systems побудили стремиться к гибкости программ. Не уверен, что этот проект правильно раскрывает необходимый пользователю функционал, но я буду стремиться к этому на протяжении всей своей работы.
- Опыт работы с Emacs помог мне понять ценность простой настройки программы под себя. Я удивлён тому, как далеко ушли в этом ребята из Emacs, одного из старейших в мире проектов с открытым кодом.
- Доклад Стивена Келла Liberating the Smalltalk lurking in C and Unix помог мне расширить горизонты и внести в программирование на C больше динамики и интроспекции.
Перечисленные ниже проекты схожи с моим, но подходы в них иные:
- В Runtime Compiled C++ для читаемости кода используется разметка C++. Затем код компилируется и загружается через DLL. Вот как это работает.
- Live++ «работает на бинарном уровне при помощи .PDB, .EXE, .DLL, .LIB, и .OBJ. Большая часть необходимой информации извлекается из исполняемых и объектных файлов и реверсируется». Это очень похоже на мой подход. На мой взгляд, два основных недостатка Live++ — это:
- отсутствие поддержки GNU/Linux, в которых я разрабатывал свою программу;
- использование закрытого проприетарного кода, который, как я считаю, должен быть свободным.
- Edit and Continue предназначен в Visual Studio для редактирования любого кода из вашего проекта в реальном времени и почти волшебного применения внесённых изменений. Ни мне, ни моим коллегам такое ни разу не удавалось. Ходят слухи, что этот функционал Visual Studio плохо поддерживается, особенно для больших проектов вроде игр (на которых я как раз и специализируюсь).
Другие применения
Этот компоновщик облегчит интроспекцию программы. Я намереваюсь, чтобы символы, которые сам компоновщик предоставляет образу программы, позволят программе проверять её же символы. Это откроет путь к целому ряду интересных возможностей:
- вызов из вашей программы любой функции в рамках интерактивного цикла «чтение — вычисление — запись»;
- визуализация размеров компилируемых функцией;
- визуализация ссылок функции5.
- анализ внутренних данных программы;
- …и многое другое, что пока ещё не пришло мне в голову!
Моменты, с которыми я ещё разбираюсь
К отладке я пока даже не прикасался. Моему компоновщику-загрузчику нужны возможности, которые неизбежно сделают образ программы уникальным в сравнении с обычными компонуемыми файлами. То есть мне потребуется сделать что-то особенное, чтобы отладчики могли находить отладочные символы в тех местах памяти, где мой загрузчик будет размещать исполняемый код. Даже быстрого взгляда на отладочную информацию DWARF достаточно, чтобы понять, что она изрядно сложная.
Мой компоновщик-загрузчик предназначен прежде всего для помощи в разработке, поэтому я сосредоточился на поддержке своей основной архитектуры разработки, x86-64 (она же AMD64). Компоновщики зависят от архитектуры компьютера, поэтому для поддержки каждой архитектуры нужно добавлять по одному. Это не означает, что ваша программа будет работать только на x86-64. Она может поддерживать надмножество архитектур, поддерживаемых моим компоновщиком, при этом для создания исполняемых файлов для других архитектур вам потребуется другой компоновщик.
Что касается программного обеспечения, всю начальную разработку я веду на GNU/Linux, а когда убеждаюсь в ценности концепции, портирую на Windows. Таким образом, я не прорабатывал свою задачу под Windows Portable Executable или Common Object File Formats. Если я увижу, что могу реализовать свои намерения на GNU/Linux (где используются исполняемые файлы ELF), я портирую компоновщик на Windows.
С разделом данных программы есть сложности, с которыми ещё предстоит разобраться. Например, вы можете сколько угодно менять функции, сохраняя данные при перезагрузке. Но при изменении вами размера элементов данных и их состава компоновщику придётся проделать определённую работу, чтобы попытаться сохранить незатронутые данные. Для этого, вероятно, потребуется определённая поддержка символов отладки, чтобы определить, где что содержится в данных, и предположить, изменились ли они с момента последней загрузки. Мне предстоит ещё немало поэкспериментировать, прежде чем я смогу выявить ограничения этой системы, но в идеале во многих случаях можно будет менять данные без перезагрузки программы.6
Будем на связи!
Присылайте свои мысли по поводу компоновщика на почту: macoy [at] macoy [dot] me.
Текущую версию кода можно посмотреть здесь. На момент публикации этой статьи поддерживаются загрузка объектных файлов в формате ELF для архитектуры x86-64, обработка перемещений файла и корректный вызов объектного файла. Это пока что ни разу не релиз. Когда программа выйдет, я напишу об этом новую статью в своём блоге.
- Вместо этого я решил проблему в Cakelisp с помощью автопреобразования статических переменных в неупорядоченный массив (heap allocate), но это пока что «костыль», и далеко мы на нём не уйдём.
- Примером JIT-библиотеки для C является libgccjit (на базе GCC). Такую можно построить и на базе LLVM. По моим меркам, и GCC, и LLVM — тяжеловесные зависимости. Миниатюрный C-компилятор может послужить примером того, как должна выглядеть лёгкая библиотека, но это всё ещё сложная зависимость.
- По идее, любой компилируемый язык, на котором создаются объектные файлы, должен автоматически работать с этим компоновщиком. Но на деле, думаю, появятся несовместимости с некоторыми языками. Они потребуют поддержки в каждом отдельном случае. Например, всё, что генерирует код в ходе компоновки, не будет работать с этой системой, пока не будет обеспечена поддержка.
- Тут возникают дополнительные проблемы. Для безопасного редактирования образа программы я бы в первую очередь требовал от программы возврата контроля компоновщику-загрузчику. Таким образом, программе потребуется код, который распознаёт, что у неё запросили перезагрузку. При этом возврат будет распространяться на весь стек во всех потоках, чтобы код можно было изменить. Когда я больше об этом узнаю, я смогу организовать такое распознавание лучше. Обратите внимание, что в этом случае программе не нужно закрывать окно и освобождать всё своё состояние для перезагрузки, ей нужно только не выполнять код в перезагружаемых секциях. Затем она сможет продолжить работу с того места, где остановилась, с неизменными данными.
- Это ограничено оптимизацией компилятора. Например, функция на уровне модуля (module-local function, на C маркируется как
static
) будет обеспечиваться относительной ссылкой компилятора, и запись о ссылке не попадёт в компоновщик.
- Изменение размера и структуры данных, на которые ссылаются другие данные, является явным исключением. Здесь пользователю придётся прописать для компоновщика функцию миграции, которая сообщит компоновщику, как обрабатывать такие случаи. С большой долей вероятности это окажется нецелесообразным, если сравнивать время написания такой функции со временем перезапуска процесса.
Научим вас аккуратно работать с данными, чтобы вы прокачали карьеру и стали востребованным IT-специалистом. Новогодняя акция — скидки до 50% по промокоду HABR.
Data Science и Machine Learning
- Профессия Data Scientist
- Профессия Data Analyst
- Курс «Математика для Data Science»
- Курс «Математика и Machine Learning для Data Science»
- Курс по Data Engineering
- Курс «Machine Learning и Deep Learning»
- Курс по Machine Learning
Python, веб-разработка
- Профессия Fullstack-разработчик на Python
- Курс «Python для веб-разработки»
- Профессия Frontend-разработчик
- Профессия Веб-разработчик
Мобильная разработка
Java и C#
- Профессия Java-разработчик
- Профессия QA-инженер на JAVA
- Профессия C#-разработчик
- Профессия Разработчик игр на Unity
От основ — в глубину
А также