diff --git a/include/libcamera/internal/sysfs.h b/include/libcamera/internal/sysfs.h
index aca60fb6c8ab..1b30317c0dea 100644
--- a/include/libcamera/internal/sysfs.h
+++ b/include/libcamera/internal/sysfs.h
@@ -15,6 +15,9 @@ namespace sysfs {
 
 std::string charDevPath(const std::string &deviceNode);
 
+std::string devicePath(const std::string &deviceNode);
+std::string devicePath(unsigned int deviceMajor, unsigned int deviceMinor);
+
 std::string firmwareNodePath(const std::string &device);
 
 } /* namespace sysfs */
diff --git a/src/libcamera/sysfs.cpp b/src/libcamera/sysfs.cpp
index 3d9885b080c6..7c441a17f86d 100644
--- a/src/libcamera/sysfs.cpp
+++ b/src/libcamera/sysfs.cpp
@@ -9,6 +9,7 @@
 
 #include <fstream>
 #include <sstream>
+#include <stdlib.h>
 #include <sys/stat.h>
 #include <sys/sysmacros.h>
 
@@ -26,6 +27,26 @@ LOG_DEFINE_CATEGORY(SysFs)
 
 namespace sysfs {
 
+namespace {
+
+/*
+ * Resolve the "device" symbolic link of a sysfs character device directory to
+ * the sysfs path of the physical device associated with it.
+ */
+std::string resolveDevicePath(const std::string &charDevDir)
+{
+	char *realPath = realpath((charDevDir + "/device").c_str(), nullptr);
+	if (!realPath)
+		return {};
+
+	std::string path{ realPath };
+	free(realPath);
+
+	return path;
+}
+
+} /* namespace */
+
 /**
  * \brief Retrieve the sysfs path for a character device
  * \param[in] deviceNode Path to character device node
@@ -49,6 +70,46 @@ std::string charDevPath(const std::string &deviceNode)
 	return dev.str();
 }
 
+/**
+ * \brief Retrieve the sysfs path of the physical device for a character device
+ * \param[in] deviceNode Path to character device node
+ *
+ * Retrieve the sysfs path of the physical device associated with the character
+ * device \a deviceNode. The path is absolute and contains no symbolic link.
+ *
+ * \return The device path on success or an empty string on failure
+ */
+std::string devicePath(const std::string &deviceNode)
+{
+	std::string path = charDevPath(deviceNode);
+	if (path.empty())
+		return {};
+
+	return resolveDevicePath(path);
+}
+
+/**
+ * \brief Retrieve the sysfs path of the physical device for a character device
+ * \param[in] deviceMajor The character device major number
+ * \param[in] deviceMinor The character device minor number
+ *
+ * Retrieve the sysfs path of the physical device associated with the character
+ * device identified by \a deviceMajor and \a deviceMinor. The path is absolute
+ * and contains no symbolic link.
+ *
+ * Unlike the device node variant of this function, resolving the device from
+ * its device numbers requires no access to the device node itself.
+ *
+ * \return The device path on success or an empty string on failure
+ */
+std::string devicePath(unsigned int deviceMajor, unsigned int deviceMinor)
+{
+	std::ostringstream dev("/sys/dev/char/", std::ios_base::ate);
+	dev << deviceMajor << ":" << deviceMinor;
+
+	return resolveDevicePath(dev.str());
+}
+
 /**
  * \brief Retrieve the path of the firmware node for a device
  * \param[in] device Path in sysfs to search
diff --git a/src/libcamera/v4l2_device.cpp b/src/libcamera/v4l2_device.cpp
index 258c82b8d238..8b9c3a357149 100644
--- a/src/libcamera/v4l2_device.cpp
+++ b/src/libcamera/v4l2_device.cpp
@@ -454,17 +454,10 @@ const struct v4l2_query_ext_ctrl *V4L2Device::controlInfo(uint32_t id) const
  */
 std::string V4L2Device::devicePath() const
 {
-	std::string devicePath = sysfs::charDevPath(deviceNode_) + "/device";
-
-	char *realPath = realpath(devicePath.c_str(), nullptr);
-	if (!realPath) {
+	std::string path = sysfs::devicePath(deviceNode_);
+	if (path.empty())
 		LOG(V4L2, Fatal)
-			<< "Can not resolve device path for " << devicePath;
-		return {};
-	}
-
-	std::string path{ realPath };
-	free(realPath);
+			<< "Can not resolve device path for " << deviceNode_;
 
 	return path;
 }
