Arm debugging with the simulator

The simulator and debugger can be very helpful when working with V8 code generation.

Please note that this simulator is designed for V8 purposes. Only the features used by V8 are implemented, and you might encounter unimplemented features or instructions. In this case, feel free to implement them and submit the code!

Compiling for Arm using the simulator #

By default on an x86 host, compiling for Arm with gm will give you a simulator build:

gm arm64.debug # For a 64-bit build or...
gm arm.debug # ... for a 32-bit build.

You may also build the optdebug configuration as the debug may be a little slow, especially if you want to run the V8 test suite.

Starting the debugger #

You can start the debugger immediately from the command line after n instructions:

out/arm64.debug/d8 --stop_sim_at <n> # Or out/arm.debug/d8 for a 32-bit build.

Alternatively, you can generate a breakpoint instruction in the generated code:

Natively, breakpoint instructions cause the program to halt with a SIGTRAP signal, allowing you to debug the issue with gdb. However, if running with a simulator, a breakpoint instruction in generated code will instead drop you into the simulator debugger.

You can generate a breakpoint in multiple ways by using DebugBreak() from Torque, from the CodeStubAssembler, as a node in a TurboFan pass, or directly using an assembler.

Here we focus on debugging low-level native code, so let's look at the assembler method:

TurboAssembler::DebugBreak();

Let's say we have a jitted function called add compiled with TurboFan and we'd like to break at the start. Given a test.js example:

// Our optimized function.
function add(a, b) {
return a + b;
}

// Typical cheat code enabled by --allow-natives-syntax.
%PrepareFunctionForOptimization(add);

// Give the optimizing compiler type feedback so it'll speculate `a` and `b` are
// numbers.
add(1, 3);

// And force it to optimize.
%OptimizeFunctionOnNextCall(add);
add(5, 7);

To do it, we can hook into TurboFan's code generator and access the assembler to insert our breakpoint:

void CodeGenerator::AssembleCode() {
// ...

// Check if we're optimizing, then look-up the name of the current function and
// insert a breakpoint.
if (info->IsOptimizing()) {
AllowHandleDereference allow_handle_dereference;
if (info->shared_info()->PassesFilter("add")) {
tasm()->DebugBreak();
}
}

// ...
}

And let's run it:

$ d8 \
# Enable '%' cheat code JS functions.
--allow-natives-syntax \
# Disassemble our function.
--print-opt-code --print-opt-code-filter="add" --code-comments \
# Disable spectre mitigations for readability.
--no-untrusted-code-mitigations \
test.js
--- Raw source ---
(a, b) {
return a + b;
}


--- Optimized code ---
optimization_id = 0
source_position = 12
kind = OPTIMIZED_FUNCTION
name = add
stack_slots = 6
compiler = turbofan
address = 0x7f0900082ba1

