1#!/usr/bin/env python3
2# vim: set syntax=python ts=4 :
3#
4# Copyright (c) 2018 Intel Corporation
5# SPDX-License-Identifier: Apache-2.0
6
7import subprocess
8import sys
9import os
10import re
11import typing
12import logging
13from twisterlib.error import TwisterRuntimeError
14
15logger = logging.getLogger('twister')
16logger.setLevel(logging.DEBUG)
17
18class SizeCalculator:
19    alloc_sections = [
20        "bss",
21        "noinit",
22        "app_bss",
23        "app_noinit",
24        "ccm_bss",
25        "ccm_noinit"
26    ]
27
28    rw_sections = [
29        "datas",
30        "initlevel",
31        "exceptions",
32        "_static_thread_data_area",
33        "k_timer_area",
34        "k_mem_slab_area",
35        "sw_isr_table",
36        "k_sem_area",
37        "k_mutex_area",
38        "app_shmem_regions",
39        "_k_fifo_area",
40        "_k_lifo_area",
41        "k_stack_area",
42        "k_msgq_area",
43        "k_mbox_area",
44        "k_pipe_area",
45        "net_if_area",
46        "net_if_dev_area",
47        "net_l2_area",
48        "net_l2_data",
49        "k_queue_area",
50        "_net_buf_pool_area",
51        "app_datas",
52        "kobject_data",
53        "mmu_tables",
54        "app_pad",
55        "priv_stacks",
56        "ccm_data",
57        "usb_descriptor",
58        "usb_data", "usb_bos_desc",
59        'log_backends_sections',
60        'log_dynamic_sections',
61        'log_const_sections',
62        "app_smem",
63        'shell_root_cmds_sections',
64        'log_const_sections',
65        "priv_stacks_noinit",
66        "_GCOV_BSS_SECTION_NAME",
67        "gcov",
68        "nocache",
69        "devices",
70        "k_heap_area",
71    ]
72
73    # These get copied into RAM only on non-XIP
74    ro_sections = [
75        "rom_start",
76        "text",
77        "ctors",
78        "init_array",
79        "reset",
80        "k_object_assignment_area",
81        "rodata",
82        "net_l2",
83        "vector",
84        "sw_isr_table",
85        "settings_handler_static_area",
86        "bt_l2cap_fixed_chan_area",
87        "bt_l2cap_br_fixed_chan_area",
88        "bt_gatt_service_static_area",
89        "vectors",
90        "net_socket_register_area",
91        "net_ppp_proto",
92        "shell_area",
93        "tracing_backend_area",
94        "ppp_protocol_handler_area",
95    ]
96
97    # Variable below is stored for calculating size using build.log
98    USEFUL_LINES_AMOUNT = 4
99
100    def __init__(self, elf_filename: str,\
101        extra_sections: typing.List[str],\
102        buildlog_filepath: str = '',\
103        generate_warning: bool = True):
104        """Constructor
105
106        @param elf_filename (str) Path to the output binary
107            parsed by objdump to determine section sizes.
108        @param extra_sections (list[str]) List of extra,
109            unexpected sections, which Twister should not
110            report as error and not include in the
111            size calculation.
112        @param buildlog_filepath (str, default: '') Path to the
113            output build.log
114        @param generate_warning (bool, default: True) Twister should
115            (or not) warning about missing the information about
116            footprint.
117        """
118        self.elf_filename = elf_filename
119        self.buildlog_filename = buildlog_filepath
120        self.sections = []
121        self.used_rom = 0
122        self.used_ram = 0
123        self.available_ram = 0
124        self.available_rom = 0
125        self.extra_sections = extra_sections
126        self.is_xip = True
127        self.generate_warning = generate_warning
128
129        self._calculate_sizes()
130
131    def size_report(self):
132        print(self.elf_filename)
133        print("SECTION NAME             VMA        LMA     SIZE  HEX SZ TYPE")
134        for v in self.sections:
135            print("%-17s 0x%08x 0x%08x %8d 0x%05x %-7s" %
136                        (v["name"], v["virt_addr"], v["load_addr"], v["size"], v["size"],
137                        v["type"]))
138
139        print("Totals: %d bytes (ROM), %d bytes (RAM)" %
140                    (self.used_rom, self.used_ram))
141        print("")
142
143    def get_used_ram(self):
144        """Get the amount of RAM the application will use up on the device
145
146        @return amount of RAM, in bytes
147        """
148        return self.used_ram
149
150    def get_used_rom(self):
151        """Get the size of the data that this application uses on device's flash
152
153        @return amount of ROM, in bytes
154        """
155        return self.used_rom
156
157    def unrecognized_sections(self):
158        """Get a list of sections inside the binary that weren't recognized
159
160        @return list of unrecognized section names
161        """
162        slist = []
163        for v in self.sections:
164            if not v["recognized"]:
165                slist.append(v["name"])
166        return slist
167
168    def get_available_ram(self) -> int:
169        """Get the total available RAM.
170
171        @return total available RAM, in bytes (int)
172        """
173        return self.available_ram
174
175    def get_available_rom(self) -> int:
176        """Get the total available ROM.
177
178        @return total available ROM, in bytes (int)
179        """
180        return self.available_rom
181
182    def _calculate_sizes(self):
183        """ELF file is analyzed, even if the option to read memory
184        footprint from the build.log file is set.
185        This is to detect potential problems contained in
186        unrecognized sections of the file.
187        """
188        self._analyze_elf_file()
189        if self.buildlog_filename.endswith("build.log"):
190            self._get_footprint_from_buildlog()
191
192    def _check_elf_file(self) -> None:
193        # Make sure this is an ELF binary
194        with open(self.elf_filename, "rb") as f:
195            magic = f.read(4)
196
197        try:
198            if magic != b'\x7fELF':
199                raise TwisterRuntimeError("%s is not an ELF binary" % self.elf_filename)
200        except Exception as e:
201            print(str(e))
202            sys.exit(2)
203
204    def _check_is_xip(self) -> None:
205        # Search for CONFIG_XIP in the ELF's list of symbols using NM and AWK.
206        # GREP can not be used as it returns an error if the symbol is not
207        # found.
208        is_xip_command = "nm " + self.elf_filename + \
209                         " | awk '/CONFIG_XIP/ { print $3 }'"
210        is_xip_output = subprocess.check_output(
211            is_xip_command, shell=True, stderr=subprocess.STDOUT).decode(
212            "utf-8").strip()
213        try:
214            if is_xip_output.endswith("no symbols"):
215                raise TwisterRuntimeError("%s has no symbol information" % self.elf_filename)
216        except Exception as e:
217            print(str(e))
218            sys.exit(2)
219
220        self.is_xip = len(is_xip_output) != 0
221
222    def _get_info_elf_sections(self) -> None:
223        """Calculate RAM and ROM usage and information about issues by section"""
224        objdump_command = "objdump -h " + self.elf_filename
225        objdump_output = subprocess.check_output(
226            objdump_command, shell=True).decode("utf-8").splitlines()
227
228        for line in objdump_output:
229            words = line.split()
230
231            if not words:  # Skip lines that are too short
232                continue
233
234            index = words[0]
235            if not index[0].isdigit():  # Skip lines that do not start
236                continue  # with a digit
237
238            name = words[1]  # Skip lines with section names
239            if name[0] == '.':  # starting with '.'
240                continue
241
242            # TODO this doesn't actually reflect the size in flash or RAM as
243            # it doesn't include linker-imposed padding between sections.
244            # It is close though.
245            size = int(words[2], 16)
246            if size == 0:
247                continue
248
249            load_addr = int(words[4], 16)
250            virt_addr = int(words[3], 16)
251
252            # Add section to memory use totals (for both non-XIP and XIP scenarios)
253            # Unrecognized section names are not included in the calculations.
254            recognized = True
255
256            # If build.log file exists, check errors (unrecognized sections
257            # in ELF file).
258            if self.buildlog_filename:
259                if name in SizeCalculator.alloc_sections or \
260                   name in SizeCalculator.rw_sections or \
261                   name in SizeCalculator.ro_sections:
262                    continue
263                else:
264                    stype = "unknown"
265                    if name not in self.extra_sections:
266                        recognized = False
267            else:
268                if name in SizeCalculator.alloc_sections:
269                    self.used_ram += size
270                    stype = "alloc"
271                elif name in SizeCalculator.rw_sections:
272                    self.used_ram += size
273                    self.used_rom += size
274                    stype = "rw"
275                elif name in SizeCalculator.ro_sections:
276                    self.used_rom += size
277                    if not self.is_xip:
278                        self.used_ram += size
279                    stype = "ro"
280                else:
281                    stype = "unknown"
282                    if name not in self.extra_sections:
283                        recognized = False
284
285            self.sections.append({"name": name, "load_addr": load_addr,
286                                  "size": size, "virt_addr": virt_addr,
287                                  "type": stype, "recognized": recognized})
288
289    def _analyze_elf_file(self) -> None:
290        self._check_elf_file()
291        self._check_is_xip()
292        self._get_info_elf_sections()
293
294    def _get_buildlog_file_content(self) -> typing.List[str]:
295        """Get content of the build.log file.
296
297        @return Content of the build.log file (list[str])
298        """
299        if os.path.exists(path=self.buildlog_filename):
300            with open(file=self.buildlog_filename, mode='r') as file:
301                file_content = file.readlines()
302        else:
303            if self.generate_warning:
304                logger.error(msg=f"Incorrect path to build.log file to analyze footprints. Please check the path {self.buildlog_filename}.")
305            file_content = []
306        return file_content
307
308    def _find_offset_of_last_pattern_occurrence(self, file_content: typing.List[str]) -> int:
309        """Find the offset from which the information about the memory footprint is read.
310
311        @param file_content (list[str]) Content of build.log.
312        @return Offset with information about the memory footprint (int)
313        """
314        result = -1
315        if len(file_content) == 0:
316            logger.warning("Build.log file is empty.")
317        else:
318            # Pattern to first line with information about memory footprint
319            PATTERN_SEARCHED_LINE = "Memory region"
320            # Check the file in reverse order.
321            for idx, line in enumerate(reversed(file_content)):
322                # Condition is fulfilled if the pattern matches with the start of the line.
323                if re.match(pattern=PATTERN_SEARCHED_LINE, string=line):
324                    result = idx + 1
325                    break
326        # If the file does not contain information about memory footprint, the warning is raised.
327        if result == -1:
328            logger.warning(msg=f"Information about memory footprint for this test configuration is not found. Please check file {self.buildlog_filename}.")
329        return result
330
331    def _get_lines_with_footprint(self, start_offset: int, file_content: typing.List[str]) -> typing.List[str]:
332        """Get lines from the file with a memory footprint.
333
334        @param start_offset (int) Offset with the first line of the information about memory footprint.
335        @param file_content (list[str]) Content of the build.log file.
336        @return Lines with information about memory footprint (list[str])
337        """
338        if len(file_content) == 0:
339            result = []
340        else:
341            if start_offset > len(file_content) or start_offset <= 0:
342                info_line_idx_start = len(file_content) - 1
343            else:
344                info_line_idx_start = len(file_content) - start_offset
345
346            info_line_idx_stop = info_line_idx_start + self.USEFUL_LINES_AMOUNT
347            if info_line_idx_stop > len(file_content):
348                info_line_idx_stop = len(file_content)
349
350            result = file_content[info_line_idx_start:info_line_idx_stop]
351        return result
352
353    def _clear_whitespaces_from_lines(self, text_lines: typing.List[str]) -> typing.List[str]:
354        """Clear text lines from whitespaces.
355
356        @param text_lines (list[str]) Lines with useful information.
357        @return  Cleared text lines with information (list[str])
358        """
359        return [line.strip("\n").rstrip("%") for line in text_lines] if text_lines else []
360
361    def _divide_text_lines_into_columns(self, text_lines: typing.List[str]) -> typing.List[typing.List[str]]:
362        """Divide lines of text into columns.
363
364        @param lines (list[list[str]]) Lines with information about memory footprint.
365        @return Lines divided into columns (list[list[str]])
366        """
367        if text_lines:
368            result = []
369            PATTERN_SPLIT_COLUMNS = "  +"
370            for line in text_lines:
371                line = [column.rstrip(":") for column in re.split(pattern=PATTERN_SPLIT_COLUMNS, string=line)]
372                result.append(list(filter(None, line)))
373        else:
374            result = [[]]
375
376        return result
377
378    def _unify_prefixes_on_all_values(self, data_lines: typing.List[typing.List[str]]) -> typing.List[typing.List[str]]:
379        """Convert all values in the table to unified order of magnitude.
380
381        @param data_lines (list[list[str]]) Lines with information about memory footprint.
382        @return Lines with unified values (list[list[str]])
383        """
384        if len(data_lines) != self.USEFUL_LINES_AMOUNT:
385            data_lines = [[]]
386            if self.generate_warning:
387                logger.warning(msg=f"Incomplete information about memory footprint. Please check file {self.buildlog_filename}")
388        else:
389            for idx, line in enumerate(data_lines):
390                # Line with description of the columns
391                if idx == 0:
392                    continue
393                line_to_replace = list(map(self._binary_prefix_converter, line))
394                data_lines[idx] = line_to_replace
395
396        return data_lines
397
398    def _binary_prefix_converter(self, value: str) -> str:
399        """Convert specific value to particular prefix.
400
401        @param value (str) Value to convert.
402        @return Converted value to output prefix (str)
403        """
404        PATTERN_VALUE = r"([0-9]?\s.?B\Z)"
405
406        if not re.search(pattern=PATTERN_VALUE, string=value):
407            converted_value = value.rstrip()
408        else:
409            PREFIX_POWER = {'B': 0, 'KB': 10, 'MB': 20, 'GB': 30}
410            DEFAULT_DATA_PREFIX = 'B'
411
412            data_parts = value.split()
413            numeric_value = int(data_parts[0])
414            unit = data_parts[1]
415            shift = PREFIX_POWER.get(unit, 0) - PREFIX_POWER.get(DEFAULT_DATA_PREFIX, 0)
416            unit_predictor = pow(2, shift)
417            converted_value = str(numeric_value * unit_predictor)
418        return converted_value
419
420    def _create_data_table(self) -> typing.List[typing.List[str]]:
421        """Create table with information about memory footprint.
422
423        @return Table with information about memory usage (list[list[str]])
424        """
425        file_content = self._get_buildlog_file_content()
426        data_line_start_idx = self._find_offset_of_last_pattern_occurrence(file_content=file_content)
427
428        if data_line_start_idx < 0:
429            data_from_content = [[]]
430        else:
431            # Clean lines and separate information to columns
432            information_lines = self._get_lines_with_footprint(start_offset=data_line_start_idx, file_content=file_content)
433            information_lines = self._clear_whitespaces_from_lines(text_lines=information_lines)
434            data_from_content = self._divide_text_lines_into_columns(text_lines=information_lines)
435            data_from_content = self._unify_prefixes_on_all_values(data_lines=data_from_content)
436
437        return data_from_content
438
439    def _get_footprint_from_buildlog(self) -> None:
440        """Get memory footprint from build.log"""
441        data_from_file = self._create_data_table()
442
443        if data_from_file == [[]] or not data_from_file:
444            self.used_ram = 0
445            self.used_rom = 0
446            self.available_ram = 0
447            self.available_rom = 0
448            if self.generate_warning:
449                logger.warning(msg=f"Missing information about memory footprint. Check file {self.buildlog_filename}.")
450        else:
451            ROW_RAM_IDX = 2
452            ROW_ROM_IDX = 1
453            COLUMN_USED_SIZE_IDX = 1
454            COLUMN_AVAILABLE_SIZE_IDX = 2
455
456            self.used_ram = int(data_from_file[ROW_RAM_IDX][COLUMN_USED_SIZE_IDX])
457            self.used_rom = int(data_from_file[ROW_ROM_IDX][COLUMN_USED_SIZE_IDX])
458            self.available_ram = int(data_from_file[ROW_RAM_IDX][COLUMN_AVAILABLE_SIZE_IDX])
459            self.available_rom = int(data_from_file[ROW_ROM_IDX][COLUMN_AVAILABLE_SIZE_IDX])
460