Работа с FPU

From Wiki.conus.info

Jump to: navigation, search

FPU (Floating-point unit) - девайс в процессоре работающий с числами с плавающей запятой. Раньше он назывался сопроцессором. Он немного похож на программируемый калькулятор и стоит немного в стороне от основного процессора. Интересен факт, что в свое время (до 80486) сопроцессор был отдельным чипом и на материнской плате он стоял не всегда. Раньше, его можно было докупить отдельно и поставить.

Перед изучением FPU полезно ознакомиться с тем как работают стековые машины:

http://en.wikipedia.org/wiki/Stack_machine

Или ознакомиться с основами языка Forth:

http://en.wikipedia.org/wiki/Forth_(programming_language)

Начиная с 80486, FPU уже всегда входит в состав процессора.

FPU имеет стек из восьми 80-битных регистров, каждый может содержать число в формате IEEE 754.

В Си типы имеются два типа для работы с числами с плавающей запятой, это float (32 бита) и double (64 бита). GCC поддерживает тип long double (80 бит), но MSVC - нет. Не смотря на то что float занимает столько же места сколько int на 32-битной архитектуре, представление чисел совершенно другое. Число состоит из знака, мантиссы и экспоненты.

http://en.wikipedia.org/wiki/IEEE_754-2008

Функция, имеющая float или double в числе аргументов, получает это значение через стек. Если функция возвращает float или double, она оставляет значение в регистре ST(0) - на вершине стека.

Contents

Простой пример

Рассмотрим простой пример:

double f (double a, double b)
{
	return a/3.14 + b*4.1;
};

Компилим с MSVC 2010:

CONST	SEGMENT
__real@4010666666666666 DQ 04010666666666666r	; 4.1
CONST	ENDS
CONST	SEGMENT
__real@40091eb851eb851f DQ 040091eb851eb851fr	; 3.14
CONST	ENDS
_TEXT	SEGMENT
_a$ = 8							; size = 8
_b$ = 16						; size = 8
_f	PROC
	push	ebp
	mov	ebp, esp
	fld	QWORD PTR _a$[ebp]
 
; состояние стека сейчас: ST(0) = _a
 
	fdiv	QWORD PTR __real@40091eb851eb851f
 
; состояние стека сейчас: ST(0) = результат деления _a на 3.13
 
	fld	QWORD PTR _b$[ebp]
 
; состояние стека сейчас: ST(0) = _b; ST(1) = результат деления _a на 3.13
 
	fmul	QWORD PTR __real@4010666666666666
 
; состояние стека сейчас: ST(0) = результат _b * 4.1; ST(1) = результат деления _a на 3.13
 
	faddp	ST(1), ST(0)
 
; состояние стека сейчас: ST(0) = результат сложения
 
	pop	ebp
	ret	0
_f	ENDP

FLD берет 8 байт из стека и загружает из в регистр ST(0), автоматически конвертируя во внутренний 80-битный формат.

FDIV делит содержимое ST(0) на число лежащее по адресу __real@40091eb851eb851f - там закодировано значение 3.14. Синтаксис ассемблера не поддерживает подобные числа, так что то что мы там видим, это шестандцатиричное представление числа 3.14.

После выполнения FDIV, в ST(0) остается результат деления.

Кстати, есть еще инструкция FDIVP, которая делит ST(1) на ST(0), выталкивает эти числа из стека и заталкивает результат. Если вы знаете язык Forth, то это как раз оно и есть - стековая машина.

Следующая FLD заталкивает в стек значение b.

После этого, в ST(1) перемещается результат деления, а в ST(0) теперь будет b.

Следующий FMUL умножает b из ST(0) на значение __real@4010666666666666 - там лежит число 4.1, и оставляет результат в ST(0).

Самая последняя инструкция FADDP складывает два значения из вершины стека, в ST(1) и затем выталкивает значение лежащее в ST(0), таким образом результат сложения остается на вершине стека в ST(0).

