# Часть 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 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 : 0: f100 0001 add.w r0, r0, #1 4: f7ff fffe bl 0 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 : 8000000: 20005000 andcs r5, r0, r0 8000004: 08000131 stmdaeq r0, {r0, r4, r5, r8} ... 08000130 : 8000130: f100 0001 add.w r0, r0, #1 8000134: f7ff fffc bl 8000130 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 read orig.bin 0x08000000 0x10000 ``` которая сохранит её в файл `orig.bin`. Для записи нашей прошивки используется команда `st-flash --connect-under-reset write loop.bin 0x08000000` ``` st-flash --connect-under-reset 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`. Вообще стек это такая структура данных, и если вдруг вы не знаете, что это такое, то проглядите [википедию](https://ru.wikipedia.org/wiki/Стек), прежде чем продолжать. Эта структура данных настолько важна, что в процессоре есть отдельные регистры и команды для работы с ним, т.н. аппаратный стек. Вопреки интуитивному представлению, аппаратный стек растёт "сверху вниз", или от больших адресов к меньшим. Регистр `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 --connect-under-reset 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` и почистили директорию. Полный код доступен на [гитхабе](https://github.com/vbezhenar/stm32-tutorial/blob/main/2-loop).