
Когда отлаживаешь программу, речь идет про использование отладчика в студии или другой IDE, то почти всегда имеешь дело с точками останова (breakpoint, бряками) — механизмом, когда выполнение программы приостанавливается, чтобы можно было заглянуть внутрь и понять, что происходит. Точек останова есть всего два основных типа, программные и аппаратные, а остальные все сделаны на их основе. Эти два базовых типа могут вести себя похоже, но устроены по-разному.
Программные точки останова — это то, с чем сталкивается каждый разработчик, когда вы ставите красную точку в среде разработки (в основном я использую большую студию) или используете команду bp под WinDbg. В этом случае отладчик просто подменяет один байт машинного кода в нужной инструкции на команду int 3. Это специальная инструкция для вызова прерывания отладки (Debug Interrupt), имеет машинный код 0xCC и говорит процессору: “Остановись, я хочу передать управление отладчику”, соответственно когда выполнение доходит до этой инструкции, срабатывает прерывание, и управление передаётся в отладчик. Отладчик "просыпается" и видит, что программа остановилась из-за исключения EXCEPTION_BREAKPOINT , возникшего по конкретному адресу, проверяет свой внутренний список точек останова и находит ту, которая была установлена по этому адресу.
Дальше происходит небольшая магия, чтобы программа могла продолжить работу после остановки, отладчик восстанавливает оригинальный байт, который был заменён на 0xCC, выполняет исходную инструкцию, и потом снова подставляет int 3 на её место. В результате вы можете снова дойти до этой точки останова позже, и она снова сработает. Всё это происходит незаметно для вас — вы просто нажимаете «Продолжить», а отладчик делает всю грязную работу.
Тут возможно два варианта, мы выполняем одну инструкцию: тогда отладчик выполняет её (исходную инструкцию, которая теперь восстановлена), а затем снова записывает 0xCC по адресу точки останова, чтобы она сработала в следующий раз. Или второй вариант, мы продолжаем выполнение программы: тут отладчик также переустанавливает точку останова, но при этом использует специальный механизм процессора (флаг Trap Flag, TF), чтобы выполнить всего одну инструкцию, после чего сразу вернуть 0xCC на место. Это гарантирует, что точка останова останется активной.
Если вы когда-нибудь пробовали посмотреть память или дизассемблировать код в месте, где стоит точка останова, то видели, что команды int 3 там нет. Это происходит из-за того, что отладчику надо показать оригинальные байты, хотя физически в памяти сейчас уже подставлен 0xCC, сохраняя тем самым иллюзию, что код текущего приложения не изменялся.
Преимущества такого способа в том, что его реализация проста и не сильно отличается в любых средах разработки. Программные точки можно ставить где угодно, их может быть сколько угодно, и они не требуют поддержки со стороны процессора. Но есть важное ограничение - это будет работать только для кода, и таким образом не получится отследить, когда изменяется конкретная переменная или область памяти.
ПАМЯТЬ ДО: ПАМЯТЬ ПОСЛЕ:
┌─────────────────────┐ ┌─────────────────────┐
│ Адрес: 0x00401000 │ │ Адрес: 0x00401000 │
│ │ │ │
│ [48 89 E5] │ ───────> │ [CC 89 E5] │
│ mov rbp, rsp │ Замена │ int 3 (0xCC) │
│ │ первого │ │
│ [48 83 EC 20] │ байта │ [48 83 EC 20] │
│ sub rsp, 0x20 │ │ sub rsp, 0x20 │
└─────────────────────┘ └─────────────────────┘
ОТЛАДЧИК ХРАНИТ ЗНАЧЕНИЯ:
┌──────────────────────────┐
│ BP[0x00401000] = 0x48 | <-- Оригинальный байт
└──────────────────────────┘
Тут в игру вступают аппаратные точки останова и они устроены принципиально иначе, и завязано это на возможностях процессора. Процессор умеет следить за определёнными адресами памяти - для этого в архитектуре x86 и x64 есть особые регистры отладки — DR0, DR1, DR2, DR3, а также управляющие DR6 и DR7. Каждый из этих регистров может содержать некий адрес, и процессор будет отслеживать обращения к нему. Как только кто-то читает, записывает или исполняет инструкцию по этому адресу — генерируется особое исключение, и отладчик получает управление. Вместо того чтобы подменять код на лету, мы перекладываем эту работу на процессор.
Таким образом, аппаратные бряки не изменяют код программы и позволяют реагировать не только на исполнение, но и на чтение или запись данных, что бывает полезно, когда надо отловить баги с порчей памяти или сложным поведением для конкретной переменной. В простых случаях можно отследить момент изменения данных, и остановить программу, как только кто-то попытается записать что-нибудь по этому адресу.
Неудобства здесь в том, что таких аппаратных точек всего четыре, по числу специальных регистров процессора, и каждая точка умеет отслеживать строго определённый диапазон памяти, выровненный по размеру (1, 2, 4 или 8 байт). Поэтому аппаратные точки не годятся для повседневной работы, но позволяют создавать удобные мини инструменты в помощь разработчику.
Если вернуться к практике, можно представить, что вы пишете некий компонент, и у вас внезапно оказывается изменена переменная у этого компонента. Программные точки тут оказывываются бесполезны, потому что нет понимания где именно происходит запись. Тут - либо обкладываться логами и "курить сорцы", либо использовать аппаратные бряки, с которыми отладка нужного места решается это в один шаг - ставим на адрес переменной бряку, выбираем тип события “на запись”, и как только кто-то попытается записать новое значение — отладчик выкинет нас в нужное место.
Большинство отладчиков умеют работать с обоими типами точек, комбинируя их в зависимости от ситуации. Программные используются для обычного кода — чтобы остановиться в функции, сделать пошаговое выполнение, перейти к вызову. Аппаратные применяются для сложных случаев: когда нужно отследить изменение памяти, поймать гонку между потоками, вычислить момент порчи данных.
Аппаратные точки останова достаточно мощный, но простой в применении инструмент, реализацию, которого я рассмотрю чуть ниже. Оба механизма не заменяют друг друга, но позволяют видеть поток исполнения программы более полно и точно понимать, что происходит в каждом случае.
┌─────────────────────────────────────────────────────────────┐
│ SOFTWARE BREAKPOINT │
├─────────────────────────────────────────────────────────────┤
│ Механизм: Замена байта на 0xCC (int 3) │
│ Лимит: Неограничен │
│ Тип: Только на выполнение кода │
│ Скорость: Медленнее (модификация памяти) │
│ Память: Изменяет код программы │
│ │
│ [48] → [CC] → [48] → [CC] (цикл восстановления) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ HARDWARE BREAKPOINT │
├─────────────────────────────────────────────────────────────┤
│ Механизм: Debug-регистры процессора (DR0-DR7) │
│ Лимит: Максимум 4 одновременно │
│ Тип: Execute / Write / Read+Write │
│ Скорость: Быстрее (аппаратный уровень) │
│ Память: НЕ изменяет код │
│ │
│ DR0 → [Адрес] │
│ DR7 → [Условия срабатывания] │
│ CPU сам отслеживает доступ │
└─────────────────────────────────────────────────────────────┘
Расширения к стандартному бряку
Так как отладчик умеет управлять программными точками остановки, то на них можно накрутить разные расширения, чтобы сделать жизнь программиста легче.
Conditional Breakpoint (условная точка останова) - срабатывает когда выполняется заданное условие — например, i == 10 или entityName == "Enemy". Удобно, если код вызывается много раз, но нас интересует лишь конкретный случай. Вместо того чтобы вручную нажимать "Continue/F5", можно задать условие, и отладчик остановится только тогда, когда оно выполнится. Но, судя по тому, что в VS условные бряки работают медленно, реализовали их не очень хорошо, и часто бывает проще дописать дебажную логику с условием, чем пользоваться реализацией в отладчике.
Hit Count Breakpoint (по счётчику попаданий) - срабатывает не при первом проходе, а после того, как строка была достигнута определённое количество раз. Например, вы можете задать «остановиться на 100-й итерации цикла». Логически вытекает из предыдущего типа с минимальными изменениями.
Function Breakpoint (на функции) - позволяет остановиться при входе в конкретную функцию, даже если у вас нет исходного кода. В отличие от обычных брейкпоинтов, которые ставятся на конкретную строку исходного кода, функциональная точка позволяет остановиться при входе в функцию, даже если у вас нет исходников. Это позволяет работать с библиотеками, SDK или сторонними модулями, где исходный код недоступен, но присутствуют символы pdb (обычно их поставляет вместе c sdk либо по запросу). Механизм использования основан на файле отладочных символов, который содержит таблицу, в которой записаны адреса всех функций и переменных, и когда разработчик вводит имя функции в отладчике, например MyNamespace::Player::Update, тот ищет её в этой таблице и находит адрес входа в функцию и подменяет первый байт инструкции по обычной схеме. Интересно, что даже при отсутствии PDB-файлов отладчик может поставить такую точку, например если найдет нужную функцию в таблице экспортов исполняемого файла или DLL, где хранятся имена экспортируемых функций. Ситуация усложняется при включённых оптимизациях, когда компилятор может встроить функцию напрямую в вызывающий код, удалить её как неиспользуемую или переставить её блоки, либо использовать код функции частично. В таких случаях отладчик может не найти функцию вовсе или установить точку в неожиданном месте — прямо внутри инлайна.
Exception Breakpoint (на исключениях) - срабатывает, когда программа выбрасывает исключение — например, деление на ноль, выход за границы массива или любая ошибка, оформленная через throw. В отладчике можно указать, на каких типах исключений нужно останавливаться — только на необработанных или на всех.
Tracepoint (логирование без остановки) - иногда нужно просто узнать, что программа делает в определённый момент, не останавливая выполнение. Для этого есть лог-точки: они выводят сообщение в консоль или окно вывода, не прерывая работу программы.
Temporary breakpoint (временная точка останова) - точка срабатывает только один раз, а затем автоматически удаляется. Подходит для случаев, когда надо проверить конкретный момент, но не хочется вручную убирать точку после срабатывания. В Visual Studio временная точка останова это не отдельный тип, а поведение, которое получается командой Run to Cursor. Студия под капотом ставит временную точку останова на текущей строке, запускает выполнение программы, и как только выполнение дойдет до этой строки, отладчик останавливает выполнение — после чего бряк автоматически удаляется.
ОТЛАДЧИК: Set breakpoint on "UpdatePlayer"
1: Поиск адреса функции
┌─────────────────────────────────────────┐
│ Ищем в PDB: │
│ "UpdatePlayer" → 0x00401000 │
│ │
│ Если PDB нет, ищем в Export Table: │
│ "UpdatePlayer" → 0x00401050 │
| |
| Ищем в DLL Export Table: |
| GameEngine.dll Export Table: │
│ │
│ ?XBH@UpdatePlayer@@QEAAXVVector3@@@Z │
│ → Адрес: GameEngine.dll + 0x12340 │
│ (mangled name) |
└─────────────────────────────────────────┘
▼
2: Установка software breakpoint
┌─────────────────────────────────────────┐
│ ПАМЯТЬ: │
│ 0x401000: [55] → [CC] │
│ push rbp → int 3 │
│ │
│ СОХРАНИТЬ: │
│ BP_Table["UpdatePlayer"] = { │
│ address: 0x401000, │
│ original_byte: 0x55 │
│ } │
└─────────────────────────────────────────┘
Простой интерфейс к своим брякам
Под спойлером полный код для работы аппаратными точками останова, давайте разберем особо интересные части.
Код для работы со своими бряками
#pragma warning(push)
#include <Windows.h>
#include <cstddef>
#include <algorithm>
#include <array>
#include <cassert>
#include <bitset>
#pragma warning(pop)
namespace DebugWatchpoint {
// Коды результатов операций с аппаратными точками останова
enum class Status {
OK, // Операция успешна
ContextReadFailed, // Не удалось получить контекст потока
ContextWriteFailed, // Не удалось установить контекст потока
AllRegistersInUse, // Все 4 debug-регистра заняты
InvalidCondition, // Неподдерживаемое условие срабатывания
InvalidSize // Размер должен быть 1, 2, 4 или 8 байт
};
// Условия срабатывания аппаратной точки останова
enum class TriggerCondition {
OnReadWrite, // При чтении или записи
OnWrite, // Только при записи
OnExecute // При выполнении инструкции
};
// Дескриптор установленной аппаратной точки останова
struct WatchpointHandle {
static constexpr WatchpointHandle CreateError(Status err) {
return {0, nullptr, err};
}
uint8_t debugRegIdx; // Индекс использованного debug-регистра (0-3)
void* targetAddr = nullptr; // Адрес памяти, на который установлена точка останова
Status statusCode; // Код результата операции
};
// Функция для безопасного изменения debug-регистров потока
// Получает контекст, выполняет действие, записывает контекст обратно
template<typename ActionFunc, typename ErrorFunc>
auto ModifyDebugContextImpl(ActionFunc action, ErrorFunc onError) {
// Подготавливаем структуру контекста потока
CONTEXT threadCtx{0};
threadCtx.ContextFlags = CONTEXT_DEBUG_REGISTERS;
// Получаем текущий контекст потока с debug-регистрами
if (::GetThreadContext(::GetCurrentThread(), &threadCtx) == FALSE) {
return onError(Status::ContextReadFailed);
}
// Битовые маски для проверки занятости каждого из 4 debug-регистров
// Dr7 содержит флаги включения для каждого регистра
std::array<bool, 4> regInUse{{false, false, false, false}};
auto markIfBusy = [&](std::size_t idx, DWORD64 enableMask) {
// Проверяем бит включения регистра в Dr7
if (threadCtx.Dr7 & enableMask)
regInUse[idx] = true;
};
// Проверяем каждый debug-регистр (DR0-DR3)
markIfBusy(0, 0b00000001); // DR0: бит 0
markIfBusy(1, 0b00000100); // DR1: бит 2
markIfBusy(2, 0b00010000); // DR2: бит 4
markIfBusy(3, 0b01000000); // DR3: бит 6
// Выполняем переданное действие с контекстом
const auto result = action(threadCtx, regInUse);
// Записываем измененный контекст обратно в поток
if (::SetThreadContext(::GetCurrentThread(), &threadCtx) == FALSE) {
return onError(Status::ContextWriteFailed);
}
return result;
}
// Устанавливает аппаратную точку останова на указанный адрес памяти
// targetAddr - адрес для отслеживания
// byteSize - размер отслеживаемой области (1, 2, 4 или 8 байт)
// condition - условие срабатывания (чтение/запись/выполнение)
WatchpointHandle Install(const void* targetAddr, std::uint8_t byteSize, TriggerCondition condition) {
return ModifyDebugContextImpl(
[&](CONTEXT& ctx, const std::array<bool, 4>& regInUse) -> WatchpointHandle {
// Ищем первый свободный debug-регистр
const auto freeReg = std::find(begin(regInUse), end(regInUse), false);
if (freeReg == end(regInUse)) {
// Все 4 регистра заняты (x86/x64 поддерживает максимум 4 аппаратных точки)
return WatchpointHandle::CreateError(Status::AllRegistersInUse);
}
// Вычисляем индекс найденного регистра (0-3)
const auto idx = static_cast<std::uint16_t>(std::distance(begin(regInUse), freeReg));
// Записываем адрес в выбранный debug-регистр (DR0-DR3)
void* addr = const_cast<void*>(targetAddr);
DWORD_PTR addrValue = reinterpret_cast<DWORD_PTR>(addr);
switch (idx) {
case 0: ctx.Dr0 = addrValue; break;
case 1: ctx.Dr1 = addrValue; break;
case 2: ctx.Dr2 = addrValue; break;
case 3: ctx.Dr3 = addrValue; break;
default:
assert(!"Impossible happened - searching in array of 4 got index < 0 or > 3");
return WatchpointHandle::CreateError(Status::AllRegistersInUse);
}
// Работаем с регистром Dr7 через bitset для удобства манипуляции битами
// Dr7 - это control register, содержащий флаги включения и настройки условий
std::bitset<sizeof(ctx.Dr7) * 8> controlReg;
std::memcpy(&controlReg, &ctx.Dr7, sizeof(ctx.Dr7));
// Включаем локальную точку останова для выбранного регистра
// Биты 0,2,4,6 - локальные флаги включения для DR0-DR3 соответственно
// (биты 1,3,5,7 - глобальные флаги, не используются в user-mode)
controlReg.set(idx * 2);
// Устанавливаем тип условия срабатывания в битах 16-31 регистра Dr7
// Каждый регистр занимает 4 бита: биты (16 + idx*4) и (16 + idx*4 + 1)
// определяют условие: 00=Execute, 01=Write, 11=ReadWrite
switch (condition) {
case TriggerCondition::OnReadWrite:
controlReg.set(16 + idx * 4 + 1, true);
controlReg.set(16 + idx * 4, true);
break;
case TriggerCondition::OnWrite:
controlReg.set(16 + idx * 4 + 1, false);
controlReg.set(16 + idx * 4, true);
break;
case TriggerCondition::OnExecute:
controlReg.set(16 + idx * 4 + 1, false);
controlReg.set(16 + idx * 4, false);
break;
default:
return WatchpointHandle::CreateError(Status::InvalidCondition);
}
// Устанавливаем размер отслеживаемой области в битах (16 + idx*4 + 2) и (16 + idx*4 + 3)
// 00=1 байт, 01=2 байта, 10=8 байт, 11=4 байта
switch (byteSize) {
case 1:
controlReg.set(16 + idx * 4 + 3, false);
controlReg.set(16 + idx * 4 + 2, false);
break;
case 2:
controlReg.set(16 + idx * 4 + 3, false);
controlReg.set(16 + idx * 4 + 2, true);
break;
case 8:
controlReg.set(16 + idx * 4 + 3, true);
controlReg.set(16 + idx * 4 + 2, false);
break;
case 4:
controlReg.set(16 + idx * 4 + 3, true);
controlReg.set(16 + idx * 4 + 2, true);
break;
default:
return WatchpointHandle::CreateError(Status::InvalidSize);
}
// Записываем измененный Dr7 обратно в контекст
std::memcpy(&ctx.Dr7, &controlReg, sizeof(ctx.Dr7));
return WatchpointHandle{static_cast<std::uint8_t>(idx), (void*)targetAddr, Status::OK};
},
[](auto errCode) {
return WatchpointHandle::CreateError(errCode);
});
}
// Удаляет аппаратную точку останова
void Uninstall(const WatchpointHandle& handle)
{
// Проверяем, что handle валидный
if (handle.statusCode != Status::OK) {
return;
}
ModifyDebugContextImpl(
[&](CONTEXT& ctx, const std::array<bool, 4>&) -> WatchpointHandle {
std::bitset<sizeof(ctx.Dr7) * 8> controlReg;
std::memcpy(&controlReg, &ctx.Dr7, sizeof(ctx.Dr7));
// Отключаем локальный флаг включения для данного регистра
controlReg.set(handle.debugRegIdx * 2, false);
// Записываем обратно
std::memcpy(&ctx.Dr7, &controlReg, sizeof(ctx.Dr7));
return WatchpointHandle{};
},
[](auto errCode) {
return WatchpointHandle::CreateError(errCode);
});
}
// Удаляет все аппаратные точки останова (очищает все 4 debug-регистра)
void ClearAll() {
for (uint8_t i = 0; i < 4; ++i) {
Uninstall(WatchpointHandle{i, nullptr, Status::OK});
}
}
} // namespace DebugWatchpointПроцессоры x86-64 предоставляют 8 специальных регистров для отладки:
DR0-DR3: хранят адреса памяти для отслеживания (4 точки максимум)
DR4-DR5: зарезервированы (алиасы DR6-DR7)
DR6: status register (какая точка сработала)
DR7: control register (настройки условий срабатывания)
Структура регистра DR7
Биты 0-7: Флаги включения (L0,G0,L1,G1,L2,G2,L3,G3)
Биты 8-15: Зарезервированы
Биты 16-31: Условия и размеры для DR0-DR3
Каждый debug-регистр занимает 4 бита в DR7:
// Для регистра i (0-3):
Бит (16 + i*4 + 0): R/W bit 0
Бит (16 + i*4 + 1): R/W bit 1
Бит (16 + i*4 + 2): LEN bit 0
Бит (16 + i*4 + 3): LEN bit 1
// Условия срабатывания (биты R/W):
00 = Execute (выполнение инструкции)
01 = Write (запись)
10 = I/O Read/Write (только для ring 0)
11 = Read/Write (чтение или запись)
// Размер отслеживаемой области (биты LEN):
00 = 1 байт
01 = 2 байта
10 = 8 байт
11 = 4 байта
Перед установкой новой точки сначала надо найти свободный регистр, каждый регистр имеет два флага: локальный (L) и глобальный (G). Для нас будут доступны только локальные (биты 0,2,4,6). Debug-регистры уникальны для каждого потока и установка в одном потоке не влияет на другие, а вот на виртуальных машинах аппаратные точки могут работать некорректно, говорить что работают и установлены, но не срабатывать или вообще не поддерживаться, но при этом отображаться как активные.
std::array<bool, 4> regInUse{{false, false, false, false}};
auto markIfBusy = [&](std::size_t idx, DWORD64 enableMask) {
if (threadCtx.Dr7 & enableMask)
regInUse[idx] = true;
};
// Локальные флаги включения в битах 0,2,4,6
markIfBusy(0, 0b00000001); // DR0: проверяем бит 0
markIfBusy(1, 0b00000100); // DR1: проверяем бит 2
markIfBusy(2, 0b00010000); // DR2: проверяем бит 4
markIfBusy(3, 0b01000000); // DR3: проверяем бит 6
Простой пример использования с "буферной канарейкой"
char buffer[100];
char canary = 0xCC; // "Канарейка" после буфера
// Ставим точку на канарейку
auto wp = DebugWatchpoint::Install(
&canary, 1,
DebugWatchpoint::TriggerCondition::OnWrite
);
strcpy(buffer, veryLongString); // Если кто-то перепишет canary -> сработает бряк
Как сделать проще
Понятно, что такой подход работает для программистов, которые имеют доступ к исходникам и могут вносить изменения, но он тоже требует понимания примерно - где и что меняется у объекта или компонента. Т.е. программист в процессе исследования бага тоже потратит N времени, чтобы докопаться до причины гонки или корапшена памяти.
Для дизайнеров и QA такое вообще не работает, в силу того что они меньше знакомы с кодовой базой или не могут её менять, тут нам на помощь приходят отладочные средства самой игры или движка. Можно их немного доработать, чтобы иметь возможность отлавливать изменения переменных конкретного объекта без вмешательства в код. Дебажный интерфейс в моем случае написан на ImGui, но по схожему принципу будет работать и в других реализациях.
// Глобальный массив для отслеживания активных аппаратных точек останова
// процессор поддерживает максимум 4 одновременных hardware breakpoints
// Инициализируем "пустыми" breakpoint'ами (nullptr означает "не используется")
std::array<HardwareBreakpoint::Breakpoint, 4> gameDebugBreakpoints = {
HardwareBreakpoint::Breakpoint{0, nullptr, HardwareBreakpoint::Result::Success},
HardwareBreakpoint::Breakpoint{0, nullptr, HardwareBreakpoint::Result::Success},
HardwareBreakpoint::Breakpoint{0, nullptr, HardwareBreakpoint::Result::Success},
HardwareBreakpoint::Breakpoint{0, nullptr, HardwareBreakpoint::Result::Success}};
// Ищет аппаратную точку останова, установленную на указанный адрес памяти
HardwareBreakpoint::Breakpoint AOETools_GetDebugBreakpoint(void* ptr)
{
const auto it = std::find_if(
gameDebugBreakpoints.begin(),
gameDebugBreakpoints.end(),
[ptr](const auto& bp) { return bp.m_onPointer == ptr; });
return (it != gameDebugBreakpoints.end())
? *it
: HardwareBreakpoint::Breakpoint::MakeFailed(HardwareBreakpoint::Result::NoAvailableRegisters);
}
// Проверяет, установлена ли аппаратная точка останова на указанный адрес
// Возвращает: true если точка установлена и активна, false иначе
bool AOETools_isDebugBreakpointSet(void* ptr)
{
const auto it = std::find_if(
gameDebugBreakpoints.begin(),
gameDebugBreakpoints.end(),
[ptr](const auto& bp) { return bp.m_onPointer == ptr; });
return (it != gameDebugBreakpoints.end()) && (it->m_onPointer != nullptr);
}
// Отображает UI-кнопку для установки/удаления аппаратной точки останова на свойство
// Работает только с числовыми типами (int, float, double и т.д.)
// field - имя поля (не используется)
// v - ссылка на переменную, которую нужно отслеживать
// disabled - флаг отключения (не используется в текущей реализации)
template<typename T>
void AOETools_DebugShowPropertyBreakpoint(const char* /*field*/, const T& v, bool disabled)
{
// Compile-time проверка: поддерживаем только целочисленные и floating-point типы
// Для других типов (структуры, классы) нужна другая логика
static_assert(
std::is_integral_v<T> || std::is_floating_point_v<T>,
"Only integral or floating point types are supported now");
const bool isDebugBreakpointAvailable = AOETools_isDebugBreakpointAvailable();
const bool breakpointSet = AOETools_isDebugBreakpointSet((void*)&v);
// Кнопка активна, если: есть свободные регистры ИЛИ точка уже установлена (можем удалить)
const bool canChangeBreakpoint = (isDebugBreakpointAvailable || breakpointSet);
// Отключаем кнопку в ImGui, если нельзя изменить состояние breakpoint
ImGui::PushItemFlag(ImGuiItemFlags_Disabled, canChangeBreakpoint == false);
// Текст кнопки зависит от состояния:
// "NA" - недоступно (все регистры заняты)
// "||" - пауза (breakpoint установлен, можно удалить)
// ">>" - play (breakpoint не установлен, можно установить)
const char* buttonText = (canChangeBreakpoint == false) ? "NA" : (breakpointSet ? "||" : ">>");
if (ImGui::Button(buttonText))
{
if (breakpointSet)
{ // Удаляем существующую точку останова
auto breakpoint = AOETools_GetDebugBreakpoint((void*)&v);
HardwareBreakpoint::Remove(breakpoint);
gameDebugBreakpoints[breakpoint.m_registerIndex] =
HardwareBreakpoint::Breakpoint{0, nullptr, HardwareBreakpoint::Result::Success};
}
else
{ // Устанавливаем новую точку останова
// Создаем hardware breakpoint на запись в переменную
// Параметры: адрес, размер типа, условие "Written" (срабатывает при записи)
auto breakpoint =
HardwareBreakpoint::Set((void*)&v, sizeof(v), HardwareBreakpoint::BreakpointCondition::Written);
if (breakpoint.m_onPointer != nullptr)
{
// Сохраняем breakpoint в массив активных по индексу использованного регистра
gameDebugBreakpoints[breakpoint.m_registerIndex] = breakpoint;
}
}
}
ImGui::PopItemFlag();
}
Что происходит на видео (это был питч технической идеи перед командой) - в редакторе игры рядом с каждым свойством (здоровье, урон и т.д.) появляется кнопка активной отладки, при нажатии устанавливается hardware breakpoint на запись в эту переменную. Когда игровая логика изменяет переменную → отладчик остановится → можно посмотреть стек вызовов в Visual Studio, на примере - выкидывает в подключенный отладчик в момент изменения переменных из редактора свойств. Соответственно при отладке разных багов появляется возможность без залезания в код понять, кто и откуда изменил или испортил переменную для конкретного юнита, а применительно к коду дает возможность програмно управлять отладкой для сложных случаев.

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

