CMake configuration for working with juliac-compiled libraries

Yesterday I gave juliac a spin to compile Julia code to a library I can then use from other languages that are C ABI compatible. I wanted to share what I ended up with.

The project structure is very simple. I keep all of the juliac-related definitions in lib/ instead of src/:

$ tree SimCore
SimCore
├── CMakeLists.txt
├── Manifest.toml
├── Project.toml
├── README.md
├── build.sh
├── configure.sh
├── lib
│   └── lib.jl
├── main.c
└── src
    └── SimCore.jl

The library code can import the main module, e.g.:

module JuliaCLib

import SimCore

const T = Float64

Base.@ccallable function lib_find_interference(
        gs_ptr::Ptr{GroundStation}, gs_size::Csize_t,
        owned_sats_ptr::Ptr{Satellite}, owned_sats_size::Csize_t,
        other_sats_ptr::Ptr{Satellite}, other_sats_size::Csize_t,
    )::Cint
    vgs = unsafe_wrap(Array, gs_ptr, gs_size)
    v_owned_sats = unsafe_wrap(Array, owned_sats_ptr, owned_sats_size)
    v_other_sats = unsafe_wrap(Array, other_sats_ptr, other_sats_size)
    libconf = Config(1.0, 2, 0.1, vgs, v_owned_sats, v_other_sats)
    conf = SimCore.Config(libconf)
    Core.println(conf)
    return 0
end

To compile a library, you’ll need function definitions annotated with Base.@ccallable. The definition has to include the return type. There are a few different ways you can pass data through the ABI. For example:

Base.@ccallable function lib_foo(n_arr::Ptr{Cdouble}, size::Csize_t)
    vn = unsafe_wrap(Array, n_arr, size)
    # ...
end

struct Foo
    x::Float64
end

# You'll need a matching struct definition, e.g., in C:
# typedef struct {
#     double x;
# } Foo;

Base.@ccallable function lib_bar(x::Foo)
    # ...
end

Base.@ccallable function lib_baz(p::Ptr{Foo})
    # ...
    f = unsafe_load(p)
    # Change f ...
    unsafe_store!(p, f)
    # ...
end

Base.@ccallable function lib_baz(p::Base.RefValue{Foo})
    # modify directly
    p[].x = 42.0
    # ...
end

I’ve experimented with C and Odin.
In C, I just directly use extern. There are ways you can automatically generate headers from your Julia code, if you’re into that. You could additionally split them into private and public files, etc. Here’s a way to generate headers courtesy of Chris: Julia @ccallable C Header Generator - Automatically generate C header files from Julia functions marked with @ccallable

extern int lib_gf_visible(const double *, const double, const double,
                          const double, const double);

In Odin:

package sim_core

import "core:c"

foreign import libcore "../SimCore/build/lib/libcore.so"

// @(default_calling_convention = "c");
// @(link_prefix="lib_");
foreign libcore {
	gf_visible :: proc(r: [3]c.double, lat, lon, alt, el_min: c.double) -> c.int ---
}
package my_app

import "core:c"
import "core:fmt"

import "sim_core"

main :: proc() {
  // call with sim_core.gf_visible(...)
  // ...
}

… and so on, you get the idea.

Right then, on to the CMakeLists.txt:

cmake_minimum_required(VERSION 3.23)
project(SimCore C)

if(NOT (CMAKE_SYSTEM_NAME STREQUAL "Linux" OR APPLE))
    message(FATAL_ERROR "Unsupported platform: ${CMAKE_SYSTEM_NAME}. This project only supports Linux and macOS.")
endif()

include(GNUInstallDirs)

set(EXECUTABLE_NAME "itu")
set(JULIA_LIB_NAME "libcore")
set(JULIA_IMPORTED_TARGET_NAME "Julia::Core")

set(JULIA_BUNDLE_DIR "${CMAKE_BINARY_DIR}/3rd_party/libcore")

set(JULIA_IMPORTED_LOCATION "${JULIA_BUNDLE_DIR}/${CMAKE_INSTALL_LIBDIR}/libcore${CMAKE_SHARED_LIBRARY_SUFFIX}")

