Часть 1 Часть 2 Часть 3 Часть 4 Часть 5 Часть 6 Часть 7 Часть 8 Часть 9
Часть 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;
}
}
В этой программе имеются следующие элементы:
- Глобальные константы.
- Инициализированная глобальная переменная.
- Неинициализированная глобальная переменная. Согласно стандарту языка C, неинициализированные глобальные переменные при запуске должны иметь нулевое значение.
- Ну и, конечно, код. Обратим внимание, что этот код никогда не завершает своё выполнение.
Скомпилируем эту программу:
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.
В итоге мы напишем код на языке ассемблера, который будет вызван в самом начале,
обнулит секцию .bss
, скопирует секцию .data
и передаст управление нашей
функции на C.
Вот наш линкер скрипт:
linker.ld
:
MEMORY
{
Flash : ORIGIN = 0x08000000, LENGTH = 64K
SRAM : ORIGIN = 0x20000000, LENGTH = 20K
}
SECTIONS {
.isr_vector :
{
LONG(0x20000000 + 20K);
LONG(_reset_exception_handler | 1);
. = 0x130;
} > Flash
.text :
{
. = ALIGN(4);
*(.text)
. = ALIGN(4);
} > Flash
.rodata :
{
. = ALIGN(4);
*(.rodata)
. = ALIGN(4);
} > Flash
.bss : {
. = ALIGN(4);
_bss_start = .;
*(.bss)
. = ALIGN(4);
_bss_end = .;
} > SRAM
.data : {
. = ALIGN(4);
_flash_data_start = LOADADDR(.data);
_sram_data_start = .;
*(.data)
. = ALIGN(4);
_sram_data_end = .;
} > SRAM AT> Flash
}
Он уже гораздо сложней предыдущих. Во-первых в нём появился раздел MEMORY
, в
котором описываются регионы памяти. Без них нам не расположить .data
в двух
местах. Во-вторых мы добавили множество выходных секций с именами, идентичными
входным. В-третьих мы добавили инструкции . = ALIGN(4);
, которые выравнивают
начало и конец каждой выходной секции по границе, кратной четырём байтам. И
в-чётвёртых у нас появилась довольно сложная секция .data
.
Для начала разберём подробней описание секции .bss
. В ней располагаются
переменные, которые нужно инициализировать нулевыми значениями. Давайте разберём
её по строкам. . = ALIGN(4);
эта строка выравнивает текущий адрес.
_bss_start = .
эта строка объявляет новый символ _bss_start
со значением
текущего адреса, т.е. начала выходной секции .bss
. *(.bss)
эта строка
копирует содержимое секций .bss
из всех входных файлов в текущую выходную
секцию. . = ALIGN(4)
эта строка опять выравнивает окончание секции до адреса,
кратного четырём. _bss_end = .
эта строка объявляет новый символ _bss_end
со
значением текущего адреса, т.е. конца выходной секции .bss
. > SRAM
эта
строка означает, что все символы, располагающиеся в секции .bss
, после
компоновки будут указывать в область SRAM
.
В дальнейшем мы сможем использовать значения символов _bss_start
и _bss_end
в коде, который будет копировать начальные значения для переменных из секции
.data
из флеш-памяти в SRAM.
Теперь перейдём к самой сложной секции .data
. Начнём с последней строки:
> SRAM AT> Flash
. Эта строка означает, что секция располагается в регионе
SRAM
, но загружается в регион Flash
. Что значит «располагается в регионе
SRAM
»? Это означает, что все символы, располагающиеся в секции .data
, после
компоновки будут указывать в 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
состоит в инициализации секций .bss
, .data
и
переходу к start
. Стоит отметить, что использование переменных с
подчёркиванием в коде на C не рекомендуется, все переменные такого рода
считаются зарезервированными для деталей реализации. Именно поэтому мы и
объявляем такие символы, в корректном коде на C они не должны появиться. Если бы
мы использовали имя reset_exception_handler
или flash_data_start
, то в коде
на C ничего бы не мешало объявить функцию с таким же именем, и получилась бы
неприятная коллизия. Конечно в нашем простом случае это не случится, но в общем
случае стоит иметь это в виду. Наш линкер и наш будущий код инициализации секций
как раз относятся к таким деталям реализации.
Итак пора написать код _reset_exception_handler
. Мы это сделаем на языке
ассемблера, чтобы к моменту запуска кода на C всё уже было инициализировано и
готово к использованию.
reset_exception_handler.s
:
.cpu cortex-m3
.syntax unified
.thumb
.global _reset_exception_handler
.text
_reset_exception_handler:
// zero out .bss section
mov r0, #0
ldr r1, =_bss_start
ldr r2, =_bss_end
copy_bss_loop:
cmp r1, r2
bge copy_bss_end
str r0, [r1], #4
b copy_bss_loop
copy_bss_end:
// copy .data section from flash to sram
ldr r0, =_flash_data_start
ldr r1, =_sram_data_start
ldr r2, =_sram_data_end
copy_data_loop:
cmp r1, r2
bge copy_data_end
ldr r3, [r0], #4
str r3, [r1], #4
b copy_data_loop
copy_data_end:
b start
Псевдокод, соответствующий этому коду, выглядит так:
_reset_exception_handler:
// zero out .bss section
r0 := 0
r1 := _bss_start
r2 := _bss_end
copy_bss_loop:
if r1 >= r2 then goto copy_bss_end
memory[r1] := r0
goto copy_bss_loop
copy_bss_end:
// copy .data section from flash to sram
r0 := _flash_data_start
r1 := _sram_data_start
r2 := _sram_data_end
copy_data_loop:
if r1 >= r2 then goto copy_data_end
r3 := memory[r0]
r0 := r0 + 4
memory[r1] := r3
r1 := r1 + 4
goto copy_data_loop
copy_data_end:
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 --connect-under-reset 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 должно быть равно 0x0800_0130
.
Теперь загрузим программу, чтобы gdb мог прочитать символы и отладочную информацию:
(gdb) symbol-file loopc.elf
Reading symbols from loopc.elf...
Задача этой отладочной секции - удостовериться в корректности работы кода по инициализации секций.
Проверим, что сейчас в этих секциях находится:
(gdb) print/z &_bss_start
$5 = 0x20000000
(gdb) print/z &_bss_end
$4 = 0x20000004
(gdb) print/z &_sram_data_start
$2 = 0x20000004
(gdb) print/z &_sram_data_end
$3 = 0x20000008
(gdb) print/z &_flash_data_start
$1 = 0x0800019c
(gdb) x/2z 0x20000000
0x20000000 <loop_value_2>: 0x124beaa5 0x124beaa5
(gdb) x/1z 0x0800019c
0x800019c: 0x12345678
Секция .bss
располагается от адреса 0x2000_0000
до адреса 0x2000_0004
(не
включительно). Секция .data
в SRAM располагается от адреса 0x2000_0004
до
адреса 0x2000_0008
. По этим адресам в SRAM находятся какие-то странные
значения 0x124beaa5
. Секция .data
во флеш-памяти начинается с адреса
0x0800019c
и по этому адресу действительно располагается то значение, которым
мы инициализировали переменную loop_start
в нашем коде на C.
У gdb есть очень полезная команда display
. Мы ей указываем формат и выражение,
аналогично команде print
, а она распечатывает это выражение при каждом шаге
программы. Воспользуемся этой командой для того, чтобы дизассемблировать
выполняющийся код:
(gdb) display/6i $pc - 2
1: x/6i $pc - 2
0x800012e: movs r0, r0
=> 0x8000130 <_reset_exception_handler>: mov.w r0, #0
0x8000134 <_reset_exception_handler+4>: ldr r1, [pc, #36] @ (0x800015c <copy_data_end+6>)
0x8000136 <_reset_exception_handler+6>: ldr r2, [pc, #40] @ (0x8000160 <copy_data_end+10>)
0x8000138 <copy_bss_loop>: cmp r1, r2
0x800013a <copy_bss_loop+2>: bge.n 0x8000142 <copy_bss_end>
Можно распознать в этом коде начало кода из файла reset_exception_handler.s. Также можно обратить внимание, что рядом с адресами появились имена символов, которые соответствуют этим адресам.
Сделаем шаг:
(gdb) stepi
0x08000134 in _reset_exception_handler ()
1: x/6i $pc - 2
0x8000132 <_reset_exception_handler+2>: movs r0, r0
=> 0x8000134 <_reset_exception_handler+4>: ldr r1, [pc, #36] @ (0x800015c <copy_data_end+6>)
0x8000136 <_reset_exception_handler+6>: ldr r2, [pc, #40] @ (0x8000160 <copy_data_end+10>)
0x8000138 <copy_bss_loop>: cmp r1, r2
0x800013a <copy_bss_loop+2>: bge.n 0x8000142 <copy_bss_end>
0x800013c <copy_bss_loop+4>: str.w r0, [r1], #4
Как и ожидалось, команда display
распечатала обновлённый код. Теперь нажмём
<Enter>
ничего не вводя:
(gdb)
0x08000136 in _reset_exception_handler ()
1: x/6i $pc - 2
0x8000134 <_reset_exception_handler+4>: ldr r1, [pc, #36] @ (0x800015c <copy_data_end+6>)
=> 0x8000136 <_reset_exception_handler+6>: ldr r2, [pc, #40] @ (0x8000160 <copy_data_end+10>)
0x8000138 <copy_bss_loop>: cmp r1, r2
0x800013a <copy_bss_loop+2>: bge.n 0x8000142 <copy_bss_end>
0x800013c <copy_bss_loop+4>: str.w r0, [r1], #4
0x8000140 <copy_bss_loop+8>: b.n 0x8000138 <copy_bss_loop>
Это повторило предыдущую инструкцию и сделало ещё один шаг вперёд.
Распечатаем листинг всего начального кода:
(gdb) x/16i 0x08000130
0x8000130 <_reset_exception_handler>: mov.w r0, #0
0x8000134 <_reset_exception_handler+4>: ldr r1, [pc, #36] @ (0x800015c <copy_data_end+6>)
=> 0x8000136 <_reset_exception_handler+6>: ldr r2, [pc, #40] @ (0x8000160 <copy_data_end+10>)
0x8000138 <copy_bss_loop>: cmp r1, r2
0x800013a <copy_bss_loop+2>: bge.n 0x8000142 <copy_bss_end>
0x800013c <copy_bss_loop+4>: str.w r0, [r1], #4
0x8000140 <copy_bss_loop+8>: b.n 0x8000138 <copy_bss_loop>
0x8000142 <copy_bss_end>: ldr r0, [pc, #32] @ (0x8000164 <copy_data_end+14>)
0x8000144 <copy_bss_end+2>: ldr r1, [pc, #32] @ (0x8000168 <copy_data_end+18>)
0x8000146 <copy_bss_end+4>: ldr r2, [pc, #36] @ (0x800016c <copy_data_end+22>)
0x8000148 <copy_data_loop>: cmp r1, r2
0x800014a <copy_data_loop+2>: bge.n 0x8000156 <copy_data_end>
0x800014c <copy_data_loop+4>: ldr.w r3, [r0], #4
0x8000150 <copy_data_loop+8>: str.w r3, [r1], #4
0x8000154 <copy_data_loop+12>: b.n 0x8000148 <copy_data_loop>
0x8000156 <copy_data_end>: b.w 0x8000170 <start>
Можно увидеть, что после того, как _reset_exception_handler
обнулит секцию
.bss
и скопирует нашу секцию .data
, он перейдёт на инструкцию по адресу
0x800_0156 copy_data_end
. Не будем шагать дальше, а поставим отладочную точку
(breakpoint, брейкпоинт) на этот адрес и запустим выполнение программы до
остановки:
(gdb) break copy_data_end
Breakpoint 1 at 0x8000156
Note: automatically using hardware breakpoints for read-only addresses.
(gdb) continue
Continuing.
Breakpoint 1, 0x08000156 in copy_data_end ()
1: x/6i $pc - 2
0x8000154 <copy_data_loop+12>: b.n 0x8000148 <copy_data_loop>
=> 0x8000156 <copy_data_end>: b.w 0x8000170 <start>
0x800015a <copy_data_end+4>: movs r0, r0
0x800015c <copy_data_end+6>: movs r0, r0
0x800015e <copy_data_end+8>: movs r0, #0
0x8000160 <copy_data_end+10>: movs r4, r0
Следующей инструкцей мы перейдём уже в скомпилированный код функции start
. А
перед этим проверим, действительно ли секции .bss
и .data
в SRAM
инициализированы.
(gdb) x/2z 0x20000000
0x20000000 <loop_value_2>: 0x00000000 0x12345678
Видно, что в секции .bss
по адресу 0x2000_0000
появился 0, а значение
0x1234_5678
действительно было скопировано с секцию .data
. Также gdb знает
про символ loop_value_2
, т.е. переменную из кода на C.
Теперь перейдём в функцию start:
(gdb) stepi
start () at loopc.c:9
9 {
0x800016e <copy_data_end+24>: movs r0, #0
=> 0x8000170 <start>: push {r7}
0x8000172 <start+2>: add r7, sp, #0
0x8000174 <start+4>: ldr r3, [pc, #20] @ (0x800018c <start+28>)
0x8000176 <start+6>: ldr r3, [r3, #0]
0x8000178 <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 = 0
Далее будем вместо команды 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 = 0
(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, а также отлаживать его.
Полный код доступен на гитхабе.