Часть 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 очень простая:
- Если в Makefile-е есть правило для сборки
source-file
, то сначала запускается оно. Что-то вроде рекурсии. - Если
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
и почистили
директорию.
Полный код доступен на гитхабе.