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!