pw_cpu_exception_cortex_m: Add exception analyzer

Introduces a python tool to analyze CPU state dumped by
pw_cpu_exception as a proto.

Change-Id: Ifdff6a3d7541edfff3122696a5f22802126cc9b5
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/22003
Reviewed-by: Ewout van Bekkum <ewout@google.com>
Reviewed-by: Keir Mierle <keir@google.com>
Commit-Queue: Armando Montanez <amontanez@google.com>
diff --git a/BUILD.gn b/BUILD.gn
index 8ecf090..6e79779 100644
--- a/BUILD.gn
+++ b/BUILD.gn
@@ -125,6 +125,7 @@
     "$dir_pw_bloat/py",
     "$dir_pw_build/py",
     "$dir_pw_cli/py",
+    "$dir_pw_cpu_exception_cortex_m/py",
     "$dir_pw_docgen/py",
     "$dir_pw_doctor/py",
     "$dir_pw_env_setup/py",
diff --git a/modules.gni b/modules.gni
index a08c554..ee10ead 100644
--- a/modules.gni
+++ b/modules.gni
@@ -38,6 +38,8 @@
   dir_pw_cpu_exception = get_path_info("pw_cpu_exception", "abspath")
   dir_pw_cpu_exception_armv7m =
       get_path_info("pw_cpu_exception_armv7m", "abspath")
+  dir_pw_cpu_exception_cortex_m =
+      get_path_info("pw_cpu_exception_cortex_m", "abspath")
   dir_pw_docgen = get_path_info("pw_docgen", "abspath")
   dir_pw_doctor = get_path_info("pw_doctor", "abspath")
   dir_pw_hex_dump = get_path_info("pw_hex_dump", "abspath")
