1#!/usr/bin/env python3 2# 3# Copyright (C) 2015 The Android Open Source Project 4# 5# Licensed under the Apache License, Version 2.0 (the "License"); 6# you may not use this file except in compliance with the License. 7# You may obtain a copy of the License at 8# 9# http://www.apache.org/licenses/LICENSE-2.0 10# 11# Unless required by applicable law or agreed to in writing, software 12# distributed under the License is distributed on an "AS IS" BASIS, 13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14# See the License for the specific language governing permissions and 15# limitations under the License. 16# 17 18import argparse 19import json 20import logging 21import os 22import pathlib 23import posixpath 24import re 25import shutil 26import subprocess 27import sys 28import tempfile 29import textwrap 30from typing import Any, BinaryIO 31 32import adb 33# Shared functions across gdbclient.py and ndk-gdb.py. 34import gdbrunner 35 36g_temp_dirs = [] 37 38g_vscode_config_marker_begin = '// #lldbclient-generated-begin' 39g_vscode_config_marker_end = '// #lldbclient-generated-end' 40 41 42def read_toolchain_config(root: str) -> str: 43 """Finds out current toolchain version.""" 44 version_output = subprocess.check_output( 45 f'{root}/build/soong/scripts/get_clang_version.py', 46 text=True) 47 return version_output.strip() 48 49 50def get_lldb_path(toolchain_path: str) -> str | None: 51 for lldb_name in ['lldb.sh', 'lldb.cmd', 'lldb', 'lldb.exe']: 52 debugger_path = os.path.join(toolchain_path, "bin", lldb_name) 53 if os.path.isfile(debugger_path): 54 return debugger_path 55 return None 56 57 58def get_lldb_server_path(root: str, clang_base: str, clang_version: str, arch: str) -> str: 59 arch = { 60 'arm': 'arm', 61 'arm64': 'aarch64', 62 'riscv64': 'riscv64', 63 'x86': 'i386', 64 'x86_64': 'x86_64', 65 }[arch] 66 return os.path.join(root, clang_base, "linux-x86", 67 clang_version, "runtimes_ndk_cxx", arch, "lldb-server") 68 69 70def get_tracer_pid(device: adb.AndroidDevice, pid: int | str | None) -> int: 71 if pid is None: 72 return 0 73 74 line, _ = device.shell(["grep", "-e", "^TracerPid:", "/proc/{}/status".format(pid)]) 75 tracer_pid = re.sub('TracerPid:\t(.*)\n', r'\1', line) 76 return int(tracer_pid) 77 78 79def parse_args() -> argparse.Namespace: 80 parser = gdbrunner.ArgumentParser() 81 82 group = parser.add_argument_group(title="attach target") 83 group = group.add_mutually_exclusive_group(required=True) 84 group.add_argument( 85 "-p", dest="target_pid", metavar="PID", type=int, 86 help="attach to a process with specified PID") 87 group.add_argument( 88 "-n", dest="target_name", metavar="NAME", 89 help="attach to a process with specified name") 90 group.add_argument( 91 "-r", dest="run_cmd", metavar="CMD", nargs=argparse.REMAINDER, 92 help="run a binary on the device, with args") 93 94 parser.add_argument( 95 "--port", nargs="?", default="5039", 96 help="Unused **host** port to forward the debug_socket to.[default: 5039]") 97 parser.add_argument( 98 "--user", nargs="?", default="root", 99 help="user to run commands as on the device [default: root]") 100 parser.add_argument( 101 "--setup-forwarding", default=None, 102 choices=["lldb", "vscode-lldb"], 103 help=("Set up lldb-server and port forwarding. Prints commands or " + 104 ".vscode/launch.json configuration needed to connect the debugging " + 105 "client to the server. 'vscode' with lldb and 'vscode-lldb' both " + 106 "require the 'vadimcn.vscode-lldb' extension.")) 107 parser.add_argument( 108 "--vscode-launch-props", default=None, 109 dest="vscode_launch_props", 110 help=("JSON with extra properties to add to launch parameters when using " + 111 "vscode-lldb forwarding.")) 112 parser.add_argument( 113 "--vscode-launch-file", default=None, 114 dest="vscode_launch_file", 115 help=textwrap.dedent(f"""Path to .vscode/launch.json file for the generated launch 116 config when using vscode-lldb forwarding. The file needs to 117 contain two marker lines: '{g_vscode_config_marker_begin}' 118 and '{g_vscode_config_marker_end}'. The config will be written inline 119 between these lines, replacing any text that is already there.""")) 120 121 parser.add_argument( 122 "--env", nargs=1, action="append", metavar="VAR=VALUE", 123 help="set environment variable when running a binary") 124 parser.add_argument( 125 "--chroot", nargs='?', default="", metavar="PATH", 126 help="run command in a chroot in the given directory. Cannot be used with --cwd.") 127 parser.add_argument( 128 "--cwd", nargs='?', default="", metavar="PATH", 129 help="working directory for the command. Cannot be used with --chroot.") 130 131 return parser.parse_args() 132 133 134def verify_device(device: adb.AndroidDevice) -> None: 135 names = set([device.get_prop("ro.build.product"), device.get_prop("ro.product.name")]) 136 target_device = os.environ["TARGET_PRODUCT"] 137 if target_device not in names: 138 msg = "You used the wrong lunch: TARGET_PRODUCT ({}) does not match attached device ({})" 139 sys.exit(msg.format(target_device, ", ".join(n if n else "None" for n in names))) 140 141 142def get_device_dir_exists(device: adb.AndroidDevice, dir: str) -> bool: 143 exit_code, _, _ = device.shell_nocheck(['[', '-d', dir, ']']) 144 return exit_code == 0 145 146 147def get_remote_pid(device: adb.AndroidDevice, process_name: str) -> int: 148 processes = gdbrunner.get_processes(device) 149 if process_name not in processes: 150 msg = "failed to find running process {}".format(process_name) 151 sys.exit(msg) 152 pids = processes[process_name] 153 if len(pids) > 1: 154 msg = "multiple processes match '{}': {}".format(process_name, pids) 155 sys.exit(msg) 156 157 # Fetch the binary using the PID later. 158 return pids[0] 159 160 161def make_temp_dir(prefix: str) -> str: 162 global g_temp_dirs 163 result = tempfile.mkdtemp(prefix='lldbclient-linker-') 164 g_temp_dirs.append(result) 165 return result 166 167 168def ensure_linker(device: adb.AndroidDevice, sysroot: str, interp: str | None) -> str | None: 169 """Ensure that the device's linker exists on the host. 170 171 PT_INTERP is usually /system/bin/linker[64], but on the device, that file is 172 a symlink to /apex/com.android.runtime/bin/linker[64]. The symbolized linker 173 binary on the host is located in ${sysroot}/apex, not in ${sysroot}/system, 174 so add the ${sysroot}/apex path to the solib search path. 175 176 PT_INTERP will be /system/bin/bootstrap/linker[64] for executables using the 177 non-APEX/bootstrap linker. No search path modification is needed. 178 179 For a tapas build, only an unbundled app is built, and there is no linker in 180 ${sysroot} at all, so copy the linker from the device. 181 182 Returns: 183 A directory to add to the soinfo search path or None if no directory 184 needs to be added. 185 """ 186 187 # Static executables have no interpreter. 188 if interp is None: 189 return None 190 191 # lldb will search for the linker using the PT_INTERP path. First try to find 192 # it in the sysroot. 193 local_path = os.path.join(sysroot, interp.lstrip("/")) 194 if os.path.exists(local_path): 195 return None 196 197 # If the linker on the device is a symlink, search for the symlink's target 198 # in the sysroot directory. 199 interp_real, _ = device.shell(["realpath", interp]) 200 interp_real = interp_real.strip() 201 local_path = os.path.join(sysroot, interp_real.lstrip("/")) 202 if os.path.exists(local_path): 203 if posixpath.basename(interp) == posixpath.basename(interp_real): 204 # Add the interpreter's directory to the search path. 205 return os.path.dirname(local_path) 206 else: 207 # If PT_INTERP is linker_asan[64], but the sysroot file is 208 # linker[64], then copy the local file to the name lldb expects. 209 result = make_temp_dir('lldbclient-linker-') 210 shutil.copy(local_path, os.path.join(result, posixpath.basename(interp))) 211 return result 212 213 # Pull the system linker. 214 result = make_temp_dir('lldbclient-linker-') 215 device.pull(interp, os.path.join(result, posixpath.basename(interp))) 216 return result 217 218 219def handle_switches(args, sysroot: str) -> tuple[BinaryIO, int | None, str | None]: 220 """Fetch the targeted binary and determine how to attach lldb. 221 222 Args: 223 args: Parsed arguments. 224 sysroot: Local sysroot path. 225 226 Returns: 227 (binary_file, attach_pid, run_cmd). 228 Precisely one of attach_pid or run_cmd will be None. 229 """ 230 231 device = args.device 232 binary_file = None 233 pid = None 234 run_cmd = None 235 236 args.su_cmd = ["su", args.user] if args.user else [] 237 238 if args.target_pid: 239 # Fetch the binary using the PID later. 240 pid = args.target_pid 241 elif args.target_name: 242 # Fetch the binary using the PID later. 243 pid = get_remote_pid(device, args.target_name) 244 elif args.run_cmd: 245 if not args.run_cmd[0]: 246 sys.exit("empty command passed to -r") 247 run_cmd = args.run_cmd 248 if not run_cmd[0].startswith("/"): 249 try: 250 run_cmd[0] = gdbrunner.find_executable_path(device, args.run_cmd[0], 251 run_as_cmd=args.su_cmd) 252 except RuntimeError: 253 sys.exit("Could not find executable '{}' passed to -r, " 254 "please provide an absolute path.".format(args.run_cmd[0])) 255 256 binary_file, local = gdbrunner.find_file(device, run_cmd[0], sysroot, 257 run_as_cmd=args.su_cmd) 258 if binary_file is None: 259 assert pid is not None 260 try: 261 binary_file, local = gdbrunner.find_binary(device, pid, sysroot, 262 run_as_cmd=args.su_cmd) 263 except adb.ShellError: 264 sys.exit("failed to pull binary for PID {}".format(pid)) 265 266 if not local: 267 logging.warning("Couldn't find local unstripped executable in {}," 268 " symbols may not be available.".format(sysroot)) 269 270 return (binary_file, pid, run_cmd) 271 272def merge_launch_dict(base: dict[str, Any], to_add: dict[str, Any] | None) -> None: 273 """Merges two dicts describing VSCode launch.json properties: base and 274 to_add. Base is modified in-place with items from to_add. 275 Items from to_add that are not present in base are inserted. Items that are 276 present are merged following these rules: 277 - Lists are merged with to_add elements appended to the end of base 278 list. Only a list can be merged with a list. 279 - dicts are merged recursively. Only a dict can be merged with a dict. 280 - Other present values in base get overwritten with values from to_add. 281 282 The reason for these rules is that merging in new values should prefer to 283 expand the existing set instead of overwriting where possible. 284 """ 285 if to_add is None: 286 return 287 288 for key, val in to_add.items(): 289 if key not in base: 290 base[key] = val 291 else: 292 if isinstance(base[key], list) and not isinstance(val, list): 293 raise ValueError(f'Cannot merge non-list into list at key={key}. ' + 294 'You probably need to wrap your value into a list.') 295 if not isinstance(base[key], list) and isinstance(val, list): 296 raise ValueError(f'Cannot merge list into non-list at key={key}.') 297 if isinstance(base[key], dict) != isinstance(val, dict): 298 raise ValueError(f'Cannot merge dict and non-dict at key={key}') 299 300 # We don't allow the user to overwrite or interleave lists and don't allow 301 # to delete dict entries. 302 # It can be done but would make the implementation a bit more complicated 303 # and provides less value than adding elements. 304 # We expect that the config generated by gdbclient doesn't contain anything 305 # the user would want to remove. 306 if isinstance(base[key], list): 307 base[key] += val 308 elif isinstance(base[key], dict): 309 merge_launch_dict(base[key], val) 310 else: 311 base[key] = val 312 313 314def generate_vscode_lldb_script(root: str, sysroot: str, binary_name: str, port: str | int, solib_search_path: list[str], extra_props: dict[str, Any] | None) -> str: 315 # TODO It would be nice if we didn't need to copy this or run the 316 # lldbclient.py program manually. Doing this would probably require 317 # writing a vscode extension or modifying an existing one. 318 # TODO: https://code.visualstudio.com/api/references/vscode-api#debug and 319 # https://code.visualstudio.com/api/extension-guides/debugger-extension and 320 # https://github.com/vadimcn/vscode-lldb/blob/6b775c439992b6615e92f4938ee4e211f1b060cf/extension/pickProcess.ts#L6 321 res = { 322 "name": "(lldbclient.py) Attach {} (port: {})".format(binary_name.split("/")[-1], port), 323 "type": "lldb", 324 "request": "custom", 325 "relativePathBase": root, 326 "sourceMap": { "/b/f/w" : root, '': root, '.': root }, 327 "initCommands": ['settings append target.exec-search-paths {}'.format(' '.join(solib_search_path))], 328 "targetCreateCommands": ["target create {}".format(binary_name), 329 "target modules search-paths add / {}/".format(sysroot)], 330 "processCreateCommands": ["gdb-remote {}".format(str(port))] 331 } 332 merge_launch_dict(res, extra_props) 333 return json.dumps(res, indent=4) 334 335def generate_lldb_script(root: str, sysroot: str, binary_name: str, port: str | int, solib_search_path: list[str]) -> str: 336 commands = [] 337 commands.append( 338 'settings append target.exec-search-paths {}'.format(' '.join(solib_search_path))) 339 340 commands.append('target create {}'.format(binary_name)) 341 # For RBE support. 342 commands.append("settings append target.source-map '/b/f/w' '{}'".format(root)) 343 commands.append("settings append target.source-map '' '{}'".format(root)) 344 commands.append('target modules search-paths add / {}/'.format(sysroot)) 345 commands.append('# If the below `gdb-remote` fails, run the command manually, ' 346 + 'as it may have raced with lldbserver startup.') 347 commands.append('gdb-remote {}'.format(str(port))) 348 return '\n'.join(commands) 349 350 351def generate_setup_script(sysroot: str, linker_search_dir: str | None, binary_name: str, is64bit: bool, port: str | int, debugger: str, vscode_launch_props: dict[str, Any] | None) -> str: 352 # Generate a setup script. 353 root = os.environ["ANDROID_BUILD_TOP"] 354 symbols_dir = os.path.join(sysroot, "system", "lib64" if is64bit else "lib") 355 vendor_dir = os.path.join(sysroot, "vendor", "lib64" if is64bit else "lib") 356 357 solib_search_path = [] 358 symbols_paths = ["", "hw", "ssl/engines", "drm", "egl", "soundfx"] 359 vendor_paths = ["", "hw", "egl"] 360 solib_search_path += [os.path.join(symbols_dir, x) for x in symbols_paths] 361 solib_search_path += [os.path.join(vendor_dir, x) for x in vendor_paths] 362 if linker_search_dir is not None: 363 solib_search_path += [linker_search_dir] 364 365 if debugger == "vscode-lldb": 366 return generate_vscode_lldb_script( 367 root, sysroot, binary_name, port, solib_search_path, vscode_launch_props) 368 elif debugger == 'lldb': 369 return generate_lldb_script( 370 root, sysroot, binary_name, port, solib_search_path) 371 else: 372 raise Exception("Unknown debugger type " + debugger) 373 374 375def insert_commands_into_vscode_config(dst_launch_config: str, setup_commands: str) -> str: 376 """Inserts setup commands into launch config between two marker lines. 377 Marker lines are set in global variables g_vscode_config_marker_end and g_vscode_config_marker_end. 378 The commands are inserted with the same indentation as the first marker line. 379 380 Args: 381 dst_launch_config: Config to insert commands into. 382 setup_commands: Commands to insert. 383 Returns: 384 Config with inserted commands. 385 Raises: 386 ValueError if the begin marker is not found or not terminated with an end marker. 387 """ 388 389 # We expect the files to be small (~10s KB), so we use simple string concatenation 390 # for simplicity and readability even if it is slower. 391 output = "" 392 found_at_least_one_begin = False 393 unterminated_begin_line = None 394 395 # It might be tempting to rewrite this using find() or even regexes, 396 # but keeping track of line numbers, preserving whitespace, and detecting indent 397 # becomes tricky enough that this simple loop is more clear. 398 for linenum, line in enumerate(dst_launch_config.splitlines(keepends=True), start=1): 399 if unterminated_begin_line != None: 400 if line.strip() == g_vscode_config_marker_end: 401 unterminated_begin_line = None 402 else: 403 continue 404 output += line 405 if line.strip() == g_vscode_config_marker_begin: 406 found_at_least_one_begin = True 407 unterminated_begin_line = linenum 408 marker_indent = line[:line.find(g_vscode_config_marker_begin)] 409 output += textwrap.indent(setup_commands, marker_indent) + '\n' 410 411 if not found_at_least_one_begin: 412 raise ValueError(f"Did not find begin marker line '{g_vscode_config_marker_begin}' " + 413 "in the VSCode launch file") 414 415 if unterminated_begin_line is not None: 416 raise ValueError(f"Unterminated begin marker at line {unterminated_begin_line} " + 417 f"in the VSCode launch file. Add end marker line to file: '{g_vscode_config_marker_end}'") 418 419 return output 420 421 422def replace_file_contents(dst_path: os.PathLike, contents: str) -> None: 423 """Replaces the contents of the file pointed to by dst_path. 424 425 This function writes the new contents into a temporary file, then atomically swaps it with 426 the target file. This way if a write fails, the original file is not overwritten. 427 428 Args: 429 dst_path: The path to the file to be replaced. 430 contents: The new contents of the file. 431 Raises: 432 Forwards exceptions from underlying filesystem methods. 433 """ 434 tempf = tempfile.NamedTemporaryFile('w', delete=False) 435 try: 436 tempf.write(contents) 437 os.replace(tempf.name, dst_path) 438 except: 439 os.remove(tempf.name) 440 raise 441 442 443def write_vscode_config(vscode_launch_file: pathlib.Path, setup_commands: str) -> None: 444 """Writes setup_commands into the file pointed by vscode_launch_file. 445 446 See insert_commands_into_vscode_config for the description of how the setup commands are written. 447 """ 448 contents = insert_commands_into_vscode_config(vscode_launch_file.read_text(), setup_commands) 449 replace_file_contents(vscode_launch_file, contents) 450 451 452def do_main() -> None: 453 required_env = ["ANDROID_BUILD_TOP", 454 "ANDROID_PRODUCT_OUT", "TARGET_PRODUCT"] 455 for env in required_env: 456 if env not in os.environ: 457 sys.exit( 458 "Environment variable '{}' not defined, have you run lunch?".format(env)) 459 460 args = parse_args() 461 device = args.device 462 463 if device is None: 464 sys.exit("ERROR: Failed to find device.") 465 466 root = os.environ["ANDROID_BUILD_TOP"] 467 sysroot = os.path.join(os.environ["ANDROID_PRODUCT_OUT"], "symbols") 468 469 if args.cwd: 470 if not get_device_dir_exists(device, args.cwd): 471 raise ValueError('Working directory does not exist on device: {}'.format(args.cwd)) 472 if args.chroot: 473 # See the comment in start_gdbserver about why this is not implemented. 474 raise ValueError('--cwd and --chroot cannot be used together') 475 476 # Make sure the environment matches the attached device. 477 # Skip when running in a chroot because the chroot lunch target may not 478 # match the device's lunch target. 479 if not args.chroot: 480 verify_device(device) 481 482 debug_socket = "/data/local/tmp/debug_socket" 483 pid = None 484 run_cmd = None 485 486 # Fetch binary for -p, -n. 487 binary_file, pid, run_cmd = handle_switches(args, sysroot) 488 489 vscode_launch_props = None 490 if args.vscode_launch_props: 491 if args.setup_forwarding != "vscode-lldb": 492 raise ValueError( 493 'vscode-launch-props requires --setup-forwarding=vscode-lldb') 494 vscode_launch_props = json.loads(args.vscode_launch_props) 495 496 vscode_launch_file = None 497 if args.vscode_launch_file: 498 if args.setup_forwarding != "vscode-lldb": 499 raise ValueError( 500 'vscode-launch-file requires --setup-forwarding=vscode-lldb') 501 vscode_launch_file = args.vscode_launch_file 502 503 with binary_file: 504 if sys.platform.startswith("linux"): 505 platform_name = "linux-x86" 506 elif sys.platform.startswith("darwin"): 507 platform_name = "darwin-x86" 508 else: 509 sys.exit("Unknown platform: {}".format(sys.platform)) 510 511 arch = gdbrunner.get_binary_arch(binary_file) 512 is64bit = arch.endswith("64") 513 514 # Make sure we have the linker 515 clang_base = 'prebuilts/clang/host' 516 clang_version = read_toolchain_config(root) 517 toolchain_path = os.path.join(root, clang_base, platform_name, 518 clang_version) 519 llvm_readobj_path = os.path.join(toolchain_path, "bin", "llvm-readobj") 520 interp = gdbrunner.get_binary_interp(binary_file.name, llvm_readobj_path) 521 linker_search_dir = ensure_linker(device, sysroot, interp) 522 523 tracer_pid = get_tracer_pid(device, pid) 524 if tracer_pid == 0: 525 cmd_prefix = args.su_cmd 526 if args.env: 527 cmd_prefix += ['env'] + [v[0] for v in args.env] 528 529 # Start lldb-server. 530 server_local_path = get_lldb_server_path(root, clang_base, clang_version, arch) 531 server_remote_path = "/data/local/tmp/{}-lldb-server".format(arch) 532 gdbrunner.start_gdbserver( 533 device, server_local_path, server_remote_path, 534 target_pid=pid, run_cmd=run_cmd, debug_socket=debug_socket, 535 port=args.port, run_as_cmd=cmd_prefix, lldb=True, chroot=args.chroot, cwd=args.cwd) 536 else: 537 print( 538 "Connecting to tracing pid {} using local port {}".format( 539 tracer_pid, args.port)) 540 gdbrunner.forward_gdbserver_port(device, local=args.port, 541 remote="tcp:{}".format(args.port)) 542 543 debugger_path = get_lldb_path(toolchain_path) 544 debugger = args.setup_forwarding or 'lldb' 545 546 # Generate the lldb script. 547 setup_commands = generate_setup_script(sysroot=sysroot, 548 linker_search_dir=linker_search_dir, 549 binary_name=binary_file.name, 550 is64bit=is64bit, 551 port=args.port, 552 debugger=debugger, 553 vscode_launch_props=vscode_launch_props) 554 555 if not args.setup_forwarding: 556 # Print a newline to separate our messages from the GDB session. 557 print("") 558 559 # Start lldb. 560 gdbrunner.start_gdb(debugger_path, setup_commands, lldb=True) 561 else: 562 if args.setup_forwarding == "vscode-lldb" and vscode_launch_file: 563 write_vscode_config(pathlib.Path(vscode_launch_file) , setup_commands) 564 print(f"Generated config written to '{vscode_launch_file}'") 565 else: 566 print("") 567 print(setup_commands) 568 print("") 569 if args.setup_forwarding == "vscode-lldb": 570 print(textwrap.dedent(""" 571 Paste the above json into .vscode/launch.json and start the debugger as 572 normal.""")) 573 else: 574 print(textwrap.dedent(""" 575 Paste the lldb commands above into the lldb frontend to set up the 576 lldb-server connection.""")) 577 578 print(textwrap.dedent(""" 579 Press enter in this terminal once debugging is finished to shut lldb-server 580 down and close all the ports.""")) 581 print("") 582 input("Press enter to shut down lldb-server") 583 584 585def main(): 586 try: 587 do_main() 588 finally: 589 global g_temp_dirs 590 for temp_dir in g_temp_dirs: 591 shutil.rmtree(temp_dir) 592 593 594if __name__ == "__main__": 595 main() 596