gccの-rdynamic option 調査メモ


はじめに

gccの-rdynamic optionの調査メモ。
gcc 9.1.0, glibc 2.29を対象に調査した。
基本内容に差はないと思うが、target archはx86_64-pc-linux-gnu。

TL;DR

  • -rdynamicオプションはリンク時のみ効果がある。
  • -rdynamicオプションを適用すると、共有ライブラリから実行ファイルのシンボル情報を動的に取得できるので、バックトレースの表示などに使われる。
  • -rdynamicオプションを適用することで、共有ライブラリとして利用可能な実行ファイルを作成できる。
  • -rdynamicオプションを適用するとシンボルバッティングや意図せず共有ライブラリに制御されてしまうリスクが増えるので、明確に目的がないなら通常は無効にしておく。
  • gcc,glibcのソースコードは面白い。
  • gccのspecファイルむずい。

公式ドキュメント

GCCには大量の起動オプションがあり、manだと少し見づらい。
そんなときは公式ドキュメントのOption Indexで確認すると楽。

-rdynamic
Pass the flag -export-dynamic to the ELF linker, on targets that support it. This instructs the linker to add all symbols, not only used ones, to the dynamic symbol table. This option is needed for some uses of dlopen or to allow obtaining backtraces from within a program.

曰く、-rdynamic optionは、Link Options
すなわち、オブジェクトファイルから実行ファイルを生成するときにのみ作用し、リンクステップがない場合無視される。

そして、-rdynamicオプションがgccに渡された場合、リンカに-export-dynamicオプションを渡す。
その結果、実行ファイルのすべてのシンボルが.dynsymテーブルに定義される。
つまり、dlopenなどの動的リンク参照の対象とすることができる。

man ld(1)-export-dynamicオプションの項にも、同等の説明がなされている。

ソースコード調査

gccでは、コマンドラインオプションのパーサ用テーブルは.optというオプション定義ファイルからawkで自動生成される。
今回の-rdynamic optionは下記のように定義されている。

gcc-9.1.0/gcc/config/gnu-user.opt
rdynamic
Driver

ちなみに、今回のようにtarget=aarch64-linuxでビルドした場合は、主に下記のoption定義ファイルが使われる。
「主に」としたのは、正確には特定言語用のoption定義ファイルも使われるから。
どのファイルが使われるかは、build-gcc-9.1.0/gcc/config.logext_opt_files, lang_opt_filesで確認可能。

  • gcc-9.1.0/gcc/common.opt
  • gcc-9.1.0/gcc/c-family/c.opt
  • gcc-9.1.0/gcc/config/fused-madd.opt
  • gcc-9.1.0/gcc/config/i386/i386.opt
  • gcc-9.1.0/gcc/config/gnu-user.opt
  • gcc-9.1.0/gcc/config/linux.opt
  • gcc-9.1.0/gcc/config/linux-android.opt

これら複数のoption定義ファイルがマージされた結果は、build-gcc-9.1.0/gcc/optionlistに生成される。

さて、Driverとは何か、gccのinternal documentによれば、

Driver
The option is handled by the compiler driver using code not shared with the compilers proper (cc1 etc.).

すなわち、コンパイラ自体には渡されず、コンパイラドライバだけが使うオプションであることがわかる。

optionlistから自動生成されたオプションパーサ用テーブルはbuild-gcc-9.1.0/gcc/options.cに生成される。
cl_options配列に確かに"-rdynamic"に対応するエントリが追加されている。
option定義ファイルに記載されたDriverに対応するのはCL_DRIVER
これはstruct cl_optionflagsに対応する。

build-gcc-9.1.0/gcc/options.c
const struct cl_option cl_options[] =
{
    ...
 /* [1491] = */ {
    "-rdynamic",
    NULL,
    NULL,
    NULL,
    NULL, NULL, N_OPTS, N_OPTS, 8, /* .neg_idx = */ -1,
    CL_DRIVER,
    0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
    (unsigned short) -1, 0, CLVC_BOOLEAN, 0, -1, -1 },
    ...
};
gcc-9.1.0/gcc/opts.h
struct cl_option
{
  ...
  /* CL_* flags for this option.  */
  unsigned int flags;
  ...
};

