Нашел возможность «добить» цикл еще одной статьей, где я подведу небольшой итог. По сути, только сейчас мы добрались до того, с чего, обычно, начинают программировать:
- рассматриваем «сложный» сценарий компоновки GNU ld;
- учимся использовать прерывания;
- наконец добираемся до hello world!
Предыдущие статьи цикла:
- ARM-ы для самых маленьких
- ARM-ы для самых маленьких: который час?
- ARM-ы для самых маленьких: тонкости компиляции и компоновщик
Примеры кода из статьи: 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 «Attribution-NonCommercial-NoDerivs» 3.0 Unported. Программный текст примеров доступен по лицензии Unlicense (если иное явно не указано в заголовках файлов). Это произведение написано исключительно в образовательных целях и никаким образом не аффилировано с текущим или предыдущими работодателями автора.
Автор: farcaller