Windows libraries, Find modules, and TARGET_RUNTIME_DLLS (re-re-revisited)

The Windows DLL Question™ has come up numerous times before in one form or another, but it’s cast in a new light by $<TARGET_RUNTIME_DLLS>, so here’s a fresh take on it.

If you’re like me (and roughly 90% of all CMake users/developers out there are like me, from what I’ve been able to observe in public projects’ source trees), your approach to writing Find modules on Windows has probably been something like this:

  1. Use the same code on all three desktop platforms
  2. Let CMake discover .lib / .dll.a import libraries instead of actual DLLs, using find_library().
  3. End up creating your targets as UNKNOWN IMPORTED, because if you try to create SHARED IMPORTED library targets with only an import library it won’t work, but UNKNOWN IMPORTED works just fine so meh.
  4. Set the import library as the target’s IMPORTED_LOCATION since that seems to work fine.
  5. Call it a day, because hey — everything compiles.

That’s served us all for years (decades, really) so we’ve mostly just accepted it as the way CMake works on Windows.

But now along comes $<TARGET_RUNTIME_DLLS>. If you’ve tried to actually use it on Windows, you’ve probably discovered is that while all of your CONFIG-mode package dependencies’ DLLs are captured just fine, the generator expression will cheerfully ignore any targets created from a Find module that’s written like I describe above. …Which is probably most of them. (In my own library build, it was all of them, even the ones I didn’t write.)

For $<TARGET_RUNTIME_DLLS> to work, the IMPORTED library target has to be correctly defined as a SHARED library target, and it needs to have its IMPORTED_ properties set correctly: import lib path in IMPORTED_IMPLIB, DLL path in IMPORTED_LOCATION.

So, now I have this new module that uses DLLTOOL.EXE and its handy -I flag to get the name of an import library’s DLL, then looks it up using find_program(). (Simply because find_library() won’t match DLLs, and I wanted to look on the PATH. I could’ve used find_file() but I’m pretty sure I’d have to explicitly give it more paths to search.)

The macro takes one argument, the name of your already-configured CACHE variable <prefix>_IMPLIB. (Or <prefix>_IMPLIBS, it’s pluralization agnostic and will follow whichever form your input uses when naming its output variable.)

The variable whose name you pass to it should already contain a valid path for an import library. Typically that’s set by find_library(), even though we’ve all been treating them like runtime libraries (DLLs) when they are not.

Armed with find_library(<prefix>_IMPLIB ...) output, implib_to_dll(<prefix>_IMPLIB) will attempt to discover and automatically populate the corresponding CACHE variable <prefix>_LIBRARY with the path to the import lib’s associated runtime DLL.

With all of the correct cache variables set to the correct values, it’s now possible to properly configure SHARED IMPORTED library targets on Windows. $<TARGET_RUNTIME_DLLS> can then be used to discover and operate on the set of DLLs defined by those target(s).

Kind of a pain in the Find, and really does sort of feel like something CMake could be doing at-least-semi-automatically. But, at least for now it works.

Now I just have to rewrite all of my find modules to use it. Sigh.

Here’s my current module code, in case it helps anyone. It’ll almost certainly become part of the libopenshot repo eventually, but I wanted to get it out here now both so that anyone who needs can make use of it, and also so you can tease out the bugs it’s sure to have.

#[=======================================================================[.rst:
IMPLIB_UTILS
------------

Tools for CMake on WIN32 to associate IMPORTED_IMPLIB paths (as discovered
by the :command:`find_library` command) with their IMPORTED_LOCATION DLLs.

Writing Find modules that create ``SHARED IMPORTED`` targets with the
correct ``IMPORTED_IMPLIB`` and ``IMPORTED_LOCATION`` properties is a
requirement for ``$<TARGET_RUNTIME_DLLS>`` to work correctly. (Probably
``IMPORTED_RUNTIME_DEPENDENCIES`` as well.)

Macros Provided
^^^^^^^^^^^^^^^

Currently the only tool here is ``implib_to_dll``. It takes a single
argument, the __name__ (_not_ value!) of a prefixed ``<prefix>_IMPLIB``
variable (containing the path to a ``.lib`` or ``.dll.a`` import library).

``implib_to_dll`` will attempt to locate the corresponding ``.dll`` file
for that import library, and set the cache variable ``<prefix>_LIBRARY``
to its location.

``implib_to_dll`` relies on the ``dlltool.exe`` utility. The path can
be set by defining ``DLLTOOL_EXECUTABLE`` in the cache prior to
including this module, if it is not set implib_utils will attempt to locate
``dlltool.exe`` using ``find_program()``.

Revision history
^^^^^^^^^^^^^^^^
2021-10-14 - Initial version

Author: FeRD (Frank Dana) <ferdnyc@gmail.com>
License: CC0-1.0 (Creative Commons Universal Public Domain Dedication)
#]=======================================================================]
include_guard(DIRECTORY)

if (NOT WIN32)
  # Nothing to do here!
  return()
endif()

if (NOT DEFINED DLLTOOL_EXECUTABLE)
  find_program(DLLTOOL_EXECUTABLE
    NAMES dlltool dlltool.exe
    DOC "The path to the DLLTOOL utility"
  )
  if (DLLTOOL_EXECUTABLE STREQUAL "DLLTOOL_EXECUTABLE-NOTFOUND")
    message(WARNING "DLLTOOL not available, cannot continue")
    return()
  endif()
  message(DEBUG "Found dlltool at ${DLLTOOL_EXECUTABLE}")
