Вместо введения.

Итак, в предыдущей статье  я показал несколько занятных случаев для иллюстрации того, что написанное на языке высокого уровня может при компиляции приводить к непокрытому объектному («ассемблерному») коду даже в случае 100% покрытия для исходного языка.

В этой статье я опишу методику инструментирования на основе промежуточного ассемблера. Данный метод в частности используется таким инструментом как LDRA, с которым мне недавно пришлось столкнуться в рамках одного проекта. Плюсом данного метода инструментирования является то, что само ПО писать можно на любом языке, который может быть транслирован в ассемблер: хочешь на С++, хочешь на Brainfuck.

Эта статья является в некотором роде реверс-инжинирингом того, как устроено инструментирование в LDRA. А также иллюстрацией, как подобное инструментирование может быть реализовано.

Дисклеймер: я не пишу профессионально на python, данная статья и примеры к ней не учат писать на python. Также я не являюсь специалистом в области регулярных выражений. Многие вещи можно сделать проще и более оптимально, чем представлено в примере к статье. Весь код написан исключительно для иллюстрации метода инструментирования.

Что мы будем инструментировать?

Итак, мы будем инструментировать программу на уровне промежуточного ассемблера, который для нас будет создан компилятором (например с помощью опции компилятора "-S" для GCC и языка С). У этого метода есть плюсы и минусы. 

Плюсы: код, который мы инструментируем, очень близок к тому, что будет исполнять процессор (ну, в простом случае), код очень просто устроен структурно.

Минусы: код не линейно соответствует тому, что написано программистом (из-за оптимизаций и прочих перестановок), и не всегда просто отследить, какой фрагмент ассемблера соответствует какому участку кода языка более высокого уровня. Для инструментирования как такового это не очень существенно, но понять, как покрытие, собранное для ассемблерного кода, соответствует коду, который вы, написали порой не легко.

Прежде всего нам нужно выделить структурные блоки программы и разобрать код на «элементарные» линейные фрагменты, которые исполняются безусловно, и переходы между ними. При таком подходе для трассировки программы нужно отслеживать только начало исполнения линейных фрагментов и случился ли переход или нет (у этого общего правила есть исключения в виде вызовов функций с нелокальными переходами, но это отдельный случай).

Методика инструментирования.

В случае инструментирования ассемблерного кода для отслеживания прохождения контрольных точек - переходов - в тело программы добавляется дополнительный код (трассёр), фиксирующий прохождение узловой точки.

В английском варианте дополнительный код, встраиваемый в программу, чаще называется probe. В виду того, что какой-либо общей русскоязычной терминологии я не нашел, то буду использовать слово «трассёр», которое, на мой взгляд, лучше отражает суть, чем калька с английского «зонд».

Для того, чтобы понять в какое место программы нужно вставить код трассёра, нам необходимо построить граф исполнения кода, выделив на нем линейные участки и переходы. Для этого, давайте разберем из чего собственно будет состоять наша программа с точки зрения инструментирования.

Рамка считывания.

Ассемблер хорош тем, что он структурирован и прост. И это его свойство прекрасно подходит для того,чтобы его инструментировать. В отличие от языков более высокого уровня нужно фактически найти и распознать только 3 элемента в коде:


  • начало метки;

  • переход на другую метку;

  • возврат из процедуры.

Тут есть момент условности. Мы собираемся инструментировать промежуточный ассемблер, который для нас сделает компилятор. У этого компилятора есть свой диалект для обозначения меток в коде, поэтому парсер, используемый для разбиения, должен понимать диалект, на котором «говорит» компилятор.

Упрощая, метка в коде - это «название» некоторого адреса с которого начинается исполняемый фрагмент кода. Частным случаем метки является начало функции. Метки в ряде случаев могут быть заданы программистом непосредственно (например, использование оператора «goto» в C). Или могут быть добавлены компилятором для обозначения точек для переходов.

Пример меток в коде:

foo: <--- ЭТО МЕТКА
…
jne .L12
…
.L12: <--- И ЭТО МЕТКА
…

Переход - это любой «скачок» указателя инструкций на произвольную метку. Переход может быть условным и безусловным, переход может быть сделан вперед и назад по коду. Поведение инструкции перехода может контролироваться некоторым словом состояний (например, регистром флагов) на основании которого инструкция может либо осуществить переход (в дальнейшем я буду называть это «джамп») или быть пройдена без перехода (далее - «скип»).

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

И еще, для удобства чтения графов в дальнейшем можно построить список всех символов в секции исполняемого кода, которые компилятор решит создать для нашей программы. Это позволит отличать метки-функции от произвольных меток, добавленых компилятором. Этот список может не совпадать с набором описанных функций (даже для «чистого» С), так как часть из них может быть выкинута в ходе оптимизации или подвергнута каким-либо трансформациям.

Скипы и джампы.

Итак, мы определились из каких блоков будет состоять тело нашей программы. Для построения пройденной трассы нам надо отметить, вставив код трассёра, следующие точки:

  • начало каждого блока - сразу после объявления метки;

  • конец каждого блока - вставляется либо перед инструкцией перехода, либо перед началом новой метки, либо перед возвратом из процедуры;

  • конец перехода - это специальный трассёр, который вставляется после каждого перехода для того, чтобы отследить произошел этот переход или нет.

Трассёры в начале и конце блоков вызываются автоматически при исполнении блока. Это происходит потому что блок - это линейный код, не содержащий условных конструкций. Как я говорил выше, у этого правила есть исключение, которое возникает в том случае, когда внутри блока вызывается какая-либо функция, которая не возвращается. Функция может не вернуться, например, в случае завершения программы, либо в случае, когда в ней выполняется нелокальный переход. В этом случае будет вызван только «открывающий» трассёр.

Так же особым случаем является трассёр, который вставлен после условного перехода. Он может быть вызван или не вызван в зависимости от выполнения инструкции перехода. Если он вызывается, то значит предыдущая инструкция условного перехода «не сработала» и произошел скип, а если он не вызывается, то произошел джамп. 
Давайте рассмотрим пример для псевдокода ниже:


foo:
  call __instrument0
  …
  call __instrument1
  jne .L10 // Jump to L10
  call __instrument2
  …
  call __instrument3
  .L10:
  call __instrument4

Если переход на метку .L10 произойдет то будут вызваны следующие маркеры:
__instrument0, __instrument1, __instrument4

В случае если переход не произойдет, то будут зафиксированы такие метки:

__instrument0, __instrument1, __instrument2, __instrument3 и __instrument4.

Соответственно, для отслеживания выполнения условного перехода при фиксации трассы нужно различать ситуацию, когда __instrument2 будет вызван сразу после __instrument1. То есть во время исполнения программы встроенный код нашего трассировщика должен отслеживать предыдущую метку (и мы не будем решать проблем с многопоточностью, т.к. это выходит за рамки этой статьи).

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

Граф исполнения.

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

Разобьем код на фрагменты, которые будем хранить в массиве (make_instrument.py::fragments). Каждый элемент этого массива будет представлять из себя блок кода (ins_blocks.py::CodeBlock). Из важных сейчас полей класса CodeBlock можно выделить jtransition, stransition и ftransition. Это собственно поля,содержащие информацию о том, куда случился переход из данного блока, а также о его типе, соответственно jump, skip или fall-thru в случае, когда в конце метки нет перехода, а есть просто следующая метка.

Собственно, make_instrument.py первым делом проходит по всем строкам ассемблерного листинга, созданного gcc для каждого файла исходного кода, и выделяет блоки, исполняемое безусловно. Затем идет построение графа переходов (transitions), в результате которого строится граф переходов от блока к блоку с определением их типов. В завершении выводится общее число переходов. Эта информация необходима для того, чтобы в дальнейшем определить размер массива, используемого в модуле инструментирования для хранения информации о переходах, собранной во время исполнения программы (финальное число будет передано при сборке в виде макроса TRANSITIONS, смотри toolbox/Makefile.koi::koi_toolbox).

Как построить трассу?

Мы построили граф переходов и определили их общее число, давайте теперь рассмотрим, а как собственно можно собрать данные о исполнении программы.
 Чтобы что-то собрать нужно что-то сохранить. В нашем случае информацию о переходах мы будем хранить в памяти нашей программы, которая должна быть слинкована с неким дополнительным объектным файлом, реализующим сбор данных о трассе и содержащим определение массива переходов (это файл лежит в папке toolbox). Для простоты объявим массив переходов приблизительно так:

typedef struct __attribute__((packed)) {
        void * from;
        void * to;
        unsigned int flags;
        size_t t;
} transition_t;

static transition_t  __transitions[TRANSITIONS];

В продакшене так делать не надо.  В большой программе массив разрастется до поистине неприличных размеров. Нужно пронумеровать все переходы и хранить только битовую маску состояний. Но для наглядности и простоты мы оставим так.

То есть наш массив хранит информацию о всех переходах в программе. Откуда был осуществлен переход, куда он был сделан, некоторая служебная информация для отслеживания прохождения условных переходов и счетчик прохождений метки (для красоты).

Для того чтобы заполнить этот массив в каждой точке инструментирования вызывается функция-трассёр - __koi_covdump(). Объявлена она следующим образом:

void __attribute__((used, noinline))
  __koi_covdump(unsigned int from, unsigned int to, unsigned int id);

При каждом вызове ей будут передана информация, откуда ее вызвали, куда она должна перейти и ID перехода - собственно его номер.

На самом деле это избыточно, достаточно знать только номер перехода, все остальное можно узнать в процессе исполнения. Но опять же для наглядности оставим так.

Как уже говорилось выше для того чтобы собрать информацию о покрытии нам нужно вставить определенный код в созданный компилятором ассемблер. Это делается в методе CodeBlock.instrumentBlock в который передается номер трассёра. Дальше этот номер трассёра используется чтобы с помощью platform.asm.instrument() получить собственно фрагмент кода, который будет вставлен в начале и конце блока.

Код трассёра платформо-специфичен и в нашей реализации для x86_64 выглядит вот так (не обращайте внимания на конкретные значения):

        pushq   %rbp ## INS
        pushq   %rdi ## INS
        pushq   %rsi ## INS
        pushq   %rax ## INS
        pushq   %rdx ## INS
        pushq   %rcx ## INS
        pushfq   ## INS
        movl    $5, %edi ## Номер строчки откуда
        movl    $0, %esi ## Номер строчки куда (0 для метки в начале блока)
        movl    $53, %edx ## Номер трассёра
        callq   ___koi_covdump ## INS
        popfq    ## INS
        popq    %rcx ## INS
        popq    %rdx ## INS
        popq    %rax ## INS
        popq    %rsi ## INS
        popq    %rdi ## INS
        popq    %rbp ## INS

Что происходит в этом блоке? Чтобы сохранить информацию о прохождении точки трассы, нам нужно вызвать функцию __koi_covdump(), в которую мы передаем 3 аргумента. Для того чтобы встроить этот код в уже созданный для нас ассемблер нам нужно согласно ABI сохранить все регистры, которые могут быть изменены при вызове этой функции на стек, вызвать функцию, передав требуемые аргументы и вернуть ранее сохранённые на стеке значения на место. Это выглядит немного по-разному для разных платформ, но суть та же.

Сама __koi_covdump() (toolbox.c) устроена очень просто. Она заполняет массив __transitions[] используя номер трассёра (id, третий аргумент) в качестве индекса и отслеживает тип перехода (jump или skip). Для того чтобы понять какой тип перехода произошел проверяется адрес источника перехода, если он совпадает для текущего и предыдущего трассёра (по индексу в массиве переходов), то текущий трассёр находится за инструкцией условного перехода и следовательно условный переход не произошел.

Файл toolbox.c также содержит функцию __koi_covdump_render(), которая печатает покрытие - содержимое массива __transitions[]. Эту функцию мы зарегистрируем в нашей тестовой программе с помощью atexit(), чтобы получить данные о собранной трассе при выходе из программы.

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

Веселые картинки:

Реализация демо—кода доступна тут - https://github.com/bougumot/koi.git

Для запуска примера потребуется третий питон и gcc или clang. Для того чтобы просматривать *.dot файлы потребуется graphviz (ну либо любая online dot-смотрелка, например https://dreampuf.github.io/GraphvizOnline/)

Как играть:

Чтобы построить инструментированную демо-программу, переходим в папку проекта (там, где лежит make_instrument.py) и делаем:

export KOI_ROOT=`pwd`

 Далее переходим в examples и делаем
:

make all_oc

 После того как все соберется запустите tst_app:


./tst_app
Usage: <option> <directory name>
1::5:0;[1]
2::49:559;[1]
49::559:0;[1]
53::5:0;[1]

Собственно программа напечатала свой вывод и за ним трассу.
 Соберем трассу в coverage.log и сохраним его там же в examples.

Теперь запустим

make graph

Для каждого файла программы создастся *.cov файл. Для ls.c он будет выглядеть вот так (часть обрезана):


        FUNCTION COVERAGE AND TRANSITIONS
===============================================================================
_main :58/556(10.43)
-------------------------------------------------------------------------------
Type    From    To      Status
S       49      50      ---
J       49      559     COVERED
S       64      65      ---
J       64      574     ---
S       99      100     ---
J       99      494     ---
…

То есть main() покрылся на 10% (58 строк из 556) и покрыт только 1 переход. Графически это выглядит вот так:

Покрытие кода программы, запуск без параметров
Покрытие кода программы, запуск без параметров

Обратите внимание на LBB0_17 в самом низу, он окрашен в коричневый цвет, для этого блока пройден только открывающий трассёр, а завершающий - нет. Данный блок вызывает _exit(), который не возвращается.

Теперь давайте запустим приложение с опциями:

user$ ./tst_app -u ./

Вывод программы, вызванной с аргументами

rwxr-xr-x vlad   staff  640 Oct   17:13:46 . 
rwxr-xr-x vlad   staff  384 Oct   15:15:45 ..
rw-r--r-- vlad   staff  51 Oct   17:13:40     coverage.log
rw-r--r-- vlad   staff  2838 Oct   15:16:25 ls.c
rw-r--r-- vlad   staff  2008 Oct   17:13:37 foo.ins.o
rw-r--r-- vlad   staff  10361 Oct   17:13:37           foo.ins.S
rw-r--r-- vlad   staff  354 Sep   12:12:37   Makefile
rw-r--r-- vlad   staff  250 Oct   17:13:40   foo.cov
rw-r--r-- vlad   staff  3 Oct   17:13:37       koi.transitions
rw-r--r-- vlad   staff  73 Jun   30:22:43     foo.c
rw-r--r-- vlad   staff  73019 Oct   17:13:45           ls.dot
rw-r--r-- vlad   staff  2172 Oct   17:13:40 foo.dot
rw-r--r-- vlad   staff  5 Oct   17:13:37       foo.ins.lst
rw-r--r-- vlad   staff  14316 Oct   17:13:37           ls.ins.o
rw-r--r-- vlad   staff  154119 Oct   17:13:37        ls.ins.S
rwxr-xr-x vlad   staff  51632 Oct   17:14:09         tst_app
rw-r--r-- vlad   staff  680 Oct   17:13:40   ls.cov
rw-r--r-- vlad   staff  6 Oct   17:13:37       ls.ins.lst
rw-r--r-- vlad   staff  135883 Oct   17:13:37        ls.S
rw-r--r-- vlad   staff  9667 Oct   17:13:37 foo.S
1::5:0;[1]
3::49:50;[1]
4::50:0;[1]
6::64:65;[1]
7::65:0;[1]
9::99:100;[1]
10::115:116;[1]
11::116:0;[20]
13::150:151;[20]
14::151:0;[20]
16::166:167;[20]
17::167:0;[20]
18::255:330;[20]
22::338:339;[20]
23::339:0;[20]
24::371:420;[20]
32::463:464;[20]
33::464:0;[20]
34::479:116;[20]
35::479:480;[1]
36::480:494;[1]
39::494:0;[1]
41::505:506;[1]
42::506:0;[1]
43::519:-1;[1]

Добавим новую трассу к ранее собранному coverage.log.


Удалим *.dot файлы и перестроим покрытие.


make graph

Отчет о покрытии для программы, вызванной с аргументами
        FUNCTION COVERAGE AND TRANSITIONS
===============================================================================
_main :380/556(68.35)
-------------------------------------------------------------------------------
Type    From    To      Status
S       49      50      COVERED
J       49      559     COVERED
S       64      65      COVERED
J       64      574     ---
S       99      100     COVERED
J       99      494     ---
S       115     116     COVERED
S       150     151     COVERED
J       150     527     ---
S       166     167     COVERED
J       166     482     ---
S       255     256     ---
J       255     330     COVERED
J       327     339     ---
S       338     339     COVERED
S       371     372     ---
J       371     420     COVERED
S       399     400     ---
J       399     420     ---
S       418     419     ---
J       418     541     ---
S       463     464     COVERED
S       479     480     COVERED
J       479     116     COVERED
J       480     494     COVERED
J       492     464     ---
S       505     506     COVERED
J       505     521     ---
S       526     527     ---
S       540     541     ---
S       558     559     ---
S       573     574     ---

Картина поменялась и покрытых переходов (и строк) стало больше:

Покрытие кода программы, запуск с параметром -u
Покрытие кода программы, запуск с параметром -u

Соответственно, добавляя все новые тесты и накапливая покрытие, можно либо покрыть всю программу, либо определить участки "мертвого" кода, которые не будут выполнены ни при каких условиях.

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


P.S.: Весь код, на который ссылается статья (включая ls.c в examples, который я утянул у кого-то с гитхаба), доступен под открытой лицензией, так что развлекайтесь как хотите.
Пример запускался и проверялся на x86 MacOS, clang-1200.0.32.21, Python 3.9.13 и RH Linux, gcc (GCC) 7.1.0, Python 3.6.8

Комментарии (0)