Nat! bio photo


Senior Mull

Twitter Github Twitch

Challenges of shared library environments, Part 2

This is a small article series concerned with static initialization and destruction.

This time we take a look at the atexit call. This is a C standard library function, that registers callbacks with the exit handler:


The atexit() function registers the given function to be
called at program exit, whether via exit(3) or via
return from the program's main().  Functions so registered
are called in reverse order; no arguments are passed.

Unfortunately on the three platforms (MacOS, Linux, FreeBSD) I tested, none of them was conforming. If you run the test program atexit-breakage yourself, you will invariably notice, that bar_exit, which is registered via atexit is called before main returns or exit is called.

-> dlopen
void bar(void)
-- install bar_atexit
<- dlclose
void bar_exit(void)
-- install main_atexit
void main_atexit(void)

Actually it is called during dlclose, which is the command to unmount a shared library.

Linux is even worse. You can get into a scenario, where dlclose isn’t even involved, yet the order of atexit callbacks is not the reverse order. See: ld-so-breakage

Linux, shared libraries, atexit: a little history

I am picking out Linux here, because I invested a non-neglible amount of time with these bugs, and that was mainly on Linux.

ELF is the shared library file format on Linux. The ELF specification prescribes atexit for use as the shared library destructor mechanism:

Similarly, shared objects may have termination functions, which
are executed with the atexit(BA_OS) mechanism after the base
process begins its termination sequence.

Termination functions are a ELF feature, that can be conveniently accessed with __attribute__((destructor)) using gcc and clang.

Since there is no mention of unloading a shared library in the document, I think it’s safe to assume, that unloading was not a concern at the time of writing. Technically then the use of atexit makes sense and is fine.

Shared library unloading

But at some point in time, shared library unloading was added to Linux (dlopen/dlclose), and that were things went wrong. You can not use atexit anymore for shared library destructors, since these do not happen at “base process termination”, but at any time dlclose gets invoked.

What is supposed to happen is written down in the Linux Core Base documentation in __cxa_finalize. Linux actually wants to implement the Itanium C++ ABI, which explains in much more detail how atexit is supposed to be treated so C and C++ stay compatible.

If you follow the algorithm described in the Itanium C++ ABI, you will see that atexit handlers are treated in a special way: they are saved with a NULL shared library handle. On dlclose only termination functions with a handle != NULL should be called. This would be all well and conforming and atexit would only be called at process end.

But Linux calls all atexit functions of a shared library at the time of dlclose… And it seems most other OS as well.

Circumvention in mulle-atexit

Since I need to have a dependable form of post-process destruction for my tests, I am writing mulle_atexit This will do what atexit should be doing in a cross-platform manner.


No tested OS upholds atexit semantics. Linux can neither guarantee the point in time when atexit functions are run, nor can it guarantee the reverse order property.

So atexit is broken everywhere, do not use it. As inertia is the strongest force in the universe, I would expect that atexit will remain broken for a long time.

Itanium C++ ABI Algorithm Explanation:

The runtime library shall maintain a list of termination functions
with the following information about each:

    A function pointer (a pointer to a function descriptor on Itanium).
    A void* operand to be passed to the function.
    A void* handle for the home DSO of the entry (below).

That translates to C as :

struct termination_function
   void  (*function_pointer)( void *);
   void  *operand;
   void  *__dso_handle;

Now when a C or C++ coder writes atexit this will happen:

When the user registers exit functions with atexit, they
should be registered with NULL parameters and DSO handles,
i.e. __cxa_atexit ( f, NULL, NULL );

So in the atexit list we have an entry { f1, 0, 0 }. For a C++ constructor or __attribute__((destructor)) we would have:

After constructing a global (or local static) object,
that will require destruction on exit, a termination
function is registered as follows:
  extern "C" int __cxa_atexit ( void (*f)(void *), void *p, void *d );

Where d is the shared library handle (returned by dlopen for instance) So the entry would be { f2, p, d }, where d is not NULL.

So our table now looks like this:

struct termination_function   table[] =
   { f1, 0, 0 },
   { f2, p, d }

Now comes the interesting part, what happens at ‘dlclose’ time ?

When __cxa_finalize(d) is called, it should walk
the termination function list, calling each in turn
if d matches __dso_handle for the termination function
entry. If d == NULL, it should call all of them.

So dlclose( d) will call __cxa_finalize( d). The handle for a specific shared library is != NULL, so the atexit installed handler will not be called! d with the value NULL is called from the base process at termination time.


A photo of Odey

From: Odey

I am not seeing this behavior on Linux Kernel 5.0.0-16
Ubuntu 19.04

bar_exit is never ran. Attempts at forcing the running after the dlclose(handle) causes an expected segfault.

A photo of Lucas Membrane

From: Lucas Membrane

The atexit function was always scary. When I looked at it (decades back), there was a limit of 32 functions that could be registered to execute, and not much way to tell how many were registered by 3rd party libraries I used or to know when I tried to register too many. Is any of this improved yet?

A photo of Nic K.

From: Nic K.

SPARC Solaris 11.4 SRU 9.5

$ ./FAIL
-> dlopen
-- install bar_atexit
<- dlclose
-- install main_atexit

A photo of LinX

From: LinX

I think executing the termination functions that were registered by a *shared library* at the moment when dlclose() is called on that library is the only thing that makes sense! Once the library has been closed and unloaded, we cannot execute anthying in that library anymore. If execution of the termination functions registered by a *shared library* was delayed until the "main" program terminates, it would almost certainly result in a segmentation fault, because it is pointing to a code (memory region) that was unloaded already... Also, from the perspective of a library author, most often you need to do some clean-up at the time when your library gets unloaded, not when the "main" application terminates.

A photo of Nat!

From: Nat!

I am not super "on top" of the topic anymore, but I think I my opinion is still, if its called "atexit" it should be called at exit (after main) and not sooner. For shared libraries the usual destructors `__attribute__((destructor))` are called at unload and that's fine (if properly implemented). This also works transparently regardless if you compile the library static or shared. Using "atexit" functionality in shared library code and then unloading it is IMO misdesign. Either because you shouldn't unload it or because you shouldn't have used "atexit".

A photo of LinX

From: LinX

I mostly agree, that using `__attribute__((destructor))` is preferable over atexit() when it comes to shared libraries. However, `__attribute__((destructor))` is totally compiler-specific and file-format-specific; it only works with GCC (and Clang) and with the ELF file format; certainly does *not* work with MSVC. At the same time, atexit() is defined by the C standard, so it works on every platform and with every conforming C compiler. If all you care about is Linux, then using `__attribute__((destructor))` is fine. But if you want to write really portable code, there is hardly a way around atexit().

Anyways, if a shared library does have registered functions via atexit(), I still think it makes sense to run them at the time when the shared library is unloaded - which is what Linux (or more precisely: glibc) as well as MSVC does.

Post a comment

All comments are held for moderation; basic HTML formatting accepted.

E-mail: (not published)