Перейти к содержанию

Часть 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;
    }
}

В этой программе имеются следующие элементы:

  1. Глобальные константы.
  2. Инициализированная глобальная переменная.
  3. Неинициализированная глобальная переменная.
  4. Ну и, конечно, код.

Скомпилируем эту программу:

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 не определено, а эти начальные значения можно сохранить только во флеш-памяти.

Решение тут такое:

  1. Секцию .data нужно разместить во флеш-памяти, чтобы там хранились инициализированные значения.
  2. Также секцию .data нужно разместить в SRAM и все адреса в коде должны указывать именно в SRAM.
  3. Необходимо в самом начале работы программы, ещё перед вызовом нашей функции 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, а также отлаживать его.