Знаходимо помилки в коді компілятора GCC з допомогою аналізатора PVS-Studio

GCCЯ регулярно перевіряю різні відкриті проекти, щоб продемонструвати можливості статичного аналізатора коду PVS-Studio (C, C++, C#). Настав час компілятора GCC. Безперечно, GCC — це дуже якісний і відтестовані проект, тому знайти в ньому хоча б кілька помилок вже велике досягнення для будь-якого інструменту. До моєї радості, PVS-Studio впорався з цим завданням. Ніхто не застрахований від помилок і неуважності. Саме тому PVS-Studio може стати вашою додатковою лінією оборони на фронті нескінченної війни з багами.

GCC
GNU Compiler Collection (зазвичай використовується скорочення GCC) — набір компіляторів для різних мов програмування, розроблений в рамках проекту GNU. GCC є вільним програмним забезпеченням, поширюється фондом вільного програмного забезпечення на умовах GNU GPL і GNU LGPL і є ключовим компонентом GNU toolchain. Проект написаний на мові C і C++.

Компілятор GCC має хороші вбудовані діагностики, допомагають виявляти багато помилок на етапі компіляції. Природно, GCC збирається за допомогою GCC і, відповідно, може виявляти помилки у власному коді. Додатково вихідний код GCC перевіряється з допомогою аналізатора Coverity. Та й взагалі, думаю GCC перевірявся ентузіастами з допомогою багатьох аналізаторів і інших інструментів. Це робить пошук помилок в GCC великим випробуванням для аналізатора коду PVS-Studio.

Для аналізу було взято trunk версія з git-репозиторію: (git) commit 00a7fcca6a4657b6cf203824beda1e89f751354b svn+ssh://gcc.gnu.org/svn/gcc/trunk@238976

Примітка. Стаття затрималася з виходом, і можливо якісь помилки вже виправлені. Але це не має значення: постійно з'являються нові помилки, старі зникають. Головне — стаття показує, що статичний аналіз може допомагати програмістам виявляти помилки після їх появи.

Передбачаючи дискусію
Як я сказав у віданні, я вважаю GCC проектом з високою якістю коду. Впевнений, багато хто захоче посперечатися. В якості прикладу наведу цитату з Wikipedia російською мовою:

Деякі розробники OpenBSD, наприклад Тео де Раадт і Отто Мурбек (Otto Moerbeek), критикують GCC, називаючи його «громіздким, глючним, повільним і генеруючим поганий код».

Я вважаю такі заяви необґрунтованими. Так, можливо, код GCC містить багато макросів, які ускладнюють його читання. Але я ніяк не можу погодитися з заявою про його роботу. Якби GCC глючив, взагалі б ніде нічого не працювало. Ви тільки згадайте, як багато програм їм компілюється і успішно працює. Творці GCC роблять велику, складну роботу з великим професіоналізмом. Спасибі їм. Я радий, що можу протестувати роботу PVS-Studio на такому високоякісному проекті.

Для тих, хто скаже, що код компілятора Clang все одно крутіше, нагадаю: у ньому PVS-Studio також знаходив помилки: 1, 2.

PVS-Studio
Я перевірив код GCC з допомогою Alpha-версії аналізатора PVS-Studio for Linux. Ми плануємо почати видавати зацікавився програмістам Beta-версію аналізатора в середині вересня 2016 року. Інструкцію про те, як стати одним з перших, хто зможе спробувати Бета-версію PVS-Studio for Linux на своєму проекті, ви знайдете в статті "PVS-Studio зізнається в любові до Linux".

Якщо ви читаєте цю статтю набагато пізніше, ніж вересень 2016, і хочете спробувати PVS-Studio for Linux, то запрошую вас на сторінку продукту: http://www.viva64.com/ru/pvs-studio/

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

На жаль, я не можу видати розробникам компіляторів повний звіт. У ньому поки що надто багато сміття (помилкових спрацьовувань), пов'язаних з тим, що аналізатор не повністю готовий до зустрічі зі світом Linux. Потрібно виконати роботу по зменшенню кількості помилкових попереджень на типові використовувані конструкції. Спробую пояснити на одному простому прикладі. Багато діагностики не повинні лаятися на вирази, що відносяться до макросів assert. Ці макроси бувають влаштовані дуже творчо і треба навчити аналізатор не звертати на них увагу. Але справа в тому, що визначається макрос assert дуже по-різному, і треба навчити аналізатор всім типовими варіантами.

Тому розробників GCC прошу почекати виходу принаймні Beta-версії аналізатора. Я не хочу зіпсувати враження звітом, згенерованим недоробленою версією.

Класика (Copy-Paste)
Почнемо ми з самої класичної і поширеної помилки, яка виявляється за допомогою діагностики V501. Як правило, такі помилки виникають через неуважність при Copy-Paste або просто є помилками, що допускаються при наборі нового коду.

static bool
dw_val_equal_p (dw_val_node *a, dw_val_node *b)
{
....
case dw_val_class_vms_delta:
return (!strcmp (a->v.val_vms_delta.lbl1,
b->v.val_vms_delta.lbl1)
&& !strcmp (a->v.val_vms_delta.lbl1,
b->v.val_vms_delta.lbl1));
....
}

Попередження аналізатора PVS-Studio: V501 There are identical sub-expressions '!strcmp(a->v.val_vms_delta.lbl1, b>v.val_vms_delta.lbl1)' to the left and to the right of the '&&' operator. dwarf2out.c 1428

Швидко побачити помилки проблематично і слід уважно придивитися. Саме тому помилка не була виявлена при оглядах коду і рефакторинге.

Функція strcmp двічі порівнює одні і ті ж рядки. Мені здається, другий раз слід порівнювати не члени класу lbl1, lbl2. Тоді коректний код повинен виглядати так:

return (!strcmp (a->v.val_vms_delta.lbl1,
b->v.val_vms_delta.lbl1)
&& !strcmp (a->v.val_vms_delta.lbl2,
b->v.val_vms_delta.lbl2));

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

Погане форматування коду

Помилки, можливо, вдалося б уникнути, якщо використовувати «табличне» вирівнювання коду. Наприклад, помилку було би легше помітити, якщо форматувати код:

Табличне форматування коду

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

Давайте розглянемо ще одну помилку, яка, я впевнений, що з'явилася з-за Copy-Paste:

const char *host_detect_local_cpu (int argc, const char **argv)
{
unsigned int has_avx512vl = 0;
unsigned int has_avx512ifma = 0;
....
has_avx512dq = ebx & bit_AVX512DQ;
has_avx512bw = ebx & bit_AVX512BW;
has_avx512vl = ebx & bit_AVX512VL; // <=
has_avx512vl = ebx & bit_AVX512IFMA; // <=
....
}

Попередження аналізатора PVS-Studio: V519 The 'has_avx512vl' variable is assigned values twice successively. Perhaps this is a mistake. Check lines: 500, 501. driver-i386.c 501

В змінну has_avx512vl двічі поспіль записуються різні значення. Це не має сенсу. Я вивчив код і виявив змінну has_avx512ifma. Швидше за все, саме вона і повинна ініціалізується виразом ebx & bit_AVX512IFMA. Тоді коректний код повинен бути таким:

has_avx512vl = ebx & bit_AVX512VL; 
has_avx512ifma = ebx & bit_AVX512IFMA;

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

static bool
ubsan_use_new_style_p (location_t loc)
{
if (loc == UNKNOWN_LOCATION)
return false;

expanded_location xloc = expand_location (loc);
if (xloc.file == NULL || strncmp (xloc.file, "\1", 2) == 0
|| xloc.file == '\0' || xloc.file[0] == '\xff'
|| xloc.file[1] == '\xff')
return false;

return true;
}

Попередження аналізатора PVS-Studio: V528 It is odd that pointer to 'char' type is compared with the '\0' value. Probably meant: *xloc.file == '\0'. ubsan.c 1472

Тут програміст випадково забув разыменовать покажчик у вираженні xloc.file == '\0'. В результаті покажчик просто порівнюється з 0, тобто NULL. Ніякого ефекту це не має, так як раніше така перевірка вже виконувалася: xloc.file == NULL.

Добре, що термінальний нуль програміст записав як '\0'. Це допомагає швидше зрозуміти, що код є помилковим і як його треба виправити. Про це я писав в книзі (див. главу N9: Використовуйте для позначення термінального нуля літерал '\0').

Правильний варіант коду:

if (xloc.file == NULL || strncmp (xloc.file, "\1", 2) == 0
|| xloc.file[0] == '\0' || xloc.file[0] == '\xff'
|| xloc.file[1] == '\xff')
return false;

Хоча, давайте ще трохи покращимо код. Я рекомендую відформатувати вираз так:

if ( xloc.file == NULL
|| strncmp (xloc.file, "\1", 2) == 0
|| xloc.file[0] == '\0'
|| xloc.file[0] == '\xff'
|| xloc.file[1] == '\xff')
return false;

Зверніть увагу: тепер, якщо допустити таку ж помилку, шанс її помітити буде трохи вище:

if ( xloc.file == NULL
|| strncmp (xloc.file, "\1", 2) == 0
|| xloc.file == '\0'
|| xloc.file[0] == '\xff'
|| xloc.file[1] == '\xff')
return false;

Потенційне розіменування нульового покажчика
Ще цей розділ можна було б назвати «стотисячний приклад, чому макроси — це погано». Я дуже не люблю макроси і завжди закликаю поменше їх використовувати. Макроси ускладнюють читання коду, провокують появу помилок, ускладнюють роботу статичних аналізаторів. Як мені здалося з недовгого спілкування з кодом GCC, його автори дуже люблять макроси. Я замучився вивчати, на що розкривається той чи інший макрос і можливо тому пропустив чимало цікавих помилок. Зізнаюся, я іноді буваю ледачий. Але пару помилок, пов'язаних з макросами, я все-таки покажу.

odr_type
get_odr_type (tree type, bool insert)
{
....
odr_types[val->id] = 0;
gcc_assert (val->derived_types.length() == 0);
if (odr_types_ptr)
val->id = odr_types.length ();
....
}

Попередження аналізатора PVS-Studio: V595 The 'odr_types_ptr' pointer was utilized before it was verified against nullptr. Check lines: 2135, 2139. ipa-devirt.c 2135

Тут бачите помилку? Думаю, немає, і повідомлення аналізатора ясності не вносить. Вся справа в тому, що odr_types — це не ім'я змінної, а макрос, оголошеним наступним чином:

#define odr_types (*odr_types_ptr)

Якщо розкрити макрос і прибрати все що не відноситься до справи, ми отримаємо наступний код:

(*odr_types_ptr)[val->id] = 0;
if (odr_types_ptr)

На початку покажчик разыменовывается, а потім перевіряється. Призведе це до біди на практиці чи ні, сказати складно. Все залежить від того, може виникнути ситуація, коли покажчик дійсно буде дорівнює nullptr. Якщо така ситуація неможлива, то слід видалити зайву перевірку, яка буде вводити в оману людей, що підтримують код і аналізатор коду. Якщо вказівник може бути нульовим, то це серйозна помилка, яка вимагає ще більшої уваги і виправлення.

Розглянемо ще один аналогічний випадок:

static inline bool
sd_iterator_cond (sd_iterator_def *it_ptr, dep_t *dep_ptr)
{
....
it_ptr->linkp = &DEPS_LIST_FIRST (list);
if (list)
continue;
....
}

Попередження аналізатора PVS-Studio: V595 The 'list' pointer was utilized before it was verified against nullptr. Check lines: 1627, 1629. sched-int.h 1627

Щоб побачити помилку, нам знову потрібно показати пристрій макросу:

#define DEPS_LIST_FIRST(L) (L)->first)

Розкриваємо макрос і отримуємо:

it_ptr->linkp = &((list)->first);
if (list)
continue;

І зараз багато вигукнуть: «Стоп, стоп! Тут немає помилки. Адже ми просто отримуємо вказівник на член класу. Ніякого розіменування нульового покажчика тут немає. Так, можливо код не акуратний, але помилки тут немає!».

Все не так просто. Тут виникає невизначений поведінку. І те, що такий код може працювати на практиці, це просто везіння. Насправді, так писати не можна. Наприклад, оптимізуючий компілятор, побачивши list->first, може видалити перевірку if (list). Раз ми виконували оператор ->, значить передбачається, що вказівник не дорівнює nullptr. Якщо це так, то перевіряти вказівник не потрібно.

Я написав цілу статтю на цю тему: "Разыменовывание нульового покажчика призводить до невизначеної поведінки". Там якраз розглядається аналогічний випадок. Перш ніж сперечатися, прошу уважно ознайомитися з цією статтею.

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

Використання зруйнованого масиву
static void
dump_hsa_symbol (FILE *f, hsa_symbol *symbol)
{
const char *name;
if (symbol->m_name)
name = symbol->m_name;
else
{
char buf[64];
sprintf (buf, "__%s_%i", hsa_seg_name (symbol->m_segment),
symbol->m_name_number);
name = buf;
}
fprintf (f, "align(%u) %s_%s %s",
hsa_byte_alignment (symbol->m_align),
hsa_seg_name(symbol->m_segment),
hsa_type_name(symbol->m_type & ~BRIG_TYPE_ARRAY_MASK),
name);
....
}

Попередження аналізатора PVS-Studio: V507 Pointer to local array 'buf' is stored outside the scope of this array. Such a pointer will become invalid. hsa-dump.c 704

Рядок формується в тимчасовому буфері buf. Адреса цього тимчасового буфера зберігається у змінній name і далі використовується в тілі функції. Помилка в тому, що після запису буфера в змінну name, сам цей буфер буде знищений.

Використовувати вказівник на зруйнований буфер не можна. Формально ми маємо справу з невизначеним поведінкою. На практиці цей код може цілком успішно працювати. Коректна робота програми — це один з варіантів прояву невизначеного поведінки.

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

Щоб виправити помилку, досить оголосити масив buf в тій же області видимості, що і покажчик name:

static void
dump_hsa_symbol (FILE *f, hsa_symbol *symbol)
{
const char *name;
char buf[64];
....
}

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

bool
thread_through_all_blocks (bool may_peel_loop_headers)
{
....
/* Case 1, threading from outside to inside the loop
after we'd already threaded through the header. */
if ((*path)[0]->e->dest->loop_father
!= path->last ()->e->src->loop_father)
{
delete_jump_thread_path (path);
e->aux = NULL;
ei_next (&ei);
}
else
{
delete_jump_thread_path (path);
e->aux = NULL;
ei_next (&ei);
}
....
}

Попередження аналізатора PVS-Studio: V523 The 'then' statement is equivalent to the 'else' statement. tree-ssa-threadupdate.c 2596

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

Надлишкове вираз вигляду (A == 1 || A != 2)
static const char *
alter_output_for_subst_insn (rtx insn, int alt)
{
const char *insn_out, *sp ;
char *old_out, *new_out, *cp;
int i, j, new_len;

insn_out = XTMPL (insn, 3);

if (alt < 2 || *insn_out == '*' || *insn_out != '@')
return insn_out;
....
}

Попередження аналізатора PVS-Studio: V590 Consider inspecting this expression. The expression is excessive or contains a misprint. gensupport.c 1640

Нас цікавить умова: (alt < 2 || *insn_out == '*' || *insn_out != '@')

Його можна скоротити до: (alt < 2 || *insn_out != '@')

Ризикну припустити, що оператор != слід замінити на ==. Тоді код прийме більш осмислений вигляд:

if (alt < 2 || *insn_out == '*' || *insn_out == '@')

Обнулення не того вказівника
Розглянемо функцію, яка займається звільненням ресурсів:

void
free_original_copy_tables (void)
{
gcc_assert (original_copy_bb_pool);
delete bb_copy;
bb_copy = NULL;
delete bb_original;
bb_copy = NULL;
delete loop_copy;
loop_copy = NULL;
delete original_copy_bb_pool;
original_copy_bb_pool = NULL;
}

Попередження аналізатора PVS-Studio: V519 The 'bb_copy' variable is assigned values twice successively. Perhaps this is a mistake. Check lines: 1076, 1078. cfg.c 1078

Зверніть увагу на ці 4 рядки коду:

delete bb_copy;
bb_copy = NULL;
delete bb_original;
bb_copy = NULL;

Випадково двічі обнуляється покажчик bb_copy. Правильний варіант:

delete bb_copy;
bb_copy = NULL;
delete bb_original;
bb_original = NULL;

Assert, який нічого не перевірять
Неправильне умова, що є аргументом макросу gcc_assert не вплине на коректність роботи програми, але ускладнить пошук помилки, якщо така виникне. Розглянемо код:

static void
output_loc_operands (dw_loc_descr_ref loc, int for_eh_or_skip)
{
unsigned long die_offset
= get_ref_die_offset (val1->v.val_die_ref.die);
....
gcc_assert (die_offset > 0
&& die_offset <= (loc->dw_loc_opc == DW_OP_call2)
? 0xffff
: 0xffffffff);
....
}

Попередження аналізатора PVS-Studio: V502 Perhaps the '?:' operator works in a different way than it was expected. The '?:' operator has a lower than the priority '<=' operator. dwarf2out.c 2053

Пріоритет тернарного оператора ?: нижче, ніж у оператора порівняння <=. Це означає, що ми маємо справу з умовою виду:

(die_offset > 0 &&
(die_offset <= (loc->dw_loc_opc == DW_OP_call2)) ?
0xffff : 0xffffffff);

Таким чином, другий операнд оператора && може приймати значення 0xffff або 0xffffffff. Обидва ці значення означають істину, тому вираз можна спростити до:

(die_offset > 0)

Це явно не те, що задумував програміст. Щоб виправити ситуацію, слід додати пару круглих дужок:

gcc_assert (die_offset > 0
&& die_offset <= ((loc->dw_loc_opc == DW_OP_call2)
? 0xffff
: 0xffffffff));

Оператор ?: дуже підступний і його краще не використовувати в складних виразах. Дуже вже легко допустити помилку. У нас зібрано велика кількість прикладів таких помилок, знайдених аналізатором PVS-Studio в різних відкритих проектах. Детальніше про оператора ?: я писав у вже згаданій раніше книзі (див. главу N4: Бійтеся оператора ?: і укладайте його в круглі дужки).

Здається, забули про «cost»
Структура alg_hash_entry оголошена таким чином:

struct alg_hash_entry {
unsigned HOST_WIDE_INT t;
machine_mode mode;
enum alg_code alg;
struct mult_cost cost;
bool speed;
};

У функції synth_mult програміст вирішив перевірити, чи це об'єкт, який йому потрібен. Для цього йому потрібно порівняти поля структури. Однак, здається в цьому місці допущена помилка:

static void synth_mult (....)
{
....
struct alg_hash_entry *entry_ptr;
....
if (entry_ptr->t == t
&& entry_ptr->mode == mode
&& entry_ptr->mode == mode
&& entry_ptr->speed == speed
&& entry_ptr->alg != alg_unknown)
{
....
}

Попередження аналізатора PVS-Studio: V501 There are identical sub-expressions 'entry_ptr->mode == mode' to the left and to the right of the '&&' operator. expmed.c 2573

Два рази поспіль перевіряється mode, але зате немає перевірки cost. Можливо, одне з порівнянь потрібно просто видалити, а можливо, потрібно порівнювати cost. Мені складно судити, але код явно варто поправити.

Дублікати присвоювань
Такі ділянки коду, на мій погляд, не представляють небезпеки і, здається, дублирующееся присвоювання можна просто видалити.

Випадок N1
type_p
find_structure (const char *name, enum typekind kind)
{
....
structures = s; // <=
s->kind = kind;
s->u.s.tag = name;
structures = s; // <=
return s;
}

Попередження аналізатора PVS-Studio: V519 The 'structures' variable is assigned values twice successively. Perhaps this is a mistake. Check lines: 842, 845. gengtype.c 845

Випадок N2
static rtx
ix86_expand_sse_pcmpistr (....)
{
unsigned int i, nargs;
....
case V8DI_FTYPE_V8DI_V8DI_V8DI_INT_UQI:
case V16SI_FTYPE_V16SI_V16SI_V16SI_INT_UHI:
case V2DF_FTYPE_V2DF_V2DF_V2DI_INT_UQI:
case V4SF_FTYPE_V4SF_V4SF_V4SI_INT_UQI:
case V8SF_FTYPE_V8SF_V8SF_V8SI_INT_UQI:
case V8SI_FTYPE_V8SI_V8SI_V8SI_INT_UQI:
case V4DF_FTYPE_V4DF_V4DF_V4DI_INT_UQI:
case V4DI_FTYPE_V4DI_V4DI_V4DI_INT_UQI:
case V4SI_FTYPE_V4SI_V4SI_V4SI_INT_UQI:
case V2DI_FTYPE_V2DI_V2DI_V2DI_INT_UQI:
nargs = 5; // <=
nargs = 5; // <=
mask_pos = 1;
nargs_constant = 1;
break;
....
}

Попередження аналізатора PVS-Studio: V519 The 'nargs' variable is assigned values twice successively. Perhaps this is a mistake. Check lines: 39951, 39952. i386.c 39952

Випадок N3

Останній випадок більш дивний, ніж інші. Можливо, тут є якась помилка. Змінної steptype значення присвоюється 2 або 3 рази. Це підозріло.

static void
cand_value_at (....)
{
aff_tree step, delta, nit;
struct iv *iv = cand->iv;
tree type = TREE_TYPE (iv->base);
tree steptype = type; // <=
if (POINTER_TYPE_P (type))
steptype = sizetype; // <=
steptype = unsigned_type_for (type); // <=
....
}

Попередження аналізатора PVS-Studio: V519 The 'steptype' variable is assigned values twice successively. Perhaps this is a mistake. Check lines: 5173, 5174. tree-ssa-loop-ivopts.c 5174

Висновок
Я радий, що написав цю статтю. Тепер мені є що відповідати на коментарі виду «PVS-Studio не потрібен, так як все ті ж видає попередження і GCC». Як бачите, PVS-Studio дуже потужний інструмент і перевершує по діагностичним можливостям GCC. Я не заперечую, що в GCC реалізовані відмінні діагностики. Цей компілятор, при належній налаштування, дійсно виявляє багато проблем в коді. Але PVS-Studio — це спеціалізований і швидко розвивається інструмент, а це значить, що він завжди буде краще виявляти помилки в коді, ніж це роблять компілятори.

Запрошую познайомитися з перевірками інших відомих відкритих проектів, відвідавши розділ нашого сайту. А також, тим, хто використовує Twitter, піти за мною @Code_Analysis. Я регулярно публікую посилання на цікаві статті з програмування на мові C і C++, а також розповідаю про нові досягнення нашої аналізатора.


Якщо хочете поділитися цією статтею з англомовної аудиторією, то прошу використовувати посилання на переклад: Andrey karpov. Bugs found in GCC with the help of PVS-Studio.

Прочитали статтю і є питання?Часто до наших статей задають одні і ті ж питання. Відповіді на них ми зібрали тут: Відповіді на питання читачів статей про PVS-Studio, версія 2015. Будь ласка, ознайомтеся зі списком.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.