Jijiki
06.10.2025 17:44у движка должно быть отделено, что движок(вот например как блендере есть отделение и раздел конфигураций, можно и через lua того же добиться(1)), а что запущено аля плейгроунд(редактирование IDE в игре визуальный с возможностью сохранения конфигурации на основе версии движка), имгуй - надо заменить на свой интерфейс плейграунда, тоесть это тестовый билд как раз, есть еще gdb -help, lldb -help
вот ваш код для дебагинга это функционал той части того плейграунда специального
чтобы дизасемблировать свой же код можно просто скомпилировать с флагом -S
1-это нужно для того, чтобы дебажить то что на выбранном уровне и только из этих обьектов, зачем например дебажить отрисовку и всю низкую ситуацию, а это приводит к дебагу типа данных и его состояний только и всего (тоесть это уже движок с каким-нибудь lua rtti)

dalerank Автор
06.10.2025 17:44Я правильно понял что Вы описываете систему, где движок и "плейграунд" (редактор на базе движка) разделены по слоям, с возможностью отладки через gdb/lldb, кастомным интерфейсом вместо ImGui, и использованием Lua для настройки и рефлексии — то есть речь о модульной архитектуре с возможностью сериализации отдельных компонентов? В теории всё верно, на практике зависит от того как развивался движок