Instructions (size = 504)
0x7f0900082be0 0 d45bd600 constant pool begin (num_const = 6)
0x7f0900082be4 4 00000000 constant
0x7f0900082be8 8 00000001 constant
0x7f0900082bec c 75626544 constant
0x7f0900082bf0 10 65724267 constant
0x7f0900082bf4 14 00006b61 constant
0x7f0900082bf8 18 d45bd7e0 constant
-- Prologue: check code start register --
0x7f0900082bfc 1c 10ffff30 adr x16, #-0x1c (addr 0x7f0900082be0)
0x7f0900082c00 20 eb02021f cmp x16, x2
0x7f0900082c04 24 54000080 b.eq #+0x10 (addr 0x7f0900082c14)
Abort message:
Wrong value in code start register passed
0x7f0900082c08 28 d2800d01 movz x1, #0x68
-- Inlined Trampoline to Abort --
0x7f0900082c0c 2c 58000d70 ldr x16, pc+428 (addr 0x00007f0900082db8) ;; off heap target
0x7f0900082c10 30 d63f0200 blr x16
-- Prologue: check for deoptimization --
[ DecompressTaggedPointer
0x7f0900082c14 34 b85d0050 ldur w16, [x2, #-48]
0x7f0900082c18 38 8b100350 add x16, x26, x16
]
0x7f0900082c1c 3c b8407210 ldur w16, [x16, #7]
0x7f0900082c20 40 36000070 tbz w16, #0, #+0xc (addr 0x7f0900082c2c)
-- Inlined Trampoline to CompileLazyDeoptimizedCode --
0x7f0900082c24 44 58000c31 ldr x17, pc+388 (addr 0x00007f0900082da8) ;; off heap target
0x7f0900082c28 48 d61f0220 br x17
-- B0 start (construct frame) --
(...)

--- End code ---
# Debugger hit 0: DebugBreak
0x00007f0900082bfc 10ffff30 adr x16, #-0x1c (addr 0x7f0900082be0)
sim>

We can see we've stopped at the start of the optimized function and the simulator gave us a prompt!

Note this is just an example and V8 changes quickly so the details may vary. But you should be able to do this anywhere where an assembler is available.

Debugging commands #

Common commands #

Enter help in the debugger prompt to get details on available commands. These include usual gdb-like commands, such as stepi, cont, disasm, etc. If the Simulator is run under gdb, the gdb debugger command will give control to gdb. You can then use cont from gdb to go back to the debugger.

Architecture specific commands #

Each target architecture implements its own simulator and debugger, so the experience and details will vary.

printobject $register (alias po) #

Describe a JS object held in a register.

For example, let's say this time we're running our example on a 32-bit Arm simulator build. We can examine incoming arguments passed in registers:

$ ./out/arm.debug/d8 --allow-natives-syntax test.js
Simulator hit stop, breaking at the next instruction:
0x26842e24 e24fc00c sub ip, pc, #12
sim> print r1
r1: 0x4b60ffb1 1264648113
# The current function object is passed with r1.
sim> printobject r1
r1:
0x4b60ffb1: [Function] in OldSpace
- map: 0x485801f9 <Map(HOLEY_ELEMENTS)> [FastProperties]
- prototype: 0x4b6010f1 <JSFunction (sfi = 0x42404e99)>
- elements: 0x5b700661 <FixedArray[0]> [HOLEY_ELEMENTS]
- function prototype:
- initial_map:
- shared_info: 0x4b60fe9d <SharedFunctionInfo add>
- name: 0x5b701c5d <String[#3]: add>
- formal_parameter_count: 2
- kind: NormalFunction
- context: 0x4b600c65 <NativeContext[261]>
- code: 0x26842de1 <Code OPTIMIZED_FUNCTION>
- source code: (a, b) {
return a + b;
}
(...)

# Now print the current JS context passed in r7.
sim> printobject r7
r7:
0x449c0c65: [NativeContext] in OldSpace
- map: 0x561000b9 <Map>
- length: 261
- scope_info: 0x34081341 <ScopeInfo SCRIPT_SCOPE [5]>
- previous: 0
- native_context: 0x449c0c65 <NativeContext[261]>
0: 0x34081341 <ScopeInfo SCRIPT_SCOPE [5]>
1: 0
2: 0x449cdaf5 <JSObject>
3: 0x58480c25 <JSGlobal Object>
4: 0x58485499 <Other heap object (EMBEDDER_DATA_ARRAY_TYPE)>
5: 0x561018a1 <Map(HOLEY_ELEMENTS)>
6: 0x3408027d <undefined>
7: 0x449c75c1 <JSFunction ArrayBuffer (sfi = 0x4be8ade1)>
8: 0x561010f9 <Map(HOLEY_ELEMENTS)>
9: 0x449c967d <JSFunction arrayBufferConstructor_DoNotInitialize (sfi = 0x4be8c3ed)>
10: 0x449c8dbd <JSFunction Array (sfi = 0x4be8be59)>
(...)

trace (alias t) #

Enable or disable tracing executed instructions.

When enabled, the simulator will print disassembled instructions as it is executing them. If you're running a 64-bit Arm build, the simulator is also able to trace changes to register values.

You may also enable this from the command-line with the --trace-sim flag to enable tracing from the start.

With the same example:

$ out/arm64.debug/d8 --allow-natives-syntax \
# --debug-sim is required on 64-bit Arm to enable disassembly
# when tracing.
--debug-sim test.js
# Debugger hit 0: DebugBreak
0x00007f1e00082bfc 10ffff30 adr x16, #-0x1c (addr 0x7f1e00082be0)
sim> trace
0x00007f1e00082bfc 10ffff30 adr x16, #-0x1c (addr 0x7f1e00082be0)
Enabling disassembly, registers and memory write tracing

# Break on the return address stored in the lr register.
sim> break lr
Set a breakpoint at 0x7f1f880abd28
0x00007f1e00082bfc 10ffff30 adr x16, #-0x1c (addr 0x7f1e00082be0)

# Continuing will trace the function's execution until we return, allowing
# us to make sense of what is happening.
sim> continue
# x0: 0x00007f1e00082ba1
# x1: 0x00007f1e08250125
# x2: 0x00007f1e00082be0
(...)

# We first load the 'a' and 'b' arguments from the stack and check if they
# are tagged numbers. This is indicated by the least significant bit being 0.
0x00007f1e00082c90 f9401fe2 ldr x2, [sp, #56]
# x2: 0x000000000000000a <- 0x00007f1f821f0278
0x00007f1e00082c94 7200005f tst w2, #0x1
# NZCV: N:0 Z:1 C:0 V:0
0x00007f1e00082c98 54000ac1 b.ne #+0x158 (addr 0x7f1e00082df0)
0x00007f1e00082c9c f9401be3 ldr x3, [sp, #48]
# x3: 0x000000000000000e <- 0x00007f1f821f0270
0x00007f1e00082ca0 7200007f tst w3, #0x1
# NZCV: N:0 Z:1 C:0 V:0
0x00007f1e00082ca4 54000a81 b.ne #+0x150 (addr 0x7f1e00082df4)

# Then we untag and add 'a' and 'b' together.
0x00007f1e00082ca8 13017c44 asr w4, w2, #1
# x4: 0x0000000000000005
0x00007f1e00082cac 2b830484 adds w4, w4, w3, asr #1
# NZCV: N:0 Z:0 C:0 V:0
# x4: 0x000000000000000c
# That's 5 + 7 == 12, all good!

# Then we check for overflows and tag the result again.
0x00007f1e00082cb0 54000a46 b.vs #+0x148 (addr 0x7f1e00082df8)
0x00007f1e00082cb4 2b040082 adds w2, w4, w4
# NZCV: N:0 Z:0 C:0 V:0
# x2: 0x0000000000000018
0x00007f1e00082cb8 54000466 b.vs #+0x8c (addr 0x7f1e00082d44)


# And finally we place the result in x0.
0x00007f1e00082cbc aa0203e0 mov x0, x2
# x0: 0x0000000000000018
(...)

0x00007f1e00082cec d65f03c0 ret
Hit and disabled a breakpoint at 0x7f1f880abd28.
0x00007f1f880abd28 f85e83b4 ldur x20, [fp, #-24]
sim>

break $address #

Inserts a breakpoint at the specified address.

Note that on 32-bit Arm, you can have only one breakpoint and you'll need to disable write protection on code pages to insert it. The 64-bit Arm simulator does not have such restrictions.

With our example again:

$ out/arm.debug/d8 --allow-natives-syntax \
# This is useful to know which address to break to.
--print-opt-code --print-opt-code-filter="add" \
test.js
(...)

Simulator hit stop, breaking at the next instruction:
0x488c2e20 e24fc00c sub ip, pc, #12

# Break on a known interesting address, where we start
# loading 'a' and 'b'.
sim> break 0x488c2e9c
sim> continue
0x488c2e9c e59b200c ldr r2, [fp, #+12]

# We can look-ahead with 'disasm'.
sim> disasm 10
0x488c2e9c e59b200c ldr r2, [fp, #+12]
0x488c2ea0 e3120001 tst r2, #1
0x488c2ea4 1a000037 bne +228 -> 0x488c2f88
0x488c2ea8 e59b3008 ldr r3, [fp, #+8]
0x488c2eac e3130001 tst r3, #1
0x488c2eb0 1a000037 bne +228 -> 0x488c2f94
0x488c2eb4 e1a040c2 mov r4, r2, asr #1
0x488c2eb8 e09440c3 adds r4, r4, r3, asr #1
0x488c2ebc 6a000037 bvs +228 -> 0x488c2fa0
0x488c2ec0 e0942004 adds r2, r4, r4

# And try and break on the result of the first `adds` instructions.
sim> break 0x488c2ebc
setting breakpoint failed

# Ah, we need to delete the breakpoint first.
sim> del
sim> break 0x488c2ebc
sim> cont
0x488c2ebc 6a000037 bvs +228 -> 0x488c2fa0

sim> print r4
r4: 0x0000000c 12
# That's 5 + 7 == 12, all good!

Generated breakpoint instuctions with a few additional features #

Instead of TurboAssembler::DebugBreak(), you may use a lower-level instruction which has the same effect except with additional features.

stop() (32-bit Arm) #

Assembler::stop(Condition cond = al, int32_t code = kDefaultStopCode);

The first argument is the condition and the second is the stop code. If a code is specified, and is less than 256, the stop is said to be “watched”, and can be disabled/enabled; a counter also keeps track of how many times the Simulator hits this code.

Imagine we are working on this V8 C++ code:

__ stop(al, 123);
__ mov(r0, r0);
__ mov(r0, r0);
__ mov(r0, r0);
__ mov(r0, r0);
__ mov(r0, r0);
__ stop(al, 0x1);
__ mov(r1, r1);
__ mov(r1, r1);
__ mov(r1, r1);
__ mov(r1, r1);
__ mov(r1, r1);

Here's a sample debugging session:

We hit the first stop.

Simulator hit stop 123, breaking at the next instruction:
0xb53559e8 e1a00000 mov r0, r0

We can see the following stop using disasm.

sim> disasm
0xb53559e8 e1a00000 mov r0, r0
0xb53559ec e1a00000 mov r0, r0
0xb53559f0 e1a00000 mov r0, r0
0xb53559f4 e1a00000 mov r0, r0
0xb53559f8 e1a00000 mov r0, r0
0xb53559fc ef800001 stop 1 - 0x1
0xb5355a00 e1a00000 mov r1, r1
0xb5355a04 e1a00000 mov r1, r1
0xb5355a08 e1a00000 mov r1, r1

Information can be printed for all (watched) stops which were hit at least once.

sim> stop info all
Stop information:
stop 123 - 0x7b: Enabled, counter = 1
sim> cont
Simulator hit stop 1, breaking at the next instruction:
0xb5355a04 e1a00000 mov r1, r1
sim> stop info all
Stop information:
stop 1 - 0x1: Enabled, counter = 1
stop 123 - 0x7b: Enabled, counter = 1

Stops can be disabled or enabled. (Only available for watched stops.)

sim> stop disable 1
sim> cont
Simulator hit stop 123, breaking at the next instruction:
0xb5356808 e1a00000 mov r0, r0
sim> cont
Simulator hit stop 123, breaking at the next instruction:
0xb5356c28 e1a00000 mov r0, r0
sim> stop info all
Stop information:
stop 1 - 0x1: Disabled, counter = 2
stop 123 - 0x7b: Enabled, counter = 3
sim> stop enable 1
sim> cont
Simulator hit stop 1, breaking at the next instruction:
0xb5356c44 e1a00000 mov r1, r1
sim> stop disable all
sim> con

Debug() (64-bit Arm) #

MacroAssembler::Debug(const char* message, uint32_t code, Instr params = BREAK);

This instruction is a breakpoint by default, but is also able to enable and disable tracing as if you had done it with the trace command in the debugger. You can also give it a message and a code as an identifier.

Imagine we are working on this V8 C++ code, taken from the native builtin that prepares the frame to call a JS function.

int64_t bad_frame_pointer = -1L;  // Bad frame pointer, should fail if it is used.
__ Mov(x13, bad_frame_pointer);
__ Mov(x12, StackFrame::TypeToMarker(type));
__ Mov(x11, ExternalReference::Create(IsolateAddressId::kCEntryFPAddress,
masm->isolate()));
__ Ldr(x10, MemOperand(x11));

__ Push(x13, x12, xzr, x10);

It might be useful to insert a breakpoint with DebugBreak() so we can examine the current state when we run this. But we can go further and trace this code if we use Debug() instead:

// Start tracing and log disassembly and register values.
__ Debug("start tracing", 42, TRACE_ENABLE | LOG_ALL);

int64_t bad_frame_pointer = -1L; // Bad frame pointer, should fail if it is used.
__ Mov(x13, bad_frame_pointer);
__ Mov(x12, StackFrame::TypeToMarker(type));
__ Mov(x11, ExternalReference::Create(IsolateAddressId::kCEntryFPAddress,
masm->isolate()));
__ Ldr(x10, MemOperand(x11));

__ Push(x13, x12, xzr, x10);

// Stop tracing.
__ Debug("stop tracing", 42, TRACE_DISABLE);

It allows us to trace register values for just the snippet of code we're working on:

$ d8 --allow-natives-syntax --debug-sim test.js
# NZCV: N:0 Z:0 C:0 V:0
# FPCR: AHP:0 DN:0 FZ:0 RMode:0b00 (Round to Nearest)
# x0: 0x00007fbf00000000
# x1: 0x00007fbf0804030d
# x2: 0x00007fbf082500e1
(...)

0x00007fc039d31cb0 9280000d movn x13, #0x0
# x13: 0xffffffffffffffff
0x00007fc039d31cb4 d280004c movz x12, #0x2
# x12: 0x0000000000000002
0x00007fc039d31cb8 d2864110 movz x16, #0x3208
# ip0: 0x0000000000003208
0x00007fc039d31cbc 8b10034b add x11, x26, x16
# x11: 0x00007fbf00003208
0x00007fc039d31cc0 f940016a ldr x10, [x11]
# x10: 0x0000000000000000 <- 0x00007fbf00003208
0x00007fc039d31cc4 a9be7fea stp x10, xzr, [sp, #-32]!
# sp: 0x00007fc033e81340
# x10: 0x0000000000000000 -> 0x00007fc033e81340
# xzr: 0x0000000000000000 -> 0x00007fc033e81348
0x00007fc039d31cc8 a90137ec stp x12, x13, [sp, #16]
# x12: 0x0000000000000002 -> 0x00007fc033e81350
# x13: 0xffffffffffffffff -> 0x00007fc033e81358
0x00007fc039d31ccc 910063fd add fp, sp, #0x18 (24)
# fp: 0x00007fc033e81358
0x00007fc039d31cd0 d45bd600 hlt #0xdeb0