PDB generated by lld-link doesn't point to correct entry point when debugged using Visual Studio

Before starting, please do note that my exact setup worked just fine few updates back (when I was still using LLVM bundled with Visual Studio 2022).

Currently:
I use LLVM installation downloaded from official Github releases (22.1.2 and not the one which comes with Visual Studio) and Visual Studio (tested on both 2022 and 2026) as a debugger.

I use a clang-cl command similar to:

clang-cl ...my_files.cpp... /Z7 /Od /MT -fuse-ld=lld-link /link /debug:full /incremental:no

And when I use Visual Studio to debug the generated binary (using F11 aka Step Into), it puts me in a wWinMainCRTStartup function (or something similar). The problem disappears when I use link.exe to link my binary. Please do note that I do native Win32 development, meaning I have wWinMain as my entry point function instead of regular main function that you see in C++.

Is this something expected? LLD claims to generate compatible PDB files, so I thought it must work, which it did, a few updates back, unfortunately I don’t know which exact version was that, and somewhere down the line, it borked my setup. And now I’m forced to use link.exe.

I tried almost all the permutations of flags like -gcodeview and other things like -fms-compatibility and several linker flags, updated and downgraded my Visual Studio versions but nothing worked at all!

So all I ask is this: how to make it so that Visual Studio puts me in the correct wWinMain function when I debug.

Do you have a small example where you experience the bug?

I tried the following:

// main.cpp
#include <Windows.h>

int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance,
                    PWSTR pCmdLine, int nCmdShow) {
  return 0;
}

Compiled with the flags you listed:

clang-cl main.cpp /Z7 /Od /MT -fuse-ld=lld-link /link /debug:full /incremental:no

Then I opened the executable in VisualStudio 2026 and hit F11 (Step Into). The debugger stopped at the wWinMain function.

If you have a small example, it might be worth opening an issue on GitHub.

Hello, I made changes to my codebase, and right now, it’s not in a build-able state, so please wait (and apologies for making you wait) till I get a functioning build.

Hey, my primary file is over 1000 lines long and I tried adding WINAPI before the function definition just to see if it works.

And it didn’t.

Also I looked into my file, and found nothing out of ordinary, typical Win32 C++ platform code. So could you be little specific on what should I share? Also, here’s my exact build step:

clang-cl ..\src\win32_main.cpp user32.lib gdi32.lib Winmm.lib -DSlow -DInternal -fuse-ld=lld-link /W4 -Wno-unused-function -Wno-unused-parameter -Wno-unused-variable -Wno-writable-strings /MT /showFilenames /Z7 /Od /Feapplication_win32.exe /link /DEBUG:FULL /INCREMENTAL:NO

And the function signature of my wWinMain function is exactly the same as yours, it’s contents should’t matter as far as debugging is concerned, right?

int WINAPI wWinMain(HINSTANCE Instance, HINSTANCE _, PWSTR CmdLine, int ShowCmd)

Try to reduce your file by removing unnecessary code and check if the bug still happens. Or the other way around, starting with an empty file and adding to it until you see the bug.

Ideally something similar to what I shared above – a small snippet with a command line to compile it where the problem occurs. You could also try the snippet I provided and see if you experience the bug, maybe I didn’t understand you correctly and missed it.

I can’t reproduce it with this command line either. FWIW, I had to remove the #include <Windows.h> because of the -DInternal.

@nothing_serious can you try @Nerixyz’s example above and check where the debugger stops for you in that one?

It could be that something changed between MSVC library versions as well.

@hansw2000 @Nerixyz
The bug doesn’t happen in 32-bit mode when linked by lld. And as you already know, it never happens when linked by link.exe

Interestingly though, I think that I found the offending piece of code:

#if SLOW_1
#define Assert(Expression) if(!(Expression)) { *(int *)0 = 0; }
#else
#define Assert(Expression)
#endif

This is little hack that I use to just pause the debugger (essentially). But the fix? Oh you might not guess it: I did try undefining my SLOW_1 macro to just see if it work (and it does!!!), but I took it a step further, and kept my SLOW_1 macro, and just commented out THIS ONE SINGLE LINE:

