Как ЦП узнает, сколько байтов он должен прочитать для следующей инструкции, учитывая, что инструкции имеют разную длину?

Итак, я читал статью, и в ней говорилось, что статический разбор кода двоичного файла неразрешим, потому что последовательность байтов может быть представлена ​​​​сколькими возможными способами, как показано на рисунке (это x86)

Как ЦП узнает, сколько байтов он должен прочитать для следующей инструкции, учитывая, что инструкции имеют разную длину?

поэтому мой вопрос:

  1. как процессор выполняет это тогда? например, на картинке, когда мы добираемся до C3, как он узнает, сколько байтов он должен прочитать для следующей инструкции?

  2. как ЦП узнает, на сколько он должен увеличить ПК после выполнения одной инструкции? он каким-то образом сохраняет размер текущей инструкции и добавляет его, когда хочет увеличить ПК?

  3. если ЦП может каким-то образом узнать, сколько байтов он должен прочитать для следующей инструкции, или как интерпретировать следующую инструкцию, почему мы не можем сделать это статически?

Он не знает заранее, но будет знать, есть ли полная инструкция или нужно прочитать больше байтов. Да, вы можете сделать это и статически. Чего вы не можете сделать, так это просто определить целевые адреса для динамических переходов и тому подобного. В вашем примере вы не знаете, куда пойдет jmp eax.

Jester 30.05.2019 23:46

@Jester, так как мы можем сделать это статически, например, как мы можем узнать, какая из трех возможностей на картинке верна? и вы говорите, что статья неверна о том, что статический дизассемблирование двоичного файла неразрешимо? ссылка на статью: utdallas.edu/~kxh060100/wartell12ccs.pdf. и да, я знаю про "незнание пункта назначения прыжка", но я спрашиваю о простом дизассемблировании кода.

OneAndOnly 30.05.2019 23:48

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

Jester 30.05.2019 23:58

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

OneAndOnly 31.05.2019 00:02

Процессор работает инструкция за инструкцией. Первое, что он делает, это jmp eax. Если вы не знаете, куда это пойдет, вы не можете сказать, что процессор будет делать дальше. Если следующие байты никогда не достигаются при выполнении, процессор не заботится о них. Он никогда ничего не сделает с ними, поэтому они могут быть просто случайными байтами.

Jester 31.05.2019 00:03

@Jester Хорошо, тогда просто представьте, что прыжка нет, и он начинается со второй инструкции, вы говорите, что тогда мы можем его разобрать? и будет только один возможный путь, а не три?

OneAndOnly 31.05.2019 00:06

Затем вы можете разобрать до retn, что является еще одним динамическим прыжком, вы не знаете, куда он вас приведет. PS: в крайних случаях одни и те же байты могут выполняться несколько раз с разными границами инструкций, поэтому процессор буду выполняет их по-разному, поэтому единой правильной дизассемблирования не бывает.

Jester 31.05.2019 00:07

Я ответил в принципе тот же вопрос некоторое время назад.

fuz 31.05.2019 15:01

Обратите внимание, что db — это не инструкция, а скорее директива ассемблера, которая говорит «поместите этот байт сюда». Процессор всегда использует левую интерпретацию при обработке этого кода.

fuz 31.05.2019 15:04
Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
5
9
1 118
3
Перейти к ответу Данный вопрос помечен как решенный

Ответы 3

Статический дизассемблер неразрешим, потому что дизассемблер не может различить, является ли группа байтов кодом или данными. Пример, который вы привели, хорош: после инструкции RETN может быть еще одна подпрограмма, или это могут быть какие-то данные, а затем подпрограмма. Невозможно решить, какой из них правильный, пока вы не выполните код.

Когда код операции считывается во время фазы выборки инструкции, сам код операции кодирует инструкцию одного типа, и секвенсор уже знает, сколько байтов нужно считать из нее. Нет никакой двусмысленности. В вашем примере после извлечения C3, но перед его выполнением, ЦП настроит свой регистр EIP (указатель инструкции), чтобы прочитать то, что, по его мнению, будет следующей инструкцией (начинающейся с 0F), НО во время выполнения инструкции C3 ( которая является инструкцией RETN), EIP изменен, так как RETN — это «Возврат из подпрограммы»), поэтому она не достигнет инструкции 0F 88 52. Эта инструкция будет достигнута только в том случае, если какая-то другая часть кода перейдет к местоположению этой инструкции. Если ни один код не выполняет такой переход, то он будет считаться данными, но проблема определения, будет или не будет выполняться та или иная инструкция, не является разрешимой проблемой.

Некоторые хитрые дизассемблеры (я думаю, что IDA Pro делает это) начинают с места, где хранится код, и предполагают, что все последующие байты также являются инструкциями, ДО ТОГО, КАК не будет найден переход или возврат. Если переход найден и пункт назначения перехода известен путем чтения двоичного кода, то сканирование продолжается там. Если прыжок условный, то сканирование разветвляется на два пути: прыжок не выполнен и прыжок совершен.

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

так что внутри раздела .text могут быть данные? вместо разделов данных? любая ссылка на это? потому что все двоичные файлы, с которыми я работал, не имели данных внутри раздела .text. и единственная ссылка, которую я нашел, была эта статья, и я не мог найти ничего другого. Знаете ли вы какие-либо другие ссылки, подтверждающие это утверждение?

OneAndOnly 31.05.2019 00:15

Конечно, вы можете разместить данные в .text. В этом нет ничего волшебного, за исключением того, что это, вероятно, будет только для чтения. Также .text — это просто имя. Вы можете называть свои разделы (хотя бы код и данные) как угодно, главное — это их атрибуты. Некоторые разделы имеют определенные имена, от которых зависят различные инструменты и загрузчики.

Jester 31.05.2019 00:48

И помните, что не весь двоичный код исходит из скомпилированной программы, изначально написанной на каком-то языке высокого уровня. Вы можете писать ассемблерный код и помещать данные между подпрограммами.

mcleod_ideafix 31.05.2019 00:59

@Jester, но я думал, что весь раздел получает одинаковые разрешения, например, только для чтения? потому что, когда вы используете readelf в двоичном файле, вы можете видеть, что, например, раздел .text является исполняемым, и, следовательно, если у нас есть данные внутри, то это также сделает данные исполняемыми!!

OneAndOnly 31.05.2019 01:15

Да, это делает размещенные там данные исполняемыми, но это не означает, что выполнение действительно произойдет. Если вы не переходите к своим данным, то процессору все равно, исполняемый он или нет.

Jester 31.05.2019 01:27

@OneAndOnly Необычно находить данные, смешанные с кодом в x86. Нет никакого выигрыша в производительности (в отличие от ARM или других ISA с режимами адресации ближнего действия относительно ПК). В «нормальном» выводе компилятора нормально работает запуск в начале раздела .text и предполагается, что другая инструкция начинается сразу после этой, даже если это безусловная ветвь. Инструкции x86 действительно декодируются уникальным образом с учетом начальной точки, и компиляторы никогда не переходят в середину инструкции, которая ранее декодировалась из другой начальной точки. Это сложно только для запутанного кода, который пытается сломать дизассемблирование.

Peter Cordes 31.05.2019 02:10
Ответ принят как подходящий

Самый простой способ — просто прочитать один байт, декодировать его, а затем определить, является ли это полной инструкцией. Если не прочитан другой байт, при необходимости декодируйте его, а затем определите, была ли прочитана полная инструкция. Если нет, продолжайте чтение/декодирование байтов до тех пор, пока не будет прочитана вся инструкция.

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

Инструкция RET (retn) в вашем примере может быть концом функции. Функции часто заканчиваются инструкцией e RET, но это не обязательно. Функция может иметь несколько инструкций RET, ни одна из которых не находится в конце функции. Вместо этого последняя инструкция будет своего рода инструкцией JMP, которая переходит назад к некоторому месту в функции или полностью к другой функции.

Это означает, что в вашем примере кода без дополнительного контекста невозможно узнать, будет ли когда-либо выполнен какой-либо из байтов, следующих за инструкцией RET, и если да, то какой из байтов будет первой инструкцией следующей функции. Между функциями могут быть данные, или эта инструкция RET может быть концом последней функции в программе.


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

В Руководстве разработчика программного обеспечения для архитектур Intel 64 и IA-32 есть этот рисунок, показывающий формат инструкций x86:

X86 Instruction Format

Python-подобный псевдокод для декодирования инструкций x86 будет выглядеть примерно так:

# read possible prefixes

prefixes = []
while is_prefix(memory[IP]):
    prefixes.append(memory[IP))
    IP += 1

# read the opcode 

opcode = [memory[IP]]
IP += 1
while not is_opcode_complete(opcode):
    opcode.append(memory[IP])
    IP += 1

# read addressing form bytes, if any

modrm = None
addressing_form = []    
if opcode_has_modrm_byte(opcode):
    modrm = memory[IP]
    IP += 1
    if modrm_has_sib_byte(modrm):
        addressing_form = [modrm, memory[IP]]
        IP += 1
    else:
        addressing_form = [modrm]

# read displacement bytes, if any

displacement = []
if (opcode_has_displacement_bytes(opcode)
    or modrm_has_displacement_bytes(modrm)):
    length = determine_displacement_length(prefixes, opcode, modrm)
    displacement = memory[IP : IP + length]
    IP += length

# read immediate bytes, if any

immediate = []
if opcode_has_immediate_bytes(opcode):
    length = determine_immediate_length(prefixes, opcode)
    immediate = memory[IP : IP + length]
    IP += length

# the full instruction

instruction = prefixes + opcode + addressing_form + displacement + immediate

Одна важная деталь, не учтенная в приведенном выше псевдокоде, заключается в том, что длина инструкций ограничена 15 байтами. Можно создать допустимые в других отношениях инструкции x86 размером 16 байтов и более, но при выполнении такие инструкции будут генерировать исключение CPU с неопределенным кодом операции. (Есть и другие детали, которые я упустил, например, как часть кода операции может быть закодирована внутри байта Mod R/M, но я не думаю, что это влияет на длину инструкций.)


