Три статьи подряд я собирал файл и отдавал его эмулятору, ни разу в него не заглянув. Компоновщик что-то делает, 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
Программа считает как ни в чём не бывало. В поле можно записать мусор, и машина этого не заметит.

Дополнение, дописано после выхода статьи. В комментариях @unreal_undead2 и @maquefel заметили, что QEMU умеет грузить ELF по заголовкам, если звать его иначе. Умеет. Одну фразу ниже надо читать с поправкой: там, где сказано «если бы QEMU читал точку входа», точнее было бы «если бы -kernel читал точку входа».
Но пока я это проверял, нашлось кое-что поинтереснее. Вот слово по адресу 0x1018, то самое, которое переходник машины читает перед прыжком:
-bios none -kernel hello.elf 0x80000000 -bios none -kernel high.elf 0x80000000 -bios hello.elf 0x80000000 -bios high.elf 0x80100000
hello.elf собран по 0x80000000, high.elf по 0x80100000. Под -kernel переходник машины не меняется: какой файл ни подсунь, управление уйдёт по адресу, зашитому в машину. А -bios high.elf этот переходник переписал, взяв адрес из заголовка файла.
Вот и вся разница. -bios и -device loader,cpu-num=0 это удобства эмулятора: они дают файлу распорядиться тем, откуда машина стартует. На настоящем кристалле адрес сброса зашит в железо, и файл на него не влияет. Поэтому весь стенд этой серии и стоит на -bios none: нужна машина, которая ведёт себя как железо, а не как эмулятор с удобствами.
Так что поле «точка входа» тут декоративно не по недосмотру QEMU. Оно декоративно потому, что мы нарочно поставили машину в положение, где его некому прочитать. Появился загрузчик, будь то U-Boot, ядро Linux или тот же -bios, и поле сразу работает.
Где я споткнулся
Поверил полю, потому что в нём стояло ожидаемое число.
Точка входа в заголовке совпадала с 0x80000000, всё сходилось, и три статьи я считал, что связь причинная. На самом деле совпадали два независимых числа: адрес, зашитый в машину, и адрес, который я написал в своём скрипте.
Пока они совпадают, ошибку не видно. Мне повезло, что я угадал правильно с первого раза, иначе первая статья закончилась бы пустым экраном и я бы искал ошибку в коде вывода.
Это второй раз за серию, когда я принял совпадение за объяснение. В третьей статье было то же самое с регистром sp: он вёл себя как указатель стека, значит казался особым, а оказался обычным x2.
Поправка к первой статье. Там сказано так: «Мы кладём .text первой секцией, поэтому _start оказывается по адресу 0x80000000, куда процессор и придёт».
Виновато слово «поэтому». Оба факта в этом предложении верные: мы правда кладём .text первой, и процессор правда приходит на 0x80000000. Но одно из другого не следует, они независимы. Процессор пришёл бы туда, даже если бы я положил .text последней, просто застал бы там не _start.
Тот же самый случай, что абзацем выше: я склеил в причинную связь два числа, которые всего лишь совпали.
Разница важная. Если бы QEMU читал точку входа, порядок секций был бы вопросом аккуратности. А -kernel его не читает, и порядок секций становится вопросом работоспособности.
Как это делают, когда не угадывают. Приём простой: отдельная секция под точку входа, которую скрипт кладёт первой явно.
.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, которые мы столько раз видели в дизассемблере.
А пока вопрос. Кто-нибудь ловил на практике, что загрузчик игнорирует точку входа из файла? Мне интересно, у нас это особенность стенда или так делают и в жизни.
Комментарии (11)

m039
09.08.2026 08:08Обычно так не бывает, чтобы точку входа игнорируют. При том, что я плохо понял статью (сейчас не могу её внятно прочитать), то относился бы к этой информации с кепсисом или, если это действтительно так, то изучал досконально момент где она игнорируется в ядре линукса.
Посмотрел чуть-чуть репозиторий, вы каждый файл ассемблера отдельно компилируете? Если так, то везде будет 0x8000000, надо либо объектные файлы, а потом собирать их вместе, либо один большой файл с ассемблером.

