На Тему Дополнения Или Как Я Нашел Ошибку В Gcc (Не Совсем)



Многие вещи нам непонятны не потому, что наши понятия слабы; а потому, что эти вещи не входят в круг наших понятий.

Вчера, просматривая новый комментарий к моему старому посту о реализации сдвигов компилятора gcc для МК, я заметил интересную ссылку godbolt.org/z/lW6rk8 .

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

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

Итак, мы видим две функции, которые сравнивают определенное значение x и это же значение, увеличенное на 1, и сообщают, какое из них больше.

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

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

Естественно, x+1> x, так как если вычесть x из обеих частей неравенства, то получим тождество 1> 0. При этом мы неявно постулируем, что для любого числа существует число, следующее за ним, и число возможных чисел бесконечно.

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

Но в компьютерной математике не всё так просто и проблема возникает из-за ограниченности кардинальных типов, поскольку это означает конечное число объектов, которые могут быть представлены определённым типом для данной кодировки.

Среди них есть максимальное, в каком-то смысле, число, и попытка его увеличить приводит к парадоксу — нужно закодировать число, которое невозможно закодировать.

Это явление обычно называют переполнением сетки или просто переполнением.

Типичный пример: 255 для 8-битного числа.

Pnp: В детстве одной из моих любимых игрушек была счетная машинка «Феликс», и моим постоянным развлечением было сложение числа «000000001» с «999999999».

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

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

На другой моей игрушке - счетах (мама была бухгалтером) происходили те же процессы, но там я сам катал домино, а на арифмометре это была какая-то мистика.

Итак, для целых чисел со знаком парадокс решается просто — его появление запрещено, поэтому переполнение приводит к UB. И, поскольку относительно ее результата справедливы любые предположения (в том числе и фантастические), компилятор имеет право предположить, что x+1 всегда будет больше x, и вернуть значение «истина», что он и делает в первой функции.

.

Пнп: В DSP используется другой подход (импортозамещение DSP) — операции с насыщением, при этом x+1 может быть равно x, но это пока экзотика.

А вот для беззнаковых целых чисел результат прибавления 1 к максимальному числу определен и равен нулю.

Следовательно, 0xFFFF+0x0001=0x0000 > 0xFFFF неверно, и компилятор должен сгенерировать код для проверки этого случая, что мы видим во второй функции.

Пока ничего интересного для читателя «по теме» я не написал, но вот тут начинается.

Для изучения ассемблерного кода я перешёл на свои любимые контроллеры AVR (их ассемблер проще и понятнее, чем у ARM).

Для начала давайте посмотрим, как gcc выполняет заказанную нами проверку.

  
  
  
  
   

alwaysTrue_unsigned(unsigned int): mov r18,r24 mov r19,r25 subi r18,lo8(-(1)) sbci r19,hi8(-(1)) ldi r20,lo8(1) cp r24,r18 cpc r25,r19 brlo .

L3 ldi r20,lo8(0) .

L3: mov r24,r20 ret

Берём число х в буфер (строки 2,3), прибавляем к нему 1 (4,5), подготавливаем результат (6), сравниваем буфер с числом х (7,8), корректируем результат (9 ,10) и задача выполнена.

Всего 9 команд и время выполнения 8+ тактов.

Но это версия 5.4.0. А вот вывод версии 9.2.0:

alwaysTrue_unsigned(unsigned int): mov r19,r25 mov r18,r24 ldi r24,lo8(1) cpi r18,-1 sbci r19,-1 breq .

L5 ret .

L5: ldi r24,0 ret

и мы видим, что компилятор реализует совсем другую проверку.

Берём число x в буфер (2,3), подготавливаем результат (4), вычитаем из буфера 0xFFFF (5,6) и корректируем результат. Всего 7 команд и время выполнения 6+ тактов.

Если мы переведем этот код обратно на C, мы получим что-то вроде:

return (x!=0xFF)

И такое преобразование абсолютно допустимо, но я не имею ни малейшего представления о том, как компилятор делает такие преобразования, это просто сказка какая-то.

Также обратите внимание на код проверки выражения (x+4)> x и так далее.

Потом я решил поиграться с типами и попробовал (u)int16_t — ничего не изменилось, (u)int32_t — появились загадочные передачи, но смысл проверки остался прежним, я использовал (u)int8_t и обалдел.

Совершенно неожиданно код беззнакового аргумента сократился и функция стала выдавать жёсткий.

Но это откровенно неверно и для случая x=0xFF условие (x+1)> x не будет выполнено.

Ура, я действительно нашел ошибку в gcc (в плане оптимизации) и могу сообщить о ней и помочь в разработке такого мощного продукта.

Пнп: Когда лет 15 назад мои мальчики пришли с фразой «Папа, я нашел ошибку в компиляторе» (мы говорили о ТурбоПаскале), я отправил их с предложением повнимательнее посмотреть на их код и ошибку компилятора.

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

Но счастье было недолгим.

На самом деле, нужно внимательно прочитать стандарт языка Си и завеса тайны приоткроется (я этого не делал и просто догадался).

Вы тоже можете это догадаться Выражение (x+1) в операторе сравнения имеет тип int (в данном случае uint16_t), поэтому перед выполнением сложения тип uint8_t преобразуется в тип uint16_t путем присвоения нулевого старшего байта, после чего переполнение становится невозможным.

и результат функции предрешен.

Достаточно просто явно обозначить свои намерения сравнивать байты следующим образом:

Return (uint8_t)(x+1)>x

и необходимая проверка возвращается в ассемблерный код. Вариант

return (x+(unsigned char)1) > x;

не катит, что характерно.

Наверняка существует прагма компилятора, которая по умолчанию сделает int 16-битным и позволит вернуть проверку значения x, но это немного сложнее.

Вот забавная предновогодняя история, которая лично мне дала больше понимания преобразования кардинальных типов в C (теперь я ее никогда не забуду), чем возможное чтение стандартов (но это вовсе не значит, что нельзя читать их).

Теги: #Программирование микроконтроллеров #программирование #C++

Вместе с данным постом часто просматривают: