Pull to refresh
411.59
Тензор
Разработчик системы СБИС

Как сделать свой С++ код кроссплатформенным?

Reading time 12 min
Views 36K
Возможно, кто-то, прочитав заголовок, спросит: «Зачем что-то делать со своим кодом?! Ведь С++ кроссплатформенный язык!». В целом, это так… но только пока нигде нет завязок на специфичные возможности компилятора и целевой платформы…

В реальной жизни разработчики, решающие конкретную задачу под конкретную платформу, редко задаются вопросом «А точно ли это соответствует Стандарту С++? А вдруг это расширение моего компилятора». Они пишут код, запускают сборку и чинят места, на которые выругался их компилятор.

В итоге получаем приложение, которое, в некоторой степени, «заточено» под конкретный компилятор (и даже под его конкретную версию!) и целевую ОС. Более того, из-за скудности стандартной библиотеки С++ некоторые вещи просто невозможно написать, не воспользовавшись специфичным API системы.

Так было и у нас в Тензоре. Мы писали на MS Visual Studio 2010. Наши продукты были 32-х битными Windows-приложениями. И, само собой, код был пронизан всевозможными завязками на технологии от Microsoft. Однажды мы решили, что пора осваивать новые горизонты: пора научить СБИС работать под Linux и другими ОС, пора попробовать перейти на другое «железо» (POWER).

В данном цикле статей я расскажу, как мы сделали свои продукты настоящими кроссплатформенными приложениями; как заставили их работать на Linux, MacOS и даже под iOS и Android; как запустили свои приложения на множестве аппаратных архитектур (x86-64, POWER, ARM и другие); как научили работать на big-endian машинах.


Основа всех наших продуктов — собственный фреймворк «Платформа СБИС» (далее по тексту — «Платформа»), который по масштабности сравним с Qt. В платформе есть практически все, что нужно разработчику: от простых функций быстрого преобразования числа в строковую форму до мощного отказоустойчивого сервера приложений.

На базе Платформы наши разработчики реализуют свои продукты (даже мобильные приложения), решающие всевозможные бизнес-задачи. Нам хотелось освободить их код (далее по тексту будем называть их код «прикладным») от всевозможных завязок на целевую программную и аппаратную платформу, спрятав всю специфику в глубинах нашего фреймворка.

Платформа СБИС написана на С++, но это вовсе не ограничивает прикладного программиста в выборе языка, кроме C++ могут быть использованы JavaScript, Python, SQL.

Наша компания активно развивает свои продукты, поэтому нужно было «чинить поезд на полном ходу» :)


Нужно было работать так, чтобы остальные разработчики не пострадали от нашей деятельности и с комфортом продолжили разрабатывать свой функционал под Windows на MSVC. Это требование сильно повлияло на многие технические решения и сильно усложнило работу.

Для того чтобы у читателя сформировалось представление о масштабности работ, я приведу некоторые цифры:

  • Объем кода нашего фреймворка ~2 миллиона строк
  • Объем «прикладного» кода (код, основанный на платформе СБИС, решающий конкретные бизнес-задачи), сложно оценить, но он в разы превышает объем Платформы
  • Свыше тысячи программистов в десяти центрах разработки

Скучное вступление закончено. Теперь приблизимся к делу и рассмотрим, с какими проблемами мы столкнулись.

Использование API операционной системы


Как упоминалось выше, стандартная библиотека С++ очень скудная, она не включает многих всюду необходимых возможностей. Например, в С++11 нет функционала для работы с сетью… То есть, как только мы захотели сделать простейший HTTP-запрос, мы вынуждены… написать некроссплатформенный код!

Ситуация еще больше усугубляется, если вы используете не самую свежую версию компилятора, как это было у нас — в MSVS 2010 отвратительная поддержка C++11, отсутствует огромная часть нововведений в ядре языка и в стандартной библиотеке.