m039
09.08.2026 08:08Забудьте, что я написал вторым абзацем, там глупость. Попробовал повторить опыт с изменением стартовой точки и у меня получилось следующее:
Скрытый текст
Правильный экзешник
m039@DESKTOP-FC78JS1:~/Code/test$ readelf -h ./a.out ELF Header: Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64 Data: 2's complement, little endian Version: 1 (current) OS/ABI: UNIX - System V ABI Version: 0 Type: DYN (Position-Independent Executable file) Machine: Advanced Micro Devices X86-64 Version: 0x1 Entry point address: 0x1060 Start of program headers: 64 (bytes into file) Start of section headers: 13968 (bytes into file) Flags: 0x0 Size of this header: 64 (bytes) Size of program headers: 56 (bytes) Number of program headers: 14 Size of section headers: 64 (bytes) Number of section headers: 31 Section header string table index: 30Измененный виртуальный адрес, изменил с помощью хекс редактора.
m039@DESKTOP-FC78JS1:~/Code/test$ readelf -h ./a2.out ELF Header: Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64 Data: 2's complement, little endian Version: 1 (current) OS/ABI: UNIX - System V ABI Version: 0 Type: DYN (Position-Independent Executable file) Machine: Advanced Micro Devices X86-64 Version: 0x1 Entry point address: 0x101060 Start of program headers: 64 (bytes into file) Start of section headers: 13968 (bytes into file) Flags: 0x0 Size of this header: 64 (bytes) Size of program headers: 56 (bytes) Number of program headers: 14 Size of section headers: 64 (bytes) Number of section headers: 31 Section header string table index: 30Там теперь `Entry point address: 0x101060`.
Пытаюсь его запустить:
m039@DESKTOP-FC78JS1:~/Code/test$ ./a2.out -bash: ./a2.out: Permission denied m039@DESKTOP-FC78JS1:~/Code/test$ qemu-x86_64 ./a2.out Error while loading /home/m039/Code/test/a2.out: Exec format errorКажется, я как-то не так все понял и не могу повторить эксперимент. У меня с измененной стартовой точкой не хочет запускаться экзешник, ни в линуксе, ни в qemu.
Можно еще поизучать исходники qemu и поискать там обращения к e_entry.

Pro100lamer Автор
09.08.2026 08:08Спасибо, что полезли проверять руками.
У вас всё получилось. Просто результат читается наоборот: вы показали, что в Linux точку входа как раз читают. Повторил ваш опыт на Ubuntu, тот же PIE, тот же сдвиг восьми байт по смещению 24:
оригинал, +x ok точка входа сдвинута, +x Segmentation fault точка входа сдвинута, без +x Permission deniedPermission denied тут не про заголовок. Файл с нетронутым заголовком и снятым правом на исполнение даёт ту же самую ошибку: хекс-редактор сохранил новый файл без +x. Сделайте chmod +x, и получите Segmentation fault, а это и есть доказательство, что заголовок прочитали и ушли по нему в пустоту.
Про Exec format error под qemu-x86_64 уверенности нет, поэтому предположением: у PIE сегменты LOAD кончаются в районе 0x4000, а 0x101060 лежит за пределами всего образа, и загрузчик qemu-user, видимо, бракует файл ещё на разборе. Сам проверить не смог, qemu-user под рукой не встал.
Где заголовок читают, там поле работает. У меня стенд стоит на -bios none, то есть без всякой прошивки и загрузчика, и читать его там просто некому. Как на плате, куда залили сырой образ. Но мне это еще стоит проверить на живой плате, но как писал ранее это не точно. Всё зависит от того что смогу ли я получить плату.

