Back to course

Binding, and the Two Tables

18 minintermediateModule 1: What a Symbol IsContinues from sym-intro
18 min · Module 1: What a Symbol Is · Intermediate

Why This Matters

Take a function and give it static. Then compile it and look at what changed:

$ cat bind.c
int  shared(void) { return 1; }        /* no storage class */
static int private(void) { return 2; } /* file-local        */
int  weakish(void) __attribute__((weak));
int  weakish(void) { return 3; }

$ clang -c bind.c -o bind.o
$ readelf -sW bind.o | grep -E ' (shared|private|weakish)$'
     2: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     3: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND 
     4: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND 
     5: 0000000000000000     1 FUNC    GLOBAL DEFAULT    1 shared
     7: 0000000000000000     1 FUNC    LOCAL  DEFAULT    1 private
     9: 0000000000000000     1 FUNC    WEAK   DEFAULT    1 weakish

One keyword, one letter in the binary.static turned a GLOBAL into a LOCAL, and that single character changes what every other file on the command line is allowed to do with the name. Nothing else about the function changed: same code, same section, same relocation, same size.

Now the part that surprises people. Look at the undefined entries — indices 2, 3 and 4. There is a LOCAL undefined symbol in that table, and there is no such thing as a local undefined symbol. Those are not symbols; they are section and file markers, and the reader in the object-files course already established what they are. The symbol table starts with bookkeeping entries that are not definitions of anything, and a reader who counts symbols by counting entries overcounts.

A Simple Model

Binding answers one question: whose name is this? There are three answers in ELF and they form a strict hierarchy.

THE THREE BINDINGS, and what each permits LOCAL this file may refer to it. NOBODY else may, and no other file may DEFINE it either. Two files may each have their own `private` and that is fine -- they are two different symbols that happen to share a name. GLOBAL everyone may refer to it, and at most one file may define it. Two definitions is the "multiple definition" error. WEAK everyone may refer to it, and a definition is OPTIONAL. Zero definitions is legal. More than one is legal, and the FIRST one on the command line wins. A strong definition always beats a weak one.

The interesting cell in that table is the WEAK row's second clause, because it is the only place in the entire symbol system where “first on the command line wins” is a real rule rather than an accident of diagnostics. You saw in the previous concept that two strong definitions are an error. Two weak definitions are not an error, they are a choice, and the choice is made by command-line order.

Note also what WEAK is not. It is not “lower priority” in any general sense. It is a permission: the linker is allowed to leave the name unresolved. That is question Q2 from the previous concept, and binding is answering it, not Q1.

Does It Actually Work?

The hierarchy, measured rather than asserted. Two files each defining a weak function, and one of them strong:

$ cat w1.c
__attribute__((weak)) int pick(void) { return 1; }
$ cat w2.c
__attribute__((weak)) int pick(void) { return 2; }

$ clang -c w1.c -o w1.o; clang -c w2.c -o w2.o
$ clang -o prog w1.o w2.o m.o ; ./prog ; echo $?
1                      <-- the FIRST weak definition won
$ clang -o prog w2.o w1.o m.o ; ./prog ; echo $?
2                      <-- and now the other one

And a strong definition beating a weak one, regardless of order:

$ cat s1.c
int pick(void) { return 99; }          /* strong */
$ clang -o prog w1.o s1.o m.o ; ./prog ; echo $?
99
$ clang -o prog s1.o w1.o m.o ; ./prog ; echo $?
99                     <-- strong wins from EITHER side

Order decides between equals and never decides between unequal. That is the whole rule, and it is worth internalising because the intuition that “first wins” is unqualified will make you predict the wrong answer in the second case.

Now the part that is genuinely two tables. An object file has .symtab. A linked dynamic object has .dynsym. Here is the same library, both tables, side by side:

$ readelf -SW libvis.so | grep -E 'symtab|dynsym'
  [ 4] .dynsym   DYNSYM   ...  000030 18  A  5   1  8
  [30] .symtab   SYMTAB   ...  0000a0 18  A 31  19  8