Однако процессоры x86 на самом деле не декодируют инструкции так, как я описал выше, они только декодируют инструкции, как если бы они считывали каждый байт по одному. Вместо этого современные ЦП считывают в буфер целых 15 байтов, а затем декодируют байты параллельно, обычно за один цикл. Когда он полностью декодирует инструкцию, определяя ее длину, и готов прочитать следующую инструкцию, он перемещается по оставшимся байтам в буфере, которые не были частью инструкции. Затем он считывает дополнительные байты, чтобы снова заполнить буфер до 15 байтов, и начинает декодирование следующей инструкции.

Еще одна вещь, которую современные процессоры будут делать, что не подразумевается тем, что я написал выше, - это спекулятивное выполнение инструкций. Это означает, что ЦП будет декодировать инструкции и предварительно пытаться выполнить их даже до того, как он завершит выполнение предыдущих инструкций. Это, в свою очередь, означает, что ЦП может в конечном итоге декодировать инструкции, следующие за инструкцией RET, но только если он не может определить, куда вернется RET. Поскольку при попытках декодирования и предварительного выполнения случайных данных, которые не предназначены для выполнения, могут возникнуть потери производительности, компиляторы обычно не помещают данные между функциями. Хотя они могут заполнить это пространство инструкциями NOP, которые никогда не будут выполняться, чтобы выровнять функции по соображениям производительности.

(Раньше они помещали данные только для чтения между функциями, но это было до того, как процессоры x86, которые могли спекулятивно выполнять инструкции, стали обычным явлением.)

Но, исходя из другого вопроса, в разделе кода не должно быть никаких данных, потому что это не дает никакой пользы! ссылка: stackoverflow.com/questions/55607052/… (спрашивают об одной и той же статье)

OneAndOnly 31.05.2019 11:28

Давным-давно, вероятно, также были разделены кеши I/D, где разреженные данные в строках, содержащих в основном инструкции, тратят место в кеше данных. (Также емкость TLB для разделения iTLB/dTLB). Но в любом случае, @OneAndOnly: правильно, обычно декодировать вывод компилятора легко; конец одной инструкции идентифицирует начало следующей, даже после безусловных переходов. Обычно они дополняются NOP или int3. Вы столкнетесь с проблемами только с запутанными двоичными файлами. Документ, на который вы ссылаетесь, хочет работать надежно с исполняемым файлом Любые, поэтому не может делать предположений.

Peter Cordes 31.05.2019 11:30

@OneAndOnly Как объясняется в моем ответе, данные больше не отображаются в разделе кода с современными компиляторами, но давным-давно компиляторы помещали данные только для чтения в разделы кода, потому что это был единственный раздел, доступный только для чтения. программы. Выделенные разделы данных только для чтения создавались только тогда, когда процессоры переходили в состояние, когда размещение данных в разделах кода вызывало проблемы с производительностью. Однако старые компиляторы продолжали использоваться еще долгое время после того, как эти процессоры стали обычным явлением (с середины до конца 90-х или около того), поэтому вы видите смешанный код и данные во многих программах x86, скомпилированных до 2000 года и немного позже.

Ross Ridge 31.05.2019 19:33

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

Ross Ridge 31.05.2019 19:41

Ваша основная проблема, по-видимому, заключается в следующем:

if the CPU can somehow know how many bytes it should read for the next instruction or basically how to interpret the next instruction, why cant we do it statically?

Проблема, описанная в статье, связана с «прыгающими» инструкциями (что означает не только jmp, но и int, ret, syscall и подобные инструкции):

Цель таких инструкций состоит в том, чтобы продолжить выполнение программы с совершенно другого адреса, а не со следующей инструкции. (Вызовы функций и циклы while() являются примерами, когда выполнение программы не продолжается на следующей инструкции.)

Ваш пример начинается с инструкции jmp eax, что означает, что значение в регистре eax определяет, какая инструкция выполняется после инструкции jmp eax.

Если eax содержит адрес байта 0F, ЦП выполнит инструкцию jcc (левый случай на картинке); если он содержит адрес 88, он выполнит инструкцию mov (средний вариант на картинке); и если он содержит адрес 52, он выполнит инструкцию push (правый случай на картинке).

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

(Мне сказали, что в 1980-х годах даже были коммерческие программы, в которых во время выполнения происходили разные случаи: в вашем примере это означало бы, что иногда выполняется инструкция jcc, а иногда инструкция mov!)

When we reach after C3, how does it know how many bytes it should read for the next instruction?

How does the CPU know how much it should increment the PC after executing one instruction?

C3 не является хорошим примером, потому что retn является «прыгающей» инструкцией: «инструкция после C3» никогда не будет достигнута, потому что выполнение программы продолжается в другом месте.

Однако вы можете заменить C3 другой инструкцией длиной в один байт (например, 52). В этом случае четко определено, что следующая инструкция будет начинаться с байта 0F, а не с 88 или 52.

Другие вопросы по теме