LINUX.ORG.RU

Осваиваем STM32 снизу: часть 2 - пишем простейшую прошивку

 ,


0

1

Часть 1 Часть 2 Часть 3 Часть 4 Часть 5 Часть 6 Часть 7 Часть 8 Часть 9

Часть 2: пишем простейшую прошивку

Вообще говоря, прошивка уже была описана в первой части. Нам нужно создать такой файл, в котором будет записано некое число из четырёх байтов, которое процессор присвоит регистру sp, далее там будет записан, к примеру, адрес 0x08000131 в следующих четырёх байтах, далее будут располагаться 296 нулевых байтов (0x130 - 4 - 4 = 304 - 4 - 4 = 296), а за ними 2 инструкции по 4 байта, которые и будут что-то делать. Итого файл прошивки должен занимать 4 + 4 + 296 + 4 + 4 = 312 байтов. Содержимое этого файла мы запишем в микроконтроллер по адресу 0x08000000, где и располагается флеш-память.

Первое, что мы сделаем - напишем, собственно, код на языке ассемблера, который соберём в объектный файл:

loop.s:

.cpu cortex-m3
.syntax unified
.thumb

.global reset_exception_handler

.section code

reset_exception_handler:
add r0, r0, 1
bl reset_exception_handler

Всё, что начинается с точки - является директивами ассемблера и непосредственно в код не преобразовывается.

Первые 3 строчки это некое мумбо-юмбо, которое объяснить кратко вряд ли получится. Самая первая строчка говорит о том, что у нас код для процессора cortex-m3. Это семейство ARM-процессоров, одним из представителей которого и является процессор STM32F103. Почитайте мануал, если интересно, но кроличья нора там глубока. Проще просто запомнить.

.global reset_exception_handler говорит о том, что мы хотим экспортировать из этого файла символ с именем reset_exception_handler. Каждый объектный файл обычно экспортирует какие-то символы. Чаще всего символ можно воспринимать, как указатель.

Далее идёт директива .section code. Объектный файл это по сути набор секций, в каждой секции лежат данные. В нашем случае объектный файл будет содержать одну секцию с названием code, в которой будут лежать 2 инструкции (8 байтов). А также символ reset_exception_handler, который будет указывать на первую инструкцию (по сути он будет равен нулю).

reset_exception_handler: это тоже не код, хоть и не начинается на точку. Это метка, или символ, как угодно.

А дальше, наконец-то, идёт код, ради которого всё и затевалось. Две инструкции: инкрементировать значение в регистре r0 и перейти на предыдущую инструкцию, в начало цикла.

Для того, чтобы собрать этот код в объектный файл, используется программа as (ассемблер). А точней arm-none-eabi-as:

$ arm-none-eabi-as -o loop.o loop.s

Файл loop.o является объектным файлом в формате ELF. Его можно посмотреть с помощью программ objdump и nm:

$ arm-none-eabi-nm -g blink.o
00000000 N reset_exception_handler

$ arm-none-eabi-objdump -D blink.o

blink.o:     file format elf32-littlearm


Disassembly of section code:

00000000 <reset_exception_handler>:
   0:	f100 0001 	add.w	r0, r0, #1
   4:	f7ff fffe 	bl	0 <reset_exception_handler>

Disassembly of section .ARM.attributes:

