Интероперабельность: Фортран и C#

в 14:49, , рубрики: .net, fortran, Программирование, метки: , , , ,

Как известно, в мире миллионы и миллионы строк легаси-кода. Первое место в легаси, разумеется, принадлежит Коболу, но и на долю Фортрана досталось немало. Причём, в основном, вычислительных модулей.

Не так давно мне принесли небольшую программку (менее 1000 строк, более четверти — комментарии и пустые строки) с задачей «сделать что-нибудь красивое, например, графики и интерфейс». Хоть программа и небольшая, а переделывать её не хотелось — дядька её ещё два месяца будет старательно обкатывать и вносить коррективы.

Результаты работы в виде нескольких кусков кода и вагона текста старательно изложены под катом.

Постановка задачи

Есть программа на фортране, которая что-то считает. Задача: минимально её скорректировать, желательно — не залезая в логику работы, — и вынести в отдельный модуль задание входных параметров, а также вывод результатов.

Для этого нам потребуется научиться делать следующие вещи:

  • компилировать dll на фортране;
  • находить экспортируемые из dll методы;
  • передавать в них параметры следующих типов:
    • атомарные (int, double);
    • строки (string);
    • колбэки (Action<>);
    • массивы (double[]);
  • вызывать методы из управляемого окружения (в нашем случае — C#).

Фронт-энд будем делать на C# — в первую очередь, по причине WPF, ну и кроссплатформенности не надо.

Окружение

Для начала подготовим окружение.

В качестве компилятора я использовал gfortran из пакета GCC (взять можно отсюда). Также нам пригодится GNU make (это лежит неподалёку). В качестве редактора исходного кода можно использовать что угодно; я поставил эклипс с плагином Photran.

Установка плагина на эклипс производится из стандартных репозиториев через пункт меню «Help»/«Install New Software...» из базового репозитория Juno (в фильтре ввести Photran).

После установки всего софта требуется прописать пути к бинарникам gfortran и make в стандартный path.

Программы все написаны на старом диалекте фортрана, то есть требуют обязательный отступ в 6 пробелов в начале каждой строки. Строки ограничены 72 знакоместами. Расширение файла — for. Не то чтобы я настолько олдскулен и хардкорен, но что есть, с тем и работаем.

С C# всё понятно — студия. Я работал в VS2010.

Первая программа

Фортран

Для начала соберём простую программу на фортране.

      module test
      contains
        subroutine hello()
          print *, "Hello, world"
        end subroutine
      end module test

      program test_main
        use test
        call hello()
      end program

Деталей разбирать не будем, мы тут не фортран всё-таки учим, но кратко освещу моменты, с которыми нам придётся столкнуться.

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

Во-вторых, синтаксис фортрана таков, что пробелы в нём необязательны. Можно писать endif, можно — end if. Можно do1i=1,10, а можно по-человечески — do 1 i = 1, 10. Так что это просто кладезь ошибок. Я полчаса искал, почему строчка

        callback()

давала ошибку «не найден символ _back()», пока не сообразил, что надо написать

        call callback()

Так что будьте внимательны.

В-третьих, диалекты f90 и f95 не требуют отступов в начале строк. Тут всё опять-таки зависит от того, что к вам пришло.

Но ладно, вернёмся к программе. Компилируется она или из эклипса (если правильно настроен makefile), или из командной строки. Для начала поработаем из командной строки:

> gfortran -o bintest.exe srctest.for

Запущенный exe-файл будет а) требовать run-time dll от фортрана, и б) выводить строку «Hello, world».

Чтобы получился exe, не требующий рантайма, компиляцию надо проводить с ключом -static:

> gfortran -static -o bintest.exe srctest.for

Для получения же dll требуется добавить ещё ключик -shared:

> gfortran -static -shared -o bintest.exe srctest.for

На этом с фортраном пока что закончим, и перейдём в C#.

C#

Создадим полностью стандартное консольное приложение. Сразу добавим ещё один класс — TestWrapper и напишем немного кода:

    public class TestWrapper {
        [DllImport("test.dll", EntryPoint = "__test_MOD_hello", CallingConvention = CallingConvention.Cdecl)]
        public static extern void hello();
    }

Входная точка в процедуру определяется при помощи стандартной VS-утилиты dumpbin:

> dumpbin /exports test.dll

Эта команда даёт длинный дамп, в котором можно найти интересующие нас строчки:

          3    2 000018CC __test_MOD_hello

Искать можно или grep-ом, или сбросить вывод dumpbin в файл, и пройтись поиском по нему. Главное — мы увидели символьное название точки входа, которое можно поместить в наш вызов.

