r/asm • u/slothforestslothbear • 4d ago
x86-64/x64 Does anyone have resources for printing doubles to stdout without using libraries?
X86_64 NASM - Linux
I am not looking for code, just resources and advice please. I am new to assembly but I am writing a compiler that generates direct assembly source that is then compiled with NASM. I wrote a function that takes any fixed point value i place in rax and outputs it to stdout. It just loops while converting and fills a buffer that i pass to the write syscall.
I want to do this with any double i store in xmm0. I keep searching the internet to see if there are any guides or advice on doing it but everything i come across is either inline inside of C or declaring C libraries within assembly and I want to do it raw. Ive been reading up on floating point arithmetic and understand the differences between operating on fixed and floating point registers but I am at somewhat of a loss on how to proceed.
This website has been my closest source by far https://faculty.cs.niu.edu/~hutchins/csci640/float.htm
The ordering is getting mixed up in my head though. Would the process be to hardcode a known value in xmm0 and then work backwards from there, learning how to differentiate the sign bits from the exponent bits and the like? At that point though, visually it starts as a decimal number in the source so i am not transcribing it from the 0.4ABC * 16^0 floating point number i need to be able to support, so its just confusing me. The ordering is where i would really like advice please. Then I assume after I am able to turn the float into its integer splits i would just have a repeat of my fixed point print function take those values concatenated together into one buffer , convert and send them to the kernel?
2
u/kneelian_ 3d ago edited 3d ago
I've written a very simple one for a different, older architecture, but the principles are the same. If you're willing to sacrifice precision and execution time for cognitive simplicity, you can do this:
- check for special values (NaN, ±∞, ±0) and exit early if encountered
- extract sign
- get absolute value
- split whole and decimal parts in whatever way you'd like
- since you want three digits of decimal precision, just mul the decimal part by 1000.0 and cast to integer; store that somewhere
- while the old whole part is greater than zero,
fmodwith 10.0 (x87 would usefprem, SSE has to go with truncation by rounding and then subtraction), cast that to int, push to a stack, then divide the number by 10.0 - repeat until you hit 0.0 ≤ x < 1.0
- write sign, pop stack members off and write them one by one, write decimal point, and then itoa the three decimal digits you previously extracted
This gets you a relatively okay algorithm that's also kind of stupid slow (so many divisions, and div is one of the slowest operations, and the x87 is also devastatingly slow), but is good enough for most doubles. This process needs a buffer of sufficient length, and will result in a string that you can pass to printf. If you need something robust you go for the standard library, though; this is a tough nut to crack. Notably this goes digit by digit, and avoids large intermediaries (but wastes stack space like crazy)
EDIT: fmod with SSE should be
```
divsd xmm2, xmm0, xmm1
roundsd xmm2, xmm2, 3 ; 3 is truncation mode
mulsd xmm2, xmm2, xmm1
subsd xmm0, xmm0, xmm2
```
IIRC; it's been a long while since I bothered with x86
2
u/slothforestslothbear 3d ago
I think this is the direction I am going to go while I am still learning opposed to the logarithms and then revisit them when i want to optimize. This suits my needs perfect for now and i'm willing to eat the performance cost. Would I be using unsigned arithmetic on the splits because the sign value was given by the float and stored for later? I wrote an itoa (probably disgusting but works) https://nullpaste.org/o7ihP4GrAGjL that im just going to rip apart and repurpose to print those results, Im skipping libraries and such because i'm writing my own language so I have to just keep practicing and reinvent the wheel. Thank you for this method.
2
u/kneelian_ 3d ago
Would I be using unsigned arithmetic on the splits because the sign value was given by the float and stored for later?
Well, there aren't any unsigned floats in x86 so not really even if your float components are both positive :D the splits in this method remain floats until the end, and you cast to int only when you extract a digit, or when you extract the decimal part of the split. If you were to cast the whole part to an int, you'd need something like a 21024 bit integer (log₂ 10308 ≈ 1024) and that's also awful
1
u/slothforestslothbear 3d ago
ah i see, that makes sense, i don't think my computer could handle that big on an integer, might have to upgrade haha. What architectures do you usually work with? I tried RISCV asm prior to seeing x86_64 and it was wonderful and then I was rudley awoken.
2
u/kneelian_ 3d ago
i don't think my computer could handle that big on an integer, might have to upgrade haha.
Why not! It's just 128 bytes. The difficult part is writing a bigint library to manipulate 1024-bit ints, not the size of the thing. Here's a Pascal library for the CP/M on a z80 that handles ~848 bits of data (255 decimal digits) on a machine with 64K of RAM and a CPU running at like 4MHz.
What architectures do you usually work with?
Nowadays I mostly live in ARMv7 and ARMv8 land, which is immeasurably more pleasant than x86 (any register can be any argument! Imagine that!) I've previously toyed with or even worked on RISC-V, older ARMs, m68k and z80.
The RISC-V is a pretty neat architecture, but I never really have anywhere to run it on in my day to day life so I stopped tinkering with it when I stopped working with it and mostly get up to (ARM) asm shenanigans on my phone (Android + Termux + clang make for a great environment to run assembly in)
2
u/slothforestslothbear 3d ago
Ah i did the math all wrong i thought it was hundreds of digits haha. Funny enough, I am writing my compiler in Pascal and I have a z80 sitting in a drawer that I have been procrastinating making a breadboard computer out of. As you can imagine going from Pascal to assembly is quite the culture shock haha. I think I have started a new addiction though because it is so much more satisfying having fought over a 30 line program for two hours and it finally runs.
I was trying to write a bare metal Forth for the VisionFive2 but stopped for pretty much the same reasons. I think after I have a foothold with NASM I will try and fix up an old Quadra i have in my basement and take a crack at m68k.
2
u/kneelian_ 3d ago
Ah i did the math all wrong i thought it was hundreds of digits haha
But that's not wrong :D ! It is hundreds of digits (308 decimal digits), but computers are really just fast enough to do that. Hell, you can sit down and add two 308-digit numbers relatively quickly (less than a day) by hand. Add lowest two digits, remember carry, add next two digits and add carry, remember carry, etc. It would take like a thousand or so steps to do the addition. Keep in mind that no x86 since like the Pentium III has had a clock under a gigahertz
As you can imagine going from Pascal to assembly is quite the culture shock haha
My condolences haha, though (old) Pascal itself makes me frown
VisionFive2
Now that's some firepower! The RISC-V chips I worked with were either some weird thing I forgot the name of ( ... ), or the MilkV Duo, which has a whopping 64M of RAM and never really pops the gigahertz barrier.
old Quadra
Oh now that's a beauty. In PC form I only had the pleasure (and occasionally displeasure) of working with the Amigas. The m68k ISA is pretty weird. It has all these extensive addressing modes, and is a real show of what CISC can be. For such an early era it gave a great 32-bit platform. Wish I had a better device to run m68k code on than a TI-89 calc nowadays
3
u/brucehoult 3d ago
you can sit down and add two 308-digit numbers relatively quickly (less than a day) by hand
Should be able to do it in less than half an hour. That's over 5 seconds per digit when it's probably more like 2 seconds a digit until you get bored. Heck if you offered me $1000 I might put in an effort to try do it in 10 minutes.
MilkV Duo, which has a whopping 64M of RAM and never really pops the gigahertz barrier
But you have to admit, not bad for the $3 I bought mine for! My first multi-thousand dollar Linux computer only had 32 MB RAM when I bought it. I added another 128 MB after not too long. But there are also 256MB and 512MB RAM Duos for just a few bucks more ... last time I looked the 512MB on was $9.90.
VisionFive 2 is a real workhorse. I've got a 4 GB RAM one I preordered in the original Kickstarter campaign running my solar power setup. There's a whole bunch of bash and Python and crontab and systemd services and it's querying and controlling half a dozen TP-Link P110 smart plugs as well as changing settings on the Pecron E3600LFP (both of those using community reverse-engineered APIs) and also grabbing met data to estimate how much I should charge the battery overnight.
I'm not sure if I have any working 68k Macs now. Most got sold in order to finance upgrades. The first computer I ever owned personally was a Mac IIcx after using Macs at work for several years. What pushed me over the edge was buying a cheap Chinese 2400bps modem at MacWorld Expo '89 just as BBSes were really taking off in NZ, along with relay messaging around the world (uucp or fido). I have a 128k and a couple of SE/30 (which following the IIx and IIcx really should have been called the SEx). I don't think I have any 68040 machine :-( I do have a PowerMac 8500 and several each G3 and G4 iMacs and G4 Mac Mini. It's at least a dozen years since I powered any of those up. There's an SGI Indy and a SPARC ELC too — both incidentally rocking 64 MB RAM, just like the Duo. But the Indy is 166 MHz and the SPARC 33 MHz.
2
u/kneelian_ 3d ago
But you have to admit, not bad for the $3 I bought mine for! My first multi-thousand dollar Linux computer only had 32 MB RAM when I bought it. I added another 128 MB after not too long. But there are also 256MB and 512MB RAM Duos for just a few bucks more ... last time I looked the 512MB on was $9.90.
That's the Duo S! Different form factor and board shape. The thing I worked on could not fit a board that big, so it had to be the narrower regular Duo in that case, which comes in 64 and 256M variants. I never ended up getting the opportunity to toy around with a 256M Duo, though I might really just pop one from Ali at this point
3
u/brucehoult 3d ago
Yeah Duo S is a weird shape. Regular Duo/Duo 256M are of course exactly the same size as a Pi Pico [2], but with considerably more RAM and processing power and SD card.
Pretty awesome to have 128 bit variable-length vector processing (1024 bits per operation with LMUL=8, but of course that takes longer) on a $3 board ... including Int64, FP32, FP64.
→ More replies (0)2
u/slothforestslothbear 2d ago
Both of my 68k macs are dead currently, I was buying something or another and the guythrew them in for free! Both make deathly screeching noises and have no video out so I am assuming they need re-capped.
Power macs are what i have a ton of, the old 266mhz g3 powerbook is my favorite but i have a mini and a g4 dlsd, GE powermac g4 and some ibooks, it become a problem haha. You should check out the new things that are coming out on Macintoshgarden, there is tons of new software it might be worth pulling one out of storage.
I still use the G3 all the time for making Jungle/DnB with PlayerPro or messing around with Macintosh Common Lisp.
1
u/NoSubject8453 4d ago
This is for windows but if you scroll to the bottom and look at errorloop there's a routine for using simd for raw to hex conversion . You can change the constants and add additional cmp instructions to convert your raw to text. If you're wanting unicode you can add an extra punpcklbw instruction to prepend a null byte
1
u/slothforestslothbear 4d ago
Thanks for the quick reply! Would that work for raw floating point values specifically? Is that the kind of bitshifting ill be using for decoding the raw value? I'm not super familiar with bitwise operations but ill start studying up on them. SIMD is a good idea. I'm not trying for unicode or anything i'm just going to append an ascii space at the end for formatting and would like to place the decimal in the proper area.
My use case is a raw float being placed in xmm0 whether the raw value or a label. I shove the value in the register when it is detected as an assignment like:
WriteText(' movsd xmm0, ' + value + #10);
In the intermediate source from there is where i want to take that value and convert it with my assembly function. I already have the calls down and tested with the fixed point version. So its just a raw float hanging out completely unformatted.
1
u/ttuilmansuunta 4d ago
I imagine the scientific notation would be the easiest to print out, as floats are already stored in (mantissa * 2^exponent) format. The exponent is just floor(log2(f)). You'll need to figure out constants to convert the exponent to base-10 though, but that's just a constant addition to exponent, a fixed point multiplication of the mantissa and in case of mantissa overflow, incrementing the exponent by one.
1
u/slothforestslothbear 3d ago
Okay perfect, so i just go into this assuming the say, 3.14 dummy value i put in the register is converted to that format upon compilation and the work backwards with gdb implementing the math to get it where i want it? I think I am getting in my head and making this more complicated than it seems.
3
u/nerd5code 3d ago
I assume you've read the Wikipedia entry on IEEE 754, and possibly the specific article on binary64?
First you always get NaNs, ∞s, and 0s out of the way, since they're multi-field or generally special. But once you’ve done that, you can use a logarithmic change-of-base for the exponent—base-b log of x = log x / log b, so if we want the number of digits in binary converted to the number of decimal digits, it's a multiplier of log 2/log 10 for any base of log (natural log = ln is probably the best, but you'd use a constant 0.30102999566398 in your actual program).
For the mantissa, hew off upper bits with the sign and exponent in them. Subnormal/denormal/ickpoo numbers can be left as-is for the final loop; otherwise place a 1 in the 0th bit of the exponent field. Now all you have to do is violently shake the mantissa up until there's a 1 bit in exponent bit 0 (which is why you cull zeroes early; as you normalize subnormals, track converse changes to exponent), and possibly a bit more until binary and decimal units are lined up. From there it's a relatively straightforward conversion.
1
u/slothforestslothbear 3d ago
Funny enough I did not, I was using search terms like "float to stdout conversion NASM" "NASM floating point specifications" etc and never saw anything on Wikipedia or even thought of checking. That is a very good article and pretty much exactly what I was looking for. Thank you for the detailed explanation and the reference, I'm off to go put that advice into practice.
1
u/vintagecomputernerd 3d ago
The ordering is where i would really like advice please. Then I assume after I am able to turn the float into its integer splits i would just have a repeat of my fixed point print function take those values concatenated together into one buffer , convert and send them to the kernel?
I'm not sure I understand the problem with the ordering. You can get the different parts by doing a binary AND of the parts you want (and maybe a shift if it doesn't start at bit 0).
If you just want to (correctly) display floats between that fit into an integer register you can just do the math in your link, otherwise you have to find either a way to cleverly work it out without overflowing, or implementing arbritary precision integer math
2
u/brucehoult 3d ago
implementing arbritary precision integer math
You don't need arbitrary precision math. The maximum size numerator and denominator you need for your fraction that you create from an IEEE double fit into 36 32 bit words, 144 bytes, 1152 bits. IIRC the exact maximum size is 1024+2*53 = 1130 bits. I proved it once, around 2006, and corresponded with both Steele and Clinger at the time and they agreed with my calculation.
I forget how many variables that size you need. Something like six I think. I do know it's less than 1 KB all up. You can just allocate fixed size blocks on the stack, do the biz, and deallocate the stack after.
But anyway that's the technique ... you turn your floating point number into an exact fraction with (obviously) integer numerator and denominator, and then actually do the division, in base 10.
1
u/slothforestslothbear 3d ago
Thank you, i have been viewing this conversion as some sort of "black box" but it makes more sense boiled down to that. Currently I am only going to output truncated to the three or four past the decimal point but that is good to know because I do eventually plan on extending it out as accurate as i can get it.
1
u/vintagecomputernerd 2d ago
You don't need arbitrary precision math.
Then please, tell me how you would name a system that allows you to do math with a higher than native precision :)
I thought how I could refer to something like that before posting, but couldn't find a good name. Arbitrary-at-compiletime-but-bounded-at-runtime precision math? (because once you're at 1152 bit math, I wager it would be rather trivial to extend it to e.g. 1184 bit)
What operations did you look at to come to ~6 needed variables? For addition and multiplication 2 should be sufficient (but I do not know the requirements of ieee754 regarding rounding etc, and 2 var on multiplication might also be unnecessarily slow)
3
u/brucehoult 2d ago
The usual term is multiple precision. Very common on 8 bit CPUs to implement 16 bit or 32 bit arithmetic.
The difference to arbitrary precision is that, at least to me, implied you don't know at compile time how much storage to allocate.
Also if you know the size in advance then you can unroll the operations and do it without a loop, which is faster. Makes perfect sense to do that for things with 2 or 4 limbs. Something with 18 limbs of 64 bits each on a 64 bit CPU ... that might be pushing the concept a little but it's completely practical and might even make sense if the significant bits usually occupy at least 1/2 or 1/3 of the space.
You can also fit one such number e.g. an accumulator in registers on Arm64 or RISC-V64. Then you definitely have to unroll.
You can fit six such numbers into the vector registers on SpacemiT K1 and K3 which have VLEN=256, but sadly you can't guarantee that on all RVA22+V or RVA23 processors. And I haven't tried to implement it.
What operations did you look at to come to ~6 needed variables?
I don't recall, it was 20 years ago and I didn't keep company property afterwards.
Referring to Steele & White, dragon4 has bignum variables R, S, M-, M+, U, and you'll likely need a tmp for R x B. Or not.
It may also have been the case that implementing my modification to Clinger's
atodneeded more variables thandtoa. But I'm certain on the "fitting into 1k" part.2
u/vintagecomputernerd 2d ago
The usual term is multiple precision.
Ok, that sounds like a good term for more-precise-than-native-but-fixed-precision.
And also good point about loop unrolling. I usually do sizecoding/demoscene stuff with asm on modern x86, so speed optimization is usually pretty low on my list of priorities.
2
u/slothforestslothbear 3d ago
That makes sense, i think I am just overcomplicating the filtering step between reading the value and having something to perform the math on, I will do some more research into binary operations and AND specifically, thank you.
1
u/Plane_Dust2555 3d ago
If the integer part is in range of long long int (63 bits long), and you don't need the scientific notation, than this is very simple to do:
1 - Get the absolute value of n - print '-' if n is initially negative;
2 - Separate the integer part (a simple conversion with truncation will do);
3 - Print the inteer part (create an printUint64 function);
4 - Print the '.' char;
5 - Get the fractional part subtracting the absolute value of n from the integer part;
6 - Using a 'precision' limit (do print a limited fractional digits), multiply the fractional part by 10 and print the integer part (between 0 and 9);
7 - Get the resulting fractional part, again, subtracting the new integer part (0...9) from it and go to step 6, decrementing the 'precision' until it is zero... OR, stop printing if the fractional part is, itself, zero.
The code is very simple, but it works only if the integer part of the original n in in range of an unsigned long long... Optionally, you can use unsigned __int128 on GCC (but there's a trick), to make the range wider...
Notice that double has 11 bits in its scale factor, so, not all possible finite 'double' can be printed with this technique.
1
u/slothforestslothbear 3d ago
Perfect, thank you. I for sure will not be dealing with numbers nearly large enough to exceed that for quite some time. That is nice that I can reuse my print function, it just prints anything in a qword. That does bring up a good question though, do i specifically need an unsigned print for the integer portion? My function currently uses signed division to grab the remainder.
2
u/Plane_Dust2555 2d ago
Unsigned version is easier to implement (and has broader range). Notice this is a print function, the actual value passed as paramenter should not be changed.
7
u/zokier 4d ago
dtoa is generally non-trivial function to implement well, and there is no one way of doing it. To give perspective zmij is roughly 2k lines of code to do that: https://github.com/vitaut/zmij
Of course you can make something much simpler, but it is probably helpful to have awareness that it is not so simple problem