m039
09.08.2026 08:08Да, не заметил как chmod +x упустил, так то я его делал, но на другом экзешнике, но все равно ошибку выдает, что говорит что линукс и qemu читают стартовую точку.
m039:~/Code/test$ chmod +x a2.out m039:~/Code/test$ ./a2.out Segmentation fault ./a2.out m039:~/Code/test$ qemu-x86_64 ./a2.out qemu: uncaught target signal 11 (Segmentation fault) - core dumped Segmentation fault qemu-x86_64 ./a2.out
Pro100lamer Автор
09.08.2026 08:08Да, я это место не проверил. Сейчас выбрал время и разобрался.
Поставил qemu-user и прогнал все четыре сочетания:
точка входа цела, +x ok точка входа сдвинута, +x сигнал 11 точка входа цела, 644 отказ, код 1 точка входа сдвинута, 644 отказ, код 1Без права на исполнение отказ одинаковый и от точки входа не зависит вообще. С правом всё решает точка входа. Так что вы правы, а моё предположение про то, что qemu-user бракует файл на разборе, было не верно, он его грузит и уходит по подменённому адресу.
А заодно нашлось, почему сообщение такое обманчивое. linux-user/linuxload.c: prepare_binprm за отсутствующий +x возвращает честный -EACCES, но вызывающий loader_exec делает так:
retval = prepare_binprm(bprm); if (retval < 4) { return -ENOEXEC; }prepare_binprm при успехе возвращает число прочитанных байт, поэтому любой отрицательный код меньше четырёх и превращается в -ENOEXEC. Отсюда и Exec format error вместо Permission denied. У меня на 8.2.2 сообщения нет, просто код 1, так что тут ещё и от версии зависит.
Итого ваш опыт дал готовый список тех, кто точку входа читает: ядро Linux и qemu-user. С моей стороны к ним U-Boot и сам QEMU через -bios или loader с cpu-num=0. На стенде нет ни одного из них, поэтому там её и не читает никто.
Занятно, что расхождение вышло не в результатах, а в том, кто какой способ запуска держал в голове. Прогоны-то у всех сошлись, спор был про -kernel против -bios и loader, то есть про разные машины. Ну и тексты ошибок, как видно, ещё и от версии зависят.
maquefel
09.08.2026 08:08На всякий случай - qemu-user и qemu-system разные вещи, общее у них только TCG:
- qemu-user эмуляция отдельного Linux-приложения. Системные вызовы транслируются в вызовы ядра хоста. Быстро, но работает только под Linux/BSD; - qemu-sytem (так же называется softmmu) эмуляция целой системы: процессора, памяти, периферии (UART, GPIO, АЦП и т.д.). Именно этот режим позволяет "крутить" операционную систему для другой архитектуры;Более того следует различать режимы baremetal без MMU где используются физчиеские адреса и с MMU используются виртуальные адреса и памятью управляет ОС.
unreal_undead2
Вроде как -device loader в qemu должен честно грузить и запускать ELF согласно заголовкам.
maquefel
Не вроде, а так и есть.
Более того в данном случае можно запускать с ключём -bios, тоже будет работать.
Если запускать бинарник c произвольным entry point, тогда:
Что имел ввиду автор я после первого прочтения не понял.
https://github.com/riscv-software-src/riscv-tests без проблем запускаются с ключом -bios=файл или через loader.
Pro100lamer Автор
Проверил
-bios, работает, спасибо.Статья вот про что: машина стартует туда, куда зашито, и файл на это не влияет, ровно как на кристалле (по крайней мере я на это надеюсь). Ради этого весь стенд и стоит на -bios none. А -bios и loader с cpu-num=0 это возможности эмулятора, которых у железа нет: они дают файлу распорядиться тем, откуда машина стартует. В статье это надо было сказать прямо, а одна фраза там и вовсе говорит «QEMU не читает» вместо «-kernel не читает». Допишу дополнение в текст.
Pro100lamer Автор
Проверил, вы правы: с cpu-num=0 точка входа читается. Одну фразу в статье надо было сказать точнее, там «QEMU не читает», а верно «-kernel не читает».
А пока проверял, нашлось кое-что поинтереснее. Вот слово по 0x1018, которое переходник машины читает перед прыжком:
hello.elf собран по 0x80000000, high.elf по 0x80100000. Под -kernel переходник машины не меняется вообще: какой файл ни дай, управление уйдёт туда, куда зашито. А -bios этот переходник переписал адресом из заголовка.
Похоже, в этом и вся разница. -bios и loader с cpu-num=0 дают файлу распорядиться тем, откуда машина стартует, а на кристалле адрес сброса зашит в железо. Стенд у меня стоит на -bios none как раз ради этого, и читать заголовок там просто некому.
Досюда всё про QEMU, а на плате читать заголовок обычно уже некому:
objcopy -O binaryсрезает их физически, у меня из 5824 байт ELF остаётся 472 байта прошивки, и сигнатуры\x7fELFв ней нет. Правда,-O ihexи-O srecточку входа всё-таки несут, отдельной записью (:040000058000001C5BиS7058000001C5E), так что зависит от формата. А дальше от того, кто грузит: U-Boot вbootelfпрыгает именно наe_entry(lib/elf.c,return ehdr->e_entry), а вотload_imageв OpenOCD только кладёт байты, адрес там задаётся отдельнойresume. Похоже, поле читает не машина, а загрузчик, и вопрос всегда в том, есть ли он. Своей платы у меня нет, на железе не проверял.Как дотянусь до железок перепроверю(но это не точно)
maquefel
А -kernel и не должен. Это опция для загрузки U-Boot Proper или Linux kernel после отработки встроенного OpenSBI. А они вообще в S-MODE уже стартуют. Это первое.
Второе -M virt (впрочем как и другие RISC-V машины в QEMU) по умочанию стартуют с mrom (небольшой код в ~8 инструкций который содержит адрес fw_info -> https://elixir.bootlin.com/qemu/v11.1.0-rc3/source/hw/riscv/boot.c#L483.
Третье - тогда как loader в принципе обходит эту цепочку и его команды означают:
Загрузи мне блоб по определнному адресу, и используя первый cpu (у cpu вообще говоря могут быть разные адресные пространства, например imx7d).
Принудительно задавай reset-vector указанное значение.
Четвертое, по умолчанию reset_vector для -M virt 0x1000 mrom который потом сам дойдет до 0x80000000 и всё собственно.
А вот если сделать:
Пожалуйста - принудильно установили PC в 0x80001000 для cpu0.
Про физику писать ничего не буду - там и так всё понятно - вот reset_vector, будь добр чтобы твоя программа начиналась и работала именно с этого адреса. Хотя там можно загрузить через gdb который сам позабодиться о pc === entry_point.