[Перевод] Исходники Borland C++ 2.0 восстановили из бинарника: BCC.EXE пересобирается байт в байт

Исходники Borland C++ 2.0 никогда не публиковались. Теперь у компилятора 1991 года есть исходный код, восстановленный из его же бинарника: если собрать его оригинальным тулчейном Borland, получается BCC.EXE, побайтово совпадающий с фирменным. Ниже перевод README проекта borland-reborn, описания метода и основной части списка находок: что пришлось угадывать про компилятор и его ключи, почему код местами написан «не как человек», и какие ошибки нашлись в самом Borland C++.
Примечание переводчика. Перевод собран из трёх документов репозитория: README, docs/HOW-IT-WORKS.md и bcc20/docs/FINDINGS.md (из последнего переведены первые три раздела, хвост с мелкими находками и заметками о переименованиях опущен). Оригинальных программ Borland в репозитории нет: для пересборки их дистрибутивы нужно скачать самому. Инструментарий Exhume, на котором сделана работа, по описанию его репозитория — набор инструментов и навыков (skills) для Claude Code, выросший из декомпиляции Ultima Underworld II.
borland-reborn
DOS-компиляторы Borland, пересобранные из их собственных бинарников.
Этот репозиторий восстанавливает исходный код классических компиляторов Borland по опубликованным исполняемым файлам. Цель — чтобы восстановленный код на C и ассемблере, собранный оригинальным тулчейном Borland, давал каждый исполняемый файл байт в байт. Потом этот код делается читаемым: настоящие имена, именованные поля записей, комментарии к каждому файлу и заметки о том, как компилятор работает и где он ошибается.
Каталог | Программа | Статус |
|---|---|---|
Borland C++ 2.0 (1991), | Готово: пересобирается побайтово (SHA-256 | |
Borland C++ 2.0 (1991), | Готово: пересобирается побайтово (SHA-256 | |
| Начато: запись вызовов, стенд воспроизведения, первые функции рантайма |
Позже добавятся другие версии Borland и Turbo, каждая отдельным каталогом верхнего уровня, с общими scripts/ и инструментами сборки Exhume.
Borland C++ 2.0 в цифрах
298 исходных файлов в 15 каталогах подсистем: 265 файлов на C (Borland C++ 2.0 и Turbo C 2.01) и 33 модуля, написанных вручную на TASM. Разделение оригинала на скомпилированный C и ручной ассемблер сохранено: ассемблер ни разу не переписывали на C насильно, а скомпилированный код ни разу не оставляли ассемблером.
Каждое имя восстановлено или описательное: около 3100 функций, глобальных переменных и меток, 300 полей записей (
src/include/FIELDS.H), 40 раскладок записей (src/include/RECORDS.H), константы сообщений, токенов, видов типов и операторов.Каждый файл задокументирован: строка о назначении, обзор и комментарии к функциям (около 10 000 строк комментариев), карта исходников и описание того, как устроен компилятор.
Находки: вероятные ошибки в оригинале, дублированный код, мёртвый код и то, как компилятор Borland был устроен внутри (bcc20/docs/FINDINGS.md). Несколько ярких: парсер написан вручную, рекурсивным спуском, с парсером выражений по приоритету операторов (никакого yacc); слияние хвостов в генераторе кода определило, как пришлось писать исходники;
strrchrчитает байт перед пустой строкой; освобождённое место в файле предкомпилированных заголовков никогда не используется повторно.
Пересобрать самому
Нужны оригинальные программы Borland (в репозиторий они не входят, см. NOTICE.md). Полное руководство — в docs/SETUP.md, а если коротко:
git clone --recurse-submodules https://github.com/abedegno/borland-reborn
cd borland-reborn
# 1. download Borland C++ 2.0 and Turbo C 2.01 from WinWorld (links in docs/SETUP.md)
python3 scripts/setup-toolchains.py --bc20 "Borland CPP 2.0 (1991) (3.5-720k).7z" --tc201 "<Turbo C 2.01>.7z"
# 2. Python packages and the DOS emulator (dosbox-x), node
python3 -m venv .venv && .venv/bin/pip install -r exhume/tools/requirements.txt
# 3. generate the link inputs from your own BCC.EXE, then compile, verify and link everything
cd bcc20
../.venv/bin/python scripts/prepare.py
../.venv/bin/python ../exhume/tools/gate.py check --all
Последняя команда компилирует все 298 исходников в DOS, сверяет каждую функцию с оригиналом байт в байт, проверяет каждую релокацию и каждое имя и линкует новый BCC.EXE, идентичный борландовскому.
Чтобы менять сам компилятор, есть сборка для модификаций. Она линкует те же исходники с раскладкой, которую выбирает TLINK, поэтому любая функция может вырасти или уменьшиться, между любыми двумя элементами данных можно вставить новые данные, можно добавлять новые исходные файлы. Проверяется она так: 1836 компиляций прогоняются обоими компиляторами (среди них примеры программ самого Borland и старые тесты GCC и cfront), и все результаты сравниваются, причём код и данные сдвигаются несколькими способами, с новыми и изменёнными файлами. Трассирующий эмулятор показывает, какие слова данных компилятор использует как адреса и какую часть его кода тесты выполняют: 83% (bcc20/docs/COVERAGE.md). Подробности — в bcc20/docs/MODDING.md.
Метод (подбор компилятора и ключей, подгонка каждой функции, проверка того, чего не видит сравнение, линковка всей программы TLINK и проход на читаемость, всё под тем же побайтовым гейтом) описан в docs/HOW-IT-WORKS.md, его перевод ниже.
Исправления имён, комментариев и находок приветствуются, см. CONTRIBUTING.md. Собственные скрипты и документация проекта распространяются по лицензии MIT (LICENSE). Восстановленные исходники получены из защищённых авторским правом программ Borland, выложены для изучения и сохранения, никаких прав на них не заявляется (NOTICE.md).
Как это работает
Цель — исходный код, который пересобирает опубликованный исполняемый файл в точности: тот же компилятор, те же ключи, те же байты. Побайтово идентичный результат — единственная проверка, с которой не поспоришь. Если пересобранная программа равна оригиналу, исходник честно её описывает. Каждое последующее изменение, включая все переименования и комментарии, проверяется тем же тестом.
Работа сделана на Exhume, наборе инструментов для декомпиляции DOS-программ (и части прошивок) с побайтовым совпадением. Здесь описан метод в том виде, в каком он применялся к BCC.EXE из Borland C++ 2.0.
1. Определить тулчейн
BCC.EXE был собран самим Borland C++ 2.0: TASM 2.51 для ручных частей и TLINK 4.0 с борландовским оверлейным менеджером VROOMM. Функция совпадает только тогда, когда угаданы компилятор, его версия и все ключи (-mm -G -O -Z -p, плюс -Y для оверлейного кода). Правильность определения доказана компиляцией функции в те же байты. Позже выяснилось, что 29 модулей библиотеки времени выполнения собраны более старым компилятором, Turbo C 2.01: они копируют дальние указатели через es:bx и собирают dx:ax перед push, а BC 2.0 так никогда не делает.
2. Разметить программу
Исполняемый файл разбили на сегменты и оверлеи, затем на процедуры и, там, где улики показывали границы модулей, на исходные файлы оригинала. Уликами служили выравнивающие байты между модулями, порядок релокаций и имена сегментов. Для каждого куска составили целевую таблицу: какие байты оригинала должен воспроизвести этот исходный файл.
3. Добиться совпадения, по одному файлу
Для каждого файла: написать код на C (или на TASM), скомпилировать его в DOS оригинальным компилятором, сравнить каждую функцию с оригиналом, прочитать разницу в инструкциях и менять исходник, пока различий не останется. Как писать исходник, диктуют привычки компилятора. Например, BC 2.0 удаляет недостижимые переходы до того, как сливает общие хвосты кода, поэтому некоторые функции совпадают только с ранним return или с повторённым оператором в том месте, где их, по-видимому, и написал человек. Такие места помечены комментариями match:. Всё время соблюдались два правила:
Сохранять язык оригинала. Модули, написанные вручную на ассемблере, остаются на TASM. Модули, которые были скомпилированы из C, остаются на C, даже если вариант на ассемблере дался бы легче (
DVPRINTF.Cвосстановили так в самом конце).Никаких фокусов. Никакой ассемблерной вставки вместо скомпилированного кода, никаких патчей байтов. Где в оригинале явно был странный код (мёртвые присваивания, лишние проверки), исходник воспроизводит его и прямо об этом говорит.
4. Проверить то, чего не видит сравнение
Побайтовое сравнение маскирует места, которые заполнит релокация, поэтому verify проверяет каждое из них: что каждая внешняя ссылка разрешается в нужный символ по нужному адресу, что данные и BSS разложены так же, и что имена совпадают с глобальной таблицей символов (symbols.tsv). Эта таблица пересобирается с нуля при каждой полной проверке.
5. Слинковать всю программу
Все объектные файлы линкуются TLINK в новый BCC.EXE, вместе с оверлеями, и сравниваются с борландовским. Части программы, которые не являются скомпилированным кодом (стартовый код-клей, сегмент данных, обвязка оверлеев), живут в исходниках групп линковки (linkgroups/). Это ассемблерные файлы с исходной раскладкой. При линковке из них вырезаются области, которые теперь поставляют восстановленные исходники, а остальное ассемблируется. Финальная проверка — сравнение SHA-256 всего исполняемого файла.
6. Сделать код читаемым
Когда каждый байт доказан, исходник переписали для людей, шаг за шагом, с полной проверкой после каждого шага:
одна команда сборки и один гейт;
общие заголовки вместо объявлений в каждом файле;
именованные константы: сообщения, токены, виды типов и операторы, с именами из собственных таблиц BCC;
поля записей: около 300 имён полей для записей пулов BCC (
FIELDS.H), доступ к ним через общие функции (POOL.H) и 40 раскладок записей в виде структур (RECORDS.H);имена файлов и дерево каталогов по подсистемам;
комментарии к каждому файлу и каждой процедуре, плюс находки.
Имена никогда не угадывали. Для каждого записано основание (descriptive — описательное или inferred — выведенное) и улики, в файле bcc20/map/names.tsv. Имя говорит, что делает код, даже если его назначение так и осталось неясным (CODEGEN_UNUSED_POOL_ONE). Комментарии проверяли сравнением потока токенов с вырезанными комментариями, так что изменить код они не могут.
Что гарантирует гейт
gate.py check --all компилирует каждый исходник, сверяет и проверяет каждый, пересобирает таблицу символов, линкует и сравнивает исполняемый файл. Если проверка прошла, значит, исходники репозитория, собранные оригинальным тулчейном, дают в точности борландовский BCC.EXE. Как запустить это самому, описано в SETUP.md.
Находки
Что удалось узнать о BCC.EXE, пока его исходник восстанавливали и читали. У каждого пункта указана степень уверенности: подтверждено (есть второй свидетель: данные, таблица или сам путь исполнения), вероятно (ясно из кода, не проверялось), возможно (умозаключение). Пункты, найденные агентами, которые подбирали имена, там, где это отмечено, сверены с кодом.
Вероятные ошибки и странности (оставлены как есть: исходник воспроизводит оригинал)
strrchr читает перед строкой (подтверждено,
RSTRCHR.C): для пустой строки он смотрит наstring[-1]. Последствия: на практике безвредно, но порт не должен это копировать.strcmp возвращает разность байтов, расширенную по знаку через cbw (подтверждено,
RSCMP.C): знак неверен, когда два символа отличаются больше чем на 127. Последствия: имена сравниваются только на равенство, так что, скорее всего, безвредно.Процедура, которая вечно вызывает сама себя (вероятно):
CALL_SELF_FOREVER(RHAPI.C), заглушки кучиRH_SELF_CALL_*(их семь) иCALL_SELF_FOREVER_RG2BENDтолько вызывают сами себя, и никто не вызывает их. Вероятно, это заготовки в исходниках Borland, которые компилятор сохранил.Постинкремент битового поля через дальний указатель не компилируется (подтверждено запуском борландовского BCC.EXE,
tests/modbuild/BITFAIL.C):return f->count++;для битового поля структуры, до которой добираются через указатель, обрывает компиляцию с «Fatal: Register allocation failure» в моделях compact, large и huge, с-Oи без. Постдекремент падает так же. Преинкремент, модель small и глобальная структура компилируются. Последствия: такой код скомпилировать нельзя; порту воспроизводить этот отказ не нужно.Константы перечислений не работают во встроенном ассемблере C++ (подтверждено запуском борландовского BCC.EXE,
tests/modbuild/ASMENUM.CPP):asm mov cx, second, гдеsecond— элемент перечисления, в C++ обрывается с «Improper use of a typedef symbol», а в C проходит. ВENCODE_ASM_SYMBOL_ALT(ASMENC2.C, C+±близнец кодировщика имён для ассемблера) есть ветка, которая записывает значение элемента перечисления, но из-за этой ошибки до неё дело не доходит. Последствия: пишите число; порт мог бы это исправить.Вывод зависит от раскладки самой программы (вероятно, нашли внешние тесты сборки для модификаций): на двух старых тестах g++ из GCC компилятор Borland выдаёт результат, который меняется, когда сдвигается любой его код или данные. В
g++.other/bitfld3.C(-S), где?:между битовыми полями используется как lvalue, он записывает имя символа как@_из байтов, которые никогда не заполнял; вg++.law/access4.Cошибка доступа превращается в «Compiler table limit exceeded in function {». Добавление байтов-заполнителей в любую из пяти групп блоков кода из восьми меняет вывод, так что дело не в одном адресе, записанном как число. Вероятно, это чтение никогда не инициализированной памяти, например старой ячейки стека. Последствия: неверный текст на этих путях ошибок и листинга; оба файла исключены из сравнения (tests/modbuild/EXTERNAL.TSV).Файлы предкомпилированных заголовков содержат неинициализированные байты (подтверждено,
MACROS.C): MACRO_DEFINE округляет каждое определение макроса до чётного размера, байт выравнивания остаётся незаписанным.-Hпишет пул макросов вTCDEF.SYMв том виде, в каком он лежит в памяти. Поэтому в файле оказывается то, что раньше было в этой памяти кучи, а это зависит от раскладки памяти программы. Ещё файл хранит ближние адреса собственных данных BCC, поэтому подходит только тому компилятору, который его записал (tools/modtest.pyсравнивает его размер и проверяет, читая обратно). Последствия: безвредно, этот байт никогда не читается.Ключ -# использовать нельзя (подтверждено запуском BCC.EXE,
RARGV0F0.C): он читается до запуска менеджера памяти и задаёт размер кучи в параграфах, но затем разборщик опций его отвергает («Incorrect command line option»). С маленьким значением всё ломается раньше:-#60напечатал пустое «Error:». С-#1500компилятор в emu2 не завершился и через десять минут. Вероятно, это забытый отладочный ключ.Заглушки, возвращающие 0 (вероятно):
ASM_FLUSH_KIND2_STUB,ASM_COLON_LABEL_STUB, аRETURN_MINUS_ONE_STUBвозвращает -1.Объявление, которое теряет возвращаемое значение (подтверждено,
LEXER.C):RTL_SCAN_LDOUBLEобъявлена какvoid, хотя процедура (RSTRTOD.ASM) возвращает число прочитанных символов. Последствия: в BCC никаких; порт должен объявить её правильно.Код, который никто не вызывает (подтверждено поиском по src/ и linkgroups/): три десятичные обёртки в
RLTOA.ASM; обработчик директивы преамбулы вPCH.C, которого нет вPREAMBLE_DIRECTIVE_TABLE; девять пулов, которые создаётINIT_CODEGEN_POOLSи которые никогда не используются (CODEGEN_UNUSED_POOL_ONE…NINE).
Дублированный код (подтверждено: тела идентичны)
STAOV198(ASMDATA.C) иBCC_FAR_06A5(OBJSEGS.C).GET_TYPE_FLAGSиGET_TYPE_FLAGS_ALT(TYPEREC.C): обе возвращают байт по смещению record+8.FIND_INIT_BY_MEMBERиFIND_INIT_BY_BASE(CPPCTOR.C).RESET_EXPR_POOL(EXPRMAKE.C) иRESET_EXPR_POOL_ALT(EXPRPOOL.C).ADD_JUMP_18_NEXT_FRAGMENT(ASMDATA.C) иADD_JUMP_18_NEXT_FRAGMENT_ALT(OBJSEGS.C).Параллельные реализации одной и той же работы: кодировщики встроенного ассемблера в
ASMENC.CиASMENC2.C(имена с_ALT), просмотр символов в конце файлаSCAN_FILE_SYMBOLS(SYMSCAN.C) иSCAN_FILE_SYMBOLS_C(SYMSCANC.C), разборщики операторов для режимов C++ и C (STMTCPP.C,STMTC.C).
Как устроен движок
Никакого генератора парсеров (подтверждено): рекурсивный спуск для операторов и объявлений, приоритет операторов для выражений, восстановление после ошибок в «режиме паники» до синхронизирующих токенов; ни строк, ни таблиц yacc. См. SUBSYSTEMS.md.
Две раскладки записей для типов (вероятно):
TYPEREC.Cчитает целевой тип по смещению +3 и флаги по +7/+8, аTYPESIZE.CиспользуетTYPE_TARGET(+7) и байт 12. Имена их разводят (TYPE_SIZE/SIZE_OF_TYPE_CHECKED).Банки кучи (вероятно): обычная память, файл подкачки, EMS, расширенная память и пустой банк, у каждого свои обработчики; узлы, которые переносятся между банками, блокируются флагом 8000h (
LOCK_HEAP_NODE).Предкомпилированные заголовки (подтверждено сигнатурой файла):
PCH.C, сигнатура 0xEDFE, версия 6;#pragma hdrfileиhdrstop; директивы преамбулы сохраняются и воспроизводятся заново.Оптимизации генератора кода определили вид исходника (подтверждено): BC 2.0 удаляет недостижимые переходы до слияния хвостов, поэтому переходы, оставшиеся после слияния, выживают. Чтобы совпали последние функции, понадобились ранние return, повторённые операторы и отдельные операторы
return, как в оригинале.Таблица обработчиков фронтенда (подтверждено): фронтенд C копирует 196-байтовую таблицу (
C_FRONTEND_HOOK_TABLE) поверх слотов обработчиков по адресу DS:80AC, так что один и тот же бэкенд обслуживает C и C++ через указатели на функции (*_HOOK).Проверки битов флагов (вероятно,
CGEXPR.C):FLAGS_BIT_TEST_JUMP_KINDпревращает(x & 2^n) != 0в прямой условный переход для n = 0, 2, 6, 7, 11 — это биты CF, PF, ZF, SF и OF.Статические локальные переменные с конструкторами (вероятно,
STMTCPP.C):GUARD_STATIC_LOCAL_INITоборачивает инициализатор в проверку новой переменной-флага и регистрирует деструктор, который выполняется, только если флаг установлен.Встроенный ассемблер (
INLINASM.ASM): записи его таблицы опкодов выбирают кодировщик по номеру формы (ASM_ENCODE_*); выходы по ошибкеASM_FAIL_*соответствуют сообщениям, которые выдаётEMIT_ASM_STATEMENT.dvprintf был скомпилированным C (подтверждено совпадением):
DVPRINTF.C— это C от Turbo C 2.01. Его цикл написан через goto: шаг и проверка стоят внутри switch после последнего case. Тот же цикл в виде оператора for сdefault: returnна 4 байта длиннее, поэтому этот модуль и оставался на TASM.%xот 0 не печатает ничего.На TASM остаётся то, что писали руками: вспомогательные процедуры плавающей точки (циклы на флаге переноса вроде
shr si,1 / jae, проталкивание аргументов, общее для двух вызовов, 64-битная мантисса в четырёх регистрах), встроенный ассемблер, оверлейный менеджер и стартовый код.Раскладки записей (подтверждено при подборе имён полей, src/include/FIELDS.H): пул типов содержит две раскладки, фронтенда C++ и 10-байтовые записи фронтенда C, где те же элементы лежат на 4 байта ниже; в обеих массив хранит тип элемента по смещению 0x07. Флаг функции 4 означает функцию с переменным числом аргументов (EXPRPARS.C принимает
...в выражении только в этом случае, как того требует va_start). «Узлы кода» генератора — это блоки графа потока управления, операции — 4-байтовые кортежи, операнды которых лежат под тем же индексом в параллельном пуле, а символы фронтенда C живут в пуле, который раньше назывался пулом операторов.
Дальше в оригинале идут мелкие находки, сделанные при комментировании; автор помечает их как вероятные, прочитанные из побайтово совпавшего кода (например, SEARCHP читает байт за концом значения PATH без ; в конце; memmove выбирает направление копирования, сначала сравнивая сегменты, так что перекрывающиеся блоки обрабатываются только в пределах одного сегмента), исправленные вводящие в заблуждение имена и открытые вопросы. Их можно прочитать в FINDINGS.md.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.