| #!/usr/bin/env python3 |
| # |
| # Copyright (C) 2024 The Android Open Source Project |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); |
| # you may not use this file except in compliance with the License. |
| # You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| # |
| |
| import argparse |
| import pathlib |
| import subprocess |
| from bisect import bisect |
| from collections import deque |
| from dataclasses import dataclass |
| from itertools import chain |
| from math import ceil |
| from typing import BinaryIO, Callable, Deque, Dict, List, Optional, Tuple |
| |
| import etm_types as etm |
| from simpleperf_report_lib import ReportLib |
| from simpleperf_utils import bytes_to_str, BinaryFinder, EtmContext, log_exit, ReadElf, Objdump, ToolFinder |
| |
| |
| class TraceWriter: |
| """A writer for the Fuchsia trace format: |
| https://fuchsia.dev/fuchsia-src/reference/tracing/trace-format |
| """ |
| |
| def __init__(self, output: BinaryIO): |
| self.output = output |
| |
| # The following dictionaries are for keeping track of the fuchsia records already made, to |
| # use them as references. They map the "value" of the record to their index that can be used |
| # as the reference. |
| |
| # Strings are strings. |
| self.strings: Dict[str, int] = {} |
| # Providers are just strings. |
| self.providers: Dict[str, int] = {} |
| # Kernel objects are a type and a name. |
| self.kernel_objects: Dict[Tuple[int, str], int] = {} |
| # Threads are made of two kernel object ids, the first is for the process, the second for |
| # the thread. |
| self.threads: Dict[Tuple[int, int], int] = {} |
| |
| def _write_bits(self, desc: List[Tuple[int, int, int]]) -> None: |
| v = 0 |
| for (start, end, value) in desc: |
| old_value = value |
| # Check if the value fits into its field. |
| value &= (1 << (end+1)) - 1 |
| assert old_value == value |
| v |= value << start |
| self.output.write(v.to_bytes(8, 'little')) |
| |
| def _write_padded(self, array: bytearray) -> None: |
| pad = 8 - (len(array) % 8) |
| if pad == 8: |
| pad = 0 |
| |
| self.output.write(array) |
| self.output.write(bytearray(pad)) |
| |
| def magic(self) -> None: |
| self._write_bits([(0, 3, 0), |
| (4, 15, 1), |
| (16, 19, 4), |
| (20, 23, 0), |
| (24, 55, 0x16547846), |
| (56, 63, 0)]) |
| |
| def switch_provider(self, name: str) -> None: |
| if name not in self.providers: |
| n = bytearray(name, 'utf-8') |
| length = ceil(len(n) / 8) |
| provider_id = len(self.providers) |
| self._write_bits([(0, 3, 0), |
| (4, 15, 1 + length), |
| (20, 51, provider_id), |
| (52, 59, len(n)), |
| (60, 63, 0)]) |
| self._write_padded(n) |
| self.providers[name] = provider_id |
| |
| provider_id = self.providers[name] |
| self._write_bits([(0, 3, 0), |
| (4, 15, 1), |
| (16, 19, 2), |
| (20, 51, provider_id), |
| (52, 63, 0)]) |
| |
| def encode_string(self, value: str) -> int: |
| if value in self.strings: |
| return self.strings[value] |
| |
| if len(self.strings) >= 0x7fff - 1: |
| raise RuntimeError("Ran out of space in the string table!") |
| |
| n = bytearray(value, 'utf-8') |
| length = ceil(len(n) / 8) |
| string_id = len(self.strings) + 1 |
| self._write_bits([(0, 3, 2), |
| (4, 15, 1 + length), |
| (16, 30, string_id), |
| (31, 31, 0), |
| (32, 46, len(n)), |
| (47, 47, 0), |
| (48, 63, 0)]) |
| self._write_padded(n) |
| self.strings[value] = string_id |
| return string_id |
| |
| def kernel_object(self, kobj_type: int, name: str, process: Optional[int] = None) -> int: |
| if (kobj_type, name) in self.kernel_objects: |
| return self.kernel_objects[(kobj_type, name)] |
| |
| name_ref = self.encode_string(name) |
| arg_name = 0 |
| if process: |
| arg_name = self.encode_string("process") |
| |
| self._write_bits([(0, 3, 7), |
| (4, 15, 4 if process else 2), |
| (16, 23, kobj_type), |
| (24, 39, name_ref), |
| (40, 43, 1 if process else 0), |
| (44, 63, 0)]) |
| |
| koid = len(self.kernel_objects) |
| self._write_bits([(0, 63, koid)]) |
| |
| if process: |
| self._write_bits([(0, 3, 8), |
| (4, 15, 2), |
| (16, 31, arg_name), |
| (32, 63, 0)]) |
| self._write_bits([(0, 63, process)]) |
| |
| self.kernel_objects[(kobj_type, name)] = koid |
| return koid |
| |
| def thread(self, process_koid: int, thread_koid: int) -> int: |
| if (process_koid, thread_koid) in self.threads: |
| return self.threads[(process_koid, thread_koid)] |
| |
| thread_index = len(self.threads) + 1 |
| self._write_bits([(0, 3, 3), |
| (4, 15, 3), |
| (16, 23, thread_index), |
| (24, 63, 0)]) |
| self._write_bits([(0, 63, process_koid)]) |
| self._write_bits([(0, 63, thread_koid)]) |
| self.threads[(process_koid, thread_koid)] = thread_index |
| return thread_index |
| |
| def duration(self, begin: bool, thread: int, category: Optional[str], |
| name: Optional[str], timestamp: int) -> None: |
| category_ref = self.encode_string(category) if category else 0 |
| name_ref = self.encode_string(name) if name else 0 |
| self._write_bits([(0, 3, 4), |
| (4, 15, 2), |
| (16, 19, 2 if begin else 3), |
| (20, 23, 0), |
| (24, 31, thread), |
| (32, 47, category_ref), |
| (48, 63, name_ref)]) |
| self._write_bits([(0, 63, timestamp)]) |
| |
| |
| @dataclass |
| class Event: |
| call: bool |
| name: str |
| timestamp: Optional[int] |
| |
| |
| class Stacker: |
| """Stacker tries to keep track of how a thread's stack changes, and uses TraceWriter to |
| generate the corresponding Fuchsia trace. |
| """ |
| |
| def __init__(self, writer: TraceWriter, thread: int): |
| self.writer = writer |
| self.thread = thread |
| self.stack: List[str] = [] |
| self.events: Deque[Event] = deque() |
| self.early_events: List[Event] = [] |
| self.waiting: List[Event] = [] |
| |
| self.reset() |
| |
| def reset(self) -> None: |
| self.was_call = False |
| self.was_ret = False |
| self.was_plt = False |
| self.last_symbol: Optional[str] = None |
| self.exception_name: Optional[str] = None |
| self.excepted_return: Optional[int] = None |
| self.first_timestamp: Optional[int] = None |
| self.last_timestamp: Optional[int] = None |
| |
| def flush(self) -> None: |
| last = -1 |
| for event in chain(self.early_events, self.events): |
| if event.timestamp: |
| assert last <= event.timestamp |
| last = event.timestamp |
| self.writer.duration(event.call, self.thread, None, event.name, event.timestamp) |
| |
| self.early_events.clear() |
| self.events.clear() |
| self.events.extend(self.waiting) |
| |
| def check_waiting(self, timestamp: Optional[int]) -> None: |
| if not timestamp: |
| return |
| |
| if not self.first_timestamp: |
| self.first_timestamp = timestamp |
| self.last_timestamp = timestamp |
| |
| for event in self.waiting: |
| event.timestamp = timestamp |
| |
| self.waiting.clear() |
| |
| def call(self, symbol: str, timestamp: Optional[int], front: bool = False) -> None: |
| event = Event(True, symbol, timestamp) |
| |
| if not timestamp: |
| self.waiting.append(event) |
| |
| for entry in self.stack: |
| if entry.startswith('Exception: '): |
| raise RuntimeError("New call while an exception is still on the stack!", |
| symbol, self.stack) |
| |
| if front and self.events and not self.events[0].call: |
| # If the front of the events is a return, and we need to fake a call at the start, the |
| # returns will have to come before the call, otherwise Perfetto can't deal with it. |
| assert( |
| not timestamp or not self.events[0].timestamp or self.events[0].timestamp <= |
| timestamp) |
| while self.events and not self.events[0].call: |
| self.early_events.append(self.events.popleft()) |
| |
| self.stack.append(symbol) |
| insert = self.events.appendleft if front else self.events.append |
| insert(event) |
| |
| def ret(self, symbol: str, timestamp: Optional[int]) -> None: |
| event = Event(False, symbol, timestamp) |
| if not timestamp: |
| self.waiting.append(event) |
| |
| if self.stack and self.stack[-1] != symbol: |
| raise RuntimeError('Top of the stack does not match the function being returned from! ' |
| f'Current function: {symbol}, top of the stack: {self.stack[-1]}') |
| |
| if self.stack: |
| self.stack.pop() |
| self.events.append(event) |
| |
| def lost_stack(self, timestamp: Optional[int]) -> None: |
| self.check_waiting(timestamp) |
| |
| for symbol in reversed(self.stack): |
| self.ret(symbol, timestamp) |
| self.stack.clear() |
| self.flush() |
| self.reset() |
| |
| def gap(self, timestamp: Optional[int]) -> None: |
| self.last_timestamp = None |
| if self.exception_name: |
| return |
| |
| if self.last_symbol and self.last_symbol.endswith('@plt'): |
| self.was_plt = True |
| return |
| |
| self.lost_stack(timestamp) |
| |
| def timestamp(self, timestamp: Optional[int]) -> None: |
| self.check_waiting(timestamp) |
| |
| def exception(self, timestamp: Optional[int], |
| name: str, excepted_return: Optional[int]) -> None: |
| self.check_waiting(timestamp) |
| |
| if self.exception_name: |
| if excepted_return is not None and self.excepted_return == excepted_return: |
| # Same return, mark the end of the previous but carry on: |
| self.ret(self.exception_name, timestamp) |
| else: |
| # Interrupted, but for a different return. This most likely means some trace was |
| # lost. Drop the stack. |
| self.lost_stack(timestamp) |
| |
| for e in self.stack: |
| if e.startswith('Exception '): |
| raise RuntimeError("Exception while another exception is already on the stack!", |
| name, self.stack) |
| |
| self.exception_name = name |
| self.excepted_return = excepted_return |
| self.call(name, timestamp) |
| if timestamp: |
| self.last_timestamp = timestamp |
| |
| def instr_range(self, timestamp: Optional[int], start: str, start_addr: int, |
| end: str, _end_addr: int, isubtype: etm.InstrSubtype) -> None: |
| self.check_waiting(timestamp) |
| |
| if self.exception_name: |
| self.ret(self.exception_name, timestamp) |
| self.exception_name = None |
| |
| if start_addr != self.excepted_return: |
| # If we are in the same symbol, assume we managed to get back safe and sound. |
| # Otherwise, drop the stack. |
| if not self.stack or self.stack[-1] != start: |
| self.lost_stack(self.last_timestamp) |
| |
| if self.was_plt: |
| self.was_plt = False |
| if len(self.stack) > 1 and self.stack[-2] == start: |
| # When was_plt was set, last_symbol should have been too. |
| assert self.last_symbol |
| self.ret(self.last_symbol, timestamp) |
| self.last_symbol = start |
| else: |
| self.lost_stack(timestamp) |
| |
| if self.last_symbol == 'art_quick_do_long_jump' and self.last_symbol != start: |
| # Art's long jump rewinds the stack to a previous state. |
| self.lost_stack(timestamp) |
| |
| # check_waiting already set the timestamps, but lost_stack above might have nulled them. Set |
| # them again to make sure everything below can use the timestamps correctly. |
| self.first_timestamp = self.first_timestamp or timestamp |
| self.last_timestamp = timestamp or self.last_timestamp |
| |
| if not self.was_call and not self.was_ret and self.last_symbol != start: |
| # If the symbol changed without us detecting a call or a return, we recognize two cases: |
| # if there's a last symbol saved, this was a tail call, and we emit a return and a call. |
| # Otherwise, this is the beginning of a new reconstruction of a stack, and we emit only |
| # the call. |
| if self.last_symbol: |
| # Tail call. |
| self.ret(self.last_symbol, timestamp) |
| self.call(start, timestamp) |
| if self.was_ret and not self.stack: |
| # If we have just returned into a function but the stack is empty, pretend that the |
| # current (new) function has been running since the first known timestamp. |
| self.call(start, self.first_timestamp, front=True) |
| if self.was_call: |
| # If the last instruction of the previous instruction range was a call (branch with |
| # link), emit a call. |
| self.call(start, timestamp) |
| if start != end: |
| # If for some reason the symbol changes inside an instruction range, pretend it was a |
| # tail call, even though this really shouldn't happen. |
| self.ret(start, timestamp) |
| self.call(end, timestamp) |
| if isubtype == etm.InstrSubtype.V8_RET: |
| # If the last instruction of the range is a return, we are returning from the current |
| # symbol. |
| self.ret(end, timestamp) |
| |
| self.was_ret = isubtype == etm.InstrSubtype.V8_RET |
| self.was_call = isubtype == etm.InstrSubtype.BR_LINK |
| self.last_symbol = end |
| if timestamp: |
| self.last_timestamp = timestamp |
| |
| |
| class Tracer: |
| def __init__(self, lib: ReportLib, binary_finder: BinaryFinder, readelf: ReadElf, |
| objdump: Objdump, w: TraceWriter) -> None: |
| self.abort: bool = False |
| |
| self.last_timestamp: Optional[int] = None |
| self.lost_decoding: bool = False |
| |
| self.context: EtmContext = EtmContext() |
| |
| self.lib: ReportLib = lib |
| self.binary_finder: BinaryFinder = binary_finder |
| self.readelf: ReadElf = readelf |
| self.objdump: Objdump = objdump |
| |
| self.processes: Dict[int, str] = {} |
| |
| self.symbols: Dict[str, Tuple[List[Tuple[int, int, str]], List[int]]] = {} |
| self.symbol_cache: Optional[Tuple[str, Tuple[int, int, str]]] = None |
| |
| self.stacks: Dict[int, Stacker] = {} |
| self.w: TraceWriter = w |
| w.magic() |
| w.switch_provider('etm') |
| |
| self.s: Optional[Stacker] = None |
| |
| def __call__(self, trace_id: int, elem: etm.GenericTraceElement) -> None: |
| if self.abort: |
| return |
| |
| try: |
| self.process(trace_id, elem) |
| except Exception as e: |
| self.abort = True |
| self.gap() |
| self.flush() |
| raise e |
| |
| def reset_trace(self) -> None: |
| self.context = EtmContext() |
| self.lost_decoding = False |
| self.last_timestamp = None |
| |
| def process(self, trace_id: int, elem: etm.GenericTraceElement) -> None: |
| if elem.elem_type == etm.ElemType.TRACE_ON or elem.elem_type == etm.ElemType.NO_SYNC: |
| if self.s: |
| self.s.gap(self.last_timestamp) |
| self.reset_trace() |
| return |
| |
| if elem.elem_type == etm.ElemType.PE_CONTEXT: |
| if self.context.update(elem.context): |
| tid = self.context.tid |
| if tid: |
| process = self.lib.GetThread(tid) |
| if process: |
| pid, tid, comm = process |
| else: |
| pid = tid |
| comm = f'Unknown process' |
| else: |
| pid = -1 |
| tid = -1 |
| comm = 'Unknown process' |
| |
| kernel = "" if self.context.ex_level == etm.ExLevel.EL0 else " (kernel)" |
| thread_name = f'{tid} {comm or ""}{kernel}' |
| |
| if pid in self.processes: |
| process_name = self.processes[pid] |
| else: |
| if pid == tid: |
| parent_name = comm |
| else: |
| t = self.lib.GetThread(pid) |
| parent_name = t[2] if t else "Unknown process" |
| process_name = f'{pid} {parent_name or ""}' |
| self.processes[pid] = process_name |
| |
| pobj = self.w.kernel_object(1, process_name) |
| tobj = self.w.kernel_object(2, thread_name, process=pobj) |
| thread = self.w.thread(pobj, tobj) |
| # It is possible that the thread_name changed and we have the old one saved here. |
| # The Fuchsia trace does not seem to have any nice ways to handle changing names, |
| # therefore we are sticking with the first name we saw. |
| if tid not in self.stacks: |
| self.stacks[tid] = Stacker(self.w, thread) |
| |
| self.s = self.stacks[tid] |
| return |
| |
| if elem.elem_type == etm.ElemType.TIMESTAMP: |
| if self.last_timestamp != elem.timestamp: |
| self.last_timestamp = elem.timestamp |
| if self.s: |
| self.s.timestamp(elem.timestamp) |
| |
| # For the other elements, a context should have happened before and must have set s. |
| assert self.s |
| |
| if elem.elem_type == etm.ElemType.ADDR_NACC: |
| if not self.lost_decoding: |
| self.lost_decoding = True |
| mapped = self.lib.ConvertETMAddressToVaddrInFile(trace_id, elem.st_addr) |
| if mapped: |
| print(f'ADDR_NACC: path {mapped[0]} cannot be decoded! ({hex(elem.st_addr)})') |
| else: |
| print(f'ADDR_NACC: trace address {hex(elem.st_addr)} is not mapped!') |
| |
| # We have lost trace. Give up on the stack. |
| # print(self.s.stack) |
| # print(self.last_timestamp, self.s.last_timestamp, self.s.waiting) |
| self.s.lost_stack(self.last_timestamp) |
| # print(self.last_timestamp, self.s.last_timestamp, self.s.waiting) |
| |
| return |
| |
| if elem.elem_type == etm.ElemType.EXCEPTION: |
| name = f'Exception: "{elem.exception_type()}" ({elem.exception_number})!' |
| self.s.exception(self.last_timestamp, name, |
| elem.en_addr if elem.excep_ret_addr else None) |
| return |
| |
| if elem.elem_type != etm.ElemType.INSTR_RANGE: |
| return |
| |
| self.lost_decoding = False |
| |
| start_path, start_offset = self.lib.ConvertETMAddressToVaddrInFile( |
| trace_id, elem.st_addr) or ("", 0) |
| end_path, end_offset = self.lib.ConvertETMAddressToVaddrInFile( |
| trace_id, elem.en_addr - elem.last_instr_sz) or ("", 0) |
| |
| error_messages = [] |
| if not start_path: |
| error_messages.append(f"Couldn't determine start path for address {elem.st_addr}!") |
| if not end_path: |
| error_messages.append( |
| f"Couldn't determine start path for address {elem.en_addr - elem.last_instr_sz}!") |
| if error_messages: |
| raise RuntimeError(' '.join(error_messages)) |
| |
| start = self.get_symbol(start_path, start_offset) or "Unknown" |
| end = self.get_symbol(end_path, end_offset) or "Unknown" |
| |
| if not elem.last_instr_cond and not elem.last_instr_exec: |
| buildid = self.lib.GetBuildIdForPath(end_path) |
| raise RuntimeError(f"Wrong binary! Unconditional branch at {hex(end_offset)}" |
| f" in {end_path} (build id: {buildid}) was not taken!") |
| |
| self.s.instr_range(self.last_timestamp, |
| start, elem.st_addr, |
| end, elem.en_addr, |
| elem.last_i_subtype) |
| |
| def gap(self) -> None: |
| for stack in self.stacks.values(): |
| stack.lost_stack(stack.last_timestamp) |
| |
| def flush(self) -> None: |
| for stack in self.stacks.values(): |
| stack.flush() |
| |
| def get_symbol(self, path: str, offset: int) -> Optional[str]: |
| if (self.symbol_cache and self.symbol_cache[0] == path and |
| (self.symbol_cache[1][0]) <= offset < (self.symbol_cache[1][0] + self.symbol_cache[1][1])): |
| return self.symbol_cache[1][2] |
| |
| (symbols, offsets) = self.get_symbols(path) |
| if symbols: |
| i = bisect(offsets, offset) |
| if i: |
| i -= 1 |
| if symbols[i][0] <= offset < symbols[i][0] + symbols[i][1]: |
| self.symbol_cache = (path, symbols[i]) |
| return symbols[i][2] |
| |
| return None |
| |
| def get_symbols(self, path: str) -> Tuple[List[Tuple[int, int, str]], List[int]]: |
| if path not in self.symbols: |
| s = self.lib.GetSymbols(path) |
| |
| if not s: |
| log_exit(f"Can't find symbols for unknown binary '{path}'!") |
| return ([], []) |
| |
| # Since other use cases don't care about the PLT, simpleperf represents it only as a |
| # single symbol called '@plt'. If it is present, try to get the actual names by parsing |
| # objdump's output. |
| if s[-1][2] == '@plt': |
| dso_info = self.objdump.get_dso_info(path, self.lib.GetBuildIdForPath(path)) |
| plts = self.objdump.get_plt_symbols(dso_info) |
| if plts: |
| del s[-1] |
| s.extend(plts) |
| |
| self.symbols[path] = (s, [e[0] for e in s]) |
| |
| return self.symbols[path] |
| |
| |
| def get_args() -> argparse.Namespace: |
| parser = argparse.ArgumentParser(description='Generate Fuchsia trace from ETM data.') |
| parser.add_argument('-i', '--record_file', nargs=1, default=['perf.data'], help=""" |
| Set profiling data file to process.""") |
| parser.add_argument('--binary_cache', nargs=1, default=["binary_cache"], help=""" |
| Set path to the binary cache.""") |
| parser.add_argument('--ndk_path', nargs=1, help='Find tools in the ndk path.') |
| parser.add_argument('-o', '--output', nargs=1, help=""" |
| Output path for the trace. If not specified, the record file's name is |
| used, with .fxt appended.""") |
| return parser.parse_args() |
| |
| |
| def main() -> None: |
| args = get_args() |
| |
| binary_cache_path = args.binary_cache[0] |
| if not pathlib.Path(binary_cache_path).is_dir(): |
| log_exit(f"Binary cache '{binary_cache_path}' is not a directory!") |
| return |
| |
| ndk_path = args.ndk_path[0] if args.ndk_path else None |
| |
| lib = ReportLib() |
| try: |
| lib.SetRecordFile(args.record_file[0]) |
| lib.SetSymfs(binary_cache_path) |
| lib.SetLogSeverity('error') |
| |
| readelf = ReadElf(ndk_path) |
| binary_finder = BinaryFinder(binary_cache_path, readelf) |
| objdump = Objdump(ndk_path, binary_finder) |
| |
| filename = args.output[0] if args.output else f'{args.record_file[0]}.fxt' |
| |
| with open(filename, 'wb') as f: |
| callback = Tracer(lib, binary_finder, readelf, objdump, TraceWriter(f)) |
| lib.SetETMCallback(callback) |
| while not callback.abort and lib.GetNextSample(): |
| pass |
| |
| # Trace has ended, make sure every call has a corresponding return. Use the largest |
| # timestamp and end everything there. |
| last_timestamps = [stacker.last_timestamp for stacker |
| in callback.stacks.values() |
| if stacker.last_timestamp] |
| last_timestamp = max(last_timestamps, default=None) |
| for stacker in callback.stacks.values(): |
| stacker.last_timestamp = last_timestamp |
| callback.gap() |
| callback.flush() |
| |
| finally: |
| lib.Close() |
| |
| |
| if __name__ == '__main__': |
| main() |