Jijiki
06.10.2025 17:44да, только это проще можно сделать чем в той таблице какую вы показывали - там всё переусложнено
вот движок варкрафта даёт интерфейс lua
получается дебаг почти в игре в процессе прям
пример блендера, дизайнер не имеет доступ к отрисовке, к апи, он имеет доступ только к УГО и дебажит по своему
тоесть поставили свет, террейн, сказали прилепай, далее поставили камеру, нажали кнопку произошел конфиг, я так себе представляю, и далее при запуске от конфига запустилась сцена, которую выставил дизайнер, и при сейве произошла подготовка манифеста грубо говоря(тоесть альфа билд)
rttr что-то этим можно сделать, что-то от lua получается

dalerank Автор
06.10.2025 17:44А можно переформулировать этот текст? К сожалению, я ничего не понял :(

Jijiki
06.10.2025 17:44ну дизайнер не должен иметь доступ к апи отрисовки, он имеет доступ только к компонентам образно, подготовленным в движке или зарегестрированными самим дизайнером
тоесть вопрос как проверить функционал портала? лезть в дебагер если можно в игре проверить на манекене
поэтому мы делаем ту вьюшку окно(пример UCR1lHrfxY01XYSoVJJAi-xQ) и прочее, манифест это просто манифестация записи конфигурации и компановка обьектов в образе, тоесть внешний клиент для запуска
манифест это простейшая компановка образа по типу как в яве, есть и лучше подходы, если формат у студии свой имеется

Jijiki
06.10.2025 17:44вы в том примере показали стрелу, так вы хотите ставить брекпойнт на стреле получается? и зачем, если типы в движке становятся подобными примитивами, и отражают только состояния, я хочу сказать что вы в коде хотите найти если обьект (условно тип Б с состоянием ) уже в игре и у него есть флаги, которые проще прочекать в игре прям, тоесть как бы вы скелетную анимацию так же дебажите?
тогда шейдеры тоже надо дебажить
а обьекты вы писали собираются в локальный пул, получается, обьекты уже собраны, надо только прочекать их состояния - логику, зачем брекпойнт - то прям

Jijiki
06.10.2025 17:44у меня вам встречный вопрос
Shader shaderText("shaders/vText.glsl","shaders/fText.glsl"); TextV textV;//VAO VBO CharacterV charV;//структура глифа createTextureFont(&charV,"DejaVuSansMono.ttf",48);//создание текстуры configTextbufs(&textV);//настройка вершинного буфера ... RenderText(&shaderText,&textV,&charV,"TESTFSDFSDFSDF",10, 100,1, glm::vec3(1.0f,1.0f,1.0f), Oproj); struct Character { GLuint TextureID; // ID texture glyph glm::ivec2 Size; // size glyph glm::ivec2 Bearing; // offset GLuint Advance; // step // ~Character() // { // glDeleteTextures(1, &TextureID); // } }; struct CharacterV { FT_Library ft; FT_Face face; std::map<char, Character> Characters; }; struct TextV { GLuint VAOtext, VBOtext; ~TextV() { glDeleteBuffers(1, &VAOtext); glDeleteVertexArrays(1, &VBOtext); } }; void createTextureFont(CharacterV *characterv,std::string path,int size); void configTextbufs(TextV *text); void RenderText(Shader *shader, TextV *textV, CharacterV *characterv, std::string text, float x, float y, float scale, glm::vec3 color, glm::mat4 &proj);зачем тут нужен может быть дебагер? ну портанёт он меня в файл Font.h и?
специально для этого примера я разбил логику эту потомучто вершинный буфер, шейдер, инициализация, и отрисовка совсем разные части
а значит дело в логике
вопрос можно расширить, тут писали, допустим баг, пользователь нажимает пробел и прога вылетает. окей там портянка на 30тыщ строк, вы предлагаете через дебугер пропустить код, окей, но в коде там видно же где ошибка вы так не думаете?
тоже самое со скелетной анимацией, почему может не помоч дебагер, в дебагере можно найти место где попадает матрица не того вида, в теории да наверно, но это же этап отладки, а значит первичный запуск скелетки не в движке, а вынесен в мини тест и там можно просто бряку поставить, например если нужна ускоренная своя математика, тоесть это сидеть с дебагером вы предлагаете проверять результаты по дебагеру получается, еслиб всё так просто было, щас еще ссылкой кинусь

ExiveR
06.10.2025 17:44За статью спасибо! Как-то раньше не задумывался как это реализовано, хотя буквально на прошлом курсе ВУЗа про регистры отладки в учебнике сказано было только, что они есть и сколько их))
P.S. не сочтите за тошноту - "страндартному" (чуть-чуть ошибка)

