I eventually ended up answering this myself. The solution for me was to do in code what I used to do in school. The method works like this:
- Take each number and make sure that the number of digits to the right of the decimal point are the same. So if adding 2.1and3.457, 'normalise' the first one to2.100. Keep a record of the number of digits that are to the right of the decimal, in this case, three.
- Now remove the decimal point and use mpz_addto add the two numbers, which have now become2100and3457. The result is5557.
- Finally, reinsert the decimal point three characters (in this case) from the right, giving the correct answer of 5.557.
I prototyped the solution in VBScript (below)  
function fadd( n1, n2 )
    dim s1, s2, max, mul, res
    normalise3 n1, n2, s1, s2, max
    s1 = replace( s1, ".", "" )
    s2 = replace( s2, ".", "" )
    mul = clng(s1) + clng(s2)
    res = left( mul, len(mul) - max ) & "." & mid( mul, len( mul ) - max + 1 )
    fadd = res
end function
sub normalise3( byval n1, byval n2, byref s1, byref s2, byref numOfDigits )
    dim a1, a2
    dim max
    if instr( n1, "." ) = 0 then n1 = n1 & "."
    if instr( n2, "." ) = 0 then n2 = n2 & "."
    a1 = split( n1, "." )
    a2 = split( n2, "." )
    max = len( a1(1) )
    if len( a2(1) ) > max then max = len( a2( 1 ) )
    s1 = a1(0) & "." & a1(1) & string( max - len( a1( 1 )), "0" )
    s2 = a2(0) & "." & a2(1) & string( max - len( a2( 1 )), "0" )
    numOfDigits = max
end sub
and finally in Visual C++ (below).
#define Z(x) mpz_t x; mpz_init( x );
BSTR __stdcall FADD( BSTR p1, BSTR p2 ) {
  USES_CONVERSION;
  LPSTR sP1 = W2A( p1 );
  LPSTR sP2 = W2A( p2 );
  char LeftOf1[ 1024 ];
  char RightOf1[ 1024 ];
  char LeftOf2[ 1024 ];
  char RightOf2[ 1024 ];
  char * dotPos;
  long numOfDigits;
  int i;
  int amtOfZeroes;
  dotPos = strstr( sP1, "." );
  if ( dotPos == NULL ) {
    strcpy( LeftOf1, sP1 );
    *RightOf1 = '\0';
  } else {
    *dotPos = '\0';
    strcpy( LeftOf1, sP1 );
    strcpy( RightOf1, (dotPos + 1) );
  }
  dotPos = strstr( sP2, "." );
  if ( dotPos == NULL ) {
    strcpy( LeftOf2, sP2 );
    *RightOf2 = '\0';
  } else {
    *dotPos = '\0';
    strcpy( LeftOf2, sP2 );
    strcpy( RightOf2, (dotPos + 1) );
  }
  numOfDigits = strlen( RightOf1 ) > strlen( RightOf2 ) ? strlen( RightOf1 ) : strlen( RightOf2 );
  strcpy( sP1, LeftOf1 );
  strcat( sP1, RightOf1 );
  amtOfZeroes = numOfDigits - strlen( RightOf1 );
  for ( i = 0; i < amtOfZeroes; i++ ) {
    strcat( sP1, "0" );
  }
  strcpy( sP2, LeftOf2 );
  strcat( sP2, RightOf2 );
  amtOfZeroes = numOfDigits - strlen( RightOf2 );
  for ( i = 0; i < amtOfZeroes; i++ ) {
    strcat( sP2, "0" );
  }
  Z(n1);
  Z(n2);
  Z(res);
  mpz_set_str( n1, sP1, 10 );
  mpz_set_str( n2, sP2, 10 );
  mpz_add( res, n1, n2 );
  char * buff =  (char *) _alloca( mpz_sizeinbase( res, 10 ) + 2 + 1 );
  mpz_get_str(buff, 10, res);
  char * here = buff + strlen(buff) - numOfDigits; 
  memmove( here + 1, here, strlen(buff)); // plus trailing null
  *(here) = '.';
  BSTR bResult = _com_util::ConvertStringToBSTR( buff );
  return bResult;
}
I accept that the C is a bit ... well ... dodgy, so please feel free to critique it. All helpful comments gratefully received.
I went on from here to implement FSUB and FMUL as well. FDIV was not nearly so satisfying, ending up in three versions and using rational numbers.