Но, к счастью, такие проблемы решаются довольно легко. Есть несколько способов:

  • Пишем свой класс, с несколькими платформоспецифичными реализациями, основанными на вызовах API целевой системы. Во время сборки препроцессорными директивами ifdef выбираем подходящую реализацию.
  • Используем кроссплатформенные библиотеки — есть множество готовых кроссплатформенных библиотек (опять же, использующие внутри себя платформоспецефичные реализации), которые сильно облегчают нашу задачу. Например, для реализации HTTP-клиента мы взяли cURL.

Особенности реализаций компиляторов


В каждой программе есть ошибки. И компилятор тоже не исключение. Поэтому даже на 100% соответствующий Стандарту код может не собраться на каком-то компиляторе.

Так же практически все разработчики компиляторов считают своим долгом добавить в своё детище возможностей, не предусмотренных Стандартом, и тем самым провоцируют программистов к написанию непереносимого кода.

Что получаем в итоге? Код, который написан четко по Стандарту, может не собраться на каком-то компиляторе; код, который компилируется и работает на одном компиляторе, может не собраться или заработать не так на другом…

Можно перечислить множество проблем этого класса. Вот одна из них:

throw std::exception( "что-то пошло не так" ); // соберется только в MSVC++, так как по стандарту нет такого конструктора

Данный код соберется в MSVC++, так как у них определен дополнительный конструктор:

exception( const char* msg ) noexcept;

К сожалению, нет общих приемов, решающих такие проблемы. В этих случаях помогает только опыт, накопленный при изучении используемых в работе инструментов, и хорошее знание Стандарта С++.

В последующих статьях я вернусь к этому вопросу, подробно опишу наиболее часто встречающиеся проблемы и предложу методы их решения.

Неопределенное поведение


В Стандарте С++ есть интересный термин «undefined behavior» (неопределенное поведение). Вот его определение из Википедии:
Неопределённое поведение (англ. undefined behavior, в ряде источников непредсказуемое поведение[1][2]) — свойство некоторых языков программирования (наиболее заметно в Си), программных библиотек и аппаратного обеспечения в определённых маргинальных ситуациях выдавать результат, зависящий от реализации компилятора (библиотеки, микросхемы) и случайных факторов наподобие состояния памяти или сработавшего прерывания. Другими словами, спецификация не определяет поведение языка (библиотеки, микросхемы) в любых возможных ситуациях, а говорит: «при условии А результат операции Б не определён». Допускать такую ситуацию в программе считается ошибкой; даже если на некотором компиляторе программа успешно выполняется, она не будет кроссплатформенной и может отказать на другой машине, в другой ОС или при других настройках компилятора.



Если вы допустите undefined behavior в своей программе, то это вовсе не значит, что она будет падать или выдавать какие-то ошибки в консоль. Такая программа вполне может работать ожидаемым образом. Но любое изменение настроек компилятора, переход на другой компилятор или на другую версию компилятора, или даже модификация любого фрагмента кода может поменять поведение программы и все сломать!

Многие ситуации с undefined behavior на одном конкретном компиляторе выдают стабильно одинаковое поведение, и ваше тщательно оттестированное приложение будет работать как швейцарские часы. Но как только мы меняем окружение (например, пытаемся запустить программу, собранную другим компилятором), эти баги начинают заявлять о себе и полностью ломают программу.

Классический пример undefined behavior — это выход за границы массива на стеке. Ниже приведен упрощенный фрагмент кода одного из наших приложений с такой проблемой. Этот баг никак не проявлял себя под Windows в течение нескольких лет и «выстрелил» только после портирования под Linux:

std::string SomeFunction()
{
   char hex[9];
   // some code
   hex[9] = 0; // тут выход за границы массива
   return hex;
}

По всей видимости, MSVS выравнивала буфер на стеке, добавляя после него несколько байт, и при перезаписи чужой памяти мы попадали на пустое, никем не используемое место. А в GCC проблема стала проявляться интересным образом — программа падала далеко от этого кода, в другой функции (видимо, GCC заинлайнил эту функцию, и она стала переписывать локальные переменные другой функции).

