This content originally appeared on Level Up Coding - Medium and was authored by Aviv Avitan
How can your C++ application enjoy the benefits of static linking but maintain the development flexibility of dynamic linking at runtime?
Motivation
Mature real-time applications are cumbersome without proper refactoring once in a while. With the increase of scope, the addition of features, and the gradual coupling of dependencies — development cycles of applications are becoming slower. In this article, I will try to provide a very basic concept, “rapidcpp”, for decreasing the duration of those development cycles.
Imagine an indicator that counts how much of your time on setting up your environment, configuring, compiling, linking, and testing the application is actually relevant directly to your market-revolutionizing feature.
Any make clean, another reset, another cold start, manually moving installation files, etc. This indicator increases as the application matures with time — an increasing invisible tax whose interest is paid by the organization each day.
This tax is can be avoided.
In my experience, the most excruciating pain is a reset of setup to test a minor change. Sometimes the pain is in recompiling for hours of source code I didn’t edit. Sometimes even never seen.
I want to focus on real-time applications. Usually, performance-oriented applications start with static linking. It makes sense. Dynamic linking is much more complicated, requires runtime overhead, and may be prone to very subtle issues (e.g., incorrect symbol resolution, different versioning, and dependency on the execution environment). This decision sticks over time and the application blob is just getting bigger and bigger.
But I am not here to promote the use of dynamic linking in real-time production environments. There are other materials justifying doing it.
I am here to promote the use of dynamic linking in development environments.
Rapidcpp
The idea that motivated me is the concept of interchangeability between static and dynamic libs of some debugged components.
Some dependencies are linked but the method of the linking, static or dynamic, is of little significance for most. However, it does play a role in a practical sense.
Since most linking in real-time applications is static, modifying the functions is not possible. Dynamic linking allows us symbol resolution in real-time and overriding current function pointers with modified implementation.
It seems like a start.
To achieve this interchangeability, rapidcpp’s main requisites are:
- Support debugging infrastructure that should be transparent as possible. (Here the advantages of dynamic linking are incorporated)
- The original code should remain intact.
- Debug mode should be triggered from the build system, not from code.
- Enable override of existing function symbols.
- Minimal impact in performance as possible
The Two Kinds of Dynamic Linking
ldd is a common tool in Linux to list binary dynamic dependencies. Symbols can not remain undefined at the end of the linking. Each undefined symbol must be either statically linked or marked as a dynamic symbol. When a symbol is detected to belong to a dynamic link, the name of the dynamic library will be listed in ELF file, and listed as a dependency in ldd. This is Dynamic linking.
This kind is problematic for our case: symbol resolution can not be undone and overwriting the function symbol is not possible.
The second type of dynamic linking, Dynamic loading, is less constrained and happens solely on runtime. dlopenand dlclose are functions that allow runtime search of library names and extract symbol information from them.
Dynamic loading is going to be the main tool for rapidcpp and for the feature: dynamic debug.
Implementation of Dynamic Debugging
Now watch as I make this symbol disappear
Original call sites must remain intact.
According to dynamic linking, this may seem contradictory: The original code is calling functions, i.e., symbols, which will force dynamic libraries’ dependencies and prevent their eviction from the process memory. For example, the following depicts the use of a symbol found in a dynamic library liblang1_shared.so in our rapidcpp_shared binary:
This dependency on libliblang1_shared.so should be deleted.
This is where some tricks are used to keep code intact but actually avoid the explicit use of a symbol.
Each function must not be treated as a symbol by the compiler. You are probably guessing right now that we need to use some preprocessing magic.
For example, libliblang1_shared has the function definition of:
void echoHelloWorldEnglish(int x);
The first step is to obfuscate the symbol itself. This can be accomplished by:
using func_int_t = void (*)(int);
#define echoHelloWorldEnglish(…) CALL_DEBUG_FUNC(func_int_t, echoHelloWorldEnglish, (int), __VA_ARGS__)
Once the symbol is “gone”, we need to evoke to function, without compiling a symbol. For that we need to map function names to function pointers:
map<string_view, void *> gDynamicSymbols = {
{“echoHelloWorldEnglish(int)”, nullptr}};
#define CALL_DEBUG_FUNC(TYPE, FUNC, PARAMS_TYPE, …)
((TYPE)(gDynamicSymbols[#FUNC #PARAMS_TYPE]))(__VA_ARGS__)
The result:
This solution solutions keep original intact by defining CALL_DEBUG_FUNC in a deactivated dynamic debug environment:
#define CALL_DEBUG_FUNC(TYPE, FUNC, PARAMS_TYPE, …)
(FUNC)(__VA_ARGS__)
which uses the symbol directly, just as before.
To illustrate the process described, use the function “foo” as an example:
What had just happened?! where did it disappear!?
The symbol no longer exists and has been replaced by a function pointer stored in gDynamicSymbols, whose values need to be stored when the program first executes or in the course of the debug session.
At first, the program will set up those dynamic symbols by opening each dynamic library. For each library, querying for the function symbol name using dlsym. (Later this mechanism will be covered).
After the initial setup, when all dependency libraries and their symbols are loaded, and the program can execute normally.
You are not in the list, Dear
So far there is an assumption standing that symbol function name is equivalent to the actual function name. But the symbol name of the functionechoHelloWorldEnglish is different.
The reason for that is Name mangling.
An example that shows the full extent of the name mangling is std::thread::detach function.
The function name that used in code is detach .
_ZNSt6thread6detachEv@@GLIBCXX_3.4.11 is the mangled name.
std::thread::detach()@@GLIBCXX_3.4.11 is the demangled name.
When the function pointers are searched in a dynamic library using dlsym, the symbol is looked at by the mangled name, and not the function name.
The name mangling is relevant only to C++ compiled code. It is taking any function’s context and signature, and translating it into a unique C symbol. This takes into account the function return type, types of parameters, and namespace hierarchy and is needed to allow the same function name to be reused with different signatures.
dlsym only uses a string to search for function symbol but ignores completely the required context and signature.
This is the reason it is has to be handled manually.
echoHelloWorldEnglish is a C++ function, compiled without any extern “C”. searching it as it is with dlsym is futile as explained before. As this function’s name is mangled (this may change in different compilers) into _Z21echoHelloWorldEnglishi.
Once the mangled name is searched and found in dlsym, it is stored it in gDynamicSymbols which maps each function’s demangled name to its function name. The calls can be made only by converting the function names to demangled function names.
To end this process, any time a function is called, the function name needs to be converted to demangled name. Only with it, its function pointer can be called.
To initialize/reload the function in the first place — The mangled name is expected.
To sum it up, name-mangling is the reason for the difference between the “function name” and “symbol name”. When there is no name-mangling involved (in C code or extern “C” modifier specified) the function name matches the demangled name (and of course the mangled name).
Function Overriding? Function Reloading!
With this simple mechanism, the process now supports runtime “function reloading”. Once a function implementation is modified by a developer, it can be reloaded into the process in runtime and be tested.
The reloading can be demonstrated by rapidcpp in this output.
Exporting Global Variables
When linking all libraries and main module in static linking, the global variables are defined and linked into one module. After linking, the variables can be used anywhere in the process so long a declaration of the global is found.
In case of dynamic linking, the only symbols known to the different modules are the symbols listed in .dynsym ELF section. While compiling dynamic libraries, exporting global symbols in dynsym is trivial, this also needs to be done explicitly in the main module.
The main module can be compiled with --export-dynamic or just use CMake to set the main executable property to ENABLE_EXPORTS 1 in order to export global symbols.
Transparent Infrastructure
The previous function described how dynamic debug is possible while retaining the original semantics of our process. In the following, the role of build system and architecture will demonstrate the setup and usage of rapidcpp in any project.
CMake
So far the article describes how to function reloading is performed, and how the implementation still supports original deployment by static linking. But the knowledge of how to do so using the build system is still hidden.
The CMake recipe defines two variables that can be used to compile code to include dynamic debug support (either as an activated mode or not).
PROJECT_DEBUG=”SHARED” or “STATIC”.
PROJECT_DEUBG_SHARED (only if PROJECT_DEBUG=”SHARED”)
The reason to use two is just for code readability. with C++17 if constexpr syntax, it is possible to compile code and perform build time string comparison on preprocessor symbols with strcmp. (Unfortunately, supported in GCC 9.4 but not in Clang 10). But if constexpr can be used only like if statement. Sometimes the code for dynamic debug is not nested in a function or places that if is allowed. That’s the reason to use PROJECT_DEBUG_SHARED.
PROJECT_DEBUG will be used in each library:
add_library(liblang1 ${PROJECT_DEBUG} ${SOURCE_FILES})
which will generate the required library type for the setup.
Note that in case your project is relatively small and compilation times are a non-issue, you can generate both binaries with and without dynamic debug simultaneously (in rapidcpp POC, also a third binary was created — without rapidcpp at all)
Header Only
The described mechanism can be used just by including a header file with the needed components. The header content is protected by both PROJECT_DEBUG_SHARED and an include guard. In case the dynamic debug is not used, this include is virtually a no-op.
This way rapidcpp abstracts the additional overhead from the original program. There is a strong decoupling here, only to be noticed by the included headers explicitly.
This code decoupling is not an issue, but the library management is more considerate concern.
Applying rapidcpp in your C++ programs
- list of all functions that should support dynamic debug.
- list of all libraries and a mapping of library to its function symbols its exporting.
- Each function symbol signature and context: return type, parameter list, nesting namespaces, is extern “C”.
Those can be generated automatically, but for now, rapidcpp updates them manually.
Cost Analysis
rapidcpp compiles the same code into three processes:
- rapidcpp_shared — process is compiled with activated dynamic debug support
- rapidcpp_static — process is compiled with dynamic debug support, but dynamic debug is deactivated
- rapidcpp — process is compiled and linked without dynamic debug support (i.e., original code)
In terms of time and space, it is easy to draw very quick highlights:
Time complexity
It is easy to draw some local tests to compare the execution of function calls on those libraries.
for the rapidcpp_shared it seems that each call takes around from 6000–16000 nanoseconds (C function calls takes slightly less). On average around 10 Microseconds (10K nanoseconds)
rapidcpp_static calls time are somewhere between 2000 to 13500 where the average is very low: 4K nanoseconds.
rapidcpp shows identical runtime results to rapidcpp_static.
The increase in call time can be explained by the following assembly diff:
Space complexity
rapidcpp, without dynamic debug support, binary size is about 218K bytes.
rapidcpp_static is slightly higher with 238K bytes.
rapidcpp_shared becomes relatively big: 858K bytes!
The difference between rapidcpp and rapidcpp_shared is explained by additional debugging information in the symbol sections. When running strip this information is discarded and the binary size becomes equal.
The size of rapidcpp shared is explained by the increased size of code and data sections that are needed to support dynamic debug — about 140K of extra information. This size may increase as more functions/libraries need dynamic debug support.
So far we discussed the main concepts that make rapidcpp work as intended.
Having that covered, the following section discusses some designs and thoughts I had experienced in development of rapidcpp.
Dynamic Debug Control
rapidcpp minimal POC was just a switch statement the user control through the standard input. Some commands are evoking the actual program dependencies functions. The other options allow dynamic debug operations: closing/opening/reloading libraries.
In an actual program it is more likely to assume that the “dynamic debug” operations will have a client/server relationship, having a client and a server on logical threads that a developer interact with for debugging the application. rapidcpp does not have this support. Currently, the actual program is just a single-threaded switch statement, where some operations call functions in their dynamic dependencies and others are “dynamic debug” operations.
Hot-Swap Function Replacement
rapidcpp doesn’t support hot-swapping — If a function is called during a dynamic library upgrade — the thread executing on the function handle is likely to crash:
rapidcpp can encounter this by using a double buffer technique — and toggling index. This way we can transition to new implementations without breaking already executed callings¹
The easier but more intrusive way is by using a mutex.
rapidcpp implements neither (calling function and replacing its underlying lib can not be executed simultaneously).
Replacing a library implementation is expected to be a rare event which may happen at most once a minute.
Note that the existence of two logical threads may have a penalty on the process itself and their affinity and execution details need to be considered per process architecture.
¹ In truth to get the most robust solution you have maintain a caller counter, but this seems overkill. Functions should be executed in matter of seconds at most and not linger indefinitely. Library in a dynamic debug is expected to be replaced once every several minutes, more than enough time to for old functions to return.
One-Level / Multi-Level Debugging
What was described allows the main module to redefine function in other dynamic libs easily. But it doesn’t allow dynamic libraries to be dynamic debugged as well. There are some challenges when those libraries have intertwined dependencies.
To clarify the issue, Imagine that the main depends on lib1 and lib2, but lib1 uses lib2. In that case, lib2 definitions can be either universal to main and lib1, or separately defined manually by the user. And even before that the dynamic debug feature may not even be possible to lib1 at all.
The first flow is the universal function table. lib1 calls only lib2v1 at all times, as the lib appears in ldd of lib1. For lib1, lib2 implementations are fixed.
The second flow describes a flow where each library maintains their own dynamic debug mechanism, therefore allows precise debugging in narrower scopes. The next two subtitles explains the difficulties of this flow.
Dynamic Debug the Libraries
A dynamic library can be linked without using the dynamic debug headers as just the main module. In that case, as explained, any dependency on other libs will be considered in ldd as explained before and it replacing its implementation in runtime will not be possible.
A dynamic library can include the dynamic lib headers just as like as the main module, but it should perform an #undef on the function calls it is exporting. The scheme that was shown above supports the calling of a function, but not its declarations.
Note: adding #undef is preventing the original code of the dynamic library to remain unmodified.
Universal / Distributed Function Symbols
The selected design for dynamic debug is central: There is only one copy of the data structures needed for dynamic debug, in the main module. The downfall of that design is that dynamic libraries can not have a different implementation of a function other than the ones loaded. However, It is possible to use a specific mapping per dynamic library. In this case, the dynamic debug design becomes distributed. The arising challenge from that design is dispatching dynamic debug command for each module and making this transaction reliable.
The distributed model was even tested for a while, but was neglected for simplicity reasons: cognitive load on the developer, whose long dynamic debug sessions become difficult to follow. And to be frank, the dispatching attempt I implemented did not work quite well and after struggling and having the first reason in mind, I had to let it go.
Small issue: Compiler Support
rapidcpp supports both GCC and Clang, but there is a subtle difference.
the result of strcmp on constant char * is not detected not as a constant expression on Clang 10, while GCC accepts this. The cost here is additional if for the clang compiler.
I have to mention that VSCode + CMake plugin made the transition between compilers too smoothly to be true.
Final Words
Rapidcpp potential is still yet to be unveiled.
Thanks for reading this article. This side project has been on my mind mine for almost a few years as a concept, and in totally two months to implement it. I tried to give you a taste of the highlighted issues that have been on my mind. You can share your thoughts openly here and ask any question you like.
Level Up Coding
Thanks for being a part of our community! More content in the Level Up Coding publication.
Follow: Twitter, LinkedIn, Newsletter
Level Up is transforming tech recruiting ➡️ Join our talent collective
C++ Library-oriented debugging was originally published in Level Up Coding on Medium, where people are continuing the conversation by highlighting and responding to this story.
This content originally appeared on Level Up Coding - Medium and was authored by Aviv Avitan
Aviv Avitan | Sciencx (2022-06-22T15:38:31+00:00) C++ Library-oriented debugging. Retrieved from https://www.scien.cx/2022/06/22/c-library-oriented-debugging/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.