Приветствую, Хабравчане!
Решил сделать цикл статей по написанию на С++, различных небольших программ. Под новые и старые ОС. Мне кажется мы стали забывать как раньше программировали:) Для себя определил несколько важных критериев.
Код должен быть простым и понятным.
Код должен быть переносим, как минимум Windows и Linux, поддерживать 32-ух битные и 64-ех битные процессоры.
Не полагаться на стандартную библиотеку на всех платформах. Пишем свой минимальный вариант.
Быть совместимым с С++/C библиотеками, так как будем их использовать в будущем.
Программы и библиотеки которые я буду разрабатывать должны, делать что то осмысленное.
Минимум ассемблера, все в рамках С++.
По мере разработки я буду портировать код под разные в том числе и старые ОС, будем использовать старые компиляторы. Подходы в реализации графики, к примеру рисовать в буфер, без всех этих ваших ускорителей. Но важно, код на С++ должен оставаться простым и понимаемым и использовать возможности С++, шаблоны, ООП, STL, libc. Собираться на современных компиляторах и ОС.
Ещё одна безумная идея, это сделать поддержку 16 битных процессоров. Компилятор Open Watcom умеет создавать такие бинарники для Windows 3.1 и MS-DOS. И поддерживает, что то похожее на С++ 11. Вполне хватит. Кстати пока у нас не так много кода, портирование не должно занять много времени. Обязательно об этом упомяну в следующих статьях. Я не буду запускать на старых ос только консольные приложения. Основной упор сделаю на графику и разберу, как оно все работало на таких скромных характеристиках древних ПК.
Первая программа не будет отличаться каким то грандиозным функционалом. Будем выводить стандартный hello world. Но, мы ее сделаем минимальной и не зависящей от внешних библиотек. Полагаемся только на системные функции.
Что бы не плодить несовместимые костыли, решил по мере разработки реализовывать стандартную библиотеку С и С++. Плюс в том, что всегда можно будет собрать такой код любым компилятором. Код обычный использующий давно известный API. Предстоит реализовать только совместимый интерфейс библиотек. Как говорится, поехали.
Разрабатывать под Windows я буду в MSVC 2022, под Linux компилятором GCC 13.0
Весь код находится в репозитории RetroFan
Отучаем программу от стандартной библиотеки.
Я использую cmake для всех платформ. В случае msvc для сборки добавил флаг /NODEFAULTLIB
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} /NODEFAULTLIB")
Но после этого, компилятор ругается на всякие отсутствующие функции. Поэтому просто добавляем их в исходные файлы.
extern "C" int main();
extern "C" int mainCRTStartup();
extern "C" void _RTC_InitBase();
extern "C" void _RTC_Shutdown();
extern "C" void _RTC_CheckEsp();
extern "C" void __CxxFrameHandler3();
extern "C" void __CxxFrameHandler4();
extern "C" void _RTC_CheckStackVars();
После чего пробуем собрать и пустая программа весит 2кб. Она запускается и закрывается.
Для linux опции компилятора выглядят так:
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -nostdlib -nostartfiles -nodefaultlibs -nostdinc -s -O2 -fno-exceptions -fno-rtti -fno-builtin")
Я пока не одолел до конца Linux версию, пока поставил заглушки. К следующей статье, разберусь как дергать системные вызовы в Linux и напишу минимальный менеджер памяти используя sbrk. Пока я не смог распарсить нагугленное:)
Выводим на консоль данные.
Так как у нас ничего нет, будем обращаться к системным вызовам Windows. Что бы не тянуть весь Windows.h, просто добавляем нужные нам макросы, функции и константы. Такой файл выглядит так. В первую очередь реализуем изи вариант printf. Который умеет просто выводить char'ы. Со временем обязательно стянем код printf из musl реализуем полное форматирование, а пока для простоты восприятия оставим.
Для каждой ос будем реализовывать простую функцию вывода на консоль:
int PortableWrite(const char* data, size_t count)
А уже её будет дергать наш самописный libc.
#if defined(_WIN32)
#include "../Windows/Portable.h"
#elif defined(__unix__)
#include "../UNIX/Portable.h"
#elif defined(__MSDOS__)
#include "../DOS/Portable.h"
#endif
int printf(const char* text)
{
return PortableWrite(text, strlen(text));
}
Если вдруг вам показалось, что вы увидели упоминание DOS, вам не показалось:) Ну, что теперь в 2025 году под MS-DOS не разрабатывать? Пока там заглушки.
Писать на С с классами хорошо, но все же С++ предоставляет кучу возможностей. Шаблоны, ООП, namespace'ы. Будем всем этим пользоваться по полной, правда сначала нужно написать.
Пишем malloc, free и new, delete.
В Windows динамической памятью управляют функции HeapX. И они идеально ложатся на malloc и free.
void* PortableAllocate(size_t bytes)
{
return HeapAlloc(_heap, 0, (SIZE_T)bytes);
}
void PortableFree(void* ptr)
{
HeapFree(_heap, 0, ptr);
}
А уже libc дергает Portable функции.
void* malloc(size_t bytes)
{
return PortableAllocate(bytes);
}
void free(void* ptr)
{
PortableFree(ptr);
}
Перегружаем глобальные new и delete.
void* operator new(size_t size)
{
return malloc(size);
}
void operator delete(void* ptr)
{
free(ptr);
}
void* operator new[](size_t size)
{
return malloc(size);
}
void operator delete[](void* ptr)
{
free(ptr);
}
Пишем свои строки.
Цель не написать за раз весь функционал std::string, он довольно велик. Пока сделаем изи строки с минимальным интерфейсом. Особо сложного там нет и для написания строк я подсматривал в реализацию STL в gcc. Но там настолько всратое наименование, из-за этого пришлось потратить довольно много времени, что бы всё это понять.
Я не стал копировать код напрямую из STL, потому, писал код для понимания. Сами строки.
Пока строки занимают всего 290 строк. Вполне понятного и читаемого кода.
Итоговый вариант программы выглядит так:
#include <stdio.h>
#include <string>
int main()
{
std::string str1 = "I am ";
std::string str2 = "litle program!";
std::string str3 = str1 + str2;
printf(str3.c_str());
return 0;
}
Бинарник занимает 3,5 кб для 32 бит и 4,0 кб для 64 бит. Не так уж и плохо. Можно в следующих статьях, еще поиграться с размером. Но в принципе, и такой размер меня устраивает.
В следующих статьях, будем уже работать с графикой. Так же код останется кроссплатформенным, графика будет работать на Windows и Linux.
Ещё большой плюс в том, что я пишу всё с нуля это независимость от компиляторов, в том числе и старых. К примеру в некоторых версиях может не быть поддержки STL, но поддержка namespace и шаблонов есть. Второй момент, весь код кроме системных функций одинаков для всех платформ, упрощается тестирование и отладка.
В итоге, мне интересно копаться во всех этих артефактах древности.
Обновление:
Добавил namespace WinAPI для функций windows.
Добавил расширение hpp для файла с реализацией строк std::string. Что бы гитхаб мог подсвечивать синтаксис С++.
Исправил утечку памяти в методеstd::string.reserve. Спасибо пользователю https://t.me/ProCxx/675906
Комментарии (44)
Jijiki
10.01.2025 14:25интересно, спасибо. осилить окна на линуксе не такая простая задача ) хотя реально вроде), а зачем писать на С++? извините строки вроде char*(1,.......32) нужного размера.
может лучше void vector - но по соглашению что тип 1 на вектор (извините увидел void *ptr)
преимущества С в том. что если задались вопросов реализаций многих например STL, то С идеально подходит там ничего нету
JordanCpp Автор
10.01.2025 14:25C++ удобен. Те же шаблоны и ООП. Код на С конкатенации двух строк выглядит, скажем так особенно...
Jijiki
10.01.2025 14:25понял вас, я дошел до более менее вменяемого кода который мне понравился на С++, научился освоил его и разочаровался. код монструозный, хотя всё по идиоме верно, как нужно
Serpentine
10.01.2025 14:25int printf(const char* text) { return PortableWrite(text, strlen(text)); }
В стандартной библиотеке printf() объявляется как:
int printf(char *fmt, ...)
т.е. ожидается, что она имеет список аргументов переменной длины (и пользователи их туда напихают при первой же возможности). Ну и сама функция выводит форматированную строку, что само по себе нетривиальная задача и в том же K&R ее минимальная реализация демонстрируется через подключение stdarg.h
Чтобы не парится, я бы ее заменил, например, на puts() и в ней или в самом PortableWrite() уже как-нибудь реализовал добавление
'\n'
Небольшое отступление. В древней книге "Системное программирование в среде Windows" Джонсона Харта есть приложение "Сопоставление функций Windows, UNIX и библиотеки C" и в табличке напротив виндовых HeapReAlloc() и HeapFree() там, где должны быть прописаны аналоги UNIX жирным текстом написано "Используйте библиотеку С".
Jijiki
10.01.2025 14:25поидее если стоит задача вывода средствами потока ввода/вывода, то надо проверить какие библиотеки есть проверить конфигурацию, создать потоки, и воспользоваться реализацией метода которая доступна из реализованного кода на платформе либо по сисколу либо по доступной обёртке сискола с созданными потоками
https://learn.microsoft.com/en-us/windows/win32/api/debugapi/nf-debugapi-outputdebugstringa
https://learn.microsoft.com/en-us/windows/win32/api/debugapi/nf-debugapi-outputdebugstringw
https://www.go4expert.com/articles/outputdebugstring-variable-arguments-t871/
это пример
Serpentine
10.01.2025 14:25https://www.go4expert.com/articles/outputdebugstring-variable-arguments-t871/
это пример
По ссылке к OutputDebugString() для добавления функционала из printf() примотали изолентой стандартный sprinf() и макросы из stdarg.h (см. va_list, va_start и va_end), о чем я говорил.
Честно имитировать стандартную printf() - это обрабатывать добрую пару десятков (если не больше) спецификаторов (%d, %x, %g, %p, etc.) и кучу их модификаторов (точность, флаги, ширина поля и пр.).
JordanCpp Автор
10.01.2025 14:25Честно имитировать стандартную printf() - это обрабатывать добрую пару десятков (если не больше) спецификаторов (%d, %x, %g, %p, etc.) и кучу их модификаторов (точность, флаги, ширина поля и пр.).
Для начала добавить несколько основных вариантов.
Jijiki
10.01.2025 14:25человек добавил такие фичи но вы можете просто строки пулять, другой способ это IPC, это я тоже немного смотрел, там есть обертки у винды свои тоесть апишки, а на линуксе это неблокируемые буферы ввода/вывода на сколько помню, вобщем и там и там стандарты вызовов к нужным сисколам (тоесть грубо говоря если пишем консоль и нужны свои буферы - то поидее нужны доступы на чтение запись тоесть чтобы в вашей консоле работал cat и read login и прочее что и делает по доступу отображения шрифта консолью - консолью)
Serpentine
10.01.2025 14:25В своих комментариях я говорил про форматированные строки и функции со списком аргументов переменной длины, а не о выводе обычных null-terminated строк.
JordanCpp Автор
10.01.2025 14:25Спасибо за ссылки, посмотрю.
Jijiki
10.01.2025 14:25OutputD...String классная штукенция в своё время смотрел туториал не по вашей тематике, там скорее пре вводная в игры, обзорщик в начале концентрировался на тематике логирования, там смотрел тестил в итоге пришел к тому что проще просто открывать буфер и пулять в лог прям строку при отладке например игры очень удобно, например такой лог кидает строку в те буферы где выводится системная инфа, что загрузилось и прочее, ну сопсно общий лог как я понял
JordanCpp Автор
10.01.2025 14:25нетривиальная задача и в том же K&R ее минимальная реализация демонстрируется через подключение stdarg.h
Всё же это не рокет сайнс, осилю. Да и примеры кода есть на гитхабе. Мне самому интересно разобраться как оно все работает.
Чтобы не парится, я бы ее заменил, например, на puts() и в ней или в самом PortableWrite() уже как-нибудь реализовал добавление
'\n'
Спасибо за идею. Сразу и не подумал.
там, где должны быть прописаны аналоги UNIX жирным текстом написано "Используйте библиотеку С".
Там нет сноски, вроде но если очень хочется, то делайте так?:)
Serpentine
10.01.2025 14:25Там нет сноски, вроде но если очень хочется, то делайте так?:)
Нет, если очень хочется, то ни на кого внимания не надо обращать :) Зато там высказывалась мысль о том, что при желании написать код, чисто запускающийся даже просто на разных версиях винды, то он будет выглядеть неказисто.
Если честно, при задаче написать код, подходящий под DOS, Win32/64, UNIX, мне первым делом приходит в голову использовать как раз ANSI C (чтобы libc предоставлялась самими платформами) и затем делать обертки над специфичными API этих осей, ну и #define'ом обыграть прочие прелести из минувших времен типа far pointer'a.
Ибо и без написания даже обрезанной кроссплатформенной libc интересных задач море.
Как пример, на Хабре была статья о портировании примеров из книги Андре Ламота про DOS игры под современный win32.
JordanCpp Автор
10.01.2025 14:25Вы все правильно пишите. И у меня уже есть похожий проект в репе. Где я пишу все на С++ и полагаюсь на поставляемые libc. И как одна из опций сборки это будет работать. Но мне хотелось бы обеспечить поддержку именно своей версии минимальной libc и STL. Уже успешно доковырял linux версию. Осталось сделать malloc и free, на основе mmap и unmap. Системные вызовы linux уже вызываются. Можете посмотреть в репе. Делаю по примерам в интернете.
Да и я много кода скопирую и для себя осмыслю. И в статье все шаги опишу. Мне интересно как оно все под капотом работает. Интересно же.
Только С++, на С программировать желания нет.
emusic
10.01.2025 14:25хотелось бы обеспечить поддержку именно своей версии минимальной libc и STL
С libc особых проблем нет, кроме поддержки локализованных строк. А вот в STL многое завязано на исключения, собственную поддержку которых делать нет смысла - она получится примерно такой же громоздкой, как и родная. Разве что подменить развесистые плюсовые исключения на минималистичные вроде SEH, с передачей кодов ошибок вместо объектов.
JordanCpp Автор
10.01.2025 14:25Ещё один аргумент за С++, это нормальная ООП абстракция типизированная на шаблонах. А не как в glibc.
voldemar_d
10.01.2025 14:25Предлагаете вообще не использовать STL?
О чем пункт про осмысленность программы? Разве это само собой не разумеется?
Ассемблер зачем вообще, можете пояснить? И как это сочетается с кросс-платформенностью?
JordanCpp Автор
10.01.2025 14:25Предлагаете вообще не использовать STL?
Нет, без STL будет слишком много ручной работы.
О чем пункт про осмысленность программы? Разве это само собой не разумеется?
Я решил добавить данный пункт. Что это не будет, что то бессмысленное. Типа только привет мир. Более сложное.
Ассемблер зачем вообще, можете пояснить? И как это сочетается с кросс-платформенностью?
Ассемблер при необходимости. Скорее всего он будет в сочетании с вызовами bios в ms dos. Прерывания вызывать.
Только кроссплатформенность, иначе смысл теряется. То, что работает под ms-dos должно работать и под другими ОС. В этом смысл.
Иметь единое API для старых и новых ОС. В том числе и для графики.
voldemar_d
10.01.2025 14:25Я что-то теряюсь в догадках: как можно написать код для графики, который будет одинаково работать и под MS DOS, и под другими ОС?
JordanCpp Автор
10.01.2025 14:25Не так сложно, под msdos графика только палитровая, на других ос, палитровая графика эмулируется. Для всех систем единое апи.
voldemar_d
10.01.2025 14:25И под графическими оконными ОС?
JordanCpp Автор
10.01.2025 14:25Да. При загрузке палитровое изображение конвертируется в rgb.
Правда жесткое ограничение, одна палитра на одно изображение и нельзя менять палитру динамически.
voldemar_d
10.01.2025 14:25Что Вы называете "библиотекой для графики"? Код для загрузки файлов с графикой?
Или для отображения графики на экране?
JordanCpp Автор
10.01.2025 14:25Вывод на экран графики. Примитивы, картинки. Опционально загрузчик разных форматов.
odisseylm
10.01.2025 14:25printf без константной строки (формата), круто! Это же круто! (мало нам дыр, добавим ещё)
И ваша статья уже устарела... Или так и планировалось?
Иметь единое API для различных ОС (включая графику), включая MSDOS.
Возникают огромные сомнения, что вы понимаете, что делаете (объем работ), если вы планируете писать что-то серьёзное.Не понимаю, почему статья не заминумована...
JordanCpp Автор
10.01.2025 14:25printf без константной строки (формата), круто! Это же круто! (мало нам дыр, добавим ещё)
И ваша статья уже устарела... Или так и планировалось?
Это же только начало. Я же упомянул в статье, что сделаем нормальный printf.
JordanCpp Автор
10.01.2025 14:25Не понимаю, почему статья не заминумована...
Возможно плюсующим нравится?
Jijiki
10.01.2025 14:25задумка интересная, я тут поверил в себя взялся делать кросс-вызов окна и остановился на вводе/выводе (на X11 вылетает в нем есть широкий функционал но надо уметь работать с вводом выводом, на XCB вообще отлично работает, но но но тоже ввод/вывод - вообщем без ввода вывода обе работают, просто ввод/вывод отнимет много времени чтобы с ним разобраться) - пара вылетов вернула меня на библиотеку, но если получается и есть понимание почему нет ) (вообще конечно в винде просто ультимативные апишки по работе с окном и асинхронным вводом выводом просто уйму времени на разбирание ассинхронного ввода вывода пропускаешь, потомучто надо чтобы мышка с клавиатурой работали и кнопки повторялись на клаве, на иксах какойто барьер называется инпут лаг )
JordanCpp Автор
10.01.2025 14:25Мне понравилась эта статья. Приложение X11 на ассемблере.
https://habr.com/ru/articles/840590/
И стало интересно, а возможно ли добиться примерно такого же размера. Но на С++ и STL. Конечно настолько малого бинарника не получится, но хотя бы приблизиться получится.
Это было долго, но мы справились!
Мы написали (очень простую) программу с графическим интерфейсом на чистом ассемблере, без каких-либо зависимостей и уложившись в 600 строк кода.
Как далеко мы можем зайти в оптимизации бинарника?
C отладочной информацией: 10744 байт (10 Кб)
Без отладочной информации (stripped): 8592 байт (8 Кб)
С оптимизациями stripped and
OMAGIC
(--omagic
это ключ линковщика, из рукводства:Set the text and data sections to be readable and writable. Also, do not page-align the data segment
): 1776 байт (1 Kб)
Вообщем вот такая программка с интерфейсом размером в 1 Кб.
Jijiki
10.01.2025 14:25поидее тогда библиотеки надо динамические, и компиляция соответствующая я например компилирую в Ofast - окно, загрузка моделек, скайбокс, волна, мышка клава, 261 кб
JordanCpp Автор
10.01.2025 14:25Я сейчас на windows простенькое окно вывожу. Без обработки ввода. 4,5 КБ для 32 бит и 5,5 КБ для 64 бит.
Потом это все я оберну в абстракции единого API для linux и windows.
JordanCpp Автор
10.01.2025 14:25Почти допилил linux версию, осталось реализовать malloc и free. Начал пилить графику.
emusic
10.01.2025 14:25После чего пробуем собрать и пустая программа весит 2кб
Если бы Вы просто добавили объявления функций, как показано в примере, программа не собралась бы. :) Вы явно добавили определения-заглушки, имеет смысл отразить это в примере.
Некоторые служебные функции таки можно тянуть из libc, но придется разбираться в структуре ее конкретных версий. Например, многие функции ссылаются только на средства установки errno, на обработчики недопустимых значений параметров и т.п. Если сделать заглушки для этого, то сами библиотечные функции вполне можно использовать в "автономном" коде.
JordanCpp Автор
10.01.2025 14:25Это не требуется. Код будет собираться и быть совместимым и со стандартным STL и libc идущий в поставке компилятора. Но, что бы добиться минимального бинарника на всех платформах, дополнительно пишу свои совместимые прослойки.
orefkov
10.01.2025 14:25В своё время Джоэл Спольски в статье "Верблюды и песочницы" писал - "80% пользователей используют 20% функционала программы. Но не думайте, что реализовав 20% функционала, вы удовлетворите 80% пользователей. Потому что эти 20% функционала у каждого разные".
slonopotamus
10.01.2025 14:25TL;DR: автор пишет самодельный libc+stl, но при этом старательно маскирует это под невнятные фразы про легаси.
JordanCpp Автор
10.01.2025 14:25В следующих статьях я буду использовать для сборки под старые системы visual C++ 6.0, а может ещё древнее версию. Под старые версии linux, gcc 3. Куда уж легаснее?
Getequ
Почему вы решили что это легаси если вы пишите новое приложение?
JordanCpp Автор
Это больше игра слов. Вроде как приложение новое, а технологии старые. На старте все устарело.
dponyatov
не legacy а retrodev (retro development) скорее