Аппаратные средства отладки ESP32/ESP32S2/ESP32S3 и их использование у себя в скетчах

Аппаратные средства отладки ESP32/ESP32S2/ESP32S3 и их использование у себя в скетчах.

Умеет наш любимый ESP32/S2/S3 аппаратную отладку: поддерживаются 2 аппаратные точки останова по коду и две - по данным.

В терминах Espressif это, соответственно, IBREAK и DBREAK, а API, которое Espressif предланает использовать вот такое:

esp_cpu_set_watchpoint()
esp_cpu_set_breakpoint()
esp_cpu_clear_watchpoint()
esp_cpu_clear_breakpoint()

На этом документация и заканчивается.

Если попробовать установить break или watch, то он, как ни странно, сработает и вывалит на экран
дамп регистров и Guru Meditation: excepttion was unhandled (DebugException)

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

Или еще что.

Ниже - скетч для ESP32/ESP32-S2 и ESP32-S3. На RISCV тоже самое можно сделать, но я не сделал, заленился.

Скетч состоит из двух файлов, их надобно будет положить в один каталог.

Что делает скетч: устанавливает watchpoint#0 на запись в переменную. И пишет туда, выводя на экран кое какую статистику (WP#1 съели канарейки из freertos): выводится количество срабатываний защиты, текущее значение защищаемой переменной и адрес инструкции, которая вызвала срабатывание защиты.

Тонкости работы кода - в комментариях.

Для чего это было нужно мне: я делал логгирование доступа к регистрам WIFI (доступ к адресам 0x60033xxx), чтобы срисовать последовательность инициализации и тайминг.

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

Секретные Файлы: di.ino

// Скетч-пример, который показывает, как на ESP32, ESP32-S2 и ESP32-S3 :
//
// 1. Устанавливать защиту на запись в память ( в примере - на переменную) (см. esp_cpu_set_watchpoint())
// 2. Пропуск инструкции (debug_exception_handler)
// 3. Обработку высокоприоритетного прерывания (di.S)
//
// Похожим образом можно установить и перехватить breakpoint. (см. esp_cpu_set_breakpoint())
// Можно комбинировать DBREAK и IBREAK для контролируемого пошагового выполнения программы

#include <Arduino.h>

// Exception Frame.
//
struct exc_frame {
  uint32_t ps;
  uint32_t pc;            // указатель на инструкцию вызвавшую прерывание
  uint32_t registers[20];
};


extern "C" void debug_exception_handler(struct exc_frame *frame);

// Счетчик сработавших прерываний
static int triggered = 0;

// Адрес инструкции, вызвавшей прерывание
static uintptr_t address = 0;

// Обработчик DebugInterrupt. (Прерывание генерируется CPU в момент доступа к защищаемой памяти)
// Сюда прилетают Data watchpoints и Code breakpoints, но в данном примере мы будем только ловить data watchpoint
// Пропускает инструкцию вызвавшую прерывание, увеличивает счетчик событий на единичку
// Вызывается из di.S, параметры передаются через стек
//
void IRAM_ATTR debug_exception_handler(struct exc_frame *f) {

  // Массив длин инструкций.
  // Длина ассемблерной инструкции по ее первому байту. Длина может варьировать от 2 до 4 байт, может быть 3 байта.
  //
  static const uint8_t xt_insn_len[] = { XCHAL_BYTE0_FORMAT_LENGTHS };

  // Регистр PC = адрес инструкции, которая вызвала прерывание.
  // Прочитаем первый байт инструкции через IBUS. Гарвард как-никак. 
  // 1. IBUS требует выровненного 32битового доступа. 
  // 2. Инструкции Xtensa могут быть 2,3 или 4 байта.
  // Поэтому читаем выровненно, потом сдвигаем. По-другому - никак, все повиснет.
  //
  // Первый байт инструкции (`opcode`) определяет длину инструкции в байтах.
  //
  uintptr_t pc = (uintptr_t)f->pc;

  // Сохраним адрес инструкции, вызвавшей прерывание
  address = pc;

  uint32_t aligned = pc & ~3;
  uint32_t word = *(volatile uint32_t *)aligned;
  uint32_t shift = (pc & 3) * 8;
  uint8_t opcode = (word >> shift) & 255;

  // Пропускаем инструкцию
  f->pc = f->pc + xt_insn_len[opcode];

  // Или так, если не хотим пропускать:
  // esp_cpu_clear_watchpoint(0);
  //

  // Увеличиваем счетчик
  triggered++;
}


