Pull to refresh

Программирование сопроцессора на C#? Да!

Reading time10 min
Views9.3K
Наверное все знают о существовании сопроцессора FPU. Как писать код для него читаем дальше . FPU – floating point unit – часть центрального процессора, специально предназначенная для работы с типами данных, представляющими числа с плавающей точкой, или по-другому с типами float и double. Данный модуль в составе процессоров появился после появления на свет Intel 486DX (благодарю за поправку), да вот так давно. И с тех пор именно он выполняет работу по вычислениям различных математических выражений, а точнее их в виде кода на языке ассемблера. Другими словами, компилятор не весь код программы преобразует в стандартный набор инструкций типа mov, sub и прочие, но и еще в fld, fstp, fsub, fadd…, если речь идет о вычислениях с участием типов double. Как видите, инструкции для FPU имеют приставку “f”, по которым собственно можно сразу отличить код, предназначенный для него. Всю информацию по FPU вы можете найти на просторах инета, погуглив его по имени, также рекомендую сайт wasm.ru – раздел «Процессоры». Сопроцессор очень интересная штука и программирование его очень интересное занятие, я бы даже сказал захватывающее – не знаю, что почувствуете Вы, но я был в восторге, когда у меня получилось «заклинать» код, давая команды непосредственно процессору без посредников-компиляторов, CLR-среды и др. Почему «заклинать»? Об этом чуть позже.
Термин «заклинать» я позаимствовал у автора замечательных статей на сайте. Это серия статей про «Заклинание кода», которые я Вам рекомендую почитать после прочтения моей статьи.
Сейчас я покажу Вам как же написать простой пример заклинания кода применительно для FPU. Сразу должен предупредить, что хоть в конце и будет участвовать C#, для самого заклинания нужен С++.
Допустим нам надо вычислить такое выражение: result = arg1 – arg2 + arg3.
Есть несколько вариантов составления кода. Чтобы не усложнить понимание происходящего, я покажу сначала один, чуть позже покажу другой.
Итак, первый вариант выглядит так:

fld [arg1]
fld [arg2]
fsubp
fld [arg3]
faddp
fstp [result]
ret

Теперь поясню. В квадратных скобках мы должны указывать адреса переменных arg1, arg2, arg3, result.
Инструкция fld загружает в вершину стека (FPU работает со стеком, причем он имеет некоторые особенности) значение переменной double, адрес которой идет сразу после инструкции; fsubp – производит вычитание значения, лежащего на 1 позиции в стеке ниже, значение, лежащее на вершине стека и освобождает вершину стека, тем самым результат записывается на место значения, из которого вычитается, результат находится теперь на вершине стека; faddp – работает по аналогии с fsubp, только не вычитает, а складывает значения; fstp – выгружает из вершины стека значение double, выгружает в ячейку по адресу, указанному далее; ну и инструкция ret – интуитивно понятная – завершает выполнение функции и передает управление в функцию, вызвавшую ее. Чтобы было более понятно, покажу работу нашего кода в картинках:



Результат записывается в ячейку памяти, откуда его можно забрать. Работа инструкций надеюсь ясна. Теперь посмотрим как нам такой код создать из программы на С++.

double ExecuteMagic(double arg1, double arg2, double arg3)
    {
      short* code;
      short* code_cursor;
      short* code_end;
      double* data;
      double* data_cursor;
      SYSTEM_INFO si;
      GetSystemInfo(&si);
      DWORD region_size = si.dwAllocationGranularity;

code = (short*)VirtualAlloc(NULL, region_size * 2, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
      code_cursor = code;
      code_end = (short*)((char*)code + region_size);
      data = (double*)code_end;
      data_cursor = data;

      *data_cursor = arg1;
      *code_cursor++ = (short)0x05DDu; //fld
      *(int*)code_cursor = (int)(INT_PTR)(data_cursor); //1.0
      code_cursor = (short*)((char*)code_cursor + sizeof(int)); // смещение
      data_cursor++;

      *data_cursor = arg2;
      *code_cursor++ = (short)0x05DDu; //fld
      *(int*)code_cursor = (int)(INT_PTR)data_cursor++; //-2.0
      code_cursor = (short*)((char*)code_cursor + sizeof(int)); // смещение

      *code_cursor++ = (short)0xE9DEu; //fsubp

      *data_cursor = arg3;
      *code_cursor++ = (short)0x05DDu; //fld
      *(int*)code_cursor = (int)(INT_PTR)data_cursor++; //2.0
      code_cursor = (short*)((char*)code_cursor + sizeof(int)); // смещение

      *code_cursor++ = (short)0xC1DEu; //faddp

      double *result = data_cursor;

      *code_cursor++ = (short)0x1DDDu; //fstp
      *(int*)code_cursor = (int)(INT_PTR)data_cursor++; //
      code_cursor = (short*)((char*)code_cursor + sizeof(int)); // смещение

      *code_cursor++ = (short)0x90C3u; //ret

      void (*function)() = (void (*)())code;

      //1-(-2)+2=5
      function();

      return *result;
    }

* This source code was highlighted with Source Code Highlighter.


Теперь давайте разберем все самое вкусное здесь. Итак, мы с помощью функции VirtualAlloc выделяем нашему коду некоторое количество памяти (а именно согласно значению структуры
SYSTEM_INFO. dwAllocationGranularity, как бы системная величина разбиения памяти); обратите внимание какие аргументы принимает функция на вход, а именно на PAGE_EXECUTE_READWRITE – именно этот параметр позволяет обратиться к вновь созданному участку памяти не только для чтения/записи, но и для выполнения кода, т.е. мы можем передать в этот участок памяти управление и процессор будет считывать дальнейшие инструкции именно отсюда.
Половина этого созданного массива выделяем для кода, вторую половину для данных – некое подобие сегмента кода и сегмента данных. Все что осталось – заполнить эти сегменты данных необходимыми значениями. Для заполнения массива кодом необходимо просто записать в этот массив опкоды (инструкции процессора) в шестнадцатеричном виде. Разберем все по порядку.
Инструкция FLD имеет опкод DD/0. Да, кстати, сразу скажу, что значения опкодов и их мнемоническое написание вы можете посмотреть в документации по архитектуре процессоров. Продолжим, FSTP имеет тоже опкод DD, но уже с приставкой /3 – это расширение опкода – mod r/m байт. Вот таблица значений mod r/m байта [http://www.sandpile.org/ia32/opc_rm32.htm] (Пытливые умы при присутствии интереса смогут разобраться во всем этом, поверьте). Так как инструкция FLD и FSTP могут оперировать с операндами разного типа, т.е. ячейками, регистрами процессора, то для этого и существует расширение опкода. Нам для работы нужен вид операнда адрес числа double, поэтому в той таблице мы смотрим значение для [sdword]. Для FLD это значение равно 05h, для FSTP 1Dh. Прибавляем эти значения к опкодам и получаем: FLD = DD05h, FSTP = DD1Dh. Инструкция FSUBP имеет опкод DE/5, и мы опять должны обратиться к таблице расширения опкода и посмотреть значение расширения для XMM1 (это ссылка элемент стека FPU) и видим, что оно равно E9h, т.е. FSUBP = DEE9h. FADDP также как и FSUBP имеет опкод DE, но уже /0, что для XMM1 имеет значение C1h, т.е. FADDP = DEC1h. Инструкция RET имеет опкод C390h.
Следует отметить, что инструкции процессор считывает с конца, поэтому их надо записывать наоборот, с учетом того, что они по 2 байта и парные, т.е. FLD = DD05h надо записывать не 50DDh, а 05DDh, это важно!
Ну вот в принципе и все по опкодам. Код на языке С++ выше показывает как заполнять массив инструкциями. Сначала записываем инструкцию, затем, если это необходимо, адрес ячеек. Обратите внимание, что адрес имеет длину 4 байта (32 бита) для 32-битных систем, поэтому после записи адреса в массив кода, необходимо сместить указатель на 4 байта вперед, вместо 2 байт в случае инструкций.
Кульминацией сего чуда является выполнение записанного в память кода. Как выполнить код из нашего массива? За помощью мы обращаемся к указателю на функцию, здесь язык С++ выручает. Создаем указатель на функцию типа void с параметрами void, далее присваиваем ему указатель на начало массива кода. Все! Запускаем наш указатель на функцию, получаем результат работы программы прям в памяти, процессор все сделал ровно так, как мы ему сказали в нашем массиве кода.
Теперь я напомню, что это 1 способ передачи параметров и возвращения результата. Второй способ заключается в том, чтобы создать указатель на функцию типа double(void), т.е. чтобы нам не в память записывался результат и мы его сами вытаскивали, а чтобы нам уже результат возвратила наша функция, созданная динамически. Для этого просто изменяем код на такой:

fld [arg1]
fld [arg2]
fsubp
fld [arg3]
faddp
//fstp [result]
ret

Т.е. просто оставляем в вершине стека результат. И наш указатель на функцию вернет нам результат из вершины стека. Все просто.

Читатель уже с середины статьи задается вопросом: «Причем тут C#??? Один С++ и Ассемблер, непонятные цифры…». Справедливо, но надо быть терпеливее :).

Итак, мы с Вами знаем, что мы можем выполнять из С# функции, написанные на С++, Delphi и др.
Реализовывать это можно с помощью ключевого слова extern и атрибута [DllImport(«*.dll»)].
Также есть вариант и проще. Программисты платформы .NET смогли подружить управляемый код и неуправляемый. Таким образом просто создаем новый класс на С++ с использованием вышеупомянутой техники, реализующий кодогенерацию, заклинание кода. Далее просто подключаем эту библиотеку к проекту, использующему управляемый код C# и совершенно беспрепятственно пользуемся. Я так и сделал. Как же я был рад, когда результат не заставил себя ждать! :)

Вот что я сделал:

#include <windows.h>

#pragma once

using namespace System;

namespace smallcodelib
{
  public ref class CodeMagics
  {
  public:
    static double ExecuteMagic(double arg1, double arg2, double arg3)
    {
      short* code;
      short* code_cursor;
      short* code_end;
      double* data;
      double* data_cursor;
      SYSTEM_INFO si;
      GetSystemInfo(&si);
      DWORD region_size = si.dwAllocationGranularity;

      code = (short*)VirtualAlloc(NULL, region_size * 2, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
      code_cursor = code;
      code_end = (short*)((char*)code + region_size);
      data = (double*)code_end;
      data_cursor = data;

      *data_cursor = arg1;
      *code_cursor++ = (short)0x05DDu; //fld
      *(int*)code_cursor = (int)(INT_PTR)(data_cursor); //1.0
      code_cursor = (short*)((char*)code_cursor + sizeof(int)); // смещение
      data_cursor++;

      *data_cursor = arg2;
      *code_cursor++ = (short)0x05DDu; //fld
      *(int*)code_cursor = (int)(INT_PTR)data_cursor++; //-2.0
      code_cursor = (short*)((char*)code_cursor + sizeof(int)); // смещение

      *code_cursor++ = (short)0xE9DEu; //fsubp

      *data_cursor = arg3;
      *code_cursor++ = (short)0x05DDu; //fld
      *(int*)code_cursor = (int)(INT_PTR)data_cursor++; //2.0
      code_cursor = (short*)((char*)code_cursor + sizeof(int)); // смещение

      *code_cursor++ = (short)0xC1DEu; //faddp

      double *result = data_cursor;

      *code_cursor++ = (short)0x1DDDu; //fstp
      *(int*)code_cursor = (int)(INT_PTR)data_cursor++; //
      code_cursor = (short*)((char*)code_cursor + sizeof(int)); // смещение

      *code_cursor++ = (short)0x90C3u; //ret

      void (*function)() = (void (*)())code;

      //1-(-2)+2=5
      function();

      return *result;
    }
  };
}

Это код для класса на С++.

И:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Runtime.InteropServices;
using smallcodelib;

namespace test_smallcodelib
{
  class Program
  {
    static void Main(string[] args)
    {
      Console.WriteLine("Заклинание кода! (* Выход)");
      while (!Console.ReadLine().Equals("*"))
      {
        double arg1;
        double arg2;
        double arg3;

        Console.Write("arg1?: "); arg1 = Convert.ToDouble(Console.ReadLine());
        Console.Write("arg2?: "); arg2 = Convert.ToDouble(Console.ReadLine());
        Console.Write("arg3?: "); arg3 = Convert.ToDouble(Console.ReadLine());

        double result = CodeMagics.ExecuteMagic(arg1, arg2, arg3);

        Console.WriteLine(String.Format("Result of arg1 - arg2 + arg3 = {0}", result));
      }
    }
  }
}

* This source code was highlighted with Source Code Highlighter.


Это уже на C#!

Проверьте! Все работает!

Ясное дело, что тут больше кода на С++, однако если у интересующихся есть определенный талант и интерес помучаться в данной области, то можно написать некоторую обертку на С++, которая будет генерировать такой код динамически, а использовать эту обертку уже из C#, наполняя ее необходимыми переменными и параметрами, ну и т.д. Можно получить довольно интересную штуку.

Добавлю еще пару приятностей.
Статья написана применительно программирования сопроцессора. На самом деле можно писать все, что душе угодно, для этого надо изучить архитектуру памяти и процессора ЭВМ, инструкции. Технологически продвинутые программисты, знающие что такое SSE (а она уже чуть ли не 5), могут писать код, используя все новшества процессорных технологий и самое приятное – это использовать это в C#. Все ограничивается фантазией =). Удачи в начинаниях!

Хочу выразить огромную благодарность своему друг Пётру Канковски, который в свое время помог мне во всем этом разобраться! У него есть свой сайт-вики, где он и его коллеги и друзья обсуждают различный способы оптимизации кода и др. [http://www.strchr.com/]

UPD: Здесь есть простой пример такого же принципа генерации нативного кода, но уже полностью на C#. Спасибо lastmsu за наводку на Marshal.GetDelegateForFunctionPointer().

Благодарю за внимание! Удачи!
Tags:
Hubs:
+8
Comments48

Articles

Change theme settings