Дальше — проще. В основном модуле Program.cs делаем вызов:

        static void Main(string[] args) {
            TestWrapper.hello();
        }

Запустив консольное приложение, можно видеть нашу строчку «Hello, world», выводимую средствами фортрана. Разумеется, надо не забыть подкинуть скомпилированный в фортране test.dll в папку bin/Debug (или bin/Release).

Атомарные параметры

Но это всё неинтересно, интересно — передать данные туда и получить что-то обратно. С этой целью проведём вторую итерацию. Пусть это будет, например, процедура, добавляющая число 1 к первому параметру, и передающая результат во второй параметр.

Фортран

Процедура проста до безобразия:

        subroutine add_one(inVal, retVal)
          integer, intent(in) :: inVal
          integer, intent(out) :: retVal

          retVal = inVal + 1
        end subroutine

В фортране вызов выглядит как-то так:

        integer :: inVal, retVal

        inVal = 10
        call add_one(inVal, retVal)
        print *, inVal, ' + 1 equals ', retVal

Теперь нам надо данный код скомпилировать и протестировать. В общем-то можно так и продолжать компилировать из консоли, но у нас же есть makefile. Давайте его пристроим к делу.

Так как мы делаем exe (для тестирования) и dll (для «продакшн-варианта»), то имеет смысл сначала компилировать в объектный код, после чего из него собирать dll/exe. Для этого открываем в эклипсе makefile и пишем что-то в духе:

FORTRAN_COMPILER = gfortran

all: srctest.for
	$(FORTRAN_COMPILER) -O2 
		-c -o objtest.obj 
		srctest.for
	$(FORTRAN_COMPILER) -static 
		-o bintest.exe 
		objtest.obj
	$(FORTRAN_COMPILER) -static -shared 
		-o bintest.dll 
		objtest.obj

clean:
	del /Q bin*.* obj*.* *.mod

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

C#

Следующее на очереди — доработка нашей оболочки в C#. Для начала импортируем ещё один метод из dll в проект:

        [DllImport("test.dll", EntryPoint = "__test_MOD_add_one", CallingConvention = CallingConvention.Cdecl)]
        public static extern void add_one(ref int i, out int r);

Точку входа определяем как и раньше, через dumpbin. Так как у нас появляются переменные, требуется указать соглашение по вызову (в данном случае cdecl). Переменные передаются по ссылке, так что ref обязателен. Если опустить ref, то при вызове получим AV: «Необработанное исключение: System.AccessViolationException: Попытка чтения или записи в защищенную память. Это часто свидетельствует о том, что другая память повреждена.»

В основной программе пишем примерно следующее:

            int inVal = 10;
            int outVal;
            TestWrapper.add_one(ref inVal, out outVal);
            Console.WriteLine("{0} add_one equals {1}", inVal, outVal);

В общем-то всё, задача решена. Если бы не одно «но» — опять требуется копировать test.dll из папки фортрана. Процедура механическая, надо бы её автоматизировать. Для этого нажимаем правой кнопкой на проект, «Свойства», выбираем вкладку «События построения», и пишем в окне «Командная строка события перед построением» что-то в духе

make -C $(SolutionDir)..Test.for clean
make -C $(SolutionDir)..Test.for all
copy $(SolutionDir)..Test.forbintest.dll $(TargetDir)test.dll

Пути, понятное дело, надо бы свои подставить.

Итого, после компиляции и запуска, если всё прошло нормально, получаем работающую программу второй версии.

Строки

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

Фортран

Тут всё просто (ну, для хардкорщиков):

        subroutine progress(text, l)
          character*(l), intent(in) :: text
          integer, intent(in) :: l

          print *, 'progress: ', text
        end subroutine

Если бы мы писали внутрифортрановский метод, без dll и прочей интероперабельности, то длину можно было бы и не передавать. А так как нам надо передавать данные между модулями, придётся работать с двумя переменными, указателем на строку и её длиной.

Вызов метода тоже не составляет сложностей:

        character(50) :: strVal
        strVal = "hello, world"
        call progress(strVal, len(trim(strVal)))

len(trim()) указан с целью обрезания пробелов в конце (т.к. выделено на строку 50 символов, а используется только 12).

C#

Теперь надо вызвать этот метод из C#. С этой целью доработаем TestWrapper:

        [DllImport("test.dll", EntryPoint = "__test_MOD_progress", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
        public static extern void progress([MarshalAs(UnmanagedType.LPStr)]string txt, ref int strl);

Здесь добавляется ещё один параметр импорта — используемый CharSet. Также появляется указание компилятору по передаче строки — MarshalAs.

Вызов при этом выглядит банально, за исключением многословности, вызванной требованием все параметры передавать по ссылке (ref):

            var str = "hello from c#";
            var strLen = str.Length;
            TestWrapper.progress(str, ref strLen);

Колбэки

Мы подошли к самому интересному — колбэкам, или передаче методов внутрь dll для отслеживания происходящего.

Фортран

Для начала напишем собственно метод, принимающий функцию как параметр. В фортране это выглядит примерно так:

        subroutine run(fnc, times)
          integer, intent(in) :: times

          integer :: i
          character(20) :: str, temp, cs

          interface
            subroutine fnc(text, l)
              character(l), intent(in) :: text
              integer, intent(in) :: l
            end subroutine
          end interface

          temp = 'iter: '
          do i = 1, times
            write(str, '(i10)') i
            call fnc(trim(temp)//trim(str), len(trim(temp)//trim(str)))
          end do
        end subroutine
      end module test

Тут нам следует обратить внимание на новую секцию interface описания прототипа передаваемого метода. Изрядно многословно, но, в общем-то, ничего нового.

Вызов же данного метода абсолютно банален:

        call run(progress, 10)

В результате 10 раз будет вызван метод progress, написанный на предыдущей итерации.

C#

Переходим в C#. Тут нам требуется провести дополнительную работу — объявить в классе TestWrapper делегат с правильным атрибутом:

        [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
        public delegate void Progress(string txt, ref int strl);

После этого можно определить прототип вызываемого метода run:

        [DllImport("test.dll", EntryPoint = "__test_MOD_run", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
        public static extern void run(Progress w, ref int times);

Точку входа традиционно определяем из выдачи dumpbin; остальное нам тоже знакомо.

Вызов этого метода тоже не составляет затруднений. Передавать туда можно как нативный фортрановский метод (типа TestWrapper.progress, описанного на прошлой итерации), так и лямбду C#:

            int rpt = 5;
            TestWrapper.run(TestWrapper.progress, ref rpt);
            TestWrapper.run((string _txt, ref int _strl) => {
                var inner = _txt.Substring(0, _strl);
                Console.WriteLine("Hello from c#: {0}", inner);
            }, ref rpt);

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

Массивы

С ними чуть сложнее, чем со строками. Если для строк достаточно написать пару атрибутов, то для массивов придётся поработать немного ручками.

Фортран

Для начала напишем процедуру печати массива, с небольшим заделом на будущее в виде передачи строки:

        subroutine print_arr(str, strL, arr, arrL)
          integer, intent(in) :: strL, arrL
          character(strL), intent(in) :: str
          real*8, intent(in) :: arr(arrL)

          integer :: i

          print *, str
          do i = 1, arrL
            print *, i, " elem: ", arr(i)
          end do
        end subroutine

Добавляется объявление массива из double (или real двойной точности), а также передаём его размер.
Вызов из фортрана тоже банален:

        character(50) :: strVal
        real*8 :: arr(4)

        strVal = "hello, world"
        arr = (/1.0, 3.14159265, 2.718281828, 8.539734222/)
        call print_arr(strVal, len(trim(strVal)), arr, size(arr))

На выходе получаем отпечатанную строку и массив.

C#

В TestWrapper ничего особого нет:

        [DllImport("test.dll", EntryPoint = "__test_MOD_print_arr", CallingConvention = CallingConvention.Cdecl)]
        public static extern void print_arr(string titles, ref int titlesl, IntPtr values, ref int qnt);

А вот внутри программы придётся немного поработать и задействовать сборку System.Runtime.InteropServices:

            var s = "abcd";
            var sLen = s.Length;
            var arr = new double[] { 1.01, 2.12, 3.23, 4.34 };
            var arrLen = arr.Length;
            var size = Marshal.SizeOf(arr[0]) * arrLen;
            var pntr = Marshal.AllocHGlobal(size);
            Marshal.Copy(arr, 0, pntr, arr.Length);
            TestWrapper.print_arr(s, ref sLen, pntr, ref arrLen);

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

        public static void PrintArr(string _titles, double[] _values) {
            var titlesLen = _titles.Length;
            var arrLen = _values.Length;
            var size = Marshal.SizeOf(_values[0]) * arrLen;
            var pntr = Marshal.AllocHGlobal(size);
            Marshal.Copy(_values, 0, pntr, _values.Length);
            TestWrapper.print_arr(_titles, ref titlesLen, pntr, ref arrLen);
        }

Собираем всё вместе

Полные исходные коды всех итераций (и ещё немного бонуса в виде передачи массива в колбэк-функцию) лежат в репозитории на битбакете (hg). Если у кого-то есть дополнения — милости прошу в комменты.

Традиционно благодарю всех, кто дочитал до конца, ибо что-то очень уж много текста получилось.

Автор: norritt

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js