// Тестовая переменная. Ее будем защищать. volatile, чтобы компилятор не оптимизнул
static volatile int test = 666;


void setup() {

  Serial.begin(115200);

  // Включаем защиту средствами ESP-IDF: Watchpoint#0, переменная test, длина - 4 байта
  // Длина может быть от 1 до 64 байт, однако, адрес обязан быть выровнен на значение длины: для защиты 
  // 16-байтового массива, данный массив должен быть выровнен в памяти на 16 байт. __attribute__((aligned)) вам в помощь
  // Watchpoint#1 уже занят под FreeRTOS а других нету. Всего два watchpoint'а аппаратных есть.
   //
  esp_cpu_set_watchpoint(0, (const void *)&test, 4 , ESP_CPU_WATCHPOINT_STORE);
}


void loop() {

  // Пишем в защищаемую память: это должно вызвать прерывание и вызов нашего debug_exception_handler(). 
  // Значение переменной НЕ изменится потому, что наш обработчик просто пропускает инструкцию. Обработчик 
  // может повторить инструкцию - для этого не нужно менять PC, но следует esp_cpu_clear_watchpoint(0) сделать.
  // Иначе, после повтора инструкции, прерывание будет сгенерировано снова и так до бесконечности
  test = triggered;

  // Скетч без делея - не скетч.
  delay(1000);

  // Ожидаемый вывод (например): "Triggered=321, test=666, address=4200xxxx"
  Serial.printf("Triggered=%d, test=%d, address=%x\r\n", triggered, test, address);
  
}

Файл di.S

// Перехват DebugInterrupt и вызов обработчика, написанного на Си (xtensa, windowed abi)
//
// Обработчики прерываний уровней 456 и 7 должны быть написаны на асскмблере. Это связано с тем, что система
// вызывает эти обработчики не так, как обычную Си-функцию. Здесь, собственно, находится адаптер, который
// написан на ассемблере но вызывает Си-код обычный.
// 

#ifdef __XTENSA__

#include <xtensa/coreasm.h>
#include <xtensa/corebits.h>
#include <xtensa/config/system.h>

#include "sdkconfig.h"
#include "xtensa_rtos.h"
#include "soc/soc.h"
#include "xt_asm_utils.h"
#include "xtensa_context.h"

#define CAUSE_DEBUGEXCEPTION    (1)
#define XT_DEBUGCAUSE_DI        (5)

#if (CONFIG_ESP32_ECO3_CACHE_LOCK_FIX && CONFIG_BTDM_CTRL_HLI)
// В ESP32 v3.00 есть бага в железе. Подробности нам не интересны, подробностей мы, увы, не понимаем.
// Код ниже содержит две вставки-затычки, которые фиксят эту багу. Затычки скопированы (без 
// малейшего понимания их работы) из xtensa_vectors.S
//
#  define BUGGY_CHIP
#endif


.section    .iram1, "ax"

// Си-функция, пользовательский код
.extern     debug_exception_handler
.type       debug_exception_handler, @function

// weak-символ, переопределение
.global     xt_debugexception
.type       xt_debugexception, @function

.align      4
.literal_position
.align      4

// DebugExceptionVector сохраняет регистр A0 и прыгает по адресу xt_debugexception
// A0 сохранен как "wsr a0, EXCSAVE+XCHAL_DEBUGLEVEL" (см. xtensa_vectors.S:_DebugExceptionVector
//
xt_debugexception:

#ifdef BUGGY_CHIP

// В оригинальном коде затычки есть странная строчка.
// Я ее на всякий случай закомментировал: А0 у нас и так сохранен, не будем чужой стек трешить
//  s32i     a0, sp, XT_STK_EXIT

// Читаем 13-й бит CPU CoreID. Если 0, то мы на ядре 0, иначе - на ядре 1.
  rsr.prid a0
  extui    a0, a0, 13, 1

