Linux Modules
 help / color / mirror / Atom feed
* Re: [PATCH v7 1/4] kallsyms: embed source file:line info in kernel stack traces
From: sashiko-bot @ 2026-07-09 16:51 UTC (permalink / raw)
  To: Sasha Levin; +Cc: linux-modules
In-Reply-To: <20260709163833.3851179-2-sashal@kernel.org>

Thank you for your contribution! Sashiko AI review found 4 potential issue(s) to consider:
- [High] Use of `snprintf` without checking for truncation leaks uninitialized kernel memory via trace buffers.
- [Medium] The host tool `scripts/gen_lineinfo` will enter an infinite loop during the kernel build if the number of unique source files exceeds 8,192.
- [Medium] Deduplication logic drops valid lineinfo entries with different offsets, breaking symbol boundary checks.
- [Low] The symbol `lineinfo_filenames_size` is missing from the kallsyms ignore list, exposing it while hiding its siblings.
--

--- Patch [1]: [PATCH v7 1/4] kallsyms: embed source file:line info in kernel stack traces ---
commit ca3ea968a16862e3d8b4586ef0bfc722a4a2f652
Author: Sasha Levin <sashal@kernel.org>

kallsyms: embed source file:line info in kernel stack traces

This commit adds a configuration option to embed a compact address-to-line
lookup table in the kernel image. It generates this table at build time from
DWARF debug information using a host tool. Stack traces can then directly
print source file and line number information without external tools.

> diff --git a/kernel/kallsyms.c b/kernel/kallsyms.c
> index aec2f06858afd..d3fcf282c33cd 100644
> --- a/kernel/kallsyms.c
> +++ b/kernel/kallsyms.c

[ ... ]

> @@ -467,13 +467,77 @@ static int append_buildid(char *buffer,   const char *modname,
>  
>  #endif /* CONFIG_STACKTRACE_BUILD_ID */
>  
> +bool kallsyms_lookup_lineinfo(unsigned long addr, unsigned long sym_start,
> +			      const char **file, unsigned int *line)
> +{
[ ... ]
> +	if (low == 0)
> +		return false;
> +	low--;
> +
> +	if (lineinfo_addrs[low] < min_offset)
> +		return false;

[Severity: Medium]
Will this boundary check unintentionally reject valid entries for functions
that start on the same source line (e.g. via macro instantiation)?

When deduplicate() in scripts/gen_lineinfo.c processes entries, it drops the
second function's entry because it shares the same file and line. When
querying the second function, kallsyms_lookup_lineinfo() finds the first
function's entry but rejects it here since its offset is earlier than the
second function's sym_start.

> +
> +	file_id = lineinfo_file_ids[low];

[ ... ]

> @@ -497,6 +562,23 @@ static int __sprint_symbol(char *buffer, unsigned long address,
>  		len += sprintf(buffer + len, "]");
>  	}
>  
> +	/*
> +	 * Append "(file:line)" only for stack-backtrace consumers.  Plain
> +	 * sprint_symbol() backs %ps, and many existing format strings tack
> +	 * literal "()" after %ps to indicate a function call ("foo()
> +	 * replaced with bar()"); appending lineinfo there would produce a
> +	 * confusing "foo (file:line)()".
> +	 */
> +	if (add_lineinfo && IS_ENABLED(CONFIG_KALLSYMS_LINEINFO) && !modname) {
> +		const char *li_file;
> +		unsigned int li_line;
> +
> +		if (kallsyms_lookup_lineinfo(address, sym_start,
> +					     &li_file, &li_line))
> +			len += snprintf(buffer + len, KSYM_SYMBOL_LEN - len,
> +					" (%s:%u)", li_file, li_line);

[Severity: High]
Does this unconditionally add snprintf()'s return value to the string length?

If the formatted string is truncated, snprintf() returns the length it
would have written. This makes len larger than the actual bytes written to
the buffer.

Can this cause callers (like fetch_store_sym() in the trace framework) to
expose uninitialized kernel memory to userspace by copying the unwritten
portion of the buffer?

> +	}
> +
>  	return len;
>  }

> diff --git a/scripts/gen_lineinfo.c b/scripts/gen_lineinfo.c
> new file mode 100644
> index 0000000000000..699e760178f09
> --- /dev/null
> +++ b/scripts/gen_lineinfo.c

[ ... ]

> +static unsigned int find_or_add_file(const char *name)
> +{
> +	unsigned int h = hash_str(name);
> +
> +	/* Open-addressing lookup with linear probing */
> +	while (file_hash[h].name) {
> +		if (!strcmp(file_hash[h].name, name))
> +			return file_hash[h].id;
> +		h = (h + 1) & (FILE_HASH_SIZE - 1);
> +	}

[Severity: Medium]
Will this open-addressing lookup loop indefinitely if the hash table is fully
saturated?

If a kernel configuration (like allyesconfig) includes 8192 or more unique
source files, the static FILE_HASH_SIZE array becomes completely full.
Any subsequent file lookup will probe all slots without finding a NULL entry
and loop infinitely.

> +
> +	if (num_files >= 65535) {

[ ... ]

> +static void deduplicate(void)
> +{
> +	unsigned int i, j;
> +
> +	if (num_entries < 2)
> +		return;
> +
> +	/* Sort by offset, then file_id, then line for stability */
> +	qsort(entries, num_entries, sizeof(*entries), compare_entries);
> +
> +	/*
> +	 * Remove duplicate entries:
> +	 * - Same offset: keep first (deterministic from stable sort keys)
> +	 * - Same file:line as previous kept entry: redundant for binary
> +	 *   search -- any address between them resolves to the earlier one
> +	 */
> +	j = 0;
> +	for (i = 1; i < num_entries; i++) {
> +		if (entries[i].offset == entries[j].offset)
> +			continue;
> +		if (entries[i].file_id == entries[j].file_id &&
> +		    entries[i].line == entries[j].line)
> +			continue;

[Severity: Medium]
Does this logic erroneously drop valid lineinfo entries that share the same
file and line but have different address offsets?

As mentioned in kallsyms_lookup_lineinfo(), dropping these entries causes
the lookup to fail its symbol boundary check and reject the previous entry.

> +		j++;
> +		if (j != i)
> +			entries[j] = entries[i];
> +	}
> +	num_entries = j + 1;
> +}

> diff --git a/scripts/kallsyms.c b/scripts/kallsyms.c
> index 37d5c095ad22a..42662c4fbc6c9 100644
> --- a/scripts/kallsyms.c
> +++ b/scripts/kallsyms.c

[ ... ]

> @@ -78,6 +78,17 @@ static char *sym_name(const struct sym_entry *s)
>  
>  static bool is_ignored_symbol(const char *name, char type)
>  {
> +	/* Ignore lineinfo symbols for kallsyms pass stability */
> +	static const char * const lineinfo_syms[] = {
> +		"lineinfo_addrs",
> +		"lineinfo_file_ids",
> +		"lineinfo_file_offsets",
> +		"lineinfo_filenames",
> +		"lineinfo_lines",
> +		"lineinfo_num_entries",
> +		"lineinfo_num_files",
> +	};

[Severity: Low]
Is lineinfo_filenames_size intentionally omitted from this ignore list?

Missing this symbol causes it to be exposed in /proc/kallsyms while hiding
its siblings.

> +
>  	if (type == 'u' || type == 'n')
>  		return true;

-- 
Sashiko AI review · https://sashiko.dev/#/patchset/20260709163833.3851179-1-sashal@kernel.org?part=1

^ permalink raw reply

* Re: [PATCH v7 4/4] kallsyms: add KUnit tests for lineinfo feature
From: sashiko-bot @ 2026-07-09 16:50 UTC (permalink / raw)
  To: Sasha Levin; +Cc: linux-modules
In-Reply-To: <20260709163833.3851179-5-sashal@kernel.org>

Thank you for your contribution! Sashiko AI review found 1 potential issue(s) to consider:
- [Medium] Unchecked NULL pointer dereference when allocating test symbol buffer.
--

--- Patch [4]: [PATCH v7 4/4] kallsyms: add KUnit tests for lineinfo feature ---
commit aa7a75a3dcd2a5f7456eb96f884496c36509f6d7
Author: Sasha Levin <sashal@kernel.org>

    kallsyms: add KUnit tests for lineinfo feature

This commit adds a KUnit test module to verify that the kallsyms lineinfo
feature produces correct annotations in stack traces. It also exports
sprint_backtrace() and sprint_backtrace_build_id() so the test module can
exercise the backtrace APIs.

> diff --git a/lib/tests/lineinfo_kunit.c b/lib/tests/lineinfo_kunit.c
> new file mode 100644
> index 0000000000000..dad1522a44f5a
> --- /dev/null
> +++ b/lib/tests/lineinfo_kunit.c
[ ... ]
> +static char *alloc_sym_buf(struct kunit *test)
> +{
> +	return kunit_kzalloc(test, KSYM_SYMBOL_LEN, GFP_KERNEL);
> +}
[ ... ]
> +static void test_normal_function(struct kunit *test)
> +{
> +	char *buf = alloc_sym_buf(test);
> +	unsigned long addr = target_addr(lineinfo_target_normal);
> +
> +	skip_without_module_lineinfo(test);
> +
> +	sprint_with_lineinfo(buf, addr);

[Severity: Medium]
Will this crash the kernel if kunit_kzalloc() fails and returns NULL?

If memory allocation fails under pressure, alloc_sym_buf() returns NULL. The
callers like test_normal_function() pass this buffer unchecked into
sprint_with_lineinfo(), which eventually passes it to kallsyms_lookup_buildid()
as the namebuf parameter.

Looking at kallsyms_lookup_buildid() in kernel/kallsyms.c, the buffer is
dereferenced immediately:

kernel/kallsyms.c:kallsyms_lookup_buildid() {
    ...
    namebuf[0] = 0;
    ...
}

Should alloc_sym_buf() use KUNIT_ASSERT_NOT_NULL(test, buf) to gracefully fail
the test instead of causing a NULL pointer dereference?

-- 
Sashiko AI review · https://sashiko.dev/#/patchset/20260709163833.3851179-1-sashal@kernel.org?part=4

^ permalink raw reply

* Re: [PATCH v7 2/4] kallsyms: extend lineinfo to loadable modules
From: sashiko-bot @ 2026-07-09 16:50 UTC (permalink / raw)
  To: Sasha Levin; +Cc: linux-modules
In-Reply-To: <20260709163833.3851179-3-sashal@kernel.org>

Thank you for your contribution! Sashiko AI review found 3 potential issue(s) to consider:
- [High] Unvalidated module blob offsets cause unaligned memory accesses, potentially triggering alignment faults in critical contexts.
- [Medium] Integer overflow in the `apply_one_dl_reloc` bounds check allows out-of-bounds heap access in the `gen_lineinfo` tool.
- [Low] Dynamically allocated memory for `extra_sections` is never freed in the `gen_lineinfo` tool.
--

--- Patch [2]: [PATCH v7 2/4] kallsyms: extend lineinfo to loadable modules ---
commit be00704ef1eba0bdfe61160ba94617348dfdc7dc
Author: Sasha Levin <sashal@kernel.org>

kallsyms: extend lineinfo to loadable modules

This patch extends the CONFIG_KALLSYMS_LINEINFO feature to loadable kernel
modules. It uses scripts/gen-mod-lineinfo.sh to generate .mod_lineinfo
sections containing binary tables of source file and line number mappings.
At runtime, module_lookup_lineinfo() searches these read-only tables to
provide NMI-safe file and line annotations for module stack traces.

> diff --git a/kernel/module/kallsyms.c b/kernel/module/kallsyms.c
> index 0fc11e45df9b9..4888ce8a7f951 100644
> --- a/kernel/module/kallsyms.c
> +++ b/kernel/module/kallsyms.c
[ ... ]
> @@ -494,3 +494,201 @@ int module_kallsyms_on_each_symbol(const char *modname,
> +static bool module_lookup_lineinfo_section(const void *blob, u32 blob_size,
> +					   u32 hdr_offset,
> +					   unsigned int section_offset,
> +					   unsigned int min_offset,
> +					   const char **file,
> +					   unsigned int *line)
> +{
> +	const struct mod_lineinfo_header *hdr;
> +	const u8 *base;
> +	const u32 *addrs, *lines, *file_offsets;
> +	const u16 *file_ids;
> +	const char *filenames;
> +	u32 num_entries, num_files, filenames_size;
> +	unsigned int low, high, mid;
> +	u16 file_id;
> +
> +	if (hdr_offset > blob_size ||
> +	    blob_size - hdr_offset < sizeof(*hdr))
> +		return false;
> +
> +	base = (const u8 *)blob + hdr_offset;
> +	hdr = (const struct mod_lineinfo_header *)base;

[Severity: High]
Does this code safely handle unaligned memory accesses?

The hdr_offset value is read directly from the binary blob and is never
validated for 4-byte alignment before being cast to struct
mod_lineinfo_header * and subsequently u32 * for the internal arrays.

If a malformed or corrupted .ko file provides an unaligned hdr_offset,
dereferencing these pointers could trigger a hardware alignment trap on
strict-alignment architectures. Since this code is designed to be called
during NMI or panic stack traces, taking an alignment fault here would cause
a recursive fault and hang the system without producing the crash dump.

Could we add an alignment check for hdr_offset before casting it?

[ ... ]
> diff --git a/scripts/gen_lineinfo.c b/scripts/gen_lineinfo.c
> index 699e760178f09..7624098d0cb42 100644
> --- a/scripts/gen_lineinfo.c
> +++ b/scripts/gen_lineinfo.c
[ ... ]
> +static void apply_one_dl_reloc(Elf_Data *dl_data, Elf_Data *sym_data,
> +			       bool target_le,
> +			       unsigned int abs32_type, unsigned int abs64_type,
> +			       GElf_Xword r_info, GElf_Addr r_offset,
> +			       GElf_Sxword addend, bool has_addend)
> +{
> +	GElf_Sym sym;
> +	unsigned int r_type = GELF_R_TYPE(r_info);
> +	size_t r_sym = GELF_R_SYM(r_info);
> +	bool is_abs64;
> +	size_t width;
> +	uint64_t value;
[ ... ]
> +	width = is_abs64 ? 8 : 4;
> +
> +	if (r_offset + width > dl_data->d_size)
> +		return;

[Severity: Medium]
Can this bounds check overflow?

Because r_offset is a 64-bit integer, a maliciously crafted .ko file fed
to the gen_lineinfo tool could provide an extremely large value (such as
0xFFFFFFFFFFFFFFFE). Adding width to it would wrap around to a small
number, successfully bypassing the bounds check.

This could lead to out-of-bounds pointer arithmetic and a subsequent
out-of-bounds write in elf_write_uint() below, potentially causing a crash
or code execution within the kernel build environment.

[ ... ]
> +static void resolve_covered_sections(Elf *elf,
> +				     struct covered_section *sections,
> +				     unsigned int num_sections)
> +{
[ ... ]
> +		if (!covered &&
> +		    (shdr.sh_flags & SHF_EXECINSTR) && (shdr.sh_flags & SHF_ALLOC) &&
> +		    shdr.sh_size && shdr.sh_size <= UINT_MAX) {
> +			struct covered_section *es;
> +
> +			extra_sections = realloc(extra_sections,
> +						 (num_extra_sections + 1) *
> +						 sizeof(*extra_sections));
[ ... ]
> @@ -552,6 +1387,5 @@ int main(int argc, char *argv[])
>  	for (unsigned int i = 0; i < num_files; i++)
>  		free(files[i].name);
>  	free(files);
> -
>  	return 0;
>  }

[Severity: Low]
Does this code leak the dynamically allocated extra_sections array?

In resolve_covered_sections(), extra_sections is allocated and expanded
via realloc(). However, the cleanup block in main() only frees entries
and files, missing the extra_sections array.

While this is a short-lived userspace tool and the OS will automatically
reclaim the memory upon exit, would it be better to explicitly free
extra_sections to keep the cleanup complete?

-- 
Sashiko AI review · https://sashiko.dev/#/patchset/20260709163833.3851179-1-sashal@kernel.org?part=2

^ permalink raw reply

* [PATCH v7 4/4] kallsyms: add KUnit tests for lineinfo feature
From: Sasha Levin @ 2026-07-09 16:38 UTC (permalink / raw)
  To: Andrew Morton, Masahiro Yamada, Luis Chamberlain, Linus Torvalds,
	Richard Weinberger, Juergen Gross, Geert Uytterhoeven,
	James Bottomley
  Cc: Sasha Levin, Jonathan Corbet, Nathan Chancellor, Nicolas Schier,
	Petr Pavlu, Daniel Gomez, Greg KH, Petr Mladek, Steven Rostedt,
	Kees Cook, Peter Zijlstra, Thorsten Leemhuis, Vlastimil Babka,
	Helge Deller, Randy Dunlap, Laurent Pinchart, Vivian Wang,
	Zhen Lei, Sami Tolvanen, linux-kernel, linux-kbuild,
	linux-modules, linux-doc
In-Reply-To: <20260709163833.3851179-1-sashal@kernel.org>

Add a KUnit test module (CONFIG_LINEINFO_KUNIT_TEST) that verifies the
kallsyms lineinfo feature produces correct source file:line annotations
in stack traces.

Export sprint_backtrace() and sprint_backtrace_build_id() as GPL symbols
so the test module can exercise the backtrace APIs.

Assisted-by: Claude:claude-opus-4-6
Signed-off-by: Sasha Levin <sashal@kernel.org>
---
 kernel/kallsyms.c          |   2 +
 lib/Kconfig.debug          |  10 +
 lib/tests/Makefile         |   3 +
 lib/tests/lineinfo_kunit.c | 973 +++++++++++++++++++++++++++++++++++++
 4 files changed, 988 insertions(+)
 create mode 100644 lib/tests/lineinfo_kunit.c

diff --git a/kernel/kallsyms.c b/kernel/kallsyms.c
index ffc65bc4cfc925..15cbcf0f9e0847 100644
--- a/kernel/kallsyms.c
+++ b/kernel/kallsyms.c
@@ -651,6 +651,7 @@ int sprint_backtrace(char *buffer, unsigned long address)
 {
 	return __sprint_symbol(buffer, address, -1, 1, 0, 1);
 }
+EXPORT_SYMBOL_GPL(sprint_backtrace);
 
 /**
  * sprint_backtrace_build_id - Look up a backtrace symbol and return it in a text buffer
@@ -671,6 +672,7 @@ int sprint_backtrace_build_id(char *buffer, unsigned long address)
 {
 	return __sprint_symbol(buffer, address, -1, 1, 1, 1);
 }
+EXPORT_SYMBOL_GPL(sprint_backtrace_build_id);
 
 /* To avoid using get_symbol_offset for every symbol, we carry prefix along. */
 struct kallsym_iter {
diff --git a/lib/Kconfig.debug b/lib/Kconfig.debug
index 1244dcac2294ad..257b5ca442f327 100644
--- a/lib/Kconfig.debug
+++ b/lib/Kconfig.debug
@@ -3092,6 +3092,16 @@ config LONGEST_SYM_KUNIT_TEST
 
 	  If unsure, say N.
 
+config LINEINFO_KUNIT_TEST
+	tristate "KUnit tests for kallsyms lineinfo" if !KUNIT_ALL_TESTS
+	depends on KUNIT && KALLSYMS_LINEINFO
+	default KUNIT_ALL_TESTS
+	help
+	  KUnit tests for the kallsyms source line info feature.
+	  Verifies that stack traces include correct (file.c:line) annotations.
+
+	  If unsure, say N.
+
 config HW_BREAKPOINT_KUNIT_TEST
 	bool "Test hw_breakpoint constraints accounting" if !KUNIT_ALL_TESTS
 	depends on HAVE_HW_BREAKPOINT
diff --git a/lib/tests/Makefile b/lib/tests/Makefile
index 4ead57602eac46..877416f5f70a3e 100644
--- a/lib/tests/Makefile
+++ b/lib/tests/Makefile
@@ -37,6 +37,9 @@ obj-$(CONFIG_LIVEUPDATE_TEST) += liveupdate.o
 CFLAGS_longest_symbol_kunit.o += $(call cc-disable-warning, missing-prototypes)
 obj-$(CONFIG_LONGEST_SYM_KUNIT_TEST) += longest_symbol_kunit.o
 
+CFLAGS_lineinfo_kunit.o += $(call cc-option,-fno-inline-functions-called-once)
+obj-$(CONFIG_LINEINFO_KUNIT_TEST) += lineinfo_kunit.o
+
 obj-$(CONFIG_MEMCPY_KUNIT_TEST) += memcpy_kunit.o
 obj-$(CONFIG_MIN_HEAP_KUNIT_TEST) += min_heap_kunit.o
 CFLAGS_overflow_kunit.o = $(call cc-disable-warning, tautological-constant-out-of-range-compare)
diff --git a/lib/tests/lineinfo_kunit.c b/lib/tests/lineinfo_kunit.c
new file mode 100644
index 00000000000000..dad1522a44f5af
--- /dev/null
+++ b/lib/tests/lineinfo_kunit.c
@@ -0,0 +1,973 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * KUnit tests for kallsyms lineinfo (CONFIG_KALLSYMS_LINEINFO).
+ *
+ * Copyright (c) 2026 Sasha Levin <sashal@kernel.org>
+ *
+ * Verifies that sprint_symbol() and related APIs append correct
+ * " (file.c:NNN)" annotations to kernel symbol lookups.
+ *
+ * Build with: CONFIG_LINEINFO_KUNIT_TEST=m (or =y)
+ * Run with:   ./tools/testing/kunit/kunit.py run lineinfo
+ */
+
+#include <kunit/test.h>
+#include <linux/kallsyms.h>
+#include <linux/module.h>
+#include <asm/sections.h>
+#include <linux/smp.h>
+#include <linux/string.h>
+#include <linux/slab.h>
+#include <linux/mod_lineinfo.h>
+
+/* --------------- helpers --------------- */
+
+static char *alloc_sym_buf(struct kunit *test)
+{
+	return kunit_kzalloc(test, KSYM_SYMBOL_LEN, GFP_KERNEL);
+}
+
+/*
+ * Function-pointer casts yield the descriptor address on architectures
+ * with function descriptors (e.g. ppc64 ELFv1, parisc64); resolve to the
+ * code address the lineinfo tables actually cover.
+ */
+static unsigned long target_addr(const void *fn)
+{
+	return (unsigned long)dereference_function_descriptor((void *)fn);
+}
+
+/*
+ * When the test is built as a module but CONFIG_KALLSYMS_LINEINFO_MODULES
+ * is disabled, the test module's own functions have no lineinfo tables, so
+ * every test that expects annotations on local targets must skip rather
+ * than fail.
+ */
+static void skip_without_module_lineinfo(struct kunit *test)
+{
+	if (IS_MODULE(CONFIG_LINEINFO_KUNIT_TEST) &&
+	    !IS_ENABLED(CONFIG_KALLSYMS_LINEINFO_MODULES))
+		kunit_skip(test,
+			   "KALLSYMS_LINEINFO_MODULES disabled; test module has no lineinfo");
+}
+
+/*
+ * Format a symbol with lineinfo annotation.  Lineinfo is appended only
+ * via the sprint_backtrace*() entry points (kernel/kallsyms.c only adds
+ * the "(file:line)" suffix in stack-trace context — sprint_symbol() is
+ * used by %ps and many existing format strings tack literal "()" after
+ * %ps, where the annotation would render as "foo (file:line)()").
+ *
+ * sprint_backtrace() subtracts 1 from the address to handle tail-call
+ * return-address corrections; pass @addr + 1 to recover the original.
+ */
+static int sprint_with_lineinfo(char *buf, unsigned long addr)
+{
+	return sprint_backtrace(buf, addr + 1);
+}
+
+/*
+ * Return true if @buf contains a lineinfo annotation matching
+ * the pattern " (<path>:<digits>)".
+ *
+ * The path may be a full path like "lib/tests/lineinfo_kunit.c" or
+ * a shortened form from module lineinfo (e.g., just a directory name).
+ */
+static bool has_lineinfo(const char *buf)
+{
+	const char *p, *colon, *end;
+
+	p = strstr(buf, " (");
+	if (!p)
+		return false;
+	p += 2; /* skip " (" */
+
+	colon = strchr(p, ':');
+	if (!colon || colon == p)
+		return false;
+
+	/* After colon: one or more digits then ')' */
+	end = colon + 1;
+	if (*end < '0' || *end > '9')
+		return false;
+	while (*end >= '0' && *end <= '9')
+		end++;
+	return *end == ')';
+}
+
+/*
+ * Extract line number from a lineinfo annotation.
+ * Returns 0 if not found.
+ */
+static unsigned int extract_line(const char *buf)
+{
+	const char *p, *colon;
+	unsigned int line = 0;
+
+	p = strstr(buf, " (");
+	if (!p)
+		return 0;
+
+	colon = strchr(p + 2, ':');
+	if (!colon)
+		return 0;
+
+	colon++;
+	while (*colon >= '0' && *colon <= '9') {
+		line = line * 10 + (*colon - '0');
+		colon++;
+	}
+	return line;
+}
+
+/*
+ * Check if the lineinfo annotation contains the given filename substring.
+ */
+static bool lineinfo_contains_file(const char *buf, const char *name)
+{
+	const char *p, *colon;
+
+	p = strstr(buf, " (");
+	if (!p)
+		return false;
+
+	colon = strchr(p + 2, ':');
+	if (!colon)
+		return false;
+
+	/* Search for @name between '(' and ':' */
+	return strnstr(p + 1, name, colon - p - 1) != NULL;
+}
+
+/* --------------- target functions --------------- */
+
+static noinline int lineinfo_target_normal(void)
+{
+	barrier();
+	return 42;
+}
+
+static noinline int lineinfo_target_short(void)
+{
+	barrier();
+	return 1;
+}
+
+static noinline int lineinfo_target_with_arg(int x)
+{
+	barrier();
+	return x + 1;
+}
+
+static noinline int lineinfo_target_many_lines(void)
+{
+	/*
+	 * volatile forces a load/store per statement so the function keeps
+	 * one line-table row per source line (and a body large enough for
+	 * mid-function lookups) at any optimization level; without it the
+	 * compiler folds the arithmetic to a single constant.
+	 */
+	volatile int a = 0;
+
+	barrier();
+	a += 1;
+	a += 2;
+	a += 3;
+	a += 4;
+	a += 5;
+	a += 6;
+	a += 7;
+	a += 8;
+	a += 9;
+	a += 10;
+	barrier();
+	return a;
+}
+
+static __always_inline int lineinfo_inline_helper(void)
+{
+	return 99;
+}
+
+static noinline int lineinfo_inline_caller(void)
+{
+	barrier();
+	return lineinfo_inline_helper();
+}
+
+/* 10-deep call chain */
+static noinline int lineinfo_chain_10(void) { barrier(); return 10; }
+static noinline int lineinfo_chain_9(void)  { barrier(); return lineinfo_chain_10(); }
+static noinline int lineinfo_chain_8(void)  { barrier(); return lineinfo_chain_9(); }
+static noinline int lineinfo_chain_7(void)  { barrier(); return lineinfo_chain_8(); }
+static noinline int lineinfo_chain_6(void)  { barrier(); return lineinfo_chain_7(); }
+static noinline int lineinfo_chain_5(void)  { barrier(); return lineinfo_chain_6(); }
+static noinline int lineinfo_chain_4(void)  { barrier(); return lineinfo_chain_5(); }
+static noinline int lineinfo_chain_3(void)  { barrier(); return lineinfo_chain_4(); }
+static noinline int lineinfo_chain_2(void)  { barrier(); return lineinfo_chain_3(); }
+static noinline int lineinfo_chain_1(void)  { barrier(); return lineinfo_chain_2(); }
+
+/* --------------- Group A: Basic lineinfo presence --------------- */
+
+static void test_normal_function(struct kunit *test)
+{
+	char *buf = alloc_sym_buf(test);
+	unsigned long addr = target_addr(lineinfo_target_normal);
+
+	skip_without_module_lineinfo(test);
+
+	sprint_with_lineinfo(buf, addr);
+	KUNIT_EXPECT_TRUE_MSG(test, has_lineinfo(buf),
+			      "No lineinfo in: %s", buf);
+	KUNIT_EXPECT_TRUE_MSG(test,
+			      lineinfo_contains_file(buf, "lineinfo_kunit.c"),
+			      "Wrong file in: %s", buf);
+}
+
+static void test_static_function(struct kunit *test)
+{
+	char *buf = alloc_sym_buf(test);
+	unsigned long addr = target_addr(lineinfo_target_short);
+
+	skip_without_module_lineinfo(test);
+
+	sprint_with_lineinfo(buf, addr);
+	KUNIT_EXPECT_TRUE_MSG(test, has_lineinfo(buf),
+			      "No lineinfo in: %s", buf);
+}
+
+static void test_noinline_function(struct kunit *test)
+{
+	char *buf = alloc_sym_buf(test);
+	unsigned long addr = target_addr(lineinfo_target_with_arg);
+
+	skip_without_module_lineinfo(test);
+
+	sprint_with_lineinfo(buf, addr);
+	KUNIT_EXPECT_TRUE_MSG(test, has_lineinfo(buf),
+			      "No lineinfo in: %s", buf);
+}
+
+static void test_inline_function(struct kunit *test)
+{
+	char *buf = alloc_sym_buf(test);
+	unsigned long addr = target_addr(lineinfo_inline_caller);
+
+	skip_without_module_lineinfo(test);
+
+	sprint_with_lineinfo(buf, addr);
+	KUNIT_EXPECT_TRUE_MSG(test, has_lineinfo(buf),
+			      "No lineinfo for inline caller in: %s", buf);
+	KUNIT_EXPECT_TRUE_MSG(test,
+			      lineinfo_contains_file(buf, "lineinfo_kunit.c"),
+			      "Wrong file in: %s", buf);
+}
+
+static void test_short_function(struct kunit *test)
+{
+	char *buf = alloc_sym_buf(test);
+	unsigned long addr = target_addr(lineinfo_target_short);
+
+	skip_without_module_lineinfo(test);
+
+	sprint_with_lineinfo(buf, addr);
+	KUNIT_EXPECT_TRUE_MSG(test, has_lineinfo(buf),
+			      "No lineinfo for short function in: %s", buf);
+}
+
+static void test_many_lines_function(struct kunit *test)
+{
+	char *buf = alloc_sym_buf(test);
+	unsigned long addr = target_addr(lineinfo_target_many_lines);
+	unsigned int line;
+
+	skip_without_module_lineinfo(test);
+
+	sprint_with_lineinfo(buf, addr);
+	KUNIT_EXPECT_TRUE_MSG(test, has_lineinfo(buf),
+			      "No lineinfo in: %s", buf);
+	line = extract_line(buf);
+	KUNIT_EXPECT_GT_MSG(test, line, (unsigned int)0,
+			    "Line number should be > 0 in: %s", buf);
+}
+
+/* --------------- Group B: Deep call chain --------------- */
+
+typedef int (*chain_fn_t)(void);
+
+static void test_deep_call_chain(struct kunit *test)
+{
+	static const chain_fn_t chain_fns[] = {
+		lineinfo_chain_1,  lineinfo_chain_2,
+		lineinfo_chain_3,  lineinfo_chain_4,
+		lineinfo_chain_5,  lineinfo_chain_6,
+		lineinfo_chain_7,  lineinfo_chain_8,
+		lineinfo_chain_9,  lineinfo_chain_10,
+	};
+	char *buf = alloc_sym_buf(test);
+	int i, found = 0;
+
+	skip_without_module_lineinfo(test);
+
+	/* Call chain to prevent dead-code elimination */
+	KUNIT_ASSERT_EQ(test, lineinfo_chain_1(), 10);
+
+	for (i = 0; i < ARRAY_SIZE(chain_fns); i++) {
+		unsigned long addr = target_addr(chain_fns[i]);
+
+		sprint_with_lineinfo(buf, addr);
+		if (has_lineinfo(buf))
+			found++;
+	}
+
+	/*
+	 * Not every tiny function gets DWARF line info (compiler may
+	 * omit it for very small stubs), but at least some should.
+	 */
+	KUNIT_EXPECT_GT_MSG(test, found, 0,
+			    "None of the 10 chain functions had lineinfo");
+}
+
+/* --------------- Group C: sprint_symbol API variants --------------- */
+
+static void test_sprint_symbol_format(struct kunit *test)
+{
+	char *buf = alloc_sym_buf(test);
+	unsigned long addr = target_addr(lineinfo_target_normal);
+
+	sprint_symbol(buf, addr);
+
+	/* Should contain +0x and /0x for offset/size */
+	KUNIT_EXPECT_NOT_NULL_MSG(test, strstr(buf, "+0x"),
+				  "Missing offset in: %s", buf);
+	KUNIT_EXPECT_NOT_NULL_MSG(test, strstr(buf, "/0x"),
+				  "Missing size in: %s", buf);
+	/*
+	 * sprint_symbol() backs %ps, which existing format strings combine
+	 * with literal "()" to indicate function calls; the lineinfo suffix
+	 * is intentionally omitted there to avoid "foo (file:line)()".
+	 */
+	KUNIT_EXPECT_FALSE_MSG(test, has_lineinfo(buf),
+			       "Unexpected lineinfo in sprint_symbol output: %s",
+			       buf);
+}
+
+static void test_sprint_backtrace(struct kunit *test)
+{
+	char *buf = alloc_sym_buf(test);
+	unsigned long addr = target_addr(lineinfo_target_normal);
+
+	skip_without_module_lineinfo(test);
+
+	/* sprint_backtrace subtracts 1 internally to handle tail calls */
+	sprint_backtrace(buf, addr + 1);
+	KUNIT_EXPECT_TRUE_MSG(test, has_lineinfo(buf),
+			      "No lineinfo in backtrace: %s", buf);
+	KUNIT_EXPECT_TRUE_MSG(test,
+			      lineinfo_contains_file(buf, "lineinfo_kunit.c"),
+			      "Wrong file in backtrace: %s", buf);
+}
+
+static void test_sprint_backtrace_build_id(struct kunit *test)
+{
+	char *buf = alloc_sym_buf(test);
+	unsigned long addr = target_addr(lineinfo_target_normal);
+
+	skip_without_module_lineinfo(test);
+
+	sprint_backtrace_build_id(buf, addr + 1);
+	KUNIT_EXPECT_TRUE_MSG(test, has_lineinfo(buf),
+			      "No lineinfo in backtrace_build_id: %s", buf);
+}
+
+static void test_sprint_symbol_no_offset(struct kunit *test)
+{
+	char *buf = alloc_sym_buf(test);
+	unsigned long addr = target_addr(lineinfo_target_normal);
+
+	sprint_symbol_no_offset(buf, addr);
+	/* No "+0x" in output */
+	KUNIT_EXPECT_NULL_MSG(test, strstr(buf, "+0x"),
+			      "Unexpected offset in no_offset: %s", buf);
+	/* sprint_symbol_no_offset is a sprint_symbol() variant; lineinfo is
+	 * intentionally only appended in sprint_backtrace*() context.
+	 */
+	KUNIT_EXPECT_FALSE_MSG(test, has_lineinfo(buf),
+			       "Unexpected lineinfo in no_offset: %s", buf);
+}
+
+/* --------------- Group D: printk format specifiers --------------- */
+
+static void test_pS_format(struct kunit *test)
+{
+	char *buf = alloc_sym_buf(test);
+	void *addr = lineinfo_target_normal;
+
+	snprintf(buf, KSYM_SYMBOL_LEN, "%pS", addr);
+	/*
+	 * %pS uses sprint_symbol(), which intentionally omits the lineinfo
+	 * suffix (see kernel/kallsyms.c::__sprint_symbol).  Lineinfo is only
+	 * added via the sprint_backtrace*() entry points, which back %pBb.
+	 */
+	KUNIT_EXPECT_FALSE_MSG(test, has_lineinfo(buf),
+			       "Unexpected lineinfo in %%pS: %s", buf);
+}
+
+static void test_pBb_format(struct kunit *test)
+{
+	char *buf = alloc_sym_buf(test);
+	/*
+	 * %pBb uses sprint_backtrace_build_id which subtracts 1 from the
+	 * address, so pass addr+1 to resolve back to the function.
+	 */
+	void *addr = (void *)(target_addr(lineinfo_target_normal) + 1);
+
+	skip_without_module_lineinfo(test);
+
+	snprintf(buf, KSYM_SYMBOL_LEN, "%pBb", addr);
+	KUNIT_EXPECT_TRUE_MSG(test, has_lineinfo(buf),
+			      "No lineinfo in %%pBb: %s", buf);
+}
+
+static void test_pSR_format(struct kunit *test)
+{
+	char *buf = alloc_sym_buf(test);
+	void *addr = lineinfo_target_normal;
+
+	snprintf(buf, KSYM_SYMBOL_LEN, "%pSR", addr);
+	/* %pSR is a sprint_symbol() variant; same rationale as %pS. */
+	KUNIT_EXPECT_FALSE_MSG(test, has_lineinfo(buf),
+			       "Unexpected lineinfo in %%pSR: %s", buf);
+}
+
+/* --------------- Group E: Address edge cases --------------- */
+
+static void test_symbol_start_addr(struct kunit *test)
+{
+	char *buf = alloc_sym_buf(test);
+	unsigned long addr = target_addr(lineinfo_target_normal);
+
+	skip_without_module_lineinfo(test);
+
+	/*
+	 * sprint_backtrace() subtracts 1 from the input and reports offset
+	 * relative to the (decremented) address, so an exact "+0x0/" can't
+	 * be expected here.  Verify the symbol resolves and carries lineinfo.
+	 */
+	sprint_with_lineinfo(buf, addr);
+	KUNIT_EXPECT_TRUE_MSG(test,
+			      strnstr(buf, "lineinfo_target_normal",
+				      KSYM_SYMBOL_LEN) != NULL,
+			      "Didn't resolve to expected function: %s", buf);
+	KUNIT_EXPECT_TRUE_MSG(test, has_lineinfo(buf),
+			      "No lineinfo at function start: %s", buf);
+}
+
+static void test_symbol_nonzero_offset(struct kunit *test)
+{
+	char *buf = alloc_sym_buf(test);
+	unsigned long addr = target_addr(lineinfo_target_normal);
+
+	skip_without_module_lineinfo(test);
+
+	/*
+	 * sprint_backtrace subtracts 1 internally.
+	 * Passing addr+2 resolves to addr+1 which is inside the function
+	 * at a non-zero offset.
+	 */
+	sprint_backtrace(buf, addr + 2);
+	KUNIT_EXPECT_TRUE_MSG(test,
+			      strnstr(buf, "lineinfo_target_normal",
+				      KSYM_SYMBOL_LEN) != NULL,
+			      "Didn't resolve to expected function: %s", buf);
+	KUNIT_EXPECT_TRUE_MSG(test, has_lineinfo(buf),
+			      "No lineinfo at non-zero offset: %s", buf);
+}
+
+static void test_unknown_address(struct kunit *test)
+{
+	char *buf = alloc_sym_buf(test);
+
+	sprint_symbol(buf, 1UL);
+	/* Should be "0x1" with no lineinfo */
+	KUNIT_EXPECT_NOT_NULL_MSG(test, strstr(buf, "0x1"),
+				  "Expected hex address for bogus addr: %s", buf);
+	KUNIT_EXPECT_FALSE_MSG(test, has_lineinfo(buf),
+			       "Unexpected lineinfo for bogus addr: %s", buf);
+}
+
+static void test_kernel_function_lineinfo(struct kunit *test)
+{
+	char *buf = alloc_sym_buf(test);
+	unsigned long addr = target_addr(sprint_symbol);
+
+	sprint_with_lineinfo(buf, addr);
+	KUNIT_EXPECT_TRUE_MSG(test, has_lineinfo(buf),
+			      "No lineinfo for sprint_symbol: %s", buf);
+	KUNIT_EXPECT_TRUE_MSG(test,
+			      lineinfo_contains_file(buf, "kallsyms.c"),
+			      "Expected kallsyms.c in: %s", buf);
+}
+
+static void test_assembly_no_lineinfo(struct kunit *test)
+{
+#if IS_BUILTIN(CONFIG_LINEINFO_KUNIT_TEST)
+	char *buf = alloc_sym_buf(test);
+	unsigned long addr = (unsigned long)_text;
+
+	sprint_with_lineinfo(buf, addr);
+	/*
+	 * _text is typically an asm entry point with no DWARF line info.
+	 * If it has lineinfo, it's a C-based entry — skip in that case.
+	 */
+	if (has_lineinfo(buf))
+		kunit_skip(test, "_text has lineinfo (C entry?): %s", buf);
+
+	KUNIT_EXPECT_FALSE_MSG(test, has_lineinfo(buf),
+			       "Unexpected lineinfo for asm symbol: %s", buf);
+#else
+	kunit_skip(test, "_text not accessible from modules");
+#endif
+}
+
+static void test_init_text_no_stale_lineinfo(struct kunit *test)
+{
+#if IS_BUILTIN(CONFIG_LINEINFO_KUNIT_TEST)
+	char *buf = alloc_sym_buf(test);
+	unsigned long addr = (unsigned long)_sinittext + 16;
+
+	/*
+	 * .init.text lies past the _etext cap of the vmlinux lineinfo
+	 * table, so a lookup there must not inherit the annotation of
+	 * the last covered .text entry.
+	 */
+	sprint_with_lineinfo(buf, addr);
+	KUNIT_EXPECT_FALSE_MSG(test, has_lineinfo(buf),
+			       "Stale lineinfo for init-text address: %s", buf);
+#else
+	kunit_skip(test, "_sinittext not accessible from modules");
+#endif
+}
+
+/* --------------- Group F: Module path --------------- */
+
+static void test_module_function_lineinfo(struct kunit *test)
+{
+	char *buf = alloc_sym_buf(test);
+	unsigned long addr = target_addr(lineinfo_target_normal);
+
+	skip_without_module_lineinfo(test);
+
+	if (!IS_MODULE(CONFIG_LINEINFO_KUNIT_TEST)) {
+		kunit_skip(test, "Test only meaningful when built as module");
+		return;
+	}
+
+	sprint_with_lineinfo(buf, addr);
+	KUNIT_EXPECT_NOT_NULL_MSG(test,
+				  strstr(buf, "[lineinfo_kunit"),
+				  "Missing module name in: %s", buf);
+	KUNIT_EXPECT_TRUE_MSG(test, has_lineinfo(buf),
+			      "No lineinfo for module function: %s", buf);
+	KUNIT_EXPECT_TRUE_MSG(test,
+			      lineinfo_contains_file(buf, "lineinfo_kunit.c"),
+			      "Wrong file for module function: %s", buf);
+}
+
+/* --------------- Group G: Stress --------------- */
+
+struct lineinfo_stress_data {
+	unsigned long addr;
+	atomic_t failures;
+};
+
+static void lineinfo_stress_fn(void *info)
+{
+	struct lineinfo_stress_data *data = info;
+	char buf[KSYM_SYMBOL_LEN];
+	int i;
+
+	for (i = 0; i < 100; i++) {
+		sprint_with_lineinfo(buf, data->addr);
+		if (!has_lineinfo(buf))
+			atomic_inc(&data->failures);
+	}
+}
+
+static void test_concurrent_sprint_symbol(struct kunit *test)
+{
+	struct lineinfo_stress_data data;
+
+	skip_without_module_lineinfo(test);
+
+	data.addr = target_addr(lineinfo_target_normal);
+	atomic_set(&data.failures, 0);
+
+	on_each_cpu(lineinfo_stress_fn, &data, 1);
+
+	KUNIT_EXPECT_EQ_MSG(test, atomic_read(&data.failures), 0,
+			    "Concurrent lineinfo failures detected");
+}
+
+static void test_rapid_sprint_symbol(struct kunit *test)
+{
+	char *buf = alloc_sym_buf(test);
+	unsigned long addr = target_addr(lineinfo_target_normal);
+	int i, failures = 0;
+
+	skip_without_module_lineinfo(test);
+
+	for (i = 0; i < 1000; i++) {
+		sprint_with_lineinfo(buf, addr);
+		if (!has_lineinfo(buf))
+			failures++;
+	}
+
+	KUNIT_EXPECT_EQ_MSG(test, failures, 0,
+			    "Rapid sprint_symbol failures: %d/1000", failures);
+}
+
+/* --------------- Group H: Safety and plausibility --------------- */
+
+static void test_line_number_plausible(struct kunit *test)
+{
+	char *buf = alloc_sym_buf(test);
+	unsigned long addr = target_addr(lineinfo_target_normal);
+	unsigned int line;
+
+	skip_without_module_lineinfo(test);
+
+	sprint_with_lineinfo(buf, addr);
+	KUNIT_ASSERT_TRUE(test, has_lineinfo(buf));
+
+	line = extract_line(buf);
+	KUNIT_EXPECT_GT_MSG(test, line, (unsigned int)0,
+			    "Line number should be > 0");
+	KUNIT_EXPECT_LT_MSG(test, line, (unsigned int)10000,
+			    "Line number %u implausibly large for this file",
+			    line);
+}
+
+static void test_buffer_no_overflow(struct kunit *test)
+{
+	const size_t canary_size = 16;
+	char *buf;
+	int i;
+
+	buf = kunit_kzalloc(test, KSYM_SYMBOL_LEN + canary_size, GFP_KERNEL);
+	KUNIT_ASSERT_NOT_NULL(test, buf);
+
+	/* Fill canary area past KSYM_SYMBOL_LEN with 0xAA */
+	memset(buf + KSYM_SYMBOL_LEN, 0xAA, canary_size);
+
+	sprint_with_lineinfo(buf, target_addr(lineinfo_target_normal));
+
+	/* Verify canary bytes are untouched */
+	for (i = 0; i < canary_size; i++) {
+		KUNIT_EXPECT_EQ_MSG(test,
+				    (unsigned char)buf[KSYM_SYMBOL_LEN + i],
+				    (unsigned char)0xAA,
+				    "Buffer overflow at offset %d past KSYM_SYMBOL_LEN",
+				    i);
+	}
+}
+
+static void test_dump_stack_no_crash(struct kunit *test)
+{
+	/* Just verify dump_stack() completes without panic */
+	dump_stack();
+	KUNIT_SUCCEED(test);
+}
+
+static void test_sprint_symbol_build_id(struct kunit *test)
+{
+	char *buf = alloc_sym_buf(test);
+	unsigned long addr = target_addr(lineinfo_target_normal);
+
+	sprint_symbol_build_id(buf, addr);
+	/* Lineinfo is appended only via sprint_backtrace*(); the symbol
+	 * variants intentionally omit it to avoid clashing with format
+	 * strings that already wrap %ps in literal "()".
+	 */
+	KUNIT_EXPECT_FALSE_MSG(test, has_lineinfo(buf),
+			       "Unexpected lineinfo in sprint_symbol_build_id: %s",
+			       buf);
+}
+
+static void test_sleb128_edge_cases(struct kunit *test)
+{
+	u32 pos;
+	int32_t result;
+
+	/* Value 0: single byte 0x00 */
+	{
+		static const u8 data[] = { 0x00 };
+
+		pos = 0;
+		result = lineinfo_read_sleb128(data, &pos, sizeof(data));
+		KUNIT_EXPECT_EQ(test, result, (int32_t)0);
+		KUNIT_EXPECT_EQ(test, pos, (u32)1);
+	}
+
+	/* Value -1: single byte 0x7F */
+	{
+		static const u8 data[] = { 0x7f };
+
+		pos = 0;
+		result = lineinfo_read_sleb128(data, &pos, sizeof(data));
+		KUNIT_EXPECT_EQ(test, result, (int32_t)-1);
+		KUNIT_EXPECT_EQ(test, pos, (u32)1);
+	}
+
+	/* Value 1: single byte 0x01 */
+	{
+		static const u8 data[] = { 0x01 };
+
+		pos = 0;
+		result = lineinfo_read_sleb128(data, &pos, sizeof(data));
+		KUNIT_EXPECT_EQ(test, result, (int32_t)1);
+		KUNIT_EXPECT_EQ(test, pos, (u32)1);
+	}
+
+	/* Value -64: single byte 0x40 */
+	{
+		static const u8 data[] = { 0x40 };
+
+		pos = 0;
+		result = lineinfo_read_sleb128(data, &pos, sizeof(data));
+		KUNIT_EXPECT_EQ(test, result, (int32_t)-64);
+		KUNIT_EXPECT_EQ(test, pos, (u32)1);
+	}
+
+	/* Value 63: single byte 0x3F */
+	{
+		static const u8 data[] = { 0x3f };
+
+		pos = 0;
+		result = lineinfo_read_sleb128(data, &pos, sizeof(data));
+		KUNIT_EXPECT_EQ(test, result, (int32_t)63);
+		KUNIT_EXPECT_EQ(test, pos, (u32)1);
+	}
+
+	/* Value -128: two bytes 0x80 0x7F */
+	{
+		static const u8 data[] = { 0x80, 0x7f };
+
+		pos = 0;
+		result = lineinfo_read_sleb128(data, &pos, sizeof(data));
+		KUNIT_EXPECT_EQ(test, result, (int32_t)-128);
+		KUNIT_EXPECT_EQ(test, pos, (u32)2);
+	}
+
+	/* Value INT32_MIN: five bytes, high bits land at shift 28 */
+	{
+		static const u8 data[] = { 0x80, 0x80, 0x80, 0x80, 0x78 };
+		const u8 *p = data;
+
+		/* Defeat constant folding so the runtime decoder runs. */
+		OPTIMIZER_HIDE_VAR(p);
+		pos = 0;
+		result = lineinfo_read_sleb128(p, &pos, sizeof(data));
+		KUNIT_EXPECT_EQ(test, result, (int32_t)INT_MIN);
+		KUNIT_EXPECT_EQ(test, pos, (u32)5);
+	}
+
+	/* Value INT32_MAX: five bytes */
+	{
+		static const u8 data[] = { 0xff, 0xff, 0xff, 0xff, 0x07 };
+		const u8 *p = data;
+
+		OPTIMIZER_HIDE_VAR(p);
+		pos = 0;
+		result = lineinfo_read_sleb128(p, &pos, sizeof(data));
+		KUNIT_EXPECT_EQ(test, result, (int32_t)INT_MAX);
+		KUNIT_EXPECT_EQ(test, pos, (u32)5);
+	}
+}
+
+static void test_uleb128_edge_cases(struct kunit *test)
+{
+	u32 pos, result;
+
+	/* Value 0: single byte 0x00 */
+	{
+		static const u8 data[] = { 0x00 };
+
+		pos = 0;
+		result = lineinfo_read_uleb128(data, &pos, sizeof(data));
+		KUNIT_EXPECT_EQ(test, result, (u32)0);
+		KUNIT_EXPECT_EQ(test, pos, (u32)1);
+	}
+
+	/* Value 127: single byte 0x7F */
+	{
+		static const u8 data[] = { 0x7F };
+
+		pos = 0;
+		result = lineinfo_read_uleb128(data, &pos, sizeof(data));
+		KUNIT_EXPECT_EQ(test, result, (u32)127);
+		KUNIT_EXPECT_EQ(test, pos, (u32)1);
+	}
+
+	/* Value 128: two bytes 0x80 0x01 */
+	{
+		static const u8 data[] = { 0x80, 0x01 };
+
+		pos = 0;
+		result = lineinfo_read_uleb128(data, &pos, sizeof(data));
+		KUNIT_EXPECT_EQ(test, result, (u32)128);
+		KUNIT_EXPECT_EQ(test, pos, (u32)2);
+	}
+
+	/* Max u32 0xFFFFFFFF: 5 bytes */
+	{
+		static const u8 data[] = { 0xFF, 0xFF, 0xFF, 0xFF, 0x0F };
+
+		pos = 0;
+		result = lineinfo_read_uleb128(data, &pos, sizeof(data));
+		KUNIT_EXPECT_EQ(test, result, (u32)0xFFFFFFFF);
+		KUNIT_EXPECT_EQ(test, pos, (u32)5);
+	}
+
+	/* Truncated input: pos >= end returns 0 */
+	{
+		static const u8 data[] = { 0x80 };
+
+		pos = 0;
+		result = lineinfo_read_uleb128(data, &pos, 0);
+		KUNIT_EXPECT_EQ_MSG(test, result, (u32)0,
+				    "Expected 0 for empty input");
+	}
+
+	/* Truncated mid-varint: continuation byte but end reached */
+	{
+		static const u8 data[] = { 0x80 };
+
+		pos = 0;
+		result = lineinfo_read_uleb128(data, &pos, 1);
+		KUNIT_EXPECT_EQ_MSG(test, result, (u32)0,
+				    "Expected 0 for truncated varint");
+		KUNIT_EXPECT_EQ(test, pos, (u32)1);
+	}
+}
+
+static void test_line_number_accuracy(struct kunit *test)
+{
+	char *buf = alloc_sym_buf(test);
+	unsigned long addr = target_addr(lineinfo_target_normal);
+	unsigned int line;
+
+	skip_without_module_lineinfo(test);
+
+	sprint_with_lineinfo(buf, addr);
+	KUNIT_ASSERT_TRUE(test, has_lineinfo(buf));
+
+	line = extract_line(buf);
+
+	/*
+	 * lineinfo_target_normal is defined around line 103-107.
+	 * Allow wide range: KASAN instrumentation and module lineinfo
+	 * address mapping can shift the reported line significantly.
+	 */
+	KUNIT_EXPECT_GE_MSG(test, line, (unsigned int)50,
+			    "Line %u too low for lineinfo_target_normal", line);
+	KUNIT_EXPECT_LE_MSG(test, line, (unsigned int)300,
+			    "Line %u too high for lineinfo_target_normal", line);
+}
+
+static void test_many_lines_mid_function(struct kunit *test)
+{
+	char *buf = alloc_sym_buf(test);
+	unsigned long addr = target_addr(lineinfo_target_many_lines);
+	unsigned int line;
+	unsigned long mid_addr;
+
+	skip_without_module_lineinfo(test);
+
+	/* Get function size from sprint_with_lineinfo output */
+	sprint_with_lineinfo(buf, addr);
+	KUNIT_ASSERT_TRUE(test, has_lineinfo(buf));
+
+	/*
+	 * Try an address 8 bytes into the function (past prologue).  Hide
+	 * the base from the compiler so this does not become a static
+	 * mid-function relocation (sym+8), which objtool flags as a
+	 * relocation to !ENDBR with CONFIG_X86_KERNEL_IBT.
+	 */
+	OPTIMIZER_HIDE_VAR(addr);
+	mid_addr = addr + 8;
+	sprint_with_lineinfo(buf, mid_addr);
+
+	/*
+	 * Should still resolve to lineinfo_target_many_lines.
+	 * Lineinfo should be present with a plausible line number.
+	 */
+	KUNIT_EXPECT_TRUE_MSG(test,
+			      strnstr(buf, "lineinfo_target_many_lines",
+				      KSYM_SYMBOL_LEN) != NULL,
+			      "Mid-function addr resolved to wrong symbol: %s",
+			      buf);
+	if (has_lineinfo(buf)) {
+		line = extract_line(buf);
+		KUNIT_EXPECT_GE_MSG(test, line, (unsigned int)50,
+				    "Line %u too low for mid-function", line);
+		KUNIT_EXPECT_LE_MSG(test, line, (unsigned int)700,
+				    "Line %u too high for mid-function", line);
+	}
+}
+
+/* --------------- Suite registration --------------- */
+
+static struct kunit_case lineinfo_test_cases[] = {
+	/* Group A: Basic lineinfo presence */
+	KUNIT_CASE(test_normal_function),
+	KUNIT_CASE(test_static_function),
+	KUNIT_CASE(test_noinline_function),
+	KUNIT_CASE(test_inline_function),
+	KUNIT_CASE(test_short_function),
+	KUNIT_CASE(test_many_lines_function),
+	/* Group B: Deep call chain */
+	KUNIT_CASE(test_deep_call_chain),
+	/* Group C: sprint_symbol API variants */
+	KUNIT_CASE(test_sprint_symbol_format),
+	KUNIT_CASE(test_sprint_backtrace),
+	KUNIT_CASE(test_sprint_backtrace_build_id),
+	KUNIT_CASE(test_sprint_symbol_no_offset),
+	/* Group D: printk format specifiers */
+	KUNIT_CASE(test_pS_format),
+	KUNIT_CASE(test_pBb_format),
+	KUNIT_CASE(test_pSR_format),
+	/* Group E: Address edge cases */
+	KUNIT_CASE(test_symbol_start_addr),
+	KUNIT_CASE(test_symbol_nonzero_offset),
+	KUNIT_CASE(test_unknown_address),
+	KUNIT_CASE(test_kernel_function_lineinfo),
+	KUNIT_CASE(test_assembly_no_lineinfo),
+	KUNIT_CASE(test_init_text_no_stale_lineinfo),
+	/* Group F: Module path */
+	KUNIT_CASE(test_module_function_lineinfo),
+	/* Group G: Stress */
+	KUNIT_CASE_SLOW(test_concurrent_sprint_symbol),
+	KUNIT_CASE_SLOW(test_rapid_sprint_symbol),
+	/* Group H: Safety and plausibility */
+	KUNIT_CASE(test_line_number_plausible),
+	KUNIT_CASE(test_buffer_no_overflow),
+	KUNIT_CASE(test_dump_stack_no_crash),
+	KUNIT_CASE(test_sprint_symbol_build_id),
+	/* Group I: Encoding/decoding and accuracy */
+	KUNIT_CASE(test_sleb128_edge_cases),
+	KUNIT_CASE(test_uleb128_edge_cases),
+	KUNIT_CASE(test_line_number_accuracy),
+	KUNIT_CASE(test_many_lines_mid_function),
+	{}
+};
+
+static struct kunit_suite lineinfo_test_suite = {
+	.name = "lineinfo",
+	.test_cases = lineinfo_test_cases,
+};
+kunit_test_suites(&lineinfo_test_suite);
+
+MODULE_LICENSE("GPL");
+MODULE_DESCRIPTION("KUnit tests for kallsyms lineinfo");
+MODULE_AUTHOR("Sasha Levin");
-- 
2.53.0


^ permalink raw reply related

* [PATCH v7 3/4] kallsyms: delta-compress lineinfo tables for ~2.7x size reduction
From: Sasha Levin @ 2026-07-09 16:38 UTC (permalink / raw)
  To: Andrew Morton, Masahiro Yamada, Luis Chamberlain, Linus Torvalds,
	Richard Weinberger, Juergen Gross, Geert Uytterhoeven,
	James Bottomley
  Cc: Sasha Levin, Jonathan Corbet, Nathan Chancellor, Nicolas Schier,
	Petr Pavlu, Daniel Gomez, Greg KH, Petr Mladek, Steven Rostedt,
	Kees Cook, Peter Zijlstra, Thorsten Leemhuis, Vlastimil Babka,
	Helge Deller, Randy Dunlap, Laurent Pinchart, Vivian Wang,
	Zhen Lei, Sami Tolvanen, linux-kernel, linux-kbuild,
	linux-modules, linux-doc
In-Reply-To: <20260709163833.3851179-1-sashal@kernel.org>

Replace the flat uncompressed parallel arrays (lineinfo_addrs[],
lineinfo_file_ids[], lineinfo_lines[]) with a block-indexed,
delta-encoded, ULEB128 varint compressed format.

The sorted address array has small deltas between consecutive entries
(typically 1-50 bytes), file IDs have high locality (delta often 0,
same file), and line numbers change slowly.  Delta-encoding followed
by ULEB128 varint compression shrinks most values from 4 bytes to 1.

Entries are grouped into blocks of 64.  A small uncompressed block
index (first addr + byte offset per block) enables O(log(N/64)) binary
search, followed by sequential decode of at most 64 varints within the
matching block.  All decode state lives on the stack -- zero
allocations, still safe for NMI/panic context.

Measured on a defconfig+debug x86_64 build (3,017,154 entries, 4,822
source files, 47,144 blocks):

  Before (flat arrays):
    lineinfo_addrs[]    12,068,616 bytes (u32 x 3.0M)
    lineinfo_file_ids[]  6,034,308 bytes (u16 x 3.0M)
    lineinfo_lines[]    12,068,616 bytes (u32 x 3.0M)
    Total:              30,171,540 bytes (28.8 MiB, 10.0 bytes/entry)

  After (block-indexed delta + ULEB128):
    lineinfo_block_addrs[]    188,576 bytes (184 KiB)
    lineinfo_block_offsets[]  188,576 bytes (184 KiB)
    lineinfo_data[]        10,926,128 bytes (10.4 MiB)
    Total:                 11,303,280 bytes (10.8 MiB, 3.7 bytes/entry)

  Savings: 18.0 MiB (2.7x reduction)

Booted in QEMU and verified with SysRq-l that annotations still work:

  default_idle+0x9/0x10 (arch/x86/kernel/process.c:767)
  default_idle_call+0x6c/0xb0 (kernel/sched/idle.c:122)
  do_idle+0x335/0x490 (kernel/sched/idle.c:191)
  cpu_startup_entry+0x4e/0x60 (kernel/sched/idle.c:429)
  rest_init+0x1aa/0x1b0 (init/main.c:760)

Suggested-by: Juergen Gross <jgross@suse.com>
Assisted-by: Claude:claude-opus-4-6
Signed-off-by: Sasha Levin <sashal@kernel.org>
---
 include/linux/mod_lineinfo.h | 250 ++++++++++++++++++++++++++++++-----
 kernel/kallsyms.c            |  55 +++-----
 kernel/kallsyms_internal.h   |   8 +-
 kernel/module/kallsyms.c     | 110 +++++++--------
 scripts/empty_lineinfo.S     |  20 ++-
 scripts/gen_lineinfo.c       | 185 +++++++++++++++++---------
 scripts/kallsyms.c           |   7 +-
 7 files changed, 436 insertions(+), 199 deletions(-)

diff --git a/include/linux/mod_lineinfo.h b/include/linux/mod_lineinfo.h
index 75d0bfba6ce57b..47277f9d7e4ff5 100644
--- a/include/linux/mod_lineinfo.h
+++ b/include/linux/mod_lineinfo.h
@@ -3,9 +3,9 @@
  * mod_lineinfo.h - Binary format for per-module source line information
  *
  * This header defines the layout of the .mod_lineinfo and
- * .init.mod_lineinfo sections embedded in loadable kernel modules.  It
- * is dual-use: included from both the kernel and the userspace
- * gen_lineinfo tool.
+ * .init.mod_lineinfo sections embedded in loadable kernel modules.  It is
+ * dual-use: included from both the kernel and the userspace gen_lineinfo
+ * tool.
  *
  * Top-level layout (all values in target-native endianness):
  *
@@ -20,16 +20,27 @@
  * If the relocation fails to resolve (e.g. unknown reloc type), .anchor
  * stays zero and lookups silently degrade to "no annotation".
  *
- * Each per-section sub-table is laid out as a stand-alone
- * mod_lineinfo_header followed by parallel arrays:
+ * Each per-section sub-table is laid out exactly as a stand-alone
+ * mod_lineinfo_header followed by its arrays:
  *
- *   struct mod_lineinfo_header     (16 bytes)
- *   u32 addrs[num_entries]         -- offsets from this section's base, sorted
- *   u16 file_ids[num_entries]      -- parallel to addrs
- *   <2-byte pad if num_entries is odd>
- *   u32 lines[num_entries]         -- parallel to addrs
+ *   struct mod_lineinfo_header
+ *   u32 block_addrs[num_blocks]    -- first addr per block, for binary search
+ *   u32 block_offsets[num_blocks]  -- byte offset into compressed data stream
+ *   u8  data[data_size]            -- LEB128 delta-compressed entries
  *   u32 file_offsets[num_files]    -- byte offset into filenames[]
  *   char filenames[filenames_size] -- concatenated NUL-terminated strings
+ *
+ * Each sub-array is located by an explicit (offset, size) pair in the
+ * header, similar to a flattened devicetree.  All offsets in the per-section
+ * header are relative to that header itself, so a sub-table is fully
+ * self-describing.
+ *
+ * Compressed stream format (per block of LINEINFO_BLOCK_ENTRIES entries):
+ *   Entry 0: file_id (ULEB128), line (ULEB128)
+ *            addr is in block_addrs[]
+ *   Entry 1..N: addr_delta (ULEB128),
+ *               file_id_delta (SLEB128),
+ *               line_delta (SLEB128)
  */
 #ifndef _LINUX_MOD_LINEINFO_H
 #define _LINUX_MOD_LINEINFO_H
@@ -44,6 +55,7 @@
 #include <stdint.h>
 typedef uint32_t u32;
 typedef uint16_t u16;
+typedef uint8_t  u8;
 typedef uint64_t u64;
 #ifndef __aligned
 #define __aligned(x)	__attribute__((__aligned__(x)))
@@ -53,6 +65,8 @@ typedef uint64_t u64;
 #endif
 #endif
 
+#define LINEINFO_BLOCK_ENTRIES 64
+
 /*
  * Per-section descriptor.  One entry per ELF text section covered by the
  * blob (.text, .exit.text, .init.text, ...).
@@ -84,39 +98,215 @@ static_assert(sizeof(struct mod_lineinfo_section) == 16,
 
 struct mod_lineinfo_header {
 	u32 num_entries;
+	u32 num_blocks;
 	u32 num_files;
-	u32 filenames_size;	/* total bytes of concatenated filenames */
+	u32 blocks_offset;	/* offset to block_addrs[] from this header */
+	u32 blocks_size;	/* bytes: num_blocks * 2 * sizeof(u32) */
+	u32 data_offset;	/* offset to compressed stream */
+	u32 data_size;		/* bytes of compressed data */
+	u32 files_offset;	/* offset to file_offsets[] */
+	u32 files_size;		/* bytes: num_files * sizeof(u32) */
+	u32 filenames_offset;
+	u32 filenames_size;
 };
 
-/* Offset helpers: compute byte offset from the per-section header to each array. */
+/*
+ * Descriptor for a lineinfo table, used by the shared lookup function.
+ * Callers populate this from either linker globals (vmlinux) or a
+ * validated mod_lineinfo_header (modules).
+ */
+struct lineinfo_table {
+	const u32 *blk_addrs;
+	const u32 *blk_offsets;
+	const u8  *data;
+	u32 data_size;
+	const u32 *file_offsets;
+	const char *filenames;
+	u32 num_entries;
+	u32 num_blocks;
+	u32 num_files;
+	u32 filenames_size;
+};
 
-static inline u32 mod_lineinfo_addrs_off(void)
+/*
+ * Read a ULEB128 varint from a byte stream.
+ * Returns the decoded value and advances *pos past the encoded bytes.
+ * If *pos would exceed 'end', returns 0 and sets *pos = end (safe for
+ * NMI/panic context: no crash, just a missed annotation).
+ */
+static inline u32 lineinfo_read_uleb128(const u8 *data, u32 *pos, u32 end)
 {
-	return sizeof(struct mod_lineinfo_header);
-}
+	u32 result = 0;
+	unsigned int shift = 0;
 
-static inline u32 mod_lineinfo_file_ids_off(u32 num_entries)
-{
-	return mod_lineinfo_addrs_off() + num_entries * sizeof(u32);
+	while (*pos < end) {
+		u8 byte = data[*pos];
+		(*pos)++;
+		result |= (u32)(byte & 0x7f) << shift;
+		if (!(byte & 0x80))
+			return result;
+		shift += 7;
+		if (shift >= 32) {
+			/* Malformed: skip remaining continuation bytes */
+			while (*pos < end && (data[*pos] & 0x80))
+				(*pos)++;
+			if (*pos < end)
+				(*pos)++;
+			return result;
+		}
+	}
+	return result;
 }
 
-static inline u32 mod_lineinfo_lines_off(u32 num_entries)
+/* Read an SLEB128 varint. Same safety guarantees as above. */
+static inline int32_t lineinfo_read_sleb128(const u8 *data, u32 *pos, u32 end)
 {
-	/* u16 file_ids[] may need 2-byte padding to align lines[] to 4 bytes */
-	u32 off = mod_lineinfo_file_ids_off(num_entries) +
-		  num_entries * sizeof(u16);
-	return (off + 3) & ~3u;
-}
+	int32_t result = 0;
+	unsigned int shift = 0;
+	u8 byte = 0;
 
-static inline u32 mod_lineinfo_file_offsets_off(u32 num_entries)
-{
-	return mod_lineinfo_lines_off(num_entries) + num_entries * sizeof(u32);
+	while (*pos < end) {
+		byte = data[*pos];
+		(*pos)++;
+		result |= (int32_t)((u32)(byte & 0x7f) << shift);
+		shift += 7;
+		if (!(byte & 0x80))
+			break;
+		if (shift >= 32) {
+			while (*pos < end && (data[*pos] & 0x80))
+				(*pos)++;
+			if (*pos < end)
+				(*pos)++;
+			return result;
+		}
+	}
+
+	/* Sign-extend if the high bit of the last byte was set */
+	if (shift < 32 && (byte & 0x40))
+		result |= -(1 << shift);
+
+	return result;
 }
 
-static inline u32 mod_lineinfo_filenames_off(u32 num_entries, u32 num_files)
+/*
+ * Search a lineinfo table for the source file and line corresponding to a
+ * given offset (from _text for vmlinux, from the covered section's base for
+ * modules).  @min_offset is the containing symbol's start in the same offset
+ * space: entries below it belong to a preceding symbol and are rejected.
+ *
+ * Safe for NMI and panic context: no locks, no allocations, all state on stack.
+ * Returns true and sets @file and @line on success; false on any failure.
+ */
+static inline bool lineinfo_search(const struct lineinfo_table *tbl,
+				   unsigned int offset,
+				   unsigned int min_offset,
+				   const char **file, unsigned int *line)
 {
-	return mod_lineinfo_file_offsets_off(num_entries) +
-	       num_files * sizeof(u32);
+	unsigned int low, high, mid, block;
+	unsigned int cur_addr, cur_file_id, cur_line;
+	unsigned int best_addr = 0, best_file_id = 0, best_line = 0;
+	unsigned int block_entries, data_end;
+	bool found = false;
+	u32 pos;
+
+	if (!tbl->num_entries || !tbl->num_blocks)
+		return false;
+
+	/* Binary search on blk_addrs[] to find the right block */
+	low = 0;
+	high = tbl->num_blocks;
+	while (low < high) {
+		mid = low + (high - low) / 2;
+		if (tbl->blk_addrs[mid] <= offset)
+			low = mid + 1;
+		else
+			high = mid;
+	}
+
+	if (low == 0)
+		return false;
+	block = low - 1;
+
+	/* How many entries in this block? */
+	block_entries = LINEINFO_BLOCK_ENTRIES;
+	if (block == tbl->num_blocks - 1) {
+		unsigned int remaining = tbl->num_entries -
+					block * LINEINFO_BLOCK_ENTRIES;
+
+		if (remaining < block_entries)
+			block_entries = remaining;
+	}
+
+	/* Determine end of this block's data in the compressed stream */
+	if (block + 1 < tbl->num_blocks)
+		data_end = tbl->blk_offsets[block + 1];
+	else
+		data_end = tbl->data_size;
+
+	/* Clamp data_end to actual data size */
+	if (data_end > tbl->data_size)
+		data_end = tbl->data_size;
+
+	/* Decode entry 0: addr from blk_addrs, file_id and line from stream */
+	pos = tbl->blk_offsets[block];
+	if (pos >= data_end)
+		return false;
+
+	cur_addr = tbl->blk_addrs[block];
+	cur_file_id = lineinfo_read_uleb128(tbl->data, &pos, data_end);
+	cur_line = lineinfo_read_uleb128(tbl->data, &pos, data_end);
+
+	/* Check entry 0 */
+	if (cur_addr <= offset) {
+		best_addr = cur_addr;
+		best_file_id = cur_file_id;
+		best_line = cur_line;
+		found = true;
+	}
+
+	/* Decode entries 1..N */
+	for (unsigned int i = 1; i < block_entries; i++) {
+		unsigned int addr_delta;
+		int32_t file_delta, line_delta;
+
+		addr_delta = lineinfo_read_uleb128(tbl->data, &pos, data_end);
+		file_delta = lineinfo_read_sleb128(tbl->data, &pos, data_end);
+		line_delta = lineinfo_read_sleb128(tbl->data, &pos, data_end);
+
+		cur_addr += addr_delta;
+		cur_file_id = (unsigned int)((int32_t)cur_file_id + file_delta);
+		cur_line = (unsigned int)((int32_t)cur_line + line_delta);
+
+		if (cur_addr > offset)
+			break;
+
+		best_addr = cur_addr;
+		best_file_id = cur_file_id;
+		best_line = cur_line;
+		found = true;
+	}
+
+	if (!found)
+		return false;
+
+	/*
+	 * The best entry is the closest one at or below @offset; reject it
+	 * if it lies below the resolved symbol's start, so a symbol without
+	 * line entries of its own does not inherit the preceding symbol's
+	 * annotation.
+	 */
+	if (best_addr < min_offset)
+		return false;
+
+	if (best_file_id >= tbl->num_files)
+		return false;
+
+	if (tbl->file_offsets[best_file_id] >= tbl->filenames_size)
+		return false;
+
+	*file = &tbl->filenames[tbl->file_offsets[best_file_id]];
+	*line = best_line;
+	return true;
 }
 
 #endif /* _LINUX_MOD_LINEINFO_H */
diff --git a/kernel/kallsyms.c b/kernel/kallsyms.c
index 55aa56c9d07b16..ffc65bc4cfc925 100644
--- a/kernel/kallsyms.c
+++ b/kernel/kallsyms.c
@@ -467,13 +467,17 @@ static int append_buildid(char *buffer,   const char *modname,
 
 #endif /* CONFIG_STACKTRACE_BUILD_ID */
 
+#include <linux/mod_lineinfo.h>
+
 bool kallsyms_lookup_lineinfo(unsigned long addr, unsigned long sym_start,
 			      const char **file, unsigned int *line)
 {
+	struct lineinfo_table tbl;
 	unsigned long long raw_offset;
-	unsigned int offset, min_offset = 0, low, high, mid, file_id;
+	unsigned int min_offset = 0;
 
-	if (!IS_ENABLED(CONFIG_KALLSYMS_LINEINFO) || !lineinfo_num_entries)
+	if (!IS_ENABLED(CONFIG_KALLSYMS_LINEINFO) ||
+	    !lineinfo_num_entries || !lineinfo_num_blocks)
 		return false;
 
 	/* Compute offset from _text */
@@ -483,11 +487,10 @@ bool kallsyms_lookup_lineinfo(unsigned long addr, unsigned long sym_start,
 	raw_offset = addr - (unsigned long)_text;
 	if (raw_offset > UINT_MAX)
 		return false;
-	offset = (unsigned int)raw_offset;
 
 	/*
-	 * The search below returns the closest entry at or below @offset, so
-	 * a symbol without line entries of its own (assembly without debug
+	 * The search returns the closest entry at or below the offset, so a
+	 * symbol without line entries of its own (assembly without debug
 	 * info, or anything past the _etext cap like .init.text) would
 	 * inherit the last entry of whatever precedes it.  Bound the result
 	 * to entries at or above the resolved symbol's start.
@@ -499,35 +502,19 @@ bool kallsyms_lookup_lineinfo(unsigned long addr, unsigned long sym_start,
 			min_offset = (unsigned int)raw_min;
 	}
 
-	/* Binary search for largest entry <= offset */
-	low = 0;
-	high = lineinfo_num_entries;
-	while (low < high) {
-		mid = low + (high - low) / 2;
-		if (lineinfo_addrs[mid] <= offset)
-			low = mid + 1;
-		else
-			high = mid;
-	}
-
-	if (low == 0)
-		return false;
-	low--;
-
-	if (lineinfo_addrs[low] < min_offset)
-		return false;
-
-	file_id = lineinfo_file_ids[low];
-	*line = lineinfo_lines[low];
-
-	if (file_id >= lineinfo_num_files)
-		return false;
-
-	if (lineinfo_file_offsets[file_id] >= lineinfo_filenames_size)
-		return false;
-
-	*file = &lineinfo_filenames[lineinfo_file_offsets[file_id]];
-	return true;
+	tbl.blk_addrs	= lineinfo_block_addrs;
+	tbl.blk_offsets	= lineinfo_block_offsets;
+	tbl.data	= lineinfo_data;
+	tbl.data_size	= lineinfo_data_size;
+	tbl.file_offsets = lineinfo_file_offsets;
+	tbl.filenames	= lineinfo_filenames;
+	tbl.num_entries	= lineinfo_num_entries;
+	tbl.num_blocks	= lineinfo_num_blocks;
+	tbl.num_files	= lineinfo_num_files;
+	tbl.filenames_size = lineinfo_filenames_size;
+
+	return lineinfo_search(&tbl, (unsigned int)raw_offset, min_offset,
+			       file, line);
 }
 
 /* Look up a kernel symbol and return it in a text buffer. */
diff --git a/kernel/kallsyms_internal.h b/kernel/kallsyms_internal.h
index d7374ce444d811..ffe4c658067ecc 100644
--- a/kernel/kallsyms_internal.h
+++ b/kernel/kallsyms_internal.h
@@ -16,10 +16,12 @@ extern const unsigned int kallsyms_markers[];
 extern const u8 kallsyms_seqs_of_names[];
 
 extern const u32 lineinfo_num_entries;
-extern const u32 lineinfo_addrs[];
-extern const u16 lineinfo_file_ids[];
-extern const u32 lineinfo_lines[];
 extern const u32 lineinfo_num_files;
+extern const u32 lineinfo_num_blocks;
+extern const u32 lineinfo_block_addrs[];
+extern const u32 lineinfo_block_offsets[];
+extern const u32 lineinfo_data_size;
+extern const u8  lineinfo_data[];
 extern const u32 lineinfo_file_offsets[];
 extern const u32 lineinfo_filenames_size;
 extern const char lineinfo_filenames[];
diff --git a/kernel/module/kallsyms.c b/kernel/module/kallsyms.c
index 4888ce8a7f951d..154eeb3738ee6c 100644
--- a/kernel/module/kallsyms.c
+++ b/kernel/module/kallsyms.c
@@ -498,9 +498,9 @@ int module_kallsyms_on_each_symbol(const char *modname,
 #include <linux/mod_lineinfo.h>
 
 /*
- * Search one per-section sub-table for @section_offset using flat parallel
- * arrays.  @hdr is the per-section header at byte offset @hdr_offset within
- * @blob.  Returns true on hit and populates @file / @line.
+ * Search one per-section sub-table for @section_offset.
+ * @hdr is the per-section header at byte offset @hdr_offset within @blob.
+ * Returns true on hit and populates @file / @line.
  */
 static bool module_lookup_lineinfo_section(const void *blob, u32 blob_size,
 					   u32 hdr_offset,
@@ -510,93 +510,71 @@ static bool module_lookup_lineinfo_section(const void *blob, u32 blob_size,
 					   unsigned int *line)
 {
 	const struct mod_lineinfo_header *hdr;
-	const u8 *base;
-	const u32 *addrs, *lines, *file_offsets;
-	const u16 *file_ids;
-	const char *filenames;
-	u32 num_entries, num_files, filenames_size;
-	unsigned int low, high, mid;
-	u16 file_id;
+	struct lineinfo_table tbl;
+	const void *base;
 
 	if (hdr_offset > blob_size ||
 	    blob_size - hdr_offset < sizeof(*hdr))
 		return false;
 
 	base = (const u8 *)blob + hdr_offset;
-	hdr = (const struct mod_lineinfo_header *)base;
-	num_entries = hdr->num_entries;
-	num_files = hdr->num_files;
-	filenames_size = hdr->filenames_size;
+	hdr = base;
 
-	if (num_entries == 0)
+	if (hdr->num_entries == 0 || hdr->num_blocks == 0)
 		return false;
 
-	/*
-	 * Validate counts before multiplying — sizing arithmetic could
-	 * otherwise overflow on 32-bit with a malformed blob.  Each entry
-	 * contributes one u32 (addrs), one u16 (file_ids), and one u32
-	 * (lines); each file contributes one u32 (file_offsets).
-	 */
+	/* Validate each sub-array fits within the remaining blob bytes */
 	{
 		u32 avail = blob_size - hdr_offset;
-		u32 needed = mod_lineinfo_filenames_off(num_entries, num_files);
 
-		if (num_entries > U32_MAX / sizeof(u32))
+		if (hdr->blocks_offset > avail ||
+		    hdr->blocks_size > avail - hdr->blocks_offset)
+			return false;
+		if (hdr->data_offset > avail ||
+		    hdr->data_size > avail - hdr->data_offset)
 			return false;
-		if (num_files > U32_MAX / sizeof(u32))
+		if (hdr->files_offset > avail ||
+		    hdr->files_size > avail - hdr->files_offset)
 			return false;
-		if (needed > avail || filenames_size > avail - needed)
+		if (hdr->filenames_offset > avail ||
+		    hdr->filenames_size > avail - hdr->filenames_offset)
 			return false;
 	}
 
 	/*
-	 * Filenames are read as NUL-terminated C strings.  Require the blob
-	 * to end in NUL so a malformed file_offsets entry can never lead the
-	 * later "%s" consumer past the end of the section.
+	 * Validate counts before multiplying by element size — multiplication
+	 * could otherwise overflow on 32-bit builds with a malformed blob.
+	 * num_blocks contributes (addr,offset) u32 pairs; num_files contributes
+	 * one u32 each.
 	 */
-	if (filenames_size == 0 ||
-	    base[mod_lineinfo_filenames_off(num_entries, num_files) +
-		 filenames_size - 1] != 0)
+	if (hdr->num_blocks > hdr->blocks_size / (2 * sizeof(u32)))
 		return false;
-
-	addrs = (const u32 *)(base + mod_lineinfo_addrs_off());
-	file_ids = (const u16 *)(base + mod_lineinfo_file_ids_off(num_entries));
-	lines = (const u32 *)(base + mod_lineinfo_lines_off(num_entries));
-	file_offsets = (const u32 *)(base + mod_lineinfo_file_offsets_off(num_entries));
-	filenames = (const char *)(base + mod_lineinfo_filenames_off(num_entries, num_files));
-
-	/* Binary search for largest entry <= section_offset. */
-	low = 0;
-	high = num_entries;
-	while (low < high) {
-		mid = low + (high - low) / 2;
-		if (addrs[mid] <= section_offset)
-			low = mid + 1;
-		else
-			high = mid;
-	}
-
-	if (low == 0)
+	if (hdr->num_files > hdr->files_size / sizeof(u32))
 		return false;
-	low--;
 
 	/*
-	 * Reject entries below the resolved symbol's start so a symbol
-	 * without line entries of its own does not inherit the preceding
-	 * symbol's annotation.
+	 * Filenames are read as NUL-terminated C strings.  Require the blob
+	 * to end in NUL so a malformed file_offsets entry can never lead the
+	 * later "%s" consumer past the end of the section.
 	 */
-	if (addrs[low] < min_offset)
+	if (hdr->filenames_size == 0 ||
+	    ((const u8 *)base)[hdr->filenames_offset +
+			       hdr->filenames_size - 1] != 0)
 		return false;
 
-	file_id = file_ids[low];
-	if (file_id >= num_files)
-		return false;
-	if (file_offsets[file_id] >= filenames_size)
-		return false;
-
-	*file = &filenames[file_offsets[file_id]];
-	*line = lines[low];
-	return true;
+	tbl.blk_addrs	= base + hdr->blocks_offset;
+	tbl.blk_offsets	= base + hdr->blocks_offset +
+			  hdr->num_blocks * sizeof(u32);
+	tbl.data	= base + hdr->data_offset;
+	tbl.data_size	= hdr->data_size;
+	tbl.file_offsets = base + hdr->files_offset;
+	tbl.filenames	= base + hdr->filenames_offset;
+	tbl.num_entries	= hdr->num_entries;
+	tbl.num_blocks	= hdr->num_blocks;
+	tbl.num_files	= hdr->num_files;
+	tbl.filenames_size = hdr->filenames_size;
+
+	return lineinfo_search(&tbl, section_offset, min_offset, file, line);
 }
 
 /*
@@ -619,6 +597,7 @@ static bool module_lookup_lineinfo_blob(const void *blob, u32 blob_size,
 	if (root->num_sections == 0)
 		return false;
 
+	/* Validate sections[] array fits within the blob */
 	if (root->num_sections > U32_MAX / sizeof(struct mod_lineinfo_section))
 		return false;
 	sections_end = sizeof(*root) +
@@ -656,6 +635,9 @@ static bool module_lookup_lineinfo_blob(const void *blob, u32 blob_size,
 
 /*
  * Look up source file:line for an address within a loaded module.
+ * Uses the .mod_lineinfo / .init.mod_lineinfo sections embedded in the .ko
+ * at build time.  Each section contains one or more per-section sub-tables
+ * keyed by an ELF-relocation-resolved anchor.
  *
  * Safe in NMI/panic context: no locks, no allocations.
  * Caller must hold RCU read lock (or be in a context where the module
diff --git a/scripts/empty_lineinfo.S b/scripts/empty_lineinfo.S
index e058c411371237..edd5b1092f0503 100644
--- a/scripts/empty_lineinfo.S
+++ b/scripts/empty_lineinfo.S
@@ -14,12 +14,20 @@ lineinfo_num_entries:
 	.balign 4
 lineinfo_num_files:
 	.long 0
-	.globl lineinfo_addrs
-lineinfo_addrs:
-	.globl lineinfo_file_ids
-lineinfo_file_ids:
-	.globl lineinfo_lines
-lineinfo_lines:
+	.globl lineinfo_num_blocks
+	.balign 4
+lineinfo_num_blocks:
+	.long 0
+	.globl lineinfo_block_addrs
+lineinfo_block_addrs:
+	.globl lineinfo_block_offsets
+lineinfo_block_offsets:
+	.globl lineinfo_data_size
+	.balign 4
+lineinfo_data_size:
+	.long 0
+	.globl lineinfo_data
+lineinfo_data:
 	.globl lineinfo_file_offsets
 lineinfo_file_offsets:
 	.globl lineinfo_filenames_size
diff --git a/scripts/gen_lineinfo.c b/scripts/gen_lineinfo.c
index 7624098d0cb426..6b69738e45933a 100644
--- a/scripts/gen_lineinfo.c
+++ b/scripts/gen_lineinfo.c
@@ -956,6 +956,45 @@ static void deduplicate(struct covered_section *sections,
 	}
 }
 
+/*
+ * Emit the LEB128 delta-compressed data stream for one block.
+ * @base is the absolute index of the first entry, @count is the number of
+ * entries in this block (<= LINEINFO_BLOCK_ENTRIES).  Used by both vmlinux
+ * mode (one section, full entries[]) and module mode (per-section ranges).
+ */
+static void emit_block_data_range(unsigned int base, unsigned int count)
+{
+	if (!count)
+		return;
+
+	/* Entry 0: file_id, line (both unsigned) */
+	printf("\t.uleb128 %u\n", entries[base].file_id);
+	printf("\t.uleb128 %u\n", entries[base].line);
+
+	/* Entries 1..N: addr_delta (unsigned), file/line deltas (signed) */
+	for (unsigned int i = 1; i < count; i++) {
+		unsigned int idx = base + i;
+
+		printf("\t.uleb128 %u\n",
+		       entries[idx].offset - entries[idx - 1].offset);
+		printf("\t.sleb128 %d\n",
+		       (int)entries[idx].file_id - (int)entries[idx - 1].file_id);
+		printf("\t.sleb128 %d\n",
+		       (int)entries[idx].line - (int)entries[idx - 1].line);
+	}
+}
+
+/* Vmlinux-mode wrapper: pick block index out of the global entries[]. */
+static void emit_block_data(unsigned int block)
+{
+	unsigned int base = block * LINEINFO_BLOCK_ENTRIES;
+	unsigned int count = num_entries - base;
+
+	if (count > LINEINFO_BLOCK_ENTRIES)
+		count = LINEINFO_BLOCK_ENTRIES;
+	emit_block_data_range(base, count);
+}
+
 static void compute_file_offsets(void)
 {
 	unsigned int offset = 0;
@@ -979,6 +1018,11 @@ static void print_escaped_asciz(const char *s)
 
 static void output_assembly(void)
 {
+	unsigned int num_blocks;
+
+	num_blocks = num_entries ?
+		(num_entries + LINEINFO_BLOCK_ENTRIES - 1) / LINEINFO_BLOCK_ENTRIES : 0;
+
 	printf("/* SPDX-License-Identifier: GPL-2.0 */\n");
 	printf("/*\n");
 	printf(" * Automatically generated by scripts/gen_lineinfo\n");
@@ -999,29 +1043,40 @@ static void output_assembly(void)
 	printf("lineinfo_num_files:\n");
 	printf("\t.long %u\n\n", num_files);
 
-	/* Sorted address offsets from _text */
-	printf("\t.globl lineinfo_addrs\n");
+	/* Number of blocks */
+	printf("\t.globl lineinfo_num_blocks\n");
 	printf("\t.balign 4\n");
-	printf("lineinfo_addrs:\n");
-	for (unsigned int i = 0; i < num_entries; i++)
-		printf("\t.long 0x%x\n", entries[i].offset);
-	printf("\n");
+	printf("lineinfo_num_blocks:\n");
+	printf("\t.long %u\n\n", num_blocks);
 
-	/* File IDs, parallel to addrs (u16 -- supports up to 65535 files) */
-	printf("\t.globl lineinfo_file_ids\n");
-	printf("\t.balign 2\n");
-	printf("lineinfo_file_ids:\n");
-	for (unsigned int i = 0; i < num_entries; i++)
-		printf("\t.short %u\n", entries[i].file_id);
-	printf("\n");
+	/* Block first-addresses for binary search */
+	printf("\t.globl lineinfo_block_addrs\n");
+	printf("\t.balign 4\n");
+	printf("lineinfo_block_addrs:\n");
+	for (unsigned int i = 0; i < num_blocks; i++)
+		printf("\t.long 0x%x\n", entries[i * LINEINFO_BLOCK_ENTRIES].offset);
 
-	/* Line numbers, parallel to addrs */
-	printf("\t.globl lineinfo_lines\n");
+	/* Block byte offsets into compressed stream */
+	printf("\t.globl lineinfo_block_offsets\n");
 	printf("\t.balign 4\n");
-	printf("lineinfo_lines:\n");
-	for (unsigned int i = 0; i < num_entries; i++)
-		printf("\t.long %u\n", entries[i].line);
-	printf("\n");
+	printf("lineinfo_block_offsets:\n");
+	for (unsigned int i = 0; i < num_blocks; i++)
+		printf("\t.long .Lblock_%u - lineinfo_data\n", i);
+
+	/* Compressed data size */
+	printf("\t.globl lineinfo_data_size\n");
+	printf("\t.balign 4\n");
+	printf("lineinfo_data_size:\n");
+	printf("\t.long .Ldata_end - lineinfo_data\n\n");
+
+	/* Compressed data stream */
+	printf("\t.globl lineinfo_data\n");
+	printf("lineinfo_data:\n");
+	for (unsigned int i = 0; i < num_blocks; i++) {
+		printf(".Lblock_%u:\n", i);
+		emit_block_data(i);
+	}
+	printf(".Ldata_end:\n\n");
 
 	/* File string offset table */
 	printf("\t.globl lineinfo_file_offsets\n");
@@ -1029,71 +1084,81 @@ static void output_assembly(void)
 	printf("lineinfo_file_offsets:\n");
 	for (unsigned int i = 0; i < num_files; i++)
 		printf("\t.long %u\n", files[i].str_offset);
-	printf("\n");
 
 	/* Filenames size */
-	{
-		unsigned int fsize = 0;
-
-		for (unsigned int i = 0; i < num_files; i++)
-			fsize += strlen(files[i].name) + 1;
-		printf("\t.globl lineinfo_filenames_size\n");
-		printf("\t.balign 4\n");
-		printf("lineinfo_filenames_size:\n");
-		printf("\t.long %u\n\n", fsize);
-	}
+	printf("\t.globl lineinfo_filenames_size\n");
+	printf("\t.balign 4\n");
+	printf("lineinfo_filenames_size:\n");
+	printf("\t.long .Lfilenames_end - lineinfo_filenames\n\n");
 
 	/* Concatenated NUL-terminated filenames */
 	printf("\t.globl lineinfo_filenames\n");
 	printf("lineinfo_filenames:\n");
 	for (unsigned int i = 0; i < num_files; i++)
 		print_escaped_asciz(files[i].name);
-	printf("\n");
+	printf(".Lfilenames_end:\n");
 }
 
 /*
- * Emit one per-section table in the simple flat-array layout:
+ * Emit one per-section table.  @suffix uniquifies the local labels so
+ * multiple tables can coexist in a single output blob; @blob_root_label
+ * is the symbol for the start of the enclosing blob (used for
+ * table_offset = .Lhdr - .Lroot).
  *
- *   mod_lineinfo_header
- *   addrs[count]    (u32, sorted)
- *   file_ids[count] (u16) + 2-byte pad if count is odd
- *   lines[count]    (u32)
- *   file_offsets[]  (u32)
- *   filenames[]
- *
- * @suffix uniquifies labels so multiple tables can coexist in one blob.
- * Caller has sorted entries[] so this section's entries occupy [first,
- * first + count).
+ * Caller has already sorted entries[] so this section's entries occupy
+ * the contiguous range [first, first + count).  This function emits
+ * block-relative addresses computed from entries[first + N].offset.
  */
 static void emit_section_table(unsigned int first, unsigned int count,
 			       const char *suffix)
 {
+	unsigned int num_blocks;
+
+	num_blocks = count ?
+		(count + LINEINFO_BLOCK_ENTRIES - 1) / LINEINFO_BLOCK_ENTRIES : 0;
+
 	printf(".Lhdr%s:\n", suffix);
 	printf("\t.balign 4\n");
 	printf("\t.long %u\t\t/* num_entries */\n", count);
+	printf("\t.long %u\t\t/* num_blocks */\n", num_blocks);
 	printf("\t.long %u\t\t/* num_files */\n", num_files);
+	printf("\t.long .Lblk_addrs%s - .Lhdr%s\n", suffix, suffix);
+	printf("\t.long .Lblk_offsets_end%s - .Lblk_addrs%s\n", suffix, suffix);
+	printf("\t.long .Ldata%s - .Lhdr%s\n", suffix, suffix);
+	printf("\t.long .Ldata_end%s - .Ldata%s\n", suffix, suffix);
+	printf("\t.long .Lfile_offsets%s - .Lhdr%s\n", suffix, suffix);
+	printf("\t.long .Lfile_offsets_end%s - .Lfile_offsets%s\n", suffix, suffix);
+	printf("\t.long .Lfilenames%s - .Lhdr%s\n", suffix, suffix);
 	printf("\t.long .Lfilenames_end%s - .Lfilenames%s\n\n", suffix, suffix);
 
-	/* addrs[] */
-	for (unsigned int i = 0; i < count; i++)
-		printf("\t.long 0x%x\n", entries[first + i].offset);
-
-	/* file_ids[] */
-	for (unsigned int i = 0; i < count; i++)
-		printf("\t.short %u\n", entries[first + i].file_id);
-	if (count & 1)
-		printf("\t.short 0\t\t/* pad to align lines[] */\n");
-
-	/* lines[] */
-	for (unsigned int i = 0; i < count; i++)
-		printf("\t.long %u\n", entries[first + i].line);
+	printf(".Lblk_addrs%s:\n", suffix);
+	for (unsigned int i = 0; i < num_blocks; i++)
+		printf("\t.long 0x%x\n",
+		       entries[first + i * LINEINFO_BLOCK_ENTRIES].offset);
+
+	printf(".Lblk_offsets%s:\n", suffix);
+	for (unsigned int i = 0; i < num_blocks; i++)
+		printf("\t.long .Lblock%s_%u - .Ldata%s\n", suffix, i, suffix);
+	printf(".Lblk_offsets_end%s:\n\n", suffix);
+
+	printf(".Ldata%s:\n", suffix);
+	for (unsigned int i = 0; i < num_blocks; i++) {
+		unsigned int base = first + i * LINEINFO_BLOCK_ENTRIES;
+		unsigned int n = count - i * LINEINFO_BLOCK_ENTRIES;
+
+		if (n > LINEINFO_BLOCK_ENTRIES)
+			n = LINEINFO_BLOCK_ENTRIES;
+		printf(".Lblock%s_%u:\n", suffix, i);
+		emit_block_data_range(base, n);
+	}
+	printf(".Ldata_end%s:\n", suffix);
 
-	/* file_offsets[] */
 	printf("\t.balign 4\n");
+	printf(".Lfile_offsets%s:\n", suffix);
 	for (unsigned int i = 0; i < num_files; i++)
 		printf("\t.long %u\n", files[i].str_offset);
+	printf(".Lfile_offsets_end%s:\n\n", suffix);
 
-	/* filenames[] */
 	printf(".Lfilenames%s:\n", suffix);
 	for (unsigned int i = 0; i < num_files; i++)
 		print_escaped_asciz(files[i].name);
@@ -1372,8 +1437,10 @@ int main(int argc, char *argv[])
 		deduplicate(NULL, 0);
 		compute_file_offsets();
 
-		fprintf(stderr, "lineinfo: %u entries, %u files\n",
-			num_entries, num_files);
+		fprintf(stderr, "lineinfo: %u entries, %u files, %u blocks\n",
+			num_entries, num_files,
+			num_entries ?
+			(num_entries + LINEINFO_BLOCK_ENTRIES - 1) / LINEINFO_BLOCK_ENTRIES : 0);
 
 		output_assembly();
 	}
diff --git a/scripts/kallsyms.c b/scripts/kallsyms.c
index 42662c4fbc6c94..94fbdad3df7c6e 100644
--- a/scripts/kallsyms.c
+++ b/scripts/kallsyms.c
@@ -80,11 +80,12 @@ static bool is_ignored_symbol(const char *name, char type)
 {
 	/* Ignore lineinfo symbols for kallsyms pass stability */
 	static const char * const lineinfo_syms[] = {
-		"lineinfo_addrs",
-		"lineinfo_file_ids",
+		"lineinfo_block_addrs",
+		"lineinfo_block_offsets",
+		"lineinfo_data",
 		"lineinfo_file_offsets",
 		"lineinfo_filenames",
-		"lineinfo_lines",
+		"lineinfo_num_blocks",
 		"lineinfo_num_entries",
 		"lineinfo_num_files",
 	};
-- 
2.53.0


^ permalink raw reply related

* [PATCH v7 2/4] kallsyms: extend lineinfo to loadable modules
From: Sasha Levin @ 2026-07-09 16:38 UTC (permalink / raw)
  To: Andrew Morton, Masahiro Yamada, Luis Chamberlain, Linus Torvalds,
	Richard Weinberger, Juergen Gross, Geert Uytterhoeven,
	James Bottomley
  Cc: Sasha Levin, Jonathan Corbet, Nathan Chancellor, Nicolas Schier,
	Petr Pavlu, Daniel Gomez, Greg KH, Petr Mladek, Steven Rostedt,
	Kees Cook, Peter Zijlstra, Thorsten Leemhuis, Vlastimil Babka,
	Helge Deller, Randy Dunlap, Laurent Pinchart, Vivian Wang,
	Zhen Lei, Sami Tolvanen, linux-kernel, linux-kbuild,
	linux-modules, linux-doc
In-Reply-To: <20260709163833.3851179-1-sashal@kernel.org>

Add CONFIG_KALLSYMS_LINEINFO_MODULES, which extends the
CONFIG_KALLSYMS_LINEINFO feature to loadable kernel modules.

At build time, each .ko is post-processed by scripts/gen-mod-lineinfo.sh
(modeled on gen-btf.sh) which runs scripts/gen_lineinfo --module on the
.ko, generates per-section .mod_lineinfo and .init.mod_lineinfo
sections containing compact binary tables of section-relative offsets,
file IDs, line numbers, and filenames, and embeds them back into the
.ko via a partial link (ld -r).

At runtime, module_lookup_lineinfo() walks the section descriptors in
each blob, finds the one whose runtime range contains the queried
address, and binary-searches that section's table.  The lookup is
NMI/panic-safe (no locks, no allocations) — the data lives in
read-only module memory and is freed automatically when the module
(or its init memory) is unloaded.

The gen_lineinfo tool gains --module mode which:
 - Walks an allowlist of text-like sections (.text, .exit.text,
   .init.text), gating each on its presence in the .ko.
 - Uses an ELF relocation against each covered section's symbol as the
   runtime "anchor", resolved by the module loader's standard
   apply_relocations() pass — no implicit base derivation from
   mod->mem[].base, no special-cased loader logic.
 - Disambiguates DWARF addresses across sections that all share
   sh_addr == 0 in ET_REL files via per-section synthetic biases
   applied inside apply_debug_line_relocations() (handles both abs32
   and abs64 width relocs).
 - Handles libdw's ET_REL path-doubling quirk in make_relative().
 - Declares empty section stanzas in its output assembly so the
   resulting lineinfo.o has LOCAL SECTION symbols rather than GLOBAL
   UND ones; otherwise ld -r would not bind the relocation to the
   .ko's existing section symbol of the same name and depmod would
   warn.

The build pipeline runs gen-mod-lineinfo.sh after the existing modfinal
step:

  gen_lineinfo --module ${KO} > ${KO}.lineinfo.S
  ${CC} -c -o ${KO}.lineinfo.o ${KO}.lineinfo.S
  ${LD} -r ${KO}.lineinfo.o ${KO} -o ${KO}.tmp && mv ${KO}.tmp ${KO}

Order matters: lineinfo.o must come first so its zero-byte text
contributions stay at offset 0 of the merged sections.

The init blob lives in MOD_INIT_RODATA and is revoked via WRITE_ONCE
in do_init_module() before do_free_init() releases the memory; the
module_init_lineinfo_data() reader uses READ_ONCE so concurrent
lookups either see the old pointer (still valid until do_free_init's
synchronize_rcu) or NULL.

The struct module fields are guarded by
#ifdef CONFIG_KALLSYMS_LINEINFO_MODULES and accessed through inline
reader accessors so callers don't duplicate the guard.

Per-module overhead is approximately 14 bytes per DWARF line entry
plus a small fixed cost per covered section descriptor.  The next
patch in this series delta-compresses the per-section streams to ~3-4
bytes per entry.

Assisted-by: Claude:claude-opus-4-6
Signed-off-by: Sasha Levin <sashal@kernel.org>
---
 .../admin-guide/kallsyms-lineinfo.rst         |  41 +-
 MAINTAINERS                                   |   3 +
 include/linux/mod_lineinfo.h                  | 122 +++
 include/linux/module.h                        |  40 +
 init/Kconfig                                  |  19 +-
 kernel/kallsyms.c                             |  19 +-
 kernel/module/kallsyms.c                      | 198 ++++
 kernel/module/main.c                          |  26 +
 scripts/Makefile.modfinal                     |   6 +
 scripts/gen-mod-lineinfo.sh                   |  50 +
 scripts/gen_lineinfo.c                        | 990 ++++++++++++++++--
 11 files changed, 1423 insertions(+), 91 deletions(-)
 create mode 100644 include/linux/mod_lineinfo.h
 create mode 100644 scripts/gen-mod-lineinfo.sh

diff --git a/Documentation/admin-guide/kallsyms-lineinfo.rst b/Documentation/admin-guide/kallsyms-lineinfo.rst
index c8ec124394354e..dd264830c8d5bf 100644
--- a/Documentation/admin-guide/kallsyms-lineinfo.rst
+++ b/Documentation/admin-guide/kallsyms-lineinfo.rst
@@ -51,22 +51,47 @@ With ``CONFIG_KALLSYMS_LINEINFO``::
 Note that assembly routines (such as ``entry_SYSCALL_64_after_hwframe``) are
 not annotated because they lack DWARF debug information.
 
+Module Support
+==============
+
+``CONFIG_KALLSYMS_LINEINFO_MODULES`` extends the feature to loadable kernel
+modules.  When enabled, each ``.ko`` is post-processed at build time to embed
+a ``.mod_lineinfo`` section containing the same kind of address-to-source
+mapping.
+
+Enable in addition to the base options::
+
+    CONFIG_MODULES=y
+    CONFIG_KALLSYMS_LINEINFO_MODULES=y
+
+Stack traces from module code will then include annotations::
+
+    my_driver_func+0x30/0x100 [my_driver] (drivers/foo/bar.c:123)
+
+The ``.mod_lineinfo`` section is loaded into read-only module memory alongside
+the module text.  No additional runtime memory allocation is required; the data
+is freed when the module is unloaded.
+
 Memory Overhead
 ===============
 
-The lineinfo tables are stored in ``.rodata`` and typically add approximately
-44 MiB to the kernel image for a standard configuration (~4.6 million DWARF
-line entries, ~10 bytes per entry after deduplication).
+The vmlinux lineinfo tables are stored in ``.rodata`` and typically add
+approximately 10-15 MiB to the kernel image for a standard configuration
+(~4.6 million DWARF line entries, ~2-3 bytes per entry after delta
+compression).
+
+Per-module lineinfo adds approximately 2-3 bytes per DWARF line entry to each
+``.ko`` file.
 
 Known Limitations
 =================
 
-- **vmlinux only**: Only symbols in the core kernel image are annotated.
-  Module symbols are not covered.
-- **4 GiB offset limit**: Address offsets from ``_text`` are stored as 32-bit
-  values.  Entries beyond 4 GiB from ``_text`` are skipped at build time with
-  a warning.
+- **4 GiB offset limit**: Address offsets from ``_text`` (vmlinux) or
+  ``.text`` base (modules) are stored as 32-bit values.  Entries beyond
+  4 GiB are skipped at build time with a warning.
 - **65535 file limit**: Source file IDs are stored as 16-bit values.  Builds
   with more than 65535 unique source files will fail with an error.
 - **No assembly annotations**: Functions implemented in assembly that lack
   DWARF ``.debug_line`` data are not annotated.
+- **No init text**: For modules, functions in ``.init.text`` are not annotated
+  because that memory is freed after module initialization.
diff --git a/MAINTAINERS b/MAINTAINERS
index b98d57b1ee1d5b..d7e1c1de7998f5 100644
--- a/MAINTAINERS
+++ b/MAINTAINERS
@@ -13935,6 +13935,9 @@ KALLSYMS LINEINFO
 M:	Sasha Levin <sashal@kernel.org>
 S:	Maintained
 F:	Documentation/admin-guide/kallsyms-lineinfo.rst
+F:	include/linux/mod_lineinfo.h
+F:	lib/tests/lineinfo_kunit.c
+F:	scripts/gen-mod-lineinfo.sh
 F:	scripts/gen_lineinfo.c
 
 KASAN
diff --git a/include/linux/mod_lineinfo.h b/include/linux/mod_lineinfo.h
new file mode 100644
index 00000000000000..75d0bfba6ce57b
--- /dev/null
+++ b/include/linux/mod_lineinfo.h
@@ -0,0 +1,122 @@
+/* SPDX-License-Identifier: GPL-2.0 */
+/*
+ * mod_lineinfo.h - Binary format for per-module source line information
+ *
+ * This header defines the layout of the .mod_lineinfo and
+ * .init.mod_lineinfo sections embedded in loadable kernel modules.  It
+ * is dual-use: included from both the kernel and the userspace
+ * gen_lineinfo tool.
+ *
+ * Top-level layout (all values in target-native endianness):
+ *
+ *   struct mod_lineinfo_root
+ *   struct mod_lineinfo_section sections[hdr.num_sections]
+ *   ... per-section sub-tables, each pointed at by sections[i].table_offset
+ *
+ * Each mod_lineinfo_section descriptor identifies one ELF text section
+ * covered by the lineinfo blob.  Its .anchor field is an ELF relocation
+ * resolved at module-load time to the runtime base of the named section,
+ * eliminating the need to derive the base from mod->mem[].base segments.
+ * If the relocation fails to resolve (e.g. unknown reloc type), .anchor
+ * stays zero and lookups silently degrade to "no annotation".
+ *
+ * Each per-section sub-table is laid out as a stand-alone
+ * mod_lineinfo_header followed by parallel arrays:
+ *
+ *   struct mod_lineinfo_header     (16 bytes)
+ *   u32 addrs[num_entries]         -- offsets from this section's base, sorted
+ *   u16 file_ids[num_entries]      -- parallel to addrs
+ *   <2-byte pad if num_entries is odd>
+ *   u32 lines[num_entries]         -- parallel to addrs
+ *   u32 file_offsets[num_files]    -- byte offset into filenames[]
+ *   char filenames[filenames_size] -- concatenated NUL-terminated strings
+ */
+#ifndef _LINUX_MOD_LINEINFO_H
+#define _LINUX_MOD_LINEINFO_H
+
+#ifdef __KERNEL__
+#include <linux/types.h>
+#include <linux/build_bug.h>
+#include <linux/stddef.h>
+#else
+#include <assert.h>
+#include <stddef.h>
+#include <stdint.h>
+typedef uint32_t u32;
+typedef uint16_t u16;
+typedef uint64_t u64;
+#ifndef __aligned
+#define __aligned(x)	__attribute__((__aligned__(x)))
+#endif
+#ifndef static_assert
+#define static_assert(e, ...)	_Static_assert(e, #e)
+#endif
+#endif
+
+/*
+ * Per-section descriptor.  One entry per ELF text section covered by the
+ * blob (.text, .exit.text, .init.text, ...).
+ */
+struct mod_lineinfo_section {
+	u64 anchor;		/* RELOC: runtime base of covered section, or 0 */
+	u32 size;		/* covered section size in bytes */
+	u32 table_offset;	/* byte offset from blob start to this section's
+				 * mod_lineinfo_header */
+} __aligned(8);
+
+/*
+ * Top-level header.  Sits at offset 0 of every .mod_lineinfo /
+ * .init.mod_lineinfo section.  The generated assembly pads to an 8-byte
+ * boundary after num_sections, so sections[0] must start at offset 8.
+ * The __aligned(8) on struct mod_lineinfo_section guarantees that even on
+ * 32-bit targets where the natural alignment of u64 is smaller (4 on i386,
+ * 2 on m68k) and the compiler would otherwise place sections[] at offset 4.
+ */
+struct mod_lineinfo_root {
+	u32 num_sections;
+	struct mod_lineinfo_section sections[];
+};
+
+static_assert(offsetof(struct mod_lineinfo_root, sections) == 8,
+	      "blob layout: sections[] must sit at offset 8 to match the generated assembly");
+static_assert(sizeof(struct mod_lineinfo_section) == 16,
+	      "blob layout: section descriptors are 16 bytes in the generated assembly");
+
+struct mod_lineinfo_header {
+	u32 num_entries;
+	u32 num_files;
+	u32 filenames_size;	/* total bytes of concatenated filenames */
+};
+
+/* Offset helpers: compute byte offset from the per-section header to each array. */
+
+static inline u32 mod_lineinfo_addrs_off(void)
+{
+	return sizeof(struct mod_lineinfo_header);
+}
+
+static inline u32 mod_lineinfo_file_ids_off(u32 num_entries)
+{
+	return mod_lineinfo_addrs_off() + num_entries * sizeof(u32);
+}
+
+static inline u32 mod_lineinfo_lines_off(u32 num_entries)
+{
+	/* u16 file_ids[] may need 2-byte padding to align lines[] to 4 bytes */
+	u32 off = mod_lineinfo_file_ids_off(num_entries) +
+		  num_entries * sizeof(u16);
+	return (off + 3) & ~3u;
+}
+
+static inline u32 mod_lineinfo_file_offsets_off(u32 num_entries)
+{
+	return mod_lineinfo_lines_off(num_entries) + num_entries * sizeof(u32);
+}
+
+static inline u32 mod_lineinfo_filenames_off(u32 num_entries, u32 num_files)
+{
+	return mod_lineinfo_file_offsets_off(num_entries) +
+	       num_files * sizeof(u32);
+}
+
+#endif /* _LINUX_MOD_LINEINFO_H */
diff --git a/include/linux/module.h b/include/linux/module.h
index 7566815fabbe8b..8ae14bb56abf8b 100644
--- a/include/linux/module.h
+++ b/include/linux/module.h
@@ -507,6 +507,12 @@ struct module {
 	void *btf_data;
 	void *btf_base_data;
 #endif
+#ifdef CONFIG_KALLSYMS_LINEINFO_MODULES
+	void *lineinfo_data;		/* .mod_lineinfo section in MOD_RODATA */
+	unsigned int lineinfo_data_size;
+	void *init_lineinfo_data;	/* .init.mod_lineinfo, NULL after init runs */
+	unsigned int init_lineinfo_data_size;
+#endif
 #ifdef CONFIG_JUMP_LABEL
 	struct jump_entry *jump_entries;
 	unsigned int num_jump_entries;
@@ -1020,6 +1026,40 @@ static inline unsigned long find_kallsyms_symbol_value(struct module *mod,
 
 #endif  /* CONFIG_MODULES && CONFIG_KALLSYMS */
 
+bool module_lookup_lineinfo(struct module *mod, unsigned long addr,
+			    unsigned long sym_start,
+			    const char **file, unsigned int *line);
+
+/*
+ * Reader accessors so callers don't need to duplicate the
+ * CONFIG_KALLSYMS_LINEINFO_MODULES guard around mod->lineinfo_data /
+ * mod->init_lineinfo_data field access.  Setters/clearers in the loader
+ * use the field directly under a matching #ifdef.
+ */
+static inline void *module_lineinfo_data(const struct module *mod,
+					 unsigned int *size)
+{
+#ifdef CONFIG_KALLSYMS_LINEINFO_MODULES
+	*size = mod->lineinfo_data_size;
+	return mod->lineinfo_data;
+#else
+	*size = 0;
+	return NULL;
+#endif
+}
+
+static inline void *module_init_lineinfo_data(const struct module *mod,
+					      unsigned int *size)
+{
+#ifdef CONFIG_KALLSYMS_LINEINFO_MODULES
+	*size = READ_ONCE(mod->init_lineinfo_data_size);
+	return READ_ONCE(mod->init_lineinfo_data);
+#else
+	*size = 0;
+	return NULL;
+#endif
+}
+
 /* Define __free(module_put) macro for struct module *. */
 DEFINE_FREE(module_put, struct module *, if (_T) module_put(_T))
 
diff --git a/init/Kconfig b/init/Kconfig
index f004cf9a69d40a..79975a6e67c21b 100644
--- a/init/Kconfig
+++ b/init/Kconfig
@@ -2102,8 +2102,23 @@ config KALLSYMS_LINEINFO
 	    anon_vma_clone+0x2ed/0xcf0 (mm/rmap.c:412)
 
 	  This requires elfutils (libdw-dev/elfutils-devel) on the build host.
-	  Adds approximately 44MB to a typical kernel image (10 bytes per
-	  DWARF line-table entry, ~4.6M entries for a typical config).
+	  Adds approximately 10-15MB to a typical kernel image (~2-3 bytes
+	  per entry after delta compression, ~4.6M entries for a typical
+	  config).
+
+	  If unsure, say N.
+
+config KALLSYMS_LINEINFO_MODULES
+	bool "Embed source file:line information in module stack traces"
+	depends on KALLSYMS_LINEINFO && MODULES
+	help
+	  Extends KALLSYMS_LINEINFO to loadable kernel modules.  Each .ko
+	  gets a lineinfo table generated from its DWARF data at build time,
+	  so stack traces from module code include (file.c:123) annotations.
+
+	  Requires elfutils (libdw-dev/elfutils-devel) on the build host.
+	  Increases .ko sizes by approximately 2-3 bytes per DWARF line
+	  entry after delta compression.
 
 	  If unsure, say N.
 
diff --git a/kernel/kallsyms.c b/kernel/kallsyms.c
index d3fcf282c33cdf..55aa56c9d07b16 100644
--- a/kernel/kallsyms.c
+++ b/kernel/kallsyms.c
@@ -569,12 +569,25 @@ static int __sprint_symbol(char *buffer, unsigned long address,
 	 * replaced with bar()"); appending lineinfo there would produce a
 	 * confusing "foo (file:line)()".
 	 */
-	if (add_lineinfo && IS_ENABLED(CONFIG_KALLSYMS_LINEINFO) && !modname) {
+	if (add_lineinfo && IS_ENABLED(CONFIG_KALLSYMS_LINEINFO)) {
 		const char *li_file;
 		unsigned int li_line;
+		bool found = false;
+
+		if (!modname) {
+			found = kallsyms_lookup_lineinfo(address, sym_start,
+							 &li_file, &li_line);
+		} else if (IS_ENABLED(CONFIG_KALLSYMS_LINEINFO_MODULES)) {
+			struct module *mod = __module_address(address);
+
+			if (mod)
+				found = module_lookup_lineinfo(mod, address,
+							       sym_start,
+							       &li_file,
+							       &li_line);
+		}
 
-		if (kallsyms_lookup_lineinfo(address, sym_start,
-					     &li_file, &li_line))
+		if (found)
 			len += snprintf(buffer + len, KSYM_SYMBOL_LEN - len,
 					" (%s:%u)", li_file, li_line);
 	}
diff --git a/kernel/module/kallsyms.c b/kernel/module/kallsyms.c
index 0fc11e45df9b91..4888ce8a7f951d 100644
--- a/kernel/module/kallsyms.c
+++ b/kernel/module/kallsyms.c
@@ -494,3 +494,201 @@ int module_kallsyms_on_each_symbol(const char *modname,
 	mutex_unlock(&module_mutex);
 	return ret;
 }
+
+#include <linux/mod_lineinfo.h>
+
+/*
+ * Search one per-section sub-table for @section_offset using flat parallel
+ * arrays.  @hdr is the per-section header at byte offset @hdr_offset within
+ * @blob.  Returns true on hit and populates @file / @line.
+ */
+static bool module_lookup_lineinfo_section(const void *blob, u32 blob_size,
+					   u32 hdr_offset,
+					   unsigned int section_offset,
+					   unsigned int min_offset,
+					   const char **file,
+					   unsigned int *line)
+{
+	const struct mod_lineinfo_header *hdr;
+	const u8 *base;
+	const u32 *addrs, *lines, *file_offsets;
+	const u16 *file_ids;
+	const char *filenames;
+	u32 num_entries, num_files, filenames_size;
+	unsigned int low, high, mid;
+	u16 file_id;
+
+	if (hdr_offset > blob_size ||
+	    blob_size - hdr_offset < sizeof(*hdr))
+		return false;
+
+	base = (const u8 *)blob + hdr_offset;
+	hdr = (const struct mod_lineinfo_header *)base;
+	num_entries = hdr->num_entries;
+	num_files = hdr->num_files;
+	filenames_size = hdr->filenames_size;
+
+	if (num_entries == 0)
+		return false;
+
+	/*
+	 * Validate counts before multiplying — sizing arithmetic could
+	 * otherwise overflow on 32-bit with a malformed blob.  Each entry
+	 * contributes one u32 (addrs), one u16 (file_ids), and one u32
+	 * (lines); each file contributes one u32 (file_offsets).
+	 */
+	{
+		u32 avail = blob_size - hdr_offset;
+		u32 needed = mod_lineinfo_filenames_off(num_entries, num_files);
+
+		if (num_entries > U32_MAX / sizeof(u32))
+			return false;
+		if (num_files > U32_MAX / sizeof(u32))
+			return false;
+		if (needed > avail || filenames_size > avail - needed)
+			return false;
+	}
+
+	/*
+	 * Filenames are read as NUL-terminated C strings.  Require the blob
+	 * to end in NUL so a malformed file_offsets entry can never lead the
+	 * later "%s" consumer past the end of the section.
+	 */
+	if (filenames_size == 0 ||
+	    base[mod_lineinfo_filenames_off(num_entries, num_files) +
+		 filenames_size - 1] != 0)
+		return false;
+
+	addrs = (const u32 *)(base + mod_lineinfo_addrs_off());
+	file_ids = (const u16 *)(base + mod_lineinfo_file_ids_off(num_entries));
+	lines = (const u32 *)(base + mod_lineinfo_lines_off(num_entries));
+	file_offsets = (const u32 *)(base + mod_lineinfo_file_offsets_off(num_entries));
+	filenames = (const char *)(base + mod_lineinfo_filenames_off(num_entries, num_files));
+
+	/* Binary search for largest entry <= section_offset. */
+	low = 0;
+	high = num_entries;
+	while (low < high) {
+		mid = low + (high - low) / 2;
+		if (addrs[mid] <= section_offset)
+			low = mid + 1;
+		else
+			high = mid;
+	}
+
+	if (low == 0)
+		return false;
+	low--;
+
+	/*
+	 * Reject entries below the resolved symbol's start so a symbol
+	 * without line entries of its own does not inherit the preceding
+	 * symbol's annotation.
+	 */
+	if (addrs[low] < min_offset)
+		return false;
+
+	file_id = file_ids[low];
+	if (file_id >= num_files)
+		return false;
+	if (file_offsets[file_id] >= filenames_size)
+		return false;
+
+	*file = &filenames[file_offsets[file_id]];
+	*line = lines[low];
+	return true;
+}
+
+/*
+ * Walk a single .mod_lineinfo / .init.mod_lineinfo blob, find the section
+ * descriptor whose [anchor, anchor+size) range contains @addr, then search
+ * that section's sub-table.
+ */
+static bool module_lookup_lineinfo_blob(const void *blob, u32 blob_size,
+					unsigned long addr,
+					unsigned long sym_start,
+					const char **file, unsigned int *line)
+{
+	const struct mod_lineinfo_root *root;
+	u32 i, sections_end;
+
+	if (!blob || blob_size < sizeof(*root))
+		return false;
+
+	root = blob;
+	if (root->num_sections == 0)
+		return false;
+
+	if (root->num_sections > U32_MAX / sizeof(struct mod_lineinfo_section))
+		return false;
+	sections_end = sizeof(*root) +
+		       root->num_sections * sizeof(struct mod_lineinfo_section);
+	if (sections_end > blob_size)
+		return false;
+
+	for (i = 0; i < root->num_sections; i++) {
+		const struct mod_lineinfo_section *s = &root->sections[i];
+		unsigned long base = (unsigned long)s->anchor;
+		unsigned long offset, min_offset = 0;
+
+		if (!base)
+			continue;	/* relocation didn't resolve */
+		if (addr < base)
+			continue;
+		offset = addr - base;
+		if (offset >= s->size)
+			continue;
+		if (offset > U32_MAX)
+			continue;
+
+		if (sym_start > base && sym_start - base <= offset)
+			min_offset = sym_start - base;
+
+		return module_lookup_lineinfo_section(blob, blob_size,
+						      s->table_offset,
+						      (unsigned int)offset,
+						      (unsigned int)min_offset,
+						      file, line);
+	}
+
+	return false;
+}
+
+/*
+ * Look up source file:line for an address within a loaded module.
+ *
+ * Safe in NMI/panic context: no locks, no allocations.
+ * Caller must hold RCU read lock (or be in a context where the module
+ * cannot be unloaded).
+ */
+bool module_lookup_lineinfo(struct module *mod, unsigned long addr,
+			    unsigned long sym_start,
+			    const char **file, unsigned int *line)
+{
+	const void *blob;
+	unsigned int size;
+
+	if (!IS_ENABLED(CONFIG_KALLSYMS_LINEINFO_MODULES))
+		return false;
+
+	blob = module_lineinfo_data(mod, &size);
+	if (blob && module_lookup_lineinfo_blob(blob, size, addr, sym_start,
+						file, line))
+		return true;
+
+	/*
+	 * The init blob lives in MOD_INIT_RODATA and is revoked by
+	 * do_init_module() before do_free_init() releases the memory.  The
+	 * READ_ONCE inside module_init_lineinfo_data() pairs with the
+	 * WRITE_ONCE in do_init_module so we never see a partial
+	 * pointer/size pair, and an RCU grace period in do_free_init()
+	 * guarantees the memory still exists for the duration of any lookup
+	 * that captured the pointer before the revocation.
+	 */
+	blob = module_init_lineinfo_data(mod, &size);
+	if (blob && module_lookup_lineinfo_blob(blob, size, addr, sym_start,
+						file, line))
+		return true;
+
+	return false;
+}
diff --git a/kernel/module/main.c b/kernel/module/main.c
index 46dd8d25a6058e..46bb2bf799d1ee 100644
--- a/kernel/module/main.c
+++ b/kernel/module/main.c
@@ -2712,6 +2712,19 @@ static int find_module_sections(struct module *mod, struct load_info *info)
 	mod->btf_base_data = any_section_objs(info, ".BTF.base", 1,
 					      &mod->btf_base_data_size);
 #endif
+#ifdef CONFIG_KALLSYMS_LINEINFO_MODULES
+	/*
+	 * Use section_objs() (not any_section_objs) — both blobs carry an
+	 * ELF anchor relocation that the module loader resolves via its
+	 * standard apply_relocations() pass, which only walks SHF_ALLOC
+	 * sections.  Picking up a non-ALLOC section here would also leave
+	 * the pointer dangling into the temporary load image once freed.
+	 */
+	mod->lineinfo_data = section_objs(info, ".mod_lineinfo", 1,
+					  &mod->lineinfo_data_size);
+	mod->init_lineinfo_data = section_objs(info, ".init.mod_lineinfo", 1,
+					       &mod->init_lineinfo_data_size);
+#endif
 #ifdef CONFIG_JUMP_LABEL
 	mod->jump_entries = section_objs(info, "__jump_table",
 					sizeof(*mod->jump_entries),
@@ -3165,6 +3178,19 @@ static noinline int do_init_module(struct module *mod)
 	/* .BTF is not SHF_ALLOC and will get removed, so sanitize pointers */
 	mod->btf_data = NULL;
 	mod->btf_base_data = NULL;
+#endif
+#ifdef CONFIG_KALLSYMS_LINEINFO_MODULES
+	/*
+	 * .init.mod_lineinfo lives in MOD_INIT_RODATA which do_free_init() is
+	 * about to release.  Clear the pointer so concurrent stack-trace
+	 * lookups stop dereferencing it; do_free_init()'s synchronize_rcu()
+	 * then waits out any reader that already captured the old pointer.
+	 * WRITE_ONCE pairs with the READ_ONCE inside module_init_lineinfo_data()
+	 * so the compiler can't tear or reorder the revocation across the
+	 * llist_add() that follows.
+	 */
+	WRITE_ONCE(mod->init_lineinfo_data, NULL);
+	WRITE_ONCE(mod->init_lineinfo_data_size, 0);
 #endif
 	/*
 	 * We want to free module_init, but be aware that kallsyms may be
diff --git a/scripts/Makefile.modfinal b/scripts/Makefile.modfinal
index 01a37ec872b905..2cdd76becb4d52 100644
--- a/scripts/Makefile.modfinal
+++ b/scripts/Makefile.modfinal
@@ -46,11 +46,17 @@ quiet_cmd_btf_ko = BTF [M] $@
 		$(CONFIG_SHELL) $(srctree)/scripts/gen-btf.sh --btf_base $(objtree)/vmlinux $@; \
 	fi;
 
+quiet_cmd_lineinfo_ko = LINEINFO [M] $@
+      cmd_lineinfo_ko = $(CONFIG_SHELL) $(srctree)/scripts/gen-mod-lineinfo.sh $@
+
 # Re-generate module BTFs if either module's .ko or vmlinux changed
 %.ko: %.o %.mod.o .module-common.o $(objtree)/scripts/module.lds $(and $(CONFIG_DEBUG_INFO_BTF_MODULES),$(KBUILD_BUILTIN),$(objtree)/vmlinux) FORCE
 	+$(call if_changed,ld_ko_o)
 ifdef CONFIG_DEBUG_INFO_BTF_MODULES
 	+$(if $(newer-prereqs),$(call cmd,btf_ko))
+endif
+ifdef CONFIG_KALLSYMS_LINEINFO_MODULES
+	+$(if $(newer-prereqs),$(call cmd,lineinfo_ko))
 endif
 	+$(call cmd,check_tracepoint)
 
diff --git a/scripts/gen-mod-lineinfo.sh b/scripts/gen-mod-lineinfo.sh
new file mode 100644
index 00000000000000..832d290f3bf4cb
--- /dev/null
+++ b/scripts/gen-mod-lineinfo.sh
@@ -0,0 +1,50 @@
+#!/bin/sh
+# SPDX-License-Identifier: GPL-2.0
+#
+# gen-mod-lineinfo.sh - Embed source line info into a kernel module (.ko)
+#
+# Reads DWARF from the .ko, generates a .mod_lineinfo section that contains
+# an ELF relocation against the module's .text section symbol, and partial-
+# links the result back into the .ko via "ld -r" so the relocation rides
+# along to the module loader.  Modeled on scripts/gen-btf.sh.
+
+set -e
+
+if [ $# -ne 1 ]; then
+	echo "Usage: $0 <module.ko>" >&2
+	exit 1
+fi
+
+KO="$1"
+
+cleanup() {
+	rm -f "${KO}.lineinfo.S" "${KO}.lineinfo.o" "${KO}.lineinfo.tmp"
+}
+trap cleanup EXIT
+
+case "${KBUILD_VERBOSE}" in
+*1*)
+	set -x
+	;;
+esac
+
+# Generate assembly from DWARF -- if it fails (no DWARF), silently skip
+if ! ${objtree}/scripts/gen_lineinfo --module "${KO}" > "${KO}.lineinfo.S"; then
+	exit 0
+fi
+
+# Compile assembly to object file
+${CC} ${NOSTDINC_FLAGS} ${LINUXINCLUDE} ${KBUILD_CPPFLAGS} \
+	${KBUILD_AFLAGS} ${KBUILD_AFLAGS_MODULE} \
+	-c -o "${KO}.lineinfo.o" "${KO}.lineinfo.S"
+
+# Partial-link lineinfo.o INTO the .ko.  Order matters: lineinfo.o must come
+# FIRST so its empty .text contributes 0 bytes at offset 0 of the merged
+# .text, which keeps the .quad .text relocation (against lineinfo.o's local
+# .text symbol, which after merge points at offset 0 of merged .text)
+# resolving to the start of the module's .text.  Reversing inputs here
+# silently breaks lookup correctness.
+${LD} -r "${KO}.lineinfo.o" "${KO}" -o "${KO}.lineinfo.tmp"
+mv "${KO}.lineinfo.tmp" "${KO}"
+
+exit 0
diff --git a/scripts/gen_lineinfo.c b/scripts/gen_lineinfo.c
index 699e760178f094..7624098d0cb426 100644
--- a/scripts/gen_lineinfo.c
+++ b/scripts/gen_lineinfo.c
@@ -24,16 +24,79 @@
 #include <gelf.h>
 #include <limits.h>
 
+#include "../include/linux/mod_lineinfo.h"
+
+static int module_mode;
+
 static unsigned int skipped_overflow;
 
+/* Target ELF traits, captured once in main() and reused at emit time. */
+static bool target_64bit;
+static bool target_le;
+
 /*
- * vmlinux mode: end of the invariant .text region.  Zero means "no cap"
- * (graceful fallback when _etext is absent on some build).
+ * Vmlinux mode only: address range of the *invariant* .text region.
+ * See find_text_end_addr() for why we cap on _etext.  text_end_addr == 0
+ * means "no cap available; capture everything above text_addr" (v3
+ * behavior, used as graceful fallback if _etext is absent).
  */
 static unsigned long long text_end_addr;
 
+/*
+ * In module mode we cover several text-like sections, split across two
+ * output blobs by lifecycle:
+ *
+ *   .mod_lineinfo      -- persistent code (.text, .exit.text); MOD_RODATA
+ *   .init.mod_lineinfo -- init code (.init.text); freed with init memory
+ *
+ * In ET_REL .ko files .text/.init.text/.exit.text all have sh_addr == 0,
+ * so DWARF line addresses (which become sh_addr + addend after relocation)
+ * collide across sections.  We disambiguate by giving each *present*
+ * covered section a unique synthetic "bias" — a u32 base address — and
+ * adding that bias to relocated values inside apply_debug_line_relocations.
+ * libdw then yields biased addresses that classify_address() can map back
+ * to a single section unambiguously.  The bias is internal to gen_lineinfo
+ * and never leaks into the emitted blob.
+ */
+enum mod_lineinfo_blob {
+	BLOB_PERSISTENT,
+	BLOB_INIT,
+	NUM_BLOBS,
+};
+
+struct covered_section {
+	const char *name;	/* ELF section name (e.g. ".text") */
+	enum mod_lineinfo_blob blob;
+	unsigned long long bias;/* synthetic base address (set in resolve_*) */
+	unsigned long long size;
+	bool present;		/* found in this .ko */
+	unsigned int sec_index;	/* ELF section header index, for reloc matching */
+	unsigned int n_entries;	/* DWARF line entries collected for this section */
+};
+
+static struct covered_section all_sections[] = {
+	{ .name = ".text",         .blob = BLOB_PERSISTENT },
+	{ .name = ".exit.text",    .blob = BLOB_PERSISTENT },
+	{ .name = ".init.text",    .blob = BLOB_INIT },
+	{ .name = ".noinstr.text", .blob = BLOB_PERSISTENT },
+};
+#define ALL_SECTIONS	(sizeof(all_sections) / sizeof(all_sections[0]))
+
+/*
+ * Executable sections present in the .ko but not covered by the blob
+ * (e.g. .static_call.text, .altinstr_replacement, or the per-function
+ * .text.* subsections parisc32 modules keep).  They get synthetic biases
+ * from the same cursor as the covered sections so their DWARF sequences
+ * classify unambiguously and can be dropped, instead of aliasing into
+ * .text's [0, size) range.
+ */
+static struct covered_section *extra_sections;
+static unsigned int num_extra_sections;
+static unsigned long long skipped_uncovered;
+
 struct line_entry {
-	unsigned int offset;	/* offset from _text */
+	unsigned int offset;	/* offset from covered section's start */
+	unsigned int section_id;/* index into covered_sections[] (module mode only) */
 	unsigned int file_id;
 	unsigned int line;
 };
@@ -52,7 +115,12 @@ static struct file_entry *files;
 static unsigned int num_files;
 static unsigned int files_capacity;
 
-#define FILE_HASH_BITS 13
+/*
+ * Hash size must comfortably exceed the 65535-file cap below so the open
+ * addressing in find_or_add_file() always has a free slot to land on.
+ * 17 bits = 131072 entries gives ~50% max load factor.
+ */
+#define FILE_HASH_BITS 17
 #define FILE_HASH_SIZE (1 << FILE_HASH_BITS)
 
 struct file_hash_entry {
@@ -71,8 +139,8 @@ static unsigned int hash_str(const char *s)
 	return h & (FILE_HASH_SIZE - 1);
 }
 
-static void add_entry(unsigned int offset, unsigned int file_id,
-		      unsigned int line)
+static void add_entry(unsigned int offset, unsigned int section_id,
+		      unsigned int file_id, unsigned int line)
 {
 	if (num_entries >= entries_capacity) {
 		entries_capacity = entries_capacity ? entries_capacity * 2 : 65536;
@@ -83,6 +151,7 @@ static void add_entry(unsigned int offset, unsigned int file_id,
 		}
 	}
 	entries[num_entries].offset = offset;
+	entries[num_entries].section_id = section_id;
 	entries[num_entries].file_id = file_id;
 	entries[num_entries].line = line;
 	num_entries++;
@@ -155,27 +224,25 @@ static const char *make_relative(const char *path, const char *comp_dir)
 {
 	const char *p;
 
-	/* If already relative, use as-is */
-	if (path[0] != '/')
-		return path;
-
-	/* comp_dir from DWARF is the most reliable method */
-	if (comp_dir) {
-		size_t len = strlen(comp_dir);
-
-		if (!strncmp(path, comp_dir, len) && path[len] == '/') {
-			const char *rel = path + len + 1;
-
-			/*
-			 * If comp_dir pointed to a subdirectory
-			 * (e.g. arch/parisc/kernel) rather than
-			 * the tree root, stripping it leaves a
-			 * bare filename.  Fall through to the
-			 * kernel_dirs scan so we recover the full
-			 * relative path instead.
-			 */
-			if (strchr(rel, '/'))
-				return rel;
+	if (path[0] == '/') {
+		/* Try comp_dir prefix from DWARF */
+		if (comp_dir) {
+			size_t len = strlen(comp_dir);
+
+			if (!strncmp(path, comp_dir, len) && path[len] == '/') {
+				const char *rel = path + len + 1;
+
+				/*
+				 * If comp_dir pointed to a subdirectory
+				 * (e.g. arch/parisc/kernel) rather than
+				 * the tree root, stripping it leaves a
+				 * bare filename.  Fall through to the
+				 * kernel_dirs scan so we recover the full
+				 * relative path instead.
+				 */
+				if (strchr(rel, '/'))
+					return rel;
+			}
 		}
 
 		/*
@@ -201,9 +268,42 @@ static const char *make_relative(const char *path, const char *comp_dir)
 		return p ? p + 1 : path;
 	}
 
-	/* Fall back to basename */
-	p = strrchr(path, '/');
-	return p ? p + 1 : path;
+	/*
+	 * Relative path — check for duplicated-path quirk from libdw
+	 * on ET_REL files (e.g., "a/b.c/a/b.c" → "a/b.c").
+	 */
+	{
+		size_t len = strlen(path);
+		size_t mid = len / 2;
+
+		if (len > 1 && path[mid] == '/' &&
+		    !memcmp(path, path + mid + 1, mid))
+			return path + mid + 1;
+	}
+
+	/*
+	 * Bare filename with no directory component — try to recover the
+	 * relative path using comp_dir.  Some toolchains/elfutils combos
+	 * produce bare filenames where comp_dir holds the source directory.
+	 * Construct the absolute path and run the kernel_dirs scan.
+	 */
+	if (!strchr(path, '/') && comp_dir && comp_dir[0] == '/') {
+		static char buf[PATH_MAX];
+
+		snprintf(buf, sizeof(buf), "%s/%s", comp_dir, path);
+		for (p = buf + 1; *p; p++) {
+			if (*(p - 1) == '/') {
+				for (unsigned int i = 0; i < sizeof(kernel_dirs) /
+				     sizeof(kernel_dirs[0]); i++) {
+					if (!strncmp(p, kernel_dirs[i],
+						     strlen(kernel_dirs[i])))
+						return p;
+				}
+			}
+		}
+	}
+
+	return path;
 }
 
 static int compare_entries(const void *a, const void *b)
@@ -211,6 +311,9 @@ static int compare_entries(const void *a, const void *b)
 	const struct line_entry *ea = a;
 	const struct line_entry *eb = b;
 
+	/* Group by section first so each per-section table is contiguous. */
+	if (ea->section_id != eb->section_id)
+		return ea->section_id < eb->section_id ? -1 : 1;
 	if (ea->offset != eb->offset)
 		return ea->offset < eb->offset ? -1 : 1;
 	if (ea->file_id != eb->file_id)
@@ -222,7 +325,8 @@ static int compare_entries(const void *a, const void *b)
 
 /*
  * Look up a vmlinux symbol by exact name and return its st_value, or
- * @fallback if absent.  Aborts when @required and the symbol is missing.
+ * @fallback if the symbol is absent (lets callers gracefully skip
+ * optional bounds like _etext).
  */
 static unsigned long long find_vmlinux_sym(Elf *elf, const char *name,
 					   unsigned long long fallback,
@@ -270,22 +374,441 @@ static unsigned long long find_text_addr(Elf *elf)
 }
 
 /*
- * vmlinux is linked in multiple passes: gen_lineinfo runs against
- * .tmp_vmlinux1 (which carries an empty lineinfo stub), then real tables
- * are linked in for the final image.  Sections placed AFTER .rodata
- * (.init.text, .exit.text, ...) shift forward as .rodata grows to hold
- * the real lineinfo blob, so DWARF addresses we'd capture for them in
- * pass 1 would be stale in the final kernel.  Cap captured addresses at
- * _etext, the symbol that marks the end of .text — placed before .rodata
- * in every architecture's vmlinux.lds.S, so its addresses are invariant
- * across the relink.  Returns 0 if _etext is absent (no cap; v3 behavior).
+ * Vmlinux is linked in multiple passes: gen_lineinfo runs against
+ * .tmp_vmlinux1 (which carries the empty lineinfo stub), and the resulting
+ * tables are then linked into the final vmlinux.  Sections placed AFTER
+ * .rodata (.init.text, .exit.text, ...) shift forward as the real lineinfo
+ * tables replace the empty stub, so DWARF addresses we'd capture for them
+ * here are stale by the time the kernel runs.
+ *
+ * Cap the captured range at _etext, the symbol that marks the end of the
+ * .text section.  .text is placed BEFORE .rodata in every architecture's
+ * vmlinux.lds.S, so its addresses are invariant across the relink.
+ * Returns 0 on architectures or builds that don't expose _etext, in which
+ * case the cap is disabled (preserving the v3 behavior — addresses past
+ * .text remain captured but may be off in stack traces).
  */
 static unsigned long long find_text_end_addr(Elf *elf)
 {
 	return find_vmlinux_sym(elf, "_etext", 0, false);
 }
 
-static void process_dwarf(Dwarf *dwarf, unsigned long long text_addr)
+/*
+ * Populate @sections[].present/sec_index/size/bias.  Sections that don't
+ * exist stay marked absent.  Biases are assigned in array order: each
+ * present section gets a base equal to the running total of preceding
+ * present sections' sizes, rounded up to 16 to keep ranges sparse.  This
+ * guarantees [bias, bias+size) ranges are pairwise disjoint and fit in
+ * u32 as long as the sum of all covered text sizes is below 4 GiB.
+ */
+static void resolve_covered_sections(Elf *elf,
+				     struct covered_section *sections,
+				     unsigned int num_sections)
+{
+	Elf_Scn *scn = NULL;
+	GElf_Shdr shdr;
+	size_t shstrndx;
+	unsigned long long cursor = 0;
+
+	if (elf_getshdrstrndx(elf, &shstrndx) != 0)
+		return;
+
+	while ((scn = elf_nextscn(elf, scn)) != NULL) {
+		const char *name;
+
+		if (!gelf_getshdr(scn, &shdr))
+			continue;
+		name = elf_strptr(elf, shstrndx, shdr.sh_name);
+		if (!name)
+			continue;
+		bool covered = false;
+
+		for (unsigned int i = 0; i < num_sections; i++) {
+			if (sections[i].present)
+				continue;
+			if (strcmp(name, sections[i].name))
+				continue;
+			if (shdr.sh_size > UINT_MAX) {
+				fprintf(stderr,
+					"lineinfo: section %s exceeds 4 GiB (size=%llu); skipping\n",
+					name,
+					(unsigned long long)shdr.sh_size);
+				break;
+			}
+			sections[i].sec_index = elf_ndxscn(scn);
+			sections[i].size = shdr.sh_size;
+			sections[i].present = true;
+			covered = true;
+			break;
+		}
+
+		/*
+		 * Track every other executable section too, so its DWARF
+		 * sequences can be biased into their own range and dropped
+		 * instead of polluting a covered section's table.
+		 */
+		if (!covered &&
+		    (shdr.sh_flags & SHF_EXECINSTR) && (shdr.sh_flags & SHF_ALLOC) &&
+		    shdr.sh_size && shdr.sh_size <= UINT_MAX) {
+			struct covered_section *es;
+
+			extra_sections = realloc(extra_sections,
+						 (num_extra_sections + 1) *
+						 sizeof(*extra_sections));
+			if (!extra_sections) {
+				fprintf(stderr, "out of memory\n");
+				exit(1);
+			}
+			es = &extra_sections[num_extra_sections++];
+			memset(es, 0, sizeof(*es));
+			es->name = name;
+			es->sec_index = elf_ndxscn(scn);
+			es->size = shdr.sh_size;
+			es->present = true;
+		}
+	}
+
+	/* Pack present sections into non-overlapping bias ranges. */
+	for (unsigned int i = 0; i < num_sections; i++) {
+		if (!sections[i].present)
+			continue;
+		sections[i].bias = cursor;
+		cursor += sections[i].size;
+		cursor = (cursor + 15) & ~15ULL;	/* pad for separation */
+	}
+	for (unsigned int i = 0; i < num_extra_sections; i++) {
+		extra_sections[i].bias = cursor;
+		cursor += extra_sections[i].size;
+		cursor = (cursor + 15) & ~15ULL;
+	}
+}
+
+/* Look up a covered_section by ELF section header index. */
+static struct covered_section *section_by_index(struct covered_section *sections,
+						unsigned int num_sections,
+						unsigned int sec_index)
+{
+	for (unsigned int i = 0; i < num_sections; i++) {
+		if (sections[i].present && sections[i].sec_index == sec_index)
+			return &sections[i];
+	}
+	return NULL;
+}
+
+/*
+ * Apply .rela.debug_line relocations to a mutable copy of .debug_line data.
+ *
+ * elfutils libdw (through at least 0.194) does NOT apply relocations for
+ * ET_REL files when using dwarf_begin_elf().  The internal libdwfl layer
+ * does this via __libdwfl_relocate(), but that API is not public.
+ *
+ * For DWARF5, the .debug_line file name table uses DW_FORM_line_strp
+ * references into .debug_line_str.  Without relocation, all these offsets
+ * resolve to 0 (or garbage), causing dwarf_linesrc()/dwarf_filesrc() to
+ * return wrong filenames (typically the comp_dir for every file).
+ *
+ * This function applies the relocations manually so that the patched
+ * .debug_line data can be fed to dwarf_begin_elf() and produce correct
+ * results.
+ *
+ * See elfutils bug https://sourceware.org/bugzilla/show_bug.cgi?id=31447
+ * A fix (dwelf_elf_apply_relocs) was proposed but not yet merged as of
+ * elfutils 0.194: https://sourceware.org/pipermail/elfutils-devel/2024q3/007388.html
+ */
+/*
+ * Determine the relocation type for a 32-bit absolute reference
+ * on the given architecture.  Returns 0 if unknown.
+ */
+static unsigned int r_type_abs32(unsigned int e_machine)
+{
+	switch (e_machine) {
+	case EM_X86_64:		return R_X86_64_32;
+	case EM_386:		return R_386_32;
+	case EM_AARCH64:	return R_AARCH64_ABS32;
+	case EM_ARM:		return R_ARM_ABS32;
+	case EM_RISCV:		return R_RISCV_32;
+	case EM_S390:		return R_390_32;
+	case EM_MIPS:		return R_MIPS_32;
+	case EM_PPC64:		return R_PPC64_ADDR32;
+	case EM_PPC:		return R_PPC_ADDR32;
+	case EM_LOONGARCH:	return R_LARCH_32;
+	case EM_PARISC:		return R_PARISC_DIR32;
+	default:		return 0;
+	}
+}
+
+/*
+ * Determine the relocation type for a 64-bit absolute reference
+ * on the given architecture.  Returns 0 on 32-bit-only architectures
+ * (where DW_LNE_set_address fits in 32 bits and r_type_abs32 covers it).
+ */
+static unsigned int r_type_abs64(unsigned int e_machine)
+{
+	switch (e_machine) {
+	case EM_X86_64:		return R_X86_64_64;
+	case EM_AARCH64:	return R_AARCH64_ABS64;
+	case EM_RISCV:		return R_RISCV_64;
+	case EM_S390:		return R_390_64;
+	case EM_MIPS:		return R_MIPS_64;
+	case EM_PPC64:		return R_PPC64_ADDR64;
+	case EM_LOONGARCH:	return R_LARCH_64;
+	case EM_PARISC:		return R_PARISC_DIR64;
+	default:		return 0;
+	}
+}
+
+/*
+ * Write a 4- or 8-byte unsigned integer in target byte order.
+ * Cross-builds (e.g. x86_64 host -> s390 module) need the patched
+ * .debug_line bytes laid out per the .ko's e_ident[EI_DATA], not the host's.
+ */
+static void elf_write_uint(unsigned char *dst, uint64_t value, size_t size,
+			   bool little_endian)
+{
+	if (little_endian) {
+		for (size_t i = 0; i < size; i++)
+			dst[i] = (value >> (i * 8)) & 0xff;
+	} else {
+		for (size_t i = 0; i < size; i++)
+			dst[i] = (value >> ((size - 1 - i) * 8)) & 0xff;
+	}
+}
+
+/* Counterpart to elf_write_uint: read the implicit addend of an SHT_REL
+ * relocation, stored in the relocated field itself in target byte order.
+ */
+static uint64_t elf_read_uint(const unsigned char *src, size_t size,
+			      bool little_endian)
+{
+	uint64_t value = 0;
+
+	if (little_endian) {
+		for (size_t i = 0; i < size; i++)
+			value |= (uint64_t)src[i] << (i * 8);
+	} else {
+		for (size_t i = 0; i < size; i++)
+			value |= (uint64_t)src[i] << ((size - 1 - i) * 8);
+	}
+	return value;
+}
+
+/*
+ * Apply one .debug_line relocation.  Two reloc widths matter:
+ *   abs32 - DW_FORM_line_strp file-table refs into .debug_line_str
+ *   abs64 - DW_LNE_set_address arguments (sequence start PCs)
+ * Without both, libdw sees zeros and reports wrong filenames or collapses
+ * every sequence to address 0 (collision after dedup).
+ *
+ * @has_addend distinguishes RELA records (explicit @addend) from REL
+ * records, whose addend is read from the relocated field itself.
+ */
+static void apply_one_dl_reloc(Elf_Data *dl_data, Elf_Data *sym_data,
+			       bool target_le,
+			       unsigned int abs32_type, unsigned int abs64_type,
+			       GElf_Xword r_info, GElf_Addr r_offset,
+			       GElf_Sxword addend, bool has_addend)
+{
+	GElf_Sym sym;
+	unsigned int r_type = GELF_R_TYPE(r_info);
+	size_t r_sym = GELF_R_SYM(r_info);
+	bool is_abs64;
+	size_t width;
+	uint64_t value;
+
+	if (abs32_type && r_type == abs32_type)
+		is_abs64 = false;
+	else if (abs64_type && r_type == abs64_type)
+		is_abs64 = true;
+	else
+		return;
+
+	if (!gelf_getsym(sym_data, r_sym, &sym))
+		return;
+
+	width = is_abs64 ? 8 : 4;
+
+	if (r_offset + width > dl_data->d_size)
+		return;
+
+	if (!has_addend)
+		addend = (GElf_Sxword)elf_read_uint(
+				(unsigned char *)dl_data->d_buf + r_offset,
+				width, target_le);
+
+	value = (uint64_t)(sym.st_value + addend);
+
+	/*
+	 * If the relocation targets one of the tracked text sections, fold
+	 * in that section's synthetic bias so the patched DWARF address
+	 * lands in a unique numeric range.  String-ref relocs
+	 * (DW_FORM_line_strp into .debug_line_str) target a different
+	 * section, so the symbol-based check correctly excludes them from
+	 * biasing — for both abs64 (64-bit ELF) and abs32 (32-bit ELF,
+	 * where DW_LNE_set_address is also 4 bytes wide).
+	 */
+	if (module_mode) {
+		struct covered_section *cs;
+
+		cs = section_by_index(all_sections, ALL_SECTIONS,
+				      sym.st_shndx);
+		if (!cs)
+			cs = section_by_index(extra_sections,
+					      num_extra_sections,
+					      sym.st_shndx);
+		if (cs)
+			value += cs->bias;
+	}
+
+	if (!is_abs64)
+		value &= 0xffffffffULL;
+
+	elf_write_uint((unsigned char *)dl_data->d_buf + r_offset,
+		       value, width, target_le);
+}
+
+/* Walk one .rela.debug_line / .rel.debug_line table, if present. */
+static void apply_dl_reloc_table(Elf_Scn *scn, bool is_rela,
+				 Elf_Data *dl_data, Elf_Data *sym_data,
+				 bool target_le,
+				 unsigned int abs32_type,
+				 unsigned int abs64_type)
+{
+	GElf_Shdr shdr;
+	Elf_Data *data;
+	size_t nrels, i;
+
+	if (!scn)
+		return;
+
+	data = elf_getdata(scn, NULL);
+	if (!data || !gelf_getshdr(scn, &shdr) || !shdr.sh_entsize)
+		return;
+
+	nrels = shdr.sh_size / shdr.sh_entsize;
+
+	for (i = 0; i < nrels; i++) {
+		if (is_rela) {
+			GElf_Rela rela;
+
+			if (!gelf_getrela(data, i, &rela))
+				continue;
+			apply_one_dl_reloc(dl_data, sym_data, target_le,
+					   abs32_type, abs64_type,
+					   rela.r_info, rela.r_offset,
+					   rela.r_addend, true);
+		} else {
+			GElf_Rel rel;
+
+			if (!gelf_getrel(data, i, &rel))
+				continue;
+			apply_one_dl_reloc(dl_data, sym_data, target_le,
+					   abs32_type, abs64_type,
+					   rel.r_info, rel.r_offset,
+					   0, false);
+		}
+	}
+}
+
+static void apply_debug_line_relocations(Elf *elf)
+{
+	Elf_Scn *scn = NULL;
+	Elf_Scn *debug_line_scn = NULL;
+	Elf_Scn *rela_debug_line_scn = NULL;
+	Elf_Scn *rel_debug_line_scn = NULL;
+	Elf_Scn *symtab_scn = NULL;
+	GElf_Shdr shdr;
+	GElf_Ehdr ehdr;
+	unsigned int abs32_type, abs64_type;
+	bool target_le;
+	size_t shstrndx;
+	Elf_Data *dl_data, *sym_data;
+	GElf_Shdr sym_shdr;
+
+	if (gelf_getehdr(elf, &ehdr) == NULL)
+		return;
+
+	abs32_type = r_type_abs32(ehdr.e_machine);
+	abs64_type = r_type_abs64(ehdr.e_machine);
+	if (!abs32_type && !abs64_type)
+		return;
+	target_le = (ehdr.e_ident[EI_DATA] == ELFDATA2LSB);
+
+	if (elf_getshdrstrndx(elf, &shstrndx) != 0)
+		return;
+
+	/* Find the relevant sections */
+	while ((scn = elf_nextscn(elf, scn)) != NULL) {
+		const char *name;
+
+		if (!gelf_getshdr(scn, &shdr))
+			continue;
+		name = elf_strptr(elf, shstrndx, shdr.sh_name);
+		if (!name)
+			continue;
+
+		if (!strcmp(name, ".debug_line"))
+			debug_line_scn = scn;
+		else if (!strcmp(name, ".rela.debug_line"))
+			rela_debug_line_scn = scn;
+		else if (!strcmp(name, ".rel.debug_line"))
+			rel_debug_line_scn = scn;
+		else if (shdr.sh_type == SHT_SYMTAB)
+			symtab_scn = scn;
+	}
+
+	if (!debug_line_scn || !symtab_scn)
+		return;
+	if (!rela_debug_line_scn && !rel_debug_line_scn)
+		return;
+
+	dl_data = elf_getdata(debug_line_scn, NULL);
+	sym_data = elf_getdata(symtab_scn, NULL);
+	if (!dl_data || !sym_data)
+		return;
+
+	if (!gelf_getshdr(symtab_scn, &sym_shdr))
+		return;
+
+	/*
+	 * RELA (64-bit ELF and most 32-bit targets) carries explicit
+	 * addends; REL (i386, arm32, ...) stores the addend in the
+	 * relocated field itself.
+	 */
+	apply_dl_reloc_table(rela_debug_line_scn, true, dl_data, sym_data,
+			     target_le, abs32_type, abs64_type);
+	apply_dl_reloc_table(rel_debug_line_scn, false, dl_data, sym_data,
+			     target_le, abs32_type, abs64_type);
+}
+
+/*
+ * Decide which covered_section a (biased) DWARF address belongs to.
+ * apply_debug_line_relocations() has already added the section's bias to
+ * each line-program PC, so [bias, bias+size) ranges are pairwise disjoint
+ * and a simple linear scan picks the right bucket.  Returns the index
+ * within @sections, or @num_sections if @addr falls outside every
+ * present range (caller skips the entry).
+ */
+static unsigned int classify_address(struct covered_section *sections,
+				     unsigned int num_sections,
+				     unsigned long long addr,
+				     unsigned long long *out_offset)
+{
+	for (unsigned int i = 0; i < num_sections; i++) {
+		if (!sections[i].present)
+			continue;
+		if (addr < sections[i].bias)
+			continue;
+		if (addr >= sections[i].bias + sections[i].size)
+			continue;
+		*out_offset = addr - sections[i].bias;
+		return i;
+	}
+	return num_sections;
+}
+
+static void process_dwarf(Dwarf *dwarf, unsigned long long text_addr,
+			  struct covered_section *sections,
+			  unsigned int num_sections)
 {
 	Dwarf_Off off = 0, next_off;
 	size_t hdr_size;
@@ -312,7 +835,8 @@ static void process_dwarf(Dwarf *dwarf, unsigned long long text_addr)
 			Dwarf_Addr addr;
 			const char *src;
 			const char *rel;
-			unsigned int file_id, loffset;
+			unsigned int file_id, loffset, sec_id;
+			unsigned long long sec_off;
 			int lineno;
 
 			if (!line)
@@ -329,56 +853,89 @@ static void process_dwarf(Dwarf *dwarf, unsigned long long text_addr)
 			if (!src)
 				continue;
 
-			if (addr < text_addr)
-				continue;
-			/*
-			 * Skip addresses past _etext.  Sections after .rodata
-			 * shift when the real lineinfo replaces the empty stub
-			 * during the multi-pass vmlinux link, so any address
-			 * we'd capture there would be stale by the time the
-			 * final kernel runs.
-			 */
-			if (text_end_addr && addr >= text_end_addr)
-				continue;
-
-			{
-				unsigned long long raw_offset = addr - text_addr;
+			if (module_mode) {
+				/*
+				 * In ET_REL .ko files .text/.init.text/.exit.text
+				 * all share sh_addr == 0; classify_address picks
+				 * the right bucket from the explicit ranges we
+				 * captured.
+				 */
+				sec_id = classify_address(sections, num_sections,
+							  addr, &sec_off);
+				if (sec_id == num_sections) {
+					skipped_uncovered++;
+					continue;
+				}
+				if (sec_off > UINT_MAX) {
+					skipped_overflow++;
+					continue;
+				}
+				loffset = (unsigned int)sec_off;
+				sections[sec_id].n_entries++;
+			} else {
+				unsigned long long raw_offset;
 
+				if (addr < text_addr)
+					continue;
+				/*
+				 * Skip addresses past _etext.  Sections after
+				 * .rodata shift when the real lineinfo replaces
+				 * the empty stub during the multi-pass vmlinux
+				 * link, so any address we'd capture there would
+				 * be stale by the time the final kernel runs.
+				 */
+				if (text_end_addr && addr >= text_end_addr)
+					continue;
+				raw_offset = addr - text_addr;
 				if (raw_offset > UINT_MAX) {
 					skipped_overflow++;
 					continue;
 				}
 				loffset = (unsigned int)raw_offset;
+				sec_id = 0;
 			}
 
 			rel = make_relative(src, comp_dir);
 			file_id = find_or_add_file(rel);
 
-			add_entry(loffset, file_id, (unsigned int)lineno);
+			add_entry(loffset, sec_id, file_id, (unsigned int)lineno);
 		}
 next:
 		off = next_off;
 	}
 }
 
-static void deduplicate(void)
+static void deduplicate(struct covered_section *sections,
+			unsigned int num_sections)
 {
 	unsigned int i, j;
 
 	if (num_entries < 2)
 		return;
 
-	/* Sort by offset, then file_id, then line for stability */
+	/*
+	 * Sort by section_id, then offset, then file_id, line.  This groups
+	 * each section's entries contiguously so the per-section emit can
+	 * iterate a simple range, and ensures the binary search invariant
+	 * (offsets ascending) holds within each section.
+	 */
 	qsort(entries, num_entries, sizeof(*entries), compare_entries);
 
 	/*
-	 * Remove duplicate entries:
-	 * - Same offset: keep first (deterministic from stable sort keys)
-	 * - Same file:line as previous kept entry: redundant for binary
-	 *   search -- any address between them resolves to the earlier one
+	 * Remove duplicates.  Reset on a section_id boundary: the same offset
+	 * can legitimately appear in two different sections (they all start
+	 * at sh_addr 0 in ET_REL), and the "same as previous kept entry"
+	 * collapse is only meaningful inside one section's binary-search
+	 * domain.
 	 */
 	j = 0;
 	for (i = 1; i < num_entries; i++) {
+		if (entries[i].section_id != entries[j].section_id) {
+			j++;
+			if (j != i)
+				entries[j] = entries[i];
+			continue;
+		}
 		if (entries[i].offset == entries[j].offset)
 			continue;
 		if (entries[i].file_id == entries[j].file_id &&
@@ -389,6 +946,14 @@ static void deduplicate(void)
 			entries[j] = entries[i];
 	}
 	num_entries = j + 1;
+
+	/* Recompute per-section n_entries from the deduped array. */
+	if (sections) {
+		for (unsigned int k = 0; k < num_sections; k++)
+			sections[k].n_entries = 0;
+		for (i = 0; i < num_entries; i++)
+			sections[entries[i].section_id].n_entries++;
+	}
 }
 
 static void compute_file_offsets(void)
@@ -486,6 +1051,199 @@ static void output_assembly(void)
 	printf("\n");
 }
 
+/*
+ * Emit one per-section table in the simple flat-array layout:
+ *
+ *   mod_lineinfo_header
+ *   addrs[count]    (u32, sorted)
+ *   file_ids[count] (u16) + 2-byte pad if count is odd
+ *   lines[count]    (u32)
+ *   file_offsets[]  (u32)
+ *   filenames[]
+ *
+ * @suffix uniquifies labels so multiple tables can coexist in one blob.
+ * Caller has sorted entries[] so this section's entries occupy [first,
+ * first + count).
+ */
+static void emit_section_table(unsigned int first, unsigned int count,
+			       const char *suffix)
+{
+	printf(".Lhdr%s:\n", suffix);
+	printf("\t.balign 4\n");
+	printf("\t.long %u\t\t/* num_entries */\n", count);
+	printf("\t.long %u\t\t/* num_files */\n", num_files);
+	printf("\t.long .Lfilenames_end%s - .Lfilenames%s\n\n", suffix, suffix);
+
+	/* addrs[] */
+	for (unsigned int i = 0; i < count; i++)
+		printf("\t.long 0x%x\n", entries[first + i].offset);
+
+	/* file_ids[] */
+	for (unsigned int i = 0; i < count; i++)
+		printf("\t.short %u\n", entries[first + i].file_id);
+	if (count & 1)
+		printf("\t.short 0\t\t/* pad to align lines[] */\n");
+
+	/* lines[] */
+	for (unsigned int i = 0; i < count; i++)
+		printf("\t.long %u\n", entries[first + i].line);
+
+	/* file_offsets[] */
+	printf("\t.balign 4\n");
+	for (unsigned int i = 0; i < num_files; i++)
+		printf("\t.long %u\n", files[i].str_offset);
+
+	/* filenames[] */
+	printf(".Lfilenames%s:\n", suffix);
+	for (unsigned int i = 0; i < num_files; i++)
+		print_escaped_asciz(files[i].name);
+	printf(".Lfilenames_end%s:\n", suffix);
+}
+
+/*
+ * Emit one mod_lineinfo_section descriptor.  The "anchor" field is a
+ * relocation against the named ELF section symbol; the module loader
+ * resolves it on load to the runtime base of that section.
+ *
+ * On 64-bit ELF: 8-byte slot via .quad <name> (R_*_64 reloc).
+ * On 32-bit ELF: 4-byte reloc via .long <name>, plus 4 bytes of zero
+ * padding.  The two halves are ordered to match target endianness so a
+ * naive u64 read on the kernel side recovers the relocated value.
+ */
+static void emit_section_descriptor(const char *section_name,
+				    unsigned long long size,
+				    const char *table_label,
+				    const char *root_label)
+{
+	if (target_64bit) {
+		printf("\t.quad %s\t/* sections[].anchor (RELOC) */\n",
+		       section_name);
+	} else if (target_le) {
+		printf("\t.long %s\t/* sections[].anchor low (RELOC) */\n",
+		       section_name);
+		printf("\t.long 0\t\t/* sections[].anchor high pad */\n");
+	} else {
+		printf("\t.long 0\t\t/* sections[].anchor high pad */\n");
+		printf("\t.long %s\t/* sections[].anchor low (RELOC) */\n",
+		       section_name);
+	}
+	printf("\t.long %llu\t/* sections[].size */\n", size);
+	printf("\t.long %s - %s\t/* sections[].table_offset */\n",
+	       table_label, root_label);
+}
+
+/*
+ * Emit one .mod_lineinfo / .init.mod_lineinfo blob.  Walks all_sections[]
+ * picking only entries that (a) belong to the requested blob and (b)
+ * actually produced at least one DWARF line entry — sections present in
+ * the .ko but without DWARF (e.g. compiler-generated stub thunks) are
+ * silently skipped.  The caller-supplied entries[] is already sorted by
+ * section_id, so each section's entries are contiguous; we walk the
+ * master array in order to compute per-section starting indices.
+ */
+static void emit_blob(const char *output_section,
+		      const char *blob_tag,
+		      enum mod_lineinfo_blob blob)
+{
+	unsigned int active = 0;
+	unsigned int section_starts[ALL_SECTIONS];
+	unsigned int cursor = 0;
+
+	for (unsigned int i = 0; i < ALL_SECTIONS; i++) {
+		section_starts[i] = cursor;
+		cursor += all_sections[i].n_entries;
+		if (all_sections[i].blob == blob && all_sections[i].n_entries)
+			active++;
+	}
+
+	if (!active)
+		return;
+
+	printf("\t.section %s, \"a\"\n\n", output_section);
+
+	printf("\t.balign 8\n");
+	printf(".Lroot_%s:\n", blob_tag);
+	printf("\t.long %u\t\t/* num_sections */\n", active);
+	/* Pad to align the u64 anchor in sections[0] to 8 bytes. */
+	printf("\t.balign 8\n");
+
+	{
+		unsigned int slot = 0;
+		for (unsigned int i = 0; i < ALL_SECTIONS; i++) {
+			char table_label[64];
+			char root_label[64];
+
+			if (all_sections[i].blob != blob)
+				continue;
+			if (!all_sections[i].n_entries)
+				continue;
+			snprintf(table_label, sizeof(table_label),
+				 ".Lhdr_%s_%u", blob_tag, slot);
+			snprintf(root_label, sizeof(root_label),
+				 ".Lroot_%s", blob_tag);
+			emit_section_descriptor(all_sections[i].name,
+						all_sections[i].size,
+						table_label, root_label);
+			slot++;
+		}
+	}
+	printf("\n");
+
+	{
+		unsigned int slot = 0;
+
+		for (unsigned int i = 0; i < ALL_SECTIONS; i++) {
+			char suffix[64];
+
+			if (all_sections[i].blob != blob)
+				continue;
+			if (!all_sections[i].n_entries)
+				continue;
+			snprintf(suffix, sizeof(suffix), "_%s_%u",
+				 blob_tag, slot);
+			emit_section_table(section_starts[i],
+					   all_sections[i].n_entries,
+					   suffix);
+			slot++;
+		}
+	}
+	printf("\n");
+}
+
+/*
+ * Declare each text-like section we plan to reference as an empty
+ * SHF_EXECINSTR section in this object.  Without these stanzas the
+ * assembler treats `.quad .exit.text` as an undefined external symbol;
+ * after ld -r the resulting GLOBAL UND `.exit.text` doesn't bind to the
+ * .ko's LOCAL SECTION symbol of the same name, leaving depmod with an
+ * unresolved-symbol warning and the loader unable to relocate the anchor.
+ *
+ * Declaring the section here gives lineinfo.o its own local SECTION
+ * symbol; ld -r merges sections by name so the local symbol simply
+ * relocates to offset 0 of the merged section (lineinfo.o is linked
+ * FIRST so its zero-byte contribution stays at the start).
+ */
+static void declare_empty_text_sections(void)
+{
+	for (unsigned int i = 0; i < ALL_SECTIONS; i++) {
+		if (!all_sections[i].present)
+			continue;
+		printf("\t.section %s, \"ax\"\n", all_sections[i].name);
+	}
+	printf("\n");
+}
+
+static void output_module_assembly(void)
+{
+	printf("/* SPDX-License-Identifier: GPL-2.0 */\n");
+	printf("/*\n");
+	printf(" * Automatically generated by scripts/gen_lineinfo --module\n");
+	printf(" * Do not edit.\n");
+	printf(" */\n\n");
+
+	declare_empty_text_sections();
+}
+
 int main(int argc, char *argv[])
 {
 	int fd;
@@ -493,12 +1251,23 @@ int main(int argc, char *argv[])
 	Dwarf *dwarf;
 	unsigned long long text_addr;
 
+	if (argc >= 2 && !strcmp(argv[1], "--module")) {
+		module_mode = 1;
+		argv++;
+		argc--;
+	}
+
 	if (argc != 2) {
-		fprintf(stderr, "Usage: %s <vmlinux>\n", argv[0]);
+		fprintf(stderr, "Usage: %s [--module] <ELF file>\n", argv[0]);
 		return 1;
 	}
 
-	fd = open(argv[1], O_RDONLY);
+	/*
+	 * For module mode, open O_RDWR so we can apply debug section
+	 * relocations to the in-memory ELF data.  The modifications
+	 * are NOT written back to disk (no elf_update() call).
+	 */
+	fd = open(argv[1], module_mode ? O_RDWR : O_RDONLY);
 	if (fd < 0) {
 		fprintf(stderr, "Cannot open %s: %s\n", argv[1],
 			strerror(errno));
@@ -506,7 +1275,7 @@ int main(int argc, char *argv[])
 	}
 
 	elf_version(EV_CURRENT);
-	elf = elf_begin(fd, ELF_C_READ, NULL);
+	elf = elf_begin(fd, module_mode ? ELF_C_RDWR : ELF_C_READ, NULL);
 	if (!elf) {
 		fprintf(stderr, "elf_begin failed: %s\n",
 			elf_errmsg(elf_errno()));
@@ -514,8 +1283,34 @@ int main(int argc, char *argv[])
 		return 1;
 	}
 
-	text_addr = find_text_addr(elf);
-	text_end_addr = find_text_end_addr(elf);
+	{
+		GElf_Ehdr ehdr;
+
+		if (gelf_getehdr(elf, &ehdr) == NULL) {
+			fprintf(stderr, "gelf_getehdr failed\n");
+			elf_end(elf);
+			close(fd);
+			return 1;
+		}
+		target_64bit = (ehdr.e_ident[EI_CLASS] == ELFCLASS64);
+		target_le = (ehdr.e_ident[EI_DATA] == ELFDATA2LSB);
+	}
+
+	if (module_mode) {
+		/*
+		 * .ko files are ET_REL after ld -r.  Resolve covered text
+		 * sections FIRST so apply_debug_line_relocations() can use
+		 * the assigned biases when patching line-program addresses;
+		 * libdw does NOT apply relocations for ET_REL files, so we
+		 * also handle DW_FORM_line_strp refs into .debug_line_str.
+		 */
+		resolve_covered_sections(elf, all_sections, ALL_SECTIONS);
+		apply_debug_line_relocations(elf);
+		text_addr = 0;	/* unused in module mode */
+	} else {
+		text_addr = find_text_addr(elf);
+		text_end_addr = find_text_end_addr(elf);
+	}
 
 	dwarf = dwarf_begin_elf(elf, DWARF_C_READ, NULL);
 	if (!dwarf) {
@@ -528,20 +1323,60 @@ int main(int argc, char *argv[])
 		return 1;
 	}
 
-	process_dwarf(dwarf, text_addr);
+	if (module_mode) {
+		unsigned int persistent_total, init_total;
+
+		output_module_assembly();	/* file header only */
 
-	if (skipped_overflow)
+		/*
+		 * Single DWARF pass classifies every line entry into its
+		 * covering section (or skips it).  Each entry is tagged with
+		 * the master-array section_id so per-blob emit can filter.
+		 */
+		process_dwarf(dwarf, 0, all_sections, ALL_SECTIONS);
+		deduplicate(all_sections, ALL_SECTIONS);
+		compute_file_offsets();
+
+		emit_blob(".mod_lineinfo", "p", BLOB_PERSISTENT);
+		emit_blob(".init.mod_lineinfo", "i", BLOB_INIT);
+
+		persistent_total = 0;
+		init_total = 0;
+		for (unsigned int i = 0; i < ALL_SECTIONS; i++) {
+			if (all_sections[i].blob == BLOB_PERSISTENT)
+				persistent_total += all_sections[i].n_entries;
+			else if (all_sections[i].blob == BLOB_INIT)
+				init_total += all_sections[i].n_entries;
+		}
 		fprintf(stderr,
-			"lineinfo: warning: %u entries skipped (offset > 4 GiB from _text)\n",
-			skipped_overflow);
+			"lineinfo: persistent %u entries, init %u entries, %u files\n",
+			persistent_total, init_total, num_files);
+
+		if (skipped_uncovered)
+			fprintf(stderr,
+				"lineinfo: %llu entries dropped (outside covered text sections)\n",
+				skipped_uncovered);
 
-	deduplicate();
-	compute_file_offsets();
+		if (skipped_overflow)
+			fprintf(stderr,
+				"lineinfo: warning: %u entries skipped (offset > 4 GiB)\n",
+				skipped_overflow);
+	} else {
+		process_dwarf(dwarf, text_addr, NULL, 0);
 
-	fprintf(stderr, "lineinfo: %u entries, %u files\n",
-		num_entries, num_files);
+		if (skipped_overflow)
+			fprintf(stderr,
+				"lineinfo: warning: %u entries skipped (offset > 4 GiB from _text)\n",
+				skipped_overflow);
 
-	output_assembly();
+		deduplicate(NULL, 0);
+		compute_file_offsets();
+
+		fprintf(stderr, "lineinfo: %u entries, %u files\n",
+			num_entries, num_files);
+
+		output_assembly();
+	}
 
 	dwarf_end(dwarf);
 	elf_end(elf);
@@ -552,6 +1387,5 @@ int main(int argc, char *argv[])
 	for (unsigned int i = 0; i < num_files; i++)
 		free(files[i].name);
 	free(files);
-
 	return 0;
 }
-- 
2.53.0


^ permalink raw reply related

* [PATCH v7 1/4] kallsyms: embed source file:line info in kernel stack traces
From: Sasha Levin @ 2026-07-09 16:38 UTC (permalink / raw)
  To: Andrew Morton, Masahiro Yamada, Luis Chamberlain, Linus Torvalds,
	Richard Weinberger, Juergen Gross, Geert Uytterhoeven,
	James Bottomley
  Cc: Sasha Levin, Jonathan Corbet, Nathan Chancellor, Nicolas Schier,
	Petr Pavlu, Daniel Gomez, Greg KH, Petr Mladek, Steven Rostedt,
	Kees Cook, Peter Zijlstra, Thorsten Leemhuis, Vlastimil Babka,
	Helge Deller, Randy Dunlap, Laurent Pinchart, Vivian Wang,
	Zhen Lei, Sami Tolvanen, linux-kernel, linux-kbuild,
	linux-modules, linux-doc
In-Reply-To: <20260709163833.3851179-1-sashal@kernel.org>

Add CONFIG_KALLSYMS_LINEINFO, which embeds a compact address-to-line
lookup table in the kernel image so stack traces directly print source
file and line number information:

  root@localhost:~# echo c > /proc/sysrq-trigger
  [   11.201987] sysrq: Trigger a crash
  [   11.202831] Kernel panic - not syncing: sysrq triggered crash
  [   11.206218] Call Trace:
  [   11.206501]  <TASK>
  [   11.206749]  dump_stack_lvl+0x5d/0x80 (lib/dump_stack.c:94)
  [   11.207403]  vpanic+0x36e/0x620 (kernel/panic.c:650)
  [   11.208565]  ? __lock_acquire+0x465/0x2240 (kernel/locking/lockdep.c:4674)
  [   11.209324]  panic+0xc9/0xd0 (kernel/panic.c:787)
  [   11.211873]  ? find_held_lock+0x2b/0x80 (kernel/locking/lockdep.c:5350)
  [   11.212597]  ? lock_release+0xd3/0x300 (kernel/locking/lockdep.c:5535)
  [   11.213312]  sysrq_handle_crash+0x1a/0x20 (drivers/tty/sysrq.c:154)
  [   11.214005]  __handle_sysrq.cold+0x66/0x256 (drivers/tty/sysrq.c:611)
  [   11.214712]  write_sysrq_trigger+0x65/0x80 (drivers/tty/sysrq.c:1221)
  [   11.215424]  proc_reg_write+0x1bd/0x3c0 (fs/proc/inode.c:330)
  [   11.216061]  vfs_write+0x1c6/0xff0 (fs/read_write.c:686)
  [   11.218848]  ksys_write+0xfa/0x200 (fs/read_write.c:740)
  [   11.222394]  do_syscall_64+0xf3/0x690 (arch/x86/entry/syscall_64.c:63)
  [   11.223942]  entry_SYSCALL_64_after_hwframe+0x77/0x7f (arch/x86/entry/entry_64.S:121)

At build time, a new host tool (scripts/gen_lineinfo) reads DWARF
.debug_line from vmlinux using libdw (elfutils), extracts all
address-to-file:line mappings, and generates an assembly file with
sorted parallel arrays (offsets from _text, file IDs, and line
numbers). These are linked into vmlinux as .rodata.

At runtime, kallsyms_lookup_lineinfo() does a binary search on the
table and __sprint_symbol() appends "(file:line)" to each stack frame.
The lookup uses offsets from _text so it works with KASLR, requires no
locks or allocations, and is safe in any context including panic.

The feature requires CONFIG_DEBUG_INFO (for DWARF data) and
elfutils (libdw-dev) on the build host.

Memory footprint measured with a 1852-option x86_64 config:

  Table: 4,597,583 entries from 4,841 source files
    lineinfo_addrs[]     4,597,583 x u32  = 17.5 MiB
    lineinfo_file_ids[]  4,597,583 x u16  =  8.8 MiB
    lineinfo_lines[]     4,597,583 x u32  = 17.5 MiB
    file_offsets + filenames              ~  0.1 MiB
    Total .rodata increase:              ~ 44.0 MiB

  vmlinux (stripped):  529 MiB -> 573 MiB  (+44 MiB / +8.3%)

The .config used for testing is a simple KVM guest configuration for
local development and testing.

Assisted-by: Claude:claude-opus-4-6
Signed-off-by: Sasha Levin <sashal@kernel.org>
---
 Documentation/admin-guide/index.rst           |   1 +
 .../admin-guide/kallsyms-lineinfo.rst         |  72 +++
 MAINTAINERS                                   |   6 +
 include/linux/kallsyms.h                      |  18 +-
 init/Kconfig                                  |  20 +
 kernel/kallsyms.c                             |  96 ++-
 kernel/kallsyms_internal.h                    |   9 +
 scripts/.gitignore                            |   1 +
 scripts/Makefile                              |   3 +
 scripts/empty_lineinfo.S                      |  30 +
 scripts/gen_lineinfo.c                        | 557 ++++++++++++++++++
 scripts/kallsyms.c                            |  16 +
 scripts/link-vmlinux.sh                       |  43 +-
 13 files changed, 861 insertions(+), 11 deletions(-)
 create mode 100644 Documentation/admin-guide/kallsyms-lineinfo.rst
 create mode 100644 scripts/empty_lineinfo.S
 create mode 100644 scripts/gen_lineinfo.c

diff --git a/Documentation/admin-guide/index.rst b/Documentation/admin-guide/index.rst
index cd28dfe91b0607..37456e08fe43cd 100644
--- a/Documentation/admin-guide/index.rst
+++ b/Documentation/admin-guide/index.rst
@@ -73,6 +73,7 @@ problems and bugs in particular.
    ramoops
    dynamic-debug-howto
    init
+   kallsyms-lineinfo
    kdump/index
    perf/index
    pstore-blk
diff --git a/Documentation/admin-guide/kallsyms-lineinfo.rst b/Documentation/admin-guide/kallsyms-lineinfo.rst
new file mode 100644
index 00000000000000..c8ec124394354e
--- /dev/null
+++ b/Documentation/admin-guide/kallsyms-lineinfo.rst
@@ -0,0 +1,72 @@
+.. SPDX-License-Identifier: GPL-2.0
+
+====================================
+Kallsyms Source Line Info (LINEINFO)
+====================================
+
+Overview
+========
+
+``CONFIG_KALLSYMS_LINEINFO`` embeds DWARF-derived source file and line number
+mappings into the kernel image so that stack traces include
+``(file.c:123)`` annotations next to each symbol.  This makes it significantly
+easier to pinpoint the exact source location during debugging, without needing
+to manually cross-reference addresses with ``addr2line``.
+
+Enabling the Feature
+====================
+
+Enable the following kernel configuration options::
+
+    CONFIG_KALLSYMS=y
+    CONFIG_DEBUG_INFO=y
+    CONFIG_KALLSYMS_LINEINFO=y
+
+Build dependency: the host tool ``scripts/gen_lineinfo`` requires ``libdw``
+from elfutils.  Install the development package:
+
+- Debian/Ubuntu: ``apt install libdw-dev``
+- Fedora/RHEL: ``dnf install elfutils-devel``
+- Arch Linux: ``pacman -S elfutils``
+
+Example Output
+==============
+
+Without ``CONFIG_KALLSYMS_LINEINFO``::
+
+    Call Trace:
+     <TASK>
+     dump_stack_lvl+0x5d/0x80
+     do_syscall_64+0x82/0x190
+     entry_SYSCALL_64_after_hwframe+0x76/0x7e
+
+With ``CONFIG_KALLSYMS_LINEINFO``::
+
+    Call Trace:
+     <TASK>
+     dump_stack_lvl+0x5d/0x80 (lib/dump_stack.c:123)
+     do_syscall_64+0x82/0x190 (arch/x86/entry/common.c:52)
+     entry_SYSCALL_64_after_hwframe+0x76/0x7e
+
+Note that assembly routines (such as ``entry_SYSCALL_64_after_hwframe``) are
+not annotated because they lack DWARF debug information.
+
+Memory Overhead
+===============
+
+The lineinfo tables are stored in ``.rodata`` and typically add approximately
+44 MiB to the kernel image for a standard configuration (~4.6 million DWARF
+line entries, ~10 bytes per entry after deduplication).
+
+Known Limitations
+=================
+
+- **vmlinux only**: Only symbols in the core kernel image are annotated.
+  Module symbols are not covered.
+- **4 GiB offset limit**: Address offsets from ``_text`` are stored as 32-bit
+  values.  Entries beyond 4 GiB from ``_text`` are skipped at build time with
+  a warning.
+- **65535 file limit**: Source file IDs are stored as 16-bit values.  Builds
+  with more than 65535 unique source files will fail with an error.
+- **No assembly annotations**: Functions implemented in assembly that lack
+  DWARF ``.debug_line`` data are not annotated.
diff --git a/MAINTAINERS b/MAINTAINERS
index 4a8b0fd665ce24..b98d57b1ee1d5b 100644
--- a/MAINTAINERS
+++ b/MAINTAINERS
@@ -13931,6 +13931,12 @@ S:	Maintained
 F:	Documentation/hwmon/k8temp.rst
 F:	drivers/hwmon/k8temp.c
 
+KALLSYMS LINEINFO
+M:	Sasha Levin <sashal@kernel.org>
+S:	Maintained
+F:	Documentation/admin-guide/kallsyms-lineinfo.rst
+F:	scripts/gen_lineinfo.c
+
 KASAN
 M:	Andrey Ryabinin <ryabinin.a.a@gmail.com>
 R:	Alexander Potapenko <glider@google.com>
diff --git a/include/linux/kallsyms.h b/include/linux/kallsyms.h
index d5dd54c53ace61..53cc25a6e85d94 100644
--- a/include/linux/kallsyms.h
+++ b/include/linux/kallsyms.h
@@ -16,10 +16,15 @@
 #include <asm/sections.h>
 
 #define KSYM_NAME_LEN 512
+
+/* Extra space for " (path/to/file.c:12345)" suffix when lineinfo is enabled */
+#define KSYM_LINEINFO_LEN (IS_ENABLED(CONFIG_KALLSYMS_LINEINFO) ? 128 : 0)
+
 #define KSYM_SYMBOL_LEN (sizeof("%s+%#lx/%#lx [%s %s]") + \
 			(KSYM_NAME_LEN - 1) + \
 			2*(BITS_PER_LONG*3/10) + (MODULE_NAME_LEN - 1) + \
-			(BUILD_ID_SIZE_MAX * 2) + 1)
+			(BUILD_ID_SIZE_MAX * 2) + 1 + \
+			KSYM_LINEINFO_LEN)
 
 struct cred;
 struct module;
@@ -96,6 +101,9 @@ extern int sprint_backtrace_build_id(char *buffer, unsigned long address);
 
 int lookup_symbol_name(unsigned long addr, char *symname);
 
+bool kallsyms_lookup_lineinfo(unsigned long addr, unsigned long sym_start,
+			      const char **file, unsigned int *line);
+
 #else /* !CONFIG_KALLSYMS */
 
 static inline unsigned long kallsyms_lookup_name(const char *name)
@@ -164,6 +172,14 @@ static inline int kallsyms_on_each_match_symbol(int (*fn)(void *, unsigned long)
 {
 	return -EOPNOTSUPP;
 }
+
+static inline bool kallsyms_lookup_lineinfo(unsigned long addr,
+					    unsigned long sym_start,
+					    const char **file,
+					    unsigned int *line)
+{
+	return false;
+}
 #endif /*CONFIG_KALLSYMS*/
 
 static inline void print_ip_sym(const char *loglvl, unsigned long ip)
diff --git a/init/Kconfig b/init/Kconfig
index 5230d4879b1c84..f004cf9a69d40a 100644
--- a/init/Kconfig
+++ b/init/Kconfig
@@ -2087,6 +2087,26 @@ config KALLSYMS_ALL
 
 	  Say N unless you really need all symbols, or kernel live patching.
 
+config KALLSYMS_LINEINFO
+	bool "Embed source file:line information in stack traces"
+	depends on KALLSYMS && DEBUG_INFO
+	help
+	  Embeds an address-to-source-line mapping table in the kernel
+	  image so that stack traces directly include file:line information,
+	  similar to what scripts/decode_stacktrace.sh provides but without
+	  needing external tools or a vmlinux with debug info at runtime.
+
+	  When enabled, stack traces will look like:
+
+	    kmem_cache_alloc_noprof+0x60/0x630 (mm/slub.c:3456)
+	    anon_vma_clone+0x2ed/0xcf0 (mm/rmap.c:412)
+
+	  This requires elfutils (libdw-dev/elfutils-devel) on the build host.
+	  Adds approximately 44MB to a typical kernel image (10 bytes per
+	  DWARF line-table entry, ~4.6M entries for a typical config).
+
+	  If unsure, say N.
+
 # end of the "standard kernel features (expert users)" menu
 
 config ARCH_HAS_MEMBARRIER_CALLBACKS
diff --git a/kernel/kallsyms.c b/kernel/kallsyms.c
index aec2f06858afdb..d3fcf282c33cdf 100644
--- a/kernel/kallsyms.c
+++ b/kernel/kallsyms.c
@@ -467,13 +467,77 @@ static int append_buildid(char *buffer,   const char *modname,
 
 #endif /* CONFIG_STACKTRACE_BUILD_ID */
 
+bool kallsyms_lookup_lineinfo(unsigned long addr, unsigned long sym_start,
+			      const char **file, unsigned int *line)
+{
+	unsigned long long raw_offset;
+	unsigned int offset, min_offset = 0, low, high, mid, file_id;
+
+	if (!IS_ENABLED(CONFIG_KALLSYMS_LINEINFO) || !lineinfo_num_entries)
+		return false;
+
+	/* Compute offset from _text */
+	if (addr < (unsigned long)_text)
+		return false;
+
+	raw_offset = addr - (unsigned long)_text;
+	if (raw_offset > UINT_MAX)
+		return false;
+	offset = (unsigned int)raw_offset;
+
+	/*
+	 * The search below returns the closest entry at or below @offset, so
+	 * a symbol without line entries of its own (assembly without debug
+	 * info, or anything past the _etext cap like .init.text) would
+	 * inherit the last entry of whatever precedes it.  Bound the result
+	 * to entries at or above the resolved symbol's start.
+	 */
+	if (sym_start > (unsigned long)_text) {
+		unsigned long long raw_min = sym_start - (unsigned long)_text;
+
+		if (raw_min <= raw_offset)
+			min_offset = (unsigned int)raw_min;
+	}
+
+	/* Binary search for largest entry <= offset */
+	low = 0;
+	high = lineinfo_num_entries;
+	while (low < high) {
+		mid = low + (high - low) / 2;
+		if (lineinfo_addrs[mid] <= offset)
+			low = mid + 1;
+		else
+			high = mid;
+	}
+
+	if (low == 0)
+		return false;
+	low--;
+
+	if (lineinfo_addrs[low] < min_offset)
+		return false;
+
+	file_id = lineinfo_file_ids[low];
+	*line = lineinfo_lines[low];
+
+	if (file_id >= lineinfo_num_files)
+		return false;
+
+	if (lineinfo_file_offsets[file_id] >= lineinfo_filenames_size)
+		return false;
+
+	*file = &lineinfo_filenames[lineinfo_file_offsets[file_id]];
+	return true;
+}
+
 /* Look up a kernel symbol and return it in a text buffer. */
 static int __sprint_symbol(char *buffer, unsigned long address,
-			   int symbol_offset, int add_offset, int add_buildid)
+			   int symbol_offset, int add_offset, int add_buildid,
+			   int add_lineinfo)
 {
 	char *modname;
 	const unsigned char *buildid;
-	unsigned long offset, size;
+	unsigned long offset, size, sym_start;
 	int len;
 
 	/* Prevent module removal until modname and modbuildid are printed */
@@ -485,6 +549,7 @@ static int __sprint_symbol(char *buffer, unsigned long address,
 	if (!len)
 		return sprintf(buffer, "0x%lx", address - symbol_offset);
 
+	sym_start = address - offset;
 	offset -= symbol_offset;
 
 	if (add_offset)
@@ -497,6 +562,23 @@ static int __sprint_symbol(char *buffer, unsigned long address,
 		len += sprintf(buffer + len, "]");
 	}
 
+	/*
+	 * Append "(file:line)" only for stack-backtrace consumers.  Plain
+	 * sprint_symbol() backs %ps, and many existing format strings tack
+	 * literal "()" after %ps to indicate a function call ("foo()
+	 * replaced with bar()"); appending lineinfo there would produce a
+	 * confusing "foo (file:line)()".
+	 */
+	if (add_lineinfo && IS_ENABLED(CONFIG_KALLSYMS_LINEINFO) && !modname) {
+		const char *li_file;
+		unsigned int li_line;
+
+		if (kallsyms_lookup_lineinfo(address, sym_start,
+					     &li_file, &li_line))
+			len += snprintf(buffer + len, KSYM_SYMBOL_LEN - len,
+					" (%s:%u)", li_file, li_line);
+	}
+
 	return len;
 }
 
@@ -513,7 +595,7 @@ static int __sprint_symbol(char *buffer, unsigned long address,
  */
 int sprint_symbol(char *buffer, unsigned long address)
 {
-	return __sprint_symbol(buffer, address, 0, 1, 0);
+	return __sprint_symbol(buffer, address, 0, 1, 0, 0);
 }
 EXPORT_SYMBOL_GPL(sprint_symbol);
 
@@ -530,7 +612,7 @@ EXPORT_SYMBOL_GPL(sprint_symbol);
  */
 int sprint_symbol_build_id(char *buffer, unsigned long address)
 {
-	return __sprint_symbol(buffer, address, 0, 1, 1);
+	return __sprint_symbol(buffer, address, 0, 1, 1, 0);
 }
 EXPORT_SYMBOL_GPL(sprint_symbol_build_id);
 
@@ -547,7 +629,7 @@ EXPORT_SYMBOL_GPL(sprint_symbol_build_id);
  */
 int sprint_symbol_no_offset(char *buffer, unsigned long address)
 {
-	return __sprint_symbol(buffer, address, 0, 0, 0);
+	return __sprint_symbol(buffer, address, 0, 0, 0, 0);
 }
 EXPORT_SYMBOL_GPL(sprint_symbol_no_offset);
 
@@ -567,7 +649,7 @@ EXPORT_SYMBOL_GPL(sprint_symbol_no_offset);
  */
 int sprint_backtrace(char *buffer, unsigned long address)
 {
-	return __sprint_symbol(buffer, address, -1, 1, 0);
+	return __sprint_symbol(buffer, address, -1, 1, 0, 1);
 }
 
 /**
@@ -587,7 +669,7 @@ int sprint_backtrace(char *buffer, unsigned long address)
  */
 int sprint_backtrace_build_id(char *buffer, unsigned long address)
 {
-	return __sprint_symbol(buffer, address, -1, 1, 1);
+	return __sprint_symbol(buffer, address, -1, 1, 1, 1);
 }
 
 /* To avoid using get_symbol_offset for every symbol, we carry prefix along. */
diff --git a/kernel/kallsyms_internal.h b/kernel/kallsyms_internal.h
index 81a867dbe57d48..d7374ce444d811 100644
--- a/kernel/kallsyms_internal.h
+++ b/kernel/kallsyms_internal.h
@@ -15,4 +15,13 @@ extern const u16 kallsyms_token_index[];
 extern const unsigned int kallsyms_markers[];
 extern const u8 kallsyms_seqs_of_names[];
 
+extern const u32 lineinfo_num_entries;
+extern const u32 lineinfo_addrs[];
+extern const u16 lineinfo_file_ids[];
+extern const u32 lineinfo_lines[];
+extern const u32 lineinfo_num_files;
+extern const u32 lineinfo_file_offsets[];
+extern const u32 lineinfo_filenames_size;
+extern const char lineinfo_filenames[];
+
 #endif // LINUX_KALLSYMS_INTERNAL_H_
diff --git a/scripts/.gitignore b/scripts/.gitignore
index 4215c2208f7e41..e175714c18b616 100644
--- a/scripts/.gitignore
+++ b/scripts/.gitignore
@@ -1,5 +1,6 @@
 # SPDX-License-Identifier: GPL-2.0-only
 /asn1_compiler
+/gen_lineinfo
 /gen_packed_field_checks
 /generate_rust_target
 /insert-sys-cert
diff --git a/scripts/Makefile b/scripts/Makefile
index 3434a82a119f09..55244ce9557811 100644
--- a/scripts/Makefile
+++ b/scripts/Makefile
@@ -4,6 +4,7 @@
 # the kernel for the build process.
 
 hostprogs-always-$(CONFIG_KALLSYMS)			+= kallsyms
+hostprogs-always-$(CONFIG_KALLSYMS_LINEINFO)		+= gen_lineinfo
 hostprogs-always-$(BUILD_C_RECORDMCOUNT)		+= recordmcount
 hostprogs-always-$(CONFIG_BUILDTIME_TABLE_SORT)		+= sorttable
 hostprogs-always-$(CONFIG_ASN1)				+= asn1_compiler
@@ -37,6 +38,8 @@ HOSTCFLAGS_asn1_compiler.o = -I$(srctree)/include
 HOSTCFLAGS_sign-file.o = $(shell $(HOSTPKG_CONFIG) --cflags libcrypto 2> /dev/null)
 HOSTCFLAGS_sign-file.o += -I$(srctree)/tools/include/uapi/
 HOSTLDLIBS_sign-file = $(shell $(HOSTPKG_CONFIG) --libs libcrypto 2> /dev/null || echo -lcrypto)
+HOSTCFLAGS_gen_lineinfo.o = $(shell $(HOSTPKG_CONFIG) --cflags libdw 2> /dev/null)
+HOSTLDLIBS_gen_lineinfo = $(shell $(HOSTPKG_CONFIG) --libs libdw 2> /dev/null || echo -ldw -lelf -lz)
 
 ifdef CONFIG_UNWINDER_ORC
 ifeq ($(ARCH),x86_64)
diff --git a/scripts/empty_lineinfo.S b/scripts/empty_lineinfo.S
new file mode 100644
index 00000000000000..e058c411371237
--- /dev/null
+++ b/scripts/empty_lineinfo.S
@@ -0,0 +1,30 @@
+/* SPDX-License-Identifier: GPL-2.0 */
+/*
+ * Copyright (C) 2026 Sasha Levin <sashal@kernel.org>
+ *
+ * Empty lineinfo stub for the initial vmlinux link.
+ * The real lineinfo is generated from .tmp_vmlinux1 by gen_lineinfo.
+ */
+	.section .rodata, "a"
+	.globl lineinfo_num_entries
+	.balign 4
+lineinfo_num_entries:
+	.long 0
+	.globl lineinfo_num_files
+	.balign 4
+lineinfo_num_files:
+	.long 0
+	.globl lineinfo_addrs
+lineinfo_addrs:
+	.globl lineinfo_file_ids
+lineinfo_file_ids:
+	.globl lineinfo_lines
+lineinfo_lines:
+	.globl lineinfo_file_offsets
+lineinfo_file_offsets:
+	.globl lineinfo_filenames_size
+	.balign 4
+lineinfo_filenames_size:
+	.long 0
+	.globl lineinfo_filenames
+lineinfo_filenames:
diff --git a/scripts/gen_lineinfo.c b/scripts/gen_lineinfo.c
new file mode 100644
index 00000000000000..699e760178f094
--- /dev/null
+++ b/scripts/gen_lineinfo.c
@@ -0,0 +1,557 @@
+// SPDX-License-Identifier: GPL-2.0-only
+/*
+ * gen_lineinfo.c - Generate address-to-source-line lookup tables from DWARF
+ *
+ * Copyright (C) 2026 Sasha Levin <sashal@kernel.org>
+ *
+ * Reads DWARF .debug_line from a vmlinux ELF file and outputs an assembly
+ * file containing sorted lookup tables that the kernel uses to annotate
+ * stack traces with source file:line information.
+ *
+ * Requires libdw from elfutils.
+ */
+
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <unistd.h>
+#include <elfutils/libdw.h>
+#include <dwarf.h>
+#include <elf.h>
+#include <gelf.h>
+#include <limits.h>
+
+static unsigned int skipped_overflow;
+
+/*
+ * vmlinux mode: end of the invariant .text region.  Zero means "no cap"
+ * (graceful fallback when _etext is absent on some build).
+ */
+static unsigned long long text_end_addr;
+
+struct line_entry {
+	unsigned int offset;	/* offset from _text */
+	unsigned int file_id;
+	unsigned int line;
+};
+
+struct file_entry {
+	char *name;
+	unsigned int id;
+	unsigned int str_offset;
+};
+
+static struct line_entry *entries;
+static unsigned int num_entries;
+static unsigned int entries_capacity;
+
+static struct file_entry *files;
+static unsigned int num_files;
+static unsigned int files_capacity;
+
+#define FILE_HASH_BITS 13
+#define FILE_HASH_SIZE (1 << FILE_HASH_BITS)
+
+struct file_hash_entry {
+	const char *name;
+	unsigned int id;
+};
+
+static struct file_hash_entry file_hash[FILE_HASH_SIZE];
+
+static unsigned int hash_str(const char *s)
+{
+	unsigned int h = 5381;
+
+	for (; *s; s++)
+		h = h * 33 + (unsigned char)*s;
+	return h & (FILE_HASH_SIZE - 1);
+}
+
+static void add_entry(unsigned int offset, unsigned int file_id,
+		      unsigned int line)
+{
+	if (num_entries >= entries_capacity) {
+		entries_capacity = entries_capacity ? entries_capacity * 2 : 65536;
+		entries = realloc(entries, entries_capacity * sizeof(*entries));
+		if (!entries) {
+			fprintf(stderr, "out of memory\n");
+			exit(1);
+		}
+	}
+	entries[num_entries].offset = offset;
+	entries[num_entries].file_id = file_id;
+	entries[num_entries].line = line;
+	num_entries++;
+}
+
+static unsigned int find_or_add_file(const char *name)
+{
+	unsigned int h = hash_str(name);
+
+	/* Open-addressing lookup with linear probing */
+	while (file_hash[h].name) {
+		if (!strcmp(file_hash[h].name, name))
+			return file_hash[h].id;
+		h = (h + 1) & (FILE_HASH_SIZE - 1);
+	}
+
+	if (num_files >= 65535) {
+		fprintf(stderr,
+			"gen_lineinfo: too many source files (%u > 65535)\n",
+			num_files);
+		exit(1);
+	}
+
+	if (num_files >= files_capacity) {
+		files_capacity = files_capacity ? files_capacity * 2 : 4096;
+		files = realloc(files, files_capacity * sizeof(*files));
+		if (!files) {
+			fprintf(stderr, "out of memory\n");
+			exit(1);
+		}
+	}
+	files[num_files].name = strdup(name);
+	files[num_files].id = num_files;
+
+	/* Insert into hash table (points to files[] entry) */
+	file_hash[h].name = files[num_files].name;
+	file_hash[h].id = num_files;
+
+	num_files++;
+	return num_files - 1;
+}
+
+/*
+ * Well-known top-level directories in the kernel source tree.
+ * Used as a fallback to recover relative paths from absolute DWARF paths
+ * when comp_dir doesn't match (e.g. O= out-of-tree builds where comp_dir
+ * is the build directory but source paths point into the source tree).
+ */
+static const char * const kernel_dirs[] = {
+	"arch/", "block/", "certs/", "crypto/", "drivers/", "fs/",
+	"include/", "init/", "io_uring/", "ipc/", "kernel/", "lib/",
+	"mm/", "net/", "rust/", "samples/", "scripts/", "security/",
+	"sound/", "tools/", "usr/", "virt/",
+};
+
+/*
+ * Strip a filename to a kernel-relative path.
+ *
+ * For absolute paths, strip the comp_dir prefix (from DWARF) to get
+ * a kernel-tree-relative path.  When that fails (e.g. O= builds where
+ * comp_dir is the build directory), scan for a well-known kernel
+ * top-level directory name in the path to recover the relative path.
+ * Fall back to the basename as a last resort.
+ *
+ * For relative paths (common in modules), libdw may produce a bogus
+ * doubled path like "net/foo/bar.c/net/foo/bar.c" due to ET_REL DWARF
+ * quirks.  Detect and strip such duplicates.
+ */
+static const char *make_relative(const char *path, const char *comp_dir)
+{
+	const char *p;
+
+	/* If already relative, use as-is */
+	if (path[0] != '/')
+		return path;
+
+	/* comp_dir from DWARF is the most reliable method */
+	if (comp_dir) {
+		size_t len = strlen(comp_dir);
+
+		if (!strncmp(path, comp_dir, len) && path[len] == '/') {
+			const char *rel = path + len + 1;
+
+			/*
+			 * If comp_dir pointed to a subdirectory
+			 * (e.g. arch/parisc/kernel) rather than
+			 * the tree root, stripping it leaves a
+			 * bare filename.  Fall through to the
+			 * kernel_dirs scan so we recover the full
+			 * relative path instead.
+			 */
+			if (strchr(rel, '/'))
+				return rel;
+		}
+
+		/*
+		 * comp_dir prefix didn't help — either it didn't match
+		 * or it was too specific and left a bare filename.
+		 * Scan for a known kernel top-level directory component
+		 * to find where the relative path starts.  This handles
+		 * O= builds and arches where comp_dir is a subdirectory.
+		 */
+		for (p = path + 1; *p; p++) {
+			if (*(p - 1) == '/') {
+				for (unsigned int i = 0; i < sizeof(kernel_dirs) /
+				     sizeof(kernel_dirs[0]); i++) {
+					if (!strncmp(p, kernel_dirs[i],
+						     strlen(kernel_dirs[i])))
+						return p;
+				}
+			}
+		}
+
+		/* Fall back to basename */
+		p = strrchr(path, '/');
+		return p ? p + 1 : path;
+	}
+
+	/* Fall back to basename */
+	p = strrchr(path, '/');
+	return p ? p + 1 : path;
+}
+
+static int compare_entries(const void *a, const void *b)
+{
+	const struct line_entry *ea = a;
+	const struct line_entry *eb = b;
+
+	if (ea->offset != eb->offset)
+		return ea->offset < eb->offset ? -1 : 1;
+	if (ea->file_id != eb->file_id)
+		return ea->file_id < eb->file_id ? -1 : 1;
+	if (ea->line != eb->line)
+		return ea->line < eb->line ? -1 : 1;
+	return 0;
+}
+
+/*
+ * Look up a vmlinux symbol by exact name and return its st_value, or
+ * @fallback if absent.  Aborts when @required and the symbol is missing.
+ */
+static unsigned long long find_vmlinux_sym(Elf *elf, const char *name,
+					   unsigned long long fallback,
+					   bool required)
+{
+	size_t nsyms, i;
+	Elf_Scn *scn = NULL;
+	GElf_Shdr shdr;
+
+	while ((scn = elf_nextscn(elf, scn)) != NULL) {
+		Elf_Data *data;
+
+		if (!gelf_getshdr(scn, &shdr))
+			continue;
+		if (shdr.sh_type != SHT_SYMTAB)
+			continue;
+
+		data = elf_getdata(scn, NULL);
+		if (!data)
+			continue;
+
+		nsyms = shdr.sh_size / shdr.sh_entsize;
+		for (i = 0; i < nsyms; i++) {
+			GElf_Sym sym;
+			const char *sname;
+
+			if (!gelf_getsym(data, i, &sym))
+				continue;
+			sname = elf_strptr(elf, shdr.sh_link, sym.st_name);
+			if (sname && !strcmp(sname, name))
+				return sym.st_value;
+		}
+	}
+
+	if (required) {
+		fprintf(stderr, "Cannot find %s symbol\n", name);
+		exit(1);
+	}
+	return fallback;
+}
+
+static unsigned long long find_text_addr(Elf *elf)
+{
+	return find_vmlinux_sym(elf, "_text", 0, true);
+}
+
+/*
+ * vmlinux is linked in multiple passes: gen_lineinfo runs against
+ * .tmp_vmlinux1 (which carries an empty lineinfo stub), then real tables
+ * are linked in for the final image.  Sections placed AFTER .rodata
+ * (.init.text, .exit.text, ...) shift forward as .rodata grows to hold
+ * the real lineinfo blob, so DWARF addresses we'd capture for them in
+ * pass 1 would be stale in the final kernel.  Cap captured addresses at
+ * _etext, the symbol that marks the end of .text — placed before .rodata
+ * in every architecture's vmlinux.lds.S, so its addresses are invariant
+ * across the relink.  Returns 0 if _etext is absent (no cap; v3 behavior).
+ */
+static unsigned long long find_text_end_addr(Elf *elf)
+{
+	return find_vmlinux_sym(elf, "_etext", 0, false);
+}
+
+static void process_dwarf(Dwarf *dwarf, unsigned long long text_addr)
+{
+	Dwarf_Off off = 0, next_off;
+	size_t hdr_size;
+
+	while (dwarf_nextcu(dwarf, off, &next_off, &hdr_size,
+			    NULL, NULL, NULL) == 0) {
+		Dwarf_Die cudie;
+		Dwarf_Lines *lines;
+		size_t nlines;
+		Dwarf_Attribute attr;
+		const char *comp_dir = NULL;
+
+		if (!dwarf_offdie(dwarf, off + hdr_size, &cudie))
+			goto next;
+
+		if (dwarf_attr(&cudie, DW_AT_comp_dir, &attr))
+			comp_dir = dwarf_formstring(&attr);
+
+		if (dwarf_getsrclines(&cudie, &lines, &nlines) != 0)
+			goto next;
+
+		for (size_t i = 0; i < nlines; i++) {
+			Dwarf_Line *line = dwarf_onesrcline(lines, i);
+			Dwarf_Addr addr;
+			const char *src;
+			const char *rel;
+			unsigned int file_id, loffset;
+			int lineno;
+
+			if (!line)
+				continue;
+
+			if (dwarf_lineaddr(line, &addr) != 0)
+				continue;
+			if (dwarf_lineno(line, &lineno) != 0)
+				continue;
+			if (lineno == 0)
+				continue;
+
+			src = dwarf_linesrc(line, NULL, NULL);
+			if (!src)
+				continue;
+
+			if (addr < text_addr)
+				continue;
+			/*
+			 * Skip addresses past _etext.  Sections after .rodata
+			 * shift when the real lineinfo replaces the empty stub
+			 * during the multi-pass vmlinux link, so any address
+			 * we'd capture there would be stale by the time the
+			 * final kernel runs.
+			 */
+			if (text_end_addr && addr >= text_end_addr)
+				continue;
+
+			{
+				unsigned long long raw_offset = addr - text_addr;
+
+				if (raw_offset > UINT_MAX) {
+					skipped_overflow++;
+					continue;
+				}
+				loffset = (unsigned int)raw_offset;
+			}
+
+			rel = make_relative(src, comp_dir);
+			file_id = find_or_add_file(rel);
+
+			add_entry(loffset, file_id, (unsigned int)lineno);
+		}
+next:
+		off = next_off;
+	}
+}
+
+static void deduplicate(void)
+{
+	unsigned int i, j;
+
+	if (num_entries < 2)
+		return;
+
+	/* Sort by offset, then file_id, then line for stability */
+	qsort(entries, num_entries, sizeof(*entries), compare_entries);
+
+	/*
+	 * Remove duplicate entries:
+	 * - Same offset: keep first (deterministic from stable sort keys)
+	 * - Same file:line as previous kept entry: redundant for binary
+	 *   search -- any address between them resolves to the earlier one
+	 */
+	j = 0;
+	for (i = 1; i < num_entries; i++) {
+		if (entries[i].offset == entries[j].offset)
+			continue;
+		if (entries[i].file_id == entries[j].file_id &&
+		    entries[i].line == entries[j].line)
+			continue;
+		j++;
+		if (j != i)
+			entries[j] = entries[i];
+	}
+	num_entries = j + 1;
+}
+
+static void compute_file_offsets(void)
+{
+	unsigned int offset = 0;
+
+	for (unsigned int i = 0; i < num_files; i++) {
+		files[i].str_offset = offset;
+		offset += strlen(files[i].name) + 1;
+	}
+}
+
+static void print_escaped_asciz(const char *s)
+{
+	printf("\t.asciz \"");
+	for (; *s; s++) {
+		if (*s == '"' || *s == '\\')
+			putchar('\\');
+		putchar(*s);
+	}
+	printf("\"\n");
+}
+
+static void output_assembly(void)
+{
+	printf("/* SPDX-License-Identifier: GPL-2.0 */\n");
+	printf("/*\n");
+	printf(" * Automatically generated by scripts/gen_lineinfo\n");
+	printf(" * Do not edit.\n");
+	printf(" */\n\n");
+
+	printf("\t.section .rodata, \"a\"\n\n");
+
+	/* Number of entries */
+	printf("\t.globl lineinfo_num_entries\n");
+	printf("\t.balign 4\n");
+	printf("lineinfo_num_entries:\n");
+	printf("\t.long %u\n\n", num_entries);
+
+	/* Number of files */
+	printf("\t.globl lineinfo_num_files\n");
+	printf("\t.balign 4\n");
+	printf("lineinfo_num_files:\n");
+	printf("\t.long %u\n\n", num_files);
+
+	/* Sorted address offsets from _text */
+	printf("\t.globl lineinfo_addrs\n");
+	printf("\t.balign 4\n");
+	printf("lineinfo_addrs:\n");
+	for (unsigned int i = 0; i < num_entries; i++)
+		printf("\t.long 0x%x\n", entries[i].offset);
+	printf("\n");
+
+	/* File IDs, parallel to addrs (u16 -- supports up to 65535 files) */
+	printf("\t.globl lineinfo_file_ids\n");
+	printf("\t.balign 2\n");
+	printf("lineinfo_file_ids:\n");
+	for (unsigned int i = 0; i < num_entries; i++)
+		printf("\t.short %u\n", entries[i].file_id);
+	printf("\n");
+
+	/* Line numbers, parallel to addrs */
+	printf("\t.globl lineinfo_lines\n");
+	printf("\t.balign 4\n");
+	printf("lineinfo_lines:\n");
+	for (unsigned int i = 0; i < num_entries; i++)
+		printf("\t.long %u\n", entries[i].line);
+	printf("\n");
+
+	/* File string offset table */
+	printf("\t.globl lineinfo_file_offsets\n");
+	printf("\t.balign 4\n");
+	printf("lineinfo_file_offsets:\n");
+	for (unsigned int i = 0; i < num_files; i++)
+		printf("\t.long %u\n", files[i].str_offset);
+	printf("\n");
+
+	/* Filenames size */
+	{
+		unsigned int fsize = 0;
+
+		for (unsigned int i = 0; i < num_files; i++)
+			fsize += strlen(files[i].name) + 1;
+		printf("\t.globl lineinfo_filenames_size\n");
+		printf("\t.balign 4\n");
+		printf("lineinfo_filenames_size:\n");
+		printf("\t.long %u\n\n", fsize);
+	}
+
+	/* Concatenated NUL-terminated filenames */
+	printf("\t.globl lineinfo_filenames\n");
+	printf("lineinfo_filenames:\n");
+	for (unsigned int i = 0; i < num_files; i++)
+		print_escaped_asciz(files[i].name);
+	printf("\n");
+}
+
+int main(int argc, char *argv[])
+{
+	int fd;
+	Elf *elf;
+	Dwarf *dwarf;
+	unsigned long long text_addr;
+
+	if (argc != 2) {
+		fprintf(stderr, "Usage: %s <vmlinux>\n", argv[0]);
+		return 1;
+	}
+
+	fd = open(argv[1], O_RDONLY);
+	if (fd < 0) {
+		fprintf(stderr, "Cannot open %s: %s\n", argv[1],
+			strerror(errno));
+		return 1;
+	}
+
+	elf_version(EV_CURRENT);
+	elf = elf_begin(fd, ELF_C_READ, NULL);
+	if (!elf) {
+		fprintf(stderr, "elf_begin failed: %s\n",
+			elf_errmsg(elf_errno()));
+		close(fd);
+		return 1;
+	}
+
+	text_addr = find_text_addr(elf);
+	text_end_addr = find_text_end_addr(elf);
+
+	dwarf = dwarf_begin_elf(elf, DWARF_C_READ, NULL);
+	if (!dwarf) {
+		fprintf(stderr, "dwarf_begin_elf failed: %s\n",
+			dwarf_errmsg(dwarf_errno()));
+		fprintf(stderr, "Is %s built with CONFIG_DEBUG_INFO?\n",
+			argv[1]);
+		elf_end(elf);
+		close(fd);
+		return 1;
+	}
+
+	process_dwarf(dwarf, text_addr);
+
+	if (skipped_overflow)
+		fprintf(stderr,
+			"lineinfo: warning: %u entries skipped (offset > 4 GiB from _text)\n",
+			skipped_overflow);
+
+	deduplicate();
+	compute_file_offsets();
+
+	fprintf(stderr, "lineinfo: %u entries, %u files\n",
+		num_entries, num_files);
+
+	output_assembly();
+
+	dwarf_end(dwarf);
+	elf_end(elf);
+	close(fd);
+
+	/* Cleanup */
+	free(entries);
+	for (unsigned int i = 0; i < num_files; i++)
+		free(files[i].name);
+	free(files);
+
+	return 0;
+}
diff --git a/scripts/kallsyms.c b/scripts/kallsyms.c
index 37d5c095ad22a5..42662c4fbc6c94 100644
--- a/scripts/kallsyms.c
+++ b/scripts/kallsyms.c
@@ -78,6 +78,17 @@ static char *sym_name(const struct sym_entry *s)
 
 static bool is_ignored_symbol(const char *name, char type)
 {
+	/* Ignore lineinfo symbols for kallsyms pass stability */
+	static const char * const lineinfo_syms[] = {
+		"lineinfo_addrs",
+		"lineinfo_file_ids",
+		"lineinfo_file_offsets",
+		"lineinfo_filenames",
+		"lineinfo_lines",
+		"lineinfo_num_entries",
+		"lineinfo_num_files",
+	};
+
 	if (type == 'u' || type == 'n')
 		return true;
 
@@ -90,6 +101,11 @@ static bool is_ignored_symbol(const char *name, char type)
 			return true;
 	}
 
+	for (size_t i = 0; i < ARRAY_SIZE(lineinfo_syms); i++) {
+		if (!strcmp(name, lineinfo_syms[i]))
+			return true;
+	}
+
 	return false;
 }
 
diff --git a/scripts/link-vmlinux.sh b/scripts/link-vmlinux.sh
index f99e196abeea4c..39ca44fbb259b9 100755
--- a/scripts/link-vmlinux.sh
+++ b/scripts/link-vmlinux.sh
@@ -103,7 +103,7 @@ vmlinux_link()
 	${ld} ${ldflags} -o ${output}					\
 		${wl}--whole-archive ${objs} ${wl}--no-whole-archive	\
 		${wl}--start-group ${libs} ${wl}--end-group		\
-		${kallsymso} ${btf_vmlinux_bin_o} ${arch_vmlinux_o} ${ldlibs}
+		${kallsymso} ${lineinfo_o} ${btf_vmlinux_bin_o} ${arch_vmlinux_o} ${ldlibs}
 }
 
 # Create ${2}.o file with all symbols from the ${1} object file
@@ -129,6 +129,26 @@ kallsyms()
 	kallsymso=${2}.o
 }
 
+# Generate lineinfo tables from DWARF debug info in a temporary vmlinux.
+# ${1} - temporary vmlinux with debug info
+# Output: sets lineinfo_o to the generated .o file
+gen_lineinfo()
+{
+	info LINEINFO .tmp_lineinfo.S
+	if ! scripts/gen_lineinfo "${1}" > .tmp_lineinfo.S; then
+		echo >&2 "Failed to generate lineinfo from ${1}"
+		echo >&2 "Try to disable CONFIG_KALLSYMS_LINEINFO"
+		exit 1
+	fi
+
+	info AS .tmp_lineinfo.o
+	${CC} ${NOSTDINC_FLAGS} ${LINUXINCLUDE} ${KBUILD_CPPFLAGS} \
+	      ${KBUILD_AFLAGS} ${KBUILD_AFLAGS_KERNEL} \
+	      -c -o .tmp_lineinfo.o .tmp_lineinfo.S
+
+	lineinfo_o=.tmp_lineinfo.o
+}
+
 # Perform kallsyms for the given temporary vmlinux.
 sysmap_and_kallsyms()
 {
@@ -155,6 +175,7 @@ sorttable()
 cleanup()
 {
 	rm -f .btf.*
+	rm -f .tmp_lineinfo.*
 	rm -f .tmp_vmlinux.nm-sort
 	rm -f System.map
 	rm -f vmlinux
@@ -183,6 +204,7 @@ fi
 btf_vmlinux_bin_o=
 btfids_vmlinux=
 kallsymso=
+lineinfo_o=
 strip_debug=
 generate_map=
 
@@ -198,10 +220,21 @@ if is_enabled CONFIG_KALLSYMS; then
 	kallsyms .tmp_vmlinux0.syms .tmp_vmlinux0.kallsyms
 fi
 
+if is_enabled CONFIG_KALLSYMS_LINEINFO; then
+	# Assemble an empty lineinfo stub for the initial link.
+	# The real lineinfo is generated from .tmp_vmlinux1 by gen_lineinfo.
+	${CC} ${NOSTDINC_FLAGS} ${LINUXINCLUDE} ${KBUILD_CPPFLAGS} \
+	      ${KBUILD_AFLAGS} ${KBUILD_AFLAGS_KERNEL} \
+	      -c -o .tmp_lineinfo.o "${srctree}/scripts/empty_lineinfo.S"
+	lineinfo_o=.tmp_lineinfo.o
+fi
+
 if is_enabled CONFIG_KALLSYMS || is_enabled CONFIG_DEBUG_INFO_BTF; then
 
-	# The kallsyms linking does not need debug symbols, but the BTF does.
-	if ! is_enabled CONFIG_DEBUG_INFO_BTF; then
+	# The kallsyms linking does not need debug symbols, but BTF and
+	# lineinfo generation do.
+	if ! is_enabled CONFIG_DEBUG_INFO_BTF &&
+	   ! is_enabled CONFIG_KALLSYMS_LINEINFO; then
 		strip_debug=1
 	fi
 
@@ -219,6 +252,10 @@ if is_enabled CONFIG_DEBUG_INFO_BTF; then
 	btfids_vmlinux=.tmp_vmlinux1.BTF_ids
 fi
 
+if is_enabled CONFIG_KALLSYMS_LINEINFO; then
+	gen_lineinfo .tmp_vmlinux1
+fi
+
 if is_enabled CONFIG_KALLSYMS; then
 
 	# kallsyms support
-- 
2.53.0


^ permalink raw reply related

* [PATCH v7 0/4] kallsyms: embed source file:line info in kernel stack traces
From: Sasha Levin @ 2026-07-09 16:38 UTC (permalink / raw)
  To: Andrew Morton, Masahiro Yamada, Luis Chamberlain, Linus Torvalds,
	Richard Weinberger, Juergen Gross, Geert Uytterhoeven,
	James Bottomley
  Cc: Sasha Levin, Jonathan Corbet, Nathan Chancellor, Nicolas Schier,
	Petr Pavlu, Daniel Gomez, Greg KH, Petr Mladek, Steven Rostedt,
	Kees Cook, Peter Zijlstra, Thorsten Leemhuis, Vlastimil Babka,
	Helge Deller, Randy Dunlap, Laurent Pinchart, Vivian Wang,
	Zhen Lei, Sami Tolvanen, linux-kernel, linux-kbuild,
	linux-modules, linux-doc

This series adds CONFIG_KALLSYMS_LINEINFO, which embeds source file:line
information directly in the kernel image so that stack traces annotate
every frame with the originating source location - no external tools, no
debug symbols at runtime, and safe to use in NMI/panic context.

Motivation
==========

The recent "slowly decommission bugzilla?" thread surfaced a recurring
problem: when users encounter kernel crashes they see stack traces like
`func+0x1ec/0x240` but have no way to identify which subsystem or
maintainer to contact. Richard Weinberger proposed building a database
mapping symbols to source files using nm/DWARF. Linus pointed to
scripts/decode_stacktrace.sh as the existing solution. But as the
discussion progressed, it became clear that decode_stacktrace.sh has
significant practical barriers that prevent it from being useful in the
common case.

Problems with scripts/decode_stacktrace.sh
==========================================

- Requires debug symbols: the script needs vmlinux with DWARF debug
  info. Many distros don't retain debug symbols for older or security
  kernels, and even when available, asking users to obtain matching
  debuginfo packages is a significant hurdle.

- Requires toolchain: users need addr2line and nm installed.

- Version-matching requirement: debug symbols must exactly match the
  running kernel binary.

What this series does
=====================

Patch 1: CONFIG_KALLSYMS_LINEINFO

At build time, a host tool (scripts/gen_lineinfo) reads DWARF
.debug_line from vmlinux, extracts address-to-file:line mappings, and
embeds them as sorted lookup tables in .rodata.  At runtime,
kallsyms_lookup_lineinfo() binary-searches the table and
sprint_backtrace() appends "(file:line)" to each stack frame.
NMI/panic-safe (no locks, no allocations), KASLR-compatible.

Patch 2: CONFIG_KALLSYMS_LINEINFO_MODULES

Extends lineinfo to loadable modules.  Each .ko gets .mod_lineinfo and
.init.mod_lineinfo sections embedded at build time, each carrying one
or more per-section sub-tables identified by an ELF relocation against
the covered text section's symbol.  The module loader's existing
apply_relocations() pass resolves the anchor on load - no special-cased
loader logic, no implicit MOD_TEXT-base assumption.  Coverage extends
to .text, .exit.text, and .init.text; the init blob is freed alongside
init memory.

Patch 3: delta compression

Block-indexed delta-encoding with LEB128 varints, implementing the
approach suggested by Juergen Gross in the RFC review.  Reduces
overhead from ~44 MiB to ~11 MiB (~3.7 bytes/entry), addressing the
primary size concern from the RFC.  Compression applies independently
to each per-section table inside the module blobs.

Patch 4: KUnit tests

30 KUnit tests covering the lineinfo lookup paths, delta-decode logic,
boundary conditions, the module-side multi-section format, and
integration with the backtrace formatting APIs.

Example output
==============

  [   11.206749]  dump_stack_lvl+0x5d/0x80 (lib/dump_stack.c:94)
  [   11.207403]  vpanic+0x36e/0x620 (kernel/panic.c:650)
  [   11.209324]  panic+0xc9/0xd0 (kernel/panic.c:787)
  [   11.213312]  sysrq_handle_crash+0x1a/0x20 (drivers/tty/sysrq.c:154)
  [   11.214005]  __handle_sysrq.cold+0x66/0x256 (drivers/tty/sysrq.c:611)
  [   11.214712]  write_sysrq_trigger+0x65/0x80 (drivers/tty/sysrq.c:1221)
  [   11.215424]  proc_reg_write+0x1bd/0x3c0 (fs/proc/inode.c:330)
  [   11.216061]  vfs_write+0x1c6/0xff0 (fs/read_write.c:686)
  [   11.218848]  ksys_write+0xfa/0x200 (fs/read_write.c:740)
  [   11.222394]  do_syscall_64+0xf3/0x690 (arch/x86/entry/syscall_64.c:63)

Size impact
===========

Measured with a Debian kernel config:

- bzImage: +3.6 MiB (14 MiB -> 18 MiB, +26%)
- Runtime memory: +5.9 MiB (text+data+bss)
- Code overhead: +5.0 KiB (.text, lookup functions only)
- Data overhead: +5.9 MiB (.data, lineinfo tables)

The ~5.9 MiB is after 2.7x delta compression; uncompressed would be
~16 MiB.  This is a fraction of the cost of shipping full DWARF debug
info (hundreds of MiB), which distros must store and serve for every
kernel version.

For distros, maintaining debug symbol repositories is expensive:
storage, mirrors, and CDN bandwidth for hundreds of MiB per kernel
build add up quickly.  A ~5.9 MiB increase in the kernel image itself
is a modest cost that eliminates the need for users to find, download,
and version-match debuginfo packages just to make a crash report
useful.

For developers, the file:line annotations appear immediately in crash
traces - no post-processing with decode_stacktrace.sh needed.

Changes since v6
================

- Address Sashiko AI review comments.

Changes since v5
================

- Rebased onto v7.2-rc2:

  - scripts/Makefile.modfinal: adapt to the removal of
    if_changed_except in commit 7abef41afad05b ("kbuild/btf: Remove
    broken module relinking exclusion").

  - Drop unrelated MAINTAINERS hunks that had leaked into patch 2
    during v5 preparation; those updates are already upstream.

Changes since v4
================

- Replace mod->mem[MOD_TEXT].base derivation with an ELF relocation
  against the covered section's symbol; resolved by the module loader
  on load.  (Suggested by Petr Pavlu)

- Cover .exit.text and .init.text in addition to .text.  Init code
  lives in a parallel .init.mod_lineinfo blob (MOD_INIT_RODATA) that
  is revoked and freed alongside init memory.

- Switch the build step from objcopy --add-section to ld -r, so
  .rela.mod_lineinfo / .rela.init.mod_lineinfo ride along into the .ko.

- Pick up lineinfo sections via section_objs() (not any_section_objs)
  to validate SHF_ALLOC and avoid dangling pointers into the temporary
  load image.

- Drop the unused "reserved" placeholder field from mod_lineinfo_root
  and mod_lineinfo_header.  (Suggested by Petr Pavlu)

- Guard the per-module fields with #ifdef CONFIG_KALLSYMS_LINEINFO_MODULES
  and expose them via inline reader accessors.  (Suggested by Petr Pavlu)

- Update the per-section header layout comment to name struct fields.
  (Suggested by Petr Pavlu)

- Cap vmlinux-side captured DWARF addresses at _etext: sections placed
  after .rodata shift as the real lineinfo replaces the empty stub
  during the multi-pass link, so addresses outside the invariant .text
  window become stale in the final image.

- Append "(file:line)" only via sprint_backtrace*(), not plain
  sprint_symbol*() (which backs %ps); many format strings already
  follow %ps with literal "()" and would otherwise render as
  "foo (file:line)()".  KUnit gains a sprint_with_lineinfo() helper.

- Build-side fixes in scripts/gen_lineinfo: apply 64-bit absolute
  relocations to .debug_line (libdw doesn't relocate ET_REL inputs);
  endian-safe writes for cross-builds; declare empty
  .text/.exit.text/.init.text stanzas so ld -r binds anchors to LOCAL
  rather than GLOBAL UND symbols.

Changes since v3
=================

- Remove redundant gen_lineinfo entry in scripts/Makefile for
  CONFIG_KALLSYMS_LINEINFO_MODULES (depends on CONFIG_KALLSYMS_LINEINFO
  which already builds it). (Reported by Petr Pavlu)

- Use R_* constants from <elf.h> instead of hardcoded relocation type
  values in r_type_abs32(). (Reported by Petr Pavlu)

- Simplify duplicated-path detection in make_relative(): replace loop
  over every '/' with a direct midpoint check, since true path
  duplication always splits at len/2. (Suggested by Petr Pavlu)

- Fix comment in process_dwarf(): sections in ET_REL objects have
  sh_addr == 0 and therefore overlapping address ranges; this is
  expected behavior, not a "may" situation. (Reported by Petr Pavlu)

- Use U32_MAX instead of UINT_MAX for the module raw_offset bounds
  check, matching the u32 type of the addrs array.
  (Reported by Petr Pavlu)

- Document the assumption that .text is at the start of the MOD_TEXT
  segment in module_lookup_lineinfo(). A proper fix using ELF
  relocations is planned for a future series.
  (Reported by Petr Pavlu)

- Wrap -fno-inline-functions-called-once in $(call cc-option,...) for
  clang compatibility. Clang does not support this GCC-specific flag;
  the noinline attribute is sufficient.

Changes since v2
=================

- Replace #ifdef CONFIG_KALLSYMS_LINEINFO with IS_ENABLED() throughout,
  so the compiler checks the code for syntax errors regardless of
  configuration. (Suggested by Helge Deller)

- Replace zigzag + ULEB128 encoding of signed deltas with native SLEB128,
  removing the unnecessary zigzag transform layer.
  (Suggested by Vivian Wang)

- Deduplicate the binary search and delta-decode logic: extract shared
  struct lineinfo_table and lineinfo_search() into mod_lineinfo.h
  instead of maintaining near-identical copies in kernel/kallsyms.c and
  kernel/module/kallsyms.c. (Suggested by Vivian Wang)

- Use .uleb128 / .sleb128 assembler directives in gen_lineinfo output
  instead of encoding varints in C and emitting .byte hex literals.
  (Suggested by Vivian Wang)

- Redesign module mod_lineinfo_header to use explicit (offset, size)
  pairs for each sub-array, similar to flattened devicetree layout.
  This makes bounds validation straightforward: offset + size <=
  section_size. (Suggested by Vivian Wang)

- Remove dead sym_start parameter from kallsyms_lookup_lineinfo() and
  module_lookup_lineinfo().

Changes since v1
=================

- Fix path stripping regression on architectures where DWARF comp_dir is
  a subdirectory (e.g. arch/parisc/kernel) rather than the source tree
  root: paths now correctly show "kernel/traps.c:212" instead of bare
  "traps.c:212". Added kernel_dirs[] fallback scan and bare-filename
  recovery via comp_dir. (Reported by Helge Deller)

- Fix RST heading: overline/underline must be at least as long as the
  heading text in kallsyms-lineinfo.rst. (Reported by Randy Dunlap)

- Fix MAINTAINERS alphabetical ordering: move KALLSYMS LINEINFO entry
  before KASAN. (Reported by Randy Dunlap)

- Fix arch-portability of .debug_line relocation handling: replace
  hardcoded R_X86_64_32 with r_type_abs32() supporting x86, arm, arm64,
  riscv, s390, mips, ppc, loongarch, and parisc.

Sasha Levin (4):
  kallsyms: embed source file:line info in kernel stack traces
  kallsyms: extend lineinfo to loadable modules
  kallsyms: delta-compress lineinfo tables for ~2.7x size reduction
  kallsyms: add KUnit tests for lineinfo feature

 Documentation/admin-guide/index.rst           |    1 +
 .../admin-guide/kallsyms-lineinfo.rst         |   97 ++
 MAINTAINERS                                   |    9 +
 include/linux/kallsyms.h                      |   18 +-
 include/linux/mod_lineinfo.h                  |  312 ++++
 include/linux/module.h                        |   40 +
 init/Kconfig                                  |   35 +
 kernel/kallsyms.c                             |   98 +-
 kernel/kallsyms_internal.h                    |   11 +
 kernel/module/kallsyms.c                      |  180 ++
 kernel/module/main.c                          |   26 +
 lib/Kconfig.debug                             |   10 +
 lib/tests/Makefile                            |    3 +
 lib/tests/lineinfo_kunit.c                    |  973 +++++++++++
 scripts/.gitignore                            |    1 +
 scripts/Makefile                              |    3 +
 scripts/Makefile.modfinal                     |    6 +
 scripts/empty_lineinfo.S                      |   38 +
 scripts/gen-mod-lineinfo.sh                   |   50 +
 scripts/gen_lineinfo.c                        | 1458 +++++++++++++++++
 scripts/kallsyms.c                            |   17 +
 scripts/link-vmlinux.sh                       |   43 +-
 22 files changed, 3418 insertions(+), 11 deletions(-)
 create mode 100644 Documentation/admin-guide/kallsyms-lineinfo.rst
 create mode 100644 include/linux/mod_lineinfo.h
 create mode 100644 lib/tests/lineinfo_kunit.c
 create mode 100644 scripts/empty_lineinfo.S
 create mode 100644 scripts/gen-mod-lineinfo.sh
 create mode 100644 scripts/gen_lineinfo.c


base-commit: 8cdeaa50eae8dad34885515f62559ee83e7e8dda
-- 
2.53.0


^ permalink raw reply

* Re: [PATCH v2] module: procfs: fix signed integer overflow in module_total_size()
From: Sami Tolvanen @ 2026-07-09 15:25 UTC (permalink / raw)
  To: Petr Pavlu
  Cc: Naveen Kumar Chaudhary, mcgrof, da.gomez, atomlin, linux-modules
In-Reply-To: <833ca499-09aa-4eca-a023-e3a7168066ed@suse.com>

On Thu, Jul 9, 2026 at 1:45 AM Petr Pavlu <petr.pavlu@suse.com> wrote:
>
> On 6/7/26 6:18 AM, Naveen Kumar Chaudhary wrote:
> > module_total_size() returns unsigned int but uses a signed int
> > accumulator. While the result is numerically correct, the type
> > mismatch is misleading.
> >
> > Change the accumulator to unsigned int to match the return type.
> >
> > Signed-off-by: Naveen Kumar Chaudhary <naveen.osdev@gmail.com>
>
> Queued on modules-next for v7.3-rc1.

Note that the commit title still claims to fix a signed integer
overflow, which isn't accurate. Otherwise LGTM.

Sami

^ permalink raw reply

* Re: About THIS_MODULE
From: Petr Pavlu @ 2026-07-09 14:31 UTC (permalink / raw)
  To: John Garry; +Cc: linux-modules
In-Reply-To: <610fc63b-f3dc-4824-99fd-907fc96f3194@oracle.com>

On 7/3/26 5:33 PM, John Garry wrote:
> Hi all,
> 
> I have a query which I hope someone can advise on.
> 
> I am adding a library API which requires a driver to pass the driver module pointer to the API.
> 
> So we use THIS_MODULE for that purpose.
> 
> However, adding a sanity check in the library to ensure that pointer is set is a challenge. Normally we would check that the module pointer is non-NULL. However, for a built-in driver module, THIS_MODULE is NULL, so rely on the non-NULL check.
> 
> Any idea how to deal with this?
Right, a NULL module pointer is ambiguous if you want to distinguish
between these two cases. I can't think of a neat way to handle this in
a library API.

One option would be to rework THIS_MODULE and introduce a reduced module
struct also for vmlinux and built-in modules. However, this isn't
a trivial change and I can't recall a case in the last few years where
this has caused a specific issue. Additionally, if someone forgets to
pass a module pointer somewhere, I would expect it to be fairly easy to
detect and fix.

-- 
Cheers,
Petr

^ permalink raw reply

* Re: [PATCH v2] rust: module_param: return value by copy from `value`
From: Miguel Ojeda @ 2026-07-09 14:13 UTC (permalink / raw)
  To: Petr Pavlu
  Cc: Andreas Hindborg, Miguel Ojeda, Luis Chamberlain, Daniel Gomez,
	Sami Tolvanen, Aaron Tomlin, Boqun Feng, Gary Guo,
	Björn Roy Baron, Benno Lossin, Alice Ryhl, Trevor Gross,
	Danilo Krummrich, linux-modules, linux-kernel, rust-for-linux
In-Reply-To: <52feb9b1-2073-4fcf-b2d1-a7f42f1e72a5@suse.com>

On Thu, Jul 9, 2026 at 1:48 PM Petr Pavlu <petr.pavlu@suse.com> wrote:
>
> @Miguel, please let me know if I should take this patch on modules-next,
> or if you'd prefer for it to go through the Rust tree.

Please feel free to take it through modules-next, of course.

Thanks!

Cheers,
Miguel

^ permalink raw reply

* Re: [PATCH v2] rust: module_param: return value by copy from `value`
From: Petr Pavlu @ 2026-07-09 11:48 UTC (permalink / raw)
  To: Andreas Hindborg, Miguel Ojeda
  Cc: Luis Chamberlain, Daniel Gomez, Sami Tolvanen, Aaron Tomlin,
	Boqun Feng, Gary Guo, Björn Roy Baron, Benno Lossin,
	Alice Ryhl, Trevor Gross, Danilo Krummrich, linux-modules,
	linux-kernel, rust-for-linux
In-Reply-To: <20260601-modules-value-ref-v2-1-12ebbf0510c9@kernel.org>

On 6/1/26 12:17 PM, Andreas Hindborg wrote:
> For `Copy` parameter types it is more ergonomic to retrieve the
> parameter value by copy than through a shared reference. Change
> `ModuleParamAccess::value` to return `T` by copy when `T: Copy`,
> and rename the previous reference-returning accessor to
> `value_ref`. Update the in-tree caller in `rust_minimal`.
> 
> Suggested-by: Alice Ryhl <aliceryhl@google.com>
> Signed-off-by: Andreas Hindborg <a.hindborg@kernel.org>

Reviewed-by: Petr Pavlu <petr.pavlu@suse.com>

@Miguel, please let me know if I should take this patch on modules-next,
or if you'd prefer for it to go through the Rust tree.

-- 
Thanks,
Petr

^ permalink raw reply

* Re: [PATCH 1/2] umh, treewide: Explicitly include linux/umh.h where needed
From: Petr Pavlu @ 2026-07-09  9:49 UTC (permalink / raw)
  To: Michal Koutný
  Cc: Tony Luck, Borislav Petkov, Thomas Gleixner, Ingo Molnar,
	Dave Hansen, x86, H. Peter Anvin, Philipp Reisner, Lars Ellenberg,
	Christoph Böhmwalder, Jens Axboe, Johan Hovold, Alex Elder,
	Greg Kroah-Hartman, Rafael J. Wysocki, Michal Januszewski,
	Helge Deller, Alexander Viro, Christian Brauner, Jan Kara,
	Trond Myklebust, Anna Schumaker, Chuck Lever, Jeff Layton,
	NeilBrown, Olga Kornievskaia, Dai Ngo, Tom Talpey, Mark Fasheh,
	Joel Becker, Joseph Qi, Tejun Heo, Johannes Weiner,
	Luis Chamberlain, Daniel Gomez, Sami Tolvanen, Aaron Tomlin,
	Pavel Machek, Len Brown, Andrew Morton, Danilo Krummrich,
	Nikolay Aleksandrov, Ido Schimmel, David S. Miller, Eric Dumazet,
	Jakub Kicinski, Paolo Abeni, Simon Horman, David Howells,
	Jarkko Sakkinen, Paul Moore, James Morris, Serge E. Hallyn,
	Kentaro Takeda, Tetsuo Handa, linux-edac, linux-kernel, drbd-dev,
	linux-block, greybus-dev, linuxppc-dev, linux-acpi, linux-fbdev,
	dri-devel, linux-fsdevel, linux-nfs, ocfs2-devel, cgroups,
	linux-modules, linux-pm, driver-core, bridge, netdev, keyrings,
	linux-security-module
In-Reply-To: <ak6STbqZd-Q-c56v@localhost.localdomain>

On 7/8/26 8:13 PM, Michal Koutný wrote:
> Hi Petr.
> 
> On Wed, Jul 08, 2026 at 05:44:29PM +0200, Petr Pavlu <petr.pavlu@suse.com> wrote:
>> diff --git a/kernel/cgroup/cgroup-v1.c b/kernel/cgroup/cgroup-v1.c
>> index a4337c9b5287..60eb994c32ae 100644
>> --- a/kernel/cgroup/cgroup-v1.c
>> +++ b/kernel/cgroup/cgroup-v1.c
>> @@ -16,6 +16,7 @@
>>  #include <linux/pid_namespace.h>
>>  #include <linux/cgroupstats.h>
>>  #include <linux/fs_parser.h>
>> +#include <linux/umh.h>
>>  
>>  #include <trace/events/cgroup.h>
> 
> There is kmod.h in here too but it's unnecessary, no module lazy loading
> in this code.

You're right. I'll remove the kmod.h include from
kernel/cgroup/cgroup-v1.c. I went through all the files again and it
seems this was the only place I missed.

-- 
Thanks,
Petr

^ permalink raw reply

* Re: [PATCH] module: validate string table section types
From: Petr Pavlu @ 2026-07-09  9:23 UTC (permalink / raw)
  To: Thiébaud Weksteen
  Cc: Luis Chamberlain, Daniel Gomez, Sami Tolvanen, Aaron Tomlin,
	Siddharth Nayyar, linux-modules, linux-kernel
In-Reply-To: <CA+zpnLfpNPoVt8ZrN8AcjNhyYHfB3G-VbykVUNZLjQhsqiP=8g@mail.gmail.com>

On 7/9/26 3:56 AM, Thiébaud Weksteen wrote:
> On Thu, Jul 9, 2026 at 1:40 AM Petr Pavlu <petr.pavlu@suse.com> wrote:
>>
>> On 7/8/26 3:21 AM, Thiébaud Weksteen wrote:
>>> In elf_validity_cache_sechdrs, section sizes and offsets are validated,
>>> unless the section type is SHT_NULL or SHT_NOBITS.
>>>
>>> Later, elf_validity_cache_secstrings and elf_validity_cache_index_str
>>> access the section name table (.shstrtab) and symbol string table
>>> (.strtab) headers without first ensuring that their types are
>>> SHT_STRTAB. If a section type is SHT_NULL or SHT_NOBITS, sh_offset has
>>> not been validated and may reference out-of-bounds memory when
>>> dereferenced in elf_validity_cache_secstrings or
>>> elf_validity_cache_strtab.
>>>
>>> Validate that both string section headers are of type SHT_STRTAB before
>>> caching them.
>>
>> The module loader should normally at least get through the signature and
>> blacklist checks without crashing due to a corrupted module ELF file.
>> Failing to validate the offset+size of .shstrtab means the module loader
>> could crash before the blacklist check, so I believe it is useful to add
>> this validation.
>>
>> How did you run into this issue? Was it observed in practice with the
>> GNU or LLVM toolchain, or with some manually crafted module?
> 
> Thanks for the review Petr. I found the issue while reading the code.
> I am working on a separate commit that reuses some of the ELF parsing
> logic (in a different subsystem, with simpler assertions). I was able
> to confirm with a basic PoC (reusing a valid .ko and replacing the
> type and offset of .shstrtab).

Thanks for the explanation. The change looks ok to me.

Reviewed-by: Petr Pavlu <petr.pavlu@suse.com>

-- Petr

^ permalink raw reply

* Re: [PATCH 0/2] module: Remove unnecessary module::args
From: Petr Pavlu @ 2026-07-09  9:02 UTC (permalink / raw)
  To: Chris Zankel, Max Filippov, Luis Chamberlain, Daniel Gomez,
	Sami Tolvanen
  Cc: Aaron Tomlin, Matthew Wood, linux-modules, linux-kernel
In-Reply-To: <20260604110455.1608038-1-petr.pavlu@suse.com>

On 6/4/26 1:04 PM, Petr Pavlu wrote:
> Historically, various parameter-handling code kept pointers into
> module::args, most notably the charp support. However, in 2009,
> commit e180a6b7759a ("param: fix charp parameters set via sysfs") changed
> charp parameters to kstrdup() the input string as well. As a result,
> module::args now mostly wastes memory.
> 
> Reviewing all kernel_param_ops and module_param_call instances shows that
> the last code still relying on module::args remaining valid for the
> module's lifetime is simdisk_param_ops_filename. Update it to use kstrdup()
> and then remove module::args.
> 
> Petr Pavlu (2):
>   xtensa/simdisk: Avoid referring to module::args
>   module: Remove unnecessary module::args
> 
>  arch/xtensa/platforms/iss/simdisk.c | 38 +++++++++++++++++++++++++----
>  include/linux/module.h              |  4 ---
>  kernel/module/main.c                | 15 ++++++------
>  3 files changed, 41 insertions(+), 16 deletions(-)
> 
> 
> base-commit: e43ffb69e0438cddd72aaa30898b4dc446f664f8

Thanks Max and Aaron for the review. Queued now on modules-next for
v7.3-rc1.

-- Petr

^ permalink raw reply

* Re: [PATCH] module: Remove unused DISCARD_EH_FRAME definition from module.lds.S
From: Petr Pavlu @ 2026-07-09  8:49 UTC (permalink / raw)
  To: Sami Tolvanen
  Cc: Luis Chamberlain, Daniel Gomez, Aaron Tomlin, Ard Biesheuvel,
	linux-modules, linux-kernel
In-Reply-To: <CABCJKueO3NkVrsR9H6JxFpEf=UsGZ2PCPSrs6hXJh+cdBjikVg@mail.gmail.com>

On 6/17/26 9:29 PM, Sami Tolvanen wrote:
> On Fri, Jun 12, 2026 at 6:40 AM Petr Pavlu <petr.pavlu@suse.com> wrote:
>>
>> The linker script scripts/module.lds.S contains an unused DISCARD_EH_FRAME
>> definition introduced by commit 68c76ad4a957 ("arm64: unwind: add
>> asynchronous unwind tables to kernel and modules").
>>
>> As shown in an earlier version of that patch [1], DISCARD_EH_FRAME was
>> meant to be used by SANITIZER_DISCARDS in the same file, as follows:
>>
>> -# define SANITIZER_DISCARDS    *(.eh_frame)
>> +# define SANITIZER_DISCARDS    DISCARD_EH_FRAME
>>
>> However, in the meantime, SANITIZER_DISCARDS was removed entirely from
>> module.lds.S by commit 89245600941e ("cfi: Switch to -fsanitize=kcfi").
>> Eventually, the mentioned commit 68c76ad4a957 only added the new
>> DISCARD_EH_FRAME definition to this file without actually using it.
>>
>> The file include/asm-generic/vmlinux.lds.h contains a similar
>> DISCARD_EH_FRAME definition for vmlinux to discard .eh_frame sections that
>> may be present when CONFIG_GCOV_KERNEL, CONFIG_KASAN_GENERIC or
>> CONFIG_KCSAN is enabled. Testing these options on arm64 with LLVM 19.1 did
>> not show any unexpected .eh_frame sections in modules.
>>
>> Remove the unused DISCARD_EH_FRAME definition from scripts/module.lds.S.
>>
>> Link: https://lore.kernel.org/linux-arm-kernel/20220701152724.3343599-2-ardb@kernel.org/ [1]
>> Signed-off-by: Petr Pavlu <petr.pavlu@suse.com>
> 
> Reviewed-by: Sami Tolvanen <samitolvanen@google.com>
Thanks, queued on modules-next for v7.3-rc1.

-- Petr

^ permalink raw reply

* Re: [PATCH v2] module: procfs: fix signed integer overflow in module_total_size()
From: Petr Pavlu @ 2026-07-09  8:45 UTC (permalink / raw)
  To: Naveen Kumar Chaudhary
  Cc: samitolvanen, mcgrof, da.gomez, atomlin, linux-modules
In-Reply-To: <bl63xtwmzo42pw4636lr75u7duhuejp5tariokl27x3wnvdu24@3xf5cupsskka>

On 6/7/26 6:18 AM, Naveen Kumar Chaudhary wrote:
> module_total_size() returns unsigned int but uses a signed int
> accumulator. While the result is numerically correct, the type
> mismatch is misleading.
> 
> Change the accumulator to unsigned int to match the return type.
> 
> Signed-off-by: Naveen Kumar Chaudhary <naveen.osdev@gmail.com>

Queued on modules-next for v7.3-rc1.

-- 
Thanks,
Petr

^ permalink raw reply

* Re: [PATCH v2] module: use strscpy() to copy module names in stats and dup tracking
From: Petr Pavlu @ 2026-07-09  8:37 UTC (permalink / raw)
  To: Naveen Kumar Chaudhary
  Cc: mcgrof, da.gomez, samitolvanen, atomlin, linux-modules
In-Reply-To: <slebbzn7p6c4ybokolvey2sgvrooit66zjewwggqac2755ujgp@ptanmtdclmw5>

On 6/4/26 7:45 PM, Naveen Kumar Chaudhary wrote:
> Both try_add_failed_module() and kmod_dup_request_exists_wait() use
> memcpy() with strlen() to copy module names into fixed-size
> char[MODULE_NAME_LEN] buffers. Neither performs a bounds check on the
> copy. Current callers always pass names originating from
> mod->name (itself char[MODULE_NAME_LEN]), so this is not exploitable
> today. However both functions accept a plain const char * with no
> documented length contract, making them latent buffer overflows if a
> future caller passes a longer string.
> 
> Replace memcpy() with strscpy() in both sites, which bounds the copy
> to MODULE_NAME_LEN and always NUL-terminates.
> 
> Signed-off-by: Naveen Kumar Chaudhary <naveen.osdev@gmail.com>

Queued on modules-next for v7.3-rc1.

-- 
Thanks,
Petr

^ permalink raw reply

* Re: [PATCH] params: fix path of /sys/module/XYZ/parameters/ in comment
From: Petr Pavlu @ 2026-07-09  8:16 UTC (permalink / raw)
  To: Zenghui Yu
  Cc: linux-modules, linux-kernel, mcgrof, da.gomez, samitolvanen,
	atomlin, rdunlap
In-Reply-To: <20260621082223.11439-1-zenghui.yu@linux.dev>

On 6/21/26 10:22 AM, Zenghui Yu wrote:
> The comment wrongly references to /sys/modules/XYZ/parameters/ directory
> instead of /sys/module/XYZ/parameters/. Fix it.
> 
> Signed-off-by: Zenghui Yu <zenghui.yu@linux.dev>

Queued on modules-next for v7.3-rc1.

-- 
Thanks,
Petr

^ permalink raw reply

* Re: [PATCH] module: validate string table section types
From: Thiébaud Weksteen @ 2026-07-09  1:56 UTC (permalink / raw)
  To: Petr Pavlu
  Cc: Luis Chamberlain, Daniel Gomez, Sami Tolvanen, Aaron Tomlin,
	Siddharth Nayyar, linux-modules, linux-kernel
In-Reply-To: <33fba1b5-d107-46cd-896f-bd2539a4a0b6@suse.com>

On Thu, Jul 9, 2026 at 1:40 AM Petr Pavlu <petr.pavlu@suse.com> wrote:
>
> On 7/8/26 3:21 AM, Thiébaud Weksteen wrote:
> > In elf_validity_cache_sechdrs, section sizes and offsets are validated,
> > unless the section type is SHT_NULL or SHT_NOBITS.
> >
> > Later, elf_validity_cache_secstrings and elf_validity_cache_index_str
> > access the section name table (.shstrtab) and symbol string table
> > (.strtab) headers without first ensuring that their types are
> > SHT_STRTAB. If a section type is SHT_NULL or SHT_NOBITS, sh_offset has
> > not been validated and may reference out-of-bounds memory when
> > dereferenced in elf_validity_cache_secstrings or
> > elf_validity_cache_strtab.
> >
> > Validate that both string section headers are of type SHT_STRTAB before
> > caching them.
>
> The module loader should normally at least get through the signature and
> blacklist checks without crashing due to a corrupted module ELF file.
> Failing to validate the offset+size of .shstrtab means the module loader
> could crash before the blacklist check, so I believe it is useful to add
> this validation.
>
> How did you run into this issue? Was it observed in practice with the
> GNU or LLVM toolchain, or with some manually crafted module?

Thanks for the review Petr. I found the issue while reading the code.
I am working on a separate commit that reuses some of the ELF parsing
logic (in a different subsystem, with simpler assertions). I was able
to confirm with a basic PoC (reusing a valid .ko and replacing the
type and offset of .shstrtab).

^ permalink raw reply

* Re: [PATCH v6 19/24] dyndbg: pin class param storage to u32
From: jim.cromie @ 2026-07-08 20:48 UTC (permalink / raw)
  To: sashiko-reviews; +Cc: linux-modules, dri-devel
In-Reply-To: <20260708023725.52B9E1F000E9@smtp.kernel.org>

On Tue, Jul 7, 2026 at 8:37 PM <sashiko-bot@kernel.org> wrote:
>
> Thank you for your contribution! Sashiko AI review found 1 potential issue(s) to consider:
> - [Medium] Support for classmaps with more than 32 classes is broken, causing undefined behavior on 32-bit architectures and silent truncation on 64-bit architectures.
> --
>
> --- Patch [19]: [PATCH v6 19/24] dyndbg: pin class param storage to u32 ---
> commit df6ebc9b56fab43581ec7860d30aab6f33272ee2
> Author: Jim Cromie <jim.cromie@gmail.com>
>
> dyndbg: pin class param storage to u32
>
> This commit changes the storage of dyndbg class parameters from unsigned long
> to u32 for consistency across architectures. It also shrinks __drm_debug back
> to u32.
>
> > diff --git a/lib/dynamic_debug.c b/lib/dynamic_debug.c
> > index d99c69b9ad12f..af05f4ae3b55e 100644
> > --- a/lib/dynamic_debug.c
> > +++ b/lib/dynamic_debug.c
> [ ... ]
> > @@ -643,24 +643,27 @@ static int ddebug_apply_class_bitmap(const struct ddebug_class_param *dcp,
> >       int bi, ct;
> >
> >       if (*new_bits != old_bits)
> > -             v2pr_info("apply bitmap: 0x%lx to: 0x%lx for %s\n", *new_bits,
> > +             v2pr_info("apply bitmap: 0x%x to: 0x%x for %s\n", *new_bits,
> >                         old_bits, query_modname ?: "'*'");
> >
> >       for (bi = 0; bi < map->length; bi++) {
> > -             if (test_bit(bi, new_bits) == test_bit(bi, &old_bits))
> > +             bool new_b = !!(*new_bits & BIT(bi));
> > +             bool old_b = !!(old_bits & BIT(bi));
>
> [Severity: Medium]
> Since classmaps can theoretically contain up to 63 classes (as mentioned in
> the commit message), is there a risk of an out-of-bounds shift on 32-bit
> architectures?
>

A macro in a later patch (not in this series) limits a classmap to 32 classes,
which is a practical limit for usability of classmap params.
As a case in point, drm.debug has only ~10 DRM_UT_* values.

64 is a per-module class-id space, allowing modules to have several classmaps.
a classmap's base/offset lets it map its 0..31 range into upper class-ids,
which is how a module can have multiple, non-overlapping mappings.


#define __DYNAMIC_DEBUG_CLASSMAP_CHECK(_clnames, _base, _mapty)         \
        static_assert(((_base) >= 0 && (_base) < _DPRINTK_CLASS_DFLT),  \
                      "_base must be in 0..62");                        \
        static_assert(__DDEBUG_ARRAY_SIZE(_clnames) > 0,
                 \
                      "classnames array size must be > 0");             \
        static_assert(__DDEBUG_ARRAY_SIZE(_clnames) <= 32,
         \
                      "classnames array size must be <= 32");           \
        static_assert((__DDEBUG_ARRAY_SIZE(_clnames) + (_base)) <
_DPRINTK_CLASS_DFLT, \
                      "_base + classnames.length must be <= 62");       \
        static_assert(((_mapty) >= DD_CLASS_TYPE_DISJOINT_BITS) &&      \
                      ((_mapty) <= DD_CLASS_TYPE_LEVEL_NUM),            \
                      "unknown class_map_type")

So while its currently possible to define a problematic classmap,
that will not be possible soon.


> BIT(bi) expands to 1UL << bi, so if map->length is greater than 32, this
> shift would exceed the capacity of an unsigned long on 32-bit systems.
>
> On 64-bit systems, *new_bits is now a u32. When it is combined with BIT(bi)
> using a bitwise AND, the u32 is zero-extended. Will this cause new_b and
> old_b to always evaluate to 0 for classes >= 32, silently ignoring the
> requested state?
>
> [ ... ]
> > @@ -704,24 +707,24 @@ static int param_set_dyndbg_module_classes(const char *instr,
> [ ... ]
> >       case DD_CLASS_TYPE_LEVEL_NUM:
> >               /* input is bitpos, of highest verbosity to be enabled */
> >               if (inrep > map->length) {
> > -                     pr_warn("%s: level:%ld exceeds max:%d, clamping\n",
> > +                     pr_warn("%s: level:%u exceeds max:%d, clamping\n",
> >                               KP_NAME(kp), inrep, map->length);
> >                       inrep = map->length;
> >               }
> >               old_bits = CLASSMAP_BITMASK(*dcp->lvl);
> >               new_bits = CLASSMAP_BITMASK(inrep);
>
> [Severity: Medium]
> Will this silently prevent enablement of classes >= 32?
>
> CLASSMAP_BITMASK was changed to limit the generated mask to 32 bits, but
> map->length is not restricted to 32.
>
> --
> Sashiko AI review · https://sashiko.dev/#/patchset/20260707-dd-maint-2-v6-0-381f3edb0045@gmail.com?part=19

^ permalink raw reply

* Re: [PATCH v6 10/24] dyndbg: drop NUM_TYPE_ARGS
From: jim.cromie @ 2026-07-08 19:30 UTC (permalink / raw)
  To: sashiko-reviews; +Cc: dri-devel, linux-modules
In-Reply-To: <20260708022424.AC02F1F000E9@smtp.kernel.org>

On Tue, Jul 7, 2026 at 8:24 PM <sashiko-bot@kernel.org> wrote:
>
> Thank you for your contribution! Sashiko AI review found 1 potential issue(s) to consider:
> - [Low] The newly defined macro __DDEBUG_ARRAY_SIZE is unused because the array size calculation is manually open-coded.
> --
>
> --- Patch [10]: [PATCH v6 10/24] dyndbg: drop NUM_TYPE_ARGS ---
> commit 0ca0f4a344ab62c4d4df9c27a60daf65fecad136
> Author: Jim Cromie <jim.cromie@gmail.com>
>
> dyndbg: drop NUM_TYPE_ARGS
>
> This commit removes the NUM_TYPE_ARGS macro and replaces it with an array
> size calculation. The commit message mentions adding a new local macro named
> __DDEBUG_ARRAY_SIZE to handle this without causing include conflicts.
>
> > diff --git a/include/linux/dynamic_debug.h b/include/linux/dynamic_debug.h
> > index 9607121c3072e..9ae1accb9bf63 100644
> > --- a/include/linux/dynamic_debug.h
> > +++ b/include/linux/dynamic_debug.h
> > @@ -8,6 +8,8 @@
> >
> >  #include <linux/build_bug.h>
> >
> > +#define __DDEBUG_ARRAY_SIZE(arr) (sizeof(arr) / sizeof((arr)[0]))
> > +
>
> [ ... ]
>
> > @@ -122,11 +124,9 @@ struct ddebug_class_param {
> >               .mod_name = KBUILD_MODNAME,                             \
> >               .base = _base,                                          \
> >               .map_type = _maptype,                                   \
> > -             .length = NUM_TYPE_ARGS(char*, __VA_ARGS__),            \
> > +             .length = (sizeof(_var##_classnames) / sizeof(_var##_classnames[0])), \
>
> [Severity: Low]
> This isn't a bug, but should this line use the new __DDEBUG_ARRAY_SIZE macro
> introduced above? The calculation appears to be manually open-coded here,
> leaving the newly defined macro unused.
>

Ack. Fixed locally. Will resend shortly.


> >               .class_names = _var##_classnames,                       \
> >       }
>
> --
> Sashiko AI review · https://sashiko.dev/#/patchset/20260707-dd-maint-2-v6-0-381f3edb0045@gmail.com?part=10

^ permalink raw reply

* Re: [PATCH v6 15/24] dyndbg: refactor param_set_dyndbg_classes and below
From: jim.cromie @ 2026-07-08 19:28 UTC (permalink / raw)
  To: sashiko-reviews; +Cc: linux-modules, dri-devel
In-Reply-To: <20260708022913.E3D9A1F000E9@smtp.kernel.org>

Hello,

The concern regarding old_bits in param_set_dyndbg_module_classes()
appears to be a false positive based on a misunderstanding of the
initialization order.

This is a sysfs parameter callback. By the time it is invoked, the
module has already been probed and ddebug_sync_classbits has updated
the module's pr-debugs to match the current parameter value.
Therefore, the global state (dcp->bits) correctly reflects the
module's state at the time of the callback. Using dcp->bits as
old_bits ensures we only toggle bits that have actually changed; using
0 as a baseline would result in redundant queries for settings already
synchronized during the probe sequence.

Thanks,
Jim

^ permalink raw reply

* Re: [PATCH net v2] ppp: defer channel free to an RCU grace period to fix pppol2tp RX UAF
From: Paul E. McKenney @ 2026-07-08 19:22 UTC (permalink / raw)
  To: Sebastian Andrzej Siewior
  Cc: Petr Pavlu, Luis Chamberlain, Daniel Gomez, Sami Tolvanen,
	Aaron Tomlin, linux-modules
In-Reply-To: <20260708190003.gmLFJGYo@linutronix.de>

On Wed, Jul 08, 2026 at 09:00:03PM +0200, Sebastian Andrzej Siewior wrote:
> -*, +module +Paul.
> 
> On 2026-07-08 15:56:53 [+0200], To Petr Pavlu wrote:
> > > One problem is that I'm not sure where the new rcu_barrier() call should
> > > be placed. The prototype adds it before calling the module's exit
> > > function. Would this actually fit all modules? From a quick look, I can
> > > see that various modules call it at different points during their exit.
> > 
> > I don't know why you would use call_rcu() in your module_exit()
> > (pointing to the same module). But you could have call_rcu() invoking
> > kmem_cache_free() and destroying that cache (kmem_cache_destroy()) in
> > your exit path. From that perspective it would make sense to flush all
> > calls before invoking module_exit().
> 
> Paul, are the RCU callbacks always invoked in FIFO order?
> If we put the module unmap into a call_rcu() (instead of the current
> synchronize_rcu()) would we invoke the callback's of the module's
> callback before the unmap of the module? Or is this not guaranteed due
> callbacks on CPU0 vs CPU1 are executed in different order?

The RCU-callback ordering guarantees are quite weak:

o	If callback A executes call_rcu() that queues callback B, then
	A will be invoked before B.  (Just in case anyone had any doubt.)

o	The callback queued prior to a call to rcu_barrier() will be
	invoked before any that are queued after return from that same
	call to rcu_barrier().

The current *implementation* orders callbacks queued on a given CPU
(but please see below), but there are absolutely no ordering guarantees
among CPUs.  To see why, consider the following sequence of events:

o	CPU 0 does an "rm -rf" of a large file tree containing huge
	numbers of small files.  This results in an RCU callback being
	queued for each file.

o	CPU 0 queues RCU callback A.

o	The corresponding grace period completes, so CPU 0 starts
	invoking callbacks.

o	CPU 1 queues RCU callback B.

o	The corresponding grace period completes, so CPU 1 invokes
	callback B.

o	Meanwhile, CPU 0 is still working off its RCU callback backlog.

o	CPU 0 eventually invokes RCU callback A

Worse yet, if we here in RCU-land ever allow a given CPU's callbacks
to be offloaded or deoffloaded while that CPU is online, then there
really will be cases where callbacks queued even by a single given CPU
get invoked out of order.  And there are people who would dearly love
us to make this happen.  (The last time we tried, we were inundated in
odd race conditions, but maybe we will come up with a better way.)

So these RCU callback ordering guarantees are probably too weak for your
use case.

> Because if the FIFO order is guaranteed then it would be cheapest
> solution. But it would require a rcu_barrier() in kmem_cache_destroy()).

What you maybe *could* do is to have the two RCU callbacks communicate,
so that the last one to be invoked did the work of both of them.  For
example, use a shared variable initialized to 2, then have each callback
do atomic_dec_and_test(), with the "winner" doing the work.

Would that do the trick?

							Thanx, Paul

^ permalink raw reply

* Re: [PATCH net v2] ppp: defer channel free to an RCU grace period to fix pppol2tp RX UAF
From: Sebastian Andrzej Siewior @ 2026-07-08 19:00 UTC (permalink / raw)
  To: Petr Pavlu
  Cc: Luis Chamberlain, Daniel Gomez, Sami Tolvanen, Aaron Tomlin,
	linux-modules, Paul E. McKenney
In-Reply-To: <20260708135651.zl03TEfr@linutronix.de>

-*, +module +Paul.

On 2026-07-08 15:56:53 [+0200], To Petr Pavlu wrote:
> > One problem is that I'm not sure where the new rcu_barrier() call should
> > be placed. The prototype adds it before calling the module's exit
> > function. Would this actually fit all modules? From a quick look, I can
> > see that various modules call it at different points during their exit.
> 
> I don't know why you would use call_rcu() in your module_exit()
> (pointing to the same module). But you could have call_rcu() invoking
> kmem_cache_free() and destroying that cache (kmem_cache_destroy()) in
> your exit path. From that perspective it would make sense to flush all
> calls before invoking module_exit().

Paul, are the RCU callbacks always invoked in FIFO order?
If we put the module unmap into a call_rcu() (instead of the current
synchronize_rcu()) would we invoke the callback's of the module's
callback before the unmap of the module? Or is this not guaranteed due
callbacks on CPU0 vs CPU1 are executed in different order?

Because if the FIFO order is guaranteed then it would be cheapest
solution. But it would require a rcu_barrier() in kmem_cache_destroy()).

> > -- 
> > Thanks,
> > Petr

Sebastian

^ permalink raw reply


This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox