Неопределённое поведение в C/C++ и приёмы против лома

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!

Некоторое время назад в Интернете ходила статья о неопределённом поведении, просто бесившая коренную аудиторию Rust. Завсегдатаи С и C++ в ответ только бурчали, что кто-то просто не понимает Всех Тонкостей и Нюансов Их Светлейшего Языка. Как обычно, пришло время и мне постараться изо всех сил и вставить мои пять копеек в эту застарелую дискуссию.

Готовьтесь поговорить об Основной Проблеме языков C и C++, а также о Принципе Лома.

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

Эта статья была на виду в конце ноября 2022 года. Читатели обсуждали небольшой изъян языка, связанный с тем, что GCC может взять неаккуратное знаковое целое и проэксплуатировать неопределённое поведение, заложенное в стандартной библиотеке C – и далее зарядит дробовик и начнёт крошить из него ваш код. Полностью пример выглядит так:

#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>

uint8_t tab[0x1ff + 1];

uint8_t f(int32_t x)
{
    if (x < 0)
        return 0;
    int32_t i = x * 0x1ff / 0xffff;
    if (i >= 0 && i < sizeof(tab)) {
        printf("tab[%d] looks safe because %d is between [0;%d[\n", i, i, (int)sizeof(tab));
        return tab[i];
    }

    return 0;
}

int main(int argc, char **argv)
{
    (void)argc;
    return f(atoi(argv[1]));
}

«Плохой» код, который превращается в проблему, будучи оптимизирован GCC, содержится в f, а именно – в операции умножения с последующей проверкой:

    // …
    int32_t i = x * 0x1ff / 0xffff;
    if (i >= 0 && i < sizeof(tab)) {
        printf("tab[%d] looks safe because %d is between [0;%d[\n", i, i, (int)sizeof(tab));
        return tab[i];
    }
    // …

Эта программа может быть скомпилирована при помощи GCC, например, вот так: gcc -02 -Wall -o f_me_up_gnu_daddy. После этого её можно запустить как ./f_me_up_gnu_daddy 50000000 – и здравствуй, дорогая ошибка сегментации (разумеется, это приведёт к дампу ядра, всё как принято). Как указано в той статье, программа даже выведет (printf) «безумную ложь, далее быстренько разыменует tab и бесславно помрёт».

Если вы понимаете, в чём дело: умножив 50 000 000 на 0x1ff (511 в десятичной системе), получаем 25 550 000 000; иными словами, число СЛИШКОМ большое для 32-разрядного целого (допустимый максимум составляет жалких 2 147 483 647). Так провоцируется переполнение знаковых целых. Но оптимизатор предполагает, что переполнения знаковых целых произойти не может, поскольку число уже положительное (что в данном случае гарантирует проверка x < 0, плюс умножение на константу). В конце концов, GCC берёт этот код, выдаёт его вам на-гора и фактически удаляет проверку i >= 0, а заодно и всё, что она подразумевает. Естественно, автору статьи это не нравится. Оказывается, далеко не только ему.

Великая борьба

Для начала должен отметить: это не первый случай, когда язык C, его реализации и даже сам стандарт попадают под град критики за подобные оптимизации. Ранее в 2022 году кто-то опубликовал код ровно в таком же стиле: с участием индекса знаковых чисел, который автор затем попытался бомбардировать проверками безопасности после нескольких арифметических операций. На тот момент (до обвала Twitter и, соответственно, блокировки аккаунта) он успел пометить этот код хештегом #vulnerability и заявил, что из-за вмешательства GCC код получается более опасным. Еще примерно полутора годами ранее Виктор Йодайкен как следует прошёлся в своей статье по «комитетчикам и реализаторам, выжившим из ума» , доведя эту идею до кульминации в своей статье о том, почему именно ISO C не подходит для разработки операционных систем (он даже вывесил видео в защиту своей позиции на Чтениях 11-го Воркшопа по языкам программирования и операционным системам).

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

Учитывая, сколько известно серьёзных выпадов в адрес компиляторов, которые своей оптимизацией доводят код до неопределённого поведения, можно было бы подумать, что WG14 — комитет C — или WG21 — комитет C++ — обратят на это внимание и попробуют найти решение. Ведь проблема то и дело всплывает в сообществах C и C++ на протяжении десятилетий. Но, прежде чем перейти к обсуждению того, что уже сделано и должно быть сделано, давайте поговорим, почему всех уже настолько достало неопределённое поведение, и почему, в частности, оно случается всё чаще. В конце концов, есть же системные программисты™ и вендоры/разработчики  компиляторов®, которым всё это очень не нравится, и которые уже берутся соревноваться «кто первый моргнёт». Глаза сохнут, начинает накатывать скука, держать пальцы на клавиатуре становится всё сложнее, а уж тем более – сосредоточенно воспринимать происходящее…

