Виявлення переписаного переповнення в C / C ++


82

На перший погляд, це питання може здатися дублікатом Як виявити переповнення цілих чисел? , проте насправді це суттєво відрізняється.

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

Найбільш очевидним, але наївним способом зробити це буде щось на зразок:

int add(int lhs, int rhs)
{
 int sum = lhs + rhs;
 if ((lhs >= 0 && sum < rhs) || (lhs < 0 && sum > rhs)) {
  /* an overflow has occurred */
  abort();
 }
 return sum; 
}

Проблема цього полягає в тому, що згідно зі стандартом C, переповнене ціле число зі знаком є невизначеною поведінкою. Іншими словами, згідно зі стандартом, як тільки ви навіть спричиняєте переповнення із підписом, ваша програма стає такою ж недійсною, як якщо б ви визначили нульовий покажчик. Отже, ви не можете спричинити невизначену поведінку, а потім спробувати виявити переповнення по факту, як у наведеному вище прикладі перевірки стану.

Незважаючи на те, що наведена вище перевірка, ймовірно, буде працювати на багатьох компіляторах, ви не можете на неї розраховувати. Насправді, оскільки стандарт C говорить, що переповнення цілочисельного підпису невизначене, деякі компілятори (наприклад, GCC) оптимізують подану вище перевірку коли встановлені прапори оптимізації, оскільки компілятор вважає, що переповнений підпис неможливий. Це повністю розриває спробу перевірити переповнення.

Отже, ще одним можливим способом перевірки переповнення буде:

int add(int lhs, int rhs)
{
 if (lhs >= 0 && rhs >= 0) {
  if (INT_MAX - lhs <= rhs) {
   /* overflow has occurred */
   abort();
  }
 }
 else if (lhs < 0 && rhs < 0) {
  if (lhs <= INT_MIN - rhs) {
   /* overflow has occurred */
   abort();
  }
 }

 return lhs + rhs;
}

Це видається більш перспективним, оскільки насправді ми не додаємо два цілих числа разом, поки заздалегідь не переконаємось, що виконання такого додавання не призведе до переповнення. Таким чином, ми не спричиняємо невизначеної поведінки.

Однак це рішення, на жаль, набагато менш ефективне, ніж початкове рішення, оскільки вам доведеться виконати операцію віднімання, лише щоб перевірити, чи буде ваша операція додавання працювати. І навіть якщо вас не хвилює цей (невеликий) показник ефективності, я все ще не впевнений, що це рішення є адекватним. Вираз lhs <= INT_MIN - rhsздається точно таким, як вираз, який компілятор може оптимізувати, вважаючи, що переповнений підпис неможливий.

То чи є тут краще рішення? Щось, що гарантовано 1) не спричинить невизначеної поведінки, і 2) не надасть компілятору можливості оптимізувати перевірки від переповнення? Я думав, що, можливо, є якийсь спосіб зробити це, перекинувши обидва операнди на непідписані та виконуючи перевірки, прокатуючи власну арифметику доповнення двох, але я не дуже впевнений, як це зробити.


1
Замість спроби виявити, чи не краще переслідувати написання коду, який не має можливості переповнення?
Арун,

9
@ArunSaha: Насправді важко брати розрахунки та гарантувати, що вони не переповнюватимуться, і це неможливо довести в загальному випадку. Звичайна практика полягає у використанні якомога ширшого цілочисельного типу та надії.
Девід Торнлі,

6
@Amardeep: Розмежування нульового вказівника однаково невизначено, як підписане переповнення. Невизначена поведінка означає, що, наскільки Стандарт відповідає, все може статися. Не можна припустити, що система не буде в недійсному та нестабільному стані після переписаного переповнення. OP вказав на один з наслідків цього: абсолютно законно для оптимізатора видаляти код, який виявляє підписане переповнення, як тільки це відбувається.
Девід Торнлі,

16
@Amardeep: Я згадав про таку реалізацію. GCC видалить код перевірки переповнення, коли встановлені прапори оптимізації. Тож це в основному порушить вашу програму. Це, мабуть, гірше, ніж розпізнавання нульового вказівника, оскільки це може призвести до незначних недоліків безпеки, тоді як відмінювання нуля, швидше за все, просто тупить вашу програму за допомогою segfault.
Channel72

