В этом разделе мы познакомимся с концепцией раздельной трансляции и научимся собирать программы, состоящие из нескольких модулей.

Немного теории

До сих пор мы имели дело с программами, состоящими из одного модуля. Мы размещали все переменные и весь код в одном файле исходного кода, а затем вызывали ассемблер, чтобы оттранслировать наш модуль (например, hello.asm) и получить на выходе объектный файл (hello.obj):

C:\work>ml /c /coff hello.asm

После этого мы вызывали компоновщик, который собирал из нашего единственного объектного файла — исполняемый файл (hello.exe), который можно запустить непосредственно:

C:\work>link /subsystem:console hello.obj

Компоновщик может принимать на вход не один, а несколько объектных файлов и компоновать их в один исполняемый файл. Такой подход позволяет разделить программу на отдельные части — модули, каждый из которых отвечает за решение своей частной задачи: например, один модуль может отвечать за ввод-вывод, другой за обработку данных и т. д. При этом один модуль объявляется главным и содержит точку входа в программу, а остальные модули считаются вспомогательными.

Простейшая программа из двух модулей

Начнем с программы, главный модуль которой занимается вводом-выводом, а собственно вычисление вынесено в отдельный (вспомогательный) модуль. Наша программа будет вычислять 25-е число Фиббоначчи.

Модуль main

В главном модуле вызовем процедуру fib25, описанную во вспомогательном модуле. Процедура fib25 запишет в переменную result (из главного модуля) вычисленное значение (25-е число Фиббоначчи). Затем выведем результат на экран:

	call fib25
	outwordln result

Для этого нам нужно не только обычным образом выделить память под переменную result, но и объявить публичные (public) и внешние (external) имена:

public	result
fib25	proto
; or 
; extrn	fib25@0: near

Первая строчка сообщает ассемблеру, что имя result нужно сделать публичным, то есть доступным в других модулях, из которых будет скомпонована программа. Вторая строчка говорит, что fib25 — это процедура, которую можно вызвать, но объявлена она (как публичная) будет в некотором другом модуле.

Примечание. Вместо директивы fib25 proto можно использовать директиву extrn fib25@0: near, которая сообщит, что fib25@0 — внешняя метка. В таком случае в инструкции call тоже придется использовать имя fib25@0. За то, что метка, соответствующая адресу входа в процедуру, получает такое имя, отвечают правила декорирования имен, определяемые действующим соглашением о связях. Подробнее об этом — ниже.

Целиком главный модуль выглядит так:

; main.asm
include console.inc

public	result
fib25	proto
; or 
; extrn	fib25@0: near

.data
result	dd ?

.code
Start:	
	call fib25
	; or
	; call fib25@0
	outwordln result

	exit
	
end Start

Модуль fib

Теперь займемся вторым модулем. Начать его нужно с двух директив. Первая директива просит ассемблер генерировать машинный код, совместимый с процессорами не старее Pentium, вторая устанавливает плоскую модель памяти и соглашение о связях stdcall. В наших предыдущих программах мы не указывали эти директивы — они уже есть в файле console.inc, который мы включаем ради макросов ввода-вывода. Однако во вспомогательном модуле, который занимается только вычислениями, ввод-вывод не нужен, поэтому подключать console.inc не требуется:

.586
.model flat, stdcall

Далее, объявим внешние и публичные имена. Во вспомогательном модуле мы определим процедуру fib25, которая будет помещать результат (типа dword) во внешнюю переменную:

public	fib25
extrn	result: dword

Сама процедура будет крайне простой: она будет вызывать функцию fib (которую мы напишем, опираясь на код из предыдущего раздела), и помещать результат в переменную result:

	push 25
	call fib
	mov result, eax

Функция fib не объявлена как публичная, поэтому вызвать ее из главного модуля напрямую будет невозможно.

Ниже приведен код вспомогательного модуля целиком:

; fib.asm
.586
.model flat, stdcall

public	fib25
extrn	result: dword

.code

; procedure fib25()
; Calculate F_25, put result into the external variable `result`.
fib25	proc

	push ebp
	mov ebp, esp

	push eax

	push 25
	call fib
	mov result, eax

	pop eax

	mov esp, ebp
	pop ebp
	
	ret