И вот. К сожалению,

Мы моргнули первыми

Как Виктор Йодайкен пытается подчеркнуть в своей статье и презентации, на его взгляд неопределённое поведение не предполагалось применять так, как его используют сегодня (в особенности это касается тех, кто пишет компиляторы). Кроме того, автор поста, на который я ссылаюсь выше, также этим шокирован и ссылается на принцип наименьшего удивления, рассуждая, почему GCC, Clang и другие компиляторы продолжают по-свински оптимизировать код именно в такой манере. Причём, максимально адекватная в таком случае реакция (не слишком весёлая, а более вдумчивая: «а ведь люди от этого действительно зависят, уф»), поступила от felix-gcc, в гораздо более старом багрепорте, касающемся GCC:

Согласно стандарту C, переполнение целочисленного типа является неопределённым, поэтому при сложении используйте беззнаковое целое или -fwrapv.

Да вы ЧТО, издеваетесь?

ПОЖАЛУЙСТА, ОТКАТИТЕ ЭТО ИЗМЕНЕНИЕ. Оно спровоцирует СЕРЬЁЗНЫЕ ПРОБЛЕМЫ С БЕЗОПАСНОСТЬЮ во ВСЕВОЗМОЖНОМ КОДЕ. Меня не волнует, что защитники вашего языка утверждают, будто gcc виднее. ЛЮДЕЙ НА ЭТОМ БУДУТ ВЗЛАМЫВАТЬ.

— felix-gcc, January 15, 2007

В игре в гляделки пользователи моргнули первыми, и в этот самый миг GCC принялся оптимизировать неопределённое поведение, ни в чём себя не ограничивая. Его примеру последовал Clang, и теперь жизнь многих разработчиков начинает напоминать рулетку, тогда как сами они полагают, что пишут надёжный и безопасный код. Теперь уже – нет, поскольку они пользуются конструкциями, которые трактуются в стандарте C как неопределённое поведение. По-видимому, победили те, кто защищает язык и регламентирует работу компиляторов, а такие как Виктор Йодайкен, felix-gcc и bug/ubitux (автор поста, из-за которого крайний раз вспыхнули протесты против оптимизаций такого рода) остались ни с чем.

… И это, разумеется, правда, но не вся.

Правда в том, что, сколько бы Йодайкен не настаивал в своём посте, что неопределённое поведение, используемое в качестве средства оптимизации – это просто «ошибка чтения», проблема началась не с ошибки чтения. Всё началось гораздо раньше, с предшественника ISO C – это было ещё до рождения некоторых из вас и до того, как вы впервые увидели компьютер.

Руками не трогать

У WG14 возникла проблема — ещё до того, как его назвали ISO/IEC SC22 JTC1 WG14 и даже до того, как он приобрёл официальный статус комитета по ANSI.

У них был парк компьютеров, и поведение этих компьютеров сильно отличалось. Сверх того появилось множество вещей, которые плохо поддавались проверке и не всегда вписывались в вычислительные мощности, доступные в то время. На тот момент казалось, что перечислить все возможные варианты поведения – титаническая задача, если не сказать – невыполнимая. Серьёзно, если сейчас кто-то не хочет писать документацию, то сложно даже представить, насколько этого не хотели делать в эпоху мейнфреймов, работавших на перфокартах. Кроме того, они совершенно не хотели препятствовать, тому, что их новенький язык с иголочки стремились использовать на самом разном железе, либо  различным вариантам использования, сложившимся на разных компьютерах. Так что, эта схема разрабатывалась во времена, когда существовал, фактически, единственный разработчик компиляторов, рассчитанных на глубоко неудобную/проклятую архитектуру.

 

Так и пришлось познакомиться с неопределённым поведением (и похожими аспектами). Комитет просто позволил себе послабление по отдельным вопросам. Тем, которые:

  • Оказались слишком сложны (например, проверить Неукоснительное Следование Правилу Единственного Определения для всех версий втягиваемой в строку функции со всеми вариантами заголовков); или

  • Не внушали уверенности (а что, если в будущем кто-то изобретёт компьютер, в котором используется ещё более экзотический CHAR_BIT или ещё более причудливые адресные пространства); или

  • Были слишком сложны для документирования (соглашения об использовании целых чисел, варианты поведения при переполнении (по модулю, с насыщением, при прерывании, т.д.)).

Они сочли, что это будет некоторая разновидность неопределённого/неуказанного/зависящего от реализации поведения. Пользуетесь обратным кодом вместо дополнительного? Получите неопределённое поведение на кончиках целочисленных диапазонов. Пользуетесь необычным сдвиговым регистром при работе с 16-разрядными целыми и перемещаете верхние биты? Неопределённое поведение. Передаёте слишком большой аргумент в функцию, задача которой – что-либо обращать в минус? Неуказанное/неопределённое поведение! Перемножаете два целых числа, и произведение больше не умещается в диапазоне? Вы правы, это

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

Проклятье

WG14 умыл руки по поводу этой проблемы. И в течение следующих 30-40 лет всё оставалось как есть. Конечно, программисты не могли просто так писать код, основанный на неопределённом поведении. Поэтому таких товарищей, как felix-gcc, Виктора Йодайкена и, пожалуй, сотни тысяч других программистов коробило, что в реализациях различных компиляторов допускаются такие договорняки. Компиляторы должны были просто «генерировать код», после чего пользователи должны были иметь дело именно с тем, что приказали сделать машине. В конечном счёте, именно эту интерпретацию пытается донести до нас Йодайкен, формулируя в вышеупомянутом посте самый выстраданный и растянутый тезис о том, что неопределённое поведение является «ошибкой чтения» в C. Независимо от того, хочет ли кто-то – и станет ли – вдаваться в такие же грамматические упражнения, как Йодайкен, всё это не имеет значения. В языке C уже сложился де-факто официальный порядок интерпретации кода. Этот порядок определяет всё, от того, как обрабатывать неопределённое поведение, то того, какие оптимизации должны срабатывать, а какие нет, и вплоть до того, как записываются неуказанные поведения и поведения, зависящие от реализации. Всё, на что падает свет  на что падают ваши пальцы, когда вы набиваете код, зависит от вашей реализации. Порядок следования факторов при интерпретации поведения – от максимально значимых до минимально значимых – получается таким:

1.     Сумма общепринятых знаний о том, как генерируется и интерпретируется код

2.     Точка зрения вендора/того, кто реализовал компилятор

3.     Стандарт языка C и другие связанные с ним стандарты (напр., POSIX, MISRA, ISO-26262, ISO-12207)

4.     Пользователь (⬅ это вы)

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

Низы не хотят

Это верно. Это выражение очень смешно звучит, если вворачивать его в разговоры об осознанном согласии, но в контексте общения с вендорами такой довод вообще не помогает! Ведь им достаточно предъявить нам каменную скрижаль и заявить: «Извините, но Так Сказано В Стандарте», дав нам отповедь как бандитам, независимо от того, нравится нам или нет заниматься таким мазохизмом. Это очень больно. «Но позвольте», — скажете вы, отчаянно пытаясь выбраться из-под той гребёнки, которая нас всех накрыла, — «а что же делать с -fwrapv или -fno-delete-null-pointer-checks? Это я, пользователь, их контролирую!» К сожалению, это не подлинный контроль. Это моменты, которые вы получаете от вашей реализации.

А реализация полностью контролируется теми, кто стоит выше вас, и, если вам доводится мигрировать на другой компилятор, не предлагающий таких плюшек, как GCC или Clang, вас могут провести ровно таким же образом. Кроме того, вас могут этого лишить. В политике Clang это сказано совершенно недвусмысленно, и именно так разработчики компилятора приобретают свободу действий, позволяющую реализовать такие флаги как -enable-trivial-auto-var-init-zero-knowing-it-will-be-removed-from-clang. Даже встраиваемые компиляторы, например, SDCC можно подкосить такими поведениями, зависящими от реализации. В результате может измениться размер структуры для этой последовательности битовых полей, описанной в данном багрепорте. Причём, хочу максимально ясно здесь обозначить, что это не вина SDCC; стандарт C позволяет разработчикам компиляторов поступать именно так, и они так и делают – вероятно, ради обеспечения работы на разных машинах и для соблюдения совместимости. В этом и суть.

Это недоработка в стандарте

Вендорам компиляторов и авторам реализаций всегда позволялось поступать по собственному усмотрению, зачастую они действовали вразрез со стандартами, действуя так, а не иначе по соображениям, связанным с совместимостью. Но пусть даже «стандарт» по рангу уступает интересам вендоров и программистов, реализующих компиляторы, он остаётся мощным орудием. Вы же, пользователь, бессильны перед лицом вендора, а Стандарт – эффективное средство, умело обращаясь которым, можно добиваться желаемого поведения. В этом не преуспели не только felix-gcc и ubitux, но и целые сообщества программистов C, работавшие в течение 30 лет. Они слишком серьёзно полагались на авторов реализаций и их закулисье, кулуарные сделки, при этом моля бездушное и своенравное божество, чтобы их расчёты не нарушались. Но у авторов реализаций свои приоритеты и свои контрольные показатели, свои  вехи, которых нужно достичь. Каждый день, смиряясь с любой дичью, которая вручалась нам как часть реализации – будь то высококачественный элемент управления, действующий на уровне Clang, действующий через #pragma, или что-то другое, или компилятор-написанный-неким-гуру-по-пьяни-за-выходные – мы обрекали себя именно на такое будущее.

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

Вот почему программистов, работающих с C и C++, настолько бесит GCC, или Clang, или любая другая реализация, в которой компилятор ведёт себя не так, как они хотят. Он сокрушает иллюзии, будто именно вы рулите вашим кодом, и полностью противоречит Принципу Наименьшего Удивления. Не потому, что концепция неопределённого поведения не была досконально объяснена, или не потому, что её кто-то не понимает, а потому, что здесь приходится усомниться в самой истинности устоявшегося убеждения, будто «C – это просто сборщик макросов». А мы из года в год продолжаем твердить: баг за багом встречается в GCC, за очередным заплюсованным постом следует другой с простынёй комментариев, но устоявшиеся убеждения не меняются, так как они превратились в догмы в сообществе С и (в меньшей степени) в сообществе C++. «Нативный» код, «машинный» код, инлайновый «ассемблер», «близко к металлу» — всё это элементы того «белого костюма», который нравится носить элитарным программистам, якобы способным распахнуть компьютер и всё и отовсюду сделать через командную строку.

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

Наш отпор

Согласитесь, мы и наши коллеги занимаемся не просто решением задач. Мы инженеры. Вендоры и разработчики компиляторов – не зло, но они чётко провели свою красную линию: они намерены заниматься оптимизациями, основанными на неопределённом поведении. Чем больше всего прописано в стандарте, тем сильнее мы рискуем, указывая этим ребятам, которые «главные по железу», что им делать. Если вендоры компиляторов и дальше собираются нас прогибать и продолжать оптимизировать с риском неопределённого поведения, то одно из немногого, что мы в силах сделать – это забрать своё.

Усваиваем "принцип лома"

Лично я выработал для себя очень простое правило, которое называю «принцип лома». Всякий раз, когда вы коммитите неопределённое поведение – считайте, что вы взяли лом и грохнули им по очень большой и дорогой вазе вашей мамы (или папы, бабушки, любого человека, который вам очень близок). Таким образом, если вам нужна была эта ваза (в данном случае – произведение знаковых целых), то вообразите, что теперь она совершенно невосстановима. От неё остались осколки, и теперь, чтобы собрать их в подобие вазы, требуется недюжинное мастерство. Поэтому прежде, чем начнёте размахивать ломом, всё проверьте до того, как закоммитить неопределённое поведение, а не после. Например, возьмём программу, рассмотренную в том посте, и внесём вот такие изменения, позволяющие проверить, не разносите ли вы её до основания – пока ситуация не покатилась к чертям:

#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <limits.h>

uint8_t tab[0x1ff + 1];

uint8_t f(int32_t x)
{
    if (x < 0)
        return 0;
    // проверка переполнения
    if ((INT32_MAX / 0x1ff) <= x) {
        printf("overflow prevented!\n");
        return 0;
    }
    // здесь мы помахали ломом и убедились,
    // что ничего страшного не происходит! 						
Источник: https://habr.com/ru/articles/756000/


Интересные статьи

Интересные статьи

ВведениеВсем привет! Меня зовут Артём Семенов, я занимаюсь пентестами в компании RTM Group.В эпоху быстро развивающегося Интернета вещей (IoT) критически важные сетевые устройства могут оказаться под ...
Привет, Хабр! Сегодня речь пойдет о предсказывании будущего, поведении людей, математике и котиках.  В повседневной жизни, общаясь с людьми, мы всегда смотрим на поведение собеседника. Повед...
Первые фото поверхности Венеры Как можно узнать из предыдущей статьи, исследование одной из наших ближайших соседок по Солнечной системе, планеты Венеры, началось с напряжённого соревнования между ...
Приветствую, Хабр! Предлагаю вашему вниманию небольшую пятничную статью про Java, Scala, ненормальных программистов и нарушенные обещания. Простые наблюдения иногда приводят к не очень простым...
Привет, Хабр! На HackQuest перед конференцией ZeroNight 2019 было одно занимательное задание. Я не сдал решение вовремя, но свою порцию острых ощущений получил. Я считаю, вам будет интерес...