diff --git a/scripts/lib/devtool/ide_plugins/__init__.py b/scripts/lib/devtool/ide_plugins/__init__.py
index 7841bc37b9..1e1e32b74b 100644
--- a/scripts/lib/devtool/ide_plugins/__init__.py
+++ b/scripts/lib/devtool/ide_plugins/__init__.py
@@ -163,6 +163,18 @@ class DebuggerCrossConfig:
         """Called after construction to generate any required config files."""
         pass
 
+    def resolve_host_port(self, mode=None):
+        """Return the host port this debug server's target port is reachable on."""
+        target_port = self.port(mode)
+        if not is_loopback_target(self.debugger_cross.target_device.target):
+            return target_port
+        return self.image_recipe.slirp_port_mappings.get(('tcp', target_port), target_port)
+
+    def _resolve_host_port_shell_lines(self, var_name, mode=None):
+        """Shell line assigning $<var_name> to resolve_host_port(), for use in
+        a generated ide=none wrapper script."""
+        return ['%s=%d' % (var_name, self.resolve_host_port(mode))]
+
     # Abstract — subclasses must implement
     def _target_start_cmd(self, mode):
         raise NotImplementedError
diff --git a/scripts/lib/devtool/ide_plugins/ide_code.py b/scripts/lib/devtool/ide_plugins/ide_code.py
index 081dc08657..89013139ea 100644
--- a/scripts/lib/devtool/ide_plugins/ide_code.py
+++ b/scripts/lib/devtool/ide_plugins/ide_code.py
@@ -423,7 +423,7 @@ class IdeVSCode(IdeBase):
             "MIMode": "gdb",
             "preLaunchTask": cross_debug_config.id_pretty_mode(server_mode),
             "miDebuggerPath": modified_recipe.debugger_cross.gdb,
-            "miDebuggerServerAddress": "%s:%d" % (modified_recipe.debugger_cross.host, cross_debug_config.port(server_mode))
+            "miDebuggerServerAddress": "%s:%d" % (modified_recipe.debugger_cross.host, cross_debug_config.resolve_host_port(server_mode))
         }
 
         if is_attach:
@@ -530,7 +530,7 @@ class IdeVSCode(IdeBase):
 
         init_commands = [
             "platform select remote-linux",
-            "platform connect connect://%s:%d" % (debugger_cross.host, lldb_config.port(server_mode)),
+            "platform connect connect://%s:%d" % (debugger_cross.host, lldb_config.resolve_host_port(server_mode)),
             # Clear the default step-avoid-regexp so std:: and other library
             # namespaces are not silently skipped on step-in. (default is "std::" in LLDB 15+)
             "settings set target.process.thread.step-avoid-regexp \"\"",
diff --git a/scripts/lib/devtool/ide_plugins/ide_none.py b/scripts/lib/devtool/ide_plugins/ide_none.py
index eba576bfdb..d5fed0809e 100644
--- a/scripts/lib/devtool/ide_plugins/ide_none.py
+++ b/scripts/lib/devtool/ide_plugins/ide_none.py
@@ -60,17 +60,14 @@ class GdbCrossConfigNone(GdbCrossConfig):
         gdbinit_lines = ['# This file is generated by devtool ide-sdk']
         if server_mode == DebuggerServerModes.MULTI:
             target_help = '#   gdbserver --multi :%d' % self.debug_server_port
-            remote_cmd = 'target extended-remote'
         else:
             target_help = '#   gdbserver :%d %s' % (
                 self.debug_server_port, self.binary)
-            remote_cmd = 'target remote'
         gdbinit_lines.append('# On the remote target:')
         gdbinit_lines.append(target_help)
         gdbinit_lines.append('# On the build machine:')
         gdbinit_lines.append('#   cd ' + self.modified_recipe.real_srctree)
-        gdbinit_lines.append(
-            '#   ' + self.debugger_cross.gdb + ' -ix ' + self.gdbinit)
+        gdbinit_lines.append('#   ' + self.gdb_script)
         gdbinit_lines.append('set sysroot ' + self.modified_recipe.d)
 
         if self.image_recipe.rootfs_dbg:
@@ -114,18 +111,25 @@ class GdbCrossConfigNone(GdbCrossConfig):
             gdbinit_lines.append("end" + os.linesep)
 
         gdbinit_lines.append(
-            '%s %s:%d' % (remote_cmd, self.debugger_cross.host, self.debug_server_port))
-        gdbinit_lines.append('set remote exec-file ' + self.binary.binary_path)
-        gdbinit_lines.append('run ' + self.binary.binary_path)
+            'set remote exec-file ' + self.binary.binary_path)
 
         GdbCrossConfigNone.write_file(self.gdbinit, gdbinit_lines)
 
