• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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