Функция должна вернуть результат в ST(0), так что больше ничего здесь не производится.

GCC 4.4.1 (с опцией -O3) генерит похожий код, хотя и с некоторой разницей:

                public f
f               proc near
 
arg_0           = qword ptr  8
arg_8           = qword ptr  10h
 
                push    ebp
                fld     ds:dbl_8048608 ; 3.14
 
; состояние стека сейчас: ST(0) = 3.13
 
                mov     ebp, esp
                fdivr   [ebp+arg_0]
 
; состояние стека сейчас: ST(0) = результат деления
 
                fld     ds:dbl_8048610 ; 4.1
 
; состояние стека сейчас: ST(0) = 4.1, ST(1) = результат деления
 
                fmul    [ebp+arg_8]
 
; состояние стека сейчас: ST(0) = результат умножения, ST(1) = результат деления
 
                pop     ebp
                faddp   st(1), st
 
; состояние стека сейчас: ST(0) = результат сложения
 
                retn
f               endp

Разница в том, что в стек сначала заталкивается 3.14 (в ST(0)), а затем значение из arg_0 делится на то что лежит в регистре ST(0).

FDIVR означает "Reverse Divide" - делить поменяв делитель и делимое местами. Точно такой же инструкции для умножения нет, потому что умножение - операция коммутативная, так что здесь остается FMUL.

FADDP не только складывает два значения, но также и выталкивает из стека одно значение. После этого, в ST(0) остается только результат сложения.

Этот кусок кода получен при помощи IDA, которая регистр ST(0) называет для краткости просто ST.

Передача чисел с плавающей запятой в аргументах

int main ()
{
	printf ("32.01 ^ 1.54 = %lf\n", pow (32.01,1.54));
 
	return 0;
}

pow() - это функция, которая возводит число в степень.

Посмотрим что у нас вышло (MSVC 2010):

CONST	SEGMENT
__real@40400147ae147ae1 DQ 040400147ae147ae1r	; 32.01
__real@3ff8a3d70a3d70a4 DQ 03ff8a3d70a3d70a4r	; 1.54
CONST	ENDS
 
_main	PROC
	push	ebp
	mov	ebp, esp
	sub	esp, 8  ; выделить место для первой переменной
	fld	QWORD PTR __real@3ff8a3d70a3d70a4
	fstp	QWORD PTR [esp]
	sub	esp, 8  ; выделить место для второй переменной
	fld	QWORD PTR __real@40400147ae147ae1
	fstp	QWORD PTR [esp]
	call	_pow
	add	esp, 8  ; "вернуть" место от одной переменной. 
 
; в локальном стеке сейчас зарезервировано 8 байт для нас. результат сейчас в ST(0)
 
	fstp	QWORD PTR [esp] ; перегрузить результат из ST(0) в локальный стек для printf()
	push	OFFSET $SG2651
	call	_printf
	add	esp, 12
	xor	eax, eax
	pop	ebp
	ret	0
_main	ENDP

FLD/FSTP перемешают переменные из/в сегмента данных в FPU-стек. pow() достает оба значения из FPU-стека и возвращает результат в ST(0). printf() берет 8 байт из стека и трактует как double.

Пример с сравнением

Попробуем теперь вот это:

double d_max (double a, double b)
{
	if (a>b)
		return a;
 
	return b;
};

Несмотря на кажущуюся простоту этой функции, понять как она работает будет чуть сложнее.

Вот что выдал MSVC 2010:

PUBLIC	_d_max
_TEXT	SEGMENT
_a$ = 8							; size = 8
_b$ = 16						; size = 8
_d_max	PROC
	push	ebp
	mov	ebp, esp
	fld	QWORD PTR _b$[ebp]
 
; состояние стека сейчас: ST(0) = _b
 
	fcomp	QWORD PTR _a$[ebp] ; compare _b (ST(0)) and _a and pop register
 
