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

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

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

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

На 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 */
    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+ всё сложнее, как я сказал выше, у них нет 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 перемещает нас на следующую инструкцию в момент возвращения.

Далее пишем 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.

Не благодарите.

Эта заметка в LiveJournal. Текст тот же, но часто там бывает много комментариев.