diff --git a/src/common/settings.cpp b/src/common/settings.cpp
index 9a9c74a70..6ffab63af 100644
--- a/src/common/settings.cpp
+++ b/src/common/settings.cpp
@@ -70,6 +70,7 @@ void LogSettings() {
     log_path("DataStorage_NANDDir", Common::FS::GetYuzuPath(Common::FS::YuzuPath::NANDDir));
     log_path("DataStorage_SDMCDir", Common::FS::GetYuzuPath(Common::FS::YuzuPath::SDMCDir));
     log_setting("Debugging_ProgramArgs", values.program_args.GetValue());
+    log_setting("Debugging_GDBStub", values.use_gdbstub.GetValue());
     log_setting("Input_EnableMotion", values.motion_enabled.GetValue());
     log_setting("Input_EnableVibration", values.vibration_enabled.GetValue());
     log_setting("Input_EnableRawInput", values.enable_raw_input.GetValue());
diff --git a/src/common/settings.h b/src/common/settings.h
index e61d9cd7f..a7bbfb0da 100644
--- a/src/common/settings.h
+++ b/src/common/settings.h
@@ -601,7 +601,7 @@ struct Values {
     // Debugging
     bool record_frame_times;
     BasicSetting<bool> use_gdbstub{false, "use_gdbstub"};
-    BasicSetting<u16> gdbstub_port{0, "gdbstub_port"};
+    BasicSetting<u16> gdbstub_port{6543, "gdbstub_port"};
     BasicSetting<std::string> program_args{std::string(), "program_args"};
     BasicSetting<bool> dump_exefs{false, "dump_exefs"};
     BasicSetting<bool> dump_nso{false, "dump_nso"};
diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt
index 62230bae0..948cc318a 100644
--- a/src/core/CMakeLists.txt
+++ b/src/core/CMakeLists.txt
@@ -36,6 +36,13 @@ add_library(core STATIC
     crypto/ctr_encryption_layer.h
     crypto/xts_encryption_layer.cpp
     crypto/xts_encryption_layer.h
+    debugger/debugger_interface.h
+    debugger/debugger.cpp
+    debugger/debugger.h
+    debugger/gdbstub_arch.cpp
+    debugger/gdbstub_arch.h
+    debugger/gdbstub.cpp
+    debugger/gdbstub.h
     device_memory.cpp
     device_memory.h
     file_sys/bis_factory.cpp
diff --git a/src/core/arm/arm_interface.cpp b/src/core/arm/arm_interface.cpp
index c347e7ea7..1310f72bf 100644
--- a/src/core/arm/arm_interface.cpp
+++ b/src/core/arm/arm_interface.cpp
@@ -9,6 +9,7 @@
 #include "core/arm/arm_interface.h"
 #include "core/arm/symbols.h"
 #include "core/core.h"
+#include "core/debugger/debugger.h"
 #include "core/hle/kernel/k_process.h"
 #include "core/loader/loader.h"
 #include "core/memory.h"
@@ -88,4 +89,8 @@ void ARM_Interface::LogBacktrace() const {
     }
 }
 
+bool ARM_Interface::ShouldStep() const {
+    return system.DebuggerEnabled() && system.GetDebugger().IsStepping();
+}
+
 } // namespace Core
diff --git a/src/core/arm/arm_interface.h b/src/core/arm/arm_interface.h
index 8ce973a77..7842c626b 100644
--- a/src/core/arm/arm_interface.h
+++ b/src/core/arm/arm_interface.h
@@ -66,9 +66,6 @@ public:
     /// Runs the CPU until an event happens
     virtual void Run() = 0;
 
-    /// Step CPU by one instruction
-    virtual void Step() = 0;
-
     /// Clear all instruction cache
     virtual void ClearInstructionCache() = 0;
 
@@ -194,6 +191,8 @@ public:
 
     void LogBacktrace() const;
 
+    bool ShouldStep() const;
+
 protected:
     /// System context that this ARM interface is running under.
     System& system;
diff --git a/src/core/arm/dynarmic/arm_dynarmic_32.cpp b/src/core/arm/dynarmic/arm_dynarmic_32.cpp
index 781a77f6f..894c1c527 100644
--- a/src/core/arm/dynarmic/arm_dynarmic_32.cpp
+++ b/src/core/arm/dynarmic/arm_dynarmic_32.cpp
@@ -17,6 +17,8 @@
 #include "core/arm/dynarmic/arm_exclusive_monitor.h"
 #include "core/core.h"
 #include "core/core_timing.h"
+#include "core/debugger/debugger.h"
+#include "core/hle/kernel/k_process.h"
 #include "core/hle/kernel/svc.h"
 #include "core/memory.h"
 
@@ -26,6 +28,7 @@ using namespace Common::Literals;
 
 constexpr Dynarmic::HaltReason break_loop = Dynarmic::HaltReason::UserDefined2;
 constexpr Dynarmic::HaltReason svc_call = Dynarmic::HaltReason::UserDefined3;
+constexpr Dynarmic::HaltReason breakpoint = Dynarmic::HaltReason::UserDefined4;
 
 class DynarmicCallbacks32 : public Dynarmic::A32::UserCallbacks {
 public:
@@ -78,11 +81,16 @@ public:
     }
 
     void ExceptionRaised(u32 pc, Dynarmic::A32::Exception exception) override {
+        if (parent.system.DebuggerEnabled()) {
+            parent.breakpoint_pc = pc;
+            parent.jit.load()->HaltExecution(breakpoint);
+            return;
+        }
+
         parent.LogBacktrace();
         LOG_CRITICAL(Core_ARM,
                      "ExceptionRaised(exception = {}, pc = {:08X}, code = {:08X}, thumb = {})",
                      exception, pc, MemoryReadCode(pc), parent.IsInThumbMode());
-        UNIMPLEMENTED();
     }
 
     void CallSVC(u32 swi) override {
@@ -234,20 +242,35 @@ std::shared_ptr<Dynarmic::A32::Jit> ARM_Dynarmic_32::MakeJit(Common::PageTable*
 
 void ARM_Dynarmic_32::Run() {
     while (true) {
-        const auto hr = jit.load()->Run();
+        const auto hr = ShouldStep() ? jit.load()->Step() : jit.load()->Run();
         if (Has(hr, svc_call)) {
             Kernel::Svc::Call(system, svc_swi);
         }
+
+        // Check to see if breakpoint is triggered.
+        // Recheck step condition in case stop is no longer desired.
+        Kernel::KThread* current_thread = system.Kernel().GetCurrentEmuThread();
+        if (Has(hr, breakpoint)) {
+            jit.load()->Regs()[15] = breakpoint_pc;
+
+            if (system.GetDebugger().NotifyThreadStopped(current_thread)) {
+                current_thread->RequestSuspend(Kernel::SuspendType::Debug);
+            }
+            break;
+        }
+        if (ShouldStep()) {
+            // When stepping, this should be the only thread running.
+            ASSERT(system.GetDebugger().NotifyThreadStopped(current_thread));
+            current_thread->RequestSuspend(Kernel::SuspendType::Debug);
+            break;
+        }
+
         if (Has(hr, break_loop) || !uses_wall_clock) {
             break;
         }
     }
 }
 
-void ARM_Dynarmic_32::Step() {
-    jit.load()->Step();
-}
-
 ARM_Dynarmic_32::ARM_Dynarmic_32(System& system_, CPUInterrupts& interrupt_handlers_,
                                  bool uses_wall_clock_, ExclusiveMonitor& exclusive_monitor_,
                                  std::size_t core_index_)
diff --git a/src/core/arm/dynarmic/arm_dynarmic_32.h b/src/core/arm/dynarmic/arm_dynarmic_32.h
index abfe76644..0557d5940 100644
--- a/src/core/arm/dynarmic/arm_dynarmic_32.h
+++ b/src/core/arm/dynarmic/arm_dynarmic_32.h
@@ -42,7 +42,6 @@ public:
     u32 GetPSTATE() const override;
     void SetPSTATE(u32 pstate) override;
     void Run() override;
-    void Step() override;
     VAddr GetTlsAddress() const override;
     void SetTlsAddress(VAddr address) override;
     void SetTPIDR_EL0(u64 value) override;
@@ -95,6 +94,9 @@ private:
 
     // SVC callback
     u32 svc_swi{};
+
+    // Debug restart address
+    u32 breakpoint_pc{};
 };
 
 } // namespace Core
diff --git a/src/core/arm/dynarmic/arm_dynarmic_64.cpp b/src/core/arm/dynarmic/arm_dynarmic_64.cpp
index 1b1334598..1f596cfef 100644
--- a/src/core/arm/dynarmic/arm_dynarmic_64.cpp
+++ b/src/core/arm/dynarmic/arm_dynarmic_64.cpp
@@ -15,6 +15,7 @@
 #include "core/arm/dynarmic/arm_exclusive_monitor.h"
 #include "core/core.h"
 #include "core/core_timing.h"
+#include "core/debugger/debugger.h"
 #include "core/hardware_properties.h"
 #include "core/hle/kernel/k_process.h"
 #include "core/hle/kernel/svc.h"
@@ -27,6 +28,7 @@ using namespace Common::Literals;
 
 constexpr Dynarmic::HaltReason break_loop = Dynarmic::HaltReason::UserDefined2;
 constexpr Dynarmic::HaltReason svc_call = Dynarmic::HaltReason::UserDefined3;
+constexpr Dynarmic::HaltReason breakpoint = Dynarmic::HaltReason::UserDefined4;
 
 class DynarmicCallbacks64 : public Dynarmic::A64::UserCallbacks {
 public:
@@ -119,8 +121,13 @@ public:
         case Dynarmic::A64::Exception::SendEventLocal:
         case Dynarmic::A64::Exception::Yield:
             return;
-        case Dynarmic::A64::Exception::Breakpoint:
         default:
+            if (parent.system.DebuggerEnabled()) {
+                parent.breakpoint_pc = pc;
+                parent.jit.load()->HaltExecution(breakpoint);
+                return;
+            }
+
             parent.LogBacktrace();
             ASSERT_MSG(false, "ExceptionRaised(exception = {}, pc = {:08X}, code = {:08X})",
                        static_cast<std::size_t>(exception), pc, MemoryReadCode(pc));
@@ -299,16 +306,31 @@ void ARM_Dynarmic_64::Run() {
         if (Has(hr, svc_call)) {
             Kernel::Svc::Call(system, svc_swi);
         }
+
+        // Check to see if breakpoint is triggered.
+        // Recheck step condition in case stop is no longer desired.
+        Kernel::KThread* current_thread = system.Kernel().GetCurrentEmuThread();
+        if (Has(hr, breakpoint)) {
+            jit.load()->SetPC(breakpoint_pc);
+
+            if (system.GetDebugger().NotifyThreadStopped(current_thread)) {
+                current_thread->RequestSuspend(Kernel::SuspendType::Debug);
+            }
+            break;
+        }
+        if (ShouldStep()) {
+            // When stepping, this should be the only thread running.
+            ASSERT(system.GetDebugger().NotifyThreadStopped(current_thread));
+            current_thread->RequestSuspend(Kernel::SuspendType::Debug);
+            break;
+        }
+
         if (Has(hr, break_loop) || !uses_wall_clock) {
             break;
         }
     }
 }
 
-void ARM_Dynarmic_64::Step() {
-    jit.load()->Step();
-}
-
 ARM_Dynarmic_64::ARM_Dynarmic_64(System& system_, CPUInterrupts& interrupt_handlers_,
                                  bool uses_wall_clock_, ExclusiveMonitor& exclusive_monitor_,
                                  std::size_t core_index_)
diff --git a/src/core/arm/dynarmic/arm_dynarmic_64.h b/src/core/arm/dynarmic/arm_dynarmic_64.h
index 01a7e4dad..aa7054e0c 100644
--- a/src/core/arm/dynarmic/arm_dynarmic_64.h
+++ b/src/core/arm/dynarmic/arm_dynarmic_64.h
@@ -40,7 +40,6 @@ public:
     u32 GetPSTATE() const override;
     void SetPSTATE(u32 pstate) override;
     void Run() override;
-    void Step() override;
     VAddr GetTlsAddress() const override;
     void SetTlsAddress(VAddr address) override;
     void SetTPIDR_EL0(u64 value) override;
@@ -88,6 +87,9 @@ private:
 
     // SVC callback
     u32 svc_swi{};
+
+    // Debug restart address
+    u64 breakpoint_pc{};
 };
 
 } // namespace Core
diff --git a/src/core/core.cpp b/src/core/core.cpp
index 8a887904d..7d974ba65 100644
--- a/src/core/core.cpp
+++ b/src/core/core.cpp
@@ -17,6 +17,7 @@
 #include "core/core.h"
 #include "core/core_timing.h"
 #include "core/cpu_manager.h"
+#include "core/debugger/debugger.h"
 #include "core/device_memory.h"
 #include "core/file_sys/bis_factory.h"
 #include "core/file_sys/mode.h"
@@ -171,6 +172,10 @@ struct System::Impl {
         }
     }
 
+    void InitializeDebugger(System& system, u16 port) {
+        debugger = std::make_unique<Debugger>(system, port);
+    }
+
     SystemResultStatus Init(System& system, Frontend::EmuWindow& emu_window) {
         LOG_DEBUG(Core, "initialized OK");
 
@@ -329,6 +334,7 @@ struct System::Impl {
             gpu_core->NotifyShutdown();
         }
 
+        debugger.reset();
         services.reset();
         service_manager.reset();
         cheat_engine.reset();
@@ -436,6 +442,9 @@ struct System::Impl {
     /// Network instance
     Network::NetworkInstance network_instance;
 
+    /// Debugger
+    std::unique_ptr<Core::Debugger> debugger;
+
     SystemResultStatus status = SystemResultStatus::Success;
     std::string status_details = "";
 
@@ -472,10 +481,6 @@ SystemResultStatus System::Pause() {
     return impl->Pause();
 }
 
-SystemResultStatus System::SingleStep() {
-    return SystemResultStatus::Success;
-}
-
 void System::InvalidateCpuInstructionCaches() {
     impl->kernel.InvalidateAllInstructionCaches();
 }
@@ -496,6 +501,10 @@ void System::UnstallCPU() {
     impl->UnstallCPU();
 }
 
+void System::InitializeDebugger() {
+    impl->InitializeDebugger(*this, Settings::values.gdbstub_port.GetValue());
+}
+
 SystemResultStatus System::Load(Frontend::EmuWindow& emu_window, const std::string& filepath,
                                 u64 program_id, std::size_t program_index) {
     return impl->Load(*this, emu_window, filepath, program_id, program_index);
@@ -809,6 +818,18 @@ bool System::IsMulticore() const {
     return impl->is_multicore;
 }
 
+bool System::DebuggerEnabled() const {
+    return Settings::values.use_gdbstub.GetValue();
+}
+
+Core::Debugger& System::GetDebugger() {
+    return *impl->debugger;
+}
+
+const Core::Debugger& System::GetDebugger() const {
+    return *impl->debugger;
+}
+
 void System::RegisterExecuteProgramCallback(ExecuteProgramCallback&& callback) {
     impl->execute_program_callback = std::move(callback);
 }
diff --git a/src/core/core.h b/src/core/core.h
index 4a0c7dc84..94477206e 100644
--- a/src/core/core.h
+++ b/src/core/core.h
@@ -97,6 +97,7 @@ namespace Core {
 
 class ARM_Interface;
 class CpuManager;
+class Debugger;
 class DeviceMemory;
 class ExclusiveMonitor;
 class SpeedLimiter;
@@ -147,12 +148,6 @@ public:
      */
     [[nodiscard]] SystemResultStatus Pause();
 
-    /**
-     * Step the CPU one instruction
-     * @return Result status, indicating whether or not the operation succeeded.
-     */
-    [[nodiscard]] SystemResultStatus SingleStep();
-
     /**
      * Invalidate the CPU instruction caches
      * This function should only be used by GDB Stub to support breakpoints, memory updates and
@@ -168,6 +163,11 @@ public:
     std::unique_lock<std::mutex> StallCPU();
     void UnstallCPU();
 
+    /**
+     * Initialize the debugger.
+     */
+    void InitializeDebugger();
+
     /**
      * Load an executable application.
      * @param emu_window Reference to the host-system window used for video output and keyboard
@@ -354,6 +354,9 @@ public:
     [[nodiscard]] Service::Time::TimeManager& GetTimeManager();
     [[nodiscard]] const Service::Time::TimeManager& GetTimeManager() const;
 
+    [[nodiscard]] Core::Debugger& GetDebugger();
+    [[nodiscard]] const Core::Debugger& GetDebugger() const;
+
     void SetExitLock(bool locked);
     [[nodiscard]] bool GetExitLock() const;
 
@@ -375,6 +378,9 @@ public:
     /// Tells if system is running on multicore.
     [[nodiscard]] bool IsMulticore() const;
 
+    /// Tells if the system debugger is enabled.
+    [[nodiscard]] bool DebuggerEnabled() const;
+
     /// Type used for the frontend to designate a callback for System to re-launch the application
     /// using a specified program index.
     using ExecuteProgramCallback = std::function<void(std::size_t)>;
diff --git a/src/core/debugger/debugger.cpp b/src/core/debugger/debugger.cpp
new file mode 100644
index 000000000..7a2012d3c
--- /dev/null
+++ b/src/core/debugger/debugger.cpp
@@ -0,0 +1,259 @@
+// SPDX-FileCopyrightText: Copyright 2022 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#include <mutex>
+#include <thread>
+
+#include <boost/asio.hpp>
+#include <boost/process/async_pipe.hpp>
+
+#include "common/logging/log.h"
+#include "common/thread.h"
+#include "core/core.h"
+#include "core/debugger/debugger.h"
+#include "core/debugger/debugger_interface.h"
+#include "core/debugger/gdbstub.h"
+#include "core/hle/kernel/global_scheduler_context.h"
+
+template <typename Readable, typename Buffer, typename Callback>
+static void AsyncReceiveInto(Readable& r, Buffer& buffer, Callback&& c) {
+    static_assert(std::is_trivial_v<Buffer>);
+    auto boost_buffer{boost::asio::buffer(&buffer, sizeof(Buffer))};
+    r.async_read_some(boost_buffer, [&](const boost::system::error_code& error, size_t bytes_read) {
+        if (!error.failed()) {
+            const u8* buffer_start = reinterpret_cast<const u8*>(&buffer);
+            std::span<const u8> received_data{buffer_start, buffer_start + bytes_read};
+            c(received_data);
+        }
+
+        AsyncReceiveInto(r, buffer, c);
+    });
+}
+
+template <typename Readable, typename Buffer>
+static std::span<const u8> ReceiveInto(Readable& r, Buffer& buffer) {
+    static_assert(std::is_trivial_v<Buffer>);
+    auto boost_buffer{boost::asio::buffer(&buffer, sizeof(Buffer))};
+    size_t bytes_read = r.read_some(boost_buffer);
+    const u8* buffer_start = reinterpret_cast<const u8*>(&buffer);
+    std::span<const u8> received_data{buffer_start, buffer_start + bytes_read};
+    return received_data;
+}
+
+namespace Core {
+
+class DebuggerImpl : public DebuggerBackend {
+public:
+    explicit DebuggerImpl(Core::System& system_, u16 port)
+        : system{system_}, signal_pipe{io_context}, client_socket{io_context} {
+        frontend = std::make_unique<GDBStub>(*this, system);
+        InitializeServer(port);
+    }
+
+    ~DebuggerImpl() {
+        ShutdownServer();
+    }
+
+    bool NotifyThreadStopped(Kernel::KThread* thread) {
+        std::scoped_lock lk{connection_lock};
+
+        if (stopped) {
+            // Do not notify the debugger about another event.
+            // It should be ignored.
+            return false;
+        }
+        stopped = true;
+
+        signal_pipe.write_some(boost::asio::buffer(&thread, sizeof(thread)));
+        return true;
+    }
+
+    std::span<const u8> ReadFromClient() override {
+        return ReceiveInto(client_socket, client_data);
+    }
+
+    void WriteToClient(std::span<const u8> data) override {
+        client_socket.write_some(boost::asio::buffer(data.data(), data.size_bytes()));
+    }
+
+    void SetActiveThread(Kernel::KThread* thread) override {
+        active_thread = thread;
+    }
+
+    Kernel::KThread* GetActiveThread() override {
+        return active_thread;
+    }
+
+    bool IsStepping() const {
+        return stepping;
+    }
+
+private:
+    void InitializeServer(u16 port) {
+        using boost::asio::ip::tcp;
+
+        LOG_INFO(Debug_GDBStub, "Starting server on port {}...", port);
+
+        // Initialize the listening socket and accept a new client.
+        tcp::endpoint endpoint{boost::asio::ip::address_v4::loopback(), port};
+        tcp::acceptor acceptor{io_context, endpoint};
+        client_socket = acceptor.accept();
+
+        // Run the connection thread.
+        connection_thread = std::jthread([&](std::stop_token stop_token) {
+            try {
+                ThreadLoop(stop_token);
+            } catch (const std::exception& ex) {
+                LOG_CRITICAL(Debug_GDBStub, "Stopping server: {}", ex.what());
+            }
+
+            client_socket.shutdown(client_socket.shutdown_both);
+            client_socket.close();
+        });
+    }
+
+    void ShutdownServer() {
+        connection_thread.request_stop();
+        io_context.stop();
+        connection_thread.join();
+    }
+
+    void ThreadLoop(std::stop_token stop_token) {
+        Common::SetCurrentThreadName("yuzu:Debugger");
+
+        // Set up the client signals for new data.
+        AsyncReceiveInto(signal_pipe, active_thread, [&](auto d) { PipeData(d); });
+        AsyncReceiveInto(client_socket, client_data, [&](auto d) { ClientData(d); });
+
+        // Stop the emulated CPU.
+        AllCoreStop();
+
+        // Set the active thread.
+        active_thread = ThreadList()[0];
+        active_thread->Resume(Kernel::SuspendType::Debug);
+
+        // Set up the frontend.
+        frontend->Connected();
+
+        // Main event loop.
+        while (!stop_token.stop_requested() && io_context.run()) {
+        }
+    }
+
+    void PipeData(std::span<const u8> data) {
+        AllCoreStop();
+        active_thread->Resume(Kernel::SuspendType::Debug);
+        frontend->Stopped(active_thread);
+    }
+
+    void ClientData(std::span<const u8> data) {
+        const auto actions{frontend->ClientData(data)};
+        for (const auto action : actions) {
+            switch (action) {
+            case DebuggerAction::Interrupt: {
+                {
+                    std::scoped_lock lk{connection_lock};
+                    stopped = true;
+                }
+                AllCoreStop();
+                active_thread = ThreadList()[0];
+                active_thread->Resume(Kernel::SuspendType::Debug);
+                frontend->Stopped(active_thread);
+                break;
+            }
+            case DebuggerAction::Continue:
+                stepping = false;
+                ResumeInactiveThreads();
+                AllCoreResume();
+                break;
+            case DebuggerAction::StepThread:
+                stepping = true;
+                SuspendInactiveThreads();
+                AllCoreResume();
+                break;
+            case DebuggerAction::ShutdownEmulation: {
+                // Suspend all threads and release any locks held
+                active_thread->RequestSuspend(Kernel::SuspendType::Debug);
+                SuspendInactiveThreads();
+                AllCoreResume();
+
+                // Spawn another thread that will exit after shutdown,
+                // to avoid a deadlock
+                Core::System* system_ref{&system};
+                std::thread t([system_ref] { system_ref->Exit(); });
+                t.detach();
+                break;
+            }
+            }
+        }
+    }
+
+    void AllCoreStop() {
+        if (!suspend) {
+            suspend = system.StallCPU();
+        }
+    }
+
+    void AllCoreResume() {
+        stopped = false;
+        system.UnstallCPU();
+        suspend.reset();
+    }
+
+    void SuspendInactiveThreads() {
+        for (auto* thread : ThreadList()) {
+            if (thread != active_thread) {
+                thread->RequestSuspend(Kernel::SuspendType::Debug);
+            }
+        }
+    }
+
+    void ResumeInactiveThreads() {
+        for (auto* thread : ThreadList()) {
+            if (thread != active_thread) {
+                thread->Resume(Kernel::SuspendType::Debug);
+            }
+        }
+    }
+
+    const std::vector<Kernel::KThread*>& ThreadList() {
+        return system.GlobalSchedulerContext().GetThreadList();
+    }
+
+private:
+    System& system;
+    std::unique_ptr<DebuggerFrontend> frontend;
+
+    std::jthread connection_thread;
+    std::mutex connection_lock;
+    boost::asio::io_context io_context;
+    boost::process::async_pipe signal_pipe;
+    boost::asio::ip::tcp::socket client_socket;
+    std::optional<std::unique_lock<std::mutex>> suspend;
+
+    Kernel::KThread* active_thread;
+    bool stopped;
+    bool stepping;
+
+    std::array<u8, 4096> client_data;
+};
+
+Debugger::Debugger(Core::System& system, u16 port) {
+    try {
+        impl = std::make_unique<DebuggerImpl>(system, port);
+    } catch (const std::exception& ex) {
+        LOG_CRITICAL(Debug_GDBStub, "Failed to initialize debugger: {}", ex.what());
+    }
+}
+
+Debugger::~Debugger() = default;
+
+bool Debugger::NotifyThreadStopped(Kernel::KThread* thread) {
+    return impl && impl->NotifyThreadStopped(thread);
+}
+
+bool Debugger::IsStepping() const {
+    return impl && impl->IsStepping();
+}
+
+} // namespace Core
diff --git a/src/core/debugger/debugger.h b/src/core/debugger/debugger.h
new file mode 100644
index 000000000..7acd11815
--- /dev/null
+++ b/src/core/debugger/debugger.h
@@ -0,0 +1,46 @@
+// SPDX-FileCopyrightText: Copyright 2022 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#pragma once
+
+#include <memory>
+
+#include "common/common_types.h"
+
+namespace Kernel {
+class KThread;
+}
+
+namespace Core {
+class System;
+
+class DebuggerImpl;
+
+class Debugger {
+public:
+    /**
+     * Blocks and waits for a connection on localhost, port `server_port`.
+     * Does not create the debugger if the port is already in use.
+     */
+    explicit Debugger(Core::System& system, u16 server_port);
+    ~Debugger();
+
+    /**
+     * Notify the debugger that the given thread is stopped
+     * (due to a breakpoint, or due to stopping after a successful step).
+     *
+     * The debugger will asynchronously halt emulation after the notification has
+     * occurred. If another thread attempts to notify before emulation has stopped,
+     * it is ignored and this method will return false. Otherwise it will return true.
+     */
+    bool NotifyThreadStopped(Kernel::KThread* thread);
+
+    /**
+     * Returns whether a step is in progress.
+     */
+    bool IsStepping() const;
+
+private:
+    std::unique_ptr<DebuggerImpl> impl;
+};
+} // namespace Core
diff --git a/src/core/debugger/debugger_interface.h b/src/core/debugger/debugger_interface.h
new file mode 100644
index 000000000..0b357fcb5
--- /dev/null
+++ b/src/core/debugger/debugger_interface.h
@@ -0,0 +1,74 @@
+// SPDX-FileCopyrightText: Copyright 2022 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#pragma once
+
+#include <functional>
+#include <span>
+#include <vector>
+
+#include "common/common_types.h"
+
+namespace Kernel {
+class KThread;
+}
+
+namespace Core {
+
+enum class DebuggerAction {
+    Interrupt,         // Stop emulation as soon as possible.
+    Continue,          // Resume emulation.
+    StepThread,        // Step the currently-active thread.
+    ShutdownEmulation, // Shut down the emulator.
+};
+
+class DebuggerBackend {
+public:
+    /**
+     * Can be invoked from a callback to synchronously wait for more data.
+     * Will return as soon as least one byte is received. Reads up to 4096 bytes.
+     */
+    virtual std::span<const u8> ReadFromClient() = 0;
+
+    /**
+     * Can be invoked from a callback to write data to the client.
+     * Returns immediately after the data is sent.
+     */
+    virtual void WriteToClient(std::span<const u8> data) = 0;
+
+    /**
+     * Gets the currently active thread when the debugger is stopped.
+     */
+    virtual Kernel::KThread* GetActiveThread() = 0;
+
+    /**
+     * Sets the currently active thread when the debugger is stopped.
+     */
+    virtual void SetActiveThread(Kernel::KThread* thread) = 0;
+};
+
+class DebuggerFrontend {
+public:
+    explicit DebuggerFrontend(DebuggerBackend& backend_) : backend{backend_} {}
+
+    /**
+     * Called after the client has successfully connected to the port.
+     */
+    virtual void Connected() = 0;
+
+    /**
+     * Called when emulation has stopped.
+     */
+    virtual void Stopped(Kernel::KThread* thread) = 0;
+
+    /**
+     * Called when new data is asynchronously received on the client socket.
+     * A list of actions to perform is returned.
+     */
+    [[nodiscard]] virtual std::vector<DebuggerAction> ClientData(std::span<const u8> data) = 0;
+
+protected:
+    DebuggerBackend& backend;
+};
+
+} // namespace Core
diff --git a/src/core/debugger/gdbstub.cpp b/src/core/debugger/gdbstub.cpp
new file mode 100644
index 000000000..718c45952
--- /dev/null
+++ b/src/core/debugger/gdbstub.cpp
@@ -0,0 +1,382 @@
+// SPDX-FileCopyrightText: Copyright 2022 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#include <atomic>
+#include <numeric>
+#include <optional>
+#include <thread>
+
+#include <boost/asio.hpp>
+#include <boost/process/async_pipe.hpp>
+
+#include "common/hex_util.h"
+#include "common/logging/log.h"
+#include "common/scope_exit.h"
+#include "core/arm/arm_interface.h"
+#include "core/core.h"
+#include "core/debugger/gdbstub.h"
+#include "core/debugger/gdbstub_arch.h"
+#include "core/hle/kernel/k_page_table.h"
+#include "core/hle/kernel/k_process.h"
+#include "core/hle/kernel/k_thread.h"
+#include "core/loader/loader.h"
+#include "core/memory.h"
+
+namespace Core {
+
+constexpr char GDB_STUB_START = '$';
+constexpr char GDB_STUB_END = '#';
+constexpr char GDB_STUB_ACK = '+';
+constexpr char GDB_STUB_NACK = '-';
+constexpr char GDB_STUB_INT3 = 0x03;
+constexpr int GDB_STUB_SIGTRAP = 5;
+
+constexpr char GDB_STUB_REPLY_ERR[] = "E01";
+constexpr char GDB_STUB_REPLY_OK[] = "OK";
+constexpr char GDB_STUB_REPLY_EMPTY[] = "";
+
+GDBStub::GDBStub(DebuggerBackend& backend_, Core::System& system_)
+    : DebuggerFrontend(backend_), system{system_} {
+    if (system.CurrentProcess()->Is64BitProcess()) {
+        arch = std::make_unique<GDBStubA64>();
+    } else {
+        arch = std::make_unique<GDBStubA32>();
+    }
+}
+
+GDBStub::~GDBStub() = default;
+
+void GDBStub::Connected() {}
+
+void GDBStub::Stopped(Kernel::KThread* thread) {
+    SendReply(arch->ThreadStatus(thread, GDB_STUB_SIGTRAP));
+}
+
+std::vector<DebuggerAction> GDBStub::ClientData(std::span<const u8> data) {
+    std::vector<DebuggerAction> actions;
+    current_command.insert(current_command.end(), data.begin(), data.end());
+
+    while (current_command.size() != 0) {
+        ProcessData(actions);
+    }
+
+    return actions;
+}
+
+void GDBStub::ProcessData(std::vector<DebuggerAction>& actions) {
+    const char c{current_command[0]};
+
+    // Acknowledgement
+    if (c == GDB_STUB_ACK || c == GDB_STUB_NACK) {
+        current_command.erase(current_command.begin());
+        return;
+    }
+
+    // Interrupt
+    if (c == GDB_STUB_INT3) {
+        LOG_INFO(Debug_GDBStub, "Received interrupt");
+        current_command.erase(current_command.begin());
+        actions.push_back(DebuggerAction::Interrupt);
+        SendStatus(GDB_STUB_ACK);
+        return;
+    }
+
+    // Otherwise, require the data to be the start of a command
+    if (c != GDB_STUB_START) {
+        LOG_ERROR(Debug_GDBStub, "Invalid command buffer contents: {}", current_command.data());
+        current_command.clear();
+        SendStatus(GDB_STUB_NACK);
+        return;
+    }
+
+    // Continue reading until command is complete
+    while (CommandEnd() == current_command.end()) {
+        const auto new_data{backend.ReadFromClient()};
+        current_command.insert(current_command.end(), new_data.begin(), new_data.end());
+    }
+
+    // Execute and respond to GDB
+    const auto command{DetachCommand()};
+
+    if (command) {
+        SendStatus(GDB_STUB_ACK);
+        ExecuteCommand(*command, actions);
+    } else {
+        SendStatus(GDB_STUB_NACK);
+    }
+}
+
+void GDBStub::ExecuteCommand(std::string_view packet, std::vector<DebuggerAction>& actions) {
+    LOG_TRACE(Debug_GDBStub, "Executing command: {}", packet);
+
+    if (packet.length() == 0) {
+        SendReply(GDB_STUB_REPLY_ERR);
+        return;
+    }
+
+    std::string_view command{packet.substr(1, packet.size())};
+
+    switch (packet[0]) {
+    case 'H': {
+        Kernel::KThread* thread{nullptr};
+        s64 thread_id{strtoll(command.data() + 1, nullptr, 16)};
+        if (thread_id >= 1) {
+            thread = GetThreadByID(thread_id);
+        }
+
+        if (thread) {
+            SendReply(GDB_STUB_REPLY_OK);
+            backend.SetActiveThread(thread);
+        } else {
+            SendReply(GDB_STUB_REPLY_ERR);
+        }
+        break;
+    }
+    case 'T': {
+        s64 thread_id{strtoll(command.data(), nullptr, 16)};
+        if (GetThreadByID(thread_id)) {
+            SendReply(GDB_STUB_REPLY_OK);
+        } else {
+            SendReply(GDB_STUB_REPLY_ERR);
+        }
+        break;
+    }
+    case 'q':
+        HandleQuery(command);
+        break;
+    case '?':
+        SendReply(arch->ThreadStatus(backend.GetActiveThread(), GDB_STUB_SIGTRAP));
+        break;
+    case 'k':
+        LOG_INFO(Debug_GDBStub, "Shutting down emulation");
+        actions.push_back(DebuggerAction::ShutdownEmulation);
+        break;
+    case 'g':
+        SendReply(arch->ReadRegisters(backend.GetActiveThread()));
+        break;
+    case 'G':
+        arch->WriteRegisters(backend.GetActiveThread(), command);
+        SendReply(GDB_STUB_REPLY_OK);
+        break;
+    case 'p': {
+        const size_t reg{static_cast<size_t>(strtoll(command.data(), nullptr, 16))};
+        SendReply(arch->RegRead(backend.GetActiveThread(), reg));
+        break;
+    }
+    case 'P': {
+        const auto sep{std::find(command.begin(), command.end(), '=') - command.begin() + 1};
+        const size_t reg{static_cast<size_t>(strtoll(command.data(), nullptr, 16))};
+        arch->RegWrite(backend.GetActiveThread(), reg, std::string_view(command).substr(sep));
+        break;
+    }
+    case 'm': {
+        const auto sep{std::find(command.begin(), command.end(), ',') - command.begin() + 1};
+        const size_t addr{static_cast<size_t>(strtoll(command.data(), nullptr, 16))};
+        const size_t size{static_cast<size_t>(strtoll(command.data() + sep, nullptr, 16))};
+
+        if (system.Memory().IsValidVirtualAddressRange(addr, size)) {
+            std::vector<u8> mem(size);
+            system.Memory().ReadBlock(addr, mem.data(), size);
+
+            SendReply(Common::HexToString(mem));
+        } else {
+            SendReply(GDB_STUB_REPLY_ERR);
+        }
+        break;
+    }
+    case 'M': {
+        const auto size_sep{std::find(command.begin(), command.end(), ',') - command.begin() + 1};
+        const auto mem_sep{std::find(command.begin(), command.end(), ':') - command.begin() + 1};
+
+        const size_t addr{static_cast<size_t>(strtoll(command.data(), nullptr, 16))};
+        const size_t size{static_cast<size_t>(strtoll(command.data() + size_sep, nullptr, 16))};
+
+        const auto mem_substr{std::string_view(command).substr(mem_sep)};
+        const auto mem{Common::HexStringToVector(mem_substr, false)};
+
+        if (system.Memory().IsValidVirtualAddressRange(addr, size)) {
+            system.Memory().WriteBlock(addr, mem.data(), size);
+            system.InvalidateCpuInstructionCacheRange(addr, size);
+            SendReply(GDB_STUB_REPLY_OK);
+        } else {
+            SendReply(GDB_STUB_REPLY_ERR);
+        }
+        break;
+    }
+    case 's':
+        actions.push_back(DebuggerAction::StepThread);
+        break;
+    case 'C':
+    case 'c':
+        actions.push_back(DebuggerAction::Continue);
+        break;
+    case 'Z': {
+        const auto addr_sep{std::find(command.begin(), command.end(), ',') - command.begin() + 1};
+        const size_t addr{static_cast<size_t>(strtoll(command.data() + addr_sep, nullptr, 16))};
+
+        if (system.Memory().IsValidVirtualAddress(addr)) {
+            replaced_instructions[addr] = system.Memory().Read32(addr);
+            system.Memory().Write32(addr, arch->BreakpointInstruction());
+            system.InvalidateCpuInstructionCacheRange(addr, sizeof(u32));
+
+            SendReply(GDB_STUB_REPLY_OK);
+        } else {
+            SendReply(GDB_STUB_REPLY_ERR);
+        }
+        break;
+    }
+    case 'z': {
+        const auto addr_sep{std::find(command.begin(), command.end(), ',') - command.begin() + 1};
+        const size_t addr{static_cast<size_t>(strtoll(command.data() + addr_sep, nullptr, 16))};
+
+        const auto orig_insn{replaced_instructions.find(addr)};
+        if (system.Memory().IsValidVirtualAddress(addr) &&
+            orig_insn != replaced_instructions.end()) {
+            system.Memory().Write32(addr, orig_insn->second);
+            system.InvalidateCpuInstructionCacheRange(addr, sizeof(u32));
+            replaced_instructions.erase(addr);
+
+            SendReply(GDB_STUB_REPLY_OK);
+        } else {
+            SendReply(GDB_STUB_REPLY_ERR);
+        }
+        break;
+    }
+    default:
+        SendReply(GDB_STUB_REPLY_EMPTY);
+        break;
+    }
+}
+
+void GDBStub::HandleQuery(std::string_view command) {
+    if (command.starts_with("TStatus")) {
+        // no tracepoint support
+        SendReply("T0");
+    } else if (command.starts_with("Supported")) {
+        SendReply("PacketSize=4000;qXfer:features:read+;qXfer:threads:read+;qXfer:libraries:read+");
+    } else if (command.starts_with("Xfer:features:read:target.xml:")) {
+        const auto offset{command.substr(30)};
+        const auto amount{command.substr(command.find(',') + 1)};
+
+        const auto offset_val{static_cast<u64>(strtoll(offset.data(), nullptr, 16))};
+        const auto amount_val{static_cast<u64>(strtoll(amount.data(), nullptr, 16))};
+        const auto target_xml{arch->GetTargetXML()};
+
+        if (offset_val + amount_val > target_xml.size()) {
+            SendReply("l" + target_xml.substr(offset_val));
+        } else {
+            SendReply("m" + target_xml.substr(offset_val, amount_val));
+        }
+    } else if (command.starts_with("Offsets")) {
+        Loader::AppLoader::Modules modules;
+        system.GetAppLoader().ReadNSOModules(modules);
+
+        const auto main = std::find_if(modules.begin(), modules.end(),
+                                       [](const auto& key) { return key.second == "main"; });
+        if (main != modules.end()) {
+            SendReply(fmt::format("TextSeg={:x}", main->first));
+        } else {
+            SendReply(fmt::format("TextSeg={:x}",
+                                  system.CurrentProcess()->PageTable().GetCodeRegionStart()));
+        }
+    } else if (command.starts_with("fThreadInfo")) {
+        // beginning of list
+        const auto& threads = system.GlobalSchedulerContext().GetThreadList();
+        std::vector<std::string> thread_ids;
+        for (const auto& thread : threads) {
+            thread_ids.push_back(fmt::format("{:x}", thread->GetThreadID()));
+        }
+        SendReply(fmt::format("m{}", fmt::join(thread_ids, ",")));
+    } else if (command.starts_with("sThreadInfo")) {
+        // end of list
+        SendReply("l");
+    } else if (command.starts_with("Xfer:threads:read")) {
+        std::string buffer;
+        buffer += R"(l<?xml version="1.0"?>)";
+        buffer += "<threads>";
+
+        const auto& threads = system.GlobalSchedulerContext().GetThreadList();
+        for (const auto& thread : threads) {
+            buffer +=
+                fmt::format(R"(<thread id="{:x}" core="{:d}" name="Thread {:d}"/>)",
+                            thread->GetThreadID(), thread->GetActiveCore(), thread->GetThreadID());
+        }
+
+        buffer += "</threads>";
+        SendReply(buffer);
+    } else {
+        SendReply(GDB_STUB_REPLY_EMPTY);
+    }
+}
+
+Kernel::KThread* GDBStub::GetThreadByID(u64 thread_id) {
+    const auto& threads{system.GlobalSchedulerContext().GetThreadList()};
+    for (auto* thread : threads) {
+        if (thread->GetThreadID() == thread_id) {
+            return thread;
+        }
+    }
+
+    return nullptr;
+}
+
+std::vector<char>::const_iterator GDBStub::CommandEnd() const {
+    // Find the end marker
+    const auto end{std::find(current_command.begin(), current_command.end(), GDB_STUB_END)};
+
+    // Require the checksum to be present
+    return std::min(end + 2, current_command.end());
+}
+
+std::optional<std::string> GDBStub::DetachCommand() {
+    // Slice the string part from the beginning to the end marker
+    const auto end{CommandEnd()};
+
+    // Extract possible command data
+    std::string data(current_command.data(), end - current_command.begin() + 1);
+
+    // Shift over the remaining contents
+    current_command.erase(current_command.begin(), end + 1);
+
+    // Validate received command
+    if (data[0] != GDB_STUB_START) {
+        LOG_ERROR(Debug_GDBStub, "Invalid start data: {}", data[0]);
+        return std::nullopt;
+    }
+
+    u8 calculated = CalculateChecksum(std::string_view(data).substr(1, data.size() - 4));
+    u8 received = static_cast<u8>(strtoll(data.data() + data.size() - 2, nullptr, 16));
+
+    // Verify checksum
+    if (calculated != received) {
+        LOG_ERROR(Debug_GDBStub, "Checksum mismatch: calculated {:02x}, received {:02x}",
+                  calculated, received);
+        return std::nullopt;
+    }
+
+    return data.substr(1, data.size() - 4);
+}
+
+u8 GDBStub::CalculateChecksum(std::string_view data) {
+    return static_cast<u8>(
+        std::accumulate(data.begin(), data.end(), u8{0}, [](u8 lhs, u8 rhs) { return lhs + rhs; }));
+}
+
+void GDBStub::SendReply(std::string_view data) {
+    const auto output{
+        fmt::format("{}{}{}{:02x}", GDB_STUB_START, data, GDB_STUB_END, CalculateChecksum(data))};
+    LOG_TRACE(Debug_GDBStub, "Writing reply: {}", output);
+
+    // C++ string support is complete rubbish
+    const u8* output_begin = reinterpret_cast<const u8*>(output.data());
+    const u8* output_end = output_begin + output.size();
+    backend.WriteToClient(std::span<const u8>(output_begin, output_end));
+}
+
+void GDBStub::SendStatus(char status) {
+    std::array<u8, 1> buf = {static_cast<u8>(status)};
+    LOG_TRACE(Debug_GDBStub, "Writing status: {}", status);
+    backend.WriteToClient(buf);
+}
+
+} // namespace Core
diff --git a/src/core/debugger/gdbstub.h b/src/core/debugger/gdbstub.h
new file mode 100644
index 000000000..b93a3a511
--- /dev/null
+++ b/src/core/debugger/gdbstub.h
@@ -0,0 +1,47 @@
+// SPDX-FileCopyrightText: Copyright 2022 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#pragma once
+
+#include <map>
+#include <memory>
+#include <optional>
+#include <string_view>
+#include <vector>
+
+#include "core/debugger/debugger_interface.h"
+#include "core/debugger/gdbstub_arch.h"
+
+namespace Core {
+
+class System;
+
+class GDBStub : public DebuggerFrontend {
+public:
+    explicit GDBStub(DebuggerBackend& backend, Core::System& system);
+    ~GDBStub();
+
+    void Connected() override;
+    void Stopped(Kernel::KThread* thread) override;
+    std::vector<DebuggerAction> ClientData(std::span<const u8> data) override;
+
+private:
+    void ProcessData(std::vector<DebuggerAction>& actions);
+    void ExecuteCommand(std::string_view packet, std::vector<DebuggerAction>& actions);
+    void HandleQuery(std::string_view command);
+    std::vector<char>::const_iterator CommandEnd() const;
+    std::optional<std::string> DetachCommand();
+    Kernel::KThread* GetThreadByID(u64 thread_id);
+
+    static u8 CalculateChecksum(std::string_view data);
+    void SendReply(std::string_view data);
+    void SendStatus(char status);
+
+private:
+    Core::System& system;
+    std::unique_ptr<GDBStubArch> arch;
+    std::vector<char> current_command;
+    std::map<VAddr, u32> replaced_instructions;
+};
+
+} // namespace Core
diff --git a/src/core/debugger/gdbstub_arch.cpp b/src/core/debugger/gdbstub_arch.cpp
new file mode 100644
index 000000000..99e3893a9
--- /dev/null
+++ b/src/core/debugger/gdbstub_arch.cpp
@@ -0,0 +1,406 @@
+// SPDX-FileCopyrightText: Copyright 2022 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#include "common/hex_util.h"
+#include "core/debugger/gdbstub_arch.h"
+#include "core/hle/kernel/k_thread.h"
+
+namespace Core {
+
+template <typename T>
+static T HexToValue(std::string_view hex) {
+    static_assert(std::is_trivially_copyable_v<T>);
+    T value{};
+    const auto mem{Common::HexStringToVector(hex, false)};
+    std::memcpy(&value, mem.data(), std::min(mem.size(), sizeof(T)));
+    return value;
+}
+
+template <typename T>
+static std::string ValueToHex(const T value) {
+    static_assert(std::is_trivially_copyable_v<T>);
+    std::array<u8, sizeof(T)> mem{};
+    std::memcpy(mem.data(), &value, sizeof(T));
+    return Common::HexToString(mem);
+}
+
+template <typename T>
+static T GetSIMDRegister(const std::array<u32, 64>& simd_regs, size_t offset) {
+    static_assert(std::is_trivially_copyable_v<T>);
+    T value{};
+    std::memcpy(&value, reinterpret_cast<const u8*>(simd_regs.data()) + sizeof(T) * offset,
+                sizeof(T));
+    return value;
+}
+
+template <typename T>
+static void PutSIMDRegister(std::array<u32, 64>& simd_regs, size_t offset, const T value) {
+    static_assert(std::is_trivially_copyable_v<T>);
+    std::memcpy(reinterpret_cast<u8*>(simd_regs.data()) + sizeof(T) * offset, &value, sizeof(T));
+}
+
+// For sample XML files see the GDB source /gdb/features
+// This XML defines what the registers are for this specific ARM device
+std::string GDBStubA64::GetTargetXML() const {
+    constexpr const char* target_xml =
+        R"(<?xml version="1.0"?>
+<!DOCTYPE target SYSTEM "gdb-target.dtd">
+<target version="1.0">
+  <feature name="org.gnu.gdb.aarch64.core">
+    <reg name="x0" bitsize="64"/>
+    <reg name="x1" bitsize="64"/>
+    <reg name="x2" bitsize="64"/>
+    <reg name="x3" bitsize="64"/>
+    <reg name="x4" bitsize="64"/>
+    <reg name="x5" bitsize="64"/>
+    <reg name="x6" bitsize="64"/>
+    <reg name="x7" bitsize="64"/>
+    <reg name="x8" bitsize="64"/>
+    <reg name="x9" bitsize="64"/>
+    <reg name="x10" bitsize="64"/>
+    <reg name="x11" bitsize="64"/>
+    <reg name="x12" bitsize="64"/>
+    <reg name="x13" bitsize="64"/>
+    <reg name="x14" bitsize="64"/>
+    <reg name="x15" bitsize="64"/>
+    <reg name="x16" bitsize="64"/>
+    <reg name="x17" bitsize="64"/>
+    <reg name="x18" bitsize="64"/>
+    <reg name="x19" bitsize="64"/>
+    <reg name="x20" bitsize="64"/>
+    <reg name="x21" bitsize="64"/>
+    <reg name="x22" bitsize="64"/>
+    <reg name="x23" bitsize="64"/>
+    <reg name="x24" bitsize="64"/>
+    <reg name="x25" bitsize="64"/>
+    <reg name="x26" bitsize="64"/>
+    <reg name="x27" bitsize="64"/>
+    <reg name="x28" bitsize="64"/>
+    <reg name="x29" bitsize="64"/>
+    <reg name="x30" bitsize="64"/>
+    <reg name="sp" bitsize="64" type="data_ptr"/>
+    <reg name="pc" bitsize="64" type="code_ptr"/>
+    <flags id="pstate_flags" size="4">
+      <field name="SP" start="0" end="0"/>
+      <field name="" start="1" end="1"/>
+      <field name="EL" start="2" end="3"/>
+      <field name="nRW" start="4" end="4"/>
+      <field name="" start="5" end="5"/>
+      <field name="F" start="6" end="6"/>
+      <field name="I" start="7" end="7"/>
+      <field name="A" start="8" end="8"/>
+      <field name="D" start="9" end="9"/>
+      <field name="IL" start="20" end="20"/>
+      <field name="SS" start="21" end="21"/>
+      <field name="V" start="28" end="28"/>
+      <field name="C" start="29" end="29"/>
+      <field name="Z" start="30" end="30"/>
+      <field name="N" start="31" end="31"/>
+    </flags>
+    <reg name="pstate" bitsize="32" type="pstate_flags"/>
+  </feature>
+  <feature name="org.gnu.gdb.aarch64.fpu">
+  </feature>
+</target>)";
+
+    return target_xml;
+}
+
+std::string GDBStubA64::RegRead(const Kernel::KThread* thread, size_t id) const {
+    if (!thread) {
+        return "";
+    }
+
+    const auto& context{thread->GetContext64()};
+    const auto& gprs{context.cpu_registers};
+    const auto& fprs{context.vector_registers};
+
+    if (id <= SP_REGISTER) {
+        return ValueToHex(gprs[id]);
+    } else if (id == PC_REGISTER) {
+        return ValueToHex(context.pc);
+    } else if (id == PSTATE_REGISTER) {
+        return ValueToHex(context.pstate);
+    } else if (id >= Q0_REGISTER && id < FPCR_REGISTER) {
+        return ValueToHex(fprs[id - Q0_REGISTER]);
+    } else if (id == FPCR_REGISTER) {
+        return ValueToHex(context.fpcr);
+    } else if (id == FPSR_REGISTER) {
+        return ValueToHex(context.fpsr);
+    } else {
+        return "";
+    }
+}
+
+void GDBStubA64::RegWrite(Kernel::KThread* thread, size_t id, std::string_view value) const {
+    if (!thread) {
+        return;
+    }
+
+    auto& context{thread->GetContext64()};
+
+    if (id <= SP_REGISTER) {
+        context.cpu_registers[id] = HexToValue<u64>(value);
+    } else if (id == PC_REGISTER) {
+        context.pc = HexToValue<u64>(value);
+    } else if (id == PSTATE_REGISTER) {
+        context.pstate = HexToValue<u32>(value);
+    } else if (id >= Q0_REGISTER && id < FPCR_REGISTER) {
+        context.vector_registers[id - Q0_REGISTER] = HexToValue<u128>(value);
+    } else if (id == FPCR_REGISTER) {
+        context.fpcr = HexToValue<u32>(value);
+    } else if (id == FPSR_REGISTER) {
+        context.fpsr = HexToValue<u32>(value);
+    }
+}
+
+std::string GDBStubA64::ReadRegisters(const Kernel::KThread* thread) const {
+    std::string output;
+
+    for (size_t reg = 0; reg <= FPCR_REGISTER; reg++) {
+        output += RegRead(thread, reg);
+    }
+
+    return output;
+}
+
+void GDBStubA64::WriteRegisters(Kernel::KThread* thread, std::string_view register_data) const {
+    for (size_t i = 0, reg = 0; reg <= FPCR_REGISTER; reg++) {
+        if (reg <= SP_REGISTER || reg == PC_REGISTER) {
+            RegWrite(thread, reg, register_data.substr(i, 16));
+            i += 16;
+        } else if (reg == PSTATE_REGISTER || reg == FPCR_REGISTER || reg == FPSR_REGISTER) {
+            RegWrite(thread, reg, register_data.substr(i, 8));
+            i += 8;
+        } else if (reg >= Q0_REGISTER && reg < FPCR_REGISTER) {
+            RegWrite(thread, reg, register_data.substr(i, 32));
+            i += 32;
+        }
+    }
+}
+
+std::string GDBStubA64::ThreadStatus(const Kernel::KThread* thread, u8 signal) const {
+    return fmt::format("T{:02x}{:02x}:{};{:02x}:{};{:02x}:{};thread:{:x};", signal, PC_REGISTER,
+                       RegRead(thread, PC_REGISTER), SP_REGISTER, RegRead(thread, SP_REGISTER),
+                       LR_REGISTER, RegRead(thread, LR_REGISTER), thread->GetThreadID());
+}
+
+u32 GDBStubA64::BreakpointInstruction() const {
+    // A64: brk #0
+    return 0xd4200000;
+}
+
+std::string GDBStubA32::GetTargetXML() const {
+    constexpr const char* target_xml =
+        R"(<?xml version="1.0"?>
+<!DOCTYPE target SYSTEM "gdb-target.dtd">
+<target version="1.0">
+  <feature name="org.gnu.gdb.arm.core">
+    <reg name="r0" bitsize="32" type="uint32"/>
+    <reg name="r1" bitsize="32" type="uint32"/>
+    <reg name="r2" bitsize="32" type="uint32"/>
+    <reg name="r3" bitsize="32" type="uint32"/>
+    <reg name="r4" bitsize="32" type="uint32"/>
+    <reg name="r5" bitsize="32" type="uint32"/>
+    <reg name="r6" bitsize="32" type="uint32"/>
+    <reg name="r7" bitsize="32" type="uint32"/>
+    <reg name="r8" bitsize="32" type="uint32"/>
+    <reg name="r9" bitsize="32" type="uint32"/>
+    <reg name="r10" bitsize="32" type="uint32"/>
+    <reg name="r11" bitsize="32" type="uint32"/>
+    <reg name="r12" bitsize="32" type="uint32"/>
+    <reg name="sp" bitsize="32" type="data_ptr"/>
+    <reg name="lr" bitsize="32" type="code_ptr"/>
+    <reg name="pc" bitsize="32" type="code_ptr"/>
+    <!-- The CPSR is register 25, rather than register 16, because
+         the FPA registers historically were placed between the PC
+         and the CPSR in the "g" packet.  -->
+    <reg name="cpsr" bitsize="32" regnum="25"/>
+  </feature>
+  <feature name="org.gnu.gdb.arm.vfp">
+    <vector id="neon_uint8x8" type="uint8" count="8"/>
+    <vector id="neon_uint16x4" type="uint16" count="4"/>
+    <vector id="neon_uint32x2" type="uint32" count="2"/>
+    <vector id="neon_float32x2" type="ieee_single" count="2"/>
+    <union id="neon_d">
+      <field name="u8" type="neon_uint8x8"/>
+      <field name="u16" type="neon_uint16x4"/>
+      <field name="u32" type="neon_uint32x2"/>
+      <field name="u64" type="uint64"/>
+      <field name="f32" type="neon_float32x2"/>
+      <field name="f64" type="ieee_double"/>
+    </union>
+    <vector id="neon_uint8x16" type="uint8" count="16"/>
+    <vector id="neon_uint16x8" type="uint16" count="8"/>
+    <vector id="neon_uint32x4" type="uint32" count="4"/>
+    <vector id="neon_uint64x2" type="uint64" count="2"/>
+    <vector id="neon_float32x4" type="ieee_single" count="4"/>
+    <vector id="neon_float64x2" type="ieee_double" count="2"/>
+    <union id="neon_q">
+      <field name="u8" type="neon_uint8x16"/>
+      <field name="u16" type="neon_uint16x8"/>
+      <field name="u32" type="neon_uint32x4"/>
+      <field name="u64" type="neon_uint64x2"/>
+      <field name="f32" type="neon_float32x4"/>
+      <field name="f64" type="neon_float64x2"/>
+    </union>
+    <reg name="d0" bitsize="64" type="neon_d" regnum="32"/>
+    <reg name="d1" bitsize="64" type="neon_d"/>
+    <reg name="d2" bitsize="64" type="neon_d"/>
+    <reg name="d3" bitsize="64" type="neon_d"/>
+    <reg name="d4" bitsize="64" type="neon_d"/>
+    <reg name="d5" bitsize="64" type="neon_d"/>
+    <reg name="d6" bitsize="64" type="neon_d"/>
+    <reg name="d7" bitsize="64" type="neon_d"/>
+    <reg name="d8" bitsize="64" type="neon_d"/>
+    <reg name="d9" bitsize="64" type="neon_d"/>
+    <reg name="d10" bitsize="64" type="neon_d"/>
+    <reg name="d11" bitsize="64" type="neon_d"/>
+    <reg name="d12" bitsize="64" type="neon_d"/>
+    <reg name="d13" bitsize="64" type="neon_d"/>
+    <reg name="d14" bitsize="64" type="neon_d"/>
+    <reg name="d15" bitsize="64" type="neon_d"/>
+    <reg name="d16" bitsize="64" type="neon_d"/>
+    <reg name="d17" bitsize="64" type="neon_d"/>
+    <reg name="d18" bitsize="64" type="neon_d"/>
+    <reg name="d19" bitsize="64" type="neon_d"/>
+    <reg name="d20" bitsize="64" type="neon_d"/>
+    <reg name="d21" bitsize="64" type="neon_d"/>
+    <reg name="d22" bitsize="64" type="neon_d"/>
+    <reg name="d23" bitsize="64" type="neon_d"/>
+    <reg name="d24" bitsize="64" type="neon_d"/>
+    <reg name="d25" bitsize="64" type="neon_d"/>
+    <reg name="d26" bitsize="64" type="neon_d"/>
+    <reg name="d27" bitsize="64" type="neon_d"/>
+    <reg name="d28" bitsize="64" type="neon_d"/>
+    <reg name="d29" bitsize="64" type="neon_d"/>
+    <reg name="d30" bitsize="64" type="neon_d"/>
+    <reg name="d31" bitsize="64" type="neon_d"/>
+
+    <reg name="q0" bitsize="128" type="neon_q" regnum="64"/>
+    <reg name="q1" bitsize="128" type="neon_q"/>
+    <reg name="q2" bitsize="128" type="neon_q"/>
+    <reg name="q3" bitsize="128" type="neon_q"/>
+    <reg name="q4" bitsize="128" type="neon_q"/>
+    <reg name="q5" bitsize="128" type="neon_q"/>
+    <reg name="q6" bitsize="128" type="neon_q"/>
+    <reg name="q7" bitsize="128" type="neon_q"/>
+    <reg name="q8" bitsize="128" type="neon_q"/>
+    <reg name="q9" bitsize="128" type="neon_q"/>
+    <reg name="q10" bitsize="128" type="neon_q"/>
+    <reg name="q10" bitsize="128" type="neon_q"/>
+    <reg name="q12" bitsize="128" type="neon_q"/>
+    <reg name="q13" bitsize="128" type="neon_q"/>
+    <reg name="q14" bitsize="128" type="neon_q"/>
+    <reg name="q15" bitsize="128" type="neon_q"/>
+
+    <reg name="fpscr" bitsize="32" type="int" group="float" regnum="80"/>
+  </feature>
+</target>)";
+
+    return target_xml;
+}
+
+std::string GDBStubA32::RegRead(const Kernel::KThread* thread, size_t id) const {
+    if (!thread) {
+        return "";
+    }
+
+    const auto& context{thread->GetContext32()};
+    const auto& gprs{context.cpu_registers};
+    const auto& fprs{context.extension_registers};
+
+    if (id <= PC_REGISTER) {
+        return ValueToHex(gprs[id]);
+    } else if (id == CPSR_REGISTER) {
+        return ValueToHex(context.cpsr);
+    } else if (id >= D0_REGISTER && id < Q0_REGISTER) {
+        const u64 dN{GetSIMDRegister<u64>(fprs, id - D0_REGISTER)};
+        return ValueToHex(dN);
+    } else if (id >= Q0_REGISTER && id < FPSCR_REGISTER) {
+        const u128 qN{GetSIMDRegister<u128>(fprs, id - Q0_REGISTER)};
+        return ValueToHex(qN);
+    } else if (id == FPSCR_REGISTER) {
+        return ValueToHex(context.fpscr);
+    } else {
+        return "";
+    }
+}
+
+void GDBStubA32::RegWrite(Kernel::KThread* thread, size_t id, std::string_view value) const {
+    if (!thread) {
+        return;
+    }
+
+    auto& context{thread->GetContext32()};
+    auto& fprs{context.extension_registers};
+
+    if (id <= PC_REGISTER) {
+        context.cpu_registers[id] = HexToValue<u32>(value);
+    } else if (id == CPSR_REGISTER) {
+        context.cpsr = HexToValue<u32>(value);
+    } else if (id >= D0_REGISTER && id < Q0_REGISTER) {
+        PutSIMDRegister(fprs, id - D0_REGISTER, HexToValue<u64>(value));
+    } else if (id >= Q0_REGISTER && id < FPSCR_REGISTER) {
+        PutSIMDRegister(fprs, id - Q0_REGISTER, HexToValue<u128>(value));
+    } else if (id == FPSCR_REGISTER) {
+        context.fpscr = HexToValue<u32>(value);
+    }
+}
+
+std::string GDBStubA32::ReadRegisters(const Kernel::KThread* thread) const {
+    std::string output;
+
+    for (size_t reg = 0; reg <= FPSCR_REGISTER; reg++) {
+        const bool gpr{reg <= PC_REGISTER};
+        const bool dfpr{reg >= D0_REGISTER && reg < Q0_REGISTER};
+        const bool qfpr{reg >= Q0_REGISTER && reg < FPSCR_REGISTER};
+
+        if (!(gpr || dfpr || qfpr || reg == CPSR_REGISTER || reg == FPSCR_REGISTER)) {
+            continue;
+        }
+
+        output += RegRead(thread, reg);
+    }
+
+    return output;
+}
+
+void GDBStubA32::WriteRegisters(Kernel::KThread* thread, std::string_view register_data) const {
+    for (size_t i = 0, reg = 0; reg <= FPSCR_REGISTER; reg++) {
+        const bool gpr{reg <= PC_REGISTER};
+        const bool dfpr{reg >= D0_REGISTER && reg < Q0_REGISTER};
+        const bool qfpr{reg >= Q0_REGISTER && reg < FPSCR_REGISTER};
+
+        if (gpr || reg == CPSR_REGISTER || reg == FPSCR_REGISTER) {
+            RegWrite(thread, reg, register_data.substr(i, 8));
+            i += 8;
+        } else if (dfpr) {
+            RegWrite(thread, reg, register_data.substr(i, 16));
+            i += 16;
+        } else if (qfpr) {
+            RegWrite(thread, reg, register_data.substr(i, 32));
+            i += 32;
+        }
+
+        if (reg == PC_REGISTER) {
+            reg = CPSR_REGISTER - 1;
+        } else if (reg == CPSR_REGISTER) {
+            reg = D0_REGISTER - 1;
+        }
+    }
+}
+
+std::string GDBStubA32::ThreadStatus(const Kernel::KThread* thread, u8 signal) const {
+    return fmt::format("T{:02x}{:02x}:{};{:02x}:{};{:02x}:{};thread:{:x};", signal, PC_REGISTER,
+                       RegRead(thread, PC_REGISTER), SP_REGISTER, RegRead(thread, SP_REGISTER),
+                       LR_REGISTER, RegRead(thread, LR_REGISTER), thread->GetThreadID());
+}
+
+u32 GDBStubA32::BreakpointInstruction() const {
+    // A32: trap
+    // T32: trap + b #4
+    return 0xe7ffdefe;
+}
+
+} // namespace Core
diff --git a/src/core/debugger/gdbstub_arch.h b/src/core/debugger/gdbstub_arch.h
new file mode 100644
index 000000000..e943848e5
--- /dev/null
+++ b/src/core/debugger/gdbstub_arch.h
@@ -0,0 +1,67 @@
+// SPDX-FileCopyrightText: Copyright 2022 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#pragma once
+
+#include <string>
+
+#include "common/common_types.h"
+
+namespace Kernel {
+class KThread;
+}
+
+namespace Core {
+
+class GDBStubArch {
+public:
+    virtual std::string GetTargetXML() const = 0;
+    virtual std::string RegRead(const Kernel::KThread* thread, size_t id) const = 0;
+    virtual void RegWrite(Kernel::KThread* thread, size_t id, std::string_view value) const = 0;
+    virtual std::string ReadRegisters(const Kernel::KThread* thread) const = 0;
+    virtual void WriteRegisters(Kernel::KThread* thread, std::string_view register_data) const = 0;
+    virtual std::string ThreadStatus(const Kernel::KThread* thread, u8 signal) const = 0;
+    virtual u32 BreakpointInstruction() const = 0;
+};
+
+class GDBStubA64 final : public GDBStubArch {
+public:
+    std::string GetTargetXML() const override;
+    std::string RegRead(const Kernel::KThread* thread, size_t id) const override;
+    void RegWrite(Kernel::KThread* thread, size_t id, std::string_view value) const override;
+    std::string ReadRegisters(const Kernel::KThread* thread) const override;
+    void WriteRegisters(Kernel::KThread* thread, std::string_view register_data) const override;
+    std::string ThreadStatus(const Kernel::KThread* thread, u8 signal) const override;
+    u32 BreakpointInstruction() const override;
+
+private:
+    static constexpr u32 LR_REGISTER = 30;
+    static constexpr u32 SP_REGISTER = 31;
+    static constexpr u32 PC_REGISTER = 32;
+    static constexpr u32 PSTATE_REGISTER = 33;
+    static constexpr u32 Q0_REGISTER = 34;
+    static constexpr u32 FPCR_REGISTER = 66;
+    static constexpr u32 FPSR_REGISTER = 67;
+};
+
+class GDBStubA32 final : public GDBStubArch {
+public:
+    std::string GetTargetXML() const override;
+    std::string RegRead(const Kernel::KThread* thread, size_t id) const override;
+    void RegWrite(Kernel::KThread* thread, size_t id, std::string_view value) const override;
+    std::string ReadRegisters(const Kernel::KThread* thread) const override;
+    void WriteRegisters(Kernel::KThread* thread, std::string_view register_data) const override;
+    std::string ThreadStatus(const Kernel::KThread* thread, u8 signal) const override;
+    u32 BreakpointInstruction() const override;
+
+private:
+    static constexpr u32 SP_REGISTER = 13;
+    static constexpr u32 LR_REGISTER = 14;
+    static constexpr u32 PC_REGISTER = 15;
+    static constexpr u32 CPSR_REGISTER = 25;
+    static constexpr u32 D0_REGISTER = 32;
+    static constexpr u32 Q0_REGISTER = 64;
+    static constexpr u32 FPSCR_REGISTER = 80;
+};
+
+} // namespace Core
diff --git a/src/core/hle/kernel/k_process.cpp b/src/core/hle/kernel/k_process.cpp
index 490e31fc7..dcfeacccd 100644
--- a/src/core/hle/kernel/k_process.cpp
+++ b/src/core/hle/kernel/k_process.cpp
@@ -64,6 +64,10 @@ void SetupMainThread(Core::System& system, KProcess& owner_process, u32 priority
     {
         KScopedSchedulerLock lock{kernel};
         thread->SetState(ThreadState::Runnable);
+
+        if (system.DebuggerEnabled()) {
+            thread->RequestSuspend(SuspendType::Debug);
+        }
     }
 }
 } // Anonymous namespace
