diff --git a/elf/Makefile b/elf/Makefile
index 94c5b7e6ed..5553cecbb5 100644
--- a/elf/Makefile
+++ b/elf/Makefile
@@ -432,6 +432,7 @@ tests += \
   tst-big-note \
   tst-bz26577 \
   tst-bz26577-minstack \
+  tst-debug-loadlock \
   tst-debug1 \
   tst-deep1 \
   tst-dl-is_dso \
@@ -959,6 +960,7 @@ modules-names += \
   tst-auditmod9b \
   tst-auxvalmod \
   tst-big-note-lib \
+  tst-debug-loadlock-mod \
   tst-deep1mod1 \
   tst-deep1mod2 \
   tst-deep1mod3 \
@@ -2891,6 +2893,9 @@ $(objpfx)tst-nodelete-dlclose.out: $(objpfx)tst-nodelete-dlclose-dso.so \
 
 $(objpfx)tst-debug1.out: $(objpfx)tst-debug1mod1.so
 
+$(objpfx)tst-debug-loadlock.out: $(objpfx)tst-debug-loadlock-mod.so
+tst-debug-loadlock-ARGS = -- $(host-test-program-cmd)
+
 $(objpfx)tst-debug1mod1.so: $(objpfx)testobj1.so
 	$(OBJCOPY) --only-keep-debug $< $@
 
diff --git a/elf/dl-debug.c b/elf/dl-debug.c
index 3105bad93a..bca5c2a407 100644
--- a/elf/dl-debug.c
+++ b/elf/dl-debug.c
@@ -167,3 +167,45 @@ _dl_debug_initialize (ElfW(Addr) ldbase, Lmid_t ns)
 
   return &r->base;
 }
+
+/* Log a dl_load_lock acquisition/release site together with a raw-address
+   backtrace.  Called only when the DL_DEBUG_LOADLOCK mask is set.
+
+   The backtrace is obtained by walking the frame-pointer chain directly.
+   This is deliberately lock-free, allocation-free and syscall-free: we may
+   be running inside an _dl_load_lock critical section, so we cannot call
+   the libc backtrace() helper or perform any symbol resolution (both would
+   re-enter the dynamic linker and either recurse or deadlock against
+   dl_load_lock itself).  Resolve the printed addresses offline with
+   addr2line(1).  Frame pointers must be present in the code being traced
+   (the default for the dynamic linker on most targets).  */
+void
+_dl_debug_loadlock (const char *action, const char *site)
+{
+  _dl_debug_printf ("dl_load_lock %s at %s\n", action, site);
+
+  struct layout
+  {
+    struct layout *next;
+    void *ret;
+  };
+  struct layout *p = (struct layout *) __builtin_frame_address (0);
+  /* Bound the frame-pointer walk to the current stack so a chain that runs
+     into frame-pointer-less code (e.g. -O2 libc, which does not set up rbp)
+     terminates instead of dereferencing garbage.  The stack grows down, so
+     caller frames sit at strictly higher addresses: require a monotonic,
+     in-range, aligned advance.  No symbol resolution is performed (it would
+     re-enter the loader and deadlock on dl_load_lock); resolve the printed
+     addresses offline with addr2line(1).  */
+  uintptr_t start = (uintptr_t) p;
+  for (int n = 0; p != NULL && n < 32; ++n)
+    {
+      _dl_debug_printf ("  #%d  0x%lx\n", n, (unsigned long int) p->ret);
+      uintptr_t naddr = (uintptr_t) p->next;
+      if (naddr <= (uintptr_t) p
+	  || naddr - start > (1u << 20)
+	  || (naddr & 7) != 0)
+	break;
+      p = p->next;
+    }
+}
diff --git a/elf/dl-open.c b/elf/dl-open.c
index 9930371867..86fa3cfcfb 100644
--- a/elf/dl-open.c
+++ b/elf/dl-open.c
@@ -43,6 +43,17 @@
 #include <dl-prop.h>
 
 
+/* When LD_DEBUG=loadlock is active, log a dl_load_lock acquire/release site
+   together with a raw-address backtrace (see _dl_debug_loadlock).  Inlined so
+   the hot path costs only a single mask test when the flag is off.  */
+static inline void
+trace_load_lock (const char *action, const char *site)
+{
+  if (__glibc_unlikely (GLRO (dl_debug_mask) & DL_DEBUG_LOADLOCK))
+    _dl_debug_loadlock (action, site);
+}
+
+
 /* We must be careful not to leave us in an inconsistent state.  Thus we
    catch any error and re-raise it after cleaning up.  */
 
