ARM-ы для самых маленьких

farcaller 8 августа 2013 в 20:50 145k


Пару дней назад я опубликовал и потом внезапно убрал в черновики статью о плане написать про создание своей ОС для архитектуры ARM. Я сделал это, потому что получил много интересных отзывов как на Хабре, так и в G+.

Сегодня я попробую подойти к вопросу с другой стороны, я буду рассказывать о том, как программировать микроконтроллеры ARM на нарастающих по сложности примерах, пока мы не напишем свою ОС или пока мне не надоест. А может, мы перепрыгнем на ковыряние в Contiki, TinyOS, ChibiOS или FreeRTOS, кто знает, их там столько много разных и интересных (а у TinyOS еще и свой язык программирования!).

Итак, почему ARM? Возиться с 8-битными микроконтроллерами хотя и интересно, но скоро надоедает. Кроме того, средства разработки под ARM обкатаны долгим опытом и намного приятнее в работе. При этом, начать мигать светодиодами на каком-то «evaluation board» так же просто, как и на Arduino.



Небольшой экскурс в архитектуру


ARM продвигает замечательную архитектуру, которую успешно лицензирует, мне на самом деле сложно представить, в каком устройстве нет никакого присутствия продуктов этой компании. В вашем смартфоне гарантированно есть несколько ядер на базе архитектуры ARM. Еще парочка найдется в современном ноутбуке (и это даже не CPU, а так, сопутствующий контроллер какой-либо периферии), еще несколько – в автомобиле. Есть они и в других бытовых вещах: микроволновках и телевизорах.

Такая гибкость достигается тем, что в самом базовом варианте ядро ARM очень простое. Сейчас существуют три разновидности этой архитектуры. Application применяется в устройствах «общего назначения» – как основной процессор в смартфоне или нетбуке. Этот профиль самый навороченный функционально, тут есть и полноценный MMU (модуль управления памятью), возможность аппаратно выполнять инструкции Java bytecode и даже поддержка DRM-схем. Microcontroller – это полная противоположность профилю application, применяемая (внезапно!) для использования в микроконтроллерах. Тут актуально минимальное энергопотребление и детерминистическое поведение. И, наконец, real-time используется как эволюция профиля microcontroller для задач, где критично иметь гарантированное время отклика. Все эти профили получили реализацию в одном или нескольких ядрах Cortex, так, например, Cortex-A9 основан на профиле application и является частью процессора в iPhone 4S, а Cortex-M0 основан на профиле microcontroller.

Железки!



В качестве целевой платформы мы будем рассматривать работу с Cortex-M, так как она самая простая, соответственно, надо вникать в меньшее количество вопросов. В качестве тестовых устройств я предлагаю вам LPC1114 – MCU производства NXP, схему на котором можно собрать буквально на коленке (нет, правда, вам нужен только сам MCU, FTDI-кабель на 3,3 В, несколько светодиодов и резисторов). LPC1114 построен на базе Cortex-M0, так что это будет самый урезанный вариант платформы.



В качестве альтернативного варианта мы будем работать с платформой mbed, а конкретно, с моделью на базе LPC1768 (а значит, внутри там Cortex-M3, несколько более навороченный). Вариант уже не настолько бюджетный, но процесс заливки бинарников на чип и отладки упрощен максимально. Да и можно поиграться с самой платформой mbed (вкратце: это онлайн-IDE и библиотека, с помощью которой можно программить на уровне ардуины).

Приступим


Интересной особенностью современных ARM-ов является то, что их вполне реально программировать целиком на С, без применения ассемблерных вставок (хотя ассемблер не так уж и сложен, у Cortex-M0 всего 56 команд). Хотя некоторые команды в принципе не доступны из С, эту проблему решает CMSIS – Cortex Microcontroller Software Interface Standard. Это драйвер для процессора, который решает все основные задачи управления им.

Как же загружается процессор? Типична ситуация, когда он просто начинает выполнять команды с адреса 0x00000000. В нашем случае процессор несколько более умный, и рассчитывает на специально определенный формат данных в начале памяти, а именно – таблицу векторов прерываний:


Старт выполнения программы происходит следующим образом: процессор читает значение по адресу 0x00000000 и записывает его в SP (SP – регистр, который указывает на вершину стека), после чего читает значение по адресу 0x00000004 и записывает его в PC (PC – регистр, который указывает на текущую инструкцию + 4 байта). Таким образом начинает выполняться какой-то код пользователя, при этом у нас уже есть стек, указывающий куда-то в память (т.е., все условия для выполнения программы на С).

В качестве тестового упражнения мы будем мигать светодиодом. На mbed у нас их целых четыре, в схему с LPC1114 (далее — «доска») мы устанавливаем светодиод вручную.

Перед тем как непосредственно писать код, нам надо выяснить еще одну вещь, а именно – что где должно располагаться в памяти. Поскольку мы не работаем с какой-то «стандартной» ОС, то компилятор (вернее, компоновщик) не может узнать, где у него должен быть стек, где сам код, а где — куча. К счастью для нас, у семейства ядер Cortex стандартизированная карта памяти, что позволяет относительно просто портировать приложения между разными процессорами этой архитектуры. Работа с периферией, конечно, остается процессорозависимой.

Карта памяти для Cortex-M0 выглядит вот так:



(изображение из Cortex™-M0 Devices Generic User Guide)

У Cortex-M3 она, по сути, такая же, но несколько более детальна. Проблема тут в том, что у NXP есть свой, отдельный взгляд на этот вопрос, так что проверяем карту памяти в документации на процессор:



(изображение из LPC111x/LPC11Cxx User manual)

На самом деле, SRAM у нас начинается с 0x10000000! Вот так, одни стандарты, другие стандарты, а все равно надо тома документации листать.

Вооружившись этими знаниями, идем писать код. Для начала – таблица прерываний:

.cpu cortex-m0      /* ограничиваем ассемблер списком существующих инструкций */
.thumb

.word   _stack_base /* начало стека в самом конце памяти, стек растет вниз */
.word   main        /* Reset: с прерывания сразу прыгаем в код на С */
.word   hang        /* NMI и все остальные прерывания нас не сильно волнуют */
.word   hang        /* HardFault */
.word   hang        /* MemManage */
.word   hang        /* BusFault */
.word   hang        /* UsageFault */
.word   _boot_checksum /* Подпись загрузчика */
.word   hang        /* RESERVED */
.word   hang        /* RESERVED*/
.word   hang        /* RESERVED */
.word   hang        /* SVCall */
.word   hang        /* Debug Monitor */
.word   hang        /* RESERVED */
.word   hang        /* PendSV */
.word   hang        /* SysTick */
.word   hang        /* Внешнее прерывание 0 */
                    /* ... */

/* дальше идут остальные 32 прерывания у LPC1114 и 35 у LPC1768, но
   их нет смысла описывать, потому как мы их все равно не используем */

.thumb_func
hang:   b .         /* функция заглушка для прерываний: вечный цикл */

.global hang


Сохраним эту таблицу в boot.s. Тут, фактически, только одна ассемблерная вставка – функция hang, которая устраивает процессору бесконечный цикл. Все прерывания, кроме reset, указывают на нее, так что в случае непредвиденной ситуации процессор просто зависнет, а не пойдет выполнять непонятный участок кода.

Сама таблица должна бы быть длиннее, но на самом деле мы могли бы закончить ее еще после вектора Reset, остальные у нас не сработали бы в этом примере. Но, на всякий случай, мы заполнили таблицу почти целиком (кроме пользовательских прерываний).

Теперь напишем реализацию функции main:

#if defined(__ARM_ARCH_6M__)

/* Cortex-M0 это ARMv6-M, код для LPC1114 */

#define GPIO_DIR_REG  0x50018000  /* GPIO1DIR  задает направление для блока GPIO 1 */
#define GPIO_REG_VAL  0x50013FFC  /* GPIO1DATA задает значение для блока GPIO 1 */
#define GPIO_PIN_NO   (1<<8)      /* 8-й бит отвечает за 8-й пин */

#elif defined(__ARM_ARCH_7M__)

/* Иначе просто считаем что это LPC1768 */

#define GPIO_DIR_REG  0x2009C020  /* FIO1DIR задает направление для блока GPIO 1 */
#define GPIO_REG_VAL  0x2009C034  /* FIO1PIN задает значение для блока GPIO 1 */
#define GPIO_PIN_NO   (1<<18)     /* 18-й бит отвечает за 18-й пин */

#else

#error Unknown architecture

#endif

void wait()
{
  volatile int i=0x20000;
  while(i>0) {
    --i;
  }
}

void main()
{
  *((volatile unsigned int *)GPIO_DIR_REG) = GPIO_PIN_NO;

  while(1) {
    *((volatile unsigned int *)GPIO_REG_VAL) = GPIO_PIN_NO;
    wait();
    *((volatile unsigned int *)GPIO_REG_VAL) = 0;
    wait();
  }

  /* main() *никогда* не должен вернуться! */
}


У mbed первый светодиод подключен к порту GPIO 1.18, на доске мы подключили светодиод к GPIO 1.8. Одни и те же пины могут выполнять разные функции, эти по умолчанию работают именно как GPIO (General Purpose I/O – линии ввода/вывода общего назначения).

Код относительно прямолинеен, если держать под рукой LPC-шный User manual (один и второй). Для начала мы указываем режим работы GPIO через регистр GPIO_DIR_REG (у наших процессоров они в разных местах, да и вообще LPC1768 может работать с GPIO более эффективно), где 1 – вывод, 0 – ввод. Потом мы запускаем бесконечный цикл, в котором пишем в порт попеременно значения 0 и 1 (0 В и 3,3 В соответственно).

Функция для «паузы» у нас работает наугад, просто прокручивая относительно долгий цикл (volatile int не дает компилятору выоптимизировать этот цикл целиком).

Наконец, все это нужно правильно скомпоновать:

_stack_base = 0x10002000;
_boot_checksum = 0 - (_stack_base + main + 1 + (hang + 1) * 5);