// Пытаемся понять юыло ли прерывание от BT или это было обычное DebugInterrupt
// Если мы думаем, что прерывание было от BT, тогда выполняем некую магическую задержку.
// Я не знаю , зачем. Так Espressif делает в своем коде. Не будем перечить.
//
#if (CONFIG_BTDM_CTRL_PINNED_TO_CORE == PRO_CPU_NUM)
  beqz    a0, 1f
#else
  bnez    a0, 1f
#endif

  rsr     a0, DEBUGCAUSE
  extui   a0, a0, XT_DEBUGCAUSE_DI, 1
  bnez    a0, debug_di_exc
1:
#endif // BUGGY_CHIP

// Устанавливаем код исключения в регистре EXCCAUSE. На всякий случай.
//
movi    a0, CAUSE_DEBUGEXCEPTION
wsr     a0, EXCCAUSE

// Код трамплина написан по образу и подобию трамплина GDBStub. Надобно его переделать: нет нужды копировать 
// данные в Level1 - нужно исправить код, чтобы везде был Level6. А пока...
//
// .. копируем EPC и EXCSAVE с уровня DEBUGLEVEL (Level6) в соответствующие регистры EXCEPTIONLEVEL (level 1)
//
rsr     a0,(EPC + XCHAL_DEBUGLEVEL)
wsr     a0,EPC_1

// Восстановим наш A0, который был сохранен _DebugExceptionVector, и сохраним его так же в Level1
//
rsr     a0,(EXCSAVE + XCHAL_DEBUGLEVEL)
wsr     a0,EXCSAVE_1

// Постройка трамплина
// Прерывания с уровнями 4,5,6 и 7 являются высокоприоритетными и их обработчик должен быть
// написан на ассемблере. Т.к. писать ВСЕ на ассемблере нам неохота, мы настраиваем окружение так,
// чтобы можно было вызвать Си функцию и вызываем ее.
//
// Выделяем память на стеке для exception frame. Указатель на эту память будет передан Си обработчику,
//  который может менять содержимое фрейма (например, менять значение PC) ,
//
addi    sp, sp, -XT_STK_FRMSZ

// Погнали сохранять контекст
// Сохраняем А0 (адрес возврата)
s32i    a0, sp, XT_STK_EXIT
s32i    a0, sp, XT_STK_A0

// Сохраняем Processor State
rsr     a0, PS                          
s32i    a0, sp, XT_STK_PS

// Сохраняем PC (его скопировали на Level1 чуть выше, поэтому EPC_1 а не EPC_6)
rsr     a0, EPC_1
s32i    a0, sp, XT_STK_PC

// Сохраняем стандартный контекст
call0   _xt_context_save

// Сохраняем SP
addi     a7, sp, XT_STK_FRMSZ
s32i     a7, sp, XT_STK_A1

// _xt_context_save() не сохраняет A12 и A13. Все приходится делать самим :(.
s32i    a12, sp, XT_STK_A12
s32i    a13, sp, XT_STK_A13

// Сохраняем EXCCAUSE и VADDR. Они нам не нужны, но пусть будут, для полноты
rsr     a0, EXCCAUSE
s32i    a0, sp, XT_STK_EXCCAUSE

rsr     a0, EXCVADDR
s32i    a0, sp, XT_STK_EXCVADDR


rsr     a0, EXCSAVE_1                   // эта строчка под удаление.


// Устанавливаем PS для вызова Си-функции: обнуляем EXCM, запрещаем прерывания level1..level5 
// (т.е. все прерывания, кроме NMI и DEBUGINTERRUPT)
movi    a0, PS_INTLEVEL(5) | PS_UM | PS_WOE
wsr     a0, PS

// Сохраняем PC. Этот PC будет доступен в Си-функции через frame->pc. 
// Его можно менять и тогда, при выходе из обработчика мы прыгнем по новому значению PC
//
rsr     a0,(EPC + XCHAL_DEBUGLEVEL)
s32i    a0, sp, XT_STK_PC

// Передаем указатель на стек нашему обработчику. На стеке лежит фрейм, который мы только что сформировали.
// После вызова callx4, регистр А6 станет регистром A2 (т.е. - первым параметром функции)
mov     a6, sp

rsr     a9, EPS_6
s32i    a9, sp, XT_STK_PS   