diff --git a/src/core/memory.cpp b/src/core/memory.cpp
index 28d30eee2..7534de01e 100644
--- a/src/core/memory.cpp
+++ b/src/core/memory.cpp
@@ -594,6 +594,19 @@ bool Memory::IsValidVirtualAddress(const VAddr vaddr) const {
     return pointer != nullptr || type == Common::PageType::RasterizerCachedMemory;
 }
 
+bool Memory::IsValidVirtualAddressRange(VAddr base, u64 size) const {
+    VAddr end = base + size;
+    VAddr page = Common::AlignDown(base, PAGE_SIZE);
+
+    for (; page < end; page += PAGE_SIZE) {
+        if (!IsValidVirtualAddress(page)) {
+            return false;
+        }
+    }
+
+    return true;
+}
+
 u8* Memory::GetPointer(VAddr vaddr) {
     return impl->GetPointer(vaddr);
 }
diff --git a/src/core/memory.h b/src/core/memory.h
index b5721b740..58cc27b29 100644
--- a/src/core/memory.h
+++ b/src/core/memory.h
@@ -95,6 +95,17 @@ public:
      */
     [[nodiscard]] bool IsValidVirtualAddress(VAddr vaddr) const;
 
+    /**
+     * Checks whether or not the supplied range of addresses are all valid
+     * virtual addresses for the current process.
+     *
+     * @param base The address to begin checking.
+     * @param size The amount of bytes to check.
+     *
+     * @returns True if all bytes in the given range are valid, false otherwise.
+     */
+    [[nodiscard]] bool IsValidVirtualAddressRange(VAddr base, u64 size) const;
+
     /**
      * Gets a pointer to the given address.
      *
diff --git a/src/yuzu/bootmanager.cpp b/src/yuzu/bootmanager.cpp
index 8f0a6bbb8..aae2de2f8 100644
--- a/src/yuzu/bootmanager.cpp
+++ b/src/yuzu/bootmanager.cpp
@@ -50,6 +50,7 @@ void EmuThread::run() {
 
     auto& gpu = system.GPU();
     auto stop_token = stop_source.get_token();
+    bool debugger_should_start = system.DebuggerEnabled();
 
     system.RegisterHostThread();
 
@@ -89,6 +90,12 @@ void EmuThread::run() {
                 this->SetRunning(false);
                 emit ErrorThrown(result, system.GetStatusDetails());
             }
+
+            if (debugger_should_start) {
+                system.InitializeDebugger();
+                debugger_should_start = false;
+            }
+
             running_wait.Wait();
             result = system.Pause();
             if (result != Core::SystemResultStatus::Success) {
@@ -102,11 +109,9 @@ void EmuThread::run() {
                 was_active = true;
                 emit DebugModeEntered();
             }
-        } else if (exec_step) {
-            UNIMPLEMENTED();
         } else {
             std::unique_lock lock{running_mutex};
-            running_cv.wait(lock, stop_token, [this] { return IsRunning() || exec_step; });
+            running_cv.wait(lock, stop_token, [this] { return IsRunning(); });
         }
     }
 
diff --git a/src/yuzu/bootmanager.h b/src/yuzu/bootmanager.h
index 841816564..87c559e7a 100644
--- a/src/yuzu/bootmanager.h
+++ b/src/yuzu/bootmanager.h
@@ -54,15 +54,6 @@ public:
      */
     void run() override;
 
