roc/src/cli/linker.zig
2025-12-12 13:05:28 +11:00

511 lines
20 KiB
Zig

//! Zig wrapper for LLD (LLVM Linker) functionality.
//! Provides a high-level interface for linking object files into executables.
//! Supports ELF, COFF, MachO, and WebAssembly targets.
const std = @import("std");
const builtin = @import("builtin");
const Allocator = std.mem.Allocator;
const base = @import("base");
const Allocators = base.Allocators;
const libc_finder = @import("libc_finder.zig");
const RocTarget = @import("roc_target").RocTarget;
/// External C functions from zig_llvm.cpp - only available when LLVM is enabled
const llvm_available = if (@import("builtin").is_test) false else @import("config").llvm;
// External C functions from zig_llvm.cpp - only available when LLVM is enabled
const llvm_externs = if (llvm_available) struct {
extern fn ZigLLDLinkCOFF(argc: c_int, argv: [*]const [*:0]const u8, can_exit_early: bool, disable_output: bool) bool;
extern fn ZigLLDLinkELF(argc: c_int, argv: [*]const [*:0]const u8, can_exit_early: bool, disable_output: bool) bool;
extern fn ZigLLDLinkMachO(argc: c_int, argv: [*]const [*:0]const u8, can_exit_early: bool, disable_output: bool) bool;
extern fn ZigLLDLinkWasm(argc: c_int, argv: [*]const [*:0]const u8, can_exit_early: bool, disable_output: bool) bool;
} else struct {};
/// Supported target formats for linking
pub const TargetFormat = enum {
elf,
coff,
macho,
wasm,
/// Automatically detect target format based on the current system
pub fn detectFromSystem() TargetFormat {
return switch (builtin.target.os.tag) {
.windows => .coff,
.macos, .ios, .watchos, .tvos => .macho,
.freestanding => .wasm,
else => .elf,
};
}
/// Detect target format from OS tag
pub fn detectFromOs(os: std.Target.Os.Tag) TargetFormat {
return switch (os) {
.windows => .coff,
.macos, .ios, .watchos, .tvos => .macho,
.freestanding => .wasm,
else => .elf,
};
}
};
/// Target ABI for runtime-configurable linking
pub const TargetAbi = enum {
musl,
gnu,
/// Convert from RocTarget to TargetAbi
pub fn fromRocTarget(roc_target: RocTarget) TargetAbi {
return if (roc_target.isStatic()) .musl else .gnu;
}
};
/// Default WASM initial memory: 64MB
pub const DEFAULT_WASM_INITIAL_MEMORY: usize = 64 * 1024 * 1024;
/// Default WASM stack size: 8MB
pub const DEFAULT_WASM_STACK_SIZE: usize = 8 * 1024 * 1024;
/// Configuration for the linker, specifying target format, ABI, paths, and linking options.
pub const LinkConfig = struct {
/// Target format to use for linking
target_format: TargetFormat = TargetFormat.detectFromSystem(),
/// Target ABI - determines static vs dynamic linking strategy
target_abi: ?TargetAbi = null, // null means detect from system
/// Target OS tag - for cross-compilation support
target_os: ?std.Target.Os.Tag = null, // null means detect from system
/// Target CPU architecture - for cross-compilation support
target_arch: ?std.Target.Cpu.Arch = null, // null means detect from system
/// Output executable path
output_path: []const u8,
/// Input object files to link
object_files: []const []const u8,
/// Platform-provided files to link before object files (e.g., Scrt1.o, crti.o, host.o)
platform_files_pre: []const []const u8 = &.{},
/// Platform-provided files to link after object files (e.g., crtn.o)
platform_files_post: []const []const u8 = &.{},
/// Additional linker flags
extra_args: []const []const u8 = &.{},
/// Whether to allow LLD to exit early on errors
can_exit_early: bool = false,
/// Whether to disable linker output
disable_output: bool = false,
/// Initial memory size for WASM targets (bytes). This is the amount of linear memory
/// available to the WASM module at runtime. Must be a multiple of 64KB (WASM page size).
wasm_initial_memory: usize = DEFAULT_WASM_INITIAL_MEMORY,
/// Stack size for WASM targets (bytes). This is the amount of memory reserved for the
/// call stack within the WASM linear memory. Must be a multiple of 16 (stack alignment).
wasm_stack_size: usize = DEFAULT_WASM_STACK_SIZE,
};
/// Errors that can occur during linking
pub const LinkError = error{
LinkFailed,
OutOfMemory,
InvalidArguments,
LLVMNotAvailable,
WindowsSDKNotFound,
} || std.zig.system.DetectError;
/// Build the linker command arguments for the given configuration.
/// Returns the args array that would be passed to LLD.
/// This is used both by link() and formatLinkCommand().
fn buildLinkArgs(allocs: *Allocators, config: LinkConfig) LinkError!std.array_list.Managed([]const u8) {
// Use arena allocator for all temporary allocations
// Pre-allocate capacity to avoid reallocations (typical command has 20-40 args)
var args = std.array_list.Managed([]const u8).initCapacity(allocs.arena, 64) catch return LinkError.OutOfMemory;
// Add platform-specific linker name and arguments
// Use target OS if provided, otherwise fall back to host OS
const target_os = config.target_os orelse builtin.target.os.tag;
const target_arch = config.target_arch orelse builtin.target.cpu.arch;
switch (target_os) {
.macos => {
// Add linker name for macOS
try args.append("ld64.lld");
// Add output argument
try args.append("-o");
try args.append(config.output_path);
// Suppress LLD warnings
try args.append("-w");
// Add architecture flag
try args.append("-arch");
switch (target_arch) {
.aarch64 => try args.append("arm64"),
.x86_64 => try args.append("x86_64"),
else => try args.append("arm64"), // default to arm64
}
// Add platform version - use a conservative minimum that works across macOS versions
try args.append("-platform_version");
try args.append("macos");
try args.append("13.0"); // minimum deployment target
try args.append("13.0"); // SDK version
// Add SDK path
try args.append("-syslibroot");
try args.append("/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk");
// Link against system libraries on macOS
try args.append("-lSystem");
},
.linux => {
// Add linker name for Linux
try args.append("ld.lld");
// Add output argument
try args.append("-o");
try args.append(config.output_path);
// Prevent hidden linker behaviour -- only explicit platfor mdependencies
try args.append("-nostdlib");
// Remove unused sections to reduce binary size
try args.append("--gc-sections");
// TODO make the confirugable instead of using comments
// Suppress linker warnings
try args.append("-w");
// Verbose linker for debugging (uncomment as needed)
// try args.append("--verbose");
// try args.append("--print-map");
// try args.append("--error-limit=0");
// Determine target ABI
const target_abi = config.target_abi orelse if (builtin.target.abi == .musl) TargetAbi.musl else TargetAbi.gnu;
switch (target_abi) {
.musl => {
// Static musl linking
try args.append("-static");
},
.gnu => {
// Dynamic GNU linking - dynamic linker path is handled by caller
// for cross-compilation. Only detect locally for native builds
if (config.extra_args.len == 0) {
// Native build - try to detect dynamic linker
if (libc_finder.findLibc(allocs)) |libc_info| {
// We need to copy the path since args holds references
try args.append("-dynamic-linker");
try args.append(libc_info.dynamic_linker);
} else |err| {
// Fallback to hardcoded path based on architecture
std.log.warn("Failed to detect libc: {}, using fallback", .{err});
try args.append("-dynamic-linker");
const fallback_ld = switch (builtin.target.cpu.arch) {
.x86_64 => "/lib64/ld-linux-x86-64.so.2",
.aarch64 => "/lib/ld-linux-aarch64.so.1",
.x86 => "/lib/ld-linux.so.2",
else => "/lib/ld-linux.so.2",
};
try args.append(fallback_ld);
}
}
// Otherwise, dynamic linker is set via extra_args from caller
},
}
},
.windows => {
// Add linker name for Windows COFF
try args.append("lld-link");
const query = std.Target.Query{
.cpu_arch = target_arch,
.os_tag = .windows,
.abi = .msvc,
.ofmt = .coff,
};
const target = try std.zig.system.resolveTargetQuery(query);
const native_libc = std.zig.LibCInstallation.findNative(.{
.allocator = allocs.arena,
.target = &target,
}) catch return error.WindowsSDKNotFound;
if (native_libc.crt_dir) |lib_dir| {
const lib_arg = try std.fmt.allocPrint(allocs.arena, "/libpath:{s}", .{lib_dir});
try args.append(lib_arg);
} else return error.WindowsSDKNotFound;
if (native_libc.msvc_lib_dir) |lib_dir| {
const lib_arg = try std.fmt.allocPrint(allocs.arena, "/libpath:{s}", .{lib_dir});
try args.append(lib_arg);
} else return error.WindowsSDKNotFound;
if (native_libc.kernel32_lib_dir) |lib_dir| {
const lib_arg = try std.fmt.allocPrint(allocs.arena, "/libpath:{s}", .{lib_dir});
try args.append(lib_arg);
} else return error.WindowsSDKNotFound;
// Add output argument using Windows style
const out_arg = try std.fmt.allocPrint(allocs.arena, "/out:{s}", .{config.output_path});
try args.append(out_arg);
// Add subsystem flag (console by default)
try args.append("/subsystem:console");
// Add machine type based on target architecture
switch (target_arch) {
.x86_64 => try args.append("/machine:x64"),
.x86 => try args.append("/machine:x86"),
.aarch64 => try args.append("/machine:arm64"),
else => try args.append("/machine:x64"), // default to x64
}
// These are part of the core Windows OS and are available on all Windows systems
try args.append("/defaultlib:kernel32");
try args.append("/defaultlib:ntdll");
try args.append("/defaultlib:msvcrt");
// Suppress warnings using Windows style
try args.append("/ignore:4217"); // Ignore locally defined symbol imported warnings
try args.append("/ignore:4049"); // Ignore locally defined symbol imported warnings
},
.freestanding => {
// WebAssembly linker (wasm-ld) for freestanding wasm32 target
try args.append("wasm-ld");
// Add output argument
try args.append("-o");
try args.append(config.output_path);
// Don't look for _start or _main entry point - we export specific functions
try args.append("--no-entry");
// Export all symbols (the Roc app exports its entrypoints)
try args.append("--export-all");
// Disable garbage collection to preserve host-defined exports (init, handleEvent, update)
// Without this, wasm-ld removes symbols that aren't referenced by the Roc app
try args.append("--no-gc-sections");
// Allow undefined symbols (imports from host environment)
try args.append("--allow-undefined");
// Set initial memory size (configurable, default 64MB)
// Must be a multiple of 64KB (WASM page size)
const initial_memory_str = std.fmt.allocPrint(allocs.arena, "--initial-memory={d}", .{config.wasm_initial_memory}) catch return LinkError.OutOfMemory;
try args.append(initial_memory_str);
// Set stack size (configurable, default 8MB)
// Must be a multiple of 16 (stack alignment)
const stack_size_str = std.fmt.allocPrint(allocs.arena, "stack-size={d}", .{config.wasm_stack_size}) catch return LinkError.OutOfMemory;
try args.append("-z");
try args.append(stack_size_str);
},
else => {
// Generic ELF linker
try args.append("ld.lld");
// Add output argument
try args.append("-o");
try args.append(config.output_path);
// Suppress LLD warnings
try args.append("-w");
},
}
// Add platform-provided files that come before object files
// Use --whole-archive to include all members from static libraries (e.g., libhost.a)
// This ensures host-exported functions like init, handleEvent, update are included
// even though they're not referenced by the Roc app's compiled code
if (config.platform_files_pre.len > 0) {
try args.append("--whole-archive");
for (config.platform_files_pre) |platform_file| {
try args.append(platform_file);
}
try args.append("--no-whole-archive");
}
// Add object files (Roc shim libraries - don't need --whole-archive)
for (config.object_files) |obj_file| {
try args.append(obj_file);
}
// Add platform-provided files that come after object files
// Also use --whole-archive in case there are static libs here too
if (config.platform_files_post.len > 0) {
try args.append("--whole-archive");
for (config.platform_files_post) |platform_file| {
try args.append(platform_file);
}
try args.append("--no-whole-archive");
}
// Add any extra arguments
for (config.extra_args) |extra_arg| {
try args.append(extra_arg);
}
return args;
}
/// Link object files into an executable using LLD
pub fn link(allocs: *Allocators, config: LinkConfig) LinkError!void {
// Check if LLVM is available at compile time
if (comptime !llvm_available) {
return LinkError.LLVMNotAvailable;
}
const args = try buildLinkArgs(allocs, config);
// Debug: Print the linker command
std.log.debug("Linker command:", .{});
for (args.items) |arg| {
std.log.debug(" {s}", .{arg});
}
// Convert to null-terminated strings for C API
// Arena allocator will clean up all these temporary allocations
var c_args = allocs.arena.alloc([*:0]const u8, args.items.len) catch return LinkError.OutOfMemory;
for (args.items, 0..) |arg, i| {
c_args[i] = (allocs.arena.dupeZ(u8, arg) catch return LinkError.OutOfMemory).ptr;
}
// Call appropriate LLD function based on target format
const success = switch (config.target_format) {
.elf => llvm_externs.ZigLLDLinkELF(
@intCast(c_args.len),
c_args.ptr,
config.can_exit_early,
config.disable_output,
),
.coff => llvm_externs.ZigLLDLinkCOFF(
@intCast(c_args.len),
c_args.ptr,
config.can_exit_early,
config.disable_output,
),
.macho => llvm_externs.ZigLLDLinkMachO(
@intCast(c_args.len),
c_args.ptr,
config.can_exit_early,
config.disable_output,
),
.wasm => llvm_externs.ZigLLDLinkWasm(
@intCast(c_args.len),
c_args.ptr,
config.can_exit_early,
config.disable_output,
),
};
if (!success) {
return LinkError.LinkFailed;
}
}
/// Format link configuration as a shell command string for manual reproduction.
/// Useful for debugging linking issues by allowing users to run the linker manually.
pub fn formatLinkCommand(allocs: *Allocators, config: LinkConfig) LinkError![]const u8 {
const args = try buildLinkArgs(allocs, config);
// Join args with spaces, quoting paths that contain spaces or special chars
var result = std.array_list.Managed(u8).init(allocs.arena);
for (args.items, 0..) |arg, i| {
if (i > 0) result.append(' ') catch return LinkError.OutOfMemory;
// Quote if contains spaces or shell metacharacters
const needs_quoting = std.mem.indexOfAny(u8, arg, " \t'\"\\$`") != null;
if (needs_quoting) {
result.append('\'') catch return LinkError.OutOfMemory;
// Escape single quotes within the string
for (arg) |c| {
if (c == '\'') {
result.appendSlice("'\\''") catch return LinkError.OutOfMemory;
} else {
result.append(c) catch return LinkError.OutOfMemory;
}
}
result.append('\'') catch return LinkError.OutOfMemory;
} else {
result.appendSlice(arg) catch return LinkError.OutOfMemory;
}
}
return result.toOwnedSlice() catch return LinkError.OutOfMemory;
}
/// Convenience function to link two object files into an executable
pub fn linkTwoObjects(allocs: *Allocators, obj1: []const u8, obj2: []const u8, output: []const u8) LinkError!void {
if (comptime !llvm_available) {
return LinkError.LLVMNotAvailable;
}
const config = LinkConfig{
.output_path = output,
.object_files = &.{ obj1, obj2 },
};
return link(allocs, config);
}
/// Convenience function to link multiple object files into an executable
pub fn linkObjects(allocs: *Allocators, object_files: []const []const u8, output: []const u8) LinkError!void {
if (comptime !llvm_available) {
return LinkError.LLVMNotAvailable;
}
const config = LinkConfig{
.output_path = output,
.object_files = object_files,
};
return link(allocs, config);
}
test "link config creation" {
const config = LinkConfig{
.output_path = "test_output",
.object_files = &.{ "file1.o", "file2.o" },
};
try std.testing.expect(config.target_format == TargetFormat.detectFromSystem());
try std.testing.expectEqualStrings("test_output", config.output_path);
try std.testing.expectEqual(@as(usize, 2), config.object_files.len);
try std.testing.expectEqual(@as(usize, 0), config.platform_files_pre.len);
try std.testing.expectEqual(@as(usize, 0), config.platform_files_post.len);
}
test "target format detection" {
const detected = TargetFormat.detectFromSystem();
// Should detect a valid format
switch (detected) {
.elf, .coff, .macho, .wasm => {},
}
}
test "link error when LLVM not available" {
if (comptime !llvm_available) {
var allocs: Allocators = undefined;
allocs.initInPlace(std.testing.allocator);
defer allocs.deinit();
const config = LinkConfig{
.output_path = "test_output",
.object_files = &.{ "file1.o", "file2.o" },
};
const result = link(&allocs, config);
try std.testing.expectError(LinkError.LLVMNotAvailable, result);
}
}