diff --git a/include/libcamera/base/backtrace.h b/include/libcamera/base/backtrace.h
new file mode 100644
index 000000000000..aefc76defa83
--- /dev/null
+++ b/include/libcamera/base/backtrace.h
@@ -0,0 +1,34 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+/*
+ * Copyright (C) 2021, Ideas on Board Oy
+ *
+ * backtrace.h - Call stack backtraces
+ */
+#ifndef __LIBCAMERA_BASE_BACKTRACE_H__
+#define __LIBCAMERA_BASE_BACKTRACE_H__
+
+#include <string>
+#include <vector>
+
+#include <libcamera/base/private.h>
+
+#include <libcamera/base/class.h>
+
+namespace libcamera {
+
+class Backtrace
+{
+public:
+	Backtrace();
+
+	std::string toString(unsigned int skipLevels = 0) const;
+
+private:
+	LIBCAMERA_DISABLE_COPY(Backtrace)
+
+	std::vector<void *> backtrace_;
+};
+
+} /* namespace libcamera */
+
+#endif /* __LIBCAMERA_BASE_BACKTRACE_H__ */
diff --git a/include/libcamera/base/meson.build b/include/libcamera/base/meson.build
index 9feb4b9346d5..525aba9d2919 100644
--- a/include/libcamera/base/meson.build
+++ b/include/libcamera/base/meson.build
@@ -3,6 +3,7 @@
 libcamera_base_include_dir = libcamera_include_dir / 'base'
 
 libcamera_base_headers = files([
+    'backtrace.h',
     'bound_method.h',
     'class.h',
     'event_dispatcher.h',
diff --git a/meson.build b/meson.build
index a49c484fe64e..dfed01ba26bc 100644
--- a/meson.build
+++ b/meson.build
@@ -29,10 +29,6 @@ cc = meson.get_compiler('c')
 cxx = meson.get_compiler('cpp')
 config_h = configuration_data()
 
-if cc.has_header_symbol('execinfo.h', 'backtrace')
-    config_h.set('HAVE_BACKTRACE', 1)
-endif
-
 if cc.has_header_symbol('unistd.h', 'issetugid')
     config_h.set('HAVE_ISSETUGID', 1)
 endif
diff --git a/src/libcamera/base/backtrace.cpp b/src/libcamera/base/backtrace.cpp
new file mode 100644
index 000000000000..913f7ba71b03
--- /dev/null
+++ b/src/libcamera/base/backtrace.cpp
@@ -0,0 +1,107 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+/*
+ * Copyright (C) 2021, Ideas on Board Oy
+ *
+ * backtrace.h - Call stack backtraces
+ */
+
+#include <libcamera/base/backtrace.h>
+
+#if HAVE_BACKTRACE
+#include <execinfo.h>
+#include <stdlib.h>
+#endif
+
+#include <sstream>
+
+#include <libcamera/base/span.h>
+
+/**
+ * \file backtrace.h
+ * \brief Generate call stack backtraces
+ */
+
+namespace libcamera {
+
+/**
+ * \class Backtrace
+ * \brief Representation of a call stack backtrace
+ *
+ * The Backtrace class represents a function call stack. Constructing an
+ * instance captures the call stack at the point the instance is constructed.
+ * The instance can later be used to access the call stack and generate a
+ * human-readable representation with the toString() function.
+ *
+ * Depending on the platform, different backends can be used to generate the
+ * backtrace. The Backtrace class provides a best effort to capture accurate
+ * backtraces, but doesn't offer any guarantee of a particular backtrace format.
+ */
+
+/**
+ * \brief Construct a backtrace
+ *
+ * The backtrace captures the call stack at the point where it is constructed.
+ * It can later be converted to a string with toString().
+ */
+Backtrace::Backtrace()
+{
+#if HAVE_BACKTRACE
+	backtrace_.resize(32);
+
+	int num_entries = backtrace(backtrace_.data(), backtrace_.size());
+	if (num_entries < 0) {
+		backtrace_.clear();
+		return;
+	}
+
+	backtrace_.resize(num_entries);
+#endif
+}
+
+/**
+ * \brief Convert a backtrace to a string representation
+ * \param[in] skipLevels Number of initial levels to skip in the backtrace
+ *
+ * The string representation of the backtrace is a multi-line string, with one
+ * line per call stack entry. The format of the entries isn't specified and is
+ * platform-dependent.
+ *
+ * The \a skipLevels parameter indicates how many initial entries to skip from
+ * the backtrace. This can be used to hide functions that wrap the construction
+ * of the Backtrace instance from the call stack. The Backtrace constructor
+ * itself is automatically skipped and never shown in the backtrace.
+ *
+ * If backtrace generation fails for any reason (usually because the platform
+ * doesn't support this feature), an empty string is returned.
+ *
+ * \return A string representation of the backtrace, or an empty string if
+ * backtrace generation isn't possible
+ */
+std::string Backtrace::toString(unsigned int skipLevels) const
+{
+	/* Skip the first entry, corresponding to the Backtrace construction. */
+	skipLevels += 1;
+
+	if (backtrace_.size() <= skipLevels)
+		return std::string();
+
+#if HAVE_BACKTRACE
+	Span<void *const> trace{ backtrace_ };
+	trace = trace.subspan(skipLevels);
+
+	char **strings = backtrace_symbols(trace.data(), trace.size());
+	if (strings) {
+		std::ostringstream msg;
+
+		for (unsigned int i = 0; i < trace.size(); ++i)
+			msg << strings[i] << std::endl;
+
+		free(strings);
+		return msg.str();
+	}
+#endif
+
+	return std::string();
+}
+
+} /* namespace libcamera */
diff --git a/src/libcamera/base/log.cpp b/src/libcamera/base/log.cpp
index a3e3f9ea2712..272c440589d4 100644
--- a/src/libcamera/base/log.cpp
+++ b/src/libcamera/base/log.cpp
@@ -8,9 +8,6 @@
 #include <libcamera/base/log.h>
 
 #include <array>
-#if HAVE_BACKTRACE
-#include <execinfo.h>
-#endif
 #include <fstream>
 #include <iostream>
 #include <list>
@@ -23,6 +20,7 @@
 
 #include <libcamera/logging.h>
 
+#include <libcamera/base/backtrace.h>
 #include <libcamera/base/thread.h>
 #include <libcamera/base/utils.h>
 
@@ -418,31 +416,20 @@ void Logger::write(const LogMessage &msg)
  */
 void Logger::backtrace()
 {
-#if HAVE_BACKTRACE
 	std::shared_ptr<LogOutput> output = std::atomic_load(&output_);
 	if (!output)
 		return;
 
-	void *buffer[32];
-	int num_entries = ::backtrace(buffer, std::size(buffer));
-	char **strings = backtrace_symbols(buffer, num_entries);
-	if (!strings)
-		return;
-
-	std::ostringstream msg;
-	msg << "Backtrace:" << std::endl;
-
 	/*
 	 * Skip the first two entries that correspond to this function and
 	 * ~LogMessage().
 	 */
-	for (int i = 2; i < num_entries; ++i)
-		msg << strings[i] << std::endl;
+	std::string backtrace = Backtrace().toString(2);
+	if (backtrace.empty())
+		return;
 
-	output->write(msg.str());
-
-	free(strings);
-#endif
+	output->write("Backtrace:\n");
+	output->write(backtrace);
 }
 
 /**
diff --git a/src/libcamera/base/meson.build b/src/libcamera/base/meson.build
index 8e125744d823..85af01a19365 100644
--- a/src/libcamera/base/meson.build
+++ b/src/libcamera/base/meson.build
@@ -1,6 +1,7 @@
 # SPDX-License-Identifier: CC0-1.0
 
 libcamera_base_sources = files([
+    'backtrace.cpp',
     'class.cpp',
     'bound_method.cpp',
     'event_dispatcher.cpp',
@@ -18,6 +19,10 @@ libcamera_base_sources = files([
     'utils.cpp',
 ])
 
+if cc.has_header_symbol('execinfo.h', 'backtrace')
+    config_h.set('HAVE_BACKTRACE', 1)
+endif
+
 libcamera_base_deps = [
     dependency('threads'),
     libatomic,