-    def _gen_gdb_start_script(self):
+    def _gen_gdb_start_script(self, server_mode=None):
         """Generate a script starting GDB with the corresponding gdbinit configuration."""
+        if server_mode is None:
+            server_mode = self.default_mode
+        remote_cmd = ('target extended-remote'
+                      if server_mode == DebuggerServerModes.MULTI
+                      else 'target remote')
         cmd_lines = ['#!/bin/sh']
         cmd_lines.append('cd ' + self.modified_recipe.real_srctree)
-        cmd_lines.append(self.debugger_cross.gdb + ' -ix ' +
-                         self.gdbinit + ' "$@"')
+        cmd_lines += self._resolve_host_port_shell_lines('GDB_PORT', server_mode)
+        gdb_cmd = self.debugger_cross.gdb + ' -ix ' + self.gdbinit
+        gdb_cmd += " -ex '%s %s:'\"$GDB_PORT\"" % (remote_cmd, self.debugger_cross.host)
+        gdb_cmd += " -ex 'run %s'" % self.binary.binary_path
+        gdb_cmd += ' "$@"'
+        cmd_lines.append(gdb_cmd)
         GdbCrossConfigNone.write_file(self.gdb_script, cmd_lines, True)
 
     def initialize(self):
@@ -210,10 +214,8 @@ class LldbServerConfigNone(LldbServerConfig):
             self.port(server_mode), self.gdbserver_ports[server_mode]))
         lines.append('# On the build machine:')
         lines.append('#   cd ' + self.modified_recipe.real_srctree)
-        lines.append('#   ' + self.debugger_cross.lldb + ' -s ' + self.lldbinit)
+        lines.append('#   ' + self.lldb_script)
         lines.append('platform select remote-linux')
-        lines.append('platform connect connect://%s:%d' % (
-            self.debugger_cross.host, self.port(server_mode)))
         lines.append('settings set target.process.thread.step-avoid-regexp ""')
 
         if self.image_recipe.rootfs_dbg:
@@ -248,16 +250,21 @@ class LldbServerConfigNone(LldbServerConfig):
                 'Cannot setup debug symbols configuration for LLDB. '
                 'IMAGE_GEN_DEBUGFS is not enabled.')
 
-        lines.append('target create --remote-file %s %s' % (
-            self.binary.binary_path, self.binary.binary_host_path))
-
         LldbServerConfigNone.write_file(self.lldbinit, lines)
 
-    def _gen_lldb_start_script(self):
+    def _gen_lldb_start_script(self, server_mode=None):
         """Generate a script starting lldb with the lldbinit configuration."""
+        if server_mode is None:
+            server_mode = self.default_mode
         cmd_lines = ['#!/bin/sh']
         cmd_lines.append('cd ' + self.modified_recipe.real_srctree)
-        cmd_lines.append(self.debugger_cross.lldb + ' -s ' + self.lldbinit + ' "$@"')
+        cmd_lines += self._resolve_host_port_shell_lines('LLDB_PORT', server_mode)
+        lldb_cmd = self.debugger_cross.lldb + ' -s ' + self.lldbinit
+        lldb_cmd += " -o 'platform connect connect://%s:'\"$LLDB_PORT\"" % self.debugger_cross.host
+        lldb_cmd += " -o 'target create --remote-file %s %s'" % (
+            self.binary.binary_path, self.binary.binary_host_path)
+        lldb_cmd += ' "$@"'
+        cmd_lines.append(lldb_cmd)
         LldbServerConfigNone.write_file(self.lldb_script, cmd_lines, True)
 
     def initialize(self):