// Наконец добрались. Вызываем наш Си-обработчик.
movi    a11, debug_exception_handler
callx4  a11                          

// Восстанавливаем регистр EPC_6 из exception frame->pc
// 
l32i    a0, sp, XT_STK_PC
wsr     a0,(EPC + XCHAL_DEBUGLEVEL)

// Раскручиваем все назад, восстанавливаем все регистры и выходим. 
// При выходе управление будет передано по EPC_6
call0   _xt_context_restore         
l32i    a12, sp, XT_STK_A12
l32i    a13, sp, XT_STK_A13

// Адрес возврата (A0)
l32i    a0, sp, XT_STK_EXIT         

// Стек (A1==SP)
addi    sp, sp, XT_STK_FRMSZ        

rfi     XCHAL_DEBUGLEVEL


#ifdef BUGGY_CHIP

  .align  4
debug_di_exc:

  // Задержка тут, всего-навсего
  movi    a0, 243

2:
  addi    a0, a0, -1
  .rept   4
  nop
  .endr
  bnez    a0, 2b

  // Восстанавливаем адрес возврата (А0) и выходим
  rsr     a0, EXCSAVE+XCHAL_DEBUGLEVEL
  rfi     XCHAL_DEBUGLEVEL

#endif // BUGGY_CHIP

// Две строчки ниже - обязательны. Если их удалить, то линкер не будет линковать этот файл к проекту.
.global ld_include_xt_debugexception
ld_include_xt_debugexception:

#endif // __XTENSA__

18:24:44.671 -> Triggered=1, test=666, address=42001a5e
18:24:45.643 -> Triggered=2, test=666, address=42001a5e
18:24:46.645 -> Triggered=3, test=666, address=42001a5e
18:24:47.662 -> Triggered=4, test=666, address=42001a5e
18:24:48.664 -> Triggered=5, test=666, address=42001a5e
18:24:49.680 -> Triggered=6, test=666, address=42001a5e
18:24:50.668 -> Triggered=7, test=666, address=42001a5e
18:24:51.663 -> Triggered=8, test=666, address=42001a5e
18:24:52.662 -> Triggered=9, test=666, address=42001a5e
18:24:53.674 -> Triggered=10, test=666, address=42001a5e
18:24:54.654 -> Triggered=11, test=666, address=42001a5e
18:24:55.664 -> Triggered=12, test=666, address=42001a5e

Можно доработать немного:

Логика такая - сработал watchpoint, мы прямо в прерывании делаем две вещи: снимаем watchpoint, ставим breakpoint на следубщую инструкцию и возвращаемся из обработчика: процессор повторит инструкцию, которая вызвала прерывание.

Следующая инструкция опять дернет обработчик, там мы устанавливаем watchpoint обратно и снимаем breakpoint. Таким образом можно отловить всех, кто пишет в память, позволив им при этом писать в память.

Код антиотладочный писать можно. Перехватываем DebugInterrupt у GDB/OpenOCD, как-никак. Начинающие хакеры на этом месте и увязнут :)))

Спасибо, а то скриншотик-то я и не приложил.

Вопрос. А возможно область пометить и рри обращении к ней получать exeption с адресом в прерыаании?

Типа - пометить 64 байта, и в прерывании точно узнать какие конкретно из этих байт пытались портить? Хороший вопрос.

По идее, адрес данных, который триггернул прерывание находится в регистре EXCVADDR (Exception Virtual Address Register), т.е. да, должно быть можно, но я не проверял. Проверить просто: написать скетч, в котором вызвать esp_cpu_set_watchpoint(0,) а потом писать в память. Вылетит Guru Meditation с дампом регистров. Отыскиваем в дампе регистр “EXCVADDR” и смотрим, наш ли там адрес или нет. Если наш - то дело в шляпе. Если нет - то надо читать TRM :slight_smile:

Примитивная виртуализация памяти и портов ввода вывода.

Ну, что есть. Нужон нормальный MMU - покупаем посерьезнее процессор. Если вам нравится программировать MMU и настраивать cache subsystem :slight_smile:

Не. Тут как раз сделать выполнение загруженной программы без доступа на запись в область прошитой. Только через “виртуализацию”.