さらにDRIVERに渡されたオプションは、specsファイルの記述に従って変換されlinkerに渡る。

build-gcc-9.1.0/gcc/specs

...
*link:
%{!static|static-pie:--eh-frame-hdr} %{!mandroid|tno-android-ld:%{m16|m32|mx32:;:-m elf_x86_64}                    %{m16|m32:-m elf_i386}                    %{mx32:-m elf32_x86_64}   %{shared:-shared}   %{!shared:     %{!static:       %{!static-pie:   %{rdynamic:-export-dynamic}     %{m16|m32:-dynamic-linker %{muclibc:/lib/ld-uClibc.so.0;:%{mbionic:/system/bin/linker;:%{mmusl:/lib/ld-musl-i386.so.1;:/lib/ld-linux.so.2}}}}   %{m16|m32|mx32:;:-dynamic-linker %{muclibc:/lib/ld64-uClibc.so.0;:%{mbionic:/system/bin/linker64;:%{mmusl:/lib/ld-musl-x86_64.so.1;:/lib64/ld-linux-x86-64.so.2}}}}     %{mx32:-dynamic-linker %{muclibc:/lib/ldx32-uClibc.so.0;:%{mbionic:/system/bin/linkerx32;:%{mmusl:/lib/ld-musl-x32.so.1;:/libx32/ld-linux-x32.so.2}}}}}}     %{static:-static} %{static-pie:-static -pie --no-dynamic-linker -z text}};:%{m16|m32|mx32:;:-m elf_x86_64}                    %{m16|m32:-m elf_i386}                    %{mx32:-m elf32_x86_64}   %{shared:-shared}   %{!shared:     %{!static:       %{!static-pie:   %{rdynamic:-export-dynamic}     %{m16|m32:-dynamic-linker %{muclibc:/lib/ld-uClibc.so.0;:%{mbionic:/system/bin/linker;:%{mmusl:/lib/ld-musl-i386.so.1;:/lib/ld-linux.so.2}}}}   %{m16|m32|mx32:;:-dynamic-linker %{muclibc:/lib/ld64-uClibc.so.0;:%{mbionic:/system/bin/linker64;:%{mmusl:/lib/ld-musl-x86_64.so.1;:/lib64/ld-linux-x86-64.so.2}}}}     %{mx32:-dynamic-linker %{muclibc:/lib/ldx32-uClibc.so.0;:%{mbionic:/system/bin/linkerx32;:%{mmusl:/lib/ld-musl-x32.so.1;:/libx32/ld-linux-x32.so.2}}}}}}     %{static:-static} %{static-pie:-static -pie --no-dynamic-linker -z text}} %{shared: -Bsymbolic}}
...

いろいろ条件はあるようだが、specのドキュメントによれば、
%{rdynamic:-export-dynamic}-rdynamic指定時に-export-dynamicを指定するらしい。

%{S:X}
Substitutes X, if the -S switch is given to GCC.

実験

.dynsymセクションの変化を確認

下記の非常にシンプルなプログラムに対し、-rdynamicオプションの有無で生成されるバイナリの差分を確認した。

test.c
#include <stdio.h>

int
func (void)
{
  printf ("Hello World\n");
  return 0;
}

int
main (void)
{
  func ();
  return 0;
}

-rdynamicなし

まずはコンパイル時、リンク時ともに-rdynamicをつけない場合。

$ gcc -o test.o test.c -c
$ gcc -o test test.o
$ readelf --dyn-sym test

Symbol table '.dynsym' contains 3 entries:
  番号:      値         サイズ タイプ  Bind   Vis      索引名
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __libc_start_main@GLIBC_2.2.5 (2)
     2: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND __gmon_start__

.dynsymセクションには、スタートアップルーチンから呼び出される__libc_start_mainへの参照と、gprof用の__gmon_start__しかない。

コンパイル時のみ-rdynamic

次にコンパイル時のみ-rdynamicを有効にした。
想定としては、無視されて何も変わらないはず。

$ gcc -o test.o test.c -c -rdynamic
$ gcc -o test test.o
$ readelf --dyn-sym test

Symbol table '.dynsym' contains 3 entries:
  番号:      値         サイズ タイプ  Bind   Vis      索引名
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __libc_start_main@GLIBC_2.2.5 (2)
     2: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND __gmon_start__

実際、.dynsymセクションに定義されるシンボルに変更はない。
実行ファイル全体としても-rdynamicなしの場合とバイナリレベルで一致した。

リンク時のみ-rdynamic

いよいよ、リンク時に-rdynamicを有効にしてみる。
想定としては、正しく-rdynamicが作用しfunc()関数とmain()関数がdynamic symbolとして見えるはず。

$ gcc -o test.o test.c -c
$ gcc -o test test.o -rdynamic
$ readelf --dyn-sym test

Symbol table '.dynsym' contains 17 entries:
  番号:      値         サイズ タイプ  Bind   Vis      索引名
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND puts@GLIBC_2.2.5 (2)
     2: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __libc_start_main@GLIBC_2.2.5 (2)
     3: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND __gmon_start__
     4: 0000000000404038     0 NOTYPE  GLOBAL DEFAULT   24 _edata
     5: 0000000000404028     0 NOTYPE  GLOBAL DEFAULT   24 __data_start
     6: 0000000000404040     0 NOTYPE  GLOBAL DEFAULT   25 _end
     7: 0000000000404028     0 NOTYPE  WEAK   DEFAULT   24 data_start
     8: 0000000000402000     4 OBJECT  GLOBAL DEFAULT   16 _IO_stdin_used
     9: 0000000000401160   101 FUNC    GLOBAL DEFAULT   14 __libc_csu_init
    10: 0000000000401060    42 FUNC    GLOBAL DEFAULT   14 _start
    11: 0000000000404038     0 NOTYPE  GLOBAL DEFAULT   25 __bss_start
    12: 0000000000401147    16 FUNC    GLOBAL DEFAULT   14 main
    13: 0000000000401000     0 FUNC    GLOBAL DEFAULT   11 _init
    14: 0000000000401132    21 FUNC    GLOBAL DEFAULT   14 func
    15: 00000000004011d0     2 FUNC    GLOBAL DEFAULT   14 __libc_csu_fini
    16: 00000000004011d4     0 FUNC    GLOBAL DEFAULT   15 _fini

実際、.dynsymセクションにfunc()関数とmain()関数が追加されている。
さらに、関数だけでなくリンカが作成した__bss_startなどのシンボルも.dynsymセクションにエントリがある。

コンパイル・リンク同時実行時に-rdynamic

一応コンパイルとリンクを同時に実行する場合に-rdynamicをつけた場合も確認した。

$ gcc -o test test.c -c -rdynamic
$ readelf --dyn-sym test
  (同じなので略)

予想通り、リンク時と同じ結果が得られた(実行ファイルバイナリ一致)。

dlopenによるシンボル取得

次に、下記のようなdlopen()を使った簡単なプログラムで、実際に実行ファイル中のシンボルを参照してみる。
なお、dlopen()の第一引数をNULLにすることで、実行ファイル自体を指定することができる。

test_dlopen.c
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>

#include <dlfcn.h>

extern const char *__bss_start[];

uint32_t
func (void)
{
  return 0xdeadbeef;
}

