Some code occupies one contiguous run of addresses. Most code does not. A function with an inlined subroutine inside it has a body, then the inlined body, then more of the original body — and a debugger asked "which source line is at address 0x2008" has to know that the middle stretch belongs to a different file and a different DW_TAG than the code on either side of it.
A single DW_AT_low_pc plus DW_AT_high_pc cannot express that. The format does not pretend otherwise: it gives the set of ranges as a table, and the attribute that points at it is DW_AT_ranges, whose form is an offset into .debug_rnglists. The table is a list of half-open address intervals, and the meaning is the same everywhere in DWARF: a DIE describes code over a set of ranges, not one address.
This is the second half of a pattern the previous concept started. .debug_loclists says where a value is, per code range. .debug_rnglists says what code is there. Same header, same idea, different question — and, as you will see, nearly the same bytes.
Two ways for a DIE to say "this applies here", and the choice between them is a question about how fragmented the code is:
| Attributes | Means |
|---|---|
DW_AT_low_pc + DW_AT_high_pc | One contiguous interval. Two numbers inline in the DIE, no table needed |
DW_AT_low_pc + DW_AT_ranges | Starts at low_pc, and the range table gives the intervals relative to it |
DW_AT_ranges alone | The table gives absolute base addresses itself, via DW_RLE_base_address |
The middle row is the one that confuses people, because it looks like the offsets in the table are absolute addresses and they are not. When low_pc is present, it is the base, and every offset_pair in the list is measured from it. A file that has both attributes and a reader that adds low_pc twice produces ranges that are correct-looking and wrong by a constant — which is the worst kind of wrong, because the table still has plausible intervals in it.
DW_RLE opcodesThey are numbered identically to the DW_LLE opcodes you just decoded, and they mean the same things:
| Byte | Opcode | Effect on a range list |
|---|---|---|
00 | DW_RLE_end_of_list | End the list |
01 | DW_RLE_base_addressx | Base := address table[index] |
02 | DW_RLE_startx_endx | Add the range address[index1], address[index2] |
03 | DW_RLE_startx_length | Add the range address[index], and that plus a length |
04 | DW_RLE_offset_pair | Add base+start, base+end |
05 | DW_RLE_base_address | Base := the literal address that follows |
06 | DW_RLE_start_end | Add the literal range that follows |
07 | DW_RLE_start_length | Add the literal start that follows, and that plus a length |
Eight opcodes, not nine: a range list has no default_location, because there is no default expression to fall back to. That is the only structural difference in the opcode set, and it is the whole difference between the two sections' tables.
DW_RLE_offset_pair in DWARF 4. Before version 5, ranges lived in .debug_ranges and used a completely different set of opcodes (DW_RLE_end_of_list and DW_RLE_base_addressx existed, but the interval entries did not). Since the version 5 opcodes overlap numerically with the old ones, a reader that skips the version field will parse version-4 data using version-5 meanings and produce a table of intervals that are individually well-formed and collectively wrong. Same trap as the previous concept, same defence: gate on the version.Take inline_O2.o — the object file built from inline.c at -O2, where a function was inlined. It is the smallest case on this machine that actually has range lists:
0000: 4a 00 00 00 05 00 08 00 00 00 00 00 05 00 00 00 0010: 00 00 00 00 00 04 00 12 04 12 1a 04 22 30 04 38 0020: 3c 00 05 00 00 00 00 00 00 00 00 04 00 00 04 08 0030: 10 04 1e 26 04 2a 2e 00 07 00 00 00 00 00 00 00 0040: 00 89 01 07 00 00 00 00 00 00 00 00 0a 00
Seventy-eight bytes, and every one of them is accounted for. The header is twelve bytes, identical in layout to the loclists header from the previous concept:
0000: 4a 00 00 00 unit_length = 74 0004: 05 00 version = 5 0006: 08 address_size = 8 0007: 00 segment_selector_size = 0 0008: 00 00 00 00 offset_entry_count = 0 000c: <- the first range entry starts here
And the DW_AT_ranges attribute in the inlined subroutine's DIE reads 0xc — pointing at exactly that first byte. Now walk the list:
000c: 05 DW_RLE_base_address 000d: 00 00 00 00 00 00 00 00 base = 0 (a relocation supplies the real value) 0015: 04 00 12 DW_RLE_offset_pair (0, 18) 0018: 04 12 1a DW_RLE_offset_pair (18, 26) 001b: 04 22 30 DW_RLE_offset_pair (34, 48) 001e: 04 38 3c DW_RLE_offset_pair (56, 60) 0021: 00 DW_RLE_end_of_list
Now the part that trips people up. The byte after that end_of_list is 05 at 0x22, which is a second DW_RLE_base_address. And after that list ends at 0x37, a third list begins at 0x38. So this section holds three independent tables, back to back:
0022: 05 DW_RLE_base_address 0023: 00 00 00 00 00 00 00 00 base = 0 (relocated again) 002b: 04 00 00 DW_RLE_offset_pair (0, 0) 002e: 04 08 10 DW_RLE_offset_pair (8, 16) 0031: 04 1e 26 DW_RLE_offset_pair (30, 38) 0034: 04 2a 2e DW_RLE_offset_pair (42, 46) 0037: 00 DW_RLE_end_of_list 0038: 07 DW_RLE_start_length 0039: 00 00 00 00 00 00 00 00 start address = 0 (relocated) 0041: 89 01 length = 137 (a ULEB128 with two bytes) 0043: 07 DW_RLE_start_length 0044: 00 00 00 00 00 00 00 00 start address = 0 (relocated) 004c: 0a length = 10 (a single-byte ULEB128) 004d: 00 DW_RLE_end_of_list
That is worth internalising: DW_AT_ranges points at the start of one list, and running off its end_of_list lands you on another DIE's list, not on the end of the section. Three tables, no table of contents, and nothing in the file says how many there are. A decoder that treats the section as one stream merges three unrelated tables into one and produces a set of intervals that belong to no single construct.
Notice also that the third list uses a different opcode family. The first two express ranges as offsets from a base; the third writes literal start addresses with lengths. Both are legal, in the same file, in the same section. And the length in the first entry is 89 01 — two bytes, because 0x89 has its high bit set and the value continues in 0x01: 0x09 | (0x01 << 7) = 137. The second is 0a = 10 in one byte. ULEB128 is variable-width in the middle of a list, not just at its head.
The eight bytes at 0x0d are all zero, and the real base address is not zero. This is an object file: it has not been placed anywhere yet. The value is supplied by a relocation, and the section has its own relocation section:
$ readelf -S -W inline_O2.o | grep rnglists
[12] .debug_rnglists PROGBITS 0000000000000000 0004dc 00004e 00 0 0 1
[13] .rela.debug_rnglists RELA 0000000000000000 000ce8 000060 18 I 13 12 8Note the size: 0x4e = 78 bytes, matching the hex dump exactly, and 0x60 = 96 bytes of relocations — eight 24-byte Elf64_Rela entries. The same lesson as the split-debugging concept: in a relocatable object, an address in a debug section is not a number, it is a placeholder for a number.
So when you read this table with a tool, the answer depends on whether the tool applied the relocations. llvm-dwarfdump does, and prints the resolved intervals:
$ llvm-dwarfdump --debug-rnglists inline_O2.o
range list header: length = 0x0000004a, format = DWARF32, version = 0x0005,
addr_size = 0x08, seg_size = 0x00, offset_entry_count = 0x00000000and readelf --debug-dump=rnglists prints nothing at all for this file. Two reference tools, one of them useful and one of them silent — and a third reader, the one shipped with this course, that walks the raw bytes. All three agree on the version, the address size, the count, and the three list boundaries. The intervals themselves can only be checked after relocations are applied, so on the object file that claim is not independently verifiable — which is exactly why the linked binary is the right place to read ranges from.
Look at the four intervals the table encodes, as offsets from the base: 0–18, 18–26, 34–48, 56–60. Those are four separate runs of code attributed to one DIE, with gaps at 26–34 and 48–56 that belong to something else. That is the signature of inlined code: the compiler has interleaved the inlined subroutine's instructions with the caller's, so no single contiguous interval describes them. Had the compiler not inlined, this DIE would have carried low_pc and high_pc and this section would not exist at all.
One more detail that is easy to miss: a range list is not sorted, and not necessarily non-adjacent. The format permits any order, and a reader that binary-searches the table for a target address will be wrong. The tables this compiler emits happen to be ascending, which is a property of the producer, not of the format. A debugger that stops at end_of_list and gives up has also stopped early — ranges can continue in a different list reached through a base-address entry.
Given an address, find the DIE that describes the code there. The naive approach — walk every subprogram DIE, read its range, test containment — works but is quadratic in the number of DIEs. The table exists so you can do better, and the improvement is the practical reason to care about the format.
First, the containment test itself, with the same half-open rule as location lists:
Then the part that makes it fast. Ranges are stored relative to a base, and the base is constant across a run of entries. So a table is really a sequence of segments: a base, then a batch of intervals relative to it. If you build, per base, a sorted list of intervals, then one binary search over the segment whose base is nearest below the target finds the answer. Building that index once per compilation unit is what turns a linear scan over every DIE into a lookup.
Now put the two list types together, because they answer the same question at different levels:
Each arrow is a different section and a different lookup algorithm, and all of them are keyed on the same address. That is the architecture of a debugger, and it is worth being able to state it from memory: the address is the join key, and the sections are indexed by it in different ways. The bug you will spend the most time on is almost always in the first arrow, because an answer from the wrong DIE looks exactly like an answer from the right one until you check a value against a breakpoint you set by hand.
$ llvm-dwarfdump --debug-rnglists inline_O2.o
$ readelf --debug-dump=info inline_O2.o | grep -B4 DW_AT_ranges
$ readelf -r -W inline_O2.o | grep -A4 debug_rnglistsinline_O2.o into an executable, then run llvm-dwarfdump --debug-rnglists on the executable. The base addresses will be real, and you can check each resolved interval against the objdump -d disassembly — do the four ranges correspond to four real runs of instructions, with real gaps between them?.debug_rnglists, apply them to the two DW_RLE_base_address fields yourself, and check you get the same intervals the tool prints. This is the same exercise as for addresses in .debug_info, on a section you have not seen before, and it is the general skill.base_address and offset_pair in the first two lists, start_length in the third. Still unobserved here are DW_RLE_start_end and the three index forms base_addressx, startx_endx and startx_length, which need a non-zero offset_entry_count that no configuration on this machine produces. Keep a list of the opcodes you have never seen produced; that list is the honest boundary of what you have tested.low_pc double-count. Find a DIE that has both DW_AT_low_pc and DW_AT_ranges. Add the offsets to low_pc, then also add it a second time, and see which of the two answers puts the code in a function that disassembles sensibly. That is the fastest way to internalise what "relative to" means.Without looking back: a DIE carries DW_AT_ranges: 0xc and no DW_AT_low_pc. The byte at offset 0xc in the section is 05. What does that one byte commit the reader to, and what would a reader get wrong if it assumed the following pairs were absolute addresses?
A colleague sends you a bug report: your tool shows the wrong source line for roughly one function in a large binary, and only in release builds. Debug builds are perfect. You suspect range lists. You have the object files and the linked binary. Lay out the investigation in the order you would actually run it, and say what result would move you to the next hypothesis.
The general habit: when a bug appears in only one build configuration, let that asymmetry choose the investigation. Debug and release differ in a small, enumerable set of things, and the difference that matters is almost always the one the format was designed to handle.
Range lists and location lists are the same table with one opcode missing, and the reason the format did that is worth holding onto: the line number program, which you decoded in Module 2, is a third such table, and it too is a list of entries terminated by a zero byte with a header that declares its own version. Three tables, one shape, three different questions. Recognising the shape is most of what it takes to read a fourth.
The low_pc half of this is the same idea as relocation processing. A low_pc in a .o is zero for the same reason the DW_RLE_base_address here is zero: the code has not been placed. That is why relocations are not an ELF-course topic with a DWARF footnote — they are the mechanism on which every address in every debug section depends.
Both list sections are now decoded, which closes the question of "where is a thing in the program". What remains is the question of whether the debug info is portable at all, and the next concept answers it by producing debug info that is deliberately not what this machine normally makes.