MyTetra Share
Делитесь знаниями!
STM32 с нуля. Часть 2. Пишем простейшую прошивку
Время создания: 11.09.2023 12:15
Автор: Vladimir Bezhenar
Текстовые метки: linux, stm, stm32, gdb, программирование, отладка, debug, debugger, линкер, линковка, ld, файл, формат
Раздел: Компьютер - Аппаратное обеспечение - Микроконтроллеры ARM
Запись: xintrea/mytetra_syncro/master/base/1694423733xhfsfk9b26/text.html на raw.github.com

Вообще говоря, прошивка уже была описана в первой части. Нам нужно создать такой файл, в котором будет записано некое число из четырёх байтов, которое процессор присвоит регистру 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 loop.o

00000000 N reset_exception_handler


arm-none-eabi-objdump -D loop.o


loop.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). У нас задача простая, поэтому выходная секция будет ровно одна, с интересующими нас данными, которые будут прошиваться в микроконтроллер.

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


Файл linker.ld


SECTIONS {

main 0x08000000 : {

LONG(0x20000000 + 20K);

LONG(reset_exception_handler | 1);

. = 0x130;

loop.o(code)

}

}


Ключевое слово SECTIONS объявляет выходные секции. Далее объявление main - это название нашей выходной секции. Можно называть её как угодно. 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 linker.ld -o loop-asm.elf loop.o


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


> arm-none-eabi-nm loop-asm.elf

08000130 R reset_exception_handler


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


loop-asm.elf: file format elf32-littlearm



Disassembly of section main:


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

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


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


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

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


> ls -l loop-asm.bin

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


> hexdump -C loop-asm.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 --connect-under-reset write loop-asm.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-asm.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 main loop.elf loop.bin


flash: loop.bin

st-flash write loop.bin 0x08000000


loop.elf: linker.ld loop.o

arm-none-eabi-ld -T linker.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 main 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 main loop.elf loop.bin


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

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


Так же в этом разделе:
 
MyTetra Share v.0.65
Яндекс индекс цитирования