; стек теперь пустой
 
	fnstsw	ax
	test	ah, 5
	jp	SHORT $LN1@d_max
 
; we are here if a>b
 
	fld	QWORD PTR _a$[ebp]
	jmp	SHORT $LN2@d_max
$LN1@d_max:
	fld	QWORD PTR _b$[ebp]
$LN2@d_max:
	pop	ebp
	ret	0
_d_max	ENDP

Итак, FLD загружает _b в регистр ST(0).

FCOMP сравнивает содержимое ST(0) с тем что лежит в _a и выставляет биты C3/C2/C0 в регистре статуса FPU. Это 16-битный регистр отражающий текущее состояние FPU.

Итак, биты C3/C2/C0 выставлены, но, к сожалению, у процессора до Intel P6 нет инструкций условного перехода проверяющих эти биты. Возможно это исторически так сложилось (вспомните о том что FPU когда-то был вообще отдельным чипом). А у Intel P6 появились инструкции FCOMI/FCOMIP/FUCOMI/FUCOMIP - делающие тоже самое, только напрямую модифицирующие флаги ZF/PF/CF.

После этого, инструкция выдергивает одно значение из стека. Это отличает её от FCOM, которая просто сравнивает значения, оставляя стек в таком же состоянии.

FNSTSW копирует содержимое регистра статуса в AX. Биты C3/C2/C0 занимают позиции, соответственно, 14, 10, 8, в этих позициях они и остаются в регистре AX, и все они расположены в старшей части регистра - AH.

Если b>a в нашем случае, то биты C3/C2/C0 должны быть выставлены так: 0, 0, 0.

Если a>b, то биты будут выставлены: 0, 0, 1.

Если a=b, то биты будут выставлены так: 1, 0, 0.

После test ah, 5, бит C3 и C1 сбросится в ноль, на позициях 0 и 2 (внутри регистра AH) останутся соответственно C0 и C2.

Теперь немного о parity flag. Еще один замечательный рудимент эпохи.

http://en.wikipedia.org/wiki/Parity_flag

Там же в статье можно прочитать: One common reason to test the parity flag actually has nothing to do with parity. The FPU has four condition flags (C0 to C3), but they can not be tested directly, and must instead be first copied to the flags register. When this happens, C0 is placed in the carry flag, C2 in the parity flag and C3 in the zero flag. The C2 flag is set when e.g. incomparable floating point values (NaN or unsupported format) are compared with the FUCOM instructions.

Этот флаг выставляется в 1 если количество едениц в последнем результате - четно. И в ноль если - нечетно.

Таким образом, что мы имеем, флаг PF будет выставлен в еденицу, если C0 и C2 оба нули или оба еденицы. И тогда сработает последующий JP (jump if PF==1). Если мы вернемся чуть назад и посмотрим значения C3/C2/C0 для разных вариантов, то увидим, что условный переход JP сработает в двух случаях: если b>a или если a==b (ведь бит C3 уже "вылетел" после исполнения test ah, 5).

Дальше все просто. Если условный переход сработал, то FLD загрузит в ST(0) значение _b, а если не сработал, то загрузится _a и произойдет выход из функции.

Но это еще не все!

А теперь скомпилим все это в MSVC 2010 с опцией /Ox

_a$ = 8							; size = 8
_b$ = 16						; size = 8
_d_max	PROC
	fld	QWORD PTR _b$[esp-4]
	fld	QWORD PTR _a$[esp-4]
 
; состояне стека сейчас: ST(0) = _a, ST(1) = _b
 
	fcom	ST(1) ; compare _a and ST(1) = (_b)
	fnstsw	ax
	test	ah, 65					; 00000041H
	jne	SHORT $LN5@d_max
	fstp	ST(1)  ; copy ST(0) to ST(1) and pop register, leave (_a) on top
 
; состояние стека сейчас: ST(0) = _a
 
	ret	0
