Using AI to Debug a Legacy UEFI Graphics Program: A Two-Round Troubleshooting Log
This article documents a two-round troubleshooting process for repairing a legacy UEFI graphics application with the help of an AI coding assistant. It explains how to recover from an immediate startup exit caused by stale Graphics Output Protocol mode information, how to diagnose a black screen by checking protocol handles, pixel format, stride, and write methods, and why Boot Services must not be used after ExitBootServices. It also covers building observability through serial logging, a second-round optimization checklist, measurable acceptance criteria, and common AI hallucinations in low-level firmware development.
1. Why Letting AI Take Over a Legacy Codebase Is a Real and Common Scenario
In real engineering work, developers often face not a greenfield project but an existing codebase that they did not write, whose original author may have left, and whose documentation may be incomplete. This kind of code may compile and look logically complete, but it fails at runtime. Module boundaries may appear reasonable, yet the lifecycle contracts between modules are not written down. The underlying platform, such as firmware, drivers, or bare metal, may also lack mature debugging tools.
If you are trying to figure out how to locate common errors in UEFI graphics programs, or how to prepare context for an AI when fixing legacy code, the value of a large language model programming assistant is not necessarily generating a complete program from a blank page. Its value lies in structured reading: quickly locating the entry point, main loop, and state transitions, and making implicit assumptions scattered across multiple files explicit. Treat it as a tireless junior pair programmer: it reads code quickly and is willing to produce checklists, but it still needs you to provide factual grounding and make the final decision.
This article uses the repair of a small UEFI graphical game, a Breakout-style game, as an example. The environment has no operating system, no standard input or output, and the only observable channel is the serial port.
2. Reproduction Environment and Prerequisites
Before making changes, fix the variables first. Otherwise, “it can run” is not reproducible.
- Firmware and emulator: OVMF, using EDK2 prebuilt OVMF.fd / OVMF_CODE.fd, running on QEMU/KVM.
- Program type: UEFI Application, PE32+, Subsystem = EFI_APPLICATION, image suffix .efi.
- Graphics prerequisite: the target platform must implement GOP, the Graphics Output Protocol. Use
LocateProtocol(&gEfiGraphicsOutputProtocolGuid, ...)to confirm availability. - Target resolution: first get it running at 640x480, then switch to 1280x720.
- Serial logs: add
-serial stdioto QEMU, and point ConOut to the serial port or enable DebugPort on the firmware side so that logs can be observed in real time. - Code baseline: record the git commit so that it is easier to bisect which change introduced the problem.
3. Task Breakdown: Two Rounds, Not One Large Refactor
- Round 1: Make the program come alive. The only goal is to move from “exits immediately / black screen” to “an interactive image appears on screen.” In this round, refactoring is forbidden. Renaming is forbidden. Changing module boundaries is forbidden. Only minimal defect fixes are allowed.
- Round 2: Refine and optimize. After the program is stable, handle frame rate jitter, input latency, memory growth, rendering tearing, magic numbers, and similar issues.
The engineering judgment behind this is simple: if the baseline does not hold, you cannot distinguish between improvement caused by a change and noise caused by a change. A one-time large refactor can make the AI modify dozens of places at once. If any one of them fails, you lose the ability to localize the problem.
4. Round 1: From Not Running to Stable Running
4.1 The First Step Is a Read-Only Review, Not Immediate Changes
The correct opening instruction is “read only, do not write”:
Read the entire project first, but do not modify any code yet:1. Explain the responsibility of each file and its call relationships by module;2. List all global state shared across modules and when it is initialized;3. Point out the 3 to 5 locations most likely to cause startup failure, and explain the reasoning;4. List all platform calls whose return values are not checked.
There are three key points here: explicitly forbid modification, require evidence for the judgment, and require a distinction between “facts” and “speculation.” Without the third point, the model can easily turn guesses into conclusions.
4.2 Feed Context in a Structured Way
The quality of information given to the AI directly determines the quality of the fix. Each feedback should include four kinds of content:
- Symptom: precisely describe what is observed.
- Code location: file name, function name, and line number.
- Observable evidence: raw serial log output, return status codes, and the last output before the crash.
- Constraints: which files must not be changed and which interfaces are fixed by the platform.
A bad example is: “The program crashed. Fix it.”
A good example is: “The program exits after frame 3. The last serial output is Enter GameLoop. Before exiting, SetMode returns EFI_SUCCESS, and there is no output afterward.”
4.3 Problem 1: The Program Exits Immediately After Startup
Symptom: the firmware loads the program, the screen has not refreshed yet, serial output reaches “entering the main loop,” and then the process ends directly with no error message.
Localization: ask the AI to add tiered logs along the critical path and narrow the search segment by segment. Eventually, the crash point is confirmed to be after the graphics output protocol mode-setting call.
Root cause: this is a typical lifecycle misuse. The memory of the information structure returned by QueryMode is owned by the firmware and is valid only at the time of the query. After SetMode switches the resolution, the firmware rebuilds the mode information, and the original pointer becomes invalid. The code caches the pointer as a global variable and reads it only after switching modes, which means it reads memory that has already been reclaimed.
/* Flawed code: long-term caching of QueryMode Info; the pointer is invalid after switching modes */EFI_GRAPHICS_MODE_INFO *Info = NULL;UINTN Size = 0;Status = Gop->QueryMode(Gop, TargetMode, &Size, &Info);Gop->SetMode(Gop, TargetMode);g_pLayers = (UINT32 *)(UINTN)Info->FrameBufferBase; /* Info may already be invalid */
Fix: call QueryMode again after switching modes. Read only scalars from the newly returned Info, then discard the pointer immediately. This does not conflict with the principle of keeping only scalar values: the pointer is used only to read scalars and is discarded afterward.
Status = Gop->SetMode(Gop, TargetMode);if (EFI_ERROR(Status)) {ERR("SetMode failed: %r", Status); /* Use the ERR macro, see Section 5; signature is consistent */return Status;}/* Call QueryMode again after switching modes. Read only scalars from the returned Info, then discard it. */UINTN Size = 0;EFI_GRAPHICS_MODE_INFO *Info = NULL;Status = Gop->QueryMode(Gop, Gop->Mode->Mode, &Size, &Info);if (EFI_ERROR(Status)) {ERR("QueryMode failed: %r", Status);return Status;}g_FbBase = (UINT32 *)(UINTN)Info->FrameBufferBase; /* Store the base address as a scalar */g_FbStride = Info->PixelsPerScanLine; /* Stride, in pixels */g_FbWidth = Info->HorizontalResolution;g_FbHeight = Info->VerticalResolution;/* Info is not used after this point. Never store it in a global variable or pass it across functions. */
General rule: pointers returned by platform APIs should be treated as valid only within the current call context unless the specification explicitly promises a longer lifetime. Caching such pointers in global variables is one of the most common sources of crashes in low-level development.
4.4 Problem 2: The Program Does Not Exit, but the Graphical Interface Does Not Appear
Symptom: serial logs advance normally, the main loop is running, but the screen remains black or still shows the firmware image. The troubleshooting order below is arranged from most likely to least likely:
| Check item | Possible cause | Evidence | Fix action |
|---|---|---|---|
| Protocol handle validity | Continuing to use an invalid handle after LocateProtocol fails | Whether the return value is checked | Check the return value and return early |
| Pixel format | 32-bit BGR, 24-bit, and 16-bit 565 have different write methods | Gop->Mode->Info->PixelFormat |
Branch by pixel format |
| Row stride | Using visible width as row width causes the whole image to skew | PixelsPerScanLine != HorizontalResolution |
Compute row start addresses using stride |
| Write method | Per-pixel scalar writes can be so slow at high resolution that it looks like no output is produced | The image does not appear for a long time | Use batch filling or row-pointer writes |
PutPixel example, with an important applicability boundary: the 32-bit version below applies only to a linear framebuffer with 32-bit BGR per pixel. 16-bit 565 requires quantizing each channel first; Alpha is meaningless in this format and the code must branch according to the actual PixelFormat.
/* Only applies to a linear framebuffer with 32-bit BGR per pixel */static VOID PutPixel32(UINT32 X, UINT32 Y, UINT32 Argb){if (X >= g_FbWidth || Y >= g_FbHeight) return; /* Bounds check */volatile UINT32 *Row =(volatile UINT32 *)((UINT8 *)g_FbBase + (UINTN)Y * g_FbStride * 4);Row[X] = Argb | 0xFF000000U; /* For 32-bit, set Alpha to fully opaque */}/* 16-bit 565: quantize each channel first, then combine */static VOID PutPixel565(UINT16 X, UINT16 Y, UINT8 R, UINT8 G, UINT8 B){if (X >= g_FbWidth || Y >= g_FbHeight) return;volatile UINT16 *Row =(volatile UINT16 *)((UINT8 *)g_FbBase + (UINTN)Y * g_FbStride * 2);Row[X] = (UINT16)(((R >> 3) << 11) | ((G >> 2) << 5) | (B >> 3));}
4.5 Problem 3: The Main Loop Calls Services That Should Not Be Called
When a demo evolves into a complete program, two kinds of misuse are almost inevitable:
- Calling Boot Services after exiting Boot Services. Once
ExitBootServicesis called, most memory allocation, event, and timing services, includinggBS->Stall, are no longer available. A graphics program usually needs to allocate all resources, such as buffers and input event handles, before entering the main loop. - Putting blocking waits into the rendering loop. Using a wait of a fixed duration to implement frame rate control causes input jitter.
Timing correction: after ExitBootServices, Boot Services timing interfaces can no longer be relied upon. On x86, read the TSC directly as a monotonic counter. The TSC is a count of CPU cycles. It must be calibrated using CPUID.0x15, or a known frequency, before it can be converted into milliseconds or frame time. Runtime Services’ GetNextMonotonicCount can also be used, but its resolution is platform-dependent and usually coarse.
/* Boot Services are unavailable after ExitBootServices. TSC must be calibrated into TicksPerSec. */static UINT64 TicksPerSec; /* Obtained from CPUID.0x15 or a known frequency */static UINT64 ReadTsc(VOID){UINT32 Lo, Hi;__asm__ __volatile__("rdtsc" : "=a"(Lo), "=d"(Hi));return ((UINT64)Hi << 32) | Lo;}/* Target: 60 FPS. Each logical frame is one frame-period count in TSC ticks. */#define FRAME_TICKS (TicksPerSec / 60U)UINT64 Now = ReadTsc();g_Accum += Now - g_Last;g_Last = Now;while (g_Accum >= FRAME_TICKS) {UpdateGame(); /* Fixed-step logic update */g_Accum -= FRAME_TICKS;}RenderGame(); /* Rendering follows the display rhythm; logic frame rate is decoupled from physical frame rate */
Irreversibility warning: once ExitBootServices is called, it cannot be rolled back, and Boot Services become broadly unavailable. Before debugging, dump serial logs, save necessary memory dumps, and keep a rollback-capable image so that the scene can be restored if the problem reproduces.
5. Key Insight: Observability Is the Prerequisite for AI Troubleshooting
Many people complain that AI is unreliable when modifying low-level code. The real reason is often not the model’s capability, but that the feedback loop is too long. In a firmware environment, one run may take tens of seconds and produce zero error messages. Both humans and AI are guessing blindly. The solution is to invest in observability first, then discuss fixes. Ask the AI to produce tiered, switchable log macros:
#define LOG_LEVEL 3 /* 0 silent / 1 error / 2 warning / 3 info / 4 per-frame */#define DBG(level, fmt, ...) \do { \if ((level) <= LOG_LEVEL) { \AsciiPrint("[%s:%d] " fmt "\r\n", \__func__, __LINE__, ##__VA_ARGS__); \} \} while (0)#define ERR(fmt, ...) DBG(1, "E " fmt, ##__VA_ARGS__) /* Fixed level=1 */#define INF(fmt, ...) DBG(3, "I " fmt, ##__VA_ARGS__)#define FRM(fmt, ...) DBG(4, "F " fmt, ##__VA_ARGS__) /* Enable only when tuning frame rate */
Dependency note: AsciiPrint comes from PrintLib in MdePkg, or it can be a custom implementation. Under the hood, output goes through SystemTable->ConOut or DebugPortLib. Under OVMF/QEMU, -serial stdio must be used and ConOut or the debug serial port must be connected correctly, otherwise the logs have no output path. The ERR macro used in the fix code above has the same signature as this macro: ERR is fixed at level=1 and takes only fmt plus variadic arguments.
With logging in place, add one more rule: change only one variable at a time. Let the AI propose only one hypothesis, change only one place, and run verification immediately after the change. When a change fails, the responsibility boundary is clear, and the model can continue reasoning from confirmed facts.
6. Round 2: Refinement and Optimization Checklist
After the program runs, the second round makes the code maintainable and stable. The following checklist can be handed to the AI item by item, with each item requiring the change location and the reason.
Rendering quality: use two off-screen buffers alternately, and switch the displayed base address at the appropriate time to eliminate tearing. Do not draw directly into the visible buffer. Use a fixed logic step and decouple physical frame rate from game speed. Redraw only dirty rectangles.
Memory and resources: dynamic allocation inside the main loop is forbidden. Firmware memory pool behavior is unpredictable, and allocating inside the loop can easily cause fragmentation and leaks. Allocate all buffers once during initialization. Define clearly who allocates each buffer, who frees it, and when it is freed.
Input handling: distinguish between a key-press edge and a key being held down. Implement debouncing and an initial trigger delay. Decouple input sampling frequency from logic frames to avoid abnormal movement at high frame rates.
Code quality: extract scattered numeric literals, such as ball speed, gravity, and paddle width, into named constants and centralize them in a configuration table. Converge state scattered across boolean variables into an explicit state machine, with transitions centralized at a single entry point. Give each module a one-sentence responsibility statement.
Acceptance criteria:
- Cold start to image display <= 3 seconds, with no black screen;- Frame rate: per-frame statistics over a 5-second sampling window; average >= 55 FPS, 1% low >= 45 FPS;- Memory: sample the free heap count every 10 seconds; no growth over a continuous 30-minute run;- Resolution test matrix: 640x480 / 800x600 / 1280x720, with no diagonal misalignment in any of them;- Collision: run 20 times with a fixed serve input, such as ball speed and initial angle; all bounce angles must fall within +/-60 degrees;- Holding a direction key for 1 second produces a displacement equal to 60 logical frames of uniform movement, with no acceleration;- No ERROR-level serial log output.
7. Three Types of AI Hallucinations to Watch For
In low-level and firmware development, model errors are harder to detect than in application-layer development because feedback is slow and failure symptoms are vague. Focus on verifying three types:
- Nonexistent interfaces or fields. The model may confidently provide an API that looks reasonable. All platform calls must be checked word by word against the official specification or header files for signatures and parameter order.
- Incorrect lifetime assumptions. As with the framebuffer case in this article, models tend to assume that once a pointer is obtained, it can be used forever. For any pointer saved across calls, always ask: until when is it valid?
- Assumed default values. Pixel format, byte order, whether Alpha is valid, and whether row stride equals width must not be assumed. Query them at runtime and add compatibility branches.
The defensive method is simple: require the AI to label the evidence source when giving a conclusion. Is it quoting a header definition it actually saw, or is it based on experience-based speculation? Distinguishing between these two filters out most rework.
8. Conclusion
When using an AI programming assistant to repair existing code, success does not depend on how fancy the prompt is. It depends on three things: cutting the task into verifiable granularity, building enough observability for the AI, and requiring evidence rather than guesses for any platform-level detail.
This process is not limited to firmware development. It also applies to any scenario with slow feedback, sparse failure information, and many platform constraints, such as drivers, kernel modules, embedded bare metal, compiler backends, and performance tuning. First perform a read-only review, then apply minimal fixes, then establish logs, and only then discuss optimization. When the environment itself does not give you feedback, your first job is not to make the AI smarter, but to make the environment start speaking.
