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

Дополнение, дописано после выхода статьи. В комментариях @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. Там же рядом три промежуточных варианта, по одному на каждый шаг этой статьи, если интересно посмотреть, что менялось.

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

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

Итог

Файл, который мы три статьи отдавали не глядя, оказался устроен разумно: одни и те же байты описаны дважды, для сборки и для запуска. Предупреждение про 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)


  1. unreal_undead2
    09.08.2026 08:08

    Вроде как -device loader в qemu должен честно грузить и запускать ELF согласно заголовкам.


    1. maquefel
      09.08.2026 08:08

      Не вроде, а так и есть.

      Loading Files
      ^^^^^^^^^^^^^
      
      The loader device also allows files to be loaded into memory. It can load ELF,
      U-Boot, and Intel HEX executable formats as well as raw images.
      

      Более того в данном случае можно запускать с ключём -bios, тоже будет работать.

      Если запускать бинарник c произвольным entry point, тогда:

      -device loader,addr=<address>,file=<file>,cpu-num=0 -device loader,addr=<entry_point>,cpu-num=0 
      

      Что имел ввиду автор я после первого прочтения не понял.

      https://github.com/riscv-software-src/riscv-tests без проблем запускаются с ключом -bios=файл или через loader.


      1. Pro100lamer Автор
        09.08.2026 08:08

        Проверил -bios, работает, спасибо.
        Статья вот про что: машина стартует туда, куда зашито, и файл на это не влияет, ровно как на кристалле (по крайней мере я на это надеюсь). Ради этого весь стенд и стоит на -bios none. А -bios и loader с cpu-num=0 это возможности эмулятора, которых у железа нет: они дают файлу распорядиться тем, откуда машина стартует. В статье это надо было сказать прямо, а одна фраза там и вовсе говорит «QEMU не читает» вместо «-kernel не читает». Допишу дополнение в текст.


    1. Pro100lamer Автор
      09.08.2026 08:08

      Проверил, вы правы: с cpu-num=0 точка входа читается. Одну фразу в статье надо было сказать точнее, там «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 этот переходник переписал адресом из заголовка.

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

      Как дотянусь до железок перепроверю(но это не точно)


      1. maquefel
        09.08.2026 08:08

        а верно «-kernel не читает»

        А -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 в принципе обходит эту цепочку и его команды означают:

        -device loader,addr=<address>,file=<file>,cpu-num=0
        

        Загрузи мне блоб по определнному адресу, и используя первый cpu (у cpu вообще говоря могут быть разные адресные пространства, например imx7d).

        -device loader,addr=<address>,cpu-num=0
        

        Принудительно задавай reset-vector указанное значение.

        Четвертое, по умолчанию reset_vector для -M virt 0x1000 mrom который потом сам дойдет до 0x80000000 и всё собственно.

        А вот если сделать:

        $ build-qemu/qemu-system-riscv32 -M virt -bios none -device loader,file=riscv-tests/isa/rv32ui-p-add,addr=0x80001000 -device loader,addr=0x80001000,cpu-num=0 -nographic -serial mon:stdio -s -S
        QEMU 11.0.0 monitor - type 'help' for more information
        (qemu) info registers 
        
        CPU#0
         V      =   0
         pc       80001000
        

        Пожалуйста - принудильно установили PC в 0x80001000 для cpu0.

        Про физику писать ничего не буду - там и так всё понятно - вот reset_vector, будь добр чтобы твоя программа начиналась и работала именно с этого адреса. Хотя там можно загрузить через gdb который сам позабодиться о pc === entry_point.


  1. m039
    09.08.2026 08:08

    Обычно так не бывает, чтобы точку входа игнорируют. При том, что я плохо понял статью (сейчас не могу её внятно прочитать), то относился бы к этой информации с кепсисом или, если это действтительно так, то изучал досконально момент где она игнорируется в ядре линукса.

    Посмотрел чуть-чуть репозиторий, вы каждый файл ассемблера отдельно компилируете? Если так, то везде будет 0x8000000, надо либо объектные файлы, а потом собирать их вместе, либо один большой файл с ассемблером.


    1. 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.


      1. Pro100lamer Автор
        09.08.2026 08:08

        Спасибо, что полезли проверять руками.

        У вас всё получилось. Просто результат читается наоборот: вы показали, что в Linux точку входа как раз читают. Повторил ваш опыт на Ubuntu, тот же PIE, тот же сдвиг восьми байт по смещению 24:

        оригинал, +x                     ok
        точка входа сдвинута, +x         Segmentation fault
        точка входа сдвинута, без +x     Permission denied

        Permission denied тут не про заголовок. Файл с нетронутым заголовком и снятым правом на исполнение даёт ту же самую ошибку: хекс-редактор сохранил новый файл без +x. Сделайте chmod +x, и получите Segmentation fault, а это и есть доказательство, что заголовок прочитали и ушли по нему в пустоту.

        Про Exec format error под qemu-x86_64 уверенности нет, поэтому предположением: у PIE сегменты LOAD кончаются в районе 0x4000, а 0x101060 лежит за пределами всего образа, и загрузчик qemu-user, видимо, бракует файл ещё на разборе. Сам проверить не смог, qemu-user под рукой не встал.

        Где заголовок читают, там поле работает. У меня стенд стоит на -bios none, то есть без всякой прошивки и загрузчика, и читать его там просто некому. Как на плате, куда залили сырой образ. Но мне это еще стоит проверить на живой плате, но как писал ранее это не точно. Всё зависит от того что смогу ли я получить плату.


        1. 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


          1. 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, то есть про разные машины. Ну и тексты ошибок, как видно, ещё и от версии зависят.


            1. maquefel
              09.08.2026 08:08

              На всякий случай - qemu-user и qemu-system разные вещи, общее у них только TCG:

              - qemu-user эмуляция отдельного Linux-приложения. Системные вызовы транслируются в вызовы ядра хоста. Быстро, но работает только под Linux/BSD;
              
              - qemu-sytem (так же называется softmmu) эмуляция целой системы: процессора, памяти, периферии (UART, GPIO, АЦП и т.д.). Именно этот режим позволяет "крутить" операционную систему для другой архитектуры;
              

              Более того следует различать режимы baremetal без MMU где используются физчиеские адреса и с MMU используются виртуальные адреса и памятью управляет ОС.