Статичний аналіз коду для верифікації 64-бітних додатків (исходники), Різне, Програмування, статті

1. Введення


Масове виробництво і повсюдна доступність 64-бітових процесорів привели розробників додатків до необхідності розробки 64-бітових версій своїх програм. Адже для того, щоб користувачі могли отримати реальні переваги від використання нових процесорів, додатки повинні бути перекомпілювати для підтримки 64-бітної архітектури. Теоретично цей процес не повинен представляти проблем. Однак на практиці часто після перекомпіляції додаток працює не так, як повинно. Це може проявлятися самим широким чином: від псування файлів з даними, до відмови роботи довідкової системи. Причина такої поведінки криється в зміні розмірів базових типів даних в 64-бітних процесорах, а точніше – у зміні співвідношень між типами. Саме тому основні проблеми при переносі коду виявляються в додатках, розроблених з використанням низькорівневих мов програмування типу C або C + +. У мовах з чітко структурованою системою типів (наприклад, мови. NET Framework), як правило, таких проблем не виникає.


У чому ж проблема саме з низькорівневими мовами? Справа в тому, що навіть усі високорівневі конструкції та бібліотеки C + + в кінцевому підсумку реалізовані з використанням низькорівневих типів даних, таких як покажчик, машинне слово і т.п. Оскільки при зміні архітектури ці типи даних змінюються, то і поведінку програм також може змінитися.


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


Тому виникає завдання пошуку в вихідному коді програми тих місць, які при перенесенні з 32-бітної на 64-бітову архітектуру можуть працювати неправильно. Вирішенню такого завдання і присвячена ця стаття.


2. Приклади проблем, що виникають при перенесенні коду на 64-бітові системи


Наведемо кілька прикладів, коли після перенесення коду на 64-бітну систему, у додатку можуть виявитися нові помилки. Інші приклади можна знайти в різних статтях [1, 2].


При розрахунку необхідної для масиву пам’яті використовувався явно розмір типу елементів. На 64-бітної системі цей розмір змінився, але код залишився тим самим:






size_t ArraySize = N * 4;
intptr_t *Array = (intptr_t *)malloc(ArraySize);


Деяка функція повертала значення -1 типу size_t у разі помилки. Перевірка результату була записана так:






size_t result = func();
if (result == 0xffffffffu) {
// error
}


На 64-бітної системі значення -1 для цього типу виглядає вже по-іншому і перевірка не спрацьовує.


Арифметика з вказівниками – постійне джерело проблем. Але у випадку з 64-бітними додатками до вже відомих додаються нові проблеми. Розглянемо приклад:






unsigned a16, b16, c16;
char *pointer;
:
pointer += a16 * b16 * c16;


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


Всі ці та багато інших помилки були виявлені в реальних додатках під час перенесення їх на 64-бітну платформу.


3. Огляд існуючих рішень


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


Юніт-тести призначені для швидкої перевірки невеликих ділянок коду, наприклад, окремих функцій і класів [3]. Їх особливість в тому, що ці тести виконуються швидко і допускають частий запуск. З цього випливають два нюанси використання такої технології. По-перше, ці тести повинні бути написані. По-друге, тестування виділення великих обсягів пам’яті (наприклад, більше двох гігабайт) займає значне час, тому недоцільно, так як юніт-тести повинні відпрацьовуватися швидко.


Динамічні аналізатори коду (кращий представник – це Compuware BoundsChecker) призначені для виявлення помилок в додатку під час виконання програми. З цього принципу роботи і випливає основний недолік динамічного аналізатора. Для того щоб переконатися в коректності програми, необхідно виконати всі можливі гілки коду. Для реальної програми це може бути важко. Але це не означає, що динамічний аналіз коду не потрібен. Такий аналіз дозволяє виявити помилки, які залежать від дій користувача і не можуть бути визначені за кодом програми.


Статичні аналізатори коду (як, наприклад, Gimpel Software PC-lint і Parasoft C + + test) призначені для комплексного забезпечення якості коду і містять кілька сотень аналізованих правил [4]. У них також є деякі з правил, які аналізують коректність 64-бітних додатків. Однак, оскільки це аналізатори коду загального призначення, то їх використання для забезпечення якості 64-бітних додатків не завжди зручно. Це пояснюється, насамперед, тим, що вони не призначені саме для цієї мети. Іншим серйозним недоліком є ​​їх зорієнтованість на модель даних, яка використовується в Unix-системах (LP64). У той час як модель даних, яка використовується в Windows-системах (LLP64), істотно відрізняється від неї. Тому застосування цих статичних аналізаторів для перевірки 64-бітних Windows-додатків можливо тільки після неочевидною додаткової настройки.