-    /**
-     * Steps the emulation thread by a single CPU instruction (if the CPU is not already running)
-     * @note This function is thread-safe
-     */
-    void ExecStep() {
-        exec_step = true;
-        running_cv.notify_all();
-    }
-
     /**
      * Sets whether the emulation thread is running or not
      * @param running Boolean value, set the emulation thread to running if true
@@ -99,7 +90,6 @@ public:
     }
 
 private:
-    bool exec_step = false;
     bool running = false;
     std::stop_source stop_source;
     std::mutex running_mutex;
diff --git a/src/yuzu/configuration/config.cpp b/src/yuzu/configuration/config.cpp
index ac26b885b..583e9df24 100644
--- a/src/yuzu/configuration/config.cpp
+++ b/src/yuzu/configuration/config.cpp
@@ -525,6 +525,9 @@ void Config::ReadDebuggingValues() {
     // Intentionally not using the QT default setting as this is intended to be changed in the ini
     Settings::values.record_frame_times =
         qt_config->value(QStringLiteral("record_frame_times"), false).toBool();
+
+    ReadBasicSetting(Settings::values.use_gdbstub);
+    ReadBasicSetting(Settings::values.gdbstub_port);
     ReadBasicSetting(Settings::values.program_args);
     ReadBasicSetting(Settings::values.dump_exefs);
     ReadBasicSetting(Settings::values.dump_nso);
@@ -1095,6 +1098,8 @@ void Config::SaveDebuggingValues() {
 
     // Intentionally not using the QT default setting as this is intended to be changed in the ini
     qt_config->setValue(QStringLiteral("record_frame_times"), Settings::values.record_frame_times);
+    WriteBasicSetting(Settings::values.use_gdbstub);
+    WriteBasicSetting(Settings::values.gdbstub_port);
     WriteBasicSetting(Settings::values.program_args);
     WriteBasicSetting(Settings::values.dump_exefs);
     WriteBasicSetting(Settings::values.dump_nso);
diff --git a/src/yuzu/configuration/configure_debug.cpp b/src/yuzu/configuration/configure_debug.cpp
index d6e8b5ead..343d2aee1 100644
--- a/src/yuzu/configuration/configure_debug.cpp
+++ b/src/yuzu/configuration/configure_debug.cpp
@@ -24,13 +24,18 @@ ConfigureDebug::ConfigureDebug(const Core::System& system_, QWidget* parent)
             QString::fromStdString(Common::FS::GetYuzuPathString(Common::FS::YuzuPath::LogDir));
         QDesktopServices::openUrl(QUrl::fromLocalFile(path));
     });
+
+    connect(ui->toggle_gdbstub, &QCheckBox::toggled,
+            [&]() { ui->gdbport_spinbox->setEnabled(ui->toggle_gdbstub->isChecked()); });
 }
 
 ConfigureDebug::~ConfigureDebug() = default;
 
 void ConfigureDebug::SetConfiguration() {
     const bool runtime_lock = !system.IsPoweredOn();
-
+    ui->toggle_gdbstub->setChecked(Settings::values.use_gdbstub.GetValue());
+    ui->gdbport_spinbox->setEnabled(Settings::values.use_gdbstub.GetValue());
+    ui->gdbport_spinbox->setValue(Settings::values.gdbstub_port.GetValue());
     ui->toggle_console->setEnabled(runtime_lock);
     ui->toggle_console->setChecked(UISettings::values.show_console.GetValue());
     ui->log_filter_edit->setText(QString::fromStdString(Settings::values.log_filter.GetValue()));
@@ -71,6 +76,8 @@ void ConfigureDebug::SetConfiguration() {
 }
 
 void ConfigureDebug::ApplyConfiguration() {
+    Settings::values.use_gdbstub = ui->toggle_gdbstub->isChecked();
+    Settings::values.gdbstub_port = ui->gdbport_spinbox->value();
     UISettings::values.show_console = ui->toggle_console->isChecked();
     Settings::values.log_filter = ui->log_filter_edit->text().toStdString();
     Settings::values.program_args = ui->homebrew_args_edit->text().toStdString();
diff --git a/src/yuzu/configuration/configure_debug.ui b/src/yuzu/configuration/configure_debug.ui
index 863a3fd57..1152fa6c6 100644
--- a/src/yuzu/configuration/configure_debug.ui
+++ b/src/yuzu/configuration/configure_debug.ui
@@ -3,6 +3,60 @@
  <class>ConfigureDebug</class>
  <widget class="QWidget" name="ConfigureDebug">
   <layout class="QVBoxLayout" name="verticalLayout_1">
+    <item>
+      <layout class="QVBoxLayout" name="verticalLayout_2">
+       <item>
+        <widget class="QGroupBox" name="groupBox">
+         <property name="title">
+          <string>Debugger</string>
+         </property>
+         <layout class="QVBoxLayout" name="verticalLayout_3">
+          <item>
+           <layout class="QHBoxLayout" name="horizontalLayout_11">
+            <item>
+             <widget class="QCheckBox" name="toggle_gdbstub">
+              <property name="text">
+               <string>Enable GDB Stub</string>
+              </property>
+             </widget>
+            </item>
+            <item>
+             <spacer name="horizontalSpacer">
+              <property name="orientation">
+               <enum>Qt::Horizontal</enum>
+              </property>
+              <property name="sizeHint" stdset="0">
+               <size>
+                <width>40</width>
+                <height>20</height>
+               </size>
+              </property>
+             </spacer>
+            </item>
+            <item>
+             <widget class="QLabel" name="label_11">
+              <property name="text">
+               <string>Port:</string>
+              </property>
+             </widget>
+            </item>
+            <item>
+             <widget class="QSpinBox" name="gdbport_spinbox">
+              <property name="minimum">
+                <number>1024</number>
+              </property>
+              <property name="maximum">
+               <number>65535</number>
+              </property>
+             </widget>
+            </item>
+           </layout>
+          </item>
+         </layout>
+        </widget>
+       </item>
+      </layout>
+     </item>
    <item>
     <widget class="QGroupBox" name="groupBox_2">
      <property name="title">
diff --git a/src/yuzu/main.cpp b/src/yuzu/main.cpp
index f4a9a7171..e14e8333c 100644
--- a/src/yuzu/main.cpp
+++ b/src/yuzu/main.cpp
@@ -3152,7 +3152,7 @@ void GMainWindow::OnTasStateChanged() {
 }
 
 void GMainWindow::UpdateStatusBar() {
-    if (emu_thread == nullptr) {
+    if (emu_thread == nullptr || !system->IsPoweredOn()) {
         status_bar_update_timer.stop();
         return;
     }