Три статьи подряд я собирал файл и отдавал его эмулятору, ни разу в него не заглянув. Компоновщик что-то делает, QEMU что-то читает, на экране появляются буквы. Работает же.
Заглянул. В заголовке первым делом нашлось поле «точка входа», и там стоял тот самый 0x80000000, вокруг которого у меня было построено объяснение из первой статьи.
Поле есть. Читать его никто не собирается.
▍ Навигация по серии
Часть 4. Точка входа, которую никто не читает ← вы здесь
Что получится в конце
Программа та же, что в третьей статье, я её не трогал. Меняется только то, как она разложена по файлу.
Уйдёт предупреждение про 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
Три сегмента вместо одного, права раздельные, предупреждения нет. Программа работает так же, вывод байт в байт тот же.

Обратите внимание на третью строку: 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 лежит переходник из 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
Программа считает как ни в чём не бывало. В поле можно записать мусор, и машина этого не заметит.

Где я споткнулся
Поверил полю, потому что в нём стояло ожидаемое число.
Точка входа в заголовке совпадала с 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. Там же рядом три промежуточных варианта, по одному на каждый шаг этой статьи, если интересно посмотреть, что менялось.
Для тех, кто хочет разобраться сам
Ссылки из первых трёх статей в силе, здесь то, что понадобилось в этой.
Введение в ELF-файлы в Linux: понимание и анализ. Про два взгляда на одни байты подробнее, чем у меня, и с картинками.
Спецификация ELF. Первоисточник, по-английски. Нужны главы про заголовок, секции и программные заголовки.
Документация GNU ld. Разделы
PHDRSиBuiltin Functions, гдеASSERT. Читается тяжело, но отвечает точно.Документация QEMU по машине virt. Карта памяти машины, включая адрес, куда уходит управление.
Итог
Файл, который мы три статьи отдавали не глядя, оказался устроен разумно: одни и те же байты описаны дважды, для сборки и для запуска. Предупреждение про 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, которые мы столько раз видели в дизассемблере.
А пока вопрос. Кто-нибудь ловил на практике, что загрузчик игнорирует точку входа из файла? Мне интересно, у нас это особенность стенда или так делают и в жизни.