blob: 4fe549b70c6bc26f7cbdc780956901fce77ac310 [file] [log] [blame]
#!/usr/bin/env python
# Copyright (c) 2011 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.
"""Android system-wide tracing utility.
This is a tool for capturing a trace that includes data from both userland and
the kernel. It creates an HTML file for visualizing the trace.
"""
import sys
# Make sure we're using a new enough version of Python.
# The flags= parameter of re.sub() is new in Python 2.7.
if sys.version_info[:2] < (2, 7):
print >> sys.stderr, '\nThis script requires Python 2.7 or newer.'
sys.exit(1)
# pylint: disable=g-bad-import-order,g-import-not-at-top
import optparse
import os
import re
import select
import subprocess
import time
import zlib
# The adb shell command to initiate a trace.
ATRACE_BASE_ARGS = ['atrace']
# If a custom list of categories is not specified, traces will include
# these categories (if available on the device).
DEFAULT_CATEGORIES = 'sched gfx view dalvik webview input disk am wm'.split()
# The command to list trace categories.
LIST_CATEGORIES_ARGS = ATRACE_BASE_ARGS + ['--list_categories']
# Plain-text trace data should always start with this string.
TRACE_TEXT_HEADER = '# tracer'
class OptionParserIgnoreErrors(optparse.OptionParser):
"""Wrapper for OptionParser that ignores errors and produces no output."""
def error(self, msg):
pass
def exit(self):
pass
def print_usage(self):
pass
def print_help(self):
pass
def print_version(self):
pass
def add_adb_serial(adb_command, device_serial):
if device_serial is not None:
adb_command.insert(1, device_serial)
adb_command.insert(1, '-s')
def construct_adb_shell_command(shell_args, device_serial):
adb_command = ['adb', 'shell', ' '.join(shell_args)]
add_adb_serial(adb_command, device_serial)
return adb_command
def run_adb_shell(shell_args, device_serial):
"""Runs "adb shell" with the given arguments.
Args:
shell_args: array of arguments to pass to adb shell.
device_serial: if not empty, will add the appropriate command-line
parameters so that adb targets the given device.
Returns:
A tuple containing the adb output (stdout & stderr) and the return code
from adb. Will exit if adb fails to start.
"""
adb_command = construct_adb_shell_command(shell_args, device_serial)
adb_output = []
adb_return_code = 0
try:
adb_output = subprocess.check_output(adb_command, stderr=subprocess.STDOUT,
shell=False, universal_newlines=True)
except OSError as error:
# This usually means that the adb executable was not found in the path.
print >> sys.stderr, ('\nThe command "%s" failed with the following error:'
% ' '.join(adb_command))
print >> sys.stderr, ' %s\n' % str(error)
print >> sys.stderr, 'Is adb in your path?'
sys.exit(1)
except subprocess.CalledProcessError as error:
# The process exited with an error.
adb_return_code = error.returncode
adb_output = error.output
return (adb_output, adb_return_code)
def get_device_sdk_version():
"""Uses adb to attempt to determine the SDK version of a running device."""
getprop_args = ['getprop', 'ro.build.version.sdk']
# get_device_sdk_version() is called before we even parse our command-line
# args. Therefore, parse just the device serial number part of the
# command-line so we can send the adb command to the correct device.
parser = OptionParserIgnoreErrors()
parser.add_option('-e', '--serial', dest='device_serial', type='string')
options, unused_args = parser.parse_args()
success = False
adb_output, adb_return_code = run_adb_shell(getprop_args,
options.device_serial)
if adb_return_code == 0:
# ADB may print output other than the version number (e.g. it chould
# print a message about starting the ADB server).
# Break the ADB output into white-space delimited segments.
parsed_output = str.split(adb_output)
if parsed_output:
# Assume that the version number is the last thing printed by ADB.
version_string = parsed_output[-1]
if version_string:
try:
# Try to convert the text into an integer.
version = int(version_string)
except ValueError:
version = -1
else:
success = True
if not success:
print >> sys.stderr, (
'\nThe command "%s" failed with the following message:'
% ' '.join(getprop_args))
print >> sys.stderr, adb_output
sys.exit(1)
return version
def get_default_categories(device_serial):
categories_output, return_code = run_adb_shell(LIST_CATEGORIES_ARGS,
device_serial)
if return_code == 0 and categories_output:
categories = [c.split('-')[0].strip()
for c in categories_output.splitlines()]
return [c for c in categories if c in DEFAULT_CATEGORIES]
return []
def parse_options():
"""Parses and checks the command-line options.
Returns:
A tuple containing the options structure and a list of categories to
be traced.
"""
usage = 'Usage: %prog [options] [category1 [category2 ...]]'
desc = 'Example: %prog -b 32768 -t 15 gfx input view sched freq'
parser = optparse.OptionParser(usage=usage, description=desc)
parser.add_option('-o', dest='output_file', help='write HTML to FILE',
default='trace.html', metavar='FILE')
parser.add_option('-t', '--time', dest='trace_time', type='int',
help='trace for N seconds', metavar='N')
parser.add_option('-b', '--buf-size', dest='trace_buf_size', type='int',
help='use a trace buffer size of N KB', metavar='N')
parser.add_option('-k', '--ktrace', dest='kfuncs', action='store',
help='specify a comma-separated list of kernel functions '
'to trace')
parser.add_option('-l', '--list-categories', dest='list_categories',
default=False, action='store_true',
help='list the available categories and exit')
parser.add_option('-a', '--app', dest='app_name', default=None, type='string',
action='store',
help='enable application-level tracing for comma-separated '
'list of app cmdlines')
parser.add_option('--no-fix-threads', dest='fix_threads', default=True,
action='store_false',
help='don\'t fix missing or truncated thread names')
parser.add_option('--no-fix-circular', dest='fix_circular', default=True,
action='store_false',
help='don\'t fix truncated circular traces')
parser.add_option('--no-compress', dest='compress_trace_data',
default=True, action='store_false',
help='Tell the device not to send the trace data in '
'compressed form.')
parser.add_option('--link-assets', dest='link_assets', default=False,
action='store_true',
help='(deprecated)')
parser.add_option('--from-file', dest='from_file', action='store',
help='read the trace from a file (compressed) rather than '
'running a live trace')
parser.add_option('--asset-dir', dest='asset_dir', default='trace-viewer',
type='string', help='(deprecated)')
parser.add_option('-e', '--serial', dest='device_serial', type='string',
help='adb device serial number')
options, categories = parser.parse_args()
if options.link_assets or options.asset_dir != 'trace-viewer':
parser.error('--link-assets and --asset-dir are deprecated.')
if (options.trace_time is not None) and (options.trace_time <= 0):
parser.error('the trace time must be a positive number')
if (options.trace_buf_size is not None) and (options.trace_buf_size <= 0):
parser.error('the trace buffer size must be a positive number')
return (options, categories)
def construct_trace_command(options, categories):
"""Builds a command-line used to invoke a trace process.
Args:
options: The command-line options.
categories: The trace categories to capture.
Returns:
A tuple where the first element is an array of command-line arguments, and
the second element is a boolean which will be true if the commend will
stream trace data.
"""
if options.list_categories:
tracer_args = construct_adb_shell_command(LIST_CATEGORIES_ARGS,
options.device_serial)
expect_trace = False
elif options.from_file is not None:
tracer_args = ['cat', options.from_file]
expect_trace = True
else:
atrace_args = ATRACE_BASE_ARGS
expect_trace = True
if options.compress_trace_data:
atrace_args.extend(['-z'])
if (options.trace_time is not None) and (options.trace_time > 0):
atrace_args.extend(['-t', str(options.trace_time)])
if (options.trace_buf_size is not None) and (options.trace_buf_size > 0):
atrace_args.extend(['-b', str(options.trace_buf_size)])
if options.app_name is not None:
atrace_args.extend(['-a', options.app_name])
if options.kfuncs is not None:
atrace_args.extend(['-k', options.kfuncs])
if not categories:
categories = get_default_categories(options.device_serial)
atrace_args.extend(categories)
if options.fix_threads:
atrace_args.extend([';', 'ps', '-t'])
tracer_args = construct_adb_shell_command(atrace_args,
options.device_serial)
return (tracer_args, expect_trace)
def collect_trace_data(tracer_args):
"""Invokes and communicates with the trace process.
Args:
tracer_args: The command-line to execute.
Returns:
The captured trace data.
"""
adb = subprocess.Popen(tracer_args, stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
result = None
data = []
# Read the text portion of the output and watch for the 'TRACE:' marker that
# indicates the start of the trace data.
while result is None:
ready = select.select([adb.stdout, adb.stderr], [],
[adb.stdout, adb.stderr])
if adb.stderr in ready[0]:
err = os.read(adb.stderr.fileno(), 4096)
sys.stderr.write(err)
sys.stderr.flush()
if adb.stdout in ready[0]:
out = os.read(adb.stdout.fileno(), 4096)
parts = out.split('\nTRACE:', 1)
txt = parts[0].replace('\r', '')
if len(parts) == 2:
# The '\nTRACE:' match stole the last newline from the text, so add it
# back here.
txt += '\n'
sys.stdout.write(txt)
sys.stdout.flush()
if len(parts) == 2:
data.append(parts[1])
sys.stdout.write('downloading trace...')
sys.stdout.flush()
break
result = adb.poll()
# Read and buffer the data portion of the output.
while True:
ready = select.select([adb.stdout, adb.stderr], [],
[adb.stdout, adb.stderr])
keepReading = False
if adb.stderr in ready[0]:
err = os.read(adb.stderr.fileno(), 4096)
if len(err) > 0:
keepReading = True
sys.stderr.write(err)
sys.stderr.flush()
if adb.stdout in ready[0]:
out = os.read(adb.stdout.fileno(), 4096)
if len(out) > 0:
keepReading = True
data.append(out)
if result is not None and not keepReading:
break
result = adb.poll()
if result != 0:
print >> sys.stderr, 'adb returned error code %d' % result
sys.exit(1)
return data
def extract_thread_list(trace_data):
threads = {}
parts = re.split('USER +PID +PPID +VSIZE +RSS +WCHAN +PC +NAME',
trace_data, 1)
if len(parts) == 2:
trace_data = parts[0]
for line in parts[1].splitlines():
cols = line.split(None, 8)
if len(cols) == 9:
tid = int(cols[1])
name = cols[8]
threads[tid] = name
return (trace_data, threads)
def strip_and_decompress_trace(data, fix_threads):
# Collapse CRLFs that are added by adb shell.
if data.startswith('\r\n'):
data = data.replace('\r\n', '\n')
# Skip the initial newline.
data = data[1:]
if not data:
print >> sys.stderr, ('No data was captured. Output file was not '
'written.')
sys.exit(1)
# Indicate to the user that the data download is complete.
print ' done\n'
# Extract the thread list dumped by ps.
threads = {}
if fix_threads:
data, threads = extract_thread_list(data)
if data.startswith(TRACE_TEXT_HEADER):
# Plain-text data.
out = data
else:
# No header found, so assume the data is compressed.
out = zlib.decompress(data)
return (out, threads)
def fix_thread_names(trace_data, thread_names):
def repl(m):
tid = int(m.group(2))
if tid > 0:
name = thread_names.get(tid)
if name is None:
name = m.group(1)
if name == '<...>':
name = '<' + str(tid) + '>'
thread_names[tid] = name
return name + '-' + m.group(2)
else:
return m.group(0)
trace_data = re.sub(r'^\s*(\S+)-(\d+)', repl, trace_data,
flags=re.MULTILINE)
return trace_data
def preprocess_trace_data(options, trace_data):
trace_data = ''.join(trace_data)
trace_data, thread_names = strip_and_decompress_trace(trace_data,
options.fix_threads)
if not trace_data:
print >> sys.stderr, ('No data was captured. Output file was not '
'written.')
sys.exit(1)
if options.fix_threads:
trace_data = fix_thread_names(trace_data, thread_names)
if options.fix_circular:
trace_data = fix_circular_traces(trace_data)
return trace_data
def write_trace_html(html_filename, script_dir, trace_data):
html_prefix = read_asset(script_dir, 'prefix.html')
html_suffix = read_asset(script_dir, 'suffix.html')
trace_viewer_html = read_asset(script_dir, 'systrace_trace_viewer.html')
# Open the file in binary mode to prevent python from changing the
# line endings.
html_file = open(html_filename, 'wb')
html_file.write(html_prefix.replace('{{SYSTRACE_TRACE_VIEWER_HTML}}',
trace_viewer_html))
html_file.write('<!-- BEGIN TRACE -->\n'
' <script class="trace-data" type="application/text">\n')
html_file.write(trace_data)
html_file.write(' </script>\n<!-- END TRACE -->\n')
html_file.write(html_suffix)
html_file.close()
print '\n wrote file://%s\n' % os.path.abspath(html_filename)
def main():
device_sdk_version = get_device_sdk_version()
if device_sdk_version < 18:
legacy_script = os.path.join(os.path.dirname(sys.argv[0]),
'systrace-legacy.py')
# execv() does not return.
os.execv(legacy_script, sys.argv)
options, categories = parse_options()
tracer_args, expect_trace = construct_trace_command(options, categories)
trace_data = collect_trace_data(tracer_args)
if not expect_trace:
# Nothing more to do.
return
trace_data = preprocess_trace_data(options, trace_data)
script_dir = os.path.dirname(os.path.abspath(sys.argv[0]))
write_trace_html(options.output_file, script_dir, trace_data)
def read_asset(src_dir, filename):
return open(os.path.join(src_dir, filename)).read()
def fix_circular_traces(out):
"""Fix inconsistentcies in traces due to circular buffering.
The circular buffers are kept per CPU, so it is not guaranteed that the
beginning of a slice is overwritten before the end. To work around this, we
throw away the prefix of the trace where not all CPUs have events yet.
Args:
out: The data to fix.
Returns:
The updated trace data.
"""
# If any of the CPU's buffers have filled up and
# older events have been dropped, the kernel
# emits markers of the form '##### CPU 2 buffer started ####' on
# the line before the first event in the trace on that CPU.
#
# No such headers are emitted if there were no overflows or the trace
# was captured with non-circular buffers.
buffer_start_re = re.compile(r'^#+ CPU \d+ buffer started', re.MULTILINE)
start_of_full_trace = 0
while True:
result = buffer_start_re.search(out, start_of_full_trace + 1)
if result:
start_of_full_trace = result.start()
else:
break
if start_of_full_trace > 0:
# Need to keep the header intact to make the importer happy.
end_of_header = re.search(r'^[^#]', out, re.MULTILINE).start()
out = out[:end_of_header] + out[start_of_full_trace:]
return out
if __name__ == '__main__':
main()