Static Library Link Order in Embedded Builds: Diagnose Undefined Symbols Before Adding Flags
Suspect static archive order when the missing symbol is defined in a selected archive, but that archive was scanned before the reference became unresolved. Prove the relationship with the actual link command and symbol tables. Then reorder the dependency, or use a narrowly scoped archive group for a genuine cycle. Adding every linker flag that has fixed another project hides the dependency you need to understand.
This article concerns GNU-style static archives linked with GNU BFD ld. The documentation baseline is GCC 14.2 and GNU Binutils 2.43; the small reproducer below was also executed with GCC 14.2.0 and GNU BFD ld 2.44 on a Linux host. It was not cross-tested on an embedded target. Other linkers, link-time optimization, and vendor toolchains require their own verification.
1. Draw the unresolved-symbol graph
Imagine a hypothetical firmware utility where main.o calls send_packet, implemented in libprotocol.a. That implementation calls crc_step, provided by libcrc.a. The dependency arrows point from caller to provider. They suggest the candidate order: application object, protocol archive, checksum archive.
GCC’s link-options documentation states that library position matters because inputs are processed in command-line order. For an ordinary BFD archive search, members are selected to satisfy unresolved references already encountered. An archive is not equivalent to listing every one of its object files unconditionally.

Do not generalize this into “all libraries must always be last” without checking the graph. A chain can be ordered; a cycle needs additional handling. The useful unit of investigation is a symbol reference and the archive member providing it, not just a library’s friendly package name.
2. Reproduce the distinction with three tiny files
Create these files in a disposable build directory. The function named crc_step deliberately performs a toy XOR; it is not a CRC implementation or production protocol code.
/* main.c */
int send_packet(void);
int main(void) { return send_packet() == 93 ? 0 : 1; }
/* protocol.c */
int crc_step(int);
int send_packet(void) { return crc_step(7); }
/* crc.c */
int crc_step(int value) { return value ^ 0x5a; }
Compile and package them, then compare only the archive order:
gcc -c main.c protocol.c crc.c
ar rcs libprotocol.a protocol.o
ar rcs libcrc.a crc.o
gcc -fuse-ld=bfd main.o -L. -lcrc -lprotocol -o wrong-order
gcc -fuse-ld=bfd main.o -L. -lprotocol -lcrc -Wl,-Map=right.map -o right-order
./right-order
Test status: these commands were executed on the host versions stated above. The reversed order failed with an undefined reference to crc_step. The dependency order linked, and the program returned zero. This establishes the example’s behavior; it does not qualify a board image, startup code, target ABI, or production checksum.
At the first archive in the failing command, the unresolved application request is send_packet. The checksum archive does not satisfy it. Extracting the protocol member later creates the reference to crc_step, after the checksum archive’s ordinary search. That is different from a provider that was never built.
3. Inspect the failing build before changing it
Capture the complete final command using the build system’s verbose mode. Expand response-file contents for review. Record the compiler driver, selected linker, object order, archive paths, and relevant options. Do not diagnose from a shortened error line while an IDE or wrapper silently rearranges inputs.
nm -A -u libprotocol.a
nm -A --defined-only libcrc.a
The tested example reports an undefined crc_step in the protocol member and a text definition in the checksum member. The GNU nm reference documents undefined-symbol filtering, defined-symbol filtering, archive/member labels, and demangling. Use the target toolchain’s symbol utility for target archives. Plugin-backed LTO objects may require the corresponding toolchain wrapper or plugin.
Keep the raw symbol spelling as well as any demangled display. A familiar-looking C++ name is not proof that its linkage identity matches the caller. Also confirm that the inspected archive is the one selected by the command. For this investigation, explicit archive filenames can remove ambiguity between static and shared library selection.
4. Choose the smallest justified correction
- No definition in the intended archive: inspect source inclusion, feature configuration, and archive contents. Reordering cannot create an implementation.
- Matching definition, provider scanned too early: move the provider after its consumer and regenerate the link map.
- Matching-looking names but different raw symbols: resolve the linkage or declaration mismatch before experimenting with archive groups.
- Linker rejects the member format or architecture: stop treating the failure as ordering. Investigate artifact compatibility separately.
- References form a cycle: identify the smallest cyclic archive set and test a scoped group.
Change the dependency declaration in the maintained build description once the minimal experiment works. Avoid leaving an unexplained hand-edited final command as the fix. Require a clean regeneration of that command so a future build-system update cannot silently restore the broken order.
5. Recognize a real cycle
Extend the hypothetical example: the checksum member calls audit_hook, while that hook lives in a separate, previously unneeded member of the protocol archive. The chain now returns to an earlier archive. Listing the two archives once in the original order leaves the hook unresolved.
The GNU ld 2.43 options reference defines --start-group and --end-group to rescan the enclosed archives until further extraction creates no new unresolved references. Grouping has a link-time cost, so reserve it for the cyclic dependency set.
gcc -fuse-ld=bfd main.o -Wl,--start-group libprotocol-cycle.a libcrc-cycle.a -Wl,--end-group -o cycle-group
Test status: a separate local extension implementing that hook was executed with the same host tools. The ordinary two-archive link failed on audit_hook; the grouped link succeeded and returned zero. These cycle archives are distinct from the three-file example. The command above requires that extension and is not a replacement command for the earlier filenames.
6. Do not confuse extraction with retention
The same ld reference distinguishes --whole-archive, which includes every archive member, from group rescanning. It changes selection semantics and can pull in unwanted objects. If intentionally used, bound it with --no-whole-archive. It is not the default response to an unexplained undefined symbol.
A registration-only object with no referenced entry symbol presents a different requirement: decide how the product deliberately anchors that object. Likewise, section garbage collection and shared-library dependency handling need their own evidence. Keep those investigations separate instead of explaining every disappearance with the archive-order model.
7. Finish with a reproducible dependency correction
Retain the original failing link, the minimal corrected link, raw symbol evidence, and the resulting map. Confirm the required member is selected and the expected behavior still works. Rebuild the relevant configurations, especially feature combinations that add another provider or remove the original caller. The acceptance criterion is a documented dependency relationship that survives a clean build, not merely the disappearance of one error message.