fib25	endp


; function fib(n: dword): dword
; Calculate and return F_n. If overflows, return -1.
fib	proc

	push ebp
	mov ebp, esp
	
	push ebx
	push ecx
	push edx

	mov edx, [ebp+8]	; n
	mov eax, 1		; F_2=F_k
	mov ebx, eax		; F_1
	mov ecx, 2		; k=2

	cmp edx, 2		; F_1 or F_2? we already have the answer
	jbe @ret

@nx:	xchg ebx, eax		; ebx <- F_k
	add eax, ebx		; eax <- F_k+1
	jc @ofl
	inc ecx			; k = k + 1
	cmp ecx, edx
	jb @nx
	jmp @ret

@ofl:	mov eax, -1		; return -1 on overflow

@ret:	pop ebx
	pop ecx
	pop edx
	
	mov esp, ebp
	pop ebp
	
	ret 4
	
fib	endp

end

Примечание. Точка входа в программу должна быть только в одном модуле — главном.

Трансляция и компоновка

Теперь вручную соберем и запустим исполняемый файл:

C:\work\fib25>ml /nologo /c /coff /Fl fib.asm
 Assembling: fib.asm

C:\work\fib25>ml /nologo /c /coff /Fl main.asm
 Assembling: main.asm

C:\work\fib25>link /nologo /subsystem:console main.obj fib.obj

C:\work\fib25>main
75025

Компоновщик строит исполняемый файл, имя которого совпадает с первым из переданных ему объектных файлов (в нашем случае main); если требуется иное имя, можно передать параметр /out:<name>, тогда исполняемый файл будет называться <name>.exe.

Во время компоновки внешние и публичные имена различных модулей должны «состыковаться»: для каждого внешнего имени в одном из модулей должно найтись соответствующее публичное. Если это не так, мы получим ошибку unresolved external. Например, если не объявить переменную result как публичную, то компоновщик выдаст вот такую диагностику:

C:\work\fib25>link /nologo /subsystem:console main.obj fib.obj
fib.obj : error LNK2001: unresolved external symbol _result
main.exe : fatal error LNK1120: 1 unresolved externals

Эта ошибка происходит именно на стадии компоновки; трансляция же каждого из модулей завершилась успешно.

Автоматизация сборки

Пакетный файл mkr.bat, который мы применяли для трансляции простейших программ, оказывается бесполезным, если программа состоит из нескольких модулей. Удобнее поступить следующим образом: поместить исходный код всех модулей в отдельный каталог (например, нашу программу поместим в каталог fib25 внутри рабочего каталога) и рядом в тот же каталог положить пакетный файл сборки — назовем его build.bat:

@echo off
ml /nologo /c /coff /Fl main.asm || goto END
ml /nologo /c /coff /Fl fib.asm || goto END
echo  Linking
link /nologo /subsystem:console main.obj fib.obj || goto END
echo  Running main.exe
echo ------------------
main
:END

Примечание. Размещать каждую программу в отдельном каталоге полезно еще и потому, что в разных программах могут иметься разные модули с одинаковыми именами — например, модуль с именем main.

Для каждой новой многомодульной программы этот файл потребуется модифицировать, добавив по вызову ml для каждого модуля, указав правильный список объектных файлов в вызове link и правильное имя исполняемого файла.

C:\work\fib25>build
 Assembling: main.asm
 Assembling: fib.asm
 Linking
 Running main.exe
------------------
75025

Благодаря конструкции || goto END после каждого из вызовов сборка продолжится только в том случае, если очередной шаг завершился успешно. Если же на каком-то шаге произошла ошибка, то дальнейшие шаги выполняться не будут, и исполняемый файл запускаться тоже не будет:

C:\work\fib25>build
 Assembling: main.asm
 Assembling: fib.asm
fib.asm(14) : error A2022: instruction operands must be the same size

Примечание. Минусом нашей самодельной системы сборки является то, что сборка выполняется заново полностью, даже если мы изменили только один из модулей. Промышленные системы сборки, которыми обычно пользуются при разработке (make, ant и т. д.) лишены этого недостатка.