Деяким додатковим рівнем перевірки коду можна вважати наявність в компіляторах спеціальної діагностики потенційно некоректного коду (наприклад, ключ / Wp64 в компіляторі Microsoft Visual C + +). Однак цей ключ дозволяє відстежити лише найбільш некоректні конструкції, у той час як багато хто з також небезпечних операцій він пропускає.


Виникає питання: <Може бути, перевірка коду додатків при перенесенні на 64-бітові системи не потрібна, оскільки таких помилок в додатку буде не так багато?>. Ми вважаємо, що така перевірка необхідна хоча б тому, що найбільші компанії (наприклад, IBM і Hewlett-Packard) розмістили на своїх сайтах статті [2], присвячені виникають при перенесенні коду помилок.


4. Правила аналізу коректності коду


Ми сформулювали 10 правил пошуку небезпечних конструкцій мови C + + з точки зору перенесення коду на 64-бітну систему. Перед описом правил необхідно нагадати про поняття значущих біт. Говорячи про кількість значущих біт, ми враховуємо, що негативні значення використовують всі біти даного типу:






int a = 1; / / Використовується 1 біт. (0x00000001) int b = -1; / / Використовується 32 біта. (0xFFFFFFFF)


У правилах використовується спеціально введений тип memsize. Під memsize-типом ми будемо розуміти будь-який простий цілочисельний тип, здатний зберігати в собі покажчик і міняє свою розмірність при зміні розрядності платформи з 32 біт на 64 бита. Приклади memsize-типів: size_t, ptrdiff_t, все покажчики, intptr_t, INT_PTR, DWORD_PTR.


Тепер перерахуємо самі правила і наведемо приклади їх застосування.


ПРАВИЛО 1


Слід вважати небезпечними конструкції явного і неявного приведення цілих типів розмірністю 32 біта до memsize типами:






unsigned a;
size_t b = a;
array[a] = 1;


Винятки:


1) приводиться 32-бітний цілий тип є результатом виразу, де для подання значення виразу потрібно менше 32 біт:






unsigned short a;
unsigned char b;
size_t c = a * b;


При цьому вираз не повинно складатися тільки з числових літералів:






size_t a = 100 * 100 * 100;


2) приводиться 32-бітний тип представлений числовим літералів:






size_t a = 1;
size_t b = “G”;


ПРАВИЛО 2


Слід вважати небезпечними конструкції явного і неявного приведення memsize-типів до цілих типам розмірністю 32 біта:






size_t a;
unsigned b = a;


Виключення:


Наведений тип size_t є результатом виконання оператора sizeof ():






int a = sizeof(float);


ПРАВИЛО 3


Небезпечною слід вважати віртуальну функцію, що задовольняє ряду умов:


а) функція оголошена в базовому класі і в класі-нащадку.


б) типи аргументів функцій не збігаються, але еквівалентні на 32-бітної системі (наприклад: unsigned, size_t) і не еквівалентні на 64-бітної.






class Base {
virtual void foo(size_t);
};
class Derive : public Base {
virtual void foo(unsigned);
};


ПРАВИЛО 4


Небезпечними слід вважати виклики перевантажених функцій з аргументом типу memsize. При цьому функції повинні бути перевантажені для цілих 32-бітних і 64-бітових типів даних:






void WriteValue(__int32);
void WriteValue(__int64);

ptrdiff_t value;
WriteValue(value);


ПРАВИЛО 5


Небезпечним слід вважати явне приведення одного типу покажчика до іншого, якщо один з них посилається на 32-х/64-x бітний тип, а інший на memsize-тип:






int *array;
size_t *sizetPtr = (size_t *)(array);


ПРАВИЛО 6


Небезпечним слід вважати явні і неявні приведення memsize-типу до double і навпаки:






size_t a;
double b = a;


ПРАВИЛО 7


Небезпечним слід вважати передачу memsize-типу у функцію з перемінним кількістю аргументів:






size_t a;
printf(“%u”, a);


