Snap for 7302914 from 050b7cc0f7e419d59240ab939d5ebb3074b93c21 to sc-d1-release

Change-Id: I512840c6aa75972981b53e604d998c4943c44988
diff --git a/.bazelignore b/.bazelignore
index 9615219..db791e1 100644
--- a/.bazelignore
+++ b/.bazelignore
@@ -1,2 +1,4 @@
 .cipd
 .presubmit
+.environment
+environment
diff --git a/.bazelrc b/.bazelrc
new file mode 100644
index 0000000..21d2703
--- /dev/null
+++ b/.bazelrc
@@ -0,0 +1 @@
+build --incompatible_enable_cc_toolchain_resolution
\ No newline at end of file
diff --git a/.pylintrc b/.pylintrc
index 09055b8..c259400 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -387,14 +387,13 @@
 ignored-classes=optparse.Values,
                 thread._local,
                 _thread._local,
-                pw_cli.envparse.EnvNamespace,
-                pw_rpc.packet_pb2.RpcPacket
+                pw_cli.envparse.EnvNamespace
 
 # List of module names for which member attributes should not be checked
 # (useful for modules/projects where namespaces are manipulated during runtime
 # and thus existing member attributes cannot be deduced by static analysis). It
 # supports qualified module names, as well as Unix pattern matching.
-ignored-modules=
+ignored-modules=*_pb2
 
 # Show a hint with possible names when a member name was not found. The aspect
 # of finding the hint is based on edit distance.
diff --git a/BUILD.gn b/BUILD.gn
index 400aac6..c16c6ba 100644
--- a/BUILD.gn
+++ b/BUILD.gn
@@ -42,12 +42,14 @@
 group("default") {
   deps = [
     ":docs",
+    ":fuzzers",
     ":host",
-    ":python.lint",
-    ":python.tests",
     ":stm32f429i",
-    ":target_support_packages.lint",
-    ":target_support_packages.tests",
+    "$dir_pw_env_setup:python.install",
+    "$dir_pw_env_setup:python.lint",
+    "$dir_pw_env_setup:python.tests",
+    "$dir_pw_env_setup:target_support_packages.lint",
+    "$dir_pw_env_setup:target_support_packages.tests",
   ]
 }
 
@@ -103,56 +105,42 @@
   toolchain_prefix = "$dir_pigweed/targets/stm32f429i-disc1:stm32f429i_disc1_"
 }
 
-if (dir_pw_third_party_arduino != "") {
+if (pw_arduino_build_CORE_PATH != "") {
   _build_pigweed_default_at_all_optimization_levels("arduino") {
     toolchain_prefix = "$dir_pigweed/targets/arduino:arduino_"
   }
 }
 
-_build_pigweed_default_at_all_optimization_levels("qemu") {
-  toolchain_prefix = "$dir_pigweed/targets/lm3s6965evb-qemu:lm3s6965evb_qemu_"
+_build_pigweed_default_at_all_optimization_levels("qemu_gcc") {
+  toolchain_prefix =
+      "$dir_pigweed/targets/lm3s6965evb-qemu:lm3s6965evb_qemu_gcc_"
+}
+
+_build_pigweed_default_at_all_optimization_levels("qemu_clang") {
+  toolchain_prefix =
+      "$dir_pigweed/targets/lm3s6965evb-qemu:lm3s6965evb_qemu_clang_"
 }
 
 group("docs") {
-  deps = [ ":pigweed_default($dir_pigweed/targets/docs)" ]
+  deps = [ "$dir_pigweed/docs($dir_pigweed/targets/docs)" ]
 }
 
+# OSS-Fuzz uses this target to build fuzzers alone.
+group("fuzzers") {
+  # Fuzzing is only supported on Linux and MacOS using clang.
+  if (host_os != "win") {
+    deps = [ ":pw_module_tests($dir_pigweed/targets/host:host_clang_fuzz)" ]
+  }
+}
+
+# TODO(pwbug/325) Delete this target.
 pw_python_group("python") {
-  python_deps = [
-    # Python packages
-    "$dir_pw_allocator/py",
-    "$dir_pw_arduino_build/py",
-    "$dir_pw_bloat/py",
-    "$dir_pw_build/py",
-    "$dir_pw_cli/py",
-    "$dir_pw_docgen/py",
-    "$dir_pw_doctor/py",
-    "$dir_pw_env_setup/py",
-    "$dir_pw_hdlc_lite/py",
-    "$dir_pw_module/py",
-    "$dir_pw_package/py",
-    "$dir_pw_presubmit/py",
-    "$dir_pw_protobuf/py",
-    "$dir_pw_protobuf_compiler/py",
-    "$dir_pw_rpc/py",
-    "$dir_pw_status/py",
-    "$dir_pw_tokenizer/py",
-    "$dir_pw_trace/py",
-    "$dir_pw_trace_tokenized/py",
-    "$dir_pw_unit_test/py",
-    "$dir_pw_watch/py",
-
-    # Standalone scripts
-    "$dir_pw_hdlc_lite/rpc_example:example_script",
-  ]
+  python_deps = [ "$dir_pw_env_setup:python" ]
 }
 
-# Python packages for supporting specific targets.
+# TODO(pwbug/325) Delete this target.
 pw_python_group("target_support_packages") {
-  python_deps = [
-    "$dir_pigweed/targets/lm3s6965evb-qemu/py",
-    "$dir_pigweed/targets/stm32f429i-disc1/py",
-  ]
+  python_deps = [ "$dir_pw_env_setup:target_support_packages" ]
 }
 
 # By default, Pigweed will build this target when invoking ninja.
@@ -161,45 +149,48 @@
 
   # Prevent the default toolchain from parsing any other BUILD.gn files.
   if (current_toolchain != default_toolchain) {
-    if (pw_docgen_BUILD_DOCS) {
-      deps += [ "$dir_pigweed/docs" ]
+    deps = [ ":apps" ]
+    if (pw_unit_test_AUTOMATIC_RUNNER == "") {
+      # Without a test runner defined, build the tests but don't run them.
+      deps += [ ":pw_module_tests" ]
     } else {
-      deps += [ ":apps" ]
-      if (pw_unit_test_AUTOMATIC_RUNNER == "") {
-        # Without a test runner defined, build the tests but don't run them.
-        deps += [ ":pw_module_tests" ]
-      } else {
-        # With a test runner, depend on the run targets so they run with the
-        # build.
-        deps += [ ":pw_module_tests_run" ]
-      }
+      # With a test runner, depend on the run targets so they run with the
+      # build.
+      deps += [ ":pw_module_tests.run" ]
     }
-    if (defined(pw_toolchain_SCOPE.is_host_toolchain) &&
-        pw_toolchain_SCOPE.is_host_toolchain && pw_build_HOST_TOOLS) {
-      deps += [ ":host_tools" ]
-    }
+  }
+  if (defined(pw_toolchain_SCOPE.is_host_toolchain) &&
+      pw_toolchain_SCOPE.is_host_toolchain && pw_build_HOST_TOOLS) {
+    deps += [ ":host_tools" ]
+  }
 
-    # Trace examples currently only support running on non-windows host
-    if (defined(pw_toolchain_SCOPE.is_host_toolchain) &&
-        pw_toolchain_SCOPE.is_host_toolchain && host_os != "win") {
-      deps += [
-        "$dir_pw_trace:trace_example_basic",
-        "$dir_pw_trace_tokenized:trace_tokenized_example_basic",
-        "$dir_pw_trace_tokenized:trace_tokenized_example_filter",
-        "$dir_pw_trace_tokenized:trace_tokenized_example_trigger",
-      ]
-    }
+  # Trace examples currently only support running on non-windows host
+  if (defined(pw_toolchain_SCOPE.is_host_toolchain) &&
+      pw_toolchain_SCOPE.is_host_toolchain && host_os != "win") {
+    deps += [
+      "$dir_pw_trace:trace_example_basic",
+      "$dir_pw_trace_tokenized:trace_tokenized_example_basic",
+      "$dir_pw_trace_tokenized:trace_tokenized_example_filter",
+      "$dir_pw_trace_tokenized:trace_tokenized_example_rpc",
+      "$dir_pw_trace_tokenized:trace_tokenized_example_trigger",
+    ]
   }
 }
 
-# Prevent the default toolchain from parsing any other BUILD.gn files.
+# The default toolchain is not used for compiling C/C++ code.
 if (current_toolchain != default_toolchain) {
   group("apps") {
     # Application images built for all targets.
-    deps = [ "$dir_pw_hdlc_lite/rpc_example" ]
+    deps = [ "$dir_pw_hdlc/rpc_example" ]
 
     # Add target-specific images.
     deps += pw_TARGET_APPLICATIONS
+
+    # Add the pw_tool target to be built on host.
+    if (defined(pw_toolchain_SCOPE.is_host_toolchain) &&
+        pw_toolchain_SCOPE.is_host_toolchain) {
+      deps += [ "$dir_pw_tool" ]
+    }
   }
 
   group("host_tools") {
@@ -218,18 +209,23 @@
       "$dir_pw_blob_store",
       "$dir_pw_bytes",
       "$dir_pw_checksum",
+      "$dir_pw_chrono",
       "$dir_pw_cpu_exception",
-      "$dir_pw_hdlc_lite",
+      "$dir_pw_hdlc",
+      "$dir_pw_i2c",
       "$dir_pw_metric",
+      "$dir_pw_persistent_ram",
       "$dir_pw_polyfill",
       "$dir_pw_preprocessor",
       "$dir_pw_protobuf",
       "$dir_pw_result",
-      "$dir_pw_span",
       "$dir_pw_status",
       "$dir_pw_stream",
       "$dir_pw_string",
+      "$dir_pw_sync",
       "$dir_pw_sys_io",
+      "$dir_pw_thread",
+      "$dir_pw_tool",
       "$dir_pw_trace",
       "$dir_pw_unit_test",
       "$dir_pw_varint",
@@ -256,17 +252,23 @@
       "$dir_pw_blob_store:tests",
       "$dir_pw_bytes:tests",
       "$dir_pw_checksum:tests",
+      "$dir_pw_chrono:tests",
       "$dir_pw_containers:tests",
-      "$dir_pw_cpu_exception_armv7m:tests",
+      "$dir_pw_cpu_exception_cortex_m:tests",
       "$dir_pw_fuzzer:tests",
-      "$dir_pw_hdlc_lite:tests",
+      "$dir_pw_hdlc:tests",
       "$dir_pw_hex_dump:tests",
+      "$dir_pw_i2c:tests",
       "$dir_pw_log:tests",
+      "$dir_pw_log_multisink:tests",
       "$dir_pw_log_null:tests",
       "$dir_pw_log_rpc:tests",
+      "$dir_pw_log_sink:tests",
       "$dir_pw_log_tokenized:tests",
       "$dir_pw_malloc_freelist:tests",
       "$dir_pw_metric:tests",
+      "$dir_pw_multisink:tests",
+      "$dir_pw_persistent_ram:tests",
       "$dir_pw_polyfill:tests",
       "$dir_pw_preprocessor:tests",
       "$dir_pw_protobuf:tests",
@@ -274,11 +276,15 @@
       "$dir_pw_random:tests",
       "$dir_pw_result:tests",
       "$dir_pw_ring_buffer:tests",
+      "$dir_pw_router:tests",
       "$dir_pw_rpc:tests",
       "$dir_pw_span:tests",
       "$dir_pw_status:tests",
       "$dir_pw_stream:tests",
       "$dir_pw_string:tests",
+      "$dir_pw_sync:tests",
+      "$dir_pw_thread:tests",
+      "$dir_pw_thread_stl:tests",
       "$dir_pw_tokenizer:tests",
       "$dir_pw_trace:tests",
       "$dir_pw_trace_tokenized:tests",
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 1199180..9447bda 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -16,38 +16,56 @@
 
 cmake_minimum_required(VERSION 3.16)
 
-add_subdirectory(pw_assert)
-add_subdirectory(pw_assert_basic)
-add_subdirectory(pw_assert_log)
-add_subdirectory(pw_base64)
-add_subdirectory(pw_blob_store)
-add_subdirectory(pw_build)
-add_subdirectory(pw_bytes)
-add_subdirectory(pw_checksum)
-add_subdirectory(pw_containers)
-add_subdirectory(pw_cpu_exception)
-add_subdirectory(pw_cpu_exception_armv7m)
-add_subdirectory(pw_hdlc_lite)
-add_subdirectory(pw_kvs)
-add_subdirectory(pw_log)
-add_subdirectory(pw_log_basic)
-add_subdirectory(pw_log_tokenized)
-add_subdirectory(pw_minimal_cpp_stdlib)
-add_subdirectory(pw_polyfill)
-add_subdirectory(pw_protobuf)
-add_subdirectory(pw_preprocessor)
-add_subdirectory(pw_random)
-add_subdirectory(pw_result)
-add_subdirectory(pw_rpc)
-add_subdirectory(pw_span)
-add_subdirectory(pw_status)
-add_subdirectory(pw_stream)
-add_subdirectory(pw_string)
-add_subdirectory(pw_sys_io)
-add_subdirectory(pw_sys_io_stdio)
-add_subdirectory(pw_tokenizer)
-add_subdirectory(pw_trace)
-add_subdirectory(pw_unit_test)
-add_subdirectory(pw_varint)
+# The PW_ROOT environment variable should be set in bootstrap. If it is not set,
+# set it to this directory.
+if("$ENV{PW_ROOT}" STREQUAL "")
+  message("The PW_ROOT environment variable is not set; "
+          "using ${CMAKE_CURRENT_LIST_DIR} within CMake")
+  set(ENV{PW_ROOT} "${CMAKE_CURRENT_LIST_DIR}")
+endif()
 
-add_subdirectory(third_party/nanopb)
+add_subdirectory(pw_assert EXCLUDE_FROM_ALL)
+add_subdirectory(pw_assert_basic EXCLUDE_FROM_ALL)
+add_subdirectory(pw_assert_log EXCLUDE_FROM_ALL)
+add_subdirectory(pw_base64 EXCLUDE_FROM_ALL)
+add_subdirectory(pw_blob_store EXCLUDE_FROM_ALL)
+add_subdirectory(pw_build EXCLUDE_FROM_ALL)
+add_subdirectory(pw_bytes EXCLUDE_FROM_ALL)
+add_subdirectory(pw_checksum EXCLUDE_FROM_ALL)
+add_subdirectory(pw_chrono EXCLUDE_FROM_ALL)
+add_subdirectory(pw_chrono_stl EXCLUDE_FROM_ALL)
+add_subdirectory(pw_containers EXCLUDE_FROM_ALL)
+add_subdirectory(pw_cpu_exception EXCLUDE_FROM_ALL)
+add_subdirectory(pw_cpu_exception_cortex_m EXCLUDE_FROM_ALL)
+add_subdirectory(pw_hdlc EXCLUDE_FROM_ALL)
+add_subdirectory(pw_kvs EXCLUDE_FROM_ALL)
+add_subdirectory(pw_log EXCLUDE_FROM_ALL)
+add_subdirectory(pw_log_basic EXCLUDE_FROM_ALL)
+add_subdirectory(pw_log_tokenized EXCLUDE_FROM_ALL)
+add_subdirectory(pw_minimal_cpp_stdlib EXCLUDE_FROM_ALL)
+add_subdirectory(pw_polyfill EXCLUDE_FROM_ALL)
+add_subdirectory(pw_protobuf EXCLUDE_FROM_ALL)
+add_subdirectory(pw_preprocessor EXCLUDE_FROM_ALL)
+add_subdirectory(pw_random EXCLUDE_FROM_ALL)
+add_subdirectory(pw_result EXCLUDE_FROM_ALL)
+add_subdirectory(pw_router EXCLUDE_FROM_ALL)
+add_subdirectory(pw_rpc EXCLUDE_FROM_ALL)
+add_subdirectory(pw_span EXCLUDE_FROM_ALL)
+add_subdirectory(pw_status EXCLUDE_FROM_ALL)
+add_subdirectory(pw_stream EXCLUDE_FROM_ALL)
+add_subdirectory(pw_string EXCLUDE_FROM_ALL)
+add_subdirectory(pw_sync EXCLUDE_FROM_ALL)
+add_subdirectory(pw_sync_stl EXCLUDE_FROM_ALL)
+add_subdirectory(pw_sys_io EXCLUDE_FROM_ALL)
+add_subdirectory(pw_sys_io_stdio EXCLUDE_FROM_ALL)
+add_subdirectory(pw_tokenizer EXCLUDE_FROM_ALL)
+add_subdirectory(pw_trace EXCLUDE_FROM_ALL)
+add_subdirectory(pw_unit_test EXCLUDE_FROM_ALL)
+add_subdirectory(pw_varint EXCLUDE_FROM_ALL)
+
+add_subdirectory(targets/host EXCLUDE_FROM_ALL)
+
+add_subdirectory(third_party/nanopb EXCLUDE_FROM_ALL)
+
+add_custom_target(pw_apps)
+add_dependencies(pw_apps pw_hdlc.rpc_example)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index e24d963..d9e325f 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -21,13 +21,15 @@
    * Install the Pigweed presubmit check hook (`pw presubmit --install`).
      (recommended).
  1. Ensure all files include a correct [copyright and license header](CONTRIBUTING.md#source-code-headers).
+ 1. Include any necessary changes to
+    [documentation](CONTRIBUTING.md#documentation).
  1. Run `pw presubmit` (see below) to detect style or compilation issues before
     uploading.
  1. Upload the change with `git push origin HEAD:refs/for/master`.
  1. Address any reviewer feedback by amending the commit (`git commit --amend`).
  1. Submit change to CI builders to merge. If you are not part of Pigweed's
     core team, you can ask the reviewer to add the `+2 CQ` vote, which will
-    trigger a rebase asd submit once the builders pass.
+    trigger a rebase and submit once the builders pass.
 
 ## Contributor License Agreement
 
@@ -43,8 +45,9 @@
 
 ## Gerrit Commit Hook
 
-Gerrit requires all changes to have a `Change-Id` tag at the bottom of each CL.
-You should set this up to be done automatically using the instructions below.
+Gerrit requires all changes to have a `Change-Id` tag at the bottom of each
+commit message. You should set this up to be done automatically using the
+instructions below.
 
 **Linux/macOS**<br/>
 ```bash
@@ -58,6 +61,20 @@
 copy %HOMEPATH%\Downloads\commit-msg %HOMEPATH%\pigweed\.git\hooks\commit-msg
 ```
 
+## Documentation
+
+All Pigweed changes must either
+
+ 1. Include updates to documentation, or
+ 1. Include `No-Docs-Update-Reason: <reason>` in the commit message or a Gerrit
+    comment on the CL. Potential reasons might include
+    * "minor code formatting change",
+    * "internal cleanup of pw_modulename, no changes to API"
+
+It's acceptable to only document new changes in an otherwise underdocumented
+module, but it's not acceptable to not document new changes because the module
+doesn't have any other documentation.
+
 ## Code Reviews
 
 All Pigweed development happens on Gerrit, following the [typical Gerrit
@@ -117,13 +134,61 @@
 # the License.
 ```
 
-## Continuous Integration
+## Presubmit Checks and Continuous Integration
 
-All Pigweed CLs must adhere to Pigweed's style guide and pass a suite of
-automated builds, tests, and style checks to be merged upstream. Much of this
-checking is done using Pigweed's pw_presubmit module by automated builders. To
-speed up the review process, consider adding `pw presubmit` as a git push hook
-using the following command:
+All Pigweed change lists (CLs) must adhere to Pigweed's style guide and pass a
+suite of automated builds, tests, and style checks to be merged upstream. Much
+of this checking is done using Pigweed's `pw_presubmit` module by automated
+builders. These builders run before each Pigweed CL is submitted and in our
+continuous integration infrastructure (see
+https://ci.chromium.org/p/pigweed/g/pigweed/console).
+
+### Running Presubmit Checks
+
+To run automated presubmit checks on a pending CL, click the `CQ DRY RUN` button
+in the Gerrit UI. The results appear in the Tryjobs section, below the source
+listing. Jobs that passed are green; jobs that failed are red.
+
+If all checks pass, you will see a ``Dry run: This CL passed the CQ dry run.``
+comment on your change. If any checks fail, you will see a ``Dry run: Failed
+builds:`` message. All failures must be addressed before submitting.
+
+In addition to the publicly visible presubmit checks, Pigweed runs internal
+presubmit checks that are only visible within Google. If any these checks fail,
+external developers will see a ``Dry run: Failed builds:`` comment on the CL,
+even if all visible checks passed. Reach out to the Pigweed team for help
+addressing these issues.
+
+### Project Presubmit Checks
+
+In addition to Pigweed's presubmit checks, some projects that use Pigweed run
+their presubmit checks in Pigweed's infrastructure. This supports a development
+flow where projects automatically update their Pigweed submodule if their tests
+pass. If a project cannot build against Pigweed's tip-of-tree, it will stay on a
+fixed Pigweed revision until the issues are fixed. See the
+[sample project](https://pigweed.googlesource.com/pigweed/sample_project/) for
+an example of this.
+
+Pigweed does its best to keep builds passing for dependent projects. In some
+circumstances, the Pigweed maintainers may choose to merge changes that break
+dependent projects. This will only be done if
+
+  * a feature or fix is needed urgently in Pigweed or for a different project,
+    and
+  * the project broken by the change does not imminently need Pigweed updates.
+
+The downstream project will continue to build against their last working
+revision of Pigweed until the incompatibilities are fixed.
+
+In these situations, Pigweed's commit queue submission process will fail for all
+changes. If a change passes all presubmit checks except for known failures, the
+Pigweed team may permit manual submission of the CL. Contact the Pigweed team
+for submission approval.
+
+### Running local presubmits
+
+To speed up the review process, consider adding `pw presubmit` as a git push
+hook using the following command:
 
 **Linux/macOS**<br/>
 ```bash
@@ -133,16 +198,13 @@
 This will be effectively the same as running the following command before every
 `git push`:
 ```bash
-$ pw presubmit --program quick
+$ pw presubmit
 ```
 
 ![pigweed presubmit demonstration](pw_presubmit/docs/pw_presubmit_demo.gif)
 
-Running `pw presubmit` manually will default to running the `full` presubmit
-program.
-
-If you ever need to bypass the presubmit hook (due to it being broken, for example) you
-may push using this command:
+If you ever need to bypass the presubmit hook (due to it being broken, for
+example) you may push using this command:
 
 ```bash
 $ git push origin HEAD:refs/for/master --no-verify
diff --git a/PW_PLUGINS b/PW_PLUGINS
index f588ded..b7e8fe4 100644
--- a/PW_PLUGINS
+++ b/PW_PLUGINS
@@ -11,7 +11,8 @@
 # return an int to use as the exit code.
 
 # Pigweed's presubmit check script
-presubmit pw_presubmit.pigweed_presubmit main
 heap-viewer pw_allocator.heap_viewer main
-rpc pw_hdlc_lite.rpc_console main
 package pw_package.pigweed_packages main
+presubmit pw_presubmit.pigweed_presubmit main
+requires pw_cli.requires main
+rpc pw_hdlc.rpc_console main
diff --git a/README.md b/README.md
index 0938a5b..9f73d55 100644
--- a/README.md
+++ b/README.md
@@ -13,14 +13,16 @@
 # Quick links
 
  - [Getting started guide](docs/getting_started.md)
- - [Source code](https://pigweed.googlesource.com/pigweed/pigweed/+/refs/heads/master)
+ - [Documentation](https://pigweed.dev)
+ - [Source code](https://cs.opensource.google/pigweed/pigweed)
  - [Code reviews](https://pigweed-review.googlesource.com/)
- - [Issue tracker](https://bugs.chromium.org/p/pigweed/issues/list)
+ - [Issue tracker](https://bugs.pigweed.dev/)
  - [Mailing list](https://groups.google.com/forum/#!forum/pigweed)
  - [Chat room (Discord)](https://discord.gg/M9NSeTA)
  - [Open Source blog post](https://opensource.googleblog.com/2020/03/pigweed-collection-of-embedded-libraries.html)
 
-Get the code: `git clone https://pigweed.googlesource.com/pigweed/pigweed`
+Get the code: `git clone https://pigweed.googlesource.com/pigweed/pigweed` (or
+[fork us on GitHub](https://github.com/google/pigweed)).
 
 # Getting Started
 
@@ -34,10 +36,6 @@
 different Pigweed module offerings, refer to "Module Guides" section in the full
 documentation.
 
-Note: For now the full documentation is not available online; you must build
-it. Building the docs is easy; see the [getting started
-guide](docs/getting_started.md) for how.
-
 ## `pw_watch` - Build, flash, run, & test on save
 
 In the web development space, file system watchers are prevalent. These watchers
@@ -122,7 +120,7 @@
 See the "Module Guides" in the documentation for the complete list and
 documentation for each, but is a selection of some others:
 
- - `pw_cpu_exception_armv7m`: Robust low level hardware fault handler for ARM
+ - `pw_cpu_exception_cortex_m`: Robust low level hardware fault handler for ARM
    Cortex-M; the handler even has unit tests written in assembly to verify
    nested-hardware-fault handling!
 
@@ -130,12 +128,6 @@
    provides selected C++17 standard library components that are compatible with
    C++11 and C++14.
 
- - `pw_minimal_cpp_stdlib`: An entirely incomplete implementation of the C++17
-   standard library, that provides some of the primitives needed by Pigweed
-   itself. Useful for projects that want to use Pigweed, but don’t enable the
-   typical standard C++ libraries like GNU’s libstdc++ or LLVM’s libc++. Don’t
-   use this module unless you know what you are doing.
-
  - `pw_tokenizer`: Replace string literals from log statements with 32-bit
    tokens, to reduce flash use, reduce logging bandwidth, and save formatting
    cycles from log statements at runtime.
diff --git a/WORKSPACE b/WORKSPACE
index 9f53c87..573d3f2 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -1,4 +1,4 @@
-# Copyright 2019 The Pigweed Authors
+# Copyright 2021 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
@@ -17,36 +17,115 @@
     managed_directories = {"@npm": ["node_modules"]},
 )
 
-# Set up build_bazel_rules_nodejs
+# Set up build_bazel_rules_nodejs.
 load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
+load("@bazel_tools//tools/build_defs/repo:git.bzl", "git_repository")
+
 http_archive(
     name = "build_bazel_rules_nodejs",
     sha256 = "4952ef879704ab4ad6729a29007e7094aef213ea79e9f2e94cbe1c9a753e63ef",
     urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/2.2.0/rules_nodejs-2.2.0.tar.gz"],
 )
-# Get the latest LTS version of Node
-load("@build_bazel_rules_nodejs//:index.bzl", "node_repositories")
+
+# Get the latest LTS version of Node.
+load(
+    "@build_bazel_rules_nodejs//:index.bzl",
+    "node_repositories",
+    "yarn_install",
+)
+
 node_repositories(package_json = ["//:package.json"])
 
-# Install packages with yarn
-load("@build_bazel_rules_nodejs//:index.bzl", "yarn_install")
 yarn_install(
     name = "npm",
     package_json = "//:package.json",
     yarn_lock = "//:yarn.lock",
 )
 
-# Set up Karma
+# Set up Karma.
 load("@npm//@bazel/karma:package.bzl", "npm_bazel_karma_dependencies")
+
 npm_bazel_karma_dependencies()
 
-load("@io_bazel_rules_webtesting//web:repositories.bzl", "web_test_repositories")
+load(
+    "@io_bazel_rules_webtesting//web:repositories.bzl",
+    "web_test_repositories",
+)
+
 web_test_repositories()
 
-load("@io_bazel_rules_webtesting//web/versioned:browsers-0.3.2.bzl", "browser_repositories")
+load(
+    "@io_bazel_rules_webtesting//web/versioned:browsers-0.3.2.bzl",
+    "browser_repositories",
+)
 
 browser_repositories(
     chromium = True,
     firefox = True,
 )
 
+# Setup embedded C/C++ toolchains.
+git_repository(
+    name = "bazel_embedded",
+    commit = "89c05fa415218abd2e24fa7016cb7903317d606b",
+    remote = "https://github.com/silvergasp/bazel-embedded.git",
+)
+
+# Instantiate Pigweed configuration for embedded toolchain,
+# this must be called before bazel_embedded_deps.
+load(
+    "//pw_build:pigweed_toolchain_upstream.bzl",
+    "toolchain_upstream_deps",
+)
+
+toolchain_upstream_deps()
+
+# Configure bazel_embedded toolchains and platforms.
+load(
+    "@bazel_embedded//:bazel_embedded_deps.bzl",
+    "bazel_embedded_deps",
+)
+
+bazel_embedded_deps()
+
+load(
+    "@bazel_embedded//platforms:execution_platforms.bzl",
+    "register_platforms",
+)
+
+register_platforms()
+
+# Fetch gcc-arm-none-eabi compiler and register for toolchain
+# resolution.
+load(
+    "@bazel_embedded//toolchains/compilers/gcc_arm_none_eabi:gcc_arm_none_repository.bzl",
+    "gcc_arm_none_compiler",
+)
+
+gcc_arm_none_compiler()
+
+load(
+    "@bazel_embedded//toolchains/gcc_arm_none_eabi:gcc_arm_none_toolchain.bzl",
+    "register_gcc_arm_none_toolchain",
+)
+
+register_gcc_arm_none_toolchain()
+
+# Fetch LLVM/Clang compiler and register for toolchain resolution.
+load(
+    "@bazel_embedded//toolchains/compilers/llvm:llvm_repository.bzl",
+    "llvm_repository",
+)
+
+llvm_repository(
+    name = "com_llvm_compiler",
+)
+
+load(
+    "@bazel_embedded//toolchains/clang:clang_toolchain.bzl",
+    "register_clang_toolchain",
+)
+
+register_clang_toolchain()
+
+register_execution_platforms("//pw_build/platforms:all")
diff --git a/bootstrap.bat b/bootstrap.bat
index 29146b6..5c46a29 100644
--- a/bootstrap.bat
+++ b/bootstrap.bat
@@ -111,8 +111,7 @@
     --pw-root "%PW_ROOT%" ^
     --shell-file "%shell_file%" ^
     --install-dir "%_PW_ACTUAL_ENVIRONMENT_ROOT%" ^
-    --use-pigweed-defaults ^
-    --virtualenv-gn-target "%PW_ROOT%#:target_support_packages.install" ^
+    --config-file "%PW_ROOT%/pw_env_setup/config.json" ^
     --project-root "%PW_PROJECT_ROOT%"
 goto activate_shell
 
diff --git a/bootstrap.sh b/bootstrap.sh
index 6f0c63b..5f75d90 100644
--- a/bootstrap.sh
+++ b/bootstrap.sh
@@ -15,7 +15,7 @@
 # This script must be tested on bash, zsh, and dash.
 
 _bootstrap_abspath () {
-  python -c "import os.path; print(os.path.abspath('$@'))"
+  $(which python || which python3 || which python2) -c "import os.path; print(os.path.abspath('$@'))"
 }
 
 # Users are not expected to set PW_CHECKOUT_ROOT, it's only used because it
@@ -26,23 +26,23 @@
 # variable set.
 # TODO(mohrr) find out a way to do this without PW_CHECKOUT_ROOT.
 if test -n "$PW_CHECKOUT_ROOT"; then
-  _BOOTSTRAP_PATH="$(_bootstrap_abspath "$PW_CHECKOUT_ROOT/bootstrap.sh")"
+  _PW_BOOTSTRAP_PATH="$(_bootstrap_abspath "$PW_CHECKOUT_ROOT/bootstrap.sh")"
   # Downstream projects need to set PW_CHECKOUT_ROOT to point to Pigweed if
   # they're using Pigweed's CI/CQ system.
   unset PW_CHECKOUT_ROOT
 # Shell: bash.
 elif test -n "$BASH"; then
-  _BOOTSTRAP_PATH="$(_bootstrap_abspath "$BASH_SOURCE")"
+  _PW_BOOTSTRAP_PATH="$(_bootstrap_abspath "$BASH_SOURCE")"
 # Shell: zsh.
 elif test -n "$ZSH_NAME"; then
-  _BOOTSTRAP_PATH="$(_bootstrap_abspath "${(%):-%N}")"
+  _PW_BOOTSTRAP_PATH="$(_bootstrap_abspath "${(%):-%N}")"
 # Shell: dash.
 elif test ${0##*/} = dash; then
-  _BOOTSTRAP_PATH="$(_bootstrap_abspath \
+  _PW_BOOTSTRAP_PATH="$(_bootstrap_abspath \
     "$(lsof -p $$ -Fn0 | tail -1 | sed 's#^[^/]*##;')")"
 # If everything else fails, try $0. It could work.
 else
-  _BOOTSTRAP_PATH="$(_bootstrap_abspath "$0")"
+  _PW_BOOTSTRAP_PATH="$(_bootstrap_abspath "$0")"
 fi
 
 # Check if this file is being executed or sourced.
@@ -66,7 +66,7 @@
 # Downstream projects need to set something other than PW_ROOT here, like
 # YOUR_PROJECT_ROOT. Please also set PW_ROOT before invoking pw_bootstrap or
 # pw_activate.
-PW_ROOT="$(dirname "$_BOOTSTRAP_PATH")"
+PW_ROOT="$(dirname "$_PW_BOOTSTRAP_PATH")"
 export PW_ROOT
 
 # Please also set PW_PROJECT_ROOT to YOUR_PROJECT_ROOT.
@@ -76,7 +76,7 @@
 . "$PW_ROOT/pw_env_setup/util.sh"
 
 pw_deactivate
-pw_eval_sourced "$_pw_sourced"
+pw_eval_sourced "$_pw_sourced" "$_PW_BOOTSTRAP_PATH"
 pw_check_root "$PW_ROOT"
 _PW_ACTUAL_ENVIRONMENT_ROOT="$(pw_get_env_root)"
 export _PW_ACTUAL_ENVIRONMENT_ROOT
@@ -86,10 +86,10 @@
 # an ASCII art banner here.
 
 # Run full bootstrap when invoked as bootstrap, or env file is missing/empty.
-if [ "$(basename "$_BOOTSTRAP_PATH")" = "bootstrap.sh" ] || \
+if [ "$(basename "$_PW_BOOTSTRAP_PATH")" = "bootstrap.sh" ] || \
   [ ! -f "$SETUP_SH" ] || \
   [ ! -s "$SETUP_SH" ]; then
-  pw_bootstrap --shell-file "$SETUP_SH" --install-dir "$_PW_ACTUAL_ENVIRONMENT_ROOT" --use-pigweed-defaults --json-file "$_PW_ACTUAL_ENVIRONMENT_ROOT/actions.json" --virtualenv-gn-target "$PW_ROOT#:target_support_packages.install"
+  pw_bootstrap --shell-file "$SETUP_SH" --install-dir "$_PW_ACTUAL_ENVIRONMENT_ROOT" --json-file "$_PW_ACTUAL_ENVIRONMENT_ROOT/actions.json" --config-file "$PW_ROOT/pw_env_setup/config.json"
   pw_finalize bootstrap "$SETUP_SH"
 else
   pw_activate
@@ -97,7 +97,7 @@
 fi
 
 unset _pw_sourced
-unset _BOOTSTRAP_PATH
+unset _PW_BOOTSTRAP_PATH
 unset SETUP_SH
 unset _bootstrap_abspath
 
diff --git a/docs/BUILD.gn b/docs/BUILD.gn
index 50abacc..3c1baae 100644
--- a/docs/BUILD.gn
+++ b/docs/BUILD.gn
@@ -60,26 +60,37 @@
     "$dir_pw_build:docs",
     "$dir_pw_bytes:docs",
     "$dir_pw_checksum:docs",
+    "$dir_pw_chrono:docs",
+    "$dir_pw_chrono_embos:docs",
+    "$dir_pw_chrono_freertos:docs",
+    "$dir_pw_chrono_stl:docs",
+    "$dir_pw_chrono_threadx:docs",
     "$dir_pw_cli:docs",
     "$dir_pw_containers:docs",
     "$dir_pw_cpu_exception:docs",
-    "$dir_pw_cpu_exception_armv7m:docs",
+    "$dir_pw_cpu_exception_cortex_m:docs",
     "$dir_pw_docgen:docs",
     "$dir_pw_doctor:docs",
     "$dir_pw_env_setup:docs",
     "$dir_pw_fuzzer:docs",
-    "$dir_pw_hdlc_lite:docs",
+    "$dir_pw_hdlc:docs",
     "$dir_pw_hex_dump:docs",
+    "$dir_pw_interrupt:docs",
+    "$dir_pw_interrupt_cortex_m:docs",
     "$dir_pw_kvs:docs",
     "$dir_pw_log:docs",
     "$dir_pw_log_basic:docs",
+    "$dir_pw_log_multisink:docs",
     "$dir_pw_log_null:docs",
     "$dir_pw_log_rpc:docs",
+    "$dir_pw_log_sink:docs",
     "$dir_pw_log_tokenized:docs",
     "$dir_pw_metric:docs",
     "$dir_pw_minimal_cpp_stdlib:docs",
     "$dir_pw_module:docs",
+    "$dir_pw_multisink:docs",
     "$dir_pw_package:docs",
+    "$dir_pw_persistent_ram:docs",
     "$dir_pw_polyfill:docs",
     "$dir_pw_preprocessor:docs",
     "$dir_pw_presubmit:docs",
@@ -88,16 +99,28 @@
     "$dir_pw_random:docs",
     "$dir_pw_result:docs",
     "$dir_pw_ring_buffer:docs",
+    "$dir_pw_router:docs",
     "$dir_pw_rpc:docs",
     "$dir_pw_span:docs",
     "$dir_pw_status:docs",
     "$dir_pw_stream:docs",
     "$dir_pw_string:docs",
+    "$dir_pw_sync:docs",
+    "$dir_pw_sync_baremetal:docs",
+    "$dir_pw_sync_embos:docs",
+    "$dir_pw_sync_freertos:docs",
+    "$dir_pw_sync_stl:docs",
+    "$dir_pw_sync_threadx:docs",
     "$dir_pw_sys_io:docs",
     "$dir_pw_sys_io_arduino:docs",
     "$dir_pw_sys_io_baremetal_stm32f429:docs",
     "$dir_pw_sys_io_stdio:docs",
     "$dir_pw_target_runner:docs",
+    "$dir_pw_thread:docs",
+    "$dir_pw_thread_embos:docs",
+    "$dir_pw_thread_freertos:docs",
+    "$dir_pw_thread_stl:docs",
+    "$dir_pw_thread_threadx:docs",
     "$dir_pw_tokenizer:docs",
     "$dir_pw_toolchain:docs",
     "$dir_pw_trace:docs",
@@ -118,6 +141,7 @@
     "build_system.rst",
     "index.rst",
     "module_guides.rst",
+    "python_build.rst",
     "targets.rst",
   ]
   output_directory = target_gen_dir
@@ -125,5 +149,6 @@
     ":core_docs",
     ":module_docs",
     ":target_docs",
+    "$dir_pw_env_setup:python.install",
   ]
 }
diff --git a/docs/build_system.rst b/docs/build_system.rst
index 6938d19..59c638b 100644
--- a/docs/build_system.rst
+++ b/docs/build_system.rst
@@ -11,6 +11,14 @@
 cases into a powerful and flexible build system, then extend it with support for
 modern software development practices.
 
+See :ref:`docs-python-build` for information about Python build automation with
+Pigweed.
+
+.. toctree::
+  :hidden:
+
+  python_build
+
 What's in a build system?
 =========================
 A quality build system provides a variety of features beyond compiling code.
@@ -60,8 +68,8 @@
 
 * :ref:`module-pw_build-python-action`
 
-Python packaging
-----------------
+Python
+------
 Python is a favorite scripting language of many development teams, and here at
 Pigweed, we're no exception. Much of Pigweed's host-side tooling is written in
 Python. While Python works great for local development, problems can arise when
@@ -383,7 +391,7 @@
 
 Module variables
 ----------------
-As Pigweed is inteded to be a subcomponent of a larger project, it cannot assume
+As Pigweed is intended to be a subcomponent of a larger project, it cannot assume
 where it or its modules is located. Therefore, Pigweed's upstream BUILD.gn files
 do not use absolute paths; instead, variables are defined pointing to each of
 Pigweed's modules, set relative to a project-specific ``dir_pigweed``.
@@ -401,7 +409,7 @@
 
 GN target type wrappers
 -----------------------
-To faciliate injecting global configuration options, Pigweed defines wrappers
+To facilitate injecting global configuration options, Pigweed defines wrappers
 around builtin GN target types such as ``source_set`` and ``executable``. These
 are defined within ``$dir_pw_build/target_types.gni``.
 
diff --git a/docs/conf.py b/docs/conf.py
index 1885a99..7eaf374 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -114,3 +114,14 @@
 # Markdown files imported using m2r aren't marked as "referenced," so exclude
 # them from the error reference checking.
 exclude_patterns = ['README.md']
+
+
+def do_not_skip_init(app, what, name, obj, would_skip, options):
+    if name == "__init__":
+        return False  # never skip __init__ functions
+
+    return would_skip
+
+
+def setup(app):
+    app.connect("autodoc-skip-member", do_not_skip_init)
diff --git a/docs/embedded_cpp_guide.rst b/docs/embedded_cpp_guide.rst
index 4d7a669..4417d06 100644
--- a/docs/embedded_cpp_guide.rst
+++ b/docs/embedded_cpp_guide.rst
@@ -73,7 +73,7 @@
       return min;
     }
     if (value > max) {
-      return min;
+      return max;
     }
     return value;
   }
@@ -108,3 +108,68 @@
 .. tip::
 
   Only use virtual functions when runtime polymorphism is needed.
+
+Compiler warnings
+=================
+Bugs in embedded systems can be difficult to track down. Compiler warnings are
+one tool to help identify and fix bugs early in development.
+
+Pigweed compiles with a strict set of warnings. The warnings include the
+following:
+
+  * ``-Wall`` and ``-Wextra`` -- Standard sets of compilation warnings, which
+    are recommended for all projects.
+  * ``-Wimplicit-fallthrough`` -- Requires explicit ``[[fallthrough]]``
+    annotations for fallthrough between switch cases. Prevents unintentional
+    fallthroughs if a ``break`` or ``return`` is forgotten.
+  * ``-Wundef`` -- Requires macros to be defined before using them. This
+    disables the standard, problematic behavior that replaces undefined (or
+    misspelled) macros with ``0``.
+
+Unused variable and function warnings
+-------------------------------------
+The ``-Wall`` and ``-Wextra`` flags enable warnings about unused variables or
+functions. Usually, the best way to address these warnings is to remove the
+unused items. In some circumstances, these cannot be removed, so the warning
+must be silenced. This is done in one of the following ways:
+
+  1. When possible, delete unused variables, functions, or class definitions.
+  2. If an unused entity must remain in the code, avoid giving it a name. A
+     common situation that triggers unused parameter warnings is implementing a
+     virtual function or callback. In C++, function parameters may be unnamed.
+     If desired, the variable name can remain in the code as a comment.
+
+     .. code-block:: cpp
+
+       class BaseCalculator {
+        public:
+         virtual int DoMath(int number_1, int number_2, int number_3) = 0;
+       };
+
+       class Calculator : public BaseCalculator {
+         int DoMath(int number_1, int /* number_2 */, int) override {
+           return number_1 * 100;
+         }
+       };
+
+  3. In C++, annotate unused entities with `[[maybe_unused]]
+     <https://en.cppreference.com/w/cpp/language/attributes/maybe_unused>`_ to
+     silence warnings.
+
+     .. code-block:: cpp
+
+       // This variable is unused in certain circumstances.
+       [[maybe_unused]] int expected_size = size * 4;
+       #if OPTION_1
+       DoThing1(expected_size);
+       #elif OPTION_2
+       DoThing2(expected_size);
+       #endif
+
+  4. As a final option, cast unused variables to ``void`` to silence these
+     warnings. Use ``static_cast<void>(unused_var)`` in C++ or
+     ``(void)unused_var`` in C.
+
+     In C, silencing warnings on unused functions may require compiler-specific
+     attributes (``__attribute__((unused))``). Avoid this by removing the
+     functions or compiling with C++ and using ``[[maybe_unused]]``.
diff --git a/docs/getting_started.md b/docs/getting_started.md
index 59e17de..5423d31 100644
--- a/docs/getting_started.md
+++ b/docs/getting_started.md
@@ -43,7 +43,7 @@
 (4) Start the watcher. The watcher will invoke Ninja to build all the targets
 
 ```bash
-$ pw watch out default
+$ pw watch
 
  ▒█████▄   █▓  ▄███▒  ▒█    ▒█ ░▓████▒ ░▓████▒ ▒▓████▄
   ▒█░  █░ ░█▒ ██▒ ▀█▒ ▒█░ █ ▒█  ▒█   ▀  ▒█   ▀  ▒█  ▀█▌
@@ -52,7 +52,7 @@
   ▒█      ░█░ ░▓███▀   ▒█▓▀▓█░ ░▓████▒ ░▓████▒ ▒▓████▀
 
 20200707 17:24:06 INF Starting Pigweed build watcher
-20200707 17:24:06 INF Will build [1/1]: out default
+20200707 17:24:06 INF Will build [1/1]: out
 20200707 17:24:06 INF Attaching filesystem watcher to $HOME/wrk/pigweed/...
 20200707 17:24:06 INF Triggering initial build...
 ...
@@ -89,6 +89,13 @@
 Make sure gcc is set to gcc-8.
 
 **macOS**<br/>
+To start using Pigweed on MacOS, you'll need to install XCode. Download it
+via the App Store, then install the relevant tools from the command line.
+
+```bash
+xcode-select --install
+```
+
 On macOS you may get SSL certificate errors with the system Python
 installation. Run `sudo pip install certifi` to fix this. If you get SSL
 errors with the Python from [Homebrew](https://brew.sh) try running the
@@ -241,7 +248,7 @@
 If you want to build JUST for the device, you can kick of watch with:
 
 ```bash
-$ pw watch out stm32f429i
+$ pw watch stm32f429i
 ```
 
 This is equivalent to the following Ninja invocation:
@@ -307,10 +314,13 @@
 ## Building the Documentation
 
 In addition to the markdown documentation, Pigweed has a collection of
-information-rich RST files that are built by the default invocation of GN. You
-will find the documents at `out/docs/gen/docs/html`.
+information-rich RST files that are used to generate HTML documentation. All the
+docs are hosted at https://pigweed.dev/, and are built as a part of the default
+build invocation. This makes it easier to make changes and see how they turn
+out. Once built, you can find the rendered HTML documentation at
+`out/docs/gen/docs/html`.
 
-You can build the documentation manually by with the command below.
+You can explicitly build just the documentation with the command below.
 
 ```shell
 $ ninja -C out docs
diff --git a/docs/index.rst b/docs/index.rst
index 9a02eca..de7c142 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -8,11 +8,11 @@
 
   Home <self>
   docs/getting_started.md
-  Source Code <https://pigweed.googlesource.com/pigweed/pigweed/+/refs/heads/master>
+  Source Code <https://cs.opensource.google/pigweed/pigweed>
   Code Reviews <https://pigweed-review.googlesource.com>
   Mailing List <https://groups.google.com/forum/#!forum/pigweed>
   Chat Room <https://discord.gg/M9NSeTA>
-  Issue Tracker <https://bugs.chromium.org/p/pigweed/issues/list>
+  Issue Tracker <https://bugs.pigweed.dev/>
   CONTRIBUTING.md
   CODE_OF_CONDUCT.md
   docs/embedded_cpp_guide
diff --git a/docs/module_structure.rst b/docs/module_structure.rst
index 586fca0..17d3a07 100644
--- a/docs/module_structure.rst
+++ b/docs/module_structure.rst
@@ -55,12 +55,14 @@
     zip_test.cc
 
     # Python files go into 'py/<module>/...'
-    py/setup.py     # All Python must be a Python module with setup.py
-    py/foo_test.py  # Tests go in py/ but outside of the Python module
+    py/BUILD.gn     # Python packages are declared in GN using pw_python_package
+    py/setup.py     # Python files are structured as standard Python packages
+    py/foo_test.py  # Tests go in py/ but outside of the Python package
     py/bar_test.py
     py/pw_foo/__init__.py
     py/pw_foo/__main__.py
     py/pw_foo/bar.py
+    py/pw_foo/py.typed  # Indicates that this package has type annotations
 
     # Go files go into 'go/...'
     go/...
@@ -203,10 +205,10 @@
 
 Declaring configuration
 ^^^^^^^^^^^^^^^^^^^^^^^
-Configuration values are declared in a header file with macros. If the macro
-value is not already defined, a default definition is provided. Otherwise,
-nothing is done. Configuration headers may include ``static_assert`` statements
-to validate configuration values.
+Configuration options are declared in a header file as macros. If the macro is
+not already defined, a default definition is provided. Otherwise, nothing is
+done. Configuration headers may include ``static_assert`` statements to validate
+configuration values.
 
 .. code-block:: c++
 
@@ -283,10 +285,11 @@
 setting backends for the individual module configurations (e.g. in GN,
 ``pw_foo_CONFIG = "//configuration:my_foo_config"``).
 
-Configurations are overridden by setting compilation options in the config
-backend. These options could be set through macro definitions, such as
-``-DPW_FOO_INPUT_BUFFER_SIZE_BYTES=256``, or in a header file included with the
-``-include`` option.
+Configurations options are overridden by setting macros in the config backend.
+These macro definitions can be provided through compilation options, such as
+``-DPW_FOO_INPUT_BUFFER_SIZE_BYTES=256``. Configuration macro definitions may
+also be set in a header file. The header file is included using the ``-include``
+compilation option.
 
 This example shows two ways to configure a module in the GN build system.
 
@@ -295,30 +298,57 @@
   # In the toolchain, set either pw_build_DEFAULT_MODULE_CONFIG or pw_foo_CONFIG
   pw_build_DEFAULT_MODULE_CONFIG = get_path_info(":define_overrides", "abspath")
 
-  # This configuration sets PW_FOO_INPUT_BUFFER_SIZE_BYTES using the -D macro.
+  # This configuration sets PW_FOO_INPUT_BUFFER_SIZE_BYTES using the -D flag.
   pw_source_set("define_overrides") {
     public_configs = [ ":define_options" ]
   }
 
   config("define_options") {
-    defines = [ "-DPW_FOO_INPUT_BUFFER_SIZE_BYTES=256" ]
+    defines = [ "PW_FOO_INPUT_BUFFER_SIZE_BYTES=256" ]
   }
 
-  # This configuration sets PW_FOO_INPUT_BUFFER_SIZE_BYTES with a header file.
+  # This configuration sets PW_FOO_INPUT_BUFFER_SIZE_BYTES in a header file.
   pw_source_set("include_overrides") {
-    public_configs = [ ":header_options" ]
+    public_configs = [ ":set_options_in_header_file" ]
 
     # Header file with #define PW_FOO_INPUT_BUFFER_SIZE_BYTES 256
     sources = [ "my_config_overrides.h" ]
   }
 
-  config("header_options") {
+  config("set_options_in_header_file") {
     cflags = [
       "-include",
-      "my_config_overrides.h",
+      rebase_path("my_config_overrides.h"),
     ]
   }
 
+.. admonition:: Why this config pattern is preferred
+
+  Alternate patterns for configuring a module include overriding the module's
+  config header or having that header optionally include a header at a known
+  path (e.g. ``pw_foo/config_overrides.h``). There are a few downsides to these
+  approaches:
+
+  * The module needs its own config header that defines, provides defaults for,
+    and validates the configuration options. Replacing this header with a
+    user-defined header would require defining all options in the user's header,
+    which is cumbersome and brittle, and would bypass validation in the module's
+    header.
+  * Including a config override header at a particular path would prevent
+    multiple modules from sharing the same configuration file. Multiple headers
+    could redirect to the same configuration file, but this would still require
+    creating a separate header and setting the config backend variable for each
+    module.
+  * Optionally including a config override header requires boilerplate code that
+    would have to be duplicated in every configurable module.
+  * An optional config override header file would silently be excluded if the
+    file path were accidentally misspelled.
+
+Python module structure
+-----------------------
+Python code is structured as described in the :ref:`docs-python-build-structure`
+section of :ref:`docs-python-build`.
+
 .. _docs-module-structure-facades:
 
 Facades
@@ -345,7 +375,7 @@
 .. caution::
 
   Modules should only use facades when necessary. Facades are permanently locked
-  to a particular implementation at compile time. Multpile backends cannot be
+  to a particular implementation at compile time. Multiple backends cannot be
   used in one build, and runtime dependency injection is not possible, which
   makes testing difficult. Where appropriate, modules should use other
   mechanisms, such as virtual interfaces, callbacks, or templates, in place of
@@ -427,7 +457,7 @@
 
 10. Add the new module to docs module
 
-    - Add in ``docs/BUILD.gn`` to ``pw_doc_gen("docs")``
+    - Add in ``docs/BUILD.gn`` to ``group("module_docs")``
 
 11. Run :ref:`module-pw_module-module-check`
 
diff --git a/docs/python_build.rst b/docs/python_build.rst
new file mode 100644
index 0000000..b89c788
--- /dev/null
+++ b/docs/python_build.rst
@@ -0,0 +1,316 @@
+.. _docs-python-build:
+
+======================
+Pigweed's Python build
+======================
+Pigweed uses a custom GN-based build system to manage its Python code. The
+Pigweed Python build supports packaging, installation, and distribution of
+interdependent local Python packages. It also provides for fast, incremental
+static analysis and test running suitable for live use during development (e.g.
+with :ref:`module-pw_watch`) or in continuous integration.
+
+Pigweed's Python code is exclusively managed by GN, but the GN-based build may
+be used alongside CMake, Bazel, or any other build system. Pigweed's environment
+setup uses GN to set up the initial Python environment, regardless of the final
+build system. As needed, non-GN projects can declare just their Python packages
+in GN.
+
+Background
+==========
+Developing software involves much more than writing source code. Software needs
+to be compiled, executed, tested, analyzed, packaged, and deployed. As projects
+grow beyond a few files, these tasks become impractical to manage manually.
+Build systems automate these auxiliary tasks of software development, making it
+possible to build larger, more complex systems quickly and robustly.
+
+Python is an interpreted language, but it shares most build automation concerns
+with other languages. Pigweed uses Python extensively and must to address these
+needs for itself and its users.
+
+Existing solutions
+==================
+The Python programming langauge does not have an official build automation
+system. However, there are numerous Python-focused build automation tools with
+varying degrees of adoption. See the `Python Wiki
+<https://wiki.python.org/moin/ConfigurationAndBuildTools>`_ for examples.
+
+A few Python tools have become defacto standards, including `setuptools
+<https://pypi.org/project/setuptools/>`_, `wheel
+<https://pypi.org/project/wheel/>`_, and `pip <https://pypi.org/project/pip/>`_.
+These essential tools address key aspects of Python packaging and distribution,
+but are not intended for general build automation. Tools like `PyBuilder
+<https://pybuilder.io/>`_ and `tox <https://tox.readthedocs.io/en/latest/>`_
+provide more general build automation for Python.
+
+The `Bazel <http://bazel.build/>`_ build system has first class support for
+Python and other languages used by Pigweed, including protocol buffers.
+
+Challenges
+==========
+Pigweed's use of Python is different from many other projects. Pigweed is a
+multi-language, modular project. It serves both as a library or middleware and
+as a development environment.
+
+This section describes Python build automation challenges encountered by
+Pigweed.
+
+Dependencies
+------------
+Pigweed is organized into distinct modules. In Python, each module is a separate
+package, potentially with dependencies on other local or `PyPI
+<https://pypi.org/>`_ packages.
+
+The basic Python packaging tools lack dependency tracking for local packages.
+For example, a package's ``setup.py`` or ``setup.cfg`` lists all of
+its dependencies, but ``pip`` is not aware of local packages until they are
+installed. Packages must be installed with their dependencies taken into
+account, in topological sorted order.
+
+To work around this, one could set up a private `PyPI server
+<https://pypi.org/project/pypiserver/>`_ instance, but this is too cumbersome
+for daily development and incompatible with editable package installation.
+
+Testing
+-------
+Tests are crucial to having a healthy, maintainable codebase. While they take
+some initial work to write, the time investment pays for itself many times over
+by contributing to the long-term resilience of a codebase. Despite their
+benefit, developers don't always take the time to write tests. Any barriers to
+writing and running tests result in fewer tests and consequently more fragile,
+bug-prone codebases.
+
+There are lots of great Python libraries for testing, such as
+`unittest <https://docs.python.org/3/library/unittest.html>`_ and
+`pytest <https://docs.pytest.org/en/stable/>`_. These tools make it easy to
+write and execute individual Python tests, but are not well suited for managing
+suites of interdependent tests in a large project. Writing a test with these
+utilities does not automatically run them or keep running them as the codebase
+changes.
+
+Static analysis
+---------------
+Various static analysis tools exist for Python. Two widely used, powerful tools
+are `Pylint <https://www.pylint.org/>`_ and `Mypy <http://mypy-lang.org/>`_.
+Using these tools improves code quality, as they catch bugs, encourage good
+design practices, and enforce a consistent coding style. As with testing,
+barriers to running static analysis tools cause many developers to skip them.
+Some developers may not even be aware of these tools.
+
+Deploying static analysis tools to a codebase like Pigweed has some challenges.
+Mypy and Pylint are simple to run, but they are extremely slow. Ideally, these
+tools would be run constantly during development, but only on files that change.
+These tools do not have built-in support for incremental runs or dependency
+tracking.
+
+Another challenge is configuration. Mypy and Pylint support using configuration
+files to select which checks to run and how to apply them. Both tools only
+support using a single configuration file for an entire run, which poses a
+challenge to modular middleware systems where different parts of a project may
+require different configurations.
+
+Protocol buffers
+----------------
+`Protocol buffers <https://developers.google.com/protocol-buffers>`_ are an
+efficient system for serializing structured data. They are widely used by Google
+and other companies.
+
+The protobuf compiler ``protoc`` generates Python modules from ``.proto`` files.
+``protoc`` strictly generates protobuf modules according to their directory
+structure. This works well in a monorepo, but poses a challenge to a middleware
+system like Pigweed. Generating protobufs by path also makes integrating
+protobufs with existing packages awkward.
+
+Requirements
+============
+Pigweed aims to provide high quality software components and a fast, effective,
+flexible development experience for its customers. Pigweed's high-level goals
+and the `challenges`_ described above inform these requirements for the Pigweed
+Python build.
+
+- Integrate seamlessly with the other Pigweed build tools.
+- Easy to use independently, even if primarily using a different build system.
+- Support standard packaging and distribution with setuptools, wheel, and pip.
+- Correctly manage interdependent local Python packages.
+- Out-of-the-box support for writing and running tests.
+- Preconfigured, trivial-to-run static analysis integration for Pylint and Mypy.
+- Fast, dependency-aware incremental rebuilds and test execution, suitable for
+  use with :ref:`module-pw_watch`.
+- Seamless protocol buffer support.
+
+Detailed design
+===============
+
+Build automation tool
+---------------------
+Existing Python tools may be effective for Python codebases, but their utility
+is more limited in a multi-language project like Pigweed. The cost of bringing
+up and maintaining an additional build automation system for a single language
+is high.
+
+Pigweed uses GN as its primary build system for all languages. While GN does not
+natively support Python, adding support is straightforward with GN templates.
+
+GN has strong multi-toolchain and multi-language capabilities. In GN, it is
+straightforward to share targets and artifacts between different languages. For
+example, C++, Go, and Python targets can depend on the same protobuf
+declaration. When using GN for multiple languages, Ninja schedules build steps
+for all languages together, resulting in faster total build times.
+
+Not all Pigweed users build with GN. Of Pigweed's three supported build systems,
+GN is the fastest, lightest weight, and easiest to run. It also has simple,
+clean syntax. This makes it feasible to use GN only for Python while building
+primarily with a different system.
+
+Given these considerations, GN is an ideal choice for Pigweed's Python build.
+
+.. _docs-python-build-structure:
+
+Module structure
+----------------
+Pigweed Python code is structured into standard Python packages. This makes it
+simple to package and distribute Pigweed Python packages with common Python
+tools.
+
+Like all Pigweed source code, Python packages are organized into Pigweed
+modules. A module's Python package is nested under a ``py/`` directory (see
+:ref:`docs-module-structure`).
+
+.. code-block::
+
+  module_name/
+  ├── py/
+  │   ├── BUILD.gn
+  │   ├── setup.py
+  │   ├── package_name/
+  │   │   ├── module_a.py
+  │   │   ├── module_b.py
+  │   │   ├── py.typed
+  │   │   └── nested_package/
+  │   │       ├── py.typed
+  │   │       └── module_c.py
+  │   ├── module_a_test.py
+  │   └── module_c_test.py
+  └── ...
+
+The ``BUILD.gn`` declares this package in GN. For upstream Pigweed, a presubmit
+check in ensures that all Python files are listed in a ``BUILD.gn``.
+
+.. _module-pw_build-python-target:
+
+pw_python_package targets
+-------------------------
+The key abstraction in the Python build is the ``pw_python_package``.
+A ``pw_python_package`` represents a Python package as a GN target. It is
+implemented with a GN template. The ``pw_python_package`` template is documented
+in :ref:`module-pw_build-python`.
+
+The key attributes of a ``pw_python_package`` are
+
+- a ``setup.py`` file,
+- source files,
+- test files,
+- dependencies on other ``pw_python_package`` targets.
+
+A ``pw_python_package`` target is composed of several GN subtargets. Each
+subtarget represents different functionality in the Python build.
+
+- ``<name>`` - Represents the Python files in the build, but does not take any
+  actions. All subtargets depend on this target.
+- ``<name>.tests`` - Runs all tests for this package.
+
+  - ``<name>.tests.<test_file>`` - Runs the specified test.
+
+- ``<name>.lint`` - Runs static analysis tools on the Python code. This is a
+  group of two subtargets:
+
+  - ``<name>.lint.mypy`` - Runs Mypy on all Python files, if enabled.
+  - ``<name>.lint.pylint`` - Runs Pylint on all Python files, if enabled.
+
+- ``<name>.install`` - Installs the package in a Python virtual environment.
+- ``<name>.wheel`` - Builds a Python wheel for this package.
+
+To avoid unnecessary duplication, all Python actions are executed in the default
+toolchain, even if they are referred to from other toolchains.
+
+Testing
+^^^^^^^
+Tests for a Python package are listed in its ``pw_python_package`` target.
+Adding a new test is simple: write the test file and list it in its accompanying
+Python package. The build will run the it when the test, the package, or one
+of its dependencies is updated.
+
+Static analysis
+^^^^^^^^^^^^^^^
+``pw_python_package`` targets are preconfigured to run Pylint and Mypy on their
+source and test files. Users may specify which  ``pylintrc`` and ``mypy.ini``
+files to
+use on a per-package basis. The configuration files may also be provided in the
+directory structure; the tools will locate them using their standard means. Like
+tests, static analysis is only run when files or their dependencies change.
+
+Packages may opt out of static analysis as necessary.
+
+Installing packages in a virtual environment
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+Python packages declared in the Python build may be installed in a specified
+`virtual environment <https://docs.python.org/3/tutorial/venv.html>`_. The
+default venv to use may be specified using a GN build arg. The venv may be
+overridden for individual targets. The Python build tracks installation status
+of packages based on which venv is in use.
+
+The Python build examines the ``VIRTUAL_ENV`` environment variable to determine
+the current venv. If the selected virtual environment is active, packages are
+installed directly into it. If the venv is not active, it is activated before
+installing the packages.
+
+.. admonition:: Under construction
+
+  Pigweed Python targets are not yet aware of the virtual environment.
+  Currently, all actions occur in the active venv without consulting
+  ``VIRTUAL_ENV``.
+
+Python packages defined entirely in tree are installed with the ``--editable``
+option. Partially or fully generated packages are installed without that option.
+
+Building Python wheels
+^^^^^^^^^^^^^^^^^^^^^^
+Wheels are the standard format for distributing Python packages. The Pigweed
+Python build supports creating wheels for individual packages and groups of
+packages. Building the ``.wheel`` subtarget creates a ``.whl`` file for the
+package using the PyPA's `build <https://pypa-build.readthedocs.io/en/stable/>`_
+tool.
+
+The ``.wheel`` subtarget of ``pw_python_package`` records the location of
+the generated wheel with `GN metadata
+<https://gn.googlesource.com/gn/+/master/docs/reference.md#var_metadata>`_.
+Wheels for a Python package and its transitive dependencies can be collected
+from the ``pw_python_package_wheels`` key. See
+:ref:`module-pw_build-python-wheels`.
+
+Protocol buffers
+^^^^^^^^^^^^^^^^
+The Pigweed GN build supports protocol buffers with the ``pw_proto_library``
+target (see :ref:`module-pw_protobuf_compiler`). Python protobuf modules are
+generated as standalone Python packages by default. Protocol buffers may also be
+nested within existing Python packages. In this case, the Python package in the
+source tree is incomplete; the final Python package, including protobufs, is
+generated in the output directory.
+
+Generating setup.py
+-------------------
+The ``pw_python_package`` target in the ``BUILD.gn`` duplicates much of the
+information in the ``setup.py`` or ``setup.cfg`` file. In many cases, it would
+be possible to generate a ``setup.py`` file rather than including it in the
+source tree. However, removing the ``setup.py`` would preclude using a direct,
+editable installation from the source tree.
+
+Pigweed packages containing protobufs are generated in full or in part. These
+packages may use generated setup files, since they are always be packaged or
+installed from the build output directory.
+
+See also
+========
+
+  - :ref:`module-pw_build-python`
+  - :ref:`module-pw_build`
+  - :ref:`docs-build-system`
diff --git a/docs/style_guide.rst b/docs/style_guide.rst
index 320aac3..e05bde9 100644
--- a/docs/style_guide.rst
+++ b/docs/style_guide.rst
@@ -163,7 +163,7 @@
 
 Comments
 ========
-Prefer C++-style (``//``) comments over C-style commments (``/* */``). C-style
+Prefer C++-style (``//``) comments over C-style comments (``/* */``). C-style
 comments should only be used for inline comments.
 
 .. code-block:: cpp
@@ -250,43 +250,57 @@
   * If private code must be exposed in a header file, it must be in a namespace
     nested under ``pw``. The namespace may be named for its subsystem or use a
     name that designates it as private, such as ``internal``.
-  * Template arguments for non-type names (e.g. ``template <int foo_bar>``)
-    should follow the variable naming convention, which means snake case (e.g.
-    ``foo_bar``). This matches the Google C++ style, however the wording in the
-    official style guide isn't explicit and could be interpreted to use
-    ``kFooBar`` style naming. Wide practice establishes that the naming
-    convention is ``snake_case``, and so that is the style we use in Pigweed.
+  * Template arguments for non-type names (e.g. ``template <int kFooBar>``)
+    should follow the constexpr and const variable Google naming convention,
+    which means k prefixed camel case (e.g.
+    ``kCamelCase``). This matches the Google C++ style for variable naming,
+    however the wording in the official style guide isn't explicit for template
+    arguments and could be interpreted to use ``foo_bar`` style naming.
+    For consistency with other variables whose value is always fixed for the
+    duration of the program, the naming convention is ``kCamelCase``, and so
+    that is the style we use in Pigweed.
 
     **Note:** At time of writing much of Pigweed incorrectly follows the
-    ``kCamelCase`` naming for non-type template arguments. This is a bug that
+    ``snake_case`` naming for non-type template arguments. This is a bug that
     will be fixed eventually.
 
 **C code**
-  * Public names used by C code must be prefixed with ``pw_``.
+In general, C symbols should be prefixed with the module name. If the symbol is
+not associated with a module, use just ``pw`` as the module name. Facade
+backends may chose to prefix symbols with the facade's name to help reduce the
+length of the prefix.
+
+  * Public names used by C code must be prefixed with the module name (e.g.
+    ``pw_tokenizer_*``).
   * If private code must be exposed in a header, private names used by C code
-    must be prefixed with ``_pw_``.
+    must be prefixed with an underscore followed by the module name (e.g.
+    ``_pw_assert_*``).
   * Avoid writing C source (.c) files in Pigweed. Prefer to write C++ code with
     C linkage using ``extern "C"``. Within C source, private C functions and
-    variables must be named with the ``_pw_`` prefix and should be declared
-    ``static`` whenever possible; for example, ``_pw__MyPrivateFunction``.
+    variables must be named with the ``_pw_my_module_*`` prefix and should be
+    declared ``static`` whenever possible; for example,
+    ``_pw_my_module_MyPrivateFunction``.
   * The C prefix rules apply to
 
-    * C functions (``int pw_FunctionName(void);``),
-    * variables used by C code (``int pw_variable_name;``),
-    * constant variables used by C code (``int pw_kConstantName;``), and
-    * structs used by C code (``typedef struct {} pw_StructName;``).
+    * C functions (``int pw_foo_FunctionName(void);``),
+    * variables used by C code (``int pw_foo_variable_name;``),
+    * constant variables used by C code (``int pw_foo_kConstantName;``),
+    * structs used by C code (``typedef struct {} pw_foo_StructName;``), and
+    * all of the above for ``extern "C"`` names in C++ code.
 
     The prefix does not apply to struct members, which use normal Google style.
 
 **Preprocessor macros**
-  * Public Pigweed macros must be prefixed with ``PW_``.
-  * Private Pigweed macros must be prefixed with ``_PW_``.
+  * Public Pigweed macros must be prefixed with the module name (e.g.
+    ``PW_MY_MODULE_*``).
+  * Private Pigweed macros must be prefixed with an underscore followed by the
+    module name (e.g. ``_PW_MY_MODULE_*``).
 
 **Example**
 
 .. code-block:: cpp
 
-  namespace pw {
+  namespace pw::my_module {
   namespace nested_namespace {
 
   // C++ names (types, variables, functions) must be in the pw namespace.
@@ -303,37 +317,37 @@
   extern "C" {
 
   // Public Pigweed code used from C must be prefixed with pw_.
-  extern const int pw_kGlobalConstant;
+  extern const int pw_my_module_kGlobalConstant;
 
-  extern int pw_global_variable;
+  extern int pw_my_module_global_variable;
 
-  void pw_Function(void);
+  void pw_my_module_Function(void);
 
   typedef struct {
     int member_variable;
-  } pw_Struct;
+  } pw_my_module_Struct;
 
   // Private Pigweed code used from C must be prefixed with _pw_.
-  extern const int _pw_kPrivateGlobalConstant;
+  extern const int _pw_my_module_kPrivateGlobalConstant;
 
-  extern int _pw_private_global_variable;
+  extern int _pw_my_module_private_global_variable;
 
-  void _pw_PrivateFunction(void);
+  void _pw_my_module_PrivateFunction(void);
 
   typedef struct {
     int member_variable;
-  } _pw_PrivateStruct;
+  } _pw_my_module_PrivateStruct;
 
   }  // extern "C"
 
   // Public macros must be prefixed with PW_.
-  #define PW_PUBLIC_MACRO(arg) arg
+  #define PW_MY_MODULE_PUBLIC_MACRO(arg) arg
 
   // Private macros must be prefixed with _PW_.
-  #define _PW_PRIVATE_MACRO(arg) arg
+  #define _PW_MY_MODULE_PRIVATE_MACRO(arg) arg
 
   }  // namespace nested_namespace
-  }  // namespace pw
+  }  // namespace pw::my_module
 
 Namespace scope formatting
 ==========================
@@ -538,11 +552,11 @@
 
 Each Pigweed source module will require a build file named BUILD.gn which
 encapsulates the build targets and specifies their sources and dependencies.
-The format of this file is simlar in structure to the
+The format of this file is similar in structure to the
 `Bazel/Blaze format <https://docs.bazel.build/versions/3.2.0/build-ref.html>`_
 (Googlers may also review `go/build-style <go/build-style>`_), but with
 nomenclature specific to Pigweed. For each target specified within the build
-file there are a list of depdency fields. Those fields, in their expected
+file there are a list of dependency fields. Those fields, in their expected
 order, are:
 
   * ``<public_config>`` -- external build configuration
@@ -566,10 +580,7 @@
 
   source_set("pw_sample_module") {
     public_configs = [ ":default_config" ]
-    public_deps = [
-      dir_pw_span,
-      dir_pw_status,
-    ]
+    public_deps = [ dir_pw_status ]
     public = [ "public/pw_sample_module/sample_module.h" ]
     sources = [
       "public/pw_sample_module/internal/sample_module.h",
diff --git a/docs/targets.rst b/docs/targets.rst
index 21f4ccc..a72ae4a 100644
--- a/docs/targets.rst
+++ b/docs/targets.rst
@@ -47,7 +47,7 @@
 Toolchain target variables
 --------------------------
 The core of a toolchain is defining the tools it uses. This is done by setting
-the variables ``ar``, ``cc``, and ``cxx`` to the appropirate compilers. Pigweed
+the variables ``ar``, ``cc``, and ``cxx`` to the appropriate compilers. Pigweed
 provides many commonly used compiler configurations in the ``pw_toolchain``
 module.
 
diff --git a/modules.gni b/modules.gni
index 974c2a4..7da2072 100644
--- a/modules.gni
+++ b/modules.gni
@@ -29,29 +29,41 @@
   dir_pw_build = get_path_info("pw_build", "abspath")
   dir_pw_bytes = get_path_info("pw_bytes", "abspath")
   dir_pw_checksum = get_path_info("pw_checksum", "abspath")
+  dir_pw_chrono = get_path_info("pw_chrono", "abspath")
+  dir_pw_chrono_embos = get_path_info("pw_chrono_embos", "abspath")
+  dir_pw_chrono_freertos = get_path_info("pw_chrono_freertos", "abspath")
+  dir_pw_chrono_stl = get_path_info("pw_chrono_stl", "abspath")
+  dir_pw_chrono_threadx = get_path_info("pw_chrono_threadx", "abspath")
   dir_pw_cli = get_path_info("pw_cli", "abspath")
   dir_pw_containers = get_path_info("pw_containers", "abspath")
   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")
   dir_pw_env_setup = get_path_info("pw_env_setup", "abspath")
-  dir_pw_hdlc_lite = get_path_info("pw_hdlc_lite", "abspath")
+  dir_pw_hdlc = get_path_info("pw_hdlc", "abspath")
+  dir_pw_i2c = get_path_info("pw_i2c", "abspath")
+  dir_pw_interrupt = get_path_info("pw_interrupt", "abspath")
+  dir_pw_interrupt_cortex_m = get_path_info("pw_interrupt_cortex_m", "abspath")
   dir_pw_kvs = get_path_info("pw_kvs", "abspath")
   dir_pw_log = get_path_info("pw_log", "abspath")
   dir_pw_log_basic = get_path_info("pw_log_basic", "abspath")
+  dir_pw_log_multisink = get_path_info("pw_log_multisink", "abspath")
   dir_pw_log_null = get_path_info("pw_log_null", "abspath")
   dir_pw_log_rpc = get_path_info("pw_log_rpc", "abspath")
+  dir_pw_log_sink = get_path_info("pw_log_sink", "abspath")
   dir_pw_log_tokenized = get_path_info("pw_log_tokenized", "abspath")
   dir_pw_malloc = get_path_info("pw_malloc", "abspath")
   dir_pw_malloc_freelist = get_path_info("pw_malloc_freelist", "abspath")
   dir_pw_metric = get_path_info("pw_metric", "abspath")
   dir_pw_minimal_cpp_stdlib = get_path_info("pw_minimal_cpp_stdlib", "abspath")
   dir_pw_module = get_path_info("pw_module", "abspath")
+  dir_pw_multisink = get_path_info("pw_multisink", "abspath")
   dir_pw_fuzzer = get_path_info("pw_fuzzer", "abspath")
   dir_pw_package = get_path_info("pw_package", "abspath")
+  dir_pw_persistent_ram = get_path_info("pw_persistent_ram", "abspath")
   dir_pw_polyfill = get_path_info("pw_polyfill", "abspath")
   dir_pw_preprocessor = get_path_info("pw_preprocessor", "abspath")
   dir_pw_presubmit = get_path_info("pw_presubmit", "abspath")
@@ -60,11 +72,18 @@
   dir_pw_random = get_path_info("pw_random", "abspath")
   dir_pw_result = get_path_info("pw_result", "abspath")
   dir_pw_ring_buffer = get_path_info("pw_ring_buffer", "abspath")
+  dir_pw_router = get_path_info("pw_router", "abspath")
   dir_pw_rpc = get_path_info("pw_rpc", "abspath")
   dir_pw_span = get_path_info("pw_span", "abspath")
   dir_pw_status = get_path_info("pw_status", "abspath")
   dir_pw_stream = get_path_info("pw_stream", "abspath")
   dir_pw_string = get_path_info("pw_string", "abspath")
+  dir_pw_sync = get_path_info("pw_sync", "abspath")
+  dir_pw_sync_embos = get_path_info("pw_sync_embos", "abspath")
+  dir_pw_sync_freertos = get_path_info("pw_sync_freertos", "abspath")
+  dir_pw_sync_baremetal = get_path_info("pw_sync_baremetal", "abspath")
+  dir_pw_sync_stl = get_path_info("pw_sync_stl", "abspath")
+  dir_pw_sync_threadx = get_path_info("pw_sync_threadx", "abspath")
   dir_pw_sys_io = get_path_info("pw_sys_io", "abspath")
   dir_pw_sys_io_baremetal_lm3s6965evb =
       get_path_info("pw_sys_io_baremetal_lm3s6965evb", "abspath")
@@ -73,8 +92,14 @@
   dir_pw_sys_io_arduino = get_path_info("pw_sys_io_arduino", "abspath")
   dir_pw_sys_io_stdio = get_path_info("pw_sys_io_stdio", "abspath")
   dir_pw_target_runner = get_path_info("pw_target_runner", "abspath")
+  dir_pw_thread = get_path_info("pw_thread", "abspath")
+  dir_pw_thread_stl = get_path_info("pw_thread_stl", "abspath")
+  dir_pw_thread_embos = get_path_info("pw_thread_embos", "abspath")
+  dir_pw_thread_freertos = get_path_info("pw_thread_freertos", "abspath")
+  dir_pw_thread_threadx = get_path_info("pw_thread_threadx", "abspath")
   dir_pw_third_party = get_path_info("third_party", "abspath")
   dir_pw_tokenizer = get_path_info("pw_tokenizer", "abspath")
+  dir_pw_tool = get_path_info("pw_tool", "abspath")
   dir_pw_toolchain = get_path_info("pw_toolchain", "abspath")
   dir_pw_trace = get_path_info("pw_trace", "abspath")
   dir_pw_trace_tokenized = get_path_info("pw_trace_tokenized", "abspath")
diff --git a/pw_allocator/BUILD.gn b/pw_allocator/BUILD.gn
index 6e8ee54..c25bb4c 100644
--- a/pw_allocator/BUILD.gn
+++ b/pw_allocator/BUILD.gn
@@ -46,7 +46,6 @@
   public = [ "public/pw_allocator/block.h" ]
   public_deps = [
     "$dir_pw_assert",
-    "$dir_pw_span",
     "$dir_pw_status",
   ]
   sources = [ "block.cc" ]
@@ -58,7 +57,6 @@
   public = [ "public/pw_allocator/freelist.h" ]
   public_deps = [
     "$dir_pw_containers:vector",
-    "$dir_pw_span",
     "$dir_pw_status",
   ]
   sources = [ "freelist.cc" ]
@@ -71,7 +69,6 @@
   public_deps = [
     ":block",
     ":freelist",
-    "$dir_pw_span",
   ]
   deps = [
     "$dir_pw_assert",
diff --git a/pw_allocator/block.cc b/pw_allocator/block.cc
index bbf1a34..7390419 100644
--- a/pw_allocator/block.cc
+++ b/pw_allocator/block.cc
@@ -38,15 +38,15 @@
   // with the following storage. Since the space between this block and the
   // next are implicitly part of the raw data, size can be computed by
   // subtracting the pointers.
-  aliased.block->next = reinterpret_cast<Block*>(region.end());
+  aliased.block->next_ = reinterpret_cast<Block*>(region.end());
   aliased.block->MarkLast();
 
-  aliased.block->prev = nullptr;
+  aliased.block->prev_ = nullptr;
   *block = aliased.block;
 #if defined(PW_ALLOCATOR_POISON_ENABLE) && PW_ALLOCATOR_POISON_ENABLE
   (*block)->PoisonBlock();
 #endif  // PW_ALLOCATOR_POISON_ENABLE
-  return Status::Ok();
+  return OkStatus();
 }
 
 Status Block::Split(size_t head_block_inner_size, Block** new_block) {
@@ -101,24 +101,24 @@
   // If we're inserting in the middle, we need to update the current next
   // block to point to what we're inserting
   if (!Last()) {
-    Next()->prev = new_next;
+    Next()->prev_ = new_next;
   }
 
   // Copy next verbatim so the next block also gets the "last"-ness
-  new_next->next = next;
-  new_next->prev = this;
+  new_next->next_ = next_;
+  new_next->prev_ = this;
 
   // Update the current block to point to the new head.
-  next = new_next;
+  next_ = new_next;
 
-  *new_block = next;
+  *new_block = next_;
 
 #if defined(PW_ALLOCATOR_POISON_ENABLE) && PW_ALLOCATOR_POISON_ENABLE
   PoisonBlock();
   (*new_block)->PoisonBlock();
 #endif  // PW_ALLOCATOR_POISON_ENABLE
 
-  return Status::Ok();
+  return OkStatus();
 }
 
 Status Block::MergeNext() {
@@ -135,27 +135,27 @@
   // Simply enough, this block's next pointer becomes the next block's
   // next pointer. We then need to re-wire the "next next" block's prev
   // pointer to point back to us though.
-  next = Next()->next;
+  next_ = Next()->next_;
 
   // Copying the pointer also copies the "last" status, so this is safe.
   if (!Last()) {
-    Next()->prev = this;
+    Next()->prev_ = this;
   }
 
-  return Status::Ok();
+  return OkStatus();
 }
 
 Status Block::MergePrev() {
   // We can't merge if we have no previous. After that though, merging with
   // the previous block is just MergeNext from the previous block.
-  if (prev == nullptr) {
+  if (prev_ == nullptr) {
     return Status::OutOfRange();
   }
 
   // WARNING: This class instance will still exist, but technically be invalid
   // after this has been invoked. Be careful when doing anything with `this`
   // After doing the below.
-  return prev->MergeNext();
+  return prev_->MergeNext();
 }
 
 // TODO(pwbug/234): Add stack tracing to locate which call to the heap operation
diff --git a/pw_allocator/block_test.cc b/pw_allocator/block_test.cc
index 0e09579..f70c826 100644
--- a/pw_allocator/block_test.cc
+++ b/pw_allocator/block_test.cc
@@ -30,7 +30,7 @@
   Block* block = nullptr;
   auto status = Block::Init(std::span(bytes, kN), &block);
 
-  ASSERT_EQ(status, Status::Ok());
+  ASSERT_EQ(status, OkStatus());
   EXPECT_EQ(block->OuterSize(), kN);
   EXPECT_EQ(block->InnerSize(),
             kN - sizeof(Block) - 2 * PW_ALLOCATOR_POISON_OFFSET);
@@ -68,12 +68,12 @@
   alignas(Block*) byte bytes[kN];
 
   Block* block = nullptr;
-  Block::Init(std::span(bytes, kN), &block);
+  EXPECT_EQ(Block::Init(std::span(bytes, kN), &block), OkStatus());
 
   Block* next_block = nullptr;
   auto status = block->Split(kSplitN, &next_block);
 
-  ASSERT_EQ(status, Status::Ok());
+  ASSERT_EQ(status, OkStatus());
   EXPECT_EQ(block->InnerSize(), kSplitN);
   EXPECT_EQ(block->OuterSize(),
             kSplitN + sizeof(Block) + 2 * PW_ALLOCATOR_POISON_OFFSET);
@@ -101,12 +101,12 @@
   uintptr_t split_len = split_addr - (uintptr_t)&bytes;
 
   Block* block = nullptr;
-  Block::Init(std::span(bytes, kN), &block);
+  EXPECT_EQ(Block::Init(std::span(bytes, kN), &block), OkStatus());
 
   Block* next_block = nullptr;
   auto status = block->Split(kSplitN, &next_block);
 
-  ASSERT_EQ(status, Status::Ok());
+  ASSERT_EQ(status, OkStatus());
   EXPECT_EQ(block->InnerSize(), split_len);
   EXPECT_EQ(block->OuterSize(),
             split_len + sizeof(Block) + 2 * PW_ALLOCATOR_POISON_OFFSET);
@@ -130,10 +130,10 @@
   constexpr size_t kN = 1024;
   constexpr size_t kSplit1 = 512;
   constexpr size_t kSplit2 = 256;
-  byte bytes[kN];
+  alignas(Block*) byte bytes[kN];
 
   Block* block = nullptr;
-  Block::Init(std::span(bytes, kN), &block);
+  EXPECT_EQ(Block::Init(std::span(bytes, kN), &block), OkStatus());
 
   Block* block2 = nullptr;
   block->Split(kSplit1, &block2);
@@ -151,10 +151,10 @@
   constexpr size_t kN = 1024;
   constexpr size_t kSplitN =
       kN - sizeof(Block) - 2 * PW_ALLOCATOR_POISON_OFFSET - 1;
-  byte bytes[kN];
+  alignas(Block*) byte bytes[kN];
 
   Block* block = nullptr;
-  Block::Init(std::span(bytes, kN), &block);
+  EXPECT_EQ(Block::Init(std::span(bytes, kN), &block), OkStatus());
 
   Block* next_block = nullptr;
   auto status = block->Split(kSplitN, &next_block);
@@ -166,10 +166,10 @@
 TEST(Block, MustProvideNextBlockPointer) {
   constexpr size_t kN = 1024;
   constexpr size_t kSplitN = kN - sizeof(Block) - 1;
-  byte bytes[kN];
+  alignas(Block*) byte bytes[kN];
 
   Block* block = nullptr;
-  Block::Init(std::span(bytes, kN), &block);
+  EXPECT_EQ(Block::Init(std::span(bytes, kN), &block), OkStatus());
 
   auto status = block->Split(kSplitN, nullptr);
   EXPECT_EQ(status, Status::InvalidArgument());
@@ -178,10 +178,10 @@
 TEST(Block, CannotMakeBlockLargerInSplit) {
   // Ensure that we can't ask for more space than the block actually has...
   constexpr size_t kN = 1024;
-  byte bytes[kN];
+  alignas(Block*) byte bytes[kN];
 
   Block* block = nullptr;
-  Block::Init(std::span(bytes, kN), &block);
+  EXPECT_EQ(Block::Init(std::span(bytes, kN), &block), OkStatus());
 
   Block* next_block = nullptr;
   auto status = block->Split(block->InnerSize() + 1, &next_block);
@@ -192,10 +192,10 @@
 TEST(Block, CannotMakeSecondBlockLargerInSplit) {
   // Ensure that the second block in split is at least of the size of header.
   constexpr size_t kN = 1024;
-  byte bytes[kN];
+  alignas(Block*) byte bytes[kN];
 
   Block* block = nullptr;
-  Block::Init(std::span(bytes, kN), &block);
+  EXPECT_EQ(Block::Init(std::span(bytes, kN), &block), OkStatus());
 
   Block* next_block = nullptr;
   auto status = block->Split(
@@ -209,32 +209,32 @@
 TEST(Block, CanMakeZeroSizeFirstBlock) {
   // This block does support splitting with zero payload size.
   constexpr size_t kN = 1024;
-  byte bytes[kN];
+  alignas(Block*) byte bytes[kN];
 
   Block* block = nullptr;
-  Block::Init(std::span(bytes, kN), &block);
+  EXPECT_EQ(Block::Init(std::span(bytes, kN), &block), OkStatus());
 
   Block* next_block = nullptr;
   auto status = block->Split(0, &next_block);
 
-  ASSERT_EQ(status, Status::Ok());
+  ASSERT_EQ(status, OkStatus());
   EXPECT_EQ(block->InnerSize(), static_cast<size_t>(0));
 }
 
 TEST(Block, CanMakeZeroSizeSecondBlock) {
   // Likewise, the split block can be zero-width.
   constexpr size_t kN = 1024;
-  byte bytes[kN];
+  alignas(Block*) byte bytes[kN];
 
   Block* block = nullptr;
-  Block::Init(std::span(bytes, kN), &block);
+  EXPECT_EQ(Block::Init(std::span(bytes, kN), &block), OkStatus());
 
   Block* next_block = nullptr;
   auto status = block->Split(
       block->InnerSize() - sizeof(Block) - 2 * PW_ALLOCATOR_POISON_OFFSET,
       &next_block);
 
-  ASSERT_EQ(status, Status::Ok());
+  ASSERT_EQ(status, OkStatus());
   EXPECT_EQ(next_block->InnerSize(), static_cast<size_t>(0));
 }
 
@@ -243,7 +243,7 @@
   alignas(Block*) byte bytes[kN];
 
   Block* block = nullptr;
-  Block::Init(std::span(bytes, kN), &block);
+  EXPECT_EQ(Block::Init(std::span(bytes, kN), &block), OkStatus());
 
   block->MarkUsed();
   EXPECT_EQ(block->Used(), true);
@@ -261,7 +261,7 @@
   alignas(Block*) byte bytes[kN];
 
   Block* block = nullptr;
-  Block::Init(std::span(bytes, kN), &block);
+  EXPECT_EQ(Block::Init(std::span(bytes, kN), &block), OkStatus());
 
   block->MarkUsed();
 
@@ -276,10 +276,10 @@
   constexpr size_t kN = 1024;
   constexpr size_t kSplit1 = 512;
   constexpr size_t kSplit2 = 256;
-  byte bytes[kN];
+  alignas(Block*) byte bytes[kN];
 
   Block* block = nullptr;
-  Block::Init(std::span(bytes, kN), &block);
+  EXPECT_EQ(Block::Init(std::span(bytes, kN), &block), OkStatus());
 
   Block* block2 = nullptr;
   block->Split(kSplit1, &block2);
@@ -287,7 +287,7 @@
   Block* block3 = nullptr;
   block->Split(kSplit2, &block3);
 
-  EXPECT_EQ(block3->MergeNext(), Status::Ok());
+  EXPECT_EQ(block3->MergeNext(), OkStatus());
 
   EXPECT_EQ(block->Next(), block3);
   EXPECT_EQ(block3->Prev(), block);
@@ -302,12 +302,12 @@
 
 TEST(Block, CannotMergeWithFirstOrLastBlock) {
   constexpr size_t kN = 1024;
-  byte bytes[kN];
+  alignas(Block*) byte bytes[kN];
 
   // Do a split, just to sanity check that the checks on Next/Prev are
   // different...
   Block* block = nullptr;
-  Block::Init(std::span(bytes, kN), &block);
+  EXPECT_EQ(Block::Init(std::span(bytes, kN), &block), OkStatus());
 
   Block* next_block = nullptr;
   block->Split(512, &next_block);
@@ -318,12 +318,12 @@
 
 TEST(Block, CannotMergeUsedBlock) {
   constexpr size_t kN = 1024;
-  byte bytes[kN];
+  alignas(Block*) byte bytes[kN];
 
   // Do a split, just to sanity check that the checks on Next/Prev are
   // different...
   Block* block = nullptr;
-  Block::Init(std::span(bytes, kN), &block);
+  EXPECT_EQ(Block::Init(std::span(bytes, kN), &block), OkStatus());
 
   Block* next_block = nullptr;
   block->Split(512, &next_block);
@@ -335,10 +335,10 @@
 
 TEST(Block, CanCheckValidBlock) {
   constexpr size_t kN = 1024;
-  byte bytes[kN];
+  alignas(Block*) byte bytes[kN];
 
   Block* first_block = nullptr;
-  Block::Init(std::span(bytes, kN), &first_block);
+  EXPECT_EQ(Block::Init(std::span(bytes, kN), &first_block), OkStatus());
 
   Block* second_block = nullptr;
   first_block->Split(512, &second_block);
@@ -353,10 +353,10 @@
 
 TEST(Block, CanCheckInalidBlock) {
   constexpr size_t kN = 1024;
-  byte bytes[kN];
+  alignas(Block*) byte bytes[kN];
 
   Block* first_block = nullptr;
-  Block::Init(std::span(bytes, kN), &first_block);
+  EXPECT_EQ(Block::Init(std::span(bytes, kN), &first_block), OkStatus());
 
   Block* second_block = nullptr;
   first_block->Split(512, &second_block);
@@ -391,10 +391,10 @@
 TEST(Block, CanPoisonBlock) {
 #if defined(PW_ALLOCATOR_POISON_ENABLE) && PW_ALLOCATOR_POISON_ENABLE
   constexpr size_t kN = 1024;
-  byte bytes[kN];
+  alignas(Block*) byte bytes[kN];
 
   Block* first_block = nullptr;
-  Block::Init(std::span(bytes, kN), &first_block);
+  EXPECT_EQ(Block::Init(std::span(bytes, kN), &first_block), OkStatus());
 
   Block* second_block = nullptr;
   first_block->Split(512, &second_block);
diff --git a/pw_allocator/freelist.cc b/pw_allocator/freelist.cc
index d46e010..03b514d 100644
--- a/pw_allocator/freelist.cc
+++ b/pw_allocator/freelist.cc
@@ -36,7 +36,7 @@
   aliased.node->next = chunks_[chunk_ptr];
   chunks_[chunk_ptr] = aliased.node;
 
-  return Status::Ok();
+  return OkStatus();
 }
 
 std::span<std::byte> FreeList::FindChunk(size_t size) const {
@@ -92,7 +92,7 @@
   if (aliased.data == chunk.data()) {
     chunks_[chunk_ptr] = aliased.node->next;
 
-    return Status::Ok();
+    return OkStatus();
   }
 
   // No? Walk the nodes.
@@ -103,7 +103,7 @@
     if (aliased_next.data == chunk.data()) {
       // Found it, remove this node out of the chain
       aliased.node->next = aliased_next.node->next;
-      return Status::Ok();
+      return OkStatus();
     }
 
     aliased.node = aliased.node->next;
diff --git a/pw_allocator/freelist_heap.cc b/pw_allocator/freelist_heap.cc
index a6124e3..750a857 100644
--- a/pw_allocator/freelist_heap.cc
+++ b/pw_allocator/freelist_heap.cc
@@ -24,7 +24,9 @@
 FreeListHeap::FreeListHeap(std::span<std::byte> region, FreeList& freelist)
     : freelist_(freelist), heap_stats_() {
   Block* block;
-  Block::Init(region, &block);
+  PW_CHECK_OK(
+      Block::Init(region, &block),
+      "Failed to initialize FreeListHeap region; misaligned or too small");
 
   freelist_.AddChunk(BlockToSpan(block));
 
diff --git a/pw_allocator/freelist_heap_test.cc b/pw_allocator/freelist_heap_test.cc
index 7746383..469e7d9 100644
--- a/pw_allocator/freelist_heap_test.cc
+++ b/pw_allocator/freelist_heap_test.cc
@@ -23,7 +23,7 @@
 TEST(FreeListHeap, CanAllocate) {
   constexpr size_t N = 2048;
   constexpr size_t kAllocSize = 512;
-  std::byte buf[N] = {std::byte(0)};
+  alignas(Block) std::byte buf[N] = {std::byte(0)};
 
   FreeListHeapBuffer allocator(buf);
 
@@ -37,7 +37,7 @@
 TEST(FreeListHeap, AllocationsDontOverlap) {
   constexpr size_t N = 2048;
   constexpr size_t kAllocSize = 512;
-  std::byte buf[N] = {std::byte(0)};
+  alignas(Block) std::byte buf[N] = {std::byte(0)};
 
   FreeListHeapBuffer allocator(buf);
 
@@ -59,7 +59,7 @@
   // and get that value back again.
   constexpr size_t N = 2048;
   constexpr size_t kAllocSize = 512;
-  std::byte buf[N] = {std::byte(0)};
+  alignas(Block) std::byte buf[N] = {std::byte(0)};
 
   FreeListHeapBuffer allocator(buf);
 
@@ -72,7 +72,7 @@
 
 TEST(FreeListHeap, ReturnsNullWhenAllocationTooLarge) {
   constexpr size_t N = 2048;
-  std::byte buf[N] = {std::byte(0)};
+  alignas(Block) std::byte buf[N] = {std::byte(0)};
 
   FreeListHeapBuffer allocator(buf);
 
@@ -81,7 +81,7 @@
 
 TEST(FreeListHeap, ReturnsNullWhenFull) {
   constexpr size_t N = 2048;
-  std::byte buf[N] = {std::byte(0)};
+  alignas(Block) std::byte buf[N] = {std::byte(0)};
 
   FreeListHeapBuffer allocator(buf);
 
@@ -93,7 +93,7 @@
 
 TEST(FreeListHeap, ReturnedPointersAreAligned) {
   constexpr size_t N = 2048;
-  std::byte buf[N] = {std::byte(0)};
+  alignas(Block) std::byte buf[N] = {std::byte(0)};
 
   FreeListHeapBuffer allocator(buf);
 
@@ -119,7 +119,7 @@
   // We can cheat; create a heap, allocate it all, and try and return something
   // random to it. Try allocating again, and check that we get nullptr back.
   constexpr size_t N = 2048;
-  std::byte buf[N] = {std::byte(0)};
+  alignas(Block) std::byte buf[N] = {std::byte(0)};
 
   FreeListHeapBuffer allocator(buf);
 
@@ -146,7 +146,7 @@
   constexpr size_t N = 2048;
   constexpr size_t kAllocSize = 512;
   constexpr size_t kNewAllocSize = 768;
-  std::byte buf[N] = {std::byte(1)};
+  alignas(Block) std::byte buf[N] = {std::byte(1)};
 
   FreeListHeapBuffer allocator(buf);
 
@@ -161,7 +161,7 @@
   constexpr size_t N = 2048;
   constexpr size_t kAllocSize = sizeof(int);
   constexpr size_t kNewAllocSize = sizeof(int) * 2;
-  std::byte buf[N] = {std::byte(1)};
+  alignas(Block) std::byte buf[N] = {std::byte(1)};
   // Data inside the allocated block.
   std::byte data1[kAllocSize];
   // Data inside the reallocated block.
@@ -185,7 +185,7 @@
   constexpr size_t N = 2048;
   constexpr size_t kAllocSize = 512;
   constexpr size_t kNewAllocSize = 256;
-  std::byte buf[N] = {std::byte(0)};
+  alignas(Block) std::byte buf[N] = {std::byte(0)};
 
   FreeListHeapBuffer allocator(buf);
 
@@ -200,7 +200,7 @@
   constexpr size_t N = 2048;
   constexpr size_t kAllocSize = 512;
   constexpr size_t kNewAllocSize = 256;
-  std::byte buf[N] = {std::byte(0)};
+  alignas(Block) std::byte buf[N] = {std::byte(0)};
 
   FreeListHeapBuffer allocator(buf);
 
@@ -215,7 +215,7 @@
   constexpr size_t N = 2048;
   constexpr size_t kAllocSize = 512;
   constexpr size_t kNewAllocSize = 4096;
-  std::byte buf[N] = {std::byte(0)};
+  alignas(Block) std::byte buf[N] = {std::byte(0)};
 
   FreeListHeapBuffer allocator(buf);
 
@@ -232,7 +232,7 @@
   constexpr size_t kAllocSize = 128;
   constexpr size_t kNum = 4;
   constexpr int size = kNum * kAllocSize;
-  std::byte buf[N] = {std::byte(1)};
+  alignas(Block) std::byte buf[N] = {std::byte(1)};
   constexpr std::byte zero{0};
 
   FreeListHeapBuffer allocator(buf);
@@ -251,7 +251,7 @@
   constexpr size_t kAllocSize = 143;
   constexpr size_t kNum = 3;
   constexpr int size = kNum * kAllocSize;
-  std::byte buf[N] = {std::byte(132)};
+  alignas(Block) std::byte buf[N] = {std::byte(132)};
   constexpr std::byte zero{0};
 
   FreeListHeapBuffer allocator(buf);
@@ -268,7 +268,7 @@
 TEST(FreeListHeap, CallocTooLarge) {
   constexpr size_t N = 2048;
   constexpr size_t kAllocSize = 2049;
-  std::byte buf[N] = {std::byte(1)};
+  alignas(Block) std::byte buf[N] = {std::byte(1)};
 
   FreeListHeapBuffer allocator(buf);
 
diff --git a/pw_allocator/freelist_test.cc b/pw_allocator/freelist_test.cc
index 865ea3f..8e0e145 100644
--- a/pw_allocator/freelist_test.cc
+++ b/pw_allocator/freelist_test.cc
@@ -43,7 +43,7 @@
   byte data[kN] = {std::byte(0)};
 
   auto status = list.AddChunk(std::span(data, kN));
-  EXPECT_EQ(status, Status::Ok());
+  EXPECT_EQ(status, OkStatus());
 
   auto item = list.FindChunk(kN);
   EXPECT_EQ(item.size(), kN);
@@ -70,7 +70,7 @@
 
   list.AddChunk(std::span(data, kN));
   auto status = list.RemoveChunk(std::span(data, kN));
-  EXPECT_EQ(status, Status::Ok());
+  EXPECT_EQ(status, OkStatus());
 
   auto item = list.FindChunk(kN);
   EXPECT_EQ(item.size(), static_cast<size_t>(0));
diff --git a/pw_allocator/public/pw_allocator/block.h b/pw_allocator/public/pw_allocator/block.h
index b82dee9..fcea0e0 100644
--- a/pw_allocator/public/pw_allocator/block.h
+++ b/pw_allocator/public/pw_allocator/block.h
@@ -176,22 +176,22 @@
 
   // Mark this block as in-use
   void MarkUsed() {
-    next = reinterpret_cast<Block*>((NextAsUIntPtr() | kInUseFlag));
+    next_ = reinterpret_cast<Block*>((NextAsUIntPtr() | kInUseFlag));
   }
 
   // Mark this block as free
   void MarkFree() {
-    next = reinterpret_cast<Block*>((NextAsUIntPtr() & ~kInUseFlag));
+    next_ = reinterpret_cast<Block*>((NextAsUIntPtr() & ~kInUseFlag));
   }
 
   // Mark this block as the last one in the chain.
   void MarkLast() {
-    next = reinterpret_cast<Block*>((NextAsUIntPtr() | kLastFlag));
+    next_ = reinterpret_cast<Block*>((NextAsUIntPtr() | kLastFlag));
   }
 
   // Clear the "last" bit from this block.
   void ClearLast() {
-    next = reinterpret_cast<Block*>((NextAsUIntPtr() & ~kLastFlag));
+    next_ = reinterpret_cast<Block*>((NextAsUIntPtr() & ~kLastFlag));
   }
 
   // Fetch the block immediately after this one.
@@ -204,7 +204,7 @@
 
   // Return the block immediately before this one. This will return nullptr
   // if this is the "first" block.
-  Block* Prev() const { return prev; }
+  Block* Prev() const { return prev_; }
 
   // Return true if the block is aligned, the prev/next field matches with the
   // previous and next block, and the poisoned bytes is not damaged. Otherwise,
@@ -238,7 +238,7 @@
 
   // Helper to reduce some of the casting nesting in the block management
   // functions.
-  uintptr_t NextAsUIntPtr() const { return reinterpret_cast<uintptr_t>(next); }
+  uintptr_t NextAsUIntPtr() const { return reinterpret_cast<uintptr_t>(next_); }
 
   void PoisonBlock();
   bool CheckPoisonBytes() const;
@@ -248,8 +248,8 @@
   // block, with templated type for the offset size. There are some interesting
   // tradeoffs here; perhaps a pool of small allocations could use 1-byte
   // next/prev offsets to reduce size further.
-  Block* next;
-  Block* prev;
+  Block* next_;
+  Block* prev_;
 };
 
 }  // namespace pw::allocator
diff --git a/pw_allocator/public/pw_allocator/freelist.h b/pw_allocator/public/pw_allocator/freelist.h
index a77f7e4..e10692a 100644
--- a/pw_allocator/public/pw_allocator/freelist.h
+++ b/pw_allocator/public/pw_allocator/freelist.h
@@ -21,7 +21,7 @@
 
 namespace pw::allocator {
 
-template <size_t num_buckets>
+template <size_t kNumBuckets>
 class FreeListBuffer;
 
 // Basic freelist implementation for an allocator.
@@ -84,7 +84,7 @@
   size_t FindChunkPtrForSize(size_t size, bool non_null) const;
 
  private:
-  template <size_t num_buckets>
+  template <size_t kNumBuckets>
   friend class FreeListBuffer;
 
   struct FreeListNode {
@@ -101,21 +101,21 @@
 };
 
 // Holder for FreeList's storage.
-template <size_t num_buckets>
+template <size_t kNumBuckets>
 class FreeListBuffer : public FreeList {
  public:
   // These constructors are a little hacky because of the initialization order.
   // Because FreeList has a trivial constructor, this is safe, however.
   explicit FreeListBuffer(std::initializer_list<size_t> sizes)
-      : FreeList(chunks_, sizes_), sizes_(sizes), chunks_(num_buckets + 1, 0) {}
-  explicit FreeListBuffer(std::array<size_t, num_buckets> sizes)
+      : FreeList(chunks_, sizes_), sizes_(sizes), chunks_(kNumBuckets + 1, 0) {}
+  explicit FreeListBuffer(std::array<size_t, kNumBuckets> sizes)
       : FreeList(chunks_, sizes_),
         sizes_(sizes.begin(), sizes.end()),
-        chunks_(num_buckets + 1, 0) {}
+        chunks_(kNumBuckets + 1, 0) {}
 
  private:
-  Vector<size_t, num_buckets> sizes_;
-  Vector<FreeList::FreeListNode*, num_buckets + 1> chunks_;
+  Vector<size_t, kNumBuckets> sizes_;
+  Vector<FreeList::FreeListNode*, kNumBuckets + 1> chunks_;
 };
 
 }  // namespace pw::allocator
diff --git a/pw_allocator/public/pw_allocator/freelist_heap.h b/pw_allocator/public/pw_allocator/freelist_heap.h
index cdd1aa1..8073ec3 100644
--- a/pw_allocator/public/pw_allocator/freelist_heap.h
+++ b/pw_allocator/public/pw_allocator/freelist_heap.h
@@ -24,7 +24,7 @@
 
 class FreeListHeap {
  public:
-  template <size_t N>
+  template <size_t kNumBuckets>
   friend class FreeListHeapBuffer;
   struct HeapStats {
     size_t total_bytes;
@@ -55,10 +55,10 @@
   HeapStats heap_stats_;
 };
 
-template <size_t N = 6>
+template <size_t kNumBuckets = 6>
 class FreeListHeapBuffer {
  public:
-  static constexpr std::array<size_t, N> defaultBuckets{
+  static constexpr std::array<size_t, kNumBuckets> defaultBuckets{
       16, 32, 64, 128, 256, 512};
 
   FreeListHeapBuffer(std::span<std::byte> region)
@@ -76,7 +76,7 @@
   void LogHeapStats() { heap_.LogHeapStats(); }
 
  private:
-  FreeListBuffer<N> freelist_;
+  FreeListBuffer<kNumBuckets> freelist_;
   FreeListHeap heap_;
 };
 
diff --git a/pw_allocator/py/BUILD.gn b/pw_allocator/py/BUILD.gn
index 2baa9eb..0322cb3 100644
--- a/pw_allocator/py/BUILD.gn
+++ b/pw_allocator/py/BUILD.gn
@@ -23,4 +23,5 @@
     "pw_allocator/heap_viewer.py",
   ]
   python_deps = [ "$dir_pw_cli/py" ]
+  pylintrc = "$dir_pigweed/.pylintrc"
 }
diff --git a/pw_allocator/py/setup.py b/pw_allocator/py/setup.py
index 9625741..1031fc7 100644
--- a/pw_allocator/py/setup.py
+++ b/pw_allocator/py/setup.py
@@ -25,6 +25,6 @@
     package_data={'pw_allocator': ['py.typed']},
     zip_safe=False,
     install_requires=[
-        # 'pw_cli',
+        'pw_cli',
     ],
 )
diff --git a/pw_arduino_build/BUILD.gn b/pw_arduino_build/BUILD.gn
index db72ea8..bbe2003 100644
--- a/pw_arduino_build/BUILD.gn
+++ b/pw_arduino_build/BUILD.gn
@@ -24,7 +24,7 @@
   pw_arduino_build_INIT_BACKEND = ""
 }
 
-if (dir_pw_third_party_arduino != "") {
+if (pw_arduino_build_CORE_PATH != "") {
   pw_facade("arduino_init") {
     backend = pw_arduino_build_INIT_BACKEND
     public = [ "public/pw_arduino_build/init.h" ]
@@ -39,7 +39,7 @@
     deps = [
       ":arduino_init",
       "$dir_pw_sys_io",
-      "$dir_pw_third_party_arduino:arduino_core_sources",
+      "$dir_pw_third_party/arduino:arduino_core_sources",
     ]
     sources = [ "arduino_main_wrapper.cc" ]
   }
diff --git a/pw_arduino_build/arduino.gni b/pw_arduino_build/arduino.gni
index dd1e6f7..0d1704c 100644
--- a/pw_arduino_build/arduino.gni
+++ b/pw_arduino_build/arduino.gni
@@ -16,59 +16,85 @@
 
 declare_args() {
   # Enable/disable Arduino builds via group("arduino").
-  # Set to the full path of ./third_party/arduino
-  dir_pw_third_party_arduino = ""
+  # Set to the full path of where cores are installed.
+  pw_arduino_build_CORE_PATH = ""
 
   # Expected args for an Arduino build:
-  arduino_core_name = "teensy"
+  pw_arduino_build_CORE_NAME = ""
 
   # TODO(tonymd): "teensy/avr" here should match the folders in this dir:
-  # "../third_party/arduino/cores/$arduino_core_name/hardware/*")
+  # "../third_party/arduino/cores/$pw_arduino_build_CORE_NAME/hardware/*")
   # For teensy: "teensy/avr", for adafruit-samd: "samd/1.6.2"
-  arduino_package_name = "teensy/avr"
-  arduino_board = "teensy40"
+  pw_arduino_build_PACKAGE_NAME = ""
+  pw_arduino_build_BOARD = ""
 
   # Menu options should be a list of strings.
-  arduino_menu_options = [
-    "menu.usb.serial",
-    "menu.keys.en-us",
-  ]
+  pw_arduino_build_MENU_OPTIONS = []
 }
 
-arduino_builder_script =
-    get_path_info("py/pw_arduino_build/__main__.py", "abspath")
+if (pw_arduino_build_CORE_PATH != "") {
+  # Check that enough pw_arduino_build_* args are set to find and use a core.
+  _required_args_message =
+      "The following build args must all be set: " +
+      "pw_arduino_build_CORE_PATH, pw_arduino_build_CORE_NAME, " +
+      "pw_arduino_build_PACKAGE_NAME."
+  assert(pw_arduino_build_CORE_NAME != "",
+         "Missing 'pw_arduino_build_CORE_NAME' build arg. " +
+             _required_args_message)
+  assert(pw_arduino_build_PACKAGE_NAME != "",
+         "Missing 'pw_arduino_build_PACKAGE_NAME' build arg. " +
+             _required_args_message)
 
-_arduino_core_path =
-    rebase_path("../third_party/arduino/cores/$arduino_core_name")
-_compiler_path_override =
-    rebase_path(getenv("_PW_ACTUAL_ENVIRONMENT_ROOT") + "/cipd/pigweed/bin")
+  _arduino_selected_core_path =
+      rebase_path("$pw_arduino_build_CORE_PATH/$pw_arduino_build_CORE_NAME")
 
-arduino_global_args = [
-  "--arduino-package-path",
-  _arduino_core_path,
-  "--arduino-package-name",
-  arduino_package_name,
-  "--compiler-path-override",
-  _compiler_path_override,
+  arduino_builder_script =
+      get_path_info("py/pw_arduino_build/__main__.py", "abspath")
 
-  # Save config files to "out/arduino_debug/gen/arduino_builder_config.json"
-  "--config-file",
-  rebase_path(root_gen_dir) + "/arduino_builder_config.json",
-  "--save-config",
-]
+  # Check pw_arduino_build_BOARD is set
+  assert(pw_arduino_build_BOARD != "",
+         "pw_arduino_build_BOARD build arg not set. " +
+             "To see supported boards run: " +
+             "arduino_builder --arduino-package-path " +
+             _arduino_selected_core_path + " --arduino-package-name " +
+             pw_arduino_build_PACKAGE_NAME + " list-boards")
 
-arduino_board_args = [
-  "--build-path",
-  rebase_path(root_build_dir),
-  "--board",
-  arduino_board,
-  "--menu-options",
-]
-arduino_board_args += arduino_menu_options
+  _compiler_path_override =
+      rebase_path(getenv("_PW_ACTUAL_ENVIRONMENT_ROOT") + "/cipd/pigweed/bin")
 
-arduino_show_command_args = arduino_global_args + [
-                              "show",
-                              "--delimit-with-newlines",
-                            ] + arduino_board_args
+  arduino_core_library_path = "$_arduino_selected_core_path/hardware/" +
+                              "$pw_arduino_build_PACKAGE_NAME/libraries"
 
-arduino_run_command_args = arduino_global_args + [ "run" ] + arduino_board_args
+  arduino_global_args = [
+    "--arduino-package-path",
+    _arduino_selected_core_path,
+    "--arduino-package-name",
+    pw_arduino_build_PACKAGE_NAME,
+    "--compiler-path-override",
+    _compiler_path_override,
+
+    # Save config files to "out/arduino_debug/gen/arduino_builder_config.json"
+    "--config-file",
+    rebase_path(root_gen_dir) + "/arduino_builder_config.json",
+    "--save-config",
+  ]
+
+  arduino_board_args = [
+    "--build-path",
+    rebase_path(root_build_dir),
+    "--board",
+    pw_arduino_build_BOARD,
+  ]
+  if (pw_arduino_build_MENU_OPTIONS != []) {
+    arduino_board_args += [ "--menu-options" ]
+    arduino_board_args += pw_arduino_build_MENU_OPTIONS
+  }
+
+  arduino_show_command_args = arduino_global_args + [
+                                "show",
+                                "--delimit-with-newlines",
+                              ] + arduino_board_args
+
+  arduino_run_command_args =
+      arduino_global_args + [ "run" ] + arduino_board_args
+}
diff --git a/pw_arduino_build/py/BUILD.gn b/pw_arduino_build/py/BUILD.gn
index c0f2e00..990f4db 100644
--- a/pw_arduino_build/py/BUILD.gn
+++ b/pw_arduino_build/py/BUILD.gn
@@ -34,5 +34,6 @@
     "builder_test.py",
     "file_operations_test.py",
   ]
+  pylintrc = "$dir_pigweed/.pylintrc"
   python_deps = [ "$dir_pw_cli/py" ]
 }
diff --git a/pw_arduino_build/py/pw_arduino_build/__main__.py b/pw_arduino_build/py/pw_arduino_build/__main__.py
index 4a17072..e6fa77d 100644
--- a/pw_arduino_build/py/pw_arduino_build/__main__.py
+++ b/pw_arduino_build/py/pw_arduino_build/__main__.py
@@ -23,6 +23,7 @@
 import subprocess
 import sys
 from collections import OrderedDict
+from pathlib import Path
 from typing import List
 
 from pw_arduino_build import core_installer, log
@@ -300,7 +301,9 @@
         default=project_source_path,
         help="Project directory. Default: '{}'".format(project_source_path))
     parser.add_argument("--library-path",
-                        default="libraries",
+                        default=[],
+                        nargs="+",
+                        type=str,
                         help="Path to Arduino Library directory.")
     parser.add_argument(
         "--build-project-name",
@@ -416,6 +419,14 @@
             raise argparse.ArgumentTypeError(
                 f'{arg.upper()} is not a valid log level')
 
+    def existing_directory(input_string: str):
+        """Argparse type that resolves to an absolute path."""
+        input_path = Path(os.path.expandvars(input_string)).absolute()
+        if not input_path.exists():
+            raise argparse.ArgumentTypeError(
+                "'{}' is not a valid directory.".format(str(input_path)))
+        return input_path.as_posix()
+
     parser = argparse.ArgumentParser()
     parser.add_argument("-q",
                         "--quiet",
@@ -432,10 +443,12 @@
 
     # Global command line options
     parser.add_argument("--arduino-package-path",
+                        type=existing_directory,
                         help="Path to the arduino IDE install location.")
     parser.add_argument("--arduino-package-name",
                         help="Name of the Arduino board package to use.")
     parser.add_argument("--compiler-path-override",
+                        type=existing_directory,
                         help="Path to arm-none-eabi-gcc bin folder. "
                         "Default: Arduino core specified gcc")
     parser.add_argument("-c", "--config-file", help="Path to a config file.")
diff --git a/pw_arduino_build/py/pw_arduino_build/builder.py b/pw_arduino_build/py/pw_arduino_build/builder.py
index fcd77e4..b19946e 100755
--- a/pw_arduino_build/py/pw_arduino_build/builder.py
+++ b/pw_arduino_build/py/pw_arduino_build/builder.py
@@ -89,10 +89,8 @@
         self.compiler_path_override = compiler_path_override
         self.variant_includes = ""
         self.build_variant_path = False
-        if library_names and library_path:
-            self.library_names = library_names
-            self.library_path = os.path.realpath(
-                os.path.expanduser(os.path.expandvars(library_path)))
+        self.library_names = library_names
+        self.library_path = library_path
 
         self.compiler_path_override_binaries = []
         if self.compiler_path_override:
@@ -135,6 +133,19 @@
             _LOG.error("\n".join(possible_alternatives))
             sys.exit(1)
 
+        # Populate library paths.
+        if not library_path:
+            self.library_path = []
+        # Append core libraries directory.
+        core_lib_path = Path(self.package_path) / "libraries"
+        if core_lib_path.is_dir():
+            self.library_path.append(Path(self.package_path) / "libraries")
+        if library_path:
+            self.library_path = [
+                os.path.realpath(os.path.expanduser(
+                    os.path.expandvars(l_path))) for l_path in library_path
+            ]
+
         # Grab all folder names in the cores directory. These are typically
         # sub-core source files.
         self.sub_core_folders = os.listdir(
@@ -180,7 +191,7 @@
     def _apply_recipe_overrides(self):
         # Override link recipes with per-core exceptions
         # Teensyduino cores
-        if self.build_arch == 'TEENSY':
+        if self.build_arch == "TEENSY":
             # Change {build.path}/{archive_file}
             # To {archive_file_path} (which should contain the core.a file)
             new_link_line = self.platform["recipe.c.combine.pattern"].replace(
@@ -200,7 +211,7 @@
 
         # Adafruit-samd core
         # TODO(tonymd): This build_arch may clash with Arduino-SAMD core
-        elif self.build_arch == 'SAMD':
+        elif self.build_arch == "SAMD":
             new_link_line = self.platform["recipe.c.combine.pattern"].replace(
                 "\"{build.path}/{archive_file}\" -Wl,--end-group",
                 "{archive_file_path} -Wl,--end-group", 1)
@@ -208,7 +219,7 @@
 
         # STM32L4 Core:
         # https://github.com/GrumpyOldPizza/arduino-STM32L4
-        elif self.build_arch == 'STM32L4':
+        elif self.build_arch == "STM32L4":
             # TODO(tonymd): {build.path}/{archive_file} for the link step always
             # seems to be core.a (except STM32 core)
             line_to_delete = "-Wl,--start-group \"{build.path}/{archive_file}\""
@@ -217,8 +228,11 @@
             self.platform["recipe.c.combine.pattern"] = new_link_line
 
         # stm32duino core
-        elif self.build_arch == 'STM32':
-            pass
+        elif self.build_arch == "STM32":
+            # Must link in SrcWrapper for all projects.
+            if not self.library_names:
+                self.library_names = []
+            self.library_names.append("SrcWrapper")
 
     def _copy_default_menu_options_to_build_variables(self):
         # Clear existing options
@@ -967,24 +981,25 @@
         if not self.library_names or not self.library_path:
             return []
 
-        library_path = self.library_path
         folder_patterns = ["*"]
         if self.library_names:
             folder_patterns = self.library_names
 
-        library_folders = file_operations.find_files(library_path,
-                                                     folder_patterns,
-                                                     directories_only=True)
-        library_source_root_folders = []
-        for lib in library_folders:
-            lib_dir = os.path.join(library_path, lib)
-            src_dir = os.path.join(lib_dir, "src")
-            if os.path.exists(src_dir) and os.path.isdir(src_dir):
-                library_source_root_folders.append(src_dir)
-            else:
-                library_source_root_folders.append(lib_dir)
+        library_folders = OrderedDict()
+        for library_dir in self.library_path:
+            found_library_names = file_operations.find_files(
+                library_dir, folder_patterns, directories_only=True)
+            _LOG.debug("Found Libraries %s: %s", library_dir,
+                       found_library_names)
+            for lib_name in found_library_names:
+                lib_dir = os.path.join(library_dir, lib_name)
+                src_dir = os.path.join(lib_dir, "src")
+                if os.path.exists(src_dir) and os.path.isdir(src_dir):
+                    library_folders[lib_name] = src_dir
+                else:
+                    library_folders[lib_name] = lib_dir
 
-        return library_source_root_folders
+        return list(library_folders.values())
 
     def library_include_dirs(self):
         return [Path(lib).as_posix() for lib in self.library_folders()]
@@ -996,9 +1011,13 @@
             include_args.append("-I{}".format(os.path.relpath(lib_dir)))
         return include_args
 
-    def library_files(self, pattern):
+    def library_files(self, pattern, only_library_name=None):
         sources = []
         library_folders = self.library_folders()
+        if only_library_name:
+            library_folders = [
+                lf for lf in self.library_folders() if only_library_name in lf
+            ]
         for lib_dir in library_folders:
             for file_path in file_operations.find_files(lib_dir, [pattern]):
                 if not file_path.startswith("examples"):
diff --git a/pw_arduino_build/py/pw_arduino_build/core_installer.py b/pw_arduino_build/py/pw_arduino_build/core_installer.py
index 5f26834..2d3dbf7 100644
--- a/pw_arduino_build/py/pw_arduino_build/core_installer.py
+++ b/pw_arduino_build/py/pw_arduino_build/core_installer.py
@@ -24,6 +24,7 @@
 import subprocess
 import sys
 import time
+from pathlib import Path
 from typing import Dict, List
 
 import pw_arduino_build.file_operations as file_operations
@@ -46,9 +47,9 @@
                 "sha256": "1b20d0ec850a2a63488009518725f058668bb6cb48c321f82dcf47dc4299b4ad",
             },
             "teensyduino": {
-                "url": "https://www.pjrc.com/teensy/td_154-beta4/TeensyduinoInstall.linux64",
+                "url": "https://www.pjrc.com/teensy/td_153/TeensyduinoInstall.linux64",
+                "sha256": "2e6cd99a757bc80593ea3de006de4cc934bcb0a6ec74cad8ec327f0289d40f0b",
                 "file_name": "TeensyduinoInstall.linux64",
-                "sha256": "76c58babb7253b65a33d73d53f3f239c2e2ccf8602c771d69300a67d82723730",
             },
         },
         # TODO(tonymd): Handle 32-bit Linux Install?
@@ -92,9 +93,9 @@
         },
         "Darwin": {
             "teensyduino": {
-                "url": "https://www.pjrc.com/teensy/td_154-beta4/Teensyduino_MacOS_Catalina.zip",
+                "url": "https://www.pjrc.com/teensy/td_153/Teensyduino_MacOS_Catalina.zip",
                 "file_name": "Teensyduino_MacOS_Catalina.zip",
-                "sha256": "7ca579c12d8f3a8949dbeec812b8dbef13242d575baa707dc7f02bc452c1f4a1",
+                "sha256": "401ef42c6e83e621cdda20191a4ef9b7db8a214bede5a94a9e26b45f79c64fe2",
             },
         },
         "Windows": {
@@ -104,9 +105,11 @@
                 "sha256": "78d3e96827b9e9b31b43e516e601c38d670d29f12483e88cbf6d91a0f89ef524",
             },
             "teensyduino": {
-                "url": "https://www.pjrc.com/teensy/td_154-beta4/TeensyduinoInstall.exe",
-                "file_name": "TeensyduinoInstall.exe",
-                "sha256": "f7bcc2ed45e10a5d7b003bedabcde12fb1b8cf7ef9081e2503cd668569642a90",
+                "url": "https://www.pjrc.com/teensy/td_153/TeensyduinoInstall.exe",
+                # The installer should be named 'Teensyduino.exe' instead of
+                # 'TeensyduinoInstall.exe' to trigger a non-admin installation.
+                "file_name": "Teensyduino.exe",
+                "sha256": "88f58681e5c4772c54e462bc88280320e4276e5b316dcab592fe38d96db990a1",
             },
         }
     },
@@ -115,6 +118,7 @@
             "core": {
                 "version": "1.6.2",
                 "url": "https://github.com/adafruit/ArduinoCore-samd/archive/1.6.2.tar.gz",
+                "file_name": "adafruit-samd-1.6.2.tar.gz",
                 "sha256": "5875f5bc05904c10e6313f02653f28f2f716db639d3d43f5a1d8a83d15339d64",
             }
         },
@@ -127,7 +131,7 @@
             "core": {
                 "version": "1.8.8",
                 "url": "http://downloads.arduino.cc/cores/samd-1.8.8.tar.bz2",
-                "file_name": "samd-1.8.8.tar.bz2",
+                "file_name": "arduino-samd-1.8.8.tar.bz2",
                 "sha256": "7b93eb705cba9125d9ee52eba09b51fb5fe34520ada351508f4253abbc9f27fa",
             }
         },
@@ -140,6 +144,7 @@
             "core": {
                 "version": "1.9.0",
                 "url": "https://github.com/stm32duino/Arduino_Core_STM32/archive/1.9.0.tar.gz",
+                "file_name": "stm32duino-1.9.0.tar.gz",
                 "sha256": "4f75ba7a117d90392e8f67c58d31d22393749b9cdd3279bc21e7261ec06c62bf",
             }
         },
@@ -152,33 +157,38 @@
 
 
 def install_core_command(args: argparse.Namespace):
-    install_prefix = os.path.realpath(
-        os.path.expanduser(os.path.expandvars(args.prefix)))
-    install_dir = os.path.join(install_prefix, args.core_name)
-    cache_dir = os.path.join(install_prefix, ".cache", args.core_name)
+    install_core(args.prefix, args.core_name)
 
-    if args.core_name in supported_cores():
+
+def install_core(prefix, core_name):
+    install_prefix = os.path.realpath(
+        os.path.expanduser(os.path.expandvars(prefix)))
+    install_dir = os.path.join(install_prefix, core_name)
+    cache_dir = os.path.join(install_prefix, ".cache", core_name)
+
+    if core_name in supported_cores():
         shutil.rmtree(install_dir, ignore_errors=True)
         os.makedirs(install_dir, exist_ok=True)
         os.makedirs(cache_dir, exist_ok=True)
 
-    if args.core_name == "teensy":
+    if core_name == "teensy":
         if platform.system() == "Linux":
             install_teensy_core_linux(install_prefix, install_dir, cache_dir)
         elif platform.system() == "Darwin":
             install_teensy_core_mac(install_prefix, install_dir, cache_dir)
         elif platform.system() == "Windows":
             install_teensy_core_windows(install_prefix, install_dir, cache_dir)
-    elif args.core_name == "adafruit-samd":
+        apply_teensy_patches(install_dir)
+    elif core_name == "adafruit-samd":
         install_adafruit_samd_core(install_prefix, install_dir, cache_dir)
-    elif args.core_name == "stm32duino":
+    elif core_name == "stm32duino":
         install_stm32duino_core(install_prefix, install_dir, cache_dir)
-    elif args.core_name == "arduino-samd":
+    elif core_name == "arduino-samd":
         install_arduino_samd_core(install_prefix, install_dir, cache_dir)
     else:
         raise ArduinoCoreNotSupported(
             "Invalid core '{}'. Supported cores: {}".format(
-                args.core_name, ", ".join(supported_cores())))
+                core_name, ", ".join(supported_cores())))
 
 
 def supported_cores():
@@ -200,13 +210,15 @@
     arduino_zipfile = file_operations.download_to_cache(
         url=arduino_artifact["url"],
         expected_sha256sum=arduino_artifact["sha256"],
-        cache_directory=cache_dir)
+        cache_directory=cache_dir,
+        downloaded_file_name=arduino_artifact["file_name"])
 
     teensyduino_artifact = teensy_artifacts["teensyduino"]
     teensyduino_installer = file_operations.download_to_cache(
         url=teensyduino_artifact["url"],
         expected_sha256sum=teensyduino_artifact["sha256"],
-        cache_directory=cache_dir)
+        cache_directory=cache_dir,
+        downloaded_file_name=teensyduino_artifact["file_name"])
 
     file_operations.extract_archive(arduino_zipfile, install_dir, cache_dir)
 
@@ -276,7 +288,8 @@
     teensyduino_zip = file_operations.download_to_cache(
         url=teensyduino_artifact["url"],
         expected_sha256sum=teensyduino_artifact["sha256"],
-        cache_directory=cache_dir)
+        cache_directory=cache_dir,
+        downloaded_file_name=teensyduino_artifact["file_name"])
 
     extracted_files = file_operations.extract_archive(
         teensyduino_zip,
@@ -297,13 +310,15 @@
     arduino_tarfile = file_operations.download_to_cache(
         url=arduino_artifact["url"],
         expected_sha256sum=arduino_artifact["sha256"],
-        cache_directory=cache_dir)
+        cache_directory=cache_dir,
+        downloaded_file_name=arduino_artifact["file_name"])
 
     teensyduino_artifact = teensy_artifacts["teensyduino"]
     teensyduino_installer = file_operations.download_to_cache(
         url=teensyduino_artifact["url"],
         expected_sha256sum=teensyduino_artifact["sha256"],
-        cache_directory=cache_dir)
+        cache_directory=cache_dir,
+        downloaded_file_name=teensyduino_artifact["file_name"])
 
     file_operations.extract_archive(arduino_tarfile, install_dir, cache_dir)
     os.chmod(teensyduino_installer,
@@ -319,13 +334,34 @@
     os.chdir(original_working_dir)
 
 
+def apply_teensy_patches(install_dir):
+    # On Mac the "hardware" directory is a symlink:
+    #   ls -l third_party/arduino/cores/teensy/
+    #   hardware -> Teensyduino.app/Contents/Java/hardware
+    # Resolve paths since `git apply` doesn't work if a path is beyond a
+    # symbolic link.
+    patch_root_path = (Path(install_dir) /
+                       "hardware/teensy/avr/cores").resolve()
+
+    # Get all *.diff files relative to this python file's parent directory.
+    patch_file_paths = sorted(
+        (Path(__file__).parent / "core_patches/teensy").glob("*.diff"))
+
+    # Apply each patch file.
+    for diff_path in patch_file_paths:
+        file_operations.git_apply_patch(patch_root_path.as_posix(),
+                                        diff_path.as_posix(),
+                                        unsafe_paths=True)
+
+
 def install_arduino_samd_core(install_prefix: str, install_dir: str,
                               cache_dir: str):
     artifacts = _ARDUINO_CORE_ARTIFACTS["arduino-samd"]["all"]["core"]
     core_tarfile = file_operations.download_to_cache(
         url=artifacts["url"],
         expected_sha256sum=artifacts["sha256"],
-        cache_directory=cache_dir)
+        cache_directory=cache_dir,
+        downloaded_file_name=artifacts["file_name"])
 
     package_path = os.path.join(install_dir, "hardware", "samd",
                                 artifacts["version"])
@@ -345,7 +381,8 @@
     core_tarfile = file_operations.download_to_cache(
         url=artifacts["url"],
         expected_sha256sum=artifacts["sha256"],
-        cache_directory=cache_dir)
+        cache_directory=cache_dir,
+        downloaded_file_name=artifacts["file_name"])
 
     package_path = os.path.join(install_dir, "hardware", "samd",
                                 artifacts["version"])
@@ -367,7 +404,8 @@
     core_tarfile = file_operations.download_to_cache(
         url=artifacts["url"],
         expected_sha256sum=artifacts["sha256"],
-        cache_directory=cache_dir)
+        cache_directory=cache_dir,
+        downloaded_file_name=artifacts["file_name"])
 
     package_path = os.path.join(install_dir, "hardware", "stm32",
                                 artifacts["version"])
diff --git a/pw_arduino_build/py/pw_arduino_build/core_patches/teensy/01-teensyduino_1.53-cpp17.diff b/pw_arduino_build/py/pw_arduino_build/core_patches/teensy/01-teensyduino_1.53-cpp17.diff
new file mode 100644
index 0000000..0487eaa
--- /dev/null
+++ b/pw_arduino_build/py/pw_arduino_build/core_patches/teensy/01-teensyduino_1.53-cpp17.diff
@@ -0,0 +1,70 @@
+diff --git a/teensy3/WCharacter.h b/teensy3/WCharacter.h
+index 5bfe697..7c500c1 100644
+--- a/teensy3/WCharacter.h
++++ b/teensy3/WCharacter.h
+@@ -61,7 +61,7 @@ inline boolean isAlpha(int c)
+ // that fits into the ASCII character set.
+ inline boolean isAscii(int c)
+ {
+-  return ( isascii (c) == 0 ? false : true);
++  return ((c & ~0x7F) != 0 ? false : true);
+ }
+ 
+ 
+@@ -143,7 +143,7 @@ inline boolean isHexadecimalDigit(int c)
+ // ASCII character set, by clearing the high-order bits.
+ inline int toAscii(int c)
+ {
+-  return toascii (c);
++  return (c & 0x7F);
+ }
+ 
+ 
+diff --git a/teensy3/avr_functions.h b/teensy3/avr_functions.h
+index 977c5e9..55c426c 100644
+--- a/teensy3/avr_functions.h
++++ b/teensy3/avr_functions.h
+@@ -95,7 +95,7 @@ static inline void eeprom_update_block(const void *buf, void *addr, uint32_t len
+ char * ultoa(unsigned long val, char *buf, int radix);
+ char * ltoa(long val, char *buf, int radix);
+ 
+-#if defined(_NEWLIB_VERSION) && (__NEWLIB__ < 2 || __NEWLIB__ == 2 && __NEWLIB_MINOR__ < 2)
++#if defined(__STRICT_ANSI__) || (defined(_NEWLIB_VERSION) && (__NEWLIB__ < 2 || __NEWLIB__ == 2 && __NEWLIB_MINOR__ < 2))
+ static inline char * utoa(unsigned int val, char *buf, int radix) __attribute__((always_inline, unused));
+ static inline char * utoa(unsigned int val, char *buf, int radix) { return ultoa(val, buf, radix); }
+ static inline char * itoa(int val, char *buf, int radix) __attribute__((always_inline, unused));
+diff --git a/teensy4/WCharacter.h b/teensy4/WCharacter.h
+index 5bfe697..7c500c1 100644
+--- a/teensy4/WCharacter.h
++++ b/teensy4/WCharacter.h
+@@ -61,7 +61,7 @@ inline boolean isAlpha(int c)
+ // that fits into the ASCII character set.
+ inline boolean isAscii(int c)
+ {
+-  return ( isascii (c) == 0 ? false : true);
++  return ((c & ~0x7F) != 0 ? false : true);
+ }
+ 
+ 
+@@ -143,7 +143,7 @@ inline boolean isHexadecimalDigit(int c)
+ // ASCII character set, by clearing the high-order bits.
+ inline int toAscii(int c)
+ {
+-  return toascii (c);
++  return (c & 0x7F);
+ }
+ 
+ 
+diff --git a/teensy4/avr_functions.h b/teensy4/avr_functions.h
+index fb6ca11..3b34391 100644
+--- a/teensy4/avr_functions.h
++++ b/teensy4/avr_functions.h
+@@ -97,7 +97,7 @@ static inline void eeprom_update_block(const void *buf, void *addr, uint32_t len
+ char * ultoa(unsigned long val, char *buf, int radix);
+ char * ltoa(long val, char *buf, int radix);
+ 
+-#if defined(_NEWLIB_VERSION) && (__NEWLIB__ < 2 || __NEWLIB__ == 2 && __NEWLIB_MINOR__ < 2)
++#if defined(__STRICT_ANSI__) || (defined(_NEWLIB_VERSION) && (__NEWLIB__ < 2 || __NEWLIB__ == 2 && __NEWLIB_MINOR__ < 2))
+ static inline char * utoa(unsigned int val, char *buf, int radix) __attribute__((always_inline, unused));
+ static inline char * utoa(unsigned int val, char *buf, int radix) { return ultoa(val, buf, radix); }
+ static inline char * itoa(int val, char *buf, int radix) __attribute__((always_inline, unused));
diff --git a/pw_arduino_build/py/pw_arduino_build/core_patches/teensy/02-teensy4_nonstatic_flash_functions.diff b/pw_arduino_build/py/pw_arduino_build/core_patches/teensy/02-teensy4_nonstatic_flash_functions.diff
new file mode 100644
index 0000000..251f550
--- /dev/null
+++ b/pw_arduino_build/py/pw_arduino_build/core_patches/teensy/02-teensy4_nonstatic_flash_functions.diff
@@ -0,0 +1,42 @@
+diff --git a/teensy4/eeprom.c b/teensy4/eeprom.c
+index dde1809..9cdfcd0 100644
+--- a/teensy4/eeprom.c
++++ b/teensy4/eeprom.c
+@@ -54,8 +54,8 @@
+ // Conversation about how this code works & what the upper limits are
+ // https://forum.pjrc.com/threads/57377?p=214566&viewfull=1#post214566
+ 
+-static void flash_write(void *addr, const void *data, uint32_t len);
+-static void flash_erase_sector(void *addr);
++void flash_write(void *addr, const void *data, uint32_t len);
++void flash_erase_sector(void *addr);
+ 
+ static uint8_t initialized=0;
+ static uint16_t sector_index[FLASH_SECTORS];
+@@ -217,7 +217,7 @@ void eeprom_write_block(const void *buf, void *addr, uint32_t len)
+ #define PINS1           FLEXSPI_LUT_NUM_PADS_1
+ #define PINS4           FLEXSPI_LUT_NUM_PADS_4
+ 
+-static void flash_wait()
++void flash_wait()
+ {
+ 	FLEXSPI_LUT60 = LUT0(CMD_SDR, PINS1, 0x05) | LUT1(READ_SDR, PINS1, 1); // 05 = read status
+ 	FLEXSPI_LUT61 = 0;
+@@ -239,7 +239,7 @@ static void flash_wait()
+ }
+ 
+ // write bytes into flash memory (which is already erased to 0xFF)
+-static void flash_write(void *addr, const void *data, uint32_t len)
++void flash_write(void *addr, const void *data, uint32_t len)
+ {
+ 	__disable_irq();
+ 	FLEXSPI_LUTKEY = FLEXSPI_LUTKEY_VALUE;
+@@ -279,7 +279,7 @@ static void flash_write(void *addr, const void *data, uint32_t len)
+ }
+ 
+ // erase a 4K sector
+-static void flash_erase_sector(void *addr)
++void flash_erase_sector(void *addr)
+ {
+ 	__disable_irq();
+ 	FLEXSPI_LUTKEY = FLEXSPI_LUTKEY_VALUE;
diff --git a/pw_arduino_build/py/pw_arduino_build/file_operations.py b/pw_arduino_build/py/pw_arduino_build/file_operations.py
index 61b728d..eb5fa41 100644
--- a/pw_arduino_build/py/pw_arduino_build/file_operations.py
+++ b/pw_arduino_build/py/pw_arduino_build/file_operations.py
@@ -21,6 +21,7 @@
 import os
 import shutil
 import sys
+import subprocess
 import tarfile
 import urllib.request
 import zipfile
@@ -77,21 +78,36 @@
         raise InvalidChecksumError(
             f"Invalid {sum_function.__name__}\n"
             f"{downloaded_checksum} {os.path.basename(file_path)}\n"
-            f"{expected_checksum} (expected)")
+            f"{expected_checksum} (expected)\n\n"
+            "Please delete this file and try again:\n"
+            f"{file_path}")
 
     _LOG.debug("  %s:", sum_function.__name__)
     _LOG.debug("  %s %s", downloaded_checksum, os.path.basename(file_path))
     return True
 
 
+def relative_or_absolute_path(file_string: str):
+    """Return a Path relative to os.getcwd(), else an absolute path."""
+    file_path = Path(file_string)
+    try:
+        return file_path.relative_to(os.getcwd())
+    except ValueError:
+        return file_path.resolve()
+
+
 def download_to_cache(url: str,
                       expected_md5sum=None,
                       expected_sha256sum=None,
-                      cache_directory=".cache") -> str:
+                      cache_directory=".cache",
+                      downloaded_file_name=None) -> str:
 
     cache_dir = os.path.realpath(
         os.path.expanduser(os.path.expandvars(cache_directory)))
-    downloaded_file = os.path.join(cache_dir, url.split("/")[-1])
+    if not downloaded_file_name:
+        # Use the last part of the URL as the file name.
+        downloaded_file_name = url.split("/")[-1]
+    downloaded_file = os.path.join(cache_dir, downloaded_file_name)
 
     if not os.path.exists(downloaded_file):
         _LOG.info("Downloading: %s", url)
@@ -99,8 +115,7 @@
         urllib.request.urlretrieve(url, filename=downloaded_file)
 
     if os.path.exists(downloaded_file):
-        _LOG.info("Downloaded: %s",
-                  Path(downloaded_file).relative_to(os.getcwd()))
+        _LOG.info("Downloaded: %s", relative_or_absolute_path(downloaded_file))
         if expected_sha256sum:
             verify_file_checksum(downloaded_file,
                                  expected_sha256sum,
@@ -148,7 +163,7 @@
                                     "." + os.path.basename(archive_file))
     os.makedirs(temp_extract_dir, exist_ok=True)
 
-    _LOG.info("Extracting: %s", Path(archive_file).relative_to(os.getcwd()))
+    _LOG.info("Extracting: %s", relative_or_absolute_path(archive_file))
     if zipfile.is_zipfile(archive_file):
         extract_zipfile(archive_file, temp_extract_dir)
     elif tarfile.is_tarfile(archive_file):
@@ -157,7 +172,7 @@
         _LOG.error("Unknown archive format: %s", archive_file)
         return sys.exit(1)
 
-    _LOG.info("Installing into: %s", Path(dest_dir).relative_to(os.getcwd()))
+    _LOG.info("Installing into: %s", relative_or_absolute_path(dest_dir))
     path_to_extracted_files = temp_extract_dir
 
     extracted_top_level_files = os.listdir(temp_extract_dir)
@@ -210,3 +225,19 @@
         _LOG.warning("Unable to read file '%s'", file_path)
 
     return json_file_options, file_path
+
+
+def git_apply_patch(root_directory,
+                    patch_file,
+                    ignore_whitespace=True,
+                    unsafe_paths=False):
+    """Use `git apply` to apply a diff file."""
+
+    _LOG.info("Applying Patch: %s", patch_file)
+    git_apply_command = ["git", "apply"]
+    if ignore_whitespace:
+        git_apply_command.append("--ignore-whitespace")
+    if unsafe_paths:
+        git_apply_command.append("--unsafe-paths")
+    git_apply_command += ["--directory", root_directory, patch_file]
+    subprocess.run(git_apply_command)
diff --git a/pw_arduino_build/py/pw_arduino_build/unit_test_server.py b/pw_arduino_build/py/pw_arduino_build/unit_test_server.py
index 0fafd71..edbae1d 100644
--- a/pw_arduino_build/py/pw_arduino_build/unit_test_server.py
+++ b/pw_arduino_build/py/pw_arduino_build/unit_test_server.py
@@ -21,6 +21,7 @@
 from typing import IO, List, Optional
 
 import pw_cli.process
+
 import pw_arduino_build.log
 from pw_arduino_build import teensy_detector
 from pw_arduino_build.file_operations import decode_file_json
diff --git a/pw_arduino_build/py/setup.py b/pw_arduino_build/py/setup.py
index 13af5fc..528ca95 100644
--- a/pw_arduino_build/py/setup.py
+++ b/pw_arduino_build/py/setup.py
@@ -22,7 +22,13 @@
     author_email='pigweed-developers@googlegroups.com',
     description='Target-specific python scripts for the arduino target',
     packages=setuptools.find_packages(),
-    package_data={'pw_arduino_build': ['py.typed']},
+    package_data={
+        'pw_arduino_build': [
+            'core_patches/teensy/01-teensyduino_1.53-cpp17.diff',
+            'core_patches/teensy/02-teensy4_nonstatic_flash_functions.diff',
+            'py.typed',
+        ]
+    },
     zip_safe=False,
     entry_points={
         'console_scripts': [
@@ -37,7 +43,7 @@
         ]
     },
     install_requires=[
-        'pyserial',
+        'pyserial>=3.5,<4.0',
         'coloredlogs',
         'parameterized',
     ])
diff --git a/pw_assert/BUILD b/pw_assert/BUILD
index 6b799f1..58ce601 100644
--- a/pw_assert/BUILD
+++ b/pw_assert/BUILD
@@ -1,4 +1,4 @@
-# Copyright 2020 The Pigweed Authors
+# Copyright 2021 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
@@ -29,9 +29,11 @@
     name = "facade",
     hdrs = [
         "public/pw_assert/assert.h",
+        "public/pw_assert/check.h",
+        "public/pw_assert/internal/check_impl.h",
         "public/pw_assert/light.h",
         "public/pw_assert/options.h",
-        "public/pw_assert/internal/assert_impl.h",
+        "public/pw_assert/short.h",
     ],
     includes = ["public"],
     deps = [
@@ -45,6 +47,7 @@
     deps = [
         ":facade",
         PW_ASSERT_BACKEND + ":headers",
+        PW_ASSERT_BACKEND,
     ],
 )
 
@@ -70,14 +73,15 @@
         "//pw_span",
         "//pw_string",
         "//pw_unit_test",
+        PW_ASSERT_BACKEND,
     ],
 )
 
 pw_cc_test(
     name = "assert_backend_compile_test",
     srcs = [
-        "assert_backend_compile_test_c.c",
         "assert_backend_compile_test.cc",
+        "assert_backend_compile_test_c.c",
     ],
     deps = [
         ":backend",
diff --git a/pw_assert/BUILD.gn b/pw_assert/BUILD.gn
index 084aa00..5e83da1 100644
--- a/pw_assert/BUILD.gn
+++ b/pw_assert/BUILD.gn
@@ -31,15 +31,21 @@
   backend = pw_assert_BACKEND
   public_configs = [ ":default_config" ]
   public = [
-    "public/pw_assert/assert.h",
-    "public/pw_assert/internal/assert_impl.h",
+    "public/pw_assert/check.h",
+    "public/pw_assert/internal/check_impl.h",
+    "public/pw_assert/short.h",
   ]
   public_deps = [
     dir_pw_preprocessor,
 
-    # Also expose light.h to all users of pw_assert.
+    # Also expose assert.h to all users of pw_assert.
     ":light",
   ]
+
+  # TODO(pwbug/350): Allow assert.h to include check.h for backwards
+  #     compatibility. Remove this when projects have migrated.
+  allow_circular_includes_from = [ ":light" ]
+  deps = [ ":light" ]
 }
 
 # Provide a way include "pw_assert/light.h" without depending on the full
@@ -51,6 +57,7 @@
 pw_source_set("light") {
   public_configs = [ ":default_config" ]
   public = [
+    "public/pw_assert/assert.h",
     "public/pw_assert/light.h",
 
     # Needed for PW_ASSERT_ENABLE_DEBUG. Note that depending on :pw_assert to
@@ -85,7 +92,7 @@
   sources = [
     "assert_facade_test.cc",
     "fake_backend.cc",
-    "public/pw_assert/internal/assert_impl.h",
+    "public/pw_assert/internal/check_impl.h",
     "pw_assert_test/fake_backend.h",
   ]
   deps = [
diff --git a/pw_assert/assert_backend_compile_test.cc b/pw_assert/assert_backend_compile_test.cc
index 6e1ac2e..5a98b15 100644
--- a/pw_assert/assert_backend_compile_test.cc
+++ b/pw_assert/assert_backend_compile_test.cc
@@ -216,10 +216,10 @@
   CHECK_OK(status, "msg: %d", 5);
 
   // Status from a literal.
-  PW_CHECK_OK(pw::Status::Ok());
+  PW_CHECK_OK(pw::OkStatus());
 
   // Status from a function.
-  PW_CHECK_OK(MakeStatus(pw::Status::Ok()));
+  PW_CHECK_OK(MakeStatus(pw::OkStatus()));
 
   // Status from C enums.
   PW_CHECK_OK(PW_STATUS_OK);
diff --git a/pw_assert/assert_backend_compile_test_c.c b/pw_assert/assert_backend_compile_test_c.c
index c602f37..a4aaced 100644
--- a/pw_assert/assert_backend_compile_test_c.c
+++ b/pw_assert/assert_backend_compile_test_c.c
@@ -25,7 +25,7 @@
 
 #include "pw_assert/assert.h"
 
-static void EnsureNullIsIncluded() {
+static void EnsureNullIsIncluded(void) {
   // This is a compile check to ensure NULL is defined. It comes before the
   // status.h include to ensure we don't accidentally get NULL from status.h.
   PW_CHECK_NOTNULL(0xa);
@@ -64,7 +64,7 @@
 
 static int Add3(int a, int b, int c) { return a + b + c; }
 
-void AssertBackendCompileTestsInC() {
+void AssertBackendCompileTestsInC(void) {
   {  // TEST(Crash, WithAndWithoutMessageArguments)
     MAYBE_SKIP_TEST;
     PW_CRASH(FAIL_IF_HIDDEN);
diff --git a/pw_assert/assert_facade_test.cc b/pw_assert/assert_facade_test.cc
index 083d206..088f725 100644
--- a/pw_assert/assert_facade_test.cc
+++ b/pw_assert/assert_facade_test.cc
@@ -22,8 +22,7 @@
 // assert backend from triggering.
 //
 // clang-format off
-#define PW_ASSERT_USE_SHORT_NAMES 1
-#include "pw_assert/internal/assert_impl.h"
+#include "pw_assert/internal/check_impl.h"
 // clang-format on
 
 #include "gtest/gtest.h"
@@ -453,43 +452,23 @@
 }
 #endif  // PW_ASSERT_ENABLE_DEBUG
 
-// Note: This requires enabling PW_ASSERT_USE_SHORT_NAMES 1 above.
-TEST(Check, ShortNamesWork) {
-  // Crash
-  CRASH("msg");
-  CRASH("msg: %d", 5);
-
-  // Check
-  CHECK(true);
-  CHECK(true, "msg");
-  CHECK(true, "msg: %d", 5);
-  CHECK(false);
-  CHECK(false, "msg");
-  CHECK(false, "msg: %d", 5);
-
-  // Check with binary comparison
-  CHECK_INT_LE(1, 2);
-  CHECK_INT_LE(1, 2, "msg");
-  CHECK_INT_LE(1, 2, "msg: %d", 5);
-}
-
 // Verify PW_CHECK_OK, including message handling.
 TEST_F(AssertFail, StatusNotOK) {
   pw::Status status = pw::Status::Unknown();
   PW_CHECK_OK(status);
-  EXPECT_MESSAGE("Check failed: status (=UNKNOWN) == Status::OK (=OK). ");
+  EXPECT_MESSAGE("Check failed: status (=UNKNOWN) == OkStatus() (=OK). ");
 }
 
 TEST_F(AssertFail, StatusNotOKMessageNoArguments) {
   pw::Status status = pw::Status::Unknown();
   PW_CHECK_OK(status, "msg");
-  EXPECT_MESSAGE("Check failed: status (=UNKNOWN) == Status::OK (=OK). msg");
+  EXPECT_MESSAGE("Check failed: status (=UNKNOWN) == OkStatus() (=OK). msg");
 }
 
 TEST_F(AssertFail, StatusNotOKMessageArguments) {
   pw::Status status = pw::Status::Unknown();
   PW_CHECK_OK(status, "msg: %d", 5);
-  EXPECT_MESSAGE("Check failed: status (=UNKNOWN) == Status::OK (=OK). msg: 5");
+  EXPECT_MESSAGE("Check failed: status (=UNKNOWN) == OkStatus() (=OK). msg: 5");
 }
 
 // Example expression for the test below.
@@ -498,13 +477,13 @@
 TEST_F(AssertFail, NonTrivialExpression) {
   PW_CHECK_OK(DoTheThing());
   EXPECT_MESSAGE(
-      "Check failed: DoTheThing() (=RESOURCE_EXHAUSTED) == Status::OK (=OK). ");
+      "Check failed: DoTheThing() (=RESOURCE_EXHAUSTED) == OkStatus() (=OK). ");
 }
 
 // Note: This function seems pointless but it is not, since pw::Status::FOO
 // constants are not actually status objects, but code objects. This way we can
 // ensure the macros work with both real status objects and literals.
-TEST_F(AssertPass, Function) { PW_CHECK_OK(pw::Status::Ok()); }
+TEST_F(AssertPass, Function) { PW_CHECK_OK(pw::OkStatus()); }
 TEST_F(AssertPass, Enum) { PW_CHECK_OK(PW_STATUS_OK); }
 TEST_F(AssertFail, Function) { PW_CHECK_OK(pw::Status::Unknown()); }
 TEST_F(AssertFail, Enum) { PW_CHECK_OK(PW_STATUS_UNKNOWN); }
@@ -512,14 +491,14 @@
 #if PW_ASSERT_ENABLE_DEBUG
 
 // In debug mode, the asserts should check their arguments.
-TEST_F(AssertPass, DCheckFunction) { PW_DCHECK_OK(pw::Status::Ok()); }
+TEST_F(AssertPass, DCheckFunction) { PW_DCHECK_OK(pw::OkStatus()); }
 TEST_F(AssertPass, DCheckEnum) { PW_DCHECK_OK(PW_STATUS_OK); }
 TEST_F(AssertFail, DCheckFunction) { PW_DCHECK_OK(pw::Status::Unknown()); }
 TEST_F(AssertFail, DCheckEnum) { PW_DCHECK_OK(PW_STATUS_UNKNOWN); }
 #else  // PW_ASSERT_ENABLE_DEBUG
 
 // In release mode, all the asserts should pass.
-TEST_F(AssertPass, DCheckFunction_Ok) { PW_DCHECK_OK(pw::Status::Ok()); }
+TEST_F(AssertPass, DCheckFunction_Ok) { PW_DCHECK_OK(pw::OkStatus()); }
 TEST_F(AssertPass, DCheckEnum_Ok) { PW_DCHECK_OK(PW_STATUS_OK); }
 TEST_F(AssertPass, DCheckFunction_Err) { PW_DCHECK_OK(pw::Status::Unknown()); }
 TEST_F(AssertPass, DCheckEnum_Err) { PW_DCHECK_OK(PW_STATUS_UNKNOWN); }
diff --git a/pw_assert/docs.rst b/pw_assert/docs.rst
index 2d96c60..0cd72cf 100644
--- a/pw_assert/docs.rst
+++ b/pw_assert/docs.rst
@@ -25,11 +25,11 @@
   a message.
 - **PW_CHECK_<type>_<cmp>(a, b[, fmt, ...])** - Assert that the expression ``a
   <cmp> b`` is true, optionally with a message.
-- **PW_ASSERT(condition)** - Header- and constexpr- assert.
+- **PW_ASSERT(condition)** - Header- and constexpr-safe assert.
 
 .. tip::
 
-  All of the assert macros optionally support a message with additional
+  All of the ``CHECK`` macros optionally support a message with additional
   arguments, to assist in debugging when an assert triggers:
 
   .. code-block:: cpp
@@ -42,7 +42,7 @@
 
 .. code-block:: cpp
 
-  #include "pw_assert/assert.h"
+  #include "pw_assert/check.h"
 
   int main() {
     bool sensor_running = StartSensor(&msg);
@@ -72,7 +72,7 @@
 
 .. tip::
 
-  Use ``PW_ASSERT`` from ``pw_assert/light.h`` for asserts in headers or
+  Use ``PW_ASSERT`` from ``pw_assert/assert.h`` for asserts in headers or
   asserting in ``constexpr`` contexts.
 
 Structure of assert modules
@@ -102,9 +102,8 @@
 ----------
 Facade API
 ----------
-
 The below functions describe the assert API functions that applications should
-invoke to assert. These macros found in the ``pw_assert/assert.h`` header.
+invoke to assert. These macros are found in the ``pw_assert/check.h`` header.
 
 .. cpp:function:: PW_CRASH(format, ...)
 
@@ -149,10 +148,9 @@
     tokenizing assert backend. For example, if ``x`` and ``b`` are integers,
     use instead ``PW_CHECK_INT_LT(x, b)``.
 
-    Additionally, use ``PW_CHECK_OK(status)`` when checking for a
-    ``Status::OK``, since it enables showing a human-readable status string
-    rather than an integer (e.g. ``status == RESOURCE_EXHAUSTED`` instead of
-    ``status == 5``.
+    Additionally, use ``PW_CHECK_OK(status)`` when checking for an OK status,
+    since it enables showing a human-readable status string rather than an
+    integer (e.g. ``status == RESOURCE_EXHAUSTED`` instead of ``status == 5``.
 
     +------------------------------------+-------------------------------------+
     | **Do NOT do this**                 | **Do this instead**                 |
@@ -164,7 +162,7 @@
     | ``PW_CHECK(Temp() <= 10.0)``       | ``PW_CHECK_FLOAT_EXACT_LE(``        |
     |                                    | ``    Temp(), 10.0)``               |
     +------------------------------------+-------------------------------------+
-    | ``PW_CHECK(Foo() == Status::OK)``  | ``PW_CHECK_OK(Foo())``              |
+    | ``PW_CHECK(Foo() == OkStatus())``  | ``PW_CHECK_OK(Foo())``              |
     +------------------------------------+-------------------------------------+
 
 .. cpp:function:: PW_CHECK_NOTNULL(ptr)
@@ -367,7 +365,7 @@
 .. cpp:function:: PW_DCHECK_OK(status)
 .. cpp:function:: PW_DCHECK_OK(status, format, ...)
 
-  Assert that ``status`` evaluates to ``pw::Status::OK`` (in C++) or
+  Assert that ``status`` evaluates to ``pw::OkStatus()`` (in C++) or
   ``PW_STATUS_OK`` (in C). Optionally include a message with arguments to
   report.
 
@@ -388,14 +386,14 @@
 
   .. note::
 
-    Using ``PW_CHECK_OK(status)`` instead of ``PW_CHECK(status == Status::OK)``
+    Using ``PW_CHECK_OK(status)`` instead of ``PW_CHECK(status == OkStatus())``
     enables displaying an error message with a string version of the error
     code; for example ``status == RESOURCE_EXHAUSTED`` instead of ``status ==
     5``.
 
----------
-Light API
----------
+----------
+Assert API
+----------
 The normal ``PW_CHECK_*`` and ``PW_DCHECK_*`` family of macros are intended to
 provide rich debug information, like the file, line number, value of operands
 in boolean comparisons, and more. However, this comes at a cost: these macros
@@ -412,12 +410,11 @@
 4. ``PW_CHECK_*`` can trigger circular dependencies when asserts are used from
    low-level contexts, like in ``<span>``.
 
-**Light asserts** solve all of the above three problems: No risk of ODR
-violations, are constexpr safe, and have a tiny call site footprint; and there
-is no header dependency on the backend preventing circular include issues.
-However, there are **no format messages, no captured line number, no captured
-file, no captured expression, or anything other than a binary indication of
-failure**.
+**PW_ASSERT** solves all of the above problems: No risk of ODR violations, are
+constexpr safe, and have a tiny call site footprint; and there is no header
+dependency on the backend preventing circular include issues.  However, there
+are **no format messages, no captured line number, no captured file, no captured
+expression, or anything other than a binary indication of failure**.
 
 Example
 -------
@@ -426,7 +423,7 @@
 
   // This example demonstrates asserting in a header.
 
-  #include "pw_assert/light.h"
+  #include "pw_assert/assert.h"
 
   class InlinedSubsystem {
    public:
@@ -442,8 +439,8 @@
     }
   };
 
-Light API reference
--------------------
+PW_ASSERT API reference
+-----------------------
 .. cpp:function:: PW_ASSERT(condition)
 
   A header- and constexpr-safe version of ``PW_CHECK()``.
@@ -464,15 +461,16 @@
   Unlike the ``PW_CHECK_*()`` suite of macros, ``PW_ASSERT()`` and
   ``PW_DASSERT()`` capture no rich information like line numbers, the file,
   expression arguments, or the stringified expression. Use these macros **only
-  when absolutely necessary**--in headers, constexr contexts, or in rare cases
+  when absolutely necessary**---in headers, constexpr contexts, or in rare cases
   where the call site overhead of a full PW_CHECK must be avoided.
 
   Use ``PW_CHECK_*()`` whenever possible.
 
-Light API backend
------------------
-The light API ultimately calls the C function ``pw_assert_HandleFailure()``,
-which must be provided by the assert backend.
+PW_ASSERT API backend
+---------------------
+The ``PW_ASSERT`` API ultimately calls the C function
+``pw_assert_HandleFailure()``, which must be provided by the ``pw_assert``
+backend.
 
 -----------
 Backend API
@@ -548,14 +546,14 @@
     See :ref:`module-pw_assert_basic` for one way to combine these arguments
     into a meaningful error message.
 
-Additionally, the backend must provide a link-time function for the light
-assert handler. This does not need to appear in the backend header, but instead
-is in a ``.cc`` file.
+Additionally, the backend must provide a link-time function for the
+``PW_ASSERT`` assert handler. This does not need to appear in the backend
+header, but instead is in a ``.cc`` file.
 
 .. cpp:function:: pw_assert_HandleFailure()
 
   Handle a low-level crash. This crash entry happens through
-  ``pw_assert/light.h``. In this crash handler, there is no access to line,
+  ``pw_assert/assert.h``. In this crash handler, there is no access to line,
   file, expression, or other rich assert information. Backends should do
   something reasonable in this case; typically, capturing the stack is useful.
 
diff --git a/pw_assert/public/pw_assert/assert.h b/pw_assert/public/pw_assert/assert.h
index 2a4c999..a6756a6 100644
--- a/pw_assert/public/pw_assert/assert.h
+++ b/pw_assert/public/pw_assert/assert.h
@@ -11,104 +11,52 @@
 // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 // License for the specific language governing permissions and limitations under
 // the License.
-// =============================================================================
-//
-// This file describes Pigweed's public user-facing assert API.
-//
-// THIS API IS NOT STABLE OR COMPLETE! NEITHER FACADE NOR BACKEND API!
-//
 #pragma once
 
-#include "pw_preprocessor/arguments.h"
+#include "pw_assert/options.h"  // For PW_ASSERT_ENABLE_DEBUG
+#include "pw_preprocessor/util.h"
 
-// The pw_assert public API:
-//
-//   Trigger a crash with a message. Replaces LOG_FATAL() in other systems.
-//   PW_CRASH(msg, ...)
-//
-//   In all below cases, the message argument is optional:
-//   PW_CHECK_INT_LE(x, y) or
-//   PW_CHECK_INT_LE(x, y, "Was booting %s subsystem", subsystem_name)
-//
-//   Asserts the condition, crashes on failure. Equivalent to assert.
-//   PW_CHECK(condition) or
-//   PW_CHECK(condition, msg, ...)
-//
-//   Some common typed checks.
-//   PW_CHECK_OK(status, msg, ...)  Asserts status == PW_STATUS_OK
-//   PW_CHECK_NOTNULL(ptr, msg, ...)  Asserts ptr != NULL
-//
-//   In many cases an assert is a binary comparison. In those cases, using the
-//   special binary assert macros below for <, <=, >, >=, == enables reporting
-//   the values of the operands in addition to the string of the condition.
-//
-//   Binary comparison asserts for 'int' type ("%d" in format strings):
-//   PW_CHECK_INT_LE(a, b, msg, ...)  Asserts a <= b
-//   PW_CHECK_INT_LT(a, b, msg, ...)  Asserts a <  b
-//   PW_CHECK_INT_GE(a, b, msg, ...)  Asserts a >= b
-//   PW_CHECK_INT_GT(a, b, msg, ...)  Asserts a >  b
-//   PW_CHECK_INT_EQ(a, b, msg, ...)  Asserts a == b
-//   PW_CHECK_INT_NE(a, b, msg, ...)  Asserts a != b
-//
-//   Binary comparison asserts for 'unsigned int' type ("%u" in format strings):
-//   PW_CHECK_UINT_LE(a, b, msg, ...)  Asserts a <= b
-//   PW_CHECK_UINT_LT(a, b, msg, ...)  Asserts a <  b
-//   PW_CHECK_UINT_GE(a, b, msg, ...)  Asserts a >= b
-//   PW_CHECK_UINT_GT(a, b, msg, ...)  Asserts a >  b
-//   PW_CHECK_UINT_EQ(a, b, msg, ...)  Asserts a == b
-//   PW_CHECK_UINT_NE(a, b, msg, ...)  Asserts a != b
-//
-//   Binary comparison asserts for 'void*' type ("%p" in format strings):
-//   PW_CHECK_PTR_LE(a, b, msg, ...)  Asserts a <= b
-//   PW_CHECK_PTR_LT(a, b, msg, ...)  Asserts a <  b
-//   PW_CHECK_PTR_GE(a, b, msg, ...)  Asserts a >= b
-//   PW_CHECK_PTR_GT(a, b, msg, ...)  Asserts a >  b
-//   PW_CHECK_PTR_EQ(a, b, msg, ...)  Asserts a == b
-//   PW_CHECK_PTR_NE(a, b, msg, ...)  Asserts a != b
-//
-//   Binary comparison asserts for 'float' type ("%f" in format strings):
-//   PW_CHECK_FLOAT_NEAR(a, b, abs_tolerance, msg, ...)
-//     Asserts (a >= (b - abs_tolerance)) && (a <= (b + abs_tolerance))
-//   PW_CHECK_FLOAT_EXACT_LE(a, b, msg, ...)  Asserts a <= b
-//   PW_CHECK_FLOAT_EXACT_LT(a, b, msg, ...)  Asserts a <  b
-//   PW_CHECK_FLOAT_EXACT_GE(a, b, msg, ...)  Asserts a >= b
-//   PW_CHECK_FLOAT_EXACT_GT(a, b, msg, ...)  Asserts a >  b
-//   PW_CHECK_FLOAT_EXACT_EQ(a, b, msg, ...)  Asserts a == b
-//   PW_CHECK_FLOAT_EXACT_NE(a, b, msg, ...)  Asserts a != b
-//
-//   The above CHECK_*_*() are also available in DCHECK variants, which will
-//   only evaluate their arguments and trigger if the NDEBUG macro is defined.
-//
-//   Note: For float, proper comparator checks which take floating point
-//   precision and ergo error accumulation into account are not provided on
-//   purpose as this comes with some complexity and requires application
-//   specific tolerances in terms of Units of Least Precision (ULP). Instead,
-//   we recommend developers carefully consider how floating point precision and
-//   error impact the data they are bounding and whether CHECKs are appropriate.
-//
-//   Note: PW_CRASH is the equivalent of LOG_FATAL in other systems, where a
-//   device crash is triggered with a message. In Pigweed, logging and
-//   crashing/asserting are separated. There is a LOG_CRITICAL level in the
-//   logging module, but it does not have side effects; for LOG_FATAL, instead
-//   use this macro (PW_CRASH).
-//
-// The public macro definitions are split out into an impl file to facilitate
-// testing the facade logic directly, without going through the facade/backend
-// build facilities.
-#include "pw_assert/internal/assert_impl.h"
+// For backwards compatibility, include check.h from assert.h.
+// TODO(pwbug/350): Remove this include when projects have migrated.
+#include "pw_assert/check.h"
 
-// The pw_assert_backend must provide these macros:
+PW_EXTERN_C_START
+
+void pw_assert_HandleFailure(void);
+
+PW_EXTERN_C_END
+
+// A header- and constexpr-safe version of PW_CHECK().
 //
-//   PW_HANDLE_CRASH(msg, ...)
-//   PW_HANDLE_ASSERT_FAILURE(condition, msg, ...)
-//   PW_HANDLE_ASSERT_BINARY_COMPARE_FAILURE(a, op, b, type_fmt, msg, ...)
+// If the given condition is false, crash the system. Otherwise, do nothing.
+// The condition is guaranteed to be evaluated. This assert implementation is
+// guaranteed to be constexpr-safe.
 //
-//   The low level functionality of triggering a crash, rebooting a device,
-//   collecting information, or hanging out in a while(1) loop, must be
-//   provided by the underlying assert backend as part of the crash or assert
-//   failure handling.
+// IMPORTANT: Unlike the PW_CHECK_*() suite of macros, this API captures no
+// rich information like line numbers, the file, expression arguments, or the
+// stringified expression. Use these macros only when absolutely necessary --
+// in headers, constexr contexts, or in rare cases where the call site overhead
+// of a full PW_CHECK must be avoided. Use PW_CHECK_*() whenever possible.
+#define PW_ASSERT(condition)     \
+  do {                           \
+    if (!(condition)) {          \
+      pw_assert_HandleFailure(); \
+    }                            \
+  } while (0)
+
+// A header- and constexpr-safe version of PW_DCHECK().
 //
-//   Note that for the assert failures, the handler should assume the assert
-//   has already failed (the facade checks the condition before delegating).
+// Same as PW_ASSERT(), except that if PW_ASSERT_ENABLE_DEBUG == 1, the assert
+// is disabled and condition is not evaluated.
 //
-#include "pw_assert_backend/assert_backend.h"
+// IMPORTANT: Unlike the PW_CHECK_*() suite of macros, this API captures no
+// rich information like line numbers, the file, expression arguments, or the
+// stringified expression. Use these macros only when absolutely necessary --
+// in headers, constexr contexts, or in rare cases where the call site overhead
+// of a full PW_CHECK must be avoided. Use PW_DCHECK_*() whenever possible.
+#define PW_DASSERT(condition)                            \
+  do {                                                   \
+    if ((PW_ASSERT_ENABLE_DEBUG == 1) && !(condition)) { \
+      pw_assert_HandleFailure();                         \
+    }                                                    \
+  } while (0)
diff --git a/pw_assert/public/pw_assert/check.h b/pw_assert/public/pw_assert/check.h
new file mode 100644
index 0000000..1cb4eb7
--- /dev/null
+++ b/pw_assert/public/pw_assert/check.h
@@ -0,0 +1,121 @@
+// 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.
+// =============================================================================
+//
+// This file describes Pigweed's public user-facing assert API.
+//
+// THIS API IS NOT STABLE OR COMPLETE! NEITHER FACADE NOR BACKEND API!
+//
+#pragma once
+
+#include "pw_preprocessor/arguments.h"
+
+// The pw_assert public API:
+//
+//   Trigger a crash with a message. Replaces LOG_FATAL() in other systems.
+//   PW_CRASH(msg, ...)
+//
+//   In all below cases, the message argument is optional:
+//   PW_CHECK_INT_LE(x, y) or
+//   PW_CHECK_INT_LE(x, y, "Was booting %s subsystem", subsystem_name)
+//
+//   Asserts the condition, crashes on failure. Equivalent to assert.
+//   PW_CHECK(condition) or
+//   PW_CHECK(condition, msg, ...)
+//
+//   Some common typed checks.
+//   PW_CHECK_OK(status, msg, ...)  Asserts status == PW_STATUS_OK
+//   PW_CHECK_NOTNULL(ptr, msg, ...)  Asserts ptr != NULL
+//
+//   In many cases an assert is a binary comparison. In those cases, using the
+//   special binary assert macros below for <, <=, >, >=, == enables reporting
+//   the values of the operands in addition to the string of the condition.
+//
+//   Binary comparison asserts for 'int' type ("%d" in format strings):
+//   PW_CHECK_INT_LE(a, b, msg, ...)  Asserts a <= b
+//   PW_CHECK_INT_LT(a, b, msg, ...)  Asserts a <  b
+//   PW_CHECK_INT_GE(a, b, msg, ...)  Asserts a >= b
+//   PW_CHECK_INT_GT(a, b, msg, ...)  Asserts a >  b
+//   PW_CHECK_INT_EQ(a, b, msg, ...)  Asserts a == b
+//   PW_CHECK_INT_NE(a, b, msg, ...)  Asserts a != b
+//
+//   Binary comparison asserts for 'unsigned int' type ("%u" in format strings):
+//   PW_CHECK_UINT_LE(a, b, msg, ...)  Asserts a <= b
+//   PW_CHECK_UINT_LT(a, b, msg, ...)  Asserts a <  b
+//   PW_CHECK_UINT_GE(a, b, msg, ...)  Asserts a >= b
+//   PW_CHECK_UINT_GT(a, b, msg, ...)  Asserts a >  b
+//   PW_CHECK_UINT_EQ(a, b, msg, ...)  Asserts a == b
+//   PW_CHECK_UINT_NE(a, b, msg, ...)  Asserts a != b
+//
+//   Binary comparison asserts for 'void*' type ("%p" in format strings):
+//   PW_CHECK_PTR_LE(a, b, msg, ...)  Asserts a <= b
+//   PW_CHECK_PTR_LT(a, b, msg, ...)  Asserts a <  b
+//   PW_CHECK_PTR_GE(a, b, msg, ...)  Asserts a >= b
+//   PW_CHECK_PTR_GT(a, b, msg, ...)  Asserts a >  b
+//   PW_CHECK_PTR_EQ(a, b, msg, ...)  Asserts a == b
+//   PW_CHECK_PTR_NE(a, b, msg, ...)  Asserts a != b
+//
+//   Binary comparison asserts for 'float' type ("%f" in format strings):
+//   PW_CHECK_FLOAT_NEAR(a, b, abs_tolerance, msg, ...)
+//     Asserts (a >= (b - abs_tolerance)) && (a <= (b + abs_tolerance))
+//   PW_CHECK_FLOAT_EXACT_LE(a, b, msg, ...)  Asserts a <= b
+//   PW_CHECK_FLOAT_EXACT_LT(a, b, msg, ...)  Asserts a <  b
+//   PW_CHECK_FLOAT_EXACT_GE(a, b, msg, ...)  Asserts a >= b
+//   PW_CHECK_FLOAT_EXACT_GT(a, b, msg, ...)  Asserts a >  b
+//   PW_CHECK_FLOAT_EXACT_EQ(a, b, msg, ...)  Asserts a == b
+//   PW_CHECK_FLOAT_EXACT_NE(a, b, msg, ...)  Asserts a != b
+//
+//   The above CHECK_*_*() are also available in DCHECK variants, which will
+//   only evaluate their arguments and trigger if the NDEBUG macro is defined.
+//
+//   Note: For float, proper comparator checks which take floating point
+//   precision and ergo error accumulation into account are not provided on
+//   purpose as this comes with some complexity and requires application
+//   specific tolerances in terms of Units of Least Precision (ULP). Instead,
+//   we recommend developers carefully consider how floating point precision and
+//   error impact the data they are bounding and whether CHECKs are appropriate.
+//
+//   Note: PW_CRASH is the equivalent of LOG_FATAL in other systems, where a
+//   device crash is triggered with a message. In Pigweed, logging and
+//   crashing/asserting are separated. There is a LOG_CRITICAL level in the
+//   logging module, but it does not have side effects; for LOG_FATAL, instead
+//   use this macro (PW_CRASH).
+//
+// The public macro definitions are split out into an impl file to facilitate
+// testing the facade logic directly, without going through the facade/backend
+// build facilities.
+#include "pw_assert/internal/check_impl.h"
+
+// For compatibility, include short.h if PW_ASSERT_USE_SHORT_NAMES is set.
+// TODO(pwbug/350): Remove this include and the PW_ASSERT_USE_SHORT_NAMES macro
+//     when projects have migrated to including the short.h header.
+#if defined(PW_ASSERT_USE_SHORT_NAMES) && PW_ASSERT_USE_SHORT_NAMES == 1
+#include "pw_assert/short.h"
+#endif  // defined(PW_ASSERT_USE_SHORT_NAMES) && PW_ASSERT_USE_SHORT_NAMES == 1
+
+// The pw_assert_backend must provide these macros:
+//
+//   PW_HANDLE_CRASH(msg, ...)
+//   PW_HANDLE_ASSERT_FAILURE(condition, msg, ...)
+//   PW_HANDLE_ASSERT_BINARY_COMPARE_FAILURE(a, op, b, type_fmt, msg, ...)
+//
+//   The low level functionality of triggering a crash, rebooting a device,
+//   collecting information, or hanging out in a while(1) loop, must be
+//   provided by the underlying assert backend as part of the crash or assert
+//   failure handling.
+//
+//   Note that for the assert failures, the handler should assume the assert
+//   has already failed (the facade checks the condition before delegating).
+//
+#include "pw_assert_backend/assert_backend.h"
diff --git a/pw_assert/public/pw_assert/internal/assert_impl.h b/pw_assert/public/pw_assert/internal/assert_impl.h
deleted file mode 100644
index 35eab1e..0000000
--- a/pw_assert/public/pw_assert/internal/assert_impl.h
+++ /dev/null
@@ -1,403 +0,0 @@
-// 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.
-#pragma once
-
-#ifndef __cplusplus
-#include <stddef.h>
-#endif  // __cplusplus
-
-// Note: This file depends on the backend header already being included.
-
-#include "pw_assert/options.h"
-#include "pw_preprocessor/arguments.h"
-#include "pw_preprocessor/compiler.h"
-
-// PW_CRASH - Crash the system, with a message.
-#define PW_CRASH PW_HANDLE_CRASH
-
-// PW_CHECK - If condition evaluates to false, crash. Message optional.
-#define PW_CHECK(condition, ...)                              \
-  do {                                                        \
-    if (!(condition)) {                                       \
-      _PW_CHECK_SELECT_MACRO(                                 \
-          #condition, PW_HAS_ARGS(__VA_ARGS__), __VA_ARGS__); \
-    }                                                         \
-  } while (0)
-
-#define PW_DCHECK(...)            \
-  do {                            \
-    if (PW_ASSERT_ENABLE_DEBUG) { \
-      PW_CHECK(__VA_ARGS__);      \
-    }                             \
-  } while (0)
-
-// PW_D?CHECK_<type>_<comparison> macros - Binary comparison asserts.
-//
-// The below blocks are structured in table form, violating the 80-column
-// Pigweed style, in order to make it clearer what is common and what isn't
-// between the multitude of assert macro instantiations. To best view this
-// section, turn off editor wrapping or make your editor wide.
-//
-// clang-format off
-
-// Checks for int: LE, LT, GE, GT, EQ.
-#define PW_CHECK_INT_LE(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, <=, argb, int, "%d", __VA_ARGS__)
-#define PW_CHECK_INT_LT(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, < , argb, int, "%d", __VA_ARGS__)
-#define PW_CHECK_INT_GE(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, >=, argb, int, "%d", __VA_ARGS__)
-#define PW_CHECK_INT_GT(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, > , argb, int, "%d", __VA_ARGS__)
-#define PW_CHECK_INT_EQ(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, ==, argb, int, "%d", __VA_ARGS__)
-#define PW_CHECK_INT_NE(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, !=, argb, int, "%d", __VA_ARGS__)
-
-// Debug checks for int: LE, LT, GE, GT, EQ.
-#define PW_DCHECK_INT_LE(...) if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_INT_LE(__VA_ARGS__)
-#define PW_DCHECK_INT_LT(...) if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_INT_LT(__VA_ARGS__)
-#define PW_DCHECK_INT_GE(...) if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_INT_GE(__VA_ARGS__)
-#define PW_DCHECK_INT_GT(...) if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_INT_GT(__VA_ARGS__)
-#define PW_DCHECK_INT_EQ(...) if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_INT_EQ(__VA_ARGS__)
-#define PW_DCHECK_INT_NE(...) if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_INT_NE(__VA_ARGS__)
-
-// Checks for unsigned int: LE, LT, GE, GT, EQ.
-#define PW_CHECK_UINT_LE(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, <=, argb, unsigned int, "%u", __VA_ARGS__)
-#define PW_CHECK_UINT_LT(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, < , argb, unsigned int, "%u", __VA_ARGS__)
-#define PW_CHECK_UINT_GE(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, >=, argb, unsigned int, "%u", __VA_ARGS__)
-#define PW_CHECK_UINT_GT(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, > , argb, unsigned int, "%u", __VA_ARGS__)
-#define PW_CHECK_UINT_EQ(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, ==, argb, unsigned int, "%u", __VA_ARGS__)
-#define PW_CHECK_UINT_NE(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, !=, argb, unsigned int, "%u", __VA_ARGS__)
-
-// Debug checks for unsigned int: LE, LT, GE, GT, EQ.
-#define PW_DCHECK_UINT_LE(...) if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_UINT_LE(__VA_ARGS__)
-#define PW_DCHECK_UINT_LT(...) if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_UINT_LT(__VA_ARGS__)
-#define PW_DCHECK_UINT_GE(...) if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_UINT_GE(__VA_ARGS__)
-#define PW_DCHECK_UINT_GT(...) if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_UINT_GT(__VA_ARGS__)
-#define PW_DCHECK_UINT_EQ(...) if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_UINT_EQ(__VA_ARGS__)
-#define PW_DCHECK_UINT_NE(...) if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_UINT_NE(__VA_ARGS__)
-
-// Checks for pointer: LE, LT, GE, GT, EQ, NE.
-#define PW_CHECK_PTR_LE(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, <=, argb, const void*, "%p", __VA_ARGS__)
-#define PW_CHECK_PTR_LT(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, < , argb, const void*, "%p", __VA_ARGS__)
-#define PW_CHECK_PTR_GE(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, >=, argb, const void*, "%p", __VA_ARGS__)
-#define PW_CHECK_PTR_GT(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, > , argb, const void*, "%p", __VA_ARGS__)
-#define PW_CHECK_PTR_EQ(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, ==, argb, const void*, "%p", __VA_ARGS__)
-#define PW_CHECK_PTR_NE(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, !=, argb, const void*, "%p", __VA_ARGS__)
-
-// Check for pointer: NOTNULL. Use "nullptr" in C++, "NULL" in C.
-#ifdef __cplusplus
-#define PW_CHECK_NOTNULL(arga, ...) \
-  _PW_CHECK_BINARY_CMP_IMPL(arga, !=, nullptr, const void*, "%p", __VA_ARGS__)
-#else  // __cplusplus
-#define PW_CHECK_NOTNULL(arga, ...) \
-  _PW_CHECK_BINARY_CMP_IMPL(arga, !=, NULL, const void*, "%p", __VA_ARGS__)
-#endif  // __cplusplus
-
-// Debug checks for pointer: LE, LT, GE, GT, EQ, NE, and NOTNULL.
-#define PW_DCHECK_PTR_LE(...)  if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_PTR_LE(__VA_ARGS__)
-#define PW_DCHECK_PTR_LT(...)  if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_PTR_LT(__VA_ARGS__)
-#define PW_DCHECK_PTR_GE(...)  if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_PTR_GE(__VA_ARGS__)
-#define PW_DCHECK_PTR_GT(...)  if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_PTR_GT(__VA_ARGS__)
-#define PW_DCHECK_PTR_EQ(...)  if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_PTR_EQ(__VA_ARGS__)
-#define PW_DCHECK_PTR_NE(...)  if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_PTR_NE(__VA_ARGS__)
-#define PW_DCHECK_NOTNULL(...) if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_NOTNULL(__VA_ARGS__)
-
-// Checks for float: EXACT_LE, EXACT_LT, EXACT_GE, EXACT_GT, EXACT_EQ, EXACT_NE,
-// NEAR.
-#define PW_CHECK_FLOAT_NEAR(arga, argb, abs_tolerance, ...) \
-  _PW_CHECK_FLOAT_NEAR(arga, argb, abs_tolerance, __VA_ARGS__)
-#define PW_CHECK_FLOAT_EXACT_LE(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, <=, argb, float, "%f", __VA_ARGS__)
-#define PW_CHECK_FLOAT_EXACT_LT(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, < , argb, float, "%f", __VA_ARGS__)
-#define PW_CHECK_FLOAT_EXACT_GE(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, >=, argb, float, "%f", __VA_ARGS__)
-#define PW_CHECK_FLOAT_EXACT_GT(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, > , argb, float, "%f", __VA_ARGS__)
-#define PW_CHECK_FLOAT_EXACT_EQ(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, ==, argb, float, "%f", __VA_ARGS__)
-#define PW_CHECK_FLOAT_EXACT_NE(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, !=, argb, float, "%f", __VA_ARGS__)
-
-// Debug checks for float: NEAR, EXACT_LE, EXACT_LT, EXACT_GE, EXACT_GT,
-// EXACT_EQ.
-#define PW_DCHECK_FLOAT_NEAR(...)     if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_FLOAT_NEAR(__VA_ARGS__)
-#define PW_DCHECK_FLOAT_EXACT_LE(...) if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_FLOAT_EXACT_LE(__VA_ARGS__)
-#define PW_DCHECK_FLOAT_EXACT_LT(...) if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_FLOAT_EXACT_LT(__VA_ARGS__)
-#define PW_DCHECK_FLOAT_EXACT_GE(...) if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_FLOAT_EXACT_GE(__VA_ARGS__)
-#define PW_DCHECK_FLOAT_EXACT_GT(...) if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_FLOAT_EXACT_GT(__VA_ARGS__)
-#define PW_DCHECK_FLOAT_EXACT_EQ(...) if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_FLOAT_EXACT_EQ(__VA_ARGS__)
-#define PW_DCHECK_FLOAT_EXACT_NE(...) if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_FLOAT_EXACT_NE(__VA_ARGS__)
-
-// clang-format on
-
-// PW_CHECK - If condition evaluates to false, crash. Message optional.
-#define PW_CHECK_OK(status, ...)                          \
-  do {                                                    \
-    if (status != PW_STATUS_OK) {                         \
-      _PW_CHECK_OK_SELECT_MACRO(#status,                  \
-                                pw_StatusString(status),  \
-                                PW_HAS_ARGS(__VA_ARGS__), \
-                                __VA_ARGS__);             \
-    }                                                     \
-  } while (0)
-
-#define PW_DCHECK_OK(...)          \
-  if (!(PW_ASSERT_ENABLE_DEBUG)) { \
-  } else                           \
-    PW_CHECK_OK(__VA_ARGS__)
-
-// =========================================================================
-// Implementation for PW_CHECK
-
-// Two layers of select macros are used to enable the preprocessor to expand
-// macros in the arguments to ultimately token paste the final macro name based
-// on whether there are printf-style arguments.
-#define _PW_CHECK_SELECT_MACRO(condition, has_args, ...) \
-  _PW_CHECK_SELECT_MACRO_EXPANDED(condition, has_args, __VA_ARGS__)
-
-// Delegate to the macro
-#define _PW_CHECK_SELECT_MACRO_EXPANDED(condition, has_args, ...) \
-  _PW_CHECK_HAS_MSG_##has_args(condition, __VA_ARGS__)
-
-// PW_CHECK version 1: No message or args
-#define _PW_CHECK_HAS_MSG_0(condition, ignored_arg) \
-  PW_HANDLE_ASSERT_FAILURE(condition, "")
-
-// PW_CHECK version 2: With message (and maybe args)
-#define _PW_CHECK_HAS_MSG_1(condition, ...) \
-  PW_HANDLE_ASSERT_FAILURE(condition, __VA_ARGS__)
-
-// =========================================================================
-// Implementation for PW_CHECK_<type>_<comparison>
-
-// Two layers of select macros are used to enable the preprocessor to expand
-// macros in the arguments to ultimately token paste the final macro name based
-// on whether there are printf-style arguments.
-#define _PW_CHECK_BINARY_COMPARISON_SELECT_MACRO(argument_a_str,       \
-                                                 argument_a_val,       \
-                                                 comparison_op_str,    \
-                                                 argument_b_str,       \
-                                                 argument_b_val,       \
-                                                 type_fmt,             \
-                                                 has_args,             \
-                                                 ...)                  \
-  _PW_CHECK_SELECT_BINARY_COMPARISON_MACRO_EXPANDED(argument_a_str,    \
-                                                    argument_a_val,    \
-                                                    comparison_op_str, \
-                                                    argument_b_str,    \
-                                                    argument_b_val,    \
-                                                    type_fmt,          \
-                                                    has_args,          \
-                                                    __VA_ARGS__)
-
-// Delegate to the macro
-#define _PW_CHECK_SELECT_BINARY_COMPARISON_MACRO_EXPANDED(argument_a_str,    \
-                                                          argument_a_val,    \
-                                                          comparison_op_str, \
-                                                          argument_b_str,    \
-                                                          argument_b_val,    \
-                                                          type_fmt,          \
-                                                          has_args,          \
-                                                          ...)               \
-  _PW_CHECK_BINARY_COMPARISON_HAS_MSG_##has_args(argument_a_str,             \
-                                                 argument_a_val,             \
-                                                 comparison_op_str,          \
-                                                 argument_b_str,             \
-                                                 argument_b_val,             \
-                                                 type_fmt,                   \
-                                                 __VA_ARGS__)
-
-// PW_CHECK_BINARY_COMPARISON version 1: No message or args
-#define _PW_CHECK_BINARY_COMPARISON_HAS_MSG_0(argument_a_str,    \
-                                              argument_a_val,    \
-                                              comparison_op_str, \
-                                              argument_b_str,    \
-                                              argument_b_val,    \
-                                              type_fmt,          \
-                                              ignored_arg)       \
-  PW_HANDLE_ASSERT_BINARY_COMPARE_FAILURE(argument_a_str,        \
-                                          argument_a_val,        \
-                                          comparison_op_str,     \
-                                          argument_b_str,        \
-                                          argument_b_val,        \
-                                          type_fmt,              \
-                                          "")
-
-// PW_CHECK_BINARY_COMPARISON version 2: With message (and maybe args)
-#define _PW_CHECK_BINARY_COMPARISON_HAS_MSG_1(argument_a_str,    \
-                                              argument_a_val,    \
-                                              comparison_op_str, \
-                                              argument_b_str,    \
-                                              argument_b_val,    \
-                                              type_fmt,          \
-                                              ...)               \
-  PW_HANDLE_ASSERT_BINARY_COMPARE_FAILURE(argument_a_str,        \
-                                          argument_a_val,        \
-                                          comparison_op_str,     \
-                                          argument_b_str,        \
-                                          argument_b_val,        \
-                                          type_fmt,              \
-                                          __VA_ARGS__)
-
-// For the binary assertions, this private macro is re-used for almost all of
-// the variants. Due to limitations of C formatting, it is necessary to have
-// separate macros for the types.
-//
-// The macro avoids evaluating the arguments multiple times at the cost of some
-// macro complexity.
-#define _PW_CHECK_BINARY_CMP_IMPL(                                       \
-    argument_a, comparison_op, argument_b, type_decl, type_fmt, ...)     \
-  do {                                                                   \
-    type_decl evaluated_argument_a = (type_decl)(argument_a);            \
-    type_decl evaluated_argument_b = (type_decl)(argument_b);            \
-    if (!(evaluated_argument_a comparison_op evaluated_argument_b)) {    \
-      _PW_CHECK_BINARY_COMPARISON_SELECT_MACRO(#argument_a,              \
-                                               evaluated_argument_a,     \
-                                               #comparison_op,           \
-                                               #argument_b,              \
-                                               evaluated_argument_b,     \
-                                               type_fmt,                 \
-                                               PW_HAS_ARGS(__VA_ARGS__), \
-                                               __VA_ARGS__);             \
-    }                                                                    \
-  } while (0)
-
-// Custom implementation for FLOAT_NEAR which is implemented through two
-// underlying checks which are not trivially replaced through the use of
-// FLOAT_EXACT_LE & FLOAT_EXACT_GE.
-#define _PW_CHECK_FLOAT_NEAR(argument_a, argument_b, abs_tolerance, ...)       \
-  do {                                                                         \
-    PW_CHECK_FLOAT_EXACT_GE(abs_tolerance, 0.0f);                              \
-    float evaluated_argument_a = (float)(argument_a);                          \
-    float evaluated_argument_b_min = (float)(argument_b)-abs_tolerance;        \
-    float evaluated_argument_b_max = (float)(argument_b) + abs_tolerance;      \
-    if (!(evaluated_argument_a >= evaluated_argument_b_min)) {                 \
-      _PW_CHECK_BINARY_COMPARISON_SELECT_MACRO(#argument_a,                    \
-                                               evaluated_argument_a,           \
-                                               ">=",                           \
-                                               #argument_b " - abs_tolerance", \
-                                               evaluated_argument_b_min,       \
-                                               "%f",                           \
-                                               PW_HAS_ARGS(__VA_ARGS__),       \
-                                               __VA_ARGS__);                   \
-    } else if (!(evaluated_argument_a <= evaluated_argument_b_max)) {          \
-      _PW_CHECK_BINARY_COMPARISON_SELECT_MACRO(#argument_a,                    \
-                                               evaluated_argument_a,           \
-                                               "<=",                           \
-                                               #argument_b " + abs_tolerance", \
-                                               evaluated_argument_b_max,       \
-                                               "%f",                           \
-                                               PW_HAS_ARGS(__VA_ARGS__),       \
-                                               __VA_ARGS__);                   \
-    }                                                                          \
-  } while (0)
-
-// =========================================================================
-// Implementation for PW_CHECK_OK
-
-// Two layers of select macros are used to enable the preprocessor to expand
-// macros in the arguments to ultimately token paste the final macro name based
-// on whether there are printf-style arguments.
-#define _PW_CHECK_OK_SELECT_MACRO(                    \
-    status_expr_str, status_value_str, has_args, ...) \
-  _PW_CHECK_OK_SELECT_MACRO_EXPANDED(                 \
-      status_expr_str, status_value_str, has_args, __VA_ARGS__)
-
-// Delegate to the macro
-#define _PW_CHECK_OK_SELECT_MACRO_EXPANDED(           \
-    status_expr_str, status_value_str, has_args, ...) \
-  _PW_CHECK_OK_HAS_MSG_##has_args(                    \
-      status_expr_str, status_value_str, __VA_ARGS__)
-
-// PW_CHECK_OK version 1: No message or args
-#define _PW_CHECK_OK_HAS_MSG_0(status_expr_str, status_value_str, ignored_arg) \
-  PW_HANDLE_ASSERT_BINARY_COMPARE_FAILURE(                                     \
-      status_expr_str, status_value_str, "==", "Status::OK", "OK", "%s", "")
-
-// PW_CHECK_OK version 2: With message (and maybe args)
-#define _PW_CHECK_OK_HAS_MSG_1(status_expr_str, status_value_str, ...) \
-  PW_HANDLE_ASSERT_BINARY_COMPARE_FAILURE(status_expr_str,             \
-                                          status_value_str,            \
-                                          "==",                        \
-                                          "Status::OK",                \
-                                          "OK",                        \
-                                          "%s",                        \
-                                          __VA_ARGS__)
-
-// Define short, usable names if requested. Note that the CHECK() macro will
-// conflict with Google Log, which expects stream style logs.
-#ifndef PW_ASSERT_USE_SHORT_NAMES
-#define PW_ASSERT_USE_SHORT_NAMES 0
-#endif
-
-// =========================================================================
-// Short name definitions (optional)
-
-// clang-format off
-#if PW_ASSERT_USE_SHORT_NAMES
-
-// Checks that always run even in production.
-#define CRASH                 PW_CRASH
-#define CHECK                 PW_CHECK
-#define CHECK_PTR_LE          PW_CHECK_PTR_LE
-#define CHECK_PTR_LT          PW_CHECK_PTR_LT
-#define CHECK_PTR_GE          PW_CHECK_PTR_GE
-#define CHECK_PTR_GT          PW_CHECK_PTR_GT
-#define CHECK_PTR_EQ          PW_CHECK_PTR_EQ
-#define CHECK_PTR_NE          PW_CHECK_PTR_NE
-#define CHECK_NOTNULL         PW_CHECK_NOTNULL
-#define CHECK_INT_LE          PW_CHECK_INT_LE
-#define CHECK_INT_LT          PW_CHECK_INT_LT
-#define CHECK_INT_GE          PW_CHECK_INT_GE
-#define CHECK_INT_GT          PW_CHECK_INT_GT
-#define CHECK_INT_EQ          PW_CHECK_INT_EQ
-#define CHECK_INT_NE          PW_CHECK_INT_NE
-#define CHECK_UINT_LE         PW_CHECK_UINT_LE
-#define CHECK_UINT_LT         PW_CHECK_UINT_LT
-#define CHECK_UINT_GE         PW_CHECK_UINT_GE
-#define CHECK_UINT_GT         PW_CHECK_UINT_GT
-#define CHECK_UINT_EQ         PW_CHECK_UINT_EQ
-#define CHECK_UINT_NE         PW_CHECK_UINT_NE
-#define CHECK_FLOAT_NEAR      PW_CHECK_FLOAT_NEAR
-#define CHECK_FLOAT_EXACT_LE  PW_CHECK_FLOAT_EXACT_LE
-#define CHECK_FLOAT_EXACT_LT  PW_CHECK_FLOAT_EXACT_LT
-#define CHECK_FLOAT_EXACT_GE  PW_CHECK_FLOAT_EXACT_GE
-#define CHECK_FLOAT_EXACT_GT  PW_CHECK_FLOAT_EXACT_GT
-#define CHECK_FLOAT_EXACT_EQ  PW_CHECK_FLOAT_EXACT_EQ
-#define CHECK_FLOAT_EXACT_NE  PW_CHECK_FLOAT_EXACT_NE
-#define CHECK_OK              PW_CHECK_OK
-
-// Checks that are disabled if NDEBUG is not defined.
-#define DCHECK                PW_DCHECK
-#define DCHECK_PTR_LE         PW_DCHECK_PTR_LE
-#define DCHECK_PTR_LT         PW_DCHECK_PTR_LT
-#define DCHECK_PTR_GE         PW_DCHECK_PTR_GE
-#define DCHECK_PTR_GT         PW_DCHECK_PTR_GT
-#define DCHECK_PTR_EQ         PW_DCHECK_PTR_EQ
-#define DCHECK_PTR_NE         PW_DCHECK_PTR_NE
-#define DCHECK_NOTNULL        PW_DCHECK_NOTNULL
-#define DCHECK_INT_LE         PW_DCHECK_INT_LE
-#define DCHECK_INT_LT         PW_DCHECK_INT_LT
-#define DCHECK_INT_GE         PW_DCHECK_INT_GE
-#define DCHECK_INT_GT         PW_DCHECK_INT_GT
-#define DCHECK_INT_EQ         PW_DCHECK_INT_EQ
-#define DCHECK_INT_NE         PW_DCHECK_INT_NE
-#define DCHECK_UINT_LE        PW_DCHECK_UINT_LE
-#define DCHECK_UINT_LT        PW_DCHECK_UINT_LT
-#define DCHECK_UINT_GE        PW_DCHECK_UINT_GE
-#define DCHECK_UINT_GT        PW_DCHECK_UINT_GT
-#define DCHECK_UINT_EQ        PW_DCHECK_UINT_EQ
-#define DCHECK_UINT_NE        PW_DCHECK_UINT_NE
-#define DCHECK_FLOAT_NEAR     PW_DCHECK_FLOAT_NEAR
-#define DCHECK_FLOAT_EXACT_LT PW_DCHECK_FLOAT_EXACT_LT
-#define DCHECK_FLOAT_EXACT_LE PW_DCHECK_FLOAT_EXACT_LE
-#define DCHECK_FLOAT_EXACT_GT PW_DCHECK_FLOAT_EXACT_GT
-#define DCHECK_FLOAT_EXACT_GE PW_DCHECK_FLOAT_EXACT_GE
-#define DCHECK_FLOAT_EXACT_EQ PW_DCHECK_FLOAT_EXACT_EQ
-#define DCHECK_FLOAT_EXACT_NE PW_DCHECK_FLOAT_EXACT_NE
-#define DCHECK_OK             PW_DCHECK_OK
-
-#endif  // PW_ASSERT_SHORT_NAMES
-// clang-format on
diff --git a/pw_assert/public/pw_assert/internal/check_impl.h b/pw_assert/public/pw_assert/internal/check_impl.h
new file mode 100644
index 0000000..584dd0f
--- /dev/null
+++ b/pw_assert/public/pw_assert/internal/check_impl.h
@@ -0,0 +1,327 @@
+// 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.
+#pragma once
+
+#ifndef __cplusplus
+#include <stddef.h>
+#endif  // __cplusplus
+
+// Note: This file depends on the backend header already being included.
+
+#include "pw_assert/options.h"
+#include "pw_preprocessor/arguments.h"
+#include "pw_preprocessor/compiler.h"
+
+// PW_CRASH - Crash the system, with a message.
+#define PW_CRASH PW_HANDLE_CRASH
+
+// PW_CHECK - If condition evaluates to false, crash. Message optional.
+#define PW_CHECK(condition, ...)                              \
+  do {                                                        \
+    if (!(condition)) {                                       \
+      _PW_CHECK_SELECT_MACRO(                                 \
+          #condition, PW_HAS_ARGS(__VA_ARGS__), __VA_ARGS__); \
+    }                                                         \
+  } while (0)
+
+#define PW_DCHECK(...)            \
+  do {                            \
+    if (PW_ASSERT_ENABLE_DEBUG) { \
+      PW_CHECK(__VA_ARGS__);      \
+    }                             \
+  } while (0)
+
+// PW_D?CHECK_<type>_<comparison> macros - Binary comparison asserts.
+//
+// The below blocks are structured in table form, violating the 80-column
+// Pigweed style, in order to make it clearer what is common and what isn't
+// between the multitude of assert macro instantiations. To best view this
+// section, turn off editor wrapping or make your editor wide.
+//
+// clang-format off
+
+// Checks for int: LE, LT, GE, GT, EQ.
+#define PW_CHECK_INT_LE(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, <=, argb, int, "%d", __VA_ARGS__)
+#define PW_CHECK_INT_LT(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, < , argb, int, "%d", __VA_ARGS__)
+#define PW_CHECK_INT_GE(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, >=, argb, int, "%d", __VA_ARGS__)
+#define PW_CHECK_INT_GT(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, > , argb, int, "%d", __VA_ARGS__)
+#define PW_CHECK_INT_EQ(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, ==, argb, int, "%d", __VA_ARGS__)
+#define PW_CHECK_INT_NE(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, !=, argb, int, "%d", __VA_ARGS__)
+
+// Debug checks for int: LE, LT, GE, GT, EQ.
+#define PW_DCHECK_INT_LE(...) if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_INT_LE(__VA_ARGS__)
+#define PW_DCHECK_INT_LT(...) if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_INT_LT(__VA_ARGS__)
+#define PW_DCHECK_INT_GE(...) if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_INT_GE(__VA_ARGS__)
+#define PW_DCHECK_INT_GT(...) if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_INT_GT(__VA_ARGS__)
+#define PW_DCHECK_INT_EQ(...) if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_INT_EQ(__VA_ARGS__)
+#define PW_DCHECK_INT_NE(...) if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_INT_NE(__VA_ARGS__)
+
+// Checks for unsigned int: LE, LT, GE, GT, EQ.
+#define PW_CHECK_UINT_LE(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, <=, argb, unsigned int, "%u", __VA_ARGS__)
+#define PW_CHECK_UINT_LT(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, < , argb, unsigned int, "%u", __VA_ARGS__)
+#define PW_CHECK_UINT_GE(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, >=, argb, unsigned int, "%u", __VA_ARGS__)
+#define PW_CHECK_UINT_GT(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, > , argb, unsigned int, "%u", __VA_ARGS__)
+#define PW_CHECK_UINT_EQ(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, ==, argb, unsigned int, "%u", __VA_ARGS__)
+#define PW_CHECK_UINT_NE(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, !=, argb, unsigned int, "%u", __VA_ARGS__)
+
+// Debug checks for unsigned int: LE, LT, GE, GT, EQ.
+#define PW_DCHECK_UINT_LE(...) if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_UINT_LE(__VA_ARGS__)
+#define PW_DCHECK_UINT_LT(...) if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_UINT_LT(__VA_ARGS__)
+#define PW_DCHECK_UINT_GE(...) if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_UINT_GE(__VA_ARGS__)
+#define PW_DCHECK_UINT_GT(...) if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_UINT_GT(__VA_ARGS__)
+#define PW_DCHECK_UINT_EQ(...) if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_UINT_EQ(__VA_ARGS__)
+#define PW_DCHECK_UINT_NE(...) if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_UINT_NE(__VA_ARGS__)
+
+// Checks for pointer: LE, LT, GE, GT, EQ, NE.
+#define PW_CHECK_PTR_LE(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, <=, argb, const void*, "%p", __VA_ARGS__)
+#define PW_CHECK_PTR_LT(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, < , argb, const void*, "%p", __VA_ARGS__)
+#define PW_CHECK_PTR_GE(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, >=, argb, const void*, "%p", __VA_ARGS__)
+#define PW_CHECK_PTR_GT(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, > , argb, const void*, "%p", __VA_ARGS__)
+#define PW_CHECK_PTR_EQ(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, ==, argb, const void*, "%p", __VA_ARGS__)
+#define PW_CHECK_PTR_NE(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, !=, argb, const void*, "%p", __VA_ARGS__)
+
+// Check for pointer: NOTNULL. Use "nullptr" in C++, "NULL" in C.
+#ifdef __cplusplus
+#define PW_CHECK_NOTNULL(arga, ...) \
+  _PW_CHECK_BINARY_CMP_IMPL(arga, !=, nullptr, const void*, "%p", __VA_ARGS__)
+#else  // __cplusplus
+#define PW_CHECK_NOTNULL(arga, ...) \
+  _PW_CHECK_BINARY_CMP_IMPL(arga, !=, NULL, const void*, "%p", __VA_ARGS__)
+#endif  // __cplusplus
+
+// Debug checks for pointer: LE, LT, GE, GT, EQ, NE, and NOTNULL.
+#define PW_DCHECK_PTR_LE(...)  if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_PTR_LE(__VA_ARGS__)
+#define PW_DCHECK_PTR_LT(...)  if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_PTR_LT(__VA_ARGS__)
+#define PW_DCHECK_PTR_GE(...)  if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_PTR_GE(__VA_ARGS__)
+#define PW_DCHECK_PTR_GT(...)  if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_PTR_GT(__VA_ARGS__)
+#define PW_DCHECK_PTR_EQ(...)  if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_PTR_EQ(__VA_ARGS__)
+#define PW_DCHECK_PTR_NE(...)  if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_PTR_NE(__VA_ARGS__)
+#define PW_DCHECK_NOTNULL(...) if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_NOTNULL(__VA_ARGS__)
+
+// Checks for float: EXACT_LE, EXACT_LT, EXACT_GE, EXACT_GT, EXACT_EQ, EXACT_NE,
+// NEAR.
+#define PW_CHECK_FLOAT_NEAR(arga, argb, abs_tolerance, ...) \
+  _PW_CHECK_FLOAT_NEAR(arga, argb, abs_tolerance, __VA_ARGS__)
+#define PW_CHECK_FLOAT_EXACT_LE(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, <=, argb, float, "%f", __VA_ARGS__)
+#define PW_CHECK_FLOAT_EXACT_LT(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, < , argb, float, "%f", __VA_ARGS__)
+#define PW_CHECK_FLOAT_EXACT_GE(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, >=, argb, float, "%f", __VA_ARGS__)
+#define PW_CHECK_FLOAT_EXACT_GT(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, > , argb, float, "%f", __VA_ARGS__)
+#define PW_CHECK_FLOAT_EXACT_EQ(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, ==, argb, float, "%f", __VA_ARGS__)
+#define PW_CHECK_FLOAT_EXACT_NE(arga, argb, ...) _PW_CHECK_BINARY_CMP_IMPL(arga, !=, argb, float, "%f", __VA_ARGS__)
+
+// Debug checks for float: NEAR, EXACT_LE, EXACT_LT, EXACT_GE, EXACT_GT,
+// EXACT_EQ.
+#define PW_DCHECK_FLOAT_NEAR(...)     if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_FLOAT_NEAR(__VA_ARGS__)
+#define PW_DCHECK_FLOAT_EXACT_LE(...) if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_FLOAT_EXACT_LE(__VA_ARGS__)
+#define PW_DCHECK_FLOAT_EXACT_LT(...) if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_FLOAT_EXACT_LT(__VA_ARGS__)
+#define PW_DCHECK_FLOAT_EXACT_GE(...) if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_FLOAT_EXACT_GE(__VA_ARGS__)
+#define PW_DCHECK_FLOAT_EXACT_GT(...) if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_FLOAT_EXACT_GT(__VA_ARGS__)
+#define PW_DCHECK_FLOAT_EXACT_EQ(...) if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_FLOAT_EXACT_EQ(__VA_ARGS__)
+#define PW_DCHECK_FLOAT_EXACT_NE(...) if (!(PW_ASSERT_ENABLE_DEBUG)) {} else PW_CHECK_FLOAT_EXACT_NE(__VA_ARGS__)
+
+// clang-format on
+
+// PW_CHECK - If condition evaluates to false, crash. Message optional.
+#define PW_CHECK_OK(status, ...)                          \
+  do {                                                    \
+    if (status != PW_STATUS_OK) {                         \
+      _PW_CHECK_OK_SELECT_MACRO(#status,                  \
+                                pw_StatusString(status),  \
+                                PW_HAS_ARGS(__VA_ARGS__), \
+                                __VA_ARGS__);             \
+    }                                                     \
+  } while (0)
+
+#define PW_DCHECK_OK(...)          \
+  if (!(PW_ASSERT_ENABLE_DEBUG)) { \
+  } else                           \
+    PW_CHECK_OK(__VA_ARGS__)
+
+// =========================================================================
+// Implementation for PW_CHECK
+
+// Two layers of select macros are used to enable the preprocessor to expand
+// macros in the arguments to ultimately token paste the final macro name based
+// on whether there are printf-style arguments.
+#define _PW_CHECK_SELECT_MACRO(condition, has_args, ...) \
+  _PW_CHECK_SELECT_MACRO_EXPANDED(condition, has_args, __VA_ARGS__)
+
+// Delegate to the macro
+#define _PW_CHECK_SELECT_MACRO_EXPANDED(condition, has_args, ...) \
+  _PW_CHECK_HAS_MSG_##has_args(condition, __VA_ARGS__)
+
+// PW_CHECK version 1: No message or args
+#define _PW_CHECK_HAS_MSG_0(condition, ignored_arg) \
+  PW_HANDLE_ASSERT_FAILURE(condition, "")
+
+// PW_CHECK version 2: With message (and maybe args)
+#define _PW_CHECK_HAS_MSG_1(condition, ...) \
+  PW_HANDLE_ASSERT_FAILURE(condition, __VA_ARGS__)
+
+// =========================================================================
+// Implementation for PW_CHECK_<type>_<comparison>
+
+// Two layers of select macros are used to enable the preprocessor to expand
+// macros in the arguments to ultimately token paste the final macro name based
+// on whether there are printf-style arguments.
+#define _PW_CHECK_BINARY_COMPARISON_SELECT_MACRO(argument_a_str,       \
+                                                 argument_a_val,       \
+                                                 comparison_op_str,    \
+                                                 argument_b_str,       \
+                                                 argument_b_val,       \
+                                                 type_fmt,             \
+                                                 has_args,             \
+                                                 ...)                  \
+  _PW_CHECK_SELECT_BINARY_COMPARISON_MACRO_EXPANDED(argument_a_str,    \
+                                                    argument_a_val,    \
+                                                    comparison_op_str, \
+                                                    argument_b_str,    \
+                                                    argument_b_val,    \
+                                                    type_fmt,          \
+                                                    has_args,          \
+                                                    __VA_ARGS__)
+
+// Delegate to the macro
+#define _PW_CHECK_SELECT_BINARY_COMPARISON_MACRO_EXPANDED(argument_a_str,    \
+                                                          argument_a_val,    \
+                                                          comparison_op_str, \
+                                                          argument_b_str,    \
+                                                          argument_b_val,    \
+                                                          type_fmt,          \
+                                                          has_args,          \
+                                                          ...)               \
+  _PW_CHECK_BINARY_COMPARISON_HAS_MSG_##has_args(argument_a_str,             \
+                                                 argument_a_val,             \
+                                                 comparison_op_str,          \
+                                                 argument_b_str,             \
+                                                 argument_b_val,             \
+                                                 type_fmt,                   \
+                                                 __VA_ARGS__)
+
+// PW_CHECK_BINARY_COMPARISON version 1: No message or args
+#define _PW_CHECK_BINARY_COMPARISON_HAS_MSG_0(argument_a_str,    \
+                                              argument_a_val,    \
+                                              comparison_op_str, \
+                                              argument_b_str,    \
+                                              argument_b_val,    \
+                                              type_fmt,          \
+                                              ignored_arg)       \
+  PW_HANDLE_ASSERT_BINARY_COMPARE_FAILURE(argument_a_str,        \
+                                          argument_a_val,        \
+                                          comparison_op_str,     \
+                                          argument_b_str,        \
+                                          argument_b_val,        \
+                                          type_fmt,              \
+                                          "")
+
+// PW_CHECK_BINARY_COMPARISON version 2: With message (and maybe args)
+#define _PW_CHECK_BINARY_COMPARISON_HAS_MSG_1(argument_a_str,    \
+                                              argument_a_val,    \
+                                              comparison_op_str, \
+                                              argument_b_str,    \
+                                              argument_b_val,    \
+                                              type_fmt,          \
+                                              ...)               \
+  PW_HANDLE_ASSERT_BINARY_COMPARE_FAILURE(argument_a_str,        \
+                                          argument_a_val,        \
+                                          comparison_op_str,     \
+                                          argument_b_str,        \
+                                          argument_b_val,        \
+                                          type_fmt,              \
+                                          __VA_ARGS__)
+
+// For the binary assertions, this private macro is re-used for almost all of
+// the variants. Due to limitations of C formatting, it is necessary to have
+// separate macros for the types.
+//
+// The macro avoids evaluating the arguments multiple times at the cost of some
+// macro complexity.
+#define _PW_CHECK_BINARY_CMP_IMPL(                                       \
+    argument_a, comparison_op, argument_b, type_decl, type_fmt, ...)     \
+  do {                                                                   \
+    type_decl evaluated_argument_a = (type_decl)(argument_a);            \
+    type_decl evaluated_argument_b = (type_decl)(argument_b);            \
+    if (!(evaluated_argument_a comparison_op evaluated_argument_b)) {    \
+      _PW_CHECK_BINARY_COMPARISON_SELECT_MACRO(#argument_a,              \
+                                               evaluated_argument_a,     \
+                                               #comparison_op,           \
+                                               #argument_b,              \
+                                               evaluated_argument_b,     \
+                                               type_fmt,                 \
+                                               PW_HAS_ARGS(__VA_ARGS__), \
+                                               __VA_ARGS__);             \
+    }                                                                    \
+  } while (0)
+
+// Custom implementation for FLOAT_NEAR which is implemented through two
+// underlying checks which are not trivially replaced through the use of
+// FLOAT_EXACT_LE & FLOAT_EXACT_GE.
+#define _PW_CHECK_FLOAT_NEAR(argument_a, argument_b, abs_tolerance, ...)       \
+  do {                                                                         \
+    PW_CHECK_FLOAT_EXACT_GE(abs_tolerance, 0.0f);                              \
+    float evaluated_argument_a = (float)(argument_a);                          \
+    float evaluated_argument_b_min = (float)(argument_b)-abs_tolerance;        \
+    float evaluated_argument_b_max = (float)(argument_b) + abs_tolerance;      \
+    if (!(evaluated_argument_a >= evaluated_argument_b_min)) {                 \
+      _PW_CHECK_BINARY_COMPARISON_SELECT_MACRO(#argument_a,                    \
+                                               evaluated_argument_a,           \
+                                               ">=",                           \
+                                               #argument_b " - abs_tolerance", \
+                                               evaluated_argument_b_min,       \
+                                               "%f",                           \
+                                               PW_HAS_ARGS(__VA_ARGS__),       \
+                                               __VA_ARGS__);                   \
+    } else if (!(evaluated_argument_a <= evaluated_argument_b_max)) {          \
+      _PW_CHECK_BINARY_COMPARISON_SELECT_MACRO(#argument_a,                    \
+                                               evaluated_argument_a,           \
+                                               "<=",                           \
+                                               #argument_b " + abs_tolerance", \
+                                               evaluated_argument_b_max,       \
+                                               "%f",                           \
+                                               PW_HAS_ARGS(__VA_ARGS__),       \
+                                               __VA_ARGS__);                   \
+    }                                                                          \
+  } while (0)
+
+// =========================================================================
+// Implementation for PW_CHECK_OK
+
+// Two layers of select macros are used to enable the preprocessor to expand
+// macros in the arguments to ultimately token paste the final macro name based
+// on whether there are printf-style arguments.
+#define _PW_CHECK_OK_SELECT_MACRO(                    \
+    status_expr_str, status_value_str, has_args, ...) \
+  _PW_CHECK_OK_SELECT_MACRO_EXPANDED(                 \
+      status_expr_str, status_value_str, has_args, __VA_ARGS__)
+
+// Delegate to the macro
+#define _PW_CHECK_OK_SELECT_MACRO_EXPANDED(           \
+    status_expr_str, status_value_str, has_args, ...) \
+  _PW_CHECK_OK_HAS_MSG_##has_args(                    \
+      status_expr_str, status_value_str, __VA_ARGS__)
+
+// PW_CHECK_OK version 1: No message or args
+#define _PW_CHECK_OK_HAS_MSG_0(status_expr_str, status_value_str, ignored_arg) \
+  PW_HANDLE_ASSERT_BINARY_COMPARE_FAILURE(                                     \
+      status_expr_str, status_value_str, "==", "OkStatus()", "OK", "%s", "")
+
+// PW_CHECK_OK version 2: With message (and maybe args)
+#define _PW_CHECK_OK_HAS_MSG_1(status_expr_str, status_value_str, ...) \
+  PW_HANDLE_ASSERT_BINARY_COMPARE_FAILURE(status_expr_str,             \
+                                          status_value_str,            \
+                                          "==",                        \
+                                          "OkStatus()",                \
+                                          "OK",                        \
+                                          "%s",                        \
+                                          __VA_ARGS__)
diff --git a/pw_assert/public/pw_assert/light.h b/pw_assert/public/pw_assert/light.h
index 95457a8..635bb3f 100644
--- a/pw_assert/public/pw_assert/light.h
+++ b/pw_assert/public/pw_assert/light.h
@@ -1,4 +1,4 @@
-// Copyright 2020 The Pigweed Authors
+// Copyright 2021 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
@@ -13,46 +13,5 @@
 // the License.
 #pragma once
 
-#include "pw_assert/options.h"  // For PW_ASSERT_ENABLE_DEBUG
-#include "pw_preprocessor/util.h"
-
-PW_EXTERN_C_START
-
-void pw_assert_HandleFailure(void);
-
-PW_EXTERN_C_END
-
-// A header- and constexpr-safe version of PW_CHECK().
-//
-// If the given condition is false, crash the system. Otherwise, do nothing.
-// The condition is guaranteed to be evaluated. This assert implementation is
-// guaranteed to be constexpr-safe.
-//
-// IMPORTANT: Unlike the PW_CHECK_*() suite of macros, this API captures no
-// rich information like line numbers, the file, expression arguments, or the
-// stringified expression. Use these macros only when absolutely necessary --
-// in headers, constexr contexts, or in rare cases where the call site overhead
-// of a full PW_CHECK must be avoided. Use PW_CHECK_*() whenever possible.
-#define PW_ASSERT(condition)     \
-  do {                           \
-    if (!(condition)) {          \
-      pw_assert_HandleFailure(); \
-    }                            \
-  } while (0)
-
-// A header- and constexpr-safe version of PW_DCHECK().
-//
-// Same as PW_ASSERT(), except that if PW_ASSERT_ENABLE_DEBUG == 1, the assert
-// is disabled and condition is not evaluated.
-//
-// IMPORTANT: Unlike the PW_CHECK_*() suite of macros, this API captures no
-// rich information like line numbers, the file, expression arguments, or the
-// stringified expression. Use these macros only when absolutely necessary --
-// in headers, constexr contexts, or in rare cases where the call site overhead
-// of a full PW_CHECK must be avoided. Use PW_DCHECK_*() whenever possible.
-#define PW_DASSERT(condition)                            \
-  do {                                                   \
-    if ((PW_ASSERT_ENABLE_DEBUG == 1) && !(condition)) { \
-      pw_assert_HandleFailure();                         \
-    }                                                    \
-  } while (0)
+// TODO(pwbug/350): Remove the deprecated light.h header in favor of assert.h.
+#include "pw_assert/assert.h"
diff --git a/pw_assert/public/pw_assert/short.h b/pw_assert/public/pw_assert/short.h
new file mode 100644
index 0000000..be4ed2d
--- /dev/null
+++ b/pw_assert/public/pw_assert/short.h
@@ -0,0 +1,81 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "pw_assert/check.h"
+
+// Optional short CHECK name definitions
+
+// clang-format off
+// Checks that always run even in production.
+#define CRASH                 PW_CRASH
+#define CHECK                 PW_CHECK
+#define CHECK_PTR_LE          PW_CHECK_PTR_LE
+#define CHECK_PTR_LT          PW_CHECK_PTR_LT
+#define CHECK_PTR_GE          PW_CHECK_PTR_GE
+#define CHECK_PTR_GT          PW_CHECK_PTR_GT
+#define CHECK_PTR_EQ          PW_CHECK_PTR_EQ
+#define CHECK_PTR_NE          PW_CHECK_PTR_NE
+#define CHECK_NOTNULL         PW_CHECK_NOTNULL
+#define CHECK_INT_LE          PW_CHECK_INT_LE
+#define CHECK_INT_LT          PW_CHECK_INT_LT
+#define CHECK_INT_GE          PW_CHECK_INT_GE
+#define CHECK_INT_GT          PW_CHECK_INT_GT
+#define CHECK_INT_EQ          PW_CHECK_INT_EQ
+#define CHECK_INT_NE          PW_CHECK_INT_NE
+#define CHECK_UINT_LE         PW_CHECK_UINT_LE
+#define CHECK_UINT_LT         PW_CHECK_UINT_LT
+#define CHECK_UINT_GE         PW_CHECK_UINT_GE
+#define CHECK_UINT_GT         PW_CHECK_UINT_GT
+#define CHECK_UINT_EQ         PW_CHECK_UINT_EQ
+#define CHECK_UINT_NE         PW_CHECK_UINT_NE
+#define CHECK_FLOAT_NEAR      PW_CHECK_FLOAT_NEAR
+#define CHECK_FLOAT_EXACT_LE  PW_CHECK_FLOAT_EXACT_LE
+#define CHECK_FLOAT_EXACT_LT  PW_CHECK_FLOAT_EXACT_LT
+#define CHECK_FLOAT_EXACT_GE  PW_CHECK_FLOAT_EXACT_GE
+#define CHECK_FLOAT_EXACT_GT  PW_CHECK_FLOAT_EXACT_GT
+#define CHECK_FLOAT_EXACT_EQ  PW_CHECK_FLOAT_EXACT_EQ
+#define CHECK_FLOAT_EXACT_NE  PW_CHECK_FLOAT_EXACT_NE
+#define CHECK_OK              PW_CHECK_OK
+
+// Checks that are disabled if NDEBUG is not defined.
+#define DCHECK                PW_DCHECK
+#define DCHECK_PTR_LE         PW_DCHECK_PTR_LE
+#define DCHECK_PTR_LT         PW_DCHECK_PTR_LT
+#define DCHECK_PTR_GE         PW_DCHECK_PTR_GE
+#define DCHECK_PTR_GT         PW_DCHECK_PTR_GT
+#define DCHECK_PTR_EQ         PW_DCHECK_PTR_EQ
+#define DCHECK_PTR_NE         PW_DCHECK_PTR_NE
+#define DCHECK_NOTNULL        PW_DCHECK_NOTNULL
+#define DCHECK_INT_LE         PW_DCHECK_INT_LE
+#define DCHECK_INT_LT         PW_DCHECK_INT_LT
+#define DCHECK_INT_GE         PW_DCHECK_INT_GE
+#define DCHECK_INT_GT         PW_DCHECK_INT_GT
+#define DCHECK_INT_EQ         PW_DCHECK_INT_EQ
+#define DCHECK_INT_NE         PW_DCHECK_INT_NE
+#define DCHECK_UINT_LE        PW_DCHECK_UINT_LE
+#define DCHECK_UINT_LT        PW_DCHECK_UINT_LT
+#define DCHECK_UINT_GE        PW_DCHECK_UINT_GE
+#define DCHECK_UINT_GT        PW_DCHECK_UINT_GT
+#define DCHECK_UINT_EQ        PW_DCHECK_UINT_EQ
+#define DCHECK_UINT_NE        PW_DCHECK_UINT_NE
+#define DCHECK_FLOAT_NEAR     PW_DCHECK_FLOAT_NEAR
+#define DCHECK_FLOAT_EXACT_LT PW_DCHECK_FLOAT_EXACT_LT
+#define DCHECK_FLOAT_EXACT_LE PW_DCHECK_FLOAT_EXACT_LE
+#define DCHECK_FLOAT_EXACT_GT PW_DCHECK_FLOAT_EXACT_GT
+#define DCHECK_FLOAT_EXACT_GE PW_DCHECK_FLOAT_EXACT_GE
+#define DCHECK_FLOAT_EXACT_EQ PW_DCHECK_FLOAT_EXACT_EQ
+#define DCHECK_FLOAT_EXACT_NE PW_DCHECK_FLOAT_EXACT_NE
+#define DCHECK_OK             PW_DCHECK_OK
+// clang-format on
diff --git a/pw_assert_basic/BUILD b/pw_assert_basic/BUILD
index 22d063c..9992d77 100644
--- a/pw_assert_basic/BUILD
+++ b/pw_assert_basic/BUILD
@@ -43,6 +43,30 @@
     ],
     deps = [
         ":headers",
+        ":pw_assert_basic_handler",
+        "//pw_assert:facade",
+        "//pw_preprocessor",
+    ],
+)
+
+pw_cc_library(
+    name = "handler_facade",
+    hdrs = [
+        "public/pw_assert_basic/handler.h",
+    ],
+    deps = [
+        "//pw_preprocessor",
+    ],
+)
+
+pw_cc_library(
+    name = "pw_assert_basic_handler",
+    srcs = [
+        "basic_handler.cc",
+    ],
+    deps = [
+        ":handler_facade",
+        ":headers",
         "//pw_assert:facade",
         "//pw_preprocessor",
         "//pw_string",
diff --git a/pw_assert_basic/BUILD.gn b/pw_assert_basic/BUILD.gn
index a43c95c..a8682f1 100644
--- a/pw_assert_basic/BUILD.gn
+++ b/pw_assert_basic/BUILD.gn
@@ -14,8 +14,10 @@
 
 import("//build_overrides/pigweed.gni")
 
+import("$dir_pw_build/facade.gni")
 import("$dir_pw_build/target_types.gni")
 import("$dir_pw_docgen/docs.gni")
+import("backend.gni")
 
 config("default_config") {
   include_dirs = [ "public" ]
@@ -25,25 +27,41 @@
   include_dirs = [ "public_overrides" ]
 }
 
+pw_facade("handler") {
+  backend = pw_assert_basic_HANDLER_BACKEND
+  public_configs = [ ":default_config" ]
+  public_deps = [ "$dir_pw_preprocessor" ]
+  public = [ "public/pw_assert_basic/handler.h" ]
+}
+
 pw_source_set("pw_assert_basic") {
   public_configs = [
     ":backend_config",
     ":default_config",
   ]
-  deps = [ ":core" ]
-  public = [ "public_overrides/pw_assert_backend/assert_backend.h" ]
+  deps = [
+    "$dir_pw_assert:facade",
+    "$dir_pw_preprocessor",
+    pw_assert_basic_HANDLER_BACKEND,
+  ]
+  public = [
+    "public/pw_assert_basic/assert_basic.h",
+    "public/pw_assert_basic/handler.h",
+    "public_overrides/pw_assert_backend/assert_backend.h",
+  ]
+  sources = [ "assert_basic.cc" ]
 }
 
-pw_source_set("core") {
-  public_configs = [ ":default_config" ]
+# A basic handler backend using sysio.
+pw_source_set("basic_handler") {
   deps = [
+    ":handler.facade",
     "$dir_pw_assert:facade",
     "$dir_pw_preprocessor",
     "$dir_pw_string",
     "$dir_pw_sys_io",
   ]
-  public = [ "public/pw_assert_basic/assert_basic.h" ]
-  sources = [ "assert_basic.cc" ]
+  sources = [ "basic_handler.cc" ]
 }
 
 pw_doc_group("docs") {
diff --git a/pw_assert_basic/assert_basic.cc b/pw_assert_basic/assert_basic.cc
index e6a64f5..72f2e8c 100644
--- a/pw_assert_basic/assert_basic.cc
+++ b/pw_assert_basic/assert_basic.cc
@@ -12,155 +12,18 @@
 // License for the specific language governing permissions and limitations under
 // the License.
 
-// This is a very basic direct output log implementation with no buffering.
-
-//#define PW_LOG_MODULE_NAME "ASRT"
-//#include "pw_log/log.h"
-
-#include "pw_assert_basic/assert_basic.h"
-
-#include <cstring>
-
 #include "pw_assert/options.h"
-#include "pw_preprocessor/util.h"
-#include "pw_string/string_builder.h"
-#include "pw_sys_io/sys_io.h"
-
-// If 1, call C's standard abort() function on assert failure.
-#ifndef PW_ASSERT_BASIC_ABORT
-#define PW_ASSERT_BASIC_ABORT 1
-#endif  // PW_ASSERT_BASIC_ABORT
-
-// TODO(pwbug/17): Expose these through the config system.
-#define PW_ASSERT_BASIC_SHOW_BANNER 1
-#define PW_ASSERT_BASIC_USE_COLORS 1
-
-// ANSI color constants to control the terminal. Not Windows compatible.
-// clang-format off
-#if PW_ASSERT_BASIC_USE_COLORS
-#define MAGENTA   "\033[35m"
-#define YELLOW    "\033[33m"
-#define RED       "\033[31m"
-#define GREEN     "\033[32m"
-#define BLUE      "\033[96m"
-#define BLACK     "\033[30m"
-#define YELLOW_BG "\033[43m"
-#define WHITE_BG  "\033[47m"
-#define RED_BG    "\033[41m"
-#define BOLD      "\033[1m"
-#define RESET     "\033[0m"
-#else
-#define MAGENTA   ""
-#define YELLOW    ""
-#define RED       ""
-#define GREEN     ""
-#define BLUE      ""
-#define BLACK     ""
-#define YELLOW_BG ""
-#define WHITE_BG  ""
-#define RED_BG    ""
-#define BOLD      ""
-#define RESET     ""
-#endif  // PW_ASSERT_BASIC_USE_COLORS
-// clang-format on
-
-static const char* kCrashBanner[] = {
-    " ",
-    "   ▄████▄      ██▀███      ▄▄▄           ██████     ██░ ██    ",
-    "  ▒██▀ ▀█     ▓██ ▒ ██▒   ▒████▄       ▒██    ▒    ▓██░ ██▒   ",
-    "  ▒▓█ 💥 ▄    ▓██ ░▄█ ▒   ▒██  ▀█▄     ░ ▓██▄      ▒██▀▀██░   ",
-    "  ▒▓▓▄ ▄██▒   ▒██▀▀█▄     ░██▄▄▄▄██      ▒   ██▒   ░▓█ ░██    ",
-    "  ▒ ▓███▀ ░   ░██▓ ▒██▒    ▓█   ▓██▒   ▒██████▒▒   ░▓█▒░██▓   ",
-    "  ░ ░▒ ▒  ░   ░ ▒▓ ░▒▓░    ▒▒   ▓▒█░   ▒ ▒▓▒ ▒ ░    ▒ ░░▒░▒   ",
-    "    ░  ▒        ░▒ ░ ▒░     ▒   ▒▒ ░   ░ ░▒  ░ ░    ▒ ░▒░ ░   ",
-    "  ░             ░░   ░      ░   ▒      ░  ░  ░      ░  ░░ ░   ",
-    "  ░ ░            ░              ░  ░         ░      ░  ░  ░   ",
-    "  ░",
-    " ",
-};
-
-using pw::sys_io::WriteLine;
-
-typedef pw::StringBuffer<150> Buffer;
-
-extern "C" void pw_Crash(const char* file_name,
-                         int line_number,
-                         const char* function_name,
-                         const char* message,
-                         ...) {
-  // As a matter of usability, crashes should be visible; make it so.
-#if PW_ASSERT_BASIC_SHOW_BANNER
-  WriteLine(RED);
-  for (const char* line : kCrashBanner) {
-    WriteLine(line);
-  }
-  WriteLine(RESET);
-#endif  // PW_ASSERT_BASIC_SHOW_BANNER
-
-  WriteLine(
-      "  Welp, that didn't go as planned. "
-      "It seems we crashed. Terribly sorry!");
-  WriteLine("");
-  WriteLine(YELLOW "  CRASH MESSAGE" RESET);
-  WriteLine("");
-  {
-    Buffer buffer;
-    buffer << "     ";
-    va_list args;
-    va_start(args, message);
-    buffer.FormatVaList(message, args);
-    va_end(args);
-    WriteLine(buffer.view());
-  }
-
-  WriteLine("");
-  WriteLine(YELLOW "  CRASH FILE & LINE" RESET);
-  WriteLine("");
-  {
-    Buffer buffer;
-    buffer.Format("     %s:%d", file_name, line_number);
-    WriteLine(buffer.view());
-  }
-  WriteLine("");
-  WriteLine(YELLOW "  CRASH FUNCTION" RESET);
-  WriteLine("");
-  {
-    Buffer buffer;
-    buffer.Format("     %s", function_name);
-    WriteLine(buffer.view());
-  }
-  WriteLine("");
-
-  // TODO(pwbug/95): Perhaps surprisingly, this doesn't actually crash the
-  // device. At some point we'll have a reboot BSP function or similar, but for
-  // now this is acceptable since no one is using this basic backend.
-  if (!PW_ASSERT_BASIC_DISABLE_NORETURN) {
-    if (PW_ASSERT_BASIC_ABORT) {
-      abort();
-    } else {
-      WriteLine(MAGENTA "  HANG TIME" RESET);
-      WriteLine("");
-      WriteLine(
-          "     ... until a debugger joins. System is waiting in a while(1)");
-      while (1) {
-      }
-    }
-    PW_UNREACHABLE;
-  } else {
-    WriteLine(MAGENTA "  NOTE: YOU ARE IN ASSERT BASIC TEST MODE" RESET);
-    WriteLine("");
-    WriteLine("     This build returns from the crash handler for testing.");
-    WriteLine("     If you see this message in production, your build is ");
-    WriteLine("     incorrectly configured. Search for");
-    WriteLine("     PW_ASSERT_BASIC_DISABLE_NORETURN to fix it.");
-    WriteLine("");
-  }
-}
+#include "pw_assert_basic/handler.h"
 
 extern "C" void pw_assert_HandleFailure(void) {
 #if PW_ASSERT_ENABLE_DEBUG
-  pw_Crash("", 0, "", "Crash: PW_ASSERT() or PW_DASSERT() failure");
+  pw_assert_basic_HandleFailure(
+      nullptr, -1, nullptr, "Crash: PW_ASSERT() or PW_DASSERT() failure");
 #else
-  pw_Crash("", 0, "", "Crash: PW_ASSERT() failure. Note: PW_DASSERT disabled");
+  pw_assert_basic_HandleFailure(
+      nullptr,
+      -1,
+      nullptr,
+      "Crash: PW_ASSERT() failure. Note: PW_DASSERT disabled");
 #endif  // PW_ASSERT_ENABLE_DEBUG
 }
diff --git a/pw_assert_basic/backend.gni b/pw_assert_basic/backend.gni
new file mode 100644
index 0000000..49e9d33
--- /dev/null
+++ b/pw_assert_basic/backend.gni
@@ -0,0 +1,29 @@
+# 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")
+
+declare_args() {
+  # Handler backend for the pw_assert_basic module which implements
+  # pw_assert_basic_HandleFailure, this defaults to the basic_handler.
+  #
+  # Note: Don't confuse pw_assert_BACKEND and pw_assert_basic_HANDLER_BACKEND:
+  # 1) pw_assert_BACKEND must be set to dir_pw_assert_basic in order to
+  #    use this module which ensures that asserts always invoke
+  #    pw_assert_basic_HandleFailure.
+  # 2) pw_assert_basic_HANDLER_BACKEND allows you to switch out the
+  #    implementation of the handler which is invoked (i.e.
+  #    pw_assert_basic_HandleFailure).
+  pw_assert_basic_HANDLER_BACKEND = "$dir_pw_assert_basic:basic_handler"
+}
diff --git a/pw_assert_basic/basic_handler.cc b/pw_assert_basic/basic_handler.cc
new file mode 100644
index 0000000..29d44e3
--- /dev/null
+++ b/pw_assert_basic/basic_handler.cc
@@ -0,0 +1,171 @@
+// Copyright 2021 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.
+
+// This is a very basic direct output log implementation with no buffering.
+
+//#define PW_LOG_MODULE_NAME "ASRT"
+//#include "pw_log/log.h"
+
+#include <cstring>
+
+#include "pw_assert/options.h"
+#include "pw_assert_basic/handler.h"
+#include "pw_preprocessor/util.h"
+#include "pw_string/string_builder.h"
+#include "pw_sys_io/sys_io.h"
+
+// If 1, call C's standard abort() function on assert failure.
+#ifndef PW_ASSERT_BASIC_ABORT
+#define PW_ASSERT_BASIC_ABORT 1
+#endif  // PW_ASSERT_BASIC_ABORT
+
+// TODO(pwbug/17): Expose these through the config system.
+#define PW_ASSERT_BASIC_SHOW_BANNER 1
+#define PW_ASSERT_BASIC_USE_COLORS 1
+
+// ANSI color constants to control the terminal. Not Windows compatible.
+// clang-format off
+#if PW_ASSERT_BASIC_USE_COLORS
+#define MAGENTA   "\033[35m"
+#define YELLOW    "\033[33m"
+#define RED       "\033[31m"
+#define GREEN     "\033[32m"
+#define BLUE      "\033[96m"
+#define BLACK     "\033[30m"
+#define YELLOW_BG "\033[43m"
+#define WHITE_BG  "\033[47m"
+#define RED_BG    "\033[41m"
+#define BOLD      "\033[1m"
+#define RESET     "\033[0m"
+#else
+#define MAGENTA   ""
+#define YELLOW    ""
+#define RED       ""
+#define GREEN     ""
+#define BLUE      ""
+#define BLACK     ""
+#define YELLOW_BG ""
+#define WHITE_BG  ""
+#define RED_BG    ""
+#define BOLD      ""
+#define RESET     ""
+#endif  // PW_ASSERT_BASIC_USE_COLORS
+// clang-format on
+
+static const char* kCrashBanner[] = {
+    " ",
+    "   ▄████▄      ██▀███      ▄▄▄           ██████     ██░ ██    ",
+    "  ▒██▀ ▀█     ▓██ ▒ ██▒   ▒████▄       ▒██    ▒    ▓██░ ██▒   ",
+    "  ▒▓█ 💥 ▄    ▓██ ░▄█ ▒   ▒██  ▀█▄     ░ ▓██▄      ▒██▀▀██░   ",
+    "  ▒▓▓▄ ▄██▒   ▒██▀▀█▄     ░██▄▄▄▄██      ▒   ██▒   ░▓█ ░██    ",
+    "  ▒ ▓███▀ ░   ░██▓ ▒██▒    ▓█   ▓██▒   ▒██████▒▒   ░▓█▒░██▓   ",
+    "  ░ ░▒ ▒  ░   ░ ▒▓ ░▒▓░    ▒▒   ▓▒█░   ▒ ▒▓▒ ▒ ░    ▒ ░░▒░▒   ",
+    "    ░  ▒        ░▒ ░ ▒░     ▒   ▒▒ ░   ░ ░▒  ░ ░    ▒ ░▒░ ░   ",
+    "  ░             ░░   ░      ░   ▒      ░  ░  ░      ░  ░░ ░   ",
+    "  ░ ░            ░              ░  ░         ░      ░  ░  ░   ",
+    "  ░",
+    " ",
+};
+
+using pw::sys_io::WriteLine;
+
+typedef pw::StringBuffer<150> Buffer;
+
+extern "C" void pw_assert_basic_HandleFailure(const char* file_name,
+                                              int line_number,
+                                              const char* function_name,
+                                              const char* format,
+                                              ...) {
+  // As a matter of usability, crashes should be visible; make it so.
+#if PW_ASSERT_BASIC_SHOW_BANNER
+  WriteLine(RED);
+  for (const char* line : kCrashBanner) {
+    WriteLine(line);
+  }
+  WriteLine(RESET);
+#endif  // PW_ASSERT_BASIC_SHOW_BANNER
+
+  WriteLine(
+      "  Welp, that didn't go as planned. "
+      "It seems we crashed. Terribly sorry!");
+  WriteLine("");
+  WriteLine(YELLOW "  CRASH MESSAGE" RESET);
+  WriteLine("");
+  {
+    Buffer buffer;
+    buffer << "     ";
+    va_list args;
+    va_start(args, format);
+    buffer.FormatVaList(format, args);
+    va_end(args);
+    WriteLine(buffer.view());
+  }
+
+  if (file_name != nullptr && line_number != -1) {
+    WriteLine("");
+    WriteLine(YELLOW "  CRASH FILE & LINE" RESET);
+    WriteLine("");
+    {
+      Buffer buffer;
+      buffer.Format("     %s:%d", file_name, line_number);
+      WriteLine(buffer.view());
+    }
+  }
+  if (function_name != nullptr) {
+    WriteLine("");
+    WriteLine(YELLOW "  CRASH FUNCTION" RESET);
+    WriteLine("");
+    {
+      Buffer buffer;
+      buffer.Format("     %s", function_name);
+      WriteLine(buffer.view());
+    }
+  }
+
+  // TODO(pwbug/95): Perhaps surprisingly, this doesn't actually crash the
+  // device. At some point we'll have a reboot BSP function or similar, but for
+  // now this is acceptable since no one is using this basic backend.
+  if (!PW_ASSERT_BASIC_DISABLE_NORETURN) {
+    if (PW_ASSERT_BASIC_ABORT) {
+      // Using exit() instead of abort() here because exit() allows for the
+      // destructors for the stdout buffers to be called. This addresses an
+      // issue that occurs when Bazel's execution wrapper binds stdout. This
+      // results in stdout going from a synchronized to a buffered file
+      // descriptor. In this case when abort() is called in a Bazel test the
+      // program exits before the stdout buffer can be synchronized with Bazel's
+      // execution wrapper, the resulting output from a test is an empty output
+      // buffer. Using exit() here allows the destructors to synchronized the
+      // stdout buffer before exiting.
+      exit(1);
+    } else {
+      WriteLine("");
+      WriteLine(MAGENTA "  HANG TIME" RESET);
+      WriteLine("");
+      WriteLine(
+          "     ... until a debugger joins. System is waiting in a while(1)");
+      while (1) {
+      }
+    }
+    PW_UNREACHABLE;
+  } else {
+    WriteLine("");
+    WriteLine(MAGENTA "  NOTE: YOU ARE IN ASSERT BASIC TEST MODE" RESET);
+    WriteLine("");
+    WriteLine("     This build returns from the crash handler for testing.");
+    WriteLine("     If you see this message in production, your build is ");
+    WriteLine("     incorrectly configured. Search for");
+    WriteLine("     PW_ASSERT_BASIC_DISABLE_NORETURN to fix it.");
+    WriteLine("");
+  }
+}
diff --git a/pw_assert_basic/docs.rst b/pw_assert_basic/docs.rst
index 45ff8f7..2ddc367 100644
--- a/pw_assert_basic/docs.rst
+++ b/pw_assert_basic/docs.rst
@@ -7,5 +7,60 @@
 --------
 Overview
 --------
-This is a simple assert backend to implement the ``pw_assert`` facade.
+This is a simple assert backend to implement the ``pw_assert`` facade which
+relies on a single function ``pw_assert_basic_HandleFailure`` handler facade
+which defaults to the ``basic_handler`` backend. Users may be interested in
+overriding this default in case they need to do things like transition to
+crash time logging or implementing application specific reset and/or hang
+behavior.
 
+.. attention::
+
+  This log backend comes with a very large ROM and potentially RAM cost. It is
+  intended mostly for ease of initial bringup. We encourage teams to use
+  tokenized asserts since they are much smaller both in terms of ROM and RAM.
+
+Custom handler backend example
+------------------------------
+Here is a typical usage example implementing a simple handler backend which uses
+a UART backed sys_io implementation to print during crash time and then reboots.
+Note that this example uses CMSIS and a psuedo STM HAL, as a backend implementer
+you are responsible for using whatever APIs make sense for your use case(s).
+
+.. code-block:: cpp
+
+  #include "cmsis.h"
+  #include "hal.h"
+  #include "pw_string/string_builder.h"
+
+  using pw::sys_io::WriteLine;
+
+  extern "C" void pw_assert_basic_HandleFailure(
+      [[maybe_unused]] const char* file_name,
+      [[maybe_unused]] int line_number,
+      [[maybe_unused]] const char* function_name,
+      const char* message,
+      ...) {
+    // Global interrupt disable for a single core microcontroller.
+    __disable_irq();
+
+    // Re-initialize the UART to ensure it's safe to use at crash time.
+    HAL_UART_DeInit(sys_io_uart);
+    HAL_UART_Init(sys_io_uart);
+
+    WriteLine(
+        "  Welp, that didn't go as planned. "
+        "It seems we crashed. Terribly sorry! Assert reason:");
+    {
+      pw::StringBuffer<150> buffer;
+      buffer << "     ";
+      va_list args;
+      va_start(args, format);
+      buffer.FormatVaList(format, args);
+      va_end(args);
+      WriteLine(buffer.view());
+    }
+
+    // Reboot the microcontroller.
+    HAL_NVIC_SystemReset();
+  }
diff --git a/pw_assert_basic/public/pw_assert_basic/assert_basic.h b/pw_assert_basic/public/pw_assert_basic/assert_basic.h
index 269c0a6..cc933d0 100644
--- a/pw_assert_basic/public/pw_assert_basic/assert_basic.h
+++ b/pw_assert_basic/public/pw_assert_basic/assert_basic.h
@@ -13,42 +13,26 @@
 // the License.
 #pragma once
 
+#include "pw_assert_basic/handler.h"
 #include "pw_preprocessor/compiler.h"
 #include "pw_preprocessor/util.h"
 
-// This is needed for testing the basic crash handler.
-// TODO(pwbug/17): Replace when Pigweed config system is added.
-#define PW_ASSERT_BASIC_DISABLE_NORETURN 0
-#if PW_ASSERT_BASIC_DISABLE_NORETURN
-#define PW_ASSERT_NORETURN
-#else
-#define PW_ASSERT_NORETURN PW_NO_RETURN
-#endif
-
-PW_EXTERN_C_START
-
-// Crash, including a message with the listed attributes.
-void pw_Crash(const char* file_name,
-              int line_number,
-              const char* function_name,
-              const char* message,
-              ...) PW_PRINTF_FORMAT(4, 5) PW_ASSERT_NORETURN;
-
-PW_EXTERN_C_END
+// Die with a message with many attributes included. This is the crash macro
+// frontend that funnels everything into the C handler provided by the user,
+// pw_assert_basic_HandleFailure().
+#define PW_HANDLE_CRASH(...)     \
+  pw_assert_basic_HandleFailure( \
+      __FILE__, __LINE__, __func__ PW_COMMA_ARGS(__VA_ARGS__))
 
 // Die with a message with many attributes included. This is the crash macro
-// frontend that funnels everything into the C handler above, pw_Crash().
-#define PW_HANDLE_CRASH(...) \
-  pw_Crash(__FILE__, __LINE__, __func__ PW_COMMA_ARGS(__VA_ARGS__))
-
-// Die with a message with many attributes included. This is the crash macro
-// frontend that funnels everything into the C handler above, pw_Crash().
-#define PW_HANDLE_ASSERT_FAILURE(condition_string, message, ...) \
-  pw_Crash(__FILE__,                                             \
-           __LINE__,                                             \
-           __func__,                                             \
-           "Check failed: " condition_string                     \
-           ". " message PW_COMMA_ARGS(__VA_ARGS__))
+// frontend that funnels everything into the C handler provided by the user,
+// pw_assert_basic_HandleFailure().
+#define PW_HANDLE_ASSERT_FAILURE(condition_string, message, ...)  \
+  pw_assert_basic_HandleFailure(__FILE__,                         \
+                                __LINE__,                         \
+                                __func__,                         \
+                                "Check failed: " condition_string \
+                                ". " message PW_COMMA_ARGS(__VA_ARGS__))
 
 // Sample assert failure message produced by the below implementation:
 //
@@ -65,7 +49,8 @@
                                                 arg_b_val,         \
                                                 type_fmt,          \
                                                 message, ...)      \
-  pw_Crash(__FILE__,                                               \
+  pw_assert_basic_HandleFailure(                                   \
+           __FILE__,                                               \
            __LINE__,                                               \
            __func__,                                               \
            "Check failed: "                                        \
diff --git a/pw_assert_basic/public/pw_assert_basic/handler.h b/pw_assert_basic/public/pw_assert_basic/handler.h
new file mode 100644
index 0000000..33dd354
--- /dev/null
+++ b/pw_assert_basic/public/pw_assert_basic/handler.h
@@ -0,0 +1,45 @@
+// 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.
+#pragma once
+
+#include "pw_preprocessor/compiler.h"
+#include "pw_preprocessor/util.h"
+
+// This is needed for testing the basic crash handler.
+// TODO(pwbug/17): Replace when Pigweed config system is added.
+#define PW_ASSERT_BASIC_DISABLE_NORETURN 0
+#if PW_ASSERT_BASIC_DISABLE_NORETURN
+#define PW_ASSERT_NORETURN
+#else
+#define PW_ASSERT_NORETURN PW_NO_RETURN
+#endif
+
+PW_EXTERN_C_START
+
+// Application-defined assert failure handler for pw_assert_basic.
+// file_name - may be nullptr if not available
+// line_number - may be -1 if not available
+// function_name - may be nullptr if not available
+// format & varags - The assert reason can be built using the format string and
+//     the varargs.
+//
+// Applications must define this function; it is not defined by pw_assert_basic.
+void pw_assert_basic_HandleFailure(const char* file_name,
+                                   int line_number,
+                                   const char* function_name,
+                                   const char* format,
+                                   ...)
+    PW_PRINTF_FORMAT(4, 5) PW_ASSERT_NORETURN;
+
+PW_EXTERN_C_END
diff --git a/pw_base64/BUILD.gn b/pw_base64/BUILD.gn
index 006b427..07d9674 100644
--- a/pw_base64/BUILD.gn
+++ b/pw_base64/BUILD.gn
@@ -26,12 +26,10 @@
   public_configs = [ ":default_config" ]
   public = [ "public/pw_base64/base64.h" ]
   sources = [ "base64.cc" ]
-  public_deps = [ "$dir_pw_span" ]
 }
 
 pw_test_group("tests") {
   tests = [ ":base64_test" ]
-  group_deps = [ "$dir_pw_span:tests" ]
 }
 
 pw_test("base64_test") {
diff --git a/pw_bloat/BUILD.gn b/pw_bloat/BUILD.gn
index 9c6d123..17d0767 100644
--- a/pw_bloat/BUILD.gn
+++ b/pw_bloat/BUILD.gn
@@ -28,10 +28,15 @@
   public_configs = [ ":default_config" ]
   public = [ "public/pw_bloat/bloat_this_binary.h" ]
   sources = [ "bloat_this_binary.cc" ]
+  deps = [
+    dir_pw_assert,
+    dir_pw_log,
+  ]
 }
 
 pw_source_set("base_main") {
   sources = [ "base_main.cc" ]
+  deps = [ ":bloat_this_binary" ]
 }
 
 # Standard minimal base binary for bloat reports.
diff --git a/pw_bloat/bloat.gni b/pw_bloat/bloat.gni
index 9acfd05..432358c 100644
--- a/pw_bloat/bloat.gni
+++ b/pw_bloat/bloat.gni
@@ -161,6 +161,7 @@
           pw_doc_sources = rebase_path([ _doc_rst_output ], root_build_dir)
         }
         script = "$dir_pw_bloat/py/pw_bloat/no_bloaty.py"
+        python_deps = [ "$dir_pw_bloat/py" ]
         args = [ rebase_path(_doc_rst_output) ]
         outputs = [ _doc_rst_output ]
       }
@@ -176,12 +177,13 @@
           pw_doc_sources = rebase_path([ _doc_rst_output ], root_build_dir)
         }
         script = "$dir_pw_bloat/py/pw_bloat/bloat.py"
+        python_deps = [ "$dir_pw_bloat/py" ]
         inputs = _bloaty_configs
         outputs = [
           "$target_gen_dir/${target_name}.txt",
           _doc_rst_output,
         ]
-        deps = _all_target_dependencies + [ "$dir_pw_bloat/py" ]
+        deps = _all_target_dependencies
         args = _bloat_script_args + _binary_paths
 
         # Print size reports to stdout when they are generated.
@@ -321,6 +323,7 @@
         pw_doc_sources = rebase_path([ _doc_rst_output ], root_build_dir)
       }
       script = "$dir_pw_bloat/py/pw_bloat/no_toolchains.py"
+      python_deps = [ "$dir_pw_bloat/py" ]
       args = [ rebase_path(_doc_rst_output) ]
       outputs = [ _doc_rst_output ]
     }
diff --git a/pw_bloat/bloat_this_binary.cc b/pw_bloat/bloat_this_binary.cc
index 5ecd0ba..bf09f22 100644
--- a/pw_bloat/bloat_this_binary.cc
+++ b/pw_bloat/bloat_this_binary.cc
@@ -16,6 +16,10 @@
 
 #include <cstring>
 
+#include "pw_assert/assert.h"
+#include "pw_assert/light.h"
+#include "pw_log/log.h"
+
 namespace pw::bloat {
 
 char* volatile non_optimizable_pointer;
@@ -50,6 +54,14 @@
                kRandomLargeNumber);
 
   *non_optimizable_pointer = std::strlen(non_optimizable_pointer);
+
+  // This code ensures users do not have to pay for the base cost of using
+  // asserts and logging.
+  PW_ASSERT(*non_optimizable_pointer);
+  PW_DASSERT(*non_optimizable_pointer);
+  PW_CHECK_INT_GE(*non_optimizable_pointer, 0, "Ensure this logic stays");
+  PW_DCHECK_INT_GE(*non_optimizable_pointer, 0, "Ensure this logic stays");
+  PW_LOG_INFO("We care about optimizing: %d", *non_optimizable_pointer);
 }
 
 }  // namespace pw::bloat
diff --git a/pw_bloat/py/BUILD.gn b/pw_bloat/py/BUILD.gn
index 74f574f..c6d5ea3 100644
--- a/pw_bloat/py/BUILD.gn
+++ b/pw_bloat/py/BUILD.gn
@@ -26,4 +26,6 @@
     "pw_bloat/no_bloaty.py",
     "pw_bloat/no_toolchains.py",
   ]
+  pylintrc = "$dir_pigweed/.pylintrc"
+  python_deps = [ "$dir_pw_cli/py" ]
 }
diff --git a/pw_bloat/py/pw_bloat/bloat.py b/pw_bloat/py/pw_bloat/bloat.py
index c3e145b..ee79d79 100755
--- a/pw_bloat/py/pw_bloat/bloat.py
+++ b/pw_bloat/py/pw_bloat/bloat.py
@@ -20,14 +20,13 @@
 import os
 import subprocess
 import sys
-
 from typing import List, Iterable, Optional
 
+import pw_cli.log
+
 from pw_bloat.binary_diff import BinaryDiff
 from pw_bloat import bloat_output
 
-import pw_cli.log
-
 _LOG = logging.getLogger(__name__)
 
 
diff --git a/pw_bloat/py/setup.py b/pw_bloat/py/setup.py
index 73410da..73c2a7e 100644
--- a/pw_bloat/py/setup.py
+++ b/pw_bloat/py/setup.py
@@ -24,4 +24,5 @@
     packages=setuptools.find_packages(),
     package_data={'pw_bloat': ['py.typed']},
     zip_safe=False,
+    install_requires=['pw_cli'],
 )
diff --git a/pw_blob_store/BUILD.gn b/pw_blob_store/BUILD.gn
index e6c9311..236c8d1 100644
--- a/pw_blob_store/BUILD.gn
+++ b/pw_blob_store/BUILD.gn
@@ -14,6 +14,7 @@
 
 import("//build_overrides/pigweed.gni")
 
+import("$dir_pw_bloat/bloat.gni")
 import("$dir_pw_build/target_types.gni")
 import("$dir_pw_docgen/docs.gni")
 import("$dir_pw_unit_test/test.gni")
@@ -84,4 +85,29 @@
 
 pw_doc_group("docs") {
   sources = [ "docs.rst" ]
+  report_deps = [ ":blob_size" ]
+}
+
+pw_size_report("blob_size") {
+  title = "Pigweed BlobStore size report"
+
+  # To see all the symbols, uncomment the following:
+  # Note: The size report RST table won't be generated when full_report = true.
+  # full_report = true
+
+  binaries = [
+    {
+      target = "size_report:basic_blob"
+      base = "size_report:base"
+      label = "BlobStore"
+    },
+  ]
+
+  binaries += [
+    {
+      target = "size_report:deferred_write_blob"
+      base = "size_report:base"
+      label = "BlobStore with deferred write"
+    },
+  ]
 }
diff --git a/pw_blob_store/blob_store.cc b/pw_blob_store/blob_store.cc
index 27398cc..39d259c 100644
--- a/pw_blob_store/blob_store.cc
+++ b/pw_blob_store/blob_store.cc
@@ -16,6 +16,7 @@
 
 #include <algorithm>
 
+#include "pw_assert/assert.h"
 #include "pw_log/log.h"
 #include "pw_status/try.h"
 
@@ -23,16 +24,14 @@
 
 Status BlobStore::Init() {
   if (initialized_) {
-    return Status::Ok();
+    return OkStatus();
   }
 
   PW_LOG_INFO("Init BlobStore");
 
   const size_t write_buffer_size_alignment =
-      write_buffer_.size_bytes() % partition_.alignment_bytes();
+      flash_write_size_bytes_ % partition_.alignment_bytes();
   PW_CHECK_UINT_EQ((write_buffer_size_alignment), 0);
-  PW_CHECK_UINT_GE(write_buffer_.size_bytes(), partition_.alignment_bytes());
-  PW_CHECK_UINT_LE(write_buffer_.size_bytes(), partition_.sector_size_bytes());
   PW_CHECK_UINT_GE(write_buffer_.size_bytes(), flash_write_size_bytes_);
   PW_CHECK_UINT_GE(flash_write_size_bytes_, partition_.alignment_bytes());
 
@@ -46,7 +45,7 @@
 
     PW_LOG_DEBUG("BlobStore init - Have valid blob of %u bytes",
                  static_cast<unsigned>(write_address_));
-    return Status::Ok();
+    return OkStatus();
   }
 
   // No saved blob, check for flash being erased.
@@ -61,7 +60,7 @@
   } else {
     PW_LOG_DEBUG("BlobStore init - not erased");
   }
-  return Status::Ok();
+  return OkStatus();
 }
 
 Status BlobStore::LoadMetadata() {
@@ -77,7 +76,7 @@
     return Status::DataLoss();
   }
 
-  return Status::Ok();
+  return OkStatus();
 }
 
 size_t BlobStore::MaxDataSizeBytes() const { return partition_.size_bytes(); }
@@ -99,7 +98,7 @@
 
   Invalidate();
 
-  return Status::Ok();
+  return OkStatus();
 }
 
 Status BlobStore::OpenRead() {
@@ -120,7 +119,7 @@
   PW_LOG_DEBUG("Blob reader open");
 
   readers_open_++;
-  return Status::Ok();
+  return OkStatus();
 }
 
 Status BlobStore::CloseWrite() {
@@ -132,7 +131,7 @@
     }
 
     if (write_address_ == 0) {
-      return Status::Ok();
+      return OkStatus();
     }
 
     PW_LOG_DEBUG(
@@ -171,7 +170,7 @@
       return Status::DataLoss();
     }
 
-    return Status::Ok();
+    return OkStatus();
   };
 
   const Status status = do_close_write();
@@ -181,14 +180,14 @@
     valid_data_ = false;
     return Status::DataLoss();
   }
-  return Status::Ok();
+  return OkStatus();
 }
 
 Status BlobStore::CloseRead() {
   PW_CHECK_UINT_GT(readers_open_, 0);
   readers_open_--;
   PW_LOG_DEBUG("Blob reader close");
-  return Status::Ok();
+  return OkStatus();
 }
 
 Status BlobStore::Write(ConstByteSpan data) {
@@ -196,7 +195,7 @@
     return Status::DataLoss();
   }
   if (data.size_bytes() == 0) {
-    return Status::Ok();
+    return OkStatus();
   }
   if (WriteBytesRemaining() == 0) {
     return Status::OutOfRange();
@@ -240,7 +239,7 @@
       // If there was not enough bytes to finish filling the write buffer, there
       // should not be any bytes left.
       PW_DCHECK(data.size_bytes() == 0);
-      return Status::Ok();
+      return OkStatus();
     }
 
     // The write buffer is full, flush to flash.
@@ -277,7 +276,7 @@
     write_address_ += data.size_bytes();
   }
 
-  return Status::Ok();
+  return OkStatus();
 }
 
 Status BlobStore::AddToWriteBuffer(ConstByteSpan data) {
@@ -297,7 +296,7 @@
       write_buffer_.data() + bytes_in_buffer, data.data(), data.size_bytes());
   write_address_ += data.size_bytes();
 
-  return Status::Ok();
+  return OkStatus();
 }
 
 Status BlobStore::Flush() {
@@ -305,7 +304,7 @@
     return Status::DataLoss();
   }
   if (WriteBufferBytesUsed() == 0) {
-    return Status::Ok();
+    return OkStatus();
   }
   // Don't need to check available space, AddToWriteBuffer() will not enqueue
   // more than can be written to flash.
@@ -335,7 +334,7 @@
     PW_DCHECK_UINT_EQ(data.size_bytes(), 0);
   }
 
-  return Status::Ok();
+  return OkStatus();
 }
 
 Status BlobStore::FlushFinalPartialChunk() {
@@ -396,7 +395,7 @@
     // Always just erase. Erase is smart enough to only erase if needed.
     return Erase();
   }
-  return Status::Ok();
+  return OkStatus();
 }
 
 StatusWithSize BlobStore::Read(size_t offset, ByteSpan dest) const {
@@ -441,7 +440,7 @@
     // Erased blobs should be valid as soon as the flash is erased. Even though
     // there are 0 bytes written, they are valid.
     PW_DCHECK(valid_data_);
-    return Status::Ok();
+    return OkStatus();
   }
 
   Invalidate();
@@ -470,9 +469,7 @@
 
   Status status = kvs_.Delete(MetadataKey());
 
-  return (status == Status::Ok() || status == Status::NotFound())
-             ? Status::Ok()
-             : Status::Internal();
+  return (status.ok() || status.IsNotFound()) ? OkStatus() : Status::Internal();
 }
 
 Status BlobStore::ValidateChecksum() {
@@ -488,7 +485,7 @@
       return Status::DataLoss();
     }
 
-    return Status::Ok();
+    return OkStatus();
   }
 
   PW_LOG_DEBUG("Validate checksum of 0x%08x in flash for blob of %u bytes",
@@ -505,7 +502,7 @@
 
 Status BlobStore::CalculateChecksumFromFlash(size_t bytes_to_check) {
   if (checksum_algo_ == nullptr) {
-    return Status::Ok();
+    return OkStatus();
   }
 
   checksum_algo_->Reset();
@@ -526,7 +523,7 @@
   // Safe to ignore the return from Finish, checksum_algo_ keeps the state
   // information that it needs.
   checksum_algo_->Finish();
-  return Status::Ok();
+  return OkStatus();
 }
 
 }  // namespace pw::blob_store
diff --git a/pw_blob_store/blob_store_chunk_write_test.cc b/pw_blob_store/blob_store_chunk_write_test.cc
index cf7219d..3dabaa8 100644
--- a/pw_blob_store/blob_store_chunk_write_test.cc
+++ b/pw_blob_store/blob_store_chunk_write_test.cc
@@ -59,12 +59,12 @@
     snprintf(name, sizeof(name), "Blob%u", static_cast<unsigned>(chunk_size));
 
     BlobStoreBuffer<kBufferSize> blob(
-        name, partition_, &checksum, kvs::TestKvs());
-    EXPECT_EQ(Status::Ok(), blob.Init());
+        name, partition_, &checksum, kvs::TestKvs(), kBufferSize);
+    EXPECT_EQ(OkStatus(), blob.Init());
 
     BlobStore::BlobWriter writer(blob);
-    EXPECT_EQ(Status::Ok(), writer.Open());
-    EXPECT_EQ(Status::Ok(), writer.Erase());
+    EXPECT_EQ(OkStatus(), writer.Open());
+    EXPECT_EQ(OkStatus(), writer.Erase());
 
     ByteSpan source = source_buffer_;
     while (source.size_bytes() > 0) {
@@ -74,20 +74,20 @@
                    static_cast<unsigned>(write_size),
                    static_cast<unsigned>(source.size_bytes()));
 
-      ASSERT_EQ(Status::Ok(), writer.Write(source.first(write_size)));
+      ASSERT_EQ(OkStatus(), writer.Write(source.first(write_size)));
 
       source = source.subspan(write_size);
     }
 
-    EXPECT_EQ(Status::Ok(), writer.Close());
+    EXPECT_EQ(OkStatus(), writer.Close());
 
     // Use reader to check for valid data.
     BlobStore::BlobReader reader(blob);
-    ASSERT_EQ(Status::Ok(), reader.Open());
+    ASSERT_EQ(OkStatus(), reader.Open());
     Result<ConstByteSpan> result = reader.GetMemoryMappedBlob();
     ASSERT_TRUE(result.ok());
     VerifyFlash(result.value());
-    EXPECT_EQ(Status::Ok(), reader.Close());
+    EXPECT_EQ(OkStatus(), reader.Close());
   }
 
   void VerifyFlash(ConstByteSpan verify_bytes) {
diff --git a/pw_blob_store/blob_store_deferred_write_test.cc b/pw_blob_store/blob_store_deferred_write_test.cc
index 71996e1..02491e6 100644
--- a/pw_blob_store/blob_store_deferred_write_test.cc
+++ b/pw_blob_store/blob_store_deferred_write_test.cc
@@ -52,7 +52,6 @@
   // Fill the source buffer with random pattern based on given seed, written to
   // BlobStore in specified chunk size.
   void ChunkWriteTest(size_t chunk_size, size_t flush_interval) {
-    constexpr size_t kBufferSize = 256;
     constexpr size_t kWriteSize = 64;
     kvs::ChecksumCrc16 checksum;
 
@@ -63,10 +62,10 @@
 
     BlobStoreBuffer<kBufferSize> blob(
         name, partition_, &checksum, kvs::TestKvs(), kWriteSize);
-    EXPECT_EQ(Status::Ok(), blob.Init());
+    EXPECT_EQ(OkStatus(), blob.Init());
 
     BlobStore::DeferredWriter writer(blob);
-    EXPECT_EQ(Status::Ok(), writer.Open());
+    EXPECT_EQ(OkStatus(), writer.Open());
 
     ByteSpan source = buffer_;
     while (source.size_bytes() > 0) {
@@ -76,7 +75,7 @@
                    static_cast<unsigned>(write_size),
                    static_cast<unsigned>(source.size_bytes()));
 
-      ASSERT_EQ(Status::Ok(), writer.Write(source.first(write_size)));
+      ASSERT_EQ(OkStatus(), writer.Write(source.first(write_size)));
       // TODO: Add check that the write did not go to flash yet.
 
       source = source.subspan(write_size);
@@ -84,19 +83,19 @@
 
       if (bytes_since_flush >= flush_interval) {
         bytes_since_flush = 0;
-        ASSERT_EQ(Status::Ok(), writer.Flush());
+        ASSERT_EQ(OkStatus(), writer.Flush());
       }
     }
 
-    EXPECT_EQ(Status::Ok(), writer.Close());
+    EXPECT_EQ(OkStatus(), writer.Close());
 
     // Use reader to check for valid data.
     BlobStore::BlobReader reader(blob);
-    ASSERT_EQ(Status::Ok(), reader.Open());
+    ASSERT_EQ(OkStatus(), reader.Open());
     Result<ConstByteSpan> result = reader.GetMemoryMappedBlob();
     ASSERT_TRUE(result.ok());
     VerifyFlash(result.value());
-    EXPECT_EQ(Status::Ok(), reader.Close());
+    EXPECT_EQ(OkStatus(), reader.Close());
   }
 
   void VerifyFlash(ConstByteSpan verify_bytes) {
@@ -112,8 +111,9 @@
   }
 
   static constexpr size_t kFlashAlignment = 16;
-  static constexpr size_t kSectorSize = 2048;
-  static constexpr size_t kSectorCount = 2;
+  static constexpr size_t kSectorSize = 1024;
+  static constexpr size_t kSectorCount = 4;
+  static constexpr size_t kBufferSize = 2 * kSectorSize;
 
   kvs::FakeFlashMemoryBuffer<kSectorSize, kSectorCount> flash_;
   kvs::FlashPartition partition_;
@@ -157,7 +157,7 @@
 
 TEST_F(DeferredWriteTest, ChunkWrite64FullBufferFill) {
   InitBufferToRandom(0x9);
-  ChunkWriteTest(64, 256);
+  ChunkWriteTest(64, kBufferSize);
 }
 
 TEST_F(DeferredWriteTest, ChunkWrite256) {
diff --git a/pw_blob_store/blob_store_test.cc b/pw_blob_store/blob_store_test.cc
index 39666b4..e5ff2f1 100644
--- a/pw_blob_store/blob_store_test.cc
+++ b/pw_blob_store/blob_store_test.cc
@@ -73,23 +73,23 @@
     snprintf(name, sizeof(name), "TestBlobBlock");
 
     BlobStoreBuffer<kBufferSize> blob(
-        name, partition_, &checksum, kvs::TestKvs());
-    EXPECT_EQ(Status::Ok(), blob.Init());
+        name, partition_, &checksum, kvs::TestKvs(), kBufferSize);
+    EXPECT_EQ(OkStatus(), blob.Init());
 
     BlobStore::BlobWriter writer(blob);
-    EXPECT_EQ(Status::OK, writer.Open());
-    ASSERT_EQ(Status::OK, writer.Write(write_data));
-    EXPECT_EQ(Status::OK, writer.Close());
+    EXPECT_EQ(OkStatus(), writer.Open());
+    ASSERT_EQ(OkStatus(), writer.Write(write_data));
+    EXPECT_EQ(OkStatus(), writer.Close());
 
     // Use reader to check for valid data.
     BlobStore::BlobReader reader(blob);
-    ASSERT_EQ(Status::Ok(), reader.Open());
+    ASSERT_EQ(OkStatus(), reader.Open());
     Result<ConstByteSpan> result = reader.GetMemoryMappedBlob();
     ASSERT_TRUE(result.ok());
     EXPECT_EQ(write_size_bytes, result.value().size_bytes());
-    VerifyFlash(result.value());
-    VerifyFlash(flash_.buffer());
-    EXPECT_EQ(Status::OK, reader.Close());
+    VerifyFlash(result.value().first(write_size_bytes));
+    VerifyFlash(flash_.buffer().first(write_size_bytes));
+    EXPECT_EQ(OkStatus(), reader.Close());
   }
 
   // Open a new blob instance and read the blob using the given read chunk size.
@@ -99,23 +99,25 @@
     VerifyFlash(flash_.buffer());
 
     char name[16] = "TestBlobBlock";
-    BlobStoreBuffer<16> blob(name, partition_, &checksum, kvs::TestKvs());
-    EXPECT_EQ(Status::Ok(), blob.Init());
+    constexpr size_t kBufferSize = 16;
+    BlobStoreBuffer<kBufferSize> blob(
+        name, partition_, &checksum, kvs::TestKvs(), kBufferSize);
+    EXPECT_EQ(OkStatus(), blob.Init());
 
     // Use reader to check for valid data.
     BlobStore::BlobReader reader1(blob);
-    ASSERT_EQ(Status::Ok(), reader1.Open());
+    ASSERT_EQ(OkStatus(), reader1.Open());
     Result<ConstByteSpan> possible_blob = reader1.GetMemoryMappedBlob();
     ASSERT_TRUE(possible_blob.ok());
     VerifyFlash(possible_blob.value());
-    EXPECT_EQ(Status::Ok(), reader1.Close());
+    EXPECT_EQ(OkStatus(), reader1.Close());
 
     BlobStore::BlobReader reader(blob);
-    ASSERT_EQ(Status::Ok(), reader.Open());
+    ASSERT_EQ(OkStatus(), reader.Open());
 
-    std::array<std::byte, kBlobDataSize> read_buffer_;
+    std::array<std::byte, kBlobDataSize> read_buffer;
 
-    ByteSpan read_span = read_buffer_;
+    ByteSpan read_span = read_buffer;
     while (read_span.size_bytes() > 0) {
       size_t read_size = std::min(read_span.size_bytes(), read_chunk_size);
 
@@ -125,12 +127,12 @@
 
       ASSERT_EQ(read_span.size_bytes(), reader.ConservativeReadLimit());
       auto result = reader.Read(read_span.first(read_size));
-      ASSERT_EQ(result.status(), Status::Ok());
+      ASSERT_EQ(result.status(), OkStatus());
       read_span = read_span.subspan(read_size);
     }
-    EXPECT_EQ(Status::Ok(), reader.Close());
+    EXPECT_EQ(OkStatus(), reader.Close());
 
-    VerifyFlash(read_buffer_);
+    VerifyFlash(read_buffer);
   }
 
   void VerifyFlash(ConstByteSpan verify_bytes, size_t offset = 0) {
@@ -158,8 +160,42 @@
 TEST_F(BlobStoreTest, Init_Ok) {
   // TODO: Do init test with flash/kvs explicitly in the different possible
   // entry states.
-  BlobStoreBuffer<256> blob("Blob_OK", partition_, nullptr, kvs::TestKvs());
-  EXPECT_EQ(Status::Ok(), blob.Init());
+  constexpr size_t kBufferSize = 256;
+  BlobStoreBuffer<kBufferSize> blob(
+      "Blob_OK", partition_, nullptr, kvs::TestKvs(), kBufferSize);
+  EXPECT_EQ(OkStatus(), blob.Init());
+}
+
+TEST_F(BlobStoreTest, IsOpen) {
+  constexpr size_t kBufferSize = 256;
+  BlobStoreBuffer<kBufferSize> blob(
+      "Blob_open", partition_, nullptr, kvs::TestKvs(), kBufferSize);
+  EXPECT_EQ(OkStatus(), blob.Init());
+
+  BlobStore::DeferredWriter deferred_writer(blob);
+  EXPECT_EQ(false, deferred_writer.IsOpen());
+  EXPECT_EQ(OkStatus(), deferred_writer.Open());
+  EXPECT_EQ(true, deferred_writer.IsOpen());
+  EXPECT_EQ(OkStatus(), deferred_writer.Close());
+  EXPECT_EQ(false, deferred_writer.IsOpen());
+
+  BlobStore::BlobWriter writer(blob);
+  EXPECT_EQ(false, writer.IsOpen());
+  EXPECT_EQ(OkStatus(), writer.Open());
+  EXPECT_EQ(true, writer.IsOpen());
+
+  // Need to write something, so the blob reader is able to open.
+  std::array<std::byte, 64> tmp_buffer = {};
+  EXPECT_EQ(OkStatus(), writer.Write(tmp_buffer));
+  EXPECT_EQ(OkStatus(), writer.Close());
+  EXPECT_EQ(false, writer.IsOpen());
+
+  BlobStore::BlobReader reader(blob);
+  EXPECT_EQ(false, reader.IsOpen());
+  ASSERT_EQ(OkStatus(), reader.Open());
+  EXPECT_EQ(true, reader.IsOpen());
+  EXPECT_EQ(OkStatus(), reader.Close());
+  EXPECT_EQ(false, reader.IsOpen());
 }
 
 TEST_F(BlobStoreTest, Discard) {
@@ -173,39 +209,43 @@
   // TODO: Do this test with flash/kvs in the different entry state
   // combinations.
 
-  BlobStoreBuffer<256> blob(blob_title, partition_, &checksum, kvs::TestKvs());
-  EXPECT_EQ(Status::OK, blob.Init());
+  constexpr size_t kBufferSize = 256;
+  BlobStoreBuffer<kBufferSize> blob(
+      blob_title, partition_, &checksum, kvs::TestKvs(), kBufferSize);
+  EXPECT_EQ(OkStatus(), blob.Init());
 
   BlobStore::BlobWriter writer(blob);
 
-  EXPECT_EQ(Status::OK, writer.Open());
-  EXPECT_EQ(Status::OK, writer.Write(tmp_buffer));
+  EXPECT_EQ(OkStatus(), writer.Open());
+  EXPECT_EQ(OkStatus(), writer.Write(tmp_buffer));
 
   // The write does an implicit erase so there should be no key for this blob.
-  EXPECT_EQ(Status::NOT_FOUND,
+  EXPECT_EQ(Status::NotFound(),
             kvs::TestKvs().Get(blob_title, tmp_buffer).status());
-  EXPECT_EQ(Status::OK, writer.Close());
+  EXPECT_EQ(OkStatus(), writer.Close());
 
-  EXPECT_EQ(Status::OK, kvs::TestKvs().Get(blob_title, tmp_buffer).status());
+  EXPECT_EQ(OkStatus(), kvs::TestKvs().Get(blob_title, tmp_buffer).status());
 
-  EXPECT_EQ(Status::OK, writer.Open());
-  EXPECT_EQ(Status::OK, writer.Discard());
-  EXPECT_EQ(Status::OK, writer.Close());
+  EXPECT_EQ(OkStatus(), writer.Open());
+  EXPECT_EQ(OkStatus(), writer.Discard());
+  EXPECT_EQ(OkStatus(), writer.Close());
 
-  EXPECT_EQ(Status::NOT_FOUND,
+  EXPECT_EQ(Status::NotFound(),
             kvs::TestKvs().Get(blob_title, tmp_buffer).status());
 }
 
 TEST_F(BlobStoreTest, MultipleErase) {
-  BlobStoreBuffer<256> blob("Blob_OK", partition_, nullptr, kvs::TestKvs());
-  EXPECT_EQ(Status::Ok(), blob.Init());
+  constexpr size_t kBufferSize = 256;
+  BlobStoreBuffer<kBufferSize> blob(
+      "Blob_OK", partition_, nullptr, kvs::TestKvs(), kBufferSize);
+  EXPECT_EQ(OkStatus(), blob.Init());
 
   BlobStore::BlobWriter writer(blob);
-  EXPECT_EQ(Status::Ok(), writer.Open());
+  EXPECT_EQ(OkStatus(), writer.Open());
 
-  EXPECT_EQ(Status::Ok(), writer.Erase());
-  EXPECT_EQ(Status::Ok(), writer.Erase());
-  EXPECT_EQ(Status::Ok(), writer.Erase());
+  EXPECT_EQ(OkStatus(), writer.Erase());
+  EXPECT_EQ(OkStatus(), writer.Erase());
+  EXPECT_EQ(OkStatus(), writer.Erase());
 }
 
 TEST_F(BlobStoreTest, OffsetRead) {
@@ -218,19 +258,21 @@
   kvs::ChecksumCrc16 checksum;
 
   char name[16] = "TestBlobBlock";
-  BlobStoreBuffer<16> blob(name, partition_, &checksum, kvs::TestKvs());
-  EXPECT_EQ(Status::Ok(), blob.Init());
+  constexpr size_t kBufferSize = 16;
+  BlobStoreBuffer<kBufferSize> blob(
+      name, partition_, &checksum, kvs::TestKvs(), kBufferSize);
+  EXPECT_EQ(OkStatus(), blob.Init());
   BlobStore::BlobReader reader(blob);
-  ASSERT_EQ(Status::Ok(), reader.Open(kOffset));
+  ASSERT_EQ(OkStatus(), reader.Open(kOffset));
 
-  std::array<std::byte, kBlobDataSize - kOffset> read_buffer_;
-  ByteSpan read_span = read_buffer_;
+  std::array<std::byte, kBlobDataSize - kOffset> read_buffer;
+  ByteSpan read_span = read_buffer;
   ASSERT_EQ(read_span.size_bytes(), reader.ConservativeReadLimit());
 
   auto result = reader.Read(read_span);
-  ASSERT_EQ(result.status(), Status::Ok());
-  EXPECT_EQ(Status::Ok(), reader.Close());
-  VerifyFlash(read_buffer_, kOffset);
+  ASSERT_EQ(result.status(), OkStatus());
+  EXPECT_EQ(OkStatus(), reader.Close());
+  VerifyFlash(read_buffer, kOffset);
 }
 
 TEST_F(BlobStoreTest, InvalidReadOffset) {
@@ -242,12 +284,40 @@
   kvs::ChecksumCrc16 checksum;
 
   char name[16] = "TestBlobBlock";
-  BlobStoreBuffer<16> blob(name, partition_, &checksum, kvs::TestKvs());
-  EXPECT_EQ(Status::Ok(), blob.Init());
+  constexpr size_t kBufferSize = 16;
+  BlobStoreBuffer<kBufferSize> blob(
+      name, partition_, &checksum, kvs::TestKvs(), kBufferSize);
+  EXPECT_EQ(OkStatus(), blob.Init());
   BlobStore::BlobReader reader(blob);
   ASSERT_EQ(Status::InvalidArgument(), reader.Open(kOffset));
 }
 
+// Test reading with a read buffer larger than the available data in the
+TEST_F(BlobStoreTest, ReadBufferIsLargerThanData) {
+  InitSourceBufferToRandom(0x57326);
+
+  constexpr size_t kWriteBytes = 64;
+  WriteTestBlock(kWriteBytes);
+
+  kvs::ChecksumCrc16 checksum;
+
+  char name[16] = "TestBlobBlock";
+  constexpr size_t kBufferSize = 16;
+  BlobStoreBuffer<kBufferSize> blob(
+      name, partition_, &checksum, kvs::TestKvs(), kBufferSize);
+  EXPECT_EQ(OkStatus(), blob.Init());
+  BlobStore::BlobReader reader(blob);
+  ASSERT_EQ(OkStatus(), reader.Open());
+  EXPECT_EQ(kWriteBytes, reader.ConservativeReadLimit());
+
+  std::array<std::byte, kWriteBytes + 10> read_buffer;
+  ByteSpan read_span = read_buffer;
+
+  auto result = reader.Read(read_span);
+  ASSERT_EQ(result.status(), OkStatus());
+  EXPECT_EQ(OkStatus(), reader.Close());
+}
+
 TEST_F(BlobStoreTest, ChunkRead1) {
   InitSourceBufferToRandom(0x8675309);
   WriteTestBlock();
diff --git a/pw_blob_store/docs.rst b/pw_blob_store/docs.rst
index a84a22e..906d942 100644
--- a/pw_blob_store/docs.rst
+++ b/pw_blob_store/docs.rst
@@ -10,7 +10,7 @@
 
 Write and read are only done using the BlobWriter and BlobReader classes.
 
-Once a blob write is closed, reopening followed by a Discard(), Write(), or
+Once a blob write is closed, reopening for write followed by a Discard(), Write(), or
 Erase() will discard the previous blob.
 
 Write blob:
@@ -26,5 +26,12 @@
      BlobReader::GetMemoryMappedBlob().
   3) BlobReader::Close().
 
+Size report
+-----------
+The following size report showcases the memory usage of the blob store.
+
+.. include:: blob_size
+
+
 .. note::
   The documentation for this module is currently incomplete.
diff --git a/pw_blob_store/public/pw_blob_store/blob_store.h b/pw_blob_store/public/pw_blob_store/blob_store.h
index e00eb6f..f8f7b80 100644
--- a/pw_blob_store/public/pw_blob_store/blob_store.h
+++ b/pw_blob_store/public/pw_blob_store/blob_store.h
@@ -93,6 +93,8 @@
       return store_.CloseWrite();
     }
 
+    bool IsOpen() { return open_; }
+
     // Erase the blob partition and reset state for a new blob. Explicit calls
     // to Erase are optional, beginning a write will do any needed Erase.
     // Returns:
@@ -225,6 +227,8 @@
       return store_.CloseRead();
     }
 
+    bool IsOpen() { return open_; }
+
     // Probable (not guaranteed) minimum number of bytes at this time that can
     // be read. Returns zero if, in the current state, Read would return status
     // other than OK. See stream.h for additional details.
@@ -248,7 +252,6 @@
       PW_DASSERT(open_);
       StatusWithSize status = store_.Read(offset_, dest);
       if (status.ok()) {
-        PW_DASSERT(status.size() == dest.size_bytes());
         offset_ += status.size();
       }
       return status;
@@ -533,7 +536,7 @@
                            kvs::FlashPartition& partition,
                            kvs::ChecksumAlgorithm* checksum_algo,
                            kvs::KeyValueStore& kvs,
-                           size_t flash_write_size_bytes = kBufferSizeBytes)
+                           size_t flash_write_size_bytes)
       : BlobStore(name,
                   partition,
                   checksum_algo,
diff --git a/pw_blob_store/size_report/BUILD b/pw_blob_store/size_report/BUILD
new file mode 100644
index 0000000..4811b4f
--- /dev/null
+++ b/pw_blob_store/size_report/BUILD
@@ -0,0 +1,63 @@
+# Copyright 2021 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.
+
+load(
+    "//pw_build:pigweed.bzl",
+    "pw_cc_binary",
+)
+
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])  # Apache License 2.0
+
+pw_cc_binary(
+    name = "base",
+    srcs = ["base.cc"],
+    deps = [
+        "//pw_assert",
+        "//pw_bloat:bloat_this_binary",
+        "//pw_kvs",
+        "//pw_kvs:flash_test_partition",
+        "//pw_kvs:fake_flash_12_byte_partition",
+        "//pw_log",
+    ],
+)
+
+pw_cc_binary(
+    name = "basic_blob",
+    srcs = ["basic_blob.cc"],
+    deps = [
+        "//pw_assert",
+        "//pw_bloat:bloat_this_binary",
+        "//pw_blob_store",
+        "//pw_kvs",
+        "//pw_kvs:flash_test_partition",
+        "//pw_kvs:fake_flash_12_byte_partition",
+        "//pw_log",
+    ],
+)
+
+pw_cc_binary(
+    name = "deferred_write_blob",
+    srcs = ["deferred_write_blob.cc"],
+    deps = [
+        "//pw_assert",
+        "//pw_bloat:bloat_this_binary",
+        "//pw_blob_store",
+        "//pw_kvs",
+        "//pw_kvs:flash_test_partition",
+        "//pw_kvs:fake_flash_12_byte_partition",
+        "//pw_log",
+    ],
+)
diff --git a/pw_blob_store/size_report/BUILD.gn b/pw_blob_store/size_report/BUILD.gn
new file mode 100644
index 0000000..aca6b84
--- /dev/null
+++ b/pw_blob_store/size_report/BUILD.gn
@@ -0,0 +1,41 @@
+# Copyright 2021 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/target_types.gni")
+
+_deps = [
+  "$dir_pw_bloat:bloat_this_binary",
+  "$dir_pw_kvs:fake_flash_12_byte_partition",
+  "$dir_pw_kvs:flash_test_partition",
+  dir_pw_assert,
+  dir_pw_kvs,
+  dir_pw_log,
+]
+
+pw_executable("base") {
+  sources = [ "base.cc" ]
+  deps = _deps
+}
+
+pw_executable("basic_blob") {
+  sources = [ "basic_blob.cc" ]
+  deps = _deps + [ dir_pw_blob_store ]
+}
+
+pw_executable("deferred_write_blob") {
+  sources = [ "deferred_write_blob.cc" ]
+  deps = _deps + [ dir_pw_blob_store ]
+}
diff --git a/pw_blob_store/size_report/base.cc b/pw_blob_store/size_report/base.cc
new file mode 100644
index 0000000..8f0cc65
--- /dev/null
+++ b/pw_blob_store/size_report/base.cc
@@ -0,0 +1,78 @@
+// Copyright 2021 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.
+
+#include <cstring>
+
+#include "pw_assert/assert.h"
+#include "pw_bloat/bloat_this_binary.h"
+#include "pw_kvs/flash_test_partition.h"
+#include "pw_kvs/key_value_store.h"
+#include "pw_log/log.h"
+
+char working_buffer[256];
+volatile bool is_set;
+
+constexpr size_t kMaxSectorCount = 64;
+constexpr size_t kKvsMaxEntries = 32;
+
+// For KVS magic value always use a random 32 bit integer rather than a human
+// readable 4 bytes. See pw_kvs/format.h for more information.
+static constexpr pw::kvs::EntryFormat kvs_format = {.magic = 0x22d3f8a0,
+                                                    .checksum = nullptr};
+
+volatile size_t kvs_entry_count;
+
+pw::kvs::KeyValueStoreBuffer<kKvsMaxEntries, kMaxSectorCount> test_kvs(
+    &pw::kvs::FlashTestPartition(), kvs_format);
+
+int volatile* unoptimizable;
+
+int main() {
+  pw::bloat::BloatThisBinary();
+
+  // Start of base **********************
+  // Ensure we are paying the cost for log and assert.
+  PW_CHECK_INT_GE(*unoptimizable, 0, "Ensure this CHECK logic stays");
+  PW_LOG_INFO("We care about optimizing: %d", *unoptimizable);
+
+  void* result =
+      std::memset((void*)working_buffer, sizeof(working_buffer), 0x55);
+  is_set = (result != nullptr);
+
+  test_kvs.Init();
+
+  unsigned kvs_value = 42;
+  test_kvs.Put("example_key", kvs_value);
+
+  kvs_entry_count = test_kvs.size();
+
+  unsigned read_value = 0;
+  test_kvs.Get("example_key", &read_value);
+  test_kvs.Delete("example_key");
+
+  auto val = pw::kvs::FlashTestPartition().PartitionAddressToMcuAddress(0);
+  PW_LOG_INFO("Use the variable. %u", unsigned(*val));
+
+  std::array<std::byte, 32> blob_source_buffer;
+  pw::ConstByteSpan write_data = std::span(blob_source_buffer);
+  char name[16] = "BLOB";
+  std::array<std::byte, 32> read_buffer;
+  pw::ByteSpan read_span = read_buffer;
+  PW_LOG_INFO("Do something so variables are used. %u, %c, %u",
+              unsigned(write_data.size()),
+              name[0],
+              unsigned(read_span.size()));
+  // End of base **********************
+  return 0;
+}
diff --git a/pw_blob_store/size_report/basic_blob.cc b/pw_blob_store/size_report/basic_blob.cc
new file mode 100644
index 0000000..667bc01
--- /dev/null
+++ b/pw_blob_store/size_report/basic_blob.cc
@@ -0,0 +1,104 @@
+// Copyright 2021 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.
+
+#include <cstring>
+
+#include "pw_assert/assert.h"
+#include "pw_bloat/bloat_this_binary.h"
+#include "pw_blob_store/blob_store.h"
+#include "pw_kvs/flash_test_partition.h"
+#include "pw_kvs/key_value_store.h"
+#include "pw_log/log.h"
+
+char working_buffer[256];
+volatile bool is_set;
+
+constexpr size_t kMaxSectorCount = 64;
+constexpr size_t kKvsMaxEntries = 32;
+
+// For KVS magic value always use a random 32 bit integer rather than a human
+// readable 4 bytes. See pw_kvs/format.h for more information.
+static constexpr pw::kvs::EntryFormat kvs_format = {.magic = 0x22d3f8a0,
+                                                    .checksum = nullptr};
+
+volatile size_t kvs_entry_count;
+
+pw::kvs::KeyValueStoreBuffer<kKvsMaxEntries, kMaxSectorCount> test_kvs(
+    &pw::kvs::FlashTestPartition(), kvs_format);
+
+int volatile* unoptimizable;
+
+int main() {
+  pw::bloat::BloatThisBinary();
+
+  // Start of base **********************
+  // Ensure we are paying the cost for log and assert.
+  PW_CHECK_INT_GE(*unoptimizable, 0, "Ensure this CHECK logic stays");
+  PW_LOG_INFO("We care about optimizing: %d", *unoptimizable);
+
+  void* result =
+      std::memset((void*)working_buffer, sizeof(working_buffer), 0x55);
+  is_set = (result != nullptr);
+
+  test_kvs.Init();
+
+  unsigned kvs_value = 42;
+  test_kvs.Put("example_key", kvs_value);
+
+  kvs_entry_count = test_kvs.size();
+
+  unsigned read_value = 0;
+  test_kvs.Get("example_key", &read_value);
+  test_kvs.Delete("example_key");
+
+  auto val = pw::kvs::FlashTestPartition().PartitionAddressToMcuAddress(0);
+  PW_LOG_INFO("Use the variable. %u", unsigned(*val));
+
+  std::array<std::byte, 32> blob_source_buffer;
+  pw::ConstByteSpan write_data = std::span(blob_source_buffer);
+  char name[16] = "BLOB";
+  std::array<std::byte, 32> read_buffer;
+  pw::ByteSpan read_span = read_buffer;
+  PW_LOG_INFO("Do something so variables are used. %u, %c, %u",
+              unsigned(write_data.size()),
+              name[0],
+              unsigned(read_span.size()));
+  // End of base **********************
+
+  // Start of basic blob **********************
+  constexpr size_t kBufferSize = 1;
+
+  pw::blob_store::BlobStoreBuffer<kBufferSize> blob(
+      name, pw::kvs::FlashTestPartition(), nullptr, test_kvs, kBufferSize);
+  blob.Init();
+
+  // Use writer.
+  pw::blob_store::BlobStore::BlobWriter writer(blob);
+  writer.Open();
+  writer.Write(write_data);
+  writer.Close();
+
+  // Use reader.
+  pw::blob_store::BlobStore::BlobReader reader(blob);
+  reader.Open();
+  pw::Result<pw::ConstByteSpan> get_result = reader.GetMemoryMappedBlob();
+  PW_LOG_INFO("%d", get_result.ok());
+  auto reader_result = reader.Read(read_span);
+  reader.Close();
+  PW_LOG_INFO("%d", reader_result.ok());
+
+  // End of basic blob **********************
+
+  return 0;
+}
diff --git a/pw_blob_store/size_report/deferred_write_blob.cc b/pw_blob_store/size_report/deferred_write_blob.cc
new file mode 100644
index 0000000..db2f18a
--- /dev/null
+++ b/pw_blob_store/size_report/deferred_write_blob.cc
@@ -0,0 +1,105 @@
+// Copyright 2021 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.
+
+#include <cstring>
+
+#include "pw_assert/assert.h"
+#include "pw_bloat/bloat_this_binary.h"
+#include "pw_blob_store/blob_store.h"
+#include "pw_kvs/flash_test_partition.h"
+#include "pw_kvs/key_value_store.h"
+#include "pw_log/log.h"
+
+char working_buffer[256];
+volatile bool is_set;
+
+constexpr size_t kMaxSectorCount = 64;
+constexpr size_t kKvsMaxEntries = 32;
+
+// For KVS magic value always use a random 32 bit integer rather than a human
+// readable 4 bytes. See pw_kvs/format.h for more information.
+static constexpr pw::kvs::EntryFormat kvs_format = {.magic = 0x22d3f8a0,
+                                                    .checksum = nullptr};
+
+volatile size_t kvs_entry_count;
+
+pw::kvs::KeyValueStoreBuffer<kKvsMaxEntries, kMaxSectorCount> test_kvs(
+    &pw::kvs::FlashTestPartition(), kvs_format);
+
+int volatile* unoptimizable;
+
+int main() {
+  pw::bloat::BloatThisBinary();
+
+  // Start of base **********************
+  // Ensure we are paying the cost for log and assert.
+  PW_CHECK_INT_GE(*unoptimizable, 0, "Ensure this CHECK logic stays");
+  PW_LOG_INFO("We care about optimizing: %d", *unoptimizable);
+
+  void* result =
+      std::memset((void*)working_buffer, sizeof(working_buffer), 0x55);
+  is_set = (result != nullptr);
+
+  test_kvs.Init();
+
+  unsigned kvs_value = 42;
+  test_kvs.Put("example_key", kvs_value);
+
+  kvs_entry_count = test_kvs.size();
+
+  unsigned read_value = 0;
+  test_kvs.Get("example_key", &read_value);
+  test_kvs.Delete("example_key");
+
+  auto val = pw::kvs::FlashTestPartition().PartitionAddressToMcuAddress(0);
+  PW_LOG_INFO("Use the variable. %u", unsigned(*val));
+
+  std::array<std::byte, 32> blob_source_buffer;
+  pw::ConstByteSpan write_data = std::span(blob_source_buffer);
+  char name[16] = "BLOB";
+  std::array<std::byte, 32> read_buffer;
+  pw::ByteSpan read_span = read_buffer;
+  PW_LOG_INFO("Do something so variables are used. %u, %c, %u",
+              unsigned(write_data.size()),
+              name[0],
+              unsigned(read_span.size()));
+  // End of base **********************
+
+  // Start of deferred blob **********************
+  constexpr size_t kBufferSize = 1;
+
+  pw::blob_store::BlobStoreBuffer<kBufferSize> blob(
+      name, pw::kvs::FlashTestPartition(), nullptr, test_kvs, kBufferSize);
+  blob.Init();
+
+  // Use writer.
+  pw::blob_store::BlobStore::DeferredWriter writer(blob);
+  writer.Open();
+  writer.Write(write_data);
+  writer.Flush();
+  writer.Close();
+
+  // Use reader.
+  pw::blob_store::BlobStore::BlobReader reader(blob);
+  reader.Open();
+  pw::Result<pw::ConstByteSpan> get_result = reader.GetMemoryMappedBlob();
+  PW_LOG_INFO("%d", get_result.ok());
+  auto reader_result = reader.Read(read_span);
+  reader.Close();
+  PW_LOG_INFO("%d", reader_result.ok());
+
+  // End of deferred blob **********************
+
+  return 0;
+}
diff --git a/pw_boot_armv7m/BUILD b/pw_boot_armv7m/BUILD
index 0980c0a..52f7901 100644
--- a/pw_boot_armv7m/BUILD
+++ b/pw_boot_armv7m/BUILD
@@ -1,4 +1,4 @@
-# Copyright 2020 The Pigweed Authors
+# Copyright 2021 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
@@ -11,15 +11,28 @@
 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 # License for the specific language governing permissions and limitations under
 # the License.
+load(
+    "//pw_build:pigweed.bzl",
+    "pw_cc_library",
+)
 
 package(default_visibility = ["//visibility:public"])
 
 licenses(["notice"])  # Apache License 2.0
 
-filegroup(
+pw_cc_library(
     name = "pw_boot_armv7m",
     srcs = [
         "core_init.c",
         "public/pw_boot_armv7m/boot.h",
     ],
+    includes = ["public"],
+    target_compatible_with = select({
+        "@platforms//cpu:armv7e-m": [],
+        "@platforms//cpu:armv7-m": [],
+        "//conditions:default": ["@platforms//:incompatible"],
+    }),
+    deps = [
+        "//pw_preprocessor",
+    ],
 )
diff --git a/pw_boot_armv7m/basic_armv7m.ld b/pw_boot_armv7m/basic_armv7m.ld
index 7ca09de..e758aa2 100644
--- a/pw_boot_armv7m/basic_armv7m.ld
+++ b/pw_boot_armv7m/basic_armv7m.ld
@@ -179,6 +179,12 @@
     . = _stack_high;
     pw_boot_stack_high_addr = .;
   } >RAM
+
+  /* Discard unwind info. */
+  .ARM.extab 0x0 (INFO) :
+  {
+    KEEP(*(.ARM.extab*))
+  }
 }
 
 /* Symbols used by core_init.c: */
diff --git a/pw_boot_armv7m/public/pw_boot_armv7m/boot.h b/pw_boot_armv7m/public/pw_boot_armv7m/boot.h
index 9b683d5..a7abd47 100644
--- a/pw_boot_armv7m/public/pw_boot_armv7m/boot.h
+++ b/pw_boot_armv7m/public/pw_boot_armv7m/boot.h
@@ -77,7 +77,7 @@
 
 // Forward declaration of main. Pigweed applications are expected to implement
 // this function. An implementation of main() is NOT provided by this module.
-int main();
+int main(void);
 
 // Reset handler or boot entry point.
 //
@@ -85,7 +85,7 @@
 // (which usually points to Reset_Handler) must be set to point to this
 // function. This function is implemented by pw_boot_armv7m, and does early
 // memory initialization.
-PW_NO_PROLOGUE void pw_boot_Entry();
+PW_NO_RETURN void pw_boot_Entry(void);
 
 // pw_boot hook: Before static memory is initialized (user supplied)
 //
@@ -97,7 +97,7 @@
 // violates the C spec in several ways as .bss has not yet been zero-initialized
 // and static values have not yet been loaded into memory. This function is NOT
 // implemented by pw_boot_armv7m.
-void pw_boot_PreStaticMemoryInit();
+void pw_boot_PreStaticMemoryInit(void);
 
 // pw_boot hook: Before C++ static constructors are invoked (user supplied).
 //
@@ -107,7 +107,7 @@
 // function is called just before C++ static constructors are invoked. It is
 // safe to run C code, but NOT safe to call out to any C++ code. This function
 // is NOT implemented by pw_boot_armv7m.
-void pw_boot_PreStaticConstructorInit();
+void pw_boot_PreStaticConstructorInit(void);
 
 // pw_boot hook: Before main is invoked (user supplied).
 //
@@ -116,13 +116,13 @@
 // targets to have pre-main initialization of the device and seamlessly swap out
 // the main() implementation. This function is NOT implemented by
 // pw_boot_armv7m.
-void pw_boot_PreMainInit();
+void pw_boot_PreMainInit(void);
 
 // pw_boot hook: After main returned (user supplied).
 //
 // This is a hook function that users of pw_boot must supply. It is called by
 // pw_boot_Entry() after main() has returned. This function must not return!
 // This function is NOT implemented by pw_boot_armv7m.
-PW_NO_RETURN void pw_boot_PostMain();
+PW_NO_RETURN void pw_boot_PostMain(void);
 
 PW_EXTERN_C_END
diff --git a/pw_build/BUILD.gn b/pw_build/BUILD.gn
index d30bbc9..706b8d6 100644
--- a/pw_build/BUILD.gn
+++ b/pw_build/BUILD.gn
@@ -14,6 +14,7 @@
 
 import("//build_overrides/pigweed.gni")
 
+import("$dir_pw_build/python.gni")
 import("$dir_pw_docgen/docs.gni")
 
 # IMPORTANT: The compilation flags in this file must be kept in sync with
@@ -86,6 +87,12 @@
   cflags_cc = [ "-Wnon-virtual-dtor" ]
 }
 
+# Thread safety warnings are only supported by Clang.
+config("clang_thread_safety_warnings") {
+  cflags = [ "-Wthread-safety" ]
+  defines = [ "_LIBCPP_ENABLE_THREAD_SAFETY_ANNOTATIONS=1" ]
+}
+
 # This config contains warnings that we don't necessarily recommend projects
 # enable, but are enabled for upstream Pigweed for maximum project
 # compatibility.
@@ -94,6 +101,7 @@
     "-Wshadow",
     "-Wredundant-decls",
   ]
+  cflags_c = [ "-Wstrict-prototypes" ]
 }
 
 config("cpp11") {
@@ -124,8 +132,24 @@
   depth = 1
 }
 
+pool("copy_from_cipd_pool") {
+  depth = 1
+}
+
+# Requirements for the pw_python_package lint targets.
+pw_python_requirements("python_lint") {
+  requirements = [
+    "build",
+    "mypy==0.800",
+    "pylint==2.6.0",
+  ]
+}
+
 pw_doc_group("docs") {
-  sources = [ "docs.rst" ]
+  sources = [
+    "docs.rst",
+    "python.rst",
+  ]
 }
 
 # Pool to limit a single thread to download external Go packages at a time.
diff --git a/pw_build/CMakeLists.txt b/pw_build/CMakeLists.txt
index b2eeef0..602dd4a 100644
--- a/pw_build/CMakeLists.txt
+++ b/pw_build/CMakeLists.txt
@@ -71,6 +71,7 @@
   INTERFACE
     "-Wshadow"
     "-Wredundant-decls"
+    $<$<COMPILE_LANGUAGE:C>:-Wstrict-prototypes>
 )
 
 add_library(pw_build.cpp17 INTERFACE)
diff --git a/pw_build/constraints/board/BUILD b/pw_build/constraints/board/BUILD
new file mode 100644
index 0000000..1bf3ea2
--- /dev/null
+++ b/pw_build/constraints/board/BUILD
@@ -0,0 +1,23 @@
+# Copyright 2021 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.
+package(default_visibility = ["//visibility:public"])
+
+constraint_setting(
+    name = "board",
+)
+
+constraint_value(
+    name = "stm32f429i-disc1",
+    constraint_setting = ":board",
+)
diff --git a/pw_build/constraints/chipset/BUILD b/pw_build/constraints/chipset/BUILD
new file mode 100644
index 0000000..1c98052
--- /dev/null
+++ b/pw_build/constraints/chipset/BUILD
@@ -0,0 +1,28 @@
+# Copyright 2021 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.
+package(default_visibility = ["//visibility:public"])
+
+constraint_setting(
+    name = "chipset",
+)
+
+constraint_value(
+    name = "stm32f429",
+    constraint_setting = ":chipset",
+)
+
+constraint_value(
+    name = "lm3s6965evb",
+    constraint_setting = ":chipset",
+)
diff --git a/pw_build/copy_from_cipd.gni b/pw_build/copy_from_cipd.gni
new file mode 100644
index 0000000..bb9502b
--- /dev/null
+++ b/pw_build/copy_from_cipd.gni
@@ -0,0 +1,97 @@
+# Copyright 2021 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("python_action.gni")
+import("target_types.gni")
+
+# Creates a pw_source_set that pulls in a static library hosted in CIPD.
+#
+# Let's say you have a package with a static library:
+# CIPD package path: `pigweed/third_party/libsomething`
+# Files:
+#   ./libsomething/include/something.h
+#   ./libsomething/libsomething.a
+# Installed by your manifest at //tools/my_packages.json.
+#
+# Your usage might look like this:
+#
+#   import("$dir_pw_build/copy_from_cipd.gni")
+#
+#   # This target links in the .a file.
+#   pw_cipd_static_library("libsomething") {
+#     manifest = "//tools/my_packages.json"
+#     library_path = "libsomething/libsomething.a"
+#     cipd_package = "pigweed/third_party/libsomething"
+#   }
+#
+#   # This target needs libsomething.a to be linked in.
+#   pw_source_set("math_stuff") {
+#     sources = [ "optimized_math.cc" ]
+#     ...
+#     deps = [ ":libsomething" ]
+#   }
+#
+# Args:
+#   manifest: (required) GN-style path to the JSON manifest file that the
+#     package of interest is defined in.
+#   library_path: (required) The path of the static library of interest,
+#     relative to the root where the manifest's CIPD packages were installed to.
+#     e.g. if the package is installed by pigweed.json, and the .a file is at
+#     //.environment/cipd/pigweed/libs/libsomething.a, this argument should be
+#     "libs/libsomething.a".
+#   cipd_package: (required) The name of the CIPD package. This is the "path" of
+#     the package in the manifest file.
+#
+template("pw_cipd_static_library") {
+  _out_dir = "${target_out_dir}/cipd/$target_name"
+  _out_relative = rebase_path(_out_dir, root_build_dir)
+  _out_file = "$_out_dir/${invoker.library_path}"
+  _manifest_path = rebase_path(invoker.manifest, root_build_dir)
+  pw_python_action("$target_name.check_copy") {
+    module = "pw_build.copy_from_cipd"
+    args = [
+      "--package-name=${invoker.cipd_package}",
+      "--manifest=${_manifest_path}",
+      "--file=${invoker.library_path}",
+      "--out-dir=${_out_relative}",
+    ]
+
+    # Parallel calls might both be invoking CIPD on the same directory which
+    # might work but is redundant, so serialize calls.
+    pool = "$dir_pw_build:copy_from_cipd_pool"
+
+    # TODO(pwbug/335): This should somehow track the actual .a for changes as
+    # well.
+    inputs = [ invoker.manifest ]
+    outputs = [ _out_file ]
+  }
+
+  pw_source_set(target_name) {
+    forward_variables_from(invoker,
+                           "*",
+                           [
+                             "manifest",
+                             "library_path",
+                             "cipd_package",
+                           ])
+    if (!defined(libs)) {
+      libs = []
+    }
+    if (!defined(deps)) {
+      deps = []
+    }
+    libs += [ _out_file ]
+    deps += [ ":$target_name.check_copy" ]
+  }
+}
diff --git a/pw_build/docs.rst b/pw_build/docs.rst
index d1c5b46..6135755 100644
--- a/pw_build/docs.rst
+++ b/pw_build/docs.rst
@@ -37,11 +37,8 @@
 ``pw_build`` also provides several useful GN templates that are used throughout
 Pigweed.
 
-Templates
----------
-
 Target types
-^^^^^^^^^^^^
+------------
 .. code-block::
 
   import("$dir_pw_build/target_types.gni")
@@ -75,10 +72,20 @@
 All of the ``pw_*`` target type overrides accept any arguments, as they simply
 forward them through to the underlying target.
 
+Python packages
+---------------
+GN templates for :ref:`Python build automation <docs-python-build>` are
+described in :ref:`module-pw_build-python`.
+
+.. toctree::
+  :hidden:
+
+  python
+
 .. _module-pw_build-facade:
 
 pw_facade
-^^^^^^^^^
+---------
 In their simplest form, a :ref:`facade<docs-module-structure-facades>` is a GN
 build arg used to change a dependency at compile time. Pigweed targets configure
 these facades as needed.
@@ -106,7 +113,7 @@
 .. _module-pw_build-python-action:
 
 pw_python_action
-^^^^^^^^^^^^^^^^
+----------------
 The ``pw_python_action`` template is a convenience wrapper around ``action`` for
 running Python scripts. The main benefit it provides is resolution of GN target
 labels to compiled binary files. This allows Python scripts to be written
@@ -148,6 +155,11 @@
 BUILD.gn files. This allows build code to use GN labels without having to worry
 about converting them to files.
 
+.. note::
+
+  We intend to replace these expressions with native GN features when possible.
+  See `pwbug/347 <http://bugs.pigweed.dev/347>`_.
+
 The following expressions are supported:
 
 .. describe:: <TARGET_FILE(gn_target)>
@@ -238,7 +250,7 @@
   }
 
 pw_input_group
-^^^^^^^^^^^^^^
+--------------
 ``pw_input_group`` defines a group of input files which are not directly
 processed by the build but are still important dependencies of later build
 steps. This is commonly used alongside metadata to propagate file dependencies
@@ -282,7 +294,7 @@
 files are modified.
 
 pw_zip
-^^^^^^
+------
 ``pw_zip`` is a target that allows users to zip up a set of input files and
 directories into a single output ``.zip`` file—a simple automation of a
 potentially repetitive task.
@@ -391,7 +403,7 @@
 
 .. code-block:: sh
 
-  pw watch out/cmake_host pw_run_tests.modules
+  pw watch -C out/cmake_host pw_run_tests.modules
 
 CMake functions
 ---------------
@@ -454,17 +466,14 @@
 ---------------------
 The CMake build includes third-party libraries similarly to the GN build. A
 ``dir_pw_third_party_<library>`` cache variable is defined for each third-party
-dependency. This variable can have one of three values:
+dependency. The variable must be set to the absolute path of the library in
+order to use it. If the variable is empty
+(``if("${dir_pw_third_party_<library>}" STREQUAL "")``), the dependency is not
+available.
 
-* ``""`` (empty) -- the dependency is not available
-* ``PRESENT`` -- the dependency is available and is already included in the
-  build
-* ``</path/to/the/dependency>`` -- the dependency is available and will be
-  automatically imported from this path using ``add_subdirectory``.
-
-If the variable is empty (``if("${dir_pw_third_party_<library>}" STREQUAL
-"")``), the dependency is not available. Otherwise, it is available and
-libraries declared by it can be referenced.
+Third-party dependencies are not automatically added to the build. They can be
+manually added with ``add_subdirectory`` or by setting the
+``pw_third_party_<library>_ADD_SUBDIRECTORY`` option to ``ON``.
 
 Third party variables are set like any other cache global variable in CMake. It
 is recommended to set these in one of the following ways:
@@ -474,7 +483,7 @@
 
   .. code-block:: cmake
 
-    set(dir_pw_third_party_nanopb PRESENT CACHE STRING "" FORCE)
+    set(dir_pw_third_party_nanopb ${CMAKE_CURRENT_SOURCE_DIR}/external/nanopb CACHE PATH "" FORCE)
 
 * Set the variable at the command line with the ``-D`` option.
 
@@ -507,11 +516,120 @@
 
 Bazel
 =====
-Bazel is currently very experimental, and only builds for host.
+Bazel is currently very experimental, and only builds for host and ARM Cortex-M
+microcontrollers.
 
 The common configuration for Bazel for all modules is in the ``pigweed.bzl``
 file. The built-in Bazel rules ``cc_binary``, ``cc_library``, and ``cc_test``
 are wrapped with ``pw_cc_binary``, ``pw_cc_library``, and ``pw_cc_test``.
 These wrappers add parameters to calls to the compiler and linker.
 
-The ``BUILD`` file is merely a placeholder and currently does nothing.
+Currently Pigweed is making use of a set of
+[open source](https://github.com/silvergasp/bazel-embedded) toolchains. The host
+builds are only supported on Linux/Mac based systems. Additionally the host
+builds are not entirely hermetic, and will make use of system
+libraries and headers. This is close to the default configuration for Bazel,
+though slightly more hermetic. The host toolchain is based around clang-11 which
+has a system dependency on 'libtinfo.so.5' which is often included as part of
+the libncurses packages. On Debian based systems this can be installed using the
+command below:
+
+.. code-block:: sh
+
+  sudo apt install libncurses5
+
+The host toolchain does not currently support native Windows, though using WSL
+is a viable alternative.
+
+The ARM Cortex-M Bazel toolchains are based around gcc-arm-non-eabi and are
+entirely hermetic. You can target Cortex-M, by using the platforms command line
+option. This set of toolchains is supported from hosts; Windows, Mac and Linux.
+The platforms that are currently supported are listed below:
+
+.. code-block:: sh
+
+  bazel build //:your_target --platforms=@pigweed//pw_build/platforms:cortex_m0
+  bazel build //:your_target --platforms=@pigweed//pw_build/platforms:cortex_m1
+  bazel build //:your_target --platforms=@pigweed//pw_build/platforms:cortex_m3
+  bazel build //:your_target --platforms=@pigweed//pw_build/platforms:cortex_m4
+  bazel build //:your_target --platforms=@pigweed//pw_build/platforms:cortex_m7
+  bazel build //:your_target \
+    --platforms=@pigweed//pw_build/platforms:cortex_m4_fpu
+  bazel build //:your_target \
+    --platforms=@pigweed//pw_build/platforms:cortex_m7_fpu
+
+
+The above examples are cpu/fpu oriented platforms and can be used where
+applicable for your application. There some more specific platforms for the
+types of boards that are included as examples in Pigweed. It is strongly
+encouraged that you create your own set of platforms specific for your project,
+that implement the constraint_settings in this repository. e.g.
+
+New board constraint_value:
+
+.. code-block:: python
+
+  #your_repo/build_settings/constraints/board/BUILD
+  constraint_value(
+    name = "nucleo_l432kc",
+    constraint_setting = "@pigweed//pw_build/constraints/board",
+  )
+
+New chipset constraint_value:
+
+.. code-block:: python
+
+  # your_repo/build_settings/constraints/chipset/BUILD
+  constraint_value(
+    name = "stm32l432kc",
+    constraint_setting = "@pigweed//pw_build/constraints/chipset",
+  )
+
+New platforms for chipset and board:
+
+.. code-block:: python
+
+  #your_repo/build_settings/platforms/BUILD
+  # Works with all stm32l432kc
+  platforms(
+    name = "stm32l432kc",
+    parents = ["@pigweed//pw_build/platforms:cortex_m4"],
+    constraint_values =
+      ["@your_repo//build_settings/constraints/chipset:stm32l432kc"],
+  )
+
+  # Works with only the nucleo_l432kc
+  platforms(
+    name = "nucleo_l432kc",
+    parents = [":stm32l432kc"],
+    constraint_values =
+      ["@your_repo//build_settings/constraints/board:nucleo_l432kc"],
+  )
+
+In the above example you can build your code with the command line:
+
+.. code-block:: python
+
+  bazel build //:your_target_for_nucleo_l432kc \
+    --platforms=@your_repo//build_settings:nucleo_l432kc
+
+
+You can also specify that a specific target is only compatible with one
+platform:
+
+.. code-block:: python
+
+  cc_library(
+    name = "compatible_with_all_stm32l432kc",
+    srcs = ["tomato_src.c"],
+    target_compatible_with =
+      ["@your_repo//build_settings/constraints/chipset:stm32l432kc"],
+  )
+
+  cc_library(
+    name = "compatible_with_only_nucleo_l432kc",
+    srcs = ["bbq_src.c"],
+    target_compatible_with =
+      ["@your_repo//build_settings/constraints/board:nucleo_l432kc"],
+  )
+
diff --git a/pw_build/error.gni b/pw_build/error.gni
index 6e7994a..a7c0e69 100644
--- a/pw_build/error.gni
+++ b/pw_build/error.gni
@@ -14,23 +14,39 @@
 
 import("python_action.gni")
 
-# Prints an error message and exits the build unsuccessfully.
+# Prints an error message and exits the build unsuccessfully. Either 'message'
+# or 'message_lines' must be specified, but not both.
 #
 # Args:
-#   message: The message to print.
+#   message: The message to print. Use \n for newlines.
+#   message_lines: List of lines to use for the message.
 #
 template("pw_error") {
-  assert(defined(invoker.message) && invoker.message != "",
-         "pw_error requires an error message")
+  assert(
+      defined(invoker.message) != defined(invoker.message_lines),
+      "pw_error requires either a 'message' string or a 'message_lines' list")
 
-  pw_python_action(target_name) {
+  if (defined(invoker.message_lines)) {
+    _message = string_join("\n", invoker.message_lines)
+  } else {
+    _message = invoker.message
+  }
+  assert(_message != "", "The message cannot be empty")
+
+  action(target_name) {
     script = "$dir_pw_build/py/pw_build/error.py"
     args = [
       "--target",
-      get_label_info(target_name, "label_no_toolchain"),
+      get_label_info(":$target_name", "label_with_toolchain"),
       "--message",
-      invoker.message,
+      _message,
+      "--root",
+      rebase_path("//"),
+      "--out",
+      rebase_path(root_build_dir),
     ]
-    stamp = true
+
+    # This output file is never created.
+    outputs = [ "$target_gen_dir/$target_name.build_error" ]
   }
 }
diff --git a/pw_build/facade.gni b/pw_build/facade.gni
index d957646..4cb422e 100644
--- a/pw_build/facade.gni
+++ b/pw_build/facade.gni
@@ -14,7 +14,7 @@
 
 import("//build_overrides/pigweed.gni")
 
-import("$dir_pw_build/python_action.gni")
+import("$dir_pw_build/error.gni")
 import("$dir_pw_build/target_types.gni")
 
 # Declare a facade.
@@ -23,33 +23,63 @@
 # link against. Typically this will be done by pointing a build arg like
 # `pw_[module]_BACKEND` at a backend implementation for that module.
 #
-# To avoid circular dependencies, pw_facade creates two targets:
+# pw_facade creates two targets:
 #
-#   - $target_name: the public-facing pw_source_set
-#   - $target_name.facade: target used by the backend to avoid circular
-#         dependencies
+#   $target_name: The public-facing pw_source_set that provides the API and
+#     implementation (backend). Users of the facade should depend on this.
+#   $target_name.facade: A private source_set that provides ONLY the API. ONLY
+#     backends should depend on this.
 #
 # If the target name matches the directory name (e.g. //foo:foo), a ":facade"
 # alias of the facade target (e.g. //foo:facade) is also provided. This avoids
 # the need to repeat the directory name, for consistency with the main target.
 #
-# Example facade:
+# The facade's headers are split out into the *.facade target to avoid circular
+# dependencies. Here's a concrete example to illustrate why this is needed:
 #
-#   # Creates ":module_name" and ":module_name.facade" GN targets.
-#   pw_facade("module_name") {
-#     backend = dir_module_name_backend
-#     public_deps = [
-#       ":module_api_layer"
-#     ]
+#   foo_BACKEND = "//foo:foo_backend_bar"
+#
+#   pw_facade("foo") {
+#     backend = foo_BACKEND
+#     public = [ "foo.h" ]
+#     sources = [ "foo.cc" ]
 #   }
 #
+#   pw_source_set("foo_backend_bar") {
+#     deps = [ ":foo.facade" ]
+#     sources = [ "bar.cc" ]
+#   }
+#
+# This creates the following dependency graph:
+#
+#   foo.facade  <-.
+#    ^             \
+#    |              \
+#    |               \
+#   foo  ---------->  foo_backend_bar
+#
+# This allows foo_backend_bar to include "foo.h". If you tried to directly
+# depend on `foo` from `foo_backend_bar`, you'd get a dependency cycle error in
+# GN.
+#
 # Accepts the standard pw_source_set args with the following additions:
 #
-#  - backend: the dependency that implements this facade (a GN variable)
+# Args:
+#  backend: (required) The dependency that implements this facade (a GN
+#    variable)
+#  public: (required) The headers exposed by this facade. A facade acts as a
+#    tool to break dependency cycles that come from the backend trying to
+#    include headers from the facade itself. If the facade doesn't expose any
+#    headers, it's basically the same as just depending directly on the build
+#    arg that `backend` is set to.
 #
 template("pw_facade") {
   assert(defined(invoker.backend),
          "pw_facade requires a reference to a backend variable for the facade")
+  assert(defined(invoker.public),
+         "If your facade does not explicitly expose an API that a backend " +
+             "must depend on, you can just directly depend on the build arg " +
+             "that the `backend` template argument would have been set to.")
 
   _facade_name = "$target_name.facade"
 
@@ -60,38 +90,10 @@
     }
   }
 
-  # For backwards compatibility, provide a _facade version of the name.
-  group(target_name + "_facade") {
-    public_deps = [ ":$_facade_name" ]
-  }
-
-  # A facade's headers are split into a separate target to avoid a circular
-  # dependency between the facade and the backend.
-  #
-  # For example, the following targets:
-  #
-  #   foo_backend = "//foo:foo_backend_bar"
-  #
-  #   pw_facade("foo") {
-  #     backend = foo_backend
-  #     public = [ "foo.h" ]
-  #     sources = [ "foo.cc" ]
-  #   }
-  #
-  #   pw_source_set("foo_backend_bar") {
-  #     deps = [ ":facade" ]
-  #     sources = [ "bar.cc" ]
-  #   }
-  #
-  # Create the following dependency graph:
-  #
-  #   facade  <-.
-  #    ^         \
-  #    |          \
-  #    |           \
-  #   foo  ------>  foo_backend_bar
-  #
   _facade_vars = [
+    # allow_circular_includes_from should very rarely be used, but when it is,
+    # it only applies to headers, so should be in the .facade target.
+    "allow_circular_includes_from",
     "public_configs",
     "public_deps",
     "public",
@@ -101,17 +103,35 @@
   }
 
   if (invoker.backend == "") {
+    # Try to guess the name of the facade's backend variable.
+    _dir = get_path_info(get_label_info(":$target_name", "dir"), "name")
+    if (target_name == _dir) {
+      _varname = target_name + "_BACKEND"
+    } else {
+      # There is no way to capitalize this string in GN, so use <FACADE_NAME>
+      # instead of the lowercase target name.
+      _varname = _dir + "_<FACADE_NAME>_BACKEND"
+    }
+
     # If backend is not set to anything, create a script that emits an error.
     # This will be added as a data dependency to the actual target, so that
     # attempting to build the facade without a backend fails with a relevant
     # error message.
-    _main_target_name = target_name
-
-    pw_python_action(_main_target_name + ".NO_BACKEND_SET") {
-      stamp = true
-      script = "$dir_pw_build/py/pw_build/null_backend.py"
-      args = [ _main_target_name ]
-      not_needed(invoker, "*")
+    pw_error(target_name + ".NO_BACKEND_SET") {
+      _label = get_label_info(":${invoker.target_name}", "label_no_toolchain")
+      message_lines = [
+        "Attempted to build the $_label facade with no backend.",
+        "",
+        "If you are using this facade, ensure you have configured a backend ",
+        "properly. The build arg for the facade must be set to a valid ",
+        "backend in the toolchain. For example, you may need to add a line ",
+        "like the following to the toolchain's .gni file:",
+        "",
+        "  $_varname = \"//path/to/the:backend\"",
+        "",
+        "If you are NOT using this facade, this error may have been triggered ",
+        "by trying to build all targets.",
+      ]
     }
   }
 
@@ -130,7 +150,7 @@
       public_deps += [ invoker.backend ]
     } else {
       # If the backend is not set, depend on the *.NO_BACKEND_SET target.
-      public_deps += [ ":$_main_target_name" + ".NO_BACKEND_SET" ]
+      public_deps += [ ":$target_name.NO_BACKEND_SET" ]
     }
   }
 }
diff --git a/pw_build/input_group.gni b/pw_build/input_group.gni
index a7b93b4..3bf8b36 100644
--- a/pw_build/input_group.gni
+++ b/pw_build/input_group.gni
@@ -21,8 +21,6 @@
 # This is typically used for targets that don't output any artifacts (e.g.
 # metadata-only targets) which list input files relevant to the build.
 template("pw_input_group") {
-  assert(defined(invoker.inputs), "pw_input_group requires some inputs")
-
   pw_python_action(target_name) {
     ignore_vars = [
       "args",
diff --git a/pw_build/mirror_tree.gni b/pw_build/mirror_tree.gni
new file mode 100644
index 0000000..5e78de8
--- /dev/null
+++ b/pw_build/mirror_tree.gni
@@ -0,0 +1,113 @@
+# Copyright 2021 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_action.gni")
+
+# Mirrors a directory structure to the output directory.
+#
+# This is similar to a GN copy target, with some differences:
+#
+#   - The outputs list is generated by the template based on the source_root and
+#     directory arguments, rather than using source expansion.
+#   - The source_root argument can be used to trim prefixes from source files.
+#   - pw_mirror_tree uses hard links instead of copies for efficiency.
+#
+# Args:
+#
+#   directory: Output directory for the files.
+#   sources: List of files to mirror to the output directory.
+#   source_root: Root path for sources; defaults to ".".
+#   path_data_keys: GN metadata data_keys from which to extract file or
+#       directory paths to add to the list of sources.
+#
+template("pw_mirror_tree") {
+  assert(defined(invoker.sources) || defined(invoker.path_data_keys),
+         "At least one of 'sources' or 'path_data_keys' must be provided")
+  assert(defined(invoker.directory) && invoker.directory != "",
+         "The output path must be specified as 'directory'")
+
+  if (defined(invoker.source_root)) {
+    _root = invoker.source_root
+  } else {
+    _root = "."
+  }
+
+  _args = [
+    "--source-root",
+    rebase_path(_root),
+    "--directory",
+    rebase_path(invoker.directory),
+  ]
+
+  _deps = []
+  if (defined(invoker.deps)) {
+    _deps += invoker.deps
+  }
+
+  _public_deps = []
+  if (defined(invoker.public_deps)) {
+    _public_deps += invoker.public_deps
+  }
+
+  if (defined(invoker.path_data_keys)) {
+    generated_file("$target_name._path_list") {
+      data_keys = invoker.path_data_keys
+      rebase = root_build_dir
+      outputs = [ "$target_gen_dir/$target_name.txt" ]
+      deps = _deps + _public_deps
+
+      assert(deps != [],
+             "'path_data_keys' requires at least one dependency in 'deps'")
+    }
+
+    _deps += [ ":$target_name._path_list" ]
+    _args += [ "--path-file" ] +
+             rebase_path(get_target_outputs(":$target_name._path_list"))
+  }
+
+  pw_python_action(target_name) {
+    script = "$dir_pw_build/py/pw_build/mirror_tree.py"
+    args = _args
+
+    outputs = []
+
+    if (defined(invoker.sources)) {
+      args += rebase_path(invoker.sources)
+
+      foreach(path, rebase_path(invoker.sources, _root)) {
+        outputs += [ "${invoker.directory}/$path" ]
+      }
+    }
+
+    # If path_data_keys is used, the outputs may be unknown.
+    if (outputs == []) {
+      stamp = true
+    }
+
+    deps = _deps
+    public_deps = _public_deps
+
+    _ignore_args = [
+      "script",
+      "args",
+      "outputs",
+      "directory",
+      "deps",
+      "public_deps",
+    ]
+    forward_variables_from(invoker, "*", _ignore_args)
+  }
+}
diff --git a/pw_build/multi_toolchain_group.gni b/pw_build/multi_toolchain_group.gni
new file mode 100644
index 0000000..46d81a4
--- /dev/null
+++ b/pw_build/multi_toolchain_group.gni
@@ -0,0 +1,51 @@
+# Copyright 2021 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.
+
+# Declares a group that builds a list of dependencies using multiple toolchains.
+#
+# In a multi-toolchain group, each dependency is built with each of the provided
+# toolchains. This results in M x N targets being built, where M is the number
+# of toolchains, and N is the number of targets listed in the `deps` variable.
+#
+# This template is useful for simplifying cases where you have multiple
+# applications you would like to build under different toolchain contexts.
+#
+# Args:
+#   deps: (required) List of GN targets to build under each listed toolchain.
+#   toolchains: (required) A list of toolchain labels to build the dependencies
+#     with.
+template("pw_multi_toolchain_group") {
+  assert(!defined(invoker.public_deps),
+         "Use `deps` for pw_multi_toolchain_group targets")
+  assert(defined(invoker.deps), "`deps` must be defined to use this template")
+  assert(defined(invoker.toolchains),
+         "`toolchains` must be defined to use this template")
+  group(target_name) {
+    deps = []
+    public_deps = []
+    foreach(tc, invoker.toolchains) {
+      foreach(item, invoker.deps) {
+        # Make sure targets don't explicitly reference a toolchain in their
+        # target label. If we poison any instances of "(", we can see a mismatch
+        # against the original string, indicating a toolchain was explicitly
+        # specified.
+        _poisioned_label = string_replace(item, "(", "!")
+        assert(item == _poisioned_label,
+               "$item can't explicitly specify a toolchain as part of " +
+                   "a multi-toolchain group")
+        deps += [ "$item($tc)" ]
+      }
+    }
+  }
+}
diff --git a/pw_build/pigweed.cmake b/pw_build/pigweed.cmake
index 10e414b..2d0fa3f 100644
--- a/pw_build/pigweed.cmake
+++ b/pw_build/pigweed.cmake
@@ -13,6 +13,21 @@
 # the License.
 include_guard(GLOBAL)
 
+# Wrapper around cmake_parse_arguments that fails with an error if any arguments
+# remained unparsed.
+macro(_pw_parse_argv_strict function start_arg options one multi)
+  cmake_parse_arguments(PARSE_ARGV
+      "${start_arg}" arg "${options}" "${one}" "${multi}"
+  )
+  if(NOT "${arg_UNPARSED_ARGUMENTS}" STREQUAL "")
+    set(_all_args ${options} ${one} ${multi})
+    message(FATAL_ERROR
+        "Unexpected arguments to ${function}: ${arg_UNPARSED_ARGUMENTS}\n"
+        "Valid arguments: ${_all_args}"
+    )
+  endif()
+endmacro()
+
 # Automatically creates a library and test targets for the files in a module.
 # This function is only suitable for simple modules that meet the following
 # requirements:
@@ -44,8 +59,11 @@
 #   PRIVATE_DEPS - private target_link_libraries arguments
 #
 function(pw_auto_add_simple_module MODULE)
-  set(multi PUBLIC_DEPS PRIVATE_DEPS TEST_DEPS)
-  cmake_parse_arguments(PARSE_ARGV 1 arg "" "IMPLEMENTS_FACADE" "${multi}")
+  _pw_parse_argv_strict(pw_auto_add_simple_module 1
+      ""
+      "IMPLEMENTS_FACADE"
+      "PUBLIC_DEPS;PRIVATE_DEPS;TEST_DEPS"
+  )
 
   file(GLOB all_sources *.cc *.c)
 
@@ -58,15 +76,15 @@
 
   if(arg_IMPLEMENTS_FACADE)
     set(groups backends)
-    set(facade_dep "${arg_IMPLEMENTS_FACADE}.facade")
   else()
     set(groups modules "${MODULE}")
   endif()
 
   pw_add_module_library("${MODULE}"
+    IMPLEMENTS_FACADES
+      ${arg_IMPLEMENTS_FACADE}
     PUBLIC_DEPS
       ${arg_PUBLIC_DEPS}
-      ${facade_dep}
     PRIVATE_DEPS
       ${arg_PRIVATE_DEPS}
     SOURCES
@@ -98,7 +116,11 @@
 #  GROUPS - groups in addition to MODULE to which to add these tests
 #
 function(pw_auto_add_module_tests MODULE)
-  cmake_parse_arguments(PARSE_ARGV 1 arg "" "" "PRIVATE_DEPS;GROUPS")
+  _pw_parse_argv_strict(pw_auto_add_module_tests 1
+      ""
+      ""
+      "PRIVATE_DEPS;GROUPS"
+  )
 
   file(GLOB cc_tests *_test.cc)
 
@@ -122,6 +144,11 @@
   endforeach()
 endfunction(pw_auto_add_module_tests)
 
+# Sets the provided variable to the common library arguments.
+macro(_pw_library_args variable)
+  set("${variable}" SOURCES HEADERS PUBLIC_DEPS PRIVATE_DEPS ${ARGN})
+endmacro()
+
 # Creates a library in a module. The library has access to the public/ include
 # directory.
 #
@@ -131,10 +158,11 @@
 #   HEADERS - header files for this library
 #   PUBLIC_DEPS - public target_link_libraries arguments
 #   PRIVATE_DEPS - private target_link_libraries arguments
+#   IMPLEMENTS_FACADES - which facades this library implements
 #
 function(pw_add_module_library NAME)
-  set(list_args SOURCES HEADERS PUBLIC_DEPS PRIVATE_DEPS)
-  cmake_parse_arguments(PARSE_ARGV 1 arg "" "" "${list_args}")
+  _pw_library_args(list_args IMPLEMENTS_FACADES)
+  _pw_parse_argv_strict(pw_add_module_library 1 "" "" "${list_args}")
 
   # Check that the library's name is prefixed by the module name.
   get_filename_component(module "${CMAKE_CURRENT_SOURCE_DIR}" NAME)
@@ -147,7 +175,7 @@
   endif()
 
   add_library("${NAME}" EXCLUDE_FROM_ALL ${arg_HEADERS} ${arg_SOURCES})
-  target_include_directories("${NAME}" PUBLIC public/)
+  target_include_directories("${NAME}" PUBLIC public)
   target_link_libraries("${NAME}"
     PUBLIC
       pw_build
@@ -158,6 +186,13 @@
       ${arg_PRIVATE_DEPS}
   )
 
+  if(NOT "${arg_IMPLEMENTS_FACADES}" STREQUAL "")
+    target_include_directories("${NAME}" PUBLIC public_overrides)
+    set(facades ${arg_IMPLEMENTS_FACADES})
+    list(TRANSFORM facades APPEND ".facade")
+    target_link_libraries("${NAME}" PUBLIC ${facades})
+  endif()
+
   # Libraries require at least one source file.
   if(NOT arg_SOURCES)
     target_sources("${NAME}" PRIVATE $<TARGET_PROPERTY:pw_build.empty,SOURCES>)
@@ -171,13 +206,14 @@
 # module that implements the facade depends on a library named
 # MODULE_NAME.facade.
 #
-# pw_add_facade accepts the same arguments as pw_add_module_library, with the
-# following additions:
+# pw_add_facade accepts the same arguments as pw_add_module_library, except for
+# IMPLEMENTS_FACADES. It also accepts the following argument:
 #
 #  DEFAULT_BACKEND - which backend to use by default
 #
 function(pw_add_facade NAME)
-  cmake_parse_arguments(PARSE_ARGV 1 arg "" "DEFAULT_BACKEND" "")
+  _pw_library_args(list_args)
+  _pw_parse_argv_strict(pw_add_facade 1 "" "DEFAULT_BACKEND" "${list_args}")
 
   # If no backend is set, a script that displays an error message is used
   # instead. If the facade is used in the build, it fails with this error.
@@ -199,13 +235,18 @@
 
   # Define the facade library, which is used by the backend to avoid circular
   # dependencies.
-  pw_add_module_library("${NAME}.facade" ${arg_UNPARSED_ARGUMENTS})
+  add_library("${NAME}.facade" INTERFACE)
+  target_include_directories("${NAME}.facade" INTERFACE public)
+  target_link_libraries("${NAME}.facade" INTERFACE ${arg_PUBLIC_DEPS})
 
   # Define the public-facing library for this facade, which depends on the
   # header files in .facade target and exposes the dependency on the backend.
-  add_library("${NAME}" INTERFACE)
-  target_link_libraries("${NAME}"
-    INTERFACE
+  pw_add_module_library("${NAME}"
+    SOURCES
+      ${arg_SOURCES}
+    HEADERS
+      ${arg_HEADERS}
+    PUBLIC_DEPS
       "${NAME}.facade"
       "${${NAME}_BACKEND}"
   )
@@ -219,7 +260,7 @@
 # Declares a unit test. Creates two targets:
 #
 #  * <TEST_NAME> - the test executable
-#  * <TEST_NAME>_run - builds and runs the test
+#  * <TEST_NAME>.run - builds and runs the test
 #
 # Args:
 #
@@ -230,7 +271,7 @@
 #       added to the 'default' and 'all' groups
 #
 function(pw_add_test NAME)
-  cmake_parse_arguments(PARSE_ARGV 1 arg "" "" "SOURCES;DEPS;GROUPS")
+  _pw_parse_argv_strict(pw_add_test 1 "" "" "SOURCES;DEPS;GROUPS")
 
   add_executable("${NAME}" EXCLUDE_FROM_ALL ${arg_SOURCES})
   target_link_libraries("${NAME}"
@@ -255,7 +296,7 @@
     OUTPUT
       "${NAME}.stamp"
   )
-  add_custom_target("${NAME}_run" DEPENDS "${NAME}.stamp")
+  add_custom_target("${NAME}.run" DEPENDS "${NAME}.stamp")
 
   # Always add tests to the "all" group. If no groups are provided, add the
   # test to the "default" group.
@@ -280,6 +321,6 @@
     endif()
 
     add_dependencies("pw_tests.${group}" "${TEST_NAME}")
-    add_dependencies("pw_run_tests.${group}" "${TEST_NAME}_run")
+    add_dependencies("pw_run_tests.${group}" "${TEST_NAME}.run")
   endforeach()
 endfunction(pw_add_test_to_groups)
diff --git a/pw_build/pigweed_toolchain_upstream.bzl b/pw_build/pigweed_toolchain_upstream.bzl
new file mode 100644
index 0000000..4b7767a
--- /dev/null
+++ b/pw_build/pigweed_toolchain_upstream.bzl
@@ -0,0 +1,77 @@
+# Copyright 2021 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.
+
+def _toolchain_upstream_repository_impl(rctx):
+    """Creates a remote repository with a set of toolchain components.
+
+    The bazel embedded toolchain expects two targets injected_headers
+    and polyfill. This is rule generates these targets so that
+    bazel-embedded can depend on them and so that the targets can depend
+    on pigweeds implementation of polyfill. This rule is only ever
+    intended to be instantiated with the name
+    "bazel_embedded_upstream_toolchain", and should only be used from
+    the "toolchain_upstream_deps" macro.
+
+    The bazel-embedded package expects to be able to access these
+    targets as @bazel_embedded_upstream_toolchain//:polyfill and
+    @bazel_embedded_upstream_toolchain//:injected_headers.
+
+    Args:
+      rctx: Repository context.
+    """
+    rctx.file("BUILD", """
+package(default_visibility = ["//visibility:public"])
+load(
+    "@bazel_embedded//toolchains/tools/include_tools:defs.bzl",
+    "cc_injected_toolchain_header_library",
+    "cc_polyfill_toolchain_library",
+)
+
+cc_polyfill_toolchain_library(
+    name = "polyfill",
+    deps = ["@pigweed//pw_polyfill:toolchain_polyfill_overrides"],
+)
+
+cc_injected_toolchain_header_library(
+    name = "injected_headers",
+    deps = ["@pigweed//pw_polyfill:toolchain_injected_headers"],
+)
+""")
+
+_toolchain_upstream_repository = repository_rule(
+    _toolchain_upstream_repository_impl,
+    doc = """
+toolchain_upstream_repository creates a remote repository that can be
+accessed by a toolchain repository to configure system includes.
+
+It's recommended to use this rule through the 'toolchain_upstream_deps'
+macro rather than using this rule directly.
+""",
+)
+
+def toolchain_upstream_deps():
+    """Implements the set of dependencies that bazel-embedded requires.
+
+    These targets are used to override the default toolchain
+    requirements in the remote bazel-embedded toolchain. The remote
+    toolchain expects to find two targets;
+      - "@bazel_embedded_upstream_toolchain//:polyfill" -> Additional
+        system headers for the toolchain
+      - "@bazel_embedded_upstream_toolchain//:injected_headers" ->
+        Headers that are injected into the toolchain via the -include
+        command line argument
+    """
+    _toolchain_upstream_repository(
+        name = "bazel_embedded_upstream_toolchain",
+    )
diff --git a/pw_build/platforms/BUILD b/pw_build/platforms/BUILD
new file mode 100644
index 0000000..a19a462
--- /dev/null
+++ b/pw_build/platforms/BUILD
@@ -0,0 +1,70 @@
+# Copyright 2021 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.
+package(default_visibility = ["//visibility:public"])
+
+# --- CPU's ---
+alias(
+    name = "cortex_m0",
+    actual = "@bazel_embedded//platforms:cortex_m0",
+)
+
+alias(
+    name = "cortex_m1",
+    actual = "@bazel_embedded//platforms:cortex_m1",
+)
+
+alias(
+    name = "cortex_m3",
+    actual = "@bazel_embedded//platforms:cortex_m3",
+)
+
+alias(
+    name = "cortex_m4",
+    actual = "@bazel_embedded//platforms:cortex_m4",
+)
+
+alias(
+    name = "cortex_m4_fpu",
+    actual = "@bazel_embedded//platforms:cortex_m4",
+)
+
+alias(
+    name = "cortex_m7",
+    actual = "@bazel_embedded//platforms:cortex_m7",
+)
+
+alias(
+    name = "cortex_m7_fpu",
+    actual = "@bazel_embedded//platforms:cortex_m7_fpu",
+)
+
+# --- Chipsets ---
+platform(
+    name = "stm32f429",
+    constraint_values = ["//pw_build/constraints/chipset:stm32f429"],
+    parents = [":cortex_m4"],
+)
+
+platform(
+    name = "lm3s6965evb",
+    constraint_values = ["//pw_build/constraints/chipset:lm3s6965evb"],
+    parents = [":cortex_m3"],
+)
+
+# --- Boards ---
+platform(
+    name = "stm32f429i-disc1",
+    constraint_values = ["//pw_build/constraints/board:stm32f429i-disc1"],
+    parents = [":stm32f429"],
+)
diff --git a/pw_build/py/BUILD.gn b/pw_build/py/BUILD.gn
index 04a30e2..ea96dc4 100644
--- a/pw_build/py/BUILD.gn
+++ b/pw_build/py/BUILD.gn
@@ -20,17 +20,27 @@
   setup = [ "setup.py" ]
   sources = [
     "pw_build/__init__.py",
+    "pw_build/copy_from_cipd.py",
     "pw_build/error.py",
     "pw_build/exec.py",
+    "pw_build/generate_python_package.py",
     "pw_build/generate_python_package_gn.py",
     "pw_build/generated_tests.py",
     "pw_build/host_tool.py",
+    "pw_build/mirror_tree.py",
     "pw_build/nop.py",
-    "pw_build/null_backend.py",
     "pw_build/python_runner.py",
     "pw_build/python_wheels.py",
     "pw_build/zip.py",
-    "pw_build/zip_test.py",
   ]
-  tests = [ "python_runner_test.py" ]
+  tests = [
+    "python_runner_test.py",
+    "zip_test.py",
+  ]
+  python_deps = [
+    "$dir_pw_cli/py",
+    "$dir_pw_env_setup/py",
+    "$dir_pw_presubmit/py",
+  ]
+  pylintrc = "$dir_pigweed/.pylintrc"
 }
diff --git a/pw_build/py/pw_build/copy_from_cipd.py b/pw_build/py/pw_build/copy_from_cipd.py
new file mode 100755
index 0000000..63921ee
--- /dev/null
+++ b/pw_build/py/pw_build/copy_from_cipd.py
@@ -0,0 +1,141 @@
+# Copyright 2021 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.
+"""Copies files from CIPD to a specified directory.
+
+By default, Pigweed installs packages from a manifest file to a CIPD
+subdirectory as part of environment setup. This script will copy files from this
+directory into a specified output directory.
+
+Here's an example of how to use this script:
+
+Let's say you have a package with a static library:
+
+CIPD path: `pigweed/third_party/libsomething`
+Files:
+  ./libsomething/include/something.h
+  ./libsomething/libsomething.a
+
+And this package was referenced in my_project_packages.json, which was provided
+as a --cipd-package-file in your bootstrap script.
+
+To copy the static libraryto $PW_PROJECT_ROOT/static_libraries, you'd have an
+invocation something like this:
+
+copy_from_cipd --package-name=pigweed/third_party/libsomething \
+               --mainfest=$PW_PROJECT_ROOT/tools/my_project_packages.json \
+               --file=libsomething/libsomething.a \
+               --out=$PW_PROJECT_ROOT/static_libraries
+"""
+
+import argparse
+import json
+import logging
+import os
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+import pw_env_setup.cipd_setup.update
+
+logger = logging.getLogger(__name__)
+
+
+def parse_args():
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument('--verbose',
+                        '-v',
+                        help='Verbose output',
+                        action='store_true')
+    parser.add_argument('--manifest',
+                        required=True,
+                        type=Path,
+                        help='Path to CIPD JSON manifest file')
+    parser.add_argument('--out-dir',
+                        type=Path,
+                        default='.',
+                        help='Output folder to copy the specified file to')
+    parser.add_argument('--package-name',
+                        required=True,
+                        help='The CIPD package name')
+    # TODO(pwbug/334) Support multiple values for --file.
+    parser.add_argument('--file',
+                        required=True,
+                        type=Path,
+                        help='Path of the file to copy from the CIPD package. '
+                        'This is relative to the CIPD package root of the '
+                        'provided manifest.')
+    parser.add_argument('--cipd-package-root',
+                        type=Path,
+                        help="Path to the root of the package's install "
+                        'directory. This is usually at '
+                        'PW_{manifest name}_CIPD_INSTALL_DIR')
+    return parser.parse_args()
+
+
+def check_version(manifest, cipd_path, package_name):
+    base_package_name = os.path.basename(package_name)
+    instance_id_path = os.path.join(cipd_path, '.versions',
+                                    f'{base_package_name}.cipd_version')
+    with open(instance_id_path, 'r') as ins:
+        instance_id = json.load(ins)['instance_id']
+
+    with open(manifest, 'r') as ins:
+        data = json.load(ins)
+
+    path = None
+    expected_version = None
+    for entry in data:
+        if package_name in entry['path']:
+            path = entry['path']
+            expected_version = entry['tags'][0]
+    if not path:
+        raise LookupError(f'failed to find {package_name} entry')
+
+    cmd = ['cipd', 'describe', path, '-version', instance_id]
+    output = subprocess.check_output(cmd).decode()
+    if expected_version not in output:
+        pw_env_setup.cipd_setup.update.update(
+            'cipd', (manifest, ), os.environ['PW_CIPD_INSTALL_DIR'],
+            os.environ['CIPD_CACHE_DIR'])
+
+
+def main():
+    args = parse_args()
+
+    if args.verbose:
+        logger.setLevel(logging.DEBUG)
+
+    # Try to infer CIPD install root from the manifest name.
+    if args.cipd_package_root is None:
+        file_base_name = args.manifest.stem
+        args.cipd_var = 'PW_{}_CIPD_INSTALL_DIR'.format(file_base_name.upper())
+        try:
+            args.cipd_package_root = os.environ[args.cipd_var]
+        except KeyError:
+            logger.error(
+                "The %s environment variable isn't set. Did you forget to run "
+                '`. ./bootstrap.sh`? Is the %s manifest installed to a '
+                'different path?', args.cipd_var, file_base_name)
+            sys.exit(1)
+
+    check_version(args.manifest, args.cipd_package_root, args.package_name)
+
+    shutil.copyfile(os.path.join(args.cipd_package_root, args.file),
+                    os.path.join(args.out_dir, args.file))
+
+
+if __name__ == '__main__':
+    logging.basicConfig()
+    main()
diff --git a/pw_build/py/pw_build/error.py b/pw_build/py/pw_build/error.py
index d98d55d..9d0f41e 100644
--- a/pw_build/py/pw_build/error.py
+++ b/pw_build/py/pw_build/error.py
@@ -1,4 +1,4 @@
-# Copyright 2020 The Pigweed Authors
+# Copyright 2021 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
@@ -15,32 +15,70 @@
 
 import argparse
 import logging
+import os
+from pathlib import Path
+import subprocess
 import sys
 
-import pw_cli.log
+try:
+    from pw_cli.log import install as setup_logging
+except ImportError:
+    from logging import basicConfig as setup_logging  # type: ignore
 
 _LOG = logging.getLogger(__name__)
 
 
-def _parse_args():
+def _parse_args() -> argparse.Namespace:
     parser = argparse.ArgumentParser(description=__doc__)
-    parser.add_argument('--message', help='Error message to print')
-    parser.add_argument('--target', help='GN target in which error occurred')
+    parser.add_argument('--message',
+                        required=True,
+                        help='Error message to print')
+    parser.add_argument('--target',
+                        required=True,
+                        help='GN target in which the error occurred')
+    parser.add_argument('--root', required=True, type=Path, help='GN root')
+    parser.add_argument('--out', required=True, type=Path, help='GN out dir')
     return parser.parse_args()
 
 
-def main(message: str, target: str) -> int:
+def main(message: str, target: str, root: Path, out: Path) -> int:
+    """Logs the error message and returns 1."""
+
     _LOG.error('')
-    _LOG.error('Build error:')
+    _LOG.error('Build error for %s:', target)
     _LOG.error('')
+
     for line in message.split('\\n'):
         _LOG.error('  %s', line)
+
     _LOG.error('')
-    _LOG.error('(in %s)', target)
-    _LOG.error('')
+
+    gn_cmd = subprocess.run(
+        ['gn', 'path', f'--root={root}', out, '//:default', target],
+        capture_output=True)
+    path_info = gn_cmd.stdout.decode(errors='replace').rstrip()
+
+    relative_out = os.path.relpath(out, root)
+
+    if gn_cmd.returncode == 0 and 'No non-data paths found' not in path_info:
+        _LOG.error('Dependency path to this target:')
+        _LOG.error('')
+        _LOG.error('  gn path %s //:default "%s"\n%s', relative_out, target,
+                   path_info)
+        _LOG.error('')
+    else:
+        _LOG.error(
+            'Run this command to see the build dependency path to this target:'
+        )
+        _LOG.error('')
+        _LOG.error('  gn path %s <target> "%s"', relative_out, target)
+        _LOG.error('')
+        _LOG.error('where <target> is the GN target you are building.')
+        _LOG.error('')
+
     return 1
 
 
 if __name__ == '__main__':
-    pw_cli.log.install()
+    setup_logging()
     sys.exit(main(**vars(_parse_args())))
diff --git a/pw_build/py/pw_build/generate_python_package.py b/pw_build/py/pw_build/generate_python_package.py
new file mode 100644
index 0000000..81e6c48
--- /dev/null
+++ b/pw_build/py/pw_build/generate_python_package.py
@@ -0,0 +1,203 @@
+# Copyright 2021 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.
+"""Script that invokes protoc to generate code for .proto files."""
+
+import argparse
+from collections import defaultdict
+import json
+from pathlib import Path
+import sys
+import textwrap
+from typing import Dict, List, Set, TextIO
+
+try:
+    from pw_build.mirror_tree import mirror_paths
+except ImportError:
+    # Append this path to the module search path to allow running this module
+    # before the pw_build package is installed.
+    sys.path.append(str(Path(__file__).resolve().parent.parent))
+    from pw_build.mirror_tree import mirror_paths
+
+
+def _parse_args() -> argparse.Namespace:
+    parser = argparse.ArgumentParser(description=__doc__)
+
+    parser.add_argument('--file-list',
+                        required=True,
+                        type=argparse.FileType('r'),
+                        help='A list of files to copy')
+    parser.add_argument('--file-list-root',
+                        required=True,
+                        type=argparse.FileType('r'),
+                        help='A file with the root of the file list')
+    parser.add_argument('--label', help='Label for this Python package')
+    parser.add_argument('--proto-library',
+                        default='',
+                        help='Name of proto library nested in this package')
+    parser.add_argument('--proto-library-file',
+                        type=Path,
+                        help="File with the proto library's name")
+    parser.add_argument('--root',
+                        required=True,
+                        type=Path,
+                        help='The base directory for the Python package')
+    parser.add_argument('--setup-json',
+                        required=True,
+                        type=argparse.FileType('r'),
+                        help='setup.py keywords as JSON')
+    parser.add_argument('--module-as-package',
+                        action='store_true',
+                        help='Generate an __init__.py that imports everything')
+    parser.add_argument('files',
+                        type=Path,
+                        nargs='+',
+                        help='Relative paths to the files in the package')
+    return parser.parse_args()
+
+
+def _check_nested_protos(label: str, proto_library_file: Path,
+                         proto_library: str) -> None:
+    """Checks that the proto library refers to this package; returns error."""
+    error = 'not set'
+
+    if proto_library_file.exists():
+        proto_label = proto_library_file.read_text().strip()
+        if proto_label == label:
+            return
+
+        if proto_label:
+            error = f'set to {proto_label}'
+
+    raise ValueError(
+        f"{label}'s 'proto_library' is set to {proto_library}, but that "
+        f"target's 'python_package' is {error}. Set {proto_library}'s "
+        f"'python_package' to {label}.")
+
+
+def _collect_all_files(files: List[Path], root: Path, file_list: TextIO,
+                       file_list_root: TextIO) -> Dict[str, Set[str]]:
+    """Collects files in output dir, adds to files; returns package_data."""
+    root.mkdir(exist_ok=True)
+
+    other_files = [Path(p.rstrip()) for p in file_list]
+    other_files_root = Path(file_list_root.read().rstrip())
+
+    # Mirror the proto files to this package.
+    files += mirror_paths(other_files_root, other_files, root)
+
+    # Find all subpackages, including empty ones.
+    subpackages: Set[Path] = set()
+    for file in (f.relative_to(root) for f in files):
+        subpackages.update(root / path for path in file.parents)
+    subpackages.remove(root)
+
+    # Make sure there are __init__.py and py.typed files for each subpackage.
+    for pkg in subpackages:
+        for file in (pkg / name for name in ['__init__.py', 'py.typed']):
+            if not file.exists():
+                file.touch()
+            files.append(file)
+
+    pkg_data: Dict[str, Set[str]] = defaultdict(set)
+
+    # Add all non-source files to package data.
+    for file in (f for f in files if f.suffix != '.py'):
+        pkg = root / file.parent
+        package_name = pkg.relative_to(root).as_posix().replace('/', '.')
+        pkg_data[package_name].add(file.name)
+
+    return pkg_data
+
+
+_SETUP_PY_FILE = '''\
+# Generated file. Do not modify.
+# pylint: skip-file
+
+import setuptools  # type: ignore
+
+setuptools.setup(
+{keywords}
+)
+'''
+
+
+def _generate_setup_py(pkg_data: dict, setup_json: TextIO) -> str:
+    setup_keywords = dict(
+        packages=list(pkg_data),
+        package_data={pkg: list(files)
+                      for pkg, files in pkg_data.items()},
+    )
+
+    specified_keywords = json.load(setup_json)
+
+    assert not any(kw in specified_keywords for kw in setup_keywords), (
+        'Generated packages may not specify "packages" or "package_data"')
+    setup_keywords.update(specified_keywords)
+
+    return _SETUP_PY_FILE.format(keywords='\n'.join(
+        f'    {k}={v!r},' for k, v in setup_keywords.items()))
+
+
+def _import_module_in_package_init(all_files: List[Path]) -> None:
+    """Generates an __init__.py that imports the module.
+
+    This makes an individual module usable as a package. This is used for proto
+    modules.
+    """
+    sources = [
+        f for f in all_files if f.suffix == '.py' and f.name != '__init__.py'
+    ]
+    assert len(sources) == 1, (
+        'Module as package expects a single .py source file')
+
+    source, = sources
+    source.parent.joinpath('__init__.py').write_text(
+        f'from {source.stem}.{source.stem} import *\n')
+
+
+def main(files: List[Path],
+         root: Path,
+         file_list: TextIO,
+         file_list_root: TextIO,
+         module_as_package: bool,
+         setup_json: TextIO,
+         label: str,
+         proto_library: str = '',
+         proto_library_file: Path = None) -> int:
+    """Generates a setup.py and other files for a Python package."""
+    if proto_library_file:
+        try:
+            _check_nested_protos(label, proto_library_file, proto_library)
+        except ValueError as error:
+            msg = '\n'.join(textwrap.wrap(str(error), 78))
+            print(
+                f'ERROR: Failed to generate Python package {label}:\n\n'
+                f'{textwrap.indent(msg, "  ")}\n',
+                file=sys.stderr)
+            return 1
+
+    pkg_data = _collect_all_files(files, root, file_list, file_list_root)
+
+    if module_as_package:
+        _import_module_in_package_init(files)
+
+    # Create the setup.py file for this package.
+    root.joinpath('setup.py').write_text(
+        _generate_setup_py(pkg_data, setup_json))
+
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main(**vars(_parse_args())))
diff --git a/pw_build/py/pw_build/mirror_tree.py b/pw_build/py/pw_build/mirror_tree.py
new file mode 100644
index 0000000..ef7e455
--- /dev/null
+++ b/pw_build/py/pw_build/mirror_tree.py
@@ -0,0 +1,104 @@
+# Copyright 2021 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.
+"""Mirrors a directory tree to another directory using hard links."""
+
+import argparse
+import os
+from pathlib import Path
+from typing import Iterable, Iterator, List
+
+
+def _parse_args() -> argparse.Namespace:
+    """Registers the script's arguments on an argument parser."""
+
+    parser = argparse.ArgumentParser(description=__doc__)
+
+    parser.add_argument('--source-root',
+                        type=Path,
+                        required=True,
+                        help='Prefix to strip from the source files')
+    parser.add_argument('sources',
+                        type=Path,
+                        nargs='*',
+                        help='Files to mirror to the directory')
+    parser.add_argument('--directory',
+                        type=Path,
+                        required=True,
+                        help='Directory to which to mirror the sources')
+    parser.add_argument('--path-file',
+                        type=Path,
+                        help='File with paths to files to mirror')
+
+    return parser.parse_args()
+
+
+def _link_files(source_root: Path, sources: Iterable[Path],
+                directory: Path) -> Iterator[Path]:
+    for source in sources:
+        dest = directory / source.relative_to(source_root)
+        dest.parent.mkdir(parents=True, exist_ok=True)
+
+        if dest.exists():
+            dest.unlink()
+
+        # Use a hard link to avoid unnecessary copies. Resolve the source before
+        # linking in case it is a symlink.
+        os.link(source.resolve(), dest)
+
+        yield dest
+
+
+def _link_files_or_dirs(paths: Iterable[Path],
+                        directory: Path) -> Iterator[Path]:
+    """Links files or directories into the output directory.
+
+    Files are linked directly; files in directories are linked as relative paths
+    from the directory.
+    """
+
+    for path in paths:
+        if path.is_dir():
+            files = (p for p in path.glob('**/*') if p.is_file())
+            yield from _link_files(path, files, directory)
+        elif path.is_file():
+            yield from _link_files(path.parent, [path], directory)
+        else:
+            raise FileNotFoundError(f'{path} does not exist!')
+
+
+def mirror_paths(source_root: Path,
+                 sources: Iterable[Path],
+                 directory: Path,
+                 path_file: Path = None) -> List[Path]:
+    """Creates hard links in the provided directory for the provided sources.
+
+    Args:
+      source_root: Base path for files in sources.
+      sources: Files to link to from the directory.
+      directory: The output directory.
+      path_file: A file with file or directory paths to link to.
+    """
+    directory.mkdir(parents=True, exist_ok=True)
+
+    outputs = list(_link_files(source_root, sources, directory))
+
+    if path_file:
+        paths = (Path(p).resolve() for p in path_file.read_text().splitlines())
+        outputs.extend(_link_files_or_dirs(paths, directory))
+
+    return outputs
+
+
+if __name__ == '__main__':
+    mirror_paths(**vars(_parse_args()))
diff --git a/pw_build/py/pw_build/null_backend.py b/pw_build/py/pw_build/null_backend.py
deleted file mode 100644
index adc61e7..0000000
--- a/pw_build/py/pw_build/null_backend.py
+++ /dev/null
@@ -1,39 +0,0 @@
-# Copyright 2019 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.
-"""Script that emits a helpful error when a facade is used without a backend."""
-
-import argparse
-import sys
-
-
-def parse_args():
-    """Parses command-line arguments."""
-
-    parser = argparse.ArgumentParser(
-        description='Emits an error when a facade has a null backend')
-    parser.add_argument('facade_name', help='The facade with a null backend')
-    return parser.parse_args()
-
-
-def main():
-    args = parse_args()
-    print(f'ERROR: {args.facade_name} tried to build without a backend.')
-    print('If you are using this module, ensure you have configured a backend '
-          'properly. If you are NOT using this module, this error may have '
-          'been triggered by trying to build all targets.')
-    sys.exit(1)
-
-
-if __name__ == '__main__':
-    main()
diff --git a/pw_build/py/pw_build/python_runner.py b/pw_build/py/pw_build/python_runner.py
index 0bd5f96..b719650 100755
--- a/pw_build/py/pw_build/python_runner.py
+++ b/pw_build/py/pw_build/python_runner.py
@@ -160,6 +160,30 @@
 # Matches a non-phony build statement.
 _GN_NINJA_BUILD_STATEMENT = re.compile(r'^build (.+):[ \n](?!phony\b)')
 
+# Extensions used for compilation artifacts.
+_MAIN_ARTIFACTS = '', '.elf', '.a', '.so', '.dylib', '.exe', '.lib', '.dll'
+
+
+def _get_artifact(build_dir: Path, entries: List[str]) -> _Artifact:
+    """Attempts to resolve which artifact to use if there are multiple.
+
+    Selects artifacts based on extension. This will not work if a toolchain
+    creates multiple compilation artifacts from one command (e.g. .a and .elf).
+    """
+    assert entries, "There should be at least one entry here!"
+
+    if len(entries) == 1:
+        return _Artifact(build_dir / entries[0], {})
+
+    filtered = [p for p in entries if Path(p).suffix in _MAIN_ARTIFACTS]
+
+    if len(filtered) == 1:
+        return _Artifact(build_dir / filtered[0], {})
+
+    raise ExpressionError(
+        f'Expected 1, but found {len(filtered)} artifacts, after filtering for '
+        f'extensions {", ".join(repr(e) for e in _MAIN_ARTIFACTS)}: {entries}')
+
 
 def _parse_build_artifacts(build_dir: Path, fd) -> Iterator[_Artifact]:
     """Partially parses the build statements in a Ninja file."""
@@ -188,7 +212,7 @@
         else:
             match = _GN_NINJA_BUILD_STATEMENT.match(line)
             if match:
-                artifact = _Artifact(build_dir / match.group(1), {})
+                artifact = _get_artifact(build_dir, match.group(1).split())
 
             line = next_line()
 
@@ -364,6 +388,7 @@
         yield _ArgAction.EMIT_NEW, str(obj)
 
 
+# TODO(pwbug/347): Replace expressions with native GN features when possible.
 _FUNCTIONS: Dict['str', Callable[[GnPaths, _Expression], _Actions]] = {
     'TARGET_FILE': _target_file,
     'TARGET_FILE_IF_EXISTS': _target_file_if_exists,
diff --git a/pw_build/py/python_runner_test.py b/pw_build/py/python_runner_test.py
index 86497f4..1ea69b0 100755
--- a/pw_build/py/python_runner_test.py
+++ b/pw_build/py/python_runner_test.py
@@ -131,7 +131,7 @@
 build fake_toolchain/obj/fake_module/fake_test.fake_test.cc.o: fake_toolchain_cxx ../fake_module/fake_test.cc
 build fake_toolchain/obj/fake_module/fake_test.fake_test_c.c.o: fake_toolchain_cc ../fake_module/fake_test_c.c
 
-build fake_toolchain/obj/fake_module/test/fake_test.elf: fake_toolchain_link fake_toolchain/obj/fake_module/fake_test.fake_test.cc.o fake_toolchain/obj/fake_module/fake_test.fake_test_c.c.o
+build fake_toolchain/obj/fake_module/test/fake_test.elf fake_toolchain/obj/fake_module/test/fake_test.map: fake_toolchain_link fake_toolchain/obj/fake_module/fake_test.fake_test.cc.o fake_toolchain/obj/fake_module/fake_test.fake_test_c.c.o
   ldflags = -Og -fdiagnostics-color
   libs =
   frameworks =
diff --git a/pw_build/py/setup.py b/pw_build/py/setup.py
index c8fc23e..50388a5 100644
--- a/pw_build/py/setup.py
+++ b/pw_build/py/setup.py
@@ -26,5 +26,13 @@
     zip_safe=False,
     install_requires=[
         'wheel',
+        'pw_cli',
+        'pw_env_setup',
+        'pw_presubmit',
     ],
+    entry_points={
+        'console_scripts': [
+            'copy_from_cipd = pw_build.copy_from_cipd:main',
+        ],
+    },
 )
diff --git a/pw_build/py/pw_build/zip_test.py b/pw_build/py/zip_test.py
similarity index 100%
rename from pw_build/py/pw_build/zip_test.py
rename to pw_build/py/zip_test.py
diff --git a/pw_build/python.gni b/pw_build/python.gni
index 9ca3a43..038d9a2 100644
--- a/pw_build/python.gni
+++ b/pw_build/python.gni
@@ -1,4 +1,4 @@
-# Copyright 2020 The Pigweed Authors
+# Copyright 2021 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
@@ -15,226 +15,609 @@
 import("//build_overrides/pigweed.gni")
 
 import("$dir_pw_build/input_group.gni")
+import("$dir_pw_build/mirror_tree.gni")
 import("$dir_pw_build/python_action.gni")
 
+# Python packages provide the following targets as $target_name.$subtarget.
+pw_python_package_subtargets = [
+  "tests",
+  "lint",
+  "lint.mypy",
+  "lint.pylint",
+  "install",
+  "wheel",
+
+  # Internal targets that directly depend on one another.
+  "_run_pip_install",
+  "_build_wheel",
+]
+
+# Create aliases for subsargets when the target name matches the directory name.
+# This allows //foo:foo.tests to be accessed as //foo:tests, for example.
+template("_pw_create_aliases_if_name_matches_directory") {
+  not_needed([ "invoker" ])
+
+  if (get_label_info(":$target_name", "name") ==
+      get_path_info(get_label_info(":$target_name", "dir"), "name")) {
+    foreach(subtarget, pw_python_package_subtargets) {
+      group(subtarget) {
+        public_deps = [ ":${invoker.target_name}.$subtarget" ]
+      }
+    }
+  }
+}
+
+# Internal template that runs Mypy.
+template("_pw_python_static_analysis_mypy") {
+  pw_python_action(target_name) {
+    module = "mypy"
+    args = [
+      "--pretty",
+      "--show-error-codes",
+    ]
+
+    if (defined(invoker.mypy_ini)) {
+      args += [ "--config-file=" + rebase_path(invoker.mypy_ini) ]
+      inputs = [ invoker.mypy_ini ]
+    }
+
+    args += rebase_path(invoker.sources)
+
+    # Use this environment variable to force mypy to colorize output.
+    # See https://github.com/python/mypy/issues/7771
+    environment = [ "MYPY_FORCE_COLOR=1" ]
+
+    directory = invoker.directory
+    stamp = true
+
+    deps = invoker.deps
+
+    foreach(dep, invoker.python_deps) {
+      deps += [ string_replace(dep, "(", ".lint.mypy(") ]
+    }
+  }
+}
+
+# Internal template that runs Pylint.
+template("_pw_python_static_analysis_pylint") {
+  # Create a target to run pylint on each of the Python files in this
+  # package and its dependencies.
+  pw_python_action_foreach(target_name) {
+    module = "pylint"
+    args = [
+      rebase_path(".") + "/{{source_target_relative}}",
+      "--jobs=1",
+      "--output-format=colorized",
+    ]
+
+    if (defined(invoker.pylintrc)) {
+      args += [ "--rcfile=" + rebase_path(invoker.pylintrc) ]
+      inputs = [ invoker.pylintrc ]
+    }
+
+    if (host_os == "win") {
+      # Allow CRLF on Windows, in case Git is set to switch line endings.
+      args += [ "--disable=unexpected-line-ending-format" ]
+    }
+
+    sources = invoker.sources
+    directory = invoker.directory
+
+    stamp = "$target_gen_dir/{{source_target_relative}}.pylint.passed"
+
+    public_deps = invoker.deps
+
+    foreach(dep, invoker.python_deps) {
+      public_deps += [ string_replace(dep, "(", ".lint.pylint(") ]
+    }
+  }
+}
+
 # Defines a Python package. GN Python packages contain several GN targets:
 #
 #   - $name - Provides the Python files in the build, but does not take any
 #         actions. All subtargets depend on this target.
 #   - $name.lint - Runs static analyis tools on the Python code. This is a group
 #     of two subtargets:
-#     - $name.lint.mypy - Runs mypy.
-#     - $name.lint.pylint - Runs pylint.
+#     - $name.lint.mypy - Runs mypy (if enabled).
+#     - $name.lint.pylint - Runs pylint (if enabled).
 #   - $name.tests - Runs all tests for this package.
 #   - $name.install - Installs the package in a venv.
-#   - $name.wheel - Builds a Python wheel for the package. (Not implemented.)
+#   - $name.wheel - Builds a Python wheel for the package.
 #
-# TODO(pwbug/239): Implement installation and wheel building.
+# All Python packages are instantiated with the default toolchain, regardless of
+# the current toolchain.
 #
 # Args:
 #   setup: List of setup file paths (setup.py or pyproject.toml & setup.cfg),
 #       which must all be in the same directory.
+#   generate_setup: As an alternative to 'setup', generate setup files with the
+#       keywords in this scope. 'name' is required.
 #   sources: Python sources files in the package.
 #   tests: Test files for this Python package.
 #   python_deps: Dependencies on other pw_python_packages in the GN build.
+#   python_test_deps: Test-only pw_python_package dependencies.
 #   other_deps: Dependencies on GN targets that are not pw_python_packages.
 #   inputs: Other files to track, such as package_data.
+#   proto_library: A pw_proto_library target to embed in this Python package.
+#       generate_setup is required in place of setup if proto_library is used.
+#   static_analysis: List of static analysis tools to run; "*" (default) runs
+#       all tools. The supported tools are "mypy" and "pylint".
+#   pylintrc: Optional path to a pylintrc configuration file to use. If not
+#       provided, Pylint's default rcfile search is used. Pylint is executed
+#       from the package's setup directory, so pylintrc files in that directory
+#       will take precedence over others.
+#   mypy_ini: Optional path to a mypy configuration file to use. If not
+#       provided, mypy's default configuration file search is used. mypy is
+#       executed from the package's setup directory, so mypy.ini files in that
+#       directory will take precedence over others.
 #
 template("pw_python_package") {
-  if (defined(invoker.sources)) {
-    _all_py_files = invoker.sources
-  } else {
-    _all_py_files = []
-  }
-
-  if (defined(invoker.tests)) {
-    _test_sources = invoker.tests
-  } else {
-    _test_sources = []
-  }
-
-  _all_py_files += _test_sources
-
-  assert(_all_py_files != [], "At least one source or test must be provided")
-
-  # pw_python_script uses pw_python_package, but with a limited set of features.
-  # _pw_standalone signals that this target is actually a pw_python_script.
-  _is_package = !(defined(invoker._pw_standalone) && invoker._pw_standalone)
-
-  if (_is_package) {
-    assert(defined(invoker.setup) && invoker.setup != [],
-           "pw_python_package requires 'setup' to point to a setup.py file " +
-               "or pyproject.toml and setup.cfg files")
-
-    _all_py_files += invoker.setup
-
-    # Get the directories of the setup files. All files must be in the same dir.
-    _setup_dirs = get_path_info(invoker.setup, "dir")
-    _setup_dir = _setup_dirs[0]
-
-    foreach(dir, _setup_dirs) {
-      assert(dir == _setup_dir,
-             "All files in 'setup' must be in the same directory")
-    }
-
-    # If sources are provided, make sure there is an __init__.py file.
-    if (defined(invoker.sources)) {
-      assert(filter_include(invoker.sources, [ "*\b__init__.py" ]) != [],
-             "Python packages must have at least one __init__.py file")
+  # The Python targets are always instantiated in the default toolchain. Use
+  # fully qualified labels so that the toolchain is not lost.
+  _other_deps = []
+  if (defined(invoker.other_deps)) {
+    foreach(dep, invoker.other_deps) {
+      _other_deps += [ get_label_info(dep, "label_with_toolchain") ]
     }
   }
 
   _python_deps = []
   if (defined(invoker.python_deps)) {
     foreach(dep, invoker.python_deps) {
-      # Use the fully qualified name so the subtarget can be appended as needed.
-      _python_deps += [ get_label_info(dep, "label_no_toolchain") ]
+      _python_deps += [ get_label_info(dep, "label_with_toolchain") ]
     }
   }
 
-  # Declare the main Python package group. This represents the Python files, but
-  # does not take any actions. GN targets can depend on the package name to run
-  # when any files in the package change.
-  pw_input_group(target_name) {
-    inputs = _all_py_files
-    if (defined(invoker.inputs)) {
-      inputs += invoker.inputs
-    }
+  # pw_python_script uses pw_python_package, but with a limited set of features.
+  # _pw_standalone signals that this target is actually a pw_python_script.
+  _is_package = !(defined(invoker._pw_standalone) && invoker._pw_standalone)
 
-    deps = _python_deps
+  _generate_package = false
 
-    if (defined(invoker.other_deps)) {
-      deps += invoker.other_deps
-    }
-  }
-
-  _package_target = ":$target_name"
-
+  # Check the generate_setup and import_protos args to determine if this package
+  # is generated.
   if (_is_package) {
-    # Install this Python package and its dependencies in the current Python
-    # environment.
-    pw_python_action("$target_name.install") {
-      module = "pip"
-      args = [
-        "install",
-        "--editable",
-        rebase_path(_setup_dir),
-      ]
+    assert(defined(invoker.generate_setup) != defined(invoker.setup),
+           "Either 'setup' or 'generate_setup' (but not both) must provided")
 
-      stamp = true
+    if (defined(invoker.proto_library)) {
+      assert(invoker.proto_library != "", "'proto_library' cannot be empty")
+      assert(defined(invoker.generate_setup),
+             "Python packages that import protos with 'proto_library' must " +
+                 "use 'generate_setup' instead of 'setup'")
 
-      # Parallel pip installations don't work, so serialize pip invocations.
-      pool = "$dir_pw_build:pip_pool"
+      _import_protos = [ invoker.proto_library ]
+    } else if (defined(invoker.generate_setup)) {
+      _import_protos = []
+    }
 
-      deps = [ _package_target ]
-      foreach(dep, _python_deps) {
-        deps += [ "$dep.install" ]
+    if (defined(invoker.generate_setup)) {
+      _generate_package = true
+      _setup_dir = "$target_gen_dir/$target_name.generated_python_package"
+
+      if (defined(invoker.strip_prefix)) {
+        _source_root = invoker.strip_prefix
+      } else {
+        _source_root = "."
+      }
+    } else {
+      # Non-generated packages with sources provided need an __init__.py.
+      assert(!defined(invoker.sources) || invoker.sources == [] ||
+                 filter_include(invoker.sources, [ "*\b__init__.py" ]) != [],
+             "Python packages must have at least one __init__.py file")
+
+      # Get the directories of the setup files. All must be in the same dir.
+      _setup_dirs = get_path_info(invoker.setup, "dir")
+      _setup_dir = _setup_dirs[0]
+
+      foreach(dir, _setup_dirs) {
+        assert(dir == _setup_dir,
+               "All files in 'setup' must be in the same directory")
+      }
+
+      assert(!defined(invoker.strip_prefix),
+             "'strip_prefix' may only be given if 'generate_setup' is provided")
+    }
+  }
+
+  # Process arguments defaults and set defaults.
+
+  _supported_static_analysis_tools = [
+    "mypy",
+    "pylint",
+  ]
+  not_needed([ "_supported_static_analysis_tools" ])
+
+  # Argument: static_analysis (list of tool names or "*"); default = "*" (all)
+  if (!defined(invoker.static_analysis) || invoker.static_analysis == "*") {
+    _static_analysis = _supported_static_analysis_tools
+  } else {
+    _static_analysis = invoker.static_analysis
+  }
+
+  # TODO(hepler): Remove support for the lint option.
+  if (defined(invoker.lint)) {
+    assert(!defined(invoker.static_analysis),
+           "'lint' is deprecated; use 'static_analysis' instead")
+
+    # Only allow 'lint = false', for backwards compatibility.
+    assert(invoker.lint == false, "'lint' is deprecated; use 'static_analysis'")
+    print("WARNING:",
+          "The 'lint' option for pw_python_package is deprecated.",
+          "Instead, use 'static_analysis = []' to disable linting.")
+    _static_analysis = []
+  }
+
+  foreach(_tool, _static_analysis) {
+    assert(_supported_static_analysis_tools + [ _tool ] - [ _tool ] !=
+               _supported_static_analysis_tools,
+           "'$_tool' is not a supported static analysis tool")
+  }
+
+  # Argument: sources (list)
+  _sources = []
+  if (defined(invoker.sources)) {
+    if (_generate_package) {
+      foreach(source, rebase_path(invoker.sources, _source_root)) {
+        _sources += [ "$_setup_dir/$source" ]
+      }
+    } else {
+      _sources += invoker.sources
+    }
+  }
+
+  # Argument: tests (list)
+  _test_sources = []
+  if (defined(invoker.tests)) {
+    if (_generate_package) {
+      foreach(source, rebase_path(invoker.tests, _source_root)) {
+        _test_sources += [ "$_setup_dir/$source" ]
+      }
+    } else {
+      _test_sources += invoker.tests
+    }
+  }
+
+  # Argument: setup (list)
+  _setup_sources = []
+  if (defined(invoker.setup)) {
+    _setup_sources = invoker.setup
+  } else if (_generate_package) {
+    _setup_sources = [ "$_setup_dir/setup.py" ]
+  }
+
+  # Argument: python_test_deps (list)
+  _python_test_deps = _python_deps  # include all deps in test deps
+  if (defined(invoker.python_test_deps)) {
+    foreach(dep, invoker.python_test_deps) {
+      _python_test_deps += [ get_label_info(dep, "label_with_toolchain") ]
+    }
+  }
+
+  if (_test_sources == []) {
+    assert(!defined(invoker.python_test_deps),
+           "python_test_deps was provided, but there are no tests in " +
+               get_label_info(":$target_name", "label_no_toolchain"))
+    not_needed([ "_python_test_deps" ])
+  }
+
+  _all_py_files = _sources + _test_sources + _setup_sources
+
+  # The pw_python_package subtargets are only instantiated in the default
+  # toolchain. Other toolchains just refer to targets in the default toolchain.
+  if (current_toolchain == default_toolchain) {
+    # Declare the main Python package group. This represents the Python files,
+    # but does not take any actions. GN targets can depend on the package name
+    # to run when any files in the package change.
+    if (_generate_package) {
+      # If this package is generated, mirror the sources to the final directory.
+      pw_mirror_tree("$target_name._mirror_sources_to_out_dir") {
+        directory = _setup_dir
+
+        sources = []
+        if (defined(invoker.sources)) {
+          sources += invoker.sources
+        }
+        if (defined(invoker.tests)) {
+          sources += invoker.tests
+        }
+
+        source_root = _source_root
+        public_deps = _python_deps + _other_deps
+      }
+
+      # Depend on the proto's _gen targets (from the default toolchain).
+      _gen_protos = []
+      foreach(proto, _import_protos) {
+        _gen_protos +=
+            [ get_label_info(proto, "label_no_toolchain") + ".python._gen" ]
+      }
+
+      generated_file("$target_name._protos") {
+        deps = _gen_protos
+        data_keys = [ "protoc_outputs" ]
+        outputs = [ "$_setup_dir/protos.txt" ]
+      }
+
+      _protos_file = get_target_outputs(":${invoker.target_name}._protos")
+
+      generated_file("$target_name._protos_root") {
+        deps = _gen_protos
+        data_keys = [ "root" ]
+        outputs = [ "$_setup_dir/proto_root.txt" ]
+      }
+
+      _root_file = get_target_outputs(":${invoker.target_name}._protos_root")
+
+      # Get generated_setup scope and write it to disk ask JSON.
+      _gen_setup = invoker.generate_setup
+      assert(defined(_gen_setup.name), "'name' is required in generate_package")
+      assert(!defined(_gen_setup.packages) && !defined(_gen_setup.package_data),
+             "'packages' and 'package_data' may not be provided " +
+                 "in 'generate_package'")
+      write_file("$_setup_dir/setup.json", _gen_setup, "json")
+
+      # Generate the setup.py, py.typed, and __init__.py files as needed.
+      action(target_name) {
+        script = "$dir_pw_build/py/pw_build/generate_python_package.py"
+        args = [
+                 "--label",
+                 get_label_info(":$target_name", "label_no_toolchain"),
+                 "--root",
+                 rebase_path(_setup_dir),
+                 "--setup-json",
+                 rebase_path("$_setup_dir/setup.json"),
+                 "--file-list",
+                 rebase_path(_protos_file[0]),
+                 "--file-list-root",
+                 rebase_path(_root_file[0]),
+               ] + rebase_path(_sources)
+
+        if (defined(invoker._pw_module_as_package) &&
+            invoker._pw_module_as_package) {
+          args += [ "--module-as-package" ]
+        }
+
+        inputs = [ "$_setup_dir/setup.json" ]
+
+        public_deps = [
+          ":$target_name._mirror_sources_to_out_dir",
+          ":$target_name._protos",
+          ":$target_name._protos_root",
+        ]
+
+        foreach(proto, _import_protos) {
+          _tgt = get_label_info(proto, "label_no_toolchain")
+          _path = get_label_info("$_tgt($default_toolchain)", "target_gen_dir")
+          _name = get_label_info(_tgt, "name")
+
+          args += [
+            "--proto-library=$_tgt",
+            "--proto-library-file",
+            rebase_path("$_path/$_name.proto_library/python_package.txt"),
+          ]
+
+          public_deps += [ "$_tgt.python._gen($default_toolchain)" ]
+        }
+
+        outputs = _setup_sources
+      }
+    } else {
+      # If the package is not generated, use an input group for the sources.
+      pw_input_group(target_name) {
+        inputs = _all_py_files
+        if (defined(invoker.inputs)) {
+          inputs += invoker.inputs
+        }
+
+        deps = _python_deps + _other_deps
       }
     }
 
-    # TODO(pwbug/239): Add support for building groups of wheels. The code below
-    #     is incomplete and untested.
-    pw_python_action("$target_name.wheel") {
-      script = "$dir_pw_build/py/pw_build/python_wheels.py"
+    if (_is_package) {
+      # Install this Python package and its dependencies in the current Python
+      # environment using pip.
+      pw_python_action("$target_name._run_pip_install") {
+        module = "pip"
+        public_deps = []
 
-      args = [
-        "--out_dir",
-        rebase_path(target_out_dir),
-      ]
-      args += rebase_path(_all_py_files)
+        args = [ "install" ]
 
-      deps = [ _package_target ]
-      stamp = true
+        # For generated packages, reinstall when any files change. For regular
+        # packages, only reinstall when setup.py changes.
+        if (_generate_package) {
+          public_deps += [ ":${invoker.target_name}" ]
+        } else {
+          inputs = invoker.setup
+
+          # Install with --editable since the complete package is in source.
+          args += [ "--editable" ]
+        }
+
+        args += [ rebase_path(_setup_dir) ]
+
+        stamp = true
+
+        # Parallel pip installations don't work, so serialize pip invocations.
+        pool = "$dir_pw_build:pip_pool"
+
+        foreach(dep, _python_deps) {
+          # We need to add a suffix to the target name, but the label is
+          # formatted as "//path/to:target(toolchain)", so we can't just append
+          # ".subtarget". Instead, we replace the opening parenthesis of the
+          # toolchain with ".suffix(".
+          public_deps += [ string_replace(dep, "(", "._run_pip_install(") ]
+        }
+      }
+
+      # Builds a Python wheel for this package. Records the output directory
+      # in the pw_python_package_wheels metadata key.
+      pw_python_action("$target_name._build_wheel") {
+        metadata = {
+          pw_python_package_wheels = [ "$target_out_dir/$target_name" ]
+        }
+
+        module = "build"
+
+        args = [
+                 rebase_path(_setup_dir),
+                 "--wheel",
+                 "--no-isolation",
+                 "--outdir",
+               ] + rebase_path(metadata.pw_python_package_wheels)
+
+        deps = [ ":${invoker.target_name}" ]
+        foreach(dep, _python_deps) {
+          deps += [ string_replace(dep, "(", ".wheel(") ]
+        }
+
+        stamp = true
+      }
+    } else {
+      # Stubs for non-package targets.
+      group("$target_name._run_pip_install") {
+      }
+      group("$target_name._build_wheel") {
+      }
+    }
+
+    # Create the .install and .wheel targets. To limit unnecessary pip
+    # executions, non-generated packages are only reinstalled when their
+    # setup.py changes. However, targets that depend on the .install subtarget
+    # re-run whenever any source files change.
+    #
+    # These targets just represent the source files if this isn't a package.
+    group("$target_name.install") {
+      public_deps = [ ":${invoker.target_name}" ]
+
+      if (_is_package) {
+        public_deps += [ ":${invoker.target_name}._run_pip_install" ]
+      }
+
+      foreach(dep, _python_deps) {
+        public_deps += [ string_replace(dep, "(", ".install(") ]
+      }
+    }
+
+    group("$target_name.wheel") {
+      public_deps = [ ":${invoker.target_name}.install" ]
+
+      if (_is_package) {
+        public_deps += [ ":${invoker.target_name}._build_wheel" ]
+      }
+
+      foreach(dep, _python_deps) {
+        public_deps += [ string_replace(dep, "(", ".wheel(") ]
+      }
+    }
+
+    # Define the static analysis targets for this package.
+    group("$target_name.lint") {
+      deps = []
+      foreach(_tool, _supported_static_analysis_tools) {
+        deps += [ ":${invoker.target_name}.lint.$_tool" ]
+      }
+    }
+
+    if (_static_analysis != [] || _test_sources != []) {
+      # All packages to install for either general use or test running.
+      _test_install_deps = [ ":$target_name.install" ]
+      foreach(dep, _python_test_deps + [ "$dir_pw_build:python_lint" ]) {
+        _test_install_deps += [ string_replace(dep, "(", ".install(") ]
+      }
+    }
+
+    # For packages that are not generated, create targets to run mypy and pylint.
+    foreach(_tool, _static_analysis) {
+      # Run lint tools from the setup or target directory so that the tools detect
+      # config files (e.g. pylintrc or mypy.ini) in that directory. Config files
+      # may be explicitly specified with the pylintrc or mypy_ini arguments.
+      target("_pw_python_static_analysis_$_tool", "$target_name.lint.$_tool") {
+        sources = _all_py_files
+        deps = _test_install_deps
+        python_deps = _python_deps
+
+        if (defined(_setup_dir)) {
+          directory = rebase_path(_setup_dir)
+        } else {
+          directory = rebase_path(".")
+        }
+
+        _optional_variables = [
+          "mypy_ini",
+          "pylintrc",
+        ]
+        forward_variables_from(invoker, _optional_variables)
+        not_needed(_optional_variables)
+      }
+    }
+
+    foreach(_unused_tool, _supported_static_analysis_tools - _static_analysis) {
+      pw_input_group("$target_name.lint.$_unused_tool") {
+        inputs = []
+        if (defined(invoker.pylintrc)) {
+          inputs += [ invoker.pylintrc ]
+        }
+        if (defined(invoker.mypy_ini)) {
+          inputs += [ invoker.mypy_ini ]
+        }
+      }
+
+      # Generated packages with linting disabled never need the whole file list.
+      not_needed([ "_all_py_files" ])
     }
   } else {
-    # If this is not a package, install or build wheels for its deps only.
-    group("$target_name.install") {
-      deps = []
-      foreach(dep, _python_deps) {
-        deps += [ "$dep.install" ]
+    # Create groups with the public target names ($target_name, $target_name.lint,
+    # $target_name.install, etc.). These are actually wrappers around internal
+    # Python actions instantiated with the default toolchain. This ensures there
+    # is only a single copy of each Python action in the build.
+    #
+    # The $target_name.tests group is created separately below.
+    group("$target_name") {
+      deps = [ ":$target_name($default_toolchain)" ]
+    }
+
+    foreach(subtarget, pw_python_package_subtargets - [ "tests" ]) {
+      group("$target_name.$subtarget") {
+        deps = [ ":${invoker.target_name}.$subtarget($default_toolchain)" ]
       }
     }
-    group("$target_name.wheel") {
-      deps = []
-      foreach(dep, _python_deps) {
-        deps += [ "$dep.wheel" ]
-      }
-    }
-  }
 
-  # Define the static analysis targets for this package.
-  group("$target_name.lint") {
-    deps = [
-      "$_package_target.lint.mypy",
-      "$_package_target.lint.pylint",
-    ]
-  }
-
-  pw_python_action("$target_name.lint.mypy") {
-    module = "mypy"
-    args = [
-      "--pretty",
-      "--show-error-codes",
-    ]
-    if (_is_package) {
-      args += [ rebase_path(_setup_dir) ]
-    } else {
-      args += rebase_path(_all_py_files)
-    }
-
-    # Use this environment variable to force mypy to colorize output.
-    # See https://github.com/python/mypy/issues/7771
-    environment = [ "MYPY_FORCE_COLOR=1" ]
-
-    stamp = true
-
-    deps = [ _package_target ]
-    foreach(dep, _python_deps) {
-      deps += [ "$dep.lint.mypy" ]
-    }
-  }
-
-  pw_python_action_foreach("$target_name.lint.pylint") {
-    module = "pylint"
-    args = [
-      "{{source_root_relative_dir}}/{{source_file_part}}",
-      "--jobs=1",
-      "--output-format=colorized",
-    ]
-
-    if (host_os == "win") {
-      # Allow CRLF on Windows, in case Git is set to switch line endings.
-      args += [ "--disable=unexpected-line-ending-format" ]
-    }
-
-    sources = _all_py_files
-
-    stamp = "$target_gen_dir/{{source_target_relative}}.pylint.pw_pystamp"
-
-    # Run pylint from the source root so that pylint detects rcfiles (.pylintrc)
-    # in the source tree.
-    directory = rebase_path("//")
-
-    deps = [ _package_target ]
-    foreach(dep, _python_deps) {
-      deps += [ "$dep.lint.pylint" ]
-    }
+    # Everything Python-related is only instantiated in the default toolchain.
+    # Silence not-needed warnings except for in the default toolchain.
+    not_needed("*")
+    not_needed(invoker, "*")
   }
 
   # Create a target for each test file.
   _test_targets = []
 
   foreach(test, _test_sources) {
-    _test_name = string_replace(test, "/", "_")
-    _test_target = "$target_name.tests.$_test_name"
+    if (_is_package) {
+      _name = rebase_path(test, _setup_dir)
+    } else {
+      _name = test
+    }
 
-    pw_python_action(_test_target) {
-      script = test
-      stamp = true
+    _test_target = "$target_name.tests." + string_replace(_name, "/", "_")
 
-      deps = [ _package_target ]
-      foreach(dep, _python_deps) {
-        deps += [ "$dep.tests" ]
+    if (current_toolchain == default_toolchain) {
+      pw_python_action(_test_target) {
+        script = test
+        stamp = true
+
+        deps = _test_install_deps
+
+        foreach(dep, _python_test_deps) {
+          deps += [ string_replace(dep, "(", ".tests(") ]
+        }
+      }
+    } else {
+      # Create a public version of each test target, so tests can be executed as
+      # //path/to:package.tests.foo.py.
+      group(_test_target) {
+        deps = [ ":$_test_target($default_toolchain)" ]
       }
     }
 
@@ -244,6 +627,9 @@
   group("$target_name.tests") {
     deps = _test_targets
   }
+
+  _pw_create_aliases_if_name_matches_directory(target_name) {
+  }
 }
 
 # Declares a group of Python packages or other Python groups. pw_python_groups
@@ -261,26 +647,20 @@
     deps = _python_deps
   }
 
-  _subtargets = [
-    "tests",
-    "lint",
-    "lint.mypy",
-    "lint.pylint",
-    "install",
-    "wheel",
-  ]
-
-  foreach(subtarget, _subtargets) {
+  foreach(subtarget, pw_python_package_subtargets) {
     group("$target_name.$subtarget") {
-      deps = []
+      public_deps = []
       foreach(dep, _python_deps) {
         # Split out the toolchain to support deps with a toolchain specified.
         _target = get_label_info(dep, "label_no_toolchain")
         _toolchain = get_label_info(dep, "toolchain")
-        deps += [ "$_target.$subtarget($_toolchain)" ]
+        public_deps += [ "$_target.$subtarget($_toolchain)" ]
       }
     }
   }
+
+  _pw_create_aliases_if_name_matches_directory(target_name) {
+  }
 }
 
 # Declares Python scripts or tests that are not part of a Python package.
@@ -299,10 +679,73 @@
     "python_deps",
     "other_deps",
     "inputs",
+    "pylintrc",
+    "mypy_ini",
+    "static_analysis",
   ]
 
   pw_python_package(target_name) {
     _pw_standalone = true
     forward_variables_from(invoker, _supported_variables)
   }
+
+  _pw_create_aliases_if_name_matches_directory(target_name) {
+  }
+}
+
+# Represents a list of Python requirements, as in a requirements.txt.
+#
+# Args:
+#  files: One or more requirements.txt files.
+#  requirements: A list of requirements.txt-style requirements.
+template("pw_python_requirements") {
+  assert(defined(invoker.files) || defined(invoker.requirements),
+         "pw_python_requirements requires a list of requirements.txt files " +
+             "in the 'files' arg or requirements in 'requirements'")
+
+  _requirements_files = []
+
+  if (defined(invoker.files)) {
+    _requirements_files += invoker.files
+  }
+
+  if (defined(invoker.requirements)) {
+    _requirements_file = "$target_gen_dir/$target_name.requirements.txt"
+    write_file(_requirements_file, invoker.requirements)
+    _requirements_files += [ _requirements_file ]
+  }
+
+  # The default target represents the requirements themselves.
+  pw_input_group(target_name) {
+    inputs = _requirements_files
+  }
+
+  # Use the same subtargets as pw_python_package so these targets can be listed
+  # as python_deps of pw_python_packages.
+  pw_python_action("$target_name.install") {
+    inputs = _requirements_files
+
+    module = "pip"
+    args = [ "install" ]
+
+    foreach(_requirements_file, inputs) {
+      args += [
+        "--requirement",
+        rebase_path(_requirements_file),
+      ]
+    }
+
+    pool = "$dir_pw_build:pip_pool"
+    stamp = true
+  }
+
+  # Create stubs for the unused subtargets so that pw_python_requirements can be
+  # used as python_deps.
+  foreach(subtarget, pw_python_package_subtargets - [ "install" ]) {
+    group("$target_name.$subtarget") {
+    }
+  }
+
+  _pw_create_aliases_if_name_matches_directory(target_name) {
+  }
 }
diff --git a/pw_build/python.rst b/pw_build/python.rst
new file mode 100644
index 0000000..09b92a2
--- /dev/null
+++ b/pw_build/python.rst
@@ -0,0 +1,138 @@
+.. _module-pw_build-python:
+
+-------------------
+Python GN templates
+-------------------
+The Python build is implemented with GN templates defined in
+``pw_build/python.gni``. That file contains the complete usage documentation.
+
+.. seealso:: :ref:`docs-python-build`
+
+pw_python_package
+=================
+The main Python template is ``pw_python_package``. Each ``pw_python_package``
+target represents a Python package. As described in
+:ref:`module-pw_build-python-target`, each ``pw_python_package`` expands to
+several subtargets. In summary, these are:
+
+- ``<name>`` - Represents the files themselves
+- ``<name>.lint`` - Runs static analysis
+- ``<name>.tests`` - Runs all tests for this package
+- ``<name>.install`` - Installs the package
+- ``<name>.wheel`` - Builds a Python wheel
+
+GN permits using abbreviated labels when the target name matches the directory
+name (e.g. ``//foo`` for ``//foo:foo``). For consistency with this, Python
+package subtargets are aliased to the directory when the target name is the
+same as the directory. For example, these two labels are equivalent:
+
+.. code-block::
+
+  //path/to/my_python_package:my_python_package.tests
+  //path/to/my_python_package:tests
+
+Arguments
+---------
+- ``setup`` - List of setup file paths (setup.py or pyproject.toml & setup.cfg),
+  which must all be in the same directory.
+- ``generate_setup``: As an alternative to ``setup``, generate setup files with
+  the keywords in this scope. ``name`` is required. For example:
+
+  .. code-block::
+
+    generate_setup = {
+      name = "a_nifty_package"
+      version = "1.2a"
+    }
+
+- ``sources`` - Python sources files in the package.
+- ``tests`` - Test files for this Python package.
+- ``python_deps`` - Dependencies on other pw_python_packages in the GN build.
+- ``python_test_deps`` - Test-only pw_python_package dependencies.
+- ``other_deps`` - Dependencies on GN targets that are not pw_python_packages.
+- ``inputs`` - Other files to track, such as package_data.
+- ``proto_library`` - A pw_proto_library target to embed in this Python package.
+  ``generate_setup`` is required in place of setup if proto_library is used. See
+  :ref:`module-pw_protobuf_compiler-add-to-python-package`.
+- ``static_analysis`` List of static analysis tools to run; ``"*"`` (default)
+  runs all tools. The supported tools are ``"mypy"`` and ``"pylint"``.
+- ``pylintrc`` - Optional path to a pylintrc configuration file to use. If not
+  provided, Pylint's default rcfile search is used. Pylint is executed
+  from the package's setup directory, so pylintrc files in that directory
+  will take precedence over others.
+- ``mypy_ini`` - Optional path to a mypy configuration file to use. If not
+  provided, mypy's default configuration file search is used. mypy is
+  executed from the package's setup directory, so mypy.ini files in that
+  directory will take precedence over others.
+
+Example
+-------
+This is an example Python package declaration for a ``pw_my_module`` module.
+
+.. code-block::
+
+  import("//build_overrides/pigweed.gni")
+
+  import("$dir_pw_build/python.gni")
+
+  pw_python_package("py") {
+    setup = [ "setup.py" ]
+    sources = [
+      "pw_my_module/__init__.py",
+      "pw_my_module/alfa.py",
+      "pw_my_module/bravo.py",
+      "pw_my_module/charlie.py",
+    ]
+    tests = [
+      "alfa_test.py",
+      "charlie_test.py",
+    ]
+    python_deps = [
+      "$dir_pw_status/py",
+      ":some_protos.python",
+    ]
+    python_test_deps = [ "$dir_pw_build/py" ]
+    pylintrc = "$dir_pigweed/.pylintrc"
+  }
+
+
+.. _module-pw_build-python-wheels:
+
+Collecting Python wheels for distribution
+-----------------------------------------
+The ``.wheel`` subtarget generates a wheel (``.whl``) for the Python package.
+Wheels for a package and its transitive dependencies can be collected by
+traversing the ``pw_python_package_wheels`` `GN metadata
+<https://gn.googlesource.com/gn/+/master/docs/reference.md#var_metadata>`_ key,
+which lists the output directory for each wheel.
+
+The ``pw_mirror_tree`` template can be used to collect wheels in an output
+directory:
+
+.. code-block::
+
+  import("$dir_pw_build/mirror_tree.gni")
+
+  pw_mirror_tree("my_wheels") {
+    path_data_keys = [ "pw_python_package_wheels" ]
+    deps = [ ":python_packages.wheel" ]
+    directory = "$root_out_dir/the_wheels"
+  }
+
+pw_python_script
+================
+A ``pw_python_script`` represents a set of standalone Python scripts and/or
+tests. These files support all of the arguments of ``pw_python_package`` except
+those ``setup``. These targets can be installed, but this only installs their
+dependencies.
+
+pw_python_group
+===============
+Represents a group of ``pw_python_package`` and ``pw_python_script`` targets.
+These targets do not add any files. Their subtargets simply forward to those of
+their dependencies.
+
+pw_python_requirements
+======================
+Represents a set of local and PyPI requirements, with no associated source
+files. These targets serve the role of a ``requirements.txt`` file.
diff --git a/pw_build/python_action.gni b/pw_build/python_action.gni
index a420fce..05b1e90 100644
--- a/pw_build/python_action.gni
+++ b/pw_build/python_action.gni
@@ -50,6 +50,8 @@
 #                     <TARGET_OBJECTS(//some/label:here)> - expands to the
 #                         object files produced by the provided GN target
 #
+#   python_deps     Dependencies on pw_python_package or related Python targets.
+#
 template("pw_python_action") {
   _script_args = [
     # GN root directory relative to the build directory (in which the runner
@@ -142,10 +144,24 @@
     _action_type = "action"
   }
 
+  if (defined(invoker.deps)) {
+    _deps = invoker.deps
+  } else {
+    _deps = []
+  }
+
+  if (defined(invoker.python_deps)) {
+    foreach(dep, invoker.python_deps) {
+      _deps += [ get_label_info(dep, "label_no_toolchain") + ".install(" +
+                 get_label_info(dep, "toolchain") + ")" ]
+    }
+  }
+
   target(_action_type, target_name) {
     _ignore_vars = [
       "script",
       "args",
+      "deps",
       "inputs",
       "outputs",
     ]
@@ -155,6 +171,7 @@
     args = _script_args
     inputs = _inputs
     outputs = _outputs
+    deps = _deps
   }
 }
 
diff --git a/pw_bytes/BUILD.gn b/pw_bytes/BUILD.gn
index d088fe4..1b98997 100644
--- a/pw_bytes/BUILD.gn
+++ b/pw_bytes/BUILD.gn
@@ -35,7 +35,6 @@
   sources = [ "byte_builder.cc" ]
   public_deps = [
     dir_pw_preprocessor,
-    dir_pw_span,
     dir_pw_status,
   ]
 }
@@ -48,7 +47,6 @@
   ]
   group_deps = [
     "$dir_pw_preprocessor:tests",
-    "$dir_pw_span:tests",
     "$dir_pw_status:tests",
   ]
 }
diff --git a/pw_bytes/array_test.cc b/pw_bytes/array_test.cc
index dfccad3..e471617 100644
--- a/pw_bytes/array_test.cc
+++ b/pw_bytes/array_test.cc
@@ -22,6 +22,8 @@
 namespace pw::bytes {
 namespace {
 
+using std::byte;
+
 template <typename T, typename U>
 constexpr bool Equal(const T& lhs, const U& rhs) {
   if (sizeof(lhs) != sizeof(rhs) || std::size(lhs) != std::size(rhs)) {
@@ -37,8 +39,6 @@
   return true;
 }
 
-using std::byte;
-
 constexpr std::array<byte, 5> kHello{
     byte{'H'}, byte{'e'}, byte{'l'}, byte{'l'}, byte{'o'}};
 
@@ -47,7 +47,7 @@
     static_cast<uint32_t>('l') << 16 | static_cast<uint32_t>('o') << 24;
 
 static_assert(Equal(String("Hello"), kHello));
-static_assert(Equal(String(""), std::array<std::byte, 0>{}));
+static_assert(Equal(String(""), std::array<byte, 0>{}));
 static_assert(Equal(MakeArray('H', 'e', 'l', 'l', 'o'), kHello));
 static_assert(Equal(Concat('H', kEllo), kHello));
 
@@ -66,5 +66,33 @@
 constexpr std::array<uint8_t, 4> kUintArray = Array<uint8_t, 1, 2, 3, 255>();
 static_assert(Equal(MakeArray<uint8_t>(1, 2, 3, 255), kUintArray));
 
+// Create a byte array with bytes::Concat and bytes::String and check that its
+// contents are correct.
+constexpr std::array<char, 2> kTestArray = {'a', 'b'};
+
+constexpr auto kConcatTest = bytes::Concat('a',
+                                           uint16_t(1),
+                                           uint8_t(23),
+                                           kTestArray,
+                                           bytes::String("c"),
+                                           uint64_t(-1));
+
+static_assert(kConcatTest.size() == 15);
+static_assert(kConcatTest[0] == byte{'a'});
+static_assert(kConcatTest[1] == byte{1});
+static_assert(kConcatTest[2] == byte{0});
+static_assert(kConcatTest[3] == byte{23});
+static_assert(kConcatTest[4] == byte{'a'});
+static_assert(kConcatTest[5] == byte{'b'});
+static_assert(kConcatTest[6] == byte{'c'});
+static_assert(kConcatTest[7] == byte{0xff});
+static_assert(kConcatTest[8] == byte{0xff});
+static_assert(kConcatTest[9] == byte{0xff});
+static_assert(kConcatTest[10] == byte{0xff});
+static_assert(kConcatTest[11] == byte{0xff});
+static_assert(kConcatTest[12] == byte{0xff});
+static_assert(kConcatTest[13] == byte{0xff});
+static_assert(kConcatTest[14] == byte{0xff});
+
 }  // namespace
 }  // namespace pw::bytes
diff --git a/pw_bytes/byte_builder.cc b/pw_bytes/byte_builder.cc
index ba01c0f..c3f2f7d 100644
--- a/pw_bytes/byte_builder.cc
+++ b/pw_bytes/byte_builder.cc
@@ -39,14 +39,14 @@
   }
 
   size_ += bytes_to_append;
-  status_ = Status::Ok();
+  status_ = OkStatus();
   return bytes_to_append;
 }
 
 void ByteBuilder::resize(size_t new_size) {
   if (new_size <= size_) {
     size_ = new_size;
-    status_ = Status::Ok();
+    status_ = OkStatus();
   } else {
     status_ = Status::OutOfRange();
   }
diff --git a/pw_bytes/byte_builder_test.cc b/pw_bytes/byte_builder_test.cc
index d1d1e7b..97e2bb6 100644
--- a/pw_bytes/byte_builder_test.cc
+++ b/pw_bytes/byte_builder_test.cc
@@ -197,7 +197,7 @@
 
 TEST(ByteBuilder, Status_StartsOk) {
   ByteBuffer<16> bb;
-  EXPECT_EQ(Status::Ok(), bb.status());
+  EXPECT_EQ(OkStatus(), bb.status());
 }
 
 TEST(ByteBuilder, Status_StatusUpdate) {
@@ -222,13 +222,13 @@
   EXPECT_EQ(Status::ResourceExhausted(), bb.status());
 
   bb.clear_status();
-  EXPECT_EQ(Status::Ok(), bb.status());
+  EXPECT_EQ(OkStatus(), bb.status());
 }
 
 TEST(ByteBuilder, PushBack) {
   ByteBuffer<12> bb;
   bb.push_back(byte{0x01});
-  EXPECT_EQ(Status::Ok(), bb.status());
+  EXPECT_EQ(OkStatus(), bb.status());
   EXPECT_EQ(1u, bb.size());
   EXPECT_EQ(byte{0x01}, bb.data()[0]);
 }
@@ -236,7 +236,7 @@
 TEST(ByteBuilder, PushBack_Full) {
   ByteBuffer<1> bb;
   bb.push_back(byte{0x01});
-  EXPECT_EQ(Status::Ok(), bb.status());
+  EXPECT_EQ(OkStatus(), bb.status());
   EXPECT_EQ(1u, bb.size());
 }
 
@@ -256,7 +256,7 @@
   bb.append(buffer.data(), 3);
 
   bb.pop_back();
-  EXPECT_EQ(Status::Ok(), bb.status());
+  EXPECT_EQ(OkStatus(), bb.status());
   EXPECT_EQ(2u, bb.size());
   EXPECT_EQ(byte{0x01}, bb.data()[0]);
   EXPECT_EQ(byte{0x02}, bb.data()[1]);
@@ -270,7 +270,7 @@
   bb.pop_back();
   bb.pop_back();
   bb.pop_back();
-  EXPECT_EQ(Status::Ok(), bb.status());
+  EXPECT_EQ(OkStatus(), bb.status());
   EXPECT_EQ(0u, bb.size());
   EXPECT_TRUE(bb.empty());
 }
@@ -338,7 +338,7 @@
 
   EXPECT_EQ(byte{0x01}, two.data()[0]);
   EXPECT_EQ(byte{0x02}, two.data()[1]);
-  EXPECT_EQ(Status::Ok(), two.status());
+  EXPECT_EQ(OkStatus(), two.status());
 }
 
 TEST(ByteBuilder, ResizeError_NoDataAddedAfter) {
@@ -372,7 +372,7 @@
 
   EXPECT_EQ(byte{0xFE}, bb.data()[0]);
   EXPECT_EQ(byte{0x02}, bb.data()[1]);
-  EXPECT_EQ(Status::Ok(), bb.status());
+  EXPECT_EQ(OkStatus(), bb.status());
 }
 
 TEST(ByteBuffer, Putting8ByteInts_Exhausted) {
@@ -396,7 +396,7 @@
   EXPECT_EQ(byte{0x08}, bb.data()[2]);
   EXPECT_EQ(byte{0x00}, bb.data()[3]);
 
-  EXPECT_EQ(Status::Ok(), bb.status());
+  EXPECT_EQ(OkStatus(), bb.status());
 }
 
 TEST(ByteBuffer, Putting16ByteInts_Exhausted_kBigEndian) {
@@ -428,7 +428,7 @@
   EXPECT_EQ(byte{0x00}, bb.data()[6]);
   EXPECT_EQ(byte{0x00}, bb.data()[7]);
 
-  EXPECT_EQ(Status::Ok(), bb.status());
+  EXPECT_EQ(OkStatus(), bb.status());
 }
 
 TEST(ByteBuffer, Putting32ByteInts_Exhausted_kBigEndian) {
@@ -472,7 +472,7 @@
   EXPECT_EQ(byte{0xFF}, bb.data()[14]);
   EXPECT_EQ(byte{0xFF}, bb.data()[15]);
 
-  EXPECT_EQ(Status::Ok(), bb.status());
+  EXPECT_EQ(OkStatus(), bb.status());
 }
 
 TEST(ByteBuffer, Putting64ByteInts_Exhausted_kBigEndian) {
@@ -527,7 +527,7 @@
   EXPECT_EQ(byte{0x17}, bb.data()[14]);
   EXPECT_EQ(byte{0xFB}, bb.data()[15]);
 
-  EXPECT_EQ(Status::Ok(), bb.status());
+  EXPECT_EQ(OkStatus(), bb.status());
 }
 
 TEST(ByteBuffer, Iterator) {
diff --git a/pw_bytes/docs.rst b/pw_bytes/docs.rst
index 01b5e0f..2e21aaa 100644
--- a/pw_bytes/docs.rst
+++ b/pw_bytes/docs.rst
@@ -31,7 +31,7 @@
   bytes in a fixed-size buffer. ByteBuilder handles reading and writing integers
   with varying endianness.
 
-.. cpp:class:: template <size_t max_size> ByteBuffer
+.. cpp:class:: template <size_t kMaxSize> ByteBuffer
 
   ``ByteBuilder`` with an internally allocated buffer.
 
diff --git a/pw_bytes/endian_test.cc b/pw_bytes/endian_test.cc
index 08ae244..7b49ee7 100644
--- a/pw_bytes/endian_test.cc
+++ b/pw_bytes/endian_test.cc
@@ -264,7 +264,7 @@
 
 TEST(ReadInOrder, BoundsChecking_Ok) {
   constexpr auto buffer = Array<1, 2, 3, 4>();
-  uint16_t value;
+  uint16_t value = 0;
   EXPECT_TRUE(ReadInOrder(std::endian::little, buffer, value));
   EXPECT_EQ(0x0201, value);
 }
diff --git a/pw_bytes/public/pw_bytes/array.h b/pw_bytes/public/pw_bytes/array.h
index 8f93ddd..453f9ea 100644
--- a/pw_bytes/public/pw_bytes/array.h
+++ b/pw_bytes/public/pw_bytes/array.h
@@ -88,9 +88,9 @@
 
 // Converts a string literal to an array of bytes, without the trailing '\0'.
 template <typename B = std::byte,
-          size_t size,
-          typename Indices = std::make_index_sequence<size - 1>>
-consteval auto String(const char (&str)[size]) {
+          size_t kSize,
+          typename Indices = std::make_index_sequence<kSize - 1>>
+consteval auto String(const char (&str)[kSize]) {
   return internal::String<B>(str, Indices{});
 }
 
@@ -116,11 +116,11 @@
 
 // Creates an initialized array of bytes. Initializes the array to a value or
 // the return values from a function that accepts the index as a parameter.
-template <typename B, size_t size, typename T>
+template <typename B, size_t kSize, typename T>
 constexpr auto Initialized(const T& value_or_function) {
-  std::array<B, size> array{};
+  std::array<B, kSize> array{};
 
-  for (size_t i = 0; i < size; ++i) {
+  for (size_t i = 0; i < kSize; ++i) {
     if constexpr (std::is_integral_v<T>) {
       array[i] = static_cast<B>(value_or_function);
     } else {
@@ -131,9 +131,9 @@
 }
 
 // Initialized(value_or_function) defaults to using std::byte.
-template <size_t size, typename T>
+template <size_t kSize, typename T>
 constexpr auto Initialized(const T& value_or_function) {
-  return Initialized<std::byte, size>(value_or_function);
+  return Initialized<std::byte, kSize>(value_or_function);
 }
 
 // Creates an array of bytes from a series of function arguments. Unlike
diff --git a/pw_bytes/public/pw_bytes/byte_builder.h b/pw_bytes/public/pw_bytes/byte_builder.h
index f046cb0..ae931b0 100644
--- a/pw_bytes/public/pw_bytes/byte_builder.h
+++ b/pw_bytes/public/pw_bytes/byte_builder.h
@@ -228,7 +228,7 @@
     return StatusWithSize(status_, size_);
   }
 
-  // True if status() is Status::Ok().
+  // True if status() is OkStatus().
   bool ok() const { return status_.ok(); }
 
   // True if the bytes builder is empty.
@@ -243,11 +243,11 @@
   // Clears the bytes and resets its error state.
   void clear() {
     size_ = 0;
-    status_ = Status::Ok();
+    status_ = OkStatus();
   };
 
-  // Sets the statuses to Status::Ok();
-  void clear_status() { status_ = Status::Ok(); }
+  // Sets the statuses to OkStatus();
+  void clear_status() { status_ = OkStatus(); }
 
   // Appends a single byte. Sets the status to RESOURCE_EXHAUSTED if the
   // byte cannot be added because the buffer is full.
@@ -350,7 +350,7 @@
 };
 
 // ByteBuffers declare a buffer along with a ByteBuilder.
-template <size_t size_bytes>
+template <size_t kSizeBytes>
 class ByteBuffer : public ByteBuilder {
  public:
   ByteBuffer() : ByteBuilder(buffer_) {}
@@ -361,28 +361,28 @@
   }
 
   // A smaller ByteBuffer may be copied or assigned into a larger one.
-  template <size_t other_size_bytes>
-  ByteBuffer(const ByteBuffer<other_size_bytes>& other)
+  template <size_t kOtherSizeBytes>
+  ByteBuffer(const ByteBuffer<kOtherSizeBytes>& other)
       : ByteBuilder(buffer_, other) {
-    static_assert(ByteBuffer<other_size_bytes>::max_size() <= max_size(),
+    static_assert(ByteBuffer<kOtherSizeBytes>::max_size() <= max_size(),
                   "A ByteBuffer cannot be copied into a smaller buffer");
     CopyContents(other);
   }
 
-  template <size_t other_size_bytes>
-  ByteBuffer& operator=(const ByteBuffer<other_size_bytes>& other) {
-    assign<other_size_bytes>(other);
+  template <size_t kOtherSizeBytes>
+  ByteBuffer& operator=(const ByteBuffer<kOtherSizeBytes>& other) {
+    assign<kOtherSizeBytes>(other);
     return *this;
   }
 
   ByteBuffer& operator=(const ByteBuffer& other) {
-    assign<size_bytes>(other);
+    assign<kSizeBytes>(other);
     return *this;
   }
 
-  template <size_t other_size_bytes>
-  ByteBuffer& assign(const ByteBuffer<other_size_bytes>& other) {
-    static_assert(ByteBuffer<other_size_bytes>::max_size() <= max_size(),
+  template <size_t kOtherSizeBytes>
+  ByteBuffer& assign(const ByteBuffer<kOtherSizeBytes>& other) {
+    static_assert(ByteBuffer<kOtherSizeBytes>::max_size() <= max_size(),
                   "A ByteBuffer cannot be copied into a smaller buffer");
     CopySizeAndStatus(other);
     CopyContents(other);
@@ -391,9 +391,9 @@
 
   // Returns the maximum length of the bytes that can be inserted in the bytes
   // buffer.
-  static constexpr size_t max_size() { return size_bytes; }
+  static constexpr size_t max_size() { return kSizeBytes; }
 
-  // Returns a ByteBuffer<size_bytes>& instead of a generic ByteBuilder& for
+  // Returns a ByteBuffer<kSizeBytes>& instead of a generic ByteBuilder& for
   // append calls.
   template <typename... Args>
   ByteBuffer& append(Args&&... args) {
@@ -407,7 +407,7 @@
     std::memcpy(buffer_.data(), other.data(), other.size());
   }
 
-  std::array<std::byte, size_bytes> buffer_;
+  std::array<std::byte, kSizeBytes> buffer_;
 };
 
 }  // namespace pw
diff --git a/pw_bytes/public/pw_bytes/endian.h b/pw_bytes/public/pw_bytes/endian.h
index 9eb1d71..08b6503 100644
--- a/pw_bytes/public/pw_bytes/endian.h
+++ b/pw_bytes/public/pw_bytes/endian.h
@@ -153,23 +153,23 @@
 // ReadInOrder from a static-extent span, with compile-time bounds checking.
 template <typename T,
           typename B,
-          size_t buffer_size,
-          typename = std::enable_if_t<buffer_size != std::dynamic_extent &&
+          size_t kBufferSize,
+          typename = std::enable_if_t<kBufferSize != std::dynamic_extent &&
                                       sizeof(B) == sizeof(std::byte)>>
-T ReadInOrder(std::endian order, std::span<B, buffer_size> buffer) {
-  static_assert(buffer_size >= sizeof(T));
+T ReadInOrder(std::endian order, std::span<B, kBufferSize> buffer) {
+  static_assert(kBufferSize >= sizeof(T));
   return ReadInOrder<T>(order, buffer.data());
 }
 
 // ReadInOrder from a std::array, with compile-time bounds checking.
-template <typename T, typename B, size_t buffer_size>
-T ReadInOrder(std::endian order, const std::array<B, buffer_size>& buffer) {
+template <typename T, typename B, size_t kBufferSize>
+T ReadInOrder(std::endian order, const std::array<B, kBufferSize>& buffer) {
   return ReadInOrder<T>(order, std::span(buffer));
 }
 
 // ReadInOrder from a C array, with compile-time bounds checking.
-template <typename T, typename B, size_t buffer_size>
-T ReadInOrder(std::endian order, const B (&buffer)[buffer_size]) {
+template <typename T, typename B, size_t kBufferSize>
+T ReadInOrder(std::endian order, const B (&buffer)[kBufferSize]) {
   return ReadInOrder<T>(order, std::span(buffer));
 }
 
diff --git a/pw_checksum/BUILD.gn b/pw_checksum/BUILD.gn
index 59f85db..d1209af 100644
--- a/pw_checksum/BUILD.gn
+++ b/pw_checksum/BUILD.gn
@@ -32,7 +32,7 @@
     "crc16_ccitt.cc",
     "crc32.cc",
   ]
-  public_deps = [ dir_pw_span ]
+  public_deps = [ dir_pw_bytes ]
 }
 
 pw_test_group("tests") {
diff --git a/pw_checksum/public/pw_checksum/crc16_ccitt.h b/pw_checksum/public/pw_checksum/crc16_ccitt.h
index 0f7785c..0a9daff 100644
--- a/pw_checksum/public/pw_checksum/crc16_ccitt.h
+++ b/pw_checksum/public/pw_checksum/crc16_ccitt.h
@@ -37,6 +37,8 @@
 
 #include <span>
 
+#include "pw_bytes/span.h"
+
 namespace pw::checksum {
 
 // Calculates the CRC-16-CCITT for all data passed to Update.
@@ -55,7 +57,7 @@
 
   static uint16_t Calculate(std::byte data,
                             uint16_t initial_value = kInitialValue) {
-    return Calculate(std::span(&data, 1), initial_value);
+    return Calculate(ConstByteSpan(&data, 1), initial_value);
   }
 
   constexpr Crc16Ccitt() : value_(kInitialValue) {}
@@ -64,7 +66,7 @@
     value_ = Calculate(data, value_);
   }
 
-  void Update(std::byte data) { Update(std::span(&data, 1)); }
+  void Update(std::byte data) { Update(ByteSpan(&data, 1)); }
 
   // Returns the value of the CRC-16-CCITT for all data passed to Update.
   uint16_t value() const { return value_; }
diff --git a/pw_chrono/BUILD b/pw_chrono/BUILD
new file mode 100644
index 0000000..ea76d5b
--- /dev/null
+++ b/pw_chrono/BUILD
@@ -0,0 +1,101 @@
+# 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.
+
+load(
+    "//pw_build:pigweed.bzl",
+    "pw_cc_library",
+    "pw_cc_test",
+)
+
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])  # Apache License 2.0
+
+# TODO(pwbug/101): Need to add support for facades/backends to Bazel.
+PW_CHRONO_SYSTEM_CLOCK_BACKEND = "//pw_chrono_stl:system_clock"
+
+pw_cc_library(
+    name = "epoch",
+    hdrs = [
+        "public/pw_chrono/epoch.h",
+    ],
+    includes = ["public"],
+)
+
+pw_cc_library(
+    name = "system_clock_facade",
+    hdrs = [
+        "public/pw_chrono/internal/system_clock_macros.h",
+        "public/pw_chrono/system_clock.h",
+    ],
+    includes = ["public"],
+    srcs = [
+        "system_clock.cc"
+    ],
+    deps = [
+        ":epoch",
+        PW_CHRONO_SYSTEM_CLOCK_BACKEND + "_headers",
+        "//pw_preprocessor",
+    ],
+)
+
+pw_cc_library(
+    name = "system_clock",
+    deps = [
+        ":system_clock_facade",
+        PW_CHRONO_SYSTEM_CLOCK_BACKEND + "_headers",
+    ],
+)
+
+pw_cc_library(
+    name = "system_clock_backend",
+    deps = [
+       PW_CHRONO_SYSTEM_CLOCK_BACKEND,
+    ],
+)
+
+pw_cc_library(
+    name = "simulated_system_clock",
+    hdrs = [
+        "public/pw_chrono/simulated_system_clock.h",
+    ],
+    deps = [
+        ":system_clock",
+        "//pw_sync:interrupt_spin_lock",
+    ],
+)
+
+pw_cc_test(
+    name = "simulated_system_clock_test",
+    srcs = [
+        "simulated_system_clock_test.cc",
+    ],
+    deps = [
+        ":simulated_system_clock",
+        "//pw_unit_test",
+    ],
+)
+
+pw_cc_test(
+    name = "system_clock_facade_test",
+    srcs = [
+        "system_clock_facade_test.cc",
+        "system_clock_facade_test_c.c",
+    ],
+    deps = [
+        ":system_clock",
+        "//pw_preprocessor",
+        "//pw_unit_test",
+    ],
+)
diff --git a/pw_chrono/BUILD.gn b/pw_chrono/BUILD.gn
new file mode 100644
index 0000000..33176db
--- /dev/null
+++ b/pw_chrono/BUILD.gn
@@ -0,0 +1,84 @@
+# 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/facade.gni")
+import("$dir_pw_chrono/backend.gni")
+import("$dir_pw_docgen/docs.gni")
+import("$dir_pw_unit_test/test.gni")
+
+config("public_include_path") {
+  include_dirs = [ "public" ]
+  visibility = [ ":*" ]
+}
+
+pw_source_set("epoch") {
+  public = [ "public/pw_chrono/epoch.h" ]
+  public_configs = [ ":public_include_path" ]
+}
+
+pw_facade("system_clock") {
+  backend = pw_chrono_SYSTEM_CLOCK_BACKEND
+  public_configs = [ ":public_include_path" ]
+  public = [
+    "public/pw_chrono/internal/system_clock_macros.h",
+    "public/pw_chrono/system_clock.h",
+  ]
+  public_deps = [
+    ":epoch",
+    "$dir_pw_preprocessor",
+  ]
+  sources = [ "system_clock.cc" ]
+}
+
+# Dependency injectable implementation of pw::chrono::SystemClock::Interface.
+pw_source_set("simulated_system_clock") {
+  public_configs = [ ":public_include_path" ]
+  public = [ "public/pw_chrono/simulated_system_clock.h" ]
+  public_deps = [
+    ":system_clock",
+    "$dir_pw_sync:interrupt_spin_lock",
+  ]
+}
+
+pw_test_group("tests") {
+  tests = [
+    ":simulated_system_clock_test",
+    ":system_clock_facade_test",
+  ]
+}
+
+pw_test("simulated_system_clock_test") {
+  enable_if = pw_chrono_SYSTEM_CLOCK_BACKEND != ""
+  sources = [ "simulated_system_clock_test.cc" ]
+  deps = [ ":simulated_system_clock" ]
+}
+
+pw_test("system_clock_facade_test") {
+  enable_if = pw_chrono_SYSTEM_CLOCK_BACKEND != ""
+  sources = [
+    "system_clock_facade_test.cc",
+    "system_clock_facade_test_c.c",
+  ]
+  deps = [
+    ":system_clock",
+    "$dir_pw_preprocessor",
+    pw_chrono_SYSTEM_CLOCK_BACKEND,
+  ]
+}
+
+pw_doc_group("docs") {
+  sources = [ "docs.rst" ]
+}
diff --git a/pw_chrono/CMakeLists.txt b/pw_chrono/CMakeLists.txt
new file mode 100644
index 0000000..d6cc918
--- /dev/null
+++ b/pw_chrono/CMakeLists.txt
@@ -0,0 +1,22 @@
+# 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.
+
+include($ENV{PW_ROOT}/pw_build/pigweed.cmake)
+
+pw_add_facade(pw_chrono.system_clock
+  SOURCES
+    system_clock.cc
+  PUBLIC_DEPS
+    pw_preprocessor
+)
diff --git a/pw_chrono/backend.gni b/pw_chrono/backend.gni
new file mode 100644
index 0000000..f5f80e8
--- /dev/null
+++ b/pw_chrono/backend.gni
@@ -0,0 +1,18 @@
+# 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.
+
+declare_args() {
+  # Backend for the pw_chrono module's system_clock.
+  pw_chrono_SYSTEM_CLOCK_BACKEND = ""
+}
diff --git a/pw_chrono/docs.rst b/pw_chrono/docs.rst
new file mode 100644
index 0000000..e62aba6
--- /dev/null
+++ b/pw_chrono/docs.rst
@@ -0,0 +1,21 @@
+.. _module-pw_chrono:
+
+---------
+pw_chrono
+---------
+Pigweed's chrono module provides facilities for applications to deal with time,
+leveraging many pieces of STL's the ``std::chrono`` library but with a focus
+on portability for constrained embedded devices and maintaining correctness.
+
+.. warning::
+  This module is under construction, not ready for use, and the documentation
+  is incomplete.
+
+SystemClock facade
+------------------
+The ``pw::chrono::SystemClock`` is meant to serve as the clock used for time
+bound operations such as thread sleeping, waiting on mutexes/semaphores, etc.
+The ``SystemClock`` always uses a signed 64 bit as the underlying type for time
+points and durations. This means users do not have to worry about clock overflow
+risk as long as rational durations and time points as used, i.e. within a range
+of ±292 years.
diff --git a/pw_chrono/public/pw_chrono/epoch.h b/pw_chrono/public/pw_chrono/epoch.h
new file mode 100644
index 0000000..5f0e860
--- /dev/null
+++ b/pw_chrono/public/pw_chrono/epoch.h
@@ -0,0 +1,40 @@
+// 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.
+#pragma once
+
+namespace pw::chrono {
+
+enum class Epoch {
+  // The epoch is unkown and possibly even undefined in case the clock is not
+  // always enabled and the epoch may reset over time.
+  kUnknown,
+
+  kTimeSinceBoot,
+
+  // Time since 00:00:00 UTC, Thursday, 1 January 1970, including leap seconds.
+  kUtcWallClock,
+
+  // Time since 00:00:00, 6 January 1980 UTC. Leap seconds are not inserted into
+  // GPS. Thus, every time a leap second is inserted into UTC, UTC falls another
+  // second behind GPS.
+  kGpsWallClock,
+
+  // Time since 00:00:00, 1 January 1958, and is offset 10 seconds ahead of UTC
+  // at that date (i.e., its epoch, 1958-01-01 00:00:00 TAI, is 1957-12-31
+  // 23:59:50 UTC). Leap seconds are not inserted into TAI. Thus, every time a
+  // leap second is inserted into UTC, UTC falls another second behind TAI.
+  kTaiWallClock,
+};
+
+}  // namespace pw::chrono
diff --git a/pw_chrono/public/pw_chrono/internal/system_clock_macros.h b/pw_chrono/public/pw_chrono/internal/system_clock_macros.h
new file mode 100644
index 0000000..623ec9b
--- /dev/null
+++ b/pw_chrono/public/pw_chrono/internal/system_clock_macros.h
@@ -0,0 +1,61 @@
+// 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.
+#pragma once
+
+#define _PW_SYSTEM_CLOCK_DURATION(num_ticks) \
+  ((pw_chrono_SystemClock_Duration){.ticks = (num_ticks)})
+
+// clang-format off
+
+// ticks_ceil = ((count * clock_period_den + time_unit_num - 1) * time_unit_den) /
+//              (clock_period_num * time_unit_num)
+#define _PW_SYSTEM_CLOCK_TIME_TO_DURATION_CEIL(                                                                                                    \
+    count, time_unit_seconds_numerator, time_unit_seconds_denominator)                                                                             \
+  _PW_SYSTEM_CLOCK_DURATION(                                                                                                                       \
+      (((int64_t)(count) * PW_CHRONO_SYSTEM_CLOCK_PERIOD_SECONDS_DENOMINATOR + time_unit_seconds_numerator - 1) * time_unit_seconds_denominator) / \
+       (PW_CHRONO_SYSTEM_CLOCK_PERIOD_SECONDS_NUMERATOR * time_unit_seconds_numerator))
+
+// ticks_floor = (count * clock_period_den * time_unit_den) /
+//               (clock_period_num * time_unit_num)
+#define _PW_SYSTEM_CLOCK_TIME_TO_DURATION_FLOOR(                                                               \
+    count, time_unit_seconds_numerator, time_unit_seconds_denominator)                                         \
+  _PW_SYSTEM_CLOCK_DURATION(                                                                                   \
+      ((int64_t)(count) * PW_CHRONO_SYSTEM_CLOCK_PERIOD_SECONDS_DENOMINATOR * time_unit_seconds_denominator) / \
+      (PW_CHRONO_SYSTEM_CLOCK_PERIOD_SECONDS_NUMERATOR * time_unit_seconds_numerator))
+
+
+#define PW_SYSTEM_CLOCK_MS_CEIL(milliseconds) \
+  _PW_SYSTEM_CLOCK_TIME_TO_DURATION_CEIL(milliseconds, 1000, 1)
+#define PW_SYSTEM_CLOCK_S_CEIL(seconds) \
+  _PW_SYSTEM_CLOCK_TIME_TO_DURATION_CEIL(seconds,         1, 1)
+#define PW_SYSTEM_CLOCK_MIN_CEIL(minutes) \
+  _PW_SYSTEM_CLOCK_TIME_TO_DURATION_CEIL(minutes,         1, 60)
+#define PW_SYSTEM_CLOCK_H_CEIL(hours) \
+  _PW_SYSTEM_CLOCK_TIME_TO_DURATION_CEIL(hours,           1, 60 * 60)
+
+#define PW_SYSTEM_CLOCK_MS_FLOOR(milliseconds) \
+  _PW_SYSTEM_CLOCK_TIME_TO_DURATION_FLOOR(milliseconds, 1000, 1)
+#define PW_SYSTEM_CLOCK_S_FLOOR(seconds) \
+  _PW_SYSTEM_CLOCK_TIME_TO_DURATION_FLOOR(seconds,         1, 1)
+#define PW_SYSTEM_CLOCK_MIN_FLOOR(minutes) \
+  _PW_SYSTEM_CLOCK_TIME_TO_DURATION_FLOOR(minutes,         1, 60)
+#define PW_SYSTEM_CLOCK_H_FLOOR(hours) \
+  _PW_SYSTEM_CLOCK_TIME_TO_DURATION_FLOOR(hours,           1, 60 * 60)
+
+// clang-format on
+
+#define PW_SYSTEM_CLOCK_MS(milliseconds) PW_SYSTEM_CLOCK_MS_CEIL(milliseconds)
+#define PW_SYSTEM_CLOCK_S(seconds) PW_SYSTEM_CLOCK_S_CEIL(seconds)
+#define PW_SYSTEM_CLOCK_MIN(minutes) PW_SYSTEM_CLOCK_MIN_CEIL(minutes)
+#define PW_SYSTEM_CLOCK_H(hours) PW_SYSTEM_CLOCK_H_CEIL(hours)
diff --git a/pw_chrono/public/pw_chrono/simulated_system_clock.h b/pw_chrono/public/pw_chrono/simulated_system_clock.h
new file mode 100644
index 0000000..8fefe47
--- /dev/null
+++ b/pw_chrono/public/pw_chrono/simulated_system_clock.h
@@ -0,0 +1,70 @@
+// 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.
+
+#pragma once
+
+#include <mutex>
+
+#include "pw_chrono/system_clock.h"
+#include "pw_sync/interrupt_spin_lock.h"
+
+namespace pw::chrono {
+
+// A simulated system clock is a concrete virtual SystemClock implementation
+// that does not "tick" on its own. Time is advanced by explicit calls to
+// AdvanceTime() or SetTime() functions. This can be used as stub for testing
+// which can be dependency injected. Be careful when using SetTime() to not
+// violate the is_monotonic requirement, in other words avoid going backwards
+// unless initializing the clock before consumers have a reference to the clock.
+//
+// Example:
+//   SimulatedSystemClock sim_system_clock;
+//
+//   SystemClock::time_point now = sim_system_clock.now();
+//   // now.time_since_epoch.duration() == std::chrono::seconds(0)
+//
+//   sim_system_clock.AdvanceTime(std::chrono::seconds(42));
+//   // now.time_since_epoch.duration() == std::chrono::seconds(42)
+//
+// This code is thread & IRQ safe.
+class SimulatedSystemClock : public VirtualSystemClock {
+ public:
+  SimulatedSystemClock(SystemClock::time_point timestamp =
+                           SystemClock::time_point(SystemClock::duration(0)))
+      : current_timestamp_(timestamp) {}
+
+  void AdvanceTime(SystemClock::duration duration) {
+    std::lock_guard lock(interrupt_spin_lock_);
+    current_timestamp_ += duration;
+  }
+
+  // WARNING: Use of this function may violate the is_monotonic clock attribute.
+  void SetTime(SystemClock::time_point timestamp) {
+    std::lock_guard lock(interrupt_spin_lock_);
+    current_timestamp_ = timestamp;
+  }
+
+  SystemClock::time_point now() override {
+    std::lock_guard lock(interrupt_spin_lock_);
+    return current_timestamp_;
+  };
+
+ private:
+  // In theory atomics could be used if 64bit atomics are supported, however
+  // performance of this test object shouldn't matter.
+  sync::InterruptSpinLock interrupt_spin_lock_;
+  SystemClock::time_point current_timestamp_;
+};
+
+}  // namespace pw::chrono
diff --git a/pw_chrono/public/pw_chrono/system_clock.h b/pw_chrono/public/pw_chrono/system_clock.h
new file mode 100644
index 0000000..3c5be0e
--- /dev/null
+++ b/pw_chrono/public/pw_chrono/system_clock.h
@@ -0,0 +1,217 @@
+// 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.
+#pragma once
+
+#include <stddef.h>
+#include <stdint.h>
+
+#include "pw_preprocessor/util.h"
+
+// The backend implements this header to provide the following SystemClock
+// parameters, for more detail on the parameters see the SystemClock usage of
+// them below:
+//   PW_CHRONO_SYSTEM_CLOCK_PERIOD_SECONDS_NUMERATOR
+//   PW_CHRONO_SYSTEM_CLOCK_PERIOD_SECONDS_DENOMINATOR
+//   constexpr pw::chrono::Epoch pw::chrono::backend::kSystemClockEpoch;
+//   constexpr bool pw::chrono::backend::kSystemClockFreeRunning;
+//   constexpr bool pw::chrono::backend::kSystemClockNmiSafe;
+#include "pw_chrono_backend/system_clock_config.h"
+
+#ifdef __cplusplus
+
+#include <chrono>
+#include <ratio>
+
+namespace pw::chrono {
+namespace backend {
+
+// The ARM AEBI does not permit the opaque 'time_point' to be passed via
+// registers, ergo the underlying fundamental type is forward declared.
+// A SystemCLock tick has the units of one SystemClock::period duration.
+// This must be thread and IRQ safe and provided by the backend.
+int64_t GetSystemClockTickCount();
+
+}  // namespace backend
+
+// The SystemClock represents an unsteady, monotonic clock.
+//
+// The epoch of this clock is unspecified and may not be related to wall time
+// (for example, it can be time since boot). The time between ticks of this
+// clock may vary due to sleep modes and potential interrupt handling.
+// SystemClock meets the requirements of C++'s TrivialClock and Pigweed's
+// PigweedClock.
+//
+// SystemClock is compatible with C++'s Clock & TrivialClock including:
+//   SystemClock::rep
+//   SystemClock::period
+//   SystemClock::duration
+//   SystemClock::time_point
+//   SystemClock::is_steady
+//   SystemClock::now()
+//
+// Example:
+//
+//   SystemClock::time_point before = SystemClock::now();
+//   TakesALongTime();
+//   SystemClock::duration time_taken = SystemClock::now() - before;
+//   bool took_way_too_long = false;
+//   if (time_taken > std::chrono::seconds(42)) {
+//     took_way_too_long = true;
+//   }
+//
+// This code is thread & IRQ safe, it may be NMI safe depending on is_nmi_safe.
+struct SystemClock {
+  using rep = int64_t;
+  // The period must be provided by the backend.
+  using period = std::ratio<PW_CHRONO_SYSTEM_CLOCK_PERIOD_SECONDS_NUMERATOR,
+                            PW_CHRONO_SYSTEM_CLOCK_PERIOD_SECONDS_DENOMINATOR>;
+  using duration = std::chrono::duration<rep, period>;
+  using time_point = std::chrono::time_point<SystemClock>;
+  // The epoch must be provided by the backend.
+  static constexpr Epoch epoch = backend::kSystemClockEpoch;
+
+  // The time points of this clock cannot decrease, however the time between
+  // ticks of this clock may slightly vary due to sleep modes. The duration
+  // during sleep may be ignored or backfilled with another clock.
+  static constexpr bool is_monotonic = true;
+  static constexpr bool is_steady = false;
+
+  // The now() function may not move forward while in a critical section or
+  // interrupt. This must be provided by the backend.
+  static constexpr bool is_free_running = backend::kSystemClockFreeRunning;
+
+  // The clock must stop while in halting debug mode.
+  static constexpr bool is_stopped_in_halting_debug_mode = true;
+
+  // The now() function can be invoked at any time.
+  static constexpr bool is_always_enabled = true;
+
+  // The now() function may work in non-masking interrupts, depending on the
+  // backend. This must be provided by the backend.
+  static constexpr bool is_nmi_safe = backend::kSystemClockNmiSafe;
+
+  // This is thread and IRQ safe. This must be provided by the backend.
+  static time_point now() noexcept {
+    return time_point(duration(backend::GetSystemClockTickCount()));
+  }
+
+  // This is purely a helper, identical to directly using std::chrono::ceil, to
+  // convert a duration type which cannot be implicitly converted where the
+  // result is rounded up.
+  template <class Rep, class Period>
+  static constexpr duration for_at_least(std::chrono::duration<Rep, Period> d) {
+    return std::chrono::ceil<duration>(d);
+  };
+};
+
+// An abstract interface representing a SystemClock.
+//
+// This interface allows decoupling code that uses time from the code that
+// creates a point in time. You can use this to your advantage by injecting
+// Clocks into interfaces rather than having implementations call
+// SystemClock::now() directly. However, this comes at a cost of a vtable per
+// implementation and more importantly passing and maintaining references to the
+// VirtualSystemCLock for all of the users.
+//
+// The VirtualSystemClock::RealClock() function returns a reference to the
+// real global SystemClock.
+//
+// Example:
+//
+//  void DoFoo(VirtualSystemClock& system_clock) {
+//    SystemClock::time_point now = clock.now();
+//    // ... Code which consumes now.
+//  }
+//
+//  // Production code:
+//  DoFoo(VirtualSystemCLock::RealClock);
+//
+//  // Test code:
+//  MockClock test_clock();
+//  DoFoo(test_clock);
+//
+// This interface is thread and IRQ safe.
+class VirtualSystemClock {
+ public:
+  // Returns a reference to the real system clock to aid instantiation.
+  static VirtualSystemClock& RealClock();
+
+  virtual ~VirtualSystemClock() = default;
+  virtual SystemClock::time_point now() = 0;
+};
+
+}  // namespace pw::chrono
+
+// The backend can opt to include an inlined implementation of the following:
+//   int64_t GetSystemClockTickCount();
+#if __has_include("pw_chrono_backend/system_clock_inline.h")
+#include "pw_chrono_backend/system_clock_inline.h"
+#endif  // __has_include("pw_chrono_backend/system_clock_inline.h")
+
+#endif  // __cplusplus
+
+PW_EXTERN_C_START
+
+// C API Users should not create pw_chrono_SystemClock_Duration's directly,
+// instead it is strongly recommended to use macros which express the duration
+// in time units, instead of non-portable ticks.
+//
+// The following macros round up just like std::chrono::ceil, this is the
+// recommended rounding to maintain the "at least" contract of timeouts and
+// deadlines (note the *_CEIL macros are the same only more explicit):
+//   PW_SYSTEM_CLOCK_MS(milliseconds)
+//   PW_SYSTEM_CLOCK_S(seconds)
+//   PW_SYSTEM_CLOCK_MIN(minutes)
+//   PW_SYSTEM_CLOCK_H(hours)
+//   PW_SYSTEM_CLOCK_MS_CEIL(milliseconds)
+//   PW_SYSTEM_CLOCK_S_CEIL(seconds)
+//   PW_SYSTEM_CLOCK_MIN_CEIL(minutes)
+//   PW_SYSTEM_CLOCK_H_CEIL(hours)
+//
+// The following macros round down like std::chrono::{floor,duration_cast},
+// these are discouraged but sometimes necessary:
+//   PW_SYSTEM_CLOCK_MS_FLOOR(milliseconds)
+//   PW_SYSTEM_CLOCK_S_FLOOR(seconds)
+//   PW_SYSTEM_CLOCK_MIN_FLOOR(minutes)
+//   PW_SYSTEM_CLOCK_H_FLOOR(hours)
+#include "pw_chrono/internal/system_clock_macros.h"
+
+typedef struct {
+  int64_t ticks;
+} pw_chrono_SystemClock_Duration;
+
+typedef struct {
+  pw_chrono_SystemClock_Duration duration_since_epoch;
+} pw_chrono_SystemClock_TimePoint;
+typedef int64_t pw_chrono_SystemClock_Nanoseconds;
+
+// Returns the current time, see SystemClock::now() for more detail.
+pw_chrono_SystemClock_TimePoint pw_chrono_SystemClock_Now(void);
+
+// Returns the change in time between the current_time - last_time.
+pw_chrono_SystemClock_Duration pw_chrono_SystemClock_TimeElapsed(
+    pw_chrono_SystemClock_TimePoint last_time,
+    pw_chrono_SystemClock_TimePoint current_time);
+
+// For lossless time unit conversion, the seconds per tick ratio that is
+// numerator/denominator should be used:
+//   PW_CHRONO_SYSTEM_CLOCK_PERIOD_SECONDS_NUMERATOR
+//   PW_CHRONO_SYSTEM_CLOCK_PERIOD_SECONDS_DENOMINATOR
+
+// Warning, this may be lossy due to the use of std::chrono::floor,
+// rounding towards zero.
+pw_chrono_SystemClock_Nanoseconds pw_chrono_SystemClock_DurationToNsFloor(
+    pw_chrono_SystemClock_Duration duration);
+
+PW_EXTERN_C_END
diff --git a/pw_chrono/simulated_system_clock_test.cc b/pw_chrono/simulated_system_clock_test.cc
new file mode 100644
index 0000000..7c35617
--- /dev/null
+++ b/pw_chrono/simulated_system_clock_test.cc
@@ -0,0 +1,55 @@
+// 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.
+
+#include "pw_chrono/simulated_system_clock.h"
+
+#include "gtest/gtest.h"
+
+using namespace std::chrono_literals;
+
+namespace pw::chrono {
+namespace {
+
+// We can't control the SystemClock's period configuration, so just in case
+// 42 hours cannot be accurately expressed in integer ticks, round the
+// duration up.
+constexpr SystemClock::duration kRoundedArbitraryDuration =
+    SystemClock::for_at_least(42h);
+
+TEST(SimulatedSystemClock, InitialTime) {
+  SimulatedSystemClock clock;
+
+  EXPECT_EQ(SystemClock::time_point(SystemClock::duration(0)), clock.now());
+}
+
+TEST(SimulatedSystemClock, SetTime) {
+  SimulatedSystemClock clock;
+
+  clock.SetTime(pw::chrono::SystemClock::time_point(kRoundedArbitraryDuration));
+  EXPECT_EQ(kRoundedArbitraryDuration, clock.now().time_since_epoch());
+}
+
+TEST(SimulatedSystemClock, AdvanceTime) {
+  SimulatedSystemClock clock;
+
+  const SystemClock::time_point before_timestamp = clock.now();
+  clock.AdvanceTime(kRoundedArbitraryDuration);
+  const SystemClock::time_point after_timestamp = clock.now();
+
+  EXPECT_EQ(kRoundedArbitraryDuration, clock.now().time_since_epoch());
+  EXPECT_EQ(kRoundedArbitraryDuration, after_timestamp - before_timestamp);
+}
+
+}  // namespace
+}  // namespace pw::chrono
diff --git a/pw_chrono/system_clock.cc b/pw_chrono/system_clock.cc
new file mode 100644
index 0000000..14a2f28
--- /dev/null
+++ b/pw_chrono/system_clock.cc
@@ -0,0 +1,53 @@
+// 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.
+
+#include "pw_chrono/system_clock.h"
+
+namespace pw::chrono {
+namespace {
+
+class RealSystemClock final : public VirtualSystemClock {
+ public:
+  SystemClock::time_point now() final { return SystemClock::now(); }
+};
+RealSystemClock real_system_clock;
+
+}  // namespace
+
+VirtualSystemClock& VirtualSystemClock::RealClock() {
+  return real_system_clock;
+}
+
+}  // namespace pw::chrono
+
+extern "C" pw_chrono_SystemClock_TimePoint pw_chrono_SystemClock_Now() {
+  return {
+      .duration_since_epoch = {
+          .ticks = pw::chrono::SystemClock::now().time_since_epoch().count()}};
+}
+
+extern "C" pw_chrono_SystemClock_Duration pw_chrono_SystemClock_TimeElapsed(
+    pw_chrono_SystemClock_TimePoint last_time,
+    pw_chrono_SystemClock_TimePoint current_time) {
+  return {.ticks = current_time.duration_since_epoch.ticks -
+                   last_time.duration_since_epoch.ticks};
+}
+
+extern "C" pw_chrono_SystemClock_Nanoseconds
+pw_chrono_SystemClock_DurationToNsFloor(
+    pw_chrono_SystemClock_Duration duration) {
+  return std::chrono::floor<std::chrono::nanoseconds>(
+             pw::chrono::SystemClock::duration(duration.ticks))
+      .count();
+}
diff --git a/pw_chrono/system_clock_facade_test.cc b/pw_chrono/system_clock_facade_test.cc
new file mode 100644
index 0000000..af17a65
--- /dev/null
+++ b/pw_chrono/system_clock_facade_test.cc
@@ -0,0 +1,114 @@
+// 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.
+
+#include <chrono>
+
+#include "gtest/gtest.h"
+#include "pw_chrono/system_clock.h"
+#include "pw_preprocessor/util.h"
+
+using namespace std::chrono_literals;
+
+namespace pw::chrono {
+namespace {
+
+extern "C" {
+
+// Functions defined in system_clock_facade_test_c.c which call the API from C.
+pw_chrono_SystemClock_TimePoint pw_chrono_SystemClock_CallNow();
+pw_chrono_SystemClock_Duration pw_chrono_SystemClock_CallTimeElapsed(
+    pw_chrono_SystemClock_TimePoint last_time,
+    pw_chrono_SystemClock_TimePoint current_time);
+
+pw_chrono_SystemClock_Nanoseconds pw_chrono_SystemClock_CallDurationToNsFloor(
+    pw_chrono_SystemClock_Duration ticks);
+
+}  // extern "C"
+
+// While testing that the clock ticks (i.e. moves forward) we want to ensure a
+// failure can be reported instead of deadlocking the test until it passes.
+// Given that there isn't really a good heuristic for this we instead make some
+// wild assumptions to bound the maximum busy loop iterations.
+// - Assume our clock is < 6Ghz
+// - Assume we can check the clock in a single cycle
+// - Wait for up to 1/10th of a second @ 6Ghz, this may be a long period on a
+//   slower (i.e. real) machine.
+constexpr uint64_t kMaxIterations = 6'000'000'000 / 10;
+
+TEST(SystemClock, Now) {
+  const SystemClock::time_point start_time = SystemClock::now();
+  // Verify the clock moves forward.
+  bool clock_moved_forward = false;
+  for (uint64_t i = 0; i < kMaxIterations; ++i) {
+    if (SystemClock::now() > start_time) {
+      clock_moved_forward = true;
+      break;
+    }
+  }
+  EXPECT_TRUE(clock_moved_forward);
+}
+
+TEST(VirtualSystemClock, Now) {
+  auto& clock = VirtualSystemClock::RealClock();
+  const SystemClock::time_point start_time = clock.now();
+  // Verify the clock moves forward.
+  bool clock_moved_forward = false;
+  for (uint64_t i = 0; i < kMaxIterations; ++i) {
+    if (clock.now() > start_time) {
+      clock_moved_forward = true;
+      break;
+    }
+  }
+  EXPECT_TRUE(clock_moved_forward);
+}
+
+TEST(SystemClock, NowInC) {
+  const pw_chrono_SystemClock_TimePoint start_time =
+      pw_chrono_SystemClock_CallNow();
+  // Verify the clock moves forward.
+  bool clock_moved_forward = false;
+  for (uint64_t i = 0; i < kMaxIterations; ++i) {
+    if (pw_chrono_SystemClock_CallNow().duration_since_epoch.ticks >
+        start_time.duration_since_epoch.ticks) {
+      clock_moved_forward = true;
+      break;
+    }
+  }
+  EXPECT_TRUE(clock_moved_forward);
+}
+
+TEST(SystemClock, TimeElapsedInC) {
+  const pw_chrono_SystemClock_TimePoint first = pw_chrono_SystemClock_CallNow();
+  const pw_chrono_SystemClock_TimePoint last = pw_chrono_SystemClock_CallNow();
+  static_assert(SystemClock::is_monotonic);
+  EXPECT_GE(0, pw_chrono_SystemClock_CallTimeElapsed(last, first).ticks);
+}
+
+TEST(SystemClock, DurationCastInC) {
+  // We can't control the SystemClock's period configuration, so just in case
+  // 42 hours cannot be accurately expressed in integer ticks, round the
+  // duration w/ floor.
+  static constexpr auto kRoundedArbitraryDuration =
+      std::chrono::floor<SystemClock::duration>(42h);
+  static constexpr pw_chrono_SystemClock_Duration kRoundedArbitraryDurationInC =
+      PW_SYSTEM_CLOCK_H_FLOOR(42);
+  EXPECT_EQ(
+      std::chrono::floor<std::chrono::nanoseconds>(kRoundedArbitraryDuration)
+          .count(),
+      pw_chrono_SystemClock_CallDurationToNsFloor(
+          kRoundedArbitraryDurationInC));
+}
+
+}  // namespace
+}  // namespace pw::chrono
diff --git a/pw_chrono/system_clock_facade_test_c.c b/pw_chrono/system_clock_facade_test_c.c
new file mode 100644
index 0000000..d8b21b6
--- /dev/null
+++ b/pw_chrono/system_clock_facade_test_c.c
@@ -0,0 +1,33 @@
+// 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.
+
+// These tests call the pw_chrono module system_clock API from C. The return
+// values are checked in the main C++ tests.
+
+#include "pw_chrono/system_clock.h"
+
+pw_chrono_SystemClock_TimePoint pw_chrono_SystemClock_CallNow(void) {
+  return pw_chrono_SystemClock_Now();
+}
+
+pw_chrono_SystemClock_Duration pw_chrono_SystemClock_CallTimeElapsed(
+    pw_chrono_SystemClock_TimePoint last_time,
+    pw_chrono_SystemClock_TimePoint current_time) {
+  return pw_chrono_SystemClock_TimeElapsed(last_time, current_time);
+}
+
+pw_chrono_SystemClock_Nanoseconds pw_chrono_SystemClock_CallDurationToNsFloor(
+    pw_chrono_SystemClock_Duration ticks) {
+  return pw_chrono_SystemClock_DurationToNsFloor(ticks);
+}
diff --git a/pw_chrono_embos/BUILD b/pw_chrono_embos/BUILD
new file mode 100644
index 0000000..a65d083
--- /dev/null
+++ b/pw_chrono_embos/BUILD
@@ -0,0 +1,52 @@
+# Copyright 2021 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.
+
+load(
+    "//pw_build:pigweed.bzl",
+    "pw_cc_library",
+)
+
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])  # Apache License 2.0
+
+pw_cc_library(
+    name = "system_clock_headers",
+    hdrs = [
+        "public/pw_chrono_embos/config.h",
+        "public/pw_chrono_embos/system_clock_config.h",
+        "public/pw_chrono_embos/system_clock_constants.h",
+        "public_overrides/pw_chrono_backend/system_clock_config.h",
+    ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+    deps = [
+        "//pw_chrono:epoch",
+    ],
+)
+
+pw_cc_library(
+    name = "system_clock",
+    srcs = [
+        "system_clock.cc",
+    ],
+    deps = [
+        ":system_clock_headers",
+        "//pw_chrono:system_clock_facade",
+        # TODO(pwbug/317): This should depend on embOS but our third parties
+        # currently do not have Bazel support.
+    ],
+)
diff --git a/pw_chrono_embos/BUILD.gn b/pw_chrono_embos/BUILD.gn
new file mode 100644
index 0000000..13a8a0c
--- /dev/null
+++ b/pw_chrono_embos/BUILD.gn
@@ -0,0 +1,73 @@
+# Copyright 2021 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/module_config.gni")
+import("$dir_pw_build/target_types.gni")
+import("$dir_pw_docgen/docs.gni")
+
+declare_args() {
+  # The build target that overrides the default configuration options for this
+  # module. This should point to a source set that provides defines through a
+  # public config (which may -include a file or add defines directly).
+  pw_chrono_embos_CONFIG = pw_build_DEFAULT_MODULE_CONFIG
+}
+
+config("public_include_path") {
+  include_dirs = [ "public" ]
+  visibility = [ ":*" ]
+}
+
+config("backend_config") {
+  include_dirs = [ "public_overrides" ]
+  visibility = [ ":*" ]
+}
+
+pw_source_set("config") {
+  public = [ "public/pw_chrono_embos/config.h" ]
+  public_configs = [ ":public_include_path" ]
+  public_deps = [
+    "$dir_pw_third_party/embos",
+    pw_chrono_embos_CONFIG,
+  ]
+}
+
+# This target provides the backend for pw::chrono::SystemClock.
+pw_source_set("system_clock") {
+  public_configs = [
+    ":public_include_path",
+    ":backend_config",
+  ]
+  public = [
+    "public/pw_chrono_embos/system_clock_config.h",
+    "public/pw_chrono_embos/system_clock_constants.h",
+    "public_overrides/pw_chrono_backend/system_clock_config.h",
+  ]
+  public_deps = [
+    ":config",
+    "$dir_pw_chrono:epoch",
+    "$dir_pw_chrono:system_clock.facade",
+    "$dir_pw_third_party/embos",
+  ]
+  sources = [ "system_clock.cc" ]
+  deps = [
+    "$dir_pw_chrono:system_clock.facade",
+    "$dir_pw_sync:interrupt_spin_lock",
+  ]
+}
+
+pw_doc_group("docs") {
+  sources = [ "docs.rst" ]
+}
diff --git a/pw_chrono_embos/docs.rst b/pw_chrono_embos/docs.rst
new file mode 100644
index 0000000..c6be81b
--- /dev/null
+++ b/pw_chrono_embos/docs.rst
@@ -0,0 +1,31 @@
+.. _module-pw_chrono_embos:
+
+---------------
+pw_chrono_embos
+---------------
+``pw_chrono_embos`` is a collection of ``pw_chrono`` backends that are
+implemented using embOS v4 for 32bit targets.
+
+.. warning::
+  This module is under construction, not ready for use, and the documentation
+  is incomplete.
+
+SystemClock backend
+-------------------
+The embOS based ``system_clock`` backend implements the
+``pw_chrono:system_clock`` facade by using ``OS_GetTime32()``. An
+InterruptSpinLock is used to manage overflows in a thread and interrupt safe
+manner to produce a signed 64 bit timestamp. Note that this does NOT use
+``OS_GetTime_us64()`` which is not always available, this could be considered
+for a future alternative backend for the SystemClock.
+
+The ``SystemClock::now()`` must be used more than once per overflow of the
+native embOS ``OS_GetTime32()`` overflow. Note that this duration may
+vary if ``OS_SUPPORT_TICKLESS`` is used.
+
+Build targets
+-------------
+The GN build for ``pw_chrono_embos`` has one target: ``system_clock``.
+The ``system_clock`` target provides the
+``pw_chrono_backend/system_clock_config.h`` and ``pw_chrono_embos/config.h``
+headers and the backend for the ``pw_chrono:system_clock``.
diff --git a/pw_chrono_embos/public/pw_chrono_embos/config.h b/pw_chrono_embos/public/pw_chrono_embos/config.h
new file mode 100644
index 0000000..ef248b5
--- /dev/null
+++ b/pw_chrono_embos/public/pw_chrono_embos/config.h
@@ -0,0 +1,42 @@
+// Copyright 2021 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.
+// Configuration macros for the tokenizer module.
+#pragma once
+
+#include <assert.h>
+
+// For the embOS backend of the SystemClock, there is no way to determine the
+// system timer's tick interval/frequency. By default most ports happen to be at
+// 1ms intervals (i.e. 1kHz), however this can be tuned for different
+// applications.
+// The resulting clock period is defined in seconds = 1 / denominator.
+// For example the default is configured to 1/1000 seconds per clock tick.
+#ifndef PW_CHRONO_EMBOS_CFG_CLOCK_PERIOD_SECONDS_DENOMINATOR
+#define PW_CHRONO_EMBOS_CFG_CLOCK_PERIOD_SECONDS_DENOMINATOR 1000
+#endif  // PW_CHRONO_EMBOS_CFG_CLOCK_PERIOD_SECONDS_DENOMINATOR
+
+static_assert(PW_CHRONO_EMBOS_CFG_CLOCK_PERIOD_SECONDS_DENOMINATOR >= 1,
+              "the denominator must be positive and cannot be fractional");
+
+// Because the SystemClock::now() implementation requires the user to invoke it
+// more than once per overflow period, the max timeout is set to ensure that
+// blocking indefinitely on a single primitive will meet this constraint with
+// margin (i.e. more than twice per overflow).
+#ifndef PW_CHRONO_EMBOS_CFG_MAX_TIMEOUT
+#define PW_CHRONO_EMBOS_CFG_MAX_TIMEOUT (0x7FFFFFFF / 3)
+#endif  // PW_CHRONO_EMBOS_CFG_MAX_TIMEOUT
+
+static_assert((PW_CHRONO_EMBOS_CFG_MAX_TIMEOUT > 0) &&
+                  (PW_CHRONO_EMBOS_CFG_MAX_TIMEOUT <= 0x7FFFFFFF),
+              "Invalid MAX timeout configuration");
diff --git a/pw_chrono_embos/public/pw_chrono_embos/system_clock_config.h b/pw_chrono_embos/public/pw_chrono_embos/system_clock_config.h
new file mode 100644
index 0000000..7b2d21a
--- /dev/null
+++ b/pw_chrono_embos/public/pw_chrono_embos/system_clock_config.h
@@ -0,0 +1,43 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "pw_chrono_embos/config.h"
+
+// embOS does not have an API to determine the tick rate/period at compile time,
+// instead require the user to specify this through the configuration. Note it
+// is configured by the user as Hz, ergo the numerator is always 1.
+#define PW_CHRONO_SYSTEM_CLOCK_PERIOD_SECONDS_NUMERATOR 1
+#define PW_CHRONO_SYSTEM_CLOCK_PERIOD_SECONDS_DENOMINATOR \
+  PW_CHRONO_EMBOS_CFG_CLOCK_PERIOD_SECONDS_DENOMINATOR
+
+#ifdef __cplusplus
+
+#include "pw_chrono/epoch.h"
+
+namespace pw::chrono::backend {
+
+// The embOS clock starts at zero during initialization, approximately the
+// time since boot.
+inline constexpr Epoch kSystemClockEpoch = pw::chrono::Epoch::kTimeSinceBoot;
+
+// The current backend implementation is not NMI safe.
+inline constexpr bool kSystemClockNmiSafe = false;
+
+// The embOS clock halts when the systick interrupt is masked.
+inline constexpr bool kSystemClockFreeRunning = false;
+
+}  // namespace pw::chrono::backend
+
+#endif  // __cplusplus
diff --git a/pw_chrono_embos/public/pw_chrono_embos/system_clock_constants.h b/pw_chrono_embos/public/pw_chrono_embos/system_clock_constants.h
new file mode 100644
index 0000000..1ec6e8f
--- /dev/null
+++ b/pw_chrono_embos/public/pw_chrono_embos/system_clock_constants.h
@@ -0,0 +1,26 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "pw_chrono/system_clock.h"
+#include "pw_chrono_embos/config.h"
+
+namespace pw::chrono::embos {
+
+// Max timeout to be used by users of the embOS's pw::chrono::SystemClock
+// backend provided by this module.
+inline constexpr SystemClock::duration kMaxTimeout =
+    SystemClock::duration(PW_CHRONO_EMBOS_CFG_MAX_TIMEOUT);
+
+}  // namespace pw::chrono::embos
diff --git a/pw_chrono_embos/public_overrides/pw_chrono_backend/system_clock_config.h b/pw_chrono_embos/public_overrides/pw_chrono_backend/system_clock_config.h
new file mode 100644
index 0000000..45e2179
--- /dev/null
+++ b/pw_chrono_embos/public_overrides/pw_chrono_backend/system_clock_config.h
@@ -0,0 +1,19 @@
+// Copyright 2021 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.
+
+// This override header includes the main tokenized logging header and defines
+// the PW_LOG macro as the tokenized logging macro.
+#pragma once
+
+#include "pw_chrono_embos/system_clock_config.h"
diff --git a/pw_chrono_embos/system_clock.cc b/pw_chrono_embos/system_clock.cc
new file mode 100644
index 0000000..a6816a9
--- /dev/null
+++ b/pw_chrono_embos/system_clock.cc
@@ -0,0 +1,62 @@
+// Copyright 2021 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.
+
+#include "pw_chrono/system_clock.h"
+
+#include <atomic>
+#include <chrono>
+#include <limits>
+#include <mutex>
+
+#include "RTOS.h"
+#include "pw_sync/interrupt_spin_lock.h"
+
+namespace pw::chrono::backend {
+namespace {
+
+sync::InterruptSpinLock system_clock_interrupt_spin_lock;
+int64_t overflow_tick_count = 0;
+uint32_t native_tick_count = 0;
+static_assert(!SystemClock::is_nmi_safe,
+              "global state is not atomic nor double buferred");
+
+static_assert(sizeof(void*) == 4, "this backend only supports 32 bit targets!");
+
+inline uint32_t GetUint32TickCount() {
+  // embOS returns a signed 32 bit value, however according to their developers
+  // the binary value continues to increment like an unsigned value, ergo we
+  // instead reinterpret the tick count as the raw underlying 32 bit unsigned
+  // tick count.
+  return static_cast<uint32_t>(OS_GetTime32());
+}
+
+// The tick count resets to 0, ergo the overflow count is the max count + 1.
+constexpr int64_t kNativeOverflowTickCount =
+    static_cast<int64_t>(std::numeric_limits<uint32_t>::max()) + 1;
+
+}  // namespace
+
+int64_t GetSystemClockTickCount() {
+  std::lock_guard lock(system_clock_interrupt_spin_lock);
+  const uint32_t new_native_tick_count = GetUint32TickCount();
+  // WARNING: This must be called more than once per overflow period!
+  if (new_native_tick_count < native_tick_count) {
+    // Native tick count overflow detected!
+    overflow_tick_count += kNativeOverflowTickCount;
+  }
+  native_tick_count = new_native_tick_count;
+  return overflow_tick_count + native_tick_count;
+}
+
+}  // namespace pw::chrono::backend
diff --git a/pw_chrono_freertos/BUILD b/pw_chrono_freertos/BUILD
new file mode 100644
index 0000000..6c65913
--- /dev/null
+++ b/pw_chrono_freertos/BUILD
@@ -0,0 +1,52 @@
+# 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.
+
+load(
+    "//pw_build:pigweed.bzl",
+    "pw_cc_library",
+)
+
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])  # Apache License 2.0
+
+pw_cc_library(
+    name = "system_clock_headers",
+    hdrs = [
+        "public/pw_chrono_freertos/config.h",
+        "public/pw_chrono_freertos/system_clock_config.h",
+        "public/pw_chrono_freertos/system_clock_constants.h",
+        "public_overrides/pw_chrono_backend/system_clock_config.h",
+    ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+    deps = [
+        "//pw_chrono:epoch",
+    ],
+)
+
+pw_cc_library(
+    name = "system_clock",
+    srcs = [
+        "system_clock.cc",
+    ],
+    deps = [
+        ":system_clock_headers",
+        "//pw_chrono:system_clock_facade",
+        # TODO: This should depend on FreeRTOS but our third parties currently
+        # do not have Bazel support.
+    ],
+)
diff --git a/pw_chrono_freertos/BUILD.gn b/pw_chrono_freertos/BUILD.gn
new file mode 100644
index 0000000..5b4c275
--- /dev/null
+++ b/pw_chrono_freertos/BUILD.gn
@@ -0,0 +1,74 @@
+# 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/module_config.gni")
+import("$dir_pw_build/target_types.gni")
+import("$dir_pw_docgen/docs.gni")
+
+declare_args() {
+  # The build target that overrides the default configuration options for this
+  # module. This should point to a source set that provides defines through a
+  # public config (which may -include a file or add defines directly).
+  pw_chrono_freertos_CONFIG = pw_build_DEFAULT_MODULE_CONFIG
+}
+
+config("public_include_path") {
+  include_dirs = [ "public" ]
+  visibility = [ ":*" ]
+}
+
+config("backend_config") {
+  include_dirs = [ "public_overrides" ]
+  visibility = [ ":*" ]
+}
+
+pw_source_set("config") {
+  public = [ "public/pw_chrono_freertos/config.h" ]
+  public_configs = [ ":public_include_path" ]
+  public_deps = [
+    "$dir_pw_third_party/freertos",
+    pw_chrono_freertos_CONFIG,
+  ]
+}
+
+# This target provides the backend for pw::chrono::SystemClock.
+pw_source_set("system_clock") {
+  public_configs = [
+    ":public_include_path",
+    ":backend_config",
+  ]
+  public = [
+    "public/pw_chrono_freertos/system_clock_config.h",
+    "public/pw_chrono_freertos/system_clock_constants.h",
+    "public_overrides/pw_chrono_backend/system_clock_config.h",
+  ]
+  public_deps = [
+    ":config",
+    "$dir_pw_chrono:epoch",
+    "$dir_pw_chrono:system_clock.facade",
+    "$dir_pw_third_party/freertos",
+  ]
+  sources = [ "system_clock.cc" ]
+  deps = [
+    "$dir_pw_chrono:system_clock.facade",
+    "$dir_pw_interrupt:context",
+    "$dir_pw_sync:interrupt_spin_lock",
+  ]
+}
+
+pw_doc_group("docs") {
+  sources = [ "docs.rst" ]
+}
diff --git a/pw_chrono_freertos/docs.rst b/pw_chrono_freertos/docs.rst
new file mode 100644
index 0000000..cb0d318
--- /dev/null
+++ b/pw_chrono_freertos/docs.rst
@@ -0,0 +1,31 @@
+.. _module-pw_chrono_freertos:
+
+------------------
+pw_chrono_freertos
+------------------
+``pw_chrono_freertos`` is a collection of ``pw_chrono`` backends that are
+implemented using FreeRTOS.
+
+.. warning::
+  This module is under construction, not ready for use, and the documentation
+  is incomplete.
+
+SystemClock backend
+-------------------
+The FreeRTOS based ``system_clock`` backend implements the
+``pw_chrono:system_clock`` facade by using ``xTaskGetTickCountFromISR()`` and
+``xTaskGetTickCount()`` based on the current context. An InterruptSpinLock is
+used to manage overflows in a thread and interrupt safe manner to produce a
+signed 64 bit timestamp.
+
+The ``SystemClock::now()`` must be used more than once per overflow of the
+native FreeRTOS ``xTaskGetTickCount*()`` overflow. Note that this duration may
+vary if ``portSUPPRESS_TICKS_AND_SLEEP()``, ``vTaskStepTick()``, and/or
+``xTaskCatchUpTicks()`` are used.
+
+Build targets
+-------------
+The GN build for ``pw_chrono_freertos`` has one target: ``system_clock``.
+The ``system_clock`` target provides the
+``pw_chrono_backend/system_clock_config.h`` and ``pw_chrono_freertos/config.h``
+headers and the backend for the ``pw_chrono:system_clock``.
diff --git a/pw_chrono_freertos/public/pw_chrono_freertos/config.h b/pw_chrono_freertos/public/pw_chrono_freertos/config.h
new file mode 100644
index 0000000..d51c3e1
--- /dev/null
+++ b/pw_chrono_freertos/public/pw_chrono_freertos/config.h
@@ -0,0 +1,31 @@
+// 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.
+// Configuration macros for the tokenizer module.
+#pragma once
+
+#include <assert.h>
+
+#include "FreeRTOS.h"
+
+// Because the SystemClock::now() implementation requires the user to invoke it
+// more than once per overflow period, the max timeout is set to ensure that
+// blocking indefinitely on a single primitive will meet this constraint with
+// margin (i.e. more than twice per overflow).
+#ifndef PW_CHRONO_FREERTOS_CFG_MAX_TIMEOUT
+#define PW_CHRONO_FREERTOS_CFG_MAX_TIMEOUT (portMAX_DELAY / 3)
+#endif  // PW_CHRONO_FREERTOS_CFG_MAX_TIMEOUT
+
+static_assert((PW_CHRONO_FREERTOS_CFG_MAX_TIMEOUT > 0) &&
+                  (PW_CHRONO_FREERTOS_CFG_MAX_TIMEOUT <= portMAX_DELAY),
+              "Invalid MAX timeout configuration");
diff --git a/pw_chrono_freertos/public/pw_chrono_freertos/system_clock_config.h b/pw_chrono_freertos/public/pw_chrono_freertos/system_clock_config.h
new file mode 100644
index 0000000..7f8d631
--- /dev/null
+++ b/pw_chrono_freertos/public/pw_chrono_freertos/system_clock_config.h
@@ -0,0 +1,40 @@
+// 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.
+#pragma once
+
+#include "FreeRTOS.h"
+
+// Use the FreeRTOS config's tick rate.
+#define PW_CHRONO_SYSTEM_CLOCK_PERIOD_SECONDS_NUMERATOR 1
+#define PW_CHRONO_SYSTEM_CLOCK_PERIOD_SECONDS_DENOMINATOR configTICK_RATE_HZ
+
+#ifdef __cplusplus
+
+#include "pw_chrono/epoch.h"
+
+namespace pw::chrono::backend {
+
+// The FreeRTOS clock starts at zero during initialization, approximately the
+// time since boot.
+constexpr inline Epoch kSystemClockEpoch = pw::chrono::Epoch::kTimeSinceBoot;
+
+// The current backend implementation is not NMI safe.
+constexpr inline bool kSystemClockNmiSafe = false;
+
+// The FreeRTOS clock halts when the systick interrupt is masked.
+constexpr inline bool kSystemClockFreeRunning = false;
+
+}  // namespace pw::chrono::backend
+
+#endif  // __cplusplus
diff --git a/pw_chrono_freertos/public/pw_chrono_freertos/system_clock_constants.h b/pw_chrono_freertos/public/pw_chrono_freertos/system_clock_constants.h
new file mode 100644
index 0000000..3035ed0
--- /dev/null
+++ b/pw_chrono_freertos/public/pw_chrono_freertos/system_clock_constants.h
@@ -0,0 +1,26 @@
+// 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.
+#pragma once
+
+#include "pw_chrono/system_clock.h"
+#include "pw_chrono_freertos/config.h"
+
+namespace pw::chrono::freertos {
+
+// Max timeout to be used by users of the FreeRTOS's pw::chrono::SystemClock
+// backend provided by this module.
+inline constexpr SystemClock::duration kMaxTimeout =
+    SystemClock::duration(PW_CHRONO_FREERTOS_CFG_MAX_TIMEOUT);
+
+}  // namespace pw::chrono::freertos
diff --git a/pw_chrono_freertos/public_overrides/pw_chrono_backend/system_clock_config.h b/pw_chrono_freertos/public_overrides/pw_chrono_backend/system_clock_config.h
new file mode 100644
index 0000000..804d76c
--- /dev/null
+++ b/pw_chrono_freertos/public_overrides/pw_chrono_backend/system_clock_config.h
@@ -0,0 +1,19 @@
+// 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.
+
+// This override header includes the main tokenized logging header and defines
+// the PW_LOG macro as the tokenized logging macro.
+#pragma once
+
+#include "pw_chrono_freertos/system_clock_config.h"
diff --git a/pw_chrono_freertos/system_clock.cc b/pw_chrono_freertos/system_clock.cc
new file mode 100644
index 0000000..b05a46c
--- /dev/null
+++ b/pw_chrono_freertos/system_clock.cc
@@ -0,0 +1,56 @@
+// 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.
+
+#include "pw_chrono/system_clock.h"
+
+#include <atomic>
+#include <chrono>
+#include <limits>
+#include <mutex>
+
+#include "FreeRTOS.h"
+#include "pw_interrupt/context.h"
+#include "pw_sync/interrupt_spin_lock.h"
+#include "task.h"
+
+namespace pw::chrono::backend {
+namespace {
+
+sync::InterruptSpinLock system_clock_interrupt_spin_lock;
+int64_t overflow_tick_count = 0;
+TickType_t native_tick_count = 0;
+static_assert(!SystemClock::is_nmi_safe,
+              "global state is not atomic nor double buferred");
+
+// The tick count resets to 0, ergo the overflow count is the max count + 1.
+constexpr int64_t kNativeOverflowTickCount =
+    static_cast<int64_t>(std::numeric_limits<TickType_t>::max()) + 1;
+
+}  // namespace
+
+int64_t GetSystemClockTickCount() {
+  std::lock_guard lock(system_clock_interrupt_spin_lock);
+  const TickType_t new_native_tick_count = interrupt::InInterruptContext()
+                                               ? xTaskGetTickCountFromISR()
+                                               : xTaskGetTickCount();
+  // WARNING: This must be called more than once per overflow period!
+  if (new_native_tick_count < native_tick_count) {
+    // Native tick count overflow detected!
+    overflow_tick_count += kNativeOverflowTickCount;
+  }
+  native_tick_count = new_native_tick_count;
+  return overflow_tick_count + native_tick_count;
+}
+
+}  // namespace pw::chrono::backend
diff --git a/pw_chrono_stl/BUILD b/pw_chrono_stl/BUILD
new file mode 100644
index 0000000..e1abacf
--- /dev/null
+++ b/pw_chrono_stl/BUILD
@@ -0,0 +1,47 @@
+# 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.
+
+load(
+    "//pw_build:pigweed.bzl",
+    "pw_cc_library",
+)
+
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])  # Apache License 2.0
+
+pw_cc_library(
+    name = "system_clock_headers",
+    hdrs = [
+        "public/pw_chrono_stl/system_clock_config.h",
+        "public/pw_chrono_stl/system_clock_inline.h",
+        "public_overrides/pw_chrono_backend/system_clock_config.h",
+        "public_overrides/pw_chrono_backend/system_clock_inline.h",
+    ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+    deps = [
+        "//pw_chrono:epoch",
+    ],
+)
+
+pw_cc_library(
+    name = "system_clock",
+    deps = [
+        ":system_clock_headers",
+        "//pw_chrono:system_clock_facade",
+    ],
+)
diff --git a/pw_chrono_stl/BUILD.gn b/pw_chrono_stl/BUILD.gn
new file mode 100644
index 0000000..948738d
--- /dev/null
+++ b/pw_chrono_stl/BUILD.gn
@@ -0,0 +1,50 @@
+# 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/target_types.gni")
+import("$dir_pw_docgen/docs.gni")
+
+config("public_include_path") {
+  include_dirs = [ "public" ]
+  visibility = [ ":*" ]
+}
+
+config("backend_config") {
+  include_dirs = [ "public_overrides" ]
+  visibility = [ ":*" ]
+}
+
+# This target provides the backend for pw::chrono::SystemClock.
+pw_source_set("system_clock") {
+  public_configs = [
+    ":public_include_path",
+    ":backend_config",
+  ]
+  public = [
+    "public/pw_chrono_stl/system_clock_config.h",
+    "public/pw_chrono_stl/system_clock_inline.h",
+    "public_overrides/pw_chrono_backend/system_clock_config.h",
+    "public_overrides/pw_chrono_backend/system_clock_inline.h",
+  ]
+  public_deps = [
+    "$dir_pw_chrono:epoch",
+    "$dir_pw_chrono:system_clock.facade",
+  ]
+}
+
+pw_doc_group("docs") {
+  sources = [ "docs.rst" ]
+}
diff --git a/pw_chrono_stl/CMakeLists.txt b/pw_chrono_stl/CMakeLists.txt
new file mode 100644
index 0000000..f0b6197
--- /dev/null
+++ b/pw_chrono_stl/CMakeLists.txt
@@ -0,0 +1,20 @@
+# 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.
+
+include($ENV{PW_ROOT}/pw_build/pigweed.cmake)
+
+pw_add_module_library(pw_chrono_stl.system_clock
+  IMPLEMENTS_FACADES
+    pw_chrono.system_clock
+)
diff --git a/pw_chrono_stl/docs.rst b/pw_chrono_stl/docs.rst
new file mode 100644
index 0000000..3ba23ba
--- /dev/null
+++ b/pw_chrono_stl/docs.rst
@@ -0,0 +1,28 @@
+.. _module-pw_chrono_stl:
+
+-------------
+pw_chrono_stl
+-------------
+``pw_chrono_stl`` is a collection of ``pw_chrono`` backends that are implemented
+using STL's ``std::chrono`` library.
+
+.. warning::
+  This module is under construction, not ready for use, and the documentation
+  is incomplete.
+
+SystemClock backend
+-------------------
+The STL based ``system_clock`` backend implements the ``pw_chrono:system_clock``
+facade by using the ``std::chrono::steady_clock``. Note that the
+``std::chrono::system_clock`` cannot be used as this is not always a monotonic
+clock source.
+
+See the documentation for ``pw_chrono`` for further details.
+
+Build targets
+-------------
+The GN build for ``pw_chrono_stl`` has one target: ``system_clock``.
+The ``system_clock`` target provides the
+``pw_chrono_backend/system_clock_config.h`` and
+``pw_chrono_backend/system_clock_inline.h`` headers and the backend for the
+``pw_chrono:system_clock``.
diff --git a/pw_chrono_stl/public/pw_chrono_stl/system_clock_config.h b/pw_chrono_stl/public/pw_chrono_stl/system_clock_config.h
new file mode 100644
index 0000000..9766fa2
--- /dev/null
+++ b/pw_chrono_stl/public/pw_chrono_stl/system_clock_config.h
@@ -0,0 +1,40 @@
+// 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.
+#pragma once
+
+// Ideally we'd use std::chrono::steady_clock::period, however this is not
+// something we can expose to the C API. Instead we assume it has nanosecond
+// compatibility and we rely on implicit conversion to tell us at compile time
+// whether this is incompatible.
+#define PW_CHRONO_SYSTEM_CLOCK_PERIOD_SECONDS_NUMERATOR 1
+#define PW_CHRONO_SYSTEM_CLOCK_PERIOD_SECONDS_DENOMINATOR 100'000'0000
+
+#ifdef __cplusplus
+
+#include "pw_chrono/epoch.h"
+
+namespace pw::chrono::backend {
+
+// The std::chrono::steady_clock does not have a defined epoch.
+constexpr inline Epoch kSystemClockEpoch = pw::chrono::Epoch::kUnknown;
+
+// The std::chrono::steady_clock can be used by signal handlers.
+constexpr inline bool kSystemClockNmiSafe = true;
+
+// The std::chrono::steady_clock ticks while in a signal handler.
+constexpr inline bool kSystemClockFreeRunning = true;
+
+}  // namespace pw::chrono::backend
+
+#endif  // __cplusplus
diff --git a/pw_chrono_stl/public/pw_chrono_stl/system_clock_inline.h b/pw_chrono_stl/public/pw_chrono_stl/system_clock_inline.h
new file mode 100644
index 0000000..9fa0d5e
--- /dev/null
+++ b/pw_chrono_stl/public/pw_chrono_stl/system_clock_inline.h
@@ -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.
+#pragma once
+
+#include <chrono>
+
+#include "pw_chrono/system_clock.h"
+
+namespace pw::chrono::backend {
+
+inline int64_t GetSystemClockTickCount() {
+  // Note that no conversion is necessary since the steady_clock's period and
+  // epoch are directly used.
+  return std::chrono::steady_clock::now().time_since_epoch().count();
+}
+
+}  // namespace pw::chrono::backend
diff --git a/pw_chrono_stl/public_overrides/pw_chrono_backend/system_clock_config.h b/pw_chrono_stl/public_overrides/pw_chrono_backend/system_clock_config.h
new file mode 100644
index 0000000..80c9d54
--- /dev/null
+++ b/pw_chrono_stl/public_overrides/pw_chrono_backend/system_clock_config.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_chrono_stl/system_clock_config.h"
diff --git a/pw_chrono_stl/public_overrides/pw_chrono_backend/system_clock_inline.h b/pw_chrono_stl/public_overrides/pw_chrono_backend/system_clock_inline.h
new file mode 100644
index 0000000..9b46fab
--- /dev/null
+++ b/pw_chrono_stl/public_overrides/pw_chrono_backend/system_clock_inline.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_chrono_stl/system_clock_inline.h"
diff --git a/pw_chrono_threadx/BUILD b/pw_chrono_threadx/BUILD
new file mode 100644
index 0000000..0920cfb
--- /dev/null
+++ b/pw_chrono_threadx/BUILD
@@ -0,0 +1,52 @@
+# 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.
+
+load(
+    "//pw_build:pigweed.bzl",
+    "pw_cc_library",
+)
+
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])  # Apache License 2.0
+
+pw_cc_library(
+    name = "system_clock_headers",
+    hdrs = [
+        "public/pw_chrono_threadx/config.h",
+        "public/pw_chrono_threadx/system_clock_config.h",
+        "public/pw_chrono_threadx/system_clock_constants.h",
+        "public_overrides/pw_chrono_backend/system_clock_config.h",
+    ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+    deps = [
+        "//pw_chrono:epoch",
+    ],
+)
+
+pw_cc_library(
+    name = "system_clock",
+    srcs = [
+        "system_clock.cc",
+    ],
+    deps = [
+        ":system_clock_headers",
+        "//pw_chrono:system_clock_facade",
+        # TODO: This should depend on ThreadX but our third parties currently
+        # do not have Bazel support.
+    ],
+)
diff --git a/pw_chrono_threadx/BUILD.gn b/pw_chrono_threadx/BUILD.gn
new file mode 100644
index 0000000..3cd0434
--- /dev/null
+++ b/pw_chrono_threadx/BUILD.gn
@@ -0,0 +1,73 @@
+# 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/module_config.gni")
+import("$dir_pw_build/target_types.gni")
+import("$dir_pw_docgen/docs.gni")
+
+declare_args() {
+  # The build target that overrides the default configuration options for this
+  # module. This should point to a source set that provides defines through a
+  # public config (which may -include a file or add defines directly).
+  pw_chrono_threadx_CONFIG = pw_build_DEFAULT_MODULE_CONFIG
+}
+
+config("public_include_path") {
+  include_dirs = [ "public" ]
+  visibility = [ ":*" ]
+}
+
+config("backend_config") {
+  include_dirs = [ "public_overrides" ]
+  visibility = [ ":*" ]
+}
+
+pw_source_set("config") {
+  public = [ "public/pw_chrono_threadx/config.h" ]
+  public_configs = [ ":public_include_path" ]
+  public_deps = [
+    "$dir_pw_third_party/threadx",
+    pw_chrono_threadx_CONFIG,
+  ]
+}
+
+# This target provides the backend for pw::chrono::SystemClock.
+pw_source_set("system_clock") {
+  public_configs = [
+    ":public_include_path",
+    ":backend_config",
+  ]
+  public = [
+    "public/pw_chrono_threadx/system_clock_config.h",
+    "public/pw_chrono_threadx/system_clock_constants.h",
+    "public_overrides/pw_chrono_backend/system_clock_config.h",
+  ]
+  public_deps = [
+    ":config",
+    "$dir_pw_chrono:epoch",
+    "$dir_pw_chrono:system_clock.facade",
+    "$dir_pw_third_party/threadx",
+  ]
+  sources = [ "system_clock.cc" ]
+  deps = [
+    "$dir_pw_chrono:system_clock.facade",
+    "$dir_pw_sync:interrupt_spin_lock",
+  ]
+}
+
+pw_doc_group("docs") {
+  sources = [ "docs.rst" ]
+}
diff --git a/pw_chrono_threadx/docs.rst b/pw_chrono_threadx/docs.rst
new file mode 100644
index 0000000..419eb00
--- /dev/null
+++ b/pw_chrono_threadx/docs.rst
@@ -0,0 +1,33 @@
+.. _module-pw_chrono_threadx:
+
+-----------------
+pw_chrono_threadx
+-----------------
+``pw_chrono_threadx`` is a collection of ``pw_chrono`` backends that are
+implemented using ThreadX.
+
+.. warning::
+  This module is under construction, not ready for use, and the documentation
+  is incomplete.
+
+SystemClock backend
+-------------------
+The ThreadX based ``system_clock`` backend implements the
+``pw_chrono:system_clock`` facade by using ``tx_time_get()``. An
+InterruptSpinLock is used to manage overflows in a thread and interrupt safe
+manner to produce a signed 64 bit timestamp.
+
+The ``SystemClock::now()`` must be used more than once per overflow of the
+native ThreadX ``tx_time_get()`` overflow. Note that this duration may vary if
+``tx_time_set()`` is used.
+
+.. warning::
+  Note that this is not compatible with TX_NO_TIMER as this disables
+  ``tx_time_get()``.
+
+Build targets
+-------------
+The GN build for ``pw_chrono_threadx`` has one target: ``system_clock``.
+The ``system_clock`` target provides the
+``pw_chrono_backend/system_clock_config.h`` and ``pw_chrono_threadx/config.h``
+headers and the backend for the ``pw_chrono:system_clock``.
diff --git a/pw_chrono_threadx/public/pw_chrono_threadx/config.h b/pw_chrono_threadx/public/pw_chrono_threadx/config.h
new file mode 100644
index 0000000..bfb2109
--- /dev/null
+++ b/pw_chrono_threadx/public/pw_chrono_threadx/config.h
@@ -0,0 +1,49 @@
+// 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.
+// Configuration macros for the tokenizer module.
+#pragma once
+
+#include <assert.h>
+
+#include "tx_api.h"
+
+// For the ThreadX backend of the SystemClock, there is no way to determine the
+// system timer's tick interval/frequency. By default most ports happen to be at
+// 10ms intervals (i.e. 100Hz), however this can be tuned for different
+// applications.
+// The resulting clock period is defined in seconds = numerator / denominator.
+// For example the default is configured to 1/100 seconds per clock tick.
+#ifndef PW_CHRONO_THREADX_CFG_CLOCK_PERIOD_SECONDS_NUMERATOR
+#define PW_CHRONO_THREADX_CFG_CLOCK_PERIOD_SECONDS_NUMERATOR 1
+#endif  // PW_CHRONO_THREADX_CFG_CLOCK_PERIOD_SECONDS_NUMERATOR
+#ifndef PW_CHRONO_THREADX_CFG_CLOCK_PERIOD_SECONDS_DENOMINATOR
+#define PW_CHRONO_THREADX_CFG_CLOCK_PERIOD_SECONDS_DENOMINATOR 100
+#endif  // PW_CHRONO_THREADX_CFG_CLOCK_PERIOD_SECONDS_DENOMINATOR
+
+static_assert(PW_CHRONO_THREADX_CFG_CLOCK_PERIOD_SECONDS_NUMERATOR >= 1,
+              "the numerator must be positive and cannot be fractional");
+static_assert(PW_CHRONO_THREADX_CFG_CLOCK_PERIOD_SECONDS_DENOMINATOR >= 1,
+              "the denominator must be positive and cannot be fractional");
+
+// Because the SystemClock::now() implementation requires the user to invoke it
+// more than once per overflow period, the max timeout is set to ensure that
+// blocking indefinitely on a single primitive will meet this constraint with
+// margin (i.e. more than twice per overflow).
+#ifndef PW_CHRONO_THREADX_CFG_MAX_TIMEOUT
+#define PW_CHRONO_THREADX_CFG_MAX_TIMEOUT ((TX_WAIT_FOREVER - 1) / 3)
+#endif  // PW_CHRONO_THREADX_CFG_MAX_TIMEOUT
+
+static_assert((PW_CHRONO_THREADX_CFG_MAX_TIMEOUT > 0) &&
+                  (PW_CHRONO_THREADX_CFG_MAX_TIMEOUT <= (TX_WAIT_FOREVER - 1)),
+              "the timeout must be greater than 0 and less than the sentinel");
diff --git a/pw_chrono_threadx/public/pw_chrono_threadx/system_clock_config.h b/pw_chrono_threadx/public/pw_chrono_threadx/system_clock_config.h
new file mode 100644
index 0000000..6ab829a
--- /dev/null
+++ b/pw_chrono_threadx/public/pw_chrono_threadx/system_clock_config.h
@@ -0,0 +1,43 @@
+// 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.
+#pragma once
+
+#include "pw_chrono_threadx/config.h"
+
+// ThreadX does not have an API to determine the tick rate/period, instead
+// require the user to specify this through the configuration.
+#define PW_CHRONO_SYSTEM_CLOCK_PERIOD_SECONDS_NUMERATOR \
+  PW_CHRONO_THREADX_CFG_CLOCK_PERIOD_SECONDS_NUMERATOR
+#define PW_CHRONO_SYSTEM_CLOCK_PERIOD_SECONDS_DENOMINATOR \
+  PW_CHRONO_THREADX_CFG_CLOCK_PERIOD_SECONDS_DENOMINATOR
+
+#ifdef __cplusplus
+
+#include "pw_chrono/epoch.h"
+
+namespace pw::chrono::backend {
+
+// The ThreadX clock starts at zero during initialization, approximately the
+// time since boot.
+constexpr inline Epoch kSystemClockEpoch = pw::chrono::Epoch::kTimeSinceBoot;
+
+// The current backend implementation is not NMI safe.
+constexpr inline bool kSystemClockNmiSafe = false;
+
+// The ThreadX clock halts when the systick interrupt is masked.
+constexpr inline bool kSystemClockFreeRunning = false;
+
+}  // namespace pw::chrono::backend
+
+#endif  // __cplusplus
diff --git a/pw_chrono_threadx/public/pw_chrono_threadx/system_clock_constants.h b/pw_chrono_threadx/public/pw_chrono_threadx/system_clock_constants.h
new file mode 100644
index 0000000..5de6fa3
--- /dev/null
+++ b/pw_chrono_threadx/public/pw_chrono_threadx/system_clock_constants.h
@@ -0,0 +1,26 @@
+// 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.
+#pragma once
+
+#include "pw_chrono/system_clock.h"
+#include "pw_chrono_threadx/config.h"
+
+namespace pw::chrono::threadx {
+
+// Max timeout to be used by users of the ThreadX's pw::chrono::SystemClock
+// backend provided by this module.
+inline constexpr SystemClock::duration kMaxTimeout =
+    SystemClock::duration(PW_CHRONO_THREADX_CFG_MAX_TIMEOUT);
+
+}  // namespace pw::chrono::threadx
diff --git a/pw_chrono_threadx/public_overrides/pw_chrono_backend/system_clock_config.h b/pw_chrono_threadx/public_overrides/pw_chrono_backend/system_clock_config.h
new file mode 100644
index 0000000..3cd5c64
--- /dev/null
+++ b/pw_chrono_threadx/public_overrides/pw_chrono_backend/system_clock_config.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_chrono_threadx/system_clock_config.h"
diff --git a/pw_chrono_threadx/system_clock.cc b/pw_chrono_threadx/system_clock.cc
new file mode 100644
index 0000000..bcb5792
--- /dev/null
+++ b/pw_chrono_threadx/system_clock.cc
@@ -0,0 +1,56 @@
+// 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.
+
+#include "pw_chrono/system_clock.h"
+
+#include <atomic>
+#include <chrono>
+#include <limits>
+#include <mutex>
+
+#include "pw_sync/interrupt_spin_lock.h"
+#include "tx_api.h"
+
+namespace pw::chrono::backend {
+namespace {
+
+#if defined(TX_NO_TIMER) && TX_NO_TIMER
+#error "This backend is not compatible with TX_NO_TIMER"
+#endif  // defined(TX_NO_TIMER) && TX_NO_TIMER
+
+sync::InterruptSpinLock system_clock_interrupt_spin_lock;
+int64_t overflow_tick_count = 0;
+ULONG native_tick_count = 0;
+static_assert(!SystemClock::is_nmi_safe,
+              "global state is not atomic nor double buferred");
+
+// The tick count resets to 0, ergo the overflow count is the max count + 1.
+constexpr int64_t kNativeOverflowTickCount =
+    static_cast<int64_t>(std::numeric_limits<ULONG>::max()) + 1;
+
+}  // namespace
+
+int64_t GetSystemClockTickCount() {
+  std::lock_guard lock(system_clock_interrupt_spin_lock);
+  const ULONG new_native_tick_count = tx_time_get();
+  // WARNING: This must be called more than once per overflow period!
+  if (new_native_tick_count < native_tick_count) {
+    // Native tick count overflow detected!
+    overflow_tick_count += kNativeOverflowTickCount;
+  }
+  native_tick_count = new_native_tick_count;
+  return overflow_tick_count + native_tick_count;
+}
+
+}  // namespace pw::chrono::backend
diff --git a/pw_cli/BUILD.gn b/pw_cli/BUILD.gn
index dd021e8..79f575b 100644
--- a/pw_cli/BUILD.gn
+++ b/pw_cli/BUILD.gn
@@ -18,4 +18,5 @@
 
 pw_doc_group("docs") {
   sources = [ "docs.rst" ]
+  other_deps = [ "py" ]
 }
diff --git a/pw_cli/docs.rst b/pw_cli/docs.rst
index 5d03f05..42144f1 100644
--- a/pw_cli/docs.rst
+++ b/pw_cli/docs.rst
@@ -265,3 +265,76 @@
 - Supporting renaming the ``pw`` command to something project specific, like
   ``foo`` in this case.
 - Re-coloring the log headers from the ``pw`` tool.
+
+pw_cli Python package
+=====================
+The ``pw_cli`` Pigweed module includes the ``pw_cli`` Python package, which
+provides utilities for creating command line tools with Pigweed.
+
+pw_cli.log
+----------
+.. automodule:: pw_cli.log
+  :members:
+
+pw_cli.plugins
+--------------
+:py:mod:`pw_cli.plugins` provides general purpose plugin functionality. The
+module can be used to create plugins for command line tools, interactive
+consoles, or anything else. Pigweed's ``pw`` command uses this module for its
+plugins.
+
+To use plugins, create a :py:class:`pw_cli.plugins.Registry`. The registry may
+have an optional validator function that checks plugins before they are
+registered (see :py:meth:`pw_cli.plugins.Registry.__init__`).
+
+Plugins may be registered in a few different ways.
+
+ * **Direct function call.** Register plugins by calling
+   :py:meth:`pw_cli.plugins.Registry.register` or
+   :py:meth:`pw_cli.plugins.Registry.register_by_name`.
+
+   .. code-block:: python
+
+     registry = pw_cli.plugins.Registry()
+
+     registry.register('plugin_name', my_plugin)
+     registry.register_by_name('plugin_name', 'module_name', 'function_name')
+
+ * **Decorator.** Register using the :py:meth:`pw_cli.plugins.Registry.plugin`
+   decorator.
+
+   .. code-block:: python
+
+     _REGISTRY = pw_cli.plugins.Registry()
+
+     # This function is registered as the "my_plugin" plugin.
+     @_REGISTRY.plugin
+     def my_plugin():
+         pass
+
+     # This function is registered as the "input" plugin.
+     @_REGISTRY.plugin(name='input')
+     def read_something():
+         pass
+
+   The decorator may be aliased to give a cleaner syntax (e.g. ``register =
+   my_registry.plugin``).
+
+ * **Plugins files.** Plugins files use a simple format:
+
+   .. code-block::
+
+     # Comments start with "#". Blank lines are ignored.
+     name_of_the_plugin module.name module_member
+
+     another_plugin some_module some_function
+
+   These files are placed in the file system and apply similarly to Git's
+   ``.gitignore`` files. From Python, these files are registered using
+   :py:meth:`pw_cli.plugins.Registry.register_file` and
+   :py:meth:`pw_cli.plugins.Registry.register_directory`.
+
+pw_cli.plugins module reference
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+.. automodule:: pw_cli.plugins
+  :members:
diff --git a/pw_cli/py/BUILD.gn b/pw_cli/py/BUILD.gn
index 46e803e..b7ed63c 100644
--- a/pw_cli/py/BUILD.gn
+++ b/pw_cli/py/BUILD.gn
@@ -26,9 +26,15 @@
     "pw_cli/color.py",
     "pw_cli/env.py",
     "pw_cli/envparse.py",
-    "pw_cli/envparse_test.py",
     "pw_cli/log.py",
     "pw_cli/plugins.py",
     "pw_cli/process.py",
+    "pw_cli/pw_command_plugins.py",
+    "pw_cli/requires.py",
   ]
+  tests = [
+    "envparse_test.py",
+    "plugins_test.py",
+  ]
+  pylintrc = "$dir_pigweed/.pylintrc"
 }
diff --git a/pw_cli/py/pw_cli/envparse_test.py b/pw_cli/py/envparse_test.py
similarity index 100%
rename from pw_cli/py/pw_cli/envparse_test.py
rename to pw_cli/py/envparse_test.py
diff --git a/pw_cli/py/plugins_test.py b/pw_cli/py/plugins_test.py
new file mode 100644
index 0000000..c206399
--- /dev/null
+++ b/pw_cli/py/plugins_test.py
@@ -0,0 +1,210 @@
+# Copyright 2021 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 for pw_cli.plugins."""
+
+from pathlib import Path
+import sys
+import tempfile
+import types
+from typing import Dict, Iterator
+import unittest
+
+from pw_cli import plugins
+
+
+def _no_docstring() -> int:
+    return 123
+
+
+def _with_docstring() -> int:
+    """This docstring is brought to you courtesy of Pigweed."""
+    return 456
+
+
+def _create_files(directory: str, files: Dict[str, str]) -> Iterator[Path]:
+    for relative_path, contents in files.items():
+        path = Path(directory) / relative_path
+        path.parent.mkdir(exist_ok=True, parents=True)
+        path.write_text(contents)
+        yield path
+
+
+class TestPlugin(unittest.TestCase):
+    """Tests for plugins.Plugins."""
+    def test_target_name_attribute(self) -> None:
+        self.assertEqual(
+            plugins.Plugin('abc', _no_docstring).target_name,
+            f'{__name__}._no_docstring')
+
+    def test_target_name_no_name_attribute(self) -> None:
+        has_no_name = 'no __name__'
+        self.assertFalse(hasattr(has_no_name, '__name__'))
+
+        self.assertEqual(
+            plugins.Plugin('abc', has_no_name).target_name,
+            '<unknown>.no __name__')
+
+
+_TEST_PLUGINS = {
+    'TEST_PLUGINS': (f'test_plugin {__name__} _with_docstring\n'
+                     f'other_plugin {__name__} _no_docstring\n'),
+    'nested/in/dirs/TEST_PLUGINS':
+    f'test_plugin {__name__} _no_docstring\n',
+}
+
+
+class TestPluginRegistry(unittest.TestCase):
+    """Tests for plugins.Registry."""
+    def setUp(self) -> None:
+        self._registry = plugins.Registry(
+            validator=plugins.callable_with_no_args)
+
+    def test_register(self) -> None:
+        self.assertIsNotNone(self._registry.register('a_plugin',
+                                                     _no_docstring))
+        self.assertIs(self._registry['a_plugin'].target, _no_docstring)
+
+    def test_register_by_name(self) -> None:
+        self.assertIsNotNone(
+            self._registry.register_by_name('plugin_one', __name__,
+                                            '_no_docstring'))
+        self.assertIsNotNone(
+            self._registry.register('plugin_two', _no_docstring))
+
+        self.assertIs(self._registry['plugin_one'].target, _no_docstring)
+        self.assertIs(self._registry['plugin_two'].target, _no_docstring)
+
+    def test_register_by_name_undefined_module(self) -> None:
+        with self.assertRaisesRegex(plugins.Error, 'No module named'):
+            self._registry.register_by_name('plugin_two', 'not a module',
+                                            'something')
+
+    def test_register_by_name_undefined_function(self) -> None:
+        with self.assertRaisesRegex(plugins.Error, 'does not exist'):
+            self._registry.register_by_name('plugin_two', __name__, 'nothing')
+
+    def test_register_fails_validation(self) -> None:
+        with self.assertRaisesRegex(plugins.Error, 'must be callable'):
+            self._registry.register('plugin_two', 'not function')
+
+    def test_run_with_argv_sets_sys_argv(self) -> None:
+        def stash_argv() -> int:
+            self.assertEqual(['pw go', '1', '2'], sys.argv)
+            return 1
+
+        self.assertIsNotNone(self._registry.register('go', stash_argv))
+
+        original_argv = sys.argv
+        self.assertEqual(self._registry.run_with_argv('go', ['1', '2']), 1)
+        self.assertIs(sys.argv, original_argv)
+
+    def test_run_with_argv_registered_plugin(self) -> None:
+        with self.assertRaises(KeyError):
+            self._registry.run_with_argv('plugin_one', [])
+
+    def test_register_cannot_overwrite(self) -> None:
+        self.assertIsNotNone(self._registry.register('foo', lambda: None))
+        self.assertIsNotNone(
+            self._registry.register_by_name('bar', __name__, '_no_docstring'))
+
+        with self.assertRaises(plugins.Error):
+            self._registry.register('foo', lambda: None)
+
+        with self.assertRaises(plugins.Error):
+            self._registry.register('bar', lambda: None)
+
+    def test_register_directory_innermost_takes_priority(self) -> None:
+        with tempfile.TemporaryDirectory() as tempdir:
+            paths = list(_create_files(tempdir, _TEST_PLUGINS))
+            self._registry.register_directory(paths[1].parent, 'TEST_PLUGINS')
+
+        self.assertEqual(self._registry.run_with_argv('test_plugin', []), 123)
+
+    def test_register_directory_only_searches_up(self) -> None:
+        with tempfile.TemporaryDirectory() as tempdir:
+            paths = list(_create_files(tempdir, _TEST_PLUGINS))
+            self._registry.register_directory(paths[0].parent, 'TEST_PLUGINS')
+
+        self.assertEqual(self._registry.run_with_argv('test_plugin', []), 456)
+
+    def test_register_directory_with_restriction(self) -> None:
+        with tempfile.TemporaryDirectory() as tempdir:
+            paths = list(_create_files(tempdir, _TEST_PLUGINS))
+            self._registry.register_directory(paths[0].parent, 'TEST_PLUGINS',
+                                              Path(tempdir, 'nested', 'in'))
+
+        self.assertNotIn('other_plugin', self._registry)
+
+    def test_register_same_file_multiple_times_no_error(self) -> None:
+        with tempfile.TemporaryDirectory() as tempdir:
+            paths = list(_create_files(tempdir, _TEST_PLUGINS))
+            self._registry.register_file(paths[0])
+            self._registry.register_file(paths[0])
+            self._registry.register_file(paths[0])
+
+        self.assertIs(self._registry['test_plugin'].target, _with_docstring)
+
+    def test_help_uses_function_or_module_docstring(self) -> None:
+        self.assertIsNotNone(self._registry.register('a', _no_docstring))
+        self.assertIsNotNone(self._registry.register('b', _with_docstring))
+
+        self.assertIn(__doc__, '\n'.join(self._registry.detailed_help(['a'])))
+
+        self.assertNotIn(__doc__,
+                         '\n'.join(self._registry.detailed_help(['b'])))
+        self.assertIn(_with_docstring.__doc__,
+                      '\n'.join(self._registry.detailed_help(['b'])))
+
+    def test_empty_string_if_no_help(self) -> None:
+        fake_module_name = f'{__name__}.fake_module_for_test{id(self)}'
+        fake_module = types.ModuleType(fake_module_name)
+        self.assertIsNone(fake_module.__doc__)
+
+        sys.modules[fake_module_name] = fake_module
+
+        try:
+
+            function = lambda: None
+            function.__module__ = fake_module_name
+            self.assertIsNotNone(self._registry.register('a', function))
+
+            self.assertEqual(self._registry['a'].help(full=False), '')
+            self.assertEqual(self._registry['a'].help(full=True), '')
+        finally:
+            del sys.modules[fake_module_name]
+
+    def test_decorator_not_called(self) -> None:
+        @self._registry.plugin
+        def nifty() -> None:
+            pass
+
+        self.assertEqual(self._registry['nifty'].target, nifty)
+
+    def test_decorator_called_no_args(self) -> None:
+        @self._registry.plugin()
+        def nifty() -> None:
+            pass
+
+        self.assertEqual(self._registry['nifty'].target, nifty)
+
+    def test_decorator_called_with_args(self) -> None:
+        @self._registry.plugin(name='nifty')
+        def my_nifty_keen_plugin() -> None:
+            pass
+
+        self.assertEqual(self._registry['nifty'].target, my_nifty_keen_plugin)
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/pw_cli/py/pw_cli/__main__.py b/pw_cli/py/pw_cli/__main__.py
index 096f341..ad6ceeb 100644
--- a/pw_cli/py/pw_cli/__main__.py
+++ b/pw_cli/py/pw_cli/__main__.py
@@ -19,7 +19,7 @@
 from typing import NoReturn
 
 import pw_cli.log
-from pw_cli import arguments, plugins
+from pw_cli import arguments, plugins, pw_command_plugins
 
 _LOG = logging.getLogger(__name__)
 
@@ -29,8 +29,7 @@
 
     args = arguments.parse_args()
 
-    pw_cli.log.install()
-    pw_cli.log.set_level(args.loglevel)
+    pw_cli.log.install(level=args.loglevel)
 
     # Start with the most critical part of the Pigweed command line tool.
     if not args.no_banner:
@@ -39,15 +38,15 @@
     _LOG.debug('Executing the pw command from %s', args.directory)
     os.chdir(args.directory)
 
-    plugins.register(args.directory)
+    pw_command_plugins.register(args.directory)
 
     if args.help or args.command is None:
-        print(arguments.format_help(), file=sys.stderr)
+        print(pw_command_plugins.format_help(), file=sys.stderr)
         sys.exit(0)
 
     try:
-        sys.exit(plugins.run(args.command, args.plugin_args))
-    except plugins.Error as err:
+        sys.exit(pw_command_plugins.run(args.command, args.plugin_args))
+    except (plugins.Error, KeyError) as err:
         _LOG.critical('Cannot run command %s.', args.command)
         _LOG.critical('%s', err)
         sys.exit(2)
diff --git a/pw_cli/py/pw_cli/arguments.py b/pw_cli/py/pw_cli/arguments.py
index 7a0f1b2..f9e2358 100644
--- a/pw_cli/py/pw_cli/arguments.py
+++ b/pw_cli/py/pw_cli/arguments.py
@@ -39,9 +39,9 @@
     print(banner(), file=sys.stderr)
 
 
-def format_help() -> str:
+def format_help(registry: plugins.Registry) -> str:
     """Returns the pw help information as a string."""
-    return f'{_parser().format_help()}\n{plugins.command_help()}'
+    return f'{_parser().format_help()}\n{registry.short_help()}'
 
 
 class _ArgumentParserWithBanner(argparse.ArgumentParser):
diff --git a/pw_cli/py/pw_cli/env.py b/pw_cli/py/pw_cli/env.py
index 021c98e..7b03e7f 100644
--- a/pw_cli/py/pw_cli/env.py
+++ b/pw_cli/py/pw_cli/env.py
@@ -43,7 +43,13 @@
 
     parser.add_allowed_suffix('_CIPD_INSTALL_DIR')
 
+    parser.add_var('PW_ENVSETUP_DISABLE_SPINNER',
+                   type=envparse.strict_bool,
+                   default=False)
     parser.add_var('PW_DOCTOR_SKIP_CIPD_CHECKS')
+    parser.add_var('PW_ACTIVATE_SKIP_CHECKS',
+                   type=envparse.strict_bool,
+                   default=False)
 
     # TODO(pwbug/274) Remove after some transition time. These are no longer
     # used but may be set by users or downstream projects, or just in currently
diff --git a/pw_cli/py/pw_cli/envparse.py b/pw_cli/py/pw_cli/envparse.py
index 91445af..9617d78 100644
--- a/pw_cli/py/pw_cli/envparse.py
+++ b/pw_cli/py/pw_cli/envparse.py
@@ -14,9 +14,9 @@
 """The envparse module defines an environment variable parser."""
 
 import argparse
+from dataclasses import dataclass
 import os
-from typing import Callable, Dict, Generic, IO, List, Mapping
-from typing import NamedTuple, Optional, TypeVar
+from typing import Callable, Dict, Generic, IO, List, Mapping, Optional, TypeVar
 
 
 class EnvNamespace(argparse.Namespace):  # pylint: disable=too-few-public-methods
@@ -27,7 +27,8 @@
 TypeConversion = Callable[[str], T]
 
 
-class VariableDescriptor(NamedTuple, Generic[T]):
+@dataclass
+class VariableDescriptor(Generic[T]):
     name: str
     type: TypeConversion[T]
     default: Optional[T]
@@ -79,7 +80,7 @@
         self,
         name: str,
         # pylint: disable=redefined-builtin
-        type: TypeConversion[T] = str,  # type: ignore
+        type: TypeConversion[T] = str,  # type: ignore[assignment]
         # pylint: enable=redefined-builtin
         default: Optional[T] = None,
     ) -> None:
@@ -98,10 +99,7 @@
             raise ValueError(
                 f'Variable {name} does not have prefix {self._prefix}')
 
-        self._variables[name] = VariableDescriptor(
-            name,
-            type,  # type: ignore
-            default)  # type: ignore
+        self._variables[name] = VariableDescriptor(name, type, default)
 
     def add_allowed_suffix(self, suffix: str) -> None:
         """Registers an environmant variable name suffix to be allowed."""
@@ -128,7 +126,7 @@
                 val = desc.default
             else:
                 try:
-                    val = desc.type(env[var])
+                    val = desc.type(env[var])  # type: ignore
                 except Exception as err:
                     raise EnvironmentValueError(var, env[var]) from err
 
diff --git a/pw_cli/py/pw_cli/log.py b/pw_cli/py/pw_cli/log.py
index 8d9d1f7..df5ebdc 100644
--- a/pw_cli/py/pw_cli/log.py
+++ b/pw_cli/py/pw_cli/log.py
@@ -11,10 +11,11 @@
 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 # License for the specific language governing permissions and limitations under
 # the License.
-"""Configure the system logger for the default pw command log format."""
+"""Tools for configuring Python logging."""
 
 import logging
-from typing import NamedTuple, Optional
+from pathlib import Path
+from typing import NamedTuple, Union, Iterator
 
 import pw_cli.color
 import pw_cli.env
@@ -24,7 +25,7 @@
 LOGLEVEL_STDOUT = 21
 
 
-class LogLevel(NamedTuple):
+class _LogLevel(NamedTuple):
     level: int
     color: str
     ascii: str
@@ -34,12 +35,12 @@
 # Shorten all the log levels to 3 characters for column-aligned logs.
 # Color the logs using ANSI codes.
 _LOG_LEVELS = (
-    LogLevel(logging.CRITICAL, 'bold_red', 'CRT', '☠️ '),
-    LogLevel(logging.ERROR,    'red',      'ERR', '❌'),
-    LogLevel(logging.WARNING,  'yellow',   'WRN', '⚠️ '),
-    LogLevel(logging.INFO,     'magenta',  'INF', 'ℹ️ '),
-    LogLevel(LOGLEVEL_STDOUT,  'cyan',     'OUT', '💬'),
-    LogLevel(logging.DEBUG,    'blue',     'DBG', '👾'),
+    _LogLevel(logging.CRITICAL, 'bold_red', 'CRT', '☠️ '),
+    _LogLevel(logging.ERROR,    'red',      'ERR', '❌'),
+    _LogLevel(logging.WARNING,  'yellow',   'WRN', '⚠️ '),
+    _LogLevel(logging.INFO,     'magenta',  'INF', 'ℹ️ '),
+    _LogLevel(LOGLEVEL_STDOUT,  'cyan',     'OUT', '💬'),
+    _LogLevel(logging.DEBUG,    'blue',     'DBG', '👾'),
 )  # yapf: disable
 
 _LOG = logging.getLogger(__name__)
@@ -47,7 +48,7 @@
 
 
 def main() -> None:
-    """Show how logs look at various levels."""
+    """Shows how logs look at various levels."""
 
     # Force the log level to make sure all logs are shown.
     _LOG.setLevel(logging.DEBUG)
@@ -61,10 +62,18 @@
     _LOG.debug('Adding 1 to i')
 
 
+def _setup_handler(handler: logging.Handler, formatter: logging.Formatter,
+                   level: int) -> None:
+    handler.setLevel(level)
+    handler.setFormatter(formatter)
+    logging.getLogger().addHandler(handler)
+
+
 def install(level: int = logging.INFO,
-            use_color: Optional[bool] = None,
-            hide_timestamp: bool = False) -> None:
-    """Configure the system logger for the default pw command log format."""
+            use_color: bool = None,
+            hide_timestamp: bool = False,
+            log_file: Union[str, Path] = None) -> None:
+    """Configures the system logger for the default pw command log format."""
 
     colors = pw_cli.color.colors(use_color)
 
@@ -79,16 +88,20 @@
         # colored text.
         timestamp_fmt = colors.black_on_white('%(asctime)s') + ' '
 
-    # Set log level on root logger to debug, otherwise any higher levels
-    # elsewhere are ignored.
-    root = logging.getLogger()
-    root.setLevel(logging.DEBUG)
+    formatter = logging.Formatter(timestamp_fmt + '%(levelname)s %(message)s',
+                                  '%Y%m%d %H:%M:%S')
 
-    _STDERR_HANDLER.setLevel(level)
-    _STDERR_HANDLER.setFormatter(
-        logging.Formatter(timestamp_fmt + '%(levelname)s %(message)s',
-                          '%Y%m%d %H:%M:%S'))
-    root.addHandler(_STDERR_HANDLER)
+    # Set the log level on the root logger to 1, so logs that all logs
+    # propagated from child loggers are handled.
+    logging.getLogger().setLevel(1)
+
+    # Always set up the stderr handler, even if it isn't used.
+    _setup_handler(_STDERR_HANDLER, formatter, level)
+
+    if log_file:
+        _setup_handler(logging.FileHandler(log_file), formatter, level)
+        # Since we're using a file, filter logs out of the stderr handler.
+        _STDERR_HANDLER.setLevel(logging.CRITICAL + 1)
 
     if env.PW_EMOJI:
         name_attr = 'emoji'
@@ -102,9 +115,19 @@
         logging.addLevelName(log_level.level, colorize(log_level)(name))
 
 
-def set_level(log_level: int):
-    """Sets the log level for logs to stderr."""
-    _STDERR_HANDLER.setLevel(log_level)
+def all_loggers() -> Iterator[logging.Logger]:
+    """Iterates over all loggers known to Python logging."""
+    manager = logging.getLogger().manager  # type: ignore[attr-defined]
+
+    for logger_name in manager.loggerDict:  # pylint: disable=no-member
+        yield logging.getLogger(logger_name)
+
+
+def set_all_loggers_minimum_level(level: int) -> None:
+    """Increases the log level to the specified value for all known loggers."""
+    for logger in all_loggers():
+        if logger.isEnabledFor(level - 1):
+            logger.setLevel(level)
 
 
 if __name__ == '__main__':
diff --git a/pw_cli/py/pw_cli/plugins.py b/pw_cli/py/pw_cli/plugins.py
index db77fcf..d264a39 100644
--- a/pw_cli/py/pw_cli/plugins.py
+++ b/pw_cli/py/pw_cli/plugins.py
@@ -1,4 +1,4 @@
-# Copyright 2020 The Pigweed Authors
+# Copyright 2021 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
@@ -11,27 +11,41 @@
 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 # License for the specific language governing permissions and limitations under
 # the License.
-"""Registry for plugins."""
+"""Provides general purpose plugin functionality.
 
-import argparse
+As used in this module, a plugin is a Python object associated with a name.
+Plugins are registered in a Registry. The plugin object is typically a function,
+but can be anything.
+
+Plugins may be loaded in a variety of ways:
+
+- Listed in a plugins file in the file system (e.g. as "name module target").
+- Registered in a Python file using a decorator (@my_registry.plugin).
+- Registered directly or by name with function calls on a registry object.
+
+This functionality can be used to create plugins for command line tools,
+interactive consoles, or anything else. Pigweed's pw command uses this module
+for its plugins.
+"""
+
 import collections
+import collections.abc
 import importlib
 import inspect
 import logging
 from pathlib import Path
+import pkgutil
 import sys
 from textwrap import TextWrapper
-from typing import Callable, Dict, List, Iterable, Optional, Union
-
-from pw_cli import arguments
+import types
+from typing import Any, Callable, Dict, List, Iterable, Iterator, Optional, Set
 
 _LOG = logging.getLogger(__name__)
-
-REGISTRY_FILE = 'PW_PLUGINS'
+_BUILT_IN = '<built-in>'
 
 
 class Error(Exception):
-    """Failed to register a Pigweed plugin."""
+    """Indicates that a plugin is invalid or cannot be registered."""
     def __str__(self):
         """Displays the error as a string, including the __cause__ if present.
 
@@ -44,141 +58,328 @@
                 f'({type(self.__cause__).__name__}: {self.__cause__})')
 
 
-class _Plugin:
-    """A plugin for the pw command."""
-    def __init__(self, name: str, module: str, function: str,
-                 source: Union[Path, str]):
-        self.name = name
-        self.source = source
+def _get_module(member: object) -> types.ModuleType:
+    """Gets the module or a dummy module if the module isn't found."""
+    module = inspect.getmodule(member)
+    return module if module else types.ModuleType('<unknown>')
 
-        # Attempt to access the module and function. Catch any errors that might
-        # occur, since a bad plugin shouldn't break the rest of the pw command.
+
+class Plugin:
+    """Represents a Python entity registered as a plugin.
+
+    Each plugin resolves to a Python object, typically a function.
+    """
+    @classmethod
+    def from_name(cls, name: str, module_name: str, member_name: str,
+                  source: Optional[Path]) -> 'Plugin':
+        """Creates a plugin by module and attribute name.
+
+        Args:
+          name: the name of the plugin
+          module_name: Python module name (e.g. 'foo_pkg.bar')
+          member_name: the name of the member in the module
+          source: path to the plugins file that declared this plugin, if any
+        """
+
+        # Attempt to access the module and member. Catch any errors that might
+        # occur, since a bad plugin shouldn't be a fatal error.
         try:
-            self._module = importlib.import_module(module)
+            module = importlib.import_module(module_name)
         except Exception as err:
-            raise Error(f'Failed to import module "{module}"') from err
+            raise Error(f'Failed to import module "{module_name}"') from err
 
         try:
-            self._function: Callable[[], int] = getattr(self._module, function)
+            member = getattr(module, member_name)
         except AttributeError as err:
             raise Error(
-                f'The function "{module}.{function}" does not exist') from err
+                f'"{module_name}.{member_name}" does not exist') from err
 
-        try:
-            params = inspect.signature(self._function).parameters
-        except TypeError:
-            raise Error(
-                'Plugin functions must be callable, but '
-                f'{module}.{function} is a {type(self._function).__name__}')
+        return cls(name, member, source)
 
-        positional_args = sum(p.default == p.empty for p in params.values())
-        if positional_args:
-            raise Error(
-                f'Plugin functions cannot have any required positional '
-                f'arguments, but {module}.{function} has {positional_args}')
+    def __init__(self, name: str, target: Any, source: Path = None) -> None:
+        """Creates a plugin for the provided target."""
+        self.name = name
+        self._module = _get_module(target)
+        self.target = target
+        self.source = source
 
-    def run(self, args: List[str]) -> int:
+    @property
+    def target_name(self) -> str:
+        return (f'{self._module.__name__}.'
+                f'{getattr(self.target, "__name__", self.target)}')
+
+    @property
+    def source_name(self) -> str:
+        return _BUILT_IN if self.source is None else str(self.source)
+
+    def run_with_argv(self, argv: Iterable[str]) -> int:
+        """Sets sys.argv and calls the plugin function.
+
+        This is used to call a plugin as if from the command line.
+        """
         original_sys_argv = sys.argv
-        sys.argv = [f'pw {self.name}', *args]
+        sys.argv = [f'pw {self.name}', *argv]
 
         try:
-            return self._function()
+            return self.target()
         finally:
             sys.argv = original_sys_argv
 
-    def help(self) -> str:
-        """Returns a brief description of this plugin."""
-        return self._function.__doc__ or self._module.__doc__ or ''
+    def help(self, full: bool = False) -> str:
+        """Returns a description of this plugin from its docstring."""
+        docstring = self.target.__doc__ or self._module.__doc__ or ''
+        return docstring if full else next(iter(docstring.splitlines()), '')
 
-    def details(self) -> List[str]:
-        return [
-            f'help      {self.help()}',
-            f'module    {self._module.__name__}',
-            f'function  {self._function.__name__}',
-            f'source    {self.source}',
-        ]
+    def details(self, full: bool = False) -> Iterator[str]:
+        yield f'help    {self.help(full=full)}'
+        yield f'module  {self._module.__name__}'
+        yield f'target  {getattr(self.target, "__name__", self.target)}'
+        yield f'source  {self.source_name}'
+
+    def __repr__(self) -> str:
+        return (f'{self.__class__.__name__}(name={self.name!r}, '
+                f'target={self.target_name}'
+                f'{f", source={self.source_name!r}" if self.source else ""})')
 
 
-# This is the global CLI plugin registry.
-_registry: Dict[str, _Plugin] = {}
-_sources: List[Path] = []  # Paths to PW_PLUGINS files
-_errors: Dict[str, List[Error]] = collections.defaultdict(list)
+def callable_with_no_args(plugin: Plugin) -> None:
+    """Checks that a plugin is callable without arguments.
 
-
-def _get(name: str) -> _Plugin:
-    if name in _registry:
-        return _registry[name]
-
-    if name in _errors:
-        raise Error(f'Registration for "{name}" failed: ' +
-                    ', '.join(str(e) for e in _errors[name]))
-
-    raise Error(f'The plugin "{name}" has not been registered')
-
-
-def run(name: str, args: List[str]) -> int:
-    """Runs a plugin by name. Raises Error if the plugin is not registered."""
-    return _get(name).run(args)
-
-
-def command_help() -> str:
-    """Returns a help string for the registered plugins."""
-    width = max(len(name) for name in _registry) + 1 if _registry else 1
-    help_items = '\n'.join(f'  {name:{width}} {plugin.help()}'
-                           for name, plugin in sorted(_registry.items()))
-    return f'supported commands:\n{help_items}'
-
-
-_BUILTIN_PLUGIN = '<built-in>'
-
-
-def _valid_registration(name: str, module: str, function: str,
-                        source: Union[Path, str]) -> bool:
-    """Determines if a plugin should be registered or not."""
-    existing = _registry.get(name)
-
-    if existing is None:
-        return True
-
-    if source == _BUILTIN_PLUGIN:
-        raise Error(
-            f'Attempted to register built-in plugin "{name}", but that '
-            f'plugin was previously registered ({existing.source})!')
-
-    if existing.source == _BUILTIN_PLUGIN:
-        _LOG.debug('%s: Overriding built-in plugin "%s" with %s.%s', source,
-                   name, module, function)
-        return True
-
-    if source == _registry[name].source:
-        _LOG.warning(
-            '%s: "%s" is registered multiple times in this file! '
-            'Only the first registration takes effect', source, name)
-    else:
-        _LOG.debug(
-            '%s: The plugin "%s" was previously registered in %s; '
-            'ignoring registration as %s.%s', source, name,
-            _registry[name].source, module, function)
-
-    return False
-
-
-def _register(name: str,
-              module: str,
-              function: str,
-              source: Union[Path, str] = _BUILTIN_PLUGIN) -> None:
-    """Registers a plugin from the specified source."""
-
-    if not _valid_registration(name, module, function, source):
-        return
-
+    May be used for the validator argument to Registry.
+    """
     try:
-        _registry[name] = _Plugin(name, module, function, source)
-        _LOG.debug('%s: Registered plugin "%s" for %s.%s', source, name,
-                   module, function)
-    except Error as err:
-        _errors[name].append(err)
-        _LOG.error('%s: Failed to register plugin "%s": %s', source, name, err)
+        params = inspect.signature(plugin.target).parameters
+    except TypeError:
+        raise Error('Plugin functions must be callable, but '
+                    f'{plugin.target_name} is a '
+                    f'{type(plugin.target).__name__}')
+
+    positional = sum(p.default == p.empty for p in params.values())
+    if positional:
+        raise Error(f'Plugin functions cannot have any required positional '
+                    f'arguments, but {plugin.target_name} has {positional}')
+
+
+class Registry(collections.abc.Mapping):
+    """Manages a set of plugins from Python modules or plugins files."""
+    def __init__(self,
+                 validator: Callable[[Plugin], Any] = lambda _: None) -> None:
+        """Creates a new, empty plugins registry.
+
+        Args:
+          validator: Function that checks whether a plugin is valid and should
+              be registered. Must raise plugins.Error is the plugin is invalid.
+        """
+
+        self._registry: Dict[str, Plugin] = {}
+        self._sources: Set[Path] = set()  # Paths to plugins files
+        self._errors: Dict[str,
+                           List[Exception]] = collections.defaultdict(list)
+        self._validate_plugin = validator
+
+    def __getitem__(self, name: str) -> Plugin:
+        """Accesses a plugin by name; raises KeyError if it does not exist."""
+        if name in self._registry:
+            return self._registry[name]
+
+        if name in self._errors:
+            raise KeyError(f'Registration for "{name}" failed: ' +
+                           ', '.join(str(e) for e in self._errors[name]))
+
+        raise KeyError(f'The plugin "{name}" has not been registered')
+
+    def __iter__(self) -> Iterator[str]:
+        return iter(self._registry)
+
+    def __len__(self) -> int:
+        return len(self._registry)
+
+    def errors(self) -> Dict[str, List[Exception]]:
+        return self._errors
+
+    def run_with_argv(self, name: str, argv: Iterable[str]) -> int:
+        """Runs a plugin by name, setting sys.argv to the provided args.
+
+        This is used to run a command as if it were executed directly from the
+        command line. The plugin is expected to return an int.
+
+        Raises:
+          KeyError if plugin is not registered.
+        """
+        return self[name].run_with_argv(argv)
+
+    def _should_register(self, plugin: Plugin) -> bool:
+        """Determines and logs if a plugin should be registered or not.
+
+        Some errors are exceptions, others are not.
+        """
+
+        if plugin.name in self._registry and plugin.source is None:
+            raise Error(
+                f'Attempted to register built-in plugin "{plugin.name}", but '
+                'a plugin with that name was previously registered '
+                f'({self[plugin.name]})!')
+
+        # Run the user-provided validation function, which raises exceptions
+        # if there are errors.
+        self._validate_plugin(plugin)
+
+        existing = self._registry.get(plugin.name)
+
+        if existing is None:
+            return True
+
+        if existing.source is None:
+            _LOG.debug('%s: Overriding built-in plugin "%s" with %s',
+                       plugin.source_name, plugin.name, plugin.target_name)
+            return True
+
+        if plugin.source != existing.source:
+            _LOG.debug(
+                '%s: The plugin "%s" was previously registered in %s; '
+                'ignoring registration as %s', plugin.source_name, plugin.name,
+                self._registry[plugin.name].source, plugin.target_name)
+        elif plugin.source not in self._sources:
+            _LOG.warning(
+                '%s: "%s" is registered file multiple times in this file! '
+                'Only the first registration takes effect', plugin.source_name,
+                plugin.name)
+
+        return False
+
+    def register(self, name: str, target: Any) -> Optional[Plugin]:
+        """Registers an object as a plugin."""
+        return self._register(Plugin(name, target, None))
+
+    def register_by_name(self,
+                         name: str,
+                         module_name: str,
+                         member_name: str,
+                         source: Path = None) -> Optional[Plugin]:
+        """Registers an object from its module and name as a plugin."""
+        return self._register(
+            Plugin.from_name(name, module_name, member_name, source))
+
+    def _register(self, plugin: Plugin) -> Optional[Plugin]:
+        # Prohibit functions not from a plugins file from overriding others.
+        if not self._should_register(plugin):
+            return None
+
+        self._registry[plugin.name] = plugin
+        _LOG.debug('%s: Registered plugin "%s" for %s', plugin.source_name,
+                   plugin.name, plugin.target_name)
+
+        return plugin
+
+    def register_file(self, path: Path) -> None:
+        """Registers plugins from a plugins file.
+
+        Any exceptions raised from parsing the file are caught and logged.
+        """
+        with path.open() as contents:
+            for lineno, line in enumerate(contents, 1):
+                line = line.strip()
+                if not line or line.startswith('#'):
+                    continue
+
+                try:
+                    name, module, function = line.split()
+                except ValueError as err:
+                    self._errors[line.strip()].append(Error(err))
+                    _LOG.error(
+                        '%s:%d: Failed to parse plugin entry "%s": '
+                        'Expected 3 items (name, module, function), '
+                        'got %d', path, lineno, line, len(line.split()))
+                    continue
+
+                try:
+                    self.register_by_name(name, module, function, path)
+                except Error as err:
+                    self._errors[name].append(err)
+                    _LOG.error('%s: Failed to register plugin "%s": %s', path,
+                               name, err)
+
+        self._sources.add(path)
+
+    def register_directory(self,
+                           directory: Path,
+                           file_name: str,
+                           restrict_to: Path = None) -> None:
+        """Finds and registers plugins from plugins files in a directory.
+
+        Args:
+          directory: The directory from which to start searching up.
+          file_name: The name of plugins files to look for.
+          restrict_to: If provided, do not search higher than this directory.
+        """
+        for path in find_all_in_parents(file_name, directory):
+            if not path.is_file():
+                continue
+
+            if restrict_to is not None and restrict_to not in path.parents:
+                _LOG.debug(
+                    "Skipping plugins file %s because it's outside of %s",
+                    path, restrict_to)
+                continue
+
+            _LOG.debug('Found plugins file %s', path)
+            self.register_file(path)
+
+    def short_help(self) -> str:
+        """Returns a help string for the registered plugins."""
+        width = max(len(name)
+                    for name in self._registry) + 1 if self._registry else 1
+        help_items = '\n'.join(
+            f'  {name:{width}} {plugin.help()}'
+            for name, plugin in sorted(self._registry.items()))
+        return f'supported plugins:\n{help_items}'
+
+    def detailed_help(self, plugins: Iterable[str] = ()) -> Iterator[str]:
+        """Yields lines of detailed information about commands."""
+        if not plugins:
+            plugins = list(self._registry)
+
+        yield '\ndetailed plugin information:'
+
+        wrapper = TextWrapper(width=80,
+                              initial_indent='   ',
+                              subsequent_indent=' ' * 11)
+
+        plugins = sorted(plugins)
+        for plugin in plugins:
+            yield f'  [{plugin}]'
+
+            try:
+                for line in self[plugin].details(full=len(plugins) == 1):
+                    yield wrapper.fill(line)
+            except KeyError as err:
+                yield wrapper.fill(f'error   {str(err)[1:-1]}')
+
+            yield ''
+
+        yield 'Plugins files:'
+
+        if self._sources:
+            yield from (f'  [{i}] {file}'
+                        for i, file in enumerate(self._sources, 1))
+        else:
+            yield '  (none found)'
+
+    def plugin(self,
+               function: Callable = None,
+               *,
+               name: str = None) -> Callable[[Callable], Callable]:
+        """Decorator that registers a function with this plugin registry."""
+        def decorator(function: Callable) -> Callable:
+            self.register(function.__name__ if name is None else name,
+                          function)
+            return function
+
+        if function is None:
+            return decorator
+
+        self.register(function.__name__, function)
+        return function
 
 
 def find_in_parents(name: str, path: Path) -> Optional[Path]:
@@ -194,7 +395,7 @@
     return path.joinpath(name)
 
 
-def find_all_in_parents(name: str, path: Path) -> Iterable[Path]:
+def find_all_in_parents(name: str, path: Path) -> Iterator[Path]:
     """Searches all parent directories of the path for files or directories."""
 
     while True:
@@ -206,83 +407,18 @@
         path = result.parent.parent
 
 
-def _register_builtin_plugins():
-    """Registers the commands that are included with pw by default."""
+def import_submodules(module: types.ModuleType,
+                      recursive: bool = False) -> None:
+    """Imports the submodules of a package.
 
-    _register('doctor', 'pw_doctor.doctor', 'main')
-    _register('format', 'pw_presubmit.format_code', 'main')
-    _register('help', 'pw_cli.plugins', '_help_command')
-    _register('logdemo', 'pw_cli.log', 'main')
-    _register('module-check', 'pw_module.check', 'main')
-    _register('test', 'pw_unit_test.test_runner', 'main')
-    _register('watch', 'pw_watch.watch', 'main')
-
-
-def register(directory: Path):
-    """Finds and registers command line plugins."""
-    _register_builtin_plugins()
-
-    # Find pw plugins files starting in the current and parent directories.
-    for path in find_all_in_parents(REGISTRY_FILE, directory):
-        if not path.is_file():
-            continue
-
-        _LOG.debug('Found plugins file %s', path)
-        _sources.append(path)
-
-        with path.open() as contents:
-            for lineno, line in enumerate(contents, 1):
-                line = line.strip()
-                if line and not line.startswith('#'):
-                    try:
-                        name, module, function = line.split()
-                        _register(name, module, function, path)
-                    except ValueError:
-                        _LOG.error(
-                            '%s:%d: Failed to parse plugin entry "%s": '
-                            'Expected 3 items (name, module, function), got %d',
-                            path, lineno, line, len(line.split()))
-
-
-def _help_text(plugins: Iterable[str] = ()) -> Iterable[str]:
-    """Yields detailed information about commands."""
-    yield arguments.format_help()
-
-    if not plugins:
-        plugins = list(_registry)
-
-    yield '\ndetailed command information:'
-
-    wrapper = TextWrapper(width=80,
-                          initial_indent='   ',
-                          subsequent_indent=' ' * 13)
-
-    for plugin in sorted(plugins):
-        yield f'  [{plugin}]'
-
-        try:
-            for line in _get(plugin).details():
-                yield wrapper.fill(line)
-        except Error as err:
-            yield wrapper.fill(f'error     {err}')
-
-        yield ''
-
-    yield 'PW_PLUGINS files:'
-
-    if _sources:
-        yield from (f'  [{i}] {file}' for i, file in enumerate(_sources, 1))
+    This can be used to collect plugins registered with a decorator from a
+    directory.
+    """
+    path = module.__path__  # type: ignore[attr-defined]
+    if recursive:
+        modules = pkgutil.walk_packages(path, module.__name__ + '.')
     else:
-        yield '  (none found)'
+        modules = pkgutil.iter_modules(path, module.__name__ + '.')
 
-
-def _help_command():
-    """Display detailed information about pw commands."""
-    parser = argparse.ArgumentParser(description=_help_command.__doc__)
-    parser.add_argument('plugins',
-                        metavar='plugin',
-                        nargs='*',
-                        help='command for which to display detailed info')
-
-    for line in _help_text(**vars(parser.parse_args())):
-        print(line, file=sys.stderr)
+    for info in modules:
+        importlib.import_module(info.name)
diff --git a/pw_cli/py/pw_cli/pw_command_plugins.py b/pw_cli/py/pw_cli/pw_command_plugins.py
new file mode 100644
index 0000000..a5c20df
--- /dev/null
+++ b/pw_cli/py/pw_cli/pw_command_plugins.py
@@ -0,0 +1,71 @@
+# Copyright 2021 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.
+"""This module manages the global plugin registry for pw_cli."""
+
+import argparse
+import os
+from pathlib import Path
+import sys
+from typing import Iterable
+
+from pw_cli import arguments, plugins
+
+_plugin_registry = plugins.Registry(validator=plugins.callable_with_no_args)
+REGISTRY_FILE = 'PW_PLUGINS'
+
+
+def _register_builtin_plugins(registry: plugins.Registry) -> None:
+    """Registers the commands that are included with pw by default."""
+
+    # Register these by name to avoid circular dependencies.
+    registry.register_by_name('doctor', 'pw_doctor.doctor', 'main')
+    registry.register_by_name('format', 'pw_presubmit.format_code', 'main')
+    registry.register_by_name('logdemo', 'pw_cli.log', 'main')
+    registry.register_by_name('module-check', 'pw_module.check', 'main')
+    registry.register_by_name('test', 'pw_unit_test.test_runner', 'main')
+    registry.register_by_name('watch', 'pw_watch.watch', 'main')
+
+    registry.register('help', _help_command)
+
+
+def _help_command():
+    """Display detailed information about pw commands."""
+    parser = argparse.ArgumentParser(description=_help_command.__doc__)
+    parser.add_argument('plugins',
+                        metavar='plugin',
+                        nargs='*',
+                        help='command for which to display detailed info')
+
+    print(arguments.format_help(_plugin_registry), file=sys.stderr)
+
+    for line in _plugin_registry.detailed_help(**vars(parser.parse_args())):
+        print(line, file=sys.stderr)
+
+
+def register(directory: Path) -> None:
+    _register_builtin_plugins(_plugin_registry)
+    _plugin_registry.register_directory(
+        directory, REGISTRY_FILE, Path(os.environ.get('PW_PROJECT_ROOT', '')))
+
+
+def errors() -> dict:
+    return _plugin_registry.errors()
+
+
+def format_help() -> str:
+    return arguments.format_help(_plugin_registry)
+
+
+def run(name: str, args: Iterable[str]) -> int:
+    return _plugin_registry.run_with_argv(name, args)
diff --git a/pw_cli/py/pw_cli/requires.py b/pw_cli/py/pw_cli/requires.py
new file mode 100755
index 0000000..5ff121f
--- /dev/null
+++ b/pw_cli/py/pw_cli/requires.py
@@ -0,0 +1,193 @@
+#!/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.
+"""Create transitive CLs for requirements on internal Gerrits.
+
+This is only intended to be used by Googlers.
+
+If the current CL needs to be tested alongside internal-project:1234 on an
+internal project, but "internal-project" is something that can't be referenced
+publicly, this automates creation of a CL on the pigweed-internal Gerrit that
+references internal-project:1234 so the current commit effectively has a
+requirement on internal-project:1234.
+
+For more see http://go/pigweed-ci-cq-intro.
+"""
+
+import argparse
+import logging
+from pathlib import Path
+import re
+import subprocess
+import sys
+import tempfile
+import uuid
+
+HELPER_GERRIT = 'pigweed-internal'
+HELPER_PROJECT = 'requires-helper'
+HELPER_REPO = 'sso://{}/{}'.format(HELPER_GERRIT, HELPER_PROJECT)
+
+# Subset of the output from pushing to Gerrit.
+DEFAULT_OUTPUT = f'''
+remote:
+remote:   https://{HELPER_GERRIT}-review.git.corp.google.com/c/{HELPER_PROJECT}/+/123456789 DO NOT SUBMIT [NEW]
+remote:
+'''.strip()
+
+_LOG = logging.getLogger(__name__)
+
+
+def parse_args() -> argparse.Namespace:
+    """Creates an argument parser and parses arguments."""
+
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument(
+        'requirements',
+        nargs='+',
+        help='Requirements to be added ("<gerrit-name>:<cl-number>").',
+    )
+    parser.add_argument(
+        '--no-push',
+        dest='push',
+        action='store_false',
+        help=argparse.SUPPRESS,  # This option is only for debugging.
+    )
+
+    return parser.parse_args()
+
+
+def _run_command(*args, **kwargs):
+    kwargs.setdefault('capture_output', True)
+    _LOG.debug('%s', args)
+    _LOG.debug('%s', kwargs)
+    res = subprocess.run(*args, **kwargs)
+    _LOG.debug('%s', res.stdout)
+    _LOG.debug('%s', res.stderr)
+    res.check_returncode()
+    return res
+
+
+def check_status() -> bool:
+    res = subprocess.run(['git', 'status'], capture_output=True)
+    if res.returncode:
+        _LOG.error('repository not clean, commit to suppress this warning')
+        return False
+    return True
+
+
+def clone(requires_dir: Path) -> None:
+    _LOG.info('cloning helper repository into %s', requires_dir)
+    _run_command(['git', 'clone', HELPER_REPO, '.'], cwd=requires_dir)
+
+
+def create_commit(requires_dir: Path, requirements) -> None:
+    change_id = str(uuid.uuid4()).replace('-', '00')
+    _LOG.debug('change_id %s', change_id)
+    path = requires_dir / change_id
+    _LOG.debug('path %s', path)
+    with open(path, 'w'):
+        pass
+
+    _run_command(['git', 'add', path], cwd=requires_dir)
+
+    commit_message = [
+        f'DO NOT SUBMIT {change_id[0:10]}',
+        '',
+        f'Change-Id: I{change_id}',
+    ]
+    for req in requirements:
+        commit_message.append(f'Requires: {req}')
+
+    _LOG.debug('message %s', commit_message)
+    _run_command(
+        ['git', 'commit', '-m', '\n'.join(commit_message)],
+        cwd=requires_dir,
+    )
+
+    # Not strictly necessary, only used for logging.
+    _run_command(['git', 'show'], cwd=requires_dir)
+
+
+def push_commit(requires_dir: Path, push=True) -> str:
+    output = DEFAULT_OUTPUT
+    if push:
+        res = _run_command(
+            ['git', 'push', HELPER_REPO, '+HEAD:refs/for/master'],
+            cwd=requires_dir,
+        )
+        output = res.stderr.decode()
+
+    _LOG.debug('output: %s', output)
+    regex = re.compile(
+        f'^\\s*remote:\\s*'
+        f'https://{HELPER_GERRIT}-review.(?:git.corp.google|googlesource).com/'
+        f'c/{HELPER_PROJECT}/\\+/(?P<num>\\d+)\\s+',
+        re.MULTILINE,
+    )
+    _LOG.debug('regex %r', regex)
+    match = regex.search(output)
+    if not match:
+        raise ValueError(f"invalid output from 'git push': {output}")
+    change_num = match.group('num')
+    _LOG.info('created %s change %s', HELPER_PROJECT, change_num)
+    return f'{HELPER_GERRIT}:{change_num}'
+
+
+def amend_existing_change(change: str) -> None:
+    res = _run_command(['git', 'log', '-1', '--pretty=%B'])
+    original = res.stdout.rstrip().decode()
+
+    addition = f'Requires: {change}'
+    _LOG.info('adding "%s" to current commit message', addition)
+    message = '\n'.join((original, addition))
+    _run_command(['git', 'commit', '--amend', '--message', message])
+
+
+def run(requirements, push=True) -> int:
+    """Entry point for requires."""
+
+    if not check_status():
+        return -1
+
+    # Create directory for checking out helper repository.
+    with tempfile.TemporaryDirectory() as requires_dir_str:
+        requires_dir = Path(requires_dir_str)
+        # Clone into helper repository.
+        clone(requires_dir)
+        # Make commit with requirements from command line.
+        create_commit(requires_dir, requirements)
+        # Push that commit and save its number.
+        change = push_commit(requires_dir, push=push)
+    # Add dependency on newly pushed commit on current commit.
+    amend_existing_change(change)
+
+    return 0
+
+
+def main() -> int:
+    return run(**vars(parse_args()))
+
+
+if __name__ == '__main__':
+    try:
+        # If pw_cli is available, use it to initialize logs.
+        from pw_cli import log
+
+        log.install(logging.INFO)
+    except ImportError:
+        # If pw_cli isn't available, display log messages like a simple print.
+        logging.basicConfig(format='%(message)s', level=logging.INFO)
+
+    sys.exit(main())
diff --git a/pw_containers/BUILD b/pw_containers/BUILD
index 0e2af8c..99926f4 100644
--- a/pw_containers/BUILD
+++ b/pw_containers/BUILD
@@ -25,6 +25,7 @@
 pw_cc_library(
     name = "pw_containers",
     deps = [
+        ":flat_map",
         ":vector",
         ":intrusive_list",
     ],
@@ -51,6 +52,25 @@
     includes = ["public"],
 )
 
+pw_cc_library(
+    name = "flat_map",
+    hdrs = [
+        "public/pw_containers/flat_map.h",
+    ],
+    includes = ["public"],
+)
+
+pw_cc_test(
+    name = "flat_map_test",
+    srcs = [
+        "flat_map_test.cc",
+    ],
+    deps = [
+        ":pw_containers",
+        "//pw_unit_test",
+    ],
+)
+
 pw_cc_test(
     name = "vector_test",
     srcs = [
diff --git a/pw_containers/BUILD.gn b/pw_containers/BUILD.gn
index a7d79b7..d59ba21 100644
--- a/pw_containers/BUILD.gn
+++ b/pw_containers/BUILD.gn
@@ -24,13 +24,20 @@
 
 group("pw_containers") {
   public_deps = [
+    ":flat_map",
     ":intrusive_list",
     ":vector",
   ]
 }
 
+pw_source_set("flat_map") {
+  public_configs = [ ":default_config" ]
+  public = [ "public/pw_containers/flat_map.h" ]
+}
+
 pw_source_set("vector") {
   public_configs = [ ":default_config" ]
+  public_deps = [ dir_pw_assert ]
   public = [ "public/pw_containers/vector.h" ]
 }
 
@@ -40,28 +47,34 @@
     "public/pw_containers/internal/intrusive_list_impl.h",
     "public/pw_containers/intrusive_list.h",
   ]
-  deps = [ dir_pw_assert ]
   sources = [ "intrusive_list.cc" ]
+  deps = [ dir_pw_assert ]
 }
 
 pw_test_group("tests") {
   tests = [
-    ":vector_test",
+    ":flat_map_test",
     ":intrusive_list_test",
+    ":vector_test",
   ]
 }
 
+pw_test("flat_map_test") {
+  sources = [ "flat_map_test.cc" ]
+  deps = [ ":flat_map" ]
+}
+
 pw_test("vector_test") {
-  deps = [ ":vector" ]
   sources = [ "vector_test.cc" ]
+  deps = [ ":vector" ]
 }
 
 pw_test("intrusive_list_test") {
+  sources = [ "intrusive_list_test.cc" ]
   deps = [
     ":intrusive_list",
     "$dir_pw_preprocessor",
   ]
-  sources = [ "intrusive_list_test.cc" ]
 }
 
 pw_doc_group("docs") {
diff --git a/pw_containers/docs.rst b/pw_containers/docs.rst
index dd36fc5..f875ce4 100644
--- a/pw_containers/docs.rst
+++ b/pw_containers/docs.rst
@@ -34,6 +34,16 @@
 list.
 
 
+pw::containers::FlatMap
+=======================
+FlatMap provides a simple, fixed-size associative array with lookup by key or
+value. ``pw::containers::FlatMap`` contains the same methods and features for
+looking up data as std::map. However, there are no methods that modify the
+underlying data.  The underlying array in ``pw::containers::FlatMap`` does not
+need to be sorted. During construction, ``pw::containers::FlatMap`` will
+perform a constexpr insertion sort.
+
+
 Usage
 -----
 While the API of `pw::IntrusiveList` is relatively similar to a
@@ -55,7 +65,7 @@
 .. code-block:: cpp
 
   class Square
-     : public pw::containers::IntrusiveList<Square>::Item {
+     : public pw::IntrusiveList<Square>::Item {
    public:
     Square(unsigned int side_length) : side_length(side_length) {}
     unsigned long Area() { return side_length * side_length; }
@@ -64,7 +74,7 @@
     unsigned int side_length;
   };
 
-  pw::containers::IntrusiveList<Square> squares;
+  pw::IntrusiveList<Square> squares;
 
   Square small(1);
   Square large(4000);
diff --git a/pw_containers/flat_map_test.cc b/pw_containers/flat_map_test.cc
new file mode 100644
index 0000000..51cf005
--- /dev/null
+++ b/pw_containers/flat_map_test.cc
@@ -0,0 +1,181 @@
+// 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.
+
+#include "pw_containers/flat_map.h"
+
+#include <limits>
+
+#include "gtest/gtest.h"
+
+namespace pw::containers {
+namespace {
+constexpr FlatMap<int, char, 5> kOddMap({{
+    {-3, 'a'},
+    {0, 'b'},
+    {1, 'c'},
+    {50, 'd'},
+    {100, 'e'},
+}});
+}  // namespace
+
+TEST(FlatMap, Size) { EXPECT_EQ(kOddMap.size(), static_cast<uint32_t>(5)); }
+
+TEST(FlatMap, EmptyFlatMapSize) {
+  constexpr FlatMap<int, char, 0> kEmpty({{}});
+  EXPECT_EQ(kEmpty.size(), static_cast<uint32_t>(0));
+}
+
+TEST(FlatMap, Empty) {
+  constexpr FlatMap<int, char, 0> kEmpty({{}});
+  EXPECT_TRUE(kEmpty.empty());
+}
+
+TEST(FlatMap, NotEmpty) {
+  constexpr FlatMap<int, char, 1> kNotEmpty({{}});
+  EXPECT_FALSE(kNotEmpty.empty());
+}
+
+TEST(FlatMap, EmptyFlatMapFind) {
+  constexpr FlatMap<int, char, 0> kEmpty({{}});
+  EXPECT_EQ(kEmpty.find(0), kEmpty.end());
+}
+
+TEST(FlatMap, EmptyFlatMapLowerBound) {
+  constexpr FlatMap<int, char, 0> kEmpty({{}});
+  EXPECT_EQ(kEmpty.lower_bound(0), kEmpty.end());
+}
+
+TEST(FlatMap, EmptyFlatMapUpperBound) {
+  constexpr FlatMap<int, char, 0> kEmpty({{}});
+  EXPECT_EQ(kEmpty.upper_bound(0), kEmpty.end());
+}
+
+TEST(FlatMap, EmptyEqualRange) {
+  constexpr FlatMap<int, char, 0> kEmpty({{}});
+  EXPECT_EQ(kEmpty.equal_range(0).first, kEmpty.end());
+  EXPECT_EQ(kEmpty.equal_range(0).second, kEmpty.end());
+}
+
+TEST(FlatMap, Contains) {
+  EXPECT_TRUE(kOddMap.contains(0));
+  EXPECT_FALSE(kOddMap.contains(10));
+}
+
+TEST(FlatMap, Iterate) {
+  char value = 'a';
+  for (const auto& item : kOddMap) {
+    EXPECT_EQ(value, item.second);
+    EXPECT_EQ(&item, kOddMap.find(item.first));
+    value += 1;
+  }
+}
+
+TEST(FlatMap, EqualRange) {
+  auto pair = kOddMap.equal_range(1);
+  EXPECT_EQ(1, pair.first->first);
+  EXPECT_EQ(50, pair.second->first);
+
+  pair = kOddMap.equal_range(75);
+  EXPECT_EQ(100, pair.first->first);
+  EXPECT_EQ(100, pair.second->first);
+}
+
+TEST(FlatMap, Find) {
+  auto it = kOddMap.find(50);
+  EXPECT_EQ(50, it->first);
+  EXPECT_EQ('d', it->second);
+
+  auto not_found = kOddMap.find(-1);
+  EXPECT_EQ(kOddMap.cend(), not_found);
+}
+
+TEST(FlatMap, UpperBoundLessThanSmallestKey) {
+  EXPECT_EQ(-3, kOddMap.upper_bound(std::numeric_limits<int>::min())->first);
+  EXPECT_EQ(-3, kOddMap.upper_bound(-123)->first);
+  EXPECT_EQ(-3, kOddMap.upper_bound(-4)->first);
+}
+
+TEST(FlatMap, UpperBoundBetweenTheTwoSmallestKeys) {
+  EXPECT_EQ(0, kOddMap.upper_bound(-3)->first);
+  EXPECT_EQ(0, kOddMap.upper_bound(-2)->first);
+  EXPECT_EQ(0, kOddMap.upper_bound(-1)->first);
+}
+
+TEST(FlatMap, UpperBoundIntermediateKeys) {
+  EXPECT_EQ(1, kOddMap.upper_bound(0)->first);
+  EXPECT_EQ('c', kOddMap.upper_bound(0)->second);
+  EXPECT_EQ(50, kOddMap.upper_bound(1)->first);
+  EXPECT_EQ('d', kOddMap.upper_bound(1)->second);
+  EXPECT_EQ(50, kOddMap.upper_bound(2)->first);
+  EXPECT_EQ(50, kOddMap.upper_bound(49)->first);
+  EXPECT_EQ(100, kOddMap.upper_bound(51)->first);
+}
+
+TEST(FlatMap, UpperBoundGreaterThanLargestKey) {
+  EXPECT_EQ(kOddMap.end(), kOddMap.upper_bound(100));
+  EXPECT_EQ(kOddMap.end(), kOddMap.upper_bound(2384924));
+  EXPECT_EQ(kOddMap.end(),
+            kOddMap.upper_bound(std::numeric_limits<int>::max()));
+}
+
+TEST(FlatMap, LowerBoundLessThanSmallestKey) {
+  EXPECT_EQ(-3, kOddMap.lower_bound(std::numeric_limits<int>::min())->first);
+  EXPECT_EQ(-3, kOddMap.lower_bound(-123)->first);
+  EXPECT_EQ(-3, kOddMap.lower_bound(-4)->first);
+}
+
+TEST(FlatMap, LowerBoundBetweenTwoSmallestKeys) {
+  EXPECT_EQ(-3, kOddMap.lower_bound(-3)->first);
+  EXPECT_EQ(0, kOddMap.lower_bound(-2)->first);
+  EXPECT_EQ(0, kOddMap.lower_bound(-1)->first);
+}
+
+TEST(FlatMap, LowerBoundIntermediateKeys) {
+  EXPECT_EQ(0, kOddMap.lower_bound(0)->first);
+  EXPECT_EQ('b', kOddMap.lower_bound(0)->second);
+  EXPECT_EQ(1, kOddMap.lower_bound(1)->first);
+  EXPECT_EQ('c', kOddMap.lower_bound(1)->second);
+  EXPECT_EQ(50, kOddMap.lower_bound(2)->first);
+  EXPECT_EQ(50, kOddMap.lower_bound(49)->first);
+  EXPECT_EQ(100, kOddMap.lower_bound(51)->first);
+}
+
+TEST(FlatMap, LowerBoundGreaterThanLargestKey) {
+  EXPECT_EQ(100, kOddMap.lower_bound(100)->first);
+  EXPECT_EQ(kOddMap.end(), kOddMap.lower_bound(2384924));
+  EXPECT_EQ(kOddMap.end(),
+            kOddMap.lower_bound(std::numeric_limits<int>::max()));
+}
+
+TEST(FlatMap, ForEachIteration) {
+  for (const auto& item : kOddMap) {
+    EXPECT_NE(item.first, 2);
+  }
+}
+
+TEST(FlatMap, MapsWithUnsortedKeys) {
+  constexpr FlatMap<int, const char*, 2> bad_array({{
+      {2, "hello"},
+      {1, "goodbye"},
+  }});
+
+  EXPECT_EQ(bad_array.begin()->first, 1);
+
+  constexpr FlatMap<int, const char*, 2> too_short({{
+      {1, "goodbye"},
+  }});
+  EXPECT_EQ(too_short.begin()->first, 0);
+}
+
+}  // namespace pw::containers
diff --git a/pw_containers/public/pw_containers/flat_map.h b/pw_containers/public/pw_containers/flat_map.h
new file mode 100644
index 0000000..b4c2ab5
--- /dev/null
+++ b/pw_containers/public/pw_containers/flat_map.h
@@ -0,0 +1,137 @@
+// 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.
+#pragma once
+
+#include <algorithm>
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <type_traits>
+
+namespace pw::containers {
+
+// A simple, fixed-size associative array with lookup by key or value.
+//
+// FlatMaps are initialized with a std::array of FlatMap::Pair objects:
+//   FlatMap<int, int> map({{{1, 2}, {3, 4}}});
+//
+// The keys do not need to be sorted as the constructor will sort the items
+// if need be.
+template <typename Key, typename Value, size_t kArraySize>
+class FlatMap {
+ public:
+  // Define and use a custom Pair object. This is because std::pair does not
+  // support constexpr assignment until C++20. The assignment is needed since
+  // the array of pairs will be sorted in the constructor (if not already).
+  template <typename First, typename Second>
+  struct Pair {
+    First first;
+    Second second;
+  };
+
+  using key_type = Key;
+  using mapped_type = Value;
+  using value_type = Pair<key_type, mapped_type>;
+  using size_type = size_t;
+  using difference_type = ptrdiff_t;
+  using container_type = typename std::array<value_type, kArraySize>;
+  using iterator = typename container_type::iterator;
+  using const_iterator = typename container_type::const_iterator;
+
+  constexpr FlatMap(const std::array<value_type, kArraySize>& items)
+      : items_(items) {
+    ConstexprSort(items_.data(), kArraySize);
+  }
+
+  FlatMap(FlatMap&) = delete;
+  FlatMap& operator=(FlatMap&) = delete;
+
+  // Capacity.
+  constexpr size_type size() const { return kArraySize; }
+  constexpr size_type empty() const { return size() == 0; }
+  constexpr size_type max_size() const { return kArraySize; }
+
+  // Lookup.
+  constexpr bool contains(const key_type& key) const {
+    return find(key) != end();
+  }
+
+  constexpr const_iterator find(const key_type& key) const {
+    if (end() == begin()) {
+      return end();
+    }
+
+    const_iterator it = lower_bound(key);
+    return key == it->first ? it : end();
+  }
+
+  constexpr const_iterator lower_bound(const key_type& key) const {
+    return std::lower_bound(
+        begin(), end(), key, [](const value_type& item, key_type lkey) {
+          return item.first < lkey;
+        });
+  }
+
+  constexpr const_iterator upper_bound(const key_type& key) const {
+    return std::upper_bound(
+        begin(), end(), key, [](key_type lkey, const value_type& item) {
+          return item.first > lkey;
+        });
+  }
+
+  constexpr std::pair<const_iterator, const_iterator> equal_range(
+      const key_type& key) const {
+    if (end() == begin()) {
+      return std::make_pair(end(), end());
+    }
+
+    return std::make_pair(lower_bound(key), upper_bound(key));
+  }
+
+  // Iterators.
+  constexpr const_iterator begin() const { return cbegin(); }
+  constexpr const_iterator cbegin() const { return items_.cbegin(); }
+  constexpr const_iterator end() const { return cend(); }
+  constexpr const_iterator cend() const { return items_.cend(); }
+
+ private:
+  // Simple stable insertion sort function for constexpr support.
+  // std::stable_sort is not constexpr. Should not be a problem with performance
+  // in regards to the sizes that are typically dealt with.
+  static constexpr void ConstexprSort(iterator data, size_type size) {
+    if (size < 2) {
+      return;
+    }
+
+    for (iterator it = data + 1, end = data + size; it < end; ++it) {
+      if (it->first < it[-1].first) {
+        // Rotate the value into place.
+        value_type temp = std::move(*it);
+        iterator it2 = it - 1;
+        while (true) {
+          *(it2 + 1) = std::move(*it2);
+          if (it2 == data || !(temp.first < it2[-1].first)) {
+            break;
+          }
+          --it2;
+        }
+        *it2 = std::move(temp);
+      }
+    }
+  }
+
+  std::array<value_type, kArraySize> items_;
+};
+
+}  // namespace pw::containers
diff --git a/pw_containers/public/pw_containers/intrusive_list.h b/pw_containers/public/pw_containers/intrusive_list.h
index 4b6bae4..b73effe 100644
--- a/pw_containers/public/pw_containers/intrusive_list.h
+++ b/pw_containers/public/pw_containers/intrusive_list.h
@@ -38,9 +38,9 @@
 // Usage:
 //
 //   class TestItem
-//      : public containers::IntrusiveList<TestItem>::Item {}
+//      : public IntrusiveList<TestItem>::Item {}
 //
-//   containers::IntrusiveList<TestItem> test_items;
+//   IntrusiveList<TestItem> test_items;
 //
 //   auto item = TestItem();
 //   test_items.push_back(item);
diff --git a/pw_containers/public/pw_containers/vector.h b/pw_containers/public/pw_containers/vector.h
index 7d734d3..1d05fd1 100644
--- a/pw_containers/public/pw_containers/vector.h
+++ b/pw_containers/public/pw_containers/vector.h
@@ -23,6 +23,9 @@
 #include <type_traits>
 #include <utility>
 
+#include "pw_assert/assert.h"
+#include "pw_polyfill/language_feature_macros.h"
+
 namespace pw {
 namespace vector_impl {
 
@@ -30,8 +33,25 @@
 using IsIterator = std::negation<
     std::is_same<typename std::iterator_traits<I>::value_type, void>>;
 
-// Used as kMaxSize in the generic-size Vector<T> interface.
-inline constexpr size_t kGeneric = size_t(-1);
+// Used as max_size in the generic-size Vector<T> interface.
+PW_INLINE_VARIABLE constexpr size_t kGeneric = size_t(-1);
+
+// The DestructorHelper is used to make Vector<T> trivially destructible if T
+// is. This could be replaced with a C++20 constraint.
+template <typename VectorClass, bool kIsTriviallyDestructible>
+class DestructorHelper;
+
+template <typename VectorClass>
+class DestructorHelper<VectorClass, true> {
+ public:
+  ~DestructorHelper() = default;
+};
+
+template <typename VectorClass>
+class DestructorHelper<VectorClass, false> {
+ public:
+  ~DestructorHelper() { static_cast<VectorClass*>(this)->clear(); }
+};
 
 }  // namespace vector_impl
 
@@ -45,7 +65,7 @@
 // the maximum size in a variable. This allows Vectors to be used without having
 // to know their maximum size at compile time. It also keeps code size small
 // since function implementations are shared for all maximum sizes.
-template <typename T, size_t kMaxSize = vector_impl::kGeneric>
+template <typename T, size_t max_size = vector_impl::kGeneric>
 class Vector : public Vector<T, vector_impl::kGeneric> {
  public:
   using typename Vector<T, vector_impl::kGeneric>::value_type;
@@ -61,37 +81,37 @@
   using typename Vector<T, vector_impl::kGeneric>::const_reverse_iterator;
 
   // Construct
-  Vector() noexcept : Vector<T, vector_impl::kGeneric>(kMaxSize) {}
+  Vector() noexcept : Vector<T, vector_impl::kGeneric>(max_size) {}
 
   Vector(size_type count, const T& value)
-      : Vector<T, vector_impl::kGeneric>(kMaxSize, count, value) {}
+      : Vector<T, vector_impl::kGeneric>(max_size, count, value) {}
 
   explicit Vector(size_type count)
-      : Vector<T, vector_impl::kGeneric>(kMaxSize, count, T()) {}
+      : Vector<T, vector_impl::kGeneric>(max_size, count, T()) {}
 
   template <
       typename Iterator,
       typename...,
       typename = std::enable_if_t<vector_impl::IsIterator<Iterator>::value>>
   Vector(Iterator first, Iterator last)
-      : Vector<T, vector_impl::kGeneric>(kMaxSize, first, last) {}
+      : Vector<T, vector_impl::kGeneric>(max_size, first, last) {}
 
   Vector(const Vector& other)
-      : Vector<T, vector_impl::kGeneric>(kMaxSize, other) {}
+      : Vector<T, vector_impl::kGeneric>(max_size, other) {}
 
   template <size_t kOtherMaxSize>
   Vector(const Vector<T, kOtherMaxSize>& other)
-      : Vector<T, vector_impl::kGeneric>(kMaxSize, other) {}
+      : Vector<T, vector_impl::kGeneric>(max_size, other) {}
 
   Vector(Vector&& other) noexcept
-      : Vector<T, vector_impl::kGeneric>(kMaxSize, std::move(other)) {}
+      : Vector<T, vector_impl::kGeneric>(max_size, std::move(other)) {}
 
   template <size_t kOtherMaxSize>
   Vector(Vector<T, kOtherMaxSize>&& other) noexcept
-      : Vector<T, vector_impl::kGeneric>(kMaxSize, std::move(other)) {}
+      : Vector<T, vector_impl::kGeneric>(max_size, std::move(other)) {}
 
   Vector(std::initializer_list<T> list)
-      : Vector<T, vector_impl::kGeneric>(kMaxSize, list) {}
+      : Vector<T, vector_impl::kGeneric>(max_size, list) {}
 
   Vector& operator=(const Vector& other) {
     Vector<T>::assign(other.begin(), other.end());
@@ -125,13 +145,18 @@
  private:
   friend class Vector<T, vector_impl::kGeneric>;
 
-  static_assert(kMaxSize <= std::numeric_limits<size_type>::max());
+  static_assert(max_size <= std::numeric_limits<size_type>::max());
 
   // Provides access to the underlying array as an array of T.
+#ifdef __cpp_lib_launder
   pointer array() { return std::launder(reinterpret_cast<T*>(&array_)); }
   const_pointer array() const {
     return std::launder(reinterpret_cast<const T*>(&array_));
   }
+#else
+  pointer array() { return reinterpret_cast<T*>(&array_); }
+  const_pointer array() const { return reinterpret_cast<const T*>(&array_); }
+#endif  // __cpp_lib_launder
 
   // Vector entries are stored as uninitialized memory blocks aligned correctly
   // for the type. Elements are initialized on demand with placement new.
@@ -141,14 +166,17 @@
   // The alignas specifier is required ensure that a zero-length array is
   // aligned the same as an array with elements.
   alignas(T) std::array<std::aligned_storage_t<sizeof(T), alignof(T)>,
-                        kMaxSize> array_;
+                        max_size> array_;
 };
 
 // Defines the generic-sized Vector<T> specialization, which serves as the base
 // class for Vector<T> of any maximum size. Except for constructors, all Vector
 // methods are implemented on this class.
 template <typename T>
-class Vector<T, vector_impl::kGeneric> {
+class Vector<T, vector_impl::kGeneric>
+    : public vector_impl::DestructorHelper<
+          Vector<T, vector_impl::kGeneric>,
+          std::is_trivially_destructible<T>::value> {
  public:
   using value_type = T;
 
@@ -171,8 +199,6 @@
   // directly. Instead, construct a Vector<T, max_size>. Vectors of any max size
   // can be used through a Vector<T> pointer or reference.
 
-  ~Vector() { clear(); }
-
   // Assign
 
   Vector& operator=(const Vector& other) {
@@ -211,12 +237,23 @@
 
   // Access
 
-  // TODO(hepler): Add an assert for bounds checking in at.
-  reference at(size_type index) { return data()[index]; }
-  const_reference at(size_type index) const { return data()[index]; }
+  reference at(size_type index) {
+    PW_ASSERT(index < size());
+    return data()[index];
+  }
+  const_reference at(size_type index) const {
+    PW_ASSERT(index < size());
+    return data()[index];
+  }
 
-  reference operator[](size_type index) { return data()[index]; }
-  const_reference operator[](size_type index) const { return data()[index]; }
+  reference operator[](size_type index) {
+    PW_DASSERT(index < size());
+    return data()[index];
+  }
+  const_reference operator[](size_type index) const {
+    PW_DASSERT(index < size());
+    return data()[index];
+  }
 
   reference front() { return data()[0]; }
   const_reference front() const { return data()[0]; }
@@ -313,7 +350,8 @@
  protected:
   // Vectors without an explicit size cannot be constructed directly. Instead,
   // the maximum size must be provided.
-  explicit Vector(size_type max_size) noexcept : max_size_(max_size) {}
+  explicit constexpr Vector(size_type max_size) noexcept
+      : max_size_(max_size) {}
 
   Vector(size_type max_size, size_type count, const T& value)
       : Vector(max_size) {
diff --git a/pw_containers/vector_test.cc b/pw_containers/vector_test.cc
index 24f42ab..9471114 100644
--- a/pw_containers/vector_test.cc
+++ b/pw_containers/vector_test.cc
@@ -15,7 +15,6 @@
 #include "pw_containers/vector.h"
 
 #include <cstddef>
-#include <vector>
 
 #include "gtest/gtest.h"
 
@@ -429,5 +428,21 @@
   }
 }
 
+// Test that Vector<T> is trivially destructible when its type is.
+static_assert(std::is_trivially_destructible_v<Vector<int>>);
+static_assert(std::is_trivially_destructible_v<Vector<int, 4>>);
+
+static_assert(std::is_trivially_destructible_v<MoveOnly>);
+static_assert(std::is_trivially_destructible_v<Vector<MoveOnly>>);
+static_assert(std::is_trivially_destructible_v<Vector<MoveOnly, 1>>);
+
+static_assert(std::is_trivially_destructible_v<CopyOnly>);
+static_assert(std::is_trivially_destructible_v<Vector<CopyOnly>>);
+static_assert(std::is_trivially_destructible_v<Vector<CopyOnly, 99>>);
+
+static_assert(!std::is_trivially_destructible_v<Counter>);
+static_assert(!std::is_trivially_destructible_v<Vector<Counter>>);
+static_assert(!std::is_trivially_destructible_v<Vector<Counter, 99>>);
+
 }  // namespace
 }  // namespace pw
diff --git a/pw_cpu_exception/BUILD.gn b/pw_cpu_exception/BUILD.gn
index f2562e2..879bb9c 100644
--- a/pw_cpu_exception/BUILD.gn
+++ b/pw_cpu_exception/BUILD.gn
@@ -61,10 +61,7 @@
 pw_facade("handler") {
   backend = pw_cpu_exception_HANDLER_BACKEND
   public_configs = [ ":default_config" ]
-  public_deps = [
-    "$dir_pw_preprocessor",
-    "$dir_pw_span",
-  ]
+  public_deps = [ "$dir_pw_preprocessor" ]
   sources = [ "start_exception_handler.cc" ]
   public = [ "public/pw_cpu_exception/handler.h" ]
 }
@@ -72,11 +69,10 @@
 # This library is technically optional. It is recommended to use `support` when
 # doing basic dumps of CPU state. As an alternative, projects may choose to
 # directly depend on the entry backend if they require direct access to
-# pw_CpuExceptionState members.
+# pw_cpu_exception_State members.
 pw_facade("support") {
   backend = pw_cpu_exception_SUPPORT_BACKEND
   public_configs = [ ":default_config" ]
-  public_deps = [ "$dir_pw_span" ]
   public = [ "public/pw_cpu_exception/support.h" ]
 }
 
diff --git a/pw_cpu_exception/basic_handler.cc b/pw_cpu_exception/basic_handler.cc
index 689fad3..5bcdadf 100644
--- a/pw_cpu_exception/basic_handler.cc
+++ b/pw_cpu_exception/basic_handler.cc
@@ -18,7 +18,7 @@
 
 namespace pw::cpu_exception {
 
-extern "C" void pw_CpuExceptionDefaultHandler(pw_CpuExceptionState*) {
+extern "C" void pw_cpu_exception_DefaultHandler(pw_cpu_exception_State*) {
   PW_LOG_CRITICAL("Unhandled CPU exception encountered!");
   // TODO(pwbug/95): Replace with pw_abort when that module exists.
   std::abort();
diff --git a/pw_cpu_exception/docs.rst b/pw_cpu_exception/docs.rst
index ec4892b..d375623 100644
--- a/pw_cpu_exception/docs.rst
+++ b/pw_cpu_exception/docs.rst
@@ -12,13 +12,13 @@
 
 Setup
 =====
-An application using this module **must** connect ``pw_CpuExceptionEntry()`` to
-the platform's CPU exception handler interrupt so ``pw_CpuExceptionEntry()`` is
+An application using this module **must** connect ``pw_cpu_exception_Entry()`` to
+the platform's CPU exception handler interrupt so ``pw_cpu_exception_Entry()`` is
 called immediately upon a CPU exception. For specifics on how this may be done,
 see the backend documentation for your architecture.
 
 Applications must also provide an implementation for
-``pw_CpuExceptionDefaultHandler()``. The behavior of this functions is entirely
+``pw_cpu_exception_DefaultHandler()``. The behavior of this functions is entirely
 up to the application/project, but some examples are provided below:
 
   * Enter an infinite loop so the device can be debugged by JTAG.
@@ -31,24 +31,24 @@
 Module Usage
 ============
 Basic usage of this module entails applications supplying a definition for
-``pw_CpuExceptionDefaultHandler()``. ``pw_CpuExceptionDefaultHandler()`` should
+``pw_cpu_exception_DefaultHandler()``. ``pw_cpu_exception_DefaultHandler()`` should
 contain any logic to determine if a exception can be recovered from, as well as
 necessary actions to properly recover. If the device cannot recover from the
 exception, the function should **not** return.
 
-``pw_CpuExceptionDefaultHandler()`` is called indirectly, and may be overridden
-at runtime via ``pw_CpuExceptionSetHandler()``. The handler can also be reset to
-point to ``pw_CpuExceptionDefaultHandler()`` by calling
-``pw_CpuExceptionRestoreDefaultHandler()``.
+``pw_cpu_exception_DefaultHandler()`` is called indirectly, and may be overridden
+at runtime via ``pw_cpu_exception_SetHandler()``. The handler can also be reset to
+point to ``pw_cpu_exception_DefaultHandler()`` by calling
+``pw_cpu_exception_RestoreDefaultHandler()``.
 
 When writing an exception handler, prefer to use the functions provided by this
 interface rather than relying on the backend implementation of
-``pw_CpuExceptionState``. This allows better code portability as it helps
+``pw_cpu_exception_State``. This allows better code portability as it helps
 prevent an application fault handler from being tied to a single backend.
 
 For example; when logging or dumping CPU state, prefer ``ToString()`` or
 ``RawFaultingCpuState()`` over directly accessing members of a
-``pw_CpuExceptionState`` object.
+``pw_cpu_exception_State`` object.
 
 Some exception handling behavior may require architecture-specific CPU state to
 attempt to correct a fault. In this situation, the application's exception
@@ -60,15 +60,15 @@
 mechanisms to capture CPU state for use by an application's exception handler,
 and allow recovery from CPU exceptions when possible.
 
-  * A backend should provide a definition for the ``pw_CpuExceptionState``
+  * A backend should provide a definition for the ``pw_cpu_exception_State``
     struct that provides suitable means to access and modify any captured CPU
     state.
   * If an application's exception handler modifies the captured CPU state, the
     state should be treated as though it were the original state of the CPU when
     the exception occurred. The backend may need to manually restore some of the
     modified state to ensure this on exception handler return.
-  * A backend should implement the ``pw_CpuExceptionEntry()`` function that will
-    call ``pw_HandleCpuException()`` after performing any necessary
+  * A backend should implement the ``pw_cpu_exception_Entry()`` function that will
+    call ``pw_cpu_exception_HandleException()`` after performing any necessary
     actions prior to handing control to the application's exception handler
     (e.g. capturing necessary CPU state).
 
diff --git a/pw_cpu_exception/public/pw_cpu_exception/entry.h b/pw_cpu_exception/public/pw_cpu_exception/entry.h
index 53b5a93..f663228 100644
--- a/pw_cpu_exception/public/pw_cpu_exception/entry.h
+++ b/pw_cpu_exception/public/pw_cpu_exception/entry.h
@@ -18,21 +18,22 @@
 // platform. By default, this module invokes the following user-defined function
 // after early exception handling completes:
 //
-//   pw_CpuExceptionDefaultHandler(pw_CpuExceptionState* state)
+//   pw_cpu_exception_DefaultHandler(pw_cpu_exception_State* state)
 //
 // If platform-dependent access to the CPU registers is needed, then
 // applications can include the respective backend module directly; for example
 // cpu_exception_armv7m.
 //
 // IMPORTANT: To use this module, you MUST implement
-//            pw_CpuExceptionDefaultHandler() in some part of your application.
+//            pw_cpu_exception_DefaultHandler() in some part of your
+//            application.
 
 #include "pw_preprocessor/compiler.h"
 #include "pw_preprocessor/util.h"
 
 // Low-level raw exception entry handler.
 //
-// Captures faulting CPU state into a platform-specific pw_CpuExceptionState
+// Captures faulting CPU state into a platform-specific pw_cpu_exception_State
 // object, then calls the user-provided fault handler.
 //
 // This function should be called immediately after a fault; typically by being
@@ -40,4 +41,4 @@
 //
 // Note: applications should almost never invoke this directly; if you do, make
 // sure you know what you are doing.
-PW_EXTERN_C PW_NO_PROLOGUE void pw_CpuExceptionEntry(void);
+PW_EXTERN_C PW_NO_PROLOGUE void pw_cpu_exception_Entry(void);
diff --git a/pw_cpu_exception/public/pw_cpu_exception/handler.h b/pw_cpu_exception/public/pw_cpu_exception/handler.h
index 83c9bf4..7c255fa 100644
--- a/pw_cpu_exception/public/pw_cpu_exception/handler.h
+++ b/pw_cpu_exception/public/pw_cpu_exception/handler.h
@@ -18,19 +18,19 @@
 
 PW_EXTERN_C_START
 
-// Forward declaration of pw_CpuExceptionState. Definition provided by cpu
+// Forward declaration of pw_cpu_exception_State. Definition provided by cpu
 // exception entry backend.
-struct pw_CpuExceptionState;
+struct pw_cpu_exception_State;
 
 // By default, the exception entry function will terminate by handing execution
-// over to pw_CpuExceptionDefaultHandler(). This can be used to override the
+// over to pw_cpu_exception_DefaultHandler(). This can be used to override the
 // current handler. This allows runtime insertion of an exception handler which
 // may also be helpful for loading a bootloader exception handler by default
 // that an application overrides.
-void pw_CpuExceptionSetHandler(void (*handler)(pw_CpuExceptionState*));
+void pw_cpu_exception_SetHandler(void (*handler)(pw_cpu_exception_State*));
 
-// Set the exception handler to point to pw_CpuExceptionDefaultHandler().
-void pw_CpuExceptionRestoreDefaultHandler(void);
+// Set the exception handler to point to pw_cpu_exception_DefaultHandler().
+void pw_cpu_exception_RestoreDefaultHandler(void);
 
 // Application-defined recoverable CPU exception handler.
 //
@@ -46,10 +46,10 @@
 // attach.
 //
 // See the cpu_exception module documentation for more details.
-PW_USED void pw_CpuExceptionDefaultHandler(pw_CpuExceptionState* state);
+PW_USED void pw_cpu_exception_DefaultHandler(pw_cpu_exception_State* state);
 
 // This is the underlying function the CPU exception entry backend should call.
 // This calls the currently set handler.
-void pw_HandleCpuException(void* cpu_state);
+void pw_cpu_exception_HandleException(void* cpu_state);
 
 PW_EXTERN_C_END
diff --git a/pw_cpu_exception/public/pw_cpu_exception/support.h b/pw_cpu_exception/public/pw_cpu_exception/support.h
index a078334..d525039 100644
--- a/pw_cpu_exception/public/pw_cpu_exception/support.h
+++ b/pw_cpu_exception/public/pw_cpu_exception/support.h
@@ -13,32 +13,33 @@
 // the License.
 
 // This facade provides an API for capturing the contents of a
-// pw_CpuExceptionState struct in a platform-agnostic way. While this facade
+// pw_cpu_exception_State struct in a platform-agnostic way. While this facade
 // does not provide a means to directly access individual members of a
-// pw_CpuExceptionState object, it does allow dumping CPU state without needing
-// to know any specifics about the underlying architecture.
+// pw_cpu_exception_State object, it does allow dumping CPU state without
+// needing to know any specifics about the underlying architecture.
 #pragma once
 
 #include <cstdint>
 #include <span>
 
-// Forward declaration of pw_CpuExceptionState. Definition provided by backend.
-struct pw_CpuExceptionState;
+// Forward declaration of pw_cpu_exception_State. Definition provided by
+// backend.
+struct pw_cpu_exception_State;
 
 namespace pw::cpu_exception {
 
 // Gets raw CPU state as a single contiguous block of data. The particular
 // contents will depend on the specific backend and platform.
 std::span<const uint8_t> RawFaultingCpuState(
-    const pw_CpuExceptionState& cpu_state);
+    const pw_cpu_exception_State& cpu_state);
 
 // Writes CPU state as a formatted string to a string builder.
 // NEVER depend on the format of this output. This is exclusively FYI human
 // readable output.
-void ToString(const pw_CpuExceptionState& cpu_state,
+void ToString(const pw_cpu_exception_State& cpu_state,
               const std::span<char>& dest);
 
 // Logs captured CPU state using pw_log at PW_LOG_LEVEL_INFO.
-void LogCpuState(const pw_CpuExceptionState& cpu_state);
+void LogCpuState(const pw_cpu_exception_State& cpu_state);
 
 }  // namespace pw::cpu_exception
diff --git a/pw_cpu_exception/start_exception_handler.cc b/pw_cpu_exception/start_exception_handler.cc
index dc566d9..284bb34 100644
--- a/pw_cpu_exception/start_exception_handler.cc
+++ b/pw_cpu_exception/start_exception_handler.cc
@@ -16,21 +16,21 @@
 
 namespace pw::cpu_exception {
 
-static void (*exception_handler)(pw_CpuExceptionState*) =
-    &pw_CpuExceptionDefaultHandler;
+static void (*exception_handler)(pw_cpu_exception_State*) =
+    &pw_cpu_exception_DefaultHandler;
 
-extern "C" void pw_CpuExceptionSetHandler(
-    void (*handler)(pw_CpuExceptionState*)) {
+extern "C" void pw_cpu_exception_SetHandler(
+    void (*handler)(pw_cpu_exception_State*)) {
   exception_handler = handler;
 }
 
-// Revert the exception handler to point to pw_CpuExceptionDefaultHandler().
-extern "C" void pw_CpuExceptionRestoreDefaultHandler() {
-  exception_handler = &pw_CpuExceptionDefaultHandler;
+// Revert the exception handler to point to pw_cpu_exception_DefaultHandler().
+extern "C" void pw_cpu_exception_RestoreDefaultHandler() {
+  exception_handler = &pw_cpu_exception_DefaultHandler;
 }
 
-extern "C" void pw_HandleCpuException(void* cpu_state) {
-  exception_handler(reinterpret_cast<pw_CpuExceptionState*>(cpu_state));
+extern "C" void pw_cpu_exception_HandleException(void* cpu_state) {
+  exception_handler(reinterpret_cast<pw_cpu_exception_State*>(cpu_state));
 }
 
 }  // namespace pw::cpu_exception
\ No newline at end of file
diff --git a/pw_cpu_exception_armv7m/BUILD b/pw_cpu_exception_armv7m/BUILD
deleted file mode 100644
index 453b448..0000000
--- a/pw_cpu_exception_armv7m/BUILD
+++ /dev/null
@@ -1,33 +0,0 @@
-# Copyright 2019 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.
-
-package(default_visibility = ["//visibility:public"])
-
-licenses(["notice"])  # Apache License 2.0
-
-filegroup(
-    name = "pw_cpu_exception_armv7m",
-    srcs = [
-        "entry.cc",
-        "cpu_state.cc",
-        "proto_dump.cc",
-        "public/pw_cpu_exception_armv7m/cpu_state.h",
-        "public/pw_cpu_exception_armv7m/proto_dump.h",
-    ],
-)
-
-filegroup(
-    name = "pw_cpu_exception_armv7m_test",
-    srcs = ["exception_entry_test.cc"],
-)
diff --git a/pw_cpu_exception_armv7m/BUILD.gn b/pw_cpu_exception_armv7m/BUILD.gn
deleted file mode 100644
index 61395e2..0000000
--- a/pw_cpu_exception_armv7m/BUILD.gn
+++ /dev/null
@@ -1,79 +0,0 @@
-# Copyright 2019 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/target_types.gni")
-import("$dir_pw_cpu_exception/backend.gni")
-import("$dir_pw_docgen/docs.gni")
-import("$dir_pw_protobuf_compiler/proto.gni")
-import("$dir_pw_unit_test/test.gni")
-
-config("default_config") {
-  include_dirs = [ "public" ]
-}
-
-pw_source_set("support") {
-  public_configs = [ ":default_config" ]
-  public_deps = [
-    "$dir_pw_cpu_exception:support.facade",
-    dir_pw_preprocessor,
-    dir_pw_string,
-  ]
-  deps = [ dir_pw_log ]
-  public = [ "public/pw_cpu_exception_armv7m/cpu_state.h" ]
-  sources = [ "cpu_state.cc" ]
-}
-
-pw_source_set("proto_dump") {
-  public_deps = [
-    ":support",
-    dir_pw_protobuf,
-    dir_pw_status,
-    dir_pw_stream,
-  ]
-  public = [ "public/pw_cpu_exception_armv7m/proto_dump.h" ]
-  deps = [ ":cpu_state_protos.pwpb" ]
-  sources = [ "proto_dump.cc" ]
-}
-
-pw_proto_library("cpu_state_protos") {
-  sources = [ "pw_cpu_exception_armv7m_protos/cpu_state.proto" ]
-}
-
-pw_source_set("pw_cpu_exception_armv7m") {
-  public_configs = [ ":default_config" ]
-  public_deps = [
-    ":proto_dump",
-    ":support",
-    "$dir_pw_cpu_exception:entry.facade",
-    "$dir_pw_cpu_exception:handler",
-    "$dir_pw_preprocessor",
-  ]
-  sources = [ "entry.cc" ]
-}
-
-pw_test_group("tests") {
-  enable_if = pw_cpu_exception_ENTRY_BACKEND == dir_pw_cpu_exception_armv7m
-  tests = [ ":cpu_exception_entry_test" ]
-}
-
-pw_test("cpu_exception_entry_test") {
-  deps = [ ":pw_cpu_exception_armv7m" ]
-  sources = [ "exception_entry_test.cc" ]
-}
-
-pw_doc_group("docs") {
-  sources = [ "docs.rst" ]
-}
diff --git a/pw_cpu_exception_armv7m/CMakeLists.txt b/pw_cpu_exception_armv7m/CMakeLists.txt
deleted file mode 100644
index 55d3a01..0000000
--- a/pw_cpu_exception_armv7m/CMakeLists.txt
+++ /dev/null
@@ -1,20 +0,0 @@
-# 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.
-
-include($ENV{PW_ROOT}/pw_build/pigweed.cmake)
-
-pw_auto_add_simple_module(pw_cpu_exception_armv7m
-  IMPLEMENTS_FACADE
-    pw_cpu_exception
-)
diff --git a/pw_cpu_exception_armv7m/cpu_state.cc b/pw_cpu_exception_armv7m/cpu_state.cc
deleted file mode 100644
index bec977e..0000000
--- a/pw_cpu_exception_armv7m/cpu_state.cc
+++ /dev/null
@@ -1,119 +0,0 @@
-// Copyright 2019 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.
-
-#include "pw_cpu_exception_armv7m/cpu_state.h"
-
-#include <cinttypes>
-#include <cstdint>
-#include <span>
-
-#include "pw_cpu_exception/support.h"
-#include "pw_log/log.h"
-#include "pw_string/string_builder.h"
-
-namespace pw::cpu_exception {
-
-std::span<const uint8_t> RawFaultingCpuState(
-    const pw_CpuExceptionState& cpu_state) {
-  return std::span(reinterpret_cast<const uint8_t*>(&cpu_state),
-                   sizeof(cpu_state));
-}
-
-// Using this function adds approximately 100 bytes to binary size.
-void ToString(const pw_CpuExceptionState& cpu_state,
-              const std::span<char>& dest) {
-  StringBuilder builder(dest);
-  const ArmV7mFaultRegisters& base = cpu_state.base;
-  const ArmV7mExtraRegisters& extended = cpu_state.extended;
-
-#define _PW_FORMAT_REGISTER(state_section, name) \
-  builder.Format("%s=0x%08" PRIx32 "\n", #name, state_section.name)
-
-  // Other registers.
-  _PW_FORMAT_REGISTER(base, pc);
-  _PW_FORMAT_REGISTER(base, lr);
-  _PW_FORMAT_REGISTER(base, psr);
-  _PW_FORMAT_REGISTER(extended, msp);
-  _PW_FORMAT_REGISTER(extended, psp);
-  _PW_FORMAT_REGISTER(extended, exc_return);
-  _PW_FORMAT_REGISTER(extended, cfsr);
-  _PW_FORMAT_REGISTER(extended, mmfar);
-  _PW_FORMAT_REGISTER(extended, bfar);
-  _PW_FORMAT_REGISTER(extended, icsr);
-  _PW_FORMAT_REGISTER(extended, hfsr);
-  _PW_FORMAT_REGISTER(extended, shcsr);
-  _PW_FORMAT_REGISTER(extended, control);
-
-  // General purpose registers.
-  _PW_FORMAT_REGISTER(base, r0);
-  _PW_FORMAT_REGISTER(base, r1);
-  _PW_FORMAT_REGISTER(base, r2);
-  _PW_FORMAT_REGISTER(base, r3);
-  _PW_FORMAT_REGISTER(extended, r4);
-  _PW_FORMAT_REGISTER(extended, r5);
-  _PW_FORMAT_REGISTER(extended, r6);
-  _PW_FORMAT_REGISTER(extended, r7);
-  _PW_FORMAT_REGISTER(extended, r8);
-  _PW_FORMAT_REGISTER(extended, r9);
-  _PW_FORMAT_REGISTER(extended, r10);
-  _PW_FORMAT_REGISTER(extended, r11);
-  _PW_FORMAT_REGISTER(base, r12);
-
-#undef _PW_FORMAT_REGISTER
-}
-
-// Using this function adds approximately 100 bytes to binary size.
-void LogCpuState(const pw_CpuExceptionState& cpu_state) {
-  const ArmV7mFaultRegisters& base = cpu_state.base;
-  const ArmV7mExtraRegisters& extended = cpu_state.extended;
-
-  PW_LOG_INFO("Captured CPU state:");
-
-#define _PW_LOG_REGISTER(state_section, name) \
-  PW_LOG_INFO("  %-10s 0x%08" PRIx32, #name, state_section.name)
-
-  // Other registers.
-  _PW_LOG_REGISTER(base, pc);
-  _PW_LOG_REGISTER(base, lr);
-  _PW_LOG_REGISTER(base, psr);
-  _PW_LOG_REGISTER(extended, msp);
-  _PW_LOG_REGISTER(extended, psp);
-  _PW_LOG_REGISTER(extended, exc_return);
-  _PW_LOG_REGISTER(extended, cfsr);
-  _PW_LOG_REGISTER(extended, mmfar);
-  _PW_LOG_REGISTER(extended, bfar);
-  _PW_LOG_REGISTER(extended, icsr);
-  _PW_LOG_REGISTER(extended, hfsr);
-  _PW_LOG_REGISTER(extended, shcsr);
-  _PW_LOG_REGISTER(extended, control);
-
-  // General purpose registers.
-  _PW_LOG_REGISTER(base, r0);
-  _PW_LOG_REGISTER(base, r1);
-  _PW_LOG_REGISTER(base, r2);
-  _PW_LOG_REGISTER(base, r3);
-  _PW_LOG_REGISTER(extended, r4);
-  _PW_LOG_REGISTER(extended, r5);
-  _PW_LOG_REGISTER(extended, r6);
-  _PW_LOG_REGISTER(extended, r7);
-  _PW_LOG_REGISTER(extended, r8);
-  _PW_LOG_REGISTER(extended, r9);
-  _PW_LOG_REGISTER(extended, r10);
-  _PW_LOG_REGISTER(extended, r11);
-  _PW_LOG_REGISTER(base, r12);
-
-#undef _PW_LOG_REGISTER
-}
-
-}  // namespace pw::cpu_exception
diff --git a/pw_cpu_exception_armv7m/docs.rst b/pw_cpu_exception_armv7m/docs.rst
deleted file mode 100644
index 9c79720..0000000
--- a/pw_cpu_exception_armv7m/docs.rst
+++ /dev/null
@@ -1,105 +0,0 @@
-.. _module-pw_cpu_exception_armv7m:
-
------------------------
-pw_cpu_exception_armv7m
------------------------
-This backend provides an ARMv7-M implementation for the CPU exception module
-frontend. See the CPU exception frontend module description for more
-information.
-
-Setup
-=====
-There are a few ways to set up the ARMv7-M exception handler so the
-application's exception handler is properly called during an exception.
-
-**1. Use existing CMSIS functions**
-  Inside of CMSIS fault handler functions, branch to ``pw_CpuExceptionEntry``.
-
-  .. code-block:: cpp
-
-    __attribute__((naked)) void HardFault_Handler(void) {
-    asm volatile(
-        " ldr r0, =pw_CpuExceptionEntry    \n"
-        " bx r0                            \n");
-    }
-
-**2. Modify a startup file**
-  Assembly startup files for some microcontrollers initialize the interrupt
-  vector table. The functions to call for fault handlers can be changed here.
-  For ARMv7-M, the fault handlers are indexes 3 to 6 of the interrupt vector
-  table. It's also may be helpful to redirect the NMI handler to the entry
-  function (if it's otherwise unused in your project).
-
-  Default:
-
-  .. code-block:: cpp
-
-    __isr_vector_table:
-      .word  __stack_start
-      .word  Reset_Handler
-      .word  NMI_Handler
-      .word  HardFault_Handler
-      .word  MemManage_Handler
-      .word  BusFault_Handler
-      .word  UsageFault_Handler
-
-  Using CPU exception module:
-
-  .. code-block:: cpp
-
-    __isr_vector_table:
-      .word  __stack_start
-      .word  Reset_Handler
-      .word  pw_CpuExceptionEntry
-      .word  pw_CpuExceptionEntry
-      .word  pw_CpuExceptionEntry
-      .word  pw_CpuExceptionEntry
-      .word  pw_CpuExceptionEntry
-
-  Note: ``__isr_vector_table`` and ``__stack_start`` are example names, and may
-  vary by platform. See your platform's assembly startup script.
-
-**3. Modify interrupt vector table at runtime**
-  Some applications may choose to modify their interrupt vector tables at
-  runtime. The ARMv7-M exception handler works with this use case (see the
-  exception_entry_test integration test), but keep in mind that your
-  application's exception handler will not be entered if an exception occurs
-  before the vector table entries are updated to point to
-  ``pw_CpuExceptionEntry``.
-
-Module Usage
-============
-For lightweight exception handlers that don't need to access
-architecture-specific registers, using the generic exception handler functions
-is preferred.
-
-However, some projects may need to explicitly access architecture-specific
-registers to attempt to recover from a CPU exception. ``pw_CpuExceptionState``
-provides access to the captured CPU state at the time of the fault. When the
-application-provided ``pw_CpuExceptionDefaultHandler()`` function returns, the
-CPU state is restored. This allows the exception handler to modify the captured
-state so that execution can safely continue.
-
-Expected Behavior
------------------
-In most cases, the CPU state captured by the exception handler will contain the
-ARMv7-M basic register frame in addition to an extended set of registers (see
-``cpu_state.h``). The exception to this is when the program stack pointer is in
-an MPU-protected or otherwise invalid memory region when the CPU attempts to
-push the exception register frame to it. In this situation, the PC, LR, and PSR
-registers will NOT be captured and will be marked with 0xFFFFFFFF to indicate
-they are invalid. This backend will still be able to capture all the other
-registers though.
-
-In the situation where the main stack pointer is in a memory protected or
-otherwise invalid region and fails to push CPU context, behavior is undefined.
-
-Nested Exceptions
------------------
-To enable nested fault handling:
-  1. Enable separate detection of usage/bus/memory faults via the SHCSR.
-  2. Decrease the priority of the memory, bus, and usage fault handlers. This
-     gives headroom for escalation.
-
-While this allows some faults to nest, it doesn't guarantee all will properly
-nest.
diff --git a/pw_cpu_exception_armv7m/entry.cc b/pw_cpu_exception_armv7m/entry.cc
deleted file mode 100644
index 77d5c43..0000000
--- a/pw_cpu_exception_armv7m/entry.cc
+++ /dev/null
@@ -1,308 +0,0 @@
-// Copyright 2019 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.
-
-#include "pw_cpu_exception/entry.h"
-
-#include <cstdint>
-#include <cstring>
-
-#include "pw_cpu_exception/handler.h"
-#include "pw_cpu_exception_armv7m/cpu_state.h"
-#include "pw_preprocessor/compiler.h"
-
-namespace pw::cpu_exception {
-namespace {
-
-// CMSIS/Cortex-M/ARMv7 related constants.
-// These values are from the ARMv7-M Architecture Reference Manual DDI 0403E.b.
-// https://static.docs.arm.com/ddi0403/e/DDI0403E_B_armv7m_arm.pdf
-
-// Masks for individual bits of CFSR. (ARMv7-M Section B3.2.15)
-constexpr uint32_t kMemFaultStart = 0x1u;
-constexpr uint32_t kMStkErrMask = kMemFaultStart << 4;
-constexpr uint32_t kBusFaultStart = 0x1u << 8;
-constexpr uint32_t kStkErrMask = kBusFaultStart << 4;
-
-// Bit masks for an exception return value. (ARMv7-M Section B1.5.8)
-constexpr uint32_t kExcReturnStackMask = (0x1u << 2);
-constexpr uint32_t kExcReturnBasicFrameMask = (0x1u << 4);
-
-// Memory mapped registers. (ARMv7-M Section B3.2.2, Table B3-4)
-volatile uint32_t& arm_v7m_cfsr =
-    *reinterpret_cast<volatile uint32_t*>(0xE000ED28u);
-volatile uint32_t& arm_v7m_mmfar =
-    *reinterpret_cast<volatile uint32_t*>(0xE000ED34u);
-volatile uint32_t& arm_v7m_bfar =
-    *reinterpret_cast<volatile uint32_t*>(0xE000ED38u);
-volatile uint32_t& arm_v7m_icsr =
-    *reinterpret_cast<volatile uint32_t*>(0xE000ED04u);
-volatile uint32_t& arm_v7m_hfsr =
-    *reinterpret_cast<volatile uint32_t*>(0xE000ED2Cu);
-volatile uint32_t& arm_v7m_shcsr =
-    *reinterpret_cast<volatile uint32_t*>(0xE000ED24u);
-
-// If the CPU fails to capture some registers, the captured struct members will
-// be populated with this value. The only registers that this value should be
-// loaded into are pc, lr, and psr when the CPU fails to push an exception
-// context frame.
-//
-// 0xFFFFFFFF is an illegal lr value, which is why it was selected for this
-// purpose. pc and psr values of 0xFFFFFFFF are dubious too, so this constant
-// is clear enough at expressing that the registers weren't properly captured.
-constexpr uint32_t kInvalidRegisterValue = 0xFFFFFFFF;
-
-// Checks exc_return in the captured CPU state to determine which stack pointer
-// was in use prior to entering the exception handler.
-bool PspWasActive(const pw_CpuExceptionState& cpu_state) {
-  return cpu_state.extended.exc_return & kExcReturnStackMask;
-}
-
-// Checks exc_return to determine if FPU state was pushed to the stack in
-// addition to the base CPU context frame.
-bool FpuStateWasPushed(const pw_CpuExceptionState& cpu_state) {
-  return !(cpu_state.extended.exc_return & kExcReturnBasicFrameMask);
-}
-
-// If the CPU successfully pushed context on exception, copy it into cpu_state.
-//
-// For more information see (See ARMv7-M Section B1.5.11, derived exceptions
-// on exception entry).
-void CloneBaseRegistersFromPsp(pw_CpuExceptionState* cpu_state) {
-  // If CPU succeeded in pushing context to PSP, copy it to the MSP.
-  if (!(cpu_state->extended.cfsr & kStkErrMask) &&
-      !(cpu_state->extended.cfsr & kMStkErrMask)) {
-    // TODO(amontanez): {r0-r3,r12} are captured in pw_CpuExceptionEntry(),
-    //                  so this only really needs to copy pc, lr, and psr. Could
-    //                  (possibly) improve speed, but would add marginally more
-    //                  complexity.
-    std::memcpy(&cpu_state->base,
-                reinterpret_cast<void*>(cpu_state->extended.psp),
-                sizeof(ArmV7mFaultRegisters));
-  } else {
-    // If CPU context wasn't pushed to stack on exception entry, we can't
-    // recover psr, lr, and pc from exception-time. Make these values clearly
-    // invalid.
-    cpu_state->base.lr = kInvalidRegisterValue;
-    cpu_state->base.pc = kInvalidRegisterValue;
-    cpu_state->base.psr = kInvalidRegisterValue;
-  }
-}
-
-// If the CPU successfully pushed context on exception, restore it from
-// cpu_state. Otherwise, don't attempt to restore state.
-//
-// For more information see (See ARMv7-M Section B1.5.11, derived exceptions
-// on exception entry).
-void RestoreBaseRegistersToPsp(pw_CpuExceptionState* cpu_state) {
-  // If CPU succeeded in pushing context to PSP on exception entry, restore the
-  // contents of cpu_state to the CPU-pushed register frame so the CPU can
-  // continue. Otherwise, don't attempt as we'll likely end up in an escalated
-  // hard fault.
-  if (!(cpu_state->extended.cfsr & kStkErrMask) &&
-      !(cpu_state->extended.cfsr & kMStkErrMask)) {
-    std::memcpy(reinterpret_cast<void*>(cpu_state->extended.psp),
-                &cpu_state->base,
-                sizeof(ArmV7mFaultRegisters));
-  }
-}
-
-// Determines the size of the CPU-pushed context frame.
-uint32_t CpuContextSize(const pw_CpuExceptionState& cpu_state) {
-  uint32_t cpu_context_size = sizeof(ArmV7mFaultRegisters);
-  if (FpuStateWasPushed(cpu_state)) {
-    cpu_context_size += sizeof(ArmV7mFaultRegistersFpu);
-  }
-  if (cpu_state.base.psr & kPsrExtraStackAlignBit) {
-    // Account for the extra 4-bytes the processor
-    // added to keep the stack pointer 8-byte aligned
-    cpu_context_size += 4;
-  }
-
-  return cpu_context_size;
-}
-
-// On exception entry, the Program Stack Pointer is patched to reflect the state
-// at exception-time. On exception return, it is restored to the appropriate
-// location. This calculates the delta that is used for these patch operations.
-uint32_t CalculatePspDelta(const pw_CpuExceptionState& cpu_state) {
-  // If CPU context was not pushed to program stack (because program stack
-  // wasn't in use, or an error occurred when pushing context), the PSP doesn't
-  // need to be shifted.
-  if (!PspWasActive(cpu_state) || (cpu_state.extended.cfsr & kStkErrMask) ||
-      (cpu_state.extended.cfsr & kMStkErrMask)) {
-    return 0;
-  }
-
-  return CpuContextSize(cpu_state);
-}
-
-// On exception entry, the Main Stack Pointer is patched to reflect the state
-// at exception-time. On exception return, it is restored to the appropriate
-// location. This calculates the delta that is used for these patch operations.
-uint32_t CalculateMspDelta(const pw_CpuExceptionState& cpu_state) {
-  if (PspWasActive(cpu_state)) {
-    // TODO(amontanez): Since FPU state isn't captured at this time, we ignore
-    //                  it when patching MSP. To add FPU capture support,
-    //                  delete this if block as CpuContextSize() will include
-    //                  FPU context size in the calculation.
-    return sizeof(ArmV7mFaultRegisters) + sizeof(ArmV7mExtraRegisters);
-  }
-
-  return CpuContextSize(cpu_state) + sizeof(ArmV7mExtraRegisters);
-}
-
-}  // namespace
-
-extern "C" {
-
-// Collect remaining CPU state (memory mapped registers), populate memory mapped
-// registers, and call application exception handler.
-PW_USED void pw_PackageAndHandleCpuException(pw_CpuExceptionState* cpu_state) {
-  // Capture memory mapped registers.
-  cpu_state->extended.cfsr = arm_v7m_cfsr;
-  cpu_state->extended.mmfar = arm_v7m_mmfar;
-  cpu_state->extended.bfar = arm_v7m_bfar;
-  cpu_state->extended.icsr = arm_v7m_icsr;
-  cpu_state->extended.hfsr = arm_v7m_hfsr;
-  cpu_state->extended.shcsr = arm_v7m_shcsr;
-
-  // CPU may have automatically pushed state to the program stack. If it did,
-  // the values can be copied into in the pw_CpuExceptionState struct that is
-  // passed to HandleCpuException(). The cpu_state passed to the handler is
-  // ALWAYS stored on the main stack (MSP).
-  if (PspWasActive(*cpu_state)) {
-    CloneBaseRegistersFromPsp(cpu_state);
-    // If PSP wasn't active, this delta is 0.
-    cpu_state->extended.psp += CalculatePspDelta(*cpu_state);
-  }
-
-  // Patch captured stack pointers so they reflect the state at exception time.
-  cpu_state->extended.msp += CalculateMspDelta(*cpu_state);
-
-  // Call application-level exception handler.
-  pw_HandleCpuException(cpu_state);
-
-  // Restore program stack pointer so exception return can restore state if
-  // needed.
-  // Note: The default behavior of NOT subtracting a delta from MSP is
-  // intentional. This simplifies the assembly to pop the exception state
-  // off the main stack on exception return (since MSP currently reflects
-  // exception-time state).
-  cpu_state->extended.psp -= CalculatePspDelta(*cpu_state);
-
-  // If PSP was active and the CPU pushed a context frame, we must copy the
-  // potentially modified state from cpu_state back to the PSP so the CPU can
-  // resume execution with the modified values.
-  if (PspWasActive(*cpu_state)) {
-    // In this case, there's no need to touch the MSP as it's at the location
-    // before we entering the exception (effectively popping the state initially
-    // pushed to the main stack).
-    RestoreBaseRegistersToPsp(cpu_state);
-  } else {
-    // Since we're restoring context from MSP, we DO need to adjust MSP to point
-    // to CPU-pushed context frame so it can be properly restored.
-    // No need to adjust PSP since nothing was pushed to program stack.
-    cpu_state->extended.msp -= CpuContextSize(*cpu_state);
-  }
-}
-
-// Captures faulting CPU state on the main stack (MSP), then calls the exception
-// handlers.
-// This function should be called immediately after an exception.
-void pw_CpuExceptionEntry(void) {
-  asm volatile(
-      // If PSP was in use at the time of exception, it's possible the CPU
-      // wasn't able to push CPU state. To be safe, this first captures scratch
-      // registers before moving forward.
-      //
-      // Stack flag is bit index 2 (0x4) of exc_return value stored in lr. When
-      // this bit is set, the Process Stack Pointer (PSP) was in use. Otherwise,
-      // the Main Stack Pointer (MSP) was in use. (See ARMv7-M Section B1.5.8
-      // for more details)
-      // The following block of assembly is equivalent to:
-      //   if (lr & (1 << 2)) {
-      //     msp -= sizeof(ArmV7mFaultRegisters);
-      //     ArmV7mFaultRegisters* state = (ArmV7mFaultRegisters*) msp;
-      //     state->r0 = r0;
-      //     state->r1 = r1;
-      //     state->r2 = r2;
-      //     state->r3 = r3;
-      //     state->r12 = r12;
-      //   }
-      //
-      " tst lr, #(1 << 2)                                     \n"
-      " itt ne                                                \n"
-      " subne sp, sp, %[base_state_size]                      \n"
-      " stmne sp, {r0-r3, r12}                                \n"
-
-      // Reserve stack space for additional registers. Since we're in exception
-      // handler mode, the main stack pointer is currently in use.
-      // r0 will temporarily store the end of captured_cpu_state to simplify
-      // assembly for copying additional registers.
-      " mrs r0, msp                                           \n"
-      " sub sp, sp, %[extra_state_size]                       \n"
-
-      // Store GPRs to stack.
-      " stmdb r0!, {r4-r11}                                   \n"
-
-      // Load special registers.
-      " mov r1, lr                                            \n"
-      " mrs r2, msp                                           \n"
-      " mrs r3, psp                                           \n"
-      " mrs r4, control                                       \n"
-
-      // Store special registers to stack.
-      " stmdb r0!, {r1-r4}                                    \n"
-
-      // Store a pointer to the beginning of special registers in r4 so they can
-      // be restored later.
-      " mov r4, r0                                            \n"
-
-      // Restore captured_cpu_state pointer to r0. This makes adding more
-      // memory mapped registers easier in the future since they're skipped in
-      // this assembly.
-      " mrs r0, msp                                           \n"
-
-      // Call intermediate handler that packages data.
-      " ldr r3, =pw_PackageAndHandleCpuException              \n"
-      " blx r3                                                \n"
-
-      // Restore state and exit exception handler.
-      // Pointer to saved CPU state was stored in r4.
-      " mov r0, r4                                            \n"
-
-      // Restore special registers.
-      " ldm r0!, {r1-r4}                                      \n"
-      " mov lr, r1                                            \n"
-      " msr control, r4                                       \n"
-
-      // Restore GPRs.
-      " ldm r0, {r4-r11}                                      \n"
-
-      // Restore stack pointers.
-      " msr msp, r2                                           \n"
-      " msr psp, r3                                           \n"
-
-      // Exit exception.
-      " bx lr                                                 \n"
-      // clang-format off
-      : /*output=*/
-      : /*input=*/[base_state_size]"i"(sizeof(ArmV7mFaultRegisters)),
-                  [extra_state_size]"i"(sizeof(ArmV7mExtraRegisters))
-      // clang-format on
-  );
-}
-
-}  // extern "C"
-}  // namespace pw::cpu_exception
diff --git a/pw_cpu_exception_armv7m/exception_entry_test.cc b/pw_cpu_exception_armv7m/exception_entry_test.cc
deleted file mode 100644
index 7cf2226..0000000
--- a/pw_cpu_exception_armv7m/exception_entry_test.cc
+++ /dev/null
@@ -1,619 +0,0 @@
-// Copyright 2019 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.
-
-#include <cstdint>
-#include <span>
-#include <type_traits>
-
-#include "gtest/gtest.h"
-#include "pw_cpu_exception/entry.h"
-#include "pw_cpu_exception/handler.h"
-#include "pw_cpu_exception/support.h"
-#include "pw_cpu_exception_armv7m/cpu_state.h"
-
-namespace pw::cpu_exception {
-namespace {
-
-// CMSIS/Cortex-M/ARMv7 related constants.
-// These values are from the ARMv7-M Architecture Reference Manual DDI 0403E.b.
-// https://static.docs.arm.com/ddi0403/e/DDI0403E_B_armv7m_arm.pdf
-
-// Exception ISR number. (ARMv7-M Section B1.5.2)
-constexpr uint32_t kHardFaultIsrNum = 0x3u;
-constexpr uint32_t kMemFaultIsrNum = 0x4u;
-constexpr uint32_t kBusFaultIsrNum = 0x5u;
-constexpr uint32_t kUsageFaultIsrNum = 0x6u;
-
-// Masks for individual bits of HFSR. (ARMv7-M Section B3.2.16)
-constexpr uint32_t kForcedHardfaultMask = 0x1u << 30;
-
-// Masks for individual bits of CFSR. (ARMv7-M Section B3.2.15)
-constexpr uint32_t kUsageFaultStart = 0x1u << 16;
-constexpr uint32_t kUnalignedFaultMask = kUsageFaultStart << 8;
-constexpr uint32_t kDivByZeroFaultMask = kUsageFaultStart << 9;
-
-// CCR flags. (ARMv7-M Section B3.2.8)
-constexpr uint32_t kUnalignedTrapEnableMask = 0x1u << 3;
-constexpr uint32_t kDivByZeroTrapEnableMask = 0x1u << 4;
-
-// Masks for individual bits of SHCSR. (ARMv7-M Section B3.2.13)
-constexpr uint32_t kMemFaultEnableMask = 0x1 << 16;
-constexpr uint32_t kBusFaultEnableMask = 0x1 << 17;
-constexpr uint32_t kUsageFaultEnableMask = 0x1 << 18;
-
-// Bit masks for an exception return value. (ARMv7-M Section B1.5.8)
-constexpr uint32_t kExcReturnBasicFrameMask = (0x1u << 4);
-
-// CPCAR mask that enables FPU. (ARMv7-M Section B3.2.20)
-constexpr uint32_t kFpuEnableMask = (0xFu << 20);
-
-// Memory mapped registers. (ARMv7-M Section B3.2.2, Table B3-4)
-volatile uint32_t& arm_v7m_vtor =
-    *reinterpret_cast<volatile uint32_t*>(0xE000ED08u);
-volatile uint32_t& arm_v7m_ccr =
-    *reinterpret_cast<volatile uint32_t*>(0xE000ED14u);
-volatile uint32_t& arm_v7m_shcsr =
-    *reinterpret_cast<volatile uint32_t*>(0xE000ED24u);
-volatile uint32_t& arm_v7m_cfsr =
-    *reinterpret_cast<volatile uint32_t*>(0xE000ED28u);
-volatile uint32_t& arm_v7m_hfsr =
-    *reinterpret_cast<volatile uint32_t*>(0xE000ED2Cu);
-volatile uint32_t& arm_v7m_cpacr =
-    *reinterpret_cast<volatile uint32_t*>(0xE000ED88u);
-
-// Begin a critical section that must not be interrupted.
-// This function disables interrupts to prevent any sort of context switch until
-// the critical section ends. This is done by setting PRIMASK to 1 using the cps
-// instruction.
-//
-// Returns the state of PRIMASK before it was disabled.
-inline uint32_t BeginCriticalSection() {
-  uint32_t previous_state;
-  asm volatile(
-      " mrs %[previous_state], primask              \n"
-      " cpsid i                                     \n"
-      // clang-format off
-      : /*output=*/[previous_state]"=r"(previous_state)
-      : /*input=*/
-      : /*clobbers=*/"memory"
-      // clang-format on
-  );
-  return previous_state;
-}
-
-// Ends a critical section.
-// Restore previous previous state produced by BeginCriticalSection().
-// Note: This does not always re-enable interrupts.
-inline void EndCriticalSection(uint32_t previous_state) {
-  asm volatile(
-      // clang-format off
-      "msr primask, %0"
-      : /*output=*/
-      : /*input=*/"r"(previous_state)
-      : /*clobbers=*/"memory"
-      // clang-format on
-  );
-}
-
-void EnableFpu() {
-#if defined(PW_ARMV7M_ENABLE_FPU) && PW_ARMV7M_ENABLE_FPU == 1
-  // TODO(pwbug/17): Replace when Pigweed config system is added.
-  arm_v7m_cpacr |= kFpuEnableMask;
-#endif  // defined(PW_ARMV7M_ENABLE_FPU) && PW_ARMV7M_ENABLE_FPU == 1
-}
-
-void DisableFpu() {
-#if defined(PW_ARMV7M_ENABLE_FPU) && PW_ARMV7M_ENABLE_FPU == 1
-  // TODO(pwbug/17): Replace when Pigweed config system is added.
-  arm_v7m_cpacr &= ~kFpuEnableMask;
-#endif  // defined(PW_ARMV7M_ENABLE_FPU) && PW_ARMV7M_ENABLE_FPU == 1
-}
-
-// Counter that is incremented if the test's exception handler correctly handles
-// a triggered exception.
-size_t exceptions_handled = 0;
-
-// Global variable that triggers a single nested fault on a fault.
-bool trigger_nested_fault = false;
-
-// Allow up to kMaxFaultDepth faults before determining the device is
-// unrecoverable.
-constexpr size_t kMaxFaultDepth = 2;
-
-// Variable to prevent more than kMaxFaultDepth nested crashes.
-size_t current_fault_depth = 0;
-
-// Faulting pw_CpuExceptionState is copied here so values can be validated after
-// exiting exception handler.
-pw_CpuExceptionState captured_states[kMaxFaultDepth] = {};
-pw_CpuExceptionState& captured_state = captured_states[0];
-
-// Flag used to check if the contents of std::span matches the captured state.
-bool span_matches = false;
-
-// Variable to be manipulated by function that uses floating
-// point to test that exceptions push Fpu state correctly.
-// Note: don't use double because a cortex-m4f with fpv4-sp-d16
-// will result in gcc generating code to use the software floating
-// point support for double.
-volatile float float_test_value;
-
-// Magic pattern to help identify if the exception handler's
-// pw_CpuExceptionState pointer was pointing to captured CPU state that was
-// pushed onto the stack when the faulting context uses the VFP. Has to be
-// computed at runtime because it uses values only available at link time.
-const float kFloatTestPattern = 12.345f * 67.89f;
-
-volatile float fpu_lhs_val = 12.345f;
-volatile float fpu_rhs_val = 67.89f;
-
-// This macro provides a calculation that equals kFloatTestPattern.
-#define _PW_TEST_FPU_OPERATION (fpu_lhs_val * fpu_rhs_val)
-
-// Magic pattern to help identify if the exception handler's
-// pw_CpuExceptionState pointer was pointing to captured CPU state that was
-// pushed onto the stack.
-constexpr uint32_t kMagicPattern = 0xDEADBEEF;
-
-// This pattern serves a purpose similar to kMagicPattern, but is used for
-// testing a nested fault to ensure both pw_CpuExceptionState objects are
-// correctly captured.
-constexpr uint32_t kNestedMagicPattern = 0x900DF00D;
-
-// The manually captured PC won't be the exact same as the faulting PC. This is
-// the maximum tolerated distance between the two to allow the test to pass.
-constexpr int32_t kMaxPcDistance = 4;
-
-// In-memory interrupt service routine vector table.
-using InterruptVectorTable = std::aligned_storage_t<512, 512>;
-InterruptVectorTable ram_vector_table;
-
-// Forward declaration of the exception handler.
-void TestingExceptionHandler(pw_CpuExceptionState*);
-
-// Populate the device's registers with testable values, then trigger exception.
-void BeginBaseFaultTest() {
-  // Make sure divide by zero causes a fault.
-  arm_v7m_ccr |= kDivByZeroTrapEnableMask;
-  uint32_t magic = kMagicPattern;
-  asm volatile(
-      " mov r0, %[magic]                                      \n"
-      " mov r1, #0                                            \n"
-      " mov r2, pc                                            \n"
-      " mov r3, lr                                            \n"
-      // This instruction divides by zero.
-      " udiv r1, r1, r1                                       \n"
-      // clang-format off
-      : /*output=*/
-      : /*input=*/[magic]"r"(magic)
-      : /*clobbers=*/"r0", "r1", "r2", "r3"
-      // clang-format on
-  );
-
-  // Check that the stack align bit was not set.
-  EXPECT_EQ(captured_state.base.psr & kPsrExtraStackAlignBit, 0u);
-}
-
-// Populate the device's registers with testable values, then trigger exception.
-void BeginNestedFaultTest() {
-  // Make sure divide by zero causes a fault.
-  arm_v7m_ccr |= kUnalignedTrapEnableMask;
-  volatile uint32_t magic = kNestedMagicPattern;
-  asm volatile(
-      " mov r0, %[magic]                                      \n"
-      " mov r1, #0                                            \n"
-      " mov r2, pc                                            \n"
-      " mov r3, lr                                            \n"
-      // This instruction does an unaligned read.
-      " ldrh r1, [%[magic_addr], 1]                           \n"
-      // clang-format off
-      : /*output=*/
-      : /*input=*/[magic]"r"(magic), [magic_addr]"r"(&magic)
-      : /*clobbers=*/"r0", "r1", "r2", "r3"
-      // clang-format on
-  );
-}
-
-// Populate the device's registers with testable values, then trigger exception.
-// This version causes stack to not be 4-byte aligned initially, testing
-// the fault handlers correction for psp.
-void BeginBaseFaultUnalignedStackTest() {
-  // Make sure divide by zero causes a fault.
-  arm_v7m_ccr |= kDivByZeroTrapEnableMask;
-  uint32_t magic = kMagicPattern;
-  asm volatile(
-      // Push one register to cause $sp to be no longer 8-byte aligned,
-      // assuming it started 8-byte aligned as expected.
-      " push {r0}                                             \n"
-      " mov r0, %[magic]                                      \n"
-      " mov r1, #0                                            \n"
-      " mov r2, pc                                            \n"
-      " mov r3, lr                                            \n"
-      // This instruction divides by zero. Our fault handler should
-      // ultimately advance the pc to the pop instruction.
-      " udiv r1, r1, r1                                       \n"
-      " pop {r0}                                              \n"
-      // clang-format off
-      : /*output=*/
-      : /*input=*/[magic]"r"(magic)
-      : /*clobbers=*/"r0", "r1", "r2", "r3"
-      // clang-format on
-  );
-
-  // Check that the stack align bit was set.
-  EXPECT_EQ(captured_state.base.psr & kPsrExtraStackAlignBit,
-            kPsrExtraStackAlignBit);
-}
-
-// Populate some of the extended set of captured registers, then trigger
-// exception.
-void BeginExtendedFaultTest() {
-  // Make sure divide by zero causes a fault.
-  arm_v7m_ccr |= kDivByZeroTrapEnableMask;
-  uint32_t magic = kMagicPattern;
-  volatile uint32_t local_msp = 0;
-  volatile uint32_t local_psp = 0;
-  asm volatile(
-      " mov r4, %[magic]                                      \n"
-      " mov r5, #0                                            \n"
-      " mov r11, %[magic]                                     \n"
-      " mrs %[local_msp], msp                                 \n"
-      " mrs %[local_psp], psp                                 \n"
-      // This instruction divides by zero.
-      " udiv r5, r5, r5                                       \n"
-      // clang-format off
-      : /*output=*/[local_msp]"=r"(local_msp), [local_psp]"=r"(local_psp)
-      : /*input=*/[magic]"r"(magic)
-      : /*clobbers=*/"r4", "r5", "r11", "memory"
-      // clang-format on
-  );
-
-  // Check that the stack align bit was not set.
-  EXPECT_EQ(captured_state.base.psr & kPsrExtraStackAlignBit, 0u);
-
-  // Check that the captured stack pointers matched the ones in the context of
-  // the fault.
-  EXPECT_EQ(static_cast<uint32_t>(captured_state.extended.msp), local_msp);
-  EXPECT_EQ(static_cast<uint32_t>(captured_state.extended.psp), local_psp);
-}
-
-// Populate some of the extended set of captured registers, then trigger
-// exception.
-// This version causes stack to not be 4-byte aligned initially, testing
-// the fault handlers correction for psp.
-void BeginExtendedFaultUnalignedStackTest() {
-  // Make sure divide by zero causes a fault.
-  arm_v7m_ccr |= kDivByZeroTrapEnableMask;
-  uint32_t magic = kMagicPattern;
-  volatile uint32_t local_msp = 0;
-  volatile uint32_t local_psp = 0;
-  asm volatile(
-      // Push one register to cause $sp to be no longer 8-byte aligned,
-      // assuming it started 8-byte aligned as expected.
-      " push {r0}                                             \n"
-      " mov r4, %[magic]                                      \n"
-      " mov r5, #0                                            \n"
-      " mov r11, %[magic]                                     \n"
-      " mrs %[local_msp], msp                                 \n"
-      " mrs %[local_psp], psp                                 \n"
-      // This instruction divides by zero. Our fault handler should
-      // ultimately advance the pc to the pop instruction.
-      " udiv r5, r5, r5                                       \n"
-      " pop {r0}                                              \n"
-      // clang-format off
-      : /*output=*/[local_msp]"=r"(local_msp), [local_psp]"=r"(local_psp)
-      : /*input=*/[magic]"r"(magic)
-      : /*clobbers=*/"r4", "r5", "r11", "memory"
-      // clang-format on
-  );
-
-  // Check that the stack align bit was set.
-  EXPECT_EQ(captured_state.base.psr & kPsrExtraStackAlignBit,
-            kPsrExtraStackAlignBit);
-
-  // Check that the captured stack pointers matched the ones in the context of
-  // the fault.
-  EXPECT_EQ(static_cast<uint32_t>(captured_state.extended.msp), local_msp);
-  EXPECT_EQ(static_cast<uint32_t>(captured_state.extended.psp), local_psp);
-}
-
-void InstallVectorTableEntries() {
-  uint32_t prev_state = BeginCriticalSection();
-  // If vector table is installed already, this is done.
-  if (arm_v7m_vtor == reinterpret_cast<uint32_t>(&ram_vector_table)) {
-    EndCriticalSection(prev_state);
-    return;
-  }
-  // Copy table to new location since it's not guaranteed that we can write to
-  // the original one.
-  std::memcpy(&ram_vector_table,
-              reinterpret_cast<uint32_t*>(arm_v7m_vtor),
-              sizeof(ram_vector_table));
-
-  // Override exception handling vector table entries.
-  uint32_t* exception_entry_addr =
-      reinterpret_cast<uint32_t*>(pw_CpuExceptionEntry);
-  uint32_t** interrupts = reinterpret_cast<uint32_t**>(&ram_vector_table);
-  interrupts[kHardFaultIsrNum] = exception_entry_addr;
-  interrupts[kMemFaultIsrNum] = exception_entry_addr;
-  interrupts[kBusFaultIsrNum] = exception_entry_addr;
-  interrupts[kUsageFaultIsrNum] = exception_entry_addr;
-
-  uint32_t old_vector_table = arm_v7m_vtor;
-  // Dismiss unused variable warning for non-debug builds.
-  PW_UNUSED(old_vector_table);
-
-  // Update Vector Table Offset Register (VTOR) to point to new vector table.
-  arm_v7m_vtor = reinterpret_cast<uint32_t>(&ram_vector_table);
-  EndCriticalSection(prev_state);
-}
-
-void EnableAllFaultHandlers() {
-  arm_v7m_shcsr |=
-      kMemFaultEnableMask | kBusFaultEnableMask | kUsageFaultEnableMask;
-}
-
-void Setup(bool use_fpu) {
-  if (use_fpu) {
-    EnableFpu();
-  } else {
-    DisableFpu();
-  }
-  pw_CpuExceptionSetHandler(TestingExceptionHandler);
-  EnableAllFaultHandlers();
-  InstallVectorTableEntries();
-  exceptions_handled = 0;
-  current_fault_depth = 0;
-  captured_state = {};
-  float_test_value = 0.0f;
-  trigger_nested_fault = false;
-}
-
-TEST(FaultEntry, BasicFault) {
-  Setup(/*use_fpu=*/false);
-  BeginBaseFaultTest();
-  ASSERT_EQ(exceptions_handled, 1u);
-  // captured_state values must be cast since they're in a packed struct.
-  EXPECT_EQ(static_cast<uint32_t>(captured_state.base.r0), kMagicPattern);
-  EXPECT_EQ(static_cast<uint32_t>(captured_state.base.r1), 0u);
-  // PC is manually saved in r2 before the exception occurs (where PC is also
-  // stored). Ensure these numbers are within a reasonable distance.
-  int32_t captured_pc_distance =
-      captured_state.base.pc - captured_state.base.r2;
-  EXPECT_LT(captured_pc_distance, kMaxPcDistance);
-  EXPECT_EQ(static_cast<uint32_t>(captured_state.base.r3),
-            static_cast<uint32_t>(captured_state.base.lr));
-}
-
-TEST(FaultEntry, BasicUnalignedStackFault) {
-  Setup(/*use_fpu=*/false);
-  BeginBaseFaultUnalignedStackTest();
-  ASSERT_EQ(exceptions_handled, 1u);
-  // captured_state values must be cast since they're in a packed struct.
-  EXPECT_EQ(static_cast<uint32_t>(captured_state.base.r0), kMagicPattern);
-  EXPECT_EQ(static_cast<uint32_t>(captured_state.base.r1), 0u);
-  // PC is manually saved in r2 before the exception occurs (where PC is also
-  // stored). Ensure these numbers are within a reasonable distance.
-  int32_t captured_pc_distance =
-      captured_state.base.pc - captured_state.base.r2;
-  EXPECT_LT(captured_pc_distance, kMaxPcDistance);
-  EXPECT_EQ(static_cast<uint32_t>(captured_state.base.r3),
-            static_cast<uint32_t>(captured_state.base.lr));
-}
-
-TEST(FaultEntry, ExtendedFault) {
-  Setup(/*use_fpu=*/false);
-  BeginExtendedFaultTest();
-  ASSERT_EQ(exceptions_handled, 1u);
-  ASSERT_TRUE(span_matches);
-  const ArmV7mExtraRegisters& extended_registers = captured_state.extended;
-  // captured_state values must be cast since they're in a packed struct.
-  EXPECT_EQ(static_cast<uint32_t>(extended_registers.r4), kMagicPattern);
-  EXPECT_EQ(static_cast<uint32_t>(extended_registers.r5), 0u);
-  EXPECT_EQ(static_cast<uint32_t>(extended_registers.r11), kMagicPattern);
-
-  // Check expected values for this crash.
-  EXPECT_EQ(static_cast<uint32_t>(extended_registers.cfsr),
-            static_cast<uint32_t>(kDivByZeroFaultMask));
-  EXPECT_EQ((extended_registers.icsr & 0x1FFu), kUsageFaultIsrNum);
-}
-
-TEST(FaultEntry, ExtendedUnalignedStackFault) {
-  Setup(/*use_fpu=*/false);
-  BeginExtendedFaultUnalignedStackTest();
-  ASSERT_EQ(exceptions_handled, 1u);
-  ASSERT_TRUE(span_matches);
-  const ArmV7mExtraRegisters& extended_registers = captured_state.extended;
-  // captured_state values must be cast since they're in a packed struct.
-  EXPECT_EQ(static_cast<uint32_t>(extended_registers.r4), kMagicPattern);
-  EXPECT_EQ(static_cast<uint32_t>(extended_registers.r5), 0u);
-  EXPECT_EQ(static_cast<uint32_t>(extended_registers.r11), kMagicPattern);
-
-  // Check expected values for this crash.
-  EXPECT_EQ(static_cast<uint32_t>(extended_registers.cfsr),
-            static_cast<uint32_t>(kDivByZeroFaultMask));
-  EXPECT_EQ((extended_registers.icsr & 0x1FFu), kUsageFaultIsrNum);
-}
-
-TEST(FaultEntry, NestedFault) {
-  // Due to the way nesting is handled, captured_states[0] is the nested fault
-  // since that fault must be handled *FIRST*. After that fault is handled, the
-  // original fault can be correctly handled afterwards (captured into
-  // captured_states[1]).
-
-  Setup(/*use_fpu=*/false);
-  trigger_nested_fault = true;
-  BeginBaseFaultTest();
-  ASSERT_EQ(exceptions_handled, 2u);
-
-  // captured_state values must be cast since they're in a packed struct.
-  EXPECT_EQ(static_cast<uint32_t>(captured_states[1].base.r0), kMagicPattern);
-  EXPECT_EQ(static_cast<uint32_t>(captured_states[1].base.r1), 0u);
-  // PC is manually saved in r2 before the exception occurs (where PC is also
-  // stored). Ensure these numbers are within a reasonable distance.
-  int32_t captured_pc_distance =
-      captured_states[1].base.pc - captured_states[1].base.r2;
-  EXPECT_LT(captured_pc_distance, kMaxPcDistance);
-  EXPECT_EQ(static_cast<uint32_t>(captured_states[1].base.r3),
-            static_cast<uint32_t>(captured_states[1].base.lr));
-
-  // NESTED STATE
-  // captured_state values must be cast since they're in a packed struct.
-  EXPECT_EQ(static_cast<uint32_t>(captured_states[0].base.r0),
-            kNestedMagicPattern);
-  EXPECT_EQ(static_cast<uint32_t>(captured_states[0].base.r1), 0u);
-  // PC is manually saved in r2 before the exception occurs (where PC is also
-  // stored). Ensure these numbers are within a reasonable distance.
-  captured_pc_distance =
-      captured_states[0].base.pc - captured_states[0].base.r2;
-  EXPECT_LT(captured_pc_distance, kMaxPcDistance);
-  EXPECT_EQ(static_cast<uint32_t>(captured_states[0].base.r3),
-            static_cast<uint32_t>(captured_states[0].base.lr));
-}
-
-// TODO(pwbug/17): Replace when Pigweed config system is added.
-// Disable tests that rely on hardware FPU if this module wasn't built with
-// hardware FPU support.
-#if defined(PW_ARMV7M_ENABLE_FPU) && PW_ARMV7M_ENABLE_FPU == 1
-
-// Populate some of the extended set of captured registers, then trigger
-// exception. This function uses floating point to validate float context
-// is pushed correctly.
-void BeginExtendedFaultFloatTest() {
-  float_test_value = _PW_TEST_FPU_OPERATION;
-  BeginExtendedFaultTest();
-}
-
-// Populate some of the extended set of captured registers, then trigger
-// exception.
-// This version causes stack to not be 4-byte aligned initially, testing
-// the fault handlers correction for psp.
-// This function uses floating point to validate float context
-// is pushed correctly.
-void BeginExtendedFaultUnalignedStackFloatTest() {
-  float_test_value = _PW_TEST_FPU_OPERATION;
-  BeginExtendedFaultUnalignedStackTest();
-}
-
-TEST(FaultEntry, FloatFault) {
-  Setup(/*use_fpu=*/true);
-  BeginExtendedFaultFloatTest();
-  ASSERT_EQ(exceptions_handled, 1u);
-  const ArmV7mExtraRegisters& extended_registers = captured_state.extended;
-  // captured_state values must be cast since they're in a packed struct.
-  EXPECT_EQ(static_cast<uint32_t>(extended_registers.r4), kMagicPattern);
-  EXPECT_EQ(static_cast<uint32_t>(extended_registers.r5), 0u);
-  EXPECT_EQ(static_cast<uint32_t>(extended_registers.r11), kMagicPattern);
-
-  // Check expected values for this crash.
-  EXPECT_EQ(static_cast<uint32_t>(extended_registers.cfsr),
-            static_cast<uint32_t>(kDivByZeroFaultMask));
-  EXPECT_EQ((extended_registers.icsr & 0x1FFu), kUsageFaultIsrNum);
-
-  // Check fpu state was pushed during exception
-  EXPECT_FALSE(extended_registers.exc_return & kExcReturnBasicFrameMask);
-
-  // Check float_test_value is correct
-  EXPECT_EQ(float_test_value, kFloatTestPattern);
-}
-
-TEST(FaultEntry, FloatUnalignedStackFault) {
-  Setup(/*use_fpu=*/true);
-  BeginExtendedFaultUnalignedStackFloatTest();
-  ASSERT_EQ(exceptions_handled, 1u);
-  ASSERT_TRUE(span_matches);
-  const ArmV7mExtraRegisters& extended_registers = captured_state.extended;
-  // captured_state values must be cast since they're in a packed struct.
-  EXPECT_EQ(static_cast<uint32_t>(extended_registers.r4), kMagicPattern);
-  EXPECT_EQ(static_cast<uint32_t>(extended_registers.r5), 0u);
-  EXPECT_EQ(static_cast<uint32_t>(extended_registers.r11), kMagicPattern);
-
-  // Check expected values for this crash.
-  EXPECT_EQ(static_cast<uint32_t>(extended_registers.cfsr),
-            static_cast<uint32_t>(kDivByZeroFaultMask));
-  EXPECT_EQ((extended_registers.icsr & 0x1FFu), kUsageFaultIsrNum);
-
-  // Check fpu state was pushed during exception.
-  EXPECT_FALSE(extended_registers.exc_return & kExcReturnBasicFrameMask);
-
-  // Check float_test_value is correct
-  EXPECT_EQ(float_test_value, kFloatTestPattern);
-}
-
-#endif  // defined(PW_ARMV7M_ENABLE_FPU) && PW_ARMV7M_ENABLE_FPU == 1
-
-void TestingExceptionHandler(pw_CpuExceptionState* state) {
-  if (++current_fault_depth > kMaxFaultDepth) {
-    volatile bool loop = true;
-    while (loop) {
-      // Hit unexpected nested crash, prevent further nesting.
-    }
-  }
-
-  if (trigger_nested_fault) {
-    // Disable nesting before triggering the nested fault to prevent infinite
-    // recursive crashes.
-    trigger_nested_fault = false;
-    BeginNestedFaultTest();
-  }
-
-  // Clear HFSR forced (nested) hard fault mask if set. This will only be
-  // set by the nested fault test.
-  EXPECT_EQ(state->extended.hfsr, arm_v7m_hfsr);
-  if (arm_v7m_hfsr & kForcedHardfaultMask) {
-    arm_v7m_hfsr = kForcedHardfaultMask;
-  }
-
-  if (arm_v7m_cfsr & kUnalignedFaultMask) {
-    // Copy captured state to check later.
-    std::memcpy(&captured_states[exceptions_handled],
-                state,
-                sizeof(pw_CpuExceptionState));
-
-    // Disable unaligned read/write trapping to "handle" exception.
-    arm_v7m_ccr &= ~kUnalignedTrapEnableMask;
-    arm_v7m_cfsr = kUnalignedFaultMask;
-    exceptions_handled++;
-    return;
-  } else if (arm_v7m_cfsr & kDivByZeroFaultMask) {
-    // Copy captured state to check later.
-    std::memcpy(&captured_states[exceptions_handled],
-                state,
-                sizeof(pw_CpuExceptionState));
-
-    // Ensure std::span compares to be the same.
-    std::span<const uint8_t> state_span = RawFaultingCpuState(*state);
-    EXPECT_EQ(state_span.size(), sizeof(pw_CpuExceptionState));
-    if (std::memcmp(state, state_span.data(), state_span.size()) == 0) {
-      span_matches = true;
-    } else {
-      span_matches = false;
-    }
-
-    // Disable divide-by-zero trapping to "handle" exception.
-    arm_v7m_ccr &= ~kDivByZeroTrapEnableMask;
-    arm_v7m_cfsr = kDivByZeroFaultMask;
-    exceptions_handled++;
-    return;
-  }
-
-  EXPECT_EQ(state->extended.shcsr, arm_v7m_shcsr);
-
-  // If an unexpected exception occurred, just enter an infinite loop.
-  while (true) {
-  }
-}
-
-}  // namespace
-}  // namespace pw::cpu_exception
diff --git a/pw_cpu_exception_armv7m/proto_dump.cc b/pw_cpu_exception_armv7m/proto_dump.cc
deleted file mode 100644
index 086652d..0000000
--- a/pw_cpu_exception_armv7m/proto_dump.cc
+++ /dev/null
@@ -1,64 +0,0 @@
-// 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.
-#include "pw_cpu_exception_armv7m/cpu_state.h"
-#include "pw_cpu_exception_armv7m_protos/cpu_state.pwpb.h"
-#include "pw_preprocessor/compiler.h"
-#include "pw_protobuf/encoder.h"
-
-namespace pw::cpu_exception {
-
-Status DumpCpuStateProto(protobuf::Encoder& dest,
-                         const pw_CpuExceptionState& cpu_state) {
-  armv7m::ArmV7mCpuState::Encoder state_encoder(&dest);
-
-  // Special and mem-mapped registers.
-  state_encoder.WritePc(cpu_state.base.pc);
-  state_encoder.WriteLr(cpu_state.base.lr);
-  state_encoder.WritePsr(cpu_state.base.psr);
-  state_encoder.WriteMsp(cpu_state.extended.msp);
-  state_encoder.WritePsp(cpu_state.extended.psp);
-  state_encoder.WriteExcReturn(cpu_state.extended.exc_return);
-  state_encoder.WriteCfsr(cpu_state.extended.cfsr);
-  state_encoder.WriteMmfar(cpu_state.extended.mmfar);
-  state_encoder.WriteBfar(cpu_state.extended.bfar);
-  state_encoder.WriteIcsr(cpu_state.extended.icsr);
-  state_encoder.WriteHfsr(cpu_state.extended.hfsr);
-  state_encoder.WriteShcsr(cpu_state.extended.shcsr);
-  state_encoder.WriteControl(cpu_state.extended.control);
-
-  // General purpose registers.
-  state_encoder.WriteR0(cpu_state.base.r0);
-  state_encoder.WriteR1(cpu_state.base.r1);
-  state_encoder.WriteR2(cpu_state.base.r2);
-  state_encoder.WriteR3(cpu_state.base.r3);
-  state_encoder.WriteR4(cpu_state.extended.r4);
-  state_encoder.WriteR5(cpu_state.extended.r5);
-  state_encoder.WriteR6(cpu_state.extended.r6);
-  state_encoder.WriteR7(cpu_state.extended.r7);
-  state_encoder.WriteR8(cpu_state.extended.r8);
-  state_encoder.WriteR9(cpu_state.extended.r9);
-  state_encoder.WriteR10(cpu_state.extended.r10);
-  state_encoder.WriteR11(cpu_state.extended.r11);
-
-  // If the encode buffer was exhausted in an earlier write, it will be
-  // reflected here.
-  Status status = state_encoder.WriteR12(cpu_state.base.r12);
-  if (!status.ok()) {
-    return status == Status::ResourceExhausted() ? Status::ResourceExhausted()
-                                                 : Status::Unknown();
-  }
-  return Status::Ok();
-}
-
-}  // namespace pw::cpu_exception
diff --git a/pw_cpu_exception_armv7m/public/pw_cpu_exception_armv7m/cpu_state.h b/pw_cpu_exception_armv7m/public/pw_cpu_exception_armv7m/cpu_state.h
deleted file mode 100644
index 23158c8..0000000
--- a/pw_cpu_exception_armv7m/public/pw_cpu_exception_armv7m/cpu_state.h
+++ /dev/null
@@ -1,97 +0,0 @@
-// Copyright 2019 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.
-#pragma once
-
-#include <cstdint>
-
-#include "pw_preprocessor/compiler.h"
-
-namespace pw::cpu_exception {
-
-// This is dictated by ARMv7-M architecture. Do not change.
-PW_PACKED(struct) ArmV7mFaultRegisters {
-  uint32_t r0;
-  uint32_t r1;
-  uint32_t r2;
-  uint32_t r3;
-  uint32_t r12;
-  uint32_t lr;   // Link register.
-  uint32_t pc;   // Program counter.
-  uint32_t psr;  // Program status register.
-};
-
-// This is dictated by ARMv7-M architecture. Do not change.
-PW_PACKED(struct) ArmV7mFaultRegistersFpu {
-  uint32_t s0;
-  uint32_t s1;
-  uint32_t s2;
-  uint32_t s3;
-  uint32_t s4;
-  uint32_t s5;
-  uint32_t s6;
-  uint32_t s7;
-  uint32_t s8;
-  uint32_t s9;
-  uint32_t s10;
-  uint32_t s11;
-  uint32_t s12;
-  uint32_t s13;
-  uint32_t s14;
-  uint32_t s15;
-  uint32_t fpscr;
-  uint32_t reserved;
-};
-
-// Bit in the PSR that indicates CPU added an extra word on the stack to
-// align it during context save for an exception.
-inline constexpr uint32_t kPsrExtraStackAlignBit = (1 << 9);
-
-// This is dictated by this module, and shouldn't change often.
-// Note that the order of entries in this struct is very important (as the
-// values are populated in assembly).
-//
-// NOTE: Memory mapped registers are NOT restored upon fault return!
-PW_PACKED(struct) ArmV7mExtraRegisters {
-  // Memory mapped registers.
-  uint32_t cfsr;
-  uint32_t mmfar;
-  uint32_t bfar;
-  uint32_t icsr;
-  uint32_t hfsr;
-  uint32_t shcsr;
-  // Special registers.
-  uint32_t exc_return;
-  uint32_t msp;
-  uint32_t psp;
-  uint32_t control;
-  // General purpose registers.
-  uint32_t r4;
-  uint32_t r5;
-  uint32_t r6;
-  uint32_t r7;
-  uint32_t r8;
-  uint32_t r9;
-  uint32_t r10;
-  uint32_t r11;
-};
-
-}  // namespace pw::cpu_exception
-
-PW_PACKED(struct) pw_CpuExceptionState {
-  pw::cpu_exception::ArmV7mExtraRegisters extended;
-  pw::cpu_exception::ArmV7mFaultRegisters base;
-  // TODO(amontanez): FPU registers may or may not be here as well. Make the
-  // availability of the FPU registers a compile-time configuration when FPU
-  // register support is added.
-};
diff --git a/pw_cpu_exception_armv7m/public/pw_cpu_exception_armv7m/proto_dump.h b/pw_cpu_exception_armv7m/public/pw_cpu_exception_armv7m/proto_dump.h
deleted file mode 100644
index b4d99a2..0000000
--- a/pw_cpu_exception_armv7m/public/pw_cpu_exception_armv7m/proto_dump.h
+++ /dev/null
@@ -1,33 +0,0 @@
-// 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.
-#pragma once
-
-#include "pw_cpu_exception_armv7m/cpu_state.h"
-#include "pw_protobuf/encoder.h"
-#include "pw_status/status.h"
-
-namespace pw::cpu_exception {
-
-// Dumps the cpu state struct as a proto (defined in
-// pw_cpu_exception_armv7m_protos/cpu_state.proto). The final proto is up to 144
-// bytes in size, so ensure your encoder is properly sized.
-//
-// Returns:
-//   OK - Entire proto was written to the encoder.
-//   RESOURCE_EXHAUSTED - Insufficient space to encode proto.
-//   UNKNOWN - Some other proto encoding error occurred.
-Status DumpCpuStateProto(protobuf::Encoder& dest,
-                         const pw_CpuExceptionState& cpu_state);
-
-}  // namespace pw::cpu_exception
diff --git a/pw_cpu_exception_armv7m/pw_cpu_exception_armv7m_protos/cpu_state.proto b/pw_cpu_exception_armv7m/pw_cpu_exception_armv7m_protos/cpu_state.proto
deleted file mode 100644
index a557d0e..0000000
--- a/pw_cpu_exception_armv7m/pw_cpu_exception_armv7m_protos/cpu_state.proto
+++ /dev/null
@@ -1,49 +0,0 @@
-// 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.
-syntax = "proto2";
-
-package pw.cpu_exception.armv7m;
-
-message ArmV7mCpuState {
-  optional uint32 pc = 1;
-  optional uint32 lr = 2;
-  optional uint32 psr = 3;
-  optional uint32 msp = 4;
-  optional uint32 psp = 5;
-  optional uint32 exc_return = 6;
-  optional uint32 cfsr = 7;
-  optional uint32 mmfar = 8;
-  optional uint32 bfar = 9;
-  optional uint32 icsr = 10;
-  optional uint32 hfsr = 25;
-  optional uint32 shcsr = 26;
-  optional uint32 control = 11;
-
-  // General purpose registers.
-  optional uint32 r0 = 12;
-  optional uint32 r1 = 13;
-  optional uint32 r2 = 14;
-  optional uint32 r3 = 15;
-  optional uint32 r4 = 16;
-  optional uint32 r5 = 17;
-  optional uint32 r6 = 18;
-  optional uint32 r7 = 19;
-  optional uint32 r8 = 20;
-  optional uint32 r9 = 21;
-  optional uint32 r10 = 22;
-  optional uint32 r11 = 23;
-  optional uint32 r12 = 24;
-
-  // Next tag: 27
-}
diff --git a/pw_cpu_exception_cortex_m/BUILD b/pw_cpu_exception_cortex_m/BUILD
new file mode 100644
index 0000000..a39d11c
--- /dev/null
+++ b/pw_cpu_exception_cortex_m/BUILD
@@ -0,0 +1,85 @@
+# Copyright 2021 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.
+
+load(
+    "//pw_build:pigweed.bzl",
+    "pw_cc_binary",
+    "pw_cc_library",
+    "pw_cc_test",
+)
+
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])  # Apache License 2.0
+
+pw_cc_library(
+    name = "support_armv7m",
+    includes = ["public"],
+    deps = [
+        "//pw_preprocessor",
+        "//pw_string",
+        "//pw_log",
+    ],
+    hdrs = [ "public/pw_cpu_exception_cortex_m/cpu_state.h" ],
+    srcs = [
+        "cpu_state.cc",
+        "pw_cpu_exception_cortex_m_private/cortex_m_constants.h",
+    ],
+)
+
+pw_cc_library(
+    name = "proto_dump_armv7m",
+    deps = [
+        ":support_armv7m",
+        ":cpu_state_protos",
+        "//pw_protobuf",
+        "//pw_status",
+        "//pw_stream",
+    ],
+    hdrs = ["public/pw_cpu_exception_cortex_m/proto_dump.h"],
+    srcs = ["proto_dump.cc"],
+)
+
+proto_library(
+    name = "cpu_state_protos",
+    srcs = ["pw_cpu_exception_cortex_m_protos/cpu_state.proto"],
+)
+
+# TODO(pwbug/296): The *_armv7m libraries work on ARMv8-M, but needs some minor
+# patches for complete correctness. Add *_armv8m targets that use the same files
+# but provide preprocessor defines to enable/disable architecture specific code.
+pw_cc_library(
+    name = "cpu_exception_armv7m",
+    deps = [
+        ":proto_dump_armv7m",
+        ":support_armv7m",
+        # TODO(pwbug/101): Need to add support for facades/backends to Bazel.
+        "//pw_cpu_exception",
+        "//pw_preprocessor",
+    ],
+    srcs = [
+        "entry.cc",
+        "pw_cpu_exception_cortex_m_private/cortex_m_constants.h",
+    ],
+)
+
+pw_cc_test(
+    name = "cpu_exception_entry_test",
+    srcs = [
+        "exception_entry_test.cc",
+    ],
+    deps = [
+        ":cpu_exception_armv7m",
+    ],
+)
diff --git a/pw_cpu_exception_cortex_m/BUILD.gn b/pw_cpu_exception_cortex_m/BUILD.gn
new file mode 100644
index 0000000..a0a9096
--- /dev/null
+++ b/pw_cpu_exception_cortex_m/BUILD.gn
@@ -0,0 +1,90 @@
+# Copyright 2019 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/target_types.gni")
+import("$dir_pw_cpu_exception/backend.gni")
+import("$dir_pw_docgen/docs.gni")
+import("$dir_pw_protobuf_compiler/proto.gni")
+import("$dir_pw_unit_test/test.gni")
+
+config("default_config") {
+  include_dirs = [ "public" ]
+  visibility = [ ":*" ]
+}
+
+pw_source_set("support_armv7m") {
+  public_configs = [ ":default_config" ]
+  public_deps = [
+    "$dir_pw_cpu_exception:support.facade",
+    dir_pw_preprocessor,
+    dir_pw_string,
+  ]
+  deps = [ dir_pw_log ]
+  public = [ "public/pw_cpu_exception_cortex_m/cpu_state.h" ]
+  sources = [
+    "cpu_state.cc",
+    "pw_cpu_exception_cortex_m_private/cortex_m_constants.h",
+  ]
+}
+
+pw_source_set("proto_dump_armv7m") {
+  public_deps = [
+    ":support_armv7m",
+    dir_pw_protobuf,
+    dir_pw_status,
+    dir_pw_stream,
+  ]
+  public = [ "public/pw_cpu_exception_cortex_m/proto_dump.h" ]
+  deps = [ ":cpu_state_protos.pwpb" ]
+  sources = [ "proto_dump.cc" ]
+}
+
+pw_proto_library("cpu_state_protos") {
+  sources = [ "pw_cpu_exception_cortex_m_protos/cpu_state.proto" ]
+}
+
+# TODO(pwbug/296): The *_armv7m libraries work on ARMv8-M, but needs some minor
+# patches for complete correctness. Add *_armv8m targets that use the same files
+# but provide preprocessor defines to enable/disable architecture specific code.
+pw_source_set("cpu_exception_armv7m") {
+  public_configs = [ ":default_config" ]
+  public_deps = [
+    ":proto_dump_armv7m",
+    ":support_armv7m",
+    "$dir_pw_cpu_exception:entry.facade",
+    "$dir_pw_cpu_exception:handler",
+    "$dir_pw_preprocessor",
+  ]
+  sources = [
+    "entry.cc",
+    "pw_cpu_exception_cortex_m_private/cortex_m_constants.h",
+  ]
+}
+
+pw_test_group("tests") {
+  enable_if = pw_cpu_exception_ENTRY_BACKEND ==
+              "$dir_pw_cpu_exception_cortex_m:cpu_exception_armv7m"
+  tests = [ ":cpu_exception_entry_test" ]
+}
+
+pw_test("cpu_exception_entry_test") {
+  deps = [ ":cpu_exception_armv7m" ]
+  sources = [ "exception_entry_test.cc" ]
+}
+
+pw_doc_group("docs") {
+  sources = [ "docs.rst" ]
+}
diff --git a/pw_cpu_exception_cortex_m/CMakeLists.txt b/pw_cpu_exception_cortex_m/CMakeLists.txt
new file mode 100644
index 0000000..c434a21
--- /dev/null
+++ b/pw_cpu_exception_cortex_m/CMakeLists.txt
@@ -0,0 +1,20 @@
+# 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.
+
+include($ENV{PW_ROOT}/pw_build/pigweed.cmake)
+
+pw_auto_add_simple_module(pw_cpu_exception_cortex_m
+  IMPLEMENTS_FACADE
+    pw_cpu_exception
+)
diff --git a/pw_cpu_exception_cortex_m/cpu_state.cc b/pw_cpu_exception_cortex_m/cpu_state.cc
new file mode 100644
index 0000000..7ee6e03
--- /dev/null
+++ b/pw_cpu_exception_cortex_m/cpu_state.cc
@@ -0,0 +1,250 @@
+// Copyright 2019 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.
+
+#include "pw_cpu_exception_cortex_m/cpu_state.h"
+
+#include <cinttypes>
+#include <cstdint>
+#include <span>
+
+#include "pw_cpu_exception/support.h"
+#include "pw_cpu_exception_cortex_m_private/cortex_m_constants.h"
+#include "pw_log/log.h"
+#include "pw_string/string_builder.h"
+
+// TODO(amontanez): Set up config when this module is moved to *_cortex_m.
+#ifndef PW_CPU_EXCEPTION_EXTENDED_CFSR_DUMP
+#define PW_CPU_EXCEPTION_EXTENDED_CFSR_DUMP 0
+#endif  // PW_CPU_EXCEPTION_EXTENDED_CFSR_DUMP
+
+namespace pw::cpu_exception {
+namespace {
+
+[[maybe_unused]] void AnalyzeCfsr(const uint32_t cfsr) {
+  if (cfsr == 0) {
+    return;
+  }
+
+  PW_LOG_INFO("Active CFSR fields:");
+
+  // Memory managment fault fields.
+  if (cfsr & kCfsrIaccviolMask) {
+    PW_LOG_ERROR("  IACCVIOL: MPU violation on instruction fetch");
+  }
+  if (cfsr & kCfsrDaccviolMask) {
+    PW_LOG_ERROR("  DACCVIOL: MPU violation on memory read/write");
+  }
+  if (cfsr & kCfsrMunstkerrMask) {
+    PW_LOG_ERROR("  MUNSTKERR: 'MPU violation on exception return");
+  }
+  if (cfsr & kCfsrMstkerrMask) {
+    PW_LOG_ERROR("  MSTKERR: MPU violation on exception entry");
+  }
+  if (cfsr & kCfsrMlsperrMask) {
+    PW_LOG_ERROR("  MLSPERR: MPU violation on lazy FPU state preservation");
+  }
+  if (cfsr & kCfsrMmarvalidMask) {
+    PW_LOG_ERROR("  MMARVALID: MMFAR register is valid");
+  }
+
+  // Bus fault fields.
+  if (cfsr & kCfsrIbuserrMask) {
+    PW_LOG_ERROR("  IBUSERR: Bus fault on instruction fetch");
+  }
+  if (cfsr & kCfsrPreciserrMask) {
+    PW_LOG_ERROR("  PRECISERR: Precise bus fault");
+  }
+  if (cfsr & kCfsrImpreciserrMask) {
+    PW_LOG_ERROR("  IMPRECISERR: Imprecise bus fault");
+  }
+  if (cfsr & kCfsrUnstkerrMask) {
+    PW_LOG_ERROR("  UNSTKERR: Derived bus fault on exception context save");
+  }
+  if (cfsr & kCfsrStkerrMask) {
+    PW_LOG_ERROR("  STKERR: Derived bus fault on exception context restore");
+  }
+  if (cfsr & kCfsrLsperrMask) {
+    PW_LOG_ERROR("  LSPERR: Derived bus fault on lazy FPU state preservation");
+  }
+  if (cfsr & kCfsrBfarvalidMask) {
+    PW_LOG_ERROR("  BFARVALID: BFAR register is valid");
+  }
+
+  // Usage fault fields.
+  if (cfsr & kCfsrUndefinstrMask) {
+    PW_LOG_ERROR("  UNDEFINSTR: Encountered invalid instruction");
+  }
+  if (cfsr & kCfsrInvstateMask) {
+    PW_LOG_ERROR(
+        "  INVSTATE: Attempted to execute an instruction with an invalid "
+        "Execution Program Status Register (EPSR) value");
+  }
+  if (cfsr & kCfsrInvpcMask) {
+    PW_LOG_ERROR("  INVPC: Program Counter (PC) is not legal");
+  }
+  if (cfsr & kCfsrNocpMask) {
+    PW_LOG_ERROR("  NOCP: Coprocessor disabled or not present");
+  }
+  if (cfsr & kCfsrUnalignedMask) {
+    PW_LOG_ERROR("  UNALIGNED: Unaligned memory access");
+  }
+  if (cfsr & kCfsrDivbyzeroMask) {
+    PW_LOG_ERROR("  DIVBYZERO: Division by zero");
+  }
+  // This flag is only present on ARMv8-M cores.
+  if (cfsr & kCfsrStkofMask) {
+    PW_LOG_ERROR("  STKOF: Stack overflowed");
+  }
+}
+
+void AnalyzeException(const pw_cpu_exception_State& cpu_state) {
+  // This provides a high-level assessment of the cause of the exception.
+  // These conditionals are ordered by priority to ensure the most critical
+  // issues are highlighted first. These are not mutually exclusive; a bus fault
+  // could occur during the handling of a MPU violation, causing a nested fault.
+  if (cpu_state.extended.hfsr & kHfsrForcedMask) {
+    PW_LOG_CRITICAL("Encountered a nested CPU fault (See active CFSR fields)");
+  }
+  // TODO(pwbug/296): #if this out on non-ARMv7-M builds.
+  if (cpu_state.extended.cfsr & kCfsrStkofMask) {
+    if (cpu_state.extended.exc_return & kExcReturnStackMask) {
+      PW_LOG_CRITICAL("Encountered stack overflow in thread mode");
+    } else {
+      PW_LOG_CRITICAL("Encountered main (interrupt handler) stack overflow");
+    }
+  }
+  if (cpu_state.extended.cfsr & kCfsrMemFaultMask) {
+    if (cpu_state.extended.cfsr & kCfsrMmarvalidMask) {
+      PW_LOG_CRITICAL(
+          "Encountered Memory Protection Unit (MPU) violation at 0x%08" PRIx32,
+          cpu_state.extended.mmfar);
+    } else {
+      PW_LOG_CRITICAL("Encountered Memory Protection Unit (MPU) violation");
+    }
+  }
+  if (cpu_state.extended.cfsr & kCfsrBusFaultMask) {
+    if (cpu_state.extended.cfsr & kCfsrBfarvalidMask) {
+      PW_LOG_CRITICAL("Encountered bus fault at 0x%08" PRIx32,
+                      cpu_state.extended.bfar);
+    } else {
+      PW_LOG_CRITICAL("Encountered bus fault");
+    }
+  }
+  if (cpu_state.extended.cfsr & kCfsrUsageFaultMask) {
+    PW_LOG_CRITICAL("Encountered usage fault (See active CFSR fields)");
+  }
+  if ((cpu_state.extended.icsr & kIcsrVectactiveMask) == kNmiIsrNum) {
+    PW_LOG_INFO("Encountered non-maskable interrupt (NMI)");
+  }
+#if PW_CPU_EXCEPTION_EXTENDED_CFSR_DUMP
+  AnalyzeCfsr(cpu_state.extended.cfsr);
+#endif  // PW_CPU_EXCEPTION_EXTENDED_CFSR_DUMP
+}
+}  // namespace
+
+std::span<const uint8_t> RawFaultingCpuState(
+    const pw_cpu_exception_State& cpu_state) {
+  return std::span(reinterpret_cast<const uint8_t*>(&cpu_state),
+                   sizeof(cpu_state));
+}
+
+// Using this function adds approximately 100 bytes to binary size.
+void ToString(const pw_cpu_exception_State& cpu_state,
+              const std::span<char>& dest) {
+  StringBuilder builder(dest);
+  const CortexMExceptionRegisters& base = cpu_state.base;
+  const CortexMExtraRegisters& extended = cpu_state.extended;
+
+#define _PW_FORMAT_REGISTER(state_section, name) \
+  builder.Format("%s=0x%08" PRIx32 "\n", #name, state_section.name)
+
+  // Other registers.
+  _PW_FORMAT_REGISTER(base, pc);
+  _PW_FORMAT_REGISTER(base, lr);
+  _PW_FORMAT_REGISTER(base, psr);
+  _PW_FORMAT_REGISTER(extended, msp);
+  _PW_FORMAT_REGISTER(extended, psp);
+  _PW_FORMAT_REGISTER(extended, exc_return);
+  _PW_FORMAT_REGISTER(extended, cfsr);
+  _PW_FORMAT_REGISTER(extended, mmfar);
+  _PW_FORMAT_REGISTER(extended, bfar);
+  _PW_FORMAT_REGISTER(extended, icsr);
+  _PW_FORMAT_REGISTER(extended, hfsr);
+  _PW_FORMAT_REGISTER(extended, shcsr);
+  _PW_FORMAT_REGISTER(extended, control);
+
+  // General purpose registers.
+  _PW_FORMAT_REGISTER(base, r0);
+  _PW_FORMAT_REGISTER(base, r1);
+  _PW_FORMAT_REGISTER(base, r2);
+  _PW_FORMAT_REGISTER(base, r3);
+  _PW_FORMAT_REGISTER(extended, r4);
+  _PW_FORMAT_REGISTER(extended, r5);
+  _PW_FORMAT_REGISTER(extended, r6);
+  _PW_FORMAT_REGISTER(extended, r7);
+  _PW_FORMAT_REGISTER(extended, r8);
+  _PW_FORMAT_REGISTER(extended, r9);
+  _PW_FORMAT_REGISTER(extended, r10);
+  _PW_FORMAT_REGISTER(extended, r11);
+  _PW_FORMAT_REGISTER(base, r12);
+
+#undef _PW_FORMAT_REGISTER
+}
+
+// Using this function adds approximately 100 bytes to binary size.
+void LogCpuState(const pw_cpu_exception_State& cpu_state) {
+  const CortexMExceptionRegisters& base = cpu_state.base;
+  const CortexMExtraRegisters& extended = cpu_state.extended;
+
+  AnalyzeException(cpu_state);
+
+  PW_LOG_INFO("All captured CPU registers:");
+
+#define _PW_LOG_REGISTER(state_section, name) \
+  PW_LOG_INFO("  %-10s 0x%08" PRIx32, #name, state_section.name)
+
+  // Other registers.
+  _PW_LOG_REGISTER(base, pc);
+  _PW_LOG_REGISTER(base, lr);
+  _PW_LOG_REGISTER(base, psr);
+  _PW_LOG_REGISTER(extended, msp);
+  _PW_LOG_REGISTER(extended, psp);
+  _PW_LOG_REGISTER(extended, exc_return);
+  _PW_LOG_REGISTER(extended, cfsr);
+  _PW_LOG_REGISTER(extended, mmfar);
+  _PW_LOG_REGISTER(extended, bfar);
+  _PW_LOG_REGISTER(extended, icsr);
+  _PW_LOG_REGISTER(extended, hfsr);
+  _PW_LOG_REGISTER(extended, shcsr);
+  _PW_LOG_REGISTER(extended, control);
+
+  // General purpose registers.
+  _PW_LOG_REGISTER(base, r0);
+  _PW_LOG_REGISTER(base, r1);
+  _PW_LOG_REGISTER(base, r2);
+  _PW_LOG_REGISTER(base, r3);
+  _PW_LOG_REGISTER(extended, r4);
+  _PW_LOG_REGISTER(extended, r5);
+  _PW_LOG_REGISTER(extended, r6);
+  _PW_LOG_REGISTER(extended, r7);
+  _PW_LOG_REGISTER(extended, r8);
+  _PW_LOG_REGISTER(extended, r9);
+  _PW_LOG_REGISTER(extended, r10);
+  _PW_LOG_REGISTER(extended, r11);
+  _PW_LOG_REGISTER(base, r12);
+
+#undef _PW_LOG_REGISTER
+}
+
+}  // namespace pw::cpu_exception
diff --git a/pw_cpu_exception_cortex_m/docs.rst b/pw_cpu_exception_cortex_m/docs.rst
new file mode 100644
index 0000000..2cb3064
--- /dev/null
+++ b/pw_cpu_exception_cortex_m/docs.rst
@@ -0,0 +1,144 @@
+.. _module-pw_cpu_exception_cortex_m:
+
+-------------------------
+pw_cpu_exception_cortex_m
+-------------------------
+This backend provides an ARMv7-M implementation for the CPU exception module
+frontend. See the CPU exception frontend module description for more
+information.
+
+Setup
+=====
+There are a few ways to set up the ARMv7-M exception handler so the
+application's exception handler is properly called during an exception.
+
+**1. Use existing CMSIS functions**
+  Inside of CMSIS fault handler functions, branch to ``pw_cpu_exception_Entry``.
+
+  .. code-block:: cpp
+
+    __attribute__((naked)) void HardFault_Handler(void) {
+    asm volatile(
+        " ldr r0, =pw_cpu_exception_Entry  \n"
+        " bx r0                            \n");
+    }
+
+**2. Modify a startup file**
+  Assembly startup files for some microcontrollers initialize the interrupt
+  vector table. The functions to call for fault handlers can be changed here.
+  For ARMv7-M, the fault handlers are indexes 3 to 6 of the interrupt vector
+  table. It's also may be helpful to redirect the NMI handler to the entry
+  function (if it's otherwise unused in your project).
+
+  Default:
+
+  .. code-block:: cpp
+
+    __isr_vector_table:
+      .word  __stack_start
+      .word  Reset_Handler
+      .word  NMI_Handler
+      .word  HardFault_Handler
+      .word  MemManage_Handler
+      .word  BusFault_Handler
+      .word  UsageFault_Handler
+
+  Using CPU exception module:
+
+  .. code-block:: cpp
+
+    __isr_vector_table:
+      .word  __stack_start
+      .word  Reset_Handler
+      .word  pw_cpu_exception_Entry
+      .word  pw_cpu_exception_Entry
+      .word  pw_cpu_exception_Entry
+      .word  pw_cpu_exception_Entry
+      .word  pw_cpu_exception_Entry
+
+  Note: ``__isr_vector_table`` and ``__stack_start`` are example names, and may
+  vary by platform. See your platform's assembly startup script.
+
+**3. Modify interrupt vector table at runtime**
+  Some applications may choose to modify their interrupt vector tables at
+  runtime. The ARMv7-M exception handler works with this use case (see the
+  exception_entry_test integration test), but keep in mind that your
+  application's exception handler will not be entered if an exception occurs
+  before the vector table entries are updated to point to
+  ``pw_cpu_exception_Entry``.
+
+Module Usage
+============
+For lightweight exception handlers that don't need to access
+architecture-specific registers, using the generic exception handler functions
+is preferred.
+
+However, some projects may need to explicitly access architecture-specific
+registers to attempt to recover from a CPU exception. ``pw_cpu_exception_State``
+provides access to the captured CPU state at the time of the fault. When the
+application-provided ``pw_cpu_exception_DefaultHandler()`` function returns, the
+CPU state is restored. This allows the exception handler to modify the captured
+state so that execution can safely continue.
+
+Expected Behavior
+-----------------
+In most cases, the CPU state captured by the exception handler will contain the
+ARMv7-M basic register frame in addition to an extended set of registers (see
+``cpu_state.h``). The exception to this is when the program stack pointer is in
+an MPU-protected or otherwise invalid memory region when the CPU attempts to
+push the exception register frame to it. In this situation, the PC, LR, and PSR
+registers will NOT be captured and will be marked with 0xFFFFFFFF to indicate
+they are invalid. This backend will still be able to capture all the other
+registers though.
+
+In the situation where the main stack pointer is in a memory protected or
+otherwise invalid region and fails to push CPU context, behavior is undefined.
+
+Nested Exceptions
+-----------------
+To enable nested fault handling:
+  1. Enable separate detection of usage/bus/memory faults via the SHCSR.
+  2. Decrease the priority of the memory, bus, and usage fault handlers. This
+     gives headroom for escalation.
+
+While this allows some faults to nest, it doesn't guarantee all will properly
+nest.
+
+Configuration Options
+=====================
+
+ - ``PW_CPU_EXCEPTION_EXTENDED_CFSR_DUMP``: Enable extended logging in
+   ``pw::cpu_exception::LogCpuState()`` that dumps the active CFSR fields with
+   help strings. This is disabled by default since it increases the binary size
+   by >1.5KB when using plain-text logs, or ~460 Bytes when using tokenized
+   logging. It's useful to enable this for device bringup until your application
+   has an end-to-end crash reporting solution.
+
+Exception Analysis
+==================
+This module provides Python tooling to analyze CPU state captured by a Cortex-M
+core during an exception. This can be useful as part of a crash report analyzer.
+
+CFSR decoder
+------------
+The ARMv7-M and ARMv8-M architectures have a Configurable Fault Status Register
+(CFSR) that explains what illegal behavior caused a fault. This module provides
+a simple command-line tool to decode CFSR contents (e.g. 0x00010000) as
+human-readable information (e.g. "Encountered invalid instruction").
+
+For example:
+
+  .. code-block::
+
+    $ python -m pw_cpu_exception_cortex_m.cfsr_decoder 0x00010100
+    20210412 15:11:14 INF Exception caused by a usage fault, bus fault.
+
+    Active Crash Fault Status Register (CFSR) fields:
+    IBUSERR     Bus fault on instruction fetch.
+    UNDEFINSTR  Encountered invalid instruction.
+
+    All registers:
+    cfsr       0x00010100
+
+.. note::
+  The CFSR is not supported on ARMv6-M CPUs (Cortex M0, M0+, M1).
diff --git a/pw_cpu_exception_cortex_m/entry.cc b/pw_cpu_exception_cortex_m/entry.cc
new file mode 100644
index 0000000..97925f5
--- /dev/null
+++ b/pw_cpu_exception_cortex_m/entry.cc
@@ -0,0 +1,287 @@
+// Copyright 2019 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.
+
+#include "pw_cpu_exception/entry.h"
+
+#include <cstdint>
+#include <cstring>
+
+#include "pw_cpu_exception/handler.h"
+#include "pw_cpu_exception_cortex_m/cpu_state.h"
+#include "pw_cpu_exception_cortex_m_private/cortex_m_constants.h"
+#include "pw_preprocessor/compiler.h"
+
+// TODO(pwbug/311): Deprecated naming.
+PW_EXTERN_C PW_NO_PROLOGUE __attribute__((alias("pw_cpu_exception_Entry"))) void
+pw_CpuExceptionEntry(void);
+
+namespace pw::cpu_exception {
+namespace {
+
+// If the CPU fails to capture some registers, the captured struct members will
+// be populated with this value. The only registers that this value should be
+// loaded into are pc, lr, and psr when the CPU fails to push an exception
+// context frame.
+//
+// 0xFFFFFFFF is an illegal lr value, which is why it was selected for this
+// purpose. pc and psr values of 0xFFFFFFFF are dubious too, so this constant
+// is clear enough at expressing that the registers weren't properly captured.
+constexpr uint32_t kInvalidRegisterValue = 0xFFFFFFFF;
+
+// Checks exc_return in the captured CPU state to determine which stack pointer
+// was in use prior to entering the exception handler.
+bool PspWasActive(const pw_cpu_exception_State& cpu_state) {
+  return cpu_state.extended.exc_return & kExcReturnStackMask;
+}
+
+// Checks exc_return to determine if FPU state was pushed to the stack in
+// addition to the base CPU context frame.
+bool FpuStateWasPushed(const pw_cpu_exception_State& cpu_state) {
+  return !(cpu_state.extended.exc_return & kExcReturnBasicFrameMask);
+}
+
+// If the CPU successfully pushed context on exception, copy it into cpu_state.
+//
+// For more information see (See ARMv7-M Section B1.5.11, derived exceptions
+// on exception entry).
+void CloneBaseRegistersFromPsp(pw_cpu_exception_State* cpu_state) {
+  // If CPU succeeded in pushing context to PSP, copy it to the MSP.
+  if (!(cpu_state->extended.cfsr & kCfsrStkerrMask) &&
+      !(cpu_state->extended.cfsr & kCfsrMstkerrMask)) {
+    // TODO(amontanez): {r0-r3,r12} are captured in pw_cpu_exception_Entry(),
+    //                  so this only really needs to copy pc, lr, and psr. Could
+    //                  (possibly) improve speed, but would add marginally more
+    //                  complexity.
+    std::memcpy(&cpu_state->base,
+                reinterpret_cast<void*>(cpu_state->extended.psp),
+                sizeof(CortexMExceptionRegisters));
+  } else {
+    // If CPU context wasn't pushed to stack on exception entry, we can't
+    // recover psr, lr, and pc from exception-time. Make these values clearly
+    // invalid.
+    cpu_state->base.lr = kInvalidRegisterValue;
+    cpu_state->base.pc = kInvalidRegisterValue;
+    cpu_state->base.psr = kInvalidRegisterValue;
+  }
+}
+
+// If the CPU successfully pushed context on exception, restore it from
+// cpu_state. Otherwise, don't attempt to restore state.
+//
+// For more information see (See ARMv7-M Section B1.5.11, derived exceptions
+// on exception entry).
+void RestoreBaseRegistersToPsp(pw_cpu_exception_State* cpu_state) {
+  // If CPU succeeded in pushing context to PSP on exception entry, restore the
+  // contents of cpu_state to the CPU-pushed register frame so the CPU can
+  // continue. Otherwise, don't attempt as we'll likely end up in an escalated
+  // hard fault.
+  if (!(cpu_state->extended.cfsr & kCfsrStkerrMask) &&
+      !(cpu_state->extended.cfsr & kCfsrMstkerrMask)) {
+    std::memcpy(reinterpret_cast<void*>(cpu_state->extended.psp),
+                &cpu_state->base,
+                sizeof(CortexMExceptionRegisters));
+  }
+}
+
+// Determines the size of the CPU-pushed context frame.
+uint32_t CpuContextSize(const pw_cpu_exception_State& cpu_state) {
+  uint32_t cpu_context_size = sizeof(CortexMExceptionRegisters);
+  if (FpuStateWasPushed(cpu_state)) {
+    cpu_context_size += sizeof(CortexMExceptionRegistersFpu);
+  }
+  if (cpu_state.base.psr & kPsrExtraStackAlignBit) {
+    // Account for the extra 4-bytes the processor
+    // added to keep the stack pointer 8-byte aligned
+    cpu_context_size += 4;
+  }
+
+  return cpu_context_size;
+}
+
+// On exception entry, the Program Stack Pointer is patched to reflect the state
+// at exception-time. On exception return, it is restored to the appropriate
+// location. This calculates the delta that is used for these patch operations.
+uint32_t CalculatePspDelta(const pw_cpu_exception_State& cpu_state) {
+  // If CPU context was not pushed to program stack (because program stack
+  // wasn't in use, or an error occurred when pushing context), the PSP doesn't
+  // need to be shifted.
+  if (!PspWasActive(cpu_state) || (cpu_state.extended.cfsr & kCfsrStkerrMask) ||
+      (cpu_state.extended.cfsr & kCfsrMstkerrMask)) {
+    return 0;
+  }
+
+  return CpuContextSize(cpu_state);
+}
+
+// On exception entry, the Main Stack Pointer is patched to reflect the state
+// at exception-time. On exception return, it is restored to the appropriate
+// location. This calculates the delta that is used for these patch operations.
+uint32_t CalculateMspDelta(const pw_cpu_exception_State& cpu_state) {
+  if (PspWasActive(cpu_state)) {
+    // TODO(amontanez): Since FPU state isn't captured at this time, we ignore
+    //                  it when patching MSP. To add FPU capture support,
+    //                  delete this if block as CpuContextSize() will include
+    //                  FPU context size in the calculation.
+    return sizeof(CortexMExceptionRegisters) + sizeof(CortexMExtraRegisters);
+  }
+
+  return CpuContextSize(cpu_state) + sizeof(CortexMExtraRegisters);
+}
+
+}  // namespace
+
+extern "C" {
+
+// Collect remaining CPU state (memory mapped registers), populate memory mapped
+// registers, and call application exception handler.
+PW_USED void pw_PackageAndHandleCpuException(
+    pw_cpu_exception_State* cpu_state) {
+  // Capture memory mapped registers.
+  cpu_state->extended.cfsr = cortex_m_cfsr;
+  cpu_state->extended.mmfar = cortex_m_mmfar;
+  cpu_state->extended.bfar = cortex_m_bfar;
+  cpu_state->extended.icsr = cortex_m_icsr;
+  cpu_state->extended.hfsr = cortex_m_hfsr;
+  cpu_state->extended.shcsr = cortex_m_shcsr;
+
+  // CPU may have automatically pushed state to the program stack. If it did,
+  // the values can be copied into in the pw_cpu_exception_State struct that is
+  // passed to HandleCpuException(). The cpu_state passed to the handler is
+  // ALWAYS stored on the main stack (MSP).
+  if (PspWasActive(*cpu_state)) {
+    CloneBaseRegistersFromPsp(cpu_state);
+    // If PSP wasn't active, this delta is 0.
+    cpu_state->extended.psp += CalculatePspDelta(*cpu_state);
+  }
+
+  // Patch captured stack pointers so they reflect the state at exception time.
+  cpu_state->extended.msp += CalculateMspDelta(*cpu_state);
+
+  // Call application-level exception handler.
+  pw_cpu_exception_HandleException(cpu_state);
+
+  // Restore program stack pointer so exception return can restore state if
+  // needed.
+  // Note: The default behavior of NOT subtracting a delta from MSP is
+  // intentional. This simplifies the assembly to pop the exception state
+  // off the main stack on exception return (since MSP currently reflects
+  // exception-time state).
+  cpu_state->extended.psp -= CalculatePspDelta(*cpu_state);
+
+  // If PSP was active and the CPU pushed a context frame, we must copy the
+  // potentially modified state from cpu_state back to the PSP so the CPU can
+  // resume execution with the modified values.
+  if (PspWasActive(*cpu_state)) {
+    // In this case, there's no need to touch the MSP as it's at the location
+    // before we entering the exception (effectively popping the state initially
+    // pushed to the main stack).
+    RestoreBaseRegistersToPsp(cpu_state);
+  } else {
+    // Since we're restoring context from MSP, we DO need to adjust MSP to point
+    // to CPU-pushed context frame so it can be properly restored.
+    // No need to adjust PSP since nothing was pushed to program stack.
+    cpu_state->extended.msp -= CpuContextSize(*cpu_state);
+  }
+}
+
+// Captures faulting CPU state on the main stack (MSP), then calls the exception
+// handlers.
+// This function should be called immediately after an exception.
+void pw_cpu_exception_Entry(void) {
+  asm volatile(
+      // clang-format off
+      // If PSP was in use at the time of exception, it's possible the CPU
+      // wasn't able to push CPU state. To be safe, this first captures scratch
+      // registers before moving forward.
+      //
+      // Stack flag is bit index 2 (0x4) of exc_return value stored in lr. When
+      // this bit is set, the Process Stack Pointer (PSP) was in use. Otherwise,
+      // the Main Stack Pointer (MSP) was in use. (See ARMv7-M Section B1.5.8
+      // for more details)
+      // The following block of assembly is equivalent to:
+      //   if (lr & (1 << 2)) {
+      //     msp -= sizeof(CortexMExceptionRegisters);
+      //     CortexMExceptionRegisters* state =
+      //         (CortexMExceptionRegisters*) msp;
+      //     state->r0 = r0;
+      //     state->r1 = r1;
+      //     state->r2 = r2;
+      //     state->r3 = r3;
+      //     state->r12 = r12;
+      //   }
+      //
+      " tst lr, #(1 << 2)                                     \n"
+      " itt ne                                                \n"
+      " subne sp, sp, %[base_state_size]                      \n"
+      " stmne sp, {r0-r3, r12}                                \n"
+
+      // Reserve stack space for additional registers. Since we're in exception
+      // handler mode, the main stack pointer is currently in use.
+      // r0 will temporarily store the end of captured_cpu_state to simplify
+      // assembly for copying additional registers.
+      " mrs r0, msp                                           \n"
+      " sub sp, sp, %[extra_state_size]                       \n"
+
+      // Store GPRs to stack.
+      " stmdb r0!, {r4-r11}                                   \n"
+
+      // Load special registers.
+      " mov r1, lr                                            \n"
+      " mrs r2, msp                                           \n"
+      " mrs r3, psp                                           \n"
+      " mrs r4, control                                       \n"
+
+      // Store special registers to stack.
+      " stmdb r0!, {r1-r4}                                    \n"
+
+      // Store a pointer to the beginning of special registers in r4 so they can
+      // be restored later.
+      " mov r4, r0                                            \n"
+
+      // Restore captured_cpu_state pointer to r0. This makes adding more
+      // memory mapped registers easier in the future since they're skipped in
+      // this assembly.
+      " mrs r0, msp                                           \n"
+
+      // Call intermediate handler that packages data.
+      " ldr r3, =pw_PackageAndHandleCpuException              \n"
+      " blx r3                                                \n"
+
+      // Restore state and exit exception handler.
+      // Pointer to saved CPU state was stored in r4.
+      " mov r0, r4                                            \n"
+
+      // Restore special registers.
+      " ldm r0!, {r1-r4}                                      \n"
+      " mov lr, r1                                            \n"
+      " msr control, r4                                       \n"
+
+      // Restore GPRs.
+      " ldm r0, {r4-r11}                                      \n"
+
+      // Restore stack pointers.
+      " msr msp, r2                                           \n"
+      " msr psp, r3                                           \n"
+
+      // Exit exception.
+      " bx lr                                                 \n"
+      : /*output=*/
+      : /*input=*/[base_state_size]"i"(sizeof(CortexMExceptionRegisters)),
+                  [extra_state_size]"i"(sizeof(CortexMExtraRegisters))
+      // clang-format on
+  );
+}
+
+}  // extern "C"
+}  // namespace pw::cpu_exception
diff --git a/pw_cpu_exception_cortex_m/exception_entry_test.cc b/pw_cpu_exception_cortex_m/exception_entry_test.cc
new file mode 100644
index 0000000..0cd21da
--- /dev/null
+++ b/pw_cpu_exception_cortex_m/exception_entry_test.cc
@@ -0,0 +1,621 @@
+// Copyright 2019 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.
+
+#include <cstdint>
+#include <span>
+#include <type_traits>
+
+#include "gtest/gtest.h"
+#include "pw_cpu_exception/entry.h"
+#include "pw_cpu_exception/handler.h"
+#include "pw_cpu_exception/support.h"
+#include "pw_cpu_exception_cortex_m/cpu_state.h"
+
+namespace pw::cpu_exception {
+namespace {
+
+// CMSIS/Cortex-M/ARMv7 related constants.
+// These values are from the ARMv7-M Architecture Reference Manual DDI 0403E.b.
+// https://static.docs.arm.com/ddi0403/e/DDI0403E_B_armv7m_arm.pdf
+
+// Exception ISR number. (ARMv7-M Section B1.5.2)
+constexpr uint32_t kHardFaultIsrNum = 0x3u;
+constexpr uint32_t kMemFaultIsrNum = 0x4u;
+constexpr uint32_t kBusFaultIsrNum = 0x5u;
+constexpr uint32_t kUsageFaultIsrNum = 0x6u;
+
+// Masks for individual bits of HFSR. (ARMv7-M Section B3.2.16)
+constexpr uint32_t kForcedHardfaultMask = 0x1u << 30;
+
+// Masks for individual bits of CFSR. (ARMv7-M Section B3.2.15)
+constexpr uint32_t kUsageFaultStart = 0x1u << 16;
+constexpr uint32_t kUnalignedFaultMask = kUsageFaultStart << 8;
+constexpr uint32_t kDivByZeroFaultMask = kUsageFaultStart << 9;
+
+// CCR flags. (ARMv7-M Section B3.2.8)
+constexpr uint32_t kUnalignedTrapEnableMask = 0x1u << 3;
+constexpr uint32_t kDivByZeroTrapEnableMask = 0x1u << 4;
+
+// Masks for individual bits of SHCSR. (ARMv7-M Section B3.2.13)
+constexpr uint32_t kMemFaultEnableMask = 0x1 << 16;
+constexpr uint32_t kBusFaultEnableMask = 0x1 << 17;
+constexpr uint32_t kUsageFaultEnableMask = 0x1 << 18;
+
+// Bit masks for an exception return value. (ARMv7-M Section B1.5.8)
+constexpr uint32_t kExcReturnBasicFrameMask = (0x1u << 4);
+
+// CPCAR mask that enables FPU. (ARMv7-M Section B3.2.20)
+constexpr uint32_t kFpuEnableMask = (0xFu << 20);
+
+// Memory mapped registers. (ARMv7-M Section B3.2.2, Table B3-4)
+volatile uint32_t& cortex_m_vtor =
+    *reinterpret_cast<volatile uint32_t*>(0xE000ED08u);
+volatile uint32_t& cortex_m_ccr =
+    *reinterpret_cast<volatile uint32_t*>(0xE000ED14u);
+volatile uint32_t& cortex_m_shcsr =
+    *reinterpret_cast<volatile uint32_t*>(0xE000ED24u);
+volatile uint32_t& cortex_m_cfsr =
+    *reinterpret_cast<volatile uint32_t*>(0xE000ED28u);
+volatile uint32_t& cortex_m_hfsr =
+    *reinterpret_cast<volatile uint32_t*>(0xE000ED2Cu);
+volatile uint32_t& cortex_m_cpacr =
+    *reinterpret_cast<volatile uint32_t*>(0xE000ED88u);
+
+// Begin a critical section that must not be interrupted.
+// This function disables interrupts to prevent any sort of context switch until
+// the critical section ends. This is done by setting PRIMASK to 1 using the cps
+// instruction.
+//
+// Returns the state of PRIMASK before it was disabled.
+inline uint32_t BeginCriticalSection() {
+  uint32_t previous_state;
+  asm volatile(
+      " mrs %[previous_state], primask              \n"
+      " cpsid i                                     \n"
+      // clang-format off
+      : /*output=*/[previous_state]"=r"(previous_state)
+      : /*input=*/
+      : /*clobbers=*/"memory"
+      // clang-format on
+  );
+  return previous_state;
+}
+
+// Ends a critical section.
+// Restore previous previous state produced by BeginCriticalSection().
+// Note: This does not always re-enable interrupts.
+inline void EndCriticalSection(uint32_t previous_state) {
+  asm volatile(
+      // clang-format off
+      "msr primask, %0"
+      : /*output=*/
+      : /*input=*/"r"(previous_state)
+      : /*clobbers=*/"memory"
+      // clang-format on
+  );
+}
+
+void EnableFpu() {
+#if defined(PW_ARMV7M_ENABLE_FPU) && PW_ARMV7M_ENABLE_FPU == 1
+  // TODO(pwbug/17): Replace when Pigweed config system is added.
+  cortex_m_cpacr |= kFpuEnableMask;
+#endif  // defined(PW_ARMV7M_ENABLE_FPU) && PW_ARMV7M_ENABLE_FPU == 1
+}
+
+void DisableFpu() {
+#if defined(PW_ARMV7M_ENABLE_FPU) && PW_ARMV7M_ENABLE_FPU == 1
+  // TODO(pwbug/17): Replace when Pigweed config system is added.
+  cortex_m_cpacr &= ~kFpuEnableMask;
+#endif  // defined(PW_ARMV7M_ENABLE_FPU) && PW_ARMV7M_ENABLE_FPU == 1
+}
+
+// Counter that is incremented if the test's exception handler correctly handles
+// a triggered exception.
+size_t exceptions_handled = 0;
+
+// Global variable that triggers a single nested fault on a fault.
+bool trigger_nested_fault = false;
+
+// Allow up to kMaxFaultDepth faults before determining the device is
+// unrecoverable.
+constexpr size_t kMaxFaultDepth = 2;
+
+// Variable to prevent more than kMaxFaultDepth nested crashes.
+size_t current_fault_depth = 0;
+
+// Faulting pw_cpu_exception_State is copied here so values can be validated
+// after exiting exception handler.
+pw_cpu_exception_State captured_states[kMaxFaultDepth] = {};
+pw_cpu_exception_State& captured_state = captured_states[0];
+
+// Flag used to check if the contents of std::span matches the captured state.
+bool span_matches = false;
+
+// Variable to be manipulated by function that uses floating
+// point to test that exceptions push Fpu state correctly.
+// Note: don't use double because a cortex-m4f with fpv4-sp-d16
+// will result in gcc generating code to use the software floating
+// point support for double.
+volatile float float_test_value;
+
+// Magic pattern to help identify if the exception handler's
+// pw_cpu_exception_State pointer was pointing to captured CPU state that was
+// pushed onto the stack when the faulting context uses the VFP. Has to be
+// computed at runtime because it uses values only available at link time.
+const float kFloatTestPattern = 12.345f * 67.89f;
+
+volatile float fpu_lhs_val = 12.345f;
+volatile float fpu_rhs_val = 67.89f;
+
+// This macro provides a calculation that equals kFloatTestPattern.
+#define _PW_TEST_FPU_OPERATION (fpu_lhs_val * fpu_rhs_val)
+
+// Magic pattern to help identify if the exception handler's
+// pw_cpu_exception_State pointer was pointing to captured CPU state that was
+// pushed onto the stack.
+constexpr uint32_t kMagicPattern = 0xDEADBEEF;
+
+// This pattern serves a purpose similar to kMagicPattern, but is used for
+// testing a nested fault to ensure both pw_cpu_exception_State objects are
+// correctly captured.
+constexpr uint32_t kNestedMagicPattern = 0x900DF00D;
+
+// The manually captured PC won't be the exact same as the faulting PC. This is
+// the maximum tolerated distance between the two to allow the test to pass.
+constexpr int32_t kMaxPcDistance = 4;
+
+// In-memory interrupt service routine vector table.
+using InterruptVectorTable = std::aligned_storage_t<512, 512>;
+InterruptVectorTable ram_vector_table;
+
+// Forward declaration of the exception handler.
+void TestingExceptionHandler(pw_cpu_exception_State*);
+
+// Populate the device's registers with testable values, then trigger exception.
+void BeginBaseFaultTest() {
+  // Make sure divide by zero causes a fault.
+  cortex_m_ccr |= kDivByZeroTrapEnableMask;
+  uint32_t magic = kMagicPattern;
+  asm volatile(
+      " mov r0, %[magic]                                      \n"
+      " mov r1, #0                                            \n"
+      " mov r2, pc                                            \n"
+      " mov r3, lr                                            \n"
+      // This instruction divides by zero.
+      " udiv r1, r1, r1                                       \n"
+      // clang-format off
+      : /*output=*/
+      : /*input=*/[magic]"r"(magic)
+      : /*clobbers=*/"r0", "r1", "r2", "r3"
+      // clang-format on
+  );
+
+  // Check that the stack align bit was not set.
+  EXPECT_EQ(captured_state.base.psr & kPsrExtraStackAlignBit, 0u);
+}
+
+// Populate the device's registers with testable values, then trigger exception.
+void BeginNestedFaultTest() {
+  // Make sure divide by zero causes a fault.
+  cortex_m_ccr |= kUnalignedTrapEnableMask;
+  volatile uint32_t magic = kNestedMagicPattern;
+  asm volatile(
+      " mov r0, %[magic]                                      \n"
+      " mov r1, #0                                            \n"
+      " mov r2, pc                                            \n"
+      " mov r3, lr                                            \n"
+      // This instruction does an unaligned read.
+      " ldrh r1, [%[magic_addr], 1]                           \n"
+      // clang-format off
+      : /*output=*/
+      : /*input=*/[magic]"r"(magic), [magic_addr]"r"(&magic)
+      : /*clobbers=*/"r0", "r1", "r2", "r3"
+      // clang-format on
+  );
+}
+
+// Populate the device's registers with testable values, then trigger exception.
+// This version causes stack to not be 4-byte aligned initially, testing
+// the fault handlers correction for psp.
+void BeginBaseFaultUnalignedStackTest() {
+  // Make sure divide by zero causes a fault.
+  cortex_m_ccr |= kDivByZeroTrapEnableMask;
+  uint32_t magic = kMagicPattern;
+  asm volatile(
+      // Push one register to cause $sp to be no longer 8-byte aligned,
+      // assuming it started 8-byte aligned as expected.
+      " push {r0}                                             \n"
+      " mov r0, %[magic]                                      \n"
+      " mov r1, #0                                            \n"
+      " mov r2, pc                                            \n"
+      " mov r3, lr                                            \n"
+      // This instruction divides by zero. Our fault handler should
+      // ultimately advance the pc to the pop instruction.
+      " udiv r1, r1, r1                                       \n"
+      " pop {r0}                                              \n"
+      // clang-format off
+      : /*output=*/
+      : /*input=*/[magic]"r"(magic)
+      : /*clobbers=*/"r0", "r1", "r2", "r3"
+      // clang-format on
+  );
+
+  // Check that the stack align bit was set.
+  EXPECT_EQ(captured_state.base.psr & kPsrExtraStackAlignBit,
+            kPsrExtraStackAlignBit);
+}
+
+// Populate some of the extended set of captured registers, then trigger
+// exception.
+void BeginExtendedFaultTest() {
+  // Make sure divide by zero causes a fault.
+  cortex_m_ccr |= kDivByZeroTrapEnableMask;
+  uint32_t magic = kMagicPattern;
+  volatile uint32_t local_msp = 0;
+  volatile uint32_t local_psp = 0;
+  asm volatile(
+      " mov r4, %[magic]                                      \n"
+      " mov r5, #0                                            \n"
+      " mov r11, %[magic]                                     \n"
+      " mrs %[local_msp], msp                                 \n"
+      " mrs %[local_psp], psp                                 \n"
+      // This instruction divides by zero.
+      " udiv r5, r5, r5                                       \n"
+      // clang-format off
+      : /*output=*/[local_msp]"=r"(local_msp), [local_psp]"=r"(local_psp)
+      : /*input=*/[magic]"r"(magic)
+      : /*clobbers=*/"r0", "r4", "r5", "r11", "memory"
+      // clang-format on
+  );
+
+  // Check that the stack align bit was not set.
+  EXPECT_EQ(captured_state.base.psr & kPsrExtraStackAlignBit, 0u);
+
+  // Check that the captured stack pointers matched the ones in the context of
+  // the fault.
+  EXPECT_EQ(static_cast<uint32_t>(captured_state.extended.msp), local_msp);
+  EXPECT_EQ(static_cast<uint32_t>(captured_state.extended.psp), local_psp);
+}
+
+// Populate some of the extended set of captured registers, then trigger
+// exception.
+// This version causes stack to not be 4-byte aligned initially, testing
+// the fault handlers correction for psp.
+void BeginExtendedFaultUnalignedStackTest() {
+  // Make sure divide by zero causes a fault.
+  cortex_m_ccr |= kDivByZeroTrapEnableMask;
+  uint32_t magic = kMagicPattern;
+  volatile uint32_t local_msp = 0;
+  volatile uint32_t local_psp = 0;
+  asm volatile(
+      // Push one register to cause $sp to be no longer 8-byte aligned,
+      // assuming it started 8-byte aligned as expected.
+      " push {r0}                                             \n"
+      " mov r4, %[magic]                                      \n"
+      " mov r5, #0                                            \n"
+      " mov r11, %[magic]                                     \n"
+      " mrs %[local_msp], msp                                 \n"
+      " mrs %[local_psp], psp                                 \n"
+      // This instruction divides by zero. Our fault handler should
+      // ultimately advance the pc to the pop instruction.
+      " udiv r5, r5, r5                                       \n"
+      " pop {r0}                                              \n"
+      // clang-format off
+      : /*output=*/[local_msp]"=r"(local_msp), [local_psp]"=r"(local_psp)
+      : /*input=*/[magic]"r"(magic)
+      : /*clobbers=*/"r0", "r4", "r5", "r11", "memory"
+      // clang-format on
+  );
+
+  // Check that the stack align bit was set.
+  EXPECT_EQ(captured_state.base.psr & kPsrExtraStackAlignBit,
+            kPsrExtraStackAlignBit);
+
+  // Check that the captured stack pointers matched the ones in the context of
+  // the fault.
+  EXPECT_EQ(static_cast<uint32_t>(captured_state.extended.msp), local_msp);
+  EXPECT_EQ(static_cast<uint32_t>(captured_state.extended.psp), local_psp);
+}
+
+void InstallVectorTableEntries() {
+  uint32_t prev_state = BeginCriticalSection();
+  // If vector table is installed already, this is done.
+  if (cortex_m_vtor == reinterpret_cast<uint32_t>(&ram_vector_table)) {
+    EndCriticalSection(prev_state);
+    return;
+  }
+  // Copy table to new location since it's not guaranteed that we can write to
+  // the original one.
+  std::memcpy(&ram_vector_table,
+              reinterpret_cast<uint32_t*>(cortex_m_vtor),
+              sizeof(ram_vector_table));
+
+  // Override exception handling vector table entries.
+  uint32_t* exception_entry_addr =
+      reinterpret_cast<uint32_t*>(pw_cpu_exception_Entry);
+  uint32_t** interrupts = reinterpret_cast<uint32_t**>(&ram_vector_table);
+  interrupts[kHardFaultIsrNum] = exception_entry_addr;
+  interrupts[kMemFaultIsrNum] = exception_entry_addr;
+  interrupts[kBusFaultIsrNum] = exception_entry_addr;
+  interrupts[kUsageFaultIsrNum] = exception_entry_addr;
+
+  // Update Vector Table Offset Register (VTOR) to point to new vector table.
+  cortex_m_vtor = reinterpret_cast<uint32_t>(&ram_vector_table);
+  EndCriticalSection(prev_state);
+}
+
+void EnableAllFaultHandlers() {
+  cortex_m_shcsr |=
+      kMemFaultEnableMask | kBusFaultEnableMask | kUsageFaultEnableMask;
+}
+
+void Setup(bool use_fpu) {
+  if (use_fpu) {
+    EnableFpu();
+  } else {
+    DisableFpu();
+  }
+  pw_cpu_exception_SetHandler(TestingExceptionHandler);
+  EnableAllFaultHandlers();
+  InstallVectorTableEntries();
+  exceptions_handled = 0;
+  current_fault_depth = 0;
+  captured_state = {};
+  float_test_value = 0.0f;
+  trigger_nested_fault = false;
+}
+
+TEST(FaultEntry, BasicFault) {
+  Setup(/*use_fpu=*/false);
+  BeginBaseFaultTest();
+  ASSERT_EQ(exceptions_handled, 1u);
+  // captured_state values must be cast since they're in a packed struct.
+  EXPECT_EQ(static_cast<uint32_t>(captured_state.base.r0), kMagicPattern);
+  EXPECT_EQ(static_cast<uint32_t>(captured_state.base.r1), 0u);
+  // PC is manually saved in r2 before the exception occurs (where PC is also
+  // stored). Ensure these numbers are within a reasonable distance.
+  int32_t captured_pc_distance =
+      captured_state.base.pc - captured_state.base.r2;
+  EXPECT_LT(captured_pc_distance, kMaxPcDistance);
+  EXPECT_EQ(static_cast<uint32_t>(captured_state.base.r3),
+            static_cast<uint32_t>(captured_state.base.lr));
+}
+
+TEST(FaultEntry, BasicUnalignedStackFault) {
+  Setup(/*use_fpu=*/false);
+  BeginBaseFaultUnalignedStackTest();
+  ASSERT_EQ(exceptions_handled, 1u);
+  // captured_state values must be cast since they're in a packed struct.
+  EXPECT_EQ(static_cast<uint32_t>(captured_state.base.r0), kMagicPattern);
+  EXPECT_EQ(static_cast<uint32_t>(captured_state.base.r1), 0u);
+  // PC is manually saved in r2 before the exception occurs (where PC is also
+  // stored). Ensure these numbers are within a reasonable distance.
+  int32_t captured_pc_distance =
+      captured_state.base.pc - captured_state.base.r2;
+  EXPECT_LT(captured_pc_distance, kMaxPcDistance);
+  EXPECT_EQ(static_cast<uint32_t>(captured_state.base.r3),
+            static_cast<uint32_t>(captured_state.base.lr));
+}
+
+TEST(FaultEntry, ExtendedFault) {
+  Setup(/*use_fpu=*/false);
+  BeginExtendedFaultTest();
+  ASSERT_EQ(exceptions_handled, 1u);
+  ASSERT_TRUE(span_matches);
+  const CortexMExtraRegisters& extended_registers = captured_state.extended;
+  // captured_state values must be cast since they're in a packed struct.
+  EXPECT_EQ(static_cast<uint32_t>(extended_registers.r4), kMagicPattern);
+  EXPECT_EQ(static_cast<uint32_t>(extended_registers.r5), 0u);
+  EXPECT_EQ(static_cast<uint32_t>(extended_registers.r11), kMagicPattern);
+
+  // Check expected values for this crash.
+  EXPECT_EQ(static_cast<uint32_t>(extended_registers.cfsr),
+            static_cast<uint32_t>(kDivByZeroFaultMask));
+  EXPECT_EQ((extended_registers.icsr & 0x1FFu), kUsageFaultIsrNum);
+}
+
+TEST(FaultEntry, ExtendedUnalignedStackFault) {
+  Setup(/*use_fpu=*/false);
+  BeginExtendedFaultUnalignedStackTest();
+  ASSERT_EQ(exceptions_handled, 1u);
+  ASSERT_TRUE(span_matches);
+  const CortexMExtraRegisters& extended_registers = captured_state.extended;
+  // captured_state values must be cast since they're in a packed struct.
+  EXPECT_EQ(static_cast<uint32_t>(extended_registers.r4), kMagicPattern);
+  EXPECT_EQ(static_cast<uint32_t>(extended_registers.r5), 0u);
+  EXPECT_EQ(static_cast<uint32_t>(extended_registers.r11), kMagicPattern);
+
+  // Check expected values for this crash.
+  EXPECT_EQ(static_cast<uint32_t>(extended_registers.cfsr),
+            static_cast<uint32_t>(kDivByZeroFaultMask));
+  EXPECT_EQ((extended_registers.icsr & 0x1FFu), kUsageFaultIsrNum);
+}
+
+TEST(FaultEntry, NestedFault) {
+  // Due to the way nesting is handled, captured_states[0] is the nested fault
+  // since that fault must be handled *FIRST*. After that fault is handled, the
+  // original fault can be correctly handled afterwards (captured into
+  // captured_states[1]).
+
+  Setup(/*use_fpu=*/false);
+  trigger_nested_fault = true;
+  BeginBaseFaultTest();
+  ASSERT_EQ(exceptions_handled, 2u);
+
+  // captured_state values must be cast since they're in a packed struct.
+  EXPECT_EQ(static_cast<uint32_t>(captured_states[1].base.r0), kMagicPattern);
+  EXPECT_EQ(static_cast<uint32_t>(captured_states[1].base.r1), 0u);
+  // PC is manually saved in r2 before the exception occurs (where PC is also
+  // stored). Ensure these numbers are within a reasonable distance.
+  int32_t captured_pc_distance =
+      captured_states[1].base.pc - captured_states[1].base.r2;
+  EXPECT_LT(captured_pc_distance, kMaxPcDistance);
+  EXPECT_EQ(static_cast<uint32_t>(captured_states[1].base.r3),
+            static_cast<uint32_t>(captured_states[1].base.lr));
+
+  // NESTED STATE
+  // captured_state values must be cast since they're in a packed struct.
+  EXPECT_EQ(static_cast<uint32_t>(captured_states[0].base.r0),
+            kNestedMagicPattern);
+  EXPECT_EQ(static_cast<uint32_t>(captured_states[0].base.r1), 0u);
+  // PC is manually saved in r2 before the exception occurs (where PC is also
+  // stored). Ensure these numbers are within a reasonable distance.
+  captured_pc_distance =
+      captured_states[0].base.pc - captured_states[0].base.r2;
+  EXPECT_LT(captured_pc_distance, kMaxPcDistance);
+  EXPECT_EQ(static_cast<uint32_t>(captured_states[0].base.r3),
+            static_cast<uint32_t>(captured_states[0].base.lr));
+}
+
+// TODO(pwbug/17): Replace when Pigweed config system is added.
+// Disable tests that rely on hardware FPU if this module wasn't built with
+// hardware FPU support.
+#if defined(PW_ARMV7M_ENABLE_FPU) && PW_ARMV7M_ENABLE_FPU == 1
+
+// Populate some of the extended set of captured registers, then trigger
+// exception. This function uses floating point to validate float context
+// is pushed correctly.
+void BeginExtendedFaultFloatTest() {
+  float_test_value = _PW_TEST_FPU_OPERATION;
+  BeginExtendedFaultTest();
+}
+
+// Populate some of the extended set of captured registers, then trigger
+// exception.
+// This version causes stack to not be 4-byte aligned initially, testing
+// the fault handlers correction for psp.
+// This function uses floating point to validate float context
+// is pushed correctly.
+void BeginExtendedFaultUnalignedStackFloatTest() {
+  float_test_value = _PW_TEST_FPU_OPERATION;
+  BeginExtendedFaultUnalignedStackTest();
+}
+
+TEST(FaultEntry, FloatFault) {
+  Setup(/*use_fpu=*/true);
+  BeginExtendedFaultFloatTest();
+  ASSERT_EQ(exceptions_handled, 1u);
+  const CortexMExtraRegisters& extended_registers = captured_state.extended;
+  // captured_state values must be cast since they're in a packed struct.
+  EXPECT_EQ(static_cast<uint32_t>(extended_registers.r4), kMagicPattern);
+  EXPECT_EQ(static_cast<uint32_t>(extended_registers.r5), 0u);
+  EXPECT_EQ(static_cast<uint32_t>(extended_registers.r11), kMagicPattern);
+
+  // Check expected values for this crash.
+  EXPECT_EQ(static_cast<uint32_t>(extended_registers.cfsr),
+            static_cast<uint32_t>(kDivByZeroFaultMask));
+  EXPECT_EQ((extended_registers.icsr & 0x1FFu), kUsageFaultIsrNum);
+
+  // Check fpu state was pushed during exception
+  EXPECT_FALSE(extended_registers.exc_return & kExcReturnBasicFrameMask);
+
+  // Check float_test_value is correct
+  EXPECT_EQ(float_test_value, kFloatTestPattern);
+}
+
+TEST(FaultEntry, FloatUnalignedStackFault) {
+  Setup(/*use_fpu=*/true);
+  BeginExtendedFaultUnalignedStackFloatTest();
+  ASSERT_EQ(exceptions_handled, 1u);
+  ASSERT_TRUE(span_matches);
+  const CortexMExtraRegisters& extended_registers = captured_state.extended;
+  // captured_state values must be cast since they're in a packed struct.
+  EXPECT_EQ(static_cast<uint32_t>(extended_registers.r4), kMagicPattern);
+  EXPECT_EQ(static_cast<uint32_t>(extended_registers.r5), 0u);
+  EXPECT_EQ(static_cast<uint32_t>(extended_registers.r11), kMagicPattern);
+
+  // Check expected values for this crash.
+  EXPECT_EQ(static_cast<uint32_t>(extended_registers.cfsr),
+            static_cast<uint32_t>(kDivByZeroFaultMask));
+  EXPECT_EQ((extended_registers.icsr & 0x1FFu), kUsageFaultIsrNum);
+
+  // Check fpu state was pushed during exception.
+  EXPECT_FALSE(extended_registers.exc_return & kExcReturnBasicFrameMask);
+
+  // Check float_test_value is correct
+  EXPECT_EQ(float_test_value, kFloatTestPattern);
+}
+
+#endif  // defined(PW_ARMV7M_ENABLE_FPU) && PW_ARMV7M_ENABLE_FPU == 1
+
+void TestingExceptionHandler(pw_cpu_exception_State* state) {
+  if (++current_fault_depth > kMaxFaultDepth) {
+    volatile bool loop = true;
+    while (loop) {
+      // Hit unexpected nested crash, prevent further nesting.
+    }
+  }
+
+  if (trigger_nested_fault) {
+    // Disable nesting before triggering the nested fault to prevent infinite
+    // recursive crashes.
+    trigger_nested_fault = false;
+    BeginNestedFaultTest();
+  }
+  // Logging may require FPU (fpu instructions in vsnprintf()), so re-enable
+  // asap.
+  EnableFpu();
+
+  // Disable traps. Must be disabled before EXPECT, as memcpy() can do unaligned
+  // operations.
+  cortex_m_ccr &= ~kUnalignedTrapEnableMask;
+  cortex_m_ccr &= ~kDivByZeroTrapEnableMask;
+
+  // Clear HFSR forced (nested) hard fault mask if set. This will only be
+  // set by the nested fault test.
+  EXPECT_EQ(state->extended.hfsr, cortex_m_hfsr);
+  if (cortex_m_hfsr & kForcedHardfaultMask) {
+    cortex_m_hfsr = kForcedHardfaultMask;
+  }
+
+  if (cortex_m_cfsr & kUnalignedFaultMask) {
+    // Copy captured state to check later.
+    std::memcpy(&captured_states[exceptions_handled],
+                state,
+                sizeof(pw_cpu_exception_State));
+
+    // Disable unaligned read/write trapping to "handle" exception.
+    cortex_m_cfsr = kUnalignedFaultMask;
+    exceptions_handled++;
+    return;
+  } else if (cortex_m_cfsr & kDivByZeroFaultMask) {
+    // Copy captured state to check later.
+    std::memcpy(&captured_states[exceptions_handled],
+                state,
+                sizeof(pw_cpu_exception_State));
+
+    // Ensure std::span compares to be the same.
+    std::span<const uint8_t> state_span = RawFaultingCpuState(*state);
+    EXPECT_EQ(state_span.size(), sizeof(pw_cpu_exception_State));
+    if (std::memcmp(state, state_span.data(), state_span.size()) == 0) {
+      span_matches = true;
+    } else {
+      span_matches = false;
+    }
+
+    // Disable divide-by-zero trapping to "handle" exception.
+    cortex_m_cfsr = kDivByZeroFaultMask;
+    exceptions_handled++;
+    return;
+  }
+
+  EXPECT_EQ(state->extended.shcsr, cortex_m_shcsr);
+
+  // If an unexpected exception occurred, just enter an infinite loop.
+  while (true) {
+  }
+}
+
+}  // namespace
+}  // namespace pw::cpu_exception
diff --git a/pw_cpu_exception_cortex_m/proto_dump.cc b/pw_cpu_exception_cortex_m/proto_dump.cc
new file mode 100644
index 0000000..0faec5a
--- /dev/null
+++ b/pw_cpu_exception_cortex_m/proto_dump.cc
@@ -0,0 +1,64 @@
+// 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.
+#include "pw_cpu_exception_cortex_m/cpu_state.h"
+#include "pw_cpu_exception_cortex_m_protos/cpu_state.pwpb.h"
+#include "pw_preprocessor/compiler.h"
+#include "pw_protobuf/encoder.h"
+
+namespace pw::cpu_exception {
+
+Status DumpCpuStateProto(protobuf::Encoder& dest,
+                         const pw_cpu_exception_State& cpu_state) {
+  cortex_m::ArmV7mCpuState::Encoder state_encoder(&dest);
+
+  // Special and mem-mapped registers.
+  state_encoder.WritePc(cpu_state.base.pc);
+  state_encoder.WriteLr(cpu_state.base.lr);
+  state_encoder.WritePsr(cpu_state.base.psr);
+  state_encoder.WriteMsp(cpu_state.extended.msp);
+  state_encoder.WritePsp(cpu_state.extended.psp);
+  state_encoder.WriteExcReturn(cpu_state.extended.exc_return);
+  state_encoder.WriteCfsr(cpu_state.extended.cfsr);
+  state_encoder.WriteMmfar(cpu_state.extended.mmfar);
+  state_encoder.WriteBfar(cpu_state.extended.bfar);
+  state_encoder.WriteIcsr(cpu_state.extended.icsr);
+  state_encoder.WriteHfsr(cpu_state.extended.hfsr);
+  state_encoder.WriteShcsr(cpu_state.extended.shcsr);
+  state_encoder.WriteControl(cpu_state.extended.control);
+
+  // General purpose registers.
+  state_encoder.WriteR0(cpu_state.base.r0);
+  state_encoder.WriteR1(cpu_state.base.r1);
+  state_encoder.WriteR2(cpu_state.base.r2);
+  state_encoder.WriteR3(cpu_state.base.r3);
+  state_encoder.WriteR4(cpu_state.extended.r4);
+  state_encoder.WriteR5(cpu_state.extended.r5);
+  state_encoder.WriteR6(cpu_state.extended.r6);
+  state_encoder.WriteR7(cpu_state.extended.r7);
+  state_encoder.WriteR8(cpu_state.extended.r8);
+  state_encoder.WriteR9(cpu_state.extended.r9);
+  state_encoder.WriteR10(cpu_state.extended.r10);
+  state_encoder.WriteR11(cpu_state.extended.r11);
+
+  // If the encode buffer was exhausted in an earlier write, it will be
+  // reflected here.
+  Status status = state_encoder.WriteR12(cpu_state.base.r12);
+  if (!status.ok()) {
+    return status.IsResourceExhausted() ? Status::ResourceExhausted()
+                                        : Status::Unknown();
+  }
+  return OkStatus();
+}
+
+}  // namespace pw::cpu_exception
diff --git a/pw_cpu_exception_cortex_m/public/pw_cpu_exception_cortex_m/cpu_state.h b/pw_cpu_exception_cortex_m/public/pw_cpu_exception_cortex_m/cpu_state.h
new file mode 100644
index 0000000..440207d
--- /dev/null
+++ b/pw_cpu_exception_cortex_m/public/pw_cpu_exception_cortex_m/cpu_state.h
@@ -0,0 +1,97 @@
+// Copyright 2019 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.
+#pragma once
+
+#include <cstdint>
+
+#include "pw_preprocessor/compiler.h"
+
+namespace pw::cpu_exception {
+
+// This is dictated by ARMv7-M architecture. Do not change.
+PW_PACKED(struct) CortexMExceptionRegisters {
+  uint32_t r0;
+  uint32_t r1;
+  uint32_t r2;
+  uint32_t r3;
+  uint32_t r12;
+  uint32_t lr;   // Link register.
+  uint32_t pc;   // Program counter.
+  uint32_t psr;  // Program status register.
+};
+
+// This is dictated by ARMv7-M architecture. Do not change.
+PW_PACKED(struct) CortexMExceptionRegistersFpu {
+  uint32_t s0;
+  uint32_t s1;
+  uint32_t s2;
+  uint32_t s3;
+  uint32_t s4;
+  uint32_t s5;
+  uint32_t s6;
+  uint32_t s7;
+  uint32_t s8;
+  uint32_t s9;
+  uint32_t s10;
+  uint32_t s11;
+  uint32_t s12;
+  uint32_t s13;
+  uint32_t s14;
+  uint32_t s15;
+  uint32_t fpscr;
+  uint32_t reserved;
+};
+
+// Bit in the PSR that indicates CPU added an extra word on the stack to
+// align it during context save for an exception.
+inline constexpr uint32_t kPsrExtraStackAlignBit = (1 << 9);
+
+// This is dictated by this module, and shouldn't change often.
+// Note that the order of entries in this struct is very important (as the
+// values are populated in assembly).
+//
+// NOTE: Memory mapped registers are NOT restored upon fault return!
+PW_PACKED(struct) CortexMExtraRegisters {
+  // Memory mapped registers.
+  uint32_t cfsr;
+  uint32_t mmfar;
+  uint32_t bfar;
+  uint32_t icsr;
+  uint32_t hfsr;
+  uint32_t shcsr;
+  // Special registers.
+  uint32_t exc_return;
+  uint32_t msp;
+  uint32_t psp;
+  uint32_t control;
+  // General purpose registers.
+  uint32_t r4;
+  uint32_t r5;
+  uint32_t r6;
+  uint32_t r7;
+  uint32_t r8;
+  uint32_t r9;
+  uint32_t r10;
+  uint32_t r11;
+};
+
+}  // namespace pw::cpu_exception
+
+PW_PACKED(struct) pw_cpu_exception_State {
+  pw::cpu_exception::CortexMExtraRegisters extended;
+  pw::cpu_exception::CortexMExceptionRegisters base;
+  // TODO(amontanez): FPU registers may or may not be here as well. Make the
+  // availability of the FPU registers a compile-time configuration when FPU
+  // register support is added.
+};
diff --git a/pw_cpu_exception_cortex_m/public/pw_cpu_exception_cortex_m/proto_dump.h b/pw_cpu_exception_cortex_m/public/pw_cpu_exception_cortex_m/proto_dump.h
new file mode 100644
index 0000000..8a8d5ba
--- /dev/null
+++ b/pw_cpu_exception_cortex_m/public/pw_cpu_exception_cortex_m/proto_dump.h
@@ -0,0 +1,33 @@
+// 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.
+#pragma once
+
+#include "pw_cpu_exception_cortex_m/cpu_state.h"
+#include "pw_protobuf/encoder.h"
+#include "pw_status/status.h"
+
+namespace pw::cpu_exception {
+
+// Dumps the cpu state struct as a proto (defined in
+// pw_cpu_exception_cortex_m_protos/cpu_state.proto). The final proto is up to
+// 144 bytes in size, so ensure your encoder is properly sized.
+//
+// Returns:
+//   OK - Entire proto was written to the encoder.
+//   RESOURCE_EXHAUSTED - Insufficient space to encode proto.
+//   UNKNOWN - Some other proto encoding error occurred.
+Status DumpCpuStateProto(protobuf::Encoder& dest,
+                         const pw_cpu_exception_State& cpu_state);
+
+}  // namespace pw::cpu_exception
diff --git a/pw_cpu_exception_cortex_m/pw_cpu_exception_cortex_m_private/cortex_m_constants.h b/pw_cpu_exception_cortex_m/pw_cpu_exception_cortex_m_private/cortex_m_constants.h
new file mode 100644
index 0000000..f384187
--- /dev/null
+++ b/pw_cpu_exception_cortex_m/pw_cpu_exception_cortex_m_private/cortex_m_constants.h
@@ -0,0 +1,90 @@
+// Copyright 2021 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.
+
+#pragma once
+
+#include <cstdint>
+
+namespace pw::cpu_exception {
+
+// CMSIS/Cortex-M/ARMv7 related constants.
+// These values are from the ARMv7-M Architecture Reference Manual DDI 0403E.b.
+// https://static.docs.arm.com/ddi0403/e/DDI0403E_B_armv7m_arm.pdf
+
+constexpr uint32_t kThreadModeIsrNum = 0x0;
+constexpr uint32_t kNmiIsrNum = 0x2;
+constexpr uint32_t kHardFaultIsrNum = 0x3;
+constexpr uint32_t kMemFaultIsrNum = 0x4;
+constexpr uint32_t kBusFaultIsrNum = 0x5;
+constexpr uint32_t kUsageFaultIsrNum = 0x6;
+
+// Masks for Interrupt Control and State Register ICSR (ARMv7-M Section B3.2.4)
+constexpr uint32_t kIcsrVectactiveMask = (1 << 9) - 1;
+
+// Masks for individual bits of HFSR. (ARMv7-M Section B3.2.16)
+constexpr uint32_t kHfsrForcedMask = (0x1 << 30);
+
+// Masks for different sections of CFSR. (ARMv7-M Section B3.2.15)
+constexpr uint32_t kCfsrMemFaultMask = 0x000000ff;
+constexpr uint32_t kCfsrBusFaultMask = 0x0000ff00;
+constexpr uint32_t kCfsrUsageFaultMask = 0xffff0000;
+
+// Masks for individual bits of CFSR. (ARMv7-M Section B3.2.15)
+// Memory faults (MemManage Status Register)
+constexpr uint32_t kCfsrMemFaultStart = (0x1);
+constexpr uint32_t kCfsrIaccviolMask = (kCfsrMemFaultStart << 0);
+constexpr uint32_t kCfsrDaccviolMask = (kCfsrMemFaultStart << 1);
+constexpr uint32_t kCfsrMunstkerrMask = (kCfsrMemFaultStart << 3);
+constexpr uint32_t kCfsrMstkerrMask = (kCfsrMemFaultStart << 4);
+constexpr uint32_t kCfsrMlsperrMask = (kCfsrMemFaultStart << 5);
+constexpr uint32_t kCfsrMmarvalidMask = (kCfsrMemFaultStart << 7);
+// Bus faults (BusFault Status Register)
+constexpr uint32_t kCfsrBusFaultStart = (0x1 << 8);
+constexpr uint32_t kCfsrIbuserrMask = (kCfsrBusFaultStart << 0);
+constexpr uint32_t kCfsrPreciserrMask = (kCfsrBusFaultStart << 1);
+constexpr uint32_t kCfsrImpreciserrMask = (kCfsrBusFaultStart << 2);
+constexpr uint32_t kCfsrUnstkerrMask = (kCfsrBusFaultStart << 3);
+constexpr uint32_t kCfsrStkerrMask = (kCfsrBusFaultStart << 4);
+constexpr uint32_t kCfsrLsperrMask = (kCfsrBusFaultStart << 5);
+constexpr uint32_t kCfsrBfarvalidMask = (kCfsrBusFaultStart << 7);
+// Usage faults (UsageFault Status Register)
+constexpr uint32_t kCfsrUsageFaultStart = (0x1 << 16);
+constexpr uint32_t kCfsrUndefinstrMask = (kCfsrUsageFaultStart << 0);
+constexpr uint32_t kCfsrInvstateMask = (kCfsrUsageFaultStart << 1);
+constexpr uint32_t kCfsrInvpcMask = (kCfsrUsageFaultStart << 2);
+constexpr uint32_t kCfsrNocpMask = (kCfsrUsageFaultStart << 3);
+constexpr uint32_t kCfsrStkofMask = (kCfsrUsageFaultStart << 4);
+constexpr uint32_t kCfsrUnalignedMask = (kCfsrUsageFaultStart << 8);
+constexpr uint32_t kCfsrDivbyzeroMask = (kCfsrUsageFaultStart << 9);
+
+// Bit masks for an exception return value. (ARMv7-M Section B1.5.8)
+constexpr uint32_t kExcReturnStackMask = 0x1u << 2;
+constexpr uint32_t kExcReturnBasicFrameMask = 0x1u << 4;
+
+// Memory mapped registers. (ARMv7-M Section B3.2.2, Table B3-4)
+// TODO(pwbug/316): Only some of these are supported on ARMv6-M.
+inline volatile uint32_t& cortex_m_cfsr =
+    *reinterpret_cast<volatile uint32_t*>(0xE000ED28u);
+inline volatile uint32_t& cortex_m_mmfar =
+    *reinterpret_cast<volatile uint32_t*>(0xE000ED34u);
+inline volatile uint32_t& cortex_m_bfar =
+    *reinterpret_cast<volatile uint32_t*>(0xE000ED38u);
+inline volatile uint32_t& cortex_m_icsr =
+    *reinterpret_cast<volatile uint32_t*>(0xE000ED04u);
+inline volatile uint32_t& cortex_m_hfsr =
+    *reinterpret_cast<volatile uint32_t*>(0xE000ED2Cu);
+inline volatile uint32_t& cortex_m_shcsr =
+    *reinterpret_cast<volatile uint32_t*>(0xE000ED24u);
+
+}  // namespace pw::cpu_exception
diff --git a/pw_cpu_exception_cortex_m/pw_cpu_exception_cortex_m_protos/cpu_state.proto b/pw_cpu_exception_cortex_m/pw_cpu_exception_cortex_m_protos/cpu_state.proto
new file mode 100644
index 0000000..ad1a054
--- /dev/null
+++ b/pw_cpu_exception_cortex_m/pw_cpu_exception_cortex_m_protos/cpu_state.proto
@@ -0,0 +1,49 @@
+// 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.
+syntax = "proto2";
+
+package pw.cpu_exception.cortex_m;
+
+message ArmV7mCpuState {
+  optional uint32 pc = 1;
+  optional uint32 lr = 2;
+  optional uint32 psr = 3;
+  optional uint32 msp = 4;
+  optional uint32 psp = 5;
+  optional uint32 exc_return = 6;
+  optional uint32 cfsr = 7;
+  optional uint32 mmfar = 8;
+  optional uint32 bfar = 9;
+  optional uint32 icsr = 10;
+  optional uint32 hfsr = 25;
+  optional uint32 shcsr = 26;
+  optional uint32 control = 11;
+
+  // General purpose registers.
+  optional uint32 r0 = 12;
+  optional uint32 r1 = 13;
+  optional uint32 r2 = 14;
+  optional uint32 r3 = 15;
+  optional uint32 r4 = 16;
+  optional uint32 r5 = 17;
+  optional uint32 r6 = 18;
+  optional uint32 r7 = 19;
+  optional uint32 r8 = 20;
+  optional uint32 r9 = 21;
+  optional uint32 r10 = 22;
+  optional uint32 r11 = 23;
+  optional uint32 r12 = 24;
+
+  // Next tag: 27
+}
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..efa0b85
--- /dev/null
+++ b/pw_cpu_exception_cortex_m/py/BUILD.gn
@@ -0,0 +1,34 @@
+# 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/cfsr_decoder.py",
+    "pw_cpu_exception_cortex_m/cortex_m_constants.py",
+    "pw_cpu_exception_cortex_m/exception_analyzer.py",
+  ]
+  tests = [ "exception_analyzer_test.py" ]
+  python_deps = [
+    "$dir_pw_cli/py",
+    "$dir_pw_protobuf_compiler/py",
+    "..:cpu_state_protos.python",
+  ]
+  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..8566c5b
--- /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_cortex_m',
+    'pw_cpu_exception_cortex_m_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_PRECISERR_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:',
+            'PRECISERR   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_hdlc_lite/py/pw_hdlc_lite/__init__.py b/pw_cpu_exception_cortex_m/py/pw_cpu_exception_cortex_m/__init__.py
similarity index 100%
copy from pw_hdlc_lite/py/pw_hdlc_lite/__init__.py
copy to 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/cfsr_decoder.py b/pw_cpu_exception_cortex_m/py/pw_cpu_exception_cortex_m/cfsr_decoder.py
new file mode 100644
index 0000000..380fb1f
--- /dev/null
+++ b/pw_cpu_exception_cortex_m/py/pw_cpu_exception_cortex_m/cfsr_decoder.py
@@ -0,0 +1,61 @@
+# Copyright 2021 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.
+"""A simple tool to decode a CFSR register from the command line
+
+Example usage:
+
+  $ python -m pw_cpu_exception_cortex_m.cfsr_decoder 0x00010100
+
+  20210412 15:09:01 INF Exception caused by a usage fault, bus fault.
+
+  Active Crash Fault Status Register (CFSR) fields:
+  IBUSERR     Bus fault on instruction fetch.
+  UNDEFINSTR  Encountered invalid instruction.
+
+  All registers:
+  cfsr       0x00010100
+"""
+
+import argparse
+import logging
+import sys
+import pw_cli.log
+
+from pw_cpu_exception_cortex_m_protos import cpu_state_pb2
+from pw_cpu_exception_cortex_m import exception_analyzer
+
+_LOG = logging.getLogger('decode_cfsr')
+
+
+def _parse_args() -> argparse.Namespace:
+    """Parses arguments for this script, splitting out the command to run."""
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument('cfsr',
+                        type=lambda val: int(val, 0),
+                        help='The Cortex-M CFSR to decode')
+    return parser.parse_args()
+
+
+def dump_cfsr(cfsr: int) -> int:
+    cpu_state_proto = cpu_state_pb2.ArmV7mCpuState()
+    cpu_state_proto.cfsr = cfsr
+    cpu_state_info = exception_analyzer.CortexMExceptionAnalyzer(
+        cpu_state_proto)
+    _LOG.info(cpu_state_info)
+    return 0
+
+
+if __name__ == '__main__':
+    pw_cli.log.install(level=logging.INFO)
+    sys.exit(dump_cfsr(**vars(_parse_args())))
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..b623c38
--- /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_PRECISERR_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('PRECISERR', PW_CORTEX_M_CFSR_PRECISERR_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..9cc3e8e
--- /dev/null
+++ b/pw_cpu_exception_cortex_m/py/setup.py
@@ -0,0 +1,32 @@
+# 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',
+        'pw_cli',
+        'pw_protobuf_compiler',
+    ],
+)
diff --git a/pw_docgen/docs.gni b/pw_docgen/docs.gni
index a6354bb..50bf8d6 100644
--- a/pw_docgen/docs.gni
+++ b/pw_docgen/docs.gni
@@ -29,6 +29,7 @@
 #   inputs: Additional resource files for the docs, such as images.
 #   group_deps: Other pw_doc_group targets on which this group depends.
 #   report_deps: Report card targets on which documentation depends.
+#   other_deps: Dependencies on any other types of targets.
 template("pw_doc_group") {
   assert(defined(invoker.sources), "pw_doc_group requires a list of sources")
 
@@ -45,6 +46,9 @@
   if (defined(invoker.report_deps)) {
     _all_deps += invoker.report_deps
   }
+  if (defined(invoker.other_deps)) {
+    _all_deps += invoker.other_deps
+  }
 
   # Create a group containing the source and input files so that docs are
   # rebuilt on file modifications.
diff --git a/pw_docgen/py/BUILD.gn b/pw_docgen/py/BUILD.gn
index 28d4734..1397437 100644
--- a/pw_docgen/py/BUILD.gn
+++ b/pw_docgen/py/BUILD.gn
@@ -22,4 +22,5 @@
     "pw_docgen/__init__.py",
     "pw_docgen/docgen.py",
   ]
+  pylintrc = "$dir_pigweed/.pylintrc"
 }
diff --git a/pw_docgen/py/pw_docgen/docgen.py b/pw_docgen/py/pw_docgen/docgen.py
index 8be124c..e130931 100644
--- a/pw_docgen/py/pw_docgen/docgen.py
+++ b/pw_docgen/py/pw_docgen/docgen.py
@@ -24,6 +24,7 @@
 import subprocess
 import sys
 
+from pathlib import Path
 from typing import Dict, List, Tuple
 
 SCRIPT_HEADER: str = '''
@@ -83,12 +84,6 @@
     os.makedirs(dirname, exist_ok=exist_ok)
 
 
-def copy(src: str, dst: str) -> None:
-    """Wrapper around shutil.copy that prints the operation."""
-    print(f'COPY  {src} -> {dst}')
-    shutil.copy(src, dst)
-
-
 def copy_doc_tree(args: argparse.Namespace) -> None:
     """Copies doc source and input files into a build tree."""
     def build_path(path):
@@ -105,8 +100,9 @@
 
     mkdir(args.sphinx_build_dir)
     for source_path in args.sources:
-        copy(source_path, f'{args.sphinx_build_dir}/')
-    copy(args.conf, f'{args.sphinx_build_dir}/conf.py')
+        os.link(source_path,
+                f'{args.sphinx_build_dir}/{Path(source_path).name}')
+    os.link(args.conf, f'{args.sphinx_build_dir}/conf.py')
 
     # Map of directory path to list of source and destination file paths.
     dirs: Dict[str, List[Tuple[str, str]]] = collections.defaultdict(list)
@@ -118,7 +114,7 @@
     for directory, file_pairs in dirs.items():
         mkdir(directory, exist_ok=True)
         for src, dst in file_pairs:
-            copy(src, dst)
+            os.link(src, dst)
 
 
 def main() -> int:
diff --git a/pw_docgen/py/setup.py b/pw_docgen/py/setup.py
index 6953c15..e14a315 100644
--- a/pw_docgen/py/setup.py
+++ b/pw_docgen/py/setup.py
@@ -24,4 +24,15 @@
     packages=setuptools.find_packages(),
     package_data={'pw_docgen': ['py.typed']},
     zip_safe=False,
+    install_requires=[
+        'sphinx>=2, <3',  # Restrict version for compatibility with m2r plugin
+        'sphinx-rtd-theme',
+        # Markdown to REST for documentation.
+        'm2r',
+        # Diagram generation modules.
+        'sphinxcontrib-actdiag',
+        'sphinxcontrib-blockdiag',
+        'sphinxcontrib-nwdiag',
+        'sphinxcontrib-seqdiag',
+    ],
 )
diff --git a/pw_doctor/docs.rst b/pw_doctor/docs.rst
index 51087a5..1bd6578 100644
--- a/pw_doctor/docs.rst
+++ b/pw_doctor/docs.rst
@@ -7,5 +7,10 @@
 it checks that things exactly match what is expected and it checks that things
 look compatible without.
 
+Currently pw_doctor expects the running python to be Python 3.8 or 3.9.
+
+Projects that adjust the behavior of pw_env_setup may need to customize
+these checks, but unfortunately this is not supported yet.
+
 .. note::
   The documentation for this module is currently incomplete.
diff --git a/pw_doctor/py/BUILD.gn b/pw_doctor/py/BUILD.gn
index 0408128..bec5e59 100644
--- a/pw_doctor/py/BUILD.gn
+++ b/pw_doctor/py/BUILD.gn
@@ -22,4 +22,6 @@
     "pw_doctor/__init__.py",
     "pw_doctor/doctor.py",
   ]
+  python_deps = [ "$dir_pw_cli/py" ]
+  pylintrc = "$dir_pigweed/.pylintrc"
 }
diff --git a/pw_doctor/py/pw_doctor/doctor.py b/pw_doctor/py/pw_doctor/doctor.py
index 58cd1d8..7594616 100755
--- a/pw_doctor/py/pw_doctor/doctor.py
+++ b/pw_doctor/py/pw_doctor/doctor.py
@@ -26,6 +26,8 @@
 import tempfile
 from typing import Callable, Iterable, List, Set
 
+import pw_cli.pw_command_plugins
+
 
 def call_stdout(*args, **kwargs):
     kwargs.update(stdout=subprocess.PIPE)
@@ -127,6 +129,12 @@
 
 
 @register_into(CHECKS)
+def pw_plugins(ctx: DoctorContext):
+    if pw_cli.pw_command_plugins.errors():
+        ctx.error('Not all pw plugins loaded successfully')
+
+
+@register_into(CHECKS)
 def env_os(ctx: DoctorContext):
     """Check that the environment matches this machine."""
     if '_PW_ACTUAL_ENVIRONMENT_ROOT' not in os.environ:
@@ -192,8 +200,9 @@
     """Check the Python version is correct."""
     actual = sys.version_info
     expected = (3, 8)
+    latest = (3, 9)
     if (actual[0:2] < expected or actual[0] != expected[0]
-            or actual[0:2] > expected):
+            or actual[0:2] > latest):
         # If we get the wrong version but it still came from CIPD print a
         # warning but give it a pass.
         if 'chromium' in sys.version:
@@ -342,6 +351,11 @@
 
     if doctor.failures:
         doctor.log.info('Failed checks: %s', ', '.join(doctor.failures))
+        doctor.log.info(
+            "Your environment setup has completed, but something isn't right "
+            'and some things may not work correctly. You may continue with '
+            'development, but please seek support at '
+            'https://bugs.pigweed.dev/ or by reaching out to your team.')
     else:
         doctor.log.info('Environment passes all checks!')
     return len(doctor.failures)
diff --git a/pw_env_setup/BUILD.gn b/pw_env_setup/BUILD.gn
index 2232988..2a4528f 100644
--- a/pw_env_setup/BUILD.gn
+++ b/pw_env_setup/BUILD.gn
@@ -14,9 +14,51 @@
 
 import("//build_overrides/pigweed.gni")
 
+import("$dir_pw_build/python.gni")
 import("$dir_pw_docgen/docs.gni")
 
 pw_doc_group("docs") {
   inputs = [ "doc_resources/pw_env_setup_output.png" ]
   sources = [ "docs.rst" ]
 }
+
+pw_python_group("python") {
+  python_deps = [
+    # Python packages
+    "$dir_pw_allocator/py",
+    "$dir_pw_arduino_build/py",
+    "$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",
+    "$dir_pw_hdlc/py",
+    "$dir_pw_log_tokenized/py",
+    "$dir_pw_module/py",
+    "$dir_pw_package/py",
+    "$dir_pw_presubmit/py",
+    "$dir_pw_protobuf/py",
+    "$dir_pw_protobuf_compiler/py",
+    "$dir_pw_rpc/py",
+    "$dir_pw_status/py",
+    "$dir_pw_tokenizer/py",
+    "$dir_pw_toolchain/py",
+    "$dir_pw_trace/py",
+    "$dir_pw_trace_tokenized/py",
+    "$dir_pw_unit_test/py",
+    "$dir_pw_watch/py",
+
+    # Standalone scripts
+    "$dir_pw_hdlc/rpc_example:example_script",
+  ]
+}
+
+# Python packages for supporting specific targets.
+pw_python_group("target_support_packages") {
+  python_deps = [
+    "$dir_pigweed/targets/lm3s6965evb-qemu/py",
+    "$dir_pigweed/targets/stm32f429i-disc1/py",
+  ]
+}
diff --git a/pw_env_setup/compatibility.json b/pw_env_setup/compatibility.json
new file mode 100644
index 0000000..07db79a
--- /dev/null
+++ b/pw_env_setup/compatibility.json
@@ -0,0 +1,14 @@
+{
+  "cipd_package_files": [
+    "pw_env_setup/py/pw_env_setup/cipd_setup/pigweed.json",
+    "pw_env_setup/py/pw_env_setup/cipd_setup/luci.json",
+    "pw_env_setup/py/pw_env_setup/cipd_setup/compatibility.json"
+  ],
+  "virtualenv": {
+    "gn_root": ".",
+    "gn_targets": [
+      ":python.install",
+      ":target_support_packages.install"
+    ]
+  }
+}
diff --git a/pw_env_setup/config.json b/pw_env_setup/config.json
new file mode 100644
index 0000000..435be72
--- /dev/null
+++ b/pw_env_setup/config.json
@@ -0,0 +1,13 @@
+{
+  "cipd_package_files": [
+    "pw_env_setup/py/pw_env_setup/cipd_setup/pigweed.json",
+    "pw_env_setup/py/pw_env_setup/cipd_setup/luci.json"
+  ],
+  "virtualenv": {
+    "gn_root": ".",
+    "gn_targets": [
+      ":python.install",
+      ":target_support_packages.install"
+    ]
+  }
+}
diff --git a/pw_env_setup/docs.rst b/pw_env_setup/docs.rst
index 018424e..7832a9f 100644
--- a/pw_env_setup/docs.rst
+++ b/pw_env_setup/docs.rst
@@ -41,11 +41,6 @@
   and ``activate.bat``. For simplicity they will be referred to with the ``.sh``
   endings unless the distinction is relevant.
 
-By default packages will be installed in a ``.environment`` folder within the
-checkout root, and CIPD will cache files in ``$HOME/.cipd-cache-dir``. These
-paths can be overridden by setting ``PW_ENVIRONMENT_ROOT`` and
-``CIPD_CACHE_DIR``, respectively.
-
 .. warning::
   At this time ``pw_env_setup`` works for us, but isn’t well tested. We don’t
   suggest relying on it just yet. However, we are interested in experience
@@ -163,66 +158,83 @@
 ********************************************
 
 Projects depending on Pigweed but using additional or different packages should
-copy the Pigweed `sample project`'s ``bootstrap.sh`` and update the call to
-``pw_bootstrap``. Search for "downstream" for other places that may require
-changes, like setting the ``PW_ROOT`` and ``PW_PROJECT_ROOT`` environment
-variables. Relevant arguments to ``pw_bootstrap`` are listed here.
+copy the Pigweed `sample project`'s ``bootstrap.sh`` and ``config.json`` and
+update the call to ``pw_bootstrap``. Search for "downstream" for other places
+that may require changes, like setting the ``PW_ROOT`` and ``PW_PROJECT_ROOT``
+environment variables. Explanations of parts of ``config.json`` are described
+here.
 
 .. _sample project: https://pigweed.googlesource.com/pigweed/sample_project/+/master
 
-``--use-pigweed-defaults``
-  Use Pigweed default values in addition to the other switches.
-
-``--cipd-package-file path/to/packages.json``
+``cipd_package_files``
   CIPD package file. JSON file consisting of a list of dictionaries with "path"
   and "tags" keys, where "tags" is a list of strings.
 
-``--virtualenv-requierements path/to/requirements.txt``
-  Pip requirements file. Compiled with pip-compile.
+``virtualenv.gn_targets``
+  Target for installing Python packages. Downstream projects will need to
+  create targets to install their packages or only use Pigweed Python packages.
 
-``--virtualenv-gn-target path/to/directory#package-install-target``
-  Target for installing Python packages, and the directory from which it must be
-  run. Example for Pigweed: ``third_party/pigweed#:python.install`` (assuming
-  Pigweed is included in the project at ``third_party/pigweed``). Downstream
-  projects will need to create targets to install their packages and either
-  choose a subset of Pigweed packages or use
-  ``third_party/pigweed#:python.install`` to install all Pigweed packages.
+``virtualenv.gn_root``
+  The root directory of your GN build tree, relative to ``PW_PROJECT_ROOT``.
+  This is the directory your project's ``.gn`` file is located in. If you're
+  only installing Pigweed Python packages, use the location of the Pigweed
+  submodule.
 
-``--cargo-package-file path/to/packages.txt``
-  Rust cargo packages to install. Lines with package name and version separated
-  by a space. Has no effect without ``--enable-cargo``.
+An example of a config file is below.
 
-``--enable-cargo``
-  Enable cargo package installation.
+.. code-block:: json
 
-An example of the changed env_setup.py line is below.
+  {
+    "cipd_package_files": [
+      "pigweed/pw_env_setup/py/pw_env_setup/cipd_setup/pigweed.json",
+      "pigweed/pw_env_setup/py/pw_env_setup/cipd_setup/luci.json"
+      "tools/myprojectname.json"
+    ],
+    "virtualenv": {
+      "gn_root": ".",
+      "gn_targets": [
+        ":python.install",
+      ]
+    }
+  }
 
-.. code-block:: bash
+In case the CIPD packages need to be referenced from other scripts, variables
+like ``PW_${BASENAME}_CIPD_INSTALL_DIR`` point to the CIPD install directories,
+where ``${BASENAME}`` is "PIGWEED" for
+"pigweed/pw_env_setup/py/pw_env_setup/cipd_setup/pigweed.json" and "LUCI" for
+"pigweed/pw_env_setup/py/pw_env_setup/cipd_setup/luci.json". This example would
+set the following environment variables.
 
-  pw_bootstrap \
-    --shell-file "$SETUP_SH" \
-    --install-dir "$_PW_ACTUAL_ENVIRONMENT_ROOT" \
-    --use-pigweed-defaults \
-    --cipd-package-file "$PW_PROJECT_ROOT/path/to/cipd.json" \
-    --virtualenv-gn-target "$PW_PROJECT_ROOT#:python.install"
+ - ``PW_LUCI_CIPD_INSTALL_DIR``
+ - ``PW_MYPROJECTNAME_CIPD_INSTALL_DIR``
+ - ``PW_PIGWEED_CIPD_INSTALL_DIR``
 
-Projects wanting some of the Pigweed environment packages but not all of them
-should not use ``--use-pigweed-defaults`` and must manually add the references
-to Pigweed default packages through the other arguments. The arguments below
-are identical to using ``--use-pigweed-defaults``.
+Environment Variables
+*********************
+The following environment variables affect env setup behavior. Most users will
+never need to set these.
 
-.. code-block:: bash
+``CIPD_CACHE_DIR``
+  Location of CIPD cache dir. Defaults to ``$HOME/.cipd-cache-dir``.
 
-  --cipd-package-file
-  "$PW_ROOT/pw_env_setup/py/pw_env_setup/cipd_setup/pigweed.json"
-  --cipd-package-file
-  "$PW_ROOT/pw_env_setup/py/pw_env_setup/cipd_setup/luci.json"
-  --virtualenv-requirements
-  "$PW_ROOT/pw_env_setup/py/pw_env_setup/virtualenv_setup/requirements.txt"
-  --virtualenv-gn-target
-  "$PW_ROOT#:python.install"
-  --cargo-package-file
-  "$PW_ROOT/pw_env_setup/py/pw_env_setup/cargo_setup/packages.txt"
+``PW_ACTIVATE_SKIP_CHECKS``
+  If set, skip running ``pw doctor`` at end of bootstrap/activate. Intended to
+  be used by automated tools but not interactively.
+
+``PW_BOOTSTRAP_PYTHON``
+  Python executable to be used, for example "python2" or "python3". Defaults to
+  "python".
+
+``PW_ENVIRONMENT_ROOT``
+  Location to which packages are installed. Defaults to ``.environment`` folder
+  within the checkout root.
+
+``PW_ENVSETUP_DISABLE_SPINNER``
+  Disable the spinner during env setup. Intended to be used when the output is
+  being redirected to a log.
+
+``PW_ENVSETUP_QUIET``
+  Disables all non-error output.
 
 Implementation
 **************
diff --git a/pw_env_setup/py/BUILD.gn b/pw_env_setup/py/BUILD.gn
index 2ad8b67..030b671 100644
--- a/pw_env_setup/py/BUILD.gn
+++ b/pw_env_setup/py/BUILD.gn
@@ -20,6 +20,8 @@
   setup = [ "setup.py" ]
   sources = [
     "pw_env_setup/__init__.py",
+    "pw_env_setup/apply_visitor.py",
+    "pw_env_setup/batch_visitor.py",
     "pw_env_setup/cargo_setup/__init__.py",
     "pw_env_setup/cipd_setup/__init__.py",
     "pw_env_setup/cipd_setup/update.py",
@@ -27,11 +29,17 @@
     "pw_env_setup/colors.py",
     "pw_env_setup/env_setup.py",
     "pw_env_setup/environment.py",
-    "pw_env_setup/environment_test.py",
+    "pw_env_setup/json_visitor.py",
+    "pw_env_setup/shell_visitor.py",
     "pw_env_setup/spinner.py",
     "pw_env_setup/virtualenv_setup/__init__.py",
     "pw_env_setup/virtualenv_setup/__main__.py",
     "pw_env_setup/virtualenv_setup/install.py",
     "pw_env_setup/windows_env_start.py",
   ]
+  tests = [
+    "environment_test.py",
+    "json_visitor_test.py",
+  ]
+  pylintrc = "$dir_pigweed/.pylintrc"
 }
diff --git a/pw_env_setup/py/environment_test.py b/pw_env_setup/py/environment_test.py
new file mode 100644
index 0000000..6b12b99
--- /dev/null
+++ b/pw_env_setup/py/environment_test.py
@@ -0,0 +1,460 @@
+# 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 for env_setup.environment.
+
+This tests the error-checking, context manager, and written environment scripts
+of the Environment class.
+
+Tests that end in "_ctx" modify the environment and validate it in-process.
+
+Tests that end in "_written" write the environment to a file intended to be
+evaluated by the shell, then launches the shell and then saves the environment.
+This environment is then validated in the test process.
+"""
+
+import logging
+import os
+import subprocess
+import tempfile
+import unittest
+
+import six
+
+from pw_env_setup import environment
+
+# pylint: disable=super-with-arguments
+
+
+class WrittenEnvFailure(Exception):
+    pass
+
+
+def _evaluate_env_in_shell(env):
+    """Write env to a file then evaluate and save the resulting environment.
+
+    Write env to a file, then launch a shell command that sources that file
+    and dumps the environment to stdout. Parse that output into a dict and
+    return it.
+
+    Args:
+      env(environment.Environment): environment to write out
+
+    Returns dictionary of resulting environment.
+    """
+
+    # Write env sourcing script to file.
+    with tempfile.NamedTemporaryFile(
+            prefix='pw-test-written-env-',
+            suffix='.bat' if os.name == 'nt' else '.sh',
+            delete=False,
+            mode='w+') as temp:
+        env.write(temp)
+        temp_name = temp.name
+
+    # Evaluate env sourcing script and capture output of 'env'.
+    if os.name == 'nt':
+        # On Windows you just run batch files and they modify your
+        # environment, no need to call 'source' or '.'.
+        cmd = '{} && set'.format(temp_name)
+    else:
+        # Using '.' instead of 'source' because 'source' is not POSIX.
+        cmd = '. {} && env'.format(temp_name)
+
+    res = subprocess.run(cmd, capture_output=True, shell=True)
+    if res.returncode:
+        raise WrittenEnvFailure(res.stderr)
+
+    # Parse environment from stdout of subprocess.
+    env_ret = {}
+    for line in res.stdout.splitlines():
+        line = line.decode()
+
+        # Some people inexplicably have newlines in some of their
+        # environment variables. This module does not allow that so we can
+        # ignore any such extra lines.
+        if '=' not in line:
+            continue
+
+        var, value = line.split('=', 1)
+        env_ret[var] = value
+
+    return env_ret
+
+
+# pylint: disable=too-many-public-methods
+class EnvironmentTest(unittest.TestCase):
+    """Tests for env_setup.environment."""
+    def setUp(self):
+        self.env = environment.Environment()
+
+        # Name of a variable that is already set when the test starts.
+        self.var_already_set = self.env.normalize_key('var_already_set')
+        os.environ[self.var_already_set] = 'orig value'
+        self.assertIn(self.var_already_set, os.environ)
+
+        # Name of a variable that is not set when the test starts.
+        self.var_not_set = self.env.normalize_key('var_not_set')
+        if self.var_not_set in os.environ:
+            del os.environ[self.var_not_set]
+        self.assertNotIn(self.var_not_set, os.environ)
+
+        self.orig_env = os.environ.copy()
+
+    def tearDown(self):
+        self.assertEqual(os.environ, self.orig_env)
+
+    def test_set_notpresent_ctx(self):
+        self.env.set(self.var_not_set, '1')
+        with self.env(export=False) as env:
+            self.assertIn(self.var_not_set, env)
+            self.assertEqual(env[self.var_not_set], '1')
+
+    def test_set_notpresent_written(self):
+        self.env.set(self.var_not_set, '1')
+        env = _evaluate_env_in_shell(self.env)
+        self.assertIn(self.var_not_set, env)
+        self.assertEqual(env[self.var_not_set], '1')
+
+    def test_set_present_ctx(self):
+        self.env.set(self.var_already_set, '1')
+        with self.env(export=False) as env:
+            self.assertIn(self.var_already_set, env)
+            self.assertEqual(env[self.var_already_set], '1')
+
+    def test_set_present_written(self):
+        self.env.set(self.var_already_set, '1')
+        env = _evaluate_env_in_shell(self.env)
+        self.assertIn(self.var_already_set, env)
+        self.assertEqual(env[self.var_already_set], '1')
+
+    def test_clear_notpresent_ctx(self):
+        self.env.clear(self.var_not_set)
+        with self.env(export=False) as env:
+            self.assertNotIn(self.var_not_set, env)
+
+    def test_clear_notpresent_written(self):
+        self.env.clear(self.var_not_set)
+        env = _evaluate_env_in_shell(self.env)
+        self.assertNotIn(self.var_not_set, env)
+
+    def test_clear_present_ctx(self):
+        self.env.clear(self.var_already_set)
+        with self.env(export=False) as env:
+            self.assertNotIn(self.var_already_set, env)
+
+    def test_clear_present_written(self):
+        self.env.clear(self.var_already_set)
+        env = _evaluate_env_in_shell(self.env)
+        self.assertNotIn(self.var_already_set, env)
+
+    def test_value_replacement(self):
+        self.env.set(self.var_not_set, '/foo/bar/baz')
+        self.env.add_replacement('FOOBAR', '/foo/bar')
+        buf = six.StringIO()
+        self.env.write(buf)
+        assert '/foo/bar' not in buf.getvalue()
+
+    def test_variable_replacement(self):
+        self.env.set('FOOBAR', '/foo/bar')
+        self.env.set(self.var_not_set, '/foo/bar/baz')
+        self.env.add_replacement('FOOBAR')
+        buf = six.StringIO()
+        self.env.write(buf)
+        print(buf.getvalue())
+        assert '/foo/bar/baz' not in buf.getvalue()
+
+    def test_nonglobal(self):
+        self.env.set(self.var_not_set, '1')
+        with self.env(export=False) as env:
+            self.assertIn(self.var_not_set, env)
+            self.assertNotIn(self.var_not_set, os.environ)
+
+    def test_global(self):
+        self.env.set(self.var_not_set, '1')
+        with self.env(export=True) as env:
+            self.assertIn(self.var_not_set, env)
+            self.assertIn(self.var_not_set, os.environ)
+
+    def test_set_badnametype(self):
+        with self.assertRaises(environment.BadNameType):
+            self.env.set(123, '123')
+
+    def test_set_badvaluetype(self):
+        with self.assertRaises(environment.BadValueType):
+            self.env.set('var', 123)
+
+    def test_prepend_badnametype(self):
+        with self.assertRaises(environment.BadNameType):
+            self.env.prepend(123, '123')
+
+    def test_prepend_badvaluetype(self):
+        with self.assertRaises(environment.BadValueType):
+            self.env.prepend('var', 123)
+
+    def test_append_badnametype(self):
+        with self.assertRaises(environment.BadNameType):
+            self.env.append(123, '123')
+
+    def test_append_badvaluetype(self):
+        with self.assertRaises(environment.BadValueType):
+            self.env.append('var', 123)
+
+    def test_set_badname_empty(self):
+        with self.assertRaises(environment.BadVariableName):
+            self.env.set('', '123')
+
+    def test_set_badname_digitstart(self):
+        with self.assertRaises(environment.BadVariableName):
+            self.env.set('123', '123')
+
+    def test_set_badname_equals(self):
+        with self.assertRaises(environment.BadVariableName):
+            self.env.set('foo=bar', '123')
+
+    def test_set_badname_period(self):
+        with self.assertRaises(environment.BadVariableName):
+            self.env.set('abc.def', '123')
+
+    def test_set_badname_hyphen(self):
+        with self.assertRaises(environment.BadVariableName):
+            self.env.set('abc-def', '123')
+
+    def test_set_empty_value(self):
+        with self.assertRaises(environment.EmptyValue):
+            self.env.set('var', '')
+
+    def test_set_newline_in_value(self):
+        with self.assertRaises(environment.NewlineInValue):
+            self.env.set('var', '123\n456')
+
+    def test_equal_sign_in_value(self):
+        with self.assertRaises(environment.BadVariableValue):
+            self.env.append(self.var_already_set, 'pa=th')
+
+
+class _PrependAppendEnvironmentTest(unittest.TestCase):
+    """Tests for env_setup.environment."""
+    def __init__(self, *args, **kwargs):
+        windows = kwargs.pop('windows', False)
+        pathsep = kwargs.pop('pathsep', os.pathsep)
+        allcaps = kwargs.pop('allcaps', False)
+        super(_PrependAppendEnvironmentTest, self).__init__(*args, **kwargs)
+        self.windows = windows
+        self.pathsep = pathsep
+        self.allcaps = allcaps
+
+        # If we're testing Windows behavior and actually running on Windows,
+        # actually launch a subprocess to evaluate the shell init script.
+        # Likewise if we're testing POSIX behavior and actually on a POSIX
+        # system. Tests can check self.run_shell_tests and exit without
+        # doing anything.
+        real_windows = (os.name == 'nt')
+        self.run_shell_tests = (self.windows == real_windows)
+
+    def setUp(self):
+        self.env = environment.Environment(windows=self.windows,
+                                           pathsep=self.pathsep,
+                                           allcaps=self.allcaps)
+
+        self.var_already_set = self.env.normalize_key('VAR_ALREADY_SET')
+        os.environ[self.var_already_set] = self.pathsep.join(
+            'one two three'.split())
+        self.assertIn(self.var_already_set, os.environ)
+
+        self.var_not_set = self.env.normalize_key('VAR_NOT_SET')
+        if self.var_not_set in os.environ:
+            del os.environ[self.var_not_set]
+        self.assertNotIn(self.var_not_set, os.environ)
+
+        self.orig_env = os.environ.copy()
+
+    def split(self, val):
+        return val.split(self.pathsep)
+
+    def tearDown(self):
+        self.assertEqual(os.environ, self.orig_env)
+
+
+# TODO(mohrr) remove disable=useless-object-inheritance once in Python 3.
+# pylint: disable=useless-object-inheritance
+class _AppendPrependTestMixin(object):
+    def test_prepend_present_ctx(self):
+        orig = os.environ[self.var_already_set]
+        self.env.prepend(self.var_already_set, 'path')
+        with self.env(export=False) as env:
+            self.assertEqual(env[self.var_already_set],
+                             self.pathsep.join(('path', orig)))
+
+    def test_prepend_present_written(self):
+        if not self.run_shell_tests:
+            return
+
+        orig = os.environ[self.var_already_set]
+        self.env.prepend(self.var_already_set, 'path')
+        env = _evaluate_env_in_shell(self.env)
+        self.assertEqual(env[self.var_already_set],
+                         self.pathsep.join(('path', orig)))
+
+    def test_prepend_notpresent_ctx(self):
+        self.env.prepend(self.var_not_set, 'path')
+        with self.env(export=False) as env:
+            self.assertEqual(env[self.var_not_set], 'path')
+
+    def test_prepend_notpresent_written(self):
+        if not self.run_shell_tests:
+            return
+
+        self.env.prepend(self.var_not_set, 'path')
+        env = _evaluate_env_in_shell(self.env)
+        self.assertEqual(env[self.var_not_set], 'path')
+
+    def test_append_present_ctx(self):
+        orig = os.environ[self.var_already_set]
+        self.env.append(self.var_already_set, 'path')
+        with self.env(export=False) as env:
+            self.assertEqual(env[self.var_already_set],
+                             self.pathsep.join((orig, 'path')))
+
+    def test_append_present_written(self):
+        if not self.run_shell_tests:
+            return
+
+        orig = os.environ[self.var_already_set]
+        self.env.append(self.var_already_set, 'path')
+        env = _evaluate_env_in_shell(self.env)
+        self.assertEqual(env[self.var_already_set],
+                         self.pathsep.join((orig, 'path')))
+
+    def test_append_notpresent_ctx(self):
+        self.env.append(self.var_not_set, 'path')
+        with self.env(export=False) as env:
+            self.assertEqual(env[self.var_not_set], 'path')
+
+    def test_append_notpresent_written(self):
+        if not self.run_shell_tests:
+            return
+
+        self.env.append(self.var_not_set, 'path')
+        env = _evaluate_env_in_shell(self.env)
+        self.assertEqual(env[self.var_not_set], 'path')
+
+    def test_remove_ctx(self):
+        self.env.set(self.var_not_set,
+                     self.pathsep.join(('path', 'one', 'path', 'two', 'path')))
+
+        self.env.append(self.var_not_set, 'path')
+        with self.env(export=False) as env:
+            self.assertEqual(env[self.var_not_set],
+                             self.pathsep.join(('one', 'two', 'path')))
+
+    def test_remove_written(self):
+        if not self.run_shell_tests:
+            return
+
+        if self.windows:  # TODO(pwbug/231) Re-enable for Windows.
+            return
+
+        self.env.set(self.var_not_set,
+                     self.pathsep.join(('path', 'one', 'path', 'two', 'path')))
+
+        self.env.append(self.var_not_set, 'path')
+        env = _evaluate_env_in_shell(self.env)
+        self.assertEqual(env[self.var_not_set],
+                         self.pathsep.join(('one', 'two', 'path')))
+
+    def test_remove_ctx_space(self):
+        self.env.set(self.var_not_set,
+                     self.pathsep.join(('pa th', 'one', 'pa th', 'two')))
+
+        self.env.append(self.var_not_set, 'pa th')
+        with self.env(export=False) as env:
+            self.assertEqual(env[self.var_not_set],
+                             self.pathsep.join(('one', 'two', 'pa th')))
+
+    def test_remove_written_space(self):
+        if not self.run_shell_tests:
+            return
+
+        if self.windows:  # TODO(pwbug/231) Re-enable for Windows.
+            return
+
+        self.env.set(self.var_not_set,
+                     self.pathsep.join(('pa th', 'one', 'pa th', 'two')))
+
+        self.env.append(self.var_not_set, 'pa th')
+        env = _evaluate_env_in_shell(self.env)
+        self.assertEqual(env[self.var_not_set],
+                         self.pathsep.join(('one', 'two', 'pa th')))
+
+    def test_remove_ctx_empty(self):
+        self.env.remove(self.var_not_set, 'path')
+        with self.env(export=False) as env:
+            self.assertNotIn(self.var_not_set, env)
+
+    def test_remove_written_empty(self):
+        if not self.run_shell_tests:
+            return
+
+        self.env.remove(self.var_not_set, 'path')
+        env = _evaluate_env_in_shell(self.env)
+        self.assertNotIn(self.var_not_set, env)
+
+
+class WindowsEnvironmentTest(_PrependAppendEnvironmentTest,
+                             _AppendPrependTestMixin):
+    def __init__(self, *args, **kwargs):
+        kwargs['pathsep'] = ';'
+        kwargs['windows'] = True
+        kwargs['allcaps'] = True
+        super(WindowsEnvironmentTest, self).__init__(*args, **kwargs)
+
+
+class PosixEnvironmentTest(_PrependAppendEnvironmentTest,
+                           _AppendPrependTestMixin):
+    def __init__(self, *args, **kwargs):
+        kwargs['pathsep'] = ':'
+        kwargs['windows'] = False
+        kwargs['allcaps'] = False
+        super(PosixEnvironmentTest, self).__init__(*args, **kwargs)
+        self.real_windows = (os.name == 'nt')
+
+
+class WindowsCaseInsensitiveTest(unittest.TestCase):
+    def test_lower_handling(self):
+        # This is only for testing case-handling on Windows. It doesn't make
+        # sense to run it on other systems.
+        if os.name != 'nt':
+            return
+
+        lower_var = 'lower_var'
+        upper_var = lower_var.upper()
+
+        if upper_var in os.environ:
+            del os.environ[upper_var]
+
+        self.assertNotIn(lower_var, os.environ)
+
+        env = environment.Environment()
+        env.append(lower_var, 'foo')
+        env.append(upper_var, 'bar')
+        with env(export=False) as env_:
+            self.assertNotIn(lower_var, env_)
+            self.assertIn(upper_var, env_)
+            self.assertEqual(env_[upper_var], 'foo;bar')
+
+
+if __name__ == '__main__':
+    import sys
+    logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
+    unittest.main()
diff --git a/pw_env_setup/py/json_visitor_test.py b/pw_env_setup/py/json_visitor_test.py
new file mode 100644
index 0000000..73ed3c7
--- /dev/null
+++ b/pw_env_setup/py/json_visitor_test.py
@@ -0,0 +1,102 @@
+# 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 for env_setup.environment.
+
+This tests the error-checking, context manager, and written environment scripts
+of the Environment class.
+
+Tests that end in "_ctx" modify the environment and validate it in-process.
+
+Tests that end in "_written" write the environment to a file intended to be
+evaluated by the shell, then launches the shell and then saves the environment.
+This environment is then validated in the test process.
+"""
+
+import json
+import unittest
+
+import six
+
+from pw_env_setup import environment, json_visitor
+
+
+# pylint: disable=super-with-arguments
+class JSONVisitorTest(unittest.TestCase):
+    """Tests for env_setup.json_visitor."""
+    def setUp(self):
+        self.env = environment.Environment()
+
+    def _write_and_parse_json(self):
+        buf = six.StringIO()
+        json_visitor.JSONVisitor(self.env, buf)
+        return json.loads(buf.getvalue())
+
+    def _assert_json(self, value):
+        self.assertEqual(self._write_and_parse_json(), value)
+
+    def test_set(self):
+        self.env.clear('VAR')
+        self.env.set('VAR', '1')
+        self._assert_json({'set': {'VAR': '1'}})
+
+    def test_clear(self):
+        self.env.set('VAR', '1')
+        self.env.clear('VAR')
+        self._assert_json({'set': {'VAR': None}})
+
+    def test_append(self):
+        self.env.append('VAR', 'path1')
+        self.env.append('VAR', 'path2')
+        self.env.append('VAR', 'path3')
+        self._assert_json(
+            {'modify': {
+                'VAR': {
+                    'append': 'path1 path2 path3'.split()
+                }
+            }})
+
+    def test_prepend(self):
+        self.env.prepend('VAR', 'path1')
+        self.env.prepend('VAR', 'path2')
+        self.env.prepend('VAR', 'path3')
+        self._assert_json(
+            {'modify': {
+                'VAR': {
+                    'prepend': 'path3 path2 path1'.split()
+                }
+            }})
+
+    def test_remove(self):
+        self.env.remove('VAR', 'path1')
+        self._assert_json({'modify': {'VAR': {'remove': ['path1']}}})
+
+    def test_echo(self):
+        self.env.echo('echo')
+        self._assert_json({})
+
+    def test_comment(self):
+        self.env.comment('comment')
+        self._assert_json({})
+
+    def test_command(self):
+        self.env.command('command')
+        self._assert_json({})
+
+    def test_doctor(self):
+        self.env.doctor()
+        self._assert_json({})
+
+    def test_function(self):
+        self.env.function('name', 'body')
+        self._assert_json({})
diff --git a/pw_env_setup/py/pw_env_setup/apply_visitor.py b/pw_env_setup/py/pw_env_setup/apply_visitor.py
new file mode 100644
index 0000000..b26f33a
--- /dev/null
+++ b/pw_env_setup/py/pw_env_setup/apply_visitor.py
@@ -0,0 +1,79 @@
+# Copyright 2021 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.
+"""Applies an Environment to the current process."""
+
+import os
+
+# Disable super() warnings since this file must be Python 2 compatible.
+# pylint: disable=super-with-arguments
+
+
+class ApplyVisitor(object):  # pylint: disable=useless-object-inheritance
+    """Applies an Environment to the current process."""
+    def __init__(self, *args, **kwargs):
+        pathsep = kwargs.pop('pathsep', os.pathsep)
+        super(ApplyVisitor, self).__init__(*args, **kwargs)
+        self._pathsep = pathsep
+        self._environ = None
+        self._unapply_steps = None
+
+    def apply(self, env, environ):
+        self._unapply_steps = []
+        try:
+            self._environ = environ
+            env.accept(self)
+        finally:
+            self._environ = None
+
+    def visit_set(self, set):  # pylint: disable=redefined-builtin
+        self._environ[set.name] = set.value
+
+    def visit_clear(self, clear):
+        if clear.name in self._environ:
+            del self._environ[clear.name]
+
+    def visit_remove(self, remove):
+        values = self._environ.get(remove.name, '').split(self._pathsep)
+        norm = os.path.normpath
+        values = [x for x in values if norm(x) != norm(remove.value)]
+        self._environ[remove.name] = self._pathsep.join(values)
+
+    def visit_prepend(self, prepend):
+        self._environ[prepend.name] = self._pathsep.join(
+            (prepend.value, self._environ.get(prepend.name, '')))
+
+    def visit_append(self, append):
+        self._environ[append.name] = self._pathsep.join(
+            (self._environ.get(append.name, ''), append.value))
+
+    def visit_echo(self, echo):
+        pass  # Not relevant for apply.
+
+    def visit_comment(self, comment):
+        pass  # Not relevant for apply.
+
+    def visit_command(self, command):
+        pass  # Not relevant for apply.
+
+    def visit_doctor(self, doctor):
+        pass  # Not relevant for apply.
+
+    def visit_blank_line(self, blank_line):
+        pass  # Not relevant for apply.
+
+    def visit_function(self, function):
+        pass  # Not relevant for apply.
+
+    def visit_hash(self, hash):  # pylint: disable=redefined-builtin
+        pass  # Not relevant for apply.
diff --git a/pw_env_setup/py/pw_env_setup/batch_visitor.py b/pw_env_setup/py/pw_env_setup/batch_visitor.py
new file mode 100644
index 0000000..e7c5353
--- /dev/null
+++ b/pw_env_setup/py/pw_env_setup/batch_visitor.py
@@ -0,0 +1,122 @@
+# Copyright 2021 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.
+"""Serializes an Environment into a batch file."""
+
+# Disable super() warnings since this file must be Python 2 compatible.
+# pylint: disable=super-with-arguments
+
+# goto label written to the end of Windows batch files for exiting a script.
+_SCRIPT_END_LABEL = '_pw_end'
+
+
+class BatchVisitor(object):  # pylint: disable=useless-object-inheritance
+    """Serializes an Environment into a batch file."""
+    def __init__(self, *args, **kwargs):
+        pathsep = kwargs.pop('pathsep', ':')
+        super(BatchVisitor, self).__init__(*args, **kwargs)
+        self._replacements = ()
+        self._outs = None
+        self._pathsep = pathsep
+
+    def serialize(self, env, outs):
+        try:
+            self._replacements = tuple(
+                (key, env.get(key) if value is None else value)
+                for key, value in env.replacements)
+            self._outs = outs
+            self._outs.write('@echo off\n')
+
+            env.accept(self)
+
+            outs.write(':{}\n'.format(_SCRIPT_END_LABEL))
+
+        finally:
+            self._replacements = ()
+            self._outs = None
+
+    def _apply_replacements(self, action):
+        value = action.value
+        for var, replacement in self._replacements:
+            if var != action.name:
+                value = value.replace(replacement, '%{}%'.format(var))
+        return value
+
+    def visit_set(self, set):  # pylint: disable=redefined-builtin
+        value = self._apply_replacements(set)
+        self._outs.write('set {name}={value}\n'.format(name=set.name,
+                                                       value=value))
+
+    def visit_clear(self, clear):
+        self._outs.write('set {name}=\n'.format(name=clear.name))
+
+    def visit_remove(self, remove):
+        pass  # Not supported on Windows.
+
+    def _join(self, *args):
+        if len(args) == 1 and isinstance(args[0], (list, tuple)):
+            args = args[0]
+        return self._pathsep.join(args)
+
+    def visit_prepend(self, prepend):
+        value = self._apply_replacements(prepend)
+        value = self._join(value, '%{}%'.format(prepend.name))
+        self._outs.write('set {name}={value}\n'.format(name=prepend.name,
+                                                       value=value))
+
+    def visit_append(self, append):
+        value = self._apply_replacements(append)
+        value = self._join('%{}%'.format(append.name), value)
+        self._outs.write('set {name}={value}\n'.format(name=append.name,
+                                                       value=value))
+
+    def visit_echo(self, echo):
+        if echo.newline:
+            if not echo.value:
+                self._outs.write('echo.\n')
+            else:
+                self._outs.write('echo {}\n'.format(echo.value))
+        else:
+            self._outs.write('<nul set /p="{}"\n'.format(echo.value))
+
+    def visit_comment(self, comment):
+        for line in comment.value.splitlines():
+            self._outs.write(':: {}\n'.format(line))
+
+    def visit_command(self, command):
+        # TODO(mohrr) use shlex.quote here?
+        self._outs.write('{}\n'.format(' '.join(command.command)))
+        if not command.exit_on_error:
+            return
+
+        # Assume failing command produced relevant output.
+        self._outs.write(
+            'if %ERRORLEVEL% neq 0 goto {}\n'.format(_SCRIPT_END_LABEL))
+
+    def visit_doctor(self, doctor):
+        self._outs.write('if "%PW_ACTIVATE_SKIP_CHECKS%"=="" (\n')
+        self.visit_command(doctor)
+        self._outs.write(') else (\n')
+        self._outs.write('echo Skipping environment check because '
+                         'PW_ACTIVATE_SKIP_CHECKS is set\n')
+        self._outs.write(')\n')
+
+    def visit_blank_line(self, blank_line):
+        del blank_line
+        self._outs.write('\n')
+
+    def visit_function(self, function):
+        pass  # Not supported on Windows.
+
+    def visit_hash(self, hash):  # pylint: disable=redefined-builtin
+        pass  # Not relevant on Windows.
diff --git a/pw_env_setup/py/pw_env_setup/cipd_setup/.cipd_version b/pw_env_setup/py/pw_env_setup/cipd_setup/.cipd_version
index 37990e9..d60179a 100644
--- a/pw_env_setup/py/pw_env_setup/cipd_setup/.cipd_version
+++ b/pw_env_setup/py/pw_env_setup/cipd_setup/.cipd_version
@@ -1 +1 @@
-git_revision:c3033615893f7bee937afa99c40a7adc85d7db1f
+git_revision:0595f95a2a689f4f93f1b363fddeeb5614cfd435
diff --git a/pw_env_setup/py/pw_env_setup/cipd_setup/.cipd_version.digests b/pw_env_setup/py/pw_env_setup/cipd_setup/.cipd_version.digests
index 6b5b58b..831294e 100644
--- a/pw_env_setup/py/pw_env_setup/cipd_setup/.cipd_version.digests
+++ b/pw_env_setup/py/pw_env_setup/cipd_setup/.cipd_version.digests
@@ -1,22 +1,23 @@
 # This file was generated by
 #
 #  cipd selfupdate-roll -version-file .cipd_version \
-#      -version git_revision:c3033615893f7bee937afa99c40a7adc85d7db1f
+#      -version git_revision:0595f95a2a689f4f93f1b363fddeeb5614cfd435
 #
 # Do not modify manually. All changes will be overwritten.
 # Use 'cipd selfupdate-roll ...' to modify.
 
-aix-ppc64       sha256  df6bf19e60cf062c0a9d186b0f6c9377c144af9af2511c62feb12166df658fa0
-linux-386       sha256  406a37dfc1e36495edcedb1fa58a101fc10369fccf88a1a68e89181b5b634069
-linux-amd64     sha256  f87c547d09c75eaac7aec940489ed4282988f0628cda232c36666bbd7f2ffcd3
-linux-arm64     sha256  a172f66db1e5951173282b4d5a00ca78c6c19b98aa4025356ca555d802562d18
-linux-armv6l    sha256  8bbda6a984e0f386d899da2da5b94d4601dc19a1ceeb42b6e691c2844ff6c1ce
-linux-mips64    sha256  60f7961bdfab674fd84680e74c42c8b305ff7ee98d1eb626ac36fc327c8654c2
-linux-mips64le  sha256  58e9df2ec3544124747b56699787210aded97d7ccd2459ec877753a32070dd18
-linux-mipsle    sha256  2a5e019011ba90b8e94a5cc9b2bd82f34d193e3c51f8548d8a5b87b526fc0e83
-linux-ppc64     sha256  7fbf4080e68ff4eda82a8a62da1645bda265bc6c1bef034c76e3848244223a5d
-linux-ppc64le   sha256  7a474bb3661c88d1f326a51f99f8e0dc7f832bfd404615f8a6be162ce1840dc0
-linux-s390x     sha256  4aafb01114114395510d2e95e175198e7335247c49ed37ad334526815e3eca05
-mac-amd64       sha256  50d685478353f3243b79a318130c8cd1dd1074fc5a9b84c8bbd14546dfe9288a
-windows-386     sha256  642cb36aac344a94092bc15662c6a43117a2db9e0d2befdc0dc63e14518ec07d
-windows-amd64   sha256  e30a7b981ffab1d54fb1395c2ac183811c1aa17e57a649f877da99a1cfa72176
+aix-ppc64       sha256  15696797ee66c845ce225916013a4636696bd41ed47a0ac535a9124d22c41022
+linux-386       sha256  5470be1985b0e81ec0d23dc00576152545733cde81fa473eb49c33bc4036599c
+linux-amd64     sha256  1acf447faa0b487871cabdd7f6fc6be81386f5984d265ce39f8566513e8cef7f
+linux-arm64     sha256  c84fe3197e270e8e47845714db8bba8ccef5bfa764c1f8d8e93c1998f3159fd3
+linux-armv6l    sha256  0563054c1634d216978d5c551cb1c219288d22e2db5719270e423d1fd65665c3
+linux-mips64    sha256  263de3e97efb3e0c7478e353c2f90212df8f190d03d448bafb47b52dca1abe5e
+linux-mips64le  sha256  49abd7f3d1f92327e689a8a81f4f3cda9c1fe178cab0d05b624befc30711c3ec
+linux-mipsle    sha256  f782241a056dca749731b2412ab04ce252d2d882abba148a46b2b6fd8639e602
+linux-ppc64     sha256  627fa35e35cd9d1192aa142a763225ab937dbafe9b7380b69acc2202b88682e2
+linux-ppc64le   sha256  0ab2152e60571655c65c89381ff151773e70c4418a3dbbc1823d44ff6493567f
+linux-s390x     sha256  e35193afdbc5b44e056acf95debc12c2efaf4714815173fae9254b6ca6bf768f
+mac-amd64       sha256  7d4dce3743c139f9343011ba151f11a702477c02e0add14844f45e6600e56310
+mac-arm64       sha256  6faad926d9bd27f60dd12f1b5fa8d4977360cd8120fbd12e43ce0e7dd2176ee2
+windows-386     sha256  eba4e0ba48d329cb87685b484582cc86780f88be41ccc276409e6e224e70642d
+windows-amd64   sha256  c78ca9c4180ca98a57a697c1cf2ac86ef9ea7df197a606bb8fa67f0702c21837
diff --git a/pw_env_setup/py/pw_env_setup/cipd_setup/pigweed.json b/pw_env_setup/py/pw_env_setup/cipd_setup/pigweed.json
index 846262e..264e86d 100644
--- a/pw_env_setup/py/pw_env_setup/cipd_setup/pigweed.json
+++ b/pw_env_setup/py/pw_env_setup/cipd_setup/pigweed.json
@@ -2,7 +2,7 @@
   {
     "path": "gn/gn/${os}-${arch=amd64,arm64}",
     "tags": [
-      "git_revision:fe9e5db149b0cc78e03511d52c452039dbf5ac1b"
+      "git_revision:b2e3d8622c1ce1bd853c7a11f62a739946669cdd"
     ]
   },
   {
@@ -19,9 +19,9 @@
   },
   {
     "_comment": "TODO(pwbug/93): Package Bazel for Windows.",
-    "path": "pigweed/third_party/bazel/${os=linux,mac}-${arch=amd64}",
+    "path": "fuchsia/third_party/bazel/${os=linux,mac}-${arch=amd64}",
     "tags": [
-      "version:3.2.0"
+      "version:4.0.0"
     ]
   },
   {
@@ -37,9 +37,9 @@
     ]
   },
   {
-    "path": "infra/tools/protoc/${os}-${arch=amd64}",
+    "path": "infra/3pp/tools/protoc/${os}-${arch}",
     "tags": [
-      "protobuf_version:v3.8.0"
+      "version:3.14.0"
     ]
   },
   {
@@ -91,13 +91,13 @@
   {
     "path": "pigweed/host_tools/${os}-${arch=amd64}",
     "tags": [
-      "git_revision:00e773eafb943b25643d2e32b0d0af2f032426b3"
+      "git_revision:2f56efcee3192685898200f6049538ebd809ae36"
     ]
   },
   {
-    "path": "infra/goma/client/${os}-${arch=amd64}",
+    "path": "infra/rbe/client/${os=linux,windows}-${arch=amd64}",
     "tags": [
-      "git_revision:b3d6d03fbdc1d0cfcdae70db30830a08eece4ae1"
+      "git_revision:bbfff8b0a8701cebd503d961c99e9587605b19e2"
     ]
   },
   {
@@ -119,5 +119,11 @@
     "tags": [
       "version:2020-08-05"
     ]
+  },
+  {
+    "path": "infra/3pp/tools/renode/${os=linux}-${arch=amd64}",
+    "tags": [
+      "version:2@renode-1.11.0+20210306gite7897c1"
+    ]
   }
 ]
diff --git a/pw_env_setup/py/pw_env_setup/cipd_setup/update.py b/pw_env_setup/py/pw_env_setup/cipd_setup/update.py
index 84c48c8..c4301ec 100755
--- a/pw_env_setup/py/pw_env_setup/cipd_setup/update.py
+++ b/pw_env_setup/py/pw_env_setup/cipd_setup/update.py
@@ -61,7 +61,7 @@
     return parser.parse_args(argv)
 
 
-def check_auth(cipd, package_files):
+def check_auth(cipd, package_files, spin):
     """Check have access to CIPD pigweed directory."""
 
     paths = []
@@ -77,12 +77,12 @@
                     parts.pop(-1)
                 paths.append('/'.join(parts))
 
+    username = None
     try:
         output = subprocess.check_output([cipd, 'auth-info'],
                                          stderr=subprocess.STDOUT).decode()
         logged_in = True
 
-        username = None
         match = re.search(r'Logged in as (\S*)\.', output)
         if match:
             username = match.group(1)
@@ -90,38 +90,60 @@
     except subprocess.CalledProcessError:
         logged_in = False
 
-    for path in paths:
-        # Not catching CalledProcessError because 'cipd ls' seems to never
-        # return an error code unless it can't reach the CIPD server.
-        output = subprocess.check_output([cipd, 'ls', path],
-                                         stderr=subprocess.STDOUT).decode()
-        if 'No matching packages' not in output:
-            continue
+    def _check_all_paths():
+        inaccessible_paths = []
 
-        # 'cipd ls' only lists sub-packages but ignores any packages at the
-        # given path. 'cipd instances' will give versions of that package.
-        # 'cipd instances' does use an error code if there's no such package or
-        # that package is inaccessible.
-        try:
-            subprocess.check_output([cipd, 'instances', path],
-                                    stderr=subprocess.STDOUT)
-        except subprocess.CalledProcessError:
+        for path in paths:
+            # Not catching CalledProcessError because 'cipd ls' seems to never
+            # return an error code unless it can't reach the CIPD server.
+            output = subprocess.check_output(
+                [cipd, 'ls', path], stderr=subprocess.STDOUT).decode()
+            if 'No matching packages' not in output:
+                continue
+
+            # 'cipd ls' only lists sub-packages but ignores any packages at the
+            # given path. 'cipd instances' will give versions of that package.
+            # 'cipd instances' does use an error code if there's no such package
+            # or that package is inaccessible.
+            try:
+                subprocess.check_output([cipd, 'instances', path],
+                                        stderr=subprocess.STDOUT)
+            except subprocess.CalledProcessError:
+                inaccessible_paths.append(path)
+
+        return inaccessible_paths
+
+    inaccessible_paths = _check_all_paths()
+
+    if inaccessible_paths and not logged_in:
+        with spin.pause():
             stderr = lambda *args: print(*args, file=sys.stderr)
             stderr()
-            stderr('=' * 60)
-            stderr('ERROR: no access to CIPD path "{}"'.format(path))
-            if logged_in:
-                username_part = ''
-                if username:
-                    username_part = '({}) '.format(username)
-                stderr('Your account {}does not have access to this '
-                       'path'.format(username_part))
-            else:
-                stderr('Try logging in with this command:')
-                stderr()
-                stderr('    {} auth-login'.format(cipd))
-            stderr('=' * 60)
-            return False
+            stderr('No access to the following CIPD paths:')
+            for path in inaccessible_paths:
+                stderr('  {}'.format(path))
+            stderr()
+            stderr('Attempting CIPD login')
+            try:
+                subprocess.check_call([cipd, 'auth-login'])
+            except subprocess.CalledProcessError:
+                stderr('CIPD login failed')
+                return False
+
+        inaccessible_paths = _check_all_paths()
+
+    if inaccessible_paths:
+        stderr = lambda *args: print(*args, file=sys.stderr)
+        stderr('=' * 60)
+        username_part = ''
+        if username:
+            username_part = '({}) '.format(username)
+        stderr('Your account {}does not have access to the following '
+               'paths'.format(username_part))
+        for path in inaccessible_paths:
+            stderr('  {}'.format(path))
+        stderr('=' * 60)
+        return False
 
     return True
 
@@ -151,10 +173,11 @@
     root_install_dir,
     cache_dir,
     env_vars=None,
+    spin=None,
 ):
     """Grab the tools listed in ensure_files."""
 
-    if not check_auth(cipd, package_files):
+    if not check_auth(cipd, package_files, spin):
         return False
 
     # TODO(mohrr) use os.makedirs(..., exist_ok=True).
diff --git a/pw_env_setup/py/pw_env_setup/cipd_setup/wrapper.py b/pw_env_setup/py/pw_env_setup/cipd_setup/wrapper.py
index 572f4ab..61843fe 100755
--- a/pw_env_setup/py/pw_env_setup/cipd_setup/wrapper.py
+++ b/pw_env_setup/py/pw_env_setup/cipd_setup/wrapper.py
@@ -40,6 +40,31 @@
 except ImportError:
     import urllib.parse as urlparse  # type: ignore[no-redef]
 
+# Generated from the following command. May need to be periodically rerun.
+# $ cipd ls infra/tools/cipd | perl -pe "s[.*/][];s/^/    '/;s/\s*$/',\n/;"
+SUPPORTED_PLATFORMS = (
+    'aix-ppc64',
+    'linux-386',
+    'linux-amd64',
+    'linux-arm64',
+    'linux-armv6l',
+    'linux-mips64',
+    'linux-mips64le',
+    'linux-mipsle',
+    'linux-ppc64',
+    'linux-ppc64le',
+    'linux-s390x',
+    'mac-amd64',
+    'mac-arm64',
+    'windows-386',
+    'windows-amd64',
+)
+
+
+class UnsupportedPlatform(Exception):
+    pass
+
+
 try:
     SCRIPT_DIR = os.path.dirname(__file__)
 except NameError:  # __file__ not defined.
@@ -195,10 +220,11 @@
         print('=' * 70)
         raise
 
-    path = '/client?platform={platform}-{arch}&version={version}'.format(
-        platform=platform_normalized(),
-        arch=arch_normalized(),
-        version=version)
+    full_platform = '{}-{}'.format(platform_normalized(), arch_normalized())
+    if full_platform not in SUPPORTED_PLATFORMS:
+        raise UnsupportedPlatform(full_platform)
+
+    path = '/client?platform={}&version={}'.format(full_platform, version)
 
     for _ in range(10):
         try:
@@ -243,7 +269,8 @@
         else:
             break
 
-    raise Exception('failed to download client')
+    raise Exception('failed to download client from https://{}{}'.format(
+        CIPD_HOST, path))
 
 
 def bootstrap(client, silent=('PW_ENVSETUP_QUIET' in os.environ)):
@@ -284,26 +311,46 @@
     subprocess.check_call(cmd)
 
 
-def init(install_dir=DEFAULT_INSTALL_DIR, silent=False):
-    """Install/update cipd client."""
-
-    os.environ['CIPD_HTTP_USER_AGENT_PREFIX'] = user_agent()
-
+def _default_client(install_dir):
     client = os.path.join(install_dir, 'cipd')
     if os.name == 'nt':
         client += '.exe'
+    return client
+
+
+def init(install_dir=DEFAULT_INSTALL_DIR, silent=False, client=None):
+    """Install/update cipd client."""
+
+    if not client:
+        client = _default_client(install_dir)
+
+    os.environ['CIPD_HTTP_USER_AGENT_PREFIX'] = user_agent()
+
+    if not os.path.isfile(client):
+        bootstrap(client, silent)
 
     try:
-        if not os.path.isfile(client):
-            bootstrap(client, silent)
+        selfupdate(client)
+    except subprocess.CalledProcessError:
+        print('CIPD selfupdate failed. Bootstrapping then retrying...',
+              file=sys.stderr)
+        bootstrap(client)
+        selfupdate(client)
 
-        try:
-            selfupdate(client)
-        except subprocess.CalledProcessError:
-            print('CIPD selfupdate failed. Bootstrapping then retrying...',
-                  file=sys.stderr)
-            bootstrap(client)
-            selfupdate(client)
+    return client
+
+
+def main(install_dir=DEFAULT_INSTALL_DIR, silent=False):
+    """Install/update cipd client."""
+
+    client = _default_client(install_dir)
+
+    try:
+        init(install_dir=install_dir, silent=silent, client=client)
+
+    except UnsupportedPlatform:
+        # Don't show help message below for this exception.
+        raise
 
     except Exception:
         print('Failed to initialize CIPD. Run '
@@ -321,5 +368,5 @@
 
 
 if __name__ == '__main__':
-    client_exe = init()
+    client_exe = main()
     subprocess.check_call([client_exe] + sys.argv[1:])
diff --git a/pw_env_setup/py/pw_env_setup/env_setup.py b/pw_env_setup/py/pw_env_setup/env_setup.py
index a03fdf4..794086c 100755
--- a/pw_env_setup/py/pw_env_setup/env_setup.py
+++ b/pw_env_setup/py/pw_env_setup/env_setup.py
@@ -166,6 +166,10 @@
     return result
 
 
+class ConfigFileError(Exception):
+    pass
+
+
 # TODO(mohrr) remove disable=useless-object-inheritance once in Python 3.
 # pylint: disable=useless-object-inheritance
 # pylint: disable=too-many-instance-attributes
@@ -175,7 +179,8 @@
     def __init__(self, pw_root, cipd_cache_dir, shell_file, quiet, install_dir,
                  use_pigweed_defaults, cipd_package_file, virtualenv_root,
                  virtualenv_requirements, virtualenv_gn_target,
-                 cargo_package_file, enable_cargo, json_file, project_root):
+                 virtualenv_gn_out_dir, cargo_package_file, enable_cargo,
+                 json_file, project_root, config_file):
         self._env = environment.Environment()
         self._project_root = project_root
         self._pw_root = pw_root
@@ -201,6 +206,9 @@
         self._cargo_package_file = []
         self._enable_cargo = enable_cargo
 
+        if config_file:
+            self._parse_config_file(config_file)
+
         self._json_file = json_file
 
         setup_root = os.path.join(pw_root, 'pw_env_setup', 'py',
@@ -217,18 +225,18 @@
                 os.path.join(setup_root, 'cipd_setup', 'pigweed.json'))
             self._cipd_package_file.append(
                 os.path.join(setup_root, 'cipd_setup', 'luci.json'))
-            self._virtualenv_requirements.append(
-                os.path.join(setup_root, 'virtualenv_setup',
-                             'requirements.txt'))
-            self._virtualenv_gn_targets.append(
-                virtualenv_setup.GnTarget(
-                    '{}#:python.install'.format(pw_root)))
+            # Only set if no other GN target is provided.
+            if not virtualenv_gn_target:
+                self._virtualenv_gn_targets.append(
+                    virtualenv_setup.GnTarget(
+                        '{}#pw_env_setup:python.install'.format(pw_root)))
             self._cargo_package_file.append(
                 os.path.join(setup_root, 'cargo_setup', 'packages.txt'))
 
         self._cipd_package_file.extend(cipd_package_file)
         self._virtualenv_requirements.extend(virtualenv_requirements)
         self._virtualenv_gn_targets.extend(virtualenv_gn_target)
+        self._virtualenv_gn_out_dir = virtualenv_gn_out_dir
         self._cargo_package_file.extend(cargo_package_file)
 
         self._env.set('PW_PROJECT_ROOT', project_root)
@@ -237,6 +245,33 @@
         self._env.add_replacement('_PW_ACTUAL_ENVIRONMENT_ROOT', install_dir)
         self._env.add_replacement('PW_ROOT', pw_root)
 
+    def _parse_config_file(self, config_file):
+        config = json.load(config_file)
+
+        self._cipd_package_file.extend(
+            os.path.join(self._project_root, x)
+            for x in config.pop('cipd_package_files', ()))
+
+        virtualenv = config.pop('virtualenv', {})
+
+        if virtualenv.get('gn_root'):
+            root = os.path.join(self._project_root, virtualenv.pop('gn_root'))
+        else:
+            root = self._project_root
+
+        for target in virtualenv.pop('gn_targets', ()):
+            self._virtualenv_gn_targets.append(
+                virtualenv_setup.GnTarget('{}#{}'.format(root, target)))
+
+        if virtualenv:
+            raise ConfigFileError(
+                'unrecognized option in {}: "virtualenv.{}"'.format(
+                    config_file.name, next(iter(virtualenv))))
+
+        if config:
+            raise ConfigFileError('unrecognized option in {}: "{}"'.format(
+                config_file.name, next(iter(config))))
+
     def _log(self, *args, **kwargs):
         # Not using logging module because it's awkward to flush a log handler.
         if self._quiet:
@@ -304,7 +339,7 @@
 
             spin = spinner.Spinner()
             with spin():
-                result = step()
+                result = step(spin)
 
             self._log(result.status_str())
 
@@ -321,13 +356,10 @@
 
         self._env.finalize()
 
-        self._env.echo(Color.bold('Sanity checking the environment:'))
+        self._env.echo(Color.bold('Checking the environment:'))
         self._env.echo()
 
-        log_level = 'warn' if 'PW_ENVSETUP_QUIET' in os.environ else 'info'
-        doctor = ['pw', '--no-banner', '--loglevel', log_level, 'doctor']
-
-        self._env.command(doctor)
+        self._env.doctor()
         self._env.echo()
 
         self._env.echo(
@@ -362,10 +394,16 @@
 
         return 0
 
-    def cipd(self):
+    def cipd(self, spin):
         install_dir = os.path.join(self._install_dir, 'cipd')
 
-        cipd_client = cipd_wrapper.init(install_dir, silent=True)
+        try:
+            cipd_client = cipd_wrapper.init(install_dir, silent=True)
+        except cipd_wrapper.UnsupportedPlatform as exc:
+            return result_func(('    {!r}'.format(exc), ))(
+                _Result.Status.SKIPPED,
+                '    abandoning CIPD setup',
+            )
 
         package_files, glob_warnings = _process_globs(self._cipd_package_file)
         result = result_func(glob_warnings)
@@ -377,12 +415,13 @@
                                   root_install_dir=install_dir,
                                   package_files=package_files,
                                   cache_dir=self._cipd_cache_dir,
-                                  env_vars=self._env):
+                                  env_vars=self._env,
+                                  spin=spin):
             return result(_Result.Status.FAILED)
 
         return result(_Result.Status.DONE)
 
-    def virtualenv(self):
+    def virtualenv(self, unused_spin):
         """Setup virtualenv."""
 
         requirements, req_glob_warnings = _process_globs(
@@ -413,6 +452,7 @@
                 venv_path=self._virtualenv_root,
                 requirements=requirements,
                 gn_targets=self._virtualenv_gn_targets,
+                gn_out_dir=self._virtualenv_gn_out_dir,
                 python=new_python3,
                 env=self._env,
         ):
@@ -420,7 +460,7 @@
 
         return result(_Result.Status.DONE)
 
-    def host_tools(self):
+    def host_tools(self, unused_spin):
         # The host tools are grabbed from CIPD, at least initially. If the
         # user has a current host build, that build will be used instead.
         # TODO(mohrr) find a way to do stuff like this for all projects.
@@ -428,14 +468,14 @@
         self._env.prepend('PATH', os.path.join(host_dir, 'host_tools'))
         return _Result(_Result.Status.DONE)
 
-    def win_scripts(self):
+    def win_scripts(self, unused_spin):
         # These scripts act as a compatibility layer for windows.
         env_setup_dir = os.path.join(self._pw_root, 'pw_env_setup')
         self._env.prepend('PATH', os.path.join(env_setup_dir,
                                                'windows_scripts'))
         return _Result(_Result.Status.DONE)
 
-    def cargo(self):
+    def cargo(self, unused_spin):
         install_dir = os.path.join(self._install_dir, 'cargo')
 
         package_files, glob_warnings = _process_globs(self._cargo_package_file)
@@ -506,6 +546,12 @@
     )
 
     parser.add_argument(
+        '--config-file',
+        help='JSON file describing CIPD and virtualenv requirements.',
+        type=argparse.FileType('r'),
+    )
+
+    parser.add_argument(
         '--use-pigweed-defaults',
         help='Use Pigweed default values in addition to the given environment '
         'variables.',
@@ -530,13 +576,19 @@
     parser.add_argument(
         '--virtualenv-gn-target',
         help=('GN targets that build and install Python packages. Format: '
-              "path/to/gn_root#target"),
+              'path/to/gn_root#target'),
         default=[],
         action='append',
         type=virtualenv_setup.GnTarget,
     )
 
     parser.add_argument(
+        '--virtualenv-gn-out-dir',
+        help=('Output directory to use when building and installing Python '
+              'packages with GN; defaults to a unique path in the environment '
+              'directory.'))
+
+    parser.add_argument(
         '--virtualenv-root',
         help=('Root of virtualenv directory. Default: '
               '<install_dir>/pigweed-venv'),
@@ -565,7 +617,7 @@
 
     args = parser.parse_args(argv)
 
-    one_required = (
+    others = (
         'use_pigweed_defaults',
         'cipd_package_file',
         'virtualenv_requirements',
@@ -573,10 +625,17 @@
         'cargo_package_file',
     )
 
+    one_required = others + ('config_file', )
+
     if not any(getattr(args, x) for x in one_required):
         parser.error('At least one of ({}) is required'.format(', '.join(
             '"--{}"'.format(x.replace('_', '-')) for x in one_required)))
 
+    if args.config_file and any(getattr(args, x) for x in others):
+        parser.error('Cannot combine --config-file with any of {}'.format(
+            ', '.join('"--{}"'.format(x.replace('_', '-'))
+                      for x in one_required)))
+
     return args
 
 
diff --git a/pw_env_setup/py/pw_env_setup/environment.py b/pw_env_setup/py/pw_env_setup/environment.py
index 3e4c9b2..1a97c0a 100644
--- a/pw_env_setup/py/pw_env_setup/environment.py
+++ b/pw_env_setup/py/pw_env_setup/environment.py
@@ -14,7 +14,6 @@
 """Stores the environment changes necessary for Pigweed."""
 
 import contextlib
-import json
 import os
 import re
 
@@ -27,12 +26,14 @@
 except ImportError:
     from io import StringIO
 
+from . import apply_visitor
+from . import batch_visitor
+from . import json_visitor
+from . import shell_visitor
+
 # Disable super() warnings since this file must be Python 2 compatible.
 # pylint: disable=super-with-arguments
 
-# goto label written to the end of Windows batch files for exiting a script.
-_SCRIPT_END_LABEL = '_pw_end'
-
 
 class BadNameType(TypeError):
     pass
@@ -58,12 +59,18 @@
     pass
 
 
+class AcceptNotOverridden(TypeError):
+    pass
+
+
 class _Action(object):  # pylint: disable=useless-object-inheritance
     def unapply(self, env, orig_env):
         pass
 
-    def json(self, data):
-        pass
+    def accept(self, visitor):
+        del visitor
+        raise AcceptNotOverridden('accept() not overridden for {}'.format(
+            self.__class__.__name__))
 
     def write_deactivate(self,
                          outs,
@@ -118,43 +125,10 @@
             env.pop(self.name, None)
 
 
-def _var_form(variable, windows=(os.name == 'nt')):
-    if windows:
-        return '%{}%'.format(variable)
-    return '${}'.format(variable)
-
-
 class Set(_VariableAction):
     """Set a variable."""
-    def write(self, outs, windows=(os.name == 'nt'), replacements=()):
-        value = self.value
-        for var, replacement in replacements:
-            if var != self.name:
-                value = value.replace(replacement, _var_form(var, windows))
-
-        if windows:
-            outs.write('set {name}={value}\n'.format(name=self.name,
-                                                     value=value))
-        else:
-            outs.write('{name}="{value}"\nexport {name}\n'.format(
-                name=self.name, value=value))
-
-    def write_deactivate(self,
-                         outs,
-                         windows=(os.name == 'nt'),
-                         replacements=()):
-        del replacements  # Unused.
-
-        if windows:
-            outs.write('set {name}=\n'.format(name=self.name))
-        else:
-            outs.write('unset {name}\n'.format(name=self.name))
-
-    def apply(self, env):
-        env[self.name] = self.value
-
-    def json(self, data):
-        data['set'][self.name] = self.value
+    def accept(self, visitor):
+        visitor.visit_set(self)
 
 
 class Clear(_VariableAction):
@@ -164,76 +138,14 @@
         kwargs['allow_empty_values'] = True
         super(Clear, self).__init__(*args, **kwargs)
 
-    def write(self, outs, windows=(os.name == 'nt'), replacements=()):
-        del replacements  # Unused.
-        if windows:
-            outs.write('set {name}=\n'.format(**vars(self)))
-        else:
-            outs.write('unset {name}\n'.format(**vars(self)))
-
-    def apply(self, env):
-        if self.name in env:
-            del env[self.name]
-
-    def json(self, data):
-        data['set'][self.name] = None
-
-
-def _initialize_path_like_variable(data, name):
-    default = {'append': [], 'prepend': [], 'remove': []}
-    data['modify'].setdefault(name, default)
-
-
-def _remove_value_from_path(variable, value, pathsep):
-    return ('{variable}="$(echo "${variable}"'
-            ' | sed "s|{pathsep}{value}{pathsep}|{pathsep}|g;"'
-            ' | sed "s|^{value}{pathsep}||g;"'
-            ' | sed "s|{pathsep}{value}$||g;"'
-            ')"\nexport {variable}\n'.format(variable=variable,
-                                             value=value,
-                                             pathsep=pathsep))
+    def accept(self, visitor):
+        visitor.visit_clear(self)
 
 
 class Remove(_VariableAction):
     """Remove a value from a PATH-like variable."""
-    def __init__(self, name, value, pathsep, *args, **kwargs):
-        super(Remove, self).__init__(name, value, *args, **kwargs)
-        self._pathsep = pathsep
-
-    def write(self, outs, windows=(os.name == 'nt'), replacements=()):
-        value = self.value
-        for var, replacement in replacements:
-            if var != self.name:
-                value = value.replace(replacement, _var_form(var, windows))
-
-        if windows:
-            pass
-            # TODO(pwbug/231) This does not seem to be supported when value
-            # contains a %variable%. Disabling for now.
-            # outs.write(':: Remove\n::   {value}\n:: from\n::   {name}\n'
-            #            ':: before adding it back.\n'
-            #            'set {name}=%{name}:{value}{pathsep}=%\n'.format(
-            #              name=self.name, value=value, pathsep=self._pathsep))
-
-        else:
-            outs.write('# Remove \n#   {value}\n# from\n#   {value}\n# before '
-                       'adding it back.\n')
-            outs.write(_remove_value_from_path(self.name, value,
-                                               self._pathsep))
-
-    def apply(self, env):
-        env[self.name] = env[self.name].replace(
-            '{}{}'.format(self.value, self._pathsep), '')
-        env[self.name] = env[self.name].replace(
-            '{}{}'.format(self._pathsep, self.value), '')
-
-    def json(self, data):
-        _initialize_path_like_variable(data, self.name)
-        data['modify'][self.name]['remove'].append(self.value)
-        if self.value in data['modify'][self.name]['append']:
-            data['modify'][self.name]['append'].remove(self.value)
-        if self.value in data['modify'][self.name]['prepend']:
-            data['modify'][self.name]['prepend'].remove(self.value)
+    def accept(self, visitor):
+        visitor.visit_remove(self)
 
 
 class BadVariableValue(ValueError):
@@ -251,44 +163,12 @@
         super(Prepend, self).__init__(name, value, *args, **kwargs)
         self._join = join
 
-    def write(self, outs, windows=(os.name == 'nt'), replacements=()):
-        value = self.value
-        for var, replacement in replacements:
-            if var != self.name:
-                value = value.replace(replacement, _var_form(var, windows))
-        value = self._join(value, _var_form(self.name, windows))
-
-        if windows:
-            outs.write('set {name}={value}\n'.format(name=self.name,
-                                                     value=value))
-        else:
-            outs.write('{name}="{value}"\nexport {name}\n'.format(
-                name=self.name, value=value))
-
-    def write_deactivate(self,
-                         outs,
-                         windows=(os.name == 'nt'),
-                         replacements=()):
-        value = self.value
-        for var, replacement in replacements:
-            if var != self.name:
-                value = value.replace(replacement, _var_form(var, windows))
-
-        outs.write(
-            _remove_value_from_path(self.name, value, self._join.pathsep))
-
-    def apply(self, env):
-        env[self.name] = self._join(self.value, env.get(self.name, ''))
-
     def _check(self):
         super(Prepend, self)._check()
         _append_prepend_check(self)
 
-    def json(self, data):
-        _initialize_path_like_variable(data, self.name)
-        data['modify'][self.name]['prepend'].append(self.value)
-        if self.value in data['modify'][self.name]['remove']:
-            data['modify'][self.name]['remove'].remove(self.value)
+    def accept(self, visitor):
+        visitor.visit_prepend(self)
 
 
 class Append(_VariableAction):
@@ -297,44 +177,12 @@
         super(Append, self).__init__(name, value, *args, **kwargs)
         self._join = join
 
-    def write(self, outs, windows=(os.name == 'nt'), replacements=()):
-        value = self.value
-        for var, repl_value in replacements:
-            if var != self.name:
-                value = value.replace(repl_value, _var_form(var, windows))
-        value = self._join(_var_form(self.name, windows), value)
-
-        if windows:
-            outs.write('set {name}={value}\n'.format(name=self.name,
-                                                     value=value))
-        else:
-            outs.write('{name}="{value}"\nexport {name}\n'.format(
-                name=self.name, value=value))
-
-    def write_deactivate(self,
-                         outs,
-                         windows=(os.name == 'nt'),
-                         replacements=()):
-        value = self.value
-        for var, replacement in replacements:
-            if var != self.name:
-                value = value.replace(replacement, _var_form(var, windows))
-
-        outs.write(
-            _remove_value_from_path(self.name, value, self._join.pathsep))
-
-    def apply(self, env):
-        env[self.name] = self._join(env.get(self.name, ''), self.value)
-
     def _check(self):
         super(Append, self)._check()
         _append_prepend_check(self)
 
-    def json(self, data):
-        _initialize_path_like_variable(data, self.name)
-        data['modify'][self.name]['append'].append(self.value)
-        if self.value in data['modify'][self.name]['remove']:
-            data['modify'][self.name]['remove'].remove(self.value)
+    def accept(self, visitor):
+        visitor.visit_append(self)
 
 
 class BadEchoValue(ValueError):
@@ -349,31 +197,10 @@
             raise BadEchoValue(value)
         super(Echo, self).__init__(*args, **kwargs)
         self.value = value
-        self._newline = newline
+        self.newline = newline
 
-    def write(self, outs, windows=(os.name == 'nt'), replacements=()):
-        del replacements  # Unused.
-        # POSIX shells parse arguments and pass to echo, but Windows seems to
-        # pass the command line as is without parsing, so quoting is wrong.
-        if windows:
-            if self._newline:
-                if not self.value:
-                    outs.write('echo.\n')
-                else:
-                    outs.write('echo {}\n'.format(self.value))
-            else:
-                outs.write('<nul set /p="{}"\n'.format(self.value))
-        else:
-            # TODO(mohrr) use shlex.quote().
-            outs.write('if [ -z "${PW_ENVSETUP_QUIET:-}" ]; then\n')
-            if self._newline:
-                outs.write('  echo "{}"\n'.format(self.value))
-            else:
-                outs.write('  echo -n "{}"\n'.format(self.value))
-            outs.write('fi\n')
-
-    def apply(self, env):
-        pass
+    def accept(self, visitor):
+        visitor.visit_echo(self)
 
 
 class Comment(_Action):
@@ -382,14 +209,8 @@
         super(Comment, self).__init__(*args, **kwargs)
         self.value = value
 
-    def write(self, outs, windows=(os.name == 'nt'), replacements=()):
-        del replacements  # Unused.
-        comment_char = '::' if windows else '#'
-        for line in self.value.splitlines():
-            outs.write('{} {}\n'.format(comment_char, line))
-
-    def apply(self, env):
-        pass
+    def accept(self, visitor):
+        visitor.visit_comment(self)
 
 
 class Command(_Action):
@@ -401,92 +222,47 @@
         self.command = command
         self.exit_on_error = exit_on_error
 
-    def write(self, outs, windows=(os.name == 'nt'), replacements=()):
-        del replacements  # Unused.
-        # TODO(mohrr) use shlex.quote here?
-        outs.write('{}\n'.format(' '.join(self.command)))
-        if not self.exit_on_error:
-            return
+    def accept(self, visitor):
+        visitor.visit_command(self)
 
-        if windows:
-            outs.write(
-                'if %ERRORLEVEL% neq 0 goto {}\n'.format(_SCRIPT_END_LABEL))
-        else:
-            # Assume failing command produced relevant output.
-            outs.write('if [ "$?" -ne 0 ]; then\n  return 1\nfi\n')
 
-    def apply(self, env):
-        pass
+class Doctor(Command):
+    def __init__(self, *args, **kwargs):
+        log_level = 'warn' if 'PW_ENVSETUP_QUIET' in os.environ else 'info'
+        super(Doctor, self).__init__(
+            command=['pw', '--no-banner', '--loglevel', log_level, 'doctor'],
+            *args,
+            **kwargs)
+
+    def accept(self, visitor):
+        visitor.visit_doctor(self)
 
 
 class BlankLine(_Action):
     """Write a blank line to the init script."""
-    def write(  # pylint: disable=no-self-use
-        self,
-        outs,
-        windows=(os.name == 'nt'),
-        replacements=()):
-        del replacements, windows  # Unused.
-        outs.write('\n')
-
-    def apply(self, env):
-        pass
+    def accept(self, visitor):
+        visitor.visit_blank_line(self)
 
 
 class Function(_Action):
     def __init__(self, name, body, *args, **kwargs):
         super(Function, self).__init__(*args, **kwargs)
-        self._name = name
-        self._body = body
+        self.name = name
+        self.body = body
 
-    def write(self, outs, windows=(os.name == 'nt'), replacements=()):
-        del replacements  # Unused.
-        if windows:
-            return
-
-        outs.write("""
-{name}() {{
-{body}
-}}
-        """.strip().format(name=self._name, body=self._body))
-
-    def apply(self, env):
-        pass
+    def accept(self, visitor):
+        visitor.visit_function(self)
 
 
 class Hash(_Action):
-    def write(  # pylint: disable=no-self-use
-        self,
-        outs,
-        windows=(os.name == 'nt'),
-        replacements=()):
-        del replacements  # Unused.
-
-        if windows:
-            return
-
-        outs.write('''
-# This should detect bash and zsh, which have a hash command that must be
-# called to get it to forget past commands. Without forgetting past
-# commands the $PATH changes we made may not be respected.
-if [ -n "${BASH:-}" -o -n "${ZSH_VERSION:-}" ] ; then
-  hash -r\n
-fi
-''')
-
-    def apply(self, env):
-        pass
+    def accept(self, visitor):
+        visitor.visit_hash(self)
 
 
 class Join(object):  # pylint: disable=useless-object-inheritance
     def __init__(self, pathsep=os.pathsep):
         self.pathsep = pathsep
 
-    def __call__(self, *args):
-        if len(args) == 1 and isinstance(args[0], (list, tuple)):
-            args = args[0]
-        return self.pathsep.join(args)
-
 
 # TODO(mohrr) remove disable=useless-object-inheritance once in Python 3.
 # pylint: disable=useless-object-inheritance
@@ -505,12 +281,12 @@
         self._pathsep = pathsep
         self._windows = windows
         self._allcaps = allcaps
-        self._replacements = []
+        self.replacements = []
         self._join = Join(pathsep)
         self._finalized = False
 
     def add_replacement(self, variable, value=None):
-        self._replacements.append((variable, value))
+        self.replacements.append((variable, value))
 
     def normalize_key(self, name):
         if self._allcaps:
@@ -593,6 +369,10 @@
         self._actions.append(Command(command, exit_on_error=exit_on_error))
         self._blankline()
 
+    def doctor(self):
+        """Run 'pw doctor'."""
+        self._actions.append(Doctor())
+
     def function(self, name, body):
         """Define a function."""
         assert not self._finalized
@@ -611,49 +391,29 @@
 
         if not self._windows:
             buf = StringIO()
-            for action in self._actions:
-                action.write_deactivate(buf, windows=self._windows)
+            self.write_deactivate(buf)
             self._actions.append(Function('_pw_deactivate', buf.getvalue()))
             self._blankline()
 
-    def write(self, outs):
-        """Writes a shell init script to outs."""
-        if self._windows:
-            outs.write('@echo off\n')
-
-        # This is a tuple and not a dictionary because we don't need random
-        # access and order needs to be preserved.
-        replacements = tuple((key, self.get(key) if value is None else value)
-                             for key, value in self._replacements)
-
+    def accept(self, visitor):
         for action in self._actions:
-            action.write(outs,
-                         windows=self._windows,
-                         replacements=replacements)
-
-        if self._windows:
-            outs.write(':{}\n'.format(_SCRIPT_END_LABEL))
+            action.accept(visitor)
 
     def json(self, outs):
-        data = {
-            'modify': {},
-            'set': {},
-        }
+        json_visitor.JSONVisitor().serialize(self, outs)
 
-        for action in self._actions:
-            action.json(data)
-
-        json.dump(data, outs, indent=4, separators=(',', ': '))
-        outs.write('\n')
+    def write(self, outs):
+        if self._windows:
+            visitor = batch_visitor.BatchVisitor(pathsep=self._pathsep)
+        else:
+            visitor = shell_visitor.ShellVisitor(pathsep=self._pathsep)
+        visitor.serialize(self, outs)
 
     def write_deactivate(self, outs):
         if self._windows:
-            outs.write('@echo off\n')
-
-        for action in reversed(self._actions):
-            action.write_deactivate(outs,
-                                    windows=self._windows,
-                                    replacements=())
+            return
+        visitor = shell_visitor.DeactivateShellVisitor(pathsep=self._pathsep)
+        visitor.serialize(self, outs)
 
     @contextlib.contextmanager
     def __call__(self, export=True):
@@ -679,15 +439,20 @@
             else:
                 env = os.environ.copy()
 
-            for action in self._actions:
-                action.apply(env)
+            apply = apply_visitor.ApplyVisitor(pathsep=self._pathsep)
+            apply.apply(self, env)
 
             yield env
 
         finally:
             if export:
-                for action in self._actions:
-                    action.unapply(env=os.environ, orig_env=orig_env)
+                for key in set(os.environ):
+                    try:
+                        os.environ[key] = orig_env[key]
+                    except KeyError:
+                        del os.environ[key]
+                for key in set(orig_env) - set(os.environ):
+                    os.environ[key] = orig_env[key]
 
     def get(self, key, default=None):
         """Get the value of a variable within context of this object."""
diff --git a/pw_env_setup/py/pw_env_setup/environment_test.py b/pw_env_setup/py/pw_env_setup/environment_test.py
deleted file mode 100644
index d062d55..0000000
--- a/pw_env_setup/py/pw_env_setup/environment_test.py
+++ /dev/null
@@ -1,454 +0,0 @@
-# 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 for env_setup.environment.
-
-This tests the error-checking, context manager, and written environment scripts
-of the Environment class.
-
-Tests that end in "_ctx" modify the environment and validate it in-process.
-
-Tests that end in "_written" write the environment to a file intended to be
-evaluated by the shell, then launches the shell and then saves the environment.
-This environment is then validated in the test process.
-"""
-
-import logging
-import os
-import subprocess
-import tempfile
-import unittest
-
-import six
-
-from pw_env_setup import environment
-
-# pylint: disable=super-with-arguments
-
-
-class WrittenEnvFailure(Exception):
-    pass
-
-
-def _evaluate_env_in_shell(env):
-    """Write env to a file then evaluate and save the resulting environment.
-
-    Write env to a file, then launch a shell command that sources that file
-    and dumps the environment to stdout. Parse that output into a dict and
-    return it.
-
-    Args:
-      env(environment.Environment): environment to write out
-
-    Returns dictionary of resulting environment.
-    """
-
-    # Write env sourcing script to file.
-    with tempfile.NamedTemporaryFile(
-            prefix='pw-test-written-env-',
-            suffix='.bat' if os.name == 'nt' else '.sh',
-            delete=False,
-            mode='w+') as temp:
-        env.write(temp)
-        temp_name = temp.name
-
-    # Evaluate env sourcing script and capture output of 'env'.
-    if os.name == 'nt':
-        # On Windows you just run batch files and they modify your
-        # environment, no need to call 'source' or '.'.
-        cmd = '{} && set'.format(temp_name)
-    else:
-        # Using '.' instead of 'source' because 'source' is not POSIX.
-        cmd = '. {} && env'.format(temp_name)
-
-    res = subprocess.run(cmd, capture_output=True, shell=True)
-    if res.returncode:
-        raise WrittenEnvFailure(res.stderr)
-
-    # Parse environment from stdout of subprocess.
-    env_ret = {}
-    for line in res.stdout.splitlines():
-        line = line.decode()
-
-        # Some people inexplicably have newlines in some of their
-        # environment variables. This module does not allow that so we can
-        # ignore any such extra lines.
-        if '=' not in line:
-            continue
-
-        var, value = line.split('=', 1)
-        env_ret[var] = value
-
-    return env_ret
-
-
-# pylint: disable=too-many-public-methods
-class EnvironmentTest(unittest.TestCase):
-    """Tests for env_setup.environment."""
-    def setUp(self):
-        self.env = environment.Environment()
-
-        # Name of a variable that is already set when the test starts.
-        self.var_already_set = self.env.normalize_key('var_already_set')
-        os.environ[self.var_already_set] = 'orig value'
-        self.assertIn(self.var_already_set, os.environ)
-
-        # Name of a variable that is not set when the test starts.
-        self.var_not_set = self.env.normalize_key('var_not_set')
-        if self.var_not_set in os.environ:
-            del os.environ[self.var_not_set]
-        self.assertNotIn(self.var_not_set, os.environ)
-
-        self.orig_env = os.environ.copy()
-
-    def tearDown(self):
-        self.assertEqual(os.environ, self.orig_env)
-
-    def test_set_notpresent_ctx(self):
-        self.env.set(self.var_not_set, '1')
-        with self.env(export=False) as env:
-            self.assertIn(self.var_not_set, env)
-            self.assertEqual(env[self.var_not_set], '1')
-
-    def test_set_notpresent_written(self):
-        self.env.set(self.var_not_set, '1')
-        env = _evaluate_env_in_shell(self.env)
-        self.assertIn(self.var_not_set, env)
-        self.assertEqual(env[self.var_not_set], '1')
-
-    def test_set_present_ctx(self):
-        self.env.set(self.var_already_set, '1')
-        with self.env(export=False) as env:
-            self.assertIn(self.var_already_set, env)
-            self.assertEqual(env[self.var_already_set], '1')
-
-    def test_set_present_written(self):
-        self.env.set(self.var_already_set, '1')
-        env = _evaluate_env_in_shell(self.env)
-        self.assertIn(self.var_already_set, env)
-        self.assertEqual(env[self.var_already_set], '1')
-
-    def test_clear_notpresent_ctx(self):
-        self.env.clear(self.var_not_set)
-        with self.env(export=False) as env:
-            self.assertNotIn(self.var_not_set, env)
-
-    def test_clear_notpresent_written(self):
-        self.env.clear(self.var_not_set)
-        env = _evaluate_env_in_shell(self.env)
-        self.assertNotIn(self.var_not_set, env)
-
-    def test_clear_present_ctx(self):
-        self.env.clear(self.var_already_set)
-        with self.env(export=False) as env:
-            self.assertNotIn(self.var_already_set, env)
-
-    def test_clear_present_written(self):
-        self.env.clear(self.var_already_set)
-        env = _evaluate_env_in_shell(self.env)
-        self.assertNotIn(self.var_already_set, env)
-
-    def test_value_replacement(self):
-        self.env.set(self.var_not_set, '/foo/bar/baz')
-        self.env.add_replacement('FOOBAR', '/foo/bar')
-        buf = six.StringIO()
-        self.env.write(buf)
-        assert '/foo/bar' not in buf.getvalue()
-
-    def test_variable_replacement(self):
-        self.env.set('FOOBAR', '/foo/bar')
-        self.env.set(self.var_not_set, '/foo/bar/baz')
-        self.env.add_replacement('FOOBAR')
-        buf = six.StringIO()
-        self.env.write(buf)
-        print(buf.getvalue())
-        assert '/foo/bar/baz' not in buf.getvalue()
-
-    def test_nonglobal(self):
-        self.env.set(self.var_not_set, '1')
-        with self.env(export=False) as env:
-            self.assertIn(self.var_not_set, env)
-            self.assertNotIn(self.var_not_set, os.environ)
-
-    def test_global(self):
-        self.env.set(self.var_not_set, '1')
-        with self.env(export=True) as env:
-            self.assertIn(self.var_not_set, env)
-            self.assertIn(self.var_not_set, os.environ)
-
-    def test_set_badnametype(self):
-        with self.assertRaises(environment.BadNameType):
-            self.env.set(123, '123')
-
-    def test_set_badvaluetype(self):
-        with self.assertRaises(environment.BadValueType):
-            self.env.set('var', 123)
-
-    def test_prepend_badnametype(self):
-        with self.assertRaises(environment.BadNameType):
-            self.env.prepend(123, '123')
-
-    def test_prepend_badvaluetype(self):
-        with self.assertRaises(environment.BadValueType):
-            self.env.prepend('var', 123)
-
-    def test_append_badnametype(self):
-        with self.assertRaises(environment.BadNameType):
-            self.env.append(123, '123')
-
-    def test_append_badvaluetype(self):
-        with self.assertRaises(environment.BadValueType):
-            self.env.append('var', 123)
-
-    def test_set_badname_empty(self):
-        with self.assertRaises(environment.BadVariableName):
-            self.env.set('', '123')
-
-    def test_set_badname_digitstart(self):
-        with self.assertRaises(environment.BadVariableName):
-            self.env.set('123', '123')
-
-    def test_set_badname_equals(self):
-        with self.assertRaises(environment.BadVariableName):
-            self.env.set('foo=bar', '123')
-
-    def test_set_badname_period(self):
-        with self.assertRaises(environment.BadVariableName):
-            self.env.set('abc.def', '123')
-
-    def test_set_badname_hyphen(self):
-        with self.assertRaises(environment.BadVariableName):
-            self.env.set('abc-def', '123')
-
-    def test_set_empty_value(self):
-        with self.assertRaises(environment.EmptyValue):
-            self.env.set('var', '')
-
-    def test_set_newline_in_value(self):
-        with self.assertRaises(environment.NewlineInValue):
-            self.env.set('var', '123\n456')
-
-    def test_equal_sign_in_value(self):
-        with self.assertRaises(environment.BadVariableValue):
-            self.env.append(self.var_already_set, 'pa=th')
-
-
-class _PrependAppendEnvironmentTest(unittest.TestCase):
-    """Tests for env_setup.environment."""
-    def __init__(self, *args, **kwargs):
-        windows = kwargs.pop('windows', False)
-        pathsep = kwargs.pop('pathsep', os.pathsep)
-        allcaps = kwargs.pop('allcaps', False)
-        super(_PrependAppendEnvironmentTest, self).__init__(*args, **kwargs)
-        self.windows = windows
-        self.pathsep = pathsep
-        self.allcaps = allcaps
-
-        # If we're testing Windows behavior and actually running on Windows,
-        # actually launch a subprocess to evaluate the shell init script.
-        # Likewise if we're testing POSIX behavior and actually on a POSIX
-        # system. Tests can check self.run_shell_tests and exit without
-        # doing anything.
-        real_windows = (os.name == 'nt')
-        self.run_shell_tests = (self.windows == real_windows)
-
-    def setUp(self):
-        self.env = environment.Environment(windows=self.windows,
-                                           pathsep=self.pathsep,
-                                           allcaps=self.allcaps)
-
-        self.var_already_set = self.env.normalize_key('VAR_ALREADY_SET')
-        os.environ[self.var_already_set] = self.pathsep.join(
-            'one two three'.split())
-        self.assertIn(self.var_already_set, os.environ)
-
-        self.var_not_set = self.env.normalize_key('VAR_NOT_SET')
-        if self.var_not_set in os.environ:
-            del os.environ[self.var_not_set]
-        self.assertNotIn(self.var_not_set, os.environ)
-
-        self.orig_env = os.environ.copy()
-
-    def split(self, val):
-        return val.split(self.pathsep)
-
-    def tearDown(self):
-        self.assertEqual(os.environ, self.orig_env)
-
-
-# TODO(mohrr) remove disable=useless-object-inheritance once in Python 3.
-# pylint: disable=useless-object-inheritance
-class _AppendPrependTestMixin(object):
-    def test_prepend_present_ctx(self):
-        orig = os.environ[self.var_already_set]
-        self.env.prepend(self.var_already_set, 'path')
-        with self.env(export=False) as env:
-            self.assertEqual(env[self.var_already_set],
-                             self.pathsep.join(('path', orig)))
-
-    def test_prepend_present_written(self):
-        if not self.run_shell_tests:
-            return
-
-        orig = os.environ[self.var_already_set]
-        self.env.prepend(self.var_already_set, 'path')
-        env = _evaluate_env_in_shell(self.env)
-        self.assertEqual(env[self.var_already_set],
-                         self.pathsep.join(('path', orig)))
-
-    def test_prepend_notpresent_ctx(self):
-        self.env.prepend(self.var_not_set, 'path')
-        with self.env(export=False) as env:
-            self.assertEqual(env[self.var_not_set], 'path')
-
-    def test_prepend_notpresent_written(self):
-        if not self.run_shell_tests:
-            return
-
-        self.env.prepend(self.var_not_set, 'path')
-        env = _evaluate_env_in_shell(self.env)
-        self.assertEqual(env[self.var_not_set], 'path')
-
-    def test_append_present_ctx(self):
-        orig = os.environ[self.var_already_set]
-        self.env.append(self.var_already_set, 'path')
-        with self.env(export=False) as env:
-            self.assertEqual(env[self.var_already_set],
-                             self.pathsep.join((orig, 'path')))
-
-    def test_append_present_written(self):
-        if not self.run_shell_tests:
-            return
-
-        orig = os.environ[self.var_already_set]
-        self.env.append(self.var_already_set, 'path')
-        env = _evaluate_env_in_shell(self.env)
-        self.assertEqual(env[self.var_already_set],
-                         self.pathsep.join((orig, 'path')))
-
-    def test_append_notpresent_ctx(self):
-        self.env.append(self.var_not_set, 'path')
-        with self.env(export=False) as env:
-            self.assertEqual(env[self.var_not_set], 'path')
-
-    def test_append_notpresent_written(self):
-        if not self.run_shell_tests:
-            return
-
-        self.env.append(self.var_not_set, 'path')
-        env = _evaluate_env_in_shell(self.env)
-        self.assertEqual(env[self.var_not_set], 'path')
-
-    def test_remove_ctx(self):
-        self.env.set(self.var_not_set,
-                     self.pathsep.join(('path', 'one', 'path', 'two', 'path')))
-
-        self.env.append(self.var_not_set, 'path')
-        with self.env(export=False) as env:
-            self.assertEqual(env[self.var_not_set],
-                             self.pathsep.join(('one', 'two', 'path')))
-
-    def test_remove_written(self):
-        if not self.run_shell_tests:
-            return
-
-        self.env.set(self.var_not_set,
-                     self.pathsep.join(('path', 'one', 'path', 'two', 'path')))
-
-        self.env.append(self.var_not_set, 'path')
-        env = _evaluate_env_in_shell(self.env)
-        self.assertEqual(env[self.var_not_set],
-                         self.pathsep.join(('one', 'two', 'path')))
-
-    def test_remove_ctx_space(self):
-        self.env.set(self.var_not_set,
-                     self.pathsep.join(('pa th', 'one', 'pa th', 'two')))
-
-        self.env.append(self.var_not_set, 'pa th')
-        with self.env(export=False) as env:
-            self.assertEqual(env[self.var_not_set],
-                             self.pathsep.join(('one', 'two', 'pa th')))
-
-    def test_remove_written_space(self):
-        if not self.run_shell_tests:
-            return
-
-        self.env.set(self.var_not_set,
-                     self.pathsep.join(('pa th', 'one', 'pa th', 'two')))
-
-        self.env.append(self.var_not_set, 'pa th')
-        env = _evaluate_env_in_shell(self.env)
-        self.assertEqual(env[self.var_not_set],
-                         self.pathsep.join(('one', 'two', 'pa th')))
-
-    def test_remove_ctx_empty(self):
-        self.env.remove(self.var_not_set, 'path')
-        with self.env(export=False) as env:
-            self.assertNotIn(self.var_not_set, env)
-
-    def test_remove_written_empty(self):
-        if not self.run_shell_tests:
-            return
-
-        self.env.remove(self.var_not_set, 'path')
-        env = _evaluate_env_in_shell(self.env)
-        self.assertNotIn(self.var_not_set, env)
-
-
-class WindowsEnvironmentTest(_PrependAppendEnvironmentTest,
-                             _AppendPrependTestMixin):
-    def __init__(self, *args, **kwargs):
-        kwargs['pathsep'] = ';'
-        kwargs['windows'] = True
-        kwargs['allcaps'] = True
-        super(WindowsEnvironmentTest, self).__init__(*args, **kwargs)
-
-
-class PosixEnvironmentTest(_PrependAppendEnvironmentTest,
-                           _AppendPrependTestMixin):
-    def __init__(self, *args, **kwargs):
-        kwargs['pathsep'] = ':'
-        kwargs['windows'] = False
-        kwargs['allcaps'] = False
-        super(PosixEnvironmentTest, self).__init__(*args, **kwargs)
-        self.real_windows = (os.name == 'nt')
-
-
-class WindowsCaseInsensitiveTest(unittest.TestCase):
-    def test_lower_handling(self):
-        # This is only for testing case-handling on Windows. It doesn't make
-        # sense to run it on other systems.
-        if os.name != 'nt':
-            return
-
-        lower_var = 'lower_var'
-        upper_var = lower_var.upper()
-
-        if upper_var in os.environ:
-            del os.environ[upper_var]
-
-        self.assertNotIn(lower_var, os.environ)
-
-        env = environment.Environment()
-        env.append(lower_var, 'foo')
-        env.append(upper_var, 'bar')
-        with env(export=False) as env_:
-            self.assertNotIn(lower_var, env_)
-            self.assertIn(upper_var, env_)
-            self.assertEqual(env_[upper_var], 'foo;bar')
-
-
-if __name__ == '__main__':
-    import sys
-    logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
-    unittest.main()
diff --git a/pw_env_setup/py/pw_env_setup/json_visitor.py b/pw_env_setup/py/pw_env_setup/json_visitor.py
new file mode 100644
index 0000000..f96acac
--- /dev/null
+++ b/pw_env_setup/py/pw_env_setup/json_visitor.py
@@ -0,0 +1,89 @@
+# Copyright 2021 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.
+"""Serializes an Environment into a JSON file."""
+
+import json
+
+# Disable super() warnings since this file must be Python 2 compatible.
+# pylint: disable=super-with-arguments
+
+
+class JSONVisitor(object):  # pylint: disable=useless-object-inheritance
+    """Serializes an Environment into a JSON file."""
+    def __init__(self, *args, **kwargs):
+        super(JSONVisitor, self).__init__(*args, **kwargs)
+        self._data = {}
+
+    def serialize(self, env, outs):
+        self._data = {
+            'modify': {},
+            'set': {},
+        }
+
+        env.accept(self)
+
+        json.dump(self._data, outs, indent=4, separators=(',', ': '))
+        outs.write('\n')
+        self._data = {}
+
+    def visit_set(self, set):  # pylint: disable=redefined-builtin
+        self._data['set'][set.name] = set.value
+
+    def visit_clear(self, clear):
+        self._data['set'][clear.name] = None
+
+    def _initialize_path_like_variable(self, name):
+        default = {'append': [], 'prepend': [], 'remove': []}
+        self._data['modify'].setdefault(name, default)
+
+    def visit_remove(self, remove):
+        self._initialize_path_like_variable(remove.name)
+        self._data['modify'][remove.name]['remove'].append(remove.value)
+        if remove.value in self._data['modify'][remove.name]['append']:
+            self._data['modify'][remove.name]['append'].remove(remove.value)
+        if remove.value in self._data['modify'][remove.name]['prepend']:
+            self._data['modify'][remove.name]['prepend'].remove(remove.value)
+
+    def visit_prepend(self, prepend):
+        self._initialize_path_like_variable(prepend.name)
+        self._data['modify'][prepend.name]['prepend'].append(prepend.value)
+        if prepend.value in self._data['modify'][prepend.name]['remove']:
+            self._data['modify'][prepend.name]['remove'].remove(prepend.value)
+
+    def visit_append(self, append):
+        self._initialize_path_like_variable(append.name)
+        self._data['modify'][append.name]['append'].append(append.value)
+        if append.value in self._data['modify'][append.name]['remove']:
+            self._data['modify'][append.name]['remove'].remove(append.value)
+
+    def visit_echo(self, echo):
+        pass
+
+    def visit_comment(self, comment):
+        pass
+
+    def visit_command(self, command):
+        pass
+
+    def visit_doctor(self, doctor):
+        pass
+
+    def visit_blank_line(self, blank_line):
+        pass
+
+    def visit_function(self, function):
+        pass
+
+    def visit_hash(self, hash):  # pylint: disable=redefined-builtin
+        pass
diff --git a/pw_env_setup/py/pw_env_setup/shell_visitor.py b/pw_env_setup/py/pw_env_setup/shell_visitor.py
new file mode 100644
index 0000000..18a42fb
--- /dev/null
+++ b/pw_env_setup/py/pw_env_setup/shell_visitor.py
@@ -0,0 +1,196 @@
+# Copyright 2021 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.
+"""Serializes an Environment into a shell file."""
+
+import inspect
+
+# Disable super() warnings since this file must be Python 2 compatible.
+# pylint: disable=super-with-arguments
+
+
+class _BaseShellVisitor(object):  # pylint: disable=useless-object-inheritance
+    def __init__(self, *args, **kwargs):
+        pathsep = kwargs.pop('pathsep', ':')
+        super(_BaseShellVisitor, self).__init__(*args, **kwargs)
+        self._pathsep = pathsep
+        self._outs = None
+
+    def _remove_value_from_path(self, variable, value):
+        return ('{variable}="$(echo "${variable}"'
+                ' | sed "s|{pathsep}{value}{pathsep}|{pathsep}|g;"'
+                ' | sed "s|^{value}{pathsep}||g;"'
+                ' | sed "s|{pathsep}{value}$||g;"'
+                ')"\nexport {variable}\n'.format(variable=variable,
+                                                 value=value,
+                                                 pathsep=self._pathsep))
+
+    def visit_hash(self, hash):  # pylint: disable=redefined-builtin
+        del hash
+        self._outs.write(
+            inspect.cleandoc('''
+        # This should detect bash and zsh, which have a hash command that must
+        # be called to get it to forget past commands. Without forgetting past
+        # commands the $PATH changes we made may not be respected.
+        if [ -n "${BASH:-}" -o -n "${ZSH_VERSION:-}" ] ; then
+            hash -r\n
+        fi
+        '''))
+
+
+class ShellVisitor(_BaseShellVisitor):
+    """Serializes an Environment into a shell file."""
+    def __init__(self, *args, **kwargs):
+        super(ShellVisitor, self).__init__(*args, **kwargs)
+        self._replacements = ()
+
+    def serialize(self, env, outs):
+        try:
+            self._replacements = tuple(
+                (key, env.get(key) if value is None else value)
+                for key, value in env.replacements)
+            self._outs = outs
+
+            env.accept(self)
+
+        finally:
+            self._replacements = ()
+            self._outs = None
+
+    def _apply_replacements(self, action):
+        value = action.value
+        for var, replacement in self._replacements:
+            if var != action.name:
+                value = value.replace(replacement, '${}'.format(var))
+        return value
+
+    def visit_set(self, set):  # pylint: disable=redefined-builtin
+        value = self._apply_replacements(set)
+        self._outs.write('{name}="{value}"\nexport {name}\n'.format(
+            name=set.name, value=value))
+
+    def visit_clear(self, clear):
+        self._outs.write('unset {name}\n'.format(**vars(clear)))
+
+    def visit_remove(self, remove):
+        value = self._apply_replacements(remove)
+        self._outs.write('# Remove \n#   {value}\n# from\n#   {value}\n# '
+                         'before adding it back.\n')
+        self._outs.write(self._remove_value_from_path(remove.name, value))
+
+    def _join(self, *args):
+        if len(args) == 1 and isinstance(args[0], (list, tuple)):
+            args = args[0]
+        return self._pathsep.join(args)
+
+    def visit_prepend(self, prepend):
+        value = self._apply_replacements(prepend)
+        value = self._join(value, '${}'.format(prepend.name))
+        self._outs.write('{name}="{value}"\nexport {name}\n'.format(
+            name=prepend.name, value=value))
+
+    def visit_append(self, append):
+        value = self._apply_replacements(append)
+        value = self._join('${}'.format(append.name), value)
+        self._outs.write('{name}="{value}"\nexport {name}\n'.format(
+            name=append.name, value=value))
+
+    def visit_echo(self, echo):
+        # TODO(mohrr) use shlex.quote().
+        self._outs.write('if [ -z "${PW_ENVSETUP_QUIET:-}" ]; then\n')
+        if echo.newline:
+            self._outs.write('  echo "{}"\n'.format(echo.value))
+        else:
+            self._outs.write('  echo -n "{}"\n'.format(echo.value))
+        self._outs.write('fi\n')
+
+    def visit_comment(self, comment):
+        for line in comment.value.splitlines():
+            self._outs.write('# {}\n'.format(line))
+
+    def visit_command(self, command):
+        # TODO(mohrr) use shlex.quote here?
+        self._outs.write('{}\n'.format(' '.join(command.command)))
+        if not command.exit_on_error:
+            return
+
+        # Assume failing command produced relevant output.
+        self._outs.write('if [ "$?" -ne 0 ]; then\n  return 1\nfi\n')
+
+    def visit_doctor(self, doctor):
+        self._outs.write('if [ -z "$PW_ACTIVATE_SKIP_CHECKS" ]; then\n')
+        self.visit_command(doctor)
+        self._outs.write('else\n')
+        self._outs.write('echo Skipping environment check because '
+                         'PW_ACTIVATE_SKIP_CHECKS is set\n')
+        self._outs.write('fi\n')
+
+    def visit_blank_line(self, blank_line):
+        del blank_line
+        self._outs.write('\n')
+
+    def visit_function(self, function):
+        self._outs.write('{name}() {{\n{body}\n}}\n'.format(
+            name=function.name, body=function.body))
+
+
+class DeactivateShellVisitor(_BaseShellVisitor):
+    """Removes values from an Environment."""
+    def __init__(self, *args, **kwargs):
+        pathsep = kwargs.pop('pathsep', ':')
+        super(DeactivateShellVisitor, self).__init__(*args, **kwargs)
+        self._pathsep = pathsep
+
+    def serialize(self, env, outs):
+        try:
+            self._outs = outs
+
+            env.accept(self)
+
+        finally:
+            self._outs = None
+
+    def visit_set(self, set):  # pylint: disable=redefined-builtin
+        self._outs.write('unset {name}\n'.format(name=set.name))
+
+    def visit_clear(self, clear):
+        pass  # Not relevant.
+
+    def visit_remove(self, remove):
+        pass  # Not relevant.
+
+    def visit_prepend(self, prepend):
+        self._outs.write(
+            self._remove_value_from_path(prepend.name, prepend.value))
+
+    def visit_append(self, append):
+        self._outs.write(
+            self._remove_value_from_path(append.name, append.value))
+
+    def visit_echo(self, echo):
+        pass  # Not relevant.
+
+    def visit_comment(self, comment):
+        pass  # Not relevant.
+
+    def visit_command(self, command):
+        pass  # Not relevant.
+
+    def visit_doctor(self, doctor):
+        pass  # Not relevant.
+
+    def visit_blank_line(self, blank_line):
+        pass  # Not relevant.
+
+    def visit_function(self, function):
+        pass  # Not relevant.
diff --git a/pw_env_setup/py/pw_env_setup/spinner.py b/pw_env_setup/py/pw_env_setup/spinner.py
index 5060395..63d577e 100644
--- a/pw_env_setup/py/pw_env_setup/spinner.py
+++ b/pw_env_setup/py/pw_env_setup/spinner.py
@@ -14,10 +14,13 @@
 """Spinner!"""
 
 import contextlib
+import os
 import sys
 import threading
 import time
 
+PW_ENVSETUP_DISABLE_SPINNER = os.environ.get('PW_ENVSETUP_DISABLE_SPINNER')
+
 
 class Spinner(object):  # pylint: disable=useless-object-inheritance
     """Spinner!"""
@@ -39,11 +42,17 @@
             i = (i + 1) % len(chars)
 
     def start(self):
+        if PW_ENVSETUP_DISABLE_SPINNER:
+            return
+
         self._done = False
         self._thread = threading.Thread(target=self._spin)
         self._thread.start()
 
     def stop(self):
+        if PW_ENVSETUP_DISABLE_SPINNER:
+            return
+
         assert self._thread
         self._done = True
         self._thread.join()
@@ -56,3 +65,11 @@
             yield self
         finally:
             self.stop()
+
+    @contextlib.contextmanager
+    def pause(self):
+        try:
+            self.stop()
+            yield self
+        finally:
+            self.start()
diff --git a/pw_env_setup/py/pw_env_setup/virtualenv_setup/install.py b/pw_env_setup/py/pw_env_setup/virtualenv_setup/install.py
index 6c0e497..d7134d4 100644
--- a/pw_env_setup/py/pw_env_setup/virtualenv_setup/install.py
+++ b/pw_env_setup/py/pw_env_setup/virtualenv_setup/install.py
@@ -121,6 +121,7 @@
         full_envsetup=True,
         requirements=(),
         gn_targets=(),
+        gn_out_dir=None,
         python=sys.executable,
         env=None,
 ):
@@ -129,7 +130,8 @@
     version = subprocess.check_output(
         (python, '--version'), stderr=subprocess.STDOUT).strip().decode()
     # We expect Python 3.8, but if it came from CIPD let it pass anyway.
-    if '3.8' not in version and 'chromium' not in version:
+    if ('3.8' not in version and '3.9' not in version
+            and 'chromium' not in version):
         print('=' * 60, file=sys.stderr)
         print('Unexpected Python version:', version, file=sys.stderr)
         print('=' * 60, file=sys.stderr)
@@ -161,6 +163,8 @@
     venv_python = os.path.join(venv_bin, 'python')
 
     pw_root = os.environ.get('PW_ROOT')
+    if not pw_root and env:
+        pw_root = env.PW_ROOT
     if not pw_root:
         pw_root = git_repo_root()
     if not pw_root:
@@ -187,13 +191,36 @@
                     *requirement_args)
 
     def install_packages(gn_target):
-        build = os.path.join(venv_path, gn_target.name)
+        if gn_out_dir is None:
+            build_dir = os.path.join(venv_path, gn_target.name)
+        else:
+            build_dir = gn_out_dir
+
+        env_log = 'env-{}.log'.format(gn_target.name)
+        env_log_path = os.path.join(venv_path, env_log)
+        with open(env_log_path, 'w') as outs:
+            for key, value in sorted(os.environ.items()):
+                if key.upper().endswith('PATH'):
+                    print(key, '=', file=outs)
+                    # pylint: disable=invalid-name
+                    for v in value.split(os.pathsep):
+                        print('   ', v, file=outs)
+                    # pylint: enable=invalid-name
+                else:
+                    print(key, '=', value, file=outs)
 
         gn_log = 'gn-gen-{}.log'.format(gn_target.name)
         gn_log_path = os.path.join(venv_path, gn_log)
         try:
             with open(gn_log_path, 'w') as outs:
-                subprocess.check_call(('gn', 'gen', build),
+                gn_cmd = (
+                    'gn',
+                    'gen',
+                    build_dir,
+                    '--args=dir_pigweed="{}"'.format(pw_root),
+                )
+                print(gn_cmd, file=outs)
+                subprocess.check_call(gn_cmd,
                                       cwd=os.path.join(project_root,
                                                        gn_target.directory),
                                       stdout=outs,
@@ -207,14 +234,21 @@
         ninja_log_path = os.path.join(venv_path, ninja_log)
         try:
             with open(ninja_log_path, 'w') as outs:
-                ninja_cmd = ['ninja', '-C', build]
+                ninja_cmd = ['ninja', '-C', build_dir]
                 ninja_cmd.append(gn_target.target)
+                print(ninja_cmd, file=outs)
                 subprocess.check_call(ninja_cmd, stdout=outs, stderr=outs)
         except subprocess.CalledProcessError as err:
             with open(ninja_log_path, 'r') as ins:
                 raise subprocess.CalledProcessError(err.returncode, err.cmd,
                                                     ins.read())
 
+        with open(os.path.join(venv_path, 'pip-list.log'), 'w') as outs:
+            subprocess.check_call(
+                [venv_python, '-m', 'pip', 'list'],
+                stdout=outs,
+            )
+
     if gn_targets:
         if env:
             env.set('VIRTUAL_ENV', venv_path)
diff --git a/pw_env_setup/py/pw_env_setup/virtualenv_setup/requirements.in b/pw_env_setup/py/pw_env_setup/virtualenv_setup/requirements.in
deleted file mode 100644
index 9cced8d..0000000
--- a/pw_env_setup/py/pw_env_setup/virtualenv_setup/requirements.in
+++ /dev/null
@@ -1,34 +0,0 @@
-# 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.
-
-# This is the list of packages we want installed. When we want to update them
-# or install additional packages update this file and run
-# `pip-compile requirements.in` and it will update requirements.txt.
-
-# To help manage these files.
-pip-tools
-
-# The below modules are all for documentation generation.
-# TODO(pwbug/198): Create a setup.py for pw_docgen and remove these.
-sphinx
-sphinx-rtd-theme
-
-# Markdown to REST for documentation.
-m2r
-
-# Diagram generation modules.
-sphinxcontrib-actdiag
-sphinxcontrib-blockdiag
-sphinxcontrib-nwdiag
-sphinxcontrib-seqdiag
diff --git a/pw_env_setup/py/pw_env_setup/virtualenv_setup/requirements.txt b/pw_env_setup/py/pw_env_setup/virtualenv_setup/requirements.txt
deleted file mode 100644
index 7e18bfe..0000000
--- a/pw_env_setup/py/pw_env_setup/virtualenv_setup/requirements.txt
+++ /dev/null
@@ -1,49 +0,0 @@
-#
-# This file is autogenerated by pip-compile
-# To update, run:
-#
-#    pip-compile requirements.in
-#
-actdiag==2.0.0            # via sphinxcontrib-actdiag
-alabaster==0.7.12         # via sphinx
-babel==2.7.0              # via sphinx
-blockdiag==2.0.1          # via actdiag, nwdiag, seqdiag, sphinxcontrib-actdiag, sphinxcontrib-blockdiag, sphinxcontrib-nwdiag, sphinxcontrib-seqdiag
-certifi==2019.9.11        # via requests
-chardet==3.0.4            # via requests
-click==7.0                # via pip-tools
-docutils==0.15.2          # via m2r, sphinx
-funcparserlib==0.3.6      # via blockdiag
-idna==2.8                 # via requests
-imagesize==1.1.0          # via sphinx
-jinja2==2.10.3            # via sphinx
-m2r==0.2.1                # via -r requirements.in
-markupsafe==1.1.1         # via jinja2
-mistune==0.8.4            # via m2r
-nwdiag==2.0.0             # via sphinxcontrib-nwdiag
-packaging==19.2           # via sphinx
-pillow==7.1.2             # via blockdiag
-pip-tools==5.1.2          # via -r requirements.in
-pygments==2.4.2           # via sphinx
-pyparsing==2.4.5          # via packaging
-pytz==2019.3              # via babel
-requests==2.22.0          # via sphinx
-seqdiag==2.0.0            # via sphinxcontrib-seqdiag
-six==1.13.0               # via packaging, pip-tools
-snowballstemmer==2.0.0    # via sphinx
-sphinx-rtd-theme==0.4.3   # via -r requirements.in
-sphinx==2.2.1             # via -r requirements.in, sphinx-rtd-theme, sphinxcontrib-actdiag, sphinxcontrib-blockdiag, sphinxcontrib-nwdiag, sphinxcontrib-seqdiag
-sphinxcontrib-actdiag==2.0.0  # via -r requirements.in
-sphinxcontrib-applehelp==1.0.1  # via sphinx
-sphinxcontrib-blockdiag==2.0.0  # via -r requirements.in
-sphinxcontrib-devhelp==1.0.1  # via sphinx
-sphinxcontrib-htmlhelp==1.0.2  # via sphinx
-sphinxcontrib-jsmath==1.0.1  # via sphinx
-sphinxcontrib-nwdiag==2.0.0  # via -r requirements.in
-sphinxcontrib-qthelp==1.0.2  # via sphinx
-sphinxcontrib-seqdiag==2.0.0  # via -r requirements.in
-sphinxcontrib-serializinghtml==1.1.3  # via sphinx
-urllib3==1.25.7           # via requests
-webcolors==1.11.1         # via blockdiag
-
-# The following packages are considered to be unsafe in a requirements file:
-# setuptools
diff --git a/pw_env_setup/py/pw_env_setup/windows_env_start.py b/pw_env_setup/py/pw_env_setup/windows_env_start.py
index 62770a8..4d33075 100644
--- a/pw_env_setup/py/pw_env_setup/windows_env_start.py
+++ b/pw_env_setup/py/pw_env_setup/windows_env_start.py
@@ -26,7 +26,7 @@
 import os
 import sys
 
-from colors import Color, enable_colors  # type: ignore
+from .colors import Color, enable_colors  # type: ignore
 
 _PIGWEED_BANNER = u'''
  ▒█████▄   █▓  ▄███▒  ▒█    ▒█ ░▓████▒ ░▓████▒ ▒▓████▄
diff --git a/pw_env_setup/util.sh b/pw_env_setup/util.sh
index 2a1168f..1379611 100644
--- a/pw_env_setup/util.sh
+++ b/pw_env_setup/util.sh
@@ -12,10 +12,6 @@
 # License for the specific language governing permissions and limitations under
 # the License.
 
-_pw_abspath () {
-  python -c "import os.path; print(os.path.abspath('$@'))"
-}
-
 # Just in case PATH isn't already exported.
 export PATH
 
@@ -73,7 +69,13 @@
 
 pw_eval_sourced() {
   if [ "$1" -eq 0 ]; then
-    _PW_NAME=$(basename "$PW_SETUP_SCRIPT_PATH" .sh)
+    # TODO(pwbug/354) Remove conditional after all downstream projects have
+    # changed to passing in second argument.
+    if [ -n "$2" ]; then
+      _PW_NAME=$(basename "$2" .sh)
+    else
+      _PW_NAME=$(basename "$_BOOTSTRAP_PATH" .sh)
+    fi
     pw_bold_red "Error: Attempting to $_PW_NAME in a subshell"
     pw_red "  Since $_PW_NAME.sh modifies your shell's environment variables,"
     pw_red "  it must be sourced rather than executed. In particular, "
@@ -105,7 +107,11 @@
   # PW_ENVIRONMENT_ROOT came from the developer and not from a previous
   # bootstrap possibly from another workspace.
   if [ -z "$PW_ENVIRONMENT_ROOT" ]; then
-    echo "$PW_ROOT/.environment"
+    if [ -n "$PW_PROJECT_ROOT" ]; then
+      echo "$PW_PROJECT_ROOT/.environment"
+    else
+      echo "$PW_ROOT/.environment"
+    fi
   else
     echo "$PW_ENVIRONMENT_ROOT"
   fi
@@ -192,6 +198,10 @@
     _PW_PYTHON="$PW_BOOTSTRAP_PYTHON"
   elif which python &> /dev/null; then
     _PW_PYTHON=python
+  elif which python3 &> /dev/null; then
+    _PW_PYTHON=python3
+  elif which python2 &> /dev/null; then
+    _PW_PYTHON=python2
   else
     pw_bold_red "Error: No system Python present\n"
     pw_red "  Pigweed's bootstrap process requires a local system Python."
@@ -206,18 +216,26 @@
 
   if [ -n "$_PW_ENV_SETUP" ]; then
     "$_PW_ENV_SETUP" "$@"
+    _PW_ENV_SETUP_STATUS="$?"
   else
     "$_PW_PYTHON" "$PW_ROOT/pw_env_setup/py/pw_env_setup/env_setup.py" "$@"
+    _PW_ENV_SETUP_STATUS="$?"
   fi
 }
 
 pw_activate() {
   _pw_hello "  ACTIVATOR! This sets your shell environment variables.\n"
+  _PW_ENV_SETUP_STATUS=0
 }
 
 pw_finalize() {
   _PW_NAME="$1"
   _PW_SETUP_SH="$2"
+
+  if [ "$_PW_ENV_SETUP_STATUS" -ne 0 ]; then
+     return
+  fi
+
   if [ -f "$_PW_SETUP_SH" ]; then
     . "$_PW_SETUP_SH"
 
@@ -245,8 +263,8 @@
   unset _PW_SETUP_SH
   unset _PW_DEACTIVATE_SH
   unset _NEW_PW_ROOT
+  unset _PW_ENV_SETUP_STATUS
 
-  unset _pw_abspath
   unset pw_none
   unset pw_red
   unset pw_bold_red
diff --git a/pw_fuzzer/BUILD.gn b/pw_fuzzer/BUILD.gn
index 264033b..e97d084 100644
--- a/pw_fuzzer/BUILD.gn
+++ b/pw_fuzzer/BUILD.gn
@@ -30,29 +30,6 @@
   ldflags = common_flags
 }
 
-config("sanitize_address") {
-  cflags = [ "-fsanitize=address" ]
-  ldflags = cflags
-}
-
-config("sanitize_memory") {
-  cflags = [ "-fsanitize=memory" ]
-  ldflags = cflags
-}
-
-config("sanitize_undefined") {
-  cflags = [ "-fsanitize=undefined" ]
-  ldflags = cflags
-}
-
-config("sanitize_coverage") {
-  cflags = [
-    "-fprofile-instr-generate",
-    "-fcoverage-mapping",
-  ]
-  ldflags = cflags
-}
-
 # OSS-Fuzz needs to be able to specify its own compilers and add flags.
 config("oss_fuzz") {
   # OSS-Fuzz doesn't always link with -fsanitize=fuzzer, sometimes it uses
diff --git a/pw_fuzzer/docs.rst b/pw_fuzzer/docs.rst
index baf34ce..fb406e1 100644
--- a/pw_fuzzer/docs.rst
+++ b/pw_fuzzer/docs.rst
@@ -25,7 +25,7 @@
 
 .. note::
 
-  ``pw_fuzzer`` is currently only supported on Linux using clang.
+  ``pw_fuzzer`` is currently only supported on Linux and MacOS using clang.
 
 .. image:: doc_resources/pw_fuzzer_coverage_guided.png
    :alt: Coverage Guided Fuzzing with libFuzzer
@@ -88,12 +88,12 @@
     ]
   }
 
-2. Select the clang toolchain and a sanitizer of your choice. See LLVM for
-   `valid options`_.
+2. Select your choice of sanitizers ("address" is also the current default).
+   See LLVM for `valid options`_.
 
 .. code:: sh
 
-  $ gn gen out/host --args='pw_target_toolchain="//pw_toolchain:host_clang_og" pw_sanitizer="address"'
+  $ gn gen out --args='pw_toolchain_SANITIZERS=["address"]'
 
 3. Build normally, e.g. using ``pw watch``.
 
@@ -113,7 +113,12 @@
 
 .. code::
 
-  $ ASAN_OPTIONS=detect_odr_violation=0 ./out/host/obj/pw_fuzzer/toy_fuzzer -artifact_prefix=artifacts/ -timeout=10 corpus
+  $ mkdir -p corpus
+  $ ASAN_OPTIONS=detect_odr_violation=0 \
+      out/host_clang_fuzz/obj/pw_fuzzer/bin/toy_fuzzer \
+      -artifact_prefix=artifacts/ \
+      -timeout=10 \
+      corpus
   INFO: Seed: 305325345
   INFO: Loaded 1 modules   (46 inline 8-bit counters): 46 [0x38dfc0, 0x38dfee),
   INFO: Loaded 1 PC tables (46 PCs): 46 [0x23aaf0,0x23add0),
diff --git a/pw_fuzzer/examples/build_and_run_toy_fuzzer.sh b/pw_fuzzer/examples/build_and_run_toy_fuzzer.sh
deleted file mode 100755
index 57c8c59..0000000
--- a/pw_fuzzer/examples/build_and_run_toy_fuzzer.sh
+++ /dev/null
@@ -1,53 +0,0 @@
-#! /bin/bash
-
-# 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.
-
-# TODO(pwbug/177): Pythonize this, or otherwise replace it.
-
-set -e
-
-OUT_DIR="out/host"
-
-confirm() {
-  echo
-  while [ -n "$1" ] ; do
-    echo "$1"
-    shift
-  done
-  echo
-  read -p "Do you wish to continue? [yN]" confirm
-  case $confirm in
-      [Yy]* ) ;;
-      * ) exit;;
-  esac
-  echo
-}
-
-confirm "This script builds and runs a example fuzzer." \
-        "This script is about to delete $OUT_DIR."
-set -x
-rm -rf $OUT_DIR
-
-mkdir -p $OUT_DIR
-echo 'pw_target_toolchain="//pw_toolchain:host_clang_og"' > $OUT_DIR/args.gn
-echo 'pw_sanitizer="address"' >> $OUT_DIR/args.gn
-gn gen $OUT_DIR
-ninja -C $OUT_DIR pw_module_fuzzers
-
-set +x
-confirm "The toy_fuzzer was built successfully!" \
-        "This script is about to start fuzzing."
-set -x
-$OUT_DIR/obj/pw_fuzzer/toy_fuzzer
diff --git a/pw_fuzzer/fuzzer.gni b/pw_fuzzer/fuzzer.gni
index 4a6fbf4..776fa5d 100644
--- a/pw_fuzzer/fuzzer.gni
+++ b/pw_fuzzer/fuzzer.gni
@@ -26,17 +26,22 @@
 #   https://llvm.org/docs/LibFuzzer.html
 #
 template("pw_fuzzer") {
-  # This currently is ONLY supported on Linux using clang (debug).
-  # TODO(pwbug/179): Add Darwin, Windows here after testing.
-  fuzzing_platforms = [ "linux" ]
-  fuzzing_toolchains =
-      [ get_path_info("$dir_pw_toolchain:host_clang_og", "abspath") ]
+  # This currently is ONLY supported on Linux and Mac using clang (debug).
+  # TODO(pwbug/179): Add Windows here after testing.
+  fuzzing_platforms = [
+    "linux",
+    "mac",
+  ]
+
+  fuzzing_toolchains = [ "//targets/host:host_clang_fuzz" ]
 
   # This is how GN says 'elem in list':
   can_fuzz = fuzzing_platforms + [ host_os ] - [ host_os ] != fuzzing_platforms
+
   can_fuzz = fuzzing_toolchains + [ current_toolchain ] -
              [ current_toolchain ] != fuzzing_toolchains && can_fuzz
-  if (can_fuzz && pw_sanitizer != "") {
+
+  if (can_fuzz && pw_toolchain_SANITIZERS != []) {
     # Build the actual fuzzer using the fuzzing config.
     pw_executable(target_name) {
       forward_variables_from(invoker, "*", [ "visibility" ])
@@ -44,23 +49,26 @@
       if (!defined(configs)) {
         configs = []
       }
-      configs += [
-        "$dir_pw_fuzzer:default_config",
-        "$dir_pw_fuzzer:sanitize_${pw_sanitizer}",
-      ]
-      if (oss_fuzz_enabled) {
+      configs += [ "$dir_pw_fuzzer:default_config" ]
+      if (pw_toolchain_OSS_FUZZ_ENABLED) {
         configs += [ "$dir_pw_fuzzer:oss_fuzz" ]
       } else {
         configs += [ "$dir_pw_fuzzer:fuzzing" ]
       }
 
+      _fuzzer_output_dir = "${target_out_dir}/bin"
+      if (defined(invoker.output_dir)) {
+        _fuzzer_output_dir = invoker.output_dir
+      }
+      output_dir = _fuzzer_output_dir
+
       # Metadata for this fuzzer when used as part of a pw_test_group target.
       metadata = {
         tests = [
           {
             type = "fuzzer"
             test_name = target_name
-            test_directory = rebase_path(target_out_dir, root_build_dir)
+            test_directory = rebase_path(output_dir, root_build_dir)
           },
         ]
       }
@@ -68,7 +76,12 @@
 
     # Dummy target to satisfy `pw_test_group`. It is empty as we don't want to
     # automatically run fuzzers.
-    group(target_name + "_run") {
+    group(target_name + ".run") {
+    }
+
+    # Dummy target to satisfy `pw_test`. It is empty as we don't need a separate
+    # lib target.
+    group(target_name + ".lib") {
     }
   } else {
     # Build a unit test that exercise the fuzz target function.
diff --git a/pw_fuzzer/oss_fuzz.gni b/pw_fuzzer/oss_fuzz.gni
index ffef017..3a21438 100644
--- a/pw_fuzzer/oss_fuzz.gni
+++ b/pw_fuzzer/oss_fuzz.gni
@@ -14,8 +14,6 @@
 
 # TODO(aarongreen): Do some minimal parsing on the environment variables to
 # identify conflicting configs.
-oss_fuzz_added_configs = []
-oss_fuzz_removed_configs = []
 oss_fuzz_extra_cflags_c = string_split(getenv("CFLAGS"))
 oss_fuzz_extra_cflags_cc = string_split(getenv("CXXFLAGS"))
 oss_fuzz_extra_ldflags = string_split(getenv("LDFLAGS"))
diff --git a/pw_fuzzer/public/pw_fuzzer/asan_interface.h b/pw_fuzzer/public/pw_fuzzer/asan_interface.h
index ce761c2..aab8717 100644
--- a/pw_fuzzer/public/pw_fuzzer/asan_interface.h
+++ b/pw_fuzzer/public/pw_fuzzer/asan_interface.h
@@ -20,8 +20,7 @@
 #include <sanitizer/asan_interface.h>
 #else  // !defined(__clang__)
 
-#define ASAN_POISON_MEMORY_REGION(addr, size) (PW_UNUSED(addr), PW_UNUSED(size))
-#define ASAN_UNPOISON_MEMORY_REGION(addr, size) \
-  (PW_UNUSED(addr), PW_UNUSED(size))
+#define ASAN_POISON_MEMORY_REGION(addr, size) ((void)(addr), (void)(size))
+#define ASAN_UNPOISON_MEMORY_REGION(addr, size) ((void)(addr), (void)(size))
 
 #endif  // defined(__clang__)
diff --git a/pw_fuzzer/public/pw_fuzzer/fuzzed_data_provider.h b/pw_fuzzer/public/pw_fuzzer/fuzzed_data_provider.h
index a136da2..f8f4777 100644
--- a/pw_fuzzer/public/pw_fuzzer/fuzzed_data_provider.h
+++ b/pw_fuzzer/public/pw_fuzzer/fuzzed_data_provider.h
@@ -40,9 +40,7 @@
 
 class FuzzedDataProvider {
  public:
-  FuzzedDataProvider(const uint8_t* data, size_t size) {
-    PW_UNUSED(data);
-    PW_UNUSED(size);
+  FuzzedDataProvider(const uint8_t* /* data */, size_t /* size */) {
     PW_LOG_INFO("Fuzzing is disabled for the current compiler.");
     PW_LOG_INFO("Using trivial stub implementation for FuzzedDataProvider.");
   }
@@ -50,15 +48,13 @@
   ~FuzzedDataProvider() = default;
 
   template <typename T>
-  std::vector<T> ConsumeBytes(size_t num_bytes) {
-    PW_UNUSED(num_bytes);
+  std::vector<T> ConsumeBytes(size_t /* num_bytes */) {
     return std::vector<T>{};
   }
 
   template <typename T>
-  std::vector<T> ConsumeBytesWithTerminator(size_t num_bytes,
+  std::vector<T> ConsumeBytesWithTerminator(size_t /* num_bytes */,
                                             T terminator = 0) {
-    PW_UNUSED(num_bytes);
     return std::vector<T>{terminator};
   }
 
@@ -67,13 +63,11 @@
     return std::vector<T>{};
   }
 
-  std::string ConsumeBytesAsString(size_t num_bytes) {
-    PW_UNUSED(num_bytes);
+  std::string ConsumeBytesAsString(size_t /* num_bytes */) {
     return std::string{};
   }
 
-  std::string ConsumeRandomLengthString(size_t max_length) {
-    PW_UNUSED(max_length);
+  std::string ConsumeRandomLengthString(size_t /* max_length */) {
     return std::string{};
   }
 
@@ -87,8 +81,7 @@
   }
 
   template <typename T>
-  T ConsumeIntegralInRange(T min, T max) {
-    PW_UNUSED(max);
+  T ConsumeIntegralInRange(T min, T /* max */) {
     return T(min);
   }
 
@@ -98,8 +91,7 @@
   }
 
   template <typename T>
-  T ConsumeFloatingPointInRange(T min, T max) {
-    PW_UNUSED(max);
+  T ConsumeFloatingPointInRange(T min, T /* max */) {
     return T(min);
   }
 
@@ -115,9 +107,9 @@
     return static_cast<T>(0);
   }
 
-  template <typename T, size_t size>
-  T PickValueInArray(const T (&array)[size]) {
-    static_assert(size > 0, "The array must be non empty.");
+  template <typename T, size_t kSize>
+  T PickValueInArray(const T (&array)[kSize]) {
+    static_assert(kSize > 0, "The array must be non empty.");
     return array[0];
   }
 
@@ -127,9 +119,7 @@
     return *list.begin();
   }
 
-  size_t ConsumeData(void* destination, size_t num_bytes) {
-    PW_UNUSED(destination);
-    PW_UNUSED(num_bytes);
+  size_t ConsumeData(void* /* destination */, size_t /* num_bytes */) {
     return 0;
   }
 
diff --git a/pw_hdlc/BUILD b/pw_hdlc/BUILD
new file mode 100644
index 0000000..9463dbc
--- /dev/null
+++ b/pw_hdlc/BUILD
@@ -0,0 +1,123 @@
+# 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.
+
+load(
+    "//pw_build:pigweed.bzl",
+    "pw_cc_library",
+)
+
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])  # Apache License 2.0
+
+pw_cc_library(
+    name = "pw_hdlc",
+    srcs = [
+        "decoder.cc",
+        "encoder.cc",
+        "public/pw_hdlc/internal/encoder.h",
+        "public/pw_hdlc/internal/protocol.h",
+        "rpc_packets.cc",
+    ],
+    hdrs = [
+        "public/pw_hdlc/decoder.h",
+        "public/pw_hdlc/encoder.h",
+    ],
+    includes = ["public"],
+    deps = [
+        "//pw_bytes",
+        "//pw_checksum",
+        "//pw_log",
+        "//pw_result",
+        "//pw_span",
+        "//pw_status",
+        "//pw_stream",
+        "//pw_varint",
+    ],
+)
+
+pw_cc_library(
+    name = "rpc_channel_output",
+    hdrs = ["public/pw_hdlc/rpc_channel.h"],
+    includes = ["public"],
+    deps = [
+        ":pw_hdlc",
+        "//pw_rpc:server",
+    ],
+)
+
+pw_cc_library(
+    name = "pw_rpc",
+    srcs = ["rpc_packets.cc"],
+    hdrs = ["public/pw_hdlc/rpc_packets.h"],
+    includes = ["public"],
+    deps = [
+        ":pw_hdlc",
+        "//pw_rpc:server",
+    ],
+)
+
+pw_cc_library(
+    name = "packet_parser",
+    srcs = ["wire_packet_parser.cc"],
+    hdrs = ["public/pw_hdlc/wire_packet_parser.h"],
+    includes = ["public"],
+    deps = [
+        ":pw_hdlc",
+        "//pw_assert",
+        "//pw_bytes",
+        "//pw_checksum",
+        "//pw_router:packet_parser",
+    ],
+)
+
+cc_test(
+    name = "encoder_test",
+    srcs = ["encoder_test.cc"],
+    deps = [
+        ":pw_hdlc",
+        "//pw_stream",
+        "//pw_unit_test",
+    ],
+)
+
+cc_test(
+    name = "decoder_test",
+    srcs = ["decoder_test.cc"],
+    deps = [
+        ":pw_hdlc",
+        "//pw_result",
+        "//pw_stream",
+        "//pw_unit_test",
+    ],
+)
+
+cc_test(
+    name = "wire_packet_parser_test",
+    srcs = ["wire_packet_parser_test.cc"],
+    deps = [
+        ":packet_parser",
+        "//pw_bytes",
+    ],
+)
+
+cc_test(
+    name = "rpc_channel_test",
+    srcs = ["rpc_channel_test.cc"],
+    deps = [
+        ":pw_hdlc",
+        "//pw_stream",
+        "//pw_unit_test",
+    ],
+)
diff --git a/pw_hdlc/BUILD.gn b/pw_hdlc/BUILD.gn
new file mode 100644
index 0000000..41e5e4b
--- /dev/null
+++ b/pw_hdlc/BUILD.gn
@@ -0,0 +1,168 @@
+# Copyright 2021 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")
+import("$dir_pw_build/target_types.gni")
+import("$dir_pw_docgen/docs.gni")
+import("$dir_pw_unit_test/test.gni")
+
+config("default_config") {
+  include_dirs = [ "public" ]
+}
+
+group("pw_hdlc") {
+  public_deps = [
+    ":decoder",
+    ":encoder",
+  ]
+}
+
+pw_source_set("common") {
+  public_configs = [ ":default_config" ]
+  public = [ "public/pw_hdlc/internal/protocol.h" ]
+  public_deps = [ dir_pw_varint ]
+  visibility = [ ":*" ]
+}
+
+pw_source_set("decoder") {
+  public_configs = [ ":default_config" ]
+  public = [ "public/pw_hdlc/decoder.h" ]
+  sources = [ "decoder.cc" ]
+  public_deps = [
+    ":common",
+    dir_pw_bytes,
+    dir_pw_checksum,
+    dir_pw_result,
+    dir_pw_status,
+  ]
+  deps = [ dir_pw_log ]
+  friend = [ ":*" ]
+}
+
+pw_source_set("encoder") {
+  public_configs = [ ":default_config" ]
+  public = [ "public/pw_hdlc/encoder.h" ]
+  sources = [
+    "encoder.cc",
+    "public/pw_hdlc/internal/encoder.h",
+  ]
+  public_deps = [
+    ":common",
+    dir_pw_bytes,
+    dir_pw_checksum,
+    dir_pw_status,
+    dir_pw_stream,
+  ]
+  friend = [ ":*" ]
+}
+
+pw_source_set("rpc_channel_output") {
+  public_configs = [ ":default_config" ]
+  public = [ "public/pw_hdlc/rpc_channel.h" ]
+  public_deps = [
+    ":pw_hdlc",
+    "$dir_pw_rpc:server",
+  ]
+}
+
+pw_source_set("pw_rpc") {
+  public_configs = [ ":default_config" ]
+  public = [ "public/pw_hdlc/rpc_packets.h" ]
+  sources = [ "rpc_packets.cc" ]
+  public_deps = [
+    ":pw_hdlc",
+    "$dir_pw_rpc:server",
+    dir_pw_sys_io,
+  ]
+}
+
+pw_source_set("packet_parser") {
+  public_configs = [ ":default_config" ]
+  public = [ "public/pw_hdlc/wire_packet_parser.h" ]
+  sources = [ "wire_packet_parser.cc" ]
+  public_deps = [
+    ":pw_hdlc",
+    "$dir_pw_router:packet_parser",
+  ]
+  deps = [
+    dir_pw_bytes,
+    dir_pw_checksum,
+  ]
+}
+
+pw_test_group("tests") {
+  tests = [
+    ":encoder_test",
+    ":decoder_test",
+    ":rpc_channel_test",
+    ":wire_packet_parser_test",
+  ]
+  group_deps = [
+    "$dir_pw_preprocessor:tests",
+    "$dir_pw_status:tests",
+    "$dir_pw_stream:tests",
+  ]
+}
+
+pw_test("encoder_test") {
+  deps = [ ":pw_hdlc" ]
+  sources = [ "encoder_test.cc" ]
+}
+
+pw_python_action("generate_decoder_test") {
+  outputs = [ "$target_gen_dir/generated_decoder_test.cc" ]
+  script = "py/decode_test.py"
+  args = [ "--generate-cc-test" ] + rebase_path(outputs)
+  python_deps = [
+    "$dir_pw_build/py",
+    "py",
+  ]
+}
+
+pw_test("decoder_test") {
+  deps = [
+    ":generate_decoder_test",
+    ":pw_hdlc",
+  ]
+  sources = [ "decoder_test.cc" ] + get_target_outputs(":generate_decoder_test")
+}
+
+pw_test("rpc_channel_test") {
+  deps = [
+    ":pw_hdlc",
+    ":rpc_channel_output",
+  ]
+  sources = [ "rpc_channel_test.cc" ]
+}
+
+pw_test("wire_packet_parser_test") {
+  deps = [
+    ":packet_parser",
+    dir_pw_bytes,
+  ]
+  sources = [ "wire_packet_parser_test.cc" ]
+}
+
+pw_doc_group("docs") {
+  sources = [
+    "docs.rst",
+    "rpc_example/docs.rst",
+  ]
+  inputs = [
+    "py/pw_hdlc/decode.py",
+    "py/pw_hdlc/encode.py",
+  ]
+}
diff --git a/pw_hdlc/CMakeLists.txt b/pw_hdlc/CMakeLists.txt
new file mode 100644
index 0000000..5f48d55
--- /dev/null
+++ b/pw_hdlc/CMakeLists.txt
@@ -0,0 +1,32 @@
+# 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.
+
+include($ENV{PW_ROOT}/pw_build/pigweed.cmake)
+
+pw_auto_add_simple_module(pw_hdlc
+  PUBLIC_DEPS
+    pw_assert
+    pw_bytes
+    pw_checksum
+    pw_result
+    pw_router.packet_parser
+    pw_rpc.common
+    pw_status
+    pw_stream
+    pw_sys_io
+  PRIVATE_DEPS
+    pw_log
+)
+
+add_subdirectory(rpc_example)
diff --git a/pw_hdlc/decoder.cc b/pw_hdlc/decoder.cc
new file mode 100644
index 0000000..c2e10aa
--- /dev/null
+++ b/pw_hdlc/decoder.cc
@@ -0,0 +1,162 @@
+// 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.
+
+#include "pw_hdlc/decoder.h"
+
+#include "pw_assert/assert.h"
+#include "pw_bytes/endian.h"
+#include "pw_hdlc/internal/protocol.h"
+#include "pw_log/log.h"
+#include "pw_varint/varint.h"
+
+using std::byte;
+
+namespace pw::hdlc {
+
+Result<Frame> Frame::Parse(ConstByteSpan frame) {
+  uint64_t address;
+  size_t address_size = varint::Decode(frame, &address, kAddressFormat);
+  int data_size = frame.size() - address_size - kControlSize - kFcsSize;
+
+  if (address_size == 0 || data_size < 0) {
+    return Status::DataLoss();
+  }
+
+  return Frame(
+      address, frame[address_size], frame.subspan(address_size + 1, data_size));
+}
+
+Result<Frame> Decoder::Process(const byte new_byte) {
+  switch (state_) {
+    case State::kInterFrame: {
+      if (new_byte == kFlag) {
+        state_ = State::kFrame;
+
+        // Report an error if non-flag bytes were read between frames.
+        if (current_frame_size_ != 0u) {
+          Reset();
+          return Status::DataLoss();
+        }
+      } else {
+        // Count bytes to track how many are discarded.
+        current_frame_size_ += 1;
+      }
+      return Status::Unavailable();  // Report error when starting a new frame.
+    }
+    case State::kFrame: {
+      if (new_byte == kFlag) {
+        const Status status = CheckFrame();
+
+        const size_t completed_frame_size = current_frame_size_;
+        Reset();
+
+        if (status.ok()) {
+          return Frame::Parse(buffer_.first(completed_frame_size));
+        }
+        return status;
+      }
+
+      if (new_byte == kEscape) {
+        state_ = State::kFrameEscape;
+      } else {
+        AppendByte(new_byte);
+      }
+      return Status::Unavailable();
+    }
+    case State::kFrameEscape: {
+      // The flag character cannot be escaped; return an error.
+      if (new_byte == kFlag) {
+        state_ = State::kFrame;
+        Reset();
+        return Status::DataLoss();
+      }
+
+      if (new_byte == kEscape) {
+        // Two escape characters in a row is illegal -- invalidate this frame.
+        // The frame is reported abandoned when the next flag byte appears.
+        state_ = State::kInterFrame;
+
+        // Count the escape byte so that the inter-frame state detects an error.
+        current_frame_size_ += 1;
+      } else {
+        state_ = State::kFrame;
+        AppendByte(Escape(new_byte));
+      }
+      return Status::Unavailable();
+    }
+  }
+  PW_CRASH("Bad decoder state");
+}
+
+void Decoder::AppendByte(byte new_byte) {
+  if (current_frame_size_ < max_size()) {
+    buffer_[current_frame_size_] = new_byte;
+  }
+
+  if (current_frame_size_ >= last_read_bytes_.size()) {
+    // A byte will be ejected. Add it to the running checksum.
+    fcs_.Update(last_read_bytes_[last_read_bytes_index_]);
+  }
+
+  last_read_bytes_[last_read_bytes_index_] = new_byte;
+  last_read_bytes_index_ =
+      (last_read_bytes_index_ + 1) % last_read_bytes_.size();
+
+  // Always increase size: if it is larger than the buffer, overflow occurred.
+  current_frame_size_ += 1;
+}
+
+Status Decoder::CheckFrame() const {
+  // Empty frames are not an error; repeated flag characters are okay.
+  if (current_frame_size_ == 0u) {
+    return Status::Unavailable();
+  }
+
+  if (current_frame_size_ < Frame::kMinSizeBytes) {
+    PW_LOG_ERROR("Received %lu-byte frame; frame must be at least 6 bytes",
+                 static_cast<unsigned long>(current_frame_size_));
+    return Status::DataLoss();
+  }
+
+  if (!VerifyFrameCheckSequence()) {
+    PW_LOG_ERROR("Frame check sequence verification failed");
+    return Status::DataLoss();
+  }
+
+  if (current_frame_size_ > max_size()) {
+    // Frame does not fit into the provided buffer; indicate this to the caller.
+    // This may not be considered an error if the caller is doing a partial
+    // decode.
+    return Status::ResourceExhausted();
+  }
+
+  return OkStatus();
+}
+
+bool Decoder::VerifyFrameCheckSequence() const {
+  // De-ring the last four bytes read, which at this point contain the FCS.
+  std::array<std::byte, sizeof(uint32_t)> fcs_buffer;
+  size_t index = last_read_bytes_index_;
+
+  for (size_t i = 0; i < fcs_buffer.size(); ++i) {
+    fcs_buffer[i] = last_read_bytes_[index];
+    index = (index + 1) % last_read_bytes_.size();
+  }
+
+  uint32_t actual_fcs =
+      bytes::ReadInOrder<uint32_t>(std::endian::little, fcs_buffer);
+  return actual_fcs == fcs_.value();
+}
+
+}  // namespace pw::hdlc
diff --git a/pw_hdlc/decoder_test.cc b/pw_hdlc/decoder_test.cc
new file mode 100644
index 0000000..af842ea
--- /dev/null
+++ b/pw_hdlc/decoder_test.cc
@@ -0,0 +1,156 @@
+// 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.
+
+#include "pw_hdlc/decoder.h"
+
+#include <array>
+#include <cstddef>
+
+#include "gtest/gtest.h"
+#include "pw_bytes/array.h"
+#include "pw_hdlc/internal/protocol.h"
+
+namespace pw::hdlc {
+namespace {
+
+using std::byte;
+
+TEST(Frame, Fields) {
+  static constexpr auto kFrameData =
+      bytes::String("\x05\xab\x42\x24\xf9\x54\xfb\x3d");
+  auto result = Frame::Parse(kFrameData);
+  ASSERT_TRUE(result.ok());
+  Frame& frame = result.value();
+
+  EXPECT_EQ(frame.address(), 2u);
+  EXPECT_EQ(frame.control(), byte{0xab});
+
+  EXPECT_EQ(frame.data().size(), 2u);
+  EXPECT_EQ(frame.data()[0], byte{0x42});
+  EXPECT_EQ(frame.data()[1], byte{0x24});
+}
+
+TEST(Frame, MultibyteAddress) {
+  static constexpr auto kFrameData =
+      bytes::String("\x2c\xd9\x33\x01\x02\xaf\xc8\x77\x48");
+  auto result = Frame::Parse(kFrameData);
+  ASSERT_TRUE(result.ok());
+  Frame& frame = result.value();
+
+  EXPECT_EQ(frame.address(), 0b11011000010110u);
+  EXPECT_EQ(frame.control(), byte{0x33});
+
+  EXPECT_EQ(frame.data().size(), 2u);
+  EXPECT_EQ(frame.data()[0], byte{0x01});
+  EXPECT_EQ(frame.data()[1], byte{0x02});
+}
+
+TEST(Frame, MultibyteAddressTooLong) {
+  // 11-byte encoded address.
+  constexpr auto kLongAddress =
+      bytes::String("\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01");
+  static constexpr auto kFrameData = bytes::Concat(
+      kLongAddress, bytes::String("\x33\x01\x02\xaf\xc8\x77\x48"));
+  auto result = Frame::Parse(kFrameData);
+  EXPECT_EQ(result.status(), Status::DataLoss());
+}
+
+TEST(Decoder, Clear) {
+  DecoderBuffer<8> decoder;
+
+  // Process a partial packet
+  decoder.Process(bytes::String("~1234abcd"),
+                  [](const Result<Frame>&) { FAIL(); });
+
+  decoder.Clear();
+  Status status = Status::Unknown();
+
+  decoder.Process(
+      bytes::String("~1234\xa3\xe0\xe3\x9b~"),
+      [&status](const Result<Frame>& result) { status = result.status(); });
+
+  EXPECT_EQ(OkStatus(), status);
+}
+
+TEST(Decoder, ExactFit) {
+  DecoderBuffer<8> decoder;
+
+  for (byte b : bytes::String("~1234\xa3\xe0\xe3\x9b")) {
+    EXPECT_EQ(Status::Unavailable(), decoder.Process(b).status());
+  }
+  auto result = decoder.Process(kFlag);
+  ASSERT_EQ(OkStatus(), result.status());
+  ASSERT_EQ(result.value().data().size(), 2u);
+  ASSERT_EQ(result.value().data()[0], byte{'3'});
+  ASSERT_EQ(result.value().data()[1], byte{'4'});
+}
+
+TEST(Decoder, MinimumSizedBuffer) {
+  DecoderBuffer<6> decoder;
+
+  for (byte b : bytes::String("~12\xcd\x44\x53\x4f")) {
+    EXPECT_EQ(Status::Unavailable(), decoder.Process(b).status());
+  }
+
+  auto result = decoder.Process(kFlag);
+  ASSERT_EQ(OkStatus(), result.status());
+  EXPECT_EQ(result.value().data().size(), 0u);
+}
+
+TEST(Decoder, TooLargeForBuffer_ReportsResourceExhausted) {
+  DecoderBuffer<8> decoder;
+
+  for (byte b : bytes::String("~12345\x1c\x3a\xf5\xcb")) {
+    EXPECT_EQ(Status::Unavailable(), decoder.Process(b).status());
+  }
+  EXPECT_EQ(Status::ResourceExhausted(), decoder.Process(kFlag).status());
+
+  for (byte b : bytes::String("~12345678901234567890\xf2\x19\x63\x90")) {
+    EXPECT_EQ(Status::Unavailable(), decoder.Process(b).status());
+  }
+  EXPECT_EQ(Status::ResourceExhausted(), decoder.Process(kFlag).status());
+}
+
+TEST(Decoder, TooLargeForBuffer_StaysWithinBufferBoundaries) {
+  std::array<byte, 16> buffer = bytes::Initialized<16>('?');
+
+  Decoder decoder(std::span(buffer.data(), 8));
+
+  for (byte b : bytes::String("~12345678901234567890\xf2\x19\x63\x90")) {
+    EXPECT_EQ(Status::Unavailable(), decoder.Process(b).status());
+  }
+
+  for (size_t i = 8; i < buffer.size(); ++i) {
+    ASSERT_EQ(byte{'?'}, buffer[i]);
+  }
+
+  EXPECT_EQ(Status::ResourceExhausted(), decoder.Process(kFlag).status());
+}
+
+TEST(Decoder, TooLargeForBuffer_DecodesNextFrame) {
+  DecoderBuffer<8> decoder;
+
+  for (byte b : bytes::String("~12345678901234567890\xf2\x19\x63\x90")) {
+    EXPECT_EQ(Status::Unavailable(), decoder.Process(b).status());
+  }
+  EXPECT_EQ(Status::ResourceExhausted(), decoder.Process(kFlag).status());
+
+  for (byte b : bytes::String("1234\xa3\xe0\xe3\x9b")) {
+    EXPECT_EQ(Status::Unavailable(), decoder.Process(b).status());
+  }
+  EXPECT_EQ(OkStatus(), decoder.Process(kFlag).status());
+}
+
+}  // namespace
+}  // namespace pw::hdlc
diff --git a/pw_hdlc/docs.rst b/pw_hdlc/docs.rst
new file mode 100644
index 0000000..6791855
--- /dev/null
+++ b/pw_hdlc/docs.rst
@@ -0,0 +1,232 @@
+.. _module-pw_hdlc:
+
+-------
+pw_hdlc
+-------
+`High-Level Data Link Control (HDLC)
+<https://en.wikipedia.org/wiki/High-Level_Data_Link_Control>`_ is a data link
+layer protocol intended for serial communication between devices. HDLC is
+standardized as `ISO/IEC 13239:2002 <https://www.iso.org/standard/37010.html>`_.
+
+The ``pw_hdlc`` module provides a simple, robust frame-oriented transport that
+uses a subset of the HDLC protocol. ``pw_hdlc`` supports sending between
+embedded devices or the host. It can be used with :ref:`module-pw_rpc` to enable
+remote procedure calls (RPCs) on embedded on devices.
+
+**Why use the pw_hdlc module?**
+
+  * Enables the transmission of RPCs and other data between devices over serial.
+  * Detects corruption and data loss.
+  * Light-weight, simple, and easy to use.
+  * Supports streaming to transport without buffering, since the length is not
+    encoded.
+
+.. admonition:: Try it out!
+
+  For an example of how to use HDLC with :ref:`module-pw_rpc`, see the
+  :ref:`module-pw_hdlc-rpc-example`.
+
+.. toctree::
+  :maxdepth: 1
+  :hidden:
+
+  rpc_example/docs
+
+Protocol Description
+====================
+
+Frames
+------
+The HDLC implementation in ``pw_hdlc`` supports only HDLC unnumbered
+information frames. These frames are encoded as follows:
+
+.. code-block:: text
+
+    _________________________________________
+    | | | |                          |    | |...
+    | | | |                          |    | |... [More frames]
+    |_|_|_|__________________________|____|_|...
+     F A C       Payload              FCS  F
+
+     F = flag byte (0x7e, the ~ character)
+     A = address field
+     C = control field
+     FCS = frame check sequence (CRC-32)
+
+
+Encoding and sending data
+-------------------------
+This module first writes an initial frame delimiter byte (0x7E) to indicate the
+beginning of the frame. Before sending any of the payload data through serial,
+the special bytes are escaped:
+
+            +-------------------------+-----------------------+
+            | Unescaped Special Bytes | Escaped Special Bytes |
+            +=========================+=======================+
+            |           7E            |        7D 5E          |
+            +-------------------------+-----------------------+
+            |           7D            |        7D 5D          |
+            +-------------------------+-----------------------+
+
+The bytes of the payload are escaped and written in a single pass. The
+frame check sequence is calculated, escaped, and written after. After this, a
+final frame delimiter byte (0x7E) is written to mark the end of the frame.
+
+Decoding received bytes
+-----------------------
+Frames may be received in multiple parts, so we need to store the received data
+in a buffer until the ending frame delimiter (0x7E) is read. When the
+``pw_hdlc`` decoder receives data, it unescapes it and adds it to a buffer.
+When the frame is complete, it calculates and verifies the frame check sequence
+and does the following:
+
+* If correctly verified, the decoder returns the decoded frame.
+* If the checksum verification fails, the frame is discarded and an error is
+  reported.
+
+API Usage
+=========
+There are two primary functions of the ``pw_hdlc`` module:
+
+  * **Encoding** data by constructing a frame with the escaped payload bytes and
+    frame check sequence.
+  * **Decoding** data by unescaping the received bytes, verifying the frame
+    check sequence, and returning successfully decoded frames.
+
+Encoder
+-------
+The Encoder API provides a single function that encodes data as an HDLC
+unnumbered information frame.
+
+C++
+^^^
+.. cpp:namespace:: pw
+
+.. cpp:function:: Status hdlc::WriteUIFrame(uint64_t address, ConstByteSpan data, stream::Writer& writer)
+
+  Writes a span of data to a :ref:`pw::stream::Writer <module-pw_stream>` and
+  returns the status. This implementation uses the :ref:`module-pw_checksum`
+  module to compute the CRC-32 frame check sequence.
+
+.. code-block:: cpp
+
+  #include "pw_hdlc/encoder.h"
+  #include "pw_hdlc/sys_io_stream.h"
+
+  int main() {
+    pw::stream::SysIoWriter serial_writer;
+    Status status = WriteUIFrame(123 /* address */,
+                                 data,
+                                 serial_writer);
+    if (!status.ok()) {
+      PW_LOG_INFO("Writing frame failed! %s", status.str());
+    }
+  }
+
+Python
+^^^^^^
+.. automodule:: pw_hdlc.encode
+  :members:
+
+.. code-block:: python
+
+  import serial
+  from pw_hdlc import encode
+
+  ser = serial.Serial()
+  ser.write(encode.ui_frame(b'your data here!'))
+
+Decoder
+-------
+The decoder class unescapes received bytes and adds them to a buffer. Complete,
+valid HDLC frames are yielded as they are received.
+
+C++
+^^^
+.. cpp:class:: pw::hdlc::Decoder
+
+  .. cpp:function:: pw::Result<Frame> Process(std::byte b)
+
+    Parses a single byte of an HDLC stream. Returns a Result with the complete
+    frame if the byte completes a frame. The status is the following:
+
+      - OK - A frame was successfully decoded. The Result contains the Frame,
+        which is invalidated by the next Process call.
+      - UNAVAILABLE - No frame is available.
+      - RESOURCE_EXHAUSTED - A frame completed, but it was too large to fit in
+        the decoder's buffer.
+      - DATA_LOSS - A frame completed, but it was invalid. The frame was
+        incomplete or the frame check sequence verification failed.
+
+  .. cpp:function:: void Process(pw::ConstByteSpan data, F&& callback, Args&&... args)
+
+    Processes a span of data and calls the provided callback with each frame or
+    error.
+
+This example demonstrates reading individual bytes from ``pw::sys_io`` and
+decoding HDLC frames:
+
+.. code-block:: cpp
+
+  #include "pw_hdlc/decoder.h"
+  #include "pw_sys_io/sys_io.h"
+
+  int main() {
+    std::byte data;
+    while (true) {
+      if (!pw::sys_io::ReadByte(&data).ok()) {
+        // Log serial reading error
+      }
+      Result<Frame> decoded_frame = decoder.Process(data);
+
+      if (decoded_frame.ok()) {
+        // Handle the decoded frame
+      }
+    }
+  }
+
+Python
+^^^^^^
+.. autoclass:: pw_hdlc.decode.FrameDecoder
+  :members:
+
+Below is an example using the decoder class to decode data read from serial:
+
+.. code-block:: python
+
+  import serial
+  from pw_hdlc import decode
+
+  ser = serial.Serial()
+  decoder = decode.FrameDecoder()
+
+  while True:
+      for frame in decoder.process_valid_frames(ser.read()):
+          # Handle the decoded frame
+
+Additional features
+===================
+
+pw::stream::SysIoWriter
+------------------------
+The ``SysIoWriter`` C++ class implements the ``Writer`` interface with
+``pw::sys_io``. This Writer may be used by the C++ encoder to send HDLC frames
+over serial.
+
+HdlcRpcClient
+-------------
+.. autoclass:: pw_hdlc.rpc.HdlcRpcClient
+  :members:
+
+Roadmap
+=======
+- **Expanded protocol support** - ``pw_hdlc`` currently only supports
+  unnumbered information frames. Support for different frame types and
+  extended control fields may be added in the future.
+
+- **Higher performance** - We plan to improve the overall performance of the
+  decoder and encoder implementations by using SIMD/NEON.
+
+Compatibility
+=============
+C++17
diff --git a/pw_hdlc/encoder.cc b/pw_hdlc/encoder.cc
new file mode 100644
index 0000000..9b1b027
--- /dev/null
+++ b/pw_hdlc/encoder.cc
@@ -0,0 +1,119 @@
+// 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.
+
+#include "pw_hdlc/encoder.h"
+
+#include <algorithm>
+#include <array>
+#include <cstddef>
+#include <cstring>
+#include <span>
+
+#include "pw_bytes/endian.h"
+#include "pw_hdlc/internal/encoder.h"
+#include "pw_varint/varint.h"
+
+using std::byte;
+
+namespace pw::hdlc {
+namespace internal {
+
+Status EscapeAndWrite(const byte b, stream::Writer& writer) {
+  if (b == kFlag) {
+    return writer.Write(kEscapedFlag);
+  }
+  if (b == kEscape) {
+    return writer.Write(kEscapedEscape);
+  }
+  return writer.Write(b);
+}
+
+Status Encoder::WriteData(ConstByteSpan data) {
+  auto begin = data.begin();
+  while (true) {
+    auto end = std::find_if(begin, data.end(), NeedsEscaping);
+
+    if (Status status = writer_.Write(std::span(begin, end)); !status.ok()) {
+      return status;
+    }
+    if (end == data.end()) {
+      fcs_.Update(data);
+      return OkStatus();
+    }
+    if (Status status = EscapeAndWrite(*end, writer_); !status.ok()) {
+      return status;
+    }
+    begin = end + 1;
+  }
+}
+
+Status Encoder::FinishFrame() {
+  if (Status status =
+          WriteData(bytes::CopyInOrder(std::endian::little, fcs_.value()));
+      !status.ok()) {
+    return status;
+  }
+  return writer_.Write(kFlag);
+}
+
+size_t Encoder::MaxEncodedSize(uint64_t address, ConstByteSpan payload) {
+  constexpr size_t kFcsMaxSize = 8;  // Worst case FCS: 0x7e7e7e7e.
+  size_t max_encoded_address_size = varint::EncodedSize(address) * 2;
+  size_t encoded_payload_size =
+      payload.size() +
+      std::count_if(payload.begin(), payload.end(), NeedsEscaping);
+
+  return max_encoded_address_size + sizeof(kUnusedControl) +
+         encoded_payload_size + kFcsMaxSize;
+}
+
+Status Encoder::StartFrame(uint64_t address, std::byte control) {
+  fcs_.clear();
+  if (Status status = writer_.Write(kFlag); !status.ok()) {
+    return status;
+  }
+
+  std::array<std::byte, 16> metadata_buffer;
+  size_t metadata_size =
+      varint::Encode(address, metadata_buffer, kAddressFormat);
+  if (metadata_size == 0) {
+    return Status::InvalidArgument();
+  }
+
+  metadata_buffer[metadata_size++] = control;
+  return WriteData(std::span(metadata_buffer).first(metadata_size));
+}
+
+}  // namespace internal
+
+Status WriteUIFrame(uint64_t address,
+                    ConstByteSpan payload,
+                    stream::Writer& writer) {
+  if (internal::Encoder::MaxEncodedSize(address, payload) >
+      writer.ConservativeWriteLimit()) {
+    return Status::ResourceExhausted();
+  }
+
+  internal::Encoder encoder(writer);
+
+  if (Status status = encoder.StartUnnumberedFrame(address); !status.ok()) {
+    return status;
+  }
+  if (Status status = encoder.WriteData(payload); !status.ok()) {
+    return status;
+  }
+  return encoder.FinishFrame();
+}
+
+}  // namespace pw::hdlc
diff --git a/pw_hdlc/encoder_test.cc b/pw_hdlc/encoder_test.cc
new file mode 100644
index 0000000..e5359a7
--- /dev/null
+++ b/pw_hdlc/encoder_test.cc
@@ -0,0 +1,227 @@
+// 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.
+
+#include "pw_hdlc/encoder.h"
+
+#include <algorithm>
+#include <array>
+#include <cstddef>
+
+#include "gtest/gtest.h"
+#include "pw_bytes/array.h"
+#include "pw_hdlc/internal/encoder.h"
+#include "pw_hdlc/internal/protocol.h"
+#include "pw_stream/memory_stream.h"
+
+using std::byte;
+
+namespace pw::hdlc {
+namespace {
+
+constexpr uint8_t kAddress = 0x7B;  // 123
+constexpr uint8_t kEncodedAddress = (kAddress << 1) | 1;
+
+#define EXPECT_ENCODER_WROTE(...)                                           \
+  do {                                                                      \
+    constexpr auto expected_data = (__VA_ARGS__);                           \
+    EXPECT_EQ(writer_.bytes_written(), expected_data.size());               \
+    EXPECT_EQ(                                                              \
+        std::memcmp(                                                        \
+            writer_.data(), expected_data.data(), writer_.bytes_written()), \
+        0);                                                                 \
+  } while (0)
+
+class WriteUnnumberedFrame : public ::testing::Test {
+ protected:
+  WriteUnnumberedFrame() : writer_(buffer_) {}
+
+  stream::MemoryWriter writer_;
+  std::array<byte, 32> buffer_;
+};
+
+constexpr byte kUnnumberedControl = byte{0x3};
+
+TEST_F(WriteUnnumberedFrame, EmptyPayload) {
+  ASSERT_EQ(OkStatus(), WriteUIFrame(kAddress, std::span<byte>(), writer_));
+  EXPECT_ENCODER_WROTE(bytes::Concat(
+      kFlag, kEncodedAddress, kUnnumberedControl, uint32_t{0x832d343f}, kFlag));
+}
+
+TEST_F(WriteUnnumberedFrame, OneBytePayload) {
+  ASSERT_EQ(OkStatus(), WriteUIFrame(kAddress, bytes::String("A"), writer_));
+  EXPECT_ENCODER_WROTE(bytes::Concat(kFlag,
+                                     kEncodedAddress,
+                                     kUnnumberedControl,
+                                     'A',
+                                     uint32_t{0x653c9e82},
+                                     kFlag));
+}
+
+TEST_F(WriteUnnumberedFrame, OneBytePayload_Escape0x7d) {
+  ASSERT_EQ(OkStatus(), WriteUIFrame(kAddress, bytes::Array<0x7d>(), writer_));
+  EXPECT_ENCODER_WROTE(bytes::Concat(kFlag,
+                                     kEncodedAddress,
+                                     kUnnumberedControl,
+                                     kEscape,
+                                     byte{0x7d} ^ byte{0x20},
+                                     uint32_t{0x4a53e205},
+                                     kFlag));
+}
+
+TEST_F(WriteUnnumberedFrame, OneBytePayload_Escape0x7E) {
+  ASSERT_EQ(OkStatus(), WriteUIFrame(kAddress, bytes::Array<0x7e>(), writer_));
+  EXPECT_ENCODER_WROTE(bytes::Concat(kFlag,
+                                     kEncodedAddress,
+                                     kUnnumberedControl,
+                                     kEscape,
+                                     byte{0x7e} ^ byte{0x20},
+                                     uint32_t{0xd35ab3bf},
+                                     kFlag));
+}
+
+TEST_F(WriteUnnumberedFrame, AddressNeedsEscaping) {
+  // Becomes 0x7d when encoded.
+  constexpr uint8_t kEscapeRequiredAddress = 0x7d >> 1;
+  ASSERT_EQ(OkStatus(),
+            WriteUIFrame(kEscapeRequiredAddress, bytes::String("A"), writer_));
+  EXPECT_ENCODER_WROTE(bytes::Concat(kFlag,
+                                     kEscape,
+                                     byte{0x5d},
+                                     kUnnumberedControl,
+                                     'A',
+                                     uint32_t{0x899E00D4},
+                                     kFlag));
+}
+
+TEST_F(WriteUnnumberedFrame, Crc32NeedsEscaping) {
+  ASSERT_EQ(OkStatus(), WriteUIFrame(kAddress, bytes::String("aa"), writer_));
+
+  // The CRC-32 of {kEncodedAddress, kUnnumberedControl, "aa"} is 0x7ee04473, so
+  // the 0x7e must be escaped.
+  constexpr auto expected_crc32 = bytes::Array<0x73, 0x44, 0xe0, 0x7d, 0x5e>();
+  EXPECT_ENCODER_WROTE(bytes::Concat(kFlag,
+                                     kEncodedAddress,
+                                     kUnnumberedControl,
+                                     bytes::String("aa"),
+                                     expected_crc32,
+                                     kFlag));
+}
+
+TEST_F(WriteUnnumberedFrame, MultiplePayloads) {
+  ASSERT_EQ(OkStatus(), WriteUIFrame(kAddress, bytes::String("ABC"), writer_));
+  ASSERT_EQ(OkStatus(), WriteUIFrame(kAddress, bytes::String("DEF"), writer_));
+  EXPECT_ENCODER_WROTE(bytes::Concat(kFlag,
+                                     kEncodedAddress,
+                                     kUnnumberedControl,
+                                     bytes::String("ABC"),
+                                     uint32_t{0x72410ee4},
+                                     kFlag,
+                                     kFlag,
+                                     kEncodedAddress,
+                                     kUnnumberedControl,
+                                     bytes::String("DEF"),
+                                     uint32_t{0x4ba1ae47},
+                                     kFlag));
+}
+
+TEST_F(WriteUnnumberedFrame, PayloadWithNoEscapes) {
+  ASSERT_EQ(
+      OkStatus(),
+      WriteUIFrame(kAddress, bytes::String("1995 toyota corolla"), writer_));
+
+  EXPECT_ENCODER_WROTE(bytes::Concat(kFlag,
+                                     kEncodedAddress,
+                                     kUnnumberedControl,
+                                     bytes::String("1995 toyota corolla"),
+                                     uint32_t{0x53ee911c},
+                                     kFlag));
+}
+
+TEST_F(WriteUnnumberedFrame, MultibyteAddress) {
+  ASSERT_EQ(OkStatus(), WriteUIFrame(0x3fff, bytes::String("abc"), writer_));
+
+  EXPECT_ENCODER_WROTE(bytes::Concat(kFlag,
+                                     bytes::String("\xfe\xff"),
+                                     kUnnumberedControl,
+                                     bytes::String("abc"),
+                                     uint32_t{0x8cee2978},
+                                     kFlag));
+}
+
+TEST_F(WriteUnnumberedFrame, PayloadWithMultipleEscapes) {
+  ASSERT_EQ(
+      OkStatus(),
+      WriteUIFrame(kAddress,
+                   bytes::Array<0x7E, 0x7B, 0x61, 0x62, 0x63, 0x7D, 0x7E>(),
+                   writer_));
+  EXPECT_ENCODER_WROTE(bytes::Concat(
+      kFlag,
+      kEncodedAddress,
+      kUnnumberedControl,
+      bytes::
+          Array<0x7D, 0x5E, 0x7B, 0x61, 0x62, 0x63, 0x7D, 0x5D, 0x7D, 0x5E>(),
+      uint32_t{0x1563a4e6},
+      kFlag));
+}
+
+TEST_F(WriteUnnumberedFrame, PayloadTooLarge_WritesNothing) {
+  constexpr auto data = bytes::Initialized<sizeof(buffer_)>(0x7e);
+  EXPECT_EQ(Status::ResourceExhausted(), WriteUIFrame(kAddress, data, writer_));
+  EXPECT_EQ(0u, writer_.bytes_written());
+}
+
+class ErrorWriter : public stream::Writer {
+ private:
+  Status DoWrite(ConstByteSpan) override { return Status::Unimplemented(); }
+};
+
+TEST(WriteUnnumberedFrame, WriterError) {
+  ErrorWriter writer;
+  EXPECT_EQ(Status::Unimplemented(),
+            WriteUIFrame(kAddress, bytes::Array<0x01>(), writer));
+}
+
+}  // namespace
+
+namespace internal {
+namespace {
+
+constexpr uint8_t kEscapeAddress = 0x7d;
+
+TEST(Encoder, MaxEncodedSize_EmptyPayload) {
+  EXPECT_EQ(11u, Encoder::MaxEncodedSize(kAddress, {}));
+  EXPECT_EQ(11u, Encoder::MaxEncodedSize(kEscapeAddress, {}));
+}
+
+TEST(Encoder, MaxEncodedSize_PayloadWithoutEscapes) {
+  constexpr auto data = bytes::Array<0x00, 0x01, 0x02, 0x03>();
+  EXPECT_EQ(15u, Encoder::MaxEncodedSize(kAddress, data));
+  EXPECT_EQ(15u, Encoder::MaxEncodedSize(kEscapeAddress, data));
+}
+
+TEST(Encoder, MaxEncodedSize_PayloadWithOneEscape) {
+  constexpr auto data = bytes::Array<0x00, 0x01, 0x7e, 0x03>();
+  EXPECT_EQ(16u, Encoder::MaxEncodedSize(kAddress, data));
+  EXPECT_EQ(16u, Encoder::MaxEncodedSize(kEscapeAddress, data));
+}
+
+TEST(Encoder, MaxEncodedSize_PayloadWithAllEscapes) {
+  constexpr auto data = bytes::Initialized<8>(0x7e);
+  EXPECT_EQ(27u, Encoder::MaxEncodedSize(kAddress, data));
+  EXPECT_EQ(27u, Encoder::MaxEncodedSize(kEscapeAddress, data));
+}
+
+}  // namespace
+}  // namespace internal
+}  // namespace pw::hdlc
diff --git a/pw_hdlc/public/pw_hdlc/decoder.h b/pw_hdlc/public/pw_hdlc/decoder.h
new file mode 100644
index 0000000..f6f5707
--- /dev/null
+++ b/pw_hdlc/public/pw_hdlc/decoder.h
@@ -0,0 +1,173 @@
+// 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.
+#pragma once
+
+#include <algorithm>
+#include <array>
+#include <cstddef>
+#include <cstring>
+#include <functional>  // std::invoke
+
+#include "pw_assert/light.h"
+#include "pw_bytes/span.h"
+#include "pw_checksum/crc32.h"
+#include "pw_result/result.h"
+#include "pw_status/status.h"
+
+namespace pw::hdlc {
+
+// Represents the contents of an HDLC frame -- the unescaped data between two
+// flag bytes. Instances of Frame are only created when a full, valid frame has
+// been read.
+//
+// For now, the Frame class assumes a single-byte control field and a 32-bit
+// frame check sequence (FCS).
+class Frame {
+ private:
+  static constexpr size_t kMinimumAddressSize = 1;
+  static constexpr size_t kControlSize = 1;
+  static constexpr size_t kFcsSize = sizeof(uint32_t);
+
+ public:
+  // The minimum size of a frame, excluding control bytes (flag or escape).
+  static constexpr size_t kMinSizeBytes =
+      kMinimumAddressSize + kControlSize + kFcsSize;
+
+  static Result<Frame> Parse(ConstByteSpan frame);
+
+  constexpr uint64_t address() const { return address_; }
+
+  constexpr std::byte control() const { return control_; }
+
+  constexpr ConstByteSpan data() const { return data_; }
+
+ private:
+  // Creates a Frame with the specified data. The data MUST be valid frame data
+  // with a verified frame check sequence.
+  constexpr Frame(uint64_t address, std::byte control, ConstByteSpan data)
+      : data_(data), address_(address), control_(control) {}
+
+  ConstByteSpan data_;
+  uint64_t address_;
+  std::byte control_;
+};
+
+// The Decoder class facilitates decoding of data frames using the HDLC
+// protocol, by returning packets as they are decoded and storing incomplete
+// data frames in a buffer.
+//
+// The Decoder class does not own the buffer it writes to. It can be used to
+// write bytes to any buffer. The DecoderBuffer template class, defined below,
+// allocates a buffer.
+class Decoder {
+ public:
+  constexpr Decoder(ByteSpan buffer)
+      : buffer_(buffer),
+        last_read_bytes_({}),
+        last_read_bytes_index_(0),
+        current_frame_size_(0),
+        state_(State::kInterFrame) {}
+
+  Decoder(const Decoder&) = delete;
+  Decoder& operator=(const Decoder&) = delete;
+
+  // Parses a single byte of an HDLC stream. Returns a Result with the complete
+  // frame if the byte completes a frame. The status is the following:
+  //
+  //     OK - A frame was successfully decoded. The Result contains the Frame,
+  //         which is invalidated by the next Process call.
+  //     UNAVAILABLE - No frame is available.
+  //     RESOURCE_EXHAUSTED - A frame completed, but it was too large to fit in
+  //         the decoder's buffer.
+  //     DATA_LOSS - A frame completed, but it was invalid. The frame was
+  //         incomplete or the frame check sequence verification failed.
+  //
+  Result<Frame> Process(std::byte b);
+
+  // Processes a span of data and calls the provided callback with each frame or
+  // error.
+  template <typename F, typename... Args>
+  void Process(ConstByteSpan data, F&& callback, Args&&... args) {
+    for (std::byte b : data) {
+      auto result = Process(b);
+      if (result.status() != Status::Unavailable()) {
+        std::invoke(
+            std::forward<F>(callback), std::forward<Args>(args)..., result);
+      }
+    }
+  }
+
+  // Returns the maximum size of the Decoder's frame buffer.
+  size_t max_size() const { return buffer_.size(); }
+
+  // Clears and resets the decoder.
+  void Clear() {
+    state_ = State::kInterFrame;
+    Reset();
+  };
+
+ private:
+  // State enum class is used to make the Decoder a finite state machine.
+  enum class State {
+    kInterFrame,
+    kFrame,
+    kFrameEscape,
+  };
+
+  void Reset() {
+    current_frame_size_ = 0;
+    last_read_bytes_index_ = 0;
+    fcs_.clear();
+  }
+
+  void AppendByte(std::byte new_byte);
+
+  Status CheckFrame() const;
+
+  bool VerifyFrameCheckSequence() const;
+
+  const ByteSpan buffer_;
+
+  // Ring buffer of the last four bytes read into the current frame, to allow
+  // calculating the frame's CRC incrementally. As data is evicted from this
+  // buffer, it is added to the running CRC. Once a frame is complete, the
+  // buffer contains the frame's FCS.
+  std::array<std::byte, sizeof(uint32_t)> last_read_bytes_;
+  size_t last_read_bytes_index_;
+
+  // Incremental checksum of the current frame.
+  checksum::Crc32 fcs_;
+
+  size_t current_frame_size_;
+
+  State state_;
+};
+
+// DecoderBuffers declare a buffer along with a Decoder.
+template <size_t kSizeBytes>
+class DecoderBuffer : public Decoder {
+ public:
+  DecoderBuffer() : Decoder(frame_buffer_) {}
+
+  // Returns the maximum length of the bytes that can be inserted in the bytes
+  // buffer.
+  static constexpr size_t max_size() { return kSizeBytes; }
+
+ private:
+  static_assert(kSizeBytes >= Frame::kMinSizeBytes);
+
+  std::array<std::byte, kSizeBytes> frame_buffer_;
+};
+
+}  // namespace pw::hdlc
diff --git a/pw_hdlc/public/pw_hdlc/encoder.h b/pw_hdlc/public/pw_hdlc/encoder.h
new file mode 100644
index 0000000..8dcf66f
--- /dev/null
+++ b/pw_hdlc/public/pw_hdlc/encoder.h
@@ -0,0 +1,36 @@
+// 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.
+#pragma once
+
+#include "pw_bytes/span.h"
+#include "pw_status/status.h"
+#include "pw_stream/stream.h"
+
+namespace pw::hdlc {
+
+// Writes an HDLC unnumbered information frame (UI-frame) to the provided
+// writer. The complete frame contains the following:
+//
+//   - HDLC flag byte (0x7e)
+//   - Address
+//   - UI-frame control (metadata) byte
+//   - Payload (0 or more bytes)
+//   - Frame check sequence (CRC-32)
+//   - HDLC flag byte (0x7e)
+//
+Status WriteUIFrame(uint64_t address,
+                    ConstByteSpan payload,
+                    stream::Writer& writer);
+
+}  // namespace pw::hdlc
diff --git a/pw_hdlc/public/pw_hdlc/internal/encoder.h b/pw_hdlc/public/pw_hdlc/internal/encoder.h
new file mode 100644
index 0000000..2f3b4b0
--- /dev/null
+++ b/pw_hdlc/public/pw_hdlc/internal/encoder.h
@@ -0,0 +1,60 @@
+// 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.
+#pragma once
+
+#include "pw_checksum/crc32.h"
+#include "pw_hdlc/internal/protocol.h"
+#include "pw_stream/stream.h"
+
+namespace pw::hdlc::internal {
+
+// Encodes and writes HDLC frames.
+class Encoder {
+ public:
+  constexpr Encoder(stream::Writer& output) : writer_(output) {}
+
+  // Writes the header for an I-frame. After successfully calling
+  // StartInformationFrame, WriteData may be called any number of times.
+  Status StartInformationFrame(uint64_t address) {
+    return StartFrame(address, kUnusedControl);
+  }
+
+  // Writes the header for an U-frame. After successfully calling
+  // StartUnnumberedFrame, WriteData may be called any number of times.
+  Status StartUnnumberedFrame(uint64_t address) {
+    return StartFrame(address, UFrameControl::UnnumberedInformation().data());
+  }
+
+  // Writes data for an ongoing frame. Must only be called after a successful
+  // StartInformationFrame call, and prior to a FinishFrame() call.
+  Status WriteData(ConstByteSpan data);
+
+  // Finishes a frame. Writes the frame check sequence and a terminating flag.
+  Status FinishFrame();
+
+  // Runs a pass through a payload, returning the worst-case encoded size for a
+  // frame containing it. Does not calculate CRC to improve efficiency.
+  static size_t MaxEncodedSize(uint64_t address, ConstByteSpan payload);
+
+ private:
+  // Indicates this an information packet with sequence numbers set to 0.
+  static constexpr std::byte kUnusedControl = std::byte{0};
+
+  Status StartFrame(uint64_t address, std::byte control);
+
+  stream::Writer& writer_;
+  checksum::Crc32 fcs_;
+};
+
+}  // namespace pw::hdlc::internal
diff --git a/pw_hdlc/public/pw_hdlc/internal/protocol.h b/pw_hdlc/public/pw_hdlc/internal/protocol.h
new file mode 100644
index 0000000..f11b34f
--- /dev/null
+++ b/pw_hdlc/public/pw_hdlc/internal/protocol.h
@@ -0,0 +1,64 @@
+// Copyright 2021 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.
+#pragma once
+
+#include <cstddef>
+
+#include "pw_varint/varint.h"
+
+namespace pw::hdlc {
+
+inline constexpr std::byte kFlag = std::byte{0x7E};
+inline constexpr std::byte kEscape = std::byte{0x7D};
+inline constexpr std::byte kEscapeConstant = std::byte{0x20};
+
+inline constexpr std::array<std::byte, 2> kEscapedFlag = {kEscape,
+                                                          std::byte{0x5E}};
+inline constexpr std::array<std::byte, 2> kEscapedEscape = {kEscape,
+                                                            std::byte{0x5D}};
+
+inline constexpr varint::Format kAddressFormat =
+    varint::Format::kOneTerminatedLeastSignificant;
+
+constexpr bool NeedsEscaping(std::byte b) {
+  return (b == kFlag || b == kEscape);
+}
+
+constexpr std::byte Escape(std::byte b) { return b ^ kEscapeConstant; }
+
+// Class that manages the 1-byte control field of an HDLC U-frame.
+class UFrameControl {
+ public:
+  static constexpr UFrameControl UnnumberedInformation() {
+    return UFrameControl(kUnnumberedInformation);
+  }
+
+  constexpr std::byte data() const { return data_; }
+
+ private:
+  // Types of HDLC U-frames and their bit patterns.
+  enum Type : uint8_t {
+    kUnnumberedInformation = 0x00,
+  };
+
+  constexpr UFrameControl(Type type)
+      : data_(kUFramePattern | std::byte{type}) {}
+
+  // U-frames are identified by having the bottom two control bits set.
+  static constexpr std::byte kUFramePattern = std::byte{0x03};
+
+  std::byte data_;
+};
+
+}  // namespace pw::hdlc
diff --git a/pw_hdlc/public/pw_hdlc/rpc_channel.h b/pw_hdlc/public/pw_hdlc/rpc_channel.h
new file mode 100644
index 0000000..58dac72
--- /dev/null
+++ b/pw_hdlc/public/pw_hdlc/rpc_channel.h
@@ -0,0 +1,89 @@
+// 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.
+#pragma once
+
+#include <array>
+#include <span>
+
+#include "pw_assert/light.h"
+#include "pw_hdlc/encoder.h"
+#include "pw_rpc/channel.h"
+#include "pw_stream/stream.h"
+
+namespace pw::hdlc {
+
+// Custom HDLC ChannelOutput class to write and read data through serial using
+// the HDLC protocol.
+//
+// WARNING: This ChannelOutput is not thread-safe. If thread-safety is required,
+// wrap this in a pw::rpc::SynchronizedChannelOutput.
+class RpcChannelOutput : public rpc::ChannelOutput {
+ public:
+  // The RpcChannelOutput class does not own the buffer it uses to store the
+  // protobuf bytes. This buffer is specified at the time of creation along with
+  // a writer object to which will be used to write and send the bytes.
+  constexpr RpcChannelOutput(stream::Writer& writer,
+                             std::span<std::byte> buffer,
+                             uint64_t address,
+                             const char* channel_name)
+      : ChannelOutput(channel_name),
+        writer_(writer),
+        buffer_(buffer),
+        address_(address) {}
+
+  std::span<std::byte> AcquireBuffer() override { return buffer_; }
+
+  Status SendAndReleaseBuffer(std::span<const std::byte> buffer) override {
+    PW_DASSERT(buffer.data() == buffer_.data());
+    if (buffer.empty()) {
+      return OkStatus();
+    }
+    return hdlc::WriteUIFrame(address_, buffer, writer_);
+  }
+
+ private:
+  stream::Writer& writer_;
+  const std::span<std::byte> buffer_;
+  const uint64_t address_;
+};
+
+// RpcChannelOutput with its own buffer.
+//
+// WARNING: This ChannelOutput is not thread-safe. If thread-safety is required,
+// wrap this in a pw::rpc::SynchronizedChannelOutput.
+template <size_t kBufferSize>
+class RpcChannelOutputBuffer : public rpc::ChannelOutput {
+ public:
+  constexpr RpcChannelOutputBuffer(stream::Writer& writer,
+                                   uint64_t address,
+                                   const char* channel_name)
+      : ChannelOutput(channel_name), writer_(writer), address_(address) {}
+
+  std::span<std::byte> AcquireBuffer() override { return buffer_; }
+
+  Status SendAndReleaseBuffer(std::span<const std::byte> buffer) override {
+    PW_DASSERT(buffer.data() == buffer_.data());
+    if (buffer.empty()) {
+      return OkStatus();
+    }
+    return hdlc::WriteUIFrame(address_, buffer, writer_);
+  }
+
+ private:
+  stream::Writer& writer_;
+  std::array<std::byte, kBufferSize> buffer_;
+  const uint64_t address_;
+};
+
+}  // namespace pw::hdlc
diff --git a/pw_hdlc/public/pw_hdlc/rpc_packets.h b/pw_hdlc/public/pw_hdlc/rpc_packets.h
new file mode 100644
index 0000000..d22137c
--- /dev/null
+++ b/pw_hdlc/public/pw_hdlc/rpc_packets.h
@@ -0,0 +1,34 @@
+// 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.
+#pragma once
+
+#include <cstdint>
+
+#include "pw_hdlc/decoder.h"
+#include "pw_rpc/channel.h"
+#include "pw_rpc/server.h"
+#include "pw_status/status.h"
+
+namespace pw::hdlc {
+
+inline constexpr uint8_t kDefaultRpcAddress = 'R';
+
+// Reads HDLC frames with sys_io::ReadByte, using decode_buffer to store frames.
+// HDLC frames sent to rpc_address are passed to the RPC server.
+Status ReadAndProcessPackets(rpc::Server& server,
+                             rpc::ChannelOutput& output,
+                             std::span<std::byte> decode_buffer,
+                             unsigned rpc_address = kDefaultRpcAddress);
+
+}  // namespace pw::hdlc
diff --git a/pw_hdlc/public/pw_hdlc/wire_packet_parser.h b/pw_hdlc/public/pw_hdlc/wire_packet_parser.h
new file mode 100644
index 0000000..cf83a0c
--- /dev/null
+++ b/pw_hdlc/public/pw_hdlc/wire_packet_parser.h
@@ -0,0 +1,43 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "pw_router/packet_parser.h"
+
+namespace pw::hdlc {
+
+// HDLC frame parser for routers that operates on wire-encoded frames.
+//
+// This allows routing HDLC frames through Pigweed routers without having to
+// first decode them from their wire format.
+class WirePacketParser : public router::PacketParser {
+ public:
+  constexpr WirePacketParser() : address_(0) {}
+
+  // Verifies and parses an HDLC frame. Packet passed in is expected to be a
+  // single, complete, wire-encoded frame, starting and ending with a flag.
+  bool Parse(ConstByteSpan packet) final;
+
+  std::optional<uint32_t> GetDestinationAddress() const override {
+    return address_;
+  }
+
+ protected:
+  constexpr uint64_t address() const { return address_; }
+
+ private:
+  uint64_t address_;
+};
+
+}  // namespace pw::hdlc
diff --git a/pw_hdlc/py/BUILD.gn b/pw_hdlc/py/BUILD.gn
new file mode 100644
index 0000000..f01f29d
--- /dev/null
+++ b/pw_hdlc/py/BUILD.gn
@@ -0,0 +1,39 @@
+# 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_hdlc/__init__.py",
+    "pw_hdlc/decode.py",
+    "pw_hdlc/encode.py",
+    "pw_hdlc/protocol.py",
+    "pw_hdlc/rpc.py",
+    "pw_hdlc/rpc_console.py",
+  ]
+  tests = [
+    "decode_test.py",
+    "encode_test.py",
+  ]
+  python_deps = [
+    "$dir_pw_protobuf_compiler/py",
+    "$dir_pw_rpc/py",
+  ]
+  python_test_deps = [ "$dir_pw_build/py" ]
+  pylintrc = "$dir_pigweed/.pylintrc"
+}
diff --git a/pw_hdlc/py/decode_test.py b/pw_hdlc/py/decode_test.py
new file mode 100755
index 0000000..5aa8f90
--- /dev/null
+++ b/pw_hdlc/py/decode_test.py
@@ -0,0 +1,310 @@
+#!/usr/bin/env python
+# 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.
+"""Contains the Python decoder tests and generates C++ decoder tests."""
+
+from typing import Iterator, List, NamedTuple, Tuple, Union
+import unittest
+
+from pw_build.generated_tests import Context, PyTest, TestGenerator, GroupOrTest
+from pw_build.generated_tests import parse_test_generation_args
+from pw_hdlc.decode import Frame, FrameDecoder, FrameStatus, NO_ADDRESS
+from pw_hdlc.protocol import frame_check_sequence as fcs
+from pw_hdlc.protocol import encode_address
+
+
+def _encode(address: int, control: int, data: bytes) -> bytes:
+    frame = encode_address(address) + bytes([control]) + data
+    frame += fcs(frame)
+    frame = frame.replace(b'\x7d', b'\x7d\x5d')
+    frame = frame.replace(b'\x7e', b'\x7d\x5e')
+    return b''.join([b'\x7e', frame, b'\x7e'])
+
+
+class Expected(NamedTuple):
+    address: int
+    control: bytes
+    data: bytes
+    status: FrameStatus = FrameStatus.OK
+
+    @classmethod
+    def error(cls, status: FrameStatus):
+        assert status is not FrameStatus.OK
+        return cls(NO_ADDRESS, b'', b'', status)
+
+    def __eq__(self, other) -> bool:
+        """Define == so an Expected and a Frame can be compared."""
+        return (self.address == other.address and self.control == other.control
+                and self.data == other.data and self.status is other.status)
+
+
+class ExpectedRaw(NamedTuple):
+    raw_encoded: bytes
+    status: FrameStatus
+
+    def __eq__(self, other) -> bool:
+        """Define == so an ExpectedRaw and a Frame can be compared."""
+        return (self.raw_encoded == other.raw_encoded
+                and self.status is other.status)
+
+
+Expectation = Union[Expected, ExpectedRaw]
+
+_PARTIAL = fcs(b'\x0ACmsg\x5e')
+_ESCAPED_FLAG_TEST_CASE = (
+    b'\x7e\x0ACmsg\x7d\x7e' + _PARTIAL + b'\x7e',
+    [
+        Expected.error(FrameStatus.FRAMING_ERROR),
+        Expected.error(FrameStatus.FRAMING_ERROR),
+    ],
+)
+
+TEST_CASES: Tuple[GroupOrTest[Tuple[bytes, List[Expectation]]], ...] = (
+    'Empty payload',
+    (_encode(0, 0, b''), [Expected(0, b'\0', b'')]),
+    (_encode(55, 0x99, b''), [Expected(55, b'\x99', b'')]),
+    (_encode(55, 0x99, b'') * 3, [Expected(55, b'\x99', b'')] * 3),
+    'Simple one-byte payload',
+    (_encode(0, 0, b'\0'), [Expected(0, b'\0', b'\0')]),
+    (_encode(123, 0, b'A'), [Expected(123, b'\0', b'A')]),
+    'Simple multi-byte payload',
+    (_encode(0, 0, b'Hello, world!'), [Expected(0, b'\0', b'Hello, world!')]),
+    (_encode(123, 0, b'\0\0\1\0\0'), [Expected(123, b'\0', b'\0\0\1\0\0')]),
+    'Escaped one-byte payload',
+    (_encode(1, 2, b'\x7e'), [Expected(1, b'\2', b'\x7e')]),
+    (_encode(1, 2, b'\x7d'), [Expected(1, b'\2', b'\x7d')]),
+    (_encode(1, 2, b'\x7e') + _encode(1, 2, b'\x7d'),
+     [Expected(1, b'\2', b'\x7e'),
+      Expected(1, b'\2', b'\x7d')]),
+    'Escaped address',
+    (_encode(0x7e, 0, b'A'), [Expected(0x7e, b'\0', b'A')]),
+    (_encode(0x7d, 0, b'B'), [Expected(0x7d, b'\0', b'B')]),
+    'Escaped control',
+    (_encode(0, 0x7e, b'C'), [Expected(0, b'\x7e', b'C')]),
+    (_encode(0, 0x7d, b'D'), [Expected(0, b'\x7d', b'D')]),
+    'Escaped address and control',
+    (_encode(0x7e, 0x7d, b'E'), [Expected(0x7e, b'\x7d', b'E')]),
+    (_encode(0x7d, 0x7e, b'F'), [Expected(0x7d, b'\x7e', b'F')]),
+    (_encode(0x7e, 0x7e, b'\x7e'), [Expected(0x7e, b'\x7e', b'\x7e')]),
+    'Multibyte address',
+    (_encode(128, 0, b'big address'), [Expected(128, b'\0', b'big address')]),
+    (_encode(0xffffffff, 0, b'\0\0\1\0\0'),
+     [Expected(0xffffffff, b'\0', b'\0\0\1\0\0')]),
+    'Multiple frames separated by single flag',
+    (_encode(0, 0, b'A')[:-1] + _encode(1, 2, b'123'),
+     [Expected(0, b'\0', b'A'),
+      Expected(1, b'\2', b'123')]),
+    (_encode(0xff, 0, b'Yo')[:-1] * 3 + b'\x7e',
+     [Expected(0xff, b'\0', b'Yo')] * 3),
+    'Ignore empty frames',
+    (b'\x7e\x7e', []),
+    (b'\x7e' * 10, []),
+    (b'\x7e\x7e' + _encode(1, 2, b'3') + b'\x7e' * 5,
+     [Expected(1, b'\2', b'3')]),
+    (b'\x7e' * 10 + _encode(1, 2, b':O') + b'\x7e' * 3 + _encode(3, 4, b':P'),
+     [Expected(1, b'\2', b':O'),
+      Expected(3, b'\4', b':P')]),
+    'Cannot escape flag',
+    (b'\x7e\xAA\x7d\x7e\xab\x00Hello' + fcs(b'\xab\0Hello') + b'\x7e', [
+        Expected.error(FrameStatus.FRAMING_ERROR),
+        Expected(0x55, b'\0', b'Hello'),
+    ]),
+    _ESCAPED_FLAG_TEST_CASE,
+    'Frame too short',
+    (b'\x7e1\x7e', [Expected.error(FrameStatus.FRAMING_ERROR)]),
+    (b'\x7e12\x7e', [Expected.error(FrameStatus.FRAMING_ERROR)]),
+    (b'\x7e12345\x7e', [Expected.error(FrameStatus.FRAMING_ERROR)]),
+    'Multibyte address too long',
+    (_encode(2 ** 100, 0, b'too long'),
+     [Expected.error(FrameStatus.BAD_ADDRESS)]),
+    'Incorrect frame check sequence',
+    (b'\x7e123456\x7e', [Expected.error(FrameStatus.FCS_MISMATCH)]),
+    (b'\x7e\1\2msg\xff\xff\xff\xff\x7e',
+     [Expected.error(FrameStatus.FCS_MISMATCH)]),
+    (_encode(0xA, 0xB, b'???')[:-2] + _encode(1, 2, b'def'), [
+        Expected.error(FrameStatus.FCS_MISMATCH),
+        Expected(1, b'\2', b'def'),
+    ]),
+    'Invalid escape in address',
+    (b'\x7e\x7d\x7d\0' + fcs(b'\x5d\0') + b'\x7e',
+     [Expected.error(FrameStatus.FRAMING_ERROR)]),
+    'Invalid escape in control',
+    (b'\x7e\0\x7d\x7d' + fcs(b'\0\x5d') + b'\x7e',
+     [Expected.error(FrameStatus.FRAMING_ERROR)]),
+    'Invalid escape in data',
+    (b'\x7e\0\1\x7d\x7d' + fcs(b'\0\1\x5d') + b'\x7e',
+     [Expected.error(FrameStatus.FRAMING_ERROR)]),
+    'Frame ends with escape',
+    (b'\x7e\x7d\x7e', [Expected.error(FrameStatus.FRAMING_ERROR)]),
+    (b'\x7e\1\x7d\x7e', [Expected.error(FrameStatus.FRAMING_ERROR)]),
+    (b'\x7e\1\2abc\x7d\x7e', [Expected.error(FrameStatus.FRAMING_ERROR)]),
+    (b'\x7e\1\2abcd\x7d\x7e', [Expected.error(FrameStatus.FRAMING_ERROR)]),
+    (b'\x7e\1\2abcd1234\x7d\x7e', [Expected.error(FrameStatus.FRAMING_ERROR)]),
+    'Inter-frame data is only escapes',
+    (b'\x7e\x7d\x7e\x7d\x7e', [
+        Expected.error(FrameStatus.FRAMING_ERROR),
+        Expected.error(FrameStatus.FRAMING_ERROR),
+    ]),
+    (b'\x7e\x7d\x7d\x7e\x7d\x7d\x7e', [
+        Expected.error(FrameStatus.FRAMING_ERROR),
+        Expected.error(FrameStatus.FRAMING_ERROR),
+    ]),
+    'Data before first flag',
+    (b'\0\1' + fcs(b'\0\1'), []),
+    (b'\0\1' + fcs(b'\0\1') + b'\x7e',
+     [Expected.error(FrameStatus.FRAMING_ERROR)]),
+    'No frames emitted until flag',
+    (_encode(1, 2, b'3')[:-1], []),
+    (b'\x7e' + _encode(1, 2, b'3')[1:-1] * 2, []),
+    'Only flag and escape characters can be escaped',
+    (b'\x7e\x7d\0' + _encode(1, 2, b'3'),
+     [Expected.error(FrameStatus.FRAMING_ERROR),
+      Expected(1, b'\2', b'3')]),
+    (b'\x7e1234\x7da' + _encode(1, 2, b'3'),
+     [Expected.error(FrameStatus.FRAMING_ERROR),
+      Expected(1, b'\2', b'3')]),
+    'Invalid frame records raw data',
+    (b'Hello?~', [ExpectedRaw(b'Hello?', FrameStatus.FRAMING_ERROR)]),
+    (b'~~Hel\x7d\x7dlo~',
+     [ExpectedRaw(b'Hel\x7d\x7dlo', FrameStatus.FRAMING_ERROR)]),
+    (b'Hello?~~~~~', [ExpectedRaw(b'Hello?', FrameStatus.FRAMING_ERROR)]),
+    (b'~~~~Hello?~~~~~', [ExpectedRaw(b'Hello?', FrameStatus.FCS_MISMATCH)]),
+    (b'Hello?~~Goodbye~', [
+        ExpectedRaw(b'Hello?', FrameStatus.FRAMING_ERROR),
+        ExpectedRaw(b'Goodbye', FrameStatus.FCS_MISMATCH),
+    ]),
+)  # yapf: disable
+# Formatting for the above tuple is very slow, so disable yapf.
+
+_TESTS = TestGenerator(TEST_CASES)
+
+
+def _expected(frames: List[Frame]) -> Iterator[str]:
+    for i, frame in enumerate(frames, 1):
+        if frame.ok():
+            yield f'      Frame::Parse(kDecodedFrame{i:02}).value(),'
+        elif frame.status is FrameStatus.BAD_ADDRESS:
+            yield f'      Frame::Parse(kDecodedFrame{i:02}).status(),'
+        else:
+            yield f'      Status::DataLoss(),  // Frame {i}'
+
+
+_CPP_HEADER = """\
+#include "pw_hdlc/decoder.h"
+
+#include <array>
+#include <cstddef>
+#include <variant>
+
+#include "gtest/gtest.h"
+#include "pw_bytes/array.h"
+
+namespace pw::hdlc {
+namespace {
+"""
+
+_CPP_FOOTER = """\
+}  // namespace
+}  // namespace pw::hdlc"""
+
+
+def _cpp_test(ctx: Context) -> Iterator[str]:
+    """Generates a C++ test for the provided test data."""
+    data, _ = ctx.test_case
+    frames = list(FrameDecoder().process(data))
+    data_bytes = ''.join(rf'\x{byte:02x}' for byte in data)
+
+    yield f'TEST(Decoder, {ctx.cc_name()}) {{'
+    yield f'  static constexpr auto kData = bytes::String("{data_bytes}");\n'
+
+    for i, frame in enumerate(frames, 1):
+        if frame.ok() or frame.status is FrameStatus.BAD_ADDRESS:
+            frame_bytes = ''.join(rf'\x{byte:02x}'
+                                  for byte in frame.raw_decoded)
+            yield (f'  static constexpr auto kDecodedFrame{i:02} = '
+                   f'bytes::String("{frame_bytes}");')
+        else:
+            yield f'  // Frame {i}: {frame.status.value}'
+
+    yield ''
+
+    expected = '\n'.join(_expected(frames)) or '      // No frames'
+    decoder_size = max(len(data), 8)  # Make sure large enough for a frame
+
+    yield f"""\
+  DecoderBuffer<{decoder_size}> decoder;
+
+  static std::array<std::variant<Frame, Status>, {len(frames)}> kExpected = {{
+{expected}
+  }};
+
+  size_t decoded_frames = 0;
+
+  decoder.Process(kData, [&](const Result<Frame>& result) {{
+    ASSERT_LT(decoded_frames++, kExpected.size());
+    auto& expected = kExpected[decoded_frames - 1];
+
+    if (std::holds_alternative<Status>(expected)) {{
+      EXPECT_EQ(Status::DataLoss(), result.status());
+    }} else {{
+      ASSERT_EQ(OkStatus(), result.status());
+
+      const Frame& decoded_frame = result.value();
+      const Frame& expected_frame = std::get<Frame>(expected);
+      EXPECT_EQ(expected_frame.address(), decoded_frame.address());
+      EXPECT_EQ(expected_frame.control(), decoded_frame.control());
+      ASSERT_EQ(expected_frame.data().size(), decoded_frame.data().size());
+      EXPECT_EQ(std::memcmp(expected_frame.data().data(),
+                            decoded_frame.data().data(),
+                            expected_frame.data().size()),
+                0);
+    }}
+  }});
+
+  EXPECT_EQ(decoded_frames, kExpected.size());
+}}"""
+
+
+def _define_py_test(ctx: Context) -> PyTest:
+    data, expected_frames = ctx.test_case
+
+    def test(self) -> None:
+        # Decode in one call
+        self.assertEqual(expected_frames,
+                         list(FrameDecoder().process(data)),
+                         msg=f'{ctx.group}: {data!r}')
+
+        # Decode byte-by-byte
+        decoder = FrameDecoder()
+        decoded_frames: List[Frame] = []
+        for i in range(len(data)):
+            decoded_frames += decoder.process(data[i:i + 1])
+
+        self.assertEqual(expected_frames,
+                         decoded_frames,
+                         msg=f'{ctx.group} (byte-by-byte): {data!r}')
+
+    return test
+
+
+# Class that tests all cases in TEST_CASES.
+DecoderTest = _TESTS.python_tests('DecoderTest', _define_py_test)
+
+if __name__ == '__main__':
+    args = parse_test_generation_args()
+    if args.generate_cc_test:
+        _TESTS.cc_tests(args.generate_cc_test, _cpp_test, _CPP_HEADER,
+                        _CPP_FOOTER)
+    else:
+        unittest.main()
diff --git a/pw_hdlc/py/encode_test.py b/pw_hdlc/py/encode_test.py
new file mode 100755
index 0000000..fa48c58
--- /dev/null
+++ b/pw_hdlc/py/encode_test.py
@@ -0,0 +1,61 @@
+#!/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 encoding HDLC frames."""
+
+import unittest
+
+from pw_hdlc import encode
+from pw_hdlc import protocol
+from pw_hdlc.protocol import frame_check_sequence as _fcs
+
+FLAG = bytes([protocol.FLAG])
+
+
+def _with_fcs(data: bytes) -> bytes:
+    return data + _fcs(data)
+
+
+class TestEncodeUIFrame(unittest.TestCase):
+    """Tests Encoding bytes with different arguments using a custom serial."""
+    def test_empty(self):
+        self.assertEqual(encode.ui_frame(0, b''),
+                         FLAG + _with_fcs(b'\x01\x03') + FLAG)
+        self.assertEqual(encode.ui_frame(0x1a, b''),
+                         FLAG + _with_fcs(b'\x35\x03') + FLAG)
+
+    def test_1byte(self):
+        self.assertEqual(encode.ui_frame(0, b'A'),
+                         FLAG + _with_fcs(b'\x01\x03A') + FLAG)
+
+    def test_multibyte(self):
+        self.assertEqual(encode.ui_frame(0, b'123456789'),
+                         FLAG + _with_fcs(b'\x01\x03123456789') + FLAG)
+
+    def test_multibyte_address(self):
+        self.assertEqual(encode.ui_frame(128, b'123456789'),
+                         FLAG + _with_fcs(b'\x00\x03\x03123456789') + FLAG)
+
+    def test_escape(self):
+        self.assertEqual(
+            encode.ui_frame(0x3e, b'\x7d'),
+            FLAG + b'\x7d\x5d\x03\x7d\x5d' + _fcs(b'\x7d\x03\x7d') + FLAG)
+        self.assertEqual(
+            encode.ui_frame(0x3e, b'A\x7e\x7dBC'),
+            FLAG + b'\x7d\x5d\x03A\x7d\x5e\x7d\x5dBC' +
+            _fcs(b'\x7d\x03A\x7e\x7dBC') + FLAG)
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/pw_hdlc_lite/py/pw_hdlc_lite/__init__.py b/pw_hdlc/py/pw_hdlc/__init__.py
similarity index 100%
rename from pw_hdlc_lite/py/pw_hdlc_lite/__init__.py
rename to pw_hdlc/py/pw_hdlc/__init__.py
diff --git a/pw_hdlc/py/pw_hdlc/decode.py b/pw_hdlc/py/pw_hdlc/decode.py
new file mode 100644
index 0000000..3c5b487
--- /dev/null
+++ b/pw_hdlc/py/pw_hdlc/decode.py
@@ -0,0 +1,179 @@
+# 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.
+"""Decoder class for decoding bytes using HDLC protocol"""
+
+import enum
+import logging
+from typing import Iterator, Optional
+import zlib
+
+from pw_hdlc import protocol
+
+_LOG = logging.getLogger('pw_hdlc')
+
+NO_ADDRESS = -1
+_MIN_FRAME_SIZE = 6  # 1 B address + 1 B control + 4 B CRC-32
+
+
+class FrameStatus(enum.Enum):
+    """Indicates that an error occurred."""
+    OK = 'OK'
+    FCS_MISMATCH = 'frame check sequence failure'
+    FRAMING_ERROR = 'invalid flag or escape characters'
+    BAD_ADDRESS = 'address field too long'
+
+
+class Frame:
+    """Represents an HDLC frame."""
+    def __init__(self,
+                 raw_encoded: bytes,
+                 raw_decoded: bytes,
+                 status: FrameStatus = FrameStatus.OK):
+        """Parses fields from an HDLC frame.
+
+        Arguments:
+            raw_encoded: The complete HDLC-encoded frame, excluding HDLC flag
+                characters.
+            raw_decoded: The complete decoded frame (address, control,
+                information, FCS).
+            status: Whether parsing the frame succeeded.
+        """
+        self.raw_encoded = raw_encoded
+        self.raw_decoded = raw_decoded
+        self.status = status
+
+        self.address: int = NO_ADDRESS
+        self.control: bytes = b''
+        self.data: bytes = b''
+
+        if status == FrameStatus.OK:
+            address, address_length = protocol.decode_address(raw_decoded)
+            if address_length == 0:
+                self.status = FrameStatus.BAD_ADDRESS
+                return
+
+            self.address = address
+            self.control = raw_decoded[address_length:address_length + 1]
+            self.data = raw_decoded[address_length + 1:-4]
+
+    def ok(self) -> bool:
+        """True if this represents a valid frame.
+
+        If false, then parsing failed. The status is set to indicate what type
+        of error occurred, and the data field contains all bytes parsed from the
+        frame (including bytes parsed as address or control bytes).
+        """
+        return self.status is FrameStatus.OK
+
+    def __repr__(self) -> str:
+        if self.ok():
+            body = (f'address={self.address}, control={self.control!r}, '
+                    f'data={self.data!r}')
+        else:
+            body = str(self.status)
+
+        return f'{type(self).__name__}({body})'
+
+
+class _State(enum.Enum):
+    INTERFRAME = 0
+    FRAME = 1
+    FRAME_ESCAPE = 2
+
+
+def _check_frame(frame_data: bytes) -> FrameStatus:
+    if len(frame_data) < _MIN_FRAME_SIZE:
+        return FrameStatus.FRAMING_ERROR
+
+    frame_crc = int.from_bytes(frame_data[-4:], 'little')
+    if zlib.crc32(frame_data[:-4]) != frame_crc:
+        return FrameStatus.FCS_MISMATCH
+
+    return FrameStatus.OK
+
+
+class FrameDecoder:
+    """Decodes one or more HDLC frames from a stream of data."""
+    def __init__(self):
+        self._decoded_data = bytearray()
+        self._raw_data = bytearray()
+        self._state = _State.INTERFRAME
+
+    def process(self, data: bytes) -> Iterator[Frame]:
+        """Decodes and yields HDLC frames, including corrupt frames.
+
+        The ok() method on Frame indicates whether it is valid or represents a
+        frame parsing error.
+
+        Yields:
+          Frames, which may be valid (frame.ok()) or corrupt (!frame.ok())
+        """
+        for byte in data:
+            frame = self._process_byte(byte)
+            if frame:
+                yield frame
+
+    def process_valid_frames(self, data: bytes) -> Iterator[Frame]:
+        """Decodes and yields valid HDLC frames, logging any errors."""
+        for frame in self.process(data):
+            if frame.ok():
+                yield frame
+            else:
+                _LOG.warning('Failed to decode frame: %s; discarded %d bytes',
+                             frame.status.value, len(frame.raw_encoded))
+                _LOG.debug('Discarded data: %s', frame.raw_encoded)
+
+    def _finish_frame(self, status: FrameStatus) -> Frame:
+        frame = Frame(bytes(self._raw_data), bytes(self._decoded_data), status)
+        self._raw_data.clear()
+        self._decoded_data.clear()
+        return frame
+
+    def _process_byte(self, byte: int) -> Optional[Frame]:
+        frame: Optional[Frame] = None
+
+        # Record every byte except the flag character.
+        if byte != protocol.FLAG:
+            self._raw_data.append(byte)
+
+        if self._state is _State.INTERFRAME:
+            if byte == protocol.FLAG:
+                if self._raw_data:
+                    frame = self._finish_frame(FrameStatus.FRAMING_ERROR)
+
+                self._state = _State.FRAME
+        elif self._state is _State.FRAME:
+            if byte == protocol.FLAG:
+                if self._raw_data:
+                    frame = self._finish_frame(_check_frame(
+                        self._decoded_data))
+
+                self._state = _State.FRAME
+            elif byte == protocol.ESCAPE:
+                self._state = _State.FRAME_ESCAPE
+            else:
+                self._decoded_data.append(byte)
+        elif self._state is _State.FRAME_ESCAPE:
+            if byte == protocol.FLAG:
+                frame = self._finish_frame(FrameStatus.FRAMING_ERROR)
+                self._state = _State.FRAME
+            elif byte in protocol.VALID_ESCAPED_BYTES:
+                self._state = _State.FRAME
+                self._decoded_data.append(protocol.escape(byte))
+            else:
+                self._state = _State.INTERFRAME
+        else:
+            raise AssertionError(f'Invalid decoder state: {self._state}')
+
+        return frame
diff --git a/pw_hdlc/py/pw_hdlc/encode.py b/pw_hdlc/py/pw_hdlc/encode.py
new file mode 100644
index 0000000..ceede92
--- /dev/null
+++ b/pw_hdlc/py/pw_hdlc/encode.py
@@ -0,0 +1,29 @@
+# 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.
+"""The encode module supports encoding HDLC frames."""
+
+from pw_hdlc import protocol
+
+_ESCAPE_BYTE = bytes([protocol.ESCAPE])
+_FLAG_BYTE = bytes([protocol.FLAG])
+
+
+def ui_frame(address: int, data: bytes) -> bytes:
+    """Encodes an HDLC UI-frame with a CRC-32 frame check sequence."""
+    frame = protocol.encode_address(
+        address) + protocol.UFrameControl.unnumbered_information().data + data
+    frame += protocol.frame_check_sequence(frame)
+    frame = frame.replace(_ESCAPE_BYTE, b'\x7d\x5d')
+    frame = frame.replace(_FLAG_BYTE, b'\x7d\x5e')
+    return b''.join([_FLAG_BYTE, frame, _FLAG_BYTE])
diff --git a/pw_hdlc/py/pw_hdlc/protocol.py b/pw_hdlc/py/pw_hdlc/protocol.py
new file mode 100644
index 0000000..f12b79b
--- /dev/null
+++ b/pw_hdlc/py/pw_hdlc/protocol.py
@@ -0,0 +1,86 @@
+# 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.
+"""Module for low-level HDLC protocol features."""
+
+from typing import Tuple
+
+import zlib
+
+# Special flag character for delimiting HDLC frames.
+FLAG = 0x7E
+
+# Special character for escaping other special characters in a frame.
+ESCAPE = 0x7D
+
+# Characters allowed after a 0x7d escape character.
+VALID_ESCAPED_BYTES = 0x5D, 0x5E
+
+# Maximum allowed HDLC address (uint64_t in C++).
+MAX_ADDRESS = 2**64 - 1
+
+
+def escape(byte: int) -> int:
+    """Escapes or unescapes a byte, which should have been preceeded by 0x7d."""
+    return byte ^ 0x20
+
+
+def frame_check_sequence(data: bytes) -> bytes:
+    return zlib.crc32(data).to_bytes(4, 'little')
+
+
+def encode_address(address: int) -> bytes:
+    """Encodes an HDLC address as a one-terminated LSB varint."""
+    result = bytearray()
+
+    while True:
+        result += bytes([(address & 0x7f) << 1])
+
+        address >>= 7
+        if address == 0:
+            break
+
+    result[-1] |= 0x1
+    return result
+
+
+def decode_address(frame: bytes) -> Tuple[int, int]:
+    """Decodes an HDLC address from a frame, returning it and its size."""
+    result = 0
+    length = 0
+
+    while length < len(frame):
+        byte = frame[length]
+        result |= (byte >> 1) << (length * 7)
+        length += 1
+
+        if byte & 0x1 == 0x1:
+            break
+
+    if result > MAX_ADDRESS:
+        return -1, 0
+
+    return result, length
+
+
+class UFrameControl:
+    def __init__(self, frame_type: int):
+        self._data: bytes = bytes([0x03 | frame_type])
+
+    @property
+    def data(self):
+        return self._data
+
+    @classmethod
+    def unnumbered_information(cls):
+        return UFrameControl(0x00)
diff --git a/pw_hdlc_lite/py/pw_hdlc_lite/py.typed b/pw_hdlc/py/pw_hdlc/py.typed
similarity index 100%
rename from pw_hdlc_lite/py/pw_hdlc_lite/py.typed
rename to pw_hdlc/py/pw_hdlc/py.typed
diff --git a/pw_hdlc/py/pw_hdlc/rpc.py b/pw_hdlc/py/pw_hdlc/rpc.py
new file mode 100644
index 0000000..d36463f
--- /dev/null
+++ b/pw_hdlc/py/pw_hdlc/rpc.py
@@ -0,0 +1,174 @@
+# 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.
+"""Utilities for using HDLC with pw_rpc."""
+
+from concurrent.futures import ThreadPoolExecutor
+import logging
+import sys
+import threading
+import time
+from typing import (Any, BinaryIO, Callable, Dict, Iterable, List, NoReturn,
+                    Optional, Union)
+
+from pw_protobuf_compiler import python_protos
+import pw_rpc
+from pw_rpc import callback_client
+
+from pw_hdlc.decode import Frame, FrameDecoder
+from pw_hdlc import encode
+
+_LOG = logging.getLogger(__name__)
+
+STDOUT_ADDRESS = 1
+DEFAULT_ADDRESS = ord('R')
+
+
+def channel_output(writer: Callable[[bytes], Any],
+                   address: int = DEFAULT_ADDRESS,
+                   delay_s: float = 0) -> Callable[[bytes], None]:
+    """Returns a function that can be used as a channel output for pw_rpc."""
+
+    if delay_s:
+
+        def slow_write(data: bytes) -> None:
+            """Slows down writes in case unbuffered serial is in use."""
+            for byte in data:
+                time.sleep(delay_s)
+                writer(bytes([byte]))
+
+        return lambda data: slow_write(encode.ui_frame(address, data))
+
+    def write_hdlc(data: bytes):
+        frame = encode.ui_frame(address, data)
+        _LOG.debug('Write %2d B: %s', len(frame), frame)
+        writer(frame)
+
+    return write_hdlc
+
+
+def _handle_error(frame: Frame) -> None:
+    _LOG.error('Failed to parse frame: %s', frame.status.value)
+    _LOG.debug('%s', frame.data)
+
+
+FrameHandlers = Dict[int, Callable[[Frame], Any]]
+
+
+def read_and_process_data(read: Callable[[], bytes],
+                          on_read_error: Callable[[Exception], Any],
+                          frame_handlers: FrameHandlers,
+                          error_handler: Callable[[Frame],
+                                                  Any] = _handle_error,
+                          handler_threads: Optional[int] = 1) -> NoReturn:
+    """Continuously reads and handles HDLC frames.
+
+    Passes frames to an executor that calls frame handler functions in other
+    threads.
+    """
+    def handle_frame(frame: Frame):
+        try:
+            if not frame.ok():
+                error_handler(frame)
+                return
+
+            try:
+                frame_handlers[frame.address](frame)
+            except KeyError:
+                _LOG.warning('Unhandled frame for address %d: %s',
+                             frame.address, frame)
+        except:  # pylint: disable=bare-except
+            _LOG.exception('Exception in HDLC frame handler thread')
+
+    decoder = FrameDecoder()
+
+    # Execute callbacks in a ThreadPoolExecutor to decouple reading the input
+    # stream from handling the data. That way, if a handler function takes a
+    # long time or crashes, this reading thread is not interrupted.
+    with ThreadPoolExecutor(max_workers=handler_threads) as executor:
+        while True:
+            try:
+                data = read()
+            except Exception as exc:  # pylint: disable=broad-except
+                on_read_error(exc)
+                continue
+
+            if data:
+                _LOG.debug('Read %2d B: %s', len(data), data)
+
+                for frame in decoder.process_valid_frames(data):
+                    executor.submit(handle_frame, frame)
+
+
+def write_to_file(data: bytes, output: BinaryIO = sys.stdout.buffer):
+    output.write(data + b'\n')
+    output.flush()
+
+
+def default_channels(write: Callable[[bytes], Any]) -> List[pw_rpc.Channel]:
+    return [pw_rpc.Channel(1, channel_output(write))]
+
+
+class HdlcRpcClient:
+    """An RPC client configured to run over HDLC."""
+    def __init__(self,
+                 read: Callable[[], bytes],
+                 paths_or_modules: Union[Iterable[python_protos.PathOrModule],
+                                         python_protos.Library],
+                 channels: Iterable[pw_rpc.Channel],
+                 output: Callable[[bytes], Any] = write_to_file,
+                 client_impl: pw_rpc.client.ClientImpl = None):
+        """Creates an RPC client configured to communicate using HDLC.
+
+        Args:
+          read: Function that reads bytes; e.g serial_device.read.
+          paths_or_modules: paths to .proto files or proto modules
+          channel: RPC channels to use for output
+          output: where to write "stdout" output from the device
+        """
+        if isinstance(paths_or_modules, python_protos.Library):
+            self.protos = paths_or_modules
+        else:
+            self.protos = python_protos.Library.from_paths(paths_or_modules)
+
+        if client_impl is None:
+            client_impl = callback_client.Impl()
+
+        self.client = pw_rpc.Client.from_modules(client_impl, channels,
+                                                 self.protos.modules())
+        frame_handlers: FrameHandlers = {
+            DEFAULT_ADDRESS: self._handle_rpc_packet,
+            STDOUT_ADDRESS: lambda frame: output(frame.data),
+        }
+
+        # Start background thread that reads and processes RPC packets.
+        threading.Thread(target=read_and_process_data,
+                         daemon=True,
+                         args=(read, lambda exc: None,
+                               frame_handlers)).start()
+
+    def rpcs(self, channel_id: int = None) -> Any:
+        """Returns object for accessing services on the specified channel.
+
+        This skips some intermediate layers to make it simpler to invoke RPCs
+        from an HdlcRpcClient. If only one channel is in use, the channel ID is
+        not necessary.
+        """
+        if channel_id is None:
+            return next(iter(self.client.channels())).rpcs
+
+        return self.client.channel(channel_id).rpcs
+
+    def _handle_rpc_packet(self, frame: Frame) -> None:
+        if not self.client.process_packet(frame.data):
+            _LOG.error('Packet not handled by RPC client: %s', frame.data)
diff --git a/pw_hdlc/py/pw_hdlc/rpc_console.py b/pw_hdlc/py/pw_hdlc/rpc_console.py
new file mode 100644
index 0000000..f5484c8
--- /dev/null
+++ b/pw_hdlc/py/pw_hdlc/rpc_console.py
@@ -0,0 +1,169 @@
+# 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.
+"""Console for interacting with pw_rpc over HDLC.
+
+To start the console, provide a serial port as the --device argument and paths
+or globs for .proto files that define the RPC services to support:
+
+  python -m pw_hdlc.rpc_console --device /dev/ttyUSB0 sample.proto
+
+This starts an IPython console for communicating with the connected device. A
+few variables are predefined in the interactive console. These include:
+
+    rpcs   - used to invoke RPCs
+    device - the serial device used for communication
+    client - the pw_rpc.Client
+    protos - protocol buffer messages indexed by proto package
+
+An example echo RPC command:
+
+  rpcs.pw.rpc.EchoService.Echo(msg="hello!")
+"""
+
+import argparse
+import glob
+import logging
+from pathlib import Path
+import sys
+from typing import Any, Collection, Iterable, Iterator
+import socket
+
+import IPython  # type: ignore
+import serial  # type: ignore
+
+from pw_hdlc.rpc import HdlcRpcClient, default_channels, write_to_file
+
+_LOG = logging.getLogger(__name__)
+
+PW_RPC_MAX_PACKET_SIZE = 256
+SOCKET_SERVER = 'localhost'
+SOCKET_PORT = 33000
+MKFIFO_MODE = 0o666
+
+
+def _parse_args():
+    """Parses and returns the command line arguments."""
+    parser = argparse.ArgumentParser(description=__doc__)
+    group = parser.add_mutually_exclusive_group(required=True)
+    group.add_argument('-d', '--device', help='the serial port to use')
+    parser.add_argument('-b',
+                        '--baudrate',
+                        type=int,
+                        default=115200,
+                        help='the baud rate to use')
+    parser.add_argument(
+        '-o',
+        '--output',
+        type=argparse.FileType('wb'),
+        default=sys.stdout.buffer,
+        help=('The file to which to write device output (HDLC channel 1); '
+              'provide - or omit for stdout.'))
+    group.add_argument('-s',
+                       '--socket-addr',
+                       type=str,
+                       help='use socket to connect to server, type default for\
+            localhost:33000, or manually input the server address:port')
+    parser.add_argument('proto_globs',
+                        nargs='+',
+                        help='glob pattern for .proto files')
+    return parser.parse_args()
+
+
+def _expand_globs(globs: Iterable[str]) -> Iterator[Path]:
+    for pattern in globs:
+        for file in glob.glob(pattern, recursive=True):
+            yield Path(file)
+
+
+def _start_ipython_terminal(client: HdlcRpcClient) -> None:
+    """Starts an interactive IPython terminal with preset variables."""
+    local_variables = dict(
+        client=client,
+        channel_client=client.client.channel(1),
+        rpcs=client.client.channel(1).rpcs,
+        protos=client.protos.packages,
+    )
+
+    print(__doc__)  # Print the banner
+    IPython.terminal.embed.InteractiveShellEmbed().mainloop(
+        local_ns=local_variables, module=argparse.Namespace())
+
+
+class SocketClientImpl:
+    def __init__(self, config: str):
+        self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+        socket_server = ''
+        socket_port = 0
+
+        if config == 'default':
+            socket_server = SOCKET_SERVER
+            socket_port = SOCKET_PORT
+        else:
+            socket_server, socket_port_str = config.split(':')
+            socket_port = int(socket_port_str)
+        self.socket.connect((socket_server, socket_port))
+
+    def write(self, data: bytes):
+        self.socket.sendall(data)
+
+    def read(self, num_bytes: int = PW_RPC_MAX_PACKET_SIZE):
+        return self.socket.recv(num_bytes)
+
+
+def console(device: str, baudrate: int, proto_globs: Collection[str],
+            socket_addr: str, output: Any) -> int:
+    """Starts an interactive RPC console for HDLC."""
+    # argparse.FileType doesn't correctly handle '-' for binary files.
+    if output is sys.stdout:
+        output = sys.stdout.buffer
+
+    if not proto_globs:
+        proto_globs = ['**/*.proto']
+
+    protos = list(_expand_globs(proto_globs))
+
+    if not protos:
+        _LOG.critical('No .proto files were found with %s',
+                      ', '.join(proto_globs))
+        _LOG.critical('At least one .proto file is required')
+        return 1
+
+    _LOG.debug('Found %d .proto files found with %s', len(protos),
+               ', '.join(proto_globs))
+
+    if socket_addr is None:
+        serial_device = serial.Serial(device, baudrate, timeout=1)
+        read = lambda: serial_device.read(8192)
+        write = serial_device.write
+    else:
+        try:
+            socket_device = SocketClientImpl(socket_addr)
+            read = socket_device.read
+            write = socket_device.write
+        except ValueError:
+            _LOG.exception('Failed to initialize socket at %s', socket_addr)
+            return 1
+
+    _start_ipython_terminal(
+        HdlcRpcClient(read, protos, default_channels(write),
+                      lambda data: write_to_file(data, output)))
+    return 0
+
+
+def main() -> int:
+    return console(**vars(_parse_args()))
+
+
+if __name__ == '__main__':
+    sys.exit(main())
diff --git a/pw_hdlc/py/setup.py b/pw_hdlc/py/setup.py
new file mode 100644
index 0000000..88db976
--- /dev/null
+++ b/pw_hdlc/py/setup.py
@@ -0,0 +1,33 @@
+# 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_hdlc"""
+
+import setuptools  # type: ignore
+
+setuptools.setup(
+    name='pw_hdlc',
+    version='0.0.1',
+    author='Pigweed Authors',
+    author_email='pigweed-developers@googlegroups.com',
+    description='Tools for Encoding/Decoding data using the HDLC protocol',
+    packages=setuptools.find_packages(),
+    package_data={'pw_hdlc': ['py.typed']},
+    zip_safe=False,
+    install_requires=[
+        'ipython',
+        'pw_protobuf_compiler',
+        'pw_rpc',
+    ],
+    tests_require=['pw_build'],
+)
diff --git a/pw_hdlc/rpc_channel_test.cc b/pw_hdlc/rpc_channel_test.cc
new file mode 100644
index 0000000..538f699
--- /dev/null
+++ b/pw_hdlc/rpc_channel_test.cc
@@ -0,0 +1,155 @@
+// 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.
+
+// 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.
+
+#include "pw_hdlc/rpc_channel.h"
+
+#include <algorithm>
+#include <array>
+#include <cstddef>
+
+#include "gtest/gtest.h"
+#include "pw_bytes/array.h"
+#include "pw_stream/memory_stream.h"
+
+using std::byte;
+
+namespace pw::hdlc {
+namespace {
+
+constexpr byte kFlag = byte{0x7E};
+constexpr uint8_t kAddress = 0x7b;  // 123
+constexpr uint8_t kEncodedAddress = (kAddress << 1) | 1;
+constexpr byte kControl = byte{0x3};  // UI-frame control sequence.
+
+// Size of the in-memory buffer to use for this test.
+constexpr size_t kSinkBufferSize = 15;
+
+TEST(RpcChannelOutput, 1BytePayload) {
+  std::array<byte, kSinkBufferSize> channel_output_buffer;
+  stream::MemoryWriterBuffer<kSinkBufferSize> memory_writer;
+
+  RpcChannelOutput output(
+      memory_writer, channel_output_buffer, kAddress, "RpcChannelOutput");
+
+  constexpr byte test_data = byte{'A'};
+  auto buffer = output.AcquireBuffer();
+  std::memcpy(buffer.data(), &test_data, sizeof(test_data));
+
+  constexpr auto expected = bytes::Concat(
+      kFlag, kEncodedAddress, kControl, 'A', uint32_t{0x653c9e82}, kFlag);
+
+  EXPECT_EQ(OkStatus(),
+            output.SendAndReleaseBuffer(buffer.first(sizeof(test_data))));
+
+  ASSERT_EQ(memory_writer.bytes_written(), expected.size());
+  EXPECT_EQ(
+      std::memcmp(
+          memory_writer.data(), expected.data(), memory_writer.bytes_written()),
+      0);
+}
+
+TEST(RpcChannelOutput, EscapingPayloadTest) {
+  std::array<byte, kSinkBufferSize> channel_output_buffer;
+  stream::MemoryWriterBuffer<kSinkBufferSize> memory_writer;
+
+  RpcChannelOutput output(
+      memory_writer, channel_output_buffer, kAddress, "RpcChannelOutput");
+
+  constexpr auto test_data = bytes::Array<0x7D>();
+  auto buffer = output.AcquireBuffer();
+  std::memcpy(buffer.data(), test_data.data(), test_data.size());
+
+  constexpr auto expected = bytes::Concat(kFlag,
+                                          kEncodedAddress,
+                                          kControl,
+                                          byte{0x7d},
+                                          byte{0x7d} ^ byte{0x20},
+                                          uint32_t{0x4a53e205},
+                                          kFlag);
+  EXPECT_EQ(OkStatus(),
+            output.SendAndReleaseBuffer(buffer.first(test_data.size())));
+
+  ASSERT_EQ(memory_writer.bytes_written(), 10u);
+  EXPECT_EQ(
+      std::memcmp(
+          memory_writer.data(), expected.data(), memory_writer.bytes_written()),
+      0);
+}
+
+TEST(RpcChannelOutputBuffer, 1BytePayload) {
+  stream::MemoryWriterBuffer<kSinkBufferSize> memory_writer;
+
+  RpcChannelOutputBuffer<kSinkBufferSize> output(
+      memory_writer, kAddress, "RpcChannelOutput");
+
+  constexpr byte test_data = byte{'A'};
+  auto buffer = output.AcquireBuffer();
+  std::memcpy(buffer.data(), &test_data, sizeof(test_data));
+
+  constexpr auto expected = bytes::Concat(
+      kFlag, kEncodedAddress, kControl, 'A', uint32_t{0x653c9e82}, kFlag);
+
+  EXPECT_EQ(OkStatus(),
+            output.SendAndReleaseBuffer(buffer.first(sizeof(test_data))));
+
+  ASSERT_EQ(memory_writer.bytes_written(), expected.size());
+  EXPECT_EQ(
+      std::memcmp(
+          memory_writer.data(), expected.data(), memory_writer.bytes_written()),
+      0);
+}
+
+TEST(RpcChannelOutputBuffer, MultibyteAddress) {
+  stream::MemoryWriterBuffer<kSinkBufferSize> memory_writer;
+
+  RpcChannelOutputBuffer<kSinkBufferSize> output(
+      memory_writer, 0x3fff, "RpcChannelOutput");
+
+  constexpr byte test_data = byte{'A'};
+  auto buffer = output.AcquireBuffer();
+  std::memcpy(buffer.data(), &test_data, sizeof(test_data));
+
+  constexpr auto expected = bytes::Concat(kFlag,
+                                          bytes::String("\xfe\xff"),
+                                          kControl,
+                                          'A',
+                                          uint32_t{0xd393a8a0},
+                                          kFlag);
+
+  EXPECT_EQ(OkStatus(),
+            output.SendAndReleaseBuffer(buffer.first(sizeof(test_data))));
+
+  ASSERT_EQ(memory_writer.bytes_written(), expected.size());
+  EXPECT_EQ(
+      std::memcmp(
+          memory_writer.data(), expected.data(), memory_writer.bytes_written()),
+      0);
+}
+
+}  // namespace
+}  // namespace pw::hdlc
diff --git a/pw_hdlc/rpc_example/BUILD b/pw_hdlc/rpc_example/BUILD
new file mode 100644
index 0000000..b2a48a2
--- /dev/null
+++ b/pw_hdlc/rpc_example/BUILD
@@ -0,0 +1,38 @@
+# 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.
+
+load(
+    "//pw_build:pigweed.bzl",
+    "pw_cc_library",
+)
+
+pw_cc_library(
+    name = "rpc_example",
+    srcs = [
+        "hdlc_rpc_server.cc",
+        "main.cc",
+    ],
+    hdrs = [
+        "public/pw_hdlc/decoder.h",
+        "public/pw_hdlc/hdlc_channel.h",
+        "public/pw_hdlc/rpc_server_packets.h",
+    ],
+    deps = [
+        "//pw_hdlc",
+        "//pw_hdlc:pw_rpc",
+        "//pw_rpc:server",
+        "//pw_log",
+    ],
+)
+
diff --git a/pw_hdlc/rpc_example/BUILD.gn b/pw_hdlc/rpc_example/BUILD.gn
new file mode 100644
index 0000000..06a6407
--- /dev/null
+++ b/pw_hdlc/rpc_example/BUILD.gn
@@ -0,0 +1,45 @@
+# 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")
+import("$dir_pw_build/target_types.gni")
+import("$dir_pw_third_party/nanopb/nanopb.gni")
+
+if (dir_pw_third_party_nanopb == "") {
+  group("rpc_example") {
+  }
+} else {
+  pw_executable("rpc_example") {
+    sources = [
+      "hdlc_rpc_server.cc",
+      "main.cc",
+    ]
+    deps = [
+      "$dir_pw_rpc:server",
+      "$dir_pw_rpc/nanopb:echo_service",
+      "$dir_pw_rpc/system_server",
+      "..:pw_rpc",
+      dir_pw_hdlc,
+      dir_pw_log,
+    ]
+  }
+}
+
+pw_python_script("example_script") {
+  sources = [ "example_script.py" ]
+  python_deps = [ "$dir_pw_hdlc/py" ]
+  pylintrc = "$dir_pigweed/.pylintrc"
+}
diff --git a/pw_hdlc/rpc_example/CMakeLists.txt b/pw_hdlc/rpc_example/CMakeLists.txt
new file mode 100644
index 0000000..67f4110
--- /dev/null
+++ b/pw_hdlc/rpc_example/CMakeLists.txt
@@ -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.
+
+
+add_executable(pw_hdlc.rpc_example EXCLUDE_FROM_ALL
+    hdlc_rpc_server.cc
+    main.cc
+)
+
+target_link_libraries(pw_hdlc.rpc_example
+  PRIVATE
+    pw_hdlc
+    pw_log
+    pw_rpc.nanopb.echo_service
+    pw_rpc.server
+    pw_rpc.system_server
+)
diff --git a/pw_hdlc/rpc_example/docs.rst b/pw_hdlc/rpc_example/docs.rst
new file mode 100644
index 0000000..d4b4ca3
--- /dev/null
+++ b/pw_hdlc/rpc_example/docs.rst
@@ -0,0 +1,134 @@
+.. _module-pw_hdlc-rpc-example:
+
+=============================
+RPC over HDLC example project
+=============================
+The :ref:`module-pw_hdlc` module includes an example of bringing up a
+:ref:`module-pw_rpc` server that can be used to invoke RPCs. The example code
+is located at ``pw_hdlc/rpc_example``. This section walks through invoking RPCs
+interactively and with a script using the RPC over HDLC example.
+
+These instructions assume the STM32F429i Discovery board, but they work with
+any target with :ref:`pw::sys_io <module-pw_sys_io>` implemented.
+
+---------------------
+Getting started guide
+---------------------
+
+1. Set up your board
+====================
+Connect the board you'll be communicating with. For the Discovery board, connect
+the mini USB port, and note which serial device it appears as (e.g.
+``/dev/ttyACM0``).
+
+2. Build Pigweed
+================
+Activate the Pigweed environment and run the default build.
+
+.. code-block:: sh
+
+  source activate.sh
+  gn gen out
+  ninja -C out
+
+3. Flash the firmware image
+===========================
+After a successful build, the binary for the example will be located at
+``out/<toolchain>/obj/pw_hdlc/rpc_example/bin/rpc_example.elf``.
+
+Flash this image to your board. If you are using the STM32F429i Discovery Board,
+you can flash the image with `OpenOCD <http://openocd.org>`_.
+
+.. code-block:: sh
+
+ openocd -f targets/stm32f429i-disc1/py/stm32f429i_disc1_utils/openocd_stm32f4xx.cfg \
+     -c "program out/stm32f429i_disc1_debug/obj/pw_hdlc/rpc_example/bin/rpc_example.elf"
+
+4. Invoke RPCs from in an interactive console
+=============================================
+The RPC console uses `IPython <https://ipython.org>`_ to make a rich interactive
+console for working with pw_rpc. Run the RPC console with the following command,
+replacing ``/dev/ttyACM0`` with the correct serial device for your board.
+
+.. code-block:: text
+
+  $ python -m pw_hdlc.rpc_console --device /dev/ttyACM0
+
+  Console for interacting with pw_rpc over HDLC.
+
+  To start the console, provide a serial port as the --device argument and paths
+  or globs for .proto files that define the RPC services to support:
+
+    python -m pw_hdlc.rpc_console --device /dev/ttyUSB0 sample.proto
+
+  This starts an IPython console for communicating with the connected device. A
+  few variables are predefined in the interactive console. These include:
+
+      rpcs   - used to invoke RPCs
+      device - the serial device used for communication
+      client - the pw_rpc.Client
+
+  An example echo RPC command:
+
+    rpcs.pw.rpc.EchoService.Echo(msg="hello!")
+
+  In [1]:
+
+RPCs may be accessed through the predefined ``rpcs`` variable. RPCs are
+organized by their protocol buffer package and RPC service, as defined in a
+.proto file. To call the ``Echo`` method is part of the ``EchoService``, which
+is in the ``pw.rpc`` package. To invoke it synchronously, call
+``rpcs.pw.rpc.EchoService.Echo``:
+
+.. code-block:: python
+
+    In [1]: rpcs.pw.rpc.EchoService.Echo(msg="Your message here!")
+    Out[1]: (<Status.OK: 0>, msg: "Your message here!")
+
+5. Invoke RPCs with a script
+============================
+RPCs may also be invoked from Python scripts. Close the RPC console if it is
+running, and execute the example script. Set the --device argument to the
+serial port for your device.
+
+.. code-block:: text
+
+  $ pw_hdlc/rpc_example/example_script.py --device /dev/ttyACM0
+  The status was Status.OK
+  The payload was msg: "Hello"
+
+  The device says: Goodbye!
+
+-------------------------
+Local RPC example project
+-------------------------
+
+This example is similar to the above example, except it use socket to
+connect server and client running on the host.
+
+1. Build Pigweed
+================
+Activate the Pigweed environment and build the code.
+
+.. code-block:: sh
+
+  source activate.sh
+  gn gen out
+  pw watch
+
+2. Start client side and server side
+====================================
+
+Run pw_rpc client (i.e. use echo.proto)
+
+.. code-block:: sh
+
+  python -m pw_hdlc.rpc_console path/to/echo.proto -s localhost:33000
+
+Run pw_rpc server
+
+.. code-block:: sh
+
+  out/host_clang_debug/obj/pw_hdlc/rpc_example/bin/rpc_example
+
+Then you can invoke RPCs from the interactive console on the client side.
diff --git a/pw_hdlc/rpc_example/example_script.py b/pw_hdlc/rpc_example/example_script.py
new file mode 100755
index 0000000..c979240
--- /dev/null
+++ b/pw_hdlc/rpc_example/example_script.py
@@ -0,0 +1,69 @@
+#!/usr/bin/env python
+# 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.
+"""Simple example script that uses pw_rpc."""
+
+import argparse
+import os
+from pathlib import Path
+
+import serial  # type: ignore
+
+from pw_hdlc.rpc import HdlcRpcClient, default_channels
+
+# Point the script to the .proto file with our RPC services.
+PROTO = Path(os.environ['PW_ROOT'], 'pw_rpc/echo.proto')
+
+
+def script(device: str, baud: int) -> None:
+    # Set up a pw_rpc client that uses HDLC.
+    ser = serial.Serial(device, baud, timeout=0.01)
+    client = HdlcRpcClient(lambda: ser.read(4096), [PROTO],
+                           default_channels(ser.write))
+
+    # Make a shortcut to the EchoService.
+    echo_service = client.rpcs().pw.rpc.EchoService
+
+    # Call some RPCs and check the results.
+    status, payload = echo_service.Echo(msg='Hello')
+
+    if status.ok():
+        print('The status was', status)
+        print('The payload was', payload)
+    else:
+        print('Uh oh, this RPC returned', status)
+
+    status, payload = echo_service.Echo(msg='Goodbye!')
+
+    print('The device says:', payload.msg)
+
+
+def main():
+    parser = argparse.ArgumentParser(
+        description=__doc__,
+        formatter_class=argparse.ArgumentDefaultsHelpFormatter)
+    parser.add_argument('--device',
+                        '-d',
+                        default='/dev/ttyACM0',
+                        help='serial device to use')
+    parser.add_argument('--baud',
+                        '-b',
+                        type=int,
+                        default=115200,
+                        help='baud rate for the serial device')
+    script(**vars(parser.parse_args()))
+
+
+if __name__ == '__main__':
+    main()
diff --git a/pw_hdlc/rpc_example/hdlc_rpc_server.cc b/pw_hdlc/rpc_example/hdlc_rpc_server.cc
new file mode 100644
index 0000000..a6184c7
--- /dev/null
+++ b/pw_hdlc/rpc_example/hdlc_rpc_server.cc
@@ -0,0 +1,49 @@
+// 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.
+
+#include <array>
+#include <span>
+#include <string_view>
+
+#include "pw_hdlc/encoder.h"
+#include "pw_hdlc/rpc_packets.h"
+#include "pw_log/log.h"
+#include "pw_rpc/echo_service_nanopb.h"
+#include "pw_rpc/server.h"
+#include "pw_rpc_system_server/rpc_server.h"
+
+namespace hdlc_example {
+namespace {
+
+using std::byte;
+
+pw::rpc::EchoService echo_service;
+
+void RegisterServices() {
+  pw::rpc::system_server::Server().RegisterService(echo_service);
+}
+
+}  // namespace
+
+void Start() {
+  pw::rpc::system_server::Init();
+
+  // Set up the server and start processing data.
+  RegisterServices();
+
+  PW_LOG_INFO("Starting pw_rpc server");
+  pw::rpc::system_server::Start();
+}
+
+}  // namespace hdlc_example
diff --git a/pw_hdlc_lite/rpc_example/main.cc b/pw_hdlc/rpc_example/main.cc
similarity index 100%
rename from pw_hdlc_lite/rpc_example/main.cc
rename to pw_hdlc/rpc_example/main.cc
diff --git a/pw_hdlc/rpc_packets.cc b/pw_hdlc/rpc_packets.cc
new file mode 100644
index 0000000..9d83d86
--- /dev/null
+++ b/pw_hdlc/rpc_packets.cc
@@ -0,0 +1,41 @@
+// 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.
+
+#include "pw_hdlc/rpc_packets.h"
+
+#include "pw_status/try.h"
+#include "pw_sys_io/sys_io.h"
+
+namespace pw::hdlc {
+
+Status ReadAndProcessPackets(rpc::Server& server,
+                             rpc::ChannelOutput& output,
+                             std::span<std::byte> decode_buffer,
+                             unsigned rpc_address) {
+  Decoder decoder(decode_buffer);
+
+  while (true) {
+    std::byte data;
+    PW_TRY(sys_io::ReadByte(&data));
+
+    if (auto result = decoder.Process(data); result.ok()) {
+      Frame& frame = result.value();
+      if (frame.address() == rpc_address) {
+        server.ProcessPacket(frame.data(), output);
+      }
+    }
+  }
+}
+
+}  // namespace pw::hdlc
diff --git a/pw_hdlc/wire_packet_parser.cc b/pw_hdlc/wire_packet_parser.cc
new file mode 100644
index 0000000..992dfde
--- /dev/null
+++ b/pw_hdlc/wire_packet_parser.cc
@@ -0,0 +1,54 @@
+// Copyright 2021 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.
+
+#include "pw_hdlc/wire_packet_parser.h"
+
+#include "pw_bytes/endian.h"
+#include "pw_checksum/crc32.h"
+#include "pw_hdlc/decoder.h"
+#include "pw_hdlc/internal/protocol.h"
+
+namespace pw::hdlc {
+
+bool WirePacketParser::Parse(ConstByteSpan packet) {
+  if (packet.size_bytes() < Frame::kMinSizeBytes) {
+    return false;
+  }
+
+  if (packet.front() != kFlag || packet.back() != kFlag) {
+    return false;
+  }
+
+  // Partially decode into a buffer with space only for the address and control
+  // fields of the frame. The decoder will verify the frame's FCS field.
+  std::array<std::byte, 16> buffer = {};
+  Decoder decoder(buffer);
+  Status status = Status::Unknown();
+
+  decoder.Process(packet, [&status](const Result<Frame>& result) {
+    status = result.status();
+  });
+
+  Result<Frame> result = Frame::Parse(buffer);
+  if (!result.ok()) {
+    return false;
+  }
+
+  address_ = result.value().address();
+
+  // RESOURCE_EXHAUSTED is expected as the buffer is too small for the packet.
+  return status.ok() || status.IsResourceExhausted();
+}
+
+}  // namespace pw::hdlc
diff --git a/pw_hdlc/wire_packet_parser_test.cc b/pw_hdlc/wire_packet_parser_test.cc
new file mode 100644
index 0000000..809afd2
--- /dev/null
+++ b/pw_hdlc/wire_packet_parser_test.cc
@@ -0,0 +1,149 @@
+// Copyright 2021 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.
+
+#include "pw_hdlc/wire_packet_parser.h"
+
+#include "gtest/gtest.h"
+#include "pw_bytes/array.h"
+#include "pw_hdlc/internal/protocol.h"
+
+namespace pw::hdlc {
+namespace {
+
+constexpr uint8_t kAddress = 0x6a;
+constexpr uint8_t kEncodedAddress = (kAddress << 1) | 0x1;
+constexpr uint8_t kControl = 0x03;
+
+TEST(WirePacketParser, Parse_ValidPacket) {
+  WirePacketParser parser;
+  EXPECT_TRUE(parser.Parse(bytes::Concat(kFlag,
+                                         kEncodedAddress,
+                                         kControl,
+                                         bytes::String("hello"),
+                                         0x1231d0a9,
+                                         kFlag)));
+  auto maybe_address = parser.GetDestinationAddress();
+  EXPECT_TRUE(maybe_address.has_value());
+  EXPECT_EQ(maybe_address.value(), kAddress);
+}
+
+TEST(WirePacketParser, Parse_MultibyteAddress) {
+  WirePacketParser parser;
+  EXPECT_TRUE(parser.Parse(bytes::Concat(kFlag,
+                                         bytes::String("\xfe\xff"),
+                                         kControl,
+                                         bytes::String("hello"),
+                                         0x6b53b014,
+                                         kFlag)));
+  auto maybe_address = parser.GetDestinationAddress();
+  EXPECT_TRUE(maybe_address.has_value());
+  EXPECT_EQ(maybe_address.value(), 0x3fffu);
+}
+
+TEST(WirePacketParser, Parse_EscapedAddress) {
+  WirePacketParser parser;
+  EXPECT_TRUE(parser.Parse(bytes::Concat(kFlag,
+                                         kEscape,
+                                         uint8_t{0x7d ^ 0x20},
+                                         kControl,
+                                         bytes::String("hello"),
+                                         0x66754da0,
+                                         kFlag)));
+  auto maybe_address = parser.GetDestinationAddress();
+  EXPECT_TRUE(maybe_address.has_value());
+  EXPECT_EQ(maybe_address.value(), 62u);
+}
+
+TEST(WirePacketParser, Parse_EscapedPayload) {
+  WirePacketParser parser;
+  EXPECT_TRUE(parser.Parse(bytes::Concat(kFlag,
+                                         kEncodedAddress,
+                                         kControl,
+                                         bytes::String("hello"),
+                                         kEscapedEscape,
+                                         bytes::String("world"),
+                                         0x1af88e47,
+                                         kFlag)));
+  auto maybe_address = parser.GetDestinationAddress();
+  EXPECT_TRUE(maybe_address.has_value());
+  EXPECT_EQ(maybe_address.value(), kAddress);
+}
+
+TEST(WirePacketParser, Parse_EscapedFcs) {
+  WirePacketParser parser;
+  EXPECT_TRUE(
+      parser.Parse(bytes::Concat(kFlag,
+                                 kEncodedAddress,
+                                 kControl,
+                                 uint8_t{'b'},
+                                 // FCS: fc 92 7d 7e
+                                 bytes::String("\x7d\x5e\x7d\x5d\x92\xfc"),
+                                 kFlag)));
+  auto maybe_address = parser.GetDestinationAddress();
+  EXPECT_TRUE(maybe_address.has_value());
+  EXPECT_EQ(maybe_address.value(), kAddress);
+}
+
+TEST(WirePacketParser, Parse_MultipleEscapes) {
+  WirePacketParser parser;
+  EXPECT_TRUE(parser.Parse(bytes::Concat(kFlag,
+                                         kEscapedEscape,
+                                         kControl,
+                                         kEscapedEscape,
+                                         kEscapedFlag,
+                                         kEscapedFlag,
+                                         0x8ffd8fcd,
+                                         kFlag)));
+  auto maybe_address = parser.GetDestinationAddress();
+  EXPECT_TRUE(maybe_address.has_value());
+  EXPECT_EQ(maybe_address.value(), 62u);
+}
+
+TEST(WirePacketParser, Parse_BadFcs) {
+  WirePacketParser parser;
+  EXPECT_FALSE(parser.Parse(bytes::Concat(kFlag,
+                                          kEncodedAddress,
+                                          kControl,
+                                          bytes::String("hello"),
+                                          0x1badda7a,
+                                          kFlag)));
+}
+
+TEST(WirePacketParser, Parse_DoubleEscape) {
+  WirePacketParser parser;
+  EXPECT_FALSE(parser.Parse(bytes::Concat(kFlag,
+                                          kEncodedAddress,
+                                          kControl,
+                                          bytes::String("hello"),
+                                          0x01027d7d,
+                                          kFlag)));
+}
+
+TEST(WirePacketParser, Parse_FlagInFrame) {
+  WirePacketParser parser;
+  EXPECT_FALSE(parser.Parse(bytes::Concat(kFlag,
+                                          kEncodedAddress,
+                                          kControl,
+                                          bytes::String("he~lo"),
+                                          0xdbae98fe,
+                                          kFlag)));
+}
+
+TEST(WirePacketParser, Parse_EmptyPacket) {
+  WirePacketParser parser;
+  EXPECT_FALSE(parser.Parse({}));
+}
+
+}  // namespace
+}  // namespace pw::hdlc
diff --git a/pw_hdlc_lite/BUILD b/pw_hdlc_lite/BUILD
deleted file mode 100644
index 125064a..0000000
--- a/pw_hdlc_lite/BUILD
+++ /dev/null
@@ -1,92 +0,0 @@
-# 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.
-
-load(
-    "//pw_build:pigweed.bzl",
-    "pw_cc_library",
-)
-
-package(default_visibility = ["//visibility:public"])
-
-licenses(["notice"])  # Apache License 2.0
-
-pw_cc_library(
-    name = "pw_hdlc_lite",
-    srcs = [
-        "decoder.cc",
-        "encoder.cc",
-        "pw_hdlc_lite_private/protocol.h",
-        "rpc_packets.cc",
-    ],
-    hdrs = [
-        "public/pw_hdlc_lite/decoder.h",
-        "public/pw_hdlc_lite/encoder.h",
-        "public/pw_hdlc_lite/sys_io_stream.h",
-    ],
-    includes = ["public"],
-    deps = [
-        "//pw_bytes",
-        "//pw_checksum",
-        "//pw_log",
-        "//pw_result",
-        "//pw_span",
-        "//pw_status",
-        "//pw_stream",
-    ],
-)
-
-pw_cc_library(
-    name = "pw_rpc",
-    srcs = ["rpc_packets.cc"],
-    hdrs = [
-        "public/pw_hdlc_lite/rpc_channel.h",
-        "public/pw_hdlc_lite/rpc_packets.h",
-    ],
-    includes = ["public"],
-    deps = [
-        ":pw_hdlc_lite",
-        "//pw_rpc:server",
-    ],
-)
-
-cc_test(
-    name = "encoder_test",
-    srcs = ["encoder_test.cc"],
-    deps = [
-        ":pw_hdlc_lite",
-        "//pw_stream",
-        "//pw_unit_test",
-    ],
-)
-
-cc_test(
-    name = "decoder_test",
-    srcs = ["decoder_test.cc"],
-    deps = [
-        ":pw_hdlc_lite",
-        "//pw_result",
-        "//pw_stream",
-        "//pw_unit_test",
-    ],
-)
-
-cc_test(
-    name = "rpc_channel_test",
-    srcs = ["rpc_channel_test.cc"],
-    deps = [
-        ":pw_hdlc_lite",
-        "//pw_stream",
-        "//pw_unit_test",
-    ],
-)
diff --git a/pw_hdlc_lite/BUILD.gn b/pw_hdlc_lite/BUILD.gn
deleted file mode 100644
index 75eb537..0000000
--- a/pw_hdlc_lite/BUILD.gn
+++ /dev/null
@@ -1,135 +0,0 @@
-# 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/target_types.gni")
-import("$dir_pw_docgen/docs.gni")
-import("$dir_pw_unit_test/test.gni")
-
-config("default_config") {
-  include_dirs = [ "public" ]
-}
-
-group("pw_hdlc_lite") {
-  public_deps = [
-    ":decoder",
-    ":encoder",
-  ]
-}
-
-pw_source_set("decoder") {
-  public_configs = [ ":default_config" ]
-  public = [ "public/pw_hdlc_lite/decoder.h" ]
-  sources = [
-    "decoder.cc",
-    "pw_hdlc_lite_private/protocol.h",
-  ]
-  public_deps = [
-    dir_pw_bytes,
-    dir_pw_result,
-    dir_pw_status,
-  ]
-  deps = [
-    dir_pw_checksum,
-    dir_pw_log,
-  ]
-  friend = [ ":*" ]
-}
-
-pw_source_set("encoder") {
-  public_configs = [ ":default_config" ]
-  public = [
-    "public/pw_hdlc_lite/encoder.h",
-    "public/pw_hdlc_lite/sys_io_stream.h",
-  ]
-  sources = [
-    "encoder.cc",
-    "pw_hdlc_lite_private/protocol.h",
-  ]
-  public_deps = [
-    dir_pw_bytes,
-    dir_pw_status,
-    dir_pw_stream,
-    dir_pw_sys_io,
-  ]
-  deps = [ dir_pw_checksum ]
-  friend = [ ":*" ]
-}
-
-pw_source_set("pw_rpc") {
-  public_configs = [ ":default_config" ]
-  public = [
-    "public/pw_hdlc_lite/rpc_channel.h",
-    "public/pw_hdlc_lite/rpc_packets.h",
-  ]
-  sources = [ "rpc_packets.cc" ]
-  public_deps = [
-    ":pw_hdlc_lite",
-    "$dir_pw_rpc:server",
-  ]
-}
-
-pw_test_group("tests") {
-  tests = [
-    ":encoder_test",
-    ":decoder_test",
-    ":rpc_channel_test",
-  ]
-  group_deps = [
-    "$dir_pw_preprocessor:tests",
-    "$dir_pw_span:tests",
-    "$dir_pw_status:tests",
-    "$dir_pw_stream:tests",
-  ]
-}
-
-pw_test("encoder_test") {
-  deps = [ ":pw_hdlc_lite" ]
-  sources = [ "encoder_test.cc" ]
-}
-
-action("generate_decoder_test") {
-  outputs = [ "$target_gen_dir/generated_decoder_test.cc" ]
-  script = "py/decode_test.py"
-  args = [ "--generate-cc-test" ] + rebase_path(outputs)
-  deps = [ "$dir_pw_build/py" ]
-}
-
-pw_test("decoder_test") {
-  deps = [
-    ":generate_decoder_test",
-    ":pw_hdlc_lite",
-  ]
-  sources = [ "decoder_test.cc" ] + get_target_outputs(":generate_decoder_test")
-}
-
-pw_test("rpc_channel_test") {
-  deps = [
-    ":pw_hdlc_lite",
-    ":pw_rpc",
-  ]
-  sources = [ "rpc_channel_test.cc" ]
-}
-
-pw_doc_group("docs") {
-  sources = [
-    "docs.rst",
-    "rpc_example/docs.rst",
-  ]
-  inputs = [
-    "py/pw_hdlc_lite/decode.py",
-    "py/pw_hdlc_lite/encode.py",
-  ]
-}
diff --git a/pw_hdlc_lite/CMakeLists.txt b/pw_hdlc_lite/CMakeLists.txt
deleted file mode 100644
index 44c0ba8..0000000
--- a/pw_hdlc_lite/CMakeLists.txt
+++ /dev/null
@@ -1,30 +0,0 @@
-# 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.
-
-include($ENV{PW_ROOT}/pw_build/pigweed.cmake)
-
-pw_auto_add_simple_module(pw_hdlc_lite
-  PUBLIC_DEPS
-    pw_bytes
-    pw_result
-    pw_rpc.common
-    pw_status
-    pw_stream
-    pw_sys_io
-  PRIVATE_DEPS
-    pw_checksum
-    pw_log
-)
-
-add_subdirectory(rpc_example)
diff --git a/pw_hdlc_lite/decoder.cc b/pw_hdlc_lite/decoder.cc
deleted file mode 100644
index fc96824..0000000
--- a/pw_hdlc_lite/decoder.cc
+++ /dev/null
@@ -1,138 +0,0 @@
-// 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.
-
-#include "pw_hdlc_lite/decoder.h"
-
-#include "pw_assert/assert.h"
-#include "pw_bytes/endian.h"
-#include "pw_checksum/crc32.h"
-#include "pw_hdlc_lite_private/protocol.h"
-#include "pw_log/log.h"
-
-using std::byte;
-
-namespace pw::hdlc_lite {
-namespace {
-
-constexpr byte kUnescapeConstant = byte{0x20};
-
-}  // namespace
-
-Result<Frame> Decoder::Process(const byte new_byte) {
-  switch (state_) {
-    case State::kInterFrame: {
-      if (new_byte == kFlag) {
-        state_ = State::kFrame;
-
-        // Report an error if non-flag bytes were read between frames.
-        if (current_frame_size_ != 0u) {
-          current_frame_size_ = 0;
-          return Status::DataLoss();
-        }
-      } else {
-        // Count bytes to track how many are discarded.
-        current_frame_size_ += 1;
-      }
-      return Status::Unavailable();  // Report error when starting a new frame.
-    }
-    case State::kFrame: {
-      if (new_byte == kFlag) {
-        const Status status = CheckFrame();
-
-        state_ = State::kFrame;
-        const size_t completed_frame_size = current_frame_size_;
-        current_frame_size_ = 0;
-
-        if (status.ok()) {
-          return Frame(buffer_.first(completed_frame_size));
-        }
-        return status;
-      }
-
-      if (new_byte == kEscape) {
-        state_ = State::kFrameEscape;
-      } else {
-        AppendByte(new_byte);
-      }
-      return Status::Unavailable();
-    }
-    case State::kFrameEscape: {
-      // The flag character cannot be escaped; return an error.
-      if (new_byte == kFlag) {
-        state_ = State::kFrame;
-        current_frame_size_ = 0;
-        return Status::DataLoss();
-      }
-
-      if (new_byte == kEscape) {
-        // Two escape characters in a row is illegal -- invalidate this frame.
-        // The frame is reported abandoned when the next flag byte appears.
-        state_ = State::kInterFrame;
-
-        // Count the escape byte so that the inter-frame state detects an error.
-        current_frame_size_ += 1;
-      } else {
-        state_ = State::kFrame;
-        AppendByte(new_byte ^ kUnescapeConstant);
-      }
-      return Status::Unavailable();
-    }
-  }
-  PW_CRASH("Bad decoder state");
-}
-
-void Decoder::AppendByte(byte new_byte) {
-  if (current_frame_size_ < max_size()) {
-    buffer_[current_frame_size_] = new_byte;
-  }
-
-  // Always increase size: if it is larger than the buffer, overflow occurred.
-  current_frame_size_ += 1;
-}
-
-Status Decoder::CheckFrame() const {
-  // Empty frames are not an error; repeated flag characters are okay.
-  if (current_frame_size_ == 0u) {
-    return Status::Unavailable();
-  }
-
-  if (current_frame_size_ < Frame::kMinSizeBytes) {
-    PW_LOG_ERROR("Received %lu-byte frame; frame must be at least 6 bytes",
-                 static_cast<unsigned long>(current_frame_size_));
-    return Status::DataLoss();
-  }
-
-  if (current_frame_size_ > max_size()) {
-    PW_LOG_ERROR("Frame size [%lu] exceeds the maximum buffer size [%lu]",
-                 static_cast<unsigned long>(current_frame_size_),
-                 static_cast<unsigned long>(max_size()));
-    return Status::ResourceExhausted();
-  }
-
-  if (!VerifyFrameCheckSequence()) {
-    PW_LOG_ERROR("Frame check sequence verification failed");
-    return Status::DataLoss();
-  }
-
-  return Status::Ok();
-}
-
-bool Decoder::VerifyFrameCheckSequence() const {
-  uint32_t fcs = bytes::ReadInOrder<uint32_t>(
-      std::endian::little, buffer_.data() + current_frame_size_ - sizeof(fcs));
-  return fcs == checksum::Crc32::Calculate(
-                    buffer_.first(current_frame_size_ - sizeof(fcs)));
-}
-
-}  // namespace pw::hdlc_lite
diff --git a/pw_hdlc_lite/decoder_test.cc b/pw_hdlc_lite/decoder_test.cc
deleted file mode 100644
index b8400e2..0000000
--- a/pw_hdlc_lite/decoder_test.cc
+++ /dev/null
@@ -1,128 +0,0 @@
-// 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.
-
-#include "pw_hdlc_lite/decoder.h"
-
-#include <array>
-#include <cstddef>
-
-#include "gtest/gtest.h"
-#include "pw_bytes/array.h"
-#include "pw_hdlc_lite_private/protocol.h"
-
-namespace pw::hdlc_lite {
-namespace {
-
-using std::byte;
-
-TEST(Frame, Fields) {
-  static constexpr auto kFrameData = bytes::String("1234\xa3\xe0\xe3\x9b");
-  constexpr Frame frame(kFrameData);
-
-  static_assert(frame.address() == unsigned{'1'});
-  static_assert(frame.control() == byte{'2'});
-
-  static_assert(frame.data().size() == 2u);
-  static_assert(frame.data()[0] == byte{'3'});
-  static_assert(frame.data()[1] == byte{'4'});
-}
-
-TEST(Decoder, Clear) {
-  DecoderBuffer<8> decoder;
-
-  // Process a partial packet
-  decoder.Process(bytes::String("~1234abcd"),
-                  [](const Result<Frame>&) { FAIL(); });
-
-  decoder.clear();
-  Status status = Status::Unknown();
-
-  decoder.Process(
-      bytes::String("~1234\xa3\xe0\xe3\x9b~"),
-      [&status](const Result<Frame>& result) { status = result.status(); });
-
-  EXPECT_EQ(Status::Ok(), status);
-}
-
-TEST(Decoder, ExactFit) {
-  DecoderBuffer<8> decoder;
-
-  for (byte b : bytes::String("~1234\xa3\xe0\xe3\x9b")) {
-    EXPECT_EQ(Status::Unavailable(), decoder.Process(b).status());
-  }
-  auto result = decoder.Process(kFlag);
-  ASSERT_EQ(Status::Ok(), result.status());
-  ASSERT_EQ(result.value().data().size(), 2u);
-  ASSERT_EQ(result.value().data()[0], byte{'3'});
-  ASSERT_EQ(result.value().data()[1], byte{'4'});
-}
-
-TEST(Decoder, MinimumSizedBuffer) {
-  DecoderBuffer<6> decoder;
-
-  for (byte b : bytes::String("~12\xcd\x44\x53\x4f")) {
-    EXPECT_EQ(Status::Unavailable(), decoder.Process(b).status());
-  }
-
-  auto result = decoder.Process(kFlag);
-  ASSERT_EQ(Status::Ok(), result.status());
-  EXPECT_EQ(result.value().data().size(), 0u);
-}
-
-TEST(Decoder, TooLargeForBuffer_ReportsResourceExhausted) {
-  DecoderBuffer<8> decoder;
-
-  for (byte b : bytes::String("~123456789")) {
-    EXPECT_EQ(Status::Unavailable(), decoder.Process(b).status());
-  }
-  EXPECT_EQ(Status::ResourceExhausted(), decoder.Process(kFlag).status());
-
-  for (byte b : bytes::String("~123456789012345678901234567890")) {
-    EXPECT_EQ(Status::Unavailable(), decoder.Process(b).status());
-  }
-  EXPECT_EQ(Status::ResourceExhausted(), decoder.Process(kFlag).status());
-}
-
-TEST(Decoder, TooLargeForBuffer_StaysWithinBufferBoundaries) {
-  std::array<byte, 16> buffer = bytes::Initialized<16>('?');
-
-  Decoder decoder(std::span(buffer.data(), 8));
-
-  for (byte b : bytes::String("~1234567890123456789012345678901234567890")) {
-    EXPECT_EQ(Status::Unavailable(), decoder.Process(b).status());
-  }
-
-  for (size_t i = 8; i < buffer.size(); ++i) {
-    ASSERT_EQ(byte{'?'}, buffer[i]);
-  }
-
-  EXPECT_EQ(Status::ResourceExhausted(), decoder.Process(kFlag).status());
-}
-
-TEST(Decoder, TooLargeForBuffer_DecodesNextFrame) {
-  DecoderBuffer<8> decoder;
-
-  for (byte b : bytes::String("~123456789012345678901234567890")) {
-    EXPECT_EQ(Status::Unavailable(), decoder.Process(b).status());
-  }
-  EXPECT_EQ(Status::ResourceExhausted(), decoder.Process(kFlag).status());
-
-  for (byte b : bytes::String("1234\xa3\xe0\xe3\x9b")) {
-    EXPECT_EQ(Status::Unavailable(), decoder.Process(b).status());
-  }
-  EXPECT_EQ(Status::Ok(), decoder.Process(kFlag).status());
-}
-
-}  // namespace
-}  // namespace pw::hdlc_lite
diff --git a/pw_hdlc_lite/docs.rst b/pw_hdlc_lite/docs.rst
deleted file mode 100644
index e42d917..0000000
--- a/pw_hdlc_lite/docs.rst
+++ /dev/null
@@ -1,234 +0,0 @@
-.. _module-pw_hdlc_lite:
-
-------------
-pw_hdlc_lite
-------------
-`High-Level Data Link Control (HDLC)
-<https://en.wikipedia.org/wiki/High-Level_Data_Link_Control>`_ is a data link
-layer protocol intended for serial communication between devices. HDLC is
-standardized as `ISO/IEC 13239:2002 <https://www.iso.org/standard/37010.html>`_.
-
-The ``pw_hdlc_lite`` module provides a simple, robust frame-oriented
-transport that uses a subset of the HDLC protocol. ``pw_hdlc_lite`` supports
-sending between embedded devices or the host. It can be used with
-:ref:`module-pw_rpc` to enable remote procedure calls (RPCs) on embedded on
-devices.
-
-**Why use the pw_hdlc_lite module?**
-
-  * Enables the transmission of RPCs and other data between devices over serial.
-  * Detects corruption and data loss.
-  * Light-weight, simple, and easy to use.
-  * Supports streaming to transport without buffering, since the length is not
-    encoded.
-
-.. admonition:: Try it out!
-
-  For an example of how to use HDLC with :ref:`module-pw_rpc`, see the
-  :ref:`module-pw_hdlc_lite-rpc-example`.
-
-.. toctree::
-  :maxdepth: 1
-  :hidden:
-
-  rpc_example/docs
-
-Protocol Description
-====================
-
-Frames
-------
-The HDLC implementation in ``pw_hdlc_lite`` supports only HDLC information
-frames. These frames are encoded as follows:
-
-.. code-block:: text
-
-    _________________________________________
-    | | | |                          |    | |...
-    | | | |                          |    | |... [More frames]
-    |_|_|_|__________________________|____|_|...
-     F A C       Payload              FCS  F
-
-     F = flag byte (0x7e, the ~ character)
-     A = address field
-     C = control field
-     FCS = frame check sequence (CRC-32)
-
-
-Encoding and sending data
--------------------------
-This module first writes an initial frame delimiter byte (0x7E) to indicate the
-beginning of the frame. Before sending any of the payload data through serial,
-the special bytes are escaped:
-
-            +-------------------------+-----------------------+
-            | Unescaped Special Bytes | Escaped Special Bytes |
-            +=========================+=======================+
-            |           7E            |        7D 5E          |
-            +-------------------------+-----------------------+
-            |           7D            |        7D 5D          |
-            +-------------------------+-----------------------+
-
-The bytes of the payload are escaped and written in a single pass. The
-frame check sequence is calculated, escaped, and written after. After this, a
-final frame delimiter byte (0x7E) is written to mark the end of the frame.
-
-Decoding received bytes
------------------------
-Frames may be received in multiple parts, so we need to store the received data
-in a buffer until the ending frame delimiter (0x7E) is read. When the
-``pw_hdlc_lite`` decoder receives data, it unescapes it and adds it to a buffer.
-When the frame is complete, it calculates and verifies the frame check sequence
-and does the following:
-
-* If correctly verified, the decoder returns the decoded frame.
-* If the checksum verification fails, the frame is discarded and an error is
-  reported.
-
-API Usage
-=========
-There are two primary functions of the ``pw_hdlc_lite`` module:
-
-  * **Encoding** data by constructing a frame with the escaped payload bytes and
-    frame check sequence.
-  * **Decoding** data by unescaping the received bytes, verifying the frame
-    check sequence, and returning successfully decoded frames.
-
-Encoder
--------
-The Encoder API provides a single function that encodes data as an HDLC
-information frame.
-
-C++
-^^^
-.. cpp:namespace:: pw
-
-.. cpp:function:: Status hdlc_lite::WriteInformationFrame(uint8_t address, ConstByteSpan data, stream::Writer& writer)
-
-  Writes a span of data to a :ref:`pw::stream::Writer <module-pw_stream>` and
-  returns the status. This implementation uses the :ref:`module-pw_checksum`
-  module to compute the CRC-32 frame check sequence.
-
-.. code-block:: cpp
-
-  #include "pw_hdlc_lite/encoder.h"
-  #include "pw_hdlc_lite/sys_io_stream.h"
-
-  int main() {
-    pw::stream::SysIoWriter serial_writer;
-    Status status = WriteInformationFrame(123 /* address */,
-                                          data,
-                                          serial_writer);
-    if (!status.ok()) {
-      PW_LOG_INFO("Writing frame failed! %s", status.str());
-    }
-  }
-
-Python
-^^^^^^
-.. automodule:: pw_hdlc_lite.encode
-  :members:
-
-.. code-block:: python
-
-  import serial
-  from pw_hdlc_lite import encode
-
-  ser = serial.Serial()
-  ser.write(encode.information_frame(b'your data here!'))
-
-Decoder
--------
-The decoder class unescapes received bytes and adds them to a buffer. Complete,
-valid HDLC frames are yielded as they are received.
-
-C++
-^^^
-.. cpp:class:: pw::hdlc_lite::Decoder
-
-  .. cpp:function:: pw::Result<Frame> Process(std::byte b)
-
-    Parses a single byte of an HDLC stream. Returns a Result with the complete
-    frame if the byte completes a frame. The status is the following:
-
-      - OK - A frame was successfully decoded. The Result contains the Frame,
-        which is invalidated by the next Process call.
-      - UNAVAILABLE - No frame is available.
-      - RESOURCE_EXHAUSTED - A frame completed, but it was too large to fit in
-        the decoder's buffer.
-      - DATA_LOSS - A frame completed, but it was invalid. The frame was
-        incomplete or the frame check sequence verification failed.
-
-  .. cpp:function:: void Process(pw::ConstByteSpan data, F&& callback, Args&&... args)
-
-    Processes a span of data and calls the provided callback with each frame or
-    error.
-
-This example demonstrates reading individual bytes from ``pw::sys_io`` and
-decoding HDLC frames:
-
-.. code-block:: cpp
-
-  #include "pw_hdlc_lite/decoder.h"
-  #include "pw_sys_io/sys_io.h"
-
-  int main() {
-    std::byte data;
-    while (true) {
-      if (!pw::sys_io::ReadByte(&data).ok()) {
-        // Log serial reading error
-      }
-      Result<Frame> decoded_frame = decoder.Process(data);
-
-      if (decoded_frame.ok()) {
-        // Handle the decoded frame
-      }
-    }
-  }
-
-Python
-^^^^^^
-.. autoclass:: pw_hdlc_lite.decode.FrameDecoder
-  :members:
-
-Below is an example using the decoder class to decode data read from serial:
-
-.. code-block:: python
-
-  import serial
-  from pw_hdlc_lite import decode
-
-  ser = serial.Serial()
-  decoder = decode.FrameDecoder()
-
-  while True:
-      for frame in decoder.process_valid_frames(ser.read()):
-          # Handle the decoded frame
-
-Additional features
-===================
-
-pw::stream::SysIoWriter
-------------------------
-The ``SysIoWriter`` C++ class implements the ``Writer`` interface with
-``pw::sys_io``. This Writer may be used by the C++ encoder to send HDLC frames
-over serial.
-
-HdlcRpcClient
--------------
-.. autoclass:: pw_hdlc_lite.rpc.HdlcRpcClient
-  :members:
-
-Roadmap
-=======
-- **Expanded protocol support** - ``pw_hdlc_lite`` currently only supports
-  information frames with a single address byte and control byte. Support for
-  different frame types and extended address or control fields may be added in
-  the future.
-
-- **Higher performance** - We plan to improve the overall performance of the
-  decoder and encoder implementations by using SIMD/NEON.
-
-Compatibility
-=============
-C++17
diff --git a/pw_hdlc_lite/encoder.cc b/pw_hdlc_lite/encoder.cc
deleted file mode 100644
index 4a785ee..0000000
--- a/pw_hdlc_lite/encoder.cc
+++ /dev/null
@@ -1,120 +0,0 @@
-// 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.
-
-#include "pw_hdlc_lite/encoder.h"
-
-#include <algorithm>
-#include <array>
-#include <cstddef>
-#include <cstring>
-#include <span>
-
-#include "pw_bytes/endian.h"
-#include "pw_checksum/crc32.h"
-#include "pw_hdlc_lite_private/protocol.h"
-
-using std::byte;
-
-namespace pw::hdlc_lite {
-namespace {
-
-// Indicates this an information packet with sequence numbers set to 0.
-constexpr byte kUnusedControl = byte{0};
-
-Status EscapeAndWrite(const byte b, stream::Writer& writer) {
-  if (b == kFlag) {
-    return writer.Write(kEscapedFlag);
-  }
-  if (b == kEscape) {
-    return writer.Write(kEscapedEscape);
-  }
-  return writer.Write(b);
-}
-
-// Encodes and writes HDLC frames.
-class Encoder {
- public:
-  constexpr Encoder(stream::Writer& output) : writer_(output) {}
-
-  // Writes the header for an I-frame. After successfully calling
-  // StartInformationFrame, WriteData may be called any number of times.
-  Status StartInformationFrame(uint8_t address);
-
-  // Writes data for an ongoing frame. Must only be called after a successful
-  // StartInformationFrame call, and prior to a FinishFrame() call.
-  Status WriteData(ConstByteSpan data);
-
-  // Finishes a frame. Writes the frame check sequence and a terminating flag.
-  Status FinishFrame();
-
- private:
-  stream::Writer& writer_;
-  checksum::Crc32 fcs_;
-};
-
-Status Encoder::StartInformationFrame(uint8_t address) {
-  fcs_.clear();
-  if (Status status = writer_.Write(kFlag); !status.ok()) {
-    return status;
-  }
-
-  const byte address_and_control[] = {std::byte{address}, kUnusedControl};
-  return WriteData(address_and_control);
-}
-
-Status Encoder::WriteData(ConstByteSpan data) {
-  auto begin = data.begin();
-  while (true) {
-    auto end = std::find_if(begin, data.end(), NeedsEscaping);
-
-    if (Status status = writer_.Write(std::span(begin, end)); !status.ok()) {
-      return status;
-    }
-    if (end == data.end()) {
-      fcs_.Update(data);
-      return Status::Ok();
-    }
-    if (Status status = EscapeAndWrite(*end, writer_); !status.ok()) {
-      return status;
-    }
-    begin = end + 1;
-  }
-}
-
-Status Encoder::FinishFrame() {
-  if (Status status =
-          WriteData(bytes::CopyInOrder(std::endian::little, fcs_.value()));
-      !status.ok()) {
-    return status;
-  }
-  return writer_.Write(kFlag);
-}
-
-}  // namespace
-
-Status WriteInformationFrame(uint8_t address,
-                             ConstByteSpan payload,
-                             stream::Writer& writer) {
-  Encoder encoder(writer);
-
-  if (Status status = encoder.StartInformationFrame(address); !status.ok()) {
-    return status;
-  }
-  if (Status status = encoder.WriteData(payload); !status.ok()) {
-    return status;
-  }
-  return encoder.FinishFrame();
-}
-
-}  // namespace pw::hdlc_lite
diff --git a/pw_hdlc_lite/encoder_test.cc b/pw_hdlc_lite/encoder_test.cc
deleted file mode 100644
index 72a66ce..0000000
--- a/pw_hdlc_lite/encoder_test.cc
+++ /dev/null
@@ -1,170 +0,0 @@
-// 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.
-
-#include "pw_hdlc_lite/encoder.h"
-
-#include <algorithm>
-#include <array>
-#include <cstddef>
-
-#include "gtest/gtest.h"
-#include "pw_bytes/array.h"
-#include "pw_hdlc_lite_private/protocol.h"
-#include "pw_stream/memory_stream.h"
-
-using std::byte;
-
-namespace pw::hdlc_lite {
-namespace {
-
-constexpr uint8_t kAddress = 0x7B;  // 123
-constexpr byte kControl = byte{0};
-
-class WriteInfoFrame : public ::testing::Test {
- protected:
-  WriteInfoFrame() : writer_(buffer_) {}
-
-  stream::MemoryWriter writer_;
-  std::array<byte, 32> buffer_;
-};
-
-#define EXPECT_ENCODER_WROTE(...)                                           \
-  do {                                                                      \
-    constexpr auto expected_data = (__VA_ARGS__);                           \
-    EXPECT_EQ(writer_.bytes_written(), expected_data.size());               \
-    EXPECT_EQ(                                                              \
-        std::memcmp(                                                        \
-            writer_.data(), expected_data.data(), writer_.bytes_written()), \
-        0);                                                                 \
-  } while (0)
-
-TEST_F(WriteInfoFrame, EmptyPayload) {
-  ASSERT_EQ(Status::Ok(),
-            WriteInformationFrame(kAddress, std::span<byte>(), writer_));
-  EXPECT_ENCODER_WROTE(
-      bytes::Concat(kFlag, kAddress, kControl, uint32_t{0x8D12B2C2}, kFlag));
-}
-
-TEST_F(WriteInfoFrame, OneBytePayload) {
-  ASSERT_EQ(Status::Ok(),
-            WriteInformationFrame(kAddress, bytes::String("A"), writer_));
-  EXPECT_ENCODER_WROTE(bytes::Concat(
-      kFlag, kAddress, kControl, 'A', uint32_t{0xA63E2FA5}, kFlag));
-}
-
-TEST_F(WriteInfoFrame, OneBytePayload_Escape0x7d) {
-  ASSERT_EQ(Status::Ok(),
-            WriteInformationFrame(kAddress, bytes::Array<0x7d>(), writer_));
-  EXPECT_ENCODER_WROTE(bytes::Concat(kFlag,
-                                     kAddress,
-                                     kControl,
-                                     kEscape,
-                                     byte{0x7d} ^ byte{0x20},
-                                     uint32_t{0x89515322},
-                                     kFlag));
-}
-
-TEST_F(WriteInfoFrame, OneBytePayload_Escape0x7E) {
-  ASSERT_EQ(Status::Ok(),
-            WriteInformationFrame(kAddress, bytes::Array<0x7e>(), writer_));
-  EXPECT_ENCODER_WROTE(bytes::Concat(kFlag,
-                                     kAddress,
-                                     kControl,
-                                     kEscape,
-                                     byte{0x7e} ^ byte{0x20},
-                                     uint32_t{0x10580298},
-                                     kFlag));
-}
-
-TEST_F(WriteInfoFrame, AddressNeedsEscaping) {
-  ASSERT_EQ(Status::Ok(),
-            WriteInformationFrame(0x7d, bytes::String("A"), writer_));
-  EXPECT_ENCODER_WROTE(bytes::Concat(
-      kFlag, kEscape, byte{0x5d}, kControl, 'A', uint32_t{0xA2B35317}, kFlag));
-}
-
-TEST_F(WriteInfoFrame, Crc32NeedsEscaping) {
-  ASSERT_EQ(Status::Ok(),
-            WriteInformationFrame(kAddress, bytes::String("abcdefg"), writer_));
-
-  // The CRC-32 is 0x38B9FC7E, so the 0x7E must be escaped.
-  constexpr auto expected_crc32 = bytes::Array<0x7d, 0x5e, 0xfc, 0xb9, 0x38>();
-  EXPECT_ENCODER_WROTE(bytes::Concat(kFlag,
-                                     kAddress,
-                                     kControl,
-                                     bytes::String("abcdefg"),
-                                     expected_crc32,
-                                     kFlag));
-}
-
-TEST_F(WriteInfoFrame, MultiplePayloads) {
-  ASSERT_EQ(Status::Ok(),
-            WriteInformationFrame(kAddress, bytes::String("ABC"), writer_));
-  ASSERT_EQ(Status::Ok(),
-            WriteInformationFrame(kAddress, bytes::String("DEF"), writer_));
-  EXPECT_ENCODER_WROTE(bytes::Concat(kFlag,
-                                     kAddress,
-                                     kControl,
-                                     bytes::String("ABC"),
-                                     uint32_t{0x14E2FC99},
-                                     kFlag,
-                                     kFlag,
-                                     kAddress,
-                                     kControl,
-                                     bytes::String("DEF"),
-                                     uint32_t{0x2D025C3A},
-                                     kFlag));
-}
-
-TEST_F(WriteInfoFrame, PayloadWithNoEscapes) {
-  ASSERT_EQ(Status::Ok(),
-            WriteInformationFrame(
-                kAddress, bytes::String("123456789012345678901234"), writer_));
-
-  // Fill the memory writer's buffer.
-  ASSERT_EQ(writer_.bytes_written(), buffer_.size());
-
-  EXPECT_ENCODER_WROTE(bytes::Concat(kFlag,
-                                     kAddress,
-                                     kControl,
-                                     bytes::String("123456789012345678901234"),
-                                     uint32_t{0x50AA35EC},
-                                     kFlag));
-}
-
-TEST_F(WriteInfoFrame, PayloadWithMultipleEscapes) {
-  ASSERT_EQ(Status::Ok(),
-            WriteInformationFrame(
-                kAddress,
-                bytes::Array<0x7E, 0x7B, 0x61, 0x62, 0x63, 0x7D, 0x7E>(),
-                writer_));
-  EXPECT_ENCODER_WROTE(bytes::Concat(
-      kFlag,
-      kAddress,
-      kControl,
-      bytes::
-          Array<0x7D, 0x5E, 0x7B, 0x61, 0x62, 0x63, 0x7D, 0x5D, 0x7D, 0x5E>(),
-      uint32_t{0x1B8D505E},
-      kFlag));
-}
-
-TEST_F(WriteInfoFrame, WriterError) {
-  constexpr auto data = bytes::Initialized<sizeof(buffer_)>(0x7e);
-
-  EXPECT_EQ(Status::ResourceExhausted(),
-            WriteInformationFrame(kAddress, data, writer_));
-}
-
-}  // namespace
-}  // namespace pw::hdlc_lite
diff --git a/pw_hdlc_lite/public/pw_hdlc_lite/decoder.h b/pw_hdlc_lite/public/pw_hdlc_lite/decoder.h
deleted file mode 100644
index d15cbe7..0000000
--- a/pw_hdlc_lite/public/pw_hdlc_lite/decoder.h
+++ /dev/null
@@ -1,154 +0,0 @@
-// 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.
-#pragma once
-
-#include <algorithm>
-#include <array>
-#include <cstddef>
-#include <cstring>
-#include <functional>  // std::invoke
-
-#include "pw_bytes/span.h"
-#include "pw_result/result.h"
-#include "pw_status/status.h"
-
-namespace pw::hdlc_lite {
-
-// Represents the contents of an HDLC frame -- the unescaped data between two
-// flag bytes. Instances of Frame are only created when a full, valid frame has
-// been read.
-//
-// For now, the Frame class assumes single-byte address and control fields and a
-// 32-bit frame check sequence (FCS).
-class Frame {
- private:
-  static constexpr size_t kAddressSize = 1;
-  static constexpr size_t kControlSize = 1;
-  static constexpr size_t kFcsSize = sizeof(uint32_t);
-
- public:
-  // The minimum size of a frame, excluding control bytes (flag or escape).
-  static constexpr size_t kMinSizeBytes =
-      kAddressSize + kControlSize + kFcsSize;
-
-  // Creates a Frame with the specified data. The data MUST be valid frame data
-  // with a verified frame check sequence.
-  explicit constexpr Frame(ConstByteSpan data) : frame_(data) {
-    // TODO(pwbug/246): Use PW_DASSERT when available.
-    // PW_DASSERT(data.size() >= kMinSizeBytes);
-  }
-
-  constexpr unsigned address() const {
-    return std::to_integer<unsigned>(frame_[0]);
-  }
-
-  constexpr std::byte control() const { return frame_[kAddressSize]; }
-
-  constexpr ConstByteSpan data() const {
-    return frame_.subspan(kAddressSize + kControlSize,
-                          frame_.size() - kMinSizeBytes);
-  }
-
- private:
-  ConstByteSpan frame_;
-};
-
-// The Decoder class facilitates decoding of data frames using the HDLC-Lite
-// protocol, by returning packets as they are decoded and storing incomplete
-// data frames in a buffer.
-//
-// The Decoder class does not own the buffer it writes to. It can be used to
-// write bytes to any buffer. The DecoderBuffer template class, defined below,
-// allocates a buffer.
-class Decoder {
- public:
-  constexpr Decoder(ByteSpan buffer)
-      : buffer_(buffer), current_frame_size_(0), state_(State::kInterFrame) {}
-
-  Decoder(const Decoder&) = delete;
-  Decoder& operator=(const Decoder&) = delete;
-
-  // Parses a single byte of an HDLC stream. Returns a Result with the complete
-  // frame if the byte completes a frame. The status is the following:
-  //
-  //     OK - A frame was successfully decoded. The Result contains the Frame,
-  //         which is invalidated by the next Process call.
-  //     UNAVAILABLE - No frame is available.
-  //     RESOURCE_EXHAUSTED - A frame completed, but it was too large to fit in
-  //         the decoder's buffer.
-  //     DATA_LOSS - A frame completed, but it was invalid. The frame was
-  //         incomplete or the frame check sequence verification failed.
-  //
-  Result<Frame> Process(std::byte b);
-
-  // Processes a span of data and calls the provided callback with each frame or
-  // error.
-  template <typename F, typename... Args>
-  void Process(ConstByteSpan data, F&& callback, Args&&... args) {
-    for (std::byte b : data) {
-      auto result = Process(b);
-      if (result.status() != Status::Unavailable()) {
-        std::invoke(
-            std::forward<F>(callback), std::forward<Args>(args)..., result);
-      }
-    }
-  }
-
-  // Returns the maximum size of the Decoder's frame buffer.
-  size_t max_size() const { return buffer_.size(); }
-
-  // Clears and resets the decoder.
-  void clear() {
-    current_frame_size_ = 0;
-    state_ = State::kInterFrame;
-  };
-
- private:
-  // State enum class is used to make the Decoder a finite state machine.
-  enum class State {
-    kInterFrame,
-    kFrame,
-    kFrameEscape,
-  };
-
-  void AppendByte(std::byte new_byte);
-
-  Status CheckFrame() const;
-
-  bool VerifyFrameCheckSequence() const;
-
-  const ByteSpan buffer_;
-
-  size_t current_frame_size_;
-
-  State state_;
-};
-
-// DecoderBuffers declare a buffer along with a Decoder.
-template <size_t size_bytes>
-class DecoderBuffer : public Decoder {
- public:
-  DecoderBuffer() : Decoder(frame_buffer_) {}
-
-  // Returns the maximum length of the bytes that can be inserted in the bytes
-  // buffer.
-  static constexpr size_t max_size() { return size_bytes; }
-
- private:
-  static_assert(size_bytes >= Frame::kMinSizeBytes);
-
-  std::array<std::byte, size_bytes> frame_buffer_;
-};
-
-}  // namespace pw::hdlc_lite
diff --git a/pw_hdlc_lite/public/pw_hdlc_lite/encoder.h b/pw_hdlc_lite/public/pw_hdlc_lite/encoder.h
deleted file mode 100644
index ba2388c..0000000
--- a/pw_hdlc_lite/public/pw_hdlc_lite/encoder.h
+++ /dev/null
@@ -1,36 +0,0 @@
-// 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.
-#pragma once
-
-#include "pw_bytes/span.h"
-#include "pw_status/status.h"
-#include "pw_stream/stream.h"
-
-namespace pw::hdlc_lite {
-
-// Writes an HDLC information frame (I-frame) to the provided writer. The frame
-// contains the following:
-//
-//   - HDLC flag byte (0x7e)
-//   - Address
-//   - Control byte (fixed at 0; sequence numbers are not used currently).
-//   - Data (0 or more bytes)
-//   - Frame check sequence (CRC-32)
-//   - HDLC flag byte (0x7e)
-//
-Status WriteInformationFrame(uint8_t address,
-                             ConstByteSpan data,
-                             stream::Writer& writer);
-
-}  // namespace pw::hdlc_lite
diff --git a/pw_hdlc_lite/public/pw_hdlc_lite/rpc_channel.h b/pw_hdlc_lite/public/pw_hdlc_lite/rpc_channel.h
deleted file mode 100644
index 4f0e401..0000000
--- a/pw_hdlc_lite/public/pw_hdlc_lite/rpc_channel.h
+++ /dev/null
@@ -1,76 +0,0 @@
-// 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.
-#pragma once
-
-#include <array>
-#include <span>
-
-#include "pw_hdlc_lite/encoder.h"
-#include "pw_rpc/channel.h"
-#include "pw_stream/stream.h"
-
-namespace pw::hdlc_lite {
-
-// Custom HDLC ChannelOutput class to write and read data through serial using
-// the HDLC-Lite protocol.
-class RpcChannelOutput : public rpc::ChannelOutput {
- public:
-  // The RpcChannelOutput class does not own the buffer it uses to store the
-  // protobuf bytes. This buffer is specified at the time of creation along with
-  // a writer object to which will be used to write and send the bytes.
-  constexpr RpcChannelOutput(stream::Writer& writer,
-                             std::span<std::byte> buffer,
-                             uint8_t address,
-                             const char* channel_name)
-      : ChannelOutput(channel_name),
-        writer_(writer),
-        buffer_(buffer),
-        address_(address) {}
-
-  std::span<std::byte> AcquireBuffer() override { return buffer_; }
-
-  Status SendAndReleaseBuffer(size_t size) override {
-    return hdlc_lite::WriteInformationFrame(
-        address_, buffer_.first(size), writer_);
-  }
-
- private:
-  stream::Writer& writer_;
-  const std::span<std::byte> buffer_;
-  const uint8_t address_;
-};
-
-// RpcChannelOutput with its own buffer.
-template <size_t buffer_size>
-class RpcChannelOutputBuffer : public rpc::ChannelOutput {
- public:
-  constexpr RpcChannelOutputBuffer(stream::Writer& writer,
-                                   uint8_t address,
-                                   const char* channel_name)
-      : ChannelOutput(channel_name), writer_(writer), address_(address) {}
-
-  std::span<std::byte> AcquireBuffer() override { return buffer_; }
-
-  Status SendAndReleaseBuffer(size_t size) override {
-    return hdlc_lite::WriteInformationFrame(
-        address_, std::span(buffer_.data(), size), writer_);
-  }
-
- private:
-  stream::Writer& writer_;
-  std::array<std::byte, buffer_size> buffer_;
-  const uint8_t address_;
-};
-
-}  // namespace pw::hdlc_lite
diff --git a/pw_hdlc_lite/public/pw_hdlc_lite/rpc_packets.h b/pw_hdlc_lite/public/pw_hdlc_lite/rpc_packets.h
deleted file mode 100644
index de2a191..0000000
--- a/pw_hdlc_lite/public/pw_hdlc_lite/rpc_packets.h
+++ /dev/null
@@ -1,34 +0,0 @@
-// 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.
-#pragma once
-
-#include <cstdint>
-
-#include "pw_hdlc_lite/decoder.h"
-#include "pw_rpc/channel.h"
-#include "pw_rpc/server.h"
-#include "pw_status/status.h"
-
-namespace pw::hdlc_lite {
-
-inline constexpr uint8_t kDefaultRpcAddress = 'R';
-
-// Reads HDLC frames with sys_io::ReadByte, using decode_buffer to store frames.
-// HDLC frames sent to rpc_address are passed to the RPC server.
-Status ReadAndProcessPackets(rpc::Server& server,
-                             rpc::ChannelOutput& output,
-                             std::span<std::byte> decode_buffer,
-                             unsigned rpc_address = kDefaultRpcAddress);
-
-}  // namespace pw::hdlc_lite
diff --git a/pw_hdlc_lite/public/pw_hdlc_lite/sys_io_stream.h b/pw_hdlc_lite/public/pw_hdlc_lite/sys_io_stream.h
deleted file mode 100644
index 219d8eb..0000000
--- a/pw_hdlc_lite/public/pw_hdlc_lite/sys_io_stream.h
+++ /dev/null
@@ -1,38 +0,0 @@
-// 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.
-#pragma once
-
-#include <array>
-#include <cstddef>
-#include <limits>
-#include <span>
-
-#include "pw_stream/stream.h"
-#include "pw_sys_io/sys_io.h"
-
-namespace pw::stream {
-
-class SysIoWriter : public Writer {
- public:
-  size_t ConservativeWriteLimit() const override {
-    return std::numeric_limits<size_t>::max();
-  }
-
- private:
-  Status DoWrite(std::span<const std::byte> data) override {
-    return pw::sys_io::WriteBytes(data).status();
-  }
-};
-
-}  // namespace pw::stream
diff --git a/pw_hdlc_lite/pw_hdlc_lite_private/protocol.h b/pw_hdlc_lite/pw_hdlc_lite_private/protocol.h
deleted file mode 100644
index 25159dbc..0000000
--- a/pw_hdlc_lite/pw_hdlc_lite_private/protocol.h
+++ /dev/null
@@ -1,32 +0,0 @@
-// 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.
-#pragma once
-
-#include <cstddef>
-
-namespace pw::hdlc_lite {
-
-inline constexpr std::byte kFlag = std::byte{0x7E};
-inline constexpr std::byte kEscape = std::byte{0x7D};
-
-inline constexpr std::array<std::byte, 2> kEscapedFlag = {kEscape,
-                                                          std::byte{0x5E}};
-inline constexpr std::array<std::byte, 2> kEscapedEscape = {kEscape,
-                                                            std::byte{0x5D}};
-
-constexpr bool NeedsEscaping(std::byte b) {
-  return (b == kFlag || b == kEscape);
-}
-
-}  // namespace pw::hdlc_lite
diff --git a/pw_hdlc_lite/py/BUILD.gn b/pw_hdlc_lite/py/BUILD.gn
deleted file mode 100644
index 1b18011..0000000
--- a/pw_hdlc_lite/py/BUILD.gn
+++ /dev/null
@@ -1,37 +0,0 @@
-# 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_hdlc_lite/__init__.py",
-    "pw_hdlc_lite/decode.py",
-    "pw_hdlc_lite/encode.py",
-    "pw_hdlc_lite/protocol.py",
-    "pw_hdlc_lite/rpc.py",
-    "pw_hdlc_lite/rpc_console.py",
-  ]
-  tests = [
-    "decode_test.py",
-    "encode_test.py",
-  ]
-  python_deps = [
-    "$dir_pw_protobuf_compiler/py",
-    "$dir_pw_rpc/py",
-  ]
-}
diff --git a/pw_hdlc_lite/py/decode_test.py b/pw_hdlc_lite/py/decode_test.py
deleted file mode 100755
index 7924632..0000000
--- a/pw_hdlc_lite/py/decode_test.py
+++ /dev/null
@@ -1,273 +0,0 @@
-#!/usr/bin/env python
-# 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.
-"""Contains the Python decoder tests and generates C++ decoder tests."""
-
-from typing import Iterator, List, NamedTuple, Tuple
-import unittest
-
-from pw_build.generated_tests import Context, PyTest, TestGenerator, GroupOrTest
-from pw_build.generated_tests import parse_test_generation_args
-from pw_hdlc_lite.decode import Frame, FrameDecoder, FrameStatus, NO_ADDRESS
-from pw_hdlc_lite.protocol import frame_check_sequence as fcs
-
-
-def _encode(address: int, control: int, data: bytes) -> bytes:
-    frame = bytearray([address, control]) + data
-    frame += fcs(frame)
-    frame = frame.replace(b'\x7d', b'\x7d\x5d')
-    frame = frame.replace(b'\x7e', b'\x7d\x5e')
-    return b''.join([b'\x7e', frame, b'\x7e'])
-
-
-class Expected(NamedTuple):
-    address: int
-    control: bytes
-    data: bytes
-    status: FrameStatus = FrameStatus.OK
-
-    def __eq__(self, other) -> bool:
-        """Define == so an Expected and a Frame can be compared."""
-        return (self.address == other.address and self.control == other.control
-                and self.data == other.data and self.status is other.status)
-
-
-_PARTIAL = fcs(b'\x0ACmsg\x5e')
-_ESCAPED_FLAG_TEST_CASE = (
-    b'\x7e\x0ACmsg\x7d\x7e' + _PARTIAL + b'\x7e',
-    [
-        Expected(0xA, b'C', b'', FrameStatus.INCOMPLETE),
-        Expected(_PARTIAL[0], _PARTIAL[1:2], b'', FrameStatus.INCOMPLETE),
-    ],
-)
-
-TEST_CASES: Tuple[GroupOrTest[Tuple[bytes, List[Expected]]], ...] = (
-    'Empty payload',
-    (_encode(0, 0, b''), [Expected(0, b'\0', b'')]),
-    (_encode(55, 0x99, b''), [Expected(55, b'\x99', b'')]),
-    (_encode(55, 0x99, b'') * 3, [Expected(55, b'\x99', b'')] * 3),
-    'Simple one-byte payload',
-    (_encode(0, 0, b'\0'), [Expected(0, b'\0', b'\0')]),
-    (_encode(123, 0, b'A'), [Expected(123, b'\0', b'A')]),
-    'Simple multi-byte payload',
-    (_encode(0, 0, b'Hello, world!'), [Expected(0, b'\0', b'Hello, world!')]),
-    (_encode(123, 0, b'\0\0\1\0\0'), [Expected(123, b'\0', b'\0\0\1\0\0')]),
-    'Escaped one-byte payload',
-    (_encode(1, 2, b'\x7e'), [Expected(1, b'\2', b'\x7e')]),
-    (_encode(1, 2, b'\x7d'), [Expected(1, b'\2', b'\x7d')]),
-    (_encode(1, 2, b'\x7e') + _encode(1, 2, b'\x7d'),
-     [Expected(1, b'\2', b'\x7e'),
-      Expected(1, b'\2', b'\x7d')]),
-    'Escaped address',
-    (_encode(0x7e, 0, b'A'), [Expected(0x7e, b'\0', b'A')]),
-    (_encode(0x7d, 0, b'B'), [Expected(0x7d, b'\0', b'B')]),
-    'Escaped control',
-    (_encode(0, 0x7e, b'C'), [Expected(0, b'\x7e', b'C')]),
-    (_encode(0, 0x7d, b'D'), [Expected(0, b'\x7d', b'D')]),
-    'Escaped address and control',
-    (_encode(0x7e, 0x7d, b'E'), [Expected(0x7e, b'\x7d', b'E')]),
-    (_encode(0x7d, 0x7e, b'F'), [Expected(0x7d, b'\x7e', b'F')]),
-    (_encode(0x7e, 0x7e, b'\x7e'), [Expected(0x7e, b'\x7e', b'\x7e')]),
-    'Multiple frames separated by single flag',
-    (_encode(0, 0, b'A')[:-1] + _encode(1, 2, b'123'),
-     [Expected(0, b'\0', b'A'),
-      Expected(1, b'\2', b'123')]),
-    (_encode(0xff, 0, b'Yo')[:-1] * 3 + b'\x7e',
-     [Expected(0xff, b'\0', b'Yo')] * 3),
-    'Ignore empty frames',
-    (b'\x7e\x7e', []),
-    (b'\x7e' * 10, []),
-    (b'\x7e\x7e' + _encode(1, 2, b'3') + b'\x7e' * 5,
-     [Expected(1, b'\2', b'3')]),
-    (b'\x7e' * 10 + _encode(1, 2, b':O') + b'\x7e' * 3 + _encode(3, 4, b':P'),
-     [Expected(1, b'\2', b':O'),
-      Expected(3, b'\4', b':P')]),
-    'Cannot escape flag',
-    (b'\x7e\xAA\x7d\x7e\xab\x00Hello' + fcs(b'\xab\0Hello') + b'\x7e', [
-        Expected(0xAA, b'', b'', FrameStatus.INCOMPLETE),
-        Expected(0xab, b'\0', b'Hello'),
-    ]),
-    _ESCAPED_FLAG_TEST_CASE,
-    'Frame too short',
-    (b'\x7e1\x7e', [Expected(ord('1'), b'', b'', FrameStatus.INCOMPLETE)]),
-    (b'\x7e12\x7e', [Expected(ord('1'), b'2', b'', FrameStatus.INCOMPLETE)]),
-    (b'\x7e12345\x7e', [Expected(ord('1'), b'2', b'',
-                                 FrameStatus.INCOMPLETE)]),
-    'Incorrect frame check sequence',
-    (b'\x7e123456\x7e',
-     [Expected(ord('1'), b'2', b'', FrameStatus.FCS_MISMATCH)]),
-    (b'\x7e\1\2msg\xff\xff\xff\xff\x7e',
-     [Expected(0x1, b'\2', b'msg', FrameStatus.FCS_MISMATCH)]),
-    (_encode(0xA, 0xB, b'???')[:-2] + _encode(1, 2, b'def'), [
-        Expected(0xA, b'\x0B', b'??', FrameStatus.FCS_MISMATCH),
-        Expected(1, b'\2', b'def'),
-    ]),
-    'Invalid escape in address',
-    (b'\x7e\x7d\x7d\0' + fcs(b'\x5d\0') + b'\x7e',
-     [Expected(0,
-               fcs(b'\x5d\0')[0:1], b'', FrameStatus.INVALID_ESCAPE)]),
-    'Invalid escape in control',
-    (b'\x7e\0\x7d\x7d' + fcs(b'\0\x5d') + b'\x7e',
-     [Expected(0,
-               fcs(b'\0\x5d')[0:1], b'', FrameStatus.INVALID_ESCAPE)]),
-    'Invalid escape in data',
-    (b'\x7e\0\1\x7d\x7d' + fcs(b'\0\1\x5d') + b'\x7e',
-     [Expected(0, b'\1', b'', FrameStatus.INVALID_ESCAPE)]),
-    'Frame ends with escape',
-    (b'\x7e\x7d\x7e', [Expected(NO_ADDRESS, b'', b'',
-                                FrameStatus.INCOMPLETE)]),
-    (b'\x7e\1\x7d\x7e', [Expected(1, b'', b'', FrameStatus.INCOMPLETE)]),
-    (b'\x7e\1\2abc\x7d\x7e', [Expected(1, b'\2', b'',
-                                       FrameStatus.INCOMPLETE)]),
-    (b'\x7e\1\2abcd\x7d\x7e',
-     [Expected(1, b'\2', b'', FrameStatus.INCOMPLETE)]),
-    (b'\x7e\1\2abcd1234\x7d\x7e',
-     [Expected(1, b'\2', b'abcd', FrameStatus.INCOMPLETE)]),
-    'Inter-frame data is only escapes',
-    (b'\x7e\x7d\x7e\x7d\x7e', [
-        Expected(NO_ADDRESS, b'', b'', FrameStatus.INCOMPLETE),
-        Expected(NO_ADDRESS, b'', b'', FrameStatus.INCOMPLETE),
-    ]),
-    (b'\x7e\x7d\x7d\x7e\x7d\x7d\x7e', [
-        Expected(NO_ADDRESS, b'', b'', FrameStatus.INVALID_ESCAPE),
-        Expected(NO_ADDRESS, b'', b'', FrameStatus.INVALID_ESCAPE),
-    ]),
-    'Data before first flag',
-    (b'\0\1' + fcs(b'\0\1'), []),
-    (b'\0\1' + fcs(b'\0\1') + b'\x7e',
-     [Expected(0, b'\1', b'', FrameStatus.INCOMPLETE)]),
-    'No frames emitted until flag',
-    (_encode(1, 2, b'3')[:-1], []),
-    (b'\x7e' + _encode(1, 2, b'3')[1:-1] * 2, []),
-)  # yapf: disable
-# Formatting for the above tuple is very slow, so disable yapf.
-
-_TESTS = TestGenerator(TEST_CASES)
-
-
-def _expected(frames: List[Frame]) -> Iterator[str]:
-    for i, frame in enumerate(frames, 1):
-        if frame.ok():
-            yield f'      Frame(kDecodedFrame{i:02}),'
-        else:
-            yield f'      Status::DATA_LOSS,  // Frame {i}'
-
-
-_CPP_HEADER = """\
-#include "pw_hdlc_lite/decoder.h"
-
-#include <array>
-#include <cstddef>
-#include <variant>
-
-#include "gtest/gtest.h"
-#include "pw_bytes/array.h"
-
-namespace pw::hdlc_lite {
-namespace {
-"""
-
-_CPP_FOOTER = """\
-}  // namespace
-}  // namespace pw::hdlc_lite"""
-
-
-def _cpp_test(ctx: Context) -> Iterator[str]:
-    """Generates a C++ test for the provided test data."""
-    data, _ = ctx.test_case
-    frames = list(FrameDecoder().process(data))
-    data_bytes = ''.join(rf'\x{byte:02x}' for byte in data)
-
-    yield f'TEST(Decoder, {ctx.cc_name()}) {{'
-    yield f'  static constexpr auto kData = bytes::String("{data_bytes}");\n'
-
-    for i, frame in enumerate(frames, 1):
-        if frame.status is FrameStatus.OK:
-            frame_bytes = ''.join(rf'\x{byte:02x}' for byte in frame.raw)
-            yield (f'  static constexpr auto kDecodedFrame{i:02} = '
-                   f'bytes::String("{frame_bytes}");')
-        else:
-            yield f'  // Frame {i}: {frame.status.value}'
-
-    yield ''
-
-    expected = '\n'.join(_expected(frames)) or '      // No frames'
-    decoder_size = max(len(data), 8)  # Make sure large enough for a frame
-
-    yield f"""\
-  DecoderBuffer<{decoder_size}> decoder;
-
-  static constexpr std::array<std::variant<Frame, Status>, {len(frames)}> kExpected = {{
-{expected}
-  }};
-
-  size_t decoded_frames = 0;
-
-  decoder.Process(kData, [&](const Result<Frame>& result) {{
-    ASSERT_LT(decoded_frames++, kExpected.size());
-    auto& expected = kExpected[decoded_frames - 1];
-
-    if (std::holds_alternative<Status>(expected)) {{
-      EXPECT_EQ(Status::DATA_LOSS, result.status());
-    }} else {{
-      ASSERT_EQ(Status::OK, result.status());
-
-      const Frame& decoded_frame = result.value();
-      const Frame& expected_frame = std::get<Frame>(expected);
-      EXPECT_EQ(expected_frame.address(), decoded_frame.address());
-      EXPECT_EQ(expected_frame.control(), decoded_frame.control());
-      ASSERT_EQ(expected_frame.data().size(), decoded_frame.data().size());
-      EXPECT_EQ(std::memcmp(expected_frame.data().data(),
-                            decoded_frame.data().data(),
-                            expected_frame.data().size()),
-                0);
-    }}
-  }});
-
-  EXPECT_EQ(decoded_frames, kExpected.size());
-}}"""
-
-
-def _define_py_test(ctx: Context) -> PyTest:
-    data, expected_frames = ctx.test_case
-
-    def test(self) -> None:
-        # Decode in one call
-        self.assertEqual(expected_frames,
-                         list(FrameDecoder().process(data)),
-                         msg=f'{ctx.group}: {data!r}')
-
-        # Decode byte-by-byte
-        decoder = FrameDecoder()
-        decoded_frames: List[Frame] = []
-        for i in range(len(data)):
-            decoded_frames += decoder.process(data[i:i + 1])
-
-        self.assertEqual(expected_frames,
-                         decoded_frames,
-                         msg=f'{ctx.group} (byte-by-byte): {data!r}')
-
-    return test
-
-
-# Class that tests all cases in TEST_CASES.
-DecoderTest = _TESTS.python_tests('DecoderTest', _define_py_test)
-
-if __name__ == '__main__':
-    args = parse_test_generation_args()
-    if args.generate_cc_test:
-        _TESTS.cc_tests(args.generate_cc_test, _cpp_test, _CPP_HEADER,
-                        _CPP_FOOTER)
-    else:
-        unittest.main()
diff --git a/pw_hdlc_lite/py/encode_test.py b/pw_hdlc_lite/py/encode_test.py
deleted file mode 100755
index b0e68ab..0000000
--- a/pw_hdlc_lite/py/encode_test.py
+++ /dev/null
@@ -1,57 +0,0 @@
-#!/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 encoding HDLC frames."""
-
-import unittest
-
-from pw_hdlc_lite import encode
-from pw_hdlc_lite import protocol
-from pw_hdlc_lite.protocol import frame_check_sequence as _fcs
-
-FLAG = bytes([protocol.FLAG])
-
-
-def _with_fcs(data: bytes) -> bytes:
-    return data + _fcs(data)
-
-
-class TestEncodeInformationFrame(unittest.TestCase):
-    """Tests Encoding bytes with different arguments using a custom serial."""
-    def test_empty(self):
-        self.assertEqual(encode.information_frame(0, b''),
-                         FLAG + _with_fcs(b'\0\0') + FLAG)
-        self.assertEqual(encode.information_frame(0x1a, b''),
-                         FLAG + _with_fcs(b'\x1a\0') + FLAG)
-
-    def test_1byte(self):
-        self.assertEqual(encode.information_frame(0, b'A'),
-                         FLAG + _with_fcs(b'\0\0A') + FLAG)
-
-    def test_multibyte(self):
-        self.assertEqual(encode.information_frame(0, b'123456789'),
-                         FLAG + _with_fcs(b'\x00\x00123456789') + FLAG)
-
-    def test_escape(self):
-        self.assertEqual(
-            encode.information_frame(0x7e, b'\x7d'),
-            FLAG + b'\x7d\x5e\x00\x7d\x5d' + _fcs(b'\x7e\x00\x7d') + FLAG)
-        self.assertEqual(
-            encode.information_frame(0x7d, b'A\x7e\x7dBC'),
-            FLAG + b'\x7d\x5d\x00A\x7d\x5e\x7d\x5dBC' +
-            _fcs(b'\x7d\x00A\x7e\x7dBC') + FLAG)
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/pw_hdlc_lite/py/pw_hdlc_lite/decode.py b/pw_hdlc_lite/py/pw_hdlc_lite/decode.py
deleted file mode 100644
index 70c63ae..0000000
--- a/pw_hdlc_lite/py/pw_hdlc_lite/decode.py
+++ /dev/null
@@ -1,202 +0,0 @@
-# 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.
-"""Decoder class for decoding bytes using HDLC-Lite protocol"""
-
-import enum
-import logging
-from typing import Iterator, NamedTuple, Optional, Tuple
-import zlib
-
-from pw_hdlc_lite import protocol
-
-_LOG = logging.getLogger('pw_hdlc_lite')
-
-
-class FrameStatus(enum.Enum):
-    """Indicates that an error occurred."""
-    OK = 'OK'
-    FCS_MISMATCH = 'frame check sequence failure'
-    INCOMPLETE = 'incomplete frame'
-    INVALID_ESCAPE = 'invalid escape character'
-
-
-_MIN_FRAME_SIZE = 6  # 1 B address + 1 B control + 4 B CRC-32
-
-NO_ADDRESS = -1
-
-
-class Frame(NamedTuple):
-    """Represents an HDLC frame."""
-
-    # All bytes in the frame (address, control, information, FCS)
-    raw: bytes
-
-    # Whether parsing the frame succeeded.
-    status: FrameStatus = FrameStatus.OK
-
-    @property
-    def address(self) -> int:
-        """The frame's address field (assumes only one byte for now)."""
-        return self.raw[0] if self.raw else NO_ADDRESS
-
-    @property
-    def control(self) -> bytes:
-        """The control byte (assumes only one byte for now)."""
-        return self.raw[1:2] if len(self.raw) >= 2 else b''
-
-    @property
-    def data(self) -> bytes:
-        """The information field in the frame."""
-        return self.raw[2:-4] if len(self.raw) >= _MIN_FRAME_SIZE else b''
-
-    def ok(self) -> bool:
-        """True if this represents a valid frame.
-
-        If false, then parsing failed. The status is set to indicate what type
-        of error occurred, and the data field contains all bytes parsed from the
-        frame (including bytes parsed as address or control bytes).
-        """
-        return self.status is FrameStatus.OK
-
-
-class _BaseFrameState:
-    """Base class for all frame parsing states."""
-    def __init__(self, data: bytearray):
-        self._data = data  # All data seen in the current frame
-        self._escape_next = False
-
-    def handle_flag(self) -> Tuple['_BaseFrameState', Optional[Frame]]:
-        """Handles an HDLC flag character (0x7e).
-
-        The HDLC flag is always interpreted as the start of a new frame.
-
-        Returns:
-            (next state, optional frame or error)
-        """
-        # If there is data or an escape character, the frame is incomplete.
-        if self._escape_next or self._data:
-            return _AddressState(), Frame(bytes(self._data),
-                                          FrameStatus.INCOMPLETE)
-
-        return _AddressState(), None
-
-    def handle_escape(self) -> '_BaseFrameState':
-        """Handles an HDLC escape character (0x7d); returns the next state."""
-        if self._escape_next:
-            # If two escapes occur in a row, the frame is invalid.
-            return _InterframeState(self._data, FrameStatus.INVALID_ESCAPE)
-
-        self._escape_next = True
-        return self
-
-    def handle_byte(self, byte: int) -> '_BaseFrameState':
-        """Handles a byte, which may have been escaped; returns next state."""
-        self._data.append(protocol.escape(byte) if self._escape_next else byte)
-        self._escape_next = False
-        return self
-
-
-class _InterframeState(_BaseFrameState):
-    """Not currently in a frame; any data is discarded."""
-    def __init__(self, data: bytearray, error: FrameStatus):
-        super().__init__(data)
-        self._error = error
-
-    def handle_flag(self) -> Tuple[_BaseFrameState, Optional[Frame]]:
-        # If this state was entered due to an error, report that error before
-        # starting a new frame.
-        if self._error is not FrameStatus.OK:
-            return _AddressState(), Frame(bytes(self._data), self._error)
-
-        return super().handle_flag()
-
-
-class _AddressState(_BaseFrameState):
-    """First field in a frame: the address."""
-    def __init__(self):
-        super().__init__(bytearray())
-
-    def handle_byte(self, byte: int) -> _BaseFrameState:
-        super().handle_byte(byte)
-        # Only handle single-byte addresses for now.
-        return _ControlState(self._data)
-
-
-class _ControlState(_BaseFrameState):
-    """Second field in a frame: control."""
-    def handle_byte(self, byte: int) -> _BaseFrameState:
-        super().handle_byte(byte)
-        # Only handle a single control byte for now.
-        return _DataState(self._data)
-
-
-class _DataState(_BaseFrameState):
-    """The information field in a frame."""
-    def handle_flag(self) -> Tuple[_BaseFrameState, Frame]:
-        return _AddressState(), Frame(bytes(self._data), self._check_frame())
-
-    def _check_frame(self) -> FrameStatus:
-        # If the last character was an escape, assume bytes are missing.
-        if self._escape_next or len(self._data) < _MIN_FRAME_SIZE:
-            return FrameStatus.INCOMPLETE
-
-        frame_crc = int.from_bytes(self._data[-4:], 'little')
-        if zlib.crc32(self._data[:-4]) != frame_crc:
-            return FrameStatus.FCS_MISMATCH
-
-        return FrameStatus.OK
-
-
-class FrameDecoder:
-    """Decodes one or more HDLC frames from a stream of data."""
-    def __init__(self):
-        self._data = bytearray()
-        self._unescape_next_byte_flag = False
-        self._state = _InterframeState(bytearray(), FrameStatus.OK)
-
-    def process(self, data: bytes) -> Iterator[Frame]:
-        """Decodes and yields HDLC frames, including corrupt frames.
-
-        The ok() method on Frame indicates whether it is valid or represents a
-        frame parsing error.
-
-        Yields:
-          Frames, which may be valid (frame.ok()) or corrupt (!frame.ok())
-        """
-        for byte in data:
-            frame = self._process_byte(byte)
-            if frame:
-                yield frame
-
-    def process_valid_frames(self, data: bytes) -> Iterator[Frame]:
-        """Decodes and yields valid HDLC frames, logging any errors."""
-        for frame in self.process(data):
-            if frame.ok():
-                yield frame
-            else:
-                _LOG.warning('Failed to decode frame: %s; discarded %d bytes',
-                             frame.status.value, len(frame.data))
-                _LOG.debug('Discarded data: %s', frame.data)
-
-    def _process_byte(self, byte: int) -> Optional[Frame]:
-        if byte == protocol.FLAG:
-            self._state, frame = self._state.handle_flag()
-            return frame
-
-        if byte == protocol.ESCAPE:
-            self._state = self._state.handle_escape()
-        else:
-            self._state = self._state.handle_byte(byte)
-
-        return None
diff --git a/pw_hdlc_lite/py/pw_hdlc_lite/encode.py b/pw_hdlc_lite/py/pw_hdlc_lite/encode.py
deleted file mode 100644
index 9db8a30..0000000
--- a/pw_hdlc_lite/py/pw_hdlc_lite/encode.py
+++ /dev/null
@@ -1,29 +0,0 @@
-# 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.
-"""The encode module supports encoding HDLC frames."""
-
-from pw_hdlc_lite import protocol
-
-_ESCAPE_BYTE = bytes([protocol.ESCAPE])
-_FLAG_BYTE = bytes([protocol.FLAG])
-_CONTROL = 0  # Currently, hard-coded to 0; no sequence numbers are used
-
-
-def information_frame(address: int, data: bytes) -> bytes:
-    """Encodes an HDLC I-frame with a CRC-32 frame check sequence."""
-    frame = bytearray([address, _CONTROL]) + data
-    frame += protocol.frame_check_sequence(frame)
-    frame = frame.replace(_ESCAPE_BYTE, b'\x7d\x5d')
-    frame = frame.replace(_FLAG_BYTE, b'\x7d\x5e')
-    return b''.join([_FLAG_BYTE, frame, _FLAG_BYTE])
diff --git a/pw_hdlc_lite/py/pw_hdlc_lite/protocol.py b/pw_hdlc_lite/py/pw_hdlc_lite/protocol.py
deleted file mode 100644
index 4f9098a..0000000
--- a/pw_hdlc_lite/py/pw_hdlc_lite/protocol.py
+++ /dev/null
@@ -1,31 +0,0 @@
-# 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.
-"""Module for low-level HDLC protocol features."""
-
-import zlib
-
-# Special flag character for delimiting HDLC frames.
-FLAG = 0x7E
-
-# Special character for escaping other special characters in a frame.
-ESCAPE = 0x7D
-
-
-def escape(byte: int) -> int:
-    """Escapes or unescapes a byte, which should have been preceeded by 0x7d."""
-    return byte ^ 0x20
-
-
-def frame_check_sequence(data: bytes) -> bytes:
-    return zlib.crc32(data).to_bytes(4, 'little')
diff --git a/pw_hdlc_lite/py/pw_hdlc_lite/rpc.py b/pw_hdlc_lite/py/pw_hdlc_lite/rpc.py
deleted file mode 100644
index 57b74b2..0000000
--- a/pw_hdlc_lite/py/pw_hdlc_lite/rpc.py
+++ /dev/null
@@ -1,138 +0,0 @@
-# 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.
-"""Utilities for using HDLC with pw_rpc."""
-
-import logging
-from pathlib import Path
-import sys
-import threading
-import time
-from types import ModuleType
-from typing import Any, BinaryIO, Callable, Iterable, List, NoReturn, Union
-
-from pw_hdlc_lite.decode import FrameDecoder
-from pw_hdlc_lite import encode
-import pw_rpc
-from pw_rpc import callback_client
-from pw_protobuf_compiler import python_protos
-
-_LOG = logging.getLogger(__name__)
-
-STDOUT_ADDRESS = 1
-DEFAULT_ADDRESS = ord('R')
-
-
-def channel_output(writer: Callable[[bytes], Any],
-                   address: int = DEFAULT_ADDRESS,
-                   delay_s: float = 0) -> Callable[[bytes], None]:
-    """Returns a function that can be used as a channel output for pw_rpc."""
-
-    if delay_s:
-
-        def slow_write(data: bytes) -> None:
-            """Slows down writes in case unbuffered serial is in use."""
-            for byte in data:
-                time.sleep(delay_s)
-                writer(bytes([byte]))
-
-        return lambda data: slow_write(encode.information_frame(address, data))
-
-    return lambda data: writer(encode.information_frame(address, data))
-
-
-def read_and_process_data(rpc_client: pw_rpc.Client,
-                          device: BinaryIO,
-                          output: Callable[[bytes], Any],
-                          rpc_address: int = DEFAULT_ADDRESS) -> NoReturn:
-    """Reads HDLC frames from the device and passes them to the RPC client."""
-    decoder = FrameDecoder()
-
-    while True:
-        byte = device.read()
-        for frame in decoder.process_valid_frames(byte):
-            if not frame.ok():
-                _LOG.error('Failed to parse frame: %s', frame.status.value)
-                continue
-
-            if frame.address == rpc_address:
-                if not rpc_client.process_packet(frame.data):
-                    _LOG.error('Packet not handled by RPC client: %s', frame)
-            elif frame.address == STDOUT_ADDRESS:
-                output(frame.data)
-            else:
-                _LOG.error('Unhandled frame for address %d: %s', frame.address,
-                           frame.data.decode(errors='replace'))
-
-
-_PathOrModule = Union[str, Path, ModuleType]
-
-
-def write_to_file(data: bytes, output: BinaryIO = sys.stdout.buffer):
-    output.write(data)
-    output.write(b'\n')
-    output.flush()
-
-
-class HdlcRpcClient:
-    """An RPC client configured to run over HDLC."""
-    def __init__(self,
-                 device: BinaryIO,
-                 proto_paths_or_modules: Iterable[_PathOrModule],
-                 output: Callable[[bytes], Any] = write_to_file,
-                 channels: Iterable[pw_rpc.Channel] = None,
-                 client_impl: pw_rpc.client.ClientImpl = None):
-        """Creates an RPC client configured to communicate using HDLC.
-
-        Args:
-          device: serial.Serial (or any BinaryIO class) for reading/writing data
-          proto_paths_or_modules: paths to .proto files or proto modules
-          output: where to write "stdout" output from the device
-        """
-        self.device = device
-
-        proto_modules = []
-        proto_paths: List[Union[Path, str]] = []
-        for proto in proto_paths_or_modules:
-            if isinstance(proto, (Path, str)):
-                proto_paths.append(proto)
-            else:
-                proto_modules.append(proto)
-
-        proto_modules += python_protos.compile_and_import(proto_paths)
-
-        if channels is None:
-            channels = [pw_rpc.Channel(1, channel_output(device.write))]
-
-        if client_impl is None:
-            client_impl = callback_client.Impl()
-
-        self.client = pw_rpc.Client.from_modules(client_impl, channels,
-                                                 proto_modules)
-
-        # Start background thread that reads and processes RPC packets.
-        threading.Thread(target=read_and_process_data,
-                         daemon=True,
-                         args=(self.client, device, output)).start()
-
-    def rpcs(self, channel_id: int = None) -> Any:
-        """Returns object for accessing services on the specified channel.
-
-        This skips some intermediate layers to make it simpler to invoke RPCs
-        from an HdlcRpcClient. If only one channel is in use, the channel ID is
-        not necessary.
-        """
-        if channel_id is None:
-            return next(iter(self.client.channels())).rpcs
-
-        return self.client.channel(channel_id).rpcs
diff --git a/pw_hdlc_lite/py/pw_hdlc_lite/rpc_console.py b/pw_hdlc_lite/py/pw_hdlc_lite/rpc_console.py
deleted file mode 100644
index 1e3b929..0000000
--- a/pw_hdlc_lite/py/pw_hdlc_lite/rpc_console.py
+++ /dev/null
@@ -1,124 +0,0 @@
-# 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.
-"""Console for interacting with pw_rpc over HDLC.
-
-To start the console, provide a serial port as the --device argument and paths
-or globs for .proto files that define the RPC services to support:
-
-  python -m pw_hdlc_lite.rpc_console --device /dev/ttyUSB0 sample.proto
-
-This starts an IPython console for communicating with the connected device. A
-few variables are predefined in the interactive console. These include:
-
-    rpcs   - used to invoke RPCs
-    device - the serial device used for communication
-    client - the pw_rpc.Client
-
-An example echo RPC command:
-
-  rpcs.pw.rpc.EchoService.Echo(msg="hello!")
-"""
-
-import argparse
-import glob
-import logging
-from pathlib import Path
-import sys
-from typing import Collection, Iterable, Iterator, BinaryIO
-
-import IPython  # type: ignore
-import serial  # type: ignore
-
-from pw_hdlc_lite.rpc import HdlcRpcClient, write_to_file
-
-_LOG = logging.getLogger(__name__)
-
-
-def _parse_args():
-    """Parses and returns the command line arguments."""
-    parser = argparse.ArgumentParser(description=__doc__)
-    parser.add_argument('-d',
-                        '--device',
-                        required=True,
-                        help='the serial port to use')
-    parser.add_argument('-b',
-                        '--baudrate',
-                        type=int,
-                        default=115200,
-                        help='the baud rate to use')
-    parser.add_argument(
-        '-o',
-        '--output',
-        type=argparse.FileType('wb'),
-        default=sys.stdout.buffer,
-        help=('The file to which to write device output (HDLC channel 1); '
-              'provide - or omit for stdout.'))
-    parser.add_argument('proto_globs',
-                        nargs='+',
-                        help='glob pattern for .proto files')
-    return parser.parse_args()
-
-
-def _expand_globs(globs: Iterable[str]) -> Iterator[Path]:
-    for pattern in globs:
-        for file in glob.glob(pattern, recursive=True):
-            yield Path(file)
-
-
-def _start_ipython_terminal(client: HdlcRpcClient) -> None:
-    """Starts an interactive IPython terminal with preset variables."""
-    local_variables = dict(
-        client=client,
-        channel_client=client.client.channel(1),
-        rpcs=client.client.channel(1).rpcs,
-    )
-
-    print(__doc__)  # Print the banner
-    IPython.terminal.embed.InteractiveShellEmbed().mainloop(
-        local_ns=local_variables, module=argparse.Namespace())
-
-
-def console(device: str, baudrate: int, proto_globs: Collection[str],
-            output: BinaryIO) -> int:
-    """Starts an interactive RPC console for HDLC."""
-    # argparse.FileType doesn't correctly handle '-' for binary files.
-    if output is sys.stdout:
-        output = sys.stdout.buffer
-
-    if not proto_globs:
-        proto_globs = ['**/*.proto']
-
-    protos = list(_expand_globs(proto_globs))
-
-    if not protos:
-        _LOG.critical('No .proto files were found with %s',
-                      ', '.join(proto_globs))
-        _LOG.critical('At least one .proto file is required')
-        return 1
-
-    _LOG.debug('Found %d .proto files found with %s', len(protos),
-               ', '.join(proto_globs))
-
-    _start_ipython_terminal(
-        HdlcRpcClient(serial.Serial(device, baudrate), protos,
-                      lambda data: write_to_file(data, output)))
-    return 0
-
-
-def main() -> int:
-    return console(**vars(_parse_args()))
-
-
-if __name__ == '__main__':
-    sys.exit(main())
diff --git a/pw_hdlc_lite/py/setup.py b/pw_hdlc_lite/py/setup.py
deleted file mode 100644
index 15266ae..0000000
--- a/pw_hdlc_lite/py/setup.py
+++ /dev/null
@@ -1,29 +0,0 @@
-# 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_hdlc_lite"""
-
-import setuptools  # type: ignore
-
-setuptools.setup(
-    name='pw_hdlc_lite',
-    version='0.0.1',
-    author='Pigweed Authors',
-    author_email='pigweed-developers@googlegroups.com',
-    description='Tools for Encoding/Decoding data using the HDLC-Lite protocol',
-    packages=setuptools.find_packages(),
-    package_data={'pw_hdlc_lite': ['py.typed']},
-    zip_safe=False,
-    install_requires=['ipython'],
-    tests_require=['pw_build'],
-)
diff --git a/pw_hdlc_lite/rpc_channel_test.cc b/pw_hdlc_lite/rpc_channel_test.cc
deleted file mode 100644
index 7e0d0dd..0000000
--- a/pw_hdlc_lite/rpc_channel_test.cc
+++ /dev/null
@@ -1,122 +0,0 @@
-// 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.
-
-// 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.
-
-#include "pw_hdlc_lite/rpc_channel.h"
-
-#include <algorithm>
-#include <array>
-#include <cstddef>
-
-#include "gtest/gtest.h"
-#include "pw_bytes/array.h"
-#include "pw_stream/memory_stream.h"
-
-using std::byte;
-
-namespace pw::hdlc_lite {
-namespace {
-
-constexpr byte kFlag = byte{0x7E};
-constexpr uint8_t kAddress = 0x7b;  // 123
-constexpr byte kControl = byte{0};
-
-// Size of the in-memory buffer to use for this test.
-constexpr size_t kSinkBufferSize = 15;
-
-TEST(RpcChannelOutput, 1BytePayload) {
-  std::array<byte, kSinkBufferSize> channel_output_buffer;
-  stream::MemoryWriterBuffer<kSinkBufferSize> memory_writer;
-
-  RpcChannelOutput output(
-      memory_writer, channel_output_buffer, kAddress, "RpcChannelOutput");
-
-  constexpr byte test_data = byte{'A'};
-  std::memcpy(output.AcquireBuffer().data(), &test_data, sizeof(test_data));
-
-  constexpr auto expected = bytes::Concat(
-      kFlag, kAddress, kControl, 'A', uint32_t{0xA63E2FA5}, kFlag);
-
-  EXPECT_EQ(Status::Ok(), output.SendAndReleaseBuffer(sizeof(test_data)));
-
-  ASSERT_EQ(memory_writer.bytes_written(), expected.size());
-  EXPECT_EQ(
-      std::memcmp(
-          memory_writer.data(), expected.data(), memory_writer.bytes_written()),
-      0);
-}
-
-TEST(RpcChannelOutput, EscapingPayloadTest) {
-  std::array<byte, kSinkBufferSize> channel_output_buffer;
-  stream::MemoryWriterBuffer<kSinkBufferSize> memory_writer;
-
-  RpcChannelOutput output(
-      memory_writer, channel_output_buffer, kAddress, "RpcChannelOutput");
-
-  constexpr auto test_data = bytes::Array<0x7D>();
-  std::memcpy(
-      output.AcquireBuffer().data(), test_data.data(), test_data.size());
-
-  constexpr auto expected = bytes::Concat(kFlag,
-                                          kAddress,
-                                          kControl,
-                                          byte{0x7d},
-                                          byte{0x7d} ^ byte{0x20},
-                                          uint32_t{0x89515322},
-                                          kFlag);
-  EXPECT_EQ(Status::Ok(), output.SendAndReleaseBuffer(test_data.size()));
-
-  ASSERT_EQ(memory_writer.bytes_written(), 10u);
-  EXPECT_EQ(
-      std::memcmp(
-          memory_writer.data(), expected.data(), memory_writer.bytes_written()),
-      0);
-}
-
-TEST(RpcChannelOutputBuffer, 1BytePayload) {
-  stream::MemoryWriterBuffer<kSinkBufferSize> memory_writer;
-
-  RpcChannelOutputBuffer<kSinkBufferSize> output(
-      memory_writer, kAddress, "RpcChannelOutput");
-
-  constexpr byte test_data = byte{'A'};
-  std::memcpy(output.AcquireBuffer().data(), &test_data, sizeof(test_data));
-
-  constexpr auto expected = bytes::Concat(
-      kFlag, kAddress, kControl, 'A', uint32_t{0xA63E2FA5}, kFlag);
-
-  EXPECT_EQ(Status::Ok(), output.SendAndReleaseBuffer(sizeof(test_data)));
-
-  ASSERT_EQ(memory_writer.bytes_written(), expected.size());
-  EXPECT_EQ(
-      std::memcmp(
-          memory_writer.data(), expected.data(), memory_writer.bytes_written()),
-      0);
-}
-
-}  // namespace
-}  // namespace pw::hdlc_lite
diff --git a/pw_hdlc_lite/rpc_example/BUILD b/pw_hdlc_lite/rpc_example/BUILD
deleted file mode 100644
index 7e6a30b..0000000
--- a/pw_hdlc_lite/rpc_example/BUILD
+++ /dev/null
@@ -1,38 +0,0 @@
-# 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.
-
-load(
-    "//pw_build:pigweed.bzl",
-    "pw_cc_library",
-)
-
-pw_cc_library(
-    name = "rpc_example",
-    srcs = [
-        "hdlc_rpc_server.cc",
-        "main.cc",
-    ],
-    hdrs = [
-        "public/pw_hdlc_lite/decoder.h",
-        "public/pw_hdlc_lite/hdlc_channel.h",
-        "public/pw_hdlc_lite/rpc_server_packets.h",
-    ],
-    deps = [
-        "//pw_hdlc_lite",
-        "//pw_hdlc_lite:pw_rpc",
-        "//pw_rpc:server",
-        "//pw_log",
-    ],
-)
-
diff --git a/pw_hdlc_lite/rpc_example/BUILD.gn b/pw_hdlc_lite/rpc_example/BUILD.gn
deleted file mode 100644
index 495b168..0000000
--- a/pw_hdlc_lite/rpc_example/BUILD.gn
+++ /dev/null
@@ -1,42 +0,0 @@
-# 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")
-import("$dir_pw_build/target_types.gni")
-import("$dir_pw_third_party/nanopb/nanopb.gni")
-
-if (dir_pw_third_party_nanopb == "") {
-  group("rpc_example") {
-  }
-} else {
-  pw_executable("rpc_example") {
-    sources = [
-      "hdlc_rpc_server.cc",
-      "main.cc",
-    ]
-    deps = [
-      "$dir_pw_rpc:server",
-      "$dir_pw_rpc/nanopb:echo_service",
-      "..:pw_rpc",
-      dir_pw_hdlc_lite,
-      dir_pw_log,
-    ]
-  }
-}
-
-pw_python_script("example_script") {
-  sources = [ "example_script.py" ]
-}
diff --git a/pw_hdlc_lite/rpc_example/CMakeLists.txt b/pw_hdlc_lite/rpc_example/CMakeLists.txt
deleted file mode 100644
index 9c2d32c..0000000
--- a/pw_hdlc_lite/rpc_example/CMakeLists.txt
+++ /dev/null
@@ -1,24 +0,0 @@
-# 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.
-
-
-add_executable(pw_hdlc_lite.rpc_example hdlc_rpc_server.cc main.cc)
-
-target_link_libraries(pw_hdlc_lite.rpc_example
-  PRIVATE
-    pw_hdlc_lite
-    pw_log
-    pw_rpc.nanopb.echo_service
-    pw_rpc.server
-)
diff --git a/pw_hdlc_lite/rpc_example/docs.rst b/pw_hdlc_lite/rpc_example/docs.rst
deleted file mode 100644
index 492ec0e..0000000
--- a/pw_hdlc_lite/rpc_example/docs.rst
+++ /dev/null
@@ -1,100 +0,0 @@
-.. _module-pw_hdlc_lite-rpc-example:
-
-=============================
-RPC over HDLC example project
-=============================
-The :ref:`module-pw_hdlc_lite` module includes an example of bringing up a
-:ref:`module-pw_rpc` server that can be used to invoke RPCs. The example code
-is located at ``pw_hdlc_lite/rpc_example``. This section walks through invoking
-RPCs interactively and with a script using the RPC over HDLC example.
-
-These instructions assume the STM32F429i Discovery board, but they work with
-any target with :ref:`pw::sys_io <module-pw_sys_io>` implemented.
-
----------------------
-Getting started guide
----------------------
-
-1. Set up your board
-====================
-Connect the board you'll be communicating with. For the Discovery board, connect
-the mini USB port, and note which serial device it appears as (e.g.
-``/dev/ttyACM0``).
-
-2. Build Pigweed
-================
-Activate the Pigweed environment and run the default build.
-
-.. code-block:: sh
-
-  source activate.sh
-  gn gen out
-  ninja -C out
-
-3. Flash the firmware image
-===========================
-After a successful build, the binary for the example will be located at
-``out/<toolchain>/obj/pw_hdlc_lite/rpc_example/bin/rpc_example.elf``.
-
-Flash this image to your board. If you are using the STM32F429i Discovery Board,
-you can flash the image with `OpenOCD <http://openocd.org>`_.
-
-.. code-block:: sh
-
- openocd -f targets/stm32f429i-disc1/py/stm32f429i_disc1_utils/openocd_stm32f4xx.cfg \
-     -c "program out/stm32f429i_disc1_debug/obj/pw_hdlc_lite/rpc_example/bin/rpc_example.elf"
-
-4. Invoke RPCs from in an interactive console
-=============================================
-The RPC console uses `IPython <https://ipython.org>`_ to make a rich interactive
-console for working with pw_rpc. Run the RPC console with the following command,
-replacing ``/dev/ttyACM0`` with the correct serial device for your board.
-
-.. code-block:: text
-
-  $ python -m pw_hdlc_lite.rpc_console --device /dev/ttyACM0
-
-  Console for interacting with pw_rpc over HDLC.
-
-  To start the console, provide a serial port as the --device argument and paths
-  or globs for .proto files that define the RPC services to support:
-
-    python -m pw_hdlc_lite.rpc_console --device /dev/ttyUSB0 sample.proto
-
-  This starts an IPython console for communicating with the connected device. A
-  few variables are predefined in the interactive console. These include:
-
-      rpcs   - used to invoke RPCs
-      device - the serial device used for communication
-      client - the pw_rpc.Client
-
-  An example echo RPC command:
-
-    rpcs.pw.rpc.EchoService.Echo(msg="hello!")
-
-  In [1]:
-
-RPCs may be accessed through the predefined ``rpcs`` variable. RPCs are
-organized by their protocol buffer package and RPC service, as defined in a
-.proto file. To call the ``Echo`` method is part of the ``EchoService``, which
-is in the ``pw.rpc`` package. To invoke it synchronously, call
-``rpcs.pw.rpc.EchoService.Echo``:
-
-.. code-block:: python
-
-    In [1]: rpcs.pw.rpc.EchoService.Echo(msg="Your message here!")
-    Out[1]: (<Status.OK: 0>, msg: "Your message here!")
-
-5. Invoke RPCs with a script
-============================
-RPCs may also be invoked from Python scripts. Close the RPC console if it is
-running, and execute the example script. Set the --device argument to the
-serial port for your device.
-
-.. code-block:: text
-
-  $ pw_hdlc_lite/rpc_example/example_script.py --device /dev/ttyACM0
-  The status was Status.OK
-  The payload was msg: "Hello"
-
-  The device says: Goodbye!
diff --git a/pw_hdlc_lite/rpc_example/example_script.py b/pw_hdlc_lite/rpc_example/example_script.py
deleted file mode 100755
index 57726de..0000000
--- a/pw_hdlc_lite/rpc_example/example_script.py
+++ /dev/null
@@ -1,67 +0,0 @@
-#!/usr/bin/env python
-# 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.
-"""Simple example script that uses pw_rpc."""
-
-import argparse
-import os
-from pathlib import Path
-
-import serial  # type: ignore
-
-from pw_hdlc_lite.rpc import HdlcRpcClient
-
-# Point the script to the .proto file with our RPC services.
-PROTO = Path(os.environ['PW_ROOT'], 'pw_rpc/pw_rpc_protos/echo.proto')
-
-
-def script(device: str, baud: int) -> None:
-    # Set up a pw_rpc client that uses HDLC.
-    client = HdlcRpcClient(serial.Serial(device, baud), [PROTO])
-
-    # Make a shortcut to the EchoService.
-    echo_service = client.rpcs().pw.rpc.EchoService
-
-    # Call some RPCs and check the results.
-    status, payload = echo_service.Echo(msg='Hello')
-
-    if status.ok():
-        print('The status was', status)
-        print('The payload was', payload)
-    else:
-        print('Uh oh, this RPC returned', status)
-
-    status, payload = echo_service.Echo(msg='Goodbye!')
-
-    print('The device says:', payload.msg)
-
-
-def main():
-    parser = argparse.ArgumentParser(
-        description=__doc__,
-        formatter_class=argparse.ArgumentDefaultsHelpFormatter)
-    parser.add_argument('--device',
-                        '-d',
-                        default='/dev/ttyACM0',
-                        help='serial device to use')
-    parser.add_argument('--baud',
-                        '-b',
-                        type=int,
-                        default=115200,
-                        help='baud rate for the serial device')
-    script(**vars(parser.parse_args()))
-
-
-if __name__ == '__main__':
-    main()
diff --git a/pw_hdlc_lite/rpc_example/hdlc_rpc_server.cc b/pw_hdlc_lite/rpc_example/hdlc_rpc_server.cc
deleted file mode 100644
index 319ee1b..0000000
--- a/pw_hdlc_lite/rpc_example/hdlc_rpc_server.cc
+++ /dev/null
@@ -1,72 +0,0 @@
-// 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.
-
-#include <array>
-#include <span>
-#include <string_view>
-
-#include "pw_hdlc_lite/encoder.h"
-#include "pw_hdlc_lite/rpc_channel.h"
-#include "pw_hdlc_lite/rpc_packets.h"
-#include "pw_hdlc_lite/sys_io_stream.h"
-#include "pw_log/log.h"
-#include "pw_rpc/echo_service_nanopb.h"
-#include "pw_rpc/server.h"
-
-namespace hdlc_example {
-namespace {
-
-using std::byte;
-
-constexpr size_t kMaxTransmissionUnit = 256;
-
-// Used to write HDLC data to pw::sys_io.
-pw::stream::SysIoWriter writer;
-
-// Set up the output channel for the pw_rpc server to use to use.
-pw::hdlc_lite::RpcChannelOutputBuffer<kMaxTransmissionUnit> hdlc_channel_output(
-    writer, pw::hdlc_lite::kDefaultRpcAddress, "HDLC channel");
-
-pw::rpc::Channel channels[] = {
-    pw::rpc::Channel::Create<1>(&hdlc_channel_output)};
-
-// Declare the pw_rpc server with the HDLC channel.
-pw::rpc::Server server(channels);
-
-pw::rpc::EchoService echo_service;
-
-void RegisterServices() { server.RegisterService(echo_service); }
-
-}  // namespace
-
-void Start() {
-  // Send log messages to HDLC address 1. This prevents logs from interfering
-  // with pw_rpc communications.
-  pw::log_basic::SetOutput([](std::string_view log) {
-    pw::hdlc_lite::WriteInformationFrame(
-        1, std::as_bytes(std::span(log)), writer);
-  });
-
-  // Set up the server and start processing data.
-  RegisterServices();
-
-  // Declare a buffer for decoding incoming HDLC frames.
-  std::array<std::byte, kMaxTransmissionUnit> input_buffer;
-
-  PW_LOG_INFO("Starting pw_rpc server");
-  pw::hdlc_lite::ReadAndProcessPackets(
-      server, hdlc_channel_output, input_buffer);
-}
-
-}  // namespace hdlc_example
diff --git a/pw_hdlc_lite/rpc_packets.cc b/pw_hdlc_lite/rpc_packets.cc
deleted file mode 100644
index 95e95b3..0000000
--- a/pw_hdlc_lite/rpc_packets.cc
+++ /dev/null
@@ -1,41 +0,0 @@
-// 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.
-
-#include "pw_hdlc_lite/rpc_packets.h"
-
-#include "pw_status/try.h"
-#include "pw_sys_io/sys_io.h"
-
-namespace pw::hdlc_lite {
-
-Status ReadAndProcessPackets(rpc::Server& server,
-                             rpc::ChannelOutput& output,
-                             std::span<std::byte> decode_buffer,
-                             unsigned rpc_address) {
-  Decoder decoder(decode_buffer);
-
-  while (true) {
-    std::byte data;
-    PW_TRY(sys_io::ReadByte(&data));
-
-    if (auto result = decoder.Process(data); result.ok()) {
-      Frame& frame = result.value();
-      if (frame.address() == rpc_address) {
-        server.ProcessPacket(frame.data(), output);
-      }
-    }
-  }
-}
-
-}  // namespace pw::hdlc_lite
diff --git a/pw_hex_dump/BUILD.gn b/pw_hex_dump/BUILD.gn
index 2ad280e..7f0bb44 100644
--- a/pw_hex_dump/BUILD.gn
+++ b/pw_hex_dump/BUILD.gn
@@ -26,7 +26,6 @@
   public_configs = [ ":default_config" ]
   public_deps = [
     dir_pw_bytes,
-    dir_pw_span,
     dir_pw_status,
   ]
   deps = [ dir_pw_string ]
diff --git a/pw_hex_dump/hex_dump.cc b/pw_hex_dump/hex_dump.cc
index ae979df..67f0f3c 100644
--- a/pw_hex_dump/hex_dump.cc
+++ b/pw_hex_dump/hex_dump.cc
@@ -215,7 +215,7 @@
     return Status::InvalidArgument();
   }
   dest_ = dest;
-  return ValidateBufferSize().ok() ? Status::Ok() : Status::ResourceExhausted();
+  return ValidateBufferSize().ok() ? OkStatus() : Status::ResourceExhausted();
 }
 
 Status FormattedHexDumper::BeginDump(ConstByteSpan data) {
@@ -227,8 +227,7 @@
   if (dest_.data() != nullptr && dest_.size_bytes() > 0) {
     dest_[0] = 0;
   }
-  return ValidateBufferSize().ok() ? Status::Ok()
-                                   : Status::FailedPrecondition();
+  return ValidateBufferSize().ok() ? OkStatus() : Status::FailedPrecondition();
 }
 
 Status FormattedHexDumper::ValidateBufferSize() {
@@ -254,7 +253,7 @@
     return Status::ResourceExhausted();
   }
 
-  return Status::Ok();
+  return OkStatus();
 }
 
 }  // namespace pw::dump
diff --git a/pw_hex_dump/hex_dump_test.cc b/pw_hex_dump/hex_dump_test.cc
index 1b10251..f374431 100644
--- a/pw_hex_dump/hex_dump_test.cc
+++ b/pw_hex_dump/hex_dump_test.cc
@@ -100,7 +100,7 @@
 TEST_F(HexDump, DumpAddr_ZeroSizeT) {
   constexpr const char* expected = EXPECTED_SIGNIFICANT_BYTES("00000000");
   size_t zero = 0;
-  EXPECT_EQ(DumpAddr(dest_, zero), Status::Ok());
+  EXPECT_EQ(DumpAddr(dest_, zero), OkStatus());
   EXPECT_STREQ(expected, dest_.data());
 }
 
diff --git a/pw_i2c/BUILD b/pw_i2c/BUILD
new file mode 100644
index 0000000..2343bdc
--- /dev/null
+++ b/pw_i2c/BUILD
@@ -0,0 +1,61 @@
+# 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.
+
+load(
+    "//pw_build:pigweed.bzl",
+    "pw_cc_library",
+    "pw_cc_test",
+)
+
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])  # Apache License 2.0
+
+pw_cc_library(
+    name = "address",
+    hdrs = [
+        "public/pw_i2c/address.h",
+    ],
+    includes = ["public"],
+    srcs = [
+      "address.cc",
+    ],
+    deps = [
+        "//pw_assert",
+    ],
+)
+
+pw_cc_library(
+    name = "initiator",
+    hdrs = [
+        "public/pw_i2c/initiator.h",
+    ],
+    includes = ["public"],
+    deps = [
+        "//pw_bytes",
+        "//pw_chrono:system_clock",
+        "//pw_status",
+    ],
+)
+
+pw_cc_test(
+    name = "address_test",
+    srcs = [
+        "address_test.cc",
+    ],
+    deps = [
+        ":address",
+        "//pw_unit_test",
+    ],
+)
diff --git a/pw_i2c/BUILD.gn b/pw_i2c/BUILD.gn
new file mode 100644
index 0000000..b939587
--- /dev/null
+++ b/pw_i2c/BUILD.gn
@@ -0,0 +1,54 @@
+# 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/target_types.gni")
+import("$dir_pw_docgen/docs.gni")
+import("$dir_pw_unit_test/test.gni")
+
+config("public_include_path") {
+  include_dirs = [ "public" ]
+}
+
+pw_source_set("address") {
+  public_configs = [ ":public_include_path" ]
+  public = [ "public/pw_i2c/address.h" ]
+  deps = [ "$dir_pw_assert" ]
+  sources = [ "address.cc" ]
+}
+
+pw_source_set("initiator") {
+  public_configs = [ ":public_include_path" ]
+  public = [ "public/pw_i2c/initiator.h" ]
+  public_deps = [
+    ":address",
+    "$dir_pw_bytes",
+    "$dir_pw_chrono:system_clock",
+    "$dir_pw_status",
+  ]
+}
+
+pw_test_group("tests") {
+  tests = [ ":address_test" ]
+}
+
+pw_test("address_test") {
+  sources = [ "address_test.cc" ]
+  deps = [ ":address" ]
+}
+
+pw_doc_group("docs") {
+  sources = [ "docs.rst" ]
+}
diff --git a/pw_i2c/address.cc b/pw_i2c/address.cc
new file mode 100644
index 0000000..5cca79a
--- /dev/null
+++ b/pw_i2c/address.cc
@@ -0,0 +1,32 @@
+// 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.
+
+#include "pw_i2c/address.h"
+
+#include "pw_assert/assert.h"
+
+namespace pw::i2c {
+
+// Implemented in the source to produce a nicer CHECK message vs an ASSERT.
+Address::Address(uint16_t address) : address_(address) {
+  PW_CHECK_UINT_LE(address_, kMaxTenBitAddress);
+}
+
+// Implemented in the source to produce a nicer CHECK message vs an ASSERT.
+uint8_t Address::GetSevenBit() const {
+  PW_CHECK_UINT_LE(address_, kMaxSevenBitAddress);
+  return address_;
+}
+
+}  // namespace pw::i2c
diff --git a/pw_i2c/address_test.cc b/pw_i2c/address_test.cc
new file mode 100644
index 0000000..71ac11f
--- /dev/null
+++ b/pw_i2c/address_test.cc
@@ -0,0 +1,48 @@
+// Copyright 2021 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.
+
+#include "pw_i2c/address.h"
+
+#include "gtest/gtest.h"
+
+namespace pw::i2c {
+
+TEST(Address, SevenBitConstexpr) {
+  constexpr Address kSevenBit =
+      Address::SevenBit<Address::kMaxSevenBitAddress>();
+  EXPECT_EQ(kSevenBit.GetSevenBit(), Address::kMaxSevenBitAddress);
+}
+
+TEST(Address, TenBitConstexpr) {
+  constexpr Address kTenBit = Address::TenBit<Address::kMaxTenBitAddress>();
+  EXPECT_EQ(kTenBit.GetTenBit(), Address::kMaxTenBitAddress);
+}
+
+TEST(Address, SevenBitRuntimeChecked) {
+  const Address seven_bit(Address::kMaxSevenBitAddress);
+  EXPECT_EQ(seven_bit.GetSevenBit(), Address::kMaxSevenBitAddress);
+}
+
+TEST(Address, TenBitRuntimeChecked) {
+  const Address ten_bit(Address::kMaxTenBitAddress);
+  EXPECT_EQ(ten_bit.GetTenBit(), Address::kMaxTenBitAddress);
+}
+
+// TODO(pwbug/88): Verify assert behaviour when trying to get a 7bit address out
+// of a 10bit address.
+
+// TODO(pwbug/47): Add tests to ensure the constexpr constructors fail to
+// compile with invalid addresses once no-copmile tests are set up in Pigweed.
+
+}  // namespace pw::i2c
diff --git a/pw_i2c/docs.rst b/pw_i2c/docs.rst
new file mode 100644
index 0000000..a82fb23
--- /dev/null
+++ b/pw_i2c/docs.rst
@@ -0,0 +1,20 @@
+.. _module-pw_i2c:
+
+------
+pw_i2c
+------
+
+.. warning::
+  This module is under construction, not ready for use, and the documentation
+  is incomplete.
+
+pw_i2c contains interfaces and utility functions for using I2C.
+
+Features
+========
+
+pw::i2c::Initiator
+------------------
+The common interface for initiating transactions with devices on an I2C bus.
+Other documentation sources may call this style of interface an I2C "master",
+"central" or "controller".
diff --git a/pw_i2c/public/pw_i2c/address.h b/pw_i2c/public/pw_i2c/address.h
new file mode 100644
index 0000000..e28f6e2
--- /dev/null
+++ b/pw_i2c/public/pw_i2c/address.h
@@ -0,0 +1,68 @@
+// 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.
+#pragma once
+
+#include <cstdint>
+
+namespace pw::i2c {
+
+// The Address is a helper class which represents I2C addresses which is
+// used by pw::i2c APIs.
+//
+// Example usage:
+//   constexpr Address foo = Address::SevenBit<0x42>();
+//   uint8_t foo_raw_address = foo.GetSevenBit();
+//
+//   const Address bar(0x200);  // 10 bit address.
+//   uint16_t bar_raw_address = bar.GetTenBit();
+//   // Note that bar.GetSevenBit() would assert here.
+//
+class Address {
+ public:
+  static constexpr uint8_t kMaxSevenBitAddress = (1 << 7) - 1;
+  static constexpr uint16_t kMaxTenBitAddress = (1 << 10) - 1;
+
+  // Helper constructor to ensure the address fits in the address space at
+  // compile time, skipping the construction time assert.
+  template <uint16_t kAddress>
+  static constexpr Address TenBit() {
+    static_assert(kAddress <= kMaxTenBitAddress);
+    return Address(kAddress, kAlreadyCheckedAddress);
+  }
+
+  // Helper constructor to ensure the address fits in the address space at
+  // compile time, skipping the construction time assert.
+  template <uint8_t kAddress>
+  static constexpr Address SevenBit() {
+    static_assert(kAddress <= kMaxSevenBitAddress);
+    return Address(kAddress, kAlreadyCheckedAddress);
+  }
+
+  // Precondition: The address is at least a valid ten bit address.
+  explicit Address(uint16_t address);
+
+  // Precondition: The address is a valid 7 bit address.
+  uint8_t GetSevenBit() const;
+
+  uint16_t GetTenBit() const { return address_; }
+
+ private:
+  enum AlreadyCheckedAddress { kAlreadyCheckedAddress };
+  constexpr Address(uint16_t address, AlreadyCheckedAddress)
+      : address_(address) {}
+
+  uint16_t address_;
+};
+
+}  // namespace pw::i2c
diff --git a/pw_i2c/public/pw_i2c/initiator.h b/pw_i2c/public/pw_i2c/initiator.h
new file mode 100644
index 0000000..bbcf9a5
--- /dev/null
+++ b/pw_i2c/public/pw_i2c/initiator.h
@@ -0,0 +1,192 @@
+// 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.
+#pragma once
+
+#include <cstdint>
+
+#include "pw_bytes/span.h"
+#include "pw_chrono/system_clock.h"
+#include "pw_i2c/address.h"
+#include "pw_status/status.h"
+
+namespace pw::i2c {
+
+// Base driver interface for I2C initiating I2C transactions in a thread safe
+// manner. Other documentation sources may call this style of interface an I2C
+// "master", "central" or "controller".
+//
+// The Initiator is not required to support 10bit addressing. If only 7bit
+// addressing is supported, the Initiator will assert when given an address
+// which is out of 7bit address range.
+//
+// The implementer of this pure virtual interface is responsible for ensuring
+// thread safety and enabling functionality such as initialization,
+// configuration, enabling/disabling, unsticking SDA, and detecting device
+// address registration collisions.
+class Initiator {
+ public:
+  virtual ~Initiator() = default;
+
+  // Write bytes and then read bytes as either one atomic or two independent I2C
+  // transaction. If the I2C bus is a multi-initiator bus then the implementer
+  // MUST ensure it is a single atomic I2C transaction.
+  // The signal on the bus should appear as follows:
+  // 1) Write Only:
+  //   START + I2C Address + WRITE(0) + TX_BUFFER_BYTES + STOP
+  // 2) Read Only:
+  //   START + I2C Address + READ(1) + RX_BUFFER_BYTES + STOP
+  // 3A) Write + Read (atomic):
+  //   START + I2C Address + WRITE(0) + TX_BUFFER_BYTES +
+  //   START + I2C Address + READ(1) + RX_BUFFER_BYTES + STOP
+  // 3B) Write + Read (separate):
+  //   START + I2C Address + WRITE(0) + TX_BUFFER_BYTES + STOP
+  //   START + I2C Address + READ(1) + RX_BUFFER_BYTES + STOP
+  //
+  // The timeout defines the minimum duration one may block waiting for both
+  // exclusive bus access and the completion of the I2C transaction.
+  //
+  // Preconditions:
+  // The Address must be supported by the Initiator, i.e. do not use a 10
+  //     address if the Initiator only supports 7 bit. This will assert.
+  //
+  // Returns:
+  // Ok - Success.
+  // InvalidArgument - device_address is larger than the 10 bit address space.
+  // DeadlineExceeded - Was unable to acquire exclusive Initiator access
+  //   and complete the I2C transaction in time.
+  // Unavailable - NACK condition occurred, meaning the addressed device did
+  //   not respond or was unable to process the request.
+  // FailedPrecondition - The interface is not currently initialized and/or
+  //    enabled.
+  Status WriteReadFor(Address device_address,
+                      ConstByteSpan tx_buffer,
+                      ByteSpan rx_buffer,
+                      chrono::SystemClock::duration for_at_least) {
+    return DoWriteReadFor(device_address, tx_buffer, rx_buffer, for_at_least);
+  }
+  Status WriteReadFor(Address device_address,
+                      const void* tx_buffer,
+                      size_t tx_size_bytes,
+                      void* rx_buffer,
+                      size_t rx_size_bytes,
+                      chrono::SystemClock::duration for_at_least) {
+    return WriteReadFor(
+        device_address,
+        std::span(static_cast<const std::byte*>(tx_buffer), tx_size_bytes),
+        std::span(static_cast<std::byte*>(rx_buffer), rx_size_bytes),
+        for_at_least);
+  }
+
+  // Write bytes. The signal on the bus should appear as follows:
+  //   START + I2C Address + WRITE(0) + TX_BUFFER_BYTES + STOP
+  //
+  // The timeout defines the minimum duration one may block waiting for both
+  // exclusive bus access and the completion of the I2C transaction.
+  //
+  // Preconditions:
+  // The Address must be supported by the Initiator, i.e. do not use a 10
+  //     address if the Initiator only supports 7 bit. This will assert.
+  //
+  // Returns:
+  // Ok - Success.
+  // InvalidArgument - device_address is larger than the 10 bit address space.
+  // DeadlineExceeded - Was unable to acquire exclusive Initiator access
+  //   and complete the I2C transaction in time.
+  // Unavailable - NACK condition occurred, meaning the addressed device did
+  //   not respond or was unable to process the request.
+  // FailedPrecondition - The interface is not currently initialized and/or
+  //    enabled.
+  Status WriteFor(Address device_address,
+                  ConstByteSpan tx_buffer,
+                  chrono::SystemClock::duration for_at_least) {
+    return WriteReadFor(device_address, tx_buffer, ByteSpan(), for_at_least);
+  }
+  Status WriteFor(Address device_address,
+                  const void* tx_buffer,
+                  size_t tx_size_bytes,
+                  chrono::SystemClock::duration for_at_least) {
+    return WriteFor(
+        device_address,
+        std::span(static_cast<const std::byte*>(tx_buffer), tx_size_bytes),
+        for_at_least);
+  }
+
+  // Read bytes. The signal on the bus should appear as follows:
+  //   START + I2C Address + READ(1) + RX_BUFFER_BYTES + STOP
+  //
+  // The timeout defines the minimum duration one may block waiting for both
+  // exclusive bus access and the completion of the I2C transaction.
+  //
+  // Preconditions:
+  // The Address must be supported by the Initiator, i.e. do not use a 10
+  //     address if the Initiator only supports 7 bit. This will assert.
+  //
+  // Returns:
+  // Ok - Success.
+  // InvalidArgument - device_address is larger than the 10 bit address space.
+  // DeadlineExceeded - Was unable to acquire exclusive Initiator access
+  //   and complete the I2C transaction in time.
+  // Unavailable - NACK condition occurred, meaning the addressed device did
+  //   not respond or was unable to process the request.
+  // FailedPrecondition - The interface is not currently initialized and/or
+  //    enabled.
+  Status ReadFor(Address device_address,
+                 ByteSpan rx_buffer,
+                 chrono::SystemClock::duration for_at_least) {
+    return WriteReadFor(
+        device_address, ConstByteSpan(), rx_buffer, for_at_least);
+  }
+  Status ReadFor(Address device_address,
+                 void* rx_buffer,
+                 size_t rx_size_bytes,
+                 chrono::SystemClock::duration for_at_least) {
+    return ReadFor(device_address,
+                   std::span(static_cast<std::byte*>(rx_buffer), rx_size_bytes),
+                   for_at_least);
+  }
+
+  // Probes the device for an I2C ACK after only writing the address.
+  // This is done by attempting to read a single byte from the specified device.
+  //
+  // The timeout defines the minimum duration one may block waiting for both
+  // exclusive bus access and the completion of the I2C transaction.
+  //
+  // Preconditions:
+  // The Address must be supported by the Initiator, i.e. do not use a 10
+  //     address if the Initiator only supports 7 bit. This will assert.
+  //
+  // Returns:
+  // Ok - Success.
+  // InvalidArgument - device_address is larger than the 10 bit address space.
+  // DeadlineExceeded - Was unable to acquire exclusive Initiator access
+  //   and complete the I2C transaction in time.
+  // Unavailable - NACK condition occurred, meaning the addressed device did
+  //   not respond or was unable to process the request.
+  // FailedPrecondition - The interface is not currently initialized and/or
+  //    enabled.
+  Status ProbeDeviceFor(Address device_address,
+                        chrono::SystemClock::duration for_at_least) {
+    std::byte ignored_buffer[1] = {};  // Read a dummy byte to probe.
+    return WriteReadFor(
+        device_address, ConstByteSpan(), ignored_buffer, for_at_least);
+  }
+
+ private:
+  virtual Status DoWriteReadFor(Address device_address,
+                                ConstByteSpan tx_buffer,
+                                ByteSpan rx_buffer,
+                                chrono::SystemClock::duration for_at_least) = 0;
+};
+
+}  // namespace pw::i2c
diff --git a/pw_interrupt/BUILD b/pw_interrupt/BUILD
new file mode 100644
index 0000000..b1f738b
--- /dev/null
+++ b/pw_interrupt/BUILD
@@ -0,0 +1,54 @@
+# 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.
+
+load(
+    "//pw_build:pigweed.bzl",
+    "pw_cc_library",
+)
+
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])  # Apache License 2.0
+
+# TODO(pwbug/101): Need to add support for facades/backends to Bazel.
+PW_INTERRUPT_CONTEXT_BACKEND = "//pw_interrupt_context_cortex_m:context_armv7m"
+
+pw_cc_library(
+    name = "context_facade",
+    hdrs = [
+        "public/pw_interrupt/context.h",
+    ],
+    includes = ["public"],
+    srcs = [
+        "context.cc"
+    ],
+    deps = [
+        PW_INTERRUPT_CONTEXT_BACKEND + "_headers",
+    ],
+)
+
+pw_cc_library(
+    name = "context",
+    deps = [
+        ":context_facade",
+        PW_INTERRUPT_CONTEXT_BACKEND + "_headers",
+    ],
+)
+
+pw_cc_library(
+    name = "context_backend",
+    deps = [
+       PW_INTERRUPT_CONTEXT_BACKEND,
+    ],
+)
diff --git a/pw_interrupt/BUILD.gn b/pw_interrupt/BUILD.gn
new file mode 100644
index 0000000..fb3581e
--- /dev/null
+++ b/pw_interrupt/BUILD.gn
@@ -0,0 +1,39 @@
+# 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/facade.gni")
+import("$dir_pw_docgen/docs.gni")
+import("$dir_pw_unit_test/test.gni")
+
+declare_args() {
+  # Backend for the pw_interrupt module.
+  pw_interrupt_CONTEXT_BACKEND = ""
+}
+
+config("public_include_path") {
+  include_dirs = [ "public" ]
+  visibility = [ ":*" ]
+}
+
+pw_facade("context") {
+  backend = pw_interrupt_CONTEXT_BACKEND
+  public_configs = [ ":public_include_path" ]
+  public = [ "public/pw_interrupt/context.h" ]
+}
+
+pw_doc_group("docs") {
+  sources = [ "docs.rst" ]
+}
diff --git a/pw_interrupt/docs.rst b/pw_interrupt/docs.rst
new file mode 100644
index 0000000..2f1b40b
--- /dev/null
+++ b/pw_interrupt/docs.rst
@@ -0,0 +1,12 @@
+.. _module-pw_interrupt:
+
+------------
+pw_interrupt
+------------
+Pigweed's interrupt module provides a consistent interface for to determine
+whether one is currently executing in an interrupt context (IRQ or NMI) or not.
+
+.. c:function:: bool InInterruptContext()
+  Returns true if currently executing within an interrupt service routine
+  handling an IRQ or NMI.:w!
+
diff --git a/pw_interrupt/public/pw_interrupt/context.h b/pw_interrupt/public/pw_interrupt/context.h
new file mode 100644
index 0000000..6b8bd12
--- /dev/null
+++ b/pw_interrupt/public/pw_interrupt/context.h
@@ -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.
+#pragma once
+
+namespace pw::interrupt {
+
+// Returns true if currently executing within an interrupt service routine
+// handling an IRQ or NMI.
+bool InInterruptContext();
+
+}  // namespace pw::interrupt
+
+// The backend can opt to include an inlined implementation of the following:
+//   bool InInterruptContext();
+#if __has_include("pw_interrupt_backend/context_inline.h")
+#include "pw_interrupt_backend/context_inline.h"
+#endif  // __has_include("pw_interrupt_backend/context_inline.h")
diff --git a/pw_interrupt_cortex_m/BUILD b/pw_interrupt_cortex_m/BUILD
new file mode 100644
index 0000000..02bd1f0
--- /dev/null
+++ b/pw_interrupt_cortex_m/BUILD
@@ -0,0 +1,66 @@
+# 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.
+
+load(
+    "//pw_build:pigweed.bzl",
+    "pw_cc_library",
+)
+
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])  # Apache License 2.0
+
+pw_cc_library(
+    name = "context_armv7m_headers",
+    hdrs = [
+        "public/pw_interrupt_cortex_m/context_inline.h",
+        "public_overrides/pw_interrupt_backend/context_inline.h",
+    ],
+    copts = [ "-DPW_INTERRUPT_CORTEX_M_ARMV7M=1" ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+)
+
+pw_cc_library(
+    name = "context_armv7m",
+    copts = [ "-DPW_INTERRUPT_CORTEX_M_ARMV7M=1" ],
+    deps = [
+        ":context_armv7m_headers",
+        "//pw_interrupt:context_facade",
+    ],
+)
+
+pw_cc_library(
+    name = "context_armv8m_headers",
+    hdrs = [
+        "public/pw_interrupt_cortex_m/context.h",
+        "public_overrides/pw_interrupt_backend/context_backend.h",
+    ],
+    copts = [ "-DPW_INTERRUPT_CORTEX_M_ARMV8M=1" ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+)
+
+pw_cc_library(
+    name = "context_armv8m",
+    copts = [ "-DPW_INTERRUPT_CORTEX_M_ARMV8M=1" ],
+    deps = [
+        ":context_armv8m_headers",
+        "//pw_interrupt:context_facade",
+    ],
+)
diff --git a/pw_interrupt_cortex_m/BUILD.gn b/pw_interrupt_cortex_m/BUILD.gn
new file mode 100644
index 0000000..c4fc14d
--- /dev/null
+++ b/pw_interrupt_cortex_m/BUILD.gn
@@ -0,0 +1,74 @@
+# 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/target_types.gni")
+import("$dir_pw_docgen/docs.gni")
+
+config("public_include_path") {
+  include_dirs = [ "public" ]
+  visibility = [ ":*" ]
+}
+
+config("backend_config") {
+  include_dirs = [ "public_overrides" ]
+  visibility = [ ":*" ]
+}
+
+config("armv6m") {
+  defines = [ "PW_INTERRUPT_CORTEX_M_ARMV6M=1" ]
+}
+
+config("armv7m") {
+  defines = [ "PW_INTERRUPT_CORTEX_M_ARMV7M=1" ]
+}
+
+config("armv8m") {
+  defines = [ "PW_INTERRUPT_CORTEX_M_ARMV8M=1" ]
+}
+
+_context_common = {
+  public_deps = [ "$dir_pw_interrupt:context.facade" ]
+  public_configs = [
+    ":public_include_path",
+    ":backend_config",
+  ]
+  public = [
+    "public/pw_interrupt_cortex_m/context_inline.h",
+    "public_overrides/pw_interrupt_backend/context_inline.h",
+  ]
+}
+
+# This targets provides the ARMv6-M backend for pw_interrupt's context facade.
+pw_source_set("context_armv6m") {
+  forward_variables_from(_context_common, "*")
+  public_configs += [ ":armv6m" ]
+}
+
+# This targets provides the ARMv7-M backend for pw_interrupt's context facade.
+pw_source_set("context_armv7m") {
+  forward_variables_from(_context_common, "*")
+  public_configs += [ ":armv7m" ]
+}
+
+# This targets provides the ARMv8-M backend for pw_interrupt's context facade.
+pw_source_set("context_armv8m") {
+  forward_variables_from(_context_common, "*")
+  public_configs += [ ":armv8m" ]
+}
+
+pw_doc_group("docs") {
+  sources = [ "docs.rst" ]
+}
diff --git a/pw_interrupt_cortex_m/docs.rst b/pw_interrupt_cortex_m/docs.rst
new file mode 100644
index 0000000..0cb8c81
--- /dev/null
+++ b/pw_interrupt_cortex_m/docs.rst
@@ -0,0 +1,7 @@
+.. _module-pw_interrupt_cortex_m:
+
+---------------------
+pw_interrupt_cortex_m
+---------------------
+Pigweed's interrupt Cortex-M module provides a set of architecture specific
+backends for ``pw_interrupt``.
diff --git a/pw_interrupt_cortex_m/public/pw_interrupt_cortex_m/context_inline.h b/pw_interrupt_cortex_m/public/pw_interrupt_cortex_m/context_inline.h
new file mode 100644
index 0000000..ad20f40
--- /dev/null
+++ b/pw_interrupt_cortex_m/public/pw_interrupt_cortex_m/context_inline.h
@@ -0,0 +1,35 @@
+// 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.
+#pragma once
+
+#include <cstdint>
+
+namespace pw::interrupt {
+
+#if defined(PW_INTERRUPT_CORTEX_M_ARMV6M) || \
+    defined(PW_INTERRUPT_CORTEX_M_ARMV7M) || \
+    defined(PW_INTERRUPT_CORTEX_M_ARMV8M)
+inline bool InInterruptContext() {
+  // ARMv7M Reference manual section B1.4.2 describes how the Interrupt
+  // Program Status Register (IPSR) is zero if there is no exception (interrupt)
+  // being processed.
+  uint32_t ipsr;
+  asm volatile("MRS %0, ipsr" : "=r"(ipsr));
+  return ipsr != 0;
+}
+#else
+#error "Please select an architecture specific backend."
+#endif
+
+}  // namespace pw::interrupt
diff --git a/pw_interrupt_cortex_m/public_overrides/pw_interrupt_backend/context_inline.h b/pw_interrupt_cortex_m/public_overrides/pw_interrupt_backend/context_inline.h
new file mode 100644
index 0000000..2f2f0f6
--- /dev/null
+++ b/pw_interrupt_cortex_m/public_overrides/pw_interrupt_backend/context_inline.h
@@ -0,0 +1,19 @@
+// 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.
+
+// This override header includes the main tokenized logging header and defines
+// the PW_LOG macro as the tokenized logging macro.
+#pragma once
+
+#include "pw_interrupt_cortex_m/context_inline.h"
diff --git a/pw_kvs/BUILD b/pw_kvs/BUILD
index fec76d0..82c4002 100644
--- a/pw_kvs/BUILD
+++ b/pw_kvs/BUILD
@@ -48,6 +48,7 @@
         "public/pw_kvs/flash_memory.h",
         "public/pw_kvs/format.h",
         "public/pw_kvs/io.h",
+        "public/pw_kvs/key.h",
         "public/pw_kvs/key_value_store.h",
     ],
     includes = ["public"],
@@ -101,8 +102,8 @@
         "public/pw_kvs/flash_test_partition.h",
     ],
     deps = [
-        ":pw_kvs",
         ":fake_flash",
+        ":pw_kvs",
     ],
 )
 
@@ -114,11 +115,11 @@
     hdrs = [
         "public/pw_kvs/flash_test_partition.h",
     ],
+    defines = ["PW_FLASH_TEST_ALIGNMENT=64"],
     deps = [
-        ":pw_kvs",
         ":fake_flash",
+        ":pw_kvs",
     ],
-  defines = [ "PW_FLASH_TEST_ALIGNMENT=64" ],
 )
 
 pw_cc_library(
@@ -129,11 +130,11 @@
     hdrs = [
         "public/pw_kvs/flash_test_partition.h",
     ],
+    defines = ["PW_FLASH_TEST_ALIGNMENT=256"],
     deps = [
-        ":pw_kvs",
         ":fake_flash",
+        ":pw_kvs",
     ],
-  defines = [ "PW_FLASH_TEST_ALIGNMENT=256" ],
 )
 
 pw_cc_library(
@@ -146,8 +147,8 @@
     ],
     deps = [
         ":crc16",
-        ":pw_kvs",
         ":fake_flash",
+        ":pw_kvs",
     ],
 )
 
@@ -204,6 +205,12 @@
 )
 
 pw_cc_test(
+    name = "converts_to_span_test",
+    srcs = ["converts_to_span_test.cc"],
+    deps = [":pw_kvs"],
+)
+
+pw_cc_test(
     name = "entry_test",
     srcs = [
         "entry_test.cc",
@@ -231,37 +238,49 @@
 pw_cc_test(
     name = "flash_partition_small_test",
     srcs = ["flash_partition_test.cc"],
+    defines = ["PW_FLASH_TEST_ITERATIONS=100"],
     deps = [
-        ":pw_kvs",
         ":fake_flash_small_partition",
+        ":pw_kvs",
         "//pw_log:backend",
         "//pw_unit_test",
     ],
-    defines = [ "PW_FLASH_TEST_ITERATIONS=100" ],
 )
 
 pw_cc_test(
     name = "flash_partition_64_alignment_test",
     srcs = ["flash_partition_test.cc"],
+    defines = ["PW_FLASH_TEST_ITERATIONS=100"],
     deps = [
-        ":pw_kvs",
         ":fake_flash_64_aligned_partition",
+        ":pw_kvs",
         "//pw_log:backend",
         "//pw_unit_test",
     ],
-    defines = [ "PW_FLASH_TEST_ITERATIONS=100" ],
 )
 
 pw_cc_test(
     name = "flash_partition_256_alignment_test",
     srcs = ["flash_partition_test.cc"],
+    defines = ["PW_FLASH_TEST_ITERATIONS=100"],
     deps = [
-        ":pw_kvs",
         ":fake_flash_256_aligned_partition",
+        ":pw_kvs",
         "//pw_log:backend",
         "//pw_unit_test",
     ],
-    defines = [ "PW_FLASH_TEST_ITERATIONS=100" ],
+)
+
+pw_cc_test(
+    name = "key_test",
+    srcs = [
+        "key_test.cc",
+    ],
+    deps = [
+        ":pw_kvs",
+        "//pw_status",
+        "//pw_unit_test",
+    ],
 )
 
 pw_cc_test(
@@ -286,8 +305,8 @@
     srcs = ["key_value_store_initialized_test.cc"],
     deps = [
         ":crc16",
-        ":pw_kvs",
         ":fake_flash_small_partition",
+        ":pw_kvs",
         ":test_utils",
         "//pw_checksum",
         "//pw_log:backend",
@@ -304,8 +323,8 @@
     srcs = ["key_value_store_initialized_test.cc"],
     deps = [
         ":crc16",
-        ":pw_kvs",
         ":fake_flash_64_aligned_partition",
+        ":pw_kvs",
         ":test_utils",
         "//pw_checksum",
         "//pw_log:backend",
@@ -322,8 +341,8 @@
     srcs = ["key_value_store_initialized_test.cc"],
     deps = [
         ":crc16",
-        ":pw_kvs",
         ":fake_flash_256_aligned_partition",
+        ":pw_kvs",
         ":test_utils",
         "//pw_checksum",
         "//pw_log:backend",
diff --git a/pw_kvs/BUILD.gn b/pw_kvs/BUILD.gn
index e3ef953..a8d84ab 100644
--- a/pw_kvs/BUILD.gn
+++ b/pw_kvs/BUILD.gn
@@ -14,6 +14,7 @@
 
 import("//build_overrides/pigweed.gni")
 
+import("$dir_pw_bloat/bloat.gni")
 import("$dir_pw_build/module_config.gni")
 import("$dir_pw_build/target_types.gni")
 import("$dir_pw_docgen/docs.gni")
@@ -40,6 +41,7 @@
     "public/pw_kvs/flash_test_partition.h",
     "public/pw_kvs/format.h",
     "public/pw_kvs/io.h",
+    "public/pw_kvs/key.h",
     "public/pw_kvs/key_value_store.h",
   ]
   sources = [
@@ -60,9 +62,10 @@
   ]
   public_deps = [
     dir_pw_assert,
+    dir_pw_bytes,
     dir_pw_containers,
-    dir_pw_span,
     dir_pw_status,
+    dir_pw_string,
   ]
   deps = [
     ":config",
@@ -103,7 +106,6 @@
   public_deps = [
     dir_pw_containers,
     dir_pw_kvs,
-    dir_pw_span,
     dir_pw_status,
   ]
   deps = [
@@ -123,6 +125,22 @@
   ]
 }
 
+pw_source_set("fake_flash_12_byte_partition") {
+  public_configs = [ ":public_include_path" ]
+  public = [ "public/pw_kvs/flash_test_partition.h" ]
+  sources = [ "fake_flash_test_partition.cc" ]
+  public_deps = [ ":flash_test_partition" ]
+  deps = [
+    ":fake_flash",
+    dir_pw_kvs,
+  ]
+  defines = [
+    "PW_FLASH_TEST_SECTORS=3",
+    "PW_FLASH_TEST_SECTOR_SIZE=4",
+    "PW_FLASH_TEST_ALIGNMENT=4",
+  ]
+}
+
 pw_source_set("fake_flash_64_aligned_partition") {
   public_configs = [ ":public_include_path" ]
   public = [ "public/pw_kvs/flash_test_partition.h" ]
@@ -221,6 +239,7 @@
   tests = [
     ":alignment_test",
     ":checksum_test",
+    ":converts_to_span_test",
     ":entry_test",
     ":entry_cache_test",
     ":flash_partition_small_test",
@@ -235,6 +254,7 @@
     ":key_value_store_map_test",
     ":fake_flash_test_key_value_store_test",
     ":sectors_test",
+    ":key_test",
     ":key_value_store_wear_test",
   ]
 }
@@ -253,6 +273,11 @@
   sources = [ "checksum_test.cc" ]
 }
 
+pw_test("converts_to_span_test") {
+  deps = [ ":pw_kvs" ]
+  sources = [ "converts_to_span_test.cc" ]
+}
+
 pw_test("entry_test") {
   deps = [
     ":crc16",
@@ -379,6 +404,11 @@
   sources = [ "sectors_test.cc" ]
 }
 
+pw_test("key_test") {
+  deps = [ ":pw_kvs" ]
+  sources = [ "key_test.cc" ]
+}
+
 pw_test("key_value_store_wear_test") {
   deps = [
     ":fake_flash",
@@ -391,4 +421,29 @@
 
 pw_doc_group("docs") {
   sources = [ "docs.rst" ]
+  report_deps = [ ":kvs_size" ]
+}
+
+pw_size_report("kvs_size") {
+  title = "Pigweed KVS size report"
+
+  # To see all the symbols, uncomment the following:
+  # Note: The size report RST table won't be generated when full_report = true.
+  # full_report = true
+
+  binaries = [
+    {
+      target = "size_report:with_kvs"
+      base = "size_report:base_with_only_flash"
+      label = "KeyValueStore"
+    },
+  ]
+
+  binaries += [
+    {
+      target = "size_report:base_with_only_flash"
+      base = "size_report:base"
+      label = "FlashPartition"
+    },
+  ]
 }
diff --git a/pw_kvs/alignment_test.cc b/pw_kvs/alignment_test.cc
index 536de87..fca1a7f 100644
--- a/pw_kvs/alignment_test.cc
+++ b/pw_kvs/alignment_test.cc
@@ -141,26 +141,26 @@
   AlignedWriterBuffer<32> writer(kAlignment, check_against_data);
 
   // Write values smaller than the alignment.
-  EXPECT_EQ(Status::Ok(), writer.Write(kBytes.subspan(0, 1)).status());
-  EXPECT_EQ(Status::Ok(), writer.Write(kBytes.subspan(1, 9)).status());
+  EXPECT_EQ(OkStatus(), writer.Write(kBytes.subspan(0, 1)).status());
+  EXPECT_EQ(OkStatus(), writer.Write(kBytes.subspan(1, 9)).status());
 
   // Write values larger than the alignment but smaller than the buffer.
-  EXPECT_EQ(Status::Ok(), writer.Write(kBytes.subspan(10, 11)).status());
+  EXPECT_EQ(OkStatus(), writer.Write(kBytes.subspan(10, 11)).status());
 
   // Exactly fill the remainder of the buffer.
-  EXPECT_EQ(Status::Ok(), writer.Write(kBytes.subspan(21, 11)).status());
+  EXPECT_EQ(OkStatus(), writer.Write(kBytes.subspan(21, 11)).status());
 
   // Fill the buffer more than once.
-  EXPECT_EQ(Status::Ok(), writer.Write(kBytes.subspan(32, 66)).status());
+  EXPECT_EQ(OkStatus(), writer.Write(kBytes.subspan(32, 66)).status());
 
   // Write nothing.
-  EXPECT_EQ(Status::Ok(), writer.Write(kBytes.subspan(98, 0)).status());
+  EXPECT_EQ(OkStatus(), writer.Write(kBytes.subspan(98, 0)).status());
 
   // Write the remaining data.
-  EXPECT_EQ(Status::Ok(), writer.Write(kBytes.subspan(98, 2)).status());
+  EXPECT_EQ(OkStatus(), writer.Write(kBytes.subspan(98, 2)).status());
 
   auto result = writer.Flush();
-  EXPECT_EQ(Status::Ok(), result.status());
+  EXPECT_EQ(OkStatus(), result.status());
   EXPECT_EQ(kData.size(), result.size());
 }
 
@@ -222,7 +222,7 @@
 
 TEST(AlignedWriter, Write_ReturnsTotalBytesWritten) {
   static Status return_status;
-  return_status = Status::Ok();
+  return_status = OkStatus();
 
   OutputToFunction output([](std::span<const byte> data) {
     return StatusWithSize(return_status, data.size());
@@ -232,11 +232,11 @@
 
   StatusWithSize result =
       writer.Write(std::as_bytes(std::span("12345678901"sv)));
-  EXPECT_EQ(Status::Ok(), result.status());
+  EXPECT_EQ(OkStatus(), result.status());
   EXPECT_EQ(0u, result.size());  // No writes; haven't filled buffer.
 
   result = writer.Write(std::as_bytes(std::span("2345678901"sv)));
-  EXPECT_EQ(Status::Ok(), result.status());
+  EXPECT_EQ(OkStatus(), result.status());
   EXPECT_EQ(20u, result.size());
 
   return_status = Status::PermissionDenied();
@@ -252,11 +252,11 @@
 
   AlignedWriterBuffer<4> writer(2, output);
 
-  EXPECT_EQ(Status::Ok(),
+  EXPECT_EQ(OkStatus(),
             writer.Write(std::as_bytes(std::span("12345678901"sv))).status());
 
   StatusWithSize result = writer.Flush();
-  EXPECT_EQ(Status::Ok(), result.status());
+  EXPECT_EQ(OkStatus(), result.status());
   EXPECT_EQ(12u, result.size());
 }
 
@@ -306,11 +306,11 @@
 
   InputWithErrorInjection input;
   StatusWithSize result = writer.Write(input, kData.size());
-  EXPECT_EQ(Status::Ok(), result.status());
+  EXPECT_EQ(OkStatus(), result.status());
   EXPECT_LE(result.size(), kData.size());  // May not have written it all yet.
 
   result = writer.Flush();
-  EXPECT_EQ(Status::Ok(), result.status());
+  EXPECT_EQ(OkStatus(), result.status());
   EXPECT_EQ(kData.size(), result.size());
 }
 
diff --git a/pw_kvs/checksum.cc b/pw_kvs/checksum.cc
index 08f59b8..4215406 100644
--- a/pw_kvs/checksum.cc
+++ b/pw_kvs/checksum.cc
@@ -27,7 +27,7 @@
   if (std::memcmp(state_.data(), checksum.data(), size_bytes()) != 0) {
     return Status::DataLoss();
   }
-  return Status::Ok();
+  return OkStatus();
 }
 
 }  // namespace pw::kvs
diff --git a/pw_kvs/checksum_test.cc b/pw_kvs/checksum_test.cc
index 4fffae0..17e8f01 100644
--- a/pw_kvs/checksum_test.cc
+++ b/pw_kvs/checksum_test.cc
@@ -32,8 +32,7 @@
   ChecksumAlgorithm& algo = crc16_algo;
 
   algo.Update(kString.data(), kString.size());
-  EXPECT_EQ(Status::Ok(),
-            algo.Verify(std::as_bytes(std::span(&kStringCrc, 1))));
+  EXPECT_EQ(OkStatus(), algo.Verify(std::as_bytes(std::span(&kStringCrc, 1))));
 }
 
 TEST(Checksum, Verify_Failure) {
@@ -56,7 +55,7 @@
 
   algo.Update(std::as_bytes(std::span(kString)));
 
-  EXPECT_EQ(Status::Ok(), algo.Verify(crc));
+  EXPECT_EQ(OkStatus(), algo.Verify(crc));
 }
 
 TEST(Checksum, Reset) {
@@ -72,20 +71,20 @@
 TEST(IgnoreChecksum, NeverUpdate_VerifyWithoutData) {
   IgnoreChecksum checksum;
 
-  EXPECT_EQ(Status::Ok(), checksum.Verify({}));
+  EXPECT_EQ(OkStatus(), checksum.Verify({}));
 }
 
 TEST(IgnoreChecksum, NeverUpdate_VerifyWithData) {
   IgnoreChecksum checksum;
 
-  EXPECT_EQ(Status::Ok(), checksum.Verify(std::as_bytes(std::span(kString))));
+  EXPECT_EQ(OkStatus(), checksum.Verify(std::as_bytes(std::span(kString))));
 }
 
 TEST(IgnoreChecksum, AfterUpdate_Verify) {
   IgnoreChecksum checksum;
 
   checksum.Update(std::as_bytes(std::span(kString)));
-  EXPECT_EQ(Status::Ok(), checksum.Verify({}));
+  EXPECT_EQ(OkStatus(), checksum.Verify({}));
 }
 
 constexpr size_t kAlignment = 10;
@@ -144,7 +143,7 @@
   EXPECT_EQ(std::string_view(reinterpret_cast<const char*>(state.data()),
                              state.size()),
             kData);
-  EXPECT_EQ(Status::Ok(), checksum.Verify(kBytes));
+  EXPECT_EQ(OkStatus(), checksum.Verify(kBytes));
 }
 
 }  // namespace
diff --git a/pw_kvs/converts_to_span_test.cc b/pw_kvs/converts_to_span_test.cc
new file mode 100644
index 0000000..68fc5b2
--- /dev/null
+++ b/pw_kvs/converts_to_span_test.cc
@@ -0,0 +1,298 @@
+// 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.
+
+#include <array>
+#include <cstddef>
+#include <span>
+#include <string_view>
+#include <vector>
+
+#include "gtest/gtest.h"
+#include "pw_kvs/internal/span_traits.h"
+
+namespace pw::kvs {
+namespace {
+
+using internal::make_span;
+using std::byte;
+using std::dynamic_extent;
+using std::span;
+
+// Test that the ConvertsToSpan trait correctly idenitifies types that convert
+// to std::span.
+
+// Basic types should not convert to span.
+struct Foo {};
+
+static_assert(!ConvertsToSpan<Foo>());
+static_assert(!ConvertsToSpan<int>());
+static_assert(!ConvertsToSpan<void>());
+static_assert(!ConvertsToSpan<byte>());
+static_assert(!ConvertsToSpan<byte*>());
+
+// Arrays without an extent are just pointers -- these should not convert.
+static_assert(!ConvertsToSpan<bool[]>());
+static_assert(!ConvertsToSpan<const int[]>());
+static_assert(!ConvertsToSpan<bool (&)[]>());
+static_assert(!ConvertsToSpan<const int (&)[]>());
+static_assert(!ConvertsToSpan<bool(&&)[]>());
+static_assert(!ConvertsToSpan<const int(&&)[]>());
+
+// C arrays convert to span.
+static_assert(ConvertsToSpan<std::array<int, 5>>());
+static_assert(ConvertsToSpan<decltype("Hello!")>());
+
+static_assert(ConvertsToSpan<bool[1]>());
+static_assert(ConvertsToSpan<char[35]>());
+static_assert(ConvertsToSpan<const int[35]>());
+
+static_assert(ConvertsToSpan<bool (&)[1]>());
+static_assert(ConvertsToSpan<char (&)[35]>());
+static_assert(ConvertsToSpan<const int (&)[35]>());
+
+static_assert(ConvertsToSpan<bool(&&)[1]>());
+static_assert(ConvertsToSpan<bool(&&)[1]>());
+static_assert(ConvertsToSpan<char(&&)[35]>());
+static_assert(ConvertsToSpan<const int(&&)[35]>());
+
+// Container types convert to span.
+struct FakeContainer {
+  const char* data() const { return nullptr; }
+  size_t size() const { return 0; }
+};
+
+static_assert(ConvertsToSpan<FakeContainer>());
+static_assert(ConvertsToSpan<FakeContainer&>());
+static_assert(ConvertsToSpan<FakeContainer&&>());
+static_assert(ConvertsToSpan<const FakeContainer>());
+static_assert(ConvertsToSpan<const FakeContainer&>());
+static_assert(ConvertsToSpan<const FakeContainer&&>());
+
+static_assert(ConvertsToSpan<std::string_view>());
+static_assert(ConvertsToSpan<std::string_view&>());
+static_assert(ConvertsToSpan<std::string_view&&>());
+
+static_assert(ConvertsToSpan<const std::string_view>());
+static_assert(ConvertsToSpan<const std::string_view&>());
+static_assert(ConvertsToSpan<const std::string_view&&>());
+
+// Spans should also convert to span.
+static_assert(ConvertsToSpan<std::span<int>>());
+static_assert(ConvertsToSpan<std::span<byte>>());
+static_assert(ConvertsToSpan<std::span<const int*>>());
+static_assert(ConvertsToSpan<std::span<bool>&&>());
+static_assert(ConvertsToSpan<const std::span<bool>&>());
+static_assert(ConvertsToSpan<std::span<bool>&&>());
+
+// These tests for the make_span function were copied from Chromium:
+// https://chromium.googlesource.com/chromium/src/+/master/base/containers/span_unittest.cc
+
+TEST(SpanTest, MakeSpanFromDataAndSize) {
+  int* nullint = nullptr;
+  auto empty_span = make_span(nullint, 0);
+  EXPECT_TRUE(empty_span.empty());
+  EXPECT_EQ(nullptr, empty_span.data());
+  std::vector<int> vector = {1, 1, 2, 3, 5, 8};
+  span<int> expected_span(vector.data(), vector.size());
+  auto made_span = make_span(vector.data(), vector.size());
+  EXPECT_EQ(expected_span.data(), made_span.data());
+  EXPECT_EQ(expected_span.size(), made_span.size());
+  static_assert(decltype(made_span)::extent == dynamic_extent, "");
+  static_assert(
+      std::is_same<decltype(expected_span), decltype(made_span)>::value,
+      "the type of made_span differs from expected_span!");
+}
+
+TEST(SpanTest, MakeSpanFromPointerPair) {
+  int* nullint = nullptr;
+  auto empty_span = make_span(nullint, nullint);
+  EXPECT_TRUE(empty_span.empty());
+  EXPECT_EQ(nullptr, empty_span.data());
+  std::vector<int> vector = {1, 1, 2, 3, 5, 8};
+  span<int> expected_span(vector.data(), vector.size());
+  auto made_span = make_span(vector.data(), vector.data() + vector.size());
+  EXPECT_EQ(expected_span.data(), made_span.data());
+  EXPECT_EQ(expected_span.size(), made_span.size());
+  static_assert(decltype(made_span)::extent == dynamic_extent, "");
+  static_assert(
+      std::is_same<decltype(expected_span), decltype(made_span)>::value,
+      "the type of made_span differs from expected_span!");
+}
+
+TEST(SpanTest, MakeSpanFromConstexprArray) {
+  static constexpr int kArray[] = {1, 2, 3, 4, 5};
+  constexpr span<const int, 5> expected_span(kArray);
+  constexpr auto made_span = make_span(kArray);
+  EXPECT_EQ(expected_span.data(), made_span.data());
+  EXPECT_EQ(expected_span.size(), made_span.size());
+  static_assert(decltype(made_span)::extent == 5, "");
+  static_assert(
+      std::is_same<decltype(expected_span), decltype(made_span)>::value,
+      "the type of made_span differs from expected_span!");
+}
+
+TEST(SpanTest, MakeSpanFromStdArray) {
+  const std::array<int, 5> kArray = {{1, 2, 3, 4, 5}};
+  span<const int, 5> expected_span(kArray);
+  auto made_span = make_span(kArray);
+  EXPECT_EQ(expected_span.data(), made_span.data());
+  EXPECT_EQ(expected_span.size(), made_span.size());
+  static_assert(decltype(made_span)::extent == 5, "");
+  static_assert(
+      std::is_same<decltype(expected_span), decltype(made_span)>::value,
+      "the type of made_span differs from expected_span!");
+}
+
+TEST(SpanTest, MakeSpanFromConstContainer) {
+  const std::vector<int> vector = {-1, -2, -3, -4, -5};
+  span<const int> expected_span(vector);
+  auto made_span = make_span(vector);
+  EXPECT_EQ(expected_span.data(), made_span.data());
+  EXPECT_EQ(expected_span.size(), made_span.size());
+  static_assert(decltype(made_span)::extent == dynamic_extent, "");
+  static_assert(
+      std::is_same<decltype(expected_span), decltype(made_span)>::value,
+      "the type of made_span differs from expected_span!");
+}
+
+#if 0  // Not currently working with fixed extent spans.
+
+TEST(SpanTest, MakeStaticSpanFromConstContainer) {
+  const std::vector<int> vector = {-1, -2, -3, -4, -5};
+  span<const int, 5> expected_span(vector.data(), vector.size());
+  auto made_span = make_span<5>(vector);
+  EXPECT_EQ(expected_span.data(), made_span.data());
+  EXPECT_EQ(expected_span.size(), made_span.size());
+  static_assert(decltype(made_span)::extent == 5, "");
+  static_assert(
+      std::is_same<decltype(expected_span), decltype(made_span)>::value,
+      "the type of made_span differs from expected_span!");
+}
+
+#endif  // 0
+
+TEST(SpanTest, MakeSpanFromContainer) {
+  std::vector<int> vector = {-1, -2, -3, -4, -5};
+  span<int> expected_span(vector);
+  auto made_span = make_span(vector);
+  EXPECT_EQ(expected_span.data(), made_span.data());
+  EXPECT_EQ(expected_span.size(), made_span.size());
+  static_assert(decltype(made_span)::extent == dynamic_extent, "");
+  static_assert(
+      std::is_same<decltype(expected_span), decltype(made_span)>::value,
+      "the type of made_span differs from expected_span!");
+}
+
+#if 0  // Not currently working with fixed extent spans.
+
+TEST(SpanTest, MakeStaticSpanFromContainer) {
+  std::vector<int> vector = {-1, -2, -3, -4, -5};
+  span<int, 5> expected_span(vector.data(), vector.size());
+  auto made_span = make_span<5>(vector);
+  EXPECT_EQ(expected_span.data(), make_span<5>(vector).data());
+  EXPECT_EQ(expected_span.size(), make_span<5>(vector).size());
+  static_assert(decltype(make_span<5>(vector))::extent == 5, "");
+  static_assert(
+      std::is_same<decltype(expected_span), decltype(made_span)>::value,
+      "the type of made_span differs from expected_span!");
+}
+
+TEST(SpanTest, MakeStaticSpanFromConstexprContainer) {
+  constexpr StringPiece str = "Hello, World";
+  constexpr auto made_span = make_span<12>(str);
+  static_assert(str.data() == made_span.data(), "Error: data() does not match");
+  static_assert(str.size() == made_span.size(), "Error: size() does not match");
+  static_assert(std::is_same<decltype(str)::value_type,
+                             decltype(made_span)::value_type>::value,
+                "Error: value_type does not match");
+  static_assert(str.size() == decltype(made_span)::extent,
+                "Error: extent does not match");
+}
+
+#endif  // 0
+
+TEST(SpanTest, MakeSpanFromRValueContainer) {
+  std::vector<int> vector = {-1, -2, -3, -4, -5};
+  span<const int> expected_span(vector);
+  // Note: While static_cast<T&&>(foo) is effectively just a fancy spelling of
+  // std::move(foo), make_span does not actually take ownership of the passed in
+  // container. Writing it this way makes it more obvious that we simply care
+  // about the right behavour when passing rvalues.
+  auto made_span = make_span(static_cast<std::vector<int>&&>(vector));
+  EXPECT_EQ(expected_span.data(), made_span.data());
+  EXPECT_EQ(expected_span.size(), made_span.size());
+  static_assert(decltype(made_span)::extent == dynamic_extent, "");
+  static_assert(
+      std::is_same<decltype(expected_span), decltype(made_span)>::value,
+      "the type of made_span differs from expected_span!");
+}
+
+#if 0  // Not currently working with fixed extent spans.
+
+TEST(SpanTest, MakeStaticSpanFromRValueContainer) {
+  std::vector<int> vector = {-1, -2, -3, -4, -5};
+  span<const int, 5> expected_span(vector.data(), vector.size());
+  // Note: While static_cast<T&&>(foo) is effectively just a fancy spelling of
+  // std::move(foo), make_span does not actually take ownership of the passed in
+  // container. Writing it this way makes it more obvious that we simply care
+  // about the right behavour when passing rvalues.
+  auto made_span = make_span<5>(static_cast<std::vector<int>&&>(vector));
+  EXPECT_EQ(expected_span.data(), made_span.data());
+  EXPECT_EQ(expected_span.size(), made_span.size());
+  static_assert(decltype(made_span)::extent == 5, "");
+  static_assert(
+      std::is_same<decltype(expected_span), decltype(made_span)>::value,
+      "the type of made_span differs from expected_span!");
+}
+
+#endif  // 0
+
+TEST(SpanTest, MakeSpanFromDynamicSpan) {
+  static constexpr int kArray[] = {1, 2, 3, 4, 5};
+  constexpr span<const int> expected_span(kArray);
+  constexpr auto made_span = make_span(expected_span);
+  static_assert(std::is_same<decltype(expected_span)::element_type,
+                             decltype(made_span)::element_type>::value,
+                "make_span(span) should have the same element_type as span");
+  static_assert(expected_span.data() == made_span.data(),
+                "make_span(span) should have the same data() as span");
+  static_assert(expected_span.size() == made_span.size(),
+                "make_span(span) should have the same size() as span");
+  static_assert(decltype(made_span)::extent == decltype(expected_span)::extent,
+                "make_span(span) should have the same extent as span");
+  static_assert(
+      std::is_same<decltype(expected_span), decltype(made_span)>::value,
+      "the type of made_span differs from expected_span!");
+}
+
+TEST(SpanTest, MakeSpanFromStaticSpan) {
+  static constexpr int kArray[] = {1, 2, 3, 4, 5};
+  constexpr span<const int, 5> expected_span(kArray);
+  constexpr auto made_span = make_span(expected_span);
+  static_assert(std::is_same<decltype(expected_span)::element_type,
+                             decltype(made_span)::element_type>::value,
+                "make_span(span) should have the same element_type as span");
+  static_assert(expected_span.data() == made_span.data(),
+                "make_span(span) should have the same data() as span");
+  static_assert(expected_span.size() == made_span.size(),
+                "make_span(span) should have the same size() as span");
+  static_assert(decltype(made_span)::extent == decltype(expected_span)::extent,
+                "make_span(span) should have the same extent as span");
+  static_assert(
+      std::is_same<decltype(expected_span), decltype(made_span)>::value,
+      "the type of made_span differs from expected_span!");
+}
+
+}  // namespace
+}  // namespace pw::kvs
diff --git a/pw_kvs/docs.rst b/pw_kvs/docs.rst
index b958c63..7504566 100644
--- a/pw_kvs/docs.rst
+++ b/pw_kvs/docs.rst
@@ -83,6 +83,13 @@
 to partition overhead (encryption, wear tracking, etc) or larger due to
 combining raw sectors into larger logical sectors.
 
+Size report
+-----------
+The following size report showcases the memory usage of the KVS and
+FlashPartition.
+
+.. include:: kvs_size
+
 Storage Allocation
 ------------------
 
diff --git a/pw_kvs/entry.cc b/pw_kvs/entry.cc
index 658c7df..fc01b0c 100644
--- a/pw_kvs/entry.cc
+++ b/pw_kvs/entry.cc
@@ -34,7 +34,6 @@
     std::max(kMaxFlashAlignment, 4 * Entry::kMinAlignmentBytes);
 
 using std::byte;
-using std::string_view;
 
 Status Entry::Read(FlashPartition& partition,
                    Address address,
@@ -59,7 +58,7 @@
   }
 
   *entry = Entry(&partition, address, *format, header);
-  return Status::Ok();
+  return OkStatus();
 }
 
 Status Entry::ReadKey(FlashPartition& partition,
@@ -77,7 +76,7 @@
 Entry::Entry(FlashPartition& partition,
              Address address,
              const EntryFormat& format,
-             string_view key,
+             Key key,
              std::span<const byte> value,
              uint16_t value_size_bytes,
              uint32_t transaction_id)
@@ -99,8 +98,7 @@
   }
 }
 
-StatusWithSize Entry::Write(string_view key,
-                            std::span<const byte> value) const {
+StatusWithSize Entry::Write(Key key, std::span<const byte> value) const {
   FlashPartition::Output flash(partition(), address_);
   return AlignedWrite<kWriteBufferSize>(flash,
                                         alignment_bytes(),
@@ -185,13 +183,12 @@
     value_ptr += read_size;
   }
 
-  return Status::Ok();
+  return OkStatus();
 }
 
-Status Entry::VerifyChecksum(string_view key,
-                             std::span<const byte> value) const {
+Status Entry::VerifyChecksum(Key key, std::span<const byte> value) const {
   if (checksum_algo_ == nullptr) {
-    return header_.checksum == 0 ? Status::Ok() : Status::DataLoss();
+    return header_.checksum == 0 ? OkStatus() : Status::DataLoss();
   }
   CalculateChecksum(key, value);
   return checksum_algo_->Verify(checksum_bytes());
@@ -221,7 +218,7 @@
   }
 
   if (checksum_algo_ == nullptr) {
-    return header_.checksum == 0 ? Status::Ok() : Status::DataLoss();
+    return header_.checksum == 0 ? OkStatus() : Status::DataLoss();
   }
 
   // The checksum is calculated as if the header's checksum field were 0.
@@ -261,7 +258,7 @@
 }
 
 std::span<const byte> Entry::CalculateChecksum(
-    const string_view key, std::span<const byte> value) const {
+    const Key key, std::span<const byte> value) const {
   checksum_algo_->Reset();
 
   {
@@ -282,7 +279,7 @@
   header_.checksum = 0;
 
   if (checksum_algo_ == nullptr) {
-    return Status::Ok();
+    return OkStatus();
   }
 
   checksum_algo_->Reset();
@@ -308,7 +305,7 @@
   std::memcpy(&header_.checksum,
               checksum.data(),
               std::min(checksum.size(), sizeof(header_.checksum)));
-  return Status::Ok();
+  return OkStatus();
 }
 
 void Entry::AddPaddingBytesToChecksum() const {
diff --git a/pw_kvs/entry_cache.cc b/pw_kvs/entry_cache.cc
index 9fa93a2..e71d42e 100644
--- a/pw_kvs/entry_cache.cc
+++ b/pw_kvs/entry_cache.cc
@@ -28,8 +28,6 @@
 namespace pw::kvs::internal {
 namespace {
 
-using std::string_view;
-
 constexpr FlashPartition::Address kNoAddress = FlashPartition::Address(-1);
 
 }  // namespace
@@ -64,7 +62,7 @@
 StatusWithSize EntryCache::Find(FlashPartition& partition,
                                 const Sectors& sectors,
                                 const EntryFormats& formats,
-                                string_view key,
+                                Key key,
                                 EntryMetadata* metadata) const {
   const uint32_t hash = internal::Hash(key);
   Entry::KeyBuffer key_buffer;
@@ -73,13 +71,13 @@
   for (size_t i = 0; i < descriptors_.size(); ++i) {
     if (descriptors_[i].key_hash == hash) {
       bool key_found = false;
-      string_view read_key;
+      Key read_key;
 
       for (Address address : addresses(i)) {
         Status read_result =
             Entry::ReadKey(partition, address, key.size(), key_buffer.data());
 
-        read_key = string_view(key_buffer.data(), key.size());
+        read_key = Key(key_buffer.data(), key.size());
 
         if (read_result.ok() && hash == internal::Hash(read_key)) {
           key_found = true;
@@ -109,7 +107,7 @@
       } else if (key == read_key) {
         PW_LOG_DEBUG("Found match for key hash 0x%08" PRIx32, hash);
         *metadata = EntryMetadata(descriptors_[i], addresses(i));
-        return StatusWithSize::Ok(error_val);
+        return StatusWithSize(error_val);
       } else {
         PW_LOG_WARN("Found key hash collision for 0x%08" PRIx32, hash);
         return StatusWithSize::AlreadyExists(error_val);
@@ -144,14 +142,14 @@
       return Status::ResourceExhausted();
     }
     AddNew(descriptor, address);
-    return Status::Ok();
+    return OkStatus();
   }
 
   // Existing entry is old; replace the existing entry with the new one.
   if (descriptor.transaction_id > descriptors_[index].transaction_id) {
     descriptors_[index] = descriptor;
     ResetAddresses(index, address);
-    return Status::Ok();
+    return OkStatus();
   }
 
   // If the entries have a duplicate transaction ID, add the new (redundant)
@@ -179,7 +177,7 @@
   } else {
     PW_LOG_DEBUG("Found stale entry when appending; ignoring");
   }
-  return Status::Ok();
+  return OkStatus();
 }
 
 size_t EntryCache::present_entries() const {
diff --git a/pw_kvs/entry_cache_test.cc b/pw_kvs/entry_cache_test.cc
index 7d6a25f..bc49e19 100644
--- a/pw_kvs/entry_cache_test.cc
+++ b/pw_kvs/entry_cache_test.cc
@@ -83,7 +83,7 @@
 }
 
 TEST_F(EmptyEntryCache, AddNewOrUpdateExisting_NewEntry) {
-  ASSERT_EQ(Status::Ok(),
+  ASSERT_EQ(OkStatus(),
             entries_.AddNewOrUpdateExisting(kDescriptor, 1000, 2000));
 
   EXPECT_EQ(1u, entries_.present_entries());
@@ -98,7 +98,7 @@
 TEST_F(EmptyEntryCache, AddNewOrUpdateExisting_NewEntry_Full) {
   for (uint32_t i = 0; i < kMaxEntries; ++i) {
     ASSERT_EQ(  // Fill up the cache
-        Status::Ok(),
+        OkStatus(),
         entries_.AddNewOrUpdateExisting({i, i, EntryState::kValid}, i, 1));
   }
   ASSERT_EQ(kMaxEntries, entries_.total_entries());
@@ -113,7 +113,7 @@
   KeyDescriptor kd = kDescriptor;
   kd.transaction_id += 3;
 
-  ASSERT_EQ(Status::Ok(), entries_.AddNewOrUpdateExisting(kd, 3210, 2000));
+  ASSERT_EQ(OkStatus(), entries_.AddNewOrUpdateExisting(kd, 3210, 2000));
 
   EXPECT_EQ(1u, entries_.present_entries());
 
@@ -125,15 +125,15 @@
 }
 
 TEST_F(EmptyEntryCache, AddNewOrUpdateExisting_AddDuplicateEntry) {
-  ASSERT_EQ(Status::Ok(),
+  ASSERT_EQ(OkStatus(),
             entries_.AddNewOrUpdateExisting(kDescriptor, 1000, 2000));
-  ASSERT_EQ(Status::Ok(),
+  ASSERT_EQ(OkStatus(),
             entries_.AddNewOrUpdateExisting(kDescriptor, 3000, 2000));
-  ASSERT_EQ(Status::Ok(),
+  ASSERT_EQ(OkStatus(),
             entries_.AddNewOrUpdateExisting(kDescriptor, 7000, 2000));
 
   // Duplicates beyond the redundancy are ignored.
-  ASSERT_EQ(Status::Ok(),
+  ASSERT_EQ(OkStatus(),
             entries_.AddNewOrUpdateExisting(kDescriptor, 9000, 2000));
 
   EXPECT_EQ(1u, entries_.present_entries());
@@ -150,7 +150,7 @@
 }
 
 TEST_F(EmptyEntryCache, AddNewOrUpdateExisting_AddDuplicateEntryInSameSector) {
-  ASSERT_EQ(Status::Ok(),
+  ASSERT_EQ(OkStatus(),
             entries_.AddNewOrUpdateExisting(kDescriptor, 1000, 1000));
   EXPECT_EQ(Status::DataLoss(),
             entries_.AddNewOrUpdateExisting(kDescriptor, 1950, 1000));
@@ -282,10 +282,9 @@
 
   void CheckForCorruptSectors(SectorDescriptor* sector1 = nullptr,
                               SectorDescriptor* sector2 = nullptr) {
-    for (auto& sector : sectors_) {
+    for (const auto& sector : sectors_) {
       bool expect_corrupt =
-          (&sector == sector1 || &sector == sector2) ? true : false;
-
+          ((sector1 && &sector == sector1) || (sector2 && &sector == sector2));
       EXPECT_EQ(expect_corrupt, sector.corrupt());
     }
   }
@@ -320,7 +319,7 @@
   StatusWithSize result =
       entries_.Find(partition_, sectors_, format_, kTheKey, &metadata);
 
-  ASSERT_EQ(Status::Ok(), result.status());
+  ASSERT_EQ(OkStatus(), result.status());
   EXPECT_EQ(0u, result.size());
   EXPECT_EQ(Hash(kTheKey), metadata.hash());
   EXPECT_EQ(EntryState::kValid, metadata.state());
@@ -337,7 +336,7 @@
   StatusWithSize result =
       entries_.Find(partition_, sectors_, format_, kTheKey, &metadata);
 
-  ASSERT_EQ(Status::Ok(), result.status());
+  ASSERT_EQ(OkStatus(), result.status());
   EXPECT_EQ(1u, result.size());
   EXPECT_EQ(Hash(kTheKey), metadata.hash());
   EXPECT_EQ(EntryState::kValid, metadata.state());
@@ -364,7 +363,7 @@
   StatusWithSize result =
       entries_.Find(partition_, sectors_, format_, "delorted", &metadata);
 
-  ASSERT_EQ(Status::Ok(), result.status());
+  ASSERT_EQ(OkStatus(), result.status());
   EXPECT_EQ(0u, result.size());
   EXPECT_EQ(Hash("delorted"), metadata.hash());
   EXPECT_EQ(EntryState::kDeleted, metadata.state());
diff --git a/pw_kvs/entry_test.cc b/pw_kvs/entry_test.cc
index 8ac1d1a..f9496e3 100644
--- a/pw_kvs/entry_test.cc
+++ b/pw_kvs/entry_test.cc
@@ -116,7 +116,7 @@
 class ValidEntryInFlash : public ::testing::Test {
  protected:
   ValidEntryInFlash() : flash_(kEntry1), partition_(&flash_) {
-    EXPECT_EQ(Status::Ok(), Entry::Read(partition_, 0, kFormats, &entry_));
+    EXPECT_EQ(OkStatus(), Entry::Read(partition_, 0, kFormats, &entry_));
   }
 
   FakeFlashMemoryBuffer<1024, 4> flash_;
@@ -125,8 +125,8 @@
 };
 
 TEST_F(ValidEntryInFlash, PassesChecksumVerification) {
-  EXPECT_EQ(Status::Ok(), entry_.VerifyChecksumInFlash());
-  EXPECT_EQ(Status::Ok(), entry_.VerifyChecksum("key45", kValue1));
+  EXPECT_EQ(OkStatus(), entry_.VerifyChecksumInFlash());
+  EXPECT_EQ(OkStatus(), entry_.VerifyChecksum("key45", kValue1));
 }
 
 TEST_F(ValidEntryInFlash, HeaderContents) {
@@ -141,7 +141,7 @@
   Entry::KeyBuffer key = {};
   auto result = entry_.ReadKey(key);
 
-  ASSERT_EQ(Status::Ok(), result.status());
+  ASSERT_EQ(OkStatus(), result.status());
   EXPECT_EQ(result.size(), entry_.key_length());
   EXPECT_STREQ(key.data(), "key45");
 }
@@ -150,7 +150,7 @@
   char value[32] = {};
   auto result = entry_.ReadValue(std::as_writable_bytes(std::span(value)));
 
-  ASSERT_EQ(Status::Ok(), result.status());
+  ASSERT_EQ(OkStatus(), result.status());
   EXPECT_EQ(result.size(), entry_.value_size());
   EXPECT_STREQ(value, "VALUE!");
 }
@@ -170,7 +170,7 @@
   char value[3] = {};
   auto result = entry_.ReadValue(std::as_writable_bytes(std::span(value)), 3);
 
-  ASSERT_EQ(Status::Ok(), result.status());
+  ASSERT_EQ(OkStatus(), result.status());
   EXPECT_EQ(3u, result.size());
   EXPECT_EQ(value[0], 'U');
   EXPECT_EQ(value[1], 'E');
@@ -190,7 +190,7 @@
   char value[16] = {'?'};
   auto result = entry_.ReadValue(std::as_writable_bytes(std::span(value)), 6);
 
-  ASSERT_EQ(Status::Ok(), result.status());
+  ASSERT_EQ(OkStatus(), result.status());
   EXPECT_EQ(0u, result.size());
   EXPECT_EQ(value[0], '?');
 }
@@ -211,7 +211,7 @@
       partition, 64, kFormatWithChecksum, "key45", kValue1, kTransactionId1);
 
   auto result = entry.Write("key45", kValue1);
-  EXPECT_EQ(Status::Ok(), result.status());
+  EXPECT_EQ(OkStatus(), result.status());
   EXPECT_EQ(32u, result.size());
   EXPECT_EQ(std::memcmp(&flash.buffer()[64], kEntry1.data(), kEntry1.size()),
             0);
@@ -233,7 +233,7 @@
  protected:
   TombstoneEntryInFlash()
       : flash_(bytes::Concat(kHeader2, kKeyAndPadding2)), partition_(&flash_) {
-    EXPECT_EQ(Status::Ok(), Entry::Read(partition_, 0, kFormats, &entry_));
+    EXPECT_EQ(OkStatus(), Entry::Read(partition_, 0, kFormats, &entry_));
   }
 
   FakeFlashMemoryBuffer<1024, 4> flash_;
@@ -242,8 +242,8 @@
 };
 
 TEST_F(TombstoneEntryInFlash, PassesChecksumVerification) {
-  EXPECT_EQ(Status::Ok(), entry_.VerifyChecksumInFlash());
-  EXPECT_EQ(Status::Ok(), entry_.VerifyChecksum("K", {}));
+  EXPECT_EQ(OkStatus(), entry_.VerifyChecksumInFlash());
+  EXPECT_EQ(OkStatus(), entry_.VerifyChecksum("K", {}));
 }
 
 TEST_F(TombstoneEntryInFlash, HeaderContents) {
@@ -258,7 +258,7 @@
   Entry::KeyBuffer key = {};
   auto result = entry_.ReadKey(key);
 
-  ASSERT_EQ(Status::Ok(), result.status());
+  ASSERT_EQ(OkStatus(), result.status());
   EXPECT_EQ(result.size(), entry_.key_length());
   EXPECT_STREQ(key.data(), "K");
 }
@@ -267,7 +267,7 @@
   char value[32] = {};
   auto result = entry_.ReadValue(std::as_writable_bytes(std::span(value)));
 
-  ASSERT_EQ(Status::Ok(), result.status());
+  ASSERT_EQ(OkStatus(), result.status());
   EXPECT_EQ(0u, result.size());
 }
 
@@ -280,7 +280,7 @@
       Entry::Tombstone(partition, 16, kFormatWithChecksum, "K", 0x03020100);
 
   auto result = entry.Write("K", {});
-  EXPECT_EQ(Status::Ok(), result.status());
+  EXPECT_EQ(OkStatus(), result.status());
   EXPECT_EQ(32u, result.size());
   EXPECT_EQ(std::memcmp(&flash.buffer()[16],
                         bytes::Concat(kHeader2, kKeyAndPadding2).data(),
@@ -296,15 +296,15 @@
   const EntryFormat format{kMagicWithChecksum, nullptr};
   const internal::EntryFormats formats(format);
 
-  ASSERT_EQ(Status::Ok(), Entry::Read(partition, 0, formats, &entry));
+  ASSERT_EQ(OkStatus(), Entry::Read(partition, 0, formats, &entry));
 
   EXPECT_EQ(Status::DataLoss(), entry.VerifyChecksumInFlash());
   EXPECT_EQ(Status::DataLoss(), entry.VerifyChecksum({}, {}));
 
   std::memset(&flash.buffer()[4], 0, 4);  // set the checksum field to 0
-  ASSERT_EQ(Status::Ok(), Entry::Read(partition, 0, formats, &entry));
-  EXPECT_EQ(Status::Ok(), entry.VerifyChecksumInFlash());
-  EXPECT_EQ(Status::Ok(), entry.VerifyChecksum({}, {}));
+  ASSERT_EQ(OkStatus(), Entry::Read(partition, 0, formats, &entry));
+  EXPECT_EQ(OkStatus(), entry.VerifyChecksumInFlash());
+  EXPECT_EQ(OkStatus(), entry.VerifyChecksum({}, {}));
 }
 
 TEST(Entry, Checksum_ChecksPadding) {
@@ -312,20 +312,20 @@
       bytes::Concat(kHeader1, kKey1, kValue1, bytes::String("\0\0\0\0\1")));
   FlashPartition partition(&flash);
   Entry entry;
-  ASSERT_EQ(Status::Ok(), Entry::Read(partition, 0, kFormats, &entry));
+  ASSERT_EQ(OkStatus(), Entry::Read(partition, 0, kFormats, &entry));
 
   // Last byte in padding is a 1; should fail.
   EXPECT_EQ(Status::DataLoss(), entry.VerifyChecksumInFlash());
 
   // The in-memory verification fills in 0s for the padding.
-  EXPECT_EQ(Status::Ok(), entry.VerifyChecksum("key45", kValue1));
+  EXPECT_EQ(OkStatus(), entry.VerifyChecksum("key45", kValue1));
 
   flash.buffer()[kEntry1.size() - 1] = byte{0};
-  EXPECT_EQ(Status::Ok(), entry.VerifyChecksumInFlash());
+  EXPECT_EQ(OkStatus(), entry.VerifyChecksumInFlash());
 }
 
 TEST_F(ValidEntryInFlash, Update_SameFormat_TransactionIdIsUpdated) {
-  ASSERT_EQ(Status::Ok(),
+  ASSERT_EQ(OkStatus(),
             entry_.Update(kFormatWithChecksum, kTransactionId1 + 3));
 
   EXPECT_EQ(kFormatWithChecksum.magic, entry_.magic());
@@ -336,7 +336,7 @@
 
 TEST_F(ValidEntryInFlash,
        Update_DifferentFormat_MagicAndTransactionIdAreUpdated) {
-  ASSERT_EQ(Status::Ok(), entry_.Update(kFormat, kTransactionId1 + 6));
+  ASSERT_EQ(OkStatus(), entry_.Update(kFormat, kTransactionId1 + 6));
 
   EXPECT_EQ(kFormat.magic, entry_.magic());
   EXPECT_EQ(0u, entry_.address());
@@ -359,14 +359,13 @@
 TEST_F(ValidEntryInFlash, Update_ReadError_NoChecksumIsOkay) {
   flash_.InjectReadError(FlashError::Unconditional(Status::Aborted()));
 
-  EXPECT_EQ(Status::Ok(),
-            entry_.Update(kNoChecksumFormat, kTransactionId1 + 1));
+  EXPECT_EQ(OkStatus(), entry_.Update(kNoChecksumFormat, kTransactionId1 + 1));
 }
 
 TEST_F(ValidEntryInFlash, Copy) {
   auto result = entry_.Copy(123);
 
-  EXPECT_EQ(Status::Ok(), result.status());
+  EXPECT_EQ(OkStatus(), result.status());
   EXPECT_EQ(entry_.size(), result.size());
   EXPECT_EQ(0,
             std::memcmp(
@@ -436,10 +435,10 @@
 
 TEST_F(ValidEntryInFlash, UpdateAndCopy_DifferentFormatSmallerAlignment) {
   // Uses 16-bit alignment, smaller than the original entry's alignment.
-  ASSERT_EQ(Status::Ok(), entry_.Update(kFormatWithSum, kTransactionId1 + 1));
+  ASSERT_EQ(OkStatus(), entry_.Update(kFormatWithSum, kTransactionId1 + 1));
 
   StatusWithSize result = entry_.Copy(kEntry1.size());
-  ASSERT_EQ(Status::Ok(), result.status());
+  ASSERT_EQ(OkStatus(), result.status());
   EXPECT_EQ(kEntry1.size(), result.size());
 
   constexpr auto new_data = MakeNewFormatWithSumEntry<16>();
@@ -450,9 +449,9 @@
       std::memcmp(
           &flash_.buffer()[kEntry1.size()], new_data.data(), new_data.size()));
   Entry new_entry;
-  ASSERT_EQ(Status::Ok(),
+  ASSERT_EQ(OkStatus(),
             Entry::Read(partition_, 32, kFormatsWithSum, &new_entry));
-  EXPECT_EQ(Status::Ok(), new_entry.VerifyChecksumInFlash());
+  EXPECT_EQ(OkStatus(), new_entry.VerifyChecksumInFlash());
   EXPECT_EQ(kFormatWithSum.magic, new_entry.magic());
   EXPECT_EQ(kTransactionId1 + 1, new_entry.transaction_id());
 }
@@ -462,12 +461,12 @@
   FakeFlashMemoryBuffer<1024, 4> flash(kEntry1);
   FlashPartition partition(&flash, 0, 4, 32);
   Entry entry;
-  ASSERT_EQ(Status::Ok(), Entry::Read(partition, 0, kFormats, &entry));
+  ASSERT_EQ(OkStatus(), Entry::Read(partition, 0, kFormats, &entry));
 
-  ASSERT_EQ(Status::Ok(), entry.Update(kFormatWithSum, kTransactionId1 + 1));
+  ASSERT_EQ(OkStatus(), entry.Update(kFormatWithSum, kTransactionId1 + 1));
 
   StatusWithSize result = entry.Copy(32);
-  ASSERT_EQ(Status::Ok(), result.status());
+  ASSERT_EQ(OkStatus(), result.status());
   EXPECT_EQ(AlignUp(kEntry1.size(), 32), result.size());
 
   constexpr auto new_data = MakeNewFormatWithSumEntry<32>();
@@ -477,9 +476,9 @@
             std::memcmp(&flash.buffer()[32], new_data.data(), new_data.size()));
 
   Entry new_entry;
-  ASSERT_EQ(Status::Ok(),
+  ASSERT_EQ(OkStatus(),
             Entry::Read(partition, 32, kFormatsWithSum, &new_entry));
-  EXPECT_EQ(Status::Ok(), new_entry.VerifyChecksumInFlash());
+  EXPECT_EQ(OkStatus(), new_entry.VerifyChecksumInFlash());
   EXPECT_EQ(kTransactionId1 + 1, new_entry.transaction_id());
 }
 
@@ -488,12 +487,12 @@
   FakeFlashMemoryBuffer<1024, 4> flash(kEntry1);
   FlashPartition partition(&flash, 0, 4, 64);
   Entry entry;
-  ASSERT_EQ(Status::Ok(), Entry::Read(partition, 0, kFormats, &entry));
+  ASSERT_EQ(OkStatus(), Entry::Read(partition, 0, kFormats, &entry));
 
-  ASSERT_EQ(Status::Ok(), entry.Update(kFormatWithSum, kTransactionId1 + 1));
+  ASSERT_EQ(OkStatus(), entry.Update(kFormatWithSum, kTransactionId1 + 1));
 
   StatusWithSize result = entry.Copy(64);
-  ASSERT_EQ(Status::Ok(), result.status());
+  ASSERT_EQ(OkStatus(), result.status());
   EXPECT_EQ(AlignUp(kEntry1.size(), 64), result.size());
 
   constexpr auto new_data = MakeNewFormatWithSumEntry<64>();
@@ -503,9 +502,9 @@
             std::memcmp(&flash.buffer()[64], new_data.data(), new_data.size()));
 
   Entry new_entry;
-  ASSERT_EQ(Status::Ok(),
+  ASSERT_EQ(OkStatus(),
             Entry::Read(partition, 64, kFormatsWithSum, &new_entry));
-  EXPECT_EQ(Status::Ok(), new_entry.VerifyChecksumInFlash());
+  EXPECT_EQ(OkStatus(), new_entry.VerifyChecksumInFlash());
   EXPECT_EQ(kTransactionId1 + 1, new_entry.transaction_id());
 }
 
@@ -514,10 +513,10 @@
   // readable 4 bytes. See pw_kvs/format.h for more information.
   constexpr EntryFormat no_checksum{.magic = 0x43fae18f, .checksum = nullptr};
 
-  ASSERT_EQ(Status::Ok(), entry_.Update(no_checksum, kTransactionId1 + 1));
+  ASSERT_EQ(OkStatus(), entry_.Update(no_checksum, kTransactionId1 + 1));
 
   auto result = entry_.Copy(kEntry1.size());
-  ASSERT_EQ(Status::Ok(), result.status());
+  ASSERT_EQ(OkStatus(), result.status());
   EXPECT_EQ(kEntry1.size(), result.size());
 
   constexpr auto kNewHeader1 =
@@ -537,23 +536,23 @@
 }
 
 TEST_F(ValidEntryInFlash, UpdateAndCopyMultple_DifferentFormat) {
-  ASSERT_EQ(Status::Ok(), entry_.Update(kFormatWithSum, kTransactionId1 + 6));
+  ASSERT_EQ(OkStatus(), entry_.Update(kFormatWithSum, kTransactionId1 + 6));
 
   FlashPartition::Address new_address = entry_.size();
 
   for (int i = 0; i < 10; i++) {
     StatusWithSize copy_result = entry_.Copy(new_address + (i * entry_.size()));
-    ASSERT_EQ(Status::Ok(), copy_result.status());
+    ASSERT_EQ(OkStatus(), copy_result.status());
     ASSERT_EQ(kEntry1.size(), copy_result.size());
   }
 
   for (int j = 0; j < 10; j++) {
     Entry entry;
     FlashPartition::Address read_address = (new_address + (j * entry_.size()));
-    ASSERT_EQ(Status::Ok(),
+    ASSERT_EQ(OkStatus(),
               Entry::Read(partition_, read_address, kFormatsWithSum, &entry));
 
-    EXPECT_EQ(Status::Ok(), entry.VerifyChecksumInFlash());
+    EXPECT_EQ(OkStatus(), entry.VerifyChecksumInFlash());
     EXPECT_EQ(kFormatWithSum.magic, entry.magic());
     EXPECT_EQ(read_address, entry.address());
     EXPECT_EQ(kTransactionId1 + 6, entry.transaction_id());
@@ -562,12 +561,12 @@
 }
 
 TEST_F(ValidEntryInFlash, DifferentFormat_UpdatedCopy_FailsWithWrongMagic) {
-  ASSERT_EQ(Status::Ok(), entry_.Update(kFormatWithSum, kTransactionId1 + 6));
+  ASSERT_EQ(OkStatus(), entry_.Update(kFormatWithSum, kTransactionId1 + 6));
 
   FlashPartition::Address new_address = entry_.size();
 
   StatusWithSize copy_result = entry_.Copy(new_address);
-  ASSERT_EQ(Status::Ok(), copy_result.status());
+  ASSERT_EQ(OkStatus(), copy_result.status());
   ASSERT_EQ(kEntry1.size(), copy_result.size());
 
   Entry entry;
@@ -578,8 +577,7 @@
 TEST_F(ValidEntryInFlash, UpdateAndCopy_WriteError) {
   flash_.InjectWriteError(FlashError::Unconditional(Status::Cancelled()));
 
-  ASSERT_EQ(Status::Ok(),
-            entry_.Update(kNoChecksumFormat, kTransactionId1 + 1));
+  ASSERT_EQ(OkStatus(), entry_.Update(kNoChecksumFormat, kTransactionId1 + 1));
 
   auto result = entry_.Copy(kEntry1.size());
   EXPECT_EQ(Status::Cancelled(), result.status());
diff --git a/pw_kvs/fake_flash_memory.cc b/pw_kvs/fake_flash_memory.cc
index 7796a72..4c30913 100644
--- a/pw_kvs/fake_flash_memory.cc
+++ b/pw_kvs/fake_flash_memory.cc
@@ -31,23 +31,23 @@
     }
   }
 
-  return Status::Ok();
+  return OkStatus();
 }
 
 Status FlashError::Check(FlashMemory::Address start_address, size_t size) {
   // Check if the event overlaps with this address range.
   if (begin_ != kAnyAddress &&
       (start_address >= end_ || (start_address + size) <= begin_)) {
-    return Status::Ok();
+    return OkStatus();
   }
 
   if (delay_ > 0u) {
     delay_ -= 1;
-    return Status::Ok();
+    return OkStatus();
   }
 
   if (remaining_ == 0u) {
-    return Status::Ok();
+    return OkStatus();
   }
 
   if (remaining_ != kAlways) {
@@ -76,7 +76,7 @@
 
   std::memset(
       &buffer_[address], int(kErasedValue), sector_size_bytes() * num_sectors);
-  return Status::Ok();
+  return OkStatus();
 }
 
 StatusWithSize FakeFlashMemory::Read(Address address,
diff --git a/pw_kvs/fake_flash_test_partition.cc b/pw_kvs/fake_flash_test_partition.cc
index fab7e38..c3298ab 100644
--- a/pw_kvs/fake_flash_test_partition.cc
+++ b/pw_kvs/fake_flash_test_partition.cc
@@ -36,7 +36,8 @@
 constexpr size_t kFlashTestSectorSize = PW_FLASH_TEST_SECTOR_SIZE;
 constexpr size_t kFlashTestAlignment = PW_FLASH_TEST_ALIGNMENT;
 
-// Use 6 x 4k sectors, 16 byte alignment.
+// Use PW_FLASH_TEST_SECTORS x PW_FLASH_TEST_SECTOR_SIZE sectors,
+// PW_FLASH_TEST_ALIGNMENT byte alignment.
 FakeFlashMemoryBuffer<kFlashTestSectorSize, kFlashTestSectors> test_flash(
     kFlashTestAlignment);
 FlashPartition test_partition(&test_flash);
diff --git a/pw_kvs/flash_memory.cc b/pw_kvs/flash_memory.cc
index 93e08d7..8424496 100644
--- a/pw_kvs/flash_memory.cc
+++ b/pw_kvs/flash_memory.cc
@@ -105,27 +105,28 @@
 
   // TODO(pwbug/214): Currently using a single flash alignment to do both the
   // read and write. The allowable flash read length may be less than what write
-  // needs (possibly by a bunch), resulting in buffer and erased_pattern_buffer
-  // being bigger than they need to be.
+  // needs (possibly by a bunch), resulting in read_buffer and
+  // erased_pattern_buffer being bigger than they need to be.
   const size_t alignment = alignment_bytes();
   if (alignment > kMaxFlashAlignment || kMaxFlashAlignment % alignment ||
       length % alignment) {
     return Status::InvalidArgument();
   }
 
-  byte buffer[kMaxFlashAlignment];
+  byte read_buffer[kMaxFlashAlignment];
   const byte erased_byte = flash_.erased_memory_content();
   size_t offset = 0;
   *is_erased = false;
   while (length > 0u) {
     // Check earlier that length is aligned, no need to round up
-    size_t read_size = std::min(sizeof(buffer), length);
-    PW_TRY(Read(source_flash_address + offset, read_size, buffer).status());
+    size_t read_size = std::min(sizeof(read_buffer), length);
+    PW_TRY(
+        Read(source_flash_address + offset, read_size, read_buffer).status());
 
-    for (byte b : std::span(buffer, read_size)) {
+    for (byte b : std::span(read_buffer, read_size)) {
       if (b != erased_byte) {
         // Detected memory chunk is not entirely erased
-        return Status::Ok();
+        return OkStatus();
       }
     }
 
@@ -133,7 +134,7 @@
     length -= read_size;
   }
   *is_erased = true;
-  return Status::Ok();
+  return OkStatus();
 }
 
 bool FlashPartition::AppearsErased(std::span<const byte> data) const {
@@ -154,7 +155,7 @@
         unsigned(length));
     return Status::OutOfRange();
   }
-  return Status::Ok();
+  return OkStatus();
 }
 
 }  // namespace pw::kvs
diff --git a/pw_kvs/flash_partition_test.cc b/pw_kvs/flash_partition_test.cc
index b9a2299..193f3d2 100644
--- a/pw_kvs/flash_partition_test.cc
+++ b/pw_kvs/flash_partition_test.cc
@@ -37,7 +37,7 @@
 
   const size_t alignment = partition.alignment_bytes();
 
-  ASSERT_EQ(Status::Ok(), partition.Erase(0, partition.sector_count()));
+  ASSERT_EQ(OkStatus(), partition.Erase(0, partition.sector_count()));
 
   const size_t chunks_per_sector = partition.sector_size_bytes() / alignment;
 
@@ -53,7 +53,7 @@
          chunk_index++) {
       StatusWithSize status =
           partition.Write(address, as_bytes(std::span(test_data, alignment)));
-      ASSERT_EQ(Status::Ok(), status.status());
+      ASSERT_EQ(OkStatus(), status.status());
       ASSERT_EQ(alignment, status.size());
       address += alignment;
     }
@@ -71,7 +71,7 @@
       memset(test_data, 0, sizeof(test_data));
       StatusWithSize status = partition.Read(address, alignment, test_data);
 
-      EXPECT_EQ(Status::Ok(), status.status());
+      EXPECT_EQ(OkStatus(), status.status());
       EXPECT_EQ(alignment, status.size());
       if (!status.ok() || (alignment != status.size())) {
         error_count++;
@@ -140,8 +140,7 @@
       std::min(sizeof(test_data), test_partition.sector_size_bytes());
   auto data_span = std::span(test_data, block_size);
 
-  ASSERT_EQ(Status::Ok(),
-            test_partition.Erase(0, test_partition.sector_count()));
+  ASSERT_EQ(OkStatus(), test_partition.Erase(0, test_partition.sector_count()));
 
   // Write to the first page of each sector.
   for (size_t sector_index = 0; sector_index < test_partition.sector_count();
@@ -150,20 +149,20 @@
         sector_index * test_partition.sector_size_bytes();
 
     StatusWithSize status = test_partition.Write(address, as_bytes(data_span));
-    ASSERT_EQ(Status::Ok(), status.status());
+    ASSERT_EQ(OkStatus(), status.status());
     ASSERT_EQ(block_size, status.size());
   }
 
   // Preset the flag to make sure the check actually sets it.
   bool is_erased = true;
-  ASSERT_EQ(Status::Ok(), test_partition.IsErased(&is_erased));
+  ASSERT_EQ(OkStatus(), test_partition.IsErased(&is_erased));
   ASSERT_EQ(false, is_erased);
 
-  ASSERT_EQ(Status::Ok(), test_partition.Erase());
+  ASSERT_EQ(OkStatus(), test_partition.Erase());
 
   // Preset the flag to make sure the check actually sets it.
   is_erased = false;
-  ASSERT_EQ(Status::Ok(), test_partition.IsErased(&is_erased));
+  ASSERT_EQ(OkStatus(), test_partition.IsErased(&is_erased));
   ASSERT_EQ(true, is_erased);
 
   // Read the first page of each sector and make sure it has been erased.
@@ -174,7 +173,7 @@
 
     StatusWithSize status =
         test_partition.Read(address, data_span.size_bytes(), data_span.data());
-    EXPECT_EQ(Status::Ok(), status.status());
+    EXPECT_EQ(OkStatus(), status.status());
     EXPECT_EQ(data_span.size_bytes(), status.size());
 
     EXPECT_EQ(true, test_partition.AppearsErased(as_bytes(data_span)));
@@ -243,10 +242,10 @@
   // Make sure the partition is big enough to do this test.
   ASSERT_GE(test_partition.size_bytes(), 3 * kMaxFlashAlignment);
 
-  ASSERT_EQ(Status::Ok(), test_partition.Erase());
+  ASSERT_EQ(OkStatus(), test_partition.Erase());
 
   bool is_erased = true;
-  ASSERT_EQ(Status::Ok(), test_partition.IsErased(&is_erased));
+  ASSERT_EQ(OkStatus(), test_partition.IsErased(&is_erased));
   ASSERT_EQ(true, is_erased);
 
   static const uint8_t fill_byte = 0x55;
@@ -256,26 +255,26 @@
 
   // Write the chunk with fill byte.
   StatusWithSize status = test_partition.Write(alignment, as_bytes(data_span));
-  ASSERT_EQ(Status::Ok(), status.status());
+  ASSERT_EQ(OkStatus(), status.status());
   ASSERT_EQ(data_span.size_bytes(), status.size());
 
-  EXPECT_EQ(Status::Ok(), test_partition.IsErased(&is_erased));
+  EXPECT_EQ(OkStatus(), test_partition.IsErased(&is_erased));
   EXPECT_EQ(false, is_erased);
 
   // Check the chunk that was written.
-  EXPECT_EQ(Status::Ok(),
+  EXPECT_EQ(OkStatus(),
             test_partition.IsRegionErased(
                 alignment, data_span.size_bytes(), &is_erased));
   EXPECT_EQ(false, is_erased);
 
   // Check a region that starts erased but later has been written.
-  EXPECT_EQ(Status::Ok(),
+  EXPECT_EQ(OkStatus(),
             test_partition.IsRegionErased(0, 2 * alignment, &is_erased));
   EXPECT_EQ(false, is_erased);
 
   // Check erased for a region smaller than kMaxFlashAlignment. This has been a
   // bug in the past.
-  EXPECT_EQ(Status::Ok(),
+  EXPECT_EQ(OkStatus(),
             test_partition.IsRegionErased(0, alignment, &is_erased));
   EXPECT_EQ(true, is_erased);
 }
diff --git a/pw_kvs/flash_partition_with_stats.cc b/pw_kvs/flash_partition_with_stats.cc
index 6b2431b..26b0eee 100644
--- a/pw_kvs/flash_partition_with_stats.cc
+++ b/pw_kvs/flash_partition_with_stats.cc
@@ -29,7 +29,7 @@
                                                  const char* label) {
   // If size is zero saving stats is disabled so do not save any stats.
   if (sector_counters_.size() == 0) {
-    return Status::Ok();
+    return OkStatus();
   }
 
   KeyValueStore::StorageStats stats = kvs.GetStorageStats();
@@ -67,7 +67,7 @@
 
   std::fprintf(out_file, "\n");
   std::fclose(out_file);
-  return Status::Ok();
+  return OkStatus();
 }
 
 Status FlashPartitionWithStats::Erase(Address address, size_t num_sectors) {
diff --git a/pw_kvs/key_test.cc b/pw_kvs/key_test.cc
new file mode 100644
index 0000000..c09399f
--- /dev/null
+++ b/pw_kvs/key_test.cc
@@ -0,0 +1,128 @@
+// 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.
+
+#include "pw_kvs/key.h"
+
+#include <string_view>
+
+#include "gtest/gtest.h"
+
+namespace pw::kvs {
+
+namespace {
+
+constexpr const char kTestString[] = "test_string";
+constexpr std::string_view kTestStringView{kTestString};
+
+// kTestString2 starts with same string as kTestString.
+constexpr const char kTestString2[] = "test_string2";
+
+}  // namespace
+
+TEST(Key, ConstructorEmpty) {
+  Key key;
+  EXPECT_EQ(key.size(), 0u);
+  EXPECT_TRUE(key.empty());
+  EXPECT_EQ(key.data(), nullptr);
+  EXPECT_EQ(key.begin(), nullptr);
+  EXPECT_EQ(key.end(), nullptr);
+}
+
+TEST(Key, ConstructorString) {
+  std::string str{kTestStringView};
+  Key key{str};
+  EXPECT_EQ(key.size(), kTestStringView.size());
+  EXPECT_FALSE(key.empty());
+  EXPECT_EQ(key.data(), str.data());
+  EXPECT_EQ(key.front(), kTestStringView.front());
+  EXPECT_EQ(key.back(), kTestStringView.back());
+}
+
+TEST(Key, ConstructorStringView) {
+  Key key{kTestStringView};
+  EXPECT_EQ(key.size(), kTestStringView.size());
+  EXPECT_FALSE(key.empty());
+  EXPECT_EQ(key.data(), kTestStringView.data());
+  EXPECT_EQ(key.front(), kTestStringView.front());
+  EXPECT_EQ(key.back(), kTestStringView.back());
+}
+
+TEST(Key, ConstructorNullTermString) {
+  Key key{kTestString};
+  EXPECT_EQ(key.size(), kTestStringView.size());
+  EXPECT_FALSE(key.empty());
+  EXPECT_EQ(key.data(), kTestString);
+  EXPECT_EQ(key.front(), kTestStringView.front());
+  EXPECT_EQ(key.back(), kTestStringView.back());
+}
+
+TEST(Key, ConstructorCharPtrLength) {
+  Key key{kTestString, kTestStringView.size() + 1};  // include null terminator
+  EXPECT_EQ(key.size(), kTestStringView.size() + 1);
+  EXPECT_FALSE(key.empty());
+  EXPECT_EQ(key.data(), kTestStringView.data());
+  EXPECT_EQ(key.front(), kTestStringView.front());
+  EXPECT_EQ(key.back(), '\0');
+}
+
+TEST(Key, ConstructorCopy) {
+  Key key1{kTestString};
+  Key key{key1};
+  EXPECT_EQ(key.size(), kTestStringView.size());
+  EXPECT_FALSE(key.empty());
+  EXPECT_EQ(key.data(), kTestStringView.data());
+  EXPECT_EQ(key.front(), kTestStringView.front());
+  EXPECT_EQ(key.back(), kTestStringView.back());
+}
+
+TEST(Key, Access) {
+  Key key{kTestStringView};
+  for (size_t i = 0; i < key.size(); i++) {
+    EXPECT_EQ(key[i], kTestStringView[i]);
+    EXPECT_EQ(key.at(i), kTestStringView.at(i));
+  }
+}
+
+TEST(Key, Iterator) {
+  size_t i = 0;
+  for (auto c : Key{kTestString}) {
+    EXPECT_EQ(c, kTestString[i++]);
+  }
+}
+
+TEST(Key, Same) {
+  // Since start of two test strings are the same, verify those are equal.
+  EXPECT_TRUE((Key{kTestString} == Key{kTestString2, kTestStringView.size()}));
+  EXPECT_FALSE((Key{kTestString} != Key{kTestString2, kTestStringView.size()}));
+}
+
+TEST(Key, Different) {
+  EXPECT_FALSE(Key{kTestString} == Key{kTestString2});
+  EXPECT_TRUE(Key{kTestString} != Key{kTestString2});
+}
+
+TEST(Key, DifferentWithSameLength) {
+  // Start second test string offset by one.
+  EXPECT_FALSE(
+      (Key{kTestString} == Key{kTestString2 + 1, kTestStringView.size()}));
+  EXPECT_TRUE(
+      (Key{kTestString} != Key{kTestString2 + 1, kTestStringView.size()}));
+}
+
+TEST(Key, ConvertToStringView) {
+  std::string_view view = Key{kTestString};
+  EXPECT_TRUE(view == kTestStringView);
+}
+
+}  // namespace pw::kvs
diff --git a/pw_kvs/key_value_store.cc b/pw_kvs/key_value_store.cc
index 534313e..335efd5 100644
--- a/pw_kvs/key_value_store.cc
+++ b/pw_kvs/key_value_store.cc
@@ -14,7 +14,6 @@
 
 #define PW_LOG_MODULE_NAME "KVS"
 #define PW_LOG_LEVEL PW_KVS_LOG_LEVEL
-#define PW_LOG_USE_ULTRA_SHORT_NAMES 1
 
 #include "pw_kvs/key_value_store.h"
 
@@ -25,16 +24,15 @@
 
 #include "pw_assert/assert.h"
 #include "pw_kvs_private/config.h"
-#include "pw_log/log.h"
+#include "pw_log/shorter.h"
 #include "pw_status/try.h"
 
 namespace pw::kvs {
 namespace {
 
 using std::byte;
-using std::string_view;
 
-constexpr bool InvalidKey(std::string_view key) {
+constexpr bool InvalidKey(Key key) {
   return key.empty() || (key.size() > internal::Entry::kMaxKeyLength);
 }
 
@@ -103,7 +101,7 @@
       Status recovery_status = FixErrors();
 
       if (recovery_status.ok()) {
-        if (metadata_result == Status::OutOfRange()) {
+        if (metadata_result.IsOutOfRange()) {
           internal_stats_.missing_redundant_entries_recovered =
               pre_fix_redundancy_errors;
           INF("KVS init: Redundancy level successfully updated");
@@ -111,7 +109,7 @@
           WRN("KVS init: Corruption detected and fully repaired");
         }
         initialized_ = InitializationState::kReady;
-      } else if (recovery_status == Status::ResourceExhausted()) {
+      } else if (recovery_status.IsResourceExhausted()) {
         WRN("KVS init: Unable to maintain required free sector");
       } else {
         WRN("KVS init: Corruption detected and unable repair");
@@ -135,7 +133,7 @@
     return Status::DataLoss();
   }
 
-  return Status::Ok();
+  return OkStatus();
 }
 
 Status KeyValueStore::InitializeMetadata() {
@@ -170,7 +168,7 @@
 
       Address next_entry_address;
       Status status = LoadEntry(entry_address, &next_entry_address);
-      if (status == Status::NotFound()) {
+      if (status.IsNotFound()) {
         DBG("Hit un-written data in sector; moving to the next sector");
         break;
       } else if (!status.ok()) {
@@ -296,7 +294,7 @@
         unsigned(entry_copies_missing));
     return Status::FailedPrecondition();
   }
-  return Status::Ok();
+  return OkStatus();
 }
 
 KeyValueStore::StorageStats KeyValueStore::GetStorageStats() const {
@@ -360,7 +358,7 @@
   // Read the key from flash & validate the entry (which reads the value).
   Entry::KeyBuffer key_buffer;
   PW_TRY_ASSIGN(size_t key_length, entry.ReadKey(key_buffer));
-  const string_view key(key_buffer.data(), key_length);
+  const Key key(key_buffer.data(), key_length);
 
   PW_TRY(entry.VerifyChecksumInFlash());
 
@@ -395,14 +393,14 @@
     if (formats_.KnownMagic(magic)) {
       DBG("Found entry magic at address %u", unsigned(address));
       *next_entry_address = address;
-      return Status::Ok();
+      return OkStatus();
     }
   }
 
   return Status::NotFound();
 }
 
-StatusWithSize KeyValueStore::Get(string_view key,
+StatusWithSize KeyValueStore::Get(Key key,
                                   std::span<byte> value_buffer,
                                   size_t offset_bytes) const {
   PW_TRY_WITH_SIZE(CheckReadOperation(key));
@@ -413,7 +411,7 @@
   return Get(key, metadata, value_buffer, offset_bytes);
 }
 
-Status KeyValueStore::PutBytes(string_view key, std::span<const byte> value) {
+Status KeyValueStore::PutBytes(Key key, std::span<const byte> value) {
   PW_TRY(CheckWriteOperation(key));
   DBG("Writing key/value; key length=%u, value length=%u",
       unsigned(key.size()),
@@ -438,14 +436,14 @@
     return WriteEntryForExistingKey(metadata, EntryState::kValid, key, value);
   }
 
-  if (status == Status::NotFound()) {
+  if (status.IsNotFound()) {
     return WriteEntryForNewKey(key, value);
   }
 
   return status;
 }
 
-Status KeyValueStore::Delete(string_view key) {
+Status KeyValueStore::Delete(Key key) {
   PW_TRY(CheckWriteOperation(key));
 
   EntryMetadata metadata;
@@ -486,7 +484,7 @@
   return iterator(*this, cache_iterator);
 }
 
-StatusWithSize KeyValueStore::ValueSize(string_view key) const {
+StatusWithSize KeyValueStore::ValueSize(Key key) const {
   PW_TRY_WITH_SIZE(CheckReadOperation(key));
 
   EntryMetadata metadata;
@@ -514,8 +512,7 @@
   return read_result;
 }
 
-Status KeyValueStore::FindEntry(string_view key,
-                                EntryMetadata* found_entry) const {
+Status KeyValueStore::FindEntry(Key key, EntryMetadata* found_entry) const {
   StatusWithSize find_result =
       entry_cache_.Find(partition_, sectors_, formats_, key, found_entry);
 
@@ -525,20 +522,19 @@
   return find_result.status();
 }
 
-Status KeyValueStore::FindExisting(string_view key,
-                                   EntryMetadata* metadata) const {
+Status KeyValueStore::FindExisting(Key key, EntryMetadata* metadata) const {
   Status status = FindEntry(key, metadata);
 
   // If the key's hash collides with an existing key or if the key is deleted,
   // treat it as if it is not in the KVS.
-  if (status == Status::AlreadyExists() ||
+  if (status.IsAlreadyExists() ||
       (status.ok() && metadata->state() == EntryState::kDeleted)) {
     return Status::NotFound();
   }
   return status;
 }
 
-StatusWithSize KeyValueStore::Get(string_view key,
+StatusWithSize KeyValueStore::Get(Key key,
                                   const EntryMetadata& metadata,
                                   std::span<std::byte> value_buffer,
                                   size_t offset_bytes) const {
@@ -560,7 +556,7 @@
   return result;
 }
 
-Status KeyValueStore::FixedSizeGet(std::string_view key,
+Status KeyValueStore::FixedSizeGet(Key key,
                                    void* value,
                                    size_t size_bytes) const {
   PW_TRY(CheckWriteOperation(key));
@@ -571,7 +567,7 @@
   return FixedSizeGet(key, metadata, value, size_bytes);
 }
 
-Status KeyValueStore::FixedSizeGet(std::string_view key,
+Status KeyValueStore::FixedSizeGet(Key key,
                                    const EntryMetadata& metadata,
                                    void* value,
                                    size_t size_bytes) const {
@@ -599,7 +595,7 @@
   return StatusWithSize(entry.value_size());
 }
 
-Status KeyValueStore::CheckWriteOperation(string_view key) const {
+Status KeyValueStore::CheckWriteOperation(Key key) const {
   if (InvalidKey(key)) {
     return Status::InvalidArgument();
   }
@@ -608,10 +604,10 @@
   if (!initialized()) {
     return Status::FailedPrecondition();
   }
-  return Status::Ok();
+  return OkStatus();
 }
 
-Status KeyValueStore::CheckReadOperation(string_view key) const {
+Status KeyValueStore::CheckReadOperation(Key key) const {
   if (InvalidKey(key)) {
     return Status::InvalidArgument();
   }
@@ -621,12 +617,12 @@
   if (initialized_ == InitializationState::kNotInitialized) {
     return Status::FailedPrecondition();
   }
-  return Status::Ok();
+  return OkStatus();
 }
 
 Status KeyValueStore::WriteEntryForExistingKey(EntryMetadata& metadata,
                                                EntryState new_state,
-                                               string_view key,
+                                               Key key,
                                                std::span<const byte> value) {
   // Read the original entry to get the size for sector accounting purposes.
   Entry entry;
@@ -635,7 +631,7 @@
   return WriteEntry(key, value, new_state, &metadata, &entry);
 }
 
-Status KeyValueStore::WriteEntryForNewKey(string_view key,
+Status KeyValueStore::WriteEntryForNewKey(Key key,
                                           std::span<const byte> value) {
   if (entry_cache_.full()) {
     WRN("KVS full: trying to store a new entry, but can't. Have %u entries",
@@ -646,7 +642,7 @@
   return WriteEntry(key, value, EntryState::kValid);
 }
 
-Status KeyValueStore::WriteEntry(string_view key,
+Status KeyValueStore::WriteEntry(Key key,
                                  std::span<const byte> value,
                                  EntryState new_state,
                                  EntryMetadata* prior_metadata,
@@ -662,7 +658,7 @@
     // keep the existing entry.
     DBG("Write for key 0x%08x with matching value skipped",
         unsigned(prior_metadata->hash()));
-    return Status::Ok();
+    return OkStatus();
   }
 
   // List of addresses for sectors with space for this entry.
@@ -689,12 +685,12 @@
     PW_TRY(AppendEntry(entry, key, value));
     new_metadata.AddNewAddress(reserved_addresses[i]);
   }
-  return Status::Ok();
+  return OkStatus();
 }
 
 KeyValueStore::EntryMetadata KeyValueStore::CreateOrUpdateKeyDescriptor(
     const Entry& entry,
-    string_view key,
+    Key key,
     EntryMetadata* prior_metadata,
     size_t prior_size) {
   // If there is no prior descriptor, create a new one.
@@ -733,7 +729,7 @@
         unsigned(write_addresses[i]));
   }
 
-  return Status::Ok();
+  return OkStatus();
 }
 
 // Finds a sector to use for writing a new entry to. Does automatic garbage
@@ -750,7 +746,7 @@
   bool do_auto_gc = options_.gc_on_write != GargbageCollectOnWrite::kDisabled;
 
   // Do garbage collection as needed, so long as policy allows.
-  while (result == Status::ResourceExhausted() && do_auto_gc) {
+  while (result.IsResourceExhausted() && do_auto_gc) {
     if (options_.gc_on_write == GargbageCollectOnWrite::kOneSector) {
       // If GC config option is kOneSector clear the flag to not do any more
       // GC after this try.
@@ -759,7 +755,7 @@
     // Garbage collect and then try again to find the best sector.
     Status gc_status = GarbageCollect(reserved);
     if (!gc_status.ok()) {
-      if (gc_status == Status::NotFound()) {
+      if (gc_status.IsNotFound()) {
         // Not enough space, and no reclaimable bytes, this KVS is full!
         return Status::ResourceExhausted();
       }
@@ -796,7 +792,7 @@
 }
 
 Status KeyValueStore::AppendEntry(const Entry& entry,
-                                  string_view key,
+                                  Key key,
                                   std::span<const byte> value) {
   const StatusWithSize result = entry.Write(key, value);
 
@@ -816,7 +812,7 @@
 
   sector.RemoveWritableBytes(result.size());
   sector.AddValidBytes(result.size());
-  return Status::Ok();
+  return OkStatus();
 }
 
 StatusWithSize KeyValueStore::CopyEntryToSector(Entry& entry,
@@ -867,7 +863,7 @@
   sectors_.FromAddress(address).RemoveValidBytes(result_size);
   address = new_address;
 
-  return Status::Ok();
+  return OkStatus();
 }
 
 Status KeyValueStore::FullMaintenanceHelper(MaintenanceType maintenance_type) {
@@ -949,7 +945,7 @@
 Status KeyValueStore::GarbageCollect(
     std::span<const Address> reserved_addresses) {
   DBG("Garbage Collect a single sector");
-  for (Address address : reserved_addresses) {
+  for ([[maybe_unused]] Address address : reserved_addresses) {
     DBG("   Avoid address %u", unsigned(address));
   }
 
@@ -979,7 +975,7 @@
     }
   }
 
-  return Status::Ok();
+  return OkStatus();
 };
 
 Status KeyValueStore::GarbageCollectSector(
@@ -1011,7 +1007,7 @@
   }
 
   DBG("  Garbage Collect sector %u complete", sectors_.Index(sector_to_gc));
-  return Status::Ok();
+  return OkStatus();
 }
 
 StatusWithSize KeyValueStore::UpdateEntriesToPrimaryFormat() {
@@ -1081,7 +1077,7 @@
 
     metadata.AddNewAddress(new_address);
   }
-  return Status::Ok();
+  return OkStatus();
 }
 
 Status KeyValueStore::RepairCorruptSectors() {
@@ -1089,15 +1085,15 @@
   // sector failed on the first pass, then do a second pass, since a later
   // sector might have cleared up space or otherwise unblocked the earlier
   // failed sector.
-  Status repair_status = Status::Ok();
+  Status repair_status = OkStatus();
 
   size_t loop_count = 0;
   do {
     loop_count++;
     // Error of RESOURCE_EXHAUSTED indicates no space found for relocation.
     // Reset back to OK for the next pass.
-    if (repair_status == Status::ResourceExhausted()) {
-      repair_status = Status::Ok();
+    if (repair_status.IsResourceExhausted()) {
+      repair_status = OkStatus();
     }
 
     DBG("   Pass %u", unsigned(loop_count));
@@ -1107,8 +1103,7 @@
         Status sector_status = GarbageCollectSector(sector, {});
         if (sector_status.ok()) {
           internal_stats_.corrupt_sectors_recovered += 1;
-        } else if (repair_status.ok() ||
-                   repair_status == Status::ResourceExhausted()) {
+        } else if (repair_status.ok() || repair_status.IsResourceExhausted()) {
           repair_status = sector_status;
         }
       }
@@ -1120,7 +1115,7 @@
 }
 
 Status KeyValueStore::EnsureFreeSectorExists() {
-  Status repair_status = Status::Ok();
+  Status repair_status = OkStatus();
   bool empty_sector_found = false;
 
   DBG("   Find empty sector");
@@ -1144,11 +1139,11 @@
 }
 
 Status KeyValueStore::EnsureEntryRedundancy() {
-  Status repair_status = Status::Ok();
+  Status repair_status = OkStatus();
 
   if (redundancy() == 1) {
     DBG("   Redundancy not in use, nothting to check");
-    return Status::Ok();
+    return OkStatus();
   }
 
   DBG("   Write any needed additional duplicate copies of keys to fulfill %u"
@@ -1218,7 +1213,7 @@
 }
 
 KeyValueStore::Entry KeyValueStore::CreateEntry(Address address,
-                                                string_view key,
+                                                Key key,
                                                 std::span<const byte> value,
                                                 EntryState state) {
   // Always bump the transaction ID when creating a new entry.
@@ -1292,7 +1287,7 @@
   for (size_t sector_id = 0; sector_id < sectors_.size(); ++sector_id) {
     // Read sector data. Yes, this will blow the stack on embedded.
     std::array<byte, 500> raw_sector_data;  // TODO!!!
-    StatusWithSize sws =
+    [[maybe_unused]] StatusWithSize sws =
         partition_.Read(sector_id * sector_size_bytes, raw_sector_data);
     DBG("Read: %u bytes", unsigned(sws.size()));
 
diff --git a/pw_kvs/key_value_store_binary_format_test.cc b/pw_kvs/key_value_store_binary_format_test.cc
index 76c0667..1fb046a 100644
--- a/pw_kvs/key_value_store_binary_format_test.cc
+++ b/pw_kvs/key_value_store_binary_format_test.cc
@@ -189,10 +189,10 @@
 TEST_F(KvsErrorHandling, Init_Ok) {
   InitFlashTo(bytes::Concat(kEntry1, kEntry2));
 
-  EXPECT_EQ(Status::Ok(), kvs_.Init());
+  EXPECT_EQ(OkStatus(), kvs_.Init());
   byte buffer[64];
-  EXPECT_EQ(Status::Ok(), kvs_.Get("key1", buffer).status());
-  EXPECT_EQ(Status::Ok(), kvs_.Get("k2", buffer).status());
+  EXPECT_EQ(OkStatus(), kvs_.Get("key1", buffer).status());
+  EXPECT_EQ(OkStatus(), kvs_.Get("k2", buffer).status());
 }
 
 TEST_F(KvsErrorHandling, Init_DuplicateEntries_ReturnsDataLossButReadsEntry) {
@@ -200,7 +200,7 @@
 
   EXPECT_EQ(Status::DataLoss(), kvs_.Init());
   byte buffer[64];
-  EXPECT_EQ(Status::Ok(), kvs_.Get("key1", buffer).status());
+  EXPECT_EQ(OkStatus(), kvs_.Get("key1", buffer).status());
   EXPECT_EQ(Status::NotFound(), kvs_.Get("k2", buffer).status());
 }
 
@@ -213,7 +213,7 @@
     ASSERT_EQ(Status::DataLoss(), kvs_.Init());
     byte buffer[64];
     ASSERT_EQ(Status::NotFound(), kvs_.Get("key1", buffer).status());
-    ASSERT_EQ(Status::Ok(), kvs_.Get("k2", buffer).status());
+    ASSERT_EQ(OkStatus(), kvs_.Get("k2", buffer).status());
 
     auto stats = kvs_.GetStorageStats();
     // One valid entry.
@@ -236,9 +236,9 @@
 
   byte buffer[64];
   EXPECT_EQ(Status::NotFound(), kvs_.Get("key1", buffer).status());
-  EXPECT_EQ(Status::Ok(), kvs_.Get("k2", buffer).status());
+  EXPECT_EQ(OkStatus(), kvs_.Get("k2", buffer).status());
   EXPECT_EQ(Status::NotFound(), kvs_.Get("k3y", buffer).status());
-  EXPECT_EQ(Status::Ok(), kvs_.Get("4k", buffer).status());
+  EXPECT_EQ(OkStatus(), kvs_.Get("4k", buffer).status());
 
   auto stats = kvs_.GetStorageStats();
   ASSERT_EQ(64u, stats.in_use_bytes);
@@ -276,7 +276,7 @@
   EXPECT_EQ(1u, kvs_.size());
   byte buffer[64];
   EXPECT_EQ(Status::NotFound(), kvs_.Get("key1", buffer).status());
-  EXPECT_EQ(Status::Ok(), kvs_.Get("k2", buffer).status());
+  EXPECT_EQ(OkStatus(), kvs_.Get("k2", buffer).status());
 
   auto stats = kvs_.GetStorageStats();
   EXPECT_EQ(32u, stats.in_use_bytes);
@@ -314,8 +314,8 @@
   byte buffer[64];
   EXPECT_EQ(2u, kvs_.size());
   EXPECT_EQ(true, kvs_.error_detected());
-  EXPECT_EQ(Status::Ok(), kvs_.Get("key1", buffer).status());
-  EXPECT_EQ(Status::Ok(), kvs_.Get("k2", buffer).status());
+  EXPECT_EQ(OkStatus(), kvs_.Get("key1", buffer).status());
+  EXPECT_EQ(OkStatus(), kvs_.Get("k2", buffer).status());
 
   auto stats = kvs_.GetStorageStats();
   EXPECT_EQ(64u, stats.in_use_bytes);
@@ -341,7 +341,7 @@
   EXPECT_EQ(1u, kvs_.size());
 
   auto result = kvs_.Get("my_key", std::as_writable_bytes(std::span(buffer)));
-  EXPECT_EQ(Status::Ok(), result.status());
+  EXPECT_EQ(OkStatus(), result.status());
   EXPECT_EQ(sizeof("version 7") - 1, result.size());
   EXPECT_STREQ("version 7", buffer);
 
@@ -352,7 +352,7 @@
 // the KvsErrorRecovery and KvsErrorHandling test fixtures (different KVS
 // configurations).
 TEST_F(KvsErrorHandling, Put_WriteFailure_EntryNotAddedButBytesMarkedWritten) {
-  ASSERT_EQ(Status::Ok(), kvs_.Init());
+  ASSERT_EQ(OkStatus(), kvs_.Init());
   flash_.InjectWriteError(FlashError::Unconditional(Status::Unavailable(), 1));
 
   EXPECT_EQ(Status::Unavailable(), kvs_.Put("key1", bytes::String("value1")));
@@ -367,7 +367,7 @@
 
   // The bytes were marked used, so a new key should not overlap with the bytes
   // from the failed Put.
-  EXPECT_EQ(Status::Ok(), kvs_.Put("key1", bytes::String("value1")));
+  EXPECT_EQ(OkStatus(), kvs_.Put("key1", bytes::String("value1")));
 
   stats = kvs_.GetStorageStats();
   EXPECT_EQ(stats.in_use_bytes, (32u * kvs_.redundancy()));
@@ -397,21 +397,21 @@
 TEST_F(KvsErrorRecovery, Init_Ok) {
   InitFlashTo(bytes::Concat(kEntry1, kEntry2));
 
-  EXPECT_EQ(Status::Ok(), kvs_.Init());
+  EXPECT_EQ(OkStatus(), kvs_.Init());
   byte buffer[64];
-  EXPECT_EQ(Status::Ok(), kvs_.Get("key1", buffer).status());
-  EXPECT_EQ(Status::Ok(), kvs_.Get("k2", buffer).status());
+  EXPECT_EQ(OkStatus(), kvs_.Get("key1", buffer).status());
+  EXPECT_EQ(OkStatus(), kvs_.Get("k2", buffer).status());
 }
 
 TEST_F(KvsErrorRecovery, Init_DuplicateEntries_RecoversDuringInit) {
   InitFlashTo(bytes::Concat(kEntry1, kEntry1));
 
-  EXPECT_EQ(Status::Ok(), kvs_.Init());
+  EXPECT_EQ(OkStatus(), kvs_.Init());
   auto stats = kvs_.GetStorageStats();
   EXPECT_EQ(stats.corrupt_sectors_recovered, 1u);
 
   byte buffer[64];
-  EXPECT_EQ(Status::Ok(), kvs_.Get("key1", buffer).status());
+  EXPECT_EQ(OkStatus(), kvs_.Get("key1", buffer).status());
   EXPECT_EQ(Status::NotFound(), kvs_.Get("k2", buffer).status());
 }
 
@@ -421,10 +421,10 @@
     InitFlashTo(bytes::Concat(kEntry1, kEntry2));
     flash_.buffer()[i] = byte(int(flash_.buffer()[i]) + 1);
 
-    ASSERT_EQ(Status::Ok(), kvs_.Init());
+    ASSERT_EQ(OkStatus(), kvs_.Init());
     byte buffer[64];
     ASSERT_EQ(Status::NotFound(), kvs_.Get("key1", buffer).status());
-    ASSERT_EQ(Status::Ok(), kvs_.Get("k2", buffer).status());
+    ASSERT_EQ(OkStatus(), kvs_.Get("k2", buffer).status());
 
     auto stats = kvs_.GetStorageStats();
     // One valid entry.
@@ -442,15 +442,15 @@
   flash_.buffer()[9] = byte(0xef);
   flash_.buffer()[67] = byte(0xef);
 
-  ASSERT_EQ(Status::Ok(), kvs_.Init());
+  ASSERT_EQ(OkStatus(), kvs_.Init());
 
   EXPECT_EQ(2u, kvs_.size());
 
   byte buffer[64];
   EXPECT_EQ(Status::NotFound(), kvs_.Get("key1", buffer).status());
-  EXPECT_EQ(Status::Ok(), kvs_.Get("k2", buffer).status());
+  EXPECT_EQ(OkStatus(), kvs_.Get("k2", buffer).status());
   EXPECT_EQ(Status::NotFound(), kvs_.Get("k3y", buffer).status());
-  EXPECT_EQ(Status::Ok(), kvs_.Get("4k", buffer).status());
+  EXPECT_EQ(OkStatus(), kvs_.Get("4k", buffer).status());
 
   auto stats = kvs_.GetStorageStats();
   ASSERT_EQ(64u, stats.in_use_bytes);
@@ -465,7 +465,7 @@
   flash_.InjectReadError(
       FlashError::InRange(Status::Unauthenticated(), kEntry1.size()));
 
-  EXPECT_EQ(Status::Ok(), kvs_.Init());
+  EXPECT_EQ(OkStatus(), kvs_.Init());
   EXPECT_TRUE(kvs_.initialized());
   auto stats = kvs_.GetStorageStats();
   ASSERT_EQ(32u, stats.in_use_bytes);
@@ -484,15 +484,15 @@
   flash_.buffer()[513] = byte(0xef);
   flash_.buffer()[1025] = byte(0xef);
 
-  ASSERT_EQ(Status::Ok(), kvs_.Init());
-  EXPECT_EQ(Status::Ok(), kvs_.Put("hello", bytes::String("world")));
-  EXPECT_EQ(Status::Ok(), kvs_.Put("a", bytes::String("b")));
+  ASSERT_EQ(OkStatus(), kvs_.Init());
+  EXPECT_EQ(OkStatus(), kvs_.Put("hello", bytes::String("world")));
+  EXPECT_EQ(OkStatus(), kvs_.Put("a", bytes::String("b")));
 
   // Existing valid entries should still be readable.
   EXPECT_EQ(3u, kvs_.size());
   byte buffer[64];
   EXPECT_EQ(Status::NotFound(), kvs_.Get("key1", buffer).status());
-  EXPECT_EQ(Status::Ok(), kvs_.Get("k2", buffer).status());
+  EXPECT_EQ(OkStatus(), kvs_.Get("k2", buffer).status());
 
   auto stats = kvs_.GetStorageStats();
   EXPECT_EQ(96u, stats.in_use_bytes);
@@ -512,7 +512,7 @@
   flash_.buffer()[1025] = byte(0xef);
   flash_.buffer()[1537] = byte(0xef);
 
-  ASSERT_EQ(Status::Ok(), kvs_.Init());
+  ASSERT_EQ(OkStatus(), kvs_.Init());
 
   auto stats = kvs_.GetStorageStats();
   EXPECT_EQ(64u, stats.in_use_bytes);
@@ -528,12 +528,12 @@
 TEST_F(KvsErrorRecovery, DISABLED_Init_OkWithWriteErrorOnFlash) {
   InitFlashTo(bytes::Concat(kEntry1, kEmpty32Bytes, kEntry2));
 
-  EXPECT_EQ(Status::Ok(), kvs_.Init());
+  EXPECT_EQ(OkStatus(), kvs_.Init());
   byte buffer[64];
   EXPECT_EQ(2u, kvs_.size());
   EXPECT_EQ(false, kvs_.error_detected());
-  EXPECT_EQ(Status::Ok(), kvs_.Get("key1", buffer).status());
-  EXPECT_EQ(Status::Ok(), kvs_.Get("k2", buffer).status());
+  EXPECT_EQ(OkStatus(), kvs_.Get("key1", buffer).status());
+  EXPECT_EQ(OkStatus(), kvs_.Get("k2", buffer).status());
 
   auto stats = kvs_.GetStorageStats();
   EXPECT_EQ(64u, stats.in_use_bytes);
@@ -554,14 +554,14 @@
   // Corrupt a byte of entry version 8 (addresses 32-63).
   flash_.buffer()[34] = byte(0xef);
 
-  ASSERT_EQ(Status::Ok(), kvs_.Init());
+  ASSERT_EQ(OkStatus(), kvs_.Init());
 
   char buffer[64] = {};
 
   EXPECT_EQ(1u, kvs_.size());
 
   auto result = kvs_.Get("my_key", std::as_writable_bytes(std::span(buffer)));
-  EXPECT_EQ(Status::Ok(), result.status());
+  EXPECT_EQ(OkStatus(), result.status());
   EXPECT_EQ(sizeof("version 7") - 1, result.size());
   EXPECT_STREQ("version 7", buffer);
 
@@ -572,7 +572,7 @@
 // the KvsErrorRecovery and KvsErrorHandling test fixtures (different KVS
 // configurations).
 TEST_F(KvsErrorRecovery, Put_WriteFailure_EntryNotAddedButBytesMarkedWritten) {
-  ASSERT_EQ(Status::Ok(), kvs_.Init());
+  ASSERT_EQ(OkStatus(), kvs_.Init());
   flash_.InjectWriteError(FlashError::Unconditional(Status::Unavailable(), 1));
 
   EXPECT_EQ(Status::Unavailable(), kvs_.Put("key1", bytes::String("value1")));
@@ -590,7 +590,7 @@
 
   // The bytes were marked used, so a new key should not overlap with the bytes
   // from the failed Put.
-  EXPECT_EQ(Status::Ok(), kvs_.Put("key1", bytes::String("value1")));
+  EXPECT_EQ(OkStatus(), kvs_.Put("key1", bytes::String("value1")));
 
   stats = kvs_.GetStorageStats();
   EXPECT_EQ(stats.in_use_bytes, (32u * kvs_.redundancy()));
@@ -647,7 +647,7 @@
                 kInitialContents.data(),
                 kInitialContents.size());
 
-    EXPECT_EQ(Status::Ok(), kvs_.Init());
+    EXPECT_EQ(OkStatus(), kvs_.Init());
   }
 
   FakeFlashMemoryBuffer<512, 4, 3> flash_;
@@ -660,7 +660,7 @@
     char val[sizeof(str_value)] = {};                          \
     StatusWithSize stat =                                      \
         kvs_.Get(key, std::as_writable_bytes(std::span(val))); \
-    ASSERT_EQ(Status::Ok(), stat.status());                    \
+    ASSERT_EQ(OkStatus(), stat.status());                      \
     ASSERT_EQ(sizeof(str_value) - 1, stat.size());             \
     ASSERT_STREQ(str_value, val);                              \
   } while (0)
@@ -681,7 +681,7 @@
   EXPECT_EQ(stats.corrupt_sectors_recovered, 0u);
   EXPECT_EQ(stats.missing_redundant_entries_recovered, 0u);
 
-  EXPECT_EQ(Status::Ok(), partition_.Erase(0, 1));
+  EXPECT_EQ(OkStatus(), partition_.Erase(0, 1));
 
   ASSERT_CONTAINS_ENTRY("key1", "value1");
   ASSERT_CONTAINS_ENTRY("k2", "value2");
@@ -698,7 +698,7 @@
   EXPECT_EQ(stats.corrupt_sectors_recovered, 0u);
   EXPECT_EQ(stats.missing_redundant_entries_recovered, 0u);
 
-  EXPECT_EQ(Status::Ok(), kvs_.FullMaintenance());
+  EXPECT_EQ(OkStatus(), kvs_.FullMaintenance());
   stats = kvs_.GetStorageStats();
   EXPECT_EQ(stats.in_use_bytes, (192u * kvs_.redundancy()));
   EXPECT_EQ(stats.reclaimable_bytes, 0u);
@@ -715,7 +715,7 @@
   EXPECT_EQ(stats.corrupt_sectors_recovered, 0u);
   EXPECT_EQ(stats.missing_redundant_entries_recovered, 0u);
 
-  EXPECT_EQ(Status::Ok(), partition_.Erase(partition_.sector_size_bytes(), 1));
+  EXPECT_EQ(OkStatus(), partition_.Erase(partition_.sector_size_bytes(), 1));
 
   ASSERT_CONTAINS_ENTRY("key1", "value1");
   ASSERT_CONTAINS_ENTRY("k2", "value2");
@@ -725,7 +725,7 @@
 
   EXPECT_EQ(false, kvs_.error_detected());
 
-  EXPECT_EQ(Status::Ok(), kvs_.Init());
+  EXPECT_EQ(OkStatus(), kvs_.Init());
   stats = kvs_.GetStorageStats();
   EXPECT_EQ(stats.in_use_bytes, (192u * kvs_.redundancy()));
   EXPECT_EQ(stats.reclaimable_bytes, 0u);
@@ -773,10 +773,10 @@
 
   char val[20] = {};
   EXPECT_EQ(
-      Status::Ok(),
+      OkStatus(),
       kvs_.Get("new key", std::as_writable_bytes(std::span(val))).status());
 
-  EXPECT_EQ(Status::Ok(), kvs_.FullMaintenance());
+  EXPECT_EQ(OkStatus(), kvs_.FullMaintenance());
   stats = kvs_.GetStorageStats();
   EXPECT_EQ(stats.in_use_bytes, (224u * kvs_.redundancy()));
   EXPECT_EQ(stats.reclaimable_bytes, 0u);
@@ -785,12 +785,12 @@
   EXPECT_EQ(stats.missing_redundant_entries_recovered, 0u);
 
   EXPECT_EQ(
-      Status::Ok(),
+      OkStatus(),
       kvs_.Get("new key", std::as_writable_bytes(std::span(val))).status());
 }
 
 TEST_F(InitializedRedundantMultiMagicKvs, DataLossAfterLosingBothCopies) {
-  EXPECT_EQ(Status::Ok(), partition_.Erase(0, 2));
+  EXPECT_EQ(OkStatus(), partition_.Erase(0, 2));
 
   char val[20] = {};
   EXPECT_EQ(Status::DataLoss(),
@@ -815,7 +815,7 @@
 }
 
 TEST_F(InitializedRedundantMultiMagicKvs, PutNewEntry_UsesFirstFormat) {
-  EXPECT_EQ(Status::Ok(), kvs_.Put("new key", bytes::String("abcd?")));
+  EXPECT_EQ(OkStatus(), kvs_.Put("new key", bytes::String("abcd?")));
 
   constexpr auto kNewEntry =
       MakeValidEntry(kMagic, 129, "new key", bytes::String("abcd?"));
@@ -827,7 +827,7 @@
 }
 
 TEST_F(InitializedRedundantMultiMagicKvs, PutExistingEntry_UsesFirstFormat) {
-  EXPECT_EQ(Status::Ok(), kvs_.Put("A Key", bytes::String("New value!")));
+  EXPECT_EQ(OkStatus(), kvs_.Put("A Key", bytes::String("New value!")));
 
   constexpr auto kNewEntry =
       MakeValidEntry(kMagic, 129, "A Key", bytes::String("New value!"));
@@ -843,20 +843,20 @@
     char val[sizeof(str_value)] = {};                         \
     StatusWithSize stat =                                     \
         kvs.Get(key, std::as_writable_bytes(std::span(val))); \
-    ASSERT_EQ(Status::Ok(), stat.status());                   \
+    ASSERT_EQ(OkStatus(), stat.status());                     \
     ASSERT_EQ(sizeof(str_value) - 1, stat.size());            \
     ASSERT_STREQ(str_value, val);                             \
   } while (0)
 
 TEST_F(InitializedRedundantMultiMagicKvs, UpdateEntryFormat) {
-  ASSERT_EQ(Status::Ok(), kvs_.FullMaintenance());
+  ASSERT_EQ(OkStatus(), kvs_.FullMaintenance());
 
   KeyValueStoreBuffer<kMaxEntries, kMaxUsableSectors, 2, 1> local_kvs(
       &partition_,
       {.magic = kMagic, .checksum = &default_checksum},
       kNoGcOptions);
 
-  ASSERT_EQ(Status::Ok(), local_kvs.Init());
+  ASSERT_EQ(OkStatus(), local_kvs.Init());
   EXPECT_EQ(false, local_kvs.error_detected());
   ASSERT_KVS_CONTAINS_ENTRY(local_kvs, "key1", "value1");
   ASSERT_KVS_CONTAINS_ENTRY(local_kvs, "k2", "value2");
@@ -885,7 +885,7 @@
                 kInitialContents.data(),
                 kInitialContents.size());
 
-    EXPECT_EQ(Status::Ok(), kvs_.Init());
+    EXPECT_EQ(OkStatus(), kvs_.Init());
   }
 
   FakeFlashMemoryBuffer<512, 4, 3> flash_;
@@ -906,14 +906,14 @@
 // Similar to test for InitializedRedundantMultiMagicKvs. Doing similar test
 // with different KVS configuration.
 TEST_F(InitializedMultiMagicKvs, UpdateEntryFormat) {
-  ASSERT_EQ(Status::Ok(), kvs_.FullMaintenance());
+  ASSERT_EQ(OkStatus(), kvs_.FullMaintenance());
 
   KeyValueStoreBuffer<kMaxEntries, kMaxUsableSectors, 1, 1> local_kvs(
       &partition_,
       {.magic = kMagic, .checksum = &default_checksum},
       kNoGcOptions);
 
-  ASSERT_EQ(Status::Ok(), local_kvs.Init());
+  ASSERT_EQ(OkStatus(), local_kvs.Init());
   EXPECT_EQ(false, local_kvs.error_detected());
   ASSERT_KVS_CONTAINS_ENTRY(local_kvs, "key1", "value1");
   ASSERT_KVS_CONTAINS_ENTRY(local_kvs, "k2", "value2");
@@ -938,7 +938,7 @@
                 kInitialContents.data(),
                 kInitialContents.size());
 
-    EXPECT_EQ(Status::Ok(), kvs_.Init());
+    EXPECT_EQ(OkStatus(), kvs_.Init());
   }
 
   FakeFlashMemoryBuffer<512, 4, 3> flash_;
@@ -947,7 +947,7 @@
 };
 
 TEST_F(InitializedRedundantLazyRecoveryKvs, WriteAfterDataLoss) {
-  EXPECT_EQ(Status::Ok(), partition_.Erase(0, 4));
+  EXPECT_EQ(OkStatus(), partition_.Erase(0, 4));
 
   char val[20] = {};
   EXPECT_EQ(Status::DataLoss(),
@@ -970,7 +970,7 @@
 
   ASSERT_EQ(Status::DataLoss(), kvs_.Put("key1", 1000));
 
-  EXPECT_EQ(Status::Ok(), kvs_.FullMaintenance());
+  EXPECT_EQ(OkStatus(), kvs_.FullMaintenance());
   stats = kvs_.GetStorageStats();
   EXPECT_EQ(stats.in_use_bytes, 0u);
   EXPECT_EQ(stats.reclaimable_bytes, 0u);
@@ -1005,7 +1005,7 @@
   ASSERT_CONTAINS_ENTRY("k3y", "value3");
   ASSERT_CONTAINS_ENTRY("4k", "value4");
 
-  EXPECT_EQ(Status::Ok(), kvs_.FullMaintenance());
+  EXPECT_EQ(OkStatus(), kvs_.FullMaintenance());
   stats = kvs_.GetStorageStats();
   EXPECT_EQ(stats.in_use_bytes, (128u * kvs_.redundancy()));
   EXPECT_EQ(stats.reclaimable_bytes, 0u);
@@ -1030,7 +1030,7 @@
                 kInitialContents.data(),
                 kInitialContents.size());
 
-    EXPECT_EQ(Status::Ok(), kvs_.Init());
+    EXPECT_EQ(OkStatus(), kvs_.Init());
   }
 
   FakeFlashMemoryBuffer<512, 8> flash_;
@@ -1066,17 +1066,17 @@
   // Add a near-sector size key entry to fill the KVS with a valid large entry
   // and stale data. Modify the value in between Puts so it actually writes
   // (identical value writes are skipped).
-  EXPECT_EQ(Status::Ok(), kvs_.Put("big_key", test_data));
+  EXPECT_EQ(OkStatus(), kvs_.Put("big_key", test_data));
   test_data[0]++;
-  EXPECT_EQ(Status::Ok(), kvs_.Put("big_key", test_data));
+  EXPECT_EQ(OkStatus(), kvs_.Put("big_key", test_data));
   test_data[0]++;
-  EXPECT_EQ(Status::Ok(), kvs_.Put("big_key", test_data));
+  EXPECT_EQ(OkStatus(), kvs_.Put("big_key", test_data));
   test_data[0]++;
-  EXPECT_EQ(Status::Ok(), kvs_.Put("big_key", test_data));
+  EXPECT_EQ(OkStatus(), kvs_.Put("big_key", test_data));
   test_data[0]++;
-  EXPECT_EQ(Status::Ok(), kvs_.Put("big_key", test_data));
+  EXPECT_EQ(OkStatus(), kvs_.Put("big_key", test_data));
   test_data[0]++;
-  EXPECT_EQ(Status::Ok(), kvs_.Put("big_key", test_data));
+  EXPECT_EQ(OkStatus(), kvs_.Put("big_key", test_data));
 
   // Instantiate a new KVS with redundancy of 2. This KVS should add an extra
   // copy of each valid key as part of the init process. Because there is not
@@ -1086,7 +1086,7 @@
       &partition_,
       {.magic = kMagic, .checksum = &default_checksum},
       kRecoveryLazyGcOptions);
-  ASSERT_EQ(Status::Ok(), local_kvs.Init());
+  ASSERT_EQ(OkStatus(), local_kvs.Init());
 
   // Verify no errors found in the new KVS and all the entries are present.
   EXPECT_EQ(false, local_kvs.error_detected());
@@ -1095,7 +1095,7 @@
   ASSERT_KVS_CONTAINS_ENTRY(local_kvs, "k3y", "value3");
   ASSERT_KVS_CONTAINS_ENTRY(local_kvs, "4k", "value4");
   StatusWithSize big_key_size = local_kvs.ValueSize("big_key");
-  EXPECT_EQ(Status::Ok(), big_key_size.status());
+  EXPECT_EQ(OkStatus(), big_key_size.status());
   EXPECT_EQ(sizeof(test_data), big_key_size.size());
 
   // Verify that storage stats of the new redundant KVS match expected values.
diff --git a/pw_kvs/key_value_store_fuzz_test.cc b/pw_kvs/key_value_store_fuzz_test.cc
index 4fd86bc..6a257d6 100644
--- a/pw_kvs/key_value_store_fuzz_test.cc
+++ b/pw_kvs/key_value_store_fuzz_test.cc
@@ -46,7 +46,7 @@
   EmptyInitializedKvs()
       : kvs_(&test_partition, {.magic = 0x873a9b50, .checksum = &checksum}) {
     test_partition.Erase(0, test_partition.sector_count());
-    ASSERT_EQ(Status::Ok(), kvs_.Init());
+    ASSERT_EQ(OkStatus(), kvs_.Init());
   }
 
   KeyValueStoreBuffer<kMaxEntries, kMaxUsableSectors> kvs_;
@@ -63,7 +63,7 @@
   for (int i = 0; i < kFuzzIterations; ++i) {
     for (unsigned key_size = 1; key_size < sizeof(value); ++key_size) {
       for (unsigned value_size = 0; value_size < sizeof(value); ++value_size) {
-        ASSERT_EQ(Status::Ok(),
+        ASSERT_EQ(OkStatus(),
                   kvs_.Put(std::string_view(value, key_size),
                            std::as_bytes(std::span(value, value_size))));
       }
diff --git a/pw_kvs/key_value_store_initialized_test.cc b/pw_kvs/key_value_store_initialized_test.cc
index 7ea3fd9..9994fb6 100644
--- a/pw_kvs/key_value_store_initialized_test.cc
+++ b/pw_kvs/key_value_store_initialized_test.cc
@@ -83,7 +83,7 @@
  protected:
   EmptyInitializedKvs() : kvs_(&test_partition, default_format) {
     test_partition.Erase();
-    ASSERT_EQ(Status::Ok(), kvs_.Init());
+    ASSERT_EQ(OkStatus(), kvs_.Init());
   }
 
   // Intention of this is to put and erase key-val to fill up sectors. It's a
@@ -107,7 +107,7 @@
     while (size_to_fill > 0) {
       // Changing buffer value so put actually does something
       buffer[0] = static_cast<byte>(static_cast<uint8_t>(buffer[0]) + 1);
-      ASSERT_EQ(Status::Ok(),
+      ASSERT_EQ(OkStatus(),
                 kvs_.Put(key,
                          std::span(buffer.data(),
                                    chunk_len - kvs_attr.ChunkHeaderSize() -
@@ -115,7 +115,7 @@
       size_to_fill -= chunk_len;
       chunk_len = std::min(size_to_fill, kMaxPutSize);
     }
-    ASSERT_EQ(Status::Ok(), kvs_.Delete(key));
+    ASSERT_EQ(OkStatus(), kvs_.Delete(key));
   }
 
   KeyValueStoreBuffer<kMaxEntries, kMaxUsableSectors> kvs_;
@@ -127,7 +127,7 @@
   std::array<char, 8> value{'v', 'a', 'l', 'u', 'e', '6', '7', '\0'};
 
   for (int i = 0; i < 1000; ++i) {
-    ASSERT_EQ(Status::Ok(),
+    ASSERT_EQ(OkStatus(),
               kvs_.Put("The Key!", std::as_bytes(std::span(value))));
   }
 }
@@ -136,7 +136,7 @@
   std::array<char, 7> value{'v', 'a', 'l', 'u', 'e', '6', '\0'};
 
   for (int i = 0; i < 1000; ++i) {
-    ASSERT_EQ(Status::Ok(),
+    ASSERT_EQ(OkStatus(),
               kvs_.Put("The Key!", std::as_bytes(std::span(value))));
   }
 }
@@ -146,27 +146,27 @@
 
   for (int i = 0; i < 100; ++i) {
     for (unsigned size = 0; size < value.size(); ++size) {
-      ASSERT_EQ(Status::Ok(), kvs_.Put("The Key!", i));
+      ASSERT_EQ(OkStatus(), kvs_.Put("The Key!", i));
     }
   }
 }
 
 TEST_F(EmptyInitializedKvs, PutAndGetByValue_ConvertibleToSpan) {
   constexpr float input[] = {1.0, -3.5};
-  ASSERT_EQ(Status::Ok(), kvs_.Put("key", input));
+  ASSERT_EQ(OkStatus(), kvs_.Put("key", input));
 
   float output[2] = {};
-  ASSERT_EQ(Status::Ok(), kvs_.Get("key", &output));
+  ASSERT_EQ(OkStatus(), kvs_.Get("key", &output));
   EXPECT_EQ(input[0], output[0]);
   EXPECT_EQ(input[1], output[1]);
 }
 
 TEST_F(EmptyInitializedKvs, PutAndGetByValue_Span) {
   float input[] = {1.0, -3.5};
-  ASSERT_EQ(Status::Ok(), kvs_.Put("key", std::span(input)));
+  ASSERT_EQ(OkStatus(), kvs_.Put("key", std::span(input)));
 
   float output[2] = {};
-  ASSERT_EQ(Status::Ok(), kvs_.Get("key", &output));
+  ASSERT_EQ(OkStatus(), kvs_.Get("key", &output));
   EXPECT_EQ(input[0], output[0]);
   EXPECT_EQ(input[1], output[1]);
 }
@@ -178,39 +178,39 @@
   };
   const TestStruct input{-1234.5, true};
 
-  ASSERT_EQ(Status::Ok(), kvs_.Put("key", input));
+  ASSERT_EQ(OkStatus(), kvs_.Put("key", input));
 
   TestStruct output;
-  ASSERT_EQ(Status::Ok(), kvs_.Get("key", &output));
+  ASSERT_EQ(OkStatus(), kvs_.Get("key", &output));
   EXPECT_EQ(input.a, output.a);
   EXPECT_EQ(input.b, output.b);
 }
 
 TEST_F(EmptyInitializedKvs, Get_Simple) {
-  ASSERT_EQ(Status::Ok(),
+  ASSERT_EQ(OkStatus(),
             kvs_.Put("Charles", std::as_bytes(std::span("Mingus"))));
 
   char value[16];
   auto result = kvs_.Get("Charles", std::as_writable_bytes(std::span(value)));
-  EXPECT_EQ(Status::Ok(), result.status());
+  EXPECT_EQ(OkStatus(), result.status());
   EXPECT_EQ(sizeof("Mingus"), result.size());
   EXPECT_STREQ("Mingus", value);
 }
 
 TEST_F(EmptyInitializedKvs, Get_WithOffset) {
-  ASSERT_EQ(Status::Ok(),
+  ASSERT_EQ(OkStatus(),
             kvs_.Put("Charles", std::as_bytes(std::span("Mingus"))));
 
   char value[16];
   auto result =
       kvs_.Get("Charles", std::as_writable_bytes(std::span(value)), 4);
-  EXPECT_EQ(Status::Ok(), result.status());
+  EXPECT_EQ(OkStatus(), result.status());
   EXPECT_EQ(sizeof("Mingus") - 4, result.size());
   EXPECT_STREQ("us", value);
 }
 
 TEST_F(EmptyInitializedKvs, Get_WithOffset_FillBuffer) {
-  ASSERT_EQ(Status::Ok(),
+  ASSERT_EQ(OkStatus(),
             kvs_.Put("Charles", std::as_bytes(std::span("Mingus"))));
 
   char value[4] = {};
@@ -222,7 +222,7 @@
 }
 
 TEST_F(EmptyInitializedKvs, Get_WithOffset_PastEnd) {
-  ASSERT_EQ(Status::Ok(),
+  ASSERT_EQ(OkStatus(),
             kvs_.Put("Charles", std::as_bytes(std::span("Mingus"))));
 
   char value[16];
@@ -234,15 +234,15 @@
 }
 
 TEST_F(EmptyInitializedKvs, GetValue) {
-  ASSERT_EQ(Status::Ok(), kvs_.Put("key", uint32_t(0xfeedbeef)));
+  ASSERT_EQ(OkStatus(), kvs_.Put("key", uint32_t(0xfeedbeef)));
 
   uint32_t value = 0;
-  EXPECT_EQ(Status::Ok(), kvs_.Get("key", &value));
+  EXPECT_EQ(OkStatus(), kvs_.Get("key", &value));
   EXPECT_EQ(uint32_t(0xfeedbeef), value);
 }
 
 TEST_F(EmptyInitializedKvs, GetValue_TooSmall) {
-  ASSERT_EQ(Status::Ok(), kvs_.Put("key", uint32_t(0xfeedbeef)));
+  ASSERT_EQ(OkStatus(), kvs_.Put("key", uint32_t(0xfeedbeef)));
 
   uint8_t value = 0;
   EXPECT_EQ(Status::InvalidArgument(), kvs_.Get("key", &value));
@@ -250,7 +250,7 @@
 }
 
 TEST_F(EmptyInitializedKvs, GetValue_TooLarge) {
-  ASSERT_EQ(Status::Ok(), kvs_.Put("key", uint32_t(0xfeedbeef)));
+  ASSERT_EQ(OkStatus(), kvs_.Put("key", uint32_t(0xfeedbeef)));
 
   uint64_t value = 0;
   EXPECT_EQ(Status::InvalidArgument(), kvs_.Get("key", &value));
@@ -258,37 +258,36 @@
 }
 
 TEST_F(EmptyInitializedKvs, Delete_GetDeletedKey_ReturnsNotFound) {
-  ASSERT_EQ(Status::Ok(), kvs_.Put("kEy", std::as_bytes(std::span("123"))));
-  ASSERT_EQ(Status::Ok(), kvs_.Delete("kEy"));
+  ASSERT_EQ(OkStatus(), kvs_.Put("kEy", std::as_bytes(std::span("123"))));
+  ASSERT_EQ(OkStatus(), kvs_.Delete("kEy"));
 
   EXPECT_EQ(Status::NotFound(), kvs_.Get("kEy", {}).status());
   EXPECT_EQ(Status::NotFound(), kvs_.ValueSize("kEy").status());
 }
 
 TEST_F(EmptyInitializedKvs, Delete_AddBackKey_PersistsAfterInitialization) {
-  ASSERT_EQ(Status::Ok(), kvs_.Put("kEy", std::as_bytes(std::span("123"))));
-  ASSERT_EQ(Status::Ok(), kvs_.Delete("kEy"));
+  ASSERT_EQ(OkStatus(), kvs_.Put("kEy", std::as_bytes(std::span("123"))));
+  ASSERT_EQ(OkStatus(), kvs_.Delete("kEy"));
 
-  EXPECT_EQ(Status::Ok(), kvs_.Put("kEy", std::as_bytes(std::span("45678"))));
+  EXPECT_EQ(OkStatus(), kvs_.Put("kEy", std::as_bytes(std::span("45678"))));
   char data[6] = {};
-  ASSERT_EQ(Status::Ok(), kvs_.Get("kEy", &data));
+  ASSERT_EQ(OkStatus(), kvs_.Get("kEy", &data));
   EXPECT_STREQ(data, "45678");
 
   // Ensure that the re-added key is still present after reinitialization.
   KeyValueStoreBuffer<kMaxEntries, kMaxUsableSectors> new_kvs(&test_partition,
                                                               default_format);
-  ASSERT_EQ(Status::Ok(), new_kvs.Init());
+  ASSERT_EQ(OkStatus(), new_kvs.Init());
 
-  EXPECT_EQ(Status::Ok(),
-            new_kvs.Put("kEy", std::as_bytes(std::span("45678"))));
+  EXPECT_EQ(OkStatus(), new_kvs.Put("kEy", std::as_bytes(std::span("45678"))));
   char new_data[6] = {};
-  EXPECT_EQ(Status::Ok(), new_kvs.Get("kEy", &new_data));
+  EXPECT_EQ(OkStatus(), new_kvs.Get("kEy", &new_data));
   EXPECT_STREQ(data, "45678");
 }
 
 TEST_F(EmptyInitializedKvs, Delete_AllItems_KvsIsEmpty) {
-  ASSERT_EQ(Status::Ok(), kvs_.Put("kEy", std::as_bytes(std::span("123"))));
-  ASSERT_EQ(Status::Ok(), kvs_.Delete("kEy"));
+  ASSERT_EQ(OkStatus(), kvs_.Put("kEy", std::as_bytes(std::span("123"))));
+  ASSERT_EQ(OkStatus(), kvs_.Delete("kEy"));
 
   EXPECT_EQ(0u, kvs_.size());
   EXPECT_TRUE(kvs_.empty());
@@ -299,12 +298,12 @@
   constexpr std::string_view key1 = "D4";
   constexpr std::string_view key2 = "dFU6S";
 
-  ASSERT_EQ(Status::Ok(), kvs_.Put(key1, 1000));
+  ASSERT_EQ(OkStatus(), kvs_.Put(key1, 1000));
 
   EXPECT_EQ(Status::AlreadyExists(), kvs_.Put(key2, 999));
 
   int value = 0;
-  EXPECT_EQ(Status::Ok(), kvs_.Get(key1, &value));
+  EXPECT_EQ(OkStatus(), kvs_.Get(key1, &value));
   EXPECT_EQ(1000, value);
 
   EXPECT_EQ(Status::NotFound(), kvs_.Get(key2, &value));
@@ -317,8 +316,8 @@
   constexpr std::string_view key1 = "1U2";
   constexpr std::string_view key2 = "ahj9d";
 
-  ASSERT_EQ(Status::Ok(), kvs_.Put(key1, 1000));
-  ASSERT_EQ(Status::Ok(), kvs_.Delete(key1));
+  ASSERT_EQ(OkStatus(), kvs_.Put(key1, 1000));
+  ASSERT_EQ(OkStatus(), kvs_.Delete(key1));
 
   // key2 collides with key1's tombstone.
   EXPECT_EQ(Status::AlreadyExists(), kvs_.Put(key2, 999));
@@ -346,42 +345,41 @@
 }
 
 TEST_F(EmptyInitializedKvs, Iteration_OneItem) {
-  ASSERT_EQ(Status::Ok(), kvs_.Put("kEy", std::as_bytes(std::span("123"))));
+  ASSERT_EQ(OkStatus(), kvs_.Put("kEy", std::as_bytes(std::span("123"))));
 
   for (KeyValueStore::Item entry : kvs_) {
     EXPECT_STREQ(entry.key(), "kEy");  // Make sure null-terminated.
 
     char temp[sizeof("123")] = {};
-    EXPECT_EQ(Status::Ok(), entry.Get(&temp));
+    EXPECT_EQ(OkStatus(), entry.Get(&temp));
     EXPECT_STREQ("123", temp);
   }
 }
 
 TEST_F(EmptyInitializedKvs, Iteration_GetWithOffset) {
-  ASSERT_EQ(Status::Ok(),
-            kvs_.Put("key", std::as_bytes(std::span("not bad!"))));
+  ASSERT_EQ(OkStatus(), kvs_.Put("key", std::as_bytes(std::span("not bad!"))));
 
   for (KeyValueStore::Item entry : kvs_) {
     char temp[5];
     auto result = entry.Get(std::as_writable_bytes(std::span(temp)), 4);
-    EXPECT_EQ(Status::Ok(), result.status());
+    EXPECT_EQ(OkStatus(), result.status());
     EXPECT_EQ(5u, result.size());
     EXPECT_STREQ("bad!", temp);
   }
 }
 
 TEST_F(EmptyInitializedKvs, Iteration_GetValue) {
-  ASSERT_EQ(Status::Ok(), kvs_.Put("key", uint32_t(0xfeedbeef)));
+  ASSERT_EQ(OkStatus(), kvs_.Put("key", uint32_t(0xfeedbeef)));
 
   for (KeyValueStore::Item entry : kvs_) {
     uint32_t value = 0;
-    EXPECT_EQ(Status::Ok(), entry.Get(&value));
+    EXPECT_EQ(OkStatus(), entry.Get(&value));
     EXPECT_EQ(uint32_t(0xfeedbeef), value);
   }
 }
 
 TEST_F(EmptyInitializedKvs, Iteration_GetValue_TooSmall) {
-  ASSERT_EQ(Status::Ok(), kvs_.Put("key", uint32_t(0xfeedbeef)));
+  ASSERT_EQ(OkStatus(), kvs_.Put("key", uint32_t(0xfeedbeef)));
 
   for (KeyValueStore::Item entry : kvs_) {
     uint8_t value = 0;
@@ -391,7 +389,7 @@
 }
 
 TEST_F(EmptyInitializedKvs, Iteration_GetValue_TooLarge) {
-  ASSERT_EQ(Status::Ok(), kvs_.Put("key", uint32_t(0xfeedbeef)));
+  ASSERT_EQ(OkStatus(), kvs_.Put("key", uint32_t(0xfeedbeef)));
 
   for (KeyValueStore::Item entry : kvs_) {
     uint64_t value = 0;
@@ -401,8 +399,8 @@
 }
 
 TEST_F(EmptyInitializedKvs, Iteration_EmptyAfterDeletion) {
-  ASSERT_EQ(Status::Ok(), kvs_.Put("kEy", std::as_bytes(std::span("123"))));
-  ASSERT_EQ(Status::Ok(), kvs_.Delete("kEy"));
+  ASSERT_EQ(OkStatus(), kvs_.Put("kEy", std::as_bytes(std::span("123"))));
+  ASSERT_EQ(OkStatus(), kvs_.Delete("kEy"));
 
   for (KeyValueStore::Item entry : kvs_) {
     static_cast<void>(entry);
@@ -425,10 +423,10 @@
   std::memset(buf2, 2, sizeof(buf2));
 
   // Start with things in KVS
-  ASSERT_EQ(Status::Ok(), kvs_.Put(key1, buf1));
-  ASSERT_EQ(Status::Ok(), kvs_.Put(key2, buf2));
+  ASSERT_EQ(OkStatus(), kvs_.Put(key1, buf1));
+  ASSERT_EQ(OkStatus(), kvs_.Put(key2, buf2));
   for (size_t j = 0; j < keys.size(); j++) {
-    ASSERT_EQ(Status::Ok(), kvs_.Put(keys[j], j));
+    ASSERT_EQ(OkStatus(), kvs_.Put(keys[j], j));
   }
 
   for (size_t i = 0; i < 100; i++) {
@@ -437,28 +435,28 @@
     size_t size2 = (kLargestBufSize) / (100 - i);
     for (size_t j = 0; j < 50; j++) {
       // Rewrite a single key many times, can fill up a sector
-      ASSERT_EQ(Status::Ok(), kvs_.Put("some_data", j));
+      ASSERT_EQ(OkStatus(), kvs_.Put("some_data", j));
     }
     // Delete and re-add everything
-    ASSERT_EQ(Status::Ok(), kvs_.Delete(key1));
-    ASSERT_EQ(Status::Ok(), kvs_.Put(key1, std::span(buf1, size1)));
-    ASSERT_EQ(Status::Ok(), kvs_.Delete(key2));
-    ASSERT_EQ(Status::Ok(), kvs_.Put(key2, std::span(buf2, size2)));
+    ASSERT_EQ(OkStatus(), kvs_.Delete(key1));
+    ASSERT_EQ(OkStatus(), kvs_.Put(key1, std::span(buf1, size1)));
+    ASSERT_EQ(OkStatus(), kvs_.Delete(key2));
+    ASSERT_EQ(OkStatus(), kvs_.Put(key2, std::span(buf2, size2)));
     for (size_t j = 0; j < keys.size(); j++) {
-      ASSERT_EQ(Status::Ok(), kvs_.Delete(keys[j]));
-      ASSERT_EQ(Status::Ok(), kvs_.Put(keys[j], j));
+      ASSERT_EQ(OkStatus(), kvs_.Delete(keys[j]));
+      ASSERT_EQ(OkStatus(), kvs_.Put(keys[j], j));
     }
 
     // Re-enable and verify
-    ASSERT_EQ(Status::Ok(), kvs_.Init());
+    ASSERT_EQ(OkStatus(), kvs_.Init());
     static byte buf[4 * 1024];
-    ASSERT_EQ(Status::Ok(), kvs_.Get(key1, std::span(buf, size1)).status());
+    ASSERT_EQ(OkStatus(), kvs_.Get(key1, std::span(buf, size1)).status());
     ASSERT_EQ(std::memcmp(buf, buf1, size1), 0);
-    ASSERT_EQ(Status::Ok(), kvs_.Get(key2, std::span(buf, size2)).status());
+    ASSERT_EQ(OkStatus(), kvs_.Get(key2, std::span(buf, size2)).status());
     ASSERT_EQ(std::memcmp(buf2, buf2, size2), 0);
     for (size_t j = 0; j < keys.size(); j++) {
       size_t ret = 1000;
-      ASSERT_EQ(Status::Ok(), kvs_.Get(keys[j], &ret));
+      ASSERT_EQ(OkStatus(), kvs_.Get(keys[j], &ret));
       ASSERT_EQ(ret, j);
     }
   }
@@ -468,28 +466,28 @@
   // Add some data
   uint8_t value1 = 0xDA;
   ASSERT_EQ(
-      Status::Ok(),
+      OkStatus(),
       kvs_.Put(keys[0], std::as_bytes(std::span(&value1, sizeof(value1)))));
 
   uint32_t value2 = 0xBAD0301f;
-  ASSERT_EQ(Status::Ok(), kvs_.Put(keys[1], value2));
+  ASSERT_EQ(OkStatus(), kvs_.Put(keys[1], value2));
 
   // Verify data
   uint32_t test2;
-  EXPECT_EQ(Status::Ok(), kvs_.Get(keys[1], &test2));
+  EXPECT_EQ(OkStatus(), kvs_.Get(keys[1], &test2));
   uint8_t test1;
-  ASSERT_EQ(Status::Ok(), kvs_.Get(keys[0], &test1));
+  ASSERT_EQ(OkStatus(), kvs_.Get(keys[0], &test1));
 
   EXPECT_EQ(test1, value1);
   EXPECT_EQ(test2, value2);
 
   // Delete a key
-  EXPECT_EQ(Status::Ok(), kvs_.Delete(keys[0]));
+  EXPECT_EQ(OkStatus(), kvs_.Delete(keys[0]));
 
   // Verify it was erased
   EXPECT_EQ(kvs_.Get(keys[0], &test1), Status::NotFound());
   test2 = 0;
-  ASSERT_EQ(Status::Ok(),
+  ASSERT_EQ(OkStatus(),
             kvs_.Get(keys[1],
                      std::span(reinterpret_cast<byte*>(&test2), sizeof(test2)))
                 .status());
diff --git a/pw_kvs/key_value_store_map_test.cc b/pw_kvs/key_value_store_map_test.cc
index 27b961c..947a3fa 100644
--- a/pw_kvs/key_value_store_map_test.cc
+++ b/pw_kvs/key_value_store_map_test.cc
@@ -88,9 +88,9 @@
         // For KVS magic value always use a random 32 bit integer rather than a
         // human readable 4 bytes. See pw_kvs/format.h for more information.
         kvs_(&partition_, {.magic = 0xc857e51d, .checksum = nullptr}) {
-    EXPECT_EQ(Status::Ok(), partition_.Erase());
+    EXPECT_EQ(OkStatus(), partition_.Erase());
     Status result = kvs_.Init();
-    EXPECT_EQ(Status::Ok(), result);
+    EXPECT_EQ(OkStatus(), result);
 
     if (!result.ok()) {
       std::abort();
@@ -250,7 +250,7 @@
         EXPECT_EQ(map_entry->first, item.key());
 
         char value[kMaxValueLength + 1] = {};
-        EXPECT_EQ(Status::Ok(),
+        EXPECT_EQ(OkStatus(),
                   item.Get(std::as_writable_bytes(std::span(value))).status());
         EXPECT_EQ(map_entry->second, std::string(value));
       }
@@ -270,7 +270,7 @@
       EXPECT_EQ(Status::InvalidArgument(), result);
     } else if (map_.size() == kvs_.max_size()) {
       EXPECT_EQ(Status::ResourceExhausted(), result);
-    } else if (result == Status::ResourceExhausted()) {
+    } else if (result.IsResourceExhausted()) {
       EXPECT_FALSE(map_.empty());
     } else if (result.ok()) {
       map_[key] = value;
@@ -302,7 +302,7 @@
       }
 
       deleted_.insert(key);
-    } else if (result == Status::ResourceExhausted()) {
+    } else if (result.IsResourceExhausted()) {
       PW_LOG_WARN("Delete: RESOURCE_EXHAUSTED could not delete key %s",
                   key.c_str());
     } else {
@@ -315,14 +315,14 @@
   void Init() {
     StartOperation("Init");
     Status status = kvs_.Init();
-    EXPECT_EQ(Status::Ok(), status);
+    EXPECT_EQ(OkStatus(), status);
     FinishOperation("Init", status);
   }
 
   void GCFull() {
     StartOperation("GCFull");
     Status status = kvs_.FullMaintenance();
-    EXPECT_EQ(Status::Ok(), status);
+    EXPECT_EQ(OkStatus(), status);
 
     KeyValueStore::StorageStats post_stats = kvs_.GetStorageStats();
     if (post_stats.in_use_bytes > ((partition_.size_bytes() * 70) / 100)) {
@@ -338,7 +338,7 @@
     Status status = kvs_.PartialMaintenance();
     KeyValueStore::StorageStats post_stats = kvs_.GetStorageStats();
     if (pre_stats.reclaimable_bytes != 0) {
-      EXPECT_EQ(Status::Ok(), status);
+      EXPECT_EQ(OkStatus(), status);
       EXPECT_LT(post_stats.reclaimable_bytes, pre_stats.reclaimable_bytes);
     } else {
       EXPECT_EQ(Status::NotFound(), status);
diff --git a/pw_kvs/key_value_store_test.cc b/pw_kvs/key_value_store_test.cc
index 140d174..2926cce 100644
--- a/pw_kvs/key_value_store_test.cc
+++ b/pw_kvs/key_value_store_test.cc
@@ -14,7 +14,6 @@
 
 #define DUMP_KVS_STATE_TO_FILE 0
 #define USE_MEMORY_BUFFER 1
-#define PW_LOG_USE_ULTRA_SHORT_NAMES 1
 
 #include "pw_kvs/key_value_store.h"
 
@@ -35,6 +34,7 @@
 #include "pw_kvs/flash_memory.h"
 #include "pw_kvs/internal/entry.h"
 #include "pw_log/log.h"
+#include "pw_log/shorter.h"
 #include "pw_status/status.h"
 #include "pw_string/string_builder.h"
 
@@ -47,72 +47,15 @@
 constexpr size_t kMaxEntries = 256;
 constexpr size_t kMaxUsableSectors = 256;
 
-// Test the functions in byte_utils.h. Create a byte array with bytes::Concat
-// and bytes::String and check that its contents are correct.
-constexpr std::array<char, 2> kTestArray = {'a', 'b'};
-
-constexpr auto kAsBytesTest = bytes::Concat('a',
-                                            uint16_t(1),
-                                            uint8_t(23),
-                                            kTestArray,
-                                            bytes::String("c"),
-                                            uint64_t(-1));
-
-static_assert(kAsBytesTest.size() == 15);
-static_assert(kAsBytesTest[0] == std::byte{'a'});
-static_assert(kAsBytesTest[1] == std::byte{1});
-static_assert(kAsBytesTest[2] == std::byte{0});
-static_assert(kAsBytesTest[3] == std::byte{23});
-static_assert(kAsBytesTest[4] == std::byte{'a'});
-static_assert(kAsBytesTest[5] == std::byte{'b'});
-static_assert(kAsBytesTest[6] == std::byte{'c'});
-static_assert(kAsBytesTest[7] == std::byte{0xff});
-static_assert(kAsBytesTest[8] == std::byte{0xff});
-static_assert(kAsBytesTest[9] == std::byte{0xff});
-static_assert(kAsBytesTest[10] == std::byte{0xff});
-static_assert(kAsBytesTest[11] == std::byte{0xff});
-static_assert(kAsBytesTest[12] == std::byte{0xff});
-static_assert(kAsBytesTest[13] == std::byte{0xff});
-static_assert(kAsBytesTest[14] == std::byte{0xff});
-
-// Test that the ConvertsToSpan trait correctly idenitifies types that convert
-// to std::span.
-static_assert(!ConvertsToSpan<int>());
-static_assert(!ConvertsToSpan<void>());
-static_assert(!ConvertsToSpan<std::byte>());
-static_assert(!ConvertsToSpan<std::byte*>());
-
-static_assert(ConvertsToSpan<std::array<int, 5>>());
-static_assert(ConvertsToSpan<decltype("Hello!")>());
-
-static_assert(ConvertsToSpan<std::string_view>());
-static_assert(ConvertsToSpan<std::string_view&>());
-static_assert(ConvertsToSpan<std::string_view&&>());
-
-static_assert(ConvertsToSpan<const std::string_view>());
-static_assert(ConvertsToSpan<const std::string_view&>());
-static_assert(ConvertsToSpan<const std::string_view&&>());
-
-static_assert(ConvertsToSpan<bool[1]>());
-static_assert(ConvertsToSpan<char[35]>());
-static_assert(ConvertsToSpan<const int[35]>());
-
-static_assert(ConvertsToSpan<std::span<int>>());
-static_assert(ConvertsToSpan<std::span<byte>>());
-static_assert(ConvertsToSpan<std::span<const int*>>());
-static_assert(ConvertsToSpan<std::span<bool>&&>());
-static_assert(ConvertsToSpan<const std::span<bool>&>());
-static_assert(ConvertsToSpan<std::span<bool>&&>());
-
 // This is a self contained flash unit with both memory and a single partition.
-template <uint32_t sector_size_bytes, uint16_t sector_count>
+template <uint32_t kSectorSizeBytes, uint16_t kSectorCount>
 struct FlashWithPartitionFake {
   // Default to 16 byte alignment, which is common in practice.
   FlashWithPartitionFake() : FlashWithPartitionFake(16) {}
   FlashWithPartitionFake(size_t alignment_bytes)
       : memory(alignment_bytes), partition(&memory, 0, memory.sector_count()) {}
 
-  FakeFlashMemoryBuffer<sector_size_bytes, sector_count> memory;
+  FakeFlashMemoryBuffer<kSectorSizeBytes, kSectorCount> memory;
   FlashPartition partition;
 
  public:
@@ -126,7 +69,7 @@
     std::vector<std::byte> out_vec(memory.size_bytes());
     Status status =
         memory.Read(0, std::span<std::byte>(out_vec.data(), out_vec.size()));
-    if (status != Status::Ok()) {
+    if (status != OkStatus()) {
       fclose(out_file);
       return status;
     }
@@ -140,14 +83,14 @@
       status = Status::DataLoss();
     } else {
       PW_LOG_INFO("Dumped to %s", filename);
-      status = Status::Ok();
+      status = OkStatus();
     }
 
     fclose(out_file);
     return status;
   }
 #else
-  Status Dump(const char*) { return Status::Ok(); }
+  Status Dump(const char*) { return OkStatus(); }
 #endif  // DUMP_KVS_STATE_TO_FILE
 };
 
@@ -213,8 +156,8 @@
   EXPECT_EQ(kvs.Init(), Status::FailedPrecondition());
 }
 
-#define ASSERT_OK(expr) ASSERT_EQ(Status::Ok(), expr)
-#define EXPECT_OK(expr) EXPECT_EQ(Status::Ok(), expr)
+#define ASSERT_OK(expr) ASSERT_EQ(OkStatus(), expr)
+#define EXPECT_OK(expr) EXPECT_EQ(OkStatus(), expr)
 
 TEST(InMemoryKvs, WriteOneKeyMultipleTimes) {
   // Create and erase the fake flash. It will persist across reloads.
@@ -358,7 +301,7 @@
 
   // Create and erase the fake flash.
   Flash flash;
-  ASSERT_EQ(Status::Ok(), flash.partition.Erase());
+  ASSERT_EQ(OkStatus(), flash.partition.Erase());
 
   // Create and initialize the KVS.
   // For KVS magic value always use a random 32 bit integer rather than a
@@ -393,7 +336,7 @@
 TEST(InMemoryKvs, CallingEraseTwice_NothingWrittenToFlash) {
   // Create and erase the fake flash.
   Flash flash;
-  ASSERT_EQ(Status::Ok(), flash.partition.Erase());
+  ASSERT_EQ(OkStatus(), flash.partition.Erase());
 
   // Create and initialize the KVS.
   KeyValueStoreBuffer<kMaxEntries, kMaxUsableSectors> kvs(&flash.partition,
@@ -401,8 +344,8 @@
   ASSERT_OK(kvs.Init());
 
   const uint8_t kValue = 0xDA;
-  ASSERT_EQ(Status::Ok(), kvs.Put(keys[0], kValue));
-  ASSERT_EQ(Status::Ok(), kvs.Delete(keys[0]));
+  ASSERT_EQ(OkStatus(), kvs.Put(keys[0], kValue));
+  ASSERT_EQ(OkStatus(), kvs.Delete(keys[0]));
 
   // Compare before / after checksums to verify that nothing was written.
   const uint16_t crc = checksum::Crc16Ccitt::Calculate(flash.memory.buffer());
@@ -415,8 +358,8 @@
 class LargeEmptyInitializedKvs : public ::testing::Test {
  protected:
   LargeEmptyInitializedKvs() : kvs_(&large_test_partition, default_format) {
-    ASSERT_EQ(Status::Ok(), large_test_partition.Erase());
-    ASSERT_EQ(Status::Ok(), kvs_.Init());
+    ASSERT_EQ(OkStatus(), large_test_partition.Erase());
+    ASSERT_EQ(OkStatus(), kvs_.Init());
   }
 
   KeyValueStoreBuffer<kMaxEntries, kMaxUsableSectors> kvs_;
@@ -426,14 +369,14 @@
   const uint8_t kValue1 = 0xDA;
   const uint8_t kValue2 = 0x12;
   uint8_t value;
-  ASSERT_EQ(Status::Ok(), kvs_.Put(keys[0], kValue1));
+  ASSERT_EQ(OkStatus(), kvs_.Put(keys[0], kValue1));
   EXPECT_EQ(kvs_.size(), 1u);
-  ASSERT_EQ(Status::Ok(), kvs_.Delete(keys[0]));
+  ASSERT_EQ(OkStatus(), kvs_.Delete(keys[0]));
   EXPECT_EQ(kvs_.Get(keys[0], &value), Status::NotFound());
-  ASSERT_EQ(Status::Ok(), kvs_.Put(keys[1], kValue1));
-  ASSERT_EQ(Status::Ok(), kvs_.Put(keys[2], kValue2));
-  ASSERT_EQ(Status::Ok(), kvs_.Delete(keys[1]));
-  EXPECT_EQ(Status::Ok(), kvs_.Get(keys[2], &value));
+  ASSERT_EQ(OkStatus(), kvs_.Put(keys[1], kValue1));
+  ASSERT_EQ(OkStatus(), kvs_.Put(keys[2], kValue2));
+  ASSERT_EQ(OkStatus(), kvs_.Delete(keys[1]));
+  EXPECT_EQ(OkStatus(), kvs_.Get(keys[2], &value));
   EXPECT_EQ(kValue2, value);
   ASSERT_EQ(kvs_.Get(keys[1], &value), Status::NotFound());
   EXPECT_EQ(kvs_.size(), 1u);
@@ -445,8 +388,8 @@
 
   // Write a key and write again with a different value, resulting in a stale
   // entry from the first write.
-  ASSERT_EQ(Status::Ok(), kvs_.Put(keys[0], kValue1));
-  ASSERT_EQ(Status::Ok(), kvs_.Put(keys[0], kValue2));
+  ASSERT_EQ(OkStatus(), kvs_.Put(keys[0], kValue1));
+  ASSERT_EQ(OkStatus(), kvs_.Put(keys[0], kValue2));
   EXPECT_EQ(kvs_.size(), 1u);
 
   KeyValueStore::StorageStats stats = kvs_.GetStorageStats();
@@ -455,14 +398,14 @@
 
   // Do regular FullMaintenance, which should not touch the sector with valid
   // data.
-  EXPECT_EQ(Status::Ok(), kvs_.FullMaintenance());
+  EXPECT_EQ(OkStatus(), kvs_.FullMaintenance());
   stats = kvs_.GetStorageStats();
   EXPECT_EQ(stats.sector_erase_count, 0u);
   EXPECT_GT(stats.reclaimable_bytes, 0u);
 
   // Do aggressive FullMaintenance, which should GC the sector with valid data,
   // resulting in no reclaimable bytes and an erased sector.
-  EXPECT_EQ(Status::Ok(), kvs_.HeavyMaintenance());
+  EXPECT_EQ(OkStatus(), kvs_.HeavyMaintenance());
   stats = kvs_.GetStorageStats();
   EXPECT_EQ(stats.sector_erase_count, 1u);
   EXPECT_EQ(stats.reclaimable_bytes, 0u);
@@ -471,7 +414,7 @@
 TEST(InMemoryKvs, Put_MaxValueSize) {
   // Create and erase the fake flash.
   Flash flash;
-  ASSERT_EQ(Status::Ok(), flash.partition.Erase());
+  ASSERT_EQ(OkStatus(), flash.partition.Erase());
 
   // Create and initialize the KVS.
   KeyValueStoreBuffer<kMaxEntries, kMaxUsableSectors> kvs(&flash.partition,
@@ -491,7 +434,7 @@
   ASSERT_GT(sizeof(large_test_flash), max_value_size + 2 * sizeof(EntryHeader));
   auto big_data = std::as_bytes(std::span(&large_test_flash, 1));
 
-  EXPECT_EQ(Status::Ok(), kvs.Put("K", big_data.subspan(0, max_value_size)));
+  EXPECT_EQ(OkStatus(), kvs.Put("K", big_data.subspan(0, max_value_size)));
 
   // Larger than maximum is rejected.
   EXPECT_EQ(Status::InvalidArgument(),
diff --git a/pw_kvs/key_value_store_wear_test.cc b/pw_kvs/key_value_store_wear_test.cc
index d272535..4110c16 100644
--- a/pw_kvs/key_value_store_wear_test.cc
+++ b/pw_kvs/key_value_store_wear_test.cc
@@ -35,7 +35,7 @@
       : flash_(internal::Entry::kMinAlignmentBytes),
         partition_(&flash_, 0, flash_.sector_count()),
         kvs_(&partition_, format) {
-    EXPECT_EQ(Status::Ok(), kvs_.Init());
+    EXPECT_EQ(OkStatus(), kvs_.Init());
   }
 
   static constexpr size_t kSectors = 16;
@@ -92,7 +92,7 @@
     test_data[0]++;
 
     EXPECT_EQ(
-        Status::Ok(),
+        OkStatus(),
         kvs_.Put("key",
                  std::as_bytes(std::span(test_data, sizeof(test_data) - 70))));
   }
@@ -105,7 +105,7 @@
     test_data[0]++;
 
     printf("Add entry %zu\n", i);
-    EXPECT_EQ(Status::Ok(), kvs_.Put("big_key", test_data));
+    EXPECT_EQ(OkStatus(), kvs_.Put("big_key", test_data));
   }
 
   EXPECT_EQ(2u, kvs_.size());
diff --git a/pw_kvs/public/pw_kvs/alignment.h b/pw_kvs/public/pw_kvs/alignment.h
index 56d0c08..e86ec73 100644
--- a/pw_kvs/public/pw_kvs/alignment.h
+++ b/pw_kvs/public/pw_kvs/alignment.h
@@ -20,6 +20,7 @@
 #include <span>
 #include <utility>
 
+#include "pw_bytes/span.h"
 #include "pw_kvs/io.h"
 #include "pw_status/status_with_size.h"
 
@@ -71,7 +72,8 @@
   StatusWithSize Write(std::span<const std::byte> data);
 
   StatusWithSize Write(const void* data, size_t size) {
-    return Write(std::span(static_cast<const std::byte*>(data), size));
+    return Write(
+        std::span<const std::byte>(static_cast<const std::byte*>(data), size));
   }
 
   // Reads size bytes from the input and writes them to the output.
@@ -83,7 +85,7 @@
   StatusWithSize Flush();
 
  private:
-  static constexpr std::byte kPadByte = std::byte{0};
+  static constexpr std::byte kPadByte = static_cast<std::byte>(0);
 
   StatusWithSize AddBytesToBuffer(size_t bytes_added);
 
@@ -122,7 +124,8 @@
   AlignedWriterBuffer<kBufferSize> buffer(alignment_bytes, output);
 
   for (const std::span<const std::byte>& chunk : data) {
-    if (StatusWithSize result = buffer.Write(chunk); !result.ok()) {
+    StatusWithSize result = buffer.Write(chunk);
+    if (!result.ok()) {
       return result;
     }
   }
@@ -137,7 +140,9 @@
     size_t alignment_bytes,
     std::initializer_list<std::span<const std::byte>> data) {
   return AlignedWrite<kBufferSize>(
-      output, alignment_bytes, std::span(data.begin(), data.size()));
+      output,
+      alignment_bytes,
+      std::span<const ConstByteSpan>(data.begin(), data.size()));
 }
 
 }  // namespace pw
diff --git a/pw_kvs/public/pw_kvs/checksum.h b/pw_kvs/public/pw_kvs/checksum.h
index 5899566..7624e39 100644
--- a/pw_kvs/public/pw_kvs/checksum.h
+++ b/pw_kvs/public/pw_kvs/checksum.h
@@ -19,7 +19,8 @@
 #include "pw_kvs/alignment.h"
 #include "pw_status/status.h"
 
-namespace pw::kvs {
+namespace pw {
+namespace kvs {
 
 class ChecksumAlgorithm {
  public:
@@ -31,7 +32,8 @@
 
   // Updates the checksum from a pointer and size.
   void Update(const void* data, size_t size_bytes) {
-    return Update(std::span(static_cast<const std::byte*>(data), size_bytes));
+    return Update(std::span<const std::byte>(
+        static_cast<const std::byte*>(data), size_bytes));
   }
 
   // Returns the final result of the checksum. Update() can no longer be called
@@ -93,7 +95,7 @@
  protected:
   constexpr AlignedChecksum(std::span<const std::byte> state)
       : ChecksumAlgorithm(state),
-        output_(this),
+        output_(*this),
         writer_(kAlignmentBytes, output_) {}
 
   ~AlignedChecksum() = default;
@@ -110,8 +112,22 @@
 
   virtual void FinalizeAligned() = 0;
 
-  OutputToMethod<&AlignedChecksum::UpdateAligned> output_;
+  class CallUpdateAligned final : public Output {
+   public:
+    constexpr CallUpdateAligned(AlignedChecksum& object) : object_(object) {}
+
+   private:
+    StatusWithSize DoWrite(std::span<const std::byte> data) override {
+      object_.UpdateAligned(data);
+      return StatusWithSize(data.size());
+    }
+
+    AlignedChecksum& object_;
+  };
+
+  CallUpdateAligned output_;
   AlignedWriterBuffer<kBufferSize> writer_;
 };
 
-}  // namespace pw::kvs
+}  // namespace kvs
+}  // namespace pw
diff --git a/pw_kvs/public/pw_kvs/crc16_checksum.h b/pw_kvs/public/pw_kvs/crc16_checksum.h
index 1cbff63..852e192 100644
--- a/pw_kvs/public/pw_kvs/crc16_checksum.h
+++ b/pw_kvs/public/pw_kvs/crc16_checksum.h
@@ -22,7 +22,8 @@
 
 class ChecksumCrc16 final : public ChecksumAlgorithm {
  public:
-  ChecksumCrc16() : ChecksumAlgorithm(std::as_bytes(std::span(&crc_, 1))) {}
+  ChecksumCrc16()
+      : ChecksumAlgorithm(std::as_bytes(std::span<uint16_t>(&crc_, 1))) {}
 
   void Reset() override { crc_ = checksum::Crc16Ccitt::kInitialValue; }
 
diff --git a/pw_kvs/public/pw_kvs/fake_flash_memory.h b/pw_kvs/public/pw_kvs/fake_flash_memory.h
index 9795e01..e892d92 100644
--- a/pw_kvs/public/pw_kvs/fake_flash_memory.h
+++ b/pw_kvs/public/pw_kvs/fake_flash_memory.h
@@ -97,9 +97,9 @@
         write_errors_(write_errors) {}
 
   // The fake flash is always enabled.
-  Status Enable() override { return Status::Ok(); }
+  Status Enable() override { return OkStatus(); }
 
-  Status Disable() override { return Status::Ok(); }
+  Status Disable() override { return OkStatus(); }
 
   bool IsEnabled() const override { return true; }
 
diff --git a/pw_kvs/public/pw_kvs/flash_memory.h b/pw_kvs/public/pw_kvs/flash_memory.h
index d1864b4..b767386 100644
--- a/pw_kvs/public/pw_kvs/flash_memory.h
+++ b/pw_kvs/public/pw_kvs/flash_memory.h
@@ -23,7 +23,8 @@
 #include "pw_status/status.h"
 #include "pw_status/status_with_size.h"
 
-namespace pw::kvs {
+namespace pw {
+namespace kvs {
 
 enum class PartitionPermission : bool {
   kReadOnly,
@@ -41,7 +42,7 @@
               size_t alignment,
               uint32_t start_address = 0,
               uint32_t sector_start = 0,
-              std::byte erased_memory_content = std::byte{0xFF})
+              std::byte erased_memory_content = std::byte(0xFF))
       : sector_size_(sector_size),
         flash_sector_count_(sector_count),
         alignment_(alignment),
@@ -78,7 +79,8 @@
   virtual StatusWithSize Read(Address address, std::span<std::byte> output) = 0;
 
   StatusWithSize Read(Address address, void* buffer, size_t len) {
-    return Read(address, std::span(static_cast<std::byte*>(buffer), len));
+    return Read(address,
+                std::span<std::byte>(static_cast<std::byte*>(buffer), len));
   }
 
   // Writes bytes to flash. Blocking call. Returns:
@@ -93,8 +95,9 @@
   StatusWithSize Write(Address destination_flash_address,
                        const void* data,
                        size_t len) {
-    return Write(destination_flash_address,
-                 std::span(static_cast<const std::byte*>(data), len));
+    return Write(
+        destination_flash_address,
+        std::span<const std::byte>(static_cast<const std::byte*>(data), len));
   }
 
   // Convert an Address to an MCU pointer, this can be used for memory
@@ -163,7 +166,6 @@
     FlashPartition::Address address_;
   };
 
-  // TODO(pwbug/246): This can be constexpr when tokenized asserts are fixed.
   FlashPartition(
       FlashMemory* flash,
       uint32_t start_sector_index,
@@ -172,7 +174,6 @@
       PartitionPermission permission = PartitionPermission::kReadAndWrite);
 
   // Creates a FlashPartition that uses the entire flash with its alignment.
-  // TODO(pwbug/246): This can be constexpr when tokenized asserts are fixed.
   FlashPartition(FlashMemory* flash)
       : FlashPartition(
             flash, 0, flash->sector_count(), flash->alignment_bytes()) {}
@@ -184,7 +185,7 @@
   virtual ~FlashPartition() = default;
 
   // Performs any required partition or flash-level initialization.
-  virtual Status Init() { return Status::Ok(); }
+  virtual Status Init() { return OkStatus(); }
 
   // Erase num_sectors starting at a given address. Blocking call.
   // Address must be on a sector boundary. Returns:
@@ -207,7 +208,8 @@
   virtual StatusWithSize Read(Address address, std::span<std::byte> output);
 
   StatusWithSize Read(Address address, size_t length, void* output) {
-    return Read(address, std::span(static_cast<std::byte*>(output), length));
+    return Read(address,
+                std::span<std::byte>(static_cast<std::byte*>(output), length));
   }
 
   // Writes bytes to flash. Address and data.size_bytes() must both be a
@@ -230,7 +232,7 @@
   // UNKNOWN - HAL error
   // TODO: Result<bool>
   virtual Status IsRegionErased(Address source_flash_address,
-                                size_t len,
+                                size_t length,
                                 bool* is_erased);
 
   // Check if the entire partition is erased.
@@ -296,4 +298,5 @@
   const PartitionPermission permission_;
 };
 
-}  // namespace pw::kvs
+}  // namespace kvs
+}  // namespace pw
diff --git a/pw_kvs/public/pw_kvs/format.h b/pw_kvs/public/pw_kvs/format.h
index 19cd174..309f786 100644
--- a/pw_kvs/public/pw_kvs/format.h
+++ b/pw_kvs/public/pw_kvs/format.h
@@ -18,7 +18,8 @@
 
 #include "pw_kvs/checksum.h"
 
-namespace pw::kvs {
+namespace pw {
+namespace kvs {
 
 struct EntryFormat;
 
@@ -104,4 +105,5 @@
   ChecksumAlgorithm* checksum;
 };
 
-}  // namespace pw::kvs
+}  // namespace kvs
+}  // namespace pw
diff --git a/pw_kvs/public/pw_kvs/internal/entry.h b/pw_kvs/public/pw_kvs/internal/entry.h
index 31b0acd..aa94cbb 100644
--- a/pw_kvs/public/pw_kvs/internal/entry.h
+++ b/pw_kvs/public/pw_kvs/internal/entry.h
@@ -20,7 +20,6 @@
 #include <cstddef>
 #include <cstdint>
 #include <span>
-#include <string_view>
 
 #include "pw_kvs/alignment.h"
 #include "pw_kvs/checksum.h"
@@ -28,8 +27,11 @@
 #include "pw_kvs/format.h"
 #include "pw_kvs/internal/hash.h"
 #include "pw_kvs/internal/key_descriptor.h"
+#include "pw_kvs/key.h"
 
-namespace pw::kvs::internal {
+namespace pw {
+namespace kvs {
+namespace internal {
 
 // Entry represents a key-value entry in a flash partition.
 class Entry {
@@ -63,7 +65,7 @@
   static Entry Valid(FlashPartition& partition,
                      Address address,
                      const EntryFormat& format,
-                     std::string_view key,
+                     Key key,
                      std::span<const std::byte> value,
                      uint32_t transaction_id) {
     return Entry(
@@ -74,7 +76,7 @@
   static Entry Tombstone(FlashPartition& partition,
                          Address address,
                          const EntryFormat& format,
-                         std::string_view key,
+                         Key key,
                          uint32_t transaction_id) {
     return Entry(partition,
                  address,
@@ -87,9 +89,7 @@
 
   Entry() = default;
 
-  KeyDescriptor descriptor(std::string_view key) const {
-    return descriptor(Hash(key));
-  }
+  KeyDescriptor descriptor(Key key) const { return descriptor(Hash(key)); }
 
   KeyDescriptor descriptor(uint32_t key_hash) const {
     return KeyDescriptor{key_hash,
@@ -97,8 +97,7 @@
                          deleted() ? EntryState::kDeleted : EntryState::kValid};
   }
 
-  StatusWithSize Write(std::string_view key,
-                       std::span<const std::byte> value) const;
+  StatusWithSize Write(Key key, std::span<const std::byte> value) const;
 
   // Changes the format and transcation ID for this entry. In order to calculate
   // the new checksum, the entire entry is read into a small stack-allocated
@@ -125,14 +124,13 @@
 
   Status ValueMatches(std::span<const std::byte> value) const;
 
-  Status VerifyChecksum(std::string_view key,
-                        std::span<const std::byte> value) const;
+  Status VerifyChecksum(Key key, std::span<const std::byte> value) const;
 
   Status VerifyChecksumInFlash() const;
 
   // Calculates the total size of an entry, including padding.
   static size_t size(const FlashPartition& partition,
-                     std::string_view key,
+                     Key key,
                      std::span<const std::byte> value) {
     return AlignUp(sizeof(EntryHeader) + key.size() + value.size(),
                    std::max(partition.alignment_bytes(), kMinAlignmentBytes));
@@ -178,7 +176,7 @@
   Entry(FlashPartition& partition,
         Address address,
         const EntryFormat& format,
-        std::string_view key,
+        Key key,
         std::span<const std::byte> value,
         uint16_t value_size_bytes,
         uint32_t transaction_id);
@@ -202,11 +200,11 @@
   }
 
   std::span<const std::byte> checksum_bytes() const {
-    return std::as_bytes(std::span(&header_.checksum, 1));
+    return std::as_bytes(std::span<const uint32_t>(&header_.checksum, 1));
   }
 
   std::span<const std::byte> CalculateChecksum(
-      std::string_view key, std::span<const std::byte> value) const;
+      Key key, std::span<const std::byte> value) const;
 
   Status CalculateChecksumFromFlash();
 
@@ -223,4 +221,6 @@
   EntryHeader header_;
 };
 
-}  // namespace pw::kvs::internal
+}  // namespace internal
+}  // namespace kvs
+}  // namespace pw
diff --git a/pw_kvs/public/pw_kvs/internal/entry_cache.h b/pw_kvs/public/pw_kvs/internal/entry_cache.h
index f347738..18e3484 100644
--- a/pw_kvs/public/pw_kvs/internal/entry_cache.h
+++ b/pw_kvs/public/pw_kvs/internal/entry_cache.h
@@ -16,7 +16,6 @@
 #include <cstddef>
 #include <cstdint>
 #include <span>
-#include <string_view>
 #include <type_traits>
 
 #include "pw_containers/vector.h"
@@ -24,8 +23,11 @@
 #include "pw_kvs/format.h"
 #include "pw_kvs/internal/key_descriptor.h"
 #include "pw_kvs/internal/sectors.h"
+#include "pw_kvs/key.h"
 
-namespace pw::kvs::internal {
+namespace pw {
+namespace kvs {
+namespace internal {
 
 // Caches information about a key-value entry. Facilitates quickly finding
 // entries without having to read flash.
@@ -57,7 +59,7 @@
   // than allowed by the redundancy.
   void AddNewAddress(Address address) {
     addresses_[addresses_.size()] = address;
-    addresses_ = std::span(addresses_.begin(), addresses_.size() + 1);
+    addresses_ = std::span<Address>(addresses_.begin(), addresses_.size() + 1);
   }
 
   // Remove an address from the entry metadata.
@@ -167,7 +169,7 @@
   StatusWithSize Find(FlashPartition& partition,
                       const Sectors& sectors,
                       const EntryFormats& formats,
-                      std::string_view key,
+                      Key key,
                       EntryMetadata* metadata) const;
 
   // Adds a new descriptor to the descriptor list. The entry MUST be unique and
@@ -230,4 +232,6 @@
   const size_t redundancy_;
 };
 
-}  // namespace pw::kvs::internal
+}  // namespace internal
+}  // namespace kvs
+}  // namespace pw
diff --git a/pw_kvs/public/pw_kvs/internal/hash.h b/pw_kvs/public/pw_kvs/internal/hash.h
index 5e97e69..e56fe88 100644
--- a/pw_kvs/public/pw_kvs/internal/hash.h
+++ b/pw_kvs/public/pw_kvs/internal/hash.h
@@ -14,12 +14,15 @@
 #pragma once
 
 #include <cstddef>
-#include <string_view>
 
-namespace pw::kvs::internal {
+#include "pw_kvs/key.h"
+
+namespace pw {
+namespace kvs {
+namespace internal {
 
 // The hash function used to hash keys.
-constexpr uint32_t Hash(std::string_view string) {
+constexpr uint32_t Hash(Key string) {
   uint32_t hash = 0;
   uint32_t coefficient = 65599u;
 
@@ -31,4 +34,6 @@
   return hash;
 }
 
-}  // namespace pw::kvs::internal
+}  // namespace internal
+}  // namespace kvs
+}  // namespace pw
diff --git a/pw_kvs/public/pw_kvs/internal/key_descriptor.h b/pw_kvs/public/pw_kvs/internal/key_descriptor.h
index a7c7183..8e05b3f 100644
--- a/pw_kvs/public/pw_kvs/internal/key_descriptor.h
+++ b/pw_kvs/public/pw_kvs/internal/key_descriptor.h
@@ -15,7 +15,9 @@
 
 #include <cstdint>
 
-namespace pw::kvs::internal {
+namespace pw {
+namespace kvs {
+namespace internal {
 
 // Whether an entry is present or deleted.
 enum class EntryState : bool { kValid, kDeleted };
@@ -28,4 +30,6 @@
   EntryState state;  // TODO: Pack into transaction ID? or something?
 };
 
-}  // namespace pw::kvs::internal
+}  // namespace internal
+}  // namespace kvs
+}  // namespace pw
diff --git a/pw_kvs/public/pw_kvs/internal/sectors.h b/pw_kvs/public/pw_kvs/internal/sectors.h
index d2f4565..50fda8d 100644
--- a/pw_kvs/public/pw_kvs/internal/sectors.h
+++ b/pw_kvs/public/pw_kvs/internal/sectors.h
@@ -21,7 +21,9 @@
 #include "pw_containers/vector.h"
 #include "pw_kvs/flash_memory.h"
 
-namespace pw::kvs::internal {
+namespace pw {
+namespace kvs {
+namespace internal {
 
 // Tracks the available and used space in each sector used by the KVS.
 class SectorDescriptor {
@@ -225,4 +227,6 @@
   const SectorDescriptor** const temp_sectors_to_skip_;
 };
 
-}  // namespace pw::kvs::internal
+}  // namespace internal
+}  // namespace kvs
+}  // namespace pw
diff --git a/pw_kvs/public/pw_kvs/internal/span_traits.h b/pw_kvs/public/pw_kvs/internal/span_traits.h
index 4c4ab56..f85f0d6 100644
--- a/pw_kvs/public/pw_kvs/internal/span_traits.h
+++ b/pw_kvs/public/pw_kvs/internal/span_traits.h
@@ -13,12 +13,65 @@
 // the License.
 #pragma once
 
+#include <iterator>
 #include <type_traits>
 
-namespace pw::kvs {
-
+namespace pw {
+namespace kvs {
 namespace internal {
-template <typename T, typename = decltype(std::span(std::declval<T>()))>
+
+// This borrows the `make_span` function from Chromium and uses to see if a type
+// can be represented as a span. See:
+// https://chromium.googlesource.com/chromium/src/+/master/base/containers/span.h
+
+// Simplified implementation of C++20's std::iter_reference_t.
+// As opposed to std::iter_reference_t, this implementation does not restrict
+// the type of `Iter`.
+//
+// Reference: https://wg21.link/iterator.synopsis#:~:text=iter_reference_t
+template <typename Iter>
+using iter_reference_t = decltype(*std::declval<Iter&>());
+
+template <typename T>
+struct ExtentImpl : std::integral_constant<size_t, std::dynamic_extent> {};
+
+template <typename T, size_t N>
+struct ExtentImpl<T[N]> : std::integral_constant<size_t, N> {};
+
+template <typename T, size_t N>
+struct ExtentImpl<std::array<T, N>> : std::integral_constant<size_t, N> {};
+
+template <typename T, size_t N>
+struct ExtentImpl<std::span<T, N>> : std::integral_constant<size_t, N> {};
+
+template <typename T>
+using Extent = ExtentImpl<std::remove_cv_t<std::remove_reference_t<T>>>;
+
+// Type-deducing helpers for constructing a span.
+template <int&... ExplicitArgumentBarrier, typename It, typename EndOrSize>
+constexpr auto make_span(It it, EndOrSize end_or_size) noexcept {
+  using T = std::remove_reference_t<iter_reference_t<It>>;
+  return std::span<T>(it, end_or_size);
+}
+
+// make_span utility function that deduces both the span's value_type and extent
+// from the passed in argument.
+//
+// Usage: auto span = base::make_span(...);
+template <int&... ExplicitArgumentBarrier,
+          typename Container,
+          typename T = std::remove_pointer_t<
+              decltype(std::data(std::declval<Container>()))>>
+constexpr auto make_span(Container&& container) noexcept {
+  return std::span<T, Extent<Container>::value>(
+      std::forward<Container>(container));
+}
+
+// The make_span functions above don't seem to work correctly with arrays of
+// non-const values, so add const to the type. That is fine for KVS's Put
+// method, since the values can be accepted as const.
+template <typename T,
+          typename = decltype(make_span(std::declval<std::add_const_t<T>>()))>
 constexpr bool ConvertsToSpan(int) {
   return true;
 }
@@ -38,4 +91,5 @@
     : public std::bool_constant<
           internal::ConvertsToSpan<std::remove_reference_t<T>>(0)> {};
 
-}  // namespace pw::kvs
+}  // namespace kvs
+}  // namespace pw
diff --git a/pw_kvs/public/pw_kvs/io.h b/pw_kvs/public/pw_kvs/io.h
index 80a0ef0..58fe24d 100644
--- a/pw_kvs/public/pw_kvs/io.h
+++ b/pw_kvs/public/pw_kvs/io.h
@@ -43,7 +43,8 @@
 
   // Convenience wrapper for writing data from a pointer and length.
   StatusWithSize Write(const void* data, size_t size_bytes) {
-    return Write(std::span(static_cast<const std::byte*>(data), size_bytes));
+    return Write(std::span<const std::byte>(static_cast<const std::byte*>(data),
+                                            size_bytes));
   }
 
  protected:
@@ -59,7 +60,8 @@
 
   // Convenience wrapper for reading data from a pointer and length.
   StatusWithSize Read(void* data, size_t size_bytes) {
-    return Read(std::span(static_cast<std::byte*>(data), size_bytes));
+    return Read(
+        std::span<std::byte>(static_cast<std::byte*>(data), size_bytes));
   }
 
  protected:
@@ -69,32 +71,6 @@
   virtual StatusWithSize DoRead(std::span<std::byte> data) = 0;
 };
 
-// Output adapter that calls a method on a class with a std::span of bytes. If
-// the method returns void instead of the expected Status, Write always returns
-// Status::Ok().
-template <auto kMethod>
-class OutputToMethod final : public Output {
-  using Class = typename internal::FunctionTraits<decltype(kMethod)>::Class;
-
- public:
-  constexpr OutputToMethod(Class* object) : object_(*object) {}
-
- private:
-  StatusWithSize DoWrite(std::span<const std::byte> data) override {
-    using Return = typename internal::FunctionTraits<decltype(kMethod)>::Return;
-
-    if constexpr (std::is_void_v<Return>) {
-      (object_.*kMethod)(data);
-      return StatusWithSize(data.size());
-    } else {
-      return (object_.*kMethod)(data);
-    }
-  }
-
- private:
-  Class& object_;
-};
-
 // Output adapter that calls a free function.
 class OutputToFunction final : public Output {
  public:
diff --git a/pw_kvs/public/pw_kvs/key.h b/pw_kvs/public/pw_kvs/key.h
new file mode 100644
index 0000000..62b8532
--- /dev/null
+++ b/pw_kvs/public/pw_kvs/key.h
@@ -0,0 +1,82 @@
+// 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.
+#pragma once
+
+#include <cstring>
+#include <limits>
+#include <string>
+
+#if __cplusplus >= 201703L
+#include <string_view>
+#endif  // __cplusplus >= 201703L
+
+#include "pw_string/util.h"
+
+namespace pw {
+namespace kvs {
+
+// Key is a simplified string_view used for KVS. This helps KVS work on
+// platforms without C++17.
+class Key {
+ public:
+  // Constructors
+  constexpr Key() : str_{nullptr}, length_{0} {}
+  constexpr Key(const Key&) = default;
+  constexpr Key(const char* str)
+      : str_{str},
+        length_{string::Length(str, std::numeric_limits<size_t>::max())} {}
+  constexpr Key(const char* str, size_t len) : str_{str}, length_{len} {}
+  Key(const std::string& str) : str_{str.data()}, length_{str.length()} {}
+
+#if __cplusplus >= 201703L
+  constexpr Key(const std::string_view& str)
+      : str_{str.data()}, length_{str.length()} {}
+  operator std::string_view() { return std::string_view{str_, length_}; }
+#endif  // __cplusplus >= 201703L
+
+  // Traits
+  constexpr size_t size() const { return length_; }
+  constexpr size_t length() const { return length_; }
+  constexpr bool empty() const { return length_ == 0; }
+
+  // Access
+  constexpr const char& operator[](size_t pos) const { return str_[pos]; }
+  constexpr const char& at(size_t pos) const { return str_[pos]; }
+  constexpr const char& front() const { return str_[0]; }
+  constexpr const char& back() const { return str_[length_ - 1]; }
+  constexpr const char* data() const { return str_; }
+
+  // Iterator
+  constexpr const char* begin() const { return str_; }
+  constexpr const char* end() const { return str_ + length_; }
+
+  // Equal
+  constexpr bool operator==(Key other_key) const {
+    return length() == other_key.length() &&
+           std::memcmp(str_, other_key.data(), length()) == 0;
+  }
+
+  // Not Equal
+  constexpr bool operator!=(Key other_key) const {
+    return length() != other_key.length() ||
+           std::memcmp(str_, other_key.data(), length()) != 0;
+  }
+
+ private:
+  const char* str_;
+  size_t length_;
+};
+
+}  // namespace kvs
+}  // namespace pw
diff --git a/pw_kvs/public/pw_kvs/key_value_store.h b/pw_kvs/public/pw_kvs/key_value_store.h
index de4249e..f5d4695 100644
--- a/pw_kvs/public/pw_kvs/key_value_store.h
+++ b/pw_kvs/public/pw_kvs/key_value_store.h
@@ -17,7 +17,6 @@
 #include <cstddef>
 #include <cstdint>
 #include <span>
-#include <string_view>
 #include <type_traits>
 
 #include "pw_containers/vector.h"
@@ -29,10 +28,12 @@
 #include "pw_kvs/internal/key_descriptor.h"
 #include "pw_kvs/internal/sectors.h"
 #include "pw_kvs/internal/span_traits.h"
+#include "pw_kvs/key.h"
 #include "pw_status/status.h"
 #include "pw_status/status_with_size.h"
 
-namespace pw::kvs {
+namespace pw {
+namespace kvs {
 
 enum class GargbageCollectOnWrite {
   // Disable all automatic garbage collection on write.
@@ -115,7 +116,7 @@
   //   FAILED_PRECONDITION: the KVS is not initialized
   //      INVALID_ARGUMENT: key is empty or too long or value is too large
   //
-  StatusWithSize Get(std::string_view key,
+  StatusWithSize Get(Key key,
                      std::span<std::byte> value,
                      size_t offset_bytes = 0) const;
 
@@ -124,8 +125,8 @@
   // std::as_writable_bytes(std::span(array)), or pass a pointer to the array
   // instead of the array itself.
   template <typename Pointer,
-            typename = std::enable_if_t<std::is_pointer_v<Pointer>>>
-  Status Get(const std::string_view& key, const Pointer& pointer) const {
+            typename = std::enable_if_t<std::is_pointer<Pointer>::value>>
+  Status Get(const Key& key, const Pointer& pointer) const {
     using T = std::remove_reference_t<std::remove_pointer_t<Pointer>>;
     CheckThatObjectCanBePutOrGet<T>();
     return FixedSizeGet(key, pointer, sizeof(T));
@@ -148,14 +149,17 @@
   //   FAILED_PRECONDITION: the KVS is not initialized
   //      INVALID_ARGUMENT: key is empty or too long or value is too large
   //
-  template <typename T>
-  Status Put(const std::string_view& key, const T& value) {
-    if constexpr (ConvertsToSpan<T>::value) {
-      return PutBytes(key, std::as_bytes(std::span(value)));
-    } else {
-      CheckThatObjectCanBePutOrGet<T>();
-      return PutBytes(key, std::as_bytes(std::span(&value, 1)));
-    }
+  template <typename T,
+            typename std::enable_if_t<ConvertsToSpan<T>::value>* = nullptr>
+  Status Put(const Key& key, const T& value) {
+    return PutBytes(key, std::as_bytes(internal::make_span(value)));
+  }
+
+  template <typename T,
+            typename std::enable_if_t<!ConvertsToSpan<T>::value>* = nullptr>
+  Status Put(const Key& key, const T& value) {
+    CheckThatObjectCanBePutOrGet<T>();
+    return PutBytes(key, std::as_bytes(std::span<const T>(&value, 1)));
   }
 
   // Removes a key-value entry from the KVS.
@@ -167,7 +171,7 @@
   //   FAILED_PRECONDITION: the KVS is not initialized
   //      INVALID_ARGUMENT: key is empty or too long
   //
-  Status Delete(std::string_view key);
+  Status Delete(Key key);
 
   // Returns the size of the value corresponding to the key.
   //
@@ -177,7 +181,7 @@
   //   FAILED_PRECONDITION: the KVS is not initialized
   //      INVALID_ARGUMENT: key is empty or too long
   //
-  StatusWithSize ValueSize(std::string_view key) const;
+  StatusWithSize ValueSize(Key key) const;
 
   // Perform all maintenance possible, including all neeeded repairing of
   // corruption and garbage collection of reclaimable space in the KVS. When
@@ -225,7 +229,7 @@
     }
 
     template <typename Pointer,
-              typename = std::enable_if_t<std::is_pointer_v<Pointer>>>
+              typename = std::enable_if_t<std::is_pointer<Pointer>::value>>
     Status Get(const Pointer& pointer) const {
       using T = std::remove_reference_t<std::remove_pointer_t<Pointer>>;
       CheckThatObjectCanBePutOrGet<T>();
@@ -360,7 +364,7 @@
   template <typename T>
   static constexpr void CheckThatObjectCanBePutOrGet() {
     static_assert(
-        std::is_trivially_copyable_v<T> && !std::is_pointer_v<T>,
+        std::is_trivially_copyable<T>::value && !std::is_pointer<T>::value,
         "Only trivially copyable, non-pointer objects may be Put and Get by "
         "value. Any value may be stored by converting it to a byte std::span "
         "with std::as_bytes(std::span(&value, 1)) or "
@@ -373,7 +377,7 @@
                       Address start_address,
                       Address* next_entry_address);
 
-  Status PutBytes(std::string_view key, std::span<const std::byte> value);
+  Status PutBytes(Key key, std::span<const std::byte> value);
 
   StatusWithSize ValueSize(const EntryMetadata& metadata) const;
 
@@ -391,7 +395,7 @@
   //                 key's hash collides with the hash for an existing
   //                 descriptor
   //
-  Status FindEntry(std::string_view key, EntryMetadata* metadata) const;
+  Status FindEntry(Key key, EntryMetadata* metadata) const;
 
   // Searches for a KeyDescriptor that matches this key and sets *metadata to
   // point to it if one is found.
@@ -399,41 +403,38 @@
   //          OK: there is a matching descriptor and *metadata is set
   //   NOT_FOUND: there is no descriptor that matches this key
   //
-  Status FindExisting(std::string_view key, EntryMetadata* metadata) const;
+  Status FindExisting(Key key, EntryMetadata* metadata) const;
 
-  StatusWithSize Get(std::string_view key,
+  StatusWithSize Get(Key key,
                      const EntryMetadata& metadata,
                      std::span<std::byte> value_buffer,
                      size_t offset_bytes) const;
 
-  Status FixedSizeGet(std::string_view key,
-                      void* value,
-                      size_t size_bytes) const;
+  Status FixedSizeGet(Key key, void* value, size_t size_bytes) const;
 
-  Status FixedSizeGet(std::string_view key,
+  Status FixedSizeGet(Key key,
                       const EntryMetadata& descriptor,
                       void* value,
                       size_t size_bytes) const;
 
-  Status CheckWriteOperation(std::string_view key) const;
-  Status CheckReadOperation(std::string_view key) const;
+  Status CheckWriteOperation(Key key) const;
+  Status CheckReadOperation(Key key) const;
 
   Status WriteEntryForExistingKey(EntryMetadata& metadata,
                                   EntryState new_state,
-                                  std::string_view key,
+                                  Key key,
                                   std::span<const std::byte> value);
 
-  Status WriteEntryForNewKey(std::string_view key,
-                             std::span<const std::byte> value);
+  Status WriteEntryForNewKey(Key key, std::span<const std::byte> value);
 
-  Status WriteEntry(std::string_view key,
+  Status WriteEntry(Key key,
                     std::span<const std::byte> value,
                     EntryState new_state,
                     EntryMetadata* prior_metadata = nullptr,
                     const internal::Entry* prior_entry = nullptr);
 
   EntryMetadata CreateOrUpdateKeyDescriptor(const Entry& new_entry,
-                                            std::string_view key,
+                                            Key key,
                                             EntryMetadata* prior_metadata,
                                             size_t prior_size);
 
@@ -451,7 +452,7 @@
   Status MarkSectorCorruptIfNotOk(Status status, SectorDescriptor* sector);
 
   Status AppendEntry(const Entry& entry,
-                     std::string_view key,
+                     Key key,
                      std::span<const std::byte> value);
 
   StatusWithSize CopyEntryToSector(Entry& entry,
@@ -508,7 +509,7 @@
   Status Repair();
 
   internal::Entry CreateEntry(Address address,
-                              std::string_view key,
+                              Key key,
                               std::span<const std::byte> value,
                               EntryState state);
 
@@ -574,7 +575,8 @@
                       const Options& options = {})
       : KeyValueStoreBuffer(
             partition,
-            std::span(reinterpret_cast<const EntryFormat (&)[1]>(format)),
+            std::span<const EntryFormat, kEntryFormats>(
+                reinterpret_cast<const EntryFormat (&)[1]>(format)),
             options) {
     static_assert(kEntryFormats == 1,
                   "kEntryFormats EntryFormats must be specified");
@@ -625,4 +627,5 @@
   std::array<EntryFormat, kEntryFormats> formats_;
 };
 
-}  // namespace pw::kvs
+}  // namespace kvs
+}  // namespace pw
diff --git a/pw_kvs/public/pw_kvs/test_key_value_store.h b/pw_kvs/public/pw_kvs/test_key_value_store.h
index 73aa609..e57e74f 100644
--- a/pw_kvs/public/pw_kvs/test_key_value_store.h
+++ b/pw_kvs/public/pw_kvs/test_key_value_store.h
@@ -15,8 +15,10 @@
 
 #include "pw_kvs/key_value_store.h"
 
-namespace pw::kvs {
+namespace pw {
+namespace kvs {
 
 KeyValueStore& TestKvs();
 
-}  // namespace pw::kvs
+}  // namespace kvs
+}  // namespace pw
diff --git a/pw_kvs/sectors.cc b/pw_kvs/sectors.cc
index e8d91b7..97d9423 100644
--- a/pw_kvs/sectors.cc
+++ b/pw_kvs/sectors.cc
@@ -14,12 +14,11 @@
 
 #define PW_LOG_MODULE_NAME "KVS"
 #define PW_LOG_LEVEL PW_KVS_LOG_LEVEL
-#define PW_LOG_USE_ULTRA_SHORT_NAMES 1
 
 #include "pw_kvs/internal/sectors.h"
 
 #include "pw_kvs_private/config.h"
-#include "pw_log/log.h"
+#include "pw_log/shorter.h"
 
 namespace pw::kvs::internal {
 namespace {
@@ -111,7 +110,7 @@
       if ((find_mode == kAppendEntry) ||
           (sector->RecoverableBytes(sector_size_bytes) == 0)) {
         *found_sector = sector;
-        return Status::Ok();
+        return OkStatus();
       } else {
         if ((non_empty_least_reclaimable_sector == nullptr) ||
             (non_empty_least_reclaimable_sector->RecoverableBytes(
@@ -140,7 +139,7 @@
         Index(first_empty_sector));
     last_new_ = first_empty_sector;
     *found_sector = first_empty_sector;
-    return Status::Ok();
+    return OkStatus();
   }
 
   // Tier 3 check: If we got this far, use the sector with least recoverable
@@ -150,7 +149,7 @@
     DBG("  Found a usable sector %u, with %u B recoverable, in GC",
         Index(*found_sector),
         unsigned((*found_sector)->RecoverableBytes(sector_size_bytes)));
-    return Status::Ok();
+    return OkStatus();
   }
 
   // No sector was found.
diff --git a/pw_kvs/size_report/BUILD b/pw_kvs/size_report/BUILD
new file mode 100644
index 0000000..bd752f3
--- /dev/null
+++ b/pw_kvs/size_report/BUILD
@@ -0,0 +1,58 @@
+# Copyright 2021 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.
+
+load(
+    "//pw_build:pigweed.bzl",
+    "pw_cc_binary",
+)
+
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])  # Apache License 2.0
+
+pw_cc_binary(
+    name = "base",
+    srcs = ["base.cc"],
+    deps = [
+        "//pw_assert",
+        "//pw_bloat:bloat_this_binary",
+        "//pw_log",
+    ],
+)
+
+pw_cc_binary(
+    name = "base_with_only_flash",
+    srcs = ["base_with_only_flash.cc"],
+    deps = [
+        "//pw_assert",
+        "//pw_bloat:bloat_this_binary",
+        "//pw_kvs",
+        "//pw_kvs:flash_test_partition",
+        "//pw_kvs:fake_flash_12_byte_partition",
+        "//pw_log",
+    ],
+)
+
+pw_cc_binary(
+    name = "with_kvs",
+    srcs = ["with_kvs.cc"],
+    deps = [
+        "//pw_assert",
+        "//pw_bloat:bloat_this_binary",
+        "//pw_kvs",
+        "//pw_kvs:flash_test_partition",
+        "//pw_kvs:fake_flash_12_byte_partition",
+        "//pw_log",
+    ],
+)
diff --git a/pw_kvs/size_report/BUILD.gn b/pw_kvs/size_report/BUILD.gn
new file mode 100644
index 0000000..e1c6774
--- /dev/null
+++ b/pw_kvs/size_report/BUILD.gn
@@ -0,0 +1,46 @@
+# Copyright 2021 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/target_types.gni")
+
+_deps = [
+  "$dir_pw_bloat:bloat_this_binary",
+  dir_pw_assert,
+  dir_pw_log,
+]
+
+pw_executable("base") {
+  sources = [ "base.cc" ]
+  deps = _deps
+}
+
+pw_executable("base_with_only_flash") {
+  sources = [ "base_with_only_flash.cc" ]
+  deps = _deps + [
+           dir_pw_kvs,
+           "$dir_pw_kvs:flash_test_partition",
+           "$dir_pw_kvs:fake_flash_12_byte_partition",
+         ]
+}
+
+pw_executable("with_kvs") {
+  sources = [ "with_kvs.cc" ]
+  deps = _deps + [
+           dir_pw_kvs,
+           "$dir_pw_kvs:flash_test_partition",
+           "$dir_pw_kvs:fake_flash_12_byte_partition",
+         ]
+}
diff --git a/pw_kvs/size_report/base.cc b/pw_kvs/size_report/base.cc
new file mode 100644
index 0000000..be6d89c
--- /dev/null
+++ b/pw_kvs/size_report/base.cc
@@ -0,0 +1,37 @@
+// Copyright 2021 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.
+
+#include <cstring>
+
+#include "pw_assert/assert.h"
+#include "pw_bloat/bloat_this_binary.h"
+#include "pw_log/log.h"
+
+char working_buffer[256];
+volatile bool is_set;
+
+int volatile* unoptimizable;
+
+int main() {
+  pw::bloat::BloatThisBinary();
+
+  // Ensure we are paying the cost for log and assert.
+  PW_CHECK_INT_GE(*unoptimizable, 0, "Ensure this CHECK logic stays");
+  PW_LOG_INFO("We care about optimizing: %d", *unoptimizable);
+
+  void* result =
+      std::memset((void*)working_buffer, 0x55, sizeof(working_buffer));
+  is_set = (result != nullptr);
+  return 0;
+}
diff --git a/pw_kvs/size_report/base_with_only_flash.cc b/pw_kvs/size_report/base_with_only_flash.cc
new file mode 100644
index 0000000..bb7c182
--- /dev/null
+++ b/pw_kvs/size_report/base_with_only_flash.cc
@@ -0,0 +1,55 @@
+// Copyright 2021 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.
+
+#include <cstring>
+
+#include "pw_assert/assert.h"
+#include "pw_bloat/bloat_this_binary.h"
+#include "pw_kvs/flash_test_partition.h"
+#include "pw_log/log.h"
+
+pw::kvs::FlashPartition& test_partition = pw::kvs::FlashTestPartition();
+
+char working_buffer[256];
+volatile bool is_set;
+
+volatile bool is_erased;
+
+int volatile* unoptimizable;
+
+int main() {
+  pw::bloat::BloatThisBinary();
+
+  // Ensure we are paying the cost for log and assert.
+  PW_CHECK_INT_GE(*unoptimizable, 0, "Ensure this CHECK logic stays");
+  PW_LOG_INFO("We care about optimizing: %d", *unoptimizable);
+
+  void* result =
+      std::memset((void*)working_buffer, 0x55, sizeof(working_buffer));
+  is_set = (result != nullptr);
+
+  test_partition.Erase();
+
+  std::memset((void*)working_buffer, 0x55, sizeof(working_buffer));
+
+  test_partition.Write(0, std::as_bytes(std::span(working_buffer)));
+
+  bool tmp_bool;
+  test_partition.IsErased(&tmp_bool);
+  is_erased = tmp_bool;
+
+  test_partition.Read(0, as_writable_bytes(std::span(working_buffer)));
+
+  return 0;
+}
diff --git a/pw_kvs/size_report/with_kvs.cc b/pw_kvs/size_report/with_kvs.cc
new file mode 100644
index 0000000..fc99e55
--- /dev/null
+++ b/pw_kvs/size_report/with_kvs.cc
@@ -0,0 +1,63 @@
+// Copyright 2021 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.
+
+#include <cstring>
+
+#include "pw_assert/assert.h"
+#include "pw_bloat/bloat_this_binary.h"
+#include "pw_kvs/flash_test_partition.h"
+#include "pw_kvs/key_value_store.h"
+#include "pw_log/log.h"
+
+char working_buffer[256];
+volatile bool is_set;
+
+constexpr size_t kMaxSectorCount = 64;
+constexpr size_t kKvsMaxEntries = 32;
+
+// For KVS magic value always use a random 32 bit integer rather than a human
+// readable 4 bytes. See pw_kvs/format.h for more information.
+static constexpr pw::kvs::EntryFormat kvs_format = {.magic = 0x22d3f8a0,
+                                                    .checksum = nullptr};
+
+volatile size_t kvs_entry_count;
+
+pw::kvs::KeyValueStoreBuffer<kKvsMaxEntries, kMaxSectorCount> kvs(
+    &pw::kvs::FlashTestPartition(), kvs_format);
+
+int volatile* unoptimizable;
+
+int main() {
+  pw::bloat::BloatThisBinary();
+
+  // Ensure we are paying the cost for log and assert.
+  PW_CHECK_INT_GE(*unoptimizable, 0, "Ensure this CHECK logic stays");
+  PW_LOG_INFO("We care about optimizing: %d", *unoptimizable);
+
+  void* result =
+      std::memset((void*)working_buffer, 0x55, sizeof(working_buffer));
+  is_set = (result != nullptr);
+
+  kvs.Init();
+
+  unsigned kvs_value = 42;
+  kvs.Put("example_key", kvs_value);
+
+  kvs_entry_count = kvs.size();
+
+  unsigned read_value = 0;
+  kvs.Get("example_key", &read_value);
+
+  return 0;
+}
diff --git a/pw_kvs/test_key_value_store_test.cc b/pw_kvs/test_key_value_store_test.cc
index af6d4a9..97ececd 100644
--- a/pw_kvs/test_key_value_store_test.cc
+++ b/pw_kvs/test_key_value_store_test.cc
@@ -24,10 +24,10 @@
 // Simple test to verify that the TestKvs() does basic function.
 TEST(TestKvs, PutGetValue) {
   KeyValueStore& kvs = TestKvs();
-  ASSERT_EQ(Status::Ok(), kvs.Put("key", uint32_t(0xfeedbeef)));
+  ASSERT_EQ(OkStatus(), kvs.Put("key", uint32_t(0xfeedbeef)));
 
   uint32_t value = 0;
-  EXPECT_EQ(Status::Ok(), kvs.Get("key", &value));
+  EXPECT_EQ(OkStatus(), kvs.Get("key", &value));
   EXPECT_EQ(uint32_t(0xfeedbeef), value);
 }
 
diff --git a/pw_log/BUILD b/pw_log/BUILD
index ec768fe..723f75d 100644
--- a/pw_log/BUILD
+++ b/pw_log/BUILD
@@ -31,6 +31,8 @@
         "public/pw_log/levels.h",
         "public/pw_log/log.h",
         "public/pw_log/options.h",
+        "public/pw_log/short.h",
+        "public/pw_log/shorter.h",
     ],
     includes = ["public"],
     deps = [
diff --git a/pw_log/BUILD.gn b/pw_log/BUILD.gn
index 8aa9b43..913e840 100644
--- a/pw_log/BUILD.gn
+++ b/pw_log/BUILD.gn
@@ -1,4 +1,4 @@
-# Copyright 2020 The Pigweed Authors
+# Copyright 2021 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
@@ -16,13 +16,10 @@
 
 import("$dir_pw_build/facade.gni")
 import("$dir_pw_docgen/docs.gni")
+import("$dir_pw_log/backend.gni")
+import("$dir_pw_protobuf_compiler/proto.gni")
 import("$dir_pw_unit_test/test.gni")
 
-declare_args() {
-  # Backend for the pw_log module.
-  pw_log_BACKEND = ""
-}
-
 config("default_config") {
   include_dirs = [ "public" ]
 }
@@ -34,6 +31,8 @@
     "public/pw_log/levels.h",
     "public/pw_log/log.h",
     "public/pw_log/options.h",
+    "public/pw_log/short.h",
+    "public/pw_log/shorter.h",
   ]
 }
 
@@ -55,6 +54,10 @@
   ]
 }
 
+pw_proto_library("protos") {
+  sources = [ "pw_log_proto/log.proto" ]
+}
+
 pw_doc_group("docs") {
   sources = [ "docs.rst" ]
 }
diff --git a/pw_log/backend.gni b/pw_log/backend.gni
new file mode 100644
index 0000000..8bb5cf3
--- /dev/null
+++ b/pw_log/backend.gni
@@ -0,0 +1,18 @@
+# Copyright 2021 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.
+
+declare_args() {
+  # Backend for the pw_log module.
+  pw_log_BACKEND = ""
+}
diff --git a/pw_log/basic_log_test.cc b/pw_log/basic_log_test.cc
index 1b640c4..a7042a8 100644
--- a/pw_log/basic_log_test.cc
+++ b/pw_log/basic_log_test.cc
@@ -20,10 +20,10 @@
 
 // clang-format off
 #define PW_LOG_MODULE_NAME "TST"
-#define PW_LOG_USE_SHORT_NAMES 1
-#define PW_LOG_USE_ULTRA_SHORT_NAMES 1
 #define PW_LOG_LEVEL PW_LOG_LEVEL_DEBUG
 #include "pw_log/log.h"
+#include "pw_log/short.h"
+#include "pw_log/shorter.h"
 // clang-format on
 
 #include "gtest/gtest.h"
diff --git a/pw_log/basic_log_test_plain_c.c b/pw_log/basic_log_test_plain_c.c
index ead81f3..ee82374 100644
--- a/pw_log/basic_log_test_plain_c.c
+++ b/pw_log/basic_log_test_plain_c.c
@@ -28,11 +28,11 @@
 #error "This file must be compiled as plain C to verify C compilation works."
 #endif  // __cplusplus
 
-static void LoggingFromFunctionPlainC() { PW_LOG_INFO("From a function!"); }
+static void LoggingFromFunctionPlainC(void) { PW_LOG_INFO("From a function!"); }
 
 static void CustomFormatStringTest(void);
 
-void BasicLogTestPlainC() {
+void BasicLogTestPlainC(void) {
   int n = 3;
 
   // Debug level
diff --git a/pw_log/docs.rst b/pw_log/docs.rst
index 8f5c31e..c36490b 100644
--- a/pw_log/docs.rst
+++ b/pw_log/docs.rst
@@ -32,14 +32,14 @@
   }
 
 In ``.cc`` files, it is possible to dispense with the ``PW_`` part of the log
-names and go for shorter log macros.
+names and go for shorter log macros. Include ``pw_log/short.h`` or
+``pw_log/shorter.h`` for shorter versions of the macros.
 
 .. code-block:: cpp
 
   #define PW_LOG_MODULE_NAME "BLE"
-  #define PW_LOG_USE_ULTRA_SHORT_NAMES 1
 
-  #include "pw_log/log.h"
+  #include "pw_log/shorter.h"
 
   int main() {
     INF("Booting...");
diff --git a/pw_log/public/pw_log/log.h b/pw_log/public/pw_log/log.h
index 16a4679..56764e3 100644
--- a/pw_log/public/pw_log/log.h
+++ b/pw_log/public/pw_log/log.h
@@ -61,6 +61,17 @@
 //
 #include "pw_log_backend/log_backend.h"
 
+// For compatibility with code that uses the deprecated PW_LOG_USE_SHORT_NAMES
+// and PW_LOG_USE_ULTRA_SHORT_NAMES, include the appropriate headers.
+// TODO(hepler): Remove this workaround when all users have migrated.
+#if defined(PW_LOG_USE_SHORT_NAMES) && PW_LOG_USE_SHORT_NAMES == 1
+#include "pw_log/short.h"
+#endif  // PW_LOG_USE_SHORT_NAMES
+
+#if defined(PW_LOG_USE_ULTRA_SHORT_NAMES) && PW_LOG_USE_ULTRA_SHORT_NAMES == 1
+#include "pw_log/shorter.h"
+#endif  // PW_LOG_USE_ULTRA_SHORT_NAMES
+
 #ifndef PW_LOG
 #define PW_LOG(level, flags, message, ...)               \
   do {                                                   \
@@ -115,38 +126,3 @@
 #ifndef PW_LOG_FLAG_BITS
 #define PW_LOG_FLAG_BITS 10
 #endif  // PW_LOG_FLAG_BITS
-
-// Define short, usable names if requested.
-// TODO(pwbug/17): Convert this to the config system when available.
-#ifndef PW_LOG_USE_SHORT_NAMES
-#define PW_LOG_USE_SHORT_NAMES 0
-#endif
-
-// Define ultra short, usable names if requested.
-#ifndef PW_LOG_USE_ULTRA_SHORT_NAMES
-#define PW_LOG_USE_ULTRA_SHORT_NAMES 0
-#endif  // PW_LOG_USE_SHORT_NAMES
-
-// clang-format off
-#if PW_LOG_USE_SHORT_NAMES
-#define LOG          PW_LOG
-#define LOG_DEBUG    PW_LOG_DEBUG
-#define LOG_INFO     PW_LOG_INFO
-#define LOG_WARN     PW_LOG_WARN
-#define LOG_ERROR    PW_LOG_ERROR
-#define LOG_CRITICAL PW_LOG_CRITICAL
-#endif  // PW_LOG_USE_SHORT_NAMES
-// clang-format on
-
-// clang-format off
-#if PW_LOG_USE_ULTRA_SHORT_NAMES
-#if !PW_LOG_USE_SHORT_NAMES
-#define LOG PW_LOG
-#endif  // LOG
-#define DBG PW_LOG_DEBUG
-#define INF PW_LOG_INFO
-#define WRN PW_LOG_WARN
-#define ERR PW_LOG_ERROR
-#define CRT PW_LOG_CRITICAL
-#endif  // PW_LOG_USE_ULTRA_SHORT_NAMES
-// clang-format on
diff --git a/pw_log/public/pw_log/short.h b/pw_log/public/pw_log/short.h
new file mode 100644
index 0000000..d5651a9
--- /dev/null
+++ b/pw_log/public/pw_log/short.h
@@ -0,0 +1,32 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "pw_log/log.h"
+
+// These are optional shortened versions of the PW_LOG macros. They are not
+// prefixed with PW_*, so may collide with other macros.
+
+// LOG is also defined by pw_log/shorter.h, so check if it's defined first.
+#ifndef LOG
+#define LOG PW_LOG
+#endif  // LOG
+
+// clang-format off
+#define LOG_DEBUG    PW_LOG_DEBUG
+#define LOG_INFO     PW_LOG_INFO
+#define LOG_WARN     PW_LOG_WARN
+#define LOG_ERROR    PW_LOG_ERROR
+#define LOG_CRITICAL PW_LOG_CRITICAL
+// clang-format on
diff --git a/pw_log/public/pw_log/shorter.h b/pw_log/public/pw_log/shorter.h
new file mode 100644
index 0000000..7a48f2f
--- /dev/null
+++ b/pw_log/public/pw_log/shorter.h
@@ -0,0 +1,33 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "pw_log/log.h"
+
+// These are optional very short versions of the PW_LOG macros. They are not
+// prefixed with PW_*, so may collide with other macros.
+
+// LOG is also defined by pw_log/short.h, so check if it's defined first.
+#ifndef LOG
+#define LOG PW_LOG
+#endif  // LOG
+
+// clang-format off
+#define LOG PW_LOG
+#define DBG PW_LOG_DEBUG
+#define INF PW_LOG_INFO
+#define WRN PW_LOG_WARN
+#define ERR PW_LOG_ERROR
+#define CRT PW_LOG_CRITICAL
+// clang-format on
diff --git a/pw_log_rpc/pw_log_proto/log.proto b/pw_log/pw_log_proto/log.proto
similarity index 100%
rename from pw_log_rpc/pw_log_proto/log.proto
rename to pw_log/pw_log_proto/log.proto
diff --git a/pw_log_basic/log_basic.cc b/pw_log_basic/log_basic.cc
index e480614..88fd527 100644
--- a/pw_log_basic/log_basic.cc
+++ b/pw_log_basic/log_basic.cc
@@ -101,19 +101,23 @@
   // Accumulate the log message in this buffer, then output it.
   pw::StringBuffer<150> buffer;
 
+  // Column: Timestamp
+  // Note that this macro method defaults to a no-op.
+  PW_LOG_APPEND_TIMESTAMP(buffer);
+
   // Column: Filename
 #if PW_LOG_SHOW_FILENAME
   buffer.Format(" %-30s:%4d |", GetFileBasename(file_name), line_number);
 #else
-  PW_UNUSED(file_name);
-  PW_UNUSED(line_number);
+  static_cast<void>(file_name);
+  static_cast<void>(line_number);
 #endif
 
   // Column: Function
 #if PW_LOG_SHOW_FUNCTION
   buffer.Format(" %20s |", function_name);
 #else
-  PW_UNUSED(function_name);
+  static_cast<void>(function_name);
 #endif
 
   // Column: Module
@@ -122,7 +126,7 @@
   buffer.Format("%3s", module_name);
   buffer << RESET " ";
 #else
-  PW_UNUSED(module_name);
+  static_cast<void>(module_name);
 #endif  // PW_LOG_SHOW_MODULE
 
   // Column: Flag
@@ -134,7 +138,7 @@
 #endif  // PW_EMOJI
   buffer << " ";
 #else
-  PW_UNUSED(flags);
+  static_cast<void>(flags);
 #endif  // PW_LOG_SHOW_FLAG
 
   // Column: Level
diff --git a/pw_log_basic/pw_log_basic_private/config.h b/pw_log_basic/pw_log_basic_private/config.h
index 6bb9438..7ed6382 100644
--- a/pw_log_basic/pw_log_basic_private/config.h
+++ b/pw_log_basic/pw_log_basic_private/config.h
@@ -18,7 +18,8 @@
 #define PW_EMOJI 0
 #endif  // PW_EMOJI
 
-// With all the following flags enabled, log messages look like this:
+// With all the following flags enabled except for the optional user provided
+// PW_LOG_APPEND_TIMESTAMP, log messages look like this:
 //
 // clang-format off
 //  my_file.cc                    :  42 |                Foo | TST | INF  Hello, world!
@@ -51,3 +52,18 @@
 #ifndef PW_LOG_SHOW_MODULE
 #define PW_LOG_SHOW_MODULE 0
 #endif  // PW_LOG_SHOW_MODULE
+
+// Optional user provided macro to append a prefixing timestamp string.
+// For example this could be implemented as:
+// #define PW_LOG_APPEND_TIMESTAMP(buffer) AppendSecSinceEpoch(buffer)
+//
+// void AppendSecSinceEpoch(pw::StringBuilder& builder) {
+//    const std::chrono::duration<float> float_s_since_epoch =
+//        pw::chrono::SystemClock::now().time_since_epoch();
+//    builder << float_s_since_epoch.count() << " ";
+// }
+#ifndef PW_LOG_APPEND_TIMESTAMP
+#define PW_LOG_APPEND_TIMESTAMP(buffer) \
+  do {                                  \
+  } while (0)
+#endif  // PW_LOG_APPEND_TIMESTAMP
diff --git a/pw_log_multisink/BUILD b/pw_log_multisink/BUILD
new file mode 100644
index 0000000..979c752
--- /dev/null
+++ b/pw_log_multisink/BUILD
@@ -0,0 +1,52 @@
+# 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.
+
+load(
+    "//pw_build:pigweed.bzl",
+    "pw_cc_library",
+    "pw_cc_test",
+)
+
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])  # Apache License 2.0
+
+pw_cc_library(
+    name = "pw_log_queue",
+    srcs = [ "log_queue.cc" ],
+    includes = [ "public" ],
+    deps = [
+        "//pw_bytes",
+        "//pw_containers",
+        "//pw_log",
+        "//pw_result",
+        "//pw_ring_buffer",
+        "//pw_status",
+    ],
+    hdrs = [
+        "public/pw_log_multisink/log_queue.h",
+        "public/pw_log_multisink/sink.h",
+    ]
+)
+
+pw_cc_test(
+    name = "log_queue_test",
+    srcs = [
+        "log_queue_test.cc",
+    ],
+    deps = [
+        "//pw_preprocessor",
+        "//pw_unit_test",
+    ],
+)
diff --git a/pw_log_multisink/BUILD.gn b/pw_log_multisink/BUILD.gn
new file mode 100644
index 0000000..36093c9
--- /dev/null
+++ b/pw_log_multisink/BUILD.gn
@@ -0,0 +1,59 @@
+# 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/target_types.gni")
+import("$dir_pw_docgen/docs.gni")
+import("$dir_pw_unit_test/test.gni")
+
+config("default_config") {
+  include_dirs = [ "public" ]
+  visibility = [ ":*" ]
+}
+
+pw_source_set("log_queue") {
+  public_configs = [ ":default_config" ]
+  public = [
+    "public/pw_log_multisink/log_queue.h",
+    "public/pw_log_multisink/sink.h",
+  ]
+  public_deps = [
+    "$dir_pw_bytes",
+    "$dir_pw_containers",
+    "$dir_pw_log",
+    "$dir_pw_result",
+    "$dir_pw_ring_buffer",
+    "$dir_pw_status",
+  ]
+  sources = [ "log_queue.cc" ]
+  deps = [ "$dir_pw_log:protos.pwpb" ]
+}
+
+pw_doc_group("docs") {
+  sources = [ "docs.rst" ]
+}
+
+pw_test("log_queue_test") {
+  sources = [ "log_queue_test.cc" ]
+  deps = [
+    ":log_queue",
+    "$dir_pw_log:protos.pwpb",
+    "$dir_pw_protobuf",
+  ]
+}
+
+pw_test_group("tests") {
+  tests = [ ":log_queue_test" ]
+}
diff --git a/pw_log_multisink/docs.rst b/pw_log_multisink/docs.rst
new file mode 100644
index 0000000..1c16a0c
--- /dev/null
+++ b/pw_log_multisink/docs.rst
@@ -0,0 +1,8 @@
+.. _module-pw_log_multisink:
+
+----------------
+pw_log_multisink
+----------------
+This is a RPC-based logging backend for Pigweed. It is not ready for use, and
+is under construction.
+
diff --git a/pw_log_multisink/log_queue.cc b/pw_log_multisink/log_queue.cc
new file mode 100644
index 0000000..48f9661
--- /dev/null
+++ b/pw_log_multisink/log_queue.cc
@@ -0,0 +1,123 @@
+// 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.
+
+#include "pw_log_multisink/log_queue.h"
+
+#include "pw_assert/assert.h"
+#include "pw_log/levels.h"
+#include "pw_log_proto/log.pwpb.h"
+#include "pw_protobuf/wire_format.h"
+#include "pw_status/try.h"
+
+namespace pw::log_rpc {
+namespace {
+
+using pw::protobuf::WireType;
+constexpr uint32_t kLogKey = pw::protobuf::MakeKey(
+    static_cast<uint32_t>(pw::log::LogEntries::Fields::ENTRIES),
+    WireType::kDelimited);
+
+}  // namespace
+
+Status LogQueue::PushTokenizedMessage(ConstByteSpan message,
+                                      uint32_t flags,
+                                      uint32_t level,
+                                      uint32_t line,
+                                      uint32_t thread,
+                                      int64_t timestamp) {
+  pw::protobuf::NestedEncoder nested_encoder(encode_buffer_);
+  pw::log::LogEntry::Encoder encoder(&nested_encoder);
+  Status status;
+
+  encoder.WriteMessageTokenized(message);
+  encoder.WriteLineLevel(
+      (level & PW_LOG_LEVEL_BITMASK) |
+      ((line << PW_LOG_LEVEL_BITWIDTH) & ~PW_LOG_LEVEL_BITMASK));
+  encoder.WriteFlags(flags);
+  encoder.WriteThreadTokenized(thread);
+
+  // TODO(prashanthsw): Add support for delta encoding of the timestamp.
+  encoder.WriteTimestamp(timestamp);
+
+  if (dropped_entries_ > 0) {
+    encoder.WriteDropped(dropped_entries_);
+  }
+
+  ConstByteSpan log_entry;
+  status = nested_encoder.Encode(&log_entry);
+  if (!status.ok() || log_entry.size_bytes() > max_log_entry_size_) {
+    // If an encoding failure occurs or the constructed log entry is larger
+    // than the configured max size, map the error to INTERNAL. If the
+    // underlying allocation of this encode buffer or the nested encoding
+    // sequencing are at fault, they are not the caller's responsibility. If
+    // the log entry is larger than the max allowed size, the log is dropped
+    // intentionally, and it is expected that the caller accepts this
+    // possibility.
+    status = PW_STATUS_INTERNAL;
+  } else {
+    // Try to push back the encoded log entry.
+    status = ring_buffer_.TryPushBack(log_entry, kLogKey);
+  }
+
+  if (!status.ok()) {
+    // The ring buffer may hit the RESOURCE_EXHAUSTED state, causing us
+    // to drop packets. However, this check captures all failures from
+    // Encode and TryPushBack, as any failure here causes packet drop.
+    dropped_entries_++;
+    latest_dropped_timestamp_ = timestamp;
+    return status;
+  }
+
+  dropped_entries_ = 0;
+  return OkStatus();
+}
+
+Result<LogEntries> LogQueue::Pop(LogEntriesBuffer entry_buffer) {
+  size_t ring_buffer_entry_size = 0;
+  PW_TRY(pop_status_for_test_);
+  // The caller must provide a buffer that is at minimum max_log_entry_size, to
+  // ensure that the front entry of the ring buffer can be popped.
+  PW_DCHECK_UINT_GE(entry_buffer.size_bytes(), max_log_entry_size_);
+  PW_TRY(ring_buffer_.PeekFrontWithPreamble(entry_buffer,
+                                            &ring_buffer_entry_size));
+  PW_DCHECK_OK(ring_buffer_.PopFront());
+
+  return LogEntries{
+      .entries = ConstByteSpan(entry_buffer.first(ring_buffer_entry_size)),
+      .entry_count = 1};
+}
+
+LogEntries LogQueue::PopMultiple(LogEntriesBuffer entries_buffer) {
+  size_t offset = 0;
+  size_t entry_count = 0;
+
+  // The caller must provide a buffer that is at minimum max_log_entry_size, to
+  // ensure that the front entry of the ring buffer can be popped.
+  PW_DCHECK_UINT_GE(entries_buffer.size_bytes(), max_log_entry_size_);
+
+  while (ring_buffer_.EntryCount() > 0 &&
+         (entries_buffer.size_bytes() - offset) > max_log_entry_size_) {
+    const Result<LogEntries> result = Pop(entries_buffer.subspan(offset));
+    if (!result.ok()) {
+      break;
+    }
+    offset += result.value().entries.size_bytes();
+    entry_count += result.value().entry_count;
+  }
+
+  return LogEntries{.entries = ConstByteSpan(entries_buffer.first(offset)),
+                    .entry_count = entry_count};
+}
+
+}  // namespace pw::log_rpc
diff --git a/pw_log_multisink/log_queue_test.cc b/pw_log_multisink/log_queue_test.cc
new file mode 100644
index 0000000..fc75706
--- /dev/null
+++ b/pw_log_multisink/log_queue_test.cc
@@ -0,0 +1,235 @@
+// 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.
+
+#include "pw_log_multisink/log_queue.h"
+
+#include "gtest/gtest.h"
+#include "pw_log/levels.h"
+#include "pw_log_proto/log.pwpb.h"
+#include "pw_protobuf/decoder.h"
+
+namespace pw::log_rpc {
+namespace {
+
+constexpr size_t kEncodeBufferSize = 512;
+
+constexpr const char kTokenizedMessage[] = "msg_token";
+constexpr uint32_t kFlags = 0xF;
+constexpr uint32_t kLevel = 0b010;
+constexpr uint32_t kLine = 0b101011000;
+constexpr uint32_t kTokenizedThread = 0xF;
+constexpr int64_t kTimestamp = 0;
+
+constexpr size_t kLogBufferSize = kEncodeBufferSize * 3;
+
+void VerifyLogEntry(pw::protobuf::Decoder& log_decoder,
+                    const char* expected_tokenized_message,
+                    const uint32_t expected_flags,
+                    const uint32_t expected_level,
+                    const uint32_t expected_line,
+                    const uint32_t expected_tokenized_thread,
+                    const int64_t expected_timestamp) {
+  ConstByteSpan log_entry_message;
+  EXPECT_TRUE(log_decoder.Next().ok());  // preamble
+  EXPECT_EQ(1U, log_decoder.FieldNumber());
+  EXPECT_TRUE(log_decoder.ReadBytes(&log_entry_message).ok());
+
+  pw::protobuf::Decoder entry_decoder(log_entry_message);
+  ConstByteSpan tokenized_message;
+  EXPECT_TRUE(entry_decoder.Next().ok());  // tokenized_message
+  EXPECT_EQ(1U, entry_decoder.FieldNumber());
+  EXPECT_TRUE(entry_decoder.ReadBytes(&tokenized_message).ok());
+  EXPECT_TRUE(std::memcmp(tokenized_message.begin(),
+                          (const void*)expected_tokenized_message,
+                          tokenized_message.size()) == 0);
+
+  uint32_t line_level;
+  EXPECT_TRUE(entry_decoder.Next().ok());  // line_level
+  EXPECT_EQ(2U, entry_decoder.FieldNumber());
+  EXPECT_TRUE(entry_decoder.ReadUint32(&line_level).ok());
+  EXPECT_EQ(expected_level, line_level & PW_LOG_LEVEL_BITMASK);
+  EXPECT_EQ(expected_line,
+            (line_level & ~PW_LOG_LEVEL_BITMASK) >> PW_LOG_LEVEL_BITWIDTH);
+
+  uint32_t flags;
+  EXPECT_TRUE(entry_decoder.Next().ok());  // flags
+  EXPECT_EQ(3U, entry_decoder.FieldNumber());
+  EXPECT_TRUE(entry_decoder.ReadUint32(&flags).ok());
+  EXPECT_EQ(expected_flags, flags);
+
+  uint32_t tokenized_thread;
+  EXPECT_TRUE(entry_decoder.Next().ok());  // tokenized_thread
+  EXPECT_EQ(4U, entry_decoder.FieldNumber());
+  EXPECT_TRUE(entry_decoder.ReadUint32(&tokenized_thread).ok());
+  EXPECT_EQ(expected_tokenized_thread, tokenized_thread);
+
+  int64_t timestamp;
+  EXPECT_TRUE(entry_decoder.Next().ok());  // timestamp
+  EXPECT_EQ(5U, entry_decoder.FieldNumber());
+  EXPECT_TRUE(entry_decoder.ReadInt64(&timestamp).ok());
+  EXPECT_EQ(expected_timestamp, timestamp);
+}
+
+}  // namespace
+
+TEST(LogQueue, SinglePushPopTokenizedMessage) {
+  std::byte log_buffer[kLogBufferSize];
+  LogQueueWithEncodeBuffer<kEncodeBufferSize> log_queue(log_buffer);
+
+  EXPECT_EQ(OkStatus(),
+            log_queue.PushTokenizedMessage(
+                std::as_bytes(std::span(kTokenizedMessage)),
+                kFlags,
+                kLevel,
+                kLine,
+                kTokenizedThread,
+                kTimestamp));
+
+  std::byte log_entry[kEncodeBufferSize];
+  Result<LogEntries> pop_result = log_queue.Pop(std::span(log_entry));
+  EXPECT_TRUE(pop_result.ok());
+
+  pw::protobuf::Decoder log_decoder(pop_result.value().entries);
+  EXPECT_EQ(pop_result.value().entry_count, 1U);
+  VerifyLogEntry(log_decoder,
+                 kTokenizedMessage,
+                 kFlags,
+                 kLevel,
+                 kLine,
+                 kTokenizedThread,
+                 kTimestamp);
+}
+
+TEST(LogQueue, MultiplePushPopTokenizedMessage) {
+  constexpr size_t kEntryCount = 3;
+
+  std::byte log_buffer[1024];
+  LogQueueWithEncodeBuffer<kEncodeBufferSize> log_queue(log_buffer);
+
+  for (size_t i = 0; i < kEntryCount; i++) {
+    EXPECT_EQ(OkStatus(),
+              log_queue.PushTokenizedMessage(
+                  std::as_bytes(std::span(kTokenizedMessage)),
+                  kFlags,
+                  kLevel,
+                  kLine + (i << 3),
+                  kTokenizedThread,
+                  kTimestamp + i));
+  }
+
+  std::byte log_entry[kEncodeBufferSize];
+  for (size_t i = 0; i < kEntryCount; i++) {
+    Result<LogEntries> pop_result = log_queue.Pop(std::span(log_entry));
+    EXPECT_TRUE(pop_result.ok());
+
+    pw::protobuf::Decoder log_decoder(pop_result.value().entries);
+    EXPECT_EQ(pop_result.value().entry_count, 1U);
+    VerifyLogEntry(log_decoder,
+                   kTokenizedMessage,
+                   kFlags,
+                   kLevel,
+                   kLine + (i << 3),
+                   kTokenizedThread,
+                   kTimestamp + i);
+  }
+}
+
+TEST(LogQueue, PopMultiple) {
+  constexpr size_t kEntryCount = 3;
+
+  std::byte log_buffer[kLogBufferSize];
+  LogQueueWithEncodeBuffer<kEncodeBufferSize> log_queue(log_buffer);
+
+  for (size_t i = 0; i < kEntryCount; i++) {
+    EXPECT_EQ(OkStatus(),
+              log_queue.PushTokenizedMessage(
+                  std::as_bytes(std::span(kTokenizedMessage)),
+                  kFlags,
+                  kLevel,
+                  kLine + (i << 3),
+                  kTokenizedThread,
+                  kTimestamp + i));
+  }
+
+  std::byte log_entries[kLogBufferSize];
+  Result<LogEntries> pop_result = log_queue.PopMultiple(log_entries);
+  EXPECT_TRUE(pop_result.ok());
+
+  pw::protobuf::Decoder log_decoder(pop_result.value().entries);
+  EXPECT_EQ(pop_result.value().entry_count, kEntryCount);
+  for (size_t i = 0; i < kEntryCount; i++) {
+    VerifyLogEntry(log_decoder,
+                   kTokenizedMessage,
+                   kFlags,
+                   kLevel,
+                   kLine + (i << 3),
+                   kTokenizedThread,
+                   kTimestamp + i);
+  }
+}
+
+TEST(LogQueue, TooSmallEncodeBuffer) {
+  constexpr size_t kSmallBuffer = 1;
+
+  std::byte log_buffer[kLogBufferSize];
+  LogQueueWithEncodeBuffer<kSmallBuffer> log_queue(log_buffer);
+  EXPECT_EQ(Status::Internal(),
+            log_queue.PushTokenizedMessage(
+                std::as_bytes(std::span(kTokenizedMessage)),
+                kFlags,
+                kLevel,
+                kLine,
+                kTokenizedThread,
+                kTimestamp));
+}
+
+TEST(LogQueue, TooSmallLogBuffer) {
+  constexpr size_t kSmallerThanPreamble = 1;
+  constexpr size_t kEntryCount = 100;
+
+  // Expect OUT_OF_RANGE when the buffer is smaller than a preamble.
+  std::byte log_buffer[kLogBufferSize];
+  LogQueueWithEncodeBuffer<kEncodeBufferSize> log_queue_small(
+      std::span(log_buffer, kSmallerThanPreamble));
+  EXPECT_EQ(Status::OutOfRange(),
+            log_queue_small.PushTokenizedMessage(
+                std::as_bytes(std::span(kTokenizedMessage)),
+                kFlags,
+                kLevel,
+                kLine,
+                kTokenizedThread,
+                kTimestamp));
+
+  // Expect RESOURCE_EXHAUSTED when there's not enough space for the chunk.
+  LogQueueWithEncodeBuffer<kEncodeBufferSize> log_queue_medium(log_buffer);
+  for (size_t i = 0; i < kEntryCount; i++) {
+    log_queue_medium.PushTokenizedMessage(
+        std::as_bytes(std::span(kTokenizedMessage)),
+        kFlags,
+        kLevel,
+        kLine,
+        kTokenizedThread,
+        kTimestamp);
+  }
+  EXPECT_EQ(Status::ResourceExhausted(),
+            log_queue_medium.PushTokenizedMessage(
+                std::as_bytes(std::span(kTokenizedMessage)),
+                kFlags,
+                kLevel,
+                kLine,
+                kTokenizedThread,
+                kTimestamp));
+}
+
+}  // namespace pw::log_rpc
diff --git a/pw_log_multisink/public/pw_log_multisink/log_queue.h b/pw_log_multisink/public/pw_log_multisink/log_queue.h
new file mode 100644
index 0000000..f358abb
--- /dev/null
+++ b/pw_log_multisink/public/pw_log_multisink/log_queue.h
@@ -0,0 +1,134 @@
+// 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.
+
+#pragma once
+
+#include "pw_bytes/span.h"
+#include "pw_result/result.h"
+#include "pw_ring_buffer/prefixed_entry_ring_buffer.h"
+#include "pw_status/status.h"
+#include "pw_status/status_with_size.h"
+
+// LogQueue is a ring-buffer queue of log messages. LogQueue is backed by
+// a caller-provided byte array and stores its messages in the format
+// dictated by the pw_log log.proto format.
+//
+// Logs can be returned as a repeated proto message and the output of this
+// class can be directly fed into an RPC stream.
+//
+// Push logs:
+// 0) Create LogQueue instance.
+// 1) LogQueue::PushTokenizedMessage().
+//
+// Pop logs:
+// 0) Use exsiting LogQueue instance.
+// 1) For single entires, LogQueue::Pop().
+// 2) For multiple entries, LogQueue::PopMultiple().
+namespace pw::log_rpc {
+namespace {
+constexpr size_t kLogEntryMaxSize = 100;
+}  // namespace
+
+using LogEntriesBuffer = ByteSpan;
+
+struct LogEntries {
+  // A buffer containing an encoded protobuf of type pw.log.LogEntries.
+  ConstByteSpan entries;
+  size_t entry_count;
+};
+
+class LogQueue {
+ public:
+  // Constructs a LogQueue. Callers can optionally supply a maximum log entry
+  // size, which limits the size of messages that can be pushed into this log
+  // queue. When such an entry arrives, the queue increments its drop counter.
+  // Calls to Pop and PopMultiple should be provided a buffer of at least the
+  // configured max size.
+  LogQueue(ByteSpan log_buffer,
+           ByteSpan encode_buffer,
+           size_t max_log_entry_size = kLogEntryMaxSize)
+      : pop_status_for_test_(OkStatus()),
+        max_log_entry_size_(max_log_entry_size),
+        encode_buffer_(encode_buffer),
+        ring_buffer_(true) {
+    ring_buffer_.SetBuffer(log_buffer);
+  }
+
+  LogQueue(const LogQueue&) = delete;
+  LogQueue& operator=(const LogQueue&) = delete;
+  LogQueue(LogQueue&&) = delete;
+  LogQueue& operator=(LogQueue&&) = delete;
+
+  // Construct a LogEntry proto message and push it into the ring buffer.
+  // Returns:
+  //
+  //  OK - success.
+  //  FAILED_PRECONDITION - Failed when encoding the proto message.
+  //  RESOURCE_EXHAUSTED - Not enough space in the buffer to write the entry.
+  Status PushTokenizedMessage(ConstByteSpan message,
+                              uint32_t flags,
+                              uint32_t level,
+                              uint32_t line,
+                              uint32_t thread,
+                              int64_t timestamp);
+
+  // Pop the oldest LogEntry from the queue into the provided buffer.
+  // On success, the size is the length of the entry, on failure, the size is 0.
+  // Returns:
+  //
+  // For now, don't support batching. This will always use a single absolute
+  // timestamp, and not use delta encoding.
+  //
+  //  OK - success.
+  //  OUT_OF_RANGE - No entries in queue to read.
+  //  RESOURCE_EXHAUSTED - Destination data std::span was smaller number of
+  //  bytes than the data size of the data chunk being read.  Available
+  //  destination bytes were filled, remaining bytes of the data chunk were
+  //  ignored.
+  Result<LogEntries> Pop(LogEntriesBuffer entry_buffer);
+
+  // Pop entries from the queue into the provided buffer. The provided buffer is
+  // filled until there is insufficient space for the next log entry.
+  // Returns:
+  //
+  // LogEntries - contains an encoded protobuf byte span of pw.log.LogEntries.
+  LogEntries PopMultiple(LogEntriesBuffer entries_buffer);
+
+ protected:
+  friend class LogQueueTester;
+  // For testing, status to return on calls to Pop.
+  Status pop_status_for_test_;
+
+ private:
+  const size_t max_log_entry_size_;
+  size_t dropped_entries_;
+  int64_t latest_dropped_timestamp_;
+
+  ByteSpan encode_buffer_;
+  pw::ring_buffer::PrefixedEntryRingBuffer ring_buffer_{true};
+};
+
+// LogQueueWithEncodeBuffer is a LogQueue where the internal encode buffer is
+// created and managed by this class.
+template <size_t kEncodeBufferSize>
+class LogQueueWithEncodeBuffer : public LogQueue {
+ public:
+  LogQueueWithEncodeBuffer(ByteSpan log_buffer)
+      : LogQueue(log_buffer, encode_buffer_) {}
+
+ private:
+  std::byte encode_buffer_[kEncodeBufferSize];
+};
+
+}  // namespace pw::log_rpc
diff --git a/pw_log_multisink/public/pw_log_multisink/sink.h b/pw_log_multisink/public/pw_log_multisink/sink.h
new file mode 100644
index 0000000..53929ac
--- /dev/null
+++ b/pw_log_multisink/public/pw_log_multisink/sink.h
@@ -0,0 +1,31 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "pw_containers/intrusive_list.h"
+
+namespace pw::log_sink {
+
+class Sink : public IntrusiveList<Sink>::Item {
+ public:
+  // Provides an entry to the sink (e.g. an encoded protobuf).
+  virtual void HandleEntry(ConstByteSpan entry) = 0;
+  // Informs the sink of an entry that was dropped. This is used to indicate
+  // to the sink that it will produce gaps in outgoing entries.
+  virtual void HandleDropped(size_t count = 1) = 0;
+
+  virtual ~Sink() = default;
+};
+
+}  // namespace pw::log_sink
diff --git a/pw_log_null/BUILD.gn b/pw_log_null/BUILD.gn
index 48a25f8..f4b546a 100644
--- a/pw_log_null/BUILD.gn
+++ b/pw_log_null/BUILD.gn
@@ -30,7 +30,7 @@
   public_configs = [ ":config" ]
   public = [ "public_overrides/pw_log_backend/log_backend.h" ]
   sources = [ "public/pw_log_null/log_null.h" ]
-  friend = [ ":test" ]
+  friend = [ ":*" ]
 }
 
 pw_doc_group("docs") {
diff --git a/pw_log_null/public/pw_log_null/log_null.h b/pw_log_null/public/pw_log_null/log_null.h
index 054d6d0..4587e09 100644
--- a/pw_log_null/public/pw_log_null/log_null.h
+++ b/pw_log_null/public/pw_log_null/log_null.h
@@ -41,10 +41,10 @@
                                   const char* module_name,
                                   const char* message,
                                   ...) {
-  PW_UNUSED(level);
-  PW_UNUSED(flags);
-  PW_UNUSED(module_name);
-  PW_UNUSED(message);
+  (void)level;
+  (void)flags;
+  (void)module_name;
+  (void)message;
 }
 
 PW_EXTERN_C_END
diff --git a/pw_log_null/test_c.c b/pw_log_null/test_c.c
index 4143f63..6d568d7 100644
--- a/pw_log_null/test_c.c
+++ b/pw_log_null/test_c.c
@@ -23,7 +23,7 @@
 
 static int IncrementGlobal(void) { return ++global; }
 
-bool CTest() {
+bool CTest(void) {
   PW_LOG(1, 2, "3");
   PW_LOG(1, 2, "whoa");
   PW_LOG(1, 2, "%s", "hello");
diff --git a/pw_log_rpc/BUILD b/pw_log_rpc/BUILD
index 17c9649..a779851 100644
--- a/pw_log_rpc/BUILD
+++ b/pw_log_rpc/BUILD
@@ -35,31 +35,6 @@
     hdrs = [ "public/pw_log_rpc/logs_rpc.h" ]
 )
 
-pw_cc_library(
-    name = "pw_log_queue",
-    srcs = [ "log_queue.cc" ],
-    includes = [ "public" ],
-    deps = [
-        "//pw_bytes",
-        "//pw_log",
-        "//pw_result",
-        "//pw_ring_buffer",
-        "//pw_status",
-    ],
-    hdrs = [ "public/pw_log_rpc/log_queue.h" ]
-)
-
-pw_cc_test(
-    name = "log_queue_test",
-    srcs = [
-        "log_queue_test.cc",
-    ],
-    deps = [
-        "//pw_preprocessor",
-        "//pw_unit_test",
-    ],
-)
-
 pw_cc_test(
     name = "logs_rpc_test",
     srcs = [
diff --git a/pw_log_rpc/BUILD.gn b/pw_log_rpc/BUILD.gn
index 2f308a0..ea40507 100644
--- a/pw_log_rpc/BUILD.gn
+++ b/pw_log_rpc/BUILD.gn
@@ -16,7 +16,6 @@
 
 import("$dir_pw_build/target_types.gni")
 import("$dir_pw_docgen/docs.gni")
-import("$dir_pw_protobuf_compiler/proto.gni")
 import("$dir_pw_unit_test/test.gni")
 
 config("default_config") {
@@ -29,26 +28,12 @@
   public = [ "public/pw_log_rpc/logs_rpc.h" ]
   sources = [ "logs_rpc.cc" ]
   public_deps = [
-    ":log_queue",
-    ":protos.pwpb",
-    ":protos.raw_rpc",
+    "$dir_pw_log:protos.pwpb",
+    "$dir_pw_log:protos.raw_rpc",
+    "$dir_pw_log_multisink:log_queue",
   ]
 }
 
-pw_source_set("log_queue") {
-  public_configs = [ ":default_config" ]
-  public = [ "public/pw_log_rpc/log_queue.h" ]
-  public_deps = [
-    "$dir_pw_bytes",
-    "$dir_pw_log",
-    "$dir_pw_result",
-    "$dir_pw_ring_buffer",
-    "$dir_pw_status",
-  ]
-  sources = [ "log_queue.cc" ]
-  deps = [ ":protos.pwpb" ]
-}
-
 pw_test("logs_rpc_test") {
   deps = [
     ":logs",
@@ -57,26 +42,10 @@
   sources = [ "logs_rpc_test.cc" ]
 }
 
-pw_proto_library("protos") {
-  sources = [ "pw_log_proto/log.proto" ]
-}
-
 pw_doc_group("docs") {
   sources = [ "docs.rst" ]
 }
 
-pw_test("log_queue_test") {
-  sources = [ "log_queue_test.cc" ]
-  deps = [
-    ":log_queue",
-    ":protos.pwpb",
-    dir_pw_protobuf,
-  ]
-}
-
 pw_test_group("tests") {
-  tests = [
-    ":log_queue_test",
-    ":logs_rpc_test",
-  ]
+  tests = [ ":logs_rpc_test" ]
 }
diff --git a/pw_log_rpc/log_queue.cc b/pw_log_rpc/log_queue.cc
deleted file mode 100644
index a17cedf..0000000
--- a/pw_log_rpc/log_queue.cc
+++ /dev/null
@@ -1,122 +0,0 @@
-// 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.
-
-#include "pw_log_rpc/log_queue.h"
-
-#include "pw_log/levels.h"
-#include "pw_log_proto/log.pwpb.h"
-#include "pw_protobuf/wire_format.h"
-#include "pw_status/try.h"
-
-namespace pw::log_rpc {
-namespace {
-
-using pw::protobuf::WireType;
-constexpr std::byte kLogKey = static_cast<std::byte>(pw::protobuf::MakeKey(
-    static_cast<uint32_t>(pw::log::LogEntries::Fields::ENTRIES),
-    WireType::kDelimited));
-
-}  // namespace
-
-Status LogQueue::PushTokenizedMessage(ConstByteSpan message,
-                                      uint32_t flags,
-                                      uint32_t level,
-                                      uint32_t line,
-                                      uint32_t thread,
-                                      int64_t timestamp) {
-  pw::protobuf::NestedEncoder nested_encoder(encode_buffer_);
-  pw::log::LogEntry::Encoder encoder(&nested_encoder);
-  Status status;
-
-  encoder.WriteMessageTokenized(message);
-  encoder.WriteLineLevel(
-      (level & PW_LOG_LEVEL_BITMASK) |
-      ((line << PW_LOG_LEVEL_BITWIDTH) & ~PW_LOG_LEVEL_BITMASK));
-  encoder.WriteFlags(flags);
-  encoder.WriteThreadTokenized(thread);
-
-  // TODO(prashanthsw): Add support for delta encoding of the timestamp.
-  encoder.WriteTimestamp(timestamp);
-
-  if (dropped_entries_ > 0) {
-    encoder.WriteDropped(dropped_entries_);
-  }
-
-  ConstByteSpan log_entry;
-  status = nested_encoder.Encode(&log_entry);
-  if (!status.ok() || log_entry.size_bytes() > max_log_entry_size_) {
-    // If an encoding failure occurs or the constructed log entry is larger
-    // than the configured max size, map the error to INTERNAL. If the
-    // underlying allocation of this encode buffer or the nested encoding
-    // sequencing are at fault, they are not the caller's responsibility. If
-    // the log entry is larger than the max allowed size, the log is dropped
-    // intentionally, and it is expected that the caller accepts this
-    // possibility.
-    status = PW_STATUS_INTERNAL;
-  } else {
-    // Try to push back the encoded log entry.
-    status = ring_buffer_.TryPushBack(log_entry, std::byte(kLogKey));
-  }
-
-  if (!status.ok()) {
-    // The ring buffer may hit the RESOURCE_EXHAUSTED state, causing us
-    // to drop packets. However, this check captures all failures from
-    // Encode and TryPushBack, as any failure here causes packet drop.
-    dropped_entries_++;
-    latest_dropped_timestamp_ = timestamp;
-    return status;
-  }
-
-  dropped_entries_ = 0;
-  return Status::Ok();
-}
-
-Result<LogEntries> LogQueue::Pop(LogEntriesBuffer entry_buffer) {
-  size_t ring_buffer_entry_size = 0;
-  PW_TRY(pop_status_for_test_);
-  // The caller must provide a buffer that is at minimum max_log_entry_size, to
-  // ensure that the front entry of the ring buffer can be popped.
-  PW_DCHECK_UINT_GE(entry_buffer.size_bytes(), max_log_entry_size_);
-  PW_TRY(ring_buffer_.PeekFrontWithPreamble(entry_buffer,
-                                            &ring_buffer_entry_size));
-  PW_DCHECK_OK(ring_buffer_.PopFront());
-
-  return LogEntries{
-      .entries = ConstByteSpan(entry_buffer.first(ring_buffer_entry_size)),
-      .entry_count = 1};
-}
-
-LogEntries LogQueue::PopMultiple(LogEntriesBuffer entries_buffer) {
-  size_t offset = 0;
-  size_t entry_count = 0;
-
-  // The caller must provide a buffer that is at minimum max_log_entry_size, to
-  // ensure that the front entry of the ring buffer can be popped.
-  PW_DCHECK_UINT_GE(entries_buffer.size_bytes(), max_log_entry_size_);
-
-  while (ring_buffer_.EntryCount() > 0 &&
-         (entries_buffer.size_bytes() - offset) > max_log_entry_size_) {
-    const Result<LogEntries> result = Pop(entries_buffer.subspan(offset));
-    if (!result.ok()) {
-      break;
-    }
-    offset += result.value().entries.size_bytes();
-    entry_count += result.value().entry_count;
-  }
-
-  return LogEntries{.entries = ConstByteSpan(entries_buffer.first(offset)),
-                    .entry_count = entry_count};
-}
-
-}  // namespace pw::log_rpc
diff --git a/pw_log_rpc/log_queue_test.cc b/pw_log_rpc/log_queue_test.cc
deleted file mode 100644
index f6a499a..0000000
--- a/pw_log_rpc/log_queue_test.cc
+++ /dev/null
@@ -1,235 +0,0 @@
-// 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.
-
-#include "pw_log_rpc/log_queue.h"
-
-#include "gtest/gtest.h"
-#include "pw_log/levels.h"
-#include "pw_log_proto/log.pwpb.h"
-#include "pw_protobuf/decoder.h"
-
-namespace pw::log_rpc {
-namespace {
-
-constexpr size_t kEncodeBufferSize = 512;
-
-constexpr const char kTokenizedMessage[] = "msg_token";
-constexpr uint32_t kFlags = 0xF;
-constexpr uint32_t kLevel = 0b010;
-constexpr uint32_t kLine = 0b101011000;
-constexpr uint32_t kTokenizedThread = 0xF;
-constexpr int64_t kTimestamp = 0;
-
-constexpr size_t kLogBufferSize = kEncodeBufferSize * 3;
-
-void VerifyLogEntry(pw::protobuf::Decoder& log_decoder,
-                    const char* expected_tokenized_message,
-                    const uint32_t expected_flags,
-                    const uint32_t expected_level,
-                    const uint32_t expected_line,
-                    const uint32_t expected_tokenized_thread,
-                    const int64_t expected_timestamp) {
-  ConstByteSpan log_entry_message;
-  EXPECT_TRUE(log_decoder.Next().ok());  // preamble
-  EXPECT_EQ(1U, log_decoder.FieldNumber());
-  EXPECT_TRUE(log_decoder.ReadBytes(&log_entry_message).ok());
-
-  pw::protobuf::Decoder entry_decoder(log_entry_message);
-  ConstByteSpan tokenized_message;
-  EXPECT_TRUE(entry_decoder.Next().ok());  // tokenized_message
-  EXPECT_EQ(1U, entry_decoder.FieldNumber());
-  EXPECT_TRUE(entry_decoder.ReadBytes(&tokenized_message).ok());
-  EXPECT_TRUE(std::memcmp(tokenized_message.begin(),
-                          (const void*)expected_tokenized_message,
-                          tokenized_message.size()) == 0);
-
-  uint32_t line_level;
-  EXPECT_TRUE(entry_decoder.Next().ok());  // line_level
-  EXPECT_EQ(2U, entry_decoder.FieldNumber());
-  EXPECT_TRUE(entry_decoder.ReadUint32(&line_level).ok());
-  EXPECT_EQ(expected_level, line_level & PW_LOG_LEVEL_BITMASK);
-  EXPECT_EQ(expected_line,
-            (line_level & ~PW_LOG_LEVEL_BITMASK) >> PW_LOG_LEVEL_BITWIDTH);
-
-  uint32_t flags;
-  EXPECT_TRUE(entry_decoder.Next().ok());  // flags
-  EXPECT_EQ(3U, entry_decoder.FieldNumber());
-  EXPECT_TRUE(entry_decoder.ReadUint32(&flags).ok());
-  EXPECT_EQ(expected_flags, flags);
-
-  uint32_t tokenized_thread;
-  EXPECT_TRUE(entry_decoder.Next().ok());  // tokenized_thread
-  EXPECT_EQ(4U, entry_decoder.FieldNumber());
-  EXPECT_TRUE(entry_decoder.ReadUint32(&tokenized_thread).ok());
-  EXPECT_EQ(expected_tokenized_thread, tokenized_thread);
-
-  int64_t timestamp;
-  EXPECT_TRUE(entry_decoder.Next().ok());  // timestamp
-  EXPECT_EQ(5U, entry_decoder.FieldNumber());
-  EXPECT_TRUE(entry_decoder.ReadInt64(&timestamp).ok());
-  EXPECT_EQ(expected_timestamp, timestamp);
-}
-
-}  // namespace
-
-TEST(LogQueue, SinglePushPopTokenizedMessage) {
-  std::byte log_buffer[kLogBufferSize];
-  LogQueueWithEncodeBuffer<kEncodeBufferSize> log_queue(log_buffer);
-
-  EXPECT_EQ(Status::OK,
-            log_queue.PushTokenizedMessage(
-                std::as_bytes(std::span(kTokenizedMessage)),
-                kFlags,
-                kLevel,
-                kLine,
-                kTokenizedThread,
-                kTimestamp));
-
-  std::byte log_entry[kEncodeBufferSize];
-  Result<LogEntries> pop_result = log_queue.Pop(std::span(log_entry));
-  EXPECT_TRUE(pop_result.ok());
-
-  pw::protobuf::Decoder log_decoder(pop_result.value().entries);
-  EXPECT_EQ(pop_result.value().entry_count, 1U);
-  VerifyLogEntry(log_decoder,
-                 kTokenizedMessage,
-                 kFlags,
-                 kLevel,
-                 kLine,
-                 kTokenizedThread,
-                 kTimestamp);
-}
-
-TEST(LogQueue, MultiplePushPopTokenizedMessage) {
-  constexpr size_t kEntryCount = 3;
-
-  std::byte log_buffer[1024];
-  LogQueueWithEncodeBuffer<kEncodeBufferSize> log_queue(log_buffer);
-
-  for (size_t i = 0; i < kEntryCount; i++) {
-    EXPECT_EQ(Status::OK,
-              log_queue.PushTokenizedMessage(
-                  std::as_bytes(std::span(kTokenizedMessage)),
-                  kFlags,
-                  kLevel,
-                  kLine + (i << 3),
-                  kTokenizedThread,
-                  kTimestamp + i));
-  }
-
-  std::byte log_entry[kEncodeBufferSize];
-  for (size_t i = 0; i < kEntryCount; i++) {
-    Result<LogEntries> pop_result = log_queue.Pop(std::span(log_entry));
-    EXPECT_TRUE(pop_result.ok());
-
-    pw::protobuf::Decoder log_decoder(pop_result.value().entries);
-    EXPECT_EQ(pop_result.value().entry_count, 1U);
-    VerifyLogEntry(log_decoder,
-                   kTokenizedMessage,
-                   kFlags,
-                   kLevel,
-                   kLine + (i << 3),
-                   kTokenizedThread,
-                   kTimestamp + i);
-  }
-}
-
-TEST(LogQueue, PopMultiple) {
-  constexpr size_t kEntryCount = 3;
-
-  std::byte log_buffer[kLogBufferSize];
-  LogQueueWithEncodeBuffer<kEncodeBufferSize> log_queue(log_buffer);
-
-  for (size_t i = 0; i < kEntryCount; i++) {
-    EXPECT_EQ(Status::OK,
-              log_queue.PushTokenizedMessage(
-                  std::as_bytes(std::span(kTokenizedMessage)),
-                  kFlags,
-                  kLevel,
-                  kLine + (i << 3),
-                  kTokenizedThread,
-                  kTimestamp + i));
-  }
-
-  std::byte log_entries[kLogBufferSize];
-  Result<LogEntries> pop_result = log_queue.PopMultiple(log_entries);
-  EXPECT_TRUE(pop_result.ok());
-
-  pw::protobuf::Decoder log_decoder(pop_result.value().entries);
-  EXPECT_EQ(pop_result.value().entry_count, kEntryCount);
-  for (size_t i = 0; i < kEntryCount; i++) {
-    VerifyLogEntry(log_decoder,
-                   kTokenizedMessage,
-                   kFlags,
-                   kLevel,
-                   kLine + (i << 3),
-                   kTokenizedThread,
-                   kTimestamp + i);
-  }
-}
-
-TEST(LogQueue, TooSmallEncodeBuffer) {
-  constexpr size_t kSmallBuffer = 1;
-
-  std::byte log_buffer[kLogBufferSize];
-  LogQueueWithEncodeBuffer<kSmallBuffer> log_queue(log_buffer);
-  EXPECT_EQ(Status::INTERNAL,
-            log_queue.PushTokenizedMessage(
-                std::as_bytes(std::span(kTokenizedMessage)),
-                kFlags,
-                kLevel,
-                kLine,
-                kTokenizedThread,
-                kTimestamp));
-}
-
-TEST(LogQueue, TooSmallLogBuffer) {
-  constexpr size_t kSmallerThanPreamble = 1;
-  constexpr size_t kEntryCount = 100;
-
-  // Expect OUT_OF_RANGE when the buffer is smaller than a preamble.
-  std::byte log_buffer[kLogBufferSize];
-  LogQueueWithEncodeBuffer<kEncodeBufferSize> log_queue_small(
-      std::span(log_buffer, kSmallerThanPreamble));
-  EXPECT_EQ(Status::OUT_OF_RANGE,
-            log_queue_small.PushTokenizedMessage(
-                std::as_bytes(std::span(kTokenizedMessage)),
-                kFlags,
-                kLevel,
-                kLine,
-                kTokenizedThread,
-                kTimestamp));
-
-  // Expect RESOURCE_EXHAUSTED when there's not enough space for the chunk.
-  LogQueueWithEncodeBuffer<kEncodeBufferSize> log_queue_medium(log_buffer);
-  for (size_t i = 0; i < kEntryCount; i++) {
-    log_queue_medium.PushTokenizedMessage(
-        std::as_bytes(std::span(kTokenizedMessage)),
-        kFlags,
-        kLevel,
-        kLine,
-        kTokenizedThread,
-        kTimestamp);
-  }
-  EXPECT_EQ(Status::RESOURCE_EXHAUSTED,
-            log_queue_medium.PushTokenizedMessage(
-                std::as_bytes(std::span(kTokenizedMessage)),
-                kFlags,
-                kLevel,
-                kLine,
-                kTokenizedThread,
-                kTimestamp));
-}
-
-}  // namespace pw::log_rpc
diff --git a/pw_log_rpc/logs_rpc.cc b/pw_log_rpc/logs_rpc.cc
index ada5470..c790b22 100644
--- a/pw_log_rpc/logs_rpc.cc
+++ b/pw_log_rpc/logs_rpc.cc
@@ -39,7 +39,7 @@
   // If the response writer was not initialized or has since been closed,
   // ignore the flush operation.
   if (!response_writer_.open()) {
-    return Status::Ok();
+    return OkStatus();
   }
 
   // If previous calls to flush resulted in dropped entries, generate a
@@ -59,7 +59,7 @@
   Result possible_logs = log_queue_.PopMultiple(payload);
   PW_TRY(possible_logs.status());
   if (possible_logs.value().entry_count == 0) {
-    return Status::Ok();
+    return OkStatus();
   }
 
   Status status = response_writer_.Write(possible_logs.value().entries);
@@ -69,7 +69,7 @@
     return status;
   }
 
-  return Status::Ok();
+  return OkStatus();
 }
 
 }  // namespace pw::log_rpc
diff --git a/pw_log_rpc/logs_rpc_test.cc b/pw_log_rpc/logs_rpc_test.cc
index 50541bb..88a3f50 100644
--- a/pw_log_rpc/logs_rpc_test.cc
+++ b/pw_log_rpc/logs_rpc_test.cc
@@ -45,7 +45,7 @@
     constexpr char kTokenizedMessage[] = "message";
     for (size_t i = 0; i < log_count; i++) {
       EXPECT_EQ(
-          Status::Ok(),
+          OkStatus(),
           log_queue_.PushTokenizedMessage(
               std::as_bytes(std::span(kTokenizedMessage)), 0, 0, 0, 0, 0));
     }
@@ -72,7 +72,7 @@
   GetLogs(context).Finish();
 
   EXPECT_TRUE(context.done());
-  EXPECT_EQ(Status::Ok(), context.status());
+  EXPECT_EQ(OkStatus(), context.status());
 
   // Although |kLogEntryCount| messages were in the queue, they are batched
   // before being written to the client, so there is only one response.
@@ -94,7 +94,7 @@
   GetLogs(context).Finish();
 
   EXPECT_TRUE(context.done());
-  EXPECT_EQ(Status::Ok(), context.status());
+  EXPECT_EQ(OkStatus(), context.status());
   EXPECT_EQ(kFlushCount, context.total_responses());
 }
 
@@ -108,7 +108,7 @@
   GetLogs(context).Finish();
 
   EXPECT_TRUE(context.done());
-  EXPECT_EQ(Status::Ok(), context.status());
+  EXPECT_EQ(OkStatus(), context.status());
   EXPECT_EQ(0U, context.total_responses());
 }
 
@@ -124,7 +124,7 @@
   GetLogs(context).Finish();
 
   EXPECT_TRUE(context.done());
-  EXPECT_EQ(Status::Ok(), context.status());
+  EXPECT_EQ(OkStatus(), context.status());
   EXPECT_EQ(0U, context.total_responses());
 }
 
diff --git a/pw_log_rpc/public/pw_log_rpc/log_queue.h b/pw_log_rpc/public/pw_log_rpc/log_queue.h
deleted file mode 100644
index 450d0fa..0000000
--- a/pw_log_rpc/public/pw_log_rpc/log_queue.h
+++ /dev/null
@@ -1,134 +0,0 @@
-// 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.
-
-#pragma once
-
-#include "pw_bytes/span.h"
-#include "pw_result/result.h"
-#include "pw_ring_buffer/prefixed_entry_ring_buffer.h"
-#include "pw_status/status.h"
-#include "pw_status/status_with_size.h"
-
-// LogQueue is a ring-buffer queue of log messages. LogQueue is backed by
-// a caller-provided byte array and stores its messages in the format
-// dictated by the pw_log log.proto format.
-//
-// Logs can be returned as a repeated proto message and the output of this
-// class can be directly fed into an RPC stream.
-//
-// Push logs:
-// 0) Create LogQueue instance.
-// 1) LogQueue::PushTokenizedMessage().
-//
-// Pop logs:
-// 0) Use exsiting LogQueue instance.
-// 1) For single entires, LogQueue::Pop().
-// 2) For multiple entries, LogQueue::PopMultiple().
-namespace pw::log_rpc {
-namespace {
-constexpr size_t kLogEntryMaxSize = 100;
-}  // namespace
-
-using LogEntriesBuffer = ByteSpan;
-
-struct LogEntries {
-  // A buffer containing an encoded protobuf of type pw.log.LogEntries.
-  ConstByteSpan entries;
-  size_t entry_count;
-};
-
-class LogQueue {
- public:
-  // Constructs a LogQueue. Callers can optionally supply a maximum log entry
-  // size, which limits the size of messages that can be pushed into this log
-  // queue. When such an entry arrives, the queue increments its drop counter.
-  // Calls to Pop and PopMultiple should be provided a buffer of at least the
-  // configured max size.
-  LogQueue(ByteSpan log_buffer,
-           ByteSpan encode_buffer,
-           size_t max_log_entry_size = kLogEntryMaxSize)
-      : pop_status_for_test_(Status::Ok()),
-        max_log_entry_size_(max_log_entry_size),
-        encode_buffer_(encode_buffer),
-        ring_buffer_(true) {
-    ring_buffer_.SetBuffer(log_buffer);
-  }
-
-  LogQueue(const LogQueue&) = delete;
-  LogQueue& operator=(const LogQueue&) = delete;
-  LogQueue(LogQueue&&) = delete;
-  LogQueue& operator=(LogQueue&&) = delete;
-
-  // Construct a LogEntry proto message and push it into the ring buffer.
-  // Returns:
-  //
-  //  OK - success.
-  //  FAILED_PRECONDITION - Failed when encoding the proto message.
-  //  RESOURCE_EXHAUSTED - Not enough space in the buffer to write the entry.
-  Status PushTokenizedMessage(ConstByteSpan message,
-                              uint32_t flags,
-                              uint32_t level,
-                              uint32_t line,
-                              uint32_t thread,
-                              int64_t timestamp);
-
-  // Pop the oldest LogEntry from the queue into the provided buffer.
-  // On success, the size is the length of the entry, on failure, the size is 0.
-  // Returns:
-  //
-  // For now, don't support batching. This will always use a single absolute
-  // timestamp, and not use delta encoding.
-  //
-  //  OK - success.
-  //  OUT_OF_RANGE - No entries in queue to read.
-  //  RESOURCE_EXHAUSTED - Destination data std::span was smaller number of
-  //  bytes than the data size of the data chunk being read.  Available
-  //  destination bytes were filled, remaining bytes of the data chunk were
-  //  ignored.
-  Result<LogEntries> Pop(LogEntriesBuffer entry_buffer);
-
-  // Pop entries from the queue into the provided buffer. The provided buffer is
-  // filled until there is insufficient space for the next log entry.
-  // Returns:
-  //
-  // LogEntries - contains an encoded protobuf byte span of pw.log.LogEntries.
-  LogEntries PopMultiple(LogEntriesBuffer entries_buffer);
-
- protected:
-  friend class LogQueueTester;
-  // For testing, status to return on calls to Pop.
-  Status pop_status_for_test_;
-
- private:
-  const size_t max_log_entry_size_;
-  size_t dropped_entries_;
-  int64_t latest_dropped_timestamp_;
-
-  ByteSpan encode_buffer_;
-  pw::ring_buffer::PrefixedEntryRingBuffer ring_buffer_{true};
-};
-
-// LogQueueWithEncodeBuffer is a LogQueue where the internal encode buffer is
-// created and managed by this class.
-template <size_t kEncodeBufferSize>
-class LogQueueWithEncodeBuffer : public LogQueue {
- public:
-  LogQueueWithEncodeBuffer(ByteSpan log_buffer)
-      : LogQueue(log_buffer, encode_buffer_) {}
-
- private:
-  std::byte encode_buffer_[kEncodeBufferSize];
-};
-
-}  // namespace pw::log_rpc
diff --git a/pw_log_rpc/public/pw_log_rpc/logs_rpc.h b/pw_log_rpc/public/pw_log_rpc/logs_rpc.h
index 2bd354f..0e3d0fd 100644
--- a/pw_log_rpc/public/pw_log_rpc/logs_rpc.h
+++ b/pw_log_rpc/public/pw_log_rpc/logs_rpc.h
@@ -15,8 +15,8 @@
 #pragma once
 
 #include "pw_log/log.h"
+#include "pw_log_multisink/log_queue.h"
 #include "pw_log_proto/log.raw_rpc.pb.h"
-#include "pw_log_rpc/log_queue.h"
 
 namespace pw::log_rpc {
 
diff --git a/pw_log_sink/BUILD b/pw_log_sink/BUILD
new file mode 100644
index 0000000..19306ab
--- /dev/null
+++ b/pw_log_sink/BUILD
@@ -0,0 +1,53 @@
+# Copyright 2021 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.
+
+load(
+    "//pw_build:pigweed.bzl",
+    "pw_cc_library",
+    "pw_cc_test",
+)
+
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])  # Apache License 2.0
+
+pw_cc_library(
+    name = "pw_log_sink",
+    srcs = [ "log_sink.cc" ],
+    includes = [ "public" ],
+    deps = [
+        "//pw_bytes",
+        "//pw_log:facade",
+        "//pw_multisink",
+        "//pw_preprocessor",
+        "//pw_status",
+        "//pw_sync:interrupt_spin_lock",
+    ],
+    hdrs = [
+        "public/pw_log_sink/log_sink.h",
+        "public/pw_log_sink/multisink_adapter.h",
+        "public/pw_log_sink/sink.h",
+        "public_overrides/pw_log_backend/log_backend.h",
+    ]
+)
+
+pw_cc_test(
+    name = "pw_log_sink_test",
+    srcs = [ "log_sink_test.cc" ],
+    deps = [
+        ":pw_log_sink",
+        "//pw_log:protos.pwpb",
+        "//pw_unit_test",
+    ]
+)
diff --git a/pw_log_sink/BUILD.gn b/pw_log_sink/BUILD.gn
new file mode 100644
index 0000000..d7c6485
--- /dev/null
+++ b/pw_log_sink/BUILD.gn
@@ -0,0 +1,70 @@
+# Copyright 2021 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/target_types.gni")
+import("$dir_pw_docgen/docs.gni")
+import("$dir_pw_unit_test/test.gni")
+
+config("default_config") {
+  include_dirs = [ "public" ]
+  visibility = [ ":*" ]
+}
+
+config("backend_config") {
+  include_dirs = [ "public_overrides" ]
+}
+
+pw_source_set("pw_log_sink") {
+  public_configs = [
+    ":backend_config",
+    ":default_config",
+  ]
+  public = [
+    "public/pw_log_sink/log_sink.h",
+    "public/pw_log_sink/multisink_adapter.h",
+    "public/pw_log_sink/sink.h",
+    "public_overrides/pw_log_backend/log_backend.h",
+  ]
+  sources = [ "log_sink.cc" ]
+  public_deps = [
+    "$dir_pw_bytes",
+    "$dir_pw_log:facade",
+    "$dir_pw_multisink",
+    "$dir_pw_preprocessor",
+    "$dir_pw_status",
+  ]
+  deps = [
+    "$dir_pw_log:protos.pwpb",
+    "$dir_pw_string",
+    "$dir_pw_sync:interrupt_spin_lock",
+  ]
+}
+
+pw_doc_group("docs") {
+  sources = [ "docs.rst" ]
+}
+
+pw_test("pw_log_sink_test") {
+  sources = [ "log_sink_test.cc" ]
+  deps = [
+    ":pw_log_sink",
+    "$dir_pw_log:protos.pwpb",
+  ]
+}
+
+pw_test_group("tests") {
+  tests = [ ":pw_log_sink_test" ]
+}
diff --git a/pw_log_sink/docs.rst b/pw_log_sink/docs.rst
new file mode 100644
index 0000000..0b645b3
--- /dev/null
+++ b/pw_log_sink/docs.rst
@@ -0,0 +1,7 @@
+.. _module-pw_log_sink:
+
+-----------
+pw_log_sink
+-----------
+This is a RPC-based logging backend for Pigweed. It is not ready for use, and
+is under construction.
diff --git a/pw_log_sink/log_sink.cc b/pw_log_sink/log_sink.cc
new file mode 100644
index 0000000..774c390
--- /dev/null
+++ b/pw_log_sink/log_sink.cc
@@ -0,0 +1,137 @@
+// Copyright 2021 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.
+
+#include "pw_log_sink/log_sink.h"
+
+#include <atomic>
+#include <cstring>
+#include <mutex>
+
+#include "pw_log/levels.h"
+#include "pw_log_proto/log.pwpb.h"
+#include "pw_protobuf/wire_format.h"
+#include "pw_status/try.h"
+#include "pw_string/string_builder.h"
+#include "pw_sync/interrupt_spin_lock.h"
+
+namespace pw::log_sink {
+namespace {
+// TODO: Make buffer sizes configurable.
+constexpr size_t kMaxMessageStringSize = 32;
+constexpr size_t kEncodeBufferSize = 128;
+
+size_t drop_count = 0;
+
+// The sink list and its corresponding lock are Meyer's singletons, to ensure
+// they are constructed before use. This enables us to use logging before C++
+// global construction has completed.
+IntrusiveList<Sink>& sink_list() {
+  static IntrusiveList<Sink> sink_list;
+  return sink_list;
+}
+
+pw::sync::InterruptSpinLock& sink_list_lock() {
+  // TODO(pwbug/304): Make lock selection configurable, some applications may
+  // not be able to tolerate interrupt jitter and may prefer a pw::sync::Mutex.
+  static pw::sync::InterruptSpinLock sink_list_lock;
+  return sink_list_lock;
+}
+
+}  // namespace
+
+// This is a fully loaded, inefficient-at-the-callsite, log implementation.
+extern "C" void pw_LogSink_Log(int level,
+                               unsigned int flags,
+                               const char* /* module_name */,
+                               const char* /* file_name */,
+                               int line_number,
+                               const char* /* function_name */,
+                               const char* message,
+                               ...) {
+  // Encode message to the LogEntry protobuf.
+  std::byte encode_buffer[kEncodeBufferSize];
+  pw::protobuf::NestedEncoder nested_encoder(encode_buffer);
+  pw::log::LogEntry::Encoder encoder(&nested_encoder);
+
+  encoder.WriteLineLevel(
+      (level & PW_LOG_LEVEL_BITMASK) |
+      ((line_number << PW_LOG_LEVEL_BITWIDTH) & ~PW_LOG_LEVEL_BITMASK));
+  encoder.WriteFlags(flags);
+
+  // TODO(pwbug/301): Insert reasonable values for thread and timestamp.
+  encoder.WriteTimestamp(0);
+
+  // Accumulate the log message in this buffer, then output it.
+  pw::StringBuffer<kMaxMessageStringSize> buffer;
+  va_list args;
+
+  va_start(args, message);
+  buffer.FormatVaList(message, args);
+  va_end(args);
+  encoder.WriteMessageString(buffer.c_str());
+  encoder.WriteThreadString("");
+
+  ConstByteSpan log_entry;
+  Status status = nested_encoder.Encode(&log_entry);
+  bool is_entry_valid = buffer.status().ok() && status.ok();
+
+  // TODO(pwbug/305): Consider using a shared buffer between users. For now,
+  // only lock after completing the encoding.
+  {
+    const std::lock_guard<pw::sync::InterruptSpinLock> lock(sink_list_lock());
+
+    // If no sinks are configured, ignore the message. When sinks are attached,
+    // they will receive this drop count to indicate logs drop to early boot.
+    // The drop count is cleared after it is sent to a sink, so sinks attached
+    // later will not receive drop counts from early boot.
+    if (sink_list().size() == 0) {
+      drop_count++;
+      return;
+    }
+
+    // If an encoding failure occurs or the constructed log entry is larger
+    // than the maximum allowed size, the log is dropped.
+    if (!is_entry_valid) {
+      drop_count++;
+    }
+
+    // Push entries to all attached sinks. This is a synchronous operation, so
+    // attached sinks should avoid blocking when processing entries. If the log
+    // entry is not valid, only the drop notification is sent to the sinks.
+    for (auto& sink : sink_list()) {
+      // The drop count is always provided before sending entries, to ensure the
+      // sink processes drops in-order.
+      if (drop_count > 0) {
+        sink.HandleDropped(drop_count);
+      }
+      if (is_entry_valid) {
+        sink.HandleEntry(log_entry);
+      }
+    }
+    // All sinks have been notified of any drops.
+    drop_count = 0;
+  }
+}
+
+void AddSink(Sink& sink) {
+  const std::lock_guard lock(sink_list_lock());
+  sink_list().push_back(sink);
+}
+
+void RemoveSink(Sink& sink) {
+  const std::lock_guard lock(sink_list_lock());
+  sink_list().remove(sink);
+}
+
+}  // namespace pw::log_sink
diff --git a/pw_log_sink/log_sink_test.cc b/pw_log_sink/log_sink_test.cc
new file mode 100644
index 0000000..52f8bbe
--- /dev/null
+++ b/pw_log_sink/log_sink_test.cc
@@ -0,0 +1,157 @@
+// Copyright 2021 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.
+
+#include "pw_log_sink/log_sink.h"
+
+#include <string>
+
+#include "gtest/gtest.h"
+#include "pw_log/levels.h"
+#include "pw_log_proto/log.pwpb.h"
+#include "pw_log_sink/multisink_adapter.h"
+#include "pw_multisink/drain.h"
+#include "pw_multisink/multisink.h"
+#include "pw_protobuf/decoder.h"
+
+namespace pw::log_sink {
+namespace {
+constexpr size_t kMaxTokenizedMessageSize = 512;
+constexpr char kTokenizedMessage[] = "Test Message";
+
+std::string LogMessageToString(ConstByteSpan message) {
+  pw::protobuf::Decoder log_decoder(message);
+  std::string_view log_entry_message;
+
+  EXPECT_TRUE(log_decoder.Next().ok());  // line_level - 2
+  EXPECT_TRUE(log_decoder.Next().ok());  // flags - 3
+  EXPECT_TRUE(log_decoder.Next().ok());  // timestamp - 5
+  EXPECT_TRUE(log_decoder.Next().ok());  // message_string - 16
+  EXPECT_EQ(16U, log_decoder.FieldNumber());
+  EXPECT_TRUE(log_decoder.ReadString(&log_entry_message).ok());
+
+  return std::string(log_entry_message);
+}
+
+void LogMessageForTest(const char* message) {
+  pw_LogSink_Log(0, 0, nullptr, nullptr, 0, nullptr, "%s", message);
+}
+
+void LogInvalidMessageForTest() {
+  char long_message[kMaxTokenizedMessageSize + 1];
+  memset(long_message, 'A', sizeof(long_message));
+  long_message[kMaxTokenizedMessageSize] = '\0';
+
+  pw_LogSink_Log(0, 0, nullptr, nullptr, 0, nullptr, "%s", long_message);
+}
+
+class TestSink final : public Sink {
+ public:
+  void HandleEntry(ConstByteSpan message) final {
+    last_message_string_ = LogMessageToString(message);
+    message_count_++;
+  }
+
+  void HandleDropped(uint32_t count) final { drop_count_ += count; }
+
+  void VerifyMessage(std::string tokenized_message) {
+    EXPECT_EQ(tokenized_message, last_message_string_);
+  }
+
+  uint32_t GetMessageCount() { return message_count_; }
+  uint32_t GetDropCount() { return drop_count_; }
+
+ private:
+  std::string last_message_string_;
+  uint32_t message_count_ = 0;
+  uint32_t drop_count_ = 0;
+};
+
+}  // namespace
+
+TEST(LogSink, NoSink) {
+  // Confirm that the log function does not crash when no sink is present.
+  LogMessageForTest(kTokenizedMessage);
+}
+
+TEST(LogSink, SingleSink) {
+  TestSink test_sink;
+
+  AddSink(test_sink);
+  LogMessageForTest(kTokenizedMessage);
+  test_sink.VerifyMessage(kTokenizedMessage);
+  RemoveSink(test_sink);
+}
+
+TEST(LogSink, MultipleSink) {
+  TestSink test_sink_io;
+  TestSink test_sink_bt;
+
+  AddSink(test_sink_io);
+  AddSink(test_sink_bt);
+  LogMessageForTest(kTokenizedMessage);
+  test_sink_io.VerifyMessage(kTokenizedMessage);
+  test_sink_bt.VerifyMessage(kTokenizedMessage);
+
+  RemoveSink(test_sink_io);
+  LogMessageForTest(kTokenizedMessage);
+  test_sink_io.VerifyMessage(kTokenizedMessage);
+  EXPECT_EQ(test_sink_io.GetMessageCount(), 1U);
+  EXPECT_EQ(test_sink_bt.GetMessageCount(), 2U);
+
+  RemoveSink(test_sink_bt);
+}
+
+TEST(LogSink, DroppedEntries) {
+  TestSink test_sink;
+
+  LogMessageForTest(kTokenizedMessage);
+
+  AddSink(test_sink);
+  LogMessageForTest(kTokenizedMessage);
+  EXPECT_EQ(test_sink.GetMessageCount(), 1U);
+  EXPECT_EQ(test_sink.GetDropCount(), 1U);
+
+  LogInvalidMessageForTest();
+  EXPECT_EQ(test_sink.GetMessageCount(), 1U);
+  EXPECT_EQ(test_sink.GetDropCount(), 2U);
+
+  LogMessageForTest(kTokenizedMessage);
+  EXPECT_EQ(test_sink.GetMessageCount(), 2U);
+  EXPECT_EQ(test_sink.GetDropCount(), 2U);
+
+  RemoveSink(test_sink);
+}
+
+TEST(LogSink, MultiSinkAdapter) {
+  constexpr size_t kMultiSinkBufferSize = 1024;
+  std::byte buffer[kMultiSinkBufferSize];
+  std::byte entry_buffer[kMultiSinkBufferSize];
+  pw::multisink::MultiSink multisink(buffer);
+  pw::multisink::Drain drain;
+  MultiSinkAdapter multisink_adapter(multisink);
+
+  multisink.AttachDrain(drain);
+  LogMessageForTest(kTokenizedMessage);
+
+  AddSink(multisink_adapter);
+  LogMessageForTest(kTokenizedMessage);
+
+  uint32_t drop_count = 0;
+  Result<ConstByteSpan> entry = drain.GetEntry(entry_buffer, drop_count);
+  ASSERT_TRUE(entry.ok());
+  EXPECT_EQ(LogMessageToString(entry.value()), kTokenizedMessage);
+  EXPECT_EQ(drop_count, 1U);
+}
+
+}  // namespace pw::log_sink
diff --git a/pw_log_sink/public/pw_log_sink/log_sink.h b/pw_log_sink/public/pw_log_sink/log_sink.h
new file mode 100644
index 0000000..648f5c2
--- /dev/null
+++ b/pw_log_sink/public/pw_log_sink/log_sink.h
@@ -0,0 +1,70 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "pw_preprocessor/arguments.h"
+#include "pw_preprocessor/compiler.h"
+#include "pw_preprocessor/util.h"
+
+PW_EXTERN_C_START
+
+// Log a message with the listed attributes.
+void pw_LogSink_Log(int level,
+                    unsigned int flags,
+                    const char* module_name,
+                    const char* file_name,
+                    int line_number,
+                    const char* function_name,
+                    const char* message,
+                    ...) PW_PRINTF_FORMAT(7, 8);
+
+PW_EXTERN_C_END
+
+// Log a message with many attributes included.
+//
+// This is the log macro frontend that funnels everything into the C handler
+// above, pw_LogSink_Log(). It's not efficient at the callsite, since it passes
+// many arguments. Additionally, the use of the __FUNC__ macro adds a static
+// const char[] variable inside functions with a log.
+//
+// TODO(pwbug/87): Reconsider the naming of this module when more is in place.
+#define PW_HANDLE_LOG(level, flags, message, ...)       \
+  do {                                                  \
+    pw_LogSink_Log((level),                             \
+                   (flags),                             \
+                   PW_LOG_MODULE_NAME,                  \
+                   __FILE__,                            \
+                   __LINE__,                            \
+                   __func__,                            \
+                   message PW_COMMA_ARGS(__VA_ARGS__)); \
+  } while (0)
+
+#ifdef __cplusplus
+
+#include <string_view>
+
+#include "pw_bytes/span.h"
+#include "pw_log_sink/sink.h"
+
+namespace pw::log_sink {
+
+// Adds sink interface to list of sinks to push messages to.
+void AddSink(Sink& sink);
+
+// Removes sink interface from list of sinks to push messages to.
+void RemoveSink(Sink& sink);
+
+}  // namespace pw::log_sink
+
+#endif  // __cplusplus
diff --git a/pw_log_sink/public/pw_log_sink/multisink_adapter.h b/pw_log_sink/public/pw_log_sink/multisink_adapter.h
new file mode 100644
index 0000000..af45cb2
--- /dev/null
+++ b/pw_log_sink/public/pw_log_sink/multisink_adapter.h
@@ -0,0 +1,45 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "pw_log_sink/sink.h"
+#include "pw_multisink/multisink.h"
+
+namespace pw {
+namespace log_sink {
+
+class MultiSinkAdapter final : public Sink {
+ public:
+  MultiSinkAdapter(multisink::MultiSink& multisink) : multisink_(multisink) {}
+
+  // Write an entry to the sink.
+  void HandleEntry(ConstByteSpan entry) final {
+    // Best-effort attempt to send data to the sink, so status is ignored. The
+    // multisink handles failures internally and propagates them to its drains.
+    multisink_.HandleEntry(entry);
+  }
+
+  // Notifies the sink of messages dropped before ingress. The writer may use
+  // this to signal to sinks that an entry (or entries) was lost before sending
+  // to the sink (e.g. the log sink failed to encode the message).
+  void HandleDropped(uint32_t drop_count) final {
+    multisink_.HandleDropped(drop_count);
+  }
+
+ private:
+  multisink::MultiSink& multisink_;
+};
+
+}  // namespace log_sink
+}  // namespace pw
diff --git a/pw_log_sink/public/pw_log_sink/sink.h b/pw_log_sink/public/pw_log_sink/sink.h
new file mode 100644
index 0000000..a85b5b5
--- /dev/null
+++ b/pw_log_sink/public/pw_log_sink/sink.h
@@ -0,0 +1,40 @@
+// Copyright 2021 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.
+#pragma once
+
+// TODO(pwbug.dev/351): This file is very similar to pw_log_multisink/sink.h,
+// which will be deprecated in a later change.
+
+#include "pw_bytes/span.h"
+#include "pw_containers/intrusive_list.h"
+
+namespace pw {
+namespace log_sink {
+
+class Sink : public IntrusiveList<Sink>::Item {
+ public:
+  virtual ~Sink() = default;
+
+  // Write an entry to the sink. This is a best-effort attempt to send data to
+  // the sink, so failures are ignored.
+  virtual void HandleEntry(ConstByteSpan entry) = 0;
+
+  // Notifies the sink of messages dropped before ingress. The writer may use
+  // this to signal to sinks that an entry (or entries) was lost before sending
+  // to the sink (e.g. the log sink failed to encode the message).
+  virtual void HandleDropped(uint32_t drop_count) = 0;
+};
+
+}  // namespace log_sink
+}  // namespace pw
diff --git a/pw_log_sink/public_overrides/pw_log_backend/log_backend.h b/pw_log_sink/public_overrides/pw_log_backend/log_backend.h
new file mode 100644
index 0000000..c696df7
--- /dev/null
+++ b/pw_log_sink/public_overrides/pw_log_backend/log_backend.h
@@ -0,0 +1,20 @@
+// Copyright 2021 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.
+
+// This override header merely points to the true backend, in this case the
+// basic one. The reason to redirect is to permit the use of multiple backends
+// (though only pw_log/log.h can only point to 1 backend).
+#pragma once
+
+#include "pw_log_sink/log_sink.h"
diff --git a/pw_log_tokenized/BUILD b/pw_log_tokenized/BUILD
index c1f5d4a..41b0747 100644
--- a/pw_log_tokenized/BUILD
+++ b/pw_log_tokenized/BUILD
@@ -25,6 +25,7 @@
 pw_cc_library(
     name = "headers",
     hdrs = [
+        "public/pw_log_tokenized/config.h",
         "public/pw_log_tokenized/log_tokenized.h",
         "public_overrides/pw_log_backend/log_backend.h",
     ],
@@ -51,7 +52,7 @@
     hdrs = ["public/pw_log_tokenized/base64_over_hdlc.h"],
     includes = ["public"],
     deps = [
-        "//pw_hdlc_lite:encoder",
+        "//pw_hdlc:encoder",
         "//pw_tokenizer:base64",
         "//pw_tokenizer:global_handler_with_payload.facade",
     ],
diff --git a/pw_log_tokenized/BUILD.gn b/pw_log_tokenized/BUILD.gn
index d8df960..d51a510 100644
--- a/pw_log_tokenized/BUILD.gn
+++ b/pw_log_tokenized/BUILD.gn
@@ -14,11 +14,20 @@
 
 import("//build_overrides/pigweed.gni")
 
+import("$dir_pw_build/module_config.gni")
 import("$dir_pw_build/target_types.gni")
 import("$dir_pw_docgen/docs.gni")
+import("$dir_pw_log/backend.gni")
 import("$dir_pw_tokenizer/backend.gni")
 import("$dir_pw_unit_test/test.gni")
 
+declare_args() {
+  # The build target that overrides the default configuration options for this
+  # module. This should point to a source set that provides defines through a
+  # public config (which may -include a file or add defines directly).
+  pw_log_tokenized_CONFIG = pw_build_DEFAULT_MODULE_CONFIG
+}
+
 config("public_includes") {
   include_dirs = [ "public" ]
   visibility = [ ":*" ]
@@ -47,8 +56,12 @@
     "$dir_pw_log:facade",
     "$dir_pw_tokenizer:global_handler_with_payload.facade",
     dir_pw_preprocessor,
+    pw_log_tokenized_CONFIG,
   ]
-  public = [ "public/pw_log_tokenized/log_tokenized.h" ]
+  public = [
+    "public/pw_log_tokenized/config.h",
+    "public/pw_log_tokenized/log_tokenized.h",
+  ]
 }
 
 # This target provides the backend for pw_log.
@@ -66,27 +79,23 @@
   public = [ "public/pw_log_tokenized/base64_over_hdlc.h" ]
   sources = [ "base64_over_hdlc.cc" ]
   deps = [
-    "$dir_pw_hdlc_lite:encoder",
+    "$dir_pw_hdlc:encoder",
+    "$dir_pw_stream:sys_io_stream",
     "$dir_pw_tokenizer:base64",
     "$dir_pw_tokenizer:global_handler_with_payload.facade",
   ]
 }
 
 pw_test_group("tests") {
-  tests = [ ":test" ]
+  tests = [ ":log_tokenized_test" ]
 }
 
-pw_test("test") {
+pw_test("log_tokenized_test") {
   sources = [ "test.cc" ]
-  deps = [
-    ":log_backend",
-    ":pw_log_tokenized",
-  ]
-
-  # TODO(hepler): Switch this to a facade test when available.
-  enable_if = pw_tokenizer_GLOBAL_HANDLER_WITH_PAYLOAD_BACKEND == ""
+  deps = [ ":pw_log_tokenized" ]
 }
 
 pw_doc_group("docs") {
   sources = [ "docs.rst" ]
+  other_deps = [ "py" ]
 }
diff --git a/pw_log_tokenized/base64_over_hdlc.cc b/pw_log_tokenized/base64_over_hdlc.cc
index b8c7bdf..f7dc0e0 100644
--- a/pw_log_tokenized/base64_over_hdlc.cc
+++ b/pw_log_tokenized/base64_over_hdlc.cc
@@ -19,8 +19,8 @@
 
 #include <span>
 
-#include "pw_hdlc_lite/encoder.h"
-#include "pw_hdlc_lite/sys_io_stream.h"
+#include "pw_hdlc/encoder.h"
+#include "pw_stream/sys_io_stream.h"
 #include "pw_tokenizer/base64.h"
 #include "pw_tokenizer/tokenize_to_global_handler_with_payload.h"
 
@@ -43,10 +43,9 @@
   base64_buffer[base64_bytes] = '\0';
 
   // HDLC-encode the Base64 string via a SysIoWriter.
-  hdlc_lite::WriteInformationFrame(
-      PW_LOG_TOKENIZED_BASE64_LOG_HDLC_ADDRESS,
-      std::as_bytes(std::span(base64_buffer, base64_bytes)),
-      writer);
+  hdlc::WriteUIFrame(PW_LOG_TOKENIZED_BASE64_LOG_HDLC_ADDRESS,
+                     std::as_bytes(std::span(base64_buffer, base64_bytes)),
+                     writer);
 }
 
 }  // namespace pw::log_tokenized
diff --git a/pw_log_tokenized/docs.rst b/pw_log_tokenized/docs.rst
index 1c30ee7..3c100ed 100644
--- a/pw_log_tokenized/docs.rst
+++ b/pw_log_tokenized/docs.rst
@@ -3,11 +3,18 @@
 ----------------
 pw_log_tokenized
 ----------------
-``pw_log_tokenized`` is a ``pw_log`` backend that tokenizes log messages using
-the ``pw_tokenizer`` module. Log messages are tokenized and passed to the
-``pw_tokenizer_HandleEncodedMessageWithPayload`` function. For maximum
-efficiency, the log level, 16-bit tokenized module name, and flags bits are
-passed through the payload argument.
+The ``pw_log_tokenized`` module contains utilities for tokenized logging. It
+connects ``pw_log`` to ``pw_tokenizer``.
+
+C++ backend
+===========
+``pw_log_tokenized`` provides a backend for ``pw_log`` that tokenizes log
+messages with the ``pw_tokenizer`` module. By default, log messages are
+tokenized with the ``PW_TOKENIZE_TO_GLOBAL_HANDLER_WITH_PAYLOAD`` macro.
+The log level, 16-bit tokenized module name, and flags bits are passed through
+the payload argument. The macro eventually passes logs to the
+``pw_tokenizer_HandleEncodedMessageWithPayload`` function, which must be
+implemented by the application.
 
 Example implementation:
 
@@ -30,7 +37,19 @@
      }
    }
 
-See the documentation for ``pw_tokenizer`` for further details.
+See the documentation for :ref:`module-pw_tokenizer` for further details.
+
+Using a custom macro
+--------------------
+Applications may use their own macro instead of
+``PW_TOKENIZE_TO_GLOBAL_HANDLER_WITH_PAYLOAD`` by setting the
+``PW_LOG_TOKENIZED_ENCODE_MESSAGE`` config macro. This macro should take
+arguments equivalent to ``PW_TOKENIZE_TO_GLOBAL_HANDLER_WITH_PAYLOAD``:
+
+  .. c:function:: PW_LOG_TOKENIZED_ENCODE_MESSAGE(pw_tokenizer_Payload log_metadata, const char* message, ...)
+
+For instructions on how to implement a custom tokenization macro, see
+:ref:`module-pw_tokenizer-custom-macro`.
 
 Build targets
 -------------
@@ -41,5 +60,12 @@
 the ``pw_tokenizer:global_handler_with_payload`` facade, which must be
 implemented by the user of ``pw_log_tokenized``.
 
-.. note::
-  The documentation for this module is currently incomplete.
+Python package
+==============
+``pw_log_tokenized`` includes a Python package for decoding tokenized logs.
+
+pw_log_tokenized
+----------------
+.. automodule:: pw_log_tokenized
+  :members:
+  :undoc-members:
diff --git a/pw_log_tokenized/public/pw_log_tokenized/config.h b/pw_log_tokenized/public/pw_log_tokenized/config.h
new file mode 100644
index 0000000..9c92d9f
--- /dev/null
+++ b/pw_log_tokenized/public/pw_log_tokenized/config.h
@@ -0,0 +1,58 @@
+// Copyright 2021 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.
+#pragma once
+
+#include <assert.h>
+
+#include "pw_log/options.h"
+#include "pw_preprocessor/concat.h"
+
+// This macro takes the PW_LOG format string and optionally transforms it. By
+// default, the PW_LOG_MODULE_NAME is prepended to the string if present.
+#ifndef PW_LOG_TOKENIZED_FORMAT_STRING
+
+#define PW_LOG_TOKENIZED_FORMAT_STRING(string) \
+  PW_CONCAT(PW_LOG_TOKENIZED_FMT_, PW_LOG_MODULE_NAME_DEFINED)(string)
+
+#define PW_LOG_TOKENIZED_FMT_0(string) string
+#define PW_LOG_TOKENIZED_FMT_1(string) PW_LOG_MODULE_NAME " " string
+
+#endif  // PW_LOG_TOKENIZED_FORMAT_STRING
+
+// The log level, module token, and flag bits are packed into the tokenizer's
+// payload argument, which is typically 32 bits. These macros specify the number
+// of bits to use for each field.
+#ifndef PW_LOG_TOKENIZED_LEVEL_BITS
+#define PW_LOG_TOKENIZED_LEVEL_BITS 6
+#endif  // PW_LOG_TOKENIZED_LEVEL_BITS
+
+#ifndef PW_LOG_TOKENIZED_MODULE_BITS
+#define PW_LOG_TOKENIZED_MODULE_BITS 16
+#endif  // PW_LOG_TOKENIZED_MODULE_BITS
+
+#ifndef PW_LOG_TOKENIZED_FLAG_BITS
+#define PW_LOG_TOKENIZED_FLAG_BITS 10
+#endif  // PW_LOG_TOKENIZED_FLAG_BITS
+
+static_assert((PW_LOG_TOKENIZED_LEVEL_BITS + PW_LOG_TOKENIZED_MODULE_BITS +
+               PW_LOG_TOKENIZED_FLAG_BITS) == 32,
+              "Log metadata must fit in a 32-bit integer");
+
+// The macro to use to tokenize the log and its arguments. Defaults to
+// PW_TOKENIZE_TO_GLOBAL_HANDLER_WITH_PAYLOAD. Projects may define their own
+// version of this macro that uses a different underlying function, if desired.
+#ifndef PW_LOG_TOKENIZED_ENCODE_MESSAGE
+#define PW_LOG_TOKENIZED_ENCODE_MESSAGE \
+  PW_TOKENIZE_TO_GLOBAL_HANDLER_WITH_PAYLOAD
+#endif  // PW_LOG_TOKENIZED_ENCODE_MESSAGE
diff --git a/pw_log_tokenized/public/pw_log_tokenized/log_tokenized.h b/pw_log_tokenized/public/pw_log_tokenized/log_tokenized.h
index 1589e88..34acb74 100644
--- a/pw_log_tokenized/public/pw_log_tokenized/log_tokenized.h
+++ b/pw_log_tokenized/public/pw_log_tokenized/log_tokenized.h
@@ -1,4 +1,4 @@
-// Copyright 2020 The Pigweed Authors
+// Copyright 2021 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
@@ -13,17 +13,22 @@
 // the License.
 #pragma once
 
-#include <assert.h>
 #include <stdint.h>
 
-#include "pw_log/options.h"
-#include "pw_preprocessor/concat.h"
+#include "pw_log_tokenized/config.h"
 #include "pw_tokenizer/tokenize_to_global_handler_with_payload.h"
 
-// This macro implements PW_LOG, using
-// PW_TOKENIZE_TO_GLOBAL_HANDLER_WITH_PAYLOAD. The log level, module token, and
+// This macro implements PW_LOG using
+// PW_TOKENIZE_TO_GLOBAL_HANDLER_WITH_PAYLOAD or an equivalent alternate macro
+// provided by PW_LOG_TOKENIZED_ENCODE_MESSAGE. The log level, module token, and
 // flags are packed into the payload argument.
 //
+// Two strings are tokenized in this macro:
+//
+//   - The log format string, tokenized in the default tokenizer domain.
+//   - PW_LOG_MODULE_NAME, masked to 16 bits and tokenized in the
+//     "pw_log_module_names" tokenizer domain.
+//
 // To use this macro, implement pw_tokenizer_HandleEncodedMessageWithPayload,
 // which is defined in pw_tokenizer/tokenize.h. The log metadata can be accessed
 // using pw::log_tokenized::Metadata. For example:
@@ -37,81 +42,59 @@
 //     }
 //   }
 //
-#define PW_LOG_TOKENIZED_TO_GLOBAL_HANDLER_WITH_PAYLOAD(                       \
-    level, flags, message, ...)                                                \
-  do {                                                                         \
-    _PW_TOKENIZER_CONST uintptr_t _pw_log_module_token =                       \
-        PW_TOKENIZE_STRING_DOMAIN("log_module_names", PW_LOG_MODULE_NAME);     \
-    PW_TOKENIZE_TO_GLOBAL_HANDLER_WITH_PAYLOAD(                                \
-        ((uintptr_t)(level) |                                                  \
-         ((_pw_log_module_token &                                              \
-           ((1u << _PW_LOG_TOKENIZED_MODULE_BITS) - 1u))                       \
-          << _PW_LOG_TOKENIZED_LEVEL_BITS) |                                   \
-         ((uintptr_t)(flags)                                                   \
-          << (_PW_LOG_TOKENIZED_LEVEL_BITS + _PW_LOG_TOKENIZED_MODULE_BITS))), \
-        PW_LOG_TOKENIZED_FORMAT_STRING(message),                               \
-        __VA_ARGS__);                                                          \
+#define PW_LOG_TOKENIZED_TO_GLOBAL_HANDLER_WITH_PAYLOAD(                     \
+    level, flags, message, ...)                                              \
+  do {                                                                       \
+    _PW_TOKENIZER_CONST uintptr_t _pw_log_module_token =                     \
+        PW_TOKENIZE_STRING_MASK("pw_log_module_names",                       \
+                                ((1u << PW_LOG_TOKENIZED_MODULE_BITS) - 1u), \
+                                PW_LOG_MODULE_NAME);                         \
+    PW_LOG_TOKENIZED_ENCODE_MESSAGE(                                         \
+        ((uintptr_t)(level) |                                                \
+         (_pw_log_module_token << PW_LOG_TOKENIZED_LEVEL_BITS) |             \
+         ((uintptr_t)(flags)                                                 \
+          << (PW_LOG_TOKENIZED_LEVEL_BITS + PW_LOG_TOKENIZED_MODULE_BITS))), \
+        PW_LOG_TOKENIZED_FORMAT_STRING(message),                             \
+        __VA_ARGS__);                                                        \
   } while (0)
 
-// By default, log format strings include the PW_LOG_MODULE_NAME, if defined.
-#ifndef PW_LOG_TOKENIZED_FORMAT_STRING
-
-#define PW_LOG_TOKENIZED_FORMAT_STRING(string) \
-  PW_CONCAT(_PW_LOG_TOKENIZED_FMT_, PW_LOG_MODULE_NAME_DEFINED)(string)
-
-#define _PW_LOG_TOKENIZED_FMT_0(string) string
-#define _PW_LOG_TOKENIZED_FMT_1(string) PW_LOG_MODULE_NAME " " string
-
-#endif  // PW_LOG_TOKENIZED_FORMAT_STRING
-
-// The log level, module token, and flag bits are packed into the tokenizer's
-// payload argument, which is typically 32 bits. These macros specify the number
-// of bits to use for each field.
-#define _PW_LOG_TOKENIZED_LEVEL_BITS 6
-#define _PW_LOG_TOKENIZED_MODULE_BITS 16
-#define _PW_LOG_TOKENIZED_FLAG_BITS 10
-
 #ifdef __cplusplus
 
-static_assert((_PW_LOG_TOKENIZED_LEVEL_BITS + _PW_LOG_TOKENIZED_MODULE_BITS +
-               _PW_LOG_TOKENIZED_FLAG_BITS) == 32,
-              "Log metadata must fit in a 32-bit integer");
-
 namespace pw {
 namespace log_tokenized {
 namespace internal {
 
 // This class, which is aliased to pw::log_tokenized::Metadata below, is used to
 // access the log metadata packed into the tokenizer's payload argument.
-template <unsigned level_bits,
-          unsigned module_bits,
-          unsigned flag_bits,
+template <unsigned kLevelBits,
+          unsigned kModuleBits,
+          unsigned kFlagBits,
           typename T = uintptr_t>
 class GenericMetadata {
  public:
   template <T log_level, T module, T flags>
   static constexpr GenericMetadata Set() {
-    static_assert(log_level < (1 << level_bits), "The level is too large!");
-    static_assert(module < (1 << module_bits), "The module is too large!");
-    static_assert(flags < (1 << flag_bits), "The flags are too large!");
+    static_assert(log_level < (1 << kLevelBits), "The level is too large!");
+    static_assert(module < (1 << kModuleBits), "The module is too large!");
+    static_assert(flags < (1 << kFlagBits), "The flags are too large!");
 
-    return GenericMetadata(log_level | (module << level_bits) |
-                           (flags << (module_bits + level_bits)));
+    return GenericMetadata(log_level | (module << kLevelBits) |
+                           (flags << (kModuleBits + kLevelBits)));
   }
 
   constexpr GenericMetadata(T value) : bits_(value) {}
 
   // The log level of this message.
-  constexpr T level() const { return bits_ & Mask<level_bits>(); }
+  constexpr T level() const { return bits_ & Mask<kLevelBits>(); }
 
   // The 16 bit tokenized version of the module name (PW_LOG_MODULE_NAME).
   constexpr T module() const {
-    return (bits_ >> level_bits) & Mask<module_bits>();
+    return (bits_ >> kLevelBits) & Mask<kModuleBits>();
   }
 
   // The flags provided to the log call.
   constexpr T flags() const {
-    return (bits_ >> (level_bits + module_bits)) & Mask<flag_bits>();
+    return (bits_ >> (kLevelBits + kModuleBits)) & Mask<kFlagBits>();
   }
 
  private:
@@ -122,14 +105,14 @@
 
   T bits_;
 
-  static_assert(level_bits + module_bits + flag_bits <= sizeof(bits_) * 8);
+  static_assert(kLevelBits + kModuleBits + kFlagBits <= sizeof(bits_) * 8);
 };
 
 }  // namespace internal
 
-using Metadata = internal::GenericMetadata<_PW_LOG_TOKENIZED_LEVEL_BITS,
-                                           _PW_LOG_TOKENIZED_MODULE_BITS,
-                                           _PW_LOG_TOKENIZED_FLAG_BITS>;
+using Metadata = internal::GenericMetadata<PW_LOG_TOKENIZED_LEVEL_BITS,
+                                           PW_LOG_TOKENIZED_MODULE_BITS,
+                                           PW_LOG_TOKENIZED_FLAG_BITS>;
 
 }  // namespace log_tokenized
 }  // namespace pw
diff --git a/pw_log_tokenized/public_overrides/pw_log_backend/log_backend.h b/pw_log_tokenized/public_overrides/pw_log_backend/log_backend.h
index 02361b2..7b1cac1 100644
--- a/pw_log_tokenized/public_overrides/pw_log_backend/log_backend.h
+++ b/pw_log_tokenized/public_overrides/pw_log_backend/log_backend.h
@@ -1,4 +1,4 @@
-// Copyright 2020 The Pigweed Authors
+// Copyright 2021 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
@@ -16,9 +16,10 @@
 // the PW_LOG macro as the tokenized logging macro.
 #pragma once
 
+#include "pw_log_tokenized/config.h"
 #include "pw_log_tokenized/log_tokenized.h"
 
 #define PW_HANDLE_LOG PW_LOG_TOKENIZED_TO_GLOBAL_HANDLER_WITH_PAYLOAD
 
-#define PW_LOG_LEVEL_BITS _PW_LOG_TOKENIZED_LEVEL_BITS
-#define PW_LOG_FLAG_BITS _PW_LOG_TOKENIZED_FLAG_BITS
+#define PW_LOG_LEVEL_BITS PW_LOG_TOKENIZED_LEVEL_BITS
+#define PW_LOG_FLAG_BITS PW_LOG_TOKENIZED_FLAG_BITS
diff --git a/pw_log_tokenized/py/BUILD.gn b/pw_log_tokenized/py/BUILD.gn
new file mode 100644
index 0000000..f8ebd18
--- /dev/null
+++ b/pw_log_tokenized/py/BUILD.gn
@@ -0,0 +1,23 @@
+# Copyright 2021 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_log_tokenized/__init__.py" ]
+  pylintrc = "$dir_pigweed/.pylintrc"
+}
diff --git a/pw_log_tokenized/py/pw_log_tokenized/__init__.py b/pw_log_tokenized/py/pw_log_tokenized/__init__.py
new file mode 100644
index 0000000..5c46b7b
--- /dev/null
+++ b/pw_log_tokenized/py/pw_log_tokenized/__init__.py
@@ -0,0 +1,41 @@
+# Copyright 2021 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 for working with tokenized logs."""
+
+from dataclasses import dataclass
+
+
+def _mask(value: int, start: int, count: int) -> int:
+    mask = (1 << count) - 1
+    return (value & (mask << start)) >> start
+
+
+@dataclass(frozen=True)
+class Metadata:
+    """Parses the metadata payload sent by pw_log_tokenized."""
+    _value: int
+
+    log_bits: int = 6
+    module_bits: int = 16
+    flag_bits: int = 10
+
+    def log_level(self) -> int:
+        return _mask(self._value, 0, self.log_bits)
+
+    def module_token(self) -> int:
+        return _mask(self._value, self.log_bits, self.module_bits)
+
+    def flags(self) -> int:
+        return _mask(self._value, self.log_bits + self.module_bits,
+                     self.flag_bits)
diff --git a/pw_hdlc_lite/py/pw_hdlc_lite/py.typed b/pw_log_tokenized/py/pw_log_tokenized/py.typed
similarity index 100%
copy from pw_hdlc_lite/py/pw_hdlc_lite/py.typed
copy to pw_log_tokenized/py/pw_log_tokenized/py.typed
diff --git a/pw_log_tokenized/py/setup.py b/pw_log_tokenized/py/setup.py
new file mode 100644
index 0000000..f1ca09d
--- /dev/null
+++ b/pw_log_tokenized/py/setup.py
@@ -0,0 +1,27 @@
+# Copyright 2021 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.
+"""The pw_log_tokenized package."""
+
+import setuptools  # type: ignore
+
+setuptools.setup(
+    name='pw_log_tokenized',
+    version='0.0.1',
+    author='Pigweed Authors',
+    author_email='pigweed-developers@googlegroups.com',
+    description='Tools for working with tokenized logs',
+    packages=setuptools.find_packages(),
+    package_data={'pw_log_tokenized': ['py.typed']},
+    zip_safe=False,
+)
diff --git a/pw_log_tokenized/test.cc b/pw_log_tokenized/test.cc
index 6b5f976..7a843f4 100644
--- a/pw_log_tokenized/test.cc
+++ b/pw_log_tokenized/test.cc
@@ -1,4 +1,4 @@
-// Copyright 2020 The Pigweed Authors
+// Copyright 2021 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
@@ -14,27 +14,48 @@
 
 #define PW_LOG_MODULE_NAME "This is the log module name!"
 
+// Create a fake version of the tokenization macro.
+#undef PW_LOG_TOKENIZED_ENCODE_MESSAGE
+#define PW_LOG_TOKENIZED_ENCODE_MESSAGE(payload, message, ...) \
+  CaptureTokenizerArgs(payload,                                \
+                       PW_MACRO_ARG_COUNT(__VA_ARGS__),        \
+                       message PW_COMMA_ARGS(__VA_ARGS__))
+
 #include <cstring>
 #include <string_view>
+#include <tuple>
 
 #include "gtest/gtest.h"
 #include "pw_log_tokenized/log_tokenized.h"
+#include "pw_preprocessor/arguments.h"
+#include "pw_preprocessor/compiler.h"
 
 namespace pw::log_tokenized {
 namespace {
 
-Metadata metadata(0);
-size_t encoded_data_size = 0;
+struct {
+  Metadata metadata = Metadata(0);
+  const char* format_string = "";
+  size_t arg_count = 0;
+} last_log{};
 
-extern "C" void pw_tokenizer_HandleEncodedMessageWithPayload(
-    pw_tokenizer_Payload payload, const uint8_t[], size_t size) {
-  metadata = payload;
-  encoded_data_size = size;
+void CaptureTokenizerArgs(pw_tokenizer_Payload payload,
+                          size_t arg_count,
+                          const char* message,
+                          ...) PW_PRINTF_FORMAT(3, 4);
+
+void CaptureTokenizerArgs(pw_tokenizer_Payload payload,
+                          size_t arg_count,
+                          const char* message,
+                          ...) {
+  last_log.metadata = payload;
+  last_log.format_string = message;
+  last_log.arg_count = arg_count;
 }
 
 constexpr uintptr_t kModuleToken =
     PW_TOKENIZER_STRING_TOKEN(PW_LOG_MODULE_NAME) &
-    ((1u << _PW_LOG_TOKENIZED_MODULE_BITS) - 1);
+    ((1u << PW_LOG_TOKENIZED_MODULE_BITS) - 1);
 
 constexpr Metadata test1 = Metadata::Set<0, 0, 0>();
 static_assert(test1.level() == 0);
@@ -53,26 +74,26 @@
 
 TEST(LogTokenized, LogMetadata_Zero) {
   PW_LOG_TOKENIZED_TO_GLOBAL_HANDLER_WITH_PAYLOAD(0, 0, "hello");
-  EXPECT_EQ(metadata.level(), 0u);
-  EXPECT_EQ(metadata.flags(), 0u);
-  EXPECT_EQ(metadata.module(), kModuleToken);
-  EXPECT_EQ(encoded_data_size, 4u /* token */);
+  EXPECT_EQ(last_log.metadata.level(), 0u);
+  EXPECT_EQ(last_log.metadata.flags(), 0u);
+  EXPECT_EQ(last_log.metadata.module(), kModuleToken);
+  EXPECT_EQ(last_log.arg_count, 0u);
 }
 
 TEST(LogTokenized, LogMetadata_DifferentValues) {
   PW_LOG_TOKENIZED_TO_GLOBAL_HANDLER_WITH_PAYLOAD(55, 36, "hello%s", "?");
-  EXPECT_EQ(metadata.level(), 55u);
-  EXPECT_EQ(metadata.flags(), 36u);
-  EXPECT_EQ(metadata.module(), kModuleToken);
-  EXPECT_EQ(encoded_data_size, 4u /* token */ + 2u /* encoded string */);
+  EXPECT_EQ(last_log.metadata.level(), 55u);
+  EXPECT_EQ(last_log.metadata.flags(), 36u);
+  EXPECT_EQ(last_log.metadata.module(), kModuleToken);
+  EXPECT_EQ(last_log.arg_count, 1u);
 }
 
 TEST(LogTokenized, LogMetadata_MaxValues) {
   PW_LOG_TOKENIZED_TO_GLOBAL_HANDLER_WITH_PAYLOAD(63, 1023, "hello %d", 1);
-  EXPECT_EQ(metadata.level(), 63u);
-  EXPECT_EQ(metadata.flags(), 1023u);
-  EXPECT_EQ(metadata.module(), kModuleToken);
-  EXPECT_EQ(encoded_data_size, 4u /* token */ + 1u /* encoded integer */);
+  EXPECT_EQ(last_log.metadata.level(), 63u);
+  EXPECT_EQ(last_log.metadata.flags(), 1023u);
+  EXPECT_EQ(last_log.metadata.module(), kModuleToken);
+  EXPECT_EQ(last_log.arg_count, 1u);
 }
 
 }  // namespace
diff --git a/pw_malloc_freelist/BUILD b/pw_malloc_freelist/BUILD
index 1efbb30..c3ca117 100644
--- a/pw_malloc_freelist/BUILD
+++ b/pw_malloc_freelist/BUILD
@@ -44,7 +44,6 @@
         "//dir_pw_boot_armv7m",
         "//dir_pw_malloc:facade",
         "//dir_pw_preprocessor",
-        "//dir_pw_span",
     ],
 )
 
diff --git a/pw_malloc_freelist/BUILD.gn b/pw_malloc_freelist/BUILD.gn
index 027d23e..446a46f 100644
--- a/pw_malloc_freelist/BUILD.gn
+++ b/pw_malloc_freelist/BUILD.gn
@@ -32,7 +32,6 @@
     "$dir_pw_boot_armv7m",
     "$dir_pw_malloc:facade",
     "$dir_pw_preprocessor",
-    "$dir_pw_span",
   ]
   sources = [ "freelist_malloc.cc" ]
 }
diff --git a/pw_malloc_freelist/freelist_malloc.cc b/pw_malloc_freelist/freelist_malloc.cc
index 8797295..db719c4 100644
--- a/pw_malloc_freelist/freelist_malloc.cc
+++ b/pw_malloc_freelist/freelist_malloc.cc
@@ -17,19 +17,19 @@
 #include "pw_allocator/freelist_heap.h"
 #include "pw_boot_armv7m/boot.h"
 #include "pw_malloc/malloc.h"
+#include "pw_preprocessor/compiler.h"
 #include "pw_preprocessor/util.h"
 
 namespace {
 std::aligned_storage_t<sizeof(pw::allocator::FreeListHeapBuffer<>),
                        alignof(pw::allocator::FreeListHeapBuffer<>)>
     buf;
-std::span<std::byte> pw_allocator_freelist_raw_heap;
 }  // namespace
 pw::allocator::FreeListHeapBuffer<>* pw_freelist_heap;
 
 #if __cplusplus
 extern "C" {
-#endif
+#endif  // __cplusplus
 // Define the global heap variables.
 void pw_MallocInit() {
   // pw_boot_heap_low_addr and pw_boot_heap_high_addr specifies the heap region
@@ -58,25 +58,19 @@
   return pw_freelist_heap->Calloc(num, size);
 }
 
-void* __wrap__malloc_r(struct _reent* r, size_t size) {
-  PW_UNUSED(r);
+void* __wrap__malloc_r(struct _reent*, size_t size) {
   return pw_freelist_heap->Allocate(size);
 }
 
-void __wrap__free_r(struct _reent* r, void* ptr) {
-  PW_UNUSED(r);
-  pw_freelist_heap->Free(ptr);
-}
+void __wrap__free_r(struct _reent*, void* ptr) { pw_freelist_heap->Free(ptr); }
 
-void* __wrap__realloc_r(struct _reent* r, void* ptr, size_t size) {
-  PW_UNUSED(r);
+void* __wrap__realloc_r(struct _reent*, void* ptr, size_t size) {
   return pw_freelist_heap->Realloc(ptr, size);
 }
 
-void* __wrap__calloc_r(struct _reent* r, size_t num, size_t size) {
-  PW_UNUSED(r);
+void* __wrap__calloc_r(struct _reent*, size_t num, size_t size) {
   return pw_freelist_heap->Calloc(num, size);
 }
 #if __cplusplus
 }
-#endif
+#endif  // __cplusplus
diff --git a/pw_malloc_freelist/freelist_malloc_test.cc b/pw_malloc_freelist/freelist_malloc_test.cc
index 1194230..ec4bf77 100644
--- a/pw_malloc_freelist/freelist_malloc_test.cc
+++ b/pw_malloc_freelist/freelist_malloc_test.cc
@@ -14,6 +14,7 @@
 
 #include "pw_malloc_freelist/freelist_malloc.h"
 
+#include <memory>
 #include <span>
 
 #include "gtest/gtest.h"
@@ -28,32 +29,38 @@
   constexpr size_t kCallocSize = 64;
   constexpr size_t zero = 0;
 
-  void* ptr1 = malloc(kAllocSize);
+  auto deleter = [](void* ptr) { free(ptr); };
+
+  std::unique_ptr<void, decltype(deleter)> ptr1(malloc(kAllocSize), deleter);
   const FreeListHeap::HeapStats& freelist_heap_stats =
       pw_freelist_heap->heap_stats();
-  ASSERT_NE(ptr1, nullptr);
+  ASSERT_NE(ptr1.get(), nullptr);
   EXPECT_EQ(freelist_heap_stats.bytes_allocated, kAllocSize);
   EXPECT_EQ(freelist_heap_stats.cumulative_allocated, kAllocSize);
   EXPECT_EQ(freelist_heap_stats.cumulative_freed, zero);
-  void* ptr2 = realloc(ptr1, kReallocSize);
-  ASSERT_NE(ptr2, nullptr);
+
+  std::unique_ptr<void, decltype(deleter)> ptr2(
+      realloc(ptr1.release(), kReallocSize), deleter);
+  ASSERT_NE(ptr2.get(), nullptr);
   EXPECT_EQ(freelist_heap_stats.bytes_allocated, kReallocSize);
   EXPECT_EQ(freelist_heap_stats.cumulative_allocated,
             kAllocSize + kReallocSize);
   EXPECT_EQ(freelist_heap_stats.cumulative_freed, kAllocSize);
-  void* ptr3 = calloc(kCallocNum, kCallocSize);
-  ASSERT_NE(ptr3, nullptr);
+
+  std::unique_ptr<void, decltype(deleter)> ptr3(calloc(kCallocNum, kCallocSize),
+                                                deleter);
+  ASSERT_NE(ptr3.get(), nullptr);
   EXPECT_EQ(freelist_heap_stats.bytes_allocated,
             kReallocSize + kCallocNum * kCallocSize);
   EXPECT_EQ(freelist_heap_stats.cumulative_allocated,
             kAllocSize + kReallocSize + kCallocNum * kCallocSize);
   EXPECT_EQ(freelist_heap_stats.cumulative_freed, kAllocSize);
-  free(ptr2);
+  free(ptr2.release());
   EXPECT_EQ(freelist_heap_stats.bytes_allocated, kCallocNum * kCallocSize);
   EXPECT_EQ(freelist_heap_stats.cumulative_allocated,
             kAllocSize + kReallocSize + kCallocNum * kCallocSize);
   EXPECT_EQ(freelist_heap_stats.cumulative_freed, kAllocSize + kReallocSize);
-  free(ptr3);
+  free(ptr3.release());
   EXPECT_EQ(freelist_heap_stats.bytes_allocated, zero);
   EXPECT_EQ(freelist_heap_stats.cumulative_allocated,
             kAllocSize + kReallocSize + kCallocNum * kCallocSize);
diff --git a/pw_metric/global_test.cc b/pw_metric/global_test.cc
index ab0aab5..d918c79 100644
--- a/pw_metric/global_test.cc
+++ b/pw_metric/global_test.cc
@@ -21,24 +21,13 @@
 namespace pw {
 namespace metric {
 
-// Count elements in an iterable.
-template <typename T>
-int Size(T& container) {
-  int num_elements = 0;
-  for (auto& element : container) {
-    PW_UNUSED(element);
-    num_elements++;
-  }
-  return num_elements;
-}
-
 // Create two global metrics; make sure they show up.
 PW_METRIC_GLOBAL(stat_x, "stat_x", 123u);
 PW_METRIC_GLOBAL(stat_y, "stat_y", 123u);
 
 TEST(Global, Metrics) {
   Metric::Dump(global_metrics);
-  EXPECT_EQ(Size(global_metrics), 2);
+  EXPECT_EQ(global_metrics.size(), 2u);
 }
 
 // Create three global metric groups; make sure they show up.
@@ -57,11 +46,11 @@
 
 TEST(Global, Groups) {
   Group::Dump(global_groups);
-  EXPECT_EQ(Size(global_groups), 4);
+  EXPECT_EQ(global_groups.size(), 4u);
 
-  EXPECT_EQ(Size(gyro_metrics.metrics()), 1);
-  EXPECT_EQ(Size(comms_metrics.metrics()), 2);
-  EXPECT_EQ(Size(power_metrics.metrics()), 3);
+  EXPECT_EQ(gyro_metrics.metrics().size(), 1u);
+  EXPECT_EQ(comms_metrics.metrics().size(), 2u);
+  EXPECT_EQ(power_metrics.metrics().size(), 3u);
 }
 
 }  // namespace metric
diff --git a/pw_metric/metric_service_nanopb.cc b/pw_metric/metric_service_nanopb.cc
index 49130a3..96d246d 100644
--- a/pw_metric/metric_service_nanopb.cc
+++ b/pw_metric/metric_service_nanopb.cc
@@ -91,7 +91,7 @@
 
   void Walk(const IntrusiveList<Metric>& metrics) {
     for (const auto& m : metrics) {
-      ScopedName(m.name(), *this);
+      ScopedName scoped_name(m.name(), *this);
       writer_.Write(m, path_);
     }
   }
@@ -103,7 +103,7 @@
   }
 
   void Walk(const Group& group) {
-    ScopedName(group.name(), *this);
+    ScopedName scoped_name(group.name(), *this);
     Walk(group.children());
     Walk(group.metrics());
   }
diff --git a/pw_metric/metric_service_nanopb_test.cc b/pw_metric/metric_service_nanopb_test.cc
index 26d6ebf..5810982 100644
--- a/pw_metric/metric_service_nanopb_test.cc
+++ b/pw_metric/metric_service_nanopb_test.cc
@@ -33,7 +33,7 @@
   MetricMethodContext context(root.metrics(), root.children());
   context.call({});
   EXPECT_TRUE(context.done());
-  EXPECT_EQ(Status::Ok(), context.status());
+  EXPECT_EQ(OkStatus(), context.status());
 
   // No metrics should be in the response.
   EXPECT_EQ(0u, context.responses().size());
@@ -52,7 +52,7 @@
   MetricMethodContext context(root.metrics(), root.children());
   context.call({});
   EXPECT_TRUE(context.done());
-  EXPECT_EQ(Status::Ok(), context.status());
+  EXPECT_EQ(OkStatus(), context.status());
 
   // All of the responses should have fit in one proto.
   EXPECT_EQ(1u, context.responses().size());
@@ -77,7 +77,7 @@
   MetricMethodContext context(root.metrics(), root.children());
   context.call({});
   EXPECT_TRUE(context.done());
-  EXPECT_EQ(Status::Ok(), context.status());
+  EXPECT_EQ(OkStatus(), context.status());
 
   // All of the responses should fit in one proto.
   EXPECT_EQ(1u, context.responses().size());
@@ -111,7 +111,7 @@
   MetricMethodContext context(root.metrics(), root.children());
   context.call({});
   EXPECT_TRUE(context.done());
-  EXPECT_EQ(Status::Ok(), context.status());
+  EXPECT_EQ(OkStatus(), context.status());
 
   // The response had to be split into two parts; check that they have the
   // appropriate sizes.
@@ -131,5 +131,77 @@
   // TODO(keir): Properly check all the fields.
 }
 
+bool TokenPathsMatch(uint32_t expected_token_path[5],
+                     const pw_metric_Metric& metric) {
+  // Calculate length of expected token & compare.
+  int expected_length = 0;
+  while (expected_token_path[expected_length]) {
+    expected_length++;
+  }
+  if (expected_length != metric.token_path_count) {
+    return false;
+  }
+
+  // Lengths match; so search the tokens themselves.
+  for (int i = 0; i < expected_length; ++i) {
+    if (expected_token_path[i] != metric.token_path[i]) {
+      return false;
+    }
+  }
+  return true;
+}
+
+TEST(MetricService, TokenPaths) {
+  // Set up a nested group of metrics that will not fit in a single batch.
+  PW_METRIC_GROUP(root, "/");
+  PW_METRIC(root, a, "a", 1u);
+
+  PW_METRIC_GROUP(inner_1, "inner1");
+  PW_METRIC(inner_1, x, "x", 4u);
+  PW_METRIC(inner_1, z, "z", 6u);
+
+  PW_METRIC_GROUP(inner_2, "inner2");
+  PW_METRIC(inner_2, p, "p", 7u);
+  PW_METRIC(inner_2, u, "s", 12u);
+
+  root.Add(inner_1);
+  root.Add(inner_2);
+
+  // Run the RPC and ensure it completes.
+  MetricMethodContext context(root.metrics(), root.children());
+  context.call({});
+  EXPECT_TRUE(context.done());
+  EXPECT_EQ(OkStatus(), context.status());
+
+  // The metrics should fit in one batch.
+  EXPECT_EQ(1u, context.responses().size());
+  EXPECT_EQ(5, context.responses()[0].metrics_count);
+
+  // Declare the token paths we expect to find.
+  // Note: This depends on the token variables from the PW_METRIC*() macros.
+  uint32_t expected_token_paths[5][5] = {
+      {a_token, 0u},
+      {inner_1_token, x_token, 0u},
+      {inner_1_token, z_token, 0u},
+      {inner_2_token, p_token, 0u},
+      {inner_2_token, u_token, 0u},
+  };
+
+  // For each expected token, search through all returned metrics to find it.
+  // The search is necessary since there is no guarantee of metric ordering.
+  for (auto& expected_token_path : expected_token_paths) {
+    int found_matches = 0;
+    // Note: There should only be 1 response.
+    for (const auto& response : context.responses()) {
+      for (unsigned m = 0; m < response.metrics_count; ++m) {
+        if (TokenPathsMatch(expected_token_path, response.metrics[m])) {
+          found_matches++;
+        }
+      }
+    }
+    EXPECT_EQ(found_matches, 1);
+  }
+}
+
 }  // namespace
 }  // namespace pw::metric
diff --git a/pw_metric/metric_test.cc b/pw_metric/metric_test.cc
index 0286485..2677ad8 100644
--- a/pw_metric/metric_test.cc
+++ b/pw_metric/metric_test.cc
@@ -81,13 +81,8 @@
   x.Increment(10);
   y.Set(5.0f);
 
-  int num_metrics = 0;
-  for (auto& m : group.metrics()) {
-    PW_UNUSED(m);
-    num_metrics++;
-  }
   group.Dump();
-  EXPECT_EQ(num_metrics, 2);
+  EXPECT_EQ(group.metrics().size(), 2u);
 }
 
 // The below are compile tests to ensure the macros work at global scope.
diff --git a/pw_metric/public/pw_metric/metric.h b/pw_metric/public/pw_metric/metric.h
index 3b8d79c..e229a6f 100644
--- a/pw_metric/public/pw_metric/metric.h
+++ b/pw_metric/public/pw_metric/metric.h
@@ -28,6 +28,8 @@
 // metric names are supported.
 using tokenizer::Token;
 
+#define _PW_METRIC_TOKEN_MASK 0x7fffffff
+
 // An individual metric. There are only two supported types: uint32_t and
 // float. More complicated compound metrics can be built on these primitives.
 // See the documentation for a discussion for this design was selected.
@@ -92,8 +94,8 @@
     uint32_t uint_;
   };
 
-  enum {
-    kTokenMask = 0x7fff'ffff,
+  enum : uint32_t {
+    kTokenMask = _PW_METRIC_TOKEN_MASK,  // 0x7fff'ffff
     kTypeMask = 0x8000'0000,
     kTypeFloat = 0x8000'0000,
     kTypeInt = 0x0,
@@ -255,17 +257,17 @@
                      uint32_t>
 
 // Case: PW_METRIC(name, initial_value)
-#define _PW_METRIC_4(static_def, variable_name, metric_name, init)       \
-  static constexpr uint32_t variable_name##_token =                      \
-      PW_TOKENIZE_STRING_DOMAIN("metrics", metric_name);                 \
-  static_def ::pw::metric::TypedMetric<_PW_METRIC_FLOAT_OR_UINT32(init)> \
+#define _PW_METRIC_4(static_def, variable_name, metric_name, init)            \
+  static constexpr uint32_t variable_name##_token =                           \
+      PW_TOKENIZE_STRING_MASK("metrics", _PW_METRIC_TOKEN_MASK, metric_name); \
+  static_def ::pw::metric::TypedMetric<_PW_METRIC_FLOAT_OR_UINT32(init)>      \
       variable_name = {variable_name##_token, init}
 
 // Case: PW_METRIC(group, name, initial_value)
-#define _PW_METRIC_5(static_def, group, variable_name, metric_name, init) \
-  static constexpr uint32_t variable_name##_token =                       \
-      PW_TOKENIZE_STRING_DOMAIN("metrics", metric_name);                  \
-  static_def ::pw::metric::TypedMetric<_PW_METRIC_FLOAT_OR_UINT32(init)>  \
+#define _PW_METRIC_5(static_def, group, variable_name, metric_name, init)     \
+  static constexpr uint32_t variable_name##_token =                           \
+      PW_TOKENIZE_STRING_MASK("metrics", _PW_METRIC_TOKEN_MASK, metric_name); \
+  static_def ::pw::metric::TypedMetric<_PW_METRIC_FLOAT_OR_UINT32(init)>      \
       variable_name = {variable_name##_token, init, group.metrics()}
 
 // Define a metric group. Works like PW_METRIC, and works in the same contexts.
diff --git a/pw_minimal_cpp_stdlib/isolated_test.cc b/pw_minimal_cpp_stdlib/isolated_test.cc
index 4f90a82..1be9662 100644
--- a/pw_minimal_cpp_stdlib/isolated_test.cc
+++ b/pw_minimal_cpp_stdlib/isolated_test.cc
@@ -252,7 +252,7 @@
 }
 
 TEST(New, PlacementNew) {
-  unsigned char value[4];
+  alignas(sizeof(int)) unsigned char value[sizeof(int)];
   new (value) int(1234);
 
   int int_value;
diff --git a/pw_minimal_cpp_stdlib/public/internal/type_traits.h b/pw_minimal_cpp_stdlib/public/internal/type_traits.h
index 6630e3b..bb6ccb5 100644
--- a/pw_minimal_cpp_stdlib/public/internal/type_traits.h
+++ b/pw_minimal_cpp_stdlib/public/internal/type_traits.h
@@ -19,6 +19,7 @@
 
 #define __cpp_lib_transformation_trait_aliases 201304L
 #define __cpp_lib_type_trait_variable_templates 201510L
+#define __cpp_lib_logical_traits 201510L
 
 template <decltype(sizeof(0)) kLength,
           decltype(sizeof(0)) kAlignment>  // no default
diff --git a/pw_module/py/BUILD.gn b/pw_module/py/BUILD.gn
index 09f7896..da1b132 100644
--- a/pw_module/py/BUILD.gn
+++ b/pw_module/py/BUILD.gn
@@ -23,4 +23,5 @@
     "pw_module/check.py",
   ]
   tests = [ "check_test.py" ]
+  pylintrc = "$dir_pigweed/.pylintrc"
 }
diff --git a/pw_multisink/BUILD b/pw_multisink/BUILD
new file mode 100644
index 0000000..de50a3c
--- /dev/null
+++ b/pw_multisink/BUILD
@@ -0,0 +1,51 @@
+# Copyright 2021 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.
+
+load(
+    "//pw_build:pigweed.bzl",
+    "pw_cc_library",
+    "pw_cc_test",
+)
+
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])  # Apache License 2.0
+
+pw_cc_library(
+    name = "pw_multisink",
+    srcs = [ "drain.cc", "multisink.cc" ],
+    includes = [ "public" ],
+    deps = [
+        "//pw_assert",
+        "//pw_bytes",
+        "//pw_result",
+        "//pw_ring_buffer",
+        "//pw_varint",
+    ],
+    hdrs = [
+        "public/pw_multisink/drain.h",
+        "public/pw_multisink/multisink.h",
+    ]
+)
+
+pw_cc_test(
+    name = "multisink_test",
+    srcs = [
+        "multisink_test.cc",
+    ],
+    deps = [
+        "//pw_preprocessor",
+        "//pw_unit_test",
+    ],
+)
diff --git a/pw_multisink/BUILD.gn b/pw_multisink/BUILD.gn
new file mode 100644
index 0000000..c8446d0
--- /dev/null
+++ b/pw_multisink/BUILD.gn
@@ -0,0 +1,59 @@
+# Copyright 2021 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/target_types.gni")
+import("$dir_pw_docgen/docs.gni")
+import("$dir_pw_unit_test/test.gni")
+
+config("default_config") {
+  include_dirs = [ "public" ]
+  visibility = [ ":*" ]
+}
+
+pw_source_set("pw_multisink") {
+  public_configs = [ ":default_config" ]
+  public = [
+    "public/pw_multisink/drain.h",
+    "public/pw_multisink/multisink.h",
+  ]
+  public_deps = [
+    "$dir_pw_bytes",
+    "$dir_pw_result",
+    "$dir_pw_ring_buffer",
+    "$dir_pw_status",
+  ]
+  deps = [
+    "$dir_pw_assert",
+    "$dir_pw_varint",
+  ]
+  sources = [
+    "drain.cc",
+    "multisink.cc",
+  ]
+}
+
+pw_doc_group("docs") {
+  sources = [ "docs.rst" ]
+}
+
+pw_test("multisink_test") {
+  sources = [ "multisink_test.cc" ]
+  deps = [ ":pw_multisink" ]
+}
+
+pw_test_group("tests") {
+  tests = [ ":multisink_test" ]
+}
diff --git a/pw_multisink/CMakeLists.txt b/pw_multisink/CMakeLists.txt
new file mode 100644
index 0000000..083c09e
--- /dev/null
+++ b/pw_multisink/CMakeLists.txt
@@ -0,0 +1,17 @@
+# Copyright 2021 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.
+
+include($ENV{PW_ROOT}/pw_build/pigweed.cmake)
+
+pw_auto_add_simple_module(pw_multisink)
diff --git a/pw_multisink/docs.rst b/pw_multisink/docs.rst
new file mode 100644
index 0000000..cf406f3
--- /dev/null
+++ b/pw_multisink/docs.rst
@@ -0,0 +1,9 @@
+.. _module-pw_multisink:
+
+------------
+pw_multisink
+------------
+This is an module that forwards messages to multiple attached sinks, which
+consume messages asynchronously. It is not ready for use and is under
+construction.
+
diff --git a/pw_multisink/drain.cc b/pw_multisink/drain.cc
new file mode 100644
index 0000000..73b935d
--- /dev/null
+++ b/pw_multisink/drain.cc
@@ -0,0 +1,51 @@
+// Copyright 2021 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.
+#include "pw_multisink/drain.h"
+
+#include "pw_assert/light.h"
+
+namespace pw {
+namespace multisink {
+
+Result<ConstByteSpan> Drain::GetEntry(ByteSpan entry,
+                                      uint32_t& drop_count_out) {
+  uint32_t entry_sequence_id = 0;
+  drop_count_out = 0;
+  const Result<ConstByteSpan> result =
+      MultiSink::GetEntry(*this, entry, entry_sequence_id);
+
+  // Exit immediately if the result isn't OK or OUT_OF_RANGE, as the
+  // entry_sequence_id cannot be used for computation. Later invocations to
+  // GetEntry will permit readers to determine how far the sequence ID moved
+  // forward.
+  if (!result.ok() && !result.status().IsOutOfRange()) {
+    return result;
+  }
+
+  // Compute the drop count delta by comparing this entry's sequence ID with the
+  // last sequence ID this drain successfully read.
+  //
+  // The drop count calculation simply computes the difference between the
+  // current and last sequence IDs. Consecutive successful reads will always
+  // differ by one at least, so it is subtracted out. If the read was not
+  // successful, the difference is not adjusted.
+  drop_count_out =
+      entry_sequence_id - last_handled_sequence_id_ - (result.ok() ? 1 : 0);
+
+  last_handled_sequence_id_ = entry_sequence_id;
+  return result;
+}
+
+}  // namespace multisink
+}  // namespace pw
diff --git a/pw_multisink/multisink.cc b/pw_multisink/multisink.cc
new file mode 100644
index 0000000..929eec6
--- /dev/null
+++ b/pw_multisink/multisink.cc
@@ -0,0 +1,62 @@
+// Copyright 2021 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.
+#include "pw_multisink/multisink.h"
+
+#include <cstring>
+
+#include "pw_assert/light.h"
+#include "pw_multisink/drain.h"
+#include "pw_status/try.h"
+#include "pw_varint/varint.h"
+
+namespace pw {
+namespace multisink {
+
+Result<ConstByteSpan> MultiSink::GetEntry(Drain& drain,
+                                          ByteSpan buffer,
+                                          uint32_t& sequence_id_out) {
+  size_t bytes_read = 0;
+
+  // Exit immediately if there's no multisink attached to this drain.
+  if (drain.multisink_ == nullptr) {
+    return Status::FailedPrecondition();
+  }
+
+  const Status status =
+      drain.reader_.PeekFrontWithPreamble(buffer, sequence_id_out, bytes_read);
+  if (status.IsOutOfRange()) {
+    // If the drain has caught up, report the last handled sequence ID so that
+    // it can still process any dropped entries.
+    sequence_id_out = drain.multisink_->sequence_id_ - 1;
+    return status;
+  }
+  PW_CHECK(drain.reader_.PopFront().ok());
+  return std::as_bytes(buffer.first(bytes_read));
+}
+
+Status MultiSink::AttachDrain(Drain& drain) {
+  PW_DCHECK(drain.multisink_ == nullptr);
+  drain.multisink_ = this;
+  drain.last_handled_sequence_id_ = sequence_id_ - 1;
+  return ring_buffer_.AttachReader(drain.reader_);
+}
+
+Status MultiSink::DetachDrain(Drain& drain) {
+  PW_DCHECK(drain.multisink_ == this);
+  drain.multisink_ = nullptr;
+  return ring_buffer_.DetachReader(drain.reader_);
+}
+
+}  // namespace multisink
+}  // namespace pw
diff --git a/pw_multisink/multisink_test.cc b/pw_multisink/multisink_test.cc
new file mode 100644
index 0000000..4e0f553
--- /dev/null
+++ b/pw_multisink/multisink_test.cc
@@ -0,0 +1,136 @@
+// Copyright 2021 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.
+
+#include "pw_multisink/multisink.h"
+
+#include "gtest/gtest.h"
+#include "pw_multisink/drain.h"
+
+namespace pw::multisink {
+
+class MultiSinkTest : public ::testing::Test {
+ protected:
+  static constexpr std::byte kMessage[] = {
+      (std::byte)0xDE, (std::byte)0xAD, (std::byte)0xBE, (std::byte)0xEF};
+  static constexpr size_t kMaxDrains = 3;
+  static constexpr size_t kEntryBufferSize = 1024;
+  static constexpr size_t kBufferSize = 5 * kEntryBufferSize;
+
+  MultiSinkTest() : multisink_(buffer_) {}
+
+  void ExpectMessageAndDropCount(Drain& drain,
+                                 std::span<const std::byte> expected_message,
+                                 uint32_t expected_drop_count) {
+    uint32_t drop_count = 0;
+    Result<ConstByteSpan> result = drain.GetEntry(entry_buffer_, drop_count);
+    if (expected_message.empty()) {
+      EXPECT_EQ(Status::OutOfRange(), result.status());
+    } else {
+      ASSERT_TRUE(result.ok());
+      EXPECT_EQ(memcmp(result.value().data(),
+                       expected_message.data(),
+                       expected_message.size_bytes()),
+                0);
+    }
+    EXPECT_EQ(drop_count, expected_drop_count);
+  }
+
+  std::byte buffer_[kBufferSize];
+  std::byte entry_buffer_[kEntryBufferSize];
+  Drain drains_[kMaxDrains];
+  MultiSink multisink_;
+};
+
+TEST_F(MultiSinkTest, SingleDrain) {
+  EXPECT_EQ(OkStatus(), multisink_.AttachDrain(drains_[0]));
+  EXPECT_EQ(OkStatus(), multisink_.HandleEntry(kMessage));
+
+  // Single entry push and pop.
+  ExpectMessageAndDropCount(drains_[0], kMessage, 0u);
+
+  // Multiple entries with intermittent drops.
+  EXPECT_EQ(OkStatus(), multisink_.HandleEntry(kMessage));
+  multisink_.HandleDropped();
+  EXPECT_EQ(OkStatus(), multisink_.HandleEntry(kMessage));
+  ExpectMessageAndDropCount(drains_[0], kMessage, 0u);
+  ExpectMessageAndDropCount(drains_[0], kMessage, 1u);
+
+  // Send drops only.
+  multisink_.HandleDropped();
+  ExpectMessageAndDropCount(drains_[0], {}, 1u);
+
+  // Confirm out-of-range if no entries are expected.
+  ExpectMessageAndDropCount(drains_[0], {}, 0u);
+}
+
+TEST_F(MultiSinkTest, MultipleDrain) {
+  EXPECT_EQ(OkStatus(), multisink_.AttachDrain(drains_[0]));
+  EXPECT_EQ(OkStatus(), multisink_.AttachDrain(drains_[1]));
+
+  EXPECT_EQ(OkStatus(), multisink_.HandleEntry(kMessage));
+  EXPECT_EQ(OkStatus(), multisink_.HandleEntry(kMessage));
+  multisink_.HandleDropped();
+  EXPECT_EQ(OkStatus(), multisink_.HandleEntry(kMessage));
+  multisink_.HandleDropped();
+
+  // Drain one drain entirely.
+  ExpectMessageAndDropCount(drains_[0], kMessage, 0u);
+  ExpectMessageAndDropCount(drains_[0], kMessage, 0u);
+  ExpectMessageAndDropCount(drains_[0], kMessage, 1u);
+  ExpectMessageAndDropCount(drains_[0], {}, 1u);
+  ExpectMessageAndDropCount(drains_[0], {}, 0u);
+
+  // Confirm the other drain can be drained separately.
+  ExpectMessageAndDropCount(drains_[1], kMessage, 0u);
+  ExpectMessageAndDropCount(drains_[1], kMessage, 0u);
+  ExpectMessageAndDropCount(drains_[1], kMessage, 1u);
+  ExpectMessageAndDropCount(drains_[1], {}, 1u);
+  ExpectMessageAndDropCount(drains_[1], {}, 0u);
+}
+
+TEST_F(MultiSinkTest, LateRegistration) {
+  // Confirm that entries pushed before attaching a drain are not seen by the
+  // drain.
+  EXPECT_EQ(OkStatus(), multisink_.HandleEntry(kMessage));
+
+  // The drain does not observe 'drops' as it did not see entries, and only sees
+  // the one entry that was added after attach.
+  EXPECT_EQ(OkStatus(), multisink_.AttachDrain(drains_[0]));
+  EXPECT_EQ(OkStatus(), multisink_.HandleEntry(kMessage));
+  ExpectMessageAndDropCount(drains_[0], kMessage, 0u);
+  ExpectMessageAndDropCount(drains_[0], {}, 0u);
+}
+
+TEST_F(MultiSinkTest, DynamicDrainRegistration) {
+  EXPECT_EQ(OkStatus(), multisink_.AttachDrain(drains_[0]));
+
+  multisink_.HandleDropped();
+  EXPECT_EQ(OkStatus(), multisink_.HandleEntry(kMessage));
+  multisink_.HandleDropped();
+  EXPECT_EQ(OkStatus(), multisink_.HandleEntry(kMessage));
+
+  // Drain out one message and detach it.
+  ExpectMessageAndDropCount(drains_[0], kMessage, 1u);
+  EXPECT_EQ(OkStatus(), multisink_.DetachDrain(drains_[0]));
+
+  // Reattach the drain and confirm that you only see events after attaching.
+  EXPECT_EQ(OkStatus(), multisink_.AttachDrain(drains_[0]));
+  ExpectMessageAndDropCount(drains_[0], {}, 0u);
+
+  EXPECT_EQ(OkStatus(), multisink_.HandleEntry(kMessage));
+  ExpectMessageAndDropCount(drains_[0], kMessage, 0u);
+  ExpectMessageAndDropCount(drains_[0], {}, 0u);
+}
+
+}  // namespace pw::multisink
diff --git a/pw_multisink/public/pw_multisink/drain.h b/pw_multisink/public/pw_multisink/drain.h
new file mode 100644
index 0000000..90aae11
--- /dev/null
+++ b/pw_multisink/public/pw_multisink/drain.h
@@ -0,0 +1,65 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "pw_multisink/multisink.h"
+#include "pw_status/status.h"
+
+namespace pw {
+namespace multisink {
+
+// An asynchronous reader which is attached to a MultiSink via AttachDrain.
+// Each Drain holds a PrefixedEntryRingBufferMulti::Reader and abstracts away
+// entry sequence information for clients.
+class Drain {
+ public:
+  constexpr Drain() : last_handled_sequence_id_(0), multisink_(nullptr) {}
+
+  // Returns the next available entry if it exists and acquires the latest drop
+  // count in parallel.
+  //
+  // The `drop_count_out` is set to the number of entries that were dropped
+  // since the last call to GetEntry, if the read operation was successful or
+  // indicated that no entries were available to read. If the read operation
+  // fails otherwise, the `drop_count_out` is set to zero.
+  //
+  // Drop counts are internally maintained with a 32-bit counter. If UINT32_MAX
+  // entries have been handled by the attached multisink between subsequent
+  // calls to GetEntry, the drop count will overflow and will report a lower
+  // count erroneously. Users should ensure that sinks call GetEntry
+  // at least once every UINT32_MAX entries.
+  //
+  // Return values:
+  // Ok - An entry was successfully read from the multisink. The drop_count_out
+  // is set to the count of entries that were dropped since the last call
+  // to GetEntry.
+  // FailedPrecondition - The drain must be attached to a sink.
+  // OutOfRange - No entries were available, the drop_count_out is set to the
+  // number of entries that were dropped since the last call to GetEntry.
+  // ResourceExhausted - The provided buffer was not large enough to store the
+  // next available entry.
+  // DataLoss - An entry was read from the multisink, but did not match the
+  // expected format (i.e. failed to decode).
+  // InvalidArgument - The drain is not currently associated with a multisink.
+  Result<ConstByteSpan> GetEntry(ByteSpan entry, uint32_t& drop_count_out);
+
+ protected:
+  friend MultiSink;
+  ring_buffer::PrefixedEntryRingBufferMulti::Reader reader_;
+  uint32_t last_handled_sequence_id_;
+  MultiSink* multisink_;
+};
+
+}  // namespace multisink
+}  // namespace pw
diff --git a/pw_multisink/public/pw_multisink/multisink.h b/pw_multisink/public/pw_multisink/multisink.h
new file mode 100644
index 0000000..097e4a3
--- /dev/null
+++ b/pw_multisink/public/pw_multisink/multisink.h
@@ -0,0 +1,107 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "pw_bytes/span.h"
+#include "pw_result/result.h"
+#include "pw_ring_buffer/prefixed_entry_ring_buffer.h"
+#include "pw_status/status.h"
+
+namespace pw {
+namespace multisink {
+class Drain;
+
+// An asynchronous single-writer multi-reader queue that ensures readers can
+// poll for dropped message counts, which is useful for logging or similar
+// scenarios where readers need to be aware of the input message sequence.
+// TODO(pwbug/342): Support notifying readers when the queue is readable,
+// rather than requiring them to poll to check for new entries.
+// TODO(pwbug/343): Add thread-safety, separate from the thread-safety work
+// planned for the underlying ring buffer.
+class MultiSink {
+ public:
+  // Constructs a multisink using a ring buffer backed by the provided buffer.
+  MultiSink(ByteSpan buffer) : ring_buffer_(true), sequence_id_(0) {
+    ring_buffer_.SetBuffer(buffer);
+  }
+
+  // Write an entry to the multisink. If available space is less than the
+  // size of the entry, the internal ring buffer will push the oldest entries
+  // out to make space, so long as the entry is not larger than the buffer.
+  // The sequence ID of the multisink will always increment as a result of
+  // calling HandleEntry, regardless of whether pushing the entry succeeds.
+  //
+  // Return values:
+  // Ok - Entry was successfully pushed to the ring buffer.
+  // InvalidArgument - Size of data to write is zero bytes.
+  // OutOfRange - Size of data is greater than buffer size.
+  // FailedPrecondition - Buffer was not initialized.
+  Status HandleEntry(ConstByteSpan entry) {
+    return ring_buffer_.PushBack(entry, sequence_id_++);
+  }
+
+  // Notifies the multisink of messages dropped before ingress. The writer
+  // may use this to signal to readers that an entry (or entries) failed
+  // before being sent to the multisink (e.g. the writer failed to encode
+  // the message). This API increments the sequence ID of the multisink by
+  // the provided `drop_count`.
+  void HandleDropped(uint32_t drop_count = 1) { sequence_id_ += drop_count; }
+
+  // Attach a drain to the multisink. Drains may not be associated with more
+  // than one multisink at a time. Entries pushed before the drain was attached
+  // are not seen by the drain, so drains should be attached before entries
+  // are pushed.
+  //
+  // Return values:
+  // Ok - Drain was successfully attached.
+  // InvalidArgument - Drain is currently associated with another multisink.
+  Status AttachDrain(Drain& drain);
+
+  // Detaches a drain from the multisink. Drains may only be detached if they
+  // were previously attached to this multisink.
+  //
+  // Return values:
+  // Ok - Drain was successfully detached.
+  // InvalidArgument - Drain is not currently associated with this multisink.
+  Status DetachDrain(Drain& drain);
+
+  // Removes all data from the internal buffer. The multisink's sequence ID is
+  // not modified, so readers may interpret this event as droppping entries.
+  void Clear() { ring_buffer_.Clear(); }
+
+ protected:
+  friend Drain;
+  // Gets an entry from the provided drain and unpacks sequence ID information.
+  // Drains use this API to strip away sequence ID information for drop
+  // calculation.
+  //
+  // Returns:
+  // Ok - An entry was successfully read from the multisink. The `sequence_id`
+  // is set to the ID encoded in the oldest entry.
+  // FailedPrecondition - The drain is not attached to a multisink.
+  // ResourceExhausted - The provided buffer was not large enough to store
+  // the next available entry.
+  // DataLoss - An entry was read from the multisink, but did not contains an
+  // encoded sequence ID.
+  static Result<ConstByteSpan> GetEntry(Drain& drain,
+                                        ByteSpan buffer,
+                                        uint32_t& sequence_id_out);
+
+ private:
+  ring_buffer::PrefixedEntryRingBufferMulti ring_buffer_;
+  uint32_t sequence_id_ = 0;
+};
+
+}  // namespace multisink
+}  // namespace pw
diff --git a/pw_package/py/BUILD.gn b/pw_package/py/BUILD.gn
index 6388bc0..dde870b 100644
--- a/pw_package/py/BUILD.gn
+++ b/pw_package/py/BUILD.gn
@@ -23,7 +23,10 @@
     "pw_package/git_repo.py",
     "pw_package/package_manager.py",
     "pw_package/packages/__init__.py",
+    "pw_package/packages/arduino_core.py",
     "pw_package/packages/nanopb.py",
     "pw_package/pigweed_packages.py",
   ]
+  pylintrc = "$dir_pigweed/.pylintrc"
+  python_deps = [ "$dir_pw_arduino_build/py" ]
 }
diff --git a/pw_package/py/pw_package/git_repo.py b/pw_package/py/pw_package/git_repo.py
index 4b98366..fa9f129 100644
--- a/pw_package/py/pw_package/git_repo.py
+++ b/pw_package/py/pw_package/git_repo.py
@@ -18,6 +18,7 @@
 import shutil
 import subprocess
 from typing import Union
+import urllib.parse
 
 import pw_package.package_manager
 
@@ -50,6 +51,16 @@
             return False
 
         remote = git_stdout('remote', 'get-url', 'origin', repo=path)
+        url = urllib.parse.urlparse(remote)
+        if url.scheme == 'sso' or '.git.corp.google.com' in url.netloc:
+            host = url.netloc.replace(
+                '.git.corp.google.com',
+                '.googlesource.com',
+            )
+            if not host.endswith('.googlesource.com'):
+                host += '.googlesource.com'
+            remote = 'https://{}{}'.format(host, url.path)
+
         commit = git_stdout('rev-parse', 'HEAD', repo=path)
         status = git_stdout('status', '--porcelain=v1', repo=path)
         return remote == self._url and commit == self._commit and not status
diff --git a/pw_package/py/pw_package/package_manager.py b/pw_package/py/pw_package/package_manager.py
index dd89dba..3f1cc80 100644
--- a/pw_package/py/pw_package/package_manager.py
+++ b/pw_package/py/pw_package/package_manager.py
@@ -19,7 +19,7 @@
 import os
 import pathlib
 import shutil
-from typing import Dict, List, Tuple
+from typing import Dict, List, Sequence, Tuple
 
 _LOG: logging.Logger = logging.getLogger(__name__)
 
@@ -59,12 +59,18 @@
         This method will be skipped if the directory does not exist.
         """
 
+    def info(self, path: pathlib.Path) -> Sequence[str]:  # pylint: disable=no-self-use
+        """Returns a short string explaining how to enable the package."""
+
 
 _PACKAGES: Dict[str, Package] = {}
 
 
-def register(package_class: type) -> None:
-    obj = package_class()
+def register(package_class: type, name: str = None) -> None:
+    if name:
+        obj = package_class(name)
+    else:
+        obj = package_class()
     _PACKAGES[obj.name] = obj
 
 
@@ -112,6 +118,10 @@
             available=tuple(available),
         )
 
+    def info(self, package: str) -> Sequence[str]:
+        pkg = _PACKAGES[package]
+        return pkg.info(self._pkg_root / pkg.name)
+
 
 class PackageManagerCLI:
     """Command-line interface to PackageManager."""
@@ -122,6 +132,8 @@
         _LOG.info('Installing %s...', package)
         self._mgr.install(package, force)
         _LOG.info('Installing %s...done.', package)
+        for line in self._mgr.info(package):
+            _LOG.info('%s', line)
         return 0
 
     def remove(self, package: str) -> int:
@@ -133,6 +145,8 @@
     def status(self, package: str) -> int:
         if self._mgr.status(package):
             _LOG.info('%s is installed.', package)
+            for line in self._mgr.info(package):
+                _LOG.info('%s', line)
             return 0
 
         _LOG.info('%s is not installed.', package)
@@ -144,6 +158,8 @@
         _LOG.info('Installed packages:')
         for package in packages.installed:
             _LOG.info('  %s', package)
+            for line in self._mgr.info(package):
+                _LOG.info('    %s', line)
         _LOG.info('')
 
         _LOG.info('Available packages:')
@@ -154,7 +170,7 @@
         return 0
 
     def run(self, command: str, pkg_root: pathlib.Path, **kwargs) -> int:
-        self._mgr = PackageManager(pkg_root)
+        self._mgr = PackageManager(pkg_root.resolve())
         return getattr(self, command)(**kwargs)
 
 
diff --git a/pw_package/py/pw_package/packages/arduino_core.py b/pw_package/py/pw_package/packages/arduino_core.py
new file mode 100644
index 0000000..994c95c
--- /dev/null
+++ b/pw_package/py/pw_package/packages/arduino_core.py
@@ -0,0 +1,140 @@
+# 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.
+"""Install and check status of teensy-core."""
+
+import json
+import logging
+import re
+import subprocess
+import tempfile
+from pathlib import Path
+from typing import Sequence
+
+from pw_arduino_build import core_installer
+
+import pw_package.package_manager
+
+_LOG: logging.Logger = logging.getLogger(__name__)
+
+
+class ArduinoCore(pw_package.package_manager.Package):
+    """Install and check status of arduino cores."""
+    def __init__(self, core_name, *args, **kwargs):
+        super().__init__(*args, name=core_name, **kwargs)
+
+    def status(self, path: Path) -> bool:
+        return (path / 'hardware').is_dir()
+
+    def populate_download_cache_from_cipd(self, path: Path) -> None:
+        """Check for arduino core availability in pigweed_internal cipd."""
+        package_path = path.parent.resolve()
+        core_name = self.name
+        core_cache_path = package_path / ".cache" / core_name
+        core_cache_path.mkdir(parents=True, exist_ok=True)
+
+        cipd_package_subpath = "pigweed_internal/third_party/"
+        cipd_package_subpath += core_name
+        cipd_package_subpath += "/${platform}"
+
+        # Check if teensy cipd package is readable
+
+        with tempfile.NamedTemporaryFile(prefix='cipd',
+                                         delete=True) as temp_json:
+            cipd_acl_check_command = [
+                "cipd",
+                "acl-check",
+                cipd_package_subpath,
+                "-reader",
+                "-json-output",
+                temp_json.name,
+            ]
+            subprocess.run(cipd_acl_check_command, capture_output=True)
+            # Return if no packages are readable.
+            if not json.load(temp_json)['result']:
+                return
+
+        def _run_command(command):
+            _LOG.debug("Running: `%s`", " ".join(command))
+            result = subprocess.run(command, capture_output=True)
+            _LOG.debug("Output:\n%s",
+                       result.stdout.decode() + result.stderr.decode())
+
+        _run_command(["cipd", "init", "-force", core_cache_path.as_posix()])
+        _run_command([
+            "cipd", "install", cipd_package_subpath, "-root",
+            core_cache_path.as_posix(), "-force"
+        ])
+
+        _LOG.debug(
+            "Available Cache Files:\n%s",
+            "\n".join([p.as_posix() for p in core_cache_path.glob("*")]))
+
+    def install(self, path: Path) -> None:
+        self.populate_download_cache_from_cipd(path)
+
+        if self.status(path):
+            return
+        # Otherwise delete current version and reinstall
+        core_installer.install_core(path.parent.resolve().as_posix(),
+                                    self.name)
+
+    def info(self, path: Path) -> Sequence[str]:
+        packages_root = path.parent.resolve()
+        arduino_package_path = path
+        arduino_package_name = None
+
+        message = [
+            f'{self.name} currently installed in: {path}',
+        ]
+        # Make gn args sample copy/paste-able by omitting the starting timestamp
+        # and INF log on each line.
+        message_gn_args = [
+            'Enable by running "gn args out" and adding these lines:',
+            f'  pw_arduino_build_CORE_PATH = "{packages_root}"',
+            f'  pw_arduino_build_CORE_NAME = "{self.name}"'
+        ]
+
+        # Search for first valid 'package/version' directory
+        for hardware_dir in [
+                path for path in (path / 'hardware').iterdir()
+                if path.is_dir()
+        ]:
+            if path.name in ["arduino", "tools"]:
+                continue
+            for subdir in [
+                    path for path in hardware_dir.iterdir() if path.is_dir()
+            ]:
+                if subdir.name == 'avr' or re.match(r'[0-9.]+', subdir.name):
+                    arduino_package_name = f'{hardware_dir.name}/{subdir.name}'
+                    break
+
+        if arduino_package_name:
+            message_gn_args += [
+                f'  pw_arduino_build_PACKAGE_NAME = "{arduino_package_name}"',
+                '  pw_arduino_build_BOARD = "BOARD_NAME"'
+            ]
+            message += ["\n".join(message_gn_args)]
+            message += [
+                'Where BOARD_NAME is any supported board.',
+                # Have arduino_builder command appear on it's own line.
+                'List available boards by running:\n'
+                '  arduino_builder '
+                f'--arduino-package-path {arduino_package_path} '
+                f'--arduino-package-name {arduino_package_name} list-boards'
+            ]
+        return message
+
+
+for arduino_core_name in core_installer.supported_cores():
+    pw_package.package_manager.register(ArduinoCore, name=arduino_core_name)
diff --git a/pw_package/py/pw_package/packages/nanopb.py b/pw_package/py/pw_package/packages/nanopb.py
index 96955bd..b5875d4 100644
--- a/pw_package/py/pw_package/packages/nanopb.py
+++ b/pw_package/py/pw_package/packages/nanopb.py
@@ -13,6 +13,9 @@
 # the License.
 """Install and check status of nanopb."""
 
+import pathlib
+from typing import Sequence
+
 import pw_package.git_repo
 import pw_package.package_manager
 
@@ -20,11 +23,17 @@
 class NanoPB(pw_package.git_repo.GitRepo):
     """Install and check status of nanopb."""
     def __init__(self, *args, **kwargs):
-        super().__init__(*args,
-                         name='nanopb',
-                         url='https://github.com/nanopb/nanopb.git',
-                         commit='9f57cc871d8a025039019c2d2fde217591f4e30d',
-                         **kwargs)
+        super().__init__(
+            *args,
+            name='nanopb',
+            url=
+            'https://pigweed.googlesource.com/third_party/github/nanopb/nanopb',
+            commit='2b48a361786dfb1f63d229840217a93aae064667',
+            **kwargs)
 
-
-pw_package.package_manager.register(NanoPB)
+    def info(self, path: pathlib.Path) -> Sequence[str]:
+        return (
+            f'{self.name} installed in: {path}',
+            "Enable by running 'gn args out' and adding this line:",
+            f'  dir_pw_third_party_nanopb = "{path}"',
+        )
diff --git a/pw_hdlc_lite/py/pw_hdlc_lite/py.typed b/pw_package/py/pw_package/packages/py.typed
similarity index 100%
copy from pw_hdlc_lite/py/pw_hdlc_lite/py.typed
copy to pw_package/py/pw_package/packages/py.typed
diff --git a/pw_package/py/pw_package/pigweed_packages.py b/pw_package/py/pw_package/pigweed_packages.py
index 734b6d7..6c39266 100644
--- a/pw_package/py/pw_package/pigweed_packages.py
+++ b/pw_package/py/pw_package/pigweed_packages.py
@@ -16,12 +16,16 @@
 import sys
 
 from pw_package import package_manager
-# These modules register themselves so must be imported despite appearing
-# unused.
-from pw_package.packages import nanopb  # pylint: disable=unused-import
+from pw_package.packages import nanopb
+from pw_package.packages import arduino_core  # pylint: disable=unused-import
+
+
+def initialize():
+    package_manager.register(nanopb.NanoPB)
 
 
 def main(argv=None) -> int:
+    initialize()
     return package_manager.run(**vars(package_manager.parse_args(argv)))
 
 
diff --git a/pw_persistent_ram/BUILD b/pw_persistent_ram/BUILD
new file mode 100644
index 0000000..1cde0cd
--- /dev/null
+++ b/pw_persistent_ram/BUILD
@@ -0,0 +1,62 @@
+# Copyright 2021 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.
+
+load(
+    "//pw_build:pigweed.bzl",
+    "pw_cc_library",
+    "pw_cc_test",
+)
+
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])  # Apache License 2.0
+
+pw_cc_library(
+    name = "pw_persistent_ram",
+    hdrs = [
+        "public/pw_persistent_ram/persistent.h",
+        "public/pw_persistent_ram/persistent_buffer.h",
+    ],
+    includes = ["public"],
+    srcs = ["persistent_buffer.cc"],
+    deps = [
+        "//pw_assert",
+        "//pw_bytes",
+        "//pw_checksum",
+        "//pw_stream",
+    ],
+)
+
+pw_cc_test(
+    name = "persistent_test",
+    srcs = [
+        "persistent_test.cc",
+    ],
+    deps = [
+        ":pw_persistent_ram",
+        "//pw_unit_test",
+    ],
+)
+
+pw_cc_test(
+    name = "persistent_buffer_test",
+    srcs = [
+        "persistent_buffer_test.cc",
+    ],
+    deps = [
+        ":pw_persistent_ram",
+        "//pw_unit_test",
+        "//pw_random",
+    ],
+)
diff --git a/pw_persistent_ram/BUILD.gn b/pw_persistent_ram/BUILD.gn
new file mode 100644
index 0000000..f992280
--- /dev/null
+++ b/pw_persistent_ram/BUILD.gn
@@ -0,0 +1,89 @@
+# Copyright 2021 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_bloat/bloat.gni")
+import("$dir_pw_build/target_types.gni")
+import("$dir_pw_docgen/docs.gni")
+import("$dir_pw_unit_test/test.gni")
+
+config("public_include_path") {
+  include_dirs = [ "public" ]
+  visibility = [ ":*" ]
+}
+
+pw_source_set("pw_persistent_ram") {
+  public_configs = [ ":public_include_path" ]
+  public = [
+    "public/pw_persistent_ram/persistent.h",
+    "public/pw_persistent_ram/persistent_buffer.h",
+  ]
+  sources = [ "persistent_buffer.cc" ]
+  public_deps = [
+    dir_pw_assert,
+    dir_pw_bytes,
+    dir_pw_checksum,
+    dir_pw_stream,
+  ]
+}
+
+pw_test_group("tests") {
+  tests = [
+    ":persistent_test",
+    ":persistent_buffer_test",
+  ]
+}
+
+pw_test("persistent_test") {
+  deps = [
+    ":pw_persistent_ram",
+    dir_pw_random,
+  ]
+  sources = [ "persistent_test.cc" ]
+}
+
+pw_test("persistent_buffer_test") {
+  deps = [
+    ":pw_persistent_ram",
+    dir_pw_random,
+  ]
+  sources = [ "persistent_buffer_test.cc" ]
+}
+
+pw_doc_group("docs") {
+  sources = [ "docs.rst" ]
+  report_deps = [ ":persistent_size" ]
+}
+
+pw_size_report("persistent_size") {
+  title = "pw::persistent_ram::Persistent"
+
+  # To see all the symbols, uncomment the following:
+  # Note: The size report RST table won't be generated when full_report = true.
+  # full_report = true
+
+  binaries = [
+    {
+      target = "size_report:persistent"
+      base = "size_report:persistent_base"
+      label = "Persistent including pw_checksum's CRC16"
+    },
+    {
+      target = "size_report:persistent"
+      base = "size_report:persistent_base_with_crc16"
+      label = "Persistent without pw_checksum's CRC16"
+    },
+  ]
+}
diff --git a/pw_persistent_ram/docs.rst b/pw_persistent_ram/docs.rst
new file mode 100644
index 0000000..19384bd
--- /dev/null
+++ b/pw_persistent_ram/docs.rst
@@ -0,0 +1,275 @@
+.. _module-pw_persistent_ram:
+
+=================
+pw_persistent_ram
+=================
+The ``pw_persistent_ram`` module contains utilities and containers for using
+persistent RAM. By persistent RAM we are referring to memory which is not
+initialized across reboots by the hardware nor bootloader(s). This memory may
+decay or bit rot between reboots including brownouts, ergo integrity checking is
+highly recommended.
+
+.. Note::
+  This is something that not all architectures and applications built on them
+  support and requires hardware in the loop testing to verify it works as
+  intended.
+
+.. Warning::
+  Do not treat the current containers provided in this module as stable storage
+  primitives. We are still evaluating lighterweight checksums from a code size
+  point of view. In other words, future updates to this module may result in a
+  loss of persistent data across software updates.
+
+------------------------
+Persistent RAM Placement
+------------------------
+Persistent RAM is typically provided through specially carved out linker script
+sections and/or memory ranges which are located in such a way that any
+bootloaders and the application boot code do not clobber it.
+
+1. If persistent linker sections are provided, we recommend using our section
+   placement macro. For example imagine the persistent section name is called
+   `.noinit`, then you could instantiate an object as such:
+
+   .. code-block:: cpp
+
+      #include "pw_persistent_ram/persistent.h"
+      #include "pw_preprocessor/compiler.h"
+
+      using pw::persistent_ram::Persistent;
+
+      PW_KEEP_IN_SECTION(".noinit") Persistent<bool> persistent_bool;
+
+2. If persistent memory ranges are provided, we recommend using a struct to wrap
+   the different persisted objects. This then could be checked to fit in the
+   provided memory range size, for example by asserting against variables
+   provided through a linker script.
+
+   .. code-block:: cpp
+
+      #include "pw_assert/assert.h"
+      #include "pw_persistent_ram/persistent.h"
+
+      // Provided for example through a linker script.
+      extern "C" uint8_t __noinit_begin;
+      extern "C" uint8_t __noinit_end;
+
+      struct PersistentData {
+        Persistent<bool> persistent_bool;
+      };
+      PersistentData& persistent_data =
+        *reinterpret_cast<NoinitData*>(&__noinit_begin);
+
+      void CheckPersistentDataSize() {
+        PW_DCHECK_UINT_LE(sizeof(PersistentData),
+                          __noinit_end - __noinit_begin,
+                          "PersistentData overflowed the noinit memory range");
+      }
+
+-----------------------------------
+Persistent RAM Lifecycle Management
+-----------------------------------
+In order for persistent RAM containers to be as useful as possible, any
+invalidation of persistent RAM and the containers therein should be executed
+before the global static C++ constructors, but after the BSS and data sections
+are initialized in RAM.
+
+The preferred way to clear Persistent RAM is to simply zero entire persistent
+RAM sections and/or memory regions. Pigweed's persistents containers have picked
+integrity checks which work with zerod memory, meaning they do not hold a value
+after zeroing. Alternatively containers can be individually cleared.
+
+The boot sequence itself is tightly coupled to the number of persistent sections
+and/or memory regions which exist in the final image, ergo this is something
+which Pigweed cannot provide to the user directly. However, we do recommend
+following some guidelines:
+
+1. Do not instantiate regular types/objects in persistent RAM, ensure integrity
+   checking is always used! This is a major risk with this technique and can
+   lead to unexpected memory corruption.
+2. Always instantiate persistent containers outside of the objects which depend
+   on them and use dependency injection. This permits unit testing and avoids
+   placement accidents of persistents and/or their users.
+3. Always erase persistent RAM data after software updates unless the
+   persistent storage containers are explicitly stored at fixed address and
+   with a fixed layout. This prevents use of swapped objects or their members
+   where the same integrity checks are used.
+4. Consider zeroing persistent RAM to recover from crashes which may be induced
+   by persistent RAM usage, for example by checking the reboot/crash reason.
+5. Consider zeroing persistent RAM on cold boots to always start from a
+   consistent state if persistence is only desired across warm reboots. This can
+   create determinism from cold boots when using for example DRAM.
+6. Consider an explicit persistent clear request which can be set before a warm
+   reboot as a signal to zero all persistent RAM on the next boot to emulate
+   persistent memory loss in a threadsafe manner.
+
+---------------------------------
+pw::persistent_ram::Persistent<T>
+---------------------------------
+The Persistent is a simple container for holding its templated value ``T`` with
+CRC16 integrity checking. Note that a Persistent will be lost if a write/set
+operation is interrupted or otherwise not completed, as it is not double
+buffered.
+
+The default constructor does nothing, meaning it will result in either invalid
+state initially or a valid persisted value from a previous session.
+
+The destructor does nothing, ergo it is okay if it is not executed during
+shutdown.
+
+Example: Storing an integer
+---------------------------
+A common use case of persistent data is to track boot counts, or effectively
+how often the device has rebooted. This can be useful for monitoring how many
+times the device rebooted and/or crashed. This can be easily accomplished using
+the Persistent container.
+
+.. code-block:: cpp
+
+    #include "pw_persistent_ram/persistent.h"
+    #include "pw_preprocessor/compiler.h"
+
+    using pw::persistent_ram::Persistent;
+
+    class BootCount {
+     public:
+      explicit BootCount(Persistent<uint16_t>& persistent_boot_count)
+          : persistent_(persistent_boot_count) {
+        if (!persistent_.has_value()) {
+          persistent_ = 0;
+        } else {
+          persistent_ = persistent_.value() + 1;
+        }
+        boot_count_ = persistent_.value();
+      }
+
+      uint16_t GetBootCount() { return boot_count_; }
+
+     private:
+      Persistent<uint16_t>& persistent_;
+      uint16_t boot_count_;
+    };
+
+    PW_KEEP_IN_SECTION(".noinit") Persistent<uint16_t> persistent_boot_count;
+    BootCount boot_count(persistent_boot_count);
+
+    int main() {
+      const uint16_t boot_count = boot_count.GetBootCount();
+      // ... rest of main
+    }
+
+Example: Storing larger objects
+-------------------------------
+Larger objects may be inefficient to copy back and forth due to the need for
+a working copy. To work around this, you can get a Mutator handle that provides
+direct access to the underlying object. As long as the Mutator is in scope, it
+is invalid to access the underlying Persistent, but you'll be able to directly
+modify the object in place. Once the Mutator goes out of scope, the Persistent
+object's checksum is updated to reflect the changes.
+
+.. code-block:: cpp
+
+    #include "pw_persistent_ram/persistent.h"
+    #include "pw_preprocessor/compiler.h"
+
+    using pw::persistent_ram::Persistent;
+
+    contexpr size_t kMaxReasonLength = 256;
+
+    struct LastCrashInfo {
+      uint32_t uptime_ms;
+      uint32_t boot_id;
+      char reason[kMaxReasonLength];
+    }
+
+    PW_KEEP_IN_SECTION(".noinit") Persistent<LastBootInfo> persistent_crash_info;
+
+    void HandleCrash(const char* fmt, va_list args) {
+      // Once this scope ends, we know the persistent object has been updated
+      // to reflect changes.
+      {
+        auto& mutable_crash_info =
+            persistent_crash_info.mutator(GetterAction::kReset);
+        vsnprintf(mutable_crash_info->reason,
+                  sizeof(mutable_crash_info->reason),
+                  fmt,
+                  args);
+        mutable_crash_info->uptime_ms = system::GetUptimeMs();
+        mutable_crash_info->boot_id = system::GetBootId();
+      }
+      // ...
+    }
+
+    int main() {
+      if (persistent_crash_info.has_value()) {
+        LogLastCrashInfo(persistent_crash_info.value());
+        // Clear crash info once it has been dumped.
+        persistent_crash_info.reset();
+      }
+
+      // ... rest of main
+    }
+
+------------------------------------
+pw::persistent_ram::PersistentBuffer
+------------------------------------
+The PersistentBuffer is a persistent storage container for variable-length
+serialized data. Rather than allowing direct access to the underlying buffer for
+random-access mutations, the PersistentBuffer is mutable through a
+PersistentBufferWriter that implements the pw::stream::Writer interface. This
+removes the potential for logical errors due to RAII or open()/close() semantics
+as both the PersistentBuffer and PersistentBufferWriter can be used validly as
+long as their access is serialized.
+
+Example
+-------
+An example use case is emitting crash handler logs to a buffer for them to be
+available after a the device reboots. Once the device reboots, the logs would be
+emitted by the logging system. While this isn't always practical for plaintext
+logs, tokenized logs are small enough for this to be useful.
+
+.. code-block:: cpp
+
+    #include "pw_persistent_ram/persistent_buffer.h"
+    #include "pw_preprocessor/compiler.h"
+
+    using pw::persistent_ram::PersistentBuffer;
+    using pw::persistent_ram::PersistentBuffer::PersistentBufferWriter;
+
+    PW_KEEP_IN_SECTION(".noinit") PersistentBuffer<2048> crash_logs;
+    void CheckForCrashLogs() {
+      if (crash_logs.has_value()) {
+        // A function that dumps sequentially serialized logs using pw_log.
+        DumpRawLogs(crash_logs.written_data());
+        crash_logs.clear();
+      }
+    }
+
+    void HandleCrash(CrashInfo* crash_info) {
+      PersistentBufferWriter crash_log_writer = crash_logs.GetWriter();
+      // Sets the pw::stream::Writer that pw_log should dump logs to.
+      crash_log_writer.clear();
+      SetLogSink(crash_log_writer);
+      // Handle crash, calling PW_LOG to log useful info.
+    }
+
+    int main() {
+      void CheckForCrashLogs();
+      // ... rest of main
+    }
+
+Size Report
+-----------
+The following size report showcases the overhead for using Persistent. Note that
+this is templating the Persistent only on a ``uint32_t``, ergo the cost without
+pw_checksum's CRC16 is the approximate cost per type.
+
+.. include:: persistent_size
+
+Compatibility
+-------------
+* C++17
+
+Dependencies
+------------
+* ``pw_checksum``
diff --git a/pw_persistent_ram/persistent_buffer.cc b/pw_persistent_ram/persistent_buffer.cc
new file mode 100644
index 0000000..9cee8fc
--- /dev/null
+++ b/pw_persistent_ram/persistent_buffer.cc
@@ -0,0 +1,43 @@
+// Copyright 2021 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.
+#include "pw_persistent_ram/persistent_buffer.h"
+
+#include "pw_bytes/span.h"
+#include "pw_checksum/crc16_ccitt.h"
+#include "pw_status/status.h"
+
+namespace pw::persistent_ram {
+
+Status PersistentBufferWriter::DoWrite(ConstByteSpan data) {
+  if (ConservativeWriteLimit() == 0) {
+    return Status::OutOfRange();
+  }
+  if (ConservativeWriteLimit() < data.size_bytes()) {
+    return Status::ResourceExhausted();
+  }
+  if (data.empty()) {
+    return OkStatus();
+  }
+
+  std::memcpy(buffer_.data() + size_, data.data(), data.size_bytes());
+
+  // Only checksum newly written data.
+  checksum_ = checksum::Crc16Ccitt::Calculate(
+      ByteSpan(buffer_.data() + size_, data.size_bytes()), checksum_);
+  size_ += data.size_bytes();
+
+  return OkStatus();
+}
+
+}  // namespace pw::persistent_ram
diff --git a/pw_persistent_ram/persistent_buffer_test.cc b/pw_persistent_ram/persistent_buffer_test.cc
new file mode 100644
index 0000000..cf72fc2
--- /dev/null
+++ b/pw_persistent_ram/persistent_buffer_test.cc
@@ -0,0 +1,158 @@
+// Copyright 2021 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.
+#include "pw_persistent_ram/persistent_buffer.h"
+
+#include <cstddef>
+#include <span>
+#include <type_traits>
+
+#include "gtest/gtest.h"
+#include "pw_bytes/span.h"
+#include "pw_random/xor_shift.h"
+
+namespace pw::persistent_ram {
+namespace {
+
+class PersistentTest : public ::testing::Test {
+ protected:
+  static constexpr size_t kBufferSize = 256;
+  PersistentTest() { ZeroPersistentMemory(); }
+
+  // Emulate invalidation of persistent section(s).
+  void ZeroPersistentMemory() { memset(buffer_, 0, sizeof(buffer_)); }
+  void RandomFillMemory() {
+    random::XorShiftStarRng64 rng(0x9ad75);
+    StatusWithSize sws = rng.Get(buffer_);
+    ASSERT_TRUE(sws.ok());
+    ASSERT_EQ(sws.size(), sizeof(buffer_));
+  }
+
+  PersistentBuffer<kBufferSize>& GetPersistentBuffer() {
+    return *(new (buffer_) PersistentBuffer<kBufferSize>());
+  }
+
+  // Allocate a chunk of aligned storage that can be independently controlled.
+  alignas(PersistentBuffer<kBufferSize>)
+      std::byte buffer_[sizeof(PersistentBuffer<kBufferSize>)];
+};
+
+TEST_F(PersistentTest, DefaultConstructionAndDestruction) {
+  constexpr uint32_t kExpectedNumber = 0x6C2C6582;
+  {
+    // Emulate a boot where the persistent sections were invalidated.
+    // Although the fixture always does this, we do this an extra time to be
+    // 100% confident that an integrity check cannot be accidentally selected
+    // which results in reporting there is valid data when zero'd.
+    ZeroPersistentMemory();
+    auto& persistent = GetPersistentBuffer();
+    auto writer = persistent.GetWriter();
+    EXPECT_EQ(persistent.size(), 0u);
+
+    writer.Write(std::as_bytes(std::span(&kExpectedNumber, 1)));
+    ASSERT_TRUE(persistent.has_value());
+
+    persistent.~PersistentBuffer();  // Emulate shutdown / global destructors.
+  }
+
+  {  // Emulate a boot where persistent memory was kept as is.
+    auto& persistent = GetPersistentBuffer();
+    ASSERT_TRUE(persistent.has_value());
+    EXPECT_EQ(persistent.size(), sizeof(kExpectedNumber));
+
+    uint32_t temp = 0;
+    memcpy(&temp, persistent.data(), sizeof(temp));
+    EXPECT_EQ(temp, kExpectedNumber);
+  }
+}
+
+TEST_F(PersistentTest, LongData) {
+  constexpr std::string_view kTestString(
+      "A nice string should remain valid even if written incrementally!");
+  constexpr size_t kWriteSize = 5;
+
+  {  // Initialize the buffer.
+    RandomFillMemory();
+    auto& persistent = GetPersistentBuffer();
+    ASSERT_FALSE(persistent.has_value());
+
+    auto writer = persistent.GetWriter();
+    for (size_t i = 0; i < kTestString.length(); i += kWriteSize) {
+      writer.Write(kTestString.data() + i,
+                   std::min(kWriteSize, kTestString.length() - i));
+    }
+    // Need to manually write a null terminator since std::string_view doesn't
+    // include one in the string length.
+    writer.Write(std::byte(0));
+
+    persistent.~PersistentBuffer();  // Emulate shutdown / global destructors.
+  }
+
+  {  // Ensure data is valid.
+    auto& persistent = GetPersistentBuffer();
+    ASSERT_TRUE(persistent.has_value());
+    ASSERT_STREQ(kTestString.data(),
+                 reinterpret_cast<const char*>(persistent.data()));
+  }
+}
+
+TEST_F(PersistentTest, ZeroDataIsNoValue) {
+  ZeroPersistentMemory();
+  auto& persistent = GetPersistentBuffer();
+  EXPECT_FALSE(persistent.has_value());
+}
+
+TEST_F(PersistentTest, RandomDataIsInvalid) {
+  RandomFillMemory();
+  auto& persistent = GetPersistentBuffer();
+  ASSERT_FALSE(persistent.has_value());
+}
+
+TEST_F(PersistentTest, AppendingData) {
+  constexpr std::string_view kTestString("Test string one!");
+  constexpr uint32_t kTestNumber = 42;
+
+  {  // Initialize the buffer.
+    RandomFillMemory();
+    auto& persistent = GetPersistentBuffer();
+    auto writer = persistent.GetWriter();
+    EXPECT_EQ(persistent.size(), 0u);
+
+    // Write an integer.
+    writer.Write(std::as_bytes(std::span(&kTestNumber, 1)));
+    ASSERT_TRUE(persistent.has_value());
+
+    persistent.~PersistentBuffer();  // Emulate shutdown / global destructors.
+  }
+
+  {  // Get a pointer to the buffer and validate the contents.
+    auto& persistent = GetPersistentBuffer();
+    ASSERT_TRUE(persistent.has_value());
+    EXPECT_EQ(persistent.size(), sizeof(kTestNumber));
+
+    // Write more data.
+    auto writer = persistent.GetWriter();
+    EXPECT_EQ(persistent.size(), sizeof(kTestNumber));
+    writer.Write(std::as_bytes(std::span<const char>(kTestString)));
+
+    persistent.~PersistentBuffer();  // Emulate shutdown / global destructors.
+  }
+  {  // Ensure data was appended.
+    auto& persistent = GetPersistentBuffer();
+    ASSERT_TRUE(persistent.has_value());
+    EXPECT_EQ(persistent.size(), sizeof(kTestNumber) + kTestString.length());
+  }
+}
+
+}  // namespace
+}  // namespace pw::persistent_ram
diff --git a/pw_persistent_ram/persistent_test.cc b/pw_persistent_ram/persistent_test.cc
new file mode 100644
index 0000000..bd3de4f
--- /dev/null
+++ b/pw_persistent_ram/persistent_test.cc
@@ -0,0 +1,176 @@
+// Copyright 2021 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.
+#include "pw_persistent_ram/persistent.h"
+
+#include <type_traits>
+
+#include "gtest/gtest.h"
+#include "pw_random/xor_shift.h"
+
+namespace pw::persistent_ram {
+namespace {
+
+class PersistentTest : public ::testing::Test {
+ protected:
+  PersistentTest() { ZeroPersistentMemory(); }
+
+  // Emulate invalidation of persistent section(s).
+  void ZeroPersistentMemory() { memset(&buffer_, 0, sizeof(buffer_)); }
+
+  // Allocate a chunk of aligned storage that can be independently controlled.
+  std::aligned_storage_t<sizeof(Persistent<uint32_t>),
+                         alignof(Persistent<uint32_t>)>
+      buffer_;
+};
+
+TEST_F(PersistentTest, DefaultConstructionAndDestruction) {
+  {  // Emulate a boot where the persistent sections were invalidated.
+    // Although the fixture always does this, we do this an extra time to be
+    // 100% confident that an integrity check cannot be accidentally selected
+    // which results in reporting there is valid data when zero'd.
+    ZeroPersistentMemory();
+    auto& persistent = *(new (&buffer_) Persistent<uint32_t>());
+    EXPECT_FALSE(persistent.has_value());
+
+    persistent = 42;
+    ASSERT_TRUE(persistent.has_value());
+    EXPECT_EQ(42u, persistent.value());
+
+    persistent.~Persistent();  // Emulate shutdown / global destructors.
+  }
+
+  {  // Emulate a boot where persistent memory was kept as is.
+    auto& persistent = *(new (&buffer_) Persistent<uint32_t>());
+    ASSERT_TRUE(persistent.has_value());
+    EXPECT_EQ(42u, persistent.value());
+  }
+}
+
+TEST_F(PersistentTest, Reset) {
+  {  // Emulate a boot where the persistent sections were invalidated.
+    auto& persistent = *(new (&buffer_) Persistent<uint32_t>());
+    persistent = 42u;
+    EXPECT_TRUE(persistent.has_value());
+    persistent.reset();
+
+    persistent.~Persistent();  // Emulate shutdown / global destructors.
+  }
+
+  {  // Emulate a boot where persistent memory was kept as is.
+    auto& persistent = *(new (&buffer_) Persistent<uint32_t>());
+    EXPECT_FALSE(persistent.has_value());
+  }
+}
+
+TEST_F(PersistentTest, Emplace) {
+  auto& persistent = *(new (&buffer_) Persistent<uint32_t>());
+  EXPECT_FALSE(persistent.has_value());
+
+  persistent.emplace(42u);
+  ASSERT_TRUE(persistent.has_value());
+  EXPECT_EQ(42u, persistent.value());
+}
+
+class MutablePersistentTest : public ::testing::Test {
+ protected:
+  struct Coordinate {
+    int x;
+    int y;
+    int z;
+  };
+  MutablePersistentTest() { ZeroPersistentMemory(); }
+
+  // Emulate invalidation of persistent section(s).
+  void ZeroPersistentMemory() { memset(&buffer_, 0, sizeof(buffer_)); }
+  void RandomFillMemory() {
+    random::XorShiftStarRng64 rng(0x9ad75);
+    StatusWithSize sws = rng.Get(std::span<std::byte>(
+        reinterpret_cast<std::byte*>(&buffer_), sizeof(buffer_)));
+    ASSERT_TRUE(sws.ok());
+    ASSERT_EQ(sws.size(), sizeof(buffer_));
+  }
+
+  // Allocate a chunk of aligned storage that can be independently controlled.
+  std::aligned_storage_t<sizeof(Persistent<Coordinate>),
+                         alignof(Persistent<Coordinate>)>
+      buffer_;
+};
+
+TEST_F(MutablePersistentTest, DefaultConstructionAndDestruction) {
+  {
+    // Emulate a boot where the persistent sections were invalidated.
+    // Although the fixture always does this, we do this an extra time to be
+    // 100% confident that an integrity check cannot be accidentally selected
+    // which results in reporting there is valid data when zero'd.
+    ZeroPersistentMemory();
+    auto& persistent = *(new (&buffer_) Persistent<Coordinate>());
+    EXPECT_FALSE(persistent.has_value());
+
+    // Default construct of a Coordinate.
+    persistent.emplace(Coordinate({.x = 5, .y = 6, .z = 7}));
+    ASSERT_TRUE(persistent.has_value());
+    {
+      auto mutable_persistent = persistent.mutator();
+      mutable_persistent->x = 42;
+      (*mutable_persistent).y = 1337;
+      mutable_persistent->z = -99;
+      ASSERT_FALSE(persistent.has_value());
+    }
+
+    EXPECT_EQ(1337, persistent.value().y);
+    EXPECT_EQ(-99, persistent.value().z);
+
+    persistent.~Persistent();  // Emulate shutdown / global destructors.
+  }
+
+  {
+    // Emulate a boot where persistent memory was kept as is.
+    auto& persistent = *(new (&buffer_) Persistent<Coordinate>());
+    ASSERT_TRUE(persistent.has_value());
+    EXPECT_EQ(42, persistent.value().x);
+  }
+}
+
+TEST_F(MutablePersistentTest, ResetObject) {
+  {
+    // Emulate a boot where the persistent sections were lost and ended up in
+    // random data.
+    RandomFillMemory();
+    auto& persistent = *(new (&buffer_) Persistent<Coordinate>());
+
+    // Default construct of a Coordinate.
+    ASSERT_FALSE(persistent.has_value());
+    {
+      auto mutable_persistent = persistent.mutator(GetterAction::kReset);
+      mutable_persistent->x = 42;
+    }
+
+    EXPECT_EQ(42, persistent.value().x);
+    EXPECT_EQ(0, persistent.value().y);
+    EXPECT_EQ(0, persistent.value().z);
+
+    persistent.~Persistent();  // Emulate shutdown / global destructors.
+  }
+
+  {
+    // Emulate a boot where persistent memory was kept as is.
+    auto& persistent = *(new (&buffer_) Persistent<Coordinate>());
+    ASSERT_TRUE(persistent.has_value());
+    EXPECT_EQ(42, persistent.value().x);
+    EXPECT_EQ(0, persistent.value().y);
+  }
+}
+
+}  // namespace
+}  // namespace pw::persistent_ram
diff --git a/pw_persistent_ram/public/pw_persistent_ram/persistent.h b/pw_persistent_ram/public/pw_persistent_ram/persistent.h
new file mode 100644
index 0000000..6067d3b
--- /dev/null
+++ b/pw_persistent_ram/public/pw_persistent_ram/persistent.h
@@ -0,0 +1,176 @@
+// Copyright 2021 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.
+#pragma once
+
+#include <cstdint>
+#include <cstring>
+#include <span>
+#include <type_traits>
+#include <utility>
+
+#include "pw_assert/light.h"
+#include "pw_checksum/crc16_ccitt.h"
+
+namespace pw::persistent_ram {
+
+// Behavior to use when attempting to get a handle to the underlying data stored
+// in persistent memory.
+enum class GetterAction {
+  // Default-construct the object before returning a handle.
+  kReset,
+  // Assert that the object is valid before returning a handle.
+  kAssertValid,
+};
+
+// The Persistent class intentionally uses uninitialized memory, which triggers
+// compiler warnings. Disable those warnings for this file.
+PW_MODIFY_DIAGNOSTICS_PUSH();
+PW_MODIFY_DIAGNOSTIC(ignored, "-Wuninitialized");
+PW_MODIFY_DIAGNOSTIC_GCC(ignored, "-Wmaybe-uninitialized");
+
+// A simple container for holding a value T with CRC16 integrity checking.
+//
+// A Persistent is simply a value T plus integrity checking for use in a
+// persistent RAM section which is not initialized on boot.
+//
+// WARNING: Unlike a DoubleBufferedPersistent, a Persistent will be lost if a
+// write/set operation is interrupted or otherwise not completed.
+//
+// TODO(pwbug/348): Consider a different integrity check implementation which
+// does not use a 512B lookup table.
+template <typename T>
+class Persistent {
+ public:
+  // This object provides mutable access to the underlying object of a
+  // Persistent<T>.
+  //
+  // WARNING: This object must remain in scope for any modifications of the
+  // Underlying object. If the object is modified after the Mutator goes out
+  // of scope, the CRC will not be updated to reflect changes, invalidating the
+  // contents of the Persistent<T>!
+  //
+  // WARNING: Persistent<T>::has_value() will return false if there are
+  // in-flight modifications by a Mutator that have not yet been flushed.
+  class Mutator {
+   public:
+    explicit constexpr Mutator(Persistent<T>& persistent)
+        : persistent_(persistent) {}
+    ~Mutator() { persistent_.crc_ = persistent_.CalculateCrc(); }
+
+    Mutator(const Mutator&) = delete;  // Copy constructor is disabled.
+
+    T* operator->() { return const_cast<T*>(&persistent_.contents_); }
+
+    // Be careful when sharing a reference or pointer to the underlying object.
+    // Once the Mutator goes out of scope, any changes to the object will
+    // invalidate the checksum. Avoid directly using the underlying object
+    // unless you need to pass it to a function.
+    T& value() { return persistent_.contents_; }
+    T& operator*() { return *const_cast<T*>(&persistent_.contents_); }
+
+   private:
+    Persistent<T>& persistent_;
+  };
+
+  // Constructor which does nothing, meaning it never sets the value.
+  constexpr Persistent() {}
+
+  Persistent(const Persistent&) = delete;  // Copy constructor is disabled.
+  Persistent(Persistent&&) = delete;       // Move constructor is disabled.
+  ~Persistent() {}                         // The destructor does nothing.
+
+  // Construct the value in-place.
+  template <class... Args>
+  const T& emplace(Args&&... args) {
+    new (const_cast<T*>(&contents_)) T(std::forward<Args>(args)...);
+    crc_ = CalculateCrc();
+    return const_cast<T&>(contents_);
+  }
+
+  // Assignment operator.
+  template <typename U = T>
+  Persistent& operator=(U&& value) {
+    contents_ = std::move(value);
+    crc_ = CalculateCrc();
+    return *this;
+  }
+
+  // Destroys any contained value.
+  void reset() {
+    // The trivial destructor is skipped as it's trivial.
+    std::memset(const_cast<T*>(&contents_), 0, sizeof(contents_));
+    crc_ = 0;
+  }
+
+  // Returns true if a value is held by the Persistent.
+  bool has_value() const {
+    return crc_ == CalculateCrc();  // There's a value if its CRC matches.
+  }
+
+  // Access the value.
+  //
+  // Precondition: has_value() must be true.
+  const T& value() const {
+    PW_ASSERT(has_value());
+    return const_cast<T&>(contents_);
+  }
+
+  // Get a mutable handle to the underlying data.
+  //
+  // Args:
+  //   action: Whether to default-construct the underlying value before
+  //           providing a mutator, or to assert that the object is valid
+  //           without modifying the underlying data.
+  // Precondition: has_value() must be true.
+  Mutator mutator(GetterAction action = GetterAction::kAssertValid) {
+    if (action == GetterAction::kReset) {
+      emplace();
+    } else {
+      PW_ASSERT(has_value());
+    }
+    return Mutator(*this);
+  }
+
+ private:
+  friend class Mutator;
+
+  static_assert(std::is_trivially_copy_constructible<T>::value,
+                "If a Persistent persists across reboots, it is effectively "
+                "loaded through a trivial copy constructor.");
+
+  static_assert(std::is_trivially_destructible<T>::value,
+                "A Persistent's destructor does not invoke the value's "
+                "destructor, ergo only trivially destructible types are "
+                "supported.");
+
+  uint16_t CalculateCrc() const {
+    return checksum::Crc16Ccitt::Calculate(
+        std::as_bytes(std::span(const_cast<const T*>(&contents_), 1)));
+  }
+
+  // Use unions to denote that these members are never initialized by design and
+  // on purpose. Volatile is used to ensure that the compiler cannot optimize
+  // out operations where it seems like there is no further usage of a
+  // Persistent as this may be on the next boot.
+  union {
+    volatile T contents_;
+  };
+  union {
+    volatile uint16_t crc_;
+  };
+};
+
+PW_MODIFY_DIAGNOSTICS_POP();
+
+}  // namespace pw::persistent_ram
diff --git a/pw_persistent_ram/public/pw_persistent_ram/persistent_buffer.h b/pw_persistent_ram/public/pw_persistent_ram/persistent_buffer.h
new file mode 100644
index 0000000..3f1d4e9
--- /dev/null
+++ b/pw_persistent_ram/public/pw_persistent_ram/persistent_buffer.h
@@ -0,0 +1,145 @@
+// Copyright 2021 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.
+#pragma once
+
+#include <cstdint>
+#include <cstring>
+#include <span>
+#include <type_traits>
+#include <utility>
+
+#include "pw_bytes/span.h"
+#include "pw_checksum/crc16_ccitt.h"
+#include "pw_preprocessor/compiler.h"
+#include "pw_status/status.h"
+#include "pw_stream/stream.h"
+
+namespace pw::persistent_ram {
+
+// A PersistentBufferWriter implements the pw::stream::Writer interface and
+// provides handles to mutate and access the underlying data of a
+// PersistentBuffer. This object should NOT be stored in persistent RAM.
+//
+// Only one writer should be open at a given time.
+class PersistentBufferWriter : public stream::Writer {
+ public:
+  PersistentBufferWriter() = delete;
+
+  size_t ConservativeWriteLimit() const override {
+    return buffer_.size_bytes() - size_;
+  }
+
+ private:
+  template <size_t>
+  friend class PersistentBuffer;
+
+  PersistentBufferWriter(ByteSpan buffer,
+                         volatile size_t& size,
+                         volatile uint16_t& checksum)
+      : buffer_(buffer), size_(size), checksum_(checksum) {}
+
+  // Implementation for writing data to this stream.
+  Status DoWrite(ConstByteSpan data) override;
+
+  ByteSpan buffer_;
+  volatile size_t& size_;
+  volatile uint16_t& checksum_;
+};
+
+// The PersistentBuffer class intentionally uses uninitialized memory, which
+// triggers compiler warnings. Disable those warnings for this file.
+PW_MODIFY_DIAGNOSTICS_PUSH();
+PW_MODIFY_DIAGNOSTIC(ignored, "-Wuninitialized");
+PW_MODIFY_DIAGNOSTIC_GCC(ignored, "-Wmaybe-uninitialized");
+
+// When a PersistentBuffer is statically allocated in persistent memory, its
+// state will persist across soft resets in accordance with the expected
+// behavior of the underlying RAM. This object is completely safe to use before
+// static constructors are called as its constructor is effectively a no-op.
+//
+// While the stored data can be read by PersistentBuffer's public functions,
+// each public function must validate the integrity of the stored data. It's
+// typically more performant to get a handle to a PersistentBufferWriter
+// instead, as data is validated on creation of the PersistentBufferWriter,
+// which allows access to the underlying data without needing to validate the
+// data's integrity with each call to PersistentBufferWriter functions.
+template <size_t kMaxSizeBytes>
+class PersistentBuffer {
+ public:
+  // The default constructor intentionally does not initialize anything. This
+  // allows a persistent buffer statically allocated in persistent RAM to be
+  // highly available.
+  //
+  // Explicitly declaring an empty constructor rather than using the default
+  // constructor prevents the object from being zero-initialized when the object
+  // is value initialized. If this was left as a default constructor,
+  // PersistentBuffer objects declared as value-initialized would be
+  // zero-initialized.
+  //
+  //   // Value initialization:
+  //   PersistentBuffer<256> persistent_buffer();
+  //
+  //   // Default initialization:
+  //   PersistentBuffer<256> persistent_buffer;
+  PersistentBuffer() {}
+  // Disable copy and move constructors.
+  PersistentBuffer(const PersistentBuffer&) = delete;
+  PersistentBuffer(PersistentBuffer&&) = delete;
+  // Explicit no-op destructor.
+  ~PersistentBuffer() {}
+
+  PersistentBufferWriter GetWriter() {
+    if (!has_value()) {
+      clear();
+    }
+    return PersistentBufferWriter(
+        ByteSpan(const_cast<std::byte*>(buffer_), kMaxSizeBytes),
+        size_,
+        checksum_);
+  }
+
+  size_t size() const {
+    if (has_value()) {
+      return size_;
+    }
+    return 0;
+  }
+
+  const std::byte* data() const { return const_cast<std::byte*>(buffer_); }
+
+  void clear() {
+    size_ = 0;
+    checksum_ = checksum::Crc16Ccitt::kInitialValue;
+  }
+
+  bool has_value() const {
+    if (size_ > kMaxSizeBytes || size_ == 0) {
+      return false;
+    }
+
+    // Check checksum. This is more costly.
+    return checksum_ == checksum::Crc16Ccitt::Calculate(ConstByteSpan(
+                            const_cast<std::byte*>(buffer_), size_));
+  }
+
+ private:
+  // None of these members are initialized by the constructor by design.
+  volatile uint16_t checksum_;
+  volatile size_t size_;
+  volatile std::byte buffer_[kMaxSizeBytes];
+};
+
+PW_MODIFY_DIAGNOSTICS_POP();
+
+}  // namespace pw::persistent_ram
diff --git a/pw_persistent_ram/size_report/BUILD b/pw_persistent_ram/size_report/BUILD
new file mode 100644
index 0000000..11f4091
--- /dev/null
+++ b/pw_persistent_ram/size_report/BUILD
@@ -0,0 +1,35 @@
+# Copyright 2021 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.
+
+load(
+    "//pw_build:pigweed.bzl",
+    "pw_cc_binary",
+)
+
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])  # Apache License 2.0
+
+pw_cc_binary(
+    name = "persistent",
+    srcs = [
+        "persistent.cc",
+        "persistent_base.cc",
+        "persistent_base_with_crc16.cc",
+    ],
+    deps = [
+        "//pw_bloat:bloat_this_binary",
+        "//pw_persistent_ram:persistent",
+    ],
+)
diff --git a/pw_persistent_ram/size_report/BUILD.gn b/pw_persistent_ram/size_report/BUILD.gn
new file mode 100644
index 0000000..afc1661
--- /dev/null
+++ b/pw_persistent_ram/size_report/BUILD.gn
@@ -0,0 +1,38 @@
+# Copyright 2021 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/target_types.gni")
+
+_persistent_deps = [
+  "$dir_pw_bloat:bloat_this_binary",
+  dir_pw_persistent_ram,
+]
+
+pw_executable("persistent") {
+  sources = [ "persistent.cc" ]
+  deps = _persistent_deps
+}
+
+pw_executable("persistent_base") {
+  sources = [ "persistent_base.cc" ]
+  deps = _persistent_deps
+}
+
+pw_executable("persistent_base_with_crc16") {
+  sources = [ "persistent_base_with_crc16.cc" ]
+  deps = _persistent_deps
+  deps += [ dir_pw_checksum ]
+}
diff --git a/pw_persistent_ram/size_report/persistent.cc b/pw_persistent_ram/size_report/persistent.cc
new file mode 100644
index 0000000..8ffeafa
--- /dev/null
+++ b/pw_persistent_ram/size_report/persistent.cc
@@ -0,0 +1,40 @@
+// Copyright 2021 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.
+
+#include "pw_persistent_ram/persistent.h"
+
+#include "pw_bloat/bloat_this_binary.h"
+
+int main() {
+  pw::bloat::BloatThisBinary();
+
+  // Default constructor.
+  pw::persistent_ram::Persistent<uint32_t> persistent;
+
+  // Emplace to construct value in place.
+  persistent.emplace(42u);
+
+  // Assignment operator.
+  persistent = 13u;
+
+  // Reset.
+  persistent.reset();
+
+  // Has value and value accesstors.
+  if (persistent.has_value() && persistent.value() == 0u) {
+    return 1;
+  }
+
+  return 0;
+}
diff --git a/pw_persistent_ram/size_report/persistent_base.cc b/pw_persistent_ram/size_report/persistent_base.cc
new file mode 100644
index 0000000..9868cc0
--- /dev/null
+++ b/pw_persistent_ram/size_report/persistent_base.cc
@@ -0,0 +1,41 @@
+// Copyright 2021 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.
+
+#include <cstdint>
+#include <utility>
+
+#include "pw_bloat/bloat_this_binary.h"
+
+int main() {
+  pw::bloat::BloatThisBinary();
+
+  // Default constructor.
+  volatile uint32_t value;
+
+  // Emplace to construct value in place.
+  value = std::move(42);
+
+  // Assignment operator.
+  value = 13u;
+
+  // Reset.
+  value = 0u;
+
+  // Has value and value accesstors.
+  if (value == 0u) {
+    return 1;
+  }
+
+  return 0;
+}
diff --git a/pw_persistent_ram/size_report/persistent_base_with_crc16.cc b/pw_persistent_ram/size_report/persistent_base_with_crc16.cc
new file mode 100644
index 0000000..a4e18e3
--- /dev/null
+++ b/pw_persistent_ram/size_report/persistent_base_with_crc16.cc
@@ -0,0 +1,46 @@
+// Copyright 2021 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.
+
+#include <cstdint>
+#include <utility>
+
+#include "pw_bloat/bloat_this_binary.h"
+#include "pw_checksum/crc16_ccitt.h"
+
+int main() {
+  pw::bloat::BloatThisBinary();
+
+  // Default constructor.
+  volatile uint32_t value;
+
+  // Emplace to construct value in place.
+  value = std::move(42);
+
+  // Assignment operator.
+  value = 13u;
+
+  // Reset.
+  value = 0u;
+
+  // Has value and value accesstors.
+  if (value == 0u) {
+    return 1;
+  }
+
+  // Use CRC16.
+  value = pw::checksum::Crc16Ccitt::Calculate(
+      std::as_bytes(std::span(const_cast<uint32_t*>(&value), 1)));
+
+  return 0;
+}
diff --git a/pw_polyfill/BUILD b/pw_polyfill/BUILD
index 976b45a..1b2f74d 100644
--- a/pw_polyfill/BUILD
+++ b/pw_polyfill/BUILD
@@ -1,4 +1,4 @@
-# Copyright 2020 The Pigweed Authors
+# Copyright 2021 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
@@ -17,11 +17,48 @@
     "pw_cc_library",
     "pw_cc_test",
 )
+load(
+    "@bazel_embedded//toolchains/tools/include_tools:defs.bzl",
+    "cc_injected_toolchain_header_library",
+    "cc_polyfill_toolchain_library",
+)
 
 package(default_visibility = ["//visibility:public"])
 
 licenses(["notice"])  # Apache License 2.0
 
+cc_injected_toolchain_header_library(
+    name = "toolchain_injected_headers",
+    hdrs = ["language_features.h"],
+)
+
+cc_polyfill_toolchain_library(
+    name = "toolchain_polyfill_overrides",
+    hdrs = [
+        "language_features.h",
+        "public_overrides/array",
+        "public_overrides/assert.h",
+        "public_overrides/bit",
+        "public_overrides/cstddef",
+        "public_overrides/iterator",
+        "public_overrides/type_traits",
+        "public_overrides/utility",
+        "standard_library_public/pw_polyfill/standard_library/array.h",
+        "standard_library_public/pw_polyfill/standard_library/assert.h",
+        "standard_library_public/pw_polyfill/standard_library/bit.h",
+        "standard_library_public/pw_polyfill/standard_library/cstddef.h",
+        "standard_library_public/pw_polyfill/standard_library/iterator.h",
+        "standard_library_public/pw_polyfill/standard_library/namespace.h",
+        "standard_library_public/pw_polyfill/standard_library/type_traits.h",
+        "standard_library_public/pw_polyfill/standard_library/utility.h",
+    ],
+    system_includes = [
+        "public_overrides",
+        "public",
+        "standard_library_public",
+    ],
+)
+
 pw_cc_library(
     name = "pw_polyfill",
     hdrs = [
diff --git a/pw_polyfill/BUILD.gn b/pw_polyfill/BUILD.gn
index 7774294..b173e06 100644
--- a/pw_polyfill/BUILD.gn
+++ b/pw_polyfill/BUILD.gn
@@ -1,4 +1,4 @@
-# Copyright 2020 The Pigweed Authors
+# Copyright 2021 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
@@ -48,7 +48,10 @@
 pw_source_set("overrides") {
   public_configs = [ ":overrides_config" ]
   remove_public_deps = [ "*" ]
-  public_deps = [ ":standard_library" ]
+  public_deps = [
+    ":standard_library",
+    "$dir_pw_span:polyfill",
+  ]
   inputs = [
     "public_overrides/array",
     "public_overrides/assert.h",
@@ -90,6 +93,7 @@
     ":cpp11_test",
     ":cpp14_test",
   ]
+  group_deps = [ "$dir_pw_span:tests" ]
 }
 
 pw_test("default_cpp_test") {
diff --git a/pw_polyfill/standard_library_public/pw_polyfill/standard_library/namespace.h b/pw_polyfill/standard_library_public/pw_polyfill/standard_library/namespace.h
index abaab09..6c6bec5 100644
--- a/pw_polyfill/standard_library_public/pw_polyfill/standard_library/namespace.h
+++ b/pw_polyfill/standard_library_public/pw_polyfill/standard_library/namespace.h
@@ -13,9 +13,9 @@
 // the License.
 #pragma once
 
-// Clang uses a special namespace for standard library headers. Use this
+// libc++ uses a special namespace for standard library headers. Use this
 // namespace via the defines in <__config>.
-#if defined(__clang__) && __has_include(<__config>)
+#if defined(_LIBCPP_VERSION) && __has_include(<__config>)
 
 #include <__config>
 
@@ -27,11 +27,11 @@
 #define _PW_POLYFILL_BEGIN_NAMESPACE_STD namespace std {
 #define _PW_POLYFILL_END_NAMESPACE_STD }  // namespace std
 
-// Cannot compile with Clang / libc++ without the <__config> header.
-#ifdef __clang__
+// Cannot compile when using libc++ without the <__config> header.
+#ifdef _LIBCPP_VERSION
 static_assert(
     false,
-    "Compiling with Clang, but the <__config> header is not available. "
+    "Compiling against libc++, but the <__config> header is not available. "
     "The <__config> header provides various _LIBCPP defines used internally "
     "by libc++. pw_polyfill needs this header for the "
     "_LIBCPP_BEGIN_NAMESPACE_STD and _LIBCPP_END_NAMESPACE_STD macros, which "
@@ -43,6 +43,6 @@
     "<__config>, in which this file should be updated to properly "
     "set _PW_POLYFILL_BEGIN_NAMESPACE_STD and _PW_POLYFILL_END_NAMESPACE_STD.");
 
-#endif  // __clang__
+#endif  // _LIBCPP_VERSION
 
-#endif  // defined(__clang__) && __has_include(<__config>)
+#endif  // defined(_LIBCPP_VERSION) && __has_include(<__config>)
diff --git a/pw_polyfill/standard_library_public/pw_polyfill/standard_library/type_traits.h b/pw_polyfill/standard_library_public/pw_polyfill/standard_library/type_traits.h
index 33ea9d1..1d9b9b2 100644
--- a/pw_polyfill/standard_library_public/pw_polyfill/standard_library/type_traits.h
+++ b/pw_polyfill/standard_library_public/pw_polyfill/standard_library/type_traits.h
@@ -65,4 +65,33 @@
 
 #endif  // __cpp_lib_is_null_pointer
 
+#ifndef __cpp_lib_bool_constant
+#define __cpp_lib_bool_constant 201505L
+template <bool value>
+using bool_constant = integral_constant<bool, value>;
+#endif  // __cpp_lib_bool_constant
+
+#ifndef __cpp_lib_logical_traits
+#define __cpp_lib_logical_traits 201510L
+template <typename value>
+struct negation : bool_constant<!bool(value::value)> {};
+
+template <typename...>
+struct conjunction : std::true_type {};
+template <typename B1>
+struct conjunction<B1> : B1 {};
+template <typename B1, typename... Bn>
+struct conjunction<B1, Bn...>
+    : std::conditional_t<bool(B1::value), conjunction<Bn...>, B1> {};
+
+template <typename...>
+struct disjunction : std::false_type {};
+template <typename B1>
+struct disjunction<B1> : B1 {};
+template <typename B1, typename... Bn>
+struct disjunction<B1, Bn...>
+    : std::conditional_t<bool(B1::value), B1, disjunction<Bn...>> {};
+
+#endif  // __cpp_lib_logical_traits
+
 _PW_POLYFILL_END_NAMESPACE_STD
diff --git a/pw_polyfill/test.cc b/pw_polyfill/test.cc
index 27d2a19..497bd3b 100644
--- a/pw_polyfill/test.cc
+++ b/pw_polyfill/test.cc
@@ -188,6 +188,19 @@
   static_assert(std::make_index_sequence<123>::size() == 123);
 }
 
+TEST(Utility, LogicalTraits) {
+  static_assert(std::conjunction<std::true_type, std::true_type>::value);
+  static_assert(!std::conjunction<std::true_type, std::false_type>::value);
+  static_assert(!std::conjunction<std::false_type, std::false_type>::value);
+
+  static_assert(std::disjunction<std::true_type, std::true_type>::value);
+  static_assert(std::disjunction<std::true_type, std::false_type>::value);
+  static_assert(!std::disjunction<std::false_type, std::false_type>::value);
+
+  static_assert(!std::negation<std::true_type>::value);
+  static_assert(std::negation<std::false_type>::value);
+}
+
 }  // namespace
 }  // namespace polyfill
 }  // namespace pw
diff --git a/pw_preprocessor/BUILD b/pw_preprocessor/BUILD
index d0d14d7..46494d1 100644
--- a/pw_preprocessor/BUILD
+++ b/pw_preprocessor/BUILD
@@ -32,6 +32,7 @@
 TESTS = [
     "arguments_test",
     "boolean_test",
+    "compiler_test",
     "concat_test",
     "util_test",
 ]
diff --git a/pw_preprocessor/BUILD.gn b/pw_preprocessor/BUILD.gn
index 6f9a48f..95459e4 100644
--- a/pw_preprocessor/BUILD.gn
+++ b/pw_preprocessor/BUILD.gn
@@ -43,6 +43,7 @@
   tests = [
     ":arguments_test",
     ":boolean_test",
+    ":compiler_test",
     ":concat_test",
     ":util_test",
   ]
@@ -58,6 +59,11 @@
   sources = [ "boolean_test.cc" ]
 }
 
+pw_test("compiler_test") {
+  deps = [ ":pw_preprocessor" ]
+  sources = [ "compiler_test.cc" ]
+}
+
 pw_test("concat_test") {
   deps = [ ":pw_preprocessor" ]
   sources = [ "concat_test.cc" ]
diff --git a/pw_preprocessor/compiler_test.cc b/pw_preprocessor/compiler_test.cc
new file mode 100644
index 0000000..2fd79cf
--- /dev/null
+++ b/pw_preprocessor/compiler_test.cc
@@ -0,0 +1,50 @@
+// Copyright 2021 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.
+
+#include "pw_preprocessor/compiler.h"
+
+#include <cstdint>
+
+#include "gtest/gtest.h"
+
+namespace pw::preprocessor {
+namespace {
+
+PW_MODIFY_DIAGNOSTICS_PUSH();
+PW_MODIFY_DIAGNOSTIC(ignored, "-Wunused-variable");
+
+int this_variable_is_unused;
+
+PW_MODIFY_DIAGNOSTICS_POP();
+
+class Foo {
+  PW_MODIFY_DIAGNOSTICS_PUSH();
+  PW_MODIFY_DIAGNOSTIC(ignored, "-Wunused");
+
+  int this_field_is_unused;
+
+  PW_MODIFY_DIAGNOSTICS_POP();
+};
+
+TEST(CompilerMacros, ModifyDiagnostics) {
+  PW_MODIFY_DIAGNOSTICS_PUSH();
+  PW_MODIFY_DIAGNOSTIC(ignored, "-Wunused-variable");
+
+  int this_variable_also_is_unused;
+
+  PW_MODIFY_DIAGNOSTICS_POP();
+}
+
+}  // namespace
+}  // namespace pw::preprocessor
diff --git a/pw_preprocessor/docs.rst b/pw_preprocessor/docs.rst
index 9602a76..070a2bd 100644
--- a/pw_preprocessor/docs.rst
+++ b/pw_preprocessor/docs.rst
@@ -67,6 +67,48 @@
 --------------------------
 Macros for compiler-specific features, such as attributes or builtins.
 
+Modifying compiler diagnostics
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+``pw_preprocessor/compiler.h`` provides macros for enabling or disabling
+compiler diagnostics (warnings or errors).
+
+.. c:macro:: PW_MODIFY_DIAGNOSTICS_PUSH()
+
+  Starts a new group of :c:macro:`PW_MODIFY_DIAGNOSTIC` statements. A
+  :c:macro:`PW_MODIFY_DIAGNOSTICS_POP` statement must follow.
+
+.. c:macro:: PW_MODIFY_DIAGNOSTICS_POP()
+
+  :c:macro:`PW_MODIFY_DIAGNOSTIC` statements since the most recent
+  :c:macro:`PW_MODIFY_DIAGNOSTICS_PUSH` no longer apply after this statement.
+
+.. c:macro:: PW_MODIFY_DIAGNOSTIC(kind, option)
+
+  Changes how a diagnostic (warning or error) is handled. Most commonly used to
+  disable warnings. ``PW_MODIFY_DIAGNOSTIC`` should be used between
+  :c:macro:`PW_MODIFY_DIAGNOSTICS_PUSH` and :c:macro:`PW_MODIFY_DIAGNOSTICS_POP`
+  statements to avoid applying the modifications too broadly.
+
+  ``kind`` may be ``warning``, ``error``, or ``ignored``.
+
+These macros can be used to disable warnings for precise sections of code, even
+a single line if necessary.
+
+.. code-block:: c
+
+  PW_MODIFY_DIAGNOSTICS_PUSH();
+  PW_MODIFY_DIAGNOSTIC(ignored, "-Wunused-variable");
+
+  static int this_variable_is_never_used;
+
+  PW_MODIFY_DIAGNOSTICS_POP();
+
+.. tip::
+
+  :c:macro:`PW_MODIFY_DIAGNOSTIC` and related macros should rarely be used.
+  Whenever possible, fix the underlying issues about which the compiler is
+  warning, rather than silencing the diagnostics.
+
 pw_preprocessor/concat.h
 ------------------------
 Defines the ``PW_CONCAT(...)`` macro, which expands its arguments if they are
@@ -78,7 +120,6 @@
 General purpose, useful macros.
 
 * ``PW_ARRAY_SIZE(array)`` -- calculates the size of a C array
-* ``PW_UNUSED(value)`` -- silences "unused variable" compiler warnings
 * ``PW_STRINGIFY(...)`` -- expands its arguments as macros and converts them to
   a string literal
 * ``PW_EXTERN_C`` -- declares a name to be ``extern "C"`` in C++; expands to
diff --git a/pw_preprocessor/public/pw_preprocessor/compiler.h b/pw_preprocessor/public/pw_preprocessor/compiler.h
index 8d002f9..91fbc13 100644
--- a/pw_preprocessor/public/pw_preprocessor/compiler.h
+++ b/pw_preprocessor/public/pw_preprocessor/compiler.h
@@ -16,6 +16,8 @@
 // This file is used by both C++ and C code.
 #pragma once
 
+#include <assert.h>
+
 // Marks a struct or class as packed.
 #define PW_PACKED(declaration) declaration __attribute__((packed))
 
@@ -106,3 +108,46 @@
 #else
 #define PW_NO_SANITIZE(check)
 #endif  // __clang__
+
+// Wrapper around `__has_attribute`, which is defined by GCC 5+ and Clang and
+// evaluates to a non zero constant integer if the attribute is supported or 0
+// if not.
+#ifdef __has_attribute
+#define PW_HAVE_ATTRIBUTE(x) __has_attribute(x)
+#else
+#define PW_HAVE_ATTRIBUTE(x) 0
+#endif
+
+#define _PW_REQUIRE_SEMICOLON \
+  static_assert(1, "This macro must be terminated with a semicolon")
+
+// PW_MODIFY_DIAGNOSTICS_PUSH and PW_MODIFY_DIAGNOSTICS_POP are used to turn off
+// or on diagnostics (warnings or errors) for a section of code. Use
+// PW_MODIFY_DIAGNOSTICS_PUSH, use PW_MODIFY_DIAGNOSTIC as many times as needed,
+// then use PW_MODIFY_DIAGNOSTICS_POP to restore the previous settings.
+#define PW_MODIFY_DIAGNOSTICS_PUSH() \
+  _Pragma("GCC diagnostic push") _PW_REQUIRE_SEMICOLON
+#define PW_MODIFY_DIAGNOSTICS_POP() \
+  _Pragma("GCC diagnostic pop") _PW_REQUIRE_SEMICOLON
+
+// Changes how a diagnostic (warning or error) is handled. Most commonly used to
+// disable warnings. PW_MODIFY_DIAGNOSTIC should be used between
+// PW_MODIFY_DIAGNOSTICS_PUSH and PW_MODIFY_DIAGNOSTICS_POP statements to avoid
+// applying the modifications too broadly.
+//
+// 'kind' must be one of warning, error, or ignored.
+#define PW_MODIFY_DIAGNOSTIC(kind, option) \
+  PW_PRAGMA(GCC diagnostic kind option) _PW_REQUIRE_SEMICOLON
+
+// Applies PW_MODIFY_DIAGNOSTIC only for GCC. This is useful for warnings that
+// aren't supported by or don't need to be changed in other compilers.
+#ifdef __clang__
+#define PW_MODIFY_DIAGNOSTIC_GCC(kind, option) _PW_REQUIRE_SEMICOLON
+#else
+#define PW_MODIFY_DIAGNOSTIC_GCC(kind, option) \
+  PW_MODIFY_DIAGNOSTIC(kind, option)
+#endif  // __clang__
+
+// Expands to a _Pragma with the contents as a string. _Pragma must take a
+// single string literal; this can be used to construct a _Pragma argument.
+#define PW_PRAGMA(contents) _Pragma(#contents)
diff --git a/pw_preprocessor/public/pw_preprocessor/util.h b/pw_preprocessor/public/pw_preprocessor/util.h
index 4d7610f..acefcda 100644
--- a/pw_preprocessor/public/pw_preprocessor/util.h
+++ b/pw_preprocessor/public/pw_preprocessor/util.h
@@ -18,9 +18,6 @@
 // Returns the number of elements in a C array.
 #define PW_ARRAY_SIZE(array) (sizeof(array) / sizeof(*array))
 
-// Prevents unused value compiler warnings.
-#define PW_UNUSED(value) ((void)sizeof(value))
-
 // Returns a string literal of the arguments after expanding macros.
 #define PW_STRINGIFY(...) _PW_STRINGIFY(__VA_ARGS__)
 #define _PW_STRINGIFY(...) #__VA_ARGS__
diff --git a/pw_preprocessor/util_test.cc b/pw_preprocessor/util_test.cc
index 05771a0..073b4ce 100644
--- a/pw_preprocessor/util_test.cc
+++ b/pw_preprocessor/util_test.cc
@@ -40,15 +40,6 @@
   static_assert(PW_ARRAY_SIZE(objects[1].array) == 7);
 }
 
-TEST(Macros, UnusedVariable) {
-  int this_is_not_used = 12;
-  PW_UNUSED(this_is_not_used);
-
-  volatile void* volatile wow;
-  wow = nullptr;
-  PW_UNUSED(wow);
-}
-
 #define HELLO hello
 #define WORLD WORLD_IMPL()
 #define WORLD_IMPL() WORLD !
diff --git a/pw_presubmit/py/BUILD.gn b/pw_presubmit/py/BUILD.gn
index a9b96e7..cfaa150 100644
--- a/pw_presubmit/py/BUILD.gn
+++ b/pw_presubmit/py/BUILD.gn
@@ -36,7 +36,9 @@
     "tools_test.py",
   ]
   python_deps = [
+    "$dir_pw_build:python_lint",
     "$dir_pw_cli/py",
     "$dir_pw_package/py",
   ]
+  pylintrc = "$dir_pigweed/.pylintrc"
 }
diff --git a/pw_presubmit/py/pw_presubmit/build.py b/pw_presubmit/py/pw_presubmit/build.py
index 4a370e3..835a95e 100644
--- a/pw_presubmit/py/pw_presubmit/build.py
+++ b/pw_presubmit/py/pw_presubmit/build.py
@@ -14,11 +14,14 @@
 """Functions for building code during presubmit checks."""
 
 import collections
+import itertools
+import json
 import logging
 import os
 from pathlib import Path
 import re
-from typing import Container, Dict, Iterable, List, Mapping, Set, Tuple
+from typing import (Collection, Container, Dict, Iterable, List, Mapping, Set,
+                    Tuple, Union)
 
 from pw_package import package_manager
 from pw_presubmit import call, log_run, plural, PresubmitFailure, tools
@@ -66,16 +69,22 @@
            gn_output_dir: Path,
            *args: str,
            gn_check: bool = True,
+           gn_fail_on_unused: bool = True,
            **gn_arguments) -> None:
     """Runs gn gen in the specified directory with optional GN args."""
     args_option = (gn_args(**gn_arguments), ) if gn_arguments else ()
-    check_option = ['--check'] if gn_check else []
+
+    # Delete args.gn to ensure this is a clean build.
+    args_gn = gn_output_dir / 'args.gn'
+    if args_gn.is_file():
+        args_gn.unlink()
 
     call('gn',
          'gen',
          gn_output_dir,
          '--color=always',
-         *check_option,
+         *(['--check'] if gn_check else []),
+         *(['--fail-on-unused-args'] if gn_fail_on_unused else []),
          *args,
          *args_option,
          cwd=gn_source_dir)
@@ -146,6 +155,38 @@
                 yield path
 
 
+def _read_compile_commands(compile_commands: Path) -> dict:
+    with compile_commands.open('rb') as fd:
+        return json.load(fd)
+
+
+def compiled_files(compile_commands: Path) -> Iterable[Path]:
+    for command in _read_compile_commands(compile_commands):
+        file = Path(command['file'])
+        if file.is_absolute():
+            yield file
+        else:
+            yield file.joinpath(command['directory']).resolve()
+
+
+def check_compile_commands_for_files(
+        compile_commands: Union[Path, Iterable[Path]],
+        files: Iterable[Path],
+        extensions: Collection[str] = ('.c', '.cc', '.cpp'),
+) -> List[Path]:
+    """Checks for paths in one or more compile_commands.json files.
+
+    Only checks C and C++ source files by default.
+    """
+    if isinstance(compile_commands, Path):
+        compile_commands = [compile_commands]
+
+    compiled = frozenset(
+        itertools.chain.from_iterable(
+            compiled_files(cmds) for cmds in compile_commands))
+    return [f for f in files if f not in compiled and f.suffix in extensions]
+
+
 def check_builds_for_files(
         bazel_extensions_to_check: Container[str],
         gn_extensions_to_check: Container[str],
diff --git a/pw_presubmit/py/pw_presubmit/cli.py b/pw_presubmit/py/pw_presubmit/cli.py
index 7b0fdd5..7469842 100644
--- a/pw_presubmit/py/pw_presubmit/cli.py
+++ b/pw_presubmit/py/pw_presubmit/cli.py
@@ -128,27 +128,38 @@
 
         _add_programs_arguments(parser, programs, default)
 
+        # LUCI builders extract the list of steps from the program and run them
+        # individually for a better UX in MILO.
+        parser.add_argument(
+            '--only-list-steps',
+            action='store_true',
+            help=argparse.SUPPRESS,
+        )
+
 
 def run(
-        program: Sequence[Callable],
-        output_directory: Optional[Path],
-        package_root: Path,
-        clear: bool,
-        root: Path = None,
-        repositories: Collection[Path] = (),
-        **other_args,
+    program: Sequence[Callable],
+    output_directory: Optional[Path],
+    package_root: Path,
+    clear: bool,
+    root: Path = None,
+    repositories: Collection[Path] = (),
+    only_list_steps=False,
+    **other_args,
 ) -> int:
     """Processes arguments from add_arguments and runs the presubmit.
 
     Args:
-      root: base path from which to run presubmit checks; defaults to the root
-          of the current directory's repository
-      repositories: roots of Git repositories on which to run presubmit checks;
-          defaults to the root of the current directory's repository
       program: from the --program option
       output_directory: from --output-directory option
       package_root: from --package-root option
       clear: from the --clear option
+      root: base path from which to run presubmit checks; defaults to the root
+          of the current directory's repository
+      repositories: roots of Git repositories on which to run presubmit checks;
+          defaults to the root of the current directory's repository
+      only_list_steps: list the steps that would be executed, one per line,
+          instead of executing them
       **other_args: remaining arguments defined by by add_arguments
 
     Returns:
@@ -177,6 +188,11 @@
 
         return 0
 
+    if only_list_steps:
+        for step in program:
+            print(step.__name__)
+        return 0
+
     if presubmit.run(program,
                      root,
                      repositories,
diff --git a/pw_presubmit/py/pw_presubmit/environment.py b/pw_presubmit/py/pw_presubmit/environment.py
index d0d7f37..7af5d52 100644
--- a/pw_presubmit/py/pw_presubmit/environment.py
+++ b/pw_presubmit/py/pw_presubmit/environment.py
@@ -86,7 +86,6 @@
         'python3',
         virtualenv_source,
         f'--venv_path={output_directory}',
-        f'--requirements={virtualenv_source / "requirements.txt"}',
         *(f'--requirements={x}' for x in requirements),
         *(f'--gn-target={t}' for t in gn_targets),
     )
diff --git a/pw_presubmit/py/pw_presubmit/install_hook.py b/pw_presubmit/py/pw_presubmit/install_hook.py
index 1df7011..632bd92 100755
--- a/pw_presubmit/py/pw_presubmit/install_hook.py
+++ b/pw_presubmit/py/pw_presubmit/install_hook.py
@@ -18,6 +18,7 @@
 import logging
 import os
 from pathlib import Path
+import re
 import shlex
 import subprocess
 from typing import Sequence, Union
@@ -40,7 +41,16 @@
     root = git_repo_root(repository).resolve()
     script = os.path.relpath(script, root)
 
-    hook_path = root.joinpath('.git', 'hooks', hook)
+    if root.joinpath('.git').is_dir():
+        hook_path = root.joinpath('.git', 'hooks', hook)
+    else:  # This repo is probably a submodule with a .git file instead
+        match = re.match('^gitdir: (.*)$', root.joinpath('.git').read_text())
+        if not match:
+            raise ValueError('Unexpected format for .git file')
+
+        hook_path = root.joinpath(match.group(1), 'hooks', hook).resolve()
+
+    hook_path.parent.mkdir(exist_ok=True)
 
     command = ' '.join(shlex.quote(arg) for arg in (script, *args))
 
diff --git a/pw_presubmit/py/pw_presubmit/pigweed_presubmit.py b/pw_presubmit/py/pw_presubmit/pigweed_presubmit.py
index 794967d..75cd910 100755
--- a/pw_presubmit/py/pw_presubmit/pigweed_presubmit.py
+++ b/pw_presubmit/py/pw_presubmit/pigweed_presubmit.py
@@ -20,6 +20,7 @@
 import os
 from pathlib import Path
 import re
+import shutil
 import sys
 from typing import Sequence, IO, Tuple, Optional
 
@@ -32,31 +33,16 @@
         os.path.abspath(__file__))))
     import pw_presubmit
 
-from pw_presubmit import build, cli, environment, format_code, git_repo
+import pw_package.pigweed_packages
+
+from pw_presubmit import build, cli, format_code, git_repo
 from pw_presubmit import call, filter_paths, plural, PresubmitContext
 from pw_presubmit import PresubmitFailure, Programs
 from pw_presubmit.install_hook import install_hook
 
 _LOG = logging.getLogger(__name__)
 
-
-#
-# Initialization
-#
-def init_cipd(ctx: PresubmitContext):
-    environment.init_cipd(ctx.root, ctx.output_dir)
-
-
-def init_virtualenv(ctx: PresubmitContext):
-    environment.init_virtualenv(
-        ctx.root,
-        ctx.output_dir,
-        gn_targets=(
-            f'{ctx.root}#:python.install',
-            f'{ctx.root}#:target_support_packages.install',
-        ),
-    )
-
+pw_package.pigweed_packages.initialize()
 
 # Trigger builds if files with these extensions change.
 _BUILD_EXTENSIONS = ('.py', '.rst', '.gn', '.gni',
@@ -64,7 +50,19 @@
 
 
 def _at_all_optimization_levels(target):
-    for level in ['debug', 'size_optimized', 'speed_optimized']:
+    levels = ('debug', 'size_optimized', 'speed_optimized')
+
+    # Skip optimized host GCC builds for now, since GCC sometimes emits spurious
+    # warnings.
+    #
+    #   -02: GCC 9.3 emits spurious maybe-uninitialized warnings
+    #   -0s: GCC 8.1 (Mingw-w64) emits a spurious nonnull warning
+    #
+    # TODO(pwbug/255): Enable optimized GCC builds when this is fixed.
+    if target == 'host_gcc':
+        levels = ('debug', )
+
+    for level in levels:
         yield f'{target}_{level}'
 
 
@@ -77,24 +75,47 @@
 
 
 @filter_paths(endswith=_BUILD_EXTENSIONS)
+def gn_gcc_build(ctx: PresubmitContext):
+    build.gn_gen(ctx.root, ctx.output_dir)
+    build.ninja(ctx.output_dir, *_at_all_optimization_levels('host_gcc'))
+
+
+_HOST_COMPILER = 'gcc' if sys.platform == 'win32' else 'clang'
+
+
+def gn_host_build(ctx: PresubmitContext):
+    build.gn_gen(ctx.root, ctx.output_dir)
+    build.ninja(ctx.output_dir,
+                *_at_all_optimization_levels(f'host_{_HOST_COMPILER}'))
+
+
+@filter_paths(endswith=_BUILD_EXTENSIONS)
 def gn_quick_build_check(ctx: PresubmitContext):
     build.gn_gen(ctx.root, ctx.output_dir)
-    build.ninja(ctx.output_dir, 'host_clang_size_optimized',
+
+    # TODO(pwbug/255): Switch to optimized GCC builds when this is fixed.
+    # See comment in _at_all_optimization_levels() above for details.
+    optimization_level = 'size_optimized'
+    if _HOST_COMPILER == 'gcc':
+        optimization_level = 'debug'
+
+    build.ninja(ctx.output_dir, f'host_{_HOST_COMPILER}_{optimization_level}',
                 'stm32f429i_size_optimized', 'python.tests', 'python.lint')
 
 
 @filter_paths(endswith=_BUILD_EXTENSIONS)
-def gn_gcc_build(ctx: PresubmitContext):
+def gn_full_build_check(ctx: PresubmitContext):
     build.gn_gen(ctx.root, ctx.output_dir)
+    build.ninja(ctx.output_dir, *_at_all_optimization_levels('stm32f429i'),
+                *_at_all_optimization_levels(f'host_{_HOST_COMPILER}'),
+                'python.tests', 'python.lint', 'docs')
 
-    # Skip optimized host GCC builds for now, since GCC sometimes emits spurious
-    # warnings.
-    #
-    #   -02: GCC 9.3 emits spurious maybe-uninitialized warnings
-    #   -0s: GCC 8.1 (Mingw-w64) emits a spurious nonnull warning
-    #
-    # TODO(pwbug/255): Enable optimized GCC builds when this is fixed.
-    build.ninja(ctx.output_dir, 'host_gcc_debug')
+
+@filter_paths(endswith=_BUILD_EXTENSIONS)
+def gn_full_qemu_check(ctx: PresubmitContext):
+    build.gn_gen(ctx.root, ctx.output_dir)
+    build.ninja(ctx.output_dir, *_at_all_optimization_levels('qemu_gcc'),
+                *_at_all_optimization_levels('qemu_clang'))
 
 
 @filter_paths(endswith=_BUILD_EXTENSIONS)
@@ -118,9 +139,28 @@
 
 
 @filter_paths(endswith=_BUILD_EXTENSIONS)
+def gn_teensy_build(ctx: PresubmitContext):
+    build.install_package(ctx.package_root, 'teensy')
+    build.gn_gen(ctx.root,
+                 ctx.output_dir,
+                 pw_arduino_build_CORE_PATH='"{}"'.format(str(
+                     ctx.package_root)),
+                 pw_arduino_build_CORE_NAME='teensy',
+                 pw_arduino_build_PACKAGE_NAME='teensy/avr',
+                 pw_arduino_build_BOARD='teensy40')
+    build.ninja(ctx.output_dir, *_at_all_optimization_levels('arduino'))
+
+
+@filter_paths(endswith=_BUILD_EXTENSIONS)
 def gn_qemu_build(ctx: PresubmitContext):
     build.gn_gen(ctx.root, ctx.output_dir)
-    build.ninja(ctx.output_dir, *_at_all_optimization_levels('qemu'))
+    build.ninja(ctx.output_dir, *_at_all_optimization_levels('qemu_gcc'))
+
+
+@filter_paths(endswith=_BUILD_EXTENSIONS)
+def gn_qemu_clang_build(ctx: PresubmitContext):
+    build.gn_gen(ctx.root, ctx.output_dir)
+    build.ninja(ctx.output_dir, *_at_all_optimization_levels('qemu_clang'))
 
 
 def gn_docs_build(ctx: PresubmitContext):
@@ -130,16 +170,13 @@
 
 def gn_host_tools(ctx: PresubmitContext):
     build.gn_gen(ctx.root, ctx.output_dir, pw_build_HOST_TOOLS=True)
-    build.ninja(ctx.output_dir)
+    build.ninja(ctx.output_dir, 'host')
 
 
 @filter_paths(endswith=format_code.C_FORMAT.extensions)
 def oss_fuzz_build(ctx: PresubmitContext):
-    build.gn_gen(ctx.root,
-                 ctx.output_dir,
-                 pw_toolchain_OSS_FUZZ_ENABLED=True,
-                 pw_toolchain_SANITIZER="address")
-    build.ninja(ctx.output_dir, "host_clang")
+    build.gn_gen(ctx.root, ctx.output_dir, pw_toolchain_OSS_FUZZ_ENABLED=True)
+    build.ninja(ctx.output_dir, "fuzzers")
 
 
 @filter_paths(endswith='.py')
@@ -154,29 +191,86 @@
     )
 
 
-@filter_paths(endswith=(*format_code.C_FORMAT.extensions, '.cmake',
-                        'CMakeLists.txt'))
-def cmake_tests(ctx: PresubmitContext):
-    toolchain = ctx.root / 'pw_toolchain' / 'host_clang' / 'toolchain.cmake'
+def _run_cmake(ctx: PresubmitContext) -> None:
+    build.install_package(ctx.package_root, 'nanopb')
 
+    toolchain = ctx.root / 'pw_toolchain' / 'host_clang' / 'toolchain.cmake'
     build.cmake(ctx.root,
                 ctx.output_dir,
                 f'-DCMAKE_TOOLCHAIN_FILE={toolchain}',
+                '-DCMAKE_EXPORT_COMPILE_COMMANDS=1',
+                f'-Ddir_pw_third_party_nanopb={ctx.package_root / "nanopb"}',
+                '-Dpw_third_party_nanopb_ADD_SUBDIRECTORY=ON',
                 env=build.env_with_clang_vars())
-    build.ninja(ctx.output_dir, 'pw_run_tests.modules')
+
+
+@filter_paths(endswith=(*format_code.C_FORMAT.extensions, '.cmake',
+                        'CMakeLists.txt'))
+def cmake_tests(ctx: PresubmitContext):
+    _run_cmake(ctx)
+    build.ninja(ctx.output_dir, 'pw_apps', 'pw_run_tests.modules')
+
+
+# TODO: Slowly add modules here that work with bazel until all
+# modules are added. Then replace with //...
+_MODULES_THAT_WORK_WITH_BAZEL = [
+    '//pw_assert_basic/...',
+    '//pw_base64/...',
+    '//pw_build/...',
+    '//pw_chrono_stl/...',
+    '//pw_containers/...',
+    '//pw_cpu_exception/...',
+    '//pw_docgen/...',
+    '//pw_doctor/...',
+    '//pw_i2c/...',
+    '//pw_log/...',
+    '//pw_log_basic/...',
+    '//pw_polyfill/...',
+    '//pw_preprocessor/...',
+    '//pw_protobuf_compiler/...',
+    '//pw_span/...',
+    '//pw_status/...',
+    '//pw_sys_io/...',
+    '//pw_sys_io_baremetal_lm3s6965evb/...',
+    '//pw_sys_io_stdio/...',
+    '//pw_thread_stl/...',
+    '//pw_toolchain/...',
+    '//pw_varint/...',
+    '//pw_web_ui/...',
+]
 
 
 @filter_paths(endswith=(*format_code.C_FORMAT.extensions, '.bzl', 'BUILD'))
 def bazel_test(ctx: PresubmitContext):
+    """Runs bazel test on each bazel compatible module"""
+
     try:
         call('bazel',
              'test',
-             '//...',
+             *_MODULES_THAT_WORK_WITH_BAZEL,
              '--verbose_failures',
              '--verbose_explanations',
              '--worker_verbose',
-             '--symlink_prefix',
-             ctx.output_dir.joinpath('bazel-'),
+             '--test_output=errors',
+             cwd=ctx.root,
+             env=build.env_with_clang_vars())
+    except:
+        _LOG.info('If the Bazel build inexplicably fails while the '
+                  'other builds are passing, try deleting the Bazel cache:\n'
+                  '    rm -rf ~/.cache/bazel')
+        raise
+
+
+@filter_paths(endswith=(*format_code.C_FORMAT.extensions, '.bzl', 'BUILD'))
+def bazel_build(ctx: PresubmitContext):
+    """Runs Bazel build on each Bazel compatible module"""
+    try:
+        call('bazel',
+             'build',
+             *_MODULES_THAT_WORK_WITH_BAZEL,
+             '--verbose_failures',
+             '--verbose_explanations',
+             '--worker_verbose',
              cwd=ctx.root,
              env=build.env_with_clang_vars())
     except:
@@ -275,6 +369,9 @@
     r'\.pb\.h$',
     r'\.pb\.c$',
     r'\_pb2.pyi?$',
+    # Diff/Patch files
+    r'\.diff$',
+    r'\.patch$',
 )
 
 
@@ -379,14 +476,26 @@
         _GN_SOURCES_IN_BUILD,
         ctx.paths,
         bazel_dirs=[ctx.root],
-        gn_build_files=git_repo.list_files(
-            pathspecs=['BUILD.gn', '*BUILD.gn']))
+        gn_build_files=git_repo.list_files(pathspecs=['BUILD.gn', '*BUILD.gn'],
+                                           repo_path=ctx.root))
 
     if missing:
         _LOG.warning(
             'All source files must appear in BUILD and BUILD.gn files')
         raise PresubmitFailure
 
+    _run_cmake(ctx)
+    cmake_missing = build.check_compile_commands_for_files(
+        ctx.output_dir / 'compile_commands.json',
+        (f for f in ctx.paths if f.suffix in ('.c', '.cc')))
+    if cmake_missing:
+        _LOG.warning('The CMake build is missing %d files', len(cmake_missing))
+        _LOG.warning('Files missing from CMake:\n%s',
+                     '\n'.join(str(f) for f in cmake_missing))
+        # TODO(hepler): Many files are missing from the CMake build. Make this
+        #     check an error when the missing files are fixed.
+        # raise PresubmitFailure
+
 
 def build_env_setup(ctx: PresubmitContext):
     if 'PW_CARGO_SETUP' not in os.environ:
@@ -414,6 +523,13 @@
     for line in lines:
         _LOG.debug(line)
 
+    # Ignore Gerrit-generated reverts.
+    if ('Revert' in lines[0]
+            and 'This reverts commit ' in git_repo.commit_message()
+            and 'Reason for revert: ' in git_repo.commit_message()):
+        _LOG.warning('Ignoring apparent Gerrit-generated revert')
+        return
+
     if not lines:
         _LOG.error('The commit message is too short!')
         raise PresubmitFailure
@@ -443,7 +559,7 @@
     # might possibly have a URL, path, or metadata in them. Also skip any lines
     # with non-ASCII characters.
     for i, line in enumerate(lines[2:], 3):
-        if ':' in line or '/' in line or not line.isascii():
+        if any(c in line for c in ':/>') or not line.isascii():
             continue
 
         if len(line) > 72:
@@ -458,31 +574,76 @@
         raise PresubmitFailure
 
 
+def static_analysis(ctx: PresubmitContext):
+    """Check that files pass static analyzer checks."""
+    build.gn_gen(ctx.root, ctx.output_dir,
+                 '--export-compile-commands=host_clang_debug')
+    build.ninja(ctx.output_dir, 'host_clang_debug')
+
+    compile_commands = ctx.output_dir.joinpath('compile_commands.json')
+    analyzer_output = ctx.output_dir.joinpath('analyze-build-output')
+
+    if analyzer_output.exists():
+        shutil.rmtree(analyzer_output)
+
+    call('analyze-build',
+         '--cdb',
+         compile_commands,
+         '--exclude',
+         'third_party',
+         '--output',
+         analyzer_output,
+         cwd=ctx.root,
+         env=build.env_with_clang_vars())
+
+    # Search for reports under output directory.
+    reports = list(analyzer_output.glob('*/report*'))
+    if len(reports) != 0:
+        archive = shutil.make_archive(str(analyzer_output), 'zip',
+                                      reports[0].parent)
+        _LOG.error('Static analyzer found errors: %s', archive)
+        _LOG.error('To view report, open: %s',
+                   Path(reports[0]).parent.joinpath('index.html'))
+        raise PresubmitFailure
+
+
+def renode_check(ctx: PresubmitContext):
+    """Placeholder for future check."""
+    _LOG.info('%s %s', ctx.root, ctx.output_dir)
+
+
 #
 # Presubmit check programs
 #
 
-BROKEN = (
-    # TODO(pwbug/45): Remove clang-tidy from BROKEN when it passes.
+OTHER_CHECKS = (
+    # TODO(pwbug/45): Remove clang-tidy from OTHER_CHECKS when it passes.
     clang_tidy,
-    # QEMU build. Currently doesn't have test runners.
-    gn_qemu_build,
     # Build that attempts to duplicate the build OSS-Fuzz does. Currently
     # failing.
     oss_fuzz_build,
     bazel_test,
     cmake_tests,
     gn_nanopb_build,
+    gn_full_build_check,
+    gn_full_qemu_check,
+    gn_clang_build,
+    gn_gcc_build,
+    renode_check,
+    static_analysis,
 )
 
-QUICK = (
+LINTFORMAT = (
     commit_message_format,
-    init_cipd,
-    init_virtualenv,
-    source_is_in_build_files,
     copyright_notice,
     format_code.presubmit_checks(),
     pw_presubmit.pragma_once,
+    source_is_in_build_files,
+)
+
+QUICK = (
+    LINTFORMAT,
+    bazel_test,
     gn_quick_build_check,
     # TODO(pwbug/141): Re-enable CMake and Bazel for Mac after we have fixed the
     # the clang issues. The problem is that all clang++ invocations need the
@@ -491,25 +652,34 @@
 )
 
 FULL = (
-    commit_message_format,
-    init_cipd,
-    init_virtualenv,
-    copyright_notice,
-    format_code.presubmit_checks(),
-    pw_presubmit.pragma_once,
-    gn_clang_build,
+    LINTFORMAT,
+    gn_host_build,
     gn_arm_build,
     gn_docs_build,
     gn_host_tools,
+    bazel_build,
+    bazel_test,
     # On Mac OS, system 'gcc' is a symlink to 'clang' by default, so skip GCC
-    # host builds on Mac for now.
-    gn_gcc_build if sys.platform != 'darwin' else (),
+    # host builds on Mac for now. Skip it on Windows too, since gn_host_build
+    # already uses 'gcc' on Windows.
+    gn_gcc_build if sys.platform not in ('darwin', 'win32') else (),
+    # Windows doesn't support QEMU yet.
+    gn_qemu_build if sys.platform != 'win32' else (),
+    gn_qemu_clang_build if sys.platform != 'win32' else (),
     source_is_in_build_files,
     python_checks,
     build_env_setup,
+    # Skip gn_teensy_build if running on Windows. The Teensycore installer is
+    # an exe that requires an admin role.
+    gn_teensy_build if sys.platform in ['linux', 'darwin'] else (),
 )
 
-PROGRAMS = Programs(broken=BROKEN, quick=QUICK, full=FULL)
+PROGRAMS = Programs(
+    full=FULL,
+    lintformat=LINTFORMAT,
+    other_checks=OTHER_CHECKS,
+    quick=QUICK,
+)
 
 
 def parse_args() -> argparse.Namespace:
diff --git a/pw_presubmit/py/pw_presubmit/presubmit.py b/pw_presubmit/py/pw_presubmit/presubmit.py
index a572a9a..575fd94 100644
--- a/pw_presubmit/py/pw_presubmit/presubmit.py
+++ b/pw_presubmit/py/pw_presubmit/presubmit.py
@@ -83,7 +83,10 @@
 
 def _format_time(time_s: float) -> str:
     minutes, seconds = divmod(time_s, 60)
-    return f' {int(minutes)}:{seconds:04.1f}'
+    if minutes < 60:
+        return f' {int(minutes)}:{seconds:04.1f}'
+    hours, minutes = divmod(minutes, 60)
+    return f'{int(hours):d}:{int(minutes):02}:{int(seconds):02}'
 
 
 def _box(style, left, middle, right, box=tools.make_box('><>')) -> str:
@@ -201,6 +204,11 @@
                 and not any(exp.search(path) for exp in self.exclude))
 
 
+def _print_ui(*args) -> None:
+    """Prints to stdout and flushes to stay in sync with logs on stderr."""
+    print(*args, flush=True)
+
+
 class Presubmit:
     """Runs a series of presubmit checks on a list of files."""
     def __init__(self, root: Path, repos: Sequence[Path],
@@ -220,18 +228,18 @@
         checks = self._apply_filters(program)
 
         _LOG.debug('Running %s for %s', program.title(), self._root.name)
-        print(_title(f'{self._root.name}: {program.title()}'))
+        _print_ui(_title(f'{self._root.name}: {program.title()}'))
 
         _LOG.info('%d of %d checks apply to %s in %s', len(checks),
                   len(program), plural(self._paths, 'file'), self._root)
 
-        print()
+        _print_ui()
         for line in tools.file_summary(self._relative_paths):
-            print(line)
-        print()
+            _print_ui(line)
+        _print_ui()
 
         if not self._paths:
-            print(color_yellow('No files are being checked!'))
+            _print_ui(color_yellow('No files are being checked!'))
 
         _LOG.debug('Checks:\n%s', '\n'.join(c.name for c, _ in checks))
 
@@ -293,7 +301,7 @@
                    plural(self._paths, 'file'), time_s)
         _LOG.debug('Presubmit checks %s: %s', result.value, summary)
 
-        print(
+        _print_ui(
             _box(
                 _SUMMARY_BOX, result.colorized(_LEFT, invert=True),
                 f'{total} checks on {plural(self._paths, "file")}: {summary}',
@@ -479,7 +487,7 @@
     def run(self, ctx: PresubmitContext, count: int, total: int) -> _Result:
         """Runs the presubmit check on the provided paths."""
 
-        print(
+        _print_ui(
             _box(_CHECK_UPPER, f'{count}/{total}', self.name,
                  plural(ctx.paths, "file")))
 
@@ -491,7 +499,8 @@
         time_str = _format_time(time.time() - start_time_s)
         _LOG.debug('%s %s', self.name, result.value)
 
-        print(_box(_CHECK_LOWER, result.colorized(_LEFT), self.name, time_str))
+        _print_ui(
+            _box(_CHECK_LOWER, result.colorized(_LEFT), self.name, time_str))
         _LOG.debug('%s duration:%s', self.name, time_str)
 
         return result
@@ -507,7 +516,7 @@
             _LOG.exception('Presubmit check %s failed!', self.name)
             return _Result.FAIL
         except KeyboardInterrupt:
-            print()
+            _print_ui()
             return _Result.CANCEL
 
         return _Result.PASS
diff --git a/pw_presubmit/py/setup.py b/pw_presubmit/py/setup.py
index a738a74..0c13568 100644
--- a/pw_presubmit/py/setup.py
+++ b/pw_presubmit/py/setup.py
@@ -22,9 +22,9 @@
     author_email='pigweed-developers@googlegroups.com',
     description='Presubmit tools and a presubmit script for Pigweed',
     install_requires=[
-        'mypy==0.790',
-        'pylint==2.6.0',
+        'scan-build==2.0.19',
         'yapf==0.30.0',
+        'pw_cli',
         'pw_package',
     ],
     packages=setuptools.find_packages(),
diff --git a/pw_protobuf/BUILD b/pw_protobuf/BUILD
index 54b4d3a..40edf6d 100644
--- a/pw_protobuf/BUILD
+++ b/pw_protobuf/BUILD
@@ -23,6 +23,12 @@
 licenses(["notice"])  # Apache License 2.0
 
 pw_cc_library(
+    name = "config",
+    hdrs = ["public/pw_protobuf/config.h"],
+    includes = ["public"],
+)
+
+pw_cc_library(
     name = "pw_protobuf",
     srcs = [
         "decoder.cc",
@@ -39,6 +45,7 @@
     ],
     includes = ["public"],
     deps = [
+        ":config",
         "//pw_span",
         "//pw_status",
         "//pw_varint",
@@ -81,6 +88,14 @@
     ],
 )
 
+# TODO(frolv): Figure out how to add facade tests to Bazel.
+filegroup(
+    name = "varint_size_test",
+    srcs = [
+        "varint_size_test.cc",
+    ],
+)
+
 # TODO(frolv): Figure out what to do about size reports in Bazel.
 filegroup(
     name = "size_reports",
diff --git a/pw_protobuf/BUILD.gn b/pw_protobuf/BUILD.gn
index 00eae35..ceb9947 100644
--- a/pw_protobuf/BUILD.gn
+++ b/pw_protobuf/BUILD.gn
@@ -1,4 +1,4 @@
-# Copyright 2020 The Pigweed Authors
+# Copyright 2021 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
@@ -15,19 +15,36 @@
 import("//build_overrides/pigweed.gni")
 
 import("$dir_pw_build/input_group.gni")
+import("$dir_pw_build/module_config.gni")
 import("$dir_pw_build/target_types.gni")
 import("$dir_pw_docgen/docs.gni")
 import("$dir_pw_fuzzer/fuzzer.gni")
 import("$dir_pw_protobuf_compiler/proto.gni")
+import("$dir_pw_unit_test/facade_test.gni")
 import("$dir_pw_unit_test/test.gni")
 
-config("default_config") {
+declare_args() {
+  # The build target that overrides the default configuration options for this
+  # module. This should point to a source set that provides defines through a
+  # public config (which may -include a file or add defines directly).
+  pw_protobuf_CONFIG = pw_build_DEFAULT_MODULE_CONFIG
+}
+
+config("public_include_path") {
   include_dirs = [ "public" ]
 }
 
+pw_source_set("config") {
+  public = [ "public/pw_protobuf/config.h" ]
+  public_configs = [ ":public_include_path" ]
+  public_deps = [ pw_protobuf_CONFIG ]
+  visibility = [ ":*" ]
+}
+
 pw_source_set("pw_protobuf") {
-  public_configs = [ ":default_config" ]
+  public_configs = [ ":public_include_path" ]
   public_deps = [
+    ":config",
     dir_pw_bytes,
     dir_pw_result,
     dir_pw_status,
@@ -66,6 +83,7 @@
     ":encoder_test",
     ":encoder_fuzzer",
     ":find_test",
+    ":varint_size_test",
   ]
 }
 
@@ -89,15 +107,38 @@
   sources = [ "codegen_test.cc" ]
 }
 
+config("one_byte_varint") {
+  defines = [ "PW_PROTOBUF_CFG_MAX_VARINT_SIZE=1" ]
+  visibility = [ ":*" ]
+}
+
+pw_source_set("varint_size_test_config") {
+  public_configs = [ ":one_byte_varint" ]
+  visibility = [ ":*" ]
+}
+
+pw_facade_test("varint_size_test") {
+  build_args = {
+    pw_protobuf_CONFIG = ":varint_size_test_config"
+  }
+  deps = [ ":pw_protobuf" ]
+  sources = [ "varint_size_test.cc" ]
+}
+
+pw_proto_library("common_protos") {
+  sources = [ "pw_protobuf_protos/common.proto" ]
+}
+
 pw_proto_library("codegen_test_protos") {
   sources = [
-    "pw_protobuf_protos/test_protos/full_test.proto",
-    "pw_protobuf_protos/test_protos/imported.proto",
-    "pw_protobuf_protos/test_protos/importer.proto",
-    "pw_protobuf_protos/test_protos/non_pw_package.proto",
-    "pw_protobuf_protos/test_protos/proto2.proto",
-    "pw_protobuf_protos/test_protos/repeated.proto",
+    "pw_protobuf_test_protos/full_test.proto",
+    "pw_protobuf_test_protos/imported.proto",
+    "pw_protobuf_test_protos/importer.proto",
+    "pw_protobuf_test_protos/non_pw_package.proto",
+    "pw_protobuf_test_protos/proto2.proto",
+    "pw_protobuf_test_protos/repeated.proto",
   ]
+  deps = [ ":common_protos" ]
 }
 
 pw_fuzzer("encoder_fuzzer") {
diff --git a/pw_protobuf/CMakeLists.txt b/pw_protobuf/CMakeLists.txt
index 66fc333..6d821b5 100644
--- a/pw_protobuf/CMakeLists.txt
+++ b/pw_protobuf/CMakeLists.txt
@@ -15,22 +15,72 @@
 include($ENV{PW_ROOT}/pw_build/pigweed.cmake)
 include($ENV{PW_ROOT}/pw_protobuf_compiler/proto.cmake)
 
-pw_auto_add_simple_module(pw_protobuf
+pw_add_module_library(pw_protobuf
+  SOURCES
+    decoder.cc
+    encoder.cc
+    find.cc
   PUBLIC_DEPS
     pw_bytes
     pw_result
     pw_status
     pw_varint
-  TEST_DEPS
+)
+
+pw_add_test(pw_protobuf.decoder_test
+  SOURCES
+    decoder_test.cc
+  DEPS
+    pw_protobuf
+  GROUPS
+    modules
+    pw_protobuf
+)
+
+pw_add_test(pw_protobuf.encoder_test
+  SOURCES
+    encoder_test.cc
+  DEPS
+    pw_protobuf
+  GROUPS
+    modules
+    pw_protobuf
+)
+
+pw_add_test(pw_protobuf.find_test
+  SOURCES
+    find_test.cc
+  DEPS
+    pw_protobuf
+  GROUPS
+    modules
+    pw_protobuf
+)
+
+pw_add_test(pw_protobuf.codegen_test
+  SOURCES
+    codegen_test.cc
+  DEPS
+    pw_protobuf
     pw_protobuf.codegen_test_protos.pwpb
+  GROUPS
+    modules
+    pw_protobuf
+)
+
+pw_proto_library(pw_protobuf.common_protos
+  SOURCES
+    pw_protobuf_protos/common.proto
 )
 
 pw_proto_library(pw_protobuf.codegen_test_protos
   SOURCES
-    pw_protobuf_protos/test_protos/full_test.proto
-    pw_protobuf_protos/test_protos/imported.proto
-    pw_protobuf_protos/test_protos/importer.proto
-    pw_protobuf_protos/test_protos/non_pw_package.proto
-    pw_protobuf_protos/test_protos/proto2.proto
-    pw_protobuf_protos/test_protos/repeated.proto
+    pw_protobuf_test_protos/full_test.proto
+    pw_protobuf_test_protos/imported.proto
+    pw_protobuf_test_protos/importer.proto
+    pw_protobuf_test_protos/non_pw_package.proto
+    pw_protobuf_test_protos/proto2.proto
+    pw_protobuf_test_protos/repeated.proto
+  DEPS
+    pw_protobuf.common_protos
 )
diff --git a/pw_protobuf/codegen_test.cc b/pw_protobuf/codegen_test.cc
index 72e11a7..faceb51 100644
--- a/pw_protobuf/codegen_test.cc
+++ b/pw_protobuf/codegen_test.cc
@@ -23,11 +23,11 @@
 // The purpose of the tests in this file is primarily to verify that the
 // generated C++ interface is valid rather than the correctness of the
 // low-level encoder.
-#include "pw_protobuf_protos/test_protos/full_test.pwpb.h"
-#include "pw_protobuf_protos/test_protos/importer.pwpb.h"
-#include "pw_protobuf_protos/test_protos/non_pw_package.pwpb.h"
-#include "pw_protobuf_protos/test_protos/proto2.pwpb.h"
-#include "pw_protobuf_protos/test_protos/repeated.pwpb.h"
+#include "pw_protobuf_test_protos/full_test.pwpb.h"
+#include "pw_protobuf_test_protos/importer.pwpb.h"
+#include "pw_protobuf_test_protos/non_pw_package.pwpb.h"
+#include "pw_protobuf_test_protos/proto2.pwpb.h"
+#include "pw_protobuf_test_protos/repeated.pwpb.h"
 
 namespace pw::protobuf {
 namespace {
@@ -167,7 +167,7 @@
   // clang-format on
 
   Result result = encoder.Encode();
-  ASSERT_EQ(result.status(), Status::Ok());
+  ASSERT_EQ(result.status(), OkStatus());
   EXPECT_EQ(result.value().size(), sizeof(expected_proto));
   EXPECT_EQ(std::memcmp(
                 result.value().data(), expected_proto, sizeof(expected_proto)),
@@ -187,7 +187,7 @@
       0x08, 0x00, 0x08, 0x10, 0x08, 0x20, 0x08, 0x30};
 
   Result result = encoder.Encode();
-  ASSERT_EQ(result.status(), Status::Ok());
+  ASSERT_EQ(result.status(), OkStatus());
   EXPECT_EQ(result.value().size(), sizeof(expected_proto));
   EXPECT_EQ(std::memcmp(
                 result.value().data(), expected_proto, sizeof(expected_proto)),
@@ -204,7 +204,7 @@
 
   constexpr uint8_t expected_proto[] = {0x0a, 0x04, 0x00, 0x10, 0x20, 0x30};
   Result result = encoder.Encode();
-  ASSERT_EQ(result.status(), Status::Ok());
+  ASSERT_EQ(result.status(), OkStatus());
   EXPECT_EQ(result.value().size(), sizeof(expected_proto));
   EXPECT_EQ(std::memcmp(
                 result.value().data(), expected_proto, sizeof(expected_proto)),
@@ -225,7 +225,7 @@
       0x1a, 0x03, 't', 'h', 'e', 0x1a, 0x5, 'q',  'u', 'i', 'c', 'k',
       0x1a, 0x5,  'b', 'r', 'o', 'w',  'n', 0x1a, 0x3, 'f', 'o', 'x'};
   Result result = encoder.Encode();
-  ASSERT_EQ(result.status(), Status::Ok());
+  ASSERT_EQ(result.status(), OkStatus());
   EXPECT_EQ(result.value().size(), sizeof(expected_proto));
   EXPECT_EQ(std::memcmp(
                 result.value().data(), expected_proto, sizeof(expected_proto)),
@@ -250,7 +250,7 @@
   // clang-format on
 
   Result result = encoder.Encode();
-  ASSERT_EQ(result.status(), Status::Ok());
+  ASSERT_EQ(result.status(), OkStatus());
   EXPECT_EQ(result.value().size(), sizeof(expected_proto));
   EXPECT_EQ(std::memcmp(
                 result.value().data(), expected_proto, sizeof(expected_proto)),
@@ -275,7 +275,7 @@
       0x08, 0x03, 0x1a, 0x06, 0x0a, 0x04, 0xde, 0xad, 0xbe, 0xef};
 
   Result result = encoder.Encode();
-  ASSERT_EQ(result.status(), Status::Ok());
+  ASSERT_EQ(result.status(), OkStatus());
   EXPECT_EQ(result.value().size(), sizeof(expected_proto));
   EXPECT_EQ(std::memcmp(
                 result.value().data(), expected_proto, sizeof(expected_proto)),
@@ -299,7 +299,7 @@
     end.WriteNanoseconds(490367432);
   }
 
-  EXPECT_EQ(encoder.Encode().status(), Status::Ok());
+  EXPECT_EQ(encoder.Encode().status(), OkStatus());
 }
 
 TEST(Codegen, NonPigweedPackage) {
@@ -311,7 +311,7 @@
   packed.WriteRep(std::span<const int64_t>(repeated));
   packed.WritePacked("packed");
 
-  EXPECT_EQ(encoder.Encode().status(), Status::Ok());
+  EXPECT_EQ(encoder.Encode().status(), OkStatus());
 }
 
 }  // namespace
diff --git a/pw_protobuf/decoder.cc b/pw_protobuf/decoder.cc
index 70880ad..813046c 100644
--- a/pw_protobuf/decoder.cc
+++ b/pw_protobuf/decoder.cc
@@ -30,7 +30,7 @@
     return Status::OutOfRange();
   }
   previous_field_consumed_ = false;
-  return FieldSize() == 0 ? Status::DataLoss() : Status::Ok();
+  return FieldSize() == 0 ? Status::DataLoss() : OkStatus();
 }
 
 Status Decoder::SkipField() {
@@ -44,7 +44,7 @@
   }
 
   proto_ = proto_.subspan(bytes_to_skip);
-  return proto_.empty() ? Status::OutOfRange() : Status::Ok();
+  return proto_.empty() ? Status::OutOfRange() : OkStatus();
 }
 
 uint32_t Decoder::FieldNumber() const {
@@ -63,7 +63,7 @@
     return Status::OutOfRange();
   }
   *out = value;
-  return Status::Ok();
+  return OkStatus();
 }
 
 Status Decoder::ReadSint32(int32_t* out) {
@@ -76,7 +76,7 @@
     return Status::OutOfRange();
   }
   *out = value;
-  return Status::Ok();
+  return OkStatus();
 }
 
 Status Decoder::ReadSint64(int64_t* out) {
@@ -86,7 +86,7 @@
     return status;
   }
   *out = varint::ZigZagDecode(value);
-  return Status::Ok();
+  return OkStatus();
 }
 
 Status Decoder::ReadBool(bool* out) {
@@ -96,7 +96,7 @@
     return status;
   }
   *out = value;
-  return Status::Ok();
+  return OkStatus();
 }
 
 Status Decoder::ReadString(std::string_view* out) {
@@ -107,7 +107,7 @@
   }
   *out = std::string_view(reinterpret_cast<const char*>(bytes.data()),
                           bytes.size());
-  return Status::Ok();
+  return OkStatus();
 }
 
 size_t Decoder::FieldSize() const {
@@ -169,7 +169,7 @@
 
   // Advance past the key.
   proto_ = proto_.subspan(bytes_read);
-  return Status::Ok();
+  return OkStatus();
 }
 
 Status Decoder::ReadVarint(uint64_t* out) {
@@ -185,7 +185,7 @@
   // Advance to the next field.
   proto_ = proto_.subspan(bytes_read);
   previous_field_consumed_ = true;
-  return Status::Ok();
+  return OkStatus();
 }
 
 Status Decoder::ReadFixed(std::byte* out, size_t size) {
@@ -204,7 +204,7 @@
   proto_ = proto_.subspan(size);
   previous_field_consumed_ = true;
 
-  return Status::Ok();
+  return OkStatus();
 }
 
 Status Decoder::ReadDelimited(std::span<const std::byte>* out) {
@@ -228,7 +228,7 @@
   proto_ = proto_.subspan(length);
   previous_field_consumed_ = true;
 
-  return Status::Ok();
+  return OkStatus();
 }
 
 Status CallbackDecoder::Decode(std::span<const std::byte> proto) {
@@ -242,7 +242,7 @@
   // Iterate the proto, calling the handler with each field number.
   while (state_ == kDecodeInProgress) {
     if (Status status = decoder_.Next(); !status.ok()) {
-      if (status == Status::OutOfRange()) {
+      if (status.IsOutOfRange()) {
         // Reached the end of the proto.
         break;
       }
@@ -253,7 +253,7 @@
 
     Status status = handler_->ProcessField(*this, decoder_.FieldNumber());
     if (!status.ok()) {
-      state_ = status == Status::Cancelled() ? kDecodeCancelled : kDecodeFailed;
+      state_ = status.IsCancelled() ? kDecodeCancelled : kDecodeFailed;
       return status;
     }
 
@@ -269,7 +269,7 @@
   }
 
   state_ = kReady;
-  return Status::Ok();
+  return OkStatus();
 }
 
 }  // namespace pw::protobuf
diff --git a/pw_protobuf/decoder_test.cc b/pw_protobuf/decoder_test.cc
index 0b4f6ff..c77e990 100644
--- a/pw_protobuf/decoder_test.cc
+++ b/pw_protobuf/decoder_test.cc
@@ -50,7 +50,7 @@
     }
 
     called = true;
-    return Status::Ok();
+    return OkStatus();
   }
 
   bool called = false;
@@ -83,40 +83,40 @@
   Decoder decoder(std::as_bytes(std::span(encoded_proto)));
 
   int32_t v1 = 0;
-  EXPECT_EQ(decoder.Next(), Status::Ok());
+  EXPECT_EQ(decoder.Next(), OkStatus());
   ASSERT_EQ(decoder.FieldNumber(), 1u);
-  EXPECT_EQ(decoder.ReadInt32(&v1), Status::Ok());
+  EXPECT_EQ(decoder.ReadInt32(&v1), OkStatus());
   EXPECT_EQ(v1, 42);
 
   int32_t v2 = 0;
-  EXPECT_EQ(decoder.Next(), Status::Ok());
+  EXPECT_EQ(decoder.Next(), OkStatus());
   ASSERT_EQ(decoder.FieldNumber(), 2u);
-  EXPECT_EQ(decoder.ReadSint32(&v2), Status::Ok());
+  EXPECT_EQ(decoder.ReadSint32(&v2), OkStatus());
   EXPECT_EQ(v2, -13);
 
   bool v3 = true;
-  EXPECT_EQ(decoder.Next(), Status::Ok());
+  EXPECT_EQ(decoder.Next(), OkStatus());
   ASSERT_EQ(decoder.FieldNumber(), 3u);
-  EXPECT_EQ(decoder.ReadBool(&v3), Status::Ok());
+  EXPECT_EQ(decoder.ReadBool(&v3), OkStatus());
   EXPECT_FALSE(v3);
 
   double v4 = 0;
-  EXPECT_EQ(decoder.Next(), Status::Ok());
+  EXPECT_EQ(decoder.Next(), OkStatus());
   ASSERT_EQ(decoder.FieldNumber(), 4u);
-  EXPECT_EQ(decoder.ReadDouble(&v4), Status::Ok());
+  EXPECT_EQ(decoder.ReadDouble(&v4), OkStatus());
   EXPECT_EQ(v4, 3.14159);
 
   uint32_t v5 = 0;
-  EXPECT_EQ(decoder.Next(), Status::Ok());
+  EXPECT_EQ(decoder.Next(), OkStatus());
   ASSERT_EQ(decoder.FieldNumber(), 5u);
-  EXPECT_EQ(decoder.ReadFixed32(&v5), Status::Ok());
+  EXPECT_EQ(decoder.ReadFixed32(&v5), OkStatus());
   EXPECT_EQ(v5, 0xdeadbeef);
 
   std::string_view v6;
   char buffer[16];
-  EXPECT_EQ(decoder.Next(), Status::Ok());
+  EXPECT_EQ(decoder.Next(), OkStatus());
   ASSERT_EQ(decoder.FieldNumber(), 6u);
-  EXPECT_EQ(decoder.ReadString(&v6), Status::Ok());
+  EXPECT_EQ(decoder.ReadString(&v6), OkStatus());
   std::memcpy(buffer, v6.data(), v6.size());
   buffer[v6.size()] = '\0';
   EXPECT_STREQ(buffer, "Hello world");
@@ -146,13 +146,13 @@
 
   // Don't process any fields except for the fourth. Next should still iterate
   // correctly despite field values not being consumed.
-  EXPECT_EQ(decoder.Next(), Status::Ok());
-  EXPECT_EQ(decoder.Next(), Status::Ok());
-  EXPECT_EQ(decoder.Next(), Status::Ok());
-  EXPECT_EQ(decoder.Next(), Status::Ok());
+  EXPECT_EQ(decoder.Next(), OkStatus());
+  EXPECT_EQ(decoder.Next(), OkStatus());
+  EXPECT_EQ(decoder.Next(), OkStatus());
+  EXPECT_EQ(decoder.Next(), OkStatus());
   ASSERT_EQ(decoder.FieldNumber(), 4u);
-  EXPECT_EQ(decoder.Next(), Status::Ok());
-  EXPECT_EQ(decoder.Next(), Status::Ok());
+  EXPECT_EQ(decoder.Next(), OkStatus());
+  EXPECT_EQ(decoder.Next(), OkStatus());
   EXPECT_EQ(decoder.Next(), Status::OutOfRange());
 }
 
@@ -179,7 +179,7 @@
 
   decoder.set_handler(&handler);
   EXPECT_EQ(decoder.Decode(std::as_bytes(std::span(encoded_proto))),
-            Status::Ok());
+            OkStatus());
   EXPECT_TRUE(handler.called);
   EXPECT_EQ(handler.test_int32, 42);
   EXPECT_EQ(handler.test_sint32, -13);
@@ -206,7 +206,7 @@
 
   decoder.set_handler(&handler);
   EXPECT_EQ(decoder.Decode(std::as_bytes(std::span(encoded_proto))),
-            Status::Ok());
+            OkStatus());
   EXPECT_TRUE(handler.called);
   EXPECT_EQ(handler.test_int32, 44);
 }
@@ -216,7 +216,7 @@
   TestDecodeHandler handler;
 
   decoder.set_handler(&handler);
-  EXPECT_EQ(decoder.Decode(std::span<std::byte>()), Status::Ok());
+  EXPECT_EQ(decoder.Decode(std::span<std::byte>()), OkStatus());
   EXPECT_FALSE(handler.called);
   EXPECT_EQ(handler.test_int32, 0);
   EXPECT_EQ(handler.test_sint32, 0);
@@ -241,10 +241,10 @@
                       uint32_t field_number) override {
     switch (field_number) {
       case 1:
-        EXPECT_EQ(decoder.ReadInt32(&field_one), Status::Ok());
+        EXPECT_EQ(decoder.ReadInt32(&field_one), OkStatus());
         break;
       case 3:
-        EXPECT_EQ(decoder.ReadInt32(&field_three), Status::Ok());
+        EXPECT_EQ(decoder.ReadInt32(&field_three), OkStatus());
         break;
       default:
         // Do nothing.
@@ -252,7 +252,7 @@
     }
 
     called = true;
-    return Status::Ok();
+    return OkStatus();
   }
 
   bool called = false;
@@ -286,7 +286,7 @@
 
   decoder.set_handler(&handler);
   EXPECT_EQ(decoder.Decode(std::as_bytes(std::span(encoded_proto))),
-            Status::Ok());
+            OkStatus());
   EXPECT_TRUE(handler.called);
   EXPECT_EQ(handler.field_one, 42);
   EXPECT_EQ(handler.field_three, 99);
@@ -299,17 +299,17 @@
                       uint32_t field_number) override {
     switch (field_number) {
       case 1:
-        EXPECT_EQ(decoder.ReadInt32(&field_one), Status::Ok());
+        EXPECT_EQ(decoder.ReadInt32(&field_one), OkStatus());
         return Status::Cancelled();
       case 3:
-        EXPECT_EQ(decoder.ReadInt32(&field_three), Status::Ok());
+        EXPECT_EQ(decoder.ReadInt32(&field_three), OkStatus());
         break;
       default:
         // Do nothing.
         break;
     }
 
-    return Status::Ok();
+    return OkStatus();
   }
 
   int32_t field_one = 0;
diff --git a/pw_protobuf/docs.rst b/pw_protobuf/docs.rst
index ffc13bc..5a02779 100644
--- a/pw_protobuf/docs.rst
+++ b/pw_protobuf/docs.rst
@@ -27,6 +27,32 @@
 specific protobuf messages. The code generation integrates with Pigweed's GN
 build system.
 
+Configuration
+=============
+``pw_protobuf`` supports the following configuration options.
+
+* ``PW_PROTOBUF_CFG_MAX_VARINT_SIZE``:
+  When encoding nested messages, the number of bytes to reserve for the varint
+  submessage length. Nested messages are limited in size to the maximum value
+  that can be varint-encoded into this reserved space.
+
+  The values that can be set, and their corresponding maximum submessage
+  lengths, are outlined below.
+
+  +-------------------+----------------------------------------+
+  | MAX_VARINT_SIZE   | Maximum submessage length              |
+  +===================+========================================+
+  | 1 byte            | 127                                    |
+  +-------------------+----------------------------------------+
+  | 2 bytes           | 16,383 or < 16KiB                      |
+  +-------------------+----------------------------------------+
+  | 3 bytes           | 2,097,151 or < 2048KiB                 |
+  +-------------------+----------------------------------------+
+  | 4 bytes (default) | 268,435,455 or < 256MiB                |
+  +-------------------+----------------------------------------+
+  | 5 bytes           | 4,294,967,295 or < 4GiB (max uint32_t) |
+  +-------------------+----------------------------------------+
+
 Usage
 =====
 ``pw_protobuf`` splits wire format encoding and decoding operations. Links to
diff --git a/pw_protobuf/encoder.cc b/pw_protobuf/encoder.cc
index 66174fc..6ca6f6d 100644
--- a/pw_protobuf/encoder.cc
+++ b/pw_protobuf/encoder.cc
@@ -1,4 +1,4 @@
-// Copyright 2019 The Pigweed Authors
+// Copyright 2021 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
@@ -14,14 +14,15 @@
 
 #include "pw_protobuf/encoder.h"
 
+#include <limits>
+
 namespace pw::protobuf {
 
 Status Encoder::WriteUint64(uint32_t field_number, uint64_t value) {
   std::byte* original_cursor = cursor_;
   WriteFieldKey(field_number, WireType::kVarint);
-  Status status = WriteVarint(value);
-  IncreaseParentSize(cursor_ - original_cursor);
-  return status;
+  WriteVarint(value);
+  return IncreaseParentSize(cursor_ - original_cursor);
 }
 
 // Encodes a base-128 varint to the buffer.
@@ -43,7 +44,7 @@
   }
 
   cursor_ += written;
-  return Status::Ok();
+  return OkStatus();
 }
 
 Status Encoder::WriteRawBytes(const std::byte* ptr, size_t size) {
@@ -61,7 +62,7 @@
   std::memmove(cursor_, ptr, size);
 
   cursor_ += size;
-  return Status::Ok();
+  return OkStatus();
 }
 
 Status Encoder::Push(uint32_t field_number) {
@@ -90,7 +91,7 @@
   }
 
   // Update parent size with the written key.
-  IncreaseParentSize(cursor_ - original_cursor);
+  PW_TRY(IncreaseParentSize(cursor_ - original_cursor));
 
   union {
     std::byte* cursor;
@@ -105,7 +106,7 @@
   blob_stack_[depth_++] = size_cursor;
 
   cursor_ += sizeof(*size_cursor);
-  return Status::Ok();
+  return OkStatus();
 }
 
 Status Encoder::Pop() {
@@ -121,17 +122,26 @@
   // Update the parent's size with how much total space the child will take
   // after its size field is varint encoded.
   SizeType child_size = *blob_stack_[--depth_];
-  IncreaseParentSize(child_size + VarintSizeBytes(child_size));
+  PW_TRY(IncreaseParentSize(child_size + VarintSizeBytes(child_size)));
 
-  return Status::Ok();
+  // Encode the child
+  if (Status status = EncodeFrom(blob_count_ - 1).status(); !status.ok()) {
+    encode_status_ = status;
+    return encode_status_;
+  }
+  blob_count_--;
+
+  return OkStatus();
 }
 
-Result<ConstByteSpan> Encoder::Encode() {
+Result<ConstByteSpan> Encoder::Encode() { return EncodeFrom(0); }
+
+Result<ConstByteSpan> Encoder::EncodeFrom(size_t blob) {
   if (!encode_status_.ok()) {
     return encode_status_;
   }
 
-  if (blob_count_ == 0) {
+  if (blob >= blob_count_) {
     // If there are no nested blobs, the buffer already contains a valid proto.
     return Result<ConstByteSpan>(buffer_.first(EncodedSize()));
   }
@@ -143,7 +153,6 @@
 
   // Starting from the first blob, encode each size field as a varint and
   // shift all subsequent data downwards.
-  unsigned int blob = 0;
   size_cursor = blob_locations_[blob];
   std::byte* write_cursor = read_cursor;
 
@@ -180,4 +189,28 @@
   return Result<ConstByteSpan>(buffer_.first(EncodedSize()));
 }
 
+Status Encoder::IncreaseParentSize(size_t size_bytes) {
+  if (!encode_status_.ok()) {
+    return encode_status_;
+  }
+
+  if (depth_ == 0) {
+    return OkStatus();
+  }
+
+  size_t current_size = *blob_stack_[depth_ - 1];
+
+  constexpr size_t max_size =
+      std::min(varint::MaxValueInBytes(sizeof(SizeType)),
+               static_cast<uint64_t>(std::numeric_limits<uint32_t>::max()));
+
+  if (size_bytes > max_size || current_size > max_size - size_bytes) {
+    encode_status_ = Status::OutOfRange();
+    return encode_status_;
+  }
+
+  *blob_stack_[depth_ - 1] = current_size + size_bytes;
+  return OkStatus();
+}
+
 }  // namespace pw::protobuf
diff --git a/pw_protobuf/encoder_test.cc b/pw_protobuf/encoder_test.cc
index 605467b..bb41875 100644
--- a/pw_protobuf/encoder_test.cc
+++ b/pw_protobuf/encoder_test.cc
@@ -85,16 +85,16 @@
   std::byte encode_buffer[32];
   NestedEncoder encoder(encode_buffer);
 
-  EXPECT_EQ(encoder.WriteUint32(kTestProtoMagicNumberField, 42), Status::Ok());
-  EXPECT_EQ(encoder.WriteSint32(kTestProtoZiggyField, -13), Status::Ok());
+  EXPECT_EQ(encoder.WriteUint32(kTestProtoMagicNumberField, 42), OkStatus());
+  EXPECT_EQ(encoder.WriteSint32(kTestProtoZiggyField, -13), OkStatus());
   EXPECT_EQ(encoder.WriteFixed64(kTestProtoCyclesField, 0xdeadbeef8badf00d),
-            Status::Ok());
-  EXPECT_EQ(encoder.WriteFloat(kTestProtoRatioField, 1.618034), Status::Ok());
+            OkStatus());
+  EXPECT_EQ(encoder.WriteFloat(kTestProtoRatioField, 1.618034), OkStatus());
   EXPECT_EQ(encoder.WriteString(kTestProtoErrorMessageField, "broken 💩"),
-            Status::Ok());
+            OkStatus());
 
   Result result = encoder.Encode();
-  ASSERT_EQ(result.status(), Status::Ok());
+  ASSERT_EQ(result.status(), OkStatus());
   EXPECT_EQ(result.value().size(), sizeof(encoded_proto));
   EXPECT_EQ(
       std::memcmp(result.value().data(), encoded_proto, sizeof(encoded_proto)),
@@ -106,9 +106,9 @@
   NestedEncoder encoder(encode_buffer);
 
   // 2 bytes.
-  EXPECT_EQ(encoder.WriteUint32(kTestProtoMagicNumberField, 42), Status::Ok());
+  EXPECT_EQ(encoder.WriteUint32(kTestProtoMagicNumberField, 42), OkStatus());
   // 2 bytes.
-  EXPECT_EQ(encoder.WriteSint32(kTestProtoZiggyField, -13), Status::Ok());
+  EXPECT_EQ(encoder.WriteSint32(kTestProtoZiggyField, -13), OkStatus());
   // 9 bytes; not enough space! The encoder will start writing the field but
   // should rollback when it realizes it doesn't have enough space.
   EXPECT_EQ(encoder.WriteFixed64(kTestProtoCyclesField, 0xdeadbeef8badf00d),
@@ -124,7 +124,7 @@
   std::byte encode_buffer[12];
   NestedEncoder encoder(encode_buffer);
 
-  EXPECT_EQ(encoder.WriteUint32(kTestProtoMagicNumberField, 42), Status::Ok());
+  EXPECT_EQ(encoder.WriteUint32(kTestProtoMagicNumberField, 42), OkStatus());
   // Invalid proto field numbers.
   EXPECT_EQ(encoder.WriteUint32(0, 1337), Status::InvalidArgument());
   encoder.Clear();
@@ -138,52 +138,51 @@
 
 TEST(Encoder, Nested) {
   std::byte encode_buffer[128];
-  NestedEncoder<5, 10> encoder(encode_buffer);
+  NestedEncoder<5, 5> encoder(encode_buffer);
 
   // TestProto test_proto;
   // test_proto.magic_number = 42;
-  EXPECT_EQ(encoder.WriteUint32(kTestProtoMagicNumberField, 42), Status::Ok());
+  EXPECT_EQ(encoder.WriteUint32(kTestProtoMagicNumberField, 42), OkStatus());
 
   {
     // NestedProto& nested_proto = test_proto.nested;
-    EXPECT_EQ(encoder.Push(kTestProtoNestedField), Status::Ok());
+    EXPECT_EQ(encoder.Push(kTestProtoNestedField), OkStatus());
     // nested_proto.hello = "world";
-    EXPECT_EQ(encoder.WriteString(kNestedProtoHelloField, "world"),
-              Status::Ok());
+    EXPECT_EQ(encoder.WriteString(kNestedProtoHelloField, "world"), OkStatus());
     // nested_proto.id = 999;
-    EXPECT_EQ(encoder.WriteUint32(kNestedProtoIdField, 999), Status::Ok());
+    EXPECT_EQ(encoder.WriteUint32(kNestedProtoIdField, 999), OkStatus());
 
     {
       // DoubleNestedProto& double_nested_proto = nested_proto.append_pair();
-      EXPECT_EQ(encoder.Push(kNestedProtoPairField), Status::Ok());
+      EXPECT_EQ(encoder.Push(kNestedProtoPairField), OkStatus());
       // double_nested_proto.key = "version";
       EXPECT_EQ(encoder.WriteString(kDoubleNestedProtoKeyField, "version"),
-                Status::Ok());
+                OkStatus());
       // double_nested_proto.value = "2.9.1";
       EXPECT_EQ(encoder.WriteString(kDoubleNestedProtoValueField, "2.9.1"),
-                Status::Ok());
+                OkStatus());
 
-      EXPECT_EQ(encoder.Pop(), Status::Ok());
+      EXPECT_EQ(encoder.Pop(), OkStatus());
     }  // end DoubleNestedProto
 
     {
       // DoubleNestedProto& double_nested_proto = nested_proto.append_pair();
-      EXPECT_EQ(encoder.Push(kNestedProtoPairField), Status::Ok());
+      EXPECT_EQ(encoder.Push(kNestedProtoPairField), OkStatus());
       // double_nested_proto.key = "device";
       EXPECT_EQ(encoder.WriteString(kDoubleNestedProtoKeyField, "device"),
-                Status::Ok());
+                OkStatus());
       // double_nested_proto.value = "left-soc";
       EXPECT_EQ(encoder.WriteString(kDoubleNestedProtoValueField, "left-soc"),
-                Status::Ok());
+                OkStatus());
 
-      EXPECT_EQ(encoder.Pop(), Status::Ok());
+      EXPECT_EQ(encoder.Pop(), OkStatus());
     }  // end DoubleNestedProto
 
-    EXPECT_EQ(encoder.Pop(), Status::Ok());
+    EXPECT_EQ(encoder.Pop(), OkStatus());
   }  // end NestedProto
 
   // test_proto.ziggy = -13;
-  EXPECT_EQ(encoder.WriteSint32(kTestProtoZiggyField, -13), Status::Ok());
+  EXPECT_EQ(encoder.WriteSint32(kTestProtoZiggyField, -13), OkStatus());
 
   // clang-format off
   constexpr uint8_t encoded_proto[] = {
@@ -213,7 +212,7 @@
   // clang-format on
 
   Result result = encoder.Encode();
-  ASSERT_EQ(result.status(), Status::Ok());
+  ASSERT_EQ(result.status(), OkStatus());
   EXPECT_EQ(result.value().size(), sizeof(encoded_proto));
   EXPECT_EQ(
       std::memcmp(result.value().data(), encoded_proto, sizeof(encoded_proto)),
@@ -222,12 +221,12 @@
 
 TEST(Encoder, NestedDepthLimit) {
   std::byte encode_buffer[128];
-  NestedEncoder<2, 10> encoder(encode_buffer);
+  NestedEncoder<2, 2> encoder(encode_buffer);
 
   // One level of nesting.
-  EXPECT_EQ(encoder.Push(2), Status::Ok());
+  EXPECT_EQ(encoder.Push(2), OkStatus());
   // Two levels of nesting.
-  EXPECT_EQ(encoder.Push(1), Status::Ok());
+  EXPECT_EQ(encoder.Push(1), OkStatus());
   // Three levels of nesting: error!
   EXPECT_EQ(encoder.Push(1), Status::ResourceExhausted());
 
@@ -239,26 +238,25 @@
 
 TEST(Encoder, NestedBlobLimit) {
   std::byte encode_buffer[128];
-  NestedEncoder<5, 3> encoder(encode_buffer);
+  NestedEncoder<3, 3> encoder(encode_buffer);
 
   // Write first blob.
-  EXPECT_EQ(encoder.Push(1), Status::Ok());
-  EXPECT_EQ(encoder.Pop(), Status::Ok());
+  EXPECT_EQ(encoder.Push(1), OkStatus());
+  EXPECT_EQ(encoder.Pop(), OkStatus());
 
   // Write second blob.
-  EXPECT_EQ(encoder.Push(2), Status::Ok());
+  EXPECT_EQ(encoder.Push(2), OkStatus());
 
   // Write nested third blob.
-  EXPECT_EQ(encoder.Push(3), Status::Ok());
-  EXPECT_EQ(encoder.Pop(), Status::Ok());
+  EXPECT_EQ(encoder.Push(3), OkStatus());
+  EXPECT_EQ(encoder.Pop(), OkStatus());
 
   // End second blob.
-  EXPECT_EQ(encoder.Pop(), Status::Ok());
+  EXPECT_EQ(encoder.Pop(), OkStatus());
 
-  // Write fourth blob: error!.
-  EXPECT_EQ(encoder.Push(4), Status::ResourceExhausted());
-  // Nothing to pop.
-  EXPECT_EQ(encoder.Pop(), Status::ResourceExhausted());
+  // Write fourth blob: OK
+  EXPECT_EQ(encoder.Push(4), OkStatus());
+  EXPECT_EQ(encoder.Pop(), OkStatus());
 }
 
 TEST(Encoder, RepeatedField) {
@@ -275,7 +273,7 @@
       0x08, 0x00, 0x08, 0x32, 0x08, 0x64, 0x08, 0x96, 0x01, 0x08, 0xc8, 0x01};
 
   Result result = encoder.Encode();
-  ASSERT_EQ(result.status(), Status::Ok());
+  ASSERT_EQ(result.status(), OkStatus());
   EXPECT_EQ(result.value().size(), sizeof(encoded_proto));
   EXPECT_EQ(
       std::memcmp(result.value().data(), encoded_proto, sizeof(encoded_proto)),
@@ -295,7 +293,7 @@
   //  key   size  v[0]  v[1]  v[2]  v[3]        v[4]
 
   Result result = encoder.Encode();
-  ASSERT_EQ(result.status(), Status::Ok());
+  ASSERT_EQ(result.status(), OkStatus());
   EXPECT_EQ(result.value().size(), sizeof(encoded_proto));
   EXPECT_EQ(
       std::memcmp(result.value().data(), encoded_proto, sizeof(encoded_proto)),
@@ -330,7 +328,7 @@
       0x12, 0x08, 0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01};
 
   Result result = encoder.Encode();
-  ASSERT_EQ(result.status(), Status::Ok());
+  ASSERT_EQ(result.status(), OkStatus());
   EXPECT_EQ(result.value().size(), sizeof(encoded_proto));
   EXPECT_EQ(
       std::memcmp(result.value().data(), encoded_proto, sizeof(encoded_proto)),
@@ -349,7 +347,7 @@
       0x0a, 0x09, 0xc7, 0x01, 0x31, 0x01, 0x00, 0x02, 0x32, 0xc8, 0x01};
 
   Result result = encoder.Encode();
-  ASSERT_EQ(result.status(), Status::Ok());
+  ASSERT_EQ(result.status(), OkStatus());
   EXPECT_EQ(result.value().size(), sizeof(encoded_proto));
   EXPECT_EQ(
       std::memcmp(result.value().data(), encoded_proto, sizeof(encoded_proto)),
diff --git a/pw_protobuf/find.cc b/pw_protobuf/find.cc
index 635f50b..0082726 100644
--- a/pw_protobuf/find.cc
+++ b/pw_protobuf/find.cc
@@ -20,7 +20,7 @@
                                        uint32_t field_number) {
   if (field_number != field_number_) {
     // Continue to the next field.
-    return Status::Ok();
+    return OkStatus();
   }
 
   found_ = true;
diff --git a/pw_protobuf/public/pw_protobuf/config.h b/pw_protobuf/public/pw_protobuf/config.h
new file mode 100644
index 0000000..c7a12eb
--- /dev/null
+++ b/pw_protobuf/public/pw_protobuf/config.h
@@ -0,0 +1,56 @@
+// Copyright 2021 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.
+
+// Configuration macros for the protobuf module.
+#pragma once
+
+#include <cstddef>
+
+// When encoding nested messages, the number of bytes to reserve for the varint
+// submessage length. Nested messages are limited in size to the maximum value
+// that can be varint-encoded into this reserved space.
+//
+// The values that can be set, and their corresponding maximum submessage
+// lengths, are outlined below.
+//
+//   1 byte  => 127
+//   2 bytes => 16,383 or < 16KiB
+//   3 bytes => 2,097,151 or < 2048KiB
+//   4 bytes => 268,435,455 or < 256MiB
+//   5 bytes => 4,294,967,295 or < 4GiB (max uint32_t)
+//
+#ifndef PW_PROTOBUF_CFG_MAX_VARINT_SIZE
+#define PW_PROTOBUF_CFG_MAX_VARINT_SIZE 4
+#endif  // PW_PROTOBUF_MAX_VARINT_SIZE
+
+static_assert(PW_PROTOBUF_CFG_MAX_VARINT_SIZE > 0 &&
+              PW_PROTOBUF_CFG_MAX_VARINT_SIZE <= 5);
+
+namespace pw::protobuf::config {
+
+inline constexpr size_t kMaxVarintSize = PW_PROTOBUF_CFG_MAX_VARINT_SIZE;
+
+// TODO(frolv): This converts the configured varint length to the legacy encoder
+// SizeType. Remove this with the encoder rewrite.
+#if PW_PROTOBUF_CFG_MAX_VARINT_SIZE == 1
+using SizeType = uint8_t;
+#elif PW_PROTOBUF_CFG_MAX_VARINT_SIZE == 2
+using SizeType = uint16_t;
+#elif PW_PROTOBUF_CFG_MAX_VARINT_SIZE <= 4
+using SizeType = uint32_t;
+#else
+using SizeType = uint64_t;
+#endif
+
+}  // namespace pw::protobuf::config
diff --git a/pw_protobuf/public/pw_protobuf/decoder.h b/pw_protobuf/public/pw_protobuf/decoder.h
index 6cb402e..baa076c 100644
--- a/pw_protobuf/public/pw_protobuf/decoder.h
+++ b/pw_protobuf/public/pw_protobuf/decoder.h
@@ -192,7 +192,7 @@
 //           break;
 //       }
 //
-//       return Status::Ok();
+//       return OkStatus();
 //     }
 //
 //     int bar;
@@ -303,7 +303,7 @@
   // Receives a pointer to the decoder object, allowing the handler to call
   // the appropriate method to extract the field's data.
   //
-  // If the status returned is not Status::Ok(), the decode operation is exited
+  // If the status returned is not OkStatus(), the decode operation is exited
   // with the provided status. Returning Status::Cancelled() allows a convenient
   // way of stopping a decode early (for example, if a desired field is found).
   virtual Status ProcessField(CallbackDecoder& decoder,
diff --git a/pw_protobuf/public/pw_protobuf/encoder.h b/pw_protobuf/public/pw_protobuf/encoder.h
index c859009..fbf9acf 100644
--- a/pw_protobuf/public/pw_protobuf/encoder.h
+++ b/pw_protobuf/public/pw_protobuf/encoder.h
@@ -1,4 +1,4 @@
-// Copyright 2019 The Pigweed Authors
+// Copyright 2021 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
@@ -18,9 +18,10 @@
 #include <span>
 
 #include "pw_bytes/span.h"
+#include "pw_protobuf/config.h"
 #include "pw_protobuf/wire_format.h"
 #include "pw_result/result.h"
-#include "pw_status/status.h"
+#include "pw_status/try.h"
 #include "pw_varint/varint.h"
 
 namespace pw::protobuf {
@@ -28,10 +29,7 @@
 // A streaming protobuf encoder which encodes to a user-specified buffer.
 class Encoder {
  public:
-  // TODO(frolv): Right now, only one intermediate size is supported. However,
-  // this can be wasteful, as it requires 4 or 8 bytes of space per nested
-  // message. This can be templated to minimize the overhead.
-  using SizeType = size_t;
+  using SizeType = config::SizeType;
 
   constexpr Encoder(ByteSpan buffer,
                     std::span<SizeType*> locations,
@@ -42,7 +40,7 @@
         blob_count_(0),
         blob_stack_(stack),
         depth_(0),
-        encode_status_(Status::Ok()) {}
+        encode_status_(OkStatus()) {}
 
   // Disallow copy/assign to avoid confusion about who owns the buffer.
   Encoder(const Encoder& other) = delete;
@@ -144,9 +142,8 @@
   Status WriteFixed32(uint32_t field_number, uint32_t value) {
     std::byte* original_cursor = cursor_;
     WriteFieldKey(field_number, WireType::kFixed32);
-    Status status = WriteRawBytes(value);
-    IncreaseParentSize(cursor_ - original_cursor);
-    return status;
+    WriteRawBytes(value);
+    return IncreaseParentSize(cursor_ - original_cursor);
   }
 
   // Writes a repeated fixed32 field using packed encoding.
@@ -159,9 +156,8 @@
   Status WriteFixed64(uint32_t field_number, uint64_t value) {
     std::byte* original_cursor = cursor_;
     WriteFieldKey(field_number, WireType::kFixed64);
-    Status status = WriteRawBytes(value);
-    IncreaseParentSize(cursor_ - original_cursor);
-    return status;
+    WriteRawBytes(value);
+    return IncreaseParentSize(cursor_ - original_cursor);
   }
 
   // Writes a repeated fixed64 field using packed encoding.
@@ -198,9 +194,8 @@
                   "Float and uint32_t are not the same size");
     std::byte* original_cursor = cursor_;
     WriteFieldKey(field_number, WireType::kFixed32);
-    Status status = WriteRawBytes(value);
-    IncreaseParentSize(cursor_ - original_cursor);
-    return status;
+    WriteRawBytes(value);
+    return IncreaseParentSize(cursor_ - original_cursor);
   }
 
   // Writes a repeated float field using packed encoding.
@@ -215,9 +210,8 @@
                   "Double and uint64_t are not the same size");
     std::byte* original_cursor = cursor_;
     WriteFieldKey(field_number, WireType::kFixed64);
-    Status status = WriteRawBytes(value);
-    IncreaseParentSize(cursor_ - original_cursor);
-    return status;
+    WriteRawBytes(value);
+    return IncreaseParentSize(cursor_ - original_cursor);
   }
 
   // Writes a repeated double field using packed encoding.
@@ -231,9 +225,8 @@
     std::byte* original_cursor = cursor_;
     WriteFieldKey(field_number, WireType::kDelimited);
     WriteVarint(value.size_bytes());
-    Status status = WriteRawBytes(value.data(), value.size_bytes());
-    IncreaseParentSize(cursor_ - original_cursor);
-    return status;
+    WriteRawBytes(value.data(), value.size_bytes());
+    return IncreaseParentSize(cursor_ - original_cursor);
   }
 
   // Writes a proto string key-value pair.
@@ -261,7 +254,7 @@
   // obtained from Encode().
   void Clear() {
     cursor_ = buffer_.data();
-    encode_status_ = Status::Ok();
+    encode_status_ = OkStatus();
     blob_count_ = 0;
     depth_ = 0;
   }
@@ -278,7 +271,7 @@
       return result.status();
     }
     *out = result.value();
-    return Status::Ok();
+    return OkStatus();
   }
 
  private:
@@ -330,17 +323,13 @@
         WriteVarint(value);
       }
     }
-    IncreaseParentSize(cursor_ - original_cursor);
+    PW_TRY(IncreaseParentSize(cursor_ - original_cursor));
 
     return Pop();
   }
 
   // Adds to the parent proto's size field in the buffer.
-  void IncreaseParentSize(size_t bytes) {
-    if (depth_ > 0) {
-      *blob_stack_[depth_ - 1] += bytes;
-    }
-  }
+  Status IncreaseParentSize(size_t size_bytes);
 
   // Returns the size of `n` encoded as a varint.
   size_t VarintSizeBytes(uint64_t n) {
@@ -352,6 +341,9 @@
     return size_bytes;
   }
 
+  // Do the actual (potentially partial) encoding. Also used in Pop().
+  Result<ConstByteSpan> EncodeFrom(size_t blob);
+
   // The buffer into which the proto is encoded.
   ByteSpan buffer_;
   std::byte* cursor_;
@@ -369,7 +361,7 @@
 };
 
 // A proto encoder with its own blob stack.
-template <size_t kMaxNestedDepth = 1, size_t kMaxBlobs = 1>
+template <size_t kMaxNestedDepth = 1, size_t kMaxBlobs = kMaxNestedDepth>
 class NestedEncoder : public Encoder {
  public:
   NestedEncoder(ByteSpan buffer) : Encoder(buffer, blobs_, stack_) {}
@@ -379,8 +371,8 @@
   NestedEncoder& operator=(const NestedEncoder& other) = delete;
 
  private:
-  std::array<size_t*, kMaxBlobs> blobs_;
-  std::array<size_t*, kMaxNestedDepth> stack_;
+  std::array<Encoder::SizeType*, kMaxBlobs> blobs_;
+  std::array<Encoder::SizeType*, kMaxNestedDepth> stack_;
 };
 
 // Explicit template argument deduction to hide warnings.
diff --git a/pw_protobuf/pw_protobuf_protos/common.proto b/pw_protobuf/pw_protobuf_protos/common.proto
new file mode 100644
index 0000000..3e6aadf
--- /dev/null
+++ b/pw_protobuf/pw_protobuf_protos/common.proto
@@ -0,0 +1,19 @@
+// 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.
+
+syntax = "proto3";
+
+package pw.protobuf;
+
+message Empty {}
diff --git a/pw_protobuf/pw_protobuf_protos/test_protos/importer.proto b/pw_protobuf/pw_protobuf_protos/test_protos/importer.proto
deleted file mode 100644
index 298a88c..0000000
--- a/pw_protobuf/pw_protobuf_protos/test_protos/importer.proto
+++ /dev/null
@@ -1,23 +0,0 @@
-// 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.
-syntax = "proto3";
-
-import 'pw_protobuf_protos/test_protos/imported.proto';
-
-package pw.protobuf.test;
-
-message Period {
-  imported.Timestamp start = 1;
-  imported.Timestamp end = 2;
-}
diff --git a/pw_protobuf/pw_protobuf_protos/test_protos/full_test.proto b/pw_protobuf/pw_protobuf_test_protos/full_test.proto
similarity index 100%
rename from pw_protobuf/pw_protobuf_protos/test_protos/full_test.proto
rename to pw_protobuf/pw_protobuf_test_protos/full_test.proto
diff --git a/pw_protobuf/pw_protobuf_protos/test_protos/imported.proto b/pw_protobuf/pw_protobuf_test_protos/imported.proto
similarity index 100%
rename from pw_protobuf/pw_protobuf_protos/test_protos/imported.proto
rename to pw_protobuf/pw_protobuf_test_protos/imported.proto
diff --git a/pw_protobuf/pw_protobuf_test_protos/importer.proto b/pw_protobuf/pw_protobuf_test_protos/importer.proto
new file mode 100644
index 0000000..39ad8b9
--- /dev/null
+++ b/pw_protobuf/pw_protobuf_test_protos/importer.proto
@@ -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.
+syntax = "proto3";
+
+import 'pw_protobuf_test_protos/imported.proto';
+import 'pw_protobuf_protos/common.proto';
+
+package pw.protobuf.test;
+
+message Period {
+  imported.Timestamp start = 1;
+  imported.Timestamp end = 2;
+}
+
+message Nothing {
+  pw.protobuf.Empty nothing = 1;
+}
diff --git a/pw_protobuf/pw_protobuf_protos/test_protos/non_pw_package.proto b/pw_protobuf/pw_protobuf_test_protos/non_pw_package.proto
similarity index 100%
rename from pw_protobuf/pw_protobuf_protos/test_protos/non_pw_package.proto
rename to pw_protobuf/pw_protobuf_test_protos/non_pw_package.proto
diff --git a/pw_protobuf/pw_protobuf_protos/test_protos/proto2.proto b/pw_protobuf/pw_protobuf_test_protos/proto2.proto
similarity index 100%
rename from pw_protobuf/pw_protobuf_protos/test_protos/proto2.proto
rename to pw_protobuf/pw_protobuf_test_protos/proto2.proto
diff --git a/pw_protobuf/pw_protobuf_protos/test_protos/repeated.proto b/pw_protobuf/pw_protobuf_test_protos/repeated.proto
similarity index 100%
rename from pw_protobuf/pw_protobuf_protos/test_protos/repeated.proto
rename to pw_protobuf/pw_protobuf_test_protos/repeated.proto
diff --git a/pw_protobuf/py/BUILD.gn b/pw_protobuf/py/BUILD.gn
index edee3aa..8f363da 100644
--- a/pw_protobuf/py/BUILD.gn
+++ b/pw_protobuf/py/BUILD.gn
@@ -25,4 +25,6 @@
     "pw_protobuf/plugin.py",
     "pw_protobuf/proto_tree.py",
   ]
+  python_deps = [ "$dir_pw_cli/py" ]
+  pylintrc = "$dir_pigweed/.pylintrc"
 }
diff --git a/pw_protobuf/py/pw_protobuf/plugin.py b/pw_protobuf/py/pw_protobuf/plugin.py
index 3702a6e..058a29d 100755
--- a/pw_protobuf/py/pw_protobuf/plugin.py
+++ b/pw_protobuf/py/pw_protobuf/plugin.py
@@ -54,6 +54,11 @@
     request = plugin_pb2.CodeGeneratorRequest.FromString(data)
     response = plugin_pb2.CodeGeneratorResponse()
     process_proto_request(request, response)
+
+    # Declare that this plugin supports optional fields in proto3.
+    response.supported_features |= (  # type: ignore[attr-defined]
+        response.FEATURE_PROTO3_OPTIONAL)  # type: ignore[attr-defined]
+
     sys.stdout.buffer.write(response.SerializeToString())
     return 0
 
diff --git a/pw_protobuf/py/pw_protobuf/proto_tree.py b/pw_protobuf/py/pw_protobuf/proto_tree.py
index 95abf44..6dedf82 100644
--- a/pw_protobuf/py/pw_protobuf/proto_tree.py
+++ b/pw_protobuf/py/pw_protobuf/proto_tree.py
@@ -67,8 +67,8 @@
 
     def cpp_namespace(self, root: Optional['ProtoNode'] = None) -> str:
         """C++ namespace of the node, up to the specified root."""
-        return '::'.join(
-            self._attr_hierarchy(lambda node: node.cpp_name(), root))
+        return '::'.join(name for name in self._attr_hierarchy(
+            lambda node: node.cpp_name(), root) if name)
 
     def proto_path(self) -> str:
         """Fully-qualified package path of the node."""
@@ -324,10 +324,14 @@
 class ProtoServiceMethod:
     """A method defined in a protobuf service."""
     class Type(enum.Enum):
-        UNARY = 0
-        SERVER_STREAMING = 1
-        CLIENT_STREAMING = 2
-        BIDIRECTIONAL_STREAMING = 3
+        UNARY = 'kUnary'
+        SERVER_STREAMING = 'kServerStreaming'
+        CLIENT_STREAMING = 'kClientStreaming'
+        BIDIRECTIONAL_STREAMING = 'kBidirectionalStreaming'
+
+        def cc_enum(self) -> str:
+            """Returns the pw_rpc MethodType C++ enum for this method type."""
+            return '::pw::rpc::internal::MethodType::' + self.value
 
     def __init__(self, name: str, method_type: Type, request_type: ProtoNode,
                  response_type: ProtoNode):
diff --git a/pw_protobuf/py/setup.py b/pw_protobuf/py/setup.py
index 96a8209..d55b53b 100644
--- a/pw_protobuf/py/setup.py
+++ b/pw_protobuf/py/setup.py
@@ -29,5 +29,6 @@
     },
     install_requires=[
         'protobuf',
+        'pw_cli',
     ],
 )
diff --git a/pw_protobuf/varint_size_test.cc b/pw_protobuf/varint_size_test.cc
new file mode 100644
index 0000000..246b265
--- /dev/null
+++ b/pw_protobuf/varint_size_test.cc
@@ -0,0 +1,73 @@
+// Copyright 2021 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.
+
+#include "gtest/gtest.h"
+#include "pw_bytes/array.h"
+#include "pw_protobuf/encoder.h"
+
+namespace pw::protobuf {
+namespace {
+
+TEST(Encoder, SizeTypeIsConfigured) {
+  static_assert(sizeof(Encoder::SizeType) == sizeof(uint8_t));
+}
+
+TEST(Encoder, NestedWriteSmallerThanVarintSize) {
+  std::array<std::byte, 256> buffer;
+  NestedEncoder<2, 2> encoder(buffer);
+
+  encoder.Push(1);
+  // 1 byte key + 1 byte size + 125 byte value = 127 byte nested length.
+  EXPECT_EQ(encoder.WriteBytes(2, bytes::Initialized<125>(0xaa)), OkStatus());
+  encoder.Pop();
+
+  auto result = encoder.Encode();
+  EXPECT_EQ(result.status(), OkStatus());
+}
+
+TEST(Encoder, NestedWriteLargerThanVarintSizeReturnsOutOfRange) {
+  std::array<std::byte, 256> buffer;
+  NestedEncoder<2, 2> encoder(buffer);
+
+  // Try to write a larger nested message than the max nested varint value.
+  encoder.Push(1);
+  // 1 byte key + 1 byte size + 126 byte value = 128 byte nested length.
+  EXPECT_EQ(encoder.WriteBytes(2, bytes::Initialized<126>(0xaa)),
+            Status::OutOfRange());
+  EXPECT_EQ(encoder.WriteUint32(3, 42), Status::OutOfRange());
+  encoder.Pop();
+
+  auto result = encoder.Encode();
+  EXPECT_EQ(result.status(), Status::OutOfRange());
+}
+
+TEST(Encoder, NestedMessageLargerThanVarintSizeReturnsOutOfRange) {
+  std::array<std::byte, 256> buffer;
+  NestedEncoder<2, 2> encoder(buffer);
+
+  // Try to write a larger nested message than the max nested varint value as
+  // multiple smaller writes.
+  encoder.Push(1);
+  EXPECT_EQ(encoder.WriteBytes(2, bytes::Initialized<60>(0xaa)), OkStatus());
+  EXPECT_EQ(encoder.WriteBytes(3, bytes::Initialized<60>(0xaa)), OkStatus());
+  EXPECT_EQ(encoder.WriteBytes(4, bytes::Initialized<60>(0xaa)),
+            Status::OutOfRange());
+  encoder.Pop();
+
+  auto result = encoder.Encode();
+  EXPECT_EQ(result.status(), Status::OutOfRange());
+}
+
+}  // namespace
+}  // namespace pw::protobuf
diff --git a/pw_protobuf_compiler/BUILD.gn b/pw_protobuf_compiler/BUILD.gn
index 58dce83..c611c6b 100644
--- a/pw_protobuf_compiler/BUILD.gn
+++ b/pw_protobuf_compiler/BUILD.gn
@@ -14,9 +14,10 @@
 
 import("//build_overrides/pigweed.gni")
 
-import("$dir_pw_build/input_group.gni")
+import("$dir_pw_build/python.gni")
 import("$dir_pw_docgen/docs.gni")
 import("$dir_pw_protobuf_compiler/proto.gni")
+import("$dir_pw_third_party/nanopb/nanopb.gni")
 import("$dir_pw_unit_test/test.gni")
 
 pw_doc_group("docs") {
@@ -34,5 +35,22 @@
 }
 
 pw_proto_library("nanopb_test_protos") {
-  sources = [ "pw_protobuf_compiler_protos/nanopb_test.proto" ]
+  sources = [ "pw_protobuf_compiler_nanopb_protos/nanopb_test.proto" ]
+
+  if (dir_pw_third_party_nanopb != "") {
+    deps = [ "$dir_pw_third_party/nanopb:proto" ]
+  }
+}
+
+pw_proto_library("test_protos") {
+  sources = [
+    "pw_protobuf_compiler_protos/nested/more_nesting/test.proto",
+    "pw_protobuf_compiler_protos/test.proto",
+  ]
+}
+
+# PyPI Requirements needed to install Python protobuf packages.
+pw_python_requirements("protobuf_requirements") {
+  # mypy-protobuf 1.24 is required to support optional fields in proto3.
+  requirements = [ "mypy-protobuf>=2.2" ]
 }
diff --git a/pw_protobuf_compiler/docs.rst b/pw_protobuf_compiler/docs.rst
index 3e63881..eeb1620 100644
--- a/pw_protobuf_compiler/docs.rst
+++ b/pw_protobuf_compiler/docs.rst
@@ -15,9 +15,6 @@
 +-------------+----------------+-----------------------------------------------+
 | pw_protobuf | ``pwpb``       | Compiles using ``pw_protobuf``.               |
 +-------------+----------------+-----------------------------------------------+
-| Go          | ``go``         | Compiles using the standard Go protobuf       |
-|             |                | plugin with gRPC service support.             |
-+-------------+----------------+-----------------------------------------------+
 | Nanopb      | ``nanopb``     | Compiles using Nanopb. The build argument     |
 |             |                | ``dir_pw_third_party_nanopb`` must be set to  |
 |             |                | point to a local nanopb installation.         |
@@ -27,6 +24,12 @@
 +-------------+----------------+-----------------------------------------------+
 | Raw RPC     | ``raw_rpc``    | Compiles raw binary pw_rpc service code.      |
 +-------------+----------------+-----------------------------------------------+
+| Go          | ``go``         | Compiles using the standard Go protobuf       |
+|             |                | plugin with gRPC service support.             |
++-------------+----------------+-----------------------------------------------+
+| Python      | ``python``     | Compiles using the standard Python protobuf   |
+|             |                | plugin, creating a ``pw_python_package``.     |
++-------------+----------------+-----------------------------------------------+
 
 GN template
 ===========
@@ -43,7 +46,7 @@
 .. code-block::
 
   pw_proto_library("test_protos") {
-    sources = [ "test.proto" ]
+    sources = [ "my_test_protos/test.proto" ]
   }
 
 ``test_protos.pwpb`` compiles code for pw_protobuf, and ``test_protos.nanopb``
@@ -52,28 +55,57 @@
 Protobuf code is only generated when a generator sub-target is listed as a
 dependency of another GN target.
 
+GN permits using abbreviated labels when the target name matches the directory
+name (e.g. ``//foo`` for ``//foo:foo``). For consistency with this, the
+sub-targets for each generator are aliased to the directory when the target name
+is the same. For example, these two labels are equivalent:
+
+.. code-block::
+
+  //path/to/my_protos:my_protos.pwpb
+  //path/to/my_protos:pwpb
+
+``pw_python_package`` subtargets are also available on the ``python`` subtarget:
+
+.. code-block::
+
+  //path/to/my_protos:my_protos.python.lint
+  //path/to/my_protos:python.lint
+
 **Arguments**
 
-* ``sources``: List of ``.proto`` files.
-* ``deps``: Other ``pw_proto_library`` targets that this one depends on.
+* ``sources``: List of input .proto files.
+* ``deps``: List of other pw_proto_library dependencies.
+* ``inputs``: Other files on which the protos depend (e.g. nanopb ``.options``
+  files).
+* ``prefix``: A prefix to add to the source protos prior to compilation. For
+  example, a source called ``"foo.proto"`` with ``prefix = "nested"`` will be
+  compiled with protoc as ``"nested/foo.proto"``.
+* ``strip_prefix``: Remove this prefix from the source protos. All source and
+  input files must be nested under this path.
+* ``python_package``: Label of Python package to which to add the proto modules.
 
 **Example**
 
-.. code::
+.. code-block::
 
   import("$dir_pw_protobuf_compiler/proto.gni")
 
   pw_proto_library("my_protos") {
     sources = [
-      "foo.proto",
-      "bar.proto",
+      "my_protos/foo.proto",
+      "my_protos/bar.proto",
     ]
   }
 
   pw_proto_library("my_other_protos") {
-    sources = [
-      "baz.proto",  # imports foo.proto
-    ]
+    sources = [ "some/other/path/baz.proto" ]  # imports foo.proto
+
+    # This removes the "some/other/path" prefix from the proto files.
+    strip_prefix = "some/other/path"
+
+    # This adds the "my_other_protos/" prefix to the proto files.
+    prefix = "my_other_protos"
 
     # Proto libraries depend on other proto libraries directly.
     deps = [ ":my_protos" ]
@@ -89,3 +121,184 @@
     # When depending on protos in a source_set, specify the generator suffix.
     deps = [ ":my_other_protos.pwpb" ]
   }
+
+From C++, ``baz.proto`` included as follows:
+
+.. code-block:: cpp
+
+  #include "my_other_protos/baz.pwpb.h"
+
+From Python, ``baz.proto`` is imported as follows:
+
+.. code-block:: python
+
+  from my_other_protos import baz_pb2
+
+Proto file structure
+--------------------
+Protobuf source files must be nested under another directory when they are
+compiled. This ensures that they can be packaged properly in Python. The first
+directory is used as the Python package name, so must be unique across the
+build. The ``prefix`` option may be used to set this directory.
+
+Using ``prefix`` and ``strip_prefix`` together allows remapping proto files to
+a completely different path. This can be useful when working with protos defined
+in external libraries. For example, consider this proto library:
+
+.. code-block::
+
+  pw_proto_library("external_protos") {
+    sources = [
+      "//other/external/some_library/src/protos/alpha.proto",
+      "//other/external/some_library/src/protos/beta.proto,
+      "//other/external/some_library/src/protos/internal/gamma.proto",
+    ]
+    strip_prefix = "//other/external/some_library/src/protos"
+    prefix = "some_library"
+  }
+
+These protos will be compiled by protoc as if they were in this file structure:
+
+.. code-block::
+
+  some_library/
+  ├── alpha.proto
+  ├── beta.proto
+  └── internal
+      └── gamma.proto
+
+.. _module-pw_protobuf_compiler-add-to-python-package:
+
+Adding Python proto modules to an existing package
+--------------------------------------------------
+By default, generated Python proto modules are organized into their own Python
+package. These proto modules can instead be added to an existing Python package
+declared with ``pw_python_package``. This is done by setting the
+``python_package`` argument on the ``pw_proto_library`` and the
+``proto_library`` argument on the ``pw_python_package``.
+
+For example, the protos declared in ``my_protos`` will be nested in the Python
+package declared by ``my_package``.
+
+.. code-block::
+
+  pw_proto_library("my_protos") {
+    sources = [ "hello.proto ]
+    prefix = "foo"
+    python_package = ":my_package"
+  }
+
+  pw_python_pacakge("my_package") {
+    generate_setup = {
+      name = "foo"
+      version = "1.0"
+    }
+    sources = [ "foo/cool_module.py" ]
+    proto_library = ":my_protos"
+  }
+
+The ``hello_pb2.py`` proto module can be used alongside other files in the
+``foo`` package.
+
+.. code-block:: python
+
+  from foo import cool_module, hello_pb2
+
+Working with externally defined protos
+--------------------------------------
+``pw_proto_library`` targets may be used to build ``.proto`` sources from
+existing projects. In these cases, it may be necessary to supply the
+``strip_prefix`` argument, which specifies the protobuf include path to use for
+``protoc``. If only a single external protobuf is being compiled, the
+``python_module_as_package`` option can be used to override the requirement that
+the protobuf be nested under a directory. This option generates a Python package
+with the same name as the proto file, so that the generated proto can be
+imported as if it were a standalone Python module.
+
+For example, the ``pw_proto_library`` target for Nanopb sets
+``python_module_as_package`` to ``nanopb_pb2``.
+
+.. code-block::
+
+  pw_proto_library("proto") {
+    strip_prefix = "$dir_pw_third_party_nanopb/generator/proto"
+    sources = [ "$dir_pw_third_party_nanopb/generator/proto/nanopb.proto" ]
+    python_module_as_package = "nanopb_pb2"
+  }
+
+In Python, this makes ``nanopb.proto`` available as ``import nanopb_pb2`` via
+the ``nanopb_pb2`` Python package. In C++, ``nanopb.proto`` is accessed as
+``#include "nanopb.pwpb.h"``.
+
+The ``python_module_as_package`` feature should only be used when absolutely
+necessary --- for example, to support proto files that include
+``import "nanopb.proto"``.
+
+CMake
+=====
+CMake provides a ``pw_proto_library`` function with similar features as the
+GN template. The CMake build only supports building firmware code, so
+``pw_proto_library`` does not generate a Python package.
+
+**Arguments**
+
+* ``NAME``: the base name of the libraries to create
+* ``SOURCES``: .proto source files
+* ``DEPS``: dependencies on other ``pw_proto_library`` targets
+* ``PREFIX``: prefix add to the proto files
+* ``STRIP_PREFIX``: prefix to remove from the proto files
+* ``INPUTS``: files to include along with the .proto files (such as Nanopb
+  .options files)
+
+**Example**
+
+ .. code-block:: cmake
+
+  include($ENV{PW_ROOT}/pw_build/pigweed.cmake)
+  include($ENV{PW_ROOT}/pw_protobuf_compiler/proto.cmake)
+
+  pw_proto_library(my_module.my_protos
+    SOURCES
+      my_protos/foo.proto
+      my_protos/bar.proto
+  )
+
+  pw_proto_library(my_module.my_protos
+    SOURCES
+      my_protos/foo.proto
+      my_protos/bar.proto
+  )
+
+  pw_proto_library(my_module.my_other_protos
+    SOURCES
+      some/other/path/baz.proto  # imports foo.proto
+
+    # This removes the "some/other/path" prefix from the proto files.
+    STRIP_PREFIX
+      some/other/path
+
+    # This adds the "my_other_protos/" prefix to the proto files.
+    PREFIX
+      my_other_protos
+
+    # Proto libraries depend on other proto libraries directly.
+    DEPS
+      my_module.my_protos
+  )
+
+  add_library(my_module.my_cc_code
+      foo.cc
+      bar.cc
+      baz.cc
+  )
+
+  # When depending on protos in a source_set, specify the generator suffix.
+  target_link_libraries(my_module.my_cc_code PUBLIC
+    my_module.my_other_protos.pwpb
+  )
+
+These proto files are accessed in C++ the same as in the GN build:
+
+.. code-block:: cpp
+
+  #include "my_other_protos/baz.pwpb.h"
diff --git a/pw_protobuf_compiler/nanopb_test.cc b/pw_protobuf_compiler/nanopb_test.cc
index 4df3520..e916b49 100644
--- a/pw_protobuf_compiler/nanopb_test.cc
+++ b/pw_protobuf_compiler/nanopb_test.cc
@@ -13,7 +13,7 @@
 // the License.
 
 #include "gtest/gtest.h"
-#include "pw_protobuf_compiler_protos/nanopb_test.pb.h"
+#include "pw_protobuf_compiler_nanopb_protos/nanopb_test.pb.h"
 
 TEST(Nanopb, CompilesProtobufs) {
   pw_protobuf_compiler_Point point = {4, 8, "point"};
diff --git a/pw_protobuf_compiler/proto.cmake b/pw_protobuf_compiler/proto.cmake
index bd8584e..d05b960 100644
--- a/pw_protobuf_compiler/proto.cmake
+++ b/pw_protobuf_compiler/proto.cmake
@@ -30,11 +30,22 @@
 #   NAME - the base name of the libraries to create
 #   SOURCES - .proto source files
 #   DEPS - dependencies on other pw_proto_library targets
+#   PREFIX - prefix add to the proto files
+#   STRIP_PREFIX - prefix to remove from the proto files
+#   INPUTS - files to include along with the .proto files (such as Nanopb
+#       .options files
 #
 function(pw_proto_library NAME)
-  cmake_parse_arguments(PARSE_ARGV 1 arg "" "" "SOURCES;DEPS")
+  cmake_parse_arguments(PARSE_ARGV 1 arg "" "STRIP_PREFIX;PREFIX"
+      "SOURCES;INPUTS;DEPS")
 
-  set(out_dir "${CMAKE_CURRENT_BINARY_DIR}/protos")
+  if("${arg_SOURCES}" STREQUAL "")
+    message(FATAL_ERROR
+        "pw_proto_library requires at least one .proto file in SOURCES. No "
+        "SOURCES were listed for ${NAME}.")
+  endif()
+
+  set(out_dir "${CMAKE_CURRENT_BINARY_DIR}/${NAME}")
 
   # Use INTERFACE libraries to track the proto include paths that are passed to
   # protoc.
@@ -42,37 +53,89 @@
   list(TRANSFORM include_deps APPEND ._includes)
 
   add_library("${NAME}._includes" INTERFACE)
-  target_include_directories("${NAME}._includes" INTERFACE ".")
+  target_include_directories("${NAME}._includes" INTERFACE "${out_dir}/sources")
   target_link_libraries("${NAME}._includes" INTERFACE ${include_deps})
 
-  # Generate a file with all include paths needed by protoc.
-  set(include_file "${out_dir}/${NAME}.include_paths.txt")
+  # Generate a file with all include paths needed by protoc. Use the include
+  # directory paths and replace ; with \n.
+  set(include_file "${out_dir}/include_paths.txt")
   file(GENERATE OUTPUT "${include_file}"
      CONTENT
-       "$<TARGET_PROPERTY:${NAME}._includes,INTERFACE_INCLUDE_DIRECTORIES>")
+       "$<JOIN:$<TARGET_PROPERTY:${NAME}._includes,INTERFACE_INCLUDE_DIRECTORIES>,\n>")
+
+  if("${arg_STRIP_PREFIX}" STREQUAL "")
+    set(arg_STRIP_PREFIX "${CMAKE_CURRENT_SOURCE_DIR}")
+  endif()
+
+  foreach(path IN LISTS arg_SOURCES arg_INPUTS)
+    get_filename_component(abspath "${path}" ABSOLUTE)
+    list(APPEND files_to_mirror "${abspath}")
+  endforeach()
+
+  # Mirror the sources to the output directory with the specified prefix.
+  _pw_rebase_paths(
+      sources "${out_dir}/sources/${arg_PREFIX}" "${arg_STRIP_PREFIX}" "${arg_SOURCES}" "")
+  _pw_rebase_paths(
+      inputs "${out_dir}/sources/${arg_PREFIX}" "${arg_STRIP_PREFIX}" "${arg_INPUTS}" "")
+
+  add_custom_command(
+    COMMAND
+      python3
+      "$ENV{PW_ROOT}/pw_build/py/pw_build/mirror_tree.py"
+      --source-root "${arg_STRIP_PREFIX}"
+      --directory "${out_dir}/sources/${arg_PREFIX}"
+      ${files_to_mirror}
+    DEPENDS
+      "$ENV{PW_ROOT}/pw_build/py/pw_build/mirror_tree.py"
+      ${files_to_mirror}
+    OUTPUT
+      ${sources} ${inputs}
+  )
+  add_custom_target("${NAME}._sources" DEPENDS ${sources} ${inputs})
+
+  set(sources_deps "${arg_DEPS}")
+  list(TRANSFORM sources_deps APPEND ._sources)
+
+  if(sources_deps)
+    add_dependencies("${NAME}._sources" ${sources_deps})
+  endif()
 
   # Create a protobuf target for each supported protobuf library.
   _pw_pwpb_library(
-      "${NAME}" "${arg_SOURCES}" "${arg_DEPS}" "${include_file}" "${out_dir}")
+      "${NAME}" "${sources}" "${inputs}" "${arg_DEPS}" "${include_file}" "${out_dir}")
   _pw_raw_rpc_library(
-      "${NAME}" "${arg_SOURCES}" "${arg_DEPS}" "${include_file}" "${out_dir}")
+      "${NAME}" "${sources}" "${inputs}" "${arg_DEPS}" "${include_file}" "${out_dir}")
   _pw_nanopb_library(
-      "${NAME}" "${arg_SOURCES}" "${arg_DEPS}" "${include_file}" "${out_dir}")
+      "${NAME}" "${sources}" "${inputs}" "${arg_DEPS}" "${include_file}" "${out_dir}")
   _pw_nanopb_rpc_library(
-      "${NAME}" "${arg_SOURCES}" "${arg_DEPS}" "${include_file}" "${out_dir}")
+      "${NAME}" "${sources}" "${inputs}" "${arg_DEPS}" "${include_file}" "${out_dir}")
 endfunction(pw_proto_library)
 
+function(_pw_rebase_paths VAR OUT_DIR ROOT FILES EXTENSIONS)
+  foreach(file IN LISTS FILES)
+    get_filename_component(file "${file}" ABSOLUTE)
+    file(RELATIVE_PATH file "${ROOT}" "${file}")
+
+    if ("${EXTENSIONS}" STREQUAL "")
+      list(APPEND mirrored_files "${OUT_DIR}/${file}")
+    else()
+      foreach(ext IN LISTS EXTENSIONS)
+        get_filename_component(dir "${file}" DIRECTORY)
+        get_filename_component(name "${file}" NAME_WE)
+        list(APPEND mirrored_files "${OUT_DIR}/${dir}/${name}${ext}")
+      endforeach()
+    endif()
+  endforeach()
+
+  set("${VAR}" "${mirrored_files}" PARENT_SCOPE)
+endfunction(_pw_rebase_paths)
+
 # Internal function that invokes protoc through generate_protos.py.
 function(_pw_generate_protos
-      TARGET LANGUAGE PLUGIN OUTPUT_EXTS INCLUDE_FILE OUT_DIR SOURCES DEPS)
-  # Determine the names of the output files.
-  foreach(extension IN LISTS OUTPUT_EXTS)
-    foreach(source_file IN LISTS SOURCES)
-      get_filename_component(dir "${source_file}" DIRECTORY)
-      get_filename_component(name "${source_file}" NAME_WE)
-      list(APPEND outputs "${OUT_DIR}/${dir}/${name}${extension}")
-    endforeach()
-  endforeach()
+    TARGET LANGUAGE PLUGIN OUTPUT_EXTS INCLUDE_FILE OUT_DIR SOURCES INPUTS DEPS)
+  # Determine the names of the compiled output files.
+  _pw_rebase_paths(outputs
+      "${OUT_DIR}/${LANGUAGE}" "${OUT_DIR}/sources" "${SOURCES}" "${OUTPUT_EXTS}")
 
   # Export the output files to the caller's scope so it can use them if needed.
   set(generated_outputs "${outputs}" PARENT_SCOPE)
@@ -86,18 +149,18 @@
   set(script "$ENV{PW_ROOT}/pw_protobuf_compiler/py/pw_protobuf_compiler/generate_protos.py")
   add_custom_command(
     COMMAND
-      python
+      python3
       "${script}"
       --language "${LANGUAGE}"
       --plugin-path "${PLUGIN}"
-      --module-path "${CMAKE_CURRENT_SOURCE_DIR}"
       --include-file "${INCLUDE_FILE}"
-      --out-dir "${OUT_DIR}"
-      ${ARGN}
-      ${SOURCES}
+      --compile-dir "${OUT_DIR}/sources"
+      --out-dir "${OUT_DIR}/${LANGUAGE}"
+      --sources ${SOURCES}
     DEPENDS
-      ${SOURCES}
       ${script}
+      ${SOURCES}
+      ${INPUTS}
       ${DEPS}
     OUTPUT
       ${outputs}
@@ -106,96 +169,114 @@
 endfunction(_pw_generate_protos)
 
 # Internal function that creates a pwpb proto library.
-function(_pw_pwpb_library NAME SOURCES DEPS INCLUDE_FILE OUT_DIR)
+function(_pw_pwpb_library NAME SOURCES INPUTS DEPS INCLUDE_FILE OUT_DIR)
   list(TRANSFORM DEPS APPEND .pwpb)
 
-  _pw_generate_protos("${NAME}.generate.pwpb"
+  _pw_generate_protos("${NAME}._generate.pwpb"
       pwpb
       "$ENV{PW_ROOT}/pw_protobuf/py/pw_protobuf/plugin.py"
       ".pwpb.h"
       "${INCLUDE_FILE}"
       "${OUT_DIR}"
       "${SOURCES}"
+      "${INPUTS}"
       "${DEPS}"
   )
 
   # Create the library with the generated source files.
   add_library("${NAME}.pwpb" INTERFACE)
-  target_include_directories("${NAME}.pwpb" INTERFACE "${OUT_DIR}")
+  target_include_directories("${NAME}.pwpb" INTERFACE "${OUT_DIR}/pwpb")
   target_link_libraries("${NAME}.pwpb" INTERFACE pw_protobuf ${DEPS})
-  add_dependencies("${NAME}.pwpb" "${NAME}.generate.pwpb")
+  add_dependencies("${NAME}.pwpb" "${NAME}._generate.pwpb")
 endfunction(_pw_pwpb_library)
 
 # Internal function that creates a raw_rpc proto library.
-function(_pw_raw_rpc_library NAME SOURCES DEPS INCLUDE_FILE OUT_DIR)
+function(_pw_raw_rpc_library NAME SOURCES INPUTS DEPS INCLUDE_FILE OUT_DIR)
   list(TRANSFORM DEPS APPEND .raw_rpc)
 
-  _pw_generate_protos("${NAME}.generate.raw_rpc"
+  _pw_generate_protos("${NAME}._generate.raw_rpc"
       raw_rpc
       "$ENV{PW_ROOT}/pw_rpc/py/pw_rpc/plugin_raw.py"
       ".raw_rpc.pb.h"
       "${INCLUDE_FILE}"
       "${OUT_DIR}"
       "${SOURCES}"
+      "${INPUTS}"
       "${DEPS}"
   )
 
   # Create the library with the generated source files.
   add_library("${NAME}.raw_rpc" INTERFACE)
-  target_include_directories("${NAME}.raw_rpc" INTERFACE "${OUT_DIR}")
+  target_include_directories("${NAME}.raw_rpc" INTERFACE "${OUT_DIR}/raw_rpc")
   target_link_libraries("${NAME}.raw_rpc"
     INTERFACE
       pw_rpc.raw
       pw_rpc.server
       ${DEPS}
   )
-  add_dependencies("${NAME}.raw_rpc" "${NAME}.generate.raw_rpc")
+  add_dependencies("${NAME}.raw_rpc" "${NAME}._generate.raw_rpc")
 endfunction(_pw_raw_rpc_library)
 
 # Internal function that creates a nanopb proto library.
-function(_pw_nanopb_library NAME SOURCES DEPS INCLUDE_FILE OUT_DIR)
+function(_pw_nanopb_library NAME SOURCES INPUTS DEPS INCLUDE_FILE OUT_DIR)
   list(TRANSFORM DEPS APPEND .nanopb)
 
-  set(nanopb_dir "$<TARGET_PROPERTY:$<IF:$<TARGET_EXISTS:protobuf-nanopb-static>,protobuf-nanopb-static,pw_build.empty>,SOURCE_DIR>")
-  set(nanopb_plugin
-      "$<IF:$<TARGET_EXISTS:protobuf-nanopb-static>,${nanopb_dir}/generator/protoc-gen-nanopb,COULD_NOT_FIND_protobuf-nanopb-static_TARGET_PLEASE_SET_UP_NANOPB>")
-
-  _pw_generate_protos("${NAME}.generate.nanopb"
-      nanopb
-      "${nanopb_plugin}"
-      ".pb.h;.pb.c"
-      "${INCLUDE_FILE}"
-      "${OUT_DIR}"
-      "${SOURCES}"
-      "${DEPS}"
-      --include-paths "${nanopb_dir}/generator/proto"
-  )
+  if("${dir_pw_third_party_nanopb}" STREQUAL "")
+    add_custom_target("${NAME}._generate.nanopb"
+        cmake -E echo
+            ERROR: Attempting to use pw_proto_library, but
+            dir_pw_third_party_nanopb is not set. Set dir_pw_third_party_nanopb
+            to the path to the Nanopb repository.
+      COMMAND
+        cmake -E false
+      DEPENDS
+        ${DEPS}
+      SOURCES
+        ${SOURCES}
+    )
+    set(generated_outputs $<TARGET_PROPERTY:pw_build.empty,SOURCES>)
+  else()
+    _pw_generate_protos("${NAME}._generate.nanopb"
+        nanopb
+        "${dir_pw_third_party_nanopb}/generator/protoc-gen-nanopb"
+        ".pb.h;.pb.c"
+        "${INCLUDE_FILE}"
+        "${OUT_DIR}"
+        "${SOURCES}"
+        "${INPUTS}"
+        "${DEPS}"
+    )
+  endif()
 
   # Create the library with the generated source files.
   add_library("${NAME}.nanopb" EXCLUDE_FROM_ALL ${generated_outputs})
-  target_include_directories("${NAME}.nanopb" PUBLIC "${OUT_DIR}")
+  target_include_directories("${NAME}.nanopb" PUBLIC "${OUT_DIR}/nanopb")
   target_link_libraries("${NAME}.nanopb" PUBLIC pw_third_party.nanopb ${DEPS})
-  add_dependencies("${NAME}.nanopb" "${NAME}.generate.nanopb")
+  add_dependencies("${NAME}.nanopb" "${NAME}._generate.nanopb")
 endfunction(_pw_nanopb_library)
 
 # Internal function that creates a nanopb_rpc library.
-function(_pw_nanopb_rpc_library NAME SOURCES DEPS INCLUDE_FILE OUT_DIR)
+function(_pw_nanopb_rpc_library NAME SOURCES INPUTS DEPS INCLUDE_FILE OUT_DIR)
   # Determine the names of the output files.
   list(TRANSFORM DEPS APPEND .nanopb_rpc)
 
-  _pw_generate_protos("${NAME}.generate.nanopb_rpc"
+  _pw_generate_protos("${NAME}._generate.nanopb_rpc"
       nanopb_rpc
       "$ENV{PW_ROOT}/pw_rpc/py/pw_rpc/plugin_nanopb.py"
       ".rpc.pb.h"
       "${INCLUDE_FILE}"
       "${OUT_DIR}"
       "${SOURCES}"
+      "${INPUTS}"
       "${DEPS}"
   )
 
   # Create the library with the generated source files.
   add_library("${NAME}.nanopb_rpc" INTERFACE)
-  target_include_directories("${NAME}.nanopb_rpc" INTERFACE "${OUT_DIR}")
+  target_include_directories("${NAME}.nanopb_rpc"
+    INTERFACE
+      "${OUT_DIR}/nanopb_rpc"
+  )
   target_link_libraries("${NAME}.nanopb_rpc"
     INTERFACE
       "${NAME}.nanopb"
@@ -203,5 +284,5 @@
       pw_rpc.server
       ${DEPS}
   )
-  add_dependencies("${NAME}.nanopb_rpc" "${NAME}.generate.nanopb_rpc")
+  add_dependencies("${NAME}.nanopb_rpc" "${NAME}._generate.nanopb_rpc")
 endfunction(_pw_nanopb_rpc_library)
diff --git a/pw_protobuf_compiler/proto.gni b/pw_protobuf_compiler/proto.gni
index f7b1a28..a6ec15e 100644
--- a/pw_protobuf_compiler/proto.gni
+++ b/pw_protobuf_compiler/proto.gni
@@ -16,6 +16,8 @@
 
 import("$dir_pw_build/error.gni")
 import("$dir_pw_build/input_group.gni")
+import("$dir_pw_build/mirror_tree.gni")
+import("$dir_pw_build/python.gni")
 import("$dir_pw_build/python_action.gni")
 import("$dir_pw_build/target_types.gni")
 import("$dir_pw_third_party/nanopb/nanopb.gni")
@@ -33,57 +35,75 @@
 # This creates the internal GN target $target_name.$language._gen that compiles
 # proto files with protoc.
 template("_pw_invoke_protoc") {
-  _output = rebase_path(get_target_outputs(":${invoker.base_target}._metadata"))
-
-  pw_python_action("$target_name._gen") {
-    forward_variables_from(invoker, [ "metadata" ])
-    script =
-        "$dir_pw_protobuf_compiler/py/pw_protobuf_compiler/generate_protos.py"
-
-    deps = [
-             ":${invoker.base_target}._metadata",
-             ":${invoker.base_target}._inputs",
-           ] + invoker.deps
-
-    args = [
-             "--language",
-             invoker.language,
-             "--module-path",
-             rebase_path("."),
-             "--include-file",
-             _output[0],
-             "--out-dir",
-             rebase_path(invoker.gen_dir),
-           ] + rebase_path(invoker.sources)
-
-    inputs = invoker.sources
-
-    if (defined(invoker.plugin)) {
-      inputs += [ invoker.plugin ]
-      args += [ "--plugin-path=" + rebase_path(invoker.plugin) ]
-    }
-
-    if (defined(invoker.include_paths)) {
-      args += [
-        "--include-paths",
-        string_join(";", rebase_path(invoker.include_paths)),
-      ]
-    }
-
-    outputs = []
-    foreach(extension, invoker.output_extensions) {
-      foreach(proto,
-              rebase_path(invoker.sources, get_path_info(".", "abspath"))) {
-        _output = string_replace(proto, ".proto", extension)
-        outputs += [ "${invoker.gen_dir}/$_output" ]
+  if (current_toolchain == default_toolchain) {
+    if (defined(invoker.out_dir)) {
+      _out_dir = invoker.out_dir
+    } else {
+      _out_dir = "${invoker.base_out_dir}/${invoker.language}"
+      if (defined(invoker.module_as_package) &&
+          invoker.module_as_package != "") {
+        assert(invoker.language == "python")
+        _out_dir = "$_out_dir/${invoker.module_as_package}"
       }
     }
 
-    if (outputs == []) {
-      stamp = true
-    }
+    _includes =
+        rebase_path(get_target_outputs(":${invoker.base_target}._includes"))
 
-    visibility = [ ":*" ]
+    pw_python_action("$target_name._gen") {
+      script =
+          "$dir_pw_protobuf_compiler/py/pw_protobuf_compiler/generate_protos.py"
+
+      python_deps = [ "$dir_pw_protobuf_compiler/py" ]
+      if (defined(invoker.python_deps)) {
+        python_deps += invoker.python_deps
+      }
+
+      deps = [
+        ":${invoker.base_target}._includes",
+        ":${invoker.base_target}._sources",
+      ]
+
+      foreach(dep, invoker.deps) {
+        deps += [ get_label_info(dep, "label_no_toolchain") + "._gen" ]
+      }
+
+      args = [
+               "--language",
+               invoker.language,
+               "--include-file",
+               _includes[0],
+               "--compile-dir",
+               rebase_path(invoker.compile_dir),
+               "--out-dir",
+               rebase_path(_out_dir),
+               "--sources",
+             ] + rebase_path(invoker.sources)
+
+      if (defined(invoker.plugin)) {
+        inputs = [ invoker.plugin ]
+        args += [ "--plugin-path=" + rebase_path(invoker.plugin) ]
+      }
+
+      if (defined(invoker.outputs)) {
+        outputs = invoker.outputs
+      } else {
+        stamp = true
+      }
+
+      if (defined(invoker.metadata)) {
+        metadata = invoker.metadata
+      } else {
+        metadata = {
+          protoc_outputs = rebase_path(outputs)
+          root = [ rebase_path(_out_dir) ]
+        }
+      }
+    }
+  } else {
+    # protoc is only ever invoked from the default toolchain.
+    not_needed([ "target_name" ])
+    not_needed(invoker, "*")
   }
 }
 
@@ -95,17 +115,21 @@
     forward_variables_from(invoker, "*", _forwarded_vars)
     language = "pwpb"
     plugin = "$dir_pw_protobuf/py/pw_protobuf/plugin.py"
-    deps += [ "$dir_pw_protobuf/py" ]
-    output_extensions = [ ".pwpb.h" ]
+    python_deps = [ "$dir_pw_protobuf/py" ]
   }
 
   # Create a library with the generated source files.
+  config("$target_name._include_path") {
+    include_dirs = [ "${invoker.base_out_dir}/pwpb" ]
+    visibility = [ ":*" ]
+  }
+
   pw_source_set(target_name) {
     forward_variables_from(invoker, _forwarded_vars)
-    public_configs = [ ":${invoker.base_target}._include_path" ]
-    deps = [ ":$target_name._gen" ]
+    public_configs = [ ":$target_name._include_path" ]
+    deps = [ ":$target_name._gen($default_toolchain)" ]
     public_deps = [ dir_pw_protobuf ] + invoker.deps
-    sources = get_target_outputs(":$target_name._gen")
+    sources = invoker.outputs
     public = filter_include(sources, [ "*.pwpb.h" ])
   }
 }
@@ -120,23 +144,26 @@
     forward_variables_from(invoker, "*", _forwarded_vars)
     language = "nanopb_rpc"
     plugin = "$dir_pw_rpc/py/pw_rpc/plugin_nanopb.py"
-    deps += [ "$dir_pw_rpc/py" ]
-    include_paths = [ "$dir_pw_third_party_nanopb/generator/proto" ]
-    output_extensions = [ ".rpc.pb.h" ]
+    python_deps = [ "$dir_pw_rpc/py" ]
   }
 
   # Create a library with the generated source files.
+  config("$target_name._include_path") {
+    include_dirs = [ "${invoker.base_out_dir}/nanopb_rpc" ]
+    visibility = [ ":*" ]
+  }
+
   pw_source_set(target_name) {
     forward_variables_from(invoker, _forwarded_vars)
-    public_configs = [ ":${invoker.base_target}._include_path" ]
-    deps = [ ":$target_name._gen" ]
+    public_configs = [ ":$target_name._include_path" ]
+    deps = [ ":$target_name._gen($default_toolchain)" ]
     public_deps = [
                     ":${invoker.base_target}.nanopb",
                     "$dir_pw_rpc:server",
                     "$dir_pw_rpc/nanopb:method_union",
                     "$dir_pw_third_party/nanopb",
                   ] + invoker.deps
-    public = get_target_outputs(":$target_name._gen")
+    public = invoker.outputs
   }
 }
 
@@ -144,27 +171,39 @@
 # files. This is internal and should not be used outside of this file. Use
 # pw_proto_library instead.
 template("_pw_nanopb_proto_library") {
-  # Create a target which runs protoc configured with the nanopb plugin to
-  # generate the C proto sources.
-  _pw_invoke_protoc(target_name) {
-    forward_variables_from(invoker, "*", _forwarded_vars)
-    language = "nanopb"
-    plugin = "$dir_pw_third_party_nanopb/generator/protoc-gen-nanopb"
-    include_paths = [ "$dir_pw_third_party_nanopb/generator/proto" ]
-    output_extensions = [
-      ".pb.h",
-      ".pb.c",
-    ]
-  }
+  # When compiling with the Nanopb plugin, the nanopb.proto file is already
+  # compiled internally, so skip recompiling it with protoc.
+  if (rebase_path(invoker.sources, invoker.compile_dir) == [ "nanopb.proto" ]) {
+    group("$target_name._gen") {
+      deps = [ ":${invoker.base_target}._sources" ]
+    }
 
-  # Create a library with the generated source files.
-  pw_source_set(target_name) {
-    forward_variables_from(invoker, _forwarded_vars)
-    public_configs = [ ":${invoker.base_target}._include_path" ]
-    deps = [ ":$target_name._gen" ]
-    public_deps = [ "$dir_pw_third_party/nanopb" ] + invoker.deps
-    sources = get_target_outputs(":$target_name._gen")
-    public = filter_include(sources, [ "*.pb.h" ])
+    group("$target_name") {
+      deps = invoker.deps + [ ":$target_name._gen($default_toolchain)" ]
+    }
+  } else {
+    # Create a target which runs protoc configured with the nanopb plugin to
+    # generate the C proto sources.
+    _pw_invoke_protoc(target_name) {
+      forward_variables_from(invoker, "*", _forwarded_vars)
+      language = "nanopb"
+      plugin = "$dir_pw_third_party_nanopb/generator/protoc-gen-nanopb"
+    }
+
+    # Create a library with the generated source files.
+    config("$target_name._include_path") {
+      include_dirs = [ "${invoker.base_out_dir}/nanopb" ]
+      visibility = [ ":*" ]
+    }
+
+    pw_source_set(target_name) {
+      forward_variables_from(invoker, _forwarded_vars)
+      public_configs = [ ":$target_name._include_path" ]
+      deps = [ ":$target_name._gen($default_toolchain)" ]
+      public_deps = [ "$dir_pw_third_party/nanopb" ] + invoker.deps
+      sources = invoker.outputs
+      public = filter_include(sources, [ "*.pb.h" ])
+    }
   }
 }
 
@@ -178,20 +217,24 @@
     forward_variables_from(invoker, "*", _forwarded_vars)
     language = "raw_rpc"
     plugin = "$dir_pw_rpc/py/pw_rpc/plugin_raw.py"
-    deps += [ "$dir_pw_rpc/py" ]
-    output_extensions = [ ".raw_rpc.pb.h" ]
+    python_deps = [ "$dir_pw_rpc/py" ]
   }
 
   # Create a library with the generated source files.
+  config("$target_name._include_path") {
+    include_dirs = [ "${invoker.base_out_dir}/raw_rpc" ]
+    visibility = [ ":*" ]
+  }
+
   pw_source_set(target_name) {
     forward_variables_from(invoker, _forwarded_vars)
-    public_configs = [ ":${invoker.base_target}._include_path" ]
-    deps = [ ":$target_name._gen" ]
+    public_configs = [ ":$target_name._include_path" ]
+    deps = [ ":$target_name._gen($default_toolchain)" ]
     public_deps = [
                     "$dir_pw_rpc:server",
                     "$dir_pw_rpc/raw:method_union",
                   ] + invoker.deps
-    public = get_target_outputs(":$target_name._gen")
+    public = invoker.outputs
   }
 }
 
@@ -210,12 +253,63 @@
         "google.golang.org/grpc",
       ]
     }
-    output_extensions = []  # Don't enumerate the generated .go files.
-    gen_dir = "$_proto_gopath/src"
+
+    # Override the default "$base_out_dir/$language" output path.
+    out_dir = "$_proto_gopath/src"
   }
 
   group(target_name) {
-    deps = [ ":$target_name._gen" ]
+    deps = invoker.deps + [ ":$target_name._gen($default_toolchain)" ]
+  }
+}
+
+# Generates Python code for proto files, creating a pw_python_package containing
+# the generated files. This is internal and should not be used outside of this
+# file. Use pw_proto_library instead.
+template("_pw_python_proto_library") {
+  _pw_invoke_protoc(target_name) {
+    forward_variables_from(invoker, "*", _forwarded_vars + [ "python_package" ])
+    language = "python"
+    python_deps = [ "$dir_pw_protobuf_compiler:protobuf_requirements" ]
+  }
+
+  if (defined(invoker.python_package) && invoker.python_package != "") {
+    # If nested in a Python package, write the package's name to a file so
+    # pw_python_package can check that the dependencies are correct.
+    write_file("${invoker.base_out_dir}/python_package.txt",
+               get_label_info(invoker.python_package, "label_no_toolchain"))
+
+    # If anyone attempts to depend on this Python package, print an error.
+    pw_error(target_name) {
+      _pkg = get_label_info(invoker.python_package, "label_no_toolchain")
+      message_lines = [
+        "This proto Python package is embedded in the $_pkg Python package.",
+        "It cannot be used directly; instead, depend on $_pkg.",
+      ]
+    }
+    foreach(subtarget, pw_python_package_subtargets) {
+      group("$target_name.$subtarget") {
+        deps = [ ":${invoker.target_name}" ]
+      }
+    }
+  } else {
+    write_file("${invoker.base_out_dir}/python_package.txt", "")
+
+    # Create a Python package with the generated source files.
+    pw_python_package(target_name) {
+      forward_variables_from(invoker, _forwarded_vars)
+      generate_setup = {
+        name = invoker._package_dir
+        version = "0.0.1"  # TODO(hepler): Need to be able to set this verison.
+      }
+      sources = invoker.outputs
+      strip_prefix = "${invoker.base_out_dir}/python"
+      python_deps = invoker.deps
+      other_deps = [ ":$target_name._gen($default_toolchain)" ]
+      static_analysis = []
+
+      _pw_module_as_package = invoker.module_as_package != ""
+    }
   }
 }
 
@@ -224,19 +318,127 @@
 #
 #   <target_name>.<generator>
 #
+# GN permits using abbreviated labels when the target name matches the directory
+# name (e.g. //foo for //foo:foo). For consistency with this, the sub-targets
+# for each generator are aliased to the directory when the target name is the
+# same. For example, these two labels are equivalent:
+#
+#   //path/to/my_protos:my_protos.pwpb
+#   //path/to/my_protos:pwpb
+#
+# pw_protobuf_library targets generate Python packages. As such, they must have
+# globally unique package names. The first directory of the prefix or the first
+# common directory of the sources is used as the Python package.
+#
 # Args:
-#  sources: List of input .proto files.
-#  deps: List of other pw_proto_library dependencies.
-#  inputs: Other files on which the protos depend (e.g. nanopb .options files).
+#   sources: List of input .proto files.
+#   deps: List of other pw_proto_library dependencies.
+#   inputs: Other files on which the protos depend (e.g. nanopb .options files).
+#   prefix: A prefix to add to the source protos prior to compilation. For
+#       example, a source called "foo.proto" with prefix = "nested" will be
+#       compiled with protoc as "nested/foo.proto".
+#   strip_prefix: Remove this prefix from the source protos. All source and
+#       input files must be nested under this path.
+#   python_package: Label of Python package to which to add the proto modules.
 #
 template("pw_proto_library") {
   assert(defined(invoker.sources) && invoker.sources != [],
          "pw_proto_library requires .proto source files")
 
+  if (defined(invoker.python_module_as_package)) {
+    _module_as_package = invoker.python_module_as_package
+
+    _must_be_one_source = invoker.sources
+    assert([ _must_be_one_source[0] ] == _must_be_one_source,
+           "'python_module_as_package' requires exactly one source file")
+    assert(_module_as_package != "",
+           "'python_module_as_package' cannot be be empty")
+    assert(string_split(_module_as_package, "/") == [ _module_as_package ],
+           "'python_module_as_package' cannot contain slashes")
+    assert(!defined(invoker.prefix),
+           "'prefix' cannot be provided with 'python_module_as_package'")
+  } else {
+    _module_as_package = ""
+  }
+
+  if (defined(invoker.strip_prefix)) {
+    _source_root = get_path_info(invoker.strip_prefix, "abspath")
+  } else {
+    _source_root = get_path_info(".", "abspath")
+  }
+
+  if (defined(invoker.prefix)) {
+    _prefix = invoker.prefix
+  } else {
+    _prefix = ""
+  }
+
   _common = {
     base_target = target_name
-    gen_dir = "$target_gen_dir/protos"
-    sources = invoker.sources
+
+    # This is the output directory for all files related to this proto library.
+    # Sources are mirrored to "$base_out_dir/sources" and protoc puts outputs in
+    # "$base_out_dir/$language" by default.
+    base_out_dir =
+        get_label_info(":$target_name($default_toolchain)", "target_gen_dir") +
+        "/$target_name.proto_library"
+
+    compile_dir = "$base_out_dir/sources"
+
+    # Refer to the source files as the are mirrored to the output directory.
+    sources = []
+    foreach(file, rebase_path(invoker.sources, _source_root)) {
+      sources += [ "$compile_dir/$_prefix/$file" ]
+    }
+  }
+
+  _package_dir = ""
+  _source_names = []
+
+  # Determine the Python package name to use for these protos. If there is no
+  # prefix, the first directory the sources are nested under is used.
+  foreach(source, rebase_path(invoker.sources, _source_root)) {
+    _path_components = []
+    _path_components = string_split(source, "/")
+
+    if (_package_dir == "") {
+      _package_dir = _path_components[0]
+    } else {
+      assert(_prefix != "" || _path_components[0] == _package_dir,
+             "Unless 'prefix' is supplied, all .proto sources in a " +
+                 "pw_proto_library must be in the same directory tree")
+    }
+
+    _source_names +=
+        [ get_path_info(source, "dir") + "/" + get_path_info(source, "name") ]
+  }
+
+  # If the 'prefix' was supplied, use that for the package directory.
+  if (_prefix != "") {
+    _prefix_path_components = string_split(_prefix, "/")
+    _package_dir = _prefix_path_components[0]
+  }
+
+  assert(_package_dir != "" && _package_dir != "." && _package_dir != "..",
+         "Either a 'prefix' must be specified or all sources must be nested " +
+             "under a common directory")
+
+  # Define an action that is never executed to prevent duplicate proto packages
+  # from being declared. The target name and the output file include only the
+  # package directory, so different targets that use the same proto package name
+  # will conflict.
+  action("pw_proto_library.$_package_dir") {
+    script = "$dir_pw_build/py/pw_build/nop.py"
+    visibility = []
+
+    # Place an error message in the output path (which is never created). If the
+    # package name conflicts occur in different BUILD.gn files, this results in
+    # an otherwise cryptic Ninja error, rather than a GN error.
+    outputs = [ "$root_out_dir/ " +
+                "ERROR - Multiple pw_proto_library targets create the " +
+                "'$_package_dir' package. Change the package name by setting " +
+                "the \"prefix\" arg or move the protos to a different " +
+                "directory, then re-run gn gen." ]
   }
 
   if (defined(invoker.deps)) {
@@ -247,35 +449,34 @@
 
   # For each proto target, create a file which collects the base directories of
   # all of its dependencies to list as include paths to protoc.
-  generated_file("$target_name._metadata") {
+  generated_file("$target_name._includes") {
     # Collect metadata from the include path files of each dependency.
-    deps = process_file_template(_deps, "{{source}}._metadata")
+
+    deps = []
+    foreach(dep, _deps) {
+      _base = get_label_info(dep, "label_no_toolchain")
+      deps += [ "$_base._includes(" + get_label_info(dep, "toolchain") + ")" ]
+    }
 
     data_keys = [ "protoc_includes" ]
-    outputs = [ "$target_gen_dir/${target_name}_includes.txt" ]
+    outputs = [ "${_common.base_out_dir}/includes.txt" ]
 
     # Indicate this library's base directory for its dependents.
     metadata = {
-      protoc_includes = [ rebase_path(".") ]
+      protoc_includes = [ rebase_path(_common.compile_dir) ]
     }
   }
 
-  # Toss any additional inputs into an input group dependency.
-  if (defined(invoker.inputs)) {
-    pw_input_group("$target_name._inputs") {
-      inputs = invoker.inputs
-      visibility = [ ":*" ]
-    }
-  } else {
-    group("$target_name._inputs") {
-      visibility = [ ":*" ]
-    }
-  }
+  # Mirror the proto sources to the output directory with the prefix added.
+  pw_mirror_tree("$target_name._sources") {
+    source_root = _source_root
+    sources = invoker.sources
 
-  # Create a config with the generated proto directory, which is used for C++.
-  config("$target_name._include_path") {
-    include_dirs = [ _common.gen_dir ]
-    visibility = [ ":*" ]
+    if (defined(invoker.inputs)) {
+      sources += invoker.inputs
+    }
+
+    directory = "${_common.compile_dir}/$_prefix"
   }
 
   # Enumerate all of the protobuf generator targets.
@@ -283,20 +484,53 @@
   _pw_pwpb_proto_library("$target_name.pwpb") {
     forward_variables_from(invoker, _forwarded_vars)
     forward_variables_from(_common, "*")
-    deps = process_file_template(_deps, "{{source}}.pwpb")
+
+    deps = []
+    foreach(dep, _deps) {
+      _base = get_label_info(dep, "label_no_toolchain")
+      deps += [ "$_base.pwpb(" + get_label_info(dep, "toolchain") + ")" ]
+    }
+
+    outputs = []
+    foreach(name, _source_names) {
+      outputs += [ "$base_out_dir/pwpb/$_prefix/${name}.pwpb.h" ]
+    }
   }
 
   if (dir_pw_third_party_nanopb != "") {
     _pw_nanopb_rpc_proto_library("$target_name.nanopb_rpc") {
       forward_variables_from(invoker, _forwarded_vars)
       forward_variables_from(_common, "*")
-      deps = process_file_template(_deps, "{{source}}.nanopb_rpc")
+
+      deps = []
+      foreach(dep, _deps) {
+        _lbl = get_label_info(dep, "label_no_toolchain")
+        deps += [ "$_lbl.nanopb_rpc(" + get_label_info(dep, "toolchain") + ")" ]
+      }
+
+      outputs = []
+      foreach(name, _source_names) {
+        outputs += [ "$base_out_dir/nanopb_rpc/$_prefix/${name}.rpc.pb.h" ]
+      }
     }
 
     _pw_nanopb_proto_library("$target_name.nanopb") {
       forward_variables_from(invoker, _forwarded_vars)
       forward_variables_from(_common, "*")
-      deps = process_file_template(_deps, "{{source}}.nanopb")
+
+      deps = []
+      foreach(dep, _deps) {
+        _base = get_label_info(dep, "label_no_toolchain")
+        deps += [ "$_base.nanopb(" + get_label_info(dep, "toolchain") + ")" ]
+      }
+
+      outputs = []
+      foreach(name, _source_names) {
+        outputs += [
+          "$base_out_dir/nanopb/$_prefix/${name}.pb.h",
+          "$base_out_dir/nanopb/$_prefix/${name}.pb.c",
+        ]
+      }
     }
   } else {
     pw_error("$target_name.nanopb_rpc") {
@@ -312,14 +546,56 @@
 
   _pw_raw_rpc_proto_library("$target_name.raw_rpc") {
     forward_variables_from(invoker, _forwarded_vars)
-    forward_variables_from(_common, "*", [ "deps" ])
-    deps = process_file_template(_deps, "{{source}}.raw_rpc")
+    forward_variables_from(_common, "*")
+
+    deps = []
+    foreach(dep, _deps) {
+      _base = get_label_info(dep, "label_no_toolchain")
+      deps += [ "$_base.raw_rpc(" + get_label_info(dep, "toolchain") + ")" ]
+    }
+
+    outputs = []
+    foreach(name, _source_names) {
+      outputs += [ "$base_out_dir/raw_rpc/$_prefix/${name}.raw_rpc.pb.h" ]
+    }
   }
 
   _pw_go_proto_library("$target_name.go") {
-    sources = invoker.sources
-    deps = process_file_template(_deps, "{{source}}.go")
-    base_target = _common.base_target
+    sources = _common.sources
+
+    deps = []
+    foreach(dep, _deps) {
+      _base = get_label_info(dep, "label_no_toolchain")
+      deps += [ "$_base.go(" + get_label_info(dep, "toolchain") + ")" ]
+    }
+
+    forward_variables_from(_common, "*")
+  }
+
+  _pw_python_proto_library("$target_name.python") {
+    forward_variables_from(_common, "*")
+    forward_variables_from(invoker, [ "python_package" ])
+    module_as_package = _module_as_package
+
+    deps = []
+    foreach(dep, _deps) {
+      _base = get_label_info(dep, "label_no_toolchain")
+      deps += [ "$_base.python(" + get_label_info(dep, "toolchain") + ")" ]
+    }
+
+    if (module_as_package == "") {
+      _python_prefix = "$base_out_dir/python/$_prefix"
+    } else {
+      _python_prefix = "$base_out_dir/python/$module_as_package"
+    }
+
+    outputs = []
+    foreach(name, _source_names) {
+      outputs += [
+        "$_python_prefix/${name}_pb2.py",
+        "$_python_prefix/${name}_pb2.pyi",
+      ]
+    }
   }
 
   # All supported pw_protobuf generators.
@@ -329,8 +605,24 @@
     "nanopb_rpc",
     "raw_rpc",
     "go",
+    "python",
   ]
 
+  # If the label matches the directory name, alias the subtargets to the
+  # directory (e.g. //foo:nanopb is an alias for //foo:foo.nanopb).
+  if (get_label_info(":$target_name", "name") ==
+      get_path_info(get_label_info(":$target_name", "dir"), "name")) {
+    foreach(_generator, _protobuf_generators - [ "python" ]) {
+      group(_generator) {
+        public_deps = [ ":${invoker.target_name}.$_generator" ]
+      }
+    }
+
+    pw_python_group("python") {
+      python_deps = [ ":${invoker.target_name}.python" ]
+    }
+  }
+
   # If the user attempts to use the target directly instead of one of the
   # generator targets, run a script which prints a nice error message.
   pw_python_action(target_name) {
diff --git a/pw_protobuf_compiler/pw_protobuf_compiler_protos/nanopb_test.proto b/pw_protobuf_compiler/pw_protobuf_compiler_nanopb_protos/nanopb_test.proto
similarity index 100%
rename from pw_protobuf_compiler/pw_protobuf_compiler_protos/nanopb_test.proto
rename to pw_protobuf_compiler/pw_protobuf_compiler_nanopb_protos/nanopb_test.proto
diff --git a/pw_protobuf_compiler/pw_protobuf_compiler_protos/nested/more_nesting/test.proto b/pw_protobuf_compiler/pw_protobuf_compiler_protos/nested/more_nesting/test.proto
new file mode 100644
index 0000000..6bb1417
--- /dev/null
+++ b/pw_protobuf_compiler/pw_protobuf_compiler_protos/nested/more_nesting/test.proto
@@ -0,0 +1,21 @@
+// 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.
+
+syntax = "proto3";
+
+package pw.protobuf_compiler.test;
+
+message Message {
+  int32 field = 1;
+}
diff --git a/pw_protobuf_compiler/pw_protobuf_compiler_protos/test.proto b/pw_protobuf_compiler/pw_protobuf_compiler_protos/test.proto
new file mode 100644
index 0000000..27bf6b4
--- /dev/null
+++ b/pw_protobuf_compiler/pw_protobuf_compiler_protos/test.proto
@@ -0,0 +1,22 @@
+// 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.
+
+syntax = "proto3";
+
+package pw.protobuf_compiler.test;
+
+enum Enum {
+  FOO = 0;
+  BAR = 1;
+}
diff --git a/pw_protobuf_compiler/py/BUILD.gn b/pw_protobuf_compiler/py/BUILD.gn
index 3c5f663..687cb1a 100644
--- a/pw_protobuf_compiler/py/BUILD.gn
+++ b/pw_protobuf_compiler/py/BUILD.gn
@@ -15,6 +15,7 @@
 import("//build_overrides/pigweed.gni")
 
 import("$dir_pw_build/python.gni")
+import("$dir_pw_third_party/nanopb/nanopb.gni")
 
 pw_python_package("py") {
   setup = [ "setup.py" ]
@@ -24,6 +25,17 @@
     "pw_protobuf_compiler/proto_target_invalid.py",
     "pw_protobuf_compiler/python_protos.py",
   ]
-  tests = [ "python_protos_test.py" ]
+  tests = [
+    "compiled_protos_test.py",
+    "python_protos_test.py",
+  ]
   python_deps = [ "$dir_pw_cli/py" ]
+  python_test_deps = [ "..:test_protos.python" ]
+  pylintrc = "$dir_pigweed/.pylintrc"
+
+  # If Nanopb is available, test protos that import nanopb.proto.
+  if (dir_pw_third_party_nanopb != "") {
+    python_test_deps += [ "..:nanopb_test_protos.python" ]
+    tests += [ "compiled_nanopb_protos_test.py" ]
+  }
 }
diff --git a/pw_protobuf_compiler/py/compiled_nanopb_protos_test.py b/pw_protobuf_compiler/py/compiled_nanopb_protos_test.py
new file mode 100755
index 0000000..d384063
--- /dev/null
+++ b/pw_protobuf_compiler/py/compiled_nanopb_protos_test.py
@@ -0,0 +1,29 @@
+#!/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 compiling and importing Python protos that import Nanopb."""
+
+import unittest
+
+from pw_protobuf_compiler_nanopb_protos import nanopb_test_pb2
+
+
+class TestCompileAndImport(unittest.TestCase):
+    def test_access_compiled_protobufs(self):
+        message = nanopb_test_pb2.Point(name='hello world')
+        self.assertEqual(message.name, 'hello world')
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/pw_protobuf_compiler/py/compiled_protos_test.py b/pw_protobuf_compiler/py/compiled_protos_test.py
new file mode 100755
index 0000000..88f8e62
--- /dev/null
+++ b/pw_protobuf_compiler/py/compiled_protos_test.py
@@ -0,0 +1,32 @@
+#!/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 compiling and importing Python protos on the fly."""
+
+import unittest
+
+from pw_protobuf_compiler_protos import test_pb2 as top_level
+from pw_protobuf_compiler_protos.nested.more_nesting import test_pb2
+
+
+class TestCompileAndImport(unittest.TestCase):
+    def test_access_compiled_protobufs(self):
+        self.assertNotEqual(top_level.FOO, top_level.BAR)
+
+        message = test_pb2.Message(field=123)
+        self.assertEqual(message.field, 123)
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/pw_protobuf_compiler/py/pw_protobuf_compiler/generate_protos.py b/pw_protobuf_compiler/py/pw_protobuf_compiler/generate_protos.py
index 7ee524f..10c0bb2 100644
--- a/pw_protobuf_compiler/py/pw_protobuf_compiler/generate_protos.py
+++ b/pw_protobuf_compiler/py/pw_protobuf_compiler/generate_protos.py
@@ -1,4 +1,4 @@
-# Copyright 2019 The Pigweed Authors
+# 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
@@ -17,24 +17,27 @@
 import logging
 import os
 from pathlib import Path
+import subprocess
 import sys
 import tempfile
+from typing import Callable, Dict, Optional, Tuple, Union
 
-from typing import Callable, Dict, List, Optional
-
-import pw_cli.log
-import pw_cli.process
+# Make sure dependencies are optional, since this script may be run when
+# installing Python package dependencies through GN.
+try:
+    from pw_cli.log import install as setup_logging
+except ImportError:
+    from logging import basicConfig as setup_logging  # type: ignore
 
 _LOG = logging.getLogger(__name__)
 
+_COMMON_FLAGS = ('--experimental_allow_proto3_optional', )
 
-def argument_parser(
-    parser: Optional[argparse.ArgumentParser] = None
-) -> argparse.ArgumentParser:
+
+def _argument_parser() -> argparse.ArgumentParser:
     """Registers the script's arguments on an argument parser."""
 
-    if parser is None:
-        parser = argparse.ArgumentParser(description=__doc__)
+    parser = argparse.ArgumentParser(description=__doc__)
 
     parser.add_argument('--language',
                         required=True,
@@ -43,95 +46,112 @@
     parser.add_argument('--plugin-path',
                         type=Path,
                         help='Path to the protoc plugin')
-    parser.add_argument('--module-path',
-                        required=True,
-                        help='Path to the module containing the .proto files')
-    parser.add_argument('--include-paths',
-                        default=[],
-                        type=lambda arg: arg.split(';'),
-                        help='protoc include paths')
     parser.add_argument('--include-file',
                         type=argparse.FileType('r'),
                         help='File containing additional protoc include paths')
     parser.add_argument('--out-dir',
+                        type=Path,
                         required=True,
                         help='Output directory for generated code')
-    parser.add_argument('protos',
-                        metavar='PROTO',
+    parser.add_argument('--compile-dir',
+                        type=Path,
+                        required=True,
+                        help='Root path for compilation')
+    parser.add_argument('--sources',
+                        type=Path,
                         nargs='+',
                         help='Input protobuf files')
 
     return parser
 
 
-def protoc_cc_args(args: argparse.Namespace) -> List[str]:
-    return [
+def protoc_cc_args(args: argparse.Namespace) -> Tuple[str, ...]:
+    return _COMMON_FLAGS + (
         '--plugin',
         f'protoc-gen-custom={args.plugin_path}',
         '--custom_out',
         args.out_dir,
-    ]
+    )
 
 
-def protoc_go_args(args: argparse.Namespace) -> List[str]:
-    return ['--go_out', f'plugins=grpc:{args.out_dir}']
+def protoc_go_args(args: argparse.Namespace) -> Tuple[str, ...]:
+    return _COMMON_FLAGS + (
+        '--go_out',
+        f'plugins=grpc:{args.out_dir}',
+    )
 
 
-def protoc_nanopb_args(args: argparse.Namespace) -> List[str]:
+def protoc_nanopb_args(args: argparse.Namespace) -> Tuple[str, ...]:
     # nanopb needs to know of the include path to parse *.options files
-    return [
+    return _COMMON_FLAGS + (
         '--plugin',
         f'protoc-gen-nanopb={args.plugin_path}',
         # nanopb_opt provides the flags to use for nanopb_out. Windows doesn't
-        # like when you merge the two using the `flag,...:out` syntax.
-        f'--nanopb_opt=-I{args.module_path}',
+        # like when you merge the two using the `flag,...:out` syntax. Use
+        # Posix-style paths since backslashes on Windows are treated like
+        # escape characters.
+        f'--nanopb_opt=-I{args.compile_dir.as_posix()}',
         f'--nanopb_out={args.out_dir}',
-    ]
+    )
 
 
-def protoc_nanopb_rpc_args(args: argparse.Namespace) -> List[str]:
-    return [
+def protoc_nanopb_rpc_args(args: argparse.Namespace) -> Tuple[str, ...]:
+    return _COMMON_FLAGS + (
         '--plugin',
         f'protoc-gen-custom={args.plugin_path}',
         '--custom_out',
         args.out_dir,
-    ]
+    )
 
 
-def protoc_raw_rpc_args(args: argparse.Namespace) -> List[str]:
-    return [
+def protoc_raw_rpc_args(args: argparse.Namespace) -> Tuple[str, ...]:
+    return _COMMON_FLAGS + (
         '--plugin',
         f'protoc-gen-custom={args.plugin_path}',
         '--custom_out',
         args.out_dir,
-    ]
+    )
 
 
+def protoc_python_args(args: argparse.Namespace) -> Tuple[str, ...]:
+    return _COMMON_FLAGS + (
+        '--python_out',
+        args.out_dir,
+        '--mypy_out',
+        args.out_dir,
+    )
+
+
+_DefaultArgsFunction = Callable[[argparse.Namespace], Tuple[str, ...]]
+
 # Default additional protoc arguments for each supported language.
 # TODO(frolv): Make these overridable with a command-line argument.
-DEFAULT_PROTOC_ARGS: Dict[str, Callable[[argparse.Namespace], List[str]]] = {
+DEFAULT_PROTOC_ARGS: Dict[str, _DefaultArgsFunction] = {
     'pwpb': protoc_cc_args,
     'go': protoc_go_args,
     'nanopb': protoc_nanopb_args,
     'nanopb_rpc': protoc_nanopb_rpc_args,
     'raw_rpc': protoc_raw_rpc_args,
+    'python': protoc_python_args,
 }
 
+# Languages that protoc internally supports.
+BUILTIN_PROTOC_LANGS = ('go', 'python')
+
 
 def main() -> int:
     """Runs protoc as configured by command-line arguments."""
 
-    parser = argument_parser()
+    parser = _argument_parser()
     args = parser.parse_args()
 
-    if args.plugin_path is None and args.language != 'go':
+    if args.plugin_path is None and args.language not in BUILTIN_PROTOC_LANGS:
         parser.error(
             f'--plugin-path is required for --language {args.language}')
 
-    os.makedirs(args.out_dir, exist_ok=True)
+    args.out_dir.mkdir(parents=True, exist_ok=True)
 
-    include_paths = [f'-I{path}' for path in args.include_paths]
-    include_paths += [f'-I{line.strip()}' for line in args.include_file]
+    include_paths = [f'-I{line.strip()}' for line in args.include_file]
 
     wrapper_script: Optional[Path] = None
 
@@ -149,24 +169,31 @@
             args.plugin_path = wrapper_script = Path(file.name)
             _LOG.debug('Using generated plugin wrapper %s', args.plugin_path)
 
+    cmd: Tuple[Union[str, Path], ...] = (
+        'protoc',
+        f'-I{args.compile_dir}',
+        *include_paths,
+        *DEFAULT_PROTOC_ARGS[args.language](args),
+        *args.sources,
+    )
+
     try:
-        process = pw_cli.process.run(
-            'protoc',
-            f'-I{args.module_path}',
-            *include_paths,
-            *DEFAULT_PROTOC_ARGS[args.language](args),
-            *args.protos,
-        )
+        process = subprocess.run(cmd,
+                                 stdout=subprocess.PIPE,
+                                 stderr=subprocess.STDOUT)
     finally:
         if wrapper_script:
             wrapper_script.unlink()
 
     if process.returncode != 0:
-        print(process.output.decode(), file=sys.stderr)
+        _LOG.error('Protocol buffer compilation failed!\n%s',
+                   ' '.join(str(c) for c in cmd))
+        sys.stderr.buffer.write(process.stdout)
+        sys.stderr.flush()
 
     return process.returncode
 
 
 if __name__ == '__main__':
-    pw_cli.log.install()
+    setup_logging()
     sys.exit(main())
diff --git a/pw_protobuf_compiler/py/pw_protobuf_compiler/python_protos.py b/pw_protobuf_compiler/py/pw_protobuf_compiler/python_protos.py
index 4b7f75a..9b6f6cd 100644
--- a/pw_protobuf_compiler/py/pw_protobuf_compiler/python_protos.py
+++ b/pw_protobuf_compiler/py/pw_protobuf_compiler/python_protos.py
@@ -13,6 +13,7 @@
 # the License.
 """Tools for compiling and importing Python protos on the fly."""
 
+from collections.abc import Mapping
 import importlib.util
 import logging
 import os
@@ -21,8 +22,8 @@
 import shlex
 import tempfile
 from types import ModuleType
-from typing import Dict, Generic, Iterable, Iterator, List, NamedTuple, Set
-from typing import Tuple, TypeVar, Union
+from typing import (Dict, Generic, Iterable, Iterator, List, NamedTuple, Set,
+                    Tuple, TypeVar, Union)
 
 _LOG = logging.getLogger(__name__)
 
@@ -47,6 +48,7 @@
 
     cmd: Tuple[PathOrStr, ...] = (
         'protoc',
+        '--experimental_allow_proto3_optional',
         '--python_out',
         os.path.abspath(output_dir),
         *(f'-I{d}' for d in include_paths),
@@ -70,7 +72,7 @@
     return module
 
 
-def import_modules(directory: PathOrStr) -> Iterator[ModuleType]:
+def import_modules(directory: PathOrStr) -> Iterator:
     """Imports modules in a directory and yields them."""
     parent = os.path.dirname(directory)
 
@@ -87,7 +89,7 @@
 
 def compile_and_import(proto_files: Iterable[PathOrStr],
                        includes: Iterable[PathOrStr] = (),
-                       output_dir: PathOrStr = None) -> Iterator[ModuleType]:
+                       output_dir: PathOrStr = None) -> Iterator:
     """Compiles protos and imports their modules; yields the proto modules.
 
     Args:
@@ -111,15 +113,14 @@
 
 def compile_and_import_file(proto_file: PathOrStr,
                             includes: Iterable[PathOrStr] = (),
-                            output_dir: PathOrStr = None) -> ModuleType:
+                            output_dir: PathOrStr = None):
     """Compiles and imports the module for a single .proto file."""
     return next(iter(compile_and_import([proto_file], includes, output_dir)))
 
 
-def compile_and_import_strings(
-        contents: Iterable[str],
-        includes: Iterable[PathOrStr] = (),
-        output_dir: PathOrStr = None) -> Iterator[ModuleType]:
+def compile_and_import_strings(contents: Iterable[str],
+                               includes: Iterable[PathOrStr] = (),
+                               output_dir: PathOrStr = None) -> Iterator:
     """Compiles protos in one or more strings."""
 
     if isinstance(contents, str):
@@ -150,16 +151,16 @@
 
     def _add_package(self, subpackage: str, package: '_NestedPackage') -> None:
         self._packages[subpackage] = package
-        setattr(self, subpackage, package)
 
     def _add_item(self, item) -> None:
-        self._items.append(item)
-        for attr, value in vars(item).items():
-            if not attr.startswith('_'):
-                setattr(self, attr, value)
+        if item not in self._items:  # Don't store the same item multiple times.
+            self._items.append(item)
 
     def __getattr__(self, attr: str):
-        # Fall back to item attributes, which includes private attributes.
+        """Look up subpackages or package members."""
+        if attr in self._packages:
+            return self._packages[attr]
+
         for item in self._items:
             if hasattr(item, attr):
                 return getattr(item, attr)
@@ -167,7 +168,30 @@
         raise AttributeError(
             f'Proto package "{self._package}" does not contain "{attr}"')
 
+    def __getitem__(self, subpackage: str) -> '_NestedPackage[T]':
+        """Support accessing nested packages by name."""
+        result = self
+
+        for package in subpackage.split('.'):
+            result = result._packages[package]
+
+        return result
+
+    def __dir__(self) -> List[str]:
+        """List subpackages and members of modules as attributes."""
+        attributes = list(self._packages)
+
+        for item in self._items:
+            for attr, value in vars(item).items():
+                # Exclude private variables and modules from dir().
+                if not attr.startswith('_') and not isinstance(
+                        value, ModuleType):
+                    attributes.append(attr)
+
+        return attributes
+
     def __iter__(self) -> Iterator['_NestedPackage[T]']:
+        """Iterate over nested packages."""
         return iter(self._packages.values())
 
     def __repr__(self) -> str:
@@ -226,6 +250,9 @@
     return packages
 
 
+PathOrModule = Union[str, Path, ModuleType]
+
+
 class Library:
     """A collection of protocol buffer modules sorted by package.
 
@@ -245,10 +272,27 @@
     for iterating over all modules.
     """
     @classmethod
+    def from_paths(cls, protos: Iterable[PathOrModule]) -> 'Library':
+        """Creates a Library from paths to proto files or proto modules."""
+        paths: List[PathOrStr] = []
+        modules: List[ModuleType] = []
+
+        for proto in protos:
+            if isinstance(proto, (Path, str)):
+                paths.append(proto)
+            else:
+                modules.append(proto)
+
+        if paths:
+            modules += compile_and_import(paths)
+        return Library(modules)
+
+    @classmethod
     def from_strings(cls,
                      contents: Iterable[str],
                      includes: Iterable[PathOrStr] = (),
                      output_dir: PathOrStr = None) -> 'Library':
+        """Creates a proto library from protos in the provided strings."""
         return cls(compile_and_import_strings(contents, includes, output_dir))
 
     def __init__(self, modules: Iterable[ModuleType]):
@@ -263,7 +307,92 @@
             (m.DESCRIPTOR.package, m)  # type: ignore[attr-defined]
             for m in modules)
 
-    def modules(self) -> Iterable[ModuleType]:
-        """Allows iterating over all protobuf modules in this library."""
+    def modules(self) -> Iterable:
+        """Iterates over all protobuf modules in this library."""
         for module_list in self.modules_by_package.values():
             yield from module_list
+
+    def messages(self) -> Iterable:
+        """Iterates over all protobuf messages in this library."""
+        for module in self.modules():
+            yield from _nested_messages(
+                module, module.DESCRIPTOR.message_types_by_name)
+
+
+def _nested_messages(scope, message_names: Iterable[str]) -> Iterator:
+    for name in message_names:
+        msg = getattr(scope, name)
+        yield msg
+        yield from _nested_messages(msg, msg.DESCRIPTOR.nested_types_by_name)
+
+
+def _repr_char(char: int) -> str:
+    r"""Returns an ASCII char or the \x code for non-printable values."""
+    if ord(' ') <= char <= ord('~'):
+        return r"\'" if chr(char) == "'" else chr(char)
+
+    return f'\\x{char:02X}'
+
+
+def bytes_repr(value: bytes) -> str:
+    """Prints bytes as mixed ASCII only if at least half are printable."""
+    ascii_char_count = sum(ord(' ') <= c <= ord('~') for c in value)
+    if ascii_char_count >= len(value) / 2:
+        contents = ''.join(_repr_char(c) for c in value)
+    else:
+        contents = ''.join(f'\\x{c:02X}' for c in value)
+
+    return f"b'{contents}'"
+
+
+def _field_repr(field, value) -> str:
+    if field.type == field.TYPE_ENUM:
+        try:
+            enum = field.enum_type.values_by_number[value]
+            return f'{field.enum_type.full_name}.{enum.name}'
+        except KeyError:
+            return repr(value)
+
+    if field.type == field.TYPE_MESSAGE:
+        return proto_repr(value)
+
+    if field.type == field.TYPE_BYTES:
+        return bytes_repr(value)
+
+    return repr(value)
+
+
+def _proto_repr(message) -> Iterator[str]:
+    for field in message.DESCRIPTOR.fields:
+        value = getattr(message, field.name)
+
+        # Skip fields that are not present.
+        try:
+            if not message.HasField(field.name):
+                continue
+        except ValueError:
+            # Skip default-valued fields that don't support HasField.
+            if (field.label != field.LABEL_REPEATED
+                    and value == field.default_value):
+                continue
+
+        if field.label == field.LABEL_REPEATED:
+            if not value:
+                continue
+
+            if isinstance(value, Mapping):
+                key_desc, value_desc = field.message_type.fields
+                values = ', '.join(
+                    f'{_field_repr(key_desc, k)}: {_field_repr(value_desc, v)}'
+                    for k, v in value.items())
+                yield f'{field.name}={{{values}}}'
+            else:
+                values = ', '.join(_field_repr(field, v) for v in value)
+                yield f'{field.name}=[{values}]'
+        else:
+            yield f'{field.name}={_field_repr(field, value)}'
+
+
+def proto_repr(message) -> str:
+    """Creates a repr-like string for a protobuf."""
+    return f'{message.DESCRIPTOR.full_name}({", ".join(_proto_repr(message))})'
diff --git a/pw_protobuf_compiler/py/python_protos_test.py b/pw_protobuf_compiler/py/python_protos_test.py
index f7dd75a..7888c55 100755
--- a/pw_protobuf_compiler/py/python_protos_test.py
+++ b/pw_protobuf_compiler/py/python_protos_test.py
@@ -19,6 +19,7 @@
 import unittest
 
 from pw_protobuf_compiler import python_protos
+from pw_protobuf_compiler.python_protos import bytes_repr, proto_repr
 
 PROTO_1 = """\
 syntax = "proto3";
@@ -83,6 +84,18 @@
   repeated int64 value = 1;
   Greeting hi = 2;
 }
+
+message NestingMessage {
+  message NestedMessage {
+    message NestedNestedMessage {
+      int32 nested_nested_field = 1;
+    }
+
+    NestedNestedMessage nested_nested_message = 1;
+  }
+
+  NestedMessage nested_message = 1;
+}
 """
 
 
@@ -154,7 +167,7 @@
         test2 = self._library.modules_by_package['pw.protobuf_compiler.test2']
         self.assertEqual(len(test2), 2)
 
-    def test_access_modules_by_package_unkonwn(self):
+    def test_access_modules_by_package_unknown(self):
         with self.assertRaises(KeyError):
             _ = self._library.modules_by_package['pw.not_real']
 
@@ -176,6 +189,209 @@
         val = library.packages.proto.library.test.test2.YO
         self.assertEqual(val, 0)
 
+    def test_access_nested_packages_by_name(self):
+        self.assertIs(self._library.packages['pw.protobuf_compiler.test1'],
+                      self._library.packages.pw.protobuf_compiler.test1)
+        self.assertIs(self._library.packages.pw['protobuf_compiler.test1'],
+                      self._library.packages.pw.protobuf_compiler.test1)
+        self.assertIs(self._library.packages.pw.protobuf_compiler['test1'],
+                      self._library.packages.pw.protobuf_compiler.test1)
+
+    def test_access_nested_packages_by_name_unknown_package(self):
+        with self.assertRaises(KeyError):
+            _ = self._library.packages['']
+
+        with self.assertRaises(KeyError):
+            _ = self._library.packages['.']
+
+        with self.assertRaises(KeyError):
+            _ = self._library.packages['protobuf_compiler.test1']
+
+        with self.assertRaises(KeyError):
+            _ = self._library.packages.pw['pw.protobuf_compiler.test1']
+
+        with self.assertRaises(KeyError):
+            _ = self._library.packages.pw.protobuf_compiler['not here']
+
+    def test_messages(self):
+        protos = self._library.packages.pw.protobuf_compiler
+        self.assertEqual(
+            set(self._library.messages()), {
+                protos.test1.SomeMessage,
+                protos.test1.AnotherMessage,
+                protos.test2.Request,
+                protos.test2.Response,
+                protos.test2.Hello,
+                protos.test2.NestingMessage,
+                protos.test2.NestingMessage.NestedMessage,
+                protos.test2.NestingMessage.NestedMessage.NestedNestedMessage,
+            })
+
+
+PROTO_FOR_REPR = """\
+syntax = "proto3";
+
+package pw.test3;
+
+enum Enum {
+  ZERO = 0;
+  ONE = 1;
+}
+
+message Nested {
+  repeated int64 value = 1;
+  Enum an_enum = 2;
+}
+
+message Message {
+  Nested message = 1;
+  repeated Nested repeated_message = 2;
+
+  fixed32 regular_int = 3;
+  optional int64 optional_int = 4;
+  repeated int32 repeated_int = 5;
+
+  bytes regular_bytes = 6;
+  optional bytes optional_bytes = 7;
+  repeated bytes repeated_bytes = 8;
+
+  string regular_string = 9;
+  optional string optional_string = 10;
+  repeated string repeated_string = 11;
+
+  Enum regular_enum = 12;
+  optional Enum optional_enum = 13;
+  repeated Enum repeated_enum = 14;
+
+  oneof oneof_test {
+    string oneof_1 = 15;
+    int32 oneof_2 = 16;
+    float oneof_3 = 17;
+  }
+
+  map<string, Nested> mapping = 18;
+}
+"""
+
+
+class TestProtoRepr(unittest.TestCase):
+    """Tests printing protobufs."""
+    def setUp(self):
+        protos = python_protos.Library.from_strings(PROTO_FOR_REPR)
+        self.enum = protos.packages.pw.test3.Enum
+        self.nested = protos.packages.pw.test3.Nested
+        self.message = protos.packages.pw.test3.Message
+
+    def test_empty(self):
+        self.assertEqual('pw.test3.Nested()', proto_repr(self.nested()))
+        self.assertEqual('pw.test3.Message()', proto_repr(self.message()))
+
+    def test_int_fields(self):
+        self.assertEqual(
+            'pw.test3.Message('
+            'regular_int=999, '
+            'optional_int=-1, '
+            'repeated_int=[0, 1, 2])',
+            proto_repr(
+                self.message(repeated_int=[0, 1, 2],
+                             regular_int=999,
+                             optional_int=-1)))
+
+    def test_bytes_fields(self):
+        self.assertEqual(
+            'pw.test3.Message('
+            r"regular_bytes=b'\xFE\xED\xBE\xEF', "
+            r"optional_bytes=b'', "
+            r"repeated_bytes=[b'Hello\'\'\''])",
+            proto_repr(
+                self.message(
+                    regular_bytes=b'\xfe\xed\xbe\xef',
+                    optional_bytes=b'',
+                    repeated_bytes=[b"Hello'''"],
+                )))
+
+    def test_string_fields(self):
+        self.assertEqual(
+            'pw.test3.Message('
+            "regular_string='hi', "
+            "optional_string='', "
+            'repeated_string=["\'"])',
+            proto_repr(
+                self.message(
+                    regular_string='hi',
+                    optional_string='',
+                    repeated_string=[b"'"],
+                )))
+
+    def test_enum_fields(self):
+        self.assertEqual('pw.test3.Nested(an_enum=pw.test3.Enum.ONE)',
+                         proto_repr(self.nested(an_enum=1)))
+        self.assertEqual('pw.test3.Message(optional_enum=pw.test3.Enum.ONE)',
+                         proto_repr(self.message(optional_enum=self.enum.ONE)))
+        self.assertEqual(
+            'pw.test3.Message(repeated_enum='
+            '[pw.test3.Enum.ONE, pw.test3.Enum.ONE, pw.test3.Enum.ZERO])',
+            proto_repr(self.message(repeated_enum=[1, 1, 0])))
+
+    def test_message_fields(self):
+        self.assertEqual(
+            'pw.test3.Message(message=pw.test3.Nested(value=[123]))',
+            proto_repr(self.message(message=self.nested(value=[123]))))
+        self.assertEqual(
+            'pw.test3.Message('
+            'repeated_message=[pw.test3.Nested(value=[123]), '
+            'pw.test3.Nested()])',
+            proto_repr(
+                self.message(
+                    repeated_message=[self.nested(
+                        value=[123]), self.nested()])))
+
+    def test_optional_shown_if_set_to_default(self):
+        self.assertEqual(
+            "pw.test3.Message("
+            "optional_int=0, optional_bytes=b'', optional_string='', "
+            "optional_enum=pw.test3.Enum.ZERO)",
+            proto_repr(
+                self.message(optional_int=0,
+                             optional_bytes=b'',
+                             optional_string='',
+                             optional_enum=0)))
+
+    def test_oneof(self):
+        self.assertEqual(proto_repr(self.message(oneof_1='test')),
+                         "pw.test3.Message(oneof_1='test')")
+        self.assertEqual(proto_repr(self.message(oneof_2=123)),
+                         "pw.test3.Message(oneof_2=123)")
+        self.assertEqual(proto_repr(self.message(oneof_3=123)),
+                         "pw.test3.Message(oneof_3=123.0)")
+
+        msg = self.message(oneof_1='test')
+        msg.oneof_2 = 99
+        self.assertEqual(proto_repr(msg), "pw.test3.Message(oneof_2=99)")
+
+    def test_map(self):
+        msg = self.message()
+        msg.mapping['zero'].MergeFrom(self.nested())
+        msg.mapping['one'].MergeFrom(
+            self.nested(an_enum=self.enum.ONE, value=[1]))
+
+        result = proto_repr(msg)
+        self.assertRegex(result, r'^pw.test3.Message\(mapping={.*}\)$')
+        self.assertIn("'zero': pw.test3.Nested()", result)
+        self.assertIn(
+            "'one': pw.test3.Nested(value=[1], an_enum=pw.test3.Enum.ONE)",
+            result)
+
+    def test_bytes_repr(self):
+        self.assertEqual(bytes_repr(b'\xfe\xed\xbe\xef'),
+                         r"b'\xFE\xED\xBE\xEF'")
+        self.assertEqual(bytes_repr(b'\xfe\xed\xbe\xef123'),
+                         r"b'\xFE\xED\xBE\xEF\x31\x32\x33'")
+        self.assertEqual(bytes_repr(b'\xfe\xed\xbe\xef1234'),
+                         r"b'\xFE\xED\xBE\xEF1234'")
+        self.assertEqual(bytes_repr(b'\xfe\xed\xbe\xef12345'),
+                         r"b'\xFE\xED\xBE\xEF12345'")
+
 
 if __name__ == '__main__':
     unittest.main()
diff --git a/pw_protobuf_compiler/py/setup.py b/pw_protobuf_compiler/py/setup.py
index 189992f..b0d8657 100644
--- a/pw_protobuf_compiler/py/setup.py
+++ b/pw_protobuf_compiler/py/setup.py
@@ -24,11 +24,8 @@
     packages=setuptools.find_packages(),
     package_data={'pw_protobuf_compiler': ['py.typed']},
     zip_safe=False,
-    entry_points={
-        'console_scripts':
-        ['generate_protos = pw_protobuf_compiler.generate_protos:main']
-    },
     install_requires=[
+        'mypy-protobuf',
         'protobuf',
         'pw_cli',
     ],
diff --git a/pw_random/BUILD.gn b/pw_random/BUILD.gn
index 1328a05..8a440f8 100644
--- a/pw_random/BUILD.gn
+++ b/pw_random/BUILD.gn
@@ -30,7 +30,6 @@
   ]
   public_deps = [
     dir_pw_bytes,
-    dir_pw_span,
     dir_pw_status,
   ]
 }
diff --git a/pw_random/xor_shift_test.cc b/pw_random/xor_shift_test.cc
index 6b7e379..a653584 100644
--- a/pw_random/xor_shift_test.cc
+++ b/pw_random/xor_shift_test.cc
@@ -44,7 +44,7 @@
   XorShiftStarRng64 rng(seed1);
   for (size_t i = 0; i < result1_count; ++i) {
     uint64_t val = 0;
-    EXPECT_EQ(rng.GetInt(val).status(), Status::Ok());
+    EXPECT_EQ(rng.GetInt(val).status(), OkStatus());
     EXPECT_EQ(val, result1[i]);
   }
 }
@@ -53,7 +53,7 @@
   XorShiftStarRng64 rng(seed2);
   for (size_t i = 0; i < result2_count; ++i) {
     uint64_t val = 0;
-    EXPECT_EQ(rng.GetInt(val).status(), Status::Ok());
+    EXPECT_EQ(rng.GetInt(val).status(), OkStatus());
     EXPECT_EQ(val, result2[i]);
   }
 }
@@ -62,7 +62,7 @@
   XorShiftStarRng64 rng(seed1);
   uint64_t val = 0;
   rng.InjectEntropyBits(0x1, 1);
-  EXPECT_EQ(rng.GetInt(val).status(), Status::Ok());
+  EXPECT_EQ(rng.GetInt(val).status(), OkStatus());
   EXPECT_NE(val, result1[0]);
 }
 
@@ -72,14 +72,14 @@
   XorShiftStarRng64 rng_1(seed1);
   uint64_t first_val = 0;
   rng_1.InjectEntropyBits(0x1, 1);
-  EXPECT_EQ(rng_1.GetInt(first_val).status(), Status::Ok());
+  EXPECT_EQ(rng_1.GetInt(first_val).status(), OkStatus());
 
   // Use the same starting seed.
   XorShiftStarRng64 rng_2(seed1);
   uint64_t second_val = 0;
   // Use a different number of entropy bits.
   rng_2.InjectEntropyBits(0x1, 2);
-  EXPECT_EQ(rng_2.GetInt(second_val).status(), Status::Ok());
+  EXPECT_EQ(rng_2.GetInt(second_val).status(), OkStatus());
 
   EXPECT_NE(first_val, second_val);
 }
@@ -91,7 +91,7 @@
   XorShiftStarRng64 rng_1(seed1);
   uint64_t first_val = 0;
   rng_1.InjectEntropyBits(0x6, 3);
-  EXPECT_EQ(rng_1.GetInt(first_val).status(), Status::Ok());
+  EXPECT_EQ(rng_1.GetInt(first_val).status(), OkStatus());
 
   // Use the same starting seed.
   XorShiftStarRng64 rng_2(seed1);
@@ -100,7 +100,7 @@
   rng_2.InjectEntropyBits(0x1, 1);
   rng_2.InjectEntropyBits(0x1, 1);
   rng_2.InjectEntropyBits(0x0, 1);
-  EXPECT_EQ(rng_2.GetInt(second_val).status(), Status::Ok());
+  EXPECT_EQ(rng_2.GetInt(second_val).status(), OkStatus());
 
   EXPECT_EQ(first_val, second_val);
 }
@@ -114,7 +114,7 @@
                                                    std::byte(0x17),
                                                    std::byte(0x02)};
   rng.InjectEntropy(entropy);
-  EXPECT_EQ(rng.GetInt(val).status(), Status::Ok());
+  EXPECT_EQ(rng.GetInt(val).status(), OkStatus());
   EXPECT_NE(val, result1[0]);
 }
 
diff --git a/pw_result/public/pw_result/result.h b/pw_result/public/pw_result/result.h
index 7093551..e529344 100644
--- a/pw_result/public/pw_result/result.h
+++ b/pw_result/public/pw_result/result.h
@@ -15,7 +15,7 @@
 
 #include <algorithm>
 
-#include "pw_assert/assert.h"
+#include "pw_assert/light.h"
 #include "pw_status/status.h"
 
 namespace pw {
@@ -26,19 +26,18 @@
 template <typename T>
 class Result {
  public:
-  constexpr Result(T&& value)
-      : value_(std::move(value)), status_(Status::Ok()) {}
-  constexpr Result(const T& value) : value_(value), status_(Status::Ok()) {}
+  constexpr Result(T&& value) : value_(std::move(value)), status_(OkStatus()) {}
+  constexpr Result(const T& value) : value_(value), status_(OkStatus()) {}
 
   template <typename... Args>
   constexpr Result(std::in_place_t, Args&&... args)
-      : value_(std::forward<Args>(args)...), status_(Status::Ok()) {}
+      : value_(std::forward<Args>(args)...), status_(OkStatus()) {}
 
-  // TODO(pwbug/246): This can be constexpr when tokenized asserts are fixed.
-  Result(Status status) : status_(status) { PW_CHECK(status_ != Status::Ok()); }
-  // TODO(pwbug/246): This can be constexpr when tokenized asserts are fixed.
-  Result(Status::Code code) : status_(code) {
-    PW_CHECK(status_ != Status::Ok());
+  constexpr Result(Status status) : dummy_({}), status_(status) {
+    PW_ASSERT(!status_.ok());
+  }
+  constexpr Result(Status::Code code) : dummy_({}), status_(code) {
+    PW_ASSERT(!status_.ok());
   }
 
   constexpr Result(const Result&) = default;
@@ -50,28 +49,29 @@
   constexpr Status status() const { return status_; }
   constexpr bool ok() const { return status_.ok(); }
 
-  // TODO(pwbug/246): This can be constexpr when tokenized asserts are fixed.
-  T& value() & {
-    PW_CHECK_OK(status_);
+  constexpr T& value() & {
+    PW_ASSERT(status_.ok());
     return value_;
   }
 
-  // TODO(pwbug/246): This can be constexpr when tokenized asserts are fixed.
-  const T& value() const& {
-    PW_CHECK_OK(status_);
+  constexpr const T& value() const& {
+    PW_ASSERT(status_.ok());
     return value_;
   }
 
-  // TODO(pwbug/246): This can be constexpr when tokenized asserts are fixed.
-  T&& value() && {
-    PW_CHECK_OK(status_);
+  constexpr T&& value() && {
+    PW_ASSERT(status_.ok());
     return std::move(value_);
   }
 
   template <typename U>
   constexpr T value_or(U&& default_value) const& {
     if (ok()) {
+      PW_MODIFY_DIAGNOSTICS_PUSH();
+      // GCC 10 emits -Wmaybe-uninitialized warnings about value_.
+      PW_MODIFY_DIAGNOSTIC_GCC(ignored, "-Wmaybe-uninitialized");
       return value_;
+      PW_MODIFY_DIAGNOSTICS_POP();
     }
     return std::forward<U>(default_value);
   }
@@ -85,8 +85,13 @@
   }
 
  private:
+  struct Dummy {};
+
   union {
     T value_;
+
+    // Ensure that there is always a trivial constructor for the union.
+    Dummy dummy_;
   };
   Status status_;
 };
diff --git a/pw_result/result_test.cc b/pw_result/result_test.cc
index ef913fd..e9095f5 100644
--- a/pw_result/result_test.cc
+++ b/pw_result/result_test.cc
@@ -22,7 +22,7 @@
 TEST(Result, CreateOk) {
   Result<const char*> res("hello");
   EXPECT_TRUE(res.ok());
-  EXPECT_EQ(res.status(), Status::Ok());
+  EXPECT_EQ(res.status(), OkStatus());
   EXPECT_EQ(res.value(), "hello");
 }
 
diff --git a/pw_result/size_report/BUILD.gn b/pw_result/size_report/BUILD.gn
index 6e1d6d5..441151f 100644
--- a/pw_result/size_report/BUILD.gn
+++ b/pw_result/size_report/BUILD.gn
@@ -57,7 +57,6 @@
     dir_pw_bytes,
     dir_pw_log,
     dir_pw_preprocessor,
-    dir_pw_span,
   ]
 }
 
@@ -68,6 +67,5 @@
     dir_pw_bytes,
     dir_pw_log,
     dir_pw_preprocessor,
-    dir_pw_span,
   ]
 }
diff --git a/pw_result/size_report/pointer_noinline.cc b/pw_result/size_report/pointer_noinline.cc
index 522c9fe..1318fec 100644
--- a/pw_result/size_report/pointer_noinline.cc
+++ b/pw_result/size_report/pointer_noinline.cc
@@ -21,7 +21,7 @@
     return pw::Status::InvalidArgument();
   }
   *out = a / b;
-  return pw::Status::Ok();
+  return pw::OkStatus();
 }
 
 int volatile* unoptimizable;
diff --git a/pw_result/size_report/pointer_read.cc b/pw_result/size_report/pointer_read.cc
index fb4f948..130cb56 100644
--- a/pw_result/size_report/pointer_read.cc
+++ b/pw_result/size_report/pointer_read.cc
@@ -38,7 +38,7 @@
   }
 
   *out = std::span<const std::byte>(std::data(kArray) + offset, size);
-  return pw::Status::Ok();
+  return pw::OkStatus();
 }
 
 }  // namespace
diff --git a/pw_result/size_report/pointer_simple.cc b/pw_result/size_report/pointer_simple.cc
index 7c1440e..599ca77 100644
--- a/pw_result/size_report/pointer_simple.cc
+++ b/pw_result/size_report/pointer_simple.cc
@@ -20,7 +20,7 @@
     return pw::Status::InvalidArgument();
   }
   *out = a / b;
-  return pw::Status::Ok();
+  return pw::OkStatus();
 }
 
 int volatile* unoptimizable;
diff --git a/pw_ring_buffer/BUILD.gn b/pw_ring_buffer/BUILD.gn
index 6c5a17b..8871caa 100644
--- a/pw_ring_buffer/BUILD.gn
+++ b/pw_ring_buffer/BUILD.gn
@@ -14,6 +14,7 @@
 
 import("//build_overrides/pigweed.gni")
 
+import("$dir_pw_bloat/bloat.gni")
 import("$dir_pw_build/target_types.gni")
 import("$dir_pw_docgen/docs.gni")
 import("$dir_pw_unit_test/test.gni")
@@ -26,12 +27,14 @@
   public_configs = [ ":default_config" ]
   public_deps = [
     "$dir_pw_containers",
-    "$dir_pw_span",
     "$dir_pw_status",
   ]
   sources = [ "prefixed_entry_ring_buffer.cc" ]
   public = [ "public/pw_ring_buffer/prefixed_entry_ring_buffer.h" ]
-  deps = [ "$dir_pw_varint" ]
+  deps = [
+    "$dir_pw_assert:pw_assert",
+    "$dir_pw_varint",
+  ]
 }
 
 pw_test_group("tests") {
@@ -48,4 +51,27 @@
 
 pw_doc_group("docs") {
   sources = [ "docs.rst" ]
+  report_deps = [ ":ring_buffer_size" ]
+}
+
+pw_size_report("ring_buffer_size") {
+  title = "pw::ring_buffer::PrefixedEntryRingBuffer"
+
+  binaries = [
+    {
+      target = "size_report:ring_buffer_simple"
+      base = "$dir_pw_bloat:bloat_base"
+      label = "Initialize single-reader ring buffer"
+    },
+    {
+      target = "size_report:ring_buffer_multi"
+      base = "$dir_pw_bloat:bloat_base"
+      label = "Initialize multi-reader ring buffer"
+    },
+    {
+      target = "size_report:ring_buffer_multi"
+      base = "size_report:ring_buffer_simple"
+      label = "Initialized multi-reader vs. single-reader"
+    },
+  ]
 }
diff --git a/pw_ring_buffer/prefixed_entry_ring_buffer.cc b/pw_ring_buffer/prefixed_entry_ring_buffer.cc
index d122162..86bdd53 100644
--- a/pw_ring_buffer/prefixed_entry_ring_buffer.cc
+++ b/pw_ring_buffer/prefixed_entry_ring_buffer.cc
@@ -14,22 +14,27 @@
 
 #include "pw_ring_buffer/prefixed_entry_ring_buffer.h"
 
+#include <algorithm>
 #include <cstring>
 
+#include "pw_assert/light.h"
 #include "pw_varint/varint.h"
 
 namespace pw {
 namespace ring_buffer {
 
 using std::byte;
+using Reader = PrefixedEntryRingBufferMulti::Reader;
 
-void PrefixedEntryRingBuffer::Clear() {
-  read_idx_ = 0;
+void PrefixedEntryRingBufferMulti::Clear() {
   write_idx_ = 0;
-  entry_count_ = 0;
+  for (Reader& reader : readers_) {
+    reader.read_idx = 0;
+    reader.entry_count = 0;
+  }
 }
 
-Status PrefixedEntryRingBuffer::SetBuffer(std::span<byte> buffer) {
+Status PrefixedEntryRingBufferMulti::SetBuffer(std::span<byte> buffer) {
   if ((buffer.data() == nullptr) ||  //
       (buffer.size_bytes() == 0) ||  //
       (buffer.size_bytes() > kMaxBufferBytes)) {
@@ -40,12 +45,38 @@
   buffer_bytes_ = buffer.size_bytes();
 
   Clear();
-  return Status::Ok();
+  return OkStatus();
 }
 
-Status PrefixedEntryRingBuffer::InternalPushBack(std::span<const byte> data,
-                                                 byte user_preamble_data,
-                                                 bool drop_elements_if_needed) {
+Status PrefixedEntryRingBufferMulti::AttachReader(Reader& reader) {
+  if (reader.buffer != nullptr) {
+    return Status::InvalidArgument();
+  }
+  reader.buffer = this;
+
+  // Note that a newly attached reader sees the buffer as empty,
+  // and is not privy to entries pushed before being attached.
+  reader.read_idx = write_idx_;
+  reader.entry_count = 0;
+  readers_.push_back(reader);
+  return OkStatus();
+}
+
+Status PrefixedEntryRingBufferMulti::DetachReader(Reader& reader) {
+  if (reader.buffer != this) {
+    return Status::InvalidArgument();
+  }
+  reader.buffer = nullptr;
+  reader.read_idx = 0;
+  reader.entry_count = 0;
+  readers_.remove(reader);
+  return OkStatus();
+}
+
+Status PrefixedEntryRingBufferMulti::InternalPushBack(
+    std::span<const byte> data,
+    uint32_t user_preamble_data,
+    bool drop_elements_if_needed) {
   if (buffer_ == nullptr) {
     return Status::FailedPrecondition();
   }
@@ -53,11 +84,18 @@
     return Status::InvalidArgument();
   }
 
-  // Prepare the preamble, and ensure we can fit the preamble and entry.
-  byte varint_buf[kMaxEntryPreambleBytes];
-  size_t varint_bytes = varint::Encode<size_t>(data.size_bytes(), varint_buf);
+  // Prepare a single buffer that can hold both the user preamble and entry
+  // length.
+  byte preamble_buf[varint::kMaxVarint32SizeBytes * 2];
+  size_t user_preamble_bytes = 0;
+  if (user_preamble_) {
+    user_preamble_bytes =
+        varint::Encode<uint32_t>(user_preamble_data, preamble_buf);
+  }
+  size_t length_bytes = varint::Encode<uint32_t>(
+      data.size_bytes(), std::span(preamble_buf).subspan(user_preamble_bytes));
   size_t total_write_bytes =
-      (user_preamble_ ? 1 : 0) + varint_bytes + data.size_bytes();
+      user_preamble_bytes + length_bytes + data.size_bytes();
   if (buffer_bytes_ < total_write_bytes) {
     return Status::OutOfRange();
   }
@@ -66,7 +104,7 @@
     // PushBack() case: evict items as needed.
     // Drop old entries until we have space for the new entry.
     while (RawAvailableBytes() < total_write_bytes) {
-      PopFront();
+      InternalPopFrontAll();
     }
   } else if (RawAvailableBytes() < total_write_bytes) {
     // TryPushBack() case: don't evict items.
@@ -74,13 +112,14 @@
   }
 
   // Write the new entry into the ring buffer.
-  if (user_preamble_) {
-    RawWrite(std::span(&user_preamble_data, sizeof(user_preamble_data)));
-  }
-  RawWrite(std::span(varint_buf, varint_bytes));
+  RawWrite(std::span(preamble_buf, user_preamble_bytes + length_bytes));
   RawWrite(data);
-  entry_count_++;
-  return Status::Ok();
+
+  // Update all readers of the new count.
+  for (Reader& reader : readers_) {
+    reader.entry_count++;
+  }
+  return OkStatus();
 }
 
 auto GetOutput(std::span<byte> data_out, size_t* write_index) {
@@ -90,46 +129,58 @@
     memcpy(data_out.data() + *write_index, src.data(), copy_size);
     *write_index += copy_size;
 
-    return (copy_size == src.size_bytes()) ? Status::Ok()
+    return (copy_size == src.size_bytes()) ? OkStatus()
                                            : Status::ResourceExhausted();
   };
 }
 
-Status PrefixedEntryRingBuffer::PeekFront(std::span<byte> data,
-                                          size_t* bytes_read) {
-  *bytes_read = 0;
-  return InternalRead(GetOutput(data, bytes_read), false);
+Status PrefixedEntryRingBufferMulti::InternalPeekFront(Reader& reader,
+                                                       std::span<byte> data,
+                                                       size_t* bytes_read_out) {
+  *bytes_read_out = 0;
+  return InternalRead(reader, GetOutput(data, bytes_read_out), false);
 }
 
-Status PrefixedEntryRingBuffer::PeekFront(ReadOutput output) {
-  return InternalRead(output, false);
+Status PrefixedEntryRingBufferMulti::InternalPeekFront(Reader& reader,
+                                                       ReadOutput output) {
+  return InternalRead(reader, output, false);
 }
 
-Status PrefixedEntryRingBuffer::PeekFrontWithPreamble(std::span<byte> data,
-                                                      size_t* bytes_read) {
-  *bytes_read = 0;
-  return InternalRead(GetOutput(data, bytes_read), true);
+Status PrefixedEntryRingBufferMulti::InternalPeekFrontWithPreamble(
+    Reader& reader, std::span<byte> data, size_t* bytes_read_out) {
+  *bytes_read_out = 0;
+  return InternalRead(reader, GetOutput(data, bytes_read_out), true);
 }
 
-Status PrefixedEntryRingBuffer::PeekFrontWithPreamble(ReadOutput output) {
-  return InternalRead(output, true);
+Status PrefixedEntryRingBufferMulti::InternalPeekFrontWithPreamble(
+    Reader& reader, ReadOutput output) {
+  return InternalRead(reader, output, true);
 }
 
+// TODO(pwbug/339): Consider whether this internal templating is required, or if
+// we can simply promote GetOutput to a static function and remove the template.
 // T should be similar to Status (*read_output)(std::span<const byte>)
 template <typename T>
-Status PrefixedEntryRingBuffer::InternalRead(T read_output, bool get_preamble) {
+Status PrefixedEntryRingBufferMulti::InternalRead(
+    Reader& reader,
+    T read_output,
+    bool include_preamble_in_output,
+    uint32_t* user_preamble_out) {
   if (buffer_ == nullptr) {
     return Status::FailedPrecondition();
   }
-  if (EntryCount() == 0) {
+  if (reader.entry_count == 0) {
     return Status::OutOfRange();
   }
 
   // Figure out where to start reading (wrapped); accounting for preamble.
-  EntryInfo info = FrontEntryInfo();
+  EntryInfo info = FrontEntryInfo(reader);
   size_t read_bytes = info.data_bytes;
-  size_t data_read_idx = read_idx_;
-  if (get_preamble) {
+  size_t data_read_idx = reader.read_idx;
+  if (user_preamble_out) {
+    *user_preamble_out = info.user_preamble;
+  }
+  if (include_preamble_in_output) {
     read_bytes += info.preamble_bytes;
   } else {
     data_read_idx = IncrementIndex(data_read_idx, info.preamble_bytes);
@@ -148,93 +199,187 @@
   return status;
 }
 
-Status PrefixedEntryRingBuffer::PopFront() {
+void PrefixedEntryRingBufferMulti::InternalPopFrontAll() {
+  // Forcefully pop all readers. Find the slowest reader, which must have
+  // the highest entry count, then pop all readers that have the same count.
+  //
+  // It is expected that InternalPopFrontAll is called only when there is
+  // something to pop from at least one reader. If no readers exist, or all
+  // readers are caught up, this function will assert.
+  size_t entry_count = GetSlowestReader().entry_count;
+  PW_DASSERT(entry_count != 0);
+  // Otherwise, pop the readers that have the largest value.
+  for (Reader& reader : readers_) {
+    if (reader.entry_count == entry_count) {
+      reader.PopFront();
+    }
+  }
+}
+
+Reader& PrefixedEntryRingBufferMulti::GetSlowestReader() {
+  // Readers are guaranteed to be before the writer pointer (the class enforces
+  // this on every read/write operation that forces the write pointer ahead of
+  // an existing reader). To determine the slowest reader, we consider three
+  // scenarios:
+  //
+  // In all below cases, WH is the write-head, and R# are readers, with R1
+  // representing the slowest reader.
+  // [[R1 R2 R3 WH]] => Right-hand writer, slowest reader is left-most reader.
+  // [[WH R1 R2 R3]] => Left-hand writer, slowest reader is left-most reader.
+  // [[R3 WH R1 R2]] => Middle-writer, slowest reader is left-most reader after
+  // writer.
+  //
+  // Formally, choose the left-most reader after the writer (ex.2,3), but if
+  // that doesn't exist, choose the left-most reader before the writer (ex.1).
+  PW_DASSERT(readers_.size() > 0);
+  Reader* slowest_reader_after_writer = nullptr;
+  Reader* slowest_reader_before_writer = nullptr;
+  for (Reader& reader : readers_) {
+    if (reader.read_idx < write_idx_) {
+      if (!slowest_reader_before_writer ||
+          reader.read_idx < slowest_reader_before_writer->read_idx) {
+        slowest_reader_before_writer = &reader;
+      }
+    } else {
+      if (!slowest_reader_after_writer ||
+          reader.read_idx < slowest_reader_after_writer->read_idx) {
+        slowest_reader_after_writer = &reader;
+      }
+    }
+  }
+  return *(slowest_reader_after_writer ? slowest_reader_after_writer
+                                       : slowest_reader_before_writer);
+}
+
+Status PrefixedEntryRingBufferMulti::Dering() {
+  if (buffer_ == nullptr || readers_.size() == 0) {
+    return Status::FailedPrecondition();
+  }
+
+  // Check if by luck we're already deringed.
+  Reader* slowest_reader = &GetSlowestReader();
+  if (slowest_reader->read_idx == 0) {
+    return OkStatus();
+  }
+
+  auto buffer_span = std::span(buffer_, buffer_bytes_);
+  std::rotate(buffer_span.begin(),
+              buffer_span.begin() + slowest_reader->read_idx,
+              buffer_span.end());
+
+  // If the new index is past the end of the buffer,
+  // alias it back (wrap) to the start of the buffer.
+  if (write_idx_ < slowest_reader->read_idx) {
+    write_idx_ += buffer_bytes_;
+  }
+  write_idx_ -= slowest_reader->read_idx;
+
+  for (Reader& reader : readers_) {
+    if (&reader == slowest_reader) {
+      continue;
+    }
+    if (reader.read_idx < slowest_reader->read_idx) {
+      reader.read_idx += buffer_bytes_;
+    }
+    reader.read_idx -= slowest_reader->read_idx;
+  }
+
+  slowest_reader->read_idx = 0;
+  return OkStatus();
+}
+
+Status PrefixedEntryRingBufferMulti::InternalPopFront(Reader& reader) {
   if (buffer_ == nullptr) {
     return Status::FailedPrecondition();
   }
-  if (EntryCount() == 0) {
+  if (reader.entry_count == 0) {
     return Status::OutOfRange();
   }
 
   // Advance the read pointer past the front entry to the next one.
-  EntryInfo info = FrontEntryInfo();
+  EntryInfo info = FrontEntryInfo(reader);
   size_t entry_bytes = info.preamble_bytes + info.data_bytes;
-  read_idx_ = IncrementIndex(read_idx_, entry_bytes);
-  entry_count_--;
-  return Status::Ok();
+  size_t prev_read_idx = reader.read_idx;
+  reader.read_idx = IncrementIndex(prev_read_idx, entry_bytes);
+  reader.entry_count--;
+  return OkStatus();
 }
 
-Status PrefixedEntryRingBuffer::Dering() {
-  if (buffer_ == nullptr) {
-    return Status::FailedPrecondition();
-  }
-  // Check if by luck we're already deringed.
-  if (read_idx_ == 0) {
-    return Status::Ok();
-  }
-
-  auto buffer_span = std::span(buffer_, buffer_bytes_);
-  std::rotate(
-      buffer_span.begin(), buffer_span.begin() + read_idx_, buffer_span.end());
-
-  // If the new index is past the end of the buffer,
-  // alias it back (wrap) to the start of the buffer.
-  if (write_idx_ < read_idx_) {
-    write_idx_ += buffer_bytes_;
-  }
-  write_idx_ -= read_idx_;
-  read_idx_ = 0;
-  return Status::Ok();
-}
-
-size_t PrefixedEntryRingBuffer::FrontEntryDataSizeBytes() {
-  if (EntryCount() == 0) {
+size_t PrefixedEntryRingBufferMulti::InternalFrontEntryDataSizeBytes(
+    Reader& reader) {
+  if (reader.entry_count == 0) {
     return 0;
   }
-  return FrontEntryInfo().data_bytes;
+  return FrontEntryInfo(reader).data_bytes;
 }
 
-size_t PrefixedEntryRingBuffer::FrontEntryTotalSizeBytes() {
-  if (EntryCount() == 0) {
+size_t PrefixedEntryRingBufferMulti::InternalFrontEntryTotalSizeBytes(
+    Reader& reader) {
+  if (reader.entry_count == 0) {
     return 0;
   }
-  EntryInfo info = FrontEntryInfo();
+  EntryInfo info = FrontEntryInfo(reader);
   return info.preamble_bytes + info.data_bytes;
 }
 
-PrefixedEntryRingBuffer::EntryInfo PrefixedEntryRingBuffer::FrontEntryInfo() {
+PrefixedEntryRingBufferMulti::EntryInfo
+PrefixedEntryRingBufferMulti::FrontEntryInfo(Reader& reader) {
   // Entry headers consists of: (optional prefix byte, varint size, data...)
 
-  // Read the entry header; extract the varint and it's size in bytes.
-  byte varint_buf[kMaxEntryPreambleBytes];
+  // If a preamble exists, extract the varint and it's bytes in bytes.
+  size_t user_preamble_bytes = 0;
+  uint64_t user_preamble_data = 0;
+  byte varint_buf[varint::kMaxVarint32SizeBytes];
+  if (user_preamble_) {
+    RawRead(varint_buf, reader.read_idx, varint::kMaxVarint32SizeBytes);
+    user_preamble_bytes = varint::Decode(varint_buf, &user_preamble_data);
+    PW_DASSERT(user_preamble_bytes != 0u);
+  }
+
+  // Read the entry header; extract the varint and it's bytes in bytes.
   RawRead(varint_buf,
-          IncrementIndex(read_idx_, user_preamble_ ? 1 : 0),
-          kMaxEntryPreambleBytes);
-  uint64_t entry_size;
-  size_t varint_size = varint::Decode(varint_buf, &entry_size);
+          IncrementIndex(reader.read_idx, user_preamble_bytes),
+          varint::kMaxVarint32SizeBytes);
+  uint64_t entry_bytes;
+  size_t length_bytes = varint::Decode(varint_buf, &entry_bytes);
+  PW_DASSERT(length_bytes != 0u);
 
   EntryInfo info = {};
-  info.preamble_bytes = (user_preamble_ ? 1 : 0) + varint_size;
-  info.data_bytes = entry_size;
+  info.preamble_bytes = user_preamble_bytes + length_bytes;
+  info.user_preamble = static_cast<uint32_t>(user_preamble_data);
+  info.data_bytes = entry_bytes;
   return info;
 }
 
 // Comparisons ordered for more probable early exits, assuming the reader is
 // not far behind the writer compared to the size of the ring.
-size_t PrefixedEntryRingBuffer::RawAvailableBytes() {
+size_t PrefixedEntryRingBufferMulti::RawAvailableBytes() {
+  // Compute slowest reader.
+  // TODO: Alternatively, the slowest reader could be actively mantained on
+  // every read operation, but reads are more likely than writes.
+  if (readers_.size() == 0) {
+    return buffer_bytes_;
+  }
+
+  size_t read_idx = GetSlowestReader().read_idx;
   // Case: Not wrapped.
-  if (read_idx_ < write_idx_) {
-    return buffer_bytes_ - (write_idx_ - read_idx_);
+  if (read_idx < write_idx_) {
+    return buffer_bytes_ - (write_idx_ - read_idx);
   }
   // Case: Wrapped
-  if (read_idx_ > write_idx_) {
-    return read_idx_ - write_idx_;
+  if (read_idx > write_idx_) {
+    return read_idx - write_idx_;
   }
   // Case: Matched read and write heads; empty or full.
-  return entry_count_ ? 0 : buffer_bytes_;
+  for (Reader& reader : readers_) {
+    if (reader.read_idx == read_idx && reader.entry_count != 0) {
+      return 0;
+    }
+  }
+  return buffer_bytes_;
 }
 
-void PrefixedEntryRingBuffer::RawWrite(std::span<const std::byte> source) {
+void PrefixedEntryRingBufferMulti::RawWrite(std::span<const std::byte> source) {
   // Write until the end of the source or the backing buffer.
   size_t bytes_until_wrap = buffer_bytes_ - write_idx_;
   size_t bytes_to_copy = std::min(source.size(), bytes_until_wrap);
@@ -248,9 +393,9 @@
   write_idx_ = IncrementIndex(write_idx_, source.size());
 }
 
-void PrefixedEntryRingBuffer::RawRead(byte* destination,
-                                      size_t source_idx,
-                                      size_t length_bytes) {
+void PrefixedEntryRingBufferMulti::RawRead(byte* destination,
+                                           size_t source_idx,
+                                           size_t length_bytes) {
   // Read the pre-wrap bytes.
   size_t bytes_until_wrap = buffer_bytes_ - source_idx;
   size_t bytes_to_copy = std::min(length_bytes, bytes_until_wrap);
@@ -262,7 +407,8 @@
   }
 }
 
-size_t PrefixedEntryRingBuffer::IncrementIndex(size_t index, size_t count) {
+size_t PrefixedEntryRingBufferMulti::IncrementIndex(size_t index,
+                                                    size_t count) {
   // Note: This doesn't use modulus (%) since the branch is cheaper, and we
   // guarantee that count will never be greater than buffer_bytes_.
   index += count;
@@ -272,5 +418,14 @@
   return index;
 }
 
+Status PrefixedEntryRingBufferMulti::Reader::PeekFrontWithPreamble(
+    std::span<byte> data,
+    uint32_t& user_preamble_out,
+    size_t& entry_bytes_read_out) {
+  entry_bytes_read_out = 0;
+  return buffer->InternalRead(
+      *this, GetOutput(data, &entry_bytes_read_out), false, &user_preamble_out);
+}
+
 }  // namespace ring_buffer
 }  // namespace pw
diff --git a/pw_ring_buffer/prefixed_entry_ring_buffer_test.cc b/pw_ring_buffer/prefixed_entry_ring_buffer_test.cc
index 6469f42..bba5bb3 100644
--- a/pw_ring_buffer/prefixed_entry_ring_buffer_test.cc
+++ b/pw_ring_buffer/prefixed_entry_ring_buffer_test.cc
@@ -82,8 +82,9 @@
   // Set read_size to an unexpected value to make sure result checks don't luck
   // out and happen to see a previous value.
   size_t read_size = 500U;
+  uint32_t user_preamble = 0U;
 
-  EXPECT_EQ(ring.SetBuffer(test_buffer), Status::Ok());
+  EXPECT_EQ(ring.SetBuffer(test_buffer), OkStatus());
 
   EXPECT_EQ(ring.EntryCount(), 0u);
   EXPECT_EQ(ring.PopFront(), Status::OutOfRange());
@@ -114,13 +115,18 @@
     ASSERT_EQ(ring.FrontEntryDataSizeBytes(), 0u);
     ASSERT_EQ(ring.FrontEntryTotalSizeBytes(), 0u);
 
-    ASSERT_EQ(ring.PushBack(std::span(single_entry_data, data_size), byte(i)),
-              Status::Ok());
+    // Limit the value of the preamble to a single byte, to ensure that we
+    // retain a static `single_entry_buffer_size` during the test. Single
+    // bytes are varint-encoded to the same value.
+    uint32_t preamble_byte = i % 128;
+    ASSERT_EQ(
+        ring.PushBack(std::span(single_entry_data, data_size), preamble_byte),
+        OkStatus());
     ASSERT_EQ(ring.FrontEntryDataSizeBytes(), data_size);
     ASSERT_EQ(ring.FrontEntryTotalSizeBytes(), single_entry_total_size);
 
     read_size = 500U;
-    ASSERT_EQ(ring.PeekFront(read_buffer, &read_size), Status::Ok());
+    ASSERT_EQ(ring.PeekFront(read_buffer, &read_size), OkStatus());
     ASSERT_EQ(read_size, data_size);
 
     // ASSERT_THAT(std::span(expect_buffer).last(data_size),
@@ -131,18 +137,31 @@
               0);
 
     read_size = 500U;
-    ASSERT_EQ(ring.PeekFrontWithPreamble(read_buffer, &read_size),
-              Status::Ok());
+    ASSERT_EQ(ring.PeekFrontWithPreamble(read_buffer, &read_size), OkStatus());
     ASSERT_EQ(read_size, single_entry_total_size);
-    ASSERT_EQ(ring.PopFront(), Status::Ok());
 
     if (user_data) {
-      expect_buffer[0] = byte(i);
+      expect_buffer[0] = byte(preamble_byte);
     }
 
     // ASSERT_THAT(std::span(expect_buffer),
     //            testing::ElementsAreArray(std::span(read_buffer)));
     ASSERT_EQ(memcmp(expect_buffer, read_buffer, single_entry_total_size), 0);
+
+    if (user_data) {
+      user_preamble = 0U;
+      ASSERT_EQ(
+          ring.PeekFrontWithPreamble(read_buffer, user_preamble, read_size),
+          OkStatus());
+      ASSERT_EQ(read_size, data_size);
+      ASSERT_EQ(user_preamble, preamble_byte);
+      ASSERT_EQ(memcmp(std::span(expect_buffer).last(data_size).data(),
+                       read_buffer,
+                       data_size),
+                0);
+    }
+
+    ASSERT_EQ(ring.PopFront(), OkStatus());
   }
 }
 
@@ -163,26 +182,26 @@
 // Write data that is filled with a byte value that increments each write. Write
 // many times without read/pop and then check to make sure correct contents are
 // in the ring buffer.
-template <bool user_data>
+template <bool kUserData>
 void CountingUpWriteReadTest() {
-  PrefixedEntryRingBuffer ring(user_data);
+  PrefixedEntryRingBuffer ring(kUserData);
   byte test_buffer[single_entry_test_buffer_size];
 
-  EXPECT_EQ(ring.SetBuffer(test_buffer), Status::Ok());
+  EXPECT_EQ(ring.SetBuffer(test_buffer), OkStatus());
   EXPECT_EQ(ring.EntryCount(), 0u);
 
-  constexpr size_t data_size = sizeof(single_entry_data) - (user_data ? 1 : 0);
+  constexpr size_t kDataSize = sizeof(single_entry_data) - (kUserData ? 1 : 0);
 
   for (size_t i = 0; i < kOuterCycles; i++) {
     size_t seed = i;
 
-    byte write_buffer[data_size];
+    byte write_buffer[kDataSize];
 
     size_t j;
     for (j = 0; j < kSingleEntryCycles; j++) {
       memset(write_buffer, j + seed, sizeof(write_buffer));
 
-      ASSERT_EQ(ring.PushBack(write_buffer), Status::Ok());
+      ASSERT_EQ(ring.PushBack(write_buffer), OkStatus());
 
       size_t expected_count = (j < kCountingUpMaxExpectedEntries)
                                   ? j + 1
@@ -196,11 +215,11 @@
       byte read_buffer[sizeof(write_buffer)];
       size_t read_size;
       memset(write_buffer, fill_val + j, sizeof(write_buffer));
-      ASSERT_EQ(ring.PeekFront(read_buffer, &read_size), Status::Ok());
+      ASSERT_EQ(ring.PeekFront(read_buffer, &read_size), OkStatus());
 
-      ASSERT_EQ(memcmp(write_buffer, read_buffer, data_size), 0);
+      ASSERT_EQ(memcmp(write_buffer, read_buffer, kDataSize), 0);
 
-      ASSERT_EQ(ring.PopFront(), Status::Ok());
+      ASSERT_EQ(ring.PopFront(), OkStatus());
     }
   }
 }
@@ -222,13 +241,13 @@
   PrefixedEntryRingBuffer ring(user_data);
   byte test_buffer[single_entry_test_buffer_size];
 
-  EXPECT_EQ(ring.SetBuffer(test_buffer), Status::Ok());
+  EXPECT_EQ(ring.SetBuffer(test_buffer), OkStatus());
 
   auto output = [](std::span<const byte> src) -> Status {
     for (byte b : src) {
       read_buffer.push_back(b);
     }
-    return Status::Ok();
+    return OkStatus();
   };
 
   size_t user_preamble_bytes = (user_data ? 1 : 0);
@@ -243,13 +262,18 @@
     ASSERT_EQ(ring.FrontEntryDataSizeBytes(), 0u);
     ASSERT_EQ(ring.FrontEntryTotalSizeBytes(), 0u);
 
-    ASSERT_EQ(ring.PushBack(std::span(single_entry_data, data_size), byte(i)),
-              Status::Ok());
+    // Limit the value of the preamble to a single byte, to ensure that we
+    // retain a static `single_entry_buffer_size` during the test. Single
+    // bytes are varint-encoded to the same value.
+    uint32_t preamble_byte = i % 128;
+    ASSERT_EQ(
+        ring.PushBack(std::span(single_entry_data, data_size), preamble_byte),
+        OkStatus());
     ASSERT_EQ(ring.FrontEntryDataSizeBytes(), data_size);
     ASSERT_EQ(ring.FrontEntryTotalSizeBytes(), single_entry_total_size);
 
     read_buffer.clear();
-    ASSERT_EQ(ring.PeekFront(output), Status::Ok());
+    ASSERT_EQ(ring.PeekFront(output), OkStatus());
     ASSERT_EQ(read_buffer.size(), data_size);
 
     ASSERT_EQ(memcmp(std::span(expect_buffer).last(data_size).data(),
@@ -258,12 +282,12 @@
               0);
 
     read_buffer.clear();
-    ASSERT_EQ(ring.PeekFrontWithPreamble(output), Status::Ok());
+    ASSERT_EQ(ring.PeekFrontWithPreamble(output), OkStatus());
     ASSERT_EQ(read_buffer.size(), single_entry_total_size);
-    ASSERT_EQ(ring.PopFront(), Status::Ok());
+    ASSERT_EQ(ring.PopFront(), OkStatus());
 
     if (user_data) {
-      expect_buffer[0] = byte(i);
+      expect_buffer[0] = byte(preamble_byte);
     }
 
     ASSERT_EQ(
@@ -293,7 +317,7 @@
   PrefixedEntryRingBuffer ring;
 
   byte test_buffer[kTestBufferSize];
-  EXPECT_EQ(ring.SetBuffer(test_buffer), Status::Ok());
+  EXPECT_EQ(ring.SetBuffer(test_buffer), OkStatus());
 
   // Entry data is entry size - preamble (single byte in this case).
   byte single_entry_buffer[kEntrySizeBytes - 1u];
@@ -336,7 +360,7 @@
     EXPECT_EQ(ring.EntryCount(), kTotalEntryCount);
     EXPECT_EQ(expected_result.size(), ring.TotalUsedBytes());
 
-    ASSERT_EQ(ring.Dering(), Status::Ok());
+    ASSERT_EQ(ring.Dering(), OkStatus());
 
     // Check values after doing the dering.
     EXPECT_EQ(ring.EntryCount(), kTotalEntryCount);
@@ -348,11 +372,11 @@
       for (byte b : src) {
         actual_result.push_back(b);
       }
-      return Status::Ok();
+      return OkStatus();
     };
     while (ring.EntryCount()) {
-      ASSERT_EQ(ring.PeekFrontWithPreamble(output), Status::Ok());
-      ASSERT_EQ(ring.PopFront(), Status::Ok());
+      ASSERT_EQ(ring.PeekFrontWithPreamble(output), OkStatus());
+      ASSERT_EQ(ring.PopFront(), OkStatus());
     }
 
     // Ensure the actual result out of the ring buffer matches our manually
@@ -371,7 +395,7 @@
 TEST(PrefixedEntryRingBuffer, DeringNoPreload) { DeringTest(false); }
 
 template <typename T>
-Status PushBack(PrefixedEntryRingBuffer& ring, T element) {
+Status PushBack(PrefixedEntryRingBufferMulti& ring, T element) {
   union {
     std::array<byte, sizeof(element)> buffer;
     T item;
@@ -381,7 +405,7 @@
 }
 
 template <typename T>
-Status TryPushBack(PrefixedEntryRingBuffer& ring, T element) {
+Status TryPushBack(PrefixedEntryRingBufferMulti& ring, T element) {
   union {
     std::array<byte, sizeof(element)> buffer;
     T item;
@@ -391,13 +415,13 @@
 }
 
 template <typename T>
-T PeekFront(PrefixedEntryRingBuffer& ring) {
+T PeekFront(PrefixedEntryRingBufferMulti::Reader& reader) {
   union {
     std::array<byte, sizeof(T)> buffer;
     T item;
   } aliased;
   size_t bytes_read = 0;
-  PW_CHECK_OK(ring.PeekFront(aliased.buffer, &bytes_read));
+  PW_CHECK_OK(reader.PeekFront(aliased.buffer, &bytes_read));
   PW_CHECK_INT_EQ(bytes_read, sizeof(T));
   return aliased.item;
 }
@@ -405,7 +429,7 @@
 TEST(PrefixedEntryRingBuffer, TryPushBack) {
   PrefixedEntryRingBuffer ring;
   byte test_buffer[kTestBufferSize];
-  EXPECT_EQ(ring.SetBuffer(test_buffer), Status::Ok());
+  EXPECT_EQ(ring.SetBuffer(test_buffer), OkStatus());
 
   // Fill up the ring buffer with a constant.
   int total_items = 0;
@@ -428,11 +452,165 @@
 
   // Fill up the ring buffer with a constant.
   for (int i = 0; i < total_items; ++i) {
-    EXPECT_EQ(PushBack<int>(ring, 100), Status::Ok());
+    EXPECT_EQ(PushBack<int>(ring, 100), OkStatus());
   }
   EXPECT_EQ(PeekFront<int>(ring), 100);
 }
 
+TEST(PrefixedEntryRingBufferMulti, TryPushBack) {
+  PrefixedEntryRingBufferMulti ring;
+  byte test_buffer[kTestBufferSize];
+  EXPECT_EQ(ring.SetBuffer(test_buffer), OkStatus());
+
+  PrefixedEntryRingBufferMulti::Reader fast_reader;
+  PrefixedEntryRingBufferMulti::Reader slow_reader;
+
+  EXPECT_EQ(ring.AttachReader(fast_reader), OkStatus());
+  EXPECT_EQ(ring.AttachReader(slow_reader), OkStatus());
+
+  // Fill up the ring buffer with an increasing count.
+  int total_items = 0;
+  while (true) {
+    Status status = TryPushBack<int>(ring, total_items);
+    if (status.ok()) {
+      total_items++;
+    } else {
+      EXPECT_EQ(status, Status::ResourceExhausted());
+      break;
+    }
+  }
+
+  // Run fast reader twice as fast as the slow reader.
+  for (int i = 0; i < total_items; ++i) {
+    if (i % 2 == 0) {
+      EXPECT_EQ(PeekFront<int>(slow_reader), i / 2);
+      EXPECT_EQ(slow_reader.PopFront(), OkStatus());
+    }
+    EXPECT_EQ(PeekFront<int>(fast_reader), i);
+    EXPECT_EQ(fast_reader.PopFront(), OkStatus());
+  }
+  EXPECT_EQ(fast_reader.PopFront(), Status::OutOfRange());
+
+  // Fill the buffer again, expect that the fast reader
+  // only sees half the entries as the slow reader.
+  size_t max_items = total_items;
+  while (true) {
+    Status status = TryPushBack<int>(ring, total_items);
+    if (status.ok()) {
+      total_items++;
+    } else {
+      EXPECT_EQ(status, Status::ResourceExhausted());
+      break;
+    }
+  }
+  EXPECT_EQ(slow_reader.EntryCount(), max_items);
+  EXPECT_EQ(fast_reader.EntryCount(), total_items - max_items);
+
+  for (int i = total_items - max_items; i < total_items; ++i) {
+    EXPECT_EQ(PeekFront<int>(slow_reader), i);
+    EXPECT_EQ(slow_reader.PopFront(), OkStatus());
+    if (static_cast<size_t>(i) >= max_items) {
+      EXPECT_EQ(PeekFront<int>(fast_reader), i);
+      EXPECT_EQ(fast_reader.PopFront(), OkStatus());
+    }
+  }
+  EXPECT_EQ(slow_reader.PopFront(), Status::OutOfRange());
+  EXPECT_EQ(fast_reader.PopFront(), Status::OutOfRange());
+}
+
+TEST(PrefixedEntryRingBufferMulti, PushBack) {
+  PrefixedEntryRingBufferMulti ring;
+  byte test_buffer[kTestBufferSize];
+  EXPECT_EQ(ring.SetBuffer(test_buffer), OkStatus());
+
+  PrefixedEntryRingBufferMulti::Reader fast_reader;
+  PrefixedEntryRingBufferMulti::Reader slow_reader;
+
+  EXPECT_EQ(ring.AttachReader(fast_reader), OkStatus());
+  EXPECT_EQ(ring.AttachReader(slow_reader), OkStatus());
+
+  // Fill up the ring buffer with an increasing count.
+  size_t total_items = 0;
+  while (true) {
+    Status status = TryPushBack<uint32_t>(ring, total_items);
+    if (status.ok()) {
+      total_items++;
+    } else {
+      EXPECT_EQ(status, Status::ResourceExhausted());
+      break;
+    }
+  }
+  EXPECT_EQ(slow_reader.EntryCount(), total_items);
+
+  // The following test:
+  //  - Moves the fast reader forward by one entry.
+  //  - Writes a single entry that is guaranteed to be larger than the size of a
+  //    single entry in the buffer (uint64_t entry > uint32_t entry).
+  //  - Checks to see that both readers were moved forward.
+  EXPECT_EQ(fast_reader.PopFront(), OkStatus());
+  EXPECT_EQ(PushBack<uint64_t>(ring, 5u), OkStatus());
+  // The readers have moved past values 0 and 1.
+  EXPECT_EQ(PeekFront<uint32_t>(slow_reader), 2u);
+  EXPECT_EQ(PeekFront<uint32_t>(fast_reader), 2u);
+  // The readers have lost two entries, but gained an entry.
+  EXPECT_EQ(slow_reader.EntryCount(), total_items - 1);
+  EXPECT_EQ(fast_reader.EntryCount(), total_items - 1);
+}
+
+TEST(PrefixedEntryRingBufferMulti, ReaderAddRemove) {
+  PrefixedEntryRingBufferMulti ring;
+  byte test_buffer[kTestBufferSize];
+  EXPECT_EQ(ring.SetBuffer(test_buffer), OkStatus());
+
+  PrefixedEntryRingBufferMulti::Reader reader;
+  PrefixedEntryRingBufferMulti::Reader transient_reader;
+
+  EXPECT_EQ(ring.AttachReader(reader), OkStatus());
+
+  // Fill up the ring buffer with a constant value.
+  int total_items = 0;
+  while (true) {
+    Status status = TryPushBack<int>(ring, 5);
+    if (status.ok()) {
+      total_items++;
+    } else {
+      EXPECT_EQ(status, Status::ResourceExhausted());
+      break;
+    }
+  }
+  EXPECT_EQ(reader.EntryCount(), static_cast<size_t>(total_items));
+
+  // Add new reader after filling the buffer.
+  EXPECT_EQ(ring.AttachReader(transient_reader), OkStatus());
+  EXPECT_EQ(transient_reader.EntryCount(), 0u);
+
+  // Push a value into the buffer and confirm the transient reader
+  // sees that value, and only that value.
+  EXPECT_EQ(PushBack<int>(ring, 1), OkStatus());
+  EXPECT_EQ(PeekFront<int>(transient_reader), 1);
+  EXPECT_EQ(transient_reader.EntryCount(), 1u);
+
+  // Confirm that detaching and attaching a reader resets its state.
+  EXPECT_EQ(ring.DetachReader(transient_reader), OkStatus());
+  EXPECT_EQ(ring.AttachReader(transient_reader), OkStatus());
+  EXPECT_EQ(transient_reader.EntryCount(), 0u);
+}
+
+TEST(PrefixedEntryRingBufferMulti, SingleBufferPerReader) {
+  PrefixedEntryRingBufferMulti ring_one;
+  PrefixedEntryRingBufferMulti ring_two;
+  byte test_buffer[kTestBufferSize];
+  EXPECT_EQ(ring_one.SetBuffer(test_buffer), OkStatus());
+
+  PrefixedEntryRingBufferMulti::Reader reader;
+  EXPECT_EQ(ring_one.AttachReader(reader), OkStatus());
+  EXPECT_EQ(ring_two.AttachReader(reader), Status::InvalidArgument());
+
+  EXPECT_EQ(ring_one.DetachReader(reader), OkStatus());
+  EXPECT_EQ(ring_two.AttachReader(reader), OkStatus());
+  EXPECT_EQ(ring_one.AttachReader(reader), Status::InvalidArgument());
+}
+
 }  // namespace
 }  // namespace ring_buffer
 }  // namespace pw
diff --git a/pw_ring_buffer/public/pw_ring_buffer/prefixed_entry_ring_buffer.h b/pw_ring_buffer/public/pw_ring_buffer/prefixed_entry_ring_buffer.h
index e164834..b7b4706 100644
--- a/pw_ring_buffer/public/pw_ring_buffer/prefixed_entry_ring_buffer.h
+++ b/pw_ring_buffer/public/pw_ring_buffer/prefixed_entry_ring_buffer.h
@@ -16,6 +16,7 @@
 #include <cstddef>
 #include <span>
 
+#include "pw_containers/intrusive_list.h"
 #include "pw_status/status.h"
 
 namespace pw {
@@ -25,21 +26,122 @@
 // produces a buffer entry. Each entry consists of a preamble followed by an
 // arbitrary length data chunk. The preamble is comprised of an optional user
 // preamble byte and an always present varint. The varint encodes the number of
-// bytes in the data chunk.
+// bytes in the data chunk. This is a FIFO queue, with the oldest entries at
+// the 'front' (to be processed by readers) and the newest entries at the 'back'
+// (where the writer pushes to).
 //
-// The ring buffer holds the most recent entries stored in the buffer. Once
-// filled to capacity, incoming entries bump out the oldest entries to make
-// room. Entries are internally wrapped around as needed.
-class PrefixedEntryRingBuffer {
+// The ring buffer supports multiple readers, which can be attached/detached
+// from the buffer. Each reader has its own read pointer and can peek and pop
+// the entry at the head. Entries are not bumped out from the buffer until all
+// readers have moved past that entry, or if the buffer is at capacity and space
+// is needed to push a new entry. When making space, the buffer will push slow
+// readers forward to the new oldest entry. Entries are internally wrapped
+// around as needed.
+class PrefixedEntryRingBufferMulti {
  public:
   typedef Status (*ReadOutput)(std::span<const std::byte>);
 
-  PrefixedEntryRingBuffer(bool user_preamble = false)
+  // A reader that provides a single-reader interface into the multi-reader ring
+  // buffer it has been attached to via AttachReader(). Readers maintain their
+  // read position in the ring buffer as well as the remaining count of entries
+  // from that position. Readers are only able to consume entries that were
+  // pushed after the attach operation.
+  //
+  // Readers can peek and pop entries similar to the single-reader interface.
+  // When popping entries, although the reader moves forward and drops the
+  // entry, the entry is not removed from the ring buffer until all other
+  // attached readers have moved past that entry.
+  //
+  // When the attached ring buffer needs to make space, it may push the reader
+  // index forward. Users of this class should consider the possibility of data
+  // loss if they read slower than the writer.
+  class Reader : public IntrusiveList<Reader>::Item {
+   public:
+    constexpr Reader() : buffer(nullptr), read_idx(0), entry_count(0) {}
+
+    // TODO(pwbug/344): Add locking to the internal functions. Who owns the
+    // lock? This class? Does this class need a lock if it's not a multi-reader?
+    // (One doesn't exist today but presumably nothing prevents push + pop
+    // operations from happening on two different threads).
+
+    // Read the oldest stored data chunk of data from the ring buffer to
+    // the provided destination std::span. The number of bytes read is written
+    // to bytes_read
+    //
+    // Return values:
+    // OK - Data successfully read from the ring buffer.
+    // FAILED_PRECONDITION - Buffer not initialized.
+    // OUT_OF_RANGE - No entries in ring buffer to read.
+    // RESOURCE_EXHAUSTED - Destination data std::span was smaller number of
+    // bytes than the data size of the data chunk being read.  Available
+    // destination bytes were filled, remaining bytes of the data chunk were
+    // ignored.
+    Status PeekFront(std::span<std::byte> data, size_t* bytes_read_out) {
+      return buffer->InternalPeekFront(*this, data, bytes_read_out);
+    }
+
+    Status PeekFront(ReadOutput output) {
+      return buffer->InternalPeekFront(*this, output);
+    }
+
+    // Same as PeekFront but includes the entry's preamble of optional user
+    // value and the varint of the data size.
+    // TODO(pwbug/341): Move all other APIs to passing bytes_read by reference,
+    // as it is required to determine the length populated in the span.
+    Status PeekFrontWithPreamble(std::span<std::byte> data,
+                                 uint32_t& user_preamble_out,
+                                 size_t& entry_bytes_read_out);
+
+    Status PeekFrontWithPreamble(std::span<std::byte> data,
+                                 size_t* bytes_read_out) {
+      return buffer->InternalPeekFrontWithPreamble(*this, data, bytes_read_out);
+    }
+
+    Status PeekFrontWithPreamble(ReadOutput output) {
+      return buffer->InternalPeekFrontWithPreamble(*this, output);
+    }
+
+    // Pop and discard the oldest stored data chunk of data from the ring
+    // buffer.
+    //
+    // Return values:
+    // OK - Data successfully read from the ring buffer.
+    // FAILED_PRECONDITION - Buffer not initialized.
+    // OUT_OF_RANGE - No entries in ring buffer to pop.
+    Status PopFront() { return buffer->InternalPopFront(*this); }
+
+    // Get the size in bytes of the next chunk, not including preamble, to be
+    // read.
+    size_t FrontEntryDataSizeBytes() {
+      return buffer->InternalFrontEntryDataSizeBytes(*this);
+    }
+
+    // Get the size in bytes of the next chunk, including preamble and data
+    // chunk, to be read.
+    size_t FrontEntryTotalSizeBytes() {
+      return buffer->InternalFrontEntryTotalSizeBytes(*this);
+    }
+
+    // Get the number of variable-length entries currently in the ring buffer.
+    //
+    // Return value:
+    // Entry count.
+    size_t EntryCount() { return entry_count; }
+
+   protected:
+    friend PrefixedEntryRingBufferMulti;
+
+    PrefixedEntryRingBufferMulti* buffer;
+    size_t read_idx;
+    size_t entry_count;
+  };
+
+  // TODO(pwbug/340): Consider changing bool to an enum, to explicitly enumerate
+  // what this variable means in clients.
+  PrefixedEntryRingBufferMulti(bool user_preamble = false)
       : buffer_(nullptr),
         buffer_bytes_(0),
         write_idx_(0),
-        read_idx_(0),
-        entry_count_(0),
         user_preamble_(user_preamble) {}
 
   // Set the raw buffer to be used by the ring buffer.
@@ -49,6 +151,23 @@
   // INVALID_ARGUMENT - Argument was nullptr, size zero, or too large.
   Status SetBuffer(std::span<std::byte> buffer);
 
+  // Attach reader to the ring buffer. Readers can only be attached to one
+  // ring buffer at a time.
+  //
+  // Return values:
+  // OK - Successfully configured reader for ring buffer.
+  // INVALID_ARGUMENT - Argument was already attached to another ring buffer.
+  Status AttachReader(Reader& reader);
+
+  // Detach reader from the ring buffer. Readers can only be detached if they
+  // were previously attached.
+  //
+  // Return values:
+  // OK - Successfully removed reader for ring buffer.
+  // INVALID_ARGUMENT - Argument was not previously attached to this ring
+  // buffer.
+  Status DetachReader(Reader& reader);
+
   // Removes all data from the ring buffer.
   void Clear();
 
@@ -58,7 +177,7 @@
   //
   // Preamble argument is a caller-provided value prepended to the front of the
   // entry. It is only used if user_preamble was set at class construction
-  // time.
+  // time. It is varint-encoded before insertion into the buffer.
   //
   // Return values:
   // OK - Data successfully written to the ring buffer.
@@ -66,15 +185,22 @@
   // FAILED_PRECONDITION - Buffer not initialized.
   // OUT_OF_RANGE - Size of data is greater than buffer size.
   Status PushBack(std::span<const std::byte> data,
-                  std::byte user_preamble_data = std::byte(0)) {
+                  uint32_t user_preamble_data = 0) {
     return InternalPushBack(data, user_preamble_data, true);
   }
 
+  // [Deprecated] An implementation of PushBack that accepts a single-byte as
+  // preamble data. Clients should migrate to passing uint32_t preamble data.
+  Status PushBack(std::span<const std::byte> data,
+                  std::byte user_preamble_data) {
+    return PushBack(data, static_cast<uint32_t>(user_preamble_data));
+  }
+
   // Write a chunk of data to the ring buffer if there is space available.
   //
   // Preamble argument is a caller-provided value prepended to the front of the
   // entry. It is only used if user_preamble was set at class construction
-  // time.
+  // time. It is varint-encoded before insertion into the buffer.
   //
   // Return values:
   // OK - Data successfully written to the ring buffer.
@@ -84,13 +210,34 @@
   // RESOURCE_EXHAUSTED - The ring buffer doesn't have space for the data
   // without popping off existing elements.
   Status TryPushBack(std::span<const std::byte> data,
-                     std::byte user_preamble_data = std::byte(0)) {
+                     uint32_t user_preamble_data = 0) {
     return InternalPushBack(data, user_preamble_data, false);
   }
 
+  // [Deprecated] An implementation of TryPushBack that accepts a single-byte as
+  // preamble data. Clients should migrate to passing uint32_t preamble data.
+  Status TryPushBack(std::span<const std::byte> data,
+                     std::byte user_preamble_data) {
+    return TryPushBack(data, static_cast<uint32_t>(user_preamble_data));
+  }
+
+  // Get the size in bytes of all the current entries in the ring buffer,
+  // including preamble and data chunk.
+  size_t TotalUsedBytes() { return buffer_bytes_ - RawAvailableBytes(); }
+
+  // Dering the buffer by reordering entries internally in the buffer by
+  // rotating to have the oldest entry is at the lowest address/index with
+  // newest entry at the highest address.
+  //
+  // Return values:
+  // OK - Buffer data successfully deringed.
+  // FAILED_PRECONDITION - Buffer not initialized, or no readers attached.
+  Status Dering();
+
+ protected:
   // Read the oldest stored data chunk of data from the ring buffer to
   // the provided destination std::span. The number of bytes read is written to
-  // bytes_read
+  // `bytes_read_out`.
   //
   // Return values:
   // OK - Data successfully read from the ring buffer.
@@ -99,15 +246,17 @@
   // RESOURCE_EXHAUSTED - Destination data std::span was smaller number of bytes
   // than the data size of the data chunk being read.  Available destination
   // bytes were filled, remaining bytes of the data chunk were ignored.
-  Status PeekFront(std::span<std::byte> data, size_t* bytes_read);
-
-  Status PeekFront(ReadOutput output);
+  Status InternalPeekFront(Reader& reader,
+                           std::span<std::byte> data,
+                           size_t* bytes_read_out);
+  Status InternalPeekFront(Reader& reader, ReadOutput output);
 
   // Same as Read but includes the entry's preamble of optional user value and
   // the varint of the data size
-  Status PeekFrontWithPreamble(std::span<std::byte> data, size_t* bytes_read);
-
-  Status PeekFrontWithPreamble(ReadOutput output);
+  Status InternalPeekFrontWithPreamble(Reader& reader,
+                                       std::span<std::byte> data,
+                                       size_t* bytes_read_out);
+  Status InternalPeekFrontWithPreamble(Reader& reader, ReadOutput output);
 
   // Pop and discard the oldest stored data chunk of data from the ring buffer.
   //
@@ -115,55 +264,52 @@
   // OK - Data successfully read from the ring buffer.
   // FAILED_PRECONDITION - Buffer not initialized.
   // OUT_OF_RANGE - No entries in ring buffer to pop.
-  Status PopFront();
-
-  // Dering the buffer by reordering entries internally in the buffer by
-  // rotating to have the oldest entry is at the lowest address/index with
-  // newest entry at the highest address.
-  //
-  // Return values:
-  // OK - Buffer data successfully deringed.
-  // FAILED_PRECONDITION - Buffer not initialized.
-  Status Dering();
-
-  // Get the number of variable-length entries currently in the ring buffer.
-  //
-  // Return value:
-  // Entry count.
-  size_t EntryCount() { return entry_count_; }
-
-  // Get the size in bytes of all the current entries in the ring buffer,
-  // including preamble and data chunk.
-  size_t TotalUsedBytes() { return buffer_bytes_ - RawAvailableBytes(); }
+  Status InternalPopFront(Reader& reader);
 
   // Get the size in bytes of the next chunk, not including preamble, to be
   // read.
-  size_t FrontEntryDataSizeBytes();
+  size_t InternalFrontEntryDataSizeBytes(Reader& reader);
 
   // Get the size in bytes of the next chunk, including preamble and data
   // chunk, to be read.
-  size_t FrontEntryTotalSizeBytes();
-
- private:
-  struct EntryInfo {
-    size_t preamble_bytes;
-    size_t data_bytes;
-  };
+  size_t InternalFrontEntryTotalSizeBytes(Reader& reader);
 
   // Internal version of Read used by all the public interface versions. T
   // should be of type ReadOutput.
   template <typename T>
-  Status InternalRead(T read_output, bool get_preamble);
+  Status InternalRead(Reader& reader,
+                      T read_output,
+                      bool include_preamble_in_output,
+                      uint32_t* user_preamble_out = nullptr);
+
+ private:
+  struct EntryInfo {
+    size_t preamble_bytes;
+    uint32_t user_preamble;
+    size_t data_bytes;
+  };
 
   // Push back implementation, which optionally discards front elements to fit
   // the incoming element.
   Status InternalPushBack(std::span<const std::byte> data,
-                          std::byte user_preamble_data,
+                          uint32_t user_preamble_data,
                           bool pop_front_if_needed);
 
+  // Internal function to pop all of the slowest readers. This function may pop
+  // multiple readers if multiple are slow.
+  //
+  // Precondition: This function requires that at least one reader is attached
+  // and has at least one entry to pop.
+  void InternalPopFrontAll();
+
+  // Returns the slowest reader in the list.
+  //
+  // Precondition: This function requires that at least one reader is attached.
+  Reader& GetSlowestReader();
+
   // Get info struct with the size of the preamble and data chunk for the next
   // entry to be read.
-  EntryInfo FrontEntryInfo();
+  EntryInfo FrontEntryInfo(Reader& reader);
 
   // Get the raw number of available bytes free in the ring buffer. This is
   // not available bytes for data, since there is a variable size preamble for
@@ -186,13 +332,10 @@
   size_t buffer_bytes_;
 
   size_t write_idx_;
-  size_t read_idx_;
-  size_t entry_count_;
   const bool user_preamble_;
 
-  // Worst case size for the variable-sized preable that is prepended to
-  // each entry.
-  static constexpr size_t kMaxEntryPreambleBytes = sizeof(size_t) + 1;
+  // List of attached readers.
+  IntrusiveList<Reader> readers_;
 
   // Maximum bufer size allowed. Restricted to this to allow index aliasing to
   // not overflow.
@@ -200,5 +343,14 @@
       std::numeric_limits<size_t>::max() / 2;
 };
 
+class PrefixedEntryRingBuffer : public PrefixedEntryRingBufferMulti,
+                                public PrefixedEntryRingBufferMulti::Reader {
+ public:
+  PrefixedEntryRingBuffer(bool user_preamble = false)
+      : PrefixedEntryRingBufferMulti(user_preamble) {
+    AttachReader(*this);
+  }
+};
+
 }  // namespace ring_buffer
 }  // namespace pw
diff --git a/pw_ring_buffer/size_report/BUILD b/pw_ring_buffer/size_report/BUILD
new file mode 100644
index 0000000..8339bc5
--- /dev/null
+++ b/pw_ring_buffer/size_report/BUILD
@@ -0,0 +1,32 @@
+# Copyright 2021 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.
+
+load(
+    "//pw_build:pigweed.bzl",
+    "pw_cc_binary",
+)
+
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])  # Apache License 2.0
+
+pw_cc_binary(
+    name = "ring_buffer_simple",
+    srcs = ["ring_buffer_simple.cc"],
+)
+
+pw_cc_binary(
+    name = "ring_buffer_multi",
+    srcs = ["ring_buffer_multi.cc"],
+)
diff --git a/pw_ring_buffer/size_report/BUILD.gn b/pw_ring_buffer/size_report/BUILD.gn
new file mode 100644
index 0000000..0eb0118
--- /dev/null
+++ b/pw_ring_buffer/size_report/BUILD.gn
@@ -0,0 +1,33 @@
+# 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/target_types.gni")
+
+pw_executable("ring_buffer_simple") {
+  sources = [ "ring_buffer_simple.cc" ]
+  deps = [
+    "$dir_pw_bloat:bloat_this_binary",
+    "..",
+  ]
+}
+
+pw_executable("ring_buffer_multi") {
+  sources = [ "ring_buffer_multi.cc" ]
+  deps = [
+    "$dir_pw_bloat:bloat_this_binary",
+    "..",
+  ]
+}
diff --git a/pw_ring_buffer/size_report/ring_buffer_multi.cc b/pw_ring_buffer/size_report/ring_buffer_multi.cc
new file mode 100644
index 0000000..1e2eb72
--- /dev/null
+++ b/pw_ring_buffer/size_report/ring_buffer_multi.cc
@@ -0,0 +1,99 @@
+// Copyright 2021 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.
+
+#include "pw_bloat/bloat_this_binary.h"
+#include "pw_ring_buffer/prefixed_entry_ring_buffer.h"
+#include "pw_status/status.h"
+
+constexpr size_t kRingBufferSize = 1024;
+constexpr size_t kReaderCount = 4;
+constexpr std::byte kValue = (std::byte)0xFF;
+constexpr std::byte kData[1] = {kValue};
+
+int main() {
+  pw::bloat::BloatThisBinary();
+
+  pw::ring_buffer::PrefixedEntryRingBufferMulti ring(true /* user_preamble */);
+  std::byte buffer[kRingBufferSize];
+
+  pw::Status status = ring.SetBuffer(buffer);
+  if (!status.ok()) {
+    return 1;
+  }
+
+  // Attach readers.
+  pw::ring_buffer::PrefixedEntryRingBufferMulti::Reader readers[kReaderCount];
+  for (auto& reader : readers) {
+    ring.AttachReader(reader);
+  }
+
+  // Push entries until the buffer is full.
+  size_t total_entries = 0;
+  while (true) {
+    status = ring.TryPushBack(kData);
+    if (status == pw::Status::ResourceExhausted()) {
+      break;
+    } else if (!status.ok()) {
+      return 2;
+    }
+    total_entries++;
+  }
+
+  // Forcefully push an entry.
+  status = ring.PushBack(kData);
+  if (!status.ok()) {
+    return 3;
+  }
+
+  // Dering the buffer.
+  status = ring.Dering();
+  if (!status.ok()) {
+    return 4;
+  }
+
+  // Peek and pop all entries.
+  __attribute__((unused)) std::byte value[1];
+  __attribute__((unused)) size_t value_size;
+  for (size_t i = 0; i < total_entries; ++i) {
+    for (auto& reader : readers) {
+      status = reader.PeekFront(value, &value_size);
+      if (!status.ok()) {
+        return 5;
+      }
+      status = reader.PeekFrontWithPreamble(value, &value_size);
+      if (!status.ok()) {
+        return 6;
+      }
+      if (reader.FrontEntryDataSizeBytes() == 0) {
+        return 7;
+      }
+      if (reader.FrontEntryTotalSizeBytes() == 0) {
+        return 8;
+      }
+      if (reader.EntryCount() == 0) {
+        return 9;
+      }
+      status = reader.PopFront();
+      if (!status.ok()) {
+        return 10;
+      }
+    }
+  }
+
+  for (auto& reader : readers) {
+    ring.DetachReader(reader);
+  }
+  ring.Clear();
+  return 0;
+}
diff --git a/pw_ring_buffer/size_report/ring_buffer_simple.cc b/pw_ring_buffer/size_report/ring_buffer_simple.cc
new file mode 100644
index 0000000..b45e476
--- /dev/null
+++ b/pw_ring_buffer/size_report/ring_buffer_simple.cc
@@ -0,0 +1,86 @@
+// Copyright 2021 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.
+
+#include "pw_bloat/bloat_this_binary.h"
+#include "pw_ring_buffer/prefixed_entry_ring_buffer.h"
+#include "pw_status/status.h"
+
+constexpr size_t kRingBufferSize = 1024;
+constexpr std::byte kValue = (std::byte)0xFF;
+constexpr std::byte kData[1] = {kValue};
+
+int main() {
+  pw::bloat::BloatThisBinary();
+
+  pw::ring_buffer::PrefixedEntryRingBuffer ring(true /* user_preamble */);
+  std::byte buffer[kRingBufferSize];
+
+  pw::Status status = ring.SetBuffer(buffer);
+  if (!status.ok()) {
+    return 1;
+  }
+
+  // Push entries until the buffer is full.
+  size_t total_entries = 0;
+  while (true) {
+    status = ring.TryPushBack(kData);
+    if (status == pw::Status::ResourceExhausted()) {
+      break;
+    } else if (!status.ok()) {
+      return 2;
+    }
+    total_entries++;
+  }
+
+  // Forcefully push an entry.
+  status = ring.PushBack(kData);
+  if (!status.ok()) {
+    return 3;
+  }
+
+  // Dering the buffer.
+  status = ring.Dering();
+  if (!status.ok()) {
+    return 4;
+  }
+
+  // Peek and pop all entries.
+  __attribute__((unused)) std::byte value[1];
+  __attribute__((unused)) size_t value_size;
+  for (size_t i = 0; i < total_entries; ++i) {
+    status = ring.PeekFront(value, &value_size);
+    if (!status.ok()) {
+      return 5;
+    }
+    status = ring.PeekFrontWithPreamble(value, &value_size);
+    if (!status.ok()) {
+      return 6;
+    }
+    if (ring.FrontEntryDataSizeBytes() == 0) {
+      return 7;
+    }
+    if (ring.FrontEntryTotalSizeBytes() == 0) {
+      return 8;
+    }
+    if (ring.EntryCount() == 0) {
+      return 9;
+    }
+    status = ring.PopFront();
+    if (!status.ok()) {
+      return 10;
+    }
+  }
+  ring.Clear();
+  return 0;
+}
diff --git a/pw_router/BUILD b/pw_router/BUILD
new file mode 100644
index 0000000..f6f33c6
--- /dev/null
+++ b/pw_router/BUILD
@@ -0,0 +1,63 @@
+# Copyright 2021 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.
+
+load(
+    "//pw_build:pigweed.bzl",
+    "pw_cc_library",
+    "pw_cc_test",
+)
+
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])  # Apache License 2.0
+
+pw_cc_library(
+    name = "static_router",
+    hdrs = ["public/pw_router/static_router.h"],
+    srcs = ["static_router.cc"],
+    deps = [
+        ":egress",
+        ":packet_parser",
+        "//pw_log",
+        "//pw_metric",
+        "//pw_sync:mutex",
+    ],
+)
+
+pw_cc_library(
+    name = "egress",
+    hdrs = ["public/pw_router/egress.h"],
+    deps = ["//pw_bytes"],
+)
+
+pw_cc_library(
+    name = "packet_parser",
+    hdrs = ["public/pw_router/packet_parser.h"],
+    deps = ["//pw_bytes"],
+)
+
+pw_cc_library(
+    name = "egress_function",
+    hdrs = ["public/pw_router/egress_function.h"],
+    deps = [":egress"],
+)
+
+pw_cc_test(
+    name = "static_router_test",
+    srcs = ["static_router_test.cc"],
+    deps = [
+        ":egress_function",
+        ":static_router",
+    ],
+)
diff --git a/pw_router/BUILD.gn b/pw_router/BUILD.gn
new file mode 100644
index 0000000..bcf934a
--- /dev/null
+++ b/pw_router/BUILD.gn
@@ -0,0 +1,88 @@
+# Copyright 2021 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_bloat/bloat.gni")
+import("$dir_pw_build/target_types.gni")
+import("$dir_pw_docgen/docs.gni")
+import("$dir_pw_sync/backend.gni")
+import("$dir_pw_unit_test/test.gni")
+
+config("public_include_path") {
+  include_dirs = [ "public" ]
+  visibility = [ ":*" ]
+}
+
+pw_source_set("static_router") {
+  public_configs = [ ":public_include_path" ]
+  public_deps = [
+    ":egress",
+    ":packet_parser",
+    "$dir_pw_sync:lock_annotations",
+    "$dir_pw_sync:mutex",
+    dir_pw_metric,
+  ]
+  public = [ "public/pw_router/static_router.h" ]
+  sources = [ "static_router.cc" ]
+}
+
+pw_source_set("egress") {
+  public_configs = [ ":public_include_path" ]
+  public = [ "public/pw_router/egress.h" ]
+  public_deps = [ dir_pw_bytes ]
+}
+
+pw_source_set("packet_parser") {
+  public_configs = [ ":public_include_path" ]
+  public = [ "public/pw_router/packet_parser.h" ]
+  public_deps = [ dir_pw_bytes ]
+}
+
+pw_source_set("egress_function") {
+  public_configs = [ ":public_include_path" ]
+  public = [ "public/pw_router/egress_function.h" ]
+  public_deps = [ ":egress" ]
+}
+
+pw_doc_group("docs") {
+  sources = [ "docs.rst" ]
+  # TODO(frolv): This size report can't currently be built as the docs target
+  # does not have a mutex backend.
+  # report_deps = [ ":static_router_size" ]
+}
+
+pw_test_group("tests") {
+  tests = [ ":static_router_test" ]
+}
+
+pw_test("static_router_test") {
+  deps = [
+    ":egress_function",
+    ":static_router",
+  ]
+  sources = [ "static_router_test.cc" ]
+  enable_if = pw_sync_MUTEX_BACKEND != ""
+}
+
+pw_size_report("static_router_size") {
+  title = "pw::router::StaticRouter size report"
+  binaries = [
+    {
+      target = "size_report:static_router_with_one_route"
+      base = "size_report:base"
+      label = "Static router with a single route"
+    },
+  ]
+}
diff --git a/pw_router/CMakeLists.txt b/pw_router/CMakeLists.txt
new file mode 100644
index 0000000..cf80e79
--- /dev/null
+++ b/pw_router/CMakeLists.txt
@@ -0,0 +1,47 @@
+# Copyright 2021 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.
+
+include($ENV{PW_ROOT}/pw_build/pigweed.cmake)
+
+pw_add_module_library(pw_router.static_router
+  SOURCES
+    static_router.cc
+  PUBLIC_DEPS
+    pw_metric
+    pw_router.egress
+    pw_router.packet_parser
+    pw_sync.mutex
+  PRIVATE_DEPS
+    pw_log
+)
+
+pw_add_module_library(pw_router.egress
+  PUBLIC_DEPS
+    pw_bytes
+)
+
+pw_add_module_library(pw_router.packet_parser
+  PUBLIC_DEPS
+    pw_bytes
+)
+
+pw_add_module_library(pw_router.egress_function
+  PUBLIC_DEPS
+    pw_rpc.egress
+)
+
+pw_auto_add_module_tests(pw_router
+  PRIVATE_DEPS
+    pw_router.static_router
+)
diff --git a/pw_router/docs.rst b/pw_router/docs.rst
new file mode 100644
index 0000000..76fec30
--- /dev/null
+++ b/pw_router/docs.rst
@@ -0,0 +1,65 @@
+.. _module-pw_router:
+
+---------
+pw_router
+---------
+The ``pw_router`` module provides transport-agnostic classes for routing packets
+over network links.
+
+Common router interfaces
+========================
+
+PacketParser
+------------
+To work with arbitrary packet formats, routers require a common interface for
+extracting relevant packet data, such as the destination. This interface is
+``pw::router::PacketParser``, defined in ``pw_router/packet_parser.h``, which
+must be implemented for the packet framing format used by the network.
+
+Egress
+------
+The Egress class is a virtual interface for sending packet data over a network
+link. Egress implementations provide a single ``SendPacket`` function, which
+takes the raw packet data and transmits it.
+
+Some common egress implementations are provided upstream in Pigweed.
+
+StaticRouter
+============
+``pw::router::StaticRouter`` is a router with a static table of address to
+egress mappings. Routes in a static router never change; packets with the same
+address are always sent through the same egress. If links are unavailable,
+packets will be dropped.
+
+Static routers are suitable for basic networks with persistent links.
+
+Usage example
+-------------
+
+.. code-block:: c++
+
+  namespace {
+
+  // Define packet parser and egresses.
+  HdlcFrameParser hdlc_parser;
+  UartEgress uart_egress;
+  BluetoothEgress ble_egress;
+
+  // Define the routing table.
+  constexpr pw::router::StaticRouter::Route routes[] = {{1, uart_egress},
+                                                        {7, ble_egress}};
+  pw::router::StaticRouter router(hdlc_parser, routes);
+
+  }  // namespace
+
+  void ProcessPacket(pw::ConstByteSpan packet) {
+    router.RoutePacket(packet);
+  }
+
+.. TODO(frolv): Re-enable this when the size report builds.
+.. Size report
+.. -----------
+.. The following size report shows the cost of a ``StaticRouter`` with a simple
+.. ``PacketParser`` implementation and a single route using an ``EgressFunction``.
+
+.. .. include:: static_router_size
diff --git a/pw_router/public/pw_router/egress.h b/pw_router/public/pw_router/egress.h
new file mode 100644
index 0000000..09097e0
--- /dev/null
+++ b/pw_router/public/pw_router/egress.h
@@ -0,0 +1,35 @@
+// Copyright 2021 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.
+#pragma once
+
+#include <span>
+
+#include "pw_bytes/span.h"
+#include "pw_status/status.h"
+
+namespace pw::router {
+
+// Data egress for a router to send packets over some transport system.
+class Egress {
+ public:
+  virtual ~Egress() = default;
+
+  // Sends a complete packet/frame over the transport. Returns OK on success, or
+  // an error status on failure.
+  //
+  // TODO(frolv): Document possible return values.
+  virtual Status SendPacket(ConstByteSpan packet) = 0;
+};
+
+}  // namespace pw::router
diff --git a/pw_router/public/pw_router/egress_function.h b/pw_router/public/pw_router/egress_function.h
new file mode 100644
index 0000000..e766766
--- /dev/null
+++ b/pw_router/public/pw_router/egress_function.h
@@ -0,0 +1,33 @@
+// Copyright 2021 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.
+#pragma once
+
+#include <span>
+
+#include "pw_router/egress.h"
+
+namespace pw::router {
+
+// Router egress that dispatches to a free function.
+class EgressFunction final : public Egress {
+ public:
+  constexpr EgressFunction(Status (*func)(ConstByteSpan)) : func_(*func) {}
+
+  Status SendPacket(ConstByteSpan packet) final { return func_(packet); }
+
+ private:
+  Status (&func_)(ConstByteSpan);
+};
+
+}  // namespace pw::router
diff --git a/pw_router/public/pw_router/packet_parser.h b/pw_router/public/pw_router/packet_parser.h
new file mode 100644
index 0000000..c4ecc8a
--- /dev/null
+++ b/pw_router/public/pw_router/packet_parser.h
@@ -0,0 +1,46 @@
+// Copyright 2021 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.
+#pragma once
+
+#include <optional>
+#include <span>
+
+#include "pw_bytes/span.h"
+
+namespace pw::router {
+
+// A PacketParser is an abstract interface for extracting data from different
+// kinds of transport layer packets or frames. It is used by routers to examine
+// fields within packets to know how to route them.
+class PacketParser {
+ public:
+  virtual ~PacketParser() = default;
+
+  // Parses a packet, storing its data for subsequent calls to Get* functions.
+  // Any currently stored packet is cleared. Returns true if successful, or
+  // false if the packet is incomplete or corrupt.
+  //
+  // The raw binary data passed to this function is guaranteed to remain valid
+  // through all subsequent Get* calls made for the packet's information, so
+  // implementations may store and use it directly.
+  virtual bool Parse(ConstByteSpan packet) = 0;
+
+  // Extracts the destination address the last parsed packet, if it exists.
+  //
+  // Guaranteed to only be called if Parse() succeeded and while the data passed
+  // to Parse() is valid.
+  virtual std::optional<uint32_t> GetDestinationAddress() const = 0;
+};
+
+}  // namespace pw::router
diff --git a/pw_router/public/pw_router/static_router.h b/pw_router/public/pw_router/static_router.h
new file mode 100644
index 0000000..9c0925d
--- /dev/null
+++ b/pw_router/public/pw_router/static_router.h
@@ -0,0 +1,78 @@
+// Copyright 2021 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.
+#pragma once
+
+#include <span>
+
+#include "pw_bytes/span.h"
+#include "pw_metric/metric.h"
+#include "pw_router/egress.h"
+#include "pw_router/packet_parser.h"
+#include "pw_status/status.h"
+#include "pw_sync/lock_annotations.h"
+#include "pw_sync/mutex.h"
+
+namespace pw::router {
+
+// A packet router with a static routing table.
+//
+// Thread-safety:
+//   Internal packet parsing and calls to the provided PacketParser are
+//   synchronized. Synchronization at the egress level must be implemented by
+//   derived egresses.
+//
+class StaticRouter {
+ public:
+  struct Route {
+    // TODO(frolv): Consider making address size configurable.
+    uint32_t address;
+    Egress& egress;
+  };
+
+  StaticRouter(PacketParser& parser, std::span<const Route> routes)
+      : parser_(parser), routes_(routes) {}
+
+  StaticRouter(const StaticRouter&) = delete;
+  StaticRouter(StaticRouter&&) = delete;
+  StaticRouter& operator=(const StaticRouter&) = delete;
+  StaticRouter& operator=(StaticRouter&&) = delete;
+
+  uint32_t dropped_packets() const {
+    return parser_errors_.value() + route_errors_.value() +
+           egress_errors_.value();
+  }
+
+  const metric::Group& metrics() { return metrics_; }
+
+  // Routes a single packet through the appropriate egress.
+  // Returns one of the following to indicate a router-side error:
+  //
+  //   OK - Packet sent successfully.
+  //   DATA_LOSS - Packet corrupt or incomplete.
+  //   NOT_FOUND - No registered route for the packet.
+  //   UNAVAILABLE - Route egress did not accept packet.
+  //
+  Status RoutePacket(ConstByteSpan packet) PW_LOCKS_EXCLUDED(mutex_);
+
+ private:
+  PacketParser& parser_ PW_GUARDED_BY(mutex_);
+  const std::span<const Route> routes_;
+  sync::Mutex mutex_;
+  PW_METRIC_GROUP(metrics_, "static_router");
+  PW_METRIC(metrics_, parser_errors_, "parser_errors", 0u);
+  PW_METRIC(metrics_, route_errors_, "route_errors", 0u);
+  PW_METRIC(metrics_, egress_errors_, "egress_errors", 0u);
+};
+
+}  // namespace pw::router
diff --git a/pw_router/size_report/BUILD b/pw_router/size_report/BUILD
new file mode 100644
index 0000000..f90c83b
--- /dev/null
+++ b/pw_router/size_report/BUILD
@@ -0,0 +1,45 @@
+# Copyright 2021 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.
+
+load(
+    "//pw_build:pigweed.bzl",
+    "pw_cc_binary",
+)
+
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])  # Apache License 2.0
+
+pw_cc_binary(
+    name = "base",
+    srcs = ["base.cc"],
+    deps = [
+        "//pw_assert",
+        "//pw_bloat:bloat_this_binary",
+        "//pw_log",
+        "//pw_sys_io",
+    ],
+)
+
+pw_cc_binary(
+    name = "static_router_with_one_route",
+    srcs = ["static_router_with_one_route.cc"],
+    deps = [
+        "//pw_assert",
+        "//pw_bloat:bloat_this_binary",
+        "//pw_log",
+        "//pw_router:static_router",
+        "//pw_sys_io",
+    ],
+)
diff --git a/pw_router/size_report/BUILD.gn b/pw_router/size_report/BUILD.gn
new file mode 100644
index 0000000..e30430e
--- /dev/null
+++ b/pw_router/size_report/BUILD.gn
@@ -0,0 +1,34 @@
+# Copyright 2021 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/target_types.gni")
+
+_common_deps = [
+  "$dir_pw_bloat:bloat_this_binary",
+  dir_pw_assert,
+  dir_pw_log,
+  dir_pw_sys_io,
+]
+
+pw_executable("base") {
+  sources = [ "base.cc" ]
+  deps = _common_deps
+}
+
+pw_executable("static_router_with_one_route") {
+  sources = [ "static_router_with_one_route.cc" ]
+  deps = _common_deps + [ "..:static_router" ]
+}
diff --git a/pw_router/size_report/base.cc b/pw_router/size_report/base.cc
new file mode 100644
index 0000000..8d63f6f
--- /dev/null
+++ b/pw_router/size_report/base.cc
@@ -0,0 +1,49 @@
+// Copyright 2021 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.
+
+#include "pw_assert/assert.h"
+#include "pw_bloat/bloat_this_binary.h"
+#include "pw_log/log.h"
+#include "pw_sys_io/sys_io.h"
+
+namespace {
+
+struct BasicPacket {
+  static constexpr uint32_t kMagic = 0x8badf00d;
+
+  constexpr BasicPacket(uint32_t addr, uint64_t data)
+      : magic(kMagic), address(addr), payload(data) {}
+
+  uint32_t magic;
+  uint32_t address;
+  uint64_t payload;
+};
+
+}  // namespace
+
+int main() {
+  pw::bloat::BloatThisBinary();
+
+  // Ensure we are paying the cost for log and assert.
+  BasicPacket packet(0x1, 0x2);
+  PW_CHECK_UINT_EQ(packet.magic, BasicPacket::kMagic, "Some CHECK logic");
+  PW_LOG_INFO("Packet has address %u", static_cast<unsigned>(packet.address));
+  PW_LOG_INFO("pw_StatusString %s", pw::OkStatus().str());
+
+  std::array<std::byte, sizeof(BasicPacket)> packet_buffer;
+  pw::sys_io::ReadBytes(packet_buffer);
+  pw::sys_io::WriteBytes(packet_buffer);
+
+  return static_cast<int>(packet.payload);
+}
diff --git a/pw_router/size_report/static_router_with_one_route.cc b/pw_router/size_report/static_router_with_one_route.cc
new file mode 100644
index 0000000..a285954
--- /dev/null
+++ b/pw_router/size_report/static_router_with_one_route.cc
@@ -0,0 +1,85 @@
+// Copyright 2021 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.
+
+#include "pw_assert/assert.h"
+#include "pw_bloat/bloat_this_binary.h"
+#include "pw_log/log.h"
+#include "pw_router/egress_function.h"
+#include "pw_router/static_router.h"
+#include "pw_sys_io/sys_io.h"
+
+namespace {
+
+struct BasicPacket {
+  static constexpr uint32_t kMagic = 0x8badf00d;
+
+  constexpr BasicPacket(uint32_t addr, uint64_t data)
+      : magic(kMagic), address(addr), payload(data) {}
+
+  uint32_t magic;
+  uint32_t address;
+  uint64_t payload;
+};
+
+}  // namespace
+
+// All the new router-specific stuff.
+namespace {
+
+class BasicPacketParser : public pw::router::PacketParser {
+ public:
+  constexpr BasicPacketParser() : packet_(nullptr) {}
+
+  bool Parse(pw::ConstByteSpan packet) final {
+    packet_ = reinterpret_cast<const BasicPacket*>(packet.data());
+    return packet_->magic == BasicPacket::kMagic;
+  }
+
+  std::optional<uint32_t> GetDestinationAddress() const final {
+    return packet_->address;
+  }
+
+ private:
+  const BasicPacket* packet_;
+};
+
+BasicPacketParser parser;
+pw::router::EgressFunction sys_io_egress(+[](pw::ConstByteSpan packet) {
+  return pw::sys_io::WriteBytes(packet).status();
+});
+constexpr pw::router::StaticRouter::Route routes[] = {{1, sys_io_egress}};
+pw::router::StaticRouter router(parser, routes);
+
+}  // namespace
+
+int main() {
+  pw::bloat::BloatThisBinary();
+
+  // Ensure we are paying the cost for log and assert.
+  BasicPacket packet(0x1, 0x2);
+  PW_CHECK_UINT_EQ(packet.magic, BasicPacket::kMagic, "Some CHECK logic");
+  PW_LOG_INFO("Packet has address %u", static_cast<unsigned>(packet.address));
+  PW_LOG_INFO("pw_StatusString %s", pw::OkStatus().str());
+
+  std::array<std::byte, sizeof(BasicPacket)> packet_buffer;
+  pw::sys_io::ReadBytes(packet_buffer);
+  pw::sys_io::WriteBytes(packet_buffer);
+
+  while (true) {
+    pw::sys_io::ReadBytes(packet_buffer);
+    router.RoutePacket(packet_buffer);
+  }
+
+  return static_cast<int>(packet.payload);
+}
diff --git a/pw_router/static_router.cc b/pw_router/static_router.cc
new file mode 100644
index 0000000..a32e74a
--- /dev/null
+++ b/pw_router/static_router.cc
@@ -0,0 +1,60 @@
+// Copyright 2021 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.
+
+#include "pw_router/static_router.h"
+
+#include <algorithm>
+#include <mutex>
+
+namespace pw::router {
+
+Status StaticRouter::RoutePacket(ConstByteSpan packet) {
+  uint32_t address;
+
+  {
+    // Only packet parsing is synchronized within the router; egresses must be
+    // synchronized externally.
+    std::lock_guard lock(mutex_);
+
+    if (!parser_.Parse(packet)) {
+      parser_errors_.Increment();
+      return Status::DataLoss();
+    }
+
+    std::optional<uint32_t> result = parser_.GetDestinationAddress();
+    if (!result.has_value()) {
+      parser_errors_.Increment();
+      return Status::DataLoss();
+    }
+
+    address = result.value();
+  }
+
+  auto route = std::find_if(routes_.begin(), routes_.end(), [&](auto r) {
+    return r.address == address;
+  });
+  if (route == routes_.end()) {
+    route_errors_.Increment();
+    return Status::NotFound();
+  }
+
+  if (Status status = route->egress.SendPacket(packet); !status.ok()) {
+    egress_errors_.Increment();
+    return Status::Unavailable();
+  }
+
+  return OkStatus();
+}
+
+}  // namespace pw::router
diff --git a/pw_router/static_router_test.cc b/pw_router/static_router_test.cc
new file mode 100644
index 0000000..72ca116
--- /dev/null
+++ b/pw_router/static_router_test.cc
@@ -0,0 +1,116 @@
+// Copyright 2021 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.
+
+#include "pw_router/static_router.h"
+
+#include "gtest/gtest.h"
+#include "pw_router/egress_function.h"
+
+namespace pw::router {
+namespace {
+
+struct BasicPacket {
+  static constexpr uint32_t kMagic = 0x8badf00d;
+
+  constexpr BasicPacket(uint32_t addr, uint64_t data)
+      : magic(kMagic), address(addr), payload(data) {}
+
+  ConstByteSpan data() const { return std::as_bytes(std::span(this, 1)); }
+
+  uint32_t magic;
+  uint32_t address;
+  uint64_t payload;
+};
+
+class BasicPacketParser : public PacketParser {
+ public:
+  constexpr BasicPacketParser() : packet_(nullptr) {}
+
+  bool Parse(pw::ConstByteSpan packet) final {
+    packet_ = reinterpret_cast<const BasicPacket*>(packet.data());
+    return packet_->magic == BasicPacket::kMagic;
+  }
+
+  std::optional<uint32_t> GetDestinationAddress() const final {
+    PW_DCHECK_NOTNULL(packet_);
+    return packet_->address;
+  }
+
+ private:
+  const BasicPacket* packet_;
+};
+
+EgressFunction GoodEgress(+[](ConstByteSpan) { return OkStatus(); });
+EgressFunction BadEgress(+[](ConstByteSpan) {
+  return Status::ResourceExhausted();
+});
+
+TEST(StaticRouter, RoutePacket_RoutesToAnEgress) {
+  BasicPacketParser parser;
+  constexpr StaticRouter::Route routes[] = {{1, GoodEgress}, {2, BadEgress}};
+  StaticRouter router(parser, std::span(routes));
+
+  EXPECT_EQ(router.RoutePacket(BasicPacket(1, 0xdddd).data()), OkStatus());
+  EXPECT_EQ(router.RoutePacket(BasicPacket(2, 0xdddd).data()),
+            Status::Unavailable());
+}
+
+TEST(StaticRouter, RoutePacket_ReturnsParserError) {
+  BasicPacketParser parser;
+  constexpr StaticRouter::Route routes[] = {{1, GoodEgress}, {2, BadEgress}};
+  StaticRouter router(parser, std::span(routes));
+
+  BasicPacket bad_magic(1, 0xdddd);
+  bad_magic.magic = 0x1badda7a;
+  EXPECT_EQ(router.RoutePacket(bad_magic.data()), Status::DataLoss());
+}
+
+TEST(StaticRouter, RoutePacket_ReturnsNotFoundOnInvalidRoute) {
+  BasicPacketParser parser;
+  constexpr StaticRouter::Route routes[] = {{1, GoodEgress}, {2, BadEgress}};
+  StaticRouter router(parser, std::span(routes));
+
+  EXPECT_EQ(router.RoutePacket(BasicPacket(42, 0xdddd).data()),
+            Status::NotFound());
+}
+
+TEST(StaticRouter, RoutePacket_TracksNumberOfDrops) {
+  BasicPacketParser parser;
+  constexpr StaticRouter::Route routes[] = {{1, GoodEgress}, {2, BadEgress}};
+  StaticRouter router(parser, std::span(routes));
+
+  // Good
+  EXPECT_EQ(router.RoutePacket(BasicPacket(1, 0xdddd).data()), OkStatus());
+
+  // Egress error
+  EXPECT_EQ(router.RoutePacket(BasicPacket(2, 0xdddd).data()),
+            Status::Unavailable());
+
+  // Parser error
+  BasicPacket bad_magic(1, 0xdddd);
+  bad_magic.magic = 0x1badda7a;
+  EXPECT_EQ(router.RoutePacket(bad_magic.data()), Status::DataLoss());
+
+  // Good
+  EXPECT_EQ(router.RoutePacket(BasicPacket(1, 0xdddd).data()), OkStatus());
+
+  // Bad route
+  EXPECT_EQ(router.RoutePacket(BasicPacket(42, 0xdddd).data()),
+            Status::NotFound());
+
+  EXPECT_EQ(router.dropped_packets(), 3u);
+}
+
+}  // namespace
+}  // namespace pw::router
diff --git a/pw_rpc/BUILD b/pw_rpc/BUILD
index a7c4a83..b2cd789 100644
--- a/pw_rpc/BUILD
+++ b/pw_rpc/BUILD
@@ -25,8 +25,8 @@
 pw_cc_library(
     name = "client",
     srcs = [
-        "client.cc",
         "base_client_call.cc",
+        "client.cc",
     ],
     hdrs = [
         "public/pw_rpc/client.h",
@@ -34,7 +34,7 @@
     ],
     deps = [
         ":common",
-    ]
+    ],
 )
 
 pw_cc_library(
@@ -45,6 +45,7 @@
         "public/pw_rpc/internal/call.h",
         "public/pw_rpc/internal/hash.h",
         "public/pw_rpc/internal/method.h",
+        "public/pw_rpc/internal/method_lookup.h",
         "public/pw_rpc/internal/method_union.h",
         "public/pw_rpc/internal/server.h",
         "server.cc",
@@ -62,11 +63,22 @@
 )
 
 pw_cc_library(
+    name = "client_server",
+    srcs = ["client_server.cc"],
+    hdrs = ["public/pw_rpc/client_server.h"],
+    deps = [
+        ":client",
+        ":server",
+    ],
+)
+
+pw_cc_library(
     name = "common",
     srcs = [
         "channel.cc",
         "packet.cc",
         "public/pw_rpc/internal/channel.h",
+        "public/pw_rpc/internal/config.h",
         "public/pw_rpc/internal/method_type.h",
         "public/pw_rpc/internal/packet.h",
     ],
@@ -83,12 +95,14 @@
 )
 
 pw_cc_library(
-    name = "service_method_traits",
-    hdrs = [
-        "public/pw_rpc/internal/service_method_traits.h",
-    ],
+    name = "synchronized_channel_output",
+    hdrs = ["public/pw_rpc/synchronized_channel_output.h"],
     includes = ["public"],
-    deps = [ ":server"  ],
+    deps = [
+        ":common",
+        "//pw_sync:lock_annotations",
+        "//pw_sync:mutex",
+    ],
 )
 
 pw_cc_library(
@@ -96,6 +110,7 @@
     hdrs = [
         "public/pw_rpc/internal/test_method.h",
         "pw_rpc_private/internal_test_utils.h",
+        "pw_rpc_private/method_impl_tester.h",
     ],
     visibility = ["//visibility:private"],
     deps = [
@@ -111,6 +126,7 @@
     srcs = [
         "nanopb/codegen_test.cc",
         "nanopb/echo_service_test.cc",
+        "nanopb/method_lookup_test.cc",
         "nanopb/nanopb_client_call.cc",
         "nanopb/nanopb_client_call_test.cc",
         "nanopb/nanopb_common.cc",
@@ -121,14 +137,10 @@
         "nanopb/public/pw_rpc/internal/nanopb_common.h",
         "nanopb/public/pw_rpc/internal/nanopb_method.h",
         "nanopb/public/pw_rpc/internal/nanopb_method_union.h",
-        "nanopb/public/pw_rpc/internal/nanopb_service_method_traits.h",
         "nanopb/public/pw_rpc/nanopb_client_call.h",
         "nanopb/public/pw_rpc/nanopb_test_method_context.h",
         "nanopb/pw_rpc_nanopb_private/internal_test_utils.h",
-        "nanopb/nanopb_service_method_traits_test.cc",
-        "nanopb/test.pb.c",
-        "nanopb/test.pb.h",
-        "nanopb/test_rpc.pb.h",
+        "nanopb/stub_generation_test.cc",
     ],
 )
 
@@ -184,10 +196,19 @@
     ],
 )
 
+pw_cc_test(
+    name = "client_server_test",
+    srcs = ["client_server_test.cc"],
+    deps = [
+        ":client_server",
+        "//pw_rpc/raw:method_union",
+    ],
+)
+
 proto_library(
     name = "packet_proto",
     srcs = [
-        "pw_rpc_protos/packet.proto",
+        "pw_rpc_protos/internal/packet.proto",
     ],
 )
 
diff --git a/pw_rpc/BUILD.gn b/pw_rpc/BUILD.gn
index 01228ad..9f4a95f 100644
--- a/pw_rpc/BUILD.gn
+++ b/pw_rpc/BUILD.gn
@@ -1,4 +1,4 @@
-# Copyright 2020 The Pigweed Authors
+# Copyright 2021 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
@@ -15,19 +15,37 @@
 import("//build_overrides/pigweed.gni")
 
 import("$dir_pw_bloat/bloat.gni")
+import("$dir_pw_build/module_config.gni")
+import("$dir_pw_build/python.gni")
+import("$dir_pw_build/python_action.gni")
 import("$dir_pw_build/target_types.gni")
 import("$dir_pw_docgen/docs.gni")
 import("$dir_pw_protobuf_compiler/proto.gni")
 import("$dir_pw_third_party/nanopb/nanopb.gni")
 import("$dir_pw_unit_test/test.gni")
 
-config("default_config") {
+declare_args() {
+  # The build target that overrides the default configuration options for this
+  # module. This should point to a source set that provides defines through a
+  # public config (which may -include a file or add defines directly).
+  pw_rpc_CONFIG = pw_build_DEFAULT_MODULE_CONFIG
+}
+
+config("public_include_path") {
   include_dirs = [ "public" ]
   visibility = [ ":*" ]
 }
 
+pw_source_set("config") {
+  sources = [ "public/pw_rpc/internal/config.h" ]
+  public_configs = [ ":public_include_path" ]
+  public_deps = [ pw_rpc_CONFIG ]
+  visibility = [ "./*" ]
+  friend = [ "./*" ]
+}
+
 pw_source_set("server") {
-  public_configs = [ ":default_config" ]
+  public_configs = [ ":public_include_path" ]
   public_deps = [ ":common" ]
   deps = [ dir_pw_log ]
   public = [
@@ -41,6 +59,7 @@
     "public/pw_rpc/internal/call.h",
     "public/pw_rpc/internal/hash.h",
     "public/pw_rpc/internal/method.h",
+    "public/pw_rpc/internal/method_lookup.h",
     "public/pw_rpc/internal/method_union.h",
     "public/pw_rpc/internal/server.h",
     "server.cc",
@@ -50,7 +69,7 @@
 }
 
 pw_source_set("client") {
-  public_configs = [ ":default_config" ]
+  public_configs = [ ":public_include_path" ]
   public_deps = [ ":common" ]
   deps = [ dir_pw_log ]
   public = [
@@ -63,15 +82,24 @@
   ]
 }
 
+pw_source_set("client_server") {
+  public_configs = [ ":public_include_path" ]
+  public_deps = [
+    ":client",
+    ":server",
+  ]
+  public = [ "public/pw_rpc/client_server.h" ]
+  sources = [ "client_server.cc" ]
+}
+
 # Classes shared by the server and client.
 pw_source_set("common") {
-  public_configs = [ ":default_config" ]
+  public_configs = [ ":public_include_path" ]
   public_deps = [
     ":protos.pwpb",
     "$dir_pw_containers:intrusive_list",
     dir_pw_assert,
     dir_pw_bytes,
-    dir_pw_span,
     dir_pw_status,
   ]
   deps = [ dir_pw_log ]
@@ -86,22 +114,26 @@
   friend = [ "./*" ]
 }
 
-pw_source_set("service_method_traits") {
-  public = [ "public/pw_rpc/internal/service_method_traits.h" ]
-  public_deps = [ ":server" ]
-  visibility = [ "./*" ]
+pw_source_set("synchronized_channel_output") {
+  public_configs = [ ":public_include_path" ]
+  public_deps = [
+    ":common",
+    "$dir_pw_sync:lock_annotations",
+    "$dir_pw_sync:mutex",
+  ]
+  public = [ "public/pw_rpc/synchronized_channel_output.h" ]
 }
 
 pw_source_set("test_utils") {
   public = [
     "public/pw_rpc/internal/test_method.h",
     "pw_rpc_private/internal_test_utils.h",
+    "pw_rpc_private/method_impl_tester.h",
   ]
   public_configs = [ ":private_includes" ]
   public_deps = [
     ":client",
     ":server",
-    dir_pw_span,
   ]
   visibility = [ "./*" ]
 }
@@ -112,21 +144,25 @@
 }
 
 pw_proto_library("protos") {
-  sources = [ "pw_rpc_protos/packet.proto" ]
-}
-
-pw_proto_library("echo_service_proto") {
-  sources = [ "pw_rpc_protos/echo.proto" ]
-  inputs = [ "pw_rpc_protos/echo.options" ]
+  sources = [
+    "echo.proto",
+    "internal/packet.proto",
+  ]
+  inputs = [ "echo.options" ]
+  python_package = "py"
+  prefix = "pw_rpc"
 }
 
 pw_doc_group("docs") {
   sources = [ "docs.rst" ]
   inputs = [
-    "pw_rpc_protos/echo.proto",
-    "pw_rpc_protos/packet.proto",
+    "echo.proto",
+    "internal/packet.proto",
   ]
-  group_deps = [ "nanopb:docs" ]
+  group_deps = [
+    "nanopb:docs",
+    "py:docs",
+  ]
   report_deps = [ ":server_size" ]
 }
 
@@ -158,6 +194,7 @@
     ":base_server_writer_test",
     ":channel_test",
     ":client_test",
+    ":client_server_test",
     ":ids_test",
     ":packet_test",
     ":server_test",
@@ -191,11 +228,15 @@
   sources = [ "channel_test.cc" ]
 }
 
-action("generate_ids_test") {
+pw_python_action("generate_ids_test") {
   outputs = [ "$target_gen_dir/generated_ids_test.cc" ]
-  script = "py/ids_test.py"
+
+  script = "py/tests/ids_test.py"
   args = [ "--generate-cc-test" ] + rebase_path(outputs)
-  deps = [ "$dir_pw_build/py" ]
+  python_deps = [
+    "$dir_pw_build/py",
+    "py",
+  ]
 }
 
 pw_test("ids_test") {
@@ -232,6 +273,15 @@
   sources = [ "client_test.cc" ]
 }
 
+pw_test("client_server_test") {
+  deps = [
+    ":client_server",
+    ":test_utils",
+    "raw:method_union",
+  ]
+  sources = [ "client_server_test.cc" ]
+}
+
 pw_test("base_client_call_test") {
   deps = [
     ":client",
diff --git a/pw_rpc/CMakeLists.txt b/pw_rpc/CMakeLists.txt
index bbaf74b..c3635fe 100644
--- a/pw_rpc/CMakeLists.txt
+++ b/pw_rpc/CMakeLists.txt
@@ -20,6 +20,7 @@
 endif()
 
 add_subdirectory(raw)
+add_subdirectory(system_server)
 
 pw_add_module_library(pw_rpc.server
   SOURCES
@@ -42,6 +43,14 @@
     pw_log
 )
 
+pw_add_module_library(pw_rpc.client_server
+  SOURCES
+    client_server.cc
+  PUBLIC_DEPS
+    pw_rpc.client
+    pw_rpc.server
+)
+
 pw_add_module_library(pw_rpc.common
   SOURCES
     channel.cc
@@ -57,17 +66,23 @@
     pw_log
 )
 
+pw_add_module_library(pw_rpc.synchronized_channel_output
+  PUBLIC_DEPS
+    pw_rpc.common
+    pw_sync.mutex
+)
+
 add_library(pw_rpc.test_utils INTERFACE)
 target_include_directories(pw_rpc.test_utils INTERFACE .)
 
 pw_proto_library(pw_rpc.protos
   SOURCES
-    pw_rpc_protos/packet.proto
-)
-
-pw_proto_library(pw_rpc.echo_proto
-  SOURCES
-    pw_rpc_protos/echo.proto
+    internal/packet.proto
+    echo.proto
+  INPUTS
+    echo.options
+  PREFIX
+    pw_rpc
 )
 
 pw_proto_library(pw_rpc.test_protos
diff --git a/pw_rpc/base_client_call_test.cc b/pw_rpc/base_client_call_test.cc
index 44a3243..7f15146 100644
--- a/pw_rpc/base_client_call_test.cc
+++ b/pw_rpc/base_client_call_test.cc
@@ -26,8 +26,8 @@
 
   {
     BaseClientCall call(&context.channel(),
-                        context.kServiceId,
-                        context.kMethodId,
+                        context.service_id(),
+                        context.method_id(),
                         [](BaseClientCall&, const Packet&) {});
     EXPECT_EQ(context.client().active_calls(), 1u);
   }
@@ -53,8 +53,8 @@
 TEST(BaseClientCall, SendsPacketWithPayload) {
   ClientContextForTest context;
   FakeClientCall call(&context.channel(),
-                      context.kServiceId,
-                      context.kMethodId,
+                      context.service_id(),
+                      context.method_id(),
                       [](BaseClientCall&, const Packet&) {});
 
   constexpr std::byte payload[]{std::byte{0x08}, std::byte{0x39}};
@@ -63,8 +63,8 @@
   EXPECT_EQ(context.output().packet_count(), 1u);
   Packet packet = context.output().sent_packet();
   EXPECT_EQ(packet.channel_id(), context.channel().id());
-  EXPECT_EQ(packet.service_id(), context.kServiceId);
-  EXPECT_EQ(packet.method_id(), context.kMethodId);
+  EXPECT_EQ(packet.service_id(), context.service_id());
+  EXPECT_EQ(packet.method_id(), context.method_id());
   EXPECT_EQ(std::memcmp(packet.payload().data(), payload, sizeof(payload)), 0);
 }
 
diff --git a/pw_rpc/base_server_writer.cc b/pw_rpc/base_server_writer.cc
index 1d9ea4d..9d4423d 100644
--- a/pw_rpc/base_server_writer.cc
+++ b/pw_rpc/base_server_writer.cc
@@ -14,6 +14,7 @@
 
 #include "pw_rpc/internal/base_server_writer.h"
 
+#include "pw_assert/assert.h"
 #include "pw_rpc/internal/method.h"
 #include "pw_rpc/internal/packet.h"
 #include "pw_rpc/internal/server.h"
@@ -45,9 +46,9 @@
 
 uint32_t BaseServerWriter::method_id() const { return call_.method().id(); }
 
-void BaseServerWriter::Finish(Status status) {
+Status BaseServerWriter::Finish(Status status) {
   if (!open()) {
-    return;
+    return Status::FailedPrecondition();
   }
 
   // If the ServerWriter implementer or user forgets to release an acquired
@@ -59,18 +60,16 @@
   Close();
 
   // Send a control packet indicating that the stream (and RPC) has terminated.
-  call_.channel().Send(Packet(PacketType::SERVER_STREAM_END,
-                              call_.channel().id(),
-                              call_.service().id(),
-                              method().id(),
-                              {},
-                              status));
+  return call_.channel().Send(Packet(PacketType::SERVER_STREAM_END,
+                                     call_.channel().id(),
+                                     call_.service().id(),
+                                     method().id(),
+                                     {},
+                                     status));
 }
 
 std::span<std::byte> BaseServerWriter::AcquirePayloadBuffer() {
-  if (!open()) {
-    return {};
-  }
+  PW_DCHECK(open());
 
   // Only allow having one active buffer at a time.
   if (response_.empty()) {
@@ -82,19 +81,14 @@
 
 Status BaseServerWriter::ReleasePayloadBuffer(
     std::span<const std::byte> payload) {
-  if (!open()) {
-    return Status::FailedPrecondition();
-  }
+  PW_DCHECK(open());
   return call_.channel().Send(response_, ResponsePacket(payload));
 }
 
 Status BaseServerWriter::ReleasePayloadBuffer() {
-  if (!open()) {
-    return Status::FailedPrecondition();
-  }
-
+  PW_DCHECK(open());
   call_.channel().Release(response_);
-  return Status::Ok();
+  return OkStatus();
 }
 
 void BaseServerWriter::Close() {
diff --git a/pw_rpc/base_server_writer_test.cc b/pw_rpc/base_server_writer_test.cc
index d5767df..f2d9d27 100644
--- a/pw_rpc/base_server_writer_test.cc
+++ b/pw_rpc/base_server_writer_test.cc
@@ -53,7 +53,9 @@
   BaseServerWriter moved(context.get());
   BaseServerWriter writer(std::move(moved));
 
+#ifndef __clang_analyzer__
   EXPECT_FALSE(moved.open());
+#endif  // ignore use-after-move
   EXPECT_TRUE(writer.open());
 }
 
@@ -104,7 +106,7 @@
   ServerContextForTest<TestService> context(TestService::method.method());
   FakeServerWriter writer(context.get());
 
-  writer.Finish();
+  EXPECT_EQ(OkStatus(), writer.Finish());
 
   auto& writers = context.server().writers();
   EXPECT_TRUE(writers.empty());
@@ -114,15 +116,23 @@
   ServerContextForTest<TestService> context(TestService::method.method());
   FakeServerWriter writer(context.get());
 
-  writer.Finish();
+  EXPECT_EQ(OkStatus(), writer.Finish());
 
   const Packet& packet = context.output().sent_packet();
   EXPECT_EQ(packet.type(), PacketType::SERVER_STREAM_END);
-  EXPECT_EQ(packet.channel_id(), context.kChannelId);
-  EXPECT_EQ(packet.service_id(), context.kServiceId);
+  EXPECT_EQ(packet.channel_id(), context.channel_id());
+  EXPECT_EQ(packet.service_id(), context.service_id());
   EXPECT_EQ(packet.method_id(), context.get().method().id());
   EXPECT_TRUE(packet.payload().empty());
-  EXPECT_EQ(packet.status(), Status::Ok());
+  EXPECT_EQ(packet.status(), OkStatus());
+}
+
+TEST(ServerWriter, Finish_ReturnsStatusFromChannelSend) {
+  ServerContextForTest<TestService> context(TestService::method.method());
+  FakeServerWriter writer(context.get());
+  context.output().set_send_status(Status::Unauthenticated());
+
+  EXPECT_EQ(Status::Unauthenticated(), writer.Finish());
 }
 
 TEST(ServerWriter, Close) {
@@ -130,8 +140,9 @@
   FakeServerWriter writer(context.get());
 
   ASSERT_TRUE(writer.open());
-  writer.Finish();
+  EXPECT_EQ(OkStatus(), writer.Finish());
   EXPECT_FALSE(writer.open());
+  EXPECT_EQ(Status::FailedPrecondition(), writer.Finish());
 }
 
 TEST(ServerWriter, Close_ReleasesBuffer) {
@@ -142,7 +153,7 @@
   auto buffer = writer.PayloadBuffer();
   buffer[0] = std::byte{0};
   EXPECT_FALSE(writer.output_buffer().empty());
-  writer.Finish();
+  EXPECT_EQ(OkStatus(), writer.Finish());
   EXPECT_FALSE(writer.open());
   EXPECT_TRUE(writer.output_buffer().empty());
 }
@@ -152,11 +163,11 @@
   FakeServerWriter writer(context.get());
 
   constexpr byte data[] = {byte{0xf0}, byte{0x0d}};
-  ASSERT_EQ(Status::Ok(), writer.Write(data));
+  ASSERT_EQ(OkStatus(), writer.Write(data));
 
   byte encoded[64];
   auto result = context.packet(data).Encode(encoded);
-  ASSERT_EQ(Status::Ok(), result.status());
+  ASSERT_EQ(OkStatus(), result.status());
 
   EXPECT_EQ(result.value().size(), context.output().sent_data().size());
   EXPECT_EQ(
@@ -165,14 +176,12 @@
           encoded, context.output().sent_data().data(), result.value().size()));
 }
 
-TEST(ServerWriter, Closed_IgnoresPacket) {
+TEST(ServerWriter, Closed_IgnoresFinish) {
   ServerContextForTest<TestService> context(TestService::method.method());
   FakeServerWriter writer(context.get());
 
-  writer.Finish();
-
-  constexpr byte data[] = {byte{0xf0}, byte{0x0d}};
-  EXPECT_EQ(Status::FailedPrecondition(), writer.Write(data));
+  EXPECT_EQ(OkStatus(), writer.Finish());
+  EXPECT_EQ(Status::FailedPrecondition(), writer.Finish());
 }
 
 }  // namespace
diff --git a/pw_rpc/channel.cc b/pw_rpc/channel.cc
index fb5eab2..70f4262 100644
--- a/pw_rpc/channel.cc
+++ b/pw_rpc/channel.cc
@@ -29,15 +29,17 @@
 
 Status Channel::Send(OutputBuffer& buffer, const internal::Packet& packet) {
   Result encoded = packet.Encode(buffer.buffer_);
-  buffer.buffer_ = {};
 
   if (!encoded.ok()) {
-    PW_LOG_ERROR("Failed to encode response packet to channel buffer");
-    output().SendAndReleaseBuffer(0);
+    PW_LOG_ERROR("Failed to encode RPC response packet to channel %u buffer",
+                 static_cast<unsigned>(id()));
+    output().DiscardBuffer(buffer.buffer_);
+    buffer.buffer_ = {};
     return Status::Internal();
   }
 
-  return output().SendAndReleaseBuffer(encoded.value().size());
+  buffer.buffer_ = {};
+  return output().SendAndReleaseBuffer(encoded.value());
 }
 
 }  // namespace pw::rpc::internal
diff --git a/pw_rpc/channel_test.cc b/pw_rpc/channel_test.cc
index cdddc14..7687a2d 100644
--- a/pw_rpc/channel_test.cc
+++ b/pw_rpc/channel_test.cc
@@ -28,7 +28,9 @@
    public:
     NameTester(const char* name) : ChannelOutput(name) {}
     std::span<std::byte> AcquireBuffer() override { return {}; }
-    Status SendAndReleaseBuffer(size_t) override { return Status::Ok(); }
+    Status SendAndReleaseBuffer(std::span<const std::byte>) override {
+      return OkStatus();
+    }
   };
 
   EXPECT_STREQ("hello_world", NameTester("hello_world").name());
@@ -40,6 +42,18 @@
                              5 /* method */ + 2 /* payload key */ +
                              2 /* status */;
 
+enum class ChannelId {
+  kOne = 1,
+  kTwo = 2,
+};
+
+TEST(Channel, Create_FromEnum) {
+  constexpr rpc::Channel one = Channel::Create<ChannelId::kOne>(nullptr);
+  constexpr rpc::Channel two = Channel::Create<ChannelId::kTwo>(nullptr);
+  static_assert(one.id() == 1);
+  static_assert(two.id() == 2);
+}
+
 TEST(Channel, TestPacket_ReservedSizeMatchesMinEncodedSizeBytes) {
   EXPECT_EQ(kReservedSize, kTestPacket.MinEncodedSizeBytes());
 }
@@ -72,7 +86,7 @@
   EXPECT_EQ(payload.size(), output.buffer().size() - kReservedSize);
   EXPECT_EQ(output.buffer().data() + kReservedSize, payload.data());
 
-  EXPECT_EQ(Status::Ok(), channel.Send(output_buffer, kTestPacket));
+  EXPECT_EQ(OkStatus(), channel.Send(output_buffer, kTestPacket));
 }
 
 TEST(Channel, OutputBuffer_PayloadDoesNotFit_ReportsError) {
@@ -96,7 +110,7 @@
   EXPECT_EQ(payload.size(), output.buffer().size() - kReservedSize);
   EXPECT_EQ(output.buffer().data() + kReservedSize, payload.data());
 
-  EXPECT_EQ(Status::Ok(), channel.Send(output_buffer, kTestPacket));
+  EXPECT_EQ(OkStatus(), channel.Send(output_buffer, kTestPacket));
 }
 
 TEST(Channel, OutputBuffer_ReturnsStatusFromChannelOutputSend) {
diff --git a/pw_rpc/client.cc b/pw_rpc/client.cc
index 07c26ee..b5f3f5d 100644
--- a/pw_rpc/client.cc
+++ b/pw_rpc/client.cc
@@ -79,7 +79,7 @@
       return Status::Unimplemented();
   }
 
-  return Status::Ok();
+  return OkStatus();
 }
 
 Status Client::RegisterCall(BaseClientCall& call) {
@@ -95,7 +95,7 @@
   }
 
   calls_.push_front(call);
-  return Status::Ok();
+  return OkStatus();
 }
 
 }  // namespace pw::rpc
diff --git a/pw_rpc/client_server.cc b/pw_rpc/client_server.cc
new file mode 100644
index 0000000..f0c34ab
--- /dev/null
+++ b/pw_rpc/client_server.cc
@@ -0,0 +1,30 @@
+// Copyright 2021 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.
+
+#include "pw_rpc/client_server.h"
+
+namespace pw::rpc {
+
+Status ClientServer::ProcessPacket(std::span<const std::byte> packet,
+                                   ChannelOutput& interface) {
+  Status status = server_.ProcessPacket(packet, interface);
+  if (status.IsInvalidArgument()) {
+    // INVALID_ARGUMENT indicates the packet is intended for a client.
+    status = client_.ProcessPacket(packet);
+  }
+
+  return status;
+}
+
+}  // namespace pw::rpc
diff --git a/pw_rpc/client_server_test.cc b/pw_rpc/client_server_test.cc
new file mode 100644
index 0000000..5104c66
--- /dev/null
+++ b/pw_rpc/client_server_test.cc
@@ -0,0 +1,85 @@
+// 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.
+
+#include "pw_rpc/client_server.h"
+
+#include "gtest/gtest.h"
+#include "pw_rpc/internal/packet.h"
+#include "pw_rpc/internal/raw_method_union.h"
+#include "pw_rpc/server_context.h"
+#include "pw_rpc/service.h"
+#include "pw_rpc_private/internal_test_utils.h"
+
+namespace pw::rpc::internal {
+namespace {
+
+constexpr uint32_t kFakeChannelId = 1;
+constexpr uint32_t kFakeServiceId = 3;
+constexpr uint32_t kFakeMethodId = 10;
+
+TestOutput<32> output;
+rpc::Channel channels[] = {Channel::Create<kFakeChannelId>(&output)};
+
+StatusWithSize FakeMethod(ServerContext&, ConstByteSpan, ByteSpan) {
+  return StatusWithSize::Unimplemented();
+}
+
+class FakeService : public Service {
+ public:
+  FakeService(uint32_t id) : Service(id, kMethods) {}
+
+  static constexpr std::array<RawMethodUnion, 1> kMethods = {
+      RawMethod::Unary<FakeMethod>(kFakeMethodId),
+  };
+};
+
+FakeService service(kFakeServiceId);
+
+TEST(ClientServer, ProcessPacket_CallsServer) {
+  ClientServer client_server(channels);
+  client_server.server().RegisterService(service);
+
+  Packet packet(
+      PacketType::REQUEST, kFakeChannelId, kFakeServiceId, kFakeMethodId);
+  std::array<std::byte, 32> buffer;
+  Result result = packet.Encode(buffer);
+  EXPECT_EQ(result.status(), OkStatus());
+
+  EXPECT_EQ(client_server.ProcessPacket(result.value(), output), OkStatus());
+}
+
+TEST(ClientServer, ProcessPacket_CallsClient) {
+  ClientServer client_server(channels);
+  client_server.server().RegisterService(service);
+
+  // Same packet as above, but type RESPONSE will skip the server and call into
+  // the client.
+  Packet packet(
+      PacketType::RESPONSE, kFakeChannelId, kFakeServiceId, kFakeMethodId);
+  std::array<std::byte, 32> buffer;
+  Result result = packet.Encode(buffer);
+  EXPECT_EQ(result.status(), OkStatus());
+
+  // No calls are registered on the client, so this should fail.
+  EXPECT_EQ(client_server.ProcessPacket(result.value(), output),
+            Status::NotFound());
+}
+
+TEST(ClientServer, ProcessPacket_BadData) {
+  ClientServer client_server(channels);
+  EXPECT_EQ(client_server.ProcessPacket({}, output), Status::DataLoss());
+}
+
+}  // namespace
+}  // namespace pw::rpc::internal
diff --git a/pw_rpc/client_test.cc b/pw_rpc/client_test.cc
index 3174cfc..a5993fe 100644
--- a/pw_rpc/client_test.cc
+++ b/pw_rpc/client_test.cc
@@ -48,8 +48,8 @@
   ClientContextForTest context;
 
   TestClientCall call(
-      &context.channel(), context.kServiceId, context.kMethodId);
-  EXPECT_EQ(context.SendResponse(Status::Ok(), {}), Status::Ok());
+      &context.channel(), context.service_id(), context.method_id());
+  EXPECT_EQ(context.SendResponse(OkStatus(), {}), OkStatus());
 
   EXPECT_TRUE(call.invoked());
 }
@@ -57,14 +57,14 @@
 TEST(Client, ProcessPacket_SendsClientErrorOnUnregisteredCall) {
   ClientContextForTest context;
 
-  EXPECT_EQ(context.SendResponse(Status::OK, {}), Status::NotFound());
+  EXPECT_EQ(context.SendResponse(OkStatus(), {}), Status::NotFound());
 
   ASSERT_EQ(context.output().packet_count(), 1u);
   const Packet& packet = context.output().sent_packet();
   EXPECT_EQ(packet.type(), PacketType::CLIENT_ERROR);
-  EXPECT_EQ(packet.channel_id(), context.kChannelId);
-  EXPECT_EQ(packet.service_id(), context.kServiceId);
-  EXPECT_EQ(packet.method_id(), context.kMethodId);
+  EXPECT_EQ(packet.channel_id(), context.channel_id());
+  EXPECT_EQ(packet.service_id(), context.service_id());
+  EXPECT_EQ(packet.method_id(), context.method_id());
   EXPECT_TRUE(packet.payload().empty());
   EXPECT_EQ(packet.status(), Status::FailedPrecondition());
 }
diff --git a/pw_rpc/docs.rst b/pw_rpc/docs.rst
index 2e62c31..daece79 100644
--- a/pw_rpc/docs.rst
+++ b/pw_rpc/docs.rst
@@ -6,11 +6,19 @@
 The ``pw_rpc`` module provides a system for defining and invoking remote
 procedure calls (RPCs) on a device.
 
+This document discusses the ``pw_rpc`` protocol and its C++ implementation.
+``pw_rpc`` implementations for other languages are described in their own
+documents:
+
+.. toctree::
+  :maxdepth: 1
+
+  py/docs
+
 .. admonition:: Try it out!
 
   For a quick intro to ``pw_rpc``, see the
-  :ref:`module-pw_hdlc_lite-rpc-example` in the :ref:`module-pw_hdlc_lite`
-  module.
+  :ref:`module-pw_hdlc-rpc-example` in the :ref:`module-pw_hdlc` module.
 
 .. attention::
 
@@ -56,21 +64,99 @@
     sources = [ "foo_bar/the_service.proto" ]
   }
 
-2. RPC service definition
--------------------------
-``pw_rpc`` generates a C++ base class for each RPC service declared in a .proto
-file. The serivce class is implemented by inheriting from this generated base
-and defining a method for each RPC.
+.. admonition:: proto2 or proto3 syntax?
 
-A service named ``TheService`` in package ``foo.bar`` will generate the
-following class:
+  Always use proto3 syntax rather than proto2 for new protocol buffers. Proto2
+  protobufs can be compiled for ``pw_rpc``, but they are not as well supported
+  as proto3. Specifically, ``pw_rpc`` lacks support for non-zero default values
+  in proto2. When using Nanopb with ``pw_rpc``, proto2 response protobufs with
+  non-zero field defaults should be manually initialized to the default struct.
+
+  In the past, proto3 was sometimes avoided because it lacked support for field
+  presence detection. Fortunately, this has been fixed: proto3 now supports
+  ``optional`` fields, which are equivalent to proto2 ``optional`` fields.
+
+  If you need to distinguish between a default-valued field and a missing field,
+  mark the field as ``optional``. The presence of the field can be detected
+  with a ``HasField(name)`` or ``has_<field>`` member, depending on the library.
+
+  Optional fields have some overhead --- default-valued fields are included in
+  the encoded proto, and, if using Nanopb, the proto structs have a
+  ``has_<field>`` flag for each optional field. Use plain fields if field
+  presence detection is not needed.
+
+  .. code-block:: protobuf
+
+    syntax = "proto3";
+
+    message MyMessage {
+      // Leaving this field unset is equivalent to setting it to 0.
+      int32 number = 1;
+
+      // Setting this field to 0 is different from leaving it unset.
+      optional int32 other_number = 2;
+    }
+
+2. RPC code generation
+----------------------
+``pw_rpc`` generates a C++ header file for each ``.proto`` file. This header is
+generated in the build output directory. Its exact location varies by build
+system and toolchain, but the C++ include path always matches the sources
+declaration in the ``pw_proto_library``. The ``.proto`` extension is replaced
+with an extension corresponding to the protobuf library in use.
+
+================== =============== =============== =============
+Protobuf libraries Build subtarget Protobuf header pw_rpc header
+================== =============== =============== =============
+Raw only           .raw_rpc        (none)          .raw_rpc.pb.h
+Nanopb or raw      .nanopb_rpc     .pb.h           .rpc.pb.h
+pw_protobuf or raw .pwpb_rpc       .pwpb.h         .rpc.pwpb.h
+================== =============== =============== =============
+
+For example, the generated RPC header for ``"foo_bar/the_service.proto"`` is
+``"foo_bar/the_service.rpc.pb.h"`` for Nanopb or
+``"foo_bar/the_service.raw_rpc.pb.h"`` for raw RPCs.
+
+The generated header defines a base class for each RPC service declared in the
+``.proto`` file. A service named ``TheService`` in package ``foo.bar`` would
+generate the following base class:
 
 .. cpp:class:: template <typename Implementation> foo::bar::generated::TheService
 
+3. RPC service definition
+-------------------------
+The serivce class is implemented by inheriting from the generated RPC service
+base class and defining a method for each RPC. The methods must match the name
+and function signature for one of the supported protobuf implementations.
+Services may mix and match protobuf implementations within one service.
+
+.. tip::
+
+  The generated code includes RPC service implementation stubs. You can
+  reference or copy and paste these to get started with implementing a service.
+  These stub classes are generated at the bottom of the pw_rpc proto header.
+
+  To use the stubs, do the following:
+
+  #. Locate the generated RPC header in the build directory. For example:
+
+     .. code-block:: sh
+
+       find out/ -name <proto_name>.rpc.pb.h
+
+  #. Scroll to the bottom of the generated RPC header.
+  #. Copy the stub class declaration to a header file.
+  #. Copy the member function definitions to a source file.
+  #. Rename the class or change the namespace, if desired.
+  #. List these files in a build target with a dependency on the
+     ``pw_proto_library``.
+
 A Nanopb implementation of this service would be as follows:
 
 .. code-block:: cpp
 
+  #include "foo_bar/the_service.rpc.pb.h"
+
   namespace foo::bar {
 
   class TheService : public generated::TheService<TheService> {
@@ -79,7 +165,7 @@
                          const foo_bar_Request& request,
                          foo_bar_Response& response) {
       // implementation
-      return pw::Status::OK;
+      return pw::OkStatus();
     }
 
     void MethodTwo(ServerContext& ctx,
@@ -103,7 +189,7 @@
   pw_source_set("the_service") {
     public_configs = [ ":public" ]
     public = [ "public/foo_bar/service.h" ]
-    public_deps = [ ":the_service_proto_nanopb_rpc" ]
+    public_deps = [ ":the_service_proto.nanopb_rpc" ]
   }
 
 .. attention::
@@ -111,9 +197,9 @@
   pw_rpc's generated classes will support using ``pw_protobuf`` or raw buffers
   (no protobuf library) in the future.
 
-3. Register the service with a server
+4. Register the service with a server
 -------------------------------------
-This example code sets up an RPC server with an :ref:`HDLC<module-pw_hdlc_lite>`
+This example code sets up an RPC server with an :ref:`HDLC<module-pw_hdlc>`
 channel output and the example service.
 
 .. code-block:: cpp
@@ -123,7 +209,7 @@
   // adapt this as necessary.
   pw::stream::SysIoWriter writer;
   pw::rpc::RpcChannelOutput<kMaxTransmissionUnit> hdlc_channel_output(
-      writer, pw::hdlc_lite::kDefaultRpcAddress, "HDLC output");
+      writer, pw::hdlc::kDefaultRpcAddress, "HDLC output");
 
   pw::rpc::Channel channels[] = {
       pw::rpc::Channel::Create<1>(&hdlc_channel_output)};
@@ -148,10 +234,37 @@
     std::array<std::byte, kMaxTransmissionUnit> input_buffer;
 
     PW_LOG_INFO("Starting pw_rpc server");
-    pw::hdlc_lite::ReadAndProcessPackets(
+    pw::hdlc::ReadAndProcessPackets(
         server, hdlc_channel_output, input_buffer);
   }
 
+Channels
+========
+``pw_rpc`` sends all of its packets over channels. These are logical,
+application-layer routes used to tell the RPC system where a packet should go.
+
+Channels over a client-server connection must all have a unique ID, which can be
+assigned statically at compile time or dynamically.
+
+.. code-block:: cpp
+
+  // Creating a channel with the static ID 3.
+  pw::rpc::Channel static_channel = pw::rpc::Channel::Create<3>(&output);
+
+  // Grouping channel IDs within an enum can lead to clearer code.
+  enum ChannelId {
+    kUartChannel = 1,
+    kSpiChannel = 2,
+  };
+
+  // Creating a channel with a static ID defined within an enum.
+  pw::rpc::Channel another_static_channel =
+      pw::rpc::Channel::Create<ChannelId::kUartChannel>(&output);
+
+  // Creating a channel with a dynamic ID (note that no output is provided; it
+  // will be set when the channel is used.
+  pw::rpc::Channel dynamic_channel;
+
 Services
 ========
 A service is a logical grouping of RPCs defined within a .proto file. ``pw_rpc``
@@ -175,9 +288,9 @@
 ============================
 After setting up a ``pw_rpc`` server in your project, you can test that it is
 working as intended by registering the provided ``EchoService``, defined in
-``pw_rpc_protos/echo.proto``, which echoes back a message that it receives.
+``echo.proto``, which echoes back a message that it receives.
 
-.. literalinclude:: pw_rpc_protos/echo.proto
+.. literalinclude:: echo.proto
   :language: protobuf
   :lines: 14-
 
@@ -209,9 +322,9 @@
 -------------
 Pigweed RPC packets consist of a type and a set of fields. The packets are
 encoded as protocol buffers. The full packet format is described in
-``pw_rpc/pw_rpc_protos/packet.proto``.
+``pw_rpc/pw_rpc/internal/packet.proto``.
 
-.. literalinclude:: pw_rpc_protos/packet.proto
+.. literalinclude:: internal/packet.proto
   :language: protobuf
   :lines: 14-
 
@@ -604,26 +717,18 @@
 ^^^^^^^^^^^^^^^^
 The RPC Server depends on the ``pw::rpc::internal::Method`` class. ``Method``
 serves as the bridge between the ``pw_rpc`` server library and the user-defined
-RPC functions. ``Method`` takes an RPC packet, decodes it using a protobuf
-library (if applicable), and calls the RPC function. Since ``Method`` interacts
-directly with the protobuf library, it must be implemented separately for each
-protobuf library.
+RPC functions. Each supported protobuf implementation extends ``Method`` to
+implement its request and response proto handling. The ``pw_rpc`` server
+calls into the ``Method`` implementation through the base class's ``Invoke``
+function.
 
-``pw::rpc::internal::Method`` is not implemented as a facade with different
-backends. Instead, there is a separate instance of the ``pw_rpc`` server library
-for each ``Method`` implementation. There are a few reasons for this.
+``Method`` implementations store metadata about each method, including a
+function pointer to the user-defined method implementation. They also provide
+``static constexpr`` functions for creating each type of method. ``Method``
+implementations must satisfy the ``MethodImplTester`` test class in
+``pw_rpc_private/method_impl_tester.h``.
 
-* ``Method`` is entirely internal to ``pw_rpc``. Users will never implement a
-  custom backend. Exposing a facade would unnecessarily expose implementation
-  details and make ``pw_rpc`` more difficult to use.
-* There is no common interface between ``pw_rpc`` / ``Method`` implementations.
-  It's not possible to swap between e.g. a Nanopb and a ``pw_protobuf`` RPC
-  server because the interface for the user-implemented RPCs changes completely.
-  This nullifies the primary benefit of facades.
-* The different ``Method`` implementations can be built easily alongside one
-  another in a cross-platform way. This makes testing simpler, since the tests
-  build with any backend configuration. Users can select which ``Method``
-  implementation to use simply by depending on the corresponding server library.
+See ``pw_rpc/internal/method.h`` for more details about ``Method``.
 
 Packet flow
 ^^^^^^^^^^^
@@ -743,3 +848,26 @@
 The RPC server stores a list of all of active ``ClientCall`` objects. When an
 incoming packet is recieved, it dispatches to one of its active calls, which
 then decodes the payload and presents it to the user.
+
+ClientServer
+============
+Sometimes, a device needs to both process RPCs as a server, as well as making
+calls to another device as a client. To do this, both a client and server must
+be set up, and incoming packets must be sent to both of them.
+
+Pigweed simplifies this setup by providing a ``ClientServer`` class which wraps
+an RPC client and server with the same set of channels.
+
+.. code-block:: cpp
+
+  pw::rpc::Channel channels[] = {
+      pw::rpc::Channel::Create<1>(&channel_output)};
+
+  // Creates both a client and a server.
+  pw::rpc::ClientServer client_server(channels);
+
+  void ProcessRpcData(pw::ConstByteSpan packet) {
+    // Calls into both the client and the server, sending the packet to the
+    // appropriate one.
+    client_server.ProcessPacket(packet, output);
+  }
diff --git a/pw_rpc/pw_rpc_protos/echo.options b/pw_rpc/echo.options
similarity index 100%
rename from pw_rpc/pw_rpc_protos/echo.options
rename to pw_rpc/echo.options
diff --git a/pw_rpc/pw_rpc_protos/echo.proto b/pw_rpc/echo.proto
similarity index 100%
rename from pw_rpc/pw_rpc_protos/echo.proto
rename to pw_rpc/echo.proto
diff --git a/pw_rpc/pw_rpc_protos/packet.proto b/pw_rpc/internal/packet.proto
similarity index 100%
rename from pw_rpc/pw_rpc_protos/packet.proto
rename to pw_rpc/internal/packet.proto
diff --git a/pw_rpc/nanopb/BUILD.gn b/pw_rpc/nanopb/BUILD.gn
index 39edd13..c2e7788 100644
--- a/pw_rpc/nanopb/BUILD.gn
+++ b/pw_rpc/nanopb/BUILD.gn
@@ -30,6 +30,7 @@
   sources = [ "nanopb_method.cc" ]
   public_deps = [
     ":common",
+    "..:config",
     "..:server",
   ]
   deps = [ dir_pw_log ]
@@ -65,21 +66,11 @@
   }
 }
 
-pw_source_set("service_method_traits") {
-  public_configs = [ ":public" ]
-  public = [ "public/pw_rpc/internal/nanopb_service_method_traits.h" ]
-  public_deps = [
-    ":method_union",
-    "..:service_method_traits",
-  ]
-}
-
 pw_source_set("test_method_context") {
   public_configs = [ ":public" ]
   public = [ "public/pw_rpc/nanopb_test_method_context.h" ]
   public_deps = [
-    ":service_method_traits",
-    "..:server",
+    ":method",
     dir_pw_assert,
     dir_pw_containers,
   ]
@@ -95,7 +86,7 @@
 
 pw_source_set("echo_service") {
   public_configs = [ ":public" ]
-  public_deps = [ "..:echo_service_proto.nanopb_rpc" ]
+  public_deps = [ "..:protos.nanopb_rpc" ]
   sources = [ "public/pw_rpc/echo_service_nanopb.h" ]
 }
 
@@ -108,9 +99,10 @@
     ":client_call_test",
     ":codegen_test",
     ":echo_service_test",
+    ":method_lookup_test",
     ":nanopb_method_test",
     ":nanopb_method_union_test",
-    ":nanopb_service_method_traits_test",
+    ":stub_generation_test",
   ]
 }
 
@@ -150,6 +142,18 @@
   enable_if = dir_pw_third_party_nanopb != ""
 }
 
+pw_test("method_lookup_test") {
+  deps = [
+    ":method",
+    ":test_method_context",
+    "..:test_protos.nanopb_rpc",
+    "..:test_utils",
+    "../raw:test_method_context",
+  ]
+  sources = [ "method_lookup_test.cc" ]
+  enable_if = dir_pw_third_party_nanopb != ""
+}
+
 pw_test("nanopb_method_union_test") {
   deps = [
     ":internal_test_utils",
@@ -171,14 +175,8 @@
   enable_if = dir_pw_third_party_nanopb != ""
 }
 
-pw_test("nanopb_service_method_traits_test") {
-  deps = [
-    ":echo_service",
-    ":method",
-    ":service_method_traits",
-    ":test_method_context",
-    "..:test_protos.nanopb_rpc",
-  ]
-  sources = [ "nanopb_service_method_traits_test.cc" ]
+pw_test("stub_generation_test") {
+  deps = [ "..:test_protos.nanopb_rpc" ]
+  sources = [ "stub_generation_test.cc" ]
   enable_if = dir_pw_third_party_nanopb != ""
 }
diff --git a/pw_rpc/nanopb/CMakeLists.txt b/pw_rpc/nanopb/CMakeLists.txt
index 896fefd..87552a0 100644
--- a/pw_rpc/nanopb/CMakeLists.txt
+++ b/pw_rpc/nanopb/CMakeLists.txt
@@ -52,7 +52,7 @@
 
 pw_add_module_library(pw_rpc.nanopb.echo_service
   PUBLIC_DEPS
-    pw_rpc.echo_proto.nanopb_rpc
+    pw_rpc.protos.nanopb_rpc
 )
 
 pw_auto_add_module_tests(pw_rpc.nanopb
@@ -61,7 +61,7 @@
     pw_rpc.raw
     pw_rpc.server
     pw_rpc.nanopb.common
-    pw_rpc.echo_proto.nanopb_rpc
+    pw_rpc.protos.nanopb_rpc
     pw_rpc.test_protos.nanopb_rpc
     pw_rpc.test_utils
 )
diff --git a/pw_rpc/nanopb/codegen_test.cc b/pw_rpc/nanopb/codegen_test.cc
index 7c7667e..5b7241a 100644
--- a/pw_rpc/nanopb/codegen_test.cc
+++ b/pw_rpc/nanopb/codegen_test.cc
@@ -31,9 +31,10 @@
     return static_cast<Status::Code>(request.status_code);
   }
 
-  void TestStreamRpc(ServerContext&,
-                     const pw_rpc_test_TestRequest& request,
-                     ServerWriter<pw_rpc_test_TestStreamResponse>& writer) {
+  static void TestStreamRpc(
+      ServerContext&,
+      const pw_rpc_test_TestRequest& request,
+      ServerWriter<pw_rpc_test_TestStreamResponse>& writer) {
     for (int i = 0; i < request.integer; ++i) {
       writer.Write({.chunk = {}, .number = static_cast<uint32_t>(i)});
     }
@@ -55,8 +56,8 @@
 TEST(NanopbCodegen, Server_InvokeUnaryRpc) {
   PW_NANOPB_TEST_METHOD_CONTEXT(test::TestService, TestRpc) context;
 
-  EXPECT_EQ(Status::Ok(),
-            context.call({.integer = 123, .status_code = Status::Ok().code()}));
+  EXPECT_EQ(OkStatus(),
+            context.call({.integer = 123, .status_code = OkStatus().code()}));
 
   EXPECT_EQ(124, context.response().value);
 
@@ -76,7 +77,7 @@
   EXPECT_TRUE(context.responses().empty());
   EXPECT_EQ(0u, context.total_responses());
 
-  context.call({.integer = 4, .status_code = Status::Ok().code()});
+  context.call({.integer = 4, .status_code = OkStatus().code()});
 
   ASSERT_EQ(4u, context.responses().size());
   ASSERT_EQ(4u, context.total_responses());
@@ -85,7 +86,7 @@
     EXPECT_EQ(context.responses()[i].number, i);
   }
 
-  EXPECT_EQ(Status::Ok().code(), context.status());
+  EXPECT_EQ(OkStatus().code(), context.status());
 }
 
 TEST(NanopbCodegen,
@@ -151,9 +152,9 @@
   EXPECT_EQ(sent_proto.integer, 123);
 
   PW_ENCODE_PB(pw_rpc_test_TestResponse, response, .value = 42);
-  context.SendResponse(Status::Ok(), response);
+  context.SendResponse(OkStatus(), response);
   ASSERT_EQ(handler.responses_received(), 1u);
-  EXPECT_EQ(handler.last_status(), Status::Ok());
+  EXPECT_EQ(handler.last_status(), OkStatus());
   EXPECT_EQ(handler.last_response().value, 42);
 }
 
@@ -176,7 +177,7 @@
 
   PW_ENCODE_PB(
       pw_rpc_test_TestStreamResponse, response, .chunk = {}, .number = 11u);
-  context.SendResponse(Status::Ok(), response);
+  context.SendResponse(OkStatus(), response);
   ASSERT_EQ(handler.responses_received(), 1u);
   EXPECT_EQ(handler.last_response().number, 11u);
 
diff --git a/pw_rpc/nanopb/docs.rst b/pw_rpc/nanopb/docs.rst
index 5e3b0d2..0d61766 100644
--- a/pw_rpc/nanopb/docs.rst
+++ b/pw_rpc/nanopb/docs.rst
@@ -114,7 +114,7 @@
   Writes a single response message to the stream. The returned status indicates
   whether the write was successful.
 
-.. cpp:function:: void ServerWriter::Finish(Status status = Status::OK)
+.. cpp:function:: void ServerWriter::Finish(Status status = OkStatus())
 
   Closes the stream and sends back the RPC's overall status to the client.
 
diff --git a/pw_rpc/nanopb/echo_service_test.cc b/pw_rpc/nanopb/echo_service_test.cc
index 31fbb7f..11a0608 100644
--- a/pw_rpc/nanopb/echo_service_test.cc
+++ b/pw_rpc/nanopb/echo_service_test.cc
@@ -21,13 +21,13 @@
 
 TEST(EchoService, Echo_EchoesRequestMessage) {
   PW_NANOPB_TEST_METHOD_CONTEXT(EchoService, Echo) context;
-  ASSERT_EQ(context.call(_pw_rpc_EchoMessage{"Hello, world"}), Status::Ok());
+  ASSERT_EQ(context.call(_pw_rpc_EchoMessage{"Hello, world"}), OkStatus());
   EXPECT_STREQ(context.response().msg, "Hello, world");
 }
 
 TEST(EchoService, Echo_EmptyRequest) {
   PW_NANOPB_TEST_METHOD_CONTEXT(EchoService, Echo) context;
-  ASSERT_EQ(context.call({.msg = {}}), Status::Ok());
+  ASSERT_EQ(context.call({.msg = {}}), OkStatus());
   EXPECT_STREQ(context.response().msg, "");
 }
 
diff --git a/pw_rpc/nanopb/method_lookup_test.cc b/pw_rpc/nanopb/method_lookup_test.cc
new file mode 100644
index 0000000..02137c8
--- /dev/null
+++ b/pw_rpc/nanopb/method_lookup_test.cc
@@ -0,0 +1,81 @@
+// 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.
+
+#include "gtest/gtest.h"
+#include "pw_rpc/nanopb_test_method_context.h"
+#include "pw_rpc/raw_test_method_context.h"
+#include "pw_rpc_test_protos/test.rpc.pb.h"
+
+namespace pw::rpc {
+namespace {
+
+class MixedService1 : public test::generated::TestService<MixedService1> {
+ public:
+  StatusWithSize TestRpc(ServerContext&, ConstByteSpan, ByteSpan) {
+    return StatusWithSize(123);
+  }
+
+  void TestStreamRpc(ServerContext&,
+                     const pw_rpc_test_TestRequest&,
+                     ServerWriter<pw_rpc_test_TestStreamResponse>&) {
+    called_streaming_method = true;
+  }
+
+  bool called_streaming_method = false;
+};
+
+class MixedService2 : public test::generated::TestService<MixedService2> {
+ public:
+  Status TestRpc(ServerContext&,
+                 const pw_rpc_test_TestRequest&,
+                 pw_rpc_test_TestResponse&) {
+    return Status::Unauthenticated();
+  }
+
+  void TestStreamRpc(ServerContext&, ConstByteSpan, RawServerWriter&) {
+    called_streaming_method = true;
+  }
+
+  bool called_streaming_method = false;
+};
+
+TEST(MixedService1, CallRawMethod) {
+  PW_RAW_TEST_METHOD_CONTEXT(MixedService1, TestRpc) context;
+  StatusWithSize sws = context.call({});
+  EXPECT_TRUE(sws.ok());
+  EXPECT_EQ(123u, sws.size());
+}
+
+TEST(MixedService1, CallNanopbMethod) {
+  PW_NANOPB_TEST_METHOD_CONTEXT(MixedService1, TestStreamRpc) context;
+  ASSERT_FALSE(context.service().called_streaming_method);
+  context.call({});
+  EXPECT_TRUE(context.service().called_streaming_method);
+}
+
+TEST(MixedService2, CallNanopbMethod) {
+  PW_NANOPB_TEST_METHOD_CONTEXT(MixedService2, TestRpc) context;
+  Status status = context.call({});
+  EXPECT_EQ(Status::Unauthenticated(), status);
+}
+
+TEST(MixedService2, CallRawMethod) {
+  PW_RAW_TEST_METHOD_CONTEXT(MixedService2, TestStreamRpc) context;
+  ASSERT_FALSE(context.service().called_streaming_method);
+  context.call({});
+  EXPECT_TRUE(context.service().called_streaming_method);
+}
+
+}  // namespace
+}  // namespace pw::rpc
diff --git a/pw_rpc/nanopb/nanopb_client_call_test.cc b/pw_rpc/nanopb/nanopb_client_call_test.cc
index 4659b95..171ae28 100644
--- a/pw_rpc/nanopb/nanopb_client_call_test.cc
+++ b/pw_rpc/nanopb/nanopb_client_call_test.cc
@@ -87,10 +87,10 @@
       context.channel(), {.integer = 123, .status_code = 0}, handler);
 
   PW_ENCODE_PB(pw_rpc_test_TestResponse, response, .value = 42);
-  context.SendResponse(Status::Ok(), response);
+  context.SendResponse(OkStatus(), response);
 
   ASSERT_EQ(handler.responses_received(), 1u);
-  EXPECT_EQ(handler.last_status(), Status::Ok());
+  EXPECT_EQ(handler.last_status(), OkStatus());
   EXPECT_EQ(handler.last_response().value, 42);
 }
 
@@ -103,7 +103,7 @@
 
   constexpr std::byte bad_payload[]{
       std::byte{0xab}, std::byte{0xcd}, std::byte{0xef}};
-  context.SendResponse(Status::Ok(), bad_payload);
+  context.SendResponse(OkStatus(), bad_payload);
 
   EXPECT_EQ(handler.responses_received(), 0u);
   EXPECT_EQ(handler.rpc_error(), Status::DataLoss());
@@ -168,19 +168,19 @@
       context.channel(), {.integer = 71, .status_code = 0}, handler);
 
   PW_ENCODE_PB(pw_rpc_test_TestStreamResponse, r1, .chunk = {}, .number = 11u);
-  context.SendResponse(Status::Ok(), r1);
+  context.SendResponse(OkStatus(), r1);
   EXPECT_TRUE(handler.active());
   EXPECT_EQ(handler.responses_received(), 1u);
   EXPECT_EQ(handler.last_response().number, 11u);
 
   PW_ENCODE_PB(pw_rpc_test_TestStreamResponse, r2, .chunk = {}, .number = 22u);
-  context.SendResponse(Status::Ok(), r2);
+  context.SendResponse(OkStatus(), r2);
   EXPECT_TRUE(handler.active());
   EXPECT_EQ(handler.responses_received(), 2u);
   EXPECT_EQ(handler.last_response().number, 22u);
 
   PW_ENCODE_PB(pw_rpc_test_TestStreamResponse, r3, .chunk = {}, .number = 33u);
-  context.SendResponse(Status::Ok(), r3);
+  context.SendResponse(OkStatus(), r3);
   EXPECT_TRUE(handler.active());
   EXPECT_EQ(handler.responses_received(), 3u);
   EXPECT_EQ(handler.last_response().number, 33u);
@@ -195,11 +195,11 @@
       context.channel(), {.integer = 71, .status_code = 0}, handler);
 
   PW_ENCODE_PB(pw_rpc_test_TestStreamResponse, r1, .chunk = {}, .number = 11u);
-  context.SendResponse(Status::Ok(), r1);
+  context.SendResponse(OkStatus(), r1);
   EXPECT_TRUE(handler.active());
 
   PW_ENCODE_PB(pw_rpc_test_TestStreamResponse, r2, .chunk = {}, .number = 22u);
-  context.SendResponse(Status::Ok(), r2);
+  context.SendResponse(OkStatus(), r2);
   EXPECT_TRUE(handler.active());
 
   // Close the stream.
@@ -207,7 +207,7 @@
                      Status::NotFound());
 
   PW_ENCODE_PB(pw_rpc_test_TestStreamResponse, r3, .chunk = {}, .number = 33u);
-  context.SendResponse(Status::Ok(), r3);
+  context.SendResponse(OkStatus(), r3);
   EXPECT_FALSE(handler.active());
 
   EXPECT_EQ(handler.responses_received(), 2u);
@@ -222,19 +222,19 @@
       context.channel(), {.integer = 71, .status_code = 0}, handler);
 
   PW_ENCODE_PB(pw_rpc_test_TestStreamResponse, r1, .chunk = {}, .number = 11u);
-  context.SendResponse(Status::Ok(), r1);
+  context.SendResponse(OkStatus(), r1);
   EXPECT_TRUE(handler.active());
   EXPECT_EQ(handler.responses_received(), 1u);
   EXPECT_EQ(handler.last_response().number, 11u);
 
   constexpr std::byte bad_payload[]{
       std::byte{0xab}, std::byte{0xcd}, std::byte{0xef}};
-  context.SendResponse(Status::Ok(), bad_payload);
+  context.SendResponse(OkStatus(), bad_payload);
   EXPECT_EQ(handler.responses_received(), 1u);
   EXPECT_EQ(handler.rpc_error(), Status::DataLoss());
 
   PW_ENCODE_PB(pw_rpc_test_TestStreamResponse, r2, .chunk = {}, .number = 22u);
-  context.SendResponse(Status::Ok(), r2);
+  context.SendResponse(OkStatus(), r2);
   EXPECT_TRUE(handler.active());
   EXPECT_EQ(handler.responses_received(), 2u);
   EXPECT_EQ(handler.last_response().number, 22u);
diff --git a/pw_rpc/nanopb/nanopb_common.cc b/pw_rpc/nanopb/nanopb_common.cc
index d3339c6..4f90e1b 100644
--- a/pw_rpc/nanopb/nanopb_common.cc
+++ b/pw_rpc/nanopb/nanopb_common.cc
@@ -41,7 +41,7 @@
     return StatusWithSize::Internal();
   }
 
-  return StatusWithSize::Ok(output.bytes_written);
+  return StatusWithSize(output.bytes_written);
 }
 
 bool NanopbMethodSerde::Decode(NanopbMessageDescriptor fields,
diff --git a/pw_rpc/nanopb/nanopb_method.cc b/pw_rpc/nanopb/nanopb_method.cc
index bc96dc9..c86837d 100644
--- a/pw_rpc/nanopb/nanopb_method.cc
+++ b/pw_rpc/nanopb/nanopb_method.cc
@@ -53,7 +53,7 @@
     return true;
   }
 
-  PW_LOG_WARN("Failed to decode request payload from channel %u",
+  PW_LOG_WARN("Nanopb failed to decode request payload from channel %u",
               unsigned(channel.id()));
   channel.Send(Packet::ServerError(request, Status::DataLoss()));
   return false;
@@ -73,13 +73,23 @@
 
     response.set_payload(payload_buffer.first(encoded.size()));
     response.set_status(status);
-    if (channel.Send(response_buffer, response).ok()) {
+    pw::Status send_status = channel.Send(response_buffer, response);
+    if (send_status.ok()) {
       return;
     }
-  }
 
-  PW_LOG_WARN("Failed to encode response packet for channel %u",
-              unsigned(channel.id()));
+    PW_LOG_WARN("Failed to send response packet for channel %u, status %u",
+                unsigned(channel.id()),
+                send_status.code());
+
+    // Re-acquire the buffer to encode an error packet.
+    response_buffer = channel.AcquireBuffer();
+  } else {
+    PW_LOG_WARN(
+        "Nanopb failed to encode response packet for channel %u, status %u",
+        unsigned(channel.id()),
+        encoded.status().code());
+  }
   channel.Send(response_buffer,
                Packet::ServerError(request, Status::Internal()));
 }
diff --git a/pw_rpc/nanopb/nanopb_method_test.cc b/pw_rpc/nanopb/nanopb_method_test.cc
index a02cf89..d38c461 100644
--- a/pw_rpc/nanopb/nanopb_method_test.cc
+++ b/pw_rpc/nanopb/nanopb_method_test.cc
@@ -22,6 +22,7 @@
 #include "pw_rpc/service.h"
 #include "pw_rpc_nanopb_private/internal_test_utils.h"
 #include "pw_rpc_private/internal_test_utils.h"
+#include "pw_rpc_private/method_impl_tester.h"
 #include "pw_rpc_test_protos/test.pb.h"
 
 namespace pw::rpc::internal {
@@ -29,10 +30,105 @@
 
 using std::byte;
 
+struct FakePb {};
+
+// Create a fake service for use with the MethodImplTester.
+class TestNanopbService final : public Service {
+ public:
+  Status Unary(ServerContext&, const FakePb&, FakePb&) { return Status(); }
+
+  static Status StaticUnary(ServerContext&, const FakePb&, FakePb&) {
+    return Status();
+  }
+
+  void ServerStreaming(ServerContext&, const FakePb&, ServerWriter<FakePb>&) {}
+
+  static void StaticServerStreaming(ServerContext&,
+                                    const FakePb&,
+                                    ServerWriter<FakePb>&) {}
+
+  Status UnaryWrongArg(ServerContext&, FakePb&, FakePb&) { return Status(); }
+
+  static void StaticUnaryVoidReturn(ServerContext&, const FakePb&, FakePb&) {}
+
+  int ServerStreamingBadReturn(ServerContext&,
+                               const FakePb&,
+                               ServerWriter<FakePb>&) {
+    return 5;
+  }
+
+  static void StaticServerStreamingMissingArg(const FakePb&,
+                                              ServerWriter<FakePb>&) {}
+};
+
+// Test that the matches() function matches valid signatures.
+static_assert(NanopbMethod::template matches<&TestNanopbService::Unary,
+                                             FakePb,
+                                             FakePb>());
+static_assert(
+    NanopbMethod::template matches<&TestNanopbService::ServerStreaming,
+                                   FakePb,
+                                   FakePb>());
+static_assert(NanopbMethod::template matches<&TestNanopbService::StaticUnary,
+                                             FakePb,
+                                             FakePb>());
+static_assert(
+    NanopbMethod::template matches<&TestNanopbService::StaticServerStreaming,
+                                   FakePb,
+                                   FakePb>());
+
+// Test that the matches() function does not match the wrong method type.
+static_assert(!NanopbMethod::template matches<&TestNanopbService::UnaryWrongArg,
+                                              FakePb,
+                                              FakePb>());
+static_assert(
+    !NanopbMethod::template matches<&TestNanopbService::StaticUnaryVoidReturn,
+                                    FakePb,
+                                    FakePb>());
+static_assert(!NanopbMethod::template matches<
+              &TestNanopbService::ServerStreamingBadReturn,
+              FakePb,
+              FakePb>());
+static_assert(!NanopbMethod::template matches<
+              &TestNanopbService::StaticServerStreamingMissingArg,
+              FakePb,
+              FakePb>());
+
+struct WrongPb;
+
+// Test matches() rejects incorrect request/response types.
+static_assert(!NanopbMethod::template matches<&TestNanopbService::Unary,
+                                              WrongPb,
+                                              FakePb>());
+static_assert(!NanopbMethod::template matches<&TestNanopbService::Unary,
+                                              FakePb,
+                                              WrongPb>());
+static_assert(!NanopbMethod::template matches<&TestNanopbService::Unary,
+                                              WrongPb,
+                                              WrongPb>());
+static_assert(
+    !NanopbMethod::template matches<&TestNanopbService::ServerStreaming,
+                                    WrongPb,
+                                    FakePb>());
+
+static_assert(!NanopbMethod::template matches<&TestNanopbService::StaticUnary,
+                                              FakePb,
+                                              WrongPb>());
+static_assert(
+    !NanopbMethod::template matches<&TestNanopbService::StaticServerStreaming,
+                                    FakePb,
+                                    WrongPb>());
+
+TEST(MethodImplTester, NanopbMethod) {
+  constexpr MethodImplTester<NanopbMethod, TestNanopbService, nullptr, nullptr>
+      method_tester;
+  EXPECT_TRUE(method_tester.MethodImplIsValid());
+}
+
 pw_rpc_test_TestRequest last_request;
 ServerWriter<pw_rpc_test_TestResponse> last_writer;
 
-Status AddFive(ServerCall&,
+Status AddFive(ServerContext&,
                const pw_rpc_test_TestRequest& request,
                pw_rpc_test_TestResponse& response) {
   last_request = request;
@@ -40,11 +136,11 @@
   return Status::Unauthenticated();
 }
 
-Status DoNothing(ServerCall&, const pw_rpc_test_Empty&, pw_rpc_test_Empty&) {
+Status DoNothing(ServerContext&, const pw_rpc_test_Empty&, pw_rpc_test_Empty&) {
   return Status::Unknown();
 }
 
-void StartStream(ServerCall&,
+void StartStream(ServerContext&,
                  const pw_rpc_test_TestRequest& request,
                  ServerWriter<pw_rpc_test_TestResponse>& writer) {
   last_request = request;
@@ -99,7 +195,7 @@
   const Packet& packet = context.output().sent_packet();
   EXPECT_EQ(PacketType::SERVER_ERROR, packet.type());
   EXPECT_EQ(Status::DataLoss(), packet.status());
-  EXPECT_EQ(context.kServiceId, packet.service_id());
+  EXPECT_EQ(context.service_id(), packet.service_id());
   EXPECT_EQ(method.id(), packet.method_id());
 }
 
@@ -120,7 +216,7 @@
   const Packet& packet = context.output().sent_packet();
   EXPECT_EQ(PacketType::SERVER_ERROR, packet.type());
   EXPECT_EQ(Status::Internal(), packet.status());
-  EXPECT_EQ(context.kServiceId, packet.service_id());
+  EXPECT_EQ(context.service_id(), packet.service_id());
   EXPECT_EQ(method.id(), packet.method_id());
 
   EXPECT_EQ(value, last_request.integer);
@@ -147,12 +243,12 @@
 
   method.Invoke(context.get(), context.packet({}));
 
-  EXPECT_EQ(Status::Ok(), last_writer.Write({.value = 100}));
+  EXPECT_EQ(OkStatus(), last_writer.Write({.value = 100}));
 
   PW_ENCODE_PB(pw_rpc_test_TestResponse, payload, .value = 100);
   std::array<byte, 128> encoded_response = {};
   auto encoded = context.packet(payload).Encode(encoded_response);
-  ASSERT_EQ(Status::Ok(), encoded.status());
+  ASSERT_EQ(OkStatus(), encoded.status());
 
   ASSERT_EQ(encoded.value().size(), context.output().sent_data().size());
   EXPECT_EQ(0,
@@ -161,6 +257,33 @@
                         encoded.value().size()));
 }
 
+TEST(NanopbMethod, ServerWriter_WriteWhenClosed_ReturnsFailedPrecondition) {
+  const NanopbMethod& method =
+      std::get<2>(FakeService::kMethods).nanopb_method();
+  ServerContextForTest<FakeService> context(method);
+
+  method.Invoke(context.get(), context.packet({}));
+
+  EXPECT_EQ(OkStatus(), last_writer.Finish());
+  EXPECT_TRUE(last_writer.Write({.value = 100}).IsFailedPrecondition());
+}
+
+TEST(NanopbMethod, ServerWriter_WriteAfterMoved_ReturnsFailedPrecondition) {
+  const NanopbMethod& method =
+      std::get<2>(FakeService::kMethods).nanopb_method();
+  ServerContextForTest<FakeService> context(method);
+
+  method.Invoke(context.get(), context.packet({}));
+  ServerWriter<pw_rpc_test_TestResponse> new_writer = std::move(last_writer);
+
+  EXPECT_EQ(OkStatus(), new_writer.Write({.value = 100}));
+
+  EXPECT_EQ(Status::FailedPrecondition(), last_writer.Write({.value = 100}));
+  EXPECT_EQ(Status::FailedPrecondition(), last_writer.Finish());
+
+  EXPECT_EQ(OkStatus(), new_writer.Finish());
+}
+
 TEST(NanopbMethod,
      ServerStreamingRpc_ServerWriterBufferTooSmall_InternalError) {
   const NanopbMethod& method =
@@ -176,12 +299,12 @@
   // Verify that the encoded size of a packet with an empty payload is correct.
   std::array<byte, 128> encoded_response = {};
   auto encoded = context.packet({}).Encode(encoded_response);
-  ASSERT_EQ(Status::Ok(), encoded.status());
+  ASSERT_EQ(OkStatus(), encoded.status());
   ASSERT_EQ(kNoPayloadPacketSize, encoded.value().size());
 
   method.Invoke(context.get(), context.packet({}));
 
-  EXPECT_EQ(Status::Ok(), last_writer.Write({}));  // Barely fits
+  EXPECT_EQ(OkStatus(), last_writer.Write({}));  // Barely fits
   EXPECT_EQ(Status::Internal(), last_writer.Write({.value = 1}));  // Too big
 }
 
diff --git a/pw_rpc/nanopb/nanopb_method_union_test.cc b/pw_rpc/nanopb/nanopb_method_union_test.cc
index ccd8493..d1bfd7f 100644
--- a/pw_rpc/nanopb/nanopb_method_union_test.cc
+++ b/pw_rpc/nanopb/nanopb_method_union_test.cc
@@ -32,13 +32,25 @@
   constexpr FakeGeneratedService(uint32_t id) : Service(id, kMethods) {}
 
   static constexpr std::array<NanopbMethodUnion, 4> kMethods = {
-      GetNanopbOrRawMethodFor<&Implementation::DoNothing>(
+      GetNanopbOrRawMethodFor<&Implementation::DoNothing,
+                              MethodType::kUnary,
+                              pw_rpc_test_Empty,
+                              pw_rpc_test_Empty>(
           10u, pw_rpc_test_Empty_fields, pw_rpc_test_Empty_fields),
-      GetNanopbOrRawMethodFor<&Implementation::RawStream>(
+      GetNanopbOrRawMethodFor<&Implementation::RawStream,
+                              MethodType::kServerStreaming,
+                              pw_rpc_test_TestRequest,
+                              pw_rpc_test_TestResponse>(
           11u, pw_rpc_test_TestRequest_fields, pw_rpc_test_TestResponse_fields),
-      GetNanopbOrRawMethodFor<&Implementation::AddFive>(
+      GetNanopbOrRawMethodFor<&Implementation::AddFive,
+                              MethodType::kUnary,
+                              pw_rpc_test_TestRequest,
+                              pw_rpc_test_TestResponse>(
           12u, pw_rpc_test_TestRequest_fields, pw_rpc_test_TestResponse_fields),
-      GetNanopbOrRawMethodFor<&Implementation::StartStream>(
+      GetNanopbOrRawMethodFor<&Implementation::StartStream,
+                              MethodType::kServerStreaming,
+                              pw_rpc_test_TestRequest,
+                              pw_rpc_test_TestResponse>(
           13u, pw_rpc_test_TestRequest_fields, pw_rpc_test_TestResponse_fields),
   };
 };
@@ -97,7 +109,7 @@
   method.Invoke(context.get(), context.packet(request));
 
   EXPECT_TRUE(last_raw_writer.open());
-  last_raw_writer.Finish();
+  EXPECT_EQ(OkStatus(), last_raw_writer.Finish());
   EXPECT_EQ(context.output().sent_packet().type(),
             PacketType::SERVER_STREAM_END);
 }
@@ -139,7 +151,7 @@
   EXPECT_EQ(555, last_request.integer);
   EXPECT_TRUE(last_writer.open());
 
-  last_writer.Finish();
+  EXPECT_EQ(OkStatus(), last_writer.Finish());
   EXPECT_EQ(context.output().sent_packet().type(),
             PacketType::SERVER_STREAM_END);
 }
diff --git a/pw_rpc/nanopb/nanopb_service_method_traits_test.cc b/pw_rpc/nanopb/nanopb_service_method_traits_test.cc
deleted file mode 100644
index 8043e12..0000000
--- a/pw_rpc/nanopb/nanopb_service_method_traits_test.cc
+++ /dev/null
@@ -1,31 +0,0 @@
-// 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.
-
-#include "pw_rpc/internal/nanopb_service_method_traits.h"
-
-#include <type_traits>
-
-#include "pw_rpc/echo_service_nanopb.h"
-#include "pw_rpc/internal/hash.h"
-
-namespace pw::rpc::internal {
-namespace {
-
-static_assert(
-    std::is_same_v<decltype(NanopbServiceMethodTraits<&EchoService::Echo,
-                                                      Hash("Echo")>::method()),
-                   const NanopbMethod&>);
-
-}  // namespace
-}  // namespace pw::rpc::internal
diff --git a/pw_rpc/nanopb/public/pw_rpc/echo_service_nanopb.h b/pw_rpc/nanopb/public/pw_rpc/echo_service_nanopb.h
index 127c712..54f8e7f 100644
--- a/pw_rpc/nanopb/public/pw_rpc/echo_service_nanopb.h
+++ b/pw_rpc/nanopb/public/pw_rpc/echo_service_nanopb.h
@@ -15,7 +15,7 @@
 
 #include <cstring>
 
-#include "pw_rpc_protos/echo.rpc.pb.h"
+#include "pw_rpc/echo.rpc.pb.h"
 
 namespace pw::rpc {
 
@@ -25,7 +25,7 @@
               const pw_rpc_EchoMessage& request,
               pw_rpc_EchoMessage& response) {
     std::strncpy(response.msg, request.msg, sizeof(response.msg));
-    return Status::Ok();
+    return OkStatus();
   }
 };
 
diff --git a/pw_rpc/nanopb/public/pw_rpc/internal/nanopb_method.h b/pw_rpc/nanopb/public/pw_rpc/internal/nanopb_method.h
index 78b798c..6944e4a 100644
--- a/pw_rpc/nanopb/public/pw_rpc/internal/nanopb_method.h
+++ b/pw_rpc/nanopb/public/pw_rpc/internal/nanopb_method.h
@@ -20,6 +20,7 @@
 #include <type_traits>
 
 #include "pw_rpc/internal/base_server_writer.h"
+#include "pw_rpc/internal/config.h"
 #include "pw_rpc/internal/method.h"
 #include "pw_rpc/internal/method_type.h"
 #include "pw_rpc/internal/nanopb_common.h"
@@ -53,22 +54,14 @@
 
 namespace internal {
 
+class NanopbMethod;
 class Packet;
 
-// Templated false value for use in static_assert(false) statements.
-template <typename...>
-constexpr std::false_type kFalseValue{};
-
-// Extracts the request and response proto types from a method.
-template <typename Method>
-struct RpcTraits {
-  static_assert(kFalseValue<Method>,
-                "The selected function is not an RPC service method");
-};
-
-// Specialization for unary RPCs.
+// MethodTraits specialization for a static unary method.
 template <typename RequestType, typename ResponseType>
-struct RpcTraits<Status (*)(ServerCall&, const RequestType&, ResponseType&)> {
+struct MethodTraits<Status (*)(
+    ServerContext&, const RequestType&, ResponseType&)> {
+  using Implementation = NanopbMethod;
   using Request = RequestType;
   using Response = ResponseType;
 
@@ -77,10 +70,20 @@
   static constexpr bool kClientStreaming = false;
 };
 
-// Specialization for server streaming RPCs.
+// MethodTraits specialization for a unary method.
+template <typename T, typename RequestType, typename ResponseType>
+struct MethodTraits<Status (T::*)(
+    ServerContext&, const RequestType&, ResponseType&)>
+    : public MethodTraits<Status (*)(
+          ServerContext&, const RequestType&, ResponseType&)> {
+  using Service = T;
+};
+
+// MethodTraits specialization for a static server streaming method.
 template <typename RequestType, typename ResponseType>
-struct RpcTraits<void (*)(
-    ServerCall&, const RequestType&, ServerWriter<ResponseType>&)> {
+struct MethodTraits<void (*)(
+    ServerContext&, const RequestType&, ServerWriter<ResponseType>&)> {
+  using Implementation = NanopbMethod;
   using Request = RequestType;
   using Response = ResponseType;
 
@@ -89,29 +92,20 @@
   static constexpr bool kClientStreaming = false;
 };
 
-// Member function specialization for unary RPCs.
+// MethodTraits specialization for a server streaming method.
 template <typename T, typename RequestType, typename ResponseType>
-struct RpcTraits<Status (T::*)(
-    ServerContext&, const RequestType&, ResponseType&)>
-    : public RpcTraits<Status (*)(
-          ServerCall&, const RequestType&, ResponseType&)> {
-  using Service = T;
-};
-
-// Member function specialization for server streaming RPCs.
-template <typename T, typename RequestType, typename ResponseType>
-struct RpcTraits<void (T::*)(
+struct MethodTraits<void (T::*)(
     ServerContext&, const RequestType&, ServerWriter<ResponseType>&)>
-    : public RpcTraits<void (*)(
-          ServerCall&, const RequestType&, ServerWriter<ResponseType>&)> {
+    : public MethodTraits<void (*)(
+          ServerContext&, const RequestType&, ServerWriter<ResponseType>&)> {
   using Service = T;
 };
 
 template <auto method>
-using Request = typename RpcTraits<decltype(method)>::Request;
+using Request = typename MethodTraits<decltype(method)>::Request;
 
 template <auto method>
-using Response = typename RpcTraits<decltype(method)>::Response;
+using Response = typename MethodTraits<decltype(method)>::Response;
 
 // The NanopbMethod class invokes user-defined service methods. When a
 // pw::rpc::Server receives an RPC request packet, it looks up the matching
@@ -125,6 +119,13 @@
 // structs.
 class NanopbMethod : public Method {
  public:
+  template <auto method, typename RequestType, typename ResponseType>
+  static constexpr bool matches() {
+    return std::is_same_v<MethodImplementation<method>, NanopbMethod> &&
+           std::is_same_v<RequestType, Request<method>> &&
+           std::is_same_v<ResponseType, Response<method>>;
+  }
+
   // Creates a NanopbMethod for a unary RPC.
   template <auto method>
   static constexpr NanopbMethod Unary(uint32_t id,
@@ -136,18 +137,19 @@
     //
     // In optimized builds, the compiler inlines the user-defined function into
     // this wrapper, elminating any overhead.
-    return NanopbMethod(
-        id,
-        UnaryInvoker<AllocateSpaceFor<Request<method>>(),
-                     AllocateSpaceFor<Response<method>>()>,
-        Function{.unary =
-                     [](ServerCall& call, const void* req, void* resp) {
-                       return method(call,
-                                     *static_cast<const Request<method>*>(req),
-                                     *static_cast<Response<method>*>(resp));
-                     }},
-        request,
-        response);
+    constexpr UnaryFunction wrapper =
+        [](ServerCall& call, const void* req, void* resp) {
+          return CallMethodImplFunction<method>(
+              call,
+              *static_cast<const Request<method>*>(req),
+              *static_cast<Response<method>*>(resp));
+        };
+    return NanopbMethod(id,
+                        UnaryInvoker<AllocateSpaceFor<Request<method>>(),
+                                     AllocateSpaceFor<Response<method>>()>,
+                        Function{.unary = wrapper},
+                        request,
+                        response);
   }
 
   // Creates a NanopbMethod for a server-streaming RPC.
@@ -160,22 +162,26 @@
     // struct as void* and a BaseServerWriter instead of the templated
     // ServerWriter class. This wrapper is stored generically in the Function
     // union, defined below.
+    constexpr ServerStreamingFunction wrapper =
+        [](ServerCall& call, const void* req, BaseServerWriter& writer) {
+          return CallMethodImplFunction<method>(
+              call,
+              *static_cast<const Request<method>*>(req),
+              static_cast<ServerWriter<Response<method>>&>(writer));
+        };
     return NanopbMethod(
         id,
         ServerStreamingInvoker<AllocateSpaceFor<Request<method>>()>,
-        Function{.server_streaming =
-                     [](ServerCall& call,
-                        const void* req,
-                        BaseServerWriter& writer) {
-                       method(call,
-                              *static_cast<const Request<method>*>(req),
-                              static_cast<ServerWriter<Response<method>>&>(
-                                  writer));
-                     }},
+        Function{.server_streaming = wrapper},
         request,
         response);
   }
 
+  // Represents an invalid method. Used to reduce error message verbosity.
+  static constexpr NanopbMethod Invalid() {
+    return {0, InvalidInvoker, {}, nullptr, nullptr};
+  }
+
   // Encodes a response protobuf with Nanopb to the provided buffer.
   StatusWithSize EncodeResponse(const void* proto_struct,
                                 std::span<std::byte> buffer) const {
@@ -219,7 +225,7 @@
   // avoid generating unnecessary copies of the invoker functions.
   template <typename T>
   static constexpr size_t AllocateSpaceFor() {
-    return std::max(sizeof(T), size_t(64));
+    return std::max(sizeof(T), cfg::kNanopbStructMinBufferSize);
   }
 
   constexpr NanopbMethod(uint32_t id,
@@ -243,13 +249,15 @@
   // Invoker function for unary RPCs. Allocates request and response structs by
   // size, with maximum alignment, to avoid generating unnecessary copies of
   // this function for each request/response type.
-  template <size_t request_size, size_t response_size>
+  template <size_t kRequestSize, size_t kResponseSize>
   static void UnaryInvoker(const Method& method,
                            ServerCall& call,
                            const Packet& request) {
-    std::aligned_storage_t<request_size, alignof(std::max_align_t)>
+    _PW_RPC_NANOPB_STRUCT_STORAGE_CLASS
+    std::aligned_storage_t<kRequestSize, alignof(std::max_align_t)>
         request_struct{};
-    std::aligned_storage_t<response_size, alignof(std::max_align_t)>
+    _PW_RPC_NANOPB_STRUCT_STORAGE_CLASS
+    std::aligned_storage_t<kResponseSize, alignof(std::max_align_t)>
         response_struct{};
 
     static_cast<const NanopbMethod&>(method).CallUnary(
@@ -259,11 +267,12 @@
   // Invoker function for server streaming RPCs. Allocates space for a request
   // struct. Ignores the payload buffer since resposnes are sent through the
   // ServerWriter.
-  template <size_t request_size>
+  template <size_t kRequestSize>
   static void ServerStreamingInvoker(const Method& method,
                                      ServerCall& call,
                                      const Packet& request) {
-    std::aligned_storage_t<request_size, alignof(std::max_align_t)>
+    _PW_RPC_NANOPB_STRUCT_STORAGE_CLASS
+    std::aligned_storage_t<kRequestSize, alignof(std::max_align_t)>
         request_struct{};
 
     static_cast<const NanopbMethod&>(method).CallServerStreaming(
@@ -293,6 +302,10 @@
 
 template <typename T>
 Status ServerWriter<T>::Write(const T& response) {
+  if (!open()) {
+    return Status::FailedPrecondition();
+  }
+
   std::span<std::byte> buffer = AcquirePayloadBuffer();
 
   if (auto result =
diff --git a/pw_rpc/nanopb/public/pw_rpc/internal/nanopb_method_union.h b/pw_rpc/nanopb/public/pw_rpc/internal/nanopb_method_union.h
index eef6e47..da4ec30 100644
--- a/pw_rpc/nanopb/public/pw_rpc/internal/nanopb_method_union.h
+++ b/pw_rpc/nanopb/public/pw_rpc/internal/nanopb_method_union.h
@@ -40,86 +40,20 @@
   } impl_;
 };
 
-// Specialization for a nanopb unary method.
-template <typename T, typename RequestType, typename ResponseType>
-struct MethodTraits<Status (T::*)(
-    ServerContext&, const RequestType&, ResponseType&)> {
-  static constexpr MethodType kType = MethodType::kUnary;
-
-  using Service = T;
-  using Implementation = NanopbMethod;
-  using Request = RequestType;
-  using Response = ResponseType;
-};
-
-// Specialization for a nanopb server streaming method.
-template <typename T, typename RequestType, typename ResponseType>
-struct MethodTraits<void (T::*)(
-    ServerContext&, const RequestType&, ServerWriter<ResponseType>&)> {
-  static constexpr MethodType kType = MethodType::kServerStreaming;
-
-  using Service = T;
-  using Implementation = NanopbMethod;
-  using Request = RequestType;
-  using Response = ResponseType;
-};
-
-template <auto method>
-constexpr bool kIsNanopb =
-    std::is_same_v<MethodImplementation<method>, NanopbMethod>;
-
-// Deduces the type of an implemented nanopb service method from its signature,
-// and returns the appropriate Method object to invoke it.
-template <auto method>
-constexpr NanopbMethod GetNanopbMethodFor(
-    uint32_t id,
-    NanopbMessageDescriptor request_fields,
-    NanopbMessageDescriptor response_fields) {
-  static_assert(
-      kIsNanopb<method>,
-      "GetNanopbMethodFor should only be called on nanopb RPC methods");
-
-  using Traits = MethodTraits<decltype(method)>;
-  using ServiceImpl = typename Traits::Service;
-
-  if constexpr (Traits::kType == MethodType::kUnary) {
-    constexpr auto invoker = +[](ServerCall& call,
-                                 const typename Traits::Request& request,
-                                 typename Traits::Response& response) {
-      return (static_cast<ServiceImpl&>(call.service()).*method)(
-          call.context(), request, response);
-    };
-    return NanopbMethod::Unary<invoker>(id, request_fields, response_fields);
-  }
-
-  if constexpr (Traits::kType == MethodType::kServerStreaming) {
-    constexpr auto invoker =
-        +[](ServerCall& call,
-            const typename Traits::Request& request,
-            ServerWriter<typename Traits::Response>& writer) {
-          (static_cast<ServiceImpl&>(call.service()).*method)(
-              call.context(), request, writer);
-        };
-    return NanopbMethod::ServerStreaming<invoker>(
-        id, request_fields, response_fields);
-  }
-
-  constexpr auto fake_invoker =
-      +[](ServerCall&, const int&, ServerWriter<int>&) {};
-  return NanopbMethod::ServerStreaming<fake_invoker>(0, nullptr, nullptr);
-}
-
-// Returns either a raw or nanopb method object, depending on an implemented
+// Returns either a raw or nanopb method object, depending on the implemented
 // function's signature.
-template <auto method>
+template <auto method, MethodType type, typename Request, typename Response>
 constexpr auto GetNanopbOrRawMethodFor(
     uint32_t id,
     [[maybe_unused]] NanopbMessageDescriptor request_fields,
     [[maybe_unused]] NanopbMessageDescriptor response_fields) {
-  if constexpr (kIsRaw<method>) {
-    return GetRawMethodFor<method>(id);
+  if constexpr (RawMethod::matches<method>()) {
+    return GetMethodFor<method, RawMethod, type>(id);
+  } else if constexpr (NanopbMethod::matches<method, Request, Response>()) {
+    return GetMethodFor<method, NanopbMethod, type>(
+        id, request_fields, response_fields);
   } else {
-    return GetNanopbMethodFor<method>(id, request_fields, response_fields);
+    return InvalidMethod<method, type, RawMethod>(id);
   }
 };
 
diff --git a/pw_rpc/nanopb/public/pw_rpc/internal/nanopb_service_method_traits.h b/pw_rpc/nanopb/public/pw_rpc/internal/nanopb_service_method_traits.h
deleted file mode 100644
index 866ceef..0000000
--- a/pw_rpc/nanopb/public/pw_rpc/internal/nanopb_service_method_traits.h
+++ /dev/null
@@ -1,27 +0,0 @@
-// 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.
-#pragma once
-
-#include "pw_rpc/internal/nanopb_method_union.h"
-#include "pw_rpc/internal/service_method_traits.h"
-
-namespace pw::rpc::internal {
-
-template <auto impl_method, uint32_t method_id>
-using NanopbServiceMethodTraits =
-    ServiceMethodTraits<&MethodBaseService<impl_method>::NanopbMethodFor,
-                        impl_method,
-                        method_id>;
-
-}  // namespace pw::rpc::internal
diff --git a/pw_rpc/nanopb/public/pw_rpc/nanopb_test_method_context.h b/pw_rpc/nanopb/public/pw_rpc/nanopb_test_method_context.h
index 6ff26d9..1dbc1e1 100644
--- a/pw_rpc/nanopb/public/pw_rpc/nanopb_test_method_context.h
+++ b/pw_rpc/nanopb/public/pw_rpc/nanopb_test_method_context.h
@@ -16,13 +16,13 @@
 #include <tuple>
 #include <utility>
 
-#include "pw_assert/assert.h"
+#include "pw_assert/light.h"
 #include "pw_containers/vector.h"
 #include "pw_preprocessor/arguments.h"
 #include "pw_rpc/channel.h"
 #include "pw_rpc/internal/hash.h"
+#include "pw_rpc/internal/method_lookup.h"
 #include "pw_rpc/internal/nanopb_method.h"
-#include "pw_rpc/internal/nanopb_service_method_traits.h"
 #include "pw_rpc/internal/packet.h"
 #include "pw_rpc/internal/server.h"
 
@@ -36,7 +36,7 @@
 // struct can be accessed via context.response().
 //
 //   PW_NANOPB_TEST_METHOD_CONTEXT(my::CoolService, TheMethod) context;
-//   EXPECT_EQ(Status::Ok(), context.call({.some_arg = 123}));
+//   EXPECT_EQ(OkStatus(), context.call({.some_arg = 123}));
 //   EXPECT_EQ(500, context.response().some_response_value);
 //
 // For a server streaming RPC, context.call(request) invokes the method. As in a
@@ -47,7 +47,7 @@
 //   context.call({.some_arg = 123});
 //
 //   EXPECT_TRUE(context.done());  // Check that the RPC completed
-//   EXPECT_EQ(Status::Ok(), context.status());  // Check the status
+//   EXPECT_EQ(OkStatus(), context.status());  // Check the status
 //
 //   EXPECT_EQ(3u, context.responses().size());
 //   EXPECT_EQ(123, context.responses()[0].value); // check individual responses
@@ -63,23 +63,24 @@
 //
 // PW_NANOPB_TEST_METHOD_CONTEXT takes two optional arguments:
 //
-//   size_t max_responses: maximum responses to store; ignored unless streaming
-//   size_t output_size_bytes: buffer size; must be large enough for a packet
+//   size_t kMaxResponse: maximum responses to store; ignored unless streaming
+//   size_t kOutputSizeBytes: buffer size; must be large enough for a packet
 //
 // Example:
 //
 //   PW_NANOPB_TEST_METHOD_CONTEXT(MyService, BestMethod, 3, 256) context;
 //   ASSERT_EQ(3u, context.responses().max_size());
 //
-
 #define PW_NANOPB_TEST_METHOD_CONTEXT(service, method, ...)              \
-  ::pw::rpc::NanopbTestMethodContext<&service::method,                   \
+  ::pw::rpc::NanopbTestMethodContext<service,                            \
+                                     &service::method,                   \
                                      ::pw::rpc::internal::Hash(#method), \
                                      ##__VA_ARGS__>
-template <auto method,
-          uint32_t method_id,
-          size_t max_responses = 4,
-          size_t output_size_bytes = 128>
+template <typename Service,
+          auto method,
+          uint32_t kMethodId,
+          size_t kMaxResponse = 4,
+          size_t kOutputSizeBytes = 128>
 class NanopbTestMethodContext;
 
 // Internal classes that implement NanopbTestMethodContext.
@@ -111,7 +112,7 @@
  private:
   std::span<std::byte> AcquireBuffer() override { return buffer_; }
 
-  Status SendAndReleaseBuffer(size_t size) override;
+  Status SendAndReleaseBuffer(std::span<const std::byte> buffer) override;
 
   const internal::NanopbMethod& method_;
   Vector<Response>& responses_;
@@ -122,17 +123,18 @@
 };
 
 // Collects everything needed to invoke a particular RPC.
-template <auto method,
-          uint32_t method_id,
-          size_t max_responses,
-          size_t output_size>
+template <typename Service,
+          auto method,
+          uint32_t kMethodId,
+          size_t kMaxResponse,
+          size_t kOutputSize>
 struct InvocationContext {
   using Request = internal::Request<method>;
   using Response = internal::Response<method>;
 
   template <typename... Args>
   InvocationContext(Args&&... args)
-      : output(NanopbServiceMethodTraits<method, method_id>::method(),
+      : output(MethodLookup::GetNanopbMethod<Service, kMethodId>(),
                responses,
                buffer),
         channel(Channel::Create<123>(&output)),
@@ -141,25 +143,25 @@
         call(static_cast<internal::Server&>(server),
              static_cast<internal::Channel&>(channel),
              service,
-             NanopbServiceMethodTraits<method, method_id>::method()) {}
+             MethodLookup::GetNanopbMethod<Service, kMethodId>()) {}
 
   MessageOutput<Response> output;
 
   rpc::Channel channel;
   rpc::Server server;
-  typename NanopbServiceMethodTraits<method, method_id>::Service service;
-  Vector<Response, max_responses> responses;
-  std::array<std::byte, output_size> buffer = {};
+  Service service;
+  Vector<Response, kMaxResponse> responses;
+  std::array<std::byte, kOutputSize> buffer = {};
 
   internal::ServerCall call;
 };
 
 // Method invocation context for a unary RPC. Returns the status in call() and
 // provides the response through the response() method.
-template <auto method, uint32_t method_id, size_t output_size>
+template <typename Service, auto method, uint32_t kMethodId, size_t kOutputSize>
 class UnaryContext {
  private:
-  InvocationContext<method, method_id, 1, output_size> ctx_;
+  InvocationContext<Service, method, kMethodId, 1, kOutputSize> ctx_;
 
  public:
   using Request = typename decltype(ctx_)::Request;
@@ -168,30 +170,33 @@
   template <typename... Args>
   UnaryContext(Args&&... args) : ctx_(std::forward<Args>(args)...) {}
 
+  Service& service() { return ctx_.service; }
+
   // Invokes the RPC with the provided request. Returns the status.
   Status call(const Request& request) {
     ctx_.output.clear();
     ctx_.responses.emplace_back();
     ctx_.responses.back() = {};
-    return (ctx_.service.*method)(
-        ctx_.call.context(), request, ctx_.responses.back());
+    return CallMethodImplFunction<method>(
+        ctx_.call, request, ctx_.responses.back());
   }
 
   // Gives access to the RPC's response.
   const Response& response() const {
-    PW_CHECK_UINT_GT(ctx_.responses.size(), 0);
+    PW_ASSERT(ctx_.responses.size() > 0u);
     return ctx_.responses.back();
   }
 };
 
 // Method invocation context for a server streaming RPC.
-template <auto method,
-          uint32_t method_id,
-          size_t max_responses,
-          size_t output_size>
+template <typename Service,
+          auto method,
+          uint32_t kMethodId,
+          size_t kMaxResponse,
+          size_t kOutputSize>
 class ServerStreamingContext {
  private:
-  InvocationContext<method, method_id, max_responses, output_size> ctx_;
+  InvocationContext<Service, method, kMethodId, kMaxResponse, kOutputSize> ctx_;
 
  public:
   using Request = typename decltype(ctx_)::Request;
@@ -200,12 +205,14 @@
   template <typename... Args>
   ServerStreamingContext(Args&&... args) : ctx_(std::forward<Args>(args)...) {}
 
+  Service& service() { return ctx_.service; }
+
   // Invokes the RPC with the provided request.
   void call(const Request& request) {
     ctx_.output.clear();
     internal::BaseServerWriter server_writer(ctx_.call);
-    return (ctx_.service.*method)(
-        ctx_.call.context(),
+    return CallMethodImplFunction<method>(
+        ctx_.call,
         request,
         static_cast<ServerWriter<Response>&>(server_writer));
   }
@@ -232,18 +239,26 @@
 
   // The status of the stream. Only valid if done() is true.
   Status status() const {
-    PW_CHECK(done());
+    PW_ASSERT(done());
     return ctx_.output.last_status();
   }
 };
 
 // Alias to select the type of the context object to use based on which type of
 // RPC it is for.
-template <auto method, uint32_t method_id, size_t responses, size_t output_size>
+template <typename Service,
+          auto method,
+          uint32_t kMethodId,
+          size_t kMaxResponse,
+          size_t kOutputSize>
 using Context = std::tuple_element_t<
-    static_cast<size_t>(internal::RpcTraits<decltype(method)>::kType),
-    std::tuple<UnaryContext<method, method_id, output_size>,
-               ServerStreamingContext<method, method_id, responses, output_size>
+    static_cast<size_t>(internal::MethodTraits<decltype(method)>::kType),
+    std::tuple<UnaryContext<Service, method, kMethodId, kOutputSize>,
+               ServerStreamingContext<Service,
+                                      method,
+                                      kMethodId,
+                                      kMaxResponse,
+                                      kOutputSize>
                // TODO(hepler): Support client and bidi streaming
                >>;
 
@@ -256,16 +271,17 @@
 }
 
 template <typename Response>
-Status MessageOutput<Response>::SendAndReleaseBuffer(size_t size) {
-  PW_CHECK(!stream_ended_);
+Status MessageOutput<Response>::SendAndReleaseBuffer(
+    std::span<const std::byte> buffer) {
+  PW_ASSERT(!stream_ended_);
+  PW_ASSERT(buffer.data() == buffer_.data());
 
-  if (size == 0u) {
-    return Status::Ok();
+  if (buffer.empty()) {
+    return OkStatus();
   }
 
-  Result<internal::Packet> result =
-      internal::Packet::FromBuffer(std::span(buffer_.data(), size));
-  PW_CHECK(result.ok());
+  Result<internal::Packet> result = internal::Packet::FromBuffer(buffer);
+  PW_ASSERT(result.ok());
 
   last_status_ = result.value().status();
 
@@ -274,7 +290,7 @@
       // If we run out of space, the back message is always the most recent.
       responses_.emplace_back();
       responses_.back() = {};
-      PW_CHECK(
+      PW_ASSERT(
           method_.DecodeResponse(result.value().payload(), &responses_.back()));
       total_responses_ += 1;
       break;
@@ -284,24 +300,25 @@
     default:
       PW_CRASH("Unhandled PacketType");
   }
-  return Status::Ok();
+  return OkStatus();
 }
 
 }  // namespace internal::test::nanopb
 
-template <auto method,
-          uint32_t method_id,
-          size_t max_responses,
-          size_t output_size_bytes>
+template <typename Service,
+          auto method,
+          uint32_t kMethodId,
+          size_t kMaxResponse,
+          size_t kOutputSizeBytes>
 class NanopbTestMethodContext
     : public internal::test::nanopb::
-          Context<method, method_id, max_responses, output_size_bytes> {
+          Context<Service, method, kMethodId, kMaxResponse, kOutputSizeBytes> {
  public:
   // Forwards constructor arguments to the service class.
   template <typename... ServiceArgs>
   NanopbTestMethodContext(ServiceArgs&&... service_args)
       : internal::test::nanopb::
-            Context<method, method_id, max_responses, output_size_bytes>(
+            Context<Service, method, kMethodId, kMaxResponse, kOutputSizeBytes>(
                 std::forward<ServiceArgs>(service_args)...) {}
 };
 
diff --git a/pw_rpc/nanopb/stub_generation_test.cc b/pw_rpc/nanopb/stub_generation_test.cc
new file mode 100644
index 0000000..18777bc
--- /dev/null
+++ b/pw_rpc/nanopb/stub_generation_test.cc
@@ -0,0 +1,29 @@
+// 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.
+
+// This macro is used to remove the generated stubs from the proto files. Define
+// so that the generated stubs can be tested.
+#define _PW_RPC_COMPILE_GENERATED_SERVICE_STUBS
+
+#include "gtest/gtest.h"
+#include "pw_rpc_test_protos/test.rpc.pb.h"
+
+namespace {
+
+TEST(NanopbServiceStub, GeneratedStubCompiles) {
+  ::pw::rpc::test::TestService test_service;
+  EXPECT_STREQ(test_service.name(), "TestService");
+}
+
+}  // namespace
diff --git a/pw_rpc/packet.cc b/pw_rpc/packet.cc
index 9b62a4f..3739370 100644
--- a/pw_rpc/packet.cc
+++ b/pw_rpc/packet.cc
@@ -62,7 +62,7 @@
     }
   }
 
-  if (status == Status::DataLoss()) {
+  if (status.IsDataLoss()) {
     return status;
   }
 
diff --git a/pw_rpc/packet_test.cc b/pw_rpc/packet_test.cc
index 9f6d7ea..534fb87 100644
--- a/pw_rpc/packet_test.cc
+++ b/pw_rpc/packet_test.cc
@@ -76,7 +76,7 @@
   Packet packet(PacketType::RESPONSE, 1, 42, 100, kPayload);
 
   auto result = packet.Encode(buffer);
-  ASSERT_EQ(Status::Ok(), result.status());
+  ASSERT_EQ(OkStatus(), result.status());
   ASSERT_EQ(kEncoded.size(), result.value().size());
   EXPECT_EQ(std::memcmp(kEncoded.data(), buffer, kEncoded.size()), 0);
 }
@@ -122,7 +122,7 @@
 
   byte buffer[128];
   Result result = packet.Encode(buffer);
-  ASSERT_EQ(result.status(), Status::Ok());
+  ASSERT_EQ(result.status(), OkStatus());
 
   std::span<byte> packet_data(buffer, result.value().size());
   auto decode_result = Packet::FromBuffer(packet_data);
diff --git a/pw_rpc/public/pw_rpc/channel.h b/pw_rpc/public/pw_rpc/channel.h
index 094d269..a8823ea 100644
--- a/pw_rpc/public/pw_rpc/channel.h
+++ b/pw_rpc/public/pw_rpc/channel.h
@@ -15,8 +15,9 @@
 
 #include <cstdint>
 #include <span>
+#include <type_traits>
 
-#include "pw_assert/assert.h"
+#include "pw_assert/light.h"
 #include "pw_status/status.h"
 
 namespace pw::rpc {
@@ -38,14 +39,22 @@
 
   constexpr const char* name() const { return name_; }
 
-  // Acquire a buffer into which to write an outgoing RPC packet.
+  // Acquire a buffer into which to write an outgoing RPC packet. The
+  // implementation is expected to handle synchronization if necessary.
   virtual std::span<std::byte> AcquireBuffer() = 0;
 
-  // Sends the contents of the buffer from AcquireBuffer(). Returns OK if the
-  // operation succeeded, on an implementation-defined Status value if there was
-  // an error. The implementation must NOT return FAILED_PRECONDITION or
-  // INTERNAL, which are reserved by pw_rpc.
-  virtual Status SendAndReleaseBuffer(size_t size) = 0;
+  // Sends the contents of a buffer previously obtained from AcquireBuffer().
+  // This may be called with an empty span, in which case the buffer should be
+  // released without sending any data.
+  //
+  // Returns OK if the operation succeeded, or an implementation-defined Status
+  // value if there was an error. The implementation must NOT return
+  // FAILED_PRECONDITION or INTERNAL, which are reserved by pw_rpc.
+  virtual Status SendAndReleaseBuffer(std::span<const std::byte> buffer) = 0;
+
+  void DiscardBuffer(std::span<const std::byte> buffer) {
+    SendAndReleaseBuffer(buffer.first(0));
+  }
 
  private:
   const char* name_;
@@ -62,10 +71,23 @@
   // Creates a channel with a static ID. The channel's output can also be
   // static, or it can set to null to allow dynamically opening connections
   // through the channel.
-  template <uint32_t id>
+  template <uint32_t kId>
   constexpr static Channel Create(ChannelOutput* output) {
-    static_assert(id != kUnassignedChannelId, "Channel ID cannot be 0");
-    return Channel(id, output);
+    static_assert(kId != kUnassignedChannelId, "Channel ID cannot be 0");
+    return Channel(kId, output);
+  }
+
+  // Creates a channel with a static ID from an enum value.
+  template <auto kId,
+            typename T = decltype(kId),
+            typename = std::enable_if_t<std::is_enum_v<T>>,
+            typename U = std::underlying_type_t<T>>
+  constexpr static Channel Create(ChannelOutput* output) {
+    constexpr U kIntId = static_cast<U>(kId);
+    static_assert(kIntId >= 0, "Channel ID cannot be negative");
+    static_assert(kIntId <= std::numeric_limits<uint32_t>::max(),
+                  "Channel ID must fit in a uint32");
+    return Create<static_cast<uint32_t>(kIntId)>(output);
   }
 
   constexpr uint32_t id() const { return id_; }
@@ -74,13 +96,11 @@
  protected:
   constexpr Channel(uint32_t id, ChannelOutput* output)
       : id_(id), output_(output), client_(nullptr) {
-    // TODO(pwbug/246): Use PW_ASSERT when that is available.
-    // PW_ASSERT(id != kUnassignedChannelId);
+    PW_ASSERT(id != kUnassignedChannelId);
   }
 
   ChannelOutput& output() const {
-    // TODO(pwbug/246): Use PW_ASSERT when that is available.
-    // PW_ASSERT(output_ != nullptr);
+    PW_ASSERT(output_ != nullptr);
     return *output_;
   }
 
diff --git a/pw_rpc/public/pw_rpc/client_server.h b/pw_rpc/public/pw_rpc/client_server.h
new file mode 100644
index 0000000..219ed67
--- /dev/null
+++ b/pw_rpc/public/pw_rpc/client_server.h
@@ -0,0 +1,40 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "pw_rpc/client.h"
+#include "pw_rpc/server.h"
+
+namespace pw::rpc {
+
+// Class that wraps both an RPC client and a server, simplifying RPC setup when
+// a device needs to function as both.
+class ClientServer {
+ public:
+  constexpr ClientServer(std::span<Channel> channels)
+      : client_(channels), server_(channels) {}
+
+  // Sends a packet to either the client or the server, depending on its type.
+  Status ProcessPacket(std::span<const std::byte> packet,
+                       ChannelOutput& interface);
+
+  constexpr Client& client() { return client_; }
+  constexpr Server& server() { return server_; }
+
+ private:
+  Client client_;
+  Server server_;
+};
+
+}  // namespace pw::rpc
diff --git a/pw_rpc/public/pw_rpc/internal/base_client_call.h b/pw_rpc/public/pw_rpc/internal/base_client_call.h
index a6b800b..24acafc 100644
--- a/pw_rpc/public/pw_rpc/internal/base_client_call.h
+++ b/pw_rpc/public/pw_rpc/internal/base_client_call.h
@@ -13,7 +13,7 @@
 // the License.
 #pragma once
 
-#include "pw_assert/assert.h"
+#include "pw_assert/light.h"
 #include "pw_containers/intrusive_list.h"
 #include "pw_rpc/internal/channel.h"
 #include "pw_rpc/internal/packet.h"
@@ -37,9 +37,7 @@
         method_id_(method_id),
         handler_(handler),
         active_(true) {
-    // TODO(pwbug/246): Use PW_ASSERT when that is available.
-    // PW_ASSERT(channel_ != nullptr);
-
+    PW_ASSERT(channel_ != nullptr);
     Register();
   }
 
diff --git a/pw_rpc/public/pw_rpc/internal/base_server_writer.h b/pw_rpc/public/pw_rpc/internal/base_server_writer.h
index 4b9dbad..895c1b8 100644
--- a/pw_rpc/public/pw_rpc/internal/base_server_writer.h
+++ b/pw_rpc/public/pw_rpc/internal/base_server_writer.h
@@ -41,7 +41,9 @@
 
   BaseServerWriter(const BaseServerWriter&) = delete;
 
-  BaseServerWriter(BaseServerWriter&& other) { *this = std::move(other); }
+  BaseServerWriter(BaseServerWriter&& other) : state_(kClosed) {
+    *this = std::move(other);
+  }
 
   ~BaseServerWriter() { Finish(); }
 
@@ -57,7 +59,7 @@
   uint32_t method_id() const;
 
   // Closes the ServerWriter, if it is open.
-  void Finish(Status status = Status::Ok());
+  Status Finish(Status status = OkStatus());
 
  protected:
   constexpr BaseServerWriter() : state_{kClosed} {}
@@ -68,9 +70,12 @@
 
   constexpr const Channel::OutputBuffer& buffer() const { return response_; }
 
+  // Acquires a buffer into which to write a payload. The BaseServerWriter MUST
+  // be open when this is called!
   std::span<std::byte> AcquirePayloadBuffer();
 
-  // Releases the buffer, sending a packet with the specified payload.
+  // Releases the buffer, sending a packet with the specified payload. The
+  // BaseServerWriter MUST be open when this is called!
   Status ReleasePayloadBuffer(std::span<const std::byte> payload);
 
   // Releases the buffer without sending a packet.
diff --git a/pw_rpc/public/pw_rpc/internal/channel.h b/pw_rpc/public/pw_rpc/internal/channel.h
index 6533eda..077ed6a 100644
--- a/pw_rpc/public/pw_rpc/internal/channel.h
+++ b/pw_rpc/public/pw_rpc/internal/channel.h
@@ -15,6 +15,7 @@
 
 #include <span>
 
+#include "pw_assert/assert.h"
 #include "pw_rpc/channel.h"
 #include "pw_status/status.h"
 
@@ -80,8 +81,8 @@
   Status Send(OutputBuffer& output, const internal::Packet& packet);
 
   void Release(OutputBuffer& buffer) {
+    output().DiscardBuffer(buffer.buffer_);
     buffer.buffer_ = {};
-    output().SendAndReleaseBuffer(0);
   }
 };
 
diff --git a/pw_rpc/public/pw_rpc/internal/config.h b/pw_rpc/public/pw_rpc/internal/config.h
new file mode 100644
index 0000000..aa9d9ee
--- /dev/null
+++ b/pw_rpc/public/pw_rpc/internal/config.h
@@ -0,0 +1,57 @@
+// 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.
+
+// Configuration macros for the pw_rpc module.
+#pragma once
+
+#include <cstddef>
+
+// The Nanopb-based pw_rpc implementation allocates memory to use for Nanopb
+// structs for the request and response protobufs. The template function that
+// allocates these structs rounds struct sizes up to this value so that
+// different structs can be allocated with the same function. Structs with sizes
+// larger than this value cause an extra function to be created, which slightly
+// increases code size.
+//
+// Ideally, this value will be set to the size of the largest Nanopb struct used
+// as an RPC request or response. The buffer can be stack or globally allocated
+// (see PW_RPC_NANOPB_STRUCT_BUFFER_STACK_ALLOCATE).
+#ifndef PW_RPC_NANOPB_STRUCT_MIN_BUFFER_SIZE
+#define PW_RPC_NANOPB_STRUCT_MIN_BUFFER_SIZE 64
+#endif  // PW_RPC_NANOPB_STRUCT_MIN_BUFFER_SIZE
+
+namespace pw::rpc::cfg {
+
+inline constexpr size_t kNanopbStructMinBufferSize =
+    PW_RPC_NANOPB_STRUCT_MIN_BUFFER_SIZE;
+
+}  // namespace pw::rpc::cfg
+
+#undef PW_RPC_NANOPB_STRUCT_MIN_BUFFER_SIZE
+
+// This option determines whether to allocate the Nanopb structs on the stack or
+// in a global variable. Globally allocated structs are NOT thread safe, but
+// work fine when the RPC server's ProcessPacket function is only called from
+// one thread.
+#ifndef PW_RPC_NANOPB_STRUCT_BUFFER_STACK_ALLOCATE
+#define PW_RPC_NANOPB_STRUCT_BUFFER_STACK_ALLOCATE 1
+#endif  // PW_RPC_NANOPB_STRUCT_BUFFER_STACK_ALLOCATE
+
+#if PW_RPC_NANOPB_STRUCT_BUFFER_STACK_ALLOCATE
+#define _PW_RPC_NANOPB_STRUCT_STORAGE_CLASS
+#else
+#define _PW_RPC_NANOPB_STRUCT_STORAGE_CLASS static
+#endif  // PW_RPC_NANOPB_STRUCT_BUFFER_STACK_ALLOCATE
+
+#undef PW_RPC_NANOPB_STRUCT_BUFFER_STACK_ALLOCATE
diff --git a/pw_rpc/public/pw_rpc/internal/method.h b/pw_rpc/public/pw_rpc/internal/method.h
index cd9155b..fec137c 100644
--- a/pw_rpc/public/pw_rpc/internal/method.h
+++ b/pw_rpc/public/pw_rpc/internal/method.h
@@ -15,6 +15,7 @@
 
 #include <cstddef>
 #include <cstdint>
+#include <utility>
 
 #include "pw_rpc/internal/call.h"
 
@@ -22,7 +23,36 @@
 
 class Packet;
 
-// RPC server implementations provide a class that dervies from Method.
+// Each supported protobuf implementation provides a class that derives from
+// Method. The implementation classes provide the following public interface:
+/*
+class MethodImpl : public Method {
+  // True if the provided function signature is valid for this method impl.
+  template <auto method>
+  static constexpr bool matches();
+
+  // Creates a unary method instance.
+  template <auto method>
+  static constexpr MethodImpl Unary(uint32_t id, [optional args]);
+
+  // Creates a server streaming method instance.
+  template <auto method>
+  static constexpr MethodImpl ServerStreaming(uint32_t id, [optional args]);
+
+  // Creates a client streaming method instance.
+  static constexpr MethodImpl ClientStreaming(uint32_t id, [optional args]);
+
+  // Creates a bidirectional streaming method instance.
+  static constexpr MethodImpl BidirectionalStreaming(uint32_t id,
+                                                     [optional args]);
+
+  // Creates a method instance for when the method implementation function has
+  // an incorrect signature. Having this helps reduce error message verbosity.
+  static constexpr MethodImpl Invalid();
+};
+*/
+// Method implementations must pass a test that uses the MethodImplTester class
+// in pw_rpc_private/method_impl_tester.h.
 class Method {
  public:
   constexpr uint32_t id() const { return id_; }
@@ -38,6 +68,10 @@
  protected:
   using Invoker = void (&)(const Method&, ServerCall&, const Packet&);
 
+  static constexpr void InvalidInvoker(const Method&,
+                                       ServerCall&,
+                                       const Packet&) {}
+
   constexpr Method(uint32_t id, Invoker invoker) : id_(id), invoker_(invoker) {}
 
  private:
@@ -45,4 +79,55 @@
   Invoker invoker_;
 };
 
+// Traits struct that determines the type of an RPC service method from its
+// signature. Derived Methods should provide specializations for their method
+// types.
+template <typename Method>
+struct MethodTraits {
+  // Specializations must set Implementation as an alias for their method
+  // implementation class.
+  using Implementation = Method;
+
+  // Specializations must set kType to the MethodType.
+  // static constexpr MethodType kType = (method type);
+
+  // Specializations for member function types must set Service to an alias to
+  // for the implemented service class.
+  using Service = rpc::Service;
+
+  // Specializations may provide the C++ types of the requests and responses if
+  // relevant.
+  using Request = void;
+  using Response = void;
+};
+
+template <auto method>
+using MethodImplementation =
+    typename MethodTraits<decltype(method)>::Implementation;
+
+// Function that calls a user-defined method implementation function from a
+// ServerCall object.
+template <auto method, typename... Args>
+constexpr auto CallMethodImplFunction(ServerCall& call, Args&&... args) {
+  // If the method impl is a member function, deduce the type of the
+  // user-defined service from it, then call the method on the service.
+  if constexpr (std::is_member_function_pointer_v<decltype(method)>) {
+    return (static_cast<typename MethodTraits<decltype(method)>::Service&>(
+                call.service()).*
+            method)(call.context(), std::forward<Args>(args)...);
+  } else {
+    return method(call.context(), std::forward<Args>(args)...);
+  }
+}
+
+// Identifies a base class from a member function it defines. This should be
+// used with decltype to retrieve the base service class.
+template <typename T, typename U>
+T BaseFromMember(U T::*);
+
+// The base generated service of an RPC service class.
+template <typename Service>
+using GeneratedService =
+    decltype(BaseFromMember(&Service::_PwRpcInternalGeneratedBase));
+
 }  // namespace pw::rpc::internal
diff --git a/pw_rpc/public/pw_rpc/internal/method_lookup.h b/pw_rpc/public/pw_rpc/internal/method_lookup.h
new file mode 100644
index 0000000..7383a01
--- /dev/null
+++ b/pw_rpc/public/pw_rpc/internal/method_lookup.h
@@ -0,0 +1,64 @@
+// 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.
+#pragma once
+
+#include "pw_rpc/internal/method.h"
+
+namespace pw::rpc::internal {
+
+// Gets a Method object from a generated RPC service class. Getter functions are
+// provided for each supported method implementation. The
+//
+// To ensure the MethodUnion actually holds the requested method type, the
+// method ID is accessed in a static_assert. It is invalid to access an unset
+// union member in a constant expression, so this results in a compiler error.
+class MethodLookup {
+ public:
+  template <typename Service, uint32_t kMethodId>
+  static constexpr const auto& GetRawMethod() {
+    const auto& method = GetMethodUnion<Service, kMethodId>().raw_method();
+    static_assert(method.id() == kMethodId, "Incorrect method implementation");
+    return method;
+  }
+
+  template <typename Service, uint32_t kMethodId>
+  static constexpr const auto& GetNanopbMethod() {
+    const auto& method = GetMethodUnion<Service, kMethodId>().nanopb_method();
+    static_assert(method.id() == kMethodId, "Incorrect method implementation");
+    return method;
+  }
+
+ private:
+  template <typename Service, uint32_t kMethodId>
+  static constexpr const auto& GetMethodUnion() {
+    constexpr auto method = GetMethodUnionPointer<Service>(kMethodId);
+    static_assert(method != nullptr,
+                  "The selected function is not an RPC service method");
+    return *method;
+  }
+
+  template <typename Service>
+  static constexpr
+      typename decltype(GeneratedService<Service>::kMethods)::const_pointer
+      GetMethodUnionPointer(uint32_t kMethodId) {
+    for (size_t i = 0; i < GeneratedService<Service>::kMethodIds.size(); ++i) {
+      if (GeneratedService<Service>::kMethodIds[i] == kMethodId) {
+        return &GeneratedService<Service>::kMethods[i];
+      }
+    }
+    return nullptr;
+  }
+};
+
+}  // namespace pw::rpc::internal
diff --git a/pw_rpc/public/pw_rpc/internal/method_union.h b/pw_rpc/public/pw_rpc/internal/method_union.h
index 9d491af..154667d 100644
--- a/pw_rpc/public/pw_rpc/internal/method_union.h
+++ b/pw_rpc/public/pw_rpc/internal/method_union.h
@@ -14,8 +14,10 @@
 #pragma once
 
 #include <type_traits>
+#include <utility>
 
 #include "pw_rpc/internal/method.h"
+#include "pw_rpc/internal/method_type.h"
 
 namespace pw::rpc::internal {
 
@@ -27,44 +29,6 @@
   constexpr const Method& method() const;
 };
 
-// Templated false value for use in static_assert(false) statements.
-template <typename...>
-constexpr std::false_type kFalse{};
-
-// Traits struct that determines the type of an RPC service method from its
-// signature. Derived MethodUnions should provide specializations for their
-// method types.
-template <typename Method>
-struct MethodTraits {
-  static_assert(kFalse<Method>,
-                "The selected function is not an RPC service method");
-
-  // Specializations must set Implementation as an alias for their method
-  // implementation class.
-  using Implementation = Method;
-
-  // Specializations must set Service as an alias to the implemented service
-  // class.
-  using Service = rpc::Service;
-};
-
-template <auto method>
-using MethodImplementation =
-    typename MethodTraits<decltype(method)>::Implementation;
-
-template <auto method>
-using MethodService = typename MethodTraits<decltype(method)>::Service;
-
-// Identifies a base class from a member function it defines. This should be
-// used with decltype to retrieve the base class.
-template <typename T, typename U>
-T BaseFromMember(U T::*);
-
-// The base generated service of an implemented RPC method.
-template <auto method>
-using MethodBaseService = decltype(
-    BaseFromMember(&MethodService<method>::_PwRpcInternalGeneratedBase));
-
 class CoreMethodUnion : public MethodUnion {
  public:
   constexpr const Method& method() const { return impl_.method; }
@@ -84,4 +48,84 @@
   return static_cast<const CoreMethodUnion*>(this)->method();
 }
 
+// Templated false value for use in static_assert(false) statements.
+template <typename...>
+constexpr std::false_type kCheckMethodSignature{};
+
+// In static_assert messages, use newlines in GCC since it displays them
+// correctly. Clang displays \n, which is not helpful.
+#ifdef __clang__
+#define _PW_RPC_FORMAT_ERROR_MESSAGE(msg, signature) msg " " signature
+#else
+#define _PW_RPC_FORMAT_ERROR_MESSAGE(msg, signature) \
+  "\n" msg "\n\n    " signature "\n"
+#endif  // __clang__
+
+#define _PW_RPC_FUNCTION_ERROR(type, return_type, args)                   \
+  _PW_RPC_FORMAT_ERROR_MESSAGE(                                           \
+      "This RPC is a " type                                               \
+      " RPC, but its function signature is not correct. The function "    \
+      "signature is determined by the protobuf library in use, but " type \
+      " RPC implementations generally take the form:",                    \
+      return_type " MethodName(ServerContext&, " args ")")
+
+// This function is called if an RPC method implementation's signature is not
+// correct. It triggers a static_assert with an error message tailored to the
+// expected RPC type.
+template <auto method,
+          MethodType expected,
+          typename InvalidImpl = MethodImplementation<method>>
+constexpr auto InvalidMethod(uint32_t) {
+  if constexpr (expected == MethodType::kUnary) {
+    static_assert(
+        kCheckMethodSignature<decltype(method)>,
+        _PW_RPC_FUNCTION_ERROR("unary", "Status", "Request, Response"));
+  } else if constexpr (expected == MethodType::kServerStreaming) {
+    static_assert(
+        kCheckMethodSignature<decltype(method)>,
+        _PW_RPC_FUNCTION_ERROR(
+            "server streaming", "void", "Request, ServerWriter<Response>&"));
+  } else if constexpr (expected == MethodType::kClientStreaming) {
+    static_assert(
+        kCheckMethodSignature<decltype(method)>,
+        _PW_RPC_FUNCTION_ERROR(
+            "client streaming", "Status", "ServerReader<Request>&, Response"));
+  } else if constexpr (expected == MethodType::kBidirectionalStreaming) {
+    static_assert(kCheckMethodSignature<decltype(method)>,
+                  _PW_RPC_FUNCTION_ERROR(
+                      "bidirectional streaming",
+                      "void",
+                      "ServerReader<Request>&, ServerWriter<Response>&"));
+  } else {
+    static_assert(kCheckMethodSignature<decltype(method)>,
+                  "Unsupported MethodType");
+  }
+  return InvalidImpl::Invalid();
+}
+
+#undef _PW_RPC_FORMAT_ERROR_MESSAGE
+#undef _PW_RPC_FUNCTION_ERROR
+
+// This function checks the type of the method and calls the appropriate
+// function to create the method instance.
+template <auto method, typename MethodImpl, MethodType type, typename... Args>
+constexpr auto GetMethodFor(uint32_t id, Args&&... args) {
+  if constexpr (MethodTraits<decltype(method)>::kType != type) {
+    return InvalidMethod<method, type>(id);
+  } else if constexpr (type == MethodType::kUnary) {
+    return MethodImpl::template Unary<method>(id, std::forward<Args>(args)...);
+  } else if constexpr (type == MethodType::kServerStreaming) {
+    return MethodImpl::template ServerStreaming<method>(
+        id, std::forward<Args>(args)...);
+  } else if constexpr (type == MethodType::kClientStreaming) {
+    return MethodImpl::template ClientStreaming<method>(
+        id, std::forward<Args>(args)...);
+  } else if constexpr (type == MethodType::kBidirectionalStreaming) {
+    return MethodImpl::template BidirectionalStreaming<method>(
+        id, std::forward<Args>(args)...);
+  } else {
+    static_assert(kCheckMethodSignature<MethodImpl>, "Invalid MethodType");
+  }
+}
+
 }  // namespace pw::rpc::internal
diff --git a/pw_rpc/public/pw_rpc/internal/packet.h b/pw_rpc/public/pw_rpc/internal/packet.h
index c67d86a..2004d70 100644
--- a/pw_rpc/public/pw_rpc/internal/packet.h
+++ b/pw_rpc/public/pw_rpc/internal/packet.h
@@ -18,7 +18,7 @@
 #include <span>
 
 #include "pw_bytes/span.h"
-#include "pw_rpc_protos/packet.pwpb.h"
+#include "pw_rpc/internal/packet.pwpb.h"
 #include "pw_status/status_with_size.h"
 
 namespace pw::rpc::internal {
@@ -34,7 +34,7 @@
   // Creates an RPC packet with the channel, service, and method ID of the
   // provided packet.
   static constexpr Packet Response(const Packet& request,
-                                   Status status = Status::Ok()) {
+                                   Status status = OkStatus()) {
     return Packet(PacketType::RESPONSE,
                   request.channel_id(),
                   request.service_id(),
@@ -74,7 +74,7 @@
                    uint32_t service_id,
                    uint32_t method_id,
                    ConstByteSpan payload = {},
-                   Status status = Status::Ok())
+                   Status status = OkStatus())
       : type_(type),
         channel_id_(channel_id),
         service_id_(service_id),
diff --git a/pw_rpc/public/pw_rpc/internal/service_method_traits.h b/pw_rpc/public/pw_rpc/internal/service_method_traits.h
deleted file mode 100644
index 0e26b8d..0000000
--- a/pw_rpc/public/pw_rpc/internal/service_method_traits.h
+++ /dev/null
@@ -1,42 +0,0 @@
-// 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.
-#pragma once
-
-#include "pw_rpc/internal/method_union.h"
-
-namespace pw::rpc::internal {
-
-// Gets information about a service and method at compile-time. Uses a pointer
-// to a member function of the service implementation to identify the service
-// class, generated service class, and Method object.
-template <auto lookup_function, auto impl_method, uint32_t method_id>
-class ServiceMethodTraits {
- public:
-  ServiceMethodTraits() = delete;
-
-  // Type of the service implementation derived class.
-  using Service = MethodService<impl_method>;
-
-  using MethodImpl = MethodImplementation<impl_method>;
-
-  // Reference to the Method object corresponding to this method.
-  static constexpr const MethodImpl& method() {
-    return *lookup_function(method_id);
-  }
-
-  static_assert(lookup_function(method_id) != nullptr,
-                "The selected function is not an RPC service method");
-};
-
-}  // namespace pw::rpc::internal
diff --git a/pw_rpc/public/pw_rpc/service.h b/pw_rpc/public/pw_rpc/service.h
index e9b0715..4e1a1a0 100644
--- a/pw_rpc/public/pw_rpc/service.h
+++ b/pw_rpc/public/pw_rpc/service.h
@@ -18,6 +18,7 @@
 #include <span>
 
 #include "pw_containers/intrusive_list.h"
+#include "pw_preprocessor/compiler.h"
 #include "pw_rpc/internal/method.h"
 #include "pw_rpc/internal/method_union.h"
 
@@ -34,13 +35,17 @@
   uint32_t id() const { return id_; }
 
  protected:
-  template <typename T, size_t method_count>
-  constexpr Service(uint32_t id, const std::array<T, method_count>& methods)
+  template <typename T, size_t kMethodCount>
+  constexpr Service(uint32_t id, const std::array<T, kMethodCount>& methods)
       : id_(id),
         methods_(methods.data()),
         method_size_(sizeof(T)),
-        method_count_(static_cast<uint16_t>(method_count)) {
-    static_assert(method_count <= std::numeric_limits<uint16_t>::max());
+        method_count_(static_cast<uint16_t>(kMethodCount)) {
+    PW_MODIFY_DIAGNOSTICS_PUSH();
+    // GCC 10 emits spurious -Wtype-limits warnings for the static_assert.
+    PW_MODIFY_DIAGNOSTIC_GCC(ignored, "-Wtype-limits");
+    static_assert(kMethodCount <= std::numeric_limits<uint16_t>::max());
+    PW_MODIFY_DIAGNOSTICS_POP();
   }
 
   // For use by tests with only one method.
diff --git a/pw_rpc/public/pw_rpc/synchronized_channel_output.h b/pw_rpc/public/pw_rpc/synchronized_channel_output.h
new file mode 100644
index 0000000..c67fe19
--- /dev/null
+++ b/pw_rpc/public/pw_rpc/synchronized_channel_output.h
@@ -0,0 +1,52 @@
+// 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.
+#pragma once
+
+#include <algorithm>
+
+#include "pw_rpc/channel.h"
+#include "pw_sync/lock_annotations.h"
+#include "pw_sync/mutex.h"
+
+namespace pw::rpc {
+
+// Wraps an RPC ChannelOutput implementation with a mutex to synchronize its
+// acquire and release buffer operations. This can be used to allow a simple
+// ChannelOutput implementation to run in multi-threaded contexts. More complex
+// implementations may want to roll their own synchronization.
+template <typename BaseChannelOutput>
+class PW_LOCKABLE("pw::rpc::SynchronizedChannelOutput")
+    SynchronizedChannelOutput final : public BaseChannelOutput {
+ public:
+  template <typename... Args>
+  constexpr SynchronizedChannelOutput(sync::Mutex& mutex, Args&&... args)
+      : BaseChannelOutput(std::forward<Args>(args)...), mutex_(mutex) {}
+
+  std::span<std::byte> AcquireBuffer() final PW_EXCLUSIVE_LOCK_FUNCTION() {
+    mutex_.lock();
+    return BaseChannelOutput::AcquireBuffer();
+  }
+
+  Status SendAndReleaseBuffer(std::span<const std::byte> buffer) final
+      PW_UNLOCK_FUNCTION() {
+    Status status = BaseChannelOutput::SendAndReleaseBuffer(buffer);
+    mutex_.unlock();
+    return status;
+  }
+
+ private:
+  sync::Mutex& mutex_;
+};
+
+}  // namespace pw::rpc
diff --git a/pw_rpc/pw_rpc_private/internal_test_utils.h b/pw_rpc/pw_rpc_private/internal_test_utils.h
index faa4833..ef0b468 100644
--- a/pw_rpc/pw_rpc_private/internal_test_utils.h
+++ b/pw_rpc/pw_rpc_private/internal_test_utils.h
@@ -21,6 +21,7 @@
 #include <cstdint>
 #include <span>
 
+#include "pw_assert/light.h"
 #include "pw_rpc/client.h"
 #include "pw_rpc/internal/channel.h"
 #include "pw_rpc/internal/method.h"
@@ -29,25 +30,27 @@
 
 namespace pw::rpc {
 
-template <size_t output_buffer_size>
+template <size_t kOutputBufferSize>
 class TestOutput : public ChannelOutput {
  public:
-  static constexpr size_t buffer_size() { return output_buffer_size; }
+  static constexpr size_t buffer_size() { return kOutputBufferSize; }
 
   constexpr TestOutput(const char* name = "TestOutput")
       : ChannelOutput(name), sent_data_{} {}
 
   std::span<std::byte> AcquireBuffer() override { return buffer_; }
 
-  Status SendAndReleaseBuffer(size_t size) override {
-    if (size == 0u) {
-      return Status::Ok();
+  Status SendAndReleaseBuffer(std::span<const std::byte> buffer) override {
+    if (buffer.empty()) {
+      return OkStatus();
     }
 
+    PW_ASSERT(buffer.data() == buffer_.data());
+
     packet_count_ += 1;
-    sent_data_ = std::span(buffer_.data(), size);
+    sent_data_ = buffer;
     Result<internal::Packet> result = internal::Packet::FromBuffer(sent_data_);
-    EXPECT_EQ(Status::Ok(), result.status());
+    EXPECT_EQ(OkStatus(), result.status());
     sent_packet_ = result.value_or(internal::Packet());
     return send_status_;
   }
@@ -79,13 +82,13 @@
 };
 
 template <typename Service,
-          size_t output_buffer_size = 128,
-          uint32_t channel_id = 99,
-          uint32_t service_id = 16>
+          size_t kOutputBufferSize = 128,
+          uint32_t kChannelId = 99,
+          uint32_t kServiceId = 16>
 class ServerContextForTest {
  public:
-  static constexpr uint32_t kChannelId = channel_id;
-  static constexpr uint32_t kServiceId = service_id;
+  static constexpr uint32_t channel_id() { return kChannelId; }
+  static constexpr uint32_t service_id() { return kServiceId; }
 
   ServerContextForTest(const internal::Method& method)
       : channel_(Channel::Create<kChannelId>(&output_)),
@@ -105,15 +108,15 @@
                             kServiceId,
                             context_.method().id(),
                             payload,
-                            Status::Ok());
+                            OkStatus());
   }
 
   internal::ServerCall& get() { return context_; }
-  const auto& output() const { return output_; }
+  auto& output() { return output_; }
   TestServer& server() { return static_cast<TestServer&>(server_); }
 
  private:
-  TestOutput<output_buffer_size> output_;
+  TestOutput<kOutputBufferSize> output_;
   Channel channel_;
   Server server_;
   Service service_;
@@ -121,16 +124,16 @@
   internal::ServerCall context_;
 };
 
-template <size_t output_buffer_size = 128,
+template <size_t kOutputBufferSize = 128,
           size_t input_buffer_size = 128,
-          uint32_t channel_id = 99,
-          uint32_t service_id = 16,
-          uint32_t method_id = 111>
+          uint32_t kChannelId = 99,
+          uint32_t kServiceId = 16,
+          uint32_t kMethodId = 111>
 class ClientContextForTest {
  public:
-  static constexpr uint32_t kChannelId = channel_id;
-  static constexpr uint32_t kServiceId = service_id;
-  static constexpr uint32_t kMethodId = method_id;
+  static constexpr uint32_t channel_id() { return kChannelId; }
+  static constexpr uint32_t service_id() { return kServiceId; }
+  static constexpr uint32_t method_id() { return kMethodId; }
 
   ClientContextForTest()
       : channel_(Channel::Create<kChannelId>(&output_)),
@@ -143,13 +146,13 @@
   // Sends a packet to be processed by the client. Returns the client's
   // ProcessPacket status.
   Status SendPacket(internal::PacketType type,
-                    Status status = Status::Ok(),
+                    Status status = OkStatus(),
                     std::span<const std::byte> payload = {}) {
     internal::Packet packet(
         type, kChannelId, kServiceId, kMethodId, payload, status);
     std::byte buffer[input_buffer_size];
     Result result = packet.Encode(buffer);
-    EXPECT_EQ(result.status(), Status::Ok());
+    EXPECT_EQ(result.status(), OkStatus());
     return client_.ProcessPacket(result.value_or(ConstByteSpan()));
   }
 
@@ -158,7 +161,7 @@
   }
 
  private:
-  TestOutput<output_buffer_size> output_;
+  TestOutput<kOutputBufferSize> output_;
   Channel channel_;
   Client client_;
 };
diff --git a/pw_rpc/pw_rpc_private/method_impl_tester.h b/pw_rpc/pw_rpc_private/method_impl_tester.h
new file mode 100644
index 0000000..8140d0b
--- /dev/null
+++ b/pw_rpc/pw_rpc_private/method_impl_tester.h
@@ -0,0 +1,90 @@
+// 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.
+#pragma once
+
+#include <tuple>
+#include <type_traits>
+
+#include "gtest/gtest.h"
+#include "pw_rpc/internal/packet.h"
+#include "pw_rpc/internal/raw_method.h"
+#include "pw_rpc/server_context.h"
+
+namespace pw::rpc::internal {
+namespace {
+
+// This class tests Method implementation classes and MethodTraits
+// specializations. It verifies that they provide the expected functions and
+// that they correctly identify and construct the various method types.
+//
+// The TestService class must inherit from Service and provide the following
+// methods with valid signatures for RPCs:
+//
+//   - Unary: a valid unary RPC member function
+//   - StaticUnary: valid unary RPC static member function
+//   - ServerStreaming: valid server streaming RPC member function
+//   - StaticServerStreaming: valid server streaming static RPC member function
+//
+// The class must also provide methods with errors as described in their names:
+//
+//   - UnaryWrongArg
+//   - StaticUnaryVoidReturn
+//   - ServerStreamingBadReturn
+//   - StaticServerStreamingMissingArg
+//
+template <typename MethodImpl, typename TestService, auto... extra_method_args>
+struct MethodImplTester {
+  // Test the MethodTraits::kType member.
+  static_assert(MethodTraits<decltype(&TestService::Unary)>::kType ==
+                MethodType::kUnary);
+  static_assert(MethodTraits<decltype(&TestService::StaticUnary)>::kType ==
+                MethodType::kUnary);
+  static_assert(MethodTraits<decltype(&TestService::ServerStreaming)>::kType ==
+                MethodType::kServerStreaming);
+  static_assert(
+      MethodTraits<decltype(&TestService::StaticServerStreaming)>::kType ==
+      MethodType::kServerStreaming);
+
+  // Test method creation.
+  static constexpr MethodImpl kUnaryMethod =
+      MethodImpl::template Unary<&TestService::Unary>(1, extra_method_args...);
+  static_assert(kUnaryMethod.id() == 1);
+
+  static constexpr MethodImpl kStaticUnaryMethod =
+      MethodImpl::template Unary<&TestService::StaticUnary>(
+          2, extra_method_args...);
+  static_assert(kStaticUnaryMethod.id() == 2);
+
+  static constexpr MethodImpl kServerStreamingMethod =
+      MethodImpl::template ServerStreaming<&TestService::ServerStreaming>(
+          3, extra_method_args...);
+  static_assert(kServerStreamingMethod.id() == 3);
+
+  static constexpr MethodImpl kStaticServerStreamingMethod =
+      MethodImpl::template ServerStreaming<&TestService::StaticServerStreaming>(
+          4, extra_method_args...);
+  static_assert(kStaticServerStreamingMethod.id() == 4);
+
+  // Test that there is an Invalid method creation function.
+  static constexpr MethodImpl kInvalidMethod = MethodImpl::Invalid();
+  static_assert(kInvalidMethod.id() == 0);
+
+  // Provide a method that tests can call to ensure this class is instantiated.
+  bool MethodImplIsValid() const {
+    return true;  // If this class compiles, the MethodImpl passes this test.
+  }
+};
+
+}  // namespace
+}  // namespace pw::rpc::internal
diff --git a/pw_rpc/py/BUILD.gn b/pw_rpc/py/BUILD.gn
index 56ca19d..71faaee 100644
--- a/pw_rpc/py/BUILD.gn
+++ b/pw_rpc/py/BUILD.gn
@@ -15,32 +15,52 @@
 import("//build_overrides/pigweed.gni")
 
 import("$dir_pw_build/python.gni")
+import("$dir_pw_docgen/docs.gni")
 
 pw_python_package("py") {
-  setup = [ "setup.py" ]
+  generate_setup = {
+    name = "pw_rpc"
+    version = "0.0.1"
+  }
+
   sources = [
     "pw_rpc/__init__.py",
     "pw_rpc/callback_client.py",
     "pw_rpc/client.py",
+    "pw_rpc/codegen.py",
     "pw_rpc/codegen_nanopb.py",
     "pw_rpc/codegen_raw.py",
+    "pw_rpc/console_tools/__init__.py",
+    "pw_rpc/console_tools/console.py",
+    "pw_rpc/console_tools/functions.py",
+    "pw_rpc/console_tools/watchdog.py",
     "pw_rpc/descriptors.py",
     "pw_rpc/ids.py",
-    "pw_rpc/packet_pb2.py",
     "pw_rpc/packets.py",
     "pw_rpc/plugin.py",
     "pw_rpc/plugin_nanopb.py",
     "pw_rpc/plugin_raw.py",
   ]
   tests = [
-    "callback_client_test.py",
-    "client_test.py",
-    "codegen_test.py",
-    "ids_test.py",
-    "packets_test.py",
+    "tests/callback_client_test.py",
+    "tests/client_test.py",
+    "tests/console_tools/console_tools_test.py",
+    "tests/console_tools/functions_test.py",
+    "tests/descriptors_test.py",
+    "tests/ids_test.py",
+    "tests/packets_test.py",
   ]
   python_deps = [
+    "$dir_pw_protobuf/py",
     "$dir_pw_protobuf_compiler/py",
     "$dir_pw_status/py",
   ]
+  python_test_deps = [ "$dir_pw_build/py" ]
+  pylintrc = "$dir_pigweed/.pylintrc"
+  proto_library = "..:protos"
+}
+
+pw_doc_group("docs") {
+  sources = [ "docs.rst" ]
+  other_deps = [ ":py" ]
 }
diff --git a/pw_rpc/py/callback_client_test.py b/pw_rpc/py/callback_client_test.py
deleted file mode 100755
index 0ae02c4..0000000
--- a/pw_rpc/py/callback_client_test.py
+++ /dev/null
@@ -1,343 +0,0 @@
-#!/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 using the callback client for pw_rpc."""
-
-import unittest
-from unittest import mock
-from typing import List, Tuple
-
-from pw_protobuf_compiler import python_protos
-from pw_rpc import callback_client, client, packet_pb2, packets
-from pw_status import Status
-
-TEST_PROTO_1 = """\
-syntax = "proto3";
-
-package pw.test1;
-
-message SomeMessage {
-  uint32 magic_number = 1;
-}
-
-message AnotherMessage {
-  enum Result {
-    FAILED = 0;
-    FAILED_MISERABLY = 1;
-    I_DONT_WANT_TO_TALK_ABOUT_IT = 2;
-  }
-
-  Result result = 1;
-  string payload = 2;
-}
-
-service PublicService {
-  rpc SomeUnary(SomeMessage) returns (AnotherMessage) {}
-  rpc SomeServerStreaming(SomeMessage) returns (stream AnotherMessage) {}
-  rpc SomeClientStreaming(stream SomeMessage) returns (AnotherMessage) {}
-  rpc SomeBidiStreaming(stream SomeMessage) returns (stream AnotherMessage) {}
-}
-"""
-
-
-def _rpc(method_stub):
-    return client.PendingRpc(method_stub.channel, method_stub.method.service,
-                             method_stub.method)
-
-
-class CallbackClientImplTest(unittest.TestCase):
-    """Tests the callback_client as used within a pw_rpc Client."""
-    def setUp(self):
-        self._protos = python_protos.Library.from_strings(TEST_PROTO_1)
-
-        self._client = client.Client.from_modules(
-            callback_client.Impl(), [client.Channel(1, self._handle_request)],
-            self._protos.modules())
-
-        self._last_request: packet_pb2.RpcPacket = None
-        self._next_packets: List[Tuple[bytes, Status]] = []
-        self._send_responses_on_request = True
-
-    def _enqueue_response(self,
-                          channel_id: int,
-                          method=None,
-                          status: Status = Status.OK,
-                          response=b'',
-                          *,
-                          ids: Tuple[int, int] = None,
-                          process_status=Status.OK):
-        if method:
-            assert ids is None
-            service_id, method_id = method.service.id, method.id
-        else:
-            assert ids is not None and method is None
-            service_id, method_id = ids
-
-        if isinstance(response, bytes):
-            payload = response
-        else:
-            payload = response.SerializeToString()
-
-        self._next_packets.append(
-            (packet_pb2.RpcPacket(type=packets.PacketType.RESPONSE,
-                                  channel_id=channel_id,
-                                  service_id=service_id,
-                                  method_id=method_id,
-                                  status=status.value,
-                                  payload=payload).SerializeToString(),
-             process_status))
-
-    def _enqueue_stream_end(self,
-                            channel_id: int,
-                            method,
-                            status: Status = Status.OK,
-                            process_status=Status.OK):
-        self._next_packets.append(
-            (packet_pb2.RpcPacket(type=packets.PacketType.SERVER_STREAM_END,
-                                  channel_id=channel_id,
-                                  service_id=method.service.id,
-                                  method_id=method.id,
-                                  status=status.value).SerializeToString(),
-             process_status))
-
-    def _handle_request(self, data: bytes):
-        # Disable this method to prevent infinite recursion if processing the
-        # packet happens to send another packet.
-        if not self._send_responses_on_request:
-            return
-
-        self._send_responses_on_request = False
-
-        self._last_request = packets.decode(data)
-
-        for packet, status in self._next_packets:
-            self.assertIs(status, self._client.process_packet(packet))
-
-        self._next_packets.clear()
-        self._send_responses_on_request = True
-
-    def _sent_payload(self, message_type):
-        self.assertIsNotNone(self._last_request)
-        message = message_type()
-        message.ParseFromString(self._last_request.payload)
-        return message
-
-    def test_invoke_unary_rpc(self):
-        stub = self._client.channel(1).rpcs.pw.test1.PublicService
-        method = stub.SomeUnary.method
-
-        for _ in range(3):
-            self._enqueue_response(1, method, Status.ABORTED,
-                                   method.response_type(payload='0_o'))
-
-            status, response = stub.SomeUnary(magic_number=6)
-
-            self.assertEqual(
-                6,
-                self._sent_payload(method.request_type).magic_number)
-
-            self.assertIs(Status.ABORTED, status)
-            self.assertEqual('0_o', response.payload)
-
-    def test_invoke_unary_rpc_with_callback(self):
-        stub = self._client.channel(1).rpcs.pw.test1.PublicService
-        method = stub.SomeUnary.method
-
-        for _ in range(3):
-            self._enqueue_response(1, method, Status.ABORTED,
-                                   method.response_type(payload='0_o'))
-
-            callback = mock.Mock()
-            stub.SomeUnary.invoke(callback, magic_number=5)
-
-            callback.assert_called_once_with(
-                _rpc(stub.SomeUnary), Status.ABORTED,
-                method.response_type(payload='0_o'))
-
-            self.assertEqual(
-                5,
-                self._sent_payload(method.request_type).magic_number)
-
-    def test_invoke_unary_rpc_callback_errors_suppressed(self):
-        stub = self._client.channel(1).rpcs.pw.test1.PublicService.SomeUnary
-
-        self._enqueue_response(1, stub.method)
-        exception_msg = 'YOU BROKE IT O-]-<'
-
-        with self.assertLogs(callback_client.__name__, 'ERROR') as logs:
-            stub.invoke(mock.Mock(side_effect=Exception(exception_msg)))
-
-        self.assertIn(exception_msg, ''.join(logs.output))
-
-        # Make sure we can still invoke the RPC.
-        self._enqueue_response(1, stub.method, Status.UNKNOWN)
-        status, _ = stub()
-        self.assertIs(status, Status.UNKNOWN)
-
-    def test_invoke_unary_rpc_with_callback_cancel(self):
-        stub = self._client.channel(1).rpcs.pw.test1.PublicService
-        callback = mock.Mock()
-
-        for _ in range(3):
-            call = stub.SomeUnary.invoke(callback, magic_number=55)
-
-            self.assertIsNotNone(self._last_request)
-            self._last_request = None
-
-            # Try to invoke the RPC again before cancelling, which is an error.
-            with self.assertRaises(client.Error):
-                stub.SomeUnary.invoke(callback, magic_number=56)
-
-            self.assertTrue(call.cancel())
-            self.assertFalse(call.cancel())  # Already cancelled, returns False
-
-            # Unary RPCs do not send a cancel request to the server.
-            self.assertIsNone(self._last_request)
-
-        callback.assert_not_called()
-
-    def test_reinvoke_unary_rpc(self):
-        stub = self._client.channel(1).rpcs.pw.test1.PublicService
-        callback = mock.Mock()
-
-        # The reinvoke method ignores pending rpcs, so can be called repeatedly.
-        for _ in range(3):
-            self._last_request = None
-            stub.SomeUnary.reinvoke(callback, magic_number=55)
-            self.assertEqual(self._last_request.type,
-                             packets.PacketType.REQUEST)
-
-    def test_invoke_server_streaming(self):
-        stub = self._client.channel(1).rpcs.pw.test1.PublicService
-        method = stub.SomeServerStreaming.method
-
-        rep1 = method.response_type(payload='!!!')
-        rep2 = method.response_type(payload='?')
-
-        for _ in range(3):
-            self._enqueue_response(1, method, response=rep1)
-            self._enqueue_response(1, method, response=rep2)
-            self._enqueue_stream_end(1, method, Status.ABORTED)
-
-            self.assertEqual([rep1, rep2],
-                             list(stub.SomeServerStreaming(magic_number=4)))
-
-            self.assertEqual(
-                4,
-                self._sent_payload(method.request_type).magic_number)
-
-    def test_invoke_server_streaming_with_callback(self):
-        stub = self._client.channel(1).rpcs.pw.test1.PublicService
-        method = stub.SomeServerStreaming.method
-
-        rep1 = method.response_type(payload='!!!')
-        rep2 = method.response_type(payload='?')
-
-        for _ in range(3):
-            self._enqueue_response(1, method, response=rep1)
-            self._enqueue_response(1, method, response=rep2)
-            self._enqueue_stream_end(1, method, Status.ABORTED)
-
-            callback = mock.Mock()
-            stub.SomeServerStreaming.invoke(callback, magic_number=3)
-
-            rpc = _rpc(stub.SomeServerStreaming)
-            callback.assert_has_calls([
-                mock.call(rpc, None, method.response_type(payload='!!!')),
-                mock.call(rpc, None, method.response_type(payload='?')),
-                mock.call(rpc, Status.ABORTED, None),
-            ])
-
-            self.assertEqual(
-                3,
-                self._sent_payload(method.request_type).magic_number)
-
-    def test_invoke_server_streaming_with_callback_cancel(self):
-        stub = self._client.channel(
-            1).rpcs.pw.test1.PublicService.SomeServerStreaming
-
-        resp = stub.method.response_type(payload='!!!')
-        self._enqueue_response(1, stub.method, response=resp)
-
-        callback = mock.Mock()
-        call = stub.invoke(callback, magic_number=3)
-        callback.assert_called_once_with(
-            _rpc(stub), None, stub.method.response_type(payload='!!!'))
-
-        callback.reset_mock()
-
-        call.cancel()
-
-        self.assertEqual(self._last_request.type,
-                         packets.PacketType.CANCEL_SERVER_STREAM)
-
-        # Ensure the RPC can be called after being cancelled.
-        self._enqueue_response(1, stub.method, response=resp)
-        self._enqueue_stream_end(1, stub.method, Status.OK)
-
-        call = stub.invoke(callback, magic_number=3)
-
-        rpc = _rpc(stub)
-        callback.assert_has_calls([
-            mock.call(rpc, None, stub.method.response_type(payload='!!!')),
-            mock.call(rpc, Status.OK, None),
-        ])
-
-    def test_ignore_bad_packets_with_pending_rpc(self):
-        rpcs = self._client.channel(1).rpcs
-        method = rpcs.pw.test1.PublicService.SomeUnary.method
-        service_id = method.service.id
-
-        # Unknown channel
-        self._enqueue_response(999, method, process_status=Status.NOT_FOUND)
-        # Bad service
-        self._enqueue_response(1,
-                               ids=(999, method.id),
-                               process_status=Status.OK)
-        # Bad method
-        self._enqueue_response(1,
-                               ids=(service_id, 999),
-                               process_status=Status.OK)
-        # For RPC not pending (is Status.OK because the packet is processed)
-        self._enqueue_response(
-            1,
-            ids=(service_id,
-                 rpcs.pw.test1.PublicService.SomeBidiStreaming.method.id),
-            process_status=Status.OK)
-
-        self._enqueue_response(1, method, process_status=Status.OK)
-
-        status, response = rpcs.pw.test1.PublicService.SomeUnary(
-            magic_number=6)
-        self.assertIs(Status.OK, status)
-        self.assertEqual('', response.payload)
-
-    def test_pass_none_if_payload_fails_to_decode(self):
-        rpcs = self._client.channel(1).rpcs
-        method = rpcs.pw.test1.PublicService.SomeUnary.method
-
-        self._enqueue_response(1,
-                               method,
-                               Status.OK,
-                               b'INVALID DATA!!!',
-                               process_status=Status.OK)
-
-        status, response = rpcs.pw.test1.PublicService.SomeUnary(
-            magic_number=6)
-        self.assertIs(status, Status.OK)
-        self.assertIsNone(response)
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/pw_rpc/py/client_test.py b/pw_rpc/py/client_test.py
deleted file mode 100755
index cb78992..0000000
--- a/pw_rpc/py/client_test.py
+++ /dev/null
@@ -1,294 +0,0 @@
-#!/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 creating pw_rpc client."""
-
-import unittest
-
-from pw_protobuf_compiler import python_protos
-from pw_rpc import callback_client, client, packets
-from pw_rpc.packet_pb2 import RpcPacket
-import pw_rpc.ids
-from pw_status import Status
-
-TEST_PROTO_1 = """\
-syntax = "proto3";
-
-package pw.test1;
-
-message SomeMessage {
-  uint32 magic_number = 1;
-}
-
-message AnotherMessage {
-  enum Result {
-    FAILED = 0;
-    FAILED_MISERABLY = 1;
-    I_DONT_WANT_TO_TALK_ABOUT_IT = 2;
-  }
-
-  Result result = 1;
-  string payload = 2;
-}
-
-service PublicService {
-  rpc SomeUnary(SomeMessage) returns (AnotherMessage) {}
-  rpc SomeServerStreaming(SomeMessage) returns (stream AnotherMessage) {}
-  rpc SomeClientStreaming(stream SomeMessage) returns (AnotherMessage) {}
-  rpc SomeBidiStreaming(stream SomeMessage) returns (stream AnotherMessage) {}
-}
-"""
-
-TEST_PROTO_2 = """\
-syntax = "proto2";
-
-package pw.test2;
-
-message Request {
-  optional float magic_number = 1;
-}
-
-message Response {
-}
-
-service Alpha {
-  rpc Unary(Request) returns (Response) {}
-}
-
-service Bravo {
-  rpc BidiStreaming(stream Request) returns (stream Response) {}
-}
-"""
-
-
-def _test_setup(output=None):
-    protos = python_protos.Library.from_strings([TEST_PROTO_1, TEST_PROTO_2])
-    return protos, client.Client.from_modules(callback_client.Impl(),
-                                              [client.Channel(1, output)],
-                                              protos.modules())
-
-
-class ChannelClientTest(unittest.TestCase):
-    """Tests the ChannelClient."""
-    def setUp(self):
-        self._channel_client = _test_setup()[1].channel(1)
-
-    def test_access_service_client_as_attribute_or_index(self):
-        self.assertIs(self._channel_client.rpcs.pw.test1.PublicService,
-                      self._channel_client.rpcs['pw.test1.PublicService'])
-        self.assertIs(
-            self._channel_client.rpcs.pw.test1.PublicService,
-            self._channel_client.rpcs[pw_rpc.ids.calculate(
-                'pw.test1.PublicService')])
-
-    def test_access_method_client_as_attribute_or_index(self):
-        self.assertIs(self._channel_client.rpcs.pw.test2.Alpha.Unary,
-                      self._channel_client.rpcs['pw.test2.Alpha']['Unary'])
-        self.assertIs(
-            self._channel_client.rpcs.pw.test2.Alpha.Unary,
-            self._channel_client.rpcs['pw.test2.Alpha'][pw_rpc.ids.calculate(
-                'Unary')])
-
-    def test_service_name(self):
-        self.assertEqual(
-            self._channel_client.rpcs.pw.test2.Alpha.Unary.service.name,
-            'Alpha')
-        self.assertEqual(
-            self._channel_client.rpcs.pw.test2.Alpha.Unary.service.full_name,
-            'pw.test2.Alpha')
-
-    def test_method_name(self):
-        self.assertEqual(
-            self._channel_client.rpcs.pw.test2.Alpha.Unary.method.name,
-            'Unary')
-        self.assertEqual(
-            self._channel_client.rpcs.pw.test2.Alpha.Unary.method.full_name,
-            'pw.test2.Alpha.Unary')
-
-    def test_iterate_over_all_methods(self):
-        channel_client = self._channel_client
-        all_methods = {
-            channel_client.rpcs.pw.test1.PublicService.SomeUnary,
-            channel_client.rpcs.pw.test1.PublicService.SomeServerStreaming,
-            channel_client.rpcs.pw.test1.PublicService.SomeClientStreaming,
-            channel_client.rpcs.pw.test1.PublicService.SomeBidiStreaming,
-            channel_client.rpcs.pw.test2.Alpha.Unary,
-            channel_client.rpcs.pw.test2.Bravo.BidiStreaming,
-        }
-        self.assertEqual(set(channel_client.methods()), all_methods)
-
-    def test_check_for_presence_of_services(self):
-        self.assertIn('pw.test1.PublicService', self._channel_client.rpcs)
-        self.assertIn(pw_rpc.ids.calculate('pw.test1.PublicService'),
-                      self._channel_client.rpcs)
-
-    def test_check_for_presence_of_missing_services(self):
-        self.assertNotIn('PublicService', self._channel_client.rpcs)
-        self.assertNotIn('NotAService', self._channel_client.rpcs)
-        self.assertNotIn(-1213, self._channel_client.rpcs)
-
-    def test_check_for_presence_of_methods(self):
-        service = self._channel_client.rpcs.pw.test1.PublicService
-        self.assertIn('SomeUnary', service)
-        self.assertIn(pw_rpc.ids.calculate('SomeUnary'), service)
-
-    def test_check_for_presence_of_missing_methods(self):
-        service = self._channel_client.rpcs.pw.test1.PublicService
-        self.assertNotIn('Some', service)
-        self.assertNotIn('Unary', service)
-        self.assertNotIn(12345, service)
-
-    def test_method_fully_qualified_name(self):
-        self.assertIs(self._channel_client.method('pw.test2.Alpha/Unary'),
-                      self._channel_client.rpcs.pw.test2.Alpha.Unary)
-        self.assertIs(self._channel_client.method('pw.test2.Alpha.Unary'),
-                      self._channel_client.rpcs.pw.test2.Alpha.Unary)
-
-
-class ClientTest(unittest.TestCase):
-    """Tests the pw_rpc Client independently of the ClientImpl."""
-    def setUp(self):
-        self._last_packet_sent_bytes = None
-        self._protos, self._client = _test_setup(self._save_packet)
-
-    def _save_packet(self, packet):
-        self._last_packet_sent_bytes = packet
-
-    def _last_packet_sent(self):
-        packet = RpcPacket()
-        self.assertIsNotNone(self._last_packet_sent_bytes)
-        packet.MergeFromString(self._last_packet_sent_bytes)
-        return packet
-
-    def test_all_methods(self):
-        services = self._client.services
-
-        all_methods = {
-            services['pw.test1.PublicService'].methods['SomeUnary'],
-            services['pw.test1.PublicService'].methods['SomeServerStreaming'],
-            services['pw.test1.PublicService'].methods['SomeClientStreaming'],
-            services['pw.test1.PublicService'].methods['SomeBidiStreaming'],
-            services['pw.test2.Alpha'].methods['Unary'],
-            services['pw.test2.Bravo'].methods['BidiStreaming'],
-        }
-        self.assertEqual(set(self._client.methods()), all_methods)
-
-    def test_method_present(self):
-        self.assertIs(
-            self._client.method('pw.test1.PublicService.SomeUnary'), self.
-            _client.services['pw.test1.PublicService'].methods['SomeUnary'])
-        self.assertIs(
-            self._client.method('pw.test1.PublicService/SomeUnary'), self.
-            _client.services['pw.test1.PublicService'].methods['SomeUnary'])
-
-    def test_method_invalid_format(self):
-        with self.assertRaises(ValueError):
-            self._client.method('SomeUnary')
-
-    def test_method_not_present(self):
-        with self.assertRaises(KeyError):
-            self._client.method('pw.test1.PublicService/ThisIsNotGood')
-
-        with self.assertRaises(KeyError):
-            self._client.method('nothing.Good')
-
-    def test_get_request_with_both_message_and_kwargs(self):
-        method = self._client.services['pw.test2.Alpha'].methods['Unary']
-
-        with self.assertRaisesRegex(TypeError, r'either'):
-            method.get_request(method.request_type(), {'magic_number': 1.0})
-
-    def test_get_request_with_wrong_type(self):
-        method = self._client.services['pw.test2.Alpha'].methods['Unary']
-        with self.assertRaisesRegex(TypeError, r'pw\.test2\.Request'):
-            method.get_request('a str!', {})
-
-    def test_get_request_with_incorrect_message_type(self):
-        msg = self._protos.packages.pw.test1.AnotherMessage()
-        with self.assertRaisesRegex(TypeError, r'pw\.test1\.SomeMessage'):
-            self._client.services['pw.test1.PublicService'].methods[
-                'SomeUnary'].get_request(msg, {})
-
-    def test_process_packet_invalid_proto_data(self):
-        self.assertIs(self._client.process_packet(b'NOT a packet!'),
-                      Status.DATA_LOSS)
-
-    def test_process_packet_not_for_client(self):
-        self.assertIs(
-            self._client.process_packet(
-                RpcPacket(
-                    type=packets.PacketType.REQUEST).SerializeToString()),
-            Status.INVALID_ARGUMENT)
-
-    def test_process_packet_unrecognized_channel(self):
-        self.assertIs(
-            self._client.process_packet(
-                packets.encode_response(
-                    (123, 456, 789),
-                    self._protos.packages.pw.test2.Request())),
-            Status.NOT_FOUND)
-
-    def test_process_packet_unrecognized_service(self):
-        self.assertIs(
-            self._client.process_packet(
-                packets.encode_response(
-                    (1, 456, 789), self._protos.packages.pw.test2.Request())),
-            Status.OK)
-
-        self.assertEqual(
-            self._last_packet_sent(),
-            RpcPacket(type=packets.PacketType.CLIENT_ERROR,
-                      channel_id=1,
-                      service_id=456,
-                      method_id=789,
-                      status=Status.NOT_FOUND.value))
-
-    def test_process_packet_unrecognized_method(self):
-        service = next(iter(self._client.services))
-
-        self.assertIs(
-            self._client.process_packet(
-                packets.encode_response(
-                    (1, service.id, 789),
-                    self._protos.packages.pw.test2.Request())), Status.OK)
-
-        self.assertEqual(
-            self._last_packet_sent(),
-            RpcPacket(type=packets.PacketType.CLIENT_ERROR,
-                      channel_id=1,
-                      service_id=service.id,
-                      method_id=789,
-                      status=Status.NOT_FOUND.value))
-
-    def test_process_packet_non_pending_method(self):
-        service = next(iter(self._client.services))
-        method = next(iter(service.methods))
-
-        self.assertIs(
-            self._client.process_packet(
-                packets.encode_response(
-                    (1, service.id, method.id),
-                    self._protos.packages.pw.test2.Request())), Status.OK)
-
-        self.assertEqual(
-            self._last_packet_sent(),
-            RpcPacket(type=packets.PacketType.CLIENT_ERROR,
-                      channel_id=1,
-                      service_id=service.id,
-                      method_id=method.id,
-                      status=Status.FAILED_PRECONDITION.value))
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/pw_rpc/py/codegen_test.py b/pw_rpc/py/codegen_test.py
deleted file mode 100644
index ee96a8c..0000000
--- a/pw_rpc/py/codegen_test.py
+++ /dev/null
@@ -1,181 +0,0 @@
-#!/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 the generated pw_rpc code."""
-
-from pathlib import Path
-import os
-import subprocess
-import tempfile
-import unittest
-
-TEST_PROTO_FILE = b"""\
-syntax = "proto3";
-
-package pw.rpc.test;
-
-message TestRequest {
-  float integer = 1;
-}
-
-message TestResponse {
-  int32 value = 1;
-}
-
-message TestStreamResponse {
-  bytes chunk = 1;
-}
-
-message Empty {}
-
-service TestService {
-  rpc TestRpc(TestRequest) returns (TestResponse) {}
-  rpc TestStreamRpc(Empty) returns (stream TestStreamResponse) {}
-}
-"""
-
-EXPECTED_NANOPB_CODE = """\
-#pragma once
-
-#include <array>
-#include <cstddef>
-#include <cstdint>
-#include <type_traits>
-
-#include "pw_rpc/internal/method.h"
-#include "pw_rpc/server_context.h"
-#include "pw_rpc/service.h"
-#include "test.pb.h"
-
-namespace pw::rpc::internal {
-
-template <auto>
-class ServiceMethodTraits;
-
-}  // namespace pw::rpc::internal
-
-namespace pw::rpc::test {
-namespace generated {
-
-template <typename Implementation>
-class TestService : public ::pw::rpc::Service {
- public:
-  using ServerContext = ::pw::rpc::ServerContext;
-  template <typename T>
-  using ServerWriter = ::pw::rpc::ServerWriter<T>;
-
-  constexpr TestService()
-      : ::pw::rpc::Service(kServiceId, kMethods) {}
-
-  TestService(const TestService&) = delete;
-  TestService& operator=(const TestService&) = delete;
-
-  static constexpr const char* name() { return "TestService"; }
-
-  // Used by ServiceMethodTraits to identify a base service.
-  constexpr void _PwRpcInternalGeneratedBase() const {}
-
- private:
-  // Hash of "pw.rpc.test.TestService".
-  static constexpr uint32_t kServiceId = 0xcc0f6de0;
-
-  static ::pw::Status Invoke_TestRpc(
-      ::pw::rpc::internal::ServerCall& call,
-      const pw_rpc_test_TestRequest& request,
-      pw_rpc_test_TestResponse& response) {
-    return static_cast<Implementation&>(call.service())
-        .TestRpc(call.context(), request, response);
-  }
-
-  static void Invoke_TestStreamRpc(
-      ::pw::rpc::internal::ServerCall& call,
-      const pw_rpc_test_TestRequest& request,
-      ServerWriter<pw_rpc_test_TestStreamResponse>& writer) {
-    static_cast<Implementation&>(call.service())
-        .TestStreamRpc(call.context(), request, writer);
-  }
-
-  static constexpr std::array<::pw::rpc::internal::Method, 2> kMethods = {
-      ::pw::rpc::internal::Method::Unary<Invoke_TestRpc>(
-          0xbc924054,  // Hash of "TestRpc"
-          pw_rpc_test_TestRequest_fields,
-          pw_rpc_test_TestResponse_fields),
-      ::pw::rpc::internal::Method::ServerStreaming<Invoke_TestStreamRpc>(
-          0xd97a28fa,  // Hash of "TestStreamRpc"
-          pw_rpc_test_TestRequest_fields,
-          pw_rpc_test_TestStreamResponse_fields),
-  };
-
-  template <auto impl_method>
-  static constexpr const ::pw::rpc::internal::Method* MethodFor() {
-    if constexpr (std::is_same_v<decltype(impl_method), decltype(&Implementation::TestRpc)>) {
-      if constexpr (impl_method == &Implementation::TestRpc) {
-        return &std::get<0>(kMethods);
-      }
-    }
-    if constexpr (std::is_same_v<decltype(impl_method), decltype(&Implementation::TestStreamRpc)>) {
-      if constexpr (impl_method == &Implementation::TestStreamRpc) {
-        return &std::get<1>(kMethods);
-      }
-    }
-    return nullptr;
-  }
-
-  template <auto>
-  friend class ::pw::rpc::internal::ServiceMethodTraits;
-};
-
-}  // namespace generated
-}  // namespace pw::rpc::test
-"""
-
-
-class TestNanopbCodegen(unittest.TestCase):
-    """Test case for nanopb code generation."""
-    def setUp(self):
-        self._output_dir = tempfile.TemporaryDirectory()
-
-    def tearDown(self):
-        self._output_dir.cleanup()
-
-    def test_nanopb_codegen(self):
-        root = Path(os.getenv('PW_ROOT'))
-        proto_dir = root / 'pw_rpc' / 'pw_rpc_test_protos'
-        proto_file = proto_dir / 'test.proto'
-
-        venv_bin = 'Scripts' if os.name == 'nt' else 'bin'
-        plugin = root / '.python3-env' / venv_bin / 'pw_rpc_codegen'
-
-        command = (
-            'protoc',
-            f'-I{proto_dir}',
-            proto_file,
-            '--plugin',
-            f'protoc-gen-custom={plugin}',
-            '--custom_out',
-            self._output_dir.name,
-        )
-
-        subprocess.run(command)
-
-        generated_files = os.listdir(self._output_dir.name)
-        self.assertEqual(len(generated_files), 1)
-        self.assertEqual(generated_files[0], 'test.rpc.pb.h')
-
-        # Read the generated file, ignoring its preamble.
-        generated_code = Path(self._output_dir.name,
-                              generated_files[0]).read_text()
-        generated_code = generated_code[generated_code.index('#pragma'):]
-
-        self.assertEqual(generated_code, EXPECTED_NANOPB_CODE)
diff --git a/pw_rpc/py/docs.rst b/pw_rpc/py/docs.rst
new file mode 100644
index 0000000..5aeea46
--- /dev/null
+++ b/pw_rpc/py/docs.rst
@@ -0,0 +1,23 @@
+.. _module-pw_rpc-py:
+
+---------------------
+pw_rpc Python package
+---------------------
+The ``pw_rpc`` Python package makes it possible to call Pigweed RPCs from
+Python. The package includes a ``pw_rpc`` client library, as well as tools for
+creating a ``pw_rpc`` console.
+
+pw_rpc.client
+=============
+.. automodule:: pw_rpc.client
+  :members: Client, ClientImpl
+
+pw_rpc.callback_client
+======================
+.. autoclass:: pw_rpc.callback_client.Impl
+  :members:
+
+pw_rpc.console_tools
+====================
+.. automodule:: pw_rpc.console_tools
+  :members: Context, ClientInfo, Watchdog, help_as_repr
diff --git a/pw_rpc/py/packets_test.py b/pw_rpc/py/packets_test.py
deleted file mode 100755
index c8ed99e..0000000
--- a/pw_rpc/py/packets_test.py
+++ /dev/null
@@ -1,96 +0,0 @@
-#!/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 creating pw_rpc client."""
-
-import unittest
-
-from pw_rpc import packets
-from pw_rpc.packet_pb2 import RpcPacket
-from pw_status import Status
-
-_TEST_REQUEST = RpcPacket(type=packets.PacketType.REQUEST,
-                          channel_id=1,
-                          service_id=2,
-                          method_id=3,
-                          payload=RpcPacket(status=321).SerializeToString())
-
-
-class PacketsTest(unittest.TestCase):
-    """Tests for packet encoding and decoding."""
-    def test_encode_request(self):
-        data = packets.encode_request((1, 2, 3), RpcPacket(status=321))
-        packet = RpcPacket()
-        packet.ParseFromString(data)
-
-        self.assertEqual(_TEST_REQUEST, packet)
-
-    def test_encode_response(self):
-        response = RpcPacket(type=packets.PacketType.RESPONSE,
-                             channel_id=1,
-                             service_id=2,
-                             method_id=3,
-                             payload=RpcPacket(status=321).SerializeToString())
-
-        data = packets.encode_response((1, 2, 3), RpcPacket(status=321))
-        packet = RpcPacket()
-        packet.ParseFromString(data)
-
-        self.assertEqual(response, packet)
-
-    def test_encode_cancel(self):
-        data = packets.encode_cancel((9, 8, 7))
-
-        packet = RpcPacket()
-        packet.ParseFromString(data)
-
-        self.assertEqual(
-            packet,
-            RpcPacket(type=packets.PacketType.CANCEL_SERVER_STREAM,
-                      channel_id=9,
-                      service_id=8,
-                      method_id=7))
-
-    def test_encode_client_error(self):
-        data = packets.encode_client_error(_TEST_REQUEST, Status.NOT_FOUND)
-
-        packet = RpcPacket()
-        packet.ParseFromString(data)
-
-        self.assertEqual(
-            packet,
-            RpcPacket(type=packets.PacketType.CLIENT_ERROR,
-                      channel_id=1,
-                      service_id=2,
-                      method_id=3,
-                      status=Status.NOT_FOUND.value))
-
-    def test_decode(self):
-        self.assertEqual(_TEST_REQUEST,
-                         packets.decode(_TEST_REQUEST.SerializeToString()))
-
-    def test_for_server(self):
-        self.assertTrue(packets.for_server(_TEST_REQUEST))
-
-        self.assertFalse(
-            packets.for_server(
-                RpcPacket(type=packets.PacketType.RESPONSE,
-                          channel_id=1,
-                          service_id=2,
-                          method_id=3,
-                          payload=RpcPacket(status=321).SerializeToString())))
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/pw_rpc/py/pw_rpc/callback_client.py b/pw_rpc/py/pw_rpc/callback_client.py
index e61490a..ea7fad8 100644
--- a/pw_rpc/py/pw_rpc/callback_client.py
+++ b/pw_rpc/py/pw_rpc/callback_client.py
@@ -1,4 +1,4 @@
-# Copyright 2020 The Pigweed Authors
+# Copyright 2021 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
@@ -11,7 +11,7 @@
 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 # License for the specific language governing permissions and limitations under
 # the License.
-"""Defines a callback-based RPC ClientImpl to use with pw_rpc.client.Client.
+"""Defines a callback-based RPC ClientImpl to use with pw_rpc.Client.
 
 callback_client.Impl supports invoking RPCs synchronously or asynchronously.
 Asynchronous invocations use a callback.
@@ -40,26 +40,63 @@
 kwargs for the message fields (but not both).
 """
 
+import enum
+import inspect
 import logging
 import queue
-from typing import Any, Callable, Optional, Tuple
+import textwrap
+import threading
+from typing import Any, Callable, Iterator, NamedTuple, Union, Optional
 
-from pw_rpc import client
-from pw_rpc.descriptors import Channel, Method, Service
+from pw_protobuf_compiler.python_protos import proto_repr
 from pw_status import Status
 
+from pw_rpc import client, descriptors
+from pw_rpc.client import PendingRpc, PendingRpcs
+from pw_rpc.descriptors import Channel, Method, Service
+
 _LOG = logging.getLogger(__name__)
 
-Callback = Callable[[client.PendingRpc, Optional[Status], Any], Any]
+
+class UseDefault(enum.Enum):
+    """Marker for args that should use a default value, when None is valid."""
+    VALUE = 0
+
+
+OptionalTimeout = Union[UseDefault, float, None]
+
+ResponseCallback = Callable[[PendingRpc, Any], Any]
+CompletionCallback = Callable[[PendingRpc, Status], Any]
+ErrorCallback = Callable[[PendingRpc, Status], Any]
+
+
+class _Callbacks(NamedTuple):
+    response: ResponseCallback
+    completion: CompletionCallback
+    error: ErrorCallback
+
+
+def _default_response(rpc: PendingRpc, response: Any) -> None:
+    _LOG.info('%s response: %s', rpc, response)
+
+
+def _default_completion(rpc: PendingRpc, status: Status) -> None:
+    _LOG.info('%s finished: %s', rpc, status)
+
+
+def _default_error(rpc: PendingRpc, status: Status) -> None:
+    _LOG.error('%s error: %s', rpc, status)
 
 
 class _MethodClient:
     """A method that can be invoked for a particular channel."""
-    def __init__(self, client_impl: 'Impl', rpcs: client.PendingRpcs,
-                 channel: Channel, method: Method):
+    def __init__(self, client_impl: 'Impl', rpcs: PendingRpcs,
+                 channel: Channel, method: Method,
+                 default_timeout_s: Optional[float]):
         self._impl = client_impl
         self._rpcs = rpcs
-        self._rpc = client.PendingRpc(channel, method.service, method)
+        self._rpc = PendingRpc(channel, method.service, method)
+        self.default_timeout_s: Optional[float] = default_timeout_s
 
     @property
     def channel(self) -> Channel:
@@ -73,26 +110,65 @@
     def service(self) -> Service:
         return self._rpc.service
 
-    def invoke(self, callback: Callback, _request=None, **request_fields):
-        """Invokes an RPC with a callback."""
+    def invoke(self,
+               request: Any,
+               response: ResponseCallback = _default_response,
+               completion: CompletionCallback = _default_completion,
+               error: ErrorCallback = _default_error,
+               *,
+               override_pending: bool = True,
+               keep_open: bool = False) -> '_AsyncCall':
+        """Invokes an RPC with callbacks."""
         self._rpcs.send_request(self._rpc,
-                                self.method.get_request(
-                                    _request, request_fields),
-                                callback,
-                                override_pending=False)
-        return _AsyncCall(self._rpcs, self._rpc)
-
-    def reinvoke(self, callback: Callback, _request=None, **request_fields):
-        """Invokes an RPC with a callback, overriding any pending requests."""
-        self._rpcs.send_request(self._rpc,
-                                self.method.get_request(
-                                    _request, request_fields),
-                                callback,
-                                override_pending=True)
+                                request,
+                                _Callbacks(response, completion, error),
+                                override_pending=override_pending,
+                                keep_open=keep_open)
         return _AsyncCall(self._rpcs, self._rpc)
 
     def __repr__(self) -> str:
-        return repr(self.method)
+        return self.help()
+
+    def __call__(self):
+        raise NotImplementedError('Implemented by derived classes')
+
+    def help(self) -> str:
+        """Returns a help message about this RPC."""
+        function_call = self.method.full_name + '('
+
+        docstring = inspect.getdoc(self.__call__)
+        assert docstring is not None
+
+        annotation = inspect.Signature.from_callable(self).return_annotation
+        if isinstance(annotation, type):
+            annotation = annotation.__name__
+
+        arg_sep = f',\n{" " * len(function_call)}'
+        return (
+            f'{function_call}'
+            f'{arg_sep.join(descriptors.field_help(self.method.request_type))})'
+            f'\n\n{textwrap.indent(docstring, "  ")}\n\n'
+            f'  Returns {annotation}.')
+
+
+class RpcTimeout(Exception):
+    def __init__(self, rpc: PendingRpc, timeout: Optional[float]):
+        super().__init__(
+            f'No response received for {rpc.method} after {timeout} s')
+        self.rpc = rpc
+        self.timeout = timeout
+
+
+class RpcError(Exception):
+    def __init__(self, rpc: PendingRpc, status: Status):
+        if status is Status.NOT_FOUND:
+            msg = ': the RPC server does not support this RPC'
+        else:
+            msg = ''
+
+        super().__init__(f'{rpc.method} failed with error {status}{msg}')
+        self.rpc = rpc
+        self.status = status
 
 
 class _AsyncCall:
@@ -100,12 +176,12 @@
 
     # TODO(hepler): Consider alternatives (futures) and/or expand functionality.
 
-    def __init__(self, rpcs: client.PendingRpcs, rpc: client.PendingRpc):
-        self.rpc = rpc
+    def __init__(self, rpcs: PendingRpcs, rpc: PendingRpc):
+        self._rpc = rpc
         self._rpcs = rpcs
 
     def cancel(self) -> bool:
-        return self._rpcs.send_cancel(self.rpc)
+        return self._rpcs.send_cancel(self._rpc)
 
     def __enter__(self) -> '_AsyncCall':
         return self
@@ -114,47 +190,213 @@
         self.cancel()
 
 
-class _StreamingResponses:
+class StreamingResponses:
     """Used to iterate over a queue.SimpleQueue."""
-    def __init__(self, responses: queue.SimpleQueue):
+    def __init__(self, method_client: _MethodClient,
+                 responses: queue.SimpleQueue,
+                 default_timeout_s: OptionalTimeout):
+        self._method_client = method_client
         self._queue = responses
         self.status: Optional[Status] = None
 
-    def get(self, block: bool = True, timeout_s: float = None):
-        while True:
-            self.status, response = self._queue.get(block, timeout_s)
-            if self.status is not None:
-                return
+        if default_timeout_s is UseDefault.VALUE:
+            self.default_timeout_s = self._method_client.default_timeout_s
+        else:
+            self.default_timeout_s = default_timeout_s
 
-            yield response
+    @property
+    def method(self) -> Method:
+        return self._method_client.method
+
+    def cancel(self) -> None:
+        self._method_client._rpcs.send_cancel(self._method_client._rpc)  # pylint: disable=protected-access
+
+    def responses(self,
+                  *,
+                  block: bool = True,
+                  timeout_s: OptionalTimeout = UseDefault.VALUE) -> Iterator:
+        """Returns an iterator of stream responses.
+
+        Args:
+          timeout_s: timeout in seconds; None blocks indefinitely
+        """
+        if timeout_s is UseDefault.VALUE:
+            timeout_s = self.default_timeout_s
+
+        try:
+            while True:
+                response = self._queue.get(block, timeout_s)
+
+                if isinstance(response, Exception):
+                    raise response
+
+                if isinstance(response, Status):
+                    self.status = response
+                    return
+
+                yield response
+        except queue.Empty:
+            self.cancel()
+            raise RpcTimeout(self._method_client._rpc, timeout_s)  # pylint: disable=protected-access
+        except:
+            self.cancel()
+            raise
 
     def __iter__(self):
-        return self.get()
+        return self.responses()
+
+    def __repr__(self) -> str:
+        return f'{type(self).__name__}({self.method})'
 
 
-class UnaryMethodClient(_MethodClient):
-    def __call__(self, _request=None, **request_fields) -> Tuple[Status, Any]:
+def _method_client_docstring(method: Method) -> str:
+    return f'''\
+Class that invokes the {method.full_name} {method.type.sentence_name()} RPC.
+
+Calling this directly invokes the RPC synchronously. The RPC can be invoked
+asynchronously using the invoke method.
+'''
+
+
+def _function_docstring(method: Method) -> str:
+    return f'''\
+Invokes the {method.full_name} {method.type.sentence_name()} RPC.
+
+This function accepts either the request protobuf fields as keyword arguments or
+a request protobuf as a positional argument.
+'''
+
+
+def _update_function_signature(method: Method, function: Callable) -> None:
+    """Updates the name, docstring, and parameters to match a method."""
+    function.__name__ = method.full_name
+    function.__doc__ = _function_docstring(method)
+
+    # In order to have good tab completion and help messages, update the
+    # function signature to accept only keyword arguments for the proto message
+    # fields. This doesn't actually change the function signature -- it just
+    # updates how it appears when inspected.
+    sig = inspect.signature(function)
+
+    params = [next(iter(sig.parameters.values()))]  # Get the "self" parameter
+    params += method.request_parameters()
+    params.append(
+        inspect.Parameter('pw_rpc_timeout_s', inspect.Parameter.KEYWORD_ONLY))
+    function.__signature__ = sig.replace(  # type: ignore[attr-defined]
+        parameters=params)
+
+
+class UnaryResponse(NamedTuple):
+    """Result of invoking a unary RPC: status and response."""
+    status: Status
+    response: Any
+
+    def __repr__(self) -> str:
+        return f'({self.status}, {proto_repr(self.response)})'
+
+
+class _UnaryResponseHandler:
+    """Tracks the state of an ongoing synchronous unary RPC call."""
+    def __init__(self, rpc: PendingRpc):
+        self._rpc = rpc
+        self._response: Any = None
+        self._status: Optional[Status] = None
+        self._error: Optional[RpcError] = None
+        self._event = threading.Event()
+
+    def on_response(self, _: PendingRpc, response: Any) -> None:
+        self._response = response
+
+    def on_completion(self, _: PendingRpc, status: Status) -> None:
+        self._status = status
+        self._event.set()
+
+    def on_error(self, _: PendingRpc, status: Status) -> None:
+        self._error = RpcError(self._rpc, status)
+        self._event.set()
+
+    def wait(self, timeout_s: Optional[float]) -> UnaryResponse:
+        if not self._event.wait(timeout_s):
+            raise RpcTimeout(self._rpc, timeout_s)
+
+        if self._error is not None:
+            raise self._error
+
+        assert self._status is not None
+        return UnaryResponse(self._status, self._response)
+
+
+def _unary_method_client(client_impl: 'Impl', rpcs: PendingRpcs,
+                         channel: Channel, method: Method,
+                         default_timeout: Optional[float]) -> _MethodClient:
+    """Creates an object used to call a unary method."""
+    def call(self: _MethodClient,
+             _rpc_request_proto=None,
+             *,
+             pw_rpc_timeout_s=UseDefault.VALUE,
+             **request_fields) -> UnaryResponse:
+
+        handler = _UnaryResponseHandler(self._rpc)  # pylint: disable=protected-access
+        self.invoke(
+            self.method.get_request(_rpc_request_proto, request_fields),
+            handler.on_response, handler.on_completion, handler.on_error)
+
+        if pw_rpc_timeout_s is UseDefault.VALUE:
+            pw_rpc_timeout_s = self.default_timeout_s
+
+        return handler.wait(pw_rpc_timeout_s)
+
+    _update_function_signature(method, call)
+
+    # The MethodClient class is created dynamically so that the __call__ method
+    # can be configured differently for each method.
+    method_client_type = type(
+        f'{method.name}_UnaryMethodClient', (_MethodClient, ),
+        dict(__call__=call, __doc__=_method_client_docstring(method)))
+    return method_client_type(client_impl, rpcs, channel, method,
+                              default_timeout)
+
+
+def _server_streaming_method_client(client_impl: 'Impl', rpcs: PendingRpcs,
+                                    channel: Channel, method: Method,
+                                    default_timeout: Optional[float]):
+    """Creates an object used to call a server streaming method."""
+    def call(self: _MethodClient,
+             _rpc_request_proto=None,
+             *,
+             pw_rpc_timeout_s=UseDefault.VALUE,
+             **request_fields) -> StreamingResponses:
         responses: queue.SimpleQueue = queue.SimpleQueue()
-        self.reinvoke(
-            lambda _, status, payload: responses.put((status, payload)),
-            _request, **request_fields)
-        return responses.get()
+        self.invoke(
+            self.method.get_request(_rpc_request_proto, request_fields),
+            lambda _, response: responses.put(response),
+            lambda _, status: responses.put(status),
+            lambda rpc, status: responses.put(RpcError(rpc, status)))
+        return StreamingResponses(self, responses, pw_rpc_timeout_s)
 
+    _update_function_signature(method, call)
 
-class ServerStreamingMethodClient(_MethodClient):
-    def __call__(self, _request=None, **request_fields) -> _StreamingResponses:
-        responses: queue.SimpleQueue = queue.SimpleQueue()
-        self.reinvoke(
-            lambda _, status, payload: responses.put((status, payload)),
-            _request, **request_fields)
-        return _StreamingResponses(responses)
+    # The MethodClient class is created dynamically so that the __call__ method
+    # can be configured differently for each method type.
+    method_client_type = type(
+        f'{method.name}_ServerStreamingMethodClient', (_MethodClient, ),
+        dict(__call__=call, __doc__=_method_client_docstring(method)))
+    return method_client_type(client_impl, rpcs, channel, method,
+                              default_timeout)
 
 
 class ClientStreamingMethodClient(_MethodClient):
     def __call__(self):
         raise NotImplementedError
 
-    def invoke(self, callback: Callback, _request=None, **request_fields):
+    def invoke(self,
+               request: Any,
+               response: ResponseCallback = _default_response,
+               completion: CompletionCallback = _default_completion,
+               error: ErrorCallback = _default_error,
+               *,
+               override_pending: bool = True,
+               keep_open: bool = False) -> _AsyncCall:
         raise NotImplementedError
 
 
@@ -162,40 +404,65 @@
     def __call__(self):
         raise NotImplementedError
 
-    def invoke(self, callback: Callback, _request=None, **request_fields):
+    def invoke(self,
+               request: Any,
+               response: ResponseCallback = _default_response,
+               completion: CompletionCallback = _default_completion,
+               error: ErrorCallback = _default_error,
+               *,
+               override_pending: bool = True,
+               keep_open: bool = False) -> _AsyncCall:
         raise NotImplementedError
 
 
 class Impl(client.ClientImpl):
-    """Callback-based client.ClientImpl."""
-    def method_client(self, rpcs: client.PendingRpcs, channel: Channel,
-                      method: Method) -> _MethodClient:
+    """Callback-based ClientImpl."""
+    def __init__(self,
+                 default_unary_timeout_s: Optional[float] = 1.0,
+                 default_stream_timeout_s: Optional[float] = 1.0):
+        super().__init__()
+        self._default_unary_timeout_s = default_unary_timeout_s
+        self._default_stream_timeout_s = default_stream_timeout_s
+
+    @property
+    def default_unary_timeout_s(self) -> Optional[float]:
+        return self._default_unary_timeout_s
+
+    @property
+    def default_stream_timeout_s(self) -> Optional[float]:
+        return self._default_stream_timeout_s
+
+    def method_client(self, channel: Channel, method: Method) -> _MethodClient:
         """Returns an object that invokes a method using the given chanel."""
 
         if method.type is Method.Type.UNARY:
-            return UnaryMethodClient(self, rpcs, channel, method)
+            return _unary_method_client(self, self.rpcs, channel, method,
+                                        self.default_unary_timeout_s)
 
         if method.type is Method.Type.SERVER_STREAMING:
-            return ServerStreamingMethodClient(self, rpcs, channel, method)
+            return _server_streaming_method_client(
+                self, self.rpcs, channel, method,
+                self.default_stream_timeout_s)
 
         if method.type is Method.Type.CLIENT_STREAMING:
-            return ClientStreamingMethodClient(self, rpcs, channel, method)
+            return ClientStreamingMethodClient(self, self.rpcs, channel,
+                                               method,
+                                               self.default_unary_timeout_s)
 
-        if method.type is Method.Type.BIDI_STREAMING:
-            return BidirectionalStreamingMethodClient(self, rpcs, channel,
-                                                      method)
+        if method.type is Method.Type.BIDIRECTIONAL_STREAMING:
+            return BidirectionalStreamingMethodClient(
+                self, self.rpcs, channel, method,
+                self.default_stream_timeout_s)
 
         raise AssertionError(f'Unknown method type {method.type}')
 
-    def process_response(self,
-                         rpcs: client.PendingRpcs,
-                         rpc: client.PendingRpc,
-                         context,
-                         status: Optional[Status],
-                         payload,
-                         *,
-                         args: tuple = (),
-                         kwargs: dict = None) -> None:
+    def handle_response(self,
+                        rpc: PendingRpc,
+                        context,
+                        payload,
+                        *,
+                        args: tuple = (),
+                        kwargs: dict = None) -> None:
         """Invokes the callback associated with this RPC.
 
         Any additional positional and keyword args passed through
@@ -205,7 +472,40 @@
             kwargs = {}
 
         try:
-            context(rpc, status, payload, *args, **kwargs)
+            context.response(rpc, payload, *args, **kwargs)
         except:  # pylint: disable=bare-except
-            rpcs.send_cancel(rpc)
-            _LOG.exception('Callback %s for %s raised exception', context, rpc)
+            self.rpcs.send_cancel(rpc)
+            _LOG.exception('Response callback %s for %s raised exception',
+                           context.response, rpc)
+
+    def handle_completion(self,
+                          rpc: PendingRpc,
+                          context,
+                          status: Status,
+                          *,
+                          args: tuple = (),
+                          kwargs: dict = None):
+        if kwargs is None:
+            kwargs = {}
+
+        try:
+            context.completion(rpc, status, *args, **kwargs)
+        except:  # pylint: disable=bare-except
+            _LOG.exception('Completion callback %s for %s raised exception',
+                           context.completion, rpc)
+
+    def handle_error(self,
+                     rpc: PendingRpc,
+                     context,
+                     status: Status,
+                     *,
+                     args: tuple = (),
+                     kwargs: dict = None) -> None:
+        if kwargs is None:
+            kwargs = {}
+
+        try:
+            context.error(rpc, status, *args, **kwargs)
+        except:  # pylint: disable=bare-except
+            _LOG.exception('Error callback %s for %s raised exception',
+                           context.error, rpc)
diff --git a/pw_rpc/py/pw_rpc/client.py b/pw_rpc/py/pw_rpc/client.py
index 4d3ec55..e990bce 100644
--- a/pw_rpc/py/pw_rpc/client.py
+++ b/pw_rpc/py/pw_rpc/client.py
@@ -11,19 +11,20 @@
 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 # License for the specific language governing permissions and limitations under
 # the License.
-"""Creates an RPC client."""
+"""Provides a pw_rpc client for Python."""
 
 import abc
 from dataclasses import dataclass
 import logging
-from typing import Collection, Dict, Iterable, Iterator, List, NamedTuple
-from typing import Optional
+from typing import (Any, Collection, Dict, Iterable, Iterator, NamedTuple,
+                    Optional)
+
+from google.protobuf.message import DecodeError
+from pw_status import Status
 
 from pw_rpc import descriptors, packets
-from pw_rpc.packet_pb2 import RpcPacket
-from pw_rpc.packets import PacketType
 from pw_rpc.descriptors import Channel, Service, Method
-from pw_status import Status
+from pw_rpc.internal.packet_pb2 import PacketType, RpcPacket
 
 _LOG = logging.getLogger(__name__)
 
@@ -42,27 +43,26 @@
         return f'PendingRpc(channel={self.channel.id}, method={self.method})'
 
 
+class _PendingRpcMetadata:
+    def __init__(self, context: Any, keep_open: bool):
+        self.context = context
+        self.keep_open = keep_open
+
+
 class PendingRpcs:
     """Tracks pending RPCs and encodes outgoing RPC packets."""
     def __init__(self):
-        self._pending: Dict[PendingRpc, List] = {}
+        self._pending: Dict[PendingRpc, _PendingRpcMetadata] = {}
 
     def request(self,
                 rpc: PendingRpc,
                 request,
                 context,
-                override_pending: bool = False) -> bytes:
+                override_pending: bool = True,
+                keep_open: bool = False) -> bytes:
         """Starts the provided RPC and returns the encoded packet to send."""
         # Ensure that every context is a unique object by wrapping it in a list.
-        unique_ctx = [context]
-
-        if override_pending:
-            self._pending[rpc] = unique_ctx
-        elif self._pending.setdefault(rpc, unique_ctx) is not unique_ctx:
-            # If the context was not added, the RPC was already pending.
-            raise Error(f'Sent request for {rpc}, but it is already pending! '
-                        'Cancel the RPC before invoking it again')
-
+        self.open(rpc, context, override_pending, keep_open)
         _LOG.debug('Starting %s', rpc)
         return packets.encode_request(rpc, request)
 
@@ -70,12 +70,33 @@
                      rpc: PendingRpc,
                      request,
                      context,
-                     override_pending: bool = False) -> None:
+                     override_pending: bool = False,
+                     keep_open: bool = False) -> None:
         """Calls request and sends the resulting packet to the channel."""
         # TODO(hepler): Remove `type: ignore` on this and similar lines when
         #     https://github.com/python/mypy/issues/5485 is fixed
         rpc.channel.output(  # type: ignore
-            self.request(rpc, request, context, override_pending))
+            self.request(rpc, request, context, override_pending, keep_open))
+
+    def open(self,
+             rpc: PendingRpc,
+             context,
+             override_pending: bool = False,
+             keep_open: bool = False) -> None:
+        """Creates a context for an RPC, but does not invoke it.
+
+        open() can be used to receive streaming responses to an RPC that was not
+        invoked by this client. For example, a server may stream logs with a
+        server streaming RPC prior to any clients invoking it.
+        """
+        metadata = _PendingRpcMetadata(context, keep_open)
+
+        if override_pending:
+            self._pending[rpc] = metadata
+        elif self._pending.setdefault(rpc, metadata) is not metadata:
+            # If the context was not added, the RPC was already pending.
+            raise Error(f'Sent request for {rpc}, but it is already pending! '
+                        'Cancel the RPC before invoking it again')
 
     def cancel(self, rpc: PendingRpc) -> Optional[bytes]:
         """Cancels the RPC. Returns the CANCEL packet to send.
@@ -109,10 +130,14 @@
     def get_pending(self, rpc: PendingRpc, status: Optional[Status]):
         """Gets the pending RPC's context. If status is set, clears the RPC."""
         if status is None:
-            return self._pending[rpc][0]  # Unwrap the context from the list
+            return self._pending[rpc].context
 
-        _LOG.debug('Finishing %s with status %s', rpc, status)
-        return self._pending.pop(rpc)[0]
+        if self._pending[rpc].keep_open:
+            _LOG.debug('%s finished with status %s; keeping open', rpc, status)
+            return self._pending[rpc].context
+
+        _LOG.debug('%s finished with status %s', rpc, status)
+        return self._pending.pop(rpc).context
 
 
 class ClientImpl(abc.ABC):
@@ -121,39 +146,73 @@
     This interface defines the semantics for invoking an RPC on a particular
     client.
     """
+    def __init__(self):
+        self.client: 'Client' = None
+        self.rpcs: PendingRpcs = None
+
     @abc.abstractmethod
-    def method_client(self, rpcs: PendingRpcs, channel: Channel,
-                      method: Method):
+    def method_client(self, channel: Channel, method: Method) -> Any:
         """Returns an object that invokes a method using the given channel."""
 
     @abc.abstractmethod
-    def process_response(self,
-                         rpcs: PendingRpcs,
-                         rpc: PendingRpc,
-                         context,
-                         status: Optional[Status],
-                         payload,
-                         *,
-                         args: tuple = (),
-                         kwargs: dict = None) -> None:
-        """Processes a response from the RPC server.
+    def handle_response(self,
+                        rpc: PendingRpc,
+                        context: Any,
+                        payload: Any,
+                        *,
+                        args: tuple = (),
+                        kwargs: dict = None) -> Any:
+        """Handles a response from the RPC server.
 
         Args:
-          rpcs: The PendingRpcs object used by the client.
           rpc: Information about the pending RPC
           context: Arbitrary context object associated with the pending RPC
-          status: If set, this is the last packet for this RPC. None otherwise.
-          payload: A protobuf message, if present. None otherwise.
+          payload: A protobuf message
+          args, kwargs: Arbitrary arguments passed to the ClientImpl
+        """
+
+    @abc.abstractmethod
+    def handle_completion(self,
+                          rpc: PendingRpc,
+                          context: Any,
+                          status: Status,
+                          *,
+                          args: tuple = (),
+                          kwargs: dict = None) -> Any:
+        """Handles the successful completion of an RPC.
+
+        Args:
+          rpc: Information about the pending RPC
+          context: Arbitrary context object associated with the pending RPC
+          status: Status returned from the RPC
+          args, kwargs: Arbitrary arguments passed to the ClientImpl
+        """
+
+    @abc.abstractmethod
+    def handle_error(self,
+                     rpc: PendingRpc,
+                     context,
+                     status: Status,
+                     *,
+                     args: tuple = (),
+                     kwargs: dict = None):
+        """Handles the abnormal termination of an RPC.
+
+        args:
+          rpc: Information about the pending RPC
+          context: Arbitrary context object associated with the pending RPC
+          status: which error occurred
+          args, kwargs: Arbitrary arguments passed to the ClientImpl
         """
 
 
 class ServiceClient(descriptors.ServiceAccessor):
     """Navigates the methods in a service provided by a ChannelClient."""
-    def __init__(self, rpcs: PendingRpcs, client_impl: ClientImpl,
-                 channel: Channel, service: Service):
+    def __init__(self, client_impl: ClientImpl, channel: Channel,
+                 service: Service):
         super().__init__(
             {
-                method: client_impl.method_client(rpcs, channel, method)
+                method: client_impl.method_client(channel, method)
                 for method in service.methods
             },
             as_attrs='members')
@@ -172,13 +231,11 @@
 
 class Services(descriptors.ServiceAccessor[ServiceClient]):
     """Navigates the services provided by a ChannelClient."""
-    def __init__(self, rpcs: PendingRpcs, client_impl, channel: Channel,
+    def __init__(self, client_impl, channel: Channel,
                  services: Collection[Service]):
         super().__init__(
-            {
-                s: ServiceClient(rpcs, client_impl, channel, s)
-                for s in services
-            },
+            {s: ServiceClient(client_impl, channel, s)
+             for s in services},
             as_attrs='packages')
 
         self._channel = channel
@@ -206,7 +263,7 @@
     if packet.type == PacketType.RESPONSE:
         try:
             return packets.decode_payload(packet, rpc.method.response_type)
-        except packets.DecodeError as err:
+        except DecodeError as err:
             _LOG.warning('Failed to decode %s response for %s: %s',
                          rpc.method.response_type.DESCRIPTOR.full_name,
                          rpc.method.full_name, err)
@@ -279,26 +336,32 @@
                      modules: Iterable):
         return cls(
             impl, channels,
-            (Service.from_descriptor(module, service) for module in modules
+            (Service.from_descriptor(service) for module in modules
              for service in module.DESCRIPTOR.services_by_name.values()))
 
     def __init__(self, impl: ClientImpl, channels: Iterable[Channel],
                  services: Iterable[Service]):
         self._impl = impl
+        self._impl.client = self
+        self._impl.rpcs = PendingRpcs()
 
         self.services = descriptors.Services(services)
 
-        self._rpcs = PendingRpcs()
-
         self._channels_by_id = {
-            channel.id: ChannelClient(
-                self, channel,
-                Services(self._rpcs, self._impl, channel, self.services))
+            channel.id:
+            ChannelClient(self, channel,
+                          Services(self._impl, channel, self.services))
             for channel in channels
         }
 
-    def channel(self, channel_id: int) -> ChannelClient:
-        """Returns a ChannelClient, which is used to call RPCs on a channel."""
+    def channel(self, channel_id: int = None) -> ChannelClient:
+        """Returns a ChannelClient, which is used to call RPCs on a channel.
+
+        If no channel is provided, the first channel is used.
+        """
+        if channel_id is None:
+            return next(iter(self._channels_by_id.values()))
+
         return self._channels_by_id[channel_id]
 
     def channels(self) -> Iterable[ChannelClient]:
@@ -339,7 +402,7 @@
         """
         try:
             packet = packets.decode(pw_rpc_raw_packet_data)
-        except packets.DecodeError as err:
+        except DecodeError as err:
             _LOG.warning('Failed to decode packet: %s', err)
             _LOG.debug('Raw packet: %r', pw_rpc_raw_packet_data)
             return Status.DATA_LOSS
@@ -373,7 +436,7 @@
         payload = _decode_payload(rpc, packet)
 
         try:
-            context = self._rpcs.get_pending(rpc, status)
+            context = self._impl.rpcs.get_pending(rpc, status)
         except KeyError:
             channel_client.channel.output(  # type: ignore
                 packets.encode_client_error(packet,
@@ -382,18 +445,28 @@
             return Status.OK
 
         if packet.type == PacketType.SERVER_ERROR:
+            assert status is not None and not status.ok()
             _LOG.warning('%s: invocation failed with %s', rpc, status)
-
-            # Do not return yet -- call process_response so the ClientImpl can
-            # do any necessary cleanup.
-
-        self._impl.process_response(self._rpcs,
-                                    rpc,
+            self._impl.handle_error(rpc,
                                     context,
                                     status,
-                                    payload,
                                     args=impl_args,
                                     kwargs=impl_kwargs)
+            return Status.OK
+
+        if payload is not None:
+            self._impl.handle_response(rpc,
+                                       context,
+                                       payload,
+                                       args=impl_args,
+                                       kwargs=impl_kwargs)
+        if status is not None:
+            self._impl.handle_completion(rpc,
+                                         context,
+                                         status,
+                                         args=impl_args,
+                                         kwargs=impl_kwargs)
+
         return Status.OK
 
     def _look_up_service_and_method(
diff --git a/pw_rpc/py/pw_rpc/codegen.py b/pw_rpc/py/pw_rpc/codegen.py
new file mode 100644
index 0000000..957a893
--- /dev/null
+++ b/pw_rpc/py/pw_rpc/codegen.py
@@ -0,0 +1,317 @@
+# 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.
+"""Common RPC codegen utilities."""
+
+import abc
+from datetime import datetime
+import os
+from typing import cast, Any, Callable, Iterable
+
+from pw_protobuf.output_file import OutputFile
+from pw_protobuf.proto_tree import ProtoNode, ProtoService, ProtoServiceMethod
+
+import pw_rpc.ids
+
+PLUGIN_NAME = 'pw_rpc_codegen'
+PLUGIN_VERSION = '0.2.0'
+
+RPC_NAMESPACE = '::pw::rpc'
+
+STUB_REQUEST_TODO = (
+    '// TODO: Read the request as appropriate for your application')
+STUB_RESPONSE_TODO = (
+    '// TODO: Fill in the response as appropriate for your application')
+STUB_WRITER_TODO = (
+    '// TODO: Send responses with the writer as appropriate for your '
+    'application')
+
+ServerWriterGenerator = Callable[[OutputFile], None]
+MethodGenerator = Callable[[ProtoServiceMethod, int, OutputFile], None]
+ServiceGenerator = Callable[[ProtoService, ProtoNode, OutputFile], None]
+IncludesGenerator = Callable[[Any, ProtoNode], Iterable[str]]
+
+
+def package(file_descriptor_proto, proto_package: ProtoNode,
+            output: OutputFile, includes: IncludesGenerator,
+            service: ServiceGenerator, client: ServiceGenerator) -> None:
+    """Generates service and client code for a package."""
+    assert proto_package.type() == ProtoNode.Type.PACKAGE
+
+    output.write_line(f'// {os.path.basename(output.name())} automatically '
+                      f'generated by {PLUGIN_NAME} {PLUGIN_VERSION}')
+    output.write_line(f'// on {datetime.now().isoformat()}')
+    output.write_line('// clang-format off')
+    output.write_line('#pragma once\n')
+
+    output.write_line('#include <array>')
+    output.write_line('#include <cstdint>')
+    output.write_line('#include <type_traits>\n')
+
+    include_lines = [
+        '#include "pw_rpc/internal/method_lookup.h"',
+        '#include "pw_rpc/server_context.h"',
+        '#include "pw_rpc/service.h"',
+    ]
+    include_lines += includes(file_descriptor_proto, proto_package)
+
+    for include_line in sorted(include_lines):
+        output.write_line(include_line)
+
+    output.write_line()
+
+    if proto_package.cpp_namespace():
+        file_namespace = proto_package.cpp_namespace()
+        if file_namespace.startswith('::'):
+            file_namespace = file_namespace[2:]
+
+        output.write_line(f'namespace {file_namespace} {{')
+
+    for node in proto_package:
+        if node.type() == ProtoNode.Type.SERVICE:
+            service(cast(ProtoService, node), proto_package, output)
+            client(cast(ProtoService, node), proto_package, output)
+
+    if proto_package.cpp_namespace():
+        output.write_line(f'}}  // namespace {file_namespace}')
+
+
+def service_class(service: ProtoService, root: ProtoNode, output: OutputFile,
+                  server_writer_alias: ServerWriterGenerator,
+                  method_union: str,
+                  method_descriptor: MethodGenerator) -> None:
+    """Generates a C++ derived class for a nanopb RPC service."""
+
+    output.write_line('namespace generated {')
+
+    base_class = f'{RPC_NAMESPACE}::Service'
+    output.write_line('\ntemplate <typename Implementation>')
+    output.write_line(
+        f'class {service.cpp_namespace(root)} : public {base_class} {{')
+    output.write_line(' public:')
+
+    with output.indent():
+        output.write_line(
+            f'using ServerContext = {RPC_NAMESPACE}::ServerContext;')
+        server_writer_alias(output)
+        output.write_line()
+
+        output.write_line(f'constexpr {service.name()}()')
+        output.write_line(f'    : {base_class}(kServiceId, kMethods) {{}}')
+
+        output.write_line()
+        output.write_line(
+            f'{service.name()}(const {service.name()}&) = delete;')
+        output.write_line(f'{service.name()}& operator='
+                          f'(const {service.name()}&) = delete;')
+
+        output.write_line()
+        output.write_line(f'static constexpr const char* name() '
+                          f'{{ return "{service.name()}"; }}')
+
+        output.write_line()
+        output.write_line(
+            '// Used by MethodLookup to identify the generated service base.')
+        output.write_line(
+            'constexpr void _PwRpcInternalGeneratedBase() const {}')
+
+    service_name_hash = pw_rpc.ids.calculate(service.proto_path())
+    output.write_line('\n private:')
+
+    with output.indent():
+        output.write_line('friend class ::pw::rpc::internal::MethodLookup;\n')
+        output.write_line(f'// Hash of "{service.proto_path()}".')
+        output.write_line(
+            f'static constexpr uint32_t kServiceId = 0x{service_name_hash:08x};'
+        )
+
+        output.write_line()
+
+        # Generate the method table
+        output.write_line('static constexpr std::array<'
+                          f'{RPC_NAMESPACE}::internal::{method_union},'
+                          f' {len(service.methods())}> kMethods = {{')
+
+        with output.indent(4):
+            for method in service.methods():
+                method_descriptor(method, pw_rpc.ids.calculate(method.name()),
+                                  output)
+
+        output.write_line('};\n')
+
+        # Generate the method lookup table
+        _method_lookup_table(service, output)
+
+    output.write_line('};')
+
+    output.write_line('\n}  // namespace generated\n')
+
+
+def _method_lookup_table(service: ProtoService, output: OutputFile) -> None:
+    """Generates array of method IDs for looking up methods at compile time."""
+    output.write_line('static constexpr std::array<uint32_t, '
+                      f'{len(service.methods())}> kMethodIds = {{')
+
+    with output.indent(4):
+        for method in service.methods():
+            method_id = pw_rpc.ids.calculate(method.name())
+            output.write_line(
+                f'0x{method_id:08x},  // Hash of "{method.name()}"')
+
+    output.write_line('};\n')
+
+
+class StubGenerator(abc.ABC):
+    @abc.abstractmethod
+    def unary_signature(self, method: ProtoServiceMethod, prefix: str) -> str:
+        """Returns the signature of this unary method."""
+
+    @abc.abstractmethod
+    def unary_stub(self, method: ProtoServiceMethod,
+                   output: OutputFile) -> None:
+        """Returns the stub for this unary method."""
+
+    @abc.abstractmethod
+    def server_streaming_signature(self, method: ProtoServiceMethod,
+                                   prefix: str) -> str:
+        """Returns the signature of this server streaming method."""
+
+    def server_streaming_stub(  # pylint: disable=no-self-use
+            self, unused_method: ProtoServiceMethod,
+            output: OutputFile) -> None:
+        """Returns the stub for this server streaming method."""
+        output.write_line(STUB_REQUEST_TODO)
+        output.write_line('static_cast<void>(request);')
+        output.write_line(STUB_WRITER_TODO)
+        output.write_line('static_cast<void>(writer);')
+
+
+def _select_stub_methods(generator: StubGenerator, method: ProtoServiceMethod):
+    if method.type() is ProtoServiceMethod.Type.UNARY:
+        return generator.unary_signature, generator.unary_stub
+
+    if method.type() is ProtoServiceMethod.Type.SERVER_STREAMING:
+        return (generator.server_streaming_signature,
+                generator.server_streaming_stub)
+
+    raise NotImplementedError(
+        'Client and bidirectional streaming not yet implemented')
+
+
+_STUBS_COMMENT = r'''
+/*
+    ____                __                          __        __  _
+   /  _/___ ___  ____  / /__  ____ ___  ___  ____  / /_____ _/ /_(_)___  ____
+   / // __ `__ \/ __ \/ / _ \/ __ `__ \/ _ \/ __ \/ __/ __ `/ __/ / __ \/ __ \
+ _/ // / / / / / /_/ / /  __/ / / / / /  __/ / / / /_/ /_/ / /_/ / /_/ / / / /
+/___/_/ /_/ /_/ .___/_/\___/_/ /_/ /_/\___/_/ /_/\__/\__,_/\__/_/\____/_/ /_/
+             /_/
+   _____ __        __         __
+  / ___// /___  __/ /_  _____/ /
+  \__ \/ __/ / / / __ \/ ___/ /
+ ___/ / /_/ /_/ / /_/ (__  )_/
+/____/\__/\__,_/_.___/____(_)
+
+*/
+// This section provides stub implementations of the RPC services in this file.
+// The code below may be referenced or copied to serve as a starting point for
+// your RPC service implementations.
+'''
+
+
+def package_stubs(proto_package: ProtoNode, output: OutputFile,
+                  stub_generator: StubGenerator) -> None:
+    """Generates the RPC stubs for a package."""
+    if proto_package.cpp_namespace():
+        file_ns = proto_package.cpp_namespace()
+        if file_ns.startswith('::'):
+            file_ns = file_ns[2:]
+
+        start_ns = lambda: output.write_line(f'namespace {file_ns} {{\n')
+        finish_ns = lambda: output.write_line(f'}}  // namespace {file_ns}\n')
+    else:
+        start_ns = finish_ns = lambda: None
+
+    services = [
+        cast(ProtoService, node) for node in proto_package
+        if node.type() == ProtoNode.Type.SERVICE
+    ]
+
+    output.write_line('#ifdef _PW_RPC_COMPILE_GENERATED_SERVICE_STUBS')
+    output.write_line(_STUBS_COMMENT)
+
+    output.write_line(f'#include "{output.name()}"\n')
+
+    start_ns()
+
+    for node in services:
+        _generate_service_class(node, output, stub_generator)
+
+    output.write_line()
+
+    finish_ns()
+
+    start_ns()
+
+    for node in services:
+        _generate_service_stubs(node, output, stub_generator)
+        output.write_line()
+
+    finish_ns()
+
+    output.write_line('#endif  // _PW_RPC_COMPILE_GENERATED_SERVICE_STUBS')
+
+
+def _generate_service_class(service: ProtoService, output: OutputFile,
+                            stub_generator: StubGenerator) -> None:
+    output.write_line(f'// Implementation class for {service.proto_path()}.')
+    output.write_line(
+        f'class {service.name()} '
+        f': public generated::{service.name()}<{service.name()}> {{')
+
+    output.write_line(' public:')
+
+    with output.indent():
+        blank_line = False
+
+        for method in service.methods():
+            if blank_line:
+                output.write_line()
+            else:
+                blank_line = True
+
+            signature, _ = _select_stub_methods(stub_generator, method)
+
+            output.write_line(signature(method, '') + ';')
+
+    output.write_line('};\n')
+
+
+def _generate_service_stubs(service: ProtoService, output: OutputFile,
+                            stub_generator: StubGenerator) -> None:
+    output.write_line(f'// Method definitions for {service.proto_path()}.')
+
+    blank_line = False
+
+    for method in service.methods():
+        if blank_line:
+            output.write_line()
+        else:
+            blank_line = True
+
+        signature, stub = _select_stub_methods(stub_generator, method)
+
+        output.write_line(signature(method, f'{service.name()}::') + ' {')
+        with output.indent():
+            stub(method, output)
+        output.write_line('}')
diff --git a/pw_rpc/py/pw_rpc/codegen_nanopb.py b/pw_rpc/py/pw_rpc/codegen_nanopb.py
index 9e5ca78..183856c 100644
--- a/pw_rpc/py/pw_rpc/codegen_nanopb.py
+++ b/pw_rpc/py/pw_rpc/codegen_nanopb.py
@@ -13,24 +13,20 @@
 # the License.
 """This module generates the code for nanopb-based pw_rpc services."""
 
-from datetime import datetime
 import os
-from typing import Iterable, cast
+from typing import Iterable, Iterator
 
 from pw_protobuf.output_file import OutputFile
 from pw_protobuf.proto_tree import ProtoNode, ProtoService, ProtoServiceMethod
 from pw_protobuf.proto_tree import build_node_tree
+from pw_rpc import codegen
+from pw_rpc.codegen import RPC_NAMESPACE
 import pw_rpc.ids
 
-PLUGIN_NAME = 'pw_rpc_codegen'
-PLUGIN_VERSION = '0.1.0'
-
 PROTO_H_EXTENSION = '.pb.h'
 PROTO_CC_EXTENSION = '.pb.cc'
 NANOPB_H_EXTENSION = '.pb.h'
 
-RPC_NAMESPACE = '::pw::rpc'
-
 
 def _proto_filename_to_nanopb_header(proto_file: str) -> str:
     """Returns the generated nanopb header name for a .proto file."""
@@ -43,113 +39,35 @@
     return f'{filename}.rpc{PROTO_H_EXTENSION}'
 
 
-def _generate_method_descriptor(method: ProtoServiceMethod,
+def _generate_method_descriptor(method: ProtoServiceMethod, method_id: int,
                                 output: OutputFile) -> None:
     """Generates a nanopb method descriptor for an RPC method."""
 
-    method_id = pw_rpc.ids.calculate(method.name())
     req_fields = f'{method.request_type().nanopb_name()}_fields'
     res_fields = f'{method.response_type().nanopb_name()}_fields'
     impl_method = f'&Implementation::{method.name()}'
 
     output.write_line(
-        f'{RPC_NAMESPACE}::internal::GetNanopbOrRawMethodFor<{impl_method}>(')
+        f'{RPC_NAMESPACE}::internal::GetNanopbOrRawMethodFor<{impl_method}, '
+        f'{method.type().cc_enum()}, '
+        f'{method.request_type().nanopb_name()}, '
+        f'{method.response_type().nanopb_name()}>(')
     with output.indent(4):
         output.write_line(f'0x{method_id:08x},  // Hash of "{method.name()}"')
         output.write_line(f'{req_fields},')
         output.write_line(f'{res_fields}),')
 
 
-def _generate_method_lookup_function(output: OutputFile):
-    """Generates a function that gets a Method object from its ID."""
-    nanopb_method = f'{RPC_NAMESPACE}::internal::NanopbMethod'
-
-    output.write_line(
-        f'static constexpr const {nanopb_method}* NanopbMethodFor(')
-    output.write_line('    uint32_t id) {')
-
-    with output.indent():
-        output.write_line('for (auto& method : kMethods) {')
-        with output.indent():
-            output.write_line('if (method.nanopb_method().id() == id) {')
-            output.write_line(
-                f'  return &static_cast<const {nanopb_method}&>(')
-            output.write_line('    method.nanopb_method());')
-            output.write_line('}')
-        output.write_line('}')
-
-        output.write_line('return nullptr;')
-
-    output.write_line('}')
+def _generate_server_writer_alias(output: OutputFile) -> None:
+    output.write_line('template <typename T>')
+    output.write_line('using ServerWriter = ::pw::rpc::ServerWriter<T>;')
 
 
 def _generate_code_for_service(service: ProtoService, root: ProtoNode,
                                output: OutputFile) -> None:
     """Generates a C++ derived class for a nanopb RPC service."""
-
-    output.write_line('namespace generated {')
-
-    base_class = f'{RPC_NAMESPACE}::Service'
-    output.write_line('\ntemplate <typename Implementation>')
-    output.write_line(
-        f'class {service.cpp_namespace(root)} : public {base_class} {{')
-    output.write_line(' public:')
-
-    with output.indent():
-        output.write_line(
-            f'using ServerContext = {RPC_NAMESPACE}::ServerContext;')
-        output.write_line('template <typename T>')
-        output.write_line(
-            f'using ServerWriter = {RPC_NAMESPACE}::ServerWriter<T>;')
-        output.write_line()
-
-        output.write_line(f'constexpr {service.name()}()')
-        output.write_line(f'    : {base_class}(kServiceId, kMethods) {{}}')
-
-        output.write_line()
-        output.write_line(
-            f'{service.name()}(const {service.name()}&) = delete;')
-        output.write_line(f'{service.name()}& operator='
-                          f'(const {service.name()}&) = delete;')
-
-        output.write_line()
-        output.write_line(f'static constexpr const char* name() '
-                          f'{{ return "{service.name()}"; }}')
-
-        output.write_line()
-        output.write_line(
-            '// Used by ServiceMethodTraits to identify a base service.')
-        output.write_line(
-            'constexpr void _PwRpcInternalGeneratedBase() const {}')
-
-        output.write_line()
-        _generate_method_lookup_function(output)
-
-    service_name_hash = pw_rpc.ids.calculate(service.proto_path())
-    output.write_line('\n private:')
-
-    with output.indent():
-        output.write_line(f'// Hash of "{service.proto_path()}".')
-        output.write_line(
-            f'static constexpr uint32_t kServiceId = 0x{service_name_hash:08x};'
-        )
-
-        output.write_line()
-
-        # Generate the method table
-        output.write_line('static constexpr std::array<'
-                          f'{RPC_NAMESPACE}::internal::NanopbMethodUnion,'
-                          f' {len(service.methods())}> kMethods = {{')
-
-        with output.indent(4):
-            for method in service.methods():
-                _generate_method_descriptor(method, output)
-
-        output.write_line('};')
-
-    output.write_line('};')
-
-    output.write_line('\n}  // namespace generated\n')
+    codegen.service_class(service, root, output, _generate_server_writer_alias,
+                          'NanopbMethodUnion', _generate_method_descriptor)
 
 
 def _generate_code_for_client_method(method: ProtoServiceMethod,
@@ -226,49 +144,45 @@
     output.write_line('\n}  // namespace nanopb\n')
 
 
-def generate_code_for_package(file_descriptor_proto, package: ProtoNode,
-                              output: OutputFile) -> None:
-    """Generates code for a header file corresponding to a .proto file."""
-
-    assert package.type() == ProtoNode.Type.PACKAGE
-
-    output.write_line(f'// {os.path.basename(output.name())} automatically '
-                      f'generated by {PLUGIN_NAME} {PLUGIN_VERSION}')
-    output.write_line(f'// on {datetime.now().isoformat()}')
-    output.write_line('// clang-format off')
-    output.write_line('#pragma once\n')
-    output.write_line('#include <array>')
-    output.write_line('#include <cstddef>')
-    output.write_line('#include <cstdint>')
-    output.write_line('#include <type_traits>\n')
-    output.write_line('#include "pw_rpc/internal/nanopb_method_union.h"')
-    output.write_line('#include "pw_rpc/nanopb_client_call.h"')
-    output.write_line('#include "pw_rpc/server_context.h"')
-    output.write_line('#include "pw_rpc/service.h"')
+def includes(proto_file, unused_package: ProtoNode) -> Iterator[str]:
+    yield '#include "pw_rpc/internal/nanopb_method_union.h"'
+    yield '#include "pw_rpc/nanopb_client_call.h"'
 
     # Include the corresponding nanopb header file for this proto file, in which
     # the file's messages and enums are generated. All other files imported from
     # the .proto file are #included in there.
-    nanopb_header = _proto_filename_to_nanopb_header(
-        file_descriptor_proto.name)
-    output.write_line(f'#include "{nanopb_header}"\n')
+    nanopb_header = _proto_filename_to_nanopb_header(proto_file.name)
+    yield f'#include "{nanopb_header}"'
 
-    if package.cpp_namespace():
-        file_namespace = package.cpp_namespace()
-        if file_namespace.startswith('::'):
-            file_namespace = file_namespace[2:]
 
-        output.write_line(f'namespace {file_namespace} {{')
+def _generate_code_for_package(proto_file, package: ProtoNode,
+                               output: OutputFile) -> None:
+    """Generates code for a header file corresponding to a .proto file."""
 
-    for node in package:
-        if node.type() == ProtoNode.Type.SERVICE:
-            _generate_code_for_service(cast(ProtoService, node), package,
-                                       output)
-            _generate_code_for_client(cast(ProtoService, node), package,
-                                      output)
+    codegen.package(proto_file, package, output, includes,
+                    _generate_code_for_service, _generate_code_for_client)
 
-    if package.cpp_namespace():
-        output.write_line(f'}}  // namespace {file_namespace}')
+
+class StubGenerator(codegen.StubGenerator):
+    def unary_signature(self, method: ProtoServiceMethod, prefix: str) -> str:
+        return (f'pw::Status {prefix}{method.name()}(ServerContext&, '
+                f'const {method.request_type().nanopb_name()}& request, '
+                f'{method.response_type().nanopb_name()}& response)')
+
+    def unary_stub(self, method: ProtoServiceMethod,
+                   output: OutputFile) -> None:
+        output.write_line(codegen.STUB_REQUEST_TODO)
+        output.write_line('static_cast<void>(request);')
+        output.write_line(codegen.STUB_RESPONSE_TODO)
+        output.write_line('static_cast<void>(response);')
+        output.write_line('return pw::Status::Unimplemented();')
+
+    def server_streaming_signature(self, method: ProtoServiceMethod,
+                                   prefix: str) -> str:
+        return (
+            f'void {prefix}{method.name()}(ServerContext&, '
+            f'const {method.request_type().nanopb_name()}& request, '
+            f'ServerWriter<{method.response_type().nanopb_name()}>& writer)')
 
 
 def process_proto_file(proto_file) -> Iterable[OutputFile]:
@@ -277,6 +191,9 @@
     _, package_root = build_node_tree(proto_file)
     output_filename = _proto_filename_to_generated_header(proto_file.name)
     output_file = OutputFile(output_filename)
-    generate_code_for_package(proto_file, package_root, output_file)
+    _generate_code_for_package(proto_file, package_root, output_file)
+
+    output_file.write_line()
+    codegen.package_stubs(package_root, output_file, StubGenerator())
 
     return [output_file]
diff --git a/pw_rpc/py/pw_rpc/codegen_raw.py b/pw_rpc/py/pw_rpc/codegen_raw.py
index 6babe1c..51fee92 100644
--- a/pw_rpc/py/pw_rpc/codegen_raw.py
+++ b/pw_rpc/py/pw_rpc/codegen_raw.py
@@ -13,22 +13,17 @@
 # the License.
 """This module generates the code for raw pw_rpc services."""
 
-from datetime import datetime
 import os
-from typing import Iterable, cast
+from typing import Iterable
 
 from pw_protobuf.output_file import OutputFile
 from pw_protobuf.proto_tree import ProtoNode, ProtoService, ProtoServiceMethod
 from pw_protobuf.proto_tree import build_node_tree
-import pw_rpc.ids
-
-PLUGIN_NAME = 'pw_rpc_codegen'
-PLUGIN_VERSION = '0.1.0'
+from pw_rpc import codegen
+from pw_rpc.codegen import RPC_NAMESPACE
 
 PROTO_H_EXTENSION = '.pb.h'
 
-RPC_NAMESPACE = '::pw::rpc'
-
 
 def _proto_filename_to_generated_header(proto_file: str) -> str:
     """Returns the generated C++ RPC header name for a .proto file."""
@@ -36,138 +31,70 @@
     return f'{filename}.raw_rpc{PROTO_H_EXTENSION}'
 
 
-def _generate_method_descriptor(method: ProtoServiceMethod,
+def _proto_filename_to_stub_header(proto_file: str) -> str:
+    """Returns the generated C++ RPC header name for a .proto file."""
+    filename = os.path.splitext(proto_file)[0]
+    return f'{filename}.raw_rpc.stub{PROTO_H_EXTENSION}'
+
+
+def _generate_method_descriptor(method: ProtoServiceMethod, method_id: int,
                                 output: OutputFile) -> None:
     """Generates a method descriptor for a raw RPC method."""
 
-    method_id = pw_rpc.ids.calculate(method.name())
     impl_method = f'&Implementation::{method.name()}'
 
     output.write_line(
-        f'{RPC_NAMESPACE}::internal::GetRawMethodFor<{impl_method}>(')
+        f'{RPC_NAMESPACE}::internal::GetRawMethodFor<{impl_method}, '
+        f'{method.type().cc_enum()}>(')
     output.write_line(f'    0x{method_id:08x}),  // Hash of "{method.name()}"')
 
 
-def _generate_method_lookup_function(output: OutputFile):
-    """Generates a function that gets a Method object from its ID."""
-    raw_method = f'{RPC_NAMESPACE}::internal::RawMethod'
+def _generate_server_writer_alias(output: OutputFile) -> None:
+    output.write_line(
+        f'using RawServerWriter = {RPC_NAMESPACE}::RawServerWriter;')
 
-    output.write_line(f'static constexpr const {raw_method}* RawMethodFor(')
-    output.write_line('    uint32_t id) {')
 
-    with output.indent():
-        output.write_line('for (auto& method : kMethods) {')
-        with output.indent():
-            output.write_line('if (method.raw_method().id() == id) {')
-            output.write_line(f'  return &static_cast<const {raw_method}&>(')
-            output.write_line('    method.raw_method());')
-            output.write_line('}')
-        output.write_line('}')
-
-        output.write_line('return nullptr;')
-
-    output.write_line('}')
+def _generate_code_for_client(unused_service: ProtoService,
+                              unused_root: ProtoNode,
+                              output: OutputFile) -> None:
+    """Outputs client code for an RPC service."""
+    output.write_line('// Raw RPC clients are not yet implemented.\n')
 
 
 def _generate_code_for_service(service: ProtoService, root: ProtoNode,
                                output: OutputFile) -> None:
     """Generates a C++ base class for a raw RPC service."""
-
-    base_class = f'{RPC_NAMESPACE}::Service'
-    output.write_line('\ntemplate <typename Implementation>')
-    output.write_line(
-        f'class {service.cpp_namespace(root)} : public {base_class} {{')
-    output.write_line(' public:')
-
-    with output.indent():
-        output.write_line(
-            f'using ServerContext = {RPC_NAMESPACE}::ServerContext;')
-        output.write_line(
-            f'using RawServerWriter = {RPC_NAMESPACE}::RawServerWriter;')
-        output.write_line()
-
-        output.write_line(f'constexpr {service.name()}()')
-        output.write_line(f'    : {base_class}(kServiceId, kMethods) {{}}')
-
-        output.write_line()
-        output.write_line(
-            f'{service.name()}(const {service.name()}&) = delete;')
-        output.write_line(f'{service.name()}& operator='
-                          f'(const {service.name()}&) = delete;')
-
-        output.write_line()
-        output.write_line(f'static constexpr const char* name() '
-                          f'{{ return "{service.name()}"; }}')
-
-        output.write_line()
-        output.write_line(
-            '// Used by ServiceMethodTraits to identify a base service.')
-        output.write_line(
-            'constexpr void _PwRpcInternalGeneratedBase() const {}')
-
-        output.write_line()
-        _generate_method_lookup_function(output)
-
-    service_name_hash = pw_rpc.ids.calculate(service.proto_path())
-    output.write_line('\n private:')
-
-    with output.indent():
-        output.write_line(f'// Hash of "{service.proto_path()}".')
-        output.write_line(
-            f'static constexpr uint32_t kServiceId = 0x{service_name_hash:08x};'
-        )
-
-        output.write_line()
-
-        # Generate the method table
-        output.write_line('static constexpr std::array<'
-                          f'{RPC_NAMESPACE}::internal::RawMethodUnion,'
-                          f' {len(service.methods())}> kMethods = {{')
-
-        with output.indent(4):
-            for method in service.methods():
-                _generate_method_descriptor(method, output)
-
-        output.write_line('};')
-
-    output.write_line('};')
+    codegen.service_class(service, root, output, _generate_server_writer_alias,
+                          'RawMethodUnion', _generate_method_descriptor)
 
 
-def _generate_code_for_package(package: ProtoNode, output: OutputFile) -> None:
+def _generate_code_for_package(proto_file, package: ProtoNode,
+                               output: OutputFile) -> None:
     """Generates code for a header file corresponding to a .proto file."""
-    assert package.type() == ProtoNode.Type.PACKAGE
+    includes = lambda *_: ['#include "pw_rpc/internal/raw_method_union.h"']
 
-    output.write_line(f'// {os.path.basename(output.name())} automatically '
-                      f'generated by {PLUGIN_NAME} {PLUGIN_VERSION}')
-    output.write_line(f'// on {datetime.now().isoformat()}')
-    output.write_line('// clang-format off')
-    output.write_line('#pragma once\n')
-    output.write_line('#include <array>')
-    output.write_line('#include <cstddef>')
-    output.write_line('#include <cstdint>')
-    output.write_line('#include <type_traits>\n')
-    output.write_line('#include "pw_rpc/internal/raw_method_union.h"')
-    output.write_line('#include "pw_rpc/server_context.h"')
-    output.write_line('#include "pw_rpc/service.h"\n')
+    codegen.package(proto_file, package, output, includes,
+                    _generate_code_for_service, _generate_code_for_client)
 
-    if package.cpp_namespace():
-        file_namespace = package.cpp_namespace()
-        if file_namespace.startswith('::'):
-            file_namespace = file_namespace[2:]
 
-        output.write_line(f'namespace {file_namespace} {{')
+class StubGenerator(codegen.StubGenerator):
+    def unary_signature(self, method: ProtoServiceMethod, prefix: str) -> str:
+        return (f'pw::StatusWithSize {prefix}{method.name()}(ServerContext&, '
+                'pw::ConstByteSpan request, pw::ByteSpan response)')
 
-    output.write_line('namespace generated {')
+    def unary_stub(self, method: ProtoServiceMethod,
+                   output: OutputFile) -> None:
+        output.write_line(codegen.STUB_REQUEST_TODO)
+        output.write_line('static_cast<void>(request);')
+        output.write_line(codegen.STUB_RESPONSE_TODO)
+        output.write_line('static_cast<void>(response);')
+        output.write_line('return pw::StatusWithSize::Unimplemented();')
 
-    for node in package:
-        if node.type() == ProtoNode.Type.SERVICE:
-            _generate_code_for_service(cast(ProtoService, node), package,
-                                       output)
+    def server_streaming_signature(self, method: ProtoServiceMethod,
+                                   prefix: str) -> str:
 
-    output.write_line('\n}  // namespace generated')
-
-    if package.cpp_namespace():
-        output.write_line(f'}}  // namespace {file_namespace}')
+        return (f'void {prefix}{method.name()}(ServerContext&, '
+                'pw::ConstByteSpan request, RawServerWriter& writer)')
 
 
 def process_proto_file(proto_file) -> Iterable[OutputFile]:
@@ -176,6 +103,9 @@
     _, package_root = build_node_tree(proto_file)
     output_filename = _proto_filename_to_generated_header(proto_file.name)
     output_file = OutputFile(output_filename)
-    _generate_code_for_package(package_root, output_file)
+    _generate_code_for_package(proto_file, package_root, output_file)
+
+    output_file.write_line()
+    codegen.package_stubs(package_root, output_file, StubGenerator())
 
     return [output_file]
diff --git a/pw_rpc/py/pw_rpc/console_tools/__init__.py b/pw_rpc/py/pw_rpc/console_tools/__init__.py
new file mode 100644
index 0000000..3f157f1
--- /dev/null
+++ b/pw_rpc/py/pw_rpc/console_tools/__init__.py
@@ -0,0 +1,18 @@
+# Copyright 2021 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.
+"""Utilities for building tools that interact with pw_rpc."""
+
+from pw_rpc.console_tools.console import Context, CommandHelper, ClientInfo
+from pw_rpc.console_tools.functions import help_as_repr
+from pw_rpc.console_tools.watchdog import Watchdog
diff --git a/pw_rpc/py/pw_rpc/console_tools/console.py b/pw_rpc/py/pw_rpc/console_tools/console.py
new file mode 100644
index 0000000..29797f5
--- /dev/null
+++ b/pw_rpc/py/pw_rpc/console_tools/console.py
@@ -0,0 +1,219 @@
+# Copyright 2021 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.
+"""Utilities for creating an interactive console."""
+
+from collections import defaultdict
+from itertools import chain
+import inspect
+import textwrap
+import types
+from typing import Any, Collection, Dict, Iterable, Mapping, NamedTuple
+
+import pw_status
+from pw_protobuf_compiler import python_protos
+
+import pw_rpc
+from pw_rpc.descriptors import Method
+from pw_rpc.console_tools import functions
+
+_INDENT = '    '
+
+
+class CommandHelper:
+    """Used to implement a help command in an RPC console."""
+    @classmethod
+    def from_methods(cls,
+                     methods: Iterable[Method],
+                     variables: Mapping[str, object],
+                     header: str,
+                     footer: str = '') -> 'CommandHelper':
+        return cls({m.full_name: m
+                    for m in methods}, variables, header, footer)
+
+    def __init__(self,
+                 methods: Mapping[str, object],
+                 variables: Mapping[str, object],
+                 header: str,
+                 footer: str = ''):
+        self._methods = methods
+        self._variables = variables
+        self.header = header
+        self.footer = footer
+
+    def help(self, item: object = None) -> str:
+        """Returns a help string with a command or all commands listed."""
+
+        if item is None:
+            all_vars = '\n'.join(sorted(self._variables_without_methods()))
+            all_rpcs = '\n'.join(self._methods)
+            return (f'{self.header}\n\n'
+                    f'All variables:\n\n{textwrap.indent(all_vars, _INDENT)}'
+                    '\n\n'
+                    f'All commands:\n\n{textwrap.indent(all_rpcs, _INDENT)}'
+                    f'\n\n{self.footer}'.strip())
+
+        # If item is a string, find commands matching that.
+        if isinstance(item, str):
+            matches = {n: m for n, m in self._methods.items() if item in n}
+            if not matches:
+                return f'No matches found for {item!r}'
+
+            if len(matches) == 1:
+                name, method = next(iter(matches.items()))
+                return f'{name}\n\n{inspect.getdoc(method)}'
+
+            return f'Multiple matches for {item!r}:\n\n' + textwrap.indent(
+                '\n'.join(matches), _INDENT)
+
+        return inspect.getdoc(item) or f'No documentation for {item!r}.'
+
+    def _variables_without_methods(self) -> Mapping[str, object]:
+        packages = frozenset(
+            n.split('.', 1)[0] for n in self._methods if '.' in n)
+
+        return {
+            name: var
+            for name, var in self._variables.items() if name not in packages
+        }
+
+    def __call__(self, item: object = None) -> None:
+        """Prints the help string."""
+        print(self.help(item))
+
+    def __repr__(self) -> str:
+        """Returns the help, so foo and foo() are equivalent in a console."""
+        return self.help()
+
+
+class ClientInfo(NamedTuple):
+    """Information about an RPC client as it appears in the console."""
+    # The name to use in the console to refer to this client.
+    name: str
+
+    # An object to use in the console for the client. May be a pw_rpc.Client.
+    client: object
+
+    # The pw_rpc.Client; may be the same object as client.
+    rpc_client: pw_rpc.Client
+
+
+class Context:
+    """The Context class is used to set up an interactive RPC console.
+
+    The Context manages a set of variables that make it easy to access RPCs and
+    protobufs in a REPL.
+
+    As an example, this class can be used to set up a console with IPython:
+
+    .. code-block:: python
+
+      context = console_tools.Context(
+          clients, default_client, protos, help_header=WELCOME_MESSAGE)
+      IPython.terminal.embed.InteractiveShellEmbed().mainloop(
+          module=types.SimpleNamespace(**context.variables()))
+    """
+    def __init__(self,
+                 client_info: Collection[ClientInfo],
+                 default_client: Any,
+                 protos: python_protos.Library,
+                 *,
+                 help_header: str = '') -> None:
+        """Creates an RPC console context.
+
+        Protos and RPC services are accessible by their proto package and name.
+        The target for these can be set with the set_target function.
+
+        Args:
+          client_info: ClientInfo objects that represent the clients this
+              console uses to communicate with other devices
+          default_client: default client object; must be one of the clients
+          protos: protobufs to use for RPCs for all clients
+          help_header: Message to display for the help command
+        """
+        assert client_info, 'At least one client must be provided!'
+
+        self.client_info = client_info
+        self.current_client = default_client
+        self.protos = protos
+
+        # Store objects with references to RPC services, sorted by package.
+        self._services: Dict[str, types.SimpleNamespace] = defaultdict(
+            types.SimpleNamespace)
+
+        self._variables: Dict[str, object] = dict(
+            Status=pw_status.Status,
+            set_target=functions.help_as_repr(self.set_target),
+            # The original built-in help function is available as 'python_help'.
+            python_help=help,
+        )
+
+        # Make the RPC clients and protos available in the console.
+        self._variables.update((c.name, c.client) for c in self.client_info)
+
+        # Make the proto package hierarchy directly available in the console.
+        for package in self.protos.packages:
+            self._variables[package._package] = package  # pylint: disable=protected-access
+
+        # Monkey patch the message types to use an improved repr function.
+        for message_type in self.protos.messages():
+            message_type.__repr__ = python_protos.proto_repr
+
+        # Set up the 'help' command.
+        all_methods = chain.from_iterable(c.rpc_client.methods()
+                                          for c in self.client_info)
+        self._helper = CommandHelper.from_methods(
+            all_methods, self._variables, help_header,
+            'Type a command and hit Enter to see detailed help information.')
+
+        self._variables['help'] = self._helper
+
+        # Call set_target to set up for the default target.
+        self.set_target(self.current_client)
+
+    def variables(self) -> Dict[str, Any]:
+        """Returns a mapping of names to variables for use in an RPC console."""
+        return self._variables
+
+    def set_target(self,
+                   selected_client: object,
+                   channel_id: int = None) -> None:
+        """Sets the default target for commands."""
+        # Make sure the variable is one of the client variables.
+        name = ''
+        rpc_client: Any = None
+
+        for name, client, rpc_client in self.client_info:
+            if selected_client is client:
+                print('CURRENT RPC TARGET:', name)
+                break
+        else:
+            raise ValueError('Supported targets :' +
+                             ', '.join(c.name for c in self.client_info))
+
+        # Update the RPC services to use the newly selected target.
+        for service_client in rpc_client.channel(channel_id).rpcs:
+            # Patch all method protos to use the improved __repr__ function too.
+            for method in (m.method for m in service_client):
+                method.request_type.__repr__ = python_protos.proto_repr
+                method.response_type.__repr__ = python_protos.proto_repr
+
+            service = service_client._service  # pylint: disable=protected-access
+            setattr(self._services[service.package], service.name,
+                    service_client)
+
+        # Add the RPC methods to their proto packages.
+        for package_name, rpcs in self._services.items():
+            self.protos.packages[package_name]._add_item(rpcs)  # pylint: disable=protected-access
+
+        self.current_client = selected_client
diff --git a/pw_rpc/py/pw_rpc/console_tools/functions.py b/pw_rpc/py/pw_rpc/console_tools/functions.py
new file mode 100644
index 0000000..dea6251
--- /dev/null
+++ b/pw_rpc/py/pw_rpc/console_tools/functions.py
@@ -0,0 +1,90 @@
+# Copyright 2021 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.
+"""Code for improving interactive use of Python functions."""
+
+import inspect
+import textwrap
+from typing import Callable
+
+
+def _annotation_name(annotation: object) -> str:
+    if isinstance(annotation, str):
+        return annotation
+
+    return getattr(annotation, '__name__', repr(annotation))
+
+
+def format_parameter(param: inspect.Parameter) -> str:
+    """Formats a parameter for printing in a function signature."""
+    if param.kind == param.VAR_POSITIONAL:
+        name = '*' + param.name
+    elif param.kind == param.VAR_KEYWORD:
+        name = '**' + param.name
+    else:
+        name = param.name
+
+    if param.default is param.empty:
+        default = ''
+    else:
+        default = f' = {param.default}'
+
+    if param.annotation is param.empty:
+        annotation = ''
+    else:
+        annotation = f': {_annotation_name(param.annotation)}'
+
+    return f'{name}{annotation}{default}'
+
+
+def format_signature(name: str, signature: inspect.Signature) -> str:
+    """Formats a function signature as if it were source code.
+
+    Does not yet handle / and * markers.
+    """
+    params = ', '.join(
+        format_parameter(arg) for arg in signature.parameters.values())
+    if signature.return_annotation is signature.empty:
+        return_annotation = ''
+    else:
+        return_annotation = ' -> ' + _annotation_name(
+            signature.return_annotation)
+
+    return f'{name}({params}){return_annotation}'
+
+
+def format_function_help(function: Callable) -> str:
+    """Formats a help string with a declaration and docstring."""
+    signature = format_signature(
+        function.__name__, inspect.signature(function, follow_wrapped=False))
+
+    docs = inspect.getdoc(function) or '(no docstring)'
+    return f'{signature}:\n\n{textwrap.indent(docs, "    ")}'
+
+
+def help_as_repr(function: Callable) -> Callable:
+    """Wraps a function so that its repr() and docstring provide detailed help.
+
+    This is useful for creating commands in an interactive console. In a
+    console, typing a function's name and hitting Enter shows rich documentation
+    with the full function signature, type annotations, and docstring when the
+    function is wrapped with help_as_repr.
+    """
+    def display_help(_):
+        return format_function_help(function)
+
+    return type(
+        function.__name__, (),
+        dict(__call__=staticmethod(function),
+             __doc__=format_function_help(function),
+             __repr__=display_help))()
diff --git a/pw_hdlc_lite/py/pw_hdlc_lite/py.typed b/pw_rpc/py/pw_rpc/console_tools/py.typed
similarity index 100%
copy from pw_hdlc_lite/py/pw_hdlc_lite/py.typed
copy to pw_rpc/py/pw_rpc/console_tools/py.typed
diff --git a/pw_rpc/py/pw_rpc/console_tools/watchdog.py b/pw_rpc/py/pw_rpc/console_tools/watchdog.py
new file mode 100644
index 0000000..bcc9ffe
--- /dev/null
+++ b/pw_rpc/py/pw_rpc/console_tools/watchdog.py
@@ -0,0 +1,83 @@
+# Copyright 2021 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.
+"""Simple watchdog class."""
+
+import threading
+from typing import Any, Callable
+
+
+class Watchdog:
+    """Simple class that times out unless reset.
+
+    This class could be used, for example, to track a device's connection state
+    for devices that send a periodic heartbeat packet.
+    """
+    def __init__(self,
+                 on_reset: Callable[[], Any],
+                 on_expiration: Callable[[], Any],
+                 while_expired: Callable[[], Any] = lambda: None,
+                 timeout_s: float = 1,
+                 expired_timeout_s: float = None):
+        """Creates a watchdog; start() must be called to start it.
+
+        Args:
+          on_reset: Function called when the watchdog is reset after having
+              expired.
+          on_expiration: Function called when the timeout expires.
+          while_expired: Function called repeatedly while the watchdog is
+              expired.
+          timeout_s: If reset() is not called for timeout_s, the watchdog
+              expires and calls the on_expiration callback.
+          expired_timeout_s: While expired, the watchdog calls the
+              while_expired callback every expired_timeout_s.
+        """
+        self._on_reset = on_reset
+        self._on_expiration = on_expiration
+        self._while_expired = while_expired
+
+        self.timeout_s = timeout_s
+
+        if expired_timeout_s is None:
+            self.expired_timeout_s = self.timeout_s * 10
+        else:
+            self.expired_timeout_s = expired_timeout_s
+
+        self.expired: bool = False
+        self._watchdog = threading.Timer(0, self._timeout_expired)
+
+    def start(self) -> None:
+        """Starts the watchdog; must be called for the watchdog to work."""
+        self._watchdog.cancel()
+        self._watchdog = threading.Timer(
+            self.expired_timeout_s if self.expired else self.timeout_s,
+            self._timeout_expired)
+        self._watchdog.daemon = True
+        self._watchdog.start()
+
+    def reset(self) -> None:
+        """Resets the timeout; calls the on_reset callback if expired."""
+        if self.expired:
+            self.expired = False
+            self._on_reset()
+
+        self.start()
+
+    def _timeout_expired(self) -> None:
+        if self.expired:
+            self._while_expired()
+        else:
+            self.expired = True
+            self._on_expiration()
+
+        self.start()
diff --git a/pw_rpc/py/pw_rpc/descriptors.py b/pw_rpc/py/pw_rpc/descriptors.py
index ce94b32..a771b8f 100644
--- a/pw_rpc/py/pw_rpc/descriptors.py
+++ b/pw_rpc/py/pw_rpc/descriptors.py
@@ -15,18 +15,23 @@
 
 from dataclasses import dataclass
 import enum
-from typing import Any, Callable, Collection, Dict, Generic, Iterable, Iterator
-from typing import Tuple, TypeVar, Union
+from inspect import Parameter
+from typing import (Any, Callable, Collection, Dict, Generic, Iterable,
+                    Iterator, Tuple, TypeVar, Union)
 
-from google.protobuf import descriptor_pb2
-from pw_rpc import ids
+from google.protobuf import descriptor_pb2, message_factory
+from google.protobuf.descriptor import (FieldDescriptor, MethodDescriptor,
+                                        ServiceDescriptor)
+from google.protobuf.message import Message
 from pw_protobuf_compiler import python_protos
 
+from pw_rpc import ids
+
 
 @dataclass(frozen=True)
 class Channel:
     id: int
-    output: Callable[[bytes], None]
+    output: Callable[[bytes], Any]
 
     def __repr__(self) -> str:
         return f'Channel({self.id})'
@@ -35,23 +40,30 @@
 @dataclass(frozen=True, eq=False)
 class Service:
     """Describes an RPC service."""
-    name: str
+    _descriptor: ServiceDescriptor
     id: int
-    package: str
     methods: 'Methods'
 
     @property
+    def name(self):
+        return self._descriptor.name
+
+    @property
     def full_name(self):
-        return f'{self.package}.{self.name}'
+        return self._descriptor.full_name
+
+    @property
+    def package(self):
+        return self._descriptor.file.package
 
     @classmethod
-    def from_descriptor(cls, module, descriptor):
-        service = cls(descriptor.name, ids.calculate(descriptor.full_name),
-                      descriptor.file.package, None)
+    def from_descriptor(cls, descriptor: ServiceDescriptor) -> 'Service':
+        service = cls(descriptor, ids.calculate(descriptor.full_name),
+                      None)  # type: ignore[arg-type]
         object.__setattr__(
             service, 'methods',
             Methods(
-                Method.from_descriptor(module, method_descriptor, service)
+                Method.from_descriptor(method_descriptor, service)
                 for method_descriptor in descriptor.methods))
 
         return service
@@ -76,12 +88,76 @@
     return method_pb.server_streaming, method_pb.client_streaming
 
 
+_PROTO_FIELD_TYPES = {
+    FieldDescriptor.TYPE_BOOL: bool,
+    FieldDescriptor.TYPE_BYTES: bytes,
+    FieldDescriptor.TYPE_DOUBLE: float,
+    FieldDescriptor.TYPE_ENUM: int,
+    FieldDescriptor.TYPE_FIXED32: int,
+    FieldDescriptor.TYPE_FIXED64: int,
+    FieldDescriptor.TYPE_FLOAT: float,
+    FieldDescriptor.TYPE_INT32: int,
+    FieldDescriptor.TYPE_INT64: int,
+    FieldDescriptor.TYPE_SFIXED32: int,
+    FieldDescriptor.TYPE_SFIXED64: int,
+    FieldDescriptor.TYPE_SINT32: int,
+    FieldDescriptor.TYPE_SINT64: int,
+    FieldDescriptor.TYPE_STRING: str,
+    FieldDescriptor.TYPE_UINT32: int,
+    FieldDescriptor.TYPE_UINT64: int,
+    # These types are not annotated:
+    # FieldDescriptor.TYPE_GROUP = 10
+    # FieldDescriptor.TYPE_MESSAGE = 11
+}
+
+
+def _field_type_annotation(field: FieldDescriptor):
+    """Creates a field type annotation to use in the help message only."""
+    if field.type == FieldDescriptor.TYPE_MESSAGE:
+        annotation = message_factory.MessageFactory(
+            field.message_type.file.pool).GetPrototype(field.message_type)
+    else:
+        annotation = _PROTO_FIELD_TYPES.get(field.type, Parameter.empty)
+
+    if field.label == FieldDescriptor.LABEL_REPEATED:
+        return Iterable[annotation]  # type: ignore[valid-type]
+
+    return annotation
+
+
+def field_help(proto_message, *, annotations: bool = False) -> Iterator[str]:
+    """Yields argument strings for proto fields for use in a help message."""
+    for field in proto_message.DESCRIPTOR.fields:
+        if field.type == FieldDescriptor.TYPE_ENUM:
+            value = field.enum_type.values_by_number[field.default_value].name
+            type_name = field.enum_type.full_name
+            value = f'{type_name.rsplit(".", 1)[0]}.{value}'
+        else:
+            type_name = _PROTO_FIELD_TYPES[field.type].__name__
+            value = repr(field.default_value)
+
+        if annotations:
+            yield f'{field.name}: {type_name} = {value}'
+        else:
+            yield f'{field.name}={value}'
+
+
+def _message_is_type(proto, expected_type) -> bool:
+    """Returns true if the protobuf instance is the expected type."""
+    # Getting protobuf classes from google.protobuf.message_factory may create a
+    # new, unique generated proto class. Any generated classes for a particular
+    # proto message share the same MessageDescriptor instance and are
+    # interchangeable, so check the descriptors in addition to the types.
+    return isinstance(proto, expected_type) or (isinstance(
+        proto, Message) and proto.DESCRIPTOR is expected_type.DESCRIPTOR)
+
+
 @dataclass(frozen=True, eq=False)
 class Method:
     """Describes a method in a service."""
 
+    _descriptor: MethodDescriptor
     service: Service
-    name: str
     id: int
     server_streaming: bool
     client_streaming: bool
@@ -89,30 +165,45 @@
     response_type: Any
 
     @classmethod
-    def from_descriptor(cls, module, descriptor, service: Service):
+    def from_descriptor(cls, descriptor: MethodDescriptor, service: Service):
+        input_factory = message_factory.MessageFactory(
+            descriptor.input_type.file.pool)
+        output_factory = message_factory.MessageFactory(
+            descriptor.output_type.file.pool)
         return Method(
+            descriptor,
             service,
-            descriptor.name,
             ids.calculate(descriptor.name),
             *_streaming_attributes(descriptor),
-            getattr(module, descriptor.input_type.name),
-            getattr(module, descriptor.output_type.name),
+            input_factory.GetPrototype(descriptor.input_type),
+            output_factory.GetPrototype(descriptor.output_type),
         )
 
     class Type(enum.Enum):
         UNARY = 0
         SERVER_STREAMING = 1
         CLIENT_STREAMING = 2
-        BIDI_STREAMING = 3
+        BIDIRECTIONAL_STREAMING = 3
+
+        def sentence_name(self) -> str:
+            return self.name.lower().replace('_', ' ')  # pylint: disable=no-member
+
+    @property
+    def name(self) -> str:
+        return self._descriptor.name
 
     @property
     def full_name(self) -> str:
-        return f'{self.service.full_name}.{self.name}'
+        return self._descriptor.full_name
+
+    @property
+    def package(self) -> str:
+        return self._descriptor.containing_service.file.package
 
     @property
     def type(self) -> 'Method.Type':
         if self.server_streaming and self.client_streaming:
-            return self.Type.BIDI_STREAMING
+            return self.Type.BIDIRECTIONAL_STREAMING
 
         if self.server_streaming:
             return self.Type.SERVER_STREAMING
@@ -130,15 +221,16 @@
         fields (but not both).
         """
         if proto and proto_kwargs:
+            proto_str = repr(proto).strip() or "''"
             raise TypeError(
                 'Requests must be provided either as a message object or a '
                 'series of keyword args, but both were provided '
-                f'({proto!r} and {proto_kwargs!r})')
+                f"({proto_str} and {proto_kwargs!r})")
 
         if proto is None:
             return self.request_type(**proto_kwargs)
 
-        if not isinstance(proto, self.request_type):
+        if not _message_is_type(proto, self.request_type):
             try:
                 bad_type = proto.DESCRIPTOR.full_name
             except AttributeError:
@@ -150,6 +242,17 @@
 
         return proto
 
+    def request_parameters(self) -> Iterator[Parameter]:
+        """Yields inspect.Parameters corresponding to the request's fields.
+
+        This can be used to make function signatures match the request proto.
+        """
+        for field in self.request_type.DESCRIPTOR.fields:
+            yield Parameter(field.name,
+                            Parameter.KEYWORD_ONLY,
+                            annotation=_field_type_annotation(field),
+                            default=field.default_value)
+
     def __repr__(self) -> str:
         req = self._method_parameter(self.request_type, self.client_streaming)
         res = self._method_parameter(self.response_type, self.server_streaming)
diff --git a/pw_rpc/py/pw_rpc/packet_pb2.py b/pw_rpc/py/pw_rpc/packet_pb2.py
deleted file mode 100644
index a86d53c..0000000
--- a/pw_rpc/py/pw_rpc/packet_pb2.py
+++ /dev/null
@@ -1,168 +0,0 @@
-# [Pigweed] This file is a checked-in version of a generated protobuf module.
-# TODO(pwbug/239) Implement the pw_protobuf_package GN template and Python
-#     proto generation, then delete this file.
-
-# pylint: skip-file
-
-# type: ignore
-
-# yapf: disable
-
-# -*- coding: utf-8 -*-
-# Generated by the protocol buffer compiler.  DO NOT EDIT!
-# source: pw_rpc/pw_rpc_protos/packet.proto
-
-import sys
-_b=sys.version_info[0]<3 and (lambda x:x) or (lambda x:x.encode('latin1'))
-from google.protobuf.internal import enum_type_wrapper
-from google.protobuf import descriptor as _descriptor
-from google.protobuf import message as _message
-from google.protobuf import reflection as _reflection
-from google.protobuf import symbol_database as _symbol_database
-# @@protoc_insertion_point(imports)
-
-_sym_db = _symbol_database.Default()
-
-
-
-
-DESCRIPTOR = _descriptor.FileDescriptor(
-  name='pw_rpc/pw_rpc_protos/packet.proto',
-  package='pw.rpc.internal',
-  syntax='proto3',
-  serialized_options=None,
-  serialized_pb=_b('\n!pw_rpc/pw_rpc_protos/packet.proto\x12\x0fpw.rpc.internal\"\x92\x01\n\tRpcPacket\x12)\n\x04type\x18\x01 \x01(\x0e\x32\x1b.pw.rpc.internal.PacketType\x12\x12\n\nchannel_id\x18\x02 \x01(\r\x12\x12\n\nservice_id\x18\x03 \x01(\x07\x12\x11\n\tmethod_id\x18\x04 \x01(\x07\x12\x0f\n\x07payload\x18\x05 \x01(\x0c\x12\x0e\n\x06status\x18\x06 \x01(\r*\x93\x01\n\nPacketType\x12\x0b\n\x07REQUEST\x10\x00\x12\x15\n\x11\x43LIENT_STREAM_END\x10\x02\x12\x10\n\x0c\x43LIENT_ERROR\x10\x04\x12\x18\n\x14\x43\x41NCEL_SERVER_STREAM\x10\x06\x12\x0c\n\x08RESPONSE\x10\x01\x12\x15\n\x11SERVER_STREAM_END\x10\x03\x12\x10\n\x0cSERVER_ERROR\x10\x05\x62\x06proto3')
-)
-
-_PACKETTYPE = _descriptor.EnumDescriptor(
-  name='PacketType',
-  full_name='pw.rpc.internal.PacketType',
-  filename=None,
-  file=DESCRIPTOR,
-  values=[
-    _descriptor.EnumValueDescriptor(
-      name='REQUEST', index=0, number=0,
-      serialized_options=None,
-      type=None),
-    _descriptor.EnumValueDescriptor(
-      name='CLIENT_STREAM_END', index=1, number=2,
-      serialized_options=None,
-      type=None),
-    _descriptor.EnumValueDescriptor(
-      name='CLIENT_ERROR', index=2, number=4,
-      serialized_options=None,
-      type=None),
-    _descriptor.EnumValueDescriptor(
-      name='CANCEL_SERVER_STREAM', index=3, number=6,
-      serialized_options=None,
-      type=None),
-    _descriptor.EnumValueDescriptor(
-      name='RESPONSE', index=4, number=1,
-      serialized_options=None,
-      type=None),
-    _descriptor.EnumValueDescriptor(
-      name='SERVER_STREAM_END', index=5, number=3,
-      serialized_options=None,
-      type=None),
-    _descriptor.EnumValueDescriptor(
-      name='SERVER_ERROR', index=6, number=5,
-      serialized_options=None,
-      type=None),
-  ],
-  containing_type=None,
-  serialized_options=None,
-  serialized_start=204,
-  serialized_end=351,
-)
-_sym_db.RegisterEnumDescriptor(_PACKETTYPE)
-
-PacketType = enum_type_wrapper.EnumTypeWrapper(_PACKETTYPE)
-REQUEST = 0
-CLIENT_STREAM_END = 2
-CLIENT_ERROR = 4
-CANCEL_SERVER_STREAM = 6
-RESPONSE = 1
-SERVER_STREAM_END = 3
-SERVER_ERROR = 5
-
-
-
-_RPCPACKET = _descriptor.Descriptor(
-  name='RpcPacket',
-  full_name='pw.rpc.internal.RpcPacket',
-  filename=None,
-  file=DESCRIPTOR,
-  containing_type=None,
-  fields=[
-    _descriptor.FieldDescriptor(
-      name='type', full_name='pw.rpc.internal.RpcPacket.type', index=0,
-      number=1, type=14, cpp_type=8, label=1,
-      has_default_value=False, default_value=0,
-      message_type=None, enum_type=None, containing_type=None,
-      is_extension=False, extension_scope=None,
-      serialized_options=None, file=DESCRIPTOR),
-    _descriptor.FieldDescriptor(
-      name='channel_id', full_name='pw.rpc.internal.RpcPacket.channel_id', index=1,
-      number=2, type=13, cpp_type=3, label=1,
-      has_default_value=False, default_value=0,
-      message_type=None, enum_type=None, containing_type=None,
-      is_extension=False, extension_scope=None,
-      serialized_options=None, file=DESCRIPTOR),
-    _descriptor.FieldDescriptor(
-      name='service_id', full_name='pw.rpc.internal.RpcPacket.service_id', index=2,
-      number=3, type=7, cpp_type=3, label=1,
-      has_default_value=False, default_value=0,
-      message_type=None, enum_type=None, containing_type=None,
-      is_extension=False, extension_scope=None,
-      serialized_options=None, file=DESCRIPTOR),
-    _descriptor.FieldDescriptor(
-      name='method_id', full_name='pw.rpc.internal.RpcPacket.method_id', index=3,
-      number=4, type=7, cpp_type=3, label=1,
-      has_default_value=False, default_value=0,
-      message_type=None, enum_type=None, containing_type=None,
-      is_extension=False, extension_scope=None,
-      serialized_options=None, file=DESCRIPTOR),
-    _descriptor.FieldDescriptor(
-      name='payload', full_name='pw.rpc.internal.RpcPacket.payload', index=4,
-      number=5, type=12, cpp_type=9, label=1,
-      has_default_value=False, default_value=_b(""),
-      message_type=None, enum_type=None, containing_type=None,
-      is_extension=False, extension_scope=None,
-      serialized_options=None, file=DESCRIPTOR),
-    _descriptor.FieldDescriptor(
-      name='status', full_name='pw.rpc.internal.RpcPacket.status', index=5,
-      number=6, type=13, cpp_type=3, label=1,
-      has_default_value=False, default_value=0,
-      message_type=None, enum_type=None, containing_type=None,
-      is_extension=False, extension_scope=None,
-      serialized_options=None, file=DESCRIPTOR),
-  ],
-  extensions=[
-  ],
-  nested_types=[],
-  enum_types=[
-  ],
-  serialized_options=None,
-  is_extendable=False,
-  syntax='proto3',
-  extension_ranges=[],
-  oneofs=[
-  ],
-  serialized_start=55,
-  serialized_end=201,
-)
-
-_RPCPACKET.fields_by_name['type'].enum_type = _PACKETTYPE
-DESCRIPTOR.message_types_by_name['RpcPacket'] = _RPCPACKET
-DESCRIPTOR.enum_types_by_name['PacketType'] = _PACKETTYPE
-_sym_db.RegisterFileDescriptor(DESCRIPTOR)
-
-RpcPacket = _reflection.GeneratedProtocolMessageType('RpcPacket', (_message.Message,), {
-  'DESCRIPTOR' : _RPCPACKET,
-  '__module__' : 'pw_rpc.pw_rpc_protos.packet_pb2'
-  # @@protoc_insertion_point(class_scope:pw.rpc.internal.RpcPacket)
-  })
-_sym_db.RegisterMessage(RpcPacket)
-
-
-# @@protoc_insertion_point(module_scope)
diff --git a/pw_rpc/py/pw_rpc/packet_pb2.pyi b/pw_rpc/py/pw_rpc/packet_pb2.pyi
deleted file mode 100644
index e13b07d..0000000
--- a/pw_rpc/py/pw_rpc/packet_pb2.pyi
+++ /dev/null
@@ -1,76 +0,0 @@
-# @generated by generate_proto_mypy_stubs.py.  Do not edit!
-import sys
-from google.protobuf.descriptor import (
-    Descriptor as google___protobuf___descriptor___Descriptor,
-    EnumDescriptor as google___protobuf___descriptor___EnumDescriptor,
-    FileDescriptor as google___protobuf___descriptor___FileDescriptor,
-)
-
-from google.protobuf.internal.enum_type_wrapper import (  # type: ignore
-    _EnumTypeWrapper as google___protobuf___internal___enum_type_wrapper____EnumTypeWrapper,
-)
-
-from google.protobuf.message import (
-    Message as google___protobuf___message___Message,
-)
-
-from typing import (
-    NewType as typing___NewType,
-    Optional as typing___Optional,
-    cast as typing___cast,
-)
-
-from typing_extensions import (
-    Literal as typing_extensions___Literal,
-)
-
-
-builtin___bool = bool
-builtin___bytes = bytes
-builtin___float = float
-builtin___int = int
-
-
-DESCRIPTOR: google___protobuf___descriptor___FileDescriptor = ...
-
-PacketTypeValue = typing___NewType('PacketTypeValue', builtin___int)
-type___PacketTypeValue = PacketTypeValue
-PacketType: _PacketType
-class _PacketType(google___protobuf___internal___enum_type_wrapper____EnumTypeWrapper[PacketTypeValue]):
-    DESCRIPTOR: google___protobuf___descriptor___EnumDescriptor = ...
-    REQUEST = typing___cast(PacketTypeValue, 0)
-    CLIENT_STREAM_END = typing___cast(PacketTypeValue, 2)
-    CLIENT_ERROR = typing___cast(PacketTypeValue, 4)
-    CANCEL_SERVER_STREAM = typing___cast(PacketTypeValue, 6)
-    RESPONSE = typing___cast(PacketTypeValue, 1)
-    SERVER_STREAM_END = typing___cast(PacketTypeValue, 3)
-    SERVER_ERROR = typing___cast(PacketTypeValue, 5)
-REQUEST = typing___cast(PacketTypeValue, 0)
-CLIENT_STREAM_END = typing___cast(PacketTypeValue, 2)
-CLIENT_ERROR = typing___cast(PacketTypeValue, 4)
-CANCEL_SERVER_STREAM = typing___cast(PacketTypeValue, 6)
-RESPONSE = typing___cast(PacketTypeValue, 1)
-SERVER_STREAM_END = typing___cast(PacketTypeValue, 3)
-SERVER_ERROR = typing___cast(PacketTypeValue, 5)
-type___PacketType = PacketType
-
-class RpcPacket(google___protobuf___message___Message):
-    DESCRIPTOR: google___protobuf___descriptor___Descriptor = ...
-    type: type___PacketTypeValue = ...
-    channel_id: builtin___int = ...
-    service_id: builtin___int = ...
-    method_id: builtin___int = ...
-    payload: builtin___bytes = ...
-    status: builtin___int = ...
-
-    def __init__(self,
-        *,
-        type : typing___Optional[type___PacketTypeValue] = None,
-        channel_id : typing___Optional[builtin___int] = None,
-        service_id : typing___Optional[builtin___int] = None,
-        method_id : typing___Optional[builtin___int] = None,
-        payload : typing___Optional[builtin___bytes] = None,
-        status : typing___Optional[builtin___int] = None,
-        ) -> None: ...
-    def ClearField(self, field_name: typing_extensions___Literal[u"channel_id",b"channel_id",u"method_id",b"method_id",u"payload",b"payload",u"service_id",b"service_id",u"status",b"status",u"type",b"type"]) -> None: ...
-type___RpcPacket = RpcPacket
diff --git a/pw_rpc/py/pw_rpc/packets.py b/pw_rpc/py/pw_rpc/packets.py
index dca5a10..3f15468 100644
--- a/pw_rpc/py/pw_rpc/packets.py
+++ b/pw_rpc/py/pw_rpc/packets.py
@@ -15,12 +15,8 @@
 
 from google.protobuf import message
 from pw_status import Status
-from pw_rpc import packet_pb2
 
-DecodeError = message.DecodeError
-Message = message.Message
-
-PacketType = packet_pb2.PacketType
+from pw_rpc.internal import packet_pb2
 
 
 def decode(data: bytes):
diff --git a/pw_rpc/py/pw_rpc/plugin.py b/pw_rpc/py/pw_rpc/plugin.py
index 595b436..44337d9 100644
--- a/pw_rpc/py/pw_rpc/plugin.py
+++ b/pw_rpc/py/pw_rpc/plugin.py
@@ -63,5 +63,11 @@
     request = plugin_pb2.CodeGeneratorRequest.FromString(data)
     response = plugin_pb2.CodeGeneratorResponse()
     process_proto_request(codegen, request, response)
+
+    # Declare that this plugin supports optional fields in proto3. No proto
+    # message code is generated, so optional in proto3 is supported trivially.
+    response.supported_features |= (  # type: ignore[attr-defined]
+        response.FEATURE_PROTO3_OPTIONAL)  # type: ignore[attr-defined]
+
     sys.stdout.buffer.write(response.SerializeToString())
     return 0
diff --git a/pw_rpc/py/setup.py b/pw_rpc/py/setup.py
deleted file mode 100644
index 957a286..0000000
--- a/pw_rpc/py/setup.py
+++ /dev/null
@@ -1,39 +0,0 @@
-# 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_rpc"""
-
-import setuptools  # type: ignore
-
-setuptools.setup(
-    name='pw_rpc',
-    version='0.0.1',
-    author='Pigweed Authors',
-    author_email='pigweed-developers@googlegroups.com',
-    description='On-device remote procedure calls',
-    packages=setuptools.find_packages(),
-    package_data={'pw_rpc': ['py.typed']},
-    zip_safe=False,
-    entry_points={
-        'console_scripts': [
-            'pw_rpc_codegen_nanopb = pw_rpc.plugin_nanopb:main',
-            'pw_rpc_codegen_raw = pw_rpc.plugin_raw:main'
-        ]
-    },
-    install_requires=[
-        'protobuf',
-        # 'pw_protobuf_compiler',
-        # 'pw_status',
-    ],
-    tests_require=['pw_build'],
-)
diff --git a/pw_rpc/py/tests/callback_client_test.py b/pw_rpc/py/tests/callback_client_test.py
new file mode 100755
index 0000000..ed7ec9c
--- /dev/null
+++ b/pw_rpc/py/tests/callback_client_test.py
@@ -0,0 +1,443 @@
+#!/usr/bin/env python3
+# Copyright 2021 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 using the callback client for pw_rpc."""
+
+import unittest
+from unittest import mock
+from typing import List, Tuple
+
+from pw_protobuf_compiler import python_protos
+from pw_status import Status
+
+from pw_rpc import callback_client, client, packets
+from pw_rpc.internal import packet_pb2
+
+TEST_PROTO_1 = """\
+syntax = "proto3";
+
+package pw.test1;
+
+message SomeMessage {
+  uint32 magic_number = 1;
+}
+
+message AnotherMessage {
+  enum Result {
+    FAILED = 0;
+    FAILED_MISERABLY = 1;
+    I_DONT_WANT_TO_TALK_ABOUT_IT = 2;
+  }
+
+  Result result = 1;
+  string payload = 2;
+}
+
+service PublicService {
+  rpc SomeUnary(SomeMessage) returns (AnotherMessage) {}
+  rpc SomeServerStreaming(SomeMessage) returns (stream AnotherMessage) {}
+  rpc SomeClientStreaming(stream SomeMessage) returns (AnotherMessage) {}
+  rpc SomeBidiStreaming(stream SomeMessage) returns (stream AnotherMessage) {}
+}
+"""
+
+
+def _rpc(method_stub):
+    return client.PendingRpc(method_stub.channel, method_stub.method.service,
+                             method_stub.method)
+
+
+class CallbackClientImplTest(unittest.TestCase):
+    """Tests the callback_client as used within a pw_rpc Client."""
+    def setUp(self):
+        self._protos = python_protos.Library.from_strings(TEST_PROTO_1)
+        self._request = self._protos.packages.pw.test1.SomeMessage
+
+        self._client = client.Client.from_modules(
+            callback_client.Impl(), [client.Channel(1, self._handle_request)],
+            self._protos.modules())
+        self._service = self._client.channel(1).rpcs.pw.test1.PublicService
+
+        self._last_request: packet_pb2.RpcPacket = None
+        self._next_packets: List[Tuple[bytes, Status]] = []
+        self._send_responses_on_request = True
+
+    def _enqueue_response(self,
+                          channel_id: int,
+                          method=None,
+                          status: Status = Status.OK,
+                          response=b'',
+                          *,
+                          ids: Tuple[int, int] = None,
+                          process_status=Status.OK):
+        if method:
+            assert ids is None
+            service_id, method_id = method.service.id, method.id
+        else:
+            assert ids is not None and method is None
+            service_id, method_id = ids
+
+        if isinstance(response, bytes):
+            payload = response
+        else:
+            payload = response.SerializeToString()
+
+        self._next_packets.append(
+            (packet_pb2.RpcPacket(type=packet_pb2.PacketType.RESPONSE,
+                                  channel_id=channel_id,
+                                  service_id=service_id,
+                                  method_id=method_id,
+                                  status=status.value,
+                                  payload=payload).SerializeToString(),
+             process_status))
+
+    def _enqueue_stream_end(self,
+                            channel_id: int,
+                            method,
+                            status: Status = Status.OK,
+                            process_status=Status.OK):
+        self._next_packets.append(
+            (packet_pb2.RpcPacket(type=packet_pb2.PacketType.SERVER_STREAM_END,
+                                  channel_id=channel_id,
+                                  service_id=method.service.id,
+                                  method_id=method.id,
+                                  status=status.value).SerializeToString(),
+             process_status))
+
+    def _enqueue_error(self,
+                       channel_id: int,
+                       method,
+                       status: Status,
+                       process_status=Status.OK):
+        self._next_packets.append(
+            (packet_pb2.RpcPacket(type=packet_pb2.PacketType.SERVER_ERROR,
+                                  channel_id=channel_id,
+                                  service_id=method.service.id,
+                                  method_id=method.id,
+                                  status=status.value).SerializeToString(),
+             process_status))
+
+    def _handle_request(self, data: bytes):
+        # Disable this method to prevent infinite recursion if processing the
+        # packet happens to send another packet.
+        if not self._send_responses_on_request:
+            return
+
+        self._send_responses_on_request = False
+
+        self._last_request = packets.decode(data)
+
+        for packet, status in self._next_packets:
+            self.assertIs(status, self._client.process_packet(packet))
+
+        self._next_packets.clear()
+        self._send_responses_on_request = True
+
+    def _sent_payload(self, message_type):
+        self.assertIsNotNone(self._last_request)
+        message = message_type()
+        message.ParseFromString(self._last_request.payload)
+        return message
+
+    def test_invoke_unary_rpc(self):
+        method = self._service.SomeUnary.method
+
+        for _ in range(3):
+            self._enqueue_response(1, method, Status.ABORTED,
+                                   method.response_type(payload='0_o'))
+
+            status, response = self._service.SomeUnary(
+                method.request_type(magic_number=6))
+
+            self.assertEqual(
+                6,
+                self._sent_payload(method.request_type).magic_number)
+
+            self.assertIs(Status.ABORTED, status)
+            self.assertEqual('0_o', response.payload)
+
+    def test_invoke_unary_rpc_keep_open(self) -> None:
+        method = self._service.SomeUnary.method
+
+        payload_1 = method.response_type(payload='-_-')
+        payload_2 = method.response_type(payload='0_o')
+
+        self._enqueue_response(1, method, Status.ABORTED, payload_1)
+
+        replies: list = []
+        enqueue_replies = lambda _, reply: replies.append(reply)
+
+        self._service.SomeUnary.invoke(method.request_type(magic_number=6),
+                                       enqueue_replies,
+                                       enqueue_replies,
+                                       keep_open=True)
+
+        self.assertEqual([payload_1, Status.ABORTED], replies)
+
+        # Send another packet and make sure it is processed even though the RPC
+        # terminated.
+        self._client.process_packet(
+            packet_pb2.RpcPacket(
+                type=packet_pb2.PacketType.RESPONSE,
+                channel_id=1,
+                service_id=method.service.id,
+                method_id=method.id,
+                status=Status.OK.value,
+                payload=payload_2.SerializeToString()).SerializeToString())
+
+        self.assertEqual([payload_1, Status.ABORTED, payload_2, Status.OK],
+                         replies)
+
+    def test_invoke_unary_rpc_with_callback(self):
+        method = self._service.SomeUnary.method
+
+        for _ in range(3):
+            self._enqueue_response(1, method, Status.ABORTED,
+                                   method.response_type(payload='0_o'))
+
+            callback = mock.Mock()
+            self._service.SomeUnary.invoke(self._request(magic_number=5),
+                                           callback, callback)
+
+            callback.assert_has_calls([
+                mock.call(_rpc(self._service.SomeUnary),
+                          method.response_type(payload='0_o')),
+                mock.call(_rpc(self._service.SomeUnary), Status.ABORTED)
+            ])
+
+            self.assertEqual(
+                5,
+                self._sent_payload(method.request_type).magic_number)
+
+    def test_unary_rpc_server_error(self):
+        method = self._service.SomeUnary.method
+
+        for _ in range(3):
+            self._enqueue_error(1, method, Status.NOT_FOUND)
+
+            with self.assertRaises(callback_client.RpcError) as context:
+                self._service.SomeUnary(method.request_type(magic_number=6))
+
+            self.assertIs(context.exception.status, Status.NOT_FOUND)
+
+    def test_invoke_unary_rpc_callback_exceptions_suppressed(self):
+        stub = self._service.SomeUnary
+
+        self._enqueue_response(1, stub.method)
+        exception_msg = 'YOU BROKE IT O-]-<'
+
+        with self.assertLogs(callback_client.__name__, 'ERROR') as logs:
+            stub.invoke(self._request(),
+                        mock.Mock(side_effect=Exception(exception_msg)))
+
+        self.assertIn(exception_msg, ''.join(logs.output))
+
+        # Make sure we can still invoke the RPC.
+        self._enqueue_response(1, stub.method, Status.UNKNOWN)
+        status, _ = stub()
+        self.assertIs(status, Status.UNKNOWN)
+
+    def test_invoke_unary_rpc_with_callback_cancel(self):
+        callback = mock.Mock()
+
+        for _ in range(3):
+            call = self._service.SomeUnary.invoke(
+                self._request(magic_number=55), callback)
+
+            self.assertIsNotNone(self._last_request)
+            self._last_request = None
+
+            # Try to invoke the RPC again before cancelling, without overriding
+            # pending RPCs.
+            with self.assertRaises(client.Error):
+                self._service.SomeUnary.invoke(self._request(magic_number=56),
+                                               callback,
+                                               override_pending=False)
+
+            self.assertTrue(call.cancel())
+            self.assertFalse(call.cancel())  # Already cancelled, returns False
+
+            # Unary RPCs do not send a cancel request to the server.
+            self.assertIsNone(self._last_request)
+
+        callback.assert_not_called()
+
+    def test_reinvoke_unary_rpc(self):
+        for _ in range(3):
+            self._last_request = None
+            self._service.SomeUnary.invoke(self._request(magic_number=55),
+                                           override_pending=True)
+            self.assertEqual(self._last_request.type,
+                             packet_pb2.PacketType.REQUEST)
+
+    def test_invoke_server_streaming(self):
+        method = self._service.SomeServerStreaming.method
+
+        rep1 = method.response_type(payload='!!!')
+        rep2 = method.response_type(payload='?')
+
+        for _ in range(3):
+            self._enqueue_response(1, method, response=rep1)
+            self._enqueue_response(1, method, response=rep2)
+            self._enqueue_stream_end(1, method, Status.ABORTED)
+
+            self.assertEqual(
+                [rep1, rep2],
+                list(self._service.SomeServerStreaming(magic_number=4)))
+
+            self.assertEqual(
+                4,
+                self._sent_payload(method.request_type).magic_number)
+
+    def test_invoke_server_streaming_with_callbacks(self):
+        method = self._service.SomeServerStreaming.method
+
+        rep1 = method.response_type(payload='!!!')
+        rep2 = method.response_type(payload='?')
+
+        for _ in range(3):
+            self._enqueue_response(1, method, response=rep1)
+            self._enqueue_response(1, method, response=rep2)
+            self._enqueue_stream_end(1, method, Status.ABORTED)
+
+            callback = mock.Mock()
+            self._service.SomeServerStreaming.invoke(
+                self._request(magic_number=3), callback, callback)
+
+            rpc = _rpc(self._service.SomeServerStreaming)
+            callback.assert_has_calls([
+                mock.call(rpc, method.response_type(payload='!!!')),
+                mock.call(rpc, method.response_type(payload='?')),
+                mock.call(rpc, Status.ABORTED),
+            ])
+
+            self.assertEqual(
+                3,
+                self._sent_payload(method.request_type).magic_number)
+
+    def test_invoke_server_streaming_with_callback_cancel(self):
+        stub = self._service.SomeServerStreaming
+
+        resp = stub.method.response_type(payload='!!!')
+        self._enqueue_response(1, stub.method, response=resp)
+
+        callback = mock.Mock()
+        call = stub.invoke(self._request(magic_number=3), callback)
+        callback.assert_called_once_with(
+            _rpc(stub), stub.method.response_type(payload='!!!'))
+
+        callback.reset_mock()
+
+        call.cancel()
+
+        self.assertEqual(self._last_request.type,
+                         packet_pb2.PacketType.CANCEL_SERVER_STREAM)
+
+        # Ensure the RPC can be called after being cancelled.
+        self._enqueue_response(1, stub.method, response=resp)
+        self._enqueue_stream_end(1, stub.method, Status.OK)
+
+        call = stub.invoke(self._request(magic_number=3), callback, callback)
+
+        callback.assert_has_calls([
+            mock.call(_rpc(stub), stub.method.response_type(payload='!!!')),
+            mock.call(_rpc(stub), Status.OK),
+        ])
+
+    def test_ignore_bad_packets_with_pending_rpc(self):
+        method = self._service.SomeUnary.method
+        service_id = method.service.id
+
+        # Unknown channel
+        self._enqueue_response(999, method, process_status=Status.NOT_FOUND)
+        # Bad service
+        self._enqueue_response(1,
+                               ids=(999, method.id),
+                               process_status=Status.OK)
+        # Bad method
+        self._enqueue_response(1,
+                               ids=(service_id, 999),
+                               process_status=Status.OK)
+        # For RPC not pending (is Status.OK because the packet is processed)
+        self._enqueue_response(1,
+                               ids=(service_id,
+                                    self._service.SomeBidiStreaming.method.id),
+                               process_status=Status.OK)
+
+        self._enqueue_response(1, method, process_status=Status.OK)
+
+        status, response = self._service.SomeUnary(magic_number=6)
+        self.assertIs(Status.OK, status)
+        self.assertEqual('', response.payload)
+
+    def test_pass_none_if_payload_fails_to_decode(self):
+        method = self._service.SomeUnary.method
+
+        self._enqueue_response(1,
+                               method,
+                               Status.OK,
+                               b'INVALID DATA!!!',
+                               process_status=Status.OK)
+
+        status, response = self._service.SomeUnary(magic_number=6)
+        self.assertIs(status, Status.OK)
+        self.assertIsNone(response)
+
+    def test_rpc_help_contains_method_name(self):
+        rpc = self._service.SomeUnary
+        self.assertIn(rpc.method.full_name, rpc.help())
+
+    def test_default_timeouts_set_on_impl(self):
+        impl = callback_client.Impl(None, 1.5)
+
+        self.assertEqual(impl.default_unary_timeout_s, None)
+        self.assertEqual(impl.default_stream_timeout_s, 1.5)
+
+    def test_default_timeouts_set_for_all_rpcs(self):
+        rpc_client = client.Client.from_modules(callback_client.Impl(
+            99, 100), [client.Channel(1, lambda *a, **b: None)],
+                                                self._protos.modules())
+        rpcs = rpc_client.channel(1).rpcs
+
+        self.assertEqual(
+            rpcs.pw.test1.PublicService.SomeUnary.default_timeout_s, 99)
+        self.assertEqual(
+            rpcs.pw.test1.PublicService.SomeServerStreaming.default_timeout_s,
+            100)
+
+    def test_timeout_unary(self):
+        with self.assertRaises(callback_client.RpcTimeout):
+            self._service.SomeUnary(pw_rpc_timeout_s=0.0001)
+
+    def test_timeout_unary_set_default(self):
+        self._service.SomeUnary.default_timeout_s = 0.0001
+
+        with self.assertRaises(callback_client.RpcTimeout):
+            self._service.SomeUnary()
+
+    def test_timeout_server_streaming_iteration(self):
+        responses = self._service.SomeServerStreaming(pw_rpc_timeout_s=0.0001)
+        with self.assertRaises(callback_client.RpcTimeout):
+            for _ in responses:
+                pass
+
+    def test_timeout_server_streaming_responses(self):
+        responses = self._service.SomeServerStreaming()
+        with self.assertRaises(callback_client.RpcTimeout):
+            for _ in responses.responses(timeout_s=0.0001):
+                pass
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/pw_rpc/py/tests/client_test.py b/pw_rpc/py/tests/client_test.py
new file mode 100755
index 0000000..6fcfd60
--- /dev/null
+++ b/pw_rpc/py/tests/client_test.py
@@ -0,0 +1,290 @@
+#!/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 creating pw_rpc client."""
+
+import unittest
+from typing import Optional
+
+from pw_protobuf_compiler import python_protos
+from pw_status import Status
+
+from pw_rpc import callback_client, client, packets
+import pw_rpc.ids
+from pw_rpc.internal.packet_pb2 import PacketType, RpcPacket
+
+TEST_PROTO_1 = """\
+syntax = "proto3";
+
+package pw.test1;
+
+message SomeMessage {
+  uint32 magic_number = 1;
+}
+
+message AnotherMessage {
+  enum Result {
+    FAILED = 0;
+    FAILED_MISERABLY = 1;
+    I_DONT_WANT_TO_TALK_ABOUT_IT = 2;
+  }
+
+  Result result = 1;
+  string payload = 2;
+}
+
+service PublicService {
+  rpc SomeUnary(SomeMessage) returns (AnotherMessage) {}
+  rpc SomeServerStreaming(SomeMessage) returns (stream AnotherMessage) {}
+  rpc SomeClientStreaming(stream SomeMessage) returns (AnotherMessage) {}
+  rpc SomeBidiStreaming(stream SomeMessage) returns (stream AnotherMessage) {}
+}
+"""
+
+TEST_PROTO_2 = """\
+syntax = "proto2";
+
+package pw.test2;
+
+message Request {
+  optional float magic_number = 1;
+}
+
+message Response {
+}
+
+service Alpha {
+  rpc Unary(Request) returns (Response) {}
+}
+
+service Bravo {
+  rpc BidiStreaming(stream Request) returns (stream Response) {}
+}
+"""
+
+
+def _test_setup(output=None):
+    protos = python_protos.Library.from_strings([TEST_PROTO_1, TEST_PROTO_2])
+    return protos, client.Client.from_modules(
+        callback_client.Impl(),
+        [client.Channel(1, output),
+         client.Channel(2, lambda _: None)], protos.modules())
+
+
+class ChannelClientTest(unittest.TestCase):
+    """Tests the ChannelClient."""
+    def setUp(self) -> None:
+        self._channel_client = _test_setup()[1].channel(1)
+
+    def test_access_service_client_as_attribute_or_index(self) -> None:
+        self.assertIs(self._channel_client.rpcs.pw.test1.PublicService,
+                      self._channel_client.rpcs['pw.test1.PublicService'])
+        self.assertIs(
+            self._channel_client.rpcs.pw.test1.PublicService,
+            self._channel_client.rpcs[pw_rpc.ids.calculate(
+                'pw.test1.PublicService')])
+
+    def test_access_method_client_as_attribute_or_index(self) -> None:
+        self.assertIs(self._channel_client.rpcs.pw.test2.Alpha.Unary,
+                      self._channel_client.rpcs['pw.test2.Alpha']['Unary'])
+        self.assertIs(
+            self._channel_client.rpcs.pw.test2.Alpha.Unary,
+            self._channel_client.rpcs['pw.test2.Alpha'][pw_rpc.ids.calculate(
+                'Unary')])
+
+    def test_service_name(self) -> None:
+        self.assertEqual(
+            self._channel_client.rpcs.pw.test2.Alpha.Unary.service.name,
+            'Alpha')
+        self.assertEqual(
+            self._channel_client.rpcs.pw.test2.Alpha.Unary.service.full_name,
+            'pw.test2.Alpha')
+
+    def test_method_name(self) -> None:
+        self.assertEqual(
+            self._channel_client.rpcs.pw.test2.Alpha.Unary.method.name,
+            'Unary')
+        self.assertEqual(
+            self._channel_client.rpcs.pw.test2.Alpha.Unary.method.full_name,
+            'pw.test2.Alpha.Unary')
+
+    def test_iterate_over_all_methods(self) -> None:
+        channel_client = self._channel_client
+        all_methods = {
+            channel_client.rpcs.pw.test1.PublicService.SomeUnary,
+            channel_client.rpcs.pw.test1.PublicService.SomeServerStreaming,
+            channel_client.rpcs.pw.test1.PublicService.SomeClientStreaming,
+            channel_client.rpcs.pw.test1.PublicService.SomeBidiStreaming,
+            channel_client.rpcs.pw.test2.Alpha.Unary,
+            channel_client.rpcs.pw.test2.Bravo.BidiStreaming,
+        }
+        self.assertEqual(set(channel_client.methods()), all_methods)
+
+    def test_check_for_presence_of_services(self) -> None:
+        self.assertIn('pw.test1.PublicService', self._channel_client.rpcs)
+        self.assertIn(pw_rpc.ids.calculate('pw.test1.PublicService'),
+                      self._channel_client.rpcs)
+
+    def test_check_for_presence_of_missing_services(self) -> None:
+        self.assertNotIn('PublicService', self._channel_client.rpcs)
+        self.assertNotIn('NotAService', self._channel_client.rpcs)
+        self.assertNotIn(-1213, self._channel_client.rpcs)
+
+    def test_check_for_presence_of_methods(self) -> None:
+        service = self._channel_client.rpcs.pw.test1.PublicService
+        self.assertIn('SomeUnary', service)
+        self.assertIn(pw_rpc.ids.calculate('SomeUnary'), service)
+
+    def test_check_for_presence_of_missing_methods(self) -> None:
+        service = self._channel_client.rpcs.pw.test1.PublicService
+        self.assertNotIn('Some', service)
+        self.assertNotIn('Unary', service)
+        self.assertNotIn(12345, service)
+
+    def test_method_fully_qualified_name(self) -> None:
+        self.assertIs(self._channel_client.method('pw.test2.Alpha/Unary'),
+                      self._channel_client.rpcs.pw.test2.Alpha.Unary)
+        self.assertIs(self._channel_client.method('pw.test2.Alpha.Unary'),
+                      self._channel_client.rpcs.pw.test2.Alpha.Unary)
+
+
+class ClientTest(unittest.TestCase):
+    """Tests the pw_rpc Client independently of the ClientImpl."""
+    def setUp(self) -> None:
+        self._last_packet_sent_bytes: Optional[bytes] = None
+        self._protos, self._client = _test_setup(self._save_packet)
+
+    def _save_packet(self, packet) -> None:
+        self._last_packet_sent_bytes = packet
+
+    def _last_packet_sent(self) -> RpcPacket:
+        packet = RpcPacket()
+        assert self._last_packet_sent_bytes is not None
+        packet.MergeFromString(self._last_packet_sent_bytes)
+        return packet
+
+    def test_channel(self) -> None:
+        self.assertEqual(self._client.channel(1).channel.id, 1)
+        self.assertEqual(self._client.channel(2).channel.id, 2)
+
+    def test_channel_default_is_first_listed(self) -> None:
+        self.assertEqual(self._client.channel().channel.id, 1)
+
+    def test_channel_invalid(self) -> None:
+        with self.assertRaises(KeyError):
+            self._client.channel(404)
+
+    def test_all_methods(self) -> None:
+        services = self._client.services
+
+        all_methods = {
+            services['pw.test1.PublicService'].methods['SomeUnary'],
+            services['pw.test1.PublicService'].methods['SomeServerStreaming'],
+            services['pw.test1.PublicService'].methods['SomeClientStreaming'],
+            services['pw.test1.PublicService'].methods['SomeBidiStreaming'],
+            services['pw.test2.Alpha'].methods['Unary'],
+            services['pw.test2.Bravo'].methods['BidiStreaming'],
+        }
+        self.assertEqual(set(self._client.methods()), all_methods)
+
+    def test_method_present(self) -> None:
+        self.assertIs(
+            self._client.method('pw.test1.PublicService.SomeUnary'), self.
+            _client.services['pw.test1.PublicService'].methods['SomeUnary'])
+        self.assertIs(
+            self._client.method('pw.test1.PublicService/SomeUnary'), self.
+            _client.services['pw.test1.PublicService'].methods['SomeUnary'])
+
+    def test_method_invalid_format(self) -> None:
+        with self.assertRaises(ValueError):
+            self._client.method('SomeUnary')
+
+    def test_method_not_present(self) -> None:
+        with self.assertRaises(KeyError):
+            self._client.method('pw.test1.PublicService/ThisIsNotGood')
+
+        with self.assertRaises(KeyError):
+            self._client.method('nothing.Good')
+
+    def test_process_packet_invalid_proto_data(self) -> None:
+        self.assertIs(self._client.process_packet(b'NOT a packet!'),
+                      Status.DATA_LOSS)
+
+    def test_process_packet_not_for_client(self) -> None:
+        self.assertIs(
+            self._client.process_packet(
+                RpcPacket(type=PacketType.REQUEST).SerializeToString()),
+            Status.INVALID_ARGUMENT)
+
+    def test_process_packet_unrecognized_channel(self) -> None:
+        self.assertIs(
+            self._client.process_packet(
+                packets.encode_response(
+                    (123, 456, 789),
+                    self._protos.packages.pw.test2.Request())),
+            Status.NOT_FOUND)
+
+    def test_process_packet_unrecognized_service(self) -> None:
+        self.assertIs(
+            self._client.process_packet(
+                packets.encode_response(
+                    (1, 456, 789), self._protos.packages.pw.test2.Request())),
+            Status.OK)
+
+        self.assertEqual(
+            self._last_packet_sent(),
+            RpcPacket(type=PacketType.CLIENT_ERROR,
+                      channel_id=1,
+                      service_id=456,
+                      method_id=789,
+                      status=Status.NOT_FOUND.value))
+
+    def test_process_packet_unrecognized_method(self) -> None:
+        service = next(iter(self._client.services))
+
+        self.assertIs(
+            self._client.process_packet(
+                packets.encode_response(
+                    (1, service.id, 789),
+                    self._protos.packages.pw.test2.Request())), Status.OK)
+
+        self.assertEqual(
+            self._last_packet_sent(),
+            RpcPacket(type=PacketType.CLIENT_ERROR,
+                      channel_id=1,
+                      service_id=service.id,
+                      method_id=789,
+                      status=Status.NOT_FOUND.value))
+
+    def test_process_packet_non_pending_method(self) -> None:
+        service = next(iter(self._client.services))
+        method = next(iter(service.methods))
+
+        self.assertIs(
+            self._client.process_packet(
+                packets.encode_response(
+                    (1, service.id, method.id),
+                    self._protos.packages.pw.test2.Request())), Status.OK)
+
+        self.assertEqual(
+            self._last_packet_sent(),
+            RpcPacket(type=PacketType.CLIENT_ERROR,
+                      channel_id=1,
+                      service_id=service.id,
+                      method_id=method.id,
+                      status=Status.FAILED_PRECONDITION.value))
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/pw_rpc/py/tests/console_tools/console_tools_test.py b/pw_rpc/py/tests/console_tools/console_tools_test.py
new file mode 100755
index 0000000..3dbb5ce
--- /dev/null
+++ b/pw_rpc/py/tests/console_tools/console_tools_test.py
@@ -0,0 +1,233 @@
+#!/usr/bin/env python3
+# Copyright 2021 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 encoding HDLC frames."""
+
+import unittest
+from unittest import mock
+
+import pw_status
+
+from pw_protobuf_compiler import python_protos
+import pw_rpc
+from pw_rpc import callback_client
+from pw_rpc.console_tools import CommandHelper, Context, ClientInfo, Watchdog
+
+
+class TestWatchdog(unittest.TestCase):
+    """Tests the Watchdog class."""
+    def setUp(self) -> None:
+        self._reset = mock.Mock()
+        self._expiration = mock.Mock()
+        self._while_expired = mock.Mock()
+
+        self._watchdog = Watchdog(self._reset, self._expiration,
+                                  self._while_expired, 99999)
+
+    def _trigger_timeout(self) -> None:
+        # Don't wait for the timeout -- that's too flaky. Call the internal
+        # timeout function instead.
+        self._watchdog._timeout_expired()  # pylint: disable=protected-access
+
+    def test_expiration_callbacks(self) -> None:
+        self._watchdog.start()
+
+        self._expiration.not_called()
+
+        self._trigger_timeout()
+
+        self._expiration.assert_called_once_with()
+        self._while_expired.assert_not_called()
+
+        self._trigger_timeout()
+
+        self._expiration.assert_called_once_with()
+        self._while_expired.assert_called_once_with()
+
+        self._trigger_timeout()
+
+        self._expiration.assert_called_once_with()
+        self._while_expired.assert_called()
+
+    def test_reset_not_called_unless_expires(self) -> None:
+        self._watchdog.start()
+        self._watchdog.reset()
+
+        self._reset.assert_not_called()
+        self._expiration.assert_not_called()
+        self._while_expired.assert_not_called()
+
+    def test_reset_called_if_expired(self) -> None:
+        self._watchdog.start()
+        self._trigger_timeout()
+
+        self._watchdog.reset()
+
+        self._trigger_timeout()
+
+        self._reset.assert_called_once_with()
+        self._expiration.assert_called()
+
+
+class TestCommandHelper(unittest.TestCase):
+    def setUp(self) -> None:
+        self._commands = {'command_a': 'A', 'command_B': 'B'}
+        self._variables = {'hello': 1, 'world': 2}
+        self._helper = CommandHelper(self._commands, self._variables,
+                                     'The header', 'The footer')
+
+    def test_help_contents(self) -> None:
+        help_contents = self._helper.help()
+
+        self.assertTrue(help_contents.startswith('The header'))
+        self.assertIn('The footer', help_contents)
+
+        for var_name in self._variables:
+            self.assertIn(var_name, help_contents)
+
+        for cmd_name in self._commands:
+            self.assertIn(cmd_name, help_contents)
+
+    def test_repr_is_help(self):
+        self.assertEqual(repr(self._helper), self._helper.help())
+
+
+_PROTO = """\
+syntax = "proto3";
+
+package the.pkg;
+
+message SomeMessage {
+  uint32 magic_number = 1;
+
+    message AnotherMessage {
+      string payload = 1;
+    }
+
+}
+
+service Service {
+  rpc Unary(SomeMessage) returns (SomeMessage.AnotherMessage);
+}
+"""
+
+
+class TestConsoleContext(unittest.TestCase):
+    """Tests console_tools.console.Context."""
+    def setUp(self) -> None:
+        self._protos = python_protos.Library.from_strings(_PROTO)
+
+        self._info = ClientInfo(
+            'the_client', object(),
+            pw_rpc.Client.from_modules(callback_client.Impl(), [
+                pw_rpc.Channel(1, lambda _: None),
+                pw_rpc.Channel(2, lambda _: None),
+            ], self._protos.modules()))
+
+    def test_sets_expected_variables(self) -> None:
+        variables = Context([self._info],
+                            default_client=self._info.client,
+                            protos=self._protos).variables()
+
+        self.assertIn('set_target', variables)
+
+        self.assertIsInstance(variables['help'], CommandHelper)
+        self.assertIs(variables['python_help'], help)
+        self.assertIs(pw_status.Status, variables['Status'])
+        self.assertIs(self._info.client, variables['the_client'])
+
+    def test_set_target_switches_between_clients(self) -> None:
+        client_1_channel = self._info.rpc_client.channel(1).channel
+
+        client_2_channel = pw_rpc.Channel(99, lambda _: None)
+        info_2 = ClientInfo(
+            'other_client', object(),
+            pw_rpc.Client.from_modules(callback_client.Impl(),
+                                       [client_2_channel],
+                                       self._protos.modules()))
+
+        context = Context([self._info, info_2],
+                          default_client=self._info.client,
+                          protos=self._protos)
+
+        # Make sure the RPC service switches from one client to the other.
+        self.assertIs(context.variables()['the'].pkg.Service.Unary.channel,
+                      client_1_channel)
+
+        context.set_target(info_2.client)
+
+        self.assertIs(context.variables()['the'].pkg.Service.Unary.channel,
+                      client_2_channel)
+
+    def test_default_client_must_be_in_clients(self) -> None:
+        with self.assertRaises(ValueError):
+            Context([self._info],
+                    default_client='something else',
+                    protos=self._protos)
+
+    def test_set_target_invalid_channel(self) -> None:
+        context = Context([self._info],
+                          default_client=self._info.client,
+                          protos=self._protos)
+
+        with self.assertRaises(KeyError):
+            context.set_target(self._info.client, 100)
+
+    def test_set_target_non_default_channel(self) -> None:
+        channel_1 = self._info.rpc_client.channel(1).channel
+        channel_2 = self._info.rpc_client.channel(2).channel
+
+        context = Context([self._info],
+                          default_client=self._info.client,
+                          protos=self._protos)
+        variables = context.variables()
+
+        self.assertIs(variables['the'].pkg.Service.Unary.channel, channel_1)
+
+        context.set_target(self._info.client, 2)
+
+        self.assertIs(variables['the'].pkg.Service.Unary.channel, channel_2)
+
+        with self.assertRaises(KeyError):
+            context.set_target(self._info.client, 100)
+
+    def test_set_target_requires_client_object(self) -> None:
+        context = Context([self._info],
+                          default_client=self._info.client,
+                          protos=self._protos)
+
+        with self.assertRaises(ValueError):
+            context.set_target(self._info.rpc_client)
+
+        context.set_target(self._info.client)
+
+    def test_derived_context(self) -> None:
+        called_derived_set_target = False
+
+        class DerivedContext(Context):
+            def set_target(self,
+                           unused_selected_client,
+                           unused_channel_id: int = None) -> None:
+                nonlocal called_derived_set_target
+                called_derived_set_target = True
+
+        variables = DerivedContext(client_info=[self._info],
+                                   default_client=self._info.client,
+                                   protos=self._protos).variables()
+        variables['set_target'](self._info.client)
+        self.assertTrue(called_derived_set_target)
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/pw_rpc/py/tests/console_tools/functions_test.py b/pw_rpc/py/tests/console_tools/functions_test.py
new file mode 100644
index 0000000..85f352a
--- /dev/null
+++ b/pw_rpc/py/tests/console_tools/functions_test.py
@@ -0,0 +1,67 @@
+# Copyright 2021 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 the pw_rpc.console_tools.functions module."""
+
+import unittest
+
+from pw_rpc.console_tools import functions
+
+
+def func(one, two: int, *a: bool, three=3, four: 'int' = 4, **kw) -> None:  # pylint: disable=unused-argument
+    """This is the docstring.
+
+    More stuff.
+    """
+
+
+_EXPECTED_HELP = """\
+func(one, two: int, *a: bool, three = 3, four: int = 4, **kw) -> None:
+
+    This is the docstring.
+
+    More stuff."""
+
+
+class TestFunctions(unittest.TestCase):
+    def test_format_no_args_function_help(self) -> None:
+        def simple_function():
+            pass
+
+        self.assertEqual(functions.format_function_help(simple_function),
+                         'simple_function():\n\n    (no docstring)')
+
+    def test_format_complex_function_help(self) -> None:
+        self.assertEqual(functions.format_function_help(func), _EXPECTED_HELP)
+
+    def test_help_as_repr_with_docstring_help(self) -> None:
+        wrapped = functions.help_as_repr(func)
+        self.assertEqual(repr(wrapped), _EXPECTED_HELP)
+
+    def test_help_as_repr_decorator(self) -> None:
+        @functions.help_as_repr
+        def no_docs():
+            pass
+
+        self.assertEqual(repr(no_docs), 'no_docs():\n\n    (no docstring)')
+
+    def test_help_as_repr_call_no_args(self) -> None:
+        self.assertEqual(functions.help_as_repr(lambda: 9876)(), 9876)
+
+    def test_help_as_repr_call_with_arg(self) -> None:
+        value = object()
+        self.assertIs(functions.help_as_repr(lambda arg: arg)(value), value)
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/pw_rpc/py/tests/descriptors_test.py b/pw_rpc/py/tests/descriptors_test.py
new file mode 100644
index 0000000..516d5e3
--- /dev/null
+++ b/pw_rpc/py/tests/descriptors_test.py
@@ -0,0 +1,91 @@
+# 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 classes in pw_rpc.descriptors."""
+
+import unittest
+
+from google.protobuf.message_factory import MessageFactory
+
+from pw_protobuf_compiler import python_protos
+from pw_rpc import descriptors
+
+TEST_PROTO = """\
+syntax = "proto3";
+
+package pw.test1;
+
+message SomeMessage {
+  uint32 magic_number = 1;
+}
+
+message AnotherMessage {
+  enum Result {
+    FAILED = 0;
+    FAILED_MISERABLY = 1;
+    I_DONT_WANT_TO_TALK_ABOUT_IT = 2;
+  }
+
+  Result result = 1;
+  string payload = 2;
+}
+
+service PublicService {
+  rpc SomeUnary(SomeMessage) returns (AnotherMessage) {}
+  rpc SomeServerStreaming(SomeMessage) returns (stream AnotherMessage) {}
+  rpc SomeClientStreaming(stream SomeMessage) returns (AnotherMessage) {}
+  rpc SomeBidiStreaming(stream SomeMessage) returns (stream AnotherMessage) {}
+}
+"""
+
+
+class MethodTest(unittest.TestCase):
+    """Tests pw_rpc.Method."""
+    def setUp(self):
+        module, = python_protos.compile_and_import_strings([TEST_PROTO])
+        service = descriptors.Service.from_descriptor(
+            module.DESCRIPTOR.services_by_name['PublicService'])
+        self._method = service.methods['SomeUnary']
+
+    def test_get_request_with_both_message_and_kwargs(self):
+        with self.assertRaisesRegex(TypeError, r'either'):
+            self._method.get_request(self._method.request_type(),
+                                     {'magic_number': 1})
+
+    def test_get_request_with_wrong_type(self):
+        with self.assertRaisesRegex(TypeError, r'pw\.test1\.SomeMessage'):
+            self._method.get_request('a str!', {})
+
+    def test_get_request_with_different_message_type(self):
+        msg = self._method.response_type()
+        with self.assertRaisesRegex(TypeError, r'pw\.test1\.SomeMessage'):
+            self._method.get_request(msg, {})
+
+    def test_get_request_with_different_copy_of_same_message_class(self):
+        some_message_clone = MessageFactory(
+            self._method.request_type.DESCRIPTOR.file.pool).GetPrototype(
+                self._method.request_type.DESCRIPTOR)
+
+        msg = some_message_clone()
+
+        # Protobuf classes obtained with a MessageFactory may or may not be a
+        # unique type, but will always use the same descriptor instance.
+        self.assertIsInstance(msg, some_message_clone)
+        self.assertIs(msg.DESCRIPTOR, self._method.request_type.DESCRIPTOR)
+
+        result = self._method.get_request(msg, {})
+        self.assertIs(result, msg)
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/pw_rpc/py/ids_test.py b/pw_rpc/py/tests/ids_test.py
similarity index 100%
rename from pw_rpc/py/ids_test.py
rename to pw_rpc/py/tests/ids_test.py
diff --git a/pw_rpc/py/tests/packets_test.py b/pw_rpc/py/tests/packets_test.py
new file mode 100755
index 0000000..4bf4dc1
--- /dev/null
+++ b/pw_rpc/py/tests/packets_test.py
@@ -0,0 +1,97 @@
+#!/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 creating pw_rpc client."""
+
+import unittest
+
+from pw_status import Status
+
+from pw_rpc.internal.packet_pb2 import PacketType, RpcPacket
+from pw_rpc import packets
+
+_TEST_REQUEST = RpcPacket(type=PacketType.REQUEST,
+                          channel_id=1,
+                          service_id=2,
+                          method_id=3,
+                          payload=RpcPacket(status=321).SerializeToString())
+
+
+class PacketsTest(unittest.TestCase):
+    """Tests for packet encoding and decoding."""
+    def test_encode_request(self):
+        data = packets.encode_request((1, 2, 3), RpcPacket(status=321))
+        packet = RpcPacket()
+        packet.ParseFromString(data)
+
+        self.assertEqual(_TEST_REQUEST, packet)
+
+    def test_encode_response(self):
+        response = RpcPacket(type=PacketType.RESPONSE,
+                             channel_id=1,
+                             service_id=2,
+                             method_id=3,
+                             payload=RpcPacket(status=321).SerializeToString())
+
+        data = packets.encode_response((1, 2, 3), RpcPacket(status=321))
+        packet = RpcPacket()
+        packet.ParseFromString(data)
+
+        self.assertEqual(response, packet)
+
+    def test_encode_cancel(self):
+        data = packets.encode_cancel((9, 8, 7))
+
+        packet = RpcPacket()
+        packet.ParseFromString(data)
+
+        self.assertEqual(
+            packet,
+            RpcPacket(type=PacketType.CANCEL_SERVER_STREAM,
+                      channel_id=9,
+                      service_id=8,
+                      method_id=7))
+
+    def test_encode_client_error(self):
+        data = packets.encode_client_error(_TEST_REQUEST, Status.NOT_FOUND)
+
+        packet = RpcPacket()
+        packet.ParseFromString(data)
+
+        self.assertEqual(
+            packet,
+            RpcPacket(type=PacketType.CLIENT_ERROR,
+                      channel_id=1,
+                      service_id=2,
+                      method_id=3,
+                      status=Status.NOT_FOUND.value))
+
+    def test_decode(self):
+        self.assertEqual(_TEST_REQUEST,
+                         packets.decode(_TEST_REQUEST.SerializeToString()))
+
+    def test_for_server(self):
+        self.assertTrue(packets.for_server(_TEST_REQUEST))
+
+        self.assertFalse(
+            packets.for_server(
+                RpcPacket(type=PacketType.RESPONSE,
+                          channel_id=1,
+                          service_id=2,
+                          method_id=3,
+                          payload=RpcPacket(status=321).SerializeToString())))
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/pw_rpc/raw/BUILD b/pw_rpc/raw/BUILD
index 229718e..d983b14 100644
--- a/pw_rpc/raw/BUILD
+++ b/pw_rpc/raw/BUILD
@@ -33,7 +33,7 @@
     deps = [
         "//pw_bytes",
         "//pw_rpc:server",
-    ]
+    ],
 )
 
 pw_cc_library(
@@ -43,18 +43,7 @@
     ],
     deps = [
         ":method",
-    ]
-)
-
-pw_cc_library(
-    name = "service_method_traits",
-    hdrs = [
-        "public/pw_rpc/internal/raw_service_method_traits.h",
     ],
-    deps = [
-        ":method_union",
-        "//pw_rpc:service_method_traits",
-    ]
 )
 
 pw_cc_library(
@@ -66,7 +55,7 @@
         ":method_union",
         "//pw_assert",
         "//pw_containers",
-    ]
+    ],
 )
 
 pw_cc_test(
@@ -103,3 +92,10 @@
         "//pw_rpc:internal_test_utils",
     ],
 )
+
+pw_cc_test(
+    name = "stub_generation_test",
+    srcs = ["stub_generation_test.cc"],
+    # TODO(hepler): Figure out proto BUILD integration.
+    # deps = ["..:test_protos.raw_rpc"],
+)
diff --git a/pw_rpc/raw/BUILD.gn b/pw_rpc/raw/BUILD.gn
index 89d0492..e2f4cfa 100644
--- a/pw_rpc/raw/BUILD.gn
+++ b/pw_rpc/raw/BUILD.gn
@@ -40,20 +40,11 @@
   public_deps = [ ":method" ]
 }
 
-pw_source_set("service_method_traits") {
-  public_configs = [ ":public" ]
-  public = [ "public/pw_rpc/internal/raw_service_method_traits.h" ]
-  public_deps = [
-    ":method_union",
-    "..:service_method_traits",
-  ]
-}
-
 pw_source_set("test_method_context") {
   public_configs = [ ":public" ]
   public = [ "public/pw_rpc/raw_test_method_context.h" ]
   public_deps = [
-    ":service_method_traits",
+    ":method",
     dir_pw_assert,
     dir_pw_containers,
   ]
@@ -64,6 +55,7 @@
     ":codegen_test",
     ":raw_method_test",
     ":raw_method_union_test",
+    ":stub_generation_test",
   ]
 }
 
@@ -79,8 +71,10 @@
 
 pw_test("raw_method_test") {
   deps = [
+    ":method",
     ":method_union",
     "..:test_protos.pwpb",
+    "..:test_protos.raw_rpc",
     "..:test_utils",
     dir_pw_protobuf,
   ]
@@ -96,3 +90,8 @@
   ]
   sources = [ "raw_method_union_test.cc" ]
 }
+
+pw_test("stub_generation_test") {
+  deps = [ "..:test_protos.raw_rpc" ]
+  sources = [ "stub_generation_test.cc" ]
+}
diff --git a/pw_rpc/raw/codegen_test.cc b/pw_rpc/raw/codegen_test.cc
index 6194031..520ae4e 100644
--- a/pw_rpc/raw/codegen_test.cc
+++ b/pw_rpc/raw/codegen_test.cc
@@ -24,12 +24,15 @@
 
 class TestService final : public generated::TestService<TestService> {
  public:
-  StatusWithSize TestRpc(ServerContext&,
-                         ConstByteSpan request,
-                         ByteSpan response) {
+  static StatusWithSize TestRpc(ServerContext&,
+                                ConstByteSpan request,
+                                ByteSpan response) {
     int64_t integer;
     Status status;
-    DecodeRequest(request, integer, status);
+
+    if (!DecodeRequest(request, integer, status)) {
+      return StatusWithSize::DataLoss();
+    }
 
     protobuf::NestedEncoder encoder(response);
     TestResponse::Encoder test_response(&encoder);
@@ -43,8 +46,8 @@
                      RawServerWriter& writer) {
     int64_t integer;
     Status status;
-    DecodeRequest(request, integer, status);
 
+    ASSERT_TRUE(DecodeRequest(request, integer, status));
     for (int i = 0; i < integer; ++i) {
       ByteSpan buffer = writer.PayloadBuffer();
       protobuf::NestedEncoder encoder(buffer);
@@ -57,22 +60,34 @@
   }
 
  private:
-  void DecodeRequest(ConstByteSpan request, int64_t& integer, Status& status) {
+  static bool DecodeRequest(ConstByteSpan request,
+                            int64_t& integer,
+                            Status& status) {
     protobuf::Decoder decoder(request);
+    Status decode_status;
+    bool has_integer = false;
+    bool has_status = false;
 
     while (decoder.Next().ok()) {
       switch (static_cast<TestRequest::Fields>(decoder.FieldNumber())) {
         case TestRequest::Fields::INTEGER:
-          decoder.ReadInt64(&integer);
+          decode_status = decoder.ReadInt64(&integer);
+          EXPECT_EQ(OkStatus(), decode_status);
+          has_integer = decode_status.ok();
           break;
         case TestRequest::Fields::STATUS_CODE: {
           uint32_t status_code;
-          decoder.ReadUint32(&status_code);
+          decode_status = decoder.ReadUint32(&status_code);
+          EXPECT_EQ(OkStatus(), decode_status);
+          has_status = decode_status.ok();
           status = static_cast<Status::Code>(status_code);
           break;
         }
       }
     }
+    EXPECT_TRUE(has_integer);
+    EXPECT_TRUE(has_status);
+    return has_integer && has_status;
   }
 };
 
@@ -93,10 +108,10 @@
   protobuf::NestedEncoder encoder(buffer);
   test::TestRequest::Encoder test_request(&encoder);
   test_request.WriteInteger(123);
-  test_request.WriteStatusCode(Status::Ok().code());
+  test_request.WriteStatusCode(OkStatus().code());
 
   auto sws = context.call(encoder.Encode().value());
-  EXPECT_EQ(Status::Ok(), sws.status());
+  EXPECT_EQ(OkStatus(), sws.status());
 
   protobuf::Decoder decoder(context.response());
 
diff --git a/pw_rpc/raw/public/pw_rpc/internal/raw_method.h b/pw_rpc/raw/public/pw_rpc/internal/raw_method.h
index a1e3b66..6d2e898 100644
--- a/pw_rpc/raw/public/pw_rpc/internal/raw_method.h
+++ b/pw_rpc/raw/public/pw_rpc/internal/raw_method.h
@@ -49,26 +49,33 @@
 class RawMethod : public Method {
  public:
   template <auto method>
-  constexpr static RawMethod Unary(uint32_t id) {
-    return RawMethod(
-        id,
-        UnaryInvoker,
-        {.unary = [](ServerCall& call, ConstByteSpan req, ByteSpan res) {
-          return method(call, req, res);
-        }});
+  static constexpr bool matches() {
+    return std::is_same_v<MethodImplementation<method>, RawMethod>;
   }
 
   template <auto method>
-  constexpr static RawMethod ServerStreaming(uint32_t id) {
-    return RawMethod(id,
-                     ServerStreamingInvoker,
-                     Function{.server_streaming = [](ServerCall& call,
-                                                     ConstByteSpan req,
-                                                     BaseServerWriter& writer) {
-                       method(call, req, static_cast<RawServerWriter&>(writer));
-                     }});
+  static constexpr RawMethod Unary(uint32_t id) {
+    constexpr UnaryFunction wrapper =
+        [](ServerCall& call, ConstByteSpan req, ByteSpan res) {
+          return CallMethodImplFunction<method>(call, req, res);
+        };
+    return RawMethod(id, UnaryInvoker, Function{.unary = wrapper});
   }
 
+  template <auto method>
+  static constexpr RawMethod ServerStreaming(uint32_t id) {
+    constexpr ServerStreamingFunction wrapper =
+        [](ServerCall& call, ConstByteSpan request, BaseServerWriter& writer) {
+          CallMethodImplFunction<method>(
+              call, request, static_cast<RawServerWriter&>(writer));
+        };
+    return RawMethod(
+        id, ServerStreamingInvoker, Function{.server_streaming = wrapper});
+  }
+
+  // Represents an invalid method. Used to reduce error message verbosity.
+  static constexpr RawMethod Invalid() { return {0, InvalidInvoker, {}}; }
+
  private:
   using UnaryFunction = StatusWithSize (*)(ServerCall&,
                                            ConstByteSpan,
@@ -105,5 +112,38 @@
   Function function_;
 };
 
+// MethodTraits specialization for a static raw unary method.
+template <>
+struct MethodTraits<StatusWithSize (*)(
+    ServerContext&, ConstByteSpan, ByteSpan)> {
+  using Implementation = RawMethod;
+  static constexpr MethodType kType = MethodType::kUnary;
+};
+
+// MethodTraits specialization for a raw unary method.
+template <typename T>
+struct MethodTraits<StatusWithSize (T::*)(
+    ServerContext&, ConstByteSpan, ByteSpan)> {
+  using Implementation = RawMethod;
+  static constexpr MethodType kType = MethodType::kUnary;
+  using Service = T;
+};
+
+// MethodTraits specialization for a static raw server streaming method.
+template <>
+struct MethodTraits<void (*)(ServerContext&, ConstByteSpan, RawServerWriter&)> {
+  using Implementation = RawMethod;
+  static constexpr MethodType kType = MethodType::kServerStreaming;
+};
+
+// MethodTraits specialization for a raw server streaming method.
+template <typename T>
+struct MethodTraits<void (T::*)(
+    ServerContext&, ConstByteSpan, RawServerWriter&)> {
+  using Implementation = RawMethod;
+  static constexpr MethodType kType = MethodType::kServerStreaming;
+  using Service = T;
+};
+
 }  // namespace internal
 }  // namespace pw::rpc
diff --git a/pw_rpc/raw/public/pw_rpc/internal/raw_method_union.h b/pw_rpc/raw/public/pw_rpc/internal/raw_method_union.h
index 90267c5..7b377d7 100644
--- a/pw_rpc/raw/public/pw_rpc/internal/raw_method_union.h
+++ b/pw_rpc/raw/public/pw_rpc/internal/raw_method_union.h
@@ -36,58 +36,15 @@
   } impl_;
 };
 
-// MethodTraits specialization for a unary method.
-template <typename T>
-struct MethodTraits<StatusWithSize (T::*)(
-    ServerContext&, ConstByteSpan, ByteSpan)> {
-  static constexpr MethodType kType = MethodType::kUnary;
-  using Service = T;
-  using Implementation = RawMethod;
-};
-
-// MethodTraits specialization for a raw server streaming method.
-template <typename T>
-struct MethodTraits<void (T::*)(
-    ServerContext&, ConstByteSpan, RawServerWriter&)> {
-  static constexpr MethodType kType = MethodType::kServerStreaming;
-  using Service = T;
-  using Implementation = RawMethod;
-};
-
-template <auto method>
-constexpr bool kIsRaw = std::is_same_v<MethodImplementation<method>, RawMethod>;
-
 // Deduces the type of an implemented service method from its signature, and
 // returns the appropriate MethodUnion object to invoke it.
-template <auto method>
+template <auto method, MethodType type>
 constexpr RawMethod GetRawMethodFor(uint32_t id) {
-  static_assert(kIsRaw<method>,
-                "GetRawMethodFor should only be called on raw RPC methods");
-
-  using Traits = MethodTraits<decltype(method)>;
-  using ServiceImpl = typename Traits::Service;
-
-  if constexpr (Traits::kType == MethodType::kUnary) {
-    constexpr auto invoker =
-        +[](ServerCall& call, ConstByteSpan request, ByteSpan response) {
-          return (static_cast<ServiceImpl&>(call.service()).*method)(
-              call.context(), request, response);
-        };
-    return RawMethod::Unary<invoker>(id);
+  if constexpr (RawMethod::matches<method>()) {
+    return GetMethodFor<method, RawMethod, type>(id);
+  } else {
+    return InvalidMethod<method, type, RawMethod>(id);
   }
-
-  if constexpr (Traits::kType == MethodType::kServerStreaming) {
-    constexpr auto invoker =
-        +[](ServerCall& call, ConstByteSpan request, RawServerWriter& writer) {
-          (static_cast<ServiceImpl&>(call.service()).*method)(
-              call.context(), request, writer);
-        };
-    return RawMethod::ServerStreaming<invoker>(id);
-  }
-
-  constexpr auto fake_invoker =
-      +[](ServerCall&, ConstByteSpan, RawServerWriter&) {};
-  return RawMethod::ServerStreaming<fake_invoker>(0);
 };
 
 }  // namespace pw::rpc::internal
diff --git a/pw_rpc/raw/public/pw_rpc/internal/raw_service_method_traits.h b/pw_rpc/raw/public/pw_rpc/internal/raw_service_method_traits.h
deleted file mode 100644
index e304fc2..0000000
--- a/pw_rpc/raw/public/pw_rpc/internal/raw_service_method_traits.h
+++ /dev/null
@@ -1,27 +0,0 @@
-// 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.
-#pragma once
-
-#include "pw_rpc/internal/raw_method_union.h"
-#include "pw_rpc/internal/service_method_traits.h"
-
-namespace pw::rpc::internal {
-
-template <auto impl_method, uint32_t method_id>
-using RawServiceMethodTraits =
-    ServiceMethodTraits<&MethodBaseService<impl_method>::RawMethodFor,
-                        impl_method,
-                        method_id>;
-
-}  // namespace pw::rpc::internal
diff --git a/pw_rpc/raw/public/pw_rpc/raw_test_method_context.h b/pw_rpc/raw/public/pw_rpc/raw_test_method_context.h
index 732a2b2..321ea61 100644
--- a/pw_rpc/raw/public/pw_rpc/raw_test_method_context.h
+++ b/pw_rpc/raw/public/pw_rpc/raw_test_method_context.h
@@ -20,8 +20,9 @@
 #include "pw_containers/vector.h"
 #include "pw_rpc/channel.h"
 #include "pw_rpc/internal/hash.h"
+#include "pw_rpc/internal/method_lookup.h"
 #include "pw_rpc/internal/packet.h"
-#include "pw_rpc/internal/raw_service_method_traits.h"
+#include "pw_rpc/internal/raw_method.h"
 #include "pw_rpc/internal/server.h"
 
 namespace pw::rpc {
@@ -34,7 +35,7 @@
 // struct can be accessed via context.response().
 //
 //   PW_RAW_TEST_METHOD_CONTEXT(my::CoolService, TheMethod) context;
-//   EXPECT_EQ(Status::Ok(), context.call(encoded_request).status());
+//   EXPECT_EQ(OkStatus(), context.call(encoded_request).status());
 //   EXPECT_EQ(0,
 //             std::memcmp(encoded_response,
 //                         context.response().data(),
@@ -48,7 +49,7 @@
 //   context.call(encoded_response);
 //
 //   EXPECT_TRUE(context.done());  // Check that the RPC completed
-//   EXPECT_EQ(Status::Ok(), context.status());  // Check the status
+//   EXPECT_EQ(OkStatus(), context.status());  // Check the status
 //
 //   EXPECT_EQ(3u, context.responses().size());
 //   ByteSpan& response = context.responses()[0];  // check individual responses
@@ -64,8 +65,8 @@
 //
 // PW_RAW_TEST_METHOD_CONTEXT takes two optional arguments:
 //
-//   size_t max_responses: maximum responses to store; ignored unless streaming
-//   size_t output_size_bytes: buffer size; must be large enough for a packet
+//   size_t kMaxResponse: maximum responses to store; ignored unless streaming
+//   size_t kOutputSizeBytes: buffer size; must be large enough for a packet
 //
 // Example:
 //
@@ -73,23 +74,25 @@
 //   ASSERT_EQ(3u, context.responses().max_size());
 //
 #define PW_RAW_TEST_METHOD_CONTEXT(service, method, ...)              \
-  ::pw::rpc::RawTestMethodContext<&service::method,                   \
+  ::pw::rpc::RawTestMethodContext<service,                            \
+                                  &service::method,                   \
                                   ::pw::rpc::internal::Hash(#method), \
                                   ##__VA_ARGS__>
-template <auto method,
-          uint32_t method_id,
-          size_t max_responses = 4,
-          size_t output_size_bytes = 128>
+template <typename Service,
+          auto method,
+          uint32_t kMethodId,
+          size_t kMaxResponse = 4,
+          size_t kOutputSizeBytes = 128>
 class RawTestMethodContext;
 
 // Internal classes that implement RawTestMethodContext.
 namespace internal::test::raw {
 
 // A ChannelOutput implementation that stores the outgoing payloads and status.
-template <size_t output_size>
+template <size_t kOutputSize>
 class MessageOutput final : public ChannelOutput {
  public:
-  using ResponseBuffer = std::array<std::byte, output_size>;
+  using ResponseBuffer = std::array<std::byte, kOutputSize>;
 
   MessageOutput(Vector<ByteSpan>& responses,
                 Vector<ResponseBuffer>& buffers,
@@ -119,7 +122,7 @@
  private:
   ByteSpan AcquireBuffer() override { return packet_buffer_; }
 
-  Status SendAndReleaseBuffer(size_t size) override;
+  Status SendAndReleaseBuffer(std::span<const std::byte> buffer) override;
 
   Vector<ByteSpan>& responses_;
   Vector<ResponseBuffer>& buffers_;
@@ -130,10 +133,10 @@
 };
 
 // Collects everything needed to invoke a particular RPC.
-template <auto method,
-          uint32_t method_id,
-          size_t max_responses,
-          size_t output_size>
+template <typename Service,
+          uint32_t kMethodId,
+          size_t kMaxResponse,
+          size_t kOutputSize>
 struct InvocationContext {
   template <typename... Args>
   InvocationContext(Args&&... args)
@@ -144,34 +147,33 @@
         call(static_cast<internal::Server&>(server),
              static_cast<internal::Channel&>(channel),
              service,
-             RawServiceMethodTraits<method, method_id>::method()) {}
+             MethodLookup::GetRawMethod<Service, kMethodId>()) {}
 
-  using ResponseBuffer = std::array<std::byte, output_size>;
-  using Service = typename RawServiceMethodTraits<method, method_id>::Service;
+  using ResponseBuffer = std::array<std::byte, kOutputSize>;
 
-  MessageOutput<output_size> output;
+  MessageOutput<kOutputSize> output;
   rpc::Channel channel;
   rpc::Server server;
   Service service;
-  Vector<ByteSpan, max_responses> responses;
-  Vector<ResponseBuffer, max_responses> buffers;
-  std::array<std::byte, output_size> packet_buffer = {};
+  Vector<ByteSpan, kMaxResponse> responses;
+  Vector<ResponseBuffer, kMaxResponse> buffers;
+  std::array<std::byte, kOutputSize> packet_buffer = {};
   internal::ServerCall call;
 };
 
 // Method invocation context for a unary RPC. Returns the status in call() and
 // provides the response through the response() method.
-template <auto method, uint32_t method_id, size_t output_size>
+template <typename Service, auto method, uint32_t kMethodId, size_t kOutputSize>
 class UnaryContext {
  private:
-  using Context = InvocationContext<method, method_id, 1, output_size>;
+  using Context = InvocationContext<Service, kMethodId, 1, kOutputSize>;
   Context ctx_;
 
  public:
   template <typename... Args>
   UnaryContext(Args&&... args) : ctx_(std::forward<Args>(args)...) {}
 
-  typename Context::Service& service() { return ctx_.service; }
+  Service& service() { return ctx_.service; }
 
   // Invokes the RPC with the provided request. Returns RPC's StatusWithSize.
   StatusWithSize call(ConstByteSpan request) {
@@ -181,7 +183,7 @@
     ctx_.responses.emplace_back();
     auto& response = ctx_.responses.back();
     response = {ctx_.buffers.back().data(), ctx_.buffers.back().size()};
-    auto sws = (ctx_.service.*method)(ctx_.call.context(), request, response);
+    auto sws = CallMethodImplFunction<method>(ctx_.call, request, response);
     response = response.first(sws.size());
     return sws;
   }
@@ -194,29 +196,29 @@
 };
 
 // Method invocation context for a server streaming RPC.
-template <auto method,
-          uint32_t method_id,
-          size_t max_responses,
-          size_t output_size>
+template <typename Service,
+          auto method,
+          uint32_t kMethodId,
+          size_t kMaxResponse,
+          size_t kOutputSize>
 class ServerStreamingContext {
  private:
   using Context =
-      InvocationContext<method, method_id, max_responses, output_size>;
+      InvocationContext<Service, kMethodId, kMaxResponse, kOutputSize>;
   Context ctx_;
 
  public:
   template <typename... Args>
   ServerStreamingContext(Args&&... args) : ctx_(std::forward<Args>(args)...) {}
 
-  typename Context::Service& service() { return ctx_.service; }
+  Service& service() { return ctx_.service; }
 
   // Invokes the RPC with the provided request.
   void call(ConstByteSpan request) {
     ctx_.output.clear();
     BaseServerWriter server_writer(ctx_.call);
-    return (ctx_.service.*method)(ctx_.call.context(),
-                                  request,
-                                  static_cast<RawServerWriter&>(server_writer));
+    return CallMethodImplFunction<method>(
+        ctx_.call, request, static_cast<RawServerWriter&>(server_writer));
   }
 
   // Returns a server writer which writes responses into the context's buffer.
@@ -248,24 +250,33 @@
 
 // Alias to select the type of the context object to use based on which type of
 // RPC it is for.
-template <auto method, uint32_t method_id, size_t responses, size_t output_size>
+template <typename Service,
+          auto method,
+          uint32_t kMethodId,
+          size_t kMaxResponse,
+          size_t kOutputSize>
 using Context = std::tuple_element_t<
     static_cast<size_t>(MethodTraits<decltype(method)>::kType),
-    std::tuple<UnaryContext<method, method_id, output_size>,
-               ServerStreamingContext<method, method_id, responses, output_size>
+    std::tuple<UnaryContext<Service, method, kMethodId, kOutputSize>,
+               ServerStreamingContext<Service,
+                                      method,
+                                      kMethodId,
+                                      kMaxResponse,
+                                      kOutputSize>
                // TODO(hepler): Support client and bidi streaming
                >>;
 
-template <size_t output_size>
-Status MessageOutput<output_size>::SendAndReleaseBuffer(size_t size) {
+template <size_t kOutputSize>
+Status MessageOutput<kOutputSize>::SendAndReleaseBuffer(
+    std::span<const std::byte> buffer) {
   PW_ASSERT(!stream_ended_);
+  PW_ASSERT(buffer.data() == packet_buffer_.data());
 
-  if (size == 0u) {
-    return Status::Ok();
+  if (buffer.empty()) {
+    return OkStatus();
   }
 
-  Result<internal::Packet> result =
-      internal::Packet::FromBuffer(std::span(packet_buffer_.data(), size));
+  Result<internal::Packet> result = internal::Packet::FromBuffer(buffer);
   PW_ASSERT(result.ok());
 
   last_status_ = result.value().status();
@@ -288,24 +299,25 @@
     default:
       PW_CRASH("Unhandled PacketType");
   }
-  return Status::Ok();
+  return OkStatus();
 }
 
 }  // namespace internal::test::raw
 
-template <auto method,
-          uint32_t method_id,
-          size_t max_responses,
-          size_t output_size_bytes>
+template <typename Service,
+          auto method,
+          uint32_t kMethodId,
+          size_t kMaxResponse,
+          size_t kOutputSizeBytes>
 class RawTestMethodContext
     : public internal::test::raw::
-          Context<method, method_id, max_responses, output_size_bytes> {
+          Context<Service, method, kMethodId, kMaxResponse, kOutputSizeBytes> {
  public:
   // Forwards constructor arguments to the service class.
   template <typename... ServiceArgs>
   RawTestMethodContext(ServiceArgs&&... service_args)
       : internal::test::raw::
-            Context<method, method_id, max_responses, output_size_bytes>(
+            Context<Service, method, kMethodId, kMaxResponse, kOutputSizeBytes>(
                 std::forward<ServiceArgs>(service_args)...) {}
 };
 
diff --git a/pw_rpc/raw/raw_method.cc b/pw_rpc/raw/raw_method.cc
index b23ff5b..7fe7f80 100644
--- a/pw_rpc/raw/raw_method.cc
+++ b/pw_rpc/raw/raw_method.cc
@@ -28,6 +28,10 @@
 }
 
 Status RawServerWriter::Write(ConstByteSpan response) {
+  if (!open()) {
+    return Status::FailedPrecondition();
+  }
+
   if (buffer().Contains(response)) {
     return ReleasePayloadBuffer(response);
   }
@@ -60,8 +64,7 @@
 
   PW_LOG_WARN("Failed to send response packet for channel %u",
               unsigned(call.channel().id()));
-  call.channel().Send(response_buffer,
-                      Packet::ServerError(request, Status::Internal()));
+  call.channel().Send(Packet::ServerError(request, Status::Internal()));
 }
 
 void RawMethod::CallServerStreaming(ServerCall& call,
diff --git a/pw_rpc/raw/raw_method_test.cc b/pw_rpc/raw/raw_method_test.cc
index 9b03402..fd60781 100644
--- a/pw_rpc/raw/raw_method_test.cc
+++ b/pw_rpc/raw/raw_method_test.cc
@@ -24,15 +24,71 @@
 #include "pw_rpc/server_context.h"
 #include "pw_rpc/service.h"
 #include "pw_rpc_private/internal_test_utils.h"
+#include "pw_rpc_private/method_impl_tester.h"
 #include "pw_rpc_test_protos/test.pwpb.h"
 
 namespace pw::rpc::internal {
 namespace {
 
+// Create a fake service for use with the MethodImplTester.
+class TestRawService final : public Service {
+ public:
+  StatusWithSize Unary(ServerContext&, ConstByteSpan, ByteSpan) {
+    return StatusWithSize(0);
+  }
+
+  static StatusWithSize StaticUnary(ServerContext&, ConstByteSpan, ByteSpan) {
+    return StatusWithSize(0);
+  }
+
+  void ServerStreaming(ServerContext&, ConstByteSpan, RawServerWriter&) {}
+
+  static void StaticServerStreaming(ServerContext&,
+                                    ConstByteSpan,
+                                    RawServerWriter&) {}
+
+  StatusWithSize UnaryWrongArg(ServerContext&, ConstByteSpan, ConstByteSpan) {
+    return StatusWithSize(0);
+  }
+
+  static void StaticUnaryVoidReturn(ServerContext&, ConstByteSpan, ByteSpan) {}
+
+  Status ServerStreamingBadReturn(ServerContext&,
+                                  ConstByteSpan,
+                                  RawServerWriter&) {
+    return Status();
+  }
+
+  static void StaticServerStreamingMissingArg(ConstByteSpan, RawServerWriter&) {
+  }
+};
+
+// Test that the matches() function matches valid signatures.
+static_assert(RawMethod::template matches<&TestRawService::Unary>());
+static_assert(RawMethod::template matches<&TestRawService::ServerStreaming>());
+static_assert(RawMethod::template matches<&TestRawService::StaticUnary>());
+static_assert(
+    RawMethod::template matches<&TestRawService::StaticServerStreaming>());
+
+// Test that the matches() function does not match the wrong method type.
+static_assert(!RawMethod::template matches<&TestRawService::UnaryWrongArg>());
+static_assert(
+    !RawMethod::template matches<&TestRawService::StaticUnaryVoidReturn>());
+static_assert(
+    !RawMethod::template matches<&TestRawService::ServerStreamingBadReturn>());
+static_assert(!RawMethod::template matches<
+              &TestRawService::StaticServerStreamingMissingArg>());
+
+TEST(MethodImplTester, RawMethod) {
+  constexpr MethodImplTester<RawMethod, TestRawService> method_tester;
+  EXPECT_TRUE(method_tester.MethodImplIsValid());
+}
+
 struct {
   int64_t integer;
   uint32_t status_code;
 } last_request;
+
 RawServerWriter last_writer;
 
 void DecodeRawTestRequest(ConstByteSpan request) {
@@ -53,7 +109,9 @@
   }
 };
 
-StatusWithSize AddFive(ServerCall&, ConstByteSpan request, ByteSpan response) {
+StatusWithSize AddFive(ServerContext&,
+                       ConstByteSpan request,
+                       ByteSpan response) {
   DecodeRawTestRequest(request);
 
   protobuf::NestedEncoder encoder(response);
@@ -65,7 +123,9 @@
   return StatusWithSize::Unauthenticated(payload.size());
 }
 
-void StartStream(ServerCall&, ConstByteSpan request, RawServerWriter& writer) {
+void StartStream(ServerContext&,
+                 ConstByteSpan request,
+                 RawServerWriter& writer) {
   DecodeRawTestRequest(request);
   last_writer = std::move(writer);
 }
@@ -100,7 +160,7 @@
   protobuf::Decoder decoder(response.payload());
   ASSERT_TRUE(decoder.Next().ok());
   int64_t value;
-  EXPECT_EQ(decoder.ReadInt64(&value), Status::Ok());
+  EXPECT_EQ(decoder.ReadInt64(&value), OkStatus());
   EXPECT_EQ(value, 461);
 }
 
@@ -120,7 +180,7 @@
   EXPECT_EQ(777, last_request.integer);
   EXPECT_EQ(2u, last_request.status_code);
   EXPECT_TRUE(last_writer.open());
-  last_writer.Finish();
+  EXPECT_EQ(OkStatus(), last_writer.Finish());
 }
 
 TEST(RawServerWriter, Write_SendsPreviouslyAcquiredBuffer) {
@@ -134,15 +194,15 @@
   constexpr auto data = bytes::Array<0x0d, 0x06, 0xf0, 0x0d>();
   std::memcpy(buffer.data(), data.data(), data.size());
 
-  EXPECT_EQ(last_writer.Write(buffer.first(data.size())), Status::Ok());
+  EXPECT_EQ(last_writer.Write(buffer.first(data.size())), OkStatus());
 
   const internal::Packet& packet = context.output().sent_packet();
   EXPECT_EQ(packet.type(), internal::PacketType::RESPONSE);
-  EXPECT_EQ(packet.channel_id(), context.kChannelId);
-  EXPECT_EQ(packet.service_id(), context.kServiceId);
+  EXPECT_EQ(packet.channel_id(), context.channel_id());
+  EXPECT_EQ(packet.service_id(), context.service_id());
   EXPECT_EQ(packet.method_id(), context.get().method().id());
   EXPECT_EQ(std::memcmp(packet.payload().data(), data.data(), data.size()), 0);
-  EXPECT_EQ(packet.status(), Status::Ok());
+  EXPECT_EQ(packet.status(), OkStatus());
 }
 
 TEST(RawServerWriter, Write_SendsExternalBuffer) {
@@ -152,15 +212,26 @@
   method.Invoke(context.get(), context.packet({}));
 
   constexpr auto data = bytes::Array<0x0d, 0x06, 0xf0, 0x0d>();
-  EXPECT_EQ(last_writer.Write(data), Status::Ok());
+  EXPECT_EQ(last_writer.Write(data), OkStatus());
 
   const internal::Packet& packet = context.output().sent_packet();
   EXPECT_EQ(packet.type(), internal::PacketType::RESPONSE);
-  EXPECT_EQ(packet.channel_id(), context.kChannelId);
-  EXPECT_EQ(packet.service_id(), context.kServiceId);
+  EXPECT_EQ(packet.channel_id(), context.channel_id());
+  EXPECT_EQ(packet.service_id(), context.service_id());
   EXPECT_EQ(packet.method_id(), context.get().method().id());
   EXPECT_EQ(std::memcmp(packet.payload().data(), data.data(), data.size()), 0);
-  EXPECT_EQ(packet.status(), Status::Ok());
+  EXPECT_EQ(packet.status(), OkStatus());
+}
+
+TEST(RawServerWriter, Write_Closed_ReturnsFailedPrecondition) {
+  const RawMethod& method = std::get<1>(FakeService::kMethods).raw_method();
+  ServerContextForTest<FakeService> context(method);
+
+  method.Invoke(context.get(), context.packet({}));
+
+  EXPECT_EQ(OkStatus(), last_writer.Finish());
+  constexpr auto data = bytes::Array<0x0d, 0x06, 0xf0, 0x0d>();
+  EXPECT_EQ(last_writer.Write(data), Status::FailedPrecondition());
 }
 
 TEST(RawServerWriter, Write_BufferTooSmall_ReturnsOutOfRange) {
diff --git a/pw_rpc/raw/raw_method_union_test.cc b/pw_rpc/raw/raw_method_union_test.cc
index 599601e..f048366 100644
--- a/pw_rpc/raw/raw_method_union_test.cc
+++ b/pw_rpc/raw/raw_method_union_test.cc
@@ -34,9 +34,10 @@
   constexpr FakeGeneratedService(uint32_t id) : Service(id, kMethods) {}
 
   static constexpr std::array<RawMethodUnion, 3> kMethods = {
-      GetRawMethodFor<&Implementation::DoNothing>(10u),
-      GetRawMethodFor<&Implementation::AddFive>(11u),
-      GetRawMethodFor<&Implementation::StartStream>(12u),
+      GetRawMethodFor<&Implementation::DoNothing, MethodType::kUnary>(10u),
+      GetRawMethodFor<&Implementation::AddFive, MethodType::kUnary>(11u),
+      GetRawMethodFor<&Implementation::StartStream,
+                      MethodType::kServerStreaming>(12u),
   };
 };
 
@@ -117,7 +118,7 @@
   protobuf::Decoder decoder(response.payload());
   ASSERT_TRUE(decoder.Next().ok());
   int64_t value;
-  EXPECT_EQ(decoder.ReadInt64(&value), Status::Ok());
+  EXPECT_EQ(decoder.ReadInt64(&value), OkStatus());
   EXPECT_EQ(value, 461);
 }
 
@@ -138,7 +139,7 @@
   EXPECT_EQ(777, last_request.integer);
   EXPECT_EQ(2u, last_request.status_code);
   EXPECT_TRUE(last_writer.open());
-  last_writer.Finish();
+  EXPECT_EQ(OkStatus(), last_writer.Finish());
 }
 
 }  // namespace
diff --git a/pw_rpc/raw/stub_generation_test.cc b/pw_rpc/raw/stub_generation_test.cc
new file mode 100644
index 0000000..dc67a02
--- /dev/null
+++ b/pw_rpc/raw/stub_generation_test.cc
@@ -0,0 +1,29 @@
+// 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.
+
+// This macro is used to remove the generated stubs from the proto files. Define
+// so that the generated stubs can be tested.
+#define _PW_RPC_COMPILE_GENERATED_SERVICE_STUBS
+
+#include "gtest/gtest.h"
+#include "pw_rpc_test_protos/test.raw_rpc.pb.h"
+
+namespace {
+
+TEST(RawServiceStub, GeneratedStubCompiles) {
+  ::pw::rpc::test::TestService test_service;
+  EXPECT_STREQ(test_service.name(), "TestService");
+}
+
+}  // namespace
diff --git a/pw_rpc/server.cc b/pw_rpc/server.cc
index 845a64f..59da7d2 100644
--- a/pw_rpc/server.cc
+++ b/pw_rpc/server.cc
@@ -86,7 +86,7 @@
       internal::Channel temp_channel(packet.channel_id(), &interface);
       temp_channel.Send(
           Packet::ServerError(packet, Status::ResourceExhausted()));
-      return Status::Ok();  // OK since the packet was handled
+      return OkStatus();  // OK since the packet was handled
     }
   }
 
@@ -94,7 +94,7 @@
 
   if (method == nullptr) {
     channel->Send(Packet::ServerError(packet, Status::NotFound()));
-    return Status::Ok();
+    return OkStatus();
   }
 
   switch (packet.type()) {
@@ -118,7 +118,7 @@
       PW_LOG_WARN("Unable to handle packet of type %u",
                   unsigned(packet.type()));
   }
-  return Status::Ok();
+  return OkStatus();
 }
 
 std::tuple<Service*, const internal::Method*> Server::FindMethod(
diff --git a/pw_rpc/server_test.cc b/pw_rpc/server_test.cc
index e109edf..685e4fc 100644
--- a/pw_rpc/server_test.cc
+++ b/pw_rpc/server_test.cc
@@ -82,7 +82,7 @@
       std::span<const byte> payload = kDefaultPayload) {
     auto result = Packet(type, channel_id, service_id, method_id, payload)
                       .Encode(request_buffer_);
-    EXPECT_EQ(Status::Ok(), result.status());
+    EXPECT_EQ(OkStatus(), result.status());
     return result.value_or(ConstByteSpan());
   }
 
@@ -96,7 +96,7 @@
 };
 
 TEST_F(BasicServer, ProcessPacket_ValidMethod_InvokesMethod) {
-  EXPECT_EQ(Status::Ok(),
+  EXPECT_EQ(OkStatus(),
             server_.ProcessPacket(
                 EncodeRequest(PacketType::REQUEST, 1, 42, 100), output_));
 
@@ -151,7 +151,7 @@
 }
 
 TEST_F(BasicServer, ProcessPacket_InvalidMethod_NothingIsInvoked) {
-  EXPECT_EQ(Status::Ok(),
+  EXPECT_EQ(OkStatus(),
             server_.ProcessPacket(
                 EncodeRequest(PacketType::REQUEST, 1, 42, 101), output_));
 
@@ -160,7 +160,7 @@
 }
 
 TEST_F(BasicServer, ProcessPacket_InvalidMethod_SendsError) {
-  EXPECT_EQ(Status::Ok(),
+  EXPECT_EQ(OkStatus(),
             server_.ProcessPacket(EncodeRequest(PacketType::REQUEST, 1, 42, 27),
                                   output_));
 
@@ -173,7 +173,7 @@
 }
 
 TEST_F(BasicServer, ProcessPacket_InvalidService_SendsError) {
-  EXPECT_EQ(Status::Ok(),
+  EXPECT_EQ(OkStatus(),
             server_.ProcessPacket(EncodeRequest(PacketType::REQUEST, 1, 43, 27),
                                   output_));
 
@@ -187,7 +187,7 @@
 
 TEST_F(BasicServer, ProcessPacket_UnassignedChannel_AssignsToAvailableSlot) {
   TestOutput<128> unassigned_output;
-  EXPECT_EQ(Status::Ok(),
+  EXPECT_EQ(OkStatus(),
             server_.ProcessPacket(
                 EncodeRequest(PacketType::REQUEST, /*channel_id=*/99, 42, 100),
                 unassigned_output));
@@ -198,7 +198,7 @@
        ProcessPacket_UnassignedChannel_SendsResourceExhaustedIfCannotAssign) {
   channels_[2] = Channel::Create<3>(&output_);  // Occupy only available channel
 
-  EXPECT_EQ(Status::Ok(),
+  EXPECT_EQ(OkStatus(),
             server_.ProcessPacket(
                 EncodeRequest(PacketType::REQUEST, /*channel_id=*/99, 42, 27),
                 output_));
@@ -212,7 +212,7 @@
 
 TEST_F(BasicServer, ProcessPacket_Cancel_MethodNotActive_SendsError) {
   // Set up a fake ServerWriter representing an ongoing RPC.
-  EXPECT_EQ(Status::Ok(),
+  EXPECT_EQ(OkStatus(),
             server_.ProcessPacket(
                 EncodeRequest(PacketType::CANCEL_SERVER_STREAM, 1, 42, 100),
                 output_));
@@ -241,7 +241,7 @@
 };
 
 TEST_F(MethodPending, ProcessPacket_Cancel_ClosesServerWriter) {
-  EXPECT_EQ(Status::Ok(),
+  EXPECT_EQ(OkStatus(),
             server_.ProcessPacket(
                 EncodeRequest(PacketType::CANCEL_SERVER_STREAM, 1, 42, 100),
                 output_));
@@ -250,7 +250,7 @@
 }
 
 TEST_F(MethodPending, ProcessPacket_Cancel_SendsStreamEndPacket) {
-  EXPECT_EQ(Status::Ok(),
+  EXPECT_EQ(OkStatus(),
             server_.ProcessPacket(
                 EncodeRequest(PacketType::CANCEL_SERVER_STREAM, 1, 42, 100),
                 output_));
@@ -266,7 +266,7 @@
 
 TEST_F(MethodPending,
        ProcessPacket_ClientError_ClosesServerWriterWithoutStreamEnd) {
-  EXPECT_EQ(Status::OK,
+  EXPECT_EQ(OkStatus(),
             server_.ProcessPacket(
                 EncodeRequest(PacketType::CLIENT_ERROR, 1, 42, 100), output_));
 
@@ -275,7 +275,7 @@
 }
 
 TEST_F(MethodPending, ProcessPacket_Cancel_IncorrectChannel) {
-  EXPECT_EQ(Status::Ok(),
+  EXPECT_EQ(OkStatus(),
             server_.ProcessPacket(
                 EncodeRequest(PacketType::CANCEL_SERVER_STREAM, 2, 42, 100),
                 output_));
@@ -286,7 +286,7 @@
 }
 
 TEST_F(MethodPending, ProcessPacket_Cancel_IncorrectService) {
-  EXPECT_EQ(Status::Ok(),
+  EXPECT_EQ(OkStatus(),
             server_.ProcessPacket(
                 EncodeRequest(PacketType::CANCEL_SERVER_STREAM, 1, 43, 100),
                 output_));
@@ -299,7 +299,7 @@
 }
 
 TEST_F(MethodPending, ProcessPacket_CancelIncorrectMethod) {
-  EXPECT_EQ(Status::Ok(),
+  EXPECT_EQ(OkStatus(),
             server_.ProcessPacket(
                 EncodeRequest(PacketType::CANCEL_SERVER_STREAM, 1, 42, 101),
                 output_));
diff --git a/pw_rpc/size_report/server_only.cc b/pw_rpc/size_report/server_only.cc
index a8f8ff2..b57102b 100644
--- a/pw_rpc/size_report/server_only.cc
+++ b/pw_rpc/size_report/server_only.cc
@@ -26,8 +26,9 @@
 
   std::span<std::byte> AcquireBuffer() override { return buffer_; }
 
-  pw::Status SendAndReleaseBuffer(size_t size) override {
-    return pw::sys_io::WriteBytes(std::span(buffer_, size)).status();
+  pw::Status SendAndReleaseBuffer(std::span<const std::byte> buffer) override {
+    PW_DCHECK_PTR_EQ(buffer.data(), buffer_);
+    return pw::sys_io::WriteBytes(buffer).status();
   }
 
  private:
diff --git a/pw_rpc/size_report/server_with_echo_service.cc b/pw_rpc/size_report/server_with_echo_service.cc
index 860ee33..9b2f4cf 100644
--- a/pw_rpc/size_report/server_with_echo_service.cc
+++ b/pw_rpc/size_report/server_with_echo_service.cc
@@ -29,8 +29,9 @@
 
   std::span<std::byte> AcquireBuffer() override { return buffer_; }
 
-  pw::Status SendAndReleaseBuffer(size_t size) override {
-    return pw::sys_io::WriteBytes(std::span(buffer_, size)).status();
+  pw::Status SendAndReleaseBuffer(std::span<const std::byte> buffer) override {
+    PW_DCHECK_PTR_EQ(buffer.data(), buffer_);
+    return pw::sys_io::WriteBytes(buffer).status();
   }
 
  private:
diff --git a/pw_rpc/system_server/BUILD b/pw_rpc/system_server/BUILD
new file mode 100644
index 0000000..253c803
--- /dev/null
+++ b/pw_rpc/system_server/BUILD
@@ -0,0 +1,42 @@
+# 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.
+
+load(
+    "//pw_build:pigweed.bzl",
+    "pw_cc_library",
+)
+
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])
+
+pw_cc_library(
+    name = "facade",
+    hdrs = ["public/pw_rpc_system_server/rpc_server.h"],
+    includes = ["public"],
+    deps = [
+        "//pw_span",
+        "//pw_status",
+    ],
+)
+
+pw_cc_library(
+    name = "system_server",
+    hdrs = ["public/pw_rpc_system_server/rpc_server.h"],
+    deps = [
+        ":facade",
+        "//pw_span",
+        "//pw_status",
+    ],
+)
diff --git a/pw_rpc/system_server/BUILD.gn b/pw_rpc/system_server/BUILD.gn
new file mode 100644
index 0000000..c420352
--- /dev/null
+++ b/pw_rpc/system_server/BUILD.gn
@@ -0,0 +1,34 @@
+# 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/facade.gni")
+import("$dir_pw_build/target_types.gni")
+import("$dir_pw_docgen/docs.gni")
+import("backend.gni")
+
+config("public_includes") {
+  include_dirs = [ "public" ]
+  visibility = [ ":*" ]
+}
+
+pw_facade("system_server") {
+  backend = pw_rpc_system_server_BACKEND
+  public_configs = [ ":public_includes" ]
+  public_deps = [
+    "$dir_pw_rpc:server",
+    "$dir_pw_stream",
+  ]
+  public = [ "public/pw_rpc_system_server/rpc_server.h" ]
+}
diff --git a/pw_rpc/system_server/CMakeLists.txt b/pw_rpc/system_server/CMakeLists.txt
new file mode 100644
index 0000000..12f9cb6
--- /dev/null
+++ b/pw_rpc/system_server/CMakeLists.txt
@@ -0,0 +1,21 @@
+# 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.
+
+include("$ENV{PW_ROOT}/pw_build/pigweed.cmake")
+
+pw_add_facade(pw_rpc.system_server
+  PUBLIC_DEPS
+    pw_rpc.server
+    pw_stream
+)
diff --git a/pw_rpc/system_server/backend.gni b/pw_rpc/system_server/backend.gni
new file mode 100644
index 0000000..980f428
--- /dev/null
+++ b/pw_rpc/system_server/backend.gni
@@ -0,0 +1,18 @@
+# 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.
+
+declare_args() {
+  # Backend for the pw_rpc_system_server module.
+  pw_rpc_system_server_BACKEND = ""
+}
diff --git a/pw_rpc/system_server/public/pw_rpc_system_server/rpc_server.h b/pw_rpc/system_server/public/pw_rpc_system_server/rpc_server.h
new file mode 100644
index 0000000..73f2dcd
--- /dev/null
+++ b/pw_rpc/system_server/public/pw_rpc_system_server/rpc_server.h
@@ -0,0 +1,30 @@
+// 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.
+#pragma once
+
+#include "pw_rpc/server.h"
+#include "pw_stream/stream.h"
+
+namespace pw::rpc::system_server {
+
+// Initialization.
+void Init();
+
+// Get the reference of RPC Server instance.
+pw::rpc::Server& Server();
+
+// Start the server and processing packets. May not return.
+Status Start();
+
+}  // namespace pw::rpc::system_server
diff --git a/pw_span/BUILD b/pw_span/BUILD
index 7b242ff..6788a6b 100644
--- a/pw_span/BUILD
+++ b/pw_span/BUILD
@@ -26,7 +26,10 @@
     name = "pw_span",
     srcs = ["public/pw_span/internal/span.h"],
     hdrs = ["public_overrides/span"],
-    includes = ["public"],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
     deps = ["//pw_polyfill"],
 )
 
diff --git a/pw_span/BUILD.gn b/pw_span/BUILD.gn
index 4a1f9ff..e1c3f11 100644
--- a/pw_span/BUILD.gn
+++ b/pw_span/BUILD.gn
@@ -18,18 +18,34 @@
 import("$dir_pw_docgen/docs.gni")
 import("$dir_pw_unit_test/test.gni")
 
-config("default_config") {
-  include_dirs = [
-    "public",
-    "public_overrides",
-  ]
+config("public_config") {
+  include_dirs = [ "public" ]
+  visibility = [ ":*" ]
 }
 
-pw_source_set("pw_span") {
-  public_configs = [ ":default_config" ]
-  public_deps = [ "$dir_pw_polyfill" ]
+config("overrides_config") {
+  include_dirs = [ "public_overrides" ]
+  visibility = [ ":*" ]
+}
+
+# This source set provides the <span> header, which is accessed only through
+# pw_polyfill.
+pw_source_set("polyfill") {
+  remove_public_deps = [ "*" ]
+  public_configs = [ ":overrides_config" ]
+  public_deps = [ ":pw_span" ]
   public = [ "public_overrides/span" ]
+  visibility = [ "$dir_pw_polyfill:*" ]
+}
+
+# This source set provides the internal span.h header included by <span>. This
+# source set is only used by pw_polyfill, so its visibility is restricted.
+pw_source_set("pw_span") {
+  remove_public_deps = [ "*" ]
+  public_configs = [ ":public_config" ]
+  public_deps = [ "$dir_pw_polyfill" ]
   sources = [ "public/pw_span/internal/span.h" ]
+  visibility = [ ":*" ]
 }
 
 pw_test_group("tests") {
diff --git a/pw_status/docs.rst b/pw_status/docs.rst
index a2de75f..0f01ea8 100644
--- a/pw_status/docs.rst
+++ b/pw_status/docs.rst
@@ -20,26 +20,31 @@
 ) and `gRPC <https://grpc.io>`_ (`doc/statuscodes.md
 <https://github.com/grpc/grpc/blob/master/doc/statuscodes.md>`_).
 
-A ``Status`` is created with a ``static constexpr`` member function
-corresponding to the code.
+An OK ``Status`` is created by the ``pw::OkStatus`` function or by the default
+``Status`` constructor.  Non-OK ``Status`` is created with a static member
+function that corresponds with the status code.
 
 .. code-block:: cpp
 
   // Ok (gRPC code "OK") does not indicate an error; this value is returned on
   // success. It is typical to check for this value before proceeding on any
   // given call across an API or RPC boundary. To check this value, use the
-  // `Status::ok()` member function rather than inspecting the raw code.
-  Status::Ok()
+  // `status.ok()` member function rather than inspecting the raw code.
+  //
+  // OkStatus() is provided as a free function, rather than a static member
+  // function like the error statuses to avoid conflicts with the ok() member
+  // function. Status::Ok() would be too similar to Status::ok().
+  pw::OkStatus()
 
   // Cancelled (gRPC code "CANCELLED") indicates the operation was cancelled,
   // typically by the caller.
-  Status::Cancelled()
+  pw::Status::Cancelled()
 
   // Unknown (gRPC code "UNKNOWN") indicates an unknown error occurred. In
   // general, more specific errors should be raised, if possible. Errors raised
   // by APIs that do not return enough error information may be converted to
   // this error.
-  Status::Unknown()
+  pw::Status::Unknown()
 
   // InvalidArgument (gRPC code "INVALID_ARGUMENT") indicates the caller
   // specified an invalid argument, such a malformed filename. Note that such
@@ -47,14 +52,14 @@
   // arguments themselves. Errors with validly formed arguments that may cause
   // errors with the state of the receiving system should be denoted with
   // `FailedPrecondition` instead.
-  Status::InvalidArgument()
+  pw::Status::InvalidArgument()
 
   // DeadlineExceeded (gRPC code "DEADLINE_EXCEEDED") indicates a deadline
   // expired before the operation could complete. For operations that may change
   // state within a system, this error may be returned even if the operation has
   // completed successfully. For example, a successful response from a server
   // could have been delayed long enough for the deadline to expire.
-  Status::DeadlineExceeded()
+  pw::Status::DeadlineExceeded()
 
   // NotFound (gRPC code "NOT_FOUND") indicates some requested entity (such as
   // a file or directory) was not found.
@@ -63,11 +68,11 @@
   // users, such as during a gradual feature rollout or undocumented allow list.
   // If, instead, a request should be denied for specific sets of users, such as
   // through user-based access control, use `PermissionDenied` instead.
-  Status::NotFound()
+  pw::Status::NotFound()
 
   // AlreadyExists (gRPC code "ALREADY_EXISTS") indicates the entity that a
   // caller attempted to create (such as file or directory) is already present.
-  Status::AlreadyExists()
+  pw::Status::AlreadyExists()
 
   // PermissionDenied (gRPC code "PERMISSION_DENIED") indicates that the caller
   // does not have permission to execute the specified operation. Note that this
@@ -79,12 +84,12 @@
   // some resource. Instead, use `ResourceExhausted` for those errors.
   // `PermissionDenied` must not be used if the caller cannot be identified.
   // Instead, use `Unauthenticated` for those errors.
-  Status::PermissionDenied()
+  pw::Status::PermissionDenied()
 
   // ResourceExhausted (gRPC code "RESOURCE_EXHAUSTED") indicates some resource
   // has been exhausted, perhaps a per-user quota, or perhaps the entire file
   // system is out of space.
-  Status::ResourceExhausted()
+  pw::Status::ResourceExhausted()
 
   // FailedPrecondition (gRPC code "FAILED_PRECONDITION") indicates that the
   // operation was rejected because the system is not in a state required for
@@ -103,7 +108,7 @@
   //      fails because the directory is non-empty, `FailedPrecondition`
   //      should be returned since the client should not retry unless
   //      the files are deleted from the directory.
-  Status::FailedPrecondition()
+  pw::Status::FailedPrecondition()
 
   // Aborted (gRPC code "ABORTED") indicates the operation was aborted,
   // typically due to a concurrency issue such as a sequencer check failure or a
@@ -111,7 +116,7 @@
   //
   // See the guidelines above for deciding between `FailedPrecondition`,
   // `Aborted`, and `Unavailable`.
-  Status::Aborted()
+  pw::Status::Aborted()
 
   // OutOfRange (gRPC code "OUT_OF_RANGE") indicates the operation was
   // attempted past the valid range, such as seeking or reading past an
@@ -129,17 +134,17 @@
   // error) when it applies so that callers who are iterating through
   // a space can easily look for an `OutOfRange` error to detect when
   // they are done.
-  Status::OutOfRange()
+  pw::Status::OutOfRange()
 
   // Unimplemented (gRPC code "UNIMPLEMENTED") indicates the operation is not
   // implemented or supported in this service. In this case, the operation
   // should not be re-attempted.
-  Status::Unimplemented()
+  pw::Status::Unimplemented()
 
   // Internal (gRPC code "INTERNAL") indicates an internal error has occurred
   // and some invariants expected by the underlying system have not been
   // satisfied. This error code is reserved for serious errors.
-  Status::Internal()
+  pw::Status::Internal()
 
   // Unavailable (gRPC code "UNAVAILABLE") indicates the service is currently
   // unavailable and that this is most likely a transient condition. An error
@@ -148,17 +153,17 @@
   //
   // See the guidelines above for deciding between `FailedPrecondition`,
   // `Aborted`, and `Unavailable`.
-  Status::Unavailable()
+  pw::Status::Unavailable()
 
   // DataLoss (gRPC code "DATA_LOSS") indicates that unrecoverable data loss or
   // corruption has occurred. As this error is serious, proper alerting should
   // be attached to errors such as this.
-  Status::DataLoss()
+  pw::Status::DataLoss()
 
   // Unauthenticated (gRPC code "UNAUTHENTICATED") indicates that the request
   // does not have valid authentication credentials for the operation. Correct
   // the authentication and try again.
-  Status::Unauthenticated()
+  pw::Status::Unauthenticated()
 
 .. attention::
 
@@ -199,7 +204,7 @@
   .. code-block:: cpp
 
     // An OK StatusWithSize with a size of 123.
-    StatusWithSize::Ok(123)
+    StatusWithSize(123)
 
     // A NOT_FOUND StatusWithSize with a size of 0.
     StatusWithSize::NotFound()
diff --git a/pw_status/public/pw_status/status.h b/pw_status/public/pw_status/status.h
index 2842797..ae3b852 100644
--- a/pw_status/public/pw_status/status.h
+++ b/pw_status/public/pw_status/status.h
@@ -32,7 +32,7 @@
   // success. It is typical to check for this value before proceeding on any
   // given call across an API or RPC boundary. To check this value, use the
   // `Status::ok()` member function rather than inspecting the raw code.
-  PW_STATUS_OK = 0,  // Use Status::Ok() in C++
+  PW_STATUS_OK = 0,  // Use OkStatus() in C++
 
   // Cancelled (gRPC code "CANCELLED") indicates the operation was cancelled,
   // typically by the caller.
@@ -180,77 +180,17 @@
 
 }  // extern "C"
 
-// This header violates the Pigweed style guide! It declares constants that use
-// macro naming style, rather than constant naming style (kConstant). This is
-// done for readability and for consistency with Google's standard status codes
-// (e.g. as in gRPC).
-//
-// The problem is that the status code names might overlap with macro
-// definitions. To workaround this, this header undefines any macros with these
-// names.
-//
-// If your project relies on a macro with one of these names (e.g. INTERNAL),
-// make sure it is included after status.h so that the macro is defined.
-//
-// TODO(pwbug/268): Remove these #undefs after removing the names that violate
-//     the style guide.
-#undef OK
-#undef CANCELLED
-#undef UNKNOWN
-#undef INVALID_ARGUMENT
-#undef DEADLINE_EXCEEDED
-#undef NOT_FOUND
-#undef ALREADY_EXISTS
-#undef PERMISSION_DENIED
-#undef UNAUTHENTICATED
-#undef RESOURCE_EXHAUSTED
-#undef FAILED_PRECONDITION
-#undef ABORTED
-#undef OUT_OF_RANGE
-#undef UNIMPLEMENTED
-#undef INTERNAL
-#undef UNAVAILABLE
-#undef DATA_LOSS
-
 namespace pw {
 
 // The Status class is a thin, zero-cost abstraction around the pw_Status enum.
-// It initializes to Status::Ok() by default and adds ok() and str() methods.
+// It initializes to OkStatus() by default and adds ok() and str() methods.
 // Implicit conversions are permitted between pw_Status and pw::Status.
 class Status {
  public:
   using Code = pw_Status;
 
-  // All of the pw_Status codes are available in the Status class as, e.g.
-  // pw::Status::Ok() or pw::Status::OutOfRange().
-  //
-  // These aliases are DEPRECATED -- prefer using the helper functions below.
-  // For example, change Status::CANCELLED to Status::Cancelled().
-  //
-  // TODO(pwbug/268): Migrate to the helper functions and remove these aliases.
-  static constexpr Code OK = PW_STATUS_OK;
-  static constexpr Code CANCELLED = PW_STATUS_CANCELLED;
-  static constexpr Code UNKNOWN = PW_STATUS_UNKNOWN;
-  static constexpr Code INVALID_ARGUMENT = PW_STATUS_INVALID_ARGUMENT;
-  static constexpr Code DEADLINE_EXCEEDED = PW_STATUS_DEADLINE_EXCEEDED;
-  static constexpr Code NOT_FOUND = PW_STATUS_NOT_FOUND;
-  static constexpr Code ALREADY_EXISTS = PW_STATUS_ALREADY_EXISTS;
-  static constexpr Code PERMISSION_DENIED = PW_STATUS_PERMISSION_DENIED;
-  static constexpr Code UNAUTHENTICATED = PW_STATUS_UNAUTHENTICATED;
-  static constexpr Code RESOURCE_EXHAUSTED = PW_STATUS_RESOURCE_EXHAUSTED;
-  static constexpr Code FAILED_PRECONDITION = PW_STATUS_FAILED_PRECONDITION;
-  static constexpr Code ABORTED = PW_STATUS_ABORTED;
-  static constexpr Code OUT_OF_RANGE = PW_STATUS_OUT_OF_RANGE;
-  static constexpr Code UNIMPLEMENTED = PW_STATUS_UNIMPLEMENTED;
-  static constexpr Code INTERNAL = PW_STATUS_INTERNAL;
-  static constexpr Code UNAVAILABLE = PW_STATUS_UNAVAILABLE;
-  static constexpr Code DATA_LOSS = PW_STATUS_DATA_LOSS;
-
   // Functions that create a Status with the specified code.
   // clang-format off
-  [[nodiscard]] static constexpr Status Ok() {
-    return PW_STATUS_OK;
-  }
   [[nodiscard]] static constexpr Status Cancelled() {
     return PW_STATUS_CANCELLED;
   }
@@ -310,7 +250,7 @@
   // Returns the Status::Code (pw_Status) for this Status.
   constexpr Code code() const { return code_; }
 
-  // True if the status is Status::Ok().
+  // True if the status is OK.
   [[nodiscard]] constexpr bool ok() const { return code_ == PW_STATUS_OK; }
 
   // Functions for checking which status this is.
@@ -370,6 +310,11 @@
   Code code_;
 };
 
+// Returns an OK status. Equivalent to Status() or Status(PW_STATUS_OK). This
+// function is used instead of a Status::Ok() function, which would be too
+// similar to Status::ok().
+[[nodiscard]] constexpr Status OkStatus() { return Status(); }
+
 constexpr bool operator==(const Status& lhs, const Status& rhs) {
   return lhs.code() == rhs.code();
 }
diff --git a/pw_status/public/pw_status/status_with_size.h b/pw_status/public/pw_status/status_with_size.h
index 751ace8..48b1496 100644
--- a/pw_status/public/pw_status/status_with_size.h
+++ b/pw_status/public/pw_status/status_with_size.h
@@ -20,24 +20,6 @@
 
 namespace pw {
 
-class StatusWithSize;
-
-namespace internal {
-
-// TODO(pwbug/268): Remove this class after migrating to the helper functions.
-template <int kStatusShift>
-class StatusWithSizeConstant {
- private:
-  friend class ::pw::StatusWithSize;
-
-  explicit constexpr StatusWithSizeConstant(Status value)
-      : value_(static_cast<size_t>(value.code()) << kStatusShift) {}
-
-  const size_t value_;
-};
-
-}  // namespace internal
-
 // StatusWithSize stores a status and an unsigned integer. The integer must not
 // exceed StatusWithSize::max_size(), which is 134,217,727 (2**27 - 1) on 32-bit
 // systems.
@@ -64,45 +46,7 @@
 //      increases code size.
 //
 class StatusWithSize {
- private:
-  static constexpr size_t kStatusBits = 5;
-  static constexpr size_t kSizeMask = ~static_cast<size_t>(0) >> kStatusBits;
-  static constexpr size_t kStatusMask = ~kSizeMask;
-  static constexpr size_t kStatusShift = sizeof(size_t) * 8 - kStatusBits;
-
-  using Constant = internal::StatusWithSizeConstant<kStatusShift>;
-
  public:
-  // Non-OK StatusWithSizes can be constructed from these constants, such as:
-  //
-  //   StatusWithSize result = StatusWithSize::NotFound();
-  //
-  // These constants are DEPRECATED! Use the helper functions below instead. For
-  // example, change StatusWithSize::NotFound() to StatusWithSize::NotFound().
-  //
-  // TODO(pwbug/268): Migrate to the functions and remove these constants.
-  static constexpr Constant CANCELLED{Status::Cancelled()};
-  static constexpr Constant UNKNOWN{Status::Unknown()};
-  static constexpr Constant INVALID_ARGUMENT{Status::InvalidArgument()};
-  static constexpr Constant DEADLINE_EXCEEDED{Status::DeadlineExceeded()};
-  static constexpr Constant NOT_FOUND{Status::NotFound()};
-  static constexpr Constant ALREADY_EXISTS{Status::AlreadyExists()};
-  static constexpr Constant PERMISSION_DENIED{Status::PermissionDenied()};
-  static constexpr Constant RESOURCE_EXHAUSTED{Status::ResourceExhausted()};
-  static constexpr Constant FAILED_PRECONDITION{Status::FailedPrecondition()};
-  static constexpr Constant ABORTED{Status::Aborted()};
-  static constexpr Constant OUT_OF_RANGE{Status::OutOfRange()};
-  static constexpr Constant UNIMPLEMENTED{Status::Unimplemented()};
-  static constexpr Constant INTERNAL{Status::Internal()};
-  static constexpr Constant UNAVAILABLE{Status::Unavailable()};
-  static constexpr Constant DATA_LOSS{Status::DataLoss()};
-  static constexpr Constant UNAUTHENTICATED{Status::Unauthenticated()};
-
-  // Functions that create a StatusWithSize with the specified status code. For
-  // codes other than OK, the size defaults to 0.
-  static constexpr StatusWithSize Ok(size_t size) {
-    return StatusWithSize(size);
-  }
   static constexpr StatusWithSize Cancelled(size_t size = 0) {
     return StatusWithSize(Status::Cancelled(), size);
   }
@@ -152,13 +96,13 @@
     return StatusWithSize(Status::DataLoss(), size);
   }
 
-  // Creates a StatusWithSize with Status::Ok() and a size of 0.
+  // Creates a StatusWithSize with OkStatus() and a size of 0.
   explicit constexpr StatusWithSize() : size_(0) {}
 
-  // Creates a StatusWithSize with Status::Ok() and the provided size.
+  // Creates a StatusWithSize with status OK and the provided size.
   // std::enable_if is used to prevent enum types (e.g. Status) from being used.
   // TODO(hepler): Add debug-only assert that size <= max_size().
-  template <typename T, typename = std::enable_if_t<std::is_integral_v<T>>>
+  template <typename T, typename = std::enable_if_t<std::is_integral<T>::value>>
   explicit constexpr StatusWithSize(T size) : size_(size) {}
 
   // Creates a StatusWithSize with the provided status and size.
@@ -166,9 +110,6 @@
       : StatusWithSize((static_cast<size_t>(status.code()) << kStatusShift) |
                        size) {}
 
-  // Allow implicit conversions from the StatusWithSize constants.
-  constexpr StatusWithSize(Constant constant) : size_(constant.value_) {}
-
   constexpr StatusWithSize(const StatusWithSize&) = default;
   constexpr StatusWithSize& operator=(const StatusWithSize&) = default;
 
@@ -178,7 +119,7 @@
   // The maximum valid value for size.
   static constexpr size_t max_size() { return kSizeMask; }
 
-  // True if status() == Status::Ok().
+  // True if status() == OkStatus().
   constexpr bool ok() const { return (size_ & kStatusMask) == 0u; }
 
   constexpr Status status() const {
@@ -236,6 +177,11 @@
   }
 
  private:
+  static constexpr size_t kStatusBits = 5;
+  static constexpr size_t kSizeMask = ~static_cast<size_t>(0) >> kStatusBits;
+  static constexpr size_t kStatusMask = ~kSizeMask;
+  static constexpr size_t kStatusShift = sizeof(size_t) * 8 - kStatusBits;
+
   size_t size_;
 };
 
diff --git a/pw_status/py/BUILD.gn b/pw_status/py/BUILD.gn
index d6d12c9..d7ee453 100644
--- a/pw_status/py/BUILD.gn
+++ b/pw_status/py/BUILD.gn
@@ -18,8 +18,6 @@
 
 pw_python_package("py") {
   setup = [ "setup.py" ]
-  sources = [
-    "pw_status/__init__.py",
-    "pw_status/update_style.py",
-  ]
+  sources = [ "pw_status/__init__.py" ]
+  pylintrc = "$dir_pigweed/.pylintrc"
 }
diff --git a/pw_status/py/pw_status/update_style.py b/pw_status/py/pw_status/update_style.py
deleted file mode 100755
index 4e67941..0000000
--- a/pw_status/py/pw_status/update_style.py
+++ /dev/null
@@ -1,121 +0,0 @@
-#!/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.
-"""Updates pw::Status usages from Status::CODE to Status::Code() style.
-
-Also updates StatusWithSize.
-"""
-
-import argparse
-from pathlib import Path
-import re
-import sys
-from typing import Iterable
-
-from pw_presubmit import git_repo
-
-_REMAP = {
-    'OK': 'Ok',
-    'CANCELLED': 'Cancelled',
-    'UNKNOWN': 'Unknown',
-    'INVALID_ARGUMENT': 'InvalidArgument',
-    'DEADLINE_EXCEEDED': 'DeadlineExceeded',
-    'NOT_FOUND': 'NotFound',
-    'ALREADY_EXISTS': 'AlreadyExists',
-    'PERMISSION_DENIED': 'PermissionDenied',
-    'UNAUTHENTICATED': 'Unauthenticated',
-    'RESOURCE_EXHAUSTED': 'ResourceExhausted',
-    'FAILED_PRECONDITION': 'FailedPrecondition',
-    'ABORTED': 'Aborted',
-    'OUT_OF_RANGE': 'OutOfRange',
-    'UNIMPLEMENTED': 'Unimplemented',
-    'INTERNAL': 'Internal',
-    'UNAVAILABLE': 'Unavailable',
-    'DATA_LOSS': 'DataLoss',
-}
-
-_CODES = '|'.join(_REMAP.keys())
-_FUNCTIONS = '|'.join(_REMAP.values())
-
-_STATUS_WITH_SIZE_CTOR = re.compile(
-    fr'\bStatusWithSize\(Status::({_CODES}),\s*'.encode())
-_STATUS = re.compile(fr'\b(Status|StatusWithSize)::({_CODES})(?!")\b'.encode())
-_STATUS_EQUALITY = re.compile(
-    fr'Status::(?P<l_func>{_FUNCTIONS})\(\)\s+==\s+(?P<value>[a-zA-Z0-9_.()]+)|'
-    fr'\s+==\s+(?:pw::)?Status::(?P<r_func>{_FUNCTIONS})\(\)'.encode())
-
-
-def _remap_status_with_size(match) -> bytes:
-    return f'StatusWithSize::{_REMAP[match.group(1).decode()]}('.encode()
-
-
-def _remap_codes(match) -> bytes:
-    status, code = (g.decode() for g in match.groups())
-    return f'{status}::{_REMAP[code]}()'.encode()
-
-
-def _remap_equality(match) -> bytes:
-    l_func, status, r_func = (g.decode() for g in match.groups(b''))
-    func = l_func or r_func
-    return (f'{status}.ok()'
-            if func == 'Ok' else f'{status}.Is{func}()').encode()
-
-
-def _parse_args():
-    """Parses and return command line arguments."""
-
-    parser = argparse.ArgumentParser(description=__doc__)
-    parser.add_argument('paths',
-                        nargs='*',
-                        type=Path,
-                        help='Paths to repositories')
-    return parser.parse_args()
-
-
-def update_status(paths: Iterable[Path]) -> None:
-    if not paths:
-        paths = [Path.cwd()]
-
-    for path in paths:
-        if git_repo.has_uncommitted_changes(path):
-            raise RuntimeError('There are pending changes in the Git repo!')
-
-        updated = 0
-
-        for file in git_repo.list_files(pathspecs=('*.h', '*.cc', '*.cpp'),
-                                        repo_path=path):
-            orig = file.read_bytes()
-
-            # Replace StatusWithSize constructor
-            text = _STATUS_WITH_SIZE_CTOR.sub(_remap_status_with_size, orig)
-
-            # Replace Status and StatusWithSize
-            text = _STATUS.sub(_remap_codes, text)
-
-            text = _STATUS_EQUALITY.sub(_remap_equality, text)
-
-            if orig != text:
-                updated += 1
-                file.write_bytes(text)
-
-    print('Updated', updated, 'files.')
-    print('Manually inspect the changes! This script is not perfect.')
-
-
-def main():
-    return update_status(**vars(_parse_args()))
-
-
-if __name__ == '__main__':
-    sys.exit(main())
diff --git a/pw_status/status_test.cc b/pw_status/status_test.cc
index 4812b9d..0bf72f5 100644
--- a/pw_status/status_test.cc
+++ b/pw_status/status_test.cc
@@ -12,26 +12,6 @@
 // License for the specific language governing permissions and limitations under
 // the License.
 
-// Make sure status works if these macros are defined.
-// TODO(pwbug/268): Remove these macros after migrating from these aliases.
-#define OK Uh oh, this macro is defined !
-#define CANCELLED Uh oh, this macro is defined !
-#define UNKNOWN Uh oh, this macro is defined !
-#define INVALID_ARGUMENT Uh oh, this macro is defined !
-#define DEADLINE_EXCEEDED Uh oh, this macro is defined !
-#define NOT_FOUND Uh oh, this macro is defined !
-#define ALREADY_EXISTS Uh oh, this macro is defined !
-#define PERMISSION_DENIED Uh oh, this macro is defined !
-#define UNAUTHENTICATED Uh oh, this macro is defined !
-#define RESOURCE_EXHAUSTED Uh oh, this macro is defined !
-#define FAILED_PRECONDITION Uh oh, this macro is defined !
-#define ABORTED Uh oh, this macro is defined !
-#define OUT_OF_RANGE Uh oh, this macro is defined !
-#define UNIMPLEMENTED Uh oh, this macro is defined !
-#define INTERNAL Uh oh, this macro is defined !
-#define UNAVAILABLE Uh oh, this macro is defined !
-#define DATA_LOSS Uh oh, this macro is defined !
-
 #include "pw_status/status.h"
 
 #include "gtest/gtest.h"
@@ -49,7 +29,7 @@
 
 TEST(Status, ConstructWithStatusCode) {
   constexpr Status status(PW_STATUS_ABORTED);
-  static_assert(Status::Aborted() == status);
+  static_assert(status.IsAborted());
 }
 
 TEST(Status, AssignFromStatusCode) {
@@ -61,7 +41,7 @@
 TEST(Status, Ok_OkIsTrue) {
   static_assert(Status().ok());
   static_assert(Status(PW_STATUS_OK).ok());
-  static_assert(Status::Ok().ok());
+  static_assert(OkStatus().ok());
 }
 
 TEST(Status, NotOk_OkIsFalse) {
@@ -72,7 +52,7 @@
 TEST(Status, Code) {
   // clang-format off
   static_assert(PW_STATUS_OK == Status().code());
-  static_assert(PW_STATUS_OK == Status::Ok().code());
+  static_assert(PW_STATUS_OK == OkStatus().code());
   static_assert(PW_STATUS_CANCELLED == Status::Cancelled().code());
   static_assert(PW_STATUS_UNKNOWN == Status::Unknown().code());
   static_assert(PW_STATUS_INVALID_ARGUMENT == Status::InvalidArgument().code());
@@ -94,7 +74,7 @@
 
 TEST(Status, EqualCodes) {
   static_assert(PW_STATUS_OK == Status());
-  static_assert(PW_STATUS_OK == Status::Ok());
+  static_assert(PW_STATUS_OK == OkStatus());
   static_assert(PW_STATUS_CANCELLED == Status::Cancelled());
   static_assert(PW_STATUS_UNKNOWN == Status::Unknown());
   static_assert(PW_STATUS_INVALID_ARGUMENT == Status::InvalidArgument());
@@ -133,27 +113,27 @@
 }
 
 TEST(Status, IsNotError) {
-  static_assert(!Status::Ok().IsCancelled());
-  static_assert(!Status::Ok().IsUnknown());
-  static_assert(!Status::Ok().IsInvalidArgument());
-  static_assert(!Status::Ok().IsDeadlineExceeded());
-  static_assert(!Status::Ok().IsNotFound());
-  static_assert(!Status::Ok().IsAlreadyExists());
-  static_assert(!Status::Ok().IsPermissionDenied());
-  static_assert(!Status::Ok().IsUnauthenticated());
-  static_assert(!Status::Ok().IsResourceExhausted());
-  static_assert(!Status::Ok().IsFailedPrecondition());
-  static_assert(!Status::Ok().IsAborted());
-  static_assert(!Status::Ok().IsOutOfRange());
-  static_assert(!Status::Ok().IsUnimplemented());
-  static_assert(!Status::Ok().IsInternal());
-  static_assert(!Status::Ok().IsUnavailable());
-  static_assert(!Status::Ok().IsDataLoss());
+  static_assert(!OkStatus().IsCancelled());
+  static_assert(!OkStatus().IsUnknown());
+  static_assert(!OkStatus().IsInvalidArgument());
+  static_assert(!OkStatus().IsDeadlineExceeded());
+  static_assert(!OkStatus().IsNotFound());
+  static_assert(!OkStatus().IsAlreadyExists());
+  static_assert(!OkStatus().IsPermissionDenied());
+  static_assert(!OkStatus().IsUnauthenticated());
+  static_assert(!OkStatus().IsResourceExhausted());
+  static_assert(!OkStatus().IsFailedPrecondition());
+  static_assert(!OkStatus().IsAborted());
+  static_assert(!OkStatus().IsOutOfRange());
+  static_assert(!OkStatus().IsUnimplemented());
+  static_assert(!OkStatus().IsInternal());
+  static_assert(!OkStatus().IsUnavailable());
+  static_assert(!OkStatus().IsDataLoss());
 }
 
 TEST(Status, Strings) {
   EXPECT_STREQ("OK", Status().str());
-  EXPECT_STREQ("OK", Status::Ok().str());
+  EXPECT_STREQ("OK", OkStatus().str());
   EXPECT_STREQ("CANCELLED", Status::Cancelled().str());
   EXPECT_STREQ("UNKNOWN", Status::Unknown().str());
   EXPECT_STREQ("INVALID_ARGUMENT", Status::InvalidArgument().str());
@@ -176,27 +156,6 @@
   EXPECT_STREQ("INVALID STATUS", Status(kInvalidCode).str());
 }
 
-TEST(Status, DeprecatedAliases) {
-  // TODO(pwbug/268): Remove this test after migrating from these aliases.
-  static_assert(PW_STATUS_OK == Status::OK);
-  static_assert(PW_STATUS_CANCELLED == Status::CANCELLED);
-  static_assert(PW_STATUS_UNKNOWN == Status::UNKNOWN);
-  static_assert(PW_STATUS_INVALID_ARGUMENT == Status::INVALID_ARGUMENT);
-  static_assert(PW_STATUS_DEADLINE_EXCEEDED == Status::DEADLINE_EXCEEDED);
-  static_assert(PW_STATUS_NOT_FOUND == Status::NOT_FOUND);
-  static_assert(PW_STATUS_ALREADY_EXISTS == Status::ALREADY_EXISTS);
-  static_assert(PW_STATUS_PERMISSION_DENIED == Status::PERMISSION_DENIED);
-  static_assert(PW_STATUS_RESOURCE_EXHAUSTED == Status::RESOURCE_EXHAUSTED);
-  static_assert(PW_STATUS_FAILED_PRECONDITION == Status::FAILED_PRECONDITION);
-  static_assert(PW_STATUS_ABORTED == Status::ABORTED);
-  static_assert(PW_STATUS_OUT_OF_RANGE == Status::OUT_OF_RANGE);
-  static_assert(PW_STATUS_UNIMPLEMENTED == Status::UNIMPLEMENTED);
-  static_assert(PW_STATUS_INTERNAL == Status::INTERNAL);
-  static_assert(PW_STATUS_UNAVAILABLE == Status::UNAVAILABLE);
-  static_assert(PW_STATUS_DATA_LOSS == Status::DATA_LOSS);
-  static_assert(PW_STATUS_UNAUTHENTICATED == Status::UNAUTHENTICATED);
-}
-
 // Functions for executing the C pw_Status tests.
 extern "C" {
 
@@ -215,7 +174,7 @@
   EXPECT_EQ(Status::Unknown(), PassStatusFromC(Status::Unknown()));
 
   EXPECT_EQ(Status::NotFound(), PassStatusFromC(PW_STATUS_NOT_FOUND));
-  EXPECT_EQ(Status::Ok(), PassStatusFromC(Status::Ok()));
+  EXPECT_EQ(OkStatus(), PassStatusFromC(OkStatus()));
 }
 
 TEST(StatusCLinkage, TestStatusFromC) { EXPECT_EQ(0, TestStatusFromC()); }
diff --git a/pw_status/status_test_c.c b/pw_status/status_test_c.c
index d4f551d..6a31cae 100644
--- a/pw_status/status_test_c.c
+++ b/pw_status/status_test_c.c
@@ -23,7 +23,7 @@
 #define CHECK_STATUS_FROM_CPP(status) \
   (PW_STATUS_##status != PassStatusFromCpp(PW_STATUS_##status))
 
-int TestStatusFromC() {
+int TestStatusFromC(void) {
   int errors = 0;
 
   errors += CHECK_STATUS_FROM_CPP(OK);
@@ -52,7 +52,7 @@
 #define CHECK_STATUS_STRING(status) \
   (strcmp(#status, pw_StatusString(PW_STATUS_##status)) != 0)
 
-int TestStatusStringsFromC() {
+int TestStatusStringsFromC(void) {
   int errors = 0;
 
   errors += CHECK_STATUS_STRING(OK);
diff --git a/pw_status/status_with_size_test.cc b/pw_status/status_with_size_test.cc
index ef0f1cf..44d6c5a 100644
--- a/pw_status/status_with_size_test.cc
+++ b/pw_status/status_with_size_test.cc
@@ -26,14 +26,14 @@
 TEST(StatusWithSize, Default) {
   StatusWithSize result;
   EXPECT_TRUE(result.ok());
-  EXPECT_EQ(Status::Ok(), result.status());
+  EXPECT_EQ(Status(), result.status());
   EXPECT_EQ(0u, result.size());
 }
 
 TEST(StatusWithSize, ConstructWithSize) {
   StatusWithSize result = StatusWithSize(456);
   EXPECT_TRUE(result.ok());
-  EXPECT_EQ(Status::Ok(), result.status());
+  EXPECT_EQ(Status(), result.status());
   EXPECT_EQ(456u, result.size());
 }
 
@@ -45,9 +45,9 @@
 }
 
 TEST(StatusWithSize, ConstructWithOkAndSize) {
-  StatusWithSize result(Status::Ok(), 99);
+  StatusWithSize result(Status(), 99);
   EXPECT_TRUE(result.ok());
-  EXPECT_EQ(Status::Ok(), result.status());
+  EXPECT_EQ(Status(), result.status());
   EXPECT_EQ(99u, result.size());
 }
 
@@ -99,7 +99,7 @@
 
   result = StatusWithSize(300);
   EXPECT_TRUE(result.ok());
-  EXPECT_EQ(Status::Ok(), result.status());
+  EXPECT_EQ(Status(), result.status());
   EXPECT_EQ(300u, result.size());
 }
 
@@ -112,7 +112,7 @@
 
 TEST(StatusWithSize, Functions_Status) {
   // clang-format off
-  static_assert(StatusWithSize::Ok(0).status() == Status::Ok());
+  static_assert(StatusWithSize(0).status() == Status());
   static_assert(StatusWithSize::Cancelled().status() == Status::Cancelled());
   static_assert(StatusWithSize::Unknown().status() == Status::Unknown());
   static_assert(StatusWithSize::InvalidArgument().status() == Status::InvalidArgument());
@@ -130,7 +130,7 @@
   static_assert(StatusWithSize::Unavailable().status() == Status::Unavailable());
   static_assert(StatusWithSize::DataLoss().status() == Status::DataLoss());
 
-  static_assert(StatusWithSize::Ok(123).status() == Status::Ok());
+  static_assert(StatusWithSize(123).status() == Status());
   static_assert(StatusWithSize::Cancelled(123).status() == Status::Cancelled());
   static_assert(StatusWithSize::Unknown(123).status() == Status::Unknown());
   static_assert(StatusWithSize::InvalidArgument(123).status() == Status::InvalidArgument());
@@ -170,7 +170,7 @@
 }
 
 TEST(StatusWithSize, Functions_SpecifiedSize) {
-  static_assert(StatusWithSize::Ok(123).size() == 123u);
+  static_assert(StatusWithSize(123).size() == 123u);
   static_assert(StatusWithSize::Cancelled(123).size() == 123u);
   static_assert(StatusWithSize::Unknown(123).size() == 123u);
   static_assert(StatusWithSize::InvalidArgument(123).size() == 123u);
@@ -209,22 +209,22 @@
 }
 
 TEST(StatusWithSize, IsNotError) {
-  static_assert(!StatusWithSize::Ok(0).IsCancelled());
-  static_assert(!StatusWithSize::Ok(0).IsUnknown());
-  static_assert(!StatusWithSize::Ok(0).IsInvalidArgument());
-  static_assert(!StatusWithSize::Ok(0).IsDeadlineExceeded());
-  static_assert(!StatusWithSize::Ok(0).IsNotFound());
-  static_assert(!StatusWithSize::Ok(0).IsAlreadyExists());
-  static_assert(!StatusWithSize::Ok(0).IsPermissionDenied());
-  static_assert(!StatusWithSize::Ok(0).IsUnauthenticated());
-  static_assert(!StatusWithSize::Ok(0).IsResourceExhausted());
-  static_assert(!StatusWithSize::Ok(0).IsFailedPrecondition());
-  static_assert(!StatusWithSize::Ok(0).IsAborted());
-  static_assert(!StatusWithSize::Ok(0).IsOutOfRange());
-  static_assert(!StatusWithSize::Ok(0).IsUnimplemented());
-  static_assert(!StatusWithSize::Ok(0).IsInternal());
-  static_assert(!StatusWithSize::Ok(0).IsUnavailable());
-  static_assert(!StatusWithSize::Ok(0).IsDataLoss());
+  static_assert(!StatusWithSize(0).IsCancelled());
+  static_assert(!StatusWithSize(0).IsUnknown());
+  static_assert(!StatusWithSize(0).IsInvalidArgument());
+  static_assert(!StatusWithSize(0).IsDeadlineExceeded());
+  static_assert(!StatusWithSize(0).IsNotFound());
+  static_assert(!StatusWithSize(0).IsAlreadyExists());
+  static_assert(!StatusWithSize(0).IsPermissionDenied());
+  static_assert(!StatusWithSize(0).IsUnauthenticated());
+  static_assert(!StatusWithSize(0).IsResourceExhausted());
+  static_assert(!StatusWithSize(0).IsFailedPrecondition());
+  static_assert(!StatusWithSize(0).IsAborted());
+  static_assert(!StatusWithSize(0).IsOutOfRange());
+  static_assert(!StatusWithSize(0).IsUnimplemented());
+  static_assert(!StatusWithSize(0).IsInternal());
+  static_assert(!StatusWithSize(0).IsUnavailable());
+  static_assert(!StatusWithSize(0).IsDataLoss());
 }
 }  // namespace
 }  // namespace pw
diff --git a/pw_status/try_test.cc b/pw_status/try_test.cc
index b84258b..a69cd95 100644
--- a/pw_status/try_test.cc
+++ b/pw_status/try_test.cc
@@ -26,7 +26,7 @@
   PW_TRY(ReturnStatus(status));
 
   // Any status other than OK should have already returned.
-  EXPECT_EQ(status, Status::Ok());
+  EXPECT_EQ(status, OkStatus());
   return status;
 }
 
@@ -34,12 +34,12 @@
   PW_TRY(ReturnStatusWithSize(status));
 
   // Any status other than OK should have already returned.
-  EXPECT_EQ(status.status(), Status::Ok());
+  EXPECT_EQ(status.status(), OkStatus());
   return status.status();
 }
 
 TEST(Status, Try_Status) {
-  EXPECT_EQ(TryStatus(Status::Ok()), Status::Ok());
+  EXPECT_EQ(TryStatus(OkStatus()), OkStatus());
 
   // Don't need all the status types, just pick a few not-ok ones.
   EXPECT_EQ(TryStatus(Status::Cancelled()), Status::Cancelled());
@@ -49,8 +49,8 @@
 
 TEST(Status, Try_StatusWithSizeOk) {
   for (size_t i = 0; i < 32; ++i) {
-    StatusWithSize val(Status::Ok(), 0);
-    EXPECT_EQ(TryStatus(val), Status::Ok());
+    StatusWithSize val(OkStatus(), 0);
+    EXPECT_EQ(TryStatus(val), OkStatus());
   }
 }
 
@@ -73,7 +73,7 @@
   PW_TRY_ASSIGN(size_val, ReturnStatusWithSize(status));
 
   // Any status other than OK should have already returned.
-  EXPECT_EQ(status.status(), Status::Ok());
+  EXPECT_EQ(status.status(), OkStatus());
   EXPECT_EQ(size_val, status.size());
   return status.status();
 }
@@ -82,8 +82,8 @@
   size_t size_val = 0;
 
   for (size_t i = 1; i < 32; ++i) {
-    StatusWithSize val(Status::Ok(), i);
-    EXPECT_EQ(TryStatusAssign(size_val, val), Status::Ok());
+    StatusWithSize val(OkStatus(), i);
+    EXPECT_EQ(TryStatusAssign(size_val, val), OkStatus());
     EXPECT_EQ(size_val, i);
   }
 }
@@ -110,15 +110,15 @@
   PW_TRY_WITH_SIZE(ReturnStatus(status));
 
   // Any status other than OK should have already returned.
-  EXPECT_EQ(status, Status::Ok());
+  EXPECT_EQ(status, OkStatus());
 
   StatusWithSize return_val(status, 0u);
   return return_val;
 }
 
 TEST(Status, TryWithSize_StatusOk) {
-  StatusWithSize result = TryStatusWithSize(Status::Ok());
-  EXPECT_EQ(result.status(), Status::Ok());
+  StatusWithSize result = TryStatusWithSize(OkStatus());
+  EXPECT_EQ(result.status(), OkStatus());
   EXPECT_EQ(result.size(), 0u);
 }
 
@@ -130,8 +130,8 @@
 
 TEST(Status, TryWithSize_StatusWithSizeOk) {
   for (size_t i = 0; i < 32; ++i) {
-    StatusWithSize val(Status::Ok(), i);
-    EXPECT_EQ(TryStatusWithSize(val).status(), Status::Ok());
+    StatusWithSize val(OkStatus(), i);
+    EXPECT_EQ(TryStatusWithSize(val).status(), OkStatus());
     EXPECT_EQ(TryStatusWithSize(val).size(), i);
   }
 }
diff --git a/pw_stream/BUILD b/pw_stream/BUILD
index a28ebae..996c044 100644
--- a/pw_stream/BUILD
+++ b/pw_stream/BUILD
@@ -24,15 +24,14 @@
 
 pw_cc_library(
     name = "pw_stream",
-    hdrs = [
-      "public/pw_stream/buffered_stream.h",
-      "public/pw_stream/memory_stream.h",
-      "public/pw_stream/null_stream.h",
-      "public/pw_stream/stream.h",
-    ],
     srcs = [
-      "memory_stream.cc",
-      "buffered_stream.cc",
+        "buffered_stream.cc",
+        "memory_stream.cc",
+    ],
+    hdrs = [
+        "public/pw_stream/memory_stream.h",
+        "public/pw_stream/null_stream.h",
+        "public/pw_stream/stream.h",
     ],
     includes = ["public"],
     deps = [
@@ -44,6 +43,25 @@
     ],
 )
 
+pw_cc_library(
+    name = "pw_stream_socket",
+    srcs = ["socket_stream.cc"],
+    hdrs = ["public/pw_stream/socket_stream.h"],
+    deps = [
+        "//pw_stream",
+        "//pw_sys_io",
+    ],
+)
+
+pw_cc_library(
+    name = "pw_stream_sys_io",
+    hdrs = ["public/pw_stream/sys_io_stream.h"],
+    deps = [
+        "//pw_stream",
+        "//pw_sys_io",
+    ],
+)
+
 pw_cc_test(
     name = "memory_stream_test",
     srcs = [
@@ -56,9 +74,9 @@
 )
 
 pw_cc_test(
-    name = "buffered_stream_test",
+    name = "stream_test",
     srcs = [
-        "buffered_stream_test.cc",
+        "stream_test.cc",
     ],
     deps = [
         ":pw_stream",
diff --git a/pw_stream/BUILD.gn b/pw_stream/BUILD.gn
index cfd3583..a1607a1 100644
--- a/pw_stream/BUILD.gn
+++ b/pw_stream/BUILD.gn
@@ -18,12 +18,13 @@
 import("$dir_pw_docgen/docs.gni")
 import("$dir_pw_unit_test/test.gni")
 
-config("default_config") {
+config("public_include_path") {
   include_dirs = [ "public" ]
+  visibility = [ ":*" ]
 }
 
 pw_source_set("pw_stream") {
-  public_configs = [ ":default_config" ]
+  public_configs = [ ":public_include_path" ]
   public = [
     "public/pw_stream/memory_stream.h",
     "public/pw_stream/null_stream.h",
@@ -34,20 +35,43 @@
     dir_pw_assert,
     dir_pw_bytes,
     dir_pw_result,
-    dir_pw_span,
     dir_pw_status,
   ]
 }
 
+pw_source_set("socket_stream") {
+  public_configs = [ ":public_include_path" ]
+  public_deps = [ "$dir_pw_stream" ]
+  sources = [ "socket_stream.cc" ]
+  public = [ "public/pw_stream/socket_stream.h" ]
+}
+
+pw_source_set("sys_io_stream") {
+  public_configs = [ ":public_include_path" ]
+  public_deps = [
+    "$dir_pw_stream",
+    "$dir_pw_sys_io",
+  ]
+  public = [ "public/pw_stream/sys_io_stream.h" ]
+}
+
 pw_doc_group("docs") {
   sources = [ "docs.rst" ]
 }
 
 pw_test_group("tests") {
-  tests = [ ":memory_stream_test" ]
+  tests = [
+    ":memory_stream_test",
+    ":stream_test",
+  ]
 }
 
 pw_test("memory_stream_test") {
   sources = [ "memory_stream_test.cc" ]
   deps = [ ":pw_stream" ]
 }
+
+pw_test("stream_test") {
+  sources = [ "stream_test.cc" ]
+  deps = [ ":pw_stream" ]
+}
diff --git a/pw_stream/CMakeLists.txt b/pw_stream/CMakeLists.txt
index 27e58f6..55a9f96 100644
--- a/pw_stream/CMakeLists.txt
+++ b/pw_stream/CMakeLists.txt
@@ -14,15 +14,26 @@
 
 include($ENV{PW_ROOT}/pw_build/pigweed.cmake)
 
-pw_auto_add_simple_module(pw_stream
+pw_add_module_library(pw_stream
+  SOURCES
+    memory_stream.cc
   PUBLIC_DEPS
+    pw_assert
     pw_bytes
-    pw_containers
-    pw_log
     pw_result
     pw_span
     pw_status
+)
+
+pw_add_module_library(pw_stream.socket_stream
+  SOURCES
+    socket_stream.cc
   PRIVATE_DEPS
-    pw_assert
-    pw_string
+    pw_stream
+)
+
+pw_add_module_library(pw_stream.sys_io_stream
+  PRIVATE_DEPS
+    pw_stream
+    pw_sys_io
 )
diff --git a/pw_stream/memory_stream.cc b/pw_stream/memory_stream.cc
index 8f7e29e..0a44810 100644
--- a/pw_stream/memory_stream.cc
+++ b/pw_stream/memory_stream.cc
@@ -33,7 +33,7 @@
   std::memcpy(dest_.data() + bytes_written_, data.data(), bytes_to_write);
   bytes_written_ += bytes_to_write;
 
-  return Status::Ok();
+  return OkStatus();
 }
 
 StatusWithSize MemoryReader::DoRead(ByteSpan dest) {
diff --git a/pw_stream/memory_stream_test.cc b/pw_stream/memory_stream_test.cc
index 51c9a38..b4a1395 100644
--- a/pw_stream/memory_stream_test.cc
+++ b/pw_stream/memory_stream_test.cc
@@ -38,7 +38,7 @@
   EXPECT_EQ(memory_writer.bytes_written(), 0u);
   Status status =
       memory_writer.Write(&kExpectedStruct, sizeof(kExpectedStruct));
-  EXPECT_EQ(status, Status::Ok());
+  EXPECT_EQ(status, OkStatus());
   EXPECT_EQ(memory_writer.bytes_written(), sizeof(kExpectedStruct));
 }  // namespace
 
@@ -68,7 +68,7 @@
     for (size_t i = 0; i < sizeof(buffer); ++i) {
       buffer[i] = std::byte(counter++);
     }
-    EXPECT_EQ(memory_writer.Write(std::span(buffer)), Status::Ok());
+    EXPECT_EQ(memory_writer.Write(std::span(buffer)), OkStatus());
   }
 
   EXPECT_GT(memory_writer.ConservativeWriteLimit(), 0u);
@@ -99,7 +99,7 @@
     size_t bytes_to_write =
         std::min(sizeof(buffer), memory_writer.ConservativeWriteLimit());
     EXPECT_EQ(memory_writer.Write(std::span(buffer, bytes_to_write)),
-              Status::Ok());
+              OkStatus());
   }
 
   EXPECT_EQ(memory_writer.ConservativeWriteLimit(), 0u);
@@ -116,7 +116,7 @@
   std::byte buffer[5] = {};
 
   MemoryWriter memory_writer(memory_buffer);
-  EXPECT_EQ(memory_writer.Write(buffer, 0), Status::Ok());
+  EXPECT_EQ(memory_writer.Write(buffer, 0), OkStatus());
   EXPECT_EQ(memory_writer.bytes_written(), 0u);
 }
 
@@ -171,7 +171,7 @@
   // Read exactly the available bytes.
   EXPECT_EQ(memory_reader.ConservativeReadLimit(), dest.size());
   Result<ByteSpan> result = memory_reader.Read(dest);
-  EXPECT_EQ(result.status(), Status::Ok());
+  EXPECT_EQ(result.status(), OkStatus());
   EXPECT_EQ(result.value().size_bytes(), dest.size());
 
   ASSERT_EQ(source.size(), result.value().size_bytes());
@@ -197,7 +197,7 @@
 
   // Read exactly the available bytes.
   Result<ByteSpan> result = memory_reader.Read(dest);
-  EXPECT_EQ(result.status(), Status::Ok());
+  EXPECT_EQ(result.status(), OkStatus());
   EXPECT_EQ(result.value().size_bytes(), 0u);
   EXPECT_EQ(result.value().data(), dest.data());
 
@@ -220,7 +220,7 @@
   // Try and read double the bytes available. Use the pointer/size version of
   // the API.
   Result<ByteSpan> result = memory_reader.Read(dest.data(), dest.size());
-  EXPECT_EQ(result.status(), Status::Ok());
+  EXPECT_EQ(result.status(), OkStatus());
   EXPECT_EQ(result.value().size_bytes(), source.size());
 
   ASSERT_EQ(source.size(), result.value().size_bytes());
@@ -255,7 +255,7 @@
 
     // Try and read a chunk of bytes.
     Result<ByteSpan> result = memory_reader.Read(dest);
-    EXPECT_EQ(result.status(), Status::Ok());
+    EXPECT_EQ(result.status(), OkStatus());
     EXPECT_EQ(result.value().size_bytes(), dest.size());
     EXPECT_EQ(memory_reader.ConservativeReadLimit(),
               read_limit - result.value().size_bytes());
diff --git a/pw_stream/public/pw_stream/memory_stream.h b/pw_stream/public/pw_stream/memory_stream.h
index 299fb3a..948b1d3 100644
--- a/pw_stream/public/pw_stream/memory_stream.h
+++ b/pw_stream/public/pw_stream/memory_stream.h
@@ -48,13 +48,13 @@
   size_t bytes_written_ = 0;
 };
 
-template <size_t size_bytes>
+template <size_t kSizeBytes>
 class MemoryWriterBuffer final : public MemoryWriter {
  public:
   MemoryWriterBuffer() : MemoryWriter(buffer_) {}
 
  private:
-  std::array<std::byte, size_bytes> buffer_;
+  std::array<std::byte, kSizeBytes> buffer_;
 };
 
 class MemoryReader final : public Reader {
diff --git a/pw_stream/public/pw_stream/null_stream.h b/pw_stream/public/pw_stream/null_stream.h
index 70048dc..b3db9c5 100644
--- a/pw_stream/public/pw_stream/null_stream.h
+++ b/pw_stream/public/pw_stream/null_stream.h
@@ -25,15 +25,15 @@
 
 // Stream writer which quietly drops all of the data, similar to /dev/null.
 class NullWriter final : public Writer {
- public:
-  size_t ConservativeWriteLimit() const override {
-    // In theory this can sink as much as is addressable, however this way it is
-    // compliant with pw::StatusWithSize.
-    return StatusWithSize::max_size();
-  }
-
  private:
-  Status DoWrite(ConstByteSpan data) override { return Status::Ok(); }
+  Status DoWrite(ConstByteSpan) final { return OkStatus(); }
+};
+
+// Stream reader which never reads any bytes. Always returns OUT_OF_RANGE, which
+// indicates there is no more data to read.
+class NullReader final : public Reader {
+ private:
+  StatusWithSize DoRead(ByteSpan) final { return StatusWithSize::OutOfRange(); }
 };
 
 }  // namespace pw::stream
diff --git a/pw_stream/public/pw_stream/socket_stream.h b/pw_stream/public/pw_stream/socket_stream.h
new file mode 100644
index 0000000..1ab1161
--- /dev/null
+++ b/pw_stream/public/pw_stream/socket_stream.h
@@ -0,0 +1,60 @@
+// 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.
+#pragma once
+
+#include <arpa/inet.h>
+#include <netinet/in.h>
+#include <stdio.h>
+#include <sys/socket.h>
+#include <unistd.h>
+
+#include <array>
+#include <cstddef>
+#include <limits>
+#include <span>
+
+#include "pw_stream/stream.h"
+
+namespace pw::stream {
+
+static constexpr int kExitCode = -1;
+static constexpr int kInvalidFd = -1;
+
+class SocketStream : public Writer, public Reader {
+ public:
+  explicit SocketStream() {}
+  ~SocketStream();
+
+  // Listen to the port and return after a client is connected
+  Status Serve(uint16_t port);
+
+  // Connect to a local or remote endpoint. Host must be an IPv4 address. If
+  // host is nullptr then the locahost address is used instead.
+  Status Connect(const char* host, uint16_t port);
+
+  // Close the socket stream and release all resources
+  void Close();
+
+ private:
+  Status DoWrite(std::span<const std::byte> data) override;
+
+  StatusWithSize DoRead(ByteSpan dest) override;
+
+  uint16_t listen_port_ = 0;
+  int socket_fd_ = kInvalidFd;
+  int conn_fd_ = kInvalidFd;
+  struct sockaddr_in sockaddr_client_;
+};
+
+}  // namespace pw::stream
diff --git a/pw_stream/public/pw_stream/stream.h b/pw_stream/public/pw_stream/stream.h
index e52bb7c..2fcde9c 100644
--- a/pw_stream/public/pw_stream/stream.h
+++ b/pw_stream/public/pw_stream/stream.h
@@ -74,8 +74,13 @@
   // RESOURCE_EXHAUSTED or OUT_OF_RANGE. As Writer processes/handles enqueued of
   // other contexts write data this number can go up or down for some Writers.
   // Returns zero if, in the current state, Write() would not return
-  // Status::Ok().
-  virtual size_t ConservativeWriteLimit() const = 0;
+  // OkStatus().
+  //
+  // Returns std::numeric_limits<size_t>::max() if the implementation has no
+  // limits on write sizes.
+  virtual size_t ConservativeWriteLimit() const {
+    return std::numeric_limits<size_t>::max();
+  }
 
  private:
   virtual Status DoWrite(ConstByteSpan data) = 0;
@@ -127,8 +132,13 @@
   // processes/handles/receives enqueued data or other contexts read data this
   // number can go up or down for some Readers.
   // Returns zero if, in the current state, Read() would not return
-  // Status::Ok().
-  virtual size_t ConservativeReadLimit() const = 0;
+  // OkStatus().
+  //
+  // Returns std::numeric_limits<size_t>::max() if the implementation imposes no
+  // limits on read sizes.
+  virtual size_t ConservativeReadLimit() const {
+    return std::numeric_limits<size_t>::max();
+  }
 
  private:
   virtual StatusWithSize DoRead(ByteSpan dest) = 0;
diff --git a/pw_stream/public/pw_stream/sys_io_stream.h b/pw_stream/public/pw_stream/sys_io_stream.h
new file mode 100644
index 0000000..446f5a4
--- /dev/null
+++ b/pw_stream/public/pw_stream/sys_io_stream.h
@@ -0,0 +1,40 @@
+// 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.
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <limits>
+#include <span>
+
+#include "pw_stream/stream.h"
+#include "pw_sys_io/sys_io.h"
+
+namespace pw::stream {
+
+class SysIoWriter : public Writer {
+ private:
+  Status DoWrite(std::span<const std::byte> data) override {
+    return pw::sys_io::WriteBytes(data).status();
+  }
+};
+
+class SysIoReader : public Reader {
+ private:
+  StatusWithSize DoRead(ByteSpan dest) override {
+    return pw::sys_io::ReadBytes(dest);
+  }
+};
+
+}  // namespace pw::stream
diff --git a/pw_stream/socket_stream.cc b/pw_stream/socket_stream.cc
new file mode 100644
index 0000000..034a3fb
--- /dev/null
+++ b/pw_stream/socket_stream.cc
@@ -0,0 +1,110 @@
+// 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.
+
+#include "pw_stream/socket_stream.h"
+namespace pw::stream {
+
+static constexpr uint32_t kMaxConcurrentUser = 1;
+static constexpr char kLocalhostAddress[] = "127.0.0.1";
+
+SocketStream::~SocketStream() { Close(); }
+
+// Listen to the port and return after a client is connected
+Status SocketStream::Serve(uint16_t port) {
+  listen_port_ = port;
+  socket_fd_ = socket(AF_INET, SOCK_STREAM, 0);
+  if (socket_fd_ == kInvalidFd) {
+    return Status::Internal();
+  }
+
+  struct sockaddr_in addr;
+  addr.sin_family = AF_INET;
+  addr.sin_port = htons(listen_port_);
+  addr.sin_addr.s_addr = INADDR_ANY;
+
+  int result =
+      bind(socket_fd_, reinterpret_cast<struct sockaddr*>(&addr), sizeof(addr));
+  if (result < 0) {
+    return Status::Internal();
+  }
+
+  result = listen(socket_fd_, kMaxConcurrentUser);
+  if (result < 0) {
+    return Status::Internal();
+  }
+
+  socklen_t len = sizeof(sockaddr_client_);
+
+  conn_fd_ =
+      accept(socket_fd_, reinterpret_cast<sockaddr*>(&sockaddr_client_), &len);
+  if (conn_fd_ < 0) {
+    return Status::Internal();
+  }
+  return OkStatus();
+}
+
+Status SocketStream::SocketStream::Connect(const char* host, uint16_t port) {
+  conn_fd_ = socket(AF_INET, SOCK_STREAM, 0);
+
+  sockaddr_in addr;
+  addr.sin_family = AF_INET;
+  addr.sin_port = htons(port);
+
+  if (host == nullptr) {
+    host = kLocalhostAddress;
+  }
+
+  if (inet_pton(AF_INET, host, &addr.sin_addr) <= 0) {
+    return Status::Unknown();
+  }
+
+  int result = connect(
+      conn_fd_, reinterpret_cast<struct sockaddr*>(&addr), sizeof(addr));
+  if (result < 0) {
+    return Status::Unknown();
+  }
+
+  return OkStatus();
+}
+
+void SocketStream::Close() {
+  if (socket_fd_ != kInvalidFd) {
+    close(socket_fd_);
+    socket_fd_ = kInvalidFd;
+  }
+
+  if (conn_fd_ != kInvalidFd) {
+    close(conn_fd_);
+    conn_fd_ = kInvalidFd;
+  }
+}
+
+Status SocketStream::DoWrite(std::span<const std::byte> data) {
+  ssize_t bytes_sent = send(conn_fd_, data.data(), data.size_bytes(), 0);
+
+  if (bytes_sent < 0 || static_cast<uint64_t>(bytes_sent) != data.size()) {
+    return Status::Internal();
+  }
+  return OkStatus();
+}
+
+StatusWithSize SocketStream::DoRead(ByteSpan dest) {
+  ssize_t bytes_rcvd = recv(conn_fd_, dest.data(), dest.size_bytes(), 0);
+  if (bytes_rcvd < 0) {
+    return StatusWithSize::Internal();
+  }
+  return StatusWithSize(bytes_rcvd);
+}
+
+};  // namespace pw::stream
diff --git a/pw_stream/stream_test.cc b/pw_stream/stream_test.cc
new file mode 100644
index 0000000..fad335a
--- /dev/null
+++ b/pw_stream/stream_test.cc
@@ -0,0 +1,37 @@
+// 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.
+
+#include "pw_stream/stream.h"
+
+#include <limits>
+
+#include "gtest/gtest.h"
+#include "pw_stream/null_stream.h"
+
+namespace pw::stream {
+namespace {
+
+TEST(Stream, DefaultConservativeWriteLimit) {
+  NullWriter stream;
+  EXPECT_EQ(stream.ConservativeWriteLimit(),
+            std::numeric_limits<size_t>::max());
+}
+
+TEST(Stream, DefaultConservativeReadLimit) {
+  NullReader stream;
+  EXPECT_EQ(stream.ConservativeReadLimit(), std::numeric_limits<size_t>::max());
+}
+
+}  // namespace
+}  // namespace pw::stream
diff --git a/pw_string/BUILD.gn b/pw_string/BUILD.gn
index 400fa48..bf59ff2 100644
--- a/pw_string/BUILD.gn
+++ b/pw_string/BUILD.gn
@@ -39,7 +39,6 @@
   ]
   public_deps = [
     "$dir_pw_preprocessor",
-    "$dir_pw_span",
     "$dir_pw_status",
   ]
 }
@@ -54,7 +53,6 @@
   ]
   group_deps = [
     "$dir_pw_preprocessor:tests",
-    "$dir_pw_span:tests",
     "$dir_pw_status:tests",
   ]
 }
diff --git a/pw_string/docs.rst b/pw_string/docs.rst
index 8944100..467908b 100644
--- a/pw_string/docs.rst
+++ b/pw_string/docs.rst
@@ -19,24 +19,15 @@
 =============
 C++17
 
-Dependencies
-============
-* ``pw_preprocessor``
-* ``pw_status``
-* ``pw_span``
-
-Features
-========
-
 pw::string::Format
-------------------
+==================
 The ``pw::string::Format`` and ``pw::string::FormatVaList`` functions provide
 safer alternatives to ``std::snprintf`` and ``std::vsnprintf``. The snprintf
 return value is awkward to interpret, and misinterpreting it can lead to serious
 bugs.
 
 Size report: replacing snprintf with pw::string::Format
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+-------------------------------------------------------
 The ``Format`` functions have a small, fixed code size cost. However, relative
 to equivalent ``std::snprintf`` calls, there is no incremental code size cost to
 using ``Format``.
@@ -44,28 +35,70 @@
 .. include:: format_size_report
 
 pw::StringBuilder
------------------
-StringBuilder facilitates building formatted strings in a fixed-size buffer. It
-is designed to give the flexibility of ``std::string`` and
-``std::ostringstream``, but with a small footprint. However, applications
-sensitive to code size should use StringBuilder with care.
+=================
+``pw::StringBuilder`` facilitates building formatted strings in a fixed-size
+buffer. It is designed to give the flexibility of ``std::string`` and
+``std::ostringstream``, but with a small footprint.
+
+Supporting custom types with StringBuilder
+------------------------------------------
+As with ``std::ostream``, StringBuilder supports printing custom types by
+overriding the ``<<`` operator. This is is done by defining ``operator<<`` in
+the same namespace as the custom type. For example:
+
+.. code-block:: cpp
+
+  namespace my_project {
+
+  struct MyType {
+    int foo;
+    const char* bar;
+  };
+
+  pw::StringBuilder& operator<<(pw::StringBuilder& sb, const MyType& value) {
+    return sb << "MyType(" << value.foo << ", " << value.bar << ')';
+  }
+
+  }  // namespace my_project
+
+Internally, ``StringBuilder`` uses the ``ToString`` function to print. The
+``ToString`` template function can be specialized to support custom types with
+``StringBuilder``, though it is recommended to overload ``operator<<`` instead.
+This example shows how to specialize ``pw::ToString``:
+
+.. code-block:: cpp
+
+  #include "pw_string/to_string.h"
+
+  namespace pw {
+
+  template <>
+  StatusWithSize ToString<MyStatus>(MyStatus value, std::span<char> buffer) {
+    return CopyString(MyStatusString(value), buffer);
+  }
+
+  }  // namespace pw
 
 Size report: replacing snprintf with pw::StringBuilder
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+------------------------------------------------------
+StringBuilder is safe, flexible, and results in much smaller code size than
+using ``std::ostringstream``. However, applications sensitive to code size
+should use StringBuilder with care.
+
 The fixed code size cost of StringBuilder is significant, though smaller than
 ``std::snprintf``. Using StringBuilder's << and append methods exclusively in
 place of ``snprintf`` reduces code size, but ``snprintf`` may be difficult to
 avoid.
 
 The incremental code size cost of StringBuilder is comparable to ``snprintf`` if
-errors are handled. Each argument to StringBuilder's << expands to a function
-call, but one or two StringBuilder appends may have a smaller code size impact
-than a single ``snprintf`` call.
+errors are handled. Each argument to StringBuilder's ``<<`` expands to a
+function call, but one or two StringBuilder appends may have a smaller code size
+impact than a single ``snprintf`` call.
 
 .. include:: string_builder_size_report
 
 Future work
-^^^^^^^^^^^
+===========
 * StringBuilder's fixed size cost can be dramatically reduced by limiting
   support for 64-bit integers.
 * Consider integrating with the tokenizer module.
diff --git a/pw_string/format_test.cc b/pw_string/format_test.cc
index b7aab52..9aef419 100644
--- a/pw_string/format_test.cc
+++ b/pw_string/format_test.cc
@@ -26,7 +26,7 @@
   char buffer[32];
   auto result = Format(buffer, "-_-");
 
-  EXPECT_EQ(Status::Ok(), result.status());
+  EXPECT_EQ(OkStatus(), result.status());
   EXPECT_EQ(3u, result.size());
   EXPECT_STREQ("-_-", buffer);
 }
@@ -35,7 +35,7 @@
   char buffer[32];
   auto result = Format(buffer, "%d4%s", 123, "5");
 
-  EXPECT_EQ(Status::Ok(), result.status());
+  EXPECT_EQ(OkStatus(), result.status());
   EXPECT_EQ(5u, result.size());
   EXPECT_STREQ("12345", buffer);
 }
@@ -81,7 +81,7 @@
   char buffer[8];
   auto result = CallFormatWithVaList(buffer, "Yo%s", "?!");
 
-  EXPECT_EQ(Status::Ok(), result.status());
+  EXPECT_EQ(OkStatus(), result.status());
   EXPECT_EQ(4u, result.size());
   EXPECT_STREQ("Yo?!", buffer);
 }
diff --git a/pw_string/public/pw_string/format.h b/pw_string/public/pw_string/format.h
index f3aa337..9499eb2 100644
--- a/pw_string/public/pw_string/format.h
+++ b/pw_string/public/pw_string/format.h
@@ -35,7 +35,7 @@
 //
 // The status is
 //
-//   Status::Ok() if the operation succeeded,
+//   OkStatus() if the operation succeeded,
 //   Status::ResourceExhausted() if the buffer was too small to fit the output,
 //   Status::InvalidArgument() if there was a formatting error.
 //
diff --git a/pw_string/public/pw_string/string_builder.h b/pw_string/public/pw_string/string_builder.h
index 425fbfb..c85e6be 100644
--- a/pw_string/public/pw_string/string_builder.h
+++ b/pw_string/public/pw_string/string_builder.h
@@ -41,12 +41,25 @@
 // StringBuilder supports C++-style << output, similar to std::ostringstream. It
 // also supports std::string-like append functions and printf-style output.
 //
-// StringBuilder uses the ToString function to support arbitrary types. Defining
-// a ToString template specialization overload in the pw namespace allows
-// writing that type to a StringBuilder with <<.
+// Support for custom types is added by overloading operator<< in the same
+// namespace as the custom type. For example:
 //
-// For example, the following ToString overload allows writing MyStatus objects
-// to StringBuilders:
+//   namespace my_project {
+//
+//   struct MyType {
+//     int foo;
+//     const char* bar;
+//   };
+//
+//   pw::StringBuilder& operator<<(pw::StringBuilder& sb, const MyType& value) {
+//     return sb << "MyType(" << value.foo << ", " << value.bar << ')';
+//   }
+//
+//   }  // namespace my_project
+//
+// The ToString template function can be specialized to support custom types
+// with StringBuilder, though overloading operator<< is generally preferred. For
+// example:
 //
 //   namespace pw {
 //
@@ -57,32 +70,6 @@
 //
 //   }  // namespace pw
 //
-// For complex types, it may be easier to override StringBuilder's << operator,
-// similar to the standard library's std::ostream. For example:
-//
-//   namespace pw {
-//
-//   StringBuilder& operator<<(StringBuilder& sb, const MyType& value) {
-//     return sb << "MyType(" << value.foo << ", " << value.bar << ')';
-//   }
-//
-//   }  // namespace pw
-//
-// Alternately, complex types may use a StringBuilder in their ToString, but it
-// is likely to be simpler to override StringBuilder's operator<<.
-//
-// StringBuilder is safe, flexible, and results in much smaller code size than
-// using std::ostringstream. However, applications sensitive to code size should
-// use StringBuilder with care.
-//
-// The fixed code size cost of StringBuilder is significant, though smaller than
-// std::snprintf. Using StringBuilder's << and append methods exclusively in
-// place of snprintf reduces code size, but snprintf may be difficult to avoid.
-//
-// The incremental code size cost of StringBuilder is comparable to snprintf if
-// errors are handled. Each argument to StringBuilder's << expands to a function
-// call, but one or two StringBuilder appends may have a smaller code size
-// impact than a single snprintf call. See the size report for further analysis.
 class StringBuilder {
  public:
   // Creates an empty StringBuilder.
@@ -135,7 +122,7 @@
   // The status from the last operation. May be OK while status() is not OK.
   Status last_status() const { return last_status_; }
 
-  // True if status() is Status::Ok().
+  // True if status() is OkStatus().
   bool ok() const { return status_.ok(); }
 
   // True if the string is empty.
@@ -150,10 +137,10 @@
   // Clears the string and resets its error state.
   void clear();
 
-  // Sets the statuses to Status::Ok();
+  // Sets the statuses to OkStatus();
   void clear_status() {
-    status_ = Status::Ok();
-    last_status_ = Status::Ok();
+    status_ = OkStatus();
+    last_status_ = OkStatus();
   }
 
   // Appends a single character. Stets the status to RESOURCE_EXHAUSTED if the
diff --git a/pw_string/public/pw_string/util.h b/pw_string/public/pw_string/util.h
index ddf6a36..37c6c0b 100644
--- a/pw_string/public/pw_string/util.h
+++ b/pw_string/public/pw_string/util.h
@@ -15,7 +15,8 @@
 
 #include <cstddef>
 
-namespace pw::string {
+namespace pw {
+namespace string {
 
 // Calculates the length of a null-terminated string up to the specified maximum
 // length. If str is nullptr, returns 0.
@@ -35,4 +36,5 @@
   return length;
 }
 
-}  // namespace pw::string
+}  // namespace string
+}  // namespace pw
diff --git a/pw_string/size_report/format_many_without_error_handling.cc b/pw_string/size_report/format_many_without_error_handling.cc
index 357fc2a..7a5ab2d 100644
--- a/pw_string/size_report/format_many_without_error_handling.cc
+++ b/pw_string/size_report/format_many_without_error_handling.cc
@@ -38,18 +38,22 @@
 
 namespace pw::string {
 
-char* volatile get_buffer;
+char buffer_1[128];
+char buffer_2[128];
+
+char* volatile get_buffer_1 = buffer_1;
+char* volatile get_buffer_2 = buffer_2;
 volatile unsigned get_size;
 
 void OutputStringsToBuffer() {
 #if USE_FORMAT
-  auto buffer = std::span(get_buffer, get_size);
+  auto buffer = std::span(get_buffer_1, get_size);
 #else
-  char* buffer = get_buffer;
+  char* buffer = get_buffer_1;
   unsigned buffer_size = get_size;
 #endif  // USE_FORMAT
 
-  const char* string = get_buffer;
+  const char* string = get_buffer_2;
   unsigned value = get_size;
 
   FORMAT_CASE("The quick brown");
diff --git a/pw_string/size_report/format_multiple.cc b/pw_string/size_report/format_multiple.cc
index b27df5a..7f516cb 100644
--- a/pw_string/size_report/format_multiple.cc
+++ b/pw_string/size_report/format_multiple.cc
@@ -82,12 +82,16 @@
 
 namespace pw::string {
 
-char* volatile get_buffer;
+char buffer_1[128];
+char buffer_2[128];
+
+char* volatile get_buffer_1 = buffer_1;
+char* volatile get_buffer_2 = buffer_2;
 volatile unsigned get_size;
 
 unsigned OutputStringsToBuffer() {
-  char* buffer = get_buffer;
-  const char* string = get_buffer;
+  char* buffer = get_buffer_1;
+  const char* string = get_buffer_2;
 
   unsigned buffer_size = get_size;
   unsigned string_size = 0;
diff --git a/pw_string/size_report/format_single.cc b/pw_string/size_report/format_single.cc
index 94afe76..6afee11 100644
--- a/pw_string/size_report/format_single.cc
+++ b/pw_string/size_report/format_single.cc
@@ -31,18 +31,22 @@
 
 namespace pw::string {
 
-char* volatile get_buffer;
+char buffer_1[128];
+char buffer_2[128];
+
+char* volatile get_buffer_1 = buffer_1;
+char* volatile get_buffer_2 = buffer_2;
 volatile unsigned get_size;
 
 unsigned OutputStringsToBuffer() {
-  char* buffer = get_buffer;
+  char* buffer = get_buffer_1;
   unsigned buffer_size = get_size;
 
 #if USE_FORMAT
   // The code for using pw::string::Format is much simpler and safer.
   return Format(std::span(buffer, buffer_size),
                 "hello %s %d",
-                get_buffer,
+                get_buffer_2,
                 get_size)
       .size();
 #else  // std::snprintf
@@ -51,7 +55,7 @@
   }
 
   int result =
-      std::snprintf(buffer, buffer_size, "hello %s %d", get_buffer, get_size);
+      std::snprintf(buffer, buffer_size, "hello %s %d", get_buffer_2, get_size);
   if (result < 0) {
     buffer[0] = '\0';
     return 0;
diff --git a/pw_string/string_builder.cc b/pw_string/string_builder.cc
index 1bc3b1f..60879a9 100644
--- a/pw_string/string_builder.cc
+++ b/pw_string/string_builder.cc
@@ -24,8 +24,8 @@
 void StringBuilder::clear() {
   size_ = 0;
   NullTerminate();
-  status_ = Status::Ok();
-  last_status_ = Status::Ok();
+  status_ = OkStatus();
+  last_status_ = OkStatus();
 }
 
 StringBuilder& StringBuilder::append(size_t count, char ch) {
@@ -70,7 +70,7 @@
   if (buffer_.empty() || chars_to_append != copied) {
     SetErrorStatus(Status::ResourceExhausted());
   } else {
-    last_status_ = Status::Ok();
+    last_status_ = OkStatus();
   }
   return copied;
 }
@@ -79,7 +79,7 @@
   if (new_size <= size_) {
     size_ = new_size;
     NullTerminate();
-    last_status_ = Status::Ok();
+    last_status_ = OkStatus();
   } else {
     SetErrorStatus(Status::OutOfRange());
   }
diff --git a/pw_string/string_builder_test.cc b/pw_string/string_builder_test.cc
index cfef0e7..0b092ad 100644
--- a/pw_string/string_builder_test.cc
+++ b/pw_string/string_builder_test.cc
@@ -102,8 +102,8 @@
 
 TEST(StringBuilder, EmptyBuffer_AppendEmpty_ResourceExhausted) {
   StringBuilder sb(std::span<char>{});
-  EXPECT_EQ(Status::Ok(), sb.last_status());
-  EXPECT_EQ(Status::Ok(), sb.status());
+  EXPECT_EQ(OkStatus(), sb.last_status());
+  EXPECT_EQ(OkStatus(), sb.status());
 
   sb << "";
 
@@ -113,8 +113,8 @@
 
 TEST(StringBuilder, Status_StartsOk) {
   StringBuffer<16> sb;
-  EXPECT_EQ(Status::Ok(), sb.status());
-  EXPECT_EQ(Status::Ok(), sb.last_status());
+  EXPECT_EQ(OkStatus(), sb.status());
+  EXPECT_EQ(OkStatus(), sb.last_status());
 }
 
 TEST(StringBuilder, Status_StatusAndLastStatusUpdate) {
@@ -129,7 +129,7 @@
 
   sb << "";
   EXPECT_EQ(Status::OutOfRange(), sb.status());
-  EXPECT_EQ(Status::Ok(), sb.last_status());
+  EXPECT_EQ(OkStatus(), sb.last_status());
 }
 
 TEST(StringBuilder, Status_ClearStatus_SetsStatuesToOk) {
@@ -138,8 +138,8 @@
   EXPECT_EQ(Status::ResourceExhausted(), sb.last_status());
 
   sb.clear_status();
-  EXPECT_EQ(Status::Ok(), sb.status());
-  EXPECT_EQ(Status::Ok(), sb.last_status());
+  EXPECT_EQ(OkStatus(), sb.status());
+  EXPECT_EQ(OkStatus(), sb.last_status());
 }
 
 TEST(StringBuilder, StreamOutput_OutputSelf) {
@@ -153,7 +153,7 @@
 TEST(StringBuilder, PushBack) {
   StringBuffer<12> sb;
   sb.push_back('?');
-  EXPECT_EQ(Status::Ok(), sb.last_status());
+  EXPECT_EQ(OkStatus(), sb.last_status());
   EXPECT_EQ(1u, sb.size());
   EXPECT_STREQ("?", sb.data());
 }
@@ -168,7 +168,7 @@
 TEST(StringBuilder, PopBack) {
   auto sb = MakeString<12>("Welcome!");
   sb.pop_back();
-  EXPECT_EQ(Status::Ok(), sb.last_status());
+  EXPECT_EQ(OkStatus(), sb.last_status());
   EXPECT_EQ(7u, sb.size());
   EXPECT_STREQ("Welcome", sb.data());
 }
@@ -362,7 +362,7 @@
   constexpr std::string_view hello("hello");
 
   buffer << hello;
-  EXPECT_EQ(Status::Ok(), buffer.status());
+  EXPECT_EQ(OkStatus(), buffer.status());
   EXPECT_STREQ("hello", buffer.data());
 }
 
@@ -422,7 +422,7 @@
   two << "";
   ASSERT_STREQ("What heck", two.data());
   ASSERT_EQ(Status::ResourceExhausted(), two.status());
-  ASSERT_EQ(Status::Ok(), two.last_status());
+  ASSERT_EQ(OkStatus(), two.last_status());
 }
 
 TEST(StringBuffer, CopyConstructFromSmaller) {
@@ -521,13 +521,18 @@
 TEST(MakeString, StringLiteral_ResizesToFitWholeLiteral) {
   EXPECT_STREQ("", MakeString().data());
 
-  auto normal = MakeString("");
+  [[maybe_unused]] auto normal = MakeString("");
   static_assert(normal.max_size() == decltype(MakeString(1))::max_size());
+  EXPECT_EQ(normal.max_size(), decltype(MakeString(1))::max_size());
 
-  auto resized = MakeString("This string is reeeeeeeeeaaaaallly long!!!!!");
+  [[maybe_unused]] auto resized =
+      MakeString("This string is reeeeeeeeeaaaaallly long!!!!!");
   static_assert(resized.max_size() > decltype(MakeString(1))::max_size());
   static_assert(resized.max_size() ==
                 sizeof("This string is reeeeeeeeeaaaaallly long!!!!!") - 1);
+  EXPECT_GT(resized.max_size(), decltype(MakeString(1))::max_size());
+  EXPECT_EQ(resized.max_size(),
+            sizeof("This string is reeeeeeeeeaaaaallly long!!!!!") - 1);
 }
 
 TEST(MakeString, StringLiteral_UsesLongerFixedSize) {
@@ -582,5 +587,42 @@
               25);
 static_assert(DefaultStringBufferSize('a', nullptr, 'b', 4, 5, 6, 7, 8) == 33);
 
+struct SomeCustomType {};
+
+StringBuilder& operator<<(StringBuilder& sb, const SomeCustomType&) {
+  return sb << "SomeCustomType was here!";
+}
+
+TEST(StringBuilder, ShiftOperatorOverload_SameNamsepace) {
+  pw::StringBuffer<48> buffer;
+  buffer << SomeCustomType{};
+
+  EXPECT_STREQ("SomeCustomType was here!", buffer.c_str());
+}
+
 }  // namespace
 }  // namespace pw
+
+namespace some_other_ns {
+
+struct MyCustomType {
+  int item;
+};
+
+pw::StringBuilder& operator<<(pw::StringBuilder& sb,
+                              const MyCustomType& value) {
+  return sb << "MyCustomType(" << value.item << ')';
+}
+
+}  // namespace some_other_ns
+
+namespace pw_test_namespace {
+
+TEST(StringBuilder, ShiftOperatorOverload_DifferentNamsepace) {
+  pw::StringBuffer<48> buffer;
+  buffer << "This is " << some_other_ns::MyCustomType{1138};
+
+  EXPECT_STREQ("This is MyCustomType(1138)", buffer.data());
+}
+
+}  // namespace pw_test_namespace
diff --git a/pw_string/to_string_test.cc b/pw_string/to_string_test.cc
index 2f3608d..347010a 100644
--- a/pw_string/to_string_test.cc
+++ b/pw_string/to_string_test.cc
@@ -101,7 +101,7 @@
 
   auto result = ToString(MyEnum::kLuckyNumber, buffer);
   EXPECT_EQ(1u, result.size());
-  EXPECT_EQ(Status::Ok(), result.status());
+  EXPECT_EQ(OkStatus(), result.status());
   EXPECT_STREQ("8", buffer);
 }
 
diff --git a/pw_string/type_to_string.cc b/pw_string/type_to_string.cc
index 483d10e..4872504 100644
--- a/pw_string/type_to_string.cc
+++ b/pw_string/type_to_string.cc
@@ -190,7 +190,7 @@
   buffer[copied] = '\0';
 
   return StatusWithSize(
-      copied == value.size() ? Status::Ok() : Status::ResourceExhausted(),
+      copied == value.size() ? OkStatus() : Status::ResourceExhausted(),
       copied);
 }
 
diff --git a/pw_sync/BUILD b/pw_sync/BUILD
new file mode 100644
index 0000000..e0a4741
--- /dev/null
+++ b/pw_sync/BUILD
@@ -0,0 +1,271 @@
+# 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.
+
+load(
+    "//pw_build:pigweed.bzl",
+    "pw_cc_library",
+    "pw_cc_test",
+)
+
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])  # Apache License 2.0
+
+# TODO(pwbug/101): Need to add support for facades/backends to Bazel.
+PW_SYNC_BINARY_SEMAPHORE_BACKEND = "//pw_sync_stl:binary_semaphore"
+PW_SYNC_COUNTING_SEMAPHORE_BACKEND = "//pw_sync_stl:counting_semaphore"
+PW_SYNC_MUTEX_BACKEND = "//pw_sync_stl:mutex"
+PW_SYNC_TIMED_MUTEX_BACKEND = "//pw_sync_stl:timed_mutex"
+PW_SYNC_INTERRUPT_SPIN_LOCK_BACKEND = "//pw_sync_stl:interrupt_spin_lock"
+
+pw_cc_library(
+    name = "binary_semaphore_facade",
+    hdrs = [
+        "public/pw_sync/binary_semaphore.h",
+    ],
+    includes = ["public"],
+    srcs = [
+        "binary_semaphore.cc"
+    ],
+    deps = [
+        PW_SYNC_BINARY_SEMAPHORE_BACKEND + "_headers",
+        "//pw_chrono:system_clock",
+        "//pw_preprocessor",
+    ],
+)
+
+pw_cc_library(
+    name = "binary_semaphore",
+    deps = [
+        ":binary_semaphore_facade",
+        PW_SYNC_BINARY_SEMAPHORE_BACKEND + "_headers",
+    ],
+)
+
+pw_cc_library(
+    name = "binary_semaphore_backend",
+    deps = [
+       PW_SYNC_BINARY_SEMAPHORE_BACKEND,
+    ],
+)
+
+pw_cc_library(
+    name = "counting_semaphore_facade",
+    hdrs = [
+        "public/pw_sync/counting_semaphore.h",
+    ],
+    includes = ["public"],
+    srcs = [
+        "counting_semaphore.cc"
+    ],
+    deps = [
+        PW_SYNC_COUNTING_SEMAPHORE_BACKEND + "_headers",
+        "//pw_chrono:system_clock",
+        "//pw_preprocessor",
+    ],
+)
+
+pw_cc_library(
+    name = "counting_semaphore",
+    deps = [
+        ":counting_semaphore_facade",
+        PW_SYNC_COUNTING_SEMAPHORE_BACKEND + "_headers",
+    ],
+)
+
+pw_cc_library(
+    name = "counting_semaphore_backend",
+    deps = [
+       PW_SYNC_COUNTING_SEMAPHORE_BACKEND,
+    ],
+)
+
+pw_cc_library(
+    name = "lock_annotations",
+    hdrs = [
+        "public/pw_sync/lock_annotations.h",
+    ],
+    includes = ["public"],
+    deps = [
+        "//pw_preprocessor",
+    ],
+)
+
+pw_cc_library(
+    name = "mutex_facade",
+    hdrs = [
+        "public/pw_sync/mutex.h",
+    ],
+    includes = ["public"],
+    srcs = [
+        "mutex.cc"
+    ],
+    deps = [
+		    ":lock_annotations",
+        "//pw_preprocessor",
+        PW_SYNC_MUTEX_BACKEND + "_headers",
+    ],
+)
+
+pw_cc_library(
+    name = "mutex",
+    deps = [
+        ":mutex_facade",
+        PW_SYNC_MUTEX_BACKEND + "_headers",
+    ],
+)
+
+pw_cc_library(
+    name = "mutex_backend",
+    deps = [
+       PW_SYNC_MUTEX_BACKEND,
+    ],
+)
+
+pw_cc_library(
+    name = "timed_mutex_facade",
+    hdrs = [
+        "public/pw_sync/timed_mutex.h",
+    ],
+    includes = ["public"],
+    srcs = [
+        "timed_mutex.cc"
+    ],
+    deps = [
+		    ":lock_annotations",
+        ":mutex_facade",
+        "//pw_chrono:system_clock",
+        "//pw_preprocessor",
+        PW_SYNC_TIMED_MUTEX_BACKEND + "_headers",
+    ],
+)
+
+pw_cc_library(
+    name = "timed_mutex",
+    deps = [
+        ":timed_mutex_facade",
+        PW_SYNC_TIMED_MUTEX_BACKEND + "_headers",
+    ],
+)
+
+pw_cc_library(
+    name = "timed_mutex_backend",
+    deps = [
+       PW_SYNC_TIMED_MUTEX_BACKEND,
+    ],
+)
+
+pw_cc_library(
+    name = "interrupt_spin_lock_facade",
+    hdrs = [
+        "public/pw_sync/interrupt_spin_lock.h",
+    ],
+    includes = ["public"],
+    srcs = [
+        "interrupt_spin_lock.cc"
+    ],
+    deps = [
+		    ":lock_annotations",
+        "//pw_preprocessor",
+        PW_SYNC_INTERRUPT_SPIN_LOCK_BACKEND + "_headers",
+    ],
+)
+
+pw_cc_library(
+    name = "interrupt_spin_lock",
+    deps = [
+        ":interrupt_spin_lock_facade",
+        PW_SYNC_INTERRUPT_SPIN_LOCK_BACKEND + "_headers",
+    ],
+)
+
+pw_cc_library(
+    name = "interrupt_spin_lock_backend",
+    deps = [
+       PW_SYNC_INTERRUPT_SPIN_LOCK_BACKEND,
+    ],
+)
+
+pw_cc_library(
+    name = "yield_core",
+    hdrs = [
+        "public/pw_sync/yield_core.h",
+    ],
+    includes = ["public"],
+)
+
+pw_cc_test(
+    name = "binary_semaphore_facade_test",
+    srcs = [
+        "binary_semaphore_facade_test.cc",
+        "binary_semaphore_facade_test_c.c",
+    ],
+    deps = [
+        ":binary_semaphore",
+        "//pw_preprocessor",
+        "//pw_unit_test",
+    ],
+)
+
+pw_cc_test(
+    name = "counting_semaphore_facade_test",
+    srcs = [
+        "counting_semaphore_facade_test.cc",
+        "counting_semaphore_facade_test_c.c",
+    ],
+    deps = [
+        ":counting_semaphore",
+        "//pw_preprocessor",
+        "//pw_unit_test",
+    ],
+)
+
+pw_cc_test(
+    name = "mutex_facade_test",
+    srcs = [
+        "mutex_facade_test.cc",
+        "mutex_facade_test_c.c",
+    ],
+    deps = [
+        ":mutex",
+        "//pw_preprocessor",
+        "//pw_unit_test",
+    ],
+)
+
+pw_cc_test(
+    name = "timed_mutex_facade_test",
+    srcs = [
+        "timed_mutex_facade_test.cc",
+        "timed_mutex_facade_test_c.c",
+    ],
+    deps = [
+        ":timed_mutex",
+        "//pw_preprocessor",
+        "//pw_unit_test",
+    ],
+)
+
+pw_cc_test(
+    name = "interrupt_spin_lock_facade_test",
+    srcs = [
+        "interrupt_spin_lock_facade_test.cc",
+        "interrupt_spin_lock_facade_test_c.c",
+    ],
+    deps = [
+        ":interrupt_spin_lock",
+        "//pw_preprocessor",
+        "//pw_unit_test",
+    ],
+)
diff --git a/pw_sync/BUILD.gn b/pw_sync/BUILD.gn
new file mode 100644
index 0000000..db702a6
--- /dev/null
+++ b/pw_sync/BUILD.gn
@@ -0,0 +1,172 @@
+# 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/facade.gni")
+import("$dir_pw_build/target_types.gni")
+import("$dir_pw_docgen/docs.gni")
+import("$dir_pw_unit_test/test.gni")
+import("backend.gni")
+
+config("public_include_path") {
+  include_dirs = [ "public" ]
+  visibility = [ ":*" ]
+}
+
+pw_facade("binary_semaphore") {
+  backend = pw_sync_BINARY_SEMAPHORE_BACKEND
+  public_configs = [ ":public_include_path" ]
+  public = [ "public/pw_sync/binary_semaphore.h" ]
+  public_deps = [
+    "$dir_pw_chrono:system_clock",
+    "$dir_pw_preprocessor",
+  ]
+  sources = [ "binary_semaphore.cc" ]
+}
+
+pw_facade("counting_semaphore") {
+  backend = pw_sync_COUNTING_SEMAPHORE_BACKEND
+  public_configs = [ ":public_include_path" ]
+  public = [ "public/pw_sync/counting_semaphore.h" ]
+  public_deps = [
+    "$dir_pw_chrono:system_clock",
+    "$dir_pw_preprocessor",
+  ]
+  sources = [ "counting_semaphore.cc" ]
+}
+
+pw_source_set("lock_annotations") {
+  public_configs = [ ":public_include_path" ]
+  public = [ "public/pw_sync/lock_annotations.h" ]
+  public_deps = [ "$dir_pw_preprocessor" ]
+}
+
+pw_facade("mutex") {
+  backend = pw_sync_MUTEX_BACKEND
+  public_configs = [ ":public_include_path" ]
+  public = [ "public/pw_sync/mutex.h" ]
+  public_deps = [
+    ":lock_annotations",
+    "$dir_pw_preprocessor",
+  ]
+  sources = [ "mutex.cc" ]
+}
+
+pw_facade("timed_mutex") {
+  backend = pw_sync_TIMED_MUTEX_BACKEND
+  public_configs = [ ":public_include_path" ]
+  public = [ "public/pw_sync/timed_mutex.h" ]
+  public_deps = [
+    ":mutex",
+    "$dir_pw_chrono:system_clock",
+    "$dir_pw_preprocessor",
+  ]
+  sources = [ "timed_mutex.cc" ]
+}
+
+pw_facade("interrupt_spin_lock") {
+  backend = pw_sync_INTERRUPT_SPIN_LOCK_BACKEND
+  public_configs = [ ":public_include_path" ]
+  public = [ "public/pw_sync/interrupt_spin_lock.h" ]
+  public_deps = [
+    ":lock_annotations",
+    "$dir_pw_preprocessor",
+  ]
+  sources = [ "interrupt_spin_lock.cc" ]
+}
+
+pw_source_set("yield_core") {
+  public = [ "public/pw_sync/yield_core.h" ]
+  public_configs = [ ":public_include_path" ]
+}
+
+pw_test_group("tests") {
+  tests = [
+    ":binary_semaphore_facade_test",
+    ":counting_semaphore_facade_test",
+    ":mutex_facade_test",
+    ":timed_mutex_facade_test",
+    ":interrupt_spin_lock_facade_test",
+  ]
+}
+
+pw_test("binary_semaphore_facade_test") {
+  enable_if = pw_sync_BINARY_SEMAPHORE_BACKEND != ""
+  sources = [
+    "binary_semaphore_facade_test.cc",
+    "binary_semaphore_facade_test_c.c",
+  ]
+  deps = [
+    ":binary_semaphore",
+    "$dir_pw_preprocessor",
+    pw_sync_BINARY_SEMAPHORE_BACKEND,
+  ]
+}
+
+pw_test("counting_semaphore_facade_test") {
+  enable_if = pw_sync_COUNTING_SEMAPHORE_BACKEND != ""
+  sources = [
+    "counting_semaphore_facade_test.cc",
+    "counting_semaphore_facade_test_c.c",
+  ]
+  deps = [
+    ":counting_semaphore",
+    "$dir_pw_preprocessor",
+    pw_sync_COUNTING_SEMAPHORE_BACKEND,
+  ]
+}
+
+pw_test("mutex_facade_test") {
+  enable_if = pw_sync_MUTEX_BACKEND != ""
+  sources = [
+    "mutex_facade_test.cc",
+    "mutex_facade_test_c.c",
+  ]
+  deps = [
+    ":mutex",
+    "$dir_pw_preprocessor",
+    pw_sync_MUTEX_BACKEND,
+  ]
+}
+
+pw_test("timed_mutex_facade_test") {
+  enable_if = pw_sync_TIMED_MUTEX_BACKEND != ""
+  sources = [
+    "timed_mutex_facade_test.cc",
+    "timed_mutex_facade_test_c.c",
+  ]
+  deps = [
+    ":timed_mutex",
+    "$dir_pw_preprocessor",
+    pw_sync_TIMED_MUTEX_BACKEND,
+  ]
+}
+
+pw_test("interrupt_spin_lock_facade_test") {
+  enable_if = pw_sync_INTERRUPT_SPIN_LOCK_BACKEND != ""
+  sources = [
+    "interrupt_spin_lock_facade_test.cc",
+    "interrupt_spin_lock_facade_test_c.c",
+  ]
+  deps = [
+    ":interrupt_spin_lock",
+    "$dir_pw_preprocessor",
+    pw_sync_INTERRUPT_SPIN_LOCK_BACKEND,
+  ]
+}
+
+pw_doc_group("docs") {
+  sources = [ "docs.rst" ]
+}
diff --git a/pw_sync/CMakeLists.txt b/pw_sync/CMakeLists.txt
new file mode 100644
index 0000000..69c553e
--- /dev/null
+++ b/pw_sync/CMakeLists.txt
@@ -0,0 +1,23 @@
+# 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.
+
+include($ENV{PW_ROOT}/pw_build/pigweed.cmake)
+
+pw_add_facade(pw_sync.mutex
+  SOURCES
+    mutex.cc
+  PUBLIC_DEPS
+    pw_chrono.system_clock
+    pw_preprocessor
+)
diff --git a/pw_sync/backend.gni b/pw_sync/backend.gni
new file mode 100644
index 0000000..8f6605a
--- /dev/null
+++ b/pw_sync/backend.gni
@@ -0,0 +1,35 @@
+# 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.
+
+declare_args() {
+  # Backend for the pw_sync module's binary semaphore.
+  pw_sync_BINARY_SEMAPHORE_BACKEND = ""
+
+  # Backend for the pw_sync module's counting semaphore.
+  pw_sync_COUNTING_SEMAPHORE_BACKEND = ""
+
+  # Backend for the pw_sync module's mutex.
+  pw_sync_MUTEX_BACKEND = ""
+
+  # Backend for the pw_sync module's timed mutex.
+  pw_sync_TIMED_MUTEX_BACKEND = ""
+
+  # Backend for the pw_sync module's interrupt spin lock.
+  pw_sync_INTERRUPT_SPIN_LOCK_BACKEND = ""
+
+  # Whether the GN asserts should be silenced in ensuring that a compatible
+  # backend for pw_chrono_SYSTEM_CLOCK_BACKEND is chosen.
+  # Set to true to disable the asserts.
+  pw_sync_OVERRIDE_SYSTEM_CLOCK_BACKEND_CHECK = false
+}
diff --git a/pw_sync/binary_semaphore.cc b/pw_sync/binary_semaphore.cc
new file mode 100644
index 0000000..5500964
--- /dev/null
+++ b/pw_sync/binary_semaphore.cc
@@ -0,0 +1,49 @@
+// 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.
+
+#include "pw_sync/binary_semaphore.h"
+
+using pw::chrono::SystemClock;
+
+extern "C" void pw_sync_BinarySemaphore_Release(
+    pw_sync_BinarySemaphore* semaphore) {
+  semaphore->release();
+}
+
+extern "C" void pw_sync_BinarySemaphore_Acquire(
+    pw_sync_BinarySemaphore* semaphore) {
+  semaphore->acquire();
+}
+
+extern "C" bool pw_sync_BinarySemaphore_TryAcquire(
+    pw_sync_BinarySemaphore* semaphore) {
+  return semaphore->try_acquire();
+}
+
+extern "C" bool pw_sync_BinarySemaphore_TryAcquireFor(
+    pw_sync_BinarySemaphore* semaphore,
+    pw_chrono_SystemClock_Duration for_at_least) {
+  return semaphore->try_acquire_for(SystemClock::duration(for_at_least.ticks));
+}
+
+extern "C" bool pw_sync_BinarySemaphore_TryAcquireUntil(
+    pw_sync_BinarySemaphore* semaphore,
+    pw_chrono_SystemClock_TimePoint until_at_least) {
+  return semaphore->try_acquire_until(SystemClock::time_point(
+      SystemClock::duration(until_at_least.duration_since_epoch.ticks)));
+}
+
+extern "C" ptrdiff_t pw_sync_BinarySemaphore_Max(void) {
+  return pw::sync::BinarySemaphore::max();
+}
diff --git a/pw_sync/binary_semaphore_facade_test.cc b/pw_sync/binary_semaphore_facade_test.cc
new file mode 100644
index 0000000..b3d136f
--- /dev/null
+++ b/pw_sync/binary_semaphore_facade_test.cc
@@ -0,0 +1,172 @@
+// 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.
+
+#include <chrono>
+
+#include "gtest/gtest.h"
+#include "pw_chrono/system_clock.h"
+#include "pw_sync/binary_semaphore.h"
+
+using pw::chrono::SystemClock;
+using namespace std::chrono_literals;
+
+namespace pw::sync {
+namespace {
+
+extern "C" {
+
+// Functions defined in binary_semaphore_facade_test_c.c which call the API
+// from C.
+void pw_sync_BinarySemaphore_CallRelease(pw_sync_BinarySemaphore* semaphore);
+void pw_sync_BinarySemaphore_CallAcquire(pw_sync_BinarySemaphore* semaphore);
+bool pw_sync_BinarySemaphore_CallTryAcquire(pw_sync_BinarySemaphore* semaphore);
+bool pw_sync_BinarySemaphore_CallTryAcquireFor(
+    pw_sync_BinarySemaphore* semaphore,
+    pw_chrono_SystemClock_Duration for_at_least);
+bool pw_sync_BinarySemaphore_CallTryAcquireUntil(
+    pw_sync_BinarySemaphore* semaphore,
+    pw_chrono_SystemClock_TimePoint until_at_least);
+ptrdiff_t pw_sync_BinarySemaphore_CallMax(void);
+
+}  // extern "C"
+
+// We can't control the SystemClock's period configuration, so just in case
+// duration cannot be accurately expressed in integer ticks, round the
+// duration up.
+constexpr SystemClock::duration kRoundedArbitraryDuration =
+    SystemClock::for_at_least(42ms);
+constexpr pw_chrono_SystemClock_Duration kRoundedArbitraryDurationInC =
+    PW_SYSTEM_CLOCK_MS(42);
+
+TEST(BinarySemaphore, EmptyInitialState) {
+  BinarySemaphore semaphore;
+  EXPECT_FALSE(semaphore.try_acquire());
+}
+
+// TODO(pwbug/291): Add real concurrency tests once we have pw::thread.
+
+TEST(BinarySemaphore, Release) {
+  BinarySemaphore semaphore;
+  semaphore.release();
+  semaphore.release();
+  semaphore.acquire();
+  // Ensure it fails when empty.
+  EXPECT_FALSE(semaphore.try_acquire());
+}
+
+BinarySemaphore empty_initial_semaphore;
+TEST(BinarySemaphore, EmptyInitialStateStatic) {
+  EXPECT_FALSE(empty_initial_semaphore.try_acquire());
+}
+
+BinarySemaphore release_semaphore;
+TEST(BinarySemaphore, ReleaseStatic) {
+  release_semaphore.release();
+  release_semaphore.release();
+  release_semaphore.acquire();
+  // Ensure it fails when empty.
+  EXPECT_FALSE(release_semaphore.try_acquire());
+}
+
+TEST(BinarySemaphore, TryAcquireFor) {
+  BinarySemaphore semaphore;
+  semaphore.release();
+
+  SystemClock::time_point before = SystemClock::now();
+  EXPECT_TRUE(semaphore.try_acquire_for(kRoundedArbitraryDuration));
+  SystemClock::duration time_elapsed = SystemClock::now() - before;
+  EXPECT_LT(time_elapsed, kRoundedArbitraryDuration);
+
+  // Ensure it blocks and fails when empty.
+  before = SystemClock::now();
+  EXPECT_FALSE(semaphore.try_acquire_for(kRoundedArbitraryDuration));
+  time_elapsed = SystemClock::now() - before;
+  EXPECT_GE(time_elapsed, kRoundedArbitraryDuration);
+}
+
+TEST(BinarySemaphore, TryAcquireUntil) {
+  BinarySemaphore semaphore;
+  semaphore.release();
+
+  const SystemClock::time_point deadline =
+      SystemClock::now() + kRoundedArbitraryDuration;
+  EXPECT_TRUE(semaphore.try_acquire_until(deadline));
+  EXPECT_LT(SystemClock::now(), deadline);
+
+  // Ensure it blocks and fails when empty.
+  EXPECT_FALSE(semaphore.try_acquire_until(deadline));
+  EXPECT_GE(SystemClock::now(), deadline);
+}
+
+TEST(BinarySemaphore, EmptyInitialStateInC) {
+  BinarySemaphore semaphore;
+  EXPECT_FALSE(pw_sync_BinarySemaphore_CallTryAcquire(&semaphore));
+}
+
+TEST(BinarySemaphore, ReleaseInC) {
+  BinarySemaphore semaphore;
+  pw_sync_BinarySemaphore_CallRelease(&semaphore);
+  pw_sync_BinarySemaphore_CallRelease(&semaphore);
+  pw_sync_BinarySemaphore_CallAcquire(&semaphore);
+  // Ensure it fails when empty.
+  EXPECT_FALSE(pw_sync_BinarySemaphore_CallTryAcquire(&semaphore));
+}
+
+TEST(BinarySemaphore, TryAcquireForInC) {
+  BinarySemaphore semaphore;
+  pw_sync_BinarySemaphore_CallRelease(&semaphore);
+
+  pw_chrono_SystemClock_TimePoint before = pw_chrono_SystemClock_Now();
+  ASSERT_TRUE(pw_sync_BinarySemaphore_CallTryAcquireFor(
+      &semaphore, kRoundedArbitraryDurationInC));
+  pw_chrono_SystemClock_Duration time_elapsed =
+      pw_chrono_SystemClock_TimeElapsed(before, pw_chrono_SystemClock_Now());
+  EXPECT_LT(time_elapsed.ticks, kRoundedArbitraryDurationInC.ticks);
+
+  // Ensure it blocks and fails when empty.
+  before = pw_chrono_SystemClock_Now();
+  EXPECT_FALSE(pw_sync_BinarySemaphore_CallTryAcquireFor(
+      &semaphore, kRoundedArbitraryDurationInC));
+  time_elapsed =
+      pw_chrono_SystemClock_TimeElapsed(before, pw_chrono_SystemClock_Now());
+  EXPECT_GE(time_elapsed.ticks, kRoundedArbitraryDurationInC.ticks);
+}
+
+TEST(BinarySemaphore, TryAcquireUntilInC) {
+  BinarySemaphore semaphore;
+  pw_sync_BinarySemaphore_CallRelease(&semaphore);
+
+  pw_chrono_SystemClock_TimePoint deadline;
+  deadline.duration_since_epoch = {
+      .ticks = pw_chrono_SystemClock_Now().duration_since_epoch.ticks +
+               kRoundedArbitraryDurationInC.ticks,
+  };
+  ASSERT_TRUE(
+      pw_sync_BinarySemaphore_CallTryAcquireUntil(&semaphore, deadline));
+  EXPECT_LT(pw_chrono_SystemClock_Now().duration_since_epoch.ticks,
+            deadline.duration_since_epoch.ticks);
+
+  // Ensure it blocks and fails when empty.
+  EXPECT_FALSE(
+      pw_sync_BinarySemaphore_CallTryAcquireUntil(&semaphore, deadline));
+  EXPECT_GE(pw_chrono_SystemClock_Now().duration_since_epoch.ticks,
+            deadline.duration_since_epoch.ticks);
+}
+
+TEST(BinarySemaphore, MaxInC) {
+  EXPECT_EQ(BinarySemaphore::max(), pw_sync_BinarySemaphore_Max());
+}
+
+}  // namespace
+}  // namespace pw::sync
diff --git a/pw_sync/binary_semaphore_facade_test_c.c b/pw_sync/binary_semaphore_facade_test_c.c
new file mode 100644
index 0000000..22dc7f6
--- /dev/null
+++ b/pw_sync/binary_semaphore_facade_test_c.c
@@ -0,0 +1,49 @@
+// 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.
+
+// These tests call the pw_sync module counting_semaphore API from C. The return
+// values are checked in the main C++ tests.
+
+#include <stdbool.h>
+
+#include "pw_sync/binary_semaphore.h"
+
+void pw_sync_BinarySemaphore_CallRelease(pw_sync_BinarySemaphore* semaphore) {
+  pw_sync_BinarySemaphore_Release(semaphore);
+}
+
+void pw_sync_BinarySemaphore_CallAcquire(pw_sync_BinarySemaphore* semaphore) {
+  pw_sync_BinarySemaphore_Acquire(semaphore);
+}
+
+bool pw_sync_BinarySemaphore_CallTryAcquire(
+    pw_sync_BinarySemaphore* semaphore) {
+  return pw_sync_BinarySemaphore_TryAcquire(semaphore);
+}
+
+bool pw_sync_BinarySemaphore_CallTryAcquireFor(
+    pw_sync_BinarySemaphore* semaphore,
+    pw_chrono_SystemClock_Duration for_at_least) {
+  return pw_sync_BinarySemaphore_TryAcquireFor(semaphore, for_at_least);
+}
+
+bool pw_sync_BinarySemaphore_CallTryAcquireUntil(
+    pw_sync_BinarySemaphore* semaphore,
+    pw_chrono_SystemClock_TimePoint until_at_least) {
+  return pw_sync_BinarySemaphore_TryAcquireUntil(semaphore, until_at_least);
+}
+
+ptrdiff_t pw_sync_BinarySemaphore_CallMax(void) {
+  return pw_sync_BinarySemaphore_Max();
+}
diff --git a/pw_sync/counting_semaphore.cc b/pw_sync/counting_semaphore.cc
new file mode 100644
index 0000000..1b7ad3f
--- /dev/null
+++ b/pw_sync/counting_semaphore.cc
@@ -0,0 +1,54 @@
+// 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.
+
+#include "pw_sync/counting_semaphore.h"
+
+using pw::chrono::SystemClock;
+
+extern "C" void pw_sync_CountingSemaphore_Release(
+    pw_sync_CountingSemaphore* semaphore) {
+  semaphore->release();
+}
+
+extern "C" void pw_sync_CountingSemaphore_ReleaseNum(
+    pw_sync_CountingSemaphore* semaphore, ptrdiff_t update) {
+  semaphore->release(update);
+}
+
+extern "C" void pw_sync_CountingSemaphore_Acquire(
+    pw_sync_CountingSemaphore* semaphore) {
+  semaphore->acquire();
+}
+
+extern "C" bool pw_sync_CountingSemaphore_TryAcquire(
+    pw_sync_CountingSemaphore* semaphore) {
+  return semaphore->try_acquire();
+}
+
+extern "C" bool pw_sync_CountingSemaphore_TryAcquireFor(
+    pw_sync_CountingSemaphore* semaphore,
+    pw_chrono_SystemClock_Duration for_at_least) {
+  return semaphore->try_acquire_for(SystemClock::duration(for_at_least.ticks));
+}
+
+extern "C" bool pw_sync_CountingSemaphore_TryAcquireUntil(
+    pw_sync_CountingSemaphore* semaphore,
+    pw_chrono_SystemClock_TimePoint until_at_least) {
+  return semaphore->try_acquire_until(SystemClock::time_point(
+      SystemClock::duration(until_at_least.duration_since_epoch.ticks)));
+}
+
+extern "C" ptrdiff_t pw_sync_CountingSemaphore_Max(void) {
+  return pw::sync::CountingSemaphore::max();
+}
diff --git a/pw_sync/counting_semaphore_facade_test.cc b/pw_sync/counting_semaphore_facade_test.cc
new file mode 100644
index 0000000..d086a90
--- /dev/null
+++ b/pw_sync/counting_semaphore_facade_test.cc
@@ -0,0 +1,202 @@
+// 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.
+
+#include <chrono>
+
+#include "gtest/gtest.h"
+#include "pw_chrono/system_clock.h"
+#include "pw_sync/counting_semaphore.h"
+
+using pw::chrono::SystemClock;
+using namespace std::chrono_literals;
+
+namespace pw::sync {
+namespace {
+
+extern "C" {
+
+// Functions defined in counting_semaphore_facade_test_c.c which call the API
+// from C.
+void pw_sync_CountingSemaphore_CallRelease(
+    pw_sync_CountingSemaphore* semaphore);
+void pw_sync_CountingSemaphore_CallReleaseNum(
+    pw_sync_CountingSemaphore* semaphore, ptrdiff_t update);
+void pw_sync_CountingSemaphore_CallAcquire(
+    pw_sync_CountingSemaphore* semaphore);
+bool pw_sync_CountingSemaphore_CallTryAcquire(
+    pw_sync_CountingSemaphore* semaphore);
+bool pw_sync_CountingSemaphore_CallTryAcquireFor(
+    pw_sync_CountingSemaphore* semaphore,
+    pw_chrono_SystemClock_Duration for_at_least);
+bool pw_sync_CountingSemaphore_CallTryAcquireUntil(
+    pw_sync_CountingSemaphore* semaphore,
+    pw_chrono_SystemClock_TimePoint until_at_least);
+ptrdiff_t pw_sync_CountingSemaphore_CallMax(void);
+
+}  // extern "C"
+
+// We can't control the SystemClock's period configuration, so just in case
+// duration cannot be accurately expressed in integer ticks, round the
+// duration up.
+constexpr SystemClock::duration kRoundedArbitraryDuration =
+    SystemClock::for_at_least(42ms);
+constexpr pw_chrono_SystemClock_Duration kRoundedArbitraryDurationInC =
+    PW_SYSTEM_CLOCK_MS(42);
+
+TEST(CountingSemaphore, EmptyInitialState) {
+  CountingSemaphore semaphore;
+  EXPECT_FALSE(semaphore.try_acquire());
+}
+
+// TODO(pwbug/291): Add real concurrency tests once we have pw::thread.
+
+TEST(CountingSemaphore, SingleRelease) {
+  CountingSemaphore semaphore;
+  semaphore.release();
+  semaphore.release();
+  semaphore.acquire();
+  semaphore.acquire();
+  // Ensure it fails when empty.
+  EXPECT_FALSE(semaphore.try_acquire());
+}
+
+CountingSemaphore empty_initial_semaphore;
+TEST(CountingSemaphore, EmptyInitialStateStatic) {
+  EXPECT_FALSE(empty_initial_semaphore.try_acquire());
+}
+
+CountingSemaphore release_semaphore;
+TEST(CountingSemaphore, ReleaseStatic) {
+  release_semaphore.release();
+  release_semaphore.release();
+  release_semaphore.acquire();
+  release_semaphore.acquire();
+  // Ensure it fails when empty.
+  EXPECT_FALSE(release_semaphore.try_acquire());
+}
+
+TEST(CountingSemaphore, MultiRelease) {
+  CountingSemaphore semaphore;
+  semaphore.release(2);
+  semaphore.release(1);
+  semaphore.acquire();
+  semaphore.acquire();
+  semaphore.acquire();
+  // Ensure it fails when empty.
+  EXPECT_FALSE(semaphore.try_acquire());
+}
+
+TEST(CountingSemaphore, TryAcquireFor) {
+  CountingSemaphore semaphore;
+  semaphore.release();
+
+  SystemClock::time_point before = SystemClock::now();
+  EXPECT_TRUE(semaphore.try_acquire_for(kRoundedArbitraryDuration));
+  SystemClock::duration time_elapsed = SystemClock::now() - before;
+  EXPECT_LT(time_elapsed, kRoundedArbitraryDuration);
+
+  // Ensure it blocks and fails when empty.
+  before = SystemClock::now();
+  EXPECT_FALSE(semaphore.try_acquire_for(kRoundedArbitraryDuration));
+  time_elapsed = SystemClock::now() - before;
+  EXPECT_GE(time_elapsed, kRoundedArbitraryDuration);
+}
+
+TEST(CountingSemaphore, TryAcquireUntil) {
+  CountingSemaphore semaphore;
+  semaphore.release();
+
+  const SystemClock::time_point deadline =
+      SystemClock::now() + kRoundedArbitraryDuration;
+  EXPECT_TRUE(semaphore.try_acquire_until(deadline));
+  EXPECT_LT(SystemClock::now(), deadline);
+
+  // Ensure it blocks and fails when empty.
+  EXPECT_FALSE(semaphore.try_acquire_until(deadline));
+  EXPECT_GE(SystemClock::now(), deadline);
+}
+
+TEST(CountingSemaphore, EmptyInitialStateInC) {
+  CountingSemaphore semaphore;
+  EXPECT_FALSE(pw_sync_CountingSemaphore_CallTryAcquire(&semaphore));
+}
+
+TEST(CountingSemaphore, SingeReleaseInC) {
+  CountingSemaphore semaphore;
+  pw_sync_CountingSemaphore_CallRelease(&semaphore);
+  pw_sync_CountingSemaphore_CallRelease(&semaphore);
+  pw_sync_CountingSemaphore_CallAcquire(&semaphore);
+  pw_sync_CountingSemaphore_CallAcquire(&semaphore);
+  // Ensure it fails when empty.
+  EXPECT_FALSE(pw_sync_CountingSemaphore_CallTryAcquire(&semaphore));
+}
+
+TEST(CountingSemaphore, MultiReleaseInC) {
+  CountingSemaphore semaphore;
+  pw_sync_CountingSemaphore_CallReleaseNum(&semaphore, 2);
+  pw_sync_CountingSemaphore_CallReleaseNum(&semaphore, 1);
+  pw_sync_CountingSemaphore_CallAcquire(&semaphore);
+  pw_sync_CountingSemaphore_CallAcquire(&semaphore);
+  pw_sync_CountingSemaphore_CallAcquire(&semaphore);
+  // Ensure it fails when empty.
+  EXPECT_FALSE(pw_sync_CountingSemaphore_CallTryAcquire(&semaphore));
+}
+
+TEST(CountingSemaphore, TryAcquireForInC) {
+  CountingSemaphore semaphore;
+  pw_sync_CountingSemaphore_CallRelease(&semaphore);
+
+  pw_chrono_SystemClock_TimePoint before = pw_chrono_SystemClock_Now();
+  ASSERT_TRUE(pw_sync_CountingSemaphore_CallTryAcquireFor(
+      &semaphore, kRoundedArbitraryDurationInC));
+  pw_chrono_SystemClock_Duration time_elapsed =
+      pw_chrono_SystemClock_TimeElapsed(before, pw_chrono_SystemClock_Now());
+  EXPECT_LT(time_elapsed.ticks, kRoundedArbitraryDurationInC.ticks);
+
+  // Ensure it blocks and fails when empty.
+  before = pw_chrono_SystemClock_Now();
+  EXPECT_FALSE(pw_sync_CountingSemaphore_CallTryAcquireFor(
+      &semaphore, kRoundedArbitraryDurationInC));
+  time_elapsed =
+      pw_chrono_SystemClock_TimeElapsed(before, pw_chrono_SystemClock_Now());
+  EXPECT_GE(time_elapsed.ticks, kRoundedArbitraryDurationInC.ticks);
+}
+
+TEST(CountingSemaphore, TryAcquireUntilInC) {
+  CountingSemaphore semaphore;
+  pw_sync_CountingSemaphore_CallRelease(&semaphore);
+
+  pw_chrono_SystemClock_TimePoint deadline;
+  deadline.duration_since_epoch = {
+      .ticks = pw_chrono_SystemClock_Now().duration_since_epoch.ticks +
+               kRoundedArbitraryDurationInC.ticks,
+  };
+  ASSERT_TRUE(
+      pw_sync_CountingSemaphore_CallTryAcquireUntil(&semaphore, deadline));
+  EXPECT_LT(pw_chrono_SystemClock_Now().duration_since_epoch.ticks,
+            deadline.duration_since_epoch.ticks);
+
+  // Ensure it blocks and fails when empty.
+  EXPECT_FALSE(
+      pw_sync_CountingSemaphore_CallTryAcquireUntil(&semaphore, deadline));
+  EXPECT_GE(pw_chrono_SystemClock_Now().duration_since_epoch.ticks,
+            deadline.duration_since_epoch.ticks);
+}
+
+TEST(CountingSemaphore, MaxInC) {
+  EXPECT_EQ(CountingSemaphore::max(), pw_sync_CountingSemaphore_Max());
+}
+
+}  // namespace
+}  // namespace pw::sync
diff --git a/pw_sync/counting_semaphore_facade_test_c.c b/pw_sync/counting_semaphore_facade_test_c.c
new file mode 100644
index 0000000..93db6d1
--- /dev/null
+++ b/pw_sync/counting_semaphore_facade_test_c.c
@@ -0,0 +1,56 @@
+// 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.
+
+// These tests call the pw_sync module counting_semaphore API from C. The return
+// values are checked in the main C++ tests.
+
+#include <stdbool.h>
+
+#include "pw_sync/counting_semaphore.h"
+
+void pw_sync_CountingSemaphore_CallRelease(
+    pw_sync_CountingSemaphore* semaphore) {
+  pw_sync_CountingSemaphore_Release(semaphore);
+}
+
+void pw_sync_CountingSemaphore_CallReleaseNum(
+    pw_sync_CountingSemaphore* semaphore, ptrdiff_t update) {
+  pw_sync_CountingSemaphore_ReleaseNum(semaphore, update);
+}
+
+void pw_sync_CountingSemaphore_CallAcquire(
+    pw_sync_CountingSemaphore* semaphore) {
+  pw_sync_CountingSemaphore_Acquire(semaphore);
+}
+
+bool pw_sync_CountingSemaphore_CallTryAcquire(
+    pw_sync_CountingSemaphore* semaphore) {
+  return pw_sync_CountingSemaphore_TryAcquire(semaphore);
+}
+
+bool pw_sync_CountingSemaphore_CallTryAcquireFor(
+    pw_sync_CountingSemaphore* semaphore,
+    pw_chrono_SystemClock_Duration for_at_least) {
+  return pw_sync_CountingSemaphore_TryAcquireFor(semaphore, for_at_least);
+}
+
+bool pw_sync_CountingSemaphore_CallTryAcquireUntil(
+    pw_sync_CountingSemaphore* semaphore,
+    pw_chrono_SystemClock_TimePoint until_at_least) {
+  return pw_sync_CountingSemaphore_TryAcquireUntil(semaphore, until_at_least);
+}
+
+ptrdiff_t pw_sync_CountingSemaphore_CallMax(void) {
+  return pw_sync_CountingSemaphore_Max();
+}
diff --git a/pw_sync/docs.rst b/pw_sync/docs.rst
new file mode 100644
index 0000000..f2959c8
--- /dev/null
+++ b/pw_sync/docs.rst
@@ -0,0 +1,1058 @@
+.. _module-pw_sync:
+
+=======
+pw_sync
+=======
+The ``pw_sync`` module contains utilities for synchronizing between threads
+and/or interrupts through signaling primitives and critical section lock
+primitives.
+
+.. contents::
+   :local:
+   :depth: 2
+
+.. Warning::
+  This module is still under construction, the API is not yet stable.
+
+.. Note::
+  The objects in this module do not have an Init() style public API which is
+  common in many RTOS C APIs. Instead, they rely on being able to invoke the
+  native initialization APIs for synchronization primitives during C++
+  construction.
+  In order to support global statically constructed synchronization without
+  constexpr constructors, the user and/or backend **MUST** ensure that any
+  initialization required in your environment is done prior to the creation
+  and/or initialization of the native synchronization primitives
+  (e.g. kernel initialization).
+
+--------------------------------
+Critical Section Lock Primitives
+--------------------------------
+The critical section lock primitives provided by this module comply with
+`BasicLockable <https://en.cppreference.com/w/cpp/named_req/BasicLockable>`_,
+`Lockable <https://en.cppreference.com/w/cpp/named_req/Lockable>`_, and where
+relevant
+`TimedLockable <https://en.cppreference.com/w/cpp/named_req/TimedLockable>`_ C++
+named requirements. This means that they are compatible with existing helpers in
+the STL's ``<mutex>`` thread support library. For example `std::lock_guard <https://en.cppreference.com/w/cpp/thread/lock_guard>`_
+and `std::unique_lock <https://en.cppreference.com/w/cpp/thread/unique_lock>`_ can be directly used.
+
+Mutex
+=====
+The Mutex is a synchronization primitive that can be used to protect shared data
+from being simultaneously accessed by multiple threads. It offers exclusive,
+non-recursive ownership semantics where priority inheritance is used to solve
+the classic priority-inversion problem.
+
+The Mutex's API is C++11 STL
+`std::mutex <https://en.cppreference.com/w/cpp/thread/mutex>`_ like,
+meaning it is a
+`BasicLockable <https://en.cppreference.com/w/cpp/named_req/BasicLockable>`_
+and `Lockable <https://en.cppreference.com/w/cpp/named_req/Lockable>`_.
+
+.. list-table::
+
+  * - *Supported on*
+    - *Backend module*
+  * - FreeRTOS
+    - :ref:`module-pw_sync_freertos`
+  * - ThreadX
+    - :ref:`module-pw_sync_threadx`
+  * - embOS
+    - :ref:`module-pw_sync_embos`
+  * - STL
+    - :ref:`module-pw_sync_stl`
+  * - Baremetal
+    - Planned
+  * - Zephyr
+    - Planned
+  * - CMSIS-RTOS API v2 & RTX5
+    - Planned
+
+C++
+---
+.. cpp:class:: pw::sync::Mutex
+
+  .. cpp:function:: void lock()
+
+     Locks the mutex, blocking indefinitely. Failures are fatal.
+
+     **Precondition:** The lock isn't already held by this thread. Recursive
+     locking is undefined behavior.
+
+  .. cpp:function:: bool try_lock()
+
+     Attempts to lock the mutex in a non-blocking manner.
+     Returns true if the mutex was successfully acquired.
+
+     **Precondition:** The lock isn't already held by this thread. Recursive
+     locking is undefined behavior.
+
+  .. cpp:function:: void unlock()
+
+     Unlocks the mutex. Failures are fatal.
+
+     **Precondition:** The mutex is held by this thread.
+
+
+  .. list-table::
+
+    * - *Safe to use in context*
+      - *Thread*
+      - *Interrupt*
+      - *NMI*
+    * - ``Mutex::Mutex``
+      - ✔
+      -
+      -
+    * - ``Mutex::~Mutex``
+      - ✔
+      -
+      -
+    * - ``void Mutex::lock``
+      - ✔
+      -
+      -
+    * - ``bool Mutex::try_lock``
+      - ✔
+      -
+      -
+    * - ``void Mutex::unlock``
+      - ✔
+      -
+      -
+
+Examples in C++
+^^^^^^^^^^^^^^^
+.. code-block:: cpp
+
+  #include "pw_sync/mutex.h"
+
+  pw::sync::Mutex mutex;
+
+  void ThreadSafeCriticalSection() {
+    mutex.lock();
+    NotThreadSafeCriticalSection();
+    mutex.unlock();
+  }
+
+
+Alternatively you can use C++'s RAII helpers to ensure you always unlock.
+
+.. code-block:: cpp
+
+  #include <mutex>
+
+  #include "pw_sync/mutex.h"
+
+  pw::sync::Mutex mutex;
+
+  void ThreadSafeCriticalSection() {
+    std::lock_guard lock(mutex);
+    NotThreadSafeCriticalSection();
+  }
+
+
+C
+-
+The Mutex must be created in C++, however it can be passed into C using the
+``pw_sync_Mutex`` opaque struct alias.
+
+.. cpp:function:: void pw_sync_Mutex_Lock(pw_sync_Mutex* mutex)
+
+  Invokes the ``Mutex::lock`` member function on the given ``mutex``.
+
+.. cpp:function:: bool pw_sync_Mutex_TryLock(pw_sync_Mutex* mutex)
+
+  Invokes the ``Mutex::try_lock`` member function on the given ``mutex``.
+
+.. cpp:function:: void pw_sync_Mutex_Unlock(pw_sync_Mutex* mutex)
+
+  Invokes the ``Mutex::unlock`` member function on the given ``mutex``.
+
+.. list-table::
+
+  * - *Safe to use in context*
+    - *Thread*
+    - *Interrupt*
+    - *NMI*
+  * - ``void pw_sync_Mutex_Lock``
+    - ✔
+    -
+    -
+  * - ``bool pw_sync_Mutex_TryLock``
+    - ✔
+    -
+    -
+  * - ``void pw_sync_Mutex_Unlock``
+    - ✔
+    -
+    -
+
+Example in C
+^^^^^^^^^^^^
+.. code-block:: cpp
+
+  #include "pw_sync/mutex.h"
+
+  pw::sync::Mutex mutex;
+
+  extern pw_sync_Mutex mutex;  // This can only be created in C++.
+
+  void ThreadSafeCriticalSection(void) {
+    pw_sync_Mutex_Lock(&mutex);
+    NotThreadSafeCriticalSection();
+    pw_sync_Mutex_Unlock(&mutex);
+  }
+
+TimedMutex
+==========
+The TimedMutex is an extension of the Mutex which offers timeout and deadline
+based semantics.
+
+The TimedMutex's API is C++11 STL
+`std::timed_mutex <https://en.cppreference.com/w/cpp/thread/timed_mutex>`_ like,
+meaning it is a
+`BasicLockable <https://en.cppreference.com/w/cpp/named_req/BasicLockable>`_,
+`Lockable <https://en.cppreference.com/w/cpp/named_req/Lockable>`_, and
+`TimedLockable <https://en.cppreference.com/w/cpp/named_req/TimedLockable>`_.
+
+Note that the ``TimedMutex`` is a derived ``Mutex`` class, meaning that
+a ``TimedMutex`` can be used by someone who needs the basic ``Mutex``. This is
+in stark contrast to the C++ STL's
+`std::timed_mutex <https://en.cppreference.com/w/cpp/thread/timed_mutex>`_.
+
+
+.. list-table::
+
+  * - *Supported on*
+    - *Backend module*
+  * - FreeRTOS
+    - :ref:`module-pw_sync_freertos`
+  * - ThreadX
+    - :ref:`module-pw_sync_threadx`
+  * - embOS
+    - :ref:`module-pw_sync_embos`
+  * - STL
+    - :ref:`module-pw_sync_stl`
+  * - Zephyr
+    - Planned
+  * - CMSIS-RTOS API v2 & RTX5
+    - Planned
+
+C++
+---
+.. cpp:class:: pw::sync::TimedMutex
+
+  .. cpp:function:: void lock()
+
+     Locks the mutex, blocking indefinitely. Failures are fatal.
+
+     **Precondition:** The lock isn't already held by this thread. Recursive
+     locking is undefined behavior.
+
+  .. cpp:function:: bool try_lock()
+
+     Attempts to lock the mutex in a non-blocking manner.
+     Returns true if the mutex was successfully acquired.
+
+     **Precondition:** The lock isn't already held by this thread. Recursive
+     locking is undefined behavior.
+
+  .. cpp:function:: bool try_lock_for(chrono::SystemClock::duration for_at_least)
+
+     Attempts to lock the mutex where, if needed, blocking for at least the
+     specified duration.
+     Returns true if the mutex was successfully acquired.
+
+     **Precondition:** The lock isn't already held by this thread. Recursive
+     locking is undefined behavior.
+
+  .. cpp:function:: bool try_lock_until(chrono::SystemClock::time_point until_at_least)
+
+     Attempts to lock the mutex where, if needed, blocking until at least the
+     specified time_point.
+     Returns true if the mutex was successfully acquired.
+
+     **Precondition:** The lock isn't already held by this thread. Recursive
+     locking is undefined behavior.
+
+  .. cpp:function:: void unlock()
+
+     Unlocks the mutex. Failures are fatal.
+
+     **Precondition:** The mutex is held by this thread.
+
+
+  .. list-table::
+
+    * - *Safe to use in context*
+      - *Thread*
+      - *Interrupt*
+      - *NMI*
+    * - ``TimedMutex::TimedMutex``
+      - ✔
+      -
+      -
+    * - ``TimedMutex::~TimedMutex``
+      - ✔
+      -
+      -
+    * - ``void TimedMutex::lock``
+      - ✔
+      -
+      -
+    * - ``bool TimedMutex::try_lock``
+      - ✔
+      -
+      -
+    * - ``bool TimedMutex::try_lock_for``
+      - ✔
+      -
+      -
+    * - ``bool TimedMutex::try_lock_until``
+      - ✔
+      -
+      -
+    * - ``void TimedMutex::unlock``
+      - ✔
+      -
+      -
+
+Examples in C++
+^^^^^^^^^^^^^^^
+.. code-block:: cpp
+
+  #include "pw_chrono/system_clock.h"
+  #include "pw_sync/timed_mutex.h"
+
+  pw::sync::TimedMutex mutex;
+
+  bool ThreadSafeCriticalSectionWithTimeout(
+      const SystemClock::duration timeout) {
+    if (!mutex.try_lock_for(timeout)) {
+      return false;
+    }
+    NotThreadSafeCriticalSection();
+    mutex.unlock();
+    return true;
+  }
+
+
+Alternatively you can use C++'s RAII helpers to ensure you always unlock.
+
+.. code-block:: cpp
+
+  #include <mutex>
+
+  #include "pw_chrono/system_clock.h"
+  #include "pw_sync/timed_mutex.h"
+
+  pw::sync::TimedMutex mutex;
+
+  bool ThreadSafeCriticalSectionWithTimeout(
+      const SystemClock::duration timeout) {
+    std::unique_lock lock(mutex, std::defer_lock);
+    if (!lock.try_lock_for(timeout)) {
+      return false;
+    }
+    NotThreadSafeCriticalSection();
+    return true;
+  }
+
+
+
+C
+-
+The TimedMutex must be created in C++, however it can be passed into C using the
+``pw_sync_TimedMutex`` opaque struct alias.
+
+.. cpp:function:: void pw_sync_TimedMutex_Lock(pw_sync_TimedMutex* mutex)
+
+  Invokes the ``TimedMutex::lock`` member function on the given ``mutex``.
+
+.. cpp:function:: bool pw_sync_TimedMutex_TryLock(pw_sync_TimedMutex* mutex)
+
+  Invokes the ``TimedMutex::try_lock`` member function on the given ``mutex``.
+
+.. cpp:function:: bool pw_sync_TimedMutex_TryLockFor(pw_sync_TimedMutex* mutex, pw_chrono_SystemClock_Duration for_at_least)
+
+  Invokes the ``TimedMutex::try_lock_for`` member function on the given ``mutex``.
+
+.. cpp:function:: bool pw_sync_TimedMutex_TryLockUntil(pw_sync_TimedMutex* mutex, pw_chrono_SystemClock_TimePoint until_at_least)
+
+  Invokes the ``TimedMutex::try_lock_until`` member function on the given ``mutex``.
+
+.. cpp:function:: void pw_sync_TimedMutex_Unlock(pw_sync_TimedMutex* mutex)
+
+  Invokes the ``TimedMutex::unlock`` member function on the given ``mutex``.
+
+.. list-table::
+
+  * - *Safe to use in context*
+    - *Thread*
+    - *Interrupt*
+    - *NMI*
+  * - ``void pw_sync_TimedMutex_Lock``
+    - ✔
+    -
+    -
+  * - ``bool pw_sync_TimedMutex_TryLock``
+    - ✔
+    -
+    -
+  * - ``bool pw_sync_TimedMutex_TryLockFor``
+    - ✔
+    -
+    -
+  * - ``bool pw_sync_TimedMutex_TryLockUntil``
+    - ✔
+    -
+    -
+  * - ``void pw_sync_TimedMutex_Unlock``
+    - ✔
+    -
+    -
+
+Example in C
+^^^^^^^^^^^^
+.. code-block:: cpp
+
+  #include "pw_chrono/system_clock.h"
+  #include "pw_sync/timed_mutex.h"
+
+  pw::sync::TimedMutex mutex;
+
+  extern pw_sync_TimedMutex mutex;  // This can only be created in C++.
+
+  bool ThreadSafeCriticalSectionWithTimeout(
+      const pw_chrono_SystemClock_Duration timeout) {
+    if (!pw_sync_TimedMutex_TryLockFor(&mutex, timeout)) {
+      return false;
+    }
+    NotThreadSafeCriticalSection();
+    pw_sync_TimedMutex_Unlock(&mutex);
+    return true;
+  }
+
+
+InterruptSpinLock
+=================
+The InterruptSpinLock is a synchronization primitive that can be used to protect
+shared data from being simultaneously accessed by multiple threads and/or
+interrupts as a targeted global lock, with the exception of Non-Maskable
+Interrupts (NMIs). It offers exclusive, non-recursive ownership semantics where
+IRQs up to a backend defined level of "NMIs" will be masked to solve
+priority-inversion.
+
+This InterruptSpinLock relies on built-in local interrupt masking to make it
+interrupt safe without requiring the caller to separately mask and unmask
+interrupts when using this primitive.
+
+Unlike global interrupt locks, this also works safely and efficiently on SMP
+systems. On systems which are not SMP, spinning is not required but some state
+may still be used to detect recursion.
+
+The InterruptSpinLock is a
+`BasicLockable <https://en.cppreference.com/w/cpp/named_req/BasicLockable>`_
+and
+`Lockable <https://en.cppreference.com/w/cpp/named_req/Lockable>`_.
+
+.. list-table::
+
+  * - *Supported on*
+    - *Backend module*
+  * - FreeRTOS
+    - :ref:`module-pw_sync_freertos`
+  * - ThreadX
+    - :ref:`module-pw_sync_threadx`
+  * - embOS
+    - :ref:`module-pw_sync_embos`
+  * - STL
+    - :ref:`module-pw_sync_stl`
+  * - Baremetal
+    - Planned, not ready for use
+  * - Zephyr
+    - Planned
+  * - CMSIS-RTOS API v2 & RTX5
+    - Planned
+
+C++
+---
+.. cpp:class:: pw::sync::InterruptSpinLock
+
+  .. cpp:function:: void lock()
+
+      Locks the spinlock, blocking indefinitely. Failures are fatal.
+
+      **Precondition:** Recursive locking is undefined behavior.
+
+  .. cpp:function:: bool try_lock()
+
+      Attempts to lock the spinlock in a non-blocking manner.
+      Returns true if the spinlock was successfully acquired.
+
+      **Precondition:** Recursive locking is undefined behavior.
+
+  .. cpp:function:: void unlock()
+
+     Unlocks the mutex. Failures are fatal.
+
+     **Precondition:** The spinlock is held by the caller.
+
+  .. list-table::
+
+    * - *Safe to use in context*
+      - *Thread*
+      - *Interrupt*
+      - *NMI*
+    * - ``InterruptSpinLock::InterruptSpinLock``
+      - ✔
+      - ✔
+      -
+    * - ``InterruptSpinLock::~InterruptSpinLock``
+      - ✔
+      - ✔
+      -
+    * - ``void InterruptSpinLock::lock``
+      - ✔
+      - ✔
+      -
+    * - ``bool InterruptSpinLock::try_lock``
+      - ✔
+      - ✔
+      -
+    * - ``void InterruptSpinLock::unlock``
+      - ✔
+      - ✔
+      -
+
+Examples in C++
+^^^^^^^^^^^^^^^
+.. code-block:: cpp
+
+  #include "pw_sync/interrupt_spin_lock.h"
+
+  pw::sync::InterruptSpinLock interrupt_spin_lock;
+
+  void InterruptSafeCriticalSection() {
+    interrupt_spin_lock.lock();
+    NotThreadSafeCriticalSection();
+    interrupt_spin_lock.unlock();
+  }
+
+
+Alternatively you can use C++'s RAII helpers to ensure you always unlock.
+
+.. code-block:: cpp
+
+  #include <mutex>
+
+  #include "pw_sync/interrupt_spin_lock.h"
+
+  pw::sync::InterruptSpinLock interrupt_spin_lock;
+
+  void InterruptSafeCriticalSection() {
+    std::lock_guard lock(interrupt_spin_lock);
+    NotThreadSafeCriticalSection();
+  }
+
+
+C
+-
+The InterruptSpinLock must be created in C++, however it can be passed into C using the
+``pw_sync_InterruptSpinLock`` opaque struct alias.
+
+.. cpp:function:: void pw_sync_InterruptSpinLock_Lock(pw_sync_InterruptSpinLock* interrupt_spin_lock)
+
+  Invokes the ``InterruptSpinLock::lock`` member function on the given ``interrupt_spin_lock``.
+
+.. cpp:function:: bool pw_sync_InterruptSpinLock_TryLock(pw_sync_InterruptSpinLock* interrupt_spin_lock)
+
+  Invokes the ``InterruptSpinLock::try_lock`` member function on the given ``interrupt_spin_lock``.
+
+.. cpp:function:: void pw_sync_InterruptSpinLock_Unlock(pw_sync_InterruptSpinLock* interrupt_spin_lock)
+
+  Invokes the ``InterruptSpinLock::unlock`` member function on the given ``interrupt_spin_lock``.
+
+.. list-table::
+
+  * - *Safe to use in context*
+    - *Thread*
+    - *Interrupt*
+    - *NMI*
+  * - ``void pw_sync_InterruptSpinLock_Lock``
+    - ✔
+    - ✔
+    -
+  * - ``bool pw_sync_InterruptSpinLock_TryLock``
+    - ✔
+    - ✔
+    -
+  * - ``void pw_sync_InterruptSpinLock_Unlock``
+    - ✔
+    - ✔
+    -
+
+Example in C
+^^^^^^^^^^^^
+.. code-block:: cpp
+
+  #include "pw_chrono/system_clock.h"
+  #include "pw_sync/interrupt_spin_lock.h"
+
+  pw::sync::InterruptSpinLock interrupt_spin_lock;
+
+  extern pw_sync_InterruptSpinLock interrupt_spin_lock;  // This can only be created in C++.
+
+  void InterruptSafeCriticalSection(void) {
+    pw_sync_InterruptSpinLock_Lock(&interrupt_spin_lock);
+    NotThreadSafeCriticalSection();
+    pw_sync_InterruptSpinLock_Unlock(&interrupt_spin_lock);
+  }
+
+Thread Safety Lock Annotations
+==============================
+Pigweed's critical section lock primitives support Clang's thread safety
+analysis extension for C++. The analysis is completely static at compile-time.
+This is only supported when building with Clang. The annotations are no-ops when
+using different compilers.
+
+Pigweed provides the ``pw_sync/lock_annotations.h`` header file with macro
+definitions to allow developers to document the locking policies of
+multi-threaded code. The annotations can also help program analysis tools to
+identify potential thread safety issues.
+
+More information on Clang's thread safety analysis system can be found
+`here <https://clang.llvm.org/docs/ThreadSafetyAnalysis.html>`_.
+
+Enabling Clang's Analysis
+-------------------------
+In order to enable the analysis, Clang requires that the ``-Wthread-safety``
+compilation flag be used. In addition, if any STL components like
+``std::lock_guard`` are used, the STL's built in annotations have to be manually
+enabled, typically by setting the ``_LIBCPP_ENABLE_THREAD_SAFETY_ANNOTATIONS``
+macro.
+
+If using GN, the ``pw_build:clang_thread_safety_warnings`` config is provided
+to do this for you, when added to your clang toolchain definition's default
+configs.
+
+Why use lock annotations?
+-------------------------
+Lock annotations can help warn you about potential race conditions in your code
+when using locks: you have to remember to grab lock(s) before entering a
+critical section, yuou have to remember to unlock it when you leave, and you
+have to avoid deadlocks.
+
+Clang's lock annotations let you inform the compiler and anyone reading your
+code which variables are guarded by which locks, which locks should or cannot be
+held when calling which function, which order locks should be acquired in, etc.
+
+Using Lock Annotations
+----------------------
+When referring to locks in the arguments of the attributes, you should
+use variable names or more complex expressions (e.g. ``my_object->lock_``)
+that evaluate to a concrete lock object whenever possible. If the lock
+you want to refer to is not in scope, you may use a member pointer
+(e.g. ``&MyClass::lock_``) to refer to a lock in some (unknown) object.
+
+Annotating Lock Usage
+^^^^^^^^^^^^^^^^^^^^^
+.. cpp:function:: PW_GUARDED_BY(x)
+
+  Documents if a shared field or global variable needs to be protected by a
+  lock. ``PW_GUARDED_BY()`` allows the user to specify a particular lock that
+  should be held when accessing the annotated variable.
+
+  Although this annotation (and ``PW_PT_GUARDED_BY``, below) cannot be applied
+  to local variables, a local variable and its associated lock can often be
+  combined into a small class or struct, thereby allowing the annotation.
+
+  Example:
+
+  .. code-block:: cpp
+
+    class Foo {
+      Mutex mu_;
+      int p1_ PW_GUARDED_BY(mu_);
+      ...
+    };
+
+.. cpp:function:: PW_PT_GUARDED_BY(x)
+
+  Documents if the memory location pointed to by a pointer should be guarded
+  by a lock when dereferencing the pointer.
+
+  Example:
+
+  .. code-block:: cpp
+
+    class Foo {
+      Mutex mu_;
+      int *p1_ PW_PT_GUARDED_BY(mu_);
+      ...
+    };
+
+  Note that a pointer variable to a shared memory location could itself be a
+  shared variable.
+
+  Example:
+
+  .. code-block:: cpp
+
+    // `q_`, guarded by `mu1_`, points to a shared memory location that is
+    // guarded by `mu2_`:
+    int *q_ PW_GUARDED_BY(mu1_) PW_PT_GUARDED_BY(mu2_);
+
+.. cpp:function:: PW_ACQUIRED_AFTER(...)
+.. cpp:function:: PW_ACQUIRED_BEFORE(...)
+
+  Documents the acquisition order between locks that can be held
+  simultaneously by a thread. For any two locks that need to be annotated
+  to establish an acquisition order, only one of them needs the annotation.
+  (i.e. You don't have to annotate both locks with both ``PW_ACQUIRED_AFTER``
+  and ``PW_ACQUIRED_BEFORE``.)
+
+  As with ``PW_GUARDED_BY``, this is only applicable to locks that are shared
+  fields or global variables.
+
+  Example:
+
+  .. code-block:: cpp
+
+    Mutex m1_;
+    Mutex m2_ PW_ACQUIRED_AFTER(m1_);
+
+.. cpp:function:: PW_EXCLUSIVE_LOCKS_REQUIRED(...)
+.. cpp:function:: PW_SHARED_LOCKS_REQUIRED(...)
+
+  Documents a function that expects a lock to be held prior to entry.
+  The lock is expected to be held both on entry to, and exit from, the
+  function.
+
+  An exclusive lock allows read-write access to the guarded data member(s), and
+  only one thread can acquire a lock exclusively at any one time. A shared lock
+  allows read-only access, and any number of threads can acquire a shared lock
+  concurrently.
+
+  Generally, non-const methods should be annotated with
+  ``PW_EXCLUSIVE_LOCKS_REQUIRED``, while const methods should be annotated with
+  ``PW_SHARED_LOCKS_REQUIRED``.
+
+  Example:
+
+  .. code-block:: cpp
+
+    Mutex mu1, mu2;
+    int a PW_GUARDED_BY(mu1);
+    int b PW_GUARDED_BY(mu2);
+
+    void foo() PW_EXCLUSIVE_LOCKS_REQUIRED(mu1, mu2) { ... }
+    void bar() const PW_SHARED_LOCKS_REQUIRED(mu1, mu2) { ... }
+
+.. cpp:function:: PW_LOCKS_EXCLUDED(...)
+
+  Documents the locks acquired in the body of the function. These locks
+  cannot be held when calling this function (as Pigweed's default locks are
+  non-reentrant).
+
+  Example:
+
+  .. code-block:: cpp
+
+    Mutex mu;
+    int a PW_GUARDED_BY(mu);
+
+    void foo() PW_LOCKS_EXCLUDED(mu) {
+      mu.lock();
+      ...
+      mu.unlock();
+    }
+
+.. cpp:function:: PW_LOCK_RETURNED(...)
+
+  Documents a function that returns a lock without acquiring it.  For example,
+  a public getter method that returns a pointer to a private lock should
+  be annotated with ``PW_LOCK_RETURNED``.
+
+  Example:
+
+  .. code-block:: cpp
+
+    class Foo {
+     public:
+      Mutex* mu() PW_LOCK_RETURNED(mu) { return &mu; }
+
+     private:
+      Mutex mu;
+    };
+
+.. cpp:function:: PW_NO_LOCK_SAFETY_ANALYSIS()
+
+   Turns off thread safety checking within the body of a particular function.
+   This annotation is used to mark functions that are known to be correct, but
+   the locking behavior is more complicated than the analyzer can handle.
+
+Annotating Lock Objects
+^^^^^^^^^^^^^^^^^^^^^^^
+In order of lock usage annotation to work, the lock objects themselves need to
+be annotated as well. In case you are providing your own lock or psuedo-lock
+object, you can use the macros in this section to annotate it.
+
+As an example we've annotated a Lock and a RAII ScopedLocker object for you, see
+the macro documentation after for more details:
+
+.. code-block:: cpp
+
+  class PW_LOCKABLE("Lock") Lock {
+   public:
+    void Lock() PW_EXCLUSIVE_LOCK_FUNCTION();
+
+    void ReaderLock() PW_SHARED_LOCK_FUNCTION();
+
+    void Unlock() PW_UNLOCK_FUNCTION();
+
+    void ReaderUnlock() PW_SHARED_TRYLOCK_FUNCTION();
+
+    bool TryLock() PW_EXCLUSIVE_TRYLOCK_FUNCTION(true);
+
+    bool ReaderTryLock() PW_SHARED_TRYLOCK_FUNCTION(true);
+
+    void AssertHeld() PW_ASSERT_EXCLUSIVE_LOCK();
+
+    void AssertReaderHeld() PW_ASSERT_SHARED_LOCK();
+  };
+
+
+  // Tag types for selecting a constructor.
+  struct adopt_lock_t {} inline constexpr adopt_lock = {};
+  struct defer_lock_t {} inline constexpr defer_lock = {};
+  struct shared_lock_t {} inline constexpr shared_lock = {};
+
+  class PW_SCOPED_LOCKABLE ScopedLocker {
+    // Acquire lock, implicitly acquire *this and associate it with lock.
+    ScopedLocker(Lock *lock) PW_EXCLUSIVE_LOCK_FUNCTION(lock)
+        : lock_(lock), locked(true) {
+      lock->Lock();
+    }
+
+    // Assume lock is held, implicitly acquire *this and associate it with lock.
+    ScopedLocker(Lock *lock, adopt_lock_t) PW_EXCLUSIVE_LOCKS_REQUIRED(lock)
+        : lock_(lock), locked(true) {}
+
+    // Acquire lock in shared mode, implicitly acquire *this and associate it
+    // with lock.
+    ScopedLocker(Lock *lock, shared_lock_t) PW_SHARED_LOCK_FUNCTION(lock)
+        : lock_(lock), locked(true) {
+      lock->ReaderLock();
+    }
+
+    // Assume lock is held in shared mode, implicitly acquire *this and associate
+    // it with lock.
+    ScopedLocker(Lock *lock, adopt_lock_t, shared_lock_t)
+        PW_SHARED_LOCKS_REQUIRED(lock) : lock_(lock), locked(true) {}
+
+    // Assume lock is not held, implicitly acquire *this and associate it with
+    // lock.
+    ScopedLocker(Lock *lock, defer_lock_t) PW_LOCKS_EXCLUDED(lock)
+        : lock_(lock), locked(false) {}
+
+    // Release *this and all associated locks, if they are still held.
+    // There is no warning if the scope was already unlocked before.
+    ~ScopedLocker() PW_UNLOCK_FUNCTION() {
+      if (locked)
+        lock_->GenericUnlock();
+    }
+
+    // Acquire all associated locks exclusively.
+    void Lock() PW_EXCLUSIVE_LOCK_FUNCTION() {
+      lock_->Lock();
+      locked = true;
+    }
+
+    // Try to acquire all associated locks exclusively.
+    bool TryLock() PW_EXCLUSIVE_TRYLOCK_FUNCTION(true) {
+      return locked = lock_->TryLock();
+    }
+
+    // Acquire all associated locks in shared mode.
+    void ReaderLock() PW_SHARED_LOCK_FUNCTION() {
+      lock_->ReaderLock();
+      locked = true;
+    }
+
+    // Try to acquire all associated locks in shared mode.
+    bool ReaderTryLock() PW_SHARED_TRYLOCK_FUNCTION(true) {
+      return locked = lock_->ReaderTryLock();
+    }
+
+    // Release all associated locks. Warn on double unlock.
+    void Unlock() PW_UNLOCK_FUNCTION() {
+      lock_->Unlock();
+      locked = false;
+    }
+
+    // Release all associated locks. Warn on double unlock.
+    void ReaderUnlock() PW_UNLOCK_FUNCTION() {
+      lock_->ReaderUnlock();
+      locked = false;
+    }
+
+   private:
+    Lock* lock_;
+    bool locked_;
+  };
+
+.. cpp:function:: PW_LOCKABLE(name)
+
+  Documents if a class/type is a lockable type (such as the ``pw::sync::Mutex``
+  class). The name is used in the warning messages. This can also be useful on
+  classes which have locking like semantics but aren't actually locks.
+
+.. cpp:function:: PW_SCOPED_LOCKABLE()
+
+  Documents if a class does RAII locking. The name is used in the warning
+  messages.
+
+  The constructor should use ``LOCK_FUNCTION()`` to specify the lock that is
+  acquired, and the destructor should use ``UNLOCK_FUNCTION()`` with no
+  arguments; the analysis will assume that the destructor unlocks whatever the
+  constructor locked.
+
+.. cpp:function:: PW_EXCLUSIVE_LOCK_FUNCTION()
+
+  Documents functions that acquire a lock in the body of a function, and do
+  not release it.
+
+.. cpp:function:: PW_SHARED_LOCK_FUNCTION()
+
+   Documents functions that acquire a shared (reader) lock in the body of a
+   function, and do not release it.
+
+.. cpp:function:: PW_UNLOCK_FUNCTION()
+
+   Documents functions that expect a lock to be held on entry to the function,
+   and release it in the body of the function.
+
+.. cpp:function:: PW_EXCLUSIVE_TRYLOCK_FUNCTION(try_success)
+.. cpp:function:: PW_SHARED_TRYLOCK_FUNCTION(try_success)
+
+  Documents functions that try to acquire a lock, and return success or failure
+  (or a non-boolean value that can be interpreted as a boolean).
+  The first argument should be ``true`` for functions that return ``true`` on
+  success, or ``false`` for functions that return `false` on success. The second
+  argument specifies the lock that is locked on success. If unspecified, this
+  lock is assumed to be ``this``.
+
+.. cpp:function:: PW_ASSERT_EXCLUSIVE_LOCK()
+.. cpp:function:: PW_ASSERT_SHARED_LOCK()
+
+   Documents functions that dynamically check to see if a lock is held, and fail
+   if it is not held.
+
+--------------------
+Signaling Primitives
+--------------------
+
+Native signaling primitives tend to vary more compared to critial section locks
+across different platforms. For example, although common signaling primtives
+like semaphores are in most if not all RTOSes and even POSIX, it was not in the
+STL before C++20. Likewise many C++ developers are surprised that conditional
+variables tend to not be natively supported on RTOSes. Although you can usually
+build any signaling primitive based on other native signaling primitives, this
+may come with non-trivial added overhead in ROM, RAM, and execution efficiency.
+
+For this reason, Pigweed intends to provide some "simpler" signaling primitives
+which exist to solve a narrow programming need but can be implemented as
+efficiently as possible for the platform that it is used on.
+
+This simpler but highly portable class of signaling primitives is intended to
+ensure that a portability efficiency tradeoff does not have to be made up front.
+For example we intend to provide a ``pw::sync::Notification`` facade which
+permits a singler consumer to block until an event occurs. This should be
+backed by the most efficient native primitive for a target, regardless of
+whether that is a semaphore, event flag group, condition variable, or something
+else.
+
+CountingSemaphore
+=================
+The CountingSemaphore is a synchronization primitive that can be used for
+counting events and/or resource management where receiver(s) can block on
+acquire until notifier(s) signal by invoking release.
+
+Note that unlike Mutexes, priority inheritance is not used by semaphores meaning
+semaphores are subject to unbounded priority inversions. Due to this, Pigweed
+does not recommend semaphores for mutual exclusion.
+
+The CountingSemaphore is initialized to being empty or having no tokens.
+
+The entire API is thread safe, but only a subset is interrupt safe. None of it
+is NMI safe.
+
+.. Warning::
+  Releasing multiple tokens is often not natively supported, meaning you may
+  end up invoking the native kernel API many times, i.e. once per token you
+  are releasing!
+
+.. list-table::
+
+  * - *Supported on*
+    - *Backend module*
+  * - FreeRTOS
+    - :ref:`module-pw_sync_freertos`
+  * - ThreadX
+    - :ref:`module-pw_sync_threadx`
+  * - embOS
+    - :ref:`module-pw_sync_embos`
+  * - STL
+    - :ref:`module-pw_sync_stl`
+  * - Zephyr
+    - Planned
+  * - CMSIS-RTOS API v2 & RTX5
+    - Planned
+
+BinarySemaphore
+===============
+BinarySemaphore is a specialization of CountingSemaphore with an arbitrary token
+limit of 1. Note that that ``max()`` is >= 1, meaning it may be released up to
+``max()`` times but only acquired once for those N releases.
+
+Implementations of BinarySemaphore are typically more efficient than the
+default implementation of CountingSemaphore.
+
+The BinarySemaphore is initialized to being empty or having no tokens.
+
+The entire API is thread safe, but only a subset is interrupt safe. None of it
+is NMI safe.
+
+.. list-table::
+
+  * - *Supported on*
+    - *Backend module*
+  * - FreeRTOS
+    - :ref:`module-pw_sync_freertos`
+  * - ThreadX
+    - :ref:`module-pw_sync_threadx`
+  * - embOS
+    - :ref:`module-pw_sync_embos`
+  * - STL
+    - :ref:`module-pw_sync_stl`
+  * - Zephyr
+    - Planned
+  * - CMSIS-RTOS API v2 & RTX5
+    - Planned
+
+Coming Soon
+===========
+We are intending to provide facades for:
+
+* ``pw::sync::Notification``: A portable abstraction to allow threads to receive
+  notification of a single occurrence of a single event.
+
+* ``pw::sync::EventGroup`` A facade for a common primitive on RTOSes like
+  FreeRTOS, RTX5, ThreadX, and embOS which permit threads and interrupts to
+  signal up to 32 events. This permits others threads to be notified when either
+  any or some combination of these events have been signaled. This is frequently
+  used as an alternative to a set of binary semaphore(s). This is not supported
+  natively on Zephyr.
diff --git a/pw_sync/interrupt_spin_lock.cc b/pw_sync/interrupt_spin_lock.cc
new file mode 100644
index 0000000..659ed19
--- /dev/null
+++ b/pw_sync/interrupt_spin_lock.cc
@@ -0,0 +1,32 @@
+// 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.
+
+#include "pw_sync/interrupt_spin_lock.h"
+
+#include "pw_sync/lock_annotations.h"
+
+extern "C" void pw_sync_InterruptSpinLock_Lock(
+    pw_sync_InterruptSpinLock* interrupt_spin_lock) {
+  interrupt_spin_lock->lock();
+}
+
+extern "C" bool pw_sync_InterruptSpinLock_TryLock(
+    pw_sync_InterruptSpinLock* interrupt_spin_lock) {
+  return interrupt_spin_lock->try_lock();
+}
+
+extern "C" void pw_sync_InterruptSpinLock_Unlock(
+    pw_sync_InterruptSpinLock* interrupt_spin_lock) {
+  interrupt_spin_lock->unlock();
+}
diff --git a/pw_sync/interrupt_spin_lock_facade_test.cc b/pw_sync/interrupt_spin_lock_facade_test.cc
new file mode 100644
index 0000000..0d8a059
--- /dev/null
+++ b/pw_sync/interrupt_spin_lock_facade_test.cc
@@ -0,0 +1,76 @@
+// 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.
+
+#include "gtest/gtest.h"
+#include "pw_sync/interrupt_spin_lock.h"
+
+namespace pw::sync {
+namespace {
+
+extern "C" {
+
+// Functions defined in interrupt_spin_lock_facade_test_c.c which call the API
+// from C.
+void pw_sync_InterruptSpinLock_CallLock(
+    pw_sync_InterruptSpinLock* interrupt_spin_lock);
+bool pw_sync_InterruptSpinLock_CallTryLock(
+    pw_sync_InterruptSpinLock* interrupt_spin_lock);
+void pw_sync_InterruptSpinLock_CallUnlock(
+    pw_sync_InterruptSpinLock* interrupt_spin_lock);
+
+}  // extern "C"
+
+TEST(InterruptSpinLock, LockUnlock) {
+  pw::sync::InterruptSpinLock interrupt_spin_lock;
+  interrupt_spin_lock.lock();
+  interrupt_spin_lock.unlock();
+}
+
+// TODO(pwbug/291): Add real concurrency tests once we have pw::thread.
+
+InterruptSpinLock static_interrupt_spin_lock;
+TEST(InterruptSpinLock, LockUnlockStatic) {
+  static_interrupt_spin_lock.lock();
+  // Ensure it fails to lock when already held.
+  EXPECT_FALSE(static_interrupt_spin_lock.try_lock());
+  static_interrupt_spin_lock.unlock();
+}
+
+TEST(InterruptSpinLock, TryLockUnlock) {
+  pw::sync::InterruptSpinLock interrupt_spin_lock;
+  const bool locked = interrupt_spin_lock.try_lock();
+  EXPECT_TRUE(locked);
+  if (locked) {
+    // Ensure it fails to lock when already held.
+    EXPECT_FALSE(interrupt_spin_lock.try_lock());
+    interrupt_spin_lock.unlock();
+  }
+}
+
+TEST(InterruptSpinLock, LockUnlockInC) {
+  pw::sync::InterruptSpinLock interrupt_spin_lock;
+  pw_sync_InterruptSpinLock_CallLock(&interrupt_spin_lock);
+  pw_sync_InterruptSpinLock_CallUnlock(&interrupt_spin_lock);
+}
+
+TEST(InterruptSpinLock, TryLockUnlockInC) {
+  pw::sync::InterruptSpinLock interrupt_spin_lock;
+  ASSERT_TRUE(pw_sync_InterruptSpinLock_CallTryLock(&interrupt_spin_lock));
+  // Ensure it fails to lock when already held.
+  EXPECT_FALSE(pw_sync_InterruptSpinLock_CallTryLock(&interrupt_spin_lock));
+  pw_sync_InterruptSpinLock_CallUnlock(&interrupt_spin_lock);
+}
+
+}  // namespace
+}  // namespace pw::sync
diff --git a/pw_sync/interrupt_spin_lock_facade_test_c.c b/pw_sync/interrupt_spin_lock_facade_test_c.c
new file mode 100644
index 0000000..3cdbe2a
--- /dev/null
+++ b/pw_sync/interrupt_spin_lock_facade_test_c.c
@@ -0,0 +1,35 @@
+// 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.
+
+// These tests call the pw_sync module interrupt_spin_lock API from C. The
+// return values are checked in the main C++ tests.
+
+#include <stdbool.h>
+
+#include "pw_sync/interrupt_spin_lock.h"
+
+void pw_sync_InterruptSpinLock_CallLock(
+    pw_sync_InterruptSpinLock* interrupt_spin_lock) {
+  pw_sync_InterruptSpinLock_Lock(interrupt_spin_lock);
+}
+
+bool pw_sync_InterruptSpinLock_CallTryLock(
+    pw_sync_InterruptSpinLock* interrupt_spin_lock) {
+  return pw_sync_InterruptSpinLock_TryLock(interrupt_spin_lock);
+}
+
+void pw_sync_InterruptSpinLock_CallUnlock(
+    pw_sync_InterruptSpinLock* interrupt_spin_lock) {
+  pw_sync_InterruptSpinLock_Unlock(interrupt_spin_lock);
+}
diff --git a/pw_sync/mutex.cc b/pw_sync/mutex.cc
new file mode 100644
index 0000000..1932c5a
--- /dev/null
+++ b/pw_sync/mutex.cc
@@ -0,0 +1,23 @@
+// 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.
+
+#include "pw_sync/mutex.h"
+
+extern "C" void pw_sync_Mutex_Lock(pw_sync_Mutex* mutex) { mutex->lock(); }
+
+extern "C" bool pw_sync_Mutex_TryLock(pw_sync_Mutex* mutex) {
+  return mutex->try_lock();
+}
+
+extern "C" void pw_sync_Mutex_Unlock(pw_sync_Mutex* mutex) { mutex->unlock(); }
diff --git a/pw_sync/mutex_facade_test.cc b/pw_sync/mutex_facade_test.cc
new file mode 100644
index 0000000..6788536
--- /dev/null
+++ b/pw_sync/mutex_facade_test.cc
@@ -0,0 +1,76 @@
+// 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.
+
+#include <chrono>
+
+#include "gtest/gtest.h"
+#include "pw_sync/mutex.h"
+
+namespace pw::sync {
+namespace {
+
+extern "C" {
+
+// Functions defined in mutex_facade_test_c.c which call the API from C.
+void pw_sync_Mutex_CallLock(pw_sync_Mutex* mutex);
+bool pw_sync_Mutex_CallTryLock(pw_sync_Mutex* mutex);
+void pw_sync_Mutex_CallUnlock(pw_sync_Mutex* mutex);
+
+}  // extern "C"
+
+// TODO(pwbug/291): Add real concurrency tests once we have pw::thread.
+
+TEST(Mutex, LockUnlock) {
+  pw::sync::Mutex mutex;
+  mutex.lock();
+  // TODO(pwbug/291): Ensure it fails to lock when already held.
+  // EXPECT_FALSE(mutex.try_lock());
+  mutex.unlock();
+}
+
+Mutex static_mutex;
+TEST(Mutex, LockUnlockStatic) {
+  static_mutex.lock();
+  // TODO(pwbug/291): Ensure it fails to lock when already held.
+  // EXPECT_FALSE(static_mutex.try_lock());
+  static_mutex.unlock();
+}
+
+TEST(Mutex, TryLockUnlock) {
+  pw::sync::Mutex mutex;
+  const bool locked = mutex.try_lock();
+  EXPECT_TRUE(locked);
+  if (locked) {
+    // TODO(pwbug/291): Ensure it fails to lock when already held.
+    // EXPECT_FALSE(mutex.try_lock());
+    mutex.unlock();
+  }
+}
+
+TEST(Mutex, LockUnlockInC) {
+  pw::sync::Mutex mutex;
+  pw_sync_Mutex_CallLock(&mutex);
+  pw_sync_Mutex_CallUnlock(&mutex);
+}
+
+TEST(Mutex, TryLockUnlockInC) {
+  pw::sync::Mutex mutex;
+  ASSERT_TRUE(pw_sync_Mutex_CallTryLock(&mutex));
+  // TODO(pwbug/291): Ensure it fails to lock when already held.
+  // EXPECT_FALSE(pw_sync_Mutex_CallTryLock(&mutex));
+  pw_sync_Mutex_CallUnlock(&mutex);
+}
+
+}  // namespace
+}  // namespace pw::sync
diff --git a/pw_sync/mutex_facade_test_c.c b/pw_sync/mutex_facade_test_c.c
new file mode 100644
index 0000000..5196d51
--- /dev/null
+++ b/pw_sync/mutex_facade_test_c.c
@@ -0,0 +1,30 @@
+// 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.
+
+// These tests call the pw_sync module mutex API from C. The return values are
+// checked in the main C++ tests.
+
+#include <stdbool.h>
+
+#include "pw_sync/mutex.h"
+
+void pw_sync_Mutex_CallLock(pw_sync_Mutex* mutex) { pw_sync_Mutex_Lock(mutex); }
+
+bool pw_sync_Mutex_CallTryLock(pw_sync_Mutex* mutex) {
+  return pw_sync_Mutex_TryLock(mutex);
+}
+
+void pw_sync_Mutex_CallUnlock(pw_sync_Mutex* mutex) {
+  pw_sync_Mutex_Unlock(mutex);
+}
diff --git a/pw_sync/public/pw_sync/binary_semaphore.h b/pw_sync/public/pw_sync/binary_semaphore.h
new file mode 100644
index 0000000..b7e1c99
--- /dev/null
+++ b/pw_sync/public/pw_sync/binary_semaphore.h
@@ -0,0 +1,118 @@
+// 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.
+#pragma once
+
+#include <stdbool.h>
+#include <stddef.h>
+
+#include "pw_chrono/system_clock.h"
+#include "pw_preprocessor/util.h"
+
+#ifdef __cplusplus
+
+#include "pw_sync_backend/binary_semaphore_native.h"
+
+namespace pw::sync {
+
+// BinarySemaphore is a specialization of CountingSemaphore with an arbitrary
+// token limit of 1. Note that that max() is >= 1, meaning it may be
+// released up to max() times but only acquired once for those N releases.
+// Implementations of BinarySemaphore are typically more efficient than the
+// default implementation of CountingSemaphore. The entire API is thread safe
+// but only a subset is IRQ safe.
+//
+// WARNING: In order to support global statically constructed BinarySemaphores,
+// the user and/or backend MUST ensure that any initialization required in your
+// environment is done prior to the creation and/or initialization of the native
+// synchronization primitives (e.g. kernel initialization).
+class BinarySemaphore {
+ public:
+  using native_handle_type = backend::NativeBinarySemaphoreHandle;
+
+  BinarySemaphore();
+  ~BinarySemaphore();
+  BinarySemaphore(const BinarySemaphore&) = delete;
+  BinarySemaphore(BinarySemaphore&&) = delete;
+  BinarySemaphore& operator=(const BinarySemaphore&) = delete;
+  BinarySemaphore& operator=(BinarySemaphore&&) = delete;
+
+  // Atomically increments the internal counter by 1 up to max_count.
+  // Any thread(s) waiting for the counter to be greater than 0,
+  // such as due to being blocked in acquire, will subsequently be unblocked.
+  // This is IRQ safe.
+  //
+  // PRECONDITIONS:
+  //   1 <= max() - counter
+  void release();
+
+  // Decrements the internal counter to 0 or blocks indefinitely until it can.
+  // This is thread safe.
+
+  //   update <= max() - counter
+  void acquire();
+
+  // Attempts to decrement by the internal counter to 0 without blocking.
+  // Returns true if the internal counter was reset successfully.
+  // This is IRQ safe.
+  bool try_acquire() noexcept;
+
+  // Attempts to decrement the internal counter to 0 where, if needed, blocking
+  // for at least the specified duration.
+  // Returns true if the internal counter was decremented successfully.
+  // This is thread safe.
+  bool try_acquire_for(chrono::SystemClock::duration for_at_least);
+
+  // Attempts to decrement the internal counter to 0 where, if needed, blocking
+  // until at least the specified time point.
+  // Returns true if the internal counter was decremented successfully.
+  // This is thread safe.
+  bool try_acquire_until(chrono::SystemClock::time_point until_at_least);
+
+  static constexpr ptrdiff_t max() noexcept {
+    return backend::kBinarySemaphoreMaxValue;
+  }
+
+  native_handle_type native_handle();
+
+ private:
+  // This may be a wrapper around a native type with additional members.
+  backend::NativeBinarySemaphore native_type_;
+};
+
+}  // namespace pw::sync
+
+#include "pw_sync_backend/binary_semaphore_inline.h"
+
+using pw_sync_BinarySemaphore = pw::sync::BinarySemaphore;
+
+#else  // !defined(__cplusplus)
+
+typedef struct pw_sync_BinarySemaphore pw_sync_BinarySemaphore;
+
+#endif  // __cplusplus
+
+PW_EXTERN_C_START
+
+void pw_sync_BinarySemaphore_Release(pw_sync_BinarySemaphore* semaphore);
+void pw_sync_BinarySemaphore_Acquire(pw_sync_BinarySemaphore* semaphore);
+bool pw_sync_BinarySemaphore_TryAcquire(pw_sync_BinarySemaphore* semaphore);
+bool pw_sync_BinarySemaphore_TryAcquireFor(
+    pw_sync_BinarySemaphore* semaphore,
+    pw_chrono_SystemClock_Duration for_at_least);
+bool pw_sync_BinarySemaphore_TryAcquireUntil(
+    pw_sync_BinarySemaphore* semaphore,
+    pw_chrono_SystemClock_TimePoint until_at_least);
+ptrdiff_t pw_sync_BinarySemaphore_Max(void);
+
+PW_EXTERN_C_END
diff --git a/pw_sync/public/pw_sync/counting_semaphore.h b/pw_sync/public/pw_sync/counting_semaphore.h
new file mode 100644
index 0000000..23fbb21
--- /dev/null
+++ b/pw_sync/public/pw_sync/counting_semaphore.h
@@ -0,0 +1,120 @@
+// 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.
+#pragma once
+
+#include <stdbool.h>
+#include <stddef.h>
+
+#include "pw_chrono/system_clock.h"
+#include "pw_preprocessor/util.h"
+
+#ifdef __cplusplus
+
+#include "pw_sync_backend/counting_semaphore_native.h"
+
+namespace pw::sync {
+
+// The CountingSemaphore is a synchronization primitive that can be used for
+// counting events and/or resource management where receiver(s) can block on
+// acquire until notifier(s) signal by invoking release.
+// Note that unlike Mutexes, priority inheritance is not used by semaphores
+// meaning semaphores are subject to unbounded priority inversions.
+// Pigweed does not recommend semaphores for mutual exclusion. The entire API is
+// thread safe but only a subset is IRQ safe.
+//
+// WARNING: In order to support global statically constructed CountingSemaphores
+// the user and/or backend MUST ensure that any initialization required in your
+// environment is done prior to the creation and/or initialization of the native
+// synchronization primitives (e.g. kernel initialization).
+class CountingSemaphore {
+ public:
+  using native_handle_type = backend::NativeCountingSemaphoreHandle;
+
+  CountingSemaphore();
+  ~CountingSemaphore();
+  CountingSemaphore(const CountingSemaphore&) = delete;
+  CountingSemaphore(CountingSemaphore&&) = delete;
+  CountingSemaphore& operator=(const CountingSemaphore&) = delete;
+  CountingSemaphore& operator=(CountingSemaphore&&) = delete;
+
+  // Atomically increments the internal counter by the value of update.
+  // Any thread(s) waiting for the counter to be greater than 0, i.e.
+  // blocked in acquire, will subsequently be unblocked.
+  // This is IRQ safe.
+  //
+  // PRECONDITIONS:
+  //   update >= 0
+  //   update <= max() - counter
+  void release(ptrdiff_t update = 1);
+
+  // Decrements the internal counter by 1 or blocks indefinitely until it can.
+  // This is thread safe.
+  void acquire();
+
+  // Attempts to decrement by the internal counter by 1 without blocking.
+  // Returns true if the internal counter was decremented successfully.
+  // This is IRQ safe.
+  bool try_acquire() noexcept;
+
+  // Attempts to decrement the internal counter by 1 where, if needed, blocking
+  // for at least the specified duration.
+  // Returns true if the internal counter was decremented successfully.
+  // This is thread safe.
+  bool try_acquire_for(chrono::SystemClock::duration for_at_least);
+
+  // Attempts to decrement the internal counter by 1 where, if needed, blocking
+  // until at least the specified time point.
+  // Returns true if the internal counter was decremented successfully.
+  // This is thread safe.
+  bool try_acquire_until(chrono::SystemClock::time_point until_at_least);
+
+  static constexpr ptrdiff_t max() noexcept {
+    return backend::kCountingSemaphoreMaxValue;
+  }
+
+  native_handle_type native_handle();
+
+ private:
+  // This may be a wrapper around a native type with additional members.
+  backend::NativeCountingSemaphore native_type_;
+};
+
+}  // namespace pw::sync
+
+#include "pw_sync_backend/counting_semaphore_inline.h"
+
+using pw_sync_CountingSemaphore = pw::sync::CountingSemaphore;
+
+#else  // !defined(__cplusplus)
+
+typedef struct pw_sync_CountingSemaphore pw_sync_CountingSemaphore;
+
+#endif  // __cplusplus
+
+PW_EXTERN_C_START
+
+void pw_sync_CountingSemaphore_Release(pw_sync_CountingSemaphore* semaphore);
+void pw_sync_CountingSemaphore_ReleaseNum(pw_sync_CountingSemaphore* semaphore,
+                                          ptrdiff_t update);
+void pw_sync_CountingSemaphore_Acquire(pw_sync_CountingSemaphore* semaphore);
+bool pw_sync_CountingSemaphore_TryAcquire(pw_sync_CountingSemaphore* semaphore);
+bool pw_sync_CountingSemaphore_TryAcquireFor(
+    pw_sync_CountingSemaphore* semaphore,
+    pw_chrono_SystemClock_Duration for_at_least);
+bool pw_sync_CountingSemaphore_TryAcquireUntil(
+    pw_sync_CountingSemaphore* semaphore,
+    pw_chrono_SystemClock_TimePoint until_at_least);
+ptrdiff_t pw_sync_CountingSemaphore_Max(void);
+
+PW_EXTERN_C_END
diff --git a/pw_sync/public/pw_sync/interrupt_spin_lock.h b/pw_sync/public/pw_sync/interrupt_spin_lock.h
new file mode 100644
index 0000000..8c1e7f2
--- /dev/null
+++ b/pw_sync/public/pw_sync/interrupt_spin_lock.h
@@ -0,0 +1,103 @@
+// 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.
+#pragma once
+
+#include <stdbool.h>
+
+#include "pw_preprocessor/util.h"
+#include "pw_sync/lock_annotations.h"
+
+#ifdef __cplusplus
+
+#include "pw_sync_backend/interrupt_spin_lock_native.h"
+
+namespace pw::sync {
+
+// The InterruptSpinLock is a synchronization primitive that can be used to
+// protect shared data from being simultaneously accessed by multiple threads
+// and/or interrupts as a targeted global lock, with the exception of
+// Non-Maskable Interrupts (NMIs).
+// It offers exclusive, non-recursive ownership semantics where IRQs up to a
+// backend defined level of "NMIs" will be masked to solve priority-inversion.
+//
+// NOTE: This InterruptSpinLock relies on built-in local interrupt masking to
+// make it interrupt safe without requiring the caller to separately mask and
+// unmask interrupts when using this primitive.
+//
+// Unlike global interrupt locks, this also works safely and efficiently on SMP
+// systems. On systems which are not SMP, spinning is not required and it's
+// possible that only interrupt masking occurs but some state may still be used
+// to detect recursion.
+//
+// This entire API is IRQ safe, but NOT NMI safe.
+//
+// Precondition: Code that holds a specific InterruptSpinLock must not try to
+// re-acquire it. However, it is okay to nest distinct spinlocks.
+class PW_LOCKABLE("pw::sync::InterruptSpinLock") InterruptSpinLock {
+ public:
+  using native_handle_type = backend::NativeInterruptSpinLockHandle;
+
+  constexpr InterruptSpinLock();
+  ~InterruptSpinLock() = default;
+  InterruptSpinLock(const InterruptSpinLock&) = delete;
+  InterruptSpinLock(InterruptSpinLock&&) = delete;
+  InterruptSpinLock& operator=(const InterruptSpinLock&) = delete;
+  InterruptSpinLock& operator=(InterruptSpinLock&&) = delete;
+
+  // Locks the spinlock, blocking indefinitely. Failures are fatal.
+  //
+  // Precondition: Recursive locking is undefined behavior.
+  void lock() PW_EXCLUSIVE_LOCK_FUNCTION();
+
+  // Attempts to lock the spinlock in a non-blocking manner.
+  // Returns true if the spinlock was successfully acquired.
+  //
+  // Precondition: Recursive locking is undefined behavior.
+  bool try_lock() PW_EXCLUSIVE_TRYLOCK_FUNCTION(true);
+
+  // Unlocks the spinlock. Failures are fatal.
+  //
+  // PRECONDITION:
+  //   The spinlock is held by the caller.
+  void unlock() PW_UNLOCK_FUNCTION();
+
+  native_handle_type native_handle();
+
+ private:
+  // This may be a wrapper around a native type with additional members.
+  backend::NativeInterruptSpinLock native_type_;
+};
+
+}  // namespace pw::sync
+
+#include "pw_sync_backend/interrupt_spin_lock_inline.h"
+
+using pw_sync_InterruptSpinLock = pw::sync::InterruptSpinLock;
+
+#else  // !defined(__cplusplus)
+
+typedef struct pw_sync_InterruptSpinLock pw_sync_InterruptSpinLock;
+
+#endif  // __cplusplus
+
+PW_EXTERN_C_START
+
+void pw_sync_InterruptSpinLock_Lock(pw_sync_InterruptSpinLock* spin_lock)
+    PW_NO_LOCK_SAFETY_ANALYSIS;
+bool pw_sync_InterruptSpinLock_TryLock(pw_sync_InterruptSpinLock* spin_lock)
+    PW_NO_LOCK_SAFETY_ANALYSIS;
+void pw_sync_InterruptSpinLock_Unlock(pw_sync_InterruptSpinLock* spin_lock)
+    PW_NO_LOCK_SAFETY_ANALYSIS;
+
+PW_EXTERN_C_END
diff --git a/pw_sync/public/pw_sync/lock_annotations.h b/pw_sync/public/pw_sync/lock_annotations.h
new file mode 100644
index 0000000..6d60b55
--- /dev/null
+++ b/pw_sync/public/pw_sync/lock_annotations.h
@@ -0,0 +1,280 @@
+// Copyright 2021 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.
+//
+// This header file contains macro definitions for thread safety annotations
+// that allow developers to document the locking policies of multi-threaded
+// code. The annotations can also help program analysis tools to identify
+// potential thread safety issues.
+//
+// These annotations are implemented using compiler attributes. Using the macros
+// defined here instead of raw attributes allow for portability and future
+// compatibility.
+//
+// The thread safety analysis system is documented at
+// http://clang.llvm.org/docs/ThreadSafetyAnalysis.html
+//
+// When referring to locks in the arguments of the attributes, you should
+// use variable names or more complex expressions (e.g. my_object->lock_)
+// that evaluate to a concrete lock object whenever possible. If the lock
+// you want to refer to is not in scope, you may use a member pointer
+// (e.g. &MyClass::lock_) to refer to a lock in some (unknown) object.
+
+#pragma once
+
+#include "pw_preprocessor/compiler.h"
+
+// PW_GUARDED_BY()
+//
+// Documents if a shared field or global variable needs to be protected by a
+// lock. PW_GUARDED_BY() allows the user to specify a particular lock that
+// should be held when accessing the annotated variable.
+//
+// Although this annotation (and PW_PT_GUARDED_BY, below) cannot be applied to
+// local variables, a local variable and its associated lock can often be
+// combined into a small class or struct, thereby allowing the annotation.
+//
+// Example:
+//
+//   class Foo {
+//     Mutex mu_;
+//     int p1_ PW_GUARDED_BY(mu_);
+//     ...
+//   };
+#if PW_HAVE_ATTRIBUTE(guarded_by)
+#define PW_GUARDED_BY(x) __attribute__((guarded_by(x)))
+#else
+#define PW_GUARDED_BY(x)
+#endif
+
+// PW_PT_GUARDED_BY()
+//
+// Documents if the memory location pointed to by a pointer should be guarded
+// by a lock when dereferencing the pointer.
+//
+// Example:
+//   class Foo {
+//     Mutex mu_;
+//     int *p1_ PW_PT_GUARDED_BY(mu_);
+//     ...
+//   };
+//
+// Note that a pointer variable to a shared memory location could itself be a
+// shared variable.
+//
+// Example:
+//
+//   // `q_`, guarded by `mu1_`, points to a shared memory location that is
+//   // guarded by `mu2_`:
+//   int *q_ PW_GUARDED_BY(mu1_) PW_PT_GUARDED_BY(mu2_);
+#if PW_HAVE_ATTRIBUTE(pt_guarded_by)
+#define PW_PT_GUARDED_BY(x) __attribute__((pt_guarded_by(x)))
+#else
+#define PW_PT_GUARDED_BY(x)
+#endif
+
+// PW_ACQUIRED_AFTER() / PW_ACQUIRED_BEFORE()
+//
+// Documents the acquisition order between locks that can be held
+// simultaneously by a thread. For any two locks that need to be annotated
+// to establish an acquisition order, only one of them needs the annotation.
+// (i.e. You don't have to annotate both locks with both PW_ACQUIRED_AFTER
+// and PW_ACQUIRED_BEFORE.)
+//
+// As with PW_GUARDED_BY, this is only applicable to locks that are shared
+// fields or global variables.
+//
+// Example:
+//
+//   Mutex m1_;
+//   Mutex m2_ PW_ACQUIRED_AFTER(m1_);
+#if PW_HAVE_ATTRIBUTE(acquired_after)
+#define PW_ACQUIRED_AFTER(...) __attribute__((acquired_after(__VA_ARGS__)))
+#else
+#define PW_ACQUIRED_AFTER(...)
+#endif
+
+#if PW_HAVE_ATTRIBUTE(acquired_before)
+#define PW_ACQUIRED_BEFORE(...) __attribute__((acquired_before(__VA_ARGS__)))
+#else
+#define PW_ACQUIRED_BEFORE(...)
+#endif
+
+// PW_EXCLUSIVE_LOCKS_REQUIRED() / PW_SHARED_LOCKS_REQUIRED()
+//
+// Documents a function that expects a lock to be held prior to entry.
+// The lock is expected to be held both on entry to, and exit from, the
+// function.
+//
+// An exclusive lock allows read-write access to the guarded data member(s), and
+// only one thread can acquire a lock exclusively at any one time. A shared lock
+// allows read-only access, and any number of threads can acquire a shared lock
+// concurrently.
+//
+// Generally, non-const methods should be annotated with
+// PW_EXCLUSIVE_LOCKS_REQUIRED, while const methods should be annotated with
+// PW_SHARED_LOCKS_REQUIRED.
+//
+// Example:
+//
+//   Mutex mu1, mu2;
+//   int a PW_GUARDED_BY(mu1);
+//   int b PW_GUARDED_BY(mu2);
+//
+//   void foo() PW_EXCLUSIVE_LOCKS_REQUIRED(mu1, mu2) { ... }
+//   void bar() const PW_SHARED_LOCKS_REQUIRED(mu1, mu2) { ... }
+#if PW_HAVE_ATTRIBUTE(exclusive_locks_required)
+#define PW_EXCLUSIVE_LOCKS_REQUIRED(...) \
+  __attribute__((exclusive_locks_required(__VA_ARGS__)))
+#else
+#define PW_EXCLUSIVE_LOCKS_REQUIRED(...)
+#endif
+
+#if PW_HAVE_ATTRIBUTE(shared_locks_required)
+#define PW_SHARED_LOCKS_REQUIRED(...) \
+  __attribute__((shared_locks_required(__VA_ARGS__)))
+#else
+#define PW_SHARED_LOCKS_REQUIRED(...)
+#endif
+
+// PW_LOCKS_EXCLUDED()
+//
+// Documents the locks acquired in the body of the function. These locks
+// cannot be held when calling this function (as Pigweed's default locks are
+// non-reentrant).
+#if PW_HAVE_ATTRIBUTE(locks_excluded)
+#define PW_LOCKS_EXCLUDED(...) __attribute__((locks_excluded(__VA_ARGS__)))
+#else
+#define PW_LOCKS_EXCLUDED(...)
+#endif
+
+// PW_LOCK_RETURNED()
+//
+// Documents a function that returns a lock without acquiring it.  For example,
+// a public getter method that returns a pointer to a private lock should
+// be annotated with PW_LOCK_RETURNED.
+#if PW_HAVE_ATTRIBUTE(lock_returned)
+#define PW_LOCK_RETURNED(x) __attribute__((lock_returned(x)))
+#else
+#define PW_LOCK_RETURNED(x)
+#endif
+
+// PW_LOCKABLE(name)
+//
+// Documents if a class/type is a lockable type (such as the `pw::sync::Mutex`
+// class). The name is used in the warning messages.
+#if PW_HAVE_ATTRIBUTE(capability)
+#define PW_LOCKABLE(name) __attribute__((capability(name)))
+#elif PW_HAVE_ATTRIBUTE(lockable)
+#define PW_LOCKABLE(name) __attribute__((lockable))
+#else
+#define PW_LOCKABLE(name)
+#endif
+
+// PW_SCOPED_LOCKABLE
+//
+// Documents if a class does RAII locking. The name is used in the warning
+// messages.
+//
+// The constructor should use `LOCK_FUNCTION()` to specify the lock that is
+// acquired, and the destructor should use `UNLOCK_FUNCTION()` with no
+// arguments; the analysis will assume that the destructor unlocks whatever the
+// constructor locked.
+#if PW_HAVE_ATTRIBUTE(scoped_lockable)
+#define PW_SCOPED_LOCKABLE __attribute__((scoped_lockable))
+#else
+#define PW_SCOPED_LOCKABLE
+#endif
+
+// PW_EXCLUSIVE_LOCK_FUNCTION()
+//
+// Documents functions that acquire a lock in the body of a function, and do
+// not release it.
+#if PW_HAVE_ATTRIBUTE(exclusive_lock_function)
+#define PW_EXCLUSIVE_LOCK_FUNCTION(...) \
+  __attribute__((exclusive_lock_function(__VA_ARGS__)))
+#else
+#define PW_EXCLUSIVE_LOCK_FUNCTION(...)
+#endif
+
+// PW_SHARED_LOCK_FUNCTION()
+//
+// Documents functions that acquire a shared (reader) lock in the body of a
+// function, and do not release it.
+#if PW_HAVE_ATTRIBUTE(shared_lock_function)
+#define PW_SHARED_LOCK_FUNCTION(...) \
+  __attribute__((shared_lock_function(__VA_ARGS__)))
+#else
+#define PW_SHARED_LOCK_FUNCTION(...)
+#endif
+
+// PW_UNLOCK_FUNCTION()
+//
+// Documents functions that expect a lock to be held on entry to the function,
+// and release it in the body of the function.
+#if PW_HAVE_ATTRIBUTE(unlock_function)
+#define PW_UNLOCK_FUNCTION(...) __attribute__((unlock_function(__VA_ARGS__)))
+#else
+#define PW_UNLOCK_FUNCTION(...)
+#endif
+
+// PW_EXCLUSIVE_TRYLOCK_FUNCTION() / PW_SHARED_TRYLOCK_FUNCTION()
+//
+// Documents functions that try to acquire a lock, and return success or failure
+// (or a non-boolean value that can be interpreted as a boolean).
+// The first argument should be `true` for functions that return `true` on
+// success, or `false` for functions that return `false` on success. The second
+// argument specifies the lock that is locked on success. If unspecified, this
+// lock is assumed to be `this`.
+#if PW_HAVE_ATTRIBUTE(exclusive_trylock_function)
+#define PW_EXCLUSIVE_TRYLOCK_FUNCTION(...) \
+  __attribute__((exclusive_trylock_function(__VA_ARGS__)))
+#else
+#define PW_EXCLUSIVE_TRYLOCK_FUNCTION(...)
+#endif
+
+#if PW_HAVE_ATTRIBUTE(shared_trylock_function)
+#define PW_SHARED_TRYLOCK_FUNCTION(...) \
+  __attribute__((shared_trylock_function(__VA_ARGS__)))
+#else
+#define PW_SHARED_TRYLOCK_FUNCTION(...)
+#endif
+
+// PW_ASSERT_EXCLUSIVE_LOCK() / PW_ASSERT_SHARED_LOCK()
+//
+// Documents functions that dynamically check to see if a lock is held, and fail
+// if it is not held.
+#if PW_HAVE_ATTRIBUTE(assert_exclusive_lock)
+#define PW_ASSERT_EXCLUSIVE_LOCK(...) \
+  __attribute__((assert_exclusive_lock(__VA_ARGS__)))
+#else
+#define PW_ASSERT_EXCLUSIVE_LOCK(...)
+#endif
+
+#if PW_HAVE_ATTRIBUTE(assert_shared_lock)
+#define PW_ASSERT_SHARED_LOCK(...) \
+  __attribute__((assert_shared_lock(__VA_ARGS__)))
+#else
+#define PW_ASSERT_SHARED_LOCK(...)
+#endif
+
+// PW_NO_LOCK_SAFETY_ANALYSIS
+//
+// Turns off thread safety checking within the body of a particular function.
+// This annotation is used to mark functions that are known to be correct, but
+// the locking behavior is more complicated than the analyzer can handle.
+#if PW_HAVE_ATTRIBUTE(no_thread_safety_analysis)
+#define PW_NO_LOCK_SAFETY_ANALYSIS __attribute__((no_thread_safety_analysis))
+#else
+#define PW_NO_LOCK_SAFETY_ANALYSIS
+#endif
diff --git a/pw_sync/public/pw_sync/mutex.h b/pw_sync/public/pw_sync/mutex.h
new file mode 100644
index 0000000..a2da0f7
--- /dev/null
+++ b/pw_sync/public/pw_sync/mutex.h
@@ -0,0 +1,94 @@
+// 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.
+#pragma once
+
+#include <stdbool.h>
+
+#include "pw_preprocessor/util.h"
+#include "pw_sync/lock_annotations.h"
+
+#ifdef __cplusplus
+
+#include "pw_sync_backend/mutex_native.h"
+
+namespace pw::sync {
+
+// The Mutex is a synchronization primitive that can be used to protect
+// shared data from being simultaneously accessed by multiple threads.
+// It offers exclusive, non-recursive ownership semantics where priority
+// inheritance is used to solve the classic priority-inversion problem.
+// This is thread safe, but NOT IRQ safe.
+//
+// WARNING: In order to support global statically constructed Mutexes, the user
+// and/or backend MUST ensure that any initialization required in your
+// environment is done prior to the creation and/or initialization of the native
+// synchronization primitives (e.g. kernel initialization).
+class PW_LOCKABLE("pw::sync::Mutex") Mutex {
+ public:
+  using native_handle_type = backend::NativeMutexHandle;
+
+  Mutex();
+  ~Mutex();
+  Mutex(const Mutex&) = delete;
+  Mutex(Mutex&&) = delete;
+  Mutex& operator=(const Mutex&) = delete;
+  Mutex& operator=(Mutex&&) = delete;
+
+  // Locks the mutex, blocking indefinitely. Failures are fatal.
+  //
+  // PRECONDITION:
+  //   The lock isn't already held by this thread. Recursive locking is
+  //   undefined behavior.
+  void lock() PW_EXCLUSIVE_LOCK_FUNCTION();
+
+  // Attempts to lock the mutex in a non-blocking manner.
+  // Returns true if the mutex was successfully acquired.
+  //
+  // PRECONDITION:
+  //   The lock isn't already held by this thread. Recursive locking is
+  //   undefined behavior.
+  bool try_lock() PW_EXCLUSIVE_TRYLOCK_FUNCTION(true);
+
+  // Unlocks the mutex. Failures are fatal.
+  //
+  // PRECONDITION:
+  //   The mutex is held by this thread.
+  void unlock() PW_UNLOCK_FUNCTION();
+
+  native_handle_type native_handle();
+
+ private:
+  // This may be a wrapper around a native type with additional members.
+  backend::NativeMutex native_type_;
+};
+
+}  // namespace pw::sync
+
+#include "pw_sync_backend/mutex_inline.h"
+
+using pw_sync_Mutex = pw::sync::Mutex;
+
+#else  // !defined(__cplusplus)
+
+typedef struct pw_sync_Mutex pw_sync_Mutex;
+
+#endif  // __cplusplus
+
+PW_EXTERN_C_START
+
+void pw_sync_Mutex_Lock(pw_sync_Mutex* mutex) PW_NO_LOCK_SAFETY_ANALYSIS;
+bool pw_sync_Mutex_TryLock(pw_sync_Mutex* mutex) PW_NO_LOCK_SAFETY_ANALYSIS;
+void pw_sync_Mutex_Unlock(pw_sync_Mutex* mutex) PW_NO_LOCK_SAFETY_ANALYSIS;
+
+PW_EXTERN_C_END
diff --git a/pw_sync/public/pw_sync/timed_mutex.h b/pw_sync/public/pw_sync/timed_mutex.h
new file mode 100644
index 0000000..2f44a78
--- /dev/null
+++ b/pw_sync/public/pw_sync/timed_mutex.h
@@ -0,0 +1,95 @@
+// 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.
+#pragma once
+
+#include <stdbool.h>
+
+#include "pw_chrono/system_clock.h"
+#include "pw_preprocessor/util.h"
+#include "pw_sync/lock_annotations.h"
+#include "pw_sync/mutex.h"
+
+#ifdef __cplusplus
+
+namespace pw::sync {
+
+// The TimedMutex is a synchronization primitive that can be used to protect
+// shared data from being simultaneously accessed by multiple threads with
+// timeouts and deadlines, extending the Mutex.
+// It offers exclusive, non-recursive ownership semantics where priority
+// inheritance is used to solve the classic priority-inversion problem.
+// This is thread safe, but NOT IRQ safe.
+//
+// WARNING: In order to support global statically constructed TimedMutexes, the
+// user and/or backend MUST ensure that any initialization required in your
+// environment is done prior to the creation and/or initialization of the native
+// synchronization primitives (e.g. kernel initialization).
+class TimedMutex : public Mutex {
+ public:
+  TimedMutex() = default;
+  ~TimedMutex() = default;
+  TimedMutex(const TimedMutex&) = delete;
+  TimedMutex(TimedMutex&&) = delete;
+  TimedMutex& operator=(const TimedMutex&) = delete;
+  TimedMutex& operator=(TimedMutex&&) = delete;
+
+  // Attempts to lock the mutex where, if needed, blocking for at least the
+  // specified duration.
+  // Returns true if the mutex was successfully acquired.
+  //
+  // PRECONDITION:
+  //   The lock isn't already held by this thread. Recursive locking is
+  //   undefined behavior.
+  bool try_lock_for(chrono::SystemClock::duration for_at_least)
+      PW_EXCLUSIVE_TRYLOCK_FUNCTION(true);
+
+  // Attempts to lock the mutex where, if needed, blocking until at least the
+  // specified time_point.
+  // Returns true if the mutex was successfully acquired.
+  //
+  // PRECONDITION:
+  //   The lock isn't already held by this thread. Recursive locking is
+  //   undefined behavior.
+  bool try_lock_until(chrono::SystemClock::time_point until_at_least)
+      PW_EXCLUSIVE_TRYLOCK_FUNCTION(true);
+};
+
+}  // namespace pw::sync
+
+#include "pw_sync_backend/timed_mutex_inline.h"
+
+using pw_sync_TimedMutex = pw::sync::TimedMutex;
+
+#else  // !defined(__cplusplus)
+
+typedef struct pw_sync_TimedMutex pw_sync_TimedMutex;
+
+#endif  // __cplusplus
+
+PW_EXTERN_C_START
+
+void pw_sync_TimedMutex_Lock(pw_sync_TimedMutex* mutex)
+    PW_NO_LOCK_SAFETY_ANALYSIS;
+bool pw_sync_TimedMutex_TryLock(pw_sync_TimedMutex* mutex)
+    PW_NO_LOCK_SAFETY_ANALYSIS;
+bool pw_sync_TimedMutex_TryLockFor(pw_sync_TimedMutex* mutex,
+                                   pw_chrono_SystemClock_Duration for_at_least)
+    PW_NO_LOCK_SAFETY_ANALYSIS;
+bool pw_sync_TimedMutex_TryLockUntil(
+    pw_sync_TimedMutex* mutex,
+    pw_chrono_SystemClock_TimePoint until_at_least) PW_NO_LOCK_SAFETY_ANALYSIS;
+void pw_sync_TimedMutex_Unlock(pw_sync_TimedMutex* mutex)
+    PW_NO_LOCK_SAFETY_ANALYSIS;
+
+PW_EXTERN_C_END
diff --git a/pw_sync/public/pw_sync/yield_core.h b/pw_sync/public/pw_sync/yield_core.h
new file mode 100644
index 0000000..4a04e2b
--- /dev/null
+++ b/pw_sync/public/pw_sync/yield_core.h
@@ -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.
+#pragma once
+
+// PW_SYNC_YIELD_CORE_FOR_SMT provides the architecture specific processor hint
+// to allow the processor to yield in the case of SMT.
+#if defined(__x86_64__) || defined(__i386__)
+#include <immintrin.h>
+#define PW_SYNC_YIELD_CORE_FOR_SMT() _mm_pause()
+
+#elif defined(__aarch64__) || defined(__arm__)
+#define PW_SYNC_YIELD_CORE_FOR_SMT() asm volatile("yield" ::: "memory")
+
+#else
+#error "No processor yield implementation for this architecture."
+
+#endif  // PW_SYNC_YIELD_CORE_FOR_SMT
diff --git a/pw_sync/timed_mutex.cc b/pw_sync/timed_mutex.cc
new file mode 100644
index 0000000..512d4c4
--- /dev/null
+++ b/pw_sync/timed_mutex.cc
@@ -0,0 +1,40 @@
+// 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.
+
+#include "pw_sync/timed_mutex.h"
+
+using pw::chrono::SystemClock;
+
+extern "C" void pw_sync_TimedMutex_Lock(pw_sync_TimedMutex* mutex) {
+  mutex->lock();
+}
+
+extern "C" bool pw_sync_TimedMutex_TryLock(pw_sync_TimedMutex* mutex) {
+  return mutex->try_lock();
+}
+
+extern "C" bool pw_sync_TimedMutex_TryLockFor(
+    pw_sync_TimedMutex* mutex, pw_chrono_SystemClock_Duration for_at_least) {
+  return mutex->try_lock_for(SystemClock::duration(for_at_least.ticks));
+}
+
+extern "C" bool pw_sync_TimedMutex_TryLockUntil(
+    pw_sync_TimedMutex* mutex, pw_chrono_SystemClock_TimePoint until_at_least) {
+  return mutex->try_lock_until(SystemClock::time_point(
+      SystemClock::duration(until_at_least.duration_since_epoch.ticks)));
+}
+
+extern "C" void pw_sync_TimedMutex_Unlock(pw_sync_TimedMutex* mutex) {
+  mutex->unlock();
+}
diff --git a/pw_sync/timed_mutex_facade_test.cc b/pw_sync/timed_mutex_facade_test.cc
new file mode 100644
index 0000000..ad4696e
--- /dev/null
+++ b/pw_sync/timed_mutex_facade_test.cc
@@ -0,0 +1,172 @@
+// 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.
+
+#include <chrono>
+
+#include "gtest/gtest.h"
+#include "pw_chrono/system_clock.h"
+#include "pw_sync/timed_mutex.h"
+
+using pw::chrono::SystemClock;
+using namespace std::chrono_literals;
+
+namespace pw::sync {
+namespace {
+
+extern "C" {
+
+// Functions defined in mutex_facade_test_c.c which call the API from C.
+void pw_sync_TimedMutex_CallLock(pw_sync_TimedMutex* mutex);
+bool pw_sync_TimedMutex_CallTryLock(pw_sync_TimedMutex* mutex);
+bool pw_sync_TimedMutex_CallTryLockFor(
+    pw_sync_TimedMutex* mutex, pw_chrono_SystemClock_Duration for_at_least);
+bool pw_sync_TimedMutex_CallTryLockUntil(
+    pw_sync_TimedMutex* mutex, pw_chrono_SystemClock_TimePoint until_at_least);
+void pw_sync_TimedMutex_CallUnlock(pw_sync_TimedMutex* mutex);
+
+}  // extern "C"
+
+// We can't control the SystemClock's period configuration, so just in case
+// duration cannot be accurately expressed in integer ticks, round the
+// duration up.
+constexpr SystemClock::duration kRoundedArbitraryDuration =
+    SystemClock::for_at_least(42ms);
+constexpr pw_chrono_SystemClock_Duration kRoundedArbitraryDurationInC =
+    PW_SYSTEM_CLOCK_MS(42);
+
+// TODO(pwbug/291): Add real concurrency tests once we have pw::thread.
+
+TEST(TimedMutex, LockUnlock) {
+  pw::sync::TimedMutex mutex;
+  mutex.lock();
+  // TODO(pwbug/291): Ensure it fails to lock when already held.
+  // EXPECT_FALSE(mutex.try_lock());
+  mutex.unlock();
+}
+
+TimedMutex static_mutex;
+TEST(TimedMutex, LockUnlockStatic) {
+  static_mutex.lock();
+  // TODO(pwbug/291): Ensure it fails to lock when already held.
+  // EXPECT_FALSE(static_mutex.try_lock());
+  static_mutex.unlock();
+}
+
+TEST(TimedMutex, TryLockUnlock) {
+  pw::sync::TimedMutex mutex;
+  const bool locked = mutex.try_lock();
+  EXPECT_TRUE(locked);
+  if (locked) {
+    // TODO(pwbug/291): Ensure it fails to lock when already held.
+    // EXPECT_FALSE(mutex.try_lock());
+    mutex.unlock();
+  }
+}
+
+TEST(TimedMutex, TryLockUnlockFor) {
+  pw::sync::TimedMutex mutex;
+
+  SystemClock::time_point before = SystemClock::now();
+  const bool locked = mutex.try_lock_for(kRoundedArbitraryDuration);
+  EXPECT_TRUE(locked);
+  if (locked) {
+    SystemClock::duration time_elapsed = SystemClock::now() - before;
+    EXPECT_LT(time_elapsed, kRoundedArbitraryDuration);
+
+    // TODO(pwbug/291): Ensure it blocks fails to lock when already held.
+    // before = SystemClock::now();
+    // EXPECT_FALSE(mutex.try_lock_for(kRoundedArbitraryDuration));
+    // time_elapsed = SystemClock::now() - before;
+    /// EXPECT_GE(time_elapsed, kRoundedArbitraryDuration);
+
+    mutex.unlock();
+  }
+}
+
+TEST(TimedMutex, TryLockUnlockUntil) {
+  pw::sync::TimedMutex mutex;
+
+  const SystemClock::time_point deadline =
+      SystemClock::now() + kRoundedArbitraryDuration;
+  const bool locked = mutex.try_lock_until(deadline);
+  EXPECT_TRUE(locked);
+  if (locked) {
+    EXPECT_LT(SystemClock::now(), deadline);
+
+    // TODO(pwbug/291): Ensure it blocks fails to lock when already held.
+    // EXPECT_FALSE(
+    //     mutex.try_lock_until(SystemClock::now() +
+    //     kRoundedArbitraryDuration));
+    // EXPECT_GE(SystemClock::now(), deadline);
+
+    mutex.unlock();
+  }
+}
+
+TEST(TimedMutex, LockUnlockInC) {
+  pw::sync::TimedMutex mutex;
+  pw_sync_TimedMutex_CallLock(&mutex);
+  pw_sync_TimedMutex_CallUnlock(&mutex);
+}
+
+TEST(TimedMutex, TryLockUnlockInC) {
+  pw::sync::TimedMutex mutex;
+  ASSERT_TRUE(pw_sync_TimedMutex_CallTryLock(&mutex));
+  // TODO(pwbug/291): Ensure it fails to lock when already held.
+  // EXPECT_FALSE(pw_sync_TimedMutex_CallTryLock(&mutex));
+  pw_sync_TimedMutex_CallUnlock(&mutex);
+}
+
+TEST(TimedMutex, TryLockUnlockForInC) {
+  pw::sync::TimedMutex mutex;
+
+  pw_chrono_SystemClock_TimePoint before = pw_chrono_SystemClock_Now();
+  ASSERT_TRUE(
+      pw_sync_TimedMutex_CallTryLockFor(&mutex, kRoundedArbitraryDurationInC));
+  pw_chrono_SystemClock_Duration time_elapsed =
+      pw_chrono_SystemClock_TimeElapsed(before, pw_chrono_SystemClock_Now());
+  EXPECT_LT(time_elapsed.ticks, kRoundedArbitraryDurationInC.ticks);
+
+  // TODO(pwbug/291): Ensure it blocks fails to lock when already held.
+  // before = pw_chrono_SystemClock_Now();
+  // EXPECT_FALSE(
+  //     pw_sync_TimedMutex_CallTryLockFor(&mutex,
+  //     kRoundedArbitraryDurationInC));
+  // time_elapsed =
+  //    pw_chrono_SystemClock_TimeElapsed(before, pw_chrono_SystemClock_Now());
+  // EXPECT_GE(time_elapsed.ticks, kRoundedArbitraryDurationInC.ticks);
+
+  pw_sync_TimedMutex_CallUnlock(&mutex);
+}
+
+TEST(TimedMutex, TryLockUnlockUntilInC) {
+  pw::sync::TimedMutex mutex;
+  pw_chrono_SystemClock_TimePoint deadline;
+  deadline.duration_since_epoch.ticks =
+      pw_chrono_SystemClock_Now().duration_since_epoch.ticks +
+      kRoundedArbitraryDurationInC.ticks;
+  ASSERT_TRUE(pw_sync_TimedMutex_CallTryLockUntil(&mutex, deadline));
+  EXPECT_LT(pw_chrono_SystemClock_Now().duration_since_epoch.ticks,
+            deadline.duration_since_epoch.ticks);
+
+  // TODO(pwbug/291): Ensure it blocks fails to lock when already held.
+  // EXPECT_FALSE(pw_sync_TimedMutex_CallTryLockUntil(&mutex, deadline));
+  // EXPECT_GE(pw_chrono_SystemClock_Now().duration_since_epoch.ticks,
+  //           deadline.duration_since_epoch.ticks);
+
+  pw_sync_TimedMutex_CallUnlock(&mutex);
+}
+
+}  // namespace
+}  // namespace pw::sync
diff --git a/pw_sync/timed_mutex_facade_test_c.c b/pw_sync/timed_mutex_facade_test_c.c
new file mode 100644
index 0000000..50f5dcb
--- /dev/null
+++ b/pw_sync/timed_mutex_facade_test_c.c
@@ -0,0 +1,42 @@
+// 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.
+
+// These tests call the pw_sync module mutex API from C. The return values are
+// checked in the main C++ tests.
+
+#include <stdbool.h>
+
+#include "pw_sync/timed_mutex.h"
+
+void pw_sync_TimedMutex_CallLock(pw_sync_TimedMutex* mutex) {
+  pw_sync_TimedMutex_Lock(mutex);
+}
+
+bool pw_sync_TimedMutex_CallTryLock(pw_sync_TimedMutex* mutex) {
+  return pw_sync_TimedMutex_TryLock(mutex);
+}
+
+bool pw_sync_TimedMutex_CallTryLockFor(
+    pw_sync_TimedMutex* mutex, pw_chrono_SystemClock_Duration for_at_least) {
+  return pw_sync_TimedMutex_TryLockFor(mutex, for_at_least);
+}
+
+bool pw_sync_TimedMutex_CallTryLockUntil(
+    pw_sync_TimedMutex* mutex, pw_chrono_SystemClock_TimePoint until_at_least) {
+  return pw_sync_TimedMutex_TryLockUntil(mutex, until_at_least);
+}
+
+void pw_sync_TimedMutex_CallUnlock(pw_sync_TimedMutex* mutex) {
+  pw_sync_TimedMutex_Unlock(mutex);
+}
diff --git a/pw_sync_baremetal/BUILD b/pw_sync_baremetal/BUILD
new file mode 100644
index 0000000..16774df
--- /dev/null
+++ b/pw_sync_baremetal/BUILD
@@ -0,0 +1,46 @@
+# Copyright 2021 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.
+
+load(
+    "//pw_build:pigweed.bzl",
+    "pw_cc_library",
+)
+
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])  # Apache License 2.0
+
+pw_cc_library(
+    name = "interrupt_spin_lock_headers",
+    hdrs = [
+        "public/pw_sync_baremetal/interrupt_spin_lock_inline.h",
+        "public/pw_sync_baremetal/interrupt_spin_lock_native.h",
+        "public_overrides/pw_sync_backend/interrupt_spin_lock_inline.h",
+        "public_overrides/pw_sync_backend/interrupt_spin_lock_native.h",
+    ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+)
+
+pw_cc_library(
+    name = "interrupt_spin_lock",
+    deps = [
+        ":interrupt_spin_lock_headers",
+        "//pw_assert",
+        "//pw_sync:interrupt_spin_lock_facade",
+        "//pw_sync:yield_core",
+    ],
+)
diff --git a/pw_sync_baremetal/BUILD.gn b/pw_sync_baremetal/BUILD.gn
new file mode 100644
index 0000000..881e807
--- /dev/null
+++ b/pw_sync_baremetal/BUILD.gn
@@ -0,0 +1,57 @@
+# Copyright 2021 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/target_types.gni")
+import("$dir_pw_chrono/backend.gni")
+import("$dir_pw_docgen/docs.gni")
+
+config("public_include_path") {
+  include_dirs = [ "public" ]
+  visibility = [ ":*" ]
+}
+
+config("backend_config") {
+  include_dirs = [ "public_overrides" ]
+  visibility = [ ":*" ]
+}
+
+# This target provides the backend for pw::sync::InterruptSpinLock.
+# The provided implementation makes a single attempt to acquire the lock and
+# asserts if it is unavailable. It does not perform interrupt masking or disable
+# global interrupts, so this implementation does not support simultaneous
+# multi-threaded environments including IRQs, and is only meant to prevent
+# data corruption.
+pw_source_set("interrupt_spin_lock") {
+  public_configs = [
+    ":public_include_path",
+    ":backend_config",
+  ]
+  public = [
+    "public/pw_sync_baremetal/interrupt_spin_lock_inline.h",
+    "public/pw_sync_baremetal/interrupt_spin_lock_native.h",
+    "public_overrides/pw_sync_backend/interrupt_spin_lock_inline.h",
+    "public_overrides/pw_sync_backend/interrupt_spin_lock_native.h",
+  ]
+  public_deps = [
+    "$dir_pw_assert",
+    "$dir_pw_sync:interrupt_spin_lock.facade",
+    "$dir_pw_sync:yield_core",
+  ]
+}
+
+pw_doc_group("docs") {
+  sources = [ "docs.rst" ]
+}
diff --git a/pw_sync_baremetal/docs.rst b/pw_sync_baremetal/docs.rst
new file mode 100644
index 0000000..1fbd99e
--- /dev/null
+++ b/pw_sync_baremetal/docs.rst
@@ -0,0 +1,11 @@
+.. _module-pw_sync_baremetal:
+
+-----------------
+pw_sync_baremetal
+-----------------
+This is a set of backends for pw_sync that works on baremetal targets. It is not
+ready for use, and is under construction. The provided implementation makes a
+single attempt to acquire the lock and asserts if it is unavailable. It does not
+perform interrupt masking or disable global interrupts, so this implementation
+does not support simultaneous multi-threaded environments including IRQs, and is
+only meant to prevent data corruption.
diff --git a/pw_sync_baremetal/public/pw_sync_baremetal/interrupt_spin_lock_inline.h b/pw_sync_baremetal/public/pw_sync_baremetal/interrupt_spin_lock_inline.h
new file mode 100644
index 0000000..369c956
--- /dev/null
+++ b/pw_sync_baremetal/public/pw_sync_baremetal/interrupt_spin_lock_inline.h
@@ -0,0 +1,40 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "pw_assert/light.h"
+#include "pw_sync/interrupt_spin_lock.h"
+#include "pw_sync/yield_core.h"
+
+namespace pw::sync {
+
+constexpr InterruptSpinLock::InterruptSpinLock() : native_type_() {}
+
+inline void InterruptSpinLock::lock() { PW_ASSERT(try_lock()); }
+
+inline bool InterruptSpinLock::try_lock() {
+  // TODO(pwbug/303): Use the pw_interrupt API here to disable interrupts.
+  return !native_type_.test_and_set(std::memory_order_acquire);
+}
+
+inline void InterruptSpinLock::unlock() {
+  native_type_.clear(std::memory_order_release);
+}
+
+inline InterruptSpinLock::native_handle_type
+InterruptSpinLock::native_handle() {
+  return native_type_;
+}
+
+}  // namespace pw::sync
diff --git a/pw_sync_baremetal/public/pw_sync_baremetal/interrupt_spin_lock_native.h b/pw_sync_baremetal/public/pw_sync_baremetal/interrupt_spin_lock_native.h
new file mode 100644
index 0000000..1e23501
--- /dev/null
+++ b/pw_sync_baremetal/public/pw_sync_baremetal/interrupt_spin_lock_native.h
@@ -0,0 +1,23 @@
+// Copyright 2021 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.
+#pragma once
+
+#include <atomic>
+
+namespace pw::sync::backend {
+
+using NativeInterruptSpinLock = std::atomic_flag;
+using NativeInterruptSpinLockHandle = std::atomic_flag&;
+
+}  // namespace pw::sync::backend
diff --git a/pw_sync_baremetal/public_overrides/pw_sync_backend/interrupt_spin_lock_inline.h b/pw_sync_baremetal/public_overrides/pw_sync_backend/interrupt_spin_lock_inline.h
new file mode 100644
index 0000000..bd40b93
--- /dev/null
+++ b/pw_sync_baremetal/public_overrides/pw_sync_backend/interrupt_spin_lock_inline.h
@@ -0,0 +1,16 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "pw_sync_baremetal/interrupt_spin_lock_inline.h"
diff --git a/pw_sync_baremetal/public_overrides/pw_sync_backend/interrupt_spin_lock_native.h b/pw_sync_baremetal/public_overrides/pw_sync_backend/interrupt_spin_lock_native.h
new file mode 100644
index 0000000..cbe2f87
--- /dev/null
+++ b/pw_sync_baremetal/public_overrides/pw_sync_backend/interrupt_spin_lock_native.h
@@ -0,0 +1,16 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "pw_sync_baremetal/interrupt_spin_lock_native.h"
diff --git a/pw_sync_embos/BUILD b/pw_sync_embos/BUILD
new file mode 100644
index 0000000..da66e9e
--- /dev/null
+++ b/pw_sync_embos/BUILD
@@ -0,0 +1,174 @@
+# Copyright 2021 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.
+
+load(
+    "//pw_build:pigweed.bzl",
+    "pw_cc_library",
+)
+
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])  # Apache License 2.0
+
+pw_cc_library(
+    name = "binary_semaphore_headers",
+    hdrs = [
+        "public/pw_sync_embos/binary_semaphore_inline.h",
+        "public/pw_sync_embos/binary_semaphore_native.h",
+        "public_overrides/pw_sync_backend/binary_semaphore_inline.h",
+        "public_overrides/pw_sync_backend/binary_semaphore_native.h",
+    ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+    deps = [
+        # TODO(pwbug/317): This should depend on embOS but our third parties
+        # currently do not have Bazel support.
+        "//pw_chrono:system_clock",
+        "//pw_chrono_embos:system_clock_headers",
+    ],
+)
+
+pw_cc_library(
+    name = "binary_semaphore",
+    srcs = [
+        "binary_semaphore.cc",
+    ],
+    deps = [
+        ":binary_semaphore_headers",
+        "//pw_interrupt:context",
+        "//pw_sync:binary_semaphore_facade",
+    ],
+)
+
+pw_cc_library(
+    name = "counting_semaphore_headers",
+    hdrs = [
+        "public/pw_sync_embos/counting_semaphore_inline.h",
+        "public/pw_sync_embos/counting_semaphore_native.h",
+        "public_overrides/pw_sync_backend/counting_semaphore_inline.h",
+        "public_overrides/pw_sync_backend/counting_semaphore_native.h",
+    ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+    deps = [
+        # TODO(pwbug/317): This should depend on embOS but our third parties
+        # currently do not have Bazel support.
+        "//pw_chrono:system_clock",
+        "//pw_chrono_embos:system_clock_headers",
+    ],
+)
+
+pw_cc_library(
+    name = "counting_semaphore",
+    srcs = [
+        "counting_semaphore.cc",
+    ],
+    deps = [
+        ":counting_semaphore_headers",
+        "//pw_interrupt:context",
+        "//pw_sync:counting_semaphore_facade",
+    ],
+)
+
+
+pw_cc_library(
+    name = "mutex_headers",
+    hdrs = [
+        "public/pw_sync_embos/mutex_inline.h",
+        "public/pw_sync_embos/mutex_native.h",
+        "public_overrides/pw_sync_backend/mutex_inline.h",
+        "public_overrides/pw_sync_backend/mutex_native.h",
+    ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+    deps = [
+        # TODO(pwbug/317): This should depend on embOS but our third parties
+        # currently do not have Bazel support.
+        "//pw_sync:mutex_facade",
+    ],
+)
+
+pw_cc_library(
+    name = "mutex",
+    deps = [
+        ":mutex_headers",
+        "//pw_sync:mutex_facade",
+    ],
+)
+
+pw_cc_library(
+    name = "timed_mutex_headers",
+    hdrs = [
+        "public/pw_sync_embos/timed_mutex_inline.h",
+        "public_overrides/pw_sync_backend/timed_mutex_inline.h",
+    ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+    deps = [
+        # TODO(pwbug/317): This should depend on embOS but our third parties
+        # currently do not have Bazel support.
+        "//pw_chrono:system_clock",
+        "//pw_sync:timed_mutex_facade",
+    ],
+)
+
+pw_cc_library(
+    name = "timed_mutex",
+    srcs = [
+        "timed_mutex.cc",
+    ],
+    deps = [
+        ":timed_mutex_headers",
+        "//pw_interrupt:context",
+        "//pw_sync:timed_mutex_facade",
+        "//pw_chrono_embos:system_clock_headers",
+    ],
+)
+
+
+pw_cc_library(
+    name = "interrupt_spin_lock_headers",
+    hdrs = [
+        "public/pw_sync_embos/interrupt_spin_lock_inline.h",
+        "public/pw_sync_embos/interrupt_spin_lock_native.h",
+        "public_overrides/pw_sync_backend/interrupt_spin_lock_inline.h",
+        "public_overrides/pw_sync_backend/interrupt_spin_lock_native.h",
+    ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+    # TODO(pwbug/317): This should depend on embOS but our third parties
+    # currently do not have Bazel support.
+)
+
+pw_cc_library(
+    name = "interrupt_spin_lock",
+    srcs = [
+        "interrupt_spin_lock.cc",
+    ],
+    deps = [
+        ":interrupt_spin_lock_headers",
+        "//pw_sync:interrupt_spin_lock_facade",
+    ],
+)
+
diff --git a/pw_sync_embos/BUILD.gn b/pw_sync_embos/BUILD.gn
new file mode 100644
index 0000000..043ae72
--- /dev/null
+++ b/pw_sync_embos/BUILD.gn
@@ -0,0 +1,158 @@
+# Copyright 2021 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/target_types.gni")
+import("$dir_pw_chrono/backend.gni")
+import("$dir_pw_docgen/docs.gni")
+
+config("public_include_path") {
+  include_dirs = [ "public" ]
+  visibility = [ ":*" ]
+}
+
+config("backend_config") {
+  include_dirs = [ "public_overrides" ]
+  visibility = [ ":*" ]
+}
+
+# This target provides the backend for pw::sync::BinarySemaphore.
+pw_source_set("binary_semaphore") {
+  public_configs = [
+    ":public_include_path",
+    ":backend_config",
+  ]
+  public = [
+    "public/pw_sync_embos/binary_semaphore_inline.h",
+    "public/pw_sync_embos/binary_semaphore_native.h",
+    "public_overrides/pw_sync_backend/binary_semaphore_inline.h",
+    "public_overrides/pw_sync_backend/binary_semaphore_native.h",
+  ]
+  public_deps = [
+    "$dir_pw_assert",
+    "$dir_pw_chrono:system_clock",
+    "$dir_pw_chrono_embos:system_clock",
+    "$dir_pw_interrupt:context",
+    "$dir_pw_third_party/embos",
+  ]
+  sources = [ "binary_semaphore.cc" ]
+  deps = [ "$dir_pw_sync:binary_semaphore.facade" ]
+  assert(
+      pw_chrono_SYSTEM_CLOCK_BACKEND == "" ||
+          pw_chrono_SYSTEM_CLOCK_BACKEND == "$dir_pw_chrono_embos:system_clock",
+      "The embOS pw::sync::BinarySemaphore backend only works with the " +
+          "embOS pw::chrono::SystemClock backend.")
+}
+
+# This target provides the backend for pw::sync::CountingSemaphore.
+pw_source_set("counting_semaphore") {
+  public_configs = [
+    ":public_include_path",
+    ":backend_config",
+  ]
+  public = [
+    "public/pw_sync_embos/counting_semaphore_inline.h",
+    "public/pw_sync_embos/counting_semaphore_native.h",
+    "public_overrides/pw_sync_backend/counting_semaphore_inline.h",
+    "public_overrides/pw_sync_backend/counting_semaphore_native.h",
+  ]
+  public_deps = [
+    "$dir_pw_assert",
+    "$dir_pw_chrono:system_clock",
+    "$dir_pw_chrono_embos:system_clock",
+    "$dir_pw_interrupt:context",
+    "$dir_pw_third_party/embos",
+  ]
+  sources = [ "counting_semaphore.cc" ]
+  deps = [ "$dir_pw_sync:counting_semaphore.facade" ]
+  assert(
+      pw_chrono_SYSTEM_CLOCK_BACKEND == "" ||
+          pw_chrono_SYSTEM_CLOCK_BACKEND == "$dir_pw_chrono_embos:system_clock",
+      "The embOS pw::sync::CountingSemaphore backend only works with " +
+          "the embOS pw::chrono::SystemClock backend.")
+}
+
+# This target provides the backend for pw::sync::Mutex.
+pw_source_set("mutex") {
+  public_configs = [
+    ":public_include_path",
+    ":backend_config",
+  ]
+  public = [
+    "public/pw_sync_embos/mutex_inline.h",
+    "public/pw_sync_embos/mutex_native.h",
+    "public_overrides/pw_sync_backend/mutex_inline.h",
+    "public_overrides/pw_sync_backend/mutex_native.h",
+  ]
+  public_deps = [
+    "$dir_pw_assert",
+    "$dir_pw_interrupt:context",
+    "$dir_pw_sync:mutex.facade",
+    "$dir_pw_third_party/embos",
+  ]
+}
+
+# This target provides the backend for pw::sync::TimedMutex.
+pw_source_set("timed_mutex") {
+  public_configs = [
+    ":public_include_path",
+    ":backend_config",
+  ]
+  public = [
+    "public/pw_sync_embos/timed_mutex_inline.h",
+    "public_overrides/pw_sync_backend/timed_mutex_inline.h",
+  ]
+  public_deps = [
+    "$dir_pw_chrono:system_clock",
+    "$dir_pw_sync:timed_mutex.facade",
+  ]
+  sources = [ "timed_mutex.cc" ]
+  deps = [
+    "$dir_pw_assert",
+    "$dir_pw_chrono_embos:system_clock",
+    "$dir_pw_interrupt:context",
+    "$dir_pw_third_party/embos",
+  ]
+  assert(
+      pw_chrono_SYSTEM_CLOCK_BACKEND == "" ||
+          pw_chrono_SYSTEM_CLOCK_BACKEND == "$dir_pw_chrono_embos:system_clock",
+      "The embOS pw::sync::Mutex backend only works with the embOS " +
+          "pw::chrono::SystemClock backend.")
+}
+
+# This target provides the backend for pw::sync::InterruptSpinLock.
+pw_source_set("interrupt_spin_lock") {
+  public_configs = [
+    ":public_include_path",
+    ":backend_config",
+  ]
+  public = [
+    "public/pw_sync_embos/interrupt_spin_lock_inline.h",
+    "public/pw_sync_embos/interrupt_spin_lock_native.h",
+    "public_overrides/pw_sync_backend/interrupt_spin_lock_inline.h",
+    "public_overrides/pw_sync_backend/interrupt_spin_lock_native.h",
+  ]
+  public_deps = [ "$dir_pw_third_party/embos" ]
+  sources = [ "interrupt_spin_lock.cc" ]
+  deps = [
+    "$dir_pw_assert",
+    "$dir_pw_sync:interrupt_spin_lock.facade",
+    "$dir_pw_third_party/embos",
+  ]
+}
+
+pw_doc_group("docs") {
+  sources = [ "docs.rst" ]
+}
diff --git a/pw_sync_embos/binary_semaphore.cc b/pw_sync_embos/binary_semaphore.cc
new file mode 100644
index 0000000..7f77afe
--- /dev/null
+++ b/pw_sync_embos/binary_semaphore.cc
@@ -0,0 +1,52 @@
+// Copyright 2021 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.
+
+#include "pw_sync/binary_semaphore.h"
+
+#include <algorithm>
+
+#include "RTOS.h"
+#include "pw_assert/assert.h"
+#include "pw_chrono/system_clock.h"
+#include "pw_chrono_embos/system_clock_constants.h"
+#include "pw_interrupt/context.h"
+
+using pw::chrono::SystemClock;
+
+namespace pw::sync {
+
+bool BinarySemaphore::try_acquire_for(SystemClock::duration for_at_least) {
+  PW_DCHECK(!interrupt::InInterruptContext());
+
+  // Use non-blocking try_acquire for negative and zero length durations.
+  if (for_at_least <= SystemClock::duration::zero()) {
+    return try_acquire();
+  }
+
+  // On a tick based kernel we cannot tell how far along we are on the current
+  // tick, ergo we add one whole tick to the final duration.
+  constexpr SystemClock::duration kMaxTimeoutMinusOne =
+      pw::chrono::embos::kMaxTimeout - SystemClock::duration(1);
+  while (for_at_least > kMaxTimeoutMinusOne) {
+    if (OS_WaitCSemaTimed(&native_type_,
+                          static_cast<OS_TIME>(kMaxTimeoutMinusOne.count()))) {
+      return true;
+    }
+    for_at_least -= kMaxTimeoutMinusOne;
+  }
+  return OS_WaitCSemaTimed(&native_type_,
+                           static_cast<OS_TIME>(for_at_least.count() + 1));
+}
+
+}  // namespace pw::sync
diff --git a/pw_sync_embos/counting_semaphore.cc b/pw_sync_embos/counting_semaphore.cc
new file mode 100644
index 0000000..a2dd40e
--- /dev/null
+++ b/pw_sync_embos/counting_semaphore.cc
@@ -0,0 +1,66 @@
+// Copyright 2021 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.
+
+#include "pw_sync/counting_semaphore.h"
+
+#include <algorithm>
+
+#include "RTOS.h"
+#include "pw_assert/assert.h"
+#include "pw_chrono/system_clock.h"
+#include "pw_chrono_embos/system_clock_constants.h"
+#include "pw_interrupt/context.h"
+
+using pw::chrono::SystemClock;
+
+namespace pw::sync {
+
+void CountingSemaphore::release(ptrdiff_t update) {
+  for (; update > 0; --update) {
+    // There is no API to atomically detect overflow, however debug builds of
+    // embOS call OS_Error() internally when overflow is detected for the native
+    // token representation. Rather than enter a critical section both due to
+    // cost and potential direct use of the native handle, a lazy check is used
+    // for debug builds which may not trigger on the initial overflow.
+    PW_DCHECK_UINT_LE(OS_GetCSemaValue(&native_type_),
+                      CountingSemaphore::max(),
+                      "Overflowed counting semaphore.");
+    OS_SignalCSema(&native_type_);
+  }
+}
+
+bool CountingSemaphore::try_acquire_for(SystemClock::duration for_at_least) {
+  PW_DCHECK(!interrupt::InInterruptContext());
+
+  // Use non-blocking try_acquire for negative and zero length durations.
+  if (for_at_least <= SystemClock::duration::zero()) {
+    return try_acquire();
+  }
+
+  // On a tick based kernel we cannot tell how far along we are on the current
+  // tick, ergo we add one whole tick to the final duration.
+  constexpr SystemClock::duration kMaxTimeoutMinusOne =
+      pw::chrono::embos::kMaxTimeout - SystemClock::duration(1);
+  while (for_at_least > kMaxTimeoutMinusOne) {
+    if (OS_WaitCSemaTimed(&native_type_,
+                          static_cast<OS_TIME>(kMaxTimeoutMinusOne.count()))) {
+      return true;
+    }
+    for_at_least -= kMaxTimeoutMinusOne;
+  }
+  return OS_WaitCSemaTimed(&native_type_,
+                           static_cast<OS_TIME>(for_at_least.count() + 1));
+}
+
+}  // namespace pw::sync
diff --git a/pw_sync_embos/docs.rst b/pw_sync_embos/docs.rst
new file mode 100644
index 0000000..19e1636
--- /dev/null
+++ b/pw_sync_embos/docs.rst
@@ -0,0 +1,8 @@
+.. _module-pw_sync_embos:
+
+-------------
+pw_sync_embos
+-------------
+This is a set of backends for pw_sync based on embOS v4. It is not ready for
+use, and is under construction.
+
diff --git a/pw_sync_embos/interrupt_spin_lock.cc b/pw_sync_embos/interrupt_spin_lock.cc
new file mode 100644
index 0000000..9d17ba6
--- /dev/null
+++ b/pw_sync_embos/interrupt_spin_lock.cc
@@ -0,0 +1,45 @@
+// Copyright 2021 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.
+
+#include "pw_sync/interrupt_spin_lock.h"
+
+#include "RTOS.h"
+#include "pw_assert/assert.h"
+
+namespace pw::sync {
+
+void InterruptSpinLock::lock() {
+  OS_IncDI();
+  // We can't deadlock here so crash instead.
+  PW_CHECK(!native_type_.locked.load(std::memory_order_relaxed),
+           "Recursive InterruptSpinLock::lock() detected");
+  native_type_.locked.store(true, std::memory_order_relaxed);
+}
+
+bool InterruptSpinLock::try_lock() {
+  OS_IncDI();
+  if (native_type_.locked.load(std::memory_order_relaxed)) {
+    OS_DecRI();  // Already locked, restore interrupts and bail out.
+    return false;
+  }
+  native_type_.locked.store(true, std::memory_order_relaxed);
+  return true;
+}
+
+void InterruptSpinLock::unlock() {
+  native_type_.locked.store(false, std::memory_order_relaxed);
+  OS_DecRI();
+}
+
+}  // namespace pw::sync
diff --git a/pw_sync_embos/public/pw_sync_embos/binary_semaphore_inline.h b/pw_sync_embos/public/pw_sync_embos/binary_semaphore_inline.h
new file mode 100644
index 0000000..d8abf72
--- /dev/null
+++ b/pw_sync_embos/public/pw_sync_embos/binary_semaphore_inline.h
@@ -0,0 +1,53 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "RTOS.h"
+#include "pw_assert/light.h"
+#include "pw_chrono/system_clock.h"
+#include "pw_chrono_embos/system_clock_constants.h"
+#include "pw_interrupt/context.h"
+#include "pw_sync/binary_semaphore.h"
+
+namespace pw::sync {
+
+inline BinarySemaphore::BinarySemaphore() : native_type_() {
+  OS_CreateCSema(&native_type_, 0);
+}
+
+inline BinarySemaphore::~BinarySemaphore() { OS_DeleteCSema(&native_type_); }
+
+inline void BinarySemaphore::release() { OS_SignalCSemaMax(&native_type_, 1); }
+
+inline void BinarySemaphore::acquire() {
+  PW_ASSERT(!interrupt::InInterruptContext());
+  OS_WaitCSema(&native_type_);
+}
+
+inline bool BinarySemaphore::try_acquire() noexcept {
+  return OS_CSemaRequest(&native_type_) != 0;
+}
+
+inline bool BinarySemaphore::try_acquire_until(
+    chrono::SystemClock::time_point until_at_least) {
+  // Note that if this deadline is in the future, it will get rounded up by
+  // one whole tick due to how try_acquire_for is implemented.
+  return try_acquire_for(until_at_least - chrono::SystemClock::now());
+}
+
+inline BinarySemaphore::native_handle_type BinarySemaphore::native_handle() {
+  return native_type_;
+}
+
+}  // namespace pw::sync
diff --git a/pw_sync_embos/public/pw_sync_embos/binary_semaphore_native.h b/pw_sync_embos/public/pw_sync_embos/binary_semaphore_native.h
new file mode 100644
index 0000000..c92afa9
--- /dev/null
+++ b/pw_sync_embos/public/pw_sync_embos/binary_semaphore_native.h
@@ -0,0 +1,30 @@
+// Copyright 2021 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.
+#pragma once
+
+#include <limits>
+
+#include "RTOS.h"
+
+namespace pw::sync::backend {
+
+using NativeBinarySemaphore = OS_CSEMA;
+using NativeBinarySemaphoreHandle = NativeBinarySemaphore&;
+
+inline constexpr ptrdiff_t kBinarySemaphoreMaxValue =
+    std::numeric_limits<ptrdiff_t>::max() < std::numeric_limits<OS_UINT>::max()
+        ? std::numeric_limits<ptrdiff_t>::max()
+        : std::numeric_limits<OS_UINT>::max();
+
+}  // namespace pw::sync::backend
diff --git a/pw_sync_embos/public/pw_sync_embos/counting_semaphore_inline.h b/pw_sync_embos/public/pw_sync_embos/counting_semaphore_inline.h
new file mode 100644
index 0000000..55ad150
--- /dev/null
+++ b/pw_sync_embos/public/pw_sync_embos/counting_semaphore_inline.h
@@ -0,0 +1,54 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "RTOS.h"
+#include "pw_assert/light.h"
+#include "pw_chrono/system_clock.h"
+#include "pw_chrono_embos/system_clock_constants.h"
+#include "pw_interrupt/context.h"
+#include "pw_sync/counting_semaphore.h"
+
+namespace pw::sync {
+
+inline CountingSemaphore::CountingSemaphore() : native_type_() {
+  OS_CreateCSema(&native_type_, 0);
+}
+
+inline CountingSemaphore::~CountingSemaphore() {
+  OS_DeleteCSema(&native_type_);
+}
+
+inline void CountingSemaphore::acquire() {
+  PW_ASSERT(!interrupt::InInterruptContext());
+  OS_WaitCSema(&native_type_);
+}
+
+inline bool CountingSemaphore::try_acquire() noexcept {
+  return OS_CSemaRequest(&native_type_) != 0;
+}
+
+inline bool CountingSemaphore::try_acquire_until(
+    chrono::SystemClock::time_point until_at_least) {
+  // Note that if this deadline is in the future, it will get rounded up by
+  // one whole tick due to how try_acquire_for is implemented.
+  return try_acquire_for(until_at_least - chrono::SystemClock::now());
+}
+
+inline CountingSemaphore::native_handle_type
+CountingSemaphore::native_handle() {
+  return native_type_;
+}
+
+}  // namespace pw::sync
diff --git a/pw_sync_embos/public/pw_sync_embos/counting_semaphore_native.h b/pw_sync_embos/public/pw_sync_embos/counting_semaphore_native.h
new file mode 100644
index 0000000..56959b6
--- /dev/null
+++ b/pw_sync_embos/public/pw_sync_embos/counting_semaphore_native.h
@@ -0,0 +1,30 @@
+// Copyright 2021 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.
+#pragma once
+
+#include <limits>
+
+#include "RTOS.h"
+
+namespace pw::sync::backend {
+
+using NativeCountingSemaphore = OS_CSEMA;
+using NativeCountingSemaphoreHandle = NativeCountingSemaphore&;
+
+inline constexpr ptrdiff_t kCountingSemaphoreMaxValue =
+    std::numeric_limits<ptrdiff_t>::max() < std::numeric_limits<OS_UINT>::max()
+        ? std::numeric_limits<ptrdiff_t>::max()
+        : std::numeric_limits<OS_UINT>::max();
+
+}  // namespace pw::sync::backend
diff --git a/pw_sync_embos/public/pw_sync_embos/interrupt_spin_lock_inline.h b/pw_sync_embos/public/pw_sync_embos/interrupt_spin_lock_inline.h
new file mode 100644
index 0000000..0484b3c
--- /dev/null
+++ b/pw_sync_embos/public/pw_sync_embos/interrupt_spin_lock_inline.h
@@ -0,0 +1,28 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "pw_sync/interrupt_spin_lock.h"
+
+namespace pw::sync {
+
+constexpr InterruptSpinLock::InterruptSpinLock()
+    : native_type_{.locked{false}} {}
+
+inline InterruptSpinLock::native_handle_type
+InterruptSpinLock::native_handle() {
+  return native_type_;
+}
+
+}  // namespace pw::sync
diff --git a/pw_sync_embos/public/pw_sync_embos/interrupt_spin_lock_native.h b/pw_sync_embos/public/pw_sync_embos/interrupt_spin_lock_native.h
new file mode 100644
index 0000000..3c91f64
--- /dev/null
+++ b/pw_sync_embos/public/pw_sync_embos/interrupt_spin_lock_native.h
@@ -0,0 +1,27 @@
+// Copyright 2021 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.
+#pragma once
+
+#include <atomic>
+
+#include "RTOS.h"
+
+namespace pw::sync::backend {
+
+struct NativeInterruptSpinLock {
+  std::atomic<bool> locked;  // Used to detect recursion.
+};
+using NativeInterruptSpinLockHandle = NativeInterruptSpinLock&;
+
+}  // namespace pw::sync::backend
diff --git a/pw_sync_embos/public/pw_sync_embos/mutex_inline.h b/pw_sync_embos/public/pw_sync_embos/mutex_inline.h
new file mode 100644
index 0000000..9f8c147
--- /dev/null
+++ b/pw_sync_embos/public/pw_sync_embos/mutex_inline.h
@@ -0,0 +1,45 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "RTOS.h"
+#include "pw_assert/light.h"
+#include "pw_interrupt/context.h"
+#include "pw_sync/mutex.h"
+
+namespace pw::sync {
+
+inline Mutex::Mutex() : native_type_() { OS_CreateRSema(&native_type_); }
+
+inline Mutex::~Mutex() { OS_DeleteRSema(&native_type_); }
+
+inline void Mutex::lock() {
+  PW_ASSERT(!interrupt::InInterruptContext());
+  const int lock_count = OS_Use(&native_type_);
+  PW_ASSERT(lock_count == 1);  // Recursive locking is not permitted.
+}
+
+inline bool Mutex::try_lock() {
+  PW_ASSERT(!interrupt::InInterruptContext());
+  return OS_Request(&native_type_) != 0;
+}
+
+inline void Mutex::unlock() {
+  PW_ASSERT(!interrupt::InInterruptContext());
+  OS_Unuse(&native_type_);
+}
+
+inline Mutex::native_handle_type Mutex::native_handle() { return native_type_; }
+
+}  // namespace pw::sync
diff --git a/pw_sync_embos/public/pw_sync_embos/mutex_native.h b/pw_sync_embos/public/pw_sync_embos/mutex_native.h
new file mode 100644
index 0000000..cf6846e
--- /dev/null
+++ b/pw_sync_embos/public/pw_sync_embos/mutex_native.h
@@ -0,0 +1,23 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "RTOS.h"
+
+namespace pw::sync::backend {
+
+using NativeMutex = OS_RSEMA;
+using NativeMutexHandle = NativeMutex&;
+
+}  // namespace pw::sync::backend
diff --git a/pw_sync_embos/public/pw_sync_embos/timed_mutex_inline.h b/pw_sync_embos/public/pw_sync_embos/timed_mutex_inline.h
new file mode 100644
index 0000000..2ea7bff
--- /dev/null
+++ b/pw_sync_embos/public/pw_sync_embos/timed_mutex_inline.h
@@ -0,0 +1,28 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "pw_chrono/system_clock.h"
+#include "pw_sync/timed_mutex.h"
+
+namespace pw::sync {
+
+inline bool TimedMutex::try_lock_until(
+    chrono::SystemClock::time_point until_at_least) {
+  // Note that if this deadline is in the future, it will get rounded up by
+  // one whole tick due to how try_lock_for is implemented.
+  return try_lock_for(until_at_least - chrono::SystemClock::now());
+}
+
+}  // namespace pw::sync
diff --git a/pw_sync_embos/public_overrides/pw_sync_backend/binary_semaphore_inline.h b/pw_sync_embos/public_overrides/pw_sync_backend/binary_semaphore_inline.h
new file mode 100644
index 0000000..0179e82
--- /dev/null
+++ b/pw_sync_embos/public_overrides/pw_sync_backend/binary_semaphore_inline.h
@@ -0,0 +1,16 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "pw_sync_embos/binary_semaphore_inline.h"
diff --git a/pw_sync_embos/public_overrides/pw_sync_backend/binary_semaphore_native.h b/pw_sync_embos/public_overrides/pw_sync_backend/binary_semaphore_native.h
new file mode 100644
index 0000000..e056b63
--- /dev/null
+++ b/pw_sync_embos/public_overrides/pw_sync_backend/binary_semaphore_native.h
@@ -0,0 +1,16 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "pw_sync_embos/binary_semaphore_native.h"
diff --git a/pw_sync_embos/public_overrides/pw_sync_backend/counting_semaphore_inline.h b/pw_sync_embos/public_overrides/pw_sync_backend/counting_semaphore_inline.h
new file mode 100644
index 0000000..aa4ffa6
--- /dev/null
+++ b/pw_sync_embos/public_overrides/pw_sync_backend/counting_semaphore_inline.h
@@ -0,0 +1,16 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "pw_sync_embos/counting_semaphore_inline.h"
diff --git a/pw_sync_embos/public_overrides/pw_sync_backend/counting_semaphore_native.h b/pw_sync_embos/public_overrides/pw_sync_backend/counting_semaphore_native.h
new file mode 100644
index 0000000..9b98e24
--- /dev/null
+++ b/pw_sync_embos/public_overrides/pw_sync_backend/counting_semaphore_native.h
@@ -0,0 +1,16 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "pw_sync_embos/counting_semaphore_native.h"
diff --git a/pw_sync_embos/public_overrides/pw_sync_backend/interrupt_spin_lock_inline.h b/pw_sync_embos/public_overrides/pw_sync_backend/interrupt_spin_lock_inline.h
new file mode 100644
index 0000000..137d3dc
--- /dev/null
+++ b/pw_sync_embos/public_overrides/pw_sync_backend/interrupt_spin_lock_inline.h
@@ -0,0 +1,16 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "pw_sync_embos/interrupt_spin_lock_inline.h"
diff --git a/pw_sync_embos/public_overrides/pw_sync_backend/interrupt_spin_lock_native.h b/pw_sync_embos/public_overrides/pw_sync_backend/interrupt_spin_lock_native.h
new file mode 100644
index 0000000..e4e36f9
--- /dev/null
+++ b/pw_sync_embos/public_overrides/pw_sync_backend/interrupt_spin_lock_native.h
@@ -0,0 +1,16 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "pw_sync_embos/interrupt_spin_lock_native.h"
diff --git a/pw_sync_embos/public_overrides/pw_sync_backend/mutex_inline.h b/pw_sync_embos/public_overrides/pw_sync_backend/mutex_inline.h
new file mode 100644
index 0000000..a75f3b2
--- /dev/null
+++ b/pw_sync_embos/public_overrides/pw_sync_backend/mutex_inline.h
@@ -0,0 +1,16 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "pw_sync_embos/mutex_inline.h"
diff --git a/pw_sync_embos/public_overrides/pw_sync_backend/mutex_native.h b/pw_sync_embos/public_overrides/pw_sync_backend/mutex_native.h
new file mode 100644
index 0000000..2710b26
--- /dev/null
+++ b/pw_sync_embos/public_overrides/pw_sync_backend/mutex_native.h
@@ -0,0 +1,16 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "pw_sync_embos/mutex_native.h"
diff --git a/pw_sync_embos/public_overrides/pw_sync_backend/timed_mutex_inline.h b/pw_sync_embos/public_overrides/pw_sync_backend/timed_mutex_inline.h
new file mode 100644
index 0000000..467eb9f
--- /dev/null
+++ b/pw_sync_embos/public_overrides/pw_sync_backend/timed_mutex_inline.h
@@ -0,0 +1,16 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "pw_sync_embos/timed_mutex_inline.h"
diff --git a/pw_sync_embos/timed_mutex.cc b/pw_sync_embos/timed_mutex.cc
new file mode 100644
index 0000000..c08bef4
--- /dev/null
+++ b/pw_sync_embos/timed_mutex.cc
@@ -0,0 +1,56 @@
+// Copyright 2021 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.
+
+#include "pw_sync/timed_mutex.h"
+
+#include <algorithm>
+
+#include "RTOS.h"
+#include "pw_assert/assert.h"
+#include "pw_chrono/system_clock.h"
+#include "pw_chrono_embos/system_clock_constants.h"
+#include "pw_interrupt/context.h"
+
+using pw::chrono::SystemClock;
+
+namespace pw::sync {
+
+bool TimedMutex::try_lock_for(SystemClock::duration for_at_least) {
+  PW_DCHECK(!interrupt::InInterruptContext());
+
+  // Use non-blocking try_lock for negative and zero length durations.
+  if (for_at_least <= SystemClock::duration::zero()) {
+    return try_lock();
+  }
+
+  // On a tick based kernel we cannot tell how far along we are on the current
+  // tick, ergo we add one whole tick to the final duration.
+  constexpr SystemClock::duration kMaxTimeoutMinusOne =
+      pw::chrono::embos::kMaxTimeout - SystemClock::duration(1);
+  while (for_at_least > kMaxTimeoutMinusOne) {
+    const int lock_count = OS_UseTimed(
+        &native_handle(), static_cast<OS_TIME>(kMaxTimeoutMinusOne.count()));
+    if (lock_count != 0) {
+      PW_CHECK_UINT_EQ(1, lock_count, "Recursive locking is not permitted");
+      return true;
+    }
+    for_at_least -= kMaxTimeoutMinusOne;
+  }
+  const int lock_count = OS_UseTimed(
+      &native_handle(), static_cast<OS_TIME>(for_at_least.count() + 1));
+  PW_CHECK_UINT_LE(1, lock_count, "Recursive locking is not permitted");
+  return lock_count == 1;
+}
+
+}  // namespace pw::sync
diff --git a/pw_sync_freertos/BUILD b/pw_sync_freertos/BUILD
new file mode 100644
index 0000000..7ad2759
--- /dev/null
+++ b/pw_sync_freertos/BUILD
@@ -0,0 +1,172 @@
+# 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.
+
+load(
+    "//pw_build:pigweed.bzl",
+    "pw_cc_library",
+)
+
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])  # Apache License 2.0
+
+pw_cc_library(
+    name = "binary_semaphore_headers",
+    hdrs = [
+        "public/pw_sync_freertos/binary_semaphore_inline.h",
+        "public/pw_sync_freertos/binary_semaphore_native.h",
+        "public_overrides/pw_sync_backend/binary_semaphore_inline.h",
+        "public_overrides/pw_sync_backend/binary_semaphore_native.h",
+    ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+    deps = [
+        # TODO: This should depend on FreeRTOS but our third parties currently
+        # do not have Bazel support.
+        "//pw_chrono:system_clock",
+        "//pw_chrono_freertos:system_clock_headers",
+    ],
+)
+
+pw_cc_library(
+    name = "binary_semaphore",
+    srcs = [
+        "binary_semaphore.cc",
+    ],
+    deps = [
+        ":binary_semaphore_headers",
+        "//pw_interrupt:context",
+        "//pw_sync:binary_semaphore_facade",
+    ],
+)
+
+pw_cc_library(
+    name = "counting_semaphore_headers",
+    hdrs = [
+        "public/pw_sync_freertos/counting_semaphore_inline.h",
+        "public/pw_sync_freertos/counting_semaphore_native.h",
+        "public_overrides/pw_sync_backend/counting_semaphore_inline.h",
+        "public_overrides/pw_sync_backend/counting_semaphore_native.h",
+    ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+    deps = [
+        # TODO: This should depend on FreeRTOS but our third parties currently
+        # do not have Bazel support.
+        "//pw_chrono:system_clock",
+        "//pw_chrono_freertos:system_clock_headers",
+    ],
+)
+
+pw_cc_library(
+    name = "counting_semaphore",
+    srcs = [
+        "counting_semaphore.cc",
+    ],
+    deps = [
+        ":counting_semaphore_headers",
+        "//pw_interrupt:context",
+        "//pw_sync:counting_semaphore_facade",
+    ],
+)
+
+pw_cc_library(
+    name = "mutex_headers",
+    hdrs = [
+        "public/pw_sync_freertos/mutex_inline.h",
+        "public/pw_sync_freertos/mutex_native.h",
+        "public_overrides/pw_sync_backend/mutex_inline.h",
+        "public_overrides/pw_sync_backend/mutex_native.h",
+    ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+    deps = [
+        # TODO: This should depend on FreeRTOS but our third parties currently
+        # do not have Bazel support.
+        "//pw_sync:mutex_facade",
+    ],
+)
+
+pw_cc_library(
+    name = "mutex",
+    deps = [
+        ":mutex_headers",
+        "//pw_sync:mutex_facade",
+    ],
+)
+
+pw_cc_library(
+    name = "timed_mutex_headers",
+    hdrs = [
+        "public/pw_sync_freertos/timed_mutex_inline.h",
+        "public_overrides/pw_sync_backend/timed_mutex_inline.h",
+    ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+    deps = [
+        # TODO: This should depend on FreeRTOS but our third parties currently
+        # do not have Bazel support.
+        "//pw_chrono:system_clock",
+        "//pw_chrono_freertos:system_clock_headers",
+        "//pw_sync:timed_mutex_facade",
+    ],
+)
+
+pw_cc_library(
+    name = "timed_mutex",
+    srcs = [
+        "timed_mutex.cc",
+    ],
+    deps = [
+        ":timed_mutex_headers",
+        "//pw_interrupt:context",
+        "//pw_sync:timed_mutex_facade",
+    ],
+)
+
+pw_cc_library(
+    name = "interrupt_spin_lock_headers",
+    hdrs = [
+        "public/pw_sync_freertos/interrupt_spin_lock_inline.h",
+        "public/pw_sync_freertos/interrupt_spin_lock_native.h",
+        "public_overrides/pw_sync_backend/interrupt_spin_lock_inline.h",
+        "public_overrides/pw_sync_backend/interrupt_spin_lock_native.h",
+    ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+    # TODO: This should depend on FreeRTOS but our third parties currently
+    # do not have Bazel support.
+)
+
+pw_cc_library(
+    name = "interrupt_spin_lock",
+    srcs = [
+        "interrupt_spin_lock.cc",
+    ],
+    deps = [
+        ":interrupt_spin_lock_headers",
+        "//pw_interrupt:context",
+        "//pw_sync:interrupt_spin_lock_facade",
+    ],
+)
diff --git a/pw_sync_freertos/BUILD.gn b/pw_sync_freertos/BUILD.gn
new file mode 100644
index 0000000..425ef4a
--- /dev/null
+++ b/pw_sync_freertos/BUILD.gn
@@ -0,0 +1,159 @@
+# 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/target_types.gni")
+import("$dir_pw_chrono/backend.gni")
+import("$dir_pw_docgen/docs.gni")
+
+config("public_include_path") {
+  include_dirs = [ "public" ]
+  visibility = [ ":*" ]
+}
+
+config("backend_config") {
+  include_dirs = [ "public_overrides" ]
+  visibility = [ ":*" ]
+}
+
+# This target provides the backend for pw::sync::BinarySemaphore.
+pw_source_set("binary_semaphore") {
+  public_configs = [
+    ":public_include_path",
+    ":backend_config",
+  ]
+  public = [
+    "public/pw_sync_freertos/binary_semaphore_inline.h",
+    "public/pw_sync_freertos/binary_semaphore_native.h",
+    "public_overrides/pw_sync_backend/binary_semaphore_inline.h",
+    "public_overrides/pw_sync_backend/binary_semaphore_native.h",
+  ]
+  public_deps = [
+    "$dir_pw_assert",
+    "$dir_pw_chrono:system_clock",
+    "$dir_pw_chrono_freertos:system_clock",
+    "$dir_pw_interrupt:context",
+    "$dir_pw_third_party/freertos",
+  ]
+  sources = [ "binary_semaphore.cc" ]
+  deps = [ "$dir_pw_sync:binary_semaphore.facade" ]
+  assert(pw_chrono_SYSTEM_CLOCK_BACKEND == "" ||
+             pw_chrono_SYSTEM_CLOCK_BACKEND ==
+                 "$dir_pw_chrono_freertos:system_clock",
+         "The FreeRTOS pw::sync::BinarySemaphore backend only works with the " +
+             "FreeRTOS pw::chrono::SystemClock backend.")
+}
+
+# This target provides the backend for pw::sync::CountingSemaphore.
+pw_source_set("counting_semaphore") {
+  public_configs = [
+    ":public_include_path",
+    ":backend_config",
+  ]
+  public = [
+    "public/pw_sync_freertos/counting_semaphore_inline.h",
+    "public/pw_sync_freertos/counting_semaphore_native.h",
+    "public_overrides/pw_sync_backend/counting_semaphore_inline.h",
+    "public_overrides/pw_sync_backend/counting_semaphore_native.h",
+  ]
+  public_deps = [
+    "$dir_pw_assert",
+    "$dir_pw_chrono:system_clock",
+    "$dir_pw_chrono_freertos:system_clock",
+    "$dir_pw_interrupt:context",
+    "$dir_pw_third_party/freertos",
+  ]
+  sources = [ "counting_semaphore.cc" ]
+  deps = [ "$dir_pw_sync:counting_semaphore.facade" ]
+  assert(pw_chrono_SYSTEM_CLOCK_BACKEND == "" ||
+             pw_chrono_SYSTEM_CLOCK_BACKEND ==
+                 "$dir_pw_chrono_freertos:system_clock",
+         "The FreeRTOS pw::sync::CountingSemaphore backend only works with " +
+             "the FreeRTOS pw::chrono::SystemClock backend.")
+}
+
+# This target provides the backend for pw::sync::Mutex.
+pw_source_set("mutex") {
+  public_configs = [
+    ":public_include_path",
+    ":backend_config",
+  ]
+  public = [
+    "public/pw_sync_freertos/mutex_inline.h",
+    "public/pw_sync_freertos/mutex_native.h",
+    "public_overrides/pw_sync_backend/mutex_inline.h",
+    "public_overrides/pw_sync_backend/mutex_native.h",
+  ]
+  public_deps = [
+    "$dir_pw_assert",
+    "$dir_pw_interrupt:context",
+    "$dir_pw_sync:mutex.facade",
+    "$dir_pw_third_party/freertos",
+  ]
+}
+
+# This target provides the backend for pw::sync::TimedMutex.
+pw_source_set("timed_mutex") {
+  public_configs = [
+    ":public_include_path",
+    ":backend_config",
+  ]
+  public = [
+    "public/pw_sync_freertos/timed_mutex_inline.h",
+    "public_overrides/pw_sync_backend/timed_mutex_inline.h",
+  ]
+  public_deps = [
+    "$dir_pw_chrono:system_clock",
+    "$dir_pw_sync:timed_mutex.facade",
+  ]
+  sources = [ "timed_mutex.cc" ]
+  deps = [
+    "$dir_pw_assert",
+    "$dir_pw_chrono_freertos:system_clock",
+    "$dir_pw_interrupt:context",
+    "$dir_pw_third_party/freertos",
+  ]
+  assert(pw_chrono_SYSTEM_CLOCK_BACKEND == "" ||
+             pw_chrono_SYSTEM_CLOCK_BACKEND ==
+                 "$dir_pw_chrono_freertos:system_clock",
+         "The FreeRTOS pw::sync::Mutex backend only works with the FreeRTOS " +
+             "pw::chrono::SystemClock backend.")
+}
+
+# This target provides the backend for pw::sync::InterruptSpinLock.
+pw_source_set("interrupt_spin_lock") {
+  public_configs = [
+    ":public_include_path",
+    ":backend_config",
+  ]
+  public = [
+    "public/pw_sync_freertos/interrupt_spin_lock_inline.h",
+    "public/pw_sync_freertos/interrupt_spin_lock_native.h",
+    "public_overrides/pw_sync_backend/interrupt_spin_lock_inline.h",
+    "public_overrides/pw_sync_backend/interrupt_spin_lock_native.h",
+  ]
+  public_deps = [ "$dir_pw_third_party/freertos" ]
+  sources = [ "interrupt_spin_lock.cc" ]
+  deps = [
+    "$dir_pw_assert",
+    "$dir_pw_interrupt:context",
+    "$dir_pw_sync:interrupt_spin_lock.facade",
+    "$dir_pw_third_party/freertos",
+  ]
+}
+
+pw_doc_group("docs") {
+  sources = [ "docs.rst" ]
+}
diff --git a/pw_sync_freertos/binary_semaphore.cc b/pw_sync_freertos/binary_semaphore.cc
new file mode 100644
index 0000000..ef32b83
--- /dev/null
+++ b/pw_sync_freertos/binary_semaphore.cc
@@ -0,0 +1,61 @@
+// 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.
+
+#include "pw_sync/binary_semaphore.h"
+
+#include <algorithm>
+
+#include "FreeRTOS.h"
+#include "pw_assert/assert.h"
+#include "pw_chrono/system_clock.h"
+#include "pw_chrono_freertos/system_clock_constants.h"
+#include "pw_interrupt/context.h"
+#include "semphr.h"
+
+using pw::chrono::SystemClock;
+
+namespace pw::sync {
+namespace {
+
+static_assert(configSUPPORT_STATIC_ALLOCATION != 0,
+              "FreeRTOS static allocations are required for this backend.");
+
+}  // namespace
+
+bool BinarySemaphore::try_acquire_for(SystemClock::duration for_at_least) {
+  PW_DCHECK(!interrupt::InInterruptContext());
+
+  // Use non-blocking try_acquire for negative and zero length durations.
+  if (for_at_least <= SystemClock::duration::zero()) {
+    return try_acquire();
+  }
+
+  // On a tick based kernel we cannot tell how far along we are on the current
+  // tick, ergo we add one whole tick to the final duration.
+  constexpr SystemClock::duration kMaxTimeoutMinusOne =
+      pw::chrono::freertos::kMaxTimeout - SystemClock::duration(1);
+  while (for_at_least > kMaxTimeoutMinusOne) {
+    if (xSemaphoreTake(&native_type_,
+                       static_cast<TickType_t>(kMaxTimeoutMinusOne.count())) ==
+        pdTRUE) {
+      return true;
+    }
+    for_at_least -= kMaxTimeoutMinusOne;
+  }
+  return xSemaphoreTake(&native_type_,
+                        static_cast<TickType_t>(for_at_least.count() + 1)) ==
+         pdTRUE;
+}
+
+}  // namespace pw::sync
diff --git a/pw_sync_freertos/counting_semaphore.cc b/pw_sync_freertos/counting_semaphore.cc
new file mode 100644
index 0000000..3b57397
--- /dev/null
+++ b/pw_sync_freertos/counting_semaphore.cc
@@ -0,0 +1,81 @@
+// 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.
+
+#include "pw_sync/counting_semaphore.h"
+
+#include <algorithm>
+
+#include "FreeRTOS.h"
+#include "pw_assert/assert.h"
+#include "pw_chrono/system_clock.h"
+#include "pw_chrono_freertos/system_clock_constants.h"
+#include "pw_interrupt/context.h"
+#include "semphr.h"
+
+using pw::chrono::SystemClock;
+
+namespace pw::sync {
+namespace {
+
+static_assert(configUSE_COUNTING_SEMAPHORES != 0,
+              "FreeRTOS counting semaphores aren't enabled.");
+
+static_assert(configSUPPORT_STATIC_ALLOCATION != 0,
+              "FreeRTOS static allocations are required for this backend.");
+
+}  // namespace
+
+void CountingSemaphore::release(ptrdiff_t update) {
+  if (interrupt::InInterruptContext()) {
+    for (; update > 0; --update) {
+      BaseType_t woke_higher_task = pdFALSE;
+      const BaseType_t result =
+          xSemaphoreGiveFromISR(&native_type_, &woke_higher_task);
+      PW_DCHECK_UINT_EQ(result, pdTRUE, "Overflowed counting semaphore.");
+      portYIELD_FROM_ISR(woke_higher_task);
+    }
+  } else {  // Task context
+    for (; update > 0; --update) {
+      const BaseType_t result = xSemaphoreGive(&native_type_);
+      PW_DCHECK_UINT_EQ(result, pdTRUE, "Overflowed counting semaphore.");
+    }
+  }
+}
+
+bool CountingSemaphore::try_acquire_for(SystemClock::duration for_at_least) {
+  PW_DCHECK(!interrupt::InInterruptContext());
+
+  // Use non-blocking try_acquire for negative and zero length durations.
+  if (for_at_least <= SystemClock::duration::zero()) {
+    return try_acquire();
+  }
+
+  // On a tick based kernel we cannot tell how far along we are on the current
+  // tick, ergo we add one whole tick to the final duration.
+  constexpr SystemClock::duration kMaxTimeoutMinusOne =
+      pw::chrono::freertos::kMaxTimeout - SystemClock::duration(1);
+  while (for_at_least > kMaxTimeoutMinusOne) {
+    if (xSemaphoreTake(&native_type_,
+                       static_cast<TickType_t>(kMaxTimeoutMinusOne.count())) ==
+        pdTRUE) {
+      return true;
+    }
+    for_at_least -= kMaxTimeoutMinusOne;
+  }
+  return xSemaphoreTake(&native_type_,
+                        static_cast<TickType_t>(for_at_least.count() + 1)) ==
+         pdTRUE;
+}
+
+}  // namespace pw::sync
diff --git a/pw_sync_freertos/docs.rst b/pw_sync_freertos/docs.rst
new file mode 100644
index 0000000..0053f02
--- /dev/null
+++ b/pw_sync_freertos/docs.rst
@@ -0,0 +1,8 @@
+.. _module-pw_sync_freertos:
+
+----------------
+pw_sync_freertos
+----------------
+This is a set of backends for pw_sync based on FreeRTOS. It is not ready for
+use, and is under construction.
+
diff --git a/pw_sync_freertos/interrupt_spin_lock.cc b/pw_sync_freertos/interrupt_spin_lock.cc
new file mode 100644
index 0000000..7c96aa8
--- /dev/null
+++ b/pw_sync_freertos/interrupt_spin_lock.cc
@@ -0,0 +1,65 @@
+// 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.
+
+#include "pw_sync/interrupt_spin_lock.h"
+
+#include "pw_assert/assert.h"
+#include "pw_interrupt/context.h"
+#include "task.h"
+
+namespace pw::sync {
+
+void InterruptSpinLock::lock() {
+  if (interrupt::InInterruptContext()) {
+    native_type_.saved_interrupt_mask = taskENTER_CRITICAL_FROM_ISR();
+  } else {  // Task context
+    taskENTER_CRITICAL();
+  }
+  // We can't deadlock here so crash instead.
+  PW_CHECK(!native_type_.locked.load(std::memory_order_relaxed),
+           "Recursive InterruptSpinLock::lock() detected");
+  native_type_.locked.store(true, std::memory_order_relaxed);
+}
+
+bool InterruptSpinLock::try_lock() {
+  if (interrupt::InInterruptContext()) {
+    UBaseType_t saved_interrupt_mask = taskENTER_CRITICAL_FROM_ISR();
+    if (native_type_.locked.load(std::memory_order_relaxed)) {
+      // Already locked, restore interrupts and bail out.
+      taskEXIT_CRITICAL_FROM_ISR(saved_interrupt_mask);
+      return false;
+    }
+    native_type_.saved_interrupt_mask = saved_interrupt_mask;
+  } else {  // Task context
+    taskENTER_CRITICAL();
+    if (native_type_.locked.load(std::memory_order_relaxed)) {
+      // ALready locked, restore interrupts and bail out.
+      taskEXIT_CRITICAL();
+      return false;
+    }
+  }
+  native_type_.locked.store(true, std::memory_order_relaxed);
+  return true;
+}
+
+void InterruptSpinLock::unlock() {
+  native_type_.locked.store(false, std::memory_order_relaxed);
+  if (interrupt::InInterruptContext()) {
+    taskEXIT_CRITICAL_FROM_ISR(native_type_.saved_interrupt_mask);
+  } else {  // Task context
+    taskEXIT_CRITICAL();
+  }
+}
+
+}  // namespace pw::sync
diff --git a/pw_sync_freertos/public/pw_sync_freertos/binary_semaphore_inline.h b/pw_sync_freertos/public/pw_sync_freertos/binary_semaphore_inline.h
new file mode 100644
index 0000000..143ef0f
--- /dev/null
+++ b/pw_sync_freertos/public/pw_sync_freertos/binary_semaphore_inline.h
@@ -0,0 +1,86 @@
+// 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.
+#pragma once
+
+#include "FreeRTOS.h"
+#include "pw_assert/light.h"
+#include "pw_chrono/system_clock.h"
+#include "pw_chrono_freertos/system_clock_constants.h"
+#include "pw_interrupt/context.h"
+#include "pw_sync/binary_semaphore.h"
+#include "semphr.h"
+
+namespace pw::sync {
+
+inline BinarySemaphore::BinarySemaphore() : native_type_() {
+  const SemaphoreHandle_t handle = xSemaphoreCreateBinaryStatic(&native_type_);
+  // This should never fail since the pointer provided was not null and it
+  // should return a pointer to the StaticSemaphore_t.
+  PW_DASSERT(handle == &native_type_);
+}
+
+inline BinarySemaphore::~BinarySemaphore() { vSemaphoreDelete(&native_type_); }
+
+inline void BinarySemaphore::release() {
+  if (interrupt::InInterruptContext()) {
+    BaseType_t woke_higher_task = pdFALSE;
+    // It's perfectly fine if the semaphore already has a count of 1.
+    [[maybe_unused]] BaseType_t already_full =
+        xSemaphoreGiveFromISR(&native_type_, &woke_higher_task);
+    portYIELD_FROM_ISR(woke_higher_task);
+  } else {  // Task context
+    // It's perfectly fine if the semaphore already has a count of 1.
+    [[maybe_unused]] BaseType_t already_full = xSemaphoreGive(&native_type_);
+  }
+}
+
+inline void BinarySemaphore::acquire() {
+  PW_ASSERT(!interrupt::InInterruptContext());
+#if INCLUDE_vTaskSuspend == 1  // This means portMAX_DELAY is indefinite.
+  const BaseType_t result = xSemaphoreTake(&native_type_, portMAX_DELAY);
+  PW_DASSERT(result == pdTRUE);
+#else
+  // In case we need to block for longer than the FreeRTOS delay can represent
+  // repeatedly hit take until success.
+  while (xSemaphoreTake(&native_type_, chrono::freertos::kMaxTimeout.count()) ==
+         pdFALSE) {
+  }
+#endif  // INCLUDE_vTaskSuspend
+}
+
+inline bool BinarySemaphore::try_acquire() noexcept {
+  if (interrupt::InInterruptContext()) {
+    BaseType_t woke_higher_task = pdFALSE;
+    const bool success =
+        xSemaphoreTakeFromISR(&native_type_, &woke_higher_task) == pdTRUE;
+    portYIELD_FROM_ISR(woke_higher_task);
+    return success;
+  }
+
+  // Task Context
+  return xSemaphoreTake(&native_type_, 0) == pdTRUE;
+}
+
+inline bool BinarySemaphore::try_acquire_until(
+    chrono::SystemClock::time_point until_at_least) {
+  // Note that if this deadline is in the future, it will get rounded up by
+  // one whole tick due to how try_acquire_for is implemented.
+  return try_acquire_for(until_at_least - chrono::SystemClock::now());
+}
+
+inline BinarySemaphore::native_handle_type BinarySemaphore::native_handle() {
+  return native_type_;
+}
+
+}  // namespace pw::sync
diff --git a/pw_sync_freertos/public/pw_sync_freertos/binary_semaphore_native.h b/pw_sync_freertos/public/pw_sync_freertos/binary_semaphore_native.h
new file mode 100644
index 0000000..dcaead6
--- /dev/null
+++ b/pw_sync_freertos/public/pw_sync_freertos/binary_semaphore_native.h
@@ -0,0 +1,32 @@
+// 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.
+#pragma once
+
+#include <limits>
+
+#include "FreeRTOS.h"
+#include "semphr.h"
+
+namespace pw::sync::backend {
+
+using NativeBinarySemaphore = StaticSemaphore_t;
+using NativeBinarySemaphoreHandle = NativeBinarySemaphore&;
+
+inline constexpr ptrdiff_t kBinarySemaphoreMaxValue =
+    std::numeric_limits<ptrdiff_t>::max() <
+            std::numeric_limits<UBaseType_t>::max()
+        ? std::numeric_limits<ptrdiff_t>::max()
+        : std::numeric_limits<UBaseType_t>::max();
+
+}  // namespace pw::sync::backend
diff --git a/pw_sync_freertos/public/pw_sync_freertos/counting_semaphore_inline.h b/pw_sync_freertos/public/pw_sync_freertos/counting_semaphore_inline.h
new file mode 100644
index 0000000..e5fb377
--- /dev/null
+++ b/pw_sync_freertos/public/pw_sync_freertos/counting_semaphore_inline.h
@@ -0,0 +1,77 @@
+// 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.
+#pragma once
+
+#include "FreeRTOS.h"
+#include "pw_assert/light.h"
+#include "pw_chrono/system_clock.h"
+#include "pw_chrono_freertos/system_clock_constants.h"
+#include "pw_interrupt/context.h"
+#include "pw_sync/counting_semaphore.h"
+#include "semphr.h"
+
+namespace pw::sync {
+
+inline CountingSemaphore::CountingSemaphore() : native_type_() {
+  const SemaphoreHandle_t handle =
+      xSemaphoreCreateCountingStatic(max(), 0, &native_type_);
+  // This should never fail since the pointer provided was not null and it
+  // should return a pointer to the StaticSemaphore_t.
+  PW_DASSERT(handle == &native_type_);
+}
+
+inline CountingSemaphore::~CountingSemaphore() {
+  vSemaphoreDelete(&native_type_);
+}
+
+inline void CountingSemaphore::acquire() {
+  PW_ASSERT(!interrupt::InInterruptContext());
+#if INCLUDE_vTaskSuspend == 1  // This means portMAX_DELAY is indefinite.
+  const BaseType_t result = xSemaphoreTake(&native_type_, portMAX_DELAY);
+  PW_DASSERT(result == pdTRUE);
+#else
+  // In case we need to block for longer than the FreeRTOS delay can represent
+  // repeatedly hit take until success.
+  while (xSemaphoreTake(&native_type_, chrono::freertos::kMaxTimeout.count()) ==
+         pdFALSE) {
+  }
+#endif  // INCLUDE_vTaskSuspend
+}
+
+inline bool CountingSemaphore::try_acquire() noexcept {
+  if (interrupt::InInterruptContext()) {
+    BaseType_t woke_higher_task = pdFALSE;
+    const bool success =
+        xSemaphoreTakeFromISR(&native_type_, &woke_higher_task) == pdTRUE;
+    portYIELD_FROM_ISR(woke_higher_task);
+    return success;
+  }
+
+  // Task Context
+  return xSemaphoreTake(&native_type_, 0) == pdTRUE;
+}
+
+inline bool CountingSemaphore::try_acquire_until(
+    chrono::SystemClock::time_point until_at_least) {
+  // Note that if this deadline is in the future, it will get rounded up by
+  // one whole tick due to how try_acquire_for is implemented.
+  return try_acquire_for(until_at_least - chrono::SystemClock::now());
+}
+
+inline CountingSemaphore::native_handle_type
+CountingSemaphore::native_handle() {
+  return native_type_;
+}
+
+}  // namespace pw::sync
diff --git a/pw_sync_freertos/public/pw_sync_freertos/counting_semaphore_native.h b/pw_sync_freertos/public/pw_sync_freertos/counting_semaphore_native.h
new file mode 100644
index 0000000..c9d436e
--- /dev/null
+++ b/pw_sync_freertos/public/pw_sync_freertos/counting_semaphore_native.h
@@ -0,0 +1,32 @@
+// 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.
+#pragma once
+
+#include <limits>
+
+#include "FreeRTOS.h"
+#include "semphr.h"
+
+namespace pw::sync::backend {
+
+using NativeCountingSemaphore = StaticSemaphore_t;
+using NativeCountingSemaphoreHandle = NativeCountingSemaphore&;
+
+inline constexpr ptrdiff_t kCountingSemaphoreMaxValue =
+    std::numeric_limits<ptrdiff_t>::max() <
+            std::numeric_limits<UBaseType_t>::max()
+        ? std::numeric_limits<ptrdiff_t>::max()
+        : std::numeric_limits<UBaseType_t>::max();
+
+}  // namespace pw::sync::backend
diff --git a/pw_sync_freertos/public/pw_sync_freertos/interrupt_spin_lock_inline.h b/pw_sync_freertos/public/pw_sync_freertos/interrupt_spin_lock_inline.h
new file mode 100644
index 0000000..8bbd4cf
--- /dev/null
+++ b/pw_sync_freertos/public/pw_sync_freertos/interrupt_spin_lock_inline.h
@@ -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.
+#pragma once
+
+#include "pw_sync/interrupt_spin_lock.h"
+
+namespace pw::sync {
+
+constexpr InterruptSpinLock::InterruptSpinLock()
+    : native_type_{.locked{false}, .saved_interrupt_mask = 0} {}
+
+inline InterruptSpinLock::native_handle_type
+InterruptSpinLock::native_handle() {
+  return native_type_;
+}
+
+}  // namespace pw::sync
diff --git a/pw_sync_freertos/public/pw_sync_freertos/interrupt_spin_lock_native.h b/pw_sync_freertos/public/pw_sync_freertos/interrupt_spin_lock_native.h
new file mode 100644
index 0000000..ac476dc
--- /dev/null
+++ b/pw_sync_freertos/public/pw_sync_freertos/interrupt_spin_lock_native.h
@@ -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.
+#pragma once
+
+#include <atomic>
+
+#include "FreeRTOS.h"
+
+namespace pw::sync::backend {
+
+struct NativeInterruptSpinLock {
+  std::atomic<bool> locked;  // Used to detect recursion.
+  UBaseType_t saved_interrupt_mask;
+};
+using NativeInterruptSpinLockHandle = NativeInterruptSpinLock&;
+
+}  // namespace pw::sync::backend
diff --git a/pw_sync_freertos/public/pw_sync_freertos/mutex_inline.h b/pw_sync_freertos/public/pw_sync_freertos/mutex_inline.h
new file mode 100644
index 0000000..92f7c07
--- /dev/null
+++ b/pw_sync_freertos/public/pw_sync_freertos/mutex_inline.h
@@ -0,0 +1,68 @@
+// 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.
+#pragma once
+
+#include "FreeRTOS.h"
+#include "pw_assert/light.h"
+#include "pw_interrupt/context.h"
+#include "pw_sync/mutex.h"
+#include "semphr.h"
+
+namespace pw::sync {
+namespace backend {
+
+static_assert(configUSE_MUTEXES != 0, "FreeRTOS mutexes aren't enabled.");
+
+static_assert(configSUPPORT_STATIC_ALLOCATION != 0,
+              "FreeRTOS static allocations are required for this backend.");
+
+}  // namespace backend
+
+inline Mutex::Mutex() : native_type_() {
+  const SemaphoreHandle_t handle = xSemaphoreCreateMutexStatic(&native_type_);
+  // This should never fail since the pointer provided was not null and it
+  // should return a pointer to the StaticSemaphore_t.
+  PW_DASSERT(handle == &native_type_);
+}
+
+inline Mutex::~Mutex() { vSemaphoreDelete(&native_type_); }
+
+inline void Mutex::lock() {
+  PW_ASSERT(!interrupt::InInterruptContext());
+#if INCLUDE_vTaskSuspend == 1  // This means portMAX_DELAY is indefinite.
+  const BaseType_t result = xSemaphoreTake(&native_type_, portMAX_DELAY);
+  PW_DASSERT(result == pdTRUE);
+#else
+  // In case we need to block for longer than the FreeRTOS delay can represent
+  // repeatedly hit take until success.
+  while (xSemaphoreTake(&native_type_, chrono::freertos::kMaxTimeout.count()) ==
+         pdFALSE) {
+  }
+#endif  // INCLUDE_vTaskSuspend
+}
+
+inline bool Mutex::try_lock() {
+  PW_ASSERT(!interrupt::InInterruptContext());
+  return xSemaphoreTake(&native_type_, 0) == pdTRUE;
+}
+
+inline void Mutex::unlock() {
+  PW_ASSERT(!interrupt::InInterruptContext());
+  // Unlocking only fails if it was not locked first.
+  PW_ASSERT(xSemaphoreGive(&native_type_) == pdTRUE);
+}
+
+inline Mutex::native_handle_type Mutex::native_handle() { return native_type_; }
+
+}  // namespace pw::sync
diff --git a/pw_sync_freertos/public/pw_sync_freertos/mutex_native.h b/pw_sync_freertos/public/pw_sync_freertos/mutex_native.h
new file mode 100644
index 0000000..973ff8f
--- /dev/null
+++ b/pw_sync_freertos/public/pw_sync_freertos/mutex_native.h
@@ -0,0 +1,24 @@
+// 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.
+#pragma once
+
+#include "FreeRTOS.h"
+#include "semphr.h"
+
+namespace pw::sync::backend {
+
+using NativeMutex = StaticSemaphore_t;
+using NativeMutexHandle = NativeMutex&;
+
+}  // namespace pw::sync::backend
diff --git a/pw_sync_freertos/public/pw_sync_freertos/timed_mutex_inline.h b/pw_sync_freertos/public/pw_sync_freertos/timed_mutex_inline.h
new file mode 100644
index 0000000..bc114b9
--- /dev/null
+++ b/pw_sync_freertos/public/pw_sync_freertos/timed_mutex_inline.h
@@ -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.
+#pragma once
+
+#include "pw_chrono/system_clock.h"
+#include "pw_sync/timed_mutex.h"
+
+namespace pw::sync {
+
+inline bool TimedMutex::try_lock_until(
+    chrono::SystemClock::time_point until_at_least) {
+  // Note that if this deadline is in the future, it will get rounded up by
+  // one whole tick due to how try_lock_for is implemented.
+  return try_lock_for(until_at_least - chrono::SystemClock::now());
+}
+
+}  // namespace pw::sync
diff --git a/pw_sync_freertos/public_overrides/pw_sync_backend/binary_semaphore_inline.h b/pw_sync_freertos/public_overrides/pw_sync_backend/binary_semaphore_inline.h
new file mode 100644
index 0000000..9a41fdf
--- /dev/null
+++ b/pw_sync_freertos/public_overrides/pw_sync_backend/binary_semaphore_inline.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_sync_freertos/binary_semaphore_inline.h"
diff --git a/pw_sync_freertos/public_overrides/pw_sync_backend/binary_semaphore_native.h b/pw_sync_freertos/public_overrides/pw_sync_backend/binary_semaphore_native.h
new file mode 100644
index 0000000..7b95373
--- /dev/null
+++ b/pw_sync_freertos/public_overrides/pw_sync_backend/binary_semaphore_native.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_sync_freertos/binary_semaphore_native.h"
diff --git a/pw_sync_freertos/public_overrides/pw_sync_backend/counting_semaphore_inline.h b/pw_sync_freertos/public_overrides/pw_sync_backend/counting_semaphore_inline.h
new file mode 100644
index 0000000..7a03fdb
--- /dev/null
+++ b/pw_sync_freertos/public_overrides/pw_sync_backend/counting_semaphore_inline.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_sync_freertos/counting_semaphore_inline.h"
diff --git a/pw_sync_freertos/public_overrides/pw_sync_backend/counting_semaphore_native.h b/pw_sync_freertos/public_overrides/pw_sync_backend/counting_semaphore_native.h
new file mode 100644
index 0000000..b3a9d1d
--- /dev/null
+++ b/pw_sync_freertos/public_overrides/pw_sync_backend/counting_semaphore_native.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_sync_freertos/counting_semaphore_native.h"
diff --git a/pw_sync_freertos/public_overrides/pw_sync_backend/interrupt_spin_lock_inline.h b/pw_sync_freertos/public_overrides/pw_sync_backend/interrupt_spin_lock_inline.h
new file mode 100644
index 0000000..ad3195f
--- /dev/null
+++ b/pw_sync_freertos/public_overrides/pw_sync_backend/interrupt_spin_lock_inline.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_sync_freertos/interrupt_spin_lock_inline.h"
diff --git a/pw_sync_freertos/public_overrides/pw_sync_backend/interrupt_spin_lock_native.h b/pw_sync_freertos/public_overrides/pw_sync_backend/interrupt_spin_lock_native.h
new file mode 100644
index 0000000..c9768b3
--- /dev/null
+++ b/pw_sync_freertos/public_overrides/pw_sync_backend/interrupt_spin_lock_native.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_sync_freertos/interrupt_spin_lock_native.h"
diff --git a/pw_sync_freertos/public_overrides/pw_sync_backend/mutex_inline.h b/pw_sync_freertos/public_overrides/pw_sync_backend/mutex_inline.h
new file mode 100644
index 0000000..2c55565
--- /dev/null
+++ b/pw_sync_freertos/public_overrides/pw_sync_backend/mutex_inline.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_sync_freertos/mutex_inline.h"
diff --git a/pw_sync_freertos/public_overrides/pw_sync_backend/mutex_native.h b/pw_sync_freertos/public_overrides/pw_sync_backend/mutex_native.h
new file mode 100644
index 0000000..36647a9
--- /dev/null
+++ b/pw_sync_freertos/public_overrides/pw_sync_backend/mutex_native.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_sync_freertos/mutex_native.h"
diff --git a/pw_sync_freertos/public_overrides/pw_sync_backend/timed_mutex_inline.h b/pw_sync_freertos/public_overrides/pw_sync_backend/timed_mutex_inline.h
new file mode 100644
index 0000000..e833723
--- /dev/null
+++ b/pw_sync_freertos/public_overrides/pw_sync_backend/timed_mutex_inline.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_sync_freertos/timed_mutex_inline.h"
diff --git a/pw_sync_freertos/timed_mutex.cc b/pw_sync_freertos/timed_mutex.cc
new file mode 100644
index 0000000..0143b84
--- /dev/null
+++ b/pw_sync_freertos/timed_mutex.cc
@@ -0,0 +1,55 @@
+// 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.
+
+#include "pw_sync/timed_mutex.h"
+
+#include <algorithm>
+
+#include "FreeRTOS.h"
+#include "pw_assert/assert.h"
+#include "pw_chrono/system_clock.h"
+#include "pw_chrono_freertos/system_clock_constants.h"
+#include "pw_interrupt/context.h"
+#include "semphr.h"
+
+using pw::chrono::SystemClock;
+
+namespace pw::sync {
+
+bool TimedMutex::try_lock_for(SystemClock::duration for_at_least) {
+  PW_DCHECK(!interrupt::InInterruptContext());
+
+  // Use non-blocking try_acquire for negative and zero length durations.
+  if (for_at_least <= SystemClock::duration::zero()) {
+    return try_lock();
+  }
+
+  // On a tick based kernel we cannot tell how far along we are on the current
+  // tick, ergo we add one whole tick to the final duration.
+  constexpr SystemClock::duration kMaxTimeoutMinusOne =
+      pw::chrono::freertos::kMaxTimeout - SystemClock::duration(1);
+  while (for_at_least > kMaxTimeoutMinusOne) {
+    if (xSemaphoreTake(&native_handle(),
+                       static_cast<TickType_t>(kMaxTimeoutMinusOne.count())) ==
+        pdTRUE) {
+      return true;
+    }
+    for_at_least -= kMaxTimeoutMinusOne;
+  }
+  return xSemaphoreTake(&native_handle(),
+                        static_cast<TickType_t>(for_at_least.count() + 1)) ==
+         pdTRUE;
+}
+
+}  // namespace pw::sync
diff --git a/pw_sync_stl/BUILD b/pw_sync_stl/BUILD
new file mode 100644
index 0000000..c2f39a7
--- /dev/null
+++ b/pw_sync_stl/BUILD
@@ -0,0 +1,152 @@
+# 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.
+
+load(
+    "//pw_build:pigweed.bzl",
+    "pw_cc_library",
+)
+
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])  # Apache License 2.0
+
+pw_cc_library(
+    name = "binary_semaphore_headers",
+    hdrs = [
+        "public/pw_sync_stl/binary_semaphore_inline.h",
+        "public/pw_sync_stl/binary_semaphore_native.h",
+        "public_overrides/pw_sync_backend/binary_semaphore_inline.h",
+        "public_overrides/pw_sync_backend/binary_semaphore_native.h",
+    ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+    deps = [
+        "//pw_chrono:system_clock",
+    ],
+)
+
+pw_cc_library(
+    name = "binary_semaphore",
+    srcs = [
+        "binary_semaphore.cc",
+    ],
+    deps = [
+        ":binary_semaphore_headers",
+        "//pw_chrono:system_clock",
+        "//pw_sync:binary_semaphore_facade",
+    ],
+)
+
+pw_cc_library(
+    name = "counting_semaphore_headers",
+    hdrs = [
+        "public/pw_sync_stl/counting_semaphore_inline.h",
+        "public/pw_sync_stl/counting_semaphore_native.h",
+        "public_overrides/pw_sync_backend/counting_semaphore_inline.h",
+        "public_overrides/pw_sync_backend/counting_semaphore_native.h",
+    ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+    deps = [
+        "//pw_chrono:system_clock",
+    ],
+)
+
+pw_cc_library(
+    name = "counting_semaphore",
+    srcs = [
+        "counting_semaphore.cc",
+    ],
+    deps = [
+        ":counting_semaphore_headers",
+        "//pw_chrono:system_clock",
+        "//pw_sync:counting_semaphore_facade",
+    ],
+)
+
+pw_cc_library(
+    name = "mutex_headers",
+    hdrs = [
+        "public/pw_sync_stl/mutex_inline.h",
+        "public/pw_sync_stl/mutex_native.h",
+        "public_overrides/pw_sync_backend/mutex_inline.h",
+        "public_overrides/pw_sync_backend/mutex_native.h",
+    ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+    deps = [
+        "//pw_sync:mutex_facade",
+    ],
+)
+
+pw_cc_library(
+    name = "mutex",
+    deps = [
+        ":mutex_headers",
+        "//pw_sync:mutex_facade",
+    ],
+)
+
+pw_cc_library(
+    name = "timed_mutex_headers",
+    hdrs = [
+        "public/pw_sync_stl/timed_mutex_inline.h",
+        "public_overrides/pw_sync_backend/timed_mutex_inline.h",
+    ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+    deps = [
+        "//pw_chrono:system_clock",
+        "//pw_sync:timed_mutex_facade",
+    ],
+)
+
+pw_cc_library(
+    name = "timed_mutex",
+    deps = [
+        ":timed_mutex_headers",
+        "//pw_sync:timed_mutex_facade",
+    ],
+)
+
+pw_cc_library(
+    name = "interrupt_spin_lock_headers",
+    hdrs = [
+        "public/pw_sync_stl/interrupt_spin_lock_inline.h",
+        "public/pw_sync_stl/interrupt_spin_lock_native.h",
+        "public_overrides/pw_sync_backend/interrupt_spin_lock_inline.h",
+        "public_overrides/pw_sync_backend/interrupt_spin_lock_native.h",
+    ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+)
+
+pw_cc_library(
+    name = "interrupt_spin_lock",
+    deps = [
+        ":interrupt_spin_lock_headers",
+        "//pw_sync:interrupt_spin_lock_facade",
+        "//pw_sync:yield_core",
+    ],
+)
diff --git a/pw_sync_stl/BUILD.gn b/pw_sync_stl/BUILD.gn
new file mode 100644
index 0000000..0b7e56b
--- /dev/null
+++ b/pw_sync_stl/BUILD.gn
@@ -0,0 +1,135 @@
+# 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/target_types.gni")
+import("$dir_pw_chrono/backend.gni")
+import("$dir_pw_docgen/docs.gni")
+
+config("public_include_path") {
+  include_dirs = [ "public" ]
+  visibility = [ ":*" ]
+}
+
+config("backend_config") {
+  include_dirs = [ "public_overrides" ]
+  visibility = [ ":*" ]
+}
+
+# This target provides the backend for pw::sync::BinarySemaphore.
+pw_source_set("binary_semaphore_backend") {
+  public_configs = [
+    ":public_include_path",
+    ":backend_config",
+  ]
+  public = [
+    "public/pw_sync_stl/binary_semaphore_inline.h",
+    "public/pw_sync_stl/binary_semaphore_native.h",
+    "public_overrides/pw_sync_backend/binary_semaphore_inline.h",
+    "public_overrides/pw_sync_backend/binary_semaphore_native.h",
+  ]
+  sources = [ "binary_semaphore.cc" ]
+  deps = [
+    "$dir_pw_assert",
+    "$dir_pw_chrono:system_clock",
+    "$dir_pw_sync:binary_semaphore.facade",
+  ]
+  assert(
+      pw_chrono_SYSTEM_CLOCK_BACKEND == "" ||
+          pw_chrono_SYSTEM_CLOCK_BACKEND == "$dir_pw_chrono_stl:system_clock",
+      "The STL pw::sync::BinarySemaphore backend only works with the " +
+          "STL pw::chrono::SystemClock backend.")
+}
+
+# This target provides the backend for pw::sync::CountingSemaphore.
+pw_source_set("counting_semaphore_backend") {
+  public_configs = [
+    ":public_include_path",
+    ":backend_config",
+  ]
+  public = [
+    "public/pw_sync_stl/counting_semaphore_inline.h",
+    "public/pw_sync_stl/counting_semaphore_native.h",
+    "public_overrides/pw_sync_backend/counting_semaphore_inline.h",
+    "public_overrides/pw_sync_backend/counting_semaphore_native.h",
+  ]
+  sources = [ "counting_semaphore.cc" ]
+  deps = [
+    "$dir_pw_assert",
+    "$dir_pw_chrono:system_clock",
+    "$dir_pw_sync:counting_semaphore.facade",
+  ]
+  assert(
+      pw_chrono_SYSTEM_CLOCK_BACKEND == "" ||
+          pw_chrono_SYSTEM_CLOCK_BACKEND == "$dir_pw_chrono_stl:system_clock",
+      "The STL pw::sync::CountingSemaphore backend only works with the " +
+          "STL pw::chrono::SystemClock backend.")
+}
+
+# This target provides the backend for pw::sync::Mutex.
+pw_source_set("mutex_backend") {
+  public_configs = [
+    ":public_include_path",
+    ":backend_config",
+  ]
+  public = [
+    "public/pw_sync_stl/mutex_inline.h",
+    "public/pw_sync_stl/mutex_native.h",
+    "public_overrides/pw_sync_backend/mutex_inline.h",
+    "public_overrides/pw_sync_backend/mutex_native.h",
+  ]
+  public_deps = [ "$dir_pw_sync:mutex.facade" ]
+}
+
+# This target provides the backend for pw::sync::TimedMutex.
+pw_source_set("timed_mutex_backend") {
+  public_configs = [
+    ":public_include_path",
+    ":backend_config",
+  ]
+  public = [
+    "$dir_pw_sync:timed_mutex.facade",
+    "public/pw_sync_stl/timed_mutex_inline.h",
+    "public_overrides/pw_sync_backend/timed_mutex_inline.h",
+  ]
+  public_deps = [ "$dir_pw_chrono:system_clock" ]
+  assert(
+      pw_chrono_SYSTEM_CLOCK_BACKEND == "" ||
+          pw_chrono_SYSTEM_CLOCK_BACKEND == "$dir_pw_chrono_stl:system_clock",
+      "The STL pw::sync::TimedMutex backend only works with the STL " +
+          "pw::chrono::SystemClock backend.")
+}
+
+# This target provides the backend for pw::sync::InterruptSpinLock.
+pw_source_set("interrupt_spin_lock") {
+  public_configs = [
+    ":public_include_path",
+    ":backend_config",
+  ]
+  public = [
+    "public/pw_sync_stl/interrupt_spin_lock_inline.h",
+    "public/pw_sync_stl/interrupt_spin_lock_native.h",
+    "public_overrides/pw_sync_backend/interrupt_spin_lock_inline.h",
+    "public_overrides/pw_sync_backend/interrupt_spin_lock_native.h",
+  ]
+  public_deps = [
+    "$dir_pw_sync:interrupt_spin_lock.facade",
+    "$dir_pw_sync:yield_core",
+  ]
+}
+
+pw_doc_group("docs") {
+  sources = [ "docs.rst" ]
+}
diff --git a/pw_sync_stl/CMakeLists.txt b/pw_sync_stl/CMakeLists.txt
new file mode 100644
index 0000000..bf8d343
--- /dev/null
+++ b/pw_sync_stl/CMakeLists.txt
@@ -0,0 +1,20 @@
+# 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.
+
+include($ENV{PW_ROOT}/pw_build/pigweed.cmake)
+
+pw_add_module_library(pw_sync_stl.mutex_backend
+  IMPLEMENTS_FACADES
+    pw_sync.mutex
+)
diff --git a/pw_sync_stl/binary_semaphore.cc b/pw_sync_stl/binary_semaphore.cc
new file mode 100644
index 0000000..fdff483
--- /dev/null
+++ b/pw_sync_stl/binary_semaphore.cc
@@ -0,0 +1,56 @@
+// 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.
+
+#include "pw_sync/binary_semaphore.h"
+
+#include "pw_assert/assert.h"
+
+using pw::chrono::SystemClock;
+
+namespace pw::sync {
+
+void BinarySemaphore::release() {
+  std::lock_guard lock(native_type_.mutex);
+  PW_DCHECK_UINT_LT(native_type_.count, BinarySemaphore::max());
+  ++native_type_.count;
+  native_type_.condition.notify_one();
+}
+
+void BinarySemaphore::acquire() {
+  std::unique_lock lock(native_type_.mutex);
+  native_type_.condition.wait(lock, [&] { return native_type_.count != 0; });
+  native_type_.count = 0;
+}
+
+bool BinarySemaphore::try_acquire() noexcept {
+  std::lock_guard lock(native_type_.mutex);
+  if (native_type_.count != 0) {
+    native_type_.count = 0;
+    return true;
+  }
+  return false;
+}
+
+bool BinarySemaphore::try_acquire_until(
+    SystemClock::time_point until_at_least) {
+  std::unique_lock lock(native_type_.mutex);
+  if (native_type_.condition.wait_until(
+          lock, until_at_least, [&] { return native_type_.count != 0; })) {
+    native_type_.count = 0;
+    return true;
+  }
+  return false;
+}
+
+}  // namespace pw::sync
diff --git a/pw_sync_stl/counting_semaphore.cc b/pw_sync_stl/counting_semaphore.cc
new file mode 100644
index 0000000..629d0dc
--- /dev/null
+++ b/pw_sync_stl/counting_semaphore.cc
@@ -0,0 +1,59 @@
+// 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.
+
+#include "pw_sync/counting_semaphore.h"
+
+#include "pw_assert/assert.h"
+
+using pw::chrono::SystemClock;
+
+namespace pw::sync {
+
+void CountingSemaphore::release(ptrdiff_t update) {
+  PW_DCHECK_UINT_GE(update, 0);
+  {
+    std::lock_guard lock(native_type_.mutex);
+    PW_DCHECK_UINT_LE(update, CountingSemaphore::max() - native_type_.count);
+    native_type_.count += update;
+    native_type_.condition.notify_one();
+  }
+}
+
+void CountingSemaphore::acquire() {
+  std::unique_lock lock(native_type_.mutex);
+  native_type_.condition.wait(lock, [&] { return native_type_.count != 0; });
+  --native_type_.count;
+}
+
+bool CountingSemaphore::try_acquire() noexcept {
+  std::lock_guard lock(native_type_.mutex);
+  if (native_type_.count != 0) {
+    --native_type_.count;
+    return true;
+  }
+  return false;
+}
+
+bool CountingSemaphore::try_acquire_until(
+    SystemClock::time_point until_at_least) {
+  std::unique_lock lock(native_type_.mutex);
+  if (native_type_.condition.wait_until(
+          lock, until_at_least, [&] { return native_type_.count != 0; })) {
+    --native_type_.count;
+    return true;
+  }
+  return false;
+}
+
+}  // namespace pw::sync
diff --git a/pw_sync_stl/docs.rst b/pw_sync_stl/docs.rst
new file mode 100644
index 0000000..5d922d2
--- /dev/null
+++ b/pw_sync_stl/docs.rst
@@ -0,0 +1,8 @@
+.. _module-pw_sync_stl:
+
+-----------
+pw_sync_stl
+-----------
+This is a set of backends for pw_sync based on the C++ STL. It is not ready for
+use, and is under construction.
+
diff --git a/pw_sync_stl/public/pw_sync_stl/binary_semaphore_inline.h b/pw_sync_stl/public/pw_sync_stl/binary_semaphore_inline.h
new file mode 100644
index 0000000..ca5a1e3
--- /dev/null
+++ b/pw_sync_stl/public/pw_sync_stl/binary_semaphore_inline.h
@@ -0,0 +1,41 @@
+// 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.
+#pragma once
+
+#include "pw_chrono/system_clock.h"
+#include "pw_sync/binary_semaphore.h"
+
+namespace pw::sync {
+
+inline BinarySemaphore::BinarySemaphore()
+    : native_type_{.mutex = {}, .condition = {}, .count = 0} {}
+
+inline BinarySemaphore::~BinarySemaphore() {}
+
+inline bool BinarySemaphore::try_acquire_for(
+    chrono::SystemClock::duration for_at_least) {
+  // Due to spurious condition variable wakeups we prefer not to use wait_for()
+  // as we may grossly extend the effective deadline after a spruious wakeup.
+  // Ergo we instead use the derived deadline which can be re-used many times
+  // without shifting the effective deadline. For more details on spurious
+  // wakeups and CVs on Windows and POSIX see:
+  //   https://en.wikipedia.org/wiki/Spurious_wakeup
+  return try_acquire_until(chrono::SystemClock::now() + for_at_least);
+}
+
+inline BinarySemaphore::native_handle_type BinarySemaphore::native_handle() {
+  return native_type_;
+}
+
+}  // namespace pw::sync
diff --git a/pw_sync_stl/public/pw_sync_stl/binary_semaphore_native.h b/pw_sync_stl/public/pw_sync_stl/binary_semaphore_native.h
new file mode 100644
index 0000000..f19a2cc
--- /dev/null
+++ b/pw_sync_stl/public/pw_sync_stl/binary_semaphore_native.h
@@ -0,0 +1,32 @@
+// 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.
+#pragma once
+
+#include <condition_variable>
+#include <limits>
+#include <mutex>
+
+namespace pw::sync::backend {
+
+struct NativeBinarySemaphore {
+  std::mutex mutex;
+  std::condition_variable_any condition;
+  ptrdiff_t count;
+};
+using NativeBinarySemaphoreHandle = NativeBinarySemaphore&;
+
+inline constexpr ptrdiff_t kBinarySemaphoreMaxValue =
+    std::numeric_limits<ptrdiff_t>::max();
+
+}  // namespace pw::sync::backend
diff --git a/pw_sync_stl/public/pw_sync_stl/counting_semaphore_inline.h b/pw_sync_stl/public/pw_sync_stl/counting_semaphore_inline.h
new file mode 100644
index 0000000..f29df4b
--- /dev/null
+++ b/pw_sync_stl/public/pw_sync_stl/counting_semaphore_inline.h
@@ -0,0 +1,39 @@
+// 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.
+#pragma once
+
+#include "pw_chrono/system_clock.h"
+#include "pw_sync/counting_semaphore.h"
+
+using pw::chrono::SystemClock;
+
+namespace pw::sync {
+
+inline CountingSemaphore::CountingSemaphore()
+    : native_type_{.mutex = {}, .condition = {}, .count = 0} {}
+
+inline CountingSemaphore::~CountingSemaphore() {}
+
+inline bool CountingSemaphore::try_acquire_for(
+    chrono::SystemClock::duration for_at_least) {
+  // Due to spurious condition variable wakeups this cannot use wait_for().
+  return try_acquire_until(chrono::SystemClock::now() + for_at_least);
+}
+
+inline CountingSemaphore::native_handle_type
+CountingSemaphore::native_handle() {
+  return native_type_;
+}
+
+}  // namespace pw::sync
diff --git a/pw_sync_stl/public/pw_sync_stl/counting_semaphore_native.h b/pw_sync_stl/public/pw_sync_stl/counting_semaphore_native.h
new file mode 100644
index 0000000..8b06307
--- /dev/null
+++ b/pw_sync_stl/public/pw_sync_stl/counting_semaphore_native.h
@@ -0,0 +1,32 @@
+// 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.
+#pragma once
+
+#include <condition_variable>
+#include <limits>
+#include <mutex>
+
+namespace pw::sync::backend {
+
+struct NativeCountingSemaphore {
+  std::mutex mutex;
+  std::condition_variable_any condition;
+  ptrdiff_t count;
+};
+using NativeCountingSemaphoreHandle = NativeCountingSemaphore&;
+
+inline constexpr ptrdiff_t kCountingSemaphoreMaxValue =
+    std::numeric_limits<ptrdiff_t>::max();
+
+}  // namespace pw::sync::backend
diff --git a/pw_sync_stl/public/pw_sync_stl/interrupt_spin_lock_inline.h b/pw_sync_stl/public/pw_sync_stl/interrupt_spin_lock_inline.h
new file mode 100644
index 0000000..3a028b3
--- /dev/null
+++ b/pw_sync_stl/public/pw_sync_stl/interrupt_spin_lock_inline.h
@@ -0,0 +1,42 @@
+// 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.
+#pragma once
+
+#include "pw_sync/interrupt_spin_lock.h"
+#include "pw_sync/yield_core.h"
+
+namespace pw::sync {
+
+constexpr InterruptSpinLock::InterruptSpinLock() : native_type_() {}
+
+inline void InterruptSpinLock::lock() {
+  while (!try_lock()) {
+    PW_SYNC_YIELD_CORE_FOR_SMT();
+  }
+}
+
+inline bool InterruptSpinLock::try_lock() {
+  return !native_type_.test_and_set(std::memory_order_acquire);
+}
+
+inline void InterruptSpinLock::unlock() {
+  native_type_.clear(std::memory_order_release);
+}
+
+inline InterruptSpinLock::native_handle_type
+InterruptSpinLock::native_handle() {
+  return native_type_;
+}
+
+}  // namespace pw::sync
diff --git a/pw_sync_stl/public/pw_sync_stl/interrupt_spin_lock_native.h b/pw_sync_stl/public/pw_sync_stl/interrupt_spin_lock_native.h
new file mode 100644
index 0000000..21f1646
--- /dev/null
+++ b/pw_sync_stl/public/pw_sync_stl/interrupt_spin_lock_native.h
@@ -0,0 +1,23 @@
+// 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.
+#pragma once
+
+#include <atomic>
+
+namespace pw::sync::backend {
+
+using NativeInterruptSpinLock = std::atomic_flag;
+using NativeInterruptSpinLockHandle = std::atomic_flag&;
+
+}  // namespace pw::sync::backend
diff --git a/pw_sync_stl/public/pw_sync_stl/mutex_inline.h b/pw_sync_stl/public/pw_sync_stl/mutex_inline.h
new file mode 100644
index 0000000..88548c4
--- /dev/null
+++ b/pw_sync_stl/public/pw_sync_stl/mutex_inline.h
@@ -0,0 +1,32 @@
+// 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.
+#pragma once
+
+#include "pw_sync/mutex.h"
+
+namespace pw::sync {
+
+inline Mutex::Mutex() : native_type_() {}
+
+inline Mutex::~Mutex() {}
+
+inline void Mutex::lock() { native_type_.lock(); }
+
+inline bool Mutex::try_lock() { return native_type_.try_lock(); }
+
+inline void Mutex::unlock() { native_type_.unlock(); }
+
+inline Mutex::native_handle_type Mutex::native_handle() { return native_type_; }
+
+}  // namespace pw::sync
diff --git a/pw_sync_stl/public/pw_sync_stl/mutex_native.h b/pw_sync_stl/public/pw_sync_stl/mutex_native.h
new file mode 100644
index 0000000..fb04d56
--- /dev/null
+++ b/pw_sync_stl/public/pw_sync_stl/mutex_native.h
@@ -0,0 +1,23 @@
+// 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.
+#pragma once
+
+#include <mutex>
+
+namespace pw::sync::backend {
+
+using NativeMutex = std::timed_mutex;
+using NativeMutexHandle = std::timed_mutex&;
+
+}  // namespace pw::sync::backend
diff --git a/pw_sync_stl/public/pw_sync_stl/timed_mutex_inline.h b/pw_sync_stl/public/pw_sync_stl/timed_mutex_inline.h
new file mode 100644
index 0000000..5bfd340
--- /dev/null
+++ b/pw_sync_stl/public/pw_sync_stl/timed_mutex_inline.h
@@ -0,0 +1,31 @@
+// 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.
+#pragma once
+
+#include "pw_chrono/system_clock.h"
+#include "pw_sync/mutex.h"
+
+namespace pw::sync {
+
+inline bool TimedMutex::try_lock_for(
+    chrono::SystemClock::duration for_at_least) {
+  return native_handle().try_lock_for(for_at_least);
+}
+
+inline bool TimedMutex::try_lock_until(
+    chrono::SystemClock::time_point until_at_least) {
+  return native_handle().try_lock_until(until_at_least);
+}
+
+}  // namespace pw::sync
diff --git a/pw_sync_stl/public_overrides/pw_sync_backend/binary_semaphore_inline.h b/pw_sync_stl/public_overrides/pw_sync_backend/binary_semaphore_inline.h
new file mode 100644
index 0000000..96d102c
--- /dev/null
+++ b/pw_sync_stl/public_overrides/pw_sync_backend/binary_semaphore_inline.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_sync_stl/binary_semaphore_inline.h"
diff --git a/pw_sync_stl/public_overrides/pw_sync_backend/binary_semaphore_native.h b/pw_sync_stl/public_overrides/pw_sync_backend/binary_semaphore_native.h
new file mode 100644
index 0000000..f855f73
--- /dev/null
+++ b/pw_sync_stl/public_overrides/pw_sync_backend/binary_semaphore_native.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_sync_stl/binary_semaphore_native.h"
diff --git a/pw_sync_stl/public_overrides/pw_sync_backend/counting_semaphore_inline.h b/pw_sync_stl/public_overrides/pw_sync_backend/counting_semaphore_inline.h
new file mode 100644
index 0000000..99ad224
--- /dev/null
+++ b/pw_sync_stl/public_overrides/pw_sync_backend/counting_semaphore_inline.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_sync_stl/counting_semaphore_inline.h"
diff --git a/pw_sync_stl/public_overrides/pw_sync_backend/counting_semaphore_native.h b/pw_sync_stl/public_overrides/pw_sync_backend/counting_semaphore_native.h
new file mode 100644
index 0000000..041856b
--- /dev/null
+++ b/pw_sync_stl/public_overrides/pw_sync_backend/counting_semaphore_native.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_sync_stl/counting_semaphore_native.h"
diff --git a/pw_sync_stl/public_overrides/pw_sync_backend/interrupt_spin_lock_inline.h b/pw_sync_stl/public_overrides/pw_sync_backend/interrupt_spin_lock_inline.h
new file mode 100644
index 0000000..2bfe80d
--- /dev/null
+++ b/pw_sync_stl/public_overrides/pw_sync_backend/interrupt_spin_lock_inline.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_sync_stl/interrupt_spin_lock_inline.h"
diff --git a/pw_sync_stl/public_overrides/pw_sync_backend/interrupt_spin_lock_native.h b/pw_sync_stl/public_overrides/pw_sync_backend/interrupt_spin_lock_native.h
new file mode 100644
index 0000000..cdd0685
--- /dev/null
+++ b/pw_sync_stl/public_overrides/pw_sync_backend/interrupt_spin_lock_native.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_sync_stl/interrupt_spin_lock_native.h"
diff --git a/pw_sync_stl/public_overrides/pw_sync_backend/mutex_inline.h b/pw_sync_stl/public_overrides/pw_sync_backend/mutex_inline.h
new file mode 100644
index 0000000..5c48c56
--- /dev/null
+++ b/pw_sync_stl/public_overrides/pw_sync_backend/mutex_inline.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_sync_stl/mutex_inline.h"
diff --git a/pw_sync_stl/public_overrides/pw_sync_backend/mutex_native.h b/pw_sync_stl/public_overrides/pw_sync_backend/mutex_native.h
new file mode 100644
index 0000000..d37bd48
--- /dev/null
+++ b/pw_sync_stl/public_overrides/pw_sync_backend/mutex_native.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_sync_stl/mutex_native.h"
diff --git a/pw_sync_stl/public_overrides/pw_sync_backend/timed_mutex_inline.h b/pw_sync_stl/public_overrides/pw_sync_backend/timed_mutex_inline.h
new file mode 100644
index 0000000..e8ddcc1
--- /dev/null
+++ b/pw_sync_stl/public_overrides/pw_sync_backend/timed_mutex_inline.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_sync_stl/timed_mutex_inline.h"
diff --git a/pw_sync_threadx/BUILD b/pw_sync_threadx/BUILD
new file mode 100644
index 0000000..b9129f9
--- /dev/null
+++ b/pw_sync_threadx/BUILD
@@ -0,0 +1,171 @@
+# 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.
+
+load(
+    "//pw_build:pigweed.bzl",
+    "pw_cc_library",
+)
+
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])  # Apache License 2.0
+
+pw_cc_library(
+    name = "binary_semaphore_headers",
+    hdrs = [
+        "public/pw_sync_threadx/binary_semaphore_inline.h",
+        "public/pw_sync_threadx/binary_semaphore_native.h",
+        "public_overrides/pw_sync_backend/binary_semaphore_inline.h",
+        "public_overrides/pw_sync_backend/binary_semaphore_native.h",
+    ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+    deps = [
+        # TODO: This should depend on ThreadX but our third parties currently
+        # do not have Bazel support.
+        "//pw_chrono:system_clock",
+    ],
+)
+
+pw_cc_library(
+    name = "binary_semaphore",
+    srcs = [
+        "binary_semaphore.cc",
+    ],
+    deps = [
+        ":binary_semaphore_headers",
+        "//pw_chrono_threadx:system_clock_headers",
+        "//pw_interrupt:context",
+        "//pw_sync:binary_semaphore_facade",
+    ],
+)
+
+pw_cc_library(
+    name = "counting_semaphore_headers",
+    hdrs = [
+        "public/pw_sync_threadx/counting_semaphore_inline.h",
+        "public/pw_sync_threadx/counting_semaphore_native.h",
+        "public_overrides/pw_sync_backend/counting_semaphore_inline.h",
+        "public_overrides/pw_sync_backend/counting_semaphore_native.h",
+    ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+    deps = [
+        # TODO: This should depend on ThreadX but our third parties currently
+        # do not have Bazel support.
+        "//pw_chrono:system_clock",
+    ],
+)
+
+pw_cc_library(
+    name = "counting_semaphore",
+    srcs = [
+        "counting_semaphore.cc",
+    ],
+    deps = [
+        ":counting_semaphore_headers",
+        "//pw_chrono_threadx:system_clock_headers",
+        "//pw_interrupt:context",
+        "//pw_sync:counting_semaphore_facade",
+    ],
+)
+
+pw_cc_library(
+    name = "mutex_headers",
+    hdrs = [
+        "public/pw_sync_threadx/mutex_inline.h",
+        "public/pw_sync_threadx/mutex_native.h",
+        "public_overrides/pw_sync_backend/mutex_inline.h",
+        "public_overrides/pw_sync_backend/mutex_native.h",
+    ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+    deps = [
+        # TODO: This should depend on ThreadX but our third parties currently
+        # do not have Bazel support.
+        "//pw_sync:mutex_facade",
+    ],
+)
+
+pw_cc_library(
+    name = "mutex",
+    deps = [
+        ":mutex_headers",
+        "//pw_sync:mutex_facade",
+    ],
+)
+
+pw_cc_library(
+    name = "timed_mutex_headers",
+    hdrs = [
+        "public/pw_sync_threadx/timed_mutex_inline.h",
+        "public_overrides/pw_sync_backend/timed_mutex_inline.h",
+    ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+    deps = [
+        # TODO: This should depend on ThreadX but our third parties currently
+        # do not have Bazel support.
+        "//pw_chrono:system_clock",
+        "//pw_sync:timed_mutex_facade",
+    ],
+)
+
+pw_cc_library(
+    name = "timed_mutex",
+    srcs = [
+        "timed_mutex.cc",
+    ],
+    deps = [
+        ":timed_mutex_headers",
+        "//pw_chrono_threadx:system_clock_headers",
+        "//pw_interrupt:context",
+        "//pw_sync:timed_mutex_facade",
+    ],
+)
+
+pw_cc_library(
+    name = "interrupt_spin_lock_headers",
+    hdrs = [
+        "public/pw_sync_threadx/interrupt_spin_lock_inline.h",
+        "public/pw_sync_threadx/interrupt_spin_lock_native.h",
+        "public_overrides/pw_sync_backend/interrupt_spin_lock_inline.h",
+        "public_overrides/pw_sync_backend/interrupt_spin_lock_native.h",
+    ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+    # TODO: This should depend on ThreadX but our third parties currently
+    # do not have Bazel support.
+)
+
+pw_cc_library(
+    name = "interrupt_spin_lock",
+    srcs = [
+        "interrupt_spin_lock.cc",
+    ],
+    deps = [
+        ":interrupt_spin_lock_headers",
+        "//pw_sync:interrupt_spin_lock_facade",
+    ],
+)
diff --git a/pw_sync_threadx/BUILD.gn b/pw_sync_threadx/BUILD.gn
new file mode 100644
index 0000000..b02d757
--- /dev/null
+++ b/pw_sync_threadx/BUILD.gn
@@ -0,0 +1,166 @@
+# 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/target_types.gni")
+import("$dir_pw_chrono/backend.gni")
+import("$dir_pw_docgen/docs.gni")
+import("$dir_pw_sync/backend.gni")
+
+config("public_include_path") {
+  include_dirs = [ "public" ]
+  visibility = [ ":*" ]
+}
+
+config("backend_config") {
+  include_dirs = [ "public_overrides" ]
+  visibility = [ ":*" ]
+}
+
+# This target provides the backend for pw::sync::Mutex.
+pw_source_set("mutex") {
+  public_configs = [
+    ":public_include_path",
+    ":backend_config",
+  ]
+  public = [
+    "public/pw_sync_threadx/mutex_inline.h",
+    "public/pw_sync_threadx/mutex_native.h",
+    "public_overrides/pw_sync_backend/mutex_inline.h",
+    "public_overrides/pw_sync_backend/mutex_native.h",
+  ]
+  public_deps = [
+    "$dir_pw_assert",
+    "$dir_pw_interrupt:context",
+    "$dir_pw_sync:mutex.facade",
+    "$dir_pw_third_party/threadx",
+  ]
+}
+
+if (pw_chrono_SYSTEM_CLOCK_BACKEND != "") {
+  # This target provides the backend for pw::sync::BinarySemaphore.
+  pw_source_set("binary_semaphore") {
+    public_configs = [
+      ":public_include_path",
+      ":backend_config",
+    ]
+    public = [
+      "public/pw_sync_threadx/binary_semaphore_inline.h",
+      "public/pw_sync_threadx/binary_semaphore_native.h",
+      "public_overrides/pw_sync_backend/binary_semaphore_inline.h",
+      "public_overrides/pw_sync_backend/binary_semaphore_native.h",
+    ]
+    public_deps = [
+      "$dir_pw_assert",
+      "$dir_pw_chrono:system_clock",
+      "$dir_pw_interrupt:context",
+      "$dir_pw_third_party/threadx",
+    ]
+    sources = [ "binary_semaphore.cc" ]
+    deps = [
+      "$dir_pw_sync:binary_semaphore.facade",
+      pw_chrono_SYSTEM_CLOCK_BACKEND,
+    ]
+    assert(
+        pw_sync_OVERRIDE_SYSTEM_CLOCK_BACKEND_CHECK ||
+            pw_chrono_SYSTEM_CLOCK_BACKEND ==
+                "$dir_pw_chrono_threadx:system_clock",
+        "The ThreadX pw::sync::BinarySemaphore backend only works with the " +
+            "ThreadX pw::chrono::SystemClock backend.")
+  }
+
+  # This target provides the backend for pw::sync::CountingSemaphore.
+  pw_source_set("counting_semaphore") {
+    public_configs = [
+      ":public_include_path",
+      ":backend_config",
+    ]
+    public = [
+      "public/pw_sync_threadx/counting_semaphore_inline.h",
+      "public/pw_sync_threadx/counting_semaphore_native.h",
+      "public_overrides/pw_sync_backend/counting_semaphore_inline.h",
+      "public_overrides/pw_sync_backend/counting_semaphore_native.h",
+    ]
+    public_deps = [
+      "$dir_pw_assert",
+      "$dir_pw_chrono:system_clock",
+      "$dir_pw_interrupt:context",
+      "$dir_pw_third_party/threadx",
+    ]
+    sources = [ "counting_semaphore.cc" ]
+    deps = [
+      "$dir_pw_sync:counting_semaphore.facade",
+      pw_chrono_SYSTEM_CLOCK_BACKEND,
+    ]
+    assert(pw_sync_OVERRIDE_SYSTEM_CLOCK_BACKEND_CHECK ||
+               pw_chrono_SYSTEM_CLOCK_BACKEND ==
+                   "$dir_pw_chrono_threadx:system_clock",
+           "The ThreadX pw::sync::CountingSemaphore backend only works with " +
+               "the ThreadX pw::chrono::SystemClock backend.")
+  }
+
+  # This target provides the backend for pw::sync::TimedMutex.
+  pw_source_set("timed_mutex") {
+    public_configs = [
+      ":public_include_path",
+      ":backend_config",
+    ]
+    public = [
+      "public/pw_sync_threadx/timed_mutex_inline.h",
+      "public_overrides/pw_sync_backend/timed_mutex_inline.h",
+    ]
+    public_deps = [
+      "$dir_pw_chrono:system_clock",
+      "$dir_pw_sync:timed_mutex.facade",
+    ]
+    sources = [ "timed_mutex.cc" ]
+    deps = [
+      "$dir_pw_assert",
+      "$dir_pw_interrupt:context",
+      "$dir_pw_third_party/threadx",
+      pw_chrono_SYSTEM_CLOCK_BACKEND,
+    ]
+    assert(pw_sync_OVERRIDE_SYSTEM_CLOCK_BACKEND_CHECK ||
+               pw_chrono_SYSTEM_CLOCK_BACKEND ==
+                   "$dir_pw_chrono_threadx:system_clock",
+           "The ThreadX pw::sync::Mutex backend only works with the ThreadX " +
+               "pw::chrono::SystemClock backend.")
+  }
+}
+
+# This target provides the backend for pw::sync::InterruptSpinLock, note that
+# this implementation does NOT support ThreadX w/ SMP.
+pw_source_set("interrupt_spin_lock") {
+  public_configs = [
+    ":public_include_path",
+    ":backend_config",
+  ]
+  public = [
+    "public/pw_sync_threadx/interrupt_spin_lock_inline.h",
+    "public/pw_sync_threadx/interrupt_spin_lock_native.h",
+    "public_overrides/pw_sync_backend/interrupt_spin_lock_inline.h",
+    "public_overrides/pw_sync_backend/interrupt_spin_lock_native.h",
+  ]
+  public_deps = [ "$dir_pw_third_party/threadx" ]
+  sources = [ "interrupt_spin_lock.cc" ]
+  deps = [
+    "$dir_pw_assert",
+    "$dir_pw_sync:interrupt_spin_lock.facade",
+  ]
+}
+
+pw_doc_group("docs") {
+  sources = [ "docs.rst" ]
+}
diff --git a/pw_sync_threadx/binary_semaphore.cc b/pw_sync_threadx/binary_semaphore.cc
new file mode 100644
index 0000000..5e16fa9
--- /dev/null
+++ b/pw_sync_threadx/binary_semaphore.cc
@@ -0,0 +1,61 @@
+// 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.
+
+#include "pw_sync/binary_semaphore.h"
+
+#include <algorithm>
+
+#include "pw_assert/assert.h"
+#include "pw_chrono/system_clock.h"
+#include "pw_chrono_threadx/system_clock_constants.h"
+#include "pw_interrupt/context.h"
+#include "tx_api.h"
+
+using pw::chrono::SystemClock;
+
+namespace pw::sync {
+
+bool BinarySemaphore::try_acquire_for(SystemClock::duration for_at_least) {
+  // Enforce the pw::sync::BinarySemaphore IRQ contract.
+  PW_DCHECK(!interrupt::InInterruptContext());
+
+  // Use non-blocking try_acquire for negative and zero length durations.
+  if (for_at_least <= SystemClock::duration::zero()) {
+    return try_acquire();
+  }
+
+  // On a tick based kernel we cannot tell how far along we are on the current
+  // tick, ergo we add one whole tick to the final duration.
+  constexpr SystemClock::duration kMaxTimeoutMinusOne =
+      pw::chrono::threadx::kMaxTimeout - SystemClock::duration(1);
+  while (for_at_least > kMaxTimeoutMinusOne) {
+    const UINT result = tx_semaphore_get(
+        &native_type_, static_cast<ULONG>(kMaxTimeoutMinusOne.count()));
+    if (result != TX_NO_INSTANCE) {
+      // If we didn't time out (TX_NO_INSTANCE), then we should have succeeded.
+      PW_CHECK_UINT_EQ(TX_SUCCESS, result);
+      return true;
+    }
+    for_at_least -= kMaxTimeoutMinusOne;
+  }
+  const UINT result = tx_semaphore_get(
+      &native_type_, static_cast<ULONG>(for_at_least.count() + 1));
+  if (result == TX_NO_INSTANCE) {
+    return false;  // We timed out, there's still no token.
+  }
+  PW_CHECK_UINT_EQ(TX_SUCCESS, result);
+  return true;
+}
+
+}  // namespace pw::sync
diff --git a/pw_sync_threadx/counting_semaphore.cc b/pw_sync_threadx/counting_semaphore.cc
new file mode 100644
index 0000000..e2e5c86
--- /dev/null
+++ b/pw_sync_threadx/counting_semaphore.cc
@@ -0,0 +1,61 @@
+// 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.
+
+#include "pw_sync/counting_semaphore.h"
+
+#include <algorithm>
+
+#include "pw_assert/assert.h"
+#include "pw_chrono/system_clock.h"
+#include "pw_chrono_threadx/system_clock_constants.h"
+#include "pw_interrupt/context.h"
+#include "tx_api.h"
+
+using pw::chrono::SystemClock;
+
+namespace pw::sync {
+
+bool CountingSemaphore::try_acquire_for(SystemClock::duration for_at_least) {
+  // Enforce the pw::sync::CountingSemaphore IRQ contract.
+  PW_DCHECK(!interrupt::InInterruptContext());
+
+  // Use non-blocking try_acquire for negative and zero length durations.
+  if (for_at_least <= SystemClock::duration::zero()) {
+    return try_acquire();
+  }
+
+  // On a tick based kernel we cannot tell how far along we are on the current
+  // tick, ergo we add one whole tick to the final duration.
+  constexpr SystemClock::duration kMaxTimeoutMinusOne =
+      pw::chrono::threadx::kMaxTimeout - SystemClock::duration(1);
+  while (for_at_least > kMaxTimeoutMinusOne) {
+    const UINT result = tx_semaphore_get(
+        &native_type_, static_cast<ULONG>(kMaxTimeoutMinusOne.count()));
+    if (result != TX_NO_INSTANCE) {
+      // If we didn't time out (TX_NO_INSTANCE), then we should have succeeded.
+      PW_CHECK_UINT_EQ(TX_SUCCESS, result);
+      return true;
+    }
+    for_at_least -= kMaxTimeoutMinusOne;
+  }
+  const UINT result = tx_semaphore_get(
+      &native_type_, static_cast<ULONG>(for_at_least.count() + 1));
+  if (result == TX_NO_INSTANCE) {
+    return false;  // We timed out, there's still no token.
+  }
+  PW_CHECK_UINT_EQ(TX_SUCCESS, result);
+  return true;
+}
+
+}  // namespace pw::sync
diff --git a/pw_sync_threadx/docs.rst b/pw_sync_threadx/docs.rst
new file mode 100644
index 0000000..b6fac08
--- /dev/null
+++ b/pw_sync_threadx/docs.rst
@@ -0,0 +1,13 @@
+.. _module-pw_sync_threadx:
+
+---------------
+pw_sync_threadx
+---------------
+This is a set of backends for pw_sync based on ThreadX. It is not ready for use,
+and is under construction.
+
+It is possible, if necessary, to use pw_sync_threadx without using the Pigweed
+provided pw_chrono_threadx in case the ThreadX time API (``tx_time_get()``)) is
+not available (i.e. ``TX_NO_TIMER`` is set). You are responsible for ensuring
+that the chrono backend provided has counts which match the ThreadX tick based
+API.
diff --git a/pw_sync_threadx/interrupt_spin_lock.cc b/pw_sync_threadx/interrupt_spin_lock.cc
new file mode 100644
index 0000000..96d5c54
--- /dev/null
+++ b/pw_sync_threadx/interrupt_spin_lock.cc
@@ -0,0 +1,57 @@
+// 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.
+
+#include "pw_sync/interrupt_spin_lock.h"
+
+#include "pw_assert/assert.h"
+#include "tx_api.h"
+
+namespace pw::sync {
+
+void InterruptSpinLock::lock() {
+  // In order to be pw::sync::InterruptSpinLock compliant, mask the interrupts
+  // before attempting to grab the internal spin lock.
+  native_type_.saved_interrupt_mask = tx_interrupt_control(TX_INT_DISABLE);
+
+  // This implementation is not set up to support SMP, meaning we cannot
+  // deadlock here due to the global interrupt lock, so we crash on recursion
+  // on a specific spinlock instead.
+  PW_CHECK(!native_type_.locked.load(std::memory_order_relaxed),
+           "Recursive InterruptSpinLock::lock() detected");
+
+  native_type_.locked.store(true, std::memory_order_relaxed);
+}
+
+bool InterruptSpinLock::try_lock() {
+  // In order to be pw::sync::InterruptSpinLock compliant, mask the interrupts
+  // before attempting to grab the internal spin lock.
+  UINT saved_interrupt_mask = tx_interrupt_control(TX_INT_DISABLE);
+
+  if (native_type_.locked.load(std::memory_order_relaxed)) {
+    // Already locked, restore interrupts and bail out.
+    tx_interrupt_control(saved_interrupt_mask);
+    return false;
+  }
+
+  native_type_.saved_interrupt_mask = saved_interrupt_mask;
+  native_type_.locked.store(true, std::memory_order_relaxed);
+  return true;
+}
+
+void InterruptSpinLock::unlock() {
+  native_type_.locked.store(false, std::memory_order_relaxed);
+  tx_interrupt_control(native_type_.saved_interrupt_mask);
+}
+
+}  // namespace pw::sync
diff --git a/pw_sync_threadx/public/pw_sync_threadx/binary_semaphore_inline.h b/pw_sync_threadx/public/pw_sync_threadx/binary_semaphore_inline.h
new file mode 100644
index 0000000..844a6e6
--- /dev/null
+++ b/pw_sync_threadx/public/pw_sync_threadx/binary_semaphore_inline.h
@@ -0,0 +1,73 @@
+// 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.
+#pragma once
+
+#include "pw_assert/light.h"
+#include "pw_chrono/system_clock.h"
+#include "pw_interrupt/context.h"
+#include "pw_sync/binary_semaphore.h"
+#include "tx_api.h"
+
+namespace pw::sync {
+namespace backend {
+
+inline constexpr char kBinarySemaphoreName[] = "pw::BinarySemaphore";
+
+}  // namespace backend
+
+inline BinarySemaphore::BinarySemaphore() : native_type_() {
+  PW_ASSERT(
+      tx_semaphore_create(&native_type_,
+                          const_cast<char*>(backend::kBinarySemaphoreName),
+                          0) == TX_SUCCESS);
+}
+
+inline BinarySemaphore::~BinarySemaphore() {
+  PW_ASSERT(tx_semaphore_delete(&native_type_) == TX_SUCCESS);
+}
+
+inline void BinarySemaphore::release() {
+  // Give at most 1 token.
+  const UINT result = tx_semaphore_ceiling_put(&native_type_, 1);
+  PW_ASSERT(result == TX_SUCCESS || result == TX_CEILING_EXCEEDED);
+}
+
+inline void BinarySemaphore::acquire() {
+  // Enforce the pw::sync::BinarySemaphore IRQ contract.
+  PW_DASSERT(!interrupt::InInterruptContext());
+  const UINT result = tx_semaphore_get(&native_type_, TX_WAIT_FOREVER);
+  PW_ASSERT(result == TX_SUCCESS);
+}
+
+inline bool BinarySemaphore::try_acquire() noexcept {
+  const UINT result = tx_semaphore_get(&native_type_, TX_NO_WAIT);
+  if (result == TX_NO_INSTANCE) {
+    return false;
+  }
+  PW_ASSERT(result == TX_SUCCESS);
+  return true;
+}
+
+inline bool BinarySemaphore::try_acquire_until(
+    chrono::SystemClock::time_point until_at_least) {
+  // Note that if this deadline is in the future, it will get rounded up by
+  // one whole tick due to how try_acquire_for is implemented.
+  return try_acquire_for(until_at_least - chrono::SystemClock::now());
+}
+
+inline BinarySemaphore::native_handle_type BinarySemaphore::native_handle() {
+  return native_type_;
+}
+
+}  // namespace pw::sync
diff --git a/pw_sync_threadx/public/pw_sync_threadx/binary_semaphore_native.h b/pw_sync_threadx/public/pw_sync_threadx/binary_semaphore_native.h
new file mode 100644
index 0000000..cb4cb97
--- /dev/null
+++ b/pw_sync_threadx/public/pw_sync_threadx/binary_semaphore_native.h
@@ -0,0 +1,30 @@
+// 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.
+#pragma once
+
+#include <limits>
+
+#include "tx_api.h"
+
+namespace pw::sync::backend {
+
+using NativeBinarySemaphore = TX_SEMAPHORE;
+using NativeBinarySemaphoreHandle = NativeBinarySemaphore&;
+
+inline constexpr ptrdiff_t kBinarySemaphoreMaxValue =
+    std::numeric_limits<ptrdiff_t>::max() < std::numeric_limits<ULONG>::max()
+        ? std::numeric_limits<ptrdiff_t>::max()
+        : std::numeric_limits<ULONG>::max();
+
+}  // namespace pw::sync::backend
diff --git a/pw_sync_threadx/public/pw_sync_threadx/counting_semaphore_inline.h b/pw_sync_threadx/public/pw_sync_threadx/counting_semaphore_inline.h
new file mode 100644
index 0000000..7125e01
--- /dev/null
+++ b/pw_sync_threadx/public/pw_sync_threadx/counting_semaphore_inline.h
@@ -0,0 +1,75 @@
+// 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.
+#pragma once
+
+#include <algorithm>
+
+#include "pw_assert/light.h"
+#include "pw_chrono/system_clock.h"
+#include "pw_interrupt/context.h"
+#include "pw_sync/counting_semaphore.h"
+#include "tx_api.h"
+
+namespace pw::sync {
+namespace backend {
+
+inline constexpr char kCountingSemaphoreName[] = "pw::CountingSemaphore";
+
+}  // namespace backend
+
+inline CountingSemaphore::CountingSemaphore() : native_type_() {
+  PW_ASSERT(
+      tx_semaphore_create(&native_type_,
+                          const_cast<char*>(backend::kCountingSemaphoreName),
+                          0) == TX_SUCCESS);
+}
+
+inline CountingSemaphore::~CountingSemaphore() {
+  PW_ASSERT(tx_semaphore_delete(&native_type_) == TX_SUCCESS);
+}
+
+inline void CountingSemaphore::release(ptrdiff_t update) {
+  for (; update > 0; --update) {
+    PW_ASSERT(tx_semaphore_put(&native_type_) == TX_SUCCESS);
+  }
+}
+
+inline void CountingSemaphore::acquire() {
+  // Enforce the pw::sync::CountingSemaphore IRQ contract.
+  PW_DASSERT(!interrupt::InInterruptContext());
+  PW_ASSERT(tx_semaphore_get(&native_type_, TX_WAIT_FOREVER) == TX_SUCCESS);
+}
+
+inline bool CountingSemaphore::try_acquire() noexcept {
+  const UINT result = tx_semaphore_get(&native_type_, TX_NO_WAIT);
+  if (result == TX_NO_INSTANCE) {
+    return false;
+  }
+  PW_ASSERT(result == TX_SUCCESS);
+  return true;
+}
+
+inline bool CountingSemaphore::try_acquire_until(
+    chrono::SystemClock::time_point until_at_least) {
+  // Note that if this deadline is in the future, it will get rounded up by
+  // one whole tick due to how try_acquire_for is implemented.
+  return try_acquire_for(until_at_least - chrono::SystemClock::now());
+}
+
+inline CountingSemaphore::native_handle_type
+CountingSemaphore::native_handle() {
+  return native_type_;
+}
+
+}  // namespace pw::sync
diff --git a/pw_sync_threadx/public/pw_sync_threadx/counting_semaphore_native.h b/pw_sync_threadx/public/pw_sync_threadx/counting_semaphore_native.h
new file mode 100644
index 0000000..3db35a9
--- /dev/null
+++ b/pw_sync_threadx/public/pw_sync_threadx/counting_semaphore_native.h
@@ -0,0 +1,30 @@
+// 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.
+#pragma once
+
+#include <limits>
+
+#include "tx_api.h"
+
+namespace pw::sync::backend {
+
+using NativeCountingSemaphore = TX_SEMAPHORE;
+using NativeCountingSemaphoreHandle = NativeCountingSemaphore&;
+
+inline constexpr ptrdiff_t kCountingSemaphoreMaxValue =
+    std::numeric_limits<ptrdiff_t>::max() < std::numeric_limits<ULONG>::max()
+        ? std::numeric_limits<ptrdiff_t>::max()
+        : std::numeric_limits<ULONG>::max();
+
+}  // namespace pw::sync::backend
diff --git a/pw_sync_threadx/public/pw_sync_threadx/interrupt_spin_lock_inline.h b/pw_sync_threadx/public/pw_sync_threadx/interrupt_spin_lock_inline.h
new file mode 100644
index 0000000..8bbd4cf
--- /dev/null
+++ b/pw_sync_threadx/public/pw_sync_threadx/interrupt_spin_lock_inline.h
@@ -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.
+#pragma once
+
+#include "pw_sync/interrupt_spin_lock.h"
+
+namespace pw::sync {
+
+constexpr InterruptSpinLock::InterruptSpinLock()
+    : native_type_{.locked{false}, .saved_interrupt_mask = 0} {}
+
+inline InterruptSpinLock::native_handle_type
+InterruptSpinLock::native_handle() {
+  return native_type_;
+}
+
+}  // namespace pw::sync
diff --git a/pw_sync_threadx/public/pw_sync_threadx/interrupt_spin_lock_native.h b/pw_sync_threadx/public/pw_sync_threadx/interrupt_spin_lock_native.h
new file mode 100644
index 0000000..cf6ceb8
--- /dev/null
+++ b/pw_sync_threadx/public/pw_sync_threadx/interrupt_spin_lock_native.h
@@ -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.
+#pragma once
+
+#include <atomic>
+
+#include "tx_api.h"
+
+namespace pw::sync::backend {
+
+struct NativeInterruptSpinLock {
+  std::atomic<bool> locked;  // Used to detect recursion.
+  UINT saved_interrupt_mask;
+};
+using NativeInterruptSpinLockHandle = NativeInterruptSpinLock&;
+
+}  // namespace pw::sync::backend
diff --git a/pw_sync_threadx/public/pw_sync_threadx/mutex_inline.h b/pw_sync_threadx/public/pw_sync_threadx/mutex_inline.h
new file mode 100644
index 0000000..564b24e
--- /dev/null
+++ b/pw_sync_threadx/public/pw_sync_threadx/mutex_inline.h
@@ -0,0 +1,63 @@
+// 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.
+#pragma once
+
+#include "pw_assert/light.h"
+#include "pw_interrupt/context.h"
+#include "pw_sync/mutex.h"
+#include "tx_api.h"
+
+namespace pw::sync {
+namespace backend {
+
+inline constexpr char kMutexName[] = "pw::Mutex";
+
+}  // namespace backend
+
+inline Mutex::Mutex() : native_type_() {
+  PW_ASSERT(tx_mutex_create(&native_type_,
+                            const_cast<char*>(backend::kMutexName),
+                            TX_INHERIT) == TX_SUCCESS);
+}
+
+inline Mutex::~Mutex() {
+  PW_ASSERT(tx_mutex_delete(&native_type_) == TX_SUCCESS);
+}
+
+inline void Mutex::lock() {
+  // Enforce the pw::sync::Mutex IRQ contract.
+  PW_ASSERT(!interrupt::InInterruptContext());
+  PW_ASSERT(tx_mutex_get(&native_type_, TX_WAIT_FOREVER) == TX_SUCCESS);
+}
+
+inline bool Mutex::try_lock() {
+  // Enforce the pw::sync::Mutex IRQ contract.
+  PW_ASSERT(!interrupt::InInterruptContext());
+  const UINT result = tx_mutex_get(&native_type_, TX_NO_WAIT);
+  if (result == TX_NOT_AVAILABLE) {
+    return false;
+  }
+  PW_ASSERT(result == TX_SUCCESS);
+  return true;
+}
+
+inline void Mutex::unlock() {
+  // Enforce the pw::sync::Mutex IRQ contract.
+  PW_ASSERT(!interrupt::InInterruptContext());
+  PW_ASSERT(tx_mutex_put(&native_type_) == TX_SUCCESS);
+}
+
+inline Mutex::native_handle_type Mutex::native_handle() { return native_type_; }
+
+}  // namespace pw::sync
diff --git a/pw_sync_threadx/public/pw_sync_threadx/mutex_native.h b/pw_sync_threadx/public/pw_sync_threadx/mutex_native.h
new file mode 100644
index 0000000..e38e3eb
--- /dev/null
+++ b/pw_sync_threadx/public/pw_sync_threadx/mutex_native.h
@@ -0,0 +1,23 @@
+// 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.
+#pragma once
+
+#include "tx_api.h"
+
+namespace pw::sync::backend {
+
+using NativeMutex = TX_MUTEX;
+using NativeMutexHandle = NativeMutex&;
+
+}  // namespace pw::sync::backend
diff --git a/pw_sync_threadx/public/pw_sync_threadx/timed_mutex_inline.h b/pw_sync_threadx/public/pw_sync_threadx/timed_mutex_inline.h
new file mode 100644
index 0000000..8644046
--- /dev/null
+++ b/pw_sync_threadx/public/pw_sync_threadx/timed_mutex_inline.h
@@ -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.
+#pragma once
+
+#include "pw_chrono/system_clock.h"
+#include "pw_sync/timed_mutex.h"
+
+namespace pw::sync {
+
+inline bool Mutex::try_lock_until(
+    chrono::SystemClock::time_point until_at_least) {
+  // Note that if this deadline is in the future, it will get rounded up by
+  // one whole tick due to how try_lock_for is implemented.
+  return try_lock_for(until_at_least - chrono::SystemClock::now());
+}
+
+}  // namespace pw::sync
diff --git a/pw_sync_threadx/public_overrides/pw_sync_backend/binary_semaphore_inline.h b/pw_sync_threadx/public_overrides/pw_sync_backend/binary_semaphore_inline.h
new file mode 100644
index 0000000..2d6969d
--- /dev/null
+++ b/pw_sync_threadx/public_overrides/pw_sync_backend/binary_semaphore_inline.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_sync_threadx/binary_semaphore_inline.h"
diff --git a/pw_sync_threadx/public_overrides/pw_sync_backend/binary_semaphore_native.h b/pw_sync_threadx/public_overrides/pw_sync_backend/binary_semaphore_native.h
new file mode 100644
index 0000000..9270618
--- /dev/null
+++ b/pw_sync_threadx/public_overrides/pw_sync_backend/binary_semaphore_native.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_sync_threadx/binary_semaphore_native.h"
diff --git a/pw_sync_threadx/public_overrides/pw_sync_backend/counting_semaphore_inline.h b/pw_sync_threadx/public_overrides/pw_sync_backend/counting_semaphore_inline.h
new file mode 100644
index 0000000..19ba3f7
--- /dev/null
+++ b/pw_sync_threadx/public_overrides/pw_sync_backend/counting_semaphore_inline.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_sync_threadx/counting_semaphore_inline.h"
diff --git a/pw_sync_threadx/public_overrides/pw_sync_backend/counting_semaphore_native.h b/pw_sync_threadx/public_overrides/pw_sync_backend/counting_semaphore_native.h
new file mode 100644
index 0000000..b85e21f
--- /dev/null
+++ b/pw_sync_threadx/public_overrides/pw_sync_backend/counting_semaphore_native.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_sync_threadx/counting_semaphore_native.h"
diff --git a/pw_sync_threadx/public_overrides/pw_sync_backend/interrupt_spin_lock_inline.h b/pw_sync_threadx/public_overrides/pw_sync_backend/interrupt_spin_lock_inline.h
new file mode 100644
index 0000000..c42d7f9
--- /dev/null
+++ b/pw_sync_threadx/public_overrides/pw_sync_backend/interrupt_spin_lock_inline.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_sync_threadx/interrupt_spin_lock_inline.h"
diff --git a/pw_sync_threadx/public_overrides/pw_sync_backend/interrupt_spin_lock_native.h b/pw_sync_threadx/public_overrides/pw_sync_backend/interrupt_spin_lock_native.h
new file mode 100644
index 0000000..9a98526
--- /dev/null
+++ b/pw_sync_threadx/public_overrides/pw_sync_backend/interrupt_spin_lock_native.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_sync_threadx/interrupt_spin_lock_native.h"
diff --git a/pw_sync_threadx/public_overrides/pw_sync_backend/mutex_inline.h b/pw_sync_threadx/public_overrides/pw_sync_backend/mutex_inline.h
new file mode 100644
index 0000000..2dae3d2
--- /dev/null
+++ b/pw_sync_threadx/public_overrides/pw_sync_backend/mutex_inline.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_sync_threadx/mutex_inline.h"
diff --git a/pw_sync_threadx/public_overrides/pw_sync_backend/mutex_native.h b/pw_sync_threadx/public_overrides/pw_sync_backend/mutex_native.h
new file mode 100644
index 0000000..02f78a0
--- /dev/null
+++ b/pw_sync_threadx/public_overrides/pw_sync_backend/mutex_native.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_sync_threadx/mutex_native.h"
diff --git a/pw_sync_threadx/public_overrides/pw_sync_backend/timed_mutex_inline.h b/pw_sync_threadx/public_overrides/pw_sync_backend/timed_mutex_inline.h
new file mode 100644
index 0000000..8bacb62
--- /dev/null
+++ b/pw_sync_threadx/public_overrides/pw_sync_backend/timed_mutex_inline.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_sync_threadx/timed_mutex_inline.h"
diff --git a/pw_sync_threadx/timed_mutex.cc b/pw_sync_threadx/timed_mutex.cc
new file mode 100644
index 0000000..92b14ed
--- /dev/null
+++ b/pw_sync_threadx/timed_mutex.cc
@@ -0,0 +1,60 @@
+// 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.
+
+#include "pw_sync/timed_mutex.h"
+
+#include <algorithm>
+
+#include "pw_assert/assert.h"
+#include "pw_chrono/system_clock.h"
+#include "pw_chrono_threadx/system_clock_constants.h"
+#include "pw_interrupt/context.h"
+#include "tx_api.h"
+
+using pw::chrono::SystemClock;
+
+namespace pw::sync {
+
+bool TimedMutex::try_lock_for(SystemClock::duration for_at_least) {
+  // Enforce the pw::sync::TimedMutex IRQ contract.
+  PW_DCHECK(!interrupt::InInterruptContext());
+
+  // Use non-blocking try_lock for negative or zero length durations.
+  if (for_at_least <= SystemClock::duration::zero()) {
+    return try_lock();
+  }
+
+  // On a tick based kernel we cannot tell how far along we are on the current
+  // tick, ergo we add one whole tick to the final duration.
+  constexpr SystemClock::duration kMaxTimeoutMinusOne =
+      pw::chrono::threadx::kMaxTimeout - SystemClock::duration(1);
+  while (for_at_least > kMaxTimeoutMinusOne) {
+    const UINT result = tx_mutex_get(
+        &native_type_, static_cast<ULONG>(kMaxTimeoutMinusOne.count()));
+    if (result != TX_NOT_AVAILABLE) {
+      PW_CHECK_UINT_EQ(TX_SUCCESS, result);
+      return true;
+    }
+    for_at_least -= kMaxTimeoutMinusOne;
+  }
+  const UINT result =
+      tx_mutex_get(&native_type_, static_cast<ULONG>(for_at_least.count() + 1));
+  if (result == TX_NOT_AVAILABLE) {
+    return false;
+  }
+  PW_CHECK_UINT_EQ(TX_SUCCESS, result);
+  return true;
+}
+
+}  // namespace pw::sync
diff --git a/pw_sys_io/BUILD.gn b/pw_sys_io/BUILD.gn
index bdff7eb..47feb43 100644
--- a/pw_sys_io/BUILD.gn
+++ b/pw_sys_io/BUILD.gn
@@ -17,11 +17,7 @@
 import("$dir_pw_build/facade.gni")
 import("$dir_pw_build/target_types.gni")
 import("$dir_pw_docgen/docs.gni")
-
-declare_args() {
-  # Backend for the pw_sys_io module.
-  pw_sys_io_BACKEND = ""
-}
+import("backend.gni")
 
 config("default_config") {
   include_dirs = [ "public" ]
@@ -30,10 +26,7 @@
 pw_facade("pw_sys_io") {
   backend = pw_sys_io_BACKEND
   public_configs = [ ":default_config" ]
-  public_deps = [
-    "$dir_pw_span",
-    "$dir_pw_status",
-  ]
+  public_deps = [ dir_pw_status ]
   public = [ "public/pw_sys_io/sys_io.h" ]
 }
 
diff --git a/pw_sys_io/backend.gni b/pw_sys_io/backend.gni
new file mode 100644
index 0000000..d9f1993
--- /dev/null
+++ b/pw_sys_io/backend.gni
@@ -0,0 +1,18 @@
+# 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.
+
+declare_args() {
+  # Backend for the pw_sys_io facade.
+  pw_sys_io_BACKEND = ""
+}
diff --git a/pw_sys_io/docs.rst b/pw_sys_io/docs.rst
index cde6a3c..3a1a6c4 100644
--- a/pw_sys_io/docs.rst
+++ b/pw_sys_io/docs.rst
@@ -10,7 +10,7 @@
 
 This facade doesn't dictate any policies on input and output data encoding,
 format, or transmission protocol. It only requires that backends return a
-``Status::OK`` if the operation succeeds. Backends may provide useful error
+``OkStatus()`` if the operation succeeds. Backends may provide useful error
 Status types, but depending on the implementation-specific Status values is
 NOT recommended. Since this facade provides a very vague I/O interface, it
 does NOT provide tests. Backends are expected to provide their own testing to
diff --git a/pw_sys_io/public/pw_sys_io/sys_io.h b/pw_sys_io/public/pw_sys_io/sys_io.h
index 477c719..f93e64a 100644
--- a/pw_sys_io/public/pw_sys_io/sys_io.h
+++ b/pw_sys_io/public/pw_sys_io/sys_io.h
@@ -20,7 +20,7 @@
 //
 // This facade doesn't dictate any policies on input and output data encoding,
 // format, or transmission protocol. It only requires that backends return a
-// Status::Ok() if the operation succeeds. Backends may provide useful error
+// OkStatus() if the operation succeeds. Backends may provide useful error
 // Status types, but depending on the implementation-specific Status values is
 // NOT recommended. Since this facade provides a very vague I/O interface, it
 // does NOT provide tests. Backends are expected to provide their own testing to
@@ -51,14 +51,14 @@
 // This function will block until it either succeeds or fails to read a byte
 // from the pw_sys_io backend.
 //
-// Returns Status::Ok() - A byte was successfully read.
+// Returns OkStatus() - A byte was successfully read.
 //         Status::ResourceExhausted() - if the underlying source vanished.
 Status ReadByte(std::byte* dest);
 
 // Read a single byte from the sys io backend, if available.
 // Implemented by: Backend
 //
-// Returns Status::Ok() - A byte was successfully read, and is in dest.
+// Returns OkStatus() - A byte was successfully read, and is in dest.
 //         Status::Unavailable() - No byte is available to read; try later.
 //         Status::Unimplemented() - Not supported on this target.
 Status TryReadByte(std::byte* dest);
@@ -69,7 +69,7 @@
 // This function will block until it either succeeds or fails to write a byte
 // out the pw_sys_io backend.
 //
-// Returns Status::Ok() if a byte was successfully read.
+// Returns OkStatus() if a byte was successfully read.
 Status WriteByte(std::byte b);
 
 // Write a string out the sys io backend.
@@ -79,7 +79,7 @@
 // backend, adding any platform-specific newline character(s) (these are
 // accounted for in the returned StatusWithSize).
 //
-// Return status is Status::Ok() if all the bytes from the source string were
+// Return status is OkStatus() if all the bytes from the source string were
 // successfully written. In all cases, the number of bytes successfully written
 // are returned as part of the StatusWithSize.
 StatusWithSize WriteLine(const std::string_view& s);
@@ -93,7 +93,7 @@
 // undefined. This function blocks until either an error occurs, or all bytes
 // are successfully read from the backend's ReadByte() implementation.
 //
-// Return status is Status::Ok() if the destination span was successfully
+// Return status is OkStatus() if the destination span was successfully
 // filled. In all cases, the number of bytes successuflly read to the
 // destination span are returned as part of the StatusWithSize.
 StatusWithSize ReadBytes(std::span<std::byte> dest);
@@ -107,7 +107,7 @@
 // either an error occurs, or all bytes are successfully read from the backend's
 // WriteByte() implementation.
 //
-// Return status is Status::Ok() if all the bytes from the source span were
+// Return status is OkStatus() if all the bytes from the source span were
 // successfully written. In all cases, the number of bytes successfully written
 // are returned as part of the StatusWithSize.
 StatusWithSize WriteBytes(std::span<const std::byte> src);
diff --git a/pw_sys_io_arduino/BUILD.gn b/pw_sys_io_arduino/BUILD.gn
index ee9b770..eb4b51c 100644
--- a/pw_sys_io_arduino/BUILD.gn
+++ b/pw_sys_io_arduino/BUILD.gn
@@ -22,7 +22,7 @@
   include_dirs = [ "public" ]
 }
 
-if (dir_pw_third_party_arduino != "") {
+if (pw_arduino_build_CORE_PATH != "") {
   pw_source_set("pw_sys_io_arduino") {
     remove_configs = [ "$dir_pw_build:strict_warnings" ]
     public_configs = [ ":default_config" ]
@@ -31,7 +31,7 @@
     deps = [
       "$dir_pw_sys_io:default_putget_bytes",
       "$dir_pw_sys_io:facade",
-      "$dir_pw_third_party_arduino:arduino_core_sources",
+      "$dir_pw_third_party/arduino:arduino_core_sources",
     ]
     sources = [ "sys_io_arduino.cc" ]
   }
diff --git a/pw_sys_io_arduino/sys_io_arduino.cc b/pw_sys_io_arduino/sys_io_arduino.cc
index de6c344..4709ad5 100644
--- a/pw_sys_io_arduino/sys_io_arduino.cc
+++ b/pw_sys_io_arduino/sys_io_arduino.cc
@@ -20,12 +20,7 @@
 #include "pw_preprocessor/compiler.h"
 #include "pw_sys_io/sys_io.h"
 
-extern "C" void pw_sys_io_Init() {
-  Serial.begin(115200);
-  // Wait for serial port to be available
-  while (!Serial) {
-  }
-}
+extern "C" void pw_sys_io_Init() { Serial.begin(115200); }
 
 namespace pw::sys_io {
 
@@ -36,7 +31,7 @@
 Status ReadByte(std::byte* dest) {
   while (true) {
     if (TryReadByte(dest).ok()) {
-      return Status::Ok();
+      return OkStatus();
     }
   }
 }
@@ -46,20 +41,14 @@
     return Status::Unavailable();
   }
   *dest = static_cast<std::byte>(Serial.read());
-  return Status::Ok();
+  return OkStatus();
 }
 
-// Send a byte over USART1. Since this blocks on every byte, it's rather
-// inefficient. At the default baud rate of 115200, one byte blocks the CPU for
-// ~87 micro seconds. This means it takes only 10 bytes to block the CPU for
-// 1ms!
+// Send a byte over the default Arduino Serial port.
 Status WriteByte(std::byte b) {
-  // Wait for TX buffer to be empty. When the buffer is empty, we can write
-  // a value to be dumped out of UART.
-  while (Serial.availableForWrite() < 1) {
-  }
+  // Serial.write() will block until data can be written.
   Serial.write((uint8_t)b);
-  return Status::Ok();
+  return OkStatus();
 }
 
 // Writes a string using pw::sys_io, and add newline characters at the end.
diff --git a/pw_sys_io_baremetal_lm3s6965evb/sys_io_baremetal.cc b/pw_sys_io_baremetal_lm3s6965evb/sys_io_baremetal.cc
index 86cc341..ca70d97 100644
--- a/pw_sys_io_baremetal_lm3s6965evb/sys_io_baremetal.cc
+++ b/pw_sys_io_baremetal_lm3s6965evb/sys_io_baremetal.cc
@@ -25,10 +25,7 @@
 
 // UART status flags.
 constexpr uint32_t kTxFifoEmptyMask = 0b10000000;
-constexpr uint32_t kTxFifoFullMask = 0b1000000;
 constexpr uint32_t kRxFifoFullMask = 0b100000;
-constexpr uint32_t kRxFifoEmptyMask = 0b10000;
-constexpr uint32_t kTxBusyMask = 0b1000;
 
 // UART line control flags.
 // Default: 8n1
@@ -91,7 +88,7 @@
 Status ReadByte(std::byte* dest) {
   while (true) {
     if (TryReadByte(dest).ok()) {
-      return Status::Ok();
+      return OkStatus();
     }
   }
 }
@@ -105,7 +102,7 @@
     return Status::Unavailable();
   }
   *dest = static_cast<std::byte>(uart0.data_register);
-  return Status::Ok();
+  return OkStatus();
 }
 
 // Send a byte over UART0. Since this blocks on every byte, it's rather
@@ -118,7 +115,7 @@
   while (!(uart0.status_flags & kTxFifoEmptyMask)) {
   }
   uart0.data_register = static_cast<uint32_t>(b);
-  return Status::Ok();
+  return OkStatus();
 }
 
 // Writes a string using pw::sys_io, and add newline characters at the end.
diff --git a/pw_sys_io_baremetal_stm32f429/BUILD b/pw_sys_io_baremetal_stm32f429/BUILD
index 48cbc0a..80b0280 100644
--- a/pw_sys_io_baremetal_stm32f429/BUILD
+++ b/pw_sys_io_baremetal_stm32f429/BUILD
@@ -1,4 +1,4 @@
-# Copyright 2019 The Pigweed Authors
+# Copyright 2021 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
@@ -25,9 +25,12 @@
     name = "pw_sys_io_baremetal_stm32f429",
     srcs = ["sys_io_baremetal.cc"],
     hdrs = ["public/pw_sys_io_baremetal_stm32f429/init.h"],
+    target_compatible_with = [
+        "//pw_build/constraints/boards:stm32f429i-disc1",
+    ],
     deps = [
         "//pw_boot_armv7m",
         "//pw_preprocessor",
         "//pw_sys_io",
-    ]
+    ],
 )
diff --git a/pw_sys_io_baremetal_stm32f429/sys_io_baremetal.cc b/pw_sys_io_baremetal_stm32f429/sys_io_baremetal.cc
index 846e34c..b5b1792 100644
--- a/pw_sys_io_baremetal_stm32f429/sys_io_baremetal.cc
+++ b/pw_sys_io_baremetal_stm32f429/sys_io_baremetal.cc
@@ -61,25 +61,21 @@
 };
 
 // Constants related to GPIO mode register masks.
-constexpr uint32_t kGpioPortModeMask = 0x3u;
 constexpr uint32_t kGpio9PortModePos = 18;
 constexpr uint32_t kGpio10PortModePos = 20;
 constexpr uint32_t kGpioPortModeAlternate = 2;
 
 // Constants related to GPIO port speed register masks.
-constexpr uint32_t kGpioPortSpeedMask = 0x3u;
 constexpr uint32_t kGpio9PortSpeedPos = 18;
 constexpr uint32_t kGpio10PortSpeedPos = 20;
 constexpr uint32_t kGpioSpeedVeryHigh = 3;
 
 // Constants related to GPIO pull up/down resistor type masks.
-constexpr uint32_t kGpioPullTypeMask = 0x3u;
 constexpr uint32_t kGpio9PullTypePos = 18;
 constexpr uint32_t kGpio10PullTypePos = 20;
 constexpr uint32_t kPullTypePullUp = 1;
 
 // Constants related to GPIO port speed register masks.
-constexpr uint32_t kGpioAltModeMask = 0x3u;
 constexpr uint32_t kGpio9AltModeHighPos = 4;
 constexpr uint32_t kGpio10AltModeHighPos = 8;
 
@@ -171,7 +167,7 @@
 Status ReadByte(std::byte* dest) {
   while (true) {
     if (TryReadByte(dest).ok()) {
-      return Status::Ok();
+      return OkStatus();
     }
   }
 }
@@ -184,7 +180,7 @@
     return Status::Unavailable();
   }
   *dest = static_cast<std::byte>(usart1.data_register);
-  return Status::Ok();
+  return OkStatus();
 }
 
 // Send a byte over USART1. Since this blocks on every byte, it's rather
@@ -197,7 +193,7 @@
   while (!(usart1.status & kTxRegisterEmpty)) {
   }
   usart1.data_register = static_cast<uint32_t>(b);
-  return Status::Ok();
+  return OkStatus();
 }
 
 // Writes a string using pw::sys_io, and add newline characters at the end.
diff --git a/pw_sys_io_stdio/sys_io.cc b/pw_sys_io_stdio/sys_io.cc
index 53c9d5e..333b18f 100644
--- a/pw_sys_io_stdio/sys_io.cc
+++ b/pw_sys_io_stdio/sys_io.cc
@@ -28,7 +28,7 @@
     return Status::ResourceExhausted();
   }
   *dest = static_cast<std::byte>(value);
-  return Status::Ok();
+  return OkStatus();
 }
 
 Status TryReadByte(std::byte*) {
@@ -40,7 +40,7 @@
   if (std::putchar(static_cast<char>(b)) == EOF) {
     return Status::Internal();
   }
-  return Status::Ok();
+  return OkStatus();
 }
 
 StatusWithSize WriteLine(const std::string_view& s) {
diff --git a/pw_target_runner/BUILD.gn b/pw_target_runner/BUILD.gn
index 6e67cbc..9db1016 100644
--- a/pw_target_runner/BUILD.gn
+++ b/pw_target_runner/BUILD.gn
@@ -27,5 +27,5 @@
 }
 
 pw_proto_library("exec_server_config_proto") {
-  sources = [ "pw_target_runner_protos/exec_server_config.proto" ]
+  sources = [ "pw_target_runner_server_protos/exec_server_config.proto" ]
 }
diff --git a/pw_target_runner/docs.rst b/pw_target_runner/docs.rst
index bca7084..28f20e9 100644
--- a/pw_target_runner/docs.rst
+++ b/pw_target_runner/docs.rst
@@ -39,7 +39,7 @@
 ^^^^^^^^^^^^^
 The standalone server is configured from a file written in Protobuf text format
 containing a ``pw.target_runner.ServerConfig`` message as defined in
-``//pw_target_runner/pw_target_runner_protos/exec_server_config.proto``.
+``//pw_target_runner/pw_target_runner_server_protos/exec_server_config.proto``.
 
 At least one ``worker`` message must be specified. Each of the workers refers to
 a script or program which is invoked with the path to an executable file as a
diff --git a/pw_target_runner/pw_target_runner_protos/exec_server_config.proto b/pw_target_runner/pw_target_runner_server_protos/exec_server_config.proto
similarity index 100%
rename from pw_target_runner/pw_target_runner_protos/exec_server_config.proto
rename to pw_target_runner/pw_target_runner_server_protos/exec_server_config.proto
diff --git a/pw_thread/BUILD b/pw_thread/BUILD
new file mode 100644
index 0000000..71957c0
--- /dev/null
+++ b/pw_thread/BUILD
@@ -0,0 +1,221 @@
+# 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.
+
+load(
+    "//pw_build:pigweed.bzl",
+    "pw_cc_library",
+    "pw_cc_test",
+)
+
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])  # Apache License 2.0
+
+# TODO(pwbug/101): Need to add support for facades/backends to Bazel.
+PW_THREAD_ID_BACKEND = "//pw_thread_stl:id"
+PW_THREAD_SLEEP_BACKEND = "//pw_thread_stl:sleep"
+PW_THREAD_THREAD_BACKEND = "//pw_thread_stl:thread"
+PW_THREAD_YIELD_BACKEND = "//pw_thread_stl:yield"
+
+pw_cc_library(
+    name = "id_facade",
+    hdrs = [
+        "public/pw_thread/id.h",
+    ],
+    includes = ["public"],
+    deps = [
+        PW_THREAD_ID_BACKEND + "_headers",
+    ],
+)
+
+pw_cc_library(
+    name = "id",
+    deps = [
+        ":id_facade",
+        PW_THREAD_ID_BACKEND + "_headers",
+    ],
+)
+
+pw_cc_library(
+    name = "id_backend",
+    deps = [
+       PW_THREAD_ID_BACKEND,
+    ],
+)
+
+pw_cc_library(
+    name = "sleep_facade",
+    hdrs = [
+        "public/pw_thread/sleep.h",
+    ],
+    includes = ["public"],
+    srcs = [
+        "sleep.cc"
+    ],
+    deps = [
+        PW_THREAD_SLEEP_BACKEND + "_headers",
+        "//pw_chrono:system_clock",
+        "//pw_preprocessor",
+    ],
+)
+
+pw_cc_library(
+    name = "sleep",
+    deps = [
+        ":sleep_facade",
+        PW_THREAD_SLEEP_BACKEND + "_headers",
+    ],
+)
+
+pw_cc_library(
+    name = "sleep_backend",
+    deps = [
+       PW_THREAD_SLEEP_BACKEND,
+    ],
+)
+
+pw_cc_library(
+    name = "thread_facade",
+    hdrs = [
+        "public/pw_thread/thread.h",
+    ],
+    includes = ["public"],
+    deps = [
+        ":id_facade",
+        PW_THREAD_THREAD_BACKEND + "_headers",
+    ],
+)
+
+pw_cc_library(
+    name = "thread",
+    deps = [
+        ":thread_facade",
+        ":thread_core",
+        PW_THREAD_THREAD_BACKEND + "_headers",
+    ],
+    srcs = [
+        "thread.cc"
+    ],
+)
+
+pw_cc_library(
+    name = "thread_backend",
+    deps = [
+       PW_THREAD_THREAD_BACKEND,
+    ],
+)
+
+pw_cc_library(
+    name = "thread_core",
+    hdrs = [
+        "public/pw_thread/thread_core.h",
+    ],
+    includes = ["public"],
+)
+
+pw_cc_library(
+    name = "yield_facade",
+    hdrs = [
+        "public/pw_thread/yield.h",
+    ],
+    includes = ["public"],
+    srcs = [
+        "yield.cc"
+    ],
+    deps = [
+        PW_THREAD_YIELD_BACKEND + "_headers",
+        "//pw_preprocessor",
+    ],
+)
+
+pw_cc_library(
+    name = "yield",
+    deps = [
+        ":yield_facade",
+        PW_THREAD_YIELD_BACKEND + "_headers",
+    ],
+)
+
+pw_cc_library(
+    name = "yield_backend",
+    deps = [
+       PW_THREAD_YIELD_BACKEND,
+    ],
+)
+
+pw_cc_library(
+    name = "test_threads_header",
+    hdrs = [
+        "public/pw_thread/test_threads.h",
+    ],
+    deps = [
+        ":thread",
+    ],
+)
+
+# To instantiate this as a pw_cc_test, depend on this pw_cc_library and the
+# pw_cc_library which implements the backend for test_threads_header. See
+# //pw_thread:thread_backend_test as an example.
+pw_cc_library(
+    name = "thread_facade_test",
+    srcs = [
+        "thread_facade_test.cc",
+    ],
+    deps = [
+        ":thread",
+        ":id",
+        ":test_threads_header",
+        "//pw_chrono:system_clock",
+        "//pw_sync:binary_semaphore",
+        "//pw_unit_test",
+    ],
+)
+
+pw_cc_test(
+    name = "id_facade_test",
+    srcs = [
+        "id_facade_test.cc",
+    ],
+    deps = [
+        ":id",
+        "//pw_unit_test",
+    ],
+)
+
+pw_cc_test(
+    name = "sleep_facade_test",
+    srcs = [
+        "sleep_facade_test.cc",
+        "sleep_facade_test_c.c",
+    ],
+    deps = [
+        ":sleep",
+        "//pw_chrono:system_clock",
+        "//pw_preprocessor",
+        "//pw_unit_test",
+    ],
+)
+
+pw_cc_test(
+    name = "yield_facade_test",
+    srcs = [
+        "yield_facade_test.cc",
+        "yield_facade_test_c.c",
+    ],
+    deps = [
+        ":yield",
+        "//pw_preprocessor",
+        "//pw_unit_test",
+    ],
+)
diff --git a/pw_thread/BUILD.gn b/pw_thread/BUILD.gn
new file mode 100644
index 0000000..ccd04f3
--- /dev/null
+++ b/pw_thread/BUILD.gn
@@ -0,0 +1,134 @@
+# 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/facade.gni")
+import("$dir_pw_build/target_types.gni")
+import("$dir_pw_docgen/docs.gni")
+import("$dir_pw_unit_test/test.gni")
+import("backend.gni")
+
+config("public_include_path") {
+  include_dirs = [ "public" ]
+  visibility = [ ":*" ]
+}
+
+pw_facade("id") {
+  backend = pw_thread_ID_BACKEND
+  public_configs = [ ":public_include_path" ]
+  public = [ "public/pw_thread/id.h" ]
+}
+
+pw_facade("sleep") {
+  backend = pw_thread_SLEEP_BACKEND
+  public_configs = [ ":public_include_path" ]
+  public = [ "public/pw_thread/sleep.h" ]
+  public_deps = [
+    "$dir_pw_chrono:system_clock",
+    "$dir_pw_preprocessor",
+  ]
+  sources = [ "sleep.cc" ]
+}
+
+pw_facade("thread") {
+  backend = pw_thread_THREAD_BACKEND
+  public_configs = [ ":public_include_path" ]
+  public = [ "public/pw_thread/thread.h" ]
+  public_deps = [
+    ":id",
+    ":thread_core",
+  ]
+  sources = [ "thread.cc" ]
+}
+
+pw_source_set("thread_core") {
+  public_configs = [ ":public_include_path" ]
+  public = [ "public/pw_thread/thread_core.h" ]
+}
+
+pw_facade("yield") {
+  backend = pw_thread_YIELD_BACKEND
+  public_configs = [ ":public_include_path" ]
+  public = [ "public/pw_thread/yield.h" ]
+  public_deps = [ "$dir_pw_preprocessor" ]
+  sources = [ "yield.cc" ]
+}
+
+pw_test_group("tests") {
+  tests = [
+    ":id_facade_test",
+    ":sleep_facade_test",
+    ":yield_facade_test",
+  ]
+}
+
+pw_test("id_facade_test") {
+  enable_if = pw_thread_ID_BACKEND != ""
+  sources = [ "id_facade_test.cc" ]
+  deps = [ ":id" ]
+}
+
+pw_test("sleep_facade_test") {
+  enable_if = pw_thread_SLEEP_BACKEND != "" && pw_thread_ID_BACKEND != ""
+  sources = [
+    "sleep_facade_test.cc",
+    "sleep_facade_test_c.c",
+  ]
+  deps = [
+    ":id",
+    ":sleep",
+    "$dir_pw_chrono:system_clock",
+  ]
+}
+
+if (pw_thread_THREAD_BACKEND != "") {
+  pw_source_set("test_threads") {
+    public_configs = [ ":public_include_path" ]
+    public = [ "public/pw_thread/test_threads.h" ]
+    public_deps = [ ":thread" ]
+  }
+
+  # To instantiate this facade test based on a selected backend to provide
+  # test_threads you can create a pw_test target which depends on this
+  # pw_source_set and a pw_source_set which provides the implementation of
+  # test_threads. See "$dir_pw_thread_stl:thread_backend_test" as an example.
+  pw_source_set("thread_facade_test") {
+    sources = [ "thread_facade_test.cc" ]
+    deps = [
+      ":id",
+      ":sleep",
+      ":test_threads",
+      ":thread",
+      "$dir_pw_sync:binary_semaphore",
+      dir_pw_unit_test,
+    ]
+  }
+}
+
+pw_test("yield_facade_test") {
+  enable_if = pw_thread_YIELD_BACKEND != "" && pw_thread_ID_BACKEND != ""
+  sources = [
+    "yield_facade_test.cc",
+    "yield_facade_test_c.c",
+  ]
+  deps = [
+    ":id",
+    ":yield",
+  ]
+}
+
+pw_doc_group("docs") {
+  sources = [ "docs.rst" ]
+}
diff --git a/pw_thread/backend.gni b/pw_thread/backend.gni
new file mode 100644
index 0000000..6ecd378
--- /dev/null
+++ b/pw_thread/backend.gni
@@ -0,0 +1,32 @@
+# 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.
+
+declare_args() {
+  # Backend for the pw_thread module's pw::thread::Id.
+  pw_thread_ID_BACKEND = ""
+
+  # Backend for the pw_thread module's pw::thread::sleep_{for,until}.
+  pw_thread_SLEEP_BACKEND = ""
+
+  # Backend for the pw_thread module's pw::thread::Thread to create threads.
+  pw_thread_THREAD_BACKEND = ""
+
+  # Backend for the pw_thread module's pw::thread::yield.
+  pw_thread_YIELD_BACKEND = ""
+
+  # Whether the GN asserts should be silenced in ensuring that a compatible
+  # backend for pw_chrono_SYSTEM_CLOCK_BACKEND is chosen.
+  # Set to true to disable the asserts.
+  pw_thread_OVERRIDE_SYSTEM_CLOCK_BACKEND_CHECK = false
+}
diff --git a/pw_thread/docs.rst b/pw_thread/docs.rst
new file mode 100644
index 0000000..03b1a5a
--- /dev/null
+++ b/pw_thread/docs.rst
@@ -0,0 +1,194 @@
+.. _module-pw_thread:
+
+=========
+pw_thread
+=========
+The ``pw_thread`` module contains utilities for thread creation and thread
+execution.
+
+.. contents::
+   :local:
+   :depth: 2
+
+.. Warning::
+  This module is still under construction, the API is not yet stable.
+
+---------------
+Thread Creation
+---------------
+The class ``pw::thread::Thread`` can represent a single thread of execution.
+Threads allow multiple functions to execute concurrently.
+
+The Thread's API is C++11 STL
+`std::thread <https://en.cppreference.com/w/cpp/thread/thread>`_ like, meaning
+the object is effectively a thread handle and not an object which contains the
+thread's context. Unlike ``std::thread``, the API requires
+``pw::thread::Options`` as an argument and is limited to only work with
+``pw::thread::ThreadCore`` objects and functions which match the
+``pw::thread::Thread::ThreadRoutine`` signature.
+
+Threads may begin execution immediately upon construction of the associated
+thread object (pending any OS scheduling delays), starting at the top-level
+function provided as a constructor argument. The return value of the
+top-level function is ignored. The top-level function may communicate its
+return value by modifying shared variables (which may require
+synchronization, see :ref:`module-pw_sync`)
+
+Thread objects may also be in the state that does not represent any thread
+(after default construction, move from, detach, or join), and a thread of
+execution may be not associated with any thread objects (after detach).
+
+No two Thread objects may represent the same thread of execution; Thread is
+not CopyConstructible or CopyAssignable, although it is MoveConstructible and
+MoveAssignable.
+
+.. list-table::
+
+  * - *Supported on*
+    - *Backend module*
+  * - FreeRTOS
+    - :ref:`module-pw_thread_freertos`
+  * - ThreadX
+    - :ref:`module-pw_thread_threadx`
+  * - embOS
+    - Planned
+  * - STL
+    - :ref:`module-pw_thread_stl`
+  * - Zephyr
+    - Planned
+  * - CMSIS-RTOS API v2 & RTX5
+    - Planned
+
+
+Options
+=======
+The ``pw::thread::Options`` contains the parameters or attributes needed for a
+thread to start.
+
+Pigweed does not generalize options, instead we strive to give you full control
+where we provide helpers to do this.
+
+Options are backend specific and ergo the generic base class cannot be
+directly instantiated.
+
+The attributes which can be set through the options are backend specific
+but may contain things like the thread name, priority, scheduling policy,
+core/processor affinity, and/or an optional reference to a pre-allocated
+Context (the collection of memory allocations needed for a thread to run).
+
+Options shall NOT permit starting as detached, this must be done explicitly
+through the Thread API.
+
+Options must not contain any memory needed for a thread to run (TCB,
+stack, etc.). The Options may be deleted or re-used immediately after
+starting a thread.
+
+Please see the thread creation backend documentation for how their Options work.
+
+.. Note::
+  Options have a default constructor, however default options are not portable!
+  Default options can only work if threads are dynamically allocated by default,
+  meaning default options cannot work on backends which require static thread
+  allocations. In addition on some schedulers, default options will not work
+  for other reasons.
+
+Detaching & Joining
+===================
+The ``Thread::detach()`` API is always available, to let you separate the
+thread of execution from the thread object, allowing execution to continue
+independently.
+
+The joining API, more specifically ``Thread::join()``, is conditionally
+available depending on the selected backend for thread creation and how it is
+configured. The backend is responsible for providing the
+``PW_THREAD_JOINING_ENABLED`` macro through
+``pw_thread_backend/thread_native.h``. This ensures that any users which include
+``pw_thread/thread.h`` can use this macro if needed.
+
+Please see the selected thread creation backend documentation for how to
+enable joining if it's not already enabled by default.
+
+.. Warning::
+  A constructed ``pw::thread::Thread`` which represents a thread of execution
+  must be EITHER detached or joined, else the destructor will assert!
+
+ThreadRoutine & ThreadCore
+==========================
+Threads must either be invoked through a
+``pw::thread::Thread::ThreadRoutine``` style function or implement the
+``pw::thread::ThreadCore`` interface.
+
+.. code-block:: cpp
+
+  namespace pw::thread {
+
+  // This function may return.
+  using Thread::ThreadRoutine = void (*)(void* arg);
+
+  class ThreadCore {
+   public:
+    virtual ~ThreadCore() = default;
+
+    // The public API to start a ThreadCore, note that this may return.
+    void Start() { Run(); }
+
+   private:
+    // This function may return.
+    virtual void Run() = 0;
+  };
+
+  }  // namespace pw::thread;
+
+
+To use the ``pw::thread::Thread::ThreadRoutine``, your function must have the
+following signature:
+
+.. code-block:: cpp
+
+  void example_thread_entry_function(void *arg);
+
+
+To invoke a member method of a class a static lambda closure can be used
+to ensure the dispatching closure is not destructed before the thread is
+done executing. For example:
+
+.. code-block:: cpp
+
+  class Foo {
+   public:
+    void DoBar() {}
+  };
+  Foo foo;
+
+  static auto invoke_foo_do_bar = [](void *void_foo_ptr) {
+      //  If needed, additional arguments could be set here.
+      static_cast<Foo*>(void_foo_ptr)->DoBar();
+  };
+
+  // Now use the lambda closure as the thread entry, passing the foo's
+  // this as the argument.
+  Thread thread(options, invoke_foo_do_bar, &foo);
+  thread.detach();
+
+
+Alternatively, the aforementioned ``pw::thread::ThreadCore`` interface can be
+be implemented by an object by overriding the private
+``void ThreadCore::Run();`` method. This makes it easier to create a thread, as
+a static lambda closure or function is not needed to dispatch to a member
+function without arguments. For example:
+
+.. code-block:: cpp
+
+  class Foo : public ThreadCore {
+   private:
+    void Run() override {}
+  };
+  Foo foo;
+
+  // Now create the thread, using foo directly.
+  Thread(options, foo).detach();
+
+.. Warning::
+  Because the thread may start after the pw::Thread creation, an object which
+  implements the ThreadCore MUST meet or exceed the lifetime of its thread of
+  execution!
diff --git a/pw_thread/id_facade_test.cc b/pw_thread/id_facade_test.cc
new file mode 100644
index 0000000..bb61607
--- /dev/null
+++ b/pw_thread/id_facade_test.cc
@@ -0,0 +1,29 @@
+// 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.
+
+#include "gtest/gtest.h"
+#include "pw_thread/id.h"
+
+namespace pw::this_thread {
+namespace {
+
+TEST(Id, GetId) {
+  // We expect unit tests to run in a thread context.
+  // Unfortunately beyond this we need the ability to create and destroy threads
+  // to test more Id functionality.
+  EXPECT_NE(get_id(), thread::Id());
+}
+
+}  // namespace
+}  // namespace pw::this_thread
diff --git a/pw_thread/public/pw_thread/id.h b/pw_thread/public/pw_thread/id.h
new file mode 100644
index 0000000..ef7c1f0
--- /dev/null
+++ b/pw_thread/public/pw_thread/id.h
@@ -0,0 +1,48 @@
+// 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.
+#pragma once
+
+#include "pw_thread_backend/id_native.h"
+
+namespace pw::thread {
+
+// The class thread::id is a lightweight, trivially copyable class that serves
+// as a unique identifier of Thread objects.
+//
+// Instances of this class may also hold the special distinct value that does
+// not represent any thread. Once a thread has finished, the value of
+// Thread::id may be reused by another thread.
+//
+// This class is designed for use as key in associative containers, both
+// ordered and unordered.
+//
+// The backend must ensure that:
+// 1) There is a default construct which does not represent a thread.
+// 2) Compare operators (==,!=,<,<=,>,>=) are provided to compare and sort IDs.
+using Id = backend::NativeId;
+
+}  // namespace pw::thread
+
+namespace pw::this_thread {
+
+// This is thread safe, not IRQ safe. It is implementation defined whether this
+// is safe before the scheduler has started.
+thread::Id get_id() noexcept;
+
+}  // namespace pw::this_thread
+
+// The backend can opt to include an inline implementation.
+#if __has_include("pw_thread_backend/id_inline.h")
+#include "pw_thread_backend/id_inline.h"
+#endif  // __has_include("pw_thread_backend/id_inline.h")
diff --git a/pw_thread/public/pw_thread/sleep.h b/pw_thread/public/pw_thread/sleep.h
new file mode 100644
index 0000000..4e1fb48
--- /dev/null
+++ b/pw_thread/public/pw_thread/sleep.h
@@ -0,0 +1,59 @@
+// 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.
+#pragma once
+
+#include "pw_chrono/system_clock.h"
+#include "pw_preprocessor/util.h"
+
+#ifdef __cplusplus
+
+namespace pw::this_thread {
+
+// Blocks the execution of the current thread for at least the specified
+// duration. This function may block for longer due to scheduling or resource
+// contention delays.
+//
+// A sleep duration of 0 will at minimum yield, meaning it will provide a hint
+// to the implementation to reschedule the execution of threads, allowing other
+// threads to run.
+//
+// This can only be called from a thread, meaning the scheduler is running.
+void sleep_for(chrono::SystemClock::duration for_at_least);
+
+// Blocks the execution of the current thread until at least the specified
+// deadline. This function may block for longer due to scheduling or resource
+// contention delays.
+//
+// A sleep deadline in the past up to the current time will at minimum yield
+// meaning it will provide a hint to the implementation to reschedule the
+// execution of threads, allowing other threads to run.
+//
+// This can only be called from a thread, meaning the scheduler is running.
+void sleep_until(chrono::SystemClock::time_point until_at_least);
+
+}  // namespace pw::this_thread
+
+// The backend can opt to include inlined implementations.
+#if __has_include("pw_thread_backend/sleep_inline.h")
+#include "pw_thread_backend/sleep_inline.h"
+#endif  // __has_include("pw_thread_backend/sleep_inline.h")
+
+#endif  // __cplusplus
+
+PW_EXTERN_C_START
+
+void pw_this_thread_SleepFor(pw_chrono_SystemClock_Duration for_at_least);
+void pw_this_thread_SleepUntil(pw_chrono_SystemClock_TimePoint until_at_least);
+
+PW_EXTERN_C_END
diff --git a/pw_thread/public/pw_thread/test_threads.h b/pw_thread/public/pw_thread/test_threads.h
new file mode 100644
index 0000000..4d7a456
--- /dev/null
+++ b/pw_thread/public/pw_thread/test_threads.h
@@ -0,0 +1,36 @@
+// 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.
+#pragma once
+
+#include "pw_thread/thread.h"
+
+namespace pw::thread::test {
+
+// Two test threads are used to verify the thread facade.
+const Options& TestOptionsThread0();
+const Options& TestOptionsThread1();
+
+// Unfortunately the thread facade test's job is also to test detached threads
+// which may be backed by static contexts or dynamic context heap allocations.
+// In literally every other case you would use join for this, however that is
+// not an option here as detached thread functionality is being tested.
+// For this reason a backend specific cleanup API is provided which shall block
+// until all the test threads above have finished executions and are ready for
+// potential re-use and/or freed any dynamic allocations.
+//
+// Precondition: The threads must have started to execute before calling this
+// if cleanup is expected.
+void WaitUntilDetachedThreadsCleanedUp();
+
+}  // namespace pw::thread::test
diff --git a/pw_thread/public/pw_thread/thread.h b/pw_thread/public/pw_thread/thread.h
new file mode 100644
index 0000000..f609fed
--- /dev/null
+++ b/pw_thread/public/pw_thread/thread.h
@@ -0,0 +1,190 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "pw_thread/id.h"
+#include "pw_thread/thread_core.h"
+
+// clang-format off
+// The backend's thread_native header must provide PW_THREAD_JOINING_ENABLED.
+#include "pw_thread_backend/thread_native.h"
+// clang-format on
+
+namespace pw::thread {
+
+// The Options contains the parameters needed for a thread to start.
+//
+// Options are backend specific and ergo the generic base class cannot be
+// directly instantiated.
+//
+// The attributes which can be set through the options are backend specific
+// but may contain things like the thread name, priority, scheduling policy,
+// core/processor affinity, and/or an optional reference to a pre-allocated
+// Context (the collection of memory allocations needed for a thread to run).
+//
+// Options shall NOT permit starting as detached, this must be done explicitly
+// through the Thread API.
+//
+// Options must not contain any memory needed for a thread to run (TCB,
+// stack, etc.). The Options may be deleted or re-used immediately after
+// starting a thread.
+class Options {
+ protected:
+  constexpr Options() = default;
+};
+
+// The class Thread can represent a single thread of execution. Threads allow
+// multiple functions to execute concurrently.
+//
+// Threads may begin execution immediately upon construction of the associated
+// thread object (pending any OS scheduling delays), starting at the top-level
+// function provided as a constructor argument. The return value of the
+// top-level function is ignored. The top-level function may communicate its
+// return value by modifying shared variables (which may require
+// synchronization, see pw_sync and std::atomic)
+//
+// Thread objects may also be in the state that does not represent any thread
+// (after default construction, move from, detach, or join), and a thread of
+// execution may be not associated with any thread objects (after detach).
+//
+// No two Thread objects may represent the same thread of execution; Thread is
+// not CopyConstructible or CopyAssignable, although it is MoveConstructible and
+// MoveAssignable.
+class Thread {
+ public:
+  using native_handle_type = backend::NativeThreadHandle;
+
+  // Creates a new thread object which does not represent a thread of execution
+  // yet.
+  Thread();
+
+  // Creates a new thread object which represents a thread of execution.
+  //
+  // Thread functions are permitted to return and must have the following
+  // ThreadRoutine signature:
+  //   void example_function(void *arg);
+  //
+  // To invoke a member method of a class a static lambda closure can be used
+  // to ensure the dispatching closure is not destructed before the thread is
+  // done executing. For example:
+  //   class Foo {
+  //    public:
+  //     void DoBar() {}
+  //   };
+  //   Foo foo;
+  //
+  //   static auto invoke_foo_do_bar = [](void *void_foo_ptr) {
+  //       //  If needed, additional arguments could be set here.
+  //       static_cast<Foo*>(void_foo_ptr)->DoBar();
+  //   };
+  //
+  //   // Now use the lambda closure as the thread entry, passing the foo's
+  //   // this as the argument.
+  //   Thread thread(options, invoke_foo_do_bar, &foo);
+  //   thread.detach();
+  //
+  // Alternatively a helper ThreadCore interface can be implemented by an object
+  // so that a static lambda closure or function is not needed to dispatch to
+  // a member function without arguments. For example:
+  //   class Foo : public ThreadCore {
+  //    private:
+  //     void Run() override {}
+  //   };
+  //   Foo foo;
+  //
+  //   // Now create the thread, using foo directly.
+  //   Thread(options, foo).detach();
+  //
+  // Postcondition: The thread get EITHER detached or joined.
+  //
+  // NOTE: Options have a default constructor, however default options are not
+  // portable! Default options can only work if threads are dynamically
+  // allocated by default, meaning default options cannot work on backends which
+  // require static thread allocations. In addition on some schedulers
+  // default options may not work for other reasons.
+  using ThreadRoutine = void (*)(void* arg);
+  Thread(const Options& options, ThreadRoutine entry, void* arg = nullptr);
+  Thread(const Options& options, ThreadCore& thread_core);
+
+  // Postcondition: The other thread no longer represents a thread of execution.
+  Thread& operator=(Thread&& other);
+
+  // Precondition: The thread must have been EITHER detached or joined.
+  ~Thread();
+
+  Thread(const Thread&) = delete;
+  Thread(Thread&&) = delete;
+  Thread& operator=(const Thread&) = delete;
+
+  // Returns a value of Thread::id identifying the thread associated with *this.
+  // If there is no thread associated, default constructed Thread::id is
+  // returned.
+  Id get_id() const;
+
+  // Checks if the Thread object identifies an active thread of execution which
+  // has not yet been detached. Specifically, returns true if get_id() !=
+  // pw::Thread::id() && detach() has NOT been invoked. So a default
+  // constructed thread is not joinable and neither is one which was detached.
+  //
+  // A thread that has not started or has finished executing code which was
+  // never detached, but has not yet been joined is still considered an active
+  // thread of execution and is therefore joinable.
+  bool joinable() const { return get_id() != Id(); }
+
+#if PW_THREAD_JOINING_ENABLED
+  // Blocks the current thread until the thread identified by *this finishes its
+  // execution.
+  //
+  // The completion of the thread identified by *this synchronizes with the
+  // corresponding successful return from join().
+  //
+  // No synchronization is performed on *this itself. Concurrently calling
+  // join() on the same thread object from multiple threads constitutes a data
+  // race that results in undefined behavior.
+  //
+  // Precondition: The thread must have been NEITHER detached nor joined.
+  //
+  // Postcondition: After calling detach *this no longer owns any thread.
+  void join();
+#endif  // PW_THREAD_JOINING_ENABLED
+
+  // Separates the thread of execution from the thread object, allowing
+  // execution to continue independently. Any allocated resources will be freed
+  // once the thread exits.
+  //
+  // Precondition: The thread must have been NEITHER detached nor joined.
+  //
+  // Postcondition: After calling detach *this no longer owns any thread.
+  void detach();
+
+  // Exchanges the underlying handles of two thread objects.
+  void swap(Thread& other);
+
+  native_handle_type native_handle();
+
+ private:
+  // Note that just like std::thread, this is effectively just a pointer or
+  // reference to the native thread -- this does not contain any memory needed
+  // for the thread to execute.
+  //
+  // This may contain more than the native thread handle to enable functionality
+  // which is not always available such as joining, which may require a
+  // reference to a binary semaphore, or passing arguments to the thread's
+  // function.
+  backend::NativeThread native_type_;
+};
+
+}  // namespace pw::thread
+
+#include "pw_thread_backend/thread_inline.h"
diff --git a/pw_thread/public/pw_thread/thread_core.h b/pw_thread/public/pw_thread/thread_core.h
new file mode 100644
index 0000000..23e4c62
--- /dev/null
+++ b/pw_thread/public/pw_thread/thread_core.h
@@ -0,0 +1,48 @@
+// Copyright 2021 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.
+#pragma once
+
+namespace pw::thread {
+
+// An optional virtual interface which can be implemented by objects which are
+// a thread as a helper to use pw::thread::Thread.
+//
+// This wrapper means that the user is not required to provide the indirection
+// callback to call run based on the passed context. For example instead of:
+//
+//   static auto invoke_foo_start = [](void *void_foo_ptr) {
+//     static_cast<Foo*>(void_foo_ptr)->Start();
+//   };
+//   Thread thread(options, invoke_foo_start, &foo).detach();
+//
+// You can instead use the helper constructor in Thread:
+//
+//   Thread thread(options, foo).detach();
+//
+// WARNING: Because the thread may start after the pw::Thread creation, an
+// object which implements the ThreadCore MUST meet or exceed the lifetime of
+// its thread of execution!
+class ThreadCore {
+ public:
+  virtual ~ThreadCore() = default;
+
+  // The public API to start a ThreadCore, note that this may return.
+  void Start() { Run(); }
+
+ private:
+  // This function may return.
+  virtual void Run() = 0;
+};
+
+}  // namespace pw::thread
diff --git a/pw_thread/public/pw_thread/yield.h b/pw_thread/public/pw_thread/yield.h
new file mode 100644
index 0000000..fc6d89d
--- /dev/null
+++ b/pw_thread/public/pw_thread/yield.h
@@ -0,0 +1,45 @@
+// 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.
+#pragma once
+
+#include "pw_preprocessor/util.h"
+
+#ifdef __cplusplus
+
+namespace pw::this_thread {
+
+// Provides a hint to the implementation to reschedule the execution of threads,
+// allowing other threads to run.
+//
+// The exact behavior of this function depends on the implementation, in
+// particular on the mechanics of the OS scheduler in use and the state of the
+// system.
+//
+// This can only be called from a thread, meaning the scheduler is running.
+void yield() noexcept;
+
+}  // namespace pw::this_thread
+
+// The backend can opt to include an inline implementation.
+#if __has_include("pw_thread_backend/yield_inline.h")
+#include "pw_thread_backend/yield_inline.h"
+#endif  // __has_include("pw_thread_backend/yield_inline.h")
+
+#endif  // __cplusplus
+
+PW_EXTERN_C_START
+
+void pw_this_thread_Yield(void);
+
+PW_EXTERN_C_END
diff --git a/pw_thread/sleep.cc b/pw_thread/sleep.cc
new file mode 100644
index 0000000..403df3a
--- /dev/null
+++ b/pw_thread/sleep.cc
@@ -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.
+
+#include "pw_thread/sleep.h"
+
+using pw::chrono::SystemClock;
+
+extern "C" void pw_this_thread_SleepFor(
+    pw_chrono_SystemClock_Duration for_at_least) {
+  pw::this_thread::sleep_for(SystemClock::duration(for_at_least.ticks));
+}
+
+extern "C" void pw_this_thread_SleepUntil(
+    pw_chrono_SystemClock_TimePoint until_at_least) {
+  pw::this_thread::sleep_until(SystemClock::time_point(
+      SystemClock::duration(until_at_least.duration_since_epoch.ticks)));
+}
diff --git a/pw_thread/sleep_facade_test.cc b/pw_thread/sleep_facade_test.cc
new file mode 100644
index 0000000..3112b63
--- /dev/null
+++ b/pw_thread/sleep_facade_test.cc
@@ -0,0 +1,90 @@
+// 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.
+
+#include <chrono>
+
+#include "gtest/gtest.h"
+#include "pw_chrono/system_clock.h"
+#include "pw_thread/id.h"
+#include "pw_thread/sleep.h"
+
+using pw::chrono::SystemClock;
+using namespace std::chrono_literals;
+
+namespace pw::this_thread {
+namespace {
+
+extern "C" {
+
+// Functions defined in sleep_facade_test_c.c which call the API from C.
+void pw_this_thread_CallSleepFor(pw_chrono_SystemClock_Duration for_at_least);
+void pw_this_thread_CallSleepUntil(
+    pw_chrono_SystemClock_TimePoint until_at_least);
+
+}  // extern "C"
+
+// We can't control the SystemClock's period configuration, so just in case
+// duration cannot be accurately expressed in integer ticks, round the
+// duration up.
+constexpr SystemClock::duration kRoundedArbitraryDuration =
+    SystemClock::for_at_least(42ms);
+constexpr pw_chrono_SystemClock_Duration kRoundedArbitraryDurationInC =
+    PW_SYSTEM_CLOCK_MS(42);
+
+TEST(Sleep, SleepFor) {
+  // Ensure we are in a thread context, meaning we are permitted to sleep.
+  ASSERT_NE(get_id(), thread::Id());
+
+  const SystemClock::time_point before = SystemClock::now();
+  sleep_for(kRoundedArbitraryDuration);
+  const SystemClock::duration time_elapsed = SystemClock::now() - before;
+  EXPECT_GE(time_elapsed, kRoundedArbitraryDuration);
+}
+
+TEST(Sleep, SleepUntil) {
+  // Ensure we are in a thread context, meaning we are permitted to sleep.
+  ASSERT_NE(get_id(), thread::Id());
+
+  const SystemClock::time_point deadline =
+      SystemClock::now() + kRoundedArbitraryDuration;
+  sleep_until(deadline);
+  EXPECT_GE(SystemClock::now(), deadline);
+}
+
+TEST(Sleep, SleepForInC) {
+  // Ensure we are in a thread context, meaning we are permitted to sleep.
+  ASSERT_NE(get_id(), thread::Id());
+
+  pw_chrono_SystemClock_TimePoint before = pw_chrono_SystemClock_Now();
+  pw_this_thread_SleepFor(kRoundedArbitraryDurationInC);
+  pw_chrono_SystemClock_Duration time_elapsed =
+      pw_chrono_SystemClock_TimeElapsed(before, pw_chrono_SystemClock_Now());
+  EXPECT_GE(time_elapsed.ticks, kRoundedArbitraryDurationInC.ticks);
+}
+
+TEST(Sleep, SleepUntilInC) {
+  // Ensure we are in a thread context, meaning we are permitted to sleep.
+  ASSERT_NE(get_id(), thread::Id());
+
+  pw_chrono_SystemClock_TimePoint deadline;
+  deadline.duration_since_epoch.ticks =
+      pw_chrono_SystemClock_Now().duration_since_epoch.ticks +
+      kRoundedArbitraryDurationInC.ticks;
+  pw_this_thread_CallSleepUntil(deadline);
+  EXPECT_GE(pw_chrono_SystemClock_Now().duration_since_epoch.ticks,
+            deadline.duration_since_epoch.ticks);
+}
+
+}  // namespace
+}  // namespace pw::this_thread
diff --git a/pw_thread/sleep_facade_test_c.c b/pw_thread/sleep_facade_test_c.c
new file mode 100644
index 0000000..450c387
--- /dev/null
+++ b/pw_thread/sleep_facade_test_c.c
@@ -0,0 +1,27 @@
+// 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.
+
+// These tests call the pw_thread module sleep API from C. The return values are
+// checked in the main C++ tests.
+
+#include "pw_thread/sleep.h"
+
+void pw_this_thread_CallSleepFor(pw_chrono_SystemClock_Duration for_at_least) {
+  pw_this_thread_SleepFor(for_at_least);
+}
+
+void pw_this_thread_CallSleepUntil(
+    pw_chrono_SystemClock_TimePoint until_at_least) {
+  pw_this_thread_SleepUntil(until_at_least);
+}
diff --git a/pw_thread/thread.cc b/pw_thread/thread.cc
new file mode 100644
index 0000000..d2844e5
--- /dev/null
+++ b/pw_thread/thread.cc
@@ -0,0 +1,30 @@
+// Copyright 2021 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.
+
+#include "pw_thread/thread.h"
+
+namespace pw::thread {
+namespace {
+
+void StartThreadCore(void* void_thread_core_ptr) {
+  static_cast<ThreadCore*>(void_thread_core_ptr)->Start();
+}
+
+}  // namespace
+
+// Delegating constructor which defers to the facade's constructor.
+Thread::Thread(const Options& options, ThreadCore& thread_core)
+    : Thread(options, StartThreadCore, &thread_core) {}
+
+}  // namespace pw::thread
diff --git a/pw_thread/thread_facade_test.cc b/pw_thread/thread_facade_test.cc
new file mode 100644
index 0000000..008e2ca
--- /dev/null
+++ b/pw_thread/thread_facade_test.cc
@@ -0,0 +1,167 @@
+// Copyright 2021 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.
+
+#include "gtest/gtest.h"
+#include "pw_sync/binary_semaphore.h"
+#include "pw_thread/id.h"
+#include "pw_thread/test_threads.h"
+#include "pw_thread/thread.h"
+
+using pw::thread::test::TestOptionsThread0;
+using pw::thread::test::TestOptionsThread1;
+using pw::thread::test::WaitUntilDetachedThreadsCleanedUp;
+
+namespace pw::thread {
+namespace {
+
+TEST(Thread, DefaultIds) {
+  Thread not_executing_thread;
+  EXPECT_EQ(not_executing_thread.get_id(), Id());
+}
+
+static void ReleaseBinarySemaphore(void* arg) {
+  static_cast<sync::BinarySemaphore*>(arg)->release();
+}
+
+#if PW_THREAD_JOINING_ENABLED
+TEST(Thread, Join) {
+  Thread thread;
+  EXPECT_FALSE(thread.joinable());
+  sync::BinarySemaphore thread_ran_sem;
+  thread =
+      Thread(TestOptionsThread0(), ReleaseBinarySemaphore, &thread_ran_sem);
+  EXPECT_TRUE(thread.joinable());
+  thread.join();
+  EXPECT_EQ(thread.get_id(), Id());
+  EXPECT_TRUE(thread_ran_sem.try_acquire());
+}
+#endif  // PW_THREAD_JOINING_ENABLED
+
+TEST(Thread, Detach) {
+  Thread thread;
+  sync::BinarySemaphore thread_ran_sem;
+  thread =
+      Thread(TestOptionsThread0(), ReleaseBinarySemaphore, &thread_ran_sem);
+  EXPECT_NE(thread.get_id(), Id());
+  EXPECT_TRUE(thread.joinable());
+  thread.detach();
+  EXPECT_EQ(thread.get_id(), Id());
+  EXPECT_FALSE(thread.joinable());
+  thread_ran_sem.acquire();
+
+  WaitUntilDetachedThreadsCleanedUp();
+}
+
+TEST(Thread, SwapWithoutExecution) {
+  Thread thread_0;
+  Thread thread_1;
+
+  // Make sure we can swap threads which are not associated with any execution.
+  thread_0.swap(thread_1);
+}
+
+TEST(Thread, SwapWithOneExecuting) {
+  Thread thread_0;
+  EXPECT_EQ(thread_0.get_id(), Id());
+
+  sync::BinarySemaphore thread_ran_sem;
+  Thread thread_1(
+      TestOptionsThread1(), ReleaseBinarySemaphore, &thread_ran_sem);
+
+  EXPECT_NE(thread_1.get_id(), Id());
+
+  thread_0.swap(thread_1);
+  EXPECT_NE(thread_0.get_id(), Id());
+  EXPECT_EQ(thread_1.get_id(), Id());
+
+  thread_0.detach();
+  EXPECT_EQ(thread_0.get_id(), Id());
+
+  thread_ran_sem.acquire();
+  WaitUntilDetachedThreadsCleanedUp();
+}
+
+TEST(Thread, SwapWithTwoExecuting) {
+  sync::BinarySemaphore thread_a_ran_sem;
+  Thread thread_0(
+      TestOptionsThread0(), ReleaseBinarySemaphore, &thread_a_ran_sem);
+  sync::BinarySemaphore thread_b_ran_sem;
+  Thread thread_1(
+      TestOptionsThread1(), ReleaseBinarySemaphore, &thread_b_ran_sem);
+  const Id thread_a_id = thread_0.get_id();
+  EXPECT_NE(thread_a_id, Id());
+  const Id thread_b_id = thread_1.get_id();
+  EXPECT_NE(thread_b_id, Id());
+  EXPECT_NE(thread_a_id, thread_b_id);
+
+  thread_0.swap(thread_1);
+  EXPECT_EQ(thread_1.get_id(), thread_a_id);
+  EXPECT_EQ(thread_0.get_id(), thread_b_id);
+
+  thread_0.detach();
+  EXPECT_EQ(thread_0.get_id(), Id());
+  thread_1.detach();
+  EXPECT_EQ(thread_1.get_id(), Id());
+
+  thread_a_ran_sem.acquire();
+  thread_b_ran_sem.acquire();
+  WaitUntilDetachedThreadsCleanedUp();
+}
+
+TEST(Thread, MoveOperator) {
+  Thread thread_0;
+  EXPECT_EQ(thread_0.get_id(), Id());
+
+  sync::BinarySemaphore thread_ran_sem;
+  Thread thread_1(
+      TestOptionsThread1(), ReleaseBinarySemaphore, &thread_ran_sem);
+  EXPECT_NE(thread_1.get_id(), Id());
+
+  thread_0 = std::move(thread_1);
+  EXPECT_NE(thread_0.get_id(), Id());
+#ifndef __clang_analyzer__
+  EXPECT_EQ(thread_1.get_id(), Id());
+#endif  // ignore use-after-move
+
+  thread_0.detach();
+  EXPECT_EQ(thread_0.get_id(), Id());
+
+  thread_ran_sem.acquire();
+  WaitUntilDetachedThreadsCleanedUp();
+}
+
+class SemaphoreReleaser : public ThreadCore {
+ public:
+  pw::sync::BinarySemaphore& semaphore() { return semaphore_; }
+
+ private:
+  void Run() override { semaphore_.release(); }
+
+  sync::BinarySemaphore semaphore_;
+};
+
+TEST(Thread, ThreadCore) {
+  SemaphoreReleaser semaphore_releaser;
+  Thread thread(TestOptionsThread0(), semaphore_releaser);
+  EXPECT_NE(thread.get_id(), Id());
+  EXPECT_TRUE(thread.joinable());
+  thread.detach();
+  EXPECT_EQ(thread.get_id(), Id());
+  EXPECT_FALSE(thread.joinable());
+  semaphore_releaser.semaphore().acquire();
+
+  WaitUntilDetachedThreadsCleanedUp();
+}
+}  // namespace
+}  // namespace pw::thread
diff --git a/pw_thread/yield.cc b/pw_thread/yield.cc
new file mode 100644
index 0000000..1883c4b
--- /dev/null
+++ b/pw_thread/yield.cc
@@ -0,0 +1,17 @@
+// 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.
+
+#include "pw_thread/yield.h"
+
+extern "C" void pw_this_thread_Yield() { pw::this_thread::yield(); }
diff --git a/pw_thread/yield_facade_test.cc b/pw_thread/yield_facade_test.cc
new file mode 100644
index 0000000..2b565f5
--- /dev/null
+++ b/pw_thread/yield_facade_test.cc
@@ -0,0 +1,52 @@
+// 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.
+
+#include "gtest/gtest.h"
+#include "pw_thread/id.h"
+#include "pw_thread/yield.h"
+
+namespace pw::this_thread {
+namespace {
+
+extern "C" {
+
+// Function defined in yield_facade_test_c.c which call the API from C.
+void pw_this_thread_CallYield();
+
+}  // extern "C"
+
+TEST(Yield, CompilesAndRuns) {
+  // Ensure we are in a thread context, meaning we are permitted to sleep.
+  ASSERT_NE(get_id(), thread::Id());
+
+  // Unfortunately we have not thought of a useful way to test yield without
+  // knowing the backend implementation as things like round robin scheduling
+  // may be enabled meaning it may appear like yield is working when it isn't.
+  // For now we just ensure it compiles and we can execute it without a crash.
+  yield();
+}
+
+TEST(Yield, CompilesAndRunsInC) {
+  // Ensure we are in a thread context, meaning we are permitted to sleep.
+  ASSERT_NE(get_id(), thread::Id());
+
+  // Unfortunately we have not thought of a useful way to test yield without
+  // knowing the backend implementation as things like round robin scheduling
+  // may be enabled meaning it may appear like yield is working when it isn't.
+  // For now we just ensure it compiles and we can execute it without a crash.
+  pw_this_thread_CallYield();
+}
+
+}  // namespace
+}  // namespace pw::this_thread
diff --git a/pw_thread/yield_facade_test_c.c b/pw_thread/yield_facade_test_c.c
new file mode 100644
index 0000000..4ff5088
--- /dev/null
+++ b/pw_thread/yield_facade_test_c.c
@@ -0,0 +1,20 @@
+// 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.
+
+// These tests call the pw_thread module yield API from C. The return values
+// are checked in the main C++ tests.
+
+#include "pw_thread/yield.h"
+
+void pw_this_thread_CallYield(void) { pw_this_thread_Yield(); }
diff --git a/pw_thread_embos/BUILD b/pw_thread_embos/BUILD
new file mode 100644
index 0000000..dd35c30
--- /dev/null
+++ b/pw_thread_embos/BUILD
@@ -0,0 +1,99 @@
+# Copyright 2021 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.
+
+load(
+    "//pw_build:pigweed.bzl",
+    "pw_cc_library",
+)
+
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])  # Apache License 2.0
+
+pw_cc_library(
+    name = "id_headers",
+    hdrs = [
+        "public/pw_thread_embos/id_inline.h",
+        "public/pw_thread_embos/id_native.h",
+        "public_overrides/pw_thread_backend/id_inline.h",
+        "public_overrides/pw_thread_backend/id_native.h",
+    ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+)
+
+pw_cc_library(
+    name = "id",
+    deps = [
+        ":id_headers",
+        "//pw_thread:id_facade",
+    ],
+    # TODO(pwbug/317): This should depend on embOS but our third parties
+    # currently do not have Bazel support.
+)
+
+pw_cc_library(
+    name = "sleep_headers",
+    hdrs = [
+        "public/pw_thread_embos/sleep_inline.h",
+        "public_overrides/pw_thread_backend/sleep_inline.h",
+    ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+    deps = [
+        "//pw_chrono:system_clock",
+    ],
+)
+
+pw_cc_library(
+    name = "sleep",
+    srcs = [
+        "sleep.cc",
+    ],
+    deps = [
+        ":sleep_headers",
+        "//pw_chrono_embos:system_clock_headers",
+        "//pw_assert",
+        "//pw_chrono:system_clock",
+        "//pw_thread:sleep_facade",
+    ],
+    # TODO(pwbug/317): This should depend on embOS but our third parties
+    # currently do not have Bazel support.
+)
+
+pw_cc_library(
+    name = "yield_headers",
+    hdrs = [
+        "public/pw_thread_embos/yield_inline.h",
+        "public_overrides/pw_thread_backend/yield_inline.h",
+    ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+    # TODO(pwbug/317): This should depend on embOS but our third parties
+    # currently do not have Bazel support.
+)
+
+pw_cc_library(
+    name = "yield",
+    deps = [
+        ":yield_headers",
+        "//pw_thread:yield_facade",
+    ],
+)
diff --git a/pw_thread_embos/BUILD.gn b/pw_thread_embos/BUILD.gn
new file mode 100644
index 0000000..4e76548
--- /dev/null
+++ b/pw_thread_embos/BUILD.gn
@@ -0,0 +1,100 @@
+# Copyright 2021 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/target_types.gni")
+import("$dir_pw_chrono/backend.gni")
+import("$dir_pw_docgen/docs.gni")
+import("$dir_pw_thread/backend.gni")
+
+config("public_include_path") {
+  include_dirs = [ "public" ]
+  visibility = [ ":*" ]
+}
+
+config("backend_config") {
+  include_dirs = [ "public_overrides" ]
+  visibility = [ ":*" ]
+}
+
+# This target provides the backend for pw::thread::Id.
+pw_source_set("id") {
+  public_configs = [
+    ":public_include_path",
+    ":backend_config",
+  ]
+  public_deps = [
+    "$dir_pw_assert",
+    "$dir_pw_interrupt:context",
+    "$dir_pw_third_party/embos",
+  ]
+  public = [
+    "public/pw_thread_embos/id_inline.h",
+    "public/pw_thread_embos/id_native.h",
+    "public_overrides/pw_thread_backend/id_inline.h",
+    "public_overrides/pw_thread_backend/id_native.h",
+  ]
+  deps = [ "$dir_pw_thread:id.facade" ]
+}
+
+if (pw_chrono_SYSTEM_CLOCK_BACKEND != "" && pw_thread_SLEEP_BACKEND != "") {
+  # This target provides the backend for pw::thread::sleep_{for,until}.
+  pw_source_set("sleep") {
+    public_configs = [
+      ":public_include_path",
+      ":backend_config",
+    ]
+    public = [
+      "public/pw_thread_embos/sleep_inline.h",
+      "public_overrides/pw_thread_backend/sleep_inline.h",
+    ]
+    public_deps = [ "$dir_pw_chrono:system_clock" ]
+    sources = [ "sleep.cc" ]
+    deps = [
+      "$dir_pw_assert",
+      "$dir_pw_chrono_embos:system_clock",
+      "$dir_pw_third_party/embos",
+      "$dir_pw_thread:id",
+      "$dir_pw_thread:sleep.facade",
+    ]
+    assert(pw_thread_OVERRIDE_SYSTEM_CLOCK_BACKEND_CHECK ||
+               pw_chrono_SYSTEM_CLOCK_BACKEND ==
+                   "$dir_pw_chrono_embos:system_clock",
+           "The embOS pw::thread::sleep_{for,until} backend only works with " +
+               "the embOS pw::chrono::SystemClock backend.")
+  }
+}
+
+# This target provides the backend for pw::thread::yield.
+pw_source_set("yield") {
+  public_configs = [
+    ":public_include_path",
+    ":backend_config",
+  ]
+  public = [
+    "public/pw_thread_embos/yield_inline.h",
+    "public_overrides/pw_thread_backend/yield_inline.h",
+  ]
+  public_deps = [
+    "$dir_pw_assert",
+    "$dir_pw_third_party/embos",
+    "$dir_pw_thread:id",
+  ]
+  deps = [ "$dir_pw_thread:yield.facade" ]
+}
+
+pw_doc_group("docs") {
+  sources = [ "docs.rst" ]
+}
diff --git a/pw_thread_embos/docs.rst b/pw_thread_embos/docs.rst
new file mode 100644
index 0000000..cad1908
--- /dev/null
+++ b/pw_thread_embos/docs.rst
@@ -0,0 +1,8 @@
+.. _module-pw_thread_embos:
+
+---------------
+pw_thread_embos
+---------------
+This is a set of backends for pw_thread based on embOS v4. It is not ready for
+use, and is under construction.
+
diff --git a/pw_thread_embos/public/pw_thread_embos/id_inline.h b/pw_thread_embos/public/pw_thread_embos/id_inline.h
new file mode 100644
index 0000000..b11078a
--- /dev/null
+++ b/pw_thread_embos/public/pw_thread_embos/id_inline.h
@@ -0,0 +1,27 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "RTOS.h"
+#include "pw_assert/light.h"
+#include "pw_thread/id.h"
+
+namespace pw::this_thread {
+
+inline thread::Id get_id() {
+  PW_DASSERT(OS_IsRunning() != 0);
+  return thread::Id(OS_GetTaskID());
+}
+
+}  // namespace pw::this_thread
diff --git a/pw_thread_embos/public/pw_thread_embos/id_native.h b/pw_thread_embos/public/pw_thread_embos/id_native.h
new file mode 100644
index 0000000..4d30539
--- /dev/null
+++ b/pw_thread_embos/public/pw_thread_embos/id_native.h
@@ -0,0 +1,51 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "RTOS.h"
+
+namespace pw::thread::backend {
+
+// Instead of using a pw::thread::embos specific identifier, the ThreadX
+// thread pointer is used as this means pw::this_thread::id works correctly on
+// threads started with the native ThreadX APIs as well as those started
+// using the pw::thread APIs.
+class NativeId {
+ public:
+  constexpr NativeId(OS_TASK* task_ptr = nullptr) : task_ptr_(task_ptr) {}
+
+  constexpr bool operator==(NativeId other) const {
+    return task_ptr_ == other.task_ptr_;
+  }
+  constexpr bool operator!=(NativeId other) const {
+    return task_ptr_ != other.task_ptr_;
+  }
+  constexpr bool operator<(NativeId other) const {
+    return task_ptr_ < other.task_ptr_;
+  }
+  constexpr bool operator<=(NativeId other) const {
+    return task_ptr_ <= other.task_ptr_;
+  }
+  constexpr bool operator>(NativeId other) const {
+    return task_ptr_ > other.task_ptr_;
+  }
+  constexpr bool operator>=(NativeId other) const {
+    return task_ptr_ >= other.task_ptr_;
+  }
+
+ private:
+  OS_TASK* task_ptr_;
+};
+
+}  // namespace pw::thread::backend
diff --git a/pw_thread_embos/public/pw_thread_embos/sleep_inline.h b/pw_thread_embos/public/pw_thread_embos/sleep_inline.h
new file mode 100644
index 0000000..5988e7e
--- /dev/null
+++ b/pw_thread_embos/public/pw_thread_embos/sleep_inline.h
@@ -0,0 +1,26 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "pw_chrono/system_clock.h"
+
+namespace pw::this_thread {
+
+inline void sleep_until(chrono::SystemClock::time_point until_at_least) {
+  // Note that if this deadline is in the future, it will get rounded up by
+  // one whole tick due to how sleep_for is implemented.
+  return sleep_for(until_at_least - chrono::SystemClock::now());
+}
+
+}  // namespace pw::this_thread
diff --git a/pw_thread_embos/public/pw_thread_embos/yield_inline.h b/pw_thread_embos/public/pw_thread_embos/yield_inline.h
new file mode 100644
index 0000000..2ad21fb
--- /dev/null
+++ b/pw_thread_embos/public/pw_thread_embos/yield_inline.h
@@ -0,0 +1,27 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "RTOS.h"
+#include "pw_assert/light.h"
+#include "pw_thread/id.h"
+
+namespace pw::this_thread {
+
+inline void yield() {
+  PW_DASSERT(get_id() != thread::Id());
+  OS_Yield();
+}
+
+}  // namespace pw::this_thread
diff --git a/pw_thread_embos/public_overrides/pw_thread_backend/id_inline.h b/pw_thread_embos/public_overrides/pw_thread_backend/id_inline.h
new file mode 100644
index 0000000..3ce10e0
--- /dev/null
+++ b/pw_thread_embos/public_overrides/pw_thread_backend/id_inline.h
@@ -0,0 +1,16 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "pw_thread_embos/id_inline.h"
diff --git a/pw_thread_embos/public_overrides/pw_thread_backend/id_native.h b/pw_thread_embos/public_overrides/pw_thread_backend/id_native.h
new file mode 100644
index 0000000..9dd53ec
--- /dev/null
+++ b/pw_thread_embos/public_overrides/pw_thread_backend/id_native.h
@@ -0,0 +1,16 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "pw_thread_embos/id_native.h"
diff --git a/pw_thread_embos/public_overrides/pw_thread_backend/sleep_inline.h b/pw_thread_embos/public_overrides/pw_thread_backend/sleep_inline.h
new file mode 100644
index 0000000..12db456
--- /dev/null
+++ b/pw_thread_embos/public_overrides/pw_thread_backend/sleep_inline.h
@@ -0,0 +1,16 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "pw_thread_embos/sleep_inline.h"
diff --git a/pw_thread_embos/public_overrides/pw_thread_backend/yield_inline.h b/pw_thread_embos/public_overrides/pw_thread_backend/yield_inline.h
new file mode 100644
index 0000000..6b07796
--- /dev/null
+++ b/pw_thread_embos/public_overrides/pw_thread_backend/yield_inline.h
@@ -0,0 +1,16 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "pw_thread_embos/yield_inline.h"
diff --git a/pw_thread_embos/sleep.cc b/pw_thread_embos/sleep.cc
new file mode 100644
index 0000000..03ca25f
--- /dev/null
+++ b/pw_thread_embos/sleep.cc
@@ -0,0 +1,49 @@
+// Copyright 2021 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.
+
+#include "pw_thread/sleep.h"
+
+#include <algorithm>
+
+#include "RTOS.h"
+#include "pw_assert/assert.h"
+#include "pw_chrono/system_clock.h"
+#include "pw_chrono_embos/system_clock_constants.h"
+#include "pw_thread/id.h"
+
+using pw::chrono::SystemClock;
+
+namespace pw::this_thread {
+
+void sleep_for(chrono::SystemClock::duration for_at_least) {
+  PW_DCHECK(get_id() != thread::Id());
+
+  // Yield for negative and zero length durations.
+  if (for_at_least <= SystemClock::duration::zero()) {
+    OS_Yield();
+    return;
+  }
+
+  // On a tick based kernel we cannot tell how far along we are on the current
+  // tick, ergo we add one whole tick to the final duration.
+  constexpr SystemClock::duration kMaxTimeoutMinusOne =
+      pw::chrono::embos::kMaxTimeout - SystemClock::duration(1);
+  while (for_at_least > kMaxTimeoutMinusOne) {
+    OS_Delay(static_cast<OS_TIME>(kMaxTimeoutMinusOne.count()));
+    for_at_least -= kMaxTimeoutMinusOne;
+  }
+  OS_Delay(static_cast<OS_TIME>(for_at_least.count()));
+}
+
+}  // namespace pw::this_thread
diff --git a/pw_thread_freertos/BUILD b/pw_thread_freertos/BUILD
new file mode 100644
index 0000000..ab6c845
--- /dev/null
+++ b/pw_thread_freertos/BUILD
@@ -0,0 +1,182 @@
+# 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.
+
+load(
+    "//pw_build:pigweed.bzl",
+    "pw_cc_library",
+    "pw_cc_test",
+)
+
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])  # Apache License 2.0
+
+pw_cc_library(
+    name = "id_headers",
+    hdrs = [
+        "public/pw_thread_freertos/id_inline.h",
+        "public/pw_thread_freertos/id_native.h",
+        "public_overrides/pw_thread_backend/id_inline.h",
+        "public_overrides/pw_thread_backend/id_native.h",
+    ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+)
+
+pw_cc_library(
+    name = "id",
+    deps = [
+        ":id_headers",
+        "//pw_thread:id_facade",
+    ],
+    # TODO(pwbug/317): This should depend on FreeRTOS but our third parties
+    # currently do not have Bazel support.
+)
+
+pw_cc_library(
+    name = "sleep_headers",
+    hdrs = [
+        "public/pw_thread_freertos/sleep_inline.h",
+        "public_overrides/pw_thread_backend/sleep_inline.h",
+    ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+    deps = [
+        "//pw_chrono:system_clock",
+    ],
+)
+
+pw_cc_library(
+    name = "sleep",
+    srcs = [
+        "sleep.cc",
+    ],
+    deps = [
+        ":sleep_headers",
+        "//pw_chrono_freertos:system_clock_headers",
+        "//pw_assert",
+        "//pw_chrono:system_clock",
+        "//pw_thread:sleep_facade",
+    ],
+    # TODO(pwbug/317): This should depend on FreeRTOS but our third parties
+    # currently do not have Bazel support.
+)
+
+# This target provides the FreeRTOS specific headers needs for thread creation.
+pw_cc_library(
+    name = "thread_headers",
+    hdrs = [
+        "public/pw_thread_freertos/context.h",
+        "public/pw_thread_freertos/options.h",
+        "public/pw_thread_freertos/config.h",
+        "public/pw_thread_freertos/thread_inline.h",
+        "public/pw_thread_freertos/thread_native.h",
+        "public_overrides/pw_thread_backend/thread_inline.h",
+        "public_overrides/pw_thread_backend/thread_native.h",
+    ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+    deps = [
+        "//pw_assert",
+        ":id",
+        "//pw_thread:thread_headers",
+    ],
+    # TODO(pwbug/317): This should depend on FreeRTOS but our third parties
+    # currently do not have Bazel support.
+)
+
+pw_cc_library(
+    name = "thread",
+    srcs = [
+        "thread.cc",
+    ],
+    deps = [
+        "//pw_assert",
+        ":id",
+        ":thread_headers",
+    ],
+    # TODO(pwbug/317): This should depend on FreeRTOS but our third parties
+    # currently do not have Bazel support.
+)
+
+pw_cc_library(
+    name = "dynamic_test_threads",
+    deps = [
+        "//pw_thread:thread_facade",
+        "//pw_thread:test_threads_header",
+        "//pw_chrono:system_clock",
+        "//pw_thread:sleep",
+    ],
+    srcs = [
+        "dynamic_test_threads.cc",
+    ]
+)
+
+pw_cc_test(
+    name = "dynamic_thread_backend_test",
+    deps = [
+        "//pw_thread:thread_facade_test",
+        ":dynamic_test_threads",
+    ]
+)
+
+pw_cc_library(
+    name = "static_test_threads",
+    deps = [
+        "//pw_thread:thread_facade",
+        "//pw_thread:test_threads_header",
+        "//pw_chrono:system_clock",
+        "//pw_thread:sleep",
+    ],
+    srcs = [
+        "static_test_threads.cc",
+    ]
+)
+
+pw_cc_test(
+    name = "static_thread_backend_test",
+    deps = [
+        "//pw_thread:thread_facade_test",
+        ":static_test_threads",
+    ]
+)
+
+pw_cc_library(
+    name = "yield_headers",
+    hdrs = [
+        "public/pw_thread_freertos/yield_inline.h",
+        "public_overrides/pw_thread_backend/yield_inline.h",
+    ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+    # TODO(pwbug/317): This should depend on FreeRTOS but our third parties
+    # currently do not have Bazel support.
+)
+
+pw_cc_library(
+    name = "yield",
+    deps = [
+        ":yield_headers",
+        "//pw_thread:yield_facade",
+    ],
+)
+
diff --git a/pw_thread_freertos/BUILD.gn b/pw_thread_freertos/BUILD.gn
new file mode 100644
index 0000000..7071804
--- /dev/null
+++ b/pw_thread_freertos/BUILD.gn
@@ -0,0 +1,186 @@
+# 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/module_config.gni")
+import("$dir_pw_build/target_types.gni")
+import("$dir_pw_chrono/backend.gni")
+import("$dir_pw_docgen/docs.gni")
+import("$dir_pw_thread/backend.gni")
+import("$dir_pw_unit_test/test.gni")
+
+declare_args() {
+  # The build target that overrides the default configuration options for this
+  # module. This should point to a source set that provides defines through a
+  # public config (which may -include a file or add defines directly).
+  pw_thread_freertos_CONFIG = pw_build_DEFAULT_MODULE_CONFIG
+}
+
+config("public_include_path") {
+  include_dirs = [ "public" ]
+  visibility = [ ":*" ]
+}
+
+config("backend_config") {
+  include_dirs = [ "public_overrides" ]
+  visibility = [ ":*" ]
+}
+
+pw_source_set("config") {
+  public = [ "public/pw_thread_freertos/config.h" ]
+  public_configs = [ ":public_include_path" ]
+  public_deps = [
+    "$dir_pw_third_party/freertos",
+    pw_thread_freertos_CONFIG,
+  ]
+}
+
+# This target provides the backend for pw::thread::Id & pw::this_thread::get_id.
+pw_source_set("id") {
+  public_configs = [
+    ":public_include_path",
+    ":backend_config",
+  ]
+  public_deps = [
+    "$dir_pw_interrupt:context",
+    "$dir_pw_third_party/freertos",
+  ]
+  public = [
+    "public/pw_thread_freertos/id_inline.h",
+    "public/pw_thread_freertos/id_native.h",
+    "public_overrides/pw_thread_backend/id_inline.h",
+    "public_overrides/pw_thread_backend/id_native.h",
+  ]
+  deps = [ "$dir_pw_thread:id.facade" ]
+}
+
+# This target provides the backend for pw::this_thread::sleep_{for,until}.
+pw_source_set("sleep") {
+  public_configs = [
+    ":public_include_path",
+    ":backend_config",
+  ]
+  public = [
+    "public/pw_thread_freertos/sleep_inline.h",
+    "public_overrides/pw_thread_backend/sleep_inline.h",
+  ]
+  public_deps = [ "$dir_pw_chrono:system_clock" ]
+  sources = [ "sleep.cc" ]
+  deps = [
+    "$dir_pw_assert",
+    "$dir_pw_chrono_freertos:system_clock",
+    "$dir_pw_third_party/freertos",
+    "$dir_pw_thread:id",
+    "$dir_pw_thread:sleep.facade",
+  ]
+  assert(pw_chrono_SYSTEM_CLOCK_BACKEND == "" ||
+             pw_chrono_SYSTEM_CLOCK_BACKEND ==
+                 "$dir_pw_chrono_freertos:system_clock",
+         "The FreeRTOS pw::this_thread::sleep_{for,until} backend only works " +
+             "with the FreeRTOS pw::chrono::SystemClock backend " +
+             "(pw_chrono_SYSTEM_CLOCK_BACKEND = " +
+             "\"$dir_pw_chrono_freertos:system_clock\")")
+}
+
+# This target provides the backend for pw::thread::Thread and the headers needed
+# for thread creation.
+pw_source_set("thread") {
+  public_configs = [
+    ":public_include_path",
+    ":backend_config",
+  ]
+  public_deps = [
+    ":config",
+    "$dir_pw_assert",
+    "$dir_pw_third_party/freertos",
+    "$dir_pw_thread:id",
+    "$dir_pw_thread:thread.facade",
+  ]
+  public = [
+    "public/pw_thread_freertos/context.h",
+    "public/pw_thread_freertos/options.h",
+    "public/pw_thread_freertos/thread_inline.h",
+    "public/pw_thread_freertos/thread_native.h",
+    "public_overrides/pw_thread_backend/thread_inline.h",
+    "public_overrides/pw_thread_backend/thread_native.h",
+  ]
+  allow_circular_includes_from = [ "$dir_pw_thread:thread.facade" ]
+  sources = [ "thread.cc" ]
+}
+
+# This target provides the backend for pw::this_thread::yield.
+pw_source_set("yield") {
+  public_configs = [
+    ":public_include_path",
+    ":backend_config",
+  ]
+  public = [
+    "public/pw_thread_freertos/yield_inline.h",
+    "public_overrides/pw_thread_backend/yield_inline.h",
+  ]
+  public_deps = [
+    "$dir_pw_assert",
+    "$dir_pw_third_party/freertos",
+    "$dir_pw_thread:id",
+  ]
+  deps = [ "$dir_pw_thread:yield.facade" ]
+}
+
+pw_test_group("tests") {
+  tests = [
+    ":dynamic_thread_backend_test",
+    ":static_thread_backend_test",
+  ]
+}
+
+pw_source_set("dynamic_test_threads") {
+  public_deps = [ "$dir_pw_thread:test_threads" ]
+  sources = [ "dynamic_test_threads.cc" ]
+  deps = [
+    "$dir_pw_chrono:system_clock",
+    "$dir_pw_thread:sleep",
+    "$dir_pw_thread:thread",
+  ]
+}
+
+pw_test("dynamic_thread_backend_test") {
+  enable_if = pw_thread_THREAD_BACKEND == "$dir_pw_thread_freertos:thread"
+  deps = [
+    ":dynamic_test_threads",
+    "$dir_pw_thread:thread_facade_test",
+  ]
+}
+
+pw_source_set("static_test_threads") {
+  public_deps = [ "$dir_pw_thread:test_threads" ]
+  sources = [ "static_test_threads.cc" ]
+  deps = [
+    "$dir_pw_chrono:system_clock",
+    "$dir_pw_thread:sleep",
+    "$dir_pw_thread:thread",
+  ]
+}
+
+pw_test("static_thread_backend_test") {
+  enable_if = pw_thread_THREAD_BACKEND == "$dir_pw_thread_freertos:thread"
+  deps = [
+    ":static_test_threads",
+    "$dir_pw_thread:thread_facade_test",
+  ]
+}
+
+pw_doc_group("docs") {
+  sources = [ "docs.rst" ]
+}
diff --git a/pw_thread_freertos/docs.rst b/pw_thread_freertos/docs.rst
new file mode 100644
index 0000000..6628718
--- /dev/null
+++ b/pw_thread_freertos/docs.rst
@@ -0,0 +1,8 @@
+.. _module-pw_thread_freertos:
+
+------------------
+pw_thread_freertos
+------------------
+This is a set of backends for pw_thread based on FreeRTOS. It is not ready for
+use, and is under construction.
+
diff --git a/pw_thread_freertos/dynamic_test_threads.cc b/pw_thread_freertos/dynamic_test_threads.cc
new file mode 100644
index 0000000..f596bab
--- /dev/null
+++ b/pw_thread_freertos/dynamic_test_threads.cc
@@ -0,0 +1,48 @@
+// 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.
+
+#include <chrono>
+
+#include "pw_chrono/system_clock.h"
+#include "pw_thread/sleep.h"
+#include "pw_thread/test_threads.h"
+#include "pw_thread_freertos/context.h"
+#include "pw_thread_freertos/options.h"
+
+namespace pw::thread::test {
+
+const Options& TestOptionsThread0() {
+  static constexpr freertos::Options thread_0_options =
+      freertos::Options().set_name("pw::TestThread0");
+  return thread_0_options;
+}
+
+const Options& TestOptionsThread1() {
+  static constexpr freertos::Options thread_1_options =
+      freertos::Options().set_name("pw::TestThread0");
+  return thread_1_options;
+}
+
+// Although there's no risk of dynamic context re-use, there is a risk
+// running out of heap. The way the FreeRTOS kernel works is that dynamic thread
+// allocations are cleaned up during idle. There is no clean way to cleanly
+// sleep until idle, ergo we simply sleep for a long period in the hope that
+// the application will on average not be able to starve the heap if they
+// execute this test over and over again.
+void WaitUntilDetachedThreadsCleanedUp() {
+  this_thread::sleep_for(
+      chrono::SystemClock::for_at_least(std::chrono::milliseconds(50)));
+}
+
+}  // namespace pw::thread::test
diff --git a/pw_thread_freertos/public/pw_thread_freertos/config.h b/pw_thread_freertos/public/pw_thread_freertos/config.h
new file mode 100644
index 0000000..90c8c7a
--- /dev/null
+++ b/pw_thread_freertos/public/pw_thread_freertos/config.h
@@ -0,0 +1,61 @@
+// 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.
+// Configuration macros for the tokenizer module.
+#pragma once
+
+#include "FreeRTOS.h"
+#include "task.h"
+
+// Whether thread joining is enabled. By default this is disabled.
+// When enabled this adds a StaticEventGroup_t & EventGroupHandle_t to
+// every pw::thread::Thread's context.
+#ifndef PW_THREAD_FREERTOS_CONFIG_JOINING_ENABLED
+#define PW_THREAD_FREERTOS_CONFIG_JOINING_ENABLED 0
+#endif  // PW_THREAD_FREERTOS_CONFIG_JOINING_ENABLED
+#define PW_THREAD_JOINING_ENABLED PW_THREAD_FREERTOS_CONFIG_JOINING_ENABLED
+
+// Whether dynamic allocation for thread contexts is enabled. By default this
+// matches the FreeRTOS configuration on whether dynamic allocations are
+// enabled. Note that static contexts _must_ be provided if dynamic allocations
+// are disabled.
+#ifndef PW_THREAD_FREERTOS_CONFIG_DYNAMIC_ALLOCATION_ENABLED
+#if configSUPPORT_DYNAMIC_ALLOCATION == 1
+#define PW_THREAD_FREERTOS_CONFIG_DYNAMIC_ALLOCATION_ENABLED 1
+#else
+#define PW_THREAD_FREERTOS_CONFIG_DYNAMIC_ALLOCATION_ENABLED 0
+#endif  // configSUPPORT_DYNAMIC_ALLOCATION
+#endif  // PW_THREAD_FREERTOS_CONFIG_DYNAMIC_ALLOCATION_ENABLED
+
+// The default stack size in words. By default this uses the minimal FreeRTOS
+// stack size.
+#ifndef PW_THREAD_FREERTOS_CONFIG_DEFAULT_STACK_SIZE_WORDS
+#define PW_THREAD_FREERTOS_CONFIG_DEFAULT_STACK_SIZE_WORDS \
+  configMINIMAL_STACK_SIZE
+#endif  // PW_THREAD_FREERTOS_CONFIG_DEFAULT_STACK_SIZE_WORDS
+
+// The default stack size in words. By default this uses the minimal FreeRTOS
+// priority level above the idle priority.
+#ifndef PW_THREAD_FREERTOS_CONFIG_DEFAULT_PRIORITY
+#define PW_THREAD_FREERTOS_CONFIG_DEFAULT_PRIORITY tskIDLE_PRIORITY + 1
+#endif  // PW_THREAD_FREERTOS_CONFIG_DEFAULT_PRIORITY
+
+namespace pw::thread::freertos::config {
+
+inline constexpr size_t kMinimumStackSizeWords = configMINIMAL_STACK_SIZE;
+inline constexpr size_t kDefaultStackSizeWords =
+    PW_THREAD_FREERTOS_CONFIG_DEFAULT_STACK_SIZE_WORDS;
+inline constexpr UBaseType_t kDefaultPriority =
+    PW_THREAD_FREERTOS_CONFIG_DEFAULT_PRIORITY;
+
+}  // namespace pw::thread::freertos::config
diff --git a/pw_thread_freertos/public/pw_thread_freertos/context.h b/pw_thread_freertos/public/pw_thread_freertos/context.h
new file mode 100644
index 0000000..71bf1d5
--- /dev/null
+++ b/pw_thread_freertos/public/pw_thread_freertos/context.h
@@ -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.
+#pragma once
+
+#include <cstdint>
+#include <span>
+
+#include "FreeRTOS.h"
+#include "pw_thread_freertos/config.h"
+#include "task.h"
+#if PW_THREAD_JOINING_ENABLED
+#include "event_groups.h"
+#endif  // PW_THREAD_JOINING_ENABLED
+
+namespace pw::thread {
+
+class Thread;  // Forward declare Thread which depends on Context.
+
+}  // namespace pw::thread
+
+namespace pw::thread::freertos {
+
+// FreeRTOS may be used for dynamic thread TCB and stack allocation, but
+// because we need some additional context beyond that the concept of a
+// thread's context is split into two halves:
+//
+//   1) Context which just contains the additional Context pw::thread::Thread
+//      requires. This is used for both static and dynamic thread allocations.
+//
+//   2) StaticContext which contains the TCB and a span to the stack which is
+//      used only for static allocations.
+class Context {
+ public:
+  Context() = default;
+  Context(const Context&) = delete;
+  Context& operator=(const Context&) = delete;
+
+ private:
+  friend Thread;
+
+  TaskHandle_t task_handle() const { return task_handle_; }
+  void set_task_handle(const TaskHandle_t task_handle) {
+    task_handle_ = task_handle;
+  }
+
+  using ThreadRoutine = void (*)(void* arg);
+  void set_thread_routine(ThreadRoutine entry, void* arg) {
+    entry_ = entry;
+    arg_ = arg;
+  }
+
+  bool detached() const { return detached_; }
+  void set_detached(bool value = true) { detached_ = value; }
+
+  bool thread_done() const { return thread_done_; }
+  void set_thread_done(bool value = true) { thread_done_ = value; }
+
+#if PW_THREAD_FREERTOS_CONFIG_DYNAMIC_ALLOCATION_ENABLED
+  bool dynamically_allocated() const { return dynamically_allocated_; }
+  void set_dynamically_allocated() { dynamically_allocated_ = true; }
+#endif  // PW_THREAD_FREERTOS_CONFIG_DYNAMIC_ALLOCATION_ENABLED
+
+#if PW_THREAD_JOINING_ENABLED
+  StaticEventGroup_t& join_event_group() { return event_group_; }
+#endif  // PW_THREAD_JOINING_ENABLED
+
+  static void RunThread(void* void_context_ptr);
+  static void TerminateThread(Context& context);
+
+  TaskHandle_t task_handle_ = nullptr;
+  ThreadRoutine entry_ = nullptr;
+  void* arg_ = nullptr;
+#if PW_THREAD_JOINING_ENABLED
+  // Note that the FreeRTOS life cycle of this event group is managed together
+  // with the task life cycle, not this object's life cycle.
+  StaticEventGroup_t event_group_;
+#endif  // PW_THREAD_JOINING_ENABLED
+  bool detached_ = false;
+  bool dynamically_allocated_ = false;
+  bool thread_done_ = false;
+};
+
+// Static thread context allocation including the TCB, an event group for
+// joining if enabled, and an external statically allocated stack.
+//
+// Example usage:
+//
+//   std::array<StackType_t, 42> example_thread_stack;
+//   pw::thread::freertos::Context example_thread_context(example_thread_stack);
+//   void StartExampleThread() {
+//      pw::thread::Thread(
+//        pw::thread::freertos::Options()
+//            .set_name("static_example_thread")
+//            .set_priority(kFooPriority)
+//            .set_static_context(example_thread_context),
+//        example_thread_function).detach();
+//   }
+class StaticContext : public Context {
+ public:
+  explicit StaticContext(std::span<StackType_t> stack_span)
+      : tcb_{}, stack_span_(stack_span) {}
+
+ private:
+  friend Thread;
+
+  StaticTask_t& tcb() { return tcb_; }
+  std::span<StackType_t> stack() { return stack_span_; }
+
+  StaticTask_t tcb_;
+  std::span<StackType_t> stack_span_;
+};
+
+// Static thread context allocation including the stack along with the Context.
+//
+// Example usage:
+//
+//   pw::thread::freertos::ContextWithStack<42> example_thread_context;
+//   void StartExampleThread() {
+//      pw::thread::Thread(
+//        pw::thread::freertos::Options()
+//            .set_name("static_example_thread")
+//            .set_priority(kFooPriority)
+//            .set_static_context(example_thread_context),
+//        example_thread_function).detach();
+//   }
+template <size_t kStackSizeWords = config::kDefaultStackSizeWords>
+class StaticContextWithStack final : public StaticContext {
+ public:
+  constexpr StaticContextWithStack() : StaticContext(stack_storage_) {
+    static_assert(kStackSizeWords >= config::kMinimumStackSizeWords);
+  }
+
+ private:
+  std::array<StackType_t, kStackSizeWords> stack_storage_;
+};
+
+}  // namespace pw::thread::freertos
diff --git a/pw_thread_freertos/public/pw_thread_freertos/id_inline.h b/pw_thread_freertos/public/pw_thread_freertos/id_inline.h
new file mode 100644
index 0000000..0c152e6
--- /dev/null
+++ b/pw_thread_freertos/public/pw_thread_freertos/id_inline.h
@@ -0,0 +1,34 @@
+// 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.
+#pragma once
+
+#include "FreeRTOS.h"
+#include "pw_assert/light.h"
+#include "pw_interrupt/context.h"
+#include "pw_thread/id.h"
+#include "task.h"
+
+namespace pw::this_thread {
+
+inline thread::Id get_id() noexcept {
+  PW_DASSERT(!interrupt::InInterruptContext());
+#if INCLUDE_xTaskGetSchedulerState == 1 or configUSE_TIMERS == 1
+  if (xTaskGetSchedulerState() == taskSCHEDULER_NOT_STARTED) {
+    return thread::Id();
+  }
+#endif  // xTaskGetSchedulerState available.
+  return thread::Id(xTaskGetCurrentTaskHandle());
+}
+
+}  // namespace pw::this_thread
diff --git a/pw_thread_freertos/public/pw_thread_freertos/id_native.h b/pw_thread_freertos/public/pw_thread_freertos/id_native.h
new file mode 100644
index 0000000..f27e9d0
--- /dev/null
+++ b/pw_thread_freertos/public/pw_thread_freertos/id_native.h
@@ -0,0 +1,53 @@
+// 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.
+#pragma once
+
+#include "FreeRTOS.h"
+#include "task.h"
+
+namespace pw::thread::backend {
+
+// Instead of using a pw::thread::freertos specific identifier, the FreeRTOS
+// task handle is used as this means pw::this_thread::id works correctly on
+// threads started with the native FreeRTOS APIs as well as those started
+// using the pw::thread APIs.
+class NativeId {
+ public:
+  constexpr NativeId(TaskHandle_t task_handle = nullptr)
+      : task_handle_(task_handle) {}
+
+  constexpr bool operator==(NativeId other) const {
+    return task_handle_ == other.task_handle_;
+  }
+  constexpr bool operator!=(NativeId other) const {
+    return task_handle_ != other.task_handle_;
+  }
+  constexpr bool operator<(NativeId other) const {
+    return task_handle_ < other.task_handle_;
+  }
+  constexpr bool operator<=(NativeId other) const {
+    return task_handle_ <= other.task_handle_;
+  }
+  constexpr bool operator>(NativeId other) const {
+    return task_handle_ > other.task_handle_;
+  }
+  constexpr bool operator>=(NativeId other) const {
+    return task_handle_ >= other.task_handle_;
+  }
+
+ private:
+  TaskHandle_t task_handle_;
+};
+
+}  // namespace pw::thread::backend
diff --git a/pw_thread_freertos/public/pw_thread_freertos/options.h b/pw_thread_freertos/public/pw_thread_freertos/options.h
new file mode 100644
index 0000000..921faa4
--- /dev/null
+++ b/pw_thread_freertos/public/pw_thread_freertos/options.h
@@ -0,0 +1,101 @@
+// 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.
+#pragma once
+
+#include "FreeRTOS.h"
+#include "pw_assert/assert.h"
+#include "pw_thread/thread.h"
+#include "pw_thread_freertos/config.h"
+#include "pw_thread_freertos/context.h"
+#include "task.h"
+
+namespace pw::thread::freertos {
+
+// pw::thread::Options for FreeRTOS.
+//
+// Example usage:
+//
+//   // Uses the default stack size and priority, but specifies a custom name.
+//   pw::thread::Thread example_thread(
+//     pw::thread::freertos::Options()
+//         .set_name("example_thread"),
+//     example_thread_function);
+//
+//   // Provides the name, priority, and pre-allocated context.
+//   pw::thread::Thread static_example_thread(
+//     pw::thread::freertos::Options()
+//         .set_name("static_example_thread")
+//         .set_priority(kFooPriority)
+//         .set_static_context(static_example_thread_context),
+//     example_thread_function);
+//
+class Options : public thread::Options {
+ public:
+  constexpr Options() = default;
+  constexpr Options(const Options&) = default;
+  constexpr Options(Options&& other) = default;
+
+  // Sets the name for the FreeRTOS task, note that this will be truncated
+  // based on configMAX_TASK_NAME_LEN.
+  constexpr Options set_name(const char* name) {
+    name_ = name;
+    return *this;
+  }
+
+  // Sets the priority for the FreeRTOS task, see FreeRTOS xTaskCreate for more
+  // detail.
+  constexpr Options set_priority(UBaseType_t priority) {
+    priority_ = priority;
+    return *this;
+  }
+
+#if PW_THREAD_FREERTOS_CONFIG_DYNAMIC_ALLOCATION_ENABLED
+  // Set the stack size for dynamic thread allocations, see FreeRTOS xTaskCreate
+  // for more detail.
+  constexpr Options set_stack_size(size_t size_words) {
+    PW_DASSERT(size_words >= config::kMinimumStackSizeWords);
+    stack_size_words_ = size_words;
+    return *this;
+  }
+#endif  // PW_THREAD_FREERTOS_CONFIG_DYNAMIC_ALLOCATION_ENABLED
+
+  // Set the pre-allocated context (all memory needed to run a thread), see the
+  // pw::thread::freertos::StaticContext for more detail.
+  constexpr Options set_static_context(StaticContext& context) {
+    context_ = &context;
+    return *this;
+  }
+
+ private:
+  friend thread::Thread;
+  // FreeRTOS requires a valid name when asserts are enabled,
+  // configMAX_TASK_NAME_LEN may be as small as one character.
+  static constexpr char kDefaultName[] = "pw::Thread";
+
+  const char* name() const { return name_; }
+  UBaseType_t priority() const { return priority_; }
+#if PW_THREAD_FREERTOS_CONFIG_DYNAMIC_ALLOCATION_ENABLED
+  size_t stack_size_words() const { return stack_size_words_; }
+#endif  // PW_THREAD_FREERTOS_CONFIG_DYNAMIC_ALLOCATION_ENABLED
+  StaticContext* static_context() const { return context_; }
+
+  const char* name_ = kDefaultName;
+  UBaseType_t priority_ = config::kDefaultPriority;
+#if PW_THREAD_FREERTOS_CONFIG_DYNAMIC_ALLOCATION_ENABLED
+  size_t stack_size_words_ = config::kDefaultStackSizeWords;
+#endif  // PW_THREAD_FREERTOS_CONFIG_DYNAMIC_ALLOCATION_ENABLED
+  StaticContext* context_ = nullptr;
+};
+
+}  // namespace pw::thread::freertos
diff --git a/pw_thread_freertos/public/pw_thread_freertos/sleep_inline.h b/pw_thread_freertos/public/pw_thread_freertos/sleep_inline.h
new file mode 100644
index 0000000..f23fdc9
--- /dev/null
+++ b/pw_thread_freertos/public/pw_thread_freertos/sleep_inline.h
@@ -0,0 +1,27 @@
+// 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.
+#pragma once
+
+#include "pw_chrono/system_clock.h"
+
+namespace pw::this_thread {
+
+// TODO(ewout): Consider optional vTaskDelayUntil support to minimize slop.
+inline void sleep_until(chrono::SystemClock::time_point until_at_least) {
+  // Note that if this deadline is in the future, it will get rounded up by
+  // one whole tick due to how sleep_for is implemented.
+  return sleep_for(until_at_least - chrono::SystemClock::now());
+}
+
+}  // namespace pw::this_thread
diff --git a/pw_thread_freertos/public/pw_thread_freertos/thread_inline.h b/pw_thread_freertos/public/pw_thread_freertos/thread_inline.h
new file mode 100644
index 0000000..e0d38b3
--- /dev/null
+++ b/pw_thread_freertos/public/pw_thread_freertos/thread_inline.h
@@ -0,0 +1,52 @@
+// 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.
+#pragma once
+
+#include <algorithm>
+
+#include "FreeRTOS.h"
+#include "pw_assert/light.h"
+#include "pw_thread/id.h"
+#include "pw_thread_freertos/config.h"
+#include "pw_thread_freertos/options.h"
+#include "task.h"
+
+namespace pw::thread {
+
+inline Thread::Thread() : native_type_(nullptr) {}
+
+inline Thread& Thread::operator=(Thread&& other) {
+  native_type_ = other.native_type_;
+  other.native_type_ = nullptr;
+  return *this;
+}
+
+inline Thread::~Thread() { PW_DASSERT(native_type_ == nullptr); }
+
+inline Id Thread::get_id() const {
+  if (native_type_ == nullptr) {
+    return Id(nullptr);
+  }
+  return Id(native_type_->task_handle());
+}
+
+inline void Thread::swap(Thread& other) {
+  std::swap(native_type_, other.native_type_);
+}
+
+inline Thread::native_handle_type Thread::native_handle() {
+  return native_type_;
+}
+
+}  // namespace pw::thread
diff --git a/pw_thread_freertos/public/pw_thread_freertos/thread_native.h b/pw_thread_freertos/public/pw_thread_freertos/thread_native.h
new file mode 100644
index 0000000..447c57c
--- /dev/null
+++ b/pw_thread_freertos/public/pw_thread_freertos/thread_native.h
@@ -0,0 +1,26 @@
+// 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.
+#pragma once
+
+#include "pw_thread_freertos/context.h"
+
+namespace pw::thread::backend {
+
+// The native thread is a pointer to a thread's context.
+using NativeThread = pw::thread::freertos::Context*;
+
+// The native thread handle is the same as the NativeThread.
+using NativeThreadHandle = pw::thread::freertos::Context*;
+
+}  // namespace pw::thread::backend
diff --git a/pw_thread_freertos/public/pw_thread_freertos/yield_inline.h b/pw_thread_freertos/public/pw_thread_freertos/yield_inline.h
new file mode 100644
index 0000000..e428c38
--- /dev/null
+++ b/pw_thread_freertos/public/pw_thread_freertos/yield_inline.h
@@ -0,0 +1,30 @@
+// 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.
+#pragma once
+
+#include <algorithm>
+
+#include "FreeRTOS.h"
+#include "pw_assert/light.h"
+#include "pw_thread/id.h"
+#include "task.h"
+
+namespace pw::this_thread {
+
+inline void yield() noexcept {
+  PW_DASSERT(get_id() != thread::Id());
+  taskYIELD();
+}
+
+}  // namespace pw::this_thread
diff --git a/pw_thread_freertos/public_overrides/pw_thread_backend/id_inline.h b/pw_thread_freertos/public_overrides/pw_thread_backend/id_inline.h
new file mode 100644
index 0000000..dcaf699
--- /dev/null
+++ b/pw_thread_freertos/public_overrides/pw_thread_backend/id_inline.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_thread_freertos/id_inline.h"
diff --git a/pw_thread_freertos/public_overrides/pw_thread_backend/id_native.h b/pw_thread_freertos/public_overrides/pw_thread_backend/id_native.h
new file mode 100644
index 0000000..245d29b
--- /dev/null
+++ b/pw_thread_freertos/public_overrides/pw_thread_backend/id_native.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_thread_freertos/id_native.h"
diff --git a/pw_thread_freertos/public_overrides/pw_thread_backend/sleep_inline.h b/pw_thread_freertos/public_overrides/pw_thread_backend/sleep_inline.h
new file mode 100644
index 0000000..4ccbe1d
--- /dev/null
+++ b/pw_thread_freertos/public_overrides/pw_thread_backend/sleep_inline.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_thread_freertos/sleep_inline.h"
diff --git a/pw_thread_freertos/public_overrides/pw_thread_backend/thread_inline.h b/pw_thread_freertos/public_overrides/pw_thread_backend/thread_inline.h
new file mode 100644
index 0000000..7305e91
--- /dev/null
+++ b/pw_thread_freertos/public_overrides/pw_thread_backend/thread_inline.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_thread_freertos/thread_inline.h"
diff --git a/pw_thread_freertos/public_overrides/pw_thread_backend/thread_native.h b/pw_thread_freertos/public_overrides/pw_thread_backend/thread_native.h
new file mode 100644
index 0000000..478f791
--- /dev/null
+++ b/pw_thread_freertos/public_overrides/pw_thread_backend/thread_native.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_thread_freertos/thread_native.h"
diff --git a/pw_thread_freertos/public_overrides/pw_thread_backend/yield_inline.h b/pw_thread_freertos/public_overrides/pw_thread_backend/yield_inline.h
new file mode 100644
index 0000000..4ad04ab
--- /dev/null
+++ b/pw_thread_freertos/public_overrides/pw_thread_backend/yield_inline.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_thread_freertos/yield_inline.h"
diff --git a/pw_thread_freertos/sleep.cc b/pw_thread_freertos/sleep.cc
new file mode 100644
index 0000000..0decfa0
--- /dev/null
+++ b/pw_thread_freertos/sleep.cc
@@ -0,0 +1,50 @@
+// 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.
+
+#include "pw_thread/sleep.h"
+
+#include <algorithm>
+
+#include "FreeRTOS.h"
+#include "pw_assert/assert.h"
+#include "pw_chrono/system_clock.h"
+#include "pw_chrono_freertos/system_clock_constants.h"
+#include "pw_thread/id.h"
+#include "task.h"
+
+using pw::chrono::SystemClock;
+
+namespace pw::this_thread {
+
+void sleep_for(SystemClock::duration for_at_least) {
+  PW_DCHECK(get_id() != thread::Id());
+
+  // Yield for negative and zero length durations.
+  if (for_at_least <= SystemClock::duration::zero()) {
+    taskYIELD();
+    return;
+  }
+
+  // On a tick based kernel we cannot tell how far along we are on the current
+  // tick, ergo we add one whole tick to the final duration.
+  constexpr SystemClock::duration kMaxTimeoutMinusOne =
+      pw::chrono::freertos::kMaxTimeout - SystemClock::duration(1);
+  while (for_at_least > kMaxTimeoutMinusOne) {
+    vTaskDelay(static_cast<TickType_t>(kMaxTimeoutMinusOne.count()));
+    for_at_least -= kMaxTimeoutMinusOne;
+  }
+  vTaskDelay(static_cast<TickType_t>(for_at_least.count() + 1));
+}
+
+}  // namespace pw::this_thread
diff --git a/pw_thread_freertos/static_test_threads.cc b/pw_thread_freertos/static_test_threads.cc
new file mode 100644
index 0000000..fc1884d
--- /dev/null
+++ b/pw_thread_freertos/static_test_threads.cc
@@ -0,0 +1,54 @@
+// 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.
+
+#include <chrono>
+
+#include "pw_chrono/system_clock.h"
+#include "pw_thread/sleep.h"
+#include "pw_thread/test_threads.h"
+#include "pw_thread_freertos/context.h"
+#include "pw_thread_freertos/options.h"
+
+namespace pw::thread::test {
+namespace {
+freertos::StaticContextWithStack thread_0_context;
+freertos::StaticContextWithStack thread_1_context;
+}  // namespace
+
+const Options& TestOptionsThread0() {
+  static constexpr freertos::Options thread_0_options =
+      freertos::Options()
+          .set_name("pw::TestThread0")
+          .set_static_context(thread_0_context);
+  return thread_0_options;
+}
+
+const Options& TestOptionsThread1() {
+  static constexpr freertos::Options thread_1_options =
+      freertos::Options()
+          .set_name("pw::TestThread1")
+          .set_static_context(thread_1_context);
+  return thread_1_options;
+}
+
+void WaitUntilDetachedThreadsCleanedUp() {
+  // One may be tempted to use the context's task_handle to wait until it's a
+  // nullptr. However, there's still a race condition that the task has not
+  // finished the execution of vTaskDelete. In addition during this time the
+  // the task_handle has been cleared meaning we cannot call vTaskDelete.
+  this_thread::sleep_for(
+      chrono::SystemClock::for_at_least(std::chrono::milliseconds(50)));
+}
+
+}  // namespace pw::thread::test
diff --git a/pw_thread_freertos/thread.cc b/pw_thread_freertos/thread.cc
new file mode 100644
index 0000000..b77df78
--- /dev/null
+++ b/pw_thread_freertos/thread.cc
@@ -0,0 +1,234 @@
+// 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.
+#include "pw_thread/thread.h"
+
+#include "FreeRTOS.h"
+#include "pw_assert/assert.h"
+#include "pw_preprocessor/compiler.h"
+#include "pw_thread/id.h"
+#include "pw_thread_freertos/config.h"
+#include "pw_thread_freertos/context.h"
+#include "pw_thread_freertos/options.h"
+#include "task.h"
+
+using pw::thread::freertos::Context;
+
+namespace pw::thread {
+namespace {
+#if PW_THREAD_JOINING_ENABLED
+constexpr EventBits_t kThreadDoneBit = 1 << 0;
+#endif  // PW_THREAD_JOINING_ENABLED
+}  // namespace
+
+void Context::RunThread(void* void_context_ptr) {
+  Context& context = *static_cast<Context*>(void_context_ptr);
+  context.entry_(context.arg_);
+
+  // Use a task only critical section to guard against join() and detach().
+  vTaskSuspendAll();
+  if (context.detached()) {
+    // There is no threadsafe way to re-use detached threads, as there's no way
+    // to signal the vTaskDelete success. Joining MUST be used for this.
+    // However to enable unit test coverage we go ahead and clear this.
+    context.set_task_handle(nullptr);
+
+#if PW_THREAD_JOINING_ENABLED
+    // Just in case someone abused our API, ensure their use of the event group
+    // is properly handled by the kernel regardless.
+    vEventGroupDelete(&context.join_event_group());
+#endif  // PW_THREAD_JOINING_ENABLED
+
+#if PW_THREAD_FREERTOS_CONFIG_DYNAMIC_ALLOCATION_ENABLED
+    // The thread was detached before the task finished, free any allocations
+    // it ran on.
+    if (context.dynamically_allocated()) {
+      delete &context;
+    }
+#endif  // PW_THREAD_FREERTOS_CONFIG_DYNAMIC_ALLOCATION_ENABLED
+
+    // Re-enable the scheduler before we delete this execution.
+    xTaskResumeAll();
+    vTaskDelete(nullptr);
+    PW_UNREACHABLE;
+  }
+
+  // Otherwise the task finished before the thread was detached or joined, defer
+  // cleanup to Thread's join() or detach().
+  context.set_thread_done();
+  xTaskResumeAll();
+
+#if PW_THREAD_JOINING_ENABLED
+  xEventGroupSetBits(&context.join_event_group(), kThreadDoneBit);
+#endif  // PW_THREAD_JOINING_ENABLED
+
+  while (true) {
+#if INCLUDE_vTaskSuspend == 1
+    // Use indefinite suspension when available.
+    vTaskSuspend(nullptr);
+#else
+    vTaskDelay(portMAX_DELAY);
+#endif  // INCLUDE_vTaskSuspend == 1
+  }
+  PW_UNREACHABLE;
+}
+
+void Context::TerminateThread(Context& context) {
+  // Stop the other task first.
+  PW_DCHECK_NOTNULL(context.task_handle(), "We shall not delete ourselves!");
+  vTaskDelete(context.task_handle());
+
+  // Mark the context as unused for potential later re-use.
+  context.set_task_handle(nullptr);
+
+#if PW_THREAD_JOINING_ENABLED
+  // Just in case someone abused our API, ensure their use of the event group is
+  // properly handled by the kernel regardless.
+  vEventGroupDelete(&context.join_event_group());
+#endif  // PW_THREAD_JOINING_ENABLED
+
+#if PW_THREAD_FREERTOS_CONFIG_DYNAMIC_ALLOCATION_ENABLED
+  // Then free any allocations it ran on.
+  if (context.dynamically_allocated()) {
+    delete &context;
+  }
+#endif  // PW_THREAD_FREERTOS_CONFIG_DYNAMIC_ALLOCATION_ENABLED
+}
+
+Thread::Thread(const thread::Options& facade_options,
+               ThreadRoutine entry,
+               void* arg)
+    : native_type_(nullptr) {
+  // Cast the generic facade options to the backend specific option of which
+  // only one type can exist at compile time.
+  auto options = static_cast<const freertos::Options&>(facade_options);
+  if (options.static_context() != nullptr) {
+    // Use the statically allocated context.
+    native_type_ = options.static_context();
+    // Can't use a context more than once.
+    PW_DCHECK_PTR_EQ(native_type_->task_handle(), nullptr);
+    // Reset the state of the static context in case it was re-used.
+    native_type_->set_detached(false);
+    native_type_->set_thread_done(false);
+#if PW_THREAD_JOINING_ENABLED
+    const EventGroupHandle_t event_group_handle =
+        xEventGroupCreateStatic(&native_type_->join_event_group());
+    PW_DCHECK_PTR_EQ(event_group_handle,
+                     &native_type_->join_event_group(),
+                     "Failed to create the joining event group");
+#endif  // PW_THREAD_JOINING_ENABLED
+
+    // In order to support functions which return and joining, a delegate is
+    // deep copied into the context with a small wrapping function to actually
+    // invoke the task with its arg.
+    native_type_->set_thread_routine(entry, arg);
+    const TaskHandle_t task_handle =
+        xTaskCreateStatic(Context::RunThread,
+                          options.name(),
+                          options.static_context()->stack().size(),
+                          native_type_,
+                          options.priority(),
+                          options.static_context()->stack().data(),
+                          &options.static_context()->tcb());
+    PW_CHECK_NOTNULL(task_handle);  // Ensure it succeeded.
+    native_type_->set_task_handle(task_handle);
+  } else {
+#if !PW_THREAD_FREERTOS_CONFIG_DYNAMIC_ALLOCATION_ENABLED
+    PW_CRASH(
+        "dynamic thread allocations are not enabled and no static_context "
+        "was provided");
+#else  // PW_THREAD_FREERTOS_CONFIG_DYNAMIC_ALLOCATION_ENABLED
+    // Dynamically allocate the context and the task.
+    native_type_ = new pw::thread::freertos::Context();
+    native_type_->set_dynamically_allocated();
+#if PW_THREAD_JOINING_ENABLED
+    const EventGroupHandle_t event_group_handle =
+        xEventGroupCreateStatic(&native_type_->join_event_group());
+    PW_DCHECK_PTR_EQ(event_group_handle,
+                     &native_type_->join_event_group(),
+                     "Failed to create the joining event group");
+#endif  // PW_THREAD_JOINING_ENABLED
+
+    // In order to support functions which return and joining, a delegate is
+    // deep copied into the context with a small wrapping function to actually
+    // invoke the task with its arg.
+    native_type_->set_thread_routine(entry, arg);
+    TaskHandle_t task_handle;
+    const BaseType_t result = xTaskCreate(Context::RunThread,
+                                          options.name(),
+                                          options.stack_size_words(),
+                                          native_type_,
+                                          options.priority(),
+                                          &task_handle);
+
+    // Ensure it succeeded.
+    PW_CHECK_UINT_EQ(result, pdPASS);
+    native_type_->set_task_handle(task_handle);
+#endif  // !PW_THREAD_FREERTOS_CONFIG_DYNAMIC_ALLOCATION_ENABLED
+  }
+}
+
+void Thread::detach() {
+  PW_CHECK(joinable());
+
+#if INCLUDE_vTaskSuspend == 1
+  // No need to suspend extra tasks.
+  vTaskSuspend(native_type_->task_handle());
+#else
+  vTaskSuspendAll();
+#endif  // INCLUDE_vTaskSuspend == 1
+  native_type_->set_detached();
+  const bool thread_done = native_type_->thread_done();
+#if INCLUDE_vTaskSuspend == 1
+  // No need to suspend extra tasks.
+  vTaskResume(native_type_->task_handle());
+#else
+  vTaskResumeAll();
+#endif  // INCLUDE_vTaskSuspend == 1
+
+  if (thread_done) {
+    // The task finished (hit end of Context::RunThread) before we invoked
+    // detach, clean up the thread.
+    Context::TerminateThread(*native_type_);
+  } else {
+    // We're detaching before the task finished, defer cleanup to the task at
+    // the end of Context::RunThread.
+  }
+
+  // Update to no longer represent a thread of execution.
+  native_type_ = nullptr;
+}
+
+#if PW_THREAD_JOINING_ENABLED
+void Thread::join() {
+  PW_CHECK(joinable());
+  PW_CHECK(this_thread::get_id() != get_id());
+
+  // Wait indefinitely until kThreadDoneBit is set.
+  while (xEventGroupWaitBits(&native_type_->join_event_group(),
+                             kThreadDoneBit,
+                             pdTRUE,   // Clear the bits.
+                             pdFALSE,  // Any bits is fine, N/A.
+                             portMAX_DELAY) != kThreadDoneBit) {
+  }
+
+  // No need for a critical section here as the thread at this point is
+  // waiting to be terminated.
+  Context::TerminateThread(*native_type_);
+
+  // Update to no longer represent a thread of execution.
+  native_type_ = nullptr;
+}
+#endif  // PW_THREAD_JOINING_ENABLED
+
+}  // namespace pw::thread
diff --git a/pw_thread_stl/BUILD b/pw_thread_stl/BUILD
new file mode 100644
index 0000000..e7624a6
--- /dev/null
+++ b/pw_thread_stl/BUILD
@@ -0,0 +1,131 @@
+# 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.
+
+load(
+    "//pw_build:pigweed.bzl",
+    "pw_cc_library",
+    "pw_cc_test",
+)
+
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])  # Apache License 2.0
+
+pw_cc_library(
+    name = "id_headers",
+    hdrs = [
+        "public/pw_thread_stl/id_inline.h",
+        "public/pw_thread_stl/id_native.h",
+        "public_overrides/pw_thread_backend/id_inline.h",
+        "public_overrides/pw_thread_backend/id_native.h",
+    ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+)
+
+pw_cc_library(
+    name = "id",
+    deps = [
+        ":id_headers",
+        "//pw_thread:id_facade",
+    ],
+)
+
+pw_cc_library(
+    name = "sleep_headers",
+    hdrs = [
+        "public/pw_thread_stl/sleep_inline.h",
+        "public_overrides/pw_thread_backend/sleep_inline.h",
+    ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+    deps = [
+        "//pw_chrono:system_clock",
+    ],
+)
+
+pw_cc_library(
+    name = "sleep",
+    deps = [
+        ":sleep_headers",
+        "//pw_chrono:system_clock",
+        "//pw_thread:sleep_facade",
+    ],
+)
+
+pw_cc_library(
+    name = "thread_headers",
+    hdrs = [
+        "public/pw_thread_stl/options.h",
+        "public/pw_thread_stl/thread_inline.h",
+        "public/pw_thread_stl/thread_native.h",
+        "public_overrides/pw_thread_backend/thread_inline.h",
+        "public_overrides/pw_thread_backend/thread_native.h",
+    ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+)
+
+pw_cc_library(
+    name = "thread",
+    deps = [
+        ":thread_headers",
+        "//pw_thread:thread_facade",
+    ],
+)
+
+pw_cc_library(
+    name = "test_threads",
+    deps = [
+        "//pw_thread:thread_facade",
+        "//pw_thread:test_threads_header",
+    ],
+    srcs = [
+        "test_threads.cc",
+    ]
+)
+
+pw_cc_test(
+    name = "thread_backend_test",
+    deps = [
+        "//pw_thread:thread_facade_test",
+        ":test_threads",
+    ]
+)
+
+pw_cc_library(
+    name = "yield_headers",
+    hdrs = [
+        "public/pw_thread_stl/yield_inline.h",
+        "public_overrides/pw_thread_backend/yield_inline.h",
+    ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+)
+
+pw_cc_library(
+    name = "yield",
+    deps = [
+        ":yield_headers",
+        "//pw_thread:yield_facade",
+    ],
+)
diff --git a/pw_thread_stl/BUILD.gn b/pw_thread_stl/BUILD.gn
new file mode 100644
index 0000000..37ccf9b
--- /dev/null
+++ b/pw_thread_stl/BUILD.gn
@@ -0,0 +1,122 @@
+# 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/target_types.gni")
+import("$dir_pw_chrono/backend.gni")
+import("$dir_pw_docgen/docs.gni")
+import("$dir_pw_thread/backend.gni")
+import("$dir_pw_unit_test/test.gni")
+
+config("public_include_path") {
+  include_dirs = [ "public" ]
+  visibility = [ ":*" ]
+}
+
+config("backend_config") {
+  include_dirs = [ "public_overrides" ]
+  visibility = [ ":*" ]
+}
+
+# This target provides the backend for pw::thread::Id & pw::this_thread::get_id.
+pw_source_set("id") {
+  public_configs = [
+    ":public_include_path",
+    ":backend_config",
+  ]
+  public = [
+    "public/pw_thread_stl/id_inline.h",
+    "public/pw_thread_stl/id_native.h",
+    "public_overrides/pw_thread_backend/id_inline.h",
+    "public_overrides/pw_thread_backend/id_native.h",
+  ]
+  deps = [ "$dir_pw_thread:id.facade" ]
+}
+
+# This target provides the backend for pw::thread::Thread with joining
+# joining capability.
+pw_source_set("thread") {
+  public_configs = [
+    ":public_include_path",
+    ":backend_config",
+  ]
+  public = [
+    "public/pw_thread_stl/options.h",
+    "public/pw_thread_stl/thread_inline.h",
+    "public/pw_thread_stl/thread_native.h",
+    "public_overrides/pw_thread_backend/thread_inline.h",
+    "public_overrides/pw_thread_backend/thread_native.h",
+  ]
+  allow_circular_includes_from = [ "$dir_pw_thread:thread.facade" ]
+  deps = [ "$dir_pw_thread:thread.facade" ]
+}
+
+# This target provides the backend for pw::this_thread::sleep_{for,until}.
+pw_source_set("sleep") {
+  public_configs = [
+    ":public_include_path",
+    ":backend_config",
+  ]
+  public = [
+    "public/pw_thread_stl/sleep_inline.h",
+    "public_overrides/pw_thread_backend/sleep_inline.h",
+  ]
+  deps = [
+    "$dir_pw_chrono:system_clock",
+    "$dir_pw_thread:sleep.facade",
+  ]
+  assert(
+      pw_chrono_SYSTEM_CLOCK_BACKEND == "" ||
+          pw_chrono_SYSTEM_CLOCK_BACKEND == "$dir_pw_chrono_stl:system_clock",
+      "The STL pw::this_thread::sleep_{for,until} backend only works with " +
+          "the STL pw::chrono::SystemClock backend " +
+          "(pw_chrono_SYSTEM_CLOCK_BACKEND = " +
+          "\"$dir_pw_chrono_stl:system_clock\")")
+}
+
+# This target provides the backend for pw::this_thread::yield.
+pw_source_set("yield") {
+  public_configs = [
+    ":public_include_path",
+    ":backend_config",
+  ]
+  public = [
+    "public/pw_thread_stl/yield_inline.h",
+    "public_overrides/pw_thread_backend/yield_inline.h",
+  ]
+  deps = [ "$dir_pw_thread:yield.facade" ]
+}
+
+pw_test_group("tests") {
+  tests = [ ":thread_backend_test" ]
+}
+
+pw_source_set("test_threads") {
+  public_deps = [ "$dir_pw_thread:test_threads" ]
+  sources = [ "test_threads.cc" ]
+  deps = [ "$dir_pw_thread:thread" ]
+}
+
+pw_test("thread_backend_test") {
+  enable_if = pw_thread_THREAD_BACKEND == "$dir_pw_thread_stl:thread"
+  deps = [
+    ":test_threads",
+    "$dir_pw_thread:thread_facade_test",
+  ]
+}
+
+pw_doc_group("docs") {
+  sources = [ "docs.rst" ]
+}
diff --git a/pw_thread_stl/docs.rst b/pw_thread_stl/docs.rst
new file mode 100644
index 0000000..1169020
--- /dev/null
+++ b/pw_thread_stl/docs.rst
@@ -0,0 +1,8 @@
+.. _module-pw_thread_stl:
+
+-------------
+pw_thread_stl
+-------------
+This is a set of backends for pw_thread based on the C++ STL. It is not ready
+for use, and is under construction.
+
diff --git a/pw_thread_stl/public/pw_thread_stl/id_inline.h b/pw_thread_stl/public/pw_thread_stl/id_inline.h
new file mode 100644
index 0000000..2d36a73
--- /dev/null
+++ b/pw_thread_stl/public/pw_thread_stl/id_inline.h
@@ -0,0 +1,24 @@
+// 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.
+#pragma once
+
+#include <thread>
+
+#include "pw_thread/id.h"
+
+namespace pw::this_thread {
+
+inline thread::Id get_id() noexcept { return std::this_thread::get_id(); }
+
+}  // namespace pw::this_thread
diff --git a/pw_thread_stl/public/pw_thread_stl/id_native.h b/pw_thread_stl/public/pw_thread_stl/id_native.h
new file mode 100644
index 0000000..26a213c
--- /dev/null
+++ b/pw_thread_stl/public/pw_thread_stl/id_native.h
@@ -0,0 +1,22 @@
+// 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.
+#pragma once
+
+#include <thread>
+
+namespace pw::thread::backend {
+
+using NativeId = std::thread::id;
+
+}  // namespace pw::thread::backend
diff --git a/pw_thread_stl/public/pw_thread_stl/options.h b/pw_thread_stl/public/pw_thread_stl/options.h
new file mode 100644
index 0000000..26f5bf5
--- /dev/null
+++ b/pw_thread_stl/public/pw_thread_stl/options.h
@@ -0,0 +1,26 @@
+// 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.
+#pragma once
+
+#include "pw_thread/thread.h"
+
+namespace pw::thread::stl {
+
+// Unfortunately std::thread:attributes was not accepted into the C++ standard.
+// Instead, users are expected to start the thread and after dynamically adjust
+// the thread's attributes using std::thread::native_handle based on the native
+// threading APIs.
+class Options : public thread::Options {};
+
+}  // namespace pw::thread::stl
diff --git a/pw_thread_stl/public/pw_thread_stl/sleep_inline.h b/pw_thread_stl/public/pw_thread_stl/sleep_inline.h
new file mode 100644
index 0000000..2b0a914
--- /dev/null
+++ b/pw_thread_stl/public/pw_thread_stl/sleep_inline.h
@@ -0,0 +1,42 @@
+// 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.
+#pragma once
+
+#include <thread>
+
+#include "pw_chrono/system_clock.h"
+#include "pw_thread/sleep.h"
+
+namespace pw::this_thread {
+
+inline void sleep_for(chrono::SystemClock::duration for_at_least) {
+  for_at_least = std::max(for_at_least, chrono::SystemClock::duration::zero());
+  // Although many implementations do yield with sleep_for(0), it is not
+  // required, ergo we explicitly add handling.
+  if (for_at_least == chrono::SystemClock::duration::zero()) {
+    return std::this_thread::yield();
+  }
+  return std::this_thread::sleep_for(for_at_least);
+}
+
+inline void sleep_until(chrono::SystemClock::time_point until_at_least) {
+  // Although many implementations do yield with deadlines in the past until
+  // the current time, it is not required, ergo we explicitly add handling.
+  if (chrono::SystemClock::now() >= until_at_least) {
+    return std::this_thread::yield();
+  }
+  return std::this_thread::sleep_until(until_at_least);
+}
+
+}  // namespace pw::this_thread
diff --git a/pw_thread_stl/public/pw_thread_stl/thread_inline.h b/pw_thread_stl/public/pw_thread_stl/thread_inline.h
new file mode 100644
index 0000000..018e807
--- /dev/null
+++ b/pw_thread_stl/public/pw_thread_stl/thread_inline.h
@@ -0,0 +1,47 @@
+// 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.
+#pragma once
+
+#include <thread>
+
+namespace pw::thread {
+
+inline Thread::Thread() : native_type_() {}
+
+inline Thread::Thread(const Options&, ThreadRoutine entry, void* arg) {
+  native_type_ = std::thread(entry, arg);
+}
+
+inline Thread& Thread::operator=(Thread&& other) {
+  native_type_ = std::move(other.native_type_);
+  return *this;
+}
+
+inline Thread::~Thread() = default;
+
+inline Id Thread::get_id() const { return native_type_.get_id(); }
+
+inline void Thread::join() { native_type_.join(); }
+
+inline void Thread::detach() { native_type_.detach(); }
+
+inline void Thread::swap(Thread& other) {
+  native_type_.swap(other.native_handle());
+}
+
+inline Thread::native_handle_type Thread::native_handle() {
+  return native_type_;
+}
+
+}  // namespace pw::thread
diff --git a/pw_thread_stl/public/pw_thread_stl/thread_native.h b/pw_thread_stl/public/pw_thread_stl/thread_native.h
new file mode 100644
index 0000000..d3592de
--- /dev/null
+++ b/pw_thread_stl/public/pw_thread_stl/thread_native.h
@@ -0,0 +1,25 @@
+// 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.
+#pragma once
+
+#include <thread>
+
+#define PW_THREAD_JOINING_ENABLED 1
+
+namespace pw::thread::backend {
+
+using NativeThread = std::thread;
+using NativeThreadHandle = std::thread&;
+
+}  // namespace pw::thread::backend
diff --git a/pw_thread_stl/public/pw_thread_stl/yield_inline.h b/pw_thread_stl/public/pw_thread_stl/yield_inline.h
new file mode 100644
index 0000000..ec01853
--- /dev/null
+++ b/pw_thread_stl/public/pw_thread_stl/yield_inline.h
@@ -0,0 +1,22 @@
+// 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.
+#pragma once
+
+#include <thread>
+
+namespace pw::this_thread {
+
+inline void yield() noexcept { std::this_thread::yield(); }
+
+}  // namespace pw::this_thread
diff --git a/pw_thread_stl/public_overrides/pw_thread_backend/id_inline.h b/pw_thread_stl/public_overrides/pw_thread_backend/id_inline.h
new file mode 100644
index 0000000..56f5e5d
--- /dev/null
+++ b/pw_thread_stl/public_overrides/pw_thread_backend/id_inline.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_thread_stl/id_inline.h"
diff --git a/pw_thread_stl/public_overrides/pw_thread_backend/id_native.h b/pw_thread_stl/public_overrides/pw_thread_backend/id_native.h
new file mode 100644
index 0000000..82aa096
--- /dev/null
+++ b/pw_thread_stl/public_overrides/pw_thread_backend/id_native.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_thread_stl/id_native.h"
diff --git a/pw_thread_stl/public_overrides/pw_thread_backend/sleep_inline.h b/pw_thread_stl/public_overrides/pw_thread_backend/sleep_inline.h
new file mode 100644
index 0000000..8d0132a
--- /dev/null
+++ b/pw_thread_stl/public_overrides/pw_thread_backend/sleep_inline.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_thread_stl/sleep_inline.h"
diff --git a/pw_thread_stl/public_overrides/pw_thread_backend/thread_inline.h b/pw_thread_stl/public_overrides/pw_thread_backend/thread_inline.h
new file mode 100644
index 0000000..2a52ee8
--- /dev/null
+++ b/pw_thread_stl/public_overrides/pw_thread_backend/thread_inline.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_thread_stl/thread_inline.h"
diff --git a/pw_thread_stl/public_overrides/pw_thread_backend/thread_native.h b/pw_thread_stl/public_overrides/pw_thread_backend/thread_native.h
new file mode 100644
index 0000000..272a595
--- /dev/null
+++ b/pw_thread_stl/public_overrides/pw_thread_backend/thread_native.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_thread_stl/thread_native.h"
diff --git a/pw_thread_stl/public_overrides/pw_thread_backend/yield_inline.h b/pw_thread_stl/public_overrides/pw_thread_backend/yield_inline.h
new file mode 100644
index 0000000..61528f6
--- /dev/null
+++ b/pw_thread_stl/public_overrides/pw_thread_backend/yield_inline.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_thread_stl/yield_inline.h"
diff --git a/pw_thread_stl/test_threads.cc b/pw_thread_stl/test_threads.cc
new file mode 100644
index 0000000..64ab33e
--- /dev/null
+++ b/pw_thread_stl/test_threads.cc
@@ -0,0 +1,40 @@
+// 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.
+
+#include "pw_thread/test_threads.h"
+
+#include "pw_thread_stl/options.h"
+
+namespace pw::thread::test {
+
+// The STL doesn't offer any options so the default constructed options are used
+// directly.
+
+const Options& TestOptionsThread0() {
+  static constexpr stl::Options thread_0_options;
+  return thread_0_options;
+}
+
+const Options& TestOptionsThread1() {
+  static constexpr stl::Options thread_1_options;
+  return thread_1_options;
+}
+
+// Thanks to the dynamic allocation, there's no need to wait here as there's
+// no re-use of contexts. In addition we have a very large heap so we expect
+// the risk to be minimal to non-existent for heap exhaustion to occur if this
+// test is run back to back.
+void WaitUntilDetachedThreadsCleanedUp() {}
+
+}  // namespace pw::thread::test
diff --git a/pw_thread_threadx/BUILD b/pw_thread_threadx/BUILD
new file mode 100644
index 0000000..427af06
--- /dev/null
+++ b/pw_thread_threadx/BUILD
@@ -0,0 +1,160 @@
+# Copyright 2021 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.
+
+load(
+    "//pw_build:pigweed.bzl",
+    "pw_cc_library",
+    "pw_cc_test",
+)
+
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])  # Apache License 2.0
+
+pw_cc_library(
+    name = "id_headers",
+    hdrs = [
+        "public/pw_thread_threadx/id_inline.h",
+        "public/pw_thread_threadx/id_native.h",
+        "public_overrides/pw_thread_backend/id_inline.h",
+        "public_overrides/pw_thread_backend/id_native.h",
+    ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+)
+
+pw_cc_library(
+    name = "id",
+    deps = [
+        ":id_headers",
+        "//pw_thread:id_facade",
+    ],
+    # TODO(pwbug/317): This should depend on ThreadX but our third parties
+		# currently do not have Bazel support.
+)
+
+# This target provides the ThreadX specific headers needs for thread creation.
+pw_cc_library(
+    name = "thread_headers",
+    hdrs = [
+        "public/pw_thread_threadx/context.h",
+        "public/pw_thread_threadx/options.h",
+        "public/pw_thread_threadx/config.h",
+        "public/pw_thread_threadx/thread_inline.h",
+        "public/pw_thread_threadx/thread_native.h",
+        "public_overrides/pw_thread_backend/thread_inline.h",
+        "public_overrides/pw_thread_backend/thread_native.h",
+    ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+    deps = [
+        "//pw_assert",
+        ":id",
+        "//pw_thread:thread_headers",
+    ],
+    # TODO(pwbug/317): This should depend on ThreadX but our third parties
+    # currently do not have Bazel support.
+)
+
+pw_cc_library(
+    name = "thread",
+    srcs = [
+        "thread.cc",
+    ],
+    deps = [
+        "//pw_assert",
+        ":id",
+        ":thread_headers",
+    ],
+    # TODO(pwbug/317): This should depend on ThreadX but our third parties
+    # currently do not have Bazel support.
+)
+
+pw_cc_library(
+    name = "test_threads",
+    deps = [
+        "//pw_thread:thread_facade",
+        "//pw_thread:test_threads_header",
+        "//pw_chrono:system_clock",
+        "//pw_thread:sleep",
+    ],
+    srcs = [
+        "test_threads.cc",
+    ]
+)
+
+pw_cc_test(
+    name = "thread_backend_test",
+    deps = [
+        "//pw_thread:thread_facade_test",
+        ":test_threads",
+    ]
+)
+
+pw_cc_library(
+    name = "sleep_headers",
+    hdrs = [
+        "public/pw_thread_threadx/sleep_inline.h",
+        "public_overrides/pw_thread_backend/sleep_inline.h",
+    ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+    deps = [
+        "//pw_chrono:system_clock",
+    ],
+)
+
+pw_cc_library(
+    name = "sleep",
+    srcs = [
+        "sleep.cc",
+    ],
+    deps = [
+        ":sleep_headers",
+        "//pw_chrono_threadx:system_clock_headers",
+        "//pw_assert",
+        "//pw_chrono:system_clock",
+        "//pw_thread:sleep_facade",
+    ],
+    # TODO(pwbug/317): This should depend on ThreadX but our third parties
+		# currently do not have Bazel support.
+)
+
+pw_cc_library(
+    name = "yield_headers",
+    hdrs = [
+        "public/pw_thread_threadx/yield_inline.h",
+        "public_overrides/pw_thread_backend/yield_inline.h",
+    ],
+    includes = [
+        "public",
+        "public_overrides",
+    ],
+    # TODO(pwbug/317): This should depend on ThreadX but our third parties
+		# currently do not have Bazel support.
+)
+
+pw_cc_library(
+    name = "yield",
+    deps = [
+        ":yield_headers",
+        "//pw_thread:yield_facade",
+    ],
+)
diff --git a/pw_thread_threadx/BUILD.gn b/pw_thread_threadx/BUILD.gn
new file mode 100644
index 0000000..f7a090b
--- /dev/null
+++ b/pw_thread_threadx/BUILD.gn
@@ -0,0 +1,167 @@
+# Copyright 2021 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/module_config.gni")
+import("$dir_pw_build/target_types.gni")
+import("$dir_pw_chrono/backend.gni")
+import("$dir_pw_docgen/docs.gni")
+import("$dir_pw_thread/backend.gni")
+import("$dir_pw_unit_test/test.gni")
+
+declare_args() {
+  # The build target that overrides the default configuration options for this
+  # module. This should point to a source set that provides defines through a
+  # public config (which may -include a file or add defines directly).
+  pw_thread_threadx_CONFIG = pw_build_DEFAULT_MODULE_CONFIG
+}
+
+config("public_include_path") {
+  include_dirs = [ "public" ]
+  visibility = [ ":*" ]
+}
+
+config("backend_config") {
+  include_dirs = [ "public_overrides" ]
+  visibility = [ ":*" ]
+}
+
+pw_source_set("config") {
+  public = [ "public/pw_thread_threadx/config.h" ]
+  public_configs = [ ":public_include_path" ]
+  public_deps = [
+    "$dir_pw_third_party/threadx",
+    pw_thread_threadx_CONFIG,
+  ]
+}
+
+# This target provides the backend for pw::thread::Id.
+pw_source_set("id") {
+  public_configs = [
+    ":public_include_path",
+    ":backend_config",
+  ]
+  public_deps = [
+    "$dir_pw_assert",
+    "$dir_pw_interrupt:context",
+    "$dir_pw_third_party/threadx",
+  ]
+  public = [
+    "public/pw_thread_threadx/id_inline.h",
+    "public/pw_thread_threadx/id_native.h",
+    "public_overrides/pw_thread_backend/id_inline.h",
+    "public_overrides/pw_thread_backend/id_native.h",
+  ]
+  deps = [ "$dir_pw_thread:id.facade" ]
+}
+
+if (pw_chrono_SYSTEM_CLOCK_BACKEND != "" && pw_thread_SLEEP_BACKEND != "") {
+  # This target provides the backend for pw::this_thread::sleep_{for,until}.
+  pw_source_set("sleep") {
+    public_configs = [
+      ":public_include_path",
+      ":backend_config",
+    ]
+    public = [
+      "public/pw_thread_threadx/sleep_inline.h",
+      "public_overrides/pw_thread_backend/sleep_inline.h",
+    ]
+    public_deps = [ "$dir_pw_chrono:system_clock" ]
+    sources = [ "sleep.cc" ]
+    deps = [
+      "$dir_pw_assert",
+      "$dir_pw_chrono_threadx:system_clock",
+      "$dir_pw_third_party/threadx",
+      "$dir_pw_thread:id",
+    ]
+    assert(
+        pw_thread_OVERRIDE_SYSTEM_CLOCK_BACKEND_CHECK ||
+            pw_chrono_SYSTEM_CLOCK_BACKEND ==
+                "$dir_pw_chrono_threadx:system_clock",
+        "The ThreadX pw::this_thread::sleep_{for,until} backend only works with " + "the ThreadX pw::chrono::SystemClock backend.")
+  }
+}
+
+# This target provides the backend for pw::thread::Thread and the headers needed
+# for thread creation.
+pw_source_set("thread") {
+  public_configs = [
+    ":public_include_path",
+    ":backend_config",
+  ]
+  public_deps = [
+    ":config",
+    "$dir_pw_assert",
+    "$dir_pw_third_party/threadx",
+    "$dir_pw_thread:id",
+    "$dir_pw_thread:thread.facade",
+  ]
+  public = [
+    "public/pw_thread_threadx/context.h",
+    "public/pw_thread_threadx/options.h",
+    "public/pw_thread_threadx/thread_inline.h",
+    "public/pw_thread_threadx/thread_native.h",
+    "public_overrides/pw_thread_backend/thread_inline.h",
+    "public_overrides/pw_thread_backend/thread_native.h",
+  ]
+  allow_circular_includes_from = [ "$dir_pw_thread:thread.facade" ]
+  sources = [ "thread.cc" ]
+}
+
+# This target provides the backend for pw::this_thread::yield.
+pw_source_set("yield") {
+  public_configs = [
+    ":public_include_path",
+    ":backend_config",
+  ]
+  public = [
+    "public/pw_thread_threadx/yield_inline.h",
+    "public_overrides/pw_thread_backend/yield_inline.h",
+  ]
+  public_deps = [
+    "$dir_pw_assert",
+    "$dir_pw_third_party/threadx",
+    "$dir_pw_thread:id",
+  ]
+  deps = [ "$dir_pw_thread:yield.facade" ]
+}
+
+pw_test_group("tests") {
+  tests = [ ":thread_backend_test" ]
+}
+
+pw_source_set("test_threads") {
+  public_deps = [ "$dir_pw_thread:test_threads" ]
+  sources = [ "test_threads.cc" ]
+  deps = [
+    "$dir_pw_chrono:system_clock",
+    "$dir_pw_thread:sleep",
+    "$dir_pw_thread:thread",
+    dir_pw_assert,
+    dir_pw_log,
+  ]
+}
+
+pw_test("thread_backend_test") {
+  enable_if = pw_thread_THREAD_BACKEND == "$dir_pw_thread_threadx:thread"
+  deps = [
+    ":test_threads",
+    "$dir_pw_thread:thread_facade_test",
+  ]
+}
+
+pw_doc_group("docs") {
+  sources = [ "docs.rst" ]
+}
diff --git a/pw_thread_threadx/docs.rst b/pw_thread_threadx/docs.rst
new file mode 100644
index 0000000..527b487
--- /dev/null
+++ b/pw_thread_threadx/docs.rst
@@ -0,0 +1,27 @@
+.. _module-pw_thread_threadx:
+
+=================
+pw_thread_threadx
+=================
+This is a set of backends for pw_thread based on ThreadX.
+
+.. Warning::
+  This module is still under construction, the API is not yet stable.
+
+.. list-table::
+
+  * - :ref:`module-pw_thread` Facade
+    - Backend Target
+    - Description
+  * - ``pw_thread:id``
+    - ``pw_thread_threadx:id``
+    - Thread identification.
+  * - ``pw_thread:yield``
+    - ``pw_thread_threadx:yield``
+    - Thread scheduler yielding.
+  * - ``pw_thread:sleep``
+    - ``pw_thread_threadx:sleep``
+    - Thread scheduler sleeping.
+  * - ``pw_thread:thread``
+    - ``pw_thread_threadx:thread``
+    - Thread creation.
diff --git a/pw_thread_threadx/public/pw_thread_threadx/config.h b/pw_thread_threadx/public/pw_thread_threadx/config.h
new file mode 100644
index 0000000..0198f4b
--- /dev/null
+++ b/pw_thread_threadx/public/pw_thread_threadx/config.h
@@ -0,0 +1,75 @@
+// Copyright 2021 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.
+// Configuration macros for the tokenizer module.
+#pragma once
+
+#include "tx_api.h"
+
+// Whether thread joining is enabled. By default this is disabled.
+// When enabled this adds a TX_EVENT_FLAGS_GROUP to every pw::thread::Thread's
+// context.
+#ifndef PW_THREAD_THREADX_CONFIG_JOINING_ENABLED
+#define PW_THREAD_THREADX_CONFIG_JOINING_ENABLED 0
+#endif  // PW_THREAD_THREADX_CONFIG_JOINING_ENABLED
+#define PW_THREAD_JOINING_ENABLED PW_THREAD_THREADX_CONFIG_JOINING_ENABLED
+
+// The default stack size in words. By default this uses the minimal ThreadX
+// stack size.
+#ifndef PW_THREAD_THREADX_CONFIG_DEFAULT_STACK_SIZE_WORDS
+#define PW_THREAD_THREADX_CONFIG_DEFAULT_STACK_SIZE_WORDS \
+  TX_MINIMUM_STACK / sizeof(ULONG)
+#endif  // PW_THREAD_THREADX_CONFIG_DEFAULT_STACK_SIZE_WORDS
+
+// The maximum length of a thread's name, not including null termination. By
+// default this is arbitrarily set to 15. This results in an array of characters
+// which is this length + 1 bytes in every pw::thread::Thread's context.
+#ifndef PW_THREAD_THREADX_CONFIG_MAX_THREAD_NAME_LEN
+#define PW_THREAD_THREADX_CONFIG_MAX_THREAD_NAME_LEN 15
+#endif  // PW_THREAD_THREADX_CONFIG_MAX_THREAD_NAME_LEN
+
+// The round robin time slice tick interval for threads at the same priority.
+// By default this is disabled as not all ports support this, using a value of 0
+// ticks.
+#ifndef PW_THREAD_THREADX_CONFIG_DEFAULT_TIME_SLICE_INTERVAL
+#define PW_THREAD_THREADX_CONFIG_DEFAULT_TIME_SLICE_INTERVAL TX_NO_TIME_SLICE
+#endif  // PW_THREAD_THREADX_CONFIG_DEFAULT_TIME_SLICE_INTERVAL
+
+// The minimum priority level, this is normally based on the number of priority
+// levels.
+#ifndef PW_THREAD_THREADX_CONFIG_MIN_PRIORITY
+#define PW_THREAD_THREADX_CONFIG_MIN_PRIORITY TX_MAX_PRIORITIES - 1
+#endif  // PW_THREAD_THREADX_CONFIG_MIN_PRIORITY
+
+// The default stack size in words. By default this uses the minimal ThreadX
+// priority level, given that 0 is the highest priority.
+#ifndef PW_THREAD_THREADX_CONFIG_DEFAULT_PRIORITY
+#define PW_THREAD_THREADX_CONFIG_DEFAULT_PRIORITY \
+  PW_THREAD_THREADX_CONFIG_MIN_PRIORITY
+#endif  // PW_THREAD_THREADX_CONFIG_DEFAULT_PRIORITY
+
+namespace pw::thread::threadx::config {
+
+inline constexpr size_t kMaximumNameLength =
+    PW_THREAD_THREADX_CONFIG_MAX_THREAD_NAME_LEN + 1;
+inline constexpr size_t kMinimumStackSizeWords =
+    TX_MINIMUM_STACK / sizeof(ULONG);
+inline constexpr size_t kDefaultStackSizeWords =
+    PW_THREAD_THREADX_CONFIG_DEFAULT_STACK_SIZE_WORDS;
+inline constexpr UINT kMinimumPriority = PW_THREAD_THREADX_CONFIG_MIN_PRIORITY;
+inline constexpr UINT kDefaultPriority =
+    PW_THREAD_THREADX_CONFIG_DEFAULT_PRIORITY;
+inline constexpr ULONG kDefaultTimeSliceInterval =
+    PW_THREAD_THREADX_CONFIG_DEFAULT_TIME_SLICE_INTERVAL;
+
+}  // namespace pw::thread::threadx::config
diff --git a/pw_thread_threadx/public/pw_thread_threadx/context.h b/pw_thread_threadx/public/pw_thread_threadx/context.h
new file mode 100644
index 0000000..162e3b3
--- /dev/null
+++ b/pw_thread_threadx/public/pw_thread_threadx/context.h
@@ -0,0 +1,135 @@
+// Copyright 2021 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.
+#pragma once
+
+#include <cstdint>
+#include <cstring>
+#include <span>
+
+#include "pw_thread_threadx/config.h"
+#include "tx_api.h"
+#include "tx_thread.h"
+
+namespace pw::thread {
+
+class Thread;  // Forward declare Thread which depends on Context.
+
+}  // namespace pw::thread
+
+namespace pw::thread::threadx {
+
+// Static thread context allocation including the TCB, an event group for
+// joining if enabled, and an external statically allocated stack.
+//
+// Example usage:
+//
+//   std::array<ULONG, 42> example_thread_stack;
+//   pw::thread::threadx::Context example_thread_context(example_thread_stack);
+//   void StartExampleThread() {
+//      pw::thread::Thread(
+//        pw::thread::threadx::Options()
+//            .set_name("static_example_thread")
+//            .set_priority(kFooPriority)
+//            .set_static_context(example_thread_context),
+//        example_thread_function).detach();
+//   }
+class Context {
+ public:
+  explicit Context(std::span<ULONG> stack_span)
+      : tcb_{}, stack_span_(stack_span) {}
+  Context(const Context&) = delete;
+  Context& operator=(const Context&) = delete;
+
+  // Intended for unit test & Thread use only.
+  TX_THREAD& tcb() { return tcb_; }
+
+ private:
+  friend Thread;
+
+  std::span<ULONG> stack() { return stack_span_; }
+
+  bool in_use() const { return in_use_; }
+  void set_in_use(bool in_use = true) { in_use_ = in_use; }
+
+  const char* name() const { return name_.data(); }
+  void set_name(const char* name) {
+    strncpy(name_.data(), name, name_.size() - 1);
+    // Forcefully NULL terminate as strncpy does not NULL terminate if the count
+    // limit is hit.
+    name_[name_.size() - 1] = '\0';
+  }
+
+  using ThreadRoutine = void (*)(void* arg);
+  void set_thread_routine(ThreadRoutine entry, void* arg) {
+    entry_ = entry;
+    arg_ = arg;
+  }
+
+  bool detached() const { return detached_; }
+  void set_detached(bool value = true) { detached_ = value; }
+
+  bool thread_done() const { return thread_done_; }
+  void set_thread_done(bool value = true) { thread_done_ = value; }
+
+#if PW_THREAD_JOINING_ENABLED
+  TX_EVENT_FLAGS_GROUP& join_event_group() { return event_group_; }
+#endif  // PW_THREAD_JOINING_ENABLED
+
+  static void RunThread(ULONG void_context_ptr);
+  static void DeleteThread(Context& context);
+
+  TX_THREAD tcb_;
+  std::span<ULONG> stack_span_;
+
+  ThreadRoutine entry_ = nullptr;
+  void* arg_ = nullptr;
+#if PW_THREAD_JOINING_ENABLED
+  // Note that the ThreadX life cycle of this event group is managed together
+  // with the thread life cycle, not this object's life cycle.
+  TX_EVENT_FLAGS_GROUP event_group_;
+#endif  // PW_THREAD_JOINING_ENABLED
+  bool in_use_ = false;
+  bool detached_ = false;
+  bool thread_done_ = false;
+
+  // The TCB does not have storage for the name, ergo we provide storage for
+  // the thread's name which can be truncated down to just a null delimeter.
+  std::array<char, config::kMaximumNameLength> name_;
+};
+
+// Static thread context allocation including the stack along with the Context.
+//
+// Example usage:
+//
+//   pw::thread::threadx::ContextWithStack<42> example_thread_context;
+//   void StartExampleThread() {
+//      pw::thread::Thread(
+//        pw::thread::threadx::Options()
+//            .set_name("static_example_thread")
+//            .set_priority(kFooPriority)
+//            .set_static_context(example_thread_context),
+//        example_thread_function).detach();
+//   }
+template <size_t kStackSizeWords = config::kDefaultStackSizeWords>
+class ContextWithStack final : public Context {
+ public:
+  constexpr ContextWithStack() : Context(stack_storage_) {
+    static_assert(kStackSizeWords >= config::kMinimumStackSizeWords);
+  }
+
+ private:
+  std::array<ULONG, kStackSizeWords> stack_storage_;
+};
+
+}  // namespace pw::thread::threadx
diff --git a/pw_thread_threadx/public/pw_thread_threadx/id_inline.h b/pw_thread_threadx/public/pw_thread_threadx/id_inline.h
new file mode 100644
index 0000000..7004996
--- /dev/null
+++ b/pw_thread_threadx/public/pw_thread_threadx/id_inline.h
@@ -0,0 +1,33 @@
+// 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.
+#pragma once
+
+#include "pw_assert/light.h"
+#include "pw_thread/id.h"
+#include "tx_api.h"
+// Prior to ThreadX 6.1, this contained TX_THREAD_GET_SYSTEM_STATE().
+#include "tx_thread.h"
+
+namespace pw::this_thread {
+
+inline thread::Id get_id() {
+  // When this value is 0, a thread is executing or the system is idle.
+  // Other values indicate that interrupt or initialization processing is
+  // active.
+  PW_DASSERT(TX_THREAD_GET_SYSTEM_STATE() == 0);
+
+  return thread::Id(tx_thread_identify());
+}
+
+}  // namespace pw::this_thread
diff --git a/pw_thread_threadx/public/pw_thread_threadx/id_native.h b/pw_thread_threadx/public/pw_thread_threadx/id_native.h
new file mode 100644
index 0000000..72c1e7f
--- /dev/null
+++ b/pw_thread_threadx/public/pw_thread_threadx/id_native.h
@@ -0,0 +1,52 @@
+// 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.
+#pragma once
+
+#include "tx_api.h"
+
+namespace pw::thread::backend {
+
+// Instead of using a pw::thread::threadx specific identifier, the ThreadX
+// thread pointer is used as this means pw::this_thread::id works correctly on
+// threads started with the native ThreadX APIs as well as those started
+// using the pw::thread APIs.
+class NativeId {
+ public:
+  constexpr NativeId(TX_THREAD* thread_ptr = nullptr)
+      : thread_ptr_(thread_ptr) {}
+
+  constexpr bool operator==(NativeId other) const {
+    return thread_ptr_ == other.thread_ptr_;
+  }
+  constexpr bool operator!=(NativeId other) const {
+    return thread_ptr_ != other.thread_ptr_;
+  }
+  constexpr bool operator<(NativeId other) const {
+    return thread_ptr_ < other.thread_ptr_;
+  }
+  constexpr bool operator<=(NativeId other) const {
+    return thread_ptr_ <= other.thread_ptr_;
+  }
+  constexpr bool operator>(NativeId other) const {
+    return thread_ptr_ > other.thread_ptr_;
+  }
+  constexpr bool operator>=(NativeId other) const {
+    return thread_ptr_ >= other.thread_ptr_;
+  }
+
+ private:
+  TX_THREAD* thread_ptr_;
+};
+
+}  // namespace pw::thread::backend
diff --git a/pw_thread_threadx/public/pw_thread_threadx/options.h b/pw_thread_threadx/public/pw_thread_threadx/options.h
new file mode 100644
index 0000000..cdd3414
--- /dev/null
+++ b/pw_thread_threadx/public/pw_thread_threadx/options.h
@@ -0,0 +1,141 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "pw_assert/assert.h"
+#include "pw_thread/thread.h"
+#include "pw_thread_threadx/config.h"
+#include "pw_thread_threadx/context.h"
+#include "tx_api.h"
+
+namespace pw::thread::threadx {
+
+// pw::thread::Options for ThreadX.
+//
+// Example usage:
+//
+//   // Uses the default priority and time slice interval (which may be
+//   // disabled), but specifies a custom name and pre-allocated context.
+//   // Note that the preemption threshold is disabled by default.
+//   pw::thread::Thread example_thread(
+//     pw::thread::threadx::Options()
+//         .set_name("example_thread"),
+//         .set_context(static_example_thread_context),
+//     example_thread_function);
+//
+//   // Specifies the name, priority, time slice interval, and pre-allocated
+//   // context, but does not use a preemption threshold.
+//   pw::thread::Thread static_example_thread(
+//     pw::thread::threadx::Options()
+//         .set_name("static_example_thread")
+//         .set_priority(kFooPriority)
+//         .set_time_slice_interval(1)
+//         .set_context(static_example_thread_context),
+//     example_thread_function);
+//
+class Options : public thread::Options {
+ public:
+  constexpr Options() = default;
+  constexpr Options(const Options&) = default;
+  constexpr Options(Options&& other) = default;
+
+  // Sets the name for the ThreadX thread, note that this will be deep copied
+  // into the context and may be truncated based on
+  // PW_THREAD_THREADX_CONFIG_MAX_THREAD_NAME_LEN.
+  constexpr Options set_name(const char* name) {
+    name_ = name;
+    return *this;
+  }
+
+  // Sets the priority for the ThreadX thread from 0 through 31, where a value
+  // of 0 represents the highest priority, see ThreadX tx_thread_create for
+  // more detail.
+  constexpr Options set_priority(UINT priority) {
+    PW_DASSERT(priority <= PW_THREAD_THREADX_CONFIG_MIN_PRIORITY);
+    priority_ = priority;
+    return *this;
+  }
+
+  // Optionally sets the preemption threshold for the ThreadX thread from 0
+  // through 31.
+  //
+  // Only priorities higher than this level (i.e. lower number) are allowed to
+  // preempt this thread. In other words this allows the thread to specify the
+  // priority ceiling for disabling preemption. Threads that have a higher
+  // priority than the ceiling are still allowed to preempt while those with
+  // less than the ceiling are not allowed to preempt.
+  //
+  // Not setting the preemption threshold or explicitly specifying a value
+  // equal to the priority disables preemption threshold.
+  //
+  // Time slicing is disabled while the preemption threshold is enabled, i.e.
+  // not equal to the priority, even if a time slice interval was specified.
+  //
+  // The preemption threshold can be adjusted at run time, this only sets the
+  // initial threshold.
+  //
+  // Precondition: preemption_threshold <= priority
+  constexpr Options set_preemption_threshold(UINT preemption_threshold) {
+    PW_DASSERT(preemption_threshold < PW_THREAD_THREADX_CONFIG_MIN_PRIORITY);
+    possible_preemption_threshold_ = preemption_threshold;
+    return *this;
+  }
+
+  // Sets the number of ticks this thread is allowed to run before other ready
+  // threads of the same priority are given a chance to run.
+  //
+  // Time slicing is disabled while the preemption threshold is enabled, i.e.
+  // not equal to the priority, even if a time slice interval was specified.
+  //
+  // A value of TX_NO_TIME_SLICE (a value of 0) disables time-slicing of this
+  // thread.
+  //
+  // Using time slicing results in a slight amount of system overhead, threads
+  // with a unique priority should consider TX_NO_TIME_SLICE.
+  constexpr Options set_time_slice_interval(ULONG time_slice_interval) {
+    time_slice_interval_ = time_slice_interval;
+    return *this;
+  }
+
+  // Set the pre-allocated context (all memory needed to run a thread), see the
+  // pw::thread::threadx::Context for more detail.
+  constexpr Options set_context(Context& context) {
+    context_ = &context;
+    return *this;
+  }
+
+ private:
+  friend thread::Thread;
+  // Note that the default name may end up truncated due to
+  // PW_THREAD_THREADX_CONFIG_MAX_THREAD_NAME_LEN.
+  static constexpr char kDefaultName[] = "pw::Thread";
+
+  const char* name() const { return name_; }
+  UINT priority() const { return priority_; }
+  UINT preemption_threshold() const {
+    return possible_preemption_threshold_.value_or(priority_);
+  }
+  ULONG time_slice_interval() const { return time_slice_interval_; }
+  Context* context() const { return context_; }
+
+  const char* name_ = kDefaultName;
+  UINT priority_ = config::kDefaultPriority;
+  // A default value cannot be used for the preemption threshold as it would
+  // have to be based on the selected priority.
+  std::optional<UINT> possible_preemption_threshold_ = std::nullopt;
+  ULONG time_slice_interval_ = config::kDefaultTimeSliceInterval;
+  Context* context_ = nullptr;
+};
+
+}  // namespace pw::thread::threadx
diff --git a/pw_thread_threadx/public/pw_thread_threadx/sleep_inline.h b/pw_thread_threadx/public/pw_thread_threadx/sleep_inline.h
new file mode 100644
index 0000000..d03b744
--- /dev/null
+++ b/pw_thread_threadx/public/pw_thread_threadx/sleep_inline.h
@@ -0,0 +1,26 @@
+// 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.
+#pragma once
+
+#include "pw_chrono/system_clock.h"
+
+namespace pw::this_thread {
+
+inline void sleep_until(chrono::SystemClock::time_point until_at_least) {
+  // Note that if this deadline is in the future, it will get rounded up by
+  // one whole tick due to how sleep_for is implemented.
+  return sleep_for(until_at_least - chrono::SystemClock::now());
+}
+
+}  // namespace pw::this_thread
diff --git a/pw_thread_threadx/public/pw_thread_threadx/thread_inline.h b/pw_thread_threadx/public/pw_thread_threadx/thread_inline.h
new file mode 100644
index 0000000..33ab70f
--- /dev/null
+++ b/pw_thread_threadx/public/pw_thread_threadx/thread_inline.h
@@ -0,0 +1,50 @@
+// Copyright 2021 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.
+#pragma once
+
+#include <algorithm>
+
+#include "pw_assert/assert.h"
+#include "pw_thread/id.h"
+#include "pw_thread_threadx/config.h"
+#include "pw_thread_threadx/options.h"
+
+namespace pw::thread {
+
+inline Thread::Thread() : native_type_(nullptr) {}
+
+inline Thread& Thread::operator=(Thread&& other) {
+  native_type_ = other.native_type_;
+  other.native_type_ = nullptr;
+  return *this;
+}
+
+inline Thread::~Thread() { PW_DASSERT(native_type_ == nullptr); }
+
+inline Id Thread::get_id() const {
+  if (native_type_ == nullptr) {
+    return Id(nullptr);
+  }
+  return Id(&native_type_->tcb());
+}
+
+inline void Thread::swap(Thread& other) {
+  std::swap(native_type_, other.native_type_);
+}
+
+inline Thread::native_handle_type Thread::native_handle() {
+  return native_type_;
+}
+
+}  // namespace pw::thread
diff --git a/pw_thread_threadx/public/pw_thread_threadx/thread_native.h b/pw_thread_threadx/public/pw_thread_threadx/thread_native.h
new file mode 100644
index 0000000..3fb42fe
--- /dev/null
+++ b/pw_thread_threadx/public/pw_thread_threadx/thread_native.h
@@ -0,0 +1,26 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "pw_thread_threadx/context.h"
+
+namespace pw::thread::backend {
+
+// The native thread is a pointer to a thread's context.
+using NativeThread = pw::thread::threadx::Context*;
+
+// The native thread handle is the same as the NativeThread.
+using NativeThreadHandle = pw::thread::threadx::Context*;
+
+}  // namespace pw::thread::backend
diff --git a/pw_thread_threadx/public/pw_thread_threadx/yield_inline.h b/pw_thread_threadx/public/pw_thread_threadx/yield_inline.h
new file mode 100644
index 0000000..32ce45c
--- /dev/null
+++ b/pw_thread_threadx/public/pw_thread_threadx/yield_inline.h
@@ -0,0 +1,27 @@
+// 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.
+#pragma once
+
+#include "pw_assert/light.h"
+#include "pw_thread/id.h"
+#include "tx_api.h"
+
+namespace pw::this_thread {
+
+inline void yield() {
+  PW_DASSERT(get_id() != thread::Id());
+  tx_thread_relinquish();
+}
+
+}  // namespace pw::this_thread
diff --git a/pw_thread_threadx/public_overrides/pw_thread_backend/id_inline.h b/pw_thread_threadx/public_overrides/pw_thread_backend/id_inline.h
new file mode 100644
index 0000000..046e842
--- /dev/null
+++ b/pw_thread_threadx/public_overrides/pw_thread_backend/id_inline.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_thread_threadx/id_inline.h"
diff --git a/pw_thread_threadx/public_overrides/pw_thread_backend/id_native.h b/pw_thread_threadx/public_overrides/pw_thread_backend/id_native.h
new file mode 100644
index 0000000..bf144dd
--- /dev/null
+++ b/pw_thread_threadx/public_overrides/pw_thread_backend/id_native.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_thread_threadx/id_native.h"
diff --git a/pw_thread_threadx/public_overrides/pw_thread_backend/sleep_inline.h b/pw_thread_threadx/public_overrides/pw_thread_backend/sleep_inline.h
new file mode 100644
index 0000000..7f1cea9
--- /dev/null
+++ b/pw_thread_threadx/public_overrides/pw_thread_backend/sleep_inline.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_thread_threadx/sleep_inline.h"
diff --git a/pw_thread_threadx/public_overrides/pw_thread_backend/thread_inline.h b/pw_thread_threadx/public_overrides/pw_thread_backend/thread_inline.h
new file mode 100644
index 0000000..de91c73
--- /dev/null
+++ b/pw_thread_threadx/public_overrides/pw_thread_backend/thread_inline.h
@@ -0,0 +1,16 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "pw_thread_threadx/thread_inline.h"
diff --git a/pw_thread_threadx/public_overrides/pw_thread_backend/thread_native.h b/pw_thread_threadx/public_overrides/pw_thread_backend/thread_native.h
new file mode 100644
index 0000000..dfa8d21
--- /dev/null
+++ b/pw_thread_threadx/public_overrides/pw_thread_backend/thread_native.h
@@ -0,0 +1,16 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "pw_thread_threadx/thread_native.h"
diff --git a/pw_thread_threadx/public_overrides/pw_thread_backend/yield_inline.h b/pw_thread_threadx/public_overrides/pw_thread_backend/yield_inline.h
new file mode 100644
index 0000000..c97d259
--- /dev/null
+++ b/pw_thread_threadx/public_overrides/pw_thread_backend/yield_inline.h
@@ -0,0 +1,16 @@
+// 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.
+#pragma once
+
+#include "pw_thread_threadx/yield_inline.h"
diff --git a/pw_thread_threadx/sleep.cc b/pw_thread_threadx/sleep.cc
new file mode 100644
index 0000000..1cb5e5c
--- /dev/null
+++ b/pw_thread_threadx/sleep.cc
@@ -0,0 +1,53 @@
+// 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.
+
+#include "pw_thread/sleep.h"
+
+#include <algorithm>
+
+#include "pw_assert/assert.h"
+#include "pw_chrono/system_clock.h"
+#include "pw_chrono_threadx/system_clock_constants.h"
+#include "pw_thread/id.h"
+#include "tx_api.h"
+
+using pw::chrono::SystemClock;
+
+namespace pw::this_thread {
+
+void sleep_for(chrono::SystemClock::duration for_at_least) {
+  PW_DCHECK(get_id() != thread::Id());
+
+  // Yield for negative and zero length durations.
+  if (for_at_least <= chrono::SystemClock::duration::zero()) {
+    tx_thread_relinquish();
+    return;
+  }
+
+  // On a tick based kernel we cannot tell how far along we are on the current
+  // tick, ergo we add one whole tick to the final duration.
+  constexpr SystemClock::duration kMaxTimeoutMinusOne =
+      pw::chrono::threadx::kMaxTimeout - SystemClock::duration(1);
+  while (for_at_least > kMaxTimeoutMinusOne) {
+    const UINT result =
+        tx_thread_sleep(static_cast<ULONG>(kMaxTimeoutMinusOne.count()));
+    PW_CHECK_UINT_EQ(TX_SUCCESS, result);
+    for_at_least -= kMaxTimeoutMinusOne;
+  }
+  const UINT result =
+      tx_thread_sleep(static_cast<ULONG>(for_at_least.count() + 1));
+  PW_CHECK_UINT_EQ(TX_SUCCESS, result);
+}
+
+}  // namespace pw::this_thread
diff --git a/pw_thread_threadx/test_threads.cc b/pw_thread_threadx/test_threads.cc
new file mode 100644
index 0000000..0cf222b
--- /dev/null
+++ b/pw_thread_threadx/test_threads.cc
@@ -0,0 +1,72 @@
+// Copyright 2021 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.
+
+#include "pw_thread/test_threads.h"
+
+#include <chrono>
+
+#include "pw_assert/check.h"
+#include "pw_chrono/system_clock.h"
+#include "pw_log/log.h"
+#include "pw_thread/sleep.h"
+#include "pw_thread_threadx/context.h"
+#include "pw_thread_threadx/options.h"
+
+namespace pw::thread::test {
+namespace {
+
+std::array<threadx::ContextWithStack<>, 2> thread_contexts;
+
+}  // namespace
+
+const Options& TestOptionsThread0() {
+  static constexpr threadx::Options thread_0_options =
+      threadx::Options()
+          .set_name("pw::TestThread0")
+          .set_context(thread_contexts[0]);
+  return thread_0_options;
+}
+
+const Options& TestOptionsThread1() {
+  static constexpr threadx::Options thread_1_options =
+      threadx::Options()
+          .set_name("pw::TestThread1")
+          .set_context(thread_contexts[1]);
+  return thread_1_options;
+}
+
+void WaitUntilDetachedThreadsCleanedUp() {
+  // ThreadX does not permit the running thread to delete itself, which means
+  // we have to do this to re-use a TCB as otherwise we will be leaking stale
+  // references in the kernel.
+  for (auto& context : thread_contexts) {
+    if (context.tcb().tx_thread_id != TX_THREAD_ID) {
+      // The TCB was either not used or was already deleted. Note that
+      // tx_thread_terminate does NOT clear this state by design.
+      continue;
+    }
+
+    // If the thread was created but has not been deleted, it means that the
+    // thread was detached before it finished. Wait until it is completed.
+    while (context.tcb().tx_thread_state != TX_COMPLETED) {
+      pw::this_thread::sleep_for(
+          chrono::SystemClock::for_at_least(std::chrono::milliseconds(1)));
+    }
+
+    const UINT result = tx_thread_delete(&context.tcb());
+    PW_CHECK_UINT_EQ(TX_SUCCESS, result, "Failed to delete thread");
+  }
+}
+
+}  // namespace pw::thread::test
diff --git a/pw_thread_threadx/thread.cc b/pw_thread_threadx/thread.cc
new file mode 100644
index 0000000..72befae
--- /dev/null
+++ b/pw_thread_threadx/thread.cc
@@ -0,0 +1,201 @@
+// Copyright 2021 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.
+#include "pw_thread/thread.h"
+
+#include "pw_assert/assert.h"
+#include "pw_preprocessor/compiler.h"
+#include "pw_thread/id.h"
+#include "pw_thread_threadx/config.h"
+#include "pw_thread_threadx/context.h"
+#include "pw_thread_threadx/options.h"
+#include "tx_event_flags.h"
+
+using pw::thread::threadx::Context;
+
+namespace pw::thread {
+namespace {
+#if PW_THREAD_JOINING_ENABLED
+constexpr ULONG kThreadDoneBit = 1;
+#endif  // PW_THREAD_JOINING_ENABLED
+}  // namespace
+
+void Context::RunThread(ULONG void_context_ptr) {
+  Context& context = *reinterpret_cast<Context*>(void_context_ptr);
+  context.entry_(context.arg_);
+
+  // Raise our preemption threshold as a thread only critical section to guard
+  // against join() and detach().
+  UINT original_preemption_threshold = TX_MAX_PRIORITIES;  // Invalid.
+  UINT preemption_success = tx_thread_preemption_change(
+      &context.tcb(), 0, &original_preemption_threshold);
+  PW_DCHECK_UINT_EQ(TX_SUCCESS,
+                    preemption_success,
+                    "Failed to enter thread critical section");
+  if (context.detached()) {
+    // There is no threadsafe way to re-use detached threads, as there's no way
+    // to invoke tx_thread_delete() from the running thread! Joining MUST be
+    // used for this. However to enable unit test coverage we go ahead and clear
+    // this.
+    context.set_in_use(false);
+
+#if PW_THREAD_JOINING_ENABLED
+    // Just in case someone abused our API, ensure their use of the event group
+    // is properly handled by the kernel regardless.
+    const UINT event_group_result =
+        tx_event_flags_delete(&context.join_event_group());
+    PW_DCHECK_UINT_EQ(TX_SUCCESS,
+                      event_group_result,
+                      "Failed to delete the join event group");
+#endif  // PW_THREAD_JOINING_ENABLED
+
+    // Note that we do not have to restore our preemption threshold as this
+    // thread is completing execution.
+
+    // WARNING: The thread at this point continues to be registered with the
+    // kernel in TX_COMPLETED state, as tx_thread_delete cannot be invoked!
+    return;
+  }
+
+  // Otherwise the task finished before the thread was detached or joined, defer
+  // cleanup to Thread's join() or detach().
+  context.set_thread_done();
+  UINT unused = 0;
+  preemption_success = tx_thread_preemption_change(
+      &context.tcb(), original_preemption_threshold, &unused);
+  PW_DCHECK_UINT_EQ(TX_SUCCESS,
+                    preemption_success,
+                    "Failed to leave thread critical section");
+
+#if PW_THREAD_JOINING_ENABLED
+  const UINT result =
+      tx_event_flags_set(&context.join_event_group(), kThreadDoneBit, TX_OR);
+  PW_DCHECK_UINT_EQ(TX_SUCCESS, result, "Failed to set the join event");
+#endif  // PW_THREAD_JOINING_ENABLED
+  return;
+}
+
+void Context::DeleteThread(Context& context) {
+  // Stop the other task first.
+  UINT thread_result = tx_thread_terminate(&context.tcb());
+  PW_CHECK_UINT_EQ(TX_SUCCESS, thread_result, "Failed to terminate the thread");
+
+  // Delete the thread, removing it out of the kernel.
+  thread_result = tx_thread_delete(&context.tcb());
+  PW_CHECK_UINT_EQ(TX_SUCCESS, thread_result, "Failed to delete the thread");
+
+  // Mark the context as unused for potential later re-use.
+  context.set_in_use(false);
+
+#if PW_THREAD_JOINING_ENABLED
+  // Just in case someone abused our API, ensure their use of the event group is
+  // properly handled by the kernel regardless.
+  const UINT event_group_result =
+      tx_event_flags_delete(&context.join_event_group());
+  PW_DCHECK_UINT_EQ(
+      TX_SUCCESS, event_group_result, "Failed to delete the join event group");
+#endif  // PW_THREAD_JOINING_ENABLED
+}
+
+Thread::Thread(const thread::Options& facade_options,
+               ThreadRoutine entry,
+               void* arg)
+    : native_type_(nullptr) {
+  // Cast the generic facade options to the backend specific option of which
+  // only one type can exist at compile time.
+  auto options = static_cast<const threadx::Options&>(facade_options);
+  PW_DCHECK_NOTNULL(options.context(), "The Context is not optional");
+  native_type_ = options.context();
+
+  // Can't use a context more than once.
+  PW_DCHECK(!native_type_->in_use());
+
+  // Reset the state of the static context in case it was re-used.
+  native_type_->set_in_use(false);
+  native_type_->set_detached(false);
+  native_type_->set_thread_done(false);
+#if PW_THREAD_JOINING_ENABLED
+  static const char* join_event_group_name = "pw::Thread";
+  const UINT event_group_result =
+      tx_event_flags_create(&options.context()->join_event_group(),
+                            const_cast<char*>(join_event_group_name));
+  PW_DCHECK_UINT_EQ(
+      TX_SUCCESS, event_group_result, "Failed to create the join event group");
+#endif  // PW_THREAD_JOINING_ENABLED
+
+  // Copy over the thread name.
+  native_type_->set_name(options.name());
+
+  // In order to support functions which return and joining, a delegate is
+  // deep copied into the context with a small wrapping function to actually
+  // invoke the task with its arg.
+  native_type_->set_thread_routine(entry, arg);
+
+  const UINT thread_result =
+      tx_thread_create(&options.context()->tcb(),
+                       const_cast<char*>(native_type_->name()),
+                       Context::RunThread,
+                       reinterpret_cast<ULONG>(native_type_),
+                       options.context()->stack().data(),
+                       options.context()->stack().size_bytes(),
+                       options.priority(),
+                       options.preemption_threshold(),
+                       options.time_slice_interval(),
+                       TX_AUTO_START);
+  PW_CHECK_UINT_EQ(TX_SUCCESS, thread_result, "Failed to create the thread");
+}
+
+void Thread::detach() {
+  PW_CHECK(joinable());
+
+  tx_thread_suspend(&native_type_->tcb());
+  native_type_->set_detached();
+  const bool thread_done = native_type_->thread_done();
+  tx_thread_resume(&native_type_->tcb());
+
+  if (thread_done) {
+    // The task finished (hit end of Context::RunThread) before we invoked
+    // detach, clean up the thread.
+    Context::DeleteThread(*native_type_);
+  } else {
+    // We're detaching before the task finished, defer cleanup to the task at
+    // the end of Context::RunThread.
+  }
+
+  // Update to no longer represent a thread of execution.
+  native_type_ = nullptr;
+}
+
+#if PW_THREAD_JOINING_ENABLED
+void Thread::join() {
+  PW_CHECK(joinable());
+  PW_CHECK(this_thread::get_id() != get_id());
+
+  ULONG actual_flags = 0;
+  const UINT result = tx_event_flags_get(&native_type_->join_event_group(),
+                                         kThreadDoneBit,
+                                         TX_OR_CLEAR,
+                                         &actual_flags,
+                                         TX_WAIT_FOREVER);
+  PW_DCHECK_UINT_EQ(TX_SUCCESS, result, "Failed to get the join event");
+
+  // No need for a critical section here as the thread at this point is
+  // waiting to be deleted.
+  Context::DeleteThread(*native_type_);
+
+  // Update to no longer represent a thread of execution.
+  native_type_ = nullptr;
+}
+#endif  // PW_THREAD_JOINING_ENABLED
+
+}  // namespace pw::thread
diff --git a/pw_tokenizer/BUILD b/pw_tokenizer/BUILD
index 350d463..4b288be 100644
--- a/pw_tokenizer/BUILD
+++ b/pw_tokenizer/BUILD
@@ -33,13 +33,14 @@
         "public/pw_tokenizer/internal/argument_types_macro_4_byte.h",
         "public/pw_tokenizer/internal/argument_types_macro_8_byte.h",
         "public/pw_tokenizer/internal/pw_tokenizer_65599_fixed_length_128_hash_macro.h",
+        "public/pw_tokenizer/internal/pw_tokenizer_65599_fixed_length_256_hash_macro.h",
         "public/pw_tokenizer/internal/pw_tokenizer_65599_fixed_length_80_hash_macro.h",
         "public/pw_tokenizer/internal/pw_tokenizer_65599_fixed_length_96_hash_macro.h",
         "public/pw_tokenizer/internal/tokenize_string.h",
-        "pw_tokenizer_private/encode_args.h",
         "tokenize.cc",
     ],
     hdrs = [
+        "public/pw_tokenizer/encode_args.h",
         "public/pw_tokenizer/hash.h",
         "public/pw_tokenizer/tokenize.h",
     ],
diff --git a/pw_tokenizer/BUILD.gn b/pw_tokenizer/BUILD.gn
index 35c1757..8a0305f 100644
--- a/pw_tokenizer/BUILD.gn
+++ b/pw_tokenizer/BUILD.gn
@@ -45,7 +45,7 @@
       "-T",
       rebase_path("pw_tokenizer_linker_sections.ld"),
     ]
-  } else if (current_os == "linux") {
+  } else if (current_os == "linux" && !pw_toolchain_OSS_FUZZ_ENABLED) {
     # When building for Linux, the linker provides a default linker script.
     # The add_tokenizer_sections_to_default_script.ld wrapper includes the
     # pw_tokenizer_linker_sections.ld script in a way that appends to the the
@@ -73,10 +73,10 @@
   public_deps = [
     ":config",
     dir_pw_preprocessor,
-    dir_pw_span,
   ]
   deps = [ dir_pw_varint ]
   public = [
+    "public/pw_tokenizer/encode_args.h",
     "public/pw_tokenizer/hash.h",
     "public/pw_tokenizer/tokenize.h",
   ]
@@ -87,10 +87,10 @@
     "public/pw_tokenizer/internal/argument_types_macro_4_byte.h",
     "public/pw_tokenizer/internal/argument_types_macro_8_byte.h",
     "public/pw_tokenizer/internal/pw_tokenizer_65599_fixed_length_128_hash_macro.h",
+    "public/pw_tokenizer/internal/pw_tokenizer_65599_fixed_length_256_hash_macro.h",
     "public/pw_tokenizer/internal/pw_tokenizer_65599_fixed_length_80_hash_macro.h",
     "public/pw_tokenizer/internal/pw_tokenizer_65599_fixed_length_96_hash_macro.h",
     "public/pw_tokenizer/internal/tokenize_string.h",
-    "pw_tokenizer_private/encode_args.h",
     "tokenize.cc",
   ]
   friend = [ ":*" ]
@@ -141,13 +141,11 @@
     ":pw_tokenizer",
     dir_pw_base64,
     dir_pw_preprocessor,
-    dir_pw_span,
   ]
 }
 
 pw_source_set("decoder") {
   public_configs = [ ":public_include_path" ]
-  public_deps = [ dir_pw_span ]
   deps = [ dir_pw_varint ]
   public = [
     "public/pw_tokenizer/detokenize.h",
@@ -200,10 +198,7 @@
     ":token_database_test",
     ":tokenize_test",
   ]
-  group_deps = [
-    "$dir_pw_preprocessor:tests",
-    "$dir_pw_span:tests",
-  ]
+  group_deps = [ "$dir_pw_preprocessor:tests" ]
 }
 
 pw_test("argument_types_test") {
@@ -214,7 +209,7 @@
   ]
   deps = [ ":pw_tokenizer" ]
 
-  if (dir_pw_third_party_arduino != "") {
+  if (pw_arduino_build_CORE_PATH != "") {
     remove_configs = [ "$dir_pw_build:strict_warnings" ]
   }
 }
@@ -279,6 +274,7 @@
   "$dir_pw_varint/varint.cc",
   "encode_args.cc",
   "public/pw_tokenizer/config.h",
+  "public/pw_tokenizer/encode_args.h",
   "public/pw_tokenizer/hash.h",
   "public/pw_tokenizer/internal/argument_types.h",
   "public/pw_tokenizer/internal/argument_types_macro_4_byte.h",
@@ -290,7 +286,6 @@
   "public/pw_tokenizer/tokenize.h",
   "public/pw_tokenizer/tokenize_to_global_handler.h",
   "public/pw_tokenizer/tokenize_to_global_handler_with_payload.h",
-  "pw_tokenizer_private/encode_args.h",
   "simple_tokenize_test.cc",
   "tokenize.cc",
   "tokenize_to_global_handler.cc",
@@ -344,7 +339,6 @@
     ":decoder",
     "$dir_pw_fuzzer",
     "$dir_pw_preprocessor",
-    "$dir_pw_span",
   ]
 }
 
diff --git a/pw_tokenizer/add_tokenizer_sections_to_default_script.ld b/pw_tokenizer/add_tokenizer_sections_to_default_script.ld
index 41cc0c1..56890b0 100644
--- a/pw_tokenizer/add_tokenizer_sections_to_default_script.ld
+++ b/pw_tokenizer/add_tokenizer_sections_to_default_script.ld
@@ -20,6 +20,6 @@
  * The INSERT directive instructs the linker to append the directives in this
  * script to the default linker script, rather than replace the default with
  * this script. It doesn't matter where the tokenizer sections are inserted, so
- * insert them after the standard .strtab section.
+ * insert them after the .debug_info section, which both clang and GCC use.
  */
-INSERT AFTER .strtab
+INSERT AFTER .debug_info
diff --git a/pw_tokenizer/argument_types_test.cc b/pw_tokenizer/argument_types_test.cc
index 67c1d17..cf3b22b 100644
--- a/pw_tokenizer/argument_types_test.cc
+++ b/pw_tokenizer/argument_types_test.cc
@@ -187,38 +187,38 @@
 
 TEST(ArgumentTypes, MultipleArgs) {
   // clang-format off
-  static_assert(PW_TOKENIZER_ARG_TYPES(1) == 1);  // NOLINT
-  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2) == 2);  // NOLINT
-  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3) == 3);  // NOLINT
-  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4) == 4);  // NOLINT
-  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5) == 5);  // NOLINT
-  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6) == 6);  // NOLINT
-  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7) == 7);  // NOLINT
-  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8) == 8);  // NOLINT
-  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9) == 9);  // NOLINT
-  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10) == 10);  // NOLINT
-  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11) == 11);  // NOLINT
-  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12) == 12);  // NOLINT
-  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13) == 13);  // NOLINT
-  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14) == 14);  // NOLINT
+  static_assert(PW_TOKENIZER_ARG_TYPES(1) == 1);
+  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2) == 2);
+  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3) == 3);
+  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4) == 4);
+  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5) == 5);
+  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6) == 6);
+  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7) == 7);
+  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8) == 8);
+  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9) == 9);
+  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10) == 10);
+  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11) == 11);
+  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12) == 12);
+  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13) == 13);
+  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14) == 14);
 
 #if PW_TOKENIZER_CFG_ARG_TYPES_SIZE_BYTES >= 8
-  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14) == 14);  // NOLINT
-  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15) == 15);  // NOLINT
-  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16) == 16);  // NOLINT
-  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17) == 17);  // NOLINT
-  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18) == 18);  // NOLINT
-  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19) == 19);  // NOLINT
-  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20) == 20);  // NOLINT
-  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21) == 21);  // NOLINT
-  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22) == 22);  // NOLINT
-  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23) == 23);  // NOLINT
-  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24) == 24);  // NOLINT
-  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25) == 25);  // NOLINT
-  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26) == 26);  // NOLINT
-  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27) == 27);  // NOLINT
-  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28) == 28);  // NOLINT
-  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29) == 29);  // NOLINT
+  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14) == 14);
+  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15) == 15);
+  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16) == 16);
+  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17) == 17);
+  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18) == 18);
+  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19) == 19);
+  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20) == 20);
+  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21) == 21);
+  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22) == 22);
+  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23) == 23);
+  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24) == 24);
+  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25) == 25);
+  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26) == 26);
+  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27) == 27);
+  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28) == 28);
+  static_assert(PW_TOKENIZER_ARG_TYPES(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29) == 29);
 #endif  // PW_TOKENIZER_CFG_ARG_TYPES_SIZE_BYTES
   // clang-format on
 }
diff --git a/pw_tokenizer/argument_types_test_c.c b/pw_tokenizer/argument_types_test_c.c
index 2030587..849357d 100644
--- a/pw_tokenizer/argument_types_test_c.c
+++ b/pw_tokenizer/argument_types_test_c.c
@@ -72,10 +72,10 @@
 static char char_array[16];
 
 // Define the test functions that are called by the C++ unit test.
-#define DEFINE_TEST_FUNCTION(name, ...)                 \
-  _pw_tokenizer_ArgTypes pw_TestTokenizer##name(void) { \
-    (void)char_array;                                   \
-    return PW_TOKENIZER_ARG_TYPES(__VA_ARGS__);         \
+#define DEFINE_TEST_FUNCTION(name, ...)                \
+  pw_tokenizer_ArgTypes pw_TestTokenizer##name(void) { \
+    (void)char_array;                                  \
+    return PW_TOKENIZER_ARG_TYPES(__VA_ARGS__);        \
   }
 
 DEFINE_TEST_FUNCTION(NoArgs);
diff --git a/pw_tokenizer/database.gni b/pw_tokenizer/database.gni
index 8b7b894..81d6832 100644
--- a/pw_tokenizer/database.gni
+++ b/pw_tokenizer/database.gni
@@ -33,7 +33,11 @@
 #   targets: GN targets (executables or libraries) from which to add tokens;
 #       these targets are added to deps
 #   optional_targets: GN targets from which to add tokens, if the output files
-#       already exist; these targets are NOT added to deps
+#       already exist; these targets are NOT added to 'deps'
+#   optional_paths: Paths or globs to files in the output directory from which
+#       to add tokens. For example, "$root_build_dir/**/*.elf" finds all ELF
+#       files in the output directory; this does NOT add anything to 'deps': add
+#       targets to 'deps' or 'targets' if they must be built first
 #   input_databases: paths to other database files from which to add tokens
 #   deps: GN targets to build prior to generating the database; artifacts from
 #       these targets are NOT implicitly used for database generation
@@ -129,6 +133,15 @@
       args += [ "<TARGET_FILE_IF_EXISTS($target)>$_domain" ]
     }
 
+    if (defined(invoker.optional_paths)) {
+      _paths = rebase_path(invoker.optional_paths)
+      _out_dir = rebase_path(root_build_dir)
+      assert(filter_include(_paths, [ "$_out_dir/*" ]) == _paths,
+             "Paths in 'optional_paths' must be in the out directory. Use " +
+                 "'input_databases' for files in the source tree.")
+      args += _paths
+    }
+
     deps = _targets
 
     if (defined(invoker.deps)) {
diff --git a/pw_tokenizer/detokenize_fuzzer.cc b/pw_tokenizer/detokenize_fuzzer.cc
index cf55717..2b5604c 100644
--- a/pw_tokenizer/detokenize_fuzzer.cc
+++ b/pw_tokenizer/detokenize_fuzzer.cc
@@ -77,7 +77,7 @@
             provider.ConsumeBytes<uint8_t>(consumed_size);
         auto detokenized_string =
             detokenizer.Detokenize(std::span(&buffer[0], buffer.size()));
-        PW_UNUSED(detokenized_string);
+        static_cast<void>(detokenized_string);
         break;
       }
 
@@ -85,7 +85,7 @@
         std::string str =
             provider.ConsumeRandomLengthString(provider.remaining_bytes());
         auto detokenized_string = detokenizer.Detokenize(str);
-        PW_UNUSED(detokenized_string);
+        static_cast<void>(detokenized_string);
         break;
       }
 
@@ -96,7 +96,7 @@
             provider.ConsumeBytes<uint8_t>(consumed_size);
         auto detokenized_string =
             detokenizer.Detokenize(&buffer[0], buffer.size());
-        PW_UNUSED(detokenized_string);
+        static_cast<void>(detokenized_string);
         break;
       }
     }
diff --git a/pw_tokenizer/docs.rst b/pw_tokenizer/docs.rst
index 121f025..a95ee5a 100644
--- a/pw_tokenizer/docs.rst
+++ b/pw_tokenizer/docs.rst
@@ -233,8 +233,97 @@
   widely expanded macros, such as a logging macro, because it will result in
   larger code size than its alternatives.
 
-Example: binary logging
-^^^^^^^^^^^^^^^^^^^^^^^
+.. _module-pw_tokenizer-custom-macro:
+
+Tokenize with a custom macro
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+Projects may need more flexbility than the standard ``pw_tokenizer`` macros
+provide. To support this, projects may define custom tokenization macros. This
+requires the use of two low-level ``pw_tokenizer`` macros:
+
+.. c:macro:: PW_TOKENIZE_FORMAT_STRING(domain, mask, format, ...)
+
+  Tokenizes a format string and sets the ``_pw_tokenizer_token`` variable to the
+  token. Must be used in its own scope, since the same variable is used in every
+  invocation.
+
+  The tokenized string uses the specified :ref:`tokenization domain
+  <module-pw_tokenizer-domains>`.  Use ``PW_TOKENIZER_DEFAULT_DOMAIN`` for the
+  default. The token also may be masked; use ``UINT32_MAX`` to keep all bits.
+
+.. c:macro:: PW_TOKENIZER_ARG_TYPES(...)
+
+  Converts a series of arguments to a compact format that replaces the format
+  string literal.
+
+Use these two macros within the custom tokenization macro to call a function
+that does the encoding. The following example implements a custom tokenization
+macro for use with :ref:`module-pw_log_tokenized`.
+
+.. code-block:: cpp
+
+  #include "pw_tokenizer/tokenize.h"
+
+  #ifndef __cplusplus
+  extern "C" {
+  #endif
+
+  void EncodeTokenizedMessage(pw_tokenizer_Payload metadata,
+                              pw_tokenizer_Token token,
+                              pw_tokenizer_ArgTypes types,
+                              ...);
+
+  #ifndef __cplusplus
+  }  // extern "C"
+  #endif
+
+  #define PW_LOG_TOKENIZED_ENCODE_MESSAGE(metadata, format, ...)         \
+    do {                                                                 \
+      PW_TOKENIZE_FORMAT_STRING(                                         \
+          PW_TOKENIZER_DEFAULT_DOMAIN, UINT32_MAX, format, __VA_ARGS__); \
+      EncodeTokenizedMessage(payload,                                    \
+                             _pw_tokenizer_token,                        \
+                             PW_TOKENIZER_ARG_TYPES(__VA_ARGS__)         \
+                                 PW_COMMA_ARGS(__VA_ARGS__));            \
+    } while (0)
+
+In this example, the ``EncodeTokenizedMessage`` function would handle encoding
+and processing the message. Encoding is done by the
+``pw::tokenizer::EncodedMessage`` class or ``pw::tokenizer::EncodeArgs``
+function from ``pw_tokenizer/encode_args.h``. The encoded message can then be
+transmitted or stored as needed.
+
+.. code-block:: cpp
+
+  #include "pw_log_tokenized/log_tokenized.h"
+  #include "pw_tokenizer/encode_args.h"
+
+  void HandleTokenizedMessage(pw::log_tokenized::Metadata metadata,
+                              std::span<std::byte> message);
+
+  extern "C" void EncodeTokenizedMessage(const pw_tokenizer_Payload metadata,
+                                         const pw_tokenizer_Token token,
+                                         const pw_tokenizer_ArgTypes types,
+                                         ...) {
+    va_list args;
+    va_start(args, types);
+    pw::tokenizer::EncodedMessage encoded_message(token, types, args);
+    va_end(args);
+
+    HandleTokenizedMessage(metadata, encoded_message);
+  }
+
+.. admonition:: When to use a custom macro
+
+  Use existing tokenization macros whenever possible. A custom macro may be
+  needed to support use cases like the following:
+
+    * Variations of ``PW_TOKENIZE_TO_GLOBAL_HANDLER_WITH_PAYLOAD`` that take
+      different arguments.
+    * Supporting global handler macros that use different handler functions.
+
+Binary logging with pw_tokenizer
+--------------------------------
 String tokenization is perfect for logging. Consider the following log macro,
 which gathers the file, line number, and log message. It calls the ``RecordLog``
 function, which formats the log string, collects a timestamp, and transmits the
@@ -363,6 +452,8 @@
 calculated values will differ between C and C++ for strings longer than
 ``PW_TOKENIZER_CFG_C_HASH_LENGTH`` characters.
 
+.. _module-pw_tokenizer-domains:
+
 Tokenization domains
 --------------------
 ``pw_tokenizer`` supports having multiple tokenization domains. Domains are a
@@ -397,6 +488,101 @@
 See `Managing token databases`_ for information about the ``database.py``
 command line tool.
 
+Smaller tokens with masking
+---------------------------
+``pw_tokenizer`` uses 32-bit tokens. On 32-bit or 64-bit architectures, using
+fewer than 32 bits does not improve runtime or code size efficiency. However,
+when tokens are packed into data structures or stored in arrays, the size of the
+token directly affects memory usage. In those cases, every bit counts, and it
+may be desireable to use fewer bits for the token.
+
+``pw_tokenizer`` allows users to provide a mask to apply to the token. This
+masked token is used in both the token database and the code. The masked token
+is not a masked version of the full 32-bit token, the masked token is the token.
+This makes it trivial to decode tokens that use fewer than 32 bits.
+
+Masking functionality is provided through the ``*_MASK`` versions of the macros.
+For example, the following generates 16-bit tokens and packs them into an
+existing value.
+
+.. code-block:: cpp
+
+  constexpr uint32_t token = PW_TOKENIZE_STRING_MASK("domain", 0xFFFF, "Pigweed!");
+  uint32_t packed_word = (other_bits << 16) | token;
+
+Tokens are hashes, so tokens of any size have a collision risk. The fewer bits
+used for tokens, the more likely two strings are to hash to the same token. See
+`token collisions`_.
+
+Token collisions
+----------------
+Tokens are calculated with a hash function. It is possible for different
+strings to hash to the same token. When this happens, multiple strings will have
+the same token in the database, and it may not be possible to unambiguously
+decode a token.
+
+The detokenization tools attempt to resolve collisions automatically. Collisions
+are resolved based on two things:
+
+  - whether the tokenized data matches the strings arguments' (if any), and
+  - if / when the string was marked as having been removed from the database.
+
+Working with collisions
+^^^^^^^^^^^^^^^^^^^^^^^
+Collisions may occur occasionally. Run the command
+``python -m pw_tokenizer.database report <database>`` to see information about a
+token database, including any collisions.
+
+If there are collisions, take the following steps to resolve them.
+
+  - Change one of the colliding strings slightly to give it a new token.
+  - In C (not C++), artificial collisions may occur if strings longer than
+    ``PW_TOKENIZER_CFG_C_HASH_LENGTH`` are hashed. If this is happening,
+    consider setting ``PW_TOKENIZER_CFG_C_HASH_LENGTH`` to a larger value.
+    See ``pw_tokenizer/public/pw_tokenizer/config.h``.
+  - Run the ``mark_removed`` command with the latest version of the build
+    artifacts to mark missing strings as removed. This deprioritizes them in
+    collision resolution.
+
+    .. code-block:: sh
+
+      python -m pw_tokenizer.database mark_removed --database <database> <ELF files>
+
+    The ``purge`` command may be used to delete these tokens from the database.
+
+Probability of collisions
+^^^^^^^^^^^^^^^^^^^^^^^^^
+Hashes of any size have a collision risk. The probability of one at least
+one collision occurring for a given number of strings is unintuitively high
+(this is known as the `birthday problem
+<https://en.wikipedia.org/wiki/Birthday_problem>`_). If fewer than 32 bits are
+used for tokens, the probability of collisions increases substantially.
+
+This table shows the approximate number of strings that can be hashed to have a
+1% or 50% probability of at least one collision (assuming a uniform, random
+hash).
+
++-------+---------------------------------------+
+| Token | Collision probability by string count |
+| bits  +--------------------+------------------+
+|       |         50%        |          1%      |
++=======+====================+==================+
+|   32  |       77000        |        9300      |
++-------+--------------------+------------------+
+|   31  |       54000        |        6600      |
++-------+--------------------+------------------+
+|   24  |        4800        |         580      |
++-------+--------------------+------------------+
+|   16  |         300        |          36      |
++-------+--------------------+------------------+
+|    8  |          19        |           3      |
++-------+--------------------+------------------+
+
+Keep this table in mind when masking tokens (see `Smaller tokens with
+masking`_). 16 bits might be acceptable when tokenizing a small set of strings,
+such as module names, but won't be suitable for large sets of strings, like log
+messages.
+
 Token databases
 ===============
 Token databases store a mapping of tokens to the strings they represent. An ELF
@@ -500,22 +686,16 @@
 GN integration
 ^^^^^^^^^^^^^^
 Token databases may be updated or created as part of a GN build. The
-``pw_tokenizer_database`` template provided by ``dir_pw_tokenizer/database.gni``
-automatically updates an in-source tokenized strings database or creates a new
-database with artifacts from one or more GN targets or other database files.
+``pw_tokenizer_database`` template provided by
+``$dir_pw_tokenizer/database.gni`` automatically updates an in-source tokenized
+strings database or creates a new database with artifacts from one or more GN
+targets or other database files.
 
 To create a new database, set the ``create`` variable to the desired database
 type (``"csv"`` or ``"binary"``). The database will be created in the output
 directory. To update an existing database, provide the path to the database with
 the ``database`` variable.
 
-Each database in the source tree can only be updated from a single
-``pw_tokenizer_database`` rule. Updating the same database in multiple rules
-results in ``Duplicate output file`` GN errors or ``multiple rules generate
-<file>`` Ninja errors. To avoid these errors, ``pw_tokenizer_database`` rules
-should be defined in the default toolchain, and the input targets should be
-referenced with specific toolchains.
-
 .. code-block::
 
   import("//build_overrides/pigweed.gni")
@@ -528,6 +708,24 @@
     input_databases = [ "other_database.csv" ]
   }
 
+Instead of specifying GN targets, paths or globs to output files may be provided
+with the ``paths`` option.
+
+.. code-block::
+
+  pw_tokenizer_database("my_database") {
+    database = "database_in_the_source_tree.csv"
+    deps = [ ":apps" ]
+    optional_paths = [ "$root_build_dir/**/*.elf" ]
+  }
+
+.. note::
+
+  The ``paths`` and ``optional_targets`` arguments do not add anything to
+  ``deps``, so there is no guarantee that the referenced artifacts will exist
+  when the database is updated. Provide ``targets`` or ``deps`` or build other
+  GN targets first if this is a concern.
+
 Detokenization
 ==============
 Detokenization is the process of expanding a token to the string it represents
diff --git a/pw_tokenizer/encode_args.cc b/pw_tokenizer/encode_args.cc
index 1ab08ed..93cd6c4 100644
--- a/pw_tokenizer/encode_args.cc
+++ b/pw_tokenizer/encode_args.cc
@@ -12,7 +12,7 @@
 // License for the specific language governing permissions and limitations under
 // the License.
 
-#include "pw_tokenizer_private/encode_args.h"
+#include "pw_tokenizer/encode_args.h"
 
 #include <algorithm>
 #include <cstring>
@@ -38,15 +38,15 @@
 static_assert(0b10u == static_cast<uint8_t>(ArgType::kDouble));
 static_assert(0b11u == static_cast<uint8_t>(ArgType::kString));
 
-size_t EncodeInt(int value, const std::span<uint8_t>& output) {
+size_t EncodeInt(int value, const std::span<std::byte>& output) {
   return varint::Encode(value, std::as_writable_bytes(output));
 }
 
-size_t EncodeInt64(int64_t value, const std::span<uint8_t>& output) {
+size_t EncodeInt64(int64_t value, const std::span<std::byte>& output) {
   return varint::Encode(value, std::as_writable_bytes(output));
 }
 
-size_t EncodeFloat(float value, const std::span<uint8_t>& output) {
+size_t EncodeFloat(float value, const std::span<std::byte>& output) {
   if (output.size() < sizeof(value)) {
     return 0;
   }
@@ -54,7 +54,7 @@
   return sizeof(value);
 }
 
-size_t EncodeString(const char* string, const std::span<uint8_t>& output) {
+size_t EncodeString(const char* string, const std::span<std::byte>& output) {
   // The top bit of the status byte indicates if the string was truncated.
   static constexpr size_t kMaxStringLength = 0x7Fu;
 
@@ -71,17 +71,17 @@
 
   // Scan the string to find out how many bytes to copy.
   size_t bytes_to_copy = 0;
-  uint8_t overflow_bit = 0;
+  std::byte overflow_bit = std::byte(0);
 
   while (string[bytes_to_copy] != '\0') {
     if (bytes_to_copy == max_bytes) {
-      overflow_bit = '\x80';
+      overflow_bit = std::byte('\x80');
       break;
     }
     bytes_to_copy += 1;
   }
 
-  output[0] = bytes_to_copy | overflow_bit;
+  output[0] = static_cast<std::byte>(bytes_to_copy) | overflow_bit;
   std::memcpy(output.data() + 1, string, bytes_to_copy);
 
   return bytes_to_copy + 1;  // include the status byte in the total
@@ -89,9 +89,9 @@
 
 }  // namespace
 
-size_t EncodeArgs(_pw_tokenizer_ArgTypes types,
+size_t EncodeArgs(pw_tokenizer_ArgTypes types,
                   va_list args,
-                  std::span<uint8_t> output) {
+                  std::span<std::byte> output) {
   size_t arg_count = types & PW_TOKENIZER_TYPE_COUNT_MASK;
   types >>= PW_TOKENIZER_TYPE_COUNT_SIZE_BITS;
 
diff --git a/pw_tokenizer/global_handlers_test.cc b/pw_tokenizer/global_handlers_test.cc
index 6cac806..bef914e 100644
--- a/pw_tokenizer/global_handlers_test.cc
+++ b/pw_tokenizer/global_handlers_test.cc
@@ -25,15 +25,17 @@
 namespace {
 
 // Constructs an array with the hashed string followed by the provided bytes.
-template <uint8_t... kData, size_t kSize>
-constexpr auto ExpectedData(const char (&format)[kSize]) {
-  const uint32_t value = Hash(format);
-  return std::array<uint8_t, sizeof(uint32_t) + sizeof...(kData)>{
+template <uint8_t... data, size_t kSize>
+constexpr auto ExpectedData(
+    const char (&format)[kSize],
+    uint32_t token_mask = std::numeric_limits<uint32_t>::max()) {
+  const uint32_t value = Hash(format) & token_mask;
+  return std::array<uint8_t, sizeof(uint32_t) + sizeof...(data)>{
       static_cast<uint8_t>(value & 0xff),
       static_cast<uint8_t>(value >> 8 & 0xff),
       static_cast<uint8_t>(value >> 16 & 0xff),
       static_cast<uint8_t>(value >> 24 & 0xff),
-      kData...};
+      data...};
 }
 
 // Test fixture for both global handler functions. Both need a global message
@@ -91,6 +93,15 @@
   EXPECT_EQ(std::memcmp(expected.data(), message_, expected.size()), 0);
 }
 
+TEST_F(TokenizeToGlobalHandler, Mask) {
+  PW_TOKENIZE_TO_GLOBAL_HANDLER_MASK(
+      "TEST_DOMAIN", 0x00FFF000, "The answer is: %s", "5432!");
+  constexpr std::array<uint8_t, 10> expected =
+      ExpectedData<5, '5', '4', '3', '2', '!'>("The answer is: %s", 0x00FFF000);
+  ASSERT_EQ(expected.size(), message_size_bytes_);
+  EXPECT_EQ(std::memcmp(expected.data(), message_, expected.size()), 0);
+}
+
 TEST_F(TokenizeToGlobalHandler, C_SequentialZigZag) {
   pw_tokenizer_ToGlobalHandlerTest_SequentialZigZag();
 
@@ -181,6 +192,20 @@
   EXPECT_EQ(payload_, 5432);
 }
 
+TEST_F(TokenizeToGlobalHandlerWithPayload, Mask) {
+  PW_TOKENIZE_TO_GLOBAL_HANDLER_WITH_PAYLOAD_MASK(
+      "TEST_DOMAIN",
+      0x12345678,
+      static_cast<pw_tokenizer_Payload>(5432),
+      "The answer is: %s",
+      "5432!");
+  constexpr std::array<uint8_t, 10> expected =
+      ExpectedData<5, '5', '4', '3', '2', '!'>("The answer is: %s", 0x12345678);
+  ASSERT_EQ(expected.size(), message_size_bytes_);
+  EXPECT_EQ(std::memcmp(expected.data(), message_, expected.size()), 0);
+  EXPECT_EQ(payload_, 5432);
+}
+
 struct Foo {
   unsigned char a;
   bool b;
diff --git a/pw_tokenizer/hash_test.cc b/pw_tokenizer/hash_test.cc
index de6681e..f6af14e 100644
--- a/pw_tokenizer/hash_test.cc
+++ b/pw_tokenizer/hash_test.cc
@@ -22,6 +22,7 @@
 #include "gtest/gtest.h"
 #include "pw_preprocessor/util.h"
 #include "pw_tokenizer/internal/pw_tokenizer_65599_fixed_length_128_hash_macro.h"
+#include "pw_tokenizer/internal/pw_tokenizer_65599_fixed_length_256_hash_macro.h"
 #include "pw_tokenizer/internal/pw_tokenizer_65599_fixed_length_80_hash_macro.h"
 #include "pw_tokenizer/internal/pw_tokenizer_65599_fixed_length_96_hash_macro.h"
 #include "pw_tokenizer_private/generated_hash_test_cases.h"
@@ -58,10 +59,10 @@
 
 // Gets the size of the string, excluding the null terminator. A uint32_t is
 // used instead of a size_t since the hash calculation requires a uint32_t.
-template <uint32_t size_with_null>
-constexpr uint32_t StringLength(const char (&)[size_with_null]) {
-  static_assert(size_with_null > 0u);
-  return size_with_null - 1;  // subtract the null terminator
+template <uint32_t kSizeWithNull>
+constexpr uint32_t StringLength(const char (&)[kSizeWithNull]) {
+  static_assert(kSizeWithNull > 0u);
+  return kSizeWithNull - 1;  // subtract the null terminator
 }
 
 TEST(Hashing, Runtime) PW_NO_SANITIZE("unsigned-integer-overflow") {
@@ -89,33 +90,28 @@
             StringLength("123456") + k2 * '1' + k3 * '2' + k4 * '3' + k5 * '4');
 }
 
+#define _CHECK_HASH_LENGTH(string, length)                                   \
+  static_assert(PwTokenizer65599FixedLengthHash(                             \
+                    std::string_view(string, sizeof(string) - 1), length) == \
+                    PW_TOKENIZER_65599_FIXED_LENGTH_##length##_HASH(string), \
+                #length "-byte hash mismatch!")
+
 // Use std::string_view so that \0 can appear in strings.
-#define TEST_SUPPORTED_HASHES(string_literal)                                  \
-  static_assert(                                                               \
-      PwTokenizer65599FixedLengthHash(                                         \
-          std::string_view(string_literal, sizeof(string_literal) - 1), 80) == \
-          PW_TOKENIZER_65599_FIXED_LENGTH_80_HASH(string_literal),             \
-      "80-byte hash mismatch!");                                               \
-  static_assert(                                                               \
-      PwTokenizer65599FixedLengthHash(                                         \
-          std::string_view(string_literal, sizeof(string_literal) - 1), 96) == \
-          PW_TOKENIZER_65599_FIXED_LENGTH_96_HASH(string_literal),             \
-      "96-byte hash mismatch!");                                               \
-  static_assert(                                                               \
-      PwTokenizer65599FixedLengthHash(                                         \
-          std::string_view(string_literal, sizeof(string_literal) - 1),        \
-          128) == PW_TOKENIZER_65599_FIXED_LENGTH_128_HASH(string_literal),    \
-      "128-byte hash mismatch!");                                              \
-  static_assert(                                                               \
-      PwTokenizer65599FixedLengthHash(                                         \
-          std::string_view(string_literal, sizeof(string_literal) - 1),        \
-          sizeof(string_literal) - 1) == Hash(string_literal),                 \
-      "Hash function mismatch!");                                              \
-  EXPECT_EQ(PwTokenizer65599FixedLengthHash(                                   \
-                std::string_view(string_literal, sizeof(string_literal) - 1),  \
-                sizeof(string_literal) - 1),                                   \
-            pw_tokenizer_65599FixedLengthHash(string_literal,                  \
-                                              sizeof(string_literal) - 1,      \
+#define TEST_SUPPORTED_HASHES(string_literal)                                 \
+  _CHECK_HASH_LENGTH(string_literal, 80);                                     \
+  _CHECK_HASH_LENGTH(string_literal, 96);                                     \
+  _CHECK_HASH_LENGTH(string_literal, 128);                                    \
+  _CHECK_HASH_LENGTH(string_literal, 256);                                    \
+  static_assert(                                                              \
+      PwTokenizer65599FixedLengthHash(                                        \
+          std::string_view(string_literal, sizeof(string_literal) - 1),       \
+          sizeof(string_literal) - 1) == Hash(string_literal),                \
+      "Hash function mismatch!");                                             \
+  EXPECT_EQ(PwTokenizer65599FixedLengthHash(                                  \
+                std::string_view(string_literal, sizeof(string_literal) - 1), \
+                sizeof(string_literal) - 1),                                  \
+            pw_tokenizer_65599FixedLengthHash(string_literal,                 \
+                                              sizeof(string_literal) - 1,     \
                                               sizeof(string_literal) - 1))
 
 TEST(HashMacro, Empty) { TEST_SUPPORTED_HASHES(""); }
diff --git a/pw_tokenizer/public/pw_tokenizer/encode_args.h b/pw_tokenizer/public/pw_tokenizer/encode_args.h
new file mode 100644
index 0000000..19f4e88
--- /dev/null
+++ b/pw_tokenizer/public/pw_tokenizer/encode_args.h
@@ -0,0 +1,94 @@
+// 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.
+#pragma once
+
+#include <cstdarg>
+#include <cstddef>
+#include <cstring>
+#include <span>
+
+#include "pw_tokenizer/config.h"
+#include "pw_tokenizer/internal/argument_types.h"
+#include "pw_tokenizer/tokenize.h"
+
+namespace pw {
+namespace tokenizer {
+
+// Encodes a tokenized string's arguments to a buffer. The
+// pw_tokenizer_ArgTypes parameter specifies the argument types, in place of a
+// format string.
+//
+// Most tokenization implementations may use the EncodedMessage class below.
+size_t EncodeArgs(pw_tokenizer_ArgTypes types,
+                  va_list args,
+                  std::span<std::byte> output);
+
+// Encodes a tokenized message to a fixed size buffer. The size of the buffer is
+// determined by the PW_TOKENIZER_CFG_ENCODING_BUFFER_SIZE_BYTES config macro.
+//
+// This class is used to encode tokenized messages passed in from the
+// tokenization macros. The macros provided by pw_tokenizer use this class, and
+// projects that elect to define their own versions of the tokenization macros
+// should use it when possible.
+//
+// To use the pw::Tokenizer::EncodedMessage, construct it with the token,
+// argument types, and va_list from the variadic arguments:
+//
+//   void SendLogMessage(std::span<std::byte> log_data);
+//
+//   extern "C" void TokenizeToSendLogMessage(pw_tokenizer_Token token,
+//                                            pw_tokenizer_ArgTypes types,
+//                                            ...) {
+//     va_list args;
+//     va_start(args, types);
+//     EncodedMessage encoded_message(token, types, args);
+//     va_end(args);
+//
+//     SendLogMessage(encoded_message);  // EncodedMessage converts to std::span
+//   }
+//
+class EncodedMessage {
+ public:
+  // Encodes a tokenized message to an internal buffer.
+  EncodedMessage(pw_tokenizer_Token token,
+                 pw_tokenizer_ArgTypes types,
+                 va_list args) {
+    std::memcpy(data_, &token, sizeof(token));
+    args_size_ = EncodeArgs(
+        types, args, std::span<std::byte>(data_).subspan(sizeof(token)));
+  }
+
+  // The binary-encoded tokenized message.
+  const std::byte* data() const { return data_; }
+
+  // Returns the data() as a pointer to uint8_t instead of std::byte.
+  const uint8_t* data_as_uint8() const {
+    return reinterpret_cast<const uint8_t*>(data());
+  }
+
+  // The size of the encoded tokenized message in bytes.
+  size_t size() const { return sizeof(pw_tokenizer_Token) + args_size_; }
+
+ private:
+  std::byte data_[PW_TOKENIZER_CFG_ENCODING_BUFFER_SIZE_BYTES];
+  size_t args_size_;
+};
+
+static_assert(PW_TOKENIZER_CFG_ENCODING_BUFFER_SIZE_BYTES >=
+                  sizeof(pw_tokenizer_Token),
+              "PW_TOKENIZER_CFG_ENCODING_BUFFER_SIZE_BYTES must be at least "
+              "large enough for a token (4 bytes)");
+
+}  // namespace tokenizer
+}  // namespace pw
diff --git a/pw_tokenizer/public/pw_tokenizer/hash.h b/pw_tokenizer/public/pw_tokenizer/hash.h
index 2cd8cf3..f97d32f 100644
--- a/pw_tokenizer/public/pw_tokenizer/hash.h
+++ b/pw_tokenizer/public/pw_tokenizer/hash.h
@@ -69,10 +69,10 @@
 
 // Take the string as an array to support either literals or character arrays,
 // but not const char*.
-template <size_t size>
-constexpr uint32_t Hash(const char (&string)[size]) {
-  static_assert(size > 0);
-  return Hash(std::string_view(string, size - 1));
+template <size_t kSize>
+constexpr uint32_t Hash(const char (&string)[kSize]) {
+  static_assert(kSize > 0);
+  return Hash(std::string_view(string, kSize - 1));
 }
 
 // This hash function is equivalent to the C hashing macros. It hashses a string
@@ -93,12 +93,12 @@
 }
 
 // Character array version of PwTokenizer65599FixedLengthHash.
-template <size_t size>
+template <size_t kSize>
 constexpr uint32_t PwTokenizer65599FixedLengthHash(
-    const char (&string)[size],
+    const char (&string)[kSize],
     size_t hash_length = PW_TOKENIZER_CFG_C_HASH_LENGTH) {
-  static_assert(size > 0);
-  return PwTokenizer65599FixedLengthHash(std::string_view(string, size - 1),
+  static_assert(kSize > 0);
+  return PwTokenizer65599FixedLengthHash(std::string_view(string, kSize - 1),
                                          hash_length);
 }
 
diff --git a/pw_tokenizer/public/pw_tokenizer/internal/argument_types.h b/pw_tokenizer/public/pw_tokenizer/internal/argument_types.h
index 1f431e4..79f631d 100644
--- a/pw_tokenizer/public/pw_tokenizer/internal/argument_types.h
+++ b/pw_tokenizer/public/pw_tokenizer/internal/argument_types.h
@@ -31,7 +31,7 @@
 #define PW_TOKENIZER_TYPE_COUNT_SIZE_BITS 4u
 #define PW_TOKENIZER_TYPE_COUNT_MASK 0x0Fu
 
-typedef uint32_t _pw_tokenizer_ArgTypes;
+typedef uint32_t pw_tokenizer_ArgTypes;
 
 #elif PW_TOKENIZER_CFG_ARG_TYPES_SIZE_BYTES == 8
 
@@ -42,7 +42,7 @@
 #define PW_TOKENIZER_TYPE_COUNT_SIZE_BITS 6u
 #define PW_TOKENIZER_TYPE_COUNT_MASK 0x1Fu  // only 5 bits will be needed
 
-typedef uint64_t _pw_tokenizer_ArgTypes;
+typedef uint64_t pw_tokenizer_ArgTypes;
 
 #else
 
@@ -52,7 +52,7 @@
 
 // The tokenized string encoding function is a variadic function that works
 // similarly to printf. Instead of a format string, however, the argument types
-// are packed into a _pw_tokenizer_ArgTypes.
+// are packed into a pw_tokenizer_ArgTypes.
 //
 // The four supported argument types are represented by two-bit argument codes.
 // Just four types are required because only printf-compatible arguments are
@@ -62,10 +62,10 @@
 // char* values cannot be printed as pointers with %p. These arguments are
 // always encoded as strings. To format a char* as an address, cast it to void*
 // or an integer.
-#define PW_TOKENIZER_ARG_TYPE_INT ((_pw_tokenizer_ArgTypes)0)
-#define PW_TOKENIZER_ARG_TYPE_INT64 ((_pw_tokenizer_ArgTypes)1)
-#define PW_TOKENIZER_ARG_TYPE_DOUBLE ((_pw_tokenizer_ArgTypes)2)
-#define PW_TOKENIZER_ARG_TYPE_STRING ((_pw_tokenizer_ArgTypes)3)
+#define PW_TOKENIZER_ARG_TYPE_INT ((pw_tokenizer_ArgTypes)0)
+#define PW_TOKENIZER_ARG_TYPE_INT64 ((pw_tokenizer_ArgTypes)1)
+#define PW_TOKENIZER_ARG_TYPE_DOUBLE ((pw_tokenizer_ArgTypes)2)
+#define PW_TOKENIZER_ARG_TYPE_STRING ((pw_tokenizer_ArgTypes)3)
 
 // Select the int argument type based on the size of the type. Values smaller
 // than int are promoted to int.
@@ -89,7 +89,7 @@
 
 // This function selects the matching type enum for supported argument types.
 template <typename T>
-constexpr _pw_tokenizer_ArgTypes VarargsType() {
+constexpr pw_tokenizer_ArgTypes VarargsType() {
   using ArgType = std::decay_t<T>;
 
   if constexpr (std::is_floating_point<ArgType>()) {
@@ -116,26 +116,26 @@
 
 template <typename T, bool kDontCare1, bool kDontCare2>
 struct SelectVarargsType<T, true, kDontCare1, kDontCare2> {
-  static constexpr _pw_tokenizer_ArgTypes kValue = PW_TOKENIZER_ARG_TYPE_DOUBLE;
+  static constexpr pw_tokenizer_ArgTypes kValue = PW_TOKENIZER_ARG_TYPE_DOUBLE;
 };
 
 template <typename T, bool kDontCare>
 struct SelectVarargsType<T, false, true, kDontCare> {
-  static constexpr _pw_tokenizer_ArgTypes kValue = PW_TOKENIZER_ARG_TYPE_STRING;
+  static constexpr pw_tokenizer_ArgTypes kValue = PW_TOKENIZER_ARG_TYPE_STRING;
 };
 
 template <typename T>
 struct SelectVarargsType<T, false, false, true> {
-  static constexpr _pw_tokenizer_ArgTypes kValue = PW_TOKENIZER_ARG_TYPE_INT64;
+  static constexpr pw_tokenizer_ArgTypes kValue = PW_TOKENIZER_ARG_TYPE_INT64;
 };
 
 template <typename T>
 struct SelectVarargsType<T, false, false, false> {
-  static constexpr _pw_tokenizer_ArgTypes kValue = PW_TOKENIZER_ARG_TYPE_INT;
+  static constexpr pw_tokenizer_ArgTypes kValue = PW_TOKENIZER_ARG_TYPE_INT;
 };
 
 template <typename T>
-constexpr _pw_tokenizer_ArgTypes VarargsType() {
+constexpr pw_tokenizer_ArgTypes VarargsType() {
   return SelectVarargsType<typename std::decay<T>::type>::kValue;
 }
 
@@ -174,8 +174,8 @@
 
 #endif  // __cplusplus
 
-// Encodes the types of the provided arguments as a _pw_tokenizer_ArgTypes
-// value. Depending on the size of _pw_tokenizer_ArgTypes, the bottom 4 or 6
+// Encodes the types of the provided arguments as a pw_tokenizer_ArgTypes
+// value. Depending on the size of pw_tokenizer_ArgTypes, the bottom 4 or 6
 // bits store the number of arguments and the remaining bits store the types,
 // two bits per type.
 //
@@ -184,4 +184,4 @@
 #define PW_TOKENIZER_ARG_TYPES(...) \
   PW_DELEGATE_BY_ARG_COUNT(_PW_TOKENIZER_TYPES_, __VA_ARGS__)
 
-#define _PW_TOKENIZER_TYPES_0() ((_pw_tokenizer_ArgTypes)0)
+#define _PW_TOKENIZER_TYPES_0() ((pw_tokenizer_ArgTypes)0)
diff --git a/pw_tokenizer/public/pw_tokenizer/internal/pw_tokenizer_65599_fixed_length_128_hash_macro.h b/pw_tokenizer/public/pw_tokenizer/internal/pw_tokenizer_65599_fixed_length_128_hash_macro.h
index 1c4eb82..27411d1 100644
--- a/pw_tokenizer/public/pw_tokenizer/internal/pw_tokenizer_65599_fixed_length_128_hash_macro.h
+++ b/pw_tokenizer/public/pw_tokenizer/internal/pw_tokenizer_65599_fixed_length_128_hash_macro.h
@@ -1,4 +1,4 @@
-// Copyright 2020 The Pigweed Authors
+// Copyright 2021 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
diff --git a/pw_tokenizer/public/pw_tokenizer/internal/pw_tokenizer_65599_fixed_length_256_hash_macro.h b/pw_tokenizer/public/pw_tokenizer/internal/pw_tokenizer_65599_fixed_length_256_hash_macro.h
new file mode 100644
index 0000000..d713bae
--- /dev/null
+++ b/pw_tokenizer/public/pw_tokenizer/internal/pw_tokenizer_65599_fixed_length_256_hash_macro.h
@@ -0,0 +1,289 @@
+// Copyright 2021 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.
+
+// AUTOGENERATED - DO NOT EDIT
+//
+// This file was generated by generate_hash_macro.py.
+// To make changes, update the script and run it to regenerate the files.
+#pragma once
+
+#include <stdint.h>
+
+// 256-character version of the tokenizer hash function.
+//
+// The argument must be a string literal. It is concatenated with "" to ensure
+// that this is the case.
+//
+// clang-format off
+
+#define PW_TOKENIZER_65599_FIXED_LENGTH_256_HASH(str)                          \
+  (uint32_t)(sizeof(str "") - 1 + /* The argument must be a string literal. */ \
+             0x0001003fu * (uint8_t)str[0] +                                   \
+             0x007e0f81u * (uint8_t)(  1 < sizeof(str) ? str[  1] : 0) +       \
+             0x2e86d0bfu * (uint8_t)(  2 < sizeof(str) ? str[  2] : 0) +       \
+             0x43ec5f01u * (uint8_t)(  3 < sizeof(str) ? str[  3] : 0) +       \
+             0x162c613fu * (uint8_t)(  4 < sizeof(str) ? str[  4] : 0) +       \
+             0xd62aee81u * (uint8_t)(  5 < sizeof(str) ? str[  5] : 0) +       \
+             0xa311b1bfu * (uint8_t)(  6 < sizeof(str) ? str[  6] : 0) +       \
+             0xd319be01u * (uint8_t)(  7 < sizeof(str) ? str[  7] : 0) +       \
+             0xb156c23fu * (uint8_t)(  8 < sizeof(str) ? str[  8] : 0) +       \
+             0x6698cd81u * (uint8_t)(  9 < sizeof(str) ? str[  9] : 0) +       \
+             0x0d1b92bfu * (uint8_t)( 10 < sizeof(str) ? str[ 10] : 0) +       \
+             0xcc881d01u * (uint8_t)( 11 < sizeof(str) ? str[ 11] : 0) +       \
+             0x7280233fu * (uint8_t)( 12 < sizeof(str) ? str[ 12] : 0) +       \
+             0x50c7ac81u * (uint8_t)( 13 < sizeof(str) ? str[ 13] : 0) +       \
+             0x8da473bfu * (uint8_t)( 14 < sizeof(str) ? str[ 14] : 0) +       \
+             0x4f377c01u * (uint8_t)( 15 < sizeof(str) ? str[ 15] : 0) +       \
+             0xfaa8843fu * (uint8_t)( 16 < sizeof(str) ? str[ 16] : 0) +       \
+             0x33b78b81u * (uint8_t)( 17 < sizeof(str) ? str[ 17] : 0) +       \
+             0x45ac54bfu * (uint8_t)( 18 < sizeof(str) ? str[ 18] : 0) +       \
+             0x7a27db01u * (uint8_t)( 19 < sizeof(str) ? str[ 19] : 0) +       \
+             0xeacfe53fu * (uint8_t)( 20 < sizeof(str) ? str[ 20] : 0) +       \
+             0xae686a81u * (uint8_t)( 21 < sizeof(str) ? str[ 21] : 0) +       \
+             0x563335bfu * (uint8_t)( 22 < sizeof(str) ? str[ 22] : 0) +       \
+             0x6c593a01u * (uint8_t)( 23 < sizeof(str) ? str[ 23] : 0) +       \
+             0xe3f6463fu * (uint8_t)( 24 < sizeof(str) ? str[ 24] : 0) +       \
+             0x5fda4981u * (uint8_t)( 25 < sizeof(str) ? str[ 25] : 0) +       \
+             0xe03916bfu * (uint8_t)( 26 < sizeof(str) ? str[ 26] : 0) +       \
+             0x44cb9901u * (uint8_t)( 27 < sizeof(str) ? str[ 27] : 0) +       \
+             0x871ba73fu * (uint8_t)( 28 < sizeof(str) ? str[ 28] : 0) +       \
+             0xe70d2881u * (uint8_t)( 29 < sizeof(str) ? str[ 29] : 0) +       \
+             0x04bdf7bfu * (uint8_t)( 30 < sizeof(str) ? str[ 30] : 0) +       \
+             0x227ef801u * (uint8_t)( 31 < sizeof(str) ? str[ 31] : 0) +       \
+             0x7540083fu * (uint8_t)( 32 < sizeof(str) ? str[ 32] : 0) +       \
+             0xe3010781u * (uint8_t)( 33 < sizeof(str) ? str[ 33] : 0) +       \
+             0xe4c1d8bfu * (uint8_t)( 34 < sizeof(str) ? str[ 34] : 0) +       \
+             0x24735701u * (uint8_t)( 35 < sizeof(str) ? str[ 35] : 0) +       \
+             0x4f63693fu * (uint8_t)( 36 < sizeof(str) ? str[ 36] : 0) +       \
+             0xf2b5e681u * (uint8_t)( 37 < sizeof(str) ? str[ 37] : 0) +       \
+             0xa144b9bfu * (uint8_t)( 38 < sizeof(str) ? str[ 38] : 0) +       \
+             0x69a8b601u * (uint8_t)( 39 < sizeof(str) ? str[ 39] : 0) +       \
+             0xb685ca3fu * (uint8_t)( 40 < sizeof(str) ? str[ 40] : 0) +       \
+             0xb52bc581u * (uint8_t)( 41 < sizeof(str) ? str[ 41] : 0) +       \
+             0x5b469abfu * (uint8_t)( 42 < sizeof(str) ? str[ 42] : 0) +       \
+             0x111f1501u * (uint8_t)( 43 < sizeof(str) ? str[ 43] : 0) +       \
+             0x4ba72b3fu * (uint8_t)( 44 < sizeof(str) ? str[ 44] : 0) +       \
+             0xc962a481u * (uint8_t)( 45 < sizeof(str) ? str[ 45] : 0) +       \
+             0x33c77bbfu * (uint8_t)( 46 < sizeof(str) ? str[ 46] : 0) +       \
+             0x39d67401u * (uint8_t)( 47 < sizeof(str) ? str[ 47] : 0) +       \
+             0xafc78c3fu * (uint8_t)( 48 < sizeof(str) ? str[ 48] : 0) +       \
+             0xce5a8381u * (uint8_t)( 49 < sizeof(str) ? str[ 49] : 0) +       \
+             0x4bc75cbfu * (uint8_t)( 50 < sizeof(str) ? str[ 50] : 0) +       \
+             0x02ced301u * (uint8_t)( 51 < sizeof(str) ? str[ 51] : 0) +       \
+             0x83e6ed3fu * (uint8_t)( 52 < sizeof(str) ? str[ 52] : 0) +       \
+             0x63136281u * (uint8_t)( 53 < sizeof(str) ? str[ 53] : 0) +       \
+             0xc4463dbfu * (uint8_t)( 54 < sizeof(str) ? str[ 54] : 0) +       \
+             0x8b083201u * (uint8_t)( 55 < sizeof(str) ? str[ 55] : 0) +       \
+             0x69054e3fu * (uint8_t)( 56 < sizeof(str) ? str[ 56] : 0) +       \
+             0x268d4181u * (uint8_t)( 57 < sizeof(str) ? str[ 57] : 0) +       \
+             0xbe441ebfu * (uint8_t)( 58 < sizeof(str) ? str[ 58] : 0) +       \
+             0xf1829101u * (uint8_t)( 59 < sizeof(str) ? str[ 59] : 0) +       \
+             0x0022af3fu * (uint8_t)( 60 < sizeof(str) ? str[ 60] : 0) +       \
+             0xb7c82081u * (uint8_t)( 61 < sizeof(str) ? str[ 61] : 0) +       \
+             0x5ac0ffbfu * (uint8_t)( 62 < sizeof(str) ? str[ 62] : 0) +       \
+             0x553df001u * (uint8_t)( 63 < sizeof(str) ? str[ 63] : 0) +       \
+             0xea3f103fu * (uint8_t)( 64 < sizeof(str) ? str[ 64] : 0) +       \
+             0xb5c3ff81u * (uint8_t)( 65 < sizeof(str) ? str[ 65] : 0) +       \
+             0xbabce0bfu * (uint8_t)( 66 < sizeof(str) ? str[ 66] : 0) +       \
+             0xd53a4f01u * (uint8_t)( 67 < sizeof(str) ? str[ 67] : 0) +       \
+             0xc85a713fu * (uint8_t)( 68 < sizeof(str) ? str[ 68] : 0) +       \
+             0xbf80de81u * (uint8_t)( 69 < sizeof(str) ? str[ 69] : 0) +       \
+             0xff37c1bfu * (uint8_t)( 70 < sizeof(str) ? str[ 70] : 0) +       \
+             0x9077ae01u * (uint8_t)( 71 < sizeof(str) ? str[ 71] : 0) +       \
+             0x3b74d23fu * (uint8_t)( 72 < sizeof(str) ? str[ 72] : 0) +       \
+             0x73febd81u * (uint8_t)( 73 < sizeof(str) ? str[ 73] : 0) +       \
+             0x4931a2bfu * (uint8_t)( 74 < sizeof(str) ? str[ 74] : 0) +       \
+             0xa5f60d01u * (uint8_t)( 75 < sizeof(str) ? str[ 75] : 0) +       \
+             0xe48e333fu * (uint8_t)( 76 < sizeof(str) ? str[ 76] : 0) +       \
+             0x723d9c81u * (uint8_t)( 77 < sizeof(str) ? str[ 77] : 0) +       \
+             0xb9aa83bfu * (uint8_t)( 78 < sizeof(str) ? str[ 78] : 0) +       \
+             0x34b56c01u * (uint8_t)( 79 < sizeof(str) ? str[ 79] : 0) +       \
+             0x64a6943fu * (uint8_t)( 80 < sizeof(str) ? str[ 80] : 0) +       \
+             0x593d7b81u * (uint8_t)( 81 < sizeof(str) ? str[ 81] : 0) +       \
+             0x71a264bfu * (uint8_t)( 82 < sizeof(str) ? str[ 82] : 0) +       \
+             0x5bb5cb01u * (uint8_t)( 83 < sizeof(str) ? str[ 83] : 0) +       \
+             0x5cbdf53fu * (uint8_t)( 84 < sizeof(str) ? str[ 84] : 0) +       \
+             0xc7fe5a81u * (uint8_t)( 85 < sizeof(str) ? str[ 85] : 0) +       \
+             0x921945bfu * (uint8_t)( 86 < sizeof(str) ? str[ 86] : 0) +       \
+             0x39f72a01u * (uint8_t)( 87 < sizeof(str) ? str[ 87] : 0) +       \
+             0x6dd4563fu * (uint8_t)( 88 < sizeof(str) ? str[ 88] : 0) +       \
+             0x5d803981u * (uint8_t)( 89 < sizeof(str) ? str[ 89] : 0) +       \
+             0x3c0f26bfu * (uint8_t)( 90 < sizeof(str) ? str[ 90] : 0) +       \
+             0xee798901u * (uint8_t)( 91 < sizeof(str) ? str[ 91] : 0) +       \
+             0x38e9b73fu * (uint8_t)( 92 < sizeof(str) ? str[ 92] : 0) +       \
+             0xb8c31881u * (uint8_t)( 93 < sizeof(str) ? str[ 93] : 0) +       \
+             0x908407bfu * (uint8_t)( 94 < sizeof(str) ? str[ 94] : 0) +       \
+             0x983ce801u * (uint8_t)( 95 < sizeof(str) ? str[ 95] : 0) +       \
+             0x5efe183fu * (uint8_t)( 96 < sizeof(str) ? str[ 96] : 0) +       \
+             0x78c6f781u * (uint8_t)( 97 < sizeof(str) ? str[ 97] : 0) +       \
+             0xb077e8bfu * (uint8_t)( 98 < sizeof(str) ? str[ 98] : 0) +       \
+             0x56414701u * (uint8_t)( 99 < sizeof(str) ? str[ 99] : 0) +       \
+             0x8111793fu * (uint8_t)(100 < sizeof(str) ? str[100] : 0) +       \
+             0x3c8bd681u * (uint8_t)(101 < sizeof(str) ? str[101] : 0) +       \
+             0xbceac9bfu * (uint8_t)(102 < sizeof(str) ? str[102] : 0) +       \
+             0x4786a601u * (uint8_t)(103 < sizeof(str) ? str[103] : 0) +       \
+             0x4023da3fu * (uint8_t)(104 < sizeof(str) ? str[104] : 0) +       \
+             0xa311b581u * (uint8_t)(105 < sizeof(str) ? str[105] : 0) +       \
+             0xd6dcaabfu * (uint8_t)(106 < sizeof(str) ? str[106] : 0) +       \
+             0x8b0d0501u * (uint8_t)(107 < sizeof(str) ? str[107] : 0) +       \
+             0x3d353b3fu * (uint8_t)(108 < sizeof(str) ? str[108] : 0) +       \
+             0x4b589481u * (uint8_t)(109 < sizeof(str) ? str[109] : 0) +       \
+             0x1f4d8bbfu * (uint8_t)(110 < sizeof(str) ? str[110] : 0) +       \
+             0x3fd46401u * (uint8_t)(111 < sizeof(str) ? str[111] : 0) +       \
+             0x19459c3fu * (uint8_t)(112 < sizeof(str) ? str[112] : 0) +       \
+             0xd4607381u * (uint8_t)(113 < sizeof(str) ? str[113] : 0) +       \
+             0xb73d6cbfu * (uint8_t)(114 < sizeof(str) ? str[114] : 0) +       \
+             0x84dcc301u * (uint8_t)(115 < sizeof(str) ? str[115] : 0) +       \
+             0x7554fd3fu * (uint8_t)(116 < sizeof(str) ? str[116] : 0) +       \
+             0xdd295281u * (uint8_t)(117 < sizeof(str) ? str[117] : 0) +       \
+             0xbfac4dbfu * (uint8_t)(118 < sizeof(str) ? str[118] : 0) +       \
+             0x79262201u * (uint8_t)(119 < sizeof(str) ? str[119] : 0) +       \
+             0xf2635e3fu * (uint8_t)(120 < sizeof(str) ? str[120] : 0) +       \
+             0x04b33181u * (uint8_t)(121 < sizeof(str) ? str[121] : 0) +       \
+             0x599a2ebfu * (uint8_t)(122 < sizeof(str) ? str[122] : 0) +       \
+             0x3bb08101u * (uint8_t)(123 < sizeof(str) ? str[123] : 0) +       \
+             0x3170bf3fu * (uint8_t)(124 < sizeof(str) ? str[124] : 0) +       \
+             0xe9fe1081u * (uint8_t)(125 < sizeof(str) ? str[125] : 0) +       \
+             0xa6070fbfu * (uint8_t)(126 < sizeof(str) ? str[126] : 0) +       \
+             0xeb7be001u * (uint8_t)(127 < sizeof(str) ? str[127] : 0) +       \
+             0xd37d203fu * (uint8_t)(128 < sizeof(str) ? str[128] : 0) +       \
+             0x2c09ef81u * (uint8_t)(129 < sizeof(str) ? str[129] : 0) +       \
+             0xc5f2f0bfu * (uint8_t)(130 < sizeof(str) ? str[130] : 0) +       \
+             0xa7883f01u * (uint8_t)(131 < sizeof(str) ? str[131] : 0) +       \
+             0x7988813fu * (uint8_t)(132 < sizeof(str) ? str[132] : 0) +       \
+             0x69d6ce81u * (uint8_t)(133 < sizeof(str) ? str[133] : 0) +       \
+             0xda5dd1bfu * (uint8_t)(134 < sizeof(str) ? str[134] : 0) +       \
+             0x8ed59e01u * (uint8_t)(135 < sizeof(str) ? str[135] : 0) +       \
+             0xc492e23fu * (uint8_t)(136 < sizeof(str) ? str[136] : 0) +       \
+             0x4264ad81u * (uint8_t)(137 < sizeof(str) ? str[137] : 0) +       \
+             0x0447b2bfu * (uint8_t)(138 < sizeof(str) ? str[138] : 0) +       \
+             0xc063fd01u * (uint8_t)(139 < sizeof(str) ? str[139] : 0) +       \
+             0x559c433fu * (uint8_t)(140 < sizeof(str) ? str[140] : 0) +       \
+             0x54b38c81u * (uint8_t)(141 < sizeof(str) ? str[141] : 0) +       \
+             0x64b093bfu * (uint8_t)(142 < sizeof(str) ? str[142] : 0) +       \
+             0x5b335c01u * (uint8_t)(143 < sizeof(str) ? str[143] : 0) +       \
+             0xcda4a43fu * (uint8_t)(144 < sizeof(str) ? str[144] : 0) +       \
+             0x3fc36b81u * (uint8_t)(145 < sizeof(str) ? str[145] : 0) +       \
+             0x1c9874bfu * (uint8_t)(146 < sizeof(str) ? str[146] : 0) +       \
+             0x7e43bb01u * (uint8_t)(147 < sizeof(str) ? str[147] : 0) +       \
+             0xcdac053fu * (uint8_t)(148 < sizeof(str) ? str[148] : 0) +       \
+             0xa2944a81u * (uint8_t)(149 < sizeof(str) ? str[149] : 0) +       \
+             0x4cff55bfu * (uint8_t)(150 < sizeof(str) ? str[150] : 0) +       \
+             0x48951a01u * (uint8_t)(151 < sizeof(str) ? str[151] : 0) +       \
+             0xf6b2663fu * (uint8_t)(152 < sizeof(str) ? str[152] : 0) +       \
+             0x1c262981u * (uint8_t)(153 < sizeof(str) ? str[153] : 0) +       \
+             0x16e536bfu * (uint8_t)(154 < sizeof(str) ? str[154] : 0) +       \
+             0xd9277901u * (uint8_t)(155 < sizeof(str) ? str[155] : 0) +       \
+             0xe9b7c73fu * (uint8_t)(156 < sizeof(str) ? str[156] : 0) +       \
+             0x4b790881u * (uint8_t)(157 < sizeof(str) ? str[157] : 0) +       \
+             0x9b4a17bfu * (uint8_t)(158 < sizeof(str) ? str[158] : 0) +       \
+             0x4efad801u * (uint8_t)(159 < sizeof(str) ? str[159] : 0) +       \
+             0x47bc283fu * (uint8_t)(160 < sizeof(str) ? str[160] : 0) +       \
+             0xcf8ce781u * (uint8_t)(161 < sizeof(str) ? str[161] : 0) +       \
+             0xfb2df8bfu * (uint8_t)(162 < sizeof(str) ? str[162] : 0) +       \
+             0xc90f3701u * (uint8_t)(163 < sizeof(str) ? str[163] : 0) +       \
+             0xb1bf893fu * (uint8_t)(164 < sizeof(str) ? str[164] : 0) +       \
+             0x4761c681u * (uint8_t)(165 < sizeof(str) ? str[165] : 0) +       \
+             0x5790d9bfu * (uint8_t)(166 < sizeof(str) ? str[166] : 0) +       \
+             0x66649601u * (uint8_t)(167 < sizeof(str) ? str[167] : 0) +       \
+             0xc8c1ea3fu * (uint8_t)(168 < sizeof(str) ? str[168] : 0) +       \
+             0x51f7a581u * (uint8_t)(169 < sizeof(str) ? str[169] : 0) +       \
+             0xd172babfu * (uint8_t)(170 < sizeof(str) ? str[170] : 0) +       \
+             0x45faf501u * (uint8_t)(171 < sizeof(str) ? str[171] : 0) +       \
+             0x2dc34b3fu * (uint8_t)(172 < sizeof(str) ? str[172] : 0) +       \
+             0x8e4e8481u * (uint8_t)(173 < sizeof(str) ? str[173] : 0) +       \
+             0x89d39bbfu * (uint8_t)(174 < sizeof(str) ? str[174] : 0) +       \
+             0x86d25401u * (uint8_t)(175 < sizeof(str) ? str[175] : 0) +       \
+             0x81c3ac3fu * (uint8_t)(176 < sizeof(str) ? str[176] : 0) +       \
+             0x9b666381u * (uint8_t)(177 < sizeof(str) ? str[177] : 0) +       \
+             0xa1b37cbfu * (uint8_t)(178 < sizeof(str) ? str[178] : 0) +       \
+             0x47eab301u * (uint8_t)(179 < sizeof(str) ? str[179] : 0) +       \
+             0x65c30d3fu * (uint8_t)(180 < sizeof(str) ? str[180] : 0) +       \
+             0x183f4281u * (uint8_t)(181 < sizeof(str) ? str[181] : 0) +       \
+             0x3a125dbfu * (uint8_t)(182 < sizeof(str) ? str[182] : 0) +       \
+             0xa8441201u * (uint8_t)(183 < sizeof(str) ? str[183] : 0) +       \
+             0x7ac16e3fu * (uint8_t)(184 < sizeof(str) ? str[184] : 0) +       \
+             0xa3d92181u * (uint8_t)(185 < sizeof(str) ? str[185] : 0) +       \
+             0x73f03ebfu * (uint8_t)(186 < sizeof(str) ? str[186] : 0) +       \
+             0xc6de7101u * (uint8_t)(187 < sizeof(str) ? str[187] : 0) +       \
+             0x61becf3fu * (uint8_t)(188 < sizeof(str) ? str[188] : 0) +       \
+             0xdd340081u * (uint8_t)(189 < sizeof(str) ? str[189] : 0) +       \
+             0x704d1fbfu * (uint8_t)(190 < sizeof(str) ? str[190] : 0) +       \
+             0xc2b9d001u * (uint8_t)(191 < sizeof(str) ? str[191] : 0) +       \
+             0xbbbb303fu * (uint8_t)(192 < sizeof(str) ? str[192] : 0) +       \
+             0x634fdf81u * (uint8_t)(193 < sizeof(str) ? str[193] : 0) +       \
+             0x502900bfu * (uint8_t)(194 < sizeof(str) ? str[194] : 0) +       \
+             0xbad62f01u * (uint8_t)(195 < sizeof(str) ? str[195] : 0) +       \
+             0x29b6913fu * (uint8_t)(196 < sizeof(str) ? str[196] : 0) +       \
+             0xd52cbe81u * (uint8_t)(197 < sizeof(str) ? str[197] : 0) +       \
+             0x3483e1bfu * (uint8_t)(198 < sizeof(str) ? str[198] : 0) +       \
+             0xce338e01u * (uint8_t)(199 < sizeof(str) ? str[199] : 0) +       \
+             0x4cb0f23fu * (uint8_t)(200 < sizeof(str) ? str[200] : 0) +       \
+             0xd1ca9d81u * (uint8_t)(201 < sizeof(str) ? str[201] : 0) +       \
+             0x3e5dc2bfu * (uint8_t)(202 < sizeof(str) ? str[202] : 0) +       \
+             0x1bd1ed01u * (uint8_t)(203 < sizeof(str) ? str[203] : 0) +       \
+             0xc5aa533fu * (uint8_t)(204 < sizeof(str) ? str[204] : 0) +       \
+             0xf8297c81u * (uint8_t)(205 < sizeof(str) ? str[205] : 0) +       \
+             0x8eb6a3bfu * (uint8_t)(206 < sizeof(str) ? str[206] : 0) +       \
+             0xc2b14c01u * (uint8_t)(207 < sizeof(str) ? str[207] : 0) +       \
+             0x35a2b43fu * (uint8_t)(208 < sizeof(str) ? str[208] : 0) +       \
+             0xe7495b81u * (uint8_t)(209 < sizeof(str) ? str[209] : 0) +       \
+             0x468e84bfu * (uint8_t)(210 < sizeof(str) ? str[210] : 0) +       \
+             0xe1d1ab01u * (uint8_t)(211 < sizeof(str) ? str[211] : 0) +       \
+             0x3d9a153fu * (uint8_t)(212 < sizeof(str) ? str[212] : 0) +       \
+             0x3e2a3a81u * (uint8_t)(213 < sizeof(str) ? str[213] : 0) +       \
+             0x86e565bfu * (uint8_t)(214 < sizeof(str) ? str[214] : 0) +       \
+             0x98330a01u * (uint8_t)(215 < sizeof(str) ? str[215] : 0) +       \
+             0x7e90763fu * (uint8_t)(216 < sizeof(str) ? str[216] : 0) +       \
+             0x9bcc1981u * (uint8_t)(217 < sizeof(str) ? str[217] : 0) +       \
+             0x70bb46bfu * (uint8_t)(218 < sizeof(str) ? str[218] : 0) +       \
+             0x04d56901u * (uint8_t)(219 < sizeof(str) ? str[219] : 0) +       \
+             0x9985d73fu * (uint8_t)(220 < sizeof(str) ? str[220] : 0) +       \
+             0x9f2ef881u * (uint8_t)(221 < sizeof(str) ? str[221] : 0) +       \
+             0x251027bfu * (uint8_t)(222 < sizeof(str) ? str[222] : 0) +       \
+             0x46b8c801u * (uint8_t)(223 < sizeof(str) ? str[223] : 0) +       \
+             0x2f7a383fu * (uint8_t)(224 < sizeof(str) ? str[224] : 0) +       \
+             0xe752d781u * (uint8_t)(225 < sizeof(str) ? str[225] : 0) +       \
+             0xc4e408bfu * (uint8_t)(226 < sizeof(str) ? str[226] : 0) +       \
+             0x7cdd2701u * (uint8_t)(227 < sizeof(str) ? str[227] : 0) +       \
+             0xe16d993fu * (uint8_t)(228 < sizeof(str) ? str[228] : 0) +       \
+             0x1337b681u * (uint8_t)(229 < sizeof(str) ? str[229] : 0) +       \
+             0x7136e9bfu * (uint8_t)(230 < sizeof(str) ? str[230] : 0) +       \
+             0xc6428601u * (uint8_t)(231 < sizeof(str) ? str[231] : 0) +       \
+             0x505ffa3fu * (uint8_t)(232 < sizeof(str) ? str[232] : 0) +       \
+             0xc1dd9581u * (uint8_t)(233 < sizeof(str) ? str[233] : 0) +       \
+             0x4b08cabfu * (uint8_t)(234 < sizeof(str) ? str[234] : 0) +       \
+             0x41e8e501u * (uint8_t)(235 < sizeof(str) ? str[235] : 0) +       \
+             0x1d515b3fu * (uint8_t)(236 < sizeof(str) ? str[236] : 0) +       \
+             0x92447481u * (uint8_t)(237 < sizeof(str) ? str[237] : 0) +       \
+             0x7359abbfu * (uint8_t)(238 < sizeof(str) ? str[238] : 0) +       \
+             0x0ed04401u * (uint8_t)(239 < sizeof(str) ? str[239] : 0) +       \
+             0xe941bc3fu * (uint8_t)(240 < sizeof(str) ? str[240] : 0) +       \
+             0x236c5381u * (uint8_t)(241 < sizeof(str) ? str[241] : 0) +       \
+             0x0b298cbfu * (uint8_t)(242 < sizeof(str) ? str[242] : 0) +       \
+             0x4bf8a301u * (uint8_t)(243 < sizeof(str) ? str[243] : 0) +       \
+             0x55311d3fu * (uint8_t)(244 < sizeof(str) ? str[244] : 0) +       \
+             0x14553281u * (uint8_t)(245 < sizeof(str) ? str[245] : 0) +       \
+             0x33786dbfu * (uint8_t)(246 < sizeof(str) ? str[246] : 0) +       \
+             0x18620201u * (uint8_t)(247 < sizeof(str) ? str[247] : 0) +       \
+             0x021f7e3fu * (uint8_t)(248 < sizeof(str) ? str[248] : 0) +       \
+             0x03ff1181u * (uint8_t)(249 < sizeof(str) ? str[249] : 0) +       \
+             0x0d464ebfu * (uint8_t)(250 < sizeof(str) ? str[250] : 0) +       \
+             0x930c6101u * (uint8_t)(251 < sizeof(str) ? str[251] : 0) +       \
+             0x910cdf3fu * (uint8_t)(252 < sizeof(str) ? str[252] : 0) +       \
+             0x9169f081u * (uint8_t)(253 < sizeof(str) ? str[253] : 0) +       \
+             0xb9932fbfu * (uint8_t)(254 < sizeof(str) ? str[254] : 0) +       \
+             0xdaf7c001u * (uint8_t)(255 < sizeof(str) ? str[255] : 0))
+
+// clang-format on
diff --git a/pw_tokenizer/public/pw_tokenizer/internal/pw_tokenizer_65599_fixed_length_80_hash_macro.h b/pw_tokenizer/public/pw_tokenizer/internal/pw_tokenizer_65599_fixed_length_80_hash_macro.h
index 857c61f..8725ed1 100644
--- a/pw_tokenizer/public/pw_tokenizer/internal/pw_tokenizer_65599_fixed_length_80_hash_macro.h
+++ b/pw_tokenizer/public/pw_tokenizer/internal/pw_tokenizer_65599_fixed_length_80_hash_macro.h
@@ -1,4 +1,4 @@
-// Copyright 2020 The Pigweed Authors
+// Copyright 2021 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
diff --git a/pw_tokenizer/public/pw_tokenizer/internal/pw_tokenizer_65599_fixed_length_96_hash_macro.h b/pw_tokenizer/public/pw_tokenizer/internal/pw_tokenizer_65599_fixed_length_96_hash_macro.h
index 799044c..c63dfed 100644
--- a/pw_tokenizer/public/pw_tokenizer/internal/pw_tokenizer_65599_fixed_length_96_hash_macro.h
+++ b/pw_tokenizer/public/pw_tokenizer/internal/pw_tokenizer_65599_fixed_length_96_hash_macro.h
@@ -1,4 +1,4 @@
-// Copyright 2020 The Pigweed Authors
+// Copyright 2021 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
diff --git a/pw_tokenizer/public/pw_tokenizer/internal/tokenize_string.h b/pw_tokenizer/public/pw_tokenizer/internal/tokenize_string.h
index 06cefd7..07591c0 100644
--- a/pw_tokenizer/public/pw_tokenizer/internal/tokenize_string.h
+++ b/pw_tokenizer/public/pw_tokenizer/internal/tokenize_string.h
@@ -39,29 +39,29 @@
 
 // The C++ tokenzied string entry supports both string literals and char arrays,
 // such as __func__.
-template <uint32_t domain_size, uint32_t string_size>
+template <uint32_t kDomainSize, uint32_t kStringSize>
 PW_PACKED(class)
 Entry {
  public:
   constexpr Entry(uint32_t token,
-                  const char(&domain)[domain_size],
-                  const char(&string)[string_size])
+                  const char(&domain)[kDomainSize],
+                  const char(&string)[kStringSize])
       : magic_(_PW_TOKENIZER_ENTRY_MAGIC),
         token_(token),
-        domain_size_(domain_size),
-        string_size_(string_size),
+        domain_size_(kDomainSize),
+        string_size_(kStringSize),
         domain_(std::to_array(domain)),
         string_(std::to_array(string)) {}
 
  private:
-  static_assert(string_size > 0u && domain_size > 0u);
+  static_assert(kStringSize > 0u && kDomainSize > 0u);
 
   uint32_t magic_;
   uint32_t token_;
   uint32_t domain_size_;
   uint32_t string_size_;
-  std::array<char, domain_size> domain_;
-  std::array<char, string_size> string_;
+  std::array<char, kDomainSize> domain_;
+  std::array<char, kStringSize> string_;
 };
 
 }  // namespace internal
@@ -117,6 +117,11 @@
 #include "pw_tokenizer/internal/pw_tokenizer_65599_fixed_length_128_hash_macro.h"
 #define PW_TOKENIZER_STRING_TOKEN PW_TOKENIZER_65599_FIXED_LENGTH_128_HASH
 
+#elif PW_TOKENIZER_CFG_C_HASH_LENGTH == 256
+
+#include "pw_tokenizer/internal/pw_tokenizer_65599_fixed_length_256_hash_macro.h"
+#define PW_TOKENIZER_STRING_TOKEN PW_TOKENIZER_65599_FIXED_LENGTH_256_HASH
+
 #else  // unsupported hash length
 
 // Only hash lengths for which there is a corresponding macro header
@@ -125,6 +130,9 @@
 // be added to this file.
 #error "Unsupported value for PW_TOKENIZER_CFG_C_HASH_LENGTH"
 
+// Define a dummy macro to give clearer compilation errors.
+#define PW_TOKENIZER_STRING_TOKEN(unused) 0u
+
 #endif  // PW_TOKENIZER_CFG_C_HASH_LENGTH
 
 #endif  // __cpp_constexpr >= 201304L && defined(__cpp_inline_variables)
diff --git a/pw_tokenizer/public/pw_tokenizer/tokenize.h b/pw_tokenizer/public/pw_tokenizer/tokenize.h
index c398160..cf21cd6 100644
--- a/pw_tokenizer/public/pw_tokenizer/tokenize.h
+++ b/pw_tokenizer/public/pw_tokenizer/tokenize.h
@@ -65,11 +65,21 @@
   PW_TOKENIZE_STRING_DOMAIN(PW_TOKENIZER_DEFAULT_DOMAIN, string_literal)
 
 // Same as PW_TOKENIZE_STRING, but tokenizes to the specified domain.
-#define PW_TOKENIZE_STRING_DOMAIN(domain, string_literal)               \
-  /* assign to a variable */ PW_TOKENIZER_STRING_TOKEN(string_literal); \
-                                                                        \
-  _PW_TOKENIZER_RECORD_ORIGINAL_STRING(                                 \
-      PW_TOKENIZER_STRING_TOKEN(string_literal), domain, string_literal)
+#define PW_TOKENIZE_STRING_DOMAIN(domain, string_literal) \
+  PW_TOKENIZE_STRING_MASK(domain, UINT32_MAX, string_literal)
+
+// Same as PW_TOKENIZE_STRING_DOMAIN, but applies a mask to the token.
+#define PW_TOKENIZE_STRING_MASK(domain, mask, string_literal)                \
+  /* assign to a variable */ _PW_TOKENIZER_MASK_TOKEN(mask, string_literal); \
+                                                                             \
+  static_assert(0 < (mask) && (mask) <= UINT32_MAX,                          \
+                "Tokenizer masks must be non-zero uint32_t values.");        \
+                                                                             \
+  _PW_TOKENIZER_RECORD_ORIGINAL_STRING(                                      \
+      _PW_TOKENIZER_MASK_TOKEN(mask, string_literal), domain, string_literal)
+
+#define _PW_TOKENIZER_MASK_TOKEN(mask, string_literal) \
+  ((pw_tokenizer_Token)(mask)&PW_TOKENIZER_STRING_TOKEN(string_literal))
 
 // Encodes a tokenized string and arguments to the provided buffer. The size of
 // the buffer is passed via a pointer to a size_t. After encoding is complete,
@@ -99,15 +109,21 @@
                                __VA_ARGS__)
 
 // Same as PW_TOKENIZE_TO_BUFFER, but tokenizes to the specified domain.
-#define PW_TOKENIZE_TO_BUFFER_DOMAIN(                          \
-    domain, buffer, buffer_size_pointer, format, ...)          \
-  do {                                                         \
-    _PW_TOKENIZE_FORMAT_STRING(domain, format, __VA_ARGS__);   \
-    _pw_tokenizer_ToBuffer(buffer,                             \
-                           buffer_size_pointer,                \
-                           _pw_tokenizer_token,                \
-                           PW_TOKENIZER_ARG_TYPES(__VA_ARGS__) \
-                               PW_COMMA_ARGS(__VA_ARGS__));    \
+#define PW_TOKENIZE_TO_BUFFER_DOMAIN(                 \
+    domain, buffer, buffer_size_pointer, format, ...) \
+  PW_TOKENIZE_TO_BUFFER_MASK(                         \
+      domain, UINT32_MAX, buffer, buffer_size_pointer, format, __VA_ARGS__)
+
+// Same as PW_TOKENIZE_TO_BUFFER_DOMAIN, but applies a mask to the token.
+#define PW_TOKENIZE_TO_BUFFER_MASK(                               \
+    domain, mask, buffer, buffer_size_pointer, format, ...)       \
+  do {                                                            \
+    PW_TOKENIZE_FORMAT_STRING(domain, mask, format, __VA_ARGS__); \
+    _pw_tokenizer_ToBuffer(buffer,                                \
+                           buffer_size_pointer,                   \
+                           _pw_tokenizer_token,                   \
+                           PW_TOKENIZER_ARG_TYPES(__VA_ARGS__)    \
+                               PW_COMMA_ARGS(__VA_ARGS__));       \
   } while (0)
 
 // Encodes a tokenized string and arguments to a buffer on the stack. The
@@ -142,13 +158,19 @@
   PW_TOKENIZE_TO_CALLBACK_DOMAIN(                      \
       PW_TOKENIZER_DEFAULT_DOMAIN, callback, format, __VA_ARGS__)
 
+// Same as PW_TOKENIZE_TO_CALLBACK, but tokenizes to the specified domain.
 #define PW_TOKENIZE_TO_CALLBACK_DOMAIN(domain, callback, format, ...) \
-  do {                                                                \
-    _PW_TOKENIZE_FORMAT_STRING(domain, format, __VA_ARGS__);          \
-    _pw_tokenizer_ToCallback(callback,                                \
-                             _pw_tokenizer_token,                     \
-                             PW_TOKENIZER_ARG_TYPES(__VA_ARGS__)      \
-                                 PW_COMMA_ARGS(__VA_ARGS__));         \
+  PW_TOKENIZE_TO_CALLBACK_MASK(                                       \
+      domain, UINT32_MAX, callback, format, __VA_ARGS__)
+
+// Same as PW_TOKENIZE_TO_CALLBACK_DOMAIN, but applies a mask to the token.
+#define PW_TOKENIZE_TO_CALLBACK_MASK(domain, mask, callback, format, ...) \
+  do {                                                                    \
+    PW_TOKENIZE_FORMAT_STRING(domain, mask, format, __VA_ARGS__);         \
+    _pw_tokenizer_ToCallback(callback,                                    \
+                             _pw_tokenizer_token,                         \
+                             PW_TOKENIZER_ARG_TYPES(__VA_ARGS__)          \
+                                 PW_COMMA_ARGS(__VA_ARGS__));             \
   } while (0)
 
 PW_EXTERN_C_START
@@ -158,13 +180,13 @@
 void _pw_tokenizer_ToBuffer(void* buffer,
                             size_t* buffer_size_bytes,  // input and output arg
                             pw_tokenizer_Token token,
-                            _pw_tokenizer_ArgTypes types,
+                            pw_tokenizer_ArgTypes types,
                             ...);
 
 void _pw_tokenizer_ToCallback(void (*callback)(const uint8_t* encoded_message,
                                                size_t size_bytes),
                               pw_tokenizer_Token token,
-                              _pw_tokenizer_ArgTypes types,
+                              pw_tokenizer_ArgTypes types,
                               ...);
 
 // This empty function allows the compiler to check the format string.
@@ -172,7 +194,7 @@
     PW_PRINTF_FORMAT(1, 2);
 
 static inline void pw_tokenizer_CheckFormatString(const char* format, ...) {
-  PW_UNUSED(format);
+  (void)format;
 }
 
 PW_EXTERN_C_END
@@ -182,9 +204,9 @@
 
 // This macro takes a printf-style format string and corresponding arguments. It
 // checks that the arguments are correct, stores the format string in a special
-// section, and calculates the string's token at compile time.
+// section, and calculates the string's token at compile time. This
 // clang-format off
-#define _PW_TOKENIZE_FORMAT_STRING(domain, format, ...)                        \
+#define PW_TOKENIZE_FORMAT_STRING(domain, mask, format, ...)                  \
   if (0) { /* Do not execute to prevent double evaluation of the arguments. */ \
     pw_tokenizer_CheckFormatString(format PW_COMMA_ARGS(__VA_ARGS__));         \
   }                                                                            \
@@ -199,7 +221,7 @@
                                                                                \
   /* Tokenize the string to a pw_tokenizer_Token at compile time. */           \
   static _PW_TOKENIZER_CONST pw_tokenizer_Token _pw_tokenizer_token =          \
-      PW_TOKENIZER_STRING_TOKEN(format);                                       \
+      _PW_TOKENIZER_MASK_TOKEN(mask, format);                                  \
                                                                                \
   _PW_TOKENIZER_RECORD_ORIGINAL_STRING(_pw_tokenizer_token, domain, format)
 
diff --git a/pw_tokenizer/public/pw_tokenizer/tokenize_to_global_handler.h b/pw_tokenizer/public/pw_tokenizer/tokenize_to_global_handler.h
index c315f91..ca7e0b9 100644
--- a/pw_tokenizer/public/pw_tokenizer/tokenize_to_global_handler.h
+++ b/pw_tokenizer/public/pw_tokenizer/tokenize_to_global_handler.h
@@ -47,9 +47,14 @@
       PW_TOKENIZER_DEFAULT_DOMAIN, format, __VA_ARGS__)
 
 // Same as PW_TOKENIZE_TO_GLOBAL_HANDLER, but tokenizes to the specified domain.
-#define PW_TOKENIZE_TO_GLOBAL_HANDLER_DOMAIN(domain, format, ...)     \
+#define PW_TOKENIZE_TO_GLOBAL_HANDLER_DOMAIN(domain, format, ...) \
+  PW_TOKENIZE_TO_GLOBAL_HANDLER_MASK(domain, UINT32_MAX, format, __VA_ARGS__)
+
+// Same as PW_TOKENIZE_TO_GLOBAL_HANDLER_DOMAIN, but applies a mask to the
+// token.
+#define PW_TOKENIZE_TO_GLOBAL_HANDLER_MASK(domain, mask, format, ...) \
   do {                                                                \
-    _PW_TOKENIZE_FORMAT_STRING(domain, format, __VA_ARGS__);          \
+    PW_TOKENIZE_FORMAT_STRING(domain, mask, format, __VA_ARGS__);     \
     _pw_tokenizer_ToGlobalHandler(_pw_tokenizer_token,                \
                                   PW_TOKENIZER_ARG_TYPES(__VA_ARGS__) \
                                       PW_COMMA_ARGS(__VA_ARGS__));    \
@@ -66,7 +71,7 @@
 // This function encodes the tokenized strings. Do not call it directly;
 // instead, use the PW_TOKENIZE_TO_GLOBAL_HANDLER macro.
 void _pw_tokenizer_ToGlobalHandler(pw_tokenizer_Token token,
-                                   _pw_tokenizer_ArgTypes types,
+                                   pw_tokenizer_ArgTypes types,
                                    ...);
 
 PW_EXTERN_C_END
diff --git a/pw_tokenizer/public/pw_tokenizer/tokenize_to_global_handler_with_payload.h b/pw_tokenizer/public/pw_tokenizer/tokenize_to_global_handler_with_payload.h
index 55914f7..1faace3 100644
--- a/pw_tokenizer/public/pw_tokenizer/tokenize_to_global_handler_with_payload.h
+++ b/pw_tokenizer/public/pw_tokenizer/tokenize_to_global_handler_with_payload.h
@@ -46,10 +46,17 @@
 
 // Same as PW_TOKENIZE_TO_GLOBAL_HANDLER_WITH_PAYLOAD, but tokenizes to the
 // specified domain.
-#define PW_TOKENIZE_TO_GLOBAL_HANDLER_WITH_PAYLOAD_DOMAIN(               \
-    domain, payload, format, ...)                                        \
+#define PW_TOKENIZE_TO_GLOBAL_HANDLER_WITH_PAYLOAD_DOMAIN( \
+    domain, payload, format, ...)                          \
+  PW_TOKENIZE_TO_GLOBAL_HANDLER_WITH_PAYLOAD_MASK(         \
+      domain, UINT32_MAX, payload, format, __VA_ARGS__)
+
+// Same as PW_TOKENIZE_TO_GLOBAL_HANDLER_WITH_PAYLOAD_DOMAIN, but applies a mask
+// to the token.
+#define PW_TOKENIZE_TO_GLOBAL_HANDLER_WITH_PAYLOAD_MASK(                 \
+    domain, mask, payload, format, ...)                                  \
   do {                                                                   \
-    _PW_TOKENIZE_FORMAT_STRING(domain, format, __VA_ARGS__);             \
+    PW_TOKENIZE_FORMAT_STRING(domain, mask, format, __VA_ARGS__);        \
     _pw_tokenizer_ToGlobalHandlerWithPayload(                            \
         payload,                                                         \
         _pw_tokenizer_token,                                             \
@@ -72,7 +79,7 @@
 // instead, use the PW_TOKENIZE_TO_GLOBAL_HANDLER_WITH_PAYLOAD macro.
 void _pw_tokenizer_ToGlobalHandlerWithPayload(pw_tokenizer_Payload payload,
                                               pw_tokenizer_Token token,
-                                              _pw_tokenizer_ArgTypes types,
+                                              pw_tokenizer_ArgTypes types,
                                               ...);
 
 PW_EXTERN_C_END
diff --git a/pw_tokenizer/pw_tokenizer_private/argument_types_test.h b/pw_tokenizer/pw_tokenizer_private/argument_types_test.h
index b616392..71f3ef2 100644
--- a/pw_tokenizer/pw_tokenizer_private/argument_types_test.h
+++ b/pw_tokenizer/pw_tokenizer_private/argument_types_test.h
@@ -20,27 +20,27 @@
 
 PW_EXTERN_C_START
 
-_pw_tokenizer_ArgTypes pw_TestTokenizerNoArgs(void);
+pw_tokenizer_ArgTypes pw_TestTokenizerNoArgs(void);
 
-_pw_tokenizer_ArgTypes pw_TestTokenizerChar(void);
-_pw_tokenizer_ArgTypes pw_TestTokenizerUint8(void);
-_pw_tokenizer_ArgTypes pw_TestTokenizerUint16(void);
-_pw_tokenizer_ArgTypes pw_TestTokenizerInt32(void);
-_pw_tokenizer_ArgTypes pw_TestTokenizerInt64(void);
-_pw_tokenizer_ArgTypes pw_TestTokenizerUint64(void);
-_pw_tokenizer_ArgTypes pw_TestTokenizerFloat(void);
-_pw_tokenizer_ArgTypes pw_TestTokenizerDouble(void);
-_pw_tokenizer_ArgTypes pw_TestTokenizerString(void);
-_pw_tokenizer_ArgTypes pw_TestTokenizerMutableString(void);
+pw_tokenizer_ArgTypes pw_TestTokenizerChar(void);
+pw_tokenizer_ArgTypes pw_TestTokenizerUint8(void);
+pw_tokenizer_ArgTypes pw_TestTokenizerUint16(void);
+pw_tokenizer_ArgTypes pw_TestTokenizerInt32(void);
+pw_tokenizer_ArgTypes pw_TestTokenizerInt64(void);
+pw_tokenizer_ArgTypes pw_TestTokenizerUint64(void);
+pw_tokenizer_ArgTypes pw_TestTokenizerFloat(void);
+pw_tokenizer_ArgTypes pw_TestTokenizerDouble(void);
+pw_tokenizer_ArgTypes pw_TestTokenizerString(void);
+pw_tokenizer_ArgTypes pw_TestTokenizerMutableString(void);
 
-_pw_tokenizer_ArgTypes pw_TestTokenizerIntFloat(void);
-_pw_tokenizer_ArgTypes pw_TestTokenizerUint64Char(void);
-_pw_tokenizer_ArgTypes pw_TestTokenizerStringString(void);
-_pw_tokenizer_ArgTypes pw_TestTokenizerUint16Int(void);
-_pw_tokenizer_ArgTypes pw_TestTokenizerFloatString(void);
+pw_tokenizer_ArgTypes pw_TestTokenizerIntFloat(void);
+pw_tokenizer_ArgTypes pw_TestTokenizerUint64Char(void);
+pw_tokenizer_ArgTypes pw_TestTokenizerStringString(void);
+pw_tokenizer_ArgTypes pw_TestTokenizerUint16Int(void);
+pw_tokenizer_ArgTypes pw_TestTokenizerFloatString(void);
 
-_pw_tokenizer_ArgTypes pw_TestTokenizerNull(void);
-_pw_tokenizer_ArgTypes pw_TestTokenizerPointer(void);
-_pw_tokenizer_ArgTypes pw_TestTokenizerPointerPointer(void);
+pw_tokenizer_ArgTypes pw_TestTokenizerNull(void);
+pw_tokenizer_ArgTypes pw_TestTokenizerPointer(void);
+pw_tokenizer_ArgTypes pw_TestTokenizerPointerPointer(void);
 
 PW_EXTERN_C_END
diff --git a/pw_tokenizer/pw_tokenizer_private/encode_args.h b/pw_tokenizer/pw_tokenizer_private/encode_args.h
deleted file mode 100644
index d16c3bf..0000000
--- a/pw_tokenizer/pw_tokenizer_private/encode_args.h
+++ /dev/null
@@ -1,54 +0,0 @@
-// 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.
-#pragma once
-
-#include <array>
-#include <cstdarg>
-#include <cstddef>
-#include <span>
-
-#include "pw_tokenizer/config.h"
-#include "pw_tokenizer/internal/argument_types.h"
-#include "pw_tokenizer/tokenize.h"
-
-namespace pw {
-namespace tokenizer {
-
-// Buffer for encoding a tokenized string and arguments.
-struct EncodedMessage {
-  pw_tokenizer_Token token;
-  std::array<uint8_t,
-             PW_TOKENIZER_CFG_ENCODING_BUFFER_SIZE_BYTES - sizeof(token)>
-      args;
-};
-
-static_assert(PW_TOKENIZER_CFG_ENCODING_BUFFER_SIZE_BYTES >=
-                  sizeof(pw_tokenizer_Token),
-              "PW_TOKENIZER_CFG_ENCODING_BUFFER_SIZE_BYTES must be at least "
-              "large enough for a token (4 bytes)");
-
-static_assert(offsetof(EncodedMessage, args) == sizeof(EncodedMessage::token) &&
-                  PW_TOKENIZER_CFG_ENCODING_BUFFER_SIZE_BYTES ==
-                      sizeof(EncodedMessage),
-              "EncodedMessage should not have padding bytes between members");
-
-// Encodes a tokenized string's arguments to a buffer. The
-// _pw_tokenizer_ArgTypes parameter specifies the argument types, in place of a
-// format string.
-size_t EncodeArgs(_pw_tokenizer_ArgTypes types,
-                  va_list args,
-                  std::span<uint8_t> output);
-
-}  // namespace tokenizer
-}  // namespace pw
diff --git a/pw_tokenizer/py/BUILD.gn b/pw_tokenizer/py/BUILD.gn
index a8a1c1b..e9e4468 100644
--- a/pw_tokenizer/py/BUILD.gn
+++ b/pw_tokenizer/py/BUILD.gn
@@ -46,4 +46,5 @@
     "example_binary_with_tokenized_strings.elf",
     "example_legacy_binary_with_tokenized_strings.elf",
   ]
+  pylintrc = "$dir_pigweed/.pylintrc"
 }
diff --git a/pw_tokenizer/py/database_test.py b/pw_tokenizer/py/database_test.py
index 54c890e..290c831 100755
--- a/pw_tokenizer/py/database_test.py
+++ b/pw_tokenizer/py/database_test.py
@@ -105,14 +105,14 @@
             'present_size_bytes': 289,
             'total_entries': 22,
             'total_size_bytes': 289,
-            'collisions': 0
+            'collisions': {}
         },
         'TEST_DOMAIN': {
             'present_entries': 5,
             'present_size_bytes': 57,
             'total_entries': 5,
             'total_size_bytes': 57,
-            'collisions': 0
+            'collisions': {}
         }
     }
 }
@@ -198,10 +198,10 @@
         self.assertEqual(db_with_custom_token.splitlines(),
                          self._csv.read_text().splitlines())
 
-    def test_mark_removals(self):
+    def test_mark_removed(self):
         self._csv.write_text(CSV_ALL_DOMAINS)
 
-        run_cli('mark_removals', '--database', self._csv, '--date',
+        run_cli('mark_removed', '--database', self._csv, '--date',
                 '1998-09-04', self._elf)
 
         # Add the removal date to the four tokens not in the default domain
@@ -223,7 +223,7 @@
         self._csv.write_text(CSV_ALL_DOMAINS)
 
         # Mark everything not in TEST_DOMAIN as removed.
-        run_cli('mark_removals', '--database', self._csv,
+        run_cli('mark_removed', '--database', self._csv,
                 f'{self._elf}#TEST_DOMAIN')
 
         # Delete all entries except those in TEST_DOMAIN.
diff --git a/pw_tokenizer/py/detokenize_test.py b/pw_tokenizer/py/detokenize_test.py
index 6613e53..1de6160 100755
--- a/pw_tokenizer/py/detokenize_test.py
+++ b/pw_tokenizer/py/detokenize_test.py
@@ -179,7 +179,9 @@
         self.assertIn('unknown token',
                       detok.detokenize(b'1234').error_message())
         self.assertIn('unknown token', repr(detok.detokenize(b'1234')))
-        self.assertEqual('', str(detok.detokenize(b'1234')))
+
+        self.assertEqual('$' + base64.b64encode(b'1234').decode(),
+                         str(detok.detokenize(b'1234')))
 
         self.assertIsNone(detok.detokenize(b'').token)
 
@@ -211,16 +213,18 @@
     def test_missing_token(self):
         detok = detokenize.Detokenizer(io.BytesIO(EMPTY_ELF))
         self.assertIn('missing token', detok.detokenize(b'').error_message())
-        self.assertEqual('', str(detok.detokenize(b'')))
+        self.assertEqual('$', str(detok.detokenize(b'')))
         self.assertIn('missing token', repr(detok.detokenize(b'123')))
 
         self.assertIn('missing token', detok.detokenize(b'1').error_message())
-        self.assertEqual('', str(detok.detokenize(b'1')))
+        self.assertEqual('$' + base64.b64encode(b'1').decode(),
+                         str(detok.detokenize(b'1')))
         self.assertIn('missing token', repr(detok.detokenize(b'1')))
 
         self.assertIn('missing token',
                       detok.detokenize(b'123').error_message())
-        self.assertEqual('', str(detok.detokenize(b'123')))
+        self.assertEqual('$' + base64.b64encode(b'123').decode(),
+                         str(detok.detokenize(b'123')))
         self.assertIn('missing token', repr(detok.detokenize(b'123')))
 
     def test_decode_from_elf_data(self):
diff --git a/pw_tokenizer/py/generate_hash_macro.py b/pw_tokenizer/py/generate_hash_macro.py
index 4c144e1..7120b59 100755
--- a/pw_tokenizer/py/generate_hash_macro.py
+++ b/pw_tokenizer/py/generate_hash_macro.py
@@ -19,7 +19,7 @@
 
 HASH_CONSTANT = 65599
 HASH_NAME = 'pw_tokenizer_65599_fixed_length'
-HASH_LENGTHS = 80, 96, 128
+HASH_LENGTHS = 80, 96, 128, 256
 
 FILE_HEADER = """\
 // Copyright {year} The Pigweed Authors
diff --git a/pw_tokenizer/py/pw_tokenizer/database.py b/pw_tokenizer/py/pw_tokenizer/database.py
index cf21481..39eb185 100755
--- a/pw_tokenizer/py/pw_tokenizer/database.py
+++ b/pw_tokenizer/py/pw_tokenizer/database.py
@@ -28,8 +28,8 @@
 import re
 import struct
 import sys
-from typing import (Callable, Dict, Iterable, Iterator, List, Pattern, Set,
-                    TextIO, Tuple, Union)
+from typing import (Any, Callable, Dict, Iterable, Iterator, List, Pattern,
+                    Set, TextIO, Tuple, Union)
 
 try:
     from pw_tokenizer import elf_reader, tokens
@@ -213,22 +213,25 @@
                                     for db in databases))
 
 
-def database_summary(db: tokens.Database) -> Dict[str, int]:
+def database_summary(db: tokens.Database) -> Dict[str, Any]:
     """Returns a simple report of properties of the database."""
     present = [entry for entry in db.entries() if not entry.date_removed]
-
-    # Add 1 to each string's size to account for the null terminator.
-    return {
-        'present_entries': len(present),
-        'present_size_bytes': sum(len(entry.string) + 1 for entry in present),
-        'total_entries': len(db.entries()),
-        'total_size_bytes':
-        sum(len(entry.string) + 1 for entry in db.entries()),
-        'collisions': len(db.collisions()),
+    collisions = {
+        token: list(e.string for e in entries)
+        for token, entries in db.collisions()
     }
 
+    # Add 1 to each string's size to account for the null terminator.
+    return dict(
+        present_entries=len(present),
+        present_size_bytes=sum(len(entry.string) + 1 for entry in present),
+        total_entries=len(db.entries()),
+        total_size_bytes=sum(len(entry.string) + 1 for entry in db.entries()),
+        collisions=collisions,
+    )
 
-_DatabaseReport = Dict[str, Dict[str, Dict[str, int]]]
+
+_DatabaseReport = Dict[str, Dict[str, Dict[str, Any]]]
 
 
 def generate_reports(paths: Iterable[Path]) -> _DatabaseReport:
@@ -293,8 +296,8 @@
               len(token_database) - initial, token_database.path)
 
 
-def _handle_mark_removals(token_database, databases, date):
-    marked_removed = token_database.mark_removals(
+def _handle_mark_removed(token_database, databases, date):
+    marked_removed = token_database.mark_removed(
         (entry for entry in tokens.Database.merged(*databases).entries()
          if not entry.date_removed), date)
 
@@ -516,15 +519,15 @@
             'marked as removed.'))
     subparser.set_defaults(handler=_handle_add)
 
-    # The 'mark_removals' command marks removed entries to match a set of ELFs.
+    # The 'mark_removed' command marks removed entries to match a set of ELFs.
     subparser = subparsers.add_parser(
-        'mark_removals',
+        'mark_removed',
         parents=[option_db, option_tokens],
         help=(
             'Updates a database with tokenized strings from a set of strings. '
             'Strings not present in the set remain in the database but are '
             'marked as removed. New strings are NOT added.'))
-    subparser.set_defaults(handler=_handle_mark_removals)
+    subparser.set_defaults(handler=_handle_mark_removed)
     subparser.add_argument(
         '--date',
         type=year_month_day,
diff --git a/pw_tokenizer/py/pw_tokenizer/detokenize.py b/pw_tokenizer/py/pw_tokenizer/detokenize.py
index 26f5d52..770a4f0 100755
--- a/pw_tokenizer/py/pw_tokenizer/detokenize.py
+++ b/pw_tokenizer/py/pw_tokenizer/detokenize.py
@@ -48,13 +48,13 @@
                     NamedTuple, Optional, Pattern, Tuple, Union)
 
 try:
-    from pw_tokenizer import database, decode, tokens
+    from pw_tokenizer import database, decode, encode, tokens
 except ImportError:
     # Append this path to the module search path to allow running this module
     # without installing the pw_tokenizer package.
     sys.path.append(os.path.dirname(os.path.dirname(
         os.path.abspath(__file__))))
-    from pw_tokenizer import database, decode, tokens
+    from pw_tokenizer import database, decode, encode, tokens
 
 ENCODED_TOKEN = struct.Struct('<I')
 _LOG = logging.getLogger('pw_tokenizer')
@@ -149,7 +149,9 @@
         if self._show_errors:
             return '<[ERROR: {}|{!r}]>'.format(self.error_message(),
                                                self.encoded_message)
-        return ''
+
+        # Display the string as prefixed Base64 if it cannot be decoded.
+        return encode.prefixed_base64(self.encoded_message)
 
     def __repr__(self) -> str:
         if self.ok():
@@ -353,7 +355,7 @@
     return decode_and_detokenize
 
 
-BASE64_PREFIX = b'$'
+BASE64_PREFIX = encode.BASE64_PREFIX.encode()
 DEFAULT_RECURSION = 9
 
 
diff --git a/pw_tokenizer/py/pw_tokenizer/encode.py b/pw_tokenizer/py/pw_tokenizer/encode.py
index e197b79..97c62bf 100644
--- a/pw_tokenizer/py/pw_tokenizer/encode.py
+++ b/pw_tokenizer/py/pw_tokenizer/encode.py
@@ -13,11 +13,13 @@
 # the License.
 """Provides functionality for encoding tokenized messages."""
 
+import base64
 import struct
 from typing import Union
 
 _INT32_MAX = 2**31 - 1
 _UINT32_MAX = 2**32 - 1
+BASE64_PREFIX = '$'
 
 
 def _zig_zag_encode(value: int) -> int:
@@ -86,3 +88,8 @@
                 f'{arg} has type {type(arg)}, which is not supported')
 
     return bytes(data)
+
+
+def prefixed_base64(data: bytes, prefix: str = '$') -> str:
+    """Encodes a tokenized message as prefixed Base64."""
+    return prefix + base64.b64encode(data).decode()
diff --git a/pw_tokenizer/py/pw_tokenizer/tokens.py b/pw_tokenizer/py/pw_tokenizer/tokens.py
index a45f4b0..663935e 100644
--- a/pw_tokenizer/py/pw_tokenizer/tokens.py
+++ b/pw_tokenizer/py/pw_tokenizer/tokens.py
@@ -22,8 +22,8 @@
 from pathlib import Path
 import re
 import struct
-from typing import BinaryIO, Callable, Dict, Iterable, List, NamedTuple
-from typing import Optional, Pattern, Tuple, Union, ValuesView
+from typing import (BinaryIO, Callable, Dict, Iterable, Iterator, List,
+                    NamedTuple, Optional, Pattern, Tuple, Union, ValuesView)
 
 DATE_FORMAT = '%Y-%m-%d'
 DEFAULT_DOMAIN = ''
@@ -155,13 +155,13 @@
         """Returns iterable over all TokenizedStringEntries in the database."""
         return self._database.values()
 
-    def collisions(self) -> Tuple[Tuple[int, List[TokenizedStringEntry]], ...]:
+    def collisions(self) -> Iterator[Tuple[int, List[TokenizedStringEntry]]]:
         """Returns tuple of (token, entries_list)) for all colliding tokens."""
-        return tuple((token, entries)
-                     for token, entries in self.token_to_entries.items()
-                     if len(entries) > 1)
+        for token, entries in self.token_to_entries.items():
+            if len(entries) > 1:
+                yield token, entries
 
-    def mark_removals(
+    def mark_removed(
             self,
             all_entries: Iterable[TokenizedStringEntry],
             removal_date: Optional[datetime] = None
@@ -457,7 +457,7 @@
 
         # Read the path as a CSV file.
         _check_that_file_is_csv_database(self.path)
-        with self.path.open('r', newline='') as file:
+        with self.path.open('r', newline='', encoding='utf-8') as file:
             super().__init__(parse_csv(file))
             self._export = write_csv
 
diff --git a/pw_tokenizer/py/tokens_test.py b/pw_tokenizer/py/tokens_test.py
index 8c71a3b..1f67d5f 100755
--- a/pw_tokenizer/py/tokens_test.py
+++ b/pw_tokenizer/py/tokens_test.py
@@ -324,8 +324,8 @@
         self.assertEqual(len(db.entries()), 18)
         self.assertEqual(len(db.token_to_entries), 17)
 
-    def test_mark_removals(self):
-        """Tests that date_removed field is set by mark_removals."""
+    def test_mark_removed(self):
+        """Tests that date_removed field is set by mark_removed."""
         db = tokens.Database.from_strings(
             ['MILK', 'apples', 'oranges', 'CHEESE', 'pears'])
 
@@ -333,7 +333,7 @@
             all(entry.date_removed is None for entry in db.entries()))
         date_1 = datetime.datetime(1, 2, 3)
 
-        db.mark_removals(_entries('apples', 'oranges', 'pears'), date_1)
+        db.mark_removed(_entries('apples', 'oranges', 'pears'), date_1)
 
         self.assertEqual(
             db.token_to_entries[default_hash('MILK')][0].date_removed, date_1)
@@ -342,7 +342,7 @@
             date_1)
 
         now = datetime.datetime.now()
-        db.mark_removals(_entries('MILK', 'CHEESE', 'pears'))
+        db.mark_removed(_entries('MILK', 'CHEESE', 'pears'))
 
         # New strings are not added or re-added in mark_removed().
         self.assertGreaterEqual(
diff --git a/pw_tokenizer/token_database_fuzzer.cc b/pw_tokenizer/token_database_fuzzer.cc
index 3e243c8..9391f87 100644
--- a/pw_tokenizer/token_database_fuzzer.cc
+++ b/pw_tokenizer/token_database_fuzzer.cc
@@ -54,10 +54,8 @@
     // Since we don't "use" the contents of the entry, we exercise
     // the entry by extracting its contents into volatile variables
     // to prevent it from being optimized out during compilation.
-    volatile const char* entry_string = entry.string;
-    volatile uint32_t entry_token = entry.token;
-    PW_UNUSED(entry_string);
-    PW_UNUSED(entry_token);
+    [[maybe_unused]] volatile const char* entry_string = entry.string;
+    [[maybe_unused]] volatile uint32_t entry_token = entry.token;
   }
 }
 
@@ -121,8 +119,7 @@
   // specified in the API.
   std::span<uint8_t> data_span(buffer, data_size);
   auto token_database = TokenDatabase::Create<std::span<uint8_t>>(data_span);
-  volatile auto match = token_database.Find(random_token);
-  PW_UNUSED(match);
+  [[maybe_unused]] volatile auto match = token_database.Find(random_token);
 
   IterateOverDatabase(&token_database);
 
diff --git a/pw_tokenizer/tokenize.cc b/pw_tokenizer/tokenize.cc
index 16703bb..d515f0c 100644
--- a/pw_tokenizer/tokenize.cc
+++ b/pw_tokenizer/tokenize.cc
@@ -20,7 +20,7 @@
 
 #include <cstring>
 
-#include "pw_tokenizer_private/encode_args.h"
+#include "pw_tokenizer/encode_args.h"
 
 namespace pw {
 namespace tokenizer {
@@ -54,7 +54,7 @@
 #endif  // __APPLE__
 
 constexpr Metadata metadata[] PW_TOKENIZER_INFO_SECTION = {
-    {"hash_length_bytes", PW_TOKENIZER_CFG_C_HASH_LENGTH},
+    {"c_hash_length_bytes", PW_TOKENIZER_CFG_C_HASH_LENGTH},
     {"sizeof_long", sizeof(long)},            // %l conversion specifier
     {"sizeof_intmax_t", sizeof(intmax_t)},    // %j conversion specifier
     {"sizeof_size_t", sizeof(size_t)},        // %z conversion specifier
@@ -66,7 +66,7 @@
 extern "C" void _pw_tokenizer_ToBuffer(void* buffer,
                                        size_t* buffer_size_bytes,
                                        Token token,
-                                       _pw_tokenizer_ArgTypes types,
+                                       pw_tokenizer_ArgTypes types,
                                        ...) {
   if (*buffer_size_bytes < sizeof(token)) {
     *buffer_size_bytes = 0;
@@ -80,8 +80,8 @@
   const size_t encoded_bytes = EncodeArgs(
       types,
       args,
-      std::span<uint8_t>(static_cast<uint8_t*>(buffer) + sizeof(token),
-                         *buffer_size_bytes - sizeof(token)));
+      std::span<std::byte>(static_cast<std::byte*>(buffer) + sizeof(token),
+                           *buffer_size_bytes - sizeof(token)));
   va_end(args);
 
   *buffer_size_bytes = sizeof(token) + encoded_bytes;
@@ -90,18 +90,14 @@
 extern "C" void _pw_tokenizer_ToCallback(
     void (*callback)(const uint8_t* encoded_message, size_t size_bytes),
     Token token,
-    _pw_tokenizer_ArgTypes types,
+    pw_tokenizer_ArgTypes types,
     ...) {
-  EncodedMessage encoded;
-  encoded.token = token;
-
   va_list args;
   va_start(args, types);
-  const size_t encoded_bytes = EncodeArgs(types, args, encoded.args);
+  EncodedMessage encoded(token, types, args);
   va_end(args);
 
-  callback(reinterpret_cast<const uint8_t*>(&encoded),
-           sizeof(encoded.token) + encoded_bytes);
+  callback(encoded.data_as_uint8(), encoded.size());
 }
 
 }  // namespace tokenizer
diff --git a/pw_tokenizer/tokenize_test.cc b/pw_tokenizer/tokenize_test.cc
index 01c6bc5..ff87f51 100644
--- a/pw_tokenizer/tokenize_test.cc
+++ b/pw_tokenizer/tokenize_test.cc
@@ -18,6 +18,7 @@
 #include <cstdint>
 #include <cstring>
 #include <iterator>
+#include <limits>
 
 #include "gtest/gtest.h"
 #include "pw_tokenizer/hash.h"
@@ -29,8 +30,10 @@
 
 // Constructs an array with the hashed string followed by the provided bytes.
 template <uint8_t... kData, size_t kSize>
-constexpr auto ExpectedData(const char (&format)[kSize]) {
-  const uint32_t value = Hash(format);
+constexpr auto ExpectedData(
+    const char (&format)[kSize],
+    uint32_t token_mask = std::numeric_limits<uint32_t>::max()) {
+  const uint32_t value = Hash(format) & token_mask;
   return std::array<uint8_t, sizeof(uint32_t) + sizeof...(kData)>{
       static_cast<uint8_t>(value & 0xff),
       static_cast<uint8_t>(value >> 8 & 0xff),
@@ -65,6 +68,22 @@
   EXPECT_EQ(Hash("???"), TokenizedWithinClass().kThisToken);
 }
 
+TEST(TokenizeString, Mask) {
+  [[maybe_unused]] constexpr uint32_t token = PW_TOKENIZE_STRING("(O_o)");
+  [[maybe_unused]] constexpr uint32_t masked_1 =
+      PW_TOKENIZE_STRING_MASK("domain", 0xAAAAAAAA, "(O_o)");
+  [[maybe_unused]] constexpr uint32_t masked_2 =
+      PW_TOKENIZE_STRING_MASK("domain", 0x55555555, "(O_o)");
+  [[maybe_unused]] constexpr uint32_t masked_3 =
+      PW_TOKENIZE_STRING_MASK("domain", 0xFFFF0000, "(O_o)");
+
+  static_assert(token != masked_1 && token != masked_2 && token != masked_3);
+  static_assert(masked_1 != masked_2 && masked_2 != masked_3);
+  static_assert((token & 0xAAAAAAAA) == masked_1);
+  static_assert((token & 0x55555555) == masked_2);
+  static_assert((token & 0xFFFF0000) == masked_3);
+}
+
 // Use a function with a shorter name to test tokenizing __func__ and
 // __PRETTY_FUNCTION__.
 //
@@ -324,6 +343,23 @@
   EXPECT_EQ(std::memcmp(expected.data(), buffer_, expected.size()), 0);
 }
 
+TEST_F(TokenizeToBuffer, Mask) {
+  size_t message_size = sizeof(buffer_);
+
+  PW_TOKENIZE_TO_BUFFER_MASK("TEST_DOMAIN",
+                             0x0000FFFF,
+                             buffer_,
+                             &message_size,
+                             "The answer was: %s",
+                             "5432!");
+  constexpr std::array<uint8_t, 10> expected =
+      ExpectedData<5, '5', '4', '3', '2', '!'>("The answer was: %s",
+                                               0x0000FFFF);
+
+  ASSERT_EQ(expected.size(), message_size);
+  EXPECT_EQ(std::memcmp(expected.data(), buffer_, expected.size()), 0);
+}
+
 TEST_F(TokenizeToBuffer, TruncateArgs) {
   // Args that can't fit are dropped completely
   size_t message_size = 6;
@@ -365,6 +401,14 @@
   EXPECT_TRUE(std::all_of(buffer_, std::end(buffer_), is_untouched));
 }
 
+TEST_F(TokenizeToBuffer, CharArray) {
+  size_t message_size = sizeof(buffer_);
+  PW_TOKENIZE_TO_BUFFER(buffer_, &message_size, __func__);
+  constexpr auto expected = ExpectedData(__func__);
+  ASSERT_EQ(expected.size(), message_size);
+  EXPECT_EQ(std::memcmp(expected.data(), buffer_, expected.size()), 0);
+}
+
 TEST_F(TokenizeToBuffer, C_StringShortFloat) {
   size_t size = sizeof(buffer_);
   pw_tokenizer_ToBufferTest_StringShortFloat(buffer_, &size);
@@ -472,6 +516,22 @@
   EXPECT_EQ(std::memcmp(expected.data(), message_, expected.size()), 0);
 }
 
+TEST_F(TokenizeToCallback, Mask) {
+  PW_TOKENIZE_TO_CALLBACK_MASK(
+      "TEST_DOMAIN", 0x00000FFF, SetMessage, "The answer is: %s", "5432!");
+  constexpr std::array<uint8_t, 10> expected =
+      ExpectedData<5, '5', '4', '3', '2', '!'>("The answer is: %s", 0x00000FFF);
+  ASSERT_EQ(expected.size(), message_size_bytes_);
+  EXPECT_EQ(std::memcmp(expected.data(), message_, expected.size()), 0);
+}
+
+TEST_F(TokenizeToCallback, CharArray) {
+  PW_TOKENIZE_TO_CALLBACK(SetMessage, __func__);
+  constexpr auto expected = ExpectedData(__func__);
+  ASSERT_EQ(expected.size(), message_size_bytes_);
+  EXPECT_EQ(std::memcmp(expected.data(), message_, expected.size()), 0);
+}
+
 TEST_F(TokenizeToCallback, C_SequentialZigZag) {
   pw_tokenizer_ToCallbackTest_SequentialZigZag(SetMessage);
 
diff --git a/pw_tokenizer/tokenize_to_global_handler.cc b/pw_tokenizer/tokenize_to_global_handler.cc
index da36a49..5ac2755 100644
--- a/pw_tokenizer/tokenize_to_global_handler.cc
+++ b/pw_tokenizer/tokenize_to_global_handler.cc
@@ -14,24 +14,20 @@
 
 #include "pw_tokenizer/tokenize_to_global_handler.h"
 
-#include "pw_tokenizer_private/encode_args.h"
+#include "pw_tokenizer/encode_args.h"
 
 namespace pw {
 namespace tokenizer {
 
 extern "C" void _pw_tokenizer_ToGlobalHandler(pw_tokenizer_Token token,
-                                              _pw_tokenizer_ArgTypes types,
+                                              pw_tokenizer_ArgTypes types,
                                               ...) {
-  EncodedMessage encoded;
-  encoded.token = token;
-
   va_list args;
   va_start(args, types);
-  const size_t encoded_bytes = EncodeArgs(types, args, encoded.args);
+  EncodedMessage encoded(token, types, args);
   va_end(args);
 
-  pw_tokenizer_HandleEncodedMessage(reinterpret_cast<const uint8_t*>(&encoded),
-                                    sizeof(encoded.token) + encoded_bytes);
+  pw_tokenizer_HandleEncodedMessage(encoded.data_as_uint8(), encoded.size());
 }
 
 }  // namespace tokenizer
diff --git a/pw_tokenizer/tokenize_to_global_handler_with_payload.cc b/pw_tokenizer/tokenize_to_global_handler_with_payload.cc
index 56b6520..44b02c6 100644
--- a/pw_tokenizer/tokenize_to_global_handler_with_payload.cc
+++ b/pw_tokenizer/tokenize_to_global_handler_with_payload.cc
@@ -14,7 +14,7 @@
 
 #include "pw_tokenizer/tokenize_to_global_handler_with_payload.h"
 
-#include "pw_tokenizer_private/encode_args.h"
+#include "pw_tokenizer/encode_args.h"
 
 namespace pw {
 namespace tokenizer {
@@ -22,20 +22,15 @@
 extern "C" void _pw_tokenizer_ToGlobalHandlerWithPayload(
     const pw_tokenizer_Payload payload,
     pw_tokenizer_Token token,
-    _pw_tokenizer_ArgTypes types,
+    pw_tokenizer_ArgTypes types,
     ...) {
-  EncodedMessage encoded;
-  encoded.token = token;
-
   va_list args;
   va_start(args, types);
-  const size_t encoded_bytes = EncodeArgs(types, args, encoded.args);
+  EncodedMessage encoded(token, types, args);
   va_end(args);
 
   pw_tokenizer_HandleEncodedMessageWithPayload(
-      payload,
-      reinterpret_cast<const uint8_t*>(&encoded),
-      sizeof(encoded.token) + encoded_bytes);
+      payload, encoded.data_as_uint8(), encoded.size());
 }
 
 }  // namespace tokenizer
diff --git a/pw_tool/BUILD b/pw_tool/BUILD
new file mode 100644
index 0000000..304ec5b
--- /dev/null
+++ b/pw_tool/BUILD
@@ -0,0 +1,30 @@
+# Copyright 2021 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.
+
+load(
+    "//pw_build:pigweed.bzl",
+    "pw_cc_binary",
+)
+
+licenses(["notice"])  # Apache License 2.0
+
+pw_cc_binary(
+    name = "pw_tool",
+    srcs = [ "main.cc" ],
+    deps = [
+        "//pw_log",
+        "//pw_polyfill",
+    ]
+)
+
diff --git a/pw_tool/BUILD.gn b/pw_tool/BUILD.gn
new file mode 100644
index 0000000..522ae3a
--- /dev/null
+++ b/pw_tool/BUILD.gn
@@ -0,0 +1,25 @@
+# Copyright 2021 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/target_types.gni")
+
+pw_executable("pw_tool") {
+  output_name = "pw_tool"
+  deps = [
+    "$dir_pw_log",
+    "$dir_pw_polyfill",
+  ]
+  sources = [ "main.cc" ]
+}
diff --git a/pw_tool/main.cc b/pw_tool/main.cc
new file mode 100644
index 0000000..b716276
--- /dev/null
+++ b/pw_tool/main.cc
@@ -0,0 +1,176 @@
+// Copyright 2021 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.
+
+#include <algorithm>
+#include <cctype>
+#include <functional>
+#include <iostream>
+#include <span>
+#include <string>
+#include <string_view>
+#include <unordered_map>
+#include <vector>
+
+#include "pw_log/log.h"
+
+namespace {
+
+// String used to prompt for user input in the CLI loop.
+constexpr char kPrompt[] = ">";
+
+// Convert the provided string to a lowercase equivalent.
+std::string ToLower(std::string_view view) {
+  std::string str{view};
+  std::transform(str.begin(), str.end(), str.begin(), [](char c) {
+    return std::tolower(c);
+  });
+  return str;
+}
+
+// Scan an input line for tokens, returning a vector containing each token.
+// Tokens are either whitespace delimited strings or a quoted string which may
+// contain spaces and is terminated by another quote. When delimiting by
+// whitespace any consecutive sequence of whitespace is treated as a single
+// delimiter.
+//
+// For example, the tokenization of the following line:
+//
+//   The duck said "quack, quack" before   eating   its bread
+//
+// Would result in the following tokens:
+//
+//   ["The", "duck", "said", "quack, quack", "before", "eating", "its", "bread"]
+//
+std::vector<std::string_view> TokenizeLine(std::string_view line) {
+  size_t token_start = 0;
+  size_t index = 0;
+  bool in_quote = false;
+  std::vector<std::string_view> tokens;
+
+  while (index < line.size()) {
+    // Trim leading/trailing whitespace for each token.
+    while (index < line.size() && std::isspace(line[index])) {
+      ++index;
+    }
+
+    if (index >= line.size()) {
+      // Have reached the end and no further tokens remain.
+      break;
+    }
+
+    token_start = index++;
+    if (line[token_start] == '"') {
+      in_quote = true;
+      // Don't include the quote character.
+      ++token_start;
+    }
+
+    // In a token, scan for the end of the token.
+    while (index < line.size()) {
+      if ((in_quote && line[index] == '"') ||
+          (!in_quote && std::isspace(line[index]))) {
+        break;
+      }
+      ++index;
+    }
+
+    if (index >= line.size() && in_quote) {
+      PW_LOG_WARN("Assuming closing quote at EOL.");
+    }
+
+    tokens.push_back(line.substr(token_start, index - token_start));
+    in_quote = false;
+    ++index;
+  }
+
+  return tokens;
+}
+
+// Context supplied to (and mutable by) each command.
+struct CommandContext {
+  // When set to `true`, the CLI will exit once the active command returns.
+  bool quit = false;
+};
+
+// Commands are given mutable CommandContext and a span tokens in the line of
+// the command.
+using Command =
+    std::function<bool(CommandContext*, std::span<std::string_view>)>;
+
+// Echoes all arguments provided to cout.
+bool CommandEcho(CommandContext* /*context*/,
+                 std::span<std::string_view> tokens) {
+  bool first = true;
+  for (const auto& token : tokens.subspan(1)) {
+    if (!first) {
+      std::cout << ' ';
+    }
+
+    std::cout << token;
+    first = false;
+  }
+  std::cout << std::endl;
+
+  return true;
+}
+
+// Quit the CLI.
+bool CommandQuit(CommandContext* context,
+                 std::span<std::string_view> /*tokens*/) {
+  context->quit = true;
+  return true;
+}
+
+}  // namespace
+
+int main(int /*argc*/, char* /*argv*/[]) {
+  CommandContext context;
+  std::unordered_map<std::string, Command> commands{
+      {"echo", CommandEcho},
+      {"exit", CommandQuit},
+      {"quit", CommandQuit},
+  };
+
+  // Enter CLI loop.
+  while (true) {
+    // Prompt for input.
+    std::string line;
+    std::cout << kPrompt << ' ' << std::flush;
+    std::getline(std::cin, line);
+
+    // Tokenize provided line.
+    auto tokens = TokenizeLine(line);
+    if (tokens.empty()) {
+      continue;
+    }
+
+    // Search for provided command.
+    auto it = commands.find(ToLower(tokens[0]));
+    if (it == commands.end()) {
+      PW_LOG_ERROR("Unrecognized command \"%.*s\".",
+                   static_cast<int>(tokens[0].size()),
+                   tokens[0].data());
+      continue;
+    }
+
+    // Invoke the command.
+    Command command = it->second;
+    command(&context, tokens);
+    if (context.quit) {
+      break;
+    }
+  }
+
+  return EXIT_SUCCESS;
+}
diff --git a/pw_toolchain/arm_clang/BUILD.gn b/pw_toolchain/arm_clang/BUILD.gn
new file mode 100644
index 0000000..d4b73a9
--- /dev/null
+++ b/pw_toolchain/arm_clang/BUILD.gn
@@ -0,0 +1,53 @@
+# Copyright 2021 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("clang_config.gni")
+
+cortex_m_common_flags = [
+  "-mabi=aapcs",
+  "-mthumb",
+]
+
+cortex_m_software_fpu_flags = [ "-mfloat-abi=soft" ]
+
+cortex_m_hardware_fpu_flags = [
+  "-mfloat-abi=hard",
+  "-mfpu=fpv4-sp-d16",
+
+  # Used by some pigweed tests/targets to correctly handle hardware FPU
+  # behavior.
+  "-DPW_ARMV7M_ENABLE_FPU=1",
+]
+
+config("enable_float_printf") {
+  ldflags = [ "-Wl,-u_printf_float" ]
+}
+
+pw_clang_arm_config("cortex_m3") {
+  cflags = [ "-mcpu=cortex-m3" ]
+  cflags += cortex_m_common_flags
+  cflags += cortex_m_software_fpu_flags
+  asmflags = cflags
+  ldflags = cflags
+}
+
+pw_clang_arm_config("cortex_m4f") {
+  cflags = [ "-mcpu=cortex-m4" ]
+  cflags += cortex_m_common_flags
+  cflags += cortex_m_hardware_fpu_flags
+  asmflags = cflags
+  ldflags = cflags
+}
diff --git a/pw_toolchain/arm_clang/clang_config.gni b/pw_toolchain/arm_clang/clang_config.gni
new file mode 100644
index 0000000..2c71a2f
--- /dev/null
+++ b/pw_toolchain/arm_clang/clang_config.gni
@@ -0,0 +1,82 @@
+# Copyright 2021 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.
+
+_script_path = rebase_path("../py/pw_toolchain/clang_arm_toolchain.py")
+
+# This template generates a config that can be used to target ARM cores using
+# a clang compiler.
+#
+# Clang isn't a plug-and-play experience for Cortex-M baremetal targets; it's
+# missing C runtime libraries, C/C++ standard libraries, and a few other
+# things. This template uses the provided cflags, asmflags, ldflags, etc. to
+# generate a config that pulls the missing components from an arm-none-eabi-gcc
+# compiler on the system PATH. The end result is a clang-based compiler that
+# pulls in gcc-provided headers and libraries to complete the toolchain.
+#
+# Args:
+#   - asmflags, cflags, cflags_c, cflags_cc, ldflags: These flags are used to
+#         locate the correct architecture-specific libraries/headers. To
+#         properly drive the script, provide all architecture flags (e.g. -mcpu,
+#         -mabi, -mthumb, -mfloat-abi, -mfpu) in at least one of these
+#         variables.
+#
+# Generated targets:
+#   - $target_name: The final config to use with your clang toolchain.
+template("pw_clang_arm_config") {
+  config(target_name) {
+    # Pull all the compiler flags into a single list.
+    _compiler_flags = []
+    forward_variables_from(invoker, "*")
+    if (defined(asmflags)) {
+      _compiler_flags += asmflags
+    } else {
+      asmflags = []
+    }
+    if (defined(cflags)) {
+      _compiler_flags += cflags
+    } else {
+      cflags = []
+    }
+    if (defined(cflags_c)) {
+      _compiler_flags += cflags_c
+    } else {
+      cflags_c = []
+    }
+    if (defined(cflags_cc)) {
+      _compiler_flags += cflags_cc
+    } else {
+      cflags_cc = []
+    }
+    if (defined(ldflags)) {
+      _compiler_flags += ldflags
+    } else {
+      ldflags = []
+    }
+
+    # Invoke the script that will generate clang flags based on the current
+    # compiler version and desired arch.
+    _script_flags = [
+      "--gn-scope",
+      "--cflags",
+      "--ldflags",
+      "--",
+    ]
+    _script_flags += _compiler_flags
+    _arm_flags = exec_script(_script_path, _script_flags, "scope")
+
+    cflags += _arm_flags.cflags
+    ldflags += _arm_flags.cflags
+    ldflags += _arm_flags.ldflags
+  }
+}
diff --git a/pw_toolchain/arm_clang/toolchains.gni b/pw_toolchain/arm_clang/toolchains.gni
new file mode 100644
index 0000000..91aea25
--- /dev/null
+++ b/pw_toolchain/arm_clang/toolchains.gni
@@ -0,0 +1,115 @@
+# Copyright 2021 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")
+
+# Specifies the tools used by host Clang toolchains.
+_arm_clang_toolchain = {
+  # Note: On macOS, there is no "llvm-ar", only "ar", which happens to be LLVM
+  # ar. This should get updated for linux systems.
+  ar = "ar"
+  cc = "clang"
+  cxx = "clang++"
+
+  link_whole_archive = true
+}
+
+# Configs specific to different architectures.
+_cortex_m3 = [ "$dir_pw_toolchain/arm_clang:cortex_m3" ]
+
+_cortex_m4 = [ "$dir_pw_toolchain/arm_clang:cortex_m4" ]
+
+_cortex_m4f = [ "$dir_pw_toolchain/arm_clang:cortex_m4f" ]
+
+# Describes ARM clang toolchains for specific targets.
+pw_toolchain_arm_clang = {
+  cortex_m3_debug = {
+    name = "arm_clang_cortex_m3_debug"
+    forward_variables_from(_arm_clang_toolchain, "*")
+    defaults = {
+      default_configs = _cortex_m3 + [ "$dir_pw_build:optimize_debugging" ]
+    }
+  }
+  cortex_m3_speed_optimized = {
+    name = "arm_clang_cortex_m3_speed_optimized"
+    forward_variables_from(_arm_clang_toolchain, "*")
+    defaults = {
+      default_configs = _cortex_m3 + [ "$dir_pw_build:optimize_speed" ]
+    }
+  }
+  cortex_m3_size_optimized = {
+    name = "arm_clang_cortex_m3_size_optimized"
+    forward_variables_from(_arm_clang_toolchain, "*")
+    defaults = {
+      default_configs = _cortex_m3 + [ "$dir_pw_build:optimize_size" ]
+    }
+  }
+  cortex_m4_debug = {
+    name = "arm_clang_cortex_m4_debug"
+    forward_variables_from(_arm_clang_toolchain, "*")
+    defaults = {
+      default_configs = _cortex_m4 + [ "$dir_pw_build:optimize_debugging" ]
+    }
+  }
+  cortex_m4_speed_optimized = {
+    name = "arm_clang_cortex_m4_speed_optimized"
+    forward_variables_from(_arm_clang_toolchain, "*")
+    defaults = {
+      default_configs = _cortex_m4 + [ "$dir_pw_build:optimize_speed" ]
+    }
+  }
+  cortex_m4_size_optimized = {
+    name = "arm_clang_cortex_m4_size_optimized"
+    forward_variables_from(_arm_clang_toolchain, "*")
+    defaults = {
+      default_configs = _cortex_m4 + [ "$dir_pw_build:optimize_size" ]
+    }
+  }
+  cortex_m4f_debug = {
+    name = "arm_clang_cortex_m4f_debug"
+    forward_variables_from(_arm_clang_toolchain, "*")
+    defaults = {
+      default_configs = _cortex_m4f + [ "$dir_pw_build:optimize_debugging" ]
+    }
+  }
+  cortex_m4f_speed_optimized = {
+    name = "arm_clang_cortex_m4f_speed_optimized"
+    forward_variables_from(_arm_clang_toolchain, "*")
+    defaults = {
+      default_configs = _cortex_m4f + [ "$dir_pw_build:optimize_speed" ]
+    }
+  }
+  cortex_m4f_size_optimized = {
+    name = "arm_clang_cortex_m4f_size_optimized"
+    forward_variables_from(_arm_clang_toolchain, "*")
+    defaults = {
+      default_configs = _cortex_m4f + [ "$dir_pw_build:optimize_size" ]
+    }
+  }
+}
+
+# This list just contains the members of the above scope for convenience to make
+# it trivial to generate all the toolchains in this file via a
+# `generate_toolchains` target.
+pw_toolchain_arm_clang_list = [
+  pw_toolchain_arm_clang.cortex_m3_debug,
+  pw_toolchain_arm_clang.cortex_m3_speed_optimized,
+  pw_toolchain_arm_clang.cortex_m3_size_optimized,
+  pw_toolchain_arm_clang.cortex_m4_debug,
+  pw_toolchain_arm_clang.cortex_m4_speed_optimized,
+  pw_toolchain_arm_clang.cortex_m4_size_optimized,
+  pw_toolchain_arm_clang.cortex_m4f_debug,
+  pw_toolchain_arm_clang.cortex_m4f_speed_optimized,
+  pw_toolchain_arm_clang.cortex_m4f_size_optimized,
+]
diff --git a/pw_toolchain/arm_gcc/BUILD.gn b/pw_toolchain/arm_gcc/BUILD.gn
index 852f06a..6d845b2 100644
--- a/pw_toolchain/arm_gcc/BUILD.gn
+++ b/pw_toolchain/arm_gcc/BUILD.gn
@@ -51,6 +51,12 @@
   ldflags = cflags
 }
 
+config("cortex_m0plus") {
+  cflags = [ "-mcpu=cortex-m0plus" ]
+  asmflags = cflags
+  ldflags = cflags
+}
+
 config("cortex_m3") {
   cflags = [ "-mcpu=cortex-m3" ]
   asmflags = cflags
diff --git a/pw_toolchain/arm_gcc/toolchains.gni b/pw_toolchain/arm_gcc/toolchains.gni
index 68bcdc1..5d247e7 100644
--- a/pw_toolchain/arm_gcc/toolchains.gni
+++ b/pw_toolchain/arm_gcc/toolchains.gni
@@ -27,6 +27,12 @@
 # Common configs shared by all ARM GCC toolchains.
 _arm_gcc = [ "$dir_pw_toolchain/arm_gcc:disable_psabi_warning" ]
 
+_cortex_m0plus = [
+  "$dir_pw_toolchain/arm_gcc:cortex_common",
+  "$dir_pw_toolchain/arm_gcc:cortex_m0plus",
+  "$dir_pw_toolchain/arm_gcc:cortex_software_fpu",
+]
+
 # Configs specific to different architectures.
 _cortex_m3 = [
   "$dir_pw_toolchain/arm_gcc:cortex_common",
@@ -60,6 +66,30 @@
 
 # Describes ARM GCC toolchains for specific targets.
 pw_toolchain_arm_gcc = {
+  cortex_m0plus_debug = {
+    name = "arm_gcc_cortex_m0plus_debug"
+    forward_variables_from(arm_gcc_toolchain_tools, "*")
+    defaults = {
+      default_configs =
+          _arm_gcc + _cortex_m0plus + [ "$dir_pw_build:optimize_debugging" ]
+    }
+  }
+  cortex_m0plus_speed_optimized = {
+    name = "arm_gcc_cortex_m0plus_speed_optimized"
+    forward_variables_from(arm_gcc_toolchain_tools, "*")
+    defaults = {
+      default_configs =
+          _arm_gcc + _cortex_m0plus + [ "$dir_pw_build:optimize_speed" ]
+    }
+  }
+  cortex_m0plus_size_optimized = {
+    name = "arm_gcc_cortex_m0plus_size_optimized"
+    forward_variables_from(arm_gcc_toolchain_tools, "*")
+    defaults = {
+      default_configs =
+          _arm_gcc + _cortex_m0plus + [ "$dir_pw_build:optimize_size" ]
+    }
+  }
   cortex_m3_debug = {
     name = "arm_gcc_cortex_m3_debug"
     forward_variables_from(arm_gcc_toolchain_tools, "*")
@@ -186,6 +216,9 @@
 # it trivial to generate all the toolchains in this file via a
 # `generate_toolchains` target.
 pw_toolchain_arm_gcc_list = [
+  pw_toolchain_arm_gcc.cortex_m0plus_debug,
+  pw_toolchain_arm_gcc.cortex_m0plus_speed_optimized,
+  pw_toolchain_arm_gcc.cortex_m0plus_size_optimized,
   pw_toolchain_arm_gcc.cortex_m3_debug,
   pw_toolchain_arm_gcc.cortex_m3_speed_optimized,
   pw_toolchain_arm_gcc.cortex_m3_size_optimized,
diff --git a/pw_toolchain/dummy/BUILD.gn b/pw_toolchain/dummy/BUILD.gn
index aaf43d3..ba0d738 100644
--- a/pw_toolchain/dummy/BUILD.gn
+++ b/pw_toolchain/dummy/BUILD.gn
@@ -29,7 +29,9 @@
 
   # If the user tries to build a target with the default toolchain, run a script
   # printing out the error.
-  _bad_toolchain_command = "python " + rebase_path("bad_toolchain.py")
+  _bad_toolchain_command =
+      "python " +
+      rebase_path("$dir_pw_toolchain/py/pw_toolchain/bad_toolchain.py")
 
   tool("asm") {
     command = _bad_toolchain_command
diff --git a/pw_toolchain/generate_toolchain.gni b/pw_toolchain/generate_toolchain.gni
index 9a74cdf..5be80eb 100644
--- a/pw_toolchain/generate_toolchain.gni
+++ b/pw_toolchain/generate_toolchain.gni
@@ -18,7 +18,7 @@
 
 declare_args() {
   # Scope defining the current toolchain. Contains all of the arguments required
-  # by the generate_toolchain template.
+  # by the generate_toolchain template. This should NOT be manually modified.
   pw_toolchain_SCOPE = {
   }
 
@@ -42,8 +42,14 @@
 #     all object files when resolving symbols.
 #   link_group: (optional) Boolean indicating if the linker should use
 #     a group to resolve circular dependencies between artifacts.
-#   defaults: (required) A scope setting defaults to apply to GN
-#     targets in this toolchain, as described in pw_vars_default.gni
+#   generate_from: (optional) The full target name of the toolchain that can
+#     trigger this toolchain to be generated. GN only allows one toolchain to
+#     be generated at a given target path, so if multiple toolchains parse the
+#     same generate_toolchain target only one should declare a toolchain. This
+#     is primarily to allow generating sub-toolchains. Defaults to
+#     default_toolchain.
+#   defaults: (required) A scope setting GN build arg values to apply to GN
+#     targets in this toolchain. These take precedence over args.gni settings.
 #
 # The defaults scope should contain values for builtin GN arguments:
 #   current_cpu: The CPU of the toolchain.
@@ -51,14 +57,24 @@
 #   current_os: The OS of the toolchain. Defaults to "".
 #     Well known values include "win", "mac", "linux", "android", and "ios".
 #
+# TODO(pwbug/333): This should be renamed to pw_generate_toolchain.
 template("generate_toolchain") {
   assert(defined(invoker.defaults), "toolchain is missing 'defaults'")
 
-  # In multi-toolchain builds from the top level, we run into issues where
-  # toolchains defined with this template are re-generated each time. To avoid
-  # collisions, the actual toolchain is only generated for the default (dummy)
-  # toolchain, and an unused target is created otherwise.
-  if (current_toolchain == default_toolchain) {
+  # On the default toolchain invocation, you typically need to generate all
+  # toolchains you encounter. For sub-toolchains, they must be generated from
+  # the context of their parent.
+  if (defined(invoker.generate_from)) {
+    _generate_toolchain =
+        get_label_info(invoker.generate_from, "label_no_toolchain") ==
+        current_toolchain
+  } else {
+    _generate_toolchain = default_toolchain == current_toolchain
+  }
+
+  if (_generate_toolchain) {
+    # TODO(amontanez): This should be renamed to build_args as "defaults" isn't
+    # sufficiently descriptive.
     invoker_toolchain_args = invoker.defaults
 
     # These values should always be set as they influence toolchain
@@ -74,6 +90,9 @@
     toolchain_os = invoker_toolchain_args.current_os
 
     toolchain(target_name) {
+      # Uncomment this line to see which toolchains generate other toolchains.
+      # print("Generating toolchain: ${target_name} by ${current_toolchain}")
+
       assert(defined(invoker.cc), "toolchain is missing 'cc'")
       tool("asm") {
         if (pw_command_launcher != "") {
@@ -200,7 +219,15 @@
 
       assert(defined(invoker.ar), "toolchain is missing 'ar'")
       tool("alink") {
-        command = "rm -f {{output}} && ${invoker.ar} rcs {{output}} {{inputs}}"
+        if (host_os == "win") {
+          rspfile = "{{output}}.rsp"
+          rspfile_content = "{{inputs}}"
+          rm_command = "del /F /Q \"{{output}}\" 2> NUL"
+          command = "cmd /c \"($rm_command) & ${invoker.ar} {{arflags}} rcs {{output}} @$rspfile\""
+        } else {
+          command = "rm -f {{output}} && ${invoker.ar} {{arflags}} rcs {{output}} {{inputs}}"
+        }
+
         description = "ar {{target_output_name}}{{output_extension}}"
         outputs =
             [ "{{output_dir}}/{{target_output_name}}{{output_extension}}" ]
@@ -272,7 +299,10 @@
       tool("link") {
         command = _link_command
         description = "ld $_link_outfile"
-        outputs = [ _link_outfile ]
+        outputs = [
+          _link_outfile,
+          _link_mapfile,
+        ]
         default_output_dir = "{{target_out_dir}}/bin"
 
         if (defined(invoker.final_binary_extension)) {
@@ -287,7 +317,10 @@
       tool("solink") {
         command = _link_command + " -shared"
         description = "ld -shared $_link_outfile"
-        outputs = [ _link_outfile ]
+        outputs = [
+          _link_outfile,
+          _link_mapfile,
+        ]
         default_output_dir = "{{target_out_dir}}/lib"
         default_output_extension = ".so"
       }
@@ -320,6 +353,7 @@
         }
         pw_toolchain_SCOPE = {
           forward_variables_from(invoker, "*")
+          name = target_name
         }
         forward_variables_from(invoker_toolchain_args, "*")
       }
diff --git a/pw_toolchain/host_clang/BUILD.gn b/pw_toolchain/host_clang/BUILD.gn
index 4e4ddb7..fe30ba4 100644
--- a/pw_toolchain/host_clang/BUILD.gn
+++ b/pw_toolchain/host_clang/BUILD.gn
@@ -30,6 +30,14 @@
   ldflags = cflags
 }
 
+config("sanitize_coverage") {
+  cflags = [
+    "-fprofile-instr-generate",
+    "-fcoverage-mapping",
+  ]
+  ldflags = cflags
+}
+
 # Locate XCode's sysroot for Clang.
 config("xcode_sysroot") {
   if (current_os == "mac") {
diff --git a/pw_toolchain/host_clang/toolchain.cmake b/pw_toolchain/host_clang/toolchain.cmake
index 2628ee6..9f4dd6a 100644
--- a/pw_toolchain/host_clang/toolchain.cmake
+++ b/pw_toolchain/host_clang/toolchain.cmake
@@ -14,8 +14,11 @@
 
 include($ENV{PW_ROOT}/pw_build/pigweed.cmake)
 
-pw_set_backend(pw_log pw_log_basic)
 pw_set_backend(pw_assert pw_assert_log)
+pw_set_backend(pw_chrono.system_clock pw_chrono_stl.system_clock)
+pw_set_backend(pw_log pw_log_basic)
+pw_set_backend(pw_rpc.system_server targets.host.system_rpc_server)
+pw_set_backend(pw_sync.mutex pw_sync_stl.mutex_backend)
 pw_set_backend(pw_sys_io pw_sys_io_stdio)
 
 set(CMAKE_C_COMPILER clang)
diff --git a/pw_toolchain/host_clang/toolchains.gni b/pw_toolchain/host_clang/toolchains.gni
index b4569ab..b04c86a 100644
--- a/pw_toolchain/host_clang/toolchains.gni
+++ b/pw_toolchain/host_clang/toolchains.gni
@@ -17,7 +17,7 @@
 declare_args() {
   # Sets the sanitizer to pass to clang. Valid values are those for "-fsanitize"
   # listed in https://clang.llvm.org/docs/UsersManual.html#id9.
-  pw_toolchain_SANITIZER = ""
+  pw_toolchain_SANITIZERS = []
 
   # Indicates if this build is a part of OSS-Fuzz, which needs to be able to
   # provide its own compiler and flags. This violates the build hermeticisim and
@@ -27,9 +27,7 @@
 
 # Specifies the tools used by host Clang toolchains.
 _host_clang_toolchain = {
-  # Note: On macOS, there is no "llvm-ar", only "ar", which happens to be LLVM
-  # ar. This should get updated for linux systems.
-  ar = "ar"
+  ar = "llvm-ar"
 
   if (pw_toolchain_OSS_FUZZ_ENABLED) {
     cc = getenv("CC")
@@ -49,19 +47,6 @@
     "$dir_pw_toolchain/host_clang:no_system_libcpp",
     "$dir_pw_toolchain/host_clang:xcode_sysroot",
   ]
-  if (pw_toolchain_SANITIZER != "") {
-    default_configs +=
-        [ "$dir_pw_toolchain/host_clang:sanitize_$pw_toolchain_SANITIZER" ]
-  }
-  if (pw_toolchain_OSS_FUZZ_ENABLED) {
-    default_configs += oss_fuzz_added_configs
-    default_configs += [ "$dir_pw_fuzzer:oss_fuzz_extra" ]
-
-    # Add the configs to be removed. They will be de-duplicated, and this
-    # ensures they are present to be removed.
-    default_configs += oss_fuzz_removed_configs
-    remove_default_configs = oss_fuzz_removed_configs
-  }
 }
 
 pw_toolchain_host_clang = {
@@ -91,6 +76,30 @@
       default_configs += [ "$dir_pw_build:optimize_size" ]
     }
   }
+
+  fuzz = {
+    name = "host_clang_fuzz"
+    forward_variables_from(_host_clang_toolchain, "*")
+    defaults = {
+      forward_variables_from(_defaults, "*")
+
+      # Fuzz faster.
+      default_configs += [ "$dir_pw_build:optimize_speed" ]
+
+      if (pw_toolchain_SANITIZERS == []) {
+        # Default to ASan for fuzzing, which is what we typically care about.
+        pw_toolchain_SANITIZERS = [ "address" ]
+      }
+      foreach(sanitizer, pw_toolchain_SANITIZERS) {
+        default_configs +=
+            [ "$dir_pw_toolchain/host_clang:sanitize_$sanitizer" ]
+      }
+
+      if (pw_toolchain_OSS_FUZZ_ENABLED) {
+        default_configs += [ "$dir_pw_fuzzer:oss_fuzz_extra" ]
+      }
+    }
+  }
 }
 
 # Describes host clang toolchains.
@@ -98,4 +107,5 @@
   pw_toolchain_host_clang.debug,
   pw_toolchain_host_clang.speed_optimized,
   pw_toolchain_host_clang.size_optimized,
+  pw_toolchain_host_clang.fuzz,
 ]
diff --git a/pw_toolchain/host_gcc/BUILD.gn b/pw_toolchain/host_gcc/BUILD.gn
index e0cdbf1..e073821 100644
--- a/pw_toolchain/host_gcc/BUILD.gn
+++ b/pw_toolchain/host_gcc/BUILD.gn
@@ -40,3 +40,9 @@
     cflags = [ "-D__USE_MINGW_ANSI_STDIO=1" ]
   }
 }
+
+# GCC needs the -pthread option to support multithreading. This must be
+# specified to build e.g. pw_thread_stl.
+config("threading_support") {
+  ldflags = [ "-pthread" ]
+}
diff --git a/pw_toolchain/host_gcc/toolchain.cmake b/pw_toolchain/host_gcc/toolchain.cmake
index 0826543..cf87958 100644
--- a/pw_toolchain/host_gcc/toolchain.cmake
+++ b/pw_toolchain/host_gcc/toolchain.cmake
@@ -14,8 +14,11 @@
 
 include($ENV{PW_ROOT}/pw_build/pigweed.cmake)
 
-pw_set_backend(pw_log pw_log_basic)
 pw_set_backend(pw_assert pw_assert_log)
+pw_set_backend(pw_chrono.system_clock pw_chrono_stl.system_clock)
+pw_set_backend(pw_log pw_log_basic)
+pw_set_backend(pw_rpc.system_server targets.host.system_rpc_server)
+pw_set_backend(pw_sync.mutex pw_sync_stl.mutex_backend)
 pw_set_backend(pw_sys_io pw_sys_io_stdio)
 
 set(CMAKE_C_COMPILER gcc)
diff --git a/pw_toolchain/py/BUILD.gn b/pw_toolchain/py/BUILD.gn
new file mode 100644
index 0000000..ee7df0a
--- /dev/null
+++ b/pw_toolchain/py/BUILD.gn
@@ -0,0 +1,28 @@
+# Copyright 2021 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_toolchain/__init__.py",
+    "pw_toolchain/bad_toolchain.py",
+    "pw_toolchain/clang_arm_toolchain.py",
+    "pw_toolchain/copy_with_metadata.py",
+  ]
+  pylintrc = "$dir_pigweed/.pylintrc"
+}
diff --git a/pw_hdlc_lite/py/pw_hdlc_lite/__init__.py b/pw_toolchain/py/pw_toolchain/__init__.py
similarity index 100%
copy from pw_hdlc_lite/py/pw_hdlc_lite/__init__.py
copy to pw_toolchain/py/pw_toolchain/__init__.py
diff --git a/pw_toolchain/dummy/bad_toolchain.py b/pw_toolchain/py/pw_toolchain/bad_toolchain.py
similarity index 100%
rename from pw_toolchain/dummy/bad_toolchain.py
rename to pw_toolchain/py/pw_toolchain/bad_toolchain.py
diff --git a/pw_toolchain/py/pw_toolchain/clang_arm_toolchain.py b/pw_toolchain/py/pw_toolchain/clang_arm_toolchain.py
new file mode 100644
index 0000000..7494727
--- /dev/null
+++ b/pw_toolchain/py/pw_toolchain/clang_arm_toolchain.py
@@ -0,0 +1,201 @@
+#!/usr/bin/env python3
+# Copyright 2021 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.
+"""Generates flags needed for an ARM build using clang.
+
+Using clang on Cortex-M cores isn't intuitive as the end-to-end experience isn't
+quite completely in LLVM. LLVM doesn't yet provide compatible C runtime
+libraries or C/C++ standard libraries. To work around this, this script pulls
+the missing bits from an arm-none-eabi-gcc compiler on the system path. This
+lets clang do the heavy lifting while only relying on some headers provided by
+newlib/arm-none-eabi-gcc in addition to a small assortment of needed libraries.
+
+To use this script, specify what flags you want from the script, and run with
+the required architecture flags like you would with gcc:
+
+  python -m pw_toolchain.clang_arm_toolchain --cflags -- -mthumb -mcpu=cortex-m3
+
+The script will then print out the additional flags you need to pass to clang to
+get a working build.
+"""
+
+import argparse
+import sys
+import subprocess
+
+from pathlib import Path
+from typing import List, Dict, Tuple
+
+_ARM_COMPILER_PREFIX = 'arm-none-eabi'
+_ARM_COMPILER_NAME = _ARM_COMPILER_PREFIX + '-gcc'
+
+
+def _parse_args() -> argparse.Namespace:
+    """Parses arguments for this script, splitting out the command to run."""
+
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument(
+        '--gn-scope',
+        action='store_true',
+        help=("Formats the output like a GN scope so it can be ingested by "
+              "exec_script()"))
+    parser.add_argument('--cflags',
+                        action='store_true',
+                        help=('Include necessary C flags in the output'))
+    parser.add_argument('--ldflags',
+                        action='store_true',
+                        help=('Include necessary linker flags in the output'))
+    parser.add_argument(
+        'clang_flags',
+        nargs=argparse.REMAINDER,
+        help='Flags to pass to clang, which can affect library/include paths',
+    )
+    parsed_args = parser.parse_args()
+
+    assert parsed_args.clang_flags[0] == '--', 'arguments not correctly split'
+    parsed_args.clang_flags = parsed_args.clang_flags[1:]
+    return parsed_args
+
+
+def _compiler_info_command(print_command: str, cflags: List[str]) -> str:
+    command = [_ARM_COMPILER_NAME]
+    command.extend(cflags)
+    command.append(print_command)
+    result = subprocess.run(
+        command,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.STDOUT,
+    )
+    result.check_returncode()
+    return result.stdout.decode().rstrip()
+
+
+def get_gcc_lib_dir(cflags: List[str]) -> Path:
+    return Path(_compiler_info_command('-print-libgcc-file-name',
+                                       cflags)).parent
+
+
+def get_compiler_info(cflags: List[str]) -> Dict[str, str]:
+    compiler_info: Dict[str, str] = {}
+    compiler_info['gcc_libs_dir'] = str(get_gcc_lib_dir(cflags))
+    compiler_info['sysroot'] = _compiler_info_command('-print-sysroot', cflags)
+    compiler_info['version'] = _compiler_info_command('-dumpversion', cflags)
+    compiler_info['multi_dir'] = _compiler_info_command(
+        '-print-multi-directory', cflags)
+    return compiler_info
+
+
+def get_cflags(compiler_info: Dict[str, str]):
+    # TODO(amontanez): Make newlib-nano optional.
+    cflags = [
+        # TODO(amontanez): For some reason, -stdlib++-isystem and
+        # -isystem-after work, but emit unused argument errors. This is the only
+        # way to let the build succeed.
+        '-Qunused-arguments',
+        # Disable all default libraries.
+        "-nodefaultlibs",
+        '--target=arm-none-eabi'
+    ]
+
+    # Add sysroot info.
+    cflags.extend((
+        '--sysroot=' + compiler_info['sysroot'],
+        '-isystem' +
+        str(Path(compiler_info['sysroot']) / 'include' / 'newlib-nano'),
+        # This must be included after Clang's builtin headers.
+        '-isystem-after' + str(Path(compiler_info['sysroot']) / 'include'),
+        '-stdlib++-isystem' + str(
+            Path(compiler_info['sysroot']) / 'include' / 'c++' /
+            compiler_info['version']),
+        '-isystem' + str(
+            Path(compiler_info['sysroot']) / 'include' / 'c++' /
+            compiler_info['version'] / _ARM_COMPILER_PREFIX /
+            compiler_info['multi_dir']),
+    ))
+
+    return cflags
+
+
+def get_crt_objs(compiler_info: Dict[str, str]) -> Tuple[str, ...]:
+    return (
+        str(Path(compiler_info['gcc_libs_dir']) / 'crtfastmath.o'),
+        str(Path(compiler_info['gcc_libs_dir']) / 'crti.o'),
+        str(Path(compiler_info['gcc_libs_dir']) / 'crtn.o'),
+        str(
+            Path(compiler_info['sysroot']) / 'lib' /
+            compiler_info['multi_dir'] / 'crt0.o'),
+    )
+
+
+def get_ldflags(compiler_info: Dict[str, str]) -> List[str]:
+    ldflags: List[str] = [
+        '-lnosys',
+        # Add library search paths.
+        '-L' + compiler_info['gcc_libs_dir'],
+        '-L' + str(
+            Path(compiler_info['sysroot']) / 'lib' /
+            compiler_info['multi_dir']),
+        # Add libraries to link.
+        '-lc_nano',
+        '-lm',
+        '-lgcc',
+        '-lstdc++_nano',
+    ]
+
+    # Add C runtime object files.
+    objs = get_crt_objs(compiler_info)
+    ldflags.extend(objs)
+
+    return ldflags
+
+
+def main(
+    cflags: bool,
+    ldflags: bool,
+    gn_scope: bool,
+    clang_flags: List[str],
+) -> int:
+    """Script entry point."""
+    compiler_info = get_compiler_info(clang_flags)
+    if ldflags:
+        ldflag_list = get_ldflags(compiler_info)
+
+    if cflags:
+        cflag_list = get_cflags(compiler_info)
+
+    if not gn_scope:
+        flags = []
+        if cflags:
+            flags.extend(cflag_list)
+        if ldflags:
+            flags.extend(ldflag_list)
+        print(' '.join(flags))
+        return 0
+
+    if cflags:
+        print('cflags = [')
+        for flag in cflag_list:
+            print(f'  "{flag}",')
+        print(']')
+
+    if ldflags:
+        print('ldflags = [')
+        for flag in ldflag_list:
+            print(f'  "{flag}",')
+        print(']')
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main(**vars(_parse_args())))
diff --git a/pw_toolchain/py/pw_toolchain/copy_with_metadata.py b/pw_toolchain/py/pw_toolchain/copy_with_metadata.py
new file mode 100644
index 0000000..0d1402f
--- /dev/null
+++ b/pw_toolchain/py/pw_toolchain/copy_with_metadata.py
@@ -0,0 +1,53 @@
+#!/usr/bin/env python3
+# Copyright 2021 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.
+"""Emulation of `cp -af src dest`."""
+
+import logging
+import os
+import shutil
+import sys
+
+_LOG = logging.getLogger(__name__)
+
+
+def copy_with_metadata(src, dest):
+    """Emulation of `cp -af in out` command."""
+    if not os.path.exists(src):
+        _LOG.error('No such file or directory.')
+        return -1
+
+    try:
+        if os.path.isdir(src):
+            shutil.copytree(src, dest, symlinks=True)
+        else:
+            shutil.copy2(src, dest, follow_symlinks=False)
+    except:  # pylint: disable=bare-except
+        _LOG.exception('Error during copying procedure.')
+        return -1
+
+    return 0
+
+
+def main():
+    # Require exactly two arguments, source and destination.
+    if (len(sys.argv) - 1) != 2:
+        _LOG.error('Incorrect parameters provided.')
+        return -1
+
+    return copy_with_metadata(sys.argv[1], sys.argv[2])
+
+
+if __name__ == '__main__':
+    sys.exit(main())
diff --git a/pw_hdlc_lite/py/pw_hdlc_lite/py.typed b/pw_toolchain/py/pw_toolchain/py.typed
similarity index 100%
copy from pw_hdlc_lite/py/pw_hdlc_lite/py.typed
copy to pw_toolchain/py/pw_toolchain/py.typed
diff --git a/pw_toolchain/py/setup.py b/pw_toolchain/py/setup.py
new file mode 100644
index 0000000..088fe50
--- /dev/null
+++ b/pw_toolchain/py/setup.py
@@ -0,0 +1,25 @@
+# Copyright 2021 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_toolchain"""
+
+import setuptools  # type: ignore
+
+setuptools.setup(name='pw_toolchain',
+                 version='0.0.1',
+                 author='Pigweed Authors',
+                 author_email='pigweed-developers@googlegroups.com',
+                 description='Pigweed toolchain wrapper',
+                 packages=setuptools.find_packages(),
+                 package_data={'pw_toolchain': ['py.typed']},
+                 zip_safe=False)
diff --git a/pw_toolchain/subtoolchain.gni b/pw_toolchain/subtoolchain.gni
new file mode 100644
index 0000000..f5ba3dc
--- /dev/null
+++ b/pw_toolchain/subtoolchain.gni
@@ -0,0 +1,48 @@
+# 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_toolchain/generate_toolchain.gni")
+
+# Generates a toolchain using the currently active toolchain as a foundation.
+# WARNING: Only works with toolchains generated by Pigweed's generate_toolchain.
+# WARNING: This can quickly create an explosion of toolchains and compile times.
+#   Use sparingly!
+#
+# Args:
+#   build_args: (required) A scope setting GN build arg values to apply to GN
+#     targets in this toolchain. These take precedence over args.gni settings,
+#     and any build_args set by the currently active toolchain.
+#   (other): You can optionally override all other generate_toolchain args. See
+#     that template's documentation for more information.
+template("pw_generate_subtoolchain") {
+  assert(defined(invoker.build_args), "Sub-toolchain is missing 'build_args'")
+  _original_scope = pw_toolchain_SCOPE
+  assert(defined(_original_scope.name),
+         "Sub-toolchain must be generated from a Pigweed toolchain")
+
+  generate_toolchain(target_name) {
+    forward_variables_from(_original_scope, "*", [ "defaults" ])
+    forward_variables_from(invoker, "*", [ "build_args" ])
+
+    # Subtoolchains must always be generated from the toolchain that parses
+    # them rather than relying on the default_toolchain.
+    generate_from = current_toolchain
+    defaults = {
+      forward_variables_from(_original_scope.defaults, "*")
+      forward_variables_from(invoker.build_args, "*")
+    }
+  }
+}
diff --git a/pw_toolchain/universal_tools.gni b/pw_toolchain/universal_tools.gni
index 8fcc062..820276d 100644
--- a/pw_toolchain/universal_tools.gni
+++ b/pw_toolchain/universal_tools.gni
@@ -12,9 +12,18 @@
 # License for the specific language governing permissions and limitations under
 # the License.
 
+import("//build_overrides/pigweed.gni")
+
 pw_universal_copy = {
   if (host_os == "win") {
-    command = "cp -af {{source}} {{output}}"
+    cp_command = "cp -af {{source}} {{output}}"
+
+    # Use python script in absence of cp command.
+    copy_tool_path =
+        rebase_path(dir_pw_toolchain) + "/py/pw_toolchain/copy_with_metadata.py"
+    fallback_command = "python $copy_tool_path {{source}} {{output}}"
+
+    command = "cmd /c \"($cp_command > NUL 2>&1) || ($fallback_command)\""
   } else {
     # Use a hard link if possible as this is faster. Also, Mac doesn't
     # preserve timestamps properly with cp -af.
diff --git a/pw_trace/BUILD.gn b/pw_trace/BUILD.gn
index 25111d2..b0e455b 100644
--- a/pw_trace/BUILD.gn
+++ b/pw_trace/BUILD.gn
@@ -17,11 +17,7 @@
 import("$dir_pw_build/facade.gni")
 import("$dir_pw_docgen/docs.gni")
 import("$dir_pw_unit_test/test.gni")
-
-declare_args() {
-  # Backend for the pw_trace module.
-  pw_trace_BACKEND = ""
-}
+import("backend.gni")
 
 config("default_config") {
   include_dirs = [ "public" ]
diff --git a/pw_trace/backend.gni b/pw_trace/backend.gni
new file mode 100644
index 0000000..961dd57
--- /dev/null
+++ b/pw_trace/backend.gni
@@ -0,0 +1,18 @@
+# Copyright 2021 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.
+
+declare_args() {
+  # Backend for the pw_trace module.
+  pw_trace_BACKEND = ""
+}
diff --git a/pw_trace/example/sample_app.cc b/pw_trace/example/sample_app.cc
index c0fd0ea..8edefa9 100644
--- a/pw_trace/example/sample_app.cc
+++ b/pw_trace/example/sample_app.cc
@@ -38,7 +38,7 @@
 auto start = system_clock::now();
 uint32_t GetTimeSinceBootMillis() {
   auto delta = system_clock::now() - start;
-  return duration_cast<milliseconds>(delta).count();
+  return floor<milliseconds>(delta).count();
 }
 
 // Creating a very simple runnable with predictable behaviour to help with the
@@ -208,4 +208,4 @@
 
 }  // namespace
 
-void RunTraceSampleApp() { StartFakeKernel(); }
\ No newline at end of file
+void RunTraceSampleApp() { StartFakeKernel(); }
diff --git a/pw_trace/py/BUILD.gn b/pw_trace/py/BUILD.gn
index caf8bc5..736258c 100644
--- a/pw_trace/py/BUILD.gn
+++ b/pw_trace/py/BUILD.gn
@@ -23,4 +23,5 @@
     "pw_trace/trace.py",
   ]
   tests = [ "trace_test.py" ]
+  pylintrc = "$dir_pigweed/.pylintrc"
 }
diff --git a/pw_trace/trace_backend_compile_test_c.c b/pw_trace/trace_backend_compile_test_c.c
index 0001775..4d99c07 100644
--- a/pw_trace/trace_backend_compile_test_c.c
+++ b/pw_trace/trace_backend_compile_test_c.c
@@ -22,7 +22,7 @@
 #error "This file must be compiled as plain C to verify C compilation works."
 #endif  // __cplusplus
 
-void BasicTraceTestPlainC() {
+void BasicTraceTestPlainC(void) {
   PW_TRACE_INSTANT("Test");
 
   PW_TRACE_START("Test");
diff --git a/pw_trace_tokenized/BUILD b/pw_trace_tokenized/BUILD
index 4c3175f..1944d71 100644
--- a/pw_trace_tokenized/BUILD
+++ b/pw_trace_tokenized/BUILD
@@ -61,6 +61,24 @@
 )
 
 pw_cc_library(
+    name = "trace_rpc_service",
+    hdrs = [
+        "public/pw_trace_tokenized/trace_rpc_service_nanopb.h",
+    ],
+    includes = [
+        "public",
+    ],
+    srcs = [
+        "trace_rpc_service_nanopb.cc",
+    ],
+    deps = [
+        "//pw_log",
+        "//pw_trace",
+        "//pw_trace_tokenized_buffer",
+    ],
+)
+
+pw_cc_library(
     name = "trace_buffer_headers",
     hdrs = [
         "public/pw_trace_tokenized/trace_buffer.h",
@@ -205,3 +223,17 @@
     ],
     srcs = [ "example/filter.cc" ]
 )
+
+pw_cc_library(
+    name = "trace_tokenized_example_rpc",
+    deps = [
+        ":pw_trace_rpc_service",
+        "//dir_pw_rpc:server",
+        "//dir_pw_rpc:system_server",
+        "//pw_log",
+        "//pw_hdlc",
+        "//dir_pw_trace",
+        "//dir_pw_trace:pw_trace_sample_app",
+    ],
+    srcs = [ "example/rpc.cc" ]
+)
\ No newline at end of file
diff --git a/pw_trace_tokenized/BUILD.gn b/pw_trace_tokenized/BUILD.gn
index c031e89..6ed2a0b 100644
--- a/pw_trace_tokenized/BUILD.gn
+++ b/pw_trace_tokenized/BUILD.gn
@@ -14,22 +14,12 @@
 
 import("//build_overrides/pigweed.gni")
 
-import("$dir_pw_build/module_config.gni")
+import("$dir_pw_build/target_types.gni")
 import("$dir_pw_docgen/docs.gni")
+import("$dir_pw_protobuf_compiler/proto.gni")
+import("$dir_pw_third_party/nanopb/nanopb.gni")
 import("$dir_pw_unit_test/test.gni")
-
-declare_args() {
-  # The build target that overrides the default configuration options for this
-  # module. This should point to a source set that provides defines through a
-  # public config (which may -include a file or add defines directly).
-  pw_trace_CONFIG = pw_build_DEFAULT_MODULE_CONFIG
-
-  # Tokenizer trace time, gets included if provided
-  pw_trace_tokenizer_time = ""
-
-  # Trace buffer size in bytes. Set to 0 to disable.
-  pw_trace_tokenized_BUFFER_SIZE = 256
-}
+import("config.gni")
 
 config("public_include_path") {
   include_dirs = [ "public" ]
@@ -61,7 +51,7 @@
   ]
   public_deps = [
     ":config",
-    ":pw_trace_tokenized_core",
+    ":core",
     "$dir_pw_tokenizer",
   ]
   if (pw_trace_tokenizer_time != "") {
@@ -74,7 +64,7 @@
 pw_test("trace_tokenized_test") {
   enable_if = pw_trace_tokenizer_time != ""
   deps = [
-    ":pw_trace_tokenized_core",
+    ":core",
     "$dir_pw_trace",
   ]
 
@@ -85,8 +75,28 @@
   defines = [ "PW_TRACE_BUFFER_SIZE_BYTES=${pw_trace_tokenized_BUFFER_SIZE}" ]
 }
 
+pw_proto_library("trace_rpc_service_proto") {
+  sources = [ "pw_trace_protos/trace_rpc.proto" ]
+  inputs = [ "pw_trace_protos/trace_rpc.options" ]
+}
+
+pw_source_set("trace_rpc_service") {
+  public_configs = [ ":public_include_path" ]
+  public_deps = [ ":trace_rpc_service_proto.nanopb_rpc" ]
+  deps = [
+    ":core",
+    ":tokenized_trace_buffer",
+    "$dir_pw_log",
+    "$dir_pw_trace",
+  ]
+  sources = [
+    "public/pw_trace_tokenized/trace_rpc_service_nanopb.h",
+    "trace_rpc_service_nanopb.cc",
+  ]
+}
+
 pw_source_set("tokenized_trace_buffer") {
-  deps = [ ":pw_trace_tokenized_core" ]
+  deps = [ ":core" ]
   public_deps = [
     ":config",
     "$dir_pw_ring_buffer",
@@ -132,16 +142,16 @@
 }
 
 pw_source_set("fake_trace_time") {
-  deps = [ ":pw_trace_tokenized_core" ]
+  deps = [ ":core" ]
   sources = [ "fake_trace_time.cc" ]
 }
 
 pw_source_set("host_trace_time") {
-  deps = [ ":pw_trace_tokenized_core" ]
+  deps = [ ":core" ]
   sources = [ "host_trace_time.cc" ]
 }
 
-pw_source_set("pw_trace_tokenized_core") {
+pw_source_set("core") {
   public_configs = [
     ":backend_config",
     ":public_include_path",
@@ -163,6 +173,7 @@
     "public/pw_trace_tokenized/trace_tokenized.h",
   ]
   sources = [ "trace.cc" ]
+  visibility = [ ":*" ]
 }
 
 pw_doc_group("docs") {
@@ -209,3 +220,21 @@
   ]
   sources = [ "example/filter.cc" ]
 }
+
+if (dir_pw_third_party_nanopb == "") {
+  group("trace_tokenized_example_rpc") {
+  }
+} else {
+  pw_executable("trace_tokenized_example_rpc") {
+    sources = [ "example/rpc.cc" ]
+    deps = [
+      ":trace_rpc_service",
+      "$dir_pw_hdlc",
+      "$dir_pw_log",
+      "$dir_pw_rpc:server",
+      "$dir_pw_rpc/system_server",
+      "$dir_pw_trace",
+      "$dir_pw_trace:trace_sample_app",
+    ]
+  }
+}
diff --git a/pw_trace_tokenized/config.gni b/pw_trace_tokenized/config.gni
new file mode 100644
index 0000000..4aef650
--- /dev/null
+++ b/pw_trace_tokenized/config.gni
@@ -0,0 +1,30 @@
+# Copyright 2021 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/module_config.gni")
+
+declare_args() {
+  # The build target that overrides the default configuration options for this
+  # module. This should point to a source set that provides defines through a
+  # public config (which may -include a file or add defines directly).
+  pw_trace_CONFIG = pw_build_DEFAULT_MODULE_CONFIG
+
+  # Tokenizer trace time, gets included if provided
+  pw_trace_tokenizer_time = ""
+
+  # Trace buffer size in bytes. Set to 0 to disable.
+  pw_trace_tokenized_BUFFER_SIZE = 256
+}
diff --git a/pw_trace_tokenized/example/filter.cc b/pw_trace_tokenized/example/filter.cc
index 8cbe3f4..573823a 100644
--- a/pw_trace_tokenized/example/filter.cc
+++ b/pw_trace_tokenized/example/filter.cc
@@ -34,17 +34,14 @@
 #include "pw_trace_tokenized/trace_callback.h"
 #include "pw_trace_tokenized/trace_tokenized.h"
 
-pw_trace_TraceEventReturnFlags TraceEventCallback(void* user_data,
-                                                  uint32_t trace_ref,
-                                                  pw_trace_EventType event_type,
-                                                  const char* module,
-                                                  uint32_t trace_id,
-                                                  uint8_t flags) {
+pw_trace_TraceEventReturnFlags TraceEventCallback(
+    void* /* user_data */,
+    uint32_t /* trace_ref */,
+    pw_trace_EventType /* event_type */,
+    const char* module,
+    uint32_t trace_id,
+    uint8_t /* flags */) {
   // Filter out all traces from processing task, which aren't traceId 3
-  PW_UNUSED(user_data);
-  PW_UNUSED(event_type);
-  PW_UNUSED(trace_ref);
-  PW_UNUSED(flags);
   static constexpr uint32_t kFilterId = 3;
   return (strcmp("Processing", module) == 0 && trace_id != kFilterId)
              ? PW_TRACE_EVENT_RETURN_FLAGS_SKIP_EVENT
@@ -68,4 +65,4 @@
   PW_LOG_INFO("Running filter example...");
   RunTraceSampleApp();
   return 0;
-}
\ No newline at end of file
+}
diff --git a/pw_trace_tokenized/example/rpc.cc b/pw_trace_tokenized/example/rpc.cc
new file mode 100644
index 0000000..8126d6a
--- /dev/null
+++ b/pw_trace_tokenized/example/rpc.cc
@@ -0,0 +1,66 @@
+// Copyright 2021 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.
+//==============================================================================
+/*
+BUILD
+ninja -C out
+host_clang_debug/obj/pw_trace_tokenized/bin/trace_tokenized_example_rpc
+
+RUN
+.out/host_clang_debug/obj/pw_trace_tokenized/bin/trace_tokenized_example_rpc
+
+DECODE
+python pw_trace_tokenized/py/pw_trace_tokenized/get_trace.py
+ -s localhost:33000
+ -o trace.json
+ -t
+ out/host_clang_debug/obj/pw_trace_tokenized/bin/trace_tokenized_example_rpc
+ pw_trace_tokenized/pw_trace_protos/trace_rpc.proto
+
+VIEW
+In chrome navigate to chrome://tracing, and load the trace.json file.
+*/
+#include <thread>
+
+#include "pw_log/log.h"
+#include "pw_rpc/server.h"
+#include "pw_rpc_system_server/rpc_server.h"
+#include "pw_trace/example/sample_app.h"
+#include "pw_trace/trace.h"
+#include "pw_trace_tokenized/trace_rpc_service_nanopb.h"
+
+namespace {
+
+pw::trace::TraceService trace_service;
+
+void RpcThread() {
+  pw::rpc::system_server::Init();
+
+  // Set up the server and start processing data.
+  pw::rpc::system_server::Server().RegisterService(trace_service);
+  pw::rpc::system_server::Start();
+}
+
+}  // namespace
+
+int main() {
+  std::thread rpc_thread(RpcThread);
+
+  // Enable tracing.
+  PW_TRACE_SET_ENABLED(true);
+
+  PW_LOG_INFO("Running basic trace example...\n");
+  RunTraceSampleApp();
+  return 0;
+}
\ No newline at end of file
diff --git a/pw_trace_tokenized/example/trigger.cc b/pw_trace_tokenized/example/trigger.cc
index 8cfaa19..011ec5f 100644
--- a/pw_trace_tokenized/example/trigger.cc
+++ b/pw_trace_tokenized/example/trigger.cc
@@ -52,16 +52,13 @@
 
 }  // namespace
 
-pw_trace_TraceEventReturnFlags TraceEventCallback(void* user_data,
-                                                  uint32_t trace_ref,
-                                                  pw_trace_EventType event_type,
-                                                  const char* module,
-                                                  uint32_t trace_id,
-                                                  uint8_t flags) {
-  PW_UNUSED(user_data);
-  PW_UNUSED(event_type);
-  PW_UNUSED(module);
-  PW_UNUSED(flags);
+pw_trace_TraceEventReturnFlags TraceEventCallback(
+    void* /* user_data */,
+    uint32_t trace_ref,
+    pw_trace_EventType /* event_type */,
+    const char* /* module */,
+    uint32_t trace_id,
+    uint8_t /* flags */) {
   if (trace_ref == kTriggerStartTraceRef && trace_id == kTriggerId) {
     PW_LOG_INFO("Trace capture started!");
     PW_TRACE_SET_ENABLED(true);
@@ -92,4 +89,4 @@
   PW_LOG_INFO("Running trigger example...");
   RunTraceSampleApp();
   return 0;
-}
\ No newline at end of file
+}
diff --git a/pw_trace_tokenized/host_trace_time.cc b/pw_trace_tokenized/host_trace_time.cc
index ed004dc..ce60880 100644
--- a/pw_trace_tokenized/host_trace_time.cc
+++ b/pw_trace_tokenized/host_trace_time.cc
@@ -29,7 +29,7 @@
 // Define trace time as a counter for tests.
 PW_TRACE_TIME_TYPE pw_trace_GetTraceTime() {
   auto delta = steady_clock::now() - start;
-  return duration_cast<microseconds>(delta).count();
+  return floor<microseconds>(delta).count();
 }
 
 // Microsecond time source
diff --git a/pw_trace_tokenized/public/pw_trace_tokenized/config.h b/pw_trace_tokenized/public/pw_trace_tokenized/config.h
index db77230..b957610 100644
--- a/pw_trace_tokenized/public/pw_trace_tokenized/config.h
+++ b/pw_trace_tokenized/public/pw_trace_tokenized/config.h
@@ -40,7 +40,7 @@
 // provided by the platform.
 #ifndef PW_TRACE_GET_TIME
 #define PW_TRACE_GET_TIME() pw_trace_GetTraceTime()
-extern PW_TRACE_TIME_TYPE pw_trace_GetTraceTime();
+extern PW_TRACE_TIME_TYPE pw_trace_GetTraceTime(void);
 #endif  // PW_TRACE_GET_TIME
 
 // PW_TRACE_GET_TIME_TICKS_PER_SECOND is the macro which is called to determine
@@ -50,7 +50,7 @@
 #ifndef PW_TRACE_GET_TIME_TICKS_PER_SECOND
 #define PW_TRACE_GET_TIME_TICKS_PER_SECOND() \
   pw_trace_GetTraceTimeTicksPerSecond()
-extern size_t pw_trace_GetTraceTimeTicksPerSecond();
+extern size_t pw_trace_GetTraceTimeTicksPerSecond(void);
 #endif  // PW_TRACE_GET_TIME_TICKS_PER_SECOND
 
 // PW_TRACE_GET_TIME_DELTA is te macro which is called to determine
diff --git a/pw_trace_tokenized/public/pw_trace_tokenized/internal/trace_tokenized_internal.h b/pw_trace_tokenized/public/pw_trace_tokenized/internal/trace_tokenized_internal.h
index 5038887..8427dc6 100644
--- a/pw_trace_tokenized/public/pw_trace_tokenized/internal/trace_tokenized_internal.h
+++ b/pw_trace_tokenized/public/pw_trace_tokenized/internal/trace_tokenized_internal.h
@@ -73,7 +73,7 @@
 void pw_trace_Enable(bool enabled);
 
 // Returns true if tracing is currently enabled.
-bool pw_trace_IsEnabled();
+bool pw_trace_IsEnabled(void);
 
 PW_EXTERN_C_END
 
diff --git a/pw_trace_tokenized/public/pw_trace_tokenized/trace_callback.h b/pw_trace_tokenized/public/pw_trace_tokenized/trace_callback.h
index 426820c..90d119d 100644
--- a/pw_trace_tokenized/public/pw_trace_tokenized/trace_callback.h
+++ b/pw_trace_tokenized/public/pw_trace_tokenized/trace_callback.h
@@ -89,7 +89,7 @@
 // start, allowing buffers to allocate the required amount at the start when
 // necessary.
 //
-// If Status::Ok() is not returned from Start, the events bytes will be skipped.
+// If OkStatus() is not returned from Start, the events bytes will be skipped.
 //
 // NOTE: Called while tracing is locked (which might be a critical section
 // depending on application), so quick/simple operations only. One trace event
diff --git a/pw_trace_tokenized/public/pw_trace_tokenized/trace_rpc_service_nanopb.h b/pw_trace_tokenized/public/pw_trace_tokenized/trace_rpc_service_nanopb.h
new file mode 100644
index 0000000..cc326c1
--- /dev/null
+++ b/pw_trace_tokenized/public/pw_trace_tokenized/trace_rpc_service_nanopb.h
@@ -0,0 +1,35 @@
+// Copyright 2021 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.
+#pragma once
+
+#include "pw_trace_protos/trace_rpc.rpc.pb.h"
+
+namespace pw::trace {
+
+class TraceService final : public generated::TraceService<TraceService> {
+ public:
+  pw::Status Enable(ServerContext&,
+                    const pw_trace_TraceEnableMessage& request,
+                    pw_trace_TraceEnableMessage& response);
+
+  pw::Status IsEnabled(ServerContext&,
+                       const pw_trace_Empty& request,
+                       pw_trace_TraceEnableMessage& response);
+
+  void GetTraceData(ServerContext&,
+                    const pw_trace_Empty& request,
+                    ServerWriter<pw_trace_TraceDataMessage>& writer);
+};
+
+}  // namespace pw::trace
diff --git a/pw_trace_tokenized/public/pw_trace_tokenized/trace_tokenized.h b/pw_trace_tokenized/public/pw_trace_tokenized/trace_tokenized.h
index a7e5ca4..34d9456 100644
--- a/pw_trace_tokenized/public/pw_trace_tokenized/trace_tokenized.h
+++ b/pw_trace_tokenized/public/pw_trace_tokenized/trace_tokenized.h
@@ -63,7 +63,7 @@
                          const void* data_buffer,
                          size_t data_size) {
     if (IsFull()) {
-      return pw::Status::RESOURCE_EXHAUSTED;
+      return pw::Status::ResourceExhausted();
     }
     event_queue_[head_].trace_token = trace_token;
     event_queue_[head_].event_type = event_type;
@@ -77,7 +77,7 @@
     }
     head_ = (head_ + 1) % kSize;
     is_empty_ = false;
-    return pw::Status::OK;
+    return pw::OkStatus();
   }
 
   const volatile QueueEventBlock* PeekFront() const {
diff --git a/pw_trace_tokenized/pw_trace_protos/trace_rpc.options b/pw_trace_tokenized/pw_trace_protos/trace_rpc.options
new file mode 100644
index 0000000..37508ac
--- /dev/null
+++ b/pw_trace_tokenized/pw_trace_protos/trace_rpc.options
@@ -0,0 +1,15 @@
+// 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.trace.TraceDataMessage.data max_size:64
diff --git a/pw_trace_tokenized/pw_trace_protos/trace_rpc.proto b/pw_trace_tokenized/pw_trace_protos/trace_rpc.proto
new file mode 100644
index 0000000..39971e7
--- /dev/null
+++ b/pw_trace_tokenized/pw_trace_protos/trace_rpc.proto
@@ -0,0 +1,32 @@
+// Copyright 2021 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.
+syntax = "proto3";
+
+package pw.trace;
+
+service TraceService {
+  rpc Enable(TraceEnableMessage) returns (TraceEnableMessage) {}
+  rpc IsEnabled(Empty) returns (TraceEnableMessage) {}
+  rpc GetTraceData(Empty) returns (stream TraceDataMessage) {}
+}
+
+message Empty {}
+
+message TraceEnableMessage {
+  bool enable = 1;
+}
+
+message TraceDataMessage {
+  bytes data = 1;
+}
diff --git a/pw_trace_tokenized/py/BUILD.gn b/pw_trace_tokenized/py/BUILD.gn
index bae2cde..22ec840 100644
--- a/pw_trace_tokenized/py/BUILD.gn
+++ b/pw_trace_tokenized/py/BUILD.gn
@@ -20,6 +20,13 @@
   setup = [ "setup.py" ]
   sources = [
     "pw_trace_tokenized/__init__.py",
+    "pw_trace_tokenized/get_trace.py",
     "pw_trace_tokenized/trace_tokenized.py",
   ]
+  python_deps = [
+    "$dir_pw_hdlc/py",
+    "$dir_pw_tokenizer/py",
+    "$dir_pw_trace/py",
+  ]
+  pylintrc = "$dir_pigweed/.pylintrc"
 }
diff --git a/pw_trace_tokenized/py/pw_trace_tokenized/get_trace.py b/pw_trace_tokenized/py/pw_trace_tokenized/get_trace.py
new file mode 100755
index 0000000..988f62b
--- /dev/null
+++ b/pw_trace_tokenized/py/pw_trace_tokenized/get_trace.py
@@ -0,0 +1,148 @@
+#!/usr/bin/env python3
+# Copyright 2021 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.
+r"""
+Generates json trace files viewable using chrome://tracing using RPCs from a
+connected HdlcRpcClient.
+
+Example usage:
+python pw_trace_tokenized/py/pw_trace_tokenized/get_trace.py -s localhost:33000
+  -o trace.json
+  -t out/host_clang_debug/obj/pw_trace_tokenized/bin/trace_tokenized_example_rpc
+  pw_trace_tokenized/pw_trace_protos/trace_rpc.proto
+"""
+import argparse
+import logging
+import glob
+from pathlib import Path
+import sys
+from typing import Collection, Iterable, Iterator
+import serial  # type: ignore
+from pw_tokenizer import database
+from pw_trace import trace
+from pw_hdlc.rpc import HdlcRpcClient, default_channels
+from pw_hdlc.rpc_console import SocketClientImpl
+from pw_trace_tokenized import trace_tokenized
+
+_LOG = logging.getLogger('pw_trace_tokenizer')
+
+PW_RPC_MAX_PACKET_SIZE = 256
+SOCKET_SERVER = 'localhost'
+SOCKET_PORT = 33000
+MKFIFO_MODE = 0o666
+
+
+def _expand_globs(globs: Iterable[str]) -> Iterator[Path]:
+    for pattern in globs:
+        for file in glob.glob(pattern, recursive=True):
+            yield Path(file)
+
+
+def get_hdlc_rpc_client(device: str, baudrate: int,
+                        proto_globs: Collection[str], socket_addr: str,
+                        **kwargs):
+    """Get the HdlcRpcClient based on arguments."""
+    del kwargs  # ignore
+    if not proto_globs:
+        proto_globs = ['**/*.proto']
+
+    protos = list(_expand_globs(proto_globs))
+
+    if not protos:
+        _LOG.critical('No .proto files were found with %s',
+                      ', '.join(proto_globs))
+        _LOG.critical('At least one .proto file is required')
+        return 1
+
+    _LOG.debug('Found %d .proto files found with %s', len(protos),
+               ', '.join(proto_globs))
+
+    # TODO(rgoliver): When pw has a generalized transport for RPC this should
+    # use it so it isn't specific to HDLC
+    if socket_addr is None:
+        serial_device = serial.Serial(device, baudrate, timeout=1)
+        read = lambda: serial_device.read(8192)
+        write = serial_device.write
+    else:
+        try:
+            socket_device = SocketClientImpl(socket_addr)
+            read = socket_device.read
+            write = socket_device.write
+        except ValueError:
+            _LOG.exception('Failed to initialize socket at %s', socket_addr)
+            return 1
+
+    return HdlcRpcClient(read, protos, default_channels(write))
+
+
+def get_trace_data_from_device(client):
+    """ Get the trace data using RPC from a Client"""
+    data = b''
+    result = \
+        client.client.channel(1).rpcs.pw.trace.TraceService.GetTraceData().get()
+    for streamed_data in result:
+        data = data + bytes([len(streamed_data.data)])
+        data = data + streamed_data.data
+        _LOG.debug(''.join(format(x, '02x') for x in streamed_data.data))
+    return data
+
+
+def _parse_args():
+    """Parse and return command line arguments."""
+
+    parser = argparse.ArgumentParser(
+        description=__doc__,
+        formatter_class=argparse.RawDescriptionHelpFormatter)
+    group = parser.add_mutually_exclusive_group(required=True)
+    group.add_argument('-d', '--device', help='the serial port to use')
+    parser.add_argument('-b',
+                        '--baudrate',
+                        type=int,
+                        default=115200,
+                        help='the baud rate to use')
+    group.add_argument('-s',
+                       '--socket-addr',
+                       type=str,
+                       help='use socket to connect to server, type default for\
+            localhost:33000, or manually input the server address:port')
+    parser.add_argument('-o',
+                        '--trace_output',
+                        dest='trace_output_file',
+                        help=('The json file to which to write the output.'))
+    parser.add_argument(
+        '-t',
+        '--trace_token_database',
+        help='Databases (ELF, binary, or CSV) to use to lookup trace tokens.')
+    parser.add_argument('proto_globs',
+                        nargs='+',
+                        help='glob pattern for .proto files')
+
+    return parser.parse_args()
+
+
+def _main(args):
+    token_database = \
+        database.load_token_database(args.trace_token_database, domain="trace")
+    _LOG.info(database.database_summary(token_database))
+    client = get_hdlc_rpc_client(**vars(args))
+    data = get_trace_data_from_device(client)
+    events = trace_tokenized.get_trace_events([token_database], data)
+    json_lines = trace.generate_trace_json(events)
+    trace_tokenized.save_trace_file(json_lines, args.trace_output_file)
+
+
+if __name__ == '__main__':
+    if sys.version_info[0] < 3:
+        sys.exit('ERROR: The detokenizer command line tools require Python 3.')
+    _main(_parse_args())
diff --git a/pw_trace_tokenized/py/pw_trace_tokenized/trace_tokenized.py b/pw_trace_tokenized/py/pw_trace_tokenized/trace_tokenized.py
index 3e88a8a..fd16b3c 100755
--- a/pw_trace_tokenized/py/pw_trace_tokenized/trace_tokenized.py
+++ b/pw_trace_tokenized/py/pw_trace_tokenized/trace_tokenized.py
@@ -107,6 +107,7 @@
 
 
 def parse_trace_event(buffer, db, last_time, ticks_per_second=1000):
+    """Parse a single trace event from bytes"""
     us_per_tick = 1000000 / ticks_per_second
     idx = 0
     # Read token
@@ -116,6 +117,8 @@
     # Decode token
     if len(db.token_to_entries[token]) == 0:
         _LOG.error("token not found: %08x", token)
+        return None
+
     token_string = str(db.token_to_entries[token][0])
 
     # Read time
@@ -138,31 +141,52 @@
     return create_trace_event(token_string, timestamp_us, trace_id, data)
 
 
-def get_trace_events_from_file(databases, input_file_name):
+def get_trace_events(databases, raw_trace_data):
     """Handles the decoding traces."""
 
     db = tokens.Database.merged(*databases)
     last_timestamp = 0
     events = []
-    with open(input_file_name, "rb") as input_file:
-        bytes_read = input_file.read()
-        idx = 0
+    idx = 0
 
-        while idx + 1 < len(bytes_read):
-            # Read size
-            size = int(bytes_read[idx])
-            if idx + size > len(bytes_read):
-                _LOG.error("incomplete file")
-                break
+    while idx + 1 < len(raw_trace_data):
+        # Read size
+        size = int(raw_trace_data[idx])
+        if idx + size > len(raw_trace_data):
+            _LOG.error("incomplete file")
+            break
 
-            event = parse_trace_event(bytes_read[idx + 1:idx + 1 + size], db,
-                                      last_timestamp)
+        event = parse_trace_event(raw_trace_data[idx + 1:idx + 1 + size], db,
+                                  last_timestamp)
+        if event:
             last_timestamp = event.timestamp_us
             events.append(event)
-            idx = idx + size + 1
+        idx = idx + size + 1
     return events
 
 
+def get_trace_data_from_file(input_file_name):
+    """Handles the decoding traces."""
+    with open(input_file_name, "rb") as input_file:
+        return input_file.read()
+    return None
+
+
+def save_trace_file(trace_lines, file_name):
+    """Handles generating the trace file."""
+    with open(file_name, 'w') as output_file:
+        output_file.write("[")
+        for line in trace_lines:
+            output_file.write("%s,\n" % line)
+        output_file.write("{}]")
+
+
+def get_trace_events_from_file(databases, input_file_name):
+    """Get trace events from a file."""
+    raw_trace_data = get_trace_data_from_file(input_file_name)
+    return get_trace_events(databases, raw_trace_data)
+
+
 def _parse_args():
     """Parse and return command line arguments."""
 
@@ -190,10 +214,7 @@
 def _main(args):
     events = get_trace_events_from_file(args.databases, args.input_file)
     json_lines = trace.generate_trace_json(events)
-
-    with open(args.output_file, 'w') as output_file:
-        for line in json_lines:
-            output_file.write("%s,\n" % line)
+    save_trace_file(json_lines, args.output_file)
 
 
 if __name__ == '__main__':
diff --git a/pw_trace_tokenized/py/setup.py b/pw_trace_tokenized/py/setup.py
index cea2439..c20e9cf 100644
--- a/pw_trace_tokenized/py/setup.py
+++ b/pw_trace_tokenized/py/setup.py
@@ -24,4 +24,5 @@
     packages=setuptools.find_packages(),
     package_data={'pw_trace_tokenized': ['py.typed']},
     zip_safe=False,
+    install_requires=['pw_tokenizer'],
 )
diff --git a/pw_trace_tokenized/trace_buffer_log.cc b/pw_trace_tokenized/trace_buffer_log.cc
index 3d9e5a6..adaf17a 100644
--- a/pw_trace_tokenized/trace_buffer_log.cc
+++ b/pw_trace_tokenized/trace_buffer_log.cc
@@ -78,7 +78,7 @@
     PW_LOG_INFO("[TRACE] data: %s", line_builder.c_str());
   }
   PW_LOG_INFO("[TRACE] end");
-  return pw::Status::Ok();
+  return pw::OkStatus();
 }
 
 }  // namespace trace
diff --git a/pw_trace_tokenized/trace_buffer_test.cc b/pw_trace_tokenized/trace_buffer_test.cc
index bbe7dc8..410db74 100644
--- a/pw_trace_tokenized/trace_buffer_test.cc
+++ b/pw_trace_tokenized/trace_buffer_test.cc
@@ -84,7 +84,7 @@
   std::byte value[expected_max_bytes_used];
   size_t bytes_read = 0;
   EXPECT_EQ(buf->PeekFront(std::span<std::byte>(value), &bytes_read),
-            pw::Status::Ok());
+            pw::OkStatus());
 
   // read size is minus 1, since doesn't include varint size
   EXPECT_GE(bytes_read, expected_min_bytes_used - 1);
@@ -123,8 +123,8 @@
     std::byte value[PW_TRACE_BUFFER_MAX_BLOCK_SIZE_BYTES];
     size_t bytes_read = 0;
     EXPECT_EQ(buf->PeekFront(std::span<std::byte>(value), &bytes_read),
-              pw::Status::Ok());
-    EXPECT_EQ(buf->PopFront(), pw::Status::Ok());
+              pw::OkStatus());
+    EXPECT_EQ(buf->PopFront(), pw::OkStatus());
     EXPECT_EQ(*reinterpret_cast<size_t*>(&value[bytes_read - sizeof(size_t)]),
               expected_count);
     expected_count++;
diff --git a/pw_trace_tokenized/trace_rpc_service_nanopb.cc b/pw_trace_tokenized/trace_rpc_service_nanopb.cc
new file mode 100644
index 0000000..a6893da
--- /dev/null
+++ b/pw_trace_tokenized/trace_rpc_service_nanopb.cc
@@ -0,0 +1,63 @@
+// Copyright 2021 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.
+//==============================================================================
+
+#include "pw_trace_tokenized/trace_rpc_service_nanopb.h"
+
+#include "pw_log/log.h"
+#include "pw_preprocessor/util.h"
+#include "pw_trace_tokenized/trace_buffer.h"
+#include "pw_trace_tokenized/trace_tokenized.h"
+
+namespace pw::trace {
+
+pw::Status TraceService::Enable(ServerContext&,
+                                const pw_trace_TraceEnableMessage& request,
+                                pw_trace_TraceEnableMessage& response) {
+  TokenizedTrace::Instance().Enable(request.enable);
+  response.enable = TokenizedTrace::Instance().IsEnabled();
+  return PW_STATUS_OK;
+}
+
+pw::Status TraceService::IsEnabled(ServerContext&,
+                                   const pw_trace_Empty&,
+                                   pw_trace_TraceEnableMessage& response) {
+  response.enable = TokenizedTrace::Instance().IsEnabled();
+  return PW_STATUS_OK;
+}
+
+void TraceService::GetTraceData(
+    ServerContext&,
+    const pw_trace_Empty&,
+    ServerWriter<pw_trace_TraceDataMessage>& writer) {
+  pw_trace_TraceDataMessage buffer = pw_trace_TraceDataMessage_init_default;
+  size_t size = 0;
+  pw::ring_buffer::PrefixedEntryRingBuffer* trace_buffer =
+      pw::trace::GetBuffer();
+
+  while (trace_buffer->PeekFront(
+             std::as_writable_bytes(std::span(buffer.data.bytes)), &size) !=
+         pw::Status::OutOfRange()) {
+    trace_buffer->PopFront();
+    buffer.data.size = size;
+    pw::Status status = writer.Write(buffer);
+    if (!status.ok()) {
+      PW_LOG_ERROR("Error sending trace; abandoning trace dump. Error: %s",
+                   status.str());
+      break;
+    }
+  }
+  writer.Finish();
+}
+}  // namespace pw::trace
\ No newline at end of file
diff --git a/pw_trace_tokenized/trace_test.cc b/pw_trace_tokenized/trace_test.cc
index 5e6d2a4..616d908 100644
--- a/pw_trace_tokenized/trace_test.cc
+++ b/pw_trace_tokenized/trace_test.cc
@@ -21,14 +21,17 @@
 // clang-format on
 
 #include <deque>
+
 #include "gtest/gtest.h"
 
 namespace {
 
-// Moving these to other lines will require updating the variables
-#define kTraceFunctionLine 32
-#define kTraceFunctionGroupLine 33
-#define kTraceFunctionIdLine 35
+// These are line numbers for the functions below. Moving these functions to
+// other lines will require updating these macros.
+#define TRACE_FUNCTION_LINE 35
+#define TRACE_FUNCTION_GROUP_LINE 36
+#define TRACE_FUNCTION_ID_LINE 38
+
 void TraceFunction() { PW_TRACE_FUNCTION(); }
 void TraceFunctionGroup() { PW_TRACE_FUNCTION("FunctionGroup"); }
 void TraceFunctionTraceId(uint32_t id) {
@@ -86,10 +89,9 @@
       pw_trace_EventType event_type,
       const char* module,
       uint32_t trace_id,
-      uint8_t flags) {
+      uint8_t /* flags */) {
     TraceTestInterface* test_interface =
         reinterpret_cast<TraceTestInterface*>(user_data);
-    PW_UNUSED(flags);
     pw_trace_TraceEventReturnFlags ret = 0;
     if (test_interface->action_ != ActionOnEvent::None &&
         (test_interface->event_match_.trace_ref == trace_ref ||
@@ -127,7 +129,7 @@
                                 size_t size) {
     TraceTestInterface* test_interface =
         reinterpret_cast<TraceTestInterface*>(user_data);
-    PW_UNUSED(bytes);
+    static_cast<void>(bytes);
     test_interface->sink_bytes_received_ += size;
   }
 
@@ -477,12 +479,14 @@
   TraceFunction();
 
   // Check results
-  EXPECT_TRACE(test_interface,
-               PW_TRACE_TYPE_DURATION_START,
-               PW_TRACE_FUNCTION_LABEL_FILE_LINE(__FILE__, kTraceFunctionLine));
-  EXPECT_TRACE(test_interface,
-               PW_TRACE_TYPE_DURATION_END,
-               PW_TRACE_FUNCTION_LABEL_FILE_LINE(__FILE__, kTraceFunctionLine));
+  EXPECT_TRACE(
+      test_interface,
+      PW_TRACE_TYPE_DURATION_START,
+      PW_TRACE_FUNCTION_LABEL_FILE_LINE(__FILE__, TRACE_FUNCTION_LINE));
+  EXPECT_TRACE(
+      test_interface,
+      PW_TRACE_TYPE_DURATION_END,
+      PW_TRACE_FUNCTION_LABEL_FILE_LINE(__FILE__, TRACE_FUNCTION_LINE));
   EXPECT_TRUE(test_interface.GetEvents().empty());
 }
 
@@ -495,12 +499,12 @@
   EXPECT_TRACE(
       test_interface,
       PW_TRACE_TYPE_DURATION_GROUP_START,
-      PW_TRACE_FUNCTION_LABEL_FILE_LINE(__FILE__, kTraceFunctionGroupLine),
+      PW_TRACE_FUNCTION_LABEL_FILE_LINE(__FILE__, TRACE_FUNCTION_GROUP_LINE),
       "FunctionGroup");
   EXPECT_TRACE(
       test_interface,
       PW_TRACE_TYPE_DURATION_GROUP_END,
-      PW_TRACE_FUNCTION_LABEL_FILE_LINE(__FILE__, kTraceFunctionGroupLine),
+      PW_TRACE_FUNCTION_LABEL_FILE_LINE(__FILE__, TRACE_FUNCTION_GROUP_LINE),
       "FunctionGroup");
   EXPECT_TRUE(test_interface.GetEvents().empty());
 }
@@ -514,13 +518,13 @@
   EXPECT_TRACE(
       test_interface,
       PW_TRACE_TYPE_ASYNC_START,
-      PW_TRACE_FUNCTION_LABEL_FILE_LINE(__FILE__, kTraceFunctionIdLine),
+      PW_TRACE_FUNCTION_LABEL_FILE_LINE(__FILE__, TRACE_FUNCTION_ID_LINE),
       "FunctionGroup",
       kTraceId);
   EXPECT_TRACE(
       test_interface,
       PW_TRACE_TYPE_ASYNC_END,
-      PW_TRACE_FUNCTION_LABEL_FILE_LINE(__FILE__, kTraceFunctionIdLine),
+      PW_TRACE_FUNCTION_LABEL_FILE_LINE(__FILE__, TRACE_FUNCTION_ID_LINE),
       "FunctionGroup",
       kTraceId);
   EXPECT_TRUE(test_interface.GetEvents().empty());
@@ -578,12 +582,12 @@
   constexpr size_t kQueueSize = 5;
   pw::trace::internal::TraceQueue<kQueueSize> queue;
   for (size_t i = 0; i < kQueueSize; i++) {
-    EXPECT_EQ(queue.TryPushBack(QUEUE_TESTS_ARGS(i)), pw::Status::OK);
+    EXPECT_EQ(queue.TryPushBack(QUEUE_TESTS_ARGS(i)), pw::OkStatus());
   }
   EXPECT_FALSE(queue.IsEmpty());
   EXPECT_TRUE(queue.IsFull());
   EXPECT_EQ(queue.TryPushBack(QUEUE_TESTS_ARGS(1)),
-            pw::Status::RESOURCE_EXHAUSTED);
+            pw::Status::ResourceExhausted());
 
   for (size_t i = 0; i < kQueueSize; i++) {
     EXPECT_TRUE(QUEUE_CHECK_RESULT(kQueueSize, queue.PeekFront(), i));
@@ -598,7 +602,7 @@
   constexpr size_t kQueueSize = 5;
   pw::trace::internal::TraceQueue<kQueueSize> queue;
   for (size_t i = 0; i < kQueueSize; i++) {
-    EXPECT_EQ(queue.TryPushBack(QUEUE_TESTS_ARGS(i)), pw::Status::OK);
+    EXPECT_EQ(queue.TryPushBack(QUEUE_TESTS_ARGS(i)), pw::OkStatus());
   }
   EXPECT_FALSE(queue.IsEmpty());
   EXPECT_TRUE(queue.IsFull());
diff --git a/pw_unit_test/BUILD b/pw_unit_test/BUILD
index ccc8e49..7d94c19 100644
--- a/pw_unit_test/BUILD
+++ b/pw_unit_test/BUILD
@@ -86,6 +86,36 @@
     ],
 )
 
+pw_cc_library(
+    name = "rpc_service",
+    hdrs = [
+        "public/pw_unit_test/internal/rpc_event_handler.h",
+        "public/pw_unit_test/unit_test_service.h",
+    ],
+    srcs = [
+        "rpc_event_handler.cc",
+        "unit_test_service.cc",
+    ],
+    deps = [
+        ":pw_unit_test",
+        "//pw_log",
+    ],
+)
+
+pw_cc_library(
+    name = "rpc_main",
+    srcs = [
+        "rpc_main.cc",
+    ],
+    deps = [
+        ":pw_unit_test",
+        ":rpc_service",
+        "//pw_hdlc:pw_rpc",
+        "//pw_log",
+        "//pw_rpc:server",
+    ],
+)
+
 pw_cc_test(
     name = "framework_test",
     srcs = ["framework_test.cc"],
diff --git a/pw_unit_test/BUILD.gn b/pw_unit_test/BUILD.gn
index c4494ae..aa80fb9 100644
--- a/pw_unit_test/BUILD.gn
+++ b/pw_unit_test/BUILD.gn
@@ -1,4 +1,4 @@
-# Copyright 2019 The Pigweed Authors
+# 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
@@ -16,6 +16,7 @@
 
 import("$dir_pw_build/target_types.gni")
 import("$dir_pw_docgen/docs.gni")
+import("$dir_pw_protobuf_compiler/proto.gni")
 import("$dir_pw_unit_test/test.gni")
 
 config("default_config") {
@@ -29,9 +30,9 @@
 pw_source_set("pw_unit_test") {
   public_configs = [ ":default_config" ]
   public_deps = [
-    "$dir_pw_polyfill",
-    "$dir_pw_preprocessor",
-    "$dir_pw_string",
+    dir_pw_polyfill,
+    dir_pw_preprocessor,
+    dir_pw_string,
   ]
   public = [
     "public/pw_unit_test/event_handler.h",
@@ -63,10 +64,6 @@
   sources = [ "simple_printing_main.cc" ]
 }
 
-pw_doc_group("docs") {
-  sources = [ "docs.rst" ]
-}
-
 # Library providing an event handler which logs using pw_log.
 pw_source_set("logging_event_handler") {
   public_deps = [
@@ -87,6 +84,43 @@
   sources = [ "logging_main.cc" ]
 }
 
+pw_source_set("rpc_service") {
+  public_configs = [ ":default_config" ]
+  public_deps = [
+    ":pw_unit_test",
+    ":unit_test_proto.pwpb",
+    ":unit_test_proto.raw_rpc",
+    "$dir_pw_containers:vector",
+  ]
+  deps = [ dir_pw_log ]
+  public = [
+    "public/pw_unit_test/internal/rpc_event_handler.h",
+    "public/pw_unit_test/unit_test_service.h",
+  ]
+  sources = [
+    "rpc_event_handler.cc",
+    "unit_test_service.cc",
+  ]
+}
+
+pw_source_set("rpc_main") {
+  public_deps = [ ":pw_unit_test" ]
+  deps = [
+    ":rpc_service",
+    "$dir_pw_rpc/system_server",
+    dir_pw_log,
+  ]
+  sources = [ "rpc_main.cc" ]
+}
+
+pw_proto_library("unit_test_proto") {
+  sources = [ "pw_unit_test_proto/unit_test.proto" ]
+}
+
+pw_doc_group("docs") {
+  sources = [ "docs.rst" ]
+}
+
 pw_test("framework_test") {
   sources = [ "framework_test.cc" ]
 }
diff --git a/pw_unit_test/docs.rst b/pw_unit_test/docs.rst
index 5bec124..5459484 100644
--- a/pw_unit_test/docs.rst
+++ b/pw_unit_test/docs.rst
@@ -6,6 +6,8 @@
 ``pw_unit_test`` unit testing library with a `Google Test`_-compatible API,
 built on top of embedded-friendly primitives.
 
+.. _Google Test: https://github.com/google/googletest/blob/master/googletest/docs/primer.md
+
 ``pw_unit_test`` is a portable library which can run on almost any system from
 from bare metal to a full-fledged desktop OS. It does this by offloading the
 responsibility of test reporting and output to the underlying system,
@@ -107,6 +109,18 @@
     return RUN_ALL_TESTS();
   }
 
+Test filtering
+^^^^^^^^^^^^^^
+If using C++17, filters can be set on the test framework to run only a subset of
+the registered unit tests. This is useful when many tests are bundled into a
+single application image.
+
+Currently, only a test suite filter is supported. This is set by calling
+``pw::unit_test::SetTestSuitesToRun`` with a list of suite names.
+
+.. note::
+  Test filtering is only supported in C++17.
+
 Build system integration
 ^^^^^^^^^^^^^^^^^^^^^^^^
 ``pw_unit_test`` integrates directly into Pigweed's GN build system. To define
@@ -136,10 +150,13 @@
 
 pw_test template
 ----------------
+``pw_test`` defines a single unit test suite. It creates several sub-targets.
 
-``pw_test`` defines a single test binary. It wraps ``pw_executable`` and pulls
-in the test framework as well as the test entry point defined by the
-``pw_unit_test_main`` build variable.
+* ``<target_name>``: The test suite within a single binary. The test code is
+  linked against the target set in the build arg ``pw_unit_test_MAIN``.
+* ``<target_name>.run``: If ``pw_unit_test_AUTOMATIC_RUNNER`` is set, this
+  target runs the test as part of the build.
+* ``<target_name>.lib``: The test sources without ``pw_unit_test_MAIN``.
 
 **Arguments**
 
@@ -165,10 +182,18 @@
 
 pw_test_group template
 ----------------------
+``pw_test_group`` defines a collection of tests or other test groups. It creates
+several sub-targets:
 
-``pw_test_group`` defines a collection of tests or other test groups. Each
-module should expose a ``pw_test_group`` called ``tests`` with the module's test
-binaries.
+* ``<target_name>``: The test group itself.
+* ``<target_name>.run``: If ``pw_unit_test_AUTOMATIC_RUNNER`` is set, this
+  target runs all of the tests in the group and all of its group dependencies
+  individually.
+* ``<target_name>.lib``: The sources of all of the tests in this group and its
+  dependencies.
+* ``<target_name>.bundle``: All of the tests in the group and its dependencies
+  bundled into a single binary.
+* ``<target_name>.bundle.run``: Automatic runner for the test bundle.
 
 **Arguments**
 
@@ -199,5 +224,67 @@
     # ...
   }
 
+pw_facade_test template
+-----------------------
+Pigweed facade test templates allow individual unit tests to build under the
+current device target configuration while overriding specific build arguments.
+This allows these tests to replace a facade's backend for the purpose of testing
+the facade layer.
 
-.. _Google Test: https://github.com/google/googletest/blob/master/googletest/docs/primer.md
+.. warning::
+   Facade tests are costly because each facade test will trigger a re-build of
+   every dependency of the test. While this sounds excessive, it's the only
+   technically correct way to handle this type of test.
+
+.. warning::
+   Some facade test configurations may not be compatible with your target. Be
+   careful when running a facade test on a system that heavily depends on the
+   facade being tested.
+
+RPC service
+===========
+``pw_unit_test`` provides an RPC service which runs unit tests on demand and
+streams the results back to the client. The service is defined in
+``pw_unit_test_proto/unit_test.proto``, and implemented by the GN target
+``$dir_pw_unit_test:rpc_service``.
+
+To set up RPC-based unit tests in your application, instantiate a
+``pw::unit_test::UnitTestService`` and register it with your RPC server.
+
+.. code:: c++
+
+  #include "pw_rpc/server.h"
+  #include "pw_unit_test/unit_test_service.h"
+
+  // Server setup; refer to pw_rpc docs for more information.
+  pw::rpc::Channel channels[] = {
+   pw::rpc::Channel::Create<1>(&my_output),
+  };
+  pw::rpc::Server server(channels);
+
+  pw::unit_test::UnitTestService unit_test_service;
+
+  void RegisterServices() {
+    server.RegisterService(unit_test_services);
+  }
+
+All tests flashed to an attached device can be run via python by calling
+``pw_unit_test.rpc.run_tests()`` with a RPC client services object that has
+the unit testing RPC service enabled. By default, the results will output via
+logging.
+
+.. code:: python
+
+  from pw_hdlc.rpc import HdlcRpcClient
+  from pw_unit_test.rpc import run_tests
+
+  PROTO = Path(os.environ['PW_ROOT'],
+               'pw_unit_test/pw_unit_test_proto/unit_test.proto')
+
+  client = HdlcRpcClient(serial.Serial(device, baud), PROTO)
+  run_tests(client.rpcs())
+
+pw_unit_test.rpc
+^^^^^^^^^^^^^^^^
+.. automodule:: pw_unit_test.rpc
+  :members: EventHandler, run_tests
diff --git a/pw_unit_test/facade_test.gni b/pw_unit_test/facade_test.gni
new file mode 100644
index 0000000..4912888
--- /dev/null
+++ b/pw_unit_test/facade_test.gni
@@ -0,0 +1,105 @@
+# Copyright 2021 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_toolchain/generate_toolchain.gni")
+import("$dir_pw_toolchain/subtoolchain.gni")
+import("$dir_pw_unit_test/test.gni")
+
+declare_args() {
+  # Pigweed uses this internally to manage toolchain generation for facade
+  # tests. This should NEVER be set manually, or depended on as stable API.
+  pw_unit_test_FACADE_TEST_NAME = ""
+}
+
+# Create a facade test. This allows you to, for a single unit test, replace
+# backends for the purpose of testing logic in a facade. To test a single
+# facade, multiple backends may need to be replaced (e.g. to test logging, you
+# can't be using the logging test runner).
+#
+# Note: pw_facade_test names MUST be globally unique, as they all are enumerated
+# to GN's output directory.
+# (e.g. `out/stm32f429i_disc1_size_optimized.tokenizer_facade_test`)
+#
+# WARNING: Facade tests can be very costly, as ALL the test/target dependencies
+#   will be rebuilt in a completely new toolchain context. This may seem
+#   wasteful, but is the only technically correct solution.
+#
+# Args:
+#   build_args: (required) Toolchain build arguments to override in the
+#     generated subtoolchain.
+#   toolchain_suffix: (optional) The suffix to use when generating a
+#     subtoolchain for the currently active toolchain. This must be globally
+#     unique as two tests with the same toolchain_suffix will generate the same
+#     toolchain name, which is illegal.
+template("pw_facade_test") {
+  assert(
+      defined(invoker.build_args),
+      "A facade test with no `defaults` is just a more expensive pw_unit_test!")
+  assert(
+      target_name != "test",
+      "This is a dangerous name, facade tests must have globally unique names!")
+
+  # Only try to generate a facade test for toolchains created by
+  # generate_toolchain. Checking if pw_toolchain_SCOPE has the "name" member
+  # is a reliable way to do this since it's only ever set by generate_toolchain.
+  if (defined(pw_toolchain_SCOPE.name)) {
+    if (defined(invoker.toolchain_suffix)) {
+      _subtoolchain_suffix = invoker.toolchain_suffix
+    } else {
+      _subtoolchain_suffix =
+          get_label_info(":$target_name", "label_no_toolchain")
+      _subtoolchain_suffix = string_replace(_subtoolchain_suffix, "//", "")
+      _subtoolchain_suffix = string_replace(_subtoolchain_suffix, "/", "-")
+      _subtoolchain_suffix = string_replace(_subtoolchain_suffix, ":", "--")
+    }
+
+    # Generate a subtoolchain for this test unless we're already in the
+    # context of a subtoolchain that was generated for this test.
+    if (pw_unit_test_FACADE_TEST_NAME != _subtoolchain_suffix) {
+      # If this branch is hit, we're still in the context of the parent
+      # toolchain, and we should generate the subtoolchain for this test.
+      _current_toolchain_name = get_label_info(current_toolchain, "name")
+      _subtoolchain_name = "${_current_toolchain_name}.${_subtoolchain_suffix}"
+      pw_generate_subtoolchain(_subtoolchain_name) {
+        build_args = {
+          pw_unit_test_FACADE_TEST_NAME = _subtoolchain_suffix
+          forward_variables_from(invoker.build_args, "*")
+        }
+      }
+      not_needed(invoker, "*")
+
+      # This target acts as a somewhat strange passthrough. In this toolchain,
+      # it refers to a test group that depends on a test of the same name in the
+      # context of another toolchain. In the subtoolchain, this same target name
+      # refers to a concrete test. It's like Inception.
+      pw_test_group(target_name) {
+        tests = [ ":$target_name(:$_subtoolchain_name)" ]
+      }
+    } else {
+      # In this branch, we instantiate the actual pw_test target that can be
+      # run.
+      pw_test(target_name) {
+        forward_variables_from(invoker, "*", [ "build_args" ])
+      }
+    }
+  } else {
+    # Dummy target for non-pigweed toolchains.
+    not_needed(invoker, "*")
+    pw_test_group(target_name) {
+      enable_if = false
+    }
+  }
+}
diff --git a/pw_unit_test/framework.cc b/pw_unit_test/framework.cc
index 5b7975f..70b36d3 100644
--- a/pw_unit_test/framework.cc
+++ b/pw_unit_test/framework.cc
@@ -14,6 +14,7 @@
 
 #include "pw_unit_test/framework.h"
 
+#include <algorithm>
 #include <cstring>
 
 namespace pw {
@@ -49,15 +50,23 @@
 int Framework::RunAllTests() {
   run_tests_summary_.passed_tests = 0;
   run_tests_summary_.failed_tests = 0;
+  run_tests_summary_.skipped_tests = 0;
+  run_tests_summary_.disabled_tests = 0;
 
   if (event_handler_ != nullptr) {
     event_handler_->RunAllTestsStart();
   }
   for (const TestInfo* test = tests_; test != nullptr; test = test->next()) {
-    if (test->enabled()) {
+    if (ShouldRunTest(*test)) {
       test->run();
+    } else if (!test->enabled()) {
+      run_tests_summary_.disabled_tests++;
+
+      if (event_handler_ != nullptr) {
+        event_handler_->TestCaseDisabled(test->test_case());
+      }
     } else {
-      event_handler_->TestCaseDisabled(test->test_case());
+      run_tests_summary_.skipped_tests++;
     }
   }
   if (event_handler_ != nullptr) {
@@ -115,6 +124,26 @@
   event_handler_->TestCaseExpect(current_test_->test_case(), expectation);
 }
 
+bool Framework::ShouldRunTest(const TestInfo& test_info) {
+#if PW_CXX_STANDARD_IS_SUPPORTED(17)
+  // Test suite filtering is only supported if using C++17.
+  if (!test_suites_to_run_.empty()) {
+    std::string_view test_suite(test_info.test_case().suite_name);
+
+    bool suite_matches =
+        std::any_of(test_suites_to_run_.begin(),
+                    test_suites_to_run_.end(),
+                    [&](auto& name) { return test_suite == name; });
+
+    if (!suite_matches) {
+      return false;
+    }
+  }
+#endif  // PW_CXX_STANDARD_IS_SUPPORTED(17)
+
+  return test_info.enabled();
+}
+
 bool TestInfo::enabled() const {
   constexpr size_t kStringSize = sizeof("DISABLED_") - 1;
   return std::strncmp("DISABLED_", test_case().test_name, kStringSize) != 0 &&
diff --git a/pw_unit_test/public/pw_unit_test/event_handler.h b/pw_unit_test/public/pw_unit_test/event_handler.h
index b7b931d..3a5a710 100644
--- a/pw_unit_test/public/pw_unit_test/event_handler.h
+++ b/pw_unit_test/public/pw_unit_test/event_handler.h
@@ -84,6 +84,12 @@
 
   // The number of passed tests among the run tests.
   int failed_tests;
+
+  // The number of tests skipped or filtered out.
+  int skipped_tests;
+
+  // The number of disabled tests encountered.
+  int disabled_tests;
 };
 
 // An event handler is responsible for collecting and processing the results of
diff --git a/pw_unit_test/public/pw_unit_test/framework.h b/pw_unit_test/public/pw_unit_test/framework.h
index b9db35b..959cef8 100644
--- a/pw_unit_test/public/pw_unit_test/framework.h
+++ b/pw_unit_test/public/pw_unit_test/framework.h
@@ -29,6 +29,8 @@
 #include "pw_unit_test/event_handler.h"
 
 #if PW_CXX_STANDARD_IS_SUPPORTED(17)
+#include <string_view>
+
 #include "pw_string/string_builder.h"
 #endif  // PW_CXX_STANDARD_IS_SUPPORTED(17)
 
@@ -155,7 +157,10 @@
   constexpr Framework()
       : current_test_(nullptr),
         current_result_(TestResult::kSuccess),
-        run_tests_summary_{.passed_tests = 0, .failed_tests = 0},
+        run_tests_summary_{.passed_tests = 0,
+                           .failed_tests = 0,
+                           .skipped_tests = 0,
+                           .disabled_tests = 0},
         exit_status_(0),
         event_handler_(nullptr),
         memory_pool_() {}
@@ -177,6 +182,17 @@
   // are sent to the registered event handler, if any.
   int RunAllTests();
 
+#if PW_CXX_STANDARD_IS_SUPPORTED(17)
+  // Only run test suites whose names are included in the provided list during
+  // the next test run. This is C++17 only; older versions of C++ will run all
+  // non-disabled tests.
+  void SetTestSuitesToRun(std::span<std::string_view> test_suites) {
+    test_suites_to_run_ = test_suites;
+  }
+#endif  // PW_CXX_STANDARD_IS_SUPPORTED(17)
+
+  bool ShouldRunTest(const TestInfo& test_info);
+
   // Constructs an instance of a unit test class and runs the test.
   //
   // Tests are constructed within a static memory pool at run time instead of
@@ -280,6 +296,10 @@
   // Handler to which to dispatch test events.
   EventHandler* event_handler_;
 
+#if PW_CXX_STANDARD_IS_SUPPORTED(17)
+  std::span<std::string_view> test_suites_to_run_;
+#endif  // PW_CXX_STANDARD_IS_SUPPORTED(17)
+
   // Memory region in which to construct test case classes as they are run.
   // TODO(frolv): Make the memory pool size configurable.
   static constexpr size_t kTestMemoryPoolSizeBytes = 16384;
@@ -376,6 +396,12 @@
   virtual void PigweedTestBody() = 0;
 };
 
+#if PW_CXX_STANDARD_IS_SUPPORTED(17)
+inline void SetTestSuitesToRun(std::span<std::string_view> test_suites) {
+  internal::Framework::Get().SetTestSuitesToRun(test_suites);
+}
+#endif  // PW_CXX_STANDARD_IS_SUPPORTED(17)
+
 }  // namespace unit_test
 }  // namespace pw
 
diff --git a/pw_unit_test/public/pw_unit_test/internal/rpc_event_handler.h b/pw_unit_test/public/pw_unit_test/internal/rpc_event_handler.h
new file mode 100644
index 0000000..ad27fc0
--- /dev/null
+++ b/pw_unit_test/public/pw_unit_test/internal/rpc_event_handler.h
@@ -0,0 +1,42 @@
+// 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.
+#pragma once
+
+#include "pw_unit_test/event_handler.h"
+
+namespace pw::unit_test {
+
+class UnitTestService;
+
+namespace internal {
+
+// Unit test event handler that streams test events through an RPC service.
+class RpcEventHandler : public EventHandler {
+ public:
+  RpcEventHandler(UnitTestService& service) : service_(service) {}
+
+  void RunAllTestsStart() override;
+  void RunAllTestsEnd(const RunTestsSummary& run_tests_summary) override;
+  void TestCaseStart(const TestCase& test_case) override;
+  void TestCaseEnd(const TestCase& test_case, TestResult result) override;
+  void TestCaseExpect(const TestCase& test_case,
+                      const TestExpectation& expectation) override;
+  void TestCaseDisabled(const TestCase& test_case) override;
+
+ private:
+  UnitTestService& service_;
+};
+
+}  // namespace internal
+}  // namespace pw::unit_test
diff --git a/pw_unit_test/public/pw_unit_test/unit_test_service.h b/pw_unit_test/public/pw_unit_test/unit_test_service.h
new file mode 100644
index 0000000..f5c7a79
--- /dev/null
+++ b/pw_unit_test/public/pw_unit_test/unit_test_service.h
@@ -0,0 +1,57 @@
+// 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.
+#pragma once
+
+#include "pw_log/log.h"
+#include "pw_unit_test/internal/rpc_event_handler.h"
+#include "pw_unit_test_proto/unit_test.pwpb.h"
+#include "pw_unit_test_proto/unit_test.raw_rpc.pb.h"
+
+namespace pw::unit_test {
+
+class UnitTestService final : public generated::UnitTest<UnitTestService> {
+ public:
+  UnitTestService() : handler_(*this), verbose_(false) {}
+
+  void Run(ServerContext& ctx, ConstByteSpan request, RawServerWriter& writer);
+
+ private:
+  friend class internal::RpcEventHandler;
+
+  // TODO(frolv): This function essentially performs what a pw_protobuf RPC
+  // method would do. Once that API is implemented, this service should be
+  // migrated to it.
+  template <typename WriteFunction>
+  void WriteEvent(WriteFunction event_writer) {
+    protobuf::NestedEncoder<2, 3> encoder(writer_.PayloadBuffer());
+    Event::Encoder event(&encoder);
+    event_writer(event);
+    if (Result<ConstByteSpan> result = encoder.Encode(); result.ok()) {
+      writer_.Write(result.value());
+    }
+  }
+
+  void WriteTestRunStart();
+  void WriteTestRunEnd(const RunTestsSummary& summary);
+  void WriteTestCaseStart(const TestCase& test_case);
+  void WriteTestCaseEnd(TestResult result);
+  void WriteTestCaseDisabled(const TestCase& test_case);
+  void WriteTestCaseExpectation(const TestExpectation& expectation);
+
+  internal::RpcEventHandler handler_;
+  RawServerWriter writer_;
+  bool verbose_;
+};
+
+}  // namespace pw::unit_test
diff --git a/pw_unit_test/pw_unit_test_proto/unit_test.proto b/pw_unit_test/pw_unit_test_proto/unit_test.proto
new file mode 100644
index 0000000..4a10e8b
--- /dev/null
+++ b/pw_unit_test/pw_unit_test_proto/unit_test.proto
@@ -0,0 +1,92 @@
+// 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.
+
+syntax = "proto3";
+
+package pw.unit_test;
+
+message TestCaseDescriptor {
+  // Name of the test suite to which this test case belongs.
+  string suite_name = 1;
+
+  // Name of the test case.
+  string test_name = 2;
+
+  // Path to the file in which the test case is defined.
+  string file_name = 3;
+}
+
+message TestCaseExpectation {
+  // The source code for the expression which was run.
+  string expression = 1;
+
+  // The expression with arguments evaluated.
+  string evaluated_expression = 2;
+
+  // Line number at which the expectation is located.
+  uint32 line_number = 3;
+
+  // Whether the expectation succeeded.
+  bool success = 4;
+}
+
+enum TestCaseResult {
+  SUCCESS = 0;
+  FAILURE = 1;
+  SKIPPED = 2;
+}
+
+message TestRunStart {}
+
+message TestRunEnd {
+  uint32 passed = 1;
+  uint32 failed = 2;
+  uint32 skipped = 3;
+  uint32 disabled = 4;
+}
+
+message Event {
+  oneof type {
+    // Unit test run has started.
+    TestRunStart test_run_start = 1;
+
+    // Unit test run has ended.
+    TestRunEnd test_run_end = 2;
+
+    // Start of a test case.
+    TestCaseDescriptor test_case_start = 3;
+
+    // End of a test case.
+    TestCaseResult test_case_end = 4;
+
+    // Encountered a disabled test case.
+    TestCaseDescriptor test_case_disabled = 5;
+
+    // Expectation statement within a test case.
+    TestCaseExpectation test_case_expectation = 6;
+  }
+};
+
+message TestRunRequest {
+  // Whether to send expectation events for successful checks.
+  bool report_passed_expectations = 1;
+
+  // Optional list of test suites to run.
+  repeated string test_suite = 2;
+}
+
+service UnitTest {
+  // Runs registered unit tests, streaming back test events as they occur.
+  rpc Run(TestRunRequest) returns (stream Event) {}
+}
diff --git a/pw_unit_test/py/BUILD.gn b/pw_unit_test/py/BUILD.gn
index 8f37417..468af15 100644
--- a/pw_unit_test/py/BUILD.gn
+++ b/pw_unit_test/py/BUILD.gn
@@ -20,7 +20,13 @@
   setup = [ "setup.py" ]
   sources = [
     "pw_unit_test/__init__.py",
+    "pw_unit_test/rpc.py",
     "pw_unit_test/test_runner.py",
   ]
-  python_deps = [ "$dir_pw_cli/py" ]
+  python_deps = [
+    "$dir_pw_cli/py",
+    "$dir_pw_rpc/py",
+    "..:unit_test_proto.python",
+  ]
+  pylintrc = "$dir_pigweed/.pylintrc"
 }
diff --git a/pw_unit_test/py/pw_unit_test/rpc.py b/pw_unit_test/py/pw_unit_test/rpc.py
new file mode 100644
index 0000000..9fc8280
--- /dev/null
+++ b/pw_unit_test/py/pw_unit_test/rpc.py
@@ -0,0 +1,178 @@
+# 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.
+"""Utilities for running unit tests over Pigweed RPC."""
+
+import abc
+from dataclasses import dataclass
+import logging
+from typing import Iterable
+
+import pw_rpc.client
+from pw_rpc.callback_client import OptionalTimeout, UseDefault
+from pw_unit_test_proto import unit_test_pb2
+
+_LOG = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True)
+class TestCase:
+    suite_name: str
+    test_name: str
+    file_name: str
+
+    def __str__(self) -> str:
+        return f'{self.suite_name}.{self.test_name}'
+
+    def __repr__(self) -> str:
+        return f'TestCase({str(self)})'
+
+
+@dataclass(frozen=True)
+class TestExpectation:
+    expression: str
+    evaluated_expression: str
+    line_number: int
+    success: bool
+
+    def __str__(self) -> str:
+        return self.expression
+
+    def __repr__(self) -> str:
+        return f'TestExpectation({str(self)})'
+
+
+class EventHandler(abc.ABC):
+    @abc.abstractmethod
+    def run_all_tests_start(self):
+        """Called before all tests are run."""
+
+    @abc.abstractmethod
+    def run_all_tests_end(self, passed_tests: int, failed_tests: int):
+        """Called after the test run is complete."""
+
+    @abc.abstractmethod
+    def test_case_start(self, test_case: TestCase):
+        """Called when a new test case is started."""
+
+    @abc.abstractmethod
+    def test_case_end(self, test_case: TestCase, result: int):
+        """Called when a test case completes with its overall result."""
+
+    @abc.abstractmethod
+    def test_case_disabled(self, test_case: TestCase):
+        """Called when a disabled test case is encountered."""
+
+    @abc.abstractmethod
+    def test_case_expect(self, test_case: TestCase,
+                         expectation: TestExpectation):
+        """Called after each expect/assert statement within a test case."""
+
+
+class LoggingEventHandler(EventHandler):
+    """Event handler that logs test events using Google Test format."""
+    def run_all_tests_start(self):
+        _LOG.info('[==========] Running all tests.')
+
+    def run_all_tests_end(self, passed_tests: int, failed_tests: int):
+        _LOG.info('[==========] Done running all tests.')
+        _LOG.info('[  PASSED  ] %d test(s).', passed_tests)
+        if failed_tests:
+            _LOG.info('[  FAILED  ] %d test(s).', failed_tests)
+
+    def test_case_start(self, test_case: TestCase):
+        _LOG.info('[ RUN      ] %s', test_case)
+
+    def test_case_end(self, test_case: TestCase, result: int):
+        if result == unit_test_pb2.TestCaseResult.SUCCESS:
+            _LOG.info('[       OK ] %s', test_case)
+        else:
+            _LOG.info('[  FAILED  ] %s', test_case)
+
+    def test_case_disabled(self, test_case: TestCase):
+        _LOG.info('Skipping disabled test %s', test_case)
+
+    def test_case_expect(self, test_case: TestCase,
+                         expectation: TestExpectation):
+        result = 'Success' if expectation.success else 'Failure'
+        log = _LOG.info if expectation.success else _LOG.error
+        log('%s:%d: %s', test_case.file_name, expectation.line_number, result)
+        log('      Expected: %s', expectation.expression)
+        log('        Actual: %s', expectation.evaluated_expression)
+
+
+def run_tests(rpcs: pw_rpc.client.Services,
+              report_passed_expectations: bool = False,
+              test_suites: Iterable[str] = (),
+              event_handlers: Iterable[EventHandler] = (
+                  LoggingEventHandler(), ),
+              timeout_s: OptionalTimeout = UseDefault.VALUE) -> bool:
+    """Runs unit tests on a device over Pigweed RPC.
+
+    Calls each of the provided event handlers as test events occur, and returns
+    True if all tests pass.
+    """
+    unit_test_service = rpcs.pw.unit_test.UnitTest  # type: ignore[attr-defined]
+
+    test_responses = iter(
+        unit_test_service.Run(
+            report_passed_expectations=report_passed_expectations,
+            test_suite=test_suites,
+            pw_rpc_timeout_s=timeout_s))
+
+    # Read the first response, which must be a test_run_start message.
+    first_response = next(test_responses)
+    if not first_response.HasField('test_run_start'):
+        raise ValueError(
+            'Expected a "test_run_start" response from pw.unit_test.Run, '
+            'but received a different message type. A response may have been '
+            'dropped.')
+
+    for event_handler in event_handlers:
+        event_handler.run_all_tests_start()
+
+    all_tests_passed = False
+
+    for response in test_responses:
+        if response.HasField('test_case_start'):
+            raw_test_case = response.test_case_start
+            current_test_case = TestCase(raw_test_case.suite_name,
+                                         raw_test_case.test_name,
+                                         raw_test_case.file_name)
+
+        for event_handler in event_handlers:
+            if response.HasField('test_run_start'):
+                event_handler.run_all_tests_start()
+            elif response.HasField('test_run_end'):
+                event_handler.run_all_tests_end(response.test_run_end.passed,
+                                                response.test_run_end.failed)
+                if response.test_run_end.failed == 0:
+                    all_tests_passed = True
+            elif response.HasField('test_case_start'):
+                event_handler.test_case_start(current_test_case)
+            elif response.HasField('test_case_end'):
+                event_handler.test_case_end(current_test_case,
+                                            response.test_case_end)
+            elif response.HasField('test_case_disabled'):
+                event_handler.test_case_disabled(current_test_case)
+            elif response.HasField('test_case_expectation'):
+                raw_expectation = response.test_case_expectation
+                expectation = TestExpectation(
+                    raw_expectation.expression,
+                    raw_expectation.evaluated_expression,
+                    raw_expectation.line_number,
+                    raw_expectation.success,
+                )
+                event_handler.test_case_expect(current_test_case, expectation)
+
+    return all_tests_passed
diff --git a/pw_unit_test/py/setup.py b/pw_unit_test/py/setup.py
index ea467d5..7ae8df1 100644
--- a/pw_unit_test/py/setup.py
+++ b/pw_unit_test/py/setup.py
@@ -26,5 +26,7 @@
     zip_safe=False,
     install_requires=[
         'pw_cli',
+        'pw_rpc',
+        'pw_unit_test_proto',
     ],
 )
diff --git a/pw_unit_test/rpc_event_handler.cc b/pw_unit_test/rpc_event_handler.cc
new file mode 100644
index 0000000..291dec3
--- /dev/null
+++ b/pw_unit_test/rpc_event_handler.cc
@@ -0,0 +1,44 @@
+// 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.
+
+#include "pw_unit_test/internal/rpc_event_handler.h"
+
+#include "pw_unit_test/unit_test_service.h"
+
+namespace pw::unit_test::internal {
+
+void RpcEventHandler::RunAllTestsStart() { service_.WriteTestRunStart(); }
+
+void RpcEventHandler::RunAllTestsEnd(const RunTestsSummary& run_tests_summary) {
+  service_.WriteTestRunEnd(run_tests_summary);
+}
+
+void RpcEventHandler::TestCaseStart(const TestCase& test_case) {
+  service_.WriteTestCaseStart(test_case);
+}
+
+void RpcEventHandler::TestCaseEnd(const TestCase&, TestResult result) {
+  service_.WriteTestCaseEnd(result);
+}
+
+void RpcEventHandler::TestCaseExpect(const TestCase&,
+                                     const TestExpectation& expectation) {
+  service_.WriteTestCaseExpectation(expectation);
+}
+
+void RpcEventHandler::TestCaseDisabled(const TestCase& test_case) {
+  service_.WriteTestCaseDisabled(test_case);
+}
+
+}  // namespace pw::unit_test::internal
diff --git a/pw_unit_test/rpc_main.cc b/pw_unit_test/rpc_main.cc
new file mode 100644
index 0000000..799e8c1
--- /dev/null
+++ b/pw_unit_test/rpc_main.cc
@@ -0,0 +1,33 @@
+// 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.
+
+#include "pw_log/log.h"
+#include "pw_rpc_system_server/rpc_server.h"
+#include "pw_unit_test/unit_test_service.h"
+
+namespace {
+
+pw::unit_test::UnitTestService unit_test_service;
+
+}  // namespace
+
+int main() {
+  pw::rpc::system_server::Init();
+  pw::rpc::system_server::Server().RegisterService(unit_test_service);
+
+  PW_LOG_INFO("Starting pw_rpc server");
+  pw::rpc::system_server::Start();
+
+  return 0;
+}
diff --git a/pw_unit_test/test.gni b/pw_unit_test/test.gni
index 58ae9aa..1fdb428 100644
--- a/pw_unit_test/test.gni
+++ b/pw_unit_test/test.gni
@@ -28,12 +28,16 @@
   # unit tests, such as desktops with multiple cores.
   pw_unit_test_AUTOMATIC_RUNNER = ""
 
+  # Additional dependencies required by all unit test targets. (For example, if
+  # using a different test library like Googletest.)
+  pw_unit_test_PUBLIC_DEPS = []
+
   # Implementation of a main function for "pw_test" unit test binaries.
   pw_unit_test_MAIN = "$dir_pw_unit_test:simple_printing_main"
 }
 
 # Defines a target if enable_if is true. Otherwise, it defines that target as
-# <target_name>_DISABLED and creates an empty <target_name> group. This can be
+# <target_name>.DISABLED and creates an empty <target_name> group. This can be
 # used to conditionally create targets without having to conditionally add them
 # to groups. This results in simpler BUILD.gn files.
 template("_pw_disableable_target") {
@@ -45,7 +49,7 @@
   if (invoker.enable_if) {
     _actual_target_name = target_name
   } else {
-    _actual_target_name = target_name + "_DISABLED"
+    _actual_target_name = target_name + ".DISABLED"
 
     # If the target is disabled, create an empty target in its place. Use an
     # action with the original target's sources as inputs to ensure that
@@ -85,17 +89,22 @@
   }
 }
 
-# Creates an executable target for a unit test.
+# Creates a library and an executable target for a unit test.
+#
+# <target_name>.lib contains the provided test sources as a library, which can
+# then be linked into a test executable.
+# <target_name> is a standalone executable which contains only the test sources
+# specified in the pw_unit_test_template.
 #
 # If the pw_unit_test_AUTOMATIC_RUNNER variable is set, this template also creates a
-# "${test_name}_run" target which runs the unit test executable after building
+# "${test_name}.run" target which runs the unit test executable after building
 # it.
 #
 # Args:
 #   - enable_if: (optional) Conditionally enables or disables this test. The test
-#         target and *_run target do nothing when the test is disabled. The
+#         target and *.run target do nothing when the test is disabled. The
 #         disabled test can still be built and run with the
-#         <target_name>_DISABLED and <target_name>_DISABLED_run targets.
+#         <target_name>.DISABLED and <target_name>.DISABLED.run targets.
 #         Defaults to true (enable_if).
 #   - All of the regular "executable" target args are accepted.
 template("pw_test") {
@@ -119,6 +128,18 @@
     _test_main = invoker.test_main
   }
 
+  # The unit test code as a source_set.
+  _pw_disableable_target("$target_name.lib") {
+    target_type = "pw_source_set"
+    enable_if = _test_is_enabled
+    forward_variables_from(invoker, "*", [ "metadata" ])
+
+    if (!defined(public_deps)) {
+      public_deps = []
+    }
+    public_deps += pw_unit_test_PUBLIC_DEPS + [ dir_pw_unit_test ]
+  }
+
   _pw_disableable_target(_test_target_name) {
     target_type = "pw_executable"
     enable_if = _test_is_enabled
@@ -134,11 +155,7 @@
       ]
     }
 
-    forward_variables_from(invoker, "*", [ "metadata" ])
-
-    if (!defined(deps)) {
-      deps = []
-    }
+    deps = [ ":$_test_target_name.lib" ]
     if (_test_main != "") {
       deps += [ _test_main ]
     }
@@ -152,19 +169,20 @@
     if (_test_is_enabled) {
       _test_to_run = _test_target_name
     } else {
-      # Create a run target for the _DISABLED version of the test.
-      _test_to_run = _test_target_name + "_DISABLED"
+      # Create a run target for the .DISABLED version of the test.
+      _test_to_run = _test_target_name + ".DISABLED"
 
       # Create a dummy _run target for the regular version of the test.
-      group(_test_target_name + "_run") {
+      group(_test_target_name + ".run") {
         deps = [ ":$_test_target_name" ]
       }
     }
 
-    pw_python_action(_test_to_run + "_run") {
+    pw_python_action(_test_to_run + ".run") {
       deps = [ ":$_test_target_name" ]
       inputs = [ pw_unit_test_AUTOMATIC_RUNNER ]
       script = "$dir_pw_unit_test/py/pw_unit_test/test_runner.py"
+      python_deps = [ "$dir_pw_cli/py" ]
       args = [
         "--runner",
         rebase_path(pw_unit_test_AUTOMATIC_RUNNER),
@@ -173,6 +191,15 @@
       ]
       stamp = true
     }
+
+    # TODO(frolv): Alias for the deprecated _run target. Remove when projects
+    # are migrated.
+    group(_test_to_run + "_run") {
+      public_deps = [ ":$_test_to_run.run" ]
+    }
+  } else {
+    group(_test_target_name + ".run") {
+    }
   }
 }
 
@@ -213,6 +240,15 @@
       _deps += invoker.group_deps
     }
 
+    group(_group_target + ".lib") {
+      deps = []
+      foreach(_target, _deps) {
+        _dep_target = get_label_info(_target, "label_no_toolchain")
+        _dep_toolchain = get_label_info(_target, "toolchain")
+        deps += [ "$_dep_target.lib($_dep_toolchain)" ]
+      }
+    }
+
     _metadata_group_target = "${target_name}_pw_test_group_metadata"
     group(_metadata_group_target) {
       metadata = {
@@ -249,22 +285,35 @@
       deps = _test_group_deps
     }
 
-    # If automatic test running is enabled, create a *_run group that collects
-    # all of the individual *_run targets and groups.
+    # If automatic test running is enabled, create a *.run group that collects
+    # all of the individual *.run targets and groups.
     if (pw_unit_test_AUTOMATIC_RUNNER != "") {
-      group(_group_target + "_run") {
+      group(_group_target + ".run") {
         deps = [ ":$_group_target" ]
         foreach(_target, _deps) {
-          deps += [ "${_target}_run" ]
+          _dep_target = get_label_info(_target, "label_no_toolchain")
+          _dep_toolchain = get_label_info(_target, "toolchain")
+          deps += [ "$_dep_target.run($_dep_toolchain)" ]
         }
       }
+
+      # TODO(frolv): Remove this deprecated alias.
+      group(_group_target + "_run") {
+        deps = [ ":$_group_target.run" ]
+      }
     }
   } else {  # _group_is_enabled
     # Create empty groups for the tests to avoid pulling in any dependencies.
     group(_group_target) {
     }
+    group(_group_target + ".lib") {
+    }
 
     if (pw_unit_test_AUTOMATIC_RUNNER != "") {
+      group(_group_target + ".run") {
+      }
+
+      # TODO(frolv): Remove this deprecated alias.
       group(_group_target + "_run") {
       }
     }
@@ -272,4 +321,11 @@
     not_needed("*")
     not_needed(invoker, "*")
   }
+
+  # All of the tests in this group and its dependencies bundled into a single
+  # test binary.
+  pw_test(_group_target + ".bundle") {
+    deps = [ ":$_group_target.lib" ]
+    enable_if = _group_is_enabled
+  }
 }
diff --git a/pw_unit_test/unit_test_service.cc b/pw_unit_test/unit_test_service.cc
new file mode 100644
index 0000000..b43537f
--- /dev/null
+++ b/pw_unit_test/unit_test_service.cc
@@ -0,0 +1,142 @@
+// 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.
+
+#include "pw_unit_test/unit_test_service.h"
+
+#include "pw_containers/vector.h"
+#include "pw_log/log.h"
+#include "pw_protobuf/decoder.h"
+#include "pw_unit_test/framework.h"
+
+namespace pw::unit_test {
+
+void UnitTestService::Run(ServerContext&,
+                          ConstByteSpan request,
+                          RawServerWriter& writer) {
+  writer_ = std::move(writer);
+  verbose_ = false;
+
+  // List of test suite names to run. The string views in this vector point to
+  // data in the raw protobuf request message, so it is only valid for the
+  // duration of this function.
+  pw::Vector<std::string_view, 16> suites_to_run;
+
+  protobuf::Decoder decoder(request);
+
+  Status status;
+  while ((status = decoder.Next()).ok()) {
+    switch (static_cast<TestRunRequest::Fields>(decoder.FieldNumber())) {
+      case TestRunRequest::Fields::REPORT_PASSED_EXPECTATIONS:
+        decoder.ReadBool(&verbose_);
+        break;
+
+      case TestRunRequest::Fields::TEST_SUITE: {
+        std::string_view suite_name;
+        if (!decoder.ReadString(&suite_name).ok()) {
+          break;
+        }
+
+        if (!suites_to_run.full()) {
+          suites_to_run.push_back(suite_name);
+        } else {
+          PW_LOG_ERROR("Maximum of %d test suite filters supported",
+                       suites_to_run.max_size());
+          writer_.Finish(Status::InvalidArgument());
+          return;
+        }
+
+        break;
+      }
+    }
+  }
+
+  if (status != Status::OutOfRange()) {
+    writer_.Finish(status);
+    return;
+  }
+
+  PW_LOG_INFO("Starting unit test run");
+
+  RegisterEventHandler(&handler_);
+  SetTestSuitesToRun(suites_to_run);
+  PW_LOG_DEBUG("%u test suite filters applied",
+               static_cast<unsigned>(suites_to_run.size()));
+
+  RUN_ALL_TESTS();
+
+  RegisterEventHandler(nullptr);
+  SetTestSuitesToRun({});
+
+  PW_LOG_INFO("Unit test run complete");
+
+  writer_.Finish();
+}
+
+void UnitTestService::WriteTestRunStart() {
+  // Write out the key for the start field (even though the message is empty).
+  WriteEvent([&](Event::Encoder& event) { event.GetTestRunStartEncoder(); });
+}
+
+void UnitTestService::WriteTestRunEnd(const RunTestsSummary& summary) {
+  WriteEvent([&](Event::Encoder& event) {
+    TestRunEnd::Encoder test_run_end = event.GetTestRunEndEncoder();
+    test_run_end.WritePassed(summary.passed_tests);
+    test_run_end.WriteFailed(summary.failed_tests);
+    test_run_end.WriteSkipped(summary.skipped_tests);
+    test_run_end.WriteDisabled(summary.disabled_tests);
+  });
+}
+
+void UnitTestService::WriteTestCaseStart(const TestCase& test_case) {
+  WriteEvent([&](Event::Encoder& event) {
+    TestCaseDescriptor::Encoder descriptor = event.GetTestCaseStartEncoder();
+    descriptor.WriteSuiteName(test_case.suite_name);
+    descriptor.WriteTestName(test_case.test_name);
+    descriptor.WriteFileName(test_case.file_name);
+  });
+}
+
+void UnitTestService::WriteTestCaseEnd(TestResult result) {
+  WriteEvent([&](Event::Encoder& event) {
+    event.WriteTestCaseEnd(static_cast<TestCaseResult>(result));
+  });
+}
+
+void UnitTestService::WriteTestCaseDisabled(const TestCase& test_case) {
+  WriteEvent([&](Event::Encoder& event) {
+    TestCaseDescriptor::Encoder descriptor = event.GetTestCaseDisabledEncoder();
+    descriptor.WriteSuiteName(test_case.suite_name);
+    descriptor.WriteTestName(test_case.test_name);
+    descriptor.WriteFileName(test_case.file_name);
+  });
+}
+
+void UnitTestService::WriteTestCaseExpectation(
+    const TestExpectation& expectation) {
+  if (!verbose_ && expectation.success) {
+    return;
+  }
+
+  WriteEvent([&](Event::Encoder& event) {
+    TestCaseExpectation::Encoder test_case_expectation =
+        event.GetTestCaseExpectationEncoder();
+    test_case_expectation.WriteExpression(expectation.expression);
+    test_case_expectation.WriteEvaluatedExpression(
+        expectation.evaluated_expression);
+    test_case_expectation.WriteLineNumber(expectation.line_number);
+    test_case_expectation.WriteSuccess(expectation.success);
+  });
+}
+
+}  // namespace pw::unit_test
diff --git a/pw_varint/BUILD.gn b/pw_varint/BUILD.gn
index 2f06bac..c5c7980 100644
--- a/pw_varint/BUILD.gn
+++ b/pw_varint/BUILD.gn
@@ -24,10 +24,7 @@
 
 pw_source_set("pw_varint") {
   public_configs = [ ":default_config" ]
-  public_deps = [
-    "$dir_pw_preprocessor",
-    "$dir_pw_span",
-  ]
+  public_deps = [ dir_pw_preprocessor ]
   sources = [
     "public/pw_varint/varint.h",
     "varint.cc",
diff --git a/pw_varint/docs.rst b/pw_varint/docs.rst
index 3327ccd..58c90f9 100644
--- a/pw_varint/docs.rst
+++ b/pw_varint/docs.rst
@@ -28,6 +28,11 @@
 
 Returns the size of a signed integer when ZigZag encoded as a varint.
 
+.. cpp:function:: uint64_t MaxValueInBytes(size_t bytes)
+
+Returns the maximum integer value that can be encoded as a varint into the
+specified number of bytes.
+
 Dependencies
 ============
 * ``pw_span``
diff --git a/pw_varint/public/pw_varint/varint.h b/pw_varint/public/pw_varint/varint.h
index a8db949..9d0e0ce 100644
--- a/pw_varint/public/pw_varint/varint.h
+++ b/pw_varint/public/pw_varint/varint.h
@@ -24,17 +24,47 @@
 
 // Expose a subset of the varint API for use in C code.
 
-size_t pw_VarintEncode(uint64_t integer, void* output, size_t output_size);
-size_t pw_VarintZigZagEncode(int64_t integer, void* output, size_t output_size);
+typedef enum {
+  PW_VARINT_ZERO_TERMINATED_LEAST_SIGNIFICANT = 0b00,
+  PW_VARINT_ZERO_TERMINATED_MOST_SIGNIFICANT = 0b01,
+  PW_VARINT_ONE_TERMINATED_LEAST_SIGNIFICANT = 0b10,
+  PW_VARINT_ONE_TERMINATED_MOST_SIGNIFICANT = 0b11,
+} pw_varint_Format;
 
-size_t pw_VarintDecode(const void* input, size_t input_size, uint64_t* output);
-size_t pw_VarintZigZagDecode(const void* input,
-                             size_t input_size,
-                             int64_t* output);
+size_t pw_varint_EncodeCustom(uint64_t integer,
+                              void* output,
+                              size_t output_size,
+                              pw_varint_Format format);
+size_t pw_varint_DecodeCustom(const void* input,
+                              size_t input_size,
+                              uint64_t* output,
+                              pw_varint_Format format);
+
+static inline size_t pw_varint_Encode(uint64_t integer,
+                                      void* output,
+                                      size_t output_size) {
+  return pw_varint_EncodeCustom(
+      integer, output, output_size, PW_VARINT_ZERO_TERMINATED_MOST_SIGNIFICANT);
+}
+
+size_t pw_varint_ZigZagEncode(int64_t integer,
+                              void* output,
+                              size_t output_size);
+
+static inline size_t pw_varint_Decode(const void* input,
+                                      size_t input_size,
+                                      uint64_t* output) {
+  return pw_varint_DecodeCustom(
+      input, input_size, output, PW_VARINT_ZERO_TERMINATED_MOST_SIGNIFICANT);
+}
+
+size_t pw_varint_ZigZagDecode(const void* input,
+                              size_t input_size,
+                              int64_t* output);
 
 // Returns the size of an when encoded as a varint.
-size_t pw_VarintEncodedSize(uint64_t integer);
-size_t pw_VarintZigZagEncodedSize(int64_t integer);
+size_t pw_varint_EncodedSize(uint64_t integer);
+size_t pw_varint_ZigZagEncodedSize(int64_t integer);
 
 #ifdef __cplusplus
 
@@ -83,7 +113,7 @@
 // Encodes a uint64_t with Little-Endian Base 128 (LEB128) encoding.
 inline size_t EncodeLittleEndianBase128(uint64_t integer,
                                         const std::span<std::byte>& output) {
-  return pw_VarintEncode(integer, output.data(), output.size());
+  return pw_varint_Encode(integer, output.data(), output.size());
 }
 
 // Encodes the provided integer using a variable-length encoding and returns the
@@ -99,9 +129,9 @@
 template <typename T>
 size_t Encode(T integer, const std::span<std::byte>& output) {
   if (std::is_signed<T>()) {
-    return pw_VarintZigZagEncode(integer, output.data(), output.size());
+    return pw_varint_ZigZagEncode(integer, output.data(), output.size());
   } else {
-    return pw_VarintEncode(integer, output.data(), output.size());
+    return pw_varint_Encode(integer, output.data(), output.size());
   }
 }
 
@@ -126,11 +156,36 @@
 //   }
 //
 inline size_t Decode(const std::span<const std::byte>& input, int64_t* value) {
-  return pw_VarintZigZagDecode(input.data(), input.size(), value);
+  return pw_varint_ZigZagDecode(input.data(), input.size(), value);
 }
 
 inline size_t Decode(const std::span<const std::byte>& input, uint64_t* value) {
-  return pw_VarintDecode(input.data(), input.size(), value);
+  return pw_varint_Decode(input.data(), input.size(), value);
+}
+
+enum class Format {
+  kZeroTerminatedLeastSignificant = PW_VARINT_ZERO_TERMINATED_LEAST_SIGNIFICANT,
+  kZeroTerminatedMostSignificant = PW_VARINT_ZERO_TERMINATED_MOST_SIGNIFICANT,
+  kOneTerminatedLeastSignificant = PW_VARINT_ONE_TERMINATED_LEAST_SIGNIFICANT,
+  kOneTerminatedMostSignificant = PW_VARINT_ONE_TERMINATED_MOST_SIGNIFICANT,
+};
+
+// Encodes a varint in a custom format.
+inline size_t Encode(uint64_t value,
+                     std::span<std::byte> output,
+                     Format format) {
+  return pw_varint_EncodeCustom(value,
+                                output.data(),
+                                output.size(),
+                                static_cast<pw_varint_Format>(format));
+}
+
+// Decodes a varint from a custom format.
+inline size_t Decode(std::span<const std::byte> input,
+                     uint64_t* value,
+                     Format format) {
+  return pw_varint_DecodeCustom(
+      input.data(), input.size(), value, static_cast<pw_varint_Format>(format));
 }
 
 // Returns a size of an integer when encoded as a varint.
@@ -143,6 +198,29 @@
   return EncodedSize(ZigZagEncode(integer));
 }
 
+// Returns the maximum integer value that can be encoded in a varint of the
+// specified number of bytes.
+//
+// These values are also listed in the table below. Zigzag encoding cuts these
+// in half, as positive and negative integers are alternated.
+//
+//   Bytes          Max value
+//     1                          127
+//     2                       16,383
+//     3                    2,097,151
+//     4                  268,435,455
+//     5               34,359,738,367 -- needed for max uint32 value
+//     6            4,398,046,511,103
+//     7          562,949,953,421,311
+//     8       72,057,594,037,927,935
+//     9    9,223,372,036,854,775,807
+//     10            uint64 max value
+//
+constexpr uint64_t MaxValueInBytes(size_t bytes) {
+  return bytes >= kMaxVarint64SizeBytes ? std::numeric_limits<uint64_t>::max()
+                                        : (uint64_t(1) << (7 * bytes)) - 1;
+}
+
 }  // namespace varint
 }  // namespace pw
 
diff --git a/pw_varint/varint.cc b/pw_varint/varint.cc
index e2d1eb2..fcef43d 100644
--- a/pw_varint/varint.cc
+++ b/pw_varint/varint.cc
@@ -18,36 +18,65 @@
 
 namespace pw {
 namespace varint {
+namespace {
 
-extern "C" size_t pw_VarintEncode(uint64_t integer,
-                                  void* output,
-                                  size_t output_size) {
+inline bool ZeroTerminated(pw_varint_Format format) {
+  return (static_cast<unsigned>(format) & 0b10) == 0;
+}
+
+inline bool LeastSignificant(pw_varint_Format format) {
+  return (static_cast<unsigned>(format) & 0b01) == 0;
+}
+
+}  // namespace
+
+extern "C" size_t pw_varint_EncodeCustom(uint64_t input,
+                                         void* output,
+                                         size_t output_size,
+                                         pw_varint_Format format) {
   size_t written = 0;
   std::byte* buffer = static_cast<std::byte*>(output);
 
+  int value_shift = LeastSignificant(format) ? 1 : 0;
+  int term_shift = value_shift == 1 ? 0 : 7;
+
+  std::byte cont, term;
+  if (ZeroTerminated(format)) {
+    cont = std::byte(0x01) << term_shift;
+    term = std::byte(0x00) << term_shift;
+  } else {
+    cont = std::byte(0x00) << term_shift;
+    term = std::byte(0x01) << term_shift;
+  }
+
   do {
     if (written >= output_size) {
       return 0;
     }
 
-    // Grab 7 bits; the eighth bit is set to 1 to indicate more data coming.
-    buffer[written++] = static_cast<std::byte>(integer) | std::byte(0x80);
-    integer >>= 7;
-  } while (integer != 0u);
+    bool last_byte = (input >> 7) == 0u;
 
-  buffer[written - 1] &= std::byte(0x7f);  // clear the top bit of the last byte
+    // Grab 7 bits and set the eighth according to the continuation bit.
+    std::byte value = (static_cast<std::byte>(input) & std::byte(0x7f))
+                      << value_shift;
+
+    if (last_byte) {
+      value |= term;
+    } else {
+      value |= cont;
+    }
+
+    buffer[written++] = value;
+    input >>= 7;
+  } while (input != 0u);
+
   return written;
 }
 
-extern "C" size_t pw_VarintZigZagEncode(int64_t integer,
-                                        void* output,
-                                        size_t output_size) {
-  return pw_VarintEncode(ZigZagEncode(integer), output, output_size);
-}
-
-extern "C" size_t pw_VarintDecode(const void* input,
-                                  size_t input_size,
-                                  uint64_t* output) {
+extern "C" size_t pw_varint_DecodeCustom(const void* input,
+                                         size_t input_size,
+                                         uint64_t* output,
+                                         pw_varint_Format format) {
   uint64_t decoded_value = 0;
   uint_fast8_t count = 0;
   const std::byte* buffer = static_cast<const std::byte*>(input);
@@ -55,17 +84,35 @@
   // The largest 64-bit ints require 10 B.
   const size_t max_count = std::min(kMaxVarint64SizeBytes, input_size);
 
+  std::byte mask;
+  uint32_t shift;
+  if (LeastSignificant(format)) {
+    mask = std::byte(0xfe);
+    shift = 1;
+  } else {
+    mask = std::byte(0x7f);
+    shift = 0;
+  }
+
+  // Determines whether a byte is the last byte of a varint.
+  auto is_last_byte = [&](std::byte byte) {
+    if (ZeroTerminated(format)) {
+      return (byte & ~mask) == std::byte(0);
+    }
+    return (byte & ~mask) != std::byte(0);
+  };
+
   while (true) {
     if (count >= max_count) {
       return 0;
     }
 
     // Add the bottom seven bits of the next byte to the result.
-    decoded_value |= static_cast<uint64_t>(buffer[count] & std::byte(0x7f))
+    decoded_value |= static_cast<uint64_t>((buffer[count] & mask) >> shift)
                      << (7 * count);
 
-    // Stop decoding if the top bit is not set.
-    if ((buffer[count++] & std::byte(0x80)) == std::byte(0)) {
+    // Stop decoding if the end is reached.
+    if (is_last_byte(buffer[count++])) {
       break;
     }
   }
@@ -74,20 +121,40 @@
   return count;
 }
 
-extern "C" size_t pw_VarintZigZagDecode(const void* input,
-                                        size_t input_size,
-                                        int64_t* output) {
+// TODO(frolv): Remove this deprecated alias.
+extern "C" size_t pw_VarintEncode(uint64_t integer,
+                                  void* output,
+                                  size_t output_size) {
+  return pw_varint_Encode(integer, output, output_size);
+}
+
+extern "C" size_t pw_varint_ZigZagEncode(int64_t integer,
+                                         void* output,
+                                         size_t output_size) {
+  return pw_varint_Encode(ZigZagEncode(integer), output, output_size);
+}
+
+// TODO(frolv): Remove this deprecated alias.
+extern "C" size_t pw_VarintDecode(const void* input,
+                                  size_t input_size,
+                                  uint64_t* output) {
+  return pw_varint_Decode(input, input_size, output);
+}
+
+extern "C" size_t pw_varint_ZigZagDecode(const void* input,
+                                         size_t input_size,
+                                         int64_t* output) {
   uint64_t value = 0;
-  size_t bytes = pw_VarintDecode(input, input_size, &value);
+  size_t bytes = pw_varint_Decode(input, input_size, &value);
   *output = ZigZagDecode(value);
   return bytes;
 }
 
-extern "C" size_t pw_VarintEncodedSize(uint64_t integer) {
+extern "C" size_t pw_varint_EncodedSize(uint64_t integer) {
   return EncodedSize(integer);
 }
 
-extern "C" size_t pw_VarintZigZagEncodedSize(int64_t integer) {
+extern "C" size_t pw_varint_ZigZagEncodedSize(int64_t integer) {
   return ZigZagEncodedSize(integer);
 }
 
diff --git a/pw_varint/varint_test.cc b/pw_varint/varint_test.cc
index eceaeb7..27282c4 100644
--- a/pw_varint/varint_test.cc
+++ b/pw_varint/varint_test.cc
@@ -27,14 +27,14 @@
 extern "C" {
 
 // Functions defined in varint_test.c which call the varint API from C.
-size_t pw_VarintCallEncode(uint64_t integer, void* output, size_t output_size);
-size_t pw_VarintCallZigZagEncode(int64_t integer,
-                                 void* output,
-                                 size_t output_size);
-size_t pw_VarintCallDecode(void* input, size_t input_size, uint64_t* output);
-size_t pw_VarintCallZigZagDecode(void* input,
-                                 size_t input_size,
-                                 int64_t* output);
+size_t pw_varint_CallEncode(uint64_t integer, void* output, size_t output_size);
+size_t pw_varint_CallZigZagEncode(int64_t integer,
+                                  void* output,
+                                  size_t output_size);
+size_t pw_varint_CallDecode(void* input, size_t input_size, uint64_t* output);
+size_t pw_varint_CallZigZagDecode(void* input,
+                                  size_t input_size,
+                                  int64_t* output);
 
 }  // extern "C"
 
@@ -64,11 +64,11 @@
 }
 
 TEST_F(Varint, EncodeSizeUnsigned32_SmallSingleByte_C) {
-  ASSERT_EQ(1u, pw_VarintCallEncode(UINT32_C(0), buffer_, sizeof(buffer_)));
+  ASSERT_EQ(1u, pw_varint_CallEncode(UINT32_C(0), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::byte{0}, buffer_[0]);
-  ASSERT_EQ(1u, pw_VarintCallEncode(UINT32_C(1), buffer_, sizeof(buffer_)));
+  ASSERT_EQ(1u, pw_varint_CallEncode(UINT32_C(1), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::byte{1}, buffer_[0]);
-  ASSERT_EQ(1u, pw_VarintCallEncode(UINT32_C(2), buffer_, sizeof(buffer_)));
+  ASSERT_EQ(1u, pw_varint_CallEncode(UINT32_C(2), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::byte{2}, buffer_[0]);
 }
 
@@ -84,13 +84,13 @@
 }
 
 TEST_F(Varint, EncodeSizeUnsigned32_LargeSingleByte_C) {
-  ASSERT_EQ(1u, pw_VarintCallEncode(UINT32_C(63), buffer_, sizeof(buffer_)));
+  ASSERT_EQ(1u, pw_varint_CallEncode(UINT32_C(63), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::byte{63}, buffer_[0]);
-  ASSERT_EQ(1u, pw_VarintCallEncode(UINT32_C(64), buffer_, sizeof(buffer_)));
+  ASSERT_EQ(1u, pw_varint_CallEncode(UINT32_C(64), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::byte{64}, buffer_[0]);
-  ASSERT_EQ(1u, pw_VarintCallEncode(UINT32_C(126), buffer_, sizeof(buffer_)));
+  ASSERT_EQ(1u, pw_varint_CallEncode(UINT32_C(126), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::byte{126}, buffer_[0]);
-  ASSERT_EQ(1u, pw_VarintCallEncode(UINT32_C(127), buffer_, sizeof(buffer_)));
+  ASSERT_EQ(1u, pw_varint_CallEncode(UINT32_C(127), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::byte{127}, buffer_[0]);
 }
 
@@ -108,20 +108,20 @@
 }
 
 TEST_F(Varint, EncodeSizeUnsigned32_MultiByte_C) {
-  ASSERT_EQ(2u, pw_VarintCallEncode(UINT32_C(128), buffer_, sizeof(buffer_)));
+  ASSERT_EQ(2u, pw_varint_CallEncode(UINT32_C(128), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::memcmp("\x80\x01", buffer_, 2), 0);
-  ASSERT_EQ(2u, pw_VarintCallEncode(UINT32_C(129), buffer_, sizeof(buffer_)));
+  ASSERT_EQ(2u, pw_varint_CallEncode(UINT32_C(129), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::memcmp("\x81\x01", buffer_, 2), 0);
 
   ASSERT_EQ(
       5u,
-      pw_VarintCallEncode(
+      pw_varint_CallEncode(
           std::numeric_limits<uint32_t>::max() - 1, buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::memcmp("\xfe\xff\xff\xff\x0f", buffer_, 5), 0);
 
   ASSERT_EQ(
       5u,
-      pw_VarintCallEncode(
+      pw_varint_CallEncode(
           std::numeric_limits<uint32_t>::max(), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::memcmp("\xff\xff\xff\xff\x0f", buffer_, 5), 0);
 }
@@ -141,19 +141,19 @@
 
 TEST_F(Varint, EncodeSizeSigned32_SmallSingleByte_C) {
   ASSERT_EQ(1u,
-            pw_VarintCallZigZagEncode(INT32_C(0), buffer_, sizeof(buffer_)));
+            pw_varint_CallZigZagEncode(INT32_C(0), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::byte{0}, buffer_[0]);
   ASSERT_EQ(1u,
-            pw_VarintCallZigZagEncode(INT32_C(-1), buffer_, sizeof(buffer_)));
+            pw_varint_CallZigZagEncode(INT32_C(-1), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::byte{1}, buffer_[0]);
   ASSERT_EQ(1u,
-            pw_VarintCallZigZagEncode(INT32_C(1), buffer_, sizeof(buffer_)));
+            pw_varint_CallZigZagEncode(INT32_C(1), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::byte{2}, buffer_[0]);
   ASSERT_EQ(1u,
-            pw_VarintCallZigZagEncode(INT32_C(-2), buffer_, sizeof(buffer_)));
+            pw_varint_CallZigZagEncode(INT32_C(-2), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::byte{3}, buffer_[0]);
   ASSERT_EQ(1u,
-            pw_VarintCallZigZagEncode(INT32_C(2), buffer_, sizeof(buffer_)));
+            pw_varint_CallZigZagEncode(INT32_C(2), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::byte{4}, buffer_[0]);
 }
 
@@ -168,13 +168,13 @@
 
 TEST_F(Varint, EncodeSizeSigned32_LargeSingleByte_C) {
   ASSERT_EQ(1u,
-            pw_VarintCallZigZagEncode(INT32_C(-63), buffer_, sizeof(buffer_)));
+            pw_varint_CallZigZagEncode(INT32_C(-63), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::byte{125}, buffer_[0]);
   ASSERT_EQ(1u,
-            pw_VarintCallZigZagEncode(INT32_C(63), buffer_, sizeof(buffer_)));
+            pw_varint_CallZigZagEncode(INT32_C(63), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::byte{126}, buffer_[0]);
   ASSERT_EQ(1u,
-            pw_VarintCallZigZagEncode(INT32_C(-64), buffer_, sizeof(buffer_)));
+            pw_varint_CallZigZagEncode(INT32_C(-64), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::byte{127}, buffer_[0]);
 }
 
@@ -195,22 +195,22 @@
 
 TEST_F(Varint, EncodeSizeSigned32_MultiByte_C) {
   ASSERT_EQ(2u,
-            pw_VarintCallZigZagEncode(INT32_C(64), buffer_, sizeof(buffer_)));
+            pw_varint_CallZigZagEncode(INT32_C(64), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::memcmp("\x80\x01", buffer_, 2), 0);
   ASSERT_EQ(2u,
-            pw_VarintCallZigZagEncode(INT32_C(-65), buffer_, sizeof(buffer_)));
+            pw_varint_CallZigZagEncode(INT32_C(-65), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::memcmp("\x81\x01", buffer_, 2), 0);
   ASSERT_EQ(2u,
-            pw_VarintCallZigZagEncode(INT32_C(65), buffer_, sizeof(buffer_)));
+            pw_varint_CallZigZagEncode(INT32_C(65), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::memcmp("\x82\x01", buffer_, 2), 0);
 
   ASSERT_EQ(5u,
-            pw_VarintCallZigZagEncode(
+            pw_varint_CallZigZagEncode(
                 std::numeric_limits<int32_t>::min(), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::memcmp("\xff\xff\xff\xff\x0f", buffer_, 5), 0);
 
   ASSERT_EQ(5u,
-            pw_VarintCallZigZagEncode(
+            pw_varint_CallZigZagEncode(
                 std::numeric_limits<int32_t>::max(), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::memcmp("\xfe\xff\xff\xff\x0f", buffer_, 5), 0);
 }
@@ -225,11 +225,11 @@
 }
 
 TEST_F(Varint, EncodeSizeUnsigned64_SmallSingleByte_C) {
-  ASSERT_EQ(1u, pw_VarintCallEncode(UINT64_C(0), buffer_, sizeof(buffer_)));
+  ASSERT_EQ(1u, pw_varint_CallEncode(UINT64_C(0), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::byte{0}, buffer_[0]);
-  ASSERT_EQ(1u, pw_VarintCallEncode(UINT64_C(1), buffer_, sizeof(buffer_)));
+  ASSERT_EQ(1u, pw_varint_CallEncode(UINT64_C(1), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::byte{1}, buffer_[0]);
-  ASSERT_EQ(1u, pw_VarintCallEncode(UINT64_C(2), buffer_, sizeof(buffer_)));
+  ASSERT_EQ(1u, pw_varint_CallEncode(UINT64_C(2), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::byte{2}, buffer_[0]);
 }
 
@@ -245,13 +245,13 @@
 }
 
 TEST_F(Varint, EncodeSizeUnsigned64_LargeSingleByte_C) {
-  ASSERT_EQ(1u, pw_VarintCallEncode(UINT64_C(63), buffer_, sizeof(buffer_)));
+  ASSERT_EQ(1u, pw_varint_CallEncode(UINT64_C(63), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::byte{63}, buffer_[0]);
-  ASSERT_EQ(1u, pw_VarintCallEncode(UINT64_C(64), buffer_, sizeof(buffer_)));
+  ASSERT_EQ(1u, pw_varint_CallEncode(UINT64_C(64), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::byte{64}, buffer_[0]);
-  ASSERT_EQ(1u, pw_VarintCallEncode(UINT64_C(126), buffer_, sizeof(buffer_)));
+  ASSERT_EQ(1u, pw_varint_CallEncode(UINT64_C(126), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::byte{126}, buffer_[0]);
-  ASSERT_EQ(1u, pw_VarintCallEncode(UINT64_C(127), buffer_, sizeof(buffer_)));
+  ASSERT_EQ(1u, pw_varint_CallEncode(UINT64_C(127), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::byte{127}, buffer_[0]);
 }
 
@@ -277,33 +277,33 @@
 }
 
 TEST_F(Varint, EncodeSizeUnsigned64_MultiByte_C) {
-  ASSERT_EQ(2u, pw_VarintCallEncode(UINT64_C(128), buffer_, sizeof(buffer_)));
+  ASSERT_EQ(2u, pw_varint_CallEncode(UINT64_C(128), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::memcmp("\x80\x01", buffer_, 2), 0);
-  ASSERT_EQ(2u, pw_VarintCallEncode(UINT64_C(129), buffer_, sizeof(buffer_)));
+  ASSERT_EQ(2u, pw_varint_CallEncode(UINT64_C(129), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::memcmp("\x81\x01", buffer_, 2), 0);
 
   ASSERT_EQ(
       5u,
-      pw_VarintCallEncode(
+      pw_varint_CallEncode(
           std::numeric_limits<uint32_t>::max() - 1, buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::memcmp("\xfe\xff\xff\xff\x0f", buffer_, 5), 0);
 
   ASSERT_EQ(
       5u,
-      pw_VarintCallEncode(
+      pw_varint_CallEncode(
           std::numeric_limits<uint32_t>::max(), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::memcmp("\xff\xff\xff\xff\x0f", buffer_, 5), 0);
 
   ASSERT_EQ(
       10u,
-      pw_VarintCallEncode(
+      pw_varint_CallEncode(
           std::numeric_limits<uint64_t>::max() - 1, buffer_, sizeof(buffer_)));
   EXPECT_EQ(
       std::memcmp("\xfe\xff\xff\xff\xff\xff\xff\xff\xff\x01", buffer_, 10), 0);
 
   ASSERT_EQ(
       10u,
-      pw_VarintCallEncode(
+      pw_varint_CallEncode(
           std::numeric_limits<uint64_t>::max(), buffer_, sizeof(buffer_)));
   EXPECT_EQ(
       std::memcmp("\xff\xff\xff\xff\xff\xff\xff\xff\xff\x01", buffer_, 10), 0);
@@ -324,19 +324,19 @@
 
 TEST_F(Varint, EncodeSizeSigned64_SmallSingleByte_C) {
   ASSERT_EQ(1u,
-            pw_VarintCallZigZagEncode(INT64_C(0), buffer_, sizeof(buffer_)));
+            pw_varint_CallZigZagEncode(INT64_C(0), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::byte{0}, buffer_[0]);
   ASSERT_EQ(1u,
-            pw_VarintCallZigZagEncode(INT64_C(-1), buffer_, sizeof(buffer_)));
+            pw_varint_CallZigZagEncode(INT64_C(-1), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::byte{1}, buffer_[0]);
   ASSERT_EQ(1u,
-            pw_VarintCallZigZagEncode(INT64_C(1), buffer_, sizeof(buffer_)));
+            pw_varint_CallZigZagEncode(INT64_C(1), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::byte{2}, buffer_[0]);
   ASSERT_EQ(1u,
-            pw_VarintCallZigZagEncode(INT64_C(-2), buffer_, sizeof(buffer_)));
+            pw_varint_CallZigZagEncode(INT64_C(-2), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::byte{3}, buffer_[0]);
   ASSERT_EQ(1u,
-            pw_VarintCallZigZagEncode(INT64_C(2), buffer_, sizeof(buffer_)));
+            pw_varint_CallZigZagEncode(INT64_C(2), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::byte{4}, buffer_[0]);
 }
 
@@ -351,13 +351,13 @@
 
 TEST_F(Varint, EncodeSizeSigned64_LargeSingleByte_C) {
   ASSERT_EQ(1u,
-            pw_VarintCallZigZagEncode(INT64_C(-63), buffer_, sizeof(buffer_)));
+            pw_varint_CallZigZagEncode(INT64_C(-63), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::byte{125}, buffer_[0]);
   ASSERT_EQ(1u,
-            pw_VarintCallZigZagEncode(INT64_C(63), buffer_, sizeof(buffer_)));
+            pw_varint_CallZigZagEncode(INT64_C(63), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::byte{126}, buffer_[0]);
   ASSERT_EQ(1u,
-            pw_VarintCallZigZagEncode(INT64_C(-64), buffer_, sizeof(buffer_)));
+            pw_varint_CallZigZagEncode(INT64_C(-64), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::byte{127}, buffer_[0]);
 }
 
@@ -390,37 +390,37 @@
 
 TEST_F(Varint, EncodeSizeSigned64_MultiByte_C) {
   ASSERT_EQ(2u,
-            pw_VarintCallZigZagEncode(INT64_C(64), buffer_, sizeof(buffer_)));
+            pw_varint_CallZigZagEncode(INT64_C(64), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::memcmp("\x80\x01", buffer_, 2), 0);
   ASSERT_EQ(2u,
-            pw_VarintCallZigZagEncode(INT64_C(-65), buffer_, sizeof(buffer_)));
+            pw_varint_CallZigZagEncode(INT64_C(-65), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::memcmp("\x81\x01", buffer_, 2), 0);
   ASSERT_EQ(2u,
-            pw_VarintCallZigZagEncode(INT64_C(65), buffer_, sizeof(buffer_)));
+            pw_varint_CallZigZagEncode(INT64_C(65), buffer_, sizeof(buffer_)));
   EXPECT_EQ(std::memcmp("\x82\x01", buffer_, 2), 0);
 
   ASSERT_EQ(5u,
-            pw_VarintCallZigZagEncode(
+            pw_varint_CallZigZagEncode(
                 static_cast<int64_t>(std::numeric_limits<int32_t>::min()),
                 buffer_,
                 sizeof(buffer_)));
   EXPECT_EQ(std::memcmp("\xff\xff\xff\xff\x0f", buffer_, 5), 0);
 
   ASSERT_EQ(5u,
-            pw_VarintCallZigZagEncode(
+            pw_varint_CallZigZagEncode(
                 static_cast<int64_t>(std::numeric_limits<int32_t>::max()),
                 buffer_,
                 sizeof(buffer_)));
   EXPECT_EQ(std::memcmp("\xfe\xff\xff\xff\x0f", buffer_, 5), 0);
 
   ASSERT_EQ(10u,
-            pw_VarintCallZigZagEncode(
+            pw_varint_CallZigZagEncode(
                 std::numeric_limits<int64_t>::min(), buffer_, sizeof(buffer_)));
   EXPECT_EQ(
       std::memcmp("\xff\xff\xff\xff\xff\xff\xff\xff\xff\x01", buffer_, 10), 0);
 
   ASSERT_EQ(10u,
-            pw_VarintCallZigZagEncode(
+            pw_varint_CallZigZagEncode(
                 std::numeric_limits<int64_t>::max(), buffer_, sizeof(buffer_)));
   EXPECT_EQ(
       std::memcmp("\xfe\xff\xff\xff\xff\xff\xff\xff\xff\x01", buffer_, 10), 0);
@@ -452,11 +452,11 @@
 TEST_F(Varint, EncodeDecodeSigned32_C) {
   int32_t i = std::numeric_limits<int32_t>::min();
   while (true) {
-    size_t encoded = pw_VarintCallZigZagEncode(i, buffer_, sizeof(buffer_));
+    size_t encoded = pw_varint_CallZigZagEncode(i, buffer_, sizeof(buffer_));
 
     int64_t result;
     size_t decoded =
-        pw_VarintCallZigZagDecode(buffer_, sizeof(buffer_), &result);
+        pw_varint_CallZigZagDecode(buffer_, sizeof(buffer_), &result);
 
     EXPECT_EQ(encoded, decoded);
     ASSERT_EQ(i, result);
@@ -491,10 +491,10 @@
 TEST_F(Varint, EncodeDecodeUnsigned32_C) {
   uint32_t i = 0;
   while (true) {
-    size_t encoded = pw_VarintCallEncode(i, buffer_, sizeof(buffer_));
+    size_t encoded = pw_varint_CallEncode(i, buffer_, sizeof(buffer_));
 
     uint64_t result;
-    size_t decoded = pw_VarintCallDecode(buffer_, sizeof(buffer_), &result);
+    size_t decoded = pw_varint_CallDecode(buffer_, sizeof(buffer_), &result);
 
     EXPECT_EQ(encoded, decoded);
     ASSERT_EQ(i, result);
@@ -543,32 +543,32 @@
   int64_t value = -1234;
 
   auto buffer = MakeBuffer("\x00");
-  EXPECT_EQ(pw_VarintCallZigZagDecode(buffer.data(), buffer.size(), &value),
+  EXPECT_EQ(pw_varint_CallZigZagDecode(buffer.data(), buffer.size(), &value),
             1u);
   EXPECT_EQ(value, 0);
 
   buffer = MakeBuffer("\x01");
-  EXPECT_EQ(pw_VarintCallZigZagDecode(buffer.data(), buffer.size(), &value),
+  EXPECT_EQ(pw_varint_CallZigZagDecode(buffer.data(), buffer.size(), &value),
             1u);
   EXPECT_EQ(value, -1);
 
   buffer = MakeBuffer("\x02");
-  EXPECT_EQ(pw_VarintCallZigZagDecode(buffer.data(), buffer.size(), &value),
+  EXPECT_EQ(pw_varint_CallZigZagDecode(buffer.data(), buffer.size(), &value),
             1u);
   EXPECT_EQ(value, 1);
 
   buffer = MakeBuffer("\x03");
-  EXPECT_EQ(pw_VarintCallZigZagDecode(buffer.data(), buffer.size(), &value),
+  EXPECT_EQ(pw_varint_CallZigZagDecode(buffer.data(), buffer.size(), &value),
             1u);
   EXPECT_EQ(value, -2);
 
   buffer = MakeBuffer("\x04");
-  EXPECT_EQ(pw_VarintCallZigZagDecode(buffer.data(), buffer.size(), &value),
+  EXPECT_EQ(pw_varint_CallZigZagDecode(buffer.data(), buffer.size(), &value),
             1u);
   EXPECT_EQ(value, 2);
 
   buffer = MakeBuffer("\x04");
-  EXPECT_EQ(pw_VarintCallZigZagDecode(buffer.data(), buffer.size(), &value),
+  EXPECT_EQ(pw_varint_CallZigZagDecode(buffer.data(), buffer.size(), &value),
             1u);
   EXPECT_EQ(value, 2);
 }
@@ -606,37 +606,37 @@
   int64_t value = -1234;
 
   auto buffer2 = MakeBuffer("\x80\x01");
-  EXPECT_EQ(pw_VarintCallZigZagDecode(buffer2.data(), buffer2.size(), &value),
+  EXPECT_EQ(pw_varint_CallZigZagDecode(buffer2.data(), buffer2.size(), &value),
             2u);
   EXPECT_EQ(value, 64);
 
   buffer2 = MakeBuffer("\x81\x01");
-  EXPECT_EQ(pw_VarintCallZigZagDecode(buffer2.data(), buffer2.size(), &value),
+  EXPECT_EQ(pw_varint_CallZigZagDecode(buffer2.data(), buffer2.size(), &value),
             2u);
   EXPECT_EQ(value, -65);
 
   buffer2 = MakeBuffer("\x82\x01");
-  EXPECT_EQ(pw_VarintCallZigZagDecode(buffer2.data(), buffer2.size(), &value),
+  EXPECT_EQ(pw_varint_CallZigZagDecode(buffer2.data(), buffer2.size(), &value),
             2u);
   EXPECT_EQ(value, 65);
 
   auto buffer4 = MakeBuffer("\xff\xff\xff\xff\x0f");
-  EXPECT_EQ(pw_VarintCallZigZagDecode(buffer4.data(), buffer4.size(), &value),
+  EXPECT_EQ(pw_varint_CallZigZagDecode(buffer4.data(), buffer4.size(), &value),
             5u);
   EXPECT_EQ(value, std::numeric_limits<int32_t>::min());
 
   buffer4 = MakeBuffer("\xfe\xff\xff\xff\x0f");
-  EXPECT_EQ(pw_VarintCallZigZagDecode(buffer4.data(), buffer4.size(), &value),
+  EXPECT_EQ(pw_varint_CallZigZagDecode(buffer4.data(), buffer4.size(), &value),
             5u);
   EXPECT_EQ(value, std::numeric_limits<int32_t>::max());
 
   auto buffer8 = MakeBuffer("\xff\xff\xff\xff\xff\xff\xff\xff\xff\x01");
-  EXPECT_EQ(pw_VarintCallZigZagDecode(buffer8.data(), buffer8.size(), &value),
+  EXPECT_EQ(pw_varint_CallZigZagDecode(buffer8.data(), buffer8.size(), &value),
             10u);
   EXPECT_EQ(value, std::numeric_limits<int64_t>::min());
 
   buffer8 = MakeBuffer("\xfe\xff\xff\xff\xff\xff\xff\xff\xff\x01");
-  EXPECT_EQ(pw_VarintCallZigZagDecode(buffer8.data(), buffer8.size(), &value),
+  EXPECT_EQ(pw_varint_CallZigZagDecode(buffer8.data(), buffer8.size(), &value),
             10u);
   EXPECT_EQ(value, std::numeric_limits<int64_t>::max());
 }
@@ -780,6 +780,235 @@
             std::numeric_limits<int64_t>::max());
 }
 
+TEST_F(Varint, EncodeWithOptions_SingleByte) {
+  ASSERT_EQ(Encode(0u, buffer_, Format::kZeroTerminatedLeastSignificant), 1u);
+  EXPECT_EQ(buffer_[0], std::byte{0x00});
+
+  ASSERT_EQ(Encode(1u, buffer_, Format::kZeroTerminatedLeastSignificant), 1u);
+  EXPECT_EQ(buffer_[0], std::byte{0x02});
+
+  ASSERT_EQ(Encode(0x7f, buffer_, Format::kZeroTerminatedLeastSignificant), 1u);
+  EXPECT_EQ(buffer_[0], std::byte{0xfe});
+
+  ASSERT_EQ(Encode(0u, buffer_, Format::kOneTerminatedLeastSignificant), 1u);
+  EXPECT_EQ(buffer_[0], std::byte{0x01});
+
+  ASSERT_EQ(Encode(2u, buffer_, Format::kOneTerminatedLeastSignificant), 1u);
+  EXPECT_EQ(buffer_[0], std::byte{0x05});
+
+  ASSERT_EQ(Encode(0x7f, buffer_, Format::kOneTerminatedLeastSignificant), 1u);
+  EXPECT_EQ(buffer_[0], std::byte{0xff});
+
+  ASSERT_EQ(Encode(0u, buffer_, Format::kZeroTerminatedMostSignificant), 1u);
+  EXPECT_EQ(buffer_[0], std::byte{0x00});
+
+  ASSERT_EQ(Encode(7u, buffer_, Format::kZeroTerminatedMostSignificant), 1u);
+  EXPECT_EQ(buffer_[0], std::byte{0x07});
+
+  ASSERT_EQ(Encode(0x7f, buffer_, Format::kZeroTerminatedMostSignificant), 1u);
+  EXPECT_EQ(buffer_[0], std::byte{0x7f});
+
+  ASSERT_EQ(Encode(0u, buffer_, Format::kOneTerminatedMostSignificant), 1u);
+  EXPECT_EQ(buffer_[0], std::byte{0x80});
+
+  ASSERT_EQ(Encode(15u, buffer_, Format::kOneTerminatedMostSignificant), 1u);
+  EXPECT_EQ(buffer_[0], std::byte{0x8f});
+
+  ASSERT_EQ(Encode(0x7f, buffer_, Format::kOneTerminatedMostSignificant), 1u);
+  EXPECT_EQ(buffer_[0], std::byte{0xff});
+}
+
+TEST_F(Varint, EncodeWithOptions_MultiByte) {
+  ASSERT_EQ(Encode(128u, buffer_, Format::kZeroTerminatedLeastSignificant), 2u);
+  EXPECT_EQ(std::memcmp("\x01\x02", buffer_, 2), 0);
+
+  ASSERT_EQ(
+      Encode(0xffffffff, buffer_, Format::kZeroTerminatedLeastSignificant), 5u);
+  EXPECT_EQ(std::memcmp("\xff\xff\xff\xff\x1e", buffer_, 5), 0);
+
+  ASSERT_EQ(Encode(128u, buffer_, Format::kOneTerminatedLeastSignificant), 2u);
+  EXPECT_EQ(std::memcmp("\x00\x03", buffer_, 2), 0);
+
+  ASSERT_EQ(Encode(0xffffffff, buffer_, Format::kOneTerminatedLeastSignificant),
+            5u);
+  EXPECT_EQ(std::memcmp("\xfe\xfe\xfe\xfe\x1f", buffer_, 5), 0);
+
+  ASSERT_EQ(Encode(128u, buffer_, Format::kZeroTerminatedMostSignificant), 2u);
+  EXPECT_EQ(std::memcmp("\x80\x01", buffer_, 2), 0);
+
+  ASSERT_EQ(Encode(0xffffffff, buffer_, Format::kZeroTerminatedMostSignificant),
+            5u);
+  EXPECT_EQ(std::memcmp("\xff\xff\xff\xff\x0f", buffer_, 5), 0);
+
+  ASSERT_EQ(Encode(128u, buffer_, Format::kOneTerminatedMostSignificant), 2u);
+  EXPECT_EQ(std::memcmp("\x00\x81", buffer_, 2), 0);
+
+  ASSERT_EQ(Encode(0xffffffff, buffer_, Format::kOneTerminatedMostSignificant),
+            5u);
+  EXPECT_EQ(std::memcmp("\x7f\x7f\x7f\x7f\x8f", buffer_, 5), 0);
+}
+
+TEST(Varint, DecodeWithOptions_SingleByte) {
+  uint64_t value = -1234;
+
+  EXPECT_EQ(
+      Decode(
+          MakeBuffer("\x00"), &value, Format::kZeroTerminatedLeastSignificant),
+      1u);
+  EXPECT_EQ(value, 0u);
+
+  EXPECT_EQ(
+      Decode(
+          MakeBuffer("\x04"), &value, Format::kZeroTerminatedLeastSignificant),
+      1u);
+  EXPECT_EQ(value, 2u);
+
+  EXPECT_EQ(
+      Decode(
+          MakeBuffer("\xaa"), &value, Format::kZeroTerminatedLeastSignificant),
+      1u);
+  EXPECT_EQ(value, 85u);
+
+  EXPECT_EQ(
+      Decode(
+          MakeBuffer("\x01"), &value, Format::kZeroTerminatedLeastSignificant),
+      0u);
+
+  EXPECT_EQ(
+      Decode(
+          MakeBuffer("\x01"), &value, Format::kOneTerminatedLeastSignificant),
+      1u);
+  EXPECT_EQ(value, 0u);
+
+  EXPECT_EQ(
+      Decode(
+          MakeBuffer("\x13"), &value, Format::kOneTerminatedLeastSignificant),
+      1u);
+  EXPECT_EQ(value, 9u);
+
+  EXPECT_EQ(
+      Decode(
+          MakeBuffer("\x00"), &value, Format::kOneTerminatedLeastSignificant),
+      0u);
+
+  EXPECT_EQ(
+      Decode(
+          MakeBuffer("\x00"), &value, Format::kZeroTerminatedMostSignificant),
+      1u);
+  EXPECT_EQ(value, 0u);
+
+  EXPECT_EQ(
+      Decode(
+          MakeBuffer("\x04"), &value, Format::kZeroTerminatedMostSignificant),
+      1u);
+  EXPECT_EQ(value, 4u);
+
+  EXPECT_EQ(
+      Decode(
+          MakeBuffer("\xff"), &value, Format::kZeroTerminatedMostSignificant),
+      0u);
+
+  EXPECT_EQ(
+      Decode(MakeBuffer("\x80"), &value, Format::kOneTerminatedMostSignificant),
+      1u);
+  EXPECT_EQ(value, 0u);
+
+  EXPECT_EQ(
+      Decode(MakeBuffer("\x83"), &value, Format::kOneTerminatedMostSignificant),
+      1u);
+  EXPECT_EQ(value, 3u);
+
+  EXPECT_EQ(
+      Decode(MakeBuffer("\xaa"), &value, Format::kOneTerminatedMostSignificant),
+      1u);
+  EXPECT_EQ(value, 42u);
+
+  EXPECT_EQ(
+      Decode(MakeBuffer("\xff"), &value, Format::kOneTerminatedMostSignificant),
+      1u);
+  EXPECT_EQ(value, 127u);
+
+  EXPECT_EQ(
+      Decode(MakeBuffer("\x00"), &value, Format::kOneTerminatedMostSignificant),
+      0u);
+}
+
+TEST(Varint, DecodeWithOptions_MultiByte) {
+  uint64_t value = -1234;
+
+  EXPECT_EQ(Decode(MakeBuffer("\x01\x10"),
+                   &value,
+                   Format::kZeroTerminatedLeastSignificant),
+            2u);
+  EXPECT_EQ(value, 1024u);
+
+  EXPECT_EQ(Decode(MakeBuffer("\xff\xff\xff\xfe"),
+                   &value,
+                   Format::kZeroTerminatedLeastSignificant),
+            4u);
+  EXPECT_EQ(value, 0x0fffffffu);
+
+  EXPECT_EQ(Decode(MakeBuffer("\x01\x01\x01\x01\x00"),
+                   &value,
+                   Format::kZeroTerminatedLeastSignificant),
+            5u);
+  EXPECT_EQ(value, 0u);
+
+  EXPECT_EQ(Decode(MakeBuffer("\x82\x2d"),
+                   &value,
+                   Format::kOneTerminatedLeastSignificant),
+            2u);
+  EXPECT_EQ(value, 2881u);
+
+  EXPECT_EQ(Decode(MakeBuffer("\xfe\xfe\xfe\xff"),
+                   &value,
+                   Format::kOneTerminatedLeastSignificant),
+            4u);
+  EXPECT_EQ(value, 0x0fffffffu);
+
+  EXPECT_EQ(Decode(MakeBuffer("\x00\x00\x00\x00\x01"),
+                   &value,
+                   Format::kOneTerminatedLeastSignificant),
+            5u);
+  EXPECT_EQ(value, 0u);
+
+  EXPECT_EQ(Decode(MakeBuffer("\x83\x6a"),
+                   &value,
+                   Format::kZeroTerminatedMostSignificant),
+            2u);
+  EXPECT_EQ(value, 0b1101010'0000011u);
+
+  EXPECT_EQ(Decode(MakeBuffer("\xff\xff\xff\x7f"),
+                   &value,
+                   Format::kZeroTerminatedMostSignificant),
+            4u);
+  EXPECT_EQ(value, 0x0fffffffu);
+
+  EXPECT_EQ(Decode(MakeBuffer("\x80\x80\x80\x80\x00"),
+                   &value,
+                   Format::kZeroTerminatedMostSignificant),
+            5u);
+  EXPECT_EQ(value, 0u);
+
+  EXPECT_EQ(Decode(MakeBuffer("\x6a\x83"),
+                   &value,
+                   Format::kOneTerminatedMostSignificant),
+            2u);
+  EXPECT_EQ(value, 0b0000011'1101010u);
+
+  EXPECT_EQ(Decode(MakeBuffer("\x7f\x7f\x7f\xff"),
+                   &value,
+                   Format::kOneTerminatedMostSignificant),
+            4u);
+  EXPECT_EQ(value, 0x0fffffffu);
+
+  EXPECT_EQ(Decode(MakeBuffer("\x00\x00\x00\x00\x80"),
+                   &value,
+                   Format::kOneTerminatedMostSignificant),
+            5u);
+  EXPECT_EQ(value, 0u);
+}
+
 TEST(Varint, EncodedSize) {
   EXPECT_EQ(EncodedSize(uint64_t(0u)), 1u);
   EXPECT_EQ(EncodedSize(uint64_t(1u)), 1u);
@@ -825,5 +1054,29 @@
   EXPECT_EQ(ZigZagEncodedSize(std::numeric_limits<int64_t>::max()), 10u);
 }
 
+constexpr uint64_t CalculateMaxValueInBytes(size_t bytes) {
+  uint64_t value = 0;
+  for (size_t i = 0; i < bytes; ++i) {
+    value |= uint64_t(0x7f) << (7 * i);
+  }
+  return value;
+}
+
+TEST(Varint, MaxValueInBytes) {
+  static_assert(MaxValueInBytes(0) == 0);
+  static_assert(MaxValueInBytes(1) == 0x7f);
+  static_assert(MaxValueInBytes(2) == 0x3fff);
+  static_assert(MaxValueInBytes(3) == 0x1fffff);
+  static_assert(MaxValueInBytes(4) == 0x0fffffff);
+  static_assert(MaxValueInBytes(5) == CalculateMaxValueInBytes(5));
+  static_assert(MaxValueInBytes(6) == CalculateMaxValueInBytes(6));
+  static_assert(MaxValueInBytes(7) == CalculateMaxValueInBytes(7));
+  static_assert(MaxValueInBytes(8) == CalculateMaxValueInBytes(8));
+  static_assert(MaxValueInBytes(9) == CalculateMaxValueInBytes(9));
+  static_assert(MaxValueInBytes(10) == std::numeric_limits<uint64_t>::max());
+  static_assert(MaxValueInBytes(11) == std::numeric_limits<uint64_t>::max());
+  static_assert(MaxValueInBytes(100) == std::numeric_limits<uint64_t>::max());
+}
+
 }  // namespace
 }  // namespace pw::varint
diff --git a/pw_varint/varint_test_c.c b/pw_varint/varint_test_c.c
index 232dab3..0e66ac9 100644
--- a/pw_varint/varint_test_c.c
+++ b/pw_varint/varint_test_c.c
@@ -19,22 +19,24 @@
 
 #include "pw_varint/varint.h"
 
-size_t pw_VarintCallEncode(uint64_t integer, void* output, size_t output_size) {
-  return pw_VarintEncode(integer, output, output_size);
+size_t pw_varint_CallEncode(uint64_t integer,
+                            void* output,
+                            size_t output_size) {
+  return pw_varint_Encode(integer, output, output_size);
 }
 
-size_t pw_VarintCallZigZagEncode(int64_t integer,
-                                 void* output,
-                                 size_t output_size) {
-  return pw_VarintZigZagEncode(integer, output, output_size);
+size_t pw_varint_CallZigZagEncode(int64_t integer,
+                                  void* output,
+                                  size_t output_size) {
+  return pw_varint_ZigZagEncode(integer, output, output_size);
 }
 
-size_t pw_VarintCallDecode(void* input, size_t input_size, uint64_t* output) {
-  return pw_VarintDecode(input, input_size, output);
+size_t pw_varint_CallDecode(void* input, size_t input_size, uint64_t* output) {
+  return pw_varint_Decode(input, input_size, output);
 }
 
-size_t pw_VarintCallZigZagDecode(void* input,
-                                 size_t input_size,
-                                 int64_t* output) {
-  return pw_VarintZigZagDecode(input, input_size, output);
+size_t pw_varint_CallZigZagDecode(void* input,
+                                  size_t input_size,
+                                  int64_t* output) {
+  return pw_varint_ZigZagDecode(input, input_size, output);
 }
diff --git a/pw_watch/docs.rst b/pw_watch/docs.rst
index f5f0c6c..ad4def6 100644
--- a/pw_watch/docs.rst
+++ b/pw_watch/docs.rst
@@ -3,7 +3,6 @@
 --------
 pw_watch
 --------
-
 ``pw_watch`` is similar to file system watchers found in the web development
 space. These watchers trigger a web server reload on source change, increasing
 iteration. In the embedded space, file system watchers are less prevalent but no
@@ -18,44 +17,45 @@
 
 Module Usage
 ============
-
 The simplest way to get started with ``pw_watch`` is to launch it from a shell
-using the Pigweed environment.
-
+using the Pigweed environment as ``pw watch``. By default, ``pw_watch`` watches
+for repository changes and triggers the default Ninja build target for an
+automatically located build directory (typically ``$PW_ROOT/out``). To override
+this behavior, provide the ``-C`` argument to ``pw watch``.
 
 .. code:: sh
 
-  $ pw watch
+  # Find a build directory and build the default target
+  pw watch
 
-By default, ``pw_watch`` will watch for repository changes and then trigger the
-default Ninja build target for ``${PIGWEED_ROOT}/out``. To override this
-behavior, follow ``pw watch`` with the path to the build directory optionally
-followed by the Ninja targets you'd like to build:
+  # Find a build directory and build the stm32f429i target
+  pw watch python.lint stm32f429i
 
-.. code:: sh
+  # Build pw_run_tests.modules in the out/cmake directory
+  pw watch -C out/cmake pw_run_tests.modules
 
-  # Build the default target in the "out" directory.
-  $ pw watch out
+  # Build the default target in out/ and pw_apps in out/cmake
+  pw watch -C out -C out/cmake pw_apps
 
-  # Build the "host" target in the "out" directory.
-  $ pw watch out host
+  # Find a directory and build python.tests, and build pw_apps in out/cmake
+  pw watch python.tests -C out/cmake pw_apps
 
-  # Build the "host" and "docs" targets in the "out" directory.
-  $ pw watch out host docs
+``pw watch`` only rebuilds when a file that is not ignored by Git changes.
+Adding exclusions to a ``.gitignore`` causes watch to ignore them, even if the
+files were forcibly added to a repo. By default, only files matching certain
+extensions are applied, even if they're tracked by Git. The ``--patterns`` and
+``--ignore_patterns`` arguments can be used to include or exclude specific
+patterns. These patterns do not override Git's ignoring logic.
 
-  # Build "host" target in "out", and "stm32f429i" target in "build_dir_2".
-  $ pw watch --build-directory out host --build-directory build_dir_2 stm32f429i
+The ``--exclude_list`` argument can be used to exclude directories from being
+watched. This decreases the number of files monitored with inotify in Linux.
 
-The ``--patterns`` and ``--ignore_patterns`` arguments can be used to include
-and exclude certain file patterns that will trigger rebuilds.
-
-The ``--exclude_list`` argument can be used to exclude directories from
-being watched by your system. This can decrease the inotify number in Linux
-system.
+By default, ``pw watch`` automatically restarts an ongoing build when files
+change. This can be disabled with the ``--no-restart`` option. While running
+``pw watch``, you may also press enter to immediately restart a build.
 
 Unit Test Integration
 =====================
-
 Thanks to GN's understanding of the full dependency tree, only the tests
 affected by a file change are run when ``pw_watch`` triggers a build. By
 default, host builds using ``pw_watch`` will run unit tests. To run unit tests
diff --git a/pw_watch/py/BUILD.gn b/pw_watch/py/BUILD.gn
index a8fe59d..e14d675 100644
--- a/pw_watch/py/BUILD.gn
+++ b/pw_watch/py/BUILD.gn
@@ -22,6 +22,8 @@
     "pw_watch/__init__.py",
     "pw_watch/debounce.py",
     "pw_watch/watch.py",
-    "pw_watch/watch_test.py",
   ]
+  tests = [ "watch_test.py" ]
+  pylintrc = "$dir_pigweed/.pylintrc"
+  python_deps = [ "$dir_pw_cli/py" ]
 }
diff --git a/pw_watch/py/pw_watch/debounce.py b/pw_watch/py/pw_watch/debounce.py
index 73c5e97..0384219 100644
--- a/pw_watch/py/pw_watch/debounce.py
+++ b/pw_watch/py/pw_watch/debounce.py
@@ -34,7 +34,7 @@
         Returns true if run was successfully cancelled, false otherwise"""
 
     @abstractmethod
-    def on_complete(self, cancelled: bool = False) -> bool:
+    def on_complete(self, cancelled: bool = False) -> None:
         """Called after run() finishes. If true, cancelled indicates
         cancel() was invoked during the last run()"""
 
@@ -57,7 +57,7 @@
 
 class Debouncer:
     """Run an interruptable, cancellable function with debouncing"""
-    def __init__(self, function):
+    def __init__(self, function: DebouncedFunction) -> None:
         super().__init__()
         self.function = function
 
@@ -69,22 +69,22 @@
         self.cooldown_seconds = 1
         self.cooldown_timer = None
 
-        self.rerun_event_description = None
+        self.rerun_event_description = ''
 
         self.lock = threading.Lock()
 
-    def press(self, event_description=None):
+    def press(self, event_description: str = '') -> None:
         """Try to run the function for the class. If the function is recently
         started, this may push out the deadline for actually starting. If the
         function is already running, will interrupt the function"""
         with self.lock:
             self._press_unlocked(event_description)
 
-    def _press_unlocked(self, event_description=None):
+    def _press_unlocked(self, event_description: str) -> None:
         _LOG.debug('Press - state = %s', str(self.state))
         if self.state == State.IDLE:
             if event_description:
-                _LOG.info(event_description)
+                _LOG.info('%s', event_description)
             self._start_debounce_timer()
             self._transition(State.DEBOUNCING)
 
@@ -118,8 +118,8 @@
             self._transition(State.RERUN)
             self.rerun_event_description = event_description
 
-    def _transition(self, new_state):
-        _LOG.debug('State: %s -> %s', str(self.state), str(new_state))
+    def _transition(self, new_state: State) -> None:
+        _LOG.debug('State: %s -> %s', self.state, new_state)
         self.state = new_state
 
     def _start_debounce_timer(self):
@@ -173,7 +173,7 @@
 
                 # If we were in the RERUN state, then re-trigger the event.
                 if rerun:
-                    self._press_unlocked('Rerunning: %s' %
+                    self._press_unlocked('Rerunning: ' +
                                          self.rerun_event_description)
 
         # Ctrl-C on Unix generates KeyboardInterrupt
diff --git a/pw_watch/py/pw_watch/watch.py b/pw_watch/py/pw_watch/watch.py
index 7f02980..c7269e7 100755
--- a/pw_watch/py/pw_watch/watch.py
+++ b/pw_watch/py/pw_watch/watch.py
@@ -12,23 +12,43 @@
 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 # License for the specific language governing permissions and limitations under
 # the License.
-"""Rebuild every time a file is changed."""
+"""Watch files for changes and rebuild.
+
+pw watch runs Ninja in a build directory when source files change. It works with
+any Ninja project (GN or CMake).
+
+Usage examples:
+
+  # Find a build directory and build the default target
+  pw watch
+
+  # Find a build directory and build the stm32f429i target
+  pw watch python.lint stm32f429i
+
+  # Build pw_run_tests.modules in the out/cmake directory
+  pw watch -C out/cmake pw_run_tests.modules
+
+  # Build the default target in out/ and pw_apps in out/cmake
+  pw watch -C out -C out/cmake pw_apps
+
+  # Find a directory and build python.tests, and build pw_apps in out/cmake
+  pw watch python.tests -C out/cmake pw_apps
+"""
 
 import argparse
 from dataclasses import dataclass
-import glob
 import logging
 import os
-import pathlib
+from pathlib import Path
 import shlex
 import subprocess
 import sys
 import threading
-from typing import List, NamedTuple, Optional, Sequence, Tuple
+from typing import (Iterable, List, NamedTuple, NoReturn, Optional, Sequence,
+                    Tuple)
 
-from watchdog.events import FileSystemEventHandler  # type: ignore
-from watchdog.observers import Observer  # type: ignore
-from watchdog.utils import has_attribute, unicode_paths  # type: ignore
+from watchdog.events import FileSystemEventHandler  # type: ignore[import]
+from watchdog.observers import Observer  # type: ignore[import]
 
 import pw_cli.branding
 import pw_cli.color
@@ -41,6 +61,12 @@
 _LOG = logging.getLogger(__name__)
 _ERRNO_INOTIFY_LIMIT_REACHED = 28
 
+# Suppress events under 'fsevents', generated by watchdog on every file
+# event on MacOS.
+# TODO(b/182281481): Fix file ignoring, rather than just suppressing logs
+_FSEVENTS_LOG = logging.getLogger('fsevents')
+_FSEVENTS_LOG.setLevel(logging.WARNING)
+
 _PASS_MESSAGE = """
   ██████╗  █████╗ ███████╗███████╗██╗
   ██╔══██╗██╔══██╗██╔════╝██╔════╝██║
@@ -68,14 +94,11 @@
 # TODO(keir): Figure out a better strategy for exiting. The problem with the
 # watcher is that doing a "clean exit" is slow. However, by directly exiting,
 # we remove the possibility of the wrapper script doing anything on exit.
-def _die(*args):
-    _LOG.fatal(*args)
+def _die(*args) -> NoReturn:
+    _LOG.critical(*args)
     sys.exit(1)
 
 
-# pylint: disable=logging-format-interpolation
-
-
 class WatchCharset(NamedTuple):
     slug_ok: str
     slug_fail: str
@@ -87,7 +110,7 @@
 
 @dataclass(frozen=True)
 class BuildCommand:
-    build_dir: pathlib.Path
+    build_dir: Path
     targets: Tuple[str, ...] = ()
 
     def args(self) -> Tuple[str, ...]:
@@ -97,6 +120,19 @@
         return ' '.join(shlex.quote(arg) for arg in self.args())
 
 
+def git_ignored(file: Path) -> bool:
+    """Returns true if this file is in a Git repo and ignored by that repo.
+
+    Returns true for ignored files that were manually added to a repo.
+    """
+    returncode = subprocess.run(
+        ['git', 'check-ignore', '--quiet', '--no-index', file],
+        stdout=subprocess.DEVNULL,
+        stderr=subprocess.DEVNULL,
+        cwd=file.parent).returncode
+    return returncode in (0, 128)
+
+
 class PigweedBuildWatcher(FileSystemEventHandler, DebouncedFunction):
     """Process filesystem events and launch builds if necessary."""
     def __init__(
@@ -104,67 +140,48 @@
         patterns: Sequence[str] = (),
         ignore_patterns: Sequence[str] = (),
         build_commands: Sequence[BuildCommand] = (),
-        ignore_dirs=Optional[List[str]],
         charset: WatchCharset = _ASCII_CHARSET,
-        restart: bool = False,
+        restart: bool = True,
     ):
         super().__init__()
 
         self.patterns = patterns
         self.ignore_patterns = ignore_patterns
         self.build_commands = build_commands
-        self.ignore_dirs = ignore_dirs or []
-        self.ignore_dirs.extend(cmd.build_dir for cmd in self.build_commands)
         self.charset: WatchCharset = charset
 
         self.restart_on_changes = restart
-        self._current_build: Optional[subprocess.Popen] = None
+        self._current_build: subprocess.Popen
 
         self.debouncer = Debouncer(self)
 
         # Track state of a build. These need to be members instead of locals
         # due to the split between dispatch(), run(), and on_complete().
-        self.matching_path = None
+        self.matching_path: Optional[Path] = None
         self.builds_succeeded: List[bool] = []
 
         self.wait_for_keypress_thread = threading.Thread(
             None, self._wait_for_enter)
         self.wait_for_keypress_thread.start()
 
-    def _wait_for_enter(self):
+    def _wait_for_enter(self) -> NoReturn:
         try:
             while True:
                 _ = input()
+                self._current_build.kill()
+
                 self.debouncer.press('Manual build requested...')
         # Ctrl-C on Unix generates KeyboardInterrupt
         # Ctrl-Z on Windows generates EOFError
         except (KeyboardInterrupt, EOFError):
             _exit_due_to_interrupt()
 
-    def path_matches(self, raw_path):
+    def _path_matches(self, path: Path) -> bool:
         """Returns true if path matches according to the watcher patterns"""
-        modified_path = pathlib.Path(raw_path).resolve()
+        return (not any(path.match(x) for x in self.ignore_patterns)
+                and any(path.match(x) for x in self.patterns))
 
-        # Check for modifications inside the ignore directories, and skip them.
-        # Ideally these events would never hit the watcher, but selectively
-        # watching directories at the OS level is not trivial due to limitations
-        # of the watchdog module.
-        for ignore_dir in self.ignore_dirs:
-            resolved_ignore_dir = pathlib.Path(ignore_dir).resolve()
-            try:
-                modified_path.relative_to(resolved_ignore_dir)
-                # If no ValueError is raised by the .relative_to() call, then
-                # this file is inside the ignore directory; so skip it.
-                return False
-            except ValueError:
-                # Otherwise, the file isn't in the ignore directory, so run the
-                # normal pattern checks below.
-                pass
-
-        return ((not any(modified_path.match(x) for x in self.ignore_patterns))
-                and any(modified_path.match(x) for x in self.patterns))
-
-    def dispatch(self, event):
+    def dispatch(self, event) -> None:
         # There isn't any point in triggering builds on new directory creation.
         # It's the creation or modification of files that indicate something
         # meaningful enough changed for a build.
@@ -172,26 +189,21 @@
             return
 
         # Collect paths of interest from the event.
-        paths = []
-        if has_attribute(event, 'dest_path'):
-            paths.append(unicode_paths.decode(event.dest_path))
+        paths: List[str] = []
+        if hasattr(event, 'dest_path'):
+            paths.append(os.fsdecode(event.dest_path))
         if event.src_path:
-            paths.append(unicode_paths.decode(event.src_path))
-        for path in paths:
-            _LOG.debug('File event: %s', path)
+            paths.append(os.fsdecode(event.src_path))
+        for raw_path in paths:
+            _LOG.debug('File event: %s', raw_path)
 
-        # Check for matching paths among the one or two in the event.
-        matching_path = None
-        for path in paths:
-            if self.path_matches(path):
-                _LOG.debug('Detected event: %s', path)
-                matching_path = path
-                break
+        # Check whether Git cares about any of these paths.
+        for path in (Path(p).resolve() for p in paths):
+            if not git_ignored(path) and self._path_matches(path):
+                self._handle_matched_event(path)
+                return
 
-        if matching_path:
-            self.handle_matched_event(matching_path)
-
-    def handle_matched_event(self, matching_path):
+    def _handle_matched_event(self, matching_path: Path) -> None:
         if self.matching_path is None:
             self.matching_path = matching_path
 
@@ -203,7 +215,7 @@
     # Note: This will run on the timer thread created by the Debouncer, rather
     # than on the main thread that's watching file events. This enables the
     # watcher to continue receiving file change events during a build.
-    def run(self):
+    def run(self) -> None:
         """Run all the builds in serial and capture pass/fail for each."""
 
         # Clear the screen and show a banner indicating the build is starting.
@@ -245,15 +257,15 @@
             self.builds_succeeded.append(build_ok)
 
     # Implementation of DebouncedFunction.cancel()
-    def cancel(self):
+    def cancel(self) -> bool:
         if self.restart_on_changes:
-            self._current_build.terminate()
+            self._current_build.kill()
             return True
 
         return False
 
     # Implementation of DebouncedFunction.run()
-    def on_complete(self, cancelled=False):
+    def on_complete(self, cancelled: bool = False) -> None:
         # First, use the standard logging facilities to report build status.
         if cancelled:
             _LOG.error('Finished; build was interrupted')
@@ -294,7 +306,7 @@
         self.matching_path = None
 
     # Implementation of DebouncedFunction.on_keyboard_interrupt()
-    def on_keyboard_interrupt(self):
+    def on_keyboard_interrupt(self) -> NoReturn:
         _exit_due_to_interrupt()
 
 
@@ -320,44 +332,52 @@
 )
 
 
-def add_parser_arguments(parser):
+def add_parser_arguments(parser: argparse.ArgumentParser) -> None:
+    """Sets up an argument parser for pw watch."""
     parser.add_argument('--patterns',
                         help=(_WATCH_PATTERN_DELIMITER +
                               '-delimited list of globs to '
                               'watch to trigger recompile'),
                         default=_WATCH_PATTERN_DELIMITER.join(_WATCH_PATTERNS))
     parser.add_argument('--ignore_patterns',
+                        dest='ignore_patterns_string',
                         help=(_WATCH_PATTERN_DELIMITER +
                               '-delimited list of globs to '
                               'ignore events from'))
 
     parser.add_argument('--exclude_list',
                         nargs='+',
-                        help=('directories to ignore during pw watch'),
+                        type=Path,
+                        help='directories to ignore during pw watch',
                         default=[])
-    parser.add_argument('--restart',
-                        action='store_true',
-                        help='restart an ongoing build if files change')
+    parser.add_argument('--no-restart',
+                        dest='restart',
+                        action='store_false',
+                        help='do not restart ongoing builds if files change')
     parser.add_argument(
-        'build_targets',
+        'default_build_targets',
         nargs='*',
+        metavar='target',
         default=[],
-        help=('A Ninja directory to build, followed by specific targets to '
-              'build. For example, `out host docs` builds the `host` and '
-              '`docs` Ninja targets in the `out` directory. To build '
-              'additional directories, use `--build-directory`.'))
-
+        help=('Automatically locate a build directory and build these '
+              'targets. For example, `host docs` searches for a Ninja '
+              'build directory (starting with out/) and builds the '
+              '`host` and `docs` targets. To specify one or more '
+              'directories, ust the -C / --build_directory option.'))
     parser.add_argument(
-        '--build-directory',
+        '-C',
+        '--build_directory',
+        dest='build_directories',
         nargs='+',
         action='append',
         default=[],
-        metavar=('dir', 'target'),
-        help=('Allows additional build directories to be specified. Uses the '
-              'same syntax as `build_targets`.'))
+        metavar=('directory', 'target'),
+        help=('Specify a build directory and optionally targets to '
+              'build. `pw watch -C out tgt` is equivalent to `ninja '
+              '-C out tgt`'))
 
 
-def _exit(code):
+def _exit(code: int) -> NoReturn:
     # Note: The "proper" way to exit is via observer.stop(), then
     # running a join. However it's slower, so just exit immediately.
     #
@@ -367,7 +387,7 @@
     os._exit(code)  # pylint: disable=protected-access
 
 
-def _exit_due_to_interrupt():
+def _exit_due_to_interrupt() -> NoReturn:
     # To keep the log lines aligned with each other in the presence of
     # a '^C' from the keyboard interrupt, add a newline before the log.
     print()
@@ -400,98 +420,77 @@
     _exit(1)
 
 
-def is_subdirectory(child, parent):
-    return (pathlib.Path(parent).resolve()
-            in pathlib.Path(pathlib.Path(child).resolve()).parents)
-
-
 # Go over each directory inside of the current directory.
 # If it is not on the path of elements in directories_to_exclude, add
 # (directory, True) to subdirectories_to_watch and later recursively call
 # Observer() on them.
 # Otherwise add (directory, False) to subdirectories_to_watch and later call
 # Observer() with recursion=False.
-def minimal_watch_directories(directory_to_watch, directories_to_exclude):
+def minimal_watch_directories(to_watch: Path, to_exclude: Iterable[Path]):
     """Determine which subdirectory to watch recursively"""
     try:
-        cur_dir = pathlib.Path(directory_to_watch)
+        to_watch = Path(to_watch)
     except TypeError:
         assert False, "Please watch one directory at a time."
-    subdirectories_to_watch = []
 
-    # Reformat directories_to_exclude.
-    directories_to_exclude = [
-        pathlib.Path(cur_dir, directory_to_exclude)
-        for directory_to_exclude in directories_to_exclude
-        if pathlib.Path(cur_dir, directory_to_exclude).is_dir()
+    # Reformat to_exclude.
+    directories_to_exclude: List[Path] = [
+        to_watch.joinpath(directory_to_exclude)
+        for directory_to_exclude in to_exclude
+        if to_watch.joinpath(directory_to_exclude).is_dir()
     ]
 
-    # Split the relative path of directories_to_exclude (compared to
-    # directory_to_watch), and generate all parent paths needed to be
-    # watched without recursion.
-    exclude_dir_parents = {cur_dir}
+    # Split the relative path of directories_to_exclude (compared to to_watch),
+    # and generate all parent paths needed to be watched without recursion.
+    exclude_dir_parents = {to_watch}
     for directory_to_exclude in directories_to_exclude:
         parts = list(
-            pathlib.Path(directory_to_exclude).relative_to(cur_dir).parts)[:-1]
-        dir_tmp = cur_dir
+            Path(directory_to_exclude).relative_to(to_watch).parts)[:-1]
+        dir_tmp = to_watch
         for part in parts:
-            dir_tmp = pathlib.Path(dir_tmp, part)
+            dir_tmp = Path(dir_tmp, part)
             exclude_dir_parents.add(dir_tmp)
 
     # Go over all layers of directory. Append those that are the parents of
     # directories_to_exclude to the list with recursion==False, and others
     # with recursion==True.
     for directory in exclude_dir_parents:
-        dir_path = pathlib.Path(directory)
-        subdirectories_to_watch.append((dir_path, False))
-        for item in pathlib.Path(directory).iterdir():
+        dir_path = Path(directory)
+        yield dir_path, False
+        for item in Path(directory).iterdir():
             if (item.is_dir() and item not in exclude_dir_parents
                     and item not in directories_to_exclude):
-                subdirectories_to_watch.append((item, True))
-
-    return subdirectories_to_watch
+                yield item, True
 
 
-def gitignore_patterns():
-    """Load patterns in pw_root_dir/.gitignore and return as [str]"""
-    pw_root_dir = pathlib.Path(os.environ['PW_ROOT'])
-
-    # Get top level .gitignore entries
-    gitignore_path = pw_root_dir / pathlib.Path('.gitignore')
-    if gitignore_path.exists():
-        for line in gitignore_path.read_text().splitlines():
-            globname = line.strip()
-            # If line is empty or a comment.
-            if not globname or globname.startswith('#'):
-                continue
-            yield line
-
-
-def get_common_excludes():
+def get_common_excludes() -> List[Path]:
     """Find commonly excluded directories, and return them as a [Path]"""
-    exclude_list = []
+    exclude_list: List[Path] = []
+
+    typical_ignored_directories: List[str] = [
+        '.environment',  # Legacy bootstrap-created CIPD and Python venv.
+        '.presubmit',  # Presubmit-created CIPD and Python venv.
+        '.git',  # Pigweed's git repo.
+        '.mypy_cache',  # Python static analyzer.
+        '.cargo',  # Rust package manager.
+        'environment',  # Bootstrap-created CIPD and Python venv.
+        'out',  # Typical build directory.
+    ]
 
     # Preset exclude list for Pigweed's upstream directories.
-    pw_root_dir = pathlib.Path(os.environ['PW_ROOT'])
-    exclude_list.extend([
-        pw_root_dir / ignored_directory for ignored_directory in [
-            '.environment',  # Bootstrap-created CIPD and Python venv.
-            '.presubmit',  # Presubmit-created CIPD and Python venv.
-            '.git',  # Pigweed's git repo.
-            '.mypy_cache',  # Python static analyzer.
-            '.cargo',  # Rust package manager.
-            'out',  # Typical build directory.
-        ]
-    ])
+    pw_root_dir = Path(os.environ['PW_ROOT'])
+    exclude_list.extend(pw_root_dir / ignored_directory
+                        for ignored_directory in typical_ignored_directories)
 
     # Preset exclude for common downstream project structures.
     #
-    # By convention, Pigweed projects use "out" as a build directory, so if
-    # watch is invoked outside the Pigweed root, also ignore the local out
-    # directory.
-    cur_dir = pathlib.Path.cwd()
-    if cur_dir != pw_root_dir:
-        exclude_list.append(cur_dir / 'out')
+    # If watch is invoked outside of the Pigweed root, exclude common
+    # directories.
+    pw_project_root_dir = Path(os.environ['PW_PROJECT_ROOT'])
+    if pw_project_root_dir != pw_root_dir:
+        exclude_list.extend(
+            pw_project_root_dir / ignored_directory
+            for ignored_directory in typical_ignored_directories)
 
     # Check for and warn about legacy directories.
     legacy_directories = [
@@ -513,55 +512,52 @@
     return exclude_list
 
 
-def watch(build_targets, build_directory, patterns, ignore_patterns,
-          exclude_list, restart: bool):
-    """TODO(keir) docstring"""
+def _find_build_dir(default_build_dir: Path = Path('out')) -> Optional[Path]:
+    """Searches for a build directory, returning the first it finds."""
+    # Give priority to out/, then something under out/.
+    if default_build_dir.joinpath('build.ninja').exists():
+        return default_build_dir
 
+    for path in default_build_dir.glob('**/build.ninja'):
+        return path.parent
+
+    for path in Path.cwd().glob('**/build.ninja'):
+        return path.parent
+
+    return None
+
+
+def watch(default_build_targets: List[str], build_directories: List[str],
+          patterns: str, ignore_patterns_string: str, exclude_list: List[Path],
+          restart: bool):
+    """Watches files and runs Ninja commands when they change."""
     _LOG.info('Starting Pigweed build watcher')
 
     # Get pigweed directory information from environment variable PW_ROOT.
     if os.environ['PW_ROOT'] is None:
         _exit_due_to_pigweed_not_installed()
-    path_of_pigweed = pathlib.Path(os.environ['PW_ROOT'])
-    cur_dir = pathlib.Path.cwd()
-    if (not (is_subdirectory(path_of_pigweed, cur_dir)
-             or path_of_pigweed == cur_dir)):
+    pw_root = Path(os.environ['PW_ROOT']).resolve()
+    if Path.cwd().resolve() not in [pw_root, *pw_root.parents]:
         _exit_due_to_pigweed_not_installed()
 
     # Preset exclude list for pigweed directory.
     exclude_list += get_common_excludes()
 
-    subdirectories_to_watch = minimal_watch_directories(cur_dir, exclude_list)
+    build_commands = [
+        BuildCommand(Path(build_dir[0]), tuple(build_dir[1:]))
+        for build_dir in build_directories
+    ]
 
-    # If no build directory was specified, search the tree for GN build
-    # directories and try to build them all. In the future this may cause
-    # slow startup, but for now this is fast enough.
-    build_commands = []
-    if not build_targets and not build_directory:
-        _LOG.info('Searching for GN build dirs...')
-        gn_args_files = []
-        if os.path.isfile('out/args.gn'):
-            gn_args_files += ['out/args.gn']
-        gn_args_files += glob.glob('out/*/args.gn')
+    # If no build directory was specified, search the tree for a build.ninja.
+    if default_build_targets or not build_directories:
+        build_dir = _find_build_dir()
 
-        for gn_args_file in gn_args_files:
-            gn_build_dir = pathlib.Path(gn_args_file).parent
-            gn_build_dir = gn_build_dir.resolve().relative_to(cur_dir)
-            if gn_build_dir.is_dir():
-                build_commands.append(BuildCommand(gn_build_dir))
-    else:
-        if build_targets:
-            build_directory.append(build_targets)
-        # Reformat the directory of build commands to be relative to the
-        # currently directory.
-        for build_target in build_directory:
-            build_commands.append(
-                BuildCommand(pathlib.Path(build_target[0]),
-                             tuple(build_target[1:])))
+        # Make sure we found something; if not, bail.
+        if build_dir is None:
+            _die("No build dirs found. Did you forget to run 'gn gen out'?")
 
-    # Make sure we found something; if not, bail.
-    if not build_commands:
-        _die("No build dirs found. Did you forget to 'gn gen out'?")
+        build_commands.append(
+            BuildCommand(build_dir, tuple(default_build_targets)))
 
     # Verify that the build output directories exist.
     for i, build_target in enumerate(build_commands, 1):
@@ -576,16 +572,11 @@
     # Try to make a short display path for the watched directory that has
     # "$HOME" instead of the full home directory. This is nice for users
     # who have deeply nested home directories.
-    path_to_log = str(pathlib.Path().resolve()).replace(
-        str(pathlib.Path.home()), '$HOME')
+    path_to_log = str(Path().resolve()).replace(str(Path.home()), '$HOME')
 
     # Ignore the user-specified patterns.
-    ignore_patterns = (ignore_patterns.split(_WATCH_PATTERN_DELIMITER)
-                       if ignore_patterns else [])
-    # Ignore top level pw_root_dir/.gitignore patterns.
-    ignore_patterns += gitignore_patterns()
-
-    ignore_dirs = ['.presubmit', '.python3-env']
+    ignore_patterns = (ignore_patterns_string.split(_WATCH_PATTERN_DELIMITER)
+                       if ignore_patterns_string else [])
 
     env = pw_cli.env.pigweed_environment()
     if env.PW_EMOJI:
@@ -597,7 +588,6 @@
         patterns=patterns.split(_WATCH_PATTERN_DELIMITER),
         ignore_patterns=ignore_patterns,
         build_commands=build_commands,
-        ignore_dirs=ignore_dirs,
         charset=charset,
         restart=restart,
     )
@@ -613,11 +603,11 @@
         # directory should be observed recursively or not is determined by the
         # second element in subdirectories_to_watch.
         observers = []
-        for directory, rec in subdirectories_to_watch:
+        for path, rec in minimal_watch_directories(Path.cwd(), exclude_list):
             observer = Observer()
             observer.schedule(
                 event_handler,
-                str(directory),
+                str(path),
                 recursive=rec,
             )
             observer.start()
@@ -625,7 +615,7 @@
 
         event_handler.debouncer.press('Triggering initial build...')
         for observer in observers:
-            while observer.isAlive():
+            while observer.is_alive():
                 observer.join(1)
 
     # Ctrl-C on Unix generates KeyboardInterrupt
@@ -642,9 +632,11 @@
     observer.join()
 
 
-def main():
+def main() -> None:
     """Watch files for changes and rebuild."""
-    parser = argparse.ArgumentParser(description=main.__doc__)
+    parser = argparse.ArgumentParser(
+        description=__doc__,
+        formatter_class=argparse.RawDescriptionHelpFormatter)
     add_parser_arguments(parser)
     watch(**vars(parser.parse_args()))
 
diff --git a/pw_watch/py/pw_watch/watch_test.py b/pw_watch/py/pw_watch/watch_test.py
deleted file mode 100755
index ff79e14..0000000
--- a/pw_watch/py/pw_watch/watch_test.py
+++ /dev/null
@@ -1,184 +0,0 @@
-# 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 for pw_watch.minimal_watch_directories."""
-
-import unittest
-import os
-import tempfile
-from pathlib import Path
-
-import pw_watch.watch as watch
-
-
-def make_tree(root, directories):
-    for directory in directories:
-        os.mkdir(Path(root, directory))
-
-
-class TestMinimalWatchDirectories(unittest.TestCase):
-    """Tests for pw_watch.watch.minimal_watch_directories."""
-    def test_empty_directory(self):
-        subdirectories_to_watch = []
-        with tempfile.TemporaryDirectory() as tmpdir:
-            ans_subdirectories_to_watch = [(Path(tmpdir), False)]
-            subdirectories_to_watch = \
-                watch.minimal_watch_directories(tmpdir, 'f1')
-
-        self.assertEqual(set(subdirectories_to_watch),
-                         set(ans_subdirectories_to_watch))
-
-    def test_non_exist_directories_to_exclude(self):
-        subdirectories_to_watch = []
-        exclude_list = ['f3']
-        with tempfile.TemporaryDirectory() as tmpdir:
-            make_tree(tmpdir, ['f1', 'f2'])
-            ans_subdirectories_to_watch = [
-                (Path(tmpdir, 'f1'), True),
-                (Path(tmpdir, 'f2'), True),
-                (Path(tmpdir), False),
-            ]
-            subdirectories_to_watch = \
-                watch.minimal_watch_directories(tmpdir, exclude_list)
-
-        self.assertEqual(set(subdirectories_to_watch),
-                         set(ans_subdirectories_to_watch))
-
-    def test_one_layer_directories(self):
-        subdirectories_to_watch = []
-        exclude_list = ['f1']
-        with tempfile.TemporaryDirectory() as tmpdir:
-            make_tree(tmpdir, [
-                'f1',
-                'f1/f1',
-                'f1/f2',
-                'f2',
-                'f2/f1',
-            ])
-            ans_subdirectories_to_watch = [
-                (Path(tmpdir, 'f2'), True),
-                (Path(tmpdir), False),
-            ]
-            subdirectories_to_watch = \
-                watch.minimal_watch_directories(tmpdir, exclude_list)
-
-        self.assertEqual(set(subdirectories_to_watch),
-                         set(ans_subdirectories_to_watch))
-
-    def test_two_layers_direcories(self):
-        subdirectories_to_watch = []
-        exclude_list = ['f1/f2']
-        with tempfile.TemporaryDirectory() as tmpdir:
-            make_tree(tmpdir, [
-                'f1',
-                'f1/f1',
-                'f1/f2',
-                'f2',
-                'f2/f1',
-            ])
-            ans_subdirectories_to_watch = [
-                (Path(tmpdir, 'f2'), True),
-                (Path(tmpdir, 'f1/f1'), True),
-                (Path(tmpdir), False),
-                (Path(tmpdir, 'f1'), False),
-            ]
-            subdirectories_to_watch = \
-                watch.minimal_watch_directories(tmpdir, exclude_list)
-
-        self.assertEqual(set(subdirectories_to_watch),
-                         set(ans_subdirectories_to_watch))
-
-    def test_empty_exclude_list(self):
-        subdirectories_to_watch = []
-        exclude_list = []
-        with tempfile.TemporaryDirectory() as tmpdir:
-            make_tree(tmpdir, [
-                'f1',
-                'f1/f1',
-                'f1/f2',
-                'f2',
-                'f2/f1',
-            ])
-            ans_subdirectories_to_watch = [
-                (Path(tmpdir, 'f2'), True),
-                (Path(tmpdir, 'f1'), True),
-                (Path(tmpdir), False),
-            ]
-            subdirectories_to_watch = \
-                watch.minimal_watch_directories(tmpdir, exclude_list)
-
-        self.assertEqual(set(subdirectories_to_watch),
-                         set(ans_subdirectories_to_watch))
-
-    def test_multiple_directories_in_exclude_list(self):
-        """test case for multiple directories to exclude"""
-        subdirectories_to_watch = []
-        exclude_list = [
-            'f1/f2',
-            'f3/f1',
-            'f3/f3',
-        ]
-        with tempfile.TemporaryDirectory() as tmpdir:
-            make_tree(tmpdir, [
-                'f1', 'f1/f1', 'f1/f2', 'f2', 'f2/f1', 'f3', 'f3/f1', 'f3/f2',
-                'f3/f3'
-            ])
-            ans_subdirectories_to_watch = [
-                (Path(tmpdir, 'f2'), True),
-                (Path(tmpdir, 'f1/f1'), True),
-                (Path(tmpdir, 'f3/f2'), True),
-                (Path(tmpdir), False),
-                (Path(tmpdir, 'f1'), False),
-                (Path(tmpdir, 'f3'), False),
-            ]
-            subdirectories_to_watch = \
-                watch.minimal_watch_directories(tmpdir, exclude_list)
-
-        self.assertEqual(set(subdirectories_to_watch),
-                         set(ans_subdirectories_to_watch))
-
-    def test_nested_sibling_exclusion(self):
-        subdirectories_to_watch = []
-        exclude_list = [
-            'f1/f1/f1/f1/f1',
-            'f1/f1/f1/f2',
-        ]
-        with tempfile.TemporaryDirectory() as tmpdir:
-            make_tree(tmpdir, [
-                'f1',
-                'f1/f1',
-                'f1/f1/f1',
-                'f1/f1/f1/f1',
-                'f1/f1/f1/f1/f1',
-                'f1/f1/f1/f1/f2',
-                'f1/f1/f1/f1/f3',
-                'f1/f1/f1/f2',
-            ])
-            ans_subdirectories_to_watch = [
-                (Path(tmpdir, 'f1/f1/f1/f1/f2'), True),
-                (Path(tmpdir, 'f1/f1/f1/f1/f3'), True),
-                (Path(tmpdir), False),
-                (Path(tmpdir, 'f1'), False),
-                (Path(tmpdir, 'f1/f1'), False),
-                (Path(tmpdir, 'f1/f1/f1'), False),
-                (Path(tmpdir, 'f1/f1/f1/f1'), False),
-            ]
-            subdirectories_to_watch = \
-                watch.minimal_watch_directories(tmpdir, exclude_list)
-
-        self.assertEqual(set(subdirectories_to_watch),
-                         set(ans_subdirectories_to_watch))
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/pw_watch/py/setup.py b/pw_watch/py/setup.py
index 7958eab..82f4c17 100644
--- a/pw_watch/py/setup.py
+++ b/pw_watch/py/setup.py
@@ -25,6 +25,12 @@
     package_data={'pw_watch': ['py.typed']},
     zip_safe=False,
     install_requires=[
-        'watchdog',
+        'pw_cli',
+        # Fixes the watchdog version to 0.10.3, released 2020-06-25
+        # as versions later than this ignore the 'recursive' argument
+        # on MacOS. This was causing us to trigger on any file within
+        # the source tree, even those that should have been ignored.
+        # See https://github.com/gorakhargosh/watchdog/issues/771.
+        'watchdog==0.10.3',
     ],
 )
diff --git a/pw_watch/py/watch_test.py b/pw_watch/py/watch_test.py
new file mode 100755
index 0000000..6c9323a
--- /dev/null
+++ b/pw_watch/py/watch_test.py
@@ -0,0 +1,177 @@
+#!/usr/bin/env python
+# 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 for pw_watch.minimal_watch_directories."""
+
+import unittest
+import tempfile
+from pathlib import Path
+
+from pw_watch import watch
+
+
+class TestMinimalWatchDirectories(unittest.TestCase):
+    """Tests for pw_watch.watch.minimal_watch_directories."""
+    def setUp(self):
+        self._tempdir = tempfile.TemporaryDirectory()
+        self._root = Path(self._tempdir.name)
+
+    def tearDown(self):
+        self._tempdir.cleanup()
+
+    def make_tree(self, *directories: str) -> None:
+        for directory in directories:
+            self._root.joinpath(directory).mkdir(parents=True)
+
+    def test_empty_directory(self):
+        subdirectories_to_watch = []
+        ans_subdirectories_to_watch = [(self._root, False)]
+        subdirectories_to_watch = watch.minimal_watch_directories(
+            self._root, 'f1')
+
+        self.assertEqual(set(subdirectories_to_watch),
+                         set(ans_subdirectories_to_watch))
+
+    def test_non_exist_directories_to_exclude(self):
+        subdirectories_to_watch = []
+        exclude_list = ['f3']
+        self.make_tree('f1', 'f2')
+        ans_subdirectories_to_watch = [
+            (self._root / 'f1', True),
+            (self._root / 'f2', True),
+            (self._root, False),
+        ]
+        subdirectories_to_watch = watch.minimal_watch_directories(
+            self._root, exclude_list)
+
+        self.assertEqual(set(subdirectories_to_watch),
+                         set(ans_subdirectories_to_watch))
+
+    def test_one_layer_directories(self):
+        subdirectories_to_watch = []
+        exclude_list = ['f1']
+        self.make_tree(
+            'f1/f1',
+            'f1/f2',
+            'f2/f1',
+        )
+        ans_subdirectories_to_watch = [
+            (self._root / 'f2', True),
+            (self._root, False),
+        ]
+        subdirectories_to_watch = watch.minimal_watch_directories(
+            self._root, exclude_list)
+
+        self.assertEqual(set(subdirectories_to_watch),
+                         set(ans_subdirectories_to_watch))
+
+    def test_two_layers_direcories(self):
+        subdirectories_to_watch = []
+        exclude_list = ['f1/f2']
+        self.make_tree(
+            'f1/f1',
+            'f1/f2',
+            'f2/f1',
+        )
+        ans_subdirectories_to_watch = [
+            (self._root / 'f2', True),
+            (self._root / 'f1/f1', True),
+            (self._root, False),
+            (self._root / 'f1', False),
+        ]
+        subdirectories_to_watch = watch.minimal_watch_directories(
+            self._root, exclude_list)
+
+        self.assertEqual(set(subdirectories_to_watch),
+                         set(ans_subdirectories_to_watch))
+
+    def test_empty_exclude_list(self):
+        subdirectories_to_watch = []
+        exclude_list = []
+        self.make_tree(
+            'f1/f1',
+            'f1/f2',
+            'f2/f1',
+        )
+        ans_subdirectories_to_watch = [
+            (self._root / 'f2', True),
+            (self._root / 'f1', True),
+            (self._root, False),
+        ]
+        subdirectories_to_watch = watch.minimal_watch_directories(
+            self._root, exclude_list)
+
+        self.assertEqual(set(subdirectories_to_watch),
+                         set(ans_subdirectories_to_watch))
+
+    def test_multiple_directories_in_exclude_list(self):
+        """test case for multiple directories to exclude"""
+        subdirectories_to_watch = []
+        exclude_list = [
+            'f1/f2',
+            'f3/f1',
+            'f3/f3',
+        ]
+        self.make_tree(
+            'f1/f1',
+            'f1/f2',
+            'f2/f1',
+            'f3/f1',
+            'f3/f2',
+            'f3/f3',
+        )
+        ans_subdirectories_to_watch = [
+            (self._root / 'f2', True),
+            (self._root / 'f1/f1', True),
+            (self._root / 'f3/f2', True),
+            (self._root, False),
+            (self._root / 'f1', False),
+            (self._root / 'f3', False),
+        ]
+        subdirectories_to_watch = watch.minimal_watch_directories(
+            self._root, exclude_list)
+
+        self.assertEqual(set(subdirectories_to_watch),
+                         set(ans_subdirectories_to_watch))
+
+    def test_nested_sibling_exclusion(self):
+        subdirectories_to_watch = []
+        exclude_list = [
+            'f1/f1/f1/f1/f1',
+            'f1/f1/f1/f2',
+        ]
+        self.make_tree(
+            'f1/f1/f1/f1/f1',
+            'f1/f1/f1/f1/f2',
+            'f1/f1/f1/f1/f3',
+            'f1/f1/f1/f2',
+        )
+        ans_subdirectories_to_watch = [
+            (self._root / 'f1/f1/f1/f1/f2', True),
+            (self._root / 'f1/f1/f1/f1/f3', True),
+            (self._root, False),
+            (self._root / 'f1', False),
+            (self._root / 'f1/f1', False),
+            (self._root / 'f1/f1/f1', False),
+            (self._root / 'f1/f1/f1/f1', False),
+        ]
+        subdirectories_to_watch = watch.minimal_watch_directories(
+            self._root, exclude_list)
+
+        self.assertEqual(set(subdirectories_to_watch),
+                         set(ans_subdirectories_to_watch))
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/targets/arduino/BUILD b/targets/arduino/BUILD
index 458ddff..7514235 100644
--- a/targets/arduino/BUILD
+++ b/targets/arduino/BUILD
@@ -30,4 +30,13 @@
         "//pw_preprocessor",
         "//pw_sys_io_arduino",
     ],
+)
+
+pw_cc_library(
+    name = "system_rpc_server",
+    srcs = ["system_rpc_server.cc"],
+    deps = [
+        "//pw_rpc/system_server:facade",
+        "//pw_hdlc:pw_rpc",
+    ],
 )
\ No newline at end of file
diff --git a/targets/arduino/BUILD.gn b/targets/arduino/BUILD.gn
index 146e784..7217dff 100644
--- a/targets/arduino/BUILD.gn
+++ b/targets/arduino/BUILD.gn
@@ -22,7 +22,7 @@
   sources = [ "target_docs.rst" ]
 }
 
-if (dir_pw_third_party_arduino != "") {
+if (pw_arduino_build_CORE_PATH != "") {
   import("target_toolchains.gni")
 
   generate_toolchains("target_toolchains") {
@@ -79,22 +79,36 @@
 
       # TODO(tonymd): Determine if libs are needed.
       #   Teensy4 core recipe uses: '-larm_cortexM7lfsp_math -lm -lstdc++'
-      # libs = exec_script(arduino_builder_script,
-      #     arduino_show_command_args + [ "--ld-lib-names" ],
-      #     "list lines")
+      libs = filter_exclude(
+              exec_script(arduino_builder_script,
+                          arduino_show_command_args + [ "--ld-lib-names" ],
+                          "list lines"),
+              # Exclude stdc++ which causes linking errors for teensy cores.
+              [ "\bstdc++\b" ])
     }
 
     pw_source_set("pre_init") {
       sources = [ "init.cc" ]
       public_deps = [
         "$dir_pw_sys_io_arduino",
-        "$dir_pw_third_party_arduino:arduino_core_sources",
+        "$dir_pw_third_party/arduino:arduino_core_sources",
       ]
       deps = [
         "$dir_pw_arduino_build:arduino_init.facade",
         "$dir_pw_preprocessor",
       ]
     }
+
+    pw_source_set("system_rpc_server") {
+      deps = [
+        "$dir_pw_hdlc:pw_rpc",
+        "$dir_pw_hdlc:rpc_channel_output",
+        "$dir_pw_rpc/system_server:facade",
+        "$dir_pw_stream:sys_io_stream",
+        dir_pw_log,
+      ]
+      sources = [ "system_rpc_server.cc" ]
+    }
   }
 } else {
   config("arduino_build") {
diff --git a/targets/arduino/system_rpc_server.cc b/targets/arduino/system_rpc_server.cc
new file mode 100644
index 0000000..00b9bf9
--- /dev/null
+++ b/targets/arduino/system_rpc_server.cc
@@ -0,0 +1,70 @@
+// 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.
+
+#include <cstddef>
+
+#include "pw_hdlc/rpc_channel.h"
+#include "pw_hdlc/rpc_packets.h"
+#include "pw_log/log.h"
+#include "pw_rpc_system_server/rpc_server.h"
+#include "pw_stream/sys_io_stream.h"
+
+namespace pw::rpc::system_server {
+namespace {
+
+constexpr size_t kMaxTransmissionUnit = 256;
+
+// Used to write HDLC data to pw::sys_io.
+stream::SysIoWriter writer;
+stream::SysIoReader reader;
+
+// Set up the output channel for the pw_rpc server to use.
+hdlc::RpcChannelOutputBuffer<kMaxTransmissionUnit> hdlc_channel_output(
+    writer, pw::hdlc::kDefaultRpcAddress, "HDLC channel");
+Channel channels[] = {pw::rpc::Channel::Create<1>(&hdlc_channel_output)};
+rpc::Server server(channels);
+
+}  // namespace
+
+void Init() {
+  // Send log messages to HDLC address 1. This prevents logs from interfering
+  // with pw_rpc communications.
+  pw::log_basic::SetOutput([](std::string_view log) {
+    pw::hdlc::WriteUIFrame(1, std::as_bytes(std::span(log)), writer);
+  });
+}
+
+rpc::Server& Server() { return server; }
+
+Status Start() {
+  // Declare a buffer for decoding incoming HDLC frames.
+  std::array<std::byte, kMaxTransmissionUnit> input_buffer;
+  hdlc::Decoder decoder(input_buffer);
+
+  while (true) {
+    std::byte byte;
+    Status ret_val = pw::sys_io::ReadByte(&byte);
+    if (!ret_val.ok()) {
+      return ret_val;
+    }
+    if (auto result = decoder.Process(byte); result.ok()) {
+      hdlc::Frame& frame = result.value();
+      if (frame.address() == hdlc::kDefaultRpcAddress) {
+        server.ProcessPacket(frame.data(), hdlc_channel_output);
+      }
+    }
+  }
+}
+
+}  // namespace pw::rpc::system_server
diff --git a/targets/arduino/target_docs.rst b/targets/arduino/target_docs.rst
index 5db55e8..a6f6b0b 100644
--- a/targets/arduino/target_docs.rst
+++ b/targets/arduino/target_docs.rst
@@ -43,8 +43,7 @@
 =====
 
 You must first install an Arduino core or let Pigweed know where you have cores
-installed using the ``dir_pw_third_party_arduino`` and ``arduino_package_path``
-build arguments.
+installed using the ``pw_arduino_build_CORE_PATH`` build arg.
 
 Installing Arduino Cores
 ------------------------
@@ -66,10 +65,12 @@
 
 .. code:: sh
 
-  gn gen out --args='dir_pw_third_party_arduino="//third_party/arduino"
-                     arduino_core_name="teensy"
-                     arduino_board="teensy40"
-                     arduino_menu_options=["menu.usb.serial", "menu.keys.en-us"]'
+  gn gen out --args='
+    pw_arduino_build_CORE_PATH="//third_party/arduino/cores"
+    pw_arduino_build_CORE_NAME="teensy"
+    pw_arduino_build_PACKAGE_NAME="teensy/avr"
+    pw_arduino_build_BOARD="teensy40"
+    pw_arduino_build_MENU_OPTIONS=["menu.usb.serial", "menu.keys.en-us"]'
 
 On a Windows machine it's easier to run:
 
@@ -81,10 +82,11 @@
 
 .. code:: text
 
-  dir_pw_third_party_arduino="//third_party/arduino"
-  arduino_core_name="teensy"
-  arduino_board="teensy40"
-  arduino_menu_options=["menu.usb.serial", "menu.keys.en-us"]
+  pw_arduino_build_CORE_PATH = "//third_party/arduino/cores"
+  pw_arduino_build_CORE_NAME = "teensy"
+  pw_arduino_build_PACKAGE_NAME="teensy/avr"
+  pw_arduino_build_BOARD = "teensy40"
+  pw_arduino_build_MENU_OPTIONS = ["menu.usb.serial", "menu.keys.en-us"]
 
 Save the file and close the text editor.
 
@@ -112,7 +114,7 @@
   teensy31    Teensy 3.2 / 3.1
 
 You may wish to set different arduino build options in
-``arduino_menu_options``. Run this to see what's available for your core:
+``pw_arduino_build_MENU_OPTIONS``. Run this to see what's available for your core:
 
 .. code:: sh
 
@@ -161,10 +163,11 @@
 
   #!/bin/bash
   gn gen out --export-compile-commands \
-      --args='dir_pw_third_party_arduino="//third_party/arduino"
-              arduino_core_name="teensy"
-              arduino_board="teensy40"
-              arduino_menu_options=["menu.usb.serial", "menu.keys.en-us"]' && \
+      --args='pw_arduino_build_CORE_PATH="//third_party/arduino/cores"
+              pw_arduino_build_CORE_NAME="teensy"
+              pw_arduino_build_PACKAGE_NAME="teensy/avr"
+              pw_arduino_build_BOARD="teensy40"
+              pw_arduino_build_MENU_OPTIONS=["menu.usb.serial", "menu.keys.en-us"]' && \
     ninja -C out arduino
 
   for f in $(find out/arduino_debug/obj/ -iname "*.elf"); do
@@ -214,9 +217,7 @@
 
   _library_args = [
     "--library-path",
-    rebase_path(
-        "$dir_pw_third_party_arduino/cores/teensy/hardware/teensy/avr/libraries"
-    ),
+    rebase_path(arduino_core_library_path),
     "--library-names",
     "Time",
     "Wire",
@@ -250,10 +251,8 @@
                                    [ "--library-include-dirs" ],
                                "list lines")
 
-    # Required if using Arduino.h and any Arduino API functions
-    if (dir_pw_third_party_arduino != "") {
-      remove_configs = [ "$dir_pw_build:strict_warnings" ]
-      deps += [ "$dir_pw_third_party_arduino:arduino_core_sources" ]
-    }
+    # Required for using Arduino.h and any Arduino API functions
+    remove_configs = [ "$dir_pw_build:strict_warnings" ]
+    deps += [ "$dir_pw_third_party/arduino:arduino_core_sources" ]
   }
 
diff --git a/targets/arduino/target_toolchains.gni b/targets/arduino/target_toolchains.gni
index e06725b..469a95f 100644
--- a/targets/arduino/target_toolchains.gni
+++ b/targets/arduino/target_toolchains.gni
@@ -14,6 +14,7 @@
 
 import("//build_overrides/pigweed.gni")
 
+import("$dir_pw_sys_io/backend.gni")
 import("$dir_pw_toolchain/arm_gcc/toolchains.gni")
 
 declare_args() {
@@ -43,7 +44,11 @@
   # Facade backends
   pw_assert_BACKEND = dir_pw_assert_basic
   pw_log_BACKEND = dir_pw_log_basic
+  pw_sync_INTERRUPT_SPIN_LOCK_BACKEND =
+      "$dir_pw_sync_baremetal:interrupt_spin_lock"
   pw_sys_io_BACKEND = dir_pw_sys_io_arduino
+  pw_rpc_system_server_BACKEND =
+      "$dir_pigweed/targets/arduino:system_rpc_server"
   pw_arduino_build_INIT_BACKEND = "$dir_pigweed/targets/arduino:pre_init"
 
   current_cpu = "arm"
diff --git a/targets/host/BUILD b/targets/host/BUILD
new file mode 100644
index 0000000..20fd666
--- /dev/null
+++ b/targets/host/BUILD
@@ -0,0 +1,32 @@
+# 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.
+
+load(
+    "//pw_build:pigweed.bzl",
+    "pw_cc_library",
+)
+
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])  # Apache License 2.0
+
+pw_cc_library(
+    name = "system_rpc_server",
+    srcs = ["system_rpc_server.cc"],
+    deps = [
+        "//pw_rpc/system_server:facade",
+        "//pw_hdlc:pw_rpc",
+    ],
+)
+
diff --git a/targets/host/BUILD.gn b/targets/host/BUILD.gn
index 801ef9f..65f1f43 100644
--- a/targets/host/BUILD.gn
+++ b/targets/host/BUILD.gn
@@ -25,3 +25,17 @@
 pw_doc_group("target_docs") {
   sources = [ "target_docs.rst" ]
 }
+
+if (current_toolchain != default_toolchain) {
+  pw_source_set("system_rpc_server") {
+    deps = [
+      "$dir_pw_hdlc:pw_rpc",
+      "$dir_pw_hdlc:rpc_channel_output",
+      "$dir_pw_rpc:synchronized_channel_output",
+      "$dir_pw_rpc/system_server:facade",
+      "$dir_pw_stream:socket_stream",
+      dir_pw_log,
+    ]
+    sources = [ "system_rpc_server.cc" ]
+  }
+}
diff --git a/targets/host/CMakeLists.txt b/targets/host/CMakeLists.txt
new file mode 100644
index 0000000..dfacc25
--- /dev/null
+++ b/targets/host/CMakeLists.txt
@@ -0,0 +1,27 @@
+# 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.
+
+include("$ENV{PW_ROOT}/pw_build/pigweed.cmake")
+
+pw_add_module_library(targets.host.system_rpc_server
+  IMPLEMENTS_FACADES
+    pw_rpc.system_server
+  SOURCES
+    system_rpc_server.cc
+  PRIVATE_DEPS
+    pw_hdlc
+    pw_rpc.server
+    pw_rpc.synchronized_channel_output
+    pw_stream.socket_stream
+)
diff --git a/targets/host/system_rpc_server.cc b/targets/host/system_rpc_server.cc
new file mode 100644
index 0000000..fd38732
--- /dev/null
+++ b/targets/host/system_rpc_server.cc
@@ -0,0 +1,75 @@
+// 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.
+
+#include <cstddef>
+#include <cstdint>
+
+#include "pw_hdlc/rpc_channel.h"
+#include "pw_hdlc/rpc_packets.h"
+#include "pw_log/log.h"
+#include "pw_rpc/synchronized_channel_output.h"
+#include "pw_rpc_system_server/rpc_server.h"
+#include "pw_stream/socket_stream.h"
+
+namespace pw::rpc::system_server {
+namespace {
+
+constexpr size_t kMaxTransmissionUnit = 256;
+constexpr uint16_t kSocketPort = 33000;
+
+stream::SocketStream socket_stream;
+sync::Mutex channel_output_mutex;
+rpc::SynchronizedChannelOutput<
+    hdlc::RpcChannelOutputBuffer<kMaxTransmissionUnit>>
+    hdlc_channel_output(channel_output_mutex,
+                        socket_stream,
+                        hdlc::kDefaultRpcAddress,
+                        "HDLC channel");
+Channel channels[] = {rpc::Channel::Create<1>(&hdlc_channel_output)};
+rpc::Server server(channels);
+
+}  // namespace
+
+void Init() {
+  log_basic::SetOutput([](std::string_view log) {
+    hdlc::WriteUIFrame(1, std::as_bytes(std::span(log)), socket_stream);
+  });
+
+  socket_stream.Serve(kSocketPort);
+}
+
+rpc::Server& Server() { return server; }
+
+Status Start() {
+  // Declare a buffer for decoding incoming HDLC frames.
+  std::array<std::byte, kMaxTransmissionUnit> input_buffer;
+  hdlc::Decoder decoder(input_buffer);
+
+  while (true) {
+    std::array<std::byte, kMaxTransmissionUnit> data;
+    auto ret_val = socket_stream.Read(data);
+    if (ret_val.ok()) {
+      for (std::byte byte : ret_val.value()) {
+        if (auto result = decoder.Process(byte); result.ok()) {
+          hdlc::Frame& frame = result.value();
+          if (frame.address() == hdlc::kDefaultRpcAddress) {
+            server.ProcessPacket(frame.data(), hdlc_channel_output);
+          }
+        }
+      }
+    }
+  }
+}
+
+}  // namespace pw::rpc::system_server
diff --git a/targets/host/target_docs.rst b/targets/host/target_docs.rst
index de087a1..7d20d17 100644
--- a/targets/host/target_docs.rst
+++ b/targets/host/target_docs.rst
@@ -30,6 +30,12 @@
 
   $ ./out/host_[compiler]_debug/obj/pw_status/status_test
 
+RPC server
+==========
+The host target implements a system RPC server that runs over a local socket,
+defaulting to port 33000. To communicate with a process running the host RPC
+server, use ``pw rpc -s localhost:33000 <protos>``.
+
 Configuration
 =============
 The host target exposes a few options that may be used to change the host build
diff --git a/targets/host/target_toolchains.gni b/targets/host/target_toolchains.gni
index 51be296..07b769a 100644
--- a/targets/host/target_toolchains.gni
+++ b/targets/host/target_toolchains.gni
@@ -14,10 +14,17 @@
 
 import("//build_overrides/pigweed.gni")
 
+import("$dir_pw_chrono/backend.gni")
 import("$dir_pw_protobuf_compiler/proto.gni")
+import("$dir_pw_rpc/system_server/backend.gni")
+import("$dir_pw_sync/backend.gni")
+import("$dir_pw_sys_io/backend.gni")
 import("$dir_pw_third_party/nanopb/nanopb.gni")
+import("$dir_pw_thread/backend.gni")
 import("$dir_pw_toolchain/host_clang/toolchains.gni")
 import("$dir_pw_toolchain/host_gcc/toolchains.gni")
+import("$dir_pw_trace/backend.gni")
+import("$dir_pw_trace_tokenized/config.gni")
 
 _host_common = {
   # Use logging-based test output on host.
@@ -29,15 +36,24 @@
   # Configure backend for logging facade.
   pw_log_BACKEND = "$dir_pw_log_basic"
 
+  # Configure backends for pw_sync's facades.
+  pw_sync_INTERRUPT_SPIN_LOCK_BACKEND = "$dir_pw_sync_stl:interrupt_spin_lock"
+
   # Configure backend for pw_sys_io facade.
   pw_sys_io_BACKEND = "$dir_pw_sys_io_stdio"
 
+  # Configure backend for pw_rpc_system_server.
+  pw_rpc_system_server_BACKEND = "$dir_pigweed/targets/host:system_rpc_server"
+
   # Configure backend for trace facade.
   pw_trace_BACKEND = "$dir_pw_trace_tokenized"
 
   # Tokenizer trace time.
   pw_trace_tokenizer_time = "$dir_pw_trace_tokenized:host_trace_time"
 
+  # Configure backend for pw_chrono's system_clock facade.
+  pw_chrono_SYSTEM_CLOCK_BACKEND = "$dir_pw_chrono_stl:system_clock"
+
   # Specify builtin GN variables.
   current_os = host_os
   current_cpu = host_cpu
@@ -63,17 +79,43 @@
   pw_unit_test_AUTOMATIC_RUNNER = get_path_info("run_test.bat", "abspath")
 }
 
+# TODO(amontanez): figure out why std::mutex doesn't work on Windows.
+# These current target configurations do not work on windows.
+_win_incompatible_config = {
+  # Configure backends for pw_sync's facades.
+  pw_sync_BINARY_SEMAPHORE_BACKEND = "$dir_pw_sync_stl:binary_semaphore_backend"
+  pw_sync_COUNTING_SEMAPHORE_BACKEND =
+      "$dir_pw_sync_stl:counting_semaphore_backend"
+  pw_sync_MUTEX_BACKEND = "$dir_pw_sync_stl:mutex_backend"
+  pw_sync_TIMED_MUTEX_BACKEND = "$dir_pw_sync_stl:timed_mutex_backend"
+
+  # Configure backends for pw_thread's facades.
+  pw_thread_ID_BACKEND = "$dir_pw_thread_stl:id"
+  pw_thread_SLEEP_BACKEND = "$dir_pw_thread_stl:sleep"
+  pw_thread_YIELD_BACKEND = "$dir_pw_thread_stl:yield"
+  pw_thread_THREAD_BACKEND = "$dir_pw_thread_stl:thread"
+}
+
 _os_specific_config = {
   if (host_os == "linux") {
     forward_variables_from(_linux_config, "*")
+    forward_variables_from(_win_incompatible_config, "*")
   } else if (host_os == "mac") {
     forward_variables_from(_mac_config, "*")
+    forward_variables_from(_win_incompatible_config, "*")
   } else if (host_os == "win") {
     forward_variables_from(_win_config, "*")
   }
 }
 
-_target_default_configs = [ "$dir_pw_build:extra_strict_warnings" ]
+_clang_default_configs = [
+  "$dir_pw_build:extra_strict_warnings",
+  "$dir_pw_build:clang_thread_safety_warnings",
+]
+_gcc_default_configs = [
+  "$dir_pw_build:extra_strict_warnings",
+  "$dir_pw_toolchain/host_gcc:threading_support",
+]
 
 pw_target_toolchain_host = {
   _excluded_members = [
@@ -89,7 +131,7 @@
       forward_variables_from(_toolchain_base.defaults, "*")
       forward_variables_from(_host_common, "*")
       forward_variables_from(_os_specific_config, "*")
-      default_configs += _target_default_configs
+      default_configs += _clang_default_configs
     }
   }
 
@@ -101,7 +143,7 @@
       forward_variables_from(_toolchain_base.defaults, "*")
       forward_variables_from(_host_common, "*")
       forward_variables_from(_os_specific_config, "*")
-      default_configs += _target_default_configs
+      default_configs += _clang_default_configs
     }
   }
 
@@ -113,7 +155,19 @@
       forward_variables_from(_toolchain_base.defaults, "*")
       forward_variables_from(_host_common, "*")
       forward_variables_from(_os_specific_config, "*")
-      default_configs += _target_default_configs
+      default_configs += _clang_default_configs
+    }
+  }
+
+  clang_fuzz = {
+    name = "host_clang_fuzz"
+    _toolchain_base = pw_toolchain_host_clang.fuzz
+    forward_variables_from(_toolchain_base, "*", _excluded_members)
+    defaults = {
+      forward_variables_from(_toolchain_base.defaults, "*")
+      forward_variables_from(_host_common, "*")
+      forward_variables_from(_os_specific_config, "*")
+      default_configs += _clang_default_configs
     }
   }
 
@@ -125,7 +179,7 @@
       forward_variables_from(_toolchain_base.defaults, "*")
       forward_variables_from(_host_common, "*")
       forward_variables_from(_os_specific_config, "*")
-      default_configs += _target_default_configs
+      default_configs += _gcc_default_configs
     }
   }
 
@@ -137,7 +191,7 @@
       forward_variables_from(_toolchain_base.defaults, "*")
       forward_variables_from(_host_common, "*")
       forward_variables_from(_os_specific_config, "*")
-      default_configs += _target_default_configs
+      default_configs += _gcc_default_configs
     }
   }
 
@@ -149,7 +203,7 @@
       forward_variables_from(_toolchain_base.defaults, "*")
       forward_variables_from(_host_common, "*")
       forward_variables_from(_os_specific_config, "*")
-      default_configs += _target_default_configs
+      default_configs += _gcc_default_configs
     }
   }
 }
@@ -161,6 +215,7 @@
   pw_target_toolchain_host.clang_debug,
   pw_target_toolchain_host.clang_speed_optimized,
   pw_target_toolchain_host.clang_size_optimized,
+  pw_target_toolchain_host.clang_fuzz,
   pw_target_toolchain_host.gcc_debug,
   pw_target_toolchain_host.gcc_speed_optimized,
   pw_target_toolchain_host.gcc_size_optimized,
diff --git a/targets/lm3s6965evb-qemu/BUILD b/targets/lm3s6965evb-qemu/BUILD
index 307d555..bb15e06 100644
--- a/targets/lm3s6965evb-qemu/BUILD
+++ b/targets/lm3s6965evb-qemu/BUILD
@@ -25,7 +25,7 @@
     name = "pre_init",
     srcs = [
         "boot.cc",
-        "vector_table.cc"
+        "vector_table.c"
     ],
     deps = [
         "//pw_boot_armv7m",
diff --git a/targets/lm3s6965evb-qemu/BUILD.gn b/targets/lm3s6965evb-qemu/BUILD.gn
index beb52f3..ebfa30f 100644
--- a/targets/lm3s6965evb-qemu/BUILD.gn
+++ b/targets/lm3s6965evb-qemu/BUILD.gn
@@ -32,7 +32,7 @@
     deps = [ "$dir_pw_preprocessor" ]
     sources = [
       "boot.cc",
-      "vector_table.cc",
+      "vector_table.c",
     ]
   }
 }
diff --git a/targets/lm3s6965evb-qemu/py/BUILD.gn b/targets/lm3s6965evb-qemu/py/BUILD.gn
index 8962902..36373ff 100644
--- a/targets/lm3s6965evb-qemu/py/BUILD.gn
+++ b/targets/lm3s6965evb-qemu/py/BUILD.gn
@@ -22,4 +22,5 @@
     "lm3s6965evb_qemu_utils/__init__.py",
     "lm3s6965evb_qemu_utils/unit_test_runner.py",
   ]
+  pylintrc = "$dir_pigweed/.pylintrc"
 }
diff --git a/targets/lm3s6965evb-qemu/target_docs.rst b/targets/lm3s6965evb-qemu/target_docs.rst
index 6e8c12f..b5f5b84 100644
--- a/targets/lm3s6965evb-qemu/target_docs.rst
+++ b/targets/lm3s6965evb-qemu/target_docs.rst
@@ -12,12 +12,12 @@
 
 Building
 ========
-To build for this Pigweed target, simply build the top-level "qemu" Ninja
+To build for this Pigweed target, simply build the top-level "qemu_gcc" Ninja
 target.
 
 .. code:: sh
 
-  $ ninja -C out qemu
+  $ ninja -C out qemu_gcc
 
 Testing
 =======
diff --git a/targets/lm3s6965evb-qemu/target_toolchains.gni b/targets/lm3s6965evb-qemu/target_toolchains.gni
index 25d7151..c3fba37 100644
--- a/targets/lm3s6965evb-qemu/target_toolchains.gni
+++ b/targets/lm3s6965evb-qemu/target_toolchains.gni
@@ -14,6 +14,8 @@
 
 import("//build_overrides/pigweed.gni")
 
+import("$dir_pw_sys_io/backend.gni")
+import("$dir_pw_toolchain/arm_clang/toolchains.gni")
 import("$dir_pw_toolchain/arm_gcc/toolchains.gni")
 
 _test_runner_script = "py/lm3s6965evb_qemu_utils/unit_test_runner.py"
@@ -38,12 +40,15 @@
   pw_boot_BACKEND = dir_pw_boot_armv7m
   pw_log_BACKEND = dir_pw_log_basic
   pw_sys_io_BACKEND = dir_pw_sys_io_baremetal_lm3s6965evb
+  pw_sync_INTERRUPT_SPIN_LOCK_BACKEND =
+      "$dir_pw_sync_baremetal:interrupt_spin_lock"
 
   # pw_cpu_exception_armv7m tests do not work as expected in QEMU. It does not
   # appear the divide-by-zero traps as expected when enabled, which prevents the
   # module from triggering a recoverable exception. Since pw_cpu_exception is
   # not fully set up on this target, disable it for now.
-  # pw_cpu_exception_BACKEND = dir_pw_cpu_exception_armv7m
+  # pw_cpu_exception_ENTRY_BACKEND =
+  #     "$dir_pw_cpu_exception_cortex_m:cpu_exception_armv7m
 
   pw_boot_armv7m_LINK_CONFIG_DEFINES = [
     "PW_BOOT_FLASH_BEGIN=0x00000200",
@@ -60,11 +65,17 @@
   current_os = ""
 }
 
-_target_default_configs = [
+_gcc_target_default_configs = [
   "$dir_pw_build:extra_strict_warnings",
   "$dir_pw_toolchain/arm_gcc:enable_float_printf",
 ]
 
+_clang_target_default_configs = [
+  "$dir_pw_build:clang_thread_safety_warnings",
+  "$dir_pw_build:extra_strict_warnings",
+  "$dir_pw_toolchain/arm_clang:enable_float_printf",
+]
+
 pw_target_toolchain_lm3s6965evb_qemu = {
   _excluded_members = [
     "defaults",
@@ -72,35 +83,68 @@
   ]
 
   debug = {
-    name = "lm3s6965evb_qemu_debug"
+    name = "lm3s6965evb_qemu_gcc_debug"
     _toolchain_base = pw_toolchain_arm_gcc.cortex_m3_debug
     forward_variables_from(_toolchain_base, "*", _excluded_members)
     defaults = {
       forward_variables_from(_toolchain_base.defaults, "*")
       forward_variables_from(_target_config, "*")
-      default_configs += _target_default_configs
+      default_configs += _gcc_target_default_configs
     }
   }
 
   speed_optimized = {
-    name = "lm3s6965evb_qemu_speed_optimized"
+    name = "lm3s6965evb_qemu_gcc_speed_optimized"
     _toolchain_base = pw_toolchain_arm_gcc.cortex_m3_speed_optimized
     forward_variables_from(_toolchain_base, "*", _excluded_members)
     defaults = {
       forward_variables_from(_toolchain_base.defaults, "*")
       forward_variables_from(_target_config, "*")
-      default_configs += _target_default_configs
+      default_configs += _gcc_target_default_configs
     }
   }
 
   size_optimized = {
-    name = "lm3s6965evb_qemu_size_optimized"
+    name = "lm3s6965evb_qemu_gcc_size_optimized"
     _toolchain_base = pw_toolchain_arm_gcc.cortex_m3_size_optimized
     forward_variables_from(_toolchain_base, "*", _excluded_members)
     defaults = {
       forward_variables_from(_toolchain_base.defaults, "*")
       forward_variables_from(_target_config, "*")
-      default_configs += _target_default_configs
+      default_configs += _gcc_target_default_configs
+    }
+  }
+
+  debug_clang = {
+    name = "lm3s6965evb_qemu_clang_debug"
+    _toolchain_base = pw_toolchain_arm_clang.cortex_m3_debug
+    forward_variables_from(_toolchain_base, "*", _excluded_members)
+    defaults = {
+      forward_variables_from(_toolchain_base.defaults, "*")
+      forward_variables_from(_target_config, "*")
+      default_configs += _clang_target_default_configs
+    }
+  }
+
+  speed_optimized_clang = {
+    name = "lm3s6965evb_qemu_clang_speed_optimized"
+    _toolchain_base = pw_toolchain_arm_clang.cortex_m3_speed_optimized
+    forward_variables_from(_toolchain_base, "*", _excluded_members)
+    defaults = {
+      forward_variables_from(_toolchain_base.defaults, "*")
+      forward_variables_from(_target_config, "*")
+      default_configs += _clang_target_default_configs
+    }
+  }
+
+  size_optimized_clang = {
+    name = "lm3s6965evb_qemu_clang_size_optimized"
+    _toolchain_base = pw_toolchain_arm_clang.cortex_m3_size_optimized
+    forward_variables_from(_toolchain_base, "*", _excluded_members)
+    defaults = {
+      forward_variables_from(_toolchain_base.defaults, "*")
+      forward_variables_from(_target_config, "*")
+      default_configs += _clang_target_default_configs
     }
   }
 }
@@ -112,4 +156,7 @@
   pw_target_toolchain_lm3s6965evb_qemu.debug,
   pw_target_toolchain_lm3s6965evb_qemu.speed_optimized,
   pw_target_toolchain_lm3s6965evb_qemu.size_optimized,
+  pw_target_toolchain_lm3s6965evb_qemu.debug_clang,
+  pw_target_toolchain_lm3s6965evb_qemu.speed_optimized_clang,
+  pw_target_toolchain_lm3s6965evb_qemu.size_optimized_clang,
 ]
diff --git a/targets/lm3s6965evb-qemu/vector_table.c b/targets/lm3s6965evb-qemu/vector_table.c
new file mode 100644
index 0000000..a9228c5
--- /dev/null
+++ b/targets/lm3s6965evb-qemu/vector_table.c
@@ -0,0 +1,57 @@
+// 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.
+
+#include <stdbool.h>
+
+#include "pw_boot_armv7m/boot.h"
+
+// Default handler to insert into the ARMv7-M vector table (below).
+// This function exists for convenience. If a device isn't doing what you
+// expect, it might have hit a fault and ended up here.
+static void DefaultFaultHandler(void) {
+  while (true) {
+    // Wait for debugger to attach.
+  }
+}
+
+// This is the device's interrupt vector table. It's not referenced in any
+// code because the platform (STM32F4xx) expects this table to be present at the
+// beginning of flash. The exact address is specified in the pw_boot_armv7m
+// configuration as part of the target config.
+//
+// For more information, see ARMv7-M Architecture Reference Manual DDI 0403E.b
+// section B1.5.3.
+
+// This typedef is for convenience when building the vector table. With the
+// exception of SP_main (0th entry in the vector table), all the entries of the
+// vector table are function pointers.
+typedef void (*InterruptHandler)(void);
+
+PW_KEEP_IN_SECTION(".vector_table")
+const InterruptHandler vector_table[] = {
+    // The starting location of the stack pointer.
+    // This address is NOT an interrupt handler/function pointer, it is simply
+    // the address that the main stack pointer should be initialized to. The
+    // value is reinterpret casted because it needs to be in the vector table.
+    [0] = (InterruptHandler)(&pw_boot_stack_high_addr),
+
+    // Reset handler, dictates how to handle reset interrupt. This is the
+    // address that the Program Counter (PC) is initialized to at boot.
+    [1] = pw_boot_Entry,
+
+    // NMI handler.
+    [2] = DefaultFaultHandler,
+    // HardFault handler.
+    [3] = DefaultFaultHandler,
+};
diff --git a/targets/lm3s6965evb-qemu/vector_table.cc b/targets/lm3s6965evb-qemu/vector_table.cc
deleted file mode 100644
index 575a673..0000000
--- a/targets/lm3s6965evb-qemu/vector_table.cc
+++ /dev/null
@@ -1,59 +0,0 @@
-// 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.
-
-#include "pw_boot_armv7m/boot.h"
-
-namespace {
-
-// Default handler to insert into the ARMv7-M vector table (below).
-// This function exists for convenience. If a device isn't doing what you
-// expect, it might have hit a fault and ended up here.
-void DefaultFaultHandler(void) {
-  while (true) {
-    // Wait for debugger to attach.
-  }
-}
-
-// This is the device's interrupt vector table. It's not referenced in any
-// code because the platform (STM32F4xx) expects this table to be present at the
-// beginning of flash. The exact address is specified in the pw_boot_armv7m
-// configuration as part of the target config.
-//
-// For more information, see ARMv7-M Architecture Reference Manual DDI 0403E.b
-// section B1.5.3.
-
-// This typedef is for convenience when building the vector table. With the
-// exception of SP_main (0th entry in the vector table), all the entries of the
-// vector table are function pointers.
-typedef void (*InterruptHandler)();
-
-PW_KEEP_IN_SECTION(".vector_table")
-const InterruptHandler vector_table[] = {
-    // The starting location of the stack pointer.
-    // This address is NOT an interrupt handler/function pointer, it is simply
-    // the address that the main stack pointer should be initialized to. The
-    // value is reinterpret casted because it needs to be in the vector table.
-    [0] = reinterpret_cast<InterruptHandler>(&pw_boot_stack_high_addr),
-
-    // Reset handler, dictates how to handle reset interrupt. This is the
-    // address that the Program Counter (PC) is initialized to at boot.
-    [1] = pw_boot_Entry,
-
-    // NMI handler.
-    [2] = DefaultFaultHandler,
-    // HardFault handler.
-    [3] = DefaultFaultHandler,
-};
-
-}  // namespace
diff --git a/targets/stm32f429i-disc1/BUILD b/targets/stm32f429i-disc1/BUILD
index 1414169..b9cffcc 100644
--- a/targets/stm32f429i-disc1/BUILD
+++ b/targets/stm32f429i-disc1/BUILD
@@ -25,7 +25,7 @@
     name = "pre_init",
     srcs = [
         "boot.cc",
-        "vector_table.cc"
+        "vector_table.c"
     ],
     deps = [
         "//pw_boot_armv7m",
@@ -34,3 +34,12 @@
         "//pw_sys_io_baremetal_stm32f429",
     ],
 )
+
+pw_cc_library(
+    name = "system_rpc_server",
+    srcs = ["system_rpc_server.cc"],
+    deps = [
+        "//pw_rpc/system_server:facade",
+        "//pw_hdlc:pw_rpc",
+    ],
+)
diff --git a/targets/stm32f429i-disc1/BUILD.gn b/targets/stm32f429i-disc1/BUILD.gn
index 6b18a9e..5527e65 100644
--- a/targets/stm32f429i-disc1/BUILD.gn
+++ b/targets/stm32f429i-disc1/BUILD.gn
@@ -43,9 +43,20 @@
     ]
     sources = [
       "boot.cc",
-      "vector_table.cc",
+      "vector_table.c",
     ]
   }
+
+  pw_source_set("system_rpc_server") {
+    deps = [
+      "$dir_pw_hdlc:pw_rpc",
+      "$dir_pw_hdlc:rpc_channel_output",
+      "$dir_pw_rpc/system_server:facade",
+      "$dir_pw_stream:sys_io_stream",
+      dir_pw_log,
+    ]
+    sources = [ "system_rpc_server.cc" ]
+  }
 }
 
 pw_doc_group("target_docs") {
diff --git a/targets/stm32f429i-disc1/py/BUILD.gn b/targets/stm32f429i-disc1/py/BUILD.gn
index b93104b..85dd255 100644
--- a/targets/stm32f429i-disc1/py/BUILD.gn
+++ b/targets/stm32f429i-disc1/py/BUILD.gn
@@ -25,4 +25,6 @@
     "stm32f429i_disc1_utils/unit_test_runner.py",
     "stm32f429i_disc1_utils/unit_test_server.py",
   ]
+  pylintrc = "$dir_pigweed/.pylintrc"
+  python_deps = [ "$dir_pw_cli/py" ]
 }
diff --git a/targets/stm32f429i-disc1/py/setup.py b/targets/stm32f429i-disc1/py/setup.py
index 309ff7e..f8ab0a2 100644
--- a/targets/stm32f429i-disc1/py/setup.py
+++ b/targets/stm32f429i-disc1/py/setup.py
@@ -37,5 +37,9 @@
             '    stm32f429i_disc1_utils.unit_test_client:main',
         ]
     },
-    install_requires=['pyserial', 'coloredlogs'],
+    install_requires=[
+        'pyserial>=3.5,<4.0',
+        'coloredlogs',
+        'pw_cli',
+    ],
 )
diff --git a/targets/stm32f429i-disc1/py/stm32f429i_disc1_utils/unit_test_server.py b/targets/stm32f429i-disc1/py/stm32f429i_disc1_utils/unit_test_server.py
index c3bfd3d..fec6ee2 100644
--- a/targets/stm32f429i-disc1/py/stm32f429i_disc1_utils/unit_test_server.py
+++ b/targets/stm32f429i-disc1/py/stm32f429i_disc1_utils/unit_test_server.py
@@ -20,11 +20,11 @@
 import tempfile
 from typing import IO, List, Optional
 
-from stm32f429i_disc1_utils import stm32f429i_detector
-
 import pw_cli.process
 import pw_cli.log
 
+from stm32f429i_disc1_utils import stm32f429i_detector
+
 _LOG = logging.getLogger('unit_test_server')
 
 _TEST_RUNNER_COMMAND = 'stm32f429i_disc1_unit_test_runner'
diff --git a/targets/stm32f429i-disc1/system_rpc_server.cc b/targets/stm32f429i-disc1/system_rpc_server.cc
new file mode 100644
index 0000000..00b9bf9
--- /dev/null
+++ b/targets/stm32f429i-disc1/system_rpc_server.cc
@@ -0,0 +1,70 @@
+// 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.
+
+#include <cstddef>
+
+#include "pw_hdlc/rpc_channel.h"
+#include "pw_hdlc/rpc_packets.h"
+#include "pw_log/log.h"
+#include "pw_rpc_system_server/rpc_server.h"
+#include "pw_stream/sys_io_stream.h"
+
+namespace pw::rpc::system_server {
+namespace {
+
+constexpr size_t kMaxTransmissionUnit = 256;
+
+// Used to write HDLC data to pw::sys_io.
+stream::SysIoWriter writer;
+stream::SysIoReader reader;
+
+// Set up the output channel for the pw_rpc server to use.
+hdlc::RpcChannelOutputBuffer<kMaxTransmissionUnit> hdlc_channel_output(
+    writer, pw::hdlc::kDefaultRpcAddress, "HDLC channel");
+Channel channels[] = {pw::rpc::Channel::Create<1>(&hdlc_channel_output)};
+rpc::Server server(channels);
+
+}  // namespace
+
+void Init() {
+  // Send log messages to HDLC address 1. This prevents logs from interfering
+  // with pw_rpc communications.
+  pw::log_basic::SetOutput([](std::string_view log) {
+    pw::hdlc::WriteUIFrame(1, std::as_bytes(std::span(log)), writer);
+  });
+}
+
+rpc::Server& Server() { return server; }
+
+Status Start() {
+  // Declare a buffer for decoding incoming HDLC frames.
+  std::array<std::byte, kMaxTransmissionUnit> input_buffer;
+  hdlc::Decoder decoder(input_buffer);
+
+  while (true) {
+    std::byte byte;
+    Status ret_val = pw::sys_io::ReadByte(&byte);
+    if (!ret_val.ok()) {
+      return ret_val;
+    }
+    if (auto result = decoder.Process(byte); result.ok()) {
+      hdlc::Frame& frame = result.value();
+      if (frame.address() == hdlc::kDefaultRpcAddress) {
+        server.ProcessPacket(frame.data(), hdlc_channel_output);
+      }
+    }
+  }
+}
+
+}  // namespace pw::rpc::system_server
diff --git a/targets/stm32f429i-disc1/target_docs.rst b/targets/stm32f429i-disc1/target_docs.rst
index 68e34d8..2e4fcbd 100644
--- a/targets/stm32f429i-disc1/target_docs.rst
+++ b/targets/stm32f429i-disc1/target_docs.rst
@@ -98,6 +98,11 @@
 run on the attached device(s). Alternatively, you may use ``pw watch`` to set up
 Pigweed to build/test whenever it sees changes to source files.
 
+RPC server
+==========
+The stm32f429i target implements a system RPC server that over a simple UART
+driver. To communicate with a device running the RPC server, run
+``pw rpc -d <device> -b 115200 <protos>``.
 
 Debugging
 =========
diff --git a/targets/stm32f429i-disc1/target_toolchains.gni b/targets/stm32f429i-disc1/target_toolchains.gni
index b6a95a7..e87245e 100644
--- a/targets/stm32f429i-disc1/target_toolchains.gni
+++ b/targets/stm32f429i-disc1/target_toolchains.gni
@@ -14,6 +14,8 @@
 
 import("//build_overrides/pigweed.gni")
 
+import("$dir_pw_rpc/system_server/backend.gni")
+import("$dir_pw_sys_io/backend.gni")
 import("$dir_pw_toolchain/arm_gcc/toolchains.gni")
 
 declare_args() {
@@ -43,11 +45,17 @@
   # Facade backends
   pw_assert_BACKEND = dir_pw_assert_basic
   pw_boot_BACKEND = dir_pw_boot_armv7m
-  pw_cpu_exception_ENTRY_BACKEND = dir_pw_cpu_exception_armv7m
+  pw_cpu_exception_ENTRY_BACKEND =
+      "$dir_pw_cpu_exception_cortex_m:cpu_exception_armv7m"
   pw_cpu_exception_HANDLER_BACKEND = "$dir_pw_cpu_exception:basic_handler"
-  pw_cpu_exception_SUPPORT_BACKEND = "$dir_pw_cpu_exception_armv7m:support"
+  pw_cpu_exception_SUPPORT_BACKEND =
+      "$dir_pw_cpu_exception_cortex_m:support_armv7m"
+  pw_sync_INTERRUPT_SPIN_LOCK_BACKEND =
+      "$dir_pw_sync_baremetal:interrupt_spin_lock"
   pw_log_BACKEND = dir_pw_log_basic
   pw_sys_io_BACKEND = dir_pw_sys_io_baremetal_stm32f429
+  pw_rpc_system_server_BACKEND =
+      "$dir_pigweed/targets/stm32f429i-disc1:system_rpc_server"
   pw_malloc_BACKEND = dir_pw_malloc_freelist
 
   pw_boot_armv7m_LINK_CONFIG_DEFINES = [
diff --git a/targets/stm32f429i-disc1/vector_table.c b/targets/stm32f429i-disc1/vector_table.c
new file mode 100644
index 0000000..a9228c5
--- /dev/null
+++ b/targets/stm32f429i-disc1/vector_table.c
@@ -0,0 +1,57 @@
+// 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.
+
+#include <stdbool.h>
+
+#include "pw_boot_armv7m/boot.h"
+
+// Default handler to insert into the ARMv7-M vector table (below).
+// This function exists for convenience. If a device isn't doing what you
+// expect, it might have hit a fault and ended up here.
+static void DefaultFaultHandler(void) {
+  while (true) {
+    // Wait for debugger to attach.
+  }
+}
+
+// This is the device's interrupt vector table. It's not referenced in any
+// code because the platform (STM32F4xx) expects this table to be present at the
+// beginning of flash. The exact address is specified in the pw_boot_armv7m
+// configuration as part of the target config.
+//
+// For more information, see ARMv7-M Architecture Reference Manual DDI 0403E.b
+// section B1.5.3.
+
+// This typedef is for convenience when building the vector table. With the
+// exception of SP_main (0th entry in the vector table), all the entries of the
+// vector table are function pointers.
+typedef void (*InterruptHandler)(void);
+
+PW_KEEP_IN_SECTION(".vector_table")
+const InterruptHandler vector_table[] = {
+    // The starting location of the stack pointer.
+    // This address is NOT an interrupt handler/function pointer, it is simply
+    // the address that the main stack pointer should be initialized to. The
+    // value is reinterpret casted because it needs to be in the vector table.
+    [0] = (InterruptHandler)(&pw_boot_stack_high_addr),
+
+    // Reset handler, dictates how to handle reset interrupt. This is the
+    // address that the Program Counter (PC) is initialized to at boot.
+    [1] = pw_boot_Entry,
+
+    // NMI handler.
+    [2] = DefaultFaultHandler,
+    // HardFault handler.
+    [3] = DefaultFaultHandler,
+};
diff --git a/targets/stm32f429i-disc1/vector_table.cc b/targets/stm32f429i-disc1/vector_table.cc
deleted file mode 100644
index 575a673..0000000
--- a/targets/stm32f429i-disc1/vector_table.cc
+++ /dev/null
@@ -1,59 +0,0 @@
-// 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.
-
-#include "pw_boot_armv7m/boot.h"
-
-namespace {
-
-// Default handler to insert into the ARMv7-M vector table (below).
-// This function exists for convenience. If a device isn't doing what you
-// expect, it might have hit a fault and ended up here.
-void DefaultFaultHandler(void) {
-  while (true) {
-    // Wait for debugger to attach.
-  }
-}
-
-// This is the device's interrupt vector table. It's not referenced in any
-// code because the platform (STM32F4xx) expects this table to be present at the
-// beginning of flash. The exact address is specified in the pw_boot_armv7m
-// configuration as part of the target config.
-//
-// For more information, see ARMv7-M Architecture Reference Manual DDI 0403E.b
-// section B1.5.3.
-
-// This typedef is for convenience when building the vector table. With the
-// exception of SP_main (0th entry in the vector table), all the entries of the
-// vector table are function pointers.
-typedef void (*InterruptHandler)();
-
-PW_KEEP_IN_SECTION(".vector_table")
-const InterruptHandler vector_table[] = {
-    // The starting location of the stack pointer.
-    // This address is NOT an interrupt handler/function pointer, it is simply
-    // the address that the main stack pointer should be initialized to. The
-    // value is reinterpret casted because it needs to be in the vector table.
-    [0] = reinterpret_cast<InterruptHandler>(&pw_boot_stack_high_addr),
-
-    // Reset handler, dictates how to handle reset interrupt. This is the
-    // address that the Program Counter (PC) is initialized to at boot.
-    [1] = pw_boot_Entry,
-
-    // NMI handler.
-    [2] = DefaultFaultHandler,
-    // HardFault handler.
-    [3] = DefaultFaultHandler,
-};
-
-}  // namespace
diff --git a/third_party/arduino/BUILD.gn b/third_party/arduino/BUILD.gn
index f7a6f3d..36a8247 100644
--- a/third_party/arduino/BUILD.gn
+++ b/third_party/arduino/BUILD.gn
@@ -17,7 +17,7 @@
 import("$dir_pw_arduino_build/arduino.gni")
 import("$dir_pw_build/target_types.gni")
 
-if (dir_pw_third_party_arduino != "") {
+if (pw_arduino_build_CORE_PATH != "") {
   pw_source_set("arduino_core_sources") {
     remove_configs = [ "$dir_pw_build:strict_warnings" ]
 
@@ -51,8 +51,24 @@
                     arduino_show_command_args + [ "--variant-cpp-files" ],
                     "list lines")
 
+    # Some cores have required built in libraries:
+    # - stm32duino requires SrcWrapper.
+    _library_c_files =
+        exec_script(arduino_builder_script,
+                    arduino_show_command_args + [ "--library-c-files" ],
+                    "list lines")
+    _library_s_files =
+        exec_script(arduino_builder_script,
+                    arduino_show_command_args + [ "--library-s-files" ],
+                    "list lines")
+    _library_cpp_files =
+        exec_script(arduino_builder_script,
+                    arduino_show_command_args + [ "--library-cpp-files" ],
+                    "list lines")
+
     sources = _core_c_files + _core_s_files + _core_cpp_files +
-              _variant_c_files + _variant_s_files + _variant_cpp_files
+              _variant_c_files + _variant_s_files + _variant_cpp_files +
+              _library_c_files + _library_s_files + _library_cpp_files
 
     # Rename main() to ArduinoMain()
     # See //pw_arduino_build/docs.rst for details on this approach.
diff --git a/third_party/embos/BUILD.gn b/third_party/embos/BUILD.gn
new file mode 100644
index 0000000..ee98b32
--- /dev/null
+++ b/third_party/embos/BUILD.gn
@@ -0,0 +1,39 @@
+# 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/target_types.gni")
+import("embos.gni")
+
+# This file defines a GN source_set for an external installation of embOS.
+# To use, checkout the embOS source into a directory, then set the build arg
+# dir_pw_third_party_embOS to point to that directory. The embOS library
+# will be available in GN at "$dir_pw_third_party/embos".
+if (dir_pw_third_party_embos_include != "") {
+  config("public_includes") {
+    include_dirs = [ "$dir_pw_third_party_embos_include" ]
+    visibility = [ ":*" ]
+  }
+
+  pw_source_set("embos") {
+    public_configs = [ ":public_includes" ]
+    allow_circular_includes_from = [ pw_third_party_embos_PORT ]
+    public_deps = [ pw_third_party_embos_PORT ]
+    public = [ "$dir_pw_third_party_embos_include/RTOS.h" ]
+  }
+} else {
+  group("embos") {
+  }
+}
diff --git a/third_party/embos/embos.gni b/third_party/embos/embos.gni
new file mode 100644
index 0000000..b2d3e25
--- /dev/null
+++ b/third_party/embos/embos.gni
@@ -0,0 +1,23 @@
+# 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.
+
+declare_args() {
+  # If compiling backends with embOS, this variable is set to the path to the
+  # embOS include directory. When set, a pw_source_set for the embos library
+  # is created at "$dir_pw_third_party/embos".
+  dir_pw_third_party_embos_include = ""
+
+  # The pw_source_set which provides the port specific includes and sources.
+  pw_third_party_embos_PORT = ""
+}
diff --git a/third_party/freertos/BUILD.gn b/third_party/freertos/BUILD.gn
new file mode 100644
index 0000000..9b30c06
--- /dev/null
+++ b/third_party/freertos/BUILD.gn
@@ -0,0 +1,75 @@
+# 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/target_types.gni")
+import("freertos.gni")
+
+# This file defines a GN source_set for an external installation of freertos.
+# To use, checkout the freertos source into a directory, then set the build arg
+# dir_pw_third_party_freertos to point to that directory. The freertos library
+# will be available in GN at "$dir_pw_third_party/freertos".
+if (dir_pw_third_party_freertos != "") {
+  config("disable_warnings") {
+    cflags = [ "-Wno-error=unused-parameter" ]
+    visibility = [ ":*" ]
+  }
+
+  config("public_includes") {
+    include_dirs = [ "Source/include" ]
+    visibility = [ ":*" ]
+  }
+
+  pw_source_set("freertos") {
+    public_configs = [ ":public_includes" ]
+    allow_circular_includes_from = [ pw_third_party_freertos_PORT ]
+    public_deps = [
+      pw_third_party_freertos_CONFIG,
+      pw_third_party_freertos_PORT,
+    ]
+    public = [
+      "$dir_pw_third_party_freertos/Source/include/FreeRTOS.h",
+      "$dir_pw_third_party_freertos/Source/include/StackMacros.h",
+      "$dir_pw_third_party_freertos/Source/include/croutine.h",
+      "$dir_pw_third_party_freertos/Source/include/deprecated_definitions.h",
+      "$dir_pw_third_party_freertos/Source/include/event_groups.h",
+      "$dir_pw_third_party_freertos/Source/include/list.h",
+      "$dir_pw_third_party_freertos/Source/include/message_buffer.h",
+      "$dir_pw_third_party_freertos/Source/include/mpu_prototypes.h",
+      "$dir_pw_third_party_freertos/Source/include/mpu_wrappers.h",
+      "$dir_pw_third_party_freertos/Source/include/portable.h",
+      "$dir_pw_third_party_freertos/Source/include/projdefs.h",
+      "$dir_pw_third_party_freertos/Source/include/queue.h",
+      "$dir_pw_third_party_freertos/Source/include/semphr.h",
+      "$dir_pw_third_party_freertos/Source/include/stack_macros.h",
+      "$dir_pw_third_party_freertos/Source/include/stream_buffer.h",
+      "$dir_pw_third_party_freertos/Source/include/task.h",
+      "$dir_pw_third_party_freertos/Source/include/timers.h",
+    ]
+    configs = [ ":disable_warnings" ]
+    sources = [
+      "$dir_pw_third_party_freertos/Source/croutine.c",
+      "$dir_pw_third_party_freertos/Source/event_groups.c",
+      "$dir_pw_third_party_freertos/Source/list.c",
+      "$dir_pw_third_party_freertos/Source/queue.c",
+      "$dir_pw_third_party_freertos/Source/stream_buffer.c",
+      "$dir_pw_third_party_freertos/Source/tasks.c",
+      "$dir_pw_third_party_freertos/Source/timers.c",
+    ]
+  }
+} else {
+  group("freertos") {
+  }
+}
diff --git a/third_party/freertos/freertos.gni b/third_party/freertos/freertos.gni
new file mode 100644
index 0000000..c3f5147
--- /dev/null
+++ b/third_party/freertos/freertos.gni
@@ -0,0 +1,26 @@
+# 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.
+
+declare_args() {
+  # If compiling backends with freertos, this variable is set to the path to the
+  # freertos installation. When set, a pw_source_set for the freertos library is
+  # created at "$dir_pw_third_party/freertos".
+  dir_pw_third_party_freertos = ""
+
+  # The pw_source_set which provides the FreeRTOS config header.
+  pw_third_party_freertos_CONFIG = ""
+
+  # The pw_source_set which provides the port specific includes and sources.
+  pw_third_party_freertos_PORT = ""
+}
diff --git a/third_party/googletest/BUILD.gn b/third_party/googletest/BUILD.gn
new file mode 100644
index 0000000..cdf272d
--- /dev/null
+++ b/third_party/googletest/BUILD.gn
@@ -0,0 +1,103 @@
+# Copyright 2021 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/target_types.gni")
+import("googletest.gni")
+
+# This file defines a GN source_set for an external installation of googletest.
+# To use, checkout the googletest source into a directory, then set the build
+# arg dir_pw_third_party_googletest to point to that directory. The googletest
+# library will be available in GN at "$dir_pw_third_party/googletest".
+if (dir_pw_third_party_googletest != "") {
+  config("includes") {
+    include_dirs = [
+      "$dir_pw_third_party_googletest/googletest",
+      "$dir_pw_third_party_googletest/googletest/include",
+      "$dir_pw_third_party_googletest/googlemock",
+      "$dir_pw_third_party_googletest/googlemock/include",
+    ]
+
+    # Fix some compiler warnings.
+    cflags = [ "-Wno-undef" ]
+  }
+
+  pw_source_set("googletest") {
+    public_configs = [ ":includes" ]
+    public = [
+      "$dir_pw_third_party_googletest/googlemock/include/gmock/gmock.h",
+      "$dir_pw_third_party_googletest/googletest/include/gtest/gtest.h",
+    ]
+    sources = [
+      "$dir_pw_third_party_googletest/googlemock/include/gmock/gmock-actions.h",
+      "$dir_pw_third_party_googletest/googlemock/include/gmock/gmock-cardinalities.h",
+      "$dir_pw_third_party_googletest/googlemock/include/gmock/gmock-function-mocker.h",
+      "$dir_pw_third_party_googletest/googlemock/include/gmock/gmock-generated-actions.h",
+      "$dir_pw_third_party_googletest/googlemock/include/gmock/gmock-matchers.h",
+      "$dir_pw_third_party_googletest/googlemock/include/gmock/gmock-more-actions.h",
+      "$dir_pw_third_party_googletest/googlemock/include/gmock/gmock-more-matchers.h",
+      "$dir_pw_third_party_googletest/googlemock/include/gmock/gmock-nice-strict.h",
+      "$dir_pw_third_party_googletest/googlemock/include/gmock/gmock-spec-builders.h",
+      "$dir_pw_third_party_googletest/googlemock/include/gmock/internal/gmock-internal-utils.h",
+      "$dir_pw_third_party_googletest/googlemock/include/gmock/internal/gmock-port.h",
+      "$dir_pw_third_party_googletest/googlemock/include/gmock/internal/gmock-pp.h",
+      "$dir_pw_third_party_googletest/googlemock/src/gmock-cardinalities.cc",
+      "$dir_pw_third_party_googletest/googlemock/src/gmock-internal-utils.cc",
+      "$dir_pw_third_party_googletest/googlemock/src/gmock-matchers.cc",
+      "$dir_pw_third_party_googletest/googlemock/src/gmock-spec-builders.cc",
+      "$dir_pw_third_party_googletest/googlemock/src/gmock.cc",
+      "$dir_pw_third_party_googletest/googletest/include/gtest/gtest-death-test.h",
+      "$dir_pw_third_party_googletest/googletest/include/gtest/gtest-matchers.h",
+      "$dir_pw_third_party_googletest/googletest/include/gtest/gtest-message.h",
+      "$dir_pw_third_party_googletest/googletest/include/gtest/gtest-param-test.h",
+      "$dir_pw_third_party_googletest/googletest/include/gtest/gtest-printers.h",
+      "$dir_pw_third_party_googletest/googletest/include/gtest/gtest-spi.h",
+      "$dir_pw_third_party_googletest/googletest/include/gtest/gtest-test-part.h",
+      "$dir_pw_third_party_googletest/googletest/include/gtest/gtest-typed-test.h",
+      "$dir_pw_third_party_googletest/googletest/include/gtest/gtest_pred_impl.h",
+      "$dir_pw_third_party_googletest/googletest/include/gtest/gtest_prod.h",
+      "$dir_pw_third_party_googletest/googletest/include/gtest/internal/gtest-death-test-internal.h",
+      "$dir_pw_third_party_googletest/googletest/include/gtest/internal/gtest-filepath.h",
+      "$dir_pw_third_party_googletest/googletest/include/gtest/internal/gtest-internal.h",
+      "$dir_pw_third_party_googletest/googletest/include/gtest/internal/gtest-param-util.h",
+      "$dir_pw_third_party_googletest/googletest/include/gtest/internal/gtest-port-arch.h",
+      "$dir_pw_third_party_googletest/googletest/include/gtest/internal/gtest-port.h",
+      "$dir_pw_third_party_googletest/googletest/include/gtest/internal/gtest-string.h",
+      "$dir_pw_third_party_googletest/googletest/include/gtest/internal/gtest-type-util.h",
+      "$dir_pw_third_party_googletest/googletest/src/gtest-death-test.cc",
+      "$dir_pw_third_party_googletest/googletest/src/gtest-filepath.cc",
+      "$dir_pw_third_party_googletest/googletest/src/gtest-internal-inl.h",
+      "$dir_pw_third_party_googletest/googletest/src/gtest-matchers.cc",
+      "$dir_pw_third_party_googletest/googletest/src/gtest-port.cc",
+      "$dir_pw_third_party_googletest/googletest/src/gtest-printers.cc",
+      "$dir_pw_third_party_googletest/googletest/src/gtest-test-part.cc",
+      "$dir_pw_third_party_googletest/googletest/src/gtest-typed-test.cc",
+      "$dir_pw_third_party_googletest/googletest/src/gtest.cc",
+    ]
+  }
+
+  pw_source_set("gtest_main") {
+    public_deps = [ ":googletest" ]
+    sources = [ "$dir_pw_third_party_googletest/googlemock/src/gtest_main.cc" ]
+  }
+
+  pw_source_set("gmock_main") {
+    public_deps = [ ":googletest" ]
+    sources = [ "$dir_pw_third_party_googletest/googlemock/src/gmock_main.cc" ]
+  }
+} else {
+  group("googletest") {
+  }
+}
diff --git a/third_party/googletest/googletest.gni b/third_party/googletest/googletest.gni
new file mode 100644
index 0000000..b9ef510
--- /dev/null
+++ b/third_party/googletest/googletest.gni
@@ -0,0 +1,20 @@
+# Copyright 2021 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.
+
+declare_args() {
+  # If compiling tests with googletest, this variable is set to the path to the
+  # googletest installation. When set, a pw_source_set for the googletest
+  # library is created at "$dir_pw_third_party/googletest".
+  dir_pw_third_party_googletest = ""
+}
diff --git a/third_party/nanopb/BUILD.gn b/third_party/nanopb/BUILD.gn
index 1927ab1..77e2453 100644
--- a/third_party/nanopb/BUILD.gn
+++ b/third_party/nanopb/BUILD.gn
@@ -15,6 +15,7 @@
 import("//build_overrides/pigweed.gni")
 
 import("$dir_pw_build/target_types.gni")
+import("$dir_pw_protobuf_compiler/proto.gni")
 import("nanopb.gni")
 
 # This file defines a GN source_set for an external installation of nanopb.
@@ -40,6 +41,12 @@
       "$dir_pw_third_party_nanopb/pb_encode.c",
     ]
   }
+
+  pw_proto_library("proto") {
+    strip_prefix = "$dir_pw_third_party_nanopb/generator/proto"
+    sources = [ "$dir_pw_third_party_nanopb/generator/proto/nanopb.proto" ]
+    python_module_as_package = "nanopb_pb2"
+  }
 } else {
   group("nanopb") {
   }
diff --git a/third_party/nanopb/CMakeLists.txt b/third_party/nanopb/CMakeLists.txt
index 1341403..7b5745e 100644
--- a/third_party/nanopb/CMakeLists.txt
+++ b/third_party/nanopb/CMakeLists.txt
@@ -12,19 +12,22 @@
 # License for the specific language governing permissions and limitations under
 # the License.
 
-set(dir_pw_third_party_nanopb "" CACHE PATH
-    "Path to a Nanopb installation to import. Set to PRESENT if Nanopb is already present in the build."
-)
+set(dir_pw_third_party_nanopb "" CACHE PATH "Path to the Nanopb installation.")
+option(pw_third_party_nanopb_ADD_SUBDIRECTORY
+    "Whether to add the dir_pw_third_party_nanopb subdirectory" OFF)
 
 if("${dir_pw_third_party_nanopb}" STREQUAL "")
   return()
-elseif(NOT "${dir_pw_third_party_nanopb}" STREQUAL PRESENT)
+elseif(pw_third_party_nanopb_ADD_SUBDIRECTORY)
   add_subdirectory("${dir_pw_third_party_nanopb}" third_party/nanopb)
 endif()
 
+set(nanopb_main_library
+    $<IF:$<TARGET_EXISTS:protobuf-nanopb-static>,protobuf-nanopb-static,protobuf-nanopb>)
+
 add_library(pw_third_party.nanopb INTERFACE)
-target_link_libraries(pw_third_party.nanopb INTERFACE protobuf-nanopb-static)
+target_link_libraries(pw_third_party.nanopb INTERFACE "${nanopb_main_library}")
 target_include_directories(pw_third_party.nanopb
   INTERFACE
-    $<TARGET_PROPERTY:protobuf-nanopb-static,SOURCE_DIR>
+    "${dir_pw_third_party_nanopb}"
 )
diff --git a/third_party/protobuf/BUILD.gn b/third_party/protobuf/BUILD.gn
new file mode 100644
index 0000000..8e42cf8
--- /dev/null
+++ b/third_party/protobuf/BUILD.gn
@@ -0,0 +1,156 @@
+# 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/target_types.gni")
+import("protobuf.gni")
+
+# This file defines a GN source_set for an external installation of protobuf.
+# To use, checkout the protobuf source into a directory, then set the build arg
+# dir_pw_third_party_protobuf to point to that directory. The protobuf library
+# will be available in GN at "$dir_pw_third_party/protobuf".
+#
+# This is known to work with commit 300dfcc5bf46d0fae35146db2195891df4959e4 in
+# the protobuf repository. These targets may not work with other versions.
+#
+# If the dir_pw_third_party_protobuf build argument is not set, the protobuf
+# targets are effectively disabled.
+if (dir_pw_third_party_protobuf != "") {
+  have_pthreads = current_os == "linux" || current_os == "mac"
+
+  config("includes") {
+    include_dirs = [ "$dir_pw_third_party_protobuf/src" ]
+  }
+
+  config("defines") {
+    defines = [ "HAVE_ZLIB=0" ]
+    if (have_pthreads) {
+      defines += [ "HAVE_PTHREAD=1" ]
+    }
+  }
+
+  config("cc_flags") {
+    cflags_cc = [
+      "-Wno-cast-qual",
+      "-Wno-shadow",
+      "-Wno-sign-compare",
+      "-Wno-unused-parameter",
+    ]
+  }
+
+  pw_source_set("libprotobuf_lite") {
+    sources = [
+      "$dir_pw_third_party_protobuf/src/google/protobuf/any_lite.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/arena.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/extension_set.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/generated_enum_util.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/generated_message_table_driven_lite.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/generated_message_util.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/implicit_weak_message.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/io/coded_stream.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/io/io_win32.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/io/strtod.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/io/zero_copy_stream.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/io/zero_copy_stream_impl.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/io/zero_copy_stream_impl_lite.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/map.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/message_lite.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/parse_context.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/repeated_field.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/stubs/bytestream.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/stubs/common.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/stubs/int128.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/stubs/status.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/stubs/statusor.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/stubs/stringpiece.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/stubs/stringprintf.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/stubs/structurally_valid.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/stubs/strutil.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/stubs/time.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/wire_format_lite.cc",
+    ]
+    public_configs = [
+      ":includes",
+      ":cc_flags",
+    ]
+    configs = [ ":defines" ]
+  }
+
+  pw_source_set("libprotobuf") {
+    sources = [
+      "$dir_pw_third_party_protobuf/src/google/protobuf/any.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/any.pb.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/api.pb.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/compiler/importer.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/compiler/parser.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/descriptor.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/descriptor.pb.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/descriptor_database.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/duration.pb.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/dynamic_message.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/empty.pb.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/extension_set_heavy.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/field_mask.pb.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/generated_message_reflection.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/generated_message_table_driven.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/io/gzip_stream.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/io/printer.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/io/tokenizer.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/map_field.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/message.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/reflection_ops.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/service.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/source_context.pb.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/struct.pb.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/stubs/substitute.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/text_format.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/timestamp.pb.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/type.pb.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/unknown_field_set.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/util/delimited_message_util.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/util/field_comparator.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/util/field_mask_util.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/util/internal/datapiece.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/util/internal/default_value_objectwriter.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/util/internal/error_listener.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/util/internal/field_mask_utility.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/util/internal/json_escaping.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/util/internal/json_objectwriter.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/util/internal/json_stream_parser.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/util/internal/object_writer.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/util/internal/proto_writer.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/util/internal/protostream_objectsource.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/util/internal/protostream_objectwriter.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/util/internal/type_info.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/util/internal/type_info_test_helper.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/util/internal/utility.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/util/json_util.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/util/message_differencer.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/util/time_util.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/util/type_resolver_util.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/wire_format.cc",
+      "$dir_pw_third_party_protobuf/src/google/protobuf/wrappers.pb.cc",
+    ]
+    public_deps = [ ":libprotobuf_lite" ]
+    configs = [ ":defines" ]
+  }
+} else {
+  # As mentioned above, these targets are effectively disabled if the build
+  # argument pointing to the protobuf source directory is not set.
+  group("libprotobuf_lite") {
+  }
+  group("libprotobuf") {
+  }
+}
diff --git a/third_party/protobuf/protobuf.gni b/third_party/protobuf/protobuf.gni
new file mode 100644
index 0000000..36f60ff
--- /dev/null
+++ b/third_party/protobuf/protobuf.gni
@@ -0,0 +1,20 @@
+# 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.
+
+declare_args() {
+  # If compiling host tools that use libprotobuf, this variable is set to the
+  # path to the protobuf installation. When set, a pw_source_set for the
+  # protobuf library is created at "$dir_pw_third_party/protobuf".
+  dir_pw_third_party_protobuf = ""
+}
diff --git a/third_party/threadx/BUILD.gn b/third_party/threadx/BUILD.gn
new file mode 100644
index 0000000..e400f06
--- /dev/null
+++ b/third_party/threadx/BUILD.gn
@@ -0,0 +1,51 @@
+# 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/target_types.gni")
+
+declare_args() {
+  # If compiling backends with ThreadX, this variable is set to the path to the
+  # ThreadX include directory. When set, a pw_source_set for the threadx library
+  # is created at "$dir_pw_third_party/threadx".
+  dir_pw_third_party_threadx_include = ""
+
+  # The pw_source_set which provides the port specific includes and sources.
+  pw_third_party_threadx_PORT = ""
+}
+
+# This file defines a GN source_set for an external installation of ThreadX.
+# To use, checkout the threadx source into a directory, then set the build arg
+# dir_pw_third_party_threadx_include to point to that directory. The ThreadX
+# library will be available in GN at "$dir_pw_third_party/threadx".
+if (dir_pw_third_party_threadx_include != "") {
+  config("public_includes") {
+    include_dirs = [ "$dir_pw_third_party_threadx_include" ]
+    visibility = [ ":*" ]
+  }
+
+  pw_source_set("threadx") {
+    public_configs = [ ":public_includes" ]
+    allow_circular_includes_from = [ pw_third_party_threadx_PORT ]
+    public_deps = [ pw_third_party_threadx_PORT ]
+    public = [
+      "$dir_pw_third_party_threadx_include/tx_api.h",
+      "$dir_pw_third_party_threadx_include/tx_port.h",
+    ]
+  }
+} else {
+  group("threadx") {
+  }
+}