""" Test that libunwind correctly injects 'ret' instructions to rebalance execution flow when unwinding C++ exceptions. This is important for Apple Processor Trace analysis. """ import lldb import os from lldbsuite.test.decorators import * from lldbsuite.test.lldbtest import * from lldbsuite.test import lldbutil from lldbsuite.test import configuration class LibunwindRetInjectionTestCase(TestBase): @skipIf(archs=no_match(["arm64", "arm64e", "aarch64"])) @skipUnlessDarwin @skipIfOutOfTreeLibunwind def test_ret_injection_on_exception_unwind(self): """Test that __libunwind_Registers_arm64_jumpto receives correct walkedFrames count and injects the right number of ret instructions.""" self.build() exe = self.getBuildArtifact("a.out") target = self.dbg.CreateTarget(exe) self.assertTrue(target, VALID_TARGET) # Find the just-built libunwind, not the system one. # llvm_tools_dir is typically /bin, so lib is a sibling. self.assertIsNotNone( configuration.llvm_tools_dir, "llvm_tools_dir must be set to find in-tree libunwind", ) llvm_lib_dir = os.path.join( os.path.dirname(configuration.llvm_tools_dir), "lib" ) # Find the libunwind library (platform-agnostic). libunwind_path = None for filename in os.listdir(llvm_lib_dir): if filename.startswith("libunwind.") or filename.startswith("unwind."): libunwind_path = os.path.join(llvm_lib_dir, filename) break self.assertIsNotNone( libunwind_path, f"Could not find libunwind in {llvm_lib_dir}" ) # Set breakpoint in __libunwind_Registers_arm64_jumpto. # This is the function that performs the actual jump and ret injection. bp = target.BreakpointCreateByName("__libunwind_Registers_arm64_jumpto") self.assertTrue(bp.IsValid()) self.assertGreater(bp.GetNumLocations(), 0) # Set up DYLD_INSERT_LIBRARIES to use the just-built libunwind. launch_info = lldb.SBLaunchInfo(None) env = target.GetEnvironment() env.Set("DYLD_INSERT_LIBRARIES", libunwind_path, True) launch_info.SetEnvironment(env, False) # Launch the process with our custom libunwind. error = lldb.SBError() process = target.Launch(launch_info, error) self.assertSuccess( error, f"Failed to launch process with libunwind at {libunwind_path}" ) self.assertTrue(process, PROCESS_IS_VALID) # We should hit the breakpoint in __libunwind_Registers_arm64_jumpto # during the exception unwinding phase 2. threads = lldbutil.get_threads_stopped_at_breakpoint(process, bp) self.assertEqual(len(threads), 1, "Should have stopped at breakpoint") thread = threads[0] frame = thread.GetFrameAtIndex(0) # Verify we're in __libunwind_Registers_arm64_jumpto. function_name = frame.GetFunctionName() self.assertTrue( "__libunwind_Registers_arm64_jumpto" in function_name, f"Expected to be in __libunwind_Registers_arm64_jumpto, got {function_name}", ) # On ARM64, the walkedFrames parameter should be in register x1 (second parameter). # According to the ARM64 calling convention, integer arguments are passed in x0-x7. # x0 = Registers_arm64* pointer. # x1 = unsigned walkedFrames. error = lldb.SBError() x1_value = frame.register["x1"].GetValueAsUnsigned(error) self.assertSuccess(error, "Failed to read x1 register") # According to the code in UnwindCursor.hpp, the walkedFrames value represents: # 1. The number of frames walked in unwind_phase2 to reach the landing pad. # 2. Plus _EXTRA_LIBUNWIND_FRAMES_WALKED = 5 - 1 = 4 additional libunwind frames. # # From the comment in the code: # frame #0: __libunwind_Registers_arm64_jumpto # frame #1: Registers_arm64::returnto # frame #2: UnwindCursor::jumpto # frame #3: __unw_resume # frame #4: __unw_resume_with_frames_walked # frame #5: unwind_phase2 # # Since __libunwind_Registers_arm64_jumpto returns to the landing pad, # we subtract 1, so _EXTRA_LIBUNWIND_FRAMES_WALKED = 4. # # For our test program: # - unwind_phase2 starts walking (frame 0 counted here). # - Walks through: func_d (throw site), func_c, func_b, func_a. # - Finds landing pad in main. # That's approximately 4-5 frames from the user code. # Plus the 4 extra libunwind frames. # # So we expect x1 to be roughly 8-10. expected_min_frames = 8 expected_max_frames = 13 # Allow some variation for libc++abi frames. self.assertGreaterEqual( x1_value, expected_min_frames, f"walkedFrames (x1) should be >= {expected_min_frames}, got {x1_value}. " "This is the number of 'ret' instructions that will be executed.", ) self.assertLessEqual( x1_value, expected_max_frames, f"walkedFrames (x1) should be <= {expected_max_frames}, got {x1_value}. " "Value seems too high.", ) # Now step through the ret injection loop and count the actual number of 'ret' executions. # The loop injects exactly x1_value ret instructions before continuing with register restoration. # We step until we hit the first 'ldp' instruction (register restoration starts with 'ldp x2, x3, [x0, #0x010]'). ret_executed_count = 0 max_steps = 100 # Safety limit to prevent infinite loops. for step_count in range(max_steps): # Get current instruction. pc = frame.GetPC() inst = process.ReadMemory(pc, 4, lldb.SBError()) # Disassemble current instruction. current_inst = target.GetInstructions(lldb.SBAddress(pc, target), inst)[0] mnemonic = current_inst.GetMnemonic(target) operands = current_inst.GetOperands(target) # Check if we've reached the register restoration part (first ldp after the loop). if mnemonic == "ldp": # We've exited the ret injection loop. break # Count 'ret' instructions that get executed. if mnemonic == "ret": self.assertEqual(operands, "x16") ret_executed_count += 1 # Step one instruction. thread.StepInstruction(False) # False = step over. # Update frame reference. frame = thread.GetFrameAtIndex(0) # Verify we didn't hit the safety limit. self.assertLess( step_count, max_steps - 1, f"Stepped {max_steps} times without reaching 'ldp' instruction. Something is wrong.", ) # The number of executed 'ret' instructions should match x1_value. # According to the implementation, the loop executes exactly x1_value times. self.assertEqual( ret_executed_count, x1_value, f"Expected {x1_value} 'ret' instructions to be executed (matching x1 register), " f"but counted {ret_executed_count} executed 'ret' instructions.", )