EuroAssembler ― Кроссплатформенный ассемблер с открытым исходным кодомАвтор ― программист Pavel Šrubař из небольшого чешского городка Vítkov (город в Опавском районе Моравскосилезского края Чехии. Население около 5500 человек). Трудился в IT отделе Чешской Почты города Vítkov. Сейчас живет в Праге. Электронная почта pavel.srubar@post.cz, твиттер http://twitter.com/vitsoft Сайт Форум посвященный EuroAsm Домашняя страница euroassembler.eu. Скачать EuroAssembler можно отсюда, прямая ссылка на актуальную версию . Ассемблер написан на себе самом, это даёт компактность и портабельность, но на него нервно реагируют эвристики некоторых антивирусов. После распаковки у вас в руках будет euroasm.exe размером в четыреста килобайт. Исходный код ассемблера открыт и доступен, его несложно пересобрать в случае необходимости.
Это переделанная и урезанная статья Об ассемблере EuroAssembler, о котором вы, возможно, не слышали Андрея Дмитриева (@AndreyDmitriev) с хабра. Если ограничить кругозор ОС Windows, тогда обнаружим, что под эту ОС существует несколько макро-ассемблеров, которых не так уж много. Если основным инструментом является Visual Studio, то будет логичен выбор ассемблера MASM. Он достаточно популярен и бесплатен. Из альтернатив можно упомянуть FASM (Flat Assembler) и NASM (Netwide Assembler). У каждого диалекта ассемблера (MASM, TASM, FASM, NASM, A386) есть свои плюсы и минусы. Есть и малоизвестные, например входящий в состав Pelles C. Эти ассемблеры могут отличаться синтаксисом и идеологией (кто-то может ассемблировать в объектный файл, а другие имеют встроенный линковщик) но речь не о них. Важно понимать, что на "чистом" ассемблере далеко не уехать, поэтому у них есть приставка "макро", когда можно упростить программирование при помощи макросов.Первая программаКак говорили великие — единственный способ научиться программировать — это начать писать программы, давайте для начала просто сложим два числа и выведем результат в консоль (чистый "Hello, World!" будет состоять из одного макроса и вообще не будет содержать ни строчки на ассемблере, так что разбавим пример хотя бы mov и add). Всё, что вам надо знать, это то, что у процессора есть Регистры (коих шестнадцать штук общего назначения) и Инструкции, которыми он оперирует. Структура минимальной программы на этом ассемблере очень проста, вот она полностью: Код (ASM): EUROASM AddWorld PROGRAM Format=PE, Entry=Start INCLUDE winapi.htm, cpuext32.htm Result D 8*B Start: nop mov eax, 17 add eax, 29 StoD Result StdOutput =B"17+29=", Result TerminateProgram ENDPROGRAM Давайте разберём все строчки, их тут десяток всего-то. Ссылки на документацию приводятся. Программа начинается с ключевого слова EUROASM, за которым вообще говоря могут идти опции, но в этой минимальной программе они не нужны (потому что выставлены по умолчанию). Следом идёт имя программы и ключевое слово (псевдоинструкция) PROGRAM, перед которой находится имя программы, за которым две опции — формат PE, что означает Portable Executable (если бы мы писали DLL, то было бы очевидно DLL), и точка входа (может быть любая строка — Start, Begin или main — всё, что хотите, вы видите эту метку чуть ниже). Кстати, в одном файле может быть несколько секций PROGRAM, тогда у них должны быть разные имена, и в этом случае компиляцией одного файла можно сразу получить несколько исполняемых файлов или библиотек. Затем следует INCLUDE — здесь мы включаем две библиотеки макросов, из одной мы возьмём макрос перевода числа в строку, а из второй — вывод в консоль. И расширение .htm — это не ошибка — да, вы можете хранить код в HTML. Включать можно не только библиотеки, но вообще любые файлы, обычно им даётся расширение *.inc. Result D 8*B резервирует восемь байт для результата (для короткого числа в этом примере нам больше и не надо). Следом идёт метка Start: , это входная точка программы, куда будет передано выполнение после загрузки в память, и инструкция nop. Наличие оператора nop помогает евроассемблеру отделить мух от котлет код от данных — так работает автосегментация. Это позволяет избавиться от явного указания секций [.text] и [.data]. Также в отладчике этот NOP удобно видеть как "метку" начала программы и начала отладки. mov eax, 17 заносит значение 17 в регистр EAX, а add eax, 29 добавляет туда 29. Числа 17 и 29 я взял не случайно — 1729 это симпатичное число Рамануджана-Харди. Регистр не важен — можете писать MOV EAX,17, так предпочитает автор, но в основном нынче используются строчные буквы, время семибитных кодировок кануло в лету. StoD Result берёт значение RAX (там 46) и переводит его в ASCII строку "46", копируя в буфер, на который указывает Result. Как бы аналог itoa(). На самом деле перевод числа в строку на чистом ассемблере — не такая уж тривиальная задача, загляните в исходник по ссылке выше, там больше полусотни инструкций. Ну а StdOutput =B"17+29=", Result выводит на экран 17+29=46. =B — это "синтаксический сахар", позволяющий "заинлайнить" строковую константу-литерал, кроме того макрос StdOutput может принимать переменное число аргументов. Здесь также надо понимать, что для вывода в консоль надо ведь вначале получить хендл через GetStdHandle(), затем писать через WriteConsole(), при этом строка может содержать Юникод, вот от всего этого нас и избавляет данный макрос. Затем программа завершается через TerminateProgram (это тоже макрос, который вызывает ExitProcess() из kernel32.dll) и ключевое слово ENDPROGRAM (если у вас несколько программ в одном файле, то там понадобится имя программы). TerminateProgram не есть обязательная вещь — программа завершится и так, но формально пусть будет — ей можно передать код возврата ошибки. Создайте файл AddWorld.asm, да хоть в блокноте Windows и скопируйте туда код, что был выше, сохраните этот файл там же, где находится euroasm.exe (либо скопируйте весь евроассемблер в %APPDATA%\eurotool и добавьте путь в PATH). Сборка программы осуществляется при помощи команды euroasm.exe AddWorld.asm. Всё. Если вы скопировали всё без ошибок, то после компиляции появится файл AddWorld.exe, который и выведет это сообщение 17+29=46. Маленький лайфхак — если вам лень ставить редактор с поддержкой ассемблер-синтаксиса, равно как и возиться с командным промптом, просто поставьте Far Manager, встроенный редактор (новый файл Shift+F4, редактирование F4) понимает ассемблер и раскрашивает код, а компиляцию можно упростить через пользовательское меню, всё вместе это будет выглядеть как-то так (как видите, все папки дистрибутива не нужны, достаточно maclib и objlib): Нехитрый код выше оставляет широкий простор для экспериментов. Например, вы можете не хардкодить, а попросить пользователя ввести числа из консоли, давайте для разнообразия сделаем в 64 бит: Код (ASM): EUROASM CPU=x64 %^SourceName PROGRAM Format=PE, Width=64, Entry=Start Buffer1 D 32*B Buffer2 D 32*B Result D 32*B INCLUDE winabi.htm, cpuext64.htm Start: nop StdOutput =B"Enter 1st Operand ]" StdInput Buffer1 StdOutput =B"Enter 2nd Operand ]" StdInput Buffer2 LodD Buffer1 mov rbx, rax LodD Buffer2 add rax, rbx StoD Result StdOutput =B"Result:", Result TerminateProgram ENDPROGRAM Здесь несколько небольших изменений. Во-первых, имя программы заменено на переменную %^SourceName. Так удобнее работать в сценарии "один файл—одна программа", потому что имя будет браться из имени файла (и именно под этим именем будет создаваться исполняемый файл или библиотека DLL). Переменных там много — €ASM system %variables. Кроме того, мы переехали на 64 бита добавлением CPU=x64 и Width=64 и соответственно поправили INCLUDE, также вместо 32-битных регистров типа EAX используется 64-бит RAX. Затем добавлены два буфера для входных строк. StdOutput вы уже знаете, этот макрос выведет приглашающий промпт, а вот StdInput получит данные, введённые пользователем и запишет введённое значение в буфер, на который указывает Buffer1 (как ASCII символы, включая перевод строки). Затем всё повторяется для второго операнда. Таким образом, если мы введём "42", то в буфере будут 0x34, 0x32, 0x0D, 0x0A. Теперь нам надо конвертировать ASCII строку в значение, что-то типа atoi(), это делает макрос LodD, который возвращает значение в регистр RAX (теперь у нас 64 бит). Мы сохраним это значение в RBX, и повторим LodD для второго буфера. Теперь в RBX у нас первый операнд, а в RAX второй. Команда add RAX, RBX их складывает, и дальше всё как и выше. Важный момент — при таком использовании вы должны быть абсолютно уверены, что LodD Buffer2 не изменит значение RBX! (чтобы в этом убедиться, достаточно заглянуть в исходник макроса). Предположим, в качестве следующего упражнения мы хотим использовать стек для хранения первого введённого операнда, затолкаем RAX в стек и вытащим его в RBX: Код (ASM): LodD Buffer1 push rax LodD Buffer2 pop rbx add rax, rbx StoD Result Кстати, в этом ассемблере поддерживаются множественные переменные при работе со стеком, то есть не надо писать отдельно push rax, push ebx, а можно одной командой push rax, rbx — это удобно. Либо можно завести временную переменную, зарезервировав восемь байт памяти: Код (ASM): TempVar D Q ; Reserve one qword. ; ... LodD Buffer1 mov [TempVar], rax LodD Buffer2 add rax, [TempVar] StoD Result Можно получить значения из аргументов командной строки, для этого есть макросы GetArgCount и GetArg. В общем не бойтесь экспериментировать. Ассемблер снабжён неплохой инструкцией, кроме того довольно большим количеством примеров и проектов, в конце есть несколько для Windows, от простого консольного приложения, поддерживающего юникод до заготовки оконного приложения. Как справедливо заметили в комментариях, этот ассемблер может "собрать" файл для Линукса в том числе, всё что нужно для этого — заменить формат PE на ELFX и включить linapi.htm вместо winapi.htm, вот минимальный код: Код (ASM): EUROASM HelloWorld PROGRAM Format=ELFX, Entry=Start INCLUDE linapi.htm Start: nop StdOutput =B"Hello, World!", Eol=yes TerminateProgram ENDPROGRAM после сборки которого вы получите файл HelloWorld.x, который можно тут же запустить под WSL: $ ./HelloWorld.x Hello, World! $ file HelloWorld.x HelloWorld.x: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), statically linked, not stripped Более того, вы можете собирать исполняемые файлы одновременно для Windows и Linux (равно как и 32- и 64-бит версии) сложив общий код во включаемый файл и пользуясь тем фактом, что в одном файле можно иметь множественные секции PROGRAM, единственная хитрость — нужно сбросить макросы между программами: Код (ASM): EUROASM AutoSegment=Yes, CPU=X64, SIMD=AVX2 HelloLinux PROGRAM Format=ELFX, Width=64, Entry=Start: INCLUDE linabi.htm, cpuext64.htm INCLUDE Code.asm ; < Your code ENDPROGRAM HelloLinux %DROPMACRO * ; Forget macros defined in "linabi.htm". HelloWindows PROGRAM Format=PE, Width=64, Entry=Start: INCLUDE winabi.htm, cpuext64.htm INCLUDE Code.asm ENDPROGRAM HelloWindows Кстати, этот метод работает "в обе стороны", в том смысле, что вы можете не только собирать программы для Линукса из под Windows, но и наоборот из под Линукса под Windows, запуская euroasm.x.ОтладчикОчень рекомендуется овладеть отладчиком. Можно использовать WinDbg, но многие предпочитают x64dbg, хотя он не без проблем (в смысле общей стабильности), но во многом удобнее. Как им пользоваться? Допустим, вы не очень уверенно понимаете, как работает стек. Пишете небольшое приложение, которое заносит значения в два регистра, заталкивает их в стек, обнуляет (xor rax, rax — стандартный способ, привыкайте) и вытаскивает обратно: Код (ASM): EUROASM CPU=x64 %^SourceName PROGRAM Format=PE, Width=64, Entry=Start Start: nop mov rax, 0x17 mov rbx, 0x29 push rax, rbx xor rax, rax xor rbx, rbx pop rbx, rax jmp Start ENDPROGRAM Запустите отладчик x64dbg (для отладки 32 бит приложения надо будет запускать 32-бит отладчик, у нас же 64 бита), затем откройте файл приложения (F3), после чего однократно нажмите F9 для загрузки и перемещения на точку входа, вы должны остановиться на NOP, затем пройдите пошагово, нажимая F7. Вот что вы увидите: Хорошо видно, как уменьшается значение регистра RSP (указатель стека) на 8 байт при каждой инструкции push и как данные записываются в область памяти, на которую указывает указатель стека. Заметьте также, что ассемблер заменил инструкции mov rax, .. на mov eax, .., сэкономив вам несколько байт. Ещё полезная вещь — листинг компиляции, который для примера выше выглядит вот так: Код (Text): | |EUROASM CPU=x64 | |%^SourceName PROGRAM Format=PE, Width=64,... |[.text] ::::Section changed. |00000000: | |00000000:90 |Start: nop |00000001:B817000000 | mov rax, 0x17 |00000006:48BB2900000000000000 | mov rbx, 0x29, IMM=Q |00000010:5053 | push rax, rbx |00000012:4831C0 | xor rax, rax |00000015:4831DB | xor rbx, rbx |00000018:5B58 | pop rbx, rax |0000001A:EBE4 | jmp Start | |ENDPROGRAM | **** ListMap "StackTest.exe",model=FLAT,groups=0,segments=2,entry=Start | [.text],FA=0200h,VA=00401000h,size=28,width=64,align=0010h,purpose=CODE | [.rsrc],FA=0400h,VA=00402000h,size=13660,width=32,align=0010h,purpose=RES Тут в левой колонке показаны машинные коды, в которые будут преобразованы инструкции. NOP — это машинный код 0х90 (который знает наизусть каждый реверс-инженер). Эти же машинные коды вы видите и в отладчике выше. Для примера я указал, что хочу получить именно 64-бит код для второго mov, добавив модификатор IMM=Q, и вы видите появившийся префикс 48 и 64 бит константу в восьми байтах. Вся архитектура фон Неймана раскрывается во всей красе. Внизу вы видите виртуальный базовый адрес 00401000h (он складывается из стандартной базы 0х400000 и смещения), его же вы видите и в отладчике и секции файла, они выровнены на границу четырёх килобайт (0х1000), что составляет стандартный размер страницы памяти Windows. Всё просто.Эксперимент — WinAPIВам никто не запрещает напрямую вызывать функции WinAPI прямо из Ассемблера, в 64-бит программе это делается следующим образом, для совсем тривиального примера два последовательных вызова GetTickCount64(), разделённых Sleep(1000): Код (ASM): EUROASM CPU=x64, SIMD=AVX2 %^SourceName PROGRAM Format=PE, Width=64, Entry=Start Elapsed D 32*B INCLUDE winabi.htm, cpuext64.htm Start: nop WinABI GetTickCount64 push rax WinABI Sleep, 1000 WinABI GetTickCount64 pop rbx sub rax, rbx StoD Elapsed StdOutput =B"Sleep(1000) - ", Elapsed, =B" ms", Eol=yes jmp Start ENDPROGRAM Макрос WinABI следует соглашениям о вызове 64-бит функций — результат GetTickCount64() возвращается в RAX, параметр 1000 передаётся в Sleep() через RCX. Как результат вы будете видеть разницу в диапазоне 1000...1016 миллисекунд — так работает таймер низкого разрешения. В принципе ИИ способен достаточно внятно объяснить этот код выше. Само собой, вы можете вызывать не только WinAPI, но и любые экспортированные функции из любой DLL, вот, к примеру, если рудиментарный вывод в консоль вас не устраивает, вы вполне можете воспользоваться стандартной printf(...) из msvcrt.dll: Код (ASM): EUROASM CPU=X64, SIMD=AVX2 printf1 PROGRAM Format=PE, Width=64, Model=Flat, Entry=main: INCLUDE winabi.htm LINK msvcrt.lib ; for printf(...) main: nop mov rax, 42 WinABI printf, =B"printf: The Answer is %%d", rax ENDPROGRAM При этом поддерживается и динамический вызов при отсутствии *.lib файла, так тоже можно: Код (ASM): EUROASM CPU=X64, SIMD=AVX2 printf2 PROGRAM Format=PE, Width=64, Model=Flat, Entry=main: INCLUDE winabi.htm main: nop mov rax, 42 WinABI printf, =B"printf: The Answer is %%d", rax, Lib=msvcrt.dll ENDPROGRAM И поскольку евроассемблер — это и ассемблер и линковщик "в одном флаконе", то существует удобный скрипт, генерирующий библиотеку импорта lib из динамической библиотеки — dll2lib.htm. Вообще использовать ассемблер для микробенчмаркинга очень хорошо, поскольку всё находится в ваших руках, позволяя спуститься на уровень машинного кода, но, конечно же не при помощи GetTickCount. Ниже ещё пара примеров. Обычно в этом месте дотошные читатели резонно замечают, мол всё тоже самое можно получить на Си/С++ заметно меньшими усилиями, и это в общем так, но в данном случае мы не зависим от компилятора, его версии и опций оптимизатора, и всё под контролем. Ниже ещё несколько примеров на ассемблере, они не настолько велики, чтобы ради них писать отдельные статьи, но в контексте изложения — вполне уместны.Эксперимент — работа предсказателя переходовНе так давно на хабре была статья Ловушка профилирования, где были получены довольно любопытные результаты. Суть там была в том, что используя google benchmark производились замеры времени исполнения кода с переходами и эквивалентного кода без них, и внезапно выяснилось, что результат зависим не только от кода, но и от данных, которые используются — при проходе по одному и тому же массиву результаты улучшались, а при изменении данных от прогона к прогону — ухудшались. Вот как можно провести поверку результатов на ассемблере. Для начала нам понадобится пара нехитрых макросов, которыми мы будем обкладывать наш код, время выполнения которого мы хотим измерить и макрос, который выведет результат. Предполагается, что мы вызовем код много раз в цикле и возьмём минимальное время прохода — это стандартный способ для синтетического бенчмаркинга: Код (ASM): Buffer DB 80 * B StartBench %MACRO CPUID RDTSC shl rdx, 32 or rax, rdx mov r8, rax %ENDMACRO EndBench %MACRO Min RDTSCP shl rdx, 32 or rax, rdx sub rax, r8 mov rbx, [%Min] cmp rax, rbx cmova rax, rbx mov [%Min], rax %ENDMACRO PrintBench %MACRO Message, Min mov rax, [%Min] StoD Buffer StdOutput =B%Message, Buffer, =B" Ticks", Eol=Yes, Console=Yes Clear Buffer, Size=80 mov [%Min], -1, DATA=Q %ENDMACRO Здесь используется инструкция RDTSC для получения тиков тактового генератора, работающего на базовой частоте процессора. Пара CPUID/RDTSC...RDTSCP — это классический подход для того чтобы свести к минимум влияние конвейеризации на результаты. Значение при старте сохраняется в R8. Затем в конце сравнивается с минимальным значением, которое обновляется. Обратите внимание на инструкцию cmova — это стандартный способ избавиться от явного перехода if (value<min) min = value; а сравниваем мы беззнаковые числа. Ещё нам понадобится генератор случайных чисел, который заполнит массив Array размером %SIZE случайными байтами 0 или 1. Этот код, кстати, генерированный ИИ копилотом чуть более чем полностью и в общем живой и рабочий, при этом количество вызовов rdrand минимально — мы берём оттуда одиночные биты, так что один вызов поставляет нам 64 случайных числа, я оставлю оригинальные комментарии как есть: Код (ASM): random PROC mov rdi, Array mov rcx, %SIZE xor rdx, rdx .next_byte: test rcx, rcx jz .done ; finished ; if no bits left in rbx, get a new 64-bit random value test rdx, rdx jnz .have_bits .get_rdrand: rdrand rbx ; random 64-bit value in rbx jnc .get_rdrand ; retry if CF=0 (no random value) mov rdx, 64 ; 64 bits available .have_bits: ; take lowest bit of rbx, store it as a byte 0 or 1 mov al, bl ; copy low byte and al, 1 ; keep only lowest bit (0 or 1) mov [rdi], al ; store into buffer shr rbx, 1 ; drop used bit dec rdx ; one less available bit inc rdi ; advance buffer pointer dec rcx ; one less byte to fill jmp .next_byte .done: ret ENDPROC random Вот, почти всё готово, теперь мы напишем небольшой тест, который запустим десять тысяч раз, меняя содержимое массива на каждом проходе вызовом call random: Код (ASM): EUROASM CPU=X64, SIMD=AVX2, SPEC=Enabled %^SourceName PROGRAM Format=PE, Width=64, Model=Flat, Entry=main %SIZE %SET 4096 %ITER %SET 10000 INCLUDE winscon.htm, winabi.htm, cpuext64.htm INCLUDE Benchmark.inc, Random.inc Array: DB %SIZE * BYTE ; 4 KiB Buffer RCycl DB Q -1 NCycl DB Q -1 main:nop StdOutput =B"Branch prediction benchmark", Eol=yes ;----------------------------------------------------------------------------- ; First Test - random array every time mov r10, %ITER ; number of timing iterations loop: call random StartBench mov r11, %SIZE ; loop counter N lea r12, [Array] ; r12 = address of byte array xor r13, r13 ; r13 = taken-branch counter (per timing run) loop_start: mov bl, [r12] ; load current value (0 or 1) test bl, bl jz branch ; branch-taken path inc r13 ; do some harmless work branch: ; no work for 0, but still advance inc r12 ; next byte dec r11 jne loop_start EndBench RCycl dec r10 jnz loop PrintBench "Rand Array: ", RCycl В регистре R13 у нас будет количество переходов, при случайном массиве размеров 4К должно быть что-то около двух тысяч, это просто для самоконтроля. Ну а второй тест мы будем запускать по одному и тому же массиву: Код (ASM): ; Second Test - same array every time mov r10, %ITER loop2: StartBench mov r11, %SIZE lea r12, [Array] xor r13, r13 loop_start2: mov bl, [r12] test bl, bl jz branch2 inc r13 branch2: inc r12 dec r11 jne loop_start2 EndBench NCycl dec r10 jnz loop2 PrintBench "Same Array: ", NCycl TerminateProgram ENDPROGRAM И вот результат на процессоре Хасвелл: Код (Text): Branch prediction benchmark Rand Array: 51960 Ticks Same Array: 36524 Ticks Как видите, в первом случае процессору потребовалось в полтора раза больше времени, так работает постоянно ошибающийся предсказатель переходов. Он на самом деле многоуровневый и может "удержать" в памяти до 4К последних переходов. Я изначально полагал, что основное влияние оказывает кэш, но нет, это именно эффект предсказателя. Можно воспользоваться профилировщиком VTune либо Intel PCM и убедиться, что количество промахов предсказателя значительно выше в первом случае. Это на самом деле неплохой результат для цикла, в котором семь инструкций, отрабатывающего 4 тысячи итераций. В качестве самостоятельного упражнения попробуйте оставить массив, забитый нулями (да просто закомментируйте вызов call random добавив перед этой строкой ";") и вы увидите, как количество тиков упадёт где-то до восьми тысяч — это всего два такта на итерацию. Так работает конвейер вкупе с предсказателем, всегда верно угадывающим переход — ведь современный процессор может выполнять несколько инструкций, таких как комбинации dec/jne и dec/jnz при верно предсказанном переходе за один такт.Эксперимент — сравнение INC и ADDЕщё один эксперимент, основанный на статье Может ли устареть инкремент.... Значение регистра процессора можно инкрементировать двумя способами — либо как inc rax, либо add rax,1. Есть ли разница между этими командами? Небольшой эксперимент на ассемблере поможет ответить и на этот вопрос. Кроме того в рамках этого эксперимента мы может воочию увидеть латентность и пропускную способность инструкций. Замеры мы будем проводить двумя способами — в одном случае мы будем просить выполнить инкремент одного и того же регистра, это даст нам зависимость по данным, а во втором случае — независимых регистров, и в этом случае процессор может начать их параллельное выполнение соответственно пропускной способности. Значения латентности и пропускной способности можно проверить в таблицах uops.info. Чтобы выдать последовательность одних и тех же команд без утомительного копипастинга , мы воспользуемся макро языком ассемблера. Основная управляющая конструкция выглядит вот так: Код (ASM): i %FOR 0..10000 inc eax %ENDFOR i Здесь будет выдано десять тысяч последовательных команд inc eax, одна за одной. Если бы мы использовали нативный цикл ассемблера, то счётчик цикла "сбил" бы нам результат измерений, его пришлось бы учитывать, а в данном случае у нас именно непрерывная последовательность. Она не должна быть очень большой, желательно, чтобы мы полностью поместились в кэш инструкций, но и не маленькой, чтобы эффект был хорошо заметен. Теперь важно понять следующее. Когда мы выдаём команды inc eax, inc eax, ... одну за одной — они зависимы по данным, это значит, что процессор должен формально дождаться выполнения предыдущей инструкции, чтобы начать следующую (хотя и не всегда — это зависит от архитектуры). Количество тактов, затрачиваемое на одну такую инструкцию — это латентность. Однако если наши инструкции будут независимы, то процессор может начать выполнение следующей, не дожидаясь предыдущую, и количество таких инструкций, выполняемых за один такт — это пропускная способность. Последовательность из десяти тысяч независимых инструкций создаётся вот так: Код (ASM): i %FOR 0..2500 inc rax inc rbx inc rcx inc rdx %ENDFOR i Здесь у нас 2500 раз повторённая последовательность из четырёх команд, всё вместе — 10000. Кстати, в этом ассемблере вы можете комбинировать множественные инкременты, то есть код "inc rax, rbx, rcx, rdx" - это ровно тоже самое, что и четыре отдельных инкремента выше. Код (ASM): EUROASM AutoSegment=Yes, CPU=X64, SIMD=AVX2 %^SourceName PROGRAM Format=PE, Width=64, Model=Flat, IconFile=, Entry=main: %ITER %SET 250_000 INCLUDE winscon.htm, winabi.htm, cpuext64.htm INCLUDE benchmark.inc Cycles DB Q -1 main: nop mov r9, %ITER L1: ; --- Latency for ADD Instruction StartBench i %FOR 0..10000 add eax, 1 %ENDFOR i EndBench Cycles dec r9 jnz L1 PrintBench "ADD cycles (Latency) = ", Cycles mov r9, %ITER L2: ; --- Latency for INC Instruction StartBench i %FOR 0..10000 inc eax %ENDFOR EndBench Cycles dec r9 jnz L2 PrintBench "INC cycles (Latency) = ", Cycles mov r9, %ITER L3: ; --- Throughput for ADD Instruction StartBench i %FOR 0..2500 add rax, 1 add rbx, 1 add rcx, 1 add rdx, 1 %ENDFOR i EndBench Cycles dec r9 jnz L3 PrintBench "ADD cycles (Throughput) = ", Cycles mov r9, %ITER L4: ; --- Throughput for INC Instruction StartBench i %FOR 0..2500 inc rax, rbx, rcx, rdx %ENDFOR i EndBench Cycles dec r9 jnz L4 PrintBench "INC cycles (Throughput) = ", Cycles TerminateProgram ENDPROGRAM И вот результат для процессора Xeon E5-1620 v3 (Haswell): >AddInc.exe ADD cycles (Latency) = 10028 Ticks INC cycles (Latency) = 10028 Ticks ADD cycles (Throughput) = 3376 Ticks INC cycles (Throughput) = 3364 Ticks Всё красиво — для 10000 зависимых инструкций процессору надо примерно 10000 тиков, это ровно одна инструкция на такт, а вот для независимых инструкций — втрое меньше, потому что он начинает выполнять три инструкции за каждый такт. И нет, разницы между INC и ADD ровно никакой. Единственное отличие в длине машинного кода, ведь add eax, 1 это три байта 83C001, а вот inc eax — только два FFC0. Более компактный код занимает меньше места в кэше инструкций и в общем предпочтительнее. Ситуация, кстати, поменяется, если погонять этот код на P и E ядрах гибридного процессора, например на Core i7-13850HX, вот там ADD инструкция окажется предпочтительнее на Е ядрах, но это уже совсем другая история. RDTSC на самом деле показывает количество тиков на базовой частоте процессора, а он как правило работает на повышенной частоте и в реальности количество тактов на данном процессоре окажется заметно выше, кроме того придётся делать поправку на разную частоту ядер и лучше использовать RDPMC, но там есть свои тонкости, о которых написано в статье Достучаться до RDPMC, но вот про исключение, которое можно спровоцировать этой инструкцией, хотелось бы написать особо.
€ASM. Буквы алфавита похоже закончились. Макроязык слабенький, годится только чтобы портянки кода по шаблону формировать.
Эксперимент — обработка исключенийПри программировании на Ассемблере не бойтесь обрабатывать исключения. Вы с процессором "один на один" и можете легко повесить операционную систему. На выброшенные исключения натыкался каждый программист, и каждый, работающий с С++ в курсе про __try... __except, но не каждый программист точно знает, как именно исключение обрабатывается, и ассемблер может помочь разобраться в пречине возникновения исключения. Запись по нулевому указателю, равно как и деление на нуль — это слишком уж просто, там можно избежать исключения банальной проверкой. Возьмём пример посложнее, когда шансов нет — попросим процессор выполнить привилегированную инструкцию не имея на это соответствующего разрешения. Вызовем RDPMC, OUT или IN. Простейшая программа на ассемблере: Код (ASM): EUROASM CPU=x64 %^SourceName PROGRAM Format=PE, Width=64, Entry=Start INCLUDE winabi.htm Start: nop StdOutput =B"Before RDPMC Call", Eol=yes xor ecx, ecx RDPMC ; Exception! StdOutput =B"After RDPMC Call", Eol=yes TerminateProgram ENDPROGRAM При запуске вы увидите первое сообщение, но не увидите второго, потому что в просмотрщике событий вы увидите ошибку с кодом 0хс0000096: Это документированная ошибка STATUS_PRIVILEGED_INSTRUCTION. Ровно того же эффекта вы добьётесь если попробуете на С++ __readpmc(): Код (C++): #include <iostream> #include <windows.h> int main() { // This will fault unless RDPMC is enabled for user mode std::cout << "Before RDPMC call" << std::endl; uint64_t value = __readpmc(0); std::cout << "RDPMC value: " << value << std::endl; return 0; } Однако не всё так плохо, ведь вы можете сделать вот так: Код (C++): #include <iostream> #include <windows.h> int main() { std::cout << "Before RDPMC call" << std::endl; __try { // This will fault unless RDPMC is enabled for user mode uint64_t value = __readpmc(0); std::cout << "RDPMC value: " << value << std::endl; } __except (EXCEPTION_EXECUTE_HANDLER) { std::cout << "SEH caught RDPMC exception!" << std::endl; } return 0; } И в этом случае программа не упадёт, она честно выдаст SEH caught RDPMC exception! И вот тут если вы попросите объяснить, как именно производится структурированная обработка исключений, то многие затруднятся ответить, а на самом деле там всё относительно несложно. Вот эквивалентный код на ассемблере, заодно и протестируем адекватность кнопки "объяснить код": Код (ASM): EUROASM CPU=X64, SIMD=AVX2 %^SourceName PROGRAM Format=PE, Width=64, Model=Flat, IconFile=, Entry=Start INCLUDE winscon.htm, winabi.htm, cpuext64.htm [.text] Start: nop StdOutput =B"Hello, SEH", Eol=yes try: MOV ECX,0 ; Instructions Retired RDPMC ; EXCEPTION_PRIV_INSTRUCTION (0xC0000096) safe_place: StdOutput =B"Sucessfully finished", Eol=yes TerminateProgram handler: SUB RSP,8*(4+1) ; 0x0F8 is offset to CONTEXT64.Rip: mov [R8+0x0F8], safe_place, DATA=Q StdOutput =B"Instruction caused exception", Eol=yes XOR EAX,EAX ADD RSP,8*(4+1) retn [.data] align 4 ; alignment is required UNWIND DB 0x19,0,0,0 ; Hard coded for the moment DD RVA# handler DD 0 [.pdata] SEGMENT PURPOSE=EXCEPTION DD RVA# try DD RVA# safe_place DD RVA# UNWIND ENDPROGRAM Здесь есть три важных адреса: try — это то место, где может поплохеть, затем safe_place: — это там, где снова станет хорошо, и handler:, которое суть обработчик. Чтобы сообщить операционной системе о том, как мы собираемся обрабатывать ошибку, служит секция [.pdata], туда занесены три адреса (по сути это RUNTIME_FUNCTION структура) — собственно критическое место и безопасное продолжение, а также адрес UNWIND_INFO структуры. Магическое число 0х19 образуется из трёх битов, где один отвечает за версию, а другие два говорят о том, что у нас есть есть SEH‑обработчик UNW_FLAG_EHANDLER с пользовательским обработчиком UNW_FLAG_UHANDLER. Следом идёт относительный адрес обработчика (тут все адреса относительные, поэтому RVA, это как раз добавилось в свежей версии этого ассемблера). Теперь, когда мы налетаем на грабли инструкцией RDPMC, ядро операционной системы первым делом просматривает таблицу обработчиков, если её нет, то программа аварийно завершается, а вот если есть, управление передаётся нашему обработчику handler:. Но это не просто передача управления, по сути под капотом идёт вызов функции с четырьмя параметрами, которые передаются согласно соглашению о вызовах Win64 ABI. Вот почему нам первой же командой нужно выравнивание стека на 4 параметра плюс один — это адрес возврата (можно и SUB RSP, 48 сделать, хуже не будет). Четыре параметра, которые нам передаются, берутся из вот такого прототипа Код (C++): typedef EXCEPTION_DISPOSITION (*PEXCEPTION_ROUTINE) ( IN PEXCEPTION_RECORD ExceptionRecord, IN ULONG64 EstablisherFrame, IN OUT PCONTEXT ContextRecord, IN OUT PDISPATCHER_CONTEXT DispatcherContext ); Соответственно они передаются через регистры RCX, RDX, R8 и R9. Из всего этого нас интересует лишь структура PCONTEXT ContextRecord, адрес который лежит в R8, так как это третий параметр. Смещение 248 байт 0x0F8 — это поле RIP. А RIP это указатель адреса текущей инструкции. Именно сюда мы записываем адрес безопасного продолжения safe_place. Больше от нас ничего не требуется, мы выводим сообщение, что нас настигло исключение, сбрасываем код ошибки и восстанавливаем стек обратно. По выходу из процедуры обработчика исключения ядро выставит наш желаемый "безопасный" RIP, и мы выведем последнее сообщение. Вот и всё. На самом деле можно усложнить — например получить код исключения, и т.д. Это как раз тот пример, когда ассемблер помогает понять механизм работы. На этом можно было бы остановиться, но хотелось бы добавить, что код на ассемблере можно собрать и в DLL, которую вызвать из любого языка, который это допускает, начиная от Си и Питона и заканчивая Растом и LabVIEW, что открывает возможности для практического применения ассемблерного кода и интегрирования его в сторонние приложения.DLL на ассемблереЧтобы не усложнять, давайте просто сложим пару байтовых массивов, но используя SIMD. Чтобы не усложнять, давайте просто сложим пару байтовых массивов, но используя SIMD инструкции, и вызовем полученную библиотеку, скажем из LabVIEW. Помимо очевидной замены РЕ на DLL нам потребуется занести нашу функцию в таблицу экспорта Код (ASM): EUROASM CPU=X64, SIMD=AVX2, AMD=ENABLED AsmDLL64 PROGRAM FORMAT=DLL, MODEL=FLAT, WIDTH=64 EXPORT add_bytes_avx2 ; void add_bytes_avx2(const uint8_t* a, ; const uint8_t* b, ; uint8_t* c, ; size_t n); add_bytes_avx2 PROC test r9, r9 jz done ; number of full 32-byte blocks mov r10, r9 shr r10, 5 ; r10 = n / 32 jz tail avx_loop: vmovdqu ymm0, [rcx] vmovdqu ymm1, [rdx] vpaddb ymm0, ymm0, ymm1 vmovdqu [r8], ymm0 add rcx, 32 add rdx, 32 add r8, 32 dec r10 jnz avx_loop tail: ; remaining bytes and r9, 31 jz done tail_loop: mov al, [rcx] add al, [rdx] mov [r8], al inc rcx, rdx, r8 dec r9 jnz tail_loop done: vzeroupper ; important for ABI ret ENDP add_bytes_avx2 ENDPROGRAM AsmDLL64 И результат:
Вывод информации в консоль Код (ASM): EUROASM AutoSegment=Yes, CPU=X64, SIMD=AVX2 cvidemo PROGRAM Format=PE, Width=64, Model=Flat, IconFile=, Entry=main: INCLUDE winscon.htm, winabi.htm, cpuext64.htm WelcomeMsg D "Hello, CVI and EuroAssembler!",0 Buffer DB 80 * B AnswerMsg D "EuroAsm: The Answer is ",0 main: nop StdOutput WelcomeMsg, Eol=Yes, Console=Yes ; standard EuroAsm Macro mov rax, 42 StoD Buffer ; https://euroassembler.eu/maclib/cpuext64.htm#StoD StdOutput AnswerMsg, Buffer, Eol=Yes, Console=Yes ; ... используя Fmt и FmtOut функции из NI LabWindows / CVI. Все, что вам нужно, это создать ссылку против cvirt.lib, которая вызывает cvirte.dll во время выполнения: Код (ASM): EUROASM AutoSegment=Yes, CPU=X64, SIMD=AVX2 cvidemo PROGRAM Format=PE, Width=64, Model=Flat, IconFile=, Entry=main: INCLUDE winscon.htm, winabi.htm, cpuext64.htm FmtBuf DB 80 * B FmtMsg D "CVI The Answer is %%d",13,10,0 FortyTwo DB D 42 LINK cvirt.lib ; For Fmt and FmtOut main: nop StdOutput WelcomeMsg, Eol=Yes, Console=Yes ; standard EuroAsm Macro mov rax, 42 WinABI FmtOut, FmtMsg, rax ; Direct Output to the Console WinABI FmtOut, FmtMsg, 42 ; From Constant WinABI FmtOut, FmtMsg, [FortyTwo] ; From Memory ;... это работает и с переменными с плавающей запятой: Код (ASM): FmtMsgF D "Float variable: %%f[p2] (Answer)",13,10,0 Numerator DO Q 42.0 Denominator DO Q 13.0 ;... mov rbx, 42 mov rdx, 13 movq xmm0, rbx ; movq xmm0, [numerator] movq xmm1, rdx ; movq xmm1, [denominator] divsd xmm0, xmm1 WinABI FmtOut, FmtMsgF, xmm0 ; Float Point ;... Полный пример кода: Код (ASM): ;/============================================================================== ;/ ;/ Title: CVI Demo for Fmt and FmtOut Formatting Functions ;/ Purpose: How to call these from Assembly ;/ ;/ Created on: 06.10.2025 at 11:32:43 by AD. ;/ ;/============================================================================== EUROASM AutoSegment=Yes, CPU=X64, SIMD=AVX2 cvidemo PROGRAM Format=PE, Width=64, Model=Flat, IconFile=, Entry=Start: INCLUDE winscon.htm, winabi.htm, cpuext64.htm WelcomeMsg D "Hello, CVI and EuroAssembler!",0 Buffer DB 80 * B AnswerMsg D "EuroAsm: The Answer is ",0 FmtBuf DB 80 * B FmtMsg D "CVI The Answer is %%d",13,10,0 FortyTwo DB D 42 FmtMsgF D "Float variable: %%f[p2] (Answer)",13,10,0 FmtMsgFO D "%%s<%%f[p1] Divided by %%f[p1] is %%f[p2]",13,10,0 Numerator DO Q 42.0 Denominator DO Q 13.0 InputNumerator D "Input Numerator (Float) :",0 InputDenominator D "Input Denominator (Float) :",0 BufferNumerator DB 80 * B BufferDenominator DB 80 * B ScanFmt D "%%s>%%f",0 ScanInFmt D "%%l>%%f",0 LINK cvirt.lib Start: nop ;/============================================================================== ;/ Standard EuroAssembler ;/ StdOutput WelcomeMsg, Eol=Yes, Console=Yes ; standard EuroAsm Macro mov rax, 42 StoD Buffer ; https://euroassembler.eu/maclib/cpuext64.htm#StoD StdOutput AnswerMsg, Buffer, Eol=Yes, Console=Yes ;/============================================================================== ;/ CVI: ;/ WinABI Fmt, FmtBuf, FmtMsg, rax ; Fmt to Buffer StdOutput FmtBuf, Console=Yes ; Buffer Output mov rax, 42 WinABI FmtOut, FmtMsg, rax ; Direct Output to the Console WinABI FmtOut, FmtMsg, 42 ; From Constant WinABI FmtOut, FmtMsg, [FortyTwo] ; From Memory ;/============================================================================== ;/ Float Point: ;/ mov rbx, 42 mov rdx, 13 movq xmm0, rbx ; movq xmm0, [numerator] movq xmm1, rdx ; movq xmm1, [denominator] divsd xmm0, xmm1 WinABI FmtOut, FmtMsgF, xmm0 ; Float Point movq xmm0, [Numerator] movq xmm1, [Denominator] divsd xmm0, xmm1 WinABI FmtOut, FmtMsgFO, [Numerator], [Denominator], xmm0 ;/============================================================================== ;/ With Input from EuroAssembler: ;/ StdOutput InputNumerator, Console=Yes ; standard EuroAsm Macro StdInput BufferNumerator, Console=Yes WinABI Scan, BufferNumerator, ScanFmt, Numerator StdOutput InputDenominator, Console=Yes ; standard EuroAsm Macro StdInput BufferDenominator, Console=Yes WinABI Scan, BufferDenominator, ScanFmt, Denominator movq xmm0, [Numerator] movq xmm1, [Denominator] divsd xmm0, xmm1 WinABI FmtOut, FmtMsgFO, [Numerator], [Denominator], xmm0 ;/============================================================================== ;/ With Input from NI CVI: ;/ WinABI FmtOut, InputNumerator WinABI ScanIn, ScanInFmt, Numerator WinABI FmtOut, InputDenominator WinABI ScanIn, ScanInFmt, Denominator movq xmm0, [Numerator] movq xmm1, [Denominator] divsd xmm0, xmm1 WinABI FmtOut, FmtMsgFO, [Numerator], [Denominator], xmm0 TerminateProgram ENDPROGRAM Выход >cvidemo.exe Hello, CVI and EuroAssembler! EuroAsm: The Answer is 42 CVI The Answer is 42 CVI The Answer is 42 CVI The Answer is 42 CVI The Answer is 42 Float variable: 3.23 (Answer) 42.0 Divided by 13.0 is 3.23 Input Numerator (Float) :1 Input Denominator (Float) :2 1.0 Divided by 2.0 is 0.50 Input Numerator (Float) :3 Input Denominator (Float) :4 3.0 Divided by 4.0 is 0.75