@@ -644,6 +655,8 @@ dl_open_worker_begin (void *a)
 	    {
 	      __rtld_lock_unlock_recursive (GL(dl_load_tls_lock));
 	      __rtld_lock_unlock_recursive (GL(dl_load_lock));
+	      trace_load_lock ("release",
+			       "dl_open_worker_begin(already-loaded)");
 	    }
 
 	  while (atomic_load_acquire (&new->l_init_once) != 2)
@@ -652,6 +665,8 @@ dl_open_worker_begin (void *a)
 	  if (unlock_for_ctor)
 	    {
 	      __rtld_lock_lock_recursive (GL(dl_load_lock));
+	      trace_load_lock ("acquire",
+			       "dl_open_worker_begin(already-loaded)");
 	      __rtld_lock_lock_recursive (GL(dl_load_tls_lock));
 	    }
 	}
@@ -879,7 +894,10 @@ dl_open_worker (void *a)
   atomic_store_release (&new->l_init_pending, 1);
 
   if (release_lock_for_ctor)
-    __rtld_lock_unlock_recursive (GL(dl_load_lock));
+    {
+      __rtld_lock_unlock_recursive (GL(dl_load_lock));
+      trace_load_lock ("release", "dl_open_worker(for-ctor)");
+    }
 
   /* Run the initializer functions of new objects.  Temporarily
      disable the exception handler, so that lazy binding failures are
@@ -889,7 +907,10 @@ dl_open_worker (void *a)
   /* Re-acquire dl_load_lock for the final global scope update and
      for the lock/unlock pairing expected by _dl_open.  */
   if (release_lock_for_ctor)
-    __rtld_lock_lock_recursive (GL(dl_load_lock));
+    {
+      __rtld_lock_lock_recursive (GL(dl_load_lock));
+      trace_load_lock ("acquire", "dl_open_worker(for-ctor)");
+    }
 
   /* Now we can make the new map available in the global scope.  */
   if (mode & RTLD_GLOBAL)
@@ -911,6 +932,7 @@ _dl_open (const char *file, int mode, const void *caller_dlopen, Lmid_t nsid,
 
   /* Make sure we are alone.  */
   __rtld_lock_lock_recursive (GL(dl_load_lock));
+  trace_load_lock ("acquire", "_dl_open");
 
   if (__glibc_unlikely (nsid == LM_ID_NEWLM))
     {
@@ -923,6 +945,7 @@ _dl_open (const char *file, int mode, const void *caller_dlopen, Lmid_t nsid,
 	{
 	  /* No more namespace available.  */
 	  __rtld_lock_unlock_recursive (GL(dl_load_lock));
+	  trace_load_lock ("release", "_dl_open(no-namespace)");
 
 	  _dl_signal_error (EINVAL, file, NULL, N_("\
 no more namespaces available for dlmopen()"));
@@ -1011,14 +1034,17 @@ no more namespaces available for dlmopen()"));
 	  && map->l_init_owner != THREAD_GETMEM (THREAD_SELF, tid))
 	{
 	  __rtld_lock_unlock_recursive (GL(dl_load_lock));
+	  trace_load_lock ("release", "_dl_open(fast-path wait)");
 
 	  while (atomic_load_acquire (&map->l_init_once) != 2)
 	    lll_futex_wait (&map->l_init_once, 1, LLL_PRIVATE);
 
 	  __rtld_lock_lock_recursive (GL(dl_load_lock));
+	  trace_load_lock ("acquire", "_dl_open(fast-path wait)");
 	}
 
       __rtld_lock_unlock_recursive (GL(dl_load_lock));
+      trace_load_lock ("release", "_dl_open(fast-path return)");
       return map;
     }
 
@@ -1059,6 +1085,7 @@ no more namespaces available for dlmopen()"));
 
       /* Release the lock.  */
       __rtld_lock_unlock_recursive (GL(dl_load_lock));
+      trace_load_lock ("release", "_dl_open(error cleanup)");
 
       /* Reraise the error.  */
       _dl_signal_exception (errcode, &exception, NULL);
@@ -1070,6 +1097,7 @@ no more namespaces available for dlmopen()"));
 
   /* Release the lock.  */
   __rtld_lock_unlock_recursive (GL(dl_load_lock));
+  trace_load_lock ("release", "_dl_open(done)");
 
   return args.map;
 }
diff --git a/elf/rtld.c b/elf/rtld.c
index fc053df858..df3e70eee6 100644
--- a/elf/rtld.c
+++ b/elf/rtld.c
@@ -2438,6 +2438,8 @@ process_dl_debug (struct dl_main_state *state, const char *dl_debug)
 	DL_DEBUG_STATISTICS },
       { LEN_AND_STR ("unused"), "determined unused DSOs",
 	DL_DEBUG_UNUSED },