unreal_undead2
06.10.2025 17:44Тут возможно два варианта
А первый вариант как то реализуется без Trap Flag?

Gumanoid
06.10.2025 17:44Зависит от дебаггера. Например, в том который мы делали, для выполнения 1 инструкции вызывался софтверный эмулятор, т.к. это было быстрее чем переключать режимы процессора ради исполнения 1 команды.

unreal_undead2
06.10.2025 17:44Верю что быстрее, но реализовать эмуляцию произвольной инструкции, скажем, на x86 или ARM с разнообразными расширениями - задачка весьма серьёзная.

Jijiki
06.10.2025 17:44задача сложности разработки на поверхности, если писать всё в 1 файле, то попроще на -1 этапе, далее идут слои, архитектура, при написании слоёв и архитектуры свой дебаг режим, а при момент когда игровой обьект попал на сцену, при условии, что платформа - тоесть софт для запуска плейграунда готов, задача я считаю сводится к отладке этого обьекта(тип этого обьекта после слоя отрисовки, к сожалению несет только логику и какие-то позиции), далее пока будет реализовываться архитектура, скорее всего из-за сложности она будет модульная, а в модульную систему ввод модуля вводится не в лоб и отлаживается, по началу же кусок задачи, который в модуле проще отладить в консоле. И сам прототип-модуля можно запустить по брейкпойнтам, тоесть ну не знаю смысл внутрянку в сборе пойнтами дебажить
просто попробуйте наивно начать разрабатывать такого рода проекты, и вы увидите ситуацию надеюсь, как бы там и будет то о чем говорит топик стартер, есть уровень (террайн, и обьекты на нём, и обьекты от еффектов)
на -1 уровень можно без архитектуры сделать при условии, что в дальнейшем можно отрефакторить, при этом, на 1 уровне разного скейла можно и коротенькую игру сандбокс сделать, еще учитывайте, что скелетная анимация может быть(а она тоже будет модулем для этой системы - отсюда следует перед вводом её надо отдебажить, проверить).
при этом если взять команды блендера, да он имеет команды отрисовки, но пользователь же не с ними контактирует, так что емуляция такого рода это индивидуально скорее всего.
что такое тип Инт - число
что такое слой отрисовки - это набор абстракций обеспечивающий отрисовку и тесно соприкасается с вызовами отрисовки и драйверами(тоесть есть 2 случая, обьект рисуется, и обьект активен в 3д мире - это 2 разные ситуации 1 обьекта)
что такое слой обьектов в такого рода системе - это scope только по визуалу, та логика и обьекты с которой взаимодействует первично дизайнер как раз, я может и ошибаюсь, но пока такое вижу
тоесть на уровне scope обьектов условном анриале, можно на блюпринте написать специализированный чекер, который будет чекать обьекты на сцене
тоесть давайте упростим 2 уровня до консоли
в первой задаче мы работаем с геометрией и передачей их в апи (картинки, треугольники такого рода алгоритмы)
во второй части это что-то наподобии массива интов или флоатов и связанными с ними логиками (например всякие поиски, пересечения, передвижение, точка и прочее)

unreal_undead2
06.10.2025 17:44Речь шла об эмуляции одной инструкции процессора, какие ещё блендеры/скейлеры?
mynameco
столкнулся как то с тем, что у многих фкнкций, здоровье и т.п. изменения типа минмакс. т.е. мы всегда присваиваем. вне зависимости поменялось значение или нет. и все варианты отладки в тулсете плохо подходят. нужны кондишенейбл бряки, что в коде.
slair
значит такие программисты писали
mynameco
это функции. функция это то что всегда возвращает рузультат. описываются декларативно и не программистами. например функции minmax, clamp, lerp и все такое. програмисты не пишут абилки, эффекты и функции изменения параметров статов сущностей.
rivo
Это нормальная практика, использовть одну строку, вместо 5 строк if/else.
Тут другой вопрос, почему переменныке не обернули в getter/setter, который скипнет модификацию, если значение не поменялось.
А еще на setter можно брекпоинт поставить и логи в нем напечатать.
aamonster
Современные дебагеры могут на breakpoint ставить свою проверку и свой скрипт. Т.е. зачастую можно отследить, менялось ли значение, не влезая в код самой проги (актуально, когда перекомпиляция или повторный запуск занимает много времени и требует усилий... Если писать юнит–тесты – надобность в этом сильно уменьшается).
Jijiki
логировать - состояние, статусы, если рпг система все получения еффектов, и наверно чтобы была возможность отключать еффекты
есть еще приколы, помимо поиска пути есть еще высота(она частично физика тоесть коллизия с террейном, тут тоже логировать наверно)
тоесть вы наверно о том случае кто именно отнял здоровье во время Н, при состоянии и статусе П
там же касания могут быть по обьему, в случае получения урона от абилки, еще нюанс, как происходит выбор цели, в одном случае в лог попадает выбранная цель и если игрок наносит урон цели, цель включает состояния,берет в таргет игрока - лог, урон получается -лог, когда детектится или нет урон-лог, смотря какая система - рпг или нет
а если игрок не выбирает, а сразу пускает луч, ну тоже самое таргет, урон, цель взяла в таргет игрока, пустила урон
тоесть нужна система логирования
система боя - потомучто если будет больше 1 нанесения урона надо по очереди наверно их обрабатывать, потом надо как-то убегать тоесть отводить/завершать бой, в том числе и абилками(абилки тоже система)
Jijiki
тоесть это общение обьектов в зависимости от их состояний при взаимодействии
тоесть должна быть приписка по получению урона, урона бывает разный от луча или по истечению времени луч, или физическое касание, но если физическое касание у обьекта, который летит от которого будет получен урон, есть обьект, который изменил своё состояние из-за взаимодействия, соотв по лучу видно обьект такой-то, статусы такие-то завершения нету, значит они покачто взаимодействуют
это конечные автоматы, луч или обьект это как передатчик сообщения для взаимодействия
хорошо более наглядный пример, при прыжке(или при появлении задали высоту 1000 юнитов) кто отнимает высоту у обьекта при падении до уровня высоты
и второй случай, в графе, в мире -+- +++, при нахождении пути мы опираемся на высоту найденную или произойдёт фикс высоты при опр случаях, в идеале граф хранит высоту, но нужна корректировка например будет
и следующий пример при переходе из карты в карту пещеры, там поидее теже ситуации