Проверка корректности адресов в памяти на Cortex-M0/M3/M4/M7 +65


Привет, Хабр!

По поводу случившегося на днях послабления режима, возмущения в комментариях одного соседнего поста о том, что статьи про микроконтроллеры — сплошь мигание светодиодом, а также безвременной гибели моего стандалон-блога, восстанавливать который мне пока лень, переложу сюда полезный материал об одном прискорбно мало освещаемом прессой трюке в работе с ядрами Cortex-M — проверке произвольных адресов на валидность.


Одна из весьма полезных и при этом почему-то в готовом виде нигде не описанных возможностей на микроконтроллерах Cortex-M (всех) — это возможность проверки корректности адреса в памяти. С её помощью можно определять размеры флэша, ОЗУ и EEPROM, определять наличие на конкретном процессоре конкретной периферии и регистров, прибивать упавшие процессы при сохранении общей работоспособности ОС и т.п.

В штатном режиме при попадании в несуществующий адрес на Cortex-M3/M4/M7 вызывается исключение BusFault, а при отсутствии его обработчика эскалируется до HardFault. На Cortex-M0 «детализированных» исключений (MemFault, BusFault, UsageFault) нет, а любые сбои сразу эскалируются до HardFault.

В общем случае игнорировать HardFault нельзя — он может быть следствием аппаратного сбоя, например, и дальнейшее поведение устройства станет непредсказуемым. Но в частном случае это делать можно и нужно.

Cortex-M3 и Cortex-M4: неслучившийся BusFault


На Cortex-M3 и выше проверка валидности адреса делается достаточно просто — надо запретить все исключения (кроме, очевидно, немаскируемых) через регистр FAULTMASK, отключить конкретно обработку BusFault, а потом ткнуть в проверяемый адрес и посмотреть, не взвёлся ли флажок BFARVALID в регистре BFAR, то есть Bus Fault Address Register. Если взвёлся — у вас только что был BusFault, т.е. адрес некорректный.

Код выглядит так, все дефайны и функции из стандартного (не вендорского) CMSIS, так что должен работать на любом M3, M4 или M7:

bool cpu_check_address(volatile const char *address)
{
    /* Cortex-M3, Cortex-M4, Cortex-M4F, Cortex-M7 are supported */
    static const uint32_t BFARVALID_MASK = (0x80 << SCB_CFSR_BUSFAULTSR_Pos);
    bool is_valid = true;

    /* Clear BFARVALID flag by writing 1 to it */
    SCB->CFSR |= BFARVALID_MASK;

    /* Ignore BusFault by enabling BFHFNMIGN and disabling interrupts */
    uint32_t mask = __get_FAULTMASK();
    __disable_fault_irq();
    SCB->CCR |= SCB_CCR_BFHFNMIGN_Msk;

    /* probe address in question */
    *address;

    /* Check BFARVALID flag */
    if ((SCB->CFSR & BFARVALID_MASK) != 0)
    {
        /* Bus Fault occured reading the address */
        is_valid = false;
    }

    /* Reenable BusFault by clearing  BFHFNMIGN */
    SCB->CCR &= ~SCB_CCR_BFHFNMIGN_Msk;
    __set_FAULTMASK(mask);

    return is_valid;
}

Cortex-M0 и Cortex-M0+


С Cortex-M0 и Cortex-M0+ всё сложнее, как я сказал выше, у них нет BusFault и всех соответствующих регистров, а исключения сразу эскалируются до HardFault. Поэтому выход один — сделать так, чтобы обработчик HardFault смог понять, что исключение было вызвано преднамеренно, и вернуться обратно в вызвавшую его функцию, передав туда некий флажок, указывающий, что HardFault был.

Делается это сугубо на ассемблере. В примере ниже регистр R5 устанавливается в 1, а в регистры R1 и R2 записываются два «магических числа». Если после попытки загрузить значение по проверяемому адресу случится HardFault, то он должен проверить значения R1 и R2, и при обнаружении в них нужных чисел установить R5 в ноль. В сишный код значение R5 передаётся через специальную переменную, жёстко привязанную к этому регистру, в ассемблер проверяемый адрес — в неявной форме, мы просто знаем, что в arm-none-eabi первый параметр функции кладётся в R0.