MEMORY
{
   rom(RX)   : ORIGIN = 0x00000000, LENGTH = 0x8000
   ram(WAIL) : ORIGIN = 0x10000000, LENGTH = 0x2000
}

SECTIONS
{
   .text : { *(.text*) } > rom
   .bss  : { *(.bss*) } > ram
}


Сценарий компоновщика объясняет ему, где у нас флеш, где оперативная память, какие у них размеры (тут используются размеры для LPC1114, так как у LPC1768 всего больше, сдвиги, к счастью, идентичны). После определения карты памяти мы указываем, какие сегменты куда копировать, .text (код программы) попадает в флеш, .bss (статические переменные, которых у нас пока нет) – в память. Помимо этого мы задаем два символа, которые использовали в boot.s: _stack_base – указывает на вершину стека и _boot_checksum (спасибо Zuy за уточнение!) – записывает чексумму загрузчика. Чексумма рассчитывается по формуле: дополнительный код (2's compliment) от суммы полей выше (т.е. адреса стека, и всех прерываний до непосредственно чексуммы). Хотя утилиты для прошивки (см. далее) сами исправили бы чексумму на правильную, если бы мы прошивали бы код из самого приложения, то загрузиться снова мы бы уже не смогли.

Теперь у нас есть три файла: boot.s, main.c, mem.ld, пора это все скомпилировать и, наконец, запустить. В качестве тулчейна мы будем использовать GCC, позже, возможно, я покажу как делать то же с LLVM. Пользователям OS X я советую взять тулчейн у Linaro – в самом конце списка: Bare-Metal GCC ARM Embedded. Пользователям других ОС я советую взять тулчейн там же :-) (разве что гентушникам будет проще сэмержить crossdev и скомпилить GCC).

arm-none-eabi-as boot.s -o boot.o
arm-none-eabi-gcc -O2 -nostdlib -nostartfiles -ffreestanding -Wall -mthumb -mcpu=cortex-m0 -c main.c -o main-c0.o
arm-none-eabi-gcc -O2 -nostdlib -nostartfiles -ffreestanding -Wall -mthumb -mcpu=cortex-m3 -c main.c -o main-c3.o
arm-none-eabi-ld -o blink-c0.elf -T mem.ld boot.o main-c0.o
arm-none-eabi-ld -o blink-c3.elf -T mem.ld boot.o main-c3.o
arm-none-eabi-objdump -D blink-c0.elf > blink-c0.lst
arm-none-eabi-objdump -D blink-c3.elf > blink-c3.lst
arm-none-eabi-objcopy blink-c0.elf blink-c0.bin -O binary
arm-none-eabi-objcopy blink-c3.elf blink-c3.bin -O binary


Интересный момент тут — это отключение использования всех стандартных библиотек у GCC. Действительно, весь код, который попадет в итоговый бинарник – это код, который написали мы сами.

Вопрос: как компоновщик знает, куда надо засунуть таблицу прерываний? А он и не знает, там не написано :-). Он просто линкует подряд, начиная с нулевого адреса, так что порядок файлов (boot.o, потом main-c0.o) очень важен! Попробуйте слинковать наоборот или слинковать boot.o два раза и сравните вывод в lst-файле.

Хорошая идея – посмотреть на итоговый листинг (файл lst) или закинуть бинарник в дизассемблер. Даже если вы не говорите на ARM UAL, то чисто визуально можно проверить, что хотя бы таблица прерываний находится на своем месте:




Еще можно обратить внимание на забавный момент – GCC при компиляции под Cortex-M3 генерирует функцию wait() больше, чем в варианте под Cortex-M0. Правда, если включить оптимизацию то она вправит ему мозги.

Мигаем!


Все что нам осталось – залить бинарники на наши тестовые платформы. С mbed тут все максимально просто, просто скопируйте blink-c3.bin на виртуальную флешку и нажмите reset (на mbed). С доской все немного сложнее. Во-первых, для того, чтобы попасть в загрузчик, нам нужен резистор между GND и GPIO 0.1. Во-вторых, необходима программа для непосредственно прошивки. Можно использовать Flash Magic (Win, OS X), можно использовать консольную утилиту – lpc21isp:

lpc21isp.out -verify -bin /path/to/blink-c0.bin /dev/ftdi/tty/device 115200 12000


Процесс прошивки следующий:
  • ставим резистор между j5 и j7 (10 кОм подойдет);
  • нажимаем reset;
  • запускаем lpc21isp;
  • снимаем резистор;
  • нажимаем reset еще раз – запускается приложение.


Если у вас есть возможность запустить примеры на разных устройствах, вы заметите, что скорость мигания на них не идентична. Это связанно с тем, что у разных устройств разная частота ядра, соответственно, wait() они выполняют за разное время. В следующей части мы изучим вопросы осцилляции детальнее и сделаем четкий отсчет времени.

P.S. Отдельное спасибо хабраюзеру pfactum за то, что тратит время на исправление моих ошибок в тексте :-).

P.P.S. Просьба тем, у кого есть тестовая платформа на базе ARM – пишите в комментариях – какая. Я могу пересмотреть аппаратную базу для дальнейших статей.
Проголосовать:
+119
Сохранить: