Три статьи подряд я собирал файл и отдавал его эмулятору, ни разу в него не заглянув. Компоновщик что-то делает, QEMU что-то читает, на экране появляются буквы. Работает же.

Заглянул. В заголовке первым делом нашлось поле «точка входа», и там стоял тот самый 0x80000000, вокруг которого у меня было построено объяснение из первой статьи.

Поле есть. Читать его никто не собирается.

▍ Навигация по серии

Что получится в конце

Программа та же, что в третьей статье, я её не трогал. Меняется только то, как она разложена по файлу.

Уйдёт предупреждение про RWX, которое висело с первой статьи. Появится ответ, откуда взялся адрес 0x80000000, и он окажется не там, где я думал. И компоновщик начнёт сам проверять размер стека, которого в конце третьей статьи «проверить было некому».

Открываем файл

Заголовок лежит в первых 52 байтах и описывает, что вообще за файл перед нами.

riscv-none-elf-readelf -h hello.elf
  Class:                             ELF32
  Type:                              EXEC (Executable file)
  Machine:                           RISC-V
  Entry point address:               0x80000000
  Start of program headers:          52 (bytes into file)
  Start of section headers:          5412 (bytes into file)
  Number of program headers:         2
  Number of section headers:         9

Тут стоит остановиться на двух последних строчках, потому что они и есть главная мысль всей статьи.

В файле два оглавления. Одно перечисляет 9 секций, другое 2 сегмента. Описывают они одни и те же байты, но по-разному и для разных читателей.

Два взгляда на одни байты

Секции это взгляд компоновщика. Он режет содержимое по смыслу: код отдельно, константы отдельно, неинициализированные данные отдельно.

riscv-none-elf-readelf -S hello.elf
  [Nr] Name        Type      Addr     Off    Size   Flg
  [ 1] .text       PROGBITS  80000000 001000 0001a0  AX
  [ 2] .rodata     PROGBITS  800001a0 0011a0 00001c   A
  [ 3] .bss        NOBITS    800001bc 0011bc 000010  WA
  [ 4] .stack      NOBITS    800001cc 0011bc 000404  WA
  [ 5] .riscv.attributes                              
  [ 6] .symtab
  [ 7] .strtab
  [ 8] .shstrtab

Лишние колонки я тут подрезал, чтобы влезло в ширину, и не показал нулевую секцию: она есть в любом ELF, называется никак, и служит заглушкой формата. Отсюда 9 в заголовке против 8 строк тут. Первые 4 секции знакомы, мы их сами написали в линкер-скрипте. Дальше идут 4 служебные: атрибуты архитектуры, таблица символов, таблица строк и таблица имён самих секций. При запуске они не нужны никому.

Сегменты это взгляд того, кто будет грузить файл в память.

riscv-none-elf-readelf -l hello.elf
  Type           Offset   VirtAddr   FileSiz MemSiz  Flg
  RISCV_ATTRIBUT 0x0011bc 0x00000000 0x0001e 0x00000 R
  LOAD           0x001000 0x80000000 0x001bc 0x005d0 RWE

 Section to Segment mapping:
   00     .riscv.attributes
   01     .text .rodata .bss .stack

Загрузчику всё равно, где кончается код и начинаются константы. Ему нужно знать другое: какой кусок файла куда положить и какие права на него поставить. Поэтому все 4 наши секции слиты в один сегмент LOAD.

Разделение полезное, а не бюрократическое. Секции нужны, пока файл собирают, и после сборки их можно вообще выкинуть, файл останется рабочим. Сегменты нужны, когда файл запускают.

444 байта в файле, 1488 в памяти

В строке LOAD стоят два разных размера, и разница у них не опечатка.

FileSiz 0x1bc это 444 байта, столько занимает сегмент в файле. MemSiz 0x5d0 это 1488 байт, столько он займёт в памяти. Разница 1044 байта берётся из ниоткуда.

Точнее, из типа секции. У .text и .rodata тип PROGBITS, их содержимое лежит в файле. А .bss со .stack помечены как NOBITS, и в файле их нет вовсе.

Считаем: .bss это 16 байт под буфер цифр, .stack это 1028 байт. Вместе 1044, ровно разница между двумя размерами.

Мы это уже писали руками, когда ставили (NOLOAD) у секции стека в третьей статье. Тогда я принял на веру, что «грузить туда нечего». Теперь видно, где именно это записано в файле и кто это прочитает.

Один и тот же файл двумя оглавлениями: слева секции для компоновщика, справа сегменты для загрузчика
Один и тот же файл двумя оглавлениями: слева секции для компоновщика, справа сегменты для загрузчика

Откуда взялось предупреждение про RWX

Оно висит с первой статьи, я дважды обещал разобраться и дважды откладывал.

warning: hello.elf has a LOAD segment with RWX permissions

Теперь понятно, о чём речь. Флаги у единственного LOAD стоят RWE, то есть чтение, запись и исполнение разом. Это не компоновщик придумал, а сложил: в сегмент попали .text, которой нужно исполнение, и .bss со .stack, которым нужна запись. Права сегмента это объединение прав всего, что в него попало.

Чем это плохо на настоящей системе, понятно: страница, в которую можно и писать, и из которой можно исполнять, это готовая площадка для чужого кода. У нас ни страниц, ни защиты нет, так что предупреждение чисто гигиеническое. Но чинится оно ровно там, где возникло.

Есть быстрый способ, --no-warn-rwx-segments, и он неправильный: гасит лампочку, не трогая причину.

Правильный способ это объявить сегменты самому. До сих пор компоновщик собирал их за меня по своим соображениям, и я не знал, что могу вмешаться.

PHDRS
{
    text   PT_LOAD FLAGS(5);    /* R + X */
    rodata PT_LOAD FLAGS(4);    /* R     */
    data   PT_LOAD FLAGS(6);    /* R + W */
}

SECTIONS
{
    .text   : { *(.text) *(.text.*) }     > RAM :text
    .rodata : { *(.rodata) *(.rodata.*) } > RAM :rodata
    .bss    : { *(.bss) *(.bss.*) }       > RAM :data
    ...
}

Блок PHDRS перечисляет сегменты, а двоеточие в конце строки секции говорит, в какой из них её положить. Числа во FLAGS это те же права: 4 читать, 2 писать, 1 исполнять.

Собираем и смотрим, что вышло.

  Type   Offset   VirtAddr   FileSiz MemSiz  Flg
  LOAD   0x001000 0x80000000 0x001a0 0x001a0 R E
  LOAD   0x0011a0 0x800001a0 0x0001c 0x0001c R
  LOAD   0x0001bc 0x800001bc 0x00000 0x00414 RW

Три сегмента вместо одного, права раздельные, предупреждения нет. Программа работает так же, вывод байт в байт тот же.

Права сегмента это сумма прав всего, что в него попало: было RWE одним куском, стало три сегмента
Права сегмента это сумма прав всего, что в него попало: было RWE одним куском, стало три сегмента

Обратите внимание на третью строку: FileSiz там ноль. Сегмент с .bss и стеком не занимает в файле ничего, а в памяти просит 1044 байта.

Откуда на самом деле взялся 0x80000000

В первой статье я написал так: у машины virt память начинается с этого адреса, QEMU передаёт туда управление, а мы кладём .text первой секцией, поэтому _start оказывается там же.

Половина этого объяснения оказалась выдумкой, и проверяется это тремя опытами.

Опыт первый: сдвинуть _start. Кладу в .text перед ним ловушку, которая печатает X и встаёт.

decoy:
    li      t0, 0x10000000
    li      t1, 'X'
    sb      t1, 0(t0)
1:  wfi
    j       1b

_start:
    la      sp, _stack_top
    ...

Компоновщик честно всё пересчитал: decoy лёг по 0x80000000, _start уехал на 0x8000001c, и в заголовке файла точка входа тоже стала 0x8000001c.

Запускаю. На экране X.

То есть управление ушло на 0x80000000, в ловушку, а не туда, куда указывает файл.

Опыт второй: сдвинуть всю программу. Меняю в линкер-скрипте одну цифру, ORIGIN становится 0x80100000. Теперь и загружается программа туда, и точка входа там же, всё согласовано.

Вывод пустой. Управление по 0x80100000 не пришло вообще.

Опыт третий: посмотреть, куда оно приходит. Раз файл ни при чём, адрес лежит в самой машине. Достаю его дампом памяти работающей машины, через монитор QEMU.

0x1000:  auipc  t0, 0x0
0x1004:  addi   a2, t0, 40
0x1008:  csrr   a0, mhartid
0x100c:  lw     a1, 32(t0)
0x1010:  lw     t0, 24(t0)
0x1014:  jr     t0
0x1018:  .word  0x80000000
0x101c:  .word  0
0x1020:  .word  0x87e00000
0x1024:  .word  0
Переходник по адресу 0x1000: читает слово по 0x1018 и прыгает по нему
Переходник по адресу 0x1000: читает слово по 0x1018 и прыгает по нему

Вот и всё объяснение. По адресу 0x1000 лежит переходник из 6 инструкций, зашитый в машину. Он кладёт в a0 номер ядра, в a1 адрес дерева устройств, читает слово по 0x1018 и прыгает по нему. В слове написано 0x80000000.

Два места тут выглядят странно, и оба объясняются одним.