diff --git a/scripts/lib/devtool/ide_sdk.py b/scripts/lib/devtool/ide_sdk.py
index 9faf041957..65f6e02675 100755
--- a/scripts/lib/devtool/ide_sdk.py
+++ b/scripts/lib/devtool/ide_sdk.py
@@ -306,6 +306,24 @@ class RecipeImage:
     def debug_support(self):
         return bool(self.rootfs_dbg)
 
+    @property
+    def slirp_port_mappings(self):
+        """QB_SLIRP_OPT's hostfwd rules as {(protocol, target_port): host_port}.
+
+        QB_SLIRP_OPT is only a wishlist: runqemu moves a forward to another
+        host port whenever qemu cannot bind the wished-for one, so host
+        port == target port cannot be assumed. A caller that knows the
+        effective mapping of a running qemu (see QemuRunner.slirp_port_mappings)
+        can write it back into the bbappend and re-run devtool ide-sdk
+        --skip-bitbake to have the debugger configs regenerated against the
+        ports that really work.
+        """
+        mappings = {}
+        for content in (self.qb_slirp_opt, self._orig_bbappend_content or ''):
+            for m in re.finditer(r'hostfwd=([a-z]+):[^:]*:(\d+)-[^:,]*:(\d+)', content):
+                mappings[(m.group(1), int(m.group(3)))] = int(m.group(2))
+        return mappings
+
     @property
     def rootfs(self):
         """Prefer the live NFS-exported rootfs (if --nfs=rootfs is used) over the
@@ -548,28 +566,39 @@ class RecipeImage:
         if not ports:
             return False
 
-        # Determine which ports are already in the base value
-        already = {int(m.group(1))
-                   for m in re.finditer(r':(\d+)-:\d+', self.qb_slirp_opt)}
-        missing_ports = [p for p in ports if p not in already]
+        # Determine which ports are already in the base value. Match the
+        # target port, not the host port: once the effective mapping is
+        # written back the two differ, and a forward already exists for a
+        # target port regardless of which host port it ended up on.
+        already = {(m.group(1), int(m.group(2)))
+               for m in re.finditer(r'hostfwd=([a-z]+):[^:]*:\d+-[^:,]*:(\d+)', self.qb_slirp_opt)}
+        missing_ports = [p for p in ports if ('tcp', p) not in already]
         if not missing_ports:
             logger.info("QB_SLIRP_OPT already contains all needed port forwards")
             return False
 
+        # Keep any already known effective mapping instead of wishing for
+        # host port == target port again, so re-running does not discard a
+        # mapping written back from a running qemu (see slirp_port_mappings).
+        mappings = self.slirp_port_mappings
+
+        def hostfwd(target_port):
+            return ',hostfwd=tcp:127.0.0.1:%d-:%d' % (
+                mappings.get(('tcp', target_port), target_port), target_port)
+
         if self.qb_slirp_opt:
             # Base value exists: :append only the missing port forwards
-            extra = ''.join(
-                ',hostfwd=tcp:127.0.0.1:%d-:%d' % (p, p) for p in missing_ports)
+            extra = ''.join(hostfwd(p) for p in missing_ports)
             new_line = 'QB_SLIRP_OPT:append = "%s"' % extra
         else:
             # No base value: mirror runqemu's built-in default (SSH 2222, telnet
             # 2323, tftp) and add the debugger ports.
             all_hostfwds = (
-                'hostfwd=tcp:127.0.0.1:2222-:22,'
-                'hostfwd=tcp:127.0.0.1:2323-:23'
+                'hostfwd=tcp:127.0.0.1:%d-:22,'
+                'hostfwd=tcp:127.0.0.1:%d-:23'
+                % (mappings.get(('tcp', 22), 2222), mappings.get(('tcp', 23), 2323))
             )
-            all_hostfwds += ''.join(
-                ',hostfwd=tcp:127.0.0.1:%d-:%d' % (p, p) for p in missing_ports)
+            all_hostfwds += ''.join(hostfwd(p) for p in missing_ports)
             new_line = 'QB_SLIRP_OPT = "-netdev user,id=net0,%s,tftp=${DEPLOY_DIR_IMAGE}"' % all_hostfwds
 
         if os.path.exists(self._bbappend):