endif()

#
### Macro: implib_to_dll
#
# (Win32 only)
# Uses dlltool.exe to find the name of the dll associated with the
# supplied import library.
macro(implib_to_dll _implib_var)
  set(_implib ${${_implib_var}})
  set(_library_var "${_implib_var}")
  # Automatically update the name, assuming it's in the correct format
  string(REGEX REPLACE
    [[_IMPLIBS$]] [[_LIBRARIES]]
    _library_var "${_library_var}")
  string(REGEX REPLACE
    [[_IMPLIB$]] [[_LIBRARY]]
    _library_var "${_library_var}")
  # We can't use the input variable name without blowing away the
  # previously-discovered contents, so that's a non-starter
  if ("${_implib_var}" STREQUAL "${_library_var}")
    message(ERROR "Name collision! You probably didn't pass"
    "implib_to_dll() a correctly-formatted variable name."
    "Only <prefix>_IMPLIB or <prefix>_IMPLIBS is supported.")
    return()
  endif()

  if(EXISTS "${_implib}")
    message(DEBUG "Looking up dll name for import library ${_implib}")

    # Check the directory where the import lib is found
    get_filename_component(_implib_dir ".." REALPATH
                           BASE_DIR "${_implib}")
    message(DEBUG "Checking import lib directory ${_implib_dir}")

    # Add a check in ../../bin/, relative to the import library
    get_filename_component(_bindir "../../bin" REALPATH
                           BASE_DIR "${_implib}")
    message(DEBUG "Also checking ${_bindir}")

    execute_process(COMMAND
      "${DLLTOOL_EXECUTABLE}" -I "${_implib}"
      OUTPUT_VARIABLE _dll_name
      OUTPUT_STRIP_TRAILING_WHITESPACE
    )
    message(DEBUG "DLLTOOL returned ${_dll_name}")

    find_program(${_library_var}
      NAMES ${_dll_name}
      HINTS
        ${_bindir}
        ${_implib_dir}
      PATHS
        ENV PATH
    )
    set(${_library_var} "${${_library_var}}" PARENT_SCOPE)
    message(DEBUG "Set ${_library_var} to ${${_library_var}}")
  endif()
endmacro()

And here’s an example of its use:

macro(find_ffmpeg_component _component _header)
  set(_library "lib${_component}")
  # Assume pkg_config check here first, for non-Windows
  find_path(FFmpeg_${_component}_INCLUDE_DIR ${_header}
    HINTS
      ${PC_${_component}_INCLUDEDIR}
      ${PC_${_component}_INCLUDE_DIRS}
    PATHS
      ENV FFMPEG_DIR
    PATH_SUFFIXES
      ffmpeg
    )

  # Windows is "special"
  if(WIN32)
    find_library(FFmpeg_${_component}_IMPLIB
      NAMES ${_library}
      PATHS
        ENV FFMPEG_DIR
      PATH_SUFFIXES
        ffmpeg
     )
     implib_to_dll(FFmpeg_${_component}_IMPLIB)
  else()
    find_library(FFmpeg_${_component}_LIBRARY
      NAMES ${_library}
      HINTS
        ${PC_${_component}_LIBDIR}
        ${PC_${_component}_LIBRARY_DIRS}
      PATHS
        ENV FFMPEG_DIR
     PATH_SUFFIXES
       ffmpeg
    )
  endif()

  set_component_found(${_component})

  mark_as_advanced(
    FFmpeg_${_component}_INCLUDE_DIR
    FFmpeg_${_component}_LIBRARY
    FFmpeg_${_component}_IMPLIB
  )
endmacro()

# And later...

foreach(_component ${FFmpeg_FIND_COMPONENTS})
  if(FFmpeg_${_component}_FOUND AND
     NOT TARGET FFmpeg::${_component})
    add_library(FFmpeg::${_component} SHARED IMPORTED)

    set_property(TARGET FFmpeg::${_component} PROPERTY
      INTERFACE_INCLUDE_DIRECTORIES
        "${FFmpeg_${_component}_INCLUDE_DIR}")

    if(WIN32)
      set_property(TARGET FFmpeg::${_component} PROPERTY
       IMPORTED_IMPLIB "${FFmpeg_${_component}_IMPLIB}")
    endif()

    set_property(TARGET FFmpeg::${_component} PROPERTY
      IMPORTED_LOCATION "${FFmpeg_${_component}_LIBRARY}")
  endif()
endforeach()

I was worried this was a bug, because "../../bin" looks wrong… but I just re-tested, and it’s right.

Because I’m passing a file path as BASE_DIR, "../bin" from C:\msys2\mingw64\lib\libavcodec.dll.a would produce C:\msys2\mingw64\lib\bin.

Also see https://gitlab.kitware.com/cmake/cmake/-/issues/22406

See also this MR.

So, there were a few bugs here.

This needs to be double-quoted:

And these too:

Not really a bug per se, but turns out also checking at least the SAME dir as the import library is a very good idea.

Beyond that, I was playing crazy fast-and-loose with my variable names in the examples and not following cmake_developer(7) § Standard Variable Names at all.

Now I use DLLTOOL_EXECUTABLE instead of DLLTOOL_COMMAND, and my find_foo() commands fill cache variables named blah_INCLUDE_DIR, blah_IMPLIB, and blah_LIBRARY. (The example skips over the step of populating the plural variable forms of those.)

Everything mentioned here has been corrected in the original post.