bool cpu_check_address(volatile const char *address)
{
    /* Cortex-M0 doesn't have BusFault so we need to catch HardFault */
    (void)address;
    
    /* R5 will be set to 0 by HardFault handler */
    /* to indicate HardFault has occured */
    register uint32_t result __asm("r5");

    __asm__ volatile (
        "ldr  r5, =1            \n" /* set default R5 value */
        "ldr  r1, =0xDEADF00D   \n" /* set magic number     */
        "ldr  r2, =0xCAFEBABE   \n" /* 2nd magic to be sure */
        "ldrb r3, [r0]          \n" /* probe address        */
    );

    return result;
}

Код обработчика HardFault в простейшем виде выглядит так:

__attribute__((naked)) void hard_fault_default(void)
{
    /* Get stack pointer where exception stack frame lies */
    __asm__ volatile
    (
        /* decide if we need MSP or PSP stack */
        "movs r0, #4                        \n" /* r0 = 0x4                   */
        "mov r2, lr                         \n" /* r2 = lr                    */
        "tst r2, r0                         \n" /* if(lr & 0x4)               */
        "bne use_psp                        \n" /* {                          */
        "mrs r0, msp                        \n" /*   r0 = msp                 */
        "b out                              \n" /* }                          */
        " use_psp:                          \n" /* else {                     */
        "mrs r0, psp                        \n" /*   r0 = psp                 */
        " out:                              \n" /* }                          */

        /* catch intended HardFaults on Cortex-M0 to probe memory addresses */
        "ldr     r1, [r0, #0x04]            \n" /* read R1 from the stack        */
        "ldr     r2, =0xDEADF00D            \n" /* magic number to be found      */
        "cmp     r1, r2                     \n" /* compare with the magic number */
        "bne     regular_handler            \n" /* no magic -> handle as usual   */
        "ldr     r1, [r0, #0x08]            \n" /* read R2 from the stack        */
        "ldr     r2, =0xCAFEBABE            \n" /* 2nd magic number to be found  */
        "cmp     r1, r2                     \n" /* compare with 2nd magic number */
        "bne     regular_handler            \n" /* no magic -> handle as usual   */
        "ldr     r1, [r0, #0x18]            \n" /* read PC from the stack        */
        "add     r1, r1, #2                 \n" /* move to the next instruction  */
        "str     r1, [r0, #0x18]            \n" /* modify PC in the stack        */
        "ldr     r5, =0                     \n" /* set R5 to indicate HardFault  */
        "bx      lr                         \n" /* exit the exception handler    */
        " regular_handler:                  \n"

        /* here comes the rest of the fucking owl */
    )

В момент ухода в обработчик исключения Cortex скидывает регистры, которые гарантированно будут испорчены обработчиком (R0-R3, R12, LR, PC…), в стек. Первый фрагмент — он уже есть в большинстве готовых обработчиков HardFault, кроме написанных под чистый bare metal — определяет, в какой именно стек: при работе в ОС это может быть либо MSP, либо PSP, и у них разные адреса. В bare metal проектах обычно априори устанавливается стек MSP (Main Stack Pointer), без проверки — ибо PSP (Process Stack Pointer) там быть не может в силу отсутствия процессов.

Определив нужный стек и положив его адрес в R0, мы читаем из него значения R1 (смещение 0x04) и R2 (смещение 0x08), сравниваем с магическими словами, если оба совпадают — читаем из стека значение PC (смещение 0x18), добавляем к нему 2 (2 байта — размер инструкции на Cortex-M*) и сохраняем обратно в стек. Если этого не сделать, при возвращении из обработчика мы окажемся на той же инструкции, которая собственно и вызвала исключение, и будем вечно бегать по кругу. Добавление 2 перемещает нас на следующую инструкцию в момент возвращения.

* Upd. В комментариях возник вопрос о размере инструкций на Cortex-M, вынесу правильный ответ сюда: в данном случае краш вызывает инструкция LDRB, которая в архитектуре ARMv7-M присутствует в двух вариантах — 16-битном и 32-битном. Второй вариант будет выбран, если выполнено хотя бы одно из условий:

  • автор явно указал инструкцию LDRB.W вместо LDRB (у нас — нет)
  • используются регистры выше R7 (у нас — R0 и R3)
  • указано смещение величиной больше 31 байта (у нас смещения нет)


Во всех остальных случаях (т.е. когда операнды подходят под формат 16-битной версии инструкции) ассемблер обязан выбрать 16-битный вариант.

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


Далее пишем 0 в R5, что служит индикатором попадания в HardFault. Регистры после R3 до специальных регистров в стек не сохраняются и при выходе из обработчика никак не восстанавливаются, поэтому портить или не портить их — на нашей совести. В данном случае R5 с 1 на 0 мы меняем целенаправленно.

Возвращение из обработчика прерывания делается строго одним способом. При входе в обработчик в регистр LR пишется специальное значение под названием EXC_RETURN, которое для выхода из обработчика надо записать в PC — и не просто записать, а сделать это командной POP или BX (то есть «mov pc, lr», например, не работает, хотя по первому разу вам может показаться, что работает). BX LR выглядит как попытка перехода по бессмысленному адресу (в LR будет лежать что-то вида 0xFFFFFFF1, не имеющее никакого отношения к реальном адресу процедуры, в которую нам надо вернуться), но в реальности процессор, увидев это значение в PC (куда оно ляжет автоматически), сам восстановит регистры из стека и продолжит выполнять нашу процедуру — со следующей после вызвавшей HardFault процедуры благодаря тому, что PC в этом стеке мы руками увеличили на 2.

Прочитать про все смещения и команды можно понятно где, разумеется.

Ну или если магических чисел не видно, то всё перейдёт к regular_handler, после которого идёт обычная процедура обработки HardFault — как правило, это сишная функция, печатающая в консоль значения регистров, решающая, что дальше делать с процессором и т.п.

Определение размера ОЗУ


Использование всего этого просто и понятно. Хотим написать прошивку, которая работает на нескольких микроконтроллерах с разным объёмом ОЗУ, при этом каждый раз используя ОЗУ по полной программе?

Да легко:

static uint32_t cpu_find_memory_size(char *base, uint32_t block, uint32_t maxsize) {
    char *address = base;
    do {
        address += block;
        if (!cpu_check_address(address)) {
            break;
        }
    } while ((uint32_t)(address - base) < maxsize);

    return (uint32_t)(address - base);
}

uint32_t get_cpu_ram_size(void) {
    return cpu_find_memory_size((char *)SRAM_BASE, 4096, 80*1024);
}

maxsize здесь нужен затем, что на максимально возможном объёме ОЗУ между ним и следующим блоком адресов может не быть зазора, на котором сломается cpu_check_address. В данном примере это 80 КБ. Все адреса прощупывать также нет смысла — достаточно по даташиту посмотреть, каков минимально возможный шаг между двумя моделями контроллера, и поставить его в качестве block.

Программный переход в загрузчик, расположенный неизвестно где


Иногда можно делать и более заковыристые трюки — например, представьте, что вам хочется программно прыгнуть на штатный фабричный бутлоадер STM32, чтобы перейти в режим обновления прошивки по UART или USB, не утруждая себя написанием своего бутлоадера.

Бутлоадер у STM32 лежит в области под названием System Memory, на которую и надо перейти, но есть одна проблема — у этой области разные адреса не то что на разных сериях процессоров, а на разных моделях одной серии (с эпической табличкой можно ознакомиться в AN2606 на страницах с 22 по 26). При внесении же соответствующего функционала в платформу вообще, а не просто в конкретное изделие, хочется универсальности.

В файлах CMSIS адрес начала System Memory также отсутствует. Определить его по Bootloader ID не представляется возможным, т.к. это проблема курицы и яйца — Bootloader ID лежит в последнем байте System Memory, что возвращает нас к вопросу об адресе.

Однако, если мы посмотрим на карту памяти STM32, то увидим примерно такую картину:


Нас в данном случае интересует окружение System Memory — например, сверху лежит однократно программируемая область (есть не во всех STM32) и Option bytes (есть во всех). Эта структура наблюдается не то что в разных моделях, а в разных линейках STM32, с разницей лишь в наличии OTP и наличии зазора в адресах между системной памятью и опциями.

Но для нас в данном случае важнее всего то, что адрес начала Option Bytes есть в штатных заголовках CMSIS — он там называется OB_BASE.

Дальнейшее просто. Пишем функцию поиска первого валидного или невалидного адреса вниз или вверх от указанного:

char *cpu_find_next_valid_address(char *start, char *stop, bool valid) {
    char *address = start;
    while (true) {       
        if (address == stop) {
            return NULL;
        }
        
        if (cpu_check_address(address) == valid) {
            return address;
        }
        
        if (stop > start) {
            address++;
        } else {
            address--;
        }
    };

    return NULL;
}

И ищем вниз от Option bytes сначала конец то ли системной памяти, то ли примыкающих к ней OTP, а потом и начало системной памяти — в два захода:

/* System memory is the valid area next _below_ Option bytes */
char *a, *b, *c;
a = (char *)(OB_BASE - 1);
b = 0;

/* Here we have System memory top address */
c = cpu_find_next_valid_address(a, b, true);

/* Here we have System memory bottom address */
c = cpu_find_next_valid_address(c, b, false) + 1;

И без особого труда оформляем это в функцию, находящую начало системной памяти и прыгающую на него, то есть — запускающую бутлоадер:

static void jump_to_bootloader(void) __attribute__ ((noreturn));

/* Sets up and jumps to the bootloader */
static void jump_to_bootloader(void) {
    /* System memory is the valid area next _below_ Option bytes */
    char *a, *b, *c;
    a = (char *)(OB_BASE - 1);
    b = 0;
    
    /* Here we have System memory top address */
    c = cpu_find_next_valid_address(a, b, true);
    
    /* Here we have System memory bottom address */
    c = cpu_find_next_valid_address(c, b, false) + 1;
    
    if (!c) {
        NVIC_SystemReset();
    }
    
    uint32_t boot_addr = (uint32_t)c;
    
    uint32_t boot_stack_ptr = *(uint32_t*)(boot_addr);
    uint32_t dfu_reset_addr = *(uint32_t*)(boot_addr+4);

    void (*dfu_bootloader)(void) = (void (*))(dfu_reset_addr);

    /* Reset the stack pointer */
    __set_MSP(boot_stack_ptr);

    dfu_bootloader();
    while (1);
}

От конкретной модели процессора тут зависит… да ничего не зависит. Логика не будет работать на моделях, у которых между OTP и системной памятью есть дырка — но я не проверял, есть ли такие вообще. Будете активно работать с OTP — проверьте.

Прочие же хитрости относятся лишь к обычной процедуре вызова бутлоадера из вашего кода — не забудьте сброcить указатель стека и вызывайте процедуру ухода в бутлоадер до того, как инициализируете в процессоре периферию, тактовые частоты и т.д.: в силу своего минимализма бутлоадер может забивать на инициализацию периферии и ожидать, что она находится в состоянии по умолчанию. Неплохим вариантом вызова бутлодера из произвольного места вашей программы является запись в RTC Backup Register или просто в известный адрес в памяти магического числа, программная перезагрузка и проверка на первых этапах инициализации этого числа.

P.S. Так как все адреса в карте памяти процессора выровнены в худшем случае по 4, описанную выше процедуру кратно ускорит идея шагать по ним с шагом 4 байта вместо одного.

Важное замечание


N.B.: обратите внимание, что на конкретном контроллере валидность конкретного адреса не обязательно говорит о фактическом присутствии функционала, который по этому адресу может располагаться. Например, адрес регистра, управляющего каким-то опциональным периферийным блоком, может быть валиден, хотя сам блок в данной модели отсутствует. Со стороны производителя здесь возможны самые интересные подвохи, обычно уходящие корнями в использование для разных моделей процессоров одних и тех же кристаллов. Тем не менее, в большинстве случаев указанные процедуры работают и оказываются весьма полезны.




К сожалению, не доступен сервер mySQL