diff --git a/meta/lib/oe/tune.py b/meta/lib/oe/tune.py
index 0d8e4417e7..1ff4b32e74 100644
--- a/meta/lib/oe/tune.py
+++ b/meta/lib/oe/tune.py
@@ -4,15 +4,42 @@
 # SPDX-License-Identifier: GPL-2.0-only
 #
 
-# RISC-V profile name -> equivalent ISA string. These are determined from compiler output, like:
-# tgamblin@alchemist ~ $ riscv64-linux-gnu-gcc -march=rva20u64 -mabi=lp64d -Q --help=target | grep rv64
-#   -march=rv64imafdc_ziccamoa_ziccif_zicclsm_ziccrse_zicntr_zicsr_zmmul_za128rs_zaamo_zalrsc_zca_zcd
+# RISC-V profile name -> equivalent ISA string, i.e. the mandatory extensions of
+# each profile as ratified in the RISC-V Profiles specification
+# (https://github.com/riscv/riscv-profiles):
+#   RVA20U64, RVA22U64: RVA20/RVA22 Profiles, version 1.0
+#   RVA23U64: RVA23 Profiles, version 1.0
+# plus the extensions those imply (e.g. zmmul, zaamo, zca, zve*).
+#
+# The profile names are used as package architectures, so each entry here is an
+# ABI claim: packages built for "rva22u64" must keep meaning exactly this
+# extension set. Do not regenerate these from compiler output (e.g. gcc's
+# -march expansion); a newer compiler, or clang, may expand a profile
+# differently. Changing an entry changes what existing package feeds mean.
 RISCV_PROFILES = {
     'rva20u64': 'rv64imafdc_ziccamoa_ziccif_zicclsm_ziccrse_zicntr_zicsr_zmmul_za128rs_zaamo_zalrsc_zca_zcd',
     'rva22u64': 'rv64imafdcb_zic64b_zicbom_zicbop_zicboz_ziccamoa_ziccif_zicclsm_ziccrse_zicntr_zicsr_zihintpause_zmmul_za64rs_zaamo_zalrsc_zfhmin_zca_zcd_zba_zbb_zbs_zkt',
     'rva23u64': 'rv64imafdcbv_zic64b_zicbom_zicbop_zicboz_ziccamoa_ziccif_zicclsm_ziccrse_zicntr_zicond_zicsr_zihintntl_zihintpause_zihpm_zimop_zmmul_za64rs_zaamo_zalrsc_zawrs_zfa_zfhmin_zca_zcb_zcd_zcmop_zba_zbb_zbs_zkt_zvbb_zve32f_zve32x_zve64d_zve64f_zve64x_zvfhmin_zvkb_zvkt_zvl128b_zvl32b_zvl64b_supm',
 }
 
+# Extensions implied by another enabled extension (per GCC's
+# riscv_implied_info). zicsr (implied by f) is left out for backwards
+# compatibility, i.e. so the package archs of the existing non-profile tunes
+# (e.g.  riscv64imafdc_zicsr_zifencei) stay the same.
+RISCV_IMPLIED = {
+    'm': ['zmmul'],
+    'a': ['zaamo', 'zalrsc'],
+    'c': ['zca'],
+    'b': ['zba', 'zbb', 'zbs'],
+    'v': ['zve32f', 'zve32x', 'zve64d', 'zve64f', 'zve64x', 'zvl32b', 'zvl64b', 'zvl128b'],
+    'zcb': ['zca'],
+    'zcd': ['zca'],
+    'zcmop': ['zca'],
+    'zvbb': ['zvkb'],
+    # a reservation set of at most 64 bytes is also at most 128 bytes
+    'za64rs': ['za128rs'],
+}
+
 # riscv_isa_to_tune(isa)
 #
 # Automatically translate a RISC-V ISA string to TUNE_FEATURES
@@ -91,6 +118,15 @@ def riscv_isa_to_tune(isa):
     feature = list(dict.fromkeys(feature))
     return ' '.join(feature)
 
+# riscv_implied(enabled)
+#
+# Return the set of extensions implied by those in enabled.
+def riscv_implied(enabled):
+    implied = {i for e in enabled for i in RISCV_IMPLIED.get(e, [])}
+    if 'c' in enabled and 'd' in enabled:
+        implied |= {'zcd', 'zca'}
+    return implied
+
 # riscv_pkgarch(features, exact_pkgarch)
 #
 # Prefer a RISCV_PROFILES name (plus any extensions enabled on top of it) as the
@@ -98,12 +134,25 @@ def riscv_isa_to_tune(isa):
 # what the sstate class can handle. When several profiles match, the one with
 # the most extensions is preferred, as it is the more specific/clearer match.
 # If there's no clear profile matching the extension list, then fall back to
-# exact_pkgarch.
+# exact_pkgarch. Either way, extensions implied by others are left out.
 def riscv_pkgarch(features, exact_pkgarch):
     if not features:
         return exact_pkgarch
 
     current = set(features.split())
+    # Match profiles whether or not implied extensions (or the b shorthand)
+    # were listed
+    if {'zba', 'zbb', 'zbs'} <= current:
+        current.add('b')
+    implied = riscv_implied(current)
+    current |= implied
+
+    # Drop implied extensions from the exact form too, keeping its base
+    # (e.g. riscv64imafdc) and canonical ordering. Only drop what the string
+    # itself implies, as b is never written out there.
+    base, *exts = exact_pkgarch.split('_')
+    exact_implied = riscv_implied(set(base[len('riscv64'):]) | set(exts))
+    exact_pkgarch = '_'.join([base] + [e for e in exts if e not in exact_implied])
 
     best_match = None
     best_features = None
@@ -117,5 +166,5 @@ def riscv_pkgarch(features, exact_pkgarch):
     if best_match is None:
         return exact_pkgarch
 
-    extra = sorted(current - best_features)
+    extra = sorted(current - best_features - implied)
     return '_'.join([best_match] + extra)
