Ask a linker where your data segment ends and it will tell you, with a symbol it made up. Here is one that appears in a program you have never heard of, that no source file mentions, and that is nevertheless in the symbol table:
$ cat lddef.c
#include <stdio.h>
extern char _end[], __bss_start[], edata[], etext[];
int main(void) {
printf(" etext %p\n", (void*)etext);
printf(" edata %p\n", (void*)edata);
printf(" __bss_start %p\n", (void*)__bss_start);
printf(" _end %p\n", (void*)_end);
return 0;
}
$ clang -O0 -o lddef lddef.c && ./lddef
etext 0x560ee68c71b9
edata 0x560ee68ca018
__bss_start 0x560ee68ca018
_end 0x560ee68ca020
Look at edata and __bss_start: the same address. That is not a bug and it is not a coincidence — edata is the end of the initialised data and __bss_start is the beginning of the zero-initialised data, and when .bss follows .data immediately, which is the normal case, those two points are the same point. A reader who sees two symbols at one address will assume a mistake, and it is worth knowing in advance that it is not one.
Two families, and they are useful for different things.
The __start_/__stop_ pair is the more useful of the two and much less known. It gives you the bounds of a section without a linker script, which means without knowing the link layout in advance — and that is exactly what you need when the code doing the iterating is a shared object that will be linked against something you have not seen yet.
There is a related pair for arrays that is worth knowing because it is the standard trick: if you declare a variable as an array, C guarantees that &x and &x[0] have the same address, and sizeof(x) gives you the length. So extern int mytable[]; plus sizeof(mytable) cannot work, but __start_/__stop_ can, because they come from the linker rather than from the type.
First, they are real symbols, synthesised, and they are in the table:
$ readelf -sW lddef | grep -E ' (_end|__bss_start|etext|edata)$'
18: 0000000000004018 0 NOTYPE GLOBAL DEFAULT 25 edata
29: 0000000000004020 0 NOTYPE GLOBAL DEFAULT 26 _end
31: 0000000000004018 0 NOTYPE GLOBAL DEFAULT 26 __bss_start
33: 00000000000011b9 0 NOTYPE GLOBAL DEFAULT 14 etext
Three properties worth reading off that listing.NOTYPE — they are not functions or objects, they are addresses, and the format has a category for that. Size 0 — they are points, not extents. And GLOBAL with DEFAULT visibility, which is what you would expect and is not what the section pair does.
Now the section pair, and note the section index in the last column:
$ cat segmark.c
extern char __start_mysecd[], __stop_mysecd[];
__attribute__((section("mysecd"))) const long tbl[4] = {10,20,30,40};
int main(void) { ... }
$ readelf -sW segmark | grep -E '__start_mysecd|__stop_mysecd'
24: 0000000000002040 0 NOTYPE GLOBAL PROTECTED 16 __start_mysecd
25: 0000000000002060 0 NOTYPE GLOBAL PROTECTED 16 __stop_mysecd
PROTECTED, not DEFAULT. That is Finding 17, and it is the most carefully-chosen byte in the whole mechanism. __start_mysecd is a per-output-file boundary: it means “the start of mysecdin this binary”. If it were DEFAULT, a second shared object loaded later could export its own __start_mysecd and interpose it, and every module in the system would silently iterate the wrong section.
So the linker marks them un-preemptible for the same reason it would mark an internal function: the name is an implementation detail of one output file and must not become part of the global namespace. Compare the four boundaries, which are genuinely image-wide and legitimately DEFAULT. Same mechanism, different question, different answer — and that difference is the entire argument for having four visibility values.
Finally, the bounds are exact:
$ ./segmark
__start_mysecd=0x6142c803b040 __stop_mysecd=0x6142c803b060 count=4
10
20
30
40
0x...060 - 0x...040 = 0x20 = 32 bytes = 4 longs, and the values are the ones in the source. The linker computed the section’s extent and published it as two symbols, and the C code above never mentions the section by name.
The use that justifies the whole mechanism: iterating a table without knowing its size.
/* The problem: sizeof on an extern array is not the array's size. */
extern int handlers[]; /* no length anywhere */
extern char __start_handlers[], __stop_handlers[];
static size_t nhandlers(void) {
return (size_t)(__stop_handlers - __start_handlers) / sizeof(int);
}
void init_all(void) {
for (int *p = (int *)__start_handlers; p < (int *)__stop_handlers; p++)
(*p)();
}
Every other method has a defect. sizeof(handlers) is the size of a pointer, not the array. A sentinel value works but costs a comparison in every loop and a branch in every writer. A count symbol has to be kept in sync by hand, which is a bug waiting for the next person who adds an entry. The __start_/__stop_ pair makes the count a property of the link rather than of the source, so adding a table entry is a one-line change that cannot desynchronise.
This is the same class of solution as Two Hash Tables, One Job’s Bloom filter and the archive index: derive the fact from the structure instead of maintaining it separately. The general name for it is a section-boundary idiom, and it appears in kernel code, in embedded firmware, and in every C runtime that has to find its own initialiser array. It is also the standard answer to “how do I find the size of something I did not allocate”, which otherwise has no correct solution in C.
One caution, because it is a real limitation. The pair works for sections the linker creates from named input sections, and it does not work for anything the compiler or assembler synthesised without a name. A .bss that exists only because a variable was tentative has a name, so it works. A string literal in .rodata.str1.1 has a generated name, so __start_.rodata.str1.1 is not something you can spell. And the mechanism requires the section to be named at all — a static array that the compiler kept local will not be exported into a section you can address this way. The feature is precise, not general.
$ cd courses/sym/assets/samples $ ./build_samples.sh 2>&1 | sed -n '/Finding 16\/17/,/Finding 18/p' $ python3 crosscheck.py 2>&1 | sed -n '/\[9\]/,/\[10\]/p'
Then build the resolver, which is the from-scratch half of this course. Start with the smallest useful one — given a name, find its definition, the way the loader does it:
$ python3 symtables.py /bin/ls | head -40
and then extend it. The exercise is the resolution algorithm from The Algorithm, Measured, reimplemented against a real table:
1. Given .dynsym, .gnu.hash and .dynstr, write
resolve(name) -> address or NOT-FOUND.
Constraints: no linear scan of .dynsym. You may use
the bloom filter and the chain walk. Check it against
every name in .dynsym and report any mismatch.
2. Now handle versioning. A name plus a version index
must find the right entry among several sharing a
base name. Which structure tells you the answer, and
what do you do when the version is 0?
3. Now handle the linker-defined symbols. resolve("_end")
on an arbitrary .so -- is it in .dynsym at all? If
not, what is the right behaviour for your resolver,
and how would a program that needs it behave?
4. Make it agree with the dynamic linker. Run it under
LD_DEBUG=bindings and diff the set of names each
resolves. They should match except for the ones the
loader resolves internally.
Question 3 is the one that teaches the limit of the exercise, and it is worth doing properly. The linker-defined boundary symbols are in .symtab and mostly not in .dynsym — a .so has its own _end and it is not the program’s. A resolver that faithfully searches the dynamic table will not find _end, and that is not a bug in the resolver: the dynamic symbol table is a list of what this file offers the world, and _end is not an offer, it is a local fact. A program that wants its own image’s _end has to get it from its own symbol table, which is a different table with a different job — and that is the two-tables lesson from Binding, and the Two Tables arriving at the end.
This closes the course, and the closing argument is about the difference between a symbol that is found and a symbol that is offered. Every concept in the runtime module found its answer by searching .dynsym, and this one is the case where that search correctly fails. A resolver that is correct will tell you _end is not there, and a program that needs it must look elsewhere — which is not a limitation of the resolver but the whole reason two symbol tables exist.
To Binding, and the Two Tables the connection is the long arc. That concept asked why there are two tables and answered that the linker needs one and the loader needs another. This concept is the case that makes the answer concrete:__start_SEC is PROTECTED because it is per-output-file, and etext is DEFAULT because it is per-image, and the two facts put those symbols in different tables for the same reason they get different visibilities.
To Visibility Is Not Binding the connection is the sharpest single fact in the course. Finding 17 is the one place where a PROTECTED symbol is created by the toolchain rather than requested by the author, which is the best possible demonstration of why the value exists. The author did not choose it; the linker chose it because it understood something about the symbol’s meaning that a default would have lost.
And forward out of the course, to the two things this one is a bridge between. The section-boundary idiom is the direct ancestor of every later mechanism for “find what you did not allocate” — linker sections in C, .init_array in every ELF system, the registration tables in the Java runtime, and Rust’s #[ctor]. All of them are the same idea: publish the bounds of a set of things as data, so that adding to the set does not require editing the code that iterates it. And that is the general shape of the whole course. The first module asked what a name means. The second asked how a linker decides. The third asked how a loader decides with less information. The fourth asked how a name can be two things at once and how a linker can tell you where the program ends. Every one of those is a question about where a fact lives and who is allowed to see it, and that is the through-line from the first concept to this one.