Часть 4: Начинаем работать с C
Знание ассемблера важно, но многие программы разумней писать на C. В этой части мы напишем простую программу на C, скомпилируем её, исследуем получившийся объектный файл, правильно скомпонуем и запустим. После этого ещё немного изучим gdb.
Вот наша простая программа:
loopc.c
#include <stdint.h>
static const uint32_t loop_start = 0x12345678;
static const uint32_t loop_increment = 3;
static uint32_t loop_value_1 = loop_start;
static uint32_t loop_value_2;
void start(void)
{
for (;;)
{
loop_value_2 = loop_value_1 + loop_increment;
loop_value_1 = loop_value_2;
}
}
В этой программе имеются следующие элементы:
- Глобальные константы.
- Инициализированная глобальная переменная.
- Неинициализированная глобальная переменная.
- Ну и, конечно, код.
Скомпилируем эту программу:
arm-none-eabi-gcc -mcpu=cortex-m3 -g -O0 -c -o loopc.o loopc.c
Параметр -mcpu=cortex-m3 указывает компилятору, что мы хотим сгенерировать код
для ARM Cortex M3.
Параметр -g указвыает компилятору, что мы хотим добавить в объектный файл
отладочную информацию. Она пригодится поздней, когда мы будем запускать
программу под отладкой.
Параметр -O0 указывает компилятору, что мы не хотим оптимизировать код. Для
реальных программ стоит указывать параметр -Os, -O2 или -O3, в зависимости
от того, хотите ли вы оптимизировать выходной код по размеру или скорости. Но
для нашего случая оптимизация совсем ни к чему и только помешает.
Параметр -c указывает компилятору, что мы хотим только скомпилировать файл.
Без него компилятор вызовет линкер, это нам не нужно.
После компиляции исследуем получившийся объектный файл:
arm-none-eabi-objdump -D loopc.o
loopc.o: file format elf32-littlearm
Disassembly of section .text:
00000000 <start>:
0: b480 push {r7}
2: af00 add r7, sp, #0
4: 4b05 ldr r3, [pc, #20] @ (1c <start+0x1c>)
6: 681b ldr r3, [r3, #0]
8: 2203 movs r2, #3
a: 4413 add r3, r2
c: 4a04 ldr r2, [pc, #16] @ (20 <start+0x20>)
e: 6013 str r3, [r2, #0]
10: 4b03 ldr r3, [pc, #12] @ (20 <start+0x20>)
12: 681b ldr r3, [r3, #0]
14: 4a01 ldr r2, [pc, #4] @ (1c <start+0x1c>)
16: 6013 str r3, [r2, #0]
18: e7f4 b.n 4 <start+0x4>
1a: bf00 nop
...
Disassembly of section .data:
00000000 <loop_value_1>:
0: 12345678 eorsne r5, r4, #120, 12 @ 0x7800000
Disassembly of section .bss:
00000000 <loop_value_2>:
0: 00000000 andeq r0, r0, r0
Disassembly of section .rodata:
00000000 <loop_start>:
0: 12345678 eorsne r5, r4, #120, 12 @ 0x7800000
00000004 <loop_increment>:
4: 00000003 andeq r0, r0, r3
...
Нас интересуют первые четыре секции: .text, .data, .bss и .rodata.
Остальные секции содержат отладочную и прочую служебную инфомацию и в конечный
файл не попадут.
Как видно, секция .text содержит машинный код нашей функции. Компилятор без
оптимизации компилирует весьма многословный код и разбираться в нём мы не будем,
хотя в целом там нет ничего сложного.
Секция .data содержит инициализированные глобальные переменные. В нашем случае
это переменная loop_value_1.
Секция .bss содержит неинициализированные глобальные переменные. В нашем
случае это переменная loop_value_2.
Секция .rodata содержит константы. В нашем случае это константы loop_start и
loop_increment.
Теперь давайте подумаем, как эти секции должны располагаться в памяти. Секция
.text по смыслу полностью аналогична секции code из второй части. Её мы
просто запишем на флеш-память после таблицы векторов. Секция .rodata по сути
ничем не отличается, её тоже запишем на флеш-память. Секция .bss содержит
переменные. Для работы с переменными нужно использовать SRAM, поэтому секция
.bss будет расположена именно там. Инициализировать её не надо, поэтому
никаких дополнительных действий не потребуется.
А вот с секцией .data всё совсем непросто. С одной стороны эта секция содержит
переменные, поэтому её нужно расположить в SRAM. С другой стороны эти переменные
при старте программы должны быть инициализированы определёнными значениями,
которые мы указали в тексте программы. Но когда наша программа запускается,
содержимое SRAM не определено, а эти начальные значения можно сохранить только
во флеш-памяти.
Решение тут такое:
- Секцию
.dataнужно разместить во флеш-памяти, чтобы там хранились инициализированные значения. - Также секцию
.dataнужно разместить в SRAM и все адреса в коде должны указывать именно в SRAM. - Необходимо в самом начале работы программы, ещё перед вызовом нашей функции
startскопировать секцию.dataиз флеш-памяти в SRAM.
Для этого мы напишем код на языке ассемблера, который будет вызван в самом
начале, скопирует секцию .data и передаст управление нашей функции на C.
Вот наш линкер скрипт:
linker.ld:
MEMORY
{
Flash : ORIGIN = 0x08000000, LENGTH = 64K
SRAM : ORIGIN = 0x20000000, LENGTH = 20K
}
SECTIONS {
.isr_vector :
{
LONG(0x20000000 + 20K);
LONG(_startup | 1);
. = 0x130;
} > Flash
.text :
{
. = ALIGN(4);
*(.text)
. = ALIGN(4);
} > Flash
.rodata :
{
. = ALIGN(4);
*(.rodata)
. = ALIGN(4);
} > Flash
.bss : {
. = ALIGN(4);
*(.bss)
. = ALIGN(4);
} > SRAM
.data : {
. = ALIGN(4);
_flash_data_start = LOADADDR(.data);
_sram_data_start = .;
*(.data)
_sram_data_end = .;
. = ALIGN(4);
} > SRAM AT> Flash
}
Он уже гораздо сложней предыдущих. Во-первых в нём появился раздел MEMORY, в
котором описываются регионы памяти. Без них нам не расположить .data в двух
местах. Во-вторых мы добавили множество выходных секций с именами, идентичными
входным. В-третьих мы добавили инструкции . = ALIGN(4);, которые выравнивают
начало и конец каждой выходной секции по границе, кратной четырём байтам. И
в-чётвёртых у нас появилась довольно сложная секция .data. Давайте разберём её
по строчкам.
Начнём с последней строчки: > SRAM AT> Flash. Эта строчка означает, что секция
располагается в регионе SRAM, но загружается в регион Flash. Что значит
"располагается в регионе SRAM"? Это означает, что все указатели, которые
ссылаются на эту секцию, после компоновки будут указывать в SRAM. Иными
словами, когда наша программа будет менять значения переменной loop_value_1,
она будет это делать в SRAM, а не пытаться менять значения во флеш-памяти. А что
значит "загружается в регион Flash"? Это означает, что в выходном файле
значения, которыми должны инициализироваться переменные, будут записаны во
флеш-память.
На выравниваниях не будем останавливаться, тут ничего сложного. Посмотрим на
строчку _flash_data_start = LOADADDR(.data). Эта строчка объявляет символ
_flash_data_start и присваивает ему адрес начала секции .data во
флеш-памяти. Далее идёт строчка _sram_data_start = .. Она объявляет символ
_sram_data_start и присваивает ему адрес начала секции .data в SRAM. Потом
идёт *(.data), с этим синтаксисом мы уже знакомы, все секции с именем .data
из всех входных файлов (в нашем случае это только loopc.o) будут скопированы в
выходную секцию .data. И наконец идёт строчка _sram_data_end = .. Она
объявляет символ _sram_data_end и присваивает ему адрес конца секции .data в
SRAM.
В дальнейшем мы сможем использовать значения символов _flash_data_start,
_sram_data_start и _sram_data_end в коде, который будет копировать начальные
значения для переменных из секции .data из флеш-памяти в SRAM.
Важно понимать, что все эти символы вычисляются линкером во время компоновки конечного файла и в нём будут присутствовать в виде готовых констант. Как, наверное, уже видно, линкер начинает выполнять весьма нетривиальную роль. И если в прошлых программах при большом желании без него можно было бы и обойтись, собрав конечный файл по кусочкам, то в этой программе без линкера никуда.
Выполнение программы начинается с кода, адрес которого обозначен символом
_reset_exception_handler, а наш код на C объявляет функцию start. Задача
кода _reset_exception_handler состоит в инициализации секции .data и
переходу к start. Стоит отметить, что использование переменных с
подчёркиванием в коде на C не рекомендуется, все переменные такого рода
считаются зарезервированными для деталей реализации. Именно поэтому мы и
объявляем такие символы, в корректном коде на C они не должны появиться. Если бы
мы использовали имя reset_exception_handler или flash_data_start, то в коде
на C ничего бы не мешало объявить функцию с таким же именем, и получилась бы
неприятная коллизия. Конечно в нашем простом случае это не случится, но в общем
случае стоит иметь это в виду. Наш линкер и наш будущий код инициализации секции
.data как раз относятся к таким деталям реализации.
Итак пора написать код _reset_exception_handler. Мы это сделаем на языке
ассемблера, чтобы к моменту запуска кода на C всё уже было инициализировано и
готово к использованию.
reset_exception_handler.s:
.cpu cortex-m3
.syntax unified
.thumb
.global _reset_exception_handler
.text
_reset_exception_handler:
ldr r0, =_flash_data_start
ldr r1, =_sram_data_start
ldr r2, =_sram_data_end
copy_loop:
cmp r1, r2
bge end_copy
ldr r3, [r0], #4
str r3, [r1], #4
b copy_loop
end_copy:
b start
Псевдокод, соответствующий этому коду, выглядит так:
_reset_exception_handler:
r0 := _flash_data_start
r1 := _sram_data_start
r2 := _sram_data_end
copy_loop:
if r1 >= r2 then goto end_copy
r3 := [r0]
r0 := r0 + 4
[r1] := r3
r1 := r1 + 4
goto copy_loop
end_copy:
goto start
Работа по программированию на этом закончена. Makefile приводить не будем, там
всё тривиально. Соберём программу, прошьём её в микроконтроллер и приступим к
отладке:
$ make flash
arm-none-eabi-gcc -mcpu=cortex-m3 -g -O0 -c -o loopc.o loopc.c
arm-none-eabi-ld -T linker.ld -o loopc.elf reset_exception_handler.o loopc.o
arm-none-eabi-objcopy -O binary loopc.elf loopc.bin
st-flash write loopc.bin 0x08000000
st-flash 1.7.0
...
2023-09-11T23:48:28 INFO common.c: Flash written and verified! jolly good!
$ st-util --connect-under-reset
st-util
2023-09-11T23:50:13 WARN common.c: NRST is not connected
2023-09-11T23:50:13 INFO common.c: F1xx Medium-density: 20 KiB SRAM, 64 KiB flash in at least 1 KiB pages.
2023-09-11T23:50:13 INFO gdb-server.c: Listening at *:4242...
$ arm-none-eabi-gdb
GNU gdb (Arm GNU Toolchain 12.3.Rel1 (Build arm-12.35)) 13.2.90.20230627-git
...
(gdb) target remote 127.0.0.1:4242
Remote debugging using 127.0.0.1:4242
warning: No executable has been specified and target does not support
determining executable automatically. Try using the "file" command.
0x08000130 in ?? ()
Значение регистра $pc должно быть равно 0x08000130.
Теперь загрузим программу, чтобы gdb мог прочитать символы и отладочную информацию:
(gdb) symbol-file loopc.elf
Reading symbols from loopc.elf...
У gdb есть очень полезная команда display. Мы ей указываем формат и выражение,
аналогично команде print, а она распечатывает это выражение при каждом шаге
программы. Воспользуемся этой командой для того, чтобы дизассемблировать
выполняющийся код:
(gdb) display/6i $pc - 2
1: x/6i $pc - 2
0x800012e: movs r0, r0
=> 0x8000130 <_reset_exception_handler>: ldr r0, [pc, #20] @ (0x8000148 <end_copy+4>)
0x8000132 <_reset_exception_handler+2>: ldr r1, [pc, #24] @ (0x800014c <end_copy+8>)
0x8000134 <_reset_exception_handler+4>: ldr r2, [pc, #24] @ (0x8000150 <end_copy+12>)
0x8000136 <copy_loop>: cmp r1, r2
0x8000138 <copy_loop+2>: bge.n 0x8000144 <end_copy>
Можно распознать в этом коде содержимое файла startup.s. Также можно обратить внимание, что рядом с адресами появились имена символов, которые соответствуют этим адресам.
Сделаем шаг:
(gdb) stepi
0x08000132 in _reset_exception_handler ()
1: x/6i $pc - 2
0x8000130 <_reset_exception_handler>: ldr r0, [pc, #20] @ (0x8000148 <end_copy+4>)
=> 0x8000132 <_reset_exception_handler+2>: ldr r1, [pc, #24] @ (0x800014c <end_copy+8>)
0x8000134 <_reset_exception_handler+4>: ldr r2, [pc, #24] @ (0x8000150 <end_copy+12>)
0x8000136 <copy_loop>: cmp r1, r2
0x8000138 <copy_loop+2>: bge.n 0x8000144 <end_copy>
0x800013a <copy_loop+4>: ldr.w r3, [r0], #4
Как и ожидалось, команда display распечатала обновлённый код. Теперь нажмём
<Enter> ничего не вводя:
(gdb)
0x08000134 in _reset_exception_handler ()
1: x/6i $pc - 2
0x8000132 <_reset_exception_handler+2>: ldr r1, [pc, #24] @ (0x800014c <end_copy+8>)
=> 0x8000134 <_reset_exception_handler+4>: ldr r2, [pc, #24] @ (0x8000150 <end_copy+12>)
0x8000136 <copy_loop>: cmp r1, r2
0x8000138 <copy_loop+2>: bge.n 0x8000144 <end_copy>
0x800013a <copy_loop+4>: ldr.w r3, [r0], #4
Это повторило предыдущую инструкцию и сделало ещё один шаг вперёд. Можно
увидеть, что после того, как startup скопирует нашу секцию .data, он перейдёт
на инструкцию по адресу 0x0800_0144 с символов end_copy. Не будем шагать
дальше, а поставим отладочную точку (breakpoint, брейкпоинт) на этот адрес и
запустим выполнение программы до остановки:
(gdb) break end_copy
Breakpoint 1 at 0x8000144
Note: automatically using hardware breakpoints for read-only addresses.
(gdb) continue
Continuing.
Breakpoint 1, 0x08000144 in end_copy ()
1: x/6i $pc - 2
0x8000142 <copy_loop+12>: b.n 0x8000136 <copy_loop>
=> 0x8000144 <end_copy>: b.w 0x8000154 <start>
0x8000148 <end_copy+4>: lsls r0, r0, #6
0x800014a <end_copy+6>: lsrs r0, r0, #32
0x800014c <end_copy+8>: movs r4, r0
0x800014e <end_copy+10>: movs r0, #0
Следующей инструкцей мы перейдём уже в скомпилированный код функции start. А
перед этим проверим, действительно ли секция .data в SRAM инициализирована.
(gdb) print/z &_flash_data_start
$1 = 0x08000180
(gdb) print/z &_sram_data_start
$2 = 0x20000004
(gdb) print/z &_sram_data_end
$3 = 0x20000008
(gdb) x/z 0x08000180
0x8000180: 0x12345678
(gdb) x/z 0x20000004
0x20000004 <loop_value_1>: 0x12345678
Видно, что значение 0x1234_5678 действительно было скопировано с флеш-памяти
по адресу 0x0800_0180 в SRAM по адресу 0x2000_0004. Также gdb знает про
символ loop_value_1, т.е. переменную из кода на C.
Теперь перейдём в функцию start:
(gdb) stepi
start () at loopc.c:9
9 {
1: x/6i $pc - 2
0x8000152 <end_copy+14>: movs r0, #0
=> 0x8000154 <start>: push {r7}
0x8000156 <start+2>: add r7, sp, #0
0x8000158 <start+4>: ldr r3, [pc, #20] @ (0x8000170 <start+28>)
0x800015a <start+6>: ldr r3, [r3, #0]
0x800015c <start+8>: movs r2, #3
Обратите внимание, что произошла очень важная вещь. gdb распечатал название
нашей функции start, название файла, где эта функция определена loopc.c и
номер строки 9.
Командой list можно распечетать исходный код в окрестности выполняемого кода:
(gdb) list
4 static const uint32_t loop_increment = 3;
5 static uint32_t loop_value_1 = loop_start;
6 static uint32_t loop_value_2;
7
8 void start(void)
9 {
10 for (;;)
11 {
12 loop_value_2 = loop_value_1 + loop_increment;
13 loop_value_1 = loop_value_2;
Теперь можно убрать отображение дизассемблированного кода и добавить отображение значений переменных из кода на C:
(gdb) delete display 1
(gdb) display loop_value_1
2: loop_value_1 = 305419896
(gdb) display loop_value_2
3: loop_value_2 = 306559500
Далее будем вместо команды stepi (step instruction) использовать команду
step, которая шагает по строкам C, а не отдельным инструкциям.
(gdb) step
12 loop_value_2 = loop_value_1 + loop_increment;
2: loop_value_1 = 305419896
3: loop_value_2 = 306559500
(gdb) step
13 loop_value_1 = loop_value_2;
2: loop_value_1 = 305419896
3: loop_value_2 = 305419899
(gdb) step
12 loop_value_2 = loop_value_1 + loop_increment;
2: loop_value_1 = 305419899
3: loop_value_2 = 305419899
На этом данную часть можно считать завершённой. Мы научились компилировать и, что куда более важно, компоновать код на C, а также отлаживать его.