$LN5@d_max:
	fstp	ST(0)  ; copy ST(0) to ST(0) and pop register, leave (_b) on top
 
; состояние стека сейчас: ST(0) = _b
 
	ret	0
_d_max	ENDP

FCOM отличается от FCOMP тем что просто сравнивает значения и оставляет стек в том же состоянии. В отличие от предыдущего примера, операнды здесь в другом порядке. Поэтому и результат в C3/C2/C0 будет другим чем раньше:

Если a>b в нашем случае, то биты C3/C2/C0 должны быть выставлены так: 0, 0, 0.

Если b>a, то биты будут выставлены: 0, 0, 1.

Если a=b, то биты будут выставлены так: 1, 0, 0.

test ah, 65 как бы оставляет только два бита - C3 и C0. Они оба будут нулями, если a>b: в таком случае переход JNE не сработает. Далее имеется инструкция FSTP ST(1) - эта инструкция копирует значение ST(0) в указанный операнд и выдергивает одно значение из стека. В данном случае, она копирует ST(0) (где сейчас лежит _a) в ST(1). После этого на вершине стека два раза лежат _a. Затем одно значение выдергивается. После этого в ST(0) остается _a и функция завершается.

Условный переход JNE сработает в двух других случаях: если b>a или a==b. Скопируется ST(0) в ST(0), что как бы холостая операция, затем одно значение из стека вылетит и на вершине стека останется то что до этого лежало в ST(1) (то есть, _b). И функция завершится. Эта инструкция используется здесь видимо потому что в FPU нет инструкции которая просто выдергивает значение из стека и больше ничего.

Но и это еще не все.

GCC 4.4.1

d_max           proc near
 
b               = qword ptr -10h
a               = qword ptr -8
a_first_half    = dword ptr  8
a_second_half   = dword ptr  0Ch
b_first_half    = dword ptr  10h
b_second_half   = dword ptr  14h
 
                push    ebp
                mov     ebp, esp
                sub     esp, 10h
 
; переложим a и b в локальный стек:
 
                mov     eax, [ebp+a_first_half]
                mov     dword ptr [ebp+a], eax
                mov     eax, [ebp+a_second_half]
                mov     dword ptr [ebp+a+4], eax
                mov     eax, [ebp+b_first_half]
                mov     dword ptr [ebp+b], eax
                mov     eax, [ebp+b_second_half]
                mov     dword ptr [ebp+b+4], eax
 
; загружаем a и b в стек FPU
 
                fld     [ebp+a]
                fld     [ebp+b]
 
; текущее состояние стека: ST(0) - b; ST(1) - a
 
                fxch    st(1) ; эта инструкция меняет ST(1) и ST(0) местами
 
; текущее состояние стека: ST(0) - a; ST(1) - b
 
                fucompp            ; сравнить a и b и выдернуть из стека два значения, т.е., a и b
                fnstsw  ax         ; записать статус FPU в AX
                sahf               ; загрузить состояние флагов SF, ZF, AF, PF, и CF из AH
                setnbe  al                ; записать еденицу в AL если CF=0 и ZF=0
                test    al, al            ; AL==0 ?
                jz      short loc_8048453 ; да
                fld     [ebp+a]
                jmp     short locret_8048456
 
loc_8048453:
                fld     [ebp+b]
 
locret_8048456:
                leave
                retn
d_max           endp

FUCOMPP - это почти то же что и FCOM, только выкидывает из стека оба значения после сравнения, а также несколько иначе реагирует на "не числа".

Отступление:

http://en.wikipedia.org/wiki/NaN

http://ru.wikipedia.org/wiki/NaN

FPU умеет работать со специальными переменными, которые числами не являются. Это бесконечность, результат деления на ноль, и так далее. Нечисла бывают "тихие" и "сигнализирующие". С первыми можно продолжать работать и далее, а вот если вы попытаетесь совершить какую-то операцию с сигнализирующим нечислом, то сработает исключение. FCOM вызовет исключение если любой из операндов - нечисло. FUCOM же вызовет исключение только если один из операндов - сигнализирующее нечисло.

