1#!/usr/bin/env python3
2#
3# Copyright (c) 2010-2025 Antmicro
4#
5# This file is licensed under the MIT License.
6# Full license text is available in 'licenses/MIT.txt'.
7#
8
9import argparse
10import platform
11import sys
12import os
13import gzip
14import typing
15from enum import Enum
16
17from ctypes import cdll, c_char_p, POINTER, c_void_p, c_ubyte, c_uint64, c_byte, c_size_t, cast
18
19# Allow directly using this as a script, without installation
20try:
21    import execution_tracer.dwarf as dwarf
22    import execution_tracer.coverview_integration as coverview_integration
23except ImportError:
24    import dwarf
25    import coverview_integration
26
27FILE_SIGNATURE = b"ReTrace"
28FILE_VERSION = b"\x04"
29HEADER_LENGTH = 10
30MEMORY_ACCESS_LENGTH = 25
31RISCV_VECTOR_CONFIGURATION_LENGTH = 16
32
33
34class AdditionalDataType(Enum):
35    Empty = 0
36    MemoryAccess = 1
37    RiscVVectorConfiguration = 2
38
39
40class MemoryAccessType(Enum):
41    MemoryIORead = 0
42    MemoryIOWrite = 1
43    MemoryRead = 2
44    MemoryWrite = 3
45    InsnFetch = 4
46
47class Header():
48    def __init__(self, pc_length, has_opcodes, extra_length=0, uses_thumb_flag=False, triple_and_model=None):
49        self.pc_length = pc_length
50        self.has_opcodes = has_opcodes
51        self.extra_length = extra_length
52        self.uses_thumb_flag = uses_thumb_flag
53        self.triple_and_model = triple_and_model
54
55    def __str__(self):
56        return "Header: pc_length: {}, has_opcodes: {}, extra_length: {}, uses_thumb_flag: {}, triple_and_model: {}".format(
57            self.pc_length, self.has_opcodes, self.extra_length, self.uses_thumb_flag, self.triple_and_model)
58
59
60def read_header(file):
61    if file.read(len(FILE_SIGNATURE)) != FILE_SIGNATURE:
62        raise InvalidFileFormatException("File signature isn't detected.")
63
64    version = file.read(1)
65    if version != FILE_VERSION:
66        raise InvalidFileFormatException(f"Unsuported file format version {version}, expected {FILE_VERSION}")
67
68    pc_length_raw = file.read(1)
69    opcodes_raw = file.read(1)
70    if len(pc_length_raw) != 1 or len(opcodes_raw) != 1:
71        raise InvalidFileFormatException("Invalid file header")
72
73    if opcodes_raw[0] == 0:
74        return Header(pc_length_raw[0], False, 0, False, None)
75    elif opcodes_raw[0] == 1:
76        uses_thumb_flag_raw = file.read(1)
77        identifier_length_raw = file.read(1)
78        if len(uses_thumb_flag_raw) != 1 or len(identifier_length_raw) != 1:
79            raise InvalidFileFormatException("Invalid file header")
80
81        uses_thumb_flag = uses_thumb_flag_raw[0] == 1
82        identifier_length = identifier_length_raw[0]
83        triple_and_model_raw = file.read(identifier_length)
84        if len(triple_and_model_raw) != identifier_length:
85            raise InvalidFileFormatException("Invalid file header")
86
87        triple_and_model = triple_and_model_raw.decode("utf-8")
88        extra_length = 2 + identifier_length
89
90        return Header(pc_length_raw[0], True, extra_length, uses_thumb_flag, triple_and_model)
91    else:
92        raise InvalidFileFormatException("Invalid opcodes field at file header")
93
94
95def read_file(file, disassemble, llvm_disas_path):
96    header = read_header(file)
97    return TraceData(file, header, disassemble, llvm_disas_path)
98
99
100def bytes_to_hex(bytes, zero_padded=True):
101    integer = int.from_bytes(bytes, byteorder="little", signed=False)
102    format_string = "0{}X".format(len(bytes)*2) if zero_padded else "X"
103    return "0x{0:{fmt}}".format(integer, fmt=format_string)
104
105
106class TraceData:
107    disassembler = None
108    disassembler_thumb = None
109    thumb_mode = False
110    instructions_left_in_block = 0
111
112    def __init__(self, file: typing.IO, header: Header, disassemble: bool, llvm_disas_path: str):
113        self.file = file
114        self.pc_length = int(header.pc_length)
115        self.has_pc = (self.pc_length != 0)
116        self.has_opcodes = bool(header.has_opcodes)
117        self.extra_length = header.extra_length
118        self.uses_thumb_flag = header.uses_thumb_flag
119        self.triple_and_model = header.triple_and_model
120        self.disassemble = disassemble
121        if self.disassemble:
122            triple, model = header.triple_and_model.split(" ")
123            self.disassembler = LLVMDisassembler(triple, model, llvm_disas_path)
124            if self.uses_thumb_flag:
125                self.disassembler_thumb = LLVMDisassembler("thumb", model, llvm_disas_path)
126
127    def __iter__(self):
128        self.file.seek(HEADER_LENGTH + self.extra_length, 0)
129        return self
130
131    def __next__(self):
132        additional_data = []
133
134        if self.uses_thumb_flag and self.instructions_left_in_block == 0:
135            thumb_flag_raw = self.file.read(1)
136            if len(thumb_flag_raw) != 1:
137                # No more data frames to read
138                raise StopIteration
139
140            self.thumb_mode = thumb_flag_raw[0] == 1
141
142            block_length_raw = self.file.read(8)
143            if len(block_length_raw) != 8:
144                raise InvalidFileFormatException("Unexpected end of file")
145
146            # The `instructions_left_in_block` counter is kept only for traces produced by cores that can switch between ARM and Thumb mode.
147            self.instructions_left_in_block = int.from_bytes(block_length_raw, byteorder="little", signed=False)
148
149        if self.uses_thumb_flag:
150            self.instructions_left_in_block -= 1
151
152        pc = self.file.read(self.pc_length)
153        opcode_length = self.file.read(int(self.has_opcodes))
154
155        if self.pc_length != len(pc):
156            # No more data frames to read
157            raise StopIteration
158        if self.has_opcodes and len(opcode_length) == 0:
159            if self.has_pc:
160                raise InvalidFileFormatException("Unexpected end of file")
161            else:
162                # No more data frames to read
163                raise StopIteration
164
165        if self.has_opcodes:
166            opcode_length = opcode_length[0]
167            opcode = self.file.read(opcode_length)
168            if len(opcode) != opcode_length:
169                raise InvalidFileFormatException("Unexpected end of file")
170        else:
171            opcode = b""
172
173        additional_data_type = AdditionalDataType(self.file.read(1)[0])
174        while (additional_data_type is not AdditionalDataType.Empty):
175            if additional_data_type is AdditionalDataType.MemoryAccess:
176                additional_data.append(self.parse_memory_access_data())
177            elif additional_data_type is AdditionalDataType.RiscVVectorConfiguration:
178                additional_data.append(self.parse_riscv_vector_configuration_data())
179
180            try:
181                additional_data_type = AdditionalDataType(self.file.read(1)[0])
182            except IndexError:
183                break
184        return (pc, opcode, additional_data, self.thumb_mode)
185
186    def parse_memory_access_data(self):
187        data = self.file.read(MEMORY_ACCESS_LENGTH)
188        if len(data) != MEMORY_ACCESS_LENGTH:
189            raise InvalidFileFormatException("Unexpected end of file")
190        type = MemoryAccessType(data[0])
191        address = bytes_to_hex(data[1:9], zero_padded=False)
192        value = bytes_to_hex(data[9:17], zero_padded=False)
193        address_physical = bytes_to_hex(data[17:], zero_padded=False)
194
195        if address == address_physical:
196            return f"{type.name} with address {address}, value {value}"
197        else:
198            return f"{type.name} with address {address} => {address_physical}, value {value}"
199
200
201    def parse_riscv_vector_configuration_data(self):
202        data = self.file.read(RISCV_VECTOR_CONFIGURATION_LENGTH)
203        if len(data) != RISCV_VECTOR_CONFIGURATION_LENGTH:
204            raise InvalidFileFormatException("Unexpected end of file")
205        vl = bytes_to_hex(data[0:8], zero_padded=False)
206        vtype = bytes_to_hex(data[8:16], zero_padded=False)
207        return f"Vector configured to VL: {vl}, VTYPE: {vtype}"
208
209    def format_entry(self, entry):
210        (pc, opcode, additional_data, thumb_mode) = entry
211        if self.pc_length:
212            pc_str = bytes_to_hex(pc)
213        if self.has_opcodes:
214            opcode_str = bytes_to_hex(opcode)
215        output = ""
216        if self.pc_length and self.has_opcodes:
217            output = f"{pc_str}: {opcode_str}"
218        elif self.pc_length:
219            output = pc_str
220        elif self.has_opcodes:
221            output = opcode_str
222        else:
223            output = ""
224
225        if self.has_opcodes and self.disassemble:
226            disas = self.disassembler_thumb if thumb_mode else self.disassembler
227            _, instruction = disas.get_instruction(opcode)
228            output += " " + instruction.decode("utf-8")
229
230        if len(additional_data) > 0:
231            output += "\n" + "\n".join(additional_data)
232
233        return output
234
235
236class InvalidFileFormatException(Exception):
237    pass
238
239
240class LLVMDisassembler():
241    def __init__(self, triple, cpu, llvm_disas_path):
242        try:
243            self.lib = cdll.LoadLibrary(llvm_disas_path)
244        except OSError:
245            raise FileNotFoundError('Could not find valid `libllvm-disas` library. Please specify the correct path with the --llvm-disas-path argument.')
246
247        self.__init_library()
248
249        self._context = self.lib.llvm_create_disasm_cpu(c_char_p(triple.encode('utf-8')), c_char_p(cpu.encode('utf-8')))
250        if not self._context:
251            raise RuntimeError('CPU or triple name not detected by LLVM. Disassembling will not be possible.')
252
253    def __del__(self):
254        if  hasattr(self, '_context'):
255            self.lib.llvm_disasm_dispose(self._context)
256
257    def __init_library(self):
258        self.lib.llvm_create_disasm_cpu.argtypes = [c_char_p, c_char_p]
259        self.lib.llvm_create_disasm_cpu.restype = POINTER(c_void_p)
260
261        self.lib.llvm_disasm_dispose.argtypes = [POINTER(c_void_p)]
262
263        self.lib.llvm_disasm_instruction.argtypes = [POINTER(c_void_p), POINTER(c_ubyte), c_uint64, c_char_p, c_size_t]
264        self.lib.llvm_disasm_instruction.restype = c_size_t
265
266    def get_instruction(self, opcode):
267        opcode_buf = cast(c_char_p(opcode), POINTER(c_ubyte))
268        disas_str = cast((c_byte * 1024)(), c_char_p)
269
270        bytes_read = self.lib.llvm_disasm_instruction(self._context, opcode_buf, c_uint64(len(opcode)), disas_str, 1024)
271
272        return (bytes_read, disas_str.value)
273
274
275def print_coverage_report(report):
276    for line in report:
277        yield f"{line.most_executions():5d}:\t {line.content.rstrip()}"
278
279
280def handle_coverage(args, trace_data):
281    coverage_config = dwarf.Coverage(
282        elf_file_handler=args.coverage_binary,
283        code_filenames=args.coverage_code,
284        substitute_paths=args.sub_source_path,
285        debug=args.debug,
286        print_unmatched_address=args.print_unmatched_address,
287        lazy_line_cache=args.lazy_line_cache,
288    )
289
290    report = coverage_config.report_coverage(trace_data)
291    if args.legacy:
292        printed_report = print_coverage_report(report)
293    else:
294        printed_report = coverage_config.convert_to_lcov(report)
295
296    if args.coverage_output != None:
297        if args.export_for_coverview:
298            if not coverview_integration.create_coverview_archive(
299                        args.coverage_output,
300                        printed_report,
301                        coverage_config._code_files,
302                        args.coverview_config
303                    ):
304                sys.exit(1)
305        else:
306            for line in printed_report:
307                args.coverage_output.write(f"{line}\n")
308    else:
309        for line in printed_report:
310            print(line)
311
312
313def main():
314    parser = argparse.ArgumentParser(description="Renode's ExecutionTracer binary format reader")
315    parser.add_argument("--debug", default=False, action="store_true", help="enable additional debug logs to stdout")
316    parser.add_argument("--decompress", action="store_true", default=False,
317        help="decompress trace file, without the flag decompression is enabled based on a file extension")
318    parser.add_argument("--force-disable-decompression", action="store_true", default=False, help="never attempt to decompress the trace file")
319
320    subparsers = parser.add_subparsers(title='subcommands', dest='subcommands', required=True)
321    trace_parser = subparsers.add_parser('inspect', help='Inspect the binary trace format')
322    trace_parser.add_argument("file", help="binary trace file")
323
324    trace_parser.add_argument("--disassemble", action="store_true", default=False)
325    trace_parser.add_argument("--llvm-disas-path", default=None, help="path to libllvm-disas library")
326
327    cov_parser = subparsers.add_parser('coverage', help='Generate coverage reports')
328    cov_parser.add_argument("file", help="binary trace file")
329
330    cov_parser.add_argument("--binary", dest='coverage_binary', required=True, default=None, type=argparse.FileType('rb'), help="path to an ELF file with DWARF data")
331    cov_parser.add_argument("--sources", dest='coverage_code', default=None, nargs='+', type=str, help="path to a (list of) source file(s)")
332    cov_parser.add_argument("--output", dest='coverage_output', default=None, type=argparse.FileType('w'), help="path to the output coverage file")
333    cov_parser.add_argument("--legacy", default=False, action="store_true", help="Output data in a legacy text-based format")
334    cov_parser.add_argument("--export-for-coverview", default=False, action="store_true", help="Pack data to a format compatible with the Coverview project (https://github.com/antmicro/coverview)")
335    cov_parser.add_argument("--coverview-config", default=None, type=str, help="Provide parameters for Coverview integration configuration JSON")
336    cov_parser.add_argument("--print-unmatched-address", default=False, action="store_true", help="Print addresses not matched to any source lines")
337    cov_parser.add_argument("--sub-source-path", default=[], nargs='*', action='extend', type=dwarf.PathSubstitution.from_arg, help="Substitute a part of sources' path. Format is: old_path:new_path")
338    cov_parser.add_argument("--lazy-line-cache", default=False, action="store_true", help="Disable line to address eager cache generation. For big programs, reduce memory usage, but process traces much slower")
339    args = parser.parse_args()
340
341    # Look for the libllvm-disas library in default location
342    if args.subcommands == 'inspect' and args.disassemble and args.llvm_disas_path is None:
343        p = platform.system()
344        if p == 'Darwin':
345            ext = '.dylib'
346        elif p == 'Windows':
347            ext = '.dll'
348        else:
349            ext = '.so'
350
351        lib_name = 'libllvm-disas' + ext
352
353        lib_search_paths = [
354            os.path.join(os.path.dirname(os.path.realpath(__file__)), os.pardir, os.pardir, "lib", "resources", "llvm"),
355            os.path.dirname(os.path.realpath(__file__)),
356            os.getcwd()
357        ]
358
359        for search_path in lib_search_paths:
360            lib_path = os.path.join(search_path, lib_name)
361            if os.path.isfile(lib_path):
362                args.llvm_disas_path = lib_path
363                break
364
365        if args.llvm_disas_path is None:
366            raise FileNotFoundError('Could not find ' + lib_name + ' in any of the following locations: ' + ', '.join([os.path.abspath(path) for path in lib_search_paths]))
367
368    try:
369        filename, file_extension = os.path.splitext(args.file)
370        if (args.decompress or file_extension == ".gz") and not args.force_disable_decompression:
371            file_open = gzip.open
372        else:
373            file_open = open
374
375        with file_open(args.file, "rb") as file:
376            if args.subcommands == 'coverage':
377                trace_data = read_file(file, False, None)
378            else:
379                trace_data = read_file(file, args.disassemble, args.llvm_disas_path)
380            if args.subcommands == 'coverage':
381                if args.export_for_coverview:
382                    if args.legacy:
383                        print("'--export-for-coverview' implies LCOV-compatible format")
384                        args.legacy = False
385                    if not args.coverage_output:
386                        raise ValueError("Specify a file with '--output' when packing an archive for Coverview")
387
388                handle_coverage(args, trace_data)
389            else:
390                for entry in trace_data:
391                    print(trace_data.format_entry(entry))
392    except BrokenPipeError:
393        # Avoid crashing when piping the results e.g. to less
394        sys.exit(0)
395    except (ValueError, RuntimeError) as err:
396        sys.exit(f"Error during execution: {err}")
397    except (FileNotFoundError, InvalidFileFormatException) as err:
398        sys.exit(f"Error while loading file: {err}")
399    except KeyboardInterrupt:
400        sys.exit(1)
401
402if __name__ == "__main__":
403    main()
404