Наша совместная команда Banwar.org

Связаться с нами

  • (097) ?601-88-87
    (067) ?493-44-27
    (096) ?830-00-01

Статьи

Intel Parallel Inspector - пошук помилок доступу до пам'яті

  1. Вступ Parallel Inspector є одним з чотирьох інструментів, що входять до складу набору Intel Parallel...
  2. інтерфейс
  3. Типи виявлених помилок
  4. Висновок

Вступ

Parallel Inspector є одним з чотирьох інструментів, що входять до складу набору Intel Parallel Studio. Inspector може бути встановлений і проінтегрувати в Microsoft Visual Studio як частина набору, так і окремо. На сьогоднішній день - це найцікавіший і очікуваний інструмент в складі пакету, так як він допомагає виявити помилки в багатопотокової програмі, на етапі верифікації, підвищуючи коректність і стабільність її виконання. Специфічний функціонал Inspector'а має на увазі його застосування командами тестувальників (QA team). Однак і самі розробники включають перевірку програми на наявність помилок, хоча б на рівні юніт-тестів (unit tests), в свою інженерну практику.

У даній статті ми розглянемо особливості використання Inspector'а для пошуку помилок доступу до пам'яті. Інструмент також дозволяє знаходити і помилки, характерні виключно для багатопоточних програм (dead locks, data races), але ця тема заслуговує на окрему статтю, і ми її обговоримо пізніше.

Механізми пошуку помилок

У загальному випадку механізм виявлення помилок пам'яті заснований на структурному аналізі всієї сукупності актів читання і запису в пам'ять процесу протягом виконання програми. Такий підхід не новий і використовується різними «меморі-чекер», які застосовують так звану статичну інструментації для відстеження інструкцій читання / запису і викликів функцій API роботи з пам'яттю. Для цього «вимірювальний інструмент» (instrumentation engine) модифікує бінарний код ще до запуску його на виконання (static binary instrumentation), вставляючи певні інструкції до і відразу після потрібної інструкції або функції. Далі, інструментований скрипт буде працювати на виконання, а при досягненні вставок коду зберігаються параметри програми, такі як тимчасова відмітка, поточний стек і контекст виконання. Потім, вся ця маса інформації обробляється з метою пошуку порушень доступу, некоректного використання, і інших помилок роботи з пам'яттю.

У Inspector'е застосовується дещо інший підхід аналізу всіх інструкцій читання / запису пам'яті і їх адрес на рівні бінарного коду за допомогою динамічної бінарної інструментації. В основі інструментатора лежить утиліта Pin - Dynamic Binary Instrumentation Tool , Яка впроваджується в аналізований процес безпосередньо перед стартом і дозволяє відстежувати виконання практично будь-яких інструкцій, надає API доступу до вмісту регістрів, контексту виконання програми, символьної та налагоджувальної інформації.

Можна провести певну аналогію між Pin і JIT (just-in-time) компілятором. Тільки на вхід «Pin-компілятора» подається не байт-код, а виконуваний бінарний код. Pin перехоплює найпершу інструкцію програми і генерує свою послідовність інструкцій (трасу), збігається з першими оригінальними інструкціями аж до першого розгалуження програми (або до значення ліміту інструкцій в трасі), а потім, передає управління цієї послідовності. Управління повертається до Pin при настанні розгалуження, він генерує нову послідовність інструкцій, відповідну гілці, і знову передає управління. Pin тримає весь згенерований код в пам'яті, тому і виконання і розгалуження досить ефективні. Оригінальний код не виконується, а виконуються тільки згенеровані послідовності. При цьому користувачу утиліти дається можливість впроваджувати свій (аналізує) код куди завгодно (інструментація), за винятком хіба що модулів ядра операційної системи.