Почему lw читают по 0x1018 и 0x1020, то есть через одно слово, а не подряд. Потому что поля 64-битные: младшее слово плюс нулевое старшее. QEMU кладёт их одинаково и на 32-битной машине, и на 64-битной, отсюда нули по 0x101c и 0x1024.

Что делает addi a2, t0, 40. Он ставит a2 на 0x1028, а там лежит 4f 53 42 49, то есть буквы OSBI. Это заголовок структуры, по которой прошивка узнаёт, куда идти дальше. У нас -bios none, прошивки нет, и регистр пропадает впустую.

Мой линкер-скрипт всё это время не задавал адрес, а угадывал чужой.

Проверка, что второе слово это дерево устройств

Переходник кладёт в a1 значение из 0x1020, там 0x87e00000. Смотрим, что по этому адресу:

0x87e00000:  d0 0d fe ed  00 00 12 cc

d0 0d fe ed это магическое число формата flattened device tree. Дальше идёт размер, 0x12cc байт.

То есть QEMU не только передаёт управление, но и оставляет программе описание машины: сколько памяти, какие устройства и по каким адресам. Мы этим пока не пользуемся и адрес UART держим константой в коде. Правильный способ узнать его лежит вот здесь, и до него мы ещё дойдём.

Опыт четвёртый: испортить только поле. Три предыдущих опыта показывают, что управление приходит не туда, куда указывает файл. Возразить на это можно: а вдруг поле всё-таки читается, просто его перебивает что-то ещё. Возразить есть чем, потому что во всех трёх опытах поле менялось вместе с программой и ни разу в одиночку.

Значит надо поменять только его. Программу не пересобираю, беру готовый ELF и правлю в нём 4 байта заголовка:

b = bytearray(open('assert.elf', 'rb').read())
struct.pack_into('<I', b, 24, 0xDEADBEEF)   # смещение 24 это e_entry
open('broken-entry.elf', 'wb').write(bytes(b))

Заголовок теперь говорит вот что:

  Entry point address:               0xdeadbeef

Запускаю:

fib(1..10): 1 1 2 3 5 8 13 21 34 55
fib(20) = 6765

Программа считает как ни в чём не бывало. В поле можно записать мусор, и машина этого не заметит.

4 опыта: что написано в файле, куда прыгнула машина, что вышло на экран
4 опыта: что написано в файле, куда прыгнула машина, что вышло на экран

Где я споткнулся

Поверил полю, потому что в нём стояло ожидаемое число.

Точка входа в заголовке совпадала с 0x80000000, всё сходилось, и три статьи я считал, что связь причинная. На самом деле совпадали два независимых числа: адрес, зашитый в машину, и адрес, который я написал в своём скрипте.

Пока они совпадают, ошибку не видно. Мне повезло, что я угадал правильно с первого раза, иначе первая статья закончилась бы пустым экраном и я бы искал ошибку в коде вывода.

Это второй раз за серию, когда я принял совпадение за объяснение. В третьей статье было то же самое с регистром sp: он вёл себя как указатель стека, значит казался особым, а оказался обычным x2.

Поправка к первой статье. Там сказано так: «Мы кладём .text первой секцией, поэтому _start оказывается по адресу 0x80000000, куда процессор и придёт».

Виновато слово «поэтому». Оба факта в этом предложении верные: мы правда кладём .text первой, и процессор правда приходит на 0x80000000. Но одно из другого не следует, они независимы. Процессор пришёл бы туда, даже если бы я положил .text последней, просто застал бы там не _start.

Тот же самый случай, что абзацем выше: я склеил в причинную связь два числа, которые всего лишь совпали.

Разница важная. Если бы QEMU читал точку входа, порядок секций был бы вопросом аккуратности. А он не читает, и порядок секций становится вопросом работоспособности.

Как это делают, когда не угадывают. Приём простой: отдельная секция под точку входа, которую скрипт кладёт первой явно.

.text : {
    *(.text.entry)      /* сюда попадёт только _start */
    *(.text)
    *(.text.*)
} > RAM

В исходнике _start объявляется как .section .text.entry, и порядок перестаёт зависеть от того, сколько у нас файлов и в каком порядке их перечислили.

Проверил на той же ловушке. В исходнике она по-прежнему стоит раньше _start, но теперь:

80000000 T _start
800001a0 t decoy

_start на месте, ловушка уехала вниз, программа считает числа. Порядок в файле больше ничего не решает.

У компоновщика есть чем проверять

Третья статья кончилась на том, что размер стека проверить некому: ни процессор, ни компоновщик, ни код.

Про процессор и код это правда. И про компоновщика правда: в той сборке он действительно ничего не проверял.

Только не потому, что не умеет. Потому что я его не попросил.

