Пару дней назад я опубликовал и потом внезапно убрал в черновики статью о плане написать про создание своей ОС для архитектуры 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 всего ХХХ команд). Хотя некоторые команды в принципе не доступны из С, эту проблему решает 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 0x10002000 /* начало стека в самом конце памяти, стек растет вниз */
.word _main /* Reset: с прерывания сразу прыгаем в код на С */
.word hang /* NMI и все остальные прерывания нас не сильно волнуют */
.word hang /* HardFault */
.word hang /* MemManage */
.word hang /* BusFault */
.word hang /* UsageFault */
.word hang /* RESERVED */
.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 . /* функция-заглушка для прерываний: вечный цикл */
Сохраним эту таблицу в 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-й пин */
#else
/* Иначе просто считаем что это 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-й пин */
#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
не дает компилятору выоптимизировать этот цикл целиком).
Наконец, все это нужно правильно скомпоновать:
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, 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 за то, что тратит время на исправление моих ошибок в тексте :-).
Автор: farcaller