1#!/usr/bin/env python 2# 3# ESP32 partition table generation tool 4# 5# Converts partition tables to/from CSV and binary formats. 6# 7# See https://docs.espressif.com/projects/esp-idf/en/latest/api-guides/partition-tables.html 8# for explanation of partition table structure and uses. 9# 10# Copyright 2015-2016 Espressif Systems (Shanghai) PTE LTD 11# 12# Licensed under the Apache License, Version 2.0 (the "License"); 13# you may not use this file except in compliance with the License. 14# You may obtain a copy of the License at 15# 16# http:#www.apache.org/licenses/LICENSE-2.0 17# 18# Unless required by applicable law or agreed to in writing, software 19# distributed under the License is distributed on an "AS IS" BASIS, 20# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 21# See the License for the specific language governing permissions and 22# limitations under the License. 23from __future__ import division, print_function, unicode_literals 24 25import argparse 26import binascii 27import errno 28import hashlib 29import os 30import re 31import struct 32import sys 33 34MAX_PARTITION_LENGTH = 0xC00 # 3K for partition data (96 entries) leaves 1K in a 4K sector for signature 35MD5_PARTITION_BEGIN = b'\xEB\xEB' + b'\xFF' * 14 # The first 2 bytes are like magic numbers for MD5 sum 36PARTITION_TABLE_SIZE = 0x1000 # Size of partition table 37 38MIN_PARTITION_SUBTYPE_APP_OTA = 0x10 39NUM_PARTITION_SUBTYPE_APP_OTA = 16 40 41__version__ = '1.2' 42 43APP_TYPE = 0x00 44DATA_TYPE = 0x01 45 46TYPES = { 47 'app': APP_TYPE, 48 'data': DATA_TYPE, 49} 50 51# Keep this map in sync with esp_partition_subtype_t enum in esp_partition.h 52SUBTYPES = { 53 APP_TYPE: { 54 'factory': 0x00, 55 'test': 0x20, 56 }, 57 DATA_TYPE: { 58 'ota': 0x00, 59 'phy': 0x01, 60 'nvs': 0x02, 61 'coredump': 0x03, 62 'nvs_keys': 0x04, 63 'efuse': 0x05, 64 'esphttpd': 0x80, 65 'fat': 0x81, 66 'spiffs': 0x82, 67 }, 68} 69 70quiet = False 71md5sum = True 72secure = False 73offset_part_table = 0 74 75 76def status(msg): 77 """ Print status message to stderr """ 78 if not quiet: 79 critical(msg) 80 81 82def critical(msg): 83 """ Print critical message to stderr """ 84 sys.stderr.write(msg) 85 sys.stderr.write('\n') 86 87 88class PartitionTable(list): 89 def __init__(self): 90 super(PartitionTable, self).__init__(self) 91 92 @classmethod 93 def from_csv(cls, csv_contents): 94 res = PartitionTable() 95 lines = csv_contents.splitlines() 96 97 def expand_vars(f): 98 f = os.path.expandvars(f) 99 m = re.match(r'(?<!\\)\$([A-Za-z_][A-Za-z0-9_]*)', f) 100 if m: 101 raise InputError("unknown variable '%s'" % m.group(1)) 102 return f 103 104 for line_no in range(len(lines)): 105 line = expand_vars(lines[line_no]).strip() 106 if line.startswith('#') or len(line) == 0: 107 continue 108 try: 109 res.append(PartitionDefinition.from_csv(line, line_no + 1)) 110 except InputError as e: 111 raise InputError('Error at line %d: %s' % (line_no + 1, e)) 112 except Exception: 113 critical('Unexpected error parsing CSV line %d: %s' % (line_no + 1, line)) 114 raise 115 116 # fix up missing offsets & negative sizes 117 last_end = offset_part_table + PARTITION_TABLE_SIZE # first offset after partition table 118 for e in res: 119 if e.offset is not None and e.offset < last_end: 120 if e == res[0]: 121 raise InputError('CSV Error: First partition offset 0x%x overlaps end of partition table 0x%x' 122 % (e.offset, last_end)) 123 else: 124 raise InputError('CSV Error: Partitions overlap. Partition at line %d sets offset 0x%x. Previous partition ends 0x%x' 125 % (e.line_no, e.offset, last_end)) 126 if e.offset is None: 127 pad_to = 0x10000 if e.type == APP_TYPE else 4 128 if last_end % pad_to != 0: 129 last_end += pad_to - (last_end % pad_to) 130 e.offset = last_end 131 if e.size < 0: 132 e.size = -e.size - e.offset 133 last_end = e.offset + e.size 134 135 return res 136 137 def __getitem__(self, item): 138 """ Allow partition table access via name as well as by 139 numeric index. """ 140 if isinstance(item, str): 141 for x in self: 142 if x.name == item: 143 return x 144 raise ValueError("No partition entry named '%s'" % item) 145 else: 146 return super(PartitionTable, self).__getitem__(item) 147 148 def find_by_type(self, ptype, subtype): 149 """ Return a partition by type & subtype, returns 150 None if not found """ 151 # convert ptype & subtypes names (if supplied this way) to integer values 152 try: 153 ptype = TYPES[ptype] 154 except KeyError: 155 try: 156 ptype = int(ptype, 0) 157 except TypeError: 158 pass 159 try: 160 subtype = SUBTYPES[int(ptype)][subtype] 161 except KeyError: 162 try: 163 subtype = int(subtype, 0) 164 except TypeError: 165 pass 166 167 for p in self: 168 if p.type == ptype and p.subtype == subtype: 169 yield p 170 return 171 172 def find_by_name(self, name): 173 for p in self: 174 if p.name == name: 175 return p 176 return None 177 178 def verify(self): 179 # verify each partition individually 180 for p in self: 181 p.verify() 182 183 # check on duplicate name 184 names = [p.name for p in self] 185 duplicates = set(n for n in names if names.count(n) > 1) 186 187 # print sorted duplicate partitions by name 188 if len(duplicates) != 0: 189 print('A list of partitions that have the same name:') 190 for p in sorted(self, key=lambda x:x.name): 191 if len(duplicates.intersection([p.name])) != 0: 192 print('%s' % (p.to_csv())) 193 raise InputError('Partition names must be unique') 194 195 # check for overlaps 196 last = None 197 for p in sorted(self, key=lambda x:x.offset): 198 if p.offset < offset_part_table + PARTITION_TABLE_SIZE: 199 raise InputError('Partition offset 0x%x is below 0x%x' % (p.offset, offset_part_table + PARTITION_TABLE_SIZE)) 200 if last is not None and p.offset < last.offset + last.size: 201 raise InputError('Partition at 0x%x overlaps 0x%x-0x%x' % (p.offset, last.offset, last.offset + last.size - 1)) 202 last = p 203 204 def flash_size(self): 205 """ Return the size that partitions will occupy in flash 206 (ie the offset the last partition ends at) 207 """ 208 try: 209 last = sorted(self, reverse=True)[0] 210 except IndexError: 211 return 0 # empty table! 212 return last.offset + last.size 213 214 @classmethod 215 def from_binary(cls, b): 216 md5 = hashlib.md5() 217 result = cls() 218 for o in range(0,len(b),32): 219 data = b[o:o + 32] 220 if len(data) != 32: 221 raise InputError('Partition table length must be a multiple of 32 bytes') 222 if data == b'\xFF' * 32: 223 return result # got end marker 224 if md5sum and data[:2] == MD5_PARTITION_BEGIN[:2]: # check only the magic number part 225 if data[16:] == md5.digest(): 226 continue # the next iteration will check for the end marker 227 else: 228 raise InputError("MD5 checksums don't match! (computed: 0x%s, parsed: 0x%s)" % (md5.hexdigest(), binascii.hexlify(data[16:]))) 229 else: 230 md5.update(data) 231 result.append(PartitionDefinition.from_binary(data)) 232 raise InputError('Partition table is missing an end-of-table marker') 233 234 def to_binary(self): 235 result = b''.join(e.to_binary() for e in self) 236 if md5sum: 237 result += MD5_PARTITION_BEGIN + hashlib.md5(result).digest() 238 if len(result) >= MAX_PARTITION_LENGTH: 239 raise InputError('Binary partition table length (%d) longer than max' % len(result)) 240 result += b'\xFF' * (MAX_PARTITION_LENGTH - len(result)) # pad the sector, for signing 241 return result 242 243 def to_csv(self, simple_formatting=False): 244 rows = ['# ESP-IDF Partition Table', 245 '# Name, Type, SubType, Offset, Size, Flags'] 246 rows += [x.to_csv(simple_formatting) for x in self] 247 return '\n'.join(rows) + '\n' 248 249 250class PartitionDefinition(object): 251 MAGIC_BYTES = b'\xAA\x50' 252 253 ALIGNMENT = { 254 APP_TYPE: 0x10000, 255 DATA_TYPE: 0x04, 256 } 257 258 # dictionary maps flag name (as used in CSV flags list, property name) 259 # to bit set in flags words in binary format 260 FLAGS = { 261 'encrypted': 0 262 } 263 264 # add subtypes for the 16 OTA slot values ("ota_XX, etc.") 265 for ota_slot in range(NUM_PARTITION_SUBTYPE_APP_OTA): 266 SUBTYPES[TYPES['app']]['ota_%d' % ota_slot] = MIN_PARTITION_SUBTYPE_APP_OTA + ota_slot 267 268 def __init__(self): 269 self.name = '' 270 self.type = None 271 self.subtype = None 272 self.offset = None 273 self.size = None 274 self.encrypted = False 275 276 @classmethod 277 def from_csv(cls, line, line_no): 278 """ Parse a line from the CSV """ 279 line_w_defaults = line + ',,,,' # lazy way to support default fields 280 fields = [f.strip() for f in line_w_defaults.split(',')] 281 282 res = PartitionDefinition() 283 res.line_no = line_no 284 res.name = fields[0] 285 res.type = res.parse_type(fields[1]) 286 res.subtype = res.parse_subtype(fields[2]) 287 res.offset = res.parse_address(fields[3]) 288 res.size = res.parse_address(fields[4]) 289 if res.size is None: 290 raise InputError("Size field can't be empty") 291 292 flags = fields[5].split(':') 293 for flag in flags: 294 if flag in cls.FLAGS: 295 setattr(res, flag, True) 296 elif len(flag) > 0: 297 raise InputError("CSV flag column contains unknown flag '%s'" % (flag)) 298 299 return res 300 301 def __eq__(self, other): 302 return self.name == other.name and self.type == other.type \ 303 and self.subtype == other.subtype and self.offset == other.offset \ 304 and self.size == other.size 305 306 def __repr__(self): 307 def maybe_hex(x): 308 return '0x%x' % x if x is not None else 'None' 309 return "PartitionDefinition('%s', 0x%x, 0x%x, %s, %s)" % (self.name, self.type, self.subtype or 0, 310 maybe_hex(self.offset), maybe_hex(self.size)) 311 312 def __str__(self): 313 return "Part '%s' %d/%d @ 0x%x size 0x%x" % (self.name, self.type, self.subtype, self.offset or -1, self.size or -1) 314 315 def __cmp__(self, other): 316 return self.offset - other.offset 317 318 def __lt__(self, other): 319 return self.offset < other.offset 320 321 def __gt__(self, other): 322 return self.offset > other.offset 323 324 def __le__(self, other): 325 return self.offset <= other.offset 326 327 def __ge__(self, other): 328 return self.offset >= other.offset 329 330 def parse_type(self, strval): 331 if strval == '': 332 raise InputError("Field 'type' can't be left empty.") 333 return parse_int(strval, TYPES) 334 335 def parse_subtype(self, strval): 336 if strval == '': 337 return 0 # default 338 return parse_int(strval, SUBTYPES.get(self.type, {})) 339 340 def parse_address(self, strval): 341 if strval == '': 342 return None # PartitionTable will fill in default 343 return parse_int(strval) 344 345 def verify(self): 346 if self.type is None: 347 raise ValidationError(self, 'Type field is not set') 348 if self.subtype is None: 349 raise ValidationError(self, 'Subtype field is not set') 350 if self.offset is None: 351 raise ValidationError(self, 'Offset field is not set') 352 align = self.ALIGNMENT.get(self.type, 4) 353 if self.offset % align: 354 raise ValidationError(self, 'Offset 0x%x is not aligned to 0x%x' % (self.offset, align)) 355 if self.size % align and secure: 356 raise ValidationError(self, 'Size 0x%x is not aligned to 0x%x' % (self.size, align)) 357 if self.size is None: 358 raise ValidationError(self, 'Size field is not set') 359 360 if self.name in TYPES and TYPES.get(self.name, '') != self.type: 361 critical("WARNING: Partition has name '%s' which is a partition type, but does not match this partition's " 362 'type (0x%x). Mistake in partition table?' % (self.name, self.type)) 363 all_subtype_names = [] 364 for names in (t.keys() for t in SUBTYPES.values()): 365 all_subtype_names += names 366 if self.name in all_subtype_names and SUBTYPES.get(self.type, {}).get(self.name, '') != self.subtype: 367 critical("WARNING: Partition has name '%s' which is a partition subtype, but this partition has " 368 'non-matching type 0x%x and subtype 0x%x. Mistake in partition table?' % (self.name, self.type, self.subtype)) 369 370 STRUCT_FORMAT = b'<2sBBLL16sL' 371 372 @classmethod 373 def from_binary(cls, b): 374 if len(b) != 32: 375 raise InputError('Partition definition length must be exactly 32 bytes. Got %d bytes.' % len(b)) 376 res = cls() 377 (magic, res.type, res.subtype, res.offset, 378 res.size, res.name, flags) = struct.unpack(cls.STRUCT_FORMAT, b) 379 if b'\x00' in res.name: # strip null byte padding from name string 380 res.name = res.name[:res.name.index(b'\x00')] 381 res.name = res.name.decode() 382 if magic != cls.MAGIC_BYTES: 383 raise InputError('Invalid magic bytes (%r) for partition definition' % magic) 384 for flag,bit in cls.FLAGS.items(): 385 if flags & (1 << bit): 386 setattr(res, flag, True) 387 flags &= ~(1 << bit) 388 if flags != 0: 389 critical('WARNING: Partition definition had unknown flag(s) 0x%08x. Newer binary format?' % flags) 390 return res 391 392 def get_flags_list(self): 393 return [flag for flag in self.FLAGS.keys() if getattr(self, flag)] 394 395 def to_binary(self): 396 flags = sum((1 << self.FLAGS[flag]) for flag in self.get_flags_list()) 397 return struct.pack(self.STRUCT_FORMAT, 398 self.MAGIC_BYTES, 399 self.type, self.subtype, 400 self.offset, self.size, 401 self.name.encode(), 402 flags) 403 404 def to_csv(self, simple_formatting=False): 405 def addr_format(a, include_sizes): 406 if not simple_formatting and include_sizes: 407 for (val, suffix) in [(0x100000, 'M'), (0x400, 'K')]: 408 if a % val == 0: 409 return '%d%s' % (a // val, suffix) 410 return '0x%x' % a 411 412 def lookup_keyword(t, keywords): 413 for k,v in keywords.items(): 414 if simple_formatting is False and t == v: 415 return k 416 return '%d' % t 417 418 def generate_text_flags(): 419 """ colon-delimited list of flags """ 420 return ':'.join(self.get_flags_list()) 421 422 return ','.join([self.name, 423 lookup_keyword(self.type, TYPES), 424 lookup_keyword(self.subtype, SUBTYPES.get(self.type, {})), 425 addr_format(self.offset, False), 426 addr_format(self.size, True), 427 generate_text_flags()]) 428 429 430def parse_int(v, keywords={}): 431 """Generic parser for integer fields - int(x,0) with provision for 432 k/m/K/M suffixes and 'keyword' value lookup. 433 """ 434 try: 435 for letter, multiplier in [('k', 1024), ('m', 1024 * 1024)]: 436 if v.lower().endswith(letter): 437 return parse_int(v[:-1], keywords) * multiplier 438 return int(v, 0) 439 except ValueError: 440 if len(keywords) == 0: 441 raise InputError('Invalid field value %s' % v) 442 try: 443 return keywords[v.lower()] 444 except KeyError: 445 raise InputError("Value '%s' is not valid. Known keywords: %s" % (v, ', '.join(keywords))) 446 447 448def main(): 449 global quiet 450 global md5sum 451 global offset_part_table 452 global secure 453 parser = argparse.ArgumentParser(description='ESP32 partition table utility') 454 455 parser.add_argument('--flash-size', help='Optional flash size limit, checks partition table fits in flash', 456 nargs='?', choices=['1MB', '2MB', '4MB', '8MB', '16MB']) 457 parser.add_argument('--disable-md5sum', help='Disable md5 checksum for the partition table', default=False, action='store_true') 458 parser.add_argument('--no-verify', help="Don't verify partition table fields", action='store_true') 459 parser.add_argument('--verify', '-v', help='Verify partition table fields (deprecated, this behaviour is ' 460 'enabled by default and this flag does nothing.', action='store_true') 461 parser.add_argument('--quiet', '-q', help="Don't print non-critical status messages to stderr", action='store_true') 462 parser.add_argument('--offset', '-o', help='Set offset partition table', default='0x8000') 463 parser.add_argument('--secure', help='Require app partitions to be suitable for secure boot', action='store_true') 464 parser.add_argument('input', help='Path to CSV or binary file to parse.', type=argparse.FileType('rb')) 465 parser.add_argument('output', help='Path to output converted binary or CSV file. Will use stdout if omitted.', 466 nargs='?', default='-') 467 468 args = parser.parse_args() 469 470 quiet = args.quiet 471 md5sum = not args.disable_md5sum 472 secure = args.secure 473 offset_part_table = int(args.offset, 0) 474 input = args.input.read() 475 input_is_binary = input[0:2] == PartitionDefinition.MAGIC_BYTES 476 if input_is_binary: 477 status('Parsing binary partition input...') 478 table = PartitionTable.from_binary(input) 479 else: 480 input = input.decode() 481 status('Parsing CSV input...') 482 table = PartitionTable.from_csv(input) 483 484 if not args.no_verify: 485 status('Verifying table...') 486 table.verify() 487 488 if args.flash_size: 489 size_mb = int(args.flash_size.replace('MB', '')) 490 size = size_mb * 1024 * 1024 # flash memory uses honest megabytes! 491 table_size = table.flash_size() 492 if size < table_size: 493 raise InputError("Partitions defined in '%s' occupy %.1fMB of flash (%d bytes) which does not fit in configured " 494 "flash size %dMB. Change the flash size in menuconfig under the 'Serial Flasher Config' menu." % 495 (args.input.name, table_size / 1024.0 / 1024.0, table_size, size_mb)) 496 497 # Make sure that the output directory is created 498 output_dir = os.path.abspath(os.path.dirname(args.output)) 499 500 if not os.path.exists(output_dir): 501 try: 502 os.makedirs(output_dir) 503 except OSError as exc: 504 if exc.errno != errno.EEXIST: 505 raise 506 507 if input_is_binary: 508 output = table.to_csv() 509 with sys.stdout if args.output == '-' else open(args.output, 'w') as f: 510 f.write(output) 511 else: 512 output = table.to_binary() 513 try: 514 stdout_binary = sys.stdout.buffer # Python 3 515 except AttributeError: 516 stdout_binary = sys.stdout 517 with stdout_binary if args.output == '-' else open(args.output, 'wb') as f: 518 f.write(output) 519 520 521class InputError(RuntimeError): 522 def __init__(self, e): 523 super(InputError, self).__init__(e) 524 525 526class ValidationError(InputError): 527 def __init__(self, partition, message): 528 super(ValidationError, self).__init__( 529 'Partition %s invalid: %s' % (partition.name, message)) 530 531 532if __name__ == '__main__': 533 try: 534 main() 535 except InputError as e: 536 print(e, file=sys.stderr) 537 sys.exit(2) 538