int
main (void)
{
  char *error = NULL;
  void *handle = dlopen (NULL, RTLD_LAZY);
  if (!handle)
    {
      fprintf (stderr, "%s\n", dlerror());
      exit (EXIT_FAILURE);
    }
  dlerror ();    /* Clear any existing error */

  int (*dyn_func)(void) = (int (*)(void)) dlsym (handle, "func");
  error = dlerror ();
  if (error != NULL)
    {
      fprintf (stderr, "%s\n", error);
      exit (EXIT_FAILURE);
    }
  Dl_info info_func;
  int result = dladdr (dyn_func, &info_func);
  if (result == 0)
    {
      fprintf (stderr, "dladdr error for dyn_func %d\n", result);
      exit (EXIT_FAILURE);
    }

  void* dyn_bss_start = (void*) dlsym (handle, "__bss_start");
  error = dlerror ();
  if (error != NULL)
    {
      fprintf (stderr, "%s\n", error);
      exit (EXIT_FAILURE);
    }
  Dl_info info_bss_start;
  result = dladdr (dyn_bss_start, &info_bss_start);
  if (result == 0)
    {
      fprintf (stderr, "dladdr error for dyn_bss_start %d\n", result);
      exit (EXIT_FAILURE);
    }

  dlclose (handle);

  uint32_t ret = func();
  printf ("func(direct) = %p, ret = 0x%x\n", func, ret);
  ret = dyn_func();
  printf ("func(dlopen) = %p, ret = 0x%x\n", dyn_func, ret);
  printf ("dladdr(func)\n"
          "  .dli_fname = \"%s\", .dlt_fbase = %p,\n"
          "  .dli_sname = \"%s\", .dli_saddr = %p\n",
          info_func.dli_fname, info_func.dli_fbase,
          info_func.dli_sname, info_func.dli_saddr);
  printf ("__bss_start(direct) = %p\n", __bss_start);
  printf ("__bss_start(dlopen) = %p\n", dyn_bss_start);
  printf ("dladdr(__bss_start)\n"
          "  .dli_fname = \"%s\", .dlt_fbase = %p,\n"
          "  .dli_sname = \"%s\", .dli_saddr = %p\n",
          info_bss_start.dli_fname, info_bss_start.dli_fbase,
          info_bss_start.dli_sname, info_bss_start.dli_saddr);
  return 0;
}

-rdynamicなし

とりあえず-rdynamicオプションなしで実行。
予想通り、dlopen()は成功して、dlsym()で"test"が見つけられず失敗した。

$ gcc -o test_dlopen test_dlopen.c -ldl
$ ./test_dlopen
./test-no-rdynamic: undefined symbol: test

-rdynamicあり

次に、-rdynamicオプションありで実行。
test関数、__bss_startシンボルともに見つけることができている。
さらにそれぞれのアドレスは、直接参照した場合と同じとなっており、当然だが関数呼び出しもできている。

dladdr()で取得した.dli_fname(ファイル名)は正しく"./test"を返している。
一方、.dli_sname(symbol名)は、func関数については正しく"func"となっているが、
__bss_startについては、"edata"となっている。
これは、readelf --dyn-symの結果を見ればわかるが、同じアドレスで_edataと`
_bss_start`という異なるシンボルを持っているため発生したと思われる。
実際、自分で適当にグローバル変数を定義し、参照した場合はこのようなことは発生しなかった。

$ gcc -o test_dlopen test_dlopen.c -ldl -rdynamic
$ ./test
func(direct) = 0x4011a2, ret = 0xdeadbeef
func(dlopen) = 0x4011a2, ret = 0xdeadbeef
dladdr(func)
  .dli_fname = "./test", .dlt_fbase = 0x400000,
  .dli_sname = "func", .dli_saddr = 0x4011a2
__bss_start(direct) = 0x404070
__bss_start(dlopen) = 0x404070
dladdr(__bss_start)
  .dli_fname = "./test", .dlt_fbase = 0x400000,
  .dli_sname = "_edata", .dli_saddr = 0x404070
$ readelf --dyn-sym test | grep 404070
    11: 0000000000404070     0 NOTYPE  GLOBAL DEFAULT   24 _edata
    18: 0000000000404070     0 NOTYPE  GLOBAL DEFAULT   25 __bss_start

いつ使うのか

さて、なかなかに好奇心をくすぐってくれる-rdynamicオプションだが、実際どのようなときに使われるのか。

