[Перевод] Капля здравого смысла для Windows-разработки на C и C++

Суровая действительность разработки на C и C++ для Windows такова: для этой платформы никогда не существовало качественной, нативной реализации стандартной библиотеки этих языков. Стандартная библиотека должна абстрагировать механизмы базовой системы ради упрощения разработки переносимого программного обеспечения. С и C++ на Windows очень плохо состыкованы с интерфейсами операционной системы. В результате большая часть переносимых, или, так сказать, «почти всегда переносимых» программ, которые отлично работают практически везде, в Windows оказываются едва заметно «поломанными», в особенности — за пределами англоговорящего мира. Причины этого почти наверняка связаны с политикой тех или иных компаний, с искусственными ограничениями, а не с техническими особенностями систем, что лишь усугубляет положение. Эта статья посвящена рассказу о проблемах Windows-разработки на C и C++ и о том, как они выражаются. Здесь же будут представлены некоторые простые методы борьбы с этими проблемами при разработке переносимого ПО.

wau7co3u9ocm-pu5du-mjp9zhk8.jpeg
Существует множество реализаций компилятора С. Как такое может быть, что все они не в порядке, даже те, что были созданы одними из первых? Библиотека времени выполнения Microsoft C определила то, как стандартная библиотека C должна работать в Windows, а все остальные реализации ради совместимости следовали за ней. Исключением я считаю платформу Cygwin и её главный форк — MSYS2, несмотря на то, что они не унаследовали описываемых недостатков. Они, в ходе эволюции, так сильно изменились, что, по сути, представляют собой совершенно новые платформы, которые нельзя назвать полностью соответствующими «обычной» Windows.

На практике стандартные библиотеки C++ реализованы на базе стандартной библиотеки C. Именно поэтому у C++ имеются те же проблемы, что и у C. CPython избегает этих проблем. Хотя эта реализация Python и написана на C, на Windows она обходит неправильную стандартную библиотеку C и напрямую обращается к проприетарным интерфейсам. Реализации других языков программирования, вроде gc в случае с Go, попросту создаются не на базе механизмов C, вместо этого, с самого начала, делая всё как нужно, поступая так, как библиотекам времени выполнения C стоило бы поступать уже давно.

Если вы работаете над единственным крупным проектом, обход библиотек времени выполнения C — это не так уж и сложно. И вы, вероятно, уже так и поступаете, обращаясь к важному функционалу платформы. Вам, по правде, даже и не нужна библиотека времени выполнения C. Но если вы заняты разработкой множества небольших программ (как я), то написание особого кода для их поддержки в Windows быстро станет основной частью вашей работы. И, откровенно говоря, обеспечение поддержки Windows не стоит подобных усилий. Я пришёл к тому, что чаще всего просто принимаю то, что мне предлагают по умолчанию, хотя и знаю, что это приведёт к проблемам в Windows.

Прежде чем мы перейдём к деталям, хочу кое-что предложить тем, кто хочет легко и быстро решить вышеозначенные проблемы при работе с набором инструментов Mingw-w64, включая w64devkit. Это — моя библиотека libwinsane. Благодаря ей ваши консольные программы, написанные на C и C++, будут работать правильно. Она решает все проблемы, о которых идёт речь в этой статье, за исключением одной. При этом для применения этой библиотеки менять ваш исходный код не нужно. Достаточно просто связать её с вашей программой.

Что конкретно работает неправильно?


Существует две разновидности Windows API: «узкий», с суффиксом A (ANSI), и «широкий» (Unicode, UTF-16) с суффиксом W. Первый — это устаревший API, где активная кодовая страница отображает 256 байт на 256 конкретных символов (поддерживается до 256 символов). На типичных компьютерах, настроенных на работу с европейскими языками, это означает применение кодовой страницы Windows-1252. Грубо говоря, внутри Windows используется кодировка UTF-16, а вызовы, выполняемые через «узкий» интерфейс используют активную кодовую страницу для перевода «узких» строк в «широкие». В результате у обращений к «узкому» API есть лишь ограниченный доступ к системе.

Кодировка UTF-8 изобретена в 1992 году, она была стандартизирована в январе 1993 года. В последующие годы мир Unix принял эту кодировку из-за её обратной совместимости с существующими интерфейсами. Программы могли читать и записывать Unicode-данные, могли пользоваться Unicode-путями, обрабатывать Unicode-аргументы, считывать значения Unicode-переменных окружения и устанавливать их значения. При этом в программах ничего не нужно было менять. В наши дни кодировка UTF-8 стала самым распространённым форматом кодирования текстовой информации. Во многом это так благодаря развитию Всемирной паутины.

