23 September 2013

ARM-ы для самых маленьких: компоновка-2, прерывания и hello world!

AssemblerSystem Programming
Tutorial


Нашел возможность «добить» цикл еще одной статьей, где я подведу небольшой итог. По сути, только сейчас мы добрались до того, с чего, обычно, начинают программировать:
  • рассматриваем «сложный» сценарий компоновки GNU ld;
  • учимся использовать прерывания;
  • наконец добираемся до hello world!


Предыдущие статьи цикла:


Примеры кода из статьи: https://github.com/farcaller/arm-demos



В прошлый раз мы выяснили, с какими секциями мы можем столкнуться в скомпонованном приложении и какое их типичное содержимое. В частности, мы разобрались с .data и .bss. Напомню, что в .data хранятся глобальные (статические) переменные со значением, заданным при компиляции. Эту секцию надо скопировать из флеш-памяти в оперативную. В .bss хранятся глобальные переменные с нулевым значением, ее надо обнулить.

В типичных условиях этим занимаются процедуры из crt0.a (википедия подсказывает, что это название означает C RunTime 0, где 0 подразумевает самое-самое начало жизнедеятельности приложения). Сегодня мы напишем аналог crt0 для наших игрушечных платформ.

Disclaimer. В GNU ld много одинаковых вещей можно делать разными путями, используя вариации синтаксиса и флаги компоновки. Все нижеописанные методы — плод моей фантазии, написанный под влиянием сценариев компоновки из LPCXpresso. Если вы знаете более эффективный метод решения какой-либо описанной ситуации, напишите мне!

Инициализация данных в памяти


Ознакомьтесь с файлом 04-helloworld/platform/protoboard/layout.ld. В целом, тут нет существенных изменений относительно предыдущей версии: несколько констант, описание памяти, секции. Давайте рассмотрим секцию .data для примера:
.data : ALIGN(4)
{
    _data = .;

    *(SORT_BY_ALIGNMENT(.data*))
    . = ALIGN(4);

    _edata = .;
} > ram AT>rom = 0xff


В выходной файл записывается секция .data с выравниванием в 4 байта (т.е., если перед этой секцией курсор указывает на адрес 0x00000101, то .data начнется со 0x00000104). Секция находится в оперативной памяти (> ram), но загружается из флеш-памяти (AT>rom).

Конструкция =0xff задает шаблон «заливки». Если в выходной секции образуются неадресованные байты, их значение будет установлено в значение байта-заполнителя. 0xff выбрано по той причине, что стертая флеш-память — это все единицы, т.е., запись 0xff (в отличие от 0x00, например) — это пустая операция.

Далее, в _data сохраняется текущее положение курсора. Поскольку секция находится в оперативной памяти, то _data будет указывать на самое ее начало, в данном случае: 0x10000000.

Поочередно в секцию копируются все исходные секции с именами, начинающимися на .data, из всех входных файлов, при этом сортируя их по размеру. Сортировка играет очень важную роль, рассмотрим ее на примере:
uint16_t static_int = 0xab;
uint8_t  static_int2 = 0xab;
uint16_t static_int3 = 0xab;
uint8_t  static_int4 = 0xab;


Здесь определены четыре переменные для секции .data. Что же попадает в итоговый файл?
.data           0x0000000010000000        0xc load address 0x00000000000007b0
                0x0000000010000000                _data = .
 *(.data*)
 .data.static_int2
                0x0000000010000000        0x1 build/d0f0154f60ed1a9c2083183e7c731846451d2bdb_helloworld.o
                0x0000000010000000                static_int2
 *fill*         0x0000000010000001        0x3 ff
 .data.static_int3
                0x0000000010000004        0x4 build/d0f0154f60ed1a9c2083183e7c731846451d2bdb_helloworld.o
                0x0000000010000004                static_int3
 .data.static_int4
                0x0000000010000008        0x1 build/d0f0154f60ed1a9c2083183e7c731846451d2bdb_helloworld.o
                0x0000000010000008                static_int4
 *fill*         0x0000000010000009        0x1 ff
 .data.static_int
                0x000000001000000a        0x2 build/d0f0154f60ed1a9c2083183e7c731846451d2bdb_helloworld.o
                0x000000001000000a                static_int
                0x000000001000000c                . = ALIGN (0x4)
                0x000000001000000c                _edata = .

