Как известно, в мире миллионы и миллионы строк легаси-кода. Первое место в легаси, разумеется, принадлежит Коболу, но и на долю Фортрана досталось немало. Причём, в основном, вычислительных модулей.
Не так давно мне принесли небольшую программку (менее 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