diff --git a/Cargo.lock b/Cargo.lock
index e5025a1ba4f9c..05ace1c99f9ae 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3334,6 +3334,7 @@ dependencies = [
  "rustc_serialize",
  "rustc_session",
  "rustc_span",
+ "smallvec",
 ]
 
 [[package]]
@@ -3506,6 +3507,7 @@ dependencies = [
  "rustc_target",
  "rustc_trait_selection",
  "rustc_type_ir",
+ "smallvec",
  "tracing",
 ]
 
@@ -3683,6 +3685,7 @@ dependencies = [
 name = "rustc_feature"
 version = "0.0.0"
 dependencies = [
+ "either",
  "rustc_data_structures",
  "rustc_span",
 ]
@@ -4217,6 +4220,7 @@ dependencies = [
  "rustc_span",
  "rustc_target",
  "rustc_trait_selection",
+ "smallvec",
  "tracing",
 ]
 
diff --git a/compiler/rustc_ast_passes/src/feature_gate.rs b/compiler/rustc_ast_passes/src/feature_gate.rs
index be6f2c152a4d2..d43ecf40ad62f 100644
--- a/compiler/rustc_ast_passes/src/feature_gate.rs
+++ b/compiler/rustc_ast_passes/src/feature_gate.rs
@@ -1,9 +1,9 @@
 use rustc_ast as ast;
 use rustc_ast::visit::{self, AssocCtxt, FnCtxt, FnKind, Visitor};
 use rustc_ast::{NodeId, PatKind, attr, token};
-use rustc_feature::{AttributeGate, BUILTIN_ATTRIBUTE_MAP, BuiltinAttribute, Features, GateIssue};
+use rustc_feature::{AttributeGate, BUILTIN_ATTRIBUTE_MAP, BuiltinAttribute, Features};
 use rustc_session::Session;
-use rustc_session::parse::{feature_err, feature_err_issue, feature_warn};
+use rustc_session::parse::{feature_err, feature_warn};
 use rustc_span::source_map::Spanned;
 use rustc_span::symbol::sym;
 use rustc_span::{Span, Symbol};
@@ -81,7 +81,7 @@ impl<'a> PostExpansionVisitor<'a> {
         match abi::is_enabled(self.features, span, symbol_unescaped.as_str()) {
             Ok(()) => (),
             Err(abi::AbiDisabled::Unstable { feature, explain }) => {
-                feature_err_issue(&self.sess, feature, span, GateIssue::Language, explain).emit();
+                feature_err(&self.sess, feature, span, explain).emit();
             }
             Err(abi::AbiDisabled::Unrecognized) => {
                 if self.sess.opts.pretty.map_or(true, |ppm| ppm.needs_hir()) {
diff --git a/compiler/rustc_attr/Cargo.toml b/compiler/rustc_attr/Cargo.toml
index 3b24452450abe..320d09f08d88d 100644
--- a/compiler/rustc_attr/Cargo.toml
+++ b/compiler/rustc_attr/Cargo.toml
@@ -17,4 +17,5 @@ rustc_macros = { path = "../rustc_macros" }
 rustc_serialize = { path = "../rustc_serialize" }
 rustc_session = { path = "../rustc_session" }
 rustc_span = { path = "../rustc_span" }
+smallvec = { version = "1.8.1", features = ["union", "may_dangle"] }
 # tidy-alphabetical-end
diff --git a/compiler/rustc_attr/messages.ftl b/compiler/rustc_attr/messages.ftl
index 235ab7572c419..0f6ebcf32e4b1 100644
--- a/compiler/rustc_attr/messages.ftl
+++ b/compiler/rustc_attr/messages.ftl
@@ -83,7 +83,10 @@ attr_multiple_item =
     multiple '{$item}' items
 
 attr_multiple_stability_levels =
-    multiple stability levels
+    multiple stability levels for feature `{$feature}`
+
+attr_multiple_unstable_reasons =
+    multiple reasons provided for unstability
 
 attr_non_ident_feature =
     'feature' is not an identifier
@@ -97,6 +100,9 @@ attr_rustc_const_stable_indirect_pairing =
 attr_rustc_promotable_pairing =
     `rustc_promotable` attribute must be paired with either a `rustc_const_unstable` or a `rustc_const_stable` attribute
 
+attr_soft_inconsistent =
+    `soft` must be present on either none or all of an item's `unstable` attributes
+
 attr_soft_no_args =
     `soft` should not have any arguments
 
diff --git a/compiler/rustc_attr/src/builtin.rs b/compiler/rustc_attr/src/builtin.rs
index 94f9727eb7fbe..c49791d81e77e 100644
--- a/compiler/rustc_attr/src/builtin.rs
+++ b/compiler/rustc_attr/src/builtin.rs
@@ -19,6 +19,7 @@ use rustc_session::{RustcVersion, Session};
 use rustc_span::Span;
 use rustc_span::hygiene::Transparency;
 use rustc_span::symbol::{Symbol, kw, sym};
+use smallvec::{SmallVec, smallvec};
 
 use crate::fluent_generated;
 use crate::session_diagnostics::{self, IncorrectReprFormatGenericCause};
@@ -66,11 +67,10 @@ pub enum OptimizeAttr {
 ///
 /// - `#[stable]`
 /// - `#[unstable]`
-#[derive(Encodable, Decodable, Copy, Clone, Debug, PartialEq, Eq, Hash)]
+#[derive(Encodable, Decodable, Clone, Debug, PartialEq, Eq, Hash)]
 #[derive(HashStable_Generic)]
 pub struct Stability {
     pub level: StabilityLevel,
-    pub feature: Symbol,
 }
 
 impl Stability {
@@ -85,14 +85,22 @@ impl Stability {
     pub fn stable_since(&self) -> Option<StableSince> {
         self.level.stable_since()
     }
+
+    pub fn unstable_features(&self) -> impl Iterator<Item = Symbol> + use<'_> {
+        self.level.unstable_features()
+    }
+
+    pub fn is_rustc_private(&self) -> bool {
+        self.level.has_unstable_feature(sym::rustc_private)
+    }
 }
 
 /// Represents the `#[rustc_const_unstable]` and `#[rustc_const_stable]` attributes.
-#[derive(Encodable, Decodable, Copy, Clone, Debug, PartialEq, Eq, Hash)]
+/// For details see [the dev guide](https://rustc-dev-guide.rust-lang.org/stability.html#rustc_const_unstable).
+#[derive(Encodable, Decodable, Clone, Debug, PartialEq, Eq, Hash)]
 #[derive(HashStable_Generic)]
 pub struct ConstStability {
     pub level: StabilityLevel,
-    pub feature: Symbol,
     /// This is true iff the `const_stable_indirect` attribute is present.
     pub const_stable_indirect: bool,
     /// whether the function has a `#[rustc_promotable]` attribute
@@ -107,47 +115,30 @@ impl ConstStability {
     pub fn is_const_stable(&self) -> bool {
         self.level.is_stable()
     }
+
+    pub fn unstable_features(&self) -> impl Iterator<Item = Symbol> + use<'_> {
+        self.level.unstable_features()
+    }
 }
 
 /// Represents the `#[rustc_default_body_unstable]` attribute.
-#[derive(Encodable, Decodable, Copy, Clone, Debug, PartialEq, Eq, Hash)]
+#[derive(Encodable, Decodable, Clone, Debug, PartialEq, Eq, Hash)]
 #[derive(HashStable_Generic)]
 pub struct DefaultBodyStability {
     pub level: StabilityLevel,
-    pub feature: Symbol,
 }
 
 /// The available stability levels.
-#[derive(Encodable, Decodable, PartialEq, Copy, Clone, Debug, Eq, Hash)]
+#[derive(Encodable, Decodable, PartialEq, Clone, Debug, Eq, Hash)]
 #[derive(HashStable_Generic)]
 pub enum StabilityLevel {
     /// `#[unstable]`
     Unstable {
+        /// The information unique to each `#[unstable]` attribute
+        unstables: SmallVec<[Unstability; 1]>,
         /// Reason for the current stability level.
         reason: UnstableReason,
-        /// Relevant `rust-lang/rust` issue.
-        issue: Option<NonZero<u32>>,
         is_soft: bool,
-        /// If part of a feature is stabilized and a new feature is added for the remaining parts,
-        /// then the `implied_by` attribute is used to indicate which now-stable feature previously
-        /// contained an item.
-        ///
-        /// ```pseudo-Rust
-        /// #[unstable(feature = "foo", issue = "...")]
-        /// fn foo() {}
-        /// #[unstable(feature = "foo", issue = "...")]
-        /// fn foobar() {}
-        /// ```
-        ///
-        /// ...becomes...
-        ///
-        /// ```pseudo-Rust
-        /// #[stable(feature = "foo", since = "1.XX.X")]
-        /// fn foo() {}
-        /// #[unstable(feature = "foobar", issue = "...", implied_by = "foo")]
-        /// fn foobar() {}
-        /// ```
-        implied_by: Option<Symbol>,
     },
     /// `#[stable]`
     Stable {
@@ -174,15 +165,59 @@ impl StabilityLevel {
     pub fn is_unstable(&self) -> bool {
         matches!(self, StabilityLevel::Unstable { .. })
     }
+
     pub fn is_stable(&self) -> bool {
         matches!(self, StabilityLevel::Stable { .. })
     }
+
     pub fn stable_since(&self) -> Option<StableSince> {
         match *self {
             StabilityLevel::Stable { since, .. } => Some(since),
             StabilityLevel::Unstable { .. } => None,
         }
     }
+
+    pub fn unstable_features(&self) -> impl Iterator<Item = Symbol> + use<'_> {
+        let features = if let StabilityLevel::Unstable { unstables, .. } = self {
+            Some(unstables.iter().map(|u| u.feature))
+        } else {
+            None
+        };
+        features.into_iter().flatten()
+    }
+
+    pub fn has_unstable_feature(&self, feature: Symbol) -> bool {
+        self.unstable_features().any(|f| f == feature)
+    }
+}
+
+/// An instance of an `#[unstable]`, `#[rustc_const_unstable]`, or similar attribute
+#[derive(Encodable, Decodable, PartialEq, Copy, Clone, Debug, Eq, Hash)]
+#[derive(HashStable_Generic)]
+pub struct Unstability {
+    pub feature: Symbol,
+    /// Relevant `rust-lang/rust` issue.
+    pub issue: Option<NonZero<u32>>,
+    /// If part of a feature is stabilized and a new feature is added for the remaining parts,
+    /// then the `implied_by` attribute is used to indicate which now-stable feature previously
+    /// contained an item.
+    ///
+    /// ```pseudo-Rust
+    /// #[unstable(feature = "foo", issue = "...")]
+    /// fn foo() {}
+    /// #[unstable(feature = "foo", issue = "...")]
+    /// fn foobar() {}
+    /// ```
+    ///
+    /// ...becomes...
+    ///
+    /// ```pseudo-Rust
+    /// #[stable(feature = "foo", since = "1.XX.X")]
+    /// fn foo() {}
+    /// #[unstable(feature = "foobar", issue = "...", implied_by = "foo")]
+    /// fn foobar() {}
+    /// ```
+    pub implied_by: Option<Symbol>,
 }
 
 #[derive(Encodable, Decodable, PartialEq, Copy, Clone, Debug, Eq, Hash)]
@@ -211,53 +246,50 @@ impl UnstableReason {
     }
 }
 
+/// The spans of each individual parsed stability attribute for an item.
+/// This is reported separately from the overall stability level for more precise diagnostics.
+pub struct StabilitySpans(SmallVec<[(StabilityLevel, Span); 1]>);
+
+impl StabilitySpans {
+    pub fn spans(&self) -> Vec<Span> {
+        self.0.iter().map(|&(_, span)| span).collect()
+    }
+
+    pub fn iter(&self) -> impl Iterator<Item = &(StabilityLevel, Span)> + use<'_> {
+        self.0.iter()
+    }
+}
+
 /// Collects stability info from `stable`/`unstable`/`rustc_allowed_through_unstable_modules`
 /// attributes in `attrs`. Returns `None` if no stability attributes are found.
 pub fn find_stability(
     sess: &Session,
     attrs: &[Attribute],
     item_sp: Span,
-) -> Option<(Stability, Span)> {
-    let mut stab: Option<(Stability, Span)> = None;
+) -> Option<(Stability, StabilitySpans)> {
+    let mut level: Option<StabilityLevel> = None;
+    let mut stab_spans = StabilitySpans(smallvec![]);
+    let mut features = smallvec![];
     let mut allowed_through_unstable_modules = false;
 
     for attr in attrs {
         match attr.name_or_empty() {
             sym::rustc_allowed_through_unstable_modules => allowed_through_unstable_modules = true,
             sym::unstable => {
-                if stab.is_some() {
-                    sess.dcx()
-                        .emit_err(session_diagnostics::MultipleStabilityLevels { span: attr.span });
-                    break;
-                }
-
-                if let Some((feature, level)) = parse_unstability(sess, attr) {
-                    stab = Some((Stability { level, feature }, attr.span));
-                }
+                add_level(sess, attr, &mut level, &mut stab_spans, &mut features, parse_unstability)
             }
             sym::stable => {
-                if stab.is_some() {
-                    sess.dcx()
-                        .emit_err(session_diagnostics::MultipleStabilityLevels { span: attr.span });
-                    break;
-                }
-                if let Some((feature, level)) = parse_stability(sess, attr) {
-                    stab = Some((Stability { level, feature }, attr.span));
-                }
+                add_level(sess, attr, &mut level, &mut stab_spans, &mut features, parse_stability)
             }
             _ => {}
         }
     }
 
     if allowed_through_unstable_modules {
-        match &mut stab {
-            Some((
-                Stability {
-                    level: StabilityLevel::Stable { allowed_through_unstable_modules, .. },
-                    ..
-                },
-                _,
-            )) => *allowed_through_unstable_modules = true,
+        match &mut level {
+            Some(StabilityLevel::Stable { allowed_through_unstable_modules, .. }) => {
+                *allowed_through_unstable_modules = true;
+            }
             _ => {
                 sess.dcx()
                     .emit_err(session_diagnostics::RustcAllowedUnstablePairing { span: item_sp });
@@ -265,7 +297,7 @@ pub fn find_stability(
         }
     }
 
-    stab
+    Some((Stability { level: level? }, stab_spans))
 }
 
 /// Collects stability info from `rustc_const_stable`/`rustc_const_unstable`/`rustc_promotable`
@@ -274,8 +306,10 @@ pub fn find_const_stability(
     sess: &Session,
     attrs: &[Attribute],
     item_sp: Span,
-) -> Option<(ConstStability, Span)> {
-    let mut const_stab: Option<(ConstStability, Span)> = None;
+) -> Option<(ConstStability, StabilitySpans)> {
+    let mut level: Option<StabilityLevel> = None;
+    let mut stab_spans = StabilitySpans(smallvec![]);
+    let mut features = smallvec![];
     let mut promotable = false;
     let mut const_stable_indirect = false;
 
@@ -284,77 +318,34 @@ pub fn find_const_stability(
             sym::rustc_promotable => promotable = true,
             sym::rustc_const_stable_indirect => const_stable_indirect = true,
             sym::rustc_const_unstable => {
-                if const_stab.is_some() {
-                    sess.dcx()
-                        .emit_err(session_diagnostics::MultipleStabilityLevels { span: attr.span });
-                    break;
-                }
-
-                if let Some((feature, level)) = parse_unstability(sess, attr) {
-                    const_stab = Some((
-                        ConstStability {
-                            level,
-                            feature,
-                            const_stable_indirect: false,
-                            promotable: false,
-                        },
-                        attr.span,
-                    ));
-                }
+                add_level(sess, attr, &mut level, &mut stab_spans, &mut features, parse_unstability)
             }
             sym::rustc_const_stable => {
-                if const_stab.is_some() {
-                    sess.dcx()
-                        .emit_err(session_diagnostics::MultipleStabilityLevels { span: attr.span });
-                    break;
-                }
-                if let Some((feature, level)) = parse_stability(sess, attr) {
-                    const_stab = Some((
-                        ConstStability {
-                            level,
-                            feature,
-                            const_stable_indirect: false,
-                            promotable: false,
-                        },
-                        attr.span,
-                    ));
-                }
+                add_level(sess, attr, &mut level, &mut stab_spans, &mut features, parse_stability)
             }
             _ => {}
         }
     }
 
     // Merge promotable and const_stable_indirect into stability info
-    if promotable {
-        match &mut const_stab {
-            Some((stab, _)) => stab.promotable = promotable,
-            _ => {
-                _ = sess
-                    .dcx()
-                    .emit_err(session_diagnostics::RustcPromotablePairing { span: item_sp })
-            }
+    if let Some(level) = level {
+        if level.is_stable() && const_stable_indirect {
+            sess.dcx()
+                .emit_err(session_diagnostics::RustcConstStableIndirectPairing { span: item_sp });
+            const_stable_indirect = false;
         }
-    }
-    if const_stable_indirect {
-        match &mut const_stab {
-            Some((stab, _)) => {
-                if stab.is_const_unstable() {
-                    stab.const_stable_indirect = true;
-                } else {
-                    _ = sess.dcx().emit_err(session_diagnostics::RustcConstStableIndirectPairing {
-                        span: item_sp,
-                    })
-                }
-            }
-            _ => {
-                // This function has no const stability attribute, but has `const_stable_indirect`.
-                // We ignore that; unmarked functions are subject to recursive const stability
-                // checks by default so we do carry out the user's intent.
-            }
+        let const_stab = ConstStability { level, const_stable_indirect, promotable };
+
+        Some((const_stab, stab_spans))
+    } else {
+        if promotable {
+            sess.dcx().emit_err(session_diagnostics::RustcPromotablePairing { span: item_sp });
         }
+        // This function has no const stability attribute, but may have `const_stable_indirect`.
+        // We ignore that; unmarked functions are subject to recursive const stability
+        // checks by default so we do carry out the user's intent.
+        None
     }
-
-    const_stab
 }
 
 /// Calculates the const stability for a const function in a `-Zforce-unstable-if-unmarked` crate
@@ -369,12 +360,7 @@ pub fn unmarked_crate_const_stab(
     // We enforce recursive const stability rules for those functions.
     let const_stable_indirect =
         attrs.iter().any(|a| a.name_or_empty() == sym::rustc_const_stable_indirect);
-    ConstStability {
-        feature: regular_stab.feature,
-        const_stable_indirect,
-        promotable: false,
-        level: regular_stab.level,
-    }
+    ConstStability { const_stable_indirect, promotable: false, level: regular_stab.level }
 }
 
 /// Collects stability info from `rustc_default_body_unstable` attributes in `attrs`.
@@ -382,24 +368,82 @@ pub fn unmarked_crate_const_stab(
 pub fn find_body_stability(
     sess: &Session,
     attrs: &[Attribute],
-) -> Option<(DefaultBodyStability, Span)> {
-    let mut body_stab: Option<(DefaultBodyStability, Span)> = None;
+) -> Option<(DefaultBodyStability, StabilitySpans)> {
+    let mut level: Option<StabilityLevel> = None;
+    let mut stab_spans = StabilitySpans(smallvec![]);
+    let mut features = smallvec![];
 
     for attr in attrs {
         if attr.has_name(sym::rustc_default_body_unstable) {
-            if body_stab.is_some() {
-                sess.dcx()
-                    .emit_err(session_diagnostics::MultipleStabilityLevels { span: attr.span });
-                break;
-            }
+            add_level(sess, attr, &mut level, &mut stab_spans, &mut features, parse_unstability);
+        }
+    }
 
-            if let Some((feature, level)) = parse_unstability(sess, attr) {
-                body_stab = Some((DefaultBodyStability { level, feature }, attr.span));
+    Some((DefaultBodyStability { level: level? }, stab_spans))
+}
+
+/// Collects stability info from one stability attribute, `attr`.
+/// Emits an error if multiple stability levels are found for the same feature.
+fn add_level(
+    sess: &Session,
+    attr: &Attribute,
+    total_level: &mut Option<StabilityLevel>,
+    stab_spans: &mut StabilitySpans,
+    features: &mut SmallVec<[Symbol; 1]>,
+    parse_level: impl FnOnce(&Session, &Attribute) -> Option<(Symbol, StabilityLevel)>,
+) {
+    use StabilityLevel::*;
+
+    let Some((feature, feature_level)) = parse_level(sess, attr) else { return };
+
+    // sanity check: is this the only stability level of its kind for its feature?
+    if features.contains(&feature) {
+        sess.dcx()
+            .emit_err(session_diagnostics::MultipleStabilityLevels { feature, span: attr.span });
+    }
+    features.push(feature);
+    stab_spans.0.push((feature_level.clone(), attr.span));
+
+    match (total_level, feature_level) {
+        (level @ None, new_level) => *level = Some(new_level),
+        // if multiple unstable attributes have been found, merge them
+        (
+            Some(Unstable { unstables, reason, is_soft }),
+            Unstable { unstables: new_unstable, reason: new_reason, is_soft: new_soft },
+        ) => {
+            unstables.extend(new_unstable);
+            match (reason, new_reason) {
+                (_, UnstableReason::None) => {}
+                (reason @ UnstableReason::None, _) => *reason = new_reason,
+                _ => {
+                    sess.dcx()
+                        .emit_err(session_diagnostics::MultipleUnstableReasons { span: attr.span });
+                }
+            }
+            // If any unstable attributes are marked 'soft', all should be. This keeps soft-unstable
+            // items from accidentally being made properly unstable as attributes are removed.
+            if *is_soft != new_soft {
+                let spans = stab_spans
+                    .iter()
+                    .filter(|(stab, _)| stab.is_unstable())
+                    .map(|&(_, sp)| sp)
+                    .chain([attr.span])
+                    .collect();
+                sess.dcx().emit_err(session_diagnostics::SoftInconsistent { spans });
             }
         }
+        // an item with some stable and some unstable features is unstable
+        (Some(Unstable { .. }), Stable { .. }) => {}
+        (Some(level @ Stable { .. }), new_level @ Unstable { .. }) => *level = new_level,
+        // if multiple stable attributes have been found, use the most recent stabilization date
+        (
+            Some(Stable { since, allowed_through_unstable_modules }),
+            Stable { since: new_since, allowed_through_unstable_modules: new_allowed },
+        ) => {
+            *since = StableSince::max(*since, new_since);
+            *allowed_through_unstable_modules |= new_allowed;
+        }
     }
-
-    body_stab
 }
 
 fn insert_or_error(sess: &Session, meta: &MetaItem, item: &mut Option<Symbol>) -> Option<()> {
@@ -474,10 +518,10 @@ fn parse_stability(sess: &Session, attr: &Attribute) -> Option<(Symbol, Stabilit
     };
 
     match feature {
-        Ok(feature) => {
-            let level = StabilityLevel::Stable { since, allowed_through_unstable_modules: false };
-            Some((feature, level))
-        }
+        Ok(feature) => Some((feature, StabilityLevel::Stable {
+            since,
+            allowed_through_unstable_modules: false,
+        })),
         Err(ErrorGuaranteed { .. }) => None,
     }
 }
@@ -563,13 +607,12 @@ fn parse_unstability(sess: &Session, attr: &Attribute) -> Option<(Symbol, Stabil
 
     match (feature, issue) {
         (Ok(feature), Ok(_)) => {
-            let level = StabilityLevel::Unstable {
+            let unstability = Unstability { feature, issue: issue_num, implied_by };
+            Some((feature, StabilityLevel::Unstable {
+                unstables: smallvec![unstability],
                 reason: UnstableReason::from_opt_reason(reason),
-                issue: issue_num,
                 is_soft,
-                implied_by,
-            };
-            Some((feature, level))
+            }))
         }
         (Err(ErrorGuaranteed { .. }), _) | (_, Err(ErrorGuaranteed { .. })) => None,
     }
diff --git a/compiler/rustc_attr/src/session_diagnostics.rs b/compiler/rustc_attr/src/session_diagnostics.rs
index 9d08a9f575425..c6d388657bd3a 100644
--- a/compiler/rustc_attr/src/session_diagnostics.rs
+++ b/compiler/rustc_attr/src/session_diagnostics.rs
@@ -79,6 +79,15 @@ pub(crate) struct MissingNote {
 pub(crate) struct MultipleStabilityLevels {
     #[primary_span]
     pub span: Span,
+
+    pub feature: Symbol,
+}
+
+#[derive(Diagnostic)]
+#[diag(attr_multiple_unstable_reasons)]
+pub(crate) struct MultipleUnstableReasons {
+    #[primary_span]
+    pub span: Span,
 }
 
 #[derive(Diagnostic)]
@@ -398,6 +407,13 @@ pub(crate) struct SoftNoArgs {
     pub span: Span,
 }
 
+#[derive(Diagnostic)]
+#[diag(attr_soft_inconsistent)]
+pub(crate) struct SoftInconsistent {
+    #[primary_span]
+    pub spans: Vec<Span>,
+}
+
 #[derive(Diagnostic)]
 #[diag(attr_unknown_version_literal)]
 pub(crate) struct UnknownVersionLiteral {
diff --git a/compiler/rustc_const_eval/Cargo.toml b/compiler/rustc_const_eval/Cargo.toml
index 41136019a88df..0fba089ec0d09 100644
--- a/compiler/rustc_const_eval/Cargo.toml
+++ b/compiler/rustc_const_eval/Cargo.toml
@@ -24,5 +24,6 @@ rustc_span = { path = "../rustc_span" }
 rustc_target = { path = "../rustc_target" }
 rustc_trait_selection = { path = "../rustc_trait_selection" }
 rustc_type_ir = { path = "../rustc_type_ir" }
+smallvec = { version = "1.8.1", features = ["union", "may_dangle"] }
 tracing = "0.1"
 # tidy-alphabetical-end
diff --git a/compiler/rustc_const_eval/src/check_consts/check.rs b/compiler/rustc_const_eval/src/check_consts/check.rs
index 8e96d365bebec..57d4560849069 100644
--- a/compiler/rustc_const_eval/src/check_consts/check.rs
+++ b/compiler/rustc_const_eval/src/check_consts/check.rs
@@ -6,7 +6,7 @@ use std::mem;
 use std::num::NonZero;
 use std::ops::Deref;
 
-use rustc_attr::{ConstStability, StabilityLevel};
+use rustc_attr::{ConstStability, StabilityLevel, Unstability};
 use rustc_errors::{Diag, ErrorGuaranteed};
 use rustc_hir::def_id::DefId;
 use rustc_hir::{self as hir, LangItem};
@@ -271,14 +271,16 @@ impl<'mir, 'tcx> Checker<'mir, 'tcx> {
     /// Emits an error at the given `span` if an expression cannot be evaluated in the current
     /// context.
     pub fn check_op_spanned<O: NonConstOp<'tcx>>(&mut self, op: O, span: Span) {
-        let gate = match op.status_in_item(self.ccx) {
+        let gates = match op.status_in_item(self.ccx) {
             Status::Unstable {
-                gate,
+                gates,
                 safe_to_expose_on_stable,
                 is_function_call,
-                gate_already_checked,
-            } if gate_already_checked || self.tcx.features().enabled(gate) => {
-                if gate_already_checked {
+                gates_already_checked,
+            } if gates_already_checked
+                || self.tcx.features().all_enabled(gates.iter().copied()) =>
+            {
+                if gates_already_checked {
                     assert!(
                         !safe_to_expose_on_stable,
                         "setting `gate_already_checked` without `safe_to_expose_on_stable` makes no sense"
@@ -288,20 +290,30 @@ impl<'mir, 'tcx> Checker<'mir, 'tcx> {
                 // if this function wants to be safe-to-expose-on-stable.
                 if !safe_to_expose_on_stable
                     && self.enforce_recursive_const_stability()
-                    && !super::rustc_allow_const_fn_unstable(self.tcx, self.def_id(), gate)
+                    && !gates.iter().all(|&gate| {
+                        super::rustc_allow_const_fn_unstable(self.tcx, self.def_id(), gate)
+                    })
                 {
-                    emit_unstable_in_stable_exposed_error(self.ccx, span, gate, is_function_call);
+                    emit_unstable_in_stable_exposed_error(self.ccx, span, &gates, is_function_call);
                 }
 
                 return;
             }
 
-            Status::Unstable { gate, .. } => Some(gate),
+            Status::Unstable { gates, .. } => Some(gates),
             Status::Forbidden => None,
         };
 
         if self.tcx.sess.opts.unstable_opts.unleash_the_miri_inside_of_you {
-            self.tcx.sess.miri_unleashed_feature(span, gate);
+            if let Some(gates) = gates {
+                for gate in gates {
+                    if !self.tcx.features().enabled(gate) {
+                        self.tcx.sess.miri_unleashed_feature(span, Some(gate));
+                    }
+                }
+            } else {
+                self.tcx.sess.miri_unleashed_feature(span, None);
+            };
             return;
         }
 
@@ -744,13 +756,12 @@ impl<'tcx> Visitor<'tcx> for Checker<'_, 'tcx> {
                             }
                         }
                         Some(ConstStability {
-                            level: StabilityLevel::Unstable { .. },
-                            feature,
+                            level: StabilityLevel::Unstable { unstables, .. },
                             ..
                         }) => {
                             self.check_op(ops::IntrinsicUnstable {
                                 name: intrinsic.name,
-                                feature,
+                                features: unstables.iter().map(|u| u.feature).collect(),
                                 const_stable_indirect: is_const_stable,
                             });
                         }
@@ -796,11 +807,10 @@ impl<'tcx> Visitor<'tcx> for Checker<'_, 'tcx> {
                         }
                     }
                     Some(ConstStability {
-                        level: StabilityLevel::Unstable { implied_by: implied_feature, issue, .. },
-                        feature,
+                        level: StabilityLevel::Unstable { unstables, .. },
                         ..
                     }) => {
-                        // An unstable const fn with a feature gate.
+                        // An unstable const fn with feature gates.
                         let callee_safe_to_expose_on_stable =
                             is_safe_to_expose_on_stable_const_fn(tcx, callee);
 
@@ -812,9 +822,11 @@ impl<'tcx> Visitor<'tcx> for Checker<'_, 'tcx> {
                         // integrated in the check below since we want to enforce
                         // `callee_safe_to_expose_on_stable` even if
                         // `!self.enforce_recursive_const_stability()`.
-                        if (self.span.allows_unstable(feature)
-                            || implied_feature.is_some_and(|f| self.span.allows_unstable(f)))
-                            && callee_safe_to_expose_on_stable
+                        if callee_safe_to_expose_on_stable
+                            && unstables.iter().all(|u| {
+                                self.span.allows_unstable(u.feature)
+                                    || u.implied_by.is_some_and(|f| self.span.allows_unstable(f))
+                            })
                         {
                             return;
                         }
@@ -823,9 +835,11 @@ impl<'tcx> Visitor<'tcx> for Checker<'_, 'tcx> {
                         // the logic is a bit different than elsewhere: local functions don't need
                         // the feature gate, and there might be an "implied" gate that also suffices
                         // to allow this.
-                        let feature_enabled = callee.is_local()
-                            || tcx.features().enabled(feature)
-                            || implied_feature.is_some_and(|f| tcx.features().enabled(f))
+                        let features_enabled = callee.is_local()
+                            || unstables.iter().all(|u| {
+                                tcx.features().enabled(u.feature)
+                                    || u.implied_by.is_some_and(|f| tcx.features().enabled(f))
+                            })
                             || {
                                 // When we're compiling the compiler itself we may pull in
                                 // crates from crates.io, but those crates may depend on other
@@ -838,17 +852,19 @@ impl<'tcx> Visitor<'tcx> for Checker<'_, 'tcx> {
                                 // annotation slide.
                                 // This matches what we do in `eval_stability_allow_unstable` for
                                 // regular stability.
-                                feature == sym::rustc_private
-                                    && issue == NonZero::new(27812)
-                                    && self.tcx.sess.opts.unstable_opts.force_unstable_if_unmarked
+                                matches!(unstables.as_slice(), [Unstability {
+                                    feature: sym::rustc_private,
+                                    issue: const { NonZero::new(27812) },
+                                    ..
+                                }]) && self.tcx.sess.opts.unstable_opts.force_unstable_if_unmarked
                             };
                         // Even if the feature is enabled, we still need check_op to double-check
                         // this if the callee is not safe to expose on stable.
-                        if !feature_enabled || !callee_safe_to_expose_on_stable {
+                        if !features_enabled || !callee_safe_to_expose_on_stable {
                             self.check_op(ops::FnCallUnstable {
                                 def_id: callee,
-                                feature,
-                                feature_enabled,
+                                features: unstables.iter().map(|u| u.feature).collect(),
+                                features_enabled,
                                 safe_to_expose_on_stable: callee_safe_to_expose_on_stable,
                             });
                         }
@@ -937,13 +953,13 @@ fn is_int_bool_float_or_char(ty: Ty<'_>) -> bool {
 fn emit_unstable_in_stable_exposed_error(
     ccx: &ConstCx<'_, '_>,
     span: Span,
-    gate: Symbol,
+    gates: &[Symbol],
     is_function_call: bool,
 ) -> ErrorGuaranteed {
     let attr_span = ccx.tcx.def_span(ccx.def_id()).shrink_to_lo();
 
     ccx.dcx().emit_err(errors::UnstableInStableExposed {
-        gate: gate.to_string(),
+        gate: gates.iter().map(|s| s.as_str()).intersperse(", ").collect(),
         span,
         attr_span,
         is_function_call,
diff --git a/compiler/rustc_const_eval/src/check_consts/ops.rs b/compiler/rustc_const_eval/src/check_consts/ops.rs
index 8ba6b89aad4d5..241b9434b6e16 100644
--- a/compiler/rustc_const_eval/src/check_consts/ops.rs
+++ b/compiler/rustc_const_eval/src/check_consts/ops.rs
@@ -18,24 +18,25 @@ use rustc_middle::util::{CallDesugaringKind, CallKind, call_kind};
 use rustc_span::symbol::sym;
 use rustc_span::{BytePos, Pos, Span, Symbol};
 use rustc_trait_selection::traits::SelectionContext;
+use smallvec::{SmallVec, smallvec};
 use tracing::debug;
 
 use super::ConstCx;
 use crate::{errors, fluent_generated};
 
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[derive(Clone, Debug, PartialEq, Eq)]
 pub enum Status {
     Unstable {
-        /// The feature that must be enabled to use this operation.
-        gate: Symbol,
-        /// Whether the feature gate was already checked (because the logic is a bit more
+        /// The features that must be enabled to use this operation.
+        gates: SmallVec<[Symbol; 1]>,
+        /// Whether the features gate were already checked (because the logic is a bit more
         /// complicated than just checking a single gate).
-        gate_already_checked: bool,
+        gates_already_checked: bool,
         /// Whether it is allowed to use this operation from stable `const fn`.
         /// This will usually be `false`.
         safe_to_expose_on_stable: bool,
         /// We indicate whether this is a function call, since we can use targeted
-        /// diagnostics for "callee is not safe to expose om stable".
+        /// diagnostics for "callee is not safe to expose on stable".
         is_function_call: bool,
     },
     Forbidden,
@@ -84,8 +85,8 @@ impl<'tcx> NonConstOp<'tcx> for ConditionallyConstCall<'tcx> {
     fn status_in_item(&self, _ccx: &ConstCx<'_, 'tcx>) -> Status {
         // We use the `const_trait_impl` gate for all conditionally-const calls.
         Status::Unstable {
-            gate: sym::const_trait_impl,
-            gate_already_checked: false,
+            gates: smallvec![sym::const_trait_impl],
+            gates_already_checked: false,
             safe_to_expose_on_stable: false,
             // We don't want the "mark the callee as `#[rustc_const_stable_indirect]`" hint
             is_function_call: false,
@@ -327,36 +328,47 @@ impl<'tcx> NonConstOp<'tcx> for FnCallNonConst<'tcx> {
 
 /// A call to an `#[unstable]` const fn or `#[rustc_const_unstable]` function.
 ///
-/// Contains the name of the feature that would allow the use of this function.
+/// Contains the names of the features that would allow the use of this function.
 #[derive(Debug)]
 pub(crate) struct FnCallUnstable {
     pub def_id: DefId,
-    pub feature: Symbol,
-    /// If this is true, then the feature is enabled, but we need to still check if it is safe to
+    pub features: SmallVec<[Symbol; 1]>,
+    /// If this is true, then the features are enabled, but we need to still check if it is safe to
     /// expose on stable.
-    pub feature_enabled: bool,
+    pub features_enabled: bool,
     pub safe_to_expose_on_stable: bool,
 }
 
 impl<'tcx> NonConstOp<'tcx> for FnCallUnstable {
     fn status_in_item(&self, _ccx: &ConstCx<'_, 'tcx>) -> Status {
         Status::Unstable {
-            gate: self.feature,
-            gate_already_checked: self.feature_enabled,
+            gates: self.features.clone(),
+            gates_already_checked: self.features_enabled,
             safe_to_expose_on_stable: self.safe_to_expose_on_stable,
             is_function_call: true,
         }
     }
 
     fn build_error(&self, ccx: &ConstCx<'_, 'tcx>, span: Span) -> Diag<'tcx> {
-        assert!(!self.feature_enabled);
+        assert!(!self.features_enabled);
         let mut err = ccx.dcx().create_err(errors::UnstableConstFn {
             span,
             def_path: ccx.tcx.def_path_str(self.def_id),
         });
         // FIXME: make this translatable
         #[allow(rustc::untranslatable_diagnostic)]
-        err.help(format!("add `#![feature({})]` to the crate attributes to enable", self.feature));
+        if ccx.tcx.sess.is_nightly_build() {
+            let missing_features = self
+                .features
+                .iter()
+                .filter(|&&feature| !ccx.tcx.features().enabled(feature))
+                .map(|feature| feature.as_str())
+                .intersperse(", ")
+                .collect::<String>();
+            err.help(format!(
+                "add `#![feature({missing_features})]` to the crate attributes to enable",
+            ));
+        }
 
         err
     }
@@ -382,15 +394,15 @@ impl<'tcx> NonConstOp<'tcx> for IntrinsicNonConst {
 #[derive(Debug)]
 pub(crate) struct IntrinsicUnstable {
     pub name: Symbol,
-    pub feature: Symbol,
+    pub features: SmallVec<[Symbol; 1]>,
     pub const_stable_indirect: bool,
 }
 
 impl<'tcx> NonConstOp<'tcx> for IntrinsicUnstable {
     fn status_in_item(&self, _ccx: &ConstCx<'_, 'tcx>) -> Status {
         Status::Unstable {
-            gate: self.feature,
-            gate_already_checked: false,
+            gates: self.features.clone(),
+            gates_already_checked: false,
             safe_to_expose_on_stable: self.const_stable_indirect,
             // We do *not* want to suggest to mark the intrinsic as `const_stable_indirect`,
             // that's not a trivial change!
@@ -399,10 +411,18 @@ impl<'tcx> NonConstOp<'tcx> for IntrinsicUnstable {
     }
 
     fn build_error(&self, ccx: &ConstCx<'_, 'tcx>, span: Span) -> Diag<'tcx> {
+        // Only report the features that aren't already enabled.
+        let missing_features = self
+            .features
+            .iter()
+            .filter(|&&feature| !ccx.tcx.features().enabled(feature))
+            .map(|feature| feature.as_str())
+            .intersperse(", ")
+            .collect();
         ccx.dcx().create_err(errors::UnstableIntrinsic {
             span,
             name: self.name,
-            feature: self.feature,
+            feature: missing_features,
         })
     }
 }
@@ -417,8 +437,8 @@ impl<'tcx> NonConstOp<'tcx> for Coroutine {
         ) = self.0
         {
             Status::Unstable {
-                gate: sym::const_async_blocks,
-                gate_already_checked: false,
+                gates: smallvec![sym::const_async_blocks],
+                gates_already_checked: false,
                 safe_to_expose_on_stable: false,
                 is_function_call: false,
             }
@@ -429,8 +449,10 @@ impl<'tcx> NonConstOp<'tcx> for Coroutine {
 
     fn build_error(&self, ccx: &ConstCx<'_, 'tcx>, span: Span) -> Diag<'tcx> {
         let msg = format!("{:#}s are not allowed in {}s", self.0, ccx.const_kind());
-        if let Status::Unstable { gate, .. } = self.status_in_item(ccx) {
-            ccx.tcx.sess.create_feature_err(errors::UnallowedOpInConstContext { span, msg }, gate)
+        if let Status::Unstable { gates, .. } = self.status_in_item(ccx) {
+            ccx.tcx
+                .sess
+                .create_features_err(errors::UnallowedOpInConstContext { span, msg }, &gates)
         } else {
             ccx.dcx().create_err(errors::UnallowedOpInConstContext { span, msg })
         }
diff --git a/compiler/rustc_const_eval/src/errors.rs b/compiler/rustc_const_eval/src/errors.rs
index 604e5ed61a35b..272edadc52844 100644
--- a/compiler/rustc_const_eval/src/errors.rs
+++ b/compiler/rustc_const_eval/src/errors.rs
@@ -128,7 +128,7 @@ pub(crate) struct UnstableIntrinsic {
     #[primary_span]
     pub span: Span,
     pub name: Symbol,
-    pub feature: Symbol,
+    pub feature: String,
 }
 
 #[derive(Diagnostic)]
diff --git a/compiler/rustc_const_eval/src/lib.rs b/compiler/rustc_const_eval/src/lib.rs
index 527236b2c22e3..bd1a7136e53d7 100644
--- a/compiler/rustc_const_eval/src/lib.rs
+++ b/compiler/rustc_const_eval/src/lib.rs
@@ -6,6 +6,8 @@
 #![feature(box_patterns)]
 #![feature(decl_macro)]
 #![feature(if_let_guard)]
+#![feature(inline_const_pat)]
+#![feature(iter_intersperse)]
 #![feature(let_chains)]
 #![feature(never_type)]
 #![feature(rustdoc_internals)]
diff --git a/compiler/rustc_error_codes/src/error_codes/E0544.md b/compiler/rustc_error_codes/src/error_codes/E0544.md
index 202401f9d45f9..94e6e36a70d0c 100644
--- a/compiler/rustc_error_codes/src/error_codes/E0544.md
+++ b/compiler/rustc_error_codes/src/error_codes/E0544.md
@@ -1,4 +1,4 @@
-Multiple stability attributes were declared on the same item.
+Multiple stability attributes were declared for the same feature on one item.
 
 Erroneous code example:
 
@@ -8,18 +8,19 @@ Erroneous code example:
 #![stable(since = "1.0.0", feature = "rust1")]
 
 #[stable(feature = "rust1", since = "1.0.0")]
-#[stable(feature = "test", since = "2.0.0")] // invalid
+#[stable(feature = "rust1", since = "1.0.0")] // invalid
 fn foo() {}
 ```
 
-To fix this issue, ensure that each item has at most one stability attribute.
+To fix this issue, ensure that each item has at most one stability attribute per
+feature.
 
 ```
 #![feature(staged_api)]
 #![allow(internal_features)]
 #![stable(since = "1.0.0", feature = "rust1")]
 
-#[stable(feature = "test", since = "2.0.0")] // ok!
+#[stable(feature = "rust1", since = "1.0.0")] // ok!
 fn foo() {}
 ```
 
diff --git a/compiler/rustc_expand/src/base.rs b/compiler/rustc_expand/src/base.rs
index bed500c303242..25262fd441de0 100644
--- a/compiler/rustc_expand/src/base.rs
+++ b/compiler/rustc_expand/src/base.rs
@@ -870,13 +870,13 @@ impl SyntaxExtension {
         let body_stability = attr::find_body_stability(sess, attrs);
         if let Some((_, sp)) = const_stability {
             sess.dcx().emit_err(errors::MacroConstStability {
-                span: sp,
+                spans: sp.spans(),
                 head_span: sess.source_map().guess_head_span(span),
             });
         }
         if let Some((_, sp)) = body_stability {
             sess.dcx().emit_err(errors::MacroBodyStability {
-                span: sp,
+                spans: sp.spans(),
                 head_span: sess.source_map().guess_head_span(span),
             });
         }
diff --git a/compiler/rustc_expand/src/errors.rs b/compiler/rustc_expand/src/errors.rs
index 7bd7c30553913..739f34eb469b7 100644
--- a/compiler/rustc_expand/src/errors.rs
+++ b/compiler/rustc_expand/src/errors.rs
@@ -72,7 +72,7 @@ pub(crate) struct CollapseMacroDebuginfoIllegal {
 pub(crate) struct MacroConstStability {
     #[primary_span]
     #[label]
-    pub span: Span,
+    pub spans: Vec<Span>,
     #[label(expand_label2)]
     pub head_span: Span,
 }
@@ -82,7 +82,7 @@ pub(crate) struct MacroConstStability {
 pub(crate) struct MacroBodyStability {
     #[primary_span]
     #[label]
-    pub span: Span,
+    pub spans: Vec<Span>,
     #[label(expand_label2)]
     pub head_span: Span,
 }
diff --git a/compiler/rustc_feature/Cargo.toml b/compiler/rustc_feature/Cargo.toml
index 9df320e1279ed..5766b4a43dbf4 100644
--- a/compiler/rustc_feature/Cargo.toml
+++ b/compiler/rustc_feature/Cargo.toml
@@ -5,6 +5,7 @@ edition = "2021"
 
 [dependencies]
 # tidy-alphabetical-start
+either = "1.5.0"
 rustc_data_structures = { path = "../rustc_data_structures" }
 rustc_span = { path = "../rustc_span" }
 # tidy-alphabetical-end
diff --git a/compiler/rustc_feature/src/lib.rs b/compiler/rustc_feature/src/lib.rs
index 5d27b8f542cbb..1eb08c6e15496 100644
--- a/compiler/rustc_feature/src/lib.rs
+++ b/compiler/rustc_feature/src/lib.rs
@@ -97,7 +97,7 @@ impl UnstableFeatures {
     }
 }
 
-fn find_lang_feature_issue(feature: Symbol) -> Option<NonZero<u32>> {
+pub fn find_lang_feature_issue(feature: Symbol) -> Option<NonZero<u32>> {
     // Search in all the feature lists.
     if let Some(f) = UNSTABLE_LANG_FEATURES.iter().find(|f| f.name == feature) {
         return f.issue;
@@ -120,15 +120,21 @@ const fn to_nonzero(n: Option<u32>) -> Option<NonZero<u32>> {
     }
 }
 
-pub enum GateIssue {
+pub enum GateIssues {
     Language,
-    Library(Option<NonZero<u32>>),
+    Library(Vec<NonZero<u32>>),
 }
 
-pub fn find_feature_issue(feature: Symbol, issue: GateIssue) -> Option<NonZero<u32>> {
-    match issue {
-        GateIssue::Language => find_lang_feature_issue(feature),
-        GateIssue::Library(lib) => lib,
+pub fn find_feature_issues(
+    features: &[Symbol],
+    issues: GateIssues,
+) -> impl Iterator<Item = NonZero<u32>> + use<'_> {
+    use either::{Left, Right};
+    match issues {
+        GateIssues::Language => {
+            Left(features.iter().flat_map(|&feature| find_lang_feature_issue(feature)))
+        }
+        GateIssues::Library(lib) => Right(lib.into_iter()),
     }
 }
 
diff --git a/compiler/rustc_feature/src/unstable.rs b/compiler/rustc_feature/src/unstable.rs
index a67a5776449d7..7c2d676bad4c4 100644
--- a/compiler/rustc_feature/src/unstable.rs
+++ b/compiler/rustc_feature/src/unstable.rs
@@ -95,6 +95,11 @@ impl Features {
     pub fn enabled(&self, feature: Symbol) -> bool {
         self.enabled_features.contains(&feature)
     }
+
+    /// Are all the given features enabled (via `#[feature(...)]`)?
+    pub fn all_enabled(&self, features: impl IntoIterator<Item = Symbol>) -> bool {
+        features.into_iter().all(|feature| self.enabled(feature))
+    }
 }
 
 macro_rules! declare_features {
diff --git a/compiler/rustc_hir_analysis/messages.ftl b/compiler/rustc_hir_analysis/messages.ftl
index 64a30e633cf9d..629340369a390 100644
--- a/compiler/rustc_hir_analysis/messages.ftl
+++ b/compiler/rustc_hir_analysis/messages.ftl
@@ -311,8 +311,14 @@ hir_analysis_missing_trait_item_suggestion = implement the missing item: `{$snip
 
 hir_analysis_missing_trait_item_unstable = not all trait items implemented, missing: `{$missing_item_name}`
     .note = default implementation of `{$missing_item_name}` is unstable
-    .some_note = use of unstable library feature `{$feature}`: {$reason}
-    .none_note = use of unstable library feature `{$feature}`
+    .unstable_features_note =
+        use of unstable library {$feature_count ->
+            [one] feature
+            *[other] features
+        } {$features}{STREQ($reason, "") ->
+            [true] {""}
+            *[false] : {$reason}
+        }
 
 hir_analysis_missing_type_params =
     the type {$parameterCount ->
diff --git a/compiler/rustc_hir_analysis/src/check/check.rs b/compiler/rustc_hir_analysis/src/check/check.rs
index cf8c81c0b0890..35aed1c5d322d 100644
--- a/compiler/rustc_hir_analysis/src/check/check.rs
+++ b/compiler/rustc_hir_analysis/src/check/check.rs
@@ -1044,14 +1044,16 @@ fn check_impl_items_against_trait<'tcx>(
             if !is_implemented_here {
                 let full_impl_span = tcx.hir().span_with_body(tcx.local_def_id_to_hir_id(impl_id));
                 match tcx.eval_default_body_stability(trait_item_id, full_impl_span) {
-                    EvalResult::Deny { feature, reason, issue, .. } => default_body_is_unstable(
-                        tcx,
-                        full_impl_span,
-                        trait_item_id,
-                        feature,
-                        reason,
-                        issue,
-                    ),
+                    EvalResult::Deny { features, reason, issues, .. } => {
+                        default_body_is_unstable(
+                            tcx,
+                            full_impl_span,
+                            trait_item_id,
+                            features,
+                            reason,
+                            issues,
+                        );
+                    }
 
                     // Unmarked default bodies are considered stable (at least for now).
                     EvalResult::Allow | EvalResult::Unmarked => {}
diff --git a/compiler/rustc_hir_analysis/src/check/mod.rs b/compiler/rustc_hir_analysis/src/check/mod.rs
index 375cbfd1c4fb8..8b293c018d87d 100644
--- a/compiler/rustc_hir_analysis/src/check/mod.rs
+++ b/compiler/rustc_hir_analysis/src/check/mod.rs
@@ -294,37 +294,29 @@ fn default_body_is_unstable(
     tcx: TyCtxt<'_>,
     impl_span: Span,
     item_did: DefId,
-    feature: Symbol,
+    features: Vec<Symbol>,
     reason: Option<Symbol>,
-    issue: Option<NonZero<u32>>,
+    issues: Vec<NonZero<u32>>,
 ) {
     let missing_item_name = tcx.associated_item(item_did).name;
-    let (mut some_note, mut none_note, mut reason_str) = (false, false, String::new());
-    match reason {
-        Some(r) => {
-            some_note = true;
-            reason_str = r.to_string();
-        }
-        None => none_note = true,
-    };
+    let reason_str = reason.map_or(String::new(), |r| r.to_string());
 
     let mut err = tcx.dcx().create_err(errors::MissingTraitItemUnstable {
         span: impl_span,
-        some_note,
-        none_note,
         missing_item_name,
-        feature,
+        features: features.clone().into(),
+        feature_count: features.len(),
         reason: reason_str,
     });
 
     let inject_span = item_did
         .as_local()
         .and_then(|id| tcx.crate_level_attribute_injection_span(tcx.local_def_id_to_hir_id(id)));
-    rustc_session::parse::add_feature_diagnostics_for_issue(
+    rustc_session::parse::add_feature_diagnostics_for_issues(
         &mut err,
         &tcx.sess,
-        feature,
-        rustc_feature::GateIssue::Library(issue),
+        &features,
+        rustc_feature::GateIssues::Library(issues),
         false,
         inject_span,
     );
diff --git a/compiler/rustc_hir_analysis/src/errors.rs b/compiler/rustc_hir_analysis/src/errors.rs
index 07d3273b09c77..73eb2fafdd4cc 100644
--- a/compiler/rustc_hir_analysis/src/errors.rs
+++ b/compiler/rustc_hir_analysis/src/errors.rs
@@ -2,7 +2,8 @@
 
 use rustc_errors::codes::*;
 use rustc_errors::{
-    Applicability, Diag, DiagCtxtHandle, Diagnostic, EmissionGuarantee, Level, MultiSpan,
+    Applicability, Diag, DiagCtxtHandle, DiagSymbolList, Diagnostic, EmissionGuarantee, Level,
+    MultiSpan,
 };
 use rustc_macros::{Diagnostic, LintDiagnostic, Subdiagnostic};
 use rustc_middle::ty::Ty;
@@ -974,15 +975,13 @@ pub(crate) struct MissingOneOfTraitItem {
 #[derive(Diagnostic)]
 #[diag(hir_analysis_missing_trait_item_unstable, code = E0046)]
 #[note]
+#[note(hir_analysis_unstable_features_note)]
 pub(crate) struct MissingTraitItemUnstable {
     #[primary_span]
     pub span: Span,
-    #[note(hir_analysis_some_note)]
-    pub some_note: bool,
-    #[note(hir_analysis_none_note)]
-    pub none_note: bool,
     pub missing_item_name: Symbol,
-    pub feature: Symbol,
+    pub features: DiagSymbolList,
+    pub feature_count: usize,
     pub reason: String,
 }
 
diff --git a/compiler/rustc_hir_typeck/src/method/probe.rs b/compiler/rustc_hir_typeck/src/method/probe.rs
index 640729576fcc6..9eedc949c2234 100644
--- a/compiler/rustc_hir_typeck/src/method/probe.rs
+++ b/compiler/rustc_hir_typeck/src/method/probe.rs
@@ -180,7 +180,7 @@ pub(crate) struct Pick<'tcx> {
     pub self_ty: Ty<'tcx>,
 
     /// Unstable candidates alongside the stable ones.
-    unstable_candidates: Vec<(Candidate<'tcx>, Symbol)>,
+    unstable_candidates: Vec<(Candidate<'tcx>, Vec<Symbol>)>,
 }
 
 #[derive(Clone, Debug, PartialEq, Eq)]
@@ -1061,7 +1061,7 @@ impl<'a, 'tcx> ProbeContext<'a, 'tcx> {
 
     fn pick_all_method(
         &self,
-        mut unstable_candidates: Option<&mut Vec<(Candidate<'tcx>, Symbol)>>,
+        mut unstable_candidates: Option<&mut Vec<(Candidate<'tcx>, Vec<Symbol>)>>,
     ) -> Option<PickResult<'tcx>> {
         self.steps
             .iter()
@@ -1126,7 +1126,7 @@ impl<'a, 'tcx> ProbeContext<'a, 'tcx> {
         &self,
         step: &CandidateStep<'tcx>,
         self_ty: Ty<'tcx>,
-        unstable_candidates: Option<&mut Vec<(Candidate<'tcx>, Symbol)>>,
+        unstable_candidates: Option<&mut Vec<(Candidate<'tcx>, Vec<Symbol>)>>,
     ) -> Option<PickResult<'tcx>> {
         if step.unsize {
             return None;
@@ -1170,7 +1170,7 @@ impl<'a, 'tcx> ProbeContext<'a, 'tcx> {
         step: &CandidateStep<'tcx>,
         self_ty: Ty<'tcx>,
         mutbl: hir::Mutability,
-        unstable_candidates: Option<&mut Vec<(Candidate<'tcx>, Symbol)>>,
+        unstable_candidates: Option<&mut Vec<(Candidate<'tcx>, Vec<Symbol>)>>,
     ) -> Option<PickResult<'tcx>> {
         let tcx = self.tcx;
 
@@ -1194,7 +1194,7 @@ impl<'a, 'tcx> ProbeContext<'a, 'tcx> {
         &self,
         step: &CandidateStep<'tcx>,
         self_ty: Ty<'tcx>,
-        unstable_candidates: Option<&mut Vec<(Candidate<'tcx>, Symbol)>>,
+        unstable_candidates: Option<&mut Vec<(Candidate<'tcx>, Vec<Symbol>)>>,
     ) -> Option<PickResult<'tcx>> {
         if !self.tcx.features().pin_ergonomics() {
             return None;
@@ -1232,7 +1232,7 @@ impl<'a, 'tcx> ProbeContext<'a, 'tcx> {
         &self,
         step: &CandidateStep<'tcx>,
         self_ty: Ty<'tcx>,
-        unstable_candidates: Option<&mut Vec<(Candidate<'tcx>, Symbol)>>,
+        unstable_candidates: Option<&mut Vec<(Candidate<'tcx>, Vec<Symbol>)>>,
     ) -> Option<PickResult<'tcx>> {
         // Don't convert an unsized reference to ptr
         if step.unsize {
@@ -1256,7 +1256,7 @@ impl<'a, 'tcx> ProbeContext<'a, 'tcx> {
     fn pick_method(
         &self,
         self_ty: Ty<'tcx>,
-        mut unstable_candidates: Option<&mut Vec<(Candidate<'tcx>, Symbol)>>,
+        mut unstable_candidates: Option<&mut Vec<(Candidate<'tcx>, Vec<Symbol>)>>,
     ) -> Option<PickResult<'tcx>> {
         debug!("pick_method(self_ty={})", self.ty_to_string(self_ty));
 
@@ -1302,7 +1302,7 @@ impl<'a, 'tcx> ProbeContext<'a, 'tcx> {
             Option<ty::Predicate<'tcx>>,
             Option<ObligationCause<'tcx>>,
         )>,
-        mut unstable_candidates: Option<&mut Vec<(Candidate<'tcx>, Symbol)>>,
+        mut unstable_candidates: Option<&mut Vec<(Candidate<'tcx>, Vec<Symbol>)>>,
     ) -> Option<PickResult<'tcx>> {
         let mut applicable_candidates: Vec<_> = candidates
             .iter()
@@ -1324,10 +1324,10 @@ impl<'a, 'tcx> ProbeContext<'a, 'tcx> {
 
         if let Some(uc) = &mut unstable_candidates {
             applicable_candidates.retain(|&(candidate, _)| {
-                if let stability::EvalResult::Deny { feature, .. } =
+                if let stability::EvalResult::Deny { features, .. } =
                     self.tcx.eval_stability(candidate.item.def_id, None, self.span, None)
                 {
-                    uc.push((candidate.clone(), feature));
+                    uc.push((candidate.clone(), features.clone()));
                     return false;
                 }
                 true
@@ -1425,8 +1425,11 @@ impl<'tcx> Pick<'tcx> {
             tcx.disabled_nightly_features(
                 lint,
                 Some(scope_expr_id),
-                self.unstable_candidates.iter().map(|(candidate, feature)| {
-                    (format!(" `{}`", tcx.def_path_str(candidate.item.def_id)), *feature)
+                self.unstable_candidates.iter().map(|(candidate, features)| {
+                    (
+                        format!(" `{}`", tcx.def_path_str(candidate.item.def_id)),
+                        features.iter().map(Symbol::as_str).intersperse(", ").collect::<String>(),
+                    )
                 }),
             );
         });
@@ -2014,7 +2017,7 @@ impl<'tcx> Candidate<'tcx> {
     fn to_unadjusted_pick(
         &self,
         self_ty: Ty<'tcx>,
-        unstable_candidates: Vec<(Candidate<'tcx>, Symbol)>,
+        unstable_candidates: Vec<(Candidate<'tcx>, Vec<Symbol>)>,
     ) -> Pick<'tcx> {
         Pick {
             item: self.item,
diff --git a/compiler/rustc_lint/src/builtin.rs b/compiler/rustc_lint/src/builtin.rs
index f6366ec3b8012..b4f632fdd2b80 100644
--- a/compiler/rustc_lint/src/builtin.rs
+++ b/compiler/rustc_lint/src/builtin.rs
@@ -22,7 +22,7 @@ use rustc_ast::visit::{FnCtxt, FnKind};
 use rustc_ast::{self as ast, *};
 use rustc_ast_pretty::pprust::{self, expr_to_string};
 use rustc_errors::{Applicability, LintDiagnostic};
-use rustc_feature::{AttributeGate, BuiltinAttribute, GateIssue, Stability, deprecated_attributes};
+use rustc_feature::{AttributeGate, BuiltinAttribute, Stability, deprecated_attributes};
 use rustc_hir as hir;
 use rustc_hir::def::{DefKind, Res};
 use rustc_hir::def_id::{CRATE_DEF_ID, DefId, LocalDefId};
@@ -2313,7 +2313,7 @@ impl EarlyLintPass for IncompleteInternalFeatures {
             .filter(|(name, _)| features.incomplete(*name) || features.internal(*name))
             .for_each(|(name, span)| {
                 if features.incomplete(name) {
-                    let note = rustc_feature::find_feature_issue(name, GateIssue::Language)
+                    let note = rustc_feature::find_lang_feature_issue(name)
                         .map(|n| BuiltinFeatureIssueNote { n });
                     let help =
                         HAS_MIN_FEATURES.contains(&name).then_some(BuiltinIncompleteFeaturesHelp);
diff --git a/compiler/rustc_lint/src/context/diagnostics.rs b/compiler/rustc_lint/src/context/diagnostics.rs
index 565c3c0425256..550b6da5f669c 100644
--- a/compiler/rustc_lint/src/context/diagnostics.rs
+++ b/compiler/rustc_lint/src/context/diagnostics.rs
@@ -382,8 +382,9 @@ pub(super) fn decorate_lint(sess: &Session, diagnostic: BuiltinLintDiag, diag: &
         BuiltinLintDiag::MacroRuleNeverUsed(n, name) => {
             lints::MacroRuleNeverUsed { n: n + 1, name }.decorate_lint(diag);
         }
-        BuiltinLintDiag::UnstableFeature(msg) => {
-            lints::UnstableFeature { msg }.decorate_lint(diag);
+        BuiltinLintDiag::SoftUnstableMacro { features, reason } => {
+            rustc_middle::error::SoftUnstableLibraryFeature::new(features, reason)
+                .decorate_lint(diag);
         }
         BuiltinLintDiag::AvoidUsingIntelSyntax => {
             lints::AvoidIntelSyntax.decorate_lint(diag);
diff --git a/compiler/rustc_lint/src/levels.rs b/compiler/rustc_lint/src/levels.rs
index 97a957874225d..9d4e3e45f66a6 100644
--- a/compiler/rustc_lint/src/levels.rs
+++ b/compiler/rustc_lint/src/levels.rs
@@ -1,7 +1,7 @@
 use rustc_ast_pretty::pprust;
 use rustc_data_structures::fx::{FxIndexMap, FxIndexSet};
 use rustc_errors::{Diag, LintDiagnostic, MultiSpan};
-use rustc_feature::{Features, GateIssue};
+use rustc_feature::{Features, GateIssues};
 use rustc_hir::intravisit::{self, Visitor};
 use rustc_hir::{CRATE_HIR_ID, HirId};
 use rustc_index::IndexVec;
@@ -985,11 +985,11 @@ impl<'s, P: LintLevelsProvider> LintLevelsBuilder<'s, P> {
                 lint.primary_message(fluent::lint_unknown_gated_lint);
                 lint.arg("name", lint_id.lint.name_lower());
                 lint.note(fluent::lint_note);
-                rustc_session::parse::add_feature_diagnostics_for_issue(
+                rustc_session::parse::add_feature_diagnostics_for_issues(
                     lint,
                     &self.sess,
-                    feature,
-                    GateIssue::Language,
+                    &[feature],
+                    GateIssues::Language,
                     lint_from_cli,
                     None,
                 );
diff --git a/compiler/rustc_lint/src/lints.rs b/compiler/rustc_lint/src/lints.rs
index 352155729e51c..f41172d4bfb4d 100644
--- a/compiler/rustc_lint/src/lints.rs
+++ b/compiler/rustc_lint/src/lints.rs
@@ -2408,16 +2408,6 @@ pub(crate) struct MacroRuleNeverUsed {
     pub name: Symbol,
 }
 
-pub(crate) struct UnstableFeature {
-    pub msg: DiagMessage,
-}
-
-impl<'a> LintDiagnostic<'a, ()> for UnstableFeature {
-    fn decorate_lint<'b>(self, diag: &'b mut Diag<'a, ()>) {
-        diag.primary_message(self.msg);
-    }
-}
-
 #[derive(LintDiagnostic)]
 #[diag(lint_avoid_intel_syntax)]
 pub(crate) struct AvoidIntelSyntax;
diff --git a/compiler/rustc_lint_defs/src/lib.rs b/compiler/rustc_lint_defs/src/lib.rs
index eac4afee05009..cdebd337a7257 100644
--- a/compiler/rustc_lint_defs/src/lib.rs
+++ b/compiler/rustc_lint_defs/src/lib.rs
@@ -9,7 +9,7 @@ use rustc_data_structures::fx::{FxIndexMap, FxIndexSet};
 use rustc_data_structures::stable_hasher::{
     HashStable, StableCompare, StableHasher, ToStableHashKey,
 };
-use rustc_error_messages::{DiagMessage, MultiSpan};
+use rustc_error_messages::MultiSpan;
 use rustc_hir::def::Namespace;
 use rustc_hir::{HashStableContext, HirId, MissingLifetimeKind};
 use rustc_macros::{Decodable, Encodable, HashStable_Generic};
@@ -750,7 +750,10 @@ pub enum BuiltinLintDiag {
     MacroIsPrivate(Ident),
     UnusedMacroDefinition(Symbol),
     MacroRuleNeverUsed(usize, Symbol),
-    UnstableFeature(DiagMessage),
+    SoftUnstableMacro {
+        features: Vec<Symbol>,
+        reason: Option<Symbol>,
+    },
     AvoidUsingIntelSyntax,
     AvoidUsingAttSyntax,
     IncompleteInclude,
diff --git a/compiler/rustc_metadata/src/rmeta/decoder/cstore_impl.rs b/compiler/rustc_metadata/src/rmeta/decoder/cstore_impl.rs
index 045fd0565ba0d..6c4345b4e8451 100644
--- a/compiler/rustc_metadata/src/rmeta/decoder/cstore_impl.rs
+++ b/compiler/rustc_metadata/src/rmeta/decoder/cstore_impl.rs
@@ -59,6 +59,13 @@ impl<'tcx, T: ArenaAllocatable<'tcx>> ProcessQueryValue<'tcx, &'tcx T> for Optio
     }
 }
 
+impl<'tcx, T: ArenaAllocatable<'tcx>> ProcessQueryValue<'tcx, Option<&'tcx T>> for Option<T> {
+    #[inline(always)]
+    fn process_decoded(self, tcx: TyCtxt<'tcx>, _err: impl Fn() -> !) -> Option<&'tcx T> {
+        self.map(|value| tcx.arena.alloc(value) as &_)
+    }
+}
+
 impl<T, E> ProcessQueryValue<'_, Result<Option<T>, E>> for Option<T> {
     #[inline(always)]
     fn process_decoded(self, _tcx: TyCtxt<'_>, _err: impl Fn() -> !) -> Result<Option<T>, E> {
diff --git a/compiler/rustc_metadata/src/rmeta/encoder.rs b/compiler/rustc_metadata/src/rmeta/encoder.rs
index b5391247cea54..deab2dd4a0318 100644
--- a/compiler/rustc_metadata/src/rmeta/encoder.rs
+++ b/compiler/rustc_metadata/src/rmeta/encoder.rs
@@ -1924,7 +1924,7 @@ impl<'a, 'tcx> EncodeContext<'a, 'tcx> {
                 }
             }
 
-            Some(ProcMacroData { proc_macro_decls_static, stability, macros })
+            Some(ProcMacroData { proc_macro_decls_static, stability: stability.cloned(), macros })
         } else {
             None
         }
diff --git a/compiler/rustc_middle/messages.ftl b/compiler/rustc_middle/messages.ftl
index 52c3212ab803a..d7a3e47b3ae86 100644
--- a/compiler/rustc_middle/messages.ftl
+++ b/compiler/rustc_middle/messages.ftl
@@ -102,6 +102,15 @@ middle_type_length_limit = reached the type-length limit while instantiating `{$
 middle_unknown_layout =
     the type `{$ty}` has an unknown layout
 
+middle_unstable_library_feature =
+    use of unstable library {$count ->
+        [one] feature
+        *[other] features
+    } {$features}{STREQ($reason, "") ->
+        [true] {""}
+        *[false] : {$reason}
+    }
+
 middle_values_too_big =
     values of the type `{$ty}` are too big for the target architecture
 middle_written_to_path = the full type name has been written to '{$path}'
diff --git a/compiler/rustc_middle/src/arena.rs b/compiler/rustc_middle/src/arena.rs
index b664230d10bce..91ef2cccb0b61 100644
--- a/compiler/rustc_middle/src/arena.rs
+++ b/compiler/rustc_middle/src/arena.rs
@@ -114,6 +114,10 @@ macro_rules! arena_types {
             [decode] specialization_graph: rustc_middle::traits::specialization_graph::Graph,
             [] crate_inherent_impls: rustc_middle::ty::CrateInherentImpls,
             [] hir_owner_nodes: rustc_hir::OwnerNodes<'tcx>,
+
+            [] stability: rustc_attr::Stability,
+            [] const_stability: rustc_attr::ConstStability,
+            [] default_body_stability: rustc_attr::DefaultBodyStability,
         ]);
     )
 }
diff --git a/compiler/rustc_middle/src/error.rs b/compiler/rustc_middle/src/error.rs
index 5c2aa0005d405..d8cb32c43205e 100644
--- a/compiler/rustc_middle/src/error.rs
+++ b/compiler/rustc_middle/src/error.rs
@@ -2,8 +2,8 @@ use std::fmt;
 use std::path::PathBuf;
 
 use rustc_errors::codes::*;
-use rustc_errors::{DiagArgName, DiagArgValue, DiagMessage};
-use rustc_macros::{Diagnostic, Subdiagnostic};
+use rustc_errors::{DiagArgName, DiagArgValue, DiagMessage, DiagSymbolList};
+use rustc_macros::{Diagnostic, LintDiagnostic, Subdiagnostic};
 use rustc_span::{Span, Symbol};
 
 use crate::ty::Ty;
@@ -164,3 +164,40 @@ pub struct TypeLengthLimit {
     pub path: PathBuf,
     pub type_length: usize,
 }
+
+#[derive(Diagnostic)]
+#[diag(middle_unstable_library_feature, code = E0658)]
+pub struct UnstableLibraryFeatureError {
+    #[primary_span]
+    pub span: Span,
+    pub features: DiagSymbolList,
+    pub count: usize,
+    pub reason: String,
+}
+
+impl UnstableLibraryFeatureError {
+    pub fn new(features: Vec<Symbol>, reason: Option<Symbol>, span: Span) -> Self {
+        let SoftUnstableLibraryFeature { features, count, reason } =
+            SoftUnstableLibraryFeature::new(features, reason);
+        UnstableLibraryFeatureError { span, features, count, reason }
+    }
+}
+
+/// Lint diagnostic for soft_unstable
+#[derive(LintDiagnostic)]
+#[diag(middle_unstable_library_feature)]
+pub struct SoftUnstableLibraryFeature {
+    pub features: DiagSymbolList,
+    pub count: usize,
+    pub reason: String,
+}
+
+impl SoftUnstableLibraryFeature {
+    pub fn new(features: Vec<Symbol>, reason: Option<Symbol>) -> Self {
+        SoftUnstableLibraryFeature {
+            count: features.len(),
+            features: features.into(),
+            reason: reason.map_or(String::new(), |r| r.to_string()),
+        }
+    }
+}
diff --git a/compiler/rustc_middle/src/lib.rs b/compiler/rustc_middle/src/lib.rs
index 04a06ba7464cb..accb081069d4a 100644
--- a/compiler/rustc_middle/src/lib.rs
+++ b/compiler/rustc_middle/src/lib.rs
@@ -48,6 +48,7 @@
 #![feature(if_let_guard)]
 #![feature(intra_doc_pointers)]
 #![feature(iter_from_coroutine)]
+#![feature(iter_intersperse)]
 #![feature(let_chains)]
 #![feature(macro_metavar_expr)]
 #![feature(min_specialization)]
diff --git a/compiler/rustc_middle/src/middle/stability.rs b/compiler/rustc_middle/src/middle/stability.rs
index 94d13021612be..12d9f8eadc2d6 100644
--- a/compiler/rustc_middle/src/middle/stability.rs
+++ b/compiler/rustc_middle/src/middle/stability.rs
@@ -9,7 +9,7 @@ use rustc_attr::{
 };
 use rustc_data_structures::unord::UnordMap;
 use rustc_errors::{Applicability, Diag, EmissionGuarantee};
-use rustc_feature::GateIssue;
+use rustc_feature::GateIssues;
 use rustc_hir::def_id::{DefId, LocalDefId, LocalDefIdMap};
 use rustc_hir::{self as hir, HirId};
 use rustc_macros::{Decodable, Encodable, HashStable, Subdiagnostic};
@@ -17,12 +17,13 @@ use rustc_middle::ty::print::with_no_trimmed_paths;
 use rustc_session::Session;
 use rustc_session::lint::builtin::{DEPRECATED, DEPRECATED_IN_FUTURE, SOFT_UNSTABLE};
 use rustc_session::lint::{BuiltinLintDiag, DeprecatedSinceKind, Level, Lint, LintBuffer};
-use rustc_session::parse::feature_err_issue;
+use rustc_session::parse::add_feature_diagnostics_for_issues;
 use rustc_span::Span;
 use rustc_span::symbol::{Symbol, sym};
 use tracing::debug;
 
 pub use self::StabilityLevel::*;
+use crate::error::{SoftUnstableLibraryFeature, UnstableLibraryFeatureError};
 use crate::ty::TyCtxt;
 
 #[derive(PartialEq, Clone, Copy, Debug)]
@@ -60,12 +61,12 @@ impl DeprecationEntry {
 
 /// A stability index, giving the stability level for items and methods.
 #[derive(HashStable, Debug)]
-pub struct Index {
+pub struct Index<'tcx> {
     /// This is mostly a cache, except the stabilities of local items
     /// are filled by the annotator.
-    pub stab_map: LocalDefIdMap<Stability>,
-    pub const_stab_map: LocalDefIdMap<ConstStability>,
-    pub default_body_stab_map: LocalDefIdMap<DefaultBodyStability>,
+    pub stab_map: LocalDefIdMap<&'tcx Stability>,
+    pub const_stab_map: LocalDefIdMap<&'tcx ConstStability>,
+    pub default_body_stab_map: LocalDefIdMap<&'tcx DefaultBodyStability>,
     pub depr_map: LocalDefIdMap<DeprecationEntry>,
     /// Mapping from feature name to feature name based on the `implied_by` field of `#[unstable]`
     /// attributes. If a `#[unstable(feature = "implier", implied_by = "impliee")]` attribute
@@ -82,16 +83,19 @@ pub struct Index {
     pub implications: UnordMap<Symbol, Symbol>,
 }
 
-impl Index {
-    pub fn local_stability(&self, def_id: LocalDefId) -> Option<Stability> {
+impl<'tcx> Index<'tcx> {
+    pub fn local_stability(&self, def_id: LocalDefId) -> Option<&'tcx Stability> {
         self.stab_map.get(&def_id).copied()
     }
 
-    pub fn local_const_stability(&self, def_id: LocalDefId) -> Option<ConstStability> {
+    pub fn local_const_stability(&self, def_id: LocalDefId) -> Option<&'tcx ConstStability> {
         self.const_stab_map.get(&def_id).copied()
     }
 
-    pub fn local_default_body_stability(&self, def_id: LocalDefId) -> Option<DefaultBodyStability> {
+    pub fn local_default_body_stability(
+        &self,
+        def_id: LocalDefId,
+    ) -> Option<&'tcx DefaultBodyStability> {
         self.default_body_stab_map.get(&def_id).copied()
     }
 
@@ -102,28 +106,26 @@ impl Index {
 
 pub fn report_unstable(
     sess: &Session,
-    feature: Symbol,
+    features: Vec<Symbol>,
     reason: Option<Symbol>,
-    issue: Option<NonZero<u32>>,
-    suggestion: Option<(Span, String, String, Applicability)>,
-    is_soft: bool,
+    issues: Vec<NonZero<u32>>,
+    suggestions: Vec<(Span, String, String, Applicability)>,
     span: Span,
-    soft_handler: impl FnOnce(&'static Lint, Span, String),
 ) {
-    let msg = match reason {
-        Some(r) => format!("use of unstable library feature `{feature}`: {r}"),
-        None => format!("use of unstable library feature `{feature}`"),
-    };
-
-    if is_soft {
-        soft_handler(SOFT_UNSTABLE, span, msg)
-    } else {
-        let mut err = feature_err_issue(sess, feature, span, GateIssue::Library(issue), msg);
-        if let Some((inner_types, msg, sugg, applicability)) = suggestion {
-            err.span_suggestion(inner_types, msg, sugg, applicability);
-        }
-        err.emit();
+    let mut err =
+        sess.dcx().create_err(UnstableLibraryFeatureError::new(features.clone(), reason, span));
+    add_feature_diagnostics_for_issues(
+        &mut err,
+        sess,
+        &features,
+        GateIssues::Library(issues),
+        false,
+        None,
+    );
+    for (inner_types, msg, sugg, applicability) in suggestions {
+        err.span_suggestion(inner_types, msg, sugg, applicability);
     }
+    err.emit();
 }
 
 fn deprecation_lint(is_in_effect: bool) -> &'static Lint {
@@ -256,16 +258,16 @@ fn late_report_deprecation(
 
 /// Result of `TyCtxt::eval_stability`.
 pub enum EvalResult {
-    /// We can use the item because it is stable or we provided the
-    /// corresponding feature gate.
+    /// We can use the item because it is stable or we enabled the
+    /// corresponding feature gates.
     Allow,
-    /// We cannot use the item because it is unstable and we did not provide the
-    /// corresponding feature gate.
+    /// We cannot use the item because it is unstable and we did not enable the
+    /// corresponding feature gates.
     Deny {
-        feature: Symbol,
+        features: Vec<Symbol>,
         reason: Option<Symbol>,
-        issue: Option<NonZero<u32>>,
-        suggestion: Option<(Span, String, String, Applicability)>,
+        issues: Vec<NonZero<u32>>,
+        suggestions: Vec<(Span, String, String, Applicability)>,
         is_soft: bool,
     },
     /// The item does not have the `#[stable]` or `#[unstable]` marker assigned.
@@ -329,8 +331,8 @@ impl<'tcx> TyCtxt<'tcx> {
     /// Evaluates the stability of an item.
     ///
     /// Returns `EvalResult::Allow` if the item is stable, or unstable but the corresponding
-    /// `#![feature]` has been provided. Returns `EvalResult::Deny` which describes the offending
-    /// unstable feature otherwise.
+    /// `#![feature]`s have been provided. Returns `EvalResult::Deny` which describes the offending
+    /// unstable features otherwise.
     ///
     /// If `id` is `Some(_)`, this function will also check if the item at `def_id` has been
     /// deprecated. If the item is indeed deprecated, we will emit a deprecation lint attached to
@@ -391,55 +393,65 @@ impl<'tcx> TyCtxt<'tcx> {
         );
 
         match stability {
-            Some(Stability {
-                level: attr::Unstable { reason, issue, is_soft, implied_by },
-                feature,
-                ..
-            }) => {
-                if span.allows_unstable(feature) {
-                    debug!("stability: skipping span={:?} since it is internal", span);
-                    return EvalResult::Allow;
-                }
-                if self.features().enabled(feature) {
+            Some(Stability { level: attr::Unstable { unstables, reason, is_soft } }) => {
+                if matches!(allow_unstable, AllowUnstable::Yes) {
                     return EvalResult::Allow;
                 }
 
-                // If this item was previously part of a now-stabilized feature which is still
-                // enabled (i.e. the user hasn't removed the attribute for the stabilized feature
-                // yet) then allow use of this item.
-                if let Some(implied_by) = implied_by
-                    && self.features().enabled(implied_by)
-                {
-                    return EvalResult::Allow;
-                }
+                let mut missing_features = vec![];
+                let mut issues = vec![];
+                let mut suggestions = vec![];
 
-                // When we're compiling the compiler itself we may pull in
-                // crates from crates.io, but those crates may depend on other
-                // crates also pulled in from crates.io. We want to ideally be
-                // able to compile everything without requiring upstream
-                // modifications, so in the case that this looks like a
-                // `rustc_private` crate (e.g., a compiler crate) and we also have
-                // the `-Z force-unstable-if-unmarked` flag present (we're
-                // compiling a compiler crate), then let this missing feature
-                // annotation slide.
-                if feature == sym::rustc_private
-                    && issue == NonZero::new(27812)
-                    && self.sess.opts.unstable_opts.force_unstable_if_unmarked
-                {
-                    return EvalResult::Allow;
-                }
+                for unstability in unstables {
+                    let &attr::Unstability { feature, issue, .. } = unstability;
+                    if span.allows_unstable(feature) {
+                        debug!("stability: skipping span={:?} since it is internal", span);
+                        continue;
+                    }
+                    if self.features().enabled(feature) {
+                        continue;
+                    }
 
-                if matches!(allow_unstable, AllowUnstable::Yes) {
-                    return EvalResult::Allow;
+                    // If this item was previously part of a now-stabilized feature which is still
+                    // active (i.e. the user hasn't removed the attribute for the stabilized feature
+                    // yet) then allow use of this item.
+                    if let Some(implied_by) = unstability.implied_by
+                        && self.features().enabled(implied_by)
+                    {
+                        continue;
+                    }
+
+                    // When we're compiling the compiler itself we may pull in
+                    // crates from crates.io, but those crates may depend on other
+                    // crates also pulled in from crates.io. We want to ideally be
+                    // able to compile everything without requiring upstream
+                    // modifications, so in the case that this looks like a
+                    // `rustc_private` crate (e.g., a compiler crate) and we also have
+                    // the `-Z force-unstable-if-unmarked` flag present (we're
+                    // compiling a compiler crate), then let this missing feature
+                    // annotation slide.
+                    if feature == sym::rustc_private
+                        && issue == NonZero::new(27812)
+                        && self.sess.opts.unstable_opts.force_unstable_if_unmarked
+                    {
+                        continue;
+                    }
+
+                    missing_features.push(feature);
+                    issues.extend(issue);
+                    suggestions.extend(suggestion_for_allocator_api(self, def_id, span, feature));
                 }
 
-                let suggestion = suggestion_for_allocator_api(self, def_id, span, feature);
-                EvalResult::Deny {
-                    feature,
-                    reason: reason.to_opt_reason(),
-                    issue,
-                    suggestion,
-                    is_soft,
+                if missing_features.is_empty() {
+                    EvalResult::Allow
+                } else {
+                    EvalResult::Deny {
+                        features: missing_features,
+                        reason: reason.to_opt_reason(),
+                        issues,
+                        suggestions,
+                        is_soft: *is_soft,
+                    }
                 }
             }
             Some(_) => {
@@ -454,8 +466,8 @@ impl<'tcx> TyCtxt<'tcx> {
     /// Evaluates the default-impl stability of an item.
     ///
     /// Returns `EvalResult::Allow` if the item's default implementation is stable, or unstable but the corresponding
-    /// `#![feature]` has been provided. Returns `EvalResult::Deny` which describes the offending
-    /// unstable feature otherwise.
+    /// `#![feature]`s have been provided. Returns `EvalResult::Deny` which describes the offending
+    /// unstable features otherwise.
     pub fn eval_default_body_stability(self, def_id: DefId, span: Span) -> EvalResult {
         let is_staged_api = self.lookup_stability(def_id.krate.as_def_id()).is_some();
         if !is_staged_api {
@@ -474,24 +486,33 @@ impl<'tcx> TyCtxt<'tcx> {
         );
 
         match stability {
-            Some(DefaultBodyStability {
-                level: attr::Unstable { reason, issue, is_soft, .. },
-                feature,
-            }) => {
-                if span.allows_unstable(feature) {
-                    debug!("body stability: skipping span={:?} since it is internal", span);
-                    return EvalResult::Allow;
-                }
-                if self.features().enabled(feature) {
-                    return EvalResult::Allow;
+            Some(DefaultBodyStability { level: attr::Unstable { unstables, reason, is_soft } }) => {
+                let mut missing_features = vec![];
+                let mut issues = vec![];
+                for unstability in unstables {
+                    let feature = unstability.feature;
+                    if span.allows_unstable(feature) {
+                        debug!("body stability: skipping span={:?} since it is internal", span);
+                        continue;
+                    }
+                    if self.features().enabled(feature) {
+                        continue;
+                    }
+
+                    missing_features.push(feature);
+                    issues.extend(unstability.issue);
                 }
 
-                EvalResult::Deny {
-                    feature,
-                    reason: reason.to_opt_reason(),
-                    issue,
-                    suggestion: None,
-                    is_soft,
+                if missing_features.is_empty() {
+                    EvalResult::Allow
+                } else {
+                    EvalResult::Deny {
+                        features: missing_features,
+                        reason: reason.to_opt_reason(),
+                        issues,
+                        suggestions: vec![],
+                        is_soft: *is_soft,
+                    }
                 }
             }
             Some(_) => {
@@ -569,26 +590,23 @@ impl<'tcx> TyCtxt<'tcx> {
         allow_unstable: AllowUnstable,
         unmarked: impl FnOnce(Span, DefId),
     ) -> bool {
-        let soft_handler = |lint, span, msg: String| {
-            self.node_span_lint(lint, id.unwrap_or(hir::CRATE_HIR_ID), span, |lint| {
-                lint.primary_message(msg);
-            })
-        };
         let eval_result =
             self.eval_stability_allow_unstable(def_id, id, span, method_span, allow_unstable);
         let is_allowed = matches!(eval_result, EvalResult::Allow);
         match eval_result {
             EvalResult::Allow => {}
-            EvalResult::Deny { feature, reason, issue, suggestion, is_soft } => report_unstable(
-                self.sess,
-                feature,
-                reason,
-                issue,
-                suggestion,
-                is_soft,
-                span,
-                soft_handler,
-            ),
+            EvalResult::Deny { features, reason, issues, suggestions, is_soft } => {
+                if is_soft {
+                    self.emit_node_span_lint(
+                        SOFT_UNSTABLE,
+                        id.unwrap_or(hir::CRATE_HIR_ID),
+                        span,
+                        SoftUnstableLibraryFeature::new(features, reason),
+                    );
+                } else {
+                    report_unstable(self.sess, features, reason, issues, suggestions, span);
+                }
+            }
             EvalResult::Unmarked => unmarked(span, def_id),
         }
 
diff --git a/compiler/rustc_middle/src/query/erase.rs b/compiler/rustc_middle/src/query/erase.rs
index 013847f0b2d53..e65b03649d1b5 100644
--- a/compiler/rustc_middle/src/query/erase.rs
+++ b/compiler/rustc_middle/src/query/erase.rs
@@ -239,9 +239,6 @@ trivial! {
     bool,
     Option<(rustc_span::def_id::DefId, rustc_session::config::EntryFnType)>,
     Option<rustc_ast::expand::allocator::AllocatorKind>,
-    Option<rustc_attr::ConstStability>,
-    Option<rustc_attr::DefaultBodyStability>,
-    Option<rustc_attr::Stability>,
     Option<rustc_data_structures::svh::Svh>,
     Option<rustc_hir::def::DefKind>,
     Option<rustc_hir::CoroutineKind>,
@@ -264,10 +261,7 @@ trivial! {
     Result<rustc_middle::traits::EvaluationResult, rustc_middle::traits::OverflowError>,
     rustc_abi::ReprOptions,
     rustc_ast::expand::allocator::AllocatorKind,
-    rustc_attr::ConstStability,
-    rustc_attr::DefaultBodyStability,
     rustc_attr::Deprecation,
-    rustc_attr::Stability,
     rustc_data_structures::svh::Svh,
     rustc_errors::ErrorGuaranteed,
     rustc_hir::Constness,
diff --git a/compiler/rustc_middle/src/query/mod.rs b/compiler/rustc_middle/src/query/mod.rs
index 684d5b6c2a778..2d7774ef1a04e 100644
--- a/compiler/rustc_middle/src/query/mod.rs
+++ b/compiler/rustc_middle/src/query/mod.rs
@@ -1220,19 +1220,19 @@ rustc_queries! {
         feedable
     }
 
-    query lookup_stability(def_id: DefId) -> Option<attr::Stability> {
+    query lookup_stability(def_id: DefId) -> Option<&'tcx attr::Stability> {
         desc { |tcx| "looking up stability of `{}`", tcx.def_path_str(def_id) }
         cache_on_disk_if { def_id.is_local() }
         separate_provide_extern
     }
 
-    query lookup_const_stability(def_id: DefId) -> Option<attr::ConstStability> {
+    query lookup_const_stability(def_id: DefId) -> Option<&'tcx attr::ConstStability> {
         desc { |tcx| "looking up const stability of `{}`", tcx.def_path_str(def_id) }
         cache_on_disk_if { def_id.is_local() }
         separate_provide_extern
     }
 
-    query lookup_default_body_stability(def_id: DefId) -> Option<attr::DefaultBodyStability> {
+    query lookup_default_body_stability(def_id: DefId) -> Option<&'tcx attr::DefaultBodyStability> {
         desc { |tcx| "looking up default body stability of `{}`", tcx.def_path_str(def_id) }
         separate_provide_extern
     }
@@ -1969,7 +1969,7 @@ rustc_queries! {
         desc { |tcx| "finding names imported by glob use for `{}`", tcx.def_path_str(def_id) }
     }
 
-    query stability_index(_: ()) -> &'tcx stability::Index {
+    query stability_index(_: ()) -> &'tcx stability::Index<'tcx> {
         arena_cache
         eval_always
         desc { "calculating the stability index for the local crate" }
diff --git a/compiler/rustc_middle/src/query/on_disk_cache.rs b/compiler/rustc_middle/src/query/on_disk_cache.rs
index 3849cb72668f6..48863a8ddd0b4 100644
--- a/compiler/rustc_middle/src/query/on_disk_cache.rs
+++ b/compiler/rustc_middle/src/query/on_disk_cache.rs
@@ -790,6 +790,27 @@ impl<'a, 'tcx> Decodable<CacheDecoder<'a, 'tcx>>
     }
 }
 
+impl<'a, 'tcx> Decodable<CacheDecoder<'a, 'tcx>> for &'tcx rustc_attr::Stability {
+    #[inline]
+    fn decode(d: &mut CacheDecoder<'a, 'tcx>) -> Self {
+        d.tcx.arena.alloc(Decodable::decode(d))
+    }
+}
+
+impl<'a, 'tcx> Decodable<CacheDecoder<'a, 'tcx>> for &'tcx rustc_attr::ConstStability {
+    #[inline]
+    fn decode(d: &mut CacheDecoder<'a, 'tcx>) -> Self {
+        d.tcx.arena.alloc(Decodable::decode(d))
+    }
+}
+
+impl<'a, 'tcx> Decodable<CacheDecoder<'a, 'tcx>> for &'tcx rustc_attr::DefaultBodyStability {
+    #[inline]
+    fn decode(d: &mut CacheDecoder<'a, 'tcx>) -> Self {
+        d.tcx.arena.alloc(Decodable::decode(d))
+    }
+}
+
 macro_rules! impl_ref_decoder {
     (<$tcx:tt> $($ty:ty,)*) => {
         $(impl<'a, $tcx> Decodable<CacheDecoder<'a, $tcx>> for &$tcx [$ty] {
diff --git a/compiler/rustc_middle/src/ty/context.rs b/compiler/rustc_middle/src/ty/context.rs
index 2ba1bf2822fbb..ae8ff331d1b5e 100644
--- a/compiler/rustc_middle/src/ty/context.rs
+++ b/compiler/rustc_middle/src/ty/context.rs
@@ -1644,7 +1644,7 @@ impl<'tcx> TyCtxt<'tcx> {
         )
     }
 
-    pub fn stability(self) -> &'tcx stability::Index {
+    pub fn stability(self) -> &'tcx stability::Index<'tcx> {
         self.stability_index(())
     }
 
@@ -2969,22 +2969,22 @@ impl<'tcx> TyCtxt<'tcx> {
         self,
         diag: &mut Diag<'_, E>,
         hir_id: Option<HirId>,
-        features: impl IntoIterator<Item = (String, Symbol)>,
+        featuresets: impl IntoIterator<Item = (String, impl std::fmt::Display)>,
     ) {
         if !self.sess.is_nightly_build() {
             return;
         }
 
         let span = hir_id.and_then(|id| self.crate_level_attribute_injection_span(id));
-        for (desc, feature) in features {
+        for (desc, features) in featuresets {
             // FIXME: make this string translatable
             let msg =
-                format!("add `#![feature({feature})]` to the crate attributes to enable{desc}");
+                format!("add `#![feature({features})]` to the crate attributes to enable{desc}");
             if let Some(span) = span {
                 diag.span_suggestion_verbose(
                     span,
                     msg,
-                    format!("#![feature({feature})]\n"),
+                    format!("#![feature({features})]\n"),
                     Applicability::MaybeIncorrect,
                 );
             } else {
diff --git a/compiler/rustc_passes/Cargo.toml b/compiler/rustc_passes/Cargo.toml
index ed5991459ac1b..3e44cc469521c 100644
--- a/compiler/rustc_passes/Cargo.toml
+++ b/compiler/rustc_passes/Cargo.toml
@@ -24,5 +24,6 @@ rustc_session = { path = "../rustc_session" }
 rustc_span = { path = "../rustc_span" }
 rustc_target = { path = "../rustc_target" }
 rustc_trait_selection = { path = "../rustc_trait_selection" }
+smallvec = { version = "1.8.1", features = ["union", "may_dangle"] }
 tracing = "0.1"
 # tidy-alphabetical-end
diff --git a/compiler/rustc_passes/src/errors.rs b/compiler/rustc_passes/src/errors.rs
index 2d1734c031433..e9cbe87838975 100644
--- a/compiler/rustc_passes/src/errors.rs
+++ b/compiler/rustc_passes/src/errors.rs
@@ -1507,7 +1507,7 @@ pub(crate) struct DeprecatedAttribute {
 pub(crate) struct UselessStability {
     #[primary_span]
     #[label]
-    pub span: Span,
+    pub spans: Vec<Span>,
     #[label(passes_item)]
     pub item_sp: Span,
 }
diff --git a/compiler/rustc_passes/src/lib_features.rs b/compiler/rustc_passes/src/lib_features.rs
index 8a360c017adbf..41a5a928de1da 100644
--- a/compiler/rustc_passes/src/lib_features.rs
+++ b/compiler/rustc_passes/src/lib_features.rs
@@ -75,10 +75,6 @@ impl<'tcx> LibFeatureCollector<'tcx> {
                         return Some((feature, FeatureStability::AcceptedSince(since), attr.span));
                     }
                 }
-                // We need to iterate over the other attributes, because
-                // `rustc_const_unstable` is not mutually exclusive with
-                // the other stability attributes, so we can't just `break`
-                // here.
             }
         }
 
diff --git a/compiler/rustc_passes/src/stability.rs b/compiler/rustc_passes/src/stability.rs
index 4a793f1875ec8..22181bcf2e035 100644
--- a/compiler/rustc_passes/src/stability.rs
+++ b/compiler/rustc_passes/src/stability.rs
@@ -6,7 +6,7 @@ use std::num::NonZero;
 
 use rustc_attr::{
     self as attr, ConstStability, DeprecatedSince, Stability, StabilityLevel, StableSince,
-    Unstable, UnstableReason, VERSION_PLACEHOLDER,
+    Unstability, Unstable, UnstableReason, VERSION_PLACEHOLDER,
 };
 use rustc_data_structures::fx::FxIndexMap;
 use rustc_data_structures::unord::{ExtendUnord, UnordMap, UnordSet};
@@ -27,6 +27,7 @@ use rustc_session::lint;
 use rustc_session::lint::builtin::{INEFFECTIVE_UNSTABLE_TRAIT_IMPL, USELESS_DEPRECATED};
 use rustc_span::Span;
 use rustc_span::symbol::{Symbol, sym};
+use smallvec::smallvec;
 use tracing::{debug, info};
 
 use crate::errors;
@@ -90,9 +91,9 @@ impl InheritStability {
 /// A private tree-walker for producing an `Index`.
 struct Annotator<'a, 'tcx> {
     tcx: TyCtxt<'tcx>,
-    index: &'a mut Index,
-    parent_stab: Option<Stability>,
-    parent_const_stab: Option<ConstStability>,
+    index: &'a mut Index<'tcx>,
+    parent_stab: Option<&'tcx Stability>,
+    parent_const_stab: Option<&'tcx ConstStability>,
     parent_depr: Option<DeprecationEntry>,
     in_trait_impl: bool,
 }
@@ -148,11 +149,11 @@ impl<'a, 'tcx> Annotator<'a, 'tcx> {
             // -Zforce-unstable-if-unmarked is set.
             if let Some(stab) = self.parent_stab {
                 if inherit_deprecation.yes() && stab.is_unstable() {
-                    self.index.stab_map.insert(def_id, stab);
+                    self.index.stab_map.insert(def_id, &stab);
                     if fn_sig.is_some_and(|s| s.header.is_const()) {
                         let const_stab =
-                            attr::unmarked_crate_const_stab(self.tcx.sess, attrs, stab);
-                        self.index.const_stab_map.insert(def_id, const_stab);
+                            attr::unmarked_crate_const_stab(self.tcx.sess, attrs, stab.clone());
+                        self.index.const_stab_map.insert(def_id, self.tcx.arena.alloc(const_stab));
                     }
                 }
             }
@@ -178,30 +179,35 @@ impl<'a, 'tcx> Annotator<'a, 'tcx> {
             self.tcx.dcx().emit_err(errors::DeprecatedAttribute { span: *span });
         }
 
-        if let Some((body_stab, _span)) = body_stab {
+        if let Some((body_stab, _spans)) = body_stab {
             // FIXME: check that this item can have body stability
 
-            self.index.default_body_stab_map.insert(def_id, body_stab);
+            self.index.default_body_stab_map.insert(def_id, self.tcx.arena.alloc(body_stab));
             debug!(?self.index.default_body_stab_map);
         }
 
-        let stab = stab.map(|(stab, span)| {
+        let stab = stab.map(|(stab, stab_spans)| {
             // Error if prohibited, or can't inherit anything from a container.
             if kind == AnnotationKind::Prohibited
                 || (kind == AnnotationKind::Container && stab.level.is_stable() && is_deprecated)
             {
-                self.tcx.dcx().emit_err(errors::UselessStability { span, item_sp });
+                let spans = stab_spans.spans();
+                self.tcx.dcx().emit_err(errors::UselessStability { spans, item_sp });
             }
 
             debug!("annotate: found {:?}", stab);
 
             // Check if deprecated_since < stable_since. If it is,
             // this is *almost surely* an accident.
-            if let (
-                &Some(DeprecatedSince::RustcVersion(dep_since)),
-                &attr::Stable { since: stab_since, .. },
-            ) = (&depr.as_ref().map(|(d, _)| d.since), &stab.level)
+            if let &Some(DeprecatedSince::RustcVersion(dep_since)) =
+                &depr.as_ref().map(|(d, _)| d.since)
+                && let Some(stab_since) = stab.stable_since()
             {
+                let &(_, span) = stab_spans
+                    .iter()
+                    .find(|(level, _)| level.stable_since() == Some(stab_since))
+                    .expect("stabilization version should have an associated span");
+
                 match stab_since {
                     StableSince::Current => {
                         self.tcx
@@ -224,19 +230,24 @@ impl<'a, 'tcx> Annotator<'a, 'tcx> {
 
             // Stable *language* features shouldn't be used as unstable library features.
             // (Not doing this for stable library features is checked by tidy.)
-            if let Stability { level: Unstable { .. }, feature } = stab {
-                if ACCEPTED_LANG_FEATURES.iter().find(|f| f.name == feature).is_some() {
-                    self.tcx
-                        .dcx()
-                        .emit_err(errors::UnstableAttrForAlreadyStableFeature { span, item_sp });
+            for (level, span) in stab_spans.iter() {
+                if ACCEPTED_LANG_FEATURES.iter().any(|f| level.has_unstable_feature(f.name)) {
+                    self.tcx.dcx().emit_err(errors::UnstableAttrForAlreadyStableFeature {
+                        span: *span,
+                        item_sp,
+                    });
                 }
             }
-            if let Stability { level: Unstable { implied_by: Some(implied_by), .. }, feature } =
-                stab
-            {
-                self.index.implications.insert(implied_by, feature);
+
+            if let Unstable { unstables, .. } = &stab.level {
+                for &Unstability { feature, implied_by, .. } in unstables {
+                    if let Some(implied_by) = implied_by {
+                        self.index.implications.insert(implied_by, feature);
+                    }
+                }
             }
 
+            let stab: &_ = self.tcx.arena.alloc(stab);
             self.index.stab_map.insert(def_id, stab);
             stab
         });
@@ -266,9 +277,10 @@ impl<'a, 'tcx> Annotator<'a, 'tcx> {
         }
 
         // If this is marked const *stable*, it must also be regular-stable.
-        if let Some((const_stab, const_span)) = const_stab
+        if let Some((const_stab, const_spans)) = &const_stab
             && let Some(fn_sig) = fn_sig
             && const_stab.is_const_stable()
+            && let Some(&(_, const_span)) = const_spans.iter().find(|(level, _)| level.is_stable())
             && !stab.is_some_and(|s| s.is_stable())
         {
             self.tcx
@@ -278,14 +290,14 @@ impl<'a, 'tcx> Annotator<'a, 'tcx> {
 
         // Stable *language* features shouldn't be used as unstable library features.
         // (Not doing this for stable library features is checked by tidy.)
-        if let Some((ConstStability { level: Unstable { .. }, feature, .. }, const_span)) =
-            const_stab
-        {
-            if ACCEPTED_LANG_FEATURES.iter().find(|f| f.name == feature).is_some() {
-                self.tcx.dcx().emit_err(errors::UnstableAttrForAlreadyStableFeature {
-                    span: const_span,
-                    item_sp,
-                });
+        if let Some((_, const_spans)) = &const_stab {
+            for (level, const_span) in const_spans.iter() {
+                if ACCEPTED_LANG_FEATURES.iter().any(|f| level.has_unstable_feature(f.name)) {
+                    self.tcx.dcx().emit_err(errors::UnstableAttrForAlreadyStableFeature {
+                        span: *const_span,
+                        item_sp,
+                    });
+                }
             }
         }
 
@@ -303,23 +315,23 @@ impl<'a, 'tcx> Annotator<'a, 'tcx> {
                 // We subject these implicitly-const functions to recursive const stability.
                 const_stable_indirect: true,
                 promotable: false,
-                level: inherit_regular_stab.level,
-                feature: inherit_regular_stab.feature,
+                level: inherit_regular_stab.level.clone(),
             });
         }
 
         // Now that everything is computed, insert it into the table.
-        const_stab.inspect(|const_stab| {
-            self.index.const_stab_map.insert(def_id, *const_stab);
+        let const_stab = const_stab.map(|const_stab| {
+            let const_stab = self.tcx.arena.alloc(const_stab) as &_;
+            self.index.const_stab_map.insert(def_id, const_stab);
+            const_stab
         });
 
-        if let Some(ConstStability {
-            level: Unstable { implied_by: Some(implied_by), .. },
-            feature,
-            ..
-        }) = const_stab
-        {
-            self.index.implications.insert(implied_by, feature);
+        if let Some(ConstStability { level: Unstable { unstables, .. }, .. }) = const_stab {
+            for &Unstability { feature, implied_by, .. } in unstables {
+                if let Some(implied_by) = implied_by {
+                    self.index.implications.insert(implied_by, feature);
+                }
+            }
         }
 
         // `impl const Trait for Type` items forward their const stability to their
@@ -346,8 +358,8 @@ impl<'a, 'tcx> Annotator<'a, 'tcx> {
     fn recurse_with_stability_attrs(
         &mut self,
         depr: Option<DeprecationEntry>,
-        stab: Option<Stability>,
-        const_stab: Option<ConstStability>,
+        stab: Option<&'tcx Stability>,
+        const_stab: Option<&'tcx ConstStability>,
         f: impl FnOnce(&mut Self),
     ) {
         // These will be `Some` if this item changes the corresponding stability attribute.
@@ -674,7 +686,7 @@ impl<'tcx> Visitor<'tcx> for MissingStabilityAnnotations<'tcx> {
     // stable (assuming they have not inherited instability from their parent).
 }
 
-fn stability_index(tcx: TyCtxt<'_>, (): ()) -> Index {
+fn stability_index(tcx: TyCtxt<'_>, (): ()) -> Index<'_> {
     let mut index = Index {
         stab_map: Default::default(),
         const_stab_map: Default::default(),
@@ -700,15 +712,17 @@ fn stability_index(tcx: TyCtxt<'_>, (): ()) -> Index {
         // while maintaining the invariant that all sysroot crates are unstable
         // by default and are unable to be used.
         if tcx.sess.opts.unstable_opts.force_unstable_if_unmarked {
-            let stability = Stability {
+            let stability = tcx.arena.alloc(Stability {
                 level: attr::StabilityLevel::Unstable {
+                    unstables: smallvec![attr::Unstability {
+                        feature: sym::rustc_private,
+                        issue: NonZero::new(27812),
+                        implied_by: None,
+                    },],
                     reason: UnstableReason::Default,
-                    issue: NonZero::new(27812),
                     is_soft: false,
-                    implied_by: None,
                 },
-                feature: sym::rustc_private,
-            };
+            });
             annotator.parent_stab = Some(stability);
         }
 
@@ -789,7 +803,7 @@ impl<'tcx> Visitor<'tcx> for Checker<'tcx> {
                     // error if all involved types and traits are stable, because
                     // it will have no effect.
                     // See: https://github.com/rust-lang/rust/issues/55436
-                    if let Some((Stability { level: attr::Unstable { .. }, .. }, span)) = stab {
+                    if let Some((Stability { level: attr::Unstable { .. }, .. }, spans)) = stab {
                         let mut c = CheckTraitImplStable { tcx: self.tcx, fully_stable: true };
                         c.visit_ty(self_ty);
                         c.visit_trait_ref(t);
@@ -800,7 +814,7 @@ impl<'tcx> Visitor<'tcx> for Checker<'tcx> {
                             self.tcx.emit_node_span_lint(
                                 INEFFECTIVE_UNSTABLE_TRAIT_IMPL,
                                 item.hir_id(),
-                                span,
+                                spans.spans(),
                                 errors::IneffectiveUnstableImpl,
                             );
                         }
diff --git a/compiler/rustc_resolve/src/macros.rs b/compiler/rustc_resolve/src/macros.rs
index 0b4d0e04c295c..a02b26112a40e 100644
--- a/compiler/rustc_resolve/src/macros.rs
+++ b/compiler/rustc_resolve/src/macros.rs
@@ -7,7 +7,7 @@ use std::mem;
 use rustc_ast::expand::StrippedCfgItem;
 use rustc_ast::{self as ast, Crate, Inline, ItemKind, ModKind, NodeId, attr};
 use rustc_ast_pretty::pprust;
-use rustc_attr::StabilityLevel;
+use rustc_attr::{StabilityLevel, Unstability};
 use rustc_data_structures::intern::Interned;
 use rustc_data_structures::sync::Lrc;
 use rustc_errors::{Applicability, StashKey};
@@ -1002,37 +1002,41 @@ impl<'ra, 'tcx> Resolver<'ra, 'tcx> {
         node_id: NodeId,
     ) {
         let span = path.span;
-        if let Some(stability) = &ext.stability {
-            if let StabilityLevel::Unstable { reason, issue, is_soft, implied_by } = stability.level
-            {
-                let feature = stability.feature;
-
-                let is_allowed =
-                    |feature| self.tcx.features().enabled(feature) || span.allows_unstable(feature);
-                let allowed_by_implication = implied_by.is_some_and(|feature| is_allowed(feature));
-                if !is_allowed(feature) && !allowed_by_implication {
-                    let lint_buffer = &mut self.lint_buffer;
-                    let soft_handler = |lint, span, msg: String| {
-                        lint_buffer.buffer_lint(
-                            lint,
-                            node_id,
-                            span,
-                            BuiltinLintDiag::UnstableFeature(
-                                // FIXME make this translatable
-                                msg.into(),
-                            ),
-                        )
-                    };
+        if let Some(stability) = &ext.stability
+            && let StabilityLevel::Unstable { unstables, reason, is_soft } = &stability.level
+        {
+            let is_allowed =
+                |feature| self.tcx.features().enabled(feature) || span.allows_unstable(feature);
+            let allowed_by_implication = |unstability: &Unstability| {
+                unstability.implied_by.is_some_and(|feature| is_allowed(feature))
+            };
+
+            let (missing_features, issues): (Vec<_>, Vec<_>) = unstables
+                .iter()
+                .filter(|u| !is_allowed(u.feature) && !allowed_by_implication(u))
+                .map(|u| (u.feature, u.issue))
+                .unzip();
+
+            if !missing_features.is_empty() {
+                if *is_soft {
+                    self.lint_buffer.buffer_lint(
+                        SOFT_UNSTABLE,
+                        node_id,
+                        span,
+                        BuiltinLintDiag::SoftUnstableMacro {
+                            features: missing_features,
+                            reason: reason.to_opt_reason(),
+                        },
+                    );
+                } else {
                     stability::report_unstable(
                         self.tcx.sess,
-                        feature,
+                        missing_features,
                         reason.to_opt_reason(),
-                        issue,
-                        None,
-                        is_soft,
+                        issues.into_iter().flatten().collect(),
+                        vec![],
                         span,
-                        soft_handler,
-                    );
+                    )
                 }
             }
         }
diff --git a/compiler/rustc_session/src/errors.rs b/compiler/rustc_session/src/errors.rs
index 33f84f104474d..6521d22632f4e 100644
--- a/compiler/rustc_session/src/errors.rs
+++ b/compiler/rustc_session/src/errors.rs
@@ -53,7 +53,7 @@ impl SuggestUpgradeCompiler {
 #[derive(Subdiagnostic)]
 #[help(session_feature_diagnostic_help)]
 pub(crate) struct FeatureDiagnosticHelp {
-    pub(crate) feature: Symbol,
+    pub(crate) feature: String,
 }
 
 #[derive(Subdiagnostic)]
@@ -63,7 +63,7 @@ pub(crate) struct FeatureDiagnosticHelp {
     code = "#![feature({feature})]\n"
 )]
 pub struct FeatureDiagnosticSuggestion {
-    pub feature: Symbol,
+    pub feature: String,
     #[primary_span]
     pub span: Span,
 }
@@ -71,7 +71,7 @@ pub struct FeatureDiagnosticSuggestion {
 #[derive(Subdiagnostic)]
 #[help(session_cli_feature_diagnostic_help)]
 pub(crate) struct CliFeatureDiagnosticHelp {
-    pub(crate) feature: Symbol,
+    pub(crate) feature: String,
 }
 
 #[derive(Diagnostic)]
diff --git a/compiler/rustc_session/src/parse.rs b/compiler/rustc_session/src/parse.rs
index 21c1165511099..c65cb890963b1 100644
--- a/compiler/rustc_session/src/parse.rs
+++ b/compiler/rustc_session/src/parse.rs
@@ -12,7 +12,7 @@ use rustc_errors::{
     ColorConfig, Diag, DiagCtxt, DiagCtxtHandle, DiagMessage, EmissionGuarantee, MultiSpan,
     StashKey, fallback_fluent_bundle,
 };
-use rustc_feature::{GateIssue, UnstableFeatures, find_feature_issue};
+use rustc_feature::{GateIssues, UnstableFeatures, find_feature_issues};
 use rustc_span::edition::Edition;
 use rustc_span::hygiene::ExpnId;
 use rustc_span::source_map::{FilePathMapping, SourceMap};
@@ -87,7 +87,7 @@ pub fn feature_err(
     span: impl Into<MultiSpan>,
     explain: impl Into<DiagMessage>,
 ) -> Diag<'_> {
-    feature_err_issue(sess, feature, span, GateIssue::Language, explain)
+    feature_err_issues(sess, &[feature], span, GateIssues::Language, explain)
 }
 
 /// Construct a diagnostic for a feature gate error.
@@ -95,13 +95,13 @@ pub fn feature_err(
 /// This variant allows you to control whether it is a library or language feature.
 /// Almost always, you want to use this for a language feature. If so, prefer `feature_err`.
 #[track_caller]
-pub fn feature_err_issue(
-    sess: &Session,
-    feature: Symbol,
+pub fn feature_err_issues<'a>(
+    sess: &'a Session,
+    features: &[Symbol],
     span: impl Into<MultiSpan>,
-    issue: GateIssue,
+    issues: GateIssues,
     explain: impl Into<DiagMessage>,
-) -> Diag<'_> {
+) -> Diag<'a> {
     let span = span.into();
 
     // Cancel an earlier warning for this same error, if it exists.
@@ -112,7 +112,7 @@ pub fn feature_err_issue(
     }
 
     let mut err = sess.dcx().create_err(FeatureGateError { span, explain: explain.into() });
-    add_feature_diagnostics_for_issue(&mut err, sess, feature, issue, false, None);
+    add_feature_diagnostics_for_issues(&mut err, sess, features, issues, false, None);
     err
 }
 
@@ -121,7 +121,7 @@ pub fn feature_err_issue(
 /// This diagnostic is only a warning and *does not cause compilation to fail*.
 #[track_caller]
 pub fn feature_warn(sess: &Session, feature: Symbol, span: Span, explain: &'static str) {
-    feature_warn_issue(sess, feature, span, GateIssue::Language, explain);
+    feature_warn_issues(sess, &[feature], span, GateIssues::Language, explain);
 }
 
 /// Construct a future incompatibility diagnostic for a feature gate.
@@ -133,15 +133,15 @@ pub fn feature_warn(sess: &Session, feature: Symbol, span: Span, explain: &'stat
 #[allow(rustc::diagnostic_outside_of_impl)]
 #[allow(rustc::untranslatable_diagnostic)]
 #[track_caller]
-pub fn feature_warn_issue(
+pub fn feature_warn_issues(
     sess: &Session,
-    feature: Symbol,
+    features: &[Symbol],
     span: Span,
-    issue: GateIssue,
+    issues: GateIssues,
     explain: &'static str,
 ) {
     let mut err = sess.dcx().struct_span_warn(span, explain);
-    add_feature_diagnostics_for_issue(&mut err, sess, feature, issue, false, None);
+    add_feature_diagnostics_for_issues(&mut err, sess, features, issues, false, None);
 
     // Decorate this as a future-incompatibility lint as in rustc_middle::lint::lint_level
     let lint = UNSTABLE_SYNTAX_PRE_EXPANSION;
@@ -161,7 +161,7 @@ pub fn add_feature_diagnostics<G: EmissionGuarantee>(
     sess: &Session,
     feature: Symbol,
 ) {
-    add_feature_diagnostics_for_issue(err, sess, feature, GateIssue::Language, false, None);
+    add_feature_diagnostics_for_issues(err, sess, &[feature], GateIssues::Language, false, None);
 }
 
 /// Adds the diagnostics for a feature to an existing error.
@@ -170,20 +170,21 @@ pub fn add_feature_diagnostics<G: EmissionGuarantee>(
 /// Almost always, you want to use this for a language feature. If so, prefer
 /// `add_feature_diagnostics`.
 #[allow(rustc::diagnostic_outside_of_impl)] // FIXME
-pub fn add_feature_diagnostics_for_issue<G: EmissionGuarantee>(
+pub fn add_feature_diagnostics_for_issues<G: EmissionGuarantee>(
     err: &mut Diag<'_, G>,
     sess: &Session,
-    feature: Symbol,
-    issue: GateIssue,
+    features: &[Symbol],
+    issues: GateIssues,
     feature_from_cli: bool,
     inject_span: Option<Span>,
 ) {
-    if let Some(n) = find_feature_issue(feature, issue) {
+    for n in find_feature_issues(features, issues) {
         err.subdiagnostic(FeatureDiagnosticForIssue { n });
     }
 
     // #23973: do not suggest `#![feature(...)]` if we are in beta/stable
     if sess.psess.unstable_features.is_nightly_build() {
+        let feature: String = features.iter().map(|s| s.as_str()).intersperse(", ").collect();
         if feature_from_cli {
             err.subdiagnostic(CliFeatureDiagnosticHelp { feature });
         } else if let Some(span) = inject_span {
diff --git a/compiler/rustc_session/src/session.rs b/compiler/rustc_session/src/session.rs
index 29fabdd1deb8d..d138dd2a368fb 100644
--- a/compiler/rustc_session/src/session.rs
+++ b/compiler/rustc_session/src/session.rs
@@ -25,6 +25,7 @@ use rustc_errors::{
     Diag, DiagCtxt, DiagCtxtHandle, DiagMessage, Diagnostic, ErrorGuaranteed, FatalAbort,
     FluentBundle, LazyFallbackBundle, TerminalUrl, fallback_fluent_bundle,
 };
+use rustc_feature::GateIssues;
 use rustc_macros::HashStable_Generic;
 pub use rustc_span::def_id::StableCrateId;
 use rustc_span::edition::Edition;
@@ -45,7 +46,7 @@ use crate::config::{
     SwitchWithOptPath,
 };
 use crate::filesearch::FileSearch;
-use crate::parse::{ParseSess, add_feature_diagnostics};
+use crate::parse::{ParseSess, add_feature_diagnostics_for_issues};
 use crate::search_paths::SearchPath;
 use crate::{errors, filesearch, lint};
 
@@ -309,12 +310,29 @@ impl Session {
     /// `feature` must be a language feature.
     #[track_caller]
     pub fn create_feature_err<'a>(&'a self, err: impl Diagnostic<'a>, feature: Symbol) -> Diag<'a> {
+        self.create_features_err(err, &[feature])
+    }
+
+    /// `features` must be language features.
+    #[track_caller]
+    pub fn create_features_err<'a>(
+        &'a self,
+        err: impl Diagnostic<'a>,
+        features: &[Symbol],
+    ) -> Diag<'a> {
         let mut err = self.dcx().create_err(err);
         if err.code.is_none() {
             #[allow(rustc::diagnostic_outside_of_impl)]
             err.code(E0658);
         }
-        add_feature_diagnostics(&mut err, self, feature);
+        add_feature_diagnostics_for_issues(
+            &mut err,
+            self,
+            features,
+            GateIssues::Language,
+            false,
+            None,
+        );
         err
     }
 
diff --git a/src/librustdoc/clean/inline.rs b/src/librustdoc/clean/inline.rs
index 5ddd6188c2d86..4a4c75f973f20 100644
--- a/src/librustdoc/clean/inline.rs
+++ b/src/librustdoc/clean/inline.rs
@@ -432,10 +432,8 @@ pub(crate) fn build_impl(
     let associated_trait = tcx.impl_trait_ref(did).map(ty::EarlyBinder::skip_binder);
 
     // Do not inline compiler-internal items unless we're a compiler-internal crate.
-    let is_compiler_internal = |did| {
-        tcx.lookup_stability(did)
-            .is_some_and(|stab| stab.is_unstable() && stab.feature == sym::rustc_private)
-    };
+    let is_compiler_internal =
+        |did| tcx.lookup_stability(did).is_some_and(|stab| stab.is_rustc_private());
     let document_compiler_internal = is_compiler_internal(LOCAL_CRATE.as_def_id());
     let is_directly_public = |cx: &mut DocContext<'_>, did| {
         cx.cache.effective_visibilities.is_directly_public(tcx, did)
diff --git a/src/librustdoc/clean/types.rs b/src/librustdoc/clean/types.rs
index a10a6a92bf57e..c321673d9848e 100644
--- a/src/librustdoc/clean/types.rs
+++ b/src/librustdoc/clean/types.rs
@@ -385,8 +385,8 @@ impl Item {
     /// Returns the effective stability of the item.
     ///
     /// This method should only be called after the `propagate-stability` pass has been run.
-    pub(crate) fn stability(&self, tcx: TyCtxt<'_>) -> Option<Stability> {
-        let stability = self.inner.stability;
+    pub(crate) fn stability(&self, tcx: TyCtxt<'_>) -> Option<&Stability> {
+        let stability = self.inner.stability.as_ref();
         debug_assert!(
             stability.is_some()
                 || self.def_id().is_none_or(|did| tcx.lookup_stability(did).is_none()),
@@ -395,7 +395,7 @@ impl Item {
         stability
     }
 
-    pub(crate) fn const_stability(&self, tcx: TyCtxt<'_>) -> Option<ConstStability> {
+    pub(crate) fn const_stability<'tcx>(&self, tcx: TyCtxt<'tcx>) -> Option<&'tcx ConstStability> {
         self.def_id().and_then(|did| tcx.lookup_const_stability(did))
     }
 
diff --git a/src/librustdoc/html/format.rs b/src/librustdoc/html/format.rs
index 2f9e7976ca142..dcb36ce32946d 100644
--- a/src/librustdoc/html/format.rs
+++ b/src/librustdoc/html/format.rs
@@ -1649,7 +1649,7 @@ impl PrintWithSpace for hir::Mutability {
 pub(crate) fn print_constness_with_space(
     c: &hir::Constness,
     overall_stab: Option<StableSince>,
-    const_stab: Option<ConstStability>,
+    const_stab: Option<&ConstStability>,
 ) -> &'static str {
     match c {
         hir::Constness::Const => match (overall_stab, const_stab) {
diff --git a/src/librustdoc/html/render/mod.rs b/src/librustdoc/html/render/mod.rs
index f11356b44d885..e4962200e3e6d 100644
--- a/src/librustdoc/html/render/mod.rs
+++ b/src/librustdoc/html/render/mod.rs
@@ -53,7 +53,7 @@ use rustc_hir::def_id::{DefId, DefIdSet};
 use rustc_middle::ty::print::PrintTraitRefExt;
 use rustc_middle::ty::{self, TyCtxt};
 use rustc_session::RustcVersion;
-use rustc_span::symbol::{Symbol, sym};
+use rustc_span::symbol::Symbol;
 use rustc_span::{BytePos, DUMMY_SP, FileName, RealFileName};
 use serde::ser::SerializeMap;
 use serde::{Serialize, Serializer};
@@ -675,17 +675,23 @@ enum ShortItemInfo {
     Deprecation {
         message: String,
     },
-    /// The feature corresponding to an unstable item, and optionally
-    /// a tracking issue URL and number.
+    /// The features corresponding to an unstable item, and optionally
+    /// a tracking issue URL and number for each.
     Unstable {
-        feature: String,
-        tracking: Option<(String, u32)>,
+        features: Vec<UnstableFeature>,
     },
     Portability {
         message: String,
     },
 }
 
+#[derive(Template)]
+#[template(path = "unstable_feature.html")]
+struct UnstableFeature {
+    feature: String,
+    tracking: Option<(String, u32)>,
+}
+
 /// Render the stability, deprecation and portability information that is displayed at the top of
 /// the item's documentation.
 fn short_item_info(
@@ -724,19 +730,24 @@ fn short_item_info(
 
     // Render unstable items. But don't render "rustc_private" crates (internal compiler crates).
     // Those crates are permanently unstable so it makes no sense to render "unstable" everywhere.
-    if let Some((StabilityLevel::Unstable { reason: _, issue, .. }, feature)) = item
+    if let Some(StabilityLevel::Unstable { unstables, .. }) = item
         .stability(cx.tcx())
         .as_ref()
-        .filter(|stab| stab.feature != sym::rustc_private)
-        .map(|stab| (stab.level, stab.feature))
+        .filter(|stab| !stab.is_rustc_private())
+        .map(|stab| &stab.level)
     {
-        let tracking = if let (Some(url), Some(issue)) = (&cx.shared.issue_tracker_base_url, issue)
-        {
-            Some((url.clone(), issue.get()))
-        } else {
-            None
+        let track = |issue: Option<std::num::NonZero<u32>>| {
+            if let (Some(url), Some(issue)) = (&cx.shared.issue_tracker_base_url, issue) {
+                Some((url.clone(), issue.get()))
+            } else {
+                None
+            }
         };
-        extra_info.push(ShortItemInfo::Unstable { feature: feature.to_string(), tracking });
+        let features = unstables
+            .iter()
+            .map(|u| UnstableFeature { feature: u.feature.to_string(), tracking: track(u.issue) })
+            .collect();
+        extra_info.push(ShortItemInfo::Unstable { features });
     }
 
     if let Some(message) = portability(item, parent) {
@@ -990,7 +1001,7 @@ fn assoc_method(
 fn render_stability_since_raw_with_extra(
     w: &mut Buffer,
     stable_version: Option<StableSince>,
-    const_stability: Option<ConstStability>,
+    const_stability: Option<&ConstStability>,
     extra_class: &str,
 ) -> bool {
     let mut title = String::new();
@@ -1006,12 +1017,16 @@ fn render_stability_since_raw_with_extra(
             since_to_string(&since)
                 .map(|since| (format!("const since {since}"), format!("const: {since}")))
         }
-        Some(ConstStability { level: StabilityLevel::Unstable { issue, .. }, feature, .. }) => {
+        Some(ConstStability { level: StabilityLevel::Unstable { unstables, .. }, .. }) => {
             if stable_version.is_none() {
                 // don't display const unstable if entirely unstable
                 None
             } else {
-                let unstable = if let Some(n) = issue {
+                // if constness depends on multiple unstable features, only link to the first
+                // tracking issue found, to save space. the issue description should link to issues
+                // for any features it can intersect with
+                let feature_issue = unstables.iter().find_map(|u| u.issue.map(|n| (u.feature, n)));
+                let unstable = if let Some((feature, n)) = feature_issue {
                     format!(
                         "<a \
                         href=\"https://github.com/rust-lang/rust/issues/{n}\" \
@@ -1061,7 +1076,7 @@ fn since_to_string(since: &StableSince) -> Option<String> {
 fn render_stability_since_raw(
     w: &mut Buffer,
     ver: Option<StableSince>,
-    const_stability: Option<ConstStability>,
+    const_stability: Option<&ConstStability>,
 ) -> bool {
     render_stability_since_raw_with_extra(w, ver, const_stability, "")
 }
diff --git a/src/librustdoc/html/render/print_item.rs b/src/librustdoc/html/render/print_item.rs
index d247e90d298db..6ce2b759ec2e2 100644
--- a/src/librustdoc/html/render/print_item.rs
+++ b/src/librustdoc/html/render/print_item.rs
@@ -14,7 +14,7 @@ use rustc_hir::def_id::DefId;
 use rustc_index::IndexVec;
 use rustc_middle::ty::{self, TyCtxt};
 use rustc_span::hygiene::MacroKind;
-use rustc_span::symbol::{Symbol, kw, sym};
+use rustc_span::symbol::{Symbol, kw};
 use tracing::{debug, info};
 
 use super::type_layout::document_type_layout;
@@ -567,7 +567,7 @@ fn extra_info_tags<'a, 'tcx: 'a>(
         // to render "unstable" everywhere.
         let stability = import_def_id
             .map_or_else(|| item.stability(tcx), |import_did| tcx.lookup_stability(import_did));
-        if stability.is_some_and(|s| s.is_unstable() && s.feature != sym::rustc_private) {
+        if stability.is_some_and(|s| s.is_unstable() && !s.is_rustc_private()) {
             write!(f, "{}", tag_html("unstable", "", "Experimental"))?;
         }
 
diff --git a/src/librustdoc/html/templates/short_item_info.html b/src/librustdoc/html/templates/short_item_info.html
index e76b98541cf61..e1ddc21a3ad35 100644
--- a/src/librustdoc/html/templates/short_item_info.html
+++ b/src/librustdoc/html/templates/short_item_info.html
@@ -4,18 +4,11 @@
             <span class="emoji">👎</span> {# #}
             <span>{{message|safe}}</span> {# #}
         </div>
-    {% when Self::Unstable with { feature, tracking } %}
+    {% when Self::Unstable with { features } %}
         <div class="stab unstable"> {# #}
             <span class="emoji">🔬</span> {# #}
             <span> {# #}
-                This is a nightly-only experimental API. ({# #}
-                <code>{{feature}}</code>
-                {% match tracking %}
-                    {% when Some with ((url, num)) %}
-                        &nbsp;<a href="{{url}}{{num}}">#{{num}}</a>
-                    {% when None %}
-                {% endmatch %}
-                ) {# #}
+                This is a nightly-only experimental API. ({{features|join(", ")|safe}}) {# #}
             </span> {# #}
         </div>
     {% when Self::Portability with { message } %}
diff --git a/src/librustdoc/html/templates/unstable_feature.html b/src/librustdoc/html/templates/unstable_feature.html
new file mode 100644
index 0000000000000..bd7674cea9628
--- /dev/null
+++ b/src/librustdoc/html/templates/unstable_feature.html
@@ -0,0 +1,6 @@
+<code>{{feature}}</code>
+{% match tracking %}
+    {% when Some with ((url, num)) %}
+        &nbsp;<a href="{{url}}{{num}}">#{{num}}</a>
+    {% when None %}
+{% endmatch %}
diff --git a/src/librustdoc/passes/propagate_stability.rs b/src/librustdoc/passes/propagate_stability.rs
index a28487cc79e5d..02c615763f6bb 100644
--- a/src/librustdoc/passes/propagate_stability.rs
+++ b/src/librustdoc/passes/propagate_stability.rs
@@ -21,7 +21,7 @@ pub(crate) const PROPAGATE_STABILITY: Pass = Pass {
 };
 
 pub(crate) fn propagate_stability(cr: Crate, cx: &mut DocContext<'_>) -> Crate {
-    let crate_stability = cx.tcx.lookup_stability(CRATE_DEF_ID);
+    let crate_stability = cx.tcx.lookup_stability(CRATE_DEF_ID).cloned();
     StabilityPropagator { parent_stability: crate_stability, cx }.fold_crate(cr)
 }
 
@@ -32,8 +32,6 @@ struct StabilityPropagator<'a, 'tcx> {
 
 impl<'a, 'tcx> DocFolder for StabilityPropagator<'a, 'tcx> {
     fn fold_item(&mut self, mut item: Item) -> Option<Item> {
-        let parent_stability = self.parent_stability;
-
         let stability = match item.item_id {
             ItemId::DefId(def_id) => {
                 let own_stability = self.cx.tcx.lookup_stability(def_id);
@@ -59,9 +57,7 @@ impl<'a, 'tcx> DocFolder for StabilityPropagator<'a, 'tcx> {
                     | ItemKind::MacroItem(..)
                     | ItemKind::ProcMacroItem(..)
                     | ItemKind::ConstantItem(..) => {
-                        // If any of the item's parents was stabilized later or is still unstable,
-                        // then use the parent's stability instead.
-                        merge_stability(own_stability, parent_stability)
+                        merge_stability(own_stability, self.parent_stability.as_ref())
                     }
 
                     // Don't inherit the parent's stability for these items, because they
@@ -74,7 +70,7 @@ impl<'a, 'tcx> DocFolder for StabilityPropagator<'a, 'tcx> {
                     | ItemKind::TyAssocTypeItem(..)
                     | ItemKind::AssocTypeItem(..)
                     | ItemKind::PrimitiveItem(..)
-                    | ItemKind::KeywordItem => own_stability,
+                    | ItemKind::KeywordItem => own_stability.cloned(),
 
                     ItemKind::StrippedItem(..) => unreachable!(),
                 }
@@ -85,8 +81,8 @@ impl<'a, 'tcx> DocFolder for StabilityPropagator<'a, 'tcx> {
             }
         };
 
-        item.inner.stability = stability;
-        self.parent_stability = stability;
+        item.inner.stability = stability.clone();
+        let parent_stability = std::mem::replace(&mut self.parent_stability, stability);
         let item = self.fold_item_recur(item);
         self.parent_stability = parent_stability;
 
@@ -95,18 +91,43 @@ impl<'a, 'tcx> DocFolder for StabilityPropagator<'a, 'tcx> {
 }
 
 fn merge_stability(
-    own_stability: Option<Stability>,
-    parent_stability: Option<Stability>,
+    own_stability: Option<&Stability>,
+    parent_stability: Option<&Stability>,
 ) -> Option<Stability> {
     if let Some(own_stab) = own_stability
-        && let StabilityLevel::Stable { since: own_since, allowed_through_unstable_modules: false } =
-            own_stab.level
         && let Some(parent_stab) = parent_stability
-        && (parent_stab.is_unstable()
-            || parent_stab.stable_since().is_some_and(|parent_since| parent_since > own_since))
     {
-        parent_stability
+        match own_stab.level {
+            // If any of a stable item's parents were stabilized later or are still unstable,
+            // then use the parent's stability instead.
+            StabilityLevel::Stable {
+                since: own_since,
+                allowed_through_unstable_modules: false,
+                ..
+            } if parent_stab.is_unstable()
+                || parent_stab
+                    .stable_since()
+                    .is_some_and(|parent_since| parent_since > own_since) =>
+            {
+                parent_stability.cloned()
+            }
+
+            // If any of an unstable item's parents depend on other unstable features,
+            // then use those as well.
+            StabilityLevel::Unstable { unstables: ref own_gates, reason, is_soft }
+                if let StabilityLevel::Unstable { unstables: parent_gates, .. } =
+                    &parent_stab.level =>
+            {
+                let missing_unstables = parent_gates
+                    .iter()
+                    .filter(|p| !own_gates.iter().any(|u| u.feature == p.feature));
+                let unstables = own_gates.iter().chain(missing_unstables).cloned().collect();
+                Some(Stability { level: StabilityLevel::Unstable { unstables, reason, is_soft } })
+            }
+
+            _ => own_stability.cloned(),
+        }
     } else {
-        own_stability
+        own_stability.cloned()
     }
 }
diff --git a/src/tools/clippy/clippy_utils/src/qualify_min_const_fn.rs b/src/tools/clippy/clippy_utils/src/qualify_min_const_fn.rs
index abadca7140019..cfa10044e6a52 100644
--- a/src/tools/clippy/clippy_utils/src/qualify_min_const_fn.rs
+++ b/src/tools/clippy/clippy_utils/src/qualify_min_const_fn.rs
@@ -394,7 +394,8 @@ fn is_stable_const_fn(tcx: TyCtxt<'_>, def_id: DefId, msrv: &Msrv) -> bool {
                 msrv.meets(const_stab_rust_version)
             } else {
                 // Unstable const fn, check if the feature is enabled.
-                tcx.features().enabled(const_stab.feature) && msrv.current().is_none()
+                tcx.features().all_enabled(const_stab.unstable_features())
+                    && msrv.current().is_none()
             }
         })
 }
diff --git a/tests/rustdoc/stability.rs b/tests/rustdoc/stability.rs
index 550eb0bc13776..6740b347ac3a8 100644
--- a/tests/rustdoc/stability.rs
+++ b/tests/rustdoc/stability.rs
@@ -55,6 +55,12 @@ pub mod unstable {
         #[stable(feature = "rust1", since = "1.0.0")]
         pub fn foo() {}
     }
+
+    //@ has stability/unstable/fn.nested_unstable.html \
+    //      '//span[@class="item-info"]//div[@class="stab unstable"]' \
+    //      'This is a nightly-only experimental API. (test, unstable)'
+    #[unstable(feature = "test", issue = "none")]
+    pub fn nested_unstable() {}
 }
 
 #[unstable(feature = "unstable", issue = "none")]
@@ -169,3 +175,11 @@ mod prim_i32 {}
 /// We currently don't document stability for keywords, but let's test it anyway.
 #[stable(feature = "rust1", since = "1.0.0")]
 mod if_keyword {}
+
+#[unstable(feature = "test", issue = "none")]
+#[unstable(feature = "test2", issue = "none")]
+pub trait UnstableTraitWithMultipleFeatures {
+    //@ has stability/trait.UnstableTraitWithMultipleFeatures.html \
+    //      '//span[@class="item-info"]//div[@class="stab unstable"]' \
+    //      'This is a nightly-only experimental API. (test, test2)'
+}
diff --git a/tests/ui/stability-attribute/auxiliary/mixed-levels.rs b/tests/ui/stability-attribute/auxiliary/mixed-levels.rs
new file mode 100644
index 0000000000000..0e8a6b8917d1e
--- /dev/null
+++ b/tests/ui/stability-attribute/auxiliary/mixed-levels.rs
@@ -0,0 +1,29 @@
+//! definitions for ../mixed-levels.rs
+
+#![stable(feature = "stable_feature", since = "1.0.0")]
+#![feature(staged_api)]
+#![crate_type = "lib"]
+
+#[stable(feature = "stable_a", since = "1.0.0")]
+#[stable(feature = "stable_b", since = "1.8.2")]
+#[macro_export]
+macro_rules! stable_mac {
+    () => ()
+}
+
+#[unstable(feature = "unstable_a", issue = "none")]
+#[stable(feature = "stable_a", since = "1.0.0")]
+#[macro_export]
+macro_rules! unstable_mac {
+    () => ()
+}
+
+#[stable(feature = "stable_feature", since = "1.0.0")]
+#[rustc_const_stable(feature = "stable_c", since = "1.8.2")]
+#[rustc_const_stable(feature = "stable_d", since = "1.0.0")]
+pub const fn const_stable_fn() {}
+
+#[stable(feature = "stable_feature", since = "1.0.0")]
+#[rustc_const_unstable(feature = "unstable_c", issue = "none")]
+#[rustc_const_stable(feature = "stable_c", since = "1.8.2")]
+pub const fn const_unstable_fn() {}
diff --git a/tests/ui/stability-attribute/auxiliary/soft-unstable.rs b/tests/ui/stability-attribute/auxiliary/soft-unstable.rs
new file mode 100644
index 0000000000000..4baf0b38270f5
--- /dev/null
+++ b/tests/ui/stability-attribute/auxiliary/soft-unstable.rs
@@ -0,0 +1,14 @@
+#![stable(feature = "stable_feature", since = "1.0.0")]
+#![feature(staged_api)]
+#![crate_type = "lib"]
+
+#[unstable(feature = "a", issue = "1", soft)]
+#[unstable(feature = "b", issue = "2", reason = "reason", soft)]
+#[macro_export]
+macro_rules! mac {
+    () => ()
+}
+
+#[unstable(feature = "c", issue = "3", soft)]
+#[unstable(feature = "d", issue = "4", reason = "reason", soft)]
+pub fn something() {}
diff --git a/tests/ui/stability-attribute/auxiliary/two-unstables.rs b/tests/ui/stability-attribute/auxiliary/two-unstables.rs
new file mode 100644
index 0000000000000..b18cdcc6dfc57
--- /dev/null
+++ b/tests/ui/stability-attribute/auxiliary/two-unstables.rs
@@ -0,0 +1,27 @@
+#![stable(feature = "stable_feature", since = "1.0.0")]
+#![feature(staged_api)]
+#![crate_type = "lib"]
+
+#[unstable(feature = "a", issue = "1", reason = "reason")]
+#[unstable(feature = "b", issue = "2")]
+pub struct Foo;
+
+#[stable(feature = "stable_feature", since = "1.0.0")]
+#[rustc_const_unstable(feature = "c", issue = "3", reason = "reason")]
+#[rustc_const_unstable(feature = "d", issue = "4")]
+pub const fn nothing() {}
+
+#[stable(feature = "stable_feature", since = "1.0.0")]
+pub trait Trait {
+    #[stable(feature = "stable_feature", since = "1.0.0")]
+    #[rustc_default_body_unstable(feature = "e", issue = "5", reason = "reason")]
+    #[rustc_default_body_unstable(feature = "f", issue = "6")]
+    fn method() {}
+}
+
+#[unstable(feature = "g", issue = "7", reason = "reason")]
+#[unstable(feature = "h", issue = "8")]
+#[macro_export]
+macro_rules! mac {
+    () => ()
+}
diff --git a/tests/ui/stability-attribute/mixed-levels.rs b/tests/ui/stability-attribute/mixed-levels.rs
new file mode 100644
index 0000000000000..6948dc174afae
--- /dev/null
+++ b/tests/ui/stability-attribute/mixed-levels.rs
@@ -0,0 +1,13 @@
+//! Test stability levels for items formerly dependent on multiple unstable features.
+//@ aux-build:mixed-levels.rs
+
+extern crate mixed_levels;
+
+const USE_STABLE: () = mixed_levels::const_stable_fn();
+const USE_UNSTABLE: () = mixed_levels::const_unstable_fn();
+//~^ ERROR `const_unstable_fn` is not yet stable as a const fn
+
+fn main() {
+    mixed_levels::stable_mac!();
+    mixed_levels::unstable_mac!(); //~ ERROR use of unstable library feature `unstable_a` [E0658]
+}
diff --git a/tests/ui/stability-attribute/mixed-levels.stderr b/tests/ui/stability-attribute/mixed-levels.stderr
new file mode 100644
index 0000000000000..745c6751bb442
--- /dev/null
+++ b/tests/ui/stability-attribute/mixed-levels.stderr
@@ -0,0 +1,20 @@
+error[E0658]: use of unstable library feature `unstable_a`
+  --> $DIR/mixed-levels.rs:12:5
+   |
+LL |     mixed_levels::unstable_mac!();
+   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^
+   |
+   = help: add `#![feature(unstable_a)]` to the crate attributes to enable
+   = note: this compiler was built on YYYY-MM-DD; consider upgrading it if it is out of date
+
+error: `const_unstable_fn` is not yet stable as a const fn
+  --> $DIR/mixed-levels.rs:7:26
+   |
+LL | const USE_UNSTABLE: () = mixed_levels::const_unstable_fn();
+   |                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+   |
+   = help: add `#![feature(unstable_c)]` to the crate attributes to enable
+
+error: aborting due to 2 previous errors
+
+For more information about this error, try `rustc --explain E0658`.
diff --git a/tests/ui/stability-attribute/multiple-stability-attribute-sanity.rs b/tests/ui/stability-attribute/multiple-stability-attribute-sanity.rs
new file mode 100644
index 0000000000000..f4c02d69ab325
--- /dev/null
+++ b/tests/ui/stability-attribute/multiple-stability-attribute-sanity.rs
@@ -0,0 +1,24 @@
+//! Checks that multiple stability attributes are used correctly together
+
+#![feature(staged_api)]
+
+#![stable(feature = "stable_test_feature", since = "1.0.0")]
+
+#[unstable(feature = "a", issue = "none", reason = "reason 1")]
+#[unstable(feature = "b", issue = "none", reason = "reason 2")] //~ ERROR multiple reasons provided for unstability
+fn f1() { }
+
+#[unstable(feature = "a", issue = "none", reason = "reason 1")]
+#[unstable(feature = "b", issue = "none", reason = "reason 2")] //~ ERROR multiple reasons provided for unstability
+#[unstable(feature = "c", issue = "none", reason = "reason 3")] //~ ERROR multiple reasons provided for unstability
+fn f2() { }
+
+#[unstable(feature = "a", issue = "none")] //~ ERROR `soft` must be present on either none or all of an item's `unstable` attributes
+#[unstable(feature = "b", issue = "none", soft)]
+fn f3() { }
+
+#[unstable(feature = "a", issue = "none", soft)] //~ ERROR `soft` must be present on either none or all of an item's `unstable` attributes
+#[unstable(feature = "b", issue = "none")]
+fn f4() { }
+
+fn main() { }
diff --git a/tests/ui/stability-attribute/multiple-stability-attribute-sanity.stderr b/tests/ui/stability-attribute/multiple-stability-attribute-sanity.stderr
new file mode 100644
index 0000000000000..a7cb4849d1245
--- /dev/null
+++ b/tests/ui/stability-attribute/multiple-stability-attribute-sanity.stderr
@@ -0,0 +1,36 @@
+error: multiple reasons provided for unstability
+  --> $DIR/multiple-stability-attribute-sanity.rs:8:1
+   |
+LL | #[unstable(feature = "b", issue = "none", reason = "reason 2")]
+   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+error: multiple reasons provided for unstability
+  --> $DIR/multiple-stability-attribute-sanity.rs:12:1
+   |
+LL | #[unstable(feature = "b", issue = "none", reason = "reason 2")]
+   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+error: multiple reasons provided for unstability
+  --> $DIR/multiple-stability-attribute-sanity.rs:13:1
+   |
+LL | #[unstable(feature = "c", issue = "none", reason = "reason 3")]
+   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+error: `soft` must be present on either none or all of an item's `unstable` attributes
+  --> $DIR/multiple-stability-attribute-sanity.rs:16:1
+   |
+LL | #[unstable(feature = "a", issue = "none")]
+   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+LL | #[unstable(feature = "b", issue = "none", soft)]
+   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+error: `soft` must be present on either none or all of an item's `unstable` attributes
+  --> $DIR/multiple-stability-attribute-sanity.rs:20:1
+   |
+LL | #[unstable(feature = "a", issue = "none", soft)]
+   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+LL | #[unstable(feature = "b", issue = "none")]
+   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+error: aborting due to 5 previous errors
+
diff --git a/tests/ui/stability-attribute/soft-unstable.none.stderr b/tests/ui/stability-attribute/soft-unstable.none.stderr
new file mode 100644
index 0000000000000..7e4b634f0283d
--- /dev/null
+++ b/tests/ui/stability-attribute/soft-unstable.none.stderr
@@ -0,0 +1,43 @@
+error: use of unstable library features `a` and `b`: reason
+  --> $DIR/soft-unstable.rs:11:5
+   |
+LL |     soft_unstable::mac!();
+   |     ^^^^^^^^^^^^^^^^^^
+   |
+   = warning: this was previously accepted by the compiler but is being phased out; it will become a hard error in a future release!
+   = note: for more information, see issue #64266 <https://github.com/rust-lang/rust/issues/64266>
+   = note: `#[deny(soft_unstable)]` on by default
+
+error: use of unstable library features `c` and `d`: reason
+  --> $DIR/soft-unstable.rs:14:5
+   |
+LL |     soft_unstable::something();
+   |     ^^^^^^^^^^^^^^^^^^^^^^^^
+   |
+   = warning: this was previously accepted by the compiler but is being phased out; it will become a hard error in a future release!
+   = note: for more information, see issue #64266 <https://github.com/rust-lang/rust/issues/64266>
+
+error: aborting due to 2 previous errors
+
+Future incompatibility report: Future breakage diagnostic:
+error: use of unstable library features `a` and `b`: reason
+  --> $DIR/soft-unstable.rs:11:5
+   |
+LL |     soft_unstable::mac!();
+   |     ^^^^^^^^^^^^^^^^^^
+   |
+   = warning: this was previously accepted by the compiler but is being phased out; it will become a hard error in a future release!
+   = note: for more information, see issue #64266 <https://github.com/rust-lang/rust/issues/64266>
+   = note: `#[deny(soft_unstable)]` on by default
+
+Future breakage diagnostic:
+error: use of unstable library features `c` and `d`: reason
+  --> $DIR/soft-unstable.rs:14:5
+   |
+LL |     soft_unstable::something();
+   |     ^^^^^^^^^^^^^^^^^^^^^^^^
+   |
+   = warning: this was previously accepted by the compiler but is being phased out; it will become a hard error in a future release!
+   = note: for more information, see issue #64266 <https://github.com/rust-lang/rust/issues/64266>
+   = note: `#[deny(soft_unstable)]` on by default
+
diff --git a/tests/ui/stability-attribute/soft-unstable.rs b/tests/ui/stability-attribute/soft-unstable.rs
new file mode 100644
index 0000000000000..e70df4c273d90
--- /dev/null
+++ b/tests/ui/stability-attribute/soft-unstable.rs
@@ -0,0 +1,17 @@
+// Test handling of soft-unstable items dependent on multiple features.
+//@ aux-build:soft-unstable.rs
+//@ revisions: all none
+//@ [all]check-pass
+
+#![cfg_attr(all, feature(a, b, c, d))]
+
+extern crate soft_unstable;
+
+fn main() {
+    soft_unstable::mac!();
+    //[none]~^ ERROR use of unstable library features `a` and `b`: reason [soft_unstable]
+    //[none]~| WARNING this was previously accepted by the compiler but is being phased out
+    soft_unstable::something();
+    //[none]~^ ERROR use of unstable library features `c` and `d`: reason [soft_unstable]
+    //[none]~| WARNING this was previously accepted by the compiler but is being phased out
+}
diff --git a/tests/ui/stability-attribute/stability-attribute-sanity.rs b/tests/ui/stability-attribute/stability-attribute-sanity.rs
index 7857a0603bd45..98b08b2604d4b 100644
--- a/tests/ui/stability-attribute/stability-attribute-sanity.rs
+++ b/tests/ui/stability-attribute/stability-attribute-sanity.rs
@@ -45,23 +45,23 @@ mod missing_version {
     fn f3() { }
 }
 
-#[unstable(feature = "b", issue = "none")]
-#[stable(feature = "a", since = "4.4.4")] //~ ERROR multiple stability levels [E0544]
+#[stable(feature = "a", since = "4.4.4")]
+#[stable(feature = "a", since = "4.4.4")] //~ ERROR multiple stability levels for feature `a` [E0544]
 fn multiple1() { }
 
 #[unstable(feature = "b", issue = "none")]
-#[unstable(feature = "b", issue = "none")] //~ ERROR multiple stability levels [E0544]
+#[unstable(feature = "b", issue = "none")] //~ ERROR multiple stability levels for feature `b` [E0544]
 fn multiple2() { }
 
-#[stable(feature = "a", since = "4.4.4")]
-#[stable(feature = "a", since = "4.4.4")] //~ ERROR multiple stability levels [E0544]
-fn multiple3() { }
+#[unstable(feature = "c", issue = "none")]
+#[stable(feature = "c", since = "4.4.4")] //~ ERROR multiple stability levels for feature `c` [E0544]
+fn multiple3() { }                       //~| ERROR feature `c` is declared stable, but was previously declared unstable
 
 #[stable(feature = "e", since = "b")] //~ ERROR 'since' must be a Rust version number, such as "1.31.0"
 #[deprecated(since = "5.5.5", note = "text")]
 #[deprecated(since = "5.5.5", note = "text")] //~ ERROR multiple `deprecated` attributes
-#[rustc_const_unstable(feature = "c", issue = "none")]
-#[rustc_const_unstable(feature = "d", issue = "none")] //~ ERROR multiple stability levels
+#[rustc_const_unstable(feature = "d", issue = "none")]
+#[rustc_const_unstable(feature = "d", issue = "none")] //~ ERROR multiple stability levels for feature `d`
 pub const fn multiple4() { }
 
 #[stable(feature = "a", since = "1.0.0")] //~ ERROR feature `a` is declared stable since 1.0.0
diff --git a/tests/ui/stability-attribute/stability-attribute-sanity.stderr b/tests/ui/stability-attribute/stability-attribute-sanity.stderr
index c614fc2b9f7fa..264de8326f11f 100644
--- a/tests/ui/stability-attribute/stability-attribute-sanity.stderr
+++ b/tests/ui/stability-attribute/stability-attribute-sanity.stderr
@@ -76,22 +76,22 @@ error[E0543]: missing 'note'
 LL |     #[deprecated(since = "5.5.5")]
    |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
-error[E0544]: multiple stability levels
+error[E0544]: multiple stability levels for feature `a`
   --> $DIR/stability-attribute-sanity.rs:49:1
    |
 LL | #[stable(feature = "a", since = "4.4.4")]
    | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
-error[E0544]: multiple stability levels
+error[E0544]: multiple stability levels for feature `b`
   --> $DIR/stability-attribute-sanity.rs:53:1
    |
 LL | #[unstable(feature = "b", issue = "none")]
    | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
-error[E0544]: multiple stability levels
+error[E0544]: multiple stability levels for feature `c`
   --> $DIR/stability-attribute-sanity.rs:57:1
    |
-LL | #[stable(feature = "a", since = "4.4.4")]
+LL | #[stable(feature = "c", since = "4.4.4")]
    | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
 error: 'since' must be a Rust version number, such as "1.31.0"
@@ -100,7 +100,7 @@ error: 'since' must be a Rust version number, such as "1.31.0"
 LL | #[stable(feature = "e", since = "b")]
    | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
-error[E0544]: multiple stability levels
+error[E0544]: multiple stability levels for feature `d`
   --> $DIR/stability-attribute-sanity.rs:64:1
    |
 LL | #[rustc_const_unstable(feature = "d", issue = "none")]
@@ -118,13 +118,19 @@ error[E0549]: deprecated attribute must be paired with either stable or unstable
 LL | #[deprecated(since = "5.5.5", note = "text")]
    | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
+error[E0711]: feature `c` is declared stable, but was previously declared unstable
+  --> $DIR/stability-attribute-sanity.rs:57:1
+   |
+LL | #[stable(feature = "c", since = "4.4.4")]
+   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
 error[E0711]: feature `a` is declared stable since 1.0.0, but was previously declared stable since 4.4.4
   --> $DIR/stability-attribute-sanity.rs:67:1
    |
 LL | #[stable(feature = "a", since = "1.0.0")]
    | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
-error: aborting due to 20 previous errors
+error: aborting due to 21 previous errors
 
 Some errors have detailed explanations: E0539, E0541, E0542, E0543, E0544, E0546, E0547, E0549, E0711.
 For more information about an error, try `rustc --explain E0539`.
diff --git a/tests/ui/stability-attribute/two-unstables.none.stderr b/tests/ui/stability-attribute/two-unstables.none.stderr
new file mode 100644
index 0000000000000..4aef8109caf13
--- /dev/null
+++ b/tests/ui/stability-attribute/two-unstables.none.stderr
@@ -0,0 +1,47 @@
+error[E0658]: use of unstable library features `g` and `h`: reason
+  --> $DIR/two-unstables.rs:24:5
+   |
+LL |     two_unstables::mac!();
+   |     ^^^^^^^^^^^^^^^^^^
+   |
+   = note: see issue #7 <https://github.com/rust-lang/rust/issues/7> for more information
+   = note: see issue #8 <https://github.com/rust-lang/rust/issues/8> for more information
+   = help: add `#![feature(g, h)]` to the crate attributes to enable
+   = note: this compiler was built on YYYY-MM-DD; consider upgrading it if it is out of date
+
+error[E0658]: use of unstable library features `a` and `b`: reason
+  --> $DIR/two-unstables.rs:15:16
+   |
+LL | struct Wrapper(two_unstables::Foo);
+   |                ^^^^^^^^^^^^^^^^^^
+   |
+   = note: see issue #1 <https://github.com/rust-lang/rust/issues/1> for more information
+   = note: see issue #2 <https://github.com/rust-lang/rust/issues/2> for more information
+   = help: add `#![feature(a, b)]` to the crate attributes to enable
+   = note: this compiler was built on YYYY-MM-DD; consider upgrading it if it is out of date
+
+error[E0046]: not all trait items implemented, missing: `method`
+  --> $DIR/two-unstables.rs:19:1
+   |
+LL | impl two_unstables::Trait for Wrapper {}
+   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+   |
+   = note: default implementation of `method` is unstable
+   = note: use of unstable library features `e` and `f`: reason
+   = note: see issue #5 <https://github.com/rust-lang/rust/issues/5> for more information
+   = note: see issue #6 <https://github.com/rust-lang/rust/issues/6> for more information
+   = help: add `#![feature(e, f)]` to the crate attributes to enable
+   = note: this compiler was built on YYYY-MM-DD; consider upgrading it if it is out of date
+
+error: `nothing` is not yet stable as a const fn
+  --> $DIR/two-unstables.rs:11:25
+   |
+LL | const USE_NOTHING: () = two_unstables::nothing();
+   |                         ^^^^^^^^^^^^^^^^^^^^^^^^
+   |
+   = help: add `#![feature(c, d)]` to the crate attributes to enable
+
+error: aborting due to 4 previous errors
+
+Some errors have detailed explanations: E0046, E0658.
+For more information about an error, try `rustc --explain E0046`.
diff --git a/tests/ui/stability-attribute/two-unstables.rs b/tests/ui/stability-attribute/two-unstables.rs
new file mode 100644
index 0000000000000..3c3678cf77bc5
--- /dev/null
+++ b/tests/ui/stability-attribute/two-unstables.rs
@@ -0,0 +1,27 @@
+// Test handling of unstable items dependent on multiple features.
+//@ aux-build:two-unstables.rs
+//@ revisions: all some none
+//@ [all]check-pass
+
+#![cfg_attr(all, feature(a, b, c, d, e, f, g, h))]
+#![cfg_attr(some, feature(a, d, e, h))]
+
+extern crate two_unstables;
+
+const USE_NOTHING: () = two_unstables::nothing();
+//[none]~^ ERROR `nothing` is not yet stable as a const fn
+//[some]~^^ ERROR `nothing` is not yet stable as a const fn
+
+struct Wrapper(two_unstables::Foo);
+//[none]~^ ERROR use of unstable library features `a` and `b`: reason [E0658]
+//[some]~^^ ERROR use of unstable library feature `b`: reason [E0658]
+
+impl two_unstables::Trait for Wrapper {}
+//[none]~^ ERROR not all trait items implemented, missing: `method` [E0046]
+//[some]~^^ ERROR not all trait items implemented, missing: `method` [E0046]
+
+fn main() {
+    two_unstables::mac!();
+    //[none]~^ ERROR use of unstable library features `g` and `h`: reason [E0658]
+    //[some]~^^ ERROR use of unstable library feature `g`: reason [E0658]
+}
diff --git a/tests/ui/stability-attribute/two-unstables.some.stderr b/tests/ui/stability-attribute/two-unstables.some.stderr
new file mode 100644
index 0000000000000..4881ad15e09d4
--- /dev/null
+++ b/tests/ui/stability-attribute/two-unstables.some.stderr
@@ -0,0 +1,44 @@
+error[E0658]: use of unstable library feature `g`: reason
+  --> $DIR/two-unstables.rs:24:5
+   |
+LL |     two_unstables::mac!();
+   |     ^^^^^^^^^^^^^^^^^^
+   |
+   = note: see issue #7 <https://github.com/rust-lang/rust/issues/7> for more information
+   = help: add `#![feature(g)]` to the crate attributes to enable
+   = note: this compiler was built on YYYY-MM-DD; consider upgrading it if it is out of date
+
+error[E0658]: use of unstable library feature `b`: reason
+  --> $DIR/two-unstables.rs:15:16
+   |
+LL | struct Wrapper(two_unstables::Foo);
+   |                ^^^^^^^^^^^^^^^^^^
+   |
+   = note: see issue #2 <https://github.com/rust-lang/rust/issues/2> for more information
+   = help: add `#![feature(b)]` to the crate attributes to enable
+   = note: this compiler was built on YYYY-MM-DD; consider upgrading it if it is out of date
+
+error[E0046]: not all trait items implemented, missing: `method`
+  --> $DIR/two-unstables.rs:19:1
+   |
+LL | impl two_unstables::Trait for Wrapper {}
+   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+   |
+   = note: default implementation of `method` is unstable
+   = note: use of unstable library feature `f`: reason
+   = note: see issue #6 <https://github.com/rust-lang/rust/issues/6> for more information
+   = help: add `#![feature(f)]` to the crate attributes to enable
+   = note: this compiler was built on YYYY-MM-DD; consider upgrading it if it is out of date
+
+error: `nothing` is not yet stable as a const fn
+  --> $DIR/two-unstables.rs:11:25
+   |
+LL | const USE_NOTHING: () = two_unstables::nothing();
+   |                         ^^^^^^^^^^^^^^^^^^^^^^^^
+   |
+   = help: add `#![feature(c)]` to the crate attributes to enable
+
+error: aborting due to 4 previous errors
+
+Some errors have detailed explanations: E0046, E0658.
+For more information about an error, try `rustc --explain E0046`.