00000000 <.ARM.attributes>:
   0:	00002041 	andeq	r2, r0, r1, asr #32
   4:	61656100 	cmnvs	r5, r0, lsl #2
   8:	01006962 	tsteq	r0, r2, ror #18
   c:	00000016 	andeq	r0, r0, r6, lsl r0
  10:	726f4305 	rsbvc	r4, pc, #335544320	@ 0x14000000
  14:	2d786574 	cfldr64cs	mvdx6, [r8, #-464]!	@ 0xfffffe30
  18:	0600334d 	streq	r3, [r0], -sp, asr #6
  1c:	094d070a 	stmdbeq	sp, {r1, r3, r8, r9, sl}^
  20:	Address 0x20 is out of bounds.

Как видно из вывода nm, в этом файле экспортируется один символ reset_exception_handler со значением 0.

С выводом objdump посложней. В файле находится две секции: code и .ARM.attributes. code это то, что мы объявили. В нём 8 байтов, которые нам любезно дизассемблировали. Секция .ARM.attributes содержит служебные сведения, которые в конечной прошивке не появятся, поэтому её можно игнорировать. objdump попытался эти сведения дизассемблировать, но на самом деле это не машинный код, а просто формат такой. objdump -D пытается всё дизассембировать, даже если это не имеет смысла.

У нас теперь есть 8 байтов нашего кода, но нужно скомпоновать всё остальное. Конечно можно в каком-нибудь hex-редакторе это сделать вручную, но вообще для этого используется компоновщик (linker, далее линкер). В составе GNU binutils имеется линкер ld, его мы и будем использовать, а точней его версию для ARM arm-none-eabi-ld.

Линкер также использует свой особый язык: linker script. По сути задача линкера состоит в следующем: он получает на вход набор объектных файлов (в нашем случае это один файл loop.o). В каждом из этих файлов есть некоторое множество секций и символов. В секциях есть какие-то данные: код, начальные значения переменных и тд. Они называются в терминах линкера входные секции (input sections). На выходе у линкера тоже объектный файл, и в нём тоже некоторый набор секций и символов: выходные секции (output sections). У нас задача простая, поэтому выходная секция будет ровно одна, с интересующими нас данными, которые будут прошиваться в микроконтроллер.

Вот такой скрипт для линкера мы будем использовать:

loop.ld:

SECTIONS {
    flash 0x08000000 : {
        LONG(0x20000000 + 20K);
        LONG(reset_exception_handler | 1);
        . = 0x130;
        loop.o(code)
    }
}

SECTIONS объявляет выходные секции. flash это название нашей выходной секции. Можно называть её как угодно. 0x0800 0000 это адрес, по которому эта секция будет располагаться в памяти. Это необходимо для того, чтобы линкер правильно подсчитал смещения. Из loop.o мы экспортируем сивол reset_exception_handler со значением 0, но на самом деле в конечной прошивке у него будет значение 0x0800 0130.

LONG(0x20000000 + 20K); запишет по первому адресу в данной секции 4-х байтовое значение, которое процессор присвоит регистру sp. В принципе по выражению очевидно, что мы ему присваиваем значение адреса сразу за окончанием адресного пространства оперативной памяти. Чуть ниже будет попытка объяснить, почему именно такое значение.

LONG(reset_exception_handler | 1); запишет по следующему адресу 4-х байтовое значение, которое представляет собой модифицированный адрес кода, который мы хотим выполнять после включения. Символ reset_exception_handler к нам пришёл из loop.o. Как было описано в первой части, этот адрес должен иметь выставленный единичный бит, поэтому мы его и выставляем с помощью операции «побитовый ИЛИ».

. = 0x130; эта команда ставит текущую позицию в выходной секции на 0x130 байтов. Вообще . это такое специальное значение, которое равно адресу, куда линкер сейчас будет что-то писать. Изначально оно равно 0, после первых 4 байтов оно равно 4, после следующих 4 байтов оно равно 8, ну а после присваивания оно равно 0x130 и последующие данные будут писаться уже с этим смещением. Почему 0x130 - тоже ниже будет объяснение.

loop.o(code) это выражение берёт входной файл loop.o, берёт в нём секцию code и копирует её содержимое в выходную секцию. Кроме того линкер делает то, ради чего его, собственно, и используют. Он понимает, что reset_exception_handler уже равен не 0, а 0x0800_0130 и в нужном месте запишет правильный адрес. Если у нас есть несколько функций в разных файлах, которые вызывают друг друга, то линкер разберётся, у какой функции какой итоговый адрес и правильно всё скомпонует. Если вы видели в других линкер скриптах выражение вроде *(.text), то это примерно то же: * это все файлы, .text это название секции, которую принято использовать для кода. Но в данном примере всё указано максимально явно и для наглядности использовано нестандартное название секции.

Линкер запускается командой:

arm-none-eabi-ld -T loop.ld -o loop.elf loop.o

Если не было допущено никаких ошибок, то у нас получится файл loop.elf. По расширению, наверное, очевидно, что это объектный файл в формате ELF (как и loop.o). Если его просмотреть с помощью nm и objdump, то можно увидеть следующее:

$ arm-none-eabi-nm loop.elf
08000130 R reset_exception_handler

$ arm-none-eabi-objdump -D loop.elf

loop.elf:     file format elf32-littlearm


Disassembly of section flash:

08000000 <reset_exception_handler-0x130>:
 8000000:	20005000 	andcs	r5, r0, r0
 8000004:	08000131 	stmdaeq	r0, {r0, r4, r5, r8}
	...

08000130 <reset_exception_handler>:
 8000130:	f100 0001 	add.w	r0, r0, #1
 8000134:	f7ff fffc 	bl	8000130 <reset_exception_handler>

Disassembly of section .ARM.attributes:

00000000 <.ARM.attributes>:
   0:	00002041 	andeq	r2, r0, r1, asr #32
   4:	61656100 	cmnvs	r5, r0, lsl #2
   8:	01006962 	tsteq	r0, r2, ror #18
   c:	00000016 	andeq	r0, r0, r6, lsl r0
  10:	726f4305 	rsbvc	r4, pc, #335544320	@ 0x14000000
  14:	2d786574 	cfldr64cs	mvdx6, [r8, #-464]!	@ 0xfffffe30
  18:	0600334d 	streq	r3, [r0], -sp, asr #6
  1c:	094d070a 	stmdbeq	sp, {r1, r3, r8, r9, sl}^
  20:	Address 0x20 is out of bounds.

Как видно, этот файл тоже экспортирует символ reset_exception_handler, но теперь уже со значением 0x0800_0130. В этом файле имеются две секции flash и .ARM.attributes. Последнюю мы так же проигнорируем, а вот в секции flash записано то, что мы и хотели получить. objdump -D пытается дизассемблировать первые 8 байтов, и у него даже что-то получается, но, конечно, это не команды, а адреса. А вот то, что начинается с адреса 0x0800_0130 это уже самый, что ни на есть, машинный код для ARM.

Но остаётся одна маленькая проблема. Как в самом начале было написано, файл прошивки должен занимать 312 байтов. А у нас вроде эти байты и есть, но они не пойми где, а весь elf файл занимает 4864 байтов, в общем не совсем то. Чтобы вытащить конечную прошивку, используется команда objcopy:

arm-none-eabi-objcopy -O binary -j flash loop.elf loop.bin

-O binary говорит о том, что мы хотим получить бинарный формат на выходе. -j flash говорит, что нас интересует только секция flash (этот флаг избыточен, когда у нас только одна не-служебная секция, но пусть будет для ясности).

Теперь посмотрим, что получилось:

ls -l loop.bin
-rwxr-xr-x. 1 vbezhenar vbezhenar 312 Sep  9 11:30 loop.bin

hexdump -C loop.bin
00000000  00 50 00 20 31 01 00 08  00 00 00 00 00 00 00 00  |.P. 1...........|
00000010  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
*
00000130  00 f1 01 00 ff f7 fc ff                           |........|
00000138

Ну, собственно, 312 ожидаемых байтов и внутри что-то, похожее на правду. В кодировке little-endian число 0x2000_5000 кодируется байтами в обратном порядке: 00 50 00 20, а число 0x0800_0131 кодируется байтами 31 01 00 08.

Пора заливать эту прошивку в микроконтроллер:

st-flash write loop.bin 0x08000000
st-flash 1.7.0
2023-09-09T11:36:28 WARN common.c: NRST is not connected
2023-09-09T11:36:28 INFO common.c: F1xx Medium-density: 20 KiB SRAM, 64 KiB flash in at least 1 KiB pages.
file loop.bin md5 checksum: 20b87b3b138d91c38b47d29d95f773b, stlink checksum: 0x0000058d
2023-09-09T11:36:28 INFO common.c: Attempting to write 312 (0x138) bytes to stm32 address: 134217728 (0x8000000)
2023-09-09T11:36:28 INFO common.c: Flash page at addr: 0x08000000 erased
2023-09-09T11:36:28 INFO common.c: Finished erasing 1 pages of 1024 (0x400) bytes
2023-09-09T11:36:28 INFO common.c: Starting Flash write for VL/F0/F3/F1_XL
2023-09-09T11:36:28 INFO flash_loader.c: Successfully loaded flash loader in sram
2023-09-09T11:36:28 INFO flash_loader.c: Clear DFSR
  1/  1 pages written
2023-09-09T11:36:28 INFO common.c: Starting verification of write complete
2023-09-09T11:36:28 INFO common.c: Flash written and verified! jolly good!

Собственно: всё. Прошивка залита в микроконтроллер, он перезагрузился и теперь крутится в вечном цикле, немножко согревая воздух. Теперь к нему можно подключиться через st-util и gdb и проверить, что там происходит, в первой части это и было описано.

Теперь пару моментов. Во-первых почему именно такое значение мы пишем в регистр sp. Вообще стек это такая структура данных, и если вдруг вы не знаете, что это такое, то проглядите википедию, прежде чем продолжать. Эта структура данных настолько важна, что в процессоре есть отдельные регистры и команды для работы с ним, т.н. аппаратный стек. Вопреки интуитивному представлению, аппаратный стек растёт «сверху вниз», или от больших адресов к меньшим. Регистр sp хранит адрес, куда было записано последнее значение. Команда push {r0} сначала уменьшает значение sp на 4, а потом записывает в память по адресу $sp значение $r0. Команда pop {r1} сначала присваивает регистру r1 значение из памяти по адресу $sp, а потом увеличивает значение регистра sp на 4. На саммом деле не обязательно устанавливать sp именно в конце, для стека можно выделить любой удобный участок оперативной памяти, но в простых программах разумно стеку отдать верхнюю часть памяти, с большими адресами, а свои переменные располагать в нижней части памяти, с меньшими адресами.

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

Во-вторых откуда взялось число 0x130, почему бы нам не расположить наш код сразу же со смещением 8. На самом деле это можно сделать и всё будет работать в данном конкретном случае. Но в общем случае так делать не нужно. В начале адресного пространства расположена т.н. таблица векторов (название странное, не ищите в нём смысл). Правильней было бы её назвать таблицей указателей на обработчики исключений. Сразу скажу, что это не исключения из C++, это исключения процессора. Некоторые исключения вызываются прерываниями, некоторые исключения вызываются по другим причинам. К примеру если вы попробуете скормить процессору какую-нибудь дичь, то вызовется исключение под названием usage fault. Когда процессор вызывает исключение, он приостанавливает текущий код (который, кстати, может обрабатывать другое исключение), находит адрес обработчика исключений в таблице векторов, проверяет, что у этого адреса младший бит выставлен в единицу и вызывает функцию с этим адресом.

Например по смещению 0x0000 000c расположен адрес обработчика исключения hard fault.

Число обработчиков исключений для разных моделей процессоров разное, для STM32F103 эту информацию можно посмотреть в Reference Manual, раздел 10.1.2, таблица 63. Там видно, что адрес последнего обработчика DMA2_Channel4_5 равен 0x0000_012C. Прибавим 4 и получим «свободную» память по адресу 0x0000_0130. В первом разделе мы выяснили, что флеш память доступна с адреса 0x0800_0000, а при загрузке с флеш-памяти она также доступна с адреса 0x0000_0000. Отсюда и взялся этот 0x0800_0130.

И напоследок давайте напишем Makefile. Команды выше, конечно, простые и в целом понятные, отрабатывают за тысячные доли секунды, но всё же для организации процесса сборки разумно использовать make.

Makefile:

loop.bin: loop.elf
	arm-none-eabi-objcopy -O binary -j flash loop.elf loop.bin

flash: loop.bin
	st-flash write loop.bin 0x08000000

loop.elf: loop.ld loop.o
	arm-none-eabi-ld -T loop.ld -o loop.elf loop.o

loop.o: loop.s
	arm-none-eabi-as -o loop.o loop.s

clean:
	rm -f loop.o loop.elf loop.bin

Каждое правило имеет вид

target-file: source-file1 source-file2
	program argument1 argument2 ...

Очень важно, что во второй строчке для отступа использутся символ табуляции, не пробелы. Убедитесь, что ваш редактор настроен правильно.

Схема работы make очень простая:

  1. Если в Makefile-е есть правило для сборки source-file, то сначала запускается оно. Что-то вроде рекурсии.
  2. Если target-file отсутствует или его дата модификации меньше даты модификации одного из source-file-ов, то make запускает указанную команду со второй строчки.

Конечно у GNU make на самом деле функционала несоизмеримо больше, и в сложных программах этот функционал может быть весьма полезен.

Если мы запустим make loop.bin в первый раз, то выполняются все нужные команды:

make loop.bin
arm-none-eabi-as -o loop.o loop.s
arm-none-eabi-ld -T linker.ld -o loop.elf loop.o
arm-none-eabi-objcopy -O binary -j flash loop.elf loop.bin

Если запустим во второй раз, то make ничего не будет делать:

make loop.bin
make: 'loop.bin' is up to date.

Если изменим дату модификации одного из исходных файлов, то make выполнит часть команд:

touch linker.ld
make loop.bin
arm-none-eabi-ld -T linker.ld -o loop.elf loop.o
arm-none-eabi-objcopy -O binary -j flash loop.elf loop.bin

loop.s не изменился, значит loop.o пересобирать нет нужды.

Внимательный читатель может увидеть, что файлов clean и flash у нас в проекте нет, а правила есть. Это т.н. phony targets, им никакие файлы не соответствуют, а нужны они просто для удобства. Набрали make clean и почистили директорию.

Полный код доступен на гитхабе.

★★★★

Проверено: hobbit ()
Последнее исправление: hobbit (всего исправлений: 3)

Уважаемый vbr, я уже несколько раз пытался найти хоть какую-то документацию на синтаксис ARM-овского Ассемблера. Такое впечатление, что подобная документация доступна только платно по заказу у вендоров.

Есть ли у тебя хоть какие-то ссылки на вменяемую документацию по ассемблеру ARM для Cortex-M3?

Xintrea ★★★★★
()
Ответ на: комментарий от Xintrea

Если ты про набор команд, то он описан в документе «ARM Architecture Reference Manual». Ищи по номеру документа ARM DDI 0100E.

Конкретно у Cortex-M3 набор инструкций Thumb, он описан в главе «A6 The Thumb Instruction Set».

Beewek ★★
()
Ответ на: комментарий от Xintrea

Programming Manual из первой части глянь. Там все инструкции есть. Думаю, это копипаст из документации ARM.

vbr ★★★★
() автор топика
Ответ на: комментарий от vM

Потому, что пишем в текстовом редакторе.

vbr ★★★★
() автор топика
Ответ на: комментарий от vbr

Я вот нашел официальный документ:

https://www.st.com/resource/en/programming_manual/pm0056-stm32f10xxx20xxx21xx...

И читаю его и по тексту вижу вот такие инструкции:

MOVSR0, R1; R0 = R1, setting flags
RSBMIR0, R1, #0; If negative, R0 = -R1
LDRR8, [R10]; loads R8 from the address in R10.
LDRNER2, [R5, #960]!; loads (conditionally) R2 from a word
Я ищу откуда есть пошел такой, мазафака, синтаксис, и не нахожу. Что за дичь они пишут?

Xintrea ★★★★★
()
Ответ на: комментарий от vbr

У тебя в этой документации на данных строках есть пробел?

У меня нет пробела между инструкцией и первым параметром в просмотрщике Firefox, в Okular и в Evince.

Пример на странице 58, после слов «Specific example 1: Absolute value». На странице 63 такая же фигня.

У тебя не так?

Xintrea ★★★★★
()
Последнее исправление: Xintrea (всего исправлений: 1)
Ответ на: комментарий от Xintrea

В примерах у меня тоже нет пробела. В описании инструкции - есть. Чуть позже проверю, но я в целом почти на 100% уверен, что без пробела не скомпилируется.

Под глюками имел в виду глюки генератора PDF, а не просмотрщика.

vbr ★★★★
() автор топика
Последнее исправление: vbr (всего исправлений: 1)
Ответ на: комментарий от vbr

В примерах у меня тоже нет пробела.

Зашибись. Официальная инструкция. Из-за это глюка сказал себе, что я никогда не пойму синтаксис ARM Ассемблера и забил на попытки понять что там авторы написали.

Xintrea ★★★★★
()
Ответ на: комментарий от Xintrea

Там выше скидывали ориентировки на документацию от ARM, там вроде такого нет.

А вообще я учил ARM ассемблер по книжке RP2040 Assembly Language Programming: ARM Cortex-M0+ on the Raspberry Pi Pico

Там немного другой МК, но инструкции общего назначения те же. Мне понравилась книжка.

vbr ★★★★
() автор топика

В самом начале очепятка. Ассемблируем loop.s в loop.o, а инспектируем nm и objdump’ом blink.o

Barracuda72 ★★
()

Еще, если перезаписываем имеющуюся прошивку, я бы предложил ее сохранить - благо команда несложная: st-flash read orig.bin 0x08000000 0x10000

Ну и если совсем уж буквоедствовать - то поименовав секцию кода .text в ассемблерном листинге и скрипте ld не придется objdumpить все подряд. (Хотя мб в этом и была задумка)

Barracuda72 ★★
()
Последнее исправление: Barracuda72 (всего исправлений: 1)
Ответ на: комментарий от Barracuda72

Спасибо, добавил. Задумка была в том, чтобы дать нестандартное имя секции, чтобы было понятно, что в этих именах ничего «сакрального» нет, а .text это всего лишь некое общепринятое название секции с кодом (как по-мне - нелогичное), но, конечно, в последующих примерах с С оно уже и используется.

Насколько я понимаю, чтобы objdump-ить всё подряд не надо было, нужно этой секции определенные атрибуты сконфигурировать, но это уже за рамками статьи. Как я ранее писал, углубляться тут можно глубоко в каждый нюанс.

vbr ★★★★
() автор топика
Вы не можете добавлять комментарии в эту тему. Тема перемещена в архив.