[Перевод] Исходники 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, давал каждый исполняемый файл байт в байт. Потом этот код делается читаемым: настоящие имена, именованные поля записей, комментарии к каждому файлу и заметки о том, как компилятор работает и где он ошибается.

Каталог

Программа

Статус

bcc20/

Borland C++ 2.0 (1991), BCC.EXE, компилятор командной строки

Готово: пересобирается побайтово (SHA-256 dd5744b9...)

cpp20/

Borland C++ 2.0 (1991), CPP.EXE, отдельный препроцессор

Готово: пересобирается побайтово (SHA-256 39879c60...)

bcc20-port/

BCC.EXE как нативный инструмент для декомпиляции игр, собранных Borland

Начато: запись вызовов, стенд воспроизведения, первые функции рантайма

Позже добавятся другие версии 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-reborncd 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 ".7z"# 2. Python packages and the DOS emulator (dosbox-x), nodepython3 -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 everythingcd 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. Сделать код читаемым

Когда каждый байт доказан, исходник переписали для людей, шаг за шагом, с полной проверкой после каждого шага:

  1. одна команда сборки и один гейт;

  2. общие заголовки вместо объявлений в каждом файле;

  3. именованные константы: сообщения, токены, виды типов и операторы, с именами из собственных таблиц BCC;

  4. поля записей: около 300 имён полей для записей пулов BCC (FIELDS.H), доступ к ним через общие функции (POOL.H) и 40 раскладок записей в виде структур (RECORDS.H);

  5. имена файлов и дерево каталогов по подсистемам;

  6. комментарии к каждому файлу и каждой процедуре, плюс находки.

Имена никогда не угадывали. Для каждого записано основание (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.

Источник: habr.com

0 0 голоса
Рейтинг новости
1
0
Подписаться
Уведомить о
0 комментариев