add_custom_target(JuliaLibBuilder ALL
        COMMAND juliac
                --output-lib "${JULIA_LIB_NAME}"
                --project "${PROJECT_SOURCE_DIR}"
                --compile-ccallable
                --bundle "${JULIA_BUNDLE_DIR}"
                --trim
                "${CMAKE_CURRENT_SOURCE_DIR}/lib/lib.jl"
        COMMENT "Compiling Julia source to a shared library..."
        VERBATIM
)

if(APPLE)
    add_custom_command(
        TARGET JuliaLibBuilder POST_BUILD
        COMMAND ${CMAKE_INSTALL_NAME_TOOL} -id "@rpath/libcore${CMAKE_SHARED_LIBRARY_SUFFIX}" "${JULIA_IMPORTED_LOCATION}"
        COMMAND /usr/bin/codesign --force --sign - "${JULIA_IMPORTED_LOCATION}"
        COMMENT "Setting install_name and re-signing libcore.dylib"
        VERBATIM
    )
endif()

install(DIRECTORY ${JULIA_BUNDLE_DIR}/${CMAKE_INSTALL_LIBDIR}/ DESTINATION ${CMAKE_INSTALL_LIBDIR})

add_library(${JULIA_IMPORTED_TARGET_NAME} SHARED IMPORTED GLOBAL)

set_target_properties(${JULIA_IMPORTED_TARGET_NAME}
        PROPERTIES
        IMPORTED_LOCATION ${JULIA_IMPORTED_LOCATION}
)
if(NOT APPLE)
    set_property(TARGET ${JULIA_IMPORTED_TARGET_NAME} APPEND PROPERTY IMPORTED_NO_SONAME TRUE)
endif()
add_dependencies(${JULIA_IMPORTED_TARGET_NAME} JuliaLibBuilder)

add_executable(${EXECUTABLE_NAME} main.c)
target_link_libraries(${EXECUTABLE_NAME} PRIVATE ${JULIA_IMPORTED_TARGET_NAME})

set_target_properties(${EXECUTABLE_NAME} PROPERTIES
    BUILD_RPATH "${JULIA_BUNDLE_DIR}/${CMAKE_INSTALL_LIBDIR}"
)

if (APPLE)
    set_target_properties(${EXECUTABLE_NAME} PROPERTIES
        INSTALL_RPATH "@executable_path/../${CMAKE_INSTALL_LIBDIR}"
    )
else ()
    set_target_properties(${EXECUTABLE_NAME} PROPERTIES
        INSTALL_RPATH "$ORIGIN/../${CMAKE_INSTALL_LIBDIR}"
    )
endif ()

install(TARGETS ${EXECUTABLE_NAME}
        RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})

You can pass --verbose to juliac to see the invoked commands. This CML file has some stuff specific to juliac, namely:

I’m using add_custom_target (you can split that into add_custom_command and add_custom_target) to also invoke juliac from the build configuration generated by CMake.

If you take a look at the commands run from JuliaC, the gcc command lacks -soname on Linux, and -install_name on MacOS, respectively. I think that’s bad practice, although I’m not really knowledgable in those domains. In any case, this won’t work as-is for relocatable builds.

The situation on Linux is straightforward enough. Only the executable is responsible for finding the libraries at runtime. Without a SONAME, I think it defaults to using the path as the library name. We use IMPORTED_NO_SONAME TRUE, and then set the rpath appropriately. Easy enough.

On MacOS, we have dyld and the .dylib files instead of .so. Instead of -soname, there’s -install_name. Also notice that instead of $ORIGIN there are other directives like @rpath, @loader_path, @executable_path. On this OS, every dynamic library has an install name, which identifies the library to the dynamic linker. If we don’t change juliac itself, we can use a tool aptly named install_name_tool to modify the library post-compilation. That also means we need to then re-do code signing, otherwise the OS will kill the process when you try to run the executable.

Hope this is useful!

3 Likes