Точка наблюдения
Функция точек наблюдения — следить, когда меняется значение выражения. Если вы никогда не пользовались точками наблюдения, попробуйте их в GDB.
Расширение функциональности вычисления выражений
Вычисление арифметических выражений вы уже реализовали, но эти выражения составлены из констант, и их значения не меняются. Такие выражения в точке наблюдения бессмысленны, поэтому чтобы задействовать функцию точек наблюдения, сначала нужно расширить вычисление выражений.
BNF'ом покажем, какие возможности нужно расширить:
<expr> ::= <decimal-number>
| <hexadecimal-number> # начинается с "0x"
| <reg_name> # начинается с "$"
| "(" <expr> ")"
| <expr> "+" <expr>
| <expr> "-" <expr>
| <expr> "*" <expr>
| <expr> "/" <expr>
| <expr> "==" <expr>
| <expr> "!=" <expr>
| <expr> "&&" <expr>
| "*" <expr> # разыменование указателя
Их функции совпадают с соответствующими операторами C, включая приоритет и ассоциативность; если есть сомнения, сверьтесь с документацией.
Что касается получения значения регистра, это очевидно функция, связанная с ISA. Каркасный код подготовил следующий API.
// nemu/src/isa/$ISA/reg.c
word_t isa_reg_str2val(const char *s, bool *success);
Она возвращает значение регистра с именем s и выставляет success, указывая, успешно ли.
Ещё важно, как распознаётся оператор разыменования указателя: при лексическом анализе умножение и разыменование указателя отличить по самому оператору нельзя, потому что оба — *. Их нужно различить до рекурсивного вычисления, иначе если разыменование указателя принять за умножение, процесс вычисления сочтёт выражение недопустимым. Отличить их не так трудно: если дать вам выражение, вы тоже их различите. На самом деле достаточно посмотреть тип token'а перед *, и можно решить, эта * — умножение или разыменование указателя; не верите — попробуйте? Вот каркас функции expr().
if (!make_token(e)) {
*success = false;
return 0;
}
/* TODO: Implement code to evaluate the expression. */
for (i = 0; i < nr_token; i ++) {
if (tokens[i].type == '*' && (i == 0 || tokens[i - 1].type == certain type) ) {
tokens[i].type = DEREF;
}
}
return eval(?, ?);
Что такое certain type — подумайте сами! На самом деле каркас выше может обрабатывать и отрицательные числа: если вы раньше реализовали отрицательные числа, распознать * для вас уже не должно быть трудно.
Кроме того, по сравнению с выражениями в GDB мы упростили: в простом отладчике у выражений нет различия типов, поэтому нужно дополнительно пояснить два пункта:
- Все результаты имеют тип
uint32_t. - У указателей тоже нет типа: при разыменовании указателя мы всегда читаем из памяти гостевого компьютера целое типа
uint32_t.
Расширить функциональность вычисления выражений
Нужно реализовать возможности, перечисленные в BNF выше. В BNF выше перечислены не все операторы C, например различные побитовые операции, <= и т.д. == и && с большой вероятностью понадобятся при точках наблюдения, поэтому их реализовать требуется. Если в дальнейшем из-за отсутствия какого-то оператора пользоваться будет неудобно, тогда и подумайте его реализовать.
Вычисление выражений в riscv64
Поскольку riscv64 — 64-битная ISA, результат вычисления выражения нужно интерпретировать как тип uint64_t.
Ограничения тестирования
Раньше мы реализовали генератор выражений, но после того как в вычисление выражений добавили использование регистров и разыменование указателей, генератор выражений уже не закрывает все наши нужды. Потому что в программах на C семантики регистров нет, а семантика разыменования указателей сильно отличается от NEMU.
Здесь хотим сказать: у тестирования тоже есть ограничения, нет техники, которая раз и навсегда решит все проблемы. Для передовых исследований это ещё вернее: они часто решают лишь малую часть задачи. Тем не менее этот генератор выражений всё же дал вам много уверенности: думать, как удобно тестировать, даже если тестировать лишь часть.
Реализовать точки наблюдения
Простой отладчик позволяет пользователю одновременно ставить несколько точек наблюдения и удалять точки наблюдения, поэтому информацию о точках наблюдения лучше организовать связным списком. Структура точки наблюдения в каркасном коде уже определена (в nemu/src/monitor/sdb/watchpoint.c).
typedef struct watchpoint {
int NO;
struct watchpoint *next;
/* TODO: Add more members if necessary */
} WP;
Но в структуре определены только два члена: NO — номер точки наблюдения, а next и так понятен. Чтобы реализовать точки наблюдения, нужно по своему пониманию того, как работают точки наблюдения, добавить в структуру необходимые члены. Одновременно для управления структурами точек наблюдения мы используем структуру данных «пул», часть связанного кода в каркасе уже дана.
static WP wp_pool[NR_WP] = {};
static WP *head = NULL, *free_ = NULL;
В коде определён пул структур точек наблюдения wp_pool и два связных списка head и free_, где head организует используемые структуры точек наблюдения, free_ — свободные, а функция init_wp_pool() инициализирует оба списка.
Реализовать управление пулом точек наблюдения
Чтобы пользоваться пулом точек наблюдения, нужно написать следующие две функции (параметры и возвращаемые значения можете изменить по своим нуждам).
WP* new_wp();
void free_wp(WP *wp);
new_wp() возвращает свободную структуру точки наблюдения из списка free_, free_wp() возвращает wp в список free_; эти две функции как интерфейс пула точек наблюдения будут вызывать другие функции. Нужно учесть: при вызове new_wp() свободной структуры точки наблюдения может не оказаться; для простоты тогда можно сразу завершить программу через assert(0). В каркасном коде определены 32 структуры точек наблюдения, в обычной ситуации этого должно хватить; если нужно больше, можете изменить значение макроса NR_WP.
В обеих функциях нужно выполнять вставку и удаление в связном списке; для тех, кто со списками не знаком, это может стать упражнением по спискам.
Повторяя старое, узнаёшь новое
В каркасном коде при определении переменных вроде wp_pool использовано ключевое слово static. Что static значит здесь? Почему его здесь используют?
Когда управление пулом точек наблюдения реализовано, можно думать, как реализовать связанные с точками наблюдения возможности. Конкретно нужно реализовать следующее.
- Когда пользователь даёт выражение для наблюдения, нужно через
new_wp()запросить свободную структуру точки наблюдения и записать выражение. Затем в конце функцииtrace_and_difftest()(определена вnemu/src/cpu/cpu-exec.c) просканировать все точки наблюдения; функциюtrace_and_difftest()вызывают каждый раз, когда циклcpu_exec()закончил выполнять одну инструкцию. При сканировании точек наблюдения нужно вычислить соответствующие выражения точек наблюдения (вычисление выражений вы уже реализовали) и сравнить, изменились ли их значения; если изменились, программа из-за срабатывания точки наблюдения приостанавливается. Чтобы получить эффект паузы, переменнуюnemu_state.stateнужно установить вNEMU_STOP. Наконец выведите сообщение, что сработала точка наблюдения, и вернитесь в циклsdb_mainloop()ждать команды пользователя. - Командой
info wпечатать информацию об используемых точках наблюдения; что именно печатать, можете ориентироваться на результатinfo watchpointsв GDB. - Командой
dудалять точки наблюдения; достаточно освободить соответствующую структуру точки наблюдения.
Реализовать точки наблюдения
Нужно реализовать связанные с точками наблюдения возможности, описанные выше; когда вычисление выражений уже есть, центр реализации точек наблюдения приходится на операции со связным списком.
Поскольку точки наблюдения нужно проверять в каждой итерации цикла cpu_exec(), это заметно бьёт по производительности NEMU. Проверку точек наблюдения можно поместить в trace_and_difftest() и обернуть код проверки новым макросом CONFIG_WATCHPOINT; затем в nemu/Kconfig добавить для точек наблюдения переключатель и через menuconfig включить эту опцию, тем самым активировав точки наблюдения. Когда точки наблюдения не нужны, этот переключатель в menuconfig можно выключить, чтобы повысить производительность NEMU.
Возможно и то, что в один момент сработают больше двух точек наблюдения; как обрабатывать эти особые случаи, решайте свободно, жёстких правил у нас нет.
Инструменты и принципы отладки
Реализуя точки наблюдения, вы с большой вероятностью столкнётесь с ошибкой сегментации. Если от этого почувствуете себя беспомощным, внимательно прочитайте этот подраздел.
Кратко разберём, почему возникает ошибка сегментации. Во-первых, машина всегда права. Если в программе что-то пошло не так, сначала подозревайте баг в своём коде. Например, по невнимательности вы написали что-то вроде if (p = NULL). Но когда выполняется эта строка, происходит лишь то, что p присваивается NULL, и программа идёт дальше. Однако когда позже p разыменуют, сработает ошибка сегментации, и программа полностью рухнет.
Из примера выше можно абстрагировать некоторые понятия из программной инженерии:
- Fault: неверно реализованный код, например
if (p = NULL) - Error: состояние выполнения программы, которое не соответствует ожидаемому, например
pошибочно присвоеноNULL - Failure: ошибка, которую можно непосредственно наблюдать, например программа вызвала ошибку сегментации
Отладка на самом деле — процесс шаг за шагом от наблюдаемого failure назад к поиску fault; когда fault найден, быстро становится ясно, как исправить неверный код. Но из примера выше видно: отладка непроста именно потому что:
- fault не обязательно сразу вызывает error
- возникший error не обязательно сразу превращается в наблюдаемый failure
- error будет нарастать как снежный ком, и когда мы наблюдаем failure, до fault на самом деле уже очень далеко
Когда эти причины понятны, можно выстроить соответствующую стратегию:
- как можно больше fault превращать в error. Это как раз то, что делают тесты, поэтому в предыдущем разделе мы добавили генератор выражений, чтобы помочь тестировать, а в дальнейшем эксперименте тоже будет богатый набор тестовых случаев. Но не любые тесты превратят все fault в error: это зависит от покрытия тестов. Спроектировать полный набор тестов с полным покрытием — не просто, и чем сложнее система, тем труднее такие тесты спроектировать. Однако как повысить покрытие тестов — вопрос, которым академия занимается давно.
Как бы вы тестировали свою реализацию точек наблюдения?
Тестов, связанных с точками наблюдения, мы не даём; подумайте, как бы вы тестировали?
Разумеется, для эксперимента «тестировать по ходу использования» — тоже приемлемый метод, смотря насколько вы уверены в своём коде.
- Как можно раньше заметить error. Момент, когда вы замечаете error, напрямую определяет сложность отладки: если ждать, пока сработает failure, отлаживать труднее; но если увидеть error сразу, как только он возник, сложность отладки сильно падает. На самом деле полезные инструменты вы уже видели:
-Wall,-Werror: в момент компиляции превращают потенциальный fault сразу в failure. Польза таких инструментов ограничена: они ищут лишь fault, который и на этапе компиляции кажется подозрительным, напримерif (p = NULL). Однако с усилением версий компилятора компилятор может находить в коде и некоторое неопределённое поведение. Это бесплатный обед: не воспользоваться, это то же самое как добровольно потратить свое время в будущем.assert(): в момент выполнения превращает error сразу в failure.assert()— простой и при этом очень мощный инструмент: достаточно в коде задать свойства, которым программа должна удовлетворять, и во время выполнения обязательно перехватятся error, эти свойства нарушающие. Например, в реализации связного списка достаточно вставить несколько простыхassert()(например, при разыменовании указатель не NULL) — и почти можно попрощаться с ошибкой сегментации. Однако писать этиassert()на самом деле нужно, уже имея некоторое понимание поведения программы; и когда свойства программы трудно выразить, пользаassert()тоже ограниченнее.printf(): наблюдать потенциальный error через вывод. Это самый употребительный инструмент, когда откатываются к fault: смотреть, не вошло ли значение переменной в программе в неверное состояние. В NEMU мы даём макросLog()для вывода большей отладочной информации; на самом деле он оборачивает функциональностьprintf(). Но поскольку по выводуprintf()правильность нужно судить вручную, по удобству он заметно уступает автоматическому суждениюassert().- GDB: в любой момент наблюдать любое состояние программы. Отладчик — самый мощный инструмент, но пользоваться им и цена наибольшая: в море поведения программы нужно наблюдать подозрительные состояния.
Могучий GDB
Если вы столкнулись с ошибкой сегментации, вам, скорее всего, захочется знать, какая именно строка кода её вызвала. Попробуйте написать программу, которая вызывает ошибку сегментации, и запустить её в GDB. Какую полезную информацию GDB может вам дать?
sanitizer — низкоуровневый assert
Ошибка сегментации обычно вызвана недопустимым обращением к памяти; простая мысль: если перед каждым обращением проверять assert(), не выходит ли адрес за границы, error можно поймать до ошибки сегментации!
Хотя нужно смотреть в основном на обращения к указателям и массивам, такого кода в проекте много, и вручную добавлять assert() перед каждым обращением слишком хлопотно. На самом деле лучше всего это делать компилятору: он знает, где все обращения к указателям и массивам. А компилятору эту возможность даёт инструмент Address Sanitizer: он автоматически вставляет код проверки выхода за границы перед обращениями к указателям и массивам. GCC даёт опцию компиляции -fsanitize=address, чтобы его включить. В menuconfig соответствующая опция уже подготовлена, нужно лишь включить:
Build Options
[*] Enable address sanitizer
Затем очистите результат компиляции и перекомпилируйте.
Можете нарочно вызвать ошибку сегментации и почитать сообщение Address Sanitizer. Однако можете обнаружить, что производительность программы упала: проверка каждого обращения даёт дополнительный расход. Но как инструмент, который помогает диагностировать баги, эта цена всё же стоит того, и когда отладка не нужна, его всё равно можно выключить.
На самом деле помимо выхода адреса за границы Address Sanitizer умеет проверять и ошибки use-after-free (то есть «продолжать использовать память после освобождения пространства, взятого из кучи»); вы знаете, как он это реализует?
Ещё sanitizer'ы
На самом деле GCC поддерживает и больше sanitizer'ов, они могут проверять разные виды ошибок; опции, связанные с -fsanitize, можно посмотреть в man gcc. Если программа при включённых разных sanitizer'ах всё ещё работает правильно, это значит, что у программы хорошее качество.
По разбору выше можно подытожить несколько советов по отладке:
- Всегда используйте
-Wallи-Werror - Как можно больше вставляйте в код
assert() - При отладке сначала включайте sanitizer
- Когда
assert()не ловит error, черезprintf()выводите подозрительные переменные, надеясь error заметить - Когда
printf()error заметить неудобно, через GDB понимайте точное поведение программы
Если на курсе программирования вы слышали эти советы, runtime-ошибок у вас почти не будет.
Точка останова
Функция точки останова — приостановить программу, чтобы удобно смотреть состояние программы в некоторый момент. На самом деле функцию точки останова легко сымитировать точками наблюдения:
w $pc == ADDR
где ADDR — адрес, по которому ставится точка останова. Так программа, дойдя до позиции ADDR, приостановится.
Как повысить эффективность точек останова (предлагается подумать на втором проходе)
Если пользоваться точками останова, запуская чуть более крупные программы (например, microbench), вы обнаружите, что после постановки точки останова эффективность выполнения программы в NEMU заметно падает. Подумайте, почему так? Есть ли способ решить эту проблему?
То, как отладчик ставит точки останова, сильно отличается от способа выше — сымитировать точку останова через точку наблюдения. На самом деле принцип работы точки останова — из тридцати шести стратагем: «подменить дракона фениксом»! Если хотите снять эту таинственную завесу, можете прочитать эту статью. Когда поймёте принцип работы точек останова, попробуйте подумать над двумя вопросами ниже.
Ни капли длиннее?
У инструкции x86 int3 нет операндов, опкод — 1 байт, поэтому длина инструкции — 1 байт. Это обязательно? Предположим, есть вариант архитектуры x86 — my-x86, где кроме того, что длина инструкции int3 стала 2 байта, остальные инструкции такие же, как в x86. В my-x86 механизм точек останова из статьи выше ещё будет нормально работать? Почему?
Точки останова где угодно
Если поставить точку останова не на первый байт инструкции (на середину или конец), что произойдёт? Можете попробовать в GDB, затем подумать и объяснить причину.
Прошлая и нынешняя жизнь NEMU
Вы уже что-то знаете о том, как работает NEMU. На самом деле до рождения NEMU какое-то время NEMU вовсе не назывался NEMU, а назывался NDB (NJU Debugger), и лишь потом по некоторой причине был переименован в NEMU. Если хотите узнать этот доисторический секрет, сначала нужно понять такой вопрос: в чём разница между эмулятором (Emulator) и отладчиком (Debugger)? Конкретнее, по сравнению с NEMU, как именно GDB отлаживает программу?