+      { LEN_AND_STR ("loadlock"), "log dl_load_lock acquire/release sites",
+	DL_DEBUG_LOADLOCK },
       { LEN_AND_STR ("help"), "display this help message and exit",
 	DL_DEBUG_HELP },
     };
diff --git a/elf/tst-debug-loadlock-mod.c b/elf/tst-debug-loadlock-mod.c
new file mode 100644
index 0000000000..dbffd9a39a
--- /dev/null
+++ b/elf/tst-debug-loadlock-mod.c
@@ -0,0 +1,4 @@
+/* Loadable module for elf/tst-debug-loadlock.c.
+   No constructor is needed: the dl_load_lock acquire/release traces fire
+   on every dlopen regardless of whether the target runs initializers.  */
+int tst_debug_loadlock_mod_variable = 1;
diff --git a/elf/tst-debug-loadlock.c b/elf/tst-debug-loadlock.c
new file mode 100644
index 0000000000..52623c097e
--- /dev/null
+++ b/elf/tst-debug-loadlock.c
@@ -0,0 +1,127 @@
+/* Test for LD_DEBUG=loadlock.
+   Verifies that dl_load_lock acquisitions/releases on the dlopen constructor
+   path are logged with a backtrace when LD_DEBUG=loadlock is active, and that
+   "loadlock" appears in LD_DEBUG=help output.
+
+   The dl_debug_mask is set by rtld only at process startup, so both checks
+   re-exec this binary as a child (under the freshly built ld.so, via
+   $(host-test-program-cmd) passed in tst-debug-loadlock-ARGS) with LD_DEBUG
+   set in the child environment, and capture the child's std streams.  The
+   trace lines themselves are emitted by the dynamic linker (elf/dl-debug.c,
+   elf/dl-open.c).
+
+   Copyright (C) 2026 Free Software Foundation, Inc.
+   This file is part of the GNU C Library.
+
+   The GNU C Library is free software; you can redistribute it and/or
+   modify it under the terms of the GNU Lesser General Public
+   License as published by the Free Software Foundation; either
+   version 2.1 of the License, or (at your option) any later version.
+
+   The GNU C Library is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+   Lesser General Public License for more details.
+
+   You should have received a copy of the GNU Lesser General Public
+   License along with the GNU C Library; if not, see
+   <https://www.gnu.org/licenses/>.  */
+
+#include <dlfcn.h>
+#include <getopt.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include <array_length.h>
+#include <support/capture_subprocess.h>
+#include <support/check.h>
+#include <support/support.h>
+
+/* Child mode (--restart): perform the dlopen whose dl_load_lock sites we want
+   rtld to trace, then exit.  */
+static int restart;
+#define CMDLINE_OPTIONS \
+  { "restart", no_argument, &restart, 1 },
+
+static int
+handle_restart (void)
+{
+  void *h = dlopen ("tst-debug-loadlock-mod.so", RTLD_LAZY);
+  if (h == NULL)
+    {
+      fprintf (stderr, "dlopen failed: %s\n", dlerror ());
+      _exit (1);
+    }
+  dlclose (h);
+  _exit (0);
+}
+
+static int
+do_test (int argc, char *argv[])
+{
+  if (restart)
+    return handle_restart ();
+
+  /* Re-exec ourselves under the freshly built ld.so.  argv[1..] holds the
+     ld.so invocation prefix (the tokens of $(host-test-program-cmd)) supplied
+     via tst-debug-loadlock-ARGS; support_test_main has already stripped the
+     leading "--" separator used to terminate option parsing.  */
+  char *spargv[argc + 2];
+  int i = 0;
+  for (; i < argc - 1; i++)
+    spargv[i] = argv[i + 1];
+  spargv[i] = NULL;
+
+  /* Check 1: LD_DEBUG=help lists "loadlock".  rtld prints the table (via
+     _dl_printf, i.e. to stdout) and _exit()s before main runs, so the child
+     need not (and will not) reach handle_restart.  */
+  setenv ("LD_DEBUG", "help", 1);
+  {
+    struct support_capture_subprocess p
+      = support_capture_subprogram (spargv[0], spargv, NULL);
+    support_capture_subprocess_check (&p, "tst-debug-loadlock (help)", 0,
+				      sc_allow_stdout);
+    if (strstr (p.out.buffer, "loadlock") == NULL)
+      {
+	support_record_failure ();
+	printf ("LD_DEBUG=help stdout was:\n%s\n", p.out.buffer);
+	FAIL_EXIT1 ("'loadlock' missing from LD_DEBUG=help output");
+      }
+    support_capture_subprocess_free (&p);
+  }
+
+  /* Check 2: a real dlopen with LD_DEBUG=loadlock emits the dl_load_lock
+     acquire trace for _dl_open and the BZ 15686 release-for-ctor site.
+     Trace goes to stderr (dl_debug_fd defaults to STDERR_FILENO).  */
+  setenv ("LD_DEBUG", "loadlock", 1);
+  {
+    spargv[i++] = (char *) "--restart";
+    spargv[i] = NULL;
+    struct support_capture_subprocess p
+      = support_capture_subprogram (spargv[0], spargv, NULL);
+    support_capture_subprocess_check (&p, "tst-debug-loadlock (loadlock)", 0,
+				      sc_allow_stderr);
+    unsetenv ("LD_DEBUG");
+
+    static const char *const needles[] =
+      {
+	"dl_load_lock acquire at _dl_open",
+	"dl_open_worker(for-ctor)",
+      };
+    for (int k = 0; k < (int) array_length (needles); k++)
+      if (strstr (p.err.buffer, needles[k]) == NULL)
+	{
+	  support_record_failure ();
+	  printf ("LD_DEBUG=loadlock stderr was:\n%s\n", p.err.buffer);
+	  FAIL_EXIT1 ("'%s' missing from trace", needles[k]);
+	}
+    support_capture_subprocess_free (&p);
+  }
+
+  return 0;
+}
+
+#define TEST_FUNCTION_ARGV do_test
+#include <support/test-driver.c>
diff --git a/manual/dynlink.texi b/manual/dynlink.texi
index ad4da753a5..5569830f23 100644
--- a/manual/dynlink.texi
+++ b/manual/dynlink.texi
@@ -407,6 +407,17 @@ Display relocation statistics.
 @item unused
 Determined unused DSOs.
 