Обидві складові результуючого коду (інструментований і аналізує) «живуть» в одному адресному просторі модуля Pintool, який можна розглядати як plug-in, що дозволяє модифікувати процес генерування коду всередині утиліти Pin. Pintool реєструє функції зворотного виклику (callback), які викликаються Pin кожен раз, коли в профільованих додатку виконується певна умова (виконується інструкція, викликається функція, і т.д.). Ці функції відповідальні за склад, що згенерувала інструкцій, вони інспектують код програми і визначають, чи є необхідність, і де саме потрібно вставити виклики аналізує коду в цю послідовність. Цими викликами можна «накрити» практично будь-які функції, виконувані в додатку. А Pin подбає про те, щоб контексти викликів були збережені і відновлені без змін, щоб функцій передавалися правильні аргументи.

Pintool може мати ще більш низьку гранулярность і інструментований кожну інструкцію додатки. Для цього Pin надає спеціальний API, що дозволяє селективно інструментований інструкції певного типу, наприклад, доступу до пам'яті або розгалуження. Інструкції, специфічні для певних мікроархітектури, теж можуть бути проінструментіровани за допомогою спеціального API.

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

інтерфейс

Таким чином, в залежності від цілей і завдань аналізу, можна сформувати кілька типів Pintool-інструментів, які б мали свій рівень гранулярності інструментації і були б «націлені» на збір певного типу даних в виконуваній програмі. Зрозуміло, що всебічний аналіз виконуваного коду не може бути проведено без істотних накладних витрат. Тому він розділений на рівні, що використовують різні Pintool-інструменти, які мають різні можливості виявлення помилок і відображають глибину і складність аналізу. Чим вище рівень, тим, як правило, більше буде потрібно часу для перевірки додатку. Таким чином, в залежності від цілей і завдань аналізу, можна сформувати кілька типів Pintool-інструментів, які б мали свій рівень гранулярності інструментації і були б «націлені» на збір певного типу даних в виконуваній програмі

Вікно вибору глибини аналізу

  • Рівень mi1 - дозволяє виявляти тільки витоку пам'яті, виділеної в купі (heap). Глибина стека функцій дорівнює 7. Це дасть достатньо інформації для визначення місцезнаходження помилки і структури викликів функцій, що виділяли пам'ять.
  • Рівень mi2 - дозволяє виявляти всі інші помилки роботи з пам'яттю в купі, які ми розглянемо нижче. Однак для зниження накладних витрат і прискорення аналізу, глибина стека обрано рівної одиниці. Тобто, на цьому рівні ми зможемо знайти відповідь на питання, чи є в принципі помилки в програмі. А де ці помилки, нам допоможе визначити наступний рівень.
  • Рівень mi3 - відрізняється від попереднього тим, що глибина стека збільшена до 12-ти. Плюс доданий функціонал пошуку загублених покажчиків. На цьому рівні ми отримуємо найбільш повний аналіз коректності роботи з пам'яттю в купі, але заплатимо за це накладними витратами, які збільшать час виконання програми від 20 до 80 разів у порівнянні з оригіналом.
  • Рівень mi4 - вищий рівень, доповнений аналізом помилок доступу до пам'яті, виділеної на стеку, які не виявлені на стадії компіляції або за допомогою run-time check опцій компілятора. Рівень вкладеності функцій - 32. Як і всі інші рівні, 4-й є інклюзивним, тобто включає в себе всі види аналізу на попередніх рівнях. Відповідно, накладні витрати будуть максимальними.

Глибина стека на кожному рівні обрана емпірично і є компромісом між повнотою наданої інформації і величиною накладних витрат на аналіз програми. У даній версії продукту користувач не може змінювати глибину стека.

Щоб почати аналіз додатки, необхідно просто вибрати відповідний тип аналізу в інструментальній панелі Parallel Inspector, натиснути кнопку "Inspect" і вибрати рівень аналізу. Щоб почати аналіз додатки, необхідно просто вибрати відповідний тип аналізу в інструментальній панелі Parallel Inspector, натиснути кнопку Inspect і вибрати рівень аналізу

Специфікація типу аналізу Inspector'а

Перш ніж додаток буде запущено, відповідний Pintool вселиться в його процес і, замінивши оригінальний код, запуститься в новому процесі, ініціалізувавши запуск коду програми. Разом із завантаженням додаткових бібліотек це займає якийсь час, тому користувач помітить затримку старту програми. Чим більше динамічних бібліотек в програмі, тим більше ця затримка. Далі, додаток виповнюється як зазвичай, тільки повільніше - в залежності від глибини аналізу, і, звичайно, від відносної кількості специфічних викликів функцій Memory API під час виконання.

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

Результати аналізу структуровані таким чином, щоб спочатку у нас був огляд списку проблем (Problem Sets), що представляють собою кінцевий результат дії декількох подій, що призвели до проблеми. Наприклад, доступ до недійсною пам'яті є наслідком виділення самої пам'яті, передчасного її звільнення і власне спроби доступу. Такі деталі у вигляді зареєстрованих подій (Observations) можна подивитися у вікні, натиснувши кнопку "Details" в головному вікні. Там же можна швидко поглянути на код, в якому ця подія відбулася (Рис.3). Результати аналізу структуровані таким чином, щоб спочатку у нас був огляд списку проблем (Problem Sets), що представляють собою кінцевий результат дії декількох подій, що призвели до проблеми

Список виявлених витоків пам'яті і їх розмір

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

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