Есть и более изящные, трудноуловимые ситуации с UB. Например, на очень интересные грабли можно наступить при использовании std::sort:

std::vector< std::string > v = some_func();
std::sort( v.begin(), v.end(),
   []( const std::string& s1, const std::string& s2 )
{
   if( s1.empty() )
      return true;
   return s1 < s2;
} );

Казалось бы, где тут может быть UB? А все дело в «плохом» компараторе.
Компаратор должен вернуть true, если s1 нужно поставить перед s2. Рассмотрим, что выдаст наш компаратор, если ему на вход подать две пустые строки:

s1 = "";
s2 = "";
cmp( s1, s2 ) == true => s1 должна стоять перед s2
cmp( s2, s1 ) == true => s2 должна стоять перед s1

Таким образом, есть ситуации, когда компаратор противоречит сам себе, то есть не задает strict weak ordering (ссылка на en.wikipedia.org/wiki/Weak_ordering#Strict_weak_orderings). Следовательно, мы нарушили требования std::sort к аргументам и получили неопределенное поведение.

И это не придуманный пример. Такую проблему мы поймали при переходе на Linux. Компаратор с подобной ошибкой работал долгие годы под Windows и… начал рушить приложение с SIGSEGV под Linux (i686). Что интересно, баг ведет себя по разному даже на разных дистрибутивах Linux (с разными GCC на борту): где-то приложение падает, где-то зависает, где-то попросту сортирует не так, как ожидалось.

Зачастую ситуации с undefined behavior можно отловить статическими анализаторами (в том числе и встроенными в компилятор). Поэтому в настройках сборки стоит всегда выставлять максимальный уровень предупреждений. А чтобы не потерять полезный warning в толпе предупреждений вида «неиспользуемая переменная», полезно однажды прибраться в коде, после чего включить опцию сборки «трактовать предупреждения как ошибки», чтобы не допустить появления новых незамеченных предупреждений.

Модели данных


Стандарт С++ не дает никаких жестких гарантий о представлении типов данных в памяти компьютера; он задает лишь некоторые соотношения (например, sizeof(char) <= sizeof(short) <= sizeof(int) <= sizeof(long) <= sizeof(long long)) и предоставляет способы определения характеристик типов.

На различных системах может существенно отличаться способ представления типов. Размерности базовых типов задаются моделью данных. Под моделью данных следует понимать соотношения размерностей типов, принятых в рамках среды разработки. В таблице, представленной ниже, перечислены популярные модели данных и приведены соответствующие ей размерности основных типов С++.


В подавляющем большинстве случаев программисту при выборе типа данных нужны гарантии по поводу его размера. Но на практике разработчики зачастую попросту завязываются на размеры базовых типов в системе, на которой они работают. И вновь при переходе на иную программную или аппаратную платформу мы получаем сюрпризы: какой-то код перестает собираться, какой-то — начинает работать по-другому или вовсе перестает работать.

Например, хэш-функция ниже будет выдавать разные результаты на одних и тех же данных при запуске на различных платформах:

unsigned long some_hash( const unsigned char* buf, size_t size )
{
    unsigned long res = 0;
    for( size_t i = 0; i < size; ++i )
        res = res * buf[i] + buf[i] + i;
   return res; 
}

Большинство таких проблем решаются, если использовать типы с гарантированным размером:

std::int8_t, std::int16_t и т. п.
std::uint32_t some_hash( const unsigned char* buf, size_t size )
{
    std::uint32_t res = 0;
    for( size_t i = 0; i < size; ++i )
        res = res * buf[i] + buf[i] + i;
   return res; 
}

Знаковость char


Полагаю, не многие разработчики задумывались, знаковый ли char. А и если возникал такой вопрос, то большинство открывали свою любимую среду разработки, писали небольшую тестовую программку и получали ответ… верный только для их системы.

На самом деле Стандарт С++ не оговаривает знаковость типа char. Из-за этого есть реализации компиляторов, в которых char знаковый, а есть те, где char — беззнаковый. И это еще одна причина, из-за которой ваша программа может отказаться работать после сборки под другую систему.

Например, этот код работает, как ожидалось, в Linux x86-64, но не работает на Linux POWER (при сборке в GCC с параметрами по умолчанию):

bool is_ascii( char s )
{
   return s >= 0;
}

Чтобы избавиться от неопределенности, достаточно добавить явное приведение к нужному типу:

bool is_ascii( char s )
{
   return static_cast<signed char>( s ) >= 0;
}

а в нашем примере можно и вовсе переписать код на битовые операции:

bool is_ascii( char s )
{
   return s & 0x80 == 0;
}

Представление строк


Стандарт С++ никак не регламентирует некоторые аспекты, и каждый компилятор решает эти вопросы по своему усмотрению.

Например, нет никаких гарантий, как будут представлены в памяти строковые константы.
Компилятор MSVS кодирует строковые константы в Windows-1251, а GCC — по умолчанию кодирует в UTF-8.

Из-за таких отличий один и тот же код выдаст разные результаты: strlen("Хабр") в программе, собранной на MSVS выдаст 4; в GCC — 8.

Так же проблемы придут и при вводе-выводе данных. Например, наша тестовая программа умеет сохранять и читать данные в каких-то текстовых файлах:

std::string readstr()
{
   std::ifstream f( "file.txt" );
   std::string s;
   std::getline( f, s );
   return s;
}

void writestr( const std::string& s )
{
  std::ofstream f( "file.txt" );
  f.write( s.c_str(), s.size() );
}

Все будет работать хорошо, пока эти файлы пишут и читают приложения, собранные в одном окружении. Но что будет, если этот файл запишет Windows-приложение, а прочитает приложение под Linux?.. Получим «кракозябры» :)