Обратите внимание на *fill*-байты, которые выравнивают переменные по границе слов. Из-за неудачного порядка мы потеряли 4 байта просто так. Повторим операцию, на этот раз используя SORT_BY_ALIGNMENT:
.data           0x0000000010000000        0x8 load address 0x00000000000007b0
                0x0000000010000000                _data = .
 *(SORT(.data*))
 .data.static_int3
                0x0000000010000000        0x4 build/d0f0154f60ed1a9c2083183e7c731846451d2bdb_helloworld.o
                0x0000000010000000                static_int3
 .data.static_int
                0x0000000010000004        0x2 build/d0f0154f60ed1a9c2083183e7c731846451d2bdb_helloworld.o
                0x0000000010000004                static_int
 .data.static_int2
                0x0000000010000006        0x1 build/d0f0154f60ed1a9c2083183e7c731846451d2bdb_helloworld.o
                0x0000000010000006                static_int2
 .data.static_int4
                0x0000000010000007        0x1 build/d0f0154f60ed1a9c2083183e7c731846451d2bdb_helloworld.o
                0x0000000010000007                static_int4
                0x0000000010000008                . = ALIGN (0x4)
                0x0000000010000008                _edata = .

Переменные аккуратно отсортированы, и мы сэкономили кучу (33%) памяти!

Вернемся к курсору, который сейчас указывает сразу на окончание всех .data. Конструкция . = ALIGN(4) выравнивает курсор (в том случае, если данных во входных секциях недостаточно для полного выравнивания) по границе слова. Окончательное значение записывается в _edata.

Помимо адресов в памяти, нам надо знать, где секция находится в флеш-памяти, для этого в начале сценария объявлен символ: _data_load = LOADADDR(.data). LOADADDR – функция, которая возвращает адрес загрузки секции. Помимо нее есть еще несколько интересных функций: ADDR возвращает «виртуальный» адрес, SIZEOF — размер секции в байтах.

Взглянем на код инициализации секции .data, 04-hello-world/platform/common/platform.c:
uint32_t *load_addr = &_data_load;

for (uint32_t *mem_addr = &_data; mem_addr < &_edata;) {
    *mem_addr++ = *load_addr++;
}

В цикле мы копируем значения из load_addr в mem_addr.

Типично эта инициализация проводится максимально рано, по возможности — как одна из самых первых задач. Этому есть вполне разумное объяснение: до инициализации доступ к глобальным переменным из С будет возвращать «мусор». В нашем случае инициализация проводится уже после вызова platform_init, поскольку эта функция не зависит от данных в .data/.bss, а ее выполнение позволит выполнить последующий код быстрее, что, в итоге, даст прирост производительности. Минусом стало появление отдельной platform_init_post, где таки инициализируется глобальная переменная значением частоты системной шины.

Последняя секция — /DISCARD/ — является специальной, это своего рода /dev/null компоновщика. Все входящие секции будут просто выброшены (как вы помните, если секция не указана явно, она будет автоматически добавлена в подходящую область памяти). Эта секция описана больше для наглядности, так как входные секции в случае с ARMv6-M0 гарантированно будут пустыми.

О разных прерываниях


Обратите свое внимание на несколько видоизмененную первую секцию .text, куда попадают две новые: .isr_vector и .isr_vector_nvic. Обе обернуты в инструкцию KEEP, что не дает компоновщику «выоптимизировать» их за ненадобностью. .isr_vector содержит общую для Cortex-M таблицу прерываний, которую можно изучить в файле platform/common/isr.c:

__attribute__ ((weak)) void isr_nmi();
__attribute__ ((weak)) void isr_hardfault();
__attribute__ ((weak)) void isr_svcall();
__attribute__ ((weak)) void isr_pendsv();
__attribute__ ((weak)) void isr_systick();

__attribute__ ((section(".isr_vector")))
void (* const isr_vector_table[])(void) = {
    &_stack_base,
    main,             // Reset
    isr_nmi,          // NMI
    isr_hardfault,    // Hard Fault
    0,                // CM3 Memory Management Fault
    0,                // CM3 Bus Fault
    0,                // CM3 Usage Fault
    &_boot_checksum,  // NXP Checksum code
    0,                // Reserved
    0,                // Reserved
    0,                // Reserved
    isr_svcall,       // SVCall
    0,                // Reserved for debug
    0,                // Reserved
    isr_pendsv,       // PendSV
    isr_systick,      // SysTick
};


Как видите, мы отошли от объявления таблицы в ассемблерном файле и описываем ее в терминологии С. Также были введены независимые обработчики прерываний (вместо одного общего hang). Все эти обработчики по умолчанию выполняют бесконечный цикл (хотя в isr_hardfault я пару раз подсовывал отладочный светодиод, пока писал примеры к статье), но, так как они объявлены с атрибутом weak, то их можно переопределить в любом другом файле. Например, в timer.c есть своя реализация isr_systick, которая и попадет в итоговый образ.

Продолжение таблицы вынесено в аналогичную структуру isr_vector_table_nvic, так как оно уже зависит от конкретного процессора, но суть остается та же.

И о прерываниях


Скажем немного больше о прерываниях. Общая суть прерываний — вызов обработчика как реакция на какие-либо внешние события (относительно кода, который выполняется в момент события). Приятная особенность Cortex-M: процессор сам упакует/распакует значения регистров, так что прерывания можно писать как обычные функции на С. Более того, вложенность прерываний также будет отработана автоматически.

NVIC — вложенный векторный контроллер прерываний обрабатывает прерывания от периферии за ядром ARM. Он позволяет выставить разным прерываниям разные приоритеты, централизованно их отключить или сгенерировать прерывание программно.

Посмотрим на новую реализацию таймера на базе systick:
static volatile uint32_t systick_10ms_ticks = 0;

void platform_delay(uint32_t msec)
{
    uint32_t tenms = msec / 10;
    uint32_t dest_time = systick_10ms_ticks + tenms;
    while(systick_10ms_ticks < dest_time) {
        __WFI();
    }
}

// override isr_systick from isr.c
void isr_systick(void)
{
    ++systick_10ms_ticks;
}

Цикл ожидания переводит процессор в режим ожидания прерывания (спящий режим), пока системный счетчик не превысит необходимое значение. При этом каждые 10 мс SysTick переполняется и генерирует прерывание, по которому isr_systick увеличивает счетчик на 1. Обратите внимание на то, что systick_10ms_ticks объявлена как volatile, это дает компилятору понять, что значение этой переменной может (и будет) изменяться вне текущего контекста, и ее следует каждый раз перечитывать заново из оперативной памяти (где ее будет менять обработчик прерывания).

libgcc


В этом коде мы впервые используем операцию деления. Казалось бы, что тут сложного, но в Cortex-M0 нет аппаратной инструкции для деления :-). Компилятор знает об этом, и вместо инструкции деления вставляет вызов функции __aeabi_uidiv, которая делит числа программно. Эта функция (и еще несколько аналогичных) реализованы в библиотеке поддержки компилятора: libgcc.a. К сожалению, наш компоновщик ничего о ней не знает, и мы натыкаемся на неприятную ошибку:
build/5a3e7023bbfde5552a4ea7cc57c4520e0e458a53_timer.o: In function `platform_delay':
timer.c:(.text.platform_delay+0x4): undefined reference to `__aeabi_uidiv'

Правильное решение — заменить вызов компоновщика непосредственно на вызов gcc, который уже разберется, что куда надо линковать. Правда, gcc может несколько переусердствовать, так что мы сообщаем ему через -nostartfiles, что инициализационный код у нас свой, и через -ffreestanding, что приложение у нас самостоятельное и ни от каких ОС не зависит.

Наконец, hello habr!


Эта версия несколько знаменательная, так как в ней есть драйвер UART, что означает, что мы увидим реальную работу нашего кода не только по мигающему светодиоду. Но сначала драйвер:
platform/protoboard/uart.c
extern uint32_t platform_clock;

void platform_uart_setup(uint32_t baud_rate)
{
    NVIC_DisableIRQ(UART_IRQn);
В первую очередь, мы выключим прерывание на NVIC в случае, если оно было включено.
    LPC_SYSCON->SYSAHBCLKCTRL |= (1<<16);

    LPC_IOCON->PIO1_6 &= ~0x07;
    LPC_IOCON->PIO1_6 |= 0x01;

    LPC_IOCON->PIO1_7 &= ~0x07;
    LPC_IOCON->PIO1_7 |= 0x01;
Далее мы включим блок микроконтроллера, отвечающий за настройку пинов, и настроим их в режим TXD/RXD UART. Этот код пролил много моей крови, когда я пытался понять, почему UART после перезагрузки не работает. Будьте внимательны, иногда очевидные вещи оказываются по умолчанию выключенными!
    LPC_SYSCON->SYSAHBCLKCTRL |= (1<<12);
    LPC_SYSCON->UARTCLKDIV = 0x1;
Теперь можно включить и сам UART, а заодно и задать входной делитель частоты.
    LPC_UART->LCR = 0x83;

    uint32_t Fdiv = platform_clock     // системная частота
            / LPC_SYSCON->SYSAHBCLKDIV // разделенная на делитель для периферии
            / LPC_SYSCON->UARTCLKDIV   // на делитель самого UART
            / 16                       // и на 16, по спеке
            / baud_rate;               // и, наконец, на бодрейт

    LPC_UART->DLM = Fdiv / 256;
    LPC_UART->DLL = Fdiv % 256;

    LPC_UART->FDR = 0x00 | (1 << 4) | 0;

    LPC_UART->LCR = 0x03;
Помимо классического режима 8N1, мы открываем доступ к выходным делителям, которые задают битрейт. Рассчитываем делители и записываем их в регистры. Для любопытных — формула в разделе 13.5.15 мануала. Кроме того, в ней описывается дополнительный делитель для еще более точного бодрейта. В моих тестах 9580 работал достаточно хорошо :-)
    LPC_UART->FCR = 0x07;

    volatile uint32_t unused = LPC_UART->LSR;

    while(( LPC_UART->LSR & (0x20|0x40)) != (0x20|0x40) )
        ;
    while( LPC_UART->LSR & 0x01 ) {
        unused = LPC_UART->RBR;
    }
Включаем FIFO, сбрасываем, убеждаемся что в регистрах не завалялись какие-то странные данные.
    // NVIC_EnableIRQ(UART_IRQn);
    // LPC_UART->IER = 0b101;
Включаем прерывания на прием (на самом деле нет). Обработчика прерываний в примере нет, так что и прерывания нам ни к чему.

Для LPC1768 код очень сильно похож, так что его я разбирать не буду. Отмечу только, что там вся периферия при загрузке включена, что упрощает ситуацию.

Важный момент: у mbed есть три UART, выведенных наружу, и несколько вариантов пинов для каждого. Поскольку общение по USB заняло бы существенно больше кода, вам придется цеплять FTDI-шнурок на UART, в примере — это пины P13/P14.

Подводя итоги


Мы разобрались с компоновщиком, у нас есть готовый костяк, на котором можно расширять базу и писать драйверы. Или вообще взять CMSIS и демо от производителя (только код все же читайте, примеры в LPCXpresso имеют опечатки разной степени печальности).

У меня хватает идей для дальнейших статей, но стало не очень хватать времени, слишком много интересных вещей еще не запрограммлены! Постараюсь, все же, возвращаться в «микромир» эмбеддедов после «макромира» офисных дней.

P.S. Как всегда, большое спасибо pfactum за вычитку текста.

Лицензия Creative Commons Это произведение доступно по лицензии Creative Commons «Attribution-NonCommercial-NoDerivs» 3.0 Unported. Программный текст примеров доступен по лицензии Unlicense (если иное явно не указано в заголовках файлов). Это произведение написано исключительно в образовательных целях и никаким образом не аффилировано с текущим или предыдущими работодателями автора.
Tags:armembedded software developmentмикроконтроллерыhello worldпрерывания
Hubs: Assembler System Programming
+55
36.5k 446
Comments 9
Ads
Top of the last 24 hours