В июле 1993 года Microsoft, с выходом Windows NT 3.1, представила «широкий» API Windows, сделав ставку на кодировку UCS-2 (позже — UTF-16), а не на UTF-8. Это, как оказалось, было ошибкой, так как UTF-16 практически во всём уступает UTF-8. Правда, надо признать, что тогда некоторые проблемы не были особенно очевидными.

Главная проблема заключается в том, что стандартные библиотеки C и C++ подключены лишь к «узкому» интерфейсу Windows. Стандартная библиотека, а значит — и типичное переносимое приложение на Windows, не может обрабатывать ничего кроме ASCII-кода. Это приводит к тому, что эти программы не могут выполнять следующие действия в том случае, если они предусматривают применение символов, отличных от ASCII-символов:

  • Принимать аргументы.
  • Читать и устанавливать значения переменных окружения.
  • Работать с путями.
  • Считывать и выводить данные в консоли.


Выполнение любого из этих действий требует вызова проприетарных функций. При этом Windows рассматривается в роли особой целевой платформы. Это часть того, что делает портирование программ на Windows весьма неприятным занятием. Разумное решение этой проблемы могло бы выглядеть как организация поддержки UTF-8 библиотекой времени выполнения C и подключение её к «широкому» API. Ещё один вариант решения проблемы заключается в том, что «узкий» API можно было бы перевести на UTF-8, постепенно отказываясь от концепции применения старой кодовой страницы. Это, в теории, то, что представляет собой «кодовая страница» UTF-8, хотя подобные решения оказываются работоспособными не всегда. При резком переходе на UTF-8 возникли бы проблемы с совместимостью. Но до совсем недавнего времени такая возможность не была представлена даже неким, условно говоря, «переключателем». Почему в Windows не может быть такого «переключателя» включив который можно обеспечить программам возможность нормально работать? Работать так же хорошо, как и на других платформах.

Как решить почти все проблемы поддержки Unicode?


В 2019 году Microsoft представила возможность, позволяющую программам при запуске запрашивать UTF-8 в роли их активной кодовой страницы. Большее, чем раньше, количество функций «узкого» API получило поддержку UTF-8. Это похоже на тот «переключатель», о котором я мечтал, но мою радость несколько омрачает то, что для реализации этих возможностей надо особенным образом встраивать в бинарники некоторый объём неприглядного XML-кода. Но теперь у нас, по крайней мере, появилась некая стандартная возможность работать с UTF-8.

При использовании Mingw-64 это означает необходимость создания такого файла ресурсов:

#include 
CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "utf8.xml"


Далее, компилируем это с помощью windres:

$ windres -o manifest.o manifest.rc


То, что получилось, связываем с программой. Это, удивительным образом, обычно работает! Программы могут получать доступ к Unicode-аргументам, могут работать с переменными окружения, с путями (в том числе — с помощью fopen). В общем, всё работает так же, как, уже десятилетия, работает на других платформах. Так как активная кодовая страница устанавливается в момент загрузки программы, это событие происходит до конструирования argv (из GetCommandLineA), и именно поэтому всё это и работоспособно.

В качестве альтернативы можно создать так называемую «параллельную сборку» (SxS, side-by-side assembly), поместив этот XML-код в файл с тем же именем, что и у EXE-файла, но с расширением .manifest (после расширения .exe), а после этого положив этот файл около EXE-файла. Пользуясь этим приёмом, стоит помнить о существовании SxS-кеша (WinSxS). Изменения в соответствующих файлах могут быть видны лишь через некоторое время после их выполнения.

При использовании описываемого метода, правда, не работает консольный ввод/вывод. Консоль является, по отношению к процессу, внешней сущностью, поэтому на неё требования процесса к активной кодовой странице не распространяются. Её нужно особо настраивать, пользуясь проприетарным вызовом:

SetConsoleOutputCP(CP_UTF8);