ld умеет ASSERT прямо в скрипте:

    .stack (NOLOAD) : {
        . = ALIGN(16);
        _stack_bottom = .;
        . += 1024;
        . = ALIGN(16);
        _stack_top = .;
    } > RAM :data

    /* Худший случай: fib(46) это 46 кадров по 16 байт. */
    ASSERT(_stack_top - _stack_bottom >= 736, "stack too small for fib(46)")

Проверяю обе стороны. С 1024 байтами сборка проходит молча. Ставлю 128:

ld.exe: stack too small for fib(46)
collect2.exe: error: ld returned 1 exit status

ELF не создаётся вовсе.

Тот самый расчёт из третьей статьи, 46 кадров по 16 байт, я тогда записал комментарием и понадеялся, что следующий его прочитает. А его можно было отдать компоновщику, и он бы сверял при каждой сборке. Бесплатно.

Что ещё умеет считать тулчейн

У GCC есть ключ -fstack-usage, он выкладывает рядом с объектным файлом .su с размером кадра по каждой функции:

t.c:1:5:leaf   0   static
t.c:2:5:mid    32  static

У листовой функции ноль: ей не нужен кадр, потому что адрес возврата живёт в регистре, и мы это разбирали в прошлый раз.

Две честные оговорки. Работает по C, а по нашему ассемблеру инструмент молчит, там считать руками. И глубину рекурсии он не даёт: он про один кадр, а не про цепочку. А как только в графе вызовов появляется цикл, длину цепочки не посчитает уже никто: она зависит от данных, а не от кода. Ровно поэтому в стандартах вроде MISRA рекурсию запрещают прямым правилом.

Нам это пригодится, когда дойдём до C. Пока просто знаем, что инструмент есть.

Что из этого получился за файл

За статью скрипт компоновщика оброс тремя вещами, и каждая закрывала то, обо что я споткнулся. Вот он целиком, чтобы не собирать по кускам.

OUTPUT_ARCH(riscv)
ENTRY(_start)

MEMORY
{
    RAM (rwx) : ORIGIN = 0x80000000, LENGTH = 128M
}

/* Имя, тип и права. 4 это R, 2 это W, 1 это X. */
PHDRS
{
    text   PT_LOAD FLAGS(5);    /* R + X */
    rodata PT_LOAD FLAGS(4);    /* R     */
    data   PT_LOAD FLAGS(6);    /* R + W */
}

SECTIONS
{
    .text   : { *(.text.entry) *(.text) *(.text.*) } > RAM :text
    .rodata : { *(.rodata) *(.rodata.*) }            > RAM :rodata
    .bss    : { *(.bss) *(.bss.*) }                  > RAM :data

    .stack (NOLOAD) : {
        . = ALIGN(16);
        _stack_bottom = .;
        . += 1024;
        . = ALIGN(16);
        _stack_top = .;
    } > RAM :data

    /* Худший случай нашей рекурсии: fib(46) это 46 кадров по 16 байт. */
    ASSERT(_stack_top - _stack_bottom >= 736, "stack too small for fib(46)")
}

Три места, которых не было в начале статьи. PHDRS разводит права, и предупреждение про RWX не возникает. .text.entry первой строкой в .text кладёт точку входа туда, куда придёт машина, сколько бы файлов у вас ни было и в каком бы порядке их ни собирали. ASSERT заставляет компоновщика сверять размер стека при каждой сборке.

Если брать себе, менять надо три числа: ORIGIN и LENGTH под карту своей машины, размер стека вместо 1024 и порог в ASSERT вместо 736. И в исходнике объявить точку входа как .section .text.entry, иначе первое из трёх не сработает.

Файл целиком лежит в репозитории: 04-elf/link-entry.ld. Там же рядом три промежуточных варианта, по одному на каждый шаг этой статьи, если интересно посмотреть, что менялось.

Для тех, кто хочет разобраться сам

Ссылки из первых трёх статей в силе, здесь то, что понадобилось в этой.

Итог

Файл, который мы три статьи отдавали не глядя, оказался устроен разумно: одни и те же байты описаны дважды, для сборки и для запуска. Предупреждение про RWX было не придиркой, а следствием того, что описание для запуска я никогда не задавал сам.

А поле «точка входа» на нашем стенде декоративное. Оно там есть, оно правильное, и его никто не читает.

Код и история по шагам: github.com/Pro100lamer/uart-to-lang, тег article-04. Опыты повторяются командами make decoy, make high, make broken-entry и make small, каждая ломается по-своему.

В следующей статье возьмёмся за собственный ассемблер. Пора перестать звать чужой gcc и разобраться, как из строчки addi sp, sp, -16 получаются те самые 4 байта ff010113, которые мы столько раз видели в дизассемблере.

А пока вопрос. Кто-нибудь ловил на практике, что загрузчик игнорирует точку входа из файла? Мне интересно, у нас это особенность стенда или так делают и в жизни.