Декорирование имен

В самом начале модуля fib мы указали директиву .model flat, stdcall, выбрав тем самым stdcall в качестве соглашения о связях. Помимо всего прочего, stdcall предусматривает определенные правила декорирования имен, то есть правила, по которым из имени процедуры, определенной в модуле, будет сформировано имя для компоновщика. В случае соглашения о связях stdcall к имени добавляется _ в начале и @ плюс размер параметров на стеке в байтах — в конце. У процедуры fib25 нет параметров, поэтому декорированное имя для нее будет выглядеть как _fib25@0.

Примечание. Соглашение stdcall предполагает, что вызывающий код помещает параметры на стек, а очищает их уже вызываемая процедура. Поэтому если вызывающая и вызываемая стороны не договорились о количестве параметров, то стек будет испорчен. Пример такой ситуации: в модуле А процедура P вызывается, а в модуле B она определена. Изначально она принимает два параметра. Далее в модуле процедура меняется так, чтобы она принимала три параметра. Без декорирования имен сборка по-прежнему будет проходить успешно, но программа перестанет работать, причем катастрофическим образом. Благодаря декорированию же ошибка будет обнаружена на этапе компоновки: модуль A публикует процедуру _P@12, а модулю B требуется процедура _P@8.

Чтобы увидеть полную таблицу имен объектного файла, можно воспользоваться программой objdump:

C:\work\mma\1fib>objdump -t fib.obj

fib.obj:     file format pe-i386

SYMBOL TABLE:
[  0](sec -2)(fl 0x00)(ty   0)(scl 103) (nx 1) 0x00000000 fib.asm
File
[  2](sec -1)(fl 0x00)(ty   0)(scl   3) (nx 0) 0x001220fc @comp.id
[  3](sec  1)(fl 0x00)(ty   0)(scl   3) (nx 1) 0x00000000 .text
AUX scnlen 0x49 nreloc 1 nlnno 0
[  5](sec  2)(fl 0x00)(ty   0)(scl   3) (nx 1) 0x00000000 .data
AUX scnlen 0x0 nreloc 0 nlnno 0
[  7](sec  0)(fl 0x00)(ty   0)(scl   2) (nx 0) 0x00000000 _result
[  8](sec  1)(fl 0x00)(ty  20)(scl   2) (nx 0) 0x00000000 _fib25@0
[  9](sec  1)(fl 0x00)(ty  20)(scl   2) (nx 0) 0x00000015 _fib@0

Примечание. У функции fib, если смотреть на ее декорированное имя, нет параметров. На самом деле мы просто не указали их в заголовке функции. Язык ассемблера позволяет это сделать, и об этом мы поговорим в следующем разделе.

Обычная метка, если объявить ее публичной, декорируется проще: к имени добавляется _ в начале. Именно поэтому вместо fib25 proto можно было указать extrn fib25@0: near — в обоих случаях соответствующее декорированное имя получалось _fib25@0.

Если убрать спецификацию stdcall из директивы .model, то таблица имен для модуля будет выглядеть иначе — имена декорироваться не будут:

[  0](sec -2)(fl 0x00)(ty   0)(scl 103) (nx 1) 0x00000000 fib.asm
File
[  2](sec -1)(fl 0x00)(ty   0)(scl   3) (nx 0) 0x001220fc @comp.id
[  3](sec  1)(fl 0x00)(ty   0)(scl   3) (nx 1) 0x00000000 .text
AUX scnlen 0x49 nreloc 1 nlnno 0
[  5](sec  2)(fl 0x00)(ty   0)(scl   3) (nx 1) 0x00000000 .data
AUX scnlen 0x0 nreloc 0 nlnno 0
[  7](sec  0)(fl 0x00)(ty   0)(scl   2) (nx 0) 0x00000000 result
[  8](sec  1)(fl 0x00)(ty  20)(scl   2) (nx 0) 0x00000000 fib25
[  9](sec  1)(fl 0x00)(ty  20)(scl   2) (nx 0) 0x00000015 fib