#ifndef VERSION
#define VERSION 42
#endif
int base_version()
return VERSION;
int base_global_value = VERSION;
char * str;
int ret = asprintf(&str, "my_glob='%d', my_version=%d, base_glob='%d', base_version=%d",
global_value, version(), base_global_value, base_version()
if (ret == -1)
return NULL;
return str;
The build system (Meson here) defines VERSION at build-time.
project('dlmopen-test-base', 'c', default_options : ['c_std=c11'])
base_shared = shared_library('base', 'base.c',
install: true,
c_args: '-DVERSION=@0@'.format(get_option('version'))
Several instances of base and user are compiled with various VERSION values.
base-0, base-1 and base-2 with VERSION=x for base-x.
user-1 with VERSION=1, that uses base-1.
user-2 with VERSION=2, that uses base-2.
runner-0 with VERSION=0, that uses base-0.
These various ELFs are compiled thanks to the Nix package manager.
Nix makes the definition of these combinations simple and makes sure that
all generated ELFs have fully defined dependencies.
As I write these lines, this is done by setting DT_RUNPATH in compiled ELFs so that
they load the right versions of their dependencies at runtime.
Here is the Nix code that describes these combinations.
{ pkgs ? import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/21.05.tar.gz") {}
gendrv = v: name: dir: inputs: pkgs.stdenv.mkDerivation rec {
pname = name;
version = v;
buildInputs = [pkgs.meson pkgs.ninja pkgs.pkgconfig] ++ inputs;
src = pkgs.lib.sourceByRegex dir [
"^.*\.c"
"^meson\.build"
"^meson_options\.txt"
mesonFlags = ["-Dversion=${v}"];
self = rec {
base-0 = gendrv "0" "base" ./base [];
base-1 = gendrv "1" "base" ./base [];
base-2 = gendrv "2" "base" ./base [];
user-1 = gendrv "1" "user" ./user [base-1];
user-2 = gendrv "2" "user" ./user [base-2];
runner-0 = gendrv "0" "runner" ./runner [base-0];
char * str;
int ret = asprintf(&str, "my_glob='%d', my_version=%d, base_glob='%d', base_version=%d",
global_value, version(), base_global_value, base_version()
if (ret == -1)
return NULL;
return str;
It then defines a struct User that enables the runner to access the variables and functions of a user instance loaded in memory (via pointers and function pointers).
struct User
void * handle;
int (*version)(void);
int* global_value;
char* (
*fullname)(void);
int (*base_version)(void);
int* base_global_value;
void (*free)(void*);
The code to load a user into a struct User uses dlmopen and dlsym.
void * load_symbol(void * handle, const char * symbol)
void * address = dlsym(handle, symbol);
if (address == NULL)
printf("dlsym failed: %s'\n", dlerror());
return address;
int populate_user(const char * lib_path, struct User * user)
user->handle = dlmopen(LM_ID_NEWLM, lib_path, RTLD_NOW | RTLD_LOCAL | RTLD_DEEPBIND);
//user->handle = dlopen(lib_path, RTLD_NOW | RTLD_LOCAL | RTLD_DEEPBIND);
if (user->handle == NULL)
printf("dlmopen error: %s'\n", dlerror());
return 0;
user->version = load_symbol(user->handle, "version");
user->global_value = load_symbol(user->handle, "global_value");
user->fullname = load_symbol(user->handle, "fullname");
user->base_version = load_symbol(user->handle, "base_version");
user->base_global_value = load_symbol(user->handle, "base_global_value");
user->free = load_symbol(user->handle, "free");
return !(user->version == NULL ||
user->global_value == NULL ||
user->fullname == NULL ||
user->base_version == NULL ||
user->base_global_value == NULL ||
user->free == NULL);
The rest of runner.c defines a quick experiment to check whether dlmopen fits my need. First, the main function reads its command-line arguments (that are paths to user ELF files) and load all of them in memory.
int main(int argc, char ** argv)
char * str = fullname();
printf("runner fullname: %s\n", str);
free(str);
// Load all user libs
const int nb_users = argc-1;
struct User users[nb_users];
for (int i = 1; i < argc; ++i)
if (!populate_user(argv[i], &users[i-1]))
printf("could not populate user %d, aborting.\n", i);
abort();
Then it prints the various values (by calling the fullname function from the runner’s ELF itself or from user ELFs).
printf("All users have been loaded.\n");
str = fullname();
printf("runner fullname: %s\n", str);
free(str);
for (int i = 0; i < nb_users; ++i)
char * value = users[i].fullname();
printf("user %d fullname: %s\n", i, value);
users[i].free(value);
During its execution, runner changes the values of all global variables to make sure the desired ones get updated (and them only).
printf("Changing global values.\n");
global_value = 42;
base_global_value = 420;
for (int i = 0; i < nb_users; ++i)
*(users[i].global_value) = (i+1)*10;
*(users[i].base_global_value) = (i+1)*100;
Printings are done at the following steps.
At the main function’s beginning (only for runner).
After all user ELFs have been loaded.
After all global variables have been modified.
At the main function’s ending (only for runner).
Does it work?
First, user ELFs can be compiled via nix-build commands.
#!/usr/bin/env bash
nix-build . -A user-1 -o result-user1
nix-build . -A user-2 -o result-user2
nix-build . -A runner-0 -o result
The following code loads user-1 and user-2.
#!/usr/bin/env bash
./result/bin/runner $(realpath ./result-user1/lib/libuser.so) $(realpath ./result-user2/lib/libuser.so)
Everything looks great in the output log :).
All values are the expected one when the user ELFs are loaded,
and changing global variables had the expected outcome.
runner fullname: my_glob='0', my_version=0, base_glob='0', base_version=0
All users have been loaded.
runner fullname: my_glob='0', my_version=0, base_glob='0', base_version=0
user 0 fullname: my_glob='1', my_version=1, base_glob='1', base_version=1
user 1 fullname: my_glob='2', my_version=2, base_glob='2', base_version=2
Changing global values.
Printing fullnames again.
runner fullname: my_glob='42', my_version=0, base_glob='420', base_version=0
user 0 fullname: my_glob='10', my_version=1, base_glob='100', base_version=1
user 1 fullname: my_glob='20', my_version=2, base_glob='200', base_version=2
Removing user libs from memory.
runner fullname: my_glob='42', my_version=0, base_glob='420', base_version=0
And everything also looks great when the exact same library is loaded twice :).
user ELFs have independent global variable, and their base dependency too.
#!/usr/bin/env bash
./result/bin/runner $(realpath ./result-user1/lib/libuser.so) $(realpath ./result-user1/lib/libuser.so)
runner fullname: my_glob='0', my_version=0, base_glob='0', base_version=0
All users have been loaded.
runner fullname: my_glob='0', my_version=0, base_glob='0', base_version=0
user 0 fullname: my_glob='1', my_version=1, base_glob='1', base_version=1
user 1 fullname: my_glob='1', my_version=1, base_glob='1', base_version=1
Changing global values.
Printing fullnames again.
runner fullname: my_glob='42', my_version=0, base_glob='420', base_version=0
user 0 fullname: my_glob='10', my_version=1, base_glob='100', base_version=1
user 1 fullname: my_glob='20', my_version=1, base_glob='200', base_version=1
Removing user libs from memory.
runner fullname: my_glob='42', my_version=0, base_glob='420', base_version=0