Eight concepts of reading, and this one writes. The artifact is dynscope.py, in this course’s sample directory, and it does one thing: it rebuilds the loader’s scope from the files and resolves symbols the way ld.so does, without a loader.
$ python3 dynscope.py scope the ordered global scope, built from DT_NEEDED resolve resolve one symbol the way ld.so would exports list what a library actually exports search every definition of a symbol, and which wins tour all of the above, verified against the real loader
And here is the thing that makes it worth building rather than reading about: it checks itself against the loader. Every resolution it computes is compared with what glibc actually did, obtained from LD_DEBUG=bindings. Two independent implementations, one reading the files and one running the code, and they have to agree.
What it has to read, and why each is unavoidable. The list is short and that is the point.
The four bugs it had while being written are worth more than the code, because each is a way of being confidently wrong:
The whole tour, verbatim, and every step ends in a verdict:
$ python3 dynscope.py tour
=== dynscope: the scope is not a secret ===
The global scope of ./prog
0 depth=0 prog 0 exported
1 depth=1 libb.so 1 exported
2 depth=1 liba.so 1 exported (+1 more)
3 depth=1 libc.so.6 3164 exported (+2 more)
4 depth=2 ld-linux-x86-64.so.2 38 exported
PASS the executable is scope[0]
PASS libb.so is scope[1] (first DT_NEEDED)
PASS liba.so comes after libb.so, because the executable listed it second
PASS and libc.so.6 is in the scope at all
Who would mid_value bind to in ./prog?
-> libb.so (scope index 1 of 12, value 0x1110, FUNC)
--- the interposed build ---
Who would lib_value bind to in ./prog2?
-> prog2 (scope index 0 of 12, value 0x1150, FUNC)
also defined by liba.so -- and it lost, because index 0 is earlier
--- exports ---
What liba.so exports
lib_value FUNC bind=1 value=0x1100 size=11
1 exported, 4 undefined.
Every definition of lib_value in scope order
0 prog2 0x1150 <== WINS
2 liba.so 0x1100
Every step verified.
Five PASSes, then two resolutions that each name a winner and a loser, and then a search that shows the loser. liba.so exports lib_value deliberately and loses to an executable that defined it by accident, because the executable is index 0. That is the interposition concept reproduced by a program that never called the loader for anything except verification.
Now the extension that makes it a real cross-check rather than a restatement of the model — a graph where the recursion and the queue disagree, which is the whole point of the third concept:
$ python3 dynscope.py scope ./order
0 depth=0 order 0 exported
1 depth=1 libd2.so 0 exported
2 depth=1 libdeep.so 1 exported
3 depth=1 libb.so 1 exported
4 depth=1 liba.so 1 exported (+1 more)
5 depth=1 libc.so.6 3164 exported (+4 more)
6 depth=2 ld-linux-x86-64.so.2 38 exported
$ LD_DEBUG=libs ./order 2>&1 | grep "find library" | sed 's/.*=//;s/ \[.*//'
libd2.so
libdeep.so <-- SECOND, not fourth
libb.so
liba.so
libc.so.6
Two independent computations, same order. One from the loader walking; one from Python reading DT_NEEDED and using a queue. And the (+N more) annotations make the deduplication visible rather than implicit — liba.so was also needed by libb.so, and libc by five objects, and neither is a second entry.
What the artifact deliberately does not do, because the omissions are the specification.
IT DOES NOT WHY IT WOULD BE WRONG TO
handle VERSIONED names a version is a second key in the
(api_v1@@V1) same search. resolving the bare
name would give an answer that
looks right and is wrong.
handle RTLD_NEXT it is a RELATIVE lookup. there is
no handle, so there is nothing
for a file-reading program to
compute.
handle thread-local offsets the value is (this thread's
block base) + a constant. the
constant is a file field; the
base is a run-time fact.
handle IFUNC resolvers the loader CHOOSES the function,
possibly by CPU feature. the
"definition" in .dynsym is a
resolver, and the answer depends
on the machine.
fail on a MISSING DT_NEEDED the real loader refuses to
start. this one prints
NOT FOUND and carries on,
because a resolver that can
answer some questions is more
useful than one that answers
none.
THAT LAST ONE IS A DELIBERATE
DIVERGENCE and it is the most
defensible: a partial answer beats
a refusal when you are the tool
being used to understand something.
And the one thing that makes the omissions safe, which is the discipline rather than the code: every one of them is a case where the file does not contain the answer. A version is a second key and both keys are in the file, so it could be done — but a half-implemented version lookup that silently ignores the suffix is the worst outcome available. The thread-pointer base is not in any file at all, and no amount of reading would produce it. “I cannot compute this from the files” is a real answer, and naming it is better than guessing.
What the artifact gets right is worth stating too, because it is the part that is easy to get subtly wrong:
it reports the LOSER, not just the winner.
-> prog2 (scope index 0 of 12, value 0x1150, FUNC)
also defined by liba.so -- and it lost, because index 0
is earlier
a resolver that prints only the answer teaches you the
rule. one that also prints what it beat teaches you WHY,
and why is the part you cannot get from the source.
and it cross-checks against LD_DEBUG, so a bug in its
own model would have to be a bug in glibc's model at the
same time and in the same direction to survive.
Which is the real argument for building any of these tools: not that the answer is useful, but that two independent implementations are a test of each other. The Relocations course’ applier had the same property, and found two of its own bugs on the way. This one found four.
$ cd courses/dyn/assets/samples $ python3 dynscope.py tour $ python3 dynscope.py scope ./order $ python3 crosscheck.py ALL 119 CHECKS PASS
Then extend it, and the order is chosen so each step is a concept from this course:
1. VERSIONED NAMES. It is the biggest gap and the
most instructive. A versioned symbol needs
.gnu.version as a third key. Make resolve() take
"api_v1@@V1" and find BOTH. Then make it FAIL
loudly if given a bare name when the library only
has versioned ones -- that failure mode is the
point of the exercise.
2. RTLD_DEFAULT. Add a scope-wide lookup that takes no
handle and searches everything. Then confirm it
agrees with the loader for a symbol you know is
global. (This is the difference the RTLD_LOCAL /
RTLD_GLOBAL concept measured, from the other side.)
3. A "why" mode: for any symbol, print the scope index,
the depth, the DT_NEEDED path that put each object
there, and the object's .dynsym index. That is
enough to diagnose a real interposition surprise
from a core dump or a bug report.
4. Make it work on a dlopen'd library: resolve a
handle to its path, then scope it the same way.
(dlopen does not change the ALGORITHM, only when
the object enters the list -- which is the point
of the dlopen concept.)
5. Finally, and this is the real test: break it
deliberately. Turn the queue back into a recursion
and watch the scope order change. Then write down
which of your answers changed. A tool you have
broken on purpose and understand is a tool you
trust; one you have only seen work is not.
Exercise 1 is the one that turns the artifact from a demonstration into a tool, and the failure mode is the lesson. Resolving api_v1 when only api_v1@@V1 exists looks completely correct and is completely wrong — it will find the right function, because the version is an attribute of a name rather than a different name. Which means a version-unaware resolver can pass every test you write and still break the moment a library ships two versions of a symbol. Knowing which of your answers are right for the wrong reason is the difference between a tool and a coincidence, and the only way to get it is to implement the key and then test the case where you did not.
Exercise 5 is the one to end on, and it is the same discipline that produced the four bugs. Turn the breadth-first walk into a depth-first one and the scope order changes — and because the artifact cross-checks against LD_DEBUG, it will start disagreeing with the loader on exactly the symbols whose resolution depends on position. A verification harness that can only say yes is not a verification harness. Every retraction in this course came from a check that failed, and every one of them is more valuable than the claims they replaced.
This is the end of the course, and it is the concept that makes the other nine into a skill. The Global Scope gave the algorithm; Interposition showed its sharpest consequence; Breadth-First gave the order; the building module gave .dynsym, visibility and -Bsymbolic; the run-time module gave dlopen, binding time and thread-local storage. And now a program that does all of it by reading files, in about two hundred lines, with no loader.
The chain this course sits in is nearly finished, and it is worth seeing the shape of the whole thing. The Object Files course wrote a 936-byte ELF object byte by byte. The Symbol Resolution course traced the resolution pass and read the map file. The Relocations course wrote a relocation applier that accepted a layout and said in a comment that deciding the layout was somebody else’s problem. The Static Linking course turned ld --verbose into a 276-line file you could edit, and ended with a driver that verified every step. This course takes the last piece — the run-time scope — and rebuilds it. Every link in the chain now has both a reading and a writing half.
Three debts the earlier courses left, settled here. The map file from the symbol-resolution course was a resolution-time artifact; this is the load-time one, and it is the same idea applied at the next stage. The -Bsymbolic relocation-type change from the building module is visible in the artifact’s scope as a missing search, which is the same fact from the other side. And the applier’s build_layout() comment is answered at last: the layout is a location counter in a script, and this course’s scope is the other half of the same problem — where rather than which.
And the connection to this course’s own first concept, which is the loop that makes it a course rather than a list. The Global Scope said the loader will tell you what it decided, and asked you to believe that the scope is not a secret. This concept is the proof, and the proof is that a program with no access to the loader’s internals reproduces its answers from the files. If a reader can only take one idea from this course, it should be that: the behaviour of a dynamic linker is a consequence of data you can print, and anyone who disagrees can go and check.
One connection outward, to the platform you are reading this on. The Underlayer server is an ELF ET_DYN binary served by a dynamic linker, and every mechanism on these pages is running right now: the executable is scope[0], libc.so.6 is in the scope, and LD_DEBUG=bindings would print every one of its decisions. You can run the instrument on it. That is not a metaphor — it is the same command, against a real process, and it is the through-line of this whole collection: every layer of the toolchain is readable by anyone who asks.