ПРАВИЛО 8


Небезпечним слід вважати використання ряду магічних констант (4, 32, 0x7fffffff, 0x80000000, 0xffffffff):






size_t values[ARRAY_SIZE];
memset(values, ARRAY_SIZE * 4, 0);


ПРАВИЛО 9


Небезпечним слід вважати наявність в об’єднаннях (union) членів memsize-типів:






union PtrNumUnion {
char *m_p;
unsigned m_n;
} u;

u.m_p = str;
u.m_n += delta;


ПРАВИЛО 10


Небезпечними слід вважати генерацію і обробку виключень з використанням memsize-типів:






char *p1, *p2;
try {
throw (p1 – p2);
}
catch (int) {
:
}


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


Представлені правила реалізовані в статичному аналізаторі коду Viva64. Принцип його роботи розглядається в наступному розділі.


5. Архітектура аналізатора


Робота аналізатора складається з декількох етапів, частина з яких властива звичайним компіляторам C + + (рисунок 1).




Рисунок 1. Архітектура аналізатора.

На вхід аналізатора надходить файл з вихідним кодом, а в результаті його роботи генерується звіт про потенційні помилки в коді з номерами рядків. Етапи роботи аналізатора: препроцесорну обробка, побудова дерева коду і власне аналіз.


На етапі препроцесорну обробки виконується підключення файлів, оголошених за допомогою # include-директив, а також обробка параметрів умовної компіляції (# ifdef / # endif).


В результаті розбору (parsing) файлу отриманого після препроцесорну обробки, будується дерево коду з тією інформацією, яка в подальшому необхідна для аналізу. Розглянемо простий приклад:






int A, B;
ptrdiff_t C;
C = B * A;


У цьому коді є потенційна проблема, пов’язана з різними типами даних. Так, мінлива C тут ніколи не зможе прийняти значення менше або більше 2 Гігабайт, що може бути неправильно. Аналізатор повинен повідомити, що в рядку “C = B * A” потенційно некоректна конструкція. Варіантів виправлення цього коду декілька. Якщо змінні B і A ніколи не можуть брати за змістом значення більше 2 гігабайт, але мінлива C може, то записати вираз слід так:






C =  (ptrdiff_t)(B) * (ptrdiff_t)(A);


Але якщо змінні A та B на 64-бітної системі можуть приймати великі значення, то треба виправити їх тип на ptrdiff_t:






ptrdiff_t A;
ptrdiff_t B;
ptrdiff_t C;
C = B * A;


Покажемо, як це виконується на рівні аналізу дерева коду.


Спочатку для коду будується дерево (малюнок 2).




Рисунок 4. Обчислення типу виразів.

Потім виконується перевірка при обчисленні типу результуючого виразу (операція “=” в нашому прикладі) і в разі конфлікту типів конструкція позначається як потенційно небезпечна. В розглянутому прикладі такий конфлікт має місце, так як змінна C має розмір 64 біта (на 64-бітної системі), а результат виразу “B * A” – 32 біта.


Аналогічним чином виконується аналіз інших правил, так як майже всі вони пов’язані з перевіркою типів тих чи інших параметрів.


6. Результати


Наведені у статті методики аналізу коду реалізовані в комерційному статичному аналізаторі коду Viva64. Використання цього аналізатора на реальних проектах показало доцільність перевірки коду при розробці 64-бітних додатків: реальні помилки в коді з його допомогою можна виявити значно швидше, ніж при простому перегляді вихідних кодів.


7. Посилання



  1. J. P. Mueller. “24 Considerations for Moving Your Application to a 64-bit Platform”, DevX.com , June 30, 2006.
  2. Hewlett-Packard, “Transitioning C and C++ programs to the 64-bit data model”. Available at devresource.hp.com/drc/STK/docs/refs/64datamodel.jsp.
  3. S. Sokolov, “Bulletproofing C++ Code”, Dr. Dobb”s Journal , January 09, 2007.
  4. S. Meyers, M. Klaus, “A First Look at C++ Program Analyzer”, Dr. Dobb”s Journal , Feb. Issue, 1997.

Схожі статті:


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

Коментарів поки що немає.

Ваш отзыв

Поділ на параграфи відбувається автоматично, адреса електронної пошти ніколи не буде опублікований, допустимий HTML: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>

*

*