The previous concept gave you a location expression: a short program that computes where a variable is. It works because it is evaluated at one instant, with the register state of one machine at one program point.
That assumption breaks in a real optimised function. Consider a parameter that arrives in rdi at function entry, gets spilled to the stack somewhere in the middle of the body, and is loaded into rsi near the end. There is no single expression that is true for the whole function. There are three, and each is true for a different range of instruction addresses.
A format that could only say DW_OP_reg5 would have to lie to a debugger. DWARF's answer is to split the attribute in two: the DIE keeps an offset into a table, and the table says "over this address range, this expression". The variable's name, type and scope stay in the DIE where a compiler front end can find them cheaply; only the per-address-range answers move into a side table that a debugger reads on demand.
First the shape. A DW_AT_location attribute is a form plus a value, and the form is what tells a reader how to interpret the value. You have already seen the exprloc form, where the value is the expression's own bytes. The loclist form looks the same but the value is an offset:
| What you see | Form | Value means |
|---|---|---|
DW_AT_location : 1 byte block: 55 | exprloc | The bytes 55 are the expression |
DW_AT_location : 0x24 (loclist) | loclistx or loclist | The number 0x24 is a byte offset into .debug_loclists |
Both readers in this course print the second form as (loclist) and agree on the offset. That agreement matters more than it sounds: it is the first thing to check, because a reader that misjudges the form will go looking in the wrong section entirely and report a location that looks valid.
Every list section starts with a header, and the header is not the same as the .debug_info CU header you decoded earlier. From types_O2:
0000: 5c 00 00 00 05 00 08 00 00 00 00 00 00 00 00 00 0010: 04 40 48 01 54 04 48 4e 04 a3 01 54 9f 00 02 03 0020: 03 00 00 00 04 44 44 05 55 93 04 93 04 04 44 48 0030: 06 55 93 04 54 93 04 04 48 4d 09 55 93 04 a3 01 0040: 54 9f 93 04 00 01 00 04 04 15 14 75 00 08 20 24 0050: 08 20 26 32 24 03 10 20 00 00 00 00 00 00 22 00
| Bytes | Field | Value |
|---|---|---|
5c 00 00 00 | unit_length | 92 — the whole header plus table is 96 bytes |
05 00 | version | 5. The form loclist and the DW_LLE opcodes exist only from version 5 |
08 | address_size | 8 |
00 | segment_selector_size | 0. Non-zero only on segmented targets, which are historical |
00 00 00 00 | offset_entry_count | 0 — and this is where the trouble starts |
.debug_loc, and its entries were different opcodes with different meanings. A DW_LLE stream and a .debug_loc stream are not distinguishable by looking at the bytes. Only the version field says which grammar applies. Any tool that decodes .debug_loclists without reading that field is guessing, and the failure is silent.DW_LLE opcodesEach entry is a one-byte opcode followed by operands, exactly like the line program's standard opcodes you decoded earlier. The nine you need:
| Byte | Opcode | Operands | Effect |
|---|---|---|---|
00 | DW_LLE_end_of_list | none | End this list. Also: an entry at a referenced offset that is 00 is an empty list |
01 | DW_LLE_base_addressx | ULEB128 index | Set the base address from the location's address table |
02 | DW_LLE_startx_endx | 2 ULEB128 indices | Range given as two address-table indices |
03 | DW_LLE_startx_length | ULEB128 + ULEB128 | Start from an index, run for a length |
04 | DW_LLE_offset_pair | 2 ULEB128 | Start and end as offsets from the current base |
05 | DW_LLE_default_location | exprloc | The fallback expression, used wherever no range matched |
06 | DW_LLE_base_address | address | Set the base address to a literal |
07 | DW_LLE_start_end | 2 addresses | A literal start and end address |
08 | DW_LLE_start_length | address + ULEB128 | A literal start and a length |
The distinction worth pausing on: start_end and offset_pair both describe a range, but one uses absolute addresses and the other uses offsets from a base. In a relocatable .o file, an absolute address is meaningless until the linker has placed the section, so producers that target relocatable output prefer the base-relative forms. This is the same reason .debug_info addresses come from relocations rather than being written down.
Three DIEs in types_O2 carry loclist offsets. Both readers name the same three: 0x10, 0x24, and 0x47. Two of them decode cleanly enough to walk by hand.
Starting at byte 0x10, which is 04 = DW_LLE_offset_pair:
0010: 04 40 48 01 54 DW_LLE_offset_pair (0x40, 0x48): DW_OP_reg4 RSI 0015: 04 48 4e 04 a3 01 54 9f 00 next entry, then 00 ends the list
Read it out. Opcode 0x04. Then a ULEB128 0x40 = 64, and a ULEB128 0x48 = 72 — those are the start and end offsets. Then the expression, an exprloc: a ULEB128 length 01, then one byte 54 = DW_OP_reg4 = rsi. The list is terminated by 00 at 0x1e.
So the complete statement about this variable is: for code between base+64 and base+72, the value is in rsi; nowhere else in this list is it anywhere. Compare that with the exprloc case in the previous concept, which said only "the value is in rsi" with no address qualifier at all. Same one-byte expression, entirely different reach.
0024: 04 44 44 05 55 93 04 93 04
04 44 48 06 55 93 04 54 93 04
04 48 4d 09 55 93 04 a3 01 54 9f 93 04 00
Three offset_pair entries and an end_of_list. Decoding the first: 04, then 44 44 — two single-byte ULEB128s, both 0x44 = 68, so a zero-length range at base+68. A zero-length range is not a mistake: it is how a producer says "at this one point, use the following expression, and do not extrapolate it". The expression is 05 55 93 04 93 04: a ULEB128 length of 5, then 55 = DW_OP_reg5 = rdi, then 93 04 = DW_OP_piece with operand 4, then 93 04 again — DW_OP_piece 4 twice.
What that says: this variable is eight bytes wide and lives in two registers. The first four bytes are in rdi, the next four bytes are in whatever the second DW_OP_piece applies to — and because the expression ended after the two piece opcodes with nothing on the stack, the second piece is undefined in this range. A reader that ignores DW_OP_piece reports the low four bytes as the whole value and is wrong by half the variable's size, silently, for every wide variable in the program.
The third entry is the interesting one. Its expression is 09 55 93 04 a3 01 54 9f 93 04: length 9, DW_OP_reg5, DW_OP_piece 4, then a3 01 = DW_OP_entry_value with a nested expression DW_OP_reg4, then 9f = DW_OP_stack_value, then DW_OP_piece 4. Read left to right: the low four bytes are in rdi; the high four bytes are the value rsi had on entry to the function, not its value now. DW_OP_entry_value asks the debugger to re-run a sub-expression against the entry register state. Without it, the high half is reported as whatever rsi happens to contain later, which is a different number every time you run the program.
$ readelf --debug-dump=loc types_O2 $ llvm-dwarfdump --debug-loclists types_O2
readelf reports its first entries at offsets 0x0c and 0x0e, describes them as "location view pair", then puts real entries at 0x10 and 0x15 and ends at 0x1d. llvm-dwarfdump reports lists at 0x0c, 0x0d, 0x0e, 0x0f (all empty), real entries at 0x10, then 0x1e, 0x23, 0x24, 0x45.0x0c the four bytes are 00 00 00 00. Byte 0x00 is DW_LLE_end_of_list in DWARF 5, so the list at 0x0c is empty and immediately terminated; the same is true at 0x0d, 0x0e and 0x0f. "Location view pair" is a DWARF 4 .debug_loc concept — it has no meaning in a DW_LLE stream. What readelf is doing is decoding a version-5 DW_LLE table with a version-4 view-pair grammar.readelf's offsets must be a sequential walk of the byte stream. Its second entry claims 0x0e and its next 0x10, but 0x10 is four bytes after 0x0e and the bytes there are 04 40 48 01 54 — an offset_pair with operands 0x40 and 0x48, a five-byte entry. The entry it skipped over was not a two-byte view pair. Meanwhile readelf's 0x1d "end of list" lands in the middle of llvm's 0x1e entry. Every llvm offset is consistent with the bytes; the readelf offsets are not.version == 5 and fail loudly otherwise. If you are comparing tools and they disagree about offsets rather than about values, the disagreement is about where entries are, which is checkable by hand from the hex, and the hex wins.offset_entry_count is forHere it is 0, which means there is no address table and no index-valued opcode can be used. The header reserves the field for a different layout: a table of start and end addresses at the front of the section, which lets DW_LLE_startx_endx name a range by index instead of writing two full addresses. A producer that wants the indirection writes a non-zero count, and a reader that assumes zero will read the address table as if it were entries. Like every other count in this format, the value tells you the shape of everything that follows.
What a debugger actually does when you ask for a variable's value at a given address. The loop is short, and every step is a decision the previous concept already gave you the vocabulary for:
Two things in that loop are worth naming. The range test is half-open: start <= target < end. A zero-length range like the one at 0x24 therefore matches nothing, which is exactly the point — it is a marker for a single instruction, and the debugger reaches it by scanning rather than by searching. And the base variable is the mechanism that makes offset_pair work: entries before the first base-setting opcode are relative to whatever base was established earlier, and a reader that resets base per list rather than per section mis-resolves every list that does not set its own.
Now the payoff, connecting to the earlier concepts. The expression you end up holding is 54 — one byte, DW_OP_reg4. You then read the register file, not memory. So a location list plus a location expression touches three subsystems that the format keeps deliberately separate: the DIE for the name and type, .debug_loclists for the address ranges, and the live machine state for the value. That separation is why the same debug info can describe a program that is not running yet, and why a debugger can show you a variable in a core dump where no register file exists except the one the dump preserved.
$ llvm-dwarfdump --debug-loclists types_O2
$ readelf --debug-dump=loc types_O2
$ readelf --debug-dump=info types_O2 | grep -A1 DW_AT_locationtypes_O2. Then hand-walk the first four bytes at 0x0c in your hex viewer and decide for yourself which reader is walking the stream correctly. The test that settles it: take each tool's reported offsets in order and check that each entry's byte length actually reaches the next one.DW_OP_piece that is not a whole variable. The list at 0x24 is a wide value in two registers. Identify which part of the source variable each piece covers — you can tell from the two piece sizes summing to the variable's DW_AT_byte_size.DW_OP_entry_value and work out what it needs. It is a nested expression evaluated against the function's entry register state, not the current one. To evaluate it you need the register values at function entry, which is not something the current state contains. Ask yourself where a debugger gets that, and what it does if the answer is "it doesn't know".DW_LLE_start_end or a non-zero offset_entry_count. This compiler emits offset_pair and leaves the count at zero. Try a different -gdwarf- version, a different target, or a hand-built .o, and check which of the nine opcodes you can actually provoke. Knowing which opcodes you have never seen is worth as much as knowing the ones you have..debug_loclists and check that your table's entry offsets agree with llvm-dwarfdump's. If they do not, the version check is the first thing to add.And a check on your own work: the section is 96 bytes, unit_length says 92, and the highest entry llvm-dwarfdump reports starts at 0x45. A table whose last entry starts 37 bytes before the end is not truncated — it has a long expression at the end. If your decoder runs out of input before 0x45, you have mis-sized an entry, most likely by reading a ULEB128 operand as a fixed-width one.
Without looking back: a DW_AT_location attribute in the -O2 build reads 0x24 (loclist). What is stored at offset 0x24, and what does a debugger have to do to use it?
You are writing a DWARF reader for a tool that has to work on binaries from many producers. It works perfectly on .debug_info and .debug_line. On DWARF 5 binaries it reports locations that are the right shape and the wrong value, and the wrongness is confined to variables that have a loclist — every exprloc variable is correct. The two reference readers on this machine also disagree about this same section, and you have verified the bytes yourself. What is the most likely cause, and what is the smallest change that would confirm it?
The general habit: when a failure is confined to one feature, suspect the thing unique to that feature, not the machinery it shares. And when two readers disagree, the bytes are the tie-breaker — check that each tool's reported offsets are reachable by sequentially consuming the bytes it claims.
Location lists answer where in the code a value is. The sibling question — where in the address space does a block of code live — is asked by the linker and the unwinder, and it has the same shape. DW_AT_ranges points into a .debug_rnglists table built from the same nine-opcode idea, and the next concept decodes one. The two sections share a header layout, a version-gated opcode set, and the same trap: they were introduced in DWARF 5 and replace something from DWARF 4, so a reader that does not check the version will happily produce a table of the wrong shape.
The DW_OP_entry_value opcode introduced here is the one place DWARF reaches backwards: it asks for an expression evaluated against the state at function entry, which is a machine-register concept. That is the same join as call frame information, which is how a debugger gets the register values at a call boundary in the first place.
Next: Range Lists.