# CMake configuration for working with juliac-compiled libraries

**URL:** <https://discourse.julialang.org/t/cmake-configuration-for-working-with-juliac-compiled-libraries/133320>\
**Category:** General Usage\
**Tags:** juliac, cmake\
**Created:** [October 21, 2025, 7:17am UTC](https://discourse.julialang.org/t/cmake-configuration-for-working-with-juliac-compiled-libraries/133320 "2025-10-21T07:17:06Z")\
**Posts on this page:** 1\
**Page:** 1

<div class="post-metadata">

**Author:** ![xlxs4](https://sea2.discourse-cdn.com/julialang/user_avatar/discourse.julialang.org/xlxs4/32/223653_2.png) [@xlxs4](https://discourse.julialang.org/u/xlxs4)\
**Post date:** [October 21, 2025, 7:17am UTC](https://discourse.julialang.org/t/cmake-configuration-for-working-with-juliac-compiled-libraries/133320/1 "2025-10-21T07:17:06Z")

</div>

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
$ 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.:

```julia
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:

```julia
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](https://gist.github.com/ChrisRackauckas/181569d8c3ec892b29dda48359abf865)

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

```

In Odin:

```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 ---
}

```

```odin
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
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!