+@item loadlock
+Log every acquisition and release of @code{dl_load_lock} on the @code{dlopen}
+constructor path, each followed by a raw return-address backtrace.  Use this to
+diagnose deadlocks of the shape described in
+@uref{https://sourceware.org/bugzilla/show_bug.cgi?id=15686, BZ 15686}, where
+code running inside an ELF constructor (or a thread it spawns) re-enters the
+dynamic linker and blocks on @code{dl_load_lock}.  The backtrace lists raw code
+addresses; resolve them offline with @command{addr2line}.  Frame pointers must
+be present in the code being traced (the default for the dynamic linker on most
+targets).
+
 @item help
 Display a help message with all available options and exit.
 @end table
diff --git a/sysdeps/generic/ldsodefs.h b/sysdeps/generic/ldsodefs.h
index c0deb11c02..d617864efd 100644
--- a/sysdeps/generic/ldsodefs.h
+++ b/sysdeps/generic/ldsodefs.h
@@ -533,6 +533,10 @@ struct rtld_global_ro
 #define DL_DEBUG_HELP       (1 << 10)
 #define DL_DEBUG_TLS        (1 << 11)
 #define DL_DEBUG_SECURITY   (1 << 12)
+/* Trace dl_load_lock acquisitions (with a raw-address backtrace) to help
+   diagnose deadlocks where code running inside a dlopen constructor (or a
+   thread it spawns) re-enters the dynamic linker.  */
+#define DL_DEBUG_LOADLOCK   (1 << 13)
 
   /* Platform name.  */
   EXTERN const char *_dl_platform;
@@ -766,6 +770,14 @@ extern void _dl_debug_printf (const char *fmt, ...)
 extern void _dl_debug_printf_c (const char *fmt, ...)
      __attribute__ ((__format__ (__printf__, 1, 2))) attribute_hidden;
 
+/* Print "dl_load_lock <action> at <site>" followed by a raw-address
+   backtrace obtained by walking the frame-pointer chain.  The caller must
+   have already checked the DL_DEBUG_LOADLOCK mask.  No symbol resolution
+   is performed (it would re-enter the loader and deadlock on
+   dl_load_lock); resolve the printed addresses offline with addr2line.  */
+extern void _dl_debug_loadlock (const char *action, const char *site)
+     attribute_hidden;
+
 
 /* Write a message on the specified descriptor FD.  The parameters are
    interpreted as for a `printf' call.  */
