September 25th, 2020

Swift Calling Conventions on ARM64: Float / Double

Learn what registers Swift uses for floating point numbers

This is the 2nd post in a series on how Swift’s calling conventions work on ARM64. If you have not read my first post on Swift’s calling conventions for Int and Bool types, please read that first.

To try out the examples in this article, please make sure you have done the following set up first:

  1. Verify that you have the right software/hardware

  2. Set up an iOS app project

  3. Learn how to set breakpoints at the exact start of Swift functions with LLDB

Item #3 is very important. If you set a breakpoint in Xcode’s UI the following steps may not work.

The Code We Will Investigate

In this post we will investigate the calling conventions used in the following code. In the iOS app project you created, replace the contents of ViewController.swift with the following:

import UIKit

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        print(testFloat(a: 100.111, b: 100.222))
        print(testDouble(a: 200.111, b: 200.222))

        print(testFloatInt(a: 300.111, b: 300.222, c: 300333, d: 300444))
        print(testIntFloatDoubleInt(a: 400111, b: 400.222, c: 400.333, d: 400444))
    }
}

func testFloat(a: Float, b: Float) -> Float {
    return a + b
}

func testDouble(a: Double, b: Double) -> Double {
    return a + b
}

func testFloatInt(a: Float, b: Float, c: Int, d: Int) -> Float {
    return a + b + Float(c + d)
}

func testIntFloatDoubleInt(a: Int, b: Float, c: Double, d: Int) -> Double {
    return Double(a) + Double(b) + c + Double(d)
}

Before continuing, make sure you can build and run the project.

Background Information

The “Procedure Call Standard for the ARM 64-bit Architecture” states:

The base standard provides for passing arguments in general-purpose registers (r0-r7), SIMD/floating-point registers (v0-v7) and on the stack. For subroutines that take a small number of small parameters, only registers are used.

Technically the document does say that floating-point arguments will be stored in registers v0-v7, but its actually a little more complicated than that. Each v<n> register holds 128bits, and there are different registers which reference different chunks of this memory.

These docs explain how these other registers work:

The mapping between the registers is as follows:
d<n> maps to the least significant half of v<n>
s<n> maps to the least significant half of d<n>
h<n> maps to the least significant half of s<n>
b<n> maps to the least significant half of h<n>

This means that the register d0 maps to the least significant 64 bits of v0. Similarly s0 maps to the least significant 32 bits of d0 (transitively, s0 also maps the least significant 32 bits of v0).

Since Floats only need 32 bits, Swift will store them in s<n> registers. Similarly Swift will use d<n> registers to store Doubles since they need 64 bits.

Armed with this knowledge, we should expect that Float arguments will be stored in registers s0-s7. Let’s try it out.

Float

Argument Values

Use the approach described here to place a breakpoint on testFloat, and run the project until the breakpoint triggers. Then run the following commands in LLDB:

(lldb) register read s0 s1 -f f
      s0 = 100.111
      s1 = 100.222

This confirms our hypothesis! We can find Float arguments in the s0-s7 registers.

We can also confirm the relationship between s0, d0, and v0 below:

(lldb) register read s0 -f h
      s0 = 0x42c838d5
(lldb) register read d0 -f h
      d0 = 0x0000000042c838d5
(lldb) register read v0 -f h
      v0 = 0x00000000000000000000000042c838d5

Notice how the larger registers contain the same data, just padded.

Return Value

Just like we learned in my previous article, Float return values will be stored the same way that the first Float argument is stored. This means that a Float return value should be stored in s0.

Let’s confirm:

(lldb) thread step-out
(lldb) register read s0 -f f
      s0 = 200.333

Great! This is exactly what we expect. Just to remind you, thread step-out will execute the rest of the current function, pop the call stack, and then pause the debugger. At this point, the return value should be correctly stored in the intended register.

Double

Argument Values

Double works exactly the same as Float execpt that values are stored in the 64bit d0-d7 registers. This means that the arguments to testDouble will be stored in d0 and d1.

You can verify this by setting a breakpoint on testDouble and running:

(lldb) register read d0 d1 -f f
      d0 = 200.111
      d1 = 200.222

Return Value

The return value works exactly the same as with Float, execpt it uses the d0 register.

You can use these commands to try it out:

(lldb) thread step-out 
(lldb) register read d0 -f f
      d0 = 400.333

Mixed Argument Types

What happens if a function has mixed argument types?

This works as you would probably expect. Float/Double arguments are stored in the floating point registers, and Int/Bool arguments are stored in the general purpose registers.

Let’s try it out to see what happens:

Float, Int

Set a breakpoint on testFloatInt and run the following commands.

(lldb) register read s0 s1 -f f
      s0 = 300.111
      s1 = 300.222
(lldb) register read x0 x1 -f d
      x0 = 300333
      x1 = 300444

Notice how the Int arguments are stored in registers x0, and x1 even though they are the 3rd, and 4th arguments respectively. This is because they are the 1st and 2nd Int arguments.

Also notice how Swift chose to put the floaing point values in the floating point registers s<n> even though the general purpose registers x<n> would have had enough space to store these values.

Int, Float, Double Int

Let’s try out a more complex example in which integer and floating point values are interleaved.

Set a breakpoint on testIntFloatDoubleInt and run the following commands.

(lldb) register read x0 -f d
      x0 = 400111
(lldb) register read s0 -f f
      s0 = 400.222
(lldb) register read d1 -f f
      d1 = 400.333
(lldb) register read x1 -f d
      x1 = 400444

Again, Swift puts the 1st and 2nd Int values in x0 and x1 respectively.

Also notice how the first floating point value is in s0, but the 2nd one is in d1. This is beacause the s<n>, and d<n> registers actually just represent a smaller section of a v<n> register. Once Swift uses s0 for one argument it means that d0, and v0 are used up too. It must store the next floaing point argument in s1 / d1, etc.

Summary

I hope this article helped explain how Swift stores its floating point arguments on ARM64! If you’ve read my first article too you should now understand how Swift stores Int, Bool, Float, and Double types as arguments and return values.

Int and Bool values are stored in the x<n> registers, and floaing point values are stored in the v<n> registers (or the smaller variants if possible).

In my next article I’m planning to cover how Swift stores Strings on ARM64.


Troubleshooting

  • Make sure you use the approach desribed here to set breakpoints. Setting breakpoints using the UI or using breakpoint set --name <func_name> may not work because LLDB may not pause the function on the first instruction. If other instructions execute, there’s a chance that the register values may get overwritten.

  • Make sure you are running this code on a real iOS device. If you run this code on the iOS simulator, Swift will likely be running on a x86 CPU which has a different calling convention.

  • If something is still unclear or not working, I would be happy to help! Feel free to DM me on twitter or send me an email.