cunit-testinglinkerstublegacy-code

Make unresolved linking dependencies reported at runtime instead of at compilation/program load time for the purposes of unit testing


I have a home-grown unit testing framework for C programs on Linux using GCC. For each file in the project, let's say foobar.c, a matching file foobar-test.c may exist. If that is the case, both files are compiled and statically linked together into a small executable foobar-test which is then run. foobar-test.c is expected to contain main() which calls all the unit test cases defined in foobar-test.c.

Let's say I want to add a new test file barbaz-test.c to exercise sort() inside an existing production file barbaz.c:

// barbaz.c
#include "barbaz.h"
#include "log.h" // declares log() as a linking dependency coming from elsewhere

int func1() { ... res = log(); ...}

int func2() {... res = log(); ...}

int sort() {...}

Besides sort() there are several other functions in the same file which call into log() defined elsewhere in the project.

The functionality of sort() does not depend on log(), so testing it will never reach log(). Neither func1() nor func2() require testing and won't be reachable from the new test case I am about to prepare.

However, the barbaz-test executable cannot be successfully linked until I provide stub implementations of all dependencies coming from barbaz.c. A usual stub looks like this:

// in barbaz-test.c
#include "barbaz.h"
#include "log.h"

int log() {
    assert(false && "stub must not be reached");
    return 0;
}

// Actual test case for sort() starts here
...

If barbaz.c is large (which is often the case for legacy code written with no regard to the possibility to test it), it will contain many linking dependencies. I cannot start writing a test case for sort() until I provide stubs for all of them. Additionally, it creates a burden of maintaining these stubs, i.e. updating their prototypes whenever the production counterpart is updated, not forgetting to delete stubs which no longer are required etc.

What I am looking for is an option to have late runtime binding performed for missing symbols, similarly to how it is done in dynamic languages, but for C. If an unresolved symbol is reached during the test execution, that should lead to a failure. Having a proper diagnostic about the reason would be ideal, but a simple NULL pointer dereference would be good enough.

My current solution is to automate the initial generation of source code of stubs. It is done by analyzing of linking error messages and then looking up declarations for missing symbols in the headers. It is done in an ad-hoc manner, e.g. it involves "parsing" of C code with regular expressions.

Needless to say, it is very fragile: depends on specific format of linker error messages and uniformly formatted function declarations for regexps to recognize. It does not solve the future maintenance burden such stubs create either.

Another approach is to collect stubs for the most "popular" linking dependencies into a common object file which is then always linked into the test executables. This leaves a shorter list of "unique" dependencies requiring attention for each new file. This approach breaks down when a slightly specialized version of a common stub function has to be prepared. In such cases linking would fail with "the same symbol defined twice".


Solution

  • I may have stumbled on a solution myself, inspired by this discussion: Why can't ld ignore an unused unresolved symbol?

    The linker can for sure determine if certain linking dependencies are not reachable. But it is not allowed to remove them by default because the compiler has put all function symbols into the same ELF section. The linker is not allowed to modify sections, but is allowed to drop whole sections.

    A solution would be to add -fdata-sections and -ffunction-sections to compiler flags, and --gc-sections to linker flags.

    The former options will create one section per function during the compilation. The latter will allow linker to remove unreachable code.

    I do not think these flags can be safely used in a project without doing some benchmarking of the effects first. They affect size/speed of the production code.

    man gcc says:

    Only use these options when there are significant benefits from doing so. When you specify these options, the assembler and linker create larger object and executable files and are also slower. These options affect code generation. They prevent optimizations by the compiler and assembler using relative locations inside a translation unit since the locations are unknown until link time.

    And it goes without saying that the solution only applies to the GCC/GNU Binutils toolchain.