$ readelf -sW libvis.so | grep -cE ' (def_fn|hid_fn)$'      # .symtab sees both
2
$ readelf --dyn-syms -W libvis.so | grep -cE ' (def_fn|hid_fn)$'   # .dynsym
1

The two tables have different sizes, different link fields, and different jobs..symtab is for the things that can still be changed — it is what ld -r reads, what nm reads, and what a debugger reads. .dynsym is for the things that are frozen: the loader cannot add definitions or change bindings, so it gets a table containing only the names the dynamic world can see.

A Real Example

Here is a real link, and the reason it fails is that a name in .symtab is not automatically a name in .dynsym.

$ cat d.c
int lib_data = 7;
$ clang -fPIC -shared -o libd.so d.c
$ clang -o prog main.c -L. -ld
$ nm -D libd.so | grep lib_data
0000000000004018 D lib_data        <-- exported, fine

$ cat e.c
static int lib_data = 7;
int read(void) { return lib_data; }
$ clang -fPIC -shared -o libe.so e.c
$ nm -D libe.so | grep lib_data
                       (nothing)    <-- static: local, and locals
                                    are not in .dynsym at all

A static symbol is not merely “less exported” — it is not in the table the loader consults. That is a stronger statement than a visibility setting, and it is the reason the two-table design is not redundant. A single table would have to either expose local names to the dynamic world or carry a per-entry “is this dynamic” bit; ELF does the latter for .symtab and solves it for .dynsym by omission.

The practical consequence shows up in shared libraries. If you build a .so and a symbol is missing from nm -D output, no amount of linking will recover it — there is nothing for the loader to find. The object-files course measured that COFF has no per-symbol dynamic marker at all and instead renames the symbol; this is the ELF answer to the same requirement, and the two are a good illustration of how differently the same problem got solved.

Try It Yourself

$ cd courses/sym/assets/samples
$ cat > b.c <'EOF'
int          g(void) { return 1; }
static int   s(void) { return 2; }
__attribute__((weak)) int w(void) { return 3; }
EOF
$ clang -c b.c -o b.o
$ readelf -sW b.o | grep -E ' [gsw]?\(?void\)?$| (g|s|w)$'

Then, without looking anything up, predict each of these and then check:

1. Two files, each with its own `static int helper(void)`. Link them. Does it work? Why? 2. Now delete `static` from both. Does it still work? 3. Build b.c into a .so. Which of g, s, w appear in `nm -D`? 4. w is in .dynsym. Is it in .dynsym for a .so that nothing links against? (Hint: look for --as-needed.)

Question 1 is the important one, and the answer is the reason static exists at all: two files can each have a static helper because those are two unrelated symbols that share a spelling. Binding is not about the name; it is about the pair of name and binding, and that is the next concept.

Connect

The obvious next step is Visibility Is Not Binding, and the connection is sharper than the titles suggest. Binding answers “who may refer to this name”; visibility answers “who may replace it with their own”. They are independent, every format stores them in different fields, and the next concept contains the measured demonstration that for two of the four visibility values the answer is not stored in the visibility field at all.

Backwards, the connection to Symbol Tables, Compared is exact rather than thematic. That concept established the three formats' table designs and the ELF layout of the 24-byte entry; this one supplies the meaning of the st_info nibble you saw there, and adds the observation that the first entries in the table are not symbols at all. COMMON and the ABI Break is the other direction: that concept measured a binding value — GLOBAL — used to carry a storage class, which is precisely the confusion this concept's hierarchy is designed to prevent, and which Two Definitions and One Tentative returns to.

Forward, the weak row of that table is load-bearing for the runtime half of the course. The PLT and the GOT is built entirely out of undefined GLOBAL symbols, and Who Owns the Storage is about what happens when an undefined symbol turns out to name a datum rather than a function. A weak undefined symbol is also the mechanism behind __gmon_start__ and the ITM profiling hooks, which you can see in the dynamic table of literally every binary on this machine — undefined, weak, never defined, never called, and costing one PLT slot each.