"""Memory usage analyzer for ESPHome compiled binaries.""" from collections import defaultdict import json import logging from pathlib import Path import re import subprocess _LOGGER = logging.getLogger(__name__) # Pattern to extract ESPHome component namespaces dynamically ESPHOME_COMPONENT_PATTERN = re.compile(r"esphome::([a-zA-Z0-9_]+)::") # Component identification rules # Symbol patterns: patterns found in raw symbol names SYMBOL_PATTERNS = { "freertos": [ "vTask", "xTask", "xQueue", "pvPort", "vPort", "uxTask", "pcTask", "prvTimerTask", "prvAddNewTaskToReadyList", "pxReadyTasksLists", "prvAddCurrentTaskToDelayedList", "xEventGroupWaitBits", "xRingbufferSendFromISR", "prvSendItemDoneNoSplit", "prvReceiveGeneric", "prvSendAcquireGeneric", "prvCopyItemAllowSplit", "xEventGroup", "xRingbuffer", "prvSend", "prvReceive", "prvCopy", "xPort", "ulTaskGenericNotifyTake", "prvIdleTask", "prvInitialiseNewTask", "prvIsYieldRequiredSMP", "prvGetItemByteBuf", "prvInitializeNewRingbuffer", "prvAcquireItemNoSplit", "prvNotifyQueueSetContainer", "ucStaticTimerQueueStorage", "eTaskGetState", "main_task", "do_system_init_fn", "xSemaphoreCreateGenericWithCaps", "vListInsert", "uxListRemove", "vRingbufferReturnItem", "vRingbufferReturnItemFromISR", "prvCheckItemFitsByteBuffer", "prvGetCurMaxSizeAllowSplit", "tick_hook", "sys_sem_new", "sys_arch_mbox_fetch", "sys_arch_sem_wait", "prvDeleteTCB", "vQueueDeleteWithCaps", "vRingbufferDeleteWithCaps", "vSemaphoreDeleteWithCaps", "prvCheckItemAvail", "prvCheckTaskCanBeScheduledSMP", "prvGetCurMaxSizeNoSplit", "prvResetNextTaskUnblockTime", "prvReturnItemByteBuf", "vApplicationStackOverflowHook", "vApplicationGetIdleTaskMemory", "sys_init", "sys_mbox_new", "sys_arch_mbox_tryfetch", ], "xtensa": ["xt_", "_xt_", "xPortEnterCriticalTimeout"], "heap": ["heap_", "multi_heap"], "spi_flash": ["spi_flash"], "rtc": ["rtc_", "rtcio_ll_"], "gpio_driver": ["gpio_", "pins"], "uart_driver": ["uart", "_uart", "UART"], "timer": ["timer_", "esp_timer"], "peripherals": ["periph_", "periman"], "network_stack": [ "vj_compress", "raw_sendto", "raw_input", "etharp_", "icmp_input", "socket_ipv6", "ip_napt", "socket_ipv4_multicast", "socket_ipv6_multicast", "netconn_", "recv_raw", "accept_function", "netconn_recv_data", "netconn_accept", "netconn_write_vectors_partly", "netconn_drain", "raw_connect", "raw_bind", "icmp_send_response", "sockets", "icmp_dest_unreach", "inet_chksum_pseudo", "alloc_socket", "done_socket", "set_global_fd_sets", "inet_chksum_pbuf", "tryget_socket_unconn_locked", "tryget_socket_unconn", "cs_create_ctrl_sock", "netbuf_alloc", ], "ipv6_stack": ["nd6_", "ip6_", "mld6_", "icmp6_", "icmp6_input"], "wifi_stack": [ "ieee80211", "hostap", "sta_", "ap_", "scan_", "wifi_", "wpa_", "wps_", "esp_wifi", "cnx_", "wpa3_", "sae_", "wDev_", "ic_", "mac_", "esf_buf", "gWpaSm", "sm_WPA", "eapol_", "owe_", "wifiLowLevelInit", "s_do_mapping", "gScanStruct", "ppSearchTxframe", "ppMapWaitTxq", "ppFillAMPDUBar", "ppCheckTxConnTrafficIdle", "ppCalTkipMic", ], "bluetooth": ["bt_", "ble_", "l2c_", "gatt_", "gap_", "hci_", "BT_init"], "wifi_bt_coex": ["coex"], "bluetooth_rom": ["r_ble", "r_lld", "r_llc", "r_llm"], "bluedroid_bt": [ "bluedroid", "btc_", "bta_", "btm_", "btu_", "BTM_", "GATT", "L2CA_", "smp_", "gatts_", "attp_", "l2cu_", "l2cb", "smp_cb", "BTA_GATTC_", "SMP_", "BTU_", "BTA_Dm", "GAP_Ble", "BT_tx_if", "host_recv_pkt_cb", "saved_local_oob_data", "string_to_bdaddr", "string_is_bdaddr", "CalConnectParamTimeout", "transmit_fragment", "transmit_data", "event_command_ready", "read_command_complete_header", "parse_read_local_extended_features_response", "parse_read_local_version_info_response", "should_request_high", "btdm_wakeup_request", "BTA_SetAttributeValue", "BTA_EnableBluetooth", "transmit_command_futured", "transmit_command", "get_waiting_command", "make_command", "transmit_downward", "host_recv_adv_packet", "copy_extra_byte_in_db", "parse_read_local_supported_commands_response", ], "crypto_math": [ "ecp_", "bignum_", "mpi_", "sswu", "modp", "dragonfly_", "gcm_mult", "__multiply", "quorem", "__mdiff", "__lshift", "__mprec_tens", "ECC_", "multiprecision_", "mix_sub_columns", "sbox", "gfm2_sbox", "gfm3_sbox", "curve_p256", "curve", "p_256_init_curve", "shift_sub_rows", "rshift", ], "hw_crypto": ["esp_aes", "esp_sha", "esp_rsa", "esp_bignum", "esp_mpi"], "libc": [ "printf", "scanf", "malloc", "free", "memcpy", "memset", "strcpy", "strlen", "_dtoa", "_fopen", "__sfvwrite_r", "qsort", "__sf", "__sflush_r", "__srefill_r", "_impure_data", "_reclaim_reent", "_open_r", "strncpy", "_strtod_l", "__gethex", "__hexnan", "_setenv_r", "_tzset_unlocked_r", "__tzcalc_limits", "select", "scalbnf", "strtof", "strtof_l", "__d2b", "__b2d", "__s2b", "_Balloc", "__multadd", "__lo0bits", "__atexit0", "__smakebuf_r", "__swhatbuf_r", "_sungetc_r", "_close_r", "_link_r", "_unsetenv_r", "_rename_r", "__month_lengths", "tzinfo", "__ratio", "__hi0bits", "__ulp", "__any_on", "__copybits", "L_shift", "_fcntl_r", "_lseek_r", "_read_r", "_write_r", "_unlink_r", "_fstat_r", "access", "fsync", "tcsetattr", "tcgetattr", "tcflush", "tcdrain", "__ssrefill_r", "_stat_r", "__hexdig_fun", "__mcmp", "_fwalk_sglue", "__fpclassifyf", "_setlocale_r", "_mbrtowc_r", "fcntl", "__match", "_lock_close", "__c$", "__func__$", "__FUNCTION__$", "DAYS_IN_MONTH", "_DAYS_BEFORE_MONTH", "CSWTCH$", "dst$", "sulp", ], "string_ops": ["strcmp", "strncmp", "strchr", "strstr", "strtok", "strdup"], "memory_alloc": ["malloc", "calloc", "realloc", "free", "_sbrk"], "file_io": [ "fread", "fwrite", "fopen", "fclose", "fseek", "ftell", "fflush", "s_fd_table", ], "string_formatting": [ "snprintf", "vsnprintf", "sprintf", "vsprintf", "sscanf", "vsscanf", ], "cpp_anonymous": ["_GLOBAL__N_", "n$"], "cpp_runtime": ["__cxx", "_ZN", "_ZL", "_ZSt", "__gxx_personality", "_Z16"], "exception_handling": ["__cxa_", "_Unwind_", "__gcc_personality", "uw_frame_state"], "static_init": ["_GLOBAL__sub_I_"], "mdns_lib": ["mdns"], "phy_radio": [ "phy_", "rf_", "chip_", "register_chipv7", "pbus_", "bb_", "fe_", "rfcal_", "ram_rfcal", "tx_pwctrl", "rx_chan", "set_rx_gain", "set_chan", "agc_reg", "ram_txiq", "ram_txdc", "ram_gen_rx_gain", "rx_11b_opt", "set_rx_sense", "set_rx_gain_cal", "set_chan_dig_gain", "tx_pwctrl_init_cal", "rfcal_txiq", "set_tx_gain_table", "correct_rfpll_offset", "pll_correct_dcap", "txiq_cal_init", "pwdet_sar", "pwdet_sar2_init", "ram_iq_est_enable", "ram_rfpll_set_freq", "ant_wifirx_cfg", "ant_btrx_cfg", "force_txrxoff", "force_txrx_off", "tx_paon_set", "opt_11b_resart", "rfpll_1p2_opt", "ram_dc_iq_est", "ram_start_tx_tone", "ram_en_pwdet", "ram_cbw2040_cfg", "rxdc_est_min", "i2cmst_reg_init", "temprature_sens_read", "ram_restart_cal", "ram_write_gain_mem", "ram_wait_rfpll_cal_end", "txcal_debuge_mode", "ant_wifitx_cfg", "reg_init_begin", ], "wifi_phy_pp": ["pp_", "ppT", "ppR", "ppP", "ppInstall", "ppCalTxAMPDULength"], "wifi_lmac": ["lmac"], "wifi_device": ["wdev", "wDev_"], "power_mgmt": [ "pm_", "sleep", "rtc_sleep", "light_sleep", "deep_sleep", "power_down", "g_pm", ], "memory_mgmt": [ "mem_", "memory_", "tlsf_", "memp_", "pbuf_", "pbuf_alloc", "pbuf_copy_partial_pbuf", ], "hal_layer": ["hal_"], "clock_mgmt": [ "clk_", "clock_", "rtc_clk", "apb_", "cpu_freq", "setCpuFrequencyMhz", ], "cache_mgmt": ["cache"], "flash_ops": ["flash", "image_load"], "interrupt_handlers": [ "isr", "interrupt", "intr_", "exc_", "exception", "port_IntStack", ], "wrapper_functions": ["_wrapper"], "error_handling": ["panic", "abort", "assert", "error_", "fault"], "authentication": ["auth"], "ppp_protocol": ["ppp", "ipcp_", "lcp_", "chap_", "LcpEchoCheck"], "dhcp": ["dhcp", "handle_dhcp"], "ethernet_phy": [ "emac_", "eth_phy_", "phy_tlk110", "phy_lan87", "phy_ip101", "phy_rtl", "phy_dp83", "phy_ksz", "lan87xx_", "rtl8201_", "ip101_", "ksz80xx_", "jl1101_", "dp83848_", "eth_on_state_changed", ], "threading": ["pthread_", "thread_", "_task_"], "pthread": ["pthread"], "synchronization": ["mutex", "semaphore", "spinlock", "portMUX"], "math_lib": [ "sin", "cos", "tan", "sqrt", "pow", "exp", "log", "atan", "asin", "acos", "floor", "ceil", "fabs", "round", ], "random": ["rand", "random", "rng_", "prng"], "time_lib": [ "time", "clock", "gettimeofday", "settimeofday", "localtime", "gmtime", "mktime", "strftime", ], "console_io": ["console_", "uart_tx", "uart_rx", "puts", "putchar", "getchar"], "rom_functions": ["r_", "rom_"], "compiler_runtime": [ "__divdi3", "__udivdi3", "__moddi3", "__muldi3", "__ashldi3", "__ashrdi3", "__lshrdi3", "__cmpdi2", "__fixdfdi", "__floatdidf", ], "libgcc": ["libgcc", "_divdi3", "_udivdi3"], "boot_startup": ["boot", "start_cpu", "call_start", "startup", "bootloader"], "bootloader": ["bootloader_", "esp_bootloader"], "app_framework": ["app_", "initArduino", "setup", "loop", "Update"], "weak_symbols": ["__weak_"], "compiler_builtins": ["__builtin_"], "vfs": ["vfs_", "VFS"], "esp32_sdk": ["esp32_", "esp32c", "esp32s"], "usb": ["usb_", "USB", "cdc_", "CDC"], "i2c_driver": ["i2c_", "I2C"], "i2s_driver": ["i2s_", "I2S"], "spi_driver": ["spi_", "SPI"], "adc_driver": ["adc_", "ADC"], "dac_driver": ["dac_", "DAC"], "touch_driver": ["touch_", "TOUCH"], "pwm_driver": ["pwm_", "PWM", "ledc_", "LEDC"], "rmt_driver": ["rmt_", "RMT"], "pcnt_driver": ["pcnt_", "PCNT"], "can_driver": ["can_", "CAN", "twai_", "TWAI"], "sdmmc_driver": ["sdmmc_", "SDMMC", "sdcard", "sd_card"], "temp_sensor": ["temp_sensor", "tsens_"], "watchdog": ["wdt_", "WDT", "watchdog"], "brownout": ["brownout", "bod_"], "ulp": ["ulp_", "ULP"], "psram": ["psram", "PSRAM", "spiram", "SPIRAM"], "efuse": ["efuse", "EFUSE"], "partition": ["partition", "esp_partition"], "esp_event": ["esp_event", "event_loop", "event_callback"], "esp_console": ["esp_console", "console_"], "chip_specific": ["chip_", "esp_chip"], "esp_system_utils": ["esp_system", "esp_hw", "esp_clk", "esp_sleep"], "ipc": ["esp_ipc", "ipc_"], "wifi_config": [ "g_cnxMgr", "gChmCxt", "g_ic", "TxRxCxt", "s_dp", "s_ni", "s_reg_dump", "packet$", "d_mult_table", "K", "fcstab", ], "smartconfig": ["sc_ack_send"], "rc_calibration": ["rc_cal", "rcUpdate"], "noise_floor": ["noise_check"], "rf_calibration": [ "set_rx_sense", "set_rx_gain_cal", "set_chan_dig_gain", "tx_pwctrl_init_cal", "rfcal_txiq", "set_tx_gain_table", "correct_rfpll_offset", "pll_correct_dcap", "txiq_cal_init", "pwdet_sar", "rx_11b_opt", ], "wifi_crypto": [ "pk_use_ecparams", "process_segments", "ccmp_", "rc4_", "aria_", "mgf_mask", "dh_group", "ccmp_aad_nonce", "ccmp_encrypt", "rc4_skip", "aria_sb1", "aria_sb2", "aria_is1", "aria_is2", "aria_sl", "aria_a", ], "radio_control": ["fsm_input", "fsm_sconfreq"], "pbuf": [ "pbuf_", ], "event_group": ["xEventGroup"], "ringbuffer": ["xRingbuffer", "prvSend", "prvReceive", "prvCopy"], "provisioning": ["prov_", "prov_stop_and_notify"], "scan": ["gScanStruct"], "port": ["xPort"], "elf_loader": [ "elf_add", "elf_add_note", "elf_add_segment", "process_image", "read_encoded", "read_encoded_value", "read_encoded_value_with_base", "process_image_header", ], "socket_api": [ "sockets", "netconn_", "accept_function", "recv_raw", "socket_ipv4_multicast", "socket_ipv6_multicast", ], "igmp": ["igmp_", "igmp_send", "igmp_input"], "icmp6": ["icmp6_"], "arp": ["arp_table"], "ampdu": [ "ampdu_", "rcAmpdu", "trc_onAmpduOp", "rcAmpduLowerRate", "ampdu_dispatch_upto", ], "ieee802_11": ["ieee802_11_", "ieee802_11_parse_elems"], "rate_control": ["rssi_margin", "rcGetSched", "get_rate_fcc_index"], "nan": ["nan_dp_", "nan_dp_post_tx", "nan_dp_delete_peer"], "channel_mgmt": ["chm_init", "chm_set_current_channel"], "trace": ["trc_init", "trc_onAmpduOp"], "country_code": ["country_info", "country_info_24ghz"], "multicore": ["do_multicore_settings"], "Update_lib": ["Update"], "stdio": [ "__sf", "__sflush_r", "__srefill_r", "_impure_data", "_reclaim_reent", "_open_r", ], "strncpy_ops": ["strncpy"], "math_internal": ["__mdiff", "__lshift", "__mprec_tens", "quorem"], "character_class": ["__chclass"], "camellia": ["camellia_", "camellia_feistel"], "crypto_tables": ["FSb", "FSb2", "FSb3", "FSb4"], "event_buffer": ["g_eb_list_desc", "eb_space"], "base_node": ["base_node_", "base_node_add_handler"], "file_descriptor": ["s_fd_table"], "tx_delay": ["tx_delay_cfg"], "deinit": ["deinit_functions"], "lcp_echo": ["LcpEchoCheck"], "raw_api": ["raw_bind", "raw_connect"], "checksum": ["process_checksum"], "entry_management": ["add_entry"], "esp_ota": ["esp_ota", "ota_", "read_otadata"], "http_server": [ "httpd_", "parse_url_char", "cb_headers_complete", "delete_entry", "validate_structure", "config_save", "config_new", "verify_url", "cb_url", ], "misc_system": [ "alarm_cbs", "start_up", "tokens", "unhex", "osi_funcs_ro", "enum_function", "fragment_and_dispatch", "alarm_set", "osi_alarm_new", "config_set_string", "config_update_newest_section", "config_remove_key", "method_strings", "interop_match", "interop_database", "__state_table", "__action_table", "s_stub_table", "s_context", "s_mmu_ctx", "s_get_bus_mask", "hli_queue_put", "list_remove", "list_delete", "lock_acquire_generic", "is_vect_desc_usable", "io_mode_str", "__c$20233", "interface", "read_id_core", "subscribe_idle", "unsubscribe_idle", "s_clkout_handle", "lock_release_generic", "config_set_int", "config_get_int", "config_get_string", "config_has_key", "config_remove_section", "osi_alarm_init", "osi_alarm_deinit", "fixed_queue_enqueue", "fixed_queue_dequeue", "fixed_queue_new", "fixed_pkt_queue_enqueue", "fixed_pkt_queue_new", "list_append", "list_prepend", "list_insert_after", "list_contains", "list_get_node", "hash_function_blob", "cb_no_body", "cb_on_body", "profile_tab", "get_arg", "trim", "buf$", "process_appended_hash_and_sig$constprop$0", "uuidType", "allocate_svc_db_buf", "_hostname_is_ours", "s_hli_handlers", "tick_cb", "idle_cb", "input", "entry_find", "section_find", "find_bucket_entry_", "config_has_section", "hli_queue_create", "hli_queue_get", "hli_c_handler", "future_ready", "future_await", "future_new", "pkt_queue_enqueue", "pkt_queue_dequeue", "pkt_queue_cleanup", "pkt_queue_create", "pkt_queue_destroy", "fixed_pkt_queue_dequeue", "osi_alarm_cancel", "osi_alarm_is_active", "osi_sem_take", "osi_event_create", "osi_event_bind", "alarm_cb_handler", "list_foreach", "list_back", "list_front", "list_clear", "fixed_queue_try_peek_first", "translate_path", "get_idx", "find_key", "init", "end", "start", "set_read_value", "copy_address_list", "copy_and_key", "sdk_cfg_opts", "leftshift_onebit", "config_section_end", "config_section_begin", "find_entry_and_check_all_reset", "image_validate", "xPendingReadyList", "vListInitialise", "lock_init_generic", "ant_bttx_cfg", "ant_dft_cfg", "cs_send_to_ctrl_sock", "config_llc_util_funcs_reset", "make_set_adv_report_flow_control", "make_set_event_mask", "raw_new", "raw_remove", "BTE_InitStack", "parse_read_local_supported_features_response", "__math_invalidf", "tinytens", "__mprec_tinytens", "__mprec_bigtens", "vRingbufferDelete", "vRingbufferDeleteWithCaps", "vRingbufferReturnItem", "vRingbufferReturnItemFromISR", "get_acl_data_size_ble", "get_features_ble", "get_features_classic", "get_acl_packet_size_ble", "get_acl_packet_size_classic", "supports_extended_inquiry_response", "supports_rssi_with_inquiry_results", "supports_interlaced_inquiry_scan", "supports_reading_remote_extended_features", ], "bluetooth_ll": [ "lld_pdu_", "ld_acl_", "lld_stop_ind_handler", "lld_evt_winsize_change", "config_lld_evt_funcs_reset", "config_lld_funcs_reset", "config_llm_funcs_reset", "llm_set_long_adv_data", "lld_retry_tx_prog", "llc_link_sup_to_ind_handler", "config_llc_funcs_reset", "lld_evt_rxwin_compute", "config_btdm_funcs_reset", "config_ea_funcs_reset", "llc_defalut_state_tab_reset", "config_rwip_funcs_reset", "ke_lmp_rx_flooding_detect", ], } # Demangled patterns: patterns found in demangled C++ names DEMANGLED_PATTERNS = { "gpio_driver": ["GPIO"], "uart_driver": ["UART"], "network_stack": [ "lwip", "tcp", "udp", "ip4", "ip6", "dhcp", "dns", "netif", "ethernet", "ppp", "slip", ], "wifi_stack": ["NetworkInterface"], "nimble_bt": [ "nimble", "NimBLE", "ble_hs", "ble_gap", "ble_gatt", "ble_att", "ble_l2cap", "ble_sm", ], "crypto": ["mbedtls", "crypto", "sha", "aes", "rsa", "ecc", "tls", "ssl"], "cpp_stdlib": ["std::", "__gnu_cxx::", "__cxxabiv"], "static_init": ["__static_initialization"], "rtti": ["__type_info", "__class_type_info"], "web_server_lib": ["AsyncWebServer", "AsyncWebHandler", "WebServer"], "async_tcp": ["AsyncClient", "AsyncServer"], "mdns_lib": ["mdns"], "json_lib": [ "ArduinoJson", "JsonDocument", "JsonArray", "JsonObject", "deserialize", "serialize", ], "http_lib": ["HTTP", "http_", "Request", "Response", "Uri", "WebSocket"], "logging": ["log", "Log", "print", "Print", "diag_"], "authentication": ["checkDigestAuthentication"], "libgcc": ["libgcc"], "esp_system": ["esp_", "ESP"], "arduino": ["arduino"], "nvs": ["nvs_", "_ZTVN3nvs", "nvs::"], "filesystem": ["spiffs", "vfs"], "libc": ["newlib"], } # Get the list of actual ESPHome components by scanning the components directory def get_esphome_components(): """Get set of actual ESPHome components from the components directory.""" components = set() # Find the components directory relative to this file current_dir = Path(__file__).parent components_dir = current_dir / "components" if components_dir.exists() and components_dir.is_dir(): for item in components_dir.iterdir(): if ( item.is_dir() and not item.name.startswith(".") and not item.name.startswith("__") ): components.add(item.name) return components # Cache the component list ESPHOME_COMPONENTS = get_esphome_components() class MemorySection: """Represents a memory section with its symbols.""" def __init__(self, name: str): self.name = name self.symbols: list[tuple[str, int, str]] = [] # (symbol_name, size, component) self.total_size = 0 class ComponentMemory: """Tracks memory usage for a component.""" def __init__(self, name: str): self.name = name self.text_size = 0 # Code in flash self.rodata_size = 0 # Read-only data in flash self.data_size = 0 # Initialized data (flash + ram) self.bss_size = 0 # Uninitialized data (ram only) self.symbol_count = 0 @property def flash_total(self) -> int: return self.text_size + self.rodata_size + self.data_size @property def ram_total(self) -> int: return self.data_size + self.bss_size class MemoryAnalyzer: """Analyzes memory usage from ELF files.""" def __init__( self, elf_path: str, objdump_path: str | None = None, readelf_path: str | None = None, external_components: set[str] | None = None, ): self.elf_path = Path(elf_path) if not self.elf_path.exists(): raise FileNotFoundError(f"ELF file not found: {elf_path}") self.objdump_path = objdump_path or "objdump" self.readelf_path = readelf_path or "readelf" self.external_components = external_components or set() self.sections: dict[str, MemorySection] = {} self.components: dict[str, ComponentMemory] = defaultdict( lambda: ComponentMemory("") ) self._demangle_cache: dict[str, str] = {} self._uncategorized_symbols: list[tuple[str, str, int]] = [] self._esphome_core_symbols: list[ tuple[str, str, int] ] = [] # Track core symbols self._component_symbols: dict[str, list[tuple[str, str, int]]] = defaultdict( list ) # Track symbols for all components def analyze(self) -> dict[str, ComponentMemory]: """Analyze the ELF file and return component memory usage.""" self._parse_sections() self._parse_symbols() self._categorize_symbols() return dict(self.components) def _parse_sections(self) -> None: """Parse section headers from ELF file.""" try: result = subprocess.run( [self.readelf_path, "-S", str(self.elf_path)], capture_output=True, text=True, check=True, ) # Parse section headers for line in result.stdout.splitlines(): # Look for section entries match = re.match( r"\s*\[\s*\d+\]\s+([\.\w]+)\s+\w+\s+[\da-fA-F]+\s+[\da-fA-F]+\s+([\da-fA-F]+)", line, ) if match: section_name = match.group(1) size_hex = match.group(2) size = int(size_hex, 16) # Map various section names to standard categories mapped_section = None if ".text" in section_name or ".iram" in section_name: mapped_section = ".text" elif ".rodata" in section_name: mapped_section = ".rodata" elif ".data" in section_name and "bss" not in section_name: mapped_section = ".data" elif ".bss" in section_name: mapped_section = ".bss" if mapped_section: if mapped_section not in self.sections: self.sections[mapped_section] = MemorySection( mapped_section ) self.sections[mapped_section].total_size += size except subprocess.CalledProcessError as e: _LOGGER.error(f"Failed to parse sections: {e}") raise def _parse_symbols(self) -> None: """Parse symbols from ELF file.""" # Section mapping - centralizes the logic SECTION_MAPPING = { ".text": [".text", ".iram"], ".rodata": [".rodata"], ".data": [".data", ".dram"], ".bss": [".bss"], } def map_section_name(raw_section: str) -> str | None: """Map raw section name to standard section.""" for standard_section, patterns in SECTION_MAPPING.items(): if any(pattern in raw_section for pattern in patterns): return standard_section return None def parse_symbol_line(line: str) -> tuple[str, str, int, str] | None: """Parse a single symbol line from objdump output. Returns (section, name, size, address) or None if not a valid symbol. Format: address l/g w/d F/O section size name Example: 40084870 l F .iram0.text 00000000 _xt_user_exc """ parts = line.split() if len(parts) < 5: return None try: # Validate and extract address address = parts[0] int(address, 16) except ValueError: return None # Look for F (function) or O (object) flag if "F" not in parts and "O" not in parts: return None # Find section, size, and name for i, part in enumerate(parts): if part.startswith("."): section = map_section_name(part) if section and i + 1 < len(parts): try: size = int(parts[i + 1], 16) if i + 2 < len(parts) and size > 0: name = " ".join(parts[i + 2 :]) return (section, name, size, address) except ValueError: pass break return None try: result = subprocess.run( [self.objdump_path, "-t", str(self.elf_path)], capture_output=True, text=True, check=True, ) # Track seen addresses to avoid duplicates seen_addresses: set[str] = set() for line in result.stdout.splitlines(): symbol_info = parse_symbol_line(line) if symbol_info: section, name, size, address = symbol_info # Skip duplicate symbols at the same address (e.g., C1/C2 constructors) if address not in seen_addresses and section in self.sections: self.sections[section].symbols.append((name, size, "")) seen_addresses.add(address) except subprocess.CalledProcessError as e: _LOGGER.error(f"Failed to parse symbols: {e}") raise def _categorize_symbols(self) -> None: """Categorize symbols by component.""" # First, collect all unique symbol names for batch demangling all_symbols = set() for section in self.sections.values(): for symbol_name, _, _ in section.symbols: all_symbols.add(symbol_name) # Batch demangle all symbols at once self._batch_demangle_symbols(list(all_symbols)) # Now categorize with cached demangled names for section_name, section in self.sections.items(): for symbol_name, size, _ in section.symbols: component = self._identify_component(symbol_name) if component not in self.components: self.components[component] = ComponentMemory(component) comp_mem = self.components[component] comp_mem.symbol_count += 1 if section_name == ".text": comp_mem.text_size += size elif section_name == ".rodata": comp_mem.rodata_size += size elif section_name == ".data": comp_mem.data_size += size elif section_name == ".bss": comp_mem.bss_size += size # Track uncategorized symbols if component == "other" and size > 0: demangled = self._demangle_symbol(symbol_name) self._uncategorized_symbols.append((symbol_name, demangled, size)) # Track ESPHome core symbols for detailed analysis if component == "[esphome]core" and size > 0: demangled = self._demangle_symbol(symbol_name) self._esphome_core_symbols.append((symbol_name, demangled, size)) # Track all component symbols for detailed analysis if size > 0: demangled = self._demangle_symbol(symbol_name) self._component_symbols[component].append( (symbol_name, demangled, size) ) def _identify_component(self, symbol_name: str) -> str: """Identify which component a symbol belongs to.""" # Demangle C++ names if needed demangled = self._demangle_symbol(symbol_name) # Check for special component classes first (before namespace pattern) # This handles cases like esphome::ESPHomeOTAComponent which should map to ota if "esphome::" in demangled: # Check for special component classes that include component name in the class # For example: esphome::ESPHomeOTAComponent -> ota component for component_name in ESPHOME_COMPONENTS: # Check various naming patterns component_upper = component_name.upper() component_camel = component_name.replace("_", "").title() patterns = [ f"esphome::{component_upper}Component", # e.g., esphome::OTAComponent f"esphome::ESPHome{component_upper}Component", # e.g., esphome::ESPHomeOTAComponent f"esphome::{component_camel}Component", # e.g., esphome::OtaComponent f"esphome::ESPHome{component_camel}Component", # e.g., esphome::ESPHomeOtaComponent ] if any(pattern in demangled for pattern in patterns): return f"[esphome]{component_name}" # Check for ESPHome component namespaces match = ESPHOME_COMPONENT_PATTERN.search(demangled) if match: component_name = match.group(1) # Strip trailing underscore if present (e.g., switch_ -> switch) component_name = component_name.rstrip("_") # Check if this is an actual component in the components directory if component_name in ESPHOME_COMPONENTS: return f"[esphome]{component_name}" # Check if this is a known external component from the config if component_name in self.external_components: return f"[external]{component_name}" # Everything else in esphome:: namespace is core return "[esphome]core" # Check for esphome core namespace (no component namespace) if "esphome::" in demangled: # If no component match found, it's core return "[esphome]core" # Check against symbol patterns for component, patterns in SYMBOL_PATTERNS.items(): if any(pattern in symbol_name for pattern in patterns): return component # Check against demangled patterns for component, patterns in DEMANGLED_PATTERNS.items(): if any(pattern in demangled for pattern in patterns): return component # Special cases that need more complex logic # Check if spi_flash vs spi_driver if "spi_" in symbol_name or "SPI" in symbol_name: if "spi_flash" in symbol_name: return "spi_flash" return "spi_driver" # libc special printf variants if symbol_name.startswith("_") and symbol_name[1:].replace("_r", "").replace( "v", "" ).replace("s", "") in ["printf", "fprintf", "sprintf", "scanf"]: return "libc" # Track uncategorized symbols for analysis return "other" def _batch_demangle_symbols(self, symbols: list[str]) -> None: """Batch demangle C++ symbol names for efficiency.""" if not symbols: return # Try to find the appropriate c++filt for the platform cppfilt_cmd = "c++filt" # Check if we have a toolchain-specific c++filt if self.objdump_path and self.objdump_path != "objdump": # Replace objdump with c++filt in the path potential_cppfilt = self.objdump_path.replace("objdump", "c++filt") if Path(potential_cppfilt).exists(): cppfilt_cmd = potential_cppfilt try: # Send all symbols to c++filt at once result = subprocess.run( [cppfilt_cmd], input="\n".join(symbols), capture_output=True, text=True, check=False, ) if result.returncode == 0: demangled_lines = result.stdout.strip().split("\n") # Map original to demangled names for original, demangled in zip(symbols, demangled_lines): self._demangle_cache[original] = demangled else: # If batch fails, cache originals for symbol in symbols: self._demangle_cache[symbol] = symbol except Exception: # On error, cache originals for symbol in symbols: self._demangle_cache[symbol] = symbol def _demangle_symbol(self, symbol: str) -> str: """Get demangled C++ symbol name from cache.""" return self._demangle_cache.get(symbol, symbol) def _categorize_esphome_core_symbol(self, demangled: str) -> str: """Categorize ESPHome core symbols into subcategories.""" # Dictionary of patterns for core subcategories CORE_SUBCATEGORY_PATTERNS = { "Component Framework": ["Component"], "Application Core": ["Application"], "Scheduler": ["Scheduler"], "Logging": ["Logger", "log_"], "Preferences": ["preferences", "Preferences"], "Synchronization": ["Mutex", "Lock"], "Helpers": ["Helper"], "Network Utilities": ["network", "Network"], "Time Management": ["time", "Time"], "String Utilities": ["str_", "string"], "Parsing/Formatting": ["parse_", "format_"], "Optional Types": ["optional", "Optional"], "Callbacks": ["Callback", "callback"], "Color Utilities": ["Color"], "C++ Operators": ["operator"], "Global Variables": ["global_", "_GLOBAL"], "Setup/Loop": ["setup", "loop"], "System Control": ["reboot", "restart"], "GPIO Management": ["GPIO", "gpio"], "Interrupt Handling": ["ISR", "interrupt"], "Hooks": ["Hook", "hook"], "Entity Base Classes": ["Entity"], "Automation Framework": ["automation", "Automation"], "Automation Components": ["Condition", "Action", "Trigger"], "Lambda Support": ["lambda"], } # Special patterns that need to be checked separately if any(pattern in demangled for pattern in ["vtable", "typeinfo", "thunk"]): return "C++ Runtime (vtables/RTTI)" if demangled.startswith("std::"): return "C++ STL" # Check against patterns for category, patterns in CORE_SUBCATEGORY_PATTERNS.items(): if any(pattern in demangled for pattern in patterns): return category return "Other Core" def generate_report(self, detailed: bool = False) -> str: """Generate a formatted memory report.""" components = sorted( self.components.items(), key=lambda x: x[1].flash_total, reverse=True ) # Calculate totals total_flash = sum(c.flash_total for _, c in components) total_ram = sum(c.ram_total for _, c in components) # Build report lines = [] # Column width constants COL_COMPONENT = 29 COL_FLASH_TEXT = 14 COL_FLASH_DATA = 14 COL_RAM_DATA = 12 COL_RAM_BSS = 12 COL_TOTAL_FLASH = 15 COL_TOTAL_RAM = 12 COL_SEPARATOR = 3 # " | " # Core analysis column widths COL_CORE_SUBCATEGORY = 30 COL_CORE_SIZE = 12 COL_CORE_COUNT = 6 COL_CORE_PERCENT = 10 # Calculate the exact table width table_width = ( COL_COMPONENT + COL_SEPARATOR + COL_FLASH_TEXT + COL_SEPARATOR + COL_FLASH_DATA + COL_SEPARATOR + COL_RAM_DATA + COL_SEPARATOR + COL_RAM_BSS + COL_SEPARATOR + COL_TOTAL_FLASH + COL_SEPARATOR + COL_TOTAL_RAM ) lines.append("=" * table_width) lines.append("Component Memory Analysis".center(table_width)) lines.append("=" * table_width) lines.append("") # Main table - fixed column widths lines.append( f"{'Component':<{COL_COMPONENT}} | {'Flash (text)':>{COL_FLASH_TEXT}} | {'Flash (data)':>{COL_FLASH_DATA}} | {'RAM (data)':>{COL_RAM_DATA}} | {'RAM (bss)':>{COL_RAM_BSS}} | {'Total Flash':>{COL_TOTAL_FLASH}} | {'Total RAM':>{COL_TOTAL_RAM}}" ) lines.append( "-" * COL_COMPONENT + "-+-" + "-" * COL_FLASH_TEXT + "-+-" + "-" * COL_FLASH_DATA + "-+-" + "-" * COL_RAM_DATA + "-+-" + "-" * COL_RAM_BSS + "-+-" + "-" * COL_TOTAL_FLASH + "-+-" + "-" * COL_TOTAL_RAM ) for name, mem in components: if mem.flash_total > 0 or mem.ram_total > 0: flash_rodata = mem.rodata_size + mem.data_size lines.append( f"{name:<{COL_COMPONENT}} | {mem.text_size:>{COL_FLASH_TEXT - 2},} B | {flash_rodata:>{COL_FLASH_DATA - 2},} B | " f"{mem.data_size:>{COL_RAM_DATA - 2},} B | {mem.bss_size:>{COL_RAM_BSS - 2},} B | " f"{mem.flash_total:>{COL_TOTAL_FLASH - 2},} B | {mem.ram_total:>{COL_TOTAL_RAM - 2},} B" ) lines.append( "-" * COL_COMPONENT + "-+-" + "-" * COL_FLASH_TEXT + "-+-" + "-" * COL_FLASH_DATA + "-+-" + "-" * COL_RAM_DATA + "-+-" + "-" * COL_RAM_BSS + "-+-" + "-" * COL_TOTAL_FLASH + "-+-" + "-" * COL_TOTAL_RAM ) lines.append( f"{'TOTAL':<{COL_COMPONENT}} | {' ':>{COL_FLASH_TEXT}} | {' ':>{COL_FLASH_DATA}} | " f"{' ':>{COL_RAM_DATA}} | {' ':>{COL_RAM_BSS}} | " f"{total_flash:>{COL_TOTAL_FLASH - 2},} B | {total_ram:>{COL_TOTAL_RAM - 2},} B" ) # Top consumers lines.append("") lines.append("Top Flash Consumers:") for i, (name, mem) in enumerate(components[:25]): if mem.flash_total > 0: percentage = ( (mem.flash_total / total_flash * 100) if total_flash > 0 else 0 ) lines.append( f"{i + 1}. {name} ({mem.flash_total:,} B) - {percentage:.1f}% of analyzed flash" ) lines.append("") lines.append("Top RAM Consumers:") ram_components = sorted(components, key=lambda x: x[1].ram_total, reverse=True) for i, (name, mem) in enumerate(ram_components[:25]): if mem.ram_total > 0: percentage = (mem.ram_total / total_ram * 100) if total_ram > 0 else 0 lines.append( f"{i + 1}. {name} ({mem.ram_total:,} B) - {percentage:.1f}% of analyzed RAM" ) lines.append("") lines.append( "Note: This analysis covers symbols in the ELF file. Some runtime allocations may not be included." ) lines.append("=" * table_width) # Add ESPHome core detailed analysis if there are core symbols if self._esphome_core_symbols: lines.append("") lines.append("=" * table_width) lines.append("[esphome]core Detailed Analysis".center(table_width)) lines.append("=" * table_width) lines.append("") # Group core symbols by subcategory core_subcategories: dict[str, list[tuple[str, str, int]]] = defaultdict( list ) for symbol, demangled, size in self._esphome_core_symbols: # Categorize based on demangled name patterns subcategory = self._categorize_esphome_core_symbol(demangled) core_subcategories[subcategory].append((symbol, demangled, size)) # Sort subcategories by total size sorted_subcategories = sorted( [ (name, symbols, sum(s[2] for s in symbols)) for name, symbols in core_subcategories.items() ], key=lambda x: x[2], reverse=True, ) lines.append( f"{'Subcategory':<{COL_CORE_SUBCATEGORY}} | {'Size':>{COL_CORE_SIZE}} | " f"{'Count':>{COL_CORE_COUNT}} | {'% of Core':>{COL_CORE_PERCENT}}" ) lines.append( "-" * COL_CORE_SUBCATEGORY + "-+-" + "-" * COL_CORE_SIZE + "-+-" + "-" * COL_CORE_COUNT + "-+-" + "-" * COL_CORE_PERCENT ) core_total = sum(size for _, _, size in self._esphome_core_symbols) for subcategory, symbols, total_size in sorted_subcategories: percentage = (total_size / core_total * 100) if core_total > 0 else 0 lines.append( f"{subcategory:<{COL_CORE_SUBCATEGORY}} | {total_size:>{COL_CORE_SIZE - 2},} B | " f"{len(symbols):>{COL_CORE_COUNT}} | {percentage:>{COL_CORE_PERCENT - 1}.1f}%" ) # Top 10 largest core symbols lines.append("") lines.append("Top 10 Largest [esphome]core Symbols:") sorted_core_symbols = sorted( self._esphome_core_symbols, key=lambda x: x[2], reverse=True ) for i, (symbol, demangled, size) in enumerate(sorted_core_symbols[:15]): lines.append(f"{i + 1}. {demangled} ({size:,} B)") lines.append("=" * table_width) # Add detailed analysis for top 5 ESPHome components esphome_components = [ (name, mem) for name, mem in components if name.startswith("[esphome]") and name != "[esphome]core" ] top_esphome_components = sorted( esphome_components, key=lambda x: x[1].flash_total, reverse=True )[:25] # Check if API component exists and ensure it's included api_component = None for name, mem in components: if name == "[esphome]api": api_component = (name, mem) break # If API exists and not in top 5, add it to the list components_to_analyze = list(top_esphome_components) if api_component and api_component not in components_to_analyze: components_to_analyze.append(api_component) if components_to_analyze: for comp_name, comp_mem in components_to_analyze: comp_symbols = self._component_symbols.get(comp_name, []) if comp_symbols: lines.append("") lines.append("=" * table_width) lines.append(f"{comp_name} Detailed Analysis".center(table_width)) lines.append("=" * table_width) lines.append("") # Sort symbols by size sorted_symbols = sorted( comp_symbols, key=lambda x: x[2], reverse=True ) lines.append(f"Total symbols: {len(sorted_symbols)}") lines.append(f"Total size: {comp_mem.flash_total:,} B") lines.append("") # For API component, show all symbols; for others show top 10 if comp_name == "[esphome]api": lines.append(f"All {comp_name} Symbols (sorted by size):") for i, (symbol, demangled, size) in enumerate(sorted_symbols): lines.append(f"{i + 1}. {demangled} ({size:,} B)") else: lines.append(f"Top 10 Largest {comp_name} Symbols:") for i, (symbol, demangled, size) in enumerate( sorted_symbols[:10] ): lines.append(f"{i + 1}. {demangled} ({size:,} B)") lines.append("=" * table_width) return "\n".join(lines) def to_json(self) -> str: """Export analysis results as JSON.""" data = { "components": { name: { "text": mem.text_size, "rodata": mem.rodata_size, "data": mem.data_size, "bss": mem.bss_size, "flash_total": mem.flash_total, "ram_total": mem.ram_total, "symbol_count": mem.symbol_count, } for name, mem in self.components.items() }, "totals": { "flash": sum(c.flash_total for c in self.components.values()), "ram": sum(c.ram_total for c in self.components.values()), }, } return json.dumps(data, indent=2) def dump_uncategorized_symbols(self, output_file: str | None = None) -> None: """Dump uncategorized symbols for analysis.""" # Sort by size descending sorted_symbols = sorted( self._uncategorized_symbols, key=lambda x: x[2], reverse=True ) lines = ["Uncategorized Symbols Analysis", "=" * 80] lines.append(f"Total uncategorized symbols: {len(sorted_symbols)}") lines.append( f"Total uncategorized size: {sum(s[2] for s in sorted_symbols):,} bytes" ) lines.append("") lines.append(f"{'Size':>10} | {'Symbol':<60} | Demangled") lines.append("-" * 10 + "-+-" + "-" * 60 + "-+-" + "-" * 40) for symbol, demangled, size in sorted_symbols[:100]: # Top 100 if symbol != demangled: lines.append(f"{size:>10,} | {symbol[:60]:<60} | {demangled[:100]}") else: lines.append(f"{size:>10,} | {symbol[:60]:<60} | [not demangled]") if len(sorted_symbols) > 100: lines.append(f"\n... and {len(sorted_symbols) - 100} more symbols") content = "\n".join(lines) if output_file: with open(output_file, "w") as f: f.write(content) else: print(content) def analyze_elf( elf_path: str, objdump_path: str | None = None, readelf_path: str | None = None, detailed: bool = False, external_components: set[str] | None = None, ) -> str: """Analyze an ELF file and return a memory report.""" analyzer = MemoryAnalyzer(elf_path, objdump_path, readelf_path, external_components) analyzer.analyze() return analyzer.generate_report(detailed) if __name__ == "__main__": import sys if len(sys.argv) < 2: print("Usage: analyze_memory.py ") sys.exit(1) try: report = analyze_elf(sys.argv[1]) print(report) except Exception as e: print(f"Error: {e}") sys.exit(1)