Anatomy of Local Register Asm in GCC

Sometimes there is desire to embed assembler instructions within C code. This is where asm statements come into play. In order to interact between embedded assembler instructions and C code, operands of Extended Asm are utilized. If there is even the need to place an operand in a particular register, this is where register asm comes into play. However, using them correctly is somewhat difficult and can lead to subtle bugs not necessarily because of how they are designed but rather because of how they are implemented. This post tries to shed some light on the implementation in GCC.

Anamnesis

Before we dive into the details let us have a look at an example where we choose for no particular reason AArch64:

extern void foo(long);

register long x0 asm ("x0") = 0x123;
long res;
foo(0x321);
asm ("add %0,%1,%1" : "=r" (res) : "r" (x0));

Naively we would expect for the Extended Asm statement that register x0 holds value 0x123 such that after the addition the result 0x246 is stored in variable res. However, this is not guaranteed since x0 is clobbered by the function call to foo which is also documented (see godbolt for a full example):

Warning: In the above example, be aware that a register (for example r0) can be call-clobbered by subsequent code, including function calls and library calls for arithmetic operators on other variables […]

The proposed solution for this is to initialize register asm variables right before an Extended Asm statement and having nothing else in between. For the initial example this means to push the register asm over the call (see godbolt for a full example):

extern void foo(long);

long res;
foo(0x321);
register long x0 asm ("x0") = 0x123;
asm ("add %0,%1,%1" : "=r" (res) : "r" (x0));

This is quite unsatisfactory since this puts the burden onto the developer to place register asm variables correctly which makes them special in contrast to ordinary automatic variables. Sometimes this is even for experienced developers difficult, or in other words, do you spot the potential problem in the following example?

register int x asm ("x0") = 0x123;
register int y asm ("x1") = *ptr;

The problem here are implicit function calls. If compiled with sanitizers (PR100908), the pointer dereference may lead to an implicit function call which clobbers x0 which is not only used for return values but also for function arguments. Likewise, implicit function calls may be added in case certain arithmetic operations are not natively supported for some target which leads to implicit library calls, or implicit calls to memset (PR101422), or even accesses to thread local variables may lead to implicit function calls (PR126290), and so on.

Therefore, over the decades, it has become clear that register asm assignments should be placed just before Extended Asm statements and that right-hand sides should only consist of lvalues referring to automatic variables, but even that is no guarantee for correctness.

Certainly those restrictions were not intended: so where do they stem from and what is the actual problem?

GCC operates on two main intermediate representations: GIMPLE and RTL. Although, local register asm also affects GIMPLE, the problem discussed so far stems due to the implementation in RTL, which is why we will examine that first.

Implementation in RTL

The translation from GIMPLE to RTL is done during expand where a register asm assignment

register long x asm ("x0") = 0x123;

materializes as a hard register assignment

x0:DI=0x123

The culprit here is that individual objects residing in hard registers are not tracked, or in other words an assignment to the same hard register simply overrides the former. There is no form of spilling or tracking which object resides currently in a hard register. If objects must remain separate from one another, which is usually the case, then a pseudo must be allocated for each object.

Let us consider the previous example again:

register long x asm ("x0") = 0x123;
register long y asm ("x1") = *ptr;

for which we have after expand with sanitizers enabled:

x0:DI=0x123
x0:DI=r104:DI
call [`__asan_load8'] argc:0
x1:DI=[r104:DI]

The first instruction where x0 is set to 0x123 corresponds to the assignment of register asm variable x. The following two instructions are due to the sanitizer where the pointer, which we are about to dereference in the last instruction, is passed to function __asan_load8. According to the AArch64 ABI conventions the argument is passed in register x0 which results in clobbering the previous assignment. Of course, this behaviour is not intended; it rather exposes GCC internals to the developer.

Implementation in GIMPLE

For RTL the problem boiled down to the fact that register asm assignments materialize during expand as hard register assignments which may be clobbered. The solution was to put those assignments as close as possible to the actual Extended Asm statement such that hopefully no clobber “gets” in between. This is of course problematic for optimizations which then have to be tweaked especially for register asm variables such that this property is preserved (PR87984).

When investigating a few further bugs (PR29877, PR61572) a pattern emerges which GIMPLE as well as RTL passes have in common:

Extending the lifetime of a hard register may lead to an ICE

This is different compared to the previous class of problems in the sense that it does not lead to wrong code but compile time errors. Since register asm assignments materialize during expand into hard register assignments an optimization like copy propagation, inlining and what not may indirectly extend the lifetime of those hard registers. The problem with this is that during register allocation, if a pseudo must be assigned a register from a class which is running out of allocatable registers, then this may lead to an ICE in certain cases. Or in other words there is no comprehensive mechanism in order to spill a hard register in each and every scenario which may lead to unsatisfiable allocations. This is especially problematic for single register constraints as e.g. a to d and S, D for x86. If a hard register r42 is live due to a hard register assignment and at the same time a pseudo must be allocated a register from a single register constraint whose register class only entails r42, then this leads to an unsatisfiable allocation which results in an ICE (PR125780, PR121426). For optimization passes this means that they need special treatment in order to not change the lifetime of hard registers which itself may lead to missed optimizations (PR61572, PR87600, r9-3594, r14-4363, r16-7378, r17-438).

Let’s Fix It

Before fixing this let us have a look at the documentation where we have:

The only supported use for this feature [local register asm] is to specify registers for input and output operands when calling Extended asm […]

That means, aside from Extended Asm statements, local register asm does not imply any special characteristics. In particular there is no guarantee where local register asm variables reside.

In GIMPLE, a register asm variable is implemented as a VAR_DECL where flag DECL_HARD_REGISTER is set. This in turn means that optimization passes must treat those special. It would be desirable to use ordinary SSA_NAME instead such that no extra treatment is required and consequently not prohibiting optimizations.

In RTL, prior register allocation, local register asm variables should not materialize as hard registers but rather reside in pseudos. Since first of all, pseudos cannot be accidentally clobbered by e.g. function calls, and second of all, register allocation operates way better without prior live hard registers. Only during register allocation, local register asm operands of an Extended Asm should be copied into their corresponding hard registers.

With the advent of hard register constraints (docs) this is possible, now. The basic idea is to demote local register asm variables to ordinary automatic variables and constraint those for Extended Asm to their respective hard register. For example, the following translation unit

register int global asm ("r3");

int foo (int x0)
{
  register int x asm ("r4") = x0;
  register int y asm ("r5");

  asm ("..." : "+r" (x) : "r" (global));
  x += 42;
  asm ("..." : "=r" (y) : "r" (x));

  return y;
}

could be rewritten during gimplification into

register int global asm ("r3");

int foo (int x0)
{
  int x = x0;
  int y;

  asm ("..." : "+{r4}" (x) "r" (global));
  x += 42;
  asm ("..." : "={r5}" (y) : "{r4}" (x));

  return y;
}

Note, after demotion there is no local register asm variable left. Thus, in GIMPLE we finally resort to SSA_NAME and in RTL to pseudos. For Extended Asm statements, demoted local register asm variables are bound to their respective hard registers via hard register constraints.

A first version of a patch accomplishing this was published. In the meantime the patch and wording has changed a bit. Hopefully the patch will land for GCC 17 where the new feature would be disabled by default and could be enabled via flag -fstrict-extended-asm. Eventually the flag should be enabled by default in a future release.

Wrap-up

Register asm variables and Extended Asm are GNU extensions (with C++26 the latter became implementation-defined). Those constructs were already available in EGCS 1.0 which was released in 1997. Therefore, I am looking forward to this problem finally (and hopefully definitively) being resolved after three decades.

However, before we all get our hopes up too soon, I need to lower those expectations a little. In the past, programs with ambiguous and questionable register asm usages have been silently accepted by GCC which should have been rejected. Of course, this was not intentional, but rather an oversight or even undecidable. Since -fstrict-extended-asm relies upon hard register constraints which are more strict than register asm, certain programs are not accepted anymore (see addendum for some examples). This, in turn, will cause some friction.

Note, for Clang/LLVM local register asm was not that troublesome since from the very beginning the register allocator had support for a similar feature like hard register constraints which GCC became just recently.

 

Addendum
Strict Extended Asm

The new feature -fstrict-extended-asm does not only fix local register asm, it also helps prevent subtle bugs. Although, strictly speaking this makes it incompatible. For example, multiple input operands referring to the same hard register are diagnosed:

register int x asm ("r5") = 42;
register int y asm ("r5") = 24;
asm ("..." : "=r" (x) : "r" (x), "r" (y));

This also includes more subtle cases as e.g.

register int x asm ("r5") = 42;
asm ("..." : "+r" (x) : "r" (x));

which becomes clear after multiplying out the in-out operand. One time register r5 is referred to via the implicit input operand "0" (x) and the other time via the explicit input operand "r" (x). Since those cases may lead to subtle bugs, an error is diagnosed. A real world example is in the Linux kernel.

Another class of potential problems is when a register asm operand does not coincide with its constraint. Assume in the following that f5 is a floating-point register which is not entailed in the register class associated with constraint r.

register float x asm ("f5");
asm ("..." : "=r" (x));

This indicates a subtle bug which is diagnosed since it is ambiguous whether the operand should be allocated register f5 or a general-purpose register.

For strict Extended Asm, it is undefined if an input operand refers to an uninitialized local register asm variable. Previously, this was accepted and now, for strict Extended Asm, this may be diagnosed in case of -Wuninitialized or -Wmaybe-uninitialized. A real world example is in glibc.

Last but not least, it is diagnosed if an lvalue is used by multiple output operands:

int x;
asm ("..." : "=r" (x), "=r" (x));

In this case, both output operands are assigned different registers for the Extended Asm statement. However, outside of the statement only one of the two registers is bound to variable x resulting effectively in “loosing” one of the two outputs. In case of register asm outputs, this was also previously diagnosed.