diff --git a/pw_cpu_exception_cortex_m/py/BUILD.gn b/pw_cpu_exception_cortex_m/py/BUILD.gn
new file mode 100644
index 0000000..8af1eb2
--- /dev/null
+++ b/pw_cpu_exception_cortex_m/py/BUILD.gn
@@ -0,0 +1,28 @@
+# Copyright 2020 The Pigweed Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+
+import("//build_overrides/pigweed.gni")
+
+import("$dir_pw_build/python.gni")
+
+pw_python_package("py") {
+  setup = [ "setup.py" ]
+  sources = [
+    "pw_cpu_exception_cortex_m/__init__.py",
+    "pw_cpu_exception_cortex_m/cortex_m_constants.py",
+    "pw_cpu_exception_cortex_m/exception_analyzer.py",
+  ]
+  tests = [ "exception_analyzer_test.py" ]
+  pylintrc = "$dir_pigweed/.pylintrc"
+}
diff --git a/pw_cpu_exception_cortex_m/py/exception_analyzer_test.py b/pw_cpu_exception_cortex_m/py/exception_analyzer_test.py
new file mode 100644
index 0000000..20e887f
--- /dev/null
+++ b/pw_cpu_exception_cortex_m/py/exception_analyzer_test.py
@@ -0,0 +1,227 @@
+#!/usr/bin/env python3
+# Copyright 2020 The Pigweed Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+"""Tests dumped Cortex-M CPU state."""
+
+import unittest
+import os
+
+from pw_protobuf_compiler import python_protos
+from pw_cli import env
+from pw_cpu_exception_cortex_m import exception_analyzer, cortex_m_constants
+
+CPU_STATE_PROTO_PATH = os.path.join(
+    env.pigweed_environment().PW_ROOT,  #pylint: disable=no-member
+    'pw_cpu_exception_armv7m',
+    'pw_cpu_exception_armv7m_protos',
+    'cpu_state.proto')
+
+cpu_state_pb2 = python_protos.compile_and_import_file(CPU_STATE_PROTO_PATH)
+
+# pylint: disable=protected-access
+
+
+class BasicFaultTest(unittest.TestCase):
+    """Test basic fault analysis functions."""
+    def test_empty_state(self):
+        """Ensure an empty CPU state proto doesn't indicate an active fault."""
+        cpu_state_proto = cpu_state_pb2.ArmV7mCpuState()
+        cpu_state_info = exception_analyzer.CortexMExceptionAnalyzer(
+            cpu_state_proto)
+        self.assertFalse(cpu_state_info.is_fault_active())
+
+    def test_cfsr_fault(self):
+        """Ensure a fault is active if CFSR bits are set."""
+        cpu_state_proto = cpu_state_pb2.ArmV7mCpuState()
+        cpu_state_proto.cfsr = (
+            cortex_m_constants.PW_CORTEX_M_CFSR_STKOF_MASK
+            | cortex_m_constants.PW_CORTEX_M_CFSR_MUNSTKERR_MASK)
+        cpu_state_info = exception_analyzer.CortexMExceptionAnalyzer(
+            cpu_state_proto)
+        self.assertTrue(cpu_state_info.is_fault_active())
+
+    def test_icsr_fault(self):
+        """Ensure a fault is active if ICSR says the handler is active."""
+        cpu_state_proto = cpu_state_pb2.ArmV7mCpuState()
+        cpu_state_proto.icsr = (
+            cortex_m_constants.PW_CORTEX_M_HARD_FAULT_ISR_NUM)
+        cpu_state_info = exception_analyzer.CortexMExceptionAnalyzer(
+            cpu_state_proto)
+        self.assertTrue(cpu_state_info.is_fault_active())
+
+    def test_cfsr_fields(self):
+        """Ensure correct fields are returned when CFSR bits are set."""
+        cpu_state_proto = cpu_state_pb2.ArmV7mCpuState()
+        cpu_state_proto.cfsr = (
+            cortex_m_constants.PW_CORTEX_M_CFSR_STKOF_MASK
+            | cortex_m_constants.PW_CORTEX_M_CFSR_MUNSTKERR_MASK)
+        cpu_state_info = exception_analyzer.CortexMExceptionAnalyzer(
+            cpu_state_proto)
+        active_fields = [
+            field.name for field in cpu_state_info.active_cfsr_fields()
+        ]
+        self.assertEqual(len(active_fields), 2)
+        self.assertIn('STKOF', active_fields)
+        self.assertIn('MUNSTKERR', active_fields)
+
+
+class ExceptionCauseTest(unittest.TestCase):
+    """Test exception cause analysis."""
+    def test_empty_cpu_state(self):
+        """Ensure empty CPU state has no known cause."""
+        cpu_state_proto = cpu_state_pb2.ArmV7mCpuState()
+        cpu_state_info = exception_analyzer.CortexMExceptionAnalyzer(
+            cpu_state_proto)
+        self.assertEqual(cpu_state_info.exception_cause(), 'unknown exception')
+
+    def test_unknown_exception(self):
+        """Ensure CPU state with insufficient info has no known cause."""
+        cpu_state_proto = cpu_state_pb2.ArmV7mCpuState()
+        # Set CFSR to a valid value.
+        cpu_state_proto.cfsr = 0
+        cpu_state_info = exception_analyzer.CortexMExceptionAnalyzer(
+            cpu_state_proto)
+        self.assertEqual(cpu_state_info.exception_cause(), 'unknown exception')
+
+    def test_single_usage_fault(self):
+        """Ensure usage faults are properly identified."""
+        cpu_state_proto = cpu_state_pb2.ArmV7mCpuState()
+        cpu_state_proto.cfsr = cortex_m_constants.PW_CORTEX_M_CFSR_STKOF_MASK
+        cpu_state_info = exception_analyzer.CortexMExceptionAnalyzer(
+            cpu_state_proto)
+        self.assertEqual(cpu_state_info.exception_cause(),
+                         'usage fault [STKOF]')
+
+    def test_single_usage_fault_without_fields(self):
+        """Ensure disabling show_active_cfsr_fields hides field names."""
+        cpu_state_proto = cpu_state_pb2.ArmV7mCpuState()
+        cpu_state_proto.cfsr = cortex_m_constants.PW_CORTEX_M_CFSR_STKOF_MASK
+        cpu_state_info = exception_analyzer.CortexMExceptionAnalyzer(
+            cpu_state_proto)
+        self.assertEqual(cpu_state_info.exception_cause(False), 'usage fault')
+
+    def test_multiple_faults(self):
+        """Ensure multiple CFSR bits are identified and reported."""
+        cpu_state_proto = cpu_state_pb2.ArmV7mCpuState()
+        cpu_state_proto.cfsr = (
+            cortex_m_constants.PW_CORTEX_M_CFSR_STKOF_MASK
+            | cortex_m_constants.PW_CORTEX_M_CFSR_UNSTKERR_MASK)
+        cpu_state_info = exception_analyzer.CortexMExceptionAnalyzer(
+            cpu_state_proto)
+        self.assertEqual(cpu_state_info.exception_cause(),
+                         'usage fault, bus fault [UNSTKERR] [STKOF]')
+
+    def test_mmfar_missing(self):
+        """Ensure if mmfar is valid but missing it is handled safely."""
+        cpu_state_proto = cpu_state_pb2.ArmV7mCpuState()
+        cpu_state_proto.cfsr = (
+            cortex_m_constants.PW_CORTEX_M_CFSR_MUNSTKERR_MASK
+            | cortex_m_constants.PW_CORTEX_M_CFSR_MMARVALID_MASK)
+        cpu_state_info = exception_analyzer.CortexMExceptionAnalyzer(
+            cpu_state_proto)
+        self.assertEqual(cpu_state_info.exception_cause(False),
+                         'memory management fault at ???')
+
+    def test_mmfar_valid(self):
+        """Validate output format of valid MMFAR."""
+        cpu_state_proto = cpu_state_pb2.ArmV7mCpuState()
+        cpu_state_proto.cfsr = (
+            cortex_m_constants.PW_CORTEX_M_CFSR_MUNSTKERR_MASK
+            | cortex_m_constants.PW_CORTEX_M_CFSR_MMARVALID_MASK)
+        cpu_state_proto.mmfar = 0x722470e4
+        cpu_state_info = exception_analyzer.CortexMExceptionAnalyzer(
+            cpu_state_proto)
+        self.assertEqual(cpu_state_info.exception_cause(False),
+                         'memory management fault at 0x722470e4')
+
+    def test_imprecise_bus_fault(self):
+        """Check that imprecise bus faults are identified correctly."""
+        cpu_state_proto = cpu_state_pb2.ArmV7mCpuState()
+        cpu_state_proto.cfsr = (
+            cortex_m_constants.PW_CORTEX_M_CFSR_IMPRECISERR_MASK
+            | cortex_m_constants.PW_CORTEX_M_CFSR_IBUSERR_MASK)
+        cpu_state_info = exception_analyzer.CortexMExceptionAnalyzer(
+            cpu_state_proto)
+        self.assertEqual(cpu_state_info.exception_cause(False),
+                         'imprecise bus fault')
+
+
+class TextDumpTest(unittest.TestCase):
+    """Test larger state dumps."""
+    def test_registers(self):
+        """Validate output of general register dumps."""
+        cpu_state_proto = cpu_state_pb2.ArmV7mCpuState()
+        cpu_state_proto.pc = 0xdfadd966
+        cpu_state_proto.mmfar = 0xaf2ea98a
+        cpu_state_proto.r0 = 0xf3b235b1
+        cpu_state_info = exception_analyzer.CortexMExceptionAnalyzer(
+            cpu_state_proto)
+        expected_dump = '\n'.join((
+            'pc         0xdfadd966',
+            'mmfar      0xaf2ea98a',
+            'r0         0xf3b235b1',
+        ))
+        self.assertEqual(cpu_state_info.dump_registers(), expected_dump)
+
+    def test_dump_no_cfsr(self):
+        """Validate basic CPU state dump."""
+        cpu_state_proto = cpu_state_pb2.ArmV7mCpuState()
+        cpu_state_proto.pc = 0xd2603058
+        cpu_state_proto.mmfar = 0x8e4eb9a2
+        cpu_state_proto.r0 = 0xdb5e7168
+        cpu_state_info = exception_analyzer.CortexMExceptionAnalyzer(
+            cpu_state_proto)
+        expected_dump = '\n'.join((
+            'Exception caused by a unknown exception.',
+            '',
+            'No active Crash Fault Status Register (CFSR) fields.',
+            '',
+            'All registers:',
+            'pc         0xd2603058',
+            'mmfar      0x8e4eb9a2',
+            'r0         0xdb5e7168',
+        ))
+        self.assertEqual(str(cpu_state_info), expected_dump)
+
+    def test_dump_with_cfsr(self):
+        """Validate CPU state dump with CFSR bits set is formatted correctly."""
+        cpu_state_proto = cpu_state_pb2.ArmV7mCpuState()
+        cpu_state_proto.cfsr = (
+            cortex_m_constants.PW_CORTEX_M_CFSR_PRECISEERR_MASK
+            | cortex_m_constants.PW_CORTEX_M_CFSR_BFARVALID_MASK)
+        cpu_state_proto.pc = 0xd2603058
+        cpu_state_proto.bfar = 0xdeadbeef
+        cpu_state_proto.mmfar = 0x8e4eb9a2
+        cpu_state_proto.r0 = 0xdb5e7168
+        cpu_state_info = exception_analyzer.CortexMExceptionAnalyzer(
+            cpu_state_proto)
+        expected_dump = '\n'.join((
+            'Exception caused by a bus fault at 0xdeadbeef.',
+            '',
+            'Active Crash Fault Status Register (CFSR) fields:',
+            'PRECISEERR  Precise bus fault.',
+            'BFARVALID   BFAR is valid.',
+            '',
+            'All registers:',
+            'pc         0xd2603058',
+            'cfsr       0x00008200',
+            'mmfar      0x8e4eb9a2',
+            'bfar       0xdeadbeef',
+            'r0         0xdb5e7168',
+        ))
+        self.assertEqual(str(cpu_state_info), expected_dump)
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/pw_cpu_exception_cortex_m/py/pw_cpu_exception_cortex_m/__init__.py b/pw_cpu_exception_cortex_m/py/pw_cpu_exception_cortex_m/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/pw_cpu_exception_cortex_m/py/pw_cpu_exception_cortex_m/__init__.py
diff --git a/pw_cpu_exception_cortex_m/py/pw_cpu_exception_cortex_m/cortex_m_constants.py b/pw_cpu_exception_cortex_m/py/pw_cpu_exception_cortex_m/cortex_m_constants.py
new file mode 100644
index 0000000..4bf5114
--- /dev/null
+++ b/pw_cpu_exception_cortex_m/py/pw_cpu_exception_cortex_m/cortex_m_constants.py
@@ -0,0 +1,117 @@
+# Copyright 2020 The Pigweed Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+"""Cortex-M architecture related constants."""
+
+import collections
+
+# Cortex-M (ARMv7-M + ARMv8-M) related constants.
+# These values are from the ARMv7-M Architecture Reference Manual DDI 0403E.b
+# and ARMv8-M Architecture Reference Manual DDI 0553A.e.
+# https =//static.docs.arm.com/ddi0403/e/DDI0403E_B_armv7m_arm.pdf
+# https =//static.docs.arm.com/ddi0553/a/DDI0553A_e_armv8m_arm.pdf
+
+# Exception ISR number. (ARMv7-M Section B1.5.2)
+# When the ISR number (accessible from ICSR and PSR) is zero, it indicates the
+# core is in thread mode.
+PW_CORTEX_M_THREAD_MODE_ISR_NUM = 0x0
+PW_CORTEX_M_NMI_ISR_NUM = 0x2
+PW_CORTEX_M_HARD_FAULT_ISR_NUM = 0x3
+PW_CORTEX_M_MEM_FAULT_ISR_NUM = 0x4
+PW_CORTEX_M_BUS_FAULT_ISR_NUM = 0x5
+PW_CORTEX_M_USAGE_FAULT_ISR_NUM = 0x6
+
+# Masks for Interrupt Control and State Register ICSR (ARMv7-M Section B3.2.4)
+PW_CORTEX_M_ICSR_VECTACTIVE_MASK = (1 << 9) - 1
+
+# Masks for individual bits of HFSR. (ARMv7-M Section B3.2.16)
+PW_CORTEX_M_HFSR_FORCED_MASK = 0x1 << 30
+
+# Masks for different sections of CFSR. (ARMv7-M Section B3.2.15)
+PW_CORTEX_M_CFSR_MEM_FAULT_MASK = 0x000000ff
+PW_CORTEX_M_CFSR_BUS_FAULT_MASK = 0x0000ff00
+PW_CORTEX_M_CFSR_USAGE_FAULT_MASK = 0xffff0000
+
+# Masks for individual bits of CFSR. (ARMv7-M Section B3.2.15)
+# Memory faults (MemManage Status Register) =
+PW_CORTEX_M_CFSR_MEM_FAULT_START = (0x1)
+PW_CORTEX_M_CFSR_IACCVIOL_MASK = (PW_CORTEX_M_CFSR_MEM_FAULT_START << 0)
+PW_CORTEX_M_CFSR_DACCVIOL_MASK = (PW_CORTEX_M_CFSR_MEM_FAULT_START << 1)
+PW_CORTEX_M_CFSR_MUNSTKERR_MASK = (PW_CORTEX_M_CFSR_MEM_FAULT_START << 3)
+PW_CORTEX_M_CFSR_MSTKERR_MASK = (PW_CORTEX_M_CFSR_MEM_FAULT_START << 4)
+PW_CORTEX_M_CFSR_MLSPERR_MASK = (PW_CORTEX_M_CFSR_MEM_FAULT_START << 5)
+PW_CORTEX_M_CFSR_MMARVALID_MASK = (PW_CORTEX_M_CFSR_MEM_FAULT_START << 7)
+# Bus faults (BusFault Status Register) =
+PW_CORTEX_M_CFSR_BUS_FAULT_START = (0x1 << 8)
+PW_CORTEX_M_CFSR_IBUSERR_MASK = (PW_CORTEX_M_CFSR_BUS_FAULT_START << 0)
+PW_CORTEX_M_CFSR_PRECISEERR_MASK = (PW_CORTEX_M_CFSR_BUS_FAULT_START << 1)
+PW_CORTEX_M_CFSR_IMPRECISERR_MASK = (PW_CORTEX_M_CFSR_BUS_FAULT_START << 2)
+PW_CORTEX_M_CFSR_UNSTKERR_MASK = (PW_CORTEX_M_CFSR_BUS_FAULT_START << 3)
+PW_CORTEX_M_CFSR_STKERR_MASK = (PW_CORTEX_M_CFSR_BUS_FAULT_START << 4)
+PW_CORTEX_M_CFSR_LSPERR_MASK = (PW_CORTEX_M_CFSR_BUS_FAULT_START << 5)
+PW_CORTEX_M_CFSR_BFARVALID_MASK = (PW_CORTEX_M_CFSR_BUS_FAULT_START << 7)
+# Usage faults (UsageFault Status Register) =
+PW_CORTEX_M_CFSR_USAGE_FAULT_START = (0x1 << 16)
+PW_CORTEX_M_CFSR_UNDEFINSTR_MASK = (PW_CORTEX_M_CFSR_USAGE_FAULT_START << 0)
+PW_CORTEX_M_CFSR_INVSTATE_MASK = (PW_CORTEX_M_CFSR_USAGE_FAULT_START << 1)
+PW_CORTEX_M_CFSR_INVPC_MASK = (PW_CORTEX_M_CFSR_USAGE_FAULT_START << 2)
+PW_CORTEX_M_CFSR_NOCP_MASK = (PW_CORTEX_M_CFSR_USAGE_FAULT_START << 3)
+PW_CORTEX_M_CFSR_STKOF_MASK = (PW_CORTEX_M_CFSR_USAGE_FAULT_START << 4)
+PW_CORTEX_M_CFSR_UNALIGNED_MASK = (PW_CORTEX_M_CFSR_USAGE_FAULT_START << 8)
+PW_CORTEX_M_CFSR_DIVBYZERO_MASK = (PW_CORTEX_M_CFSR_USAGE_FAULT_START << 9)
+
+# TODO(amontanez): We could probably make a whole module on bit field handling
+# in python.
+BitField = collections.namedtuple('BitField',
+                                  ['name', 'bit_mask', 'description'])
+
+PW_CORTEX_M_CFSR_BIT_FIELDS = [
+    BitField('IACCVIOL', PW_CORTEX_M_CFSR_IACCVIOL_MASK,
+             'MPU violation on instruction fetch.'),
+    BitField('DACCVIOL', PW_CORTEX_M_CFSR_DACCVIOL_MASK,
+             'MPU violation on memory read/write.'),
+    BitField('MUNSTKERR', PW_CORTEX_M_CFSR_MUNSTKERR_MASK,
+             'MPU violation on exception return.'),
+    BitField('MSTKERR', PW_CORTEX_M_CFSR_MSTKERR_MASK,
+             'MPU violation on exception entry.'),
+    BitField('MLSPERR', PW_CORTEX_M_CFSR_MLSPERR_MASK,
+             'FPU lazy state preservation failed.'),
+    BitField('MMARVALID', PW_CORTEX_M_CFSR_MMARVALID_MASK,
+             'MMFAR register is valid.'),
+    BitField('IBUSERR', PW_CORTEX_M_CFSR_IBUSERR_MASK,
+             'Bus fault on instruction fetch.'),
+    BitField('PRECISEERR', PW_CORTEX_M_CFSR_PRECISEERR_MASK,
+             'Precise bus fault.'),
+    BitField('IMPRECISERR', PW_CORTEX_M_CFSR_IMPRECISERR_MASK,
+             'Imprecise bus fault.'),
+    BitField('UNSTKERR', PW_CORTEX_M_CFSR_UNSTKERR_MASK,
+             'Hardware failure on context restore.'),
+    BitField('STKERR', PW_CORTEX_M_CFSR_STKERR_MASK,
+             'Hardware failure on context save.'),
+    BitField('LSPERR', PW_CORTEX_M_CFSR_LSPERR_MASK,
+             'FPU lazy state preservation failed.'),
+    BitField('BFARVALID', PW_CORTEX_M_CFSR_BFARVALID_MASK, 'BFAR is valid.'),
+    BitField('UNDEFINSTR', PW_CORTEX_M_CFSR_UNDEFINSTR_MASK,
+             'Encountered invalid instruction.'),
+    BitField('INVSTATE', PW_CORTEX_M_CFSR_INVSTATE_MASK,
+             ('Attempted to execute an instruction with an invalid Execution '
+              'Program Status Register (EPSR) value.')),
+    BitField('INVPC', PW_CORTEX_M_CFSR_INVPC_MASK,
+             'Program Counter (PC) is not legal.'),
+    BitField('NOCP', PW_CORTEX_M_CFSR_NOCP_MASK,
+             'Coprocessor disabled or not present.'),
+    BitField('STKOF', PW_CORTEX_M_CFSR_STKOF_MASK, 'Stack overflowed.'),
+    BitField('UNALIGNED', PW_CORTEX_M_CFSR_UNALIGNED_MASK,
+             'Unaligned load or store. (This exception can be disabled)'),
+    BitField('DIVBYZERO', PW_CORTEX_M_CFSR_DIVBYZERO_MASK, 'Divide by zero.'),
+]
diff --git a/pw_cpu_exception_cortex_m/py/pw_cpu_exception_cortex_m/exception_analyzer.py b/pw_cpu_exception_cortex_m/py/pw_cpu_exception_cortex_m/exception_analyzer.py
new file mode 100644
index 0000000..c246a80
--- /dev/null
+++ b/pw_cpu_exception_cortex_m/py/pw_cpu_exception_cortex_m/exception_analyzer.py
@@ -0,0 +1,148 @@
+# Copyright 2020 The Pigweed Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+"""Tools to analyze Cortex-M CPU state context captured during an exception."""
+
+from typing import Tuple
+
+from pw_cpu_exception_cortex_m import cortex_m_constants
+
+
+class CortexMExceptionAnalyzer:
+    """This class provides helper functions to dump a ArmV7mCpuState proto."""
+    def __init__(self, cpu_state):
+        self._cpu_state = cpu_state
+        self._active_cfsr_fields = None
+
+    def active_cfsr_fields(self) -> Tuple[cortex_m_constants.BitField, ...]:
+        """Returns a list of BitFields for each active CFSR flag."""
+
+        if self._active_cfsr_fields is not None:
+            return self._active_cfsr_fields
+
+        temp_field_list = []
+        if self._cpu_state.HasField('cfsr'):
+            for bit_field in cortex_m_constants.PW_CORTEX_M_CFSR_BIT_FIELDS:
+                if self._cpu_state.cfsr & bit_field.bit_mask:
+                    temp_field_list.append(bit_field)
+        self._active_cfsr_fields = tuple(temp_field_list)
+        return self._active_cfsr_fields
+
+    def is_fault_active(self) -> bool:
+        """Returns true if the current CPU state indicates a fault is active."""
+        if self._cpu_state.HasField('cfsr') and self._cpu_state.cfsr != 0:
+            return True
+        if self._cpu_state.HasField('icsr'):
+            exception_number = (
+                self._cpu_state.icsr
+                & cortex_m_constants.PW_CORTEX_M_ICSR_VECTACTIVE_MASK)
+            if (cortex_m_constants.PW_CORTEX_M_HARD_FAULT_ISR_NUM <=
+                    exception_number <=
+                    cortex_m_constants.PW_CORTEX_M_USAGE_FAULT_ISR_NUM):
+                return True
+        return False
+
+    def is_nested_fault(self) -> bool:
+        """Returns true if the current CPU state indicates a nested fault."""
+        if not self.is_fault_active():
+            return False
+        if (self._cpu_state.HasField('hfsr') and self._cpu_state.hfsr
+                & cortex_m_constants.PW_CORTEX_M_HFSR_FORCED_MASK):
+            return True
+        return False
+
+    def exception_cause(self, show_active_cfsr_fields=True) -> str:
+        """Analyzes CPU state to tries and classify the exception.
+
+        Examples:
+            show_active_cfsr_fields=False
+              unknown exception
+              memory management fault at 0x00000000
+              usage fault, imprecise bus fault
+
+            show_active_cfsr_fields=True
+              usage fault [DIVBYZERO]
+              memory management fault at 0x00000000 [DACCVIOL] [MMARVALID]
+        """
+        cause = ''
+        # The CFSR can accumulate multiple exceptions.
+        split_major_cause = lambda cause: cause if not cause else cause + ', '
+
+        if self._cpu_state.HasField('cfsr') and self.is_fault_active():
+            if (self._cpu_state.cfsr
+                    & cortex_m_constants.PW_CORTEX_M_CFSR_USAGE_FAULT_MASK):
+                cause += 'usage fault'
+
+            if (self._cpu_state.cfsr
+                    & cortex_m_constants.PW_CORTEX_M_CFSR_MEM_FAULT_MASK):
+                cause = split_major_cause(cause)
+                cause += 'memory management fault'
+                if (self._cpu_state.cfsr
+                        & cortex_m_constants.PW_CORTEX_M_CFSR_MMARVALID_MASK):
+                    addr = '???' if not self._cpu_state.HasField(
+                        'mmfar') else f'0x{self._cpu_state.mmfar:08x}'
+                    cause += f' at {addr}'
+
+            if (self._cpu_state.cfsr
+                    & cortex_m_constants.PW_CORTEX_M_CFSR_BUS_FAULT_MASK):
+                cause = split_major_cause(cause)
+                if (self._cpu_state.cfsr &
+                        cortex_m_constants.PW_CORTEX_M_CFSR_IMPRECISERR_MASK):
+                    cause += 'imprecise '
+                cause += 'bus fault'
+                if (self._cpu_state.cfsr
+                        & cortex_m_constants.PW_CORTEX_M_CFSR_BFARVALID_MASK):
+                    addr = '???' if not self._cpu_state.HasField(
+                        'bfar') else f'0x{self._cpu_state.bfar:08x}'
+                    cause += f' at {addr}'
+            if show_active_cfsr_fields:
+                for field in self.active_cfsr_fields():
+                    cause += f' [{field.name}]'
+
+        return cause if cause else 'unknown exception'
+
+    def dump_registers(self) -> str:
+        """Dumps all captured CPU registers as a multi-line string."""
+        registers = []
+        # TODO(amontanez): Do fancier decode of some registers like PC and LR.
+        for field in self._cpu_state.DESCRIPTOR.fields:
+            if self._cpu_state.HasField(field.name):
+                register_value = getattr(self._cpu_state, field.name)
+                registers.append(f'{field.name:<10} 0x{register_value:08x}')
+        return '\n'.join(registers)
+
+    def dump_active_active_cfsr_fields(self) -> str:
+        """Dumps CFSR flags with their descriptions as a multi-line string."""
+        fields = []
+        for field in self.active_cfsr_fields():
+            fields.append(f'{field.name:<11} {field.description}')
+        return '\n'.join(fields)
+
+    def __str__(self):
+        dump = [f'Exception caused by a {self.exception_cause(False)}.', '']
+        if self.active_cfsr_fields():
+            dump.extend((
+                'Active Crash Fault Status Register (CFSR) fields:',
+                self.dump_active_active_cfsr_fields(),
+                '',
+            ))
+        else:
+            dump.extend((
+                'No active Crash Fault Status Register (CFSR) fields.',
+                '',
+            ))
+        dump.extend((
+            'All registers:',
+            self.dump_registers(),
+        ))
+        return '\n'.join(dump)
diff --git a/pw_cpu_exception_cortex_m/py/setup.py b/pw_cpu_exception_cortex_m/py/setup.py
new file mode 100644
index 0000000..e476dee
--- /dev/null
+++ b/pw_cpu_exception_cortex_m/py/setup.py
@@ -0,0 +1,28 @@
+# Copyright 2020 The Pigweed Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+"""pw_cpu_exception_cortex_m"""
+
+import setuptools  # type: ignore
+
+setuptools.setup(
+    name='pw_cpu_exception_cortex_m',
+    version='0.0.1',
+    author='Pigweed Authors',
+    author_email='pigweed-developers@googlegroups.com',
+    description='Tools for analyzing dumped ARM Cortex-M CPU exceptions',
+    packages=setuptools.find_packages(),
+    package_data={'pw_cpu_exception_cortex_m': ['py.typed']},
+    zip_safe=False,
+    install_requires=['protobuf'],
+)