LRESULT CALLBACK
Win32MainWindowProc(HWND Window, UINT Message, WPARAM WParam, LPARAM LParam)
{

switch (Message)
{
case WM_SIZE:
{} break;
case WM_SYSKEYDOWN:
case WM_SYSKEYUP:
case WM_KEYDOWN:
case WM_KEYUP:
{
Assert(!1); /////// <<<<<<<<<<<<< THIS LINE
} break;
// ... closing statements
}

What the above code does, is that it checks if somehow windows ends up in it’s procedure function instead of passing through my own keyboard logic (which is why I want it to crash).

Finally: Reproduction Steps

// somefile.h
#if PROJECT_SLOW
#define Assert(Expression) if(!(Expression)) {  *(int *)0 = 0; }
#else
#define Assert(Expression)
#endif

And in win32_main.cpp file:

#ifndef UNICODE
#define UNICODE
#endif

#include <windows.h>
#include <stdio.h>
#include "somefile.h"

LRESULT CALLBACK WindowProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
    switch (uMsg)
    {
    case WM_DESTROY:
        PostQuitMessage(0);
        return 0;

    case WM_PAINT:
        {
            PAINTSTRUCT ps;
            HDC hdc = BeginPaint(hwnd, &ps);

            // All painting occurs here, between BeginPaint and EndPaint.

            FillRect(hdc, &ps.rcPaint, (HBRUSH) (COLOR_WINDOW+1));

            Assert(!0); ///////////////////////////// Offending Iine

            EndPaint(hwnd, &ps);
        }
        return 0;

    }
    return DefWindowProc(hwnd, uMsg, wParam, lParam);
}

///////// Standard Win32 stuff /////////

int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, PWSTR pCmdLine, int nCmdShow)
{
    // Register the window class.
    const wchar_t CLASS_NAME[]  = L"Sample Window Class";

    WNDCLASS wc = { };

    wc.lpfnWndProc   = WindowProc;
    wc.hInstance     = hInstance;
    wc.lpszClassName = CLASS_NAME;

    RegisterClass(&wc);

    // Create the window.

    HWND hwnd = CreateWindowEx(
        0,                              // Optional window styles.
        CLASS_NAME,                     // Window class
        L"Learn to Program Windows",    // Window text
        WS_OVERLAPPEDWINDOW,            // Window style

        // Size and position
        CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT,

        NULL,       // Parent window
        NULL,       // Menu
        hInstance,  // Instance handle
        NULL        // Additional application data
        );

    if (hwnd == NULL)
    {
        return 0;
    }

    ShowWindow(hwnd, nCmdShow);

    // Run the message loop.

    MSG msg = { };
    while (GetMessage(&msg, NULL, 0, 0) > 0)
    {
        TranslateMessage(&msg);
        DispatchMessage(&msg);
    }
    return 0;
}

Somehow, in debug builds, Windows ends up in CRTStartup function even if the condition of my Assert would never make it do a null dereference.

edit: forgot to add the command-line:

clang-cl ..\src\win32_main.cpp user32.lib gdi32.lib /DPROJECT_SLOW /Z7 /Od /MT -fuse-ld=lld

All code is compiled in x64

So a Github issue time?

Reduced it to

// win32_main.cpp
void __stdcall SomeOtherFunction() {} // comment out
int __stdcall wWinMain() {}
clang-cl win32_main.cpp /Z7 -fuse-ld=lld

Basically, if SomeOtherFunction is present (and not static), VS stops in wWinMainCRTStartup, otherwise it stops in wWinMain.

I’m not that familiar with LLD, but this looks like an issue to me.

Whoa! Thank you very much for sticking through!

And yeah, this is a bug, even if we didn’t know this, because LLD advertises full LINK.EXE compat. and the respective debug format.

But, could you explain me this: since my Assert was a macro, which evaluated to an expression, why was it caught between this?

I’m not sure. At least for me, editing it to Assert(!1) or commenting it out didn’t cause any difference to where VS stopped.
As a sidenote, your assert macro probably doesn’t do what you expect. Clang should warn you about it as well:

win32_main.cpp(24,5): warning: indirection of non-volatile null pointer will be deleted, not trap [-Wnull-dereference]
   24 |     Assert(!1); ///////////////////////////// Offending Iine
      |     ^~~~~~~~~~
.\somefile.h(5,5): note: expanded from macro 'Assert'
    5 |     *(int *)0 = 0;                                                             \
      |     ^~~~~~~~~
win32_main.cpp(24,5): note: consider using __builtin_trap() or qualifying pointer with 'volatile'
.\somefile.h(5,5): note: expanded from macro 'Assert'
    5 |     *(int *)0 = 0;                                                             \
      |     ^

You can use the suggested changes or __debugbreak() to break in the debugger.

This is getting weirder everytime I look at it!
You’re right, my Assert does nothing to change the outcome. But I swear I remember seeing Visual Studio end up in the correct main function, the first time I ran it!

Anyway, I ran the file (with bad code) in RemedyBG, just to get a second opinion… And RemedyBG always ends up in the correct file, 100% of the time. So I don’t know, I think Visual Studio has got a little stricter with how it handles PDBs or what? But then why link.exe runs fine with exact same code?

This should be reported as an issue anyway, what do you think?

Eh, It’s very much portable, for one, and I just use pragmas to shut up clang/msvc. I tend to stay away from compiler-specific intrinsics, and I don’t see a good reason to remove that now, because this simply doesn’t solve anything. Plus whatever it says in it’s warnings, is one thing, but for me, it just works, and mostly what that warning tells me if I’m not wrong is that “it’s not guaranteed to crash (since it’s an UB, compiler could just do anything about it), so if you wanna guarantee a crash, use this compiler intrinsic”

I’m fine with having UBs around in my debug builds.

This is such a great reproducer :slight_smile:

It seems the debugger will also stop in wWinMain if that’s defined first:

int __stdcall wWinMain() {}
void __stdcall SomeOtherFunction() {}

Regardless of linker, I wonder what mechanism the debugger uses to decide whether to break in wWinMainCRTStartup or wWinMain in the first place.

I first thought maybe this has something to do with Just My Code (JMC) debugging. On my machine, `C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\Common7\Packages\Debugger\Visualizers\default.natstepfilter` has

  <!-- CRT-->
  <Module Name="msvcr*.dll" />
  <Module Name="msvcp*.dll" />         
  <Module Name="vccorlib*.dll" />
  <Module Name="vcruntime*.dll" />
  <Module Name="ucrtbase*.dll" /> 
  <Module Name="ConcRT*.dll" />  
  <Module Name="clang_rt.asan*" />
  <File Name="*\vctools\crt\* "/>

which I think would make JMC skip CRT functions when stepping. wWinMainCRTStartup is defined in D:\a_work\1\s\src\vctools\crt\vcstartup\src\startup\exe_wwinmain.cpp(15) so it should be caught by the source file filter.

However, I tried disabling JMC in Debug → Options and Step Into still proceeds to wWinMain in that case, so JMC doesn’t seem to be a factor here.


Maybe the debugger just has a list of desirable entry point functions like main, wWinMain, etc., and in the “broken” case it fails to find that for some reason.

I’ve tried comparing the PDB files when linked with lld vs. link.exe, and I don’t see any obvious problem. The natural way to find wWinMainwould be to look it up in the Public Symbols stream, and it’s there in both cases.

I’m not really familiar with these details, but I’m curiously following the discussion… If it was said that this worked correctly with older versions of LLD, are we able to bisect and find which version broke/changed this case? That might offer some clues about what’s different/wrong.

I tried with clang/lld 17 which was the oldest I had lying around, and that reproduces the same problem at least.


This is interesting, because just changing the order should have very little effect on the PDB file: the function offsets will be different, and the symbols in the module stream will come in a different order, but that should be it really.

However, there’s also a difference in the publics stream. Both symbols are still there, but wWinMain’s S_PUB32 gets dumped a bit later in the “bad” case. And what’s really interesting is that something seems different with the hash table layout itself: llvm-pdbutil dump --publics --public-extras prints sym hash = 40216 instead of 40220 in the bad case, and num buckets = 10120 instead of 10124.

Since the set of public symbols is exactly the same, just encountered in a slightly different order, it seems suspicious that the number of buckets would be different.

Adding some dumping to GSIHashStreamBuilder::finalizeBuckets exposes the bug: we’re computing the wrong hash. This will fix it: [pdb] Fix public symbol hashing in GSIHashStreamBuilder::finalizeBuckets by zmodem · Pull Request #190133 · llvm/llvm-project · GitHub

Thanks Hans, marking this as solution for now. Will appreciate if you update here the release in which your patch is included.

The fix was merged to the 22.x branch, so it will first ship in LLVM 22.1.4 which I believe will be released in about two weeks.

And it’s out: Release LLVM 22.1.4 · llvm/llvm-project · GitHub