Как быть в таких случаях? Общий принцип возможных решений один — выбрать какой-то унифицированный способ представления строк в памяти программы и при вводе-выводе делать явное кодирование/декодирование строк. Многие разработчики используют кодировку UTF-8 в своих программах. И это очень хорошее решение.

Но, как я упоминал выше, мы «чинили поезд на полном ходу», и мы не могли сломать некоторые инварианты, на которые полагался наш код (он разрабатывался с учетом, что кодировка строк — Windows-1251):

  • фиксированная ширина символов — возможен произвольный доступ к символу по его индексу
  • есть возможность написания строковых констант на русском языке в коде

В кодировке UTF-8 символы могут представляться различным количеством байт, из-за чего первому требованию она не удовлетворяет. Второе требование в случае UTF-8 не выполняется, к примеру, в MSVC 2010, где строковые константы кодируются в Windows-1251. Поэтому нам пришлось отказаться от UTF-8, и мы решили… полностью абстрагироваться от кодировки, в которой представлены строки, и перешли на «широкие строки» (wide strings).

Такое решение почти полностью удовлетворило наши требования:

  • Практически на всех UNIX-системах «широкие строки» представлены кодировкой UTF-32, то есть ширина символов в ней фиксирована и совпадает с размером элемента типа wchar_t
  • На Windows используется UTF-16. С этой кодировкой дело обстоит несколько сложнее, так как некоторые символы могут быть представлены суррогатными парами. Но, к счастью, все, что есть в Windows-1251, на котором работало наше Windows-приложение, представлено двухбайтовыми последовательностями. Поэтому на начальном этапе мы вовсе не стали поддерживать суррогатные пары и сделали допущение, что под Windows все символы влезают в один элемент типа wchar_t.
  • В С++ можно задавать «широкие» строковые константы, например, L"Привет, хабр!". В этом случае компилятор сам заботится о перекодировке этой строки из кодировки файла исходника в кодировку, в которой представлен wchar_t на целевой системе.

Кроме того, при использовании «широких строк» мы получили ряд преимуществ:

  • В стандартных библиотеках С и С++ есть множество функций и классов для работы с «широкими строками» — нет необходимости писать свои аналоги функций strlen, strstr, классов std::string, std::stringstream и т. п.
  • Многие сторонние библиотеки поддерживают «широкие строки» (например, BOOST)
  • Большая часть WinAPI умеет работать с «широкими строками»