2
@Amardeep: Я, безсумнівно, здаюся реалізаціями, де, залежно від налаштувань компілятора, переповнення може призвести до пастки. Було б непогано, якби мови дозволяли вказувати, чи потрібно певним беззнаковим змінним чи величинам (1) обертати чисто, (2) винуватити або (3) робити все, що зручно. Зауважте, що якщо змінна менша за розмір реєстру машини, вимагаючи чистого обертання неподписаних величин, це може перешкодити генерації оптимального коду.
supercat

Відповіді:


26

Ваш підхід із відніманням правильний і чітко визначений. Компілятор не може оптимізувати його.

Іншим правильним підходом, якщо у вас є більший цілочисельний тип, є виконання арифметики для більшого типу, а потім перевірка, чи підходить результат у менший тип при перетворенні його назад

int sum(int a, int b)
{
    long long c;
    assert(LLONG_MAX>INT_MAX);
    c = (long long)a + b;
    if (c < INT_MIN || c > INT_MAX) abort();
    return c;
}

Хороший компілятор повинен перетворити все додавання та ifоператор у intвелике додавання та єдине умовне перехідне переповнення і ніколи насправді не виконувати більшого додавання.

Редагувати: Як зазначив Стівен, у мене виникають проблеми з отриманням (не дуже хорошого) компілятора, gcc, для генерації розумного asm. Код, який він генерує, не дуже повільний, але, безумовно, неоптимальний. Якщо хтось знає варіанти цього коду, які змусять gcc робити правильно, я б хотів їх бачити.


1
Для тих, хто хоче використовувати це, переконайтесь, що ви переглядаєте мою відредаговану версію. В оригіналі я тупо пропустив акторський long longсклад перед додаванням.
R .. GitHub СТОП ДОПОМОГАЙ ЛЕД

3
З цікавості ви досягли успіху в тому, щоб змусити компілятор виконати цю оптимізацію? Швидкий тест проти кількох компіляторів не виявив жодного, хто міг би це зробити.
Стівен Канон

2
У x86_64 немає нічого неефективного у використанні 32-розрядних цілих чисел. Продуктивність ідентична 64-розрядної. Однією з мотивацій використання типів розміру менше рідного слова є те, що надзвичайно ефективно обробляти умови переповнення або перенесення (для арифметики довільної точності), оскільки переповнення / перенесення відбувається в безпосередньо доступному місці.
R .. GitHub СТОП ДОПОМОГАЙ ЛЕД

2
@R., @Steven: жоден код віднімання, який вказав ОП, не є правильним, див. Мою відповідь. Я також даю там код, який просто робить це з максимум двома порівняннями. Можливо, компілятори з цим впораються краще.
Йенс Густедт,

3
Цей підхід не працює на незвичній платформі, де sizeof(long long) == sizeof(int). C вказує лише це sizeof(long long) >= sizeof(int).
chux

36

Ні, ваш другий код неправильний, але ви близькі: якщо ви встановите

int half = INT_MAX/2;
int half1 = half + 1;

результатом додавання є INT_MAX. ( INT_MAXзавжди непарне число). Отже, це дійсне введення. Але у вашому розпорядку у вас будеINT_MAX - half == half1 і ви перервете. Хибнопозитивний.

Цю помилку можна виправити, поставивши <замість<= обох чеків.

Але тоді ваш код не є оптимальним. Буде зроблено наступне:

int add(int lhs, int rhs)
{
 if (lhs >= 0) {
  if (INT_MAX - lhs < rhs) {
   /* would overflow */
   abort();
  }
 }
 else {
  if (rhs < INT_MIN - lhs) {
   /* would overflow */
   abort();
  }
 }
 return lhs + rhs;
}

Щоб побачити, що це справедливо, вам потрібно символічно додати lhsз обох сторін нерівності, і це дає вам точно арифметичні умови, що ваш результат виходить за межі.


+1 за найкращу відповідь. Незначні: пропонуємо /* overflow will occurred */підкреслити, що вся суть у тому, щоб виявити, що переповнення могло б статися, якби код робився lhs + rhsбез фактичної суми.
chux

