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

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

    Не так давно мне принесли небольшую программку (менее 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 bin\test.exe src\test.for
    

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

    Чтобы получился exe, не требующий рантайма, компиляцию надо проводить с ключом -static:
    > gfortran -static -o bin\test.exe src\test.for
    

    Для получения же dll требуется добавить ещё ключик -shared:
    > gfortran -static -shared -o bin\test.dll src\test.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: src\test.for
    	$(FORTRAN_COMPILER) -O2 \
    		-c -o obj\test.obj \
    		src\test.for
    	$(FORTRAN_COMPILER) -static \
    		-o bin\test.exe \
    		obj\test.obj
    	$(FORTRAN_COMPILER) -static -shared \
    		-o bin\test.dll \
    		obj\test.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.for\bin\test.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). Если у кого-то есть дополнения — милости прошу в комменты.

    Традиционно благодарю всех, кто дочитал до конца, ибо что-то очень уж много текста получилось.
    • +38
    • 19,1k
    • 6
    Поделиться публикацией

    Комментарии 6

      +2
      Напомню про Intel Visual Fortran, PGI Visual Fortran и про Silverfrost FTN95 (последний компилирует под .NET).
        0
        Первые два платные, третий — бесплатный для персонального использования. Так-то мои цели можно конечно за уши притянуть, но всё равно это будет нарушение лицензии :-)
        Но вообще спасибо, FTN95 почему-то не нагуглился так просто.
          0
          intel fortran вообще в VS встраивается.
            +1
            Только стоит от 600 баксов, ЕМНИП
        +1
        Что-то более сложное можно портировать при помощи Managed Extensions for C++. Я когда-то давно часть LAPACK и BLAS портировал, используя за основу готовую C++ обертку: github.com/2xmax/NLapack
          0
          Спасибо, не исключено, что будет для меня полезным в ближайшем будущем.

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

        Самое читаемое