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

Итак, в предыдущей статье  я показал несколько занятных случаев для иллюстрации того, что написанное на языке высокого уровня может при компиляции приводить к непокрытому объектному («ассемблерному») коду даже в случае 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