調べた限りでは、下記2種類の用途が見つかった。
他にもあればぜひ教えてください。

backtrace取得

デバッグ機能を提供するライブラリから実行ファイルの情報を取得するとき、シンボルテーブルを動的に参照したい、というのはとてもわかり易い。

実際、glibcが提供するbacktrace_symbols()では、manにて下記のように-rdynamicに言及されている。
バックトレース情報をアドレス値として表示する分には不要だが、一緒に関数名を表示するためには-rdynamicオプションが必要。

The symbol names may be unavailable without the use of special linker options. For systems using the GNU linker, it is necessary to
use the -rdynamic linker option. Note that names of "static" functions are not exposed, and won't be available in the backtrace.

backtrace_symbolsのコードを見ればdladdrを使っているからということがわかる。

debug/backtracesyms.c
char **
__backtrace_symbols (void *const *array, int size)
{
  Dl_info info[size];

  ...

  /* Fill in the information we can get from `dladdr'.  */
  for (cnt = 0; cnt < size; ++cnt)
    {
      struct link_map *map;
      status[cnt] = _dl_addr (array[cnt], &info[cnt], &map, NULL);
      if (status[cnt] && info[cnt].dli_fname && info[cnt].dli_fname[0] != '\0')
        {
          /* We have some info, compute the length of the string which will be
             "<file-name>(<sym-name>+offset) [address].  */
          total += (strlen (info[cnt].dli_fname ?: "")
                    + strlen (info[cnt].dli_sname ?: "")
                    + 3 + WORD_WIDTH + 3 + WORD_WIDTH + 5);

          /* The load bias is more useful to the user than the load
             address.  The use of these addresses is to calculate an
             address in the ELF file, so its prelinked bias is not
             something we want to subtract out.  */
          info[cnt].dli_fbase = (void *) map->l_addr;
        }
      else
        total += 5 + WORD_WIDTH;
    }
...

なお、libSegFault.soは内部backtrace_symbols_fd(3)を使用しているので同類。

debug/segfault.c
/* This function is called when a segmentation fault is caught.  The system
   is in an unstable state now.  This means especially that malloc() might
   not work anymore.  */
static void
catch_segfault (int signal, SIGCONTEXT ctx)
{
  ...

  /* Get the backtrace.  */
  arr = alloca (256 * sizeof (void *));
  cnt = backtrace (arr, 256);

  ...

  /* Now generate nicely formatted output.  */
  __backtrace_symbols_fd (arr + i, cnt - i, fd);

ちなみに実は、gcc自身もpluginをサポートする環境では、-rdynamicオプションありでビルドされる。
なぜpluginだと-rdynamicが必要なのか。
もちろんpluginのシンボルを取り出すためにdlopen等が必要にはなるが、
それは本来の向きのdlopenであって、実行ファイル側のdynamic symbol tableは関係ないはずだ。
正確な答えは不明だが、こちらの記事のようにpluginを開発している人が、
backtraceを使って効率よく開発できるようにするための措置ではないかと思う。

逆にいえば、gcc自身ですら必要とされるとき以外は無効にしているので、
明確な目的がない場合は-rdynamicオプションは無効にしておくのが良いといえるだろう。

実行可能な共有ライブラリを作成するとき

共有ライブラリでありつつ、単体で実行も可能だと便利な時がある。
バージョンや、どのようなオプションでビルドされたかの情報を表示するときだ。

バイナリ配布されるライブラリは、パッケージマネージャやheader、pkg-configなどにバージョンを書くことができるが、
いずれもライブラリそのものとは別ファイルでバージョンを管理することになるので、
いつの間にかバージョン情報とバイナリが乖離してしまう可能性がある。
ライブラリそのものがバージョンを語ることができれば、それが一番間違いがない。

$ lib/libc.so.6 
GNU C Library (GNU libc) stable release version 2.29.
Copyright (C) 2019 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.
There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE.
Compiled by GNU CC version 9.1.0.
libc ABIs: UNIQUE IFUNC ABSOLUTE
For bug reporting instructions, please see:
<http://www.gnu.org/software/libc/bugs.html>.

このような実行可能共有ライブラリを作るためには、
下記のようにビルドすればいい。

  1. -fPICでコンパイル
  2. -shared -pie -rdynamicでリンク

共有ライブラリのソースコード。

executable_solib.c
#include <stdio.h>
#include <stdlib.h>

int
func (int a, int b)
{
  return a + b;
}

int
main (void)
{
  printf ("This is executable solib (%s)\n", __func__);
  exit (EXIT_SUCCESS);
}

共有ライブラリを使うソースコード。

test_executable_solib.c
#include <stdio.h>
#include <assert.h>

extern int func (int a, int b);

int
main (void)
{
  assert (func (2, 3) == 5);

  printf ("test passed\n");
  return 0;
}

単体としても実行できているし、共有ライブラリとしても使えている。

$ gcc -g -o executable_solib.o executable_solib.c -c -fPIC
$ gcc -o libexecutable.so executable_solib.o -shared -pie -rdynamic
$ gcc -g -o test_executable_solib.o test_executable_solib.c -c
$ gcc -g -o test_executable_solib test_executable_solib.o -L. -Wl,-rpath=. -lexecutable
$ ./libexecutable.so
This is executable solib
$ ./test_executable_solib
test passed

さて、ではglibcのリンクオプションを確認してみる。
下記は実際にglibc-2.29をビルドした時のリンクコマンドだ。

gcc  
 -shared
 -static-libgcc
 -Wl,-O1 
 -Wl,-z,defs
 -Wl,-dynamic-linker=/work/install/lib/ld-linux-x86-64.so.2 
 -B/work/build-glibc-2.29/csu/ 
 -Wl,--version-script=/work/build-glibc-2.29/libc.map
 -Wl,-soname=libc.so.6
 -Wl,-z,combreloc
 -Wl,-z,relro
 -Wl,--hash-style=both 
 -nostdlib
 -nostartfiles
 -e __libc_main
 -L/work/build-glibc-2.29
 -L/work/build-glibc-2.29/math
 -L/work/build-glibc-2.29/elf
 -L/work/build-glibc-2.29/dlfcn
 -L/work/build-glibc-2.29/nss
 -L/work/build-glibc-2.29/nis
 -L/work/build-glibc-2.29/rt
 -L/work/build-glibc-2.29/resolv
 -L/work/build-glibc-2.29/mathvec
 -L/work/build-glibc-2.29/support
 -L/work/build-glibc-2.29/crypt
 -L/work/build-glibc-2.29/nptl
 -Wl,-rpath-link=/work/build-glibc-2.29
                :/work/build-glibc-2.29/math
                :/work/build-glibc-2.29/elf
                :/work/build-glibc-2.29/dlfcn
                :/work/build-glibc-2.29/nss
                :/work/build-glibc-2.29/nis
                :/work/build-glibc-2.29/rt
                :/work/build-glibc-2.29/resolv
                :/work/build-glibc-2.29/mathvec
                :/work/build-glibc-2.29/support
                :/work/build-glibc-2.29/crypt
                :/work/build-glibc-2.29/nptl
 -o /work/build-glibc-2.29/libc.so
 -T /work/build-glibc-2.29/shlib.lds
 /work/build-glibc-2.29/csu/abi-note.o
 /work/build-glibc-2.29/elf/soinit.os
 /work/build-glibc-2.29/libc_pic.os
 /work/build-glibc-2.29/elf/interp.os
 /work/build-glibc-2.29/elf/ld.so
 -lgcc
 /work/build-glibc-2.29/elf/sofini.os

-rdynamicがない。。。
同等の効果をもつ、-Wl,-E, -Wl,--export-dynamicもない。。。
-pieもない。。。

ということで、残念ながらglibcは別の方法で実現していた。
がせっかくなので、実現方法を確認してみる。

まずはエントリポイントを-eオプションで__libc_main変更しているので、そこから確認してみる。

glibc-2.29/csu/version.c
void
__libc_main (void)
{
  __libc_print_version ();
  _exit (0);
}

versionを出力して終了しているだけ。
では、__libc_print_versionは?

void
__libc_print_version (void)
{
  __write (STDOUT_FILENO, banner, sizeof banner - 1);
}

おぉ、直接システムコールを呼び出してバージョンを出力しているのか。
これを見て気づいたが、glibcの場合文字列を出力するだけなら外部ライブラリを参照することがない。
そのため位置独立な実行ファイルにする必要がなくて-pieをつけなくていい。
-pieをつけなければリンカは-sharedが指定されているからdynamic symbolをすべて公開するので、-rdynamicもいらない、と。

前述の-rdynamicオプションを使った方法は、「動的リンク可能な実行ファイル」、
glibcはあくまでも「entryポイントが設定された共有ライブラリ」、ということか。

おまけ(ビルド手順)

今回の実験で使ったaarch64-linux向けのクロスコンパイラのビルド手順は下記の通り。

x84-64向け

# インストール先ディレクトリ
mkdir -p install

# binutils-2.32 (as, ld等)のビルド
wget https://ftp.gnu.org/gnu/binutils/binutils-2.32.tar.xz
tar xf binutils-2.32.tar.xz
mkdir -p build-binutils-2.32
cd build-binutils-2.32
../binutils-2.32/configure --prefix=${PWD}/../install
make -j12
make install
cd ..

# gcc-9.1.0のビルド
wget http://ftp.tsukuba.wide.ad.jp/software/gcc/releases/gcc-9.1.0/gcc-9.1.0.tar.xz
tar xf gcc-9.1.0.tar.xz
mkdir -p build-gcc-9.1.0
cd build-gcc-9.1.0
../gcc-9.1.0/configure --prefix=${PWD}/../install --enable-languages=c,c++ --disable-multilib
make -j12 all-gcc
make install-gcc
make -j12 all-target-libgcc
make install-target-libgcc
cd ..

# glibcのビルド
wget http://ftp.jaist.ac.jp/pub/GNU/glibc/glibc-2.29.tar.xz
tar xf glibc-2.29.tar.xz
mkdir -p build-glibc-2.29
cd build-glibc-2.29
../gcc-9.1.0/configure --prefix=${PWD}/../install
make -j12
make install
cd ..

# ちゃんとビルドできたか確認
export PATH="${PWD}/install/bin/:$PATH"
as --version # 2.32のはず
ld --version # 2.32のはず
gcc --version # 9.10のはず

aarch64-linux向け

下記でld/gccのビルドはできるが、それらで実際にリンクまでするためには、さらにkernel headerやlibcの準備が必要なので注意。

# インストール先ディレクトリ
mkdir -p install

# binutils-2.32 (as, ld等)のビルド
wget https://ftp.gnu.org/gnu/binutils/binutils-2.32.tar.xz
tar xf binutils-2.32.tar.xz
mkdir -p build-binutils-2.32
cd build-binutils-2.32
../binutils-2.32/configure --prefix=${PWD}/../install --target=aarch64-linux
make -j12
make install
cd ..

# gcc-9.1.0のビルド
wget http://ftp.tsukuba.wide.ad.jp/software/gcc/releases/gcc-9.1.0/gcc-9.1.0.tar.xz
tar xf gcc-9.1.0.tar.xz
mkdir -p build-gcc-9.1.0
cd build-gcc-9.1.0
../gcc-9.1.0/configure --prefix=${PWD}/../install --target=aarch64-linux --enable-languages=c,c++ --disable-multilib
make -j12 all-gcc
make install-gcc
cd ..

# ちゃんとビルドできたか確認
export PATH="${PWD}/install/bin/:$PATH"
aarch64-linux-as --version # 2.32のはず
aarch64-linux-ld --version # 2.32のはず
aarch64-linux-gcc --version # 9.10のはず

参考ページ