Это, конечно, так себе занятие, но, по крайней мере, теперь всё не так плохо, как раньше. Правда, эта настройка имеет отношение лишь к выводу данных. То есть — программы могут писать в консоль, пользуясь UTF-8, но не читать из консоли. К сожалению, возможность чтения UTF-8-текстов из консоли всё ещё не работает. При установке кодовой страницы для входных данных нам сообщают об успешном проведении операции, но этим всё и ограничивается.

SetConsoleCP(CP_UTF8);  // не работает


Если вам нужно читать Unicode-данные из консоли в интерактивном режиме — вам не остаётся ничего кроме обхода библиотеки времени выполнения C, так как вышеописанный механизм всё ещё неработоспособен.

Преобразование текстовых потоков


Ещё одна давняя проблема программирования для Windows на C и C++ заключается в наличии отличающихся друг от друга «текстовых» и «двоичных» потоков, унаследованных от DOS. В основном это означает автоматическое преобразование символов перевода строки (CRLF и LF). Стандарт C это недвусмысленно позволяет, но на Unix-подобных платформах никогда не делалось различия между текстовыми и двоичными потоками.

Стандарт, кроме того, указывает на то, что стандартные потоки ввода, вывода и ошибок открываются в виде текстовых потоков, при этом нет переносимого способа переключить поток в двоичный режим. Это серьёзный недостаток стандарта. В Unix-подобных системах это неважно, но в Windows это означает, что программа не может читать или писать двоичные данные при работе со стандартными потоками, не вызывая нестандартные функции. Это означает ещё и то, что чтение стандартных потоков и запись в них выполняются медленно. Часто это становится узким местом программ, если только создатель программы не обойдёт этот недостаток.

Лично я предпочитаю писать двоичные данные в стандартный поток вывода, в том числе — видеоданные, и иногда пользуюсь двоичными фильтрами, которые тоже читают двоичные входные данные. Я делаю это так часто, что, вероятно, в половине моих C-программ, в функции main, имеется такой фрагмент кода, обеспечивающий их правильную работу в Windows:

    #ifdef _WIN32
    int _setmode(int, int);
    _setmode(0, 0x8000);
    _setmode(1, 0x8000);
    #endif


Эта магическая формула устанавливает стандартные потоки ввода и вывода в библиотеке времени выполнения C в двоичный режим. При этом не нужны никакие заголовочные файлы, код получается компактным, простым и самодостаточным.

Вышеописанное встроенное преобразование символов перевода строки, а также стандартный текстовый редактор Windows, Notepad, сильно отстающий от других систем, стали причиной того, что многие другие программы, включая Git, создали собственные, вызывающие неудобства, «полезные» возможности по преобразованию символов перевода строки, которые привели к другим проблемам.

Библиотека libwinsane


В начале статьи я говорил о моей библиотеке libwinsane. Она позволяет исправить все вышеописанные проблемы путём простого связывания её с программой. Сюда входит использование раздела .rsrc XML-манифеста, настройка консоли на вывод UTF-8-текстов, перевод стандартных потоков в двоичный режим. Всё это выполняется до вызова функции main (с помощью конструктора GCC). Я называю мою разработку «библиотекой», но это, на самом деле, всего лишь объектный файл. Эта разработка не может быть представлена в виде статической библиотеки, так как она должна быть связана с программой, несмотря на то, что в коде программы она нигде не упоминается.

Вот программа:

#include 
#include 

int main(int argc, char **argv)
{
    char *arg = argv[argc-1];
    size_t len = strlen(arg);
    printf("%zu %s\n", len, arg);
}


В обычных условиях её компилируют и запускают так:

C:\>cc -o example example.c
C:\>example π
1 p


Как всегда, Unicode-аргументы по-тихому ужимаются до одного байта. А теперь свяжем эту программу с libwinsane:

C:\>gcc -o example example.c libwinsane.o
C:\>example π
2 π


Это приведёт к тому, что в Windows программа заработает так же, как на любой другой платформе.

Если вы занимаетесь поддержкой достаточно крупной программы, то вы, возможно, решите внедрить в свой проект необходимые вам части libwinsane, вместо того, чтобы постоянно связывать его с представленным объектным файлом, который даже не используется в самой программе. Причины его существования заключаются в основном в удобстве использования и в том, чтобы сжато продемонстрировать мои идеи. А в своей версии моего кода вы можете даже решить организовать обработку управляющих последовательностей ANSI.

С какими проблемами вы сталкивались, программируя для Windows на C и C++?

image-loader.svg

Habrahabr.ru прочитано 18536 раз