Як налагоджувати свій код за допомогою Asan

pic

Вступ

Я настійно рекомендую своїм колегам використовувати AddressSanitizer під час написання юніт-тестів на C++, оскільки операції з пам'яттю в C++ можуть бути дуже ризикованими. Використання AddressSanitizer для ваших юніт-тестів значно підвищить цілісність та безпеку коду. У цьому есе я детально розповім про один процес налагодження та поділюся досвідом використання цього інструменту для аналізу пам'яті.

Чому простий юніт-тест ненадійний?

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

Налагоджуючи мій тест gtest

Нещодавно я розробляв нову функцію для Milvus, пов'язану з агрегацією даних. Основна частина цієї функції написана на C++. Тому мені потрібно було створити юніт-тести для мого коду. Спочатку я написав тест наступним чином:

data = {{3,3,5,5,6}}  
Plan plan(data, group_by(column_a))  
auto res = framework.exec(plan);  
AssertEqual(res.size, 4)

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

unique_ptr agg = make_uniuqe();  
agg.virutal_method();//ok  
framework.input_data(agg, data);  
agg.non_virtual_method();//ok  
agg.virtual_method();//crushed

Той самий unique_ptr, що вказує на об'єкти похідного типу агрегації (agg), зламався після запису кількох елементів даних в agg. Я переглянув свій код і впевнений, що я не скидав або не звільняв unique_ptr, гарантуючи, що він залишиться дійсним під час другого виклику методу test1. Крім того, той факт, що виклик не-віртуального методу agg працює, підтримує це спостереження. На основі цього доказу я підозрюю, що в моїй програмі сталося пошкодження пам'яті, що, ймовірно, знищує vtable об'єкта DerivedAgg, перешкоджаючи знаходженню похідних віртуальних методів.

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

add_compile_options(-O0 -fno-stack-protector -fno-omit-frame-pointer -fno-var-tracking -fsanitize=address)   
add_link_options(-fno-stack-protector -fno-omit-frame-pointer -fno-var-tracking -fsanitize=address)

Примітка: -fno-stack-protector, -fno-omit-frame-pointer, -fsanitize=address критичні для AddressSanitizer, а -O0 використовується для зручності роботи з gdb, -fno-var-tracking зменшує накладні витрати при компіляції.

Помилка 1: Heap-buffer-overflow

pic

Після повторного запуску юніт-тесту виникла помилка heap-buffer-overflow. AddressSanitizer допоміг мені зібрати трасування стеку, коли сталася переповнення буфера в купі.

0x604000021600 is located 8 bytes to the right of 40-byte region   
 #6 0x7d08eead15ae in milvus::exec::HashLookup::reset(int)   
 /home/xxxxxx/Documents/project/milvus/internal/core/src/exec/HashTable.h:32

Цей вивід вказує на те, що код намагався записати 8 байт за адресою пам'яті 0x604000021600. Адреса 0x604000021600 знаходиться на 8 байт правіше виділеної області пам'яті [0x6040000215d0, 0x6040000215f8], що означає запис за межі виділеної пам'яті.

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

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

Помилка 2: витоки пам'яті

pic

“LeakSanitizer: виявлено витоки пам'яті.” AddressSanitizer (ASan) виявив витік пам'яті в моїй програмі та чітко вказав на трасу стека, де була виділена пам'ять. Перевіривши код, я виявив, що забув звільнити динамічно виділену пам'ять для std::string у рядках навантаження. Наступна діаграма ілюструє цей витік пам'яті.

pic

Забули звільнити пам'ять для рядків, що каскадно виділяються в купі

У моїй програмі, коли я зберігаю дані з кількох стовпців в одному рядку навантаження, я безпосередньо зберігаю оригінальні значення для примітивних типів і типів з фіксованою довжиною, таких як int8, int16 і float. Однак для стовпців змінної довжини, таких як string або JSON, я зберігаю вказівник у рядку навантаження, що займає фіксований розмір 8 байт у пам'яті, і виділяю фактичний вміст рядка в купі. Основною причиною помилки AddressSanitizer (ASan) було те, що хоча програма правильно видаляла пам'ять, виділену для всього рядка навантаження, вона не звільняла динамічно виділену пам'ять для рядків. Це упущення призвело до витоку пам'яті, що сталося в цьому тестовому випадку.

Помилка 3: Спроба звільнити не-виділену адресу

pic

Після того, як я додав логіку для звільнення пам'яті купи для стовпців змінної довжини, програма зламалася з помилкою: “спроба звільнити не-виділену пам'ять.” Я виявив, що ця проблема виникає лише у тестах, що містять нульові значення. Причина очевидна: для null рядків або null об'єктів JSON в Milvus відповідна позиція не містить дійсного вказівника на рядок, а радше сегмент безглуздої пам'яті. Тому спроба звільнити такий "підроблений вказівник" призводить до аварії.

pic

Після виправлення цієї помилки мій gTest випадок пройшов правильно, як і очікувалося.

Висновок

Це есе описує процес налагодження за допомогою AddressSanitizer, потужного інструменту для налагодження пам'яті. Завдяки AddressSanitizer я виявив і виправив три критичні помилки в коді під час етапу тестування. Такий проактивний підхід допомагає запобігти серйозним проблемам на подальших етапах розробки програмного забезпечення. Чесно кажучи, я добре усвідомлюю потенційні ризики та основні причини помилок, про які йдеться вище, і погоджуюсь, що такі помилки слід уникати, дотримуючись хороших практик кодування. Однак я впевнений, що використання інструментів налагодження, таких як AddressSanitizer, є необхідним, оскільки програмістам дуже складно контролювати всі потенційні ризики у всіх сценаріях та гілках коду.

Перекладено з: How to debug your code with Asan

Leave a Reply

Your email address will not be published. Required fields are marked *