I've searched and tested a bit ...
First, the changing libC:
// libC1.c => libC.so.1
int c(void) { return 21; }
// libC2.c => libC.so.2
int c(void) { return 42; }
Then libA and libB:
// libA.c => libA.so | gcc -fPIC -shared -o libA.so libA.c -l:libC.so.1 -L.
extern int c(void);
int a(void) { return c(); }
// libB.c => libB.so | gcc -fPIC -shared -o libB.so libB.c -l:libC.so.2 -L.
extern int c(void);
int b(void) { return c(); }
Note that above in both cases I specifiy the correct .so file directly (-l:libC.so.1 and -l:libC.so.2). Now both libA and libB refer to the correct libC, but there's a problem: Both libCs export the symbol c!
Thus ...
extern int a(void);
extern int b(void);
#include <stdio.h>
int main() {
printf("a => %d, b => %d\n", a(), b());
}
... will happily print a => 21, b => 21. The reason is that once the dynamic linker loads one of the libCs, the symbol c (which is undefined in both libA and libB) is resolved (for both libA and libB) to the one loaded.
dlopen seems to be the only way
There are two approaches:
Modify the application using libA and libB
Load both libraries on your own using dlopen, RTLD_LOCAL makes symbols loaded (thus also symbols of dependencies of the loaded library) not visible to the application (or later calls to dlopen).
#include <stdio.h>
#include <assert.h>
#include <dlfcn.h>
int (*a)(void);
int (*b)(void);
int main() {
void * const a_handle = dlopen("libA.so", RTLD_NOW | RTLD_LOCAL);
// you could dlopen("libC.so.2", RTLD_NOW | RTLD_GLOBAL) here to "select"
// the correct symbol `c` for the following, too.
void * const b_handle = dlopen("libB.so", RTLD_NOW | RTLD_LOCAL);
assert(a_handle); // real error handling here please!
assert(b_handle);
*(void **)(&a) = dlsym(a_handle, "a");
*(void **)(&b) = dlsym(b_handle, "b");
assert(a); // real error handling here please!
assert(b);
printf("a => %d, b => %d\n", a(), b());
}
Then compiling, linking and running above (main2.c) gives
# gcc main2.c -ldl
# LD_LIBRARY_PATH=. ./a.out
a => 21, b => 42
Modify libA
In the source code of your libA, wherever you call a function funC from libC, you need to replace that call with a call to funC_impl with:
int (*funC_impl)(char *, double); // for a funC(char *, double) which returns an int
// and somewhere during initialization:
void * const c_handle = dlopen("libC.so.2", RTLD_NOW | RTLD_LOCAL | RTLD_DEEPBIND);
// check c_handle != NULL
*(void **)(&funC_impl) = dlsym(c_handle, "funC");
// check for errors! (dlerror)
And all that for every function, of course ... and of course you cannot control libB in that way.
If one runs LD_LIBRARY_PATH=. LD_DEBUG=all ./a.out 2>&1 (for the version at the top of the answer) then this is part of the output:
10545: Initial object scopes
10545: object=./a.out [0]
10545: scope 0: ./a.out ./libA.so ./libB.so /nix/store/681354n3k44r8z90m35hm8945vsp95h1-glibc-2.27/lib/libc.so.6 ./libC.so.1 ./libC.so.2 /nix/store/681354n3k44r8z90m35hm8945vsp95h1-glibc-2.27/lib/ld-linux-x86-64.so.2
10545:
10545: object=linux-vdso.so.1 [0]
10545: scope 0: ./a.out ./libA.so ./libB.so /nix/store/681354n3k44r8z90m35hm8945vsp95h1-glibc-2.27/lib/libc.so.6 ./libC.so.1 ./libC.so.2 /nix/store/681354n3k44r8z90m35hm8945vsp95h1-glibc-2.27/lib/ld-linux-x86-64.so.2
10545: scope 1: linux-vdso.so.1
10545:
10545: object=./libA.so [0]
10545: scope 0: ./a.out ./libA.so ./libB.so /nix/store/681354n3k44r8z90m35hm8945vsp95h1-glibc-2.27/lib/libc.so.6 ./libC.so.1 ./libC.so.2 /nix/store/681354n3k44r8z90m35hm8945vsp95h1-glibc-2.27/lib/ld-linux-x86-64.so.2
10545:
10545: object=./libB.so [0]
10545: scope 0: ./a.out ./libA.so ./libB.so /nix/store/681354n3k44r8z90m35hm8945vsp95h1-glibc-2.27/lib/libc.so.6 ./libC.so.1 ./libC.so.2 /nix/store/681354n3k44r8z90m35hm8945vsp95h1-glibc-2.27/lib/ld-linux-x86-64.so.2
10545:
10545: object=/nix/store/681354n3k44r8z90m35hm8945vsp95h1-glibc-2.27/lib/libc.so.6 [0]
10545: scope 0: ./a.out ./libA.so ./libB.so /nix/store/681354n3k44r8z90m35hm8945vsp95h1-glibc-2.27/lib/libc.so.6 ./libC.so.1 ./libC.so.2 /nix/store/681354n3k44r8z90m35hm8945vsp95h1-glibc-2.27/lib/ld-linux-x86-64.so.2
10545:
10545: object=./libC.so.1 [0]
10545: scope 0: ./a.out ./libA.so ./libB.so /nix/store/681354n3k44r8z90m35hm8945vsp95h1-glibc-2.27/lib/libc.so.6 ./libC.so.1 ./libC.so.2 /nix/store/681354n3k44r8z90m35hm8945vsp95h1-glibc-2.27/lib/ld-linux-x86-64.so.2
10545:
10545: object=./libC.so.2 [0]
10545: scope 0: ./a.out ./libA.so ./libB.so /nix/store/681354n3k44r8z90m35hm8945vsp95h1-glibc-2.27/lib/libc.so.6 ./libC.so.1 ./libC.so.2 /nix/store/681354n3k44r8z90m35hm8945vsp95h1-glibc-2.27/lib/ld-linux-x86-64.so.2
10545:
10545: object=/nix/store/681354n3k44r8z90m35hm8945vsp95h1-glibc-2.27/lib/ld-linux-x86-64.so.2 [0]
10545: no scope
10545:
The issue is that for both libA and libB the initial scope contains the libraries libC.so.1 and libC.so.2 in that order. So when resolving the symbol c in each of libA and libB, it first looks into libC.so.1, finds the symbol and is done with it.
Now "all" that's missing is a way to change this "initial object scope".