Files
u-boot/scripts/build_api.py
Simon Glass f379b6570f ulib: scripts: Add a script to support symbol-renaming
When U-Boot is used as a library with other programs, some U-Boot
function names may conflict with the program, or with standard-library
functions. For example, printf() is defined by U-Boot but is typically
used by the program as well.

The easiest solution is to rename symbols in the object file, so that
they appear with a 'ub_' prefix when linked with the program.

Add a new build_api.py script which can:

- rename symbols based on a rename.syms file
- generate a header file (with the renamed symbols) for use by the
  program

This makes use of the 'objcopy --redefine-sym' feature.

The tool has 100% test coverage.

Co-developed-by: Claude <noreply@anthropic.com>
Signed-off-by: Simon Glass <sjg@chromium.org>
2025-09-08 13:09:56 -06:00

715 lines
24 KiB
Python
Executable File

#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-2.0
"""Script to parse rename.syms files and generate API headers"""
import argparse
import filecmp
import os
import re
import subprocess
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from itertools import groupby
from typing import List
# Add the tools directory to the path for u_boot_pylib
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'tools'))
# pylint: disable=wrong-import-position,import-error
from u_boot_pylib import tools
from u_boot_pylib import test_util
# API header template parts
API_HEADER = '''#ifndef __ULIB_API_H
#define __ULIB_API_H
#include <stdarg.h>
#include <stddef.h>
/* Auto-generated header with renamed U-Boot library functions */
'''
API_FOOTER = '''#endif /* __ULIB_API_H */
'''
def rename_function(src, old_name, new_name):
"""Rename a function in C source code
Args:
src (str): The source code containing the function
old_name (str): Current function name to rename
new_name (str): New function name
Returns:
str: Source code with the function renamed
"""
# Pattern to match function declaration/definition
# Matches: return_type func(parameters)
pattern = r'\b' + re.escape(old_name) + r'\b(?=\s*\()'
# Replace all occurrences of the function name (in function comment too)
renamed_code = re.sub(pattern, new_name, src)
return renamed_code
@dataclass
class Symbol:
"""Represents a symbol rename operation for library functions.
Used to track how functions from a header file should be renamed
to create a namespaced API (e.g., printf -> ub_printf).
"""
hdr: str # Header file containing the function
orig: str # Original function name
new_name: str # New function name after renaming
class RenameSymsParser:
"""Parser for rename.syms files.
Format:
file: header.h
symbol1
symbol2=renamed_symbol2
symbol3
Lines starting with 'file:' specify a header file.
Lines indented with space or tab specify symbols from that header.
Symbol lines can use '=' for explicit renaming, otherwise 'ub_' prefix is
added.
Comments start with '#' and must begin at start of line.
Empty lines are allowed.
Trailing spaces are stripped but no other whitespace is allowed except for
symbol indentation.
"""
def __init__(self, fname: str):
"""Initialize the parser with a rename.syms file path
Args:
fname (str): Path to the rename.syms file to parse
"""
self.fname = fname
self.syms: List[Symbol] = []
def parse_line(self, line: str, hdr: str) -> Symbol:
"""Parse a line and return a Symbol or None
Args:
line (str): The line to parse (already stripped)
hdr (str): Current header file name
Returns:
Symbol or None: Symbol if line contains a symbol definition,
None otherwise
"""
if '=' in line:
# Explicit mapping: orig=new
orig, new = line.split('=', 1)
orig = orig.strip()
new = new.strip()
else:
# Default mapping: add 'ub_' prefix
orig = line
new = f'ub_{orig}'
return Symbol(hdr=hdr, orig=orig, new_name=new)
def parse(self) -> List[Symbol]:
"""Parse the rename.syms file and return list of symbols
Returns:
List[Symbol]: List of symbol rename operations
"""
hdr = None
content = tools.read_file(self.fname, binary=False)
for line_num, line in enumerate(content.splitlines(), 1):
line = line.rstrip()
# Skip empty lines and comments
if not line or line.startswith('#'):
continue
# Check for file directive
if line.startswith('file:'):
hdr = line.split(':', 1)[1].strip()
continue
# Check for symbol (indented line with space or tab)
if line[0] not in [' ', '\t']:
# Non-indented, non-file lines are invalid
raise ValueError(f'Line {line_num}: Invalid format - '
f"symbols must be indented: '{line}'")
if hdr is None:
raise ValueError(f"Line {line_num}: Symbol '{line.strip()}' "
f'found without a header file directive')
# Process valid symbol
symbol = self.parse_line(line.strip(), hdr)
if symbol:
self.syms.append(symbol)
return self.syms
def dump(self):
"""Print the parsed symbols in a formatted way"""
print(f'Parsed {len(self.syms)} symbols from '
f'{self.fname}:')
print()
hdr = None
for sym in self.syms:
if sym.hdr != hdr:
hdr = sym.hdr
print(f'Header: {hdr}')
print(f' {sym.orig} -> {sym.new_name}')
print(f'\nTotal: {len(self.syms)} symbols')
class DeclExtractor:
"""Extracts function declarations from header files with comments
Expects functions to have an optional preceding comment block (either /**/
style or // single-line) followed immediately by the function declaration.
The declaration may span multiple lines until a semicolon or opening brace.
Properties:
lines (str): List of lines from the header file, set by extract()
"""
def __init__(self, fname: str):
"""Initialize with header file path
Args:
fname (str): Path to the header file
"""
self.fname = fname
self.lines = []
def find_function(self, func: str):
"""Find the line index of a function declaration
Args:
func (str): Name of the function to find
Returns:
int or None: Line index of function declaration, or None if
not found
"""
pattern = r'\b' + re.escape(func) + r'\s*\('
for i, full_line in enumerate(self.lines):
line = full_line.strip()
# Skip comment lines and find actual function declarations
if (not line.startswith('*') and not line.startswith('//') and
re.search(pattern, full_line)):
return i
return None
def find_preceding_comment(self, func_idx: int):
"""Find comment block preceding a function declaration
Args:
func_idx (int): Line index of the function declaration
Returns:
int or None: Start line index of comment block, or None if not found
"""
# Search backwards from the line before the function declaration
for i in range(func_idx - 1, -1, -1):
line = self.lines[i].strip()
if not line:
continue # Skip empty lines
if line.startswith('*/'):
# Find the start of this comment block
for j in range(i, -1, -1):
if '/**' in self.lines[j]:
return j
break
if line.startswith('//'):
# Found single-line comment, include it if it's the first
# non-empty line before function
return i
if not line.startswith('*'):
# Hit non-comment content, no preceding comment
break
return None
def extract_lines(self, start_idx: int, func_idx: int):
"""Extract comment and function declaration lines
Args:
start_idx (int): Starting line index (comment or function)
func_idx (int): Function declaration line index
Returns:
str: Lines containing the complete declaration joined with newlines
"""
lines = []
# Add comment lines if found
if start_idx < func_idx:
lines.extend(self.lines[start_idx:func_idx])
# Add function declaration lines
for line in self.lines[func_idx:func_idx + 10]:
lines.append(line)
if ';' in line or '{' in line:
break
return '\n'.join(lines)
def extract(self, func: str):
"""Find a function declaration in a header file, including its comment
Args:
func (str): Name of the function to find
Returns:
str or None: The function declaration with its comment, or None
if not found
"""
self.lines = tools.read_file(self.fname, binary=False).split('\n')
func_idx = self.find_function(func)
if func_idx is None:
return None
comment_idx = self.find_preceding_comment(func_idx)
start_idx = comment_idx if comment_idx is not None else func_idx
return self.extract_lines(start_idx, func_idx)
@staticmethod
def extract_decl(fname, func):
"""Find a function declaration in a header file, including its comment
Args:
fname (str): Path to the header file
func (str): Name of the function to find
Returns:
str or None: The function declaration with its comment, or None
if not found
"""
extractor = DeclExtractor(fname)
return extractor.extract(func)
class SymbolRedefiner:
"""Applies symbol redefinitions to object files using objcopy
Processes object files to rename symbols using objcopy --redefine-sym.
Always copies modified files to an output directory.
Properties:
redefine_args (List[str]): objcopy arguments for symbol redefinition
symbol_names (set): Set of original symbol names to look for
"""
def __init__(self, syms: List[Symbol], outdir: str, max_workers,
verbose=False):
"""Initialize with symbols and output settings
Args:
syms (List[Symbol]): List of symbols to redefine
outdir (str): Directory to write modified object files
max_workers (int): Number of parallel workers
verbose (bool): Whether to show verbose output
"""
self.syms = syms
self.outdir = outdir
self.verbose = verbose
self.max_workers = max_workers
self.redefine_args = []
self.symbol_names = set()
# Build objcopy command arguments and symbol set
for sym in syms:
self.redefine_args.extend(['--redefine-sym',
f'{sym.orig}={sym.new_name}'])
self.symbol_names.add(sym.orig)
def redefine_file(self, infile: str, outfile: str):
"""Apply symbol redefinitions to a single object file
Args:
infile (str): Input object file path
outfile (str): Output object file path
"""
cmd = ['objcopy'] + self.redefine_args + [infile, outfile]
subprocess.run(cmd, check=True, capture_output=True, text=True)
if self.verbose:
print(f'Copied and modified {infile} -> {outfile}')
def _process_single_file(self, path: str, outfile: str) -> bool:
"""Process a single file (for parallel execution)
Args:
path (str): Input file path
outfile (str): Output file path
Returns:
bool: True if file was modified, False otherwise
"""
# Always run objcopy to apply redefinitions
self.redefine_file(path, outfile)
# Check if the file was actually modified
return not filecmp.cmp(path, outfile, shallow=False)
def process(self, work_items: List[tuple[str, str]]) -> \
tuple[List[str], int]:
"""Process object files and apply symbol redefinitions
Args:
work_items (List[tuple[str, str]]): List of
(input_path, output_path) tuples
Returns:
tuple[List[str], int]: List of output object file paths and
count of modified files
"""
# Process files in parallel
outfiles = []
modified = 0
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
# Submit all jobs
future_to_item = {
executor.submit(self._process_single_file, path, outfile):
(path, outfile)
for path, outfile in work_items
}
# Collect results
for future in as_completed(future_to_item):
path, outfile = future_to_item[future]
was_modified = future.result()
if was_modified:
modified += 1
outfiles.append(outfile)
# Sort outfiles to maintain consistent order
outfiles.sort()
return outfiles, modified
@staticmethod
def apply_renames(obj_files, syms, outdir: str, max_workers, verbose=False):
"""Apply symbol redefinitions to object files using objcopy
Args:
obj_files (List[str]): List of object file paths
syms (List[Symbol]): List of symbols
outdir (str): Directory to write modified object files
max_workers (int): Number of parallel workers
verbose (bool): Whether to show verbose output
Returns:
tuple[List[str], int]: List of output object file paths and
count of modified files
"""
if not syms:
return obj_files, 0
redefiner = SymbolRedefiner(syms, outdir, max_workers, verbose)
# Setup: create output directory and prepare work items
os.makedirs(outdir, exist_ok=True)
# Prepare work items - just input and output paths
work_items = []
for path in obj_files:
uniq = os.path.relpath(path).replace('/', '_')
outfile = os.path.join(outdir, uniq)
work_items.append((path, outfile))
return redefiner.process(work_items)
class ApiGenerator:
"""Generates API headers with renamed function declarations
Processes symbols and creates a unified header file with renamed function
declarations extracted from original header files.
"""
def __init__(self, syms: List[Symbol], include_dir: str, verbose=False):
"""Initialize with symbols and include directory
Args:
syms (List[Symbol]): List of symbols
include_dir (str): Directory to search for header files
verbose (bool): Whether to print status messages
"""
self.syms = syms
self.include_dir = include_dir
self.verbose = verbose
self.missing_decls = []
self.missing_hdrs = []
def process_header(self, hdr: str, header_syms: List[Symbol]):
"""Process a single header file and its symbols
Args:
hdr (str): Header file name
header_syms (List[Symbol]): Symbols from this header
Returns:
List[str]: Lines for this header section
"""
lines = [f'/* Functions from {hdr} */']
path = os.path.join(self.include_dir, hdr)
if not os.path.exists(path):
self.missing_hdrs.append(hdr)
else:
# Extract and rename declarations from the actual header
for sym in header_syms:
orig = DeclExtractor.extract_decl(
path, sym.orig)
if orig:
# Rename the function in the declaration
renamed_decl = rename_function(
orig, sym.orig, sym.new_name)
lines.append(renamed_decl)
else:
self.missing_decls.append((sym.orig, hdr))
lines.append('')
lines.append('')
return lines
def check_errors(self):
"""Check for missing headers or declarations and build error message
Returns:
str: Error messages, or '' if None
"""
msgs = []
if self.missing_hdrs:
msgs.append('')
msgs.append('Missing header files:')
for header in self.missing_hdrs:
msgs.append(f' - {header}')
if self.missing_decls:
msgs.append('')
msgs.append('Missing function declarations:')
for func_name, hdr in self.missing_decls:
msgs.append(f' - {func_name} in {hdr}')
return '\n'.join(msgs)
def generate(self, outfile: str):
"""Generate the API header file
Args:
outfile (str): Path where to write the new header file
Returns:
int: 0 on success, 1 on error
"""
# Process each header file
out = []
sorted_syms = sorted(self.syms, key=lambda s: s.hdr)
by_header = {hdr: list(syms)
for hdr, syms in groupby(sorted_syms, key=lambda s: s.hdr)}
for hdr, syms in by_header.items():
out.extend(self.process_header(hdr, syms))
# Check for errors and abort if any declarations are missing
error_msg = self.check_errors()
if error_msg:
print(error_msg, file=sys.stderr)
return 1
# Write the header file
content = API_HEADER + '\n'.join(out) + API_FOOTER
tools.write_file(outfile, content, binary=False)
if self.verbose:
print(f'Generated API header: {outfile}')
return 0
@staticmethod
def generate_hdr(syms, include_dir, outfile, verbose=False):
"""Generate a new header file with renamed function declarations
Args:
syms (List[Symbol]): List of symbols
include_dir (str): Directory to search for header files
outfile (str): Path where to write the new header file
verbose (bool): Whether to print status messages
Returns:
int: 0 on success, 1 on error
"""
if not syms:
print('Warning: No symbols found', file=sys.stderr)
return 0
generator = ApiGenerator(syms, include_dir, verbose)
return generator.generate(outfile)
def run_tests(processes, test_name): # pragma: no cover
"""Run all the tests we have for build_api
Args:
processes (int): Number of processes to use to run tests
test_name (str): Name of specific test to run, or None to run all tests
Returns:
int: 0 if successful, 1 if not
"""
# pylint: disable=import-outside-toplevel,import-error
# Import our test module
test_dir = os.path.join(os.path.dirname(__file__), '../test/scripts')
sys.path.insert(0, test_dir)
import test_build_api
sys.argv = [sys.argv[0]]
result = test_util.run_test_suites(
toolname='build_api', debug=True, verbosity=2, no_capture=False,
test_preserve_dirs=False, processes=processes, test_name=test_name,
toolpath=[],
class_and_module_list=[test_build_api.TestBuildApi])
return 0 if result.wasSuccessful() else 1
def run_test_coverage(): # pragma: no cover
"""Run the tests and check that we get 100% coverage"""
sys.argv = [sys.argv[0]]
test_util.run_test_coverage('scripts/build_api.py', None,
['tools/u_boot_pylib/*', '*/test*'], '.')
def parse_args(argv):
"""Parse and validate command line arguments
Args:
argv (List[str]): Arguments to parse
Returns:
tuple: (args, error_code) where args is argparse.Namespace or None,
and error_code is 0 for success or 1 for error
"""
parser = argparse.ArgumentParser(
description='Parse rename.syms file and show symbols')
parser.add_argument('rename_syms', nargs='?',
help='Path to rename.syms file')
parser.add_argument('-d', '--dump', action='store_true',
help='Dump parsed symbols')
parser.add_argument('-r', '--redefine', nargs='*', metavar='OBJ_FILE',
help='Apply symbol redefinitions to object files')
parser.add_argument('-a', '--api', metavar='HEADER_FILE',
help='Generate API header with renamed functions')
parser.add_argument('-i', '--include-dir', metavar='DIR',
help='Include directory containing header files')
parser.add_argument('-o', '--output-dir', metavar='DIR',
help='Output directory for modified object files')
parser.add_argument('-v', '--verbose', action='store_true',
help='Show verbose output')
parser.add_argument('-j', '--jobs', type=int, metavar='N',
help='Number of parallel jobs for symbol processing')
parser.add_argument('-P', '--processes', type=int,
help='set number of processes to use for running tests')
parser.add_argument('-t', '--test', action='store_true', dest='test',
default=False, help='run tests')
parser.add_argument('-T', '--test-coverage', action='store_true',
default=False,
help='run tests and check for 100%% coverage')
args = parser.parse_args(argv)
# Check if running tests - if so, rename_syms is optional
running_tests = args.test or args.test_coverage
if not running_tests and not args.rename_syms: # pragma: no cover
print('Error: rename_syms is required unless running tests',
# pragma: no cover
file=sys.stderr) # pragma: no cover
return None, 1 # pragma: no cover
# Validate argument combinations
if args.redefine is not None and not args.redefine:
# args.redefine is [] when --redefine used with no object files
print('Error: --redefine requires at least one object file',
file=sys.stderr)
return None, 1
if args.redefine is not None and not args.output_dir:
print('Error: --output-dir is required with --redefine',
file=sys.stderr)
return None, 1
if args.api and not args.include_dir:
print('Error: --include-dir is required with --api',
file=sys.stderr)
return None, 1
return args, 0
def main(argv=None):
"""Main entry point for the script
Args:
argv (List[str], optional): Arguments to parse. Uses sys.argv[1:]
if None.
Returns:
int: Exit code (0 for success, 1 for error)
"""
if argv is None:
argv = sys.argv[1:]
args, error_code = parse_args(argv)
if error_code:
return error_code
# Handle test options
if args.test: # pragma: no cover
test_name = args.rename_syms # pragma: no cover
return run_tests(args.processes, test_name) # pragma: no cover
if args.test_coverage: # pragma: no cover
run_test_coverage() # pragma: no cover
return 0 # pragma: no cover
symbols_parser = RenameSymsParser(args.rename_syms)
syms = symbols_parser.parse()
if args.dump:
symbols_parser.dump()
if args.redefine is not None:
# Determine number of jobs
jobs = args.jobs if args.jobs else min(os.cpu_count() or 4, 8)
start_time = time.time()
outfiles, modified = SymbolRedefiner.apply_renames(
args.redefine, syms, args.output_dir, jobs, args.verbose)
# Print the list of output files for the build system to use
if args.output_dir:
print('\n'.join(outfiles))
elapsed = time.time() - start_time
if args.verbose:
print(f'Processed {len(args.redefine)} files ({modified} modified) '
f'in {elapsed:.3f} seconds ({jobs} threads)', file=sys.stderr)
if args.api:
result = ApiGenerator.generate_hdr(syms, args.include_dir, args.api,
args.verbose)
if result:
return result
return 0
if __name__ == '__main__': # pragma: no cover
sys.exit(main())