[C++] Dynamic Loading of Shared Objects at Runtime

Dynamic Loading of Shared Objects at Runtime

I’ve been doing a lot of tutorials about dynamic stuff. It’s because we trees rustle whenever the wind blows.

Anyway, this post comes as an addition to @_py’s Linux Internals - The Art Of Symbol Resolution.

This whole thing started when I decided that the bot in our channel (#0x00sec at irc.0x00sec.org), pinetree, needed to be able to dynamically load and unload event-driven modules at runtime.

Building a complex piece of software is very difficult. The typical, rudimentary way to do it is that every time you add a feature (or module) you recompile the entire program. This is just a waste of time. Instead, you can support dynamic modules that get loaded at runtime. This way, if I need to make a new command for pinetree or if I need to revise an existing one, I can simply unload the command from the running instance of pinetree, edit the code for the command, recompile just that code, and then reload it into the bot.

Like most things in C++, you need a few C functions to implement this behaviour.

The functions we’ll be using today are:

  • void *dlopen(const char *filename, int flags);
  • int dlclose(void *handle);
  • and, void *dlsym(void *handle, const char *symbol);

All of these are defined in dlfcn.h, which comes standard. These functions are only available on Linux. If the viewers want to see this done on Windoze, then pestering @dtm might be a good idea!


Details on the dlfcn API.

The dlopen function takes two parameters:

const char *filename is the path to the shared object file to load.

Note: A shared object file (usually denoted with the extension *.so) is a compiled binary with no main function. Instead, it is to be loaded by one or more other programs. The most common shared object file is… libc. That’s right! Your C program (unless static) calls upon a shared object file to get all of its standard library functionality. In C++, libc++ is the corresponding standard library.

int flags is not too important for now because most of the defaults are fine for us. I will explicitly use only one flag: RTLD_NOW which performs symbol resolution for all symbols within the shared object file right when it is first loaded. You may recall @_py’s article on lazy symbol resolution, Linux Internals - Dynamic Linking Wizardry. The flag that resolves symbols lazily, or as-needed, is RTLD_LAZY which is a default flag that would be used if RTLD_NOW were not specified. I use RTLD_NOW because I want the program to crash immediately if a symbol is unresolvable (it helps for debugging).

The return value of dlopen is handle (a void*) to the newly-loaded shared library. If the file couldn’t be loaded or couldn’t be found, dlopen returns NULL (nullptr in C++).


dlclose takes one parameter, a void *handle to the shared library. This is the same void* that was returned from dlopen.

Any non-zero value indicates that dlclose failed; a return of 0 means that dlclose executed successfully. Note: It’s very important to be aware of return values since many functions return the same values (e.g., 0, 1, -1) yet the values carry very difference meanings.


dlsym is the program that accesses symbols from the shared library. It returns any/all requested symbols simply as void*'s. Some casting must be done, then, to get your desired results.

The first parameter void *handle is just the handle you get from dlopen.

const char *symbol is the string representation of the symbol you want. Note: A symbol can be a function, struct, variable, or anything, really.

If the symbol is found, you get a pointer to it (that, again, you may have to cast); otherwise, the function returns NULL or nullptr.


Symbols in C++

Symbols in C++ can only be described as a giant pain. Name mangling makes it difficult to resolve symbols at runtime because there is no standard convention for how symbols are mangled (i.e., it is compiler-specific).

For example, a typical C function like do_something has the symbol do_something when compiled with a C compiler. With a C++ compiler, that function could look like AFDa_do_somethingFAK_x.

I think it’s important to briefly mention the purpose of name mangling, however: name mangling allows for function overriding (did someone say “class inheritance?”) and other really convenient things about C++ that wouldn’t work in C.

Of course, if you want your C++ compiler to not name-mangle a certain function or variable, you can use extern "C" to direct the compiler to compile the code as C. Here is an example:

// do_something.cpp
extern C int do_something(void) {
	return 0;
}

int main() {
	return do_something();
}

If we compile the program with g++ and then run nm (a program that shows us the symbols within the binary), we can see that do_something() has this symbol:

00000000004004d6 T do_something

Let’s use g++ again but we will remove the extern "C" part:

00000000004004d6 T _Z12do_somethingv

See the difference?


The Problem with extern "C"

extern "C" will not work with things that are specific to C++ and have no corresponding C behaviour. For example, you can’t extern "C" a class because there are no classes in C!

The solution to this is to use helper functions to instantiate classes like so:

extern "C" SomeObject* factory(void) {
	return new SomeObject;
}

A helper function that instantiates a class this way is often called a factory.


The Second Problem

So you have some class declared in some file and a factory to instantiate it. You want to compile that code as a shared library so that some main program can utilize the functionality.

The solution is Polymorphism, a programming concept that allows to to define some base functionality that all subclasses can implement differently. Because this topic is so large, I won’t go into it here.

We will define a base class with some basic functionality. In this tutorial, we will have a base class Widget that is purely abstract, meaning that descending classes must implement all of Widget's virtual functions to even compile.

// widget.hpp
#ifndef __WIDGET_H
#define __WIDGET_H

// ignore this line for now...
extern int unique_signal; // for use in proving symbol stuff.

#include <string>

// pure virtual class!
class Widget {

public:
	virtual std::string message(void) = 0; // pure virtual func

};

#endif

All descending classes will inherit from Widget and implement std::string message(void). When we load the dynamic libraries, we will treat all of the instances as Widget*'s (this is the polymorphism at work).


The Main Program

// main.cpp
#include <dlfcn.h>

#include <iostream>
#include <fstream>
#include <stdexcept>
#include <string>
#include <vector>

#include "./widget.hpp"

typedef void* dynamic_lib_handle;

static dynamic_lib_handle load_lib(const std::string& path);
static Widget* instantiate(const dynamic_lib_handle handle);
static void close_lib(dynamic_lib_handle handle);

struct dynamic_lib {
	dynamic_lib_handle  handle;
	std::string			path;

	dynamic_lib(std::string p) : path(p), handle(nullptr) {}

	~dynamic_lib() {
		if (handle != nullptr)
			close_lib(handle);
	}
};

int unique_signal = 42;

int main(int argc, char **argv) {
	if (argc < 2)
		return 1;

	std::vector<dynamic_lib> libs;

	try {
		std::cout << "Opening: " << argv[1] << std::endl;
		std::ifstream fs(argv[1]);
		std::string tmp;

		// read from the file.
		while(std::getline(fs, tmp))
			libs.push_back(dynamic_lib(tmp));
	} catch (std::exception& e) {
		std::cerr << e.what() << std::endl;
		return 2;
	}

	// load up all the libs
	for (auto& l : libs) {
		l.handle = load_lib(l.path);
	}

	std::vector<Widget*> widgets;
	// instantiate!
	for (auto& l : libs)
		widgets.push_back( instantiate(l.handle) );

	// call each widget's message() func.
	for (Widget* w : widgets) {
		if (w == nullptr) continue;

		std::cout << w->message() << std::endl;
		delete w;
	}
}

static dynamic_lib_handle load_lib(const std::string& path) {
	std::cout << "Trying to open: " << path << std::endl;
	return dlopen(path.data() , RTLD_NOW); // get a handle to the lib, may be nullptr.
			// RTLD_NOW ensures that all the symbols are resolved immediately. This means that
			// if a symbol cannot be found, the program will crash now instead of later.
}

// ...

static void close_lib(dynamic_lib_handle handle) {
	dlclose(handle);
}

I hope the above code is fairly straight forward to you guys. Feel free to comment questions about it, though. Study it carefully first.

The really important part of code is the static Widget* instantiate(const dynamic_lib_handle handle) function. This function does the symbol resolution of the factory function, does the appropriate casting, and then returns an instance of the widget subclass defined in the shared library pointed to by the handle:

static Widget* instantiate(const dynamic_lib_handle handle) {

	if (handle == nullptr) return nullptr;

	void *maker = dlsym(handle , "factory");

	if (maker == nullptr) return nullptr;

	typedef Widget* (*fptr)();
	fptr func = reinterpret_cast<fptr>(reinterpret_cast<void*>(maker));

	return func();
}

The casting part was a pain to do because C++ is very picking about casting a void* to a function pointer. I will post the StackOverflow link that helped me if I find it again.


Adding Some Widgets

Now, let’s add some widgets:

// test-widget1.cpp
#include <string>
#include "./widget.hpp"

class TestWidget1 : public Widget {
public:
	std::string message(void) {
		return "Hello. I'm Test Widget1\nOh and the unique_signal is: " + std::to_string(unique_signal);
	}
};

extern "C" Widget* factory(void) {
	return static_cast<Widget*>(new TestWidget1);
}
// test-widget2.cpp
#include <string>
#include "./widget.hpp"

class TestWidget2 : public Widget  {
public:
	std::string message(void) {
		return "Hello. I'm Test Widget2";
	}
};

extern "C" Widget* factory(void) {
	return static_cast<Widget*>(new TestWidget2);
}

The main difference between the two widgets above is that TestWidget1 uses unique_signal in its message function. This symbol is declared as extern in widget.hpp and is defined in main.cpp. TestWidget1 will crash the program if it cannot access the value of unique_signal. This means that the widgets need access to symbols in the main program. By tinkering with soem compiler flags (specifically -rdynamic) we can make this happen.


Compiling

Let’s compile the widgets first (after all, order doesn’t matter and that’s sort of the point of this whole thing…).

$ g++ --std=c++17 -fPIC -rdynamic -shared -o ./test-widget1.so ./test-widget1.cpp
$ g++ --std=c++17 -fPIC -rdynamic -shared -o ./test-widget2.so ./test-widget2.cpp

-shared tells the compiler that we intentionally didn’t put a main() in the source for these binaries. -rdynamic and -fPIC have to do with symbol access.

Compiling the main program isn’t too different:

$ g++ --std=c++17 -rdynamic -o main.out main.cpp -ldl

-rdynamic is what lets the shared libraries access the symbols in our main program. -ldl links our main program with the dlfcn code. Experienced C/C++ people know that libc and/or libc++ are automatically linked by the compiler as if llibc(++) was passed to gcc or g++. However, -ldl must be specified.


Running

The main program expects one argument, a path to a file listing out which libraries to load. I have called this file libs.txt and it looks like this:

./test-widget1.so
./test-widget2.so

$ ./main.out ./libs.txt
Opening: ./libs.txt
Trying to open: ./test-widget1.so
Trying to open: ./test-widget2.so
Hello. I'm Test Widget1
Oh and the unique_signal is: 42
Hello. I'm Test Widget2

Looks like it all worked!


Conclusion

All of the source code used here is up on the 0x00sec GitLab here.

I hope that this was straightforward and informative.

15 Likes

This topic was automatically closed after 30 days. New replies are no longer allowed.