Faced with converting floating-point values from a sensor obtained to string on an embedded system to transmit over UART, I came up with the following dbl2str()
to handle either float
or double
input. The accuracy of the last digit in the fractional part wasn't important as the floating point-values were from a temperature sensor on an MSP432. The intent was to avoid loading stdio.h
and math.h
.
The double value, an adequately sized buffer and then precision for the fractional-part are parameters to the function:
/**
* convert double d to string with fractional part
* limited to prec digits. s must have adequate
* storage to hold the converted value.
*/
char *dbl2str (double d, char *s, int prec);
The approach is:
- Handle
0.0
case where integer-part is'0'
and pad fractional part toprec
'0'
s, return at that point. - Save
sign
flag (1
-negative,0
-posititve), set padding variablezeros
equal toprec
, change sign of floating-point value to positive if negative. - Nul-terminate temp string and fill from end with fractional-part conversion, subtracting
1
fromzeros
on each iteration, and after leaving conversion loop, pad to remainingzeros
. - Add separator
'.'
and continue to fill temp string with integer-part conversion. - if
sign
add'-'
to front of temp string. - copy temp string to buffer and return pointer to buffer.
(note: the range of floating-point values is from roughly -50.0
to 200.00
so INF
was not protected against, nor was exhausting of the 32-byte buffer a consideration)
The code with test case is:
#include <stdio.h>
#include <stdint.h>
#define FPMAXC 32
/**
* convert double d to string with fractional part
* limited to prec digits. s must have adequate
* storage to hold the converted value.
*/
char *dbl2str (double d, char *s, int prec)
{
if (d == 0) { /* handle zero case */
int i = 0;
*s = '0'; /* single '0' for int part */
s[1] = '.'; /* separator */
for (i = 2; i < 2 + prec; i++) /* pad fp to prec with '0' */
s[i] = '0';
s[i] = 0; /* nul-terminate */
return s;
}
char tmp[FPMAXC], *p = tmp + FPMAXC - 1; /* tmp buf, ptr to end */
int sign = d < 0 ? 1 : 0, /* set sign if negative */
mult = 1; /* multiplier for precision */
unsigned zeros = prec; /* padding zeros for fp */
uint64_t ip, fp; /* integer & fractional parts */
if (sign) /* work with positive value */
d = -d;
for (int i = 0; i < prec; i++) /* compute multiplier */
mult *= 10;
ip = (uint64_t)d; /* set integer part */
fp = (uint64_t)((d - ip) * mult); /* fractional part to prec */
*p = 0; /* nul-terminate tmp */
while (fp) { /* convert fractional part */
*--p = fp % 10 + '0';
fp /= 10;
if (zeros) /* decrement zero pad */
zeros--;
}
while (zeros--) /* pad reaming zeros */
*--p = '0';
*--p = '.';
if (!ip) /* no integer part */
*--p = '0';
else
while (ip) { /* convert integer part */
*--p = ip % 10 + '0';
ip /= 10;
}
if (sign) /* if sign, add '-' */
*--p = '-';
for (int i = 0;; i++, p++) { /* copy to s with 0円 */
s[i] = *p;
if (!*p)
break;
}
return s;
}
int main (void) {
char buf[FPMAXC];
double d = 123.45678;
printf ("% 8.3lf => %8s\n", d, dbl2str (d, buf, 3));
d = -d;
printf ("% 8.3lf => %8s\n", d, dbl2str (d, buf, 3));
d = 0.;
printf ("% 8.3lf => %8s\n", d, dbl2str (d, buf, 3));
d = 0.12345;
printf ("% 8.3lf => %8s\n", d, dbl2str (d, buf, 3));
d = -d;
printf ("% 8.3lf => %8s\n", d, dbl2str (d, buf, 3));
d = 123.0;
printf ("% 8.3lf => %8s\n", d, dbl2str (d, buf, 3));
d = -d;
printf ("% 8.3lf => %8s\n", d, dbl2str (d, buf, 3));
}
The function does what I intended, but would like to know if there are any obvious improvements that can be made with a slight-eye on optimization.
Program Output
./bin/dbl2str
123.457 => 123.456
-123.457 => -123.456
0.000 => 0.000
0.123 => 0.123
-0.123 => -0.123
123.000 => 123.000
-123.000 => -123.000
1 Answer 1
The intent was to avoid loading stdio.h and math.h.
if there are any obvious improvements that can be made with a slight-eye on optimization.
Consider float
rather than double
Avoid splitting string processing
Separate processing for integer part and fraction not needed. A simple alternative is to create a scaled integer and then process that integer "right to left" (least to most).
mult
type
A limiting factor is the type of width
. Code uses int
, which is 16-bit on some embedded machines. To match the rest of codes wide integer type usage, uint64_t mult
makes more sense.
Offload padding
dbl2str(double d, char *s, int prec)
might as well handle space padding, thus allowing a simple puts()
rather than printf ("%8s\n", dbl2str (d, buf, 3));
Such as
dbl2str(double d, char *s, int width, int prec)
Minor: Parameter order
Maybe instead of double d, char *s, int prec
, follow the sprintf()
order char *s, int prec, double d
as a more familiar idiom.
Rounding
The code cost to do basic rounding is not high. I recommend it.
Temperature and -0.000
When reporting temperature, seeing -0.0 can be informative.
Consider using its potential appearance with a signbit(d)
test rather than d < 0
, or due to rounding.
Size limited string
Early in a project, data is often not what one thinks. A double
to string function that uses buffer overflow protection would pay for itself in reduced debugging - better than risk UB.
I did not see a need for special zero handling.
See do
loop below.
Some of the above ideas with a modified OP's code
#include <assert.h>
#include <math.h>
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <stdbool.h>
#define FPMAXC 32
char* dbl2str2(size_t n, char s[n], int width, int prec, double d) {
assert(prec >= 0 && prec <= 9); // Or some other upper bound
assert(width >= 0 && (unsigned ) width < n);
char tmp[FPMAXC];
char *p = tmp + FPMAXC - 1;
*p = '0円';
// Or use conventional code for signbit, fabs
bool sign = signbit(d);
int w = sign + 1; // count characters used: sign, ','
d = fabs(d) * 2.0; // * 2 for rounding
for (int p = 0; p < prec; p++) {
d *= 10.0;
}
uint64_t i64 = (uint64_t) d;
i64 = (i64 + i64 % 2) / 2; // round
do {
if (prec-- == 0) {
*(--p) = '.';
}
if ((unsigned) ++w >= n) {
*s = 0; // Number too big - add error code here as desired.
return s;
}
*(--p) = (char) (i64 % 10 + '0');
i64 /= 10;
} while (prec >= 0 || i64);
if (sign) {
*(--p) = '-';
// w++ counted above
}
while (w++ < width) {
*(--p) = ' ';
}
return memcpy(s, p, (size_t) (tmp + FPMAXC - p));
}
Output (with "%8s"
changed to "%s"
and #define dbl2str( d, s, prec) dbl2str2(sizeof(s), (s) , 8, (prec), (d))
)
123.457 => 123.457
-123.457 => -123.457
0.000 => 0.000
0.123 => 0.123
-0.123 => -0.123
123.000 => 123.000
-123.000 => -123.000
Code not heavily tested, yet good enough to give some alternative ideas.
-
\$\begingroup\$ Ah hah! Much improved. I had thought about the rounding issue, but didn't have time to devote last night (and it had a low priority given the difference between
68.82
degrees F and68.83
degrees F isn't important). The other comments suggested profiling withstdio.h
linked -- profiling isn't the problem -- executable size is. Simply linkingstdio.h
forsnprint()
only, balloons the executable size by57%
from126908
bytes without to199504
with. I'll tinker a bit with what you have a report further. \$\endgroup\$David C. Rankin– David C. Rankin2021年01月31日 05:17:23 +00:00Commented Jan 31, 2021 at 5:17 -
\$\begingroup\$ @DavidC.Rankin I am surprised, with space as a concern, why
double
is used - unless it is native to the processor. IAC I'd consider using temperature as anint16_t
in units of centi-degrees. \$\endgroup\$chux– chux2021年01月31日 05:23:48 +00:00Commented Jan 31, 2021 at 5:23 -
\$\begingroup\$ No, the thought-of-the-moment was write generic, so I chose
double
, but float would be optimal given the platform -- no need to double the number of registers needed for each temperature value... The floating-point issue has to do with the way the analog-to-digital conversion is done based on a reference voltage that is sampled and scaled to obtain a temperature reading. The native result of the conversion isfloat
so that began the experiment. While most temp sensors give 10th of a degree as well, I started with 100th of a degree knowing the last place would be basic throw-away later:)
\$\endgroup\$David C. Rankin– David C. Rankin2021年01月31日 05:43:30 +00:00Commented Jan 31, 2021 at 5:43 -
\$\begingroup\$ @DavidC.Rankin Hmmm. If
prec
is only a short range of possible values like [0-3], use a look-up table instead offor (int p = 0; p < prec; p++) { d *= 10.0; }
. Faster and better precision for what is essentially a repetitive calculation. \$\endgroup\$chux– chux2021年01月31日 06:50:41 +00:00Commented Jan 31, 2021 at 6:50 -
\$\begingroup\$ I've combined some of your thoughts (got rid of
ip
andfp
and did it all at once), protected the bounds ofs
with ap > tmp
check on all additions to the buffer, added rounding in the last place withfpm = (uint64_t)(d * mult + .5);
and then did pretty much what you suggest in your comment above sinceprec
will normally be less than3
, I just added a counter and in the loop converting digits, whencnt == prec
I add the'.'
then -- nothing fancy. I like a lot of the other additions for general use, but I was really avoiding other headers, unlinkedmath.h
macros are fine. \$\endgroup\$David C. Rankin– David C. Rankin2021年01月31日 07:04:27 +00:00Commented Jan 31, 2021 at 7:04
sprintf()
does exactly this job already. Though I'd recommendsnprintf()
rather than just telling the caller "s must have adequate storage to hold the converted value", since callers can't be trusted. \$\endgroup\$pi*10E19
overflowed, requiring a painful check on p >= 0. Returning the modified passed array is double. The bounds of the passed array cannot be checked. With buffer overflow exploits, please ensure this rests academic code. Use bool for sign. \$\endgroup\$<stdio.h>
. Note that including that file, on its own, should not have any performance or memory impact since it's just function signatures; the impact comes at the link stage when you actually use a function from it. Have you profiled the difference between using your function and using anftoa
(if implemented) orsprintf
? \$\endgroup\$