Далее мы видим SAHF - это довольно редкая инструкция в коде не использущим FPU. 8 бит из AH перекладываются в младшие 8 бит регистра статуса процессора в таком порядке: SF:ZF:?:AF:?:PF:?:CF <- AH.

Вспомним, что FNSTSW перегружает интересующие нас биты C3/C2/C0 в AH, и соответственно они будут в позициях 6, 2, 0 в регистре AH.

Иными словами, пара инструкций fnstsw ax / sahf перекладывает биты C3/C2/C0 в флаги ZF, PF, CF.

Теперь снова вспомним, какие значения бит C3/C2/C0 будут при каких результатах сравнения:

Если a больше b в нашем случае, то биты C3/C2/C0 должны быть выставлены так: 0, 0, 0.

Если a меньше b, то биты будут выставлены: 0, 0, 1.

Если a=b, то биты будут выставлены так: 1, 0, 0.

Иными словами, после инструкций FUCOMPP/FNSTSW/SAHF, мы получим такое состояние флагов:

Если a>b в нашем случае, то флаги будут выставлены так: ZF=0, PF=0, CF=0.

Если a меньше b, то флаги будут выставлены: ZF=0, PF=0, CF=1.

Если a равно b, то флаги будут выставлены так: ZF=1, PF=0, CF=0.

Инструкция SETNBE выставит в AL еденицу или ноль, в зависимости от флагов и условий. Это почти аналог JNBE, за тем лишь исключением, что SETcc (cc это condition code) выставляет 1 или 0 в AL, а Jcc делает переход или нет. SETNBE запишет еденицу если только CF=0 и ZF=0. Если это не так, то запишет 0.

CF будет 0 и ZF будет 0 одновременно только в одном случае, если a>b.

Тогда в AL будет записана еденица, послудющий условный переход JZ взят не будет, и функция вернет _a. В остальных случаях, функция вернет _b.

Но и это еще не конец.

GCC 4.4.1 с оптимизацией -O3

                public d_max
d_max           proc near
 
arg_0           = qword ptr  8
arg_8           = qword ptr  10h
 
                push    ebp
                mov     ebp, esp
                fld     [ebp+arg_0] ; _a
                fld     [ebp+arg_8] ; _b
 
; состояние стека сейчас: ST(0) = _b, ST(1) = _a
 
                fxch    st(1)
 
; состояние стека сейчас: ST(0) = _a, ST(1) = _b
 
                fucom   st(1) ; сравнить _a и _b
                fnstsw  ax
                sahf
                ja      short loc_8048448
                fstp    st ; записать ST(0) в ST(0) (холостая операция), выкинуть значение лежащее на вершине стека, оставить _b
                jmp     short loc_804844A
 
loc_8048448:
                fstp    st(1) ; записать _a в ST(0), выкинуть значение лежащее на вершине стека, оставить _a на вершине стека
 
loc_804844A:
                pop     ebp
                retn
d_max           endp

Почти все что здесь есть уже описано мною, кроме одного: использование JA после SAHF. Действительно, инструкции условных переходов "больше", "меньше", "равно" для сравнения беззнаковых чисел (JA, JAE, JB, JBE, JE/JZ, JNA, JNAE, JNB, JNBE, JNE/JNZ) проверяют только флаги CF и ZF. И биты C3/C2/C0 после сравнения перекладываются в эти флаги аккурат так, чтобы перечисленные инструкции переходов могли работать. JA сработает если CF и ZF обнулены.

Таким образом, перечисленные инструкции условного перехода можно использовать после инструкций FNSTSW/SAHF.

Вполне возможно что биты статуса FPU C3/C2/C0 преднамерено были размещены таким образом, чтобы переноситься на флаги процессора без перестановок.

Personal tools
Namespaces
Variants
Actions
Navigation
Toolbox