pkg-config & CMake Package Config: A Comprehensive Guide¶
A complete reference for shipping installable C libraries that consumers
can find with both pkg-config and CMake's find_package.
Why Both?¶
Two different toolchain ecosystems consume installed C libraries:
| Tool | Used by |
|---|---|
pkg-config |
Makefiles, Meson, Python cffi/ctypes, autoconf, shell scripts |
| CMake config | Any project using find_package(MyLib REQUIRED) |
They are independent systems. Supporting both takes about 30 extra lines of CMake and two small template files — always do both.
File Layout¶
myproject/
├── CMakeLists.txt
└── cmake/
├── mylib.pc.in # pkg-config template
└── mylibConfig.cmake.in # CMake find_package template
Keep the templates in cmake/ next to the CMakeLists.txt that references
them. If your project uses subdirectories (e.g. a c/ subdirectory for the
library), put cmake/ there — CMake resolves the path relative to the
CMakeLists.txt that calls configure_file.
In a jm project these are jm's files. cmake/<pkg>.pc.in and
cmake/<pkg>-config.cmake.in are re-rendered whole by jm apply while their
first line is # jm:generated (_createonly.RULES; deleting that line makes
the template yours), and so is the root CMakeLists.txt from
# ── Install down to # ── End install (gh-1589) — put install rules of your
own below that block. A project scaffolded before gh-1589 has templates
without the token; jm status reports them, and jm adopt --packaging hands
them to jm.
The pkg-config Template (mylib.pc.in)¶
prefix=@PC_PREFIX@
exec_prefix=${prefix}
libdir=@PC_LIBDIR@
includedir=@PC_INCLUDEDIR@
Name: mylib
Description: @PROJECT_DESCRIPTION@
URL: @PROJECT_HOMEPAGE_URL@
Version: @PROJECT_VERSION@
Requires: libzmq
Requires.private: libfftw3
Libs: -L${libdir} -lmylib
Libs.private: -lm -lpthread
Cflags: -I${includedir}
Anatomy¶
prefix— the absolute prefix the files were INSTALLED under, written at install time.configure_fileleaves a marker (prefix=%JM_INSTALL_PREFIX%), and aninstall(CODE)declared before theinstall(FILES)replaces it with${CMAKE_INSTALL_PREFIX}as the install step sees it, socmake --install --prefixis honoured.DESTDIRis not part of it: a staged tree names its real target. An absolute prefix is also what lets pkg-config drop-I/-Lfor its system dirs under/usr, which it does only for a literal path. A${pcfiledir}-relative prefix defeats that, so don't use one. Relocation belongs to the consumer:pkg-config --define-prefixfor a moved tree,PKG_CONFIG_SYSROOT_DIRfor a staged one (gh-1582).libdir/includedir—${exec_prefix}/@CMAKE_INSTALL_LIBDIR@when that is relative, and the path itself when it is absolute.GNUInstallDirsmay be given absolute paths (Nix and Guix do), and the naive form then comes out as${exec_prefix}//nix/store/.../lib.Requires— public dependencies: consumers need them at link time.Requires.private— private dependencies: only needed when linking statically against your library. Omit fromRequiresto keep consumer link lines clean.Libs.private— same idea for-lflags.-lmand-lpthreadusually belong here, not inLibs-- unless a public header calls into them inline. jm's headers do (step()isstatic inline), so jm puts-lminLibs(gh-1452).
What jm generates. A manifest dependency with a .pc (a pkg_modules
entry, or a find_packages entry naming its pkg_config) goes in
Requires.private — its Cflags always apply (a jm header may include the
dependency's), its Libs only under --static. One without a .pc goes in
Libs.private, as the libs_private its entry states. The project's
public_link_libs and public_defines — what the installed headers need of
every consumer — go on Libs: and Cflags: (gh-1573, gh-1576, gh-1599; the
mapping is in _apply.py).
Common Pitfalls¶
Using absolute paths directly.
# WRONG — not relocatable
libdir=/usr/local/lib
includedir=/usr/local/include
# RIGHT — the dirs follow the prefix, which is filled in at install
prefix=@PC_PREFIX@
libdir=${exec_prefix}/lib
includedir=${prefix}/include
Baking in the configure-time CMAKE_INSTALL_PREFIX.
cmake --install --prefix chooses the prefix at install time, so a
.pc written at configure time names a prefix where nothing was
installed. Write the prefix from an install(CODE).
A ${pcfiledir}-relative prefix. It survives a moved tree, but
pkg-config then cannot recognise /usr as a system prefix: every
consumer gets -I/usr/include, which breaks #include_next, and
-L/usr/lib ahead of its own -L. Moving a tree is what
pkg-config --define-prefix is for.
Writing an optional field that may be empty. URL: @PROJECT_HOMEPAGE_URL@
comes out as a bare URL: when project() has no HOMEPAGE_URL, and a slot
on a line of its own leaves a blank line, because configure_file ends every
output line with a newline. jm assembles the optional fields in CMake, each
ending its own line and only when set (JM_PC_EXTRA_FIELDS), and puts that
one slot at the start of the Version: line (gh-1582).
Over-populating Requires.
If your shared library links libfftw3 with PRIVATE visibility, the
symbol is already resolved inside your .so. Consumer executables do not
need -lfftw3 on their link line. Move it to Requires.private or omit
it entirely.
Missing Requires for truly public deps.
If a public header of yours #includes a header from another library,
that library belongs in Requires (or Cflags if header-only). The
consumer's compiler must find it.
The CMake Config Template (mylibConfig.cmake.in)¶
@PACKAGE_INIT@
include("${CMAKE_CURRENT_LIST_DIR}/mylibTargets.cmake")
check_required_components(mylib)
That is the complete correct minimal form. Do not add more unless you have public CMake dependencies (see below).
@PACKAGE_INIT@¶
This macro, provided by CMakePackageConfigHelpers, inserts the
set_and_check() and check_required_components() helpers and sets up
PACKAGE_PREFIX_DIR so relative paths work after install. Without it,
your config file will break whenever the install prefix changes.
Public CMake Dependencies¶
If your public headers expose types from another CMake package, consumers
need to find_package that dependency too. Add it explicitly:
@PACKAGE_INIT@
include(CMakeFindDependencyMacro)
find_dependency(ZeroMQ REQUIRED) # public dep — headers expose zmq types
find_dependency(FFTW3) # optional dep
include("${CMAKE_CURRENT_LIST_DIR}/mylibTargets.cmake")
check_required_components(mylib)
Use find_dependency (not find_package) inside config files — it
propagates REQUIRED/QUIET correctly to the caller.
Common Pitfalls¶
Using plain configure_file instead of configure_package_config_file.
# WRONG — paths baked in, not relocatable
configure_file(cmake/mylibConfig.cmake.in ...)
# RIGHT
configure_package_config_file(cmake/mylibConfig.cmake.in ...)
configure_package_config_file handles PACKAGE_PREFIX_DIR and the path
helper macros. Plain configure_file does string substitution only.
Not using imported targets (the old variables pattern).
# OUTDATED — avoid
set(MYLIB_INCLUDE_DIRS "${PACKAGE_PREFIX_DIR}/include")
set(MYLIB_LIBRARIES "${PACKAGE_PREFIX_DIR}/lib/libmylib.so")
# CORRECT — modern CMake, exported targets
include("${CMAKE_CURRENT_LIST_DIR}/mylibTargets.cmake")
# Consumer now uses: target_link_libraries(app PRIVATE mylib::mylib)
Imported targets carry include paths, compile definitions and transitive dependencies automatically. Consumers do not need to set anything manually.
CMakeLists.txt Wiring¶
Includes (top-level CMakeLists.txt)¶
GNUInstallDirs defines CMAKE_INSTALL_LIBDIR, CMAKE_INSTALL_INCLUDEDIR,
CMAKE_INSTALL_BINDIR etc. as relative paths that follow platform
conventions. Always include it before any install() call.
Build vs Install Include Paths¶
target_include_directories(mylib
PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>
)
The generator expressions give the build tree the source path and give
the installed package the install path. Without this, consumers who
call find_package get your source tree path baked into their builds —
a hard-to-debug failure on other machines.
Install Rules¶
# 1. Install the library and export the target
install(TARGETS mylib
EXPORT mylibTargets
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
)
# 2. Install public headers
install(FILES include/mylib.h
DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
)
# 3. Install the export set (the Targets.cmake file)
install(EXPORT mylibTargets
FILE mylibTargets.cmake
NAMESPACE mylib::
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/mylib
)
# 4. Generate and install the Config and ConfigVersion files
configure_package_config_file(
cmake/mylibConfig.cmake.in
${CMAKE_CURRENT_BINARY_DIR}/mylibConfig.cmake
INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/mylib
)
write_basic_package_version_file(
${CMAKE_CURRENT_BINARY_DIR}/mylibConfigVersion.cmake
VERSION ${PROJECT_VERSION}
COMPATIBILITY SameMajorVersion
)
install(FILES
${CMAKE_CURRENT_BINARY_DIR}/mylibConfig.cmake
${CMAKE_CURRENT_BINARY_DIR}/mylibConfigVersion.cmake
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/mylib
)
# 5. Generate the pkg-config file, and write its prefix at INSTALL time
# (the prefix `cmake --install --prefix` chose; not DESTDIR)
set(PC_PREFIX "%INSTALL_PREFIX%")
set(PC_LIBDIR "\${exec_prefix}/${CMAKE_INSTALL_LIBDIR}")
set(PC_INCLUDEDIR "\${prefix}/${CMAKE_INSTALL_INCLUDEDIR}")
configure_file(cmake/mylib.pc.in mylib.pc.configured @ONLY)
install(CODE "file(READ \"${CMAKE_CURRENT_BINARY_DIR}/mylib.pc.configured\" pc)
string(REPLACE %INSTALL_PREFIX% \"\${CMAKE_INSTALL_PREFIX}\" pc \"\${pc}\")
file(WRITE \"${CMAKE_CURRENT_BINARY_DIR}/mylib.pc\" \"\${pc}\")")
install(FILES ${CMAKE_CURRENT_BINARY_DIR}/mylib.pc
DESTINATION ${CMAKE_INSTALL_LIBDIR}/pkgconfig
)
jm's root template differs from this sample in two ways. It installs the
whole header tree, install(DIRECTORY native/inc/ ...), with a
PATTERN "pyex_common.h" EXCLUDE so the Python-only header stays behind. And
it tags every rule with an install component (gh-1601): the shared library a
program loads is COMPONENT runtime; everything a build needs -- headers, the
static library, the unversioned lib<pkg>.so link (NAMELINK_COMPONENT dev),
the CMake package and the .pc -- is dev. cmake --install --component
with runtime or dev installs one half, which is what a distribution's
split packages are built from; a plain cmake --install installs both.
Version Compatibility Modes¶
write_basic_package_version_file takes a COMPATIBILITY argument:
| Mode | Meaning |
|---|---|
SameMajorVersion |
1.x satisfies requests for 1.y (safe default for semver) |
SameMinorVersion |
1.2.x satisfies 1.2.y (stricter, use for unstable APIs) |
AnyNewerVersion |
2.0 satisfies a request for 1.0 (dangerous, avoid) |
ExactVersion |
Only exact match (too strict for most use) |
SameMajorVersion is the right default for a semver library at 1.0 or later.
Under 0.x semver lets any minor release break, so there it is
SameMinorVersion. jm's root template picks by PROJECT_VERSION_MAJOR and
sets the shared library's SOVERSION by the same rule (0.y, then x), so
the soname and find_package agree about what is compatible (gh-1582).
NAMESPACE Convention¶
Always pass NAMESPACE mylib:: to install(EXPORT ...). This means
consumers write:
The double-colon makes it clear the target is an imported CMake target (not a raw library name), and prevents name collisions across packages.
What Gets Installed Where¶
A jm project <pkg> with one object <comp>, after
cmake --install build --prefix <prefix> (on a configure whose
CMAKE_INSTALL_LIBDIR is lib; see the Debian note below):
<prefix>/
├── include/<pkg>/
│ ├── <pkg>.h # the umbrella header
│ ├── clib_common.h
│ └── <comp>/<comp>_core.h
└── lib/
├── lib<pkg>.so -> lib<pkg>.so.0.1 # the namelink (dev)
├── lib<pkg>.so.0.1 -> lib<pkg>.so.0.1.0
├── lib<pkg>.so.0.1.0
├── lib<pkg>.a
├── cmake/<pkg>/
│ ├── <pkg>-config.cmake
│ ├── <pkg>-config-version.cmake
│ ├── <pkg>-targets.cmake
│ └── <pkg>-targets-release.cmake
└── pkgconfig/
└── <pkg>.pc # Libs: -L${libdir} -l<pkg> -lm
The 0.1 in the library's names is the soname rule above, for a project at
version 0.1.0.
Verifying the Install¶
Always write a post-install smoke test. Minimum checks:
#!/usr/bin/env bash
set -euo pipefail
PREFIX="${1:-/usr/local}"
export PKG_CONFIG_PATH="${PREFIX}/lib/pkgconfig:${PKG_CONFIG_PATH:-}"
# pkg-config
pkg-config --exists mylib
pkg-config --modversion mylib
pkg-config --cflags mylib
pkg-config --libs mylib
# headers and library files
test -f "${PREFIX}/include/mylib.h"
LIBDIR=$(pkg-config --variable=libdir mylib) # lib, lib64 or lib/<multiarch>
ls "${LIBDIR}/libmylib"*
# CMake config files
test -f "${PREFIX}/lib/cmake/mylib/mylibConfig.cmake"
test -f "${PREFIX}/lib/cmake/mylib/mylibTargets.cmake"
# compile smoke test
cat > /tmp/smoke.c <<'EOF'
#include <mylib.h>
int main(void) { return 0; }
EOF
gcc -o /tmp/smoke /tmp/smoke.c \
$(pkg-config --cflags --libs mylib) \
"-Wl,-rpath,${LIBDIR}"
/tmp/smoke # with no LD_LIBRARY_PATH: that would hide a missing rpath
Run it as bash test_install.sh "$HOME/.local" for non-root installs.
Platform Notes¶
Non-system Prefix (any platform)¶
pkg-config only scans standard system paths by default. For any prefix
other than /usr or /usr/local:
For CMake:
Or at install/configure time, pass --prefix or -DCMAKE_INSTALL_PREFIX.
Those two find the package; neither makes a program LOAD its shared library.
A program needs an rpath to the libraries it links (and each installed library
needs one to the libraries it links: jm installs its libraries with RUNPATH
$ORIGIN plus the directory of any dependency elsewhere). What a consumer
writes, per platform and prefix, is in
C library — Runtime loading.
Debian / Ubuntu (multiarch)¶
GNUInstallDirs sets CMAKE_INSTALL_LIBDIR to lib/<multiarch> (e.g.
lib/x86_64-linux-gnu) on Debian/Ubuntu when CMAKE_INSTALL_PREFIX is /usr
at configure time, and to lib otherwise. The value is fixed at configure
time, so cmake --install --prefix /usr does not change it: pass the prefix
when configuring if you want the multiarch dir. pkg-config scans that path
automatically. Do not hardcode lib; always use ${CMAKE_INSTALL_LIBDIR}.
The smoke test above should probe both paths:
macOS (Homebrew)¶
- Intel: prefix
/usr/local, Apple Silicon:/opt/homebrew - No multiarch —
CMAKE_INSTALL_LIBDIRis justlib - A jm library's install name is its absolute path (gh-1594), so a program
loads it from any prefix with no rpath and no
DYLD_LIBRARY_PATH - CMake's
find_packagesearches$(brew --prefix)/lib/cmakeautomatically
Quick Checklist¶
- [ ]
include(GNUInstallDirs)andinclude(CMakePackageConfigHelpers)at top - [ ]
$<BUILD_INTERFACE:...>/$<INSTALL_INTERFACE:...>on include paths - [ ]
install(TARGETS ... EXPORT ...)withLIBRARY,ARCHIVE,RUNTIME - [ ]
install(EXPORT ... NAMESPACE mylib:: ...)for the Targets file - [ ]
configure_package_config_file(notconfigure_file) for the Config - [ ]
write_basic_package_version_file:SameMinorVersionunder 0.x,SameMajorVersionfrom 1.0 - [ ]
VERSION/SOVERSIONon the shared library, by the same rule - [ ]
export(EXPORT ...)so the build tree is a package too - [ ]
.pcprefix: absolute, written at install time (honours--prefix, notDESTDIR); libdir/includedir follow it unless they were given as absolute paths - [ ] Private deps in
Requires.private/Libs.private, notRequires - [ ] Post-install smoke test that covers both pkg-config and CMake consumers
- [ ] Document the
PKG_CONFIG_PATHandCMAKE_PREFIX_PATHoverrides for non-system installs