На всех нужных нам платформах «широкие символы» представлены Unicode. За счет этого наши приложения теперь не ограничены латиницей и кириллицей, они поддерживают любые языки мира.

На самом деле борьба с кодировками была самой сложной частью работы по портированию наших продуктов. О ней можно еще очень много рассказать — оставим это для следующих статей :)

Особенности файловых систем ОС


Файловая система Windows имеет несколько отличий от большинства ФС UNIX-подобных систем:

  1. Она регистронезависимая
  2. Она позволяет использовать и символ «\» в качестве разделителя пути

К чему это приводит? Вы можете назвать свой заголовочный файл «FiLe.H», а в коде писать «#include <myfolder\file.h>». В Windows такой код скомпилируется, а в Linux вы получите ошибку, что файл с именем «myfolder\file.h» не найден.

Но, к счастью, избежать таких проблем очень просто — достаточно принять правила именования файлов (например, называть все файлы в нижнем регистре) и придерживаться их, а в качестве разделителей путей всегда использовать «/» (Windows его тоже поддерживает).

Чтобы полностью исключить досадные ошибки, мы повесили на свои git-репозитории простой hook, который проверяет соответствие include-директив этим правилам.

Также особенности ФС влияют и на само приложение. Например,

std::string root_path = get_some_path();
std::string path = root_path + '\\' + fname;

Если у вас есть код, который «клеит» пути через обычные операции конкатенации строк и использует «\» в качестве разделителей, то он сломается, так как под некоторыми ОС разделитель будет воспринят как часть имени файла.

Конечно, можно использовать '/', но в Windows это выглядит некрасиво, да и в общем случае нет гарантий, что не найдется ОС, в которой будет использоваться какой-то иной разделитель.

Чтобы решить эту проблему, мы используем библиотеку boost::filesystem. Она позволяет правильно сформировать путь под текущую систему:

boost::filesystem::path root_path = get_some_path();
boost::filesystem::path path = root_path / fname;

Заключение


Разработка кроссплатформенного ПО на С++ — это нетривиальная задача. Написать программу, которая будет работать на различных программных и аппаратных платформах, не прилагая для этого никаких дополнительных усилий, пожалуй, невозможно. Да и разработать на С++ крупную программу, которая без изменений правильно соберется на любом компиляторе под любую ОС и под любое железо, нельзя, несмотря на то, что С++ — кроссплатформенный язык. Но если вы будете придерживаться ряда правил, которые я вкратце изложил в статье, то вы сможете написать код, который запустится на всех нужных вам платформах. Да и перенести эту программу под новую ОС или железо будет уже не так трудно.

Итого, чтобы писать кроссплатформенный код нужно:

  • Хорошо знать Стандарт С++, понимать, что допускается в нем, а что является расширением конкретного компилятора или вовсе приводит к undefined behavior.

  • Отказаться от использования API системы в коде, инкапсулировав платформоспецифичный код в некоторых классах или воспользоваться готовыми кроссплатформенными библиотеками.

  • Учитывать возможные различия типизации, не завязываться на свойства базовых типов, которые не гарантируются Стандартом С++. Для этого можно использовать типы с фиксированной размерностью из стандартной библиотеки С++.

  • Определиться с форматом представления строк в памяти программы. Тут может быть много вариантов. Например, использовать UTF-8, как это сделано во многих программах, или вовсе перейти на «широкие» строки, абстрагировавшись от формата представления строк вовсе.

  • Учитывать особенности файловых систем на разных ОС (как в коде, в директивах #include, так и в логике самой программы).

Автор: Алексей Коновалов
Tags:
Hubs:
+30
Comments 82
Comments Comments 82

Articles

Information

Website
sbis.ru
Registered
Founded
Employees
1,001–5,000 employees
Location
Россия