blob: 1e87f44416577c38177b03c8bea61c15832b71c8 [file] [log] [blame]
# Copyright 2017 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""An interactive console for looking analyzing .size files."""
import argparse
import atexit
import code
import contextlib
import itertools
import logging
import os
import readline
import subprocess
import sys
import archive
import canned_queries
import describe
import diff
import file_format
import match_util
import models
import paths
# Number of lines before using less for Print().
_THRESHOLD_FOR_PAGER = 50
@contextlib.contextmanager
def _LessPipe():
"""Output to `less`. Yields a file object to write to."""
try:
proc = subprocess.Popen(['less'], stdin=subprocess.PIPE, stdout=sys.stdout)
yield proc.stdin
proc.stdin.close()
proc.wait()
except IOError:
pass # Happens when less is quit before all data is written.
except KeyboardInterrupt:
pass # Assume used to break out of less.
def _WriteToStream(lines, use_pager=None, to_file=None):
if to_file:
use_pager = False
if use_pager is None and sys.stdout.isatty():
# Does not take into account line-wrapping... Oh well.
first_lines = list(itertools.islice(lines, _THRESHOLD_FOR_PAGER))
if len(first_lines) == _THRESHOLD_FOR_PAGER:
use_pager = True
lines = itertools.chain(first_lines, lines)
if use_pager:
with _LessPipe() as stdin:
describe.WriteLines(lines, stdin.write)
elif to_file:
with open(to_file, 'w') as file_obj:
describe.WriteLines(lines, file_obj.write)
else:
describe.WriteLines(lines, sys.stdout.write)
class _Session(object):
_readline_initialized = False
def __init__(self, size_infos, size_paths, lazy_paths):
self._variables = {
'Print': self._PrintFunc,
'Diff': self._DiffFunc,
'Disassemble': self._DisassembleFunc,
'ExpandRegex': match_util.ExpandRegexIdentifierPlaceholder,
'ShowExamples': self._ShowExamplesFunc,
'canned_queries': canned_queries.CannedQueries(size_infos),
}
self._lazy_paths = lazy_paths
self._size_infos = size_infos
self._size_paths = size_paths
self._disassemble_prefix_len = None
if len(size_infos) == 1:
self._variables['size_info'] = size_infos[0]
else:
for i, size_info in enumerate(size_infos):
self._variables['size_info%d' % (i + 1)] = size_info
def _DiffFunc(self, before=None, after=None, cluster=True, sort=True):
"""Diffs two SizeInfo objects. Returns a SizeInfoDiff.
Args:
before: Defaults to first size_infos[0].
after: Defaults to second size_infos[1].
cluster: When True (default), calls SymbolGroup.Clustered() after diffing.
Generally reduces noise.
sort: When True (default), calls SymbolGroup.Sorted() after diffing.
"""
before = before if before is not None else self._size_infos[0]
after = after if after is not None else self._size_infos[1]
ret = diff.Diff(before, after)
if cluster:
ret.symbols = ret.symbols.Clustered()
if sort:
ret.symbols = ret.symbols.Sorted()
return ret
def _PrintFunc(self, obj=None, verbose=False, recursive=False, use_pager=None,
to_file=None):
"""Prints out the given Symbol / SymbolGroup / SymbolDiff / SizeInfo.
Args:
obj: The object to be printed. Defaults to size_infos[-1].
verbose: Show more detailed output.
recursive: Print children of nested SymbolGroups.
use_pager: Pipe output through `less`. Ignored when |obj| is a Symbol.
default is to automatically pipe when output is long.
to_file: Rather than print to stdio, write to the given file.
"""
obj = obj if obj is not None else self._size_infos[-1]
lines = describe.GenerateLines(obj, verbose=verbose, recursive=recursive)
_WriteToStream(lines, use_pager=use_pager, to_file=to_file)
def _ElfPathAndToolPrefixForSymbol(self, symbol, elf_path, tool_prefix):
size_info = None
size_path = None
for size_info, size_path in zip(self._size_infos, self._size_paths):
if symbol in size_info.symbols:
break
else:
# If symbols is from a diff(), use its address+name to find it.
for size_info, size_path in zip(self._size_infos, self._size_paths):
matched = size_info.symbols.WhereAddressInRange(symbol.address)
# Use last matched symbol to skip over padding-only symbols.
if len(matched) > 0 and matched[-1].full_name == symbol.full_name:
symbol = matched[-1]
break
else:
assert False, 'Symbol does not belong to a size_info.'
orig_tool_prefix = size_info.metadata.get(models.METADATA_TOOL_PREFIX)
if orig_tool_prefix:
orig_tool_prefix = paths.FromSrcRootRelative(orig_tool_prefix)
if os.path.exists(orig_tool_prefix + 'objdump'):
tool_prefix = orig_tool_prefix
# TODO(agrieve): Would be even better to use objdump --info to check that
# the toolchain is for the correct architecture.
assert tool_prefix is not None, (
'Could not determine --tool-prefix. Possible fixes include setting '
'--tool-prefix, or setting --output-directory')
if elf_path is None:
filename = size_info.metadata.get(models.METADATA_ELF_FILENAME)
output_dir = self._lazy_paths.output_directory
size_path = self._size_paths[self._size_infos.index(size_info)]
if output_dir:
# Local build: File is located in output directory.
path = os.path.normpath(os.path.join(output_dir, filename))
if not output_dir or not os.path.exists(path):
# Downloaded build: File is located beside .size file.
path = os.path.normpath(os.path.join(
os.path.dirname(size_path), os.path.basename(filename)))
assert os.path.exists(path), (
'Could locate ELF file. If binary was built locally, ensure '
'--output-directory is set. If output directory is unavailable, '
'ensure {} is located beside {}, or pass its path explicitly using '
'elf_path=').format(os.path.basename(filename), size_path)
found_build_id = archive.BuildIdFromElf(path, tool_prefix)
expected_build_id = size_info.metadata.get(models.METADATA_ELF_BUILD_ID)
assert found_build_id == expected_build_id, (
'Build ID does not match for %s' % path)
return path, tool_prefix
def _DetectDisassemblePrefixLen(self, args):
# Look for a line that looks like:
# /usr/{snip}/src/out/Release/../../net/quic/core/quic_time.h:100
output = subprocess.check_output(args)
for line in output.splitlines():
if line and line[0] == os.path.sep and line[-1].isdigit():
release_idx = line.find('Release')
if release_idx == -1:
break
return line.count(os.path.sep, 0, release_idx)
logging.warning('Found no source paths in objdump output.')
return None
def _DisassembleFunc(self, symbol, elf_path=None, use_pager=None,
to_file=None):
"""Shows objdump disassembly for the given symbol.
Args:
symbol: Must be a .text symbol and not a SymbolGroup.
elf_path: Path to the executable containing the symbol. Required only
when auto-detection fails.
"""
assert symbol.address and symbol.section_name == '.text'
tool_prefix = self._lazy_paths.tool_prefix
if not elf_path:
elf_path, tool_prefix = self._ElfPathAndToolPrefixForSymbol(
symbol, elf_path, tool_prefix)
args = [tool_prefix + 'objdump', '--disassemble', '--source',
'--line-numbers', '--demangle',
'--start-address=0x%x' % symbol.address,
'--stop-address=0x%x' % symbol.end_address, elf_path]
if self._disassemble_prefix_len is None:
prefix_len = self._DetectDisassemblePrefixLen(args)
if prefix_len is not None:
self._disassemble_prefix_len = prefix_len
if self._disassemble_prefix_len is not None:
output_directory = self._lazy_paths.output_directory
# Only matters for non-generated paths, so be lenient here.
if output_directory is None:
output_directory = os.path.join(paths.SRC_ROOT, 'out', 'Release')
if not os.path.exists(output_directory):
os.makedirs(output_directory)
args += [
'--prefix-strip', str(self._disassemble_prefix_len),
'--prefix', os.path.normpath(os.path.relpath(output_directory))
]
proc = subprocess.Popen(args, stdout=subprocess.PIPE)
lines = itertools.chain(('Showing disassembly for %r' % symbol,
'Command: %s' % ' '.join(args)),
(l.rstrip() for l in proc.stdout))
_WriteToStream(lines, use_pager=use_pager, to_file=to_file)
proc.kill()
def _ShowExamplesFunc(self):
print self._CreateBanner()
print '\n'.join([
'# Show pydoc for main types:',
'import models',
'help(models)',
'',
'# Show all attributes of all symbols & per-section totals:',
'Print(size_info, verbose=True)',
'',
'# Show two levels of .text, grouped by first two subdirectories',
'text_syms = size_info.symbols.WhereInSection("t")',
'by_path = text_syms.GroupedByPath(depth=2)',
'Print(by_path.WherePssBiggerThan(1024))',
'',
'# Show all non-vtable generated symbols',
'generated_syms = size_info.symbols.WhereGeneratedByToolchain()',
'Print(generated_syms.WhereNameMatches(r"vtable").Inverted().Sorted())',
'',
'# Show all symbols that have "print" in their name or path, except',
'# those within components/.',
'# Note: Could have also used Inverted(), as above.',
'# Note: Use "help(ExpandRegex)" for more about what {{_print_}} does.',
'print_syms = size_info.symbols.WhereMatches(r"{{_print_}}")',
'Print(print_syms - print_syms.WherePathMatches(r"^components/"))',
'',
'# Diff two .size files and save result to a file:',
'Print(Diff(size_info1, size_info2), to_file="output.txt")',
'',
'# View per-component breakdowns, then drill into the last entry.',
'c = canned_queries.CategorizeByChromeComponent()',
'Print(c)',
'Print(c[-1].GroupedByPath(depth=2).Clustered().Sorted())',
'',
'# For even more inspiration, look at canned_queries.py',
'# (and feel free to add your own!).',
])
def _CreateBanner(self):
symbol_info_keys = sorted(m for m in dir(models.SizeInfo) if m[0] != '_')
symbol_keys = sorted(m for m in dir(models.Symbol) if m[0] != '_')
symbol_group_keys = [m for m in dir(models.SymbolGroup) if m[0] != '_']
symbol_diff_keys = sorted(m for m in dir(models.SymbolDiff)
if m[0] != '_' and m not in symbol_group_keys)
symbol_group_keys = sorted(m for m in symbol_group_keys
if m not in symbol_keys)
canned_queries_keys = sorted(m for m in dir(canned_queries.CannedQueries)
if m[0] != '_')
functions = sorted(k for k in self._variables if k[0].isupper())
variables = sorted(k for k in self._variables if k[0].islower())
return '\n'.join([
'*' * 80,
'Entering interactive Python shell. Quick reference:',
'',
'SizeInfo: %s' % ', '.join(symbol_info_keys),
'Symbol: %s' % ', '.join(symbol_keys),
'',
'SymbolGroup (extends Symbol): %s' % ', '.join(symbol_group_keys),
'',
'SymbolDiff (extends SymbolGroup): %s' % ', '.join(symbol_diff_keys),
'',
'canned_queries: %s' % ', '.join(canned_queries_keys),
'',
'Functions: %s' % ', '.join('%s()' % f for f in functions),
'Variables: %s' % ', '.join(variables),
'*' * 80,
])
@classmethod
def _InitReadline(cls):
if cls._readline_initialized:
return
cls._readline_initialized = True
# Without initializing readline, arrow keys don't even work!
readline.parse_and_bind('tab: complete')
history_file = os.path.join(os.path.expanduser('~'),
'.binary_size_query_history')
if os.path.exists(history_file):
readline.read_history_file(history_file)
atexit.register(lambda: readline.write_history_file(history_file))
def Eval(self, query):
exec query in self._variables
def GoInteractive(self):
_Session._InitReadline()
code.InteractiveConsole(self._variables).interact(self._CreateBanner())
def AddArguments(parser):
parser.add_argument(
'inputs', nargs='+',
help='Input .size files to load. For a single file, it will be mapped to '
'the variable "size_info". For multiple inputs, the names will be '
'size_info1, size_info2, etc.')
parser.add_argument('--query',
help='Execute the given snippet. '
'Example: Print(size_info)')
parser.add_argument('--tool-prefix',
help='Path prefix for objdump. Required only for '
'Disassemble().')
parser.add_argument('--output-directory',
help='Path to the root build directory. Used only for '
'Disassemble().')
def Run(args, parser):
for path in args.inputs:
if not path.endswith('.size'):
parser.error('All inputs must end with ".size"')
size_infos = [archive.LoadAndPostProcessSizeInfo(p) for p in args.inputs]
lazy_paths = paths.LazyPaths(tool_prefix=args.tool_prefix,
output_directory=args.output_directory,
any_path_within_output_directory=args.inputs[0])
session = _Session(size_infos, args.inputs, lazy_paths)
if args.query:
logging.info('Running query from command-line.')
session.Eval(args.query)
else:
logging.info('Entering interactive console.')
session.GoInteractive()