Зі списку проблем або детального списку можна перейти в режим Sources (Рис.4). Зі списку проблем або детального списку можна перейти в режим Sources (Рис

режим Sources

Інструмент надасть два вікна з вихідним кодом, в яких буде відображена взаємозв'язок між початковим дією, як правило, виділенням або инициализацией пам'яті, і кінцевим дією, яке призвело до помилки, наприклад спроба читання або запису. Яке саме з цих дій є помилковим, інструмент знати не може, так як вони визначаються логікою програми. Але порушення коректності реалізації логіки буде продемонстровано, як за допомогою вихідного коду, так і в вікні Observations Relationships, де графічно вибудовується зв'язок між вихідним і кінцевим подіями. При цьому події позначаються різними кольорами, щоб легко було визначити відповідні вікна вихідного коду, опису подій і стеки викликів функцій. Стеки викликів потрібні для того, щоб було легше орієнтуватися, яким чином ми потрапили в ту чи іншу функцію, так як подія, що призвела до помилки, далеко не завжди може виявитися в самому досліджуваному додатку, а, наприклад, в сторонньої бібліотеці. Знайшовши помилку, зовсім не обов'язково перемикатися Alt-Tab'ом в вікно редактора Visual Studio і шукати потрібні файл исходника і рядок. Досить подвійного клацання мишки на рядку коду в вікні Sources, і ми потрапляємо в редактор Visual Studio в потрібне нам місце.

Однією з корисних особливостей інтерфейсу Inspector'а є придушення діагностики вже відомих помилок. За яких-небудь причин, у нас може не бути необхідності виправляти помилки певного типу, або помилки в певних модулях, або у вихідних файлах. Наприклад, існує проблема так званих false positives, тобто помилкових діагностик, коли виявляється нібито помилка роботи з пам'яттю, яка, насправді, такою не є. Такі випадки можливі при використанні деяких сторонніх бібліотек, або при реалізації власних користувальницьких аллокаторов в програмі. В такому випадку можна, використовуючи фільтри, додати помилку в список Private Suppressions, і при старті аналізу використовувати одну з опцій: "Mark problems" або "Delete Problems". При цьому помилки будуть або позначені в списку, або взагалі не відображені. Для скасування придушення досить вибрати опцію "Do not use suppressions".

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

Типи виявлених помилок

Memory Leaks. На першому рівні mi1 інструмент виявляє тільки «витоку пам'яті». Вони виникають при виділенні пам'яті в купі і незвільнена її після закінчення програми. У списку Problems ми побачимо помилки Memory Leak, для яких, крім звичайної інформації буде вказано розмір витоку. Якщо виділення втраченої пам'яті відбувається кілька разів на одній і тій же рядку, наприклад, в циклі, то діагностика видасть сумарний результат. Якщо до цього рядка здійснювався доступ різними шляхами, тобто будуть різні стеки, то діагностики будуть окремими.

Необхідно відзначити, що аналіз на рівні mi1 відбувається набагато швидше, ніж на інших рівнях. Це пов'язано з тим, що, незважаючи на інструментації, утиліта Pin використовується в режимі Probe mode. Probe mode - це метод, в якому використовується впровадження «датчиків», jump-інструкцій, тільки на початку певних функцій перед завантаженням образа виконуваного модуля. Перед впровадженням коду «датчика», Pin заміщає кілька перших інструкцій коду функції на свої, і перенаправляє управління в обробник. Довжина впровадженого коду «датчика» на архітектурі IA-32 становить 5 байт, на архітектурі Intel 64 - 7 байт. Оскільки і додаток, і обробник виконуються практично без змін (немає заміщення коду всіх інструкцій), то продуктивність виявляється набагато вище, ніж в звичайному режимі.

Pintool (mi1) доповнює функції виділення і звільнення пам'яті власними, які аналізують функціями, за допомогою Pin API. Відстеживши всі виклики виділення і звільнення пам'яті в купі, можна, зіставивши їх, зробити висновок, які з виділених об'єктів пам'яті не були звільнені до завершення програми. Варто тільки відзначити, що якщо посилання на виділену в купі пам'ять буде збережена в глобальному покажчику, то помилка «витік пам'яті» сигналізувати не буде. Це частково є обмеженням технології, і на сьогоднішній день аналіз глобальних покажчиків не підтримується. За допомогою Windows API dbghelp.dll будуть визначені модулі, імена функцій, в яких пам'ять була виділена, і номери відповідних рядків коду.

Знаходження символів і рядків коду, як і отримання коректного стека, вимагає наявності налагоджувальної інформації для виконуваного коду. Тому вкрай бажано включити її генерування під час компіляції і компонування за допомогою ключів / Zi і / DEBUG. Оптимізуючий компілятор створить додаткові труднощі на шляху визначення приналежності зібраних даних функцій і рядках, тому оптимізацію коду краще відключити, використовуючи ключ / Od. Якщо аналіз додатки відбувається в Debug-режимі, то ці опції включені в проект Visual Studio за замовчуванням. Який саме С ++ компілятор використовується в Visual Studio, - Intel або Microsoft, особливого значення не має.

Наступний рівень аналізу mi2 здатний виявити майже всі залишилися типи помилок доступу до пам'яті, виділеної в купі. Однак для прискорення аналізу глибина стека обрано рівної одиниці. Тобто на цьому рівні ми зможемо знайти відповідь на питання, чи є в принципі помилки в програмі. А де ці помилки, як ми потрапили в функції з помилками, нам допоможе визначити наступний рівень mi3. Він відрізняється від попереднього тим, що глибина стека функцій збільшена до 12. Плюс додана можливість пошуку загублених покажчиків, яку ми розглянемо пізніше.

Missing Allocation. Помилки виникають при спробі звільнити пам'ять за неіснуючою адресою. Простіше кажучи, якщо буде випадково продубльований виклик функцій free / delete або їх аргумент вказує на неіснуючу пам'ять, буде діагностована помилка. І якщо в Debug-режимі компілятор з включеною опцією run-time check знайде цю помилку ще в процесі компіляції, в Release-режимі повідомлення про помилку видано не буде.

Mismatched Allocation / Deallocation. Такі помилки виникають при спробі звільнити пам'ять за допомогою функцій, які не відповідають функції виділення пам'яті. Наприклад, десь «глибоко в надрах програми» виділяється який-небудь об'єкт, припустимо, буфер обміну або дескриптор, і кінцевий користувач повинен його звільнити. При цьому сам об'єкт може бути виділений за допомогою run-time функції malloc, але користувач використовує в своєму C ++ модулі функцію delete (Лістинг 1).

char * pStr = (char *) malloc (16); ... delete pStr; // Err: Mismatched Allocation / Deallocation free (pStr); free (pStr); // Err: Missing Allocation

У вікні Details Inspector відасть две цитати коду з віділенімі рядками: як Allocation Site - рядок коли, де пам'ять булу віділена в malloc, и Mismatched Allocation / Deallocation діагностику в місці, де віклікана delete-функція. Цім особливо добре ілюструється різніця между подіямі и проблемою.Більше. Кілька цілком коректних самі по собі подій, таких як алокація і деаллокація пам'яті, складають одну проблему зі списку, який і представлений в вікні Overview. А вікно Observations Relationships вказує тимчасову причинно-наслідковий зв'язок між подіями. Тобто в даному випадку є первинною алокація пам'яті, за якою послідувала її некоректна деаллокація.

Invalid Memory Access і Invalid Partial Memory Access. Помилки виникають при читанні / запису по недійсним адресами пам'яті в купі або в стеку і по частково недійсним адресами пам'яті. У програмах досить часто зустрічається така небезпечна функція копіювання рядків, як strcpy. У лістингу 2 представлений приклад, де зроблена спроба скопіювати рядок "my string" по вже неіснуючою адресою.

char * pStr = (char *) malloc (16); free (pStr); strcpy (pStr, "my string"); // Err: Invalid Memory Access char * pStr = (char *) malloc (16); free (pStr); char * pStr1 = (char *) malloc (16); strcpy (pStr, "my string"); // Err: Invalid Memory Access

В результаті роботи Inspector'а ми можемо отримати кілька однакових помилок Invalid Memory Access, що посилаються на одну і ту ж рядок коду. Можливо, це збентежить користувача, проте потрібно розуміти, що Inspector аналізує не вихідний код, а виконуваний. І в даному випадку, можливо, компілятор оптимізував копіювання рядка, виконавши його кількома інструкціями. Природно, що всі ці інструкції належать одній і тій же рядку вихідного коду. Це, до речі, одна з причин, чому краще використовувати Debug-режим для аналізу додатки Inspector'ом.

Якщо в попередньому випадку ми копіювали рядок в пам'ять, яка була недійсна, то зараз ми розглянемо більш складний випадок - одна з найпідступніших помилок при роботі з покажчиками і пам'яттю (Лістинг 2). Спочатку ми виділяємо буфер пам'яті за допомогою malloc і зберігаємо його адресу в покажчику pStr. Потім відразу звільняємо її і виділяємо буфер такого ж розміру, але за вказівником pStr1. Далі, копіюємо рядок по старому вказівником. У деяких випадках, коли між виділеннями пам'яті немає інших операцій з купою, і при цьому, коли програма виконується в незавантажених іншими додатками системах, велика ймовірність, що значення адреси в обох покажчиках pStr і pStr1 співпадуть, і нічого страшного в цей раз не відбудеться. Але тим і підступна дана помилка, що при перенесенні програми на реальну систему, наприклад, у замовника, додаток почне падати, що абсолютно неприпустимо. Залишилося відзначити, що такий тип помилки виявляється Inspector'ом тільки на рівні mi3 і вище, де включений додатковий механізм пошуку «загублених» покажчиків. Природно, розплатою за це стануть додаткові накладні витрати під час аналізу.

Помилка Invalid Partial Memory Access виникає, коли відбувається доступ до складеного об'єкту пам'яті, наприклад, структуру, частина якого недійсна. У лістингу 3 наведено приклад такого коду.

struct tally {int num; char count; }; struct tally * pCurrent = (struct tally *) malloc (5); // incorrect size! struct tally * pRoot = (struct tally *) malloc (sizeof (struct tally)); pCurrent-> num = 1; pCurrent-> count = 1; * PRoot = * pCurrent; // Err: Invalid Partial Memory Access char array [10]; strcpy (array, "my string"); int len ​​= strlen (array); while (array [len]! = 'Z') // Will read from below the stack pointer len--;

Тут використовується функція виділення пам'яті під структуру tally з явним зазначенням розміру і за допомогою sizeof, що більш правильно. Далі, ми инициализируем поля однієї з структур за вказівником pCurrent одиницями і копіюємо її в структуру за вказівником pRoot. Inspector діагностує помилку Invalid Partial Memory Access в рядку копіювання, при цьому в діагностиці помилковою (Partial Invalid Read) буде названа структура за вказівником pCurrent.

Давайте розберемося, в чому полягає помилка. На жаль, Inspector поки ще не може підказати нам, що за замовчуванням включена опція компілятора / Zp4, яка змушує його вирівнювати розміри структур до величини 4 байт. А значить, sizeof нашої структури з змінних типу int і char становить не 5, а 8 байт. Тобто при копіюванні ми спробували прочитати 8 байт структури за вказівником pCurrent, в якій ініціалізовані, а значить, дійсні, тільки перші 5 байт пам'яті.

У тому ж лістингу 3 наведено приклад помилки, яка може бути виявлена ​​тільки на рівні mi4. Цей рівень дозволяє знаходити помилки доступу до пам'яті, виділеної на стеку, які, з якоїсь причини, не виявляються ще на стадії компіляції за допомогою run-time chek опцій компілятора. Для даного прикладу копіювання символів рядка "my string" в циклі Inspector видасть діагностику читання недійсною пам'яті в тілі масиву. Дійсно, так як в цьому рядку немає символу 'Z', то на одинадцятий ітерації відбудеться спроба читання символу за кордоном виділеного в стеці ділянки пам'яті.

Uninitialized Memory Access і Uninitialized Partial Memory Access. Помилки виникають при спробі читання виділеної, тобто дійсної, але неініціалізованої пам'яті, в купі або в стеку. Найпростіший приклад такої помилки представлений в лістингу 4.

char * pStr = (char *) malloc (16); char c = pStr [0] // Uninitialized Memory Access struct person {unsigned char age; char firstInitial; char middleInitial; char lastInitial; }; struct person * p1, * p2; p1 = (struct person *) malloc (sizeof (struct person)); p2 = (struct person *) malloc (sizeof (struct person)); p1-> firstInitial = 'c'; p1-> lastInitial = 'o'; * P2 = * p1; // Uninitialized Partial Memory Read

Ми зробили спробу читання першого символу з неініціалізованої рядки в змінну с. Як і в разі доступу до частково недійсною пам'яті, може бути помилка доступу до частково неініціалізованої пам'яті. Приклад теж зі структурою. Якщо спробувати тільки скопіювати частково проініціалізувати структуру person, розташовану за вказівником p1, в нову структуру за вказівником p2, Inspector видасть помилку Uninitialized Partial Memory Read для рядка копіювання, при цьому в якості Allocation Site буде визначена рядок виділення пам'яті для структури за вказівником p1.

Висновок

Напевно, не має особливого сенсу пояснювати наслідки помилок, викликаних некоректним використанням пам'яті, розробникам програмного забезпечення. Необхідність використання інструменту, подібного Inspector, в процесі розробки не викликає сумнівів. Питання в тому, наскільки цей інструмент зручний для використання, і наскільки повно він покриває можливі проблеми коректності виконання програм. Розробники Intel Parallel Inspector будуть раді почути думки користувачів про продукт і обговорити ті недоліки, які ще є в ньому, на ISN форумах, як англомовному , Так и російськомовному .

Новости

Banwar.org
Наша совместная команда Banwar.org. Сайт казино "Пари Матч" теперь доступен для всех желающих, жаждущих волнения и азартных приключений.