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

Соответственно, добавляя все новые тесты и накапливая покрытие, можно либо покрыть всю программу, либо определить участки "мертвого" кода, которые не будут выполнены ни при каких условиях.
Вот и все, надеюсь мне удалось понятно рассказать, как можно построить простую систему для инструментирования кода на уровне ассемблера и на ее примере проиллюстрировать вариант того, как вообще подобные системы могут работать.
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