16

IMHO, найпростіший спосіб боротьби з переповненням чутливого коду С ++ - це використання SafeInt<T>. Це крос-платформний шаблон C ++, розміщений на плекс-коді, який забезпечує гарантії безпеки, які ви тут бажаєте.

Я вважаю, що це дуже інтуїтивно зрозуміле використання, оскільки воно забезпечує безліч тих самих схем використання, що і звичайні числові операції, і виражає потоки та недоліки через винятки.


14

Що стосується випадку gcc, з приміток до випуску gcc 5.0 ми бачимо, що тепер він містить додаткову __builtin_add_overflowперевірку переповнення:

Додано новий набір вбудованих функцій для арифметики з перевіркою переповнення: __builtin_add_overflow, __builtin_sub_overflow і __builtin_mul_overflow, а також для сумісності з clang також інші варіанти. Ці вбудовані модулі мають два інтегральні аргументи (для яких не потрібно мати однаковий тип), аргументи поширюються на нескінченну точність підписаного типу, +, - або * виконується над ними, а результат зберігається у цілочисельній змінній, вказаній на за останнім аргументом. Якщо збережене значення дорівнює результату нескінченної точності, вбудовані функції повертають false, інакше true. Тип цілочисельної змінної, яка буде містити результат, може відрізнятися від типів перших двох аргументів.

Наприклад:

__builtin_add_overflow( rhs, lhs, &result )

З документа gcc вбудовані функції для виконання арифметики з перевіркою переповнення ми бачимо, що:

[...] ці вбудовані функції мають повністю визначену поведінку для всіх значень аргументів.

clang також надає набір перевірених арифметичних вбудованих елементів :

Clang пропонує набір вбудованих програм, що реалізують перевірену арифметику для програм, що мають важливе значення для безпеки, таким чином, щоб це було швидко та легко виразно в C.

в цьому випадку вбудованим буде:

__builtin_sadd_overflow( rhs, lhs, &result )

Ця функція виявляється дуже корисною, за винятком одного: int result; __builtin_add_overflow(INT_MAX, 1, &result);вона прямо не говорить про те, що зберігається при resultпереповненні, і, на жаль, тиха при вказівці невизначеної поведінки , не відбувається. Звичайно, це був намір - ніякого УБ. Краще, якщо це вказано.
chux

1
@chux хороший момент, тут зазначено, що результат завжди визначений, я оновив свою відповідь. Було б досить іронічно, якби це було не так.
Шафік Ягмор

Цікаво, що ваше нове посилання не має (unsigned) long long *resultдля __builtin_(s/u)addll_overflow. Звичайно, це помилка. Здивує достовірність інших аспектів. IAC, приємно їх бачити __builtin_add/sub/mull_overflow(). Сподіваюся, колись вони потраплять до специфікації C.
chux

1
+1 це генерує набагато кращу збірку, ніж будь-що, що ви могли б отримати в стандартній C, принаймні, не покладаючись на оптимізатор компілятора, щоб зрозуміти, що ви робите. Слід визначити, коли такі вбудовані програми доступні, і використовувати стандартне рішення лише тоді, коли компілятор не надає такого.
Алекс

11

Якщо ви використовуєте вбудований асемблер, ви можете перевірити прапор переповнення . Інша можливість полягає в тому, що ви можете використовувати тип даних safeint . Рекомендую прочитати цю статтю про Integer Security .


6
+1 Це ще один спосіб сказати: "Якщо C не може це визначити, то вас змушують до поведінки, специфічної для платформи". Так багато речей, за якими легко піклуватися у збірці, не визначено в мові С, створюючи гори з кротових гір в ім’я переносимості.
Mike DeSimone

5
Я дав голос проти відповіді asm на запитання C. Як я вже говорив, існують правильні, портативні способи написання чека на C, який генерує точно такий же asm, який ви писали б від руки. Звичайно, якщо ви використовуєте їх, вплив на продуктивність буде однаковим, і це буде набагато меншим впливом, ніж матеріали C ++ safeint, які ви також рекомендували.
R .. GitHub СТОП ДОПОМОГАЙ ЛЕД

1
@Matthieu: Якщо ви пишете код, який буде використовуватися лише для однієї реалізації, і ця реалізація гарантує, що щось спрацює, і вам потрібна хороша цілочисельна продуктивність, ви, безумовно, можете використовувати специфічні прийоми реалізації. Однак це не те, про що просив ОП.
Девід Торнлі,

3
C розрізняє поведінку, визначену реалізацією, та невизначену поведінку з поважних причин, і навіть якщо щось із UB "працює" у поточній версії вашої реалізації, це не означає, що вона буде продовжувати працювати в наступних версіях. Розглянемо поведінку gcc та підписане переповнення ...
R .. GitHub СТОП ДОПОМОГАЙ ЛЕД

2
Оскільки я засновував свій -1 на твердженні, що ми могли б отримати код С для створення ідентичного asm, я думаю, справедливо відмовитись від нього, коли всі основні компілятори виявляться сміттям у цьому відношенні ..
R .. GitHub STOP HELPING ICE

6

Найшвидший можливий спосіб - це використання вбудованого GCC:

int add(int lhs, int rhs) {
    int sum;
    if (__builtin_add_overflow(lhs, rhs, &sum))
        abort();
    return sum;
}

На x86 GCC компілює це у:

    mov %edi, %eax
    add %esi, %eax
    jo call_abort 
    ret
call_abort:
    call abort

який використовує вбудований в процесор виявлення переповнення.

Якщо ви не в порядку з використанням вбудованих GCC, наступним найшвидшим способом є використання бітових операцій над бітами знаків. Підписане переповнення додатково відбувається, коли:

  • два операнди мають однаковий знак, і
  • результат має інший знак, ніж операнди.

Знаковий біт ~(lhs ^ rhs)знаходиться на iff, якщо операнди мають той самий знак, а знаковий біт lhs ^ sum- на iff, якщо результат має інший знак, ніж операнди. Отже, ви можете зробити додавання у беззнаковій формі, щоб уникнути невизначеної поведінки, а потім використовувати знаковий біт ~(lhs ^ rhs) & (lhs ^ sum):

int add(int lhs, int rhs) {
    unsigned sum = (unsigned) lhs + (unsigned) rhs;
    if ((~(lhs ^ rhs) & (lhs ^ sum)) & 0x80000000)
        abort();
    return (int) sum;
}

Це компілюється у:

    lea (%rsi,%rdi), %eax
    xor %edi, %esi
    not %esi
    xor %eax, %edi
    test %edi, %esi
    js call_abort
    ret
call_abort:
    call abort

що набагато швидше, ніж передача на 64-розрядний тип на 32-розрядному комп'ютері (з gcc):

    push %ebx
    mov 12(%esp), %ecx
    mov 8(%esp), %eax
    mov %ecx, %ebx
    sar $31, %ebx
    clt
    add %ecx, %eax
    adc %ebx, %edx
    mov %eax, %ecx
    add $-2147483648, %ecx
    mov %edx, %ebx
    adc $0, %ebx
    cmp $0, %ebx
    ja call_abort
    pop %ebx
    ret
call_abort:
    call abort

1

Можливо, вам пощастить перетворити на 64-розрядні цілі числа та протестувати подібні умови. Наприклад:

#include <stdint.h>

...

int64_t sum = (int64_t)lhs + (int64_t)rhs;
if (sum < INT_MIN || sum > INT_MAX) {
    // Overflow occurred!
}
else {
    return sum;
}

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


Видаліть побітове-та і закинь із оператора return. Вони неправильні, як написано. Перетворення з більших підписаних цілих типів на менші є цілком чітко визначеним, поки значення вписується в менший тип, і воно не потребує явного приведення. Будь-який компілятор, який видає попередження та пропонує вам додати приклад, коли ви тільки перевірили, що значення не переповнюється, - це непрацюючий компілятор.
R .. GitHub СТОП ДОПОМОГИ ЛЕДУ

@R Ви маєте рацію, мені просто подобається бути явним щодо моїх акторських складів. Однак я зміню це для коректності. Для майбутніх читачів зворотний рядок читавreturn (int32_t)(sum & 0xffffffff); .
Джонатан,

2
Зверніть увагу, що якщо ви пишете sum & 0xffffffff, sumнеявно перетворюється на тип unsigned int(припускаючи 32-біт int), оскільки 0xffffffffмає тип unsigned int. Тоді результат побітового і дорівнює an unsigned int, а якщо sumбув від'ємним, він буде поза діапазоном значень, що підтримуються int32_t. Тоді перетворення у int32_tмає поведінку, визначену реалізацією.
R .. GitHub СТОП ДОПОМОГАЙ ЛЕД

Зверніть увагу, що це не буде працювати в середовищах ILP64, де ints 64-розрядні.
rtx13

1

Як щодо:

int sum(int n1, int n2)
{
  int result;
  if (n1 >= 0)
  {
    result = (n1 - INT_MAX)+n2; /* Can't overflow */
    if (result > 0) return INT_MAX; else return (result + INT_MAX);
  }
  else
  {
    result = (n1 - INT_MIN)+n2; /* Can't overflow */
    if (0 > result) return INT_MIN; else return (result + INT_MIN);
  }
}

Я думаю, що це має працювати на будь-які законні INT_MINта INT_MAX(симетричні чи ні); функція, як показано на кліпах, але повинно бути очевидно, як отримати інші способи поведінки).


+1 за хороший альтернативний підхід, який, можливо, є більш інтуїтивним.
R .. GitHub СТОП ДОПОМОГАЙ ЛЕД

1
Я думаю, що це - result = (n1 - INT_MAX)+n2;- може перелитися, якби n1 був малим (скажімо 0), а n2 був негативним.
davmac

@davmac: Хм ... можливо, потрібно розбити три випадки: починайте з одного for (n1 ^ n2) < 0, що на машині, що доповнює два, означатиме, що значення мають протилежний знак і можуть бути додані безпосередньо. Якщо значення мають однаковий знак, то наведений вище підхід буде безпечним. З іншого боку, мені цікаво, якщо автори стандарту очікували, що реалізації для двох додаткових апаратів безшумного переповнення перескочать рейки у випадку переповнення таким чином, що не призведе до негайного ненормального припинення програми, а спричинить непередбачуваний зрив інших обчислень.
supercat

0

Очевидним рішенням є перетворення на unsigned, щоб отримати чітко визначену поведінку без знаку переповнення:

int add(int lhs, int rhs) 
{ 
   int sum = (unsigned)lhs + (unsigned)rhs; 
   if ((lhs >= 0 && sum < rhs) || (lhs < 0 && sum > rhs)) { 
      /* an overflow has occurred */ 
      abort(); 
   } 
   return sum;  
} 

Це замінює невизначену поведінку підписаного переповнення перетворенням визначених реалізацією значень поза діапазоном між підписаними та беззнаковими, тому вам потрібно перевірити документацію компілятора, щоб точно знати, що відбудеться, але це має бути принаймні чітко визначеним, і слід правильно робити на будь-якій машині з двома комплементами, яка не піднімає сигнали про перетворення, як це майже кожна машина та компілятор C, побудовані за останні 20 років.


Ви все ще зберігаєте результат у sum, який є int. Це призводить до того, що результат, визначений реалізацією, або сигнал, визначений реалізацією, піднімається, якщо значення (unsigned)lhs + (unsigned)rhsбільше ніж INT_MAX.
R .. GitHub STOP HELPING ICE

2
@R: у цьому вся суть - поведінка визначається реалізацією, а не невизначеною, тому реалізація повинна документувати, що вона робить, і робити це послідовно. Сигнал може бути піднятий лише в тому випадку, якщо реалізація його задокументує, і в цьому випадку його потрібно завжди піднімати, і ви можете використовувати таку поведінку.
Chris Dodd,

0

У разі додавання двох longзначень, переносний код може розділити longзначення на низьку та високу intчастини (або на shortчастини, якщо справа longмає такий самий розмір, як int):

static_assert(sizeof(long) == 2*sizeof(int), "");
long a, b;
int ai[2] = {int(a), int(a >> (8*sizeof(int)))};
int bi[2] = {int(b), int(b >> (8*sizeof(int))});
... use the 'long' type to add the elements of 'ai' and 'bi'

Використання вбудованої збірки - це найшвидший спосіб націлення на певний процесор:

long a, b;
bool overflow;
#ifdef __amd64__
    asm (
        "addq %2, %0; seto %1"
        : "+r" (a), "=ro" (overflow)
        : "ro" (b)
    );
#else
    #error "unsupported CPU"
#endif
if(overflow) ...
// The result is stored in variable 'a'

-1

Я думаю, що це працює:

int add(int lhs, int rhs) {
   volatile int sum = lhs + rhs;
   if (lhs != (sum - rhs) ) {
       /* overflow */
       //errno = ERANGE;
       abort();
   }
   return sum;
}

Використання volatile утримує компілятор від оптимізації тесту, оскільки він вважає, що sumце могло змінитися між додаванням та відніманням.

Використовуючи gcc 4.4.3 для x86_64, збірка для цього коду робить додавання, віднімання та тест, хоча вона зберігає все в стеку та непотрібні операції зі стеком. Я навіть намагався, register volatile int sum =але збірка була однаковою.

Для версії з лише int sum =(без мінливості або реєстру) функція не виконала перевірку і зробила додавання, використовуючи лише одну leaінструкцію (lea це Ефективна адреса завантаження і часто використовується для додавання, не торкаючись реєстру прапорів).

Ваша версія є більшим кодом і має набагато більше стрибків, але я не знаю, що було б краще .


4
-1 за неправильне використання volatileдля маскування невизначеної поведінки. Якщо це «спрацьовує», вам все одно просто «пощастить».
R .. GitHub STOP HELPING ICE

@R: Якщо це не працює, компілятор реалізує volatileнеправильно. Все, що я намагався - це більш просте рішення дуже поширеної проблеми на вже відповіли питання.
nategoose

Однак там, де це може вийти з ладу, буде система, числове представлення якої обернеться до нижчих значень при переповненні цілих чисел.
nategoose

Цей останній коментар повинен містити "не" або "ні".
nategoose

@nategoose, ваше твердження про те, що "якщо це не працює, компілятор неправильно реалізує мінливу систему" є помилковим. З одного боку, в арифметиці доповнення двох завжди буде вірно, що lhs = sum - rhs, навіть якщо відбулося переповнення. Навіть якби це було не так, і хоча цей конкретний приклад трохи надуманий, компілятор може, наприклад, генерувати код, який виконує додавання, зберігає значення результату, зчитує значення назад в інший регістр, порівнює збережене значення з прочитаним значення та зазначає, що вони однакові і тому припускають, що переповнення не відбулося.
davmac

-1

Для мене найпростіша перевірка - перевірка ознак операндів та результатів.

Давайте вивчимо суму: переповнення може відбуватися в обох напрямках, + або -, лише тоді, коли обидва операнди мають однаковий знак. І, очевидно, переповнення буде, коли знак результату не буде таким, як знак операндів.

Отже, такої перевірки буде достатньо:

int a, b, sum;
sum = a + b;
if  (((a ^ ~b) & (a ^ sum)) & 0x80000000)
    detect_oveflow();

Редагувати: як запропонував Нільс, це правильна ifумова:

((((unsigned int)a ^ ~(unsigned int)b) & ((unsigned int)a ^ (unsigned int)sum)) & 0x80000000)

І відколи інструкція

add eax, ebx 

призводить до невизначеної поведінки? У порівнянні наборів інструкцій Intel x86 такого немає.


2
Тут ви втрачаєте суть. Ваш другий рядок коду sum = a + bможе спричинити невизначену поведінку.
Channel72

якщо ви додасте суму, a та b до непідписаних під час додавання тесту, ваш код буде працювати до
кінця

Це не визначено не тому, що програма вийде з ладу або поведеться по-іншому. Це саме те, що робить процесор для обчислення прапора OF. Стандарт просто намагається захиститися від нестандартних випадків, але це не означає, що вам заборонено це робити.
ruslik

@ Нільс, так, я хотів це зробити, але я думав, що чотири секунди (usngined int)зроблять це набагато нечитабельнішим. (ви знаєте, ви спочатку прочитали його і спробуйте, лише якщо вам сподобалось).
ruslik

1
невизначена поведінка в C, а не після компіляції до збірки
phuclv
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.