DarkZeros DarkZeros - 17 days ago 5
Android Question

Strange behaviour of `const char *` storage in libraries .so files

I have a library that I use in Android, though I am quite sure the problem is not specific to Android.
This library contains a bunch of error codes I print to logcat, and all of them consist of a constant string.

...
if(...){ALOGE("Error in parameter XXXXXX");}
if(...){ALOGE("Error in parameter YYYYYY");}
if(...){ALOGE("Error in parameter ZZZZZZ");}
...


Today I noticed I have a big amount of data in my .rodata section (around 16kB). So I run a
strings mylib.so
and I got a bunch of those strings.

Error in parameter XXXXXX
Error in parameter YYYYYY
Error in parameter ZZZZZZ


I though, that with a small extra cost of printing (which should be fine, since these codes are rarely used), I could save a lot in space if I split the string in 2 parts. Then the compiler should do the job and group in a single string the common part. Since the compiler have a duplicated string removal optimization step (CLANG and GCC).

I did it this way: (I have MANY of these, but they all have a pattern like this, I know I should use a define (but this was a quick test))

...
if(...){ALOGE("Error in parameter %s","XXXXXX");}
if(...){ALOGE("Error in parameter %s","YYYYYY");}
if(...){ALOGE("Error in parameter %s","ZZZZZZ");}
...


What I found is that:


  1. The library is EXACTLY the same size.
    .rodata
    is now much smaller, but
    .text
    increased by almost the same amount. (a few bytes difference only)

  2. strings
    command prints now 1 time only the
    "Error in parameter %s"
    string, and the separated parts. So there is no string merging taking place.

  3. Does not seem to matter if I Compile in 32bits, 64bits, etc..



So, what is going on here? How can I fix? Any guidance? What is the compiler doing?
Thanks

Extra data:


  • Compiler CLANG 4.9 (4.8 does same result).

  • Flags: -Os -fexceptions
    -std=c++11 -fvisivility=hidden






EDIT:

I created an online example test using GCC same results Online GCC

Split:

#include <stdio.h>
int main()
{
int a = rand()%7;
switch(a){
case 0: printf("Hello, %s!\n","Anna"); break;
case 1: printf("Hello, %s!\n","Bob"); break;
case 2: printf("Hello, %s!\n","Clark"); break;
case 3: printf("Hello, %s!\n","Danniel"); break;
case 4: printf("Hello, %s!\n","Edison"); break;
case 5: printf("Hello, %s!\n","Foo"); break;
case 6: printf("Hello, %s!\n","Garret"); break;
}
return 0;
}


NonSplit:

#include <stdio.h>
int main()
{
int a = rand()%7;
switch(a){
case 0: printf("Hello, Anna!\n"); break;
case 1: printf("Hello, Bob!\n"); break;
case 2: printf("Hello, Clark!\n"); break;
case 3: printf("Hello, Danniel!\n"); break;
case 4: printf("Hello, Edison!\n"); break;
case 5: printf("Hello, Foo!\n"); break;
case 6: printf("Hello, Garret!\n"); break;
}
return 0;
}


Compiled with:

gcc -Os -o main main.c
gcc -Os -o main2 main2.c


Sizes:

-rwxr-xr-x 1 20446 20446 8560 Nov 16 11:43 main
-rw-r--r-- 1 20446 20446 478 Nov 16 11:41 main.c
-rwxr-xr-x 1 20446 20446 8560 Nov 16 11:42 main2
-rw-r--r-- 1 20446 20446 443 Nov 16 11:39 main2.c


Strings:

strings main2 | grep "Hello"
Hello, Anna!
Hello, Bob!
Hello, Clark!
Hello, Danniel!
Hello, Edison!
Hello, Foo!
Hello, Garret!

strings main | grep "Hello"
Hello, %s!

Answer

All your expectations are fairly correct, but test cases are not sufficient to demonstrate the effect. First of all binary executable files have a notion of a "segment/section alignment" (or something like this). In brief it means that first bytes of different sections can be placed only at file offsets that are a multiples of some value (e.g. decimal 512). Unused space between sections is filled with zeros to meet this requirement. And all data that were provided by your test cases don't exhaust that padding and as result you can not feel real difference. Next - if you want to compare effect more clearly - you shouldn't link against startup code, i.e. you should build dynamic library with minimal number of references instead of regular executable.

Next, my test program. It differs a bit from your one. But not so conceptually.

#include <stdio.h>

#if defined(_SPLIT)
#define LOG(str) printf("Very very very loooo-o-o-o-o-o-o-ooooong prefix %s", str )
#elif defined(_NO_SPLIT)
#define LOG(str) printf("Very very very loooo-o-o-o-o-o-o-ooooong prefix " str )
#else
#error "Don't know what you want."
#endif

int foo(void) {
    LOG("aaaaaaaa");
    LOG("bbbbbbbb");
    LOG("cccccccc");
    LOG("dddddddd");
    LOG("eeeeeeee");
    LOG("ffffffff");
    LOG("gggggggg");
    LOG("hhhhhhhh");
    LOG("iiiiiiii");
    LOG("jjjjjjjj");
    LOG("kkkkkkkk");
    LOG("llllllll");
    LOG("mmmmmmmm");
    LOG("nnnnnnnn");
    LOG("oooooooo");
    LOG("pppppppp");
    LOG("qqqqqqqq");
    LOG("rrrrrrrr");
    LOG("ssssssss");
    LOG("tttttttt");
    LOG("uuuuuuuu");
    LOG("vvvvvvvv");
    LOG("wwwwwwww");
    LOG("xxxxxxxx");
    LOG("yyyyyyyy");
    LOG("zzzzzzzz");
    return 0;
}

Then, lets create dynamic libraries:

$ gcc --shared -fPIC -o t_no_split.so -D_NO_SPLIT test.c
$ gcc --shared -fPIC -o t_split.so -D_SPLIT test.c

And compare sizes:

-rwxr-xr-x  1 sysuser sysuser   12098 Nov 16 14:19 t_no_split.so
-rwxr-xr-x  1 sysuser sysuser    8002 Nov 16 14:19 t_split.so

IMO, there is really notable difference. And, being honest, I've not checked per-section sizes, but anyway you can do it by yourself.

Of course it doesn't mean that not splitted string use 12098 - 8002 bytes more than splitted ones. It just means that compiler / linker is obliged to use more space for t_no_split.so than for t_split.so. And this bloating is definitely caused by the difference in string sizes. Another interesting thing - splits even neutralize small bloating of machine code caused by passing a second argument to printf().

P